leaf_core/doc.rs
1//! The document model: a `twig::Editor` plus a byte-offset caret and selection.
2//!
3//! Where bough moves a selection through the *tree*, leaf moves a *caret*
4//! through the *characters* — a normal text editor's model — and expresses
5//! every mutation as one of twig's offset-addressed ops:
6//!
7//! - typing / delete → `edit_range(start, end, text)` (P0)
8//! - re-anchoring → the returned `Change` (P1)
9//! - cursor context → `node_at` / `ancestors_at` (P3)
10//! - the toolbar → `wrap_range`/`toggle_inline`/`set_block`,
11//! `toggle_block_container`/`insert_link` (P5)
12//!
13//! twig reparses after every edit and leaves everything outside the splice
14//! byte-for-byte untouched, so the document stays a live, navigable AST while
15//! you type into it.
16
17// `PathBuf` names the `path` field and the untitled marker on every build;
18// `Path` is only touched by the filesystem I/O gated behind the `fs` feature.
19// The docs in this file lay their `- key → meaning` lists out in aligned
20// columns, which puts a continuation line further right than clippy's
21// list-indent rule likes. A lazy continuation renders as the same paragraph
22// either way, and the alignment is what makes those tables readable, so the
23// layout wins over the lint.
24#![allow(clippy::doc_overindented_list_items)]
25
26use std::collections::HashMap;
27use std::ops::Range;
28#[cfg(feature = "fs")]
29use std::path::Path;
30use std::path::PathBuf;
31
32#[cfg(feature = "fs")]
33use anyhow::Context;
34use anyhow::{Result, anyhow};
35use twig::{
36 Alignment, BlockContainerKind, BlockKind, Change, Editor, FlatNode, Format, Gesture,
37 InlineKind, Kind, MarkdownExtensions, NodeId, QueryMatch,
38};
39use unicode_segmentation::GraphemeCursor;
40
41use crate::counts::{self, TextCounts};
42use crate::html;
43use crate::source::{self, SourceMap};
44use crate::style::{Align, FontFace, FontSize, LineHeight, MarkColor, TextColor};
45use crate::wysiwyg::{self, MediaKind, MediaStop, Reveal, VisualMap};
46
47/// Which view the body shows.
48#[derive(Clone, Copy, PartialEq, Eq, Debug)]
49pub enum View {
50 /// The raw document with a caret in source bytes.
51 Source,
52 /// Markup resolved to real styles, caret riding the rendered glyphs.
53 Wysiwyg,
54}
55
56/// How much of the source markup the WYSIWYG view exposes — a per-editor
57/// preference, orthogonal to [`View`]. Named for markup rather than for Markdown
58/// because leaf is grammar-agnostic: twig hands it Djot, HTML and XML on the same
59/// terms, and every rung below is about *delimiters*, whatever grammar spells
60/// them. The examples are Markdown only because that is what most documents are.
61///
62/// A single ladder over two underlying axes, because only three of their four
63/// combinations are coherent:
64///
65/// | | authoring off | authoring on |
66/// |---|---|---|
67/// | delimiters hidden | [`None`](Self::None) | [`Shortcuts`](Self::Shortcuts) |
68/// | caret line revealed | *incoherent* | [`Full`](Self::Full) |
69///
70/// The empty quadrant would show delimiters on the caret's line and then escape
71/// the ones you type — a surface that displays a syntax it refuses to accept.
72/// Someone who wants to read raw markup without authoring it has
73/// [`View::Source`], which is the better tool for it.
74///
75/// The two axes are read separately by the code that cares — see
76/// [`reveals_caret_line`](Self::reveals_caret_line) and
77/// [`authors`](Self::authors) — so neither behaviour has to know it's spelled
78/// as a ladder.
79#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
80pub enum MarkupMode {
81 /// Delimiters stay hidden even on the caret's line, and typed syntax stays
82 /// literal — twig escapes anything that would open markup, so formatting
83 /// comes from commands (⌘b, the toolbar) instead of from spelling. The clean
84 /// reading surface for people who don't write markup by hand; the default,
85 /// and what Diaryx ships.
86 #[default]
87 None,
88 /// Delimiters stay hidden, but typing them authors real markup: `*x*`
89 /// becomes italic and the asterisks disappear into the styling
90 /// (Typora/Bear-shaped). For someone who knows the syntax but wants the
91 /// clean surface back once it has been applied.
92 Shortcuts,
93 /// The caret's line shows its raw markup while every other line renders
94 /// resolved (Obsidian live-preview-shaped), and typed syntax authors markup
95 /// — for people fluent in the document's grammar who want to see and edit
96 /// the delimiters they type.
97 Full,
98}
99
100impl MarkupMode {
101 /// Whether the rich view shows raw delimiters on the line holding the caret.
102 /// The rendering axis — read by [`Doc::reveal_line`] and threaded into the
103 /// WYSIWYG builder.
104 pub fn reveals_caret_line(self) -> bool {
105 matches!(self, MarkupMode::Full)
106 }
107
108 /// Whether typed markup characters author real formatting. The editing axis
109 /// — read by [`Doc::insert`], which escapes typed syntax when this is false.
110 pub fn authors(self) -> bool {
111 !matches!(self, MarkupMode::None)
112 }
113}
114
115/// How the WYSIWYG view treats a *soft break* — a bare newline inside a
116/// paragraph. An axis of its own, orthogonal to [`MarkupMode`] (which governs
117/// inline-markup delimiters) and to [`View`]: any reveal preference pairs with
118/// either flow. The renderer consults it when it lays a block's inline content
119/// into visual rows.
120#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
121pub enum LineFlow {
122 /// A soft break folds into a space and the paragraph reflows to the
123 /// viewport width — flowing prose, where the source's line wrapping is
124 /// insignificant. The default, and what Diaryx ships.
125 #[default]
126 Fold,
127 /// A soft break renders as a line break exactly where it was written, so
128 /// the author's source line structure shows on screen unchanged — the mode
129 /// for people who lay out their prose deliberately (one sentence or clause
130 /// per line, semantic line breaks). The break is still a soft break in the
131 /// source; only its rendering changes.
132 Preserve,
133}
134
135/// What the file behind a document looks like right now, against the bytes leaf
136/// last read from it or wrote to it — the question a frontend asks before it
137/// saves (a `Changed` file plus a `dirty` document is an overwrite about to
138/// happen) or when its window regains focus. See [`Doc::disk_state`].
139#[derive(Clone, Copy, Debug, PartialEq, Eq)]
140pub enum DiskState {
141 /// The file holds exactly the bytes leaf last read or wrote.
142 Unchanged,
143 /// Someone else wrote the file since. Saving overwrites their work; see
144 /// [`Doc::reload`] for the other direction.
145 Changed,
146 /// The file is gone — deleted or renamed away. A save recreates it.
147 Missing,
148 /// There is a path, but the file couldn't be read (permissions, a directory
149 /// in the way): leaf can't tell, and won't guess.
150 Unreadable,
151 /// No file behind this document yet — see [`Doc::blank`]. Nothing can have
152 /// changed under a document that was never on disk.
153 Untitled,
154}
155
156/// The inline marks in force at a point in the document — what a toolbar
157/// lights up. A `Copy` bitset rather than a `HashSet`, because
158/// [`Doc::active_inline_marks`] is called on every frame that draws a toolbar
159/// and a set that allocates to answer "is Bold on?" is a set that shouldn't.
160#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
161pub struct InlineMarks(u8);
162
163impl InlineMarks {
164 /// Every kind, in the order [`InlineMarks::iter`] yields them.
165 const ALL: [InlineKind; 8] = [
166 InlineKind::Strong,
167 InlineKind::Emph,
168 InlineKind::Verbatim,
169 InlineKind::Mark,
170 InlineKind::Superscript,
171 InlineKind::Subscript,
172 InlineKind::Insert,
173 InlineKind::Delete,
174 ];
175
176 pub const fn empty() -> Self {
177 InlineMarks(0)
178 }
179
180 /// Private: the set is an *answer*, and adding a mark to it doesn't mark
181 /// anything ([`Doc::toggle`] does that). `FromIterator` is the way in.
182 fn insert(&mut self, kind: InlineKind) {
183 self.0 |= Self::bit(kind);
184 }
185
186 /// Flip `kind` in the set — the sticky-marks toggle at a collapsed caret.
187 fn flip(&mut self, kind: InlineKind) {
188 self.0 ^= Self::bit(kind);
189 }
190
191 /// The symmetric difference: which marks differ between the two sets. Used
192 /// to resolve the marks already in force at the caret against the pending
193 /// delta — a bit set in the delta flips the base mark for the next keystroke.
194 fn xor(self, other: InlineMarks) -> InlineMarks {
195 InlineMarks(self.0 ^ other.0)
196 }
197
198 /// Whether `kind` is in force — the toolbar's "is Bold active?".
199 pub fn contains(self, kind: InlineKind) -> bool {
200 self.0 & Self::bit(kind) != 0
201 }
202
203 pub fn is_empty(self) -> bool {
204 self.0 == 0
205 }
206
207 /// The marks in force, for a frontend that renders whatever is on rather
208 /// than asking after a fixed list.
209 pub fn iter(self) -> impl Iterator<Item = InlineKind> {
210 Self::ALL.into_iter().filter(move |&k| self.contains(k))
211 }
212
213 fn bit(kind: InlineKind) -> u8 {
214 1 << match kind {
215 InlineKind::Strong => 0,
216 InlineKind::Emph => 1,
217 InlineKind::Verbatim => 2,
218 InlineKind::Mark => 3,
219 InlineKind::Superscript => 4,
220 InlineKind::Subscript => 5,
221 InlineKind::Insert => 6,
222 InlineKind::Delete => 7,
223 }
224 }
225}
226
227impl FromIterator<InlineKind> for InlineMarks {
228 fn from_iter<I: IntoIterator<Item = InlineKind>>(iter: I) -> Self {
229 let mut m = InlineMarks::empty();
230 for k in iter {
231 m.insert(k);
232 }
233 m
234 }
235}
236
237/// What kind of edit produced an undo group. Same-kind edits in a row coalesce
238/// into one undo step (a run of typed characters undoes together); `Other` never
239/// coalesces, so a paste, format toggle, or block change is always its own step.
240#[derive(Clone, Copy, PartialEq, Eq)]
241enum EditKind {
242 Insert,
243 Delete,
244 /// One step of an IME composition — see [`Doc::edit_composing`]. Its own kind
245 /// rather than `Insert`'s because a composition is not typing: each step
246 /// *replaces* the last (`か` → `かん` → `感`), so the run has to coalesce even
247 /// though no two steps insert the same bytes, and it must not fold into the
248 /// typed characters on either side of it.
249 Compose,
250 Other,
251}
252
253/// Which side of the caret a delete looks for an in-cell `<br>` break to swallow
254/// whole — see [`Doc::cell_break_at`]. `Backward` is Backspace (a break ending at
255/// the caret), `Forward` is Delete (one starting at it).
256#[derive(Clone, Copy)]
257enum BreakEdge {
258 Backward,
259 Forward,
260}
261
262/// A re-spelling of one inline mark run, held ready in case the edit about to
263/// happen breaks it — see [`Doc::mark_edge_fix`] and [`Doc::repair_mark_edges`].
264/// Every offset in it is in the coordinates the document will have *after* the
265/// plain edit, since that is when it may be applied.
266struct MarkEdgeFix {
267 /// The run's kind, and an offset inside what was its content: together they
268 /// answer "did the plain edit actually break this mark?" — the question that
269 /// decides whether any of this is applied at all.
270 kind: InlineKind,
271 probe: usize,
272 /// The byte range to re-spell (the run's delimiters included) and its new
273 /// spelling, with the edge whitespace moved outside the delimiters.
274 start: usize,
275 end: usize,
276 text: String,
277 /// Where the caret belongs afterwards — the same place on screen it would
278 /// have had, which is now on the other side of a delimiter.
279 caret: usize,
280 /// The marks in force for text typed at that caret. The caret can land
281 /// outside a run it was inside, and the marks have to survive the move or
282 /// the toolbar goes dark mid-word.
283 want: InlineMarks,
284}
285
286/// The caret and selection at one moment — the part of a history step twig's
287/// `Change` cannot carry, because the caret is leaf's state and twig only knows
288/// about bytes. leaf serializes it into the opaque per-state blob twig now
289/// stores in its own undo history (see `record_caret`), so undo and redo hand
290/// back the caret that matches the source they restore.
291#[derive(Clone, Copy)]
292struct CaretState {
293 caret: usize,
294 anchor: Option<usize>,
295}
296
297impl CaretState {
298 /// Pack into the fixed 17-byte blob leaf hands twig: the caret as a u64,
299 /// then an anchor-present flag and the anchor. twig copies these bytes and
300 /// never reads them.
301 fn to_blob(self) -> [u8; 17] {
302 let mut b = [0u8; 17];
303 b[..8].copy_from_slice(&(self.caret as u64).to_le_bytes());
304 if let Some(a) = self.anchor {
305 b[8] = 1;
306 b[9..].copy_from_slice(&(a as u64).to_le_bytes());
307 }
308 b
309 }
310
311 /// Recover a state from twig's blob, or `None` when it is empty or the wrong
312 /// length — a state twig restored that never had a caret set on it, which
313 /// leaves the caller to fall back to the edit site.
314 fn from_blob(b: &[u8]) -> Option<Self> {
315 let b: &[u8; 17] = b.try_into().ok()?;
316 let caret = u64::from_le_bytes(b[..8].try_into().unwrap()) as usize;
317 let anchor = (b[8] != 0).then(|| u64::from_le_bytes(b[9..].try_into().unwrap()) as usize);
318 Some(CaretState { caret, anchor })
319 }
320}
321
322/// A footnote reference and the note it names — the answer to
323/// [`Doc::footnote_at`].
324///
325/// The two `Option`s move together: a reference whose definition is missing has
326/// neither a body to show nor a place to jump to, and one that resolved has
327/// both.
328#[derive(Clone, PartialEq, Eq, Debug)]
329pub struct FootnoteRef {
330 /// The reference's label — the `1` of `[^1]`, with neither the `^` that
331 /// spells it a footnote nor the brackets around it.
332 pub label: String,
333 /// The note's body as source bytes (see
334 /// [`wysiwyg::footnote_body_span`](crate::wysiwyg)), or `None` when the
335 /// document defines no `[^label]:` to read one from.
336 pub text: Option<String>,
337 /// Where the note's *body* starts, for a "go to note" that moves the caret
338 /// there. `None` alongside a `None` `text`.
339 ///
340 /// The body rather than the definition, because this is an offset to put a
341 /// caret on and the `[^1]:` marker is decoration the caret can't occupy —
342 /// aiming at the definition's first byte snaps to the nearest real stop,
343 /// which is up in the paragraph above the note. It is also simply where a
344 /// reader following a reference wants to land: at the note's first word,
345 /// ready to read or amend it.
346 pub offset: Option<usize>,
347 /// Where the note's body ends, exclusive — so a frontend can ask which
348 /// *rendered rows* the note occupies and draw those instead of [`text`](Self::text).
349 ///
350 /// The rows are the note with its markup resolved: `see *later*` reaches a
351 /// frontend as an italic run, not as asterisks. `text` is the source bytes
352 /// and stays the honest answer for anything that wants the note as written
353 /// (a search index, a copy); this pair of offsets is for anything that wants
354 /// it as *read*. `None` alongside a `None` `offset`.
355 pub end: Option<usize>,
356}
357
358/// A footnote definition and the reference that sends a reader to it — the
359/// answer to [`Doc::footnote_definition_at`], and the other half of the round
360/// trip [`FootnoteRef`] starts.
361///
362/// A note is a place a reader *arrives*, so the useful thing to know while
363/// standing in one is the way back. Without this the jump to a note is a
364/// one-way door: the definitions sit at the foot of the document, so returning
365/// by hand means scrolling back up and finding the sentence again.
366#[derive(Clone, PartialEq, Eq, Debug)]
367pub struct FootnoteDef {
368 /// The definition's label — the `1` of `[^1]: …`, marker and colon stripped,
369 /// spelled exactly as [`FootnoteRef::label`] spells the same footnote's.
370 pub label: String,
371 /// Where the reference's *label* is, for a "back to reference" that moves
372 /// the caret there. `None` for a note nothing refers to — an orphan, which
373 /// is worth being able to say rather than silently doing nothing.
374 ///
375 /// The label rather than the reference's first byte, for
376 /// [`FootnoteRef::offset`]'s reason: a reference's brackets are decoration
377 /// and its label is the only part of it the caret can rest on.
378 ///
379 /// The *first* reference, when a label is cited more than once: a repeated
380 /// citation has no one true home, and the first is both the one a reader
381 /// most likely came from and the only choice that doesn't depend on how
382 /// they got here.
383 pub offset: Option<usize>,
384}
385
386/// Where a locator lands — the answer to [`Doc::locate`].
387///
388/// A locator (the `v2` of a `chapter.dj#v2`) names a *place* rather than a
389/// document, and a place is a span rather than a point: a reader following one
390/// wants the caret at its first byte, and a reader merely *peeking* at one wants
391/// the block it covers drawn. Both are served by carrying the whole span, and
392/// only one of the two can be recovered from an offset alone.
393#[derive(Clone, PartialEq, Eq, Debug)]
394pub struct Landing {
395 /// The first byte of the block the locator names — where a caret goes.
396 pub start: usize,
397 /// One past its last byte, so a frontend can map the pair through
398 /// [`VisualMap::row_range_for`](crate::wysiwyg::VisualMap::row_range_for) to the rendered rows the block occupies and draw
399 /// those, the way a footnote peek draws a note ([`FootnoteRef::end`]).
400 pub end: usize,
401}
402
403/// A selection cited out of the source: the text itself, up to a requested
404/// number of characters either side, and the byte range it came from. See
405/// [`Doc::selection_quote`].
406///
407/// The prefix and suffix are what make the quote *re-findable*: the same text
408/// can occur twice, and a little of what surrounded it is how a later reader —
409/// or the same document after an edit — tells the occurrences apart. The Web
410/// Annotation model calls this a `TextQuoteSelector`; the shape is older than
411/// the name.
412#[derive(Debug, Clone, PartialEq, Eq)]
413pub struct Quote {
414 /// The selected source, verbatim.
415 pub exact: String,
416 /// What immediately preceded it — possibly empty, at the document's start.
417 pub prefix: String,
418 /// What immediately followed it — possibly empty, at the document's end.
419 pub suffix: String,
420 /// Byte offset in the source where the selection begins.
421 pub start: usize,
422 /// Byte offset where it ends (exclusive).
423 pub end: usize,
424}
425
426/// A host-painted range of the source — an annotation's footprint, a search
427/// hit, a reviewer's mark. Leaf renders it (a background wash behind the
428/// glyphs whose source falls inside it) and hands back the `id` when the
429/// reader activates it; what the range *means* is entirely the host's.
430///
431/// Ranges are source bytes, like the caret and the selection, so a host that
432/// anchors quotes against the source ([`Doc::selection_quote`] is the other
433/// half of that loop) can paint what it found without any coordinate
434/// conversion. A range that drifts off the text it meant is the host's to
435/// re-anchor; leaf draws what it is told.
436#[derive(Debug, Clone, PartialEq, Eq)]
437pub struct Highlight {
438 /// Byte offset in the source where the wash begins.
439 pub start: usize,
440 /// Byte offset where it ends (exclusive).
441 pub end: usize,
442 /// The host's name for it, handed back on activation. Opaque to leaf.
443 pub id: String,
444 /// A rendering hint the frontend maps — a `#RRGGBB` hex string, or
445 /// nothing for the theme's default wash.
446 pub color: Option<String>,
447 /// A margin glyph's name, or nothing for wash-only ink. A highlight with
448 /// a marker gets a small glyph in the margin beside its first line, and
449 /// the glyph — not the wash — is what activates it: the wash is ink, the
450 /// marker is the control, which is what lets a reader put a caret in (or
451 /// copy from) annotated text without a card leaping at them. The name is
452 /// opaque to leaf; an Apple frontend reads it as an SF Symbol, a web one
453 /// as a class.
454 pub marker: Option<String>,
455}
456
457impl Highlight {
458 /// The range covering source `offset` in a list [`Doc::set_highlights`]
459 /// sorted, first by start where several overlap — the one place that
460 /// question is answered, for the frontends that paint by asking it as well
461 /// as for [`Doc::highlight_at`].
462 ///
463 /// The list is sorted by `(start, end)`, so the scan can stop at the first
464 /// range starting past `offset` rather than running to the end. A painter
465 /// asking once per glyph wants [`HighlightCursor`] instead; this is the
466 /// one-shot form, for the host asking what the reader just activated.
467 pub fn covering(highlights: &[Highlight], offset: usize) -> Option<&Highlight> {
468 highlights
469 .iter()
470 .take_while(|h| h.start <= offset)
471 .find(|h| offset < h.end)
472 }
473}
474
475/// [`Highlight::covering`] for a caller walking the document in order — which
476/// is every painter, since a frontend draws rows top to bottom and glyphs left
477/// to right.
478///
479/// The one-shot form is a scan from the front of the list per glyph, and a
480/// document with two hundred search hits pays that two hundred times a row. A
481/// range that ends at or before an offset can never cover that offset *or any
482/// later one*, so the cursor retires those permanently and each glyph costs the
483/// ranges that actually reach it. The answer is identical to
484/// [`Highlight::covering`]'s, offset for offset — this is the same scan with
485/// the part that was being redone dropped, not a cheaper approximation.
486///
487/// Offsets are expected to arrive non-decreasing. One that goes backwards is
488/// still answered correctly: the cursor re-seats to the front, since a painter
489/// that revisits a row is asking a question the retired ranges may own again.
490pub struct HighlightCursor<'a> {
491 highlights: &'a [Highlight],
492 /// The first range not yet retired.
493 at: usize,
494 /// The last offset asked about, to notice a caller going backwards.
495 last: usize,
496}
497
498impl<'a> HighlightCursor<'a> {
499 pub fn new(highlights: &'a [Highlight]) -> Self {
500 HighlightCursor {
501 highlights,
502 at: 0,
503 last: 0,
504 }
505 }
506
507 /// The range covering `offset`, advancing the cursor past every range that
508 /// can no longer cover anything.
509 pub fn at(&mut self, offset: usize) -> Option<&'a Highlight> {
510 if offset < self.last {
511 self.at = 0;
512 }
513 self.last = offset;
514 while self
515 .highlights
516 .get(self.at)
517 .is_some_and(|h| h.end <= offset)
518 {
519 self.at += 1;
520 }
521 Highlight::covering(&self.highlights[self.at..], offset)
522 }
523}
524
525/// The identity of a built [`VisualMap`] — see [`Doc::visual_key`]. Opaque on
526/// purpose: the only useful question is whether two of them are the same map,
527/// and what is behind it — which `Doc` built it, and the (revision, wrap,
528/// reveal line) it was built from — is core's business.
529///
530/// The document is part of it because the rest is not unique to one: two
531/// documents opened at the same width are both at revision zero with no reveal
532/// line, and a frontend holding one copy of a map across the two would take
533/// the second's key for the first's and paint the wrong document.
534#[derive(Clone, PartialEq, Eq, Debug)]
535pub struct VisualKey(u64, Option<(u64, Option<usize>, Option<Reveal>)>);
536
537pub struct Doc {
538 editor: Editor,
539 pub format: Format,
540 pub path: PathBuf,
541 /// Current source, refreshed from the editor after every successful edit.
542 pub source: String,
543 /// The caret, as a byte offset into `source` (always on a char boundary).
544 pub caret: usize,
545 /// The selection's fixed end, if a selection is active; the moving end is
546 /// the caret. `None` means no selection.
547 pub anchor: Option<usize>,
548 pub dirty: bool,
549 pub status: Option<String>,
550 pub view: View,
551 /// Whether the document refuses to change — a *reading* surface over the
552 /// same rendering, selection, and navigation the editor has.
553 ///
554 /// Enforced here rather than by each frontend hiding its input paths,
555 /// because every mutation funnels through a few doors —
556 /// [`splice_exact`](Self::splice_exact), [`undo`](Self::undo),
557 /// [`redo`](Self::redo), and the handful of inserts that go to twig's own
558 /// verbs directly rather than through the splice (a typed literal, a link,
559 /// an image, a rule, a footnote, a cell's line break) — and guarded doors
560 /// are a guarantee where a frontend's suppressed keyboard is a hope. A
561 /// gated door reports exactly like a rolled-back splice, a path every
562 /// caller already handles. `a_read_only_document_refuses_every_door` is
563 /// the list; a new `self.editor.insert_*` call belongs on it.
564 read_only: bool,
565 /// The host-painted ranges, kept sorted by start — see [`Highlight`].
566 /// State like the selection rather than like the text: no edit history,
567 /// no dirty bit, redrawn from whatever the host last set.
568 highlights: Vec<Highlight>,
569 /// How much of the source markup the rich view exposes — a frontend preference (see
570 /// [`MarkupMode`]). Its two axes are read apart: the rendering one by
571 /// [`reveal_line`](Self::reveal_line), the editing one by
572 /// [`insert`](Self::insert).
573 markup_mode: MarkupMode,
574 /// Whether soft breaks fold into the reflowed paragraph or render where
575 /// they were written (see [`LineFlow`]) — an independent frontend
576 /// preference the WYSIWYG builder consults when it lays out a block.
577 line_flow: LineFlow,
578 /// The kind of the last edit, for coalescing: twig owns the undo *history*
579 /// (see `undo`/`redo`), but "what counts as one undo step" is a frontend-UX
580 /// call, so leaf decides when a run continues and tells twig to coalesce.
581 last_edit_kind: Option<EditKind>,
582 /// The inline marks the user has toggled *at a collapsed caret* with no
583 /// selection — "start typing bold here". Held as the XOR delta from the marks
584 /// already in force at [`pending_at`](Self::pending_at): a set bit means
585 /// "flip this kind for the next typed text", so it both turns a mark on where
586 /// none is (type into bold) and off where one already covers the caret (type
587 /// past the bold you're standing in). [`Doc::insert`] realises it onto the
588 /// freshly typed text and then clears it — a mark once realised is carried by
589 /// the caret sitting inside the run, not by this delta.
590 pending_marks: InlineMarks,
591 /// The caret offset [`pending_marks`](Self::pending_marks) applies to. The
592 /// delta is live only while the caret still stands here with no selection;
593 /// any motion or edit ([`move_to`](Self::move_to), a splice, a click) drops
594 /// it, so a toggled-but-never-typed format doesn't leak onto text elsewhere.
595 pending_at: Option<usize>,
596 /// The source as of the last open/save — `dirty` is `source != clean_source`,
597 /// so undoing back to the saved state correctly clears the modified flag.
598 clean_source: String,
599 /// A hash of the bytes leaf last read from `path` or wrote to it; `None`
600 /// while the document has no file behind it. [`Doc::disk_state`] compares
601 /// the file against this to catch an edit made *outside* leaf before a save
602 /// silently overwrites it — `clean_source` only knows what leaf itself did.
603 ///
604 /// A hash, not an mtime: mtime is the cheap answer and the wrong one — two
605 /// writes inside one filesystem timestamp tick are indistinguishable, a
606 /// clock that steps backwards (or a writer that restores an mtime) hides a
607 /// real change, and a `touch` invents one. The whole point of the watermark
608 /// is to not clobber someone's work, so it reads the bytes and compares what
609 /// is actually there. That costs a file read per question, which is why the
610 /// question is asked on a user event (focus, save) and not every frame.
611 disk_hash: Option<u64>,
612 /// The "sticky" display column vertical motion aims for, in the active
613 /// view's grid. Set on the first `move_up`/`move_down` of a run and
614 /// reused by every subsequent one in that run, so passing through a
615 /// shorter line doesn't permanently forget the original column. Any
616 /// horizontal motion or edit clears it.
617 ///
618 /// A column, not a character index: dropping down a line of `你好` onto one
619 /// of ASCII has to land under the glyph the caret was drawn beneath, which
620 /// is the only thing the user can see to aim by. Where the goal falls inside
621 /// a wide character on the target line, the mapping resolves it to that
622 /// character — the caret lands on it rather than between its cells.
623 goal_col: Option<usize>,
624 /// The rendered map for the WYSIWYG view; empty in the source view. Movement
625 /// and clicks read it to stay in visible space.
626 pub vmap: VisualMap,
627 /// The syntax map for the source view; empty in the WYSIWYG view, which
628 /// styles resolved glyphs instead. Built by [`Doc::build_source`] — a
629 /// frontend that never calls it paints raw source unstyled, which is what
630 /// every frontend did before this map existed.
631 pub smap: SourceMap,
632 /// The revision `smap` was built from, or `None` before the first build.
633 /// The map is a pure function of the text alone — no width, no caret, no
634 /// reveal line — so unlike [`vmap_key`](Self::vmap_key) the revision is the
635 /// whole key.
636 smap_key: Option<u64>,
637 /// Everything the map is built from, as one number: bumped whenever the
638 /// document's text changes, and never by a motion, a selection, or a save.
639 /// A frontend can hold work against it — see [`Doc::revision`].
640 revision: u64,
641 /// How many history steps stand behind the caret, and how many ahead of
642 /// it — the answer to a native Edit menu's "may Undo be enabled?", which
643 /// twig's history does not ask itself. Counted at [`refresh`](Self::refresh),
644 /// the funnel every edit comes through, and moved back and forth by
645 /// [`undo`](Self::undo)/[`redo`](Self::redo). An upper bound rather than
646 /// an exact depth: a coalesced run of typing is one of twig's steps but
647 /// several of these, and twig's own cap on history is not mirrored here.
648 /// Neither error can make `can_undo` false while a step remains, which is
649 /// the only property a menu needs; the one place the bound can be wrong the
650 /// other way — the cap has retired every step — is reconciled the moment
651 /// twig reports nothing to undo.
652 undo_steps: usize,
653 redo_steps: usize,
654 /// What `vmap` was built from, or `None` before the first build. The map is
655 /// a pure function of `(revision, wrap, reveal line)`, so when those haven't
656 /// moved, rebuilding it produces the identical map — see
657 /// [`Doc::build_visual`].
658 ///
659 /// The reveal line ([`Doc::reveal_line`]) is the caret's, and is `None` in
660 /// every mode but [`MarkupMode::Full`] — so outside that mode the key is
661 /// text and width alone, and a caret motion still rebuilds nothing.
662 vmap_key: Option<(u64, Option<usize>, Option<Reveal>)>,
663 /// Which `Doc` this is, distinct from every other one built in this
664 /// process. Folded into [`VisualKey`] so that a map stashed by a frontend
665 /// can never be mistaken for another document's — see
666 /// [`Doc::visual_key`]. Nothing else reads it.
667 identity: u64,
668 /// Per-block row cache backing the incremental rebuild: when the text
669 /// changes, only the top-level blocks whose bytes moved are re-rendered and
670 /// the rest are reused shifted (see [`wysiwyg::BlockCache`]). Persists across
671 /// builds; a pure accelerator, so it's never read for correctness.
672 block_cache: wysiwyg::BlockCache,
673 /// What the frontend has said about itself — how tall its pictures came
674 /// out, keyed by destination or by TeX, and whether it paints a picture in
675 /// a line — set through [`Doc::set_media_rows`], [`Doc::set_math_rows`] and
676 /// [`Doc::set_inline_pictures`]. Core does no I/O and lays out in glyphs,
677 /// so this is the only way it learns a height or a capability. Threaded
678 /// into every build; a change drops both caches, since none of it is in a
679 /// block's bytes.
680 surface: wysiwyg::Surface,
681
682 // View geometry the renderer stamps each frame, so mouse events can map a
683 // screen cell back to a byte offset.
684 pub scroll: usize,
685 pub body_origin: (u16, u16),
686 /// Width of the body rectangle last painted by the frontend. Zero means
687 /// unknown (used by tests or a frontend that has not drawn yet).
688 pub body_width: u16,
689 pub body_height: u16,
690 /// The caret as of the last frame drawn, or `None` before the first.
691 ///
692 /// Scrolling is the viewport's business, not the caret's: the view follows
693 /// the caret when the caret *moves*, but a wheel that doesn't touch the
694 /// caret has to be free to scroll away from it — otherwise the view is
695 /// pinned to the caret and stops dead at the edge of the document you can
696 /// see. Comparing against this is what tells the two apart, and it catches a
697 /// caret set by any route, including a frontend assigning the field itself.
698 pub drawn_caret: Option<usize>,
699}
700
701/// The Markdown extensions every leaf document is parsed with — five of them,
702/// each departing from twig's defaults for a reason leaf can state.
703///
704/// `html_elements` promotes embedded raw HTML (`<img>`, `<picture>`,
705/// `<source>`, …) into semantic AST nodes, so a picture becomes a real `image`
706/// node the frontends can frame and rasterize instead of opaque `raw_block`
707/// text. `directives` turns on generic `:::name{.class}` fenced-div containers
708/// (`directive` nodes), which a host app uses for its own semantics (diaryx's
709/// `:::vis{.audience}` visibility blocks) — core renders any directive as a
710/// plain tinted container, agnostic of `name`.
711///
712/// `highlight` and `highlight_colors` are the pair that makes Markdown read
713/// `==text==` as a `mark` node, and `==🔴 text==` as one carrying a
714/// `data-color`. leaf already had somewhere to put both: the
715/// [`Mark`](crate::Role::Mark) role and the ⌘⇧M highlight button predate them,
716/// and until twig 3.3 a Markdown document could only ever *receive* a highlight
717/// from a Djot one it was converted from — the button wrote `==…==` and the
718/// reparse read it straight back as text.
719/// They are on together because a colour is inert without the highlight itself,
720/// and a document that writes `==🔴 x==` means the colour by it.
721///
722/// `math` makes Markdown read `$…$` as an `inline_math` node and `$$…$$` as
723/// a `display_math` one — what djot reads natively and what leaf has
724/// somewhere to put: a formula typesets to a picture, or reveals to its TeX
725/// on the caret's line. Without it a `$$` block is a paragraph whose `\,`
726/// twig has already read as an escaped comma, and an author who types a
727/// backslash in it is authoring Markdown, not TeX. The flag is bounded by
728/// twig's own rule that a dollar followed by whitespace never opens math, so
729/// `$5 and $6` stays prose.
730///
731/// Every flag is inert for non-Markdown formats, so it's safe to pass them
732/// unconditionally. Threading this through every constructor (not just `open`)
733/// keeps `from_source`, `blank`, and `reload` parsing the same document the same
734/// way — twig reparses with these same flags after each edit.
735pub(crate) fn parse_extensions() -> MarkdownExtensions {
736 MarkdownExtensions {
737 html_elements: true,
738 directives: true,
739 highlight: true,
740 highlight_colors: true,
741 math: true,
742 }
743}
744
745/// Build an editor over `bytes` in `format` with leaf's [`parse_extensions`],
746/// mapping twig's error into the `anyhow` context every constructor shares.
747fn new_editor(bytes: &[u8], format: Format) -> Result<Editor> {
748 Editor::new_ext(bytes, format, parse_extensions()).map_err(|e| anyhow!("twig parse: {e}"))
749}
750
751/// Does `format` spell a table as a **pipe table** — the one grid twig's table
752/// editor knows how to emit?
753///
754/// This is the single capability leaf still has to answer for itself, and the
755/// only hand-maintained format list left in this file. Every other gesture is
756/// [`Format::supports`], which is twig's own answer read across the C ABI — but
757/// twig deliberately leaves the table ops out of that query, because they read
758/// no `Syntax` table at all. They rewrite a grid that is already in the source
759/// and refuse on *position*, never on format. Handed a caret inside an HTML
760/// `<table>`, `table_insert_row` therefore re-emits the whole element as
761/// `| a | b |` and reports success — a real splice, a clean reparse, an honest
762/// `dirty` flag, and nothing downstream able to tell it from a good edit.
763///
764/// So the list is narrow on purpose. `Format` is `#[non_exhaustive]`, and the
765/// wildcard answers "no" for a format leaf has never heard of: a new twig
766/// language that *does* spell pipe tables loses its grid controls until this
767/// line is updated, which shows up as a missing button. The other default hands
768/// it to [`Doc::table_op`], which rewrites documents it cannot spell.
769fn spells_pipe_tables(format: Format) -> bool {
770 matches!(format, Format::Markdown | Format::Djot)
771}
772
773/// Which of leaf's authoring controls this document's format can actually
774/// spell — one flag per toolbar button, resolved once so a frontend can build
775/// its chrome instead of discovering each refusal on a click.
776///
777/// Every field but [`table`](Self::table) is `Format::supports_with` on the
778/// gesture the matching [`Doc`] method calls, so this record cannot drift from
779/// what the ops do; `table` is [`spells_pipe_tables`], the one answer twig
780/// doesn't export.
781///
782/// `supports_with` rather than `supports` because two of these are facts about
783/// the *parse options* as much as about the format. `Format::supports` answers
784/// for twig's defaults, and leaf never parses with those — it parses with
785/// [`parse_extensions`], and a document's toolbar has to describe the document
786/// it is over. A Markdown editor holding `highlight` authors `==text==`; one
787/// without it would mint bytes its own reparse hands back as plain text, which
788/// is why twig asks before it writes.
789///
790/// **The formats are ragged, and that is the point.** A single per-document
791/// boolean was enough while the two authorable formats were Markdown and djot
792/// and everything else spelled nothing. HTML is neither: it writes seven of the
793/// eight inline marks as a tag pair, plus `<code>`, `<hr>`, an in-cell
794/// `<br>`, and — since twig 3.4 — a heading or paragraph rebuilt as its tag
795/// pair, and since 3.5 a quote, a list, a code block, a link and an image
796/// printed as fresh nodes; it spells no task box (a form control there) and
797/// no footnote, and its `<table>` is one twig reads but will not write. So
798/// ⌘B, ⌘1 and the quote button work in an HTML document and the task and
799/// table buttons do not, and no one flag can say that. Markdown and djot
800/// differ from each other too:
801/// `^superscript^` is djot-only, and an in-cell `<br>` is Markdown-only.
802#[derive(Clone, Copy, Debug, Eq, PartialEq)]
803pub struct Capabilities {
804 /// ⌘B — `InlineKind::Strong`.
805 pub bold: bool,
806 /// ⌘I — `InlineKind::Emph`.
807 pub italic: bool,
808 /// Inline code — `InlineKind::Verbatim`.
809 pub code: bool,
810 /// Highlight — `InlineKind::Mark`. Djot spells it, and so does Markdown
811 /// under the `highlight` extension [`parse_extensions`] turns on: the
812 /// button writes `==text==`, which is what the reparse reads back.
813 pub mark: bool,
814 /// ⌘U — `InlineKind::Insert`, which every format that marks at all spells.
815 pub underline: bool,
816 /// Strikethrough — `InlineKind::Delete`. Markdown spells GFM's `~~text~~`
817 /// out of the box, since twig parses it out of the box.
818 pub strike: bool,
819 /// The highlight *palette* — [`Doc::set_mark_color`]. Narrower than
820 /// [`mark`](Self::mark) and deliberately its own flag: Markdown spells a
821 /// colour on a highlight (`==🔴 text==`) and djot spells only the highlight,
822 /// so a toolbar offering the swatches wherever the button lights would offer
823 /// them in a document that cannot write one. Pair with
824 /// [`Doc::caret_in_mark`], which asks the other question — the palette needs
825 /// a highlight to colour as much as a format that spells one.
826 pub mark_color: bool,
827 pub superscript: bool,
828 pub subscript: bool,
829 /// Heading levels and "make this a paragraph" — [`Doc::set_block`].
830 pub heading: bool,
831 pub blockquote: bool,
832 pub bullet_list: bool,
833 pub ordered_list: bool,
834 /// The checkbox controls: giving an item a box, and ticking one.
835 pub task: bool,
836 pub link: bool,
837 /// Covers [`Doc::insert_media`] too — see the note there on why the three
838 /// media kinds stand or fall together.
839 pub image: bool,
840 /// The horizontal-rule button. HTML spells this one (`<hr>`).
841 pub thematic_break: bool,
842 /// The footnote button — [`Doc::insert_footnote`]. Markdown and djot spell
843 /// the pair; HTML has no footnote of its own, so the button goes away rather
844 /// than writing brackets that would render as brackets.
845 pub footnote: bool,
846 /// The code-block button — [`Doc::toggle_code_block`], twig's
847 /// `Gesture::ToggleCodeBlock`. Markdown and djot spell the fence; HTML
848 /// rebuilds the block as `<pre><code>`.
849 pub code_block: bool,
850 /// Setting a fenced block's language — a control only ever offered with the
851 /// caret already in a fence.
852 pub code_language: bool,
853 /// The grid controls: insert/delete/move a row or column, set a column's
854 /// alignment. Pair with [`Doc::caret_in_table`], which asks the other
855 /// question — an HTML `<table>` holds the caret and still can't be edited.
856 pub table: bool,
857 /// Shift+Return inside a cell. Markdown and HTML spell it; djot has no
858 /// idiomatic in-cell break.
859 pub cell_line_break: bool,
860 /// The alignment control — [`Doc::set_alignment`], twig's
861 /// `Gesture::SetBlockAttrs`. Every format leaf opens but XML spells a
862 /// block's attributes, Markdown under the `html_elements`
863 /// [`parse_extensions`] turns on (a `<div>` around the block) and AsciiDoc
864 /// through its `[…]` line.
865 pub alignment: bool,
866 /// The line-spacing menu — [`Doc::set_line_spacing`]. The same gesture as
867 /// [`alignment`](Self::alignment) and so the same answer, and its own flag
868 /// because a toolbar dims controls one at a time and the pair may yet
869 /// diverge.
870 pub line_spacing: bool,
871 /// The size menu — [`Doc::set_font_size`], twig's `Gesture::WrapRangeAttrs`
872 /// over a selection. **Narrower than the block pair**: AsciiDoc's
873 /// `[#id.role]#text#` keeps an id and a role and has no slot for a
874 /// `data-` key, so twig refuses the span there and this is `false` while
875 /// [`alignment`](Self::alignment) is `true`. The block-level form of the
876 /// same property — the caret in a paragraph, no selection — goes through
877 /// `SetBlockAttrs` and still works, which is why the flag describes the
878 /// control rather than the caret.
879 pub font_size: bool,
880 /// The face menu — [`Doc::set_font_family`]. `WrapRangeAttrs`, as
881 /// [`font_size`](Self::font_size) is.
882 pub font_family: bool,
883 /// The text-colour swatches — [`Doc::set_text_color`]. `WrapRangeAttrs`,
884 /// and not to be confused with [`mark_color`](Self::mark_color): that is a
885 /// highlight's background and rides the `mark` node twig already owns,
886 /// this is a run's foreground and rides an attributed span.
887 pub text_color: bool,
888 /// The page-break button — [`Doc::insert_page_break`], twig's
889 /// `Gesture::InsertDirective`. Markdown under the `directives` extension
890 /// [`parse_extensions`] turns on (`::page-break`) and djot, which spells
891 /// it as an empty `::: page-break` fence.
892 ///
893 /// **Those two and no others**, though twig spells the gesture in HTML and
894 /// AsciiDoc as well — see [`Capabilities::of`].
895 pub page_break: bool,
896}
897
898impl Capabilities {
899 /// Resolve every flag for `format`, as leaf parses it. Pure and cheap —
900 /// twig computes each from a static table — but a frontend that wants to
901 /// hold them can.
902 ///
903 /// The extensions are not a parameter because they are not a choice a
904 /// caller makes: every leaf document is parsed with [`parse_extensions`],
905 /// so the format is the whole of what varies.
906 pub fn of(format: Format) -> Self {
907 let exts = parse_extensions();
908 let supports = |g| format.supports_with(exts, g);
909 let inline = |k| supports(Gesture::ToggleInline(k));
910 let container = |k| supports(Gesture::ToggleBlockContainer(k));
911 Self {
912 bold: inline(InlineKind::Strong),
913 italic: inline(InlineKind::Emph),
914 code: inline(InlineKind::Verbatim),
915 mark: inline(InlineKind::Mark),
916 underline: inline(InlineKind::Insert),
917 strike: inline(InlineKind::Delete),
918 mark_color: supports(Gesture::SetMarkColor),
919 superscript: inline(InlineKind::Superscript),
920 subscript: inline(InlineKind::Subscript),
921 heading: supports(Gesture::SetBlock),
922 blockquote: container(BlockContainerKind::BlockQuote),
923 bullet_list: container(BlockContainerKind::BulletList),
924 ordered_list: container(BlockContainerKind::OrderedList),
925 // Both halves of the checkbox story, and leaf offers no control that
926 // needs only one: the item gesture mints the box, the checked one
927 // ticks it, and a format spelling a `task_marker` spells both.
928 task: supports(Gesture::ToggleTaskItem) && supports(Gesture::ToggleTaskChecked),
929 link: supports(Gesture::InsertLink),
930 image: supports(Gesture::InsertImage),
931 thematic_break: supports(Gesture::InsertThematicBreak),
932 footnote: supports(Gesture::InsertFootnote),
933 code_block: supports(Gesture::ToggleCodeBlock),
934 code_language: supports(Gesture::SetCodeLanguage),
935 table: spells_pipe_tables(format),
936 cell_line_break: supports(Gesture::InsertLineBreak),
937 // The presentation vocabulary, one gesture per level: the two
938 // line-level properties are a block's attributes and the three
939 // run-level ones a span's. They are asked separately because the
940 // formats answer differently — AsciiDoc spells the block and not
941 // the span — and a toolbar that dimmed all five together would dim
942 // three controls that work.
943 alignment: supports(Gesture::SetBlockAttrs),
944 line_spacing: supports(Gesture::SetBlockAttrs),
945 font_size: supports(Gesture::WrapRangeAttrs),
946 font_family: supports(Gesture::WrapRangeAttrs),
947 text_color: supports(Gesture::WrapRangeAttrs),
948 // Narrower than the gesture, on purpose. Twig spells
949 // `InsertDirective` in HTML and AsciiDoc too, and spells it
950 // *differently* there — `<page-break></page-break>` and `<<<` —
951 // and the walker reads only the two spellings above. An HTML page
952 // break draws as nothing at all (no row, no caret home) and an
953 // AsciiDoc one as an empty unlabelled row, so the button would
954 // write a break the author cannot see and cannot get back to.
955 // The proposal claims Markdown and djot, and this is that claim.
956 // Widening it is the walker's work, not this line's — see
957 // `docs/tasks/page-break-in-html-and-asciidoc.md`.
958 page_break: supports(Gesture::InsertDirective)
959 && matches!(format, Format::Markdown | Format::Djot),
960 }
961 }
962}
963
964/// The source of [`Doc::identity`], one per document ever built.
965static NEXT_IDENTITY: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
966
967impl Doc {
968 #[cfg(feature = "fs")]
969 pub fn open(path: PathBuf) -> Result<Self> {
970 let bytes = std::fs::read(&path).with_context(|| format!("reading {}", path.display()))?;
971 Self::from_disk_bytes(path, bytes)
972 }
973
974 /// An empty document *named* `path`, for a file that isn't there yet — what
975 /// every other terminal editor gives you when you name a file that doesn't
976 /// exist. It is a real named document, not a [`Doc::blank`]: `is_untitled`
977 /// is false, so ⌘S writes straight to `path` with no Save As detour, and
978 /// the header shows the name the user asked for.
979 ///
980 /// The format comes from the extension, exactly as [`Doc::open`] reads it —
981 /// so `leaf notes.dj` starts a djot buffer rather than the Markdown
982 /// [`Doc::blank`] has to assume for want of a name. An extension leaf can't
983 /// parse is still an error: a mistyped flag or a stray argument should say
984 /// so, not open a buffer promising to save somewhere.
985 ///
986 /// The watermark is the hash of *no bytes*, not `None`, and that is the
987 /// whole trick: `None` means untitled, and would leave [`Doc::disk_state`]
988 /// answering [`DiskState::Untitled`] for a document that has a path and
989 /// intends to write to it. Hashing `""` instead makes the answers the true
990 /// ones — [`DiskState::Missing`] while the file still isn't there (a save
991 /// recreates it, which is exactly what this is for), and
992 /// [`DiskState::Changed`] if somebody creates it underneath us between
993 /// launch and save, so the frontend's overwrite prompt guards a new file as
994 /// it guards an opened one.
995 ///
996 /// Nothing is written here. A buffer that is never typed into never touches
997 /// the filesystem, and a `path` whose directory doesn't exist is allowed to
998 /// open — the write is where that fails, and it says so then.
999 #[cfg(feature = "fs")]
1000 pub fn create(path: PathBuf) -> Result<Self> {
1001 Self::from_disk_bytes(path, Vec::new())
1002 }
1003
1004 /// [`Doc::open`] when the file is there, [`Doc::create`] when it isn't —
1005 /// the call a CLI frontend wants for its path argument.
1006 ///
1007 /// The decision is made from the failed read itself rather than a `exists()`
1008 /// check first, so there is no window between the two for the file to appear
1009 /// or vanish in. Only `NotFound` opens a new buffer: a permissions error or
1010 /// a directory in the way is still an error, because pretending those are
1011 /// "no file yet" would offer to save over something leaf couldn't read.
1012 #[cfg(feature = "fs")]
1013 pub fn open_or_create(path: PathBuf) -> Result<Self> {
1014 match std::fs::read(&path) {
1015 Ok(bytes) => Self::from_disk_bytes(path, bytes),
1016 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Self::create(path),
1017 Err(e) => Err(e).with_context(|| format!("reading {}", path.display())),
1018 }
1019 }
1020
1021 /// The shared body of [`Doc::open`] and [`Doc::create`]: bytes that are (or
1022 /// stand in for) the file at `path`, parsed as the format its extension
1023 /// names. Keeping the two on one path is what makes a new file's document
1024 /// identical in every respect to an opened one but its contents.
1025 #[cfg(feature = "fs")]
1026 fn from_disk_bytes(path: PathBuf, bytes: Vec<u8>) -> Result<Self> {
1027 let format = detect_format(&path)?;
1028 let editor = new_editor(&bytes, format)?;
1029 let source = String::from_utf8(bytes).map_err(|_| anyhow!("document is not UTF-8"))?;
1030 let disk_hash = Some(hash_bytes(source.as_bytes()));
1031 // Store the document's *absolute* path. A relative one (`leaf README.md`)
1032 // has an empty parent, so a frontend can't resolve a relative image
1033 // destination (``) against the document's directory and the
1034 // picture silently falls back to its text placeholder. `absolute` is
1035 // purely lexical — it prefixes the current directory and normalizes, but
1036 // reads nothing and resolves no symlinks — so `file_name` and save are
1037 // unchanged; it only gives `path.parent()` something to join against.
1038 let path = std::path::absolute(&path).unwrap_or(path);
1039 Ok(Doc::from_parts(editor, format, path, source, disk_hash))
1040 }
1041
1042 /// Build a document from an in-memory string, the format named explicitly —
1043 /// the portable, filesystem-free counterpart to [`Doc::open`] (which reads a
1044 /// path and sniffs the format from its extension). A wasm or FFI host, which
1045 /// has no path to read, uses this: it hands over bytes it fetched however it
1046 /// could, and later persists [`Doc::source`] however it can (a browser
1047 /// download, `localStorage`, a backend `PUT`) and calls [`Doc::mark_saved`].
1048 ///
1049 /// No file backs the result, so it starts untitled ([`Doc::is_untitled`] is
1050 /// true) exactly like a [`Doc::blank`] that has been given content.
1051 pub fn from_source(source: String, format: Format) -> Result<Self> {
1052 let editor = new_editor(source.as_bytes(), format)?;
1053 Ok(Doc::from_parts(
1054 editor,
1055 format,
1056 PathBuf::new(),
1057 source,
1058 None,
1059 ))
1060 }
1061
1062 /// An untitled, empty document — the `+` button and a `leaf` launched with
1063 /// no file argument. Nothing on disk backs it until a [`Doc::save_as`].
1064 ///
1065 /// It is Markdown, because a format has to be chosen before a name exists to
1066 /// read one from: `detect_format` reads the extension and an untitled
1067 /// document has neither. Markdown is what leaf's own files are, what its
1068 /// block markers are already written for (`insert_block_prefix`), and the
1069 /// extension a Save As will overwhelmingly pick — a wrong guess here would
1070 /// mean typing djot into a buffer parsing it as Markdown. Note that Save As
1071 /// *doesn't* revisit this: see [`Doc::save_as`].
1072 pub fn blank() -> Result<Self> {
1073 let format = Format::Markdown;
1074 let editor = new_editor(b"", format)?;
1075 // An empty `path` is the untitled marker (`path` is a public `PathBuf`
1076 // field two frontends already read; making it an `Option` to say this
1077 // would break both). `is_untitled` is the question to ask, not the
1078 // representation to copy.
1079 Ok(Doc::from_parts(
1080 editor,
1081 format,
1082 PathBuf::new(),
1083 String::new(),
1084 None,
1085 ))
1086 }
1087
1088 /// The fields every constructor agrees on, so `open` and `blank` can't drift
1089 /// apart in the ones neither of them has an opinion about.
1090 // `identity` is taken from a counter rather than from the `Doc`'s address,
1091 // which moves — a session that holds one is moved into and out of
1092 // containers freely, and an identity that changed with it would defeat the
1093 // one comparison it exists for.
1094 fn from_parts(
1095 editor: Editor,
1096 format: Format,
1097 path: PathBuf,
1098 source: String,
1099 disk_hash: Option<u64>,
1100 ) -> Self {
1101 Doc {
1102 editor,
1103 format,
1104 path,
1105 disk_hash,
1106 clean_source: source.clone(),
1107 source,
1108 caret: 0,
1109 anchor: None,
1110 dirty: false,
1111 status: None,
1112 read_only: false,
1113 highlights: Vec::new(),
1114 // leaf opens in the rich-text (WYSIWYG) view by default — the
1115 // markup-resolved surface is leaf's differentiator. Frontends can
1116 // still start in source view explicitly (e.g. a CLI flag), and ⌘e/⌥w
1117 // toggles at runtime.
1118 view: View::Wysiwyg,
1119 // `None` by default — the clean surface Diaryx ships, with typed
1120 // syntax kept literal; a markup-fluent frontend can climb the
1121 // ladder to `Shortcuts` or `Full`.
1122 markup_mode: MarkupMode::default(),
1123 // Fold by default — flowing prose that reflows to the viewport, the
1124 // behaviour every frontend had before this preference existed.
1125 line_flow: LineFlow::default(),
1126 last_edit_kind: None,
1127 pending_marks: InlineMarks::empty(),
1128 pending_at: None,
1129 goal_col: None,
1130 vmap: VisualMap::default(),
1131 smap: SourceMap::default(),
1132 // No map yet — the first `build_source` always builds.
1133 smap_key: None,
1134 revision: 0,
1135 undo_steps: 0,
1136 redo_steps: 0,
1137 // No map yet — the first `build_visual` always builds.
1138 vmap_key: None,
1139 identity: NEXT_IDENTITY.fetch_add(1, std::sync::atomic::Ordering::Relaxed),
1140 block_cache: wysiwyg::BlockCache::default(),
1141 surface: wysiwyg::Surface::default(),
1142 scroll: 0,
1143 body_origin: (0, 0),
1144 body_width: 0,
1145 body_height: 0,
1146 drawn_caret: None,
1147 }
1148 }
1149
1150 /// Whether this document has no file behind it yet — a [`Doc::blank`] that
1151 /// has never been saved. The question a ⌘S handler asks to know it should
1152 /// open a Save As picker instead ([`Doc::save`] won't guess a name), and the
1153 /// header asks to know the name it shows is a placeholder.
1154 pub fn is_untitled(&self) -> bool {
1155 self.path.as_os_str().is_empty()
1156 }
1157
1158 pub fn toggle_view(&mut self) {
1159 self.view = match self.view {
1160 View::Source => View::Wysiwyg,
1161 View::Wysiwyg => View::Source,
1162 };
1163 self.scroll = 0;
1164 self.status = None;
1165 // Entering WYSIWYG, the caret may be sitting in now-hidden frontmatter;
1166 // lift it to the first rendered offset.
1167 self.clamp_caret();
1168 }
1169
1170 /// The current markup-exposure preference (see [`MarkupMode`]).
1171 pub fn markup_mode(&self) -> MarkupMode {
1172 self.markup_mode
1173 }
1174
1175 /// Set the markup-exposure preference. Both of its axes take effect at
1176 /// once: the editing one on the next [`insert`](Self::insert), and the
1177 /// rendering one on the next build — which is why this drops the cached
1178 /// visual map and the per-block render cache, exactly as
1179 /// [`set_line_flow`](Self::set_line_flow) does.
1180 pub fn set_markup_mode(&mut self, mode: MarkupMode) {
1181 if self.markup_mode == mode {
1182 return;
1183 }
1184 self.markup_mode = mode;
1185 // Neither cache is keyed on the mode, and moving between `Full` and the
1186 // hidden modes changes every row the caret's line renders to — so
1187 // invalidate both explicitly.
1188 self.vmap_key = None;
1189 self.block_cache = wysiwyg::BlockCache::default();
1190 }
1191
1192 /// The line the caret sits on, when that line should render something
1193 /// raw — `None` when nothing on it would, which is what the builder reads
1194 /// as "reveal nothing" and what keeps caret motion from costing a build.
1195 ///
1196 /// Two things ask for it. Under [`MarkupMode::Full`] every delimiter on
1197 /// the caret's line shows ([`Reveal::full`]). In the two hidden modes a
1198 /// *formula* on it still shows its TeX ([`Reveal::math`]), because a
1199 /// formula's content is not its picture and hiding the `$` alone would
1200 /// leave nothing to edit; there the line is threaded through only when it
1201 /// meets a block that holds one, which the last build's layout knows
1202 /// ([`wysiwyg::BlockCache::math_meets`]) — so a document with no math
1203 /// keeps the `None` it always had, and one with math pays a rebuild only
1204 /// while the caret is in the formula's block.
1205 ///
1206 /// A *source* line (newline to newline), not a visual row: a wrapped
1207 /// paragraph and a `LineFlow::Preserve` soft break both split one source
1208 /// line across several rows, and revealing half a delimiter pair because the
1209 /// other half wrapped would be worse than revealing neither. The range
1210 /// excludes the terminating newline and is empty-but-present on a blank
1211 /// line, which reveals nothing but still keys the caches correctly.
1212 ///
1213 /// Only in [`View::Wysiwyg`]: source view already shows every byte, so
1214 /// there is nothing there to reveal.
1215 pub(crate) fn reveal_line(&self) -> Option<Reveal> {
1216 if self.view != View::Wysiwyg {
1217 return None;
1218 }
1219 let line = source_line_range(&self.source, self.caret);
1220 if self.markup_mode.reveals_caret_line() {
1221 return Some(Reveal::full(line));
1222 }
1223 self.block_cache
1224 .math_meets(&line)
1225 .then_some(Reveal::math(line))
1226 }
1227
1228 /// The current soft-break flow preference (see [`LineFlow`]).
1229 pub fn line_flow(&self) -> LineFlow {
1230 self.line_flow
1231 }
1232
1233 /// Set the soft-break flow preference. The mode changes how every block lays
1234 /// out, so a change drops the cached visual map and the per-block render
1235 /// cache, forcing the next [`build_visual`] to rebuild under the new flow.
1236 ///
1237 /// [`build_visual`]: Self::build_visual
1238 pub fn set_line_flow(&mut self, mode: LineFlow) {
1239 if self.line_flow == mode {
1240 return;
1241 }
1242 self.line_flow = mode;
1243 // Both caches are keyed on `(revision, wrap)`, neither of which moved —
1244 // so invalidate them explicitly, or the next build would reuse rows laid
1245 // out under the old flow.
1246 self.vmap_key = None;
1247 self.block_cache = wysiwyg::BlockCache::default();
1248 }
1249
1250 pub fn view_name(&self) -> &'static str {
1251 match self.view {
1252 View::Source => "source",
1253 View::Wysiwyg => "wysiwyg",
1254 }
1255 }
1256
1257 /// Rebuild the WYSIWYG visual map for the current tree at `width` columns
1258 /// (called by the renderer each frame it's in the WYSIWYG view).
1259 /// Build the WYSIWYG map, wrapped at `width` display columns.
1260 ///
1261 /// Cheap to call every frame, which is what both frontends do: the map is a
1262 /// pure function of the document and the wrap width, so a call that would
1263 /// rebuild the same map returns the one already built. Only an edit (or a
1264 /// resize) pays.
1265 ///
1266 /// That isn't a micro-optimisation. A frontend repaints for reasons that have
1267 /// nothing to do with the text — a blinking caret, a scroll, a focus change —
1268 /// and rebuilding here is O(document): 23 ms on a 1 MB file, of which 5 ms is
1269 /// marshalling twig's AST across the C ABI. Paid twice a second by the GUI's
1270 /// blink timer, that was 14% of a core spent redrawing an unchanged document.
1271 /// (`cargo run --release -p leaf-core --example bench` for the numbers.)
1272 pub fn build_visual(&mut self, width: usize) {
1273 self.build_map(Some(width));
1274 }
1275
1276 /// Build the WYSIWYG map with each block as a single unwrapped row — for a
1277 /// frontend (the GUI) that wraps at its own proportional pixel width rather
1278 /// than a fixed character column.
1279 pub fn build_visual_unwrapped(&mut self) {
1280 self.build_map(None);
1281 }
1282
1283 /// Build the source view's syntax map ([`Doc::smap`]) — the styling for
1284 /// [`View::Source`], the way [`build_visual`](Self::build_visual) is the
1285 /// styling for [`View::Wysiwyg`].
1286 ///
1287 /// A frontend calls this before painting raw source. One that doesn't gets
1288 /// an empty map and paints unstyled text, so this is additive: nothing
1289 /// breaks by not calling it.
1290 ///
1291 /// Built at most once per revision, and the revision is the whole key — the
1292 /// map has no width and no caret in it, so it survives every resize, every
1293 /// motion, and every selection change.
1294 ///
1295 /// The builds it does do cost a whole-arena marshal, which is precisely what
1296 /// the WYSIWYG path works to avoid, so this has no incremental path where
1297 /// that one has two. From `cargo run --release -p leaf-core --example
1298 /// bench`, per keystroke, against the WYSIWYG build the source view is
1299 /// *not* doing:
1300 ///
1301 /// | size | nodes | marshal | `source::build` | (`wysiwyg::build`) |
1302 /// |------:|-------:|--------:|----------------:|-------------------:|
1303 /// | 10 KB| 613 | 0.16 ms| 0.07 ms | 0.28 ms |
1304 /// | 100 KB| 6 097 | 0.84 ms| 0.38 ms | 2.43 ms |
1305 /// | 1 MB| 60 601 | 5.67 ms| 3.12 ms | 23.39 ms |
1306 ///
1307 /// Linear, two thirds of it the marshal, and the build itself five to seven
1308 /// times cheaper than the one it stands in for at every size. Comfortable
1309 /// well past any document a person edits in a terminal — a megabyte is where
1310 /// it would want [`Editor::dirty_range`] and the same splice treatment
1311 /// `build_spliced` gives the other map. The door is open; nothing has needed
1312 /// it yet.
1313 pub fn build_source(&mut self) {
1314 if self.smap_key == Some(self.revision) {
1315 return;
1316 }
1317 let nodes = self.nodes();
1318 self.smap = source::build(&nodes, &self.source);
1319 self.smap_key = Some(self.revision);
1320 }
1321
1322 /// Tell the model how many visual rows each block image should reserve, keyed
1323 /// by the image's destination. A terminal frontend calls this once it has
1324 /// decoded and measured its pictures — core does no image I/O, so this is the
1325 /// only way it learns a height — and the next [`Doc::build_visual`] lays each
1326 /// placeholder out that tall (the label row plus blank filler rows the
1327 /// frontend paints the raster over). A destination left out of the map falls
1328 /// back to the bare one-row placeholder, which is also what a frontend that
1329 /// can't draw pictures (or lays them out in its own units, like the GUI) gets
1330 /// by never calling this.
1331 ///
1332 /// Cheap to call every frame with the same map: only a *change* invalidates
1333 /// the built map (and the block-row cache, since a height isn't part of a
1334 /// block's bytes and so wouldn't otherwise re-render it). Steady state is a
1335 /// no-op, so a frontend can just hand over its current measurements each frame.
1336 pub fn set_media_rows(&mut self, rows: HashMap<String, usize>) {
1337 if self.surface.media_rows == rows {
1338 return;
1339 }
1340 self.surface.media_rows = rows;
1341 self.surface_changed();
1342 }
1343
1344 /// Tell the model how many visual rows each display formula should
1345 /// reserve, keyed by the formula's TeX exactly as the map's
1346 /// [`MathInfo::tex`](wysiwyg::MathInfo::tex) handed it over. The peer of
1347 /// [`set_media_rows`](Self::set_media_rows) for the terminal, which
1348 /// typesets the picture, measures it in cells, and reports back; a
1349 /// frontend that lays formulas out in pixels never calls this and gets
1350 /// the one-row placeholder to paint over.
1351 pub fn set_math_rows(&mut self, rows: HashMap<String, usize>) {
1352 if self.surface.math_rows == rows {
1353 return;
1354 }
1355 self.surface.math_rows = rows;
1356 self.surface_changed();
1357 }
1358
1359 /// Tell the model whether the frontend can paint a picture *inside* a line
1360 /// of text. When it can, an inline formula renders to one atom glyph the
1361 /// frontend draws its typeset picture over — see
1362 /// [`MathInfo`](wysiwyg::MathInfo) — and when it cannot (a terminal), to
1363 /// the code-styled TeX it always showed. Off until a frontend says
1364 /// otherwise, so a host that has not caught up sees what it saw.
1365 pub fn set_inline_pictures(&mut self, on: bool) {
1366 if self.surface.inline_pictures == on {
1367 return;
1368 }
1369 self.surface.inline_pictures = on;
1370 self.surface_changed();
1371 }
1372
1373 /// A height or a capability lives outside a block's source bytes, so the
1374 /// content-keyed block cache would hand back the old rows on a hit. Drop
1375 /// it (and the splice layout it carries) so the next build re-renders
1376 /// every block against the new surface, and force that build by clearing
1377 /// the map key.
1378 fn surface_changed(&mut self) {
1379 self.block_cache = wysiwyg::BlockCache::default();
1380 self.vmap_key = None;
1381 }
1382
1383 /// The revision the document's text is at — bumped by every edit, undo,
1384 /// redo, and reload, and by nothing else. A frontend caches against this to
1385 /// tell a repaint that needs new work from one that doesn't.
1386 ///
1387 /// It counts *edits*, not distinct texts: typing `x` and deleting it again
1388 /// lands on the same text two revisions later. Work is only ever rebuilt
1389 /// needlessly, never wrongly reused.
1390 pub fn revision(&self) -> u64 {
1391 self.revision
1392 }
1393
1394 /// The identity of the map presently in [`vmap`](Self::vmap) — what the last
1395 /// [`build_visual`](Self::build_visual) built it from, or the identity of an
1396 /// unbuilt map before the first one.
1397 ///
1398 /// This is *not* [`revision`](Self::revision). The revision says where the
1399 /// text is; this says where the map is, and the two part company the moment
1400 /// an edit lands, until something rebuilds. A frontend that keeps its own
1401 /// copy of the map — leaf-ratatui stashes core's before splicing filler rows
1402 /// under an oversized heading — compares this against the value it held when
1403 /// it took the copy, and learns whether `vmap` is still the map it stashed
1404 /// or one somebody else has since rebuilt. Restoring a copy over a newer
1405 /// map would paint a stale document; restoring nothing hands core's
1406 /// incremental rebuild a map it never built.
1407 ///
1408 /// "Somebody else" includes another document. The key names the `Doc`
1409 /// as well as the build, so a frontend that draws two documents through
1410 /// one stash — a host with several buffers, or one that opens the next
1411 /// document where the last one stood — never has the copy it took of one
1412 /// accepted by the other, however alike their builds are.
1413 pub fn visual_key(&self) -> VisualKey {
1414 VisualKey(self.identity, self.vmap_key.clone())
1415 }
1416
1417 /// The map, built at most once per `(revision, wrap)`. `clamp_caret` still
1418 /// runs on every call: the caret moves without the document changing, and
1419 /// keeping it on a legal stop is this function's job either way.
1420 fn build_map(&mut self, wrap: Option<usize>) {
1421 // Under `MarkupMode::Full` the map is a function of the caret's *line*
1422 // as well as the text, so the line joins the key: moving within a line
1423 // still reuses the map, and crossing into another one rebuilds it. In
1424 // every other mode `reveal_line` is `None` and the key is what it was,
1425 // so caret motion goes on costing nothing.
1426 let reveal = self.reveal_line();
1427 let key = (self.revision, wrap, reveal.clone());
1428 if self.vmap_key.as_ref() != Some(&key) {
1429 self.build_map_with(wrap, reveal);
1430 self.vmap_key = Some(key);
1431 // In a hidden mode the reveal line was decided from the *previous*
1432 // build's layout, whose spans are stale across an edit: the
1433 // keystroke that closes a new `$…$` on the caret's line asked "is
1434 // there math here?" of a layout that had none, and the formula
1435 // would snap to its picture under the caret until the next
1436 // motion. Ask again of the layout just built, and go once more if
1437 // the answer moved. Between edits the first answer is exact and
1438 // this is one comparison.
1439 let again = self.reveal_line();
1440 if again != self.vmap_key.as_ref().and_then(|k| k.2.clone()) {
1441 self.build_map_with(wrap, again.clone());
1442 self.vmap_key = Some((self.revision, wrap, again));
1443 }
1444 }
1445 self.clamp_caret();
1446 }
1447
1448 /// One build of the map at `wrap` under `reveal`, incremental where it can
1449 /// be — the body of [`build_map`](Self::build_map), which decides whether
1450 /// to call it.
1451 fn build_map_with(&mut self, wrap: Option<usize>, reveal: Option<Reveal>) {
1452 {
1453 // Enumerate the top-level blocks cheaply — no whole-arena marshal.
1454 // A subtree is pulled only for the block(s) that actually changed, so
1455 // the FFI marshal shrinks from O(document) to O(edited block).
1456 let top = self.top_blocks();
1457
1458 // Fast path: when twig reports a dirty byte range, try to patch the
1459 // previous map in place — a single-block edit moves the prefix,
1460 // shifts the suffix, and re-renders only one block. `build_spliced`
1461 // returns `None` (and we fall back to the always-correct full rebuild)
1462 // whenever the edit reshaped the block structure, hit a table, or
1463 // there's no previous map to patch.
1464 // Preserve soft breaks as written when the flow preference asks for
1465 // it — the builder renders each as its own visual row instead of
1466 // folding it into the reflowed paragraph.
1467 let preserve_soft = self.line_flow == LineFlow::Preserve;
1468 let spliced = match self.editor.dirty_range() {
1469 Some(dirty) => {
1470 let prev = std::mem::take(&mut self.vmap);
1471 let source = &self.source;
1472 let cache = &mut self.block_cache;
1473 let surface = &self.surface;
1474 let editor = &mut self.editor;
1475 wysiwyg::build_spliced(
1476 prev,
1477 source,
1478 wrap,
1479 preserve_soft,
1480 &top,
1481 dirty,
1482 surface,
1483 reveal.clone(),
1484 cache,
1485 |id| editor.subtree(NodeId(id)).unwrap_or_default(),
1486 )
1487 }
1488 None => None,
1489 };
1490 self.vmap = spliced.unwrap_or_else(|| {
1491 let source = &self.source;
1492 let cache = &mut self.block_cache;
1493 let surface = &self.surface;
1494 let editor = &mut self.editor;
1495 wysiwyg::build_cached(
1496 &top,
1497 source,
1498 wrap,
1499 preserve_soft,
1500 surface,
1501 reveal,
1502 cache,
1503 |id| editor.subtree(NodeId(id)).unwrap_or_default(),
1504 )
1505 });
1506 // Acknowledge the dirty range so the next edit's range starts fresh.
1507 self.editor.clear_dirty();
1508 }
1509 }
1510
1511 fn nodes(&mut self) -> Vec<FlatNode> {
1512 self.editor.nodes().unwrap_or_default()
1513 }
1514
1515 /// The document's top-level blocks for the incremental render. See
1516 /// [`wysiwyg::top_blocks`] for why this isn't simply `child_spans(None)`.
1517 fn top_blocks(&mut self) -> Vec<QueryMatch> {
1518 wysiwyg::top_blocks(&mut self.editor)
1519 }
1520
1521 pub fn format_name(&self) -> &'static str {
1522 // `Format` is `#[non_exhaustive]` as of twig 3.0, so the wildcard is
1523 // required. It also covers `Asciidoc`, which twig parses but cannot
1524 // serialize — leaf never opens a document in it (see `Doc::open`).
1525 match self.format {
1526 Format::Djot => "djot",
1527 Format::Markdown => "markdown",
1528 Format::Xml => "xml",
1529 Format::Html => "html",
1530 _ => "unknown",
1531 }
1532 }
1533
1534 /// Whether this document's format offers *any* door in — `false` only for a
1535 /// wholly parse-only format (XML, AsciiDoc), where every gesture refuses and
1536 /// a frontend may as well open the file read-only.
1537 ///
1538 /// This is a much weaker claim than the name suggests, and driving per-button
1539 /// state from it is exactly the mistake to avoid: HTML answers `true` because
1540 /// it spells the inline marks with a tag pair (`<strong>`, `<em>`, `<code>`)
1541 /// while a heading, a quote, a list, a task box, a link and a code fence all
1542 /// remain unspellable there. Ask [`capabilities`](Self::capabilities) — or
1543 /// [`supports`](Self::supports) — per control.
1544 pub fn authorable(&self) -> bool {
1545 self.format.is_authorable()
1546 }
1547
1548 /// Whether this document can spell `gesture`, which is twig's own answer
1549 /// rather than a copy of it: `Format::supports_with` reads the same
1550 /// `Syntax` table the `Editor` method consults before refusing, chosen by
1551 /// the very [`parse_extensions`] this document's editor reparses with — so
1552 /// what the toolbar offers and what the splice will accept are one table.
1553 ///
1554 /// It is a fact about the *document*, not about the caret. `true` does not
1555 /// promise the gesture succeeds where it is standing — a link over a table
1556 /// border still fails — only that it will not fail with
1557 /// `UnsupportedFormat`. Gray out on `false`; don't read `true` as "this
1558 /// will work here".
1559 pub fn supports(&self, gesture: Gesture) -> bool {
1560 self.format.supports_with(parse_extensions(), gesture)
1561 }
1562
1563 /// Every control's enabled state in one read — what a toolbar builds itself
1564 /// from when a document opens or its format changes. See [`Capabilities`].
1565 pub fn capabilities(&self) -> Capabilities {
1566 Capabilities::of(self.format)
1567 }
1568
1569 /// Refuse a gesture this document's format cannot spell, saying so in the
1570 /// status line. `true` means the caller must return without calling twig.
1571 ///
1572 /// Most of these refusals duplicate one twig would make anyway, and they are
1573 /// made here regardless because a message naming the *document's* format
1574 /// reads better than one naming twig's internals. Two of them are not
1575 /// duplicates and are the reason this is a guard rather than an error
1576 /// translation:
1577 ///
1578 /// - The table family (see [`table_op`](Self::table_op)) consults no
1579 /// `Syntax` table, so twig does not refuse it at all.
1580 /// - [`toggle`](Self::toggle) at a collapsed caret never reaches twig — it
1581 /// arms a sticky mark for text not yet typed, which is a promise `insert`
1582 /// could not keep.
1583 fn refuse_unsupported(&mut self, what: &str, gesture: Gesture) -> bool {
1584 self.refuse_unless(what, self.supports(gesture))
1585 }
1586
1587 /// [`refuse_unsupported`](Self::refuse_unsupported) against a capability leaf
1588 /// answers itself — today only [`spells_pipe_tables`].
1589 fn refuse_unless(&mut self, what: &str, supported: bool) -> bool {
1590 if supported {
1591 return false;
1592 }
1593 self.status = Some(format!("{what}: not supported in {}", self.format_name()));
1594 true
1595 }
1596
1597 /// The name to show for this document. An untitled one has no file to name
1598 /// it, and both frontends put this straight on screen — an empty path
1599 /// renders as an empty header, so it says so instead.
1600 pub fn file_name(&self) -> String {
1601 if self.is_untitled() {
1602 return "untitled".into();
1603 }
1604 self.path
1605 .file_name()
1606 .map(|s| s.to_string_lossy().into_owned())
1607 .unwrap_or_else(|| self.path.display().to_string())
1608 }
1609
1610 /// The selection as an ordered `[start, end)` byte range, or `None` when the
1611 /// caret and anchor coincide (an empty selection is no selection).
1612 pub fn selection(&self) -> Option<(usize, usize)> {
1613 self.anchor
1614 .map(|a| (a.min(self.caret), a.max(self.caret)))
1615 .filter(|(s, e)| s != e)
1616 }
1617
1618 /// The selected text, or `None` when there's no selection — the source
1619 /// slice a copy/cut hands to the system clipboard.
1620 pub fn selected_text(&self) -> Option<&str> {
1621 self.selection().map(|(s, e)| &self.source[s..e])
1622 }
1623
1624 /// The selection as a quote with a little of what surrounds it — the shape
1625 /// a host that cites, annotates, or searches for a passage wants, cut from
1626 /// the **source** rather than from anything rendered, so the quote is
1627 /// findable in the document again by plain string search.
1628 ///
1629 /// `context` is a count of characters (not bytes) on each side, clipped at
1630 /// the document's edges; the slices land on char boundaries by
1631 /// construction. `None` when nothing is selected.
1632 pub fn selection_quote(&self, context: usize) -> Option<Quote> {
1633 let (start, end) = self.selection()?;
1634 let mut before = start;
1635 for _ in 0..context {
1636 match self.source[..before].chars().next_back() {
1637 Some(c) => before -= c.len_utf8(),
1638 None => break,
1639 }
1640 }
1641 let mut after = end;
1642 for _ in 0..context {
1643 match self.source[after..].chars().next() {
1644 Some(c) => after += c.len_utf8(),
1645 None => break,
1646 }
1647 }
1648 Some(Quote {
1649 exact: self.source[start..end].to_string(),
1650 prefix: self.source[before..start].to_string(),
1651 suffix: self.source[end..after].to_string(),
1652 start,
1653 end,
1654 })
1655 }
1656
1657 /// Words, characters, and paragraphs over the whole document — the numbers
1658 /// a status bar or an inspector puts next to a piece of writing.
1659 ///
1660 /// Counted over the text a **reader** sees, not the markup that spells it:
1661 /// `**bold**` is one word and four characters, a link is its label and not
1662 /// its destination, a block picture's `🖼 alt` placeholder is a picture and
1663 /// counts nothing, and leading frontmatter — which the WYSIWYG view does
1664 /// not render at all — is not writing. [`crate::counts`] states the rules
1665 /// in full; [`TextCounts`] states them per field.
1666 ///
1667 /// The same numbers in both views. They have to be: a word count that fell
1668 /// when you pressed ⌘E would be telling you the view had changed, which
1669 /// you knew already. So this reads neither [`Doc::view`] nor the map the
1670 /// frontend last built — it renders the source afresh, unwrapped, with
1671 /// soft breaks folded and no line revealed, and counts that. A narrower
1672 /// window, a different [`MarkupMode`], a different [`LineFlow`], and the
1673 /// source view all give the identical answer, because none of them is an
1674 /// input.
1675 ///
1676 /// That costs a reparse and an unwrapped layout — O(document), about 4 ms
1677 /// on a 45 KB file in release and 36 ms on half a megabyte. Fine on a
1678 /// settle and wrong in a paint loop, so a frontend should ask when the
1679 /// typing stops rather than once a keystroke. Caching it against
1680 /// [`revision`](Self::revision) is the obvious next move if that is ever
1681 /// not enough; nothing has needed it yet.
1682 pub fn counts(&self) -> TextCounts {
1683 self.count_over(None)
1684 }
1685
1686 /// The same statistics over the selection alone — `None` when nothing is
1687 /// selected, since an empty selection is no selection.
1688 ///
1689 /// Same rules, over the same rendering, narrowed to the glyphs whose
1690 /// source byte falls inside [`selection`](Self::selection)'s range. A
1691 /// block the selection only clips still counts as one paragraph, and one
1692 /// it enters without catching a visible character counts as none — a
1693 /// selection that starts on a hidden `**` gains no paragraph from it.
1694 pub fn selection_counts(&self) -> Option<TextCounts> {
1695 let (start, end) = self.selection()?;
1696 Some(self.count_over(Some(start..end)))
1697 }
1698
1699 /// The rendering both counters tally, and the tally itself.
1700 ///
1701 /// A fresh parse rather than `self.editor`, because these take `&self` and
1702 /// twig's arena is reached through `&mut`. A document that will not
1703 /// reparse is a "cannot happen" — the source came out of an editor that
1704 /// had already accepted it — and answers zero rather than panicking in
1705 /// what is very likely a paint path.
1706 fn count_over(&self, range: Option<Range<usize>>) -> TextCounts {
1707 let Ok(mut editor) = new_editor(self.source.as_bytes(), self.format) else {
1708 return TextCounts::default();
1709 };
1710 let Ok(nodes) = editor.nodes() else {
1711 return TextCounts::default();
1712 };
1713 // A surface that paints pictures in a line, so an inline formula is
1714 // an atom here and never its TeX: a formula is a picture to a reader
1715 // whichever way it is written, and the count says so consistently.
1716 let surface = wysiwyg::Surface {
1717 inline_pictures: true,
1718 ..Default::default()
1719 };
1720 let map = wysiwyg::build(&nodes, &self.source, None, false, &surface, None);
1721 counts::tally(&map, range)
1722 }
1723
1724 /// Whether the document refuses to change — see the field.
1725 pub fn read_only(&self) -> bool {
1726 self.read_only
1727 }
1728
1729 /// Turn the read-only gate on or off. A frontend preference like
1730 /// [`set_markup_mode`](Self::set_markup_mode): nothing about the document
1731 /// itself changes, only what may be done to it from here on.
1732 pub fn set_read_only(&mut self, on: bool) {
1733 self.read_only = on;
1734 }
1735
1736 /// The host-painted ranges, sorted by start — see [`Highlight`].
1737 pub fn highlights(&self) -> &[Highlight] {
1738 &self.highlights
1739 }
1740
1741 /// Replace the host-painted ranges wholesale. The whole set each time,
1742 /// rather than add/remove verbs: the host owns the list (it derives it
1743 /// from its own state — annotations, search hits), and a replace can
1744 /// never leave the two disagreeing about what should be on screen.
1745 pub fn set_highlights(&mut self, mut highlights: Vec<Highlight>) {
1746 highlights.retain(|h| h.start < h.end);
1747 highlights.sort_by_key(|h| (h.start, h.end));
1748 self.highlights = highlights;
1749 }
1750
1751 /// The highlight covering source `offset`, if one does — first by start
1752 /// when several overlap, which makes overlapping washes resolvable rather
1753 /// than undefined. What a frontend asks when the reader activates a spot.
1754 ///
1755 /// [`Highlight::covering`] is the whole of it: the frontends paint by
1756 /// asking the same question per glyph, against a slice they were handed
1757 /// rather than against a `Doc`, and one answer for both is what keeps a
1758 /// wash and an activation agreeing about which range a spot is in.
1759 pub fn highlight_at(&self, offset: usize) -> Option<&Highlight> {
1760 Highlight::covering(&self.highlights, offset)
1761 }
1762
1763 /// The AST breadcrumb at the caret (root → deepest), e.g.
1764 /// `doc › para › strong`. Read live from twig via `ancestors_at`.
1765 pub fn breadcrumb(&mut self) -> String {
1766 match self.editor.ancestors_at(self.caret) {
1767 Ok(chain) => chain
1768 .iter()
1769 .map(|m| m.kind.as_str())
1770 .collect::<Vec<_>>()
1771 .join(" › "),
1772 Err(_) => String::new(),
1773 }
1774 }
1775
1776 // ── editing ──────────────────────────────────────────────────────────────
1777
1778 /// Replace the byte range `[start, end)` with `text`, re-anchoring the caret
1779 /// after it. The public form of the internal splice — a pixel frontend that
1780 /// hit-tests to a byte offset (or an IME that hands back an explicit range)
1781 /// edits through this, the same twig `edit_range` the caret ops use.
1782 pub fn edit(&mut self, start: usize, end: usize, text: &str) {
1783 self.splice(start, end, text, EditKind::Other);
1784 }
1785
1786 /// Insert typed `text` at the caret, replacing the selection if there is one.
1787 /// A single typed character coalesces with the run of typing before it; a
1788 /// newline or a multi-character insert is its own undo step.
1789 ///
1790 /// Typed input only — clipboard text goes through [`paste`](Self::paste).
1791 pub fn insert(&mut self, text: &str) {
1792 // The read-only gate, up front: the paths below reach twig by several
1793 // verbs, not all of them through the splice — see the field.
1794 if self.read_only {
1795 return;
1796 }
1797 // Typing against a block picture would dissolve it, and typing past a
1798 // table would grow it a row — see `open_paragraph_at_block_edge`. Give
1799 // the text a paragraph first, so what the caret was standing beside
1800 // stays what it was.
1801 self.open_paragraph_at_block_edge(text);
1802 // Armed sticky marks (⌘b with no selection) turn the next typed text
1803 // bold/italic/… and then retire — see `insert_with_marks`. Whitespace is
1804 // the exception: it takes no mark of its own and keeps the delta armed
1805 // for the character behind it — see `insert_space_with_marks`.
1806 let pending = self.pending_here();
1807 if !pending.is_empty() && self.selection().is_none() && !text.is_empty() {
1808 if text.trim().is_empty() {
1809 self.insert_space_with_marks(self.caret, text, pending);
1810 } else {
1811 self.insert_with_marks(self.caret, text, pending);
1812 }
1813 return;
1814 }
1815 // `MarkupMode::None`: typed syntax stays literal — twig escapes
1816 // anything that would open markup, so a Diaryx user never mints
1817 // formatting by keyboard (it comes from commands instead). The other two
1818 // rungs of the ladder author markup from what you type, which is the
1819 // whole difference between them and this one. Only in the rendered view
1820 // (source view is for typing raw markup) and only where the format has a
1821 // literal spelling at all: escaping is a backslash before a byte from the
1822 // format's own alphabet, and a format with no such alphabet (HTML escapes
1823 // with entities, XML spells nothing) would have `\&` written into it,
1824 // which is two literal characters and not an escape. Marks (⌘b) still
1825 // format — that path returned above; and leaf's own structural inserts go
1826 // through `insert_raw`, never here, so a list marker or quote gutter is
1827 // written as the markup it is.
1828 if !self.markup_mode.authors()
1829 && self.view == View::Wysiwyg
1830 && !text.is_empty()
1831 && self.supports(Gesture::InsertLiteral)
1832 {
1833 self.insert_literal_typed(text);
1834 return;
1835 }
1836 self.insert_raw(text);
1837 }
1838
1839 /// Insert `text` verbatim at the caret (replacing any selection) — the plain
1840 /// path with no Hidden-mode literal escaping. leaf's own structural inserts
1841 /// (a list marker, a quote gutter, an in-cell `<br>`) call this: they ARE
1842 /// markup by design and must not be escaped.
1843 fn insert_raw(&mut self, text: &str) {
1844 let (s, e) = self.selection().unwrap_or((self.caret, self.caret));
1845 self.splice(s, e, text, typed_edit_kind(text));
1846 }
1847
1848 /// Open a paragraph for text about to be inserted at one of a block media's
1849 /// two caret stops, or at a table's trailing stop, and leave the caret
1850 /// standing in it.
1851 ///
1852 /// A block image is a paragraph whose entire content is the picture, and the
1853 /// caret's only homes on it are in front of it and just past it (see
1854 /// [`VisualMap::block_media_stop`]). Text inserted at either offset joins
1855 /// *that* paragraph — and a paragraph holding anything besides the image is
1856 /// no longer a block image but a line of text with an inline one in it. The
1857 /// frontend that was painting a photo there paints a text run instead; the
1858 /// picture is still in the file, and nothing said a word. Those two offsets
1859 /// are also exactly where a click on the picture lands, so the whole accident
1860 /// is one tap and one keystroke.
1861 ///
1862 /// So the break goes in first and the text lands in the new empty paragraph —
1863 /// what pressing Return before typing would have done, which is a habit no
1864 /// one should have to learn from losing a photo. A no-op everywhere else, and
1865 /// over a selection (which is replaced, not joined into).
1866 ///
1867 /// A picture inside a quote or a list leaves its container, because `\n\n`
1868 /// ends the block. The alternative is worse: the `\n> ` / next-item
1869 /// continuation [`newline`](Self::newline) writes stays in the same
1870 /// *paragraph*, which is the thing being prevented.
1871 ///
1872 /// A table's trailing stop ([`VisualMap::table_end_stop`]) is the same
1873 /// accident from the other side of a different block: the stop sits at the
1874 /// end of the table's last source line, and a line glued under a table is
1875 /// a row of it — `| 1 | 2 |x` is a three-cell row, not a paragraph. So the
1876 /// break goes in there too, and the text lands under the table.
1877 ///
1878 /// Only in the rendered view. Source view is for typing raw markup, where
1879 /// putting a character against an image is exactly what it looks like.
1880 fn open_paragraph_at_block_edge(&mut self, text: &str) {
1881 if self.view != View::Wysiwyg || text.is_empty() || text == "\n" {
1882 return;
1883 }
1884 if self.selection().is_some() {
1885 return;
1886 }
1887 // The map may be a revision behind (nothing has drawn since the last
1888 // edit), and this asks it about offsets — a stale answer would splice a
1889 // break into the wrong place. Free when it is already current, which it
1890 // is whenever a frontend drew a frame between keystrokes.
1891 self.rebuild_map();
1892 let at = self.caret;
1893 let side = match self.vmap.block_media_stop(at) {
1894 Some((side, _)) => side,
1895 None if self.vmap.table_end_stop(at) => MediaStop::After,
1896 None => return,
1897 };
1898 if !self.splice(at, at, "\n\n", EditKind::Other) {
1899 return;
1900 }
1901 // The break is part of the keystroke, not an edit of its own: leave the
1902 // run marked as typing so the character about to arrive folds into it and
1903 // one undo puts the document back the way it was found. (A paste, or a
1904 // multi-character insert, is `EditKind::Other` and stays its own step —
1905 // as it would have been anywhere else in the document.)
1906 self.last_edit_kind = Some(EditKind::Insert);
1907 if side == MediaStop::Before {
1908 // The break went in above the picture and the caret rode to the end
1909 // of it — which is still hard against the picture. Step back onto the
1910 // blank line it opened, so the text lands above rather than in front.
1911 self.caret = at;
1912 }
1913 }
1914
1915 /// A delete key pressed at one of a block picture's two caret stops, handled
1916 /// as the picture being an *atom* rather than a run of bytes. Returns whether
1917 /// the key was consumed.
1918 ///
1919 /// The caret rests in front of a block image and just past it, never inside
1920 /// its markup — which the rendered view doesn't show. So the byte a delete
1921 /// key nominally takes there is one the writer cannot see, and taking it
1922 /// leaves the picture as broken markup rather than as anything anyone asked
1923 /// for: Backspace at the stop past `` removes the closing paren, and
1924 /// a photo becomes the literal text `
1927 /// prevents from the typing side, and it cost this repository's own test vault
1928 /// a photo before it was found.
1929 ///
1930 /// So the key aimed *at* the picture deletes the picture, whole — Backspace
1931 /// when it is behind the caret, Delete when it is in front — which is what
1932 /// every editor does with an embed, and one undo away. The key aimed *away*
1933 /// from it would otherwise delete the paragraph break and merge a neighbour
1934 /// into the picture's own paragraph, which dissolves it just as surely; it
1935 /// steps the caret over the boundary instead and leaves the
1936 /// next press to delete in the block it has reached — the same "first press
1937 /// steps out of the atom, second press deletes" every delete key here gets,
1938 /// word-deletes included (⌥⌫ in front of a picture is aimed at the prose
1939 /// above, and reaches it on the second press rather than taking the break and
1940 /// the picture with it on the first).
1941 fn delete_around_block_media(&mut self, forward: bool) -> bool {
1942 // The map answers about offsets, so it has to be this revision's — see
1943 // the same call in `open_paragraph_at_block_edge`.
1944 self.rebuild_map();
1945 let Some((side, span)) = self.vmap.block_media_stop(self.caret) else {
1946 return false;
1947 };
1948 let aimed_at_it = side
1949 == if forward {
1950 MediaStop::Before
1951 } else {
1952 MediaStop::After
1953 };
1954 if !aimed_at_it {
1955 let over = if forward {
1956 self.vmap.stop_after(self.caret)
1957 } else {
1958 self.vmap.stop_before(self.caret)
1959 };
1960 if let Some(off) = over.filter(|&o| o >= self.caret_floor()) {
1961 self.caret = off;
1962 self.anchor = None;
1963 self.goal_col = None;
1964 }
1965 return true;
1966 }
1967 // Take the break that held the picture apart from its neighbour with it,
1968 // so the delete doesn't leave a blank paragraph standing where the
1969 // picture was. The last arm is a picture that is the whole document.
1970 let (from, to) = if self.source[..span.start].ends_with("\n\n") {
1971 (span.start - 2, span.end)
1972 } else if self.source[span.end..].starts_with("\n\n") {
1973 (span.start, span.end + 2)
1974 } else {
1975 (span.start, span.end)
1976 };
1977 self.splice(from.max(self.caret_floor()), to, "", EditKind::Other);
1978 true
1979 }
1980
1981 /// The Hidden-mode typing path: replace any selection, then insert `text`
1982 /// escaped so it stays literal. When it replaces a selection the two edits
1983 /// fold into one undo step, so an overwrite undoes atomically (and restores
1984 /// the selection) exactly as a plain one does.
1985 fn insert_literal_typed(&mut self, text: &str) {
1986 let kind = typed_edit_kind(text);
1987 match self.selection() {
1988 Some((s, e)) => {
1989 if !self.splice(s, e, "", EditKind::Other) {
1990 return;
1991 }
1992 // Typing over a whole marked run takes its delimiters with it
1993 // (the empty content couldn't hold them — see
1994 // `repair_mark_edges`) and leaves its marks armed at the caret.
1995 // The text taking the run's place inherits them, exactly as it
1996 // would have by landing inside a run that survived.
1997 let pending = self.pending_here();
1998 if !pending.is_empty() && !text.trim().is_empty() {
1999 self.insert_with_marks(self.caret, text, pending);
2000 return;
2001 }
2002 self.insert_literal_at(self.caret, text, kind, true);
2003 }
2004 None => {
2005 self.insert_literal_at(self.caret, text, kind, false);
2006 }
2007 }
2008 }
2009
2010 /// The sticky-mark delta that is live right now: the marks armed by [`toggle`]
2011 /// at a collapsed caret, but only while the caret still stands where they
2012 /// were armed and nothing is selected. Empty otherwise, so a stale delta
2013 /// never styles text it wasn't meant for.
2014 fn pending_here(&self) -> InlineMarks {
2015 if self.anchor.is_none() && self.pending_at == Some(self.caret) {
2016 self.pending_marks
2017 } else {
2018 InlineMarks::empty()
2019 }
2020 }
2021
2022 /// Drop the armed sticky marks — any caret motion, selection, or edit does
2023 /// this, so "start bold here" only ever applies at the exact spot it was
2024 /// asked for.
2025 fn clear_pending(&mut self) {
2026 self.pending_marks = InlineMarks::empty();
2027 self.pending_at = None;
2028 }
2029
2030 /// Insert `text` at `at` carrying the armed sticky `marks`: a mark not yet in
2031 /// force is wrapped around the freshly typed text; a mark the caret already
2032 /// stands inside is *shed* — the text is inserted past the run's end so it
2033 /// lands unmarked ("type normally again"). The caret comes to rest inside any
2034 /// added runs, so continued typing inherits the marks with no re-wrapping,
2035 /// and the delta is cleared: the marks now live in the document, not here.
2036 fn insert_with_marks(&mut self, at: usize, text: &str, marks: InlineMarks) {
2037 let base = self.mark_spans_at(at);
2038 let base_set: InlineMarks = base.iter().map(|(k, _)| *k).collect();
2039 // Nothing to shed, and a run of exactly these marks standing just behind
2040 // the caret: carry on writing *that* run rather than opening a second
2041 // one beside it.
2042 if base_set.is_empty() && self.rejoin_run(at, text, marks) {
2043 return;
2044 }
2045 // Shed the marks we're turning off: step the insertion point past the
2046 // end of each run the caret sits in, so the new text falls outside it.
2047 let mut ins_at = at;
2048 for (kind, span) in &base {
2049 if marks.contains(*kind) {
2050 ins_at = ins_at.max(span.end);
2051 }
2052 }
2053 if !self.splice_exact(ins_at, ins_at, text, EditKind::Other) {
2054 return;
2055 }
2056 // The plain splice inserted exactly `text` at `ins_at`; that byte range
2057 // is the content every added mark wraps.
2058 let (mut cs, mut ce) = (ins_at, ins_at + text.len());
2059 for kind in marks.iter() {
2060 if !base_set.contains(kind) {
2061 let (ncs, nce) = self.wrap_span(cs, ce, kind);
2062 cs = ncs;
2063 ce = nce;
2064 }
2065 }
2066 self.caret = ce.min(self.source.len());
2067 self.anchor = None;
2068 self.last_edit_kind = None;
2069 // Realised: the marks are in the document now, and the caret sits inside
2070 // them, so there is no delta left to carry. Arm nothing, but remember the
2071 // spot so a *further* toggle before typing starts a clean delta here.
2072 self.pending_marks = InlineMarks::empty();
2073 self.pending_at = Some(self.caret);
2074 self.clamp_caret();
2075 self.record_caret();
2076 }
2077
2078 /// Carry on the marked run just behind `at` — moving its closing delimiters
2079 /// out past the new text — instead of opening a second run of the same marks
2080 /// beside it. Returns whether it did.
2081 ///
2082 /// This is the far half of the mark-edge rule (see [`splice`](Self::splice)).
2083 /// A space typed after a bold word steps the caret out of the run, because
2084 /// `**bold **` is not bold; the next character has to step back *in*, or the
2085 /// writer who typed one bold phrase is left with `**bold** **and**` — two
2086 /// runs that read the same to a reader but spell the file in a way nobody
2087 /// wrote. Only whitespace may stand in the gap (a run doesn't reach across
2088 /// words it isn't marking), and the marks behind it must be exactly the ones
2089 /// armed — a run of *some* other kind is a neighbour, not this phrase.
2090 fn rejoin_run(&mut self, at: usize, text: &str, marks: InlineMarks) -> bool {
2091 if text.is_empty() || text.trim() != text {
2092 return false;
2093 }
2094 let gap_at = self.source[..at].trim_end_matches([' ', '\t']).len();
2095 // Walk in through the delimiters stacked at that point, innermost last:
2096 // `***both*** ` closes two runs with one `***`, and rejoining means
2097 // getting behind all of them.
2098 let (mut cut, mut kinds) = (gap_at, InlineMarks::empty());
2099 while let Some((kind, content_end)) = self
2100 .editor
2101 .ancestors_at(prev_boundary(&self.source, cut))
2102 .unwrap_or_default()
2103 .into_iter()
2104 .filter(|m| m.span.end == cut)
2105 .find_map(|m| Some((inline_kind(&m.kind)?, m.content_span.clone()?.end)))
2106 {
2107 if content_end >= cut {
2108 break; // a mark with no closing delimiter to step behind
2109 }
2110 kinds.insert(kind);
2111 cut = content_end;
2112 }
2113 if cut == gap_at || kinds != marks {
2114 return false;
2115 }
2116 // Re-spell the tail: the gap, then the new text, then the delimiters that
2117 // used to close in front of them — read out of the document rather than
2118 // written from a table, so whatever twig spells them with is what moves.
2119 let tail = format!(
2120 "{}{text}{}",
2121 &self.source[gap_at..at],
2122 &self.source[cut..gap_at]
2123 );
2124 if !self.splice_exact(cut, at, &tail, EditKind::Other) {
2125 return false;
2126 }
2127 self.caret = (cut + (at - gap_at) + text.len()).min(self.source.len());
2128 self.anchor = None;
2129 self.last_edit_kind = None;
2130 self.pending_marks = InlineMarks::empty();
2131 self.pending_at = Some(self.caret);
2132 self.clamp_caret();
2133 self.record_caret();
2134 true
2135 }
2136
2137 /// Insert typed whitespace at a caret with sticky marks armed. Whitespace is
2138 /// never itself wrapped: a mark around a space draws nothing a reader can
2139 /// see, and in Markdown and Djot it draws its own delimiters instead
2140 /// (`** **`). So the space goes in unmarked — outside any run the armed
2141 /// marks are shedding — and the marks stay armed for the character after it,
2142 /// which rejoins the run (see [`rejoin_run`](Self::rejoin_run)).
2143 fn insert_space_with_marks(&mut self, at: usize, text: &str, marks: InlineMarks) {
2144 let base = self.mark_spans_at(at);
2145 // What the *next* character carries: the armed delta resolved against the
2146 // marks in force here, which the space must not quietly drop.
2147 let want = base
2148 .iter()
2149 .map(|(k, _)| *k)
2150 .collect::<InlineMarks>()
2151 .xor(marks);
2152 let mut ins_at = at;
2153 for (kind, span) in &base {
2154 if marks.contains(*kind) {
2155 ins_at = ins_at.max(span.end);
2156 }
2157 }
2158 if !self.splice(ins_at, ins_at, text, typed_edit_kind(text)) {
2159 return;
2160 }
2161 self.rearm(want);
2162 self.record_caret();
2163 }
2164
2165 /// Wrap `[s, e)` in `kind` via twig and return the byte span the *content*
2166 /// (not the delimiters) occupies afterwards. Markdown/Djot inline delimiters
2167 /// are symmetric (`**`…`**`, `_`…`_`, `` ` ``…`` ` ``), so the bytes twig
2168 /// added split evenly around the content — half the growth on each side.
2169 fn wrap_span(&mut self, s: usize, e: usize, kind: InlineKind) -> (usize, usize) {
2170 // The read-only gate — this door reaches twig without the splice.
2171 if self.read_only {
2172 return (s, e);
2173 }
2174 match self.editor.toggle_inline(s, e, kind) {
2175 Ok(change) => {
2176 self.last_edit_kind = None;
2177 self.refresh();
2178 self.dirty = self.source != self.clean_source;
2179 let added = (change.new.end - change.new.start).saturating_sub(e - s);
2180 let half = added / 2;
2181 (change.new.start + half, change.new.end - half)
2182 }
2183 // Unsupported here (e.g. mark on Markdown): leave the text unwrapped
2184 // rather than lose the keystroke.
2185 Err(e2) => {
2186 self.status = Some(format!("{kind:?}: {e2}"));
2187 (s, e)
2188 }
2189 }
2190 }
2191
2192 /// The safe offset to splice a block-level break at, given a caret that may
2193 /// sit exactly between an inline mark's content and its own closing
2194 /// delimiter (`content_span.end == off < span.end` for some enclosing mark
2195 /// — the WYSIWYG caret's natural resting place at the end of `**bold**`
2196 /// with nothing following it on the line: the closing `**` renders no
2197 /// glyph of its own, so the caret's "end of line" offset lands right
2198 /// before it). Splicing a paragraph/list/quote break at `off` itself would
2199 /// sever the delimiter from its content, stranding it alone on the new
2200 /// line. Walks out to the *outermost* such mark's `span.end` instead, so
2201 /// nested marks closing at the same point (`**_x_**`) all clear together.
2202 /// A no-op everywhere else — mid-run, or past real trailing content, no
2203 /// mark's `content_span` ends exactly at `off`.
2204 fn skip_trailing_close_delims(&mut self, off: usize) -> usize {
2205 let off = off.min(self.source.len());
2206 let runs = self.run_span_ids();
2207 self.editor
2208 .ancestors_at(off)
2209 .unwrap_or_default()
2210 .into_iter()
2211 .filter(|m| hides_delims(m, &runs))
2212 .filter(|m| off < m.span.end && m.content_span.as_ref().is_some_and(|c| c.end == off))
2213 .map(|m| m.span.end)
2214 .max()
2215 .unwrap_or(off)
2216 }
2217
2218 /// The offset a *delete* aimed at the character before `off` should stop at,
2219 /// when `off` is the start of a run's text and the bytes behind it are that
2220 /// run's opening delimiter. The rich view draws no glyph for a `**`, so the
2221 /// byte behind the caret at the start of a bold word is not a character the
2222 /// writer can see, let alone one they aimed Backspace at: taking it leaves
2223 /// `a *bold** c` — the styling gone and a literal asterisk in its place. The
2224 /// delete steps over the whole delimiter to the visible character in front of
2225 /// it instead. Walks out to the *outermost* mark opening there, so
2226 /// `**_x_**` clears every delimiter at once, and is a no-op anywhere else.
2227 fn skip_leading_open_delims(&mut self, off: usize) -> usize {
2228 let off = off.min(self.source.len());
2229 let runs = self.run_span_ids();
2230 self.editor
2231 .ancestors_at(off)
2232 .unwrap_or_default()
2233 .into_iter()
2234 .filter(|m| hides_delims(m, &runs))
2235 .filter(|m| {
2236 m.span.start < off && m.content_span.as_ref().is_some_and(|c| c.start == off)
2237 })
2238 .map(|m| m.span.start)
2239 .min()
2240 .unwrap_or(off)
2241 }
2242
2243 /// `off` moved *inside* the run whose closing delimiters end there — the
2244 /// other offset the rich view draws in the same place, since a `**` renders
2245 /// no glyph of its own. `**bold**` has a caret home on each side of its
2246 /// closing delimiter, one column apart on screen and eight bytes and a whole
2247 /// run apart in the file, and a plain ← lands on the outer one whenever a
2248 /// space follows the phrase. The inner one is what the writer is pointing at
2249 /// there: the end of their bold word. Walks in through every mark closing at
2250 /// that point, innermost last, so `***both***` lands inside both. A no-op
2251 /// anywhere else — mid-run, or in prose, no mark's span ends at `off`.
2252 fn step_inside_close_delims(&mut self, off: usize) -> usize {
2253 let mut off = off.min(self.source.len());
2254 let runs = self.run_span_ids();
2255 loop {
2256 let inner = self
2257 .editor
2258 .ancestors_at(prev_boundary(&self.source, off))
2259 .unwrap_or_default()
2260 .into_iter()
2261 .filter(|m| hides_delims(m, &runs) && m.span.end == off)
2262 .filter_map(|m| m.content_span.clone().map(|c| c.end))
2263 .filter(|&end| end < off)
2264 .max();
2265 match inner {
2266 Some(end) => off = end,
2267 None => return off,
2268 }
2269 }
2270 }
2271
2272 /// The mirror at the opening edge: `off` moved inside the run whose
2273 /// delimiters *start* there, onto the first character of its text. See
2274 /// [`step_inside_close_delims`](Self::step_inside_close_delims).
2275 fn step_inside_open_delims(&mut self, off: usize) -> usize {
2276 let mut off = off.min(self.source.len());
2277 let runs = self.run_span_ids();
2278 loop {
2279 let inner = self
2280 .editor
2281 .ancestors_at(off)
2282 .unwrap_or_default()
2283 .into_iter()
2284 .filter(|m| hides_delims(m, &runs) && m.span.start == off)
2285 .filter_map(|m| m.content_span.clone().map(|c| c.start))
2286 .filter(|&start| start > off)
2287 .min();
2288 match inner {
2289 Some(start) => off = start,
2290 None => return off,
2291 }
2292 }
2293 }
2294
2295 /// The ids of the document's attributed run spans — the inline
2296 /// `Container`s [`wysiwyg::is_run_span`] picks out — for [`hides_delims`],
2297 /// which sees an ancestor chain and so only a kind. Read once per gesture,
2298 /// not once per step of a walk.
2299 fn run_span_ids(&mut self) -> Vec<NodeId> {
2300 self.nodes()
2301 .iter()
2302 .filter(|n| wysiwyg::is_run_span(n))
2303 .map(|n| n.id)
2304 .collect()
2305 }
2306
2307 /// The attributed span whose text is exactly `content` — the whole of
2308 /// `<span …>i</span>`'s `i`, or nothing at all when `content` is empty
2309 /// and sits between the tags of `<span …></span>` — as the whole range
2310 /// spelling the span: the node's span, widened to its attribute block
2311 /// where the format writes that outside the node, as djot's
2312 /// `[i]{data-size="large"}` does. `None` for any other range, including
2313 /// part of a span's text.
2314 ///
2315 /// An empty span has an interior of no bytes, or no known interior at
2316 /// all: twig gives Markdown's `<span …></span>` the first and djot's
2317 /// `[]{…}` the second, and the chain already says the offset is inside.
2318 fn run_span_of_content(&mut self, content: Range<usize>) -> Option<Range<usize>> {
2319 let runs = self.run_span_ids();
2320 let m = self
2321 .editor
2322 .ancestors_at(content.start)
2323 .unwrap_or_default()
2324 .into_iter()
2325 .filter(|m| runs.contains(&NodeId(m.node_id)))
2326 .find(|m| match &m.content_span {
2327 Some(c) => *c == content,
2328 None => content.is_empty(),
2329 })?;
2330 let mut range = m.span;
2331 if let Some(attrs) = self
2332 .editor
2333 .document()
2334 .ok()
2335 .and_then(|mut d| d.attrs_span(NodeId(m.node_id)).ok().flatten())
2336 {
2337 range.start = range.start.min(attrs.start);
2338 range.end = range.end.max(attrs.end);
2339 }
2340 Some(range)
2341 }
2342
2343 /// The attributed block whose whole text is exactly `content` — the `T`
2344 /// of Markdown's `<div class="center">\n\nT\n\n</div>` or djot's
2345 /// `{.center}\nT` — as the range a delete that takes that text takes with
2346 /// it: the whole `<div>` when the block is all the div holds, or the
2347 /// `{…}` line down to the end of the text. The block version of
2348 /// [`run_span_of_content`](Self::run_span_of_content), for the same
2349 /// reason: a paragraph with no text is no block, so the div would stand
2350 /// around nothing and the `{…}` line above nothing, and a from-scratch
2351 /// map gives neither a caret home — the `T`'s row is gone with the `T`.
2352 /// `None` for a block with more text, a div holding more, a heading (an
2353 /// empty `# ` is still a heading), and a format whose attributes are the
2354 /// block's own tag (HTML's `<p class="center"></p>` is still a
2355 /// paragraph).
2356 fn attributed_block_of_content(&mut self, content: Range<usize>) -> Option<Range<usize>> {
2357 if content.is_empty() || !matches!(self.format, Format::Markdown | Format::Djot) {
2358 return None;
2359 }
2360 let nodes = self.nodes();
2361 let block = nodes
2362 .iter()
2363 .filter(|n| n.kind == Kind::Para)
2364 .find(|n| n.content_span.as_ref() == Some(&content))?;
2365 match self.format {
2366 Format::Djot => {
2367 let attrs = self
2368 .editor
2369 .document()
2370 .ok()
2371 .and_then(|mut d| d.attrs_span(block.id).ok().flatten())?;
2372 (attrs.end <= block.span.start).then_some(attrs.start..content.end)
2373 }
2374 _ => {
2375 let div = block
2376 .parent
2377 .and_then(|p| nodes.iter().find(|n| n.id == p))
2378 .filter(|p| wysiwyg::element_tag(p) == Some("div"))?;
2379 let alone = nodes.iter().filter(|n| n.parent == Some(div.id)).count() == 1;
2380 alone.then(|| div.span.clone())
2381 }
2382 }
2383 }
2384
2385 /// The inline mark kinds whose span covers `off`, each with that span — the
2386 /// span-carrying sibling of [`marks_at`](Self::marks_at), which reports node
2387 /// ids instead. Used to shed a mark by stepping past the end of its run.
2388 fn mark_spans_at(&mut self, off: usize) -> Vec<(InlineKind, std::ops::Range<usize>)> {
2389 let off = off.min(self.source.len());
2390 self.editor
2391 .ancestors_at(off)
2392 .unwrap_or_default()
2393 .into_iter()
2394 .filter(|m| off < m.span.end)
2395 .filter_map(|m| inline_kind(&m.kind).map(|k| (k, m.span.clone())))
2396 .collect()
2397 }
2398
2399 /// Insert clipboard `text` at the caret, replacing the selection if there is
2400 /// one — always its own undo step, whatever its length.
2401 ///
2402 /// Provenance is the whole point, and only the caller has it. `insert` reads
2403 /// a lone character as a keystroke and folds it into the run around it,
2404 /// which is right for typing and wrong for a one-character paste: that paste
2405 /// would vanish mid-run on an undo it was never part of, and the characters
2406 /// the user actually typed would go with it. Length can't tell the two
2407 /// apart — `⌘V` of `x` and typing `x` are the same string — so the door the
2408 /// caller comes through is what says which happened.
2409 pub fn paste(&mut self, text: &str) {
2410 // Pasting against a block picture or a table's end joins the block
2411 // exactly as typing does, and for the same reason — see
2412 // `open_paragraph_at_block_edge`.
2413 self.open_paragraph_at_block_edge(text);
2414 let (s, e) = self.selection().unwrap_or((self.caret, self.caret));
2415 self.splice(s, e, text, EditKind::Other);
2416 }
2417
2418 /// Replace `[start, end)` with `text` as one step of an IME composition —
2419 /// the same splice as [`edit`](Self::edit), but marked so the run of steps
2420 /// folds into a single undo.
2421 ///
2422 /// A composition is *one* act of writing. Typing `かんじ` and picking 感じ is a
2423 /// dozen calls here, each replacing the last one's provisional bytes, and an
2424 /// undo step per call means undoing a word means pressing ⌘Z until the reading
2425 /// unspools backwards through kana — the intermediate states were never text
2426 /// the user wrote. Only the frontend knows a call is provisional (the bytes
2427 /// look like any other edit), so the door the caller comes through is what
2428 /// says so, exactly as it is for [`paste`](Self::paste) versus
2429 /// [`insert`](Self::insert).
2430 ///
2431 /// Pair with [`end_composition`](Self::end_composition), or the *next*
2432 /// composition folds into this one.
2433 pub fn edit_composing(&mut self, start: usize, end: usize, text: &str) {
2434 self.splice(start, end, text, EditKind::Compose);
2435 }
2436
2437 /// Close the open composition run, so the next one is its own undo step.
2438 /// Call when the IME commits or withdraws a composition.
2439 ///
2440 /// Only clears a *composition* run: a frontend that reports an end it never
2441 /// began (some IMEs unmark unprompted) would otherwise split the run of
2442 /// typing around it into two undo steps for no reason the user can see.
2443 pub fn end_composition(&mut self) {
2444 if self.last_edit_kind == Some(EditKind::Compose) {
2445 self.last_edit_kind = None;
2446 }
2447 }
2448
2449 // ── the clipboard's rich flavor ──────────────────────────────────────────
2450
2451 /// The selection rendered as HTML, for the clipboard's `text/html` flavor —
2452 /// what lets a paste into Docs/Mail/Slack keep its formatting. `None` when
2453 /// nothing is selected, or when the selection doesn't render (the caller
2454 /// still has [`selected_text`](Self::selected_text), which is what to publish
2455 /// as `text/plain` either way).
2456 ///
2457 /// **The fragment is a source substring, and that is the honest limit here.**
2458 /// It's parsed standalone, so a selection whose meaning depends on its
2459 /// surroundings converts as what it literally says rather than what it looks
2460 /// like on screen: half a list item is a paragraph, a row torn out of a table
2461 /// is the text of a row, the `**` of a bold run selected without its closing
2462 /// `**` is two asterisks. Every one of those still *renders* — there's no
2463 /// error to report — it just renders as the fragment and not as the document.
2464 /// Widening the range to whole blocks would publish text the user didn't
2465 /// select, which is a worse lie than a fragment being a fragment; the plain
2466 /// flavor has the same substring, so the two flavors at least agree.
2467 pub fn selection_html(&mut self) -> Option<String> {
2468 let (start, end) = self.selection()?;
2469 let inline = self.selection_is_inline(start, end);
2470 let html = html::render_fragment(&self.source[start..end], self.format)?;
2471 Some(match inline {
2472 true => html::strip_sole_paragraph(html),
2473 false => html,
2474 })
2475 }
2476
2477 /// Paste the clipboard's `text/html` flavor, converting it to this document's
2478 /// format first. Its own undo step, like any [`paste`](Self::paste).
2479 ///
2480 /// Returns whether it landed. `false` means the HTML didn't convert to
2481 /// anything worth pasting — the caller should fall back to the plain flavor
2482 /// rather than treat it as an error. The `html` module has the full list of
2483 /// what that covers: a table twig won't build, markup it doesn't recognise,
2484 /// an empty result.
2485 pub fn paste_html(&mut self, html: &str) -> bool {
2486 match html::parse_fragment(html, self.format) {
2487 Some(source) => {
2488 self.paste(&source);
2489 true
2490 }
2491 None => false,
2492 }
2493 }
2494
2495 /// Does the selection live *inside* a single top-level block?
2496 ///
2497 /// The question [`selection_html`](Self::selection_html) needs and the
2498 /// fragment can't answer: `**bold**` renders as `<p><strong>bold</strong></p>`
2499 /// whether the user selected one word of a sentence or a whole paragraph, and
2500 /// only the document knows which. Selecting a word and pasting into Docs
2501 /// should extend the line you paste into; selecting the paragraph should make
2502 /// a paragraph. So a selection strictly within one block is inline (its `<p>`
2503 /// is an artifact of standalone parsing), and one that covers a whole block —
2504 /// or spans two — keeps its structure.
2505 ///
2506 /// Reads the block from twig rather than guessing from the bytes:
2507 /// `ancestors_at` is `[doc, block, …inline]`, so index 1 is the top-level
2508 /// block containing an offset, and two ends inside the same one cannot have
2509 /// crossed a block boundary.
2510 fn selection_is_inline(&mut self, start: usize, end: usize) -> bool {
2511 // The last *character*, not `end - 1`: the selection's end is exclusive
2512 // and may sit mid-codepoint's-worth of bytes past the last char.
2513 let Some((off, _)) = self.source[start..end].char_indices().next_back() else {
2514 return false;
2515 };
2516 let (Some(head), Some(tail)) =
2517 (self.top_block_span(start), self.top_block_span(start + off))
2518 else {
2519 return false;
2520 };
2521 head == tail && !(start <= head.start && end >= head.end)
2522 }
2523
2524 /// The byte span of the top-level block containing `offset`, or `None` at an
2525 /// offset that belongs to no block (the blank line between two of them).
2526 fn top_block_span(&mut self, offset: usize) -> Option<std::ops::Range<usize>> {
2527 self.editor
2528 .ancestors_at(offset)
2529 .ok()?
2530 .get(1)
2531 .map(|m| m.span.clone())
2532 }
2533
2534 // ── indentation ──────────────────────────────────────────────────────────
2535
2536 /// One indent level.
2537 ///
2538 /// Two spaces, not the four both frontends type for Tab today, because in a
2539 /// markdown document four columns isn't a width — it's a *meaning*. Four
2540 /// spaces at the head of a line is markdown's indented-code-block marker, so
2541 /// one Tab on a paragraph would reparse it into code and style it as such;
2542 /// two cannot, and the line stays the prose it was. Two is also exactly
2543 /// where a `- ` bullet's content starts, so an indented line lands under its
2544 /// parent item's text instead of beside it — the column a list-aware indent
2545 /// has to hit anyway, which keeps this width from being relitigated later.
2546 const INDENT: &'static str = " ";
2547
2548 /// Indent the selected lines — or the caret's line, with no selection — by
2549 /// one level (Tab).
2550 pub fn indent(&mut self) {
2551 self.reindent(true);
2552 // Nesting changes an ordered list's numbering (the nested item restarts,
2553 // its old siblings resume) — keep the source markers in step.
2554 self.renumber_here();
2555 // Nesting an empty `-` item under a text line reparses that text as a
2556 // setext heading; swap the dash for a `*` before it can (a no-op unless
2557 // the collapse actually happened).
2558 self.avoid_setext_collapse();
2559 }
2560
2561 /// Take one indent level back off the selected lines, or the caret's line
2562 /// (Shift+Tab). A line with no indentation is left exactly as it is.
2563 ///
2564 /// A line with *less* than a full level gives back what it has rather than
2565 /// refusing: outdent's job is to walk a line left, and real documents — hand
2566 /// written, or reflowed by some other editor — are full of indentation that
2567 /// was never a clean multiple of anything. Refusing there would strand the
2568 /// line at a depth Shift+Tab couldn't undo.
2569 pub fn outdent(&mut self) {
2570 self.reindent(false);
2571 self.renumber_here();
2572 }
2573
2574 /// The body of [`indent`](Self::indent) / [`outdent`](Self::outdent).
2575 ///
2576 /// One splice across the whole line range, never one per line: a Tab is one
2577 /// thing the user did, so it has to be one undo step and one reparse. Per
2578 /// line, twig would reparse the document once per line and leave a stack of
2579 /// steps that Shift+⌘Z walks back one line at a time.
2580 fn reindent(&mut self, add: bool) {
2581 let (sel_start, sel_end) = self.selection().unwrap_or((self.caret, self.caret));
2582 let start = source_line_range(&self.source, sel_start).start;
2583 let end = source_line_range(&self.source, sel_end).end;
2584 let region = self.source[start..end].to_string();
2585 let lines: Vec<&str> = region.split('\n').collect();
2586 // A blank line has no text to move, and padding it would leave nothing
2587 // but trailing whitespace — but Tab on a blank line *is* a request for
2588 // indentation to type into, so the skip only applies where the op has
2589 // other lines to do real work on.
2590 let skip_blank = add && lines.len() > 1;
2591
2592 let mut out = String::with_capacity(region.len() + lines.len() * Self::INDENT.len());
2593 let mut deltas: Vec<isize> = Vec::with_capacity(lines.len());
2594 let mut line_off = start;
2595 for (i, full) in lines.iter().enumerate() {
2596 if i > 0 {
2597 out.push('\n');
2598 }
2599 // A list item moves by having its whole leading prefix *replaced*,
2600 // never by having spaces pushed in front of the line. twig spells
2601 // both prefixes, so the quote markers, the parent's indent and an
2602 // ordered marker's extra column all come out right without leaf
2603 // measuring any of them — and a line that only looks like an item
2604 // (a Djot continuation) reports no marker and is left to the plain
2605 // path, where a Tab is just a Tab.
2606 let marker = self.list_marker_on_line(line_off);
2607 let own = marker
2608 .as_ref()
2609 .map(|m| m.marker_start - m.line_start)
2610 .unwrap_or(0);
2611 let delta = if add {
2612 if skip_blank && full.trim().is_empty() {
2613 out.push_str(full);
2614 0
2615 } else if marker.is_some() && self.first_item_of_list(line_off) {
2616 // The first item of a list has no preceding sibling to nest
2617 // under, so a Tab here can't spell a sub-list — twig would
2618 // reparse the shoved-over marker as the same list, only
2619 // indented, which Shift+Tab then can't cleanly undo. Leave the
2620 // item where it is, the way every list editor refuses to
2621 // over-indent a list's first line.
2622 out.push_str(full);
2623 0
2624 } else if marker.is_some() {
2625 // Nesting means standing where a *continuation* of this line
2626 // would stand: past the parent's marker, inside its content
2627 // column. That is `continuation_prefix`, less a checkbox.
2628 let new = self.nesting_prefix_at(line_off);
2629 let delta = new.len() as isize - own as isize;
2630 out.push_str(&new);
2631 out.push_str(&full[own..]);
2632 delta
2633 } else {
2634 out.push_str(Self::INDENT);
2635 out.push_str(full);
2636 Self::INDENT.len() as isize
2637 }
2638 } else if marker.is_some() {
2639 // Unnesting is the mirror: stand where the parent item's own
2640 // line starts, which drops exactly the level it contributed.
2641 let new = self.outdent_prefix_at(line_off);
2642 let delta = new.len() as isize - own as isize;
2643 out.push_str(&new);
2644 out.push_str(&full[own..]);
2645 delta
2646 } else {
2647 // A plain line gives back the ordinary step.
2648 let strip = outdent_width(full, Self::INDENT.len());
2649 out.push_str(&full[strip..]);
2650 -(strip as isize)
2651 };
2652 deltas.push(delta);
2653 line_off += full.len() + 1;
2654 }
2655 // Nothing to give back. Returning before the splice keeps an outdent at
2656 // column zero from spending an undo step on a document it never changed.
2657 if deltas.iter().all(|d| *d == 0) {
2658 return;
2659 }
2660
2661 // Every line's text keeps its offset *within the line*, so the caret is
2662 // remapped by its column, not by its byte offset — which the prefixes on
2663 // the lines above it have already invalidated.
2664 let remap = |off: usize| -> usize {
2665 let (mut old_ls, mut new_ls) = (start, start);
2666 for (line, delta) in lines.iter().zip(&deltas) {
2667 let old_le = old_ls + line.len();
2668 let new_len = (line.len() as isize + delta) as usize;
2669 if off <= old_le {
2670 let col = (off - old_ls) as isize;
2671 return new_ls + ((col + delta).max(0) as usize).min(new_len);
2672 }
2673 old_ls = old_le + 1;
2674 new_ls += new_len + 1;
2675 }
2676 start + out.len()
2677 };
2678 let placed = match self.selection() {
2679 // Keep the rewritten region selected, the way a container toggle
2680 // keeps its own: it leaves a second Tab aimed at the same lines
2681 // rather than at whatever the shifted offsets now happen to cover.
2682 Some(_) => (start + out.len(), Some(start)),
2683 None => (remap(self.caret), None),
2684 };
2685
2686 // A rolled-back splice leaves the old source in place, where every offset
2687 // computed above addresses text that was never written.
2688 if !self.splice(start, end, &out, EditKind::Other) {
2689 return;
2690 }
2691 // `splice` re-anchors to the end of the `Change`, which for a whole-region
2692 // rewrite is the last line's end — nowhere the caret was. Place it, then
2693 // re-record the caret so this is the state redo restores, not the one
2694 // `splice` left behind from the `Change`.
2695 self.caret = placed.0.min(self.source.len());
2696 self.anchor = placed.1;
2697 self.clamp_caret();
2698 self.record_caret();
2699 }
2700
2701 /// The Enter key.
2702 ///
2703 /// In source view it's a literal newline. In WYSIWYG it's **AST-aware**: a
2704 /// bare `\n` is only a markdown soft break (same paragraph), so the block the
2705 /// caret is in decides what actually gets written.
2706 ///
2707 /// - paragraph → twig's [`Editor::split_block`], which parts the
2708 /// block at the caret and reopens its container
2709 /// - list item → likewise: the next item, its indent, quote
2710 /// prefix and `[ ]` box all reproduced by twig —
2711 /// except an *empty* item, which exits the list
2712 /// - block quote → likewise: a new paragraph inside the quote
2713 /// - heading → a new *paragraph*, not another heading
2714 /// - code block → a literal newline (stay in the block)
2715 /// - blank line → a literal newline (one Backspace undoes it)
2716 /// - [`LineFlow::Preserve`] → a single soft break, which renders as a
2717 /// visible line
2718 ///
2719 /// Where `split_block` is used it replaces markup leaf used to spell by hand,
2720 /// and it is better at it: it drops the whitespace the caret was sitting in
2721 /// front of instead of stranding it at the head of the second half, and it
2722 /// knows continuations leaf's marker scan never covered — a checklist item
2723 /// continues as an *unchecked* checklist item rather than a plain bullet.
2724 ///
2725 /// The exceptions above are exceptions because `split_block` is either wrong
2726 /// there or refuses: parting a fence yields two fences with the code split
2727 /// between them, parting a heading yields a second heading where every editor
2728 /// gives a paragraph, and a blank line, an empty item, a setext heading and a
2729 /// table all report an error rather than a split.
2730 pub fn newline(&mut self) {
2731 if self.view == View::Source {
2732 self.insert_raw("\n");
2733 return;
2734 }
2735 // Enter over a selection replaces it with a paragraph break.
2736 if let Some((s, e)) = self.selection() {
2737 self.splice(s, e, "\n\n", EditKind::Other);
2738 return;
2739 }
2740 // A caret resting exactly between an inline mark's content and its own
2741 // closing delimiter (`**bold**` with nothing after it on the line —
2742 // the WYSIWYG caret's natural end-of-line position) must not splice a
2743 // block break there: every path below eventually does via
2744 // `insert_raw`/`self.caret`, and splicing before the hidden closing
2745 // delimiter would strand it alone on the new line.
2746 self.caret = self.skip_trailing_close_delims(self.caret);
2747 // The block the caret is in. `block_offset_for_caret` nudges off a line
2748 // end (where the caret sits at the doc level); on a bare line (e.g. an
2749 // empty list item) fall back to the caret so the enclosing list/quote is
2750 // still visible in the ancestors.
2751 let off = self.block_offset_for_caret().unwrap_or(self.caret);
2752 let kinds: Vec<Kind> = self
2753 .editor
2754 .ancestors_at(off)
2755 .map(|c| c.into_iter().map(|m| m.kind).collect())
2756 .unwrap_or_default();
2757 let has = |k: Kind| kinds.contains(&k);
2758
2759 if has(Kind::CodeBlock) {
2760 self.insert_raw("\n");
2761 return;
2762 }
2763 // An *empty* list item exits the list — the standard double-Enter — which
2764 // `split_block` reports as an error rather than a split (there is no
2765 // content to part), so it stays leaf's. `list_marker_on_line` is itself
2766 // the AST gate — it answers from the tree, so a `- ` that reads as a
2767 // marker byte-for-byte but opens no item (a setext underline, a Djot
2768 // continuation line) never reaches here.
2769 if let Some(marker) = self.list_marker_on_line(self.caret)
2770 && self.item_is_empty(&marker)
2771 {
2772 self.exit_list(&marker);
2773 return;
2774 }
2775 // On an *empty* paragraph line, a lone Enter should add a single blank line,
2776 // not another full paragraph break — so it moves down one line and one
2777 // Backspace undoes it, not two. (`split_block` errors here too.)
2778 let line_start = self.source[..self.caret].rfind('\n').map_or(0, |i| i + 1);
2779 let line_end = self.source[self.caret..]
2780 .find('\n')
2781 .map_or(self.source.len(), |i| self.caret + i);
2782 if self.source[line_start..line_end].trim().is_empty() {
2783 self.insert_raw("\n");
2784 return;
2785 }
2786 // In `Preserve` flow a soft break is a *visible* line the author means to
2787 // make, so Enter writes a single `\n` and typing continues the same
2788 // paragraph on the next line — the behaviour of an ordinary text editor.
2789 // A second Enter then lands on the blank line above and takes the
2790 // empty-line branch, so double-Enter still promotes to a full paragraph
2791 // break; and Backspace, which deletes a lone `\n` over a soft break,
2792 // undoes a single Enter symmetrically. In `Fold` flow a lone `\n` would
2793 // render as an invisible space, so Enter keeps making the paragraph break
2794 // that actually shows.
2795 //
2796 // Only in running prose. A list or a quote has a continuation of its own
2797 // to write, and a `\n` there is not a soft line but a lost container.
2798 let in_container = has(Kind::ListItem) || has(Kind::TaskListItem) || has(Kind::BlockQuote);
2799 if self.line_flow == LineFlow::Preserve && !in_container {
2800 self.insert_raw("\n");
2801 return;
2802 }
2803 // A heading gets a *paragraph*, never a second heading: Enter at the end
2804 // of a title is how every editor is asked for the body under it, and
2805 // `split_block` would repeat the `#` instead. Whitespace at the split
2806 // point goes with the break rather than opening the new paragraph, which
2807 // is what `split_block` does everywhere else.
2808 if has(Kind::Heading) {
2809 let mut end = self.caret;
2810 while self.source.as_bytes().get(end) == Some(&b' ') {
2811 end += 1;
2812 }
2813 self.splice(self.caret, end, "\n\n", EditKind::Other);
2814 return;
2815 }
2816 self.split_block_here();
2817 }
2818
2819 /// Part the block at the caret with twig's [`Editor::split_block`], leaving
2820 /// the caret in the second half.
2821 ///
2822 /// twig reopens whatever the first half was inside of — the bullet with its
2823 /// indent, the quote's `>`, a checklist item's `[ ]` — which is the whole
2824 /// reason this replaced the markup leaf used to spell from the line's bytes.
2825 /// It renumbers nothing, though: a new item mid-list is written with its
2826 /// neighbour's number, so [`renumber_here`](Self::renumber_here) still runs
2827 /// behind it, folded into the same undo step.
2828 ///
2829 /// Falls back to a plain paragraph break if twig declines, so an unhandled
2830 /// shape still moves the caret down rather than swallowing the keystroke.
2831 fn split_block_here(&mut self) {
2832 // The read-only gate — this door reaches twig without the splice.
2833 if self.read_only {
2834 return;
2835 }
2836 match self.editor.split_block(self.caret) {
2837 Ok(change) => {
2838 self.last_edit_kind = None;
2839 self.refresh();
2840 self.anchor = None;
2841 self.caret = change.new.end;
2842 self.dirty = self.source != self.clean_source;
2843 self.status = None;
2844 self.clamp_caret();
2845 self.record_caret();
2846 // Aimed at the new block's *start*: the caret twig leaves is one
2847 // past the marker it wrote, where there is no list in reach.
2848 self.renumber_at(change.new.start);
2849 }
2850 Err(_) => self.insert_raw("\n\n"),
2851 }
2852 }
2853
2854 /// Whether the item on the marker's line carries no content — the shape
2855 /// double-Enter reads as "I'm done with this list."
2856 fn item_is_empty(&self, line: &ListMarker) -> bool {
2857 let content_start = line.content_start().min(self.source.len());
2858 let line_end = self.source[self.caret..]
2859 .find('\n')
2860 .map(|i| self.caret + i)
2861 .unwrap_or(self.source.len());
2862 self.source[content_start..line_end.max(content_start)]
2863 .trim()
2864 .is_empty()
2865 }
2866
2867 /// Leave the list: replace the empty item's marker with a blank line, so the
2868 /// caret lands in a fresh paragraph below it.
2869 ///
2870 /// Inside a quote the blank line has to stay quoted (a bare one would end the
2871 /// quote), and the caret's new line keeps the `> ` it was already behind —
2872 /// leaving the list without also leaving the quote.
2873 fn exit_list(&mut self, line: &ListMarker) {
2874 let prefix = self.quote_prefix_at(line.marker_start);
2875 let blank = prefix.trim_end();
2876 self.splice(
2877 line.line_start,
2878 self.caret,
2879 &format!("{blank}\n{prefix}"),
2880 EditKind::Other,
2881 );
2882 }
2883
2884 /// What a line continuing the containers at `off` has to open with — the
2885 /// quote markers reproduced, each enclosing item's marker as its width in
2886 /// spaces. Also the column a nested item's marker stands in, which is what
2887 /// makes it Tab's answer.
2888 fn continuation_prefix_at(&mut self, off: usize) -> String {
2889 self.editor
2890 .document()
2891 .and_then(|mut d| d.continuation_prefix(off))
2892 .map(|p| p.text)
2893 .unwrap_or_default()
2894 }
2895
2896 /// The column a *nested list* may open at inside the item at `off` — which
2897 /// is not always where the item's own text continues.
2898 ///
2899 /// twig counts a task item's `[ ] ` box as part of its marker, correctly:
2900 /// it is markup a rich view hides, and the item's own wrapped text does
2901 /// stand past it. But a nested list may only open at the *list* marker's
2902 /// column, and four columns further in is an indented continuation of the
2903 /// paragraph instead — `- [ ] a` + ` - [ ] b` is one item, not two.
2904 /// So the box's own width goes back.
2905 ///
2906 /// The one place leaf still reads a checkbox's spelling. It goes when twig
2907 /// reports the list marker's column apart from the box; `checked` is what
2908 /// says a box is there at all, so only its width is being measured here.
2909 fn nesting_prefix_at(&mut self, off: usize) -> String {
2910 let cont = self.continuation_prefix_at(off);
2911 let Some(item) = self.innermost_list_item(off) else {
2912 return cont;
2913 };
2914 if item.checked.is_none() {
2915 return cont;
2916 }
2917 let box_width = item
2918 .marker_span
2919 .and_then(|m| self.source.get(m))
2920 .and_then(|marker| marker.rfind('[').map(|i| marker.len() - i))
2921 .unwrap_or(0);
2922 // The trailing columns are the ones the item's own marker contributed,
2923 // so trimming from the end leaves any quote prefix standing.
2924 cont[..cont.len().saturating_sub(box_width)].to_string()
2925 }
2926
2927 /// Where the line of the item *containing* the item at `off` begins — the
2928 /// prefix Shift+Tab moves back to, which gives up exactly the level the
2929 /// parent contributed. The quote prefix alone for a top-level item, which
2930 /// has no level left to give.
2931 fn outdent_prefix_at(&mut self, off: usize) -> String {
2932 let items: Vec<usize> = self
2933 .editor
2934 .document()
2935 .and_then(|mut d| d.ancestors_at_caret(off))
2936 .map(|c| {
2937 c.into_iter()
2938 .filter(|m| m.kind == Kind::ListItem || m.kind == Kind::TaskListItem)
2939 .map(|m| m.span.start)
2940 .collect()
2941 })
2942 .unwrap_or_default();
2943 // The second-innermost item is the parent; its own line's indent is the
2944 // target. `list_marker_on_line` gives that line's prefix directly.
2945 let parent = items.len().checked_sub(2).map(|i| items[i]);
2946 match parent.and_then(|p| self.list_marker_on_line(p)) {
2947 Some(m) => self.source[m.line_start..m.marker_start].to_string(),
2948 None => self.quote_prefix_at(off),
2949 }
2950 }
2951
2952 /// The block-quote prefix in force at `off` — `""` outside a quote, `"> "`
2953 /// inside one, `"> > "` inside two.
2954 ///
2955 /// Assembled from each enclosing quote's own [`FlatNode::marker_span`], so
2956 /// the `>` and the space after it are twig's spelling rather than leaf's.
2957 /// The whole line prefix can't answer this: it also carries the indent of
2958 /// whatever the quote holds, which a blank separator line must *not* repeat.
2959 fn quote_prefix_at(&mut self, off: usize) -> String {
2960 let Ok(chain) = self
2961 .editor
2962 .document()
2963 .and_then(|mut d| d.ancestors_at_caret(off))
2964 else {
2965 return String::new();
2966 };
2967 let quotes: Vec<usize> = chain
2968 .iter()
2969 .filter(|m| m.kind == Kind::BlockQuote)
2970 .map(|m| m.node_id as usize)
2971 .collect();
2972 let Ok(nodes) = self.editor.nodes() else {
2973 return String::new();
2974 };
2975 quotes
2976 .iter()
2977 .filter_map(|id| nodes.get(*id)?.marker_span.clone())
2978 .filter_map(|s| self.source.get(s))
2979 .collect()
2980 }
2981
2982 /// Whether the item at `off` sits inside another one — the test Backspace
2983 /// uses to choose between outdenting and dropping the marker.
2984 ///
2985 /// Counted from the AST rather than from the line's leading whitespace,
2986 /// which is indentation in Markdown and, in Djot, may be nothing at all.
2987 fn item_is_nested(&mut self, off: usize) -> bool {
2988 self.editor
2989 .document()
2990 .and_then(|mut d| d.ancestors_at_caret(off))
2991 .map(|c| {
2992 c.into_iter()
2993 .filter(|m| m.kind == Kind::ListItem || m.kind == Kind::TaskListItem)
2994 .count()
2995 > 1
2996 })
2997 .unwrap_or(false)
2998 }
2999
3000 /// The innermost list item containing `probe`, under twig's **caret**
3001 /// containment rule — a block's end is inside it.
3002 ///
3003 /// Half-open containment can't answer this. An empty item's span is exactly
3004 /// its marker, so the caret sitting after `- ` is one past the end and the
3005 /// item it is plainly in tests as out of reach; that is the shape
3006 /// double-Enter has to recognise to leave the list.
3007 fn innermost_list_item(&mut self, probe: usize) -> Option<FlatNode> {
3008 let chain = self
3009 .editor
3010 .document()
3011 .and_then(|mut d| d.ancestors_at_caret(probe))
3012 .ok()?;
3013 let id = chain
3014 .iter()
3015 .rev()
3016 .find(|m| m.kind == Kind::ListItem || m.kind == Kind::TaskListItem)?
3017 .node_id as usize;
3018 self.editor.nodes().ok()?.get(id).cloned()
3019 }
3020
3021 /// The list marker opening `off`'s line, per twig — `None` when that line
3022 /// opens no list item.
3023 ///
3024 /// [`Document::line_prefix`] is the whole hidden run from the line start:
3025 /// `> 1. ` is a quote's marker, an indent, and an item's marker together,
3026 /// and it is `None` on a *continuation* line, which opens nothing. That last
3027 /// case is the one leaf could never get right by reading bytes. `- a\n - b`
3028 /// is two items in Markdown and one in Djot, where a marker cannot interrupt
3029 /// a paragraph and ` - b` is literal text — identical bytes, and only the
3030 /// parser knows which document it is looking at.
3031 ///
3032 /// The item's own marker is separated out via its
3033 /// [`FlatNode::marker_span`], so `marker_start` splits the prefix into what
3034 /// the containers around it contribute and what the item does.
3035 fn list_marker_on_line(&mut self, off: usize) -> Option<ListMarker> {
3036 let off = off.min(self.source.len());
3037 let prefix = self.editor.document().ok()?.line_prefix(off).ok()??;
3038 // The prefix belongs to a list only when an item's marker closes it —
3039 // a heading's `# ` or a bare quote's `> ` is a prefix too.
3040 let item = self.innermost_list_item(prefix.end.min(self.source.len()))?;
3041 let marker = item.marker_span.clone()?;
3042 if marker.end != prefix.end {
3043 return None;
3044 }
3045 Some(ListMarker {
3046 line_start: prefix.start,
3047 marker_start: marker.start,
3048 text: self.source.get(prefix)?.to_string(),
3049 })
3050 }
3051
3052 /// Whether the list item on `line_start`'s line is the **first item** of its
3053 /// list — the one Tab must not nest, because nesting needs a preceding
3054 /// sibling to become the new parent and a first item has none. `false` for a
3055 /// line that isn't a list item, and for an item with a sibling above it (the
3056 /// one Tab *can* nest). Gated on the AST, not the marker bytes: `- ` reads
3057 /// the same in a setext underline that opens no list at all.
3058 fn first_item_of_list(&mut self, line_start: usize) -> bool {
3059 let Some(marker) = self.list_marker_on_line(line_start) else {
3060 return false;
3061 };
3062 // Probe just inside the marker, where the item's own node is in reach —
3063 // the marker offset itself can resolve to the enclosing list, not the
3064 // `list_item`, whose span starts at the marker.
3065 let probe = marker.content_start().min(self.source.len());
3066 let Some(item) = self.innermost_list_item(probe) else {
3067 return false;
3068 };
3069 let Ok(nodes) = self.editor.nodes() else {
3070 return false;
3071 };
3072 match item.parent {
3073 // First when the parent list opens with this very item.
3074 Some(pid) => nodes
3075 .get(pid.0 as usize)
3076 .is_some_and(|p| p.first_child == Some(item.id)),
3077 // A parentless item is trivially the first (and only) one.
3078 None => true,
3079 }
3080 }
3081
3082 pub fn backspace(&mut self) {
3083 if let Some((s, e)) = self.selection() {
3084 self.splice(s, e, "", EditKind::Other);
3085 return;
3086 }
3087 // WYSIWYG: Backspace at the very start of a list item's content is a
3088 // structural key, not a character delete — it walks the "un-indent, then
3089 // un-list" ladder every list editor gives that keystroke (outdent a
3090 // nested item, strip a top-level one's marker to a paragraph). In source
3091 // view the `- ` is visible text the user is deleting a byte of, so it
3092 // keeps its literal meaning there, like Enter does.
3093 if self.view != View::Source && self.backspace_list_start() {
3094 return;
3095 }
3096 // WYSIWYG: and the same at the start of a heading's content — the `# `
3097 // there is markup the rich view hides, not text the user typed.
3098 if self.view != View::Source && self.backspace_heading_start() {
3099 return;
3100 }
3101 // WYSIWYG: and at the start of a block whose presentation is spelled
3102 // as hidden markup before it — djot's `{.center}` line, Markdown's
3103 // `<div class="center">` — Backspace takes that markup, the way it
3104 // takes a heading's `#`, rather than a byte out of it.
3105 if self.view != View::Source && self.backspace_attributed_block_start() {
3106 return;
3107 }
3108 // WYSIWYG: at a block picture's stops, a byte-at-a-time delete would take
3109 // the markup apart under a caret that cannot see it — see
3110 // `delete_around_block_media`.
3111 if self.view != View::Source && self.delete_around_block_media(false) {
3112 return;
3113 }
3114 // WYSIWYG: Backspace at a table's trailing stop steps back into its last
3115 // cell rather than taking the byte behind the caret — the row's closing
3116 // `|`, which the rich view never drew, so the key would have looked like
3117 // it did nothing. The stop before is the last cell's end.
3118 if self.view != View::Source && self.backspace_at_table_end() {
3119 return;
3120 }
3121 // WYSIWYG: at the start of a block's content, the byte behind the caret
3122 // is a block boundary, and Backspace over one is a join — twig's, so
3123 // that what a join is in each format is not this file's to know. After
3124 // the picture and table cases, which are block starts with their own
3125 // answers.
3126 if self.view != View::Source && self.backspace_joins_block() {
3127 return;
3128 }
3129 // WYSIWYG: Backspace on a *blank line* deletes back to the previous caret
3130 // stop, not a single newline. On a line with no text of its own, the byte
3131 // before the caret is a `\n` that spells part of a block boundary — the gap
3132 // between two blocks, drawn but never a caret home. Removing just it strands
3133 // the caret in that gap and leaves an odd blank line the eye reads as one
3134 // separator but the caret can't land on: the "extra newline" left behind
3135 // after leaving a list (Enter, Enter) or a paragraph and pressing Backspace.
3136 // Deleting to the previous stop instead collapses the whole break at once,
3137 // landing the caret at the end of the block above. Two blank lines in a row
3138 // are one stop apart, so this still removes exactly one — the lone-Enter /
3139 // lone-Backspace symmetry the empty-line case is built on is untouched.
3140 if self.view != View::Source
3141 && self.caret > self.caret_floor()
3142 && self.caret_on_blank_line()
3143 && let Some(stop) = self.vmap.stop_before(self.caret)
3144 {
3145 let stop = stop.max(self.caret_floor());
3146 if stop < self.caret {
3147 if self.source[stop..self.caret].trim().is_empty() {
3148 self.splice(stop, self.caret, "", EditKind::Delete);
3149 } else {
3150 // Hidden markup stands between the stop and the caret — a
3151 // `</div>`, a comment, a link reference definition — and
3152 // collapsing to the stop would delete it. Take the blank
3153 // line alone, with the newline that opened it, and land
3154 // the caret where the collapse would have.
3155 self.delete_blank_line_to(stop);
3156 }
3157 return;
3158 }
3159 }
3160 if self.caret > self.caret_floor() {
3161 // An in-cell `<br>` draws as one newline glyph, so Backspace over it
3162 // takes the whole tag — a single-byte step would leave a broken `<br`
3163 // showing in the cell. Rich view only (source view edits the literal).
3164 if self.view != View::Source
3165 && let Some((start, end)) = self.cell_break_at(BreakEdge::Backward)
3166 {
3167 let start = start.max(self.caret_floor());
3168 if start < end {
3169 self.splice(start, end, "", EditKind::Delete);
3170 return;
3171 }
3172 }
3173 // Aim the delete at the character the writer can *see* behind the
3174 // caret, never at a delimiter the rich view drew nothing for. Two
3175 // steps, and either can apply: from the far side of a run's closing
3176 // `**` step back into the run (the caret is drawn at the end of its
3177 // word), and at the start of a run's text step out past its opening
3178 // `**` to the character in front of it, leaving the run standing.
3179 // Without them a plain Backspace unspells the phrase it is editing
3180 // and leaves a literal asterisk on screen.
3181 let end = if self.view == View::Source {
3182 self.caret
3183 } else {
3184 let inside = self.step_inside_close_delims(self.caret);
3185 // An attributed span with no text — `<span …></span>` as the
3186 // file was written — is hidden markup around nothing, and a
3187 // byte-step here would take its `>`. Backspace takes the span
3188 // whole, with the character before it: the character the key
3189 // looks aimed at, since the span draws nothing.
3190 if let Some(span) = self.run_span_of_content(inside..inside) {
3191 let from = if self.source[..span.start].ends_with('\n') {
3192 span.start
3193 } else {
3194 prev_boundary(&self.source, span.start)
3195 };
3196 let from = from.max(self.caret_floor());
3197 self.splice(from, span.end, "", EditKind::Delete);
3198 return;
3199 }
3200 self.skip_leading_open_delims(inside)
3201 .max(self.caret_floor())
3202 };
3203 // Never delete back across the floor — that would eat hidden
3204 // frontmatter the WYSIWYG caret can't even see.
3205 let mut prev = prev_boundary(&self.source, end).max(self.caret_floor());
3206 // Take a hidden escape backslash with the char it escapes: the rich
3207 // view draws `\*` as a single `*`, so Backspace over it must delete
3208 // both bytes, never strand the `\` as a lone visible backslash (the
3209 // mirror of the Hidden-mode typing that wrote the escape). Source view
3210 // shows the `\`, so there it is an ordinary character.
3211 if self.view != View::Source
3212 && prev > self.caret_floor()
3213 && self.is_hidden_escape(prev - 1)
3214 {
3215 prev -= 1;
3216 }
3217 // The delete that takes the last of a span's text takes the span
3218 // with it, in the same edit: `<span …>i</span>` losing its `i`
3219 // would leave an empty span the map has no stop inside, so the
3220 // caret would draw at the next stop — a line away — until a
3221 // further key removed the span. Landing on the span's start is
3222 // where the letter was.
3223 if self.view != View::Source
3224 && let Some(span) = self.run_span_of_content(prev..end)
3225 {
3226 self.splice(span.start, span.end, "", EditKind::Delete);
3227 return;
3228 }
3229 // And the same for a block: the letter that was all of a centred
3230 // paragraph's text goes with the `<div>` around it, or the `{…}`
3231 // line above it, leaving a plain blank line where the letter was.
3232 // A paragraph with no text is no block, so the markup would stand
3233 // around nothing, the map would give it no caret home, and the
3234 // next key would take the tag apart.
3235 if self.view != View::Source
3236 && let Some(block) = self.attributed_block_of_content(prev..end)
3237 {
3238 self.splice(block.start, block.end, "", EditKind::Delete);
3239 return;
3240 }
3241 if prev < end {
3242 self.splice(prev, end, "", EditKind::Delete);
3243 }
3244 }
3245 }
3246
3247 /// Remove the blank line the caret is on — its own newline and the one
3248 /// that ended the line before it — and put the caret on `stop`, the caret
3249 /// stop before it. The [`backspace`](Self::backspace) blank-line rule for a
3250 /// blank line that hidden markup separates from the block above: the
3251 /// navigable blank row after a `</div>` is always one of at least three
3252 /// newlines under the tag (the drawn separators either side of it), so
3253 /// taking two leaves the blank line the tag needs under it.
3254 fn delete_blank_line_to(&mut self, stop: usize) {
3255 let caret = self.caret;
3256 let line_start = self.source[..caret].rfind('\n').map_or(0, |i| i + 1);
3257 let line_end = self.source[caret..]
3258 .find('\n')
3259 .map_or(self.source.len(), |i| caret + i);
3260 let from = line_start.saturating_sub(1).max(stop);
3261 let to = (line_end + 1).min(self.source.len());
3262 self.splice(from, to, "", EditKind::Delete);
3263 self.caret = stop;
3264 self.anchor = None;
3265 self.goal_col = None;
3266 self.record_caret();
3267 }
3268
3269 /// Move the caret to the stop before it and consume the key — what
3270 /// Backspace does where the byte behind the caret is hidden markup it
3271 /// has no structural answer for, rather than take that markup apart.
3272 fn step_back_to_stop(&mut self) {
3273 // The map answers about offsets, so it has to be this revision's — see
3274 // `open_paragraph_at_block_edge`.
3275 self.rebuild_map();
3276 if let Some(off) = self
3277 .vmap
3278 .stop_before(self.caret)
3279 .filter(|&o| o >= self.caret_floor())
3280 {
3281 self.caret = off;
3282 self.anchor = None;
3283 self.goal_col = None;
3284 }
3285 }
3286
3287 /// Backspace's presentation behaviour: with the caret exactly at the start
3288 /// of a block's content, and that block's attributes spelled as hidden
3289 /// markup before it, strip the attributes. The peer of
3290 /// [`backspace_heading_start`](Self::backspace_heading_start), and the same
3291 /// reasoning: the `{.center}` line above a djot block and the
3292 /// `<div class="center">` around a Markdown one are what the byte behind
3293 /// the caret belongs to, and the rich view draws neither. The ordinary
3294 /// delete took the newline out of `{.center}\nhello` and left
3295 /// `{.center}hello` — the attribute line fused onto the text as prose —
3296 /// and out of `<div …>\n\nhello` it took the blank line the div needs.
3297 ///
3298 /// The whole attribute set goes, the way the whole `#` marker does — the
3299 /// press is over the line that spells it, not over one key of it — and
3300 /// twig's `set_block_attrs` with an empty list is the edit: it removes the
3301 /// djot line and unwraps the Markdown div. Where the block is the first of
3302 /// several in a div, twig has no sole child to unwrap and answers with a
3303 /// no-op, so the caret steps back to the stop before instead, as it does
3304 /// at a table's end. A later child of the div has an ordinary paragraph
3305 /// above it and is not this rule's.
3306 ///
3307 /// Returns whether it acted; `false` leaves Backspace its character delete.
3308 fn backspace_attributed_block_start(&mut self) -> bool {
3309 if !matches!(self.format, Format::Markdown | Format::Djot) {
3310 return false;
3311 }
3312 let caret = self.caret;
3313 let nodes = self.nodes();
3314 let Some(block) = nodes
3315 .iter()
3316 .filter(|n| matches!(n.kind, Kind::Para | Kind::Heading))
3317 .find(|n| n.content_span.as_ref().map_or(n.span.start, |c| c.start) == caret)
3318 else {
3319 return false;
3320 };
3321 match self.format {
3322 Format::Djot => {
3323 // twig records where the `{…}` block was written, so this is
3324 // the parser's own answer and not a scan for a `{` above the
3325 // block; `None` (a synthesized or merged set) is not a line
3326 // the caret is standing after.
3327 let spelled = self
3328 .editor
3329 .document()
3330 .ok()
3331 .and_then(|mut d| d.attrs_span(block.id).ok().flatten())
3332 .is_some_and(|s| s.end <= caret);
3333 if !spelled {
3334 return false;
3335 }
3336 }
3337 _ => {
3338 let Some(div) = block
3339 .parent
3340 .and_then(|p| nodes.iter().find(|n| n.id == p))
3341 .filter(|p| wysiwyg::element_tag(p) == Some("div"))
3342 else {
3343 return false;
3344 };
3345 let mut kids = nodes.iter().filter(|n| n.parent == Some(div.id));
3346 if kids.clone().any(|k| k.span.start < block.span.start) {
3347 return false;
3348 }
3349 if kids.nth(1).is_some() {
3350 self.step_back_to_stop();
3351 return true;
3352 }
3353 }
3354 }
3355 self.write_block_attrs("block attributes", Vec::new());
3356 true
3357 }
3358
3359 /// Backspace at the start of a block's content: join the block into the
3360 /// block before it, as one gesture — twig's `join_blocks`, the inverse of
3361 /// the split Enter makes, spelled the format's way. Two paragraphs join
3362 /// on a soft break; a paragraph under a marker heading joins onto the
3363 /// heading's line; a paragraph after a Markdown `<div>` moves inside it,
3364 /// the hidden `</div>` carried past the joined text; HTML's `</p><p>` is
3365 /// taken as one; a quote's or an item's continuation prefix is written.
3366 /// The joined text takes the block above's presentation and containers,
3367 /// which is the rule every editor with a centred paragraph follows.
3368 ///
3369 /// Leaf used to join by deleting the one newline behind the caret, which
3370 /// is the right bytes for two Markdown paragraphs and nothing else: under
3371 /// a heading it left two blocks, in HTML it took the `>` off a tag, and
3372 /// after a div it took the newline under the hidden `</div>`, which drew
3373 /// nothing different and took the tag apart on the next press. What a
3374 /// join is in each format is twig's to know, and now it does.
3375 ///
3376 /// Where twig refuses — the block above is a code block, a table or a
3377 /// rule with no text to join into, or the caret's block would have to
3378 /// leave a div that holds more after it — the caret steps back to the
3379 /// stop before instead, as it does at a table's end: the key moves the
3380 /// caret and takes no markup apart. Where nothing precedes the block, or
3381 /// the format cannot join at all, Backspace keeps its character delete.
3382 ///
3383 /// Returns whether it acted.
3384 fn backspace_joins_block(&mut self) -> bool {
3385 let caret = self.caret;
3386 if caret <= self.caret_floor() {
3387 return false;
3388 }
3389 let Some(text) = self.text_block_opening_at(caret) else {
3390 return false;
3391 };
3392 match self.join_blocks(caret) {
3393 Ok(change) => {
3394 // The caret keeps its place at the start of the text it stood
3395 // on, wherever the join put that text — after a soft break,
3396 // a space, or a quote's prefix. Found by the bytes, as
3397 // `block_content_in` finds a re-spelled block.
3398 let region = &self.source[change.new.clone()];
3399 let at = region
3400 .find(&text)
3401 .map_or(change.new.start, |i| change.new.start + i);
3402 self.land_after_join(at);
3403 true
3404 }
3405 Err(twig::Error::NotEditable) => {
3406 self.step_back_to_stop();
3407 true
3408 }
3409 Err(twig::Error::NotFound | twig::Error::UnsupportedFormat) => false,
3410 Err(e) => {
3411 self.status = Some(format!("join: {e}"));
3412 true
3413 }
3414 }
3415 }
3416
3417 /// Delete at the end of a block's content: join the block after it into
3418 /// this one — [`backspace_joins_block`](Self::backspace_joins_block)'s
3419 /// mirror, and the same twig gesture aimed at the next block. The caret
3420 /// stays where it was, which is where the joined text now begins after
3421 /// the separator. Where twig refuses, the caret steps forward to the next
3422 /// stop instead; where no block follows, Delete keeps its character
3423 /// delete.
3424 fn delete_forward_joins_block(&mut self) -> bool {
3425 let caret = self.caret;
3426 let at_end = self
3427 .nodes()
3428 .iter()
3429 .filter(|n| matches!(n.kind, Kind::Para | Kind::Heading))
3430 .any(|n| n.content_span.as_ref().is_some_and(|c| c.end == caret));
3431 if !at_end {
3432 return false;
3433 }
3434 // The next stop, across a line end, in a block: what Delete at a
3435 // block's end points at. On the same line it is a hidden delimiter's
3436 // far side, which the ordinary delete handles; on a blank line it is
3437 // the empty paragraph the byte delete has always closed.
3438 self.rebuild_map();
3439 let Some(stop) = self.vmap.stop_after(caret) else {
3440 return false;
3441 };
3442 if !self.source[caret..stop].contains('\n') || !self.has_block_at(stop) {
3443 return false;
3444 }
3445 match self.join_blocks(stop) {
3446 Ok(change) => {
3447 self.land_after_join(change.old.start);
3448 true
3449 }
3450 Err(twig::Error::NotEditable | twig::Error::NotFound) => {
3451 self.caret = stop;
3452 self.anchor = None;
3453 self.goal_col = None;
3454 true
3455 }
3456 Err(twig::Error::UnsupportedFormat) => false,
3457 Err(e) => {
3458 self.status = Some(format!("join: {e}"));
3459 true
3460 }
3461 }
3462 }
3463
3464 /// The content bytes of the paragraph or heading whose content opens
3465 /// exactly at `off` — the block a Backspace there is at the start of.
3466 fn text_block_opening_at(&mut self, off: usize) -> Option<String> {
3467 self.nodes()
3468 .into_iter()
3469 .filter(|n| matches!(n.kind, Kind::Para | Kind::Heading))
3470 .find_map(|n| {
3471 let c = n.content_span?;
3472 (c.start == off).then(|| self.source[c].to_string())
3473 })
3474 }
3475
3476 /// Hand the block at `offset` to twig's `join_blocks`, with the undo
3477 /// plumbing every structural gesture has; the caret is the caller's to
3478 /// place from the change, via [`land_after_join`](Self::land_after_join).
3479 fn join_blocks(&mut self, offset: usize) -> Result<Change, twig::Error> {
3480 if self.read_only {
3481 return Err(twig::Error::NotEditable);
3482 }
3483 self.record_caret();
3484 let change = self.editor.join_blocks(offset)?;
3485 self.last_edit_kind = None; // structural edit is its own undo step
3486 self.refresh();
3487 Ok(change)
3488 }
3489
3490 /// Finish a join: the caret at `at`, no selection, the map this
3491 /// revision's before the clamp — see `write_block_attrs` for why.
3492 fn land_after_join(&mut self, at: usize) {
3493 self.caret = at;
3494 self.anchor = None;
3495 self.goal_col = None;
3496 self.dirty = self.source != self.clean_source;
3497 self.status = None;
3498 self.rebuild_map();
3499 self.clamp_caret();
3500 self.record_caret();
3501 }
3502
3503 /// Backspace at a table's trailing stop: move onto the stop before it (the
3504 /// last cell's end) and consume the key. `false` anywhere else. See
3505 /// [`VisualMap::table_end_stop`] for why the byte behind the caret there is
3506 /// not one to delete.
3507 fn backspace_at_table_end(&mut self) -> bool {
3508 // The map answers about offsets, so it has to be this revision's — see
3509 // `open_paragraph_at_block_edge`.
3510 self.rebuild_map();
3511 if !self.vmap.table_end_stop(self.caret) {
3512 return false;
3513 }
3514 if let Some(off) = self
3515 .vmap
3516 .stop_before(self.caret)
3517 .filter(|&o| o >= self.caret_floor())
3518 {
3519 self.caret = off;
3520 self.anchor = None;
3521 self.goal_col = None;
3522 }
3523 true
3524 }
3525
3526 /// Whether the caret's own source line holds nothing but whitespace — an
3527 /// empty paragraph, or the blank line a block boundary is spelled with. The
3528 /// test for [`backspace`](Self::backspace)'s stop-wise delete: such a line has
3529 /// no text of its own, so the newline before the caret belongs to the gap
3530 /// between blocks rather than to any word the caret is editing.
3531 fn caret_on_blank_line(&self) -> bool {
3532 let line_start = self.source[..self.caret].rfind('\n').map_or(0, |i| i + 1);
3533 let line_end = self.source[self.caret..]
3534 .find('\n')
3535 .map_or(self.source.len(), |i| self.caret + i);
3536 self.source[line_start..line_end].trim().is_empty()
3537 }
3538
3539 /// The source span of an in-cell hard break (`<br>`) touching the caret on the
3540 /// `edge` side — the byte range to delete whole. A table row is one source
3541 /// line, so its break is spelled `<br>` yet drawn as a single newline glyph
3542 /// (see `wysiwyg.rs`); a delete over it must take every byte, or a one-byte
3543 /// step strands a broken `<br` in the cell. `Backward` matches a break ending
3544 /// at the caret (Backspace), `Forward` one starting at it (Delete). `None`
3545 /// when no such break is adjacent. Only the in-cell break is spelled `<br>`
3546 /// (an ordinary hard break is ` \n`), so the leading `<` alone tells them
3547 /// apart — no ancestor walk needed. Rich view only; source view shows the
3548 /// literal tag and deletes it a byte at a time.
3549 fn cell_break_at(&mut self, edge: BreakEdge) -> Option<(usize, usize)> {
3550 let caret = self.caret;
3551 let nodes = self.nodes();
3552 let src = self.source.as_bytes();
3553 nodes
3554 .iter()
3555 .find(|n| {
3556 n.kind == Kind::HardBreak
3557 && n.span.start < n.span.end
3558 && src.get(n.span.start) == Some(&b'<')
3559 && match edge {
3560 BreakEdge::Backward => n.span.end == caret,
3561 BreakEdge::Forward => n.span.start == caret,
3562 }
3563 })
3564 .map(|n| (n.span.start, n.span.end))
3565 }
3566
3567 /// Whether the source byte at `off` is a backslash twig consumed as an escape
3568 /// (hidden in the rich view), as against a literal backslash (drawn). A
3569 /// backslash escapes exactly an ASCII-punctuation character (the CommonMark /
3570 /// Djot rule twig follows), so `\` + punctuation is the whole test — no AST
3571 /// round-trip needed.
3572 fn is_hidden_escape(&self, off: usize) -> bool {
3573 let b = self.source.as_bytes();
3574 b.get(off) == Some(&b'\\') && b.get(off + 1).is_some_and(u8::is_ascii_punctuation)
3575 }
3576
3577 /// Backspace's list behaviour: when the caret sits exactly at the start of a
3578 /// list item's content (right after its marker), outdent the item if it's
3579 /// nested, else strip the marker so it becomes a paragraph. Returns whether
3580 /// it acted — `false` leaves Backspace its ordinary character delete.
3581 fn backspace_list_start(&mut self) -> bool {
3582 let Some(marker) = self.list_marker_on_line(self.caret) else {
3583 return false;
3584 };
3585 // Only right after the marker. That the line opens a real item is
3586 // already settled: `list_marker_on_line` answers from the tree.
3587 if self.caret != marker.content_start() {
3588 return false;
3589 }
3590 if self.item_is_nested(marker.marker_start) {
3591 // Nested: give back one level, keeping the marker and carrying the
3592 // caret with it.
3593 self.outdent();
3594 } else {
3595 // Top level: drop the marker, leaving a paragraph, then renumber the
3596 // siblings the removed item was counted among. Only the marker goes —
3597 // a quote prefix in front of it still has a quote to hold up.
3598 self.splice(marker.marker_start, self.caret, "", EditKind::Other);
3599 self.renumber_here();
3600 }
3601 true
3602 }
3603
3604 /// Backspace's heading behaviour: with the caret exactly at the start of an
3605 /// ATX heading's content — right after the `#` marker the rich view hides —
3606 /// strip the marker so the line becomes a paragraph. The peer of
3607 /// [`backspace_list_start`](Self::backspace_list_start)'s ladder, and the same
3608 /// reasoning: hidden block markup is structure, so the keystroke over it is
3609 /// structural.
3610 ///
3611 /// Without this the ordinary delete takes the space out of `# Title` and
3612 /// leaves `#Title`, which is no longer a heading at all — the hash the view
3613 /// had been hiding surfaces as literal text the user has to delete a second
3614 /// time, having never typed it. A closing sequence (`# Title #`, hidden at the
3615 /// other end) goes with the marker for the same reason.
3616 ///
3617 /// Returns whether it acted; `false` leaves Backspace its character delete.
3618 fn backspace_heading_start(&mut self) -> bool {
3619 let caret = self.caret;
3620 // The heading whose content opens exactly at the caret. A bare `#` has no
3621 // content span at all — its content starts (and ends) where the line does.
3622 let Some((span, content_end, marker)) = self.nodes().iter().find_map(|n| {
3623 let (start, end) = match &n.content_span {
3624 Some(c) => (c.start, c.end),
3625 None => (n.span.end, n.span.end),
3626 };
3627 (n.kind == Kind::Heading && start == caret)
3628 .then(|| (n.span.clone(), end, n.marker_span.clone()))
3629 }) else {
3630 return false;
3631 };
3632 // twig reports the marker's own extent, so there is nothing to walk back
3633 // over and no `#` in this file. A setext heading has no marker — its
3634 // content opens the line — so it falls through to the ordinary delete,
3635 // as does anything else sitting at a content start.
3636 // `m.end == caret` is what excludes a setext heading, whose marker is the
3637 // underline *after* the content rather than a prefix before it.
3638 let Some(marker) = marker.filter(|m| m.end == caret) else {
3639 return false;
3640 };
3641 let start = marker.start;
3642 // A closing `#` sequence is hidden too, so it can't be left behind. Only
3643 // when the tail really is one: trailing spaces alone are nothing to strip.
3644 let tail = &self.source[content_end..span.end];
3645 if tail.contains('#') && tail.chars().all(|c| c == '#' || c.is_whitespace()) {
3646 let kept = self.source[caret..content_end].to_string();
3647 self.splice(start, span.end, &kept, EditKind::Other);
3648 // The splice leaves the caret past the text it re-wrote; the caret
3649 // belongs where the content now starts, which is where it already was.
3650 self.caret = start;
3651 self.record_caret();
3652 } else {
3653 self.splice(start, caret, "", EditKind::Other);
3654 }
3655 true
3656 }
3657
3658 pub fn delete_forward(&mut self) {
3659 if let Some((s, e)) = self.selection() {
3660 self.splice(s, e, "", EditKind::Other);
3661 } else if self.caret < self.source.len() {
3662 // The mirror of Backspace's: forward-delete in front of a picture
3663 // would eat the `!` off its markup and leave a link where a photo was.
3664 if self.view != View::Source && self.delete_around_block_media(true) {
3665 return;
3666 }
3667 // And of Backspace's join: at the end of a block's content, Delete
3668 // joins the next block into this one.
3669 if self.view != View::Source && self.delete_forward_joins_block() {
3670 return;
3671 }
3672 // Delete forward over an in-cell `<br>` takes the whole tag, the mirror
3673 // of Backspace's swallow (see `cell_break_at`) — else a byte-step
3674 // strands a broken `<br` in the cell.
3675 if self.view != View::Source
3676 && let Some((start, end)) = self.cell_break_at(BreakEdge::Forward)
3677 {
3678 self.splice(start, end, "", EditKind::Delete);
3679 return;
3680 }
3681 // The mirror of Backspace's two steps: from in front of a run's
3682 // opening `**` step into it, onto the first letter of its text, and
3683 // at the end of a run's text step out past its closing `**` to the
3684 // character beyond. Either way Delete takes the character it looks
3685 // like it is pointing at, and never a delimiter drawn as nothing.
3686 // The caret then settles back inside the run it was standing in —
3687 // see `settle_inside_close_delims`.
3688 let from = if self.view == View::Source {
3689 self.caret
3690 } else {
3691 let inside = self.step_inside_open_delims(self.caret);
3692 // The mirror of Backspace's empty-span rule: an attributed
3693 // span with no text goes whole, with the character after it.
3694 if let Some(span) = self.run_span_of_content(inside..inside) {
3695 let to = if self.source[span.end..].starts_with('\n') {
3696 span.end
3697 } else {
3698 next_boundary(&self.source, span.end)
3699 };
3700 self.splice(span.start, to, "", EditKind::Delete);
3701 return;
3702 }
3703 self.skip_trailing_close_delims(inside)
3704 };
3705 let next = next_boundary(&self.source, from);
3706 // And of its emptying rules: the span goes with its last letter,
3707 // and so does the block's div or `{…}` line.
3708 if self.view != View::Source
3709 && let Some(span) = self.run_span_of_content(from..next)
3710 {
3711 self.splice(span.start, span.end, "", EditKind::Delete);
3712 return;
3713 }
3714 if self.view != View::Source
3715 && let Some(block) = self.attributed_block_of_content(from..next)
3716 {
3717 self.splice(block.start, block.end, "", EditKind::Delete);
3718 return;
3719 }
3720 if from < next {
3721 self.splice(from, next, "", EditKind::Delete);
3722 }
3723 }
3724 }
3725
3726 /// Delete from the caret back to the start of the previous word (⌥⌫ /
3727 /// Ctrl+⌫). Deletes the selection instead when one is active.
3728 pub fn delete_word_back(&mut self) {
3729 if let Some((s, e)) = self.selection() {
3730 self.splice(s, e, "", EditKind::Other);
3731 } else {
3732 // A word back from just past a picture is a word *of its markup*, and
3733 // a word back from in front of one runs through the paragraph break
3734 // into the prose above — dissolving the picture either way. See
3735 // `delete_around_block_media`.
3736 if self.view != View::Source && self.delete_around_block_media(false) {
3737 return;
3738 }
3739 let start = self.word_left_from(self.caret).max(self.caret_floor());
3740 if start < self.caret {
3741 let (s, e) = self.widen_over_emptied_inlines(start, self.caret);
3742 self.splice(s, e, "", EditKind::Delete);
3743 }
3744 }
3745 }
3746
3747 /// Delete from the caret forward to the end of the next word (⌥⌦ /
3748 /// Ctrl+Del). Deletes the selection instead when one is active.
3749 pub fn delete_word_forward(&mut self) {
3750 if let Some((s, e)) = self.selection() {
3751 self.splice(s, e, "", EditKind::Other);
3752 } else {
3753 // The mirror: a word forward from in front of a picture is its markup.
3754 if self.view != View::Source && self.delete_around_block_media(true) {
3755 return;
3756 }
3757 let end = self.word_right_from(self.caret);
3758 if end > self.caret {
3759 let (s, e) = self.widen_over_emptied_inlines(self.caret, end);
3760 self.splice(s, e, "", EditKind::Delete);
3761 }
3762 }
3763 }
3764
3765 /// Delete from the caret back to the start of its line (⌘⌫). Deletes the
3766 /// selection instead when one is active, as every other delete here does.
3767 ///
3768 /// The line is the view's own — the one Home and End work on, so in WYSIWYG
3769 /// a soft-wrapped row is a line. It is not Home's *target*, though: Home
3770 /// stops at the first character and this takes the indentation with it, the
3771 /// way Cocoa's `deleteToBeginningOfLine:` does. Stopping at the text would
3772 /// leave an indent behind that nothing can then ask to delete, where a caret
3773 /// left at column 0 is one press of Home away from either.
3774 pub fn delete_to_line_start(&mut self) {
3775 if let Some((s, e)) = self.selection() {
3776 self.splice(s, e, "", EditKind::Other);
3777 return;
3778 }
3779 // Never back across the floor: hidden frontmatter isn't on this line, or
3780 // on any line the WYSIWYG caret can see.
3781 let (start, _) = self.line_span();
3782 let start = start.max(self.caret_floor());
3783 if start < self.caret {
3784 let (s, e) = self.widen_over_emptied_inlines(start, self.caret);
3785 self.splice(s, e, "", EditKind::Delete);
3786 }
3787 }
3788
3789 /// Kill from the caret to the end of its line (^K). Deletes the selection
3790 /// instead when one is active.
3791 ///
3792 /// At the end of the line it does nothing, rather than pulling the line
3793 /// below up into this one. Joining has no meaning to give it in both views
3794 /// at once: a WYSIWYG line ends at a soft wrap as often as at a newline, and
3795 /// there is nothing there to delete, while the newline a *source* line ends
3796 /// with is only half of the blank line that separates two paragraphs —
3797 /// deleting one leaves a soft break, which is not the join it looks like.
3798 /// The views agreeing is worth more than emacs' second press, and Delete is
3799 /// already the key that joins.
3800 pub fn delete_to_line_end(&mut self) {
3801 if let Some((s, e)) = self.selection() {
3802 self.splice(s, e, "", EditKind::Other);
3803 return;
3804 }
3805 let (_, end) = self.line_span();
3806 if end > self.caret {
3807 let (s, e) = self.widen_over_emptied_inlines(self.caret, end);
3808 self.splice(s, e, "", EditKind::Delete);
3809 }
3810 }
3811
3812 /// Grow a WYSIWYG word-delete to swallow any inline node it empties.
3813 ///
3814 /// A glyph-space range covers what the user can see, which for `**bold**` is
3815 /// the word and never the delimiters around it — so deleting the word on its
3816 /// own leaves `a **** c`, markup wrapped around nothing. They asked for the
3817 /// word, and the styling was the word's; the two go together. Only the
3818 /// node's delimiters are taken, and those are hidden here anyway, so nothing
3819 /// visible outside the range is lost.
3820 ///
3821 /// Repeated to a fixed point: emptying `***bold***` empties the emph inside
3822 /// the strong, and only then is the strong empty too.
3823 fn widen_over_emptied_inlines(&mut self, start: usize, end: usize) -> (usize, usize) {
3824 if self.view == View::Source {
3825 return (start, end);
3826 }
3827 let nodes = self.nodes();
3828 let (mut s, mut e) = (start, end);
3829 loop {
3830 let mut grew = false;
3831 for n in nodes.iter().filter(|n| wysiwyg::is_inline(n)) {
3832 let Some(text) = inline_content_span(n, &self.source) else {
3833 continue;
3834 };
3835 // Some of its text survives, so the node still has a job.
3836 if text.start < s || text.end > e {
3837 continue;
3838 }
3839 if n.span.start < s || n.span.end > e {
3840 s = s.min(n.span.start);
3841 e = e.max(n.span.end);
3842 grew = true;
3843 }
3844 }
3845 if !grew {
3846 return (s, e);
3847 }
3848 }
3849 }
3850
3851 /// One splice of document text, keeping the **mark-edge rule**: an inline
3852 /// mark's content never begins or ends with whitespace. In Markdown and Djot
3853 /// a delimiter standing against a space is not a delimiter at all — `**bold **`
3854 /// is four literal asterisks around a word, and a rich view drawing the
3855 /// document faithfully has no choice but to show them. That is correct
3856 /// rendering of what the file says, and nobody typing a space after a bold
3857 /// word meant to say it.
3858 ///
3859 /// So the space goes *outside* the run instead — `**bold** ` — which is the
3860 /// same document to a reader and a live one to a parser. The caret follows it
3861 /// out and keeps the marks armed (see [`rearm`](Self::rearm)), so the next
3862 /// character rejoins the run (see [`rejoin_run`](Self::rejoin_run)) and the
3863 /// writer sees one unbroken bold phrase, never a flash of raw syntax.
3864 ///
3865 /// Every ordinary edit — typing, deleting, pasting, an IME step — comes
3866 /// through here, so the rule holds however the whitespace arrives at the
3867 /// edge. The repair is decided *after* the plain edit, by asking whether the
3868 /// mark actually died: a code span's backticks aren't whitespace-sensitive
3869 /// (`` `code ` `` is still code), and nothing is re-spelled when nothing broke.
3870 fn splice(&mut self, start: usize, end: usize, text: &str, kind: EditKind) -> bool {
3871 let fix = self.mark_edge_fix(start, end, text);
3872 if !self.splice_exact(start, end, text, kind) {
3873 return false;
3874 }
3875 if let Some(fix) = fix {
3876 self.repair_mark_edges(fix);
3877 }
3878 if text.is_empty() && end > start {
3879 self.settle_inside_close_delims();
3880 }
3881 true
3882 }
3883
3884 /// After a delete, take a caret left standing past a run's closing delimiters
3885 /// back inside the run.
3886 ///
3887 /// A delete leaves the caret where the deleted bytes began, and when those
3888 /// bytes were the last thing after a marked phrase — the space the mark-edge
3889 /// rule pushed out of `**bold** `, say — that spot is the far side of the
3890 /// closing `**`. The rich view has nothing to draw there: the delimiters are
3891 /// hidden, so the caret shows at the end of the word either way, and the two
3892 /// offsets are one place on screen with two different meanings. Typing at the
3893 /// outer one lands past the run, so the writer who backspaced a space out of
3894 /// their bold phrase watches the next character come out plain, and the
3895 /// toolbar button go dark, with the caret never appearing to move.
3896 ///
3897 /// The end of the run's text is the caret's home there — a delete that took
3898 /// away everything after a phrase leaves the caret at the end of that phrase,
3899 /// which is inside it — so it settles onto that
3900 /// ([`step_inside_close_delims`](Self::step_inside_close_delims) does the
3901 /// walk, through every mark closing at the point): the word stays bold, the
3902 /// button stays lit, and the next character carries on the phrase.
3903 ///
3904 /// Rich view only, and only where a mark really closes at the caret — mid-run
3905 /// or in plain prose no span ends there and the caret stays put. The opening
3906 /// edge is left alone on purpose: a caret in front of a run inherits from the
3907 /// text on its left, which is the plain text outside.
3908 fn settle_inside_close_delims(&mut self) {
3909 if self.view != View::Wysiwyg {
3910 return;
3911 }
3912 let at = self.step_inside_close_delims(self.caret);
3913 if at != self.caret {
3914 self.caret = at;
3915 self.clear_pending();
3916 self.record_caret();
3917 }
3918 }
3919
3920 /// The splice exactly as asked, with no mark-edge repair — for the callers
3921 /// that are *writing* the delimiters themselves ([`insert_with_marks`](Self::insert_with_marks)
3922 /// and [`rejoin_run`](Self::rejoin_run)) and place their own offsets around
3923 /// the bytes they inserted.
3924 ///
3925 /// One `edit_range` through twig, then re-anchor the caret from the returned
3926 /// `Change` and refresh the cached source. A reparse-breaking edit (rare for
3927 /// Markdown/Djot) leaves the document untouched and reports.
3928 ///
3929 /// Returns whether the edit landed — for a caller that has offsets of its
3930 /// own to place afterwards, which a rolled-back splice would leave pointing
3931 /// into text that never came to exist.
3932 fn splice_exact(&mut self, start: usize, end: usize, text: &str, kind: EditKind) -> bool {
3933 // The read-only gate, for every edit at once — see the field.
3934 if self.read_only {
3935 return false;
3936 }
3937 // twig records an undo step for every edit; when this one continues a
3938 // run of the same kind (typing, deleting), tell twig to fold it into the
3939 // step before it so the whole run undoes at once.
3940 let coalesce = kind != EditKind::Other && self.last_edit_kind == Some(kind);
3941 // Hand twig the pre-edit caret before the splice, so the undo step it
3942 // retires carries where the caret was standing.
3943 self.record_caret();
3944 match self.editor.edit_range(start, end, text) {
3945 Ok(change) => {
3946 if coalesce {
3947 let _ = self.editor.coalesce_last_undo();
3948 }
3949 self.last_edit_kind = Some(kind);
3950 self.refresh();
3951 self.caret = change.new.end;
3952 self.anchor = None;
3953 self.goal_col = None;
3954 self.clear_pending();
3955 self.dirty = self.source != self.clean_source;
3956 self.status = None;
3957 // And the post-edit caret, so a later redo restores it.
3958 self.record_caret();
3959 true
3960 }
3961 // The edit was rolled back, so twig's history did not move and
3962 // neither may ours: pushing here would leave a step with no edit
3963 // under it and shift every later undo onto the wrong caret.
3964 Err(e) => {
3965 self.status = Some(format!("edit: {e}"));
3966 false
3967 }
3968 }
3969 }
3970
3971 /// The re-spelling that would keep the mark-edge rule for the edit
3972 /// `[start, end)` → `text`, or `None` when the edit leaves no whitespace
3973 /// against a delimiter and the plain splice is already right. Computed
3974 /// *before* the edit, while the run's spans and delimiters can still be read
3975 /// off the document; applied afterwards, and only if the mark really died —
3976 /// see [`repair_mark_edges`](Self::repair_mark_edges).
3977 ///
3978 /// Rich view only. Source view is for typing raw markup, where a space put
3979 /// against a `**` is exactly the character it looks like.
3980 fn mark_edge_fix(&mut self, start: usize, end: usize, text: &str) -> Option<MarkEdgeFix> {
3981 if self.view != View::Wysiwyg || start > end || end > self.source.len() {
3982 return None;
3983 }
3984 // Every inline mark standing over the edit, outermost first, with the
3985 // content span that says where its delimiters are.
3986 let chain: Vec<(InlineKind, std::ops::Range<usize>, std::ops::Range<usize>)> = self
3987 .editor
3988 .ancestors_at(start)
3989 .unwrap_or_default()
3990 .into_iter()
3991 .filter_map(|m| {
3992 let kind = inline_kind(&m.kind)?;
3993 let content = m.content_span.clone()?;
3994 Some((kind, m.span.clone(), content))
3995 })
3996 .collect();
3997 // The innermost run whose *content* holds the whole edit: the one whose
3998 // text is being changed, rather than one the edit merely sits under.
3999 let (kind, span, content) = chain
4000 .iter()
4001 .rev()
4002 .find(|(_, _, c)| c.start <= start && end <= c.end)?
4003 .clone();
4004 // What that content becomes. Whitespace at either end of it is what
4005 // would put out the mark.
4006 let body = format!(
4007 "{}{text}{}",
4008 &self.source[content.start..start],
4009 &self.source[end..content.end]
4010 );
4011 let (lead, trail) = if body.trim().is_empty() {
4012 // Nothing but whitespace left: there is no content to mark at all,
4013 // and the delimiters go with it rather than closing on a space.
4014 (body.len(), 0)
4015 } else {
4016 (
4017 body.len() - body.trim_start().len(),
4018 body.len() - body.trim_end().len(),
4019 )
4020 };
4021 // Nothing against a delimiter, and something still between them: the
4022 // plain edit stands. An emptied run is broken just as surely (`**b**`
4023 // with the `b` deleted is the literal `****`) and is re-spelt as the
4024 // nothing it now says.
4025 if lead == 0 && trail == 0 && !body.is_empty() {
4026 return None;
4027 }
4028 // Marks that open or close exactly where this one does — `***both***` is
4029 // two runs sharing an edge — spell their delimiters as one run of bytes,
4030 // so the whitespace has to clear all of them together.
4031 let (mut open_at, mut close_at) = (span.start, span.end);
4032 for _ in 0..chain.len() {
4033 match chain.iter().find(|(_, _, c)| c.start == open_at) {
4034 Some((_, s, _)) => open_at = s.start,
4035 None => break,
4036 }
4037 }
4038 for _ in 0..chain.len() {
4039 match chain.iter().find(|(_, _, c)| c.end == close_at) {
4040 Some((_, s, _)) => close_at = s.end,
4041 None => break,
4042 }
4043 }
4044 let open = &self.source[open_at..content.start];
4045 let close = &self.source[content.end..close_at];
4046 let core = &body[lead..body.len() - trail];
4047 let respelt = if core.is_empty() {
4048 body.clone()
4049 } else {
4050 format!(
4051 "{}{open}{core}{close}{}",
4052 &body[..lead],
4053 &body[body.len() - trail..]
4054 )
4055 };
4056 // The caret sits just past the inserted text within the new content —
4057 // which, when that lands in the whitespace, is now outside the delimiters.
4058 let pos = (start - content.start) + text.len();
4059 let caret = if core.is_empty() || pos <= lead {
4060 open_at + pos
4061 } else if pos >= lead + core.len() {
4062 open_at + lead + open.len() + core.len() + close.len() + (pos - lead - core.len())
4063 } else {
4064 open_at + lead + open.len() + (pos - lead)
4065 };
4066 Some(MarkEdgeFix {
4067 kind,
4068 probe: content.start,
4069 start: open_at,
4070 end: close_at + text.len() - (end - start),
4071 text: respelt,
4072 caret,
4073 // The marks in force here, resolved against any armed sticky delta —
4074 // what the writer is typing in, and so what has to still be true on
4075 // the far side of the delimiter the caret just stepped over.
4076 want: chain
4077 .iter()
4078 .filter(|(_, s, _)| start < s.end)
4079 .map(|(k, _, _)| *k)
4080 .collect::<InlineMarks>()
4081 .xor(self.pending_here()),
4082 })
4083 }
4084
4085 /// Apply a [`MarkEdgeFix`] — but only if the edit it was computed for really
4086 /// did break the mark. Whether whitespace at a delimiter is fatal is the
4087 /// format's business, not leaf's: `**bold **` is no longer strong, while
4088 /// `` `code ` `` is still perfectly good verbatim, and Djot's braced spellings
4089 /// don't care either. Asking the parser afterwards settles it for every kind
4090 /// and format at once, and costs a re-spelling only where one is due.
4091 ///
4092 /// The repair rides along with the edit that caused it — one undo step puts
4093 /// back what the writer typed, not a delimiter shuffle they never saw.
4094 fn repair_mark_edges(&mut self, fix: MarkEdgeFix) {
4095 if fix.end > self.source.len() {
4096 return;
4097 }
4098 if self.marks_at(fix.probe).iter().any(|(k, _)| *k == fix.kind) {
4099 return; // still a mark: these delimiters don't mind the whitespace
4100 }
4101 let resumed = self.last_edit_kind;
4102 if !self.splice_exact(fix.start, fix.end, &fix.text, EditKind::Other) {
4103 return;
4104 }
4105 let _ = self.editor.coalesce_last_undo();
4106 // The keystroke owns the undo step, so the run of typing it belongs to
4107 // keeps coalescing over the repair rather than breaking in two here.
4108 self.last_edit_kind = resumed;
4109 self.caret = fix.caret.min(self.source.len());
4110 self.anchor = None;
4111 self.goal_col = None;
4112 self.rearm(fix.want);
4113 self.clamp_caret();
4114 self.record_caret();
4115 }
4116
4117 /// Arm whatever sticky delta reproduces `want` at the caret — the marks the
4118 /// writer is typing in, carried across an edit that moved the caret out of
4119 /// the run holding them. Arms nothing when the caret already stands in
4120 /// exactly those marks, but still remembers the spot, so a further ⌘b starts
4121 /// a clean delta here (see [`toggle`](Self::toggle)).
4122 fn rearm(&mut self, want: InlineMarks) {
4123 let here: InlineMarks = self
4124 .marks_at(self.caret)
4125 .into_iter()
4126 .map(|(k, _)| k)
4127 .collect();
4128 self.pending_marks = want.xor(here);
4129 self.pending_at = Some(self.caret);
4130 }
4131
4132 /// Insert `text` at `at` as a *literal* run via twig's `insert_literal`,
4133 /// which backslash-escapes any character that would otherwise open markup in
4134 /// this format and position (`*` → `\*`, a line-start `#` → `\#`). The mirror
4135 /// of [`splice`](Self::splice) for the Hidden reveal mode's typing path, with
4136 /// the same caret re-anchor, coalescing, and rollback contract. `at` must be
4137 /// a collapsed point — a selection is deleted by the caller first, since
4138 /// `insert_literal` inserts rather than replaces.
4139 fn insert_literal_at(
4140 &mut self,
4141 at: usize,
4142 text: &str,
4143 kind: EditKind,
4144 force_coalesce: bool,
4145 ) -> bool {
4146 // The read-only gate: this door goes to twig directly, not through
4147 // `splice_exact`, so it guards itself — see the field.
4148 if self.read_only {
4149 return false;
4150 }
4151 // `force_coalesce` folds this into the immediately preceding edit (the
4152 // selection-delete of an overwrite) so the pair is one undo step; else it
4153 // coalesces only when it continues a run of the same-kind typing.
4154 let coalesce =
4155 force_coalesce || (kind != EditKind::Other && self.last_edit_kind == Some(kind));
4156 // The mark-edge rule holds for typed text however it is spelled — see
4157 // `splice`. Only an insert twig passed through unchanged can use it,
4158 // since a fix is measured in the bytes that actually land, and an escape
4159 // adds bytes this couldn't have counted.
4160 let fix = self.mark_edge_fix(at, at, text);
4161 self.record_caret();
4162 match self.editor.insert_literal(at, text) {
4163 Ok(change) => {
4164 if coalesce {
4165 let _ = self.editor.coalesce_last_undo();
4166 }
4167 self.last_edit_kind = Some(kind);
4168 self.refresh();
4169 self.caret = change.new.end;
4170 self.anchor = None;
4171 self.goal_col = None;
4172 self.clear_pending();
4173 self.dirty = self.source != self.clean_source;
4174 self.status = None;
4175 self.record_caret();
4176 if let Some(fix) = fix.filter(|_| change.new.end - change.new.start == text.len()) {
4177 self.repair_mark_edges(fix);
4178 }
4179 true
4180 }
4181 Err(e) => {
4182 self.status = Some(format!("edit: {e}"));
4183 false
4184 }
4185 }
4186 }
4187
4188 /// After a structural list edit (a new item, a nest/unnest), renumber the
4189 /// ordered list the caret sits in so its source markers run `1, 2, 3, …`
4190 /// again — a raw splice leaves them stale (`1. 2. 2. 3.`). twig does the
4191 /// renumber as its own edit; fold it into the edit that triggered it so the
4192 /// two undo as one, and only when it actually changed the source (a no-op or
4193 /// a caret outside any ordered list must not coalesce the real edit into the
4194 /// step before it).
4195 fn renumber_here(&mut self) {
4196 self.renumber_at(self.caret);
4197 }
4198
4199 /// [`renumber_here`](Self::renumber_here) aimed somewhere other than the
4200 /// caret — for an edit that leaves the caret one past the item it just wrote,
4201 /// where twig resolves no list to renumber.
4202 fn renumber_at(&mut self, off: usize) {
4203 // The read-only gate — this door reaches twig without the splice.
4204 if self.read_only {
4205 return;
4206 }
4207 let before = self.source.clone();
4208 if self.editor.renumber_ordered_lists(off).is_err() {
4209 return; // not inside an ordered list — nothing to renumber
4210 }
4211 self.refresh();
4212 if self.source != before {
4213 let _ = self.editor.coalesce_last_undo();
4214 self.dirty = self.source != self.clean_source;
4215 self.clamp_caret();
4216 self.record_caret();
4217 }
4218 }
4219
4220 /// Repair the one trap a list edit can spring on itself. An *empty* `-`
4221 /// sub-item written directly beneath a text line reparses that text as a
4222 /// setext heading — `- hello\n - ` is `<h2>hello</h2>`, because a lone `-`
4223 /// is also a setext-H2 underline (twig is right; pandoc agrees). `*` and `+`
4224 /// bullets can't underline anything, so swap the dash for a `*`: the item
4225 /// stays an empty nested bullet, the parent stays prose, and the source
4226 /// round-trips instead of hiding a heading the user never asked for. Folded
4227 /// into the triggering edit's undo step, the way renumbering is.
4228 ///
4229 /// Gated on the collapse having actually happened (the swapped dash was
4230 /// swallowed into a `heading`), so a real setext heading the author wrote —
4231 /// or a `- x` with content, which can't underline anything — is never
4232 /// touched. This has to live in the *edit*, not the renderer: leaving the
4233 /// hazardous bytes on disk and only painting over them would ship a file
4234 /// every other CommonMark tool reads as a heading.
4235 ///
4236 /// This one keeps its own byte scan, and has to: the hazard is precisely
4237 /// that the dash stopped being a list marker, so [`list_marker_on_line`] —
4238 /// which asks twig which lines open an item — reports nothing here. There is
4239 /// no node to ask about. It is also the last Markdown spelling leaf writes on
4240 /// purpose rather than for want of an answer; once twig spells continuations
4241 /// itself, avoiding the trap becomes twig's, and this goes.
4242 ///
4243 /// [`list_marker_on_line`]: Self::list_marker_on_line
4244 fn avoid_setext_collapse(&mut self) {
4245 let caret = self.caret.min(self.source.len());
4246 let line_start = self.source[..caret].rfind('\n').map_or(0, |i| i + 1);
4247 let bytes = self.source.as_bytes();
4248 let mut dash = line_start;
4249 while matches!(bytes.get(dash), Some(b' ' | b'\t')) {
4250 dash += 1;
4251 }
4252 // A dash bullet is the only marker that doubles as a setext underline.
4253 if bytes.get(dash) != Some(&b'-') {
4254 return;
4255 }
4256 // Only an *empty* item is a bare underline; `- x` carries content and
4257 // can't fold the line above into a heading.
4258 let line_end = self.source[dash..]
4259 .find('\n')
4260 .map_or(self.source.len(), |i| dash + i);
4261 if !self.source[dash + 1..line_end].trim().is_empty() {
4262 return;
4263 }
4264 // The tell: that dash was swallowed into a `heading`. A properly nested
4265 // empty item sits under a `list_item`, with no heading in reach. Probe
4266 // the dash byte itself (well inside the heading), not the caret, whose
4267 // end-of-line offset can fall on the half-open span boundary.
4268 let collapsed = self
4269 .editor
4270 .ancestors_at(dash)
4271 .map(|c| c.into_iter().any(|m| m.kind == Kind::Heading))
4272 .unwrap_or(false);
4273 if !collapsed {
4274 return;
4275 }
4276 let caret = self.caret;
4277 if self.splice(dash, dash + 1, "*", EditKind::Other) {
4278 // Same width, so the caret keeps its column; fold into the edit that
4279 // triggered this so Tab stays one undo step.
4280 let _ = self.editor.coalesce_last_undo();
4281 self.caret = caret.min(self.source.len());
4282 self.clamp_caret();
4283 self.record_caret();
4284 }
4285 }
4286
4287 fn snapshot(&self) -> CaretState {
4288 CaretState {
4289 caret: self.caret,
4290 anchor: self.anchor,
4291 }
4292 }
4293
4294 /// Hand twig the current caret and selection as the blob for the live
4295 /// document state. Called before an edit — so the step twig retires records
4296 /// where the caret was, and undo can restore it — and again once the op has
4297 /// placed the caret, so redo restores where the edit left it.
4298 ///
4299 /// This is the whole of leaf's undo-caret bookkeeping now. twig carries the
4300 /// caret through its own history, so coalescing falls out for free (folding
4301 /// two twig steps into one drops the intermediate blob, keeping the run's
4302 /// first) and the parallel stacks that had to march in lockstep — and could
4303 /// silently drift out of it — are gone.
4304 fn record_caret(&mut self) {
4305 let _ = self.editor.set_caret_blob(&self.snapshot().to_blob());
4306 }
4307
4308 /// Toggle an inline mark over the selection (Bold / Italic / Code / …). Keeps
4309 /// the toggled region selected so a second press cleanly reverses it.
4310 pub fn toggle(&mut self, kind: InlineKind) {
4311 // The read-only gate — this door reaches twig without the splice.
4312 if self.read_only {
4313 return;
4314 }
4315 // Ahead of the no-selection branch below: arming a mark for text not yet
4316 // typed is a promise `insert` cannot keep in a format with no delimiters
4317 // to spell it with. Per *kind*, not per format — Markdown spells five
4318 // of the eight marks (highlight among them, under the `highlight`
4319 // extension leaf parses with), djot all eight, HTML seven.
4320 if self.refuse_unsupported(&format!("{kind:?}"), Gesture::ToggleInline(kind)) {
4321 return;
4322 }
4323 let Some((s, e)) = self.selection() else {
4324 // No selection: arm the mark for the next text typed here, the way a
4325 // word processor does. `⌘b`, type, `⌘b` again toggles bold on and off
4326 // in the flow of typing without ever selecting anything — the delta
4327 // is realised onto the freshly typed text by `insert`. A fresh caret
4328 // position starts the delta over from the marks actually in force.
4329 if self.pending_at != Some(self.caret) {
4330 self.pending_marks = InlineMarks::empty();
4331 self.pending_at = Some(self.caret);
4332 }
4333 self.pending_marks.flip(kind);
4334 self.status = None;
4335 return;
4336 };
4337 // Whitespace at the edge of a selection is not part of what was chosen —
4338 // a double-click takes the space after the word with it — and a mark
4339 // cannot close against one anyway: `**word **` is four literal asterisks
4340 // (the mark-edge rule, see `splice`). Mark the words, leave the spaces.
4341 let picked = &self.source[s..e];
4342 let (s, e) = (
4343 s + (picked.len() - picked.trim_start().len()),
4344 e - (picked.len() - picked.trim_end().len()),
4345 );
4346 if s >= e {
4347 self.status = Some(format!("{kind:?}: nothing selected to mark"));
4348 return;
4349 }
4350 // Styling a selection is a one-shot act, not a sticky mode.
4351 self.clear_pending();
4352 self.record_caret();
4353 match self.editor.toggle_inline(s, e, kind) {
4354 Ok(change) => {
4355 self.last_edit_kind = None; // structural edit is its own undo step
4356 self.refresh();
4357 self.anchor = Some(change.new.start);
4358 self.caret = change.new.end;
4359 self.dirty = self.source != self.clean_source;
4360 self.status = None;
4361 self.record_caret();
4362 }
4363 Err(e) => self.status = Some(format!("{kind:?}: {e}")),
4364 }
4365 }
4366
4367 /// Whether the caret stands in a highlight — what a frontend asks to enable
4368 /// or disable its highlight-colour controls, the way
4369 /// [`caret_in_table`](Self::caret_in_table) gates the grid ones.
4370 ///
4371 /// A fact about the *caret*, and the other half of
4372 /// [`Capabilities::mark_color`], which is the fact about the format. A
4373 /// frontend needs both: djot spells a highlight and no colour for it, so a
4374 /// caret standing in `{=word=}` answers `true` here and still has no palette
4375 /// to offer.
4376 ///
4377 /// The rule is [`active_inline_marks`](Self::active_inline_marks)' rule, so
4378 /// the palette appears exactly where the Highlight button is lit — with one
4379 /// deliberate exception: a mark *armed* at a bare caret and not yet typed
4380 /// into lights the button and answers `false` here, because there is no node
4381 /// to colour until the text exists.
4382 pub fn caret_in_mark(&mut self) -> bool {
4383 self.mark_offset().is_some()
4384 }
4385
4386 /// The offset [`set_mark_color`](Self::set_mark_color) speaks for — the one
4387 /// standing in the highlight the gesture means — or `None` when neither end
4388 /// of what is selected is in one.
4389 ///
4390 /// The caret first, and the selection's *start* after it, because of what
4391 /// [`toggle`](Self::toggle) leaves behind: a fresh `==word==` is selected
4392 /// whole, with the caret at its far edge, one past the closing `==` and so
4393 /// (by `marks_at`' half-open rule) not in the mark at all. Highlight a word
4394 /// and colour it — the two presses a coloured highlight is made of — would
4395 /// otherwise refuse on the second, having just written the highlight the
4396 /// author is pointing at.
4397 fn mark_offset(&mut self) -> Option<usize> {
4398 let in_mark = |d: &mut Self, off: usize| {
4399 d.marks_at(off)
4400 .into_iter()
4401 .any(|(k, _)| k == InlineKind::Mark)
4402 .then_some(off)
4403 };
4404 let caret = self.caret.min(self.source.len());
4405 in_mark(self, caret).or_else(|| {
4406 let start = self.selection()?.0;
4407 in_mark(self, start)
4408 })
4409 }
4410
4411 /// The colour of the highlight at the caret — `None` both when the caret is
4412 /// in no highlight and when the highlight it is in names no colour, which
4413 /// are the same answer to "which swatch is lit".
4414 ///
4415 /// The innermost mark, by span, for the same reason
4416 /// [`current_heading_level`](Self::current_heading_level) walks the tree:
4417 /// what the caret is *in* is the deepest node containing it. A `data-color`
4418 /// naming a colour this build has no variant for reads as `None` — the
4419 /// renderer already draws that as a plain highlight rather than guessing,
4420 /// and the toolbar agrees with the renderer.
4421 pub fn mark_color_at_caret(&mut self) -> Option<MarkColor> {
4422 let at = self.mark_offset()?;
4423 self.mark_color_at(at)
4424 }
4425
4426 /// [`mark_color_at_caret`](Self::mark_color_at_caret) at a given offset —
4427 /// the innermost `mark` covering it, and the colour it names.
4428 fn mark_color_at(&mut self, off: usize) -> Option<MarkColor> {
4429 self.nodes()
4430 .into_iter()
4431 .filter(|n| n.kind == Kind::Mark)
4432 .filter(|n| n.span.start <= off && off < n.span.end)
4433 .min_by_key(|n| n.span.end - n.span.start)
4434 .and_then(|n| MarkColor::from_attrs(&n.attrs))
4435 }
4436
4437 /// Colour the highlight at the caret, or clear its colour with `None` — the
4438 /// palette behind a toolbar's Highlight button.
4439 ///
4440 /// Markdown only, and the one gesture whose availability is a fact about the
4441 /// *parse extensions* rather than about the format alone: the colour is
4442 /// spelled `==🔴 text==`, an emoji twig reads back out of the content and
4443 /// records as the mark's `data-color`, and only an editor parsing with
4444 /// `highlight_colors` (which [`parse_extensions`] turns on for every leaf
4445 /// document) reads it back that way. Djot spells the highlight and no colour
4446 /// for it, so this refuses there — see [`Capabilities::mark_color`].
4447 ///
4448 /// **A colour is a property of a highlight that already exists.** There is
4449 /// no "highlight this in red" here, because that is two splices and would be
4450 /// two undo steps under one press; a frontend that wants it calls
4451 /// [`toggle`](Self::toggle) with [`InlineKind::Mark`] first, which is the
4452 /// order the two buttons already sit in. With no highlight at the caret this
4453 /// says so in the status line and writes nothing.
4454 ///
4455 /// The caret keeps its place in the *text*: the splice is entirely in the
4456 /// prefix between the opening `==` and the first word, so an offset past it
4457 /// rides the emoji's width, and one standing on the prefix itself lands
4458 /// where the prefix now ends.
4459 pub fn set_mark_color(&mut self, color: Option<MarkColor>) {
4460 // The read-only gate — this door reaches twig without the splice.
4461 if self.read_only {
4462 return;
4463 }
4464 if self.refuse_unsupported("highlight colour", Gesture::SetMarkColor) {
4465 return;
4466 }
4467 let Some(at) = self.mark_offset() else {
4468 self.status = Some("highlight colour: no highlight at the caret".into());
4469 return;
4470 };
4471 // Clearing a colour a highlight hasn't got is twig's one *successful*
4472 // no-op, and the `Change` it hands back then describes whatever edit came
4473 // before it — a stale span that would drag the caret somewhere it never
4474 // was. Answer it here, where the question is cheap, rather than trusting
4475 // a change that isn't one.
4476 if color.is_none() && self.mark_color_at(at).is_none() {
4477 self.status = None;
4478 return;
4479 }
4480 self.record_caret();
4481 match self.editor.set_mark_color(at, color.map(twig_mark_color)) {
4482 Ok(change) => {
4483 // Re-anchored from the offsets as they were, *before* `refresh`
4484 // sees the new bytes: the caret it clamps is one standing inside
4485 // a prefix that didn't exist a moment ago, and walking it back to
4486 // a char boundary of the emoji loses the place this is restoring.
4487 let caret = reanchor(self.caret, &change);
4488 let anchor = self.anchor.map(|a| reanchor(a, &change));
4489 self.last_edit_kind = None; // structural edit is its own undo step
4490 self.refresh();
4491 self.caret = caret;
4492 self.anchor = anchor;
4493 self.dirty = self.source != self.clean_source;
4494 self.status = None;
4495 self.clamp_caret();
4496 self.record_caret();
4497 }
4498 Err(e) => self.status = Some(format!("highlight colour: {e}")),
4499 }
4500 }
4501
4502 /// One press of a colour swatch: colour the highlight at the caret, or —
4503 /// over a selection that isn't highlighted yet — highlight it and colour it,
4504 /// as **one** undo step.
4505 ///
4506 /// [`set_mark_color`](Self::set_mark_color) is the exact gesture and stays
4507 /// one splice; this is the compound every toolbar actually presses, and it
4508 /// lives here rather than in each frontend because the rule it encodes —
4509 /// what a swatch means when there is no highlight under it yet — is one
4510 /// answer, not one per frontend. The two splices are folded into a single
4511 /// history step, so the press that made a red highlight is taken back by a
4512 /// single undo rather than leaving an uncoloured one behind.
4513 ///
4514 /// `None` clears the colour, and over an unhighlighted selection means
4515 /// simply "highlight this" — the same thing the Highlight button does.
4516 /// A bare caret in no highlight is left alone with a status line, because
4517 /// [`toggle`](Self::toggle) there arms a mark for text not yet typed and a
4518 /// colour cannot be armed with it.
4519 pub fn highlight(&mut self, color: Option<MarkColor>) {
4520 if self.caret_in_mark() || self.selection().is_none() {
4521 self.set_mark_color(color);
4522 return;
4523 }
4524 self.toggle(InlineKind::Mark);
4525 // The format may not spell a highlight at all (`toggle` said so), and
4526 // there is nothing to colour if it doesn't.
4527 if self.status.is_some() {
4528 return;
4529 }
4530 let before = self.revision;
4531 self.set_mark_color(color);
4532 // Only fold when the colour really spliced. `highlight(None)` over a
4533 // fresh highlight is a no-op by design, and coalescing there would eat
4534 // the *previous* edit into the toggle instead.
4535 if self.revision != before {
4536 let _ = self.editor.coalesce_last_undo();
4537 }
4538 }
4539
4540 // ── the presentation vocabulary ─────────────────────────────────────────
4541 //
4542 // Six gestures and five queries over twig's two attribute ops. Each gesture
4543 // edits **one key and keeps the rest**: it reads the node's attributes,
4544 // removes its own key (and, for alignment, its own tokens out of `class`),
4545 // adds the new value or nothing, and passes the list back whole — twig's
4546 // contract is replace-not-merge, so the read is the caller's job. A
4547 // paragraph that came in as `class="lead center" id="intro"
4548 // data-line-height="1.5"` and is right-aligned goes out as `class="lead
4549 // right" id="intro" data-line-height="1.5"`. Nothing leaf did not write is
4550 // touched, which is what lets a document from elsewhere pass through the
4551 // editor unharmed.
4552 //
4553 // Clearing is the same gesture with `None`: the key goes, and an empty list
4554 // at the end unwraps the span or the Markdown div, which twig does.
4555
4556 /// Set — or with `None` clear — the alignment of the block the caret is in.
4557 ///
4558 /// A block property, so the gesture is `set_block_attrs` on the caret's
4559 /// block **whatever is selected**: a line is a block's, and "centre this"
4560 /// with three words selected means the paragraph, not the words. The
4561 /// vocabulary is [`Align`], written as `class` tokens; other tokens on the
4562 /// same `class` are kept.
4563 ///
4564 /// In Markdown the attributes live on a `<div>` around the block — twig has
4565 /// no paragraph attribute syntax to write — and this reads them back off
4566 /// that div when the block is its sole child, so a second press rewrites
4567 /// the div rather than nesting a second one.
4568 pub fn set_alignment(&mut self, align: Option<Align>) {
4569 let attrs = self.block_attrs_at_caret();
4570 if align.is_none()
4571 && self.refuse_clear_from_div("alignment", &attrs, |a| Align::from_attrs(a).is_some())
4572 {
4573 return;
4574 }
4575 let attrs = with_class_token(
4576 &attrs,
4577 |t| Align::from_token(t).is_some(),
4578 align.map(Align::name),
4579 );
4580 self.write_block_attrs("alignment", attrs);
4581 }
4582
4583 /// Set — or with `None` clear — the line spacing of the block the caret is
4584 /// in. [`set_alignment`](Self::set_alignment)'s peer in every respect but
4585 /// the key: [`LineHeight`] under `data-line-height`, one of the menu's
4586 /// three names or an exact ratio, written in its canonical spelling.
4587 pub fn set_line_spacing(&mut self, spacing: Option<LineHeight>) {
4588 let attrs = self.block_attrs_at_caret();
4589 if spacing.is_none()
4590 && self.refuse_clear_from_div("line spacing", &attrs, |a| {
4591 LineHeight::from_attrs(a).is_some()
4592 })
4593 {
4594 return;
4595 }
4596 let spelling = spacing.map(LineHeight::name);
4597 let attrs = with_attr(&attrs, "data-line-height", spelling.as_deref());
4598 self.write_block_attrs("line spacing", attrs);
4599 }
4600
4601 /// Set — or with `None` clear — the size of the selected run, or of the
4602 /// caret's whole block when nothing is selected.
4603 ///
4604 /// Size, face and colour are the *run's*, and the block's when no run is
4605 /// chosen. With a selection the gesture is `wrap_range_attrs`, which wraps
4606 /// the range in an attributed span or re-styles the span it already lies in
4607 /// (never nesting a second, and unwrapping it when the last key goes). With
4608 /// no selection it is `set_block_attrs` on the caret's block, so that "make
4609 /// this paragraph larger" is a click with the caret in it rather than a
4610 /// select-all first.
4611 ///
4612 /// The walker reads the key at both levels with the nearer winning, so a
4613 /// span's `data-size` inside a block carrying its own applies to the span.
4614 ///
4615 /// The vocabulary is [`FontSize`]: one of CSS's seven keywords, which is
4616 /// what a menu offers first because a step reads as a step up under every
4617 /// theme, or the point size an author asked for, which is exact and is all
4618 /// it is. Either is written in its canonical spelling, so a size set twice
4619 /// from the same field writes the same bytes both times.
4620 pub fn set_font_size(&mut self, size: Option<FontSize>) {
4621 let spelling = size.map(FontSize::name);
4622 self.set_run_attr("size", "data-size", spelling.as_deref());
4623 }
4624
4625 /// Set — or with `None` clear — the face of the selected run, or of the
4626 /// caret's whole block. [`set_font_size`](Self::set_font_size)'s peer, with
4627 /// [`FontFace`] under `data-font` — one of the four generics, or the family
4628 /// the author named, which the frontends resolve through the platform's
4629 /// font registry and fall back to the body face without.
4630 pub fn set_font_family(&mut self, font: Option<FontFace>) {
4631 let spelling = font.as_ref().map(FontFace::name);
4632 self.set_run_attr("font", "data-font", spelling.as_deref());
4633 }
4634
4635 /// Set — or with `None` clear — the *text* colour of the selected run, or of
4636 /// the caret's whole block. [`set_font_size`](Self::set_font_size)'s peer,
4637 /// with [`TextColor`] under `data-color` — one of the seven names, whose
4638 /// two inks the theme owns, or the triple the author picked, which is
4639 /// painted as written in both appearances.
4640 ///
4641 /// The same key and the same seven names [`set_mark_color`](Self::set_mark_color)
4642 /// writes, and a different thing: that one colours a highlight's
4643 /// *background* and rides the `mark` node twig owns the spelling of, this
4644 /// one colours the letters and rides an attributed span. The two never
4645 /// collide, because a `mark` is a `mark` and a span is a span — and they
4646 /// share a vocabulary on purpose, so that a frontend with a red for a
4647 /// highlight has a red for text and both are *that* red.
4648 pub fn set_text_color(&mut self, color: Option<TextColor>) {
4649 let spelling = color.map(TextColor::name);
4650 self.set_run_attr("text colour", "data-color", spelling.as_deref());
4651 }
4652
4653 /// Insert a page break at the caret — `::page-break`, a leaf directive with
4654 /// no label and no attributes, which twig spells in every format that names
4655 /// a leaf container (Markdown under the `directives` extension
4656 /// [`parse_extensions`] turns on, and djot, where it is an empty `:::
4657 /// page-break` fence).
4658 ///
4659 /// Placed exactly as [`insert_thematic_break`](Self::insert_thematic_break)
4660 /// places a rule, and for the same reason: a directive is a block, so twig
4661 /// alone has nowhere to put one mid-paragraph and lands it after the
4662 /// caret's whole block. A bare paragraph is therefore parted at the caret
4663 /// first and the break aimed at the *first* half. See that method for the
4664 /// whole of the rule, including why a code block, a list item, a table and
4665 /// a setext heading are left unsplit.
4666 ///
4667 /// The frontends that paginate read the row's
4668 /// [`DirectiveMark`](crate::wysiwyg::DirectiveMark) and open a page there;
4669 /// the ones that do not draw the `⧉ page-break` placeholder every leaf
4670 /// directive gets.
4671 pub fn insert_page_break(&mut self) {
4672 if self.read_only || self.refuse_unsupported("page break", Gesture::InsertDirective) {
4673 return;
4674 }
4675 self.caret = self.skip_trailing_close_delims(self.caret);
4676 // A selection is replaced by the break, as a rule replaces one.
4677 if let Some((s, e)) = self.selection() {
4678 self.splice(s, e, "", EditKind::Other);
4679 }
4680 self.anchor = None;
4681 self.record_caret();
4682 let at = self.caret;
4683 if self.caret_parts_bare_paragraph() {
4684 // A failure here is not fatal: the break still lands after the
4685 // block, which is what this call was trying to improve on.
4686 let _ = self.editor.split_block(at);
4687 }
4688 match self.editor.insert_directive(at, PAGE_BREAK, None, &[]) {
4689 Ok(change) => {
4690 self.last_edit_kind = None;
4691 self.refresh();
4692 self.anchor = None;
4693 self.caret = change.new.end;
4694 self.dirty = self.source != self.clean_source;
4695 self.status = None;
4696 self.clamp_caret();
4697 self.record_caret();
4698 }
4699 Err(e) => self.status = Some(format!("page break: {e}")),
4700 }
4701 }
4702
4703 /// The alignment in force at the caret, or `None` for the theme's default —
4704 /// which swatch of an alignment control is lit.
4705 ///
4706 /// Read off the nearest node that names one: the block the caret is in, and
4707 /// the `div`s around it after that. [`mark_color_at_caret`](Self::mark_color_at_caret)'s
4708 /// shape, one property along.
4709 pub fn alignment_at_caret(&mut self) -> Option<Align> {
4710 self.presentation_chain()
4711 .iter()
4712 .find_map(|attrs| Align::from_attrs(attrs))
4713 }
4714
4715 /// The line spacing in force at the caret, or `None` for the theme's own.
4716 /// [`alignment_at_caret`](Self::alignment_at_caret)'s peer.
4717 pub fn line_spacing_at_caret(&mut self) -> Option<LineHeight> {
4718 self.presentation_chain()
4719 .iter()
4720 .find_map(|attrs| LineHeight::from_attrs(attrs))
4721 }
4722
4723 /// The size in force at the caret, or `None` for the theme's own — the
4724 /// entry a size menu shows ticked.
4725 ///
4726 /// Run-level, so the chain starts one node deeper: the attributed span the
4727 /// caret stands in, then its block, then the `div`s around it. The nearest
4728 /// wins, which is the rule the walker draws by.
4729 ///
4730 /// A name or a value, whichever the nearest node wrote. A `data-size` the
4731 /// grammar does not cover — a `huge` from elsewhere — is not a size this
4732 /// can answer, so the answer is `None` and the menu ticks *Default*, the
4733 /// same thing it did before the vocabulary opened.
4734 pub fn font_size_at_caret(&mut self) -> Option<FontSize> {
4735 self.presentation_chain()
4736 .iter()
4737 .find_map(|attrs| FontSize::from_attrs(attrs))
4738 }
4739
4740 /// The face in force at the caret, or `None` for the theme's body face.
4741 /// [`font_size_at_caret`](Self::font_size_at_caret)'s peer.
4742 pub fn font_family_at_caret(&mut self) -> Option<FontFace> {
4743 self.presentation_chain()
4744 .iter()
4745 .find_map(|attrs| FontFace::from_attrs(attrs))
4746 }
4747
4748 /// The *text* colour in force at the caret, or `None` for the theme's.
4749 /// [`font_size_at_caret`](Self::font_size_at_caret)'s peer, and not
4750 /// [`mark_color_at_caret`](Self::mark_color_at_caret) — that one reads a
4751 /// highlight's background off a `mark`, and a `mark` is never in this chain.
4752 pub fn text_color_at_caret(&mut self) -> Option<TextColor> {
4753 self.presentation_chain()
4754 .iter()
4755 .find_map(|attrs| TextColor::from_attrs(attrs))
4756 }
4757
4758 /// The selection-or-caret half of the three run-level gestures: a span over
4759 /// a real selection, the caret's block over none.
4760 fn set_run_attr(&mut self, what: &str, key: &str, value: Option<&str>) {
4761 match self.selection() {
4762 Some((start, end)) => {
4763 let attrs = with_attr(&self.run_attrs_over(start, end), key, value);
4764 self.write_run_attrs(what, start, end, attrs);
4765 }
4766 None => {
4767 let own = self.block_attrs_at_caret();
4768 if value.is_none()
4769 && self.refuse_clear_from_div(what, &own, |a| a.iter().any(|(k, _)| k == key))
4770 {
4771 return;
4772 }
4773 let attrs = with_attr(&own, key, value);
4774 self.write_block_attrs(what, attrs);
4775 }
4776 }
4777 }
4778
4779 /// A clear this gesture cannot carry out, said out loud instead of written:
4780 /// the node it rewrites — the caret's block, or the `<div>` around it that
4781 /// [`block_attrs_at_caret`](Self::block_attrs_at_caret) folds to in Markdown
4782 /// — does not name the property at all, and a `div` further out does.
4783 ///
4784 /// Handing twig the block's attributes with the key already absent changes
4785 /// no byte, and the query goes on answering `Some` off the div: the menu
4786 /// entry the author pressed stays unticked, and nothing says why. Twig's
4787 /// `set_block_attrs` reaches one node, so leaf cannot clear a key it did not
4788 /// write on a node it is not rewriting — the honest answer is the status
4789 /// line, in the voice the other refusals use.
4790 ///
4791 /// `names` is the property's own reading of an attribute list, because
4792 /// alignment lives in a `class` token rather than a key of its own. Spans
4793 /// are skipped: one inside the block is not what a *block* gesture writes
4794 /// either, but neither is it "the div around the block", and the run-level
4795 /// gestures reach it through a selection.
4796 fn refuse_clear_from_div(
4797 &mut self,
4798 what: &str,
4799 own: &Attrs,
4800 names: impl Fn(&Attrs) -> bool,
4801 ) -> bool {
4802 if names(own) {
4803 return false;
4804 }
4805 let caret = self.caret.min(self.source.len());
4806 if !self
4807 .attr_chain_at(caret)
4808 .iter()
4809 .any(|(span, attrs)| !span && names(attrs))
4810 {
4811 return false;
4812 }
4813 self.status = Some(format!("{what}: set on the div around the block"));
4814 true
4815 }
4816
4817 /// Hand `attrs` to twig as the caret's block's whole attribute set, with the
4818 /// status, undo and caret plumbing [`set_mark_color`](Self::set_mark_color)
4819 /// has.
4820 ///
4821 /// **The caret keeps its place in the text, not its byte offset.** How a
4822 /// format spells a block's attributes is markup written *around* the block
4823 /// — djot's `{…}` line above it, a `<div …>` and two blank lines in front of
4824 /// it in Markdown, a longer opening tag in HTML — and every one of those
4825 /// grows or shrinks above the author's own bytes. Where twig's change
4826 /// rewrites the block whole (Markdown's div is spliced as one region, block
4827 /// included) the plain arithmetic of [`reanchor`] has nothing to shift by
4828 /// and parks the caret at the end of the splice, past the closing `</div>`:
4829 /// the caret is then in no block at all, so a second press of the same menu
4830 /// answers "no block at the caret" and the toolbar's queries read nothing.
4831 /// [`reanchor_in_block`] is what carries it across instead — the block's
4832 /// content span before and after, which is the one thing the respelling
4833 /// leaves alone.
4834 ///
4835 /// Read *before* the splice and applied *after* `refresh`, because both
4836 /// halves of that mapping are facts about a tree twig is between: the
4837 /// block's old bytes are gone once the edit lands, and its new ones are not
4838 /// in `self.source` until the refresh puts them there.
4839 fn write_block_attrs(&mut self, what: &str, attrs: Attrs) {
4840 if self.read_only || self.refuse_unsupported(what, Gesture::SetBlockAttrs) {
4841 return;
4842 }
4843 // A blank line has no block to carry an attribute, and twig answers
4844 // `NotFound` there — say so in leaf's own words instead.
4845 let Some(at) = self.block_offset_for_caret() else {
4846 self.status = Some(format!("{what}: no block at the caret"));
4847 return;
4848 };
4849 self.record_caret();
4850 let pairs = attr_pairs(&attrs);
4851 let was = self.block_content_at(at);
4852 let text = was.clone().map(|s| self.source[s].to_string());
4853 match self.editor.set_block_attrs(at, &pairs) {
4854 Ok(change) => {
4855 let (caret, anchor) = (self.caret, self.anchor);
4856 self.last_edit_kind = None; // structural edit is its own undo step
4857 self.refresh();
4858 let now = self.block_content_in(&change.new, text.as_deref());
4859 // A block the two halves cannot both name — a code block, a
4860 // caret in a list's marker — takes the plain arithmetic, which
4861 // is what it had before.
4862 let block = was.as_ref().zip(now.as_ref());
4863 self.caret = reanchor_in_block(caret, &change, block);
4864 self.anchor = anchor.map(|a| reanchor_in_block(a, &change, block));
4865 self.dirty = self.source != self.clean_source;
4866 self.status = None;
4867 // The clamp reads the caret floor off the map, and this edit
4868 // can move the floor: taking the `{…}` line off a djot
4869 // document's first block moves the first rendered offset to 0,
4870 // and a floor read from the old map stood the caret past the
4871 // block's text. So the map is this revision's before the clamp
4872 // — see `open_paragraph_at_block_edge`.
4873 self.rebuild_map();
4874 self.clamp_caret();
4875 self.record_caret();
4876 }
4877 Err(e) => self.status = Some(format!("{what}: {e}")),
4878 }
4879 }
4880
4881 /// The content span of the innermost paragraph or heading covering `off` —
4882 /// the author's own bytes, without the `# ` or the `<p>` that spells the
4883 /// block around them.
4884 ///
4885 /// The same two kinds [`block_attrs_at_caret`](Self::block_attrs_at_caret)
4886 /// reads, so that what a gesture re-anchors by is the block it wrote to.
4887 fn block_content_at(&mut self, off: usize) -> Option<Range<usize>> {
4888 self.nodes()
4889 .into_iter()
4890 .filter(|n| matches!(n.kind, Kind::Para | Kind::Heading))
4891 .filter(|n| n.span.start <= off && off <= n.span.end)
4892 .min_by_key(|n| n.span.end - n.span.start)
4893 .map(|n| n.content_span.unwrap_or(n.span))
4894 }
4895
4896 /// [`block_content_at`](Self::block_content_at)'s other half: the content
4897 /// span of the block `region` holds now, found by the bytes it held before.
4898 ///
4899 /// Matched on the text rather than taken as the first block in the region,
4900 /// because a rewritten region is markup and all — `<div class="center">`
4901 /// carries words of its own — and because the block this gesture moved is
4902 /// the one whose content the respelling did not touch. `None` where the
4903 /// region holds no block at all, which is djot's every case: the `{…}` line
4904 /// is spliced above the block and the block itself never moves through the
4905 /// change at all, only past it.
4906 fn block_content_in(
4907 &mut self,
4908 region: &Range<usize>,
4909 text: Option<&str>,
4910 ) -> Option<Range<usize>> {
4911 let text = text?;
4912 let spans: Vec<Range<usize>> = self
4913 .nodes()
4914 .into_iter()
4915 .filter(|n| matches!(n.kind, Kind::Para | Kind::Heading))
4916 .filter(|n| region.start <= n.span.start && n.span.end <= region.end)
4917 .map(|n| n.content_span.unwrap_or(n.span))
4918 .collect();
4919 spans
4920 .into_iter()
4921 .find(|s| self.source.get(s.clone()) == Some(text))
4922 }
4923
4924 /// Hand `attrs` to twig as the attribute set of the span over `[start,
4925 /// end)` — wrapping one, or re-styling the one the range already lies in,
4926 /// or unwrapping it when `attrs` is empty.
4927 ///
4928 /// What the splice leaves selected is the span's **content** — the author's
4929 /// words — and not the whole of `change.new`, which is markup and all:
4930 /// `[big]{data-size="large"}` in djot, `<span …>big</span>` in Markdown. A
4931 /// selection reaching past the node's own span lies in no span at all, so a
4932 /// second press of the menu would nest a fresh one instead of re-styling
4933 /// the one just written.
4934 fn write_run_attrs(&mut self, what: &str, start: usize, end: usize, attrs: Attrs) {
4935 if self.read_only || self.refuse_unsupported(what, Gesture::WrapRangeAttrs) {
4936 return;
4937 }
4938 self.record_caret();
4939 let pairs = attr_pairs(&attrs);
4940 match self.editor.wrap_range_attrs(start, end, &pairs) {
4941 Ok(change) => {
4942 self.last_edit_kind = None;
4943 self.refresh();
4944 let content = self.span_content_in(&change.new);
4945 self.anchor = Some(content.start);
4946 self.caret = content.end;
4947 self.dirty = self.source != self.clean_source;
4948 self.status = None;
4949 self.clamp_caret();
4950 self.record_caret();
4951 }
4952 Err(e) => self.status = Some(format!("{what}: {e}")),
4953 }
4954 }
4955
4956 /// The content range of the attributed span `spliced` now holds — the
4957 /// outermost one inside it, since that is the one just written — or
4958 /// `spliced` itself where the splice left no span, which is what an unwrap
4959 /// leaves behind.
4960 fn span_content_in(&mut self, spliced: &Range<usize>) -> Range<usize> {
4961 self.nodes()
4962 .into_iter()
4963 .filter(wysiwyg::is_run_span)
4964 .filter(|n| spliced.start <= n.span.start && n.span.end <= spliced.end)
4965 .max_by_key(|n| n.span.end - n.span.start)
4966 .and_then(|n| n.content_span)
4967 .unwrap_or_else(|| spliced.clone())
4968 }
4969
4970 /// The attribute set `set_block_attrs` is about to **replace** at the caret
4971 /// — which is the block's own, except in Markdown, where twig writes a
4972 /// block's attributes onto a `<div>` around it and rewrites that div when
4973 /// the block is its sole child. Reading the paragraph there would hand back
4974 /// an empty list and quietly drop everything the div said.
4975 ///
4976 /// Empty when the caret is in no block at all, which is the same list a
4977 /// block carrying no attributes gives — and the right one either way, since
4978 /// the gesture then refuses on its own.
4979 fn block_attrs_at_caret(&mut self) -> Attrs {
4980 let Some(off) = self.block_offset_for_caret() else {
4981 return Vec::new();
4982 };
4983 let nodes = self.nodes();
4984 let Some(block) = nodes
4985 .iter()
4986 .filter(|n| matches!(n.kind, Kind::Para | Kind::Heading))
4987 .filter(|n| n.span.start <= off && off <= n.span.end)
4988 .min_by_key(|n| n.span.end - n.span.start)
4989 else {
4990 return Vec::new();
4991 };
4992 if self.format == Format::Markdown
4993 && let Some(parent) = block.parent.and_then(|p| nodes.iter().find(|n| n.id == p))
4994 && wysiwyg::element_tag(parent) == Some("div")
4995 && nodes.iter().filter(|n| n.parent == Some(parent.id)).count() == 1
4996 {
4997 return parent.attrs.clone();
4998 }
4999 block.attrs.clone()
5000 }
5001
5002 /// The attribute set `wrap_range_attrs` is about to **replace** over
5003 /// `[start, end)` — the innermost attributed span the range lies inside,
5004 /// which twig re-styles rather than nesting a second one in. Empty when the
5005 /// range lies in no span, where the gesture mints a fresh one.
5006 fn run_attrs_over(&mut self, start: usize, end: usize) -> Attrs {
5007 self.nodes()
5008 .into_iter()
5009 .filter(wysiwyg::is_run_span)
5010 .filter(|n| n.span.start <= start && end <= n.span.end)
5011 .min_by_key(|n| n.span.end - n.span.start)
5012 .map(|n| n.attrs)
5013 .unwrap_or_default()
5014 }
5015
5016 /// The attribute lists that bear on a presentation query, **nearest first**:
5017 /// the attributed spans the caret stands in (innermost first), then its
5018 /// block, then the `div`s around it. A `find_map` down this is the whole of
5019 /// each query, and the order is the rule the walker draws by.
5020 ///
5021 /// Read at the caret, and at the selection's *start* when the caret stands
5022 /// in no span there. [`write_run_attrs`](Self::write_run_attrs) leaves the
5023 /// caret one past the span it just wrote — `toggle`'s convention — so
5024 /// asking the menu which entry that press just ticked must not answer
5025 /// `None`. Exactly the reason [`mark_offset`](Self::mark_offset) tries both.
5026 fn presentation_chain(&mut self) -> Vec<Attrs> {
5027 let caret = self.caret.min(self.source.len());
5028 let mut chain = self.attr_chain_at(caret);
5029 if !chain.iter().any(|(span, _)| *span)
5030 && let Some((start, _)) = self.selection()
5031 {
5032 let alt = self.attr_chain_at(start);
5033 if alt.iter().any(|(span, _)| *span) {
5034 chain = alt;
5035 }
5036 }
5037 chain.into_iter().map(|(_, attrs)| attrs).collect()
5038 }
5039
5040 /// [`presentation_chain`](Self::presentation_chain) at one offset — every
5041 /// node bearing the vocabulary that covers it, innermost first, each paired
5042 /// with whether it is an attributed span (which is what tells the caller
5043 /// its run-level answer came from a run).
5044 ///
5045 /// Sorted by span length, which *is* the nesting order: a span lies inside
5046 /// its block and a block inside its div, so shortest-first is
5047 /// nearest-first without a second tree walk.
5048 fn attr_chain_at(&mut self, off: usize) -> Vec<(bool, Attrs)> {
5049 let off = off.min(self.source.len());
5050 let mut hits: Vec<(usize, bool, Attrs)> = Vec::new();
5051 for n in self.nodes() {
5052 let span = wysiwyg::is_run_span(&n);
5053 let block = matches!(n.kind, Kind::Para | Kind::Heading);
5054 let div = wysiwyg::element_tag(&n) == Some("div");
5055 if !(span || block || div) {
5056 continue;
5057 }
5058 // A span is half-open, the way a mark is: the offset one past it is
5059 // the text after it. A block and a div claim their end too, so a
5060 // caret resting at the end of a line still reads its paragraph.
5061 let inside = if span {
5062 n.span.start <= off && off < n.span.end
5063 } else {
5064 n.span.start <= off && off <= n.span.end
5065 };
5066 if !inside {
5067 continue;
5068 }
5069 hits.push((n.span.end - n.span.start, span, n.attrs));
5070 }
5071 hits.sort_by_key(|(len, _, _)| *len);
5072 hits.into_iter()
5073 .map(|(_, span, attrs)| (span, attrs))
5074 .collect()
5075 }
5076
5077 /// Convert the block at the caret to a heading level or paragraph.
5078 pub fn set_block(&mut self, kind: BlockKind) {
5079 // The read-only gate — this door reaches twig without the splice.
5080 if self.read_only {
5081 return;
5082 }
5083 if self.refuse_unsupported(&format!("{kind:?}"), Gesture::SetBlock) {
5084 return;
5085 }
5086 self.record_caret();
5087 // A blank line has no node to convert, and twig opens a block there
5088 // rather than declining — so the caret's own offset is the right thing
5089 // to hand it when `block_offset_for_caret` finds nothing.
5090 let offset = self.block_offset_for_caret().unwrap_or(self.caret);
5091 match self.editor.set_block(offset, kind) {
5092 Ok(change) => {
5093 self.last_edit_kind = None;
5094 self.refresh();
5095 // Opening a block on a blank line writes a marker the caret
5096 // belongs *after*; converting an existing one moves nothing.
5097 self.caret = self.caret.max(change.new.end);
5098 self.clamp_caret();
5099 self.anchor = None;
5100 self.dirty = self.source != self.clean_source;
5101 self.status = None;
5102 self.record_caret();
5103 }
5104 Err(e) => self.status = Some(format!("{kind:?}: {e}")),
5105 }
5106 }
5107
5108 /// Whether `off` is inside a text block (paragraph, heading, code block…).
5109 fn has_block_at(&mut self, off: usize) -> bool {
5110 self.editor.ancestors_at(off).ok().is_some_and(|chain| {
5111 chain
5112 .iter()
5113 .any(|m| !wysiwyg::is_inline_kind(&m.kind) && !is_block_container(&m.kind))
5114 })
5115 }
5116
5117 /// The offset to hand twig's `set_block`: the caret when it is already inside
5118 /// a block, otherwise nudged onto the previous character (a caret at a line
5119 /// end sits at the doc level, outside the block). `None` when the caret is on
5120 /// a blank line — a new paragraph with no block node to convert.
5121 fn block_offset_for_caret(&mut self) -> Option<usize> {
5122 let caret = self.caret.min(self.source.len());
5123 if self.has_block_at(caret) {
5124 return Some(caret);
5125 }
5126 // Nudge to the previous character — but never across a newline: that would
5127 // target the previous block, and a blank line genuinely has no block.
5128 if let Some((i, ch)) = self.source[..caret].char_indices().next_back()
5129 && ch != '\n'
5130 && self.has_block_at(i)
5131 {
5132 return Some(i);
5133 }
5134 None
5135 }
5136
5137 /// The heading level of the text block at the caret, or `None` when that
5138 /// block is not a heading.
5139 pub fn current_heading_level(&mut self) -> Option<u32> {
5140 let caret = self.caret;
5141 self.nodes()
5142 .into_iter()
5143 .filter(|n| n.kind == Kind::Heading)
5144 .find(|n| n.span.start <= caret && caret <= n.span.end)
5145 .and_then(|n| n.level)
5146 }
5147
5148 /// The inline marks in force at the caret (or over the selection) — what a
5149 /// toolbar draws lit, and the block-level [`Doc::current_heading_level`]'s
5150 /// inline counterpart. Cheap enough to call every frame: one twig
5151 /// `ancestors_at` query per caret (two with a selection), each walking root
5152 /// → deepest node at one offset. It never snapshots the tree the way
5153 /// `current_heading_level` does, and the returned set is a `Copy` bitset, so
5154 /// the only allocation is twig's own small ancestor `Vec`.
5155 ///
5156 /// **A selection reports a mark only when the mark covers *all* of it.**
5157 /// That's what every real toolbar means by an active button — Bold lit over
5158 /// a half-bold selection would claim a press turns bold *off*, when
5159 /// [`Doc::toggle`] hands the range to twig and gets the whole thing bolded.
5160 /// Whole-coverage is asked as "is the same mark node standing over both the
5161 /// first and the last character?": inline nodes are contiguous, so one node
5162 /// covering both ends covers every byte between them. Two touching runs
5163 /// (`**a****b**`) are two nodes, and correctly light nothing.
5164 ///
5165 /// At a bare caret a mark is active when the caret stands inside the mark's
5166 /// span — `span.start <= caret < span.end`, delimiters included, which is
5167 /// what makes the boundaries behave. In `a **bold** b` the offsets from the
5168 /// opening `*` (2) through the last byte of the closing `**` (9) are all
5169 /// bold, so the WYSIWYG caret both before `b` and after `d` (the delimiters
5170 /// are hidden, and those offsets are 4 and 8) reports bold — matching where
5171 /// typing would actually land inside the marked run. The offset one past the
5172 /// mark (10) is the text after it and reports nothing, at the end of the
5173 /// buffer exactly as in the middle.
5174 pub fn active_inline_marks(&mut self) -> InlineMarks {
5175 let Some((start, end)) = self.selection() else {
5176 // The marks actually in force at the caret, flipped by any armed
5177 // sticky delta — so `⌘b` at a bare caret lights the Bold button
5178 // immediately, before a single character is typed.
5179 let base: InlineMarks = self
5180 .marks_at(self.caret)
5181 .into_iter()
5182 .map(|(k, _)| k)
5183 .collect();
5184 return base.xor(self.pending_here());
5185 };
5186 // The selection's *last character*, not its exclusive end: `end` is the
5187 // offset one past the selection, which for a selection ending exactly at
5188 // a mark's close is already outside it (`[4,10)` of `a **bold** b` is
5189 // entirely bold, but offset 10 is the space after).
5190 let last = prev_boundary(&self.source, end);
5191 let head = self.marks_at(start);
5192 let tail = self.marks_at(last);
5193 head.into_iter()
5194 .filter(|m| tail.contains(m))
5195 .map(|(k, _)| k)
5196 .collect()
5197 }
5198
5199 /// The inline marks whose span covers `off`, each with the id of the node
5200 /// carrying it — the id is what lets a selection tell one mark node from
5201 /// another of the same kind.
5202 fn marks_at(&mut self, off: usize) -> Vec<(InlineKind, u32)> {
5203 let off = off.min(self.source.len());
5204 self.editor
5205 .ancestors_at(off)
5206 .unwrap_or_default()
5207 .into_iter()
5208 // `span.end` is the offset one *past* the mark, so it isn't in it.
5209 // twig already resolves a boundary to whatever starts there — in
5210 // `**bold** x` offset 8 is the following text, not the strong — but
5211 // when nothing follows, the tie has nobody to break for and the
5212 // chain still ends at the mark. That would make the answer at the
5213 // last offset of the document depend on whether the file happens to
5214 // end in a newline; the rule is `span.start <= off < span.end`, and
5215 // it's the same rule at the end of a buffer as in the middle.
5216 .filter(|m| off < m.span.end)
5217 .filter_map(|m| inline_kind(&m.kind).map(|k| (k, m.node_id)))
5218 .collect()
5219 }
5220
5221 /// Toggle a heading at the caret: if the block is already this heading level,
5222 /// revert it to a paragraph; otherwise convert it to this heading level.
5223 /// This gives the heading commands the same toggle feel as bold/italic/code —
5224 /// re-applying a heading a line already has turns it back into body text.
5225 pub fn toggle_heading(&mut self, level: u32) {
5226 if self.current_heading_level() == Some(level) {
5227 self.set_block(BlockKind::Paragraph);
5228 } else {
5229 self.set_block(BlockKind::Heading(level));
5230 }
5231 }
5232
5233 /// Toggle a block quote around the selection, or around the block at the
5234 /// caret — the toolbar's Quote button.
5235 pub fn toggle_blockquote(&mut self) {
5236 self.toggle_container(BlockContainerKind::BlockQuote);
5237 }
5238
5239 /// Toggle a numbered (`ordered`) or bulleted list over the selection, or
5240 /// over the block at the caret — one op with the kind as a flag, the way
5241 /// `toggle_heading` takes its level, so a frontend needs no twig type to
5242 /// name the two buttons.
5243 ///
5244 /// Pressing the *other* list's button while in a list converts in place
5245 /// rather than nesting, so the pair reads as one three-state control
5246 /// (bulleted / numbered / neither) rather than two independent wrappers.
5247 pub fn toggle_list(&mut self, ordered: bool) {
5248 self.toggle_container(if ordered {
5249 BlockContainerKind::OrderedList
5250 } else {
5251 BlockContainerKind::BulletList
5252 });
5253 }
5254
5255 /// Toggle a fenced code block over the selection, or over the block at the
5256 /// caret — the toolbar's Code Block button, and the only way the rich view
5257 /// offers to open one: a typed backtick is escaped there, since it is a
5258 /// character the author wrote and not markup they meant.
5259 ///
5260 /// Fencing is twig's ([`Editor::toggle_code_block`]): it measures the fence
5261 /// against the body, keeps a quote's `> ` on every line, and peels a fence
5262 /// (or dedents an indented block) on the way back. What is leaf's is the
5263 /// shape twig declines: a blank line holds no block to fence, and the
5264 /// gesture nobody should have to learn is "type something first" — so leaf
5265 /// spells the empty fence itself, with the caret on the empty line inside it
5266 /// and a blank line either side, which is what typing into a new block and
5267 /// then Backspacing out of it would have left.
5268 ///
5269 /// Inside a list item twig refuses in both directions (a fence at column
5270 /// zero would swallow the item's marker), and the refusal is reported rather
5271 /// than worked around.
5272 pub fn toggle_code_block(&mut self) {
5273 // The read-only gate — this door reaches twig without the splice.
5274 if self.read_only {
5275 return;
5276 }
5277 if self.refuse_unsupported("code block", Gesture::ToggleCodeBlock) {
5278 return;
5279 }
5280 let selected = self.selection();
5281 // The caret at a code block's rows resolves to its *content*, and twig
5282 // finds the block from any offset inside its span — but a caret parked
5283 // on the closing fence's own line end is past it, so the block's start
5284 // is handed over whenever the caret is in one at all.
5285 let (start, end) = match selected {
5286 Some(range) => range,
5287 None => match self.code_block_start_at_caret() {
5288 Some(start) => (start, start),
5289 None => match self.block_offset_for_caret() {
5290 Some(off) => (off, off),
5291 None => return self.open_empty_code_block(),
5292 },
5293 },
5294 };
5295 self.record_caret();
5296 match self.editor.toggle_code_block(start, end, None) {
5297 Ok(change) => {
5298 // Read the caret's place out of the *pre-edit* source, before
5299 // `refresh` swaps it out — and how many lines the region held,
5300 // which is what says whether a fence line went in above the
5301 // caret's line or came off it.
5302 let place = selected
5303 .is_none()
5304 .then(|| self.caret_line_tail(&change.old));
5305 let old_lines = self.source[change.old.clone()].matches('\n').count();
5306 self.last_edit_kind = None; // structural edit is its own undo step
5307 self.refresh();
5308 match place {
5309 // From a selection: keep the block selected, so a second
5310 // press reverses the first (twig unfences from any offset in
5311 // the fence's span, the region's start included).
5312 None => {
5313 self.anchor = Some(change.new.start);
5314 self.caret = change.new.end;
5315 }
5316 // From a caret: the same line, the same distance from its
5317 // end, shifted by the opening fence that was written above
5318 // it (or peeled off). Dedenting an indented block keeps the
5319 // lines one-to-one, and shifts nothing.
5320 Some((line, tail)) => {
5321 let new_lines = self.source[change.new.start.min(self.source.len())
5322 ..change.new.end.min(self.source.len())]
5323 .matches('\n')
5324 .count();
5325 let line = match new_lines.cmp(&old_lines) {
5326 std::cmp::Ordering::Greater => line + 1,
5327 std::cmp::Ordering::Less => line.saturating_sub(1),
5328 std::cmp::Ordering::Equal => line,
5329 };
5330 self.anchor = None;
5331 self.caret = self.line_tail_offset(&change.new, (line, tail));
5332 }
5333 }
5334 self.dirty = self.source != self.clean_source;
5335 self.status = None;
5336 self.clamp_caret();
5337 self.record_caret();
5338 }
5339 Err(e) => self.status = Some(format!("code block: {e}")),
5340 }
5341 }
5342
5343 /// Write an empty fenced block on the blank line the caret stands on, and
5344 /// put the caret on the empty line inside it — the half of
5345 /// [`toggle_code_block`](Self::toggle_code_block) that is leaf's, because
5346 /// twig reports `NotFound` for a range no block covers.
5347 ///
5348 /// Inside a quote the fence lines and the empty line keep the quote's
5349 /// prefix, as twig's own fencing does. A blank line goes in on whichever
5350 /// side has text against it, since a fence may interrupt a paragraph in
5351 /// Markdown but a block standing tight against its neighbours is not the
5352 /// document any editor writes.
5353 fn open_empty_code_block(&mut self) {
5354 let caret = self.caret.min(self.source.len());
5355 let prefix = self.quote_prefix_at(caret);
5356 let line_start = self.source[..caret].rfind('\n').map_or(0, |i| i + 1);
5357 let line_end = self.source[caret..]
5358 .find('\n')
5359 .map_or(self.source.len(), |i| caret + i);
5360 let prev_blank = line_start == 0
5361 || self.source[..line_start - 1]
5362 .rsplit('\n')
5363 .next()
5364 .is_some_and(|l| l.trim_start_matches(['>', ' ']).is_empty());
5365 let next_blank = line_end >= self.source.len()
5366 || self.source[line_end + 1..]
5367 .split('\n')
5368 .next()
5369 .is_some_and(|l| l.trim_start_matches(['>', ' ']).is_empty());
5370 // A blank line inside a quote is a bare `>`: the space after it is
5371 // the content's, and there is none.
5372 let blank = prefix.trim_end();
5373 let mut text = String::new();
5374 if !prev_blank {
5375 text.push_str(blank);
5376 text.push('\n');
5377 }
5378 text.push_str(&prefix);
5379 text.push_str("```\n");
5380 text.push_str(&prefix);
5381 let inside = text.len();
5382 text.push('\n');
5383 text.push_str(&prefix);
5384 text.push_str("```");
5385 if !next_blank {
5386 text.push('\n');
5387 text.push_str(blank);
5388 }
5389 // Over whatever the blank line held (its quote prefix, trailing
5390 // spaces), so the block's own prefix is the one twig will read back.
5391 let at = line_start;
5392 if !self.splice(at, line_end, &text, EditKind::Other) {
5393 return;
5394 }
5395 self.caret = at + inside;
5396 self.anchor = None;
5397 self.clamp_caret();
5398 self.record_caret();
5399 }
5400
5401 /// Whether the caret stands in a code block, fenced or indented — what
5402 /// lights the Code Block button. Wider than
5403 /// [`caret_in_fenced_code`](Self::caret_in_fenced_code), which asks the
5404 /// narrower question a language prompt needs.
5405 pub fn caret_in_code_block(&mut self) -> bool {
5406 self.code_block_start_at_caret().is_some()
5407 }
5408
5409 // ── Task list items ──────────────────────────────────────────────────────
5410 // The checkbox in `- [x] done`. twig owns all three gestures: the box is
5411 // inline content of the item's first paragraph rather than part of its
5412 // marker, so adding or removing one must leave the item's continuation
5413 // indentation alone, and an item inside a quote is found past the quote
5414 // markers. leaf names the gesture and the offset; the spelling is twig's.
5415
5416 /// Whether the list item at the caret carries a checkbox, and which way it
5417 /// faces — `Some(true)` ticked, `Some(false)` empty, `None` for a plain list
5418 /// item or no item at all. What a toolbar reads to light its checkbox button.
5419 pub fn task_checked_at_caret(&mut self) -> Option<bool> {
5420 self.task_checked_at(self.caret)
5421 }
5422
5423 /// [`task_checked_at_caret`](Self::task_checked_at_caret) for an arbitrary
5424 /// offset — what a frontend asks before deciding a click landed on a box.
5425 pub fn task_checked_at(&mut self, offset: usize) -> Option<bool> {
5426 self.innermost_list_item(offset.min(self.source.len()))?
5427 .checked
5428 }
5429
5430 /// Tick or untick the task item at the caret (the checkbox's keyboard half).
5431 /// A no-op with a reported reason when the caret is in no task item — minting
5432 /// a box here is [`toggle_task_item`](Self::toggle_task_item)'s job.
5433 pub fn toggle_task_checked(&mut self) {
5434 self.toggle_task_at(self.caret);
5435 }
5436
5437 /// Tick or untick the task item covering `offset` — what a *click* on a
5438 /// rendered checkbox is. Separate from the caret form because a click carries
5439 /// its own offset and must not first move the caret there: ticking a box
5440 /// three paragraphs away should not take the cursor with it.
5441 pub fn toggle_task_at(&mut self, offset: usize) {
5442 // The read-only gate — this door reaches twig without the splice.
5443 if self.read_only {
5444 return;
5445 }
5446 if self.refuse_unsupported("task", Gesture::ToggleTaskChecked) {
5447 return;
5448 }
5449 let offset = offset.min(self.source.len());
5450 self.record_caret();
5451 match self.editor.toggle_task_checked(offset) {
5452 Ok(_) => self.after_task_edit(),
5453 Err(e) => self.status = Some(format!("task: {e}")),
5454 }
5455 }
5456
5457 /// Give the list item at the caret a checkbox, or take its checkbox away —
5458 /// the gesture that converts between a plain bullet and a task. A new box
5459 /// arrives unticked.
5460 pub fn toggle_task_item(&mut self) {
5461 // The read-only gate — this door reaches twig without the splice.
5462 if self.read_only {
5463 return;
5464 }
5465 if self.refuse_unsupported("task", Gesture::ToggleTaskItem) {
5466 return;
5467 }
5468 let caret = self.caret.min(self.source.len());
5469 self.record_caret();
5470 match self.editor.toggle_task_item(caret) {
5471 Ok(_) => self.after_task_edit(),
5472 Err(e) => self.status = Some(format!("task: {e}")),
5473 }
5474 }
5475
5476 /// Settle after a task gesture. The caret rides its old byte offset and is
5477 /// clamped back in: a box is three or four bytes on the item's first line, so
5478 /// text after it shifts by that much at most, and `clamp_caret` lands it on a
5479 /// real stop either way.
5480 fn after_task_edit(&mut self) {
5481 self.last_edit_kind = None;
5482 self.refresh();
5483 self.anchor = None;
5484 self.dirty = self.source != self.clean_source;
5485 self.status = None;
5486 self.clamp_caret();
5487 self.record_caret();
5488 }
5489
5490 // ── Tables ───────────────────────────────────────────────────────────────
5491 // A table is a grid, and twig edits it as one — add/remove/move a row or
5492 // column, set a column's alignment — re-spelling the whole table in a single
5493 // splice. Every gesture is anchored at the caret's cell. leaf just names the
5494 // gesture and re-reads the result; the whole table's numbering, borders, and
5495 // delimiter are twig's to keep straight.
5496
5497 /// Whether the caret is inside a table — what a frontend asks to enable or
5498 /// disable its table controls.
5499 ///
5500 /// An HTML `<table>` still answers `true`: the caret really is in a table,
5501 /// and the reason the grid controls stay dark there is
5502 /// [`Capabilities::table`], which is a fact about the document's format
5503 /// rather than about the caret. A frontend needs both.
5504 pub fn caret_in_table(&mut self) -> bool {
5505 let caret = self.caret.min(self.source.len());
5506 self.editor
5507 .ancestors_at(caret)
5508 .map(|c| c.into_iter().any(|m| m.kind == Kind::Table))
5509 .unwrap_or(false)
5510 }
5511
5512 /// One grid op, guarded and settled — the shared body of the seven below.
5513 ///
5514 /// The guard is why this exists rather than seven copies of the same three
5515 /// lines, and it is the one guard leaf cannot delegate to twig. The table
5516 /// editor is the gesture family that consults no `Syntax` table (it spells a
5517 /// grid, not a delimiter) and therefore the one twig's `Format::supports`
5518 /// deliberately has no variant for: handed an HTML `<table>` it rebuilds the
5519 /// grid as a *pipe table* and reports success, swapping the element out for
5520 /// `| a | b |` and taking the rest of the document's markup with it. Nothing
5521 /// downstream could tell that from a successful edit — the splice is real,
5522 /// the reparse succeeds, `dirty` is honest — which is what makes it worth
5523 /// stopping at the door rather than detecting after the fact. See
5524 /// [`spells_pipe_tables`].
5525 fn table_op(
5526 &mut self,
5527 what: &str,
5528 op: impl FnOnce(&mut Editor, usize) -> Result<(), twig::Error>,
5529 ) {
5530 if self.refuse_unless(what, spells_pipe_tables(self.format)) {
5531 return;
5532 }
5533 self.record_caret();
5534 let at = self.caret;
5535 let r = op(&mut self.editor, at);
5536 self.apply_table(r, what);
5537 }
5538
5539 /// Insert an empty row below (`below`) or above the caret's row.
5540 pub fn table_insert_row(&mut self, below: bool) {
5541 self.table_op("table row", |e, at| e.table_insert_row(at, below));
5542 }
5543
5544 /// Delete the caret's row (not the header, not the last body row).
5545 pub fn table_delete_row(&mut self) {
5546 self.table_op("table row", |e, at| e.table_delete_row(at));
5547 }
5548
5549 /// Insert an empty column right (`right`) or left of the caret's column.
5550 pub fn table_insert_column(&mut self, right: bool) {
5551 self.table_op("table column", |e, at| e.table_insert_column(at, right));
5552 }
5553
5554 /// Delete the caret's column (unless it is the only one).
5555 pub fn table_delete_column(&mut self) {
5556 self.table_op("table column", |e, at| e.table_delete_column(at));
5557 }
5558
5559 /// Set the caret's column to `alignment`.
5560 pub fn table_set_alignment(&mut self, alignment: Alignment) {
5561 self.table_op("table alignment", |e, at| {
5562 e.table_set_alignment(at, alignment)
5563 });
5564 }
5565
5566 /// Move the caret's row one place down (`down`) or up, within the body rows.
5567 pub fn table_move_row(&mut self, down: bool) {
5568 self.table_op("table row", |e, at| e.table_move_row(at, down));
5569 }
5570
5571 /// Move the caret's column one place right (`right`) or left.
5572 pub fn table_move_column(&mut self, right: bool) {
5573 self.table_op("table column", |e, at| e.table_move_column(at, right));
5574 }
5575
5576 /// Settle the caret and document flags after a table op (or report its
5577 /// error). twig re-spells the whole table, so the caret rides its old byte
5578 /// offset and is clamped back into the rebuilt bytes — near enough to where
5579 /// it was, since the op preserves the cells' content and order around it.
5580 fn apply_table(&mut self, result: Result<(), twig::Error>, what: &str) {
5581 match result {
5582 Ok(()) => {
5583 self.last_edit_kind = None;
5584 self.refresh();
5585 self.anchor = None;
5586 self.clamp_caret();
5587 self.dirty = self.source != self.clean_source;
5588 self.status = None;
5589 self.record_caret();
5590 }
5591 Err(e) => self.status = Some(format!("{what}: {e}")),
5592 }
5593 }
5594
5595 /// One `toggle_block_container` over the block-level target.
5596 ///
5597 /// leaf says *where*; twig decides everything else — which blocks the range
5598 /// covers, whether that means wrapping, unwrapping, nesting or converting,
5599 /// and how this document's format spells the prefix. The rule that a
5600 /// container only comes off when the range covers every block it holds is
5601 /// what the re-anchoring below is built around.
5602 fn toggle_container(&mut self, kind: BlockContainerKind) {
5603 // The read-only gate — this door reaches twig without the splice.
5604 if self.read_only {
5605 return;
5606 }
5607 if self.refuse_unsupported(&format!("{kind:?}"), Gesture::ToggleBlockContainer(kind)) {
5608 return;
5609 }
5610 let selected = self.selection();
5611 // A blank line holds no block, and twig opens an *empty* container on one
5612 // — since 3.2.0; it used to decline the range with `NotFound`, which is
5613 // why this used to lend it a scratch paragraph to wrap. Worth knowing
5614 // here because the line-for-line caret mapping below cannot describe it:
5615 // opening one under a paragraph writes the blank line the format needs
5616 // above the marker too, so the rewritten region has a line the old one
5617 // didn't, and "the same line, the same distance from its end" lands on
5618 // that new blank instead of in the container.
5619 let opened_empty = selected.is_none() && self.block_offset_for_caret().is_none();
5620 // Without a selection the target is the caret's own block, resolved the
5621 // way `set_block` resolves it — a caret at a line end sits at the doc
5622 // level and has to be nudged back onto the block it looks like it's in.
5623 // An empty range is enough: twig widens to the whole lines it touches.
5624 let (start, end) = match selected {
5625 Some(range) => range,
5626 None => {
5627 let off = self.block_offset_for_caret().unwrap_or(self.caret);
5628 (off, off)
5629 }
5630 };
5631 self.record_caret();
5632 match self.editor.toggle_block_container(start, end, kind) {
5633 Ok(change) => {
5634 // Read the caret's place out of the *pre-edit* source, before
5635 // `refresh` swaps that source out from under it.
5636 let place = (selected.is_none() && !opened_empty)
5637 .then(|| self.caret_line_tail(&change.old));
5638 self.last_edit_kind = None; // structural edit is its own undo step
5639 self.refresh();
5640 match place {
5641 // Both land the caret at the far end of what twig wrote, and
5642 // differ only in what they leave selected.
5643 //
5644 // From a selection: select what the container now holds, the
5645 // way `toggle` keeps its marked region selected — and for a
5646 // stronger reason than symmetry: a container comes *off* only
5647 // a range covering every block it holds, so a selection left
5648 // on its old bytes (now short by a prefix per line) would nest
5649 // on the second press instead of reversing the first.
5650 //
5651 // From a blank line: nothing to select, and the end of the
5652 // region is exactly past the bare `> ` / `- ` twig wrote —
5653 // the caret standing inside the container that was asked for.
5654 None => {
5655 self.anchor = (!opened_empty).then_some(change.new.start);
5656 self.caret = change.new.end;
5657 }
5658 Some(place) => {
5659 self.anchor = None;
5660 self.caret = self.line_tail_offset(&change.new, place);
5661 }
5662 }
5663 self.dirty = self.source != self.clean_source;
5664 self.status = None;
5665 self.clamp_caret();
5666 self.record_caret();
5667 }
5668 Err(e) => self.status = Some(format!("{kind:?}: {e}")),
5669 }
5670 }
5671
5672 /// The caret's place inside the region a container toggle is rewriting, in
5673 /// the only terms the rewrite preserves: which of the region's lines it sits
5674 /// on, and how many bytes of that line lie ahead of it.
5675 ///
5676 /// A container's markup goes in at column 0 and never touches what follows
5677 /// on the line, so that pair survives the edit exactly where a byte offset
5678 /// does not — a caret left on its old offset slides back by one prefix per
5679 /// line above it, which on a hard-wrapped paragraph parks it *inside* the
5680 /// `> ` it just asked for.
5681 fn caret_line_tail(&self, old: &std::ops::Range<usize>) -> (usize, usize) {
5682 let caret = self.caret.clamp(old.start, old.end);
5683 let line = self.source[old.start..caret].matches('\n').count();
5684 let end = self.source[caret..old.end]
5685 .find('\n')
5686 .map_or(old.end, |i| caret + i);
5687 (line, end - caret)
5688 }
5689
5690 /// [`caret_line_tail`](Self::caret_line_tail) undone against the rewritten
5691 /// region: the offset `tail` bytes back from the end of the region's `line`.
5692 ///
5693 /// Both walks are clamped rather than trusted, because the one op that does
5694 /// *not* keep a region's lines one-to-one is stripping a list — twig blows
5695 /// the items back apart with blank lines between them — and a caret landing
5696 /// on the nearest line of the right item beats one landing out of the region
5697 /// entirely.
5698 fn line_tail_offset(
5699 &self,
5700 new: &std::ops::Range<usize>,
5701 (line, tail): (usize, usize),
5702 ) -> usize {
5703 let region = &self.source[new.start.min(self.source.len())..new.end.min(self.source.len())];
5704 let mut start = 0;
5705 for _ in 0..line {
5706 match region[start..].find('\n') {
5707 Some(i) => start += i + 1,
5708 None => break,
5709 }
5710 }
5711 let end = region[start..]
5712 .find('\n')
5713 .map_or(region.len(), |i| start + i);
5714 new.start + end.saturating_sub(tail).max(start)
5715 }
5716
5717 /// Link the selection to `destination` — the toolbar's Link button. With no
5718 /// selection it acts at the caret, which re-points a link the caret is
5719 /// already standing in (twig replaces an existing link's destination and
5720 /// keeps its text) and otherwise spells a link that has no text of its own:
5721 /// an autolink (`<https://x.dev>`) where the destination is one, and
5722 /// `[destination](destination)` where it isn't.
5723 ///
5724 /// `destination` reaches twig raw. Escaping it is format knowledge and the
5725 /// two formats genuinely disagree — Markdown ends a destination at the first
5726 /// space and moves it into `<…>`, djot reads that `<…>` as part of the URL
5727 /// itself — so the side holding the document is the side that gets to spell
5728 /// it. A destination twig can't carry at all (one with a newline) comes back
5729 /// as an error rather than a quietly rewritten URL.
5730 pub fn insert_link(&mut self, destination: &str) {
5731 if self.read_only || self.refuse_unsupported("link", Gesture::InsertLink) {
5732 return;
5733 }
5734 let (start, end) = self.selection().unwrap_or((self.caret, self.caret));
5735 self.record_caret();
5736 match self.editor.insert_link(start, end, destination) {
5737 Ok(change) => {
5738 self.last_edit_kind = None;
5739 self.refresh();
5740 match self.link_text_span(change.new.start) {
5741 // A link with text of its own: select it, so typing replaces
5742 // a `[dest](dest)`'s stand-in label and a second press
5743 // re-points what the first one linked.
5744 Some(text) => {
5745 self.anchor = (text.start != text.end).then_some(text.start);
5746 self.caret = text.end;
5747 }
5748 // An autolink is finished the moment it's written — its text
5749 // *is* the URL. Leaving it selected would aim the next press
5750 // at the one shape twig still wraps instead of re-points.
5751 None => {
5752 self.anchor = None;
5753 self.caret = change.new.end;
5754 }
5755 }
5756 self.dirty = self.source != self.clean_source;
5757 self.status = None;
5758 self.clamp_caret();
5759 self.record_caret();
5760 }
5761 Err(e) => self.status = Some(format!("link: {e}")),
5762 }
5763 }
5764
5765 /// Insert a block-level image at the caret: ``. Any
5766 /// selection becomes the alt text (so "select a caption, insert image" labels
5767 /// it); with no selection, `alt` is used — empty for none. The caret lands
5768 /// just past the inserted image.
5769 ///
5770 /// Both halves go through twig (`insert_literal` for the alt text,
5771 /// `insert_image` for the image), so neither is spelled here. That used to be a
5772 /// `format!`, and it was wrong the first time an app inserted a real filename:
5773 /// Markdown ends a destination at the first space, so `` is
5774 /// not an image at all — and the fix is per-format, since moving into the
5775 /// `<…>` form is exactly wrong for Djot, where `<…>` becomes the URL itself.
5776 pub fn insert_image(&mut self, destination: &str, alt: &str) {
5777 if self.read_only || self.refuse_unsupported("image", Gesture::InsertImage) {
5778 return;
5779 }
5780 let (start, end) = self.selection().unwrap_or((self.caret, self.caret));
5781 self.record_caret();
5782 // With no selection and an explicit `alt`, the alt text has to exist in the
5783 // document before it can be the image's — and it is raw caller input, so
5784 // it goes in through `insert_literal`, which escapes it for the format
5785 // rather than letting a `]` in someone's caption close the image early.
5786 let (start, end) = if start == end && !alt.is_empty() {
5787 match self.editor.insert_literal(start, alt) {
5788 Ok(change) => (change.new.start, change.new.end),
5789 Err(e) => {
5790 self.status = Some(format!("image: {e}"));
5791 return;
5792 }
5793 }
5794 } else {
5795 (start, end)
5796 };
5797 match self.editor.insert_image(start, end, destination) {
5798 Ok(change) => {
5799 self.last_edit_kind = None;
5800 self.refresh();
5801 // Just past the image, nothing selected — where a caret belongs
5802 // after inserting one.
5803 self.anchor = None;
5804 self.caret = change.new.end;
5805 self.dirty = self.source != self.clean_source;
5806 self.status = None;
5807 self.clamp_caret();
5808 self.record_caret();
5809 }
5810 Err(e) => self.status = Some(format!("image: {e}")),
5811 }
5812 }
5813
5814 /// Insert a block-level image, video, or audio at the caret. The image case
5815 /// is [`insert_image`](Self::insert_image); video and audio are spelled as
5816 /// HTML elements, which is the only spelling Markdown and Djot have for them:
5817 ///
5818 /// ```text
5819 /// <video src="clip.mp4" controls>alt</video>
5820 /// <audio src="take.mp3" controls>alt</audio>
5821 /// ```
5822 ///
5823 /// HTML rather than a `::video{…}` directive deliberately. A directive means
5824 /// something only to an app that knows the vocabulary, so the document would
5825 /// read as literal punctuation everywhere else; `<video>` is what every other
5826 /// renderer already understands, and what leaf's own reader picks back up
5827 /// through `html_elements` promotion (see [`parse_extensions`]).
5828 ///
5829 /// The one-line spelling needs twig ≥ 2.5.1, which widened CommonMark's
5830 /// HTML-block tag list to cover `<video>`/`<audio>`/`<picture>` under
5831 /// `html_elements`. Before that only the multi-line form parsed as a block at
5832 /// all, and this wrote three lines to work around it.
5833 ///
5834 /// `controls` is always written: a player with no transport is a still frame
5835 /// the reader can't do anything with. Any selection becomes the element's
5836 /// fallback text, exactly as it becomes an image's alt.
5837 ///
5838 /// The same verbatim-insertion caveat as [`insert_image`](Self::insert_image)
5839 /// applies, and bites harder here: a `"` in `destination` closes the
5840 /// attribute. A frontend taking these from a file picker is fine; one taking
5841 /// them from free text should keep them tame.
5842 ///
5843 /// [`MediaInfo`]: crate::MediaInfo
5844 pub fn insert_media(&mut self, kind: MediaKind, destination: &str, alt: &str) {
5845 if kind == MediaKind::Image {
5846 return self.insert_image(destination, alt);
5847 }
5848 // Gated on the *image* gesture, not on one of its own — there isn't one,
5849 // since the bytes below are spelled here rather than by twig, and an HTML
5850 // document would in fact parse them. The button is one control with three
5851 // kinds behind it, and two of them working in a format where the third
5852 // cannot is a worse surface than three that agree — especially as
5853 // `insert_image` is the kind anyone reaches for first.
5854 if self.refuse_unsupported("media", Gesture::InsertImage) {
5855 return;
5856 }
5857 let (start, end) = self.selection().unwrap_or((self.caret, self.caret));
5858 let alt_text = self
5859 .selected_text()
5860 .map(str::to_string)
5861 .unwrap_or_else(|| alt.to_string());
5862 let tag = match kind {
5863 MediaKind::Audio => "audio",
5864 _ => "video",
5865 };
5866 let markup = format!("<{tag} src=\"{destination}\" controls>{alt_text}</{tag}>");
5867 self.edit(start, end, &markup);
5868 }
5869
5870 /// Insert a thematic break at the caret — the toolbar's Horizontal Rule
5871 /// button. Spelling and placement are both twig's; leaf used to write `---`
5872 /// itself, which was the Markdown spelling in a djot document too.
5873 ///
5874 /// A rule is a block, so `insert_thematic_break` alone has nowhere to put one
5875 /// mid-paragraph and lands it after the caret's whole block. To get a rule
5876 /// *at* the caret — the paragraph parted in two around it, which is what a
5877 /// rule button is understood to do — the paragraph is first divided with
5878 /// `split_block` and the rule then aimed at the **first** half. Aiming it at
5879 /// the offset `split_block` returns puts the rule after the *second* half
5880 /// instead, which is a rule in the right document and the wrong place.
5881 ///
5882 /// Only a plain paragraph is split, and only where there is something to
5883 /// part: at the paragraph's end the split has no second half to mint and
5884 /// would write the separator anyway — a blank line and the empty slot Enter
5885 /// leaves for the next paragraph, which the rule then lands above and
5886 /// nothing fills — so there the rule goes straight after the paragraph,
5887 /// which is where the split-and-aim was sending it regardless. At the
5888 /// paragraph's *start* the split is kept, though it parts nothing either:
5889 /// `|para` becomes `\npara` with the caret on the new blank line, and a
5890 /// rule aimed at a blank line is written on it (twig ≥ 3.5.2), which is how
5891 /// "before the paragraph" is said through a gesture that only knows
5892 /// "after" — `---\n\npara`, and `prev\n\n---\n\npara` mid-document. Everywhere
5893 /// else the rule simply lands after the block, which is both twig's own
5894 /// answer and the better one: splitting a fenced code block would leave two
5895 /// fences with a rule between them, and splitting a list item would mint an
5896 /// item nobody asked for on the way to a rule that lands after the list
5897 /// regardless. A table and a setext heading refuse the split outright, so
5898 /// they take the same path by themselves.
5899 pub fn insert_thematic_break(&mut self) {
5900 if self.read_only || self.refuse_unsupported("thematic break", Gesture::InsertThematicBreak)
5901 {
5902 return;
5903 }
5904 self.caret = self.skip_trailing_close_delims(self.caret);
5905 // A selection is replaced by the rule, so collapse it first and let the
5906 // split-and-rule below run from the caret it leaves behind.
5907 if let Some((s, e)) = self.selection() {
5908 self.splice(s, e, "", EditKind::Other);
5909 }
5910 self.anchor = None;
5911 self.record_caret();
5912 let at = self.caret;
5913 if self.caret_parts_bare_paragraph() {
5914 // A failure here is not fatal: the rule still lands after the block,
5915 // which is exactly what this call was trying to improve on.
5916 let _ = self.editor.split_block(at);
5917 }
5918 match self.editor.insert_thematic_break(at) {
5919 Ok(change) => {
5920 self.last_edit_kind = None;
5921 self.refresh();
5922 self.anchor = None;
5923 self.caret = change.new.end;
5924 self.dirty = self.source != self.clean_source;
5925 self.status = None;
5926 self.clamp_caret();
5927 self.record_caret();
5928 }
5929 Err(e) => self.status = Some(format!("thematic break: {e}")),
5930 }
5931 }
5932
5933 /// Insert a fresh table at the caret — the toolbar's Table button. One
5934 /// header row, `rows` empty body rows, `cols` columns, spelled by twig in
5935 /// the document's own dialect and placed the way its thematic break is:
5936 /// after the caret's block, blank-separated. A bare paragraph is parted
5937 /// around the caret first, exactly as
5938 /// [`insert_thematic_break`](Self::insert_thematic_break) parts it, so the
5939 /// table lands *at* the caret rather than after everything the caret's
5940 /// paragraph says.
5941 ///
5942 /// The caret ends in the first header cell, selected the way Tab selects
5943 /// a cell — the natural next act is to type the heading, and Tab then
5944 /// walks the grid. That cell is read back from the rebuilt table map
5945 /// rather than computed from the splice, because twig's blank line and
5946 /// quote prefix put the first bar at an offset only the reparse knows.
5947 ///
5948 /// The shape is the caller's: a menu offers a few, a dialog asks. Zero
5949 /// rows or columns is twig's refusal (a header with nothing under it is
5950 /// what its row delete refuses to leave), reported through `status`.
5951 pub fn insert_table(&mut self, rows: usize, cols: usize) {
5952 if self.read_only || self.refuse_unsupported("table", Gesture::InsertTable) {
5953 return;
5954 }
5955 self.caret = self.skip_trailing_close_delims(self.caret);
5956 if let Some((s, e)) = self.selection() {
5957 self.splice(s, e, "", EditKind::Other);
5958 }
5959 self.anchor = None;
5960 self.record_caret();
5961 let at = self.caret;
5962 if self.caret_parts_bare_paragraph() {
5963 let _ = self.editor.split_block(at);
5964 }
5965 match self.editor.insert_table(at, rows, cols) {
5966 Ok(change) => {
5967 self.last_edit_kind = None;
5968 self.refresh();
5969 self.anchor = None;
5970 self.caret = change.new.end;
5971 self.dirty = self.source != self.clean_source;
5972 self.status = None;
5973 self.clamp_caret();
5974 // Into the first header cell of the table just written: the
5975 // first table whose grid begins inside the splice.
5976 self.rebuild_map();
5977 let first_cell = self
5978 .vmap
5979 .tables
5980 .iter()
5981 .filter_map(|t| t.grid.first().and_then(|row| row.cells.first()))
5982 .find(|cell| cell.start >= change.new.start && cell.start < change.new.end)
5983 .map(|cell| (cell.start, cell.end));
5984 if let Some((start, end)) = first_cell {
5985 self.select_cell(start, end);
5986 }
5987 self.record_caret();
5988 }
5989 Err(e) => self.status = Some(format!("table: {e}")),
5990 }
5991 }
5992
5993 /// Whether the caret sits in a paragraph and nothing else — no list item, no
5994 /// quote, no fence, no table — with paragraph text still ahead of it. The
5995 /// one shape where parting the block around the caret is unambiguously what
5996 /// a rule button means; see
5997 /// [`insert_thematic_break`](Self::insert_thematic_break) for why every other
5998 /// container is left to take the rule after itself.
5999 ///
6000 /// The "text ahead" half is what keeps `split_block` from running at the
6001 /// one edge where its output composes badly. At a paragraph's end twig
6002 /// cannot mint the empty second half (no format spells an empty
6003 /// paragraph), so it writes only the separator — a blank line and the
6004 /// slot Enter leaves for the paragraph to come — and a block then aimed at
6005 /// the first half lands above a slot that nothing fills: `para\n` with the
6006 /// caret at 4 came out as `para\n\n* * *\n\n\n`. Trailing whitespace counts
6007 /// as nothing ahead, since the split would shed it as the second half's
6008 /// leading indent and leave the same slot. Which end of the newline a
6009 /// paragraph's span stops at differs between the formats (Markdown before
6010 /// it, djot after), which is why this reads the remaining bytes rather
6011 /// than comparing offsets. The paragraph's
6012 /// start is deliberately not the same case — see
6013 /// [`insert_thematic_break`](Self::insert_thematic_break) for why that
6014 /// split is kept.
6015 fn caret_parts_bare_paragraph(&mut self) -> bool {
6016 let caret = self.caret.min(self.source.len());
6017 let Ok(chain) = self.editor.ancestors_at(caret) else {
6018 return false;
6019 };
6020 let mut para_end = None;
6021 for m in chain {
6022 match m.kind {
6023 Kind::Para => para_end = Some(m.span.end.min(self.source.len())),
6024 Kind::ListItem
6025 | Kind::TaskListItem
6026 | Kind::BlockQuote
6027 | Kind::CodeBlock
6028 | Kind::Table => return false,
6029 _ => {}
6030 }
6031 }
6032 match para_end {
6033 Some(end) if end > caret => !self.source[caret..end].trim().is_empty(),
6034 _ => false,
6035 }
6036 }
6037
6038 /// The destination of the link under the caret — what a Link prompt shows so
6039 /// ⌘K on an existing link edits its URL instead of asking for it again.
6040 /// `None` when the caret stands in no link.
6041 ///
6042 /// An autolink carries no separate destination: its text *is* the URL, so
6043 /// that's what comes back for one.
6044 pub fn link_destination_at_caret(&mut self) -> Option<String> {
6045 self.link_destination_at(self.caret)
6046 }
6047
6048 /// The destination of the link at `off`.
6049 /// [`link_destination_at_caret`](Self::link_destination_at_caret) for a place
6050 /// the caret isn't.
6051 ///
6052 /// The offset form exists for the same reason
6053 /// [`footnote_at`](Self::footnote_at)'s does: a frontend drawing a *piece* of
6054 /// the document somewhere else — a footnote's text in a popover, say — has
6055 /// rows and runs but no caret in them, and still needs to know which of those
6056 /// runs a reader can follow.
6057 pub fn link_destination_at(&mut self, off: usize) -> Option<String> {
6058 self.nodes()
6059 .into_iter()
6060 .filter(|n| matches!(n.kind.as_str(), "link" | "url" | "email"))
6061 .filter(|n| n.span.start <= off && off < n.span.end)
6062 .max_by_key(|n| n.span.start)
6063 .and_then(|n| n.destination.or(n.text))
6064 }
6065
6066 /// Where the locator `id` lands in this document — the `#v2` half of a
6067 /// `chapter.dj#v2`, resolved to the block it names. `None` when nothing here
6068 /// answers to it.
6069 ///
6070 /// The other end of a link, and the reason this exists: without it a
6071 /// destination has only file granularity, so following a citation into a
6072 /// chapter drops the reader at the top of it to hunt for the verse. Which is
6073 /// also why it is a *document* query rather than a caret one — the document
6074 /// being asked is usually not the one the reader is in.
6075 ///
6076 /// Three readings, tried in order, because the same `#some-heading` is
6077 /// written three ways across the formats leaf opens:
6078 ///
6079 /// 1. **A declared id**, exactly as written: djot's `{#v1}` on a block, and
6080 /// the auto-ids djot mints for its headings. The only exact answer, so it
6081 /// goes first — a document that says `{#v1}` has settled the question.
6082 /// 2. **A declared id, slugged.** djot spells a heading's auto-id
6083 /// `Some-Heading-Here`; nearly every tool that *writes* a link to one
6084 /// spells it `#some-heading-here`. Comparing slugs is what lets a link
6085 /// authored anywhere land on a djot heading.
6086 /// 3. **A heading's text, slugged.** Markdown has no ids at all — twig mints
6087 /// none and `{#custom}` is literal text in a Markdown heading — so for
6088 /// the format most vaults are written in, the heading's own words are the
6089 /// only thing a fragment can name. This is the rule every Markdown
6090 /// renderer already follows, which is what makes `#a-heading` mean in
6091 /// diaryx what it means on the web.
6092 ///
6093 /// Ties go to the earliest match, then to the widest: a duplicated id is the
6094 /// document's mistake and the first one is the answer every anchor
6095 /// implementation gives, while preferring the wider span picks the section
6096 /// over the heading that opens it — more for a peek to show, same place to
6097 /// land.
6098 pub fn locate(&mut self, id: &str) -> Option<Landing> {
6099 let id = id.trim();
6100 if id.is_empty() {
6101 return None;
6102 }
6103 let nodes = self.nodes();
6104
6105 // Earliest wins, then widest. `Reverse` on the end because `min_by_key`
6106 // is picking, among nodes that start together, the one that ends last.
6107 let pick = |matches: &mut dyn Iterator<Item = &FlatNode>| {
6108 matches
6109 .min_by_key(|n| (n.span.start, std::cmp::Reverse(n.span.end)))
6110 .map(|n| Landing {
6111 start: n.span.start,
6112 end: n.span.end,
6113 })
6114 };
6115
6116 if let Some(landing) = pick(&mut nodes.iter().filter(|n| declared_id(n) == Some(id))) {
6117 return Some(landing);
6118 }
6119 let want = slug(id);
6120 if want.is_empty() {
6121 return None;
6122 }
6123 if let Some(landing) = pick(
6124 &mut nodes
6125 .iter()
6126 .filter(|n| declared_id(n).map(slug).as_deref() == Some(&*want)),
6127 ) {
6128 return Some(landing);
6129 }
6130
6131 // A heading by its words. Its span is one line, so the end comes from
6132 // where the *section* it opens gives out — the next heading that is not
6133 // under it, or the end of the document. A Markdown heading has no
6134 // section node to ask (twig only builds those for djot), and a peek that
6135 // showed the heading alone would answer "what does that say" with the
6136 // title of the thing it says.
6137 let heading = nodes
6138 .iter()
6139 .filter(|n| n.kind == Kind::Heading)
6140 .filter(|n| {
6141 n.content_span
6142 .clone()
6143 .and_then(|s| self.source.get(s))
6144 .is_some_and(|text| slug(text) == want)
6145 })
6146 .min_by_key(|n| n.span.start)?;
6147 let level = heading.level.unwrap_or(u32::MAX);
6148 let end = nodes
6149 .iter()
6150 .filter(|n| n.kind == Kind::Heading)
6151 .filter(|n| n.span.start > heading.span.start)
6152 .filter(|n| n.level.unwrap_or(u32::MAX) <= level)
6153 .map(|n| n.span.start)
6154 .min()
6155 .unwrap_or(self.source.len());
6156 Some(Landing {
6157 start: heading.span.start,
6158 end,
6159 })
6160 }
6161
6162 /// Write a footnote at the caret — the toolbar's Footnote button, and the
6163 /// one gesture in the footnote story that *authors* rather than follows.
6164 ///
6165 /// Both halves go in as one twig edit: the `[^1]` where the caret is, and
6166 /// the `[^1]:` definition at the end of the document. Half a footnote is not
6167 /// a footnote — a bare reference with nothing defining it renders as literal
6168 /// brackets — so a single button that wrote only the reference would leave
6169 /// the author to hand-spell the other half in a document that had just
6170 /// stopped showing them what the first half meant. One edit also means one
6171 /// undo takes both back.
6172 ///
6173 /// The definition's body is left empty and **the caret lands in it**, which
6174 /// is the whole point of pressing the button: nobody wants a reference to a
6175 /// note they have not written yet. Getting back to where they were writing
6176 /// is [`footnote_definition_at_caret`](Self::footnote_definition_at_caret) —
6177 /// the same return leg a reader following a reference already uses, so the
6178 /// author is left standing on the near end of a round trip that works.
6179 ///
6180 /// A selection collapses to its *end* rather than being replaced: a
6181 /// reference annotates the words before it, so "select the claim, add a
6182 /// footnote" should mark that claim, not consume it.
6183 pub fn insert_footnote(&mut self) {
6184 if self.read_only || self.refuse_unsupported("footnote", Gesture::InsertFootnote) {
6185 return;
6186 }
6187 let at = self.selection().map_or(self.caret, |(_, end)| end);
6188 self.anchor = None;
6189 self.caret = at;
6190 self.record_caret();
6191 let label = self.next_footnote_label();
6192 match self.editor.insert_footnote(at, &label) {
6193 Ok(change) => {
6194 self.last_edit_kind = None;
6195 self.refresh();
6196 self.anchor = None;
6197 // `change.new` runs from the reference to the end of the
6198 // document, so its start is the `[^1]` just written and
6199 // `footnote_at` resolves it to the note the same way a reader's
6200 // tap does — and to the note's *body*, which is already a caret
6201 // stop even when it is empty (the `[^1]:` marker draws as `[1] `
6202 // and has none), so this needs no snap on top. The fallback is
6203 // the reference's own offset: a format that spelled the pair some
6204 // way leaf can't read back should still leave the caret on the
6205 // edit rather than at the far end of a document it just grew.
6206 self.caret = self
6207 .footnote_at(change.new.start)
6208 .and_then(|note| note.offset)
6209 .unwrap_or(change.new.start);
6210 self.dirty = self.source != self.clean_source;
6211 self.status = None;
6212 self.clamp_caret();
6213 self.record_caret();
6214 }
6215 Err(e) => self.status = Some(format!("footnote: {e}")),
6216 }
6217 }
6218
6219 /// The label to give a footnote the author has not named: the lowest counting
6220 /// number no footnote in the document is already wearing.
6221 ///
6222 /// twig takes the label rather than minting one, because it holds no opinion
6223 /// about what a document's footnotes should be called — and it is right not
6224 /// to. Numbering them is what every author of a numbered note expects, and
6225 /// re-using a taken number would silently point the new reference at somebody
6226 /// else's note (twig reuses an existing definition rather than appending a
6227 /// second one, which is the right rule for citing a note twice on purpose and
6228 /// exactly the wrong accident to have by default).
6229 ///
6230 /// *References* are counted alongside definitions, not just definitions: a
6231 /// document carrying a dangling `[^2]` has a 2 that means something to
6232 /// whoever wrote it, and minting a definition for it here would answer a
6233 /// question nobody asked. Non-numeric labels (`[^why]`) are left out of the
6234 /// count entirely — they take no number, so they block none.
6235 fn next_footnote_label(&mut self) -> String {
6236 let mut taken: Vec<u32> = wysiwyg::footnote_definitions(&mut self.editor)
6237 .into_iter()
6238 .filter_map(|note| wysiwyg::footnote_label(&self.source, note.span.start))
6239 .filter_map(|label| label.parse().ok())
6240 .collect();
6241 taken.extend(
6242 self.nodes()
6243 .into_iter()
6244 .filter(|n| n.kind == Kind::FootnoteReference)
6245 .filter_map(|n| wysiwyg::footnote_reference_label(&self.source, n.span))
6246 .filter_map(|label| label.parse::<u32>().ok()),
6247 );
6248 (1..).find(|n| !taken.contains(n)).unwrap_or(1).to_string()
6249 }
6250
6251 /// The footnote reference under the caret, resolved to the note it names.
6252 /// [`footnote_at`](Self::footnote_at) at the caret's offset.
6253 pub fn footnote_at_caret(&mut self) -> Option<FootnoteRef> {
6254 self.footnote_at(self.caret)
6255 }
6256
6257 /// The footnote reference at `off`, resolved to the note it names — what a
6258 /// frontend shows when a reader activates a `[^1]`.
6259 ///
6260 /// A reference is not a link node, so
6261 /// [`link_destination_at_caret`](Self::link_destination_at_caret) does not
6262 /// (and should not) answer for one: a link names a destination to leave for,
6263 /// a reference names a note that is already in this document. Following one
6264 /// is a move within the page, which is why this hands back an `offset`
6265 /// rather than something to open.
6266 ///
6267 /// Offset-based rather than caret-only because the gesture that wants this
6268 /// most is the one that must not move the caret: a pointer hovering a `[1]`
6269 /// asks what note it names without disturbing where the reader was typing.
6270 /// The caret is just the offset a click already placed —
6271 /// [`footnote_at_caret`](Self::footnote_at_caret) passes it.
6272 ///
6273 /// `None` when `off` stands in no reference. A reference whose note the
6274 /// document never defines is *not* `None` — it answers with the label it
6275 /// looked for and no text, which is what lets a frontend say so instead of
6276 /// silently doing nothing.
6277 pub fn footnote_at(&mut self, off: usize) -> Option<FootnoteRef> {
6278 // Innermost-wins by latest start, the rule its link sibling uses.
6279 let span = self
6280 .nodes()
6281 .into_iter()
6282 .filter(|n| n.kind == Kind::FootnoteReference)
6283 .filter(|n| n.span.start <= off && off < n.span.end)
6284 .max_by_key(|n| n.span.start)?
6285 .span;
6286 let label = wysiwyg::footnote_reference_label(&self.source, span)?.to_string();
6287
6288 // The note itself. Definitions are roots beside `doc` rather than
6289 // children of it, so they're asked for directly — see
6290 // `wysiwyg::footnote_definitions`.
6291 let note = wysiwyg::footnote_definitions(&mut self.editor)
6292 .into_iter()
6293 .find(|m| wysiwyg::footnote_label(&self.source, m.span.start) == Some(&label));
6294 let Some(note) = note else {
6295 return Some(FootnoteRef {
6296 label,
6297 text: None,
6298 offset: None,
6299 end: None,
6300 });
6301 };
6302 let body = wysiwyg::footnote_body_span(&self.source, note.span.clone());
6303 Some(FootnoteRef {
6304 label,
6305 text: body
6306 .clone()
6307 .and_then(|b| self.source.get(b))
6308 .map(str::to_string),
6309 // The body's start, not the definition's — see `FootnoteRef::offset`.
6310 offset: body.clone().map(|b| b.start),
6311 end: body.map(|b| b.end),
6312 })
6313 }
6314
6315 /// The footnote *definition* the caret stands in, and where the reference
6316 /// that names it is. [`footnote_definition_at`](Self::footnote_definition_at)
6317 /// at the caret's offset.
6318 pub fn footnote_definition_at_caret(&mut self) -> Option<FootnoteDef> {
6319 self.footnote_definition_at(self.caret)
6320 }
6321
6322 /// The footnote definition spanning `off`, and where the reference that
6323 /// names it is — the return leg of [`footnote_at`](Self::footnote_at).
6324 ///
6325 /// The mirror image, deliberately: the same gesture that takes a reader from
6326 /// `[1]` down to the note takes them from the note back up to `[1]`, so
6327 /// following a footnote is a round trip rather than a fall. It needs no
6328 /// memory of how the reader arrived — the document says where the reference
6329 /// is — which is what makes it work for a reader who scrolled to the notes
6330 /// themselves, and what keeps it right after an edit moves either end.
6331 ///
6332 /// `None` when `off` stands in no definition. A definition nothing cites is
6333 /// *not* `None`, for [`FootnoteRef`]'s reason in reverse: it answers with
6334 /// its label and no offset, so a frontend can say "nothing refers to this"
6335 /// rather than offer a jump that goes nowhere.
6336 pub fn footnote_definition_at(&mut self, off: usize) -> Option<FootnoteDef> {
6337 // Definitions are roots beside `doc`, so `nodes()` — which walks the
6338 // document body — never reports one. They're asked for directly, the way
6339 // `footnote_at` asks for the note it resolves to.
6340 //
6341 // Closed at the end, unlike the half-open test its neighbours use. A
6342 // definition's span stops at its last content byte — the newline ending
6343 // the line is outside it — so `span.end` is the caret stop at the end of
6344 // the note's own row, not the first byte of anything after. Excluding it
6345 // meant the one caret an author is guaranteed to have, the one left
6346 // sitting at the end of the note they just typed, was in no definition at
6347 // all: writing a note and then asking to go back to its reference
6348 // answered nothing. Two definitions in a row still can't both match —
6349 // there is a blank line between them — and `max_by_key` decides anyway.
6350 let note = wysiwyg::footnote_definitions(&mut self.editor)
6351 .into_iter()
6352 .filter(|m| m.span.start <= off && off <= m.span.end)
6353 .max_by_key(|m| m.span.start)?;
6354 let label = wysiwyg::footnote_label(&self.source, note.span.start)?.to_string();
6355
6356 // The earliest reference carrying this label. `min` rather than a `find`,
6357 // because `nodes()` reports a flattened walk whose order is twig's
6358 // business, not document order. Bound first: the walk needs `&mut self`
6359 // and reading the labels back out needs `&self.source`.
6360 let nodes = self.nodes();
6361 let offset = nodes
6362 .into_iter()
6363 .filter(|n| n.kind == Kind::FootnoteReference)
6364 .filter(|n| {
6365 wysiwyg::footnote_reference_label(&self.source, n.span.clone()) == Some(&*label)
6366 })
6367 // Past the `[^`, onto the label — see `FootnoteDef::offset`.
6368 .map(|n| n.span.start + 2)
6369 .min();
6370 Some(FootnoteDef { label, offset })
6371 }
6372
6373 /// The destination of the image under the caret — what an image prompt shows
6374 /// so editing an existing image starts from its current URL instead of blank,
6375 /// the image analogue of [`link_destination_at_caret`](Self::link_destination_at_caret).
6376 /// `None` when the caret stands in no image. A caret resting just after a
6377 /// block image (its trailing stop) is still "in" it — the half-open span test
6378 /// excludes that offset, which is the intended precision: past the image is
6379 /// past it.
6380 pub fn image_destination_at_caret(&mut self) -> Option<String> {
6381 let off = self.caret;
6382 self.nodes()
6383 .into_iter()
6384 .filter(|n| n.kind == Kind::Image)
6385 .filter(|n| n.span.start <= off && off < n.span.end)
6386 .max_by_key(|n| n.span.start)
6387 .and_then(|n| n.destination)
6388 }
6389
6390 /// The language of the fenced code block the caret stands in — what a
6391 /// language prompt shows so editing it starts from the current value rather
6392 /// than blank. `None` when the caret is in no code block, or in one whose
6393 /// fence carries no language (or an indented block, which has no fence).
6394 pub fn code_language_at_caret(&mut self) -> Option<String> {
6395 let start = self.code_block_start_at_caret()?;
6396 wysiwyg::code_language(&self.source, start)
6397 }
6398
6399 /// Whether the caret stands in a fenced code block — the one a language
6400 /// prompt could edit. A frontend gates its "set language" affordance on this
6401 /// (an indented block, which can't carry a language, reports `false`).
6402 pub fn caret_in_fenced_code(&mut self) -> bool {
6403 self.code_block_start_at_caret()
6404 .is_some_and(|start| wysiwyg::code_info_span(&self.source, start).is_some())
6405 }
6406
6407 /// Set (or clear, with `""`) the language of the fenced code block the caret
6408 /// is in — the prompt's confirm. A no-op when the caret is in no fenced
6409 /// block, and a reported error for a language the format's fence cannot
6410 /// carry.
6411 ///
6412 /// twig rewrites the info string, so the fence's own width — measured
6413 /// against a body neither side touches — is kept, and a language holding a
6414 /// space, a line end or the fence character is refused rather than written
6415 /// out to reparse as something else. Leaf used to splice over the info span
6416 /// itself and `trim()` the input, which handled the one bad case it had
6417 /// thought of.
6418 pub fn set_code_language(&mut self, lang: &str) {
6419 // The read-only gate — this door reaches twig without the splice.
6420 if self.read_only {
6421 return;
6422 }
6423 if self.refuse_unsupported("code language", Gesture::SetCodeLanguage) {
6424 return;
6425 }
6426 if self.code_block_start_at_caret().is_none() {
6427 return;
6428 }
6429 let lang = lang.trim();
6430 // `None` clears the info string; `Some("")` asks for an empty one. Both
6431 // write a bare fence, and the prompt's empty value means "clear".
6432 let want = (!lang.is_empty()).then_some(lang);
6433 self.record_caret();
6434 match self.editor.set_code_language(self.caret, want) {
6435 Ok(_) => {
6436 self.last_edit_kind = None;
6437 self.refresh();
6438 self.anchor = None;
6439 self.dirty = self.source != self.clean_source;
6440 self.status = None;
6441 self.clamp_caret();
6442 self.record_caret();
6443 }
6444 Err(e) => self.status = Some(format!("code language: {e}")),
6445 }
6446 }
6447
6448 /// The `span.start` of the code block covering the caret — the anchor
6449 /// [`wysiwyg::code_info_span`] reads the fence from. `None` when the caret is
6450 /// in none.
6451 fn code_block_start_at_caret(&mut self) -> Option<usize> {
6452 let off = self.caret;
6453 self.nodes()
6454 .into_iter()
6455 .filter(|n| n.kind == Kind::CodeBlock && n.span.start <= off && off <= n.span.end)
6456 .max_by_key(|n| n.span.start)
6457 .map(|n| n.span.start)
6458 }
6459
6460 /// The source range of the text inside the link covering `off` — what sits
6461 /// between its `[` and `]`. `None` when twig reports no link there.
6462 fn link_text_span(&mut self, off: usize) -> Option<std::ops::Range<usize>> {
6463 self.nodes()
6464 .into_iter()
6465 // Two links can touch (`[a](x)[b](y)`), and then one's `span.end` is
6466 // the other's `span.start`; the link that starts latest at or before
6467 // `off` is the one `off` is actually in.
6468 .filter(|n| n.kind == Kind::Link && n.span.start <= off && off < n.span.end)
6469 .max_by_key(|n| n.span.start)
6470 .and_then(|n| n.content_span)
6471 }
6472
6473 // ── undo / redo ───────────────────────────────────────────────────────────
6474 // twig owns the history of *bytes* (it owns the buffer) and now carries the
6475 // caret through it too: `record_caret` stashes each state's caret in twig's
6476 // opaque per-step blob, and undo/redo hand it back with the source they
6477 // restore. So leaf keeps no history of its own — no parallel stacks to march
6478 // in lockstep and silently drift out of it.
6479
6480 /// Undo the last edit step (⌘Z / ^Z), putting the caret and selection back
6481 /// where they were when that step began.
6482 pub fn undo(&mut self) {
6483 if self.read_only {
6484 return;
6485 }
6486 let (undone, redoable) = (self.undo_steps, self.redo_steps);
6487 match self.editor.undo() {
6488 Ok(Some(change)) => {
6489 self.after_history(change);
6490 // `refresh` counted the restore as an edit; it was a step back.
6491 self.undo_steps = undone.saturating_sub(1);
6492 self.redo_steps = redoable + 1;
6493 }
6494 Ok(None) => {
6495 self.undo_steps = 0;
6496 self.status = Some("nothing to undo".into());
6497 }
6498 Err(e) => self.status = Some(format!("undo: {e}")),
6499 }
6500 }
6501
6502 /// Redo the last undone edit step (⇧⌘Z / ^Y), putting the caret and
6503 /// selection back where that step originally left them.
6504 pub fn redo(&mut self) {
6505 if self.read_only {
6506 return;
6507 }
6508 let (undone, redoable) = (self.undo_steps, self.redo_steps);
6509 match self.editor.redo() {
6510 Ok(Some(change)) => {
6511 self.after_history(change);
6512 // `refresh` counted the restore as an edit; it was a step forward.
6513 self.undo_steps = undone + 1;
6514 self.redo_steps = redoable.saturating_sub(1);
6515 }
6516 Ok(None) => {
6517 self.redo_steps = 0;
6518 self.status = Some("nothing to redo".into());
6519 }
6520 Err(e) => self.status = Some(format!("redo: {e}")),
6521 }
6522 }
6523
6524 /// Refresh the cached source and put the caret back where the step being
6525 /// undone/redone had it, clearing any active run.
6526 ///
6527 /// The caret comes from twig's blob for the restored state (what
6528 /// `record_caret` stored). `change` is only the fallback for a state with no
6529 /// blob — a caret at the end of the restored text, which is where this always
6530 /// landed before the blobs were kept. It is the edit site, not where the user
6531 /// was standing, so it's a floor and not the behaviour: undoing should hand
6532 /// back the document *and* the place you were working, which for an edit made
6533 /// anywhere but under the caret are two different places.
6534 fn after_history(&mut self, change: Change) {
6535 self.refresh();
6536 match self
6537 .editor
6538 .caret_blob()
6539 .ok()
6540 .and_then(|b| CaretState::from_blob(&b))
6541 {
6542 Some(state) => {
6543 self.caret = state.caret.min(self.source.len());
6544 self.anchor = state.anchor.map(|a| a.min(self.source.len()));
6545 }
6546 None => {
6547 self.caret = change.new.end.min(self.source.len());
6548 self.anchor = None;
6549 }
6550 }
6551 self.goal_col = None;
6552 self.last_edit_kind = None;
6553 self.dirty = self.source != self.clean_source;
6554 self.status = None;
6555 self.clamp_caret();
6556 }
6557
6558 // ── the file ──────────────────────────────────────────────────────────────
6559
6560 #[cfg(feature = "fs")]
6561 pub fn save(&mut self) {
6562 if self.is_untitled() {
6563 // No path to write and no name to invent: ⌘S on an untitled document
6564 // is a Save As, and only a frontend has a picker to ask with. Say so
6565 // rather than failing at the filesystem with an empty path.
6566 self.status = Some("untitled — save as…".into());
6567 return;
6568 }
6569 let path = self.path.clone();
6570 if self.write(&path) {
6571 self.mark_saved();
6572 }
6573 }
6574
6575 /// Save As: write the document to `path` and *move* it there — `self.path`
6576 /// becomes `path`, and every later [`Doc::save`] writes the new file. That's
6577 /// what Save As means; a copy would leave the user editing a document whose
6578 /// name is no longer where their keystrokes go.
6579 ///
6580 /// The move only happens if the bytes actually landed. A failed write leaves
6581 /// the path, `dirty`, and the disk watermark exactly as they were, with the
6582 /// same `save failed: …` status a failed [`Doc::save`] sets — the document
6583 /// must never come away believing it was saved.
6584 ///
6585 /// An existing `path` is overwritten, and the caller is the one that knows
6586 /// whether to ask first: a Save As picker has already run that prompt, and a
6587 /// second confirmation from down here would be the same question twice.
6588 ///
6589 /// `format` does **not** follow the new extension. The buffer is parsed as
6590 /// the format it was opened with, and re-reading it as another one is a
6591 /// conversion — a different, lossy operation that would throw away the undo
6592 /// history — not a rename. So `notes.md` saved as `notes.dj` holds Markdown
6593 /// in a `.dj` file, and `format_name()` keeps honestly saying `markdown`
6594 /// until it's reopened.
6595 #[cfg(feature = "fs")]
6596 pub fn save_as(&mut self, path: PathBuf) {
6597 if !self.write(&path) {
6598 return;
6599 }
6600 self.path = path;
6601 self.mark_saved();
6602 }
6603
6604 /// Put `source` on disk at `path`, reporting whether it got there. The one
6605 /// place leaf writes a document, so a save and a Save As can't disagree
6606 /// about what a failure looks like.
6607 #[cfg(feature = "fs")]
6608 fn write(&mut self, path: &Path) -> bool {
6609 match std::fs::write(path, self.source.as_bytes()) {
6610 Ok(()) => true,
6611 Err(e) => {
6612 self.status = Some(format!("save failed: {e}"));
6613 false
6614 }
6615 }
6616 }
6617
6618 /// Re-base the document's saved watermark to the current bytes: clears
6619 /// `dirty`, records `source` as the new clean state (so undoing back to here
6620 /// clears the flag again), and re-stamps the on-disk hash.
6621 ///
6622 /// [`Doc::save`]/[`Doc::save_as`] call this after a write lands. It is also
6623 /// the hook a **filesystem-free host** calls itself once it has persisted
6624 /// [`Doc::source`] its own way (a browser download, `localStorage`, a backend
6625 /// `PUT`) — which is why it is public and touches no filesystem: the bytes
6626 /// are already where that host wants them, and this just tells the model they
6627 /// are safe.
6628 pub fn mark_saved(&mut self) {
6629 self.clean_source = self.source.clone();
6630 self.dirty = false;
6631 // The bytes on disk are now ours, so this is the new watermark: without
6632 // re-stamping it, every save would report its own work as an external
6633 // change forever after.
6634 self.disk_hash = Some(hash_bytes(self.source.as_bytes()));
6635 self.status = Some(format!("saved {}", self.file_name()));
6636 }
6637
6638 /// What the file looks like now against the bytes leaf last read or wrote.
6639 ///
6640 /// Reads the file and hashes it (see `disk_hash` for why it isn't an mtime),
6641 /// so this is a filesystem round-trip, not a per-frame question — ask it
6642 /// when a window regains focus, on a timer, or before a save.
6643 ///
6644 /// This *only* reports the file. Whether the document also has unsaved edits
6645 /// is `dirty`, and the interesting case is the conjunction: `dirty` plus
6646 /// [`DiskState::Changed`] means a save overwrites someone's work and a
6647 /// [`Doc::reload`] discards the user's. leaf-core deliberately won't choose —
6648 /// it has no way to ask — so it hands a frontend both halves and lets it put
6649 /// the question to the person who can answer it.
6650 #[cfg(feature = "fs")]
6651 pub fn disk_state(&self) -> DiskState {
6652 let Some(want) = self.disk_hash else {
6653 return DiskState::Untitled;
6654 };
6655 match std::fs::read(&self.path) {
6656 Ok(bytes) if hash_bytes(&bytes) == want => DiskState::Unchanged,
6657 Ok(_) => DiskState::Changed,
6658 Err(e) if e.kind() == std::io::ErrorKind::NotFound => DiskState::Missing,
6659 Err(_) => DiskState::Unreadable,
6660 }
6661 }
6662
6663 /// Re-read the file and replace the document with what's there — the other
6664 /// answer to a [`DiskState::Changed`].
6665 ///
6666 /// **Discards unsaved changes, unconditionally.** It doesn't check `dirty`
6667 /// first: a frontend that wants to protect unsaved work asks (`dirty` +
6668 /// [`Doc::disk_state`]) *before* calling this, and one reloading a clean
6669 /// document shouldn't have to argue with a guard.
6670 ///
6671 /// **The undo history survives, and the reload is one step in it.** The
6672 /// whole buffer is spliced with the file's bytes through the same door every
6673 /// other edit goes through, as an [`EditKind::Other`] that coalesces with
6674 /// nothing on either side — so ^Z after a formatter or a `git checkout` has
6675 /// swapped the document out from under a reader gives them back what they
6676 /// were looking at, marked dirty, and ^Z again carries on into whatever they
6677 /// had done before it. This used to build a fresh parse and drop the stack,
6678 /// on the reasoning that twig's history belongs to the buffer and these are
6679 /// different bytes; that is true of *rebasing* a step onto them and not of
6680 /// recording the swap itself as one, which is all this is. A splice twig
6681 /// won't take falls back to the fresh parse, and only that path still costs
6682 /// the history.
6683 ///
6684 /// The caret keeps its byte offset, clamped to the new length; the selection
6685 /// is dropped. Anything cleverer would be a lie: leaf doesn't know how the
6686 /// file changed, so it can't know where the caret "still" is. Clamping keeps
6687 /// it where the user left it in the common case (a change further down the
6688 /// file, or none in the text they're sitting in), and never puts it
6689 /// somewhere invalid. A selection has two such offsets and no such excuse —
6690 /// silently reinterpreting one over changed bytes would arm the *next*
6691 /// keystroke to delete something the user never selected.
6692 ///
6693 /// Nothing is touched unless the whole reload succeeds; a failure leaves the
6694 /// document alone with a status.
6695 #[cfg(feature = "fs")]
6696 pub fn reload(&mut self) {
6697 if self.is_untitled() {
6698 self.status = Some("no file to reload".into());
6699 return;
6700 }
6701 let bytes = match std::fs::read(&self.path) {
6702 Ok(b) => b,
6703 Err(e) => {
6704 self.status = Some(format!("reload failed: {e}"));
6705 return;
6706 }
6707 };
6708 let Ok(source) = String::from_utf8(bytes) else {
6709 self.status = Some("reload failed: file is not UTF-8".into());
6710 return;
6711 };
6712 // Already these bytes — someone saved a file back unchanged, or leaf's
6713 // own write is being read back. Re-baseline against it and stop: a
6714 // splice of the text onto itself would put an undo step on the stack for
6715 // something nobody did.
6716 if source == self.source {
6717 self.disk_hash = Some(hash_bytes(source.as_bytes()));
6718 self.clean_source = source;
6719 self.dirty = false;
6720 self.status = Some(format!("reloaded {}", self.file_name()));
6721 return;
6722 }
6723 let caret = self.caret;
6724 // The pre-reload caret, so undoing the swap puts it back where the
6725 // reader was standing — the same bracketing `splice_exact` does.
6726 self.record_caret();
6727 if self
6728 .editor
6729 .edit_range(0, self.source.len(), &source)
6730 .is_ok()
6731 {
6732 self.refresh();
6733 } else {
6734 // twig wouldn't take the splice. Start over from the bytes, which is
6735 // what this always did, and is the one path that still costs the
6736 // history — `format` is the format this document *is*, not what the
6737 // (unchanged) name now says, see `save_as`.
6738 match new_editor(source.as_bytes(), self.format) {
6739 Ok(editor) => {
6740 self.editor = editor;
6741 self.source = source.clone();
6742 // Not going through `refresh`, so the revision has to move
6743 // here or every frontend keeps painting the old file from
6744 // cache.
6745 self.revision += 1;
6746 }
6747 Err(e) => {
6748 self.status = Some(format!("reload failed: {e}"));
6749 return;
6750 }
6751 }
6752 }
6753 self.disk_hash = Some(hash_bytes(source.as_bytes()));
6754 self.clean_source = self.source.clone();
6755 self.caret = caret.min(self.source.len());
6756 self.anchor = None;
6757 self.goal_col = None;
6758 self.last_edit_kind = None;
6759 self.dirty = false;
6760 self.status = Some(format!("reloaded {}", self.file_name()));
6761 self.clamp_caret();
6762 // And the post-reload caret, so a redo restores it.
6763 self.record_caret();
6764 }
6765
6766 /// Re-read the source from twig after it has changed the document. The one
6767 /// funnel every edit, undo, and redo comes through — so it's where the
6768 /// revision moves, and anything cached against the text dies here.
6769 fn refresh(&mut self) {
6770 if let Ok(s) = self.editor.source_str() {
6771 self.source = s;
6772 }
6773 self.revision += 1;
6774 // An edit is a step onto the history and the end of anything undone;
6775 // `undo`/`redo` come through here too and correct this after.
6776 self.undo_steps += 1;
6777 self.redo_steps = 0;
6778 self.clamp_caret();
6779 }
6780
6781 /// Whether [`undo`](Self::undo) has a step to take back — for a native
6782 /// Edit menu to enable its item by. See the note on `undo_steps` for what
6783 /// "has" means here.
6784 pub fn can_undo(&self) -> bool {
6785 !self.read_only && self.undo_steps > 0
6786 }
6787
6788 /// Whether [`redo`](Self::redo) has an undone step to restore.
6789 pub fn can_redo(&self) -> bool {
6790 !self.read_only && self.redo_steps > 0
6791 }
6792
6793 // ── caret movement ─────────────────────────────────────────────────────────
6794 // `extend` grows the selection (Shift+motion): it pins the anchor on the
6795 // first extended step and moves only the caret; an un-extended motion drops
6796 // the selection.
6797
6798 /// Place the caret at byte `offset` (clamped to a char boundary), extending
6799 /// the selection when `extend` is set. The public form of `move_to`, for a
6800 /// frontend that hit-tests pixels straight to a source offset.
6801 pub fn place_caret(&mut self, offset: usize, extend: bool) {
6802 self.goal_col = None;
6803 let before = self.caret;
6804 // A pixel hit-test can land between the visible caret stops — in the
6805 // blank gap a paragraph break is drawn with, or inside a hidden delimiter.
6806 // Snap to the nearest real stop so the caret can't come to rest where it
6807 // would draw in one place and type in another. The `(row, col)` click
6808 // path (`click`) already snaps this way through `offset_of_pos`; the
6809 // source view reaches every byte, so it snaps to nothing.
6810 let target = match self.view {
6811 View::Wysiwyg => self.vmap.snap_to_stop(offset.min(self.source.len())),
6812 // The source view reaches every byte, so there is no stop to snap
6813 // to — but "every byte" still means every *character* boundary. A
6814 // caret resting inside a multi-byte character draws nowhere real
6815 // and panics the next time anything slices there.
6816 View::Source => self.char_boundary_at_or_before(offset),
6817 };
6818 self.move_to(target, extend);
6819 self.clamp_caret();
6820 self.debug_assert_on_a_stop(before);
6821 }
6822
6823 /// Select the whole document (⌘A / Ctrl+A) — everything reachable in the
6824 /// active view, so in WYSIWYG it starts below hidden frontmatter (copy won't
6825 /// grab the metadata) while the source view still selects the literal whole.
6826 pub fn select_all(&mut self) {
6827 self.anchor = Some(self.caret_floor());
6828 self.caret = self.source.len();
6829 self.goal_col = None;
6830 self.last_edit_kind = None;
6831 self.status = None;
6832 }
6833
6834 /// Select the word (or whitespace / punctuation run) at `offset` — the
6835 /// double-click gesture. Anchors on the run's start with the caret at its
6836 /// end so a following Shift-motion extends from the far edge.
6837 pub fn select_word_at(&mut self, offset: usize) {
6838 let (s, e) = word_range_at(&self.source, offset.min(self.source.len()));
6839 self.anchor = Some(s);
6840 self.caret = e;
6841 self.goal_col = None;
6842 self.last_edit_kind = None;
6843 self.status = None;
6844 self.clamp_caret();
6845 }
6846
6847 /// Select the whole enclosing text block (paragraph, heading, list item's
6848 /// text…) at `offset` — the triple-click gesture. Reads the range straight
6849 /// from the AST (twig's `content_span`), so it selects the entire *logical*
6850 /// paragraph even when that paragraph soft-wraps across several visual rows —
6851 /// where a visual-row-based select breaks down, because one source offset at
6852 /// a wrap boundary belongs to two rows at once.
6853 pub fn select_block_at(&mut self, offset: usize) {
6854 let off = offset.min(self.source.len());
6855 let range = self
6856 .editor
6857 .ancestors_at(off)
6858 .ok()
6859 .and_then(|chain| {
6860 // Ancestors run root → deepest; the deepest node that is neither
6861 // an inline span nor a multi-block container is the text block
6862 // the caret sits in (a paragraph, a heading, a code block…).
6863 chain
6864 .into_iter()
6865 .rev()
6866 .find(|m| !wysiwyg::is_inline_kind(&m.kind) && !is_block_container(&m.kind))
6867 .map(|m| m.content_span.unwrap_or(m.span))
6868 })
6869 .unwrap_or_else(|| source_line_range(&self.source, off));
6870 self.anchor = Some(range.start.min(self.source.len()));
6871 self.caret = range.end.min(self.source.len());
6872 self.goal_col = None;
6873 self.last_edit_kind = None;
6874 self.status = None;
6875 self.clamp_caret();
6876 }
6877
6878 /// Select the exact source range `[start, end)` — anchor at `start`, caret
6879 /// at `end` — without snapping either end to a visible caret stop.
6880 ///
6881 /// The one caret verb that takes a range it was *handed* rather than one it
6882 /// worked out, for a host that already knows the bytes it means: a search
6883 /// hit, an annotation's footprint, a quote re-anchored through
6884 /// [`Doc::selection_quote`]. [`place_caret`](Self::place_caret) is the
6885 /// wrong tool for that, and not by a little — it snaps to the nearest
6886 /// *visible* stop, and where a range butts up against a hidden delimiter
6887 /// the nearest stop is the one before it, so selecting the "needle" of
6888 /// `**needle**` comes back with "needl" and an edit against it strands the
6889 /// "e".
6890 ///
6891 /// What `place_caret` does that is bookkeeping rather than snapping still
6892 /// happens here, because a host handing in a range is not asking to opt out
6893 /// of the invariants:
6894 ///
6895 /// - both ends are clamped into the document and up to
6896 /// [`caret_floor`](Self::caret_floor) — in WYSIWYG the leading
6897 /// frontmatter is hidden, and a caret parked in it draws nowhere and
6898 /// types into the metadata;
6899 /// - both land on character boundaries, so nothing slices a `é` in half;
6900 /// - the sticky vertical goal column is dropped, and any armed inline mark
6901 /// disarmed, since a range from outside inherits neither.
6902 ///
6903 /// An empty range is a caret rather than a selection —
6904 /// [`selection`](Self::selection) reports `None` for it, as it does for any
6905 /// anchor that has met the caret.
6906 pub fn select_range(&mut self, start: usize, end: usize) {
6907 let floor = self.caret_floor();
6908 let anchor = self.char_boundary_at_or_before(start.clamp(floor, self.source.len()));
6909 let caret = self.char_boundary_at_or_before(end.clamp(floor, self.source.len()));
6910 self.anchor = Some(anchor);
6911 self.caret = caret;
6912 self.goal_col = None;
6913 self.status = None;
6914 self.last_edit_kind = None;
6915 self.clear_pending();
6916 }
6917
6918 /// `offset` itself if it is a character boundary, else the boundary before
6919 /// it. An offset that isn't one draws nowhere real and panics the next time
6920 /// anything slices there.
6921 fn char_boundary_at_or_before(&self, offset: usize) -> usize {
6922 let mut o = offset.min(self.source.len());
6923 while o > 0 && !self.source.is_char_boundary(o) {
6924 o -= 1;
6925 }
6926 o
6927 }
6928
6929 /// The lowest source offset the caret may occupy in the active view. In
6930 /// WYSIWYG, leading frontmatter is hidden and unreachable, so the floor is
6931 /// the first rendered offset; the source view reaches everything, so it's 0.
6932 fn caret_floor(&self) -> usize {
6933 match self.view {
6934 View::Wysiwyg => self.vmap.content_start.min(self.source.len()),
6935 View::Source => 0,
6936 }
6937 }
6938
6939 /// Land in a table cell with its whole content selected — the anchor at the
6940 /// cell's start, the caret at its end — so a Tab/Return hop into a cell reads
6941 /// like tabbing into a form field: the text comes up selected, so typing
6942 /// replaces it and an arrow collapses to an edge. An empty cell (`start ==
6943 /// end`) collapses to a plain caret home (an empty selection is no selection).
6944 fn select_cell(&mut self, start: usize, end: usize) {
6945 self.select_range(start, end);
6946 }
6947
6948 fn move_to(&mut self, offset: usize, extend: bool) {
6949 if extend {
6950 if self.anchor.is_none() {
6951 self.anchor = Some(self.caret);
6952 }
6953 } else {
6954 self.anchor = None;
6955 }
6956 self.caret = offset.min(self.source.len()).max(self.caret_floor());
6957 self.status = None;
6958 // A caret move ends the current typing/deletion run, so the next edit
6959 // starts a fresh undo group rather than coalescing across the gap.
6960 self.last_edit_kind = None;
6961 // Moving away disarms any sticky mark — "start bold" applies only where
6962 // it was asked for, not wherever the caret next lands.
6963 self.clear_pending();
6964 }
6965
6966 // In the source view, motion walks source bytes / source lines. In the
6967 // WYSIWYG view it walks the rendered glyph grid (the visual map), which is
6968 // what steps the caret cleanly over hidden delimiters.
6969
6970 pub fn move_left(&mut self, extend: bool) {
6971 self.goal_col = None;
6972 if !extend && let Some((s, _e)) = self.selection() {
6973 self.move_to(s, false);
6974 return;
6975 }
6976 let target = match self.view {
6977 View::Source => {
6978 if self.caret > 0 {
6979 prev_boundary(&self.source, self.caret)
6980 } else {
6981 0
6982 }
6983 }
6984 // Walks caret *stops*, not columns: decoration (a table border, a
6985 // cell's padding) is stepped over in one press, and a hidden
6986 // delimiter never holds the caret up — though the end of a mark's
6987 // content is a stop of its own (`VisualMap::mark_ends`), so
6988 // leaving `**bold**` from past its `**` is a press onto the end of
6989 // the bold and another onto the `d`.
6990 View::Wysiwyg => self
6991 .vmap
6992 .caret_stop_before(self.caret)
6993 .unwrap_or(self.caret),
6994 };
6995 let before = self.caret;
6996 self.move_to(target, extend);
6997 self.debug_assert_on_a_stop(before);
6998 }
6999
7000 pub fn move_right(&mut self, extend: bool) {
7001 self.goal_col = None;
7002 if !extend && let Some((_s, e)) = self.selection() {
7003 self.move_to(e, false);
7004 return;
7005 }
7006 let target = match self.view {
7007 View::Source => {
7008 if self.caret < self.source.len() {
7009 next_boundary(&self.source, self.caret)
7010 } else {
7011 self.caret
7012 }
7013 }
7014 View::Wysiwyg => self.vmap.caret_stop_after(self.caret).unwrap_or(self.caret),
7015 };
7016 let before = self.caret;
7017 self.move_to(target, extend);
7018 self.debug_assert_on_a_stop(before);
7019 }
7020
7021 /// Move to the start of the previous word (⌥← / Ctrl+←).
7022 pub fn move_word_left(&mut self, extend: bool) {
7023 self.goal_col = None;
7024 let before = self.caret;
7025 let target = self.word_left_from(self.caret);
7026 self.move_to(target, extend);
7027 self.debug_assert_on_a_stop(before);
7028 }
7029
7030 /// Move to the end of the next word (⌥→ / Ctrl+→).
7031 pub fn move_word_right(&mut self, extend: bool) {
7032 self.goal_col = None;
7033 let before = self.caret;
7034 let target = self.word_right_from(self.caret);
7035 self.move_to(target, extend);
7036 self.debug_assert_on_a_stop(before);
7037 }
7038
7039 // Word boundaries are found in the space the *view* is in. The source view
7040 // walks the source, because there the source is what's rendered. WYSIWYG
7041 // walks the rendered text instead: `**` is invisible to the user, so it has
7042 // to be invisible to word motion too — a caret parked inside one draws in
7043 // the column after `bold` and types two bytes earlier, and a word-delete
7044 // that stops there shreds the markup into `a ** c`.
7045
7046 /// The word boundary to the left of `off` in the active view's space.
7047 fn word_left_from(&self, off: usize) -> usize {
7048 match self.view {
7049 View::Source => prev_word(&self.source, off),
7050 View::Wysiwyg => self.glyph_word_left(off),
7051 }
7052 }
7053
7054 /// The word boundary to the right of `off` in the active view's space.
7055 fn word_right_from(&self, off: usize) -> usize {
7056 match self.view {
7057 View::Source => next_word(&self.source, off),
7058 View::Wysiwyg => self.glyph_word_right(off),
7059 }
7060 }
7061
7062 /// The character class of the glyph drawn at stop `off`.
7063 ///
7064 /// Read from the source, because a stop points at the source byte its glyph
7065 /// came from — the source *is* where the rendered character is written. What
7066 /// makes the walk glyph space rather than source space is that it only ever
7067 /// visits stops, and the hidden bytes between them have none.
7068 fn class_at(&self, off: usize) -> Class {
7069 self.source
7070 .get(off..)
7071 .and_then(|s| s.chars().next())
7072 .map_or(Class::Space, classify)
7073 }
7074
7075 /// [`next_word`] in glyph space: skip any leading separators, then consume
7076 /// the following word run, with the stop table standing in for the source's
7077 /// characters.
7078 fn glyph_word_right(&self, from: usize) -> usize {
7079 let Some(mut off) = self.vmap.stop_at_or_after(from) else {
7080 return from;
7081 };
7082 let mut in_word = false;
7083 loop {
7084 match self.class_at(off) {
7085 Class::Word => in_word = true,
7086 _ if in_word => return off,
7087 _ => {}
7088 }
7089 match self.vmap.stop_after(off) {
7090 Some(next) => off = next,
7091 None => return off,
7092 }
7093 }
7094 }
7095
7096 /// [`prev_word`] in glyph space: skip separators walking left, then consume
7097 /// the preceding word run.
7098 fn glyph_word_left(&self, from: usize) -> usize {
7099 let Some(mut off) = self.vmap.stop_at_or_before(from) else {
7100 return from;
7101 };
7102 let mut in_word = false;
7103 while let Some(prev) = self.vmap.stop_before(off) {
7104 match self.class_at(prev) {
7105 Class::Word => in_word = true,
7106 _ if in_word => return off,
7107 _ => {}
7108 }
7109 off = prev;
7110 }
7111 off
7112 }
7113
7114 /// After a motion that walks the visual map, the caret must be *on* the map.
7115 /// A stop is the only offset where the caret draws and edits in the same
7116 /// place, and it's the invariant both a caret parked inside an emoji and one
7117 /// parked inside a `**` were quietly breaking.
7118 ///
7119 /// Only when the caret actually moved: a walk with nowhere to go leaves it
7120 /// where it was, which is wherever the floor or a frontend put it rather
7121 /// than somewhere this motion chose.
7122 fn debug_assert_on_a_stop(&self, before: usize) {
7123 debug_assert!(
7124 self.view != View::Wysiwyg
7125 || self.vmap.num_rows() == 0
7126 || self.caret == before
7127 || self.vmap.is_stop(self.caret),
7128 "motion left the caret at {}, which is not a caret stop: it would draw in \
7129 one place and type in another",
7130 self.caret
7131 );
7132 }
7133
7134 // Up and Down run off the ends of the document rather than stopping dead at
7135 // them: Up from the first row lands at the document's start, Down from the
7136 // last at its end. That's Cocoa's rule (`moveUp:`/`moveDown:` past the edge
7137 // are `moveToBeginningOfDocument:`/`moveToEndOfDocument:`), and holding ↓
7138 // reaching the end of the text is what a reader means by it.
7139 //
7140 // The views used to disagree here by accident rather than by decision: the
7141 // source view fell into the edge behaviour through `row_col_to_offset`
7142 // clamping an out-of-range row to the end of the string, while WYSIWYG had
7143 // no row below to walk to and did nothing at all. They share the rule now,
7144 // each in its own space — the source view reaches every byte, WYSIWYG only
7145 // the offsets it draws.
7146
7147 pub fn move_up(&mut self, extend: bool) {
7148 let (row, col) = self.caret_pos();
7149 let goal = self.goal_col.unwrap_or(col);
7150 let target = match self.view {
7151 View::Source => match row.checked_sub(1) {
7152 Some(r) => row_col_to_offset(&self.source, r, goal),
7153 None => self.reachable_start(),
7154 },
7155 // A table's border rules are drawn but hold no caret, so Up steps
7156 // over them to the row that does.
7157 View::Wysiwyg => match self.vmap.navigable_above(row) {
7158 Some(r) => self.row_target(r, goal),
7159 None => self.reachable_start(),
7160 },
7161 };
7162 self.step_vertical(target, goal, extend);
7163 }
7164
7165 pub fn move_down(&mut self, extend: bool) {
7166 let (row, col) = self.caret_pos();
7167 let goal = self.goal_col.unwrap_or(col);
7168 let target = match self.view {
7169 View::Source => match self.source_row_below(row) {
7170 Some(r) => row_col_to_offset(&self.source, r, goal),
7171 None => self.reachable_end(),
7172 },
7173 View::Wysiwyg => match self.vmap.navigable_below(row) {
7174 Some(r) => self.row_target(r, goal),
7175 None => self.reachable_end(),
7176 },
7177 };
7178 self.step_vertical(target, goal, extend);
7179 }
7180
7181 /// Land a vertical motion at `target`, latching the `goal` column it aimed
7182 /// with so the rest of the run keeps aiming there.
7183 ///
7184 /// A motion with nowhere to go changes *nothing*, the goal column included:
7185 /// the latch used to run before the early return at the top of the document,
7186 /// so an Up that did nothing still armed a column, and the next Down aimed
7187 /// at one the caret had never been in.
7188 fn step_vertical(&mut self, target: usize, goal: usize, extend: bool) {
7189 let before = self.caret;
7190 if target == before {
7191 return;
7192 }
7193 self.goal_col = Some(goal);
7194 self.move_to(target, extend);
7195 self.debug_assert_on_a_stop(before);
7196 }
7197
7198 /// The source line below `row`, or `None` when `row` is the last one. Lines
7199 /// are counted by newline, so a trailing one leaves a real, empty last line
7200 /// for the caret to sit on — the document ends below it, not on it.
7201 fn source_row_below(&self, row: usize) -> Option<usize> {
7202 let last = self.source.bytes().filter(|&b| b == b'\n').count();
7203 (row < last).then_some(row + 1)
7204 }
7205
7206 /// Where a vertical motion aiming at the `goal` column lands on visual row
7207 /// `r`: the column clamped to the row, mapped to its offset, then held
7208 /// inside the row's own [bounds](Self::row_bounds) — a wrapped row's last
7209 /// column belongs to the row below, and a gutter's column 0 points at the
7210 /// block rather than at this row.
7211 fn row_target(&self, r: usize, goal: usize) -> usize {
7212 let (start, end) = self.row_bounds(r);
7213 self.vmap
7214 .offset_of_pos(r, goal.min(self.vmap.row_width(r)))
7215 .clamp(start, end)
7216 }
7217
7218 /// The first and last offsets the caret can reach in the active view.
7219 ///
7220 /// Not the same span in both: the source view shows every byte, so it can
7221 /// reach every byte. WYSIWYG reaches only what it draws — hidden frontmatter
7222 /// sits below the first stop, and a document's trailing newline is drawn
7223 /// nowhere and so sits past the last.
7224 fn reachable_start(&self) -> usize {
7225 match self.view {
7226 View::Source => 0,
7227 View::Wysiwyg => self.vmap.stop_at_or_after(0).unwrap_or(self.caret),
7228 }
7229 }
7230
7231 fn reachable_end(&self) -> usize {
7232 match self.view {
7233 View::Source => self.source.len(),
7234 View::Wysiwyg => self
7235 .vmap
7236 .stop_at_or_before(self.source.len())
7237 .unwrap_or(self.caret),
7238 }
7239 }
7240
7241 /// The `[start, end]` offsets visual row `r` *draws* — everything on it,
7242 /// including the space a soft wrap ate off its end, which is drawn on this
7243 /// row however much the offset past it belongs to the next one.
7244 fn row_span(&self, r: usize) -> (usize, usize) {
7245 let start = self
7246 .vmap
7247 .row_start(r)
7248 .unwrap_or_else(|| self.vmap.offset_of_pos(r, 0));
7249 let end = self.vmap.offset_of_pos(r, self.vmap.row_width(r));
7250 (start.min(end), end)
7251 }
7252
7253 /// [`row_span`](Self::row_span) narrowed to where the caret can stand: a
7254 /// soft wrap's shared offset opens the row below (see `pos_of_offset`), so
7255 /// this row's last position is the one before it — the offset before the
7256 /// space the wrap ate, where the caret draws just past the row's last word
7257 /// and types there too.
7258 ///
7259 /// Aiming at the shared offset instead is what stalled End: it is the row's
7260 /// last *column*, so End pressed on the row reached it and then read back as
7261 /// the row below's start, where a second press ran on to that row's end and
7262 /// the next to the one after — End walking down the paragraph a row a press.
7263 fn row_bounds(&self, r: usize) -> (usize, usize) {
7264 let (start, end) = self.row_span(r);
7265 let wraps = self
7266 .vmap
7267 .navigable_below(r)
7268 .and_then(|b| self.vmap.row_start(b))
7269 .is_some_and(|off| off == end);
7270 match wraps {
7271 true => (start, self.vmap.stop_before(end).unwrap_or(end).max(start)),
7272 false => (start, end),
7273 }
7274 }
7275
7276 /// The `[start, end]` of the line Home and End aim at: the visual row in
7277 /// WYSIWYG, the logical line in the source view. Both ends are caret stops.
7278 ///
7279 /// A soft-wrapped row is a line here, because it is one to the eye and the
7280 /// eye is what these keys are aimed by — a reader pressing End means the end
7281 /// of the line they can see. (`select_block_at` wants the opposite and reads
7282 /// the AST for it: a triple-click grabs the whole paragraph, however many
7283 /// rows it folds into.)
7284 fn line_bounds(&self) -> (usize, usize) {
7285 let (row, _) = self.caret_pos();
7286 match self.view {
7287 View::Source => {
7288 let start = line_start(&self.source, row);
7289 (start, line_end_from(&self.source, start))
7290 }
7291 View::Wysiwyg => self.row_bounds(row),
7292 }
7293 }
7294
7295 /// The same line as [`line_bounds`](Self::line_bounds), as far as it is
7296 /// *drawn* — what a kill takes.
7297 ///
7298 /// The two part only at a soft wrap, over the space the wrap ate: the caret
7299 /// can't stand after it (that offset opens the row below, and End stopping
7300 /// there would walk), but it is on this row, and a kill that spared it would
7301 /// leave a double space behind where the row's text had been. Deleting it
7302 /// joins nothing — a wrap is drawn, not written.
7303 fn line_span(&self) -> (usize, usize) {
7304 let (row, _) = self.caret_pos();
7305 match self.view {
7306 View::Source => self.line_bounds(),
7307 View::Wysiwyg => self.row_span(row),
7308 }
7309 }
7310
7311 /// The first offset in `[start, end]` holding something other than
7312 /// whitespace, or `end` when the line holds nothing else — where Home aims.
7313 ///
7314 /// Walks the space the view is in, as word motion does: WYSIWYG steps stops,
7315 /// so a hidden delimiter is never taken for the line's first character (nor
7316 /// landed on), and the source view steps the source it is showing.
7317 fn first_non_space(&self, start: usize, end: usize) -> usize {
7318 let mut off = start;
7319 while off < end {
7320 if self.class_at(off) != Class::Space {
7321 return off;
7322 }
7323 off = match self.view {
7324 View::Source => next_boundary(&self.source, off),
7325 View::Wysiwyg => match self.vmap.stop_after(off) {
7326 Some(next) => next,
7327 None => return end,
7328 },
7329 };
7330 }
7331 end
7332 }
7333
7334 /// Home: to the first character on the line, or to column 0 when the caret
7335 /// is already on it — the two-press toggle every editor spells this way.
7336 /// The indentation is somewhere the caret has to be able to reach and almost
7337 /// never where a reader is headed, so it costs the second press.
7338 pub fn move_home(&mut self, extend: bool) {
7339 self.goal_col = None;
7340 let (start, end) = self.line_bounds();
7341 let text = self.first_non_space(start, end);
7342 let target = if self.caret == text { start } else { text };
7343 let before = self.caret;
7344 self.move_to(target, extend);
7345 self.debug_assert_on_a_stop(before);
7346 }
7347
7348 /// End: to the end of the line.
7349 pub fn move_end(&mut self, extend: bool) {
7350 self.goal_col = None;
7351 let (_, end) = self.line_bounds();
7352 let before = self.caret;
7353 self.move_to(end, extend);
7354 self.debug_assert_on_a_stop(before);
7355 }
7356
7357 /// Hop to the next (Tab) or previous (Shift+Tab) table cell, landing with the
7358 /// cell's whole content selected (see [`Self::select_cell`]). Returns `false`
7359 /// when the caret isn't in a table, or is already in the last/first cell — the
7360 /// frontend then does whatever Tab normally does (indent), so Tab keeps its
7361 /// meaning everywhere else.
7362 pub fn cell_hop(&mut self, forward: bool) -> bool {
7363 let Some((grid, r, c)) = self.table_grid_at(self.caret) else {
7364 return false;
7365 };
7366 // Flatten to document (row-major) order and step one cell either way.
7367 let i: usize = grid[..r].iter().map(Vec::len).sum::<usize>() + c;
7368 let flat: Vec<(usize, usize)> = grid.into_iter().flatten().collect();
7369 let next = if forward {
7370 i.checked_add(1)
7371 } else {
7372 i.checked_sub(1)
7373 };
7374 let Some(&(start, end)) = next.and_then(|j| flat.get(j)) else {
7375 return false; // at the table's edge; leave Tab to the frontend
7376 };
7377 self.select_cell(start, end);
7378 true
7379 }
7380
7381 /// Move the caret to the cell directly above (`down == false`) or below in
7382 /// the same column, landing with the cell's whole content selected (see
7383 /// [`Self::select_cell`]). Returns `false` at the grid's top/bottom edge (or
7384 /// when the caret isn't in a table), so the frontend can fall through — the
7385 /// vertical counterpart of [`Self::cell_hop`].
7386 ///
7387 /// A ragged row that is short a column clamps to its last cell, so Down never
7388 /// falls out of the table over a gap the row above happened to have.
7389 pub fn cell_move_vertical(&mut self, down: bool) -> bool {
7390 let Some((grid, r, c)) = self.table_grid_at(self.caret) else {
7391 return false;
7392 };
7393 let target = match down {
7394 true => r + 1,
7395 false if r == 0 => return false,
7396 false => r - 1,
7397 };
7398 let Some(row) = grid.get(target) else {
7399 return false;
7400 };
7401 let Some(&(start, end)) = row.get(c).or_else(|| row.last()) else {
7402 return false;
7403 };
7404 self.select_cell(start, end);
7405 true
7406 }
7407
7408 /// The table containing `off` as a row-major grid of `(start, end)` cell
7409 /// caret homes, plus the `(row, col)` the caret sits in — `None` when `off`
7410 /// isn't in a table. Read straight off the visual map's laid-out grid, so
7411 /// every cell (an empty one included, whose derived home twig gives no
7412 /// `content_span` for) is present and in the order Tab walks them.
7413 // Grid, row, column — three returns that only ever travel together, and a
7414 // named type for the pair of them would be read at one call site.
7415 #[allow(clippy::type_complexity)]
7416 fn table_grid_at(&self, off: usize) -> Option<(Vec<Vec<(usize, usize)>>, usize, usize)> {
7417 for t in &self.vmap.tables {
7418 let mut pos = None;
7419 let grid: Vec<Vec<(usize, usize)>> = t
7420 .grid
7421 .iter()
7422 .enumerate()
7423 .map(|(r, row)| {
7424 row.cells
7425 .iter()
7426 .enumerate()
7427 .map(|(c, cell)| {
7428 if pos.is_none() && off >= cell.start && off <= cell.end {
7429 pos = Some((r, c));
7430 }
7431 (cell.start, cell.end)
7432 })
7433 .collect()
7434 })
7435 .collect();
7436 if let Some((r, c)) = pos {
7437 return Some((grid, r, c));
7438 }
7439 }
7440 None
7441 }
7442
7443 // ── table key policy ──────────────────────────────────────────────────────
7444 // The three keys a table gives its own meaning — Tab, Return, Shift+Return —
7445 // as one policy every frontend shares, rather than each re-deriving it. Each
7446 // reports whether it acted *as a table key*; a `false` hands the key back to
7447 // the frontend's ordinary handling (indent, newline) so it keeps its meaning
7448 // everywhere else.
7449
7450 /// Tab / Shift+Tab inside a table. Tab steps to the next cell, appending a
7451 /// fresh row and entering it when it runs off the last one; Shift+Tab steps
7452 /// back and simply stays put at the very first cell. `false` when the caret
7453 /// isn't in a table.
7454 pub fn cell_tab(&mut self, forward: bool) -> bool {
7455 if !self.caret_in_table() {
7456 return false;
7457 }
7458 if self.cell_hop(forward) {
7459 return true;
7460 }
7461 // Off the last cell: grow the table by a row and step into its first
7462 // cell. (Shift+Tab at the first cell has nowhere to go and just holds.)
7463 if forward {
7464 self.append_row_and_enter(0);
7465 }
7466 true
7467 }
7468
7469 /// Return inside a table: drop to the cell below in the same column,
7470 /// appending a new row when the caret is already in the last one. `false`
7471 /// when the caret isn't in a table, so the frontend inserts a newline.
7472 pub fn cell_return(&mut self) -> bool {
7473 if !self.caret_in_table() {
7474 return false;
7475 }
7476 if self.cell_move_vertical(true) {
7477 return true;
7478 }
7479 // Already on the last row: grow one below and drop into the same column.
7480 let col = self.table_grid_at(self.caret).map_or(0, |(_, _, c)| c);
7481 self.append_row_and_enter(col);
7482 true
7483 }
7484
7485 /// Append a row below the caret's (last) row and land in `col` of it. The
7486 /// caret is in the last row, so twig's "insert below" makes the fresh row the
7487 /// table's new last — but twig re-spells the whole table, moving every byte,
7488 /// so the destination is read back from the rebuilt grid by the table's
7489 /// position (stable across a row insert), not from the pre-edit caret.
7490 fn append_row_and_enter(&mut self, col: usize) {
7491 let table = self.caret_table_index();
7492 self.table_insert_row(true);
7493 self.rebuild_map();
7494 let Some((start, end)) = table
7495 .and_then(|ti| self.vmap.tables.get(ti))
7496 .and_then(|t| t.grid.last())
7497 .and_then(|row| row.cells.get(col.min(row.cells.len().saturating_sub(1))))
7498 .map(|cell| (cell.start, cell.end))
7499 else {
7500 return;
7501 };
7502 self.select_cell(start, end);
7503 }
7504
7505 /// The index, among the document's tables, of the one the caret sits in —
7506 /// `None` when it's in none. Used to re-find a table after an edit re-spells
7507 /// it (a row insert leaves the table order unchanged).
7508 fn caret_table_index(&self) -> Option<usize> {
7509 let off = self.caret;
7510 self.vmap.tables.iter().position(|t| {
7511 t.grid
7512 .iter()
7513 .any(|row| row.cells.iter().any(|c| off >= c.start && off <= c.end))
7514 })
7515 }
7516
7517 /// Shift+Return inside a table: insert a hard line break *within* the current
7518 /// cell, via twig's `insert_line_break`. `false` when the caret isn't in a
7519 /// table, so the frontend inserts an ordinary line break.
7520 ///
7521 /// A table row is a single source line, so the newline-spelled hard break
7522 /// can't live in a cell. twig spells the in-cell break the format's way
7523 /// (`<br>` for Markdown) and reparses it as a *semantic* `hard_break`, so the
7524 /// break round-trips as structure the renderer reads back as a line — not the
7525 /// opaque raw HTML the old raw-splice left behind.
7526 ///
7527 /// Djot has no idiomatic in-cell break, so twig refuses it
7528 /// (`UnsupportedFormat`) rather than emit a `<br>` that any other djot reader
7529 /// would render as the literal text `<br>`. The gesture is still *consumed*
7530 /// there — returning `false` would let the frontend insert a real newline,
7531 /// which splits the one-line row — it just leaves the cell unchanged and says
7532 /// so on the status line. A rollback (`EditConflict`) is swallowed the same.
7533 ///
7534 /// Which formats refuse is [`Capabilities::cell_line_break`], and the two
7535 /// have to be read together: djot is not the only `false`, and naming it in
7536 /// the message was already a guess that HTML — which spells the break as its
7537 /// own `<br>` — would have made wrong.
7538 pub fn cell_line_break(&mut self) -> bool {
7539 if self.read_only || !self.caret_in_table() {
7540 return false;
7541 }
7542 self.record_caret();
7543 match self.editor.insert_line_break(self.caret) {
7544 Ok(change) => {
7545 self.last_edit_kind = None;
7546 self.refresh();
7547 self.caret = change.new.end;
7548 self.anchor = None;
7549 self.goal_col = None;
7550 self.clamp_caret();
7551 self.dirty = self.source != self.clean_source;
7552 self.status = None;
7553 self.record_caret();
7554 }
7555 Err(twig::Error::UnsupportedFormat) => {
7556 self.status = Some(format!(
7557 "in-cell line breaks aren't supported in {}",
7558 self.format_name()
7559 ));
7560 }
7561 Err(_) => {}
7562 }
7563 true
7564 }
7565
7566 /// Rebuild the visual map at the width the last build used. A structural edit
7567 /// bumps the revision and swaps the source in, but leaves the *map* stale;
7568 /// when a single gesture edits and then moves over the result (Tab appending
7569 /// a row, then stepping into it), the move needs the map to already show the
7570 /// edit rather than waiting for the frontend's next frame.
7571 fn rebuild_map(&mut self) {
7572 let wrap = self.vmap_key.as_ref().and_then(|(_, w, _)| *w);
7573 self.build_map(wrap);
7574 }
7575
7576 /// Move the caret to the very start of the document (⌘↑ on macOS,
7577 /// Ctrl+Home on Windows/Linux).
7578 pub fn move_doc_start(&mut self, extend: bool) {
7579 self.goal_col = None;
7580 self.move_to(0, extend);
7581 }
7582
7583 /// Move the caret to the very end of the document (⌘↓ on macOS,
7584 /// Ctrl+End on Windows/Linux).
7585 pub fn move_doc_end(&mut self, extend: bool) {
7586 self.goal_col = None;
7587 let end = self.source.len();
7588 self.move_to(end, extend);
7589 }
7590
7591 /// Point the caret at the body cell `(row, col)` the mouse landed on —
7592 /// `col` being a cell of the terminal grid, which is what a display column
7593 /// is. A click on the far cell of a wide character lands at that
7594 /// character's start; the mapping's own doc-comments carry the rule.
7595 pub fn click(&mut self, row: usize, col: usize, extend: bool) {
7596 self.goal_col = None;
7597 let target = match self.view {
7598 View::Source => row_col_to_offset(&self.source, row, col),
7599 View::Wysiwyg => self.vmap.offset_of_pos(row, col),
7600 };
7601 let before = self.caret;
7602 self.move_to(target, extend);
7603 self.debug_assert_on_a_stop(before);
7604 }
7605
7606 /// A click in the blank space under the document's last block.
7607 ///
7608 /// Not a click *on* anything, so it lands on nothing in particular: the
7609 /// caret goes onto an empty paragraph under the last block, wherever the
7610 /// pointer was horizontally — and if the document does not end with one,
7611 /// one is opened, which is the only way to get out from under a block Enter
7612 /// cannot leave. Enter inside a fenced code block is a literal newline (see
7613 /// [`newline`](Self::newline)), so a document that *ends* in a fence had no
7614 /// way out at all; and a click under any last block used to land at the
7615 /// pointer's x on the block's last line, which is what a click on that line
7616 /// means and not what a click under it does.
7617 ///
7618 /// "Ends with an empty paragraph" is two trailing newlines: the first closes
7619 /// the last line and the second opens the blank line the visual map lays
7620 /// out as a navigable empty row (see `emit_trailing_blank_lines`). A
7621 /// document ending inside an *unclosed* fence gets the fence closed first,
7622 /// since a newline written there would only be another line of code. An
7623 /// empty document has nothing to be under, and the caret simply goes to its
7624 /// start.
7625 ///
7626 /// In the source view the gesture is the ordinary one: the caret goes to the
7627 /// end of the source, and nothing is written. A read-only document likewise.
7628 pub fn click_past_end(&mut self) {
7629 self.goal_col = None;
7630 let len = self.source.len();
7631 if self.view == View::Source || self.read_only || self.source.trim().is_empty() {
7632 self.move_to(len, false);
7633 return;
7634 }
7635 let mut tail = String::new();
7636 if let Some(fence) = self.unclosed_fence_at_end() {
7637 if !self.source.ends_with('\n') {
7638 tail.push('\n');
7639 }
7640 tail.push_str(&fence);
7641 tail.push('\n');
7642 }
7643 let joined = format!("{}{tail}", self.source);
7644 let trailing = joined.len() - joined.trim_end_matches('\n').len();
7645 for _ in trailing..2 {
7646 tail.push('\n');
7647 }
7648 if !tail.is_empty() && !self.splice(len, len, &tail, EditKind::Other) {
7649 return;
7650 }
7651 let end = self.source.len();
7652 self.move_to(end, false);
7653 }
7654
7655 /// The closing fence a document ending inside an unclosed fenced code block
7656 /// needs — the opening fence's own run, behind the quote prefix the block
7657 /// wears — or `None` when the last block is closed, indented, or not a code
7658 /// block at all.
7659 ///
7660 /// Unclosed is when twig's content span reaches the block's end: a closing
7661 /// fence line would lie between the two. `code_info_span`'s read of the
7662 /// fence is not used because it starts at the block's span, which inside a
7663 /// quote is the quote marker rather than the fence.
7664 fn unclosed_fence_at_end(&mut self) -> Option<String> {
7665 let content_end = self.source.trim_end_matches('\n').len();
7666 let block = self
7667 .nodes()
7668 .into_iter()
7669 .filter(|n| n.kind == Kind::CodeBlock && n.span.end >= content_end)
7670 .max_by_key(|n| n.span.start)?;
7671 if block.content_span.as_ref()?.end < block.span.end {
7672 return None;
7673 }
7674 let line = self.source[block.span.start..].lines().next()?;
7675 let opening = line.trim_start_matches(['>', ' ', '\t']);
7676 let fence = opening.chars().next().filter(|c| matches!(c, '`' | '~'))?;
7677 let run: String = opening.chars().take_while(|&c| c == fence).collect();
7678 let prefix = self.quote_prefix_at(block.span.start);
7679 Some(format!("{prefix}{run}"))
7680 }
7681
7682 /// Settle `scroll` for a frame about to be drawn: follow the caret onto the
7683 /// screen if it has moved since the last frame, and never scroll past the
7684 /// last of `rows`.
7685 ///
7686 /// Only if it has *moved* — that's the whole point. Revealing the caret on
7687 /// every frame ties the viewport to it, and a scroll wheel that fights the
7688 /// caret for the viewport loses: the view snaps back the instant it tries to
7689 /// pass the caret's row, so the document can't be scrolled beyond what's
7690 /// already on screen. A caret move is the frontend's cue to follow; a scroll
7691 /// with the caret sitting still is the reader's cue to leave it alone.
7692 pub fn follow_caret(&mut self, caret_row: usize, height: usize, rows: usize) {
7693 if self.drawn_caret != Some(self.caret) {
7694 if caret_row < self.scroll {
7695 self.scroll = caret_row;
7696 } else if height > 0 && caret_row >= self.scroll + height {
7697 self.scroll = caret_row + 1 - height;
7698 }
7699 self.drawn_caret = Some(self.caret);
7700 }
7701 self.scroll = self.scroll.min(rows.saturating_sub(1));
7702 }
7703
7704 /// The caret's screen position `(row, col)` in the active view's grid, with
7705 /// `col` a display column: the cell to draw the caret in, which on a line of
7706 /// `你好` or emoji is not the count of characters before it.
7707 pub fn caret_pos(&self) -> (usize, usize) {
7708 match self.view {
7709 View::Source => offset_to_row_col(&self.source, self.caret),
7710 View::Wysiwyg => self.vmap.pos_of_offset(self.caret),
7711 }
7712 }
7713
7714 fn clamp_caret(&mut self) {
7715 if self.caret > self.source.len() {
7716 self.caret = self.source.len();
7717 }
7718 // In WYSIWYG the caret can't sit inside hidden frontmatter; lift it (and
7719 // any selection anchor) to the first rendered offset.
7720 let floor = self.caret_floor();
7721 if self.caret < floor {
7722 self.caret = floor;
7723 }
7724 if let Some(a) = self.anchor
7725 && a < floor
7726 {
7727 self.anchor = Some(floor);
7728 }
7729 while self.caret > 0 && !self.source.is_char_boundary(self.caret) {
7730 self.caret -= 1;
7731 }
7732 }
7733}
7734
7735// ── byte-offset ⇄ (row, col) helpers ─────────────────────────────────────────
7736
7737// Left/right motion and backspace/delete step by *grapheme cluster*, not
7738// codepoint, so an emoji (a ZWJ sequence) or a base letter plus its combining
7739// marks moves and deletes as the single character a user sees. Grapheme
7740// boundaries are a superset of char boundaries, so the caret stays valid for twig.
7741
7742/// How an insert of `text` groups for undo: a single typed character folds into
7743/// the run of typing around it, while a newline or a multi-character insert is a
7744/// step of its own.
7745fn typed_edit_kind(text: &str) -> EditKind {
7746 if text.chars().take(2).count() == 1 && text != "\n" {
7747 EditKind::Insert
7748 } else {
7749 EditKind::Other
7750 }
7751}
7752
7753fn prev_boundary(s: &str, i: usize) -> usize {
7754 let mut cursor = GraphemeCursor::new(i, s.len(), true);
7755 cursor.prev_boundary(s, 0).ok().flatten().unwrap_or(0)
7756}
7757
7758fn next_boundary(s: &str, i: usize) -> usize {
7759 let mut cursor = GraphemeCursor::new(i, s.len(), true);
7760 cursor.next_boundary(s, 0).ok().flatten().unwrap_or(s.len())
7761}
7762
7763// ── word boundaries ──────────────────────────────────────────────────────────
7764// The shared primitive behind word-wise motion, word deletion, and
7765// double-click-to-select-a-word. A "word" is a maximal run of one character
7766// class; whitespace and punctuation are their own classes, so motion skips
7767// cleanly between them the way native text fields do.
7768
7769#[derive(PartialEq, Eq, Clone, Copy)]
7770enum Class {
7771 Word,
7772 Space,
7773 Other,
7774}
7775
7776/// The source range of an inline node's own visible text — the part of it a
7777/// WYSIWYG caret can reach, as against the delimiters that only spell it.
7778/// `None` for a node with no interior to empty (a `str`, a break).
7779///
7780/// twig reports no `content_span` for `verbatim`/`inline_math`, whose text sits
7781/// one delimiter in from the span — the same place the renderer maps it to. A
7782/// longer fence (`` ``a`` ``) breaks that assumption, so the guess is checked
7783/// against the source rather than trusted: a range guessed wrong here is text
7784/// deleted wrong.
7785fn inline_content_span(n: &FlatNode, source: &str) -> Option<std::ops::Range<usize>> {
7786 if let Some(span) = n.content_span.clone() {
7787 return Some(span);
7788 }
7789 match n.kind.as_str() {
7790 "verbatim" | "inline_math" => {
7791 let text = n.text.as_ref()?;
7792 let start = n.span.start + 1;
7793 let range = start..start + text.len();
7794 (source.get(range.clone()) == Some(text.as_str())).then_some(range)
7795 }
7796 _ => None,
7797 }
7798}
7799
7800/// The `id` a node declares, or `None` for one that declares none — the
7801/// attribute djot writes for a `{#v1}` and mints for a heading.
7802///
7803/// A bare attribute (`{#v1 hidden}`'s `hidden`) has no value, and a bare `id`
7804/// names nothing, so it reads as absent rather than as the empty string.
7805fn declared_id(n: &FlatNode) -> Option<&str> {
7806 n.attrs.iter().find(|(k, _)| k == "id")?.1.as_deref()
7807}
7808
7809/// A heading's words reduced to the form a link fragment spells them in:
7810/// lowercase, runs of anything else collapsed to a single `-`, with none left
7811/// dangling at either end. `## Some Heading Here` → `some-heading-here`.
7812///
7813/// The rule every Markdown renderer follows, and applied to djot's own auto-ids
7814/// too so that `#some-heading-here` and `#Some-Heading-Here` are one question.
7815/// Unicode-aware (`is_alphanumeric`, not an ASCII test), because a heading in
7816/// any other language is still a heading someone will link to. Underscores
7817/// survive for the same reason they do on the web: they are word characters
7818/// wherever identifiers are written.
7819fn slug(text: &str) -> String {
7820 let mut out = String::new();
7821 let mut pending = false;
7822 for c in text.chars() {
7823 if c.is_alphanumeric() || c == '_' {
7824 if pending && !out.is_empty() {
7825 out.push('-');
7826 }
7827 pending = false;
7828 out.extend(c.to_lowercase());
7829 } else {
7830 pending = true;
7831 }
7832 }
7833 out
7834}
7835
7836fn is_block_container(kind: &Kind) -> bool {
7837 matches!(
7838 kind,
7839 Kind::Doc
7840 | Kind::Section
7841 | Kind::BlockQuote
7842 | Kind::BulletList
7843 | Kind::OrderedList
7844 | Kind::TaskList
7845 | Kind::ListItem
7846 | Kind::TaskListItem
7847 // Every `container` — a directive in any of its three forms, or a
7848 // promoted HTML element. A *text* directive is really inline, so
7849 // claiming it here is a small overreach, and the deliberate one this
7850 // function's kind-only peer `is_inline_kind` documents: the pair is
7851 // consulted together, and answering "block container" for something
7852 // inline is what keeps an ancestor walk from stopping short of the
7853 // paragraph that actually holds it.
7854 | Kind::Container
7855 )
7856}
7857
7858/// The `[start, end)` byte range of the source line containing `off` (newline
7859/// excluded) — the fallback when `off` sits outside any AST block (e.g. a blank
7860/// line between paragraphs).
7861fn source_line_range(s: &str, off: usize) -> std::ops::Range<usize> {
7862 let off = off.min(s.len());
7863 let start = s[..off].rfind('\n').map(|p| p + 1).unwrap_or(0);
7864 let end = s[off..].find('\n').map(|p| off + p).unwrap_or(s.len());
7865 start..end
7866}
7867
7868/// How many leading bytes an outdent takes off `line`: a whole indent level
7869/// where the line has one, and whatever it has where it has less.
7870///
7871/// A leading tab counts as a level on its own. It's indentation some other
7872/// editor wrote, and one tab is one level everywhere it came from — measuring it
7873/// in spaces it doesn't contain would leave it untouchable.
7874fn outdent_width(line: &str, unit: usize) -> usize {
7875 if line.starts_with('\t') {
7876 return 1;
7877 }
7878 line.bytes().take(unit).take_while(|b| *b == b' ').count()
7879}
7880
7881/// A list marker found at the head of a line, together with everything before it
7882/// that a sibling line has to repeat.
7883///
7884/// The three offsets differ only inside a block quote, where `> - b` opens with
7885/// a `> ` quote marker the line's own text doesn't own. Outside one they collapse:
7886/// `line_start == marker_start`, and `text` is the plain `" - "`.
7887#[derive(Clone, Debug)]
7888struct ListMarker {
7889 /// The line's first byte.
7890 line_start: usize,
7891 /// Where the marker proper begins, past any quote prefix. The offset to hand
7892 /// the AST: a quoted item's span opens at its bullet, not at the `>`.
7893 marker_start: usize,
7894 /// `line_start` through the marker's trailing space — quote prefix, indent
7895 /// and bullet together, which is what the next item's line opens with.
7896 text: String,
7897}
7898
7899impl ListMarker {
7900 /// Where the item's content starts — one past the marker's trailing space.
7901 fn content_start(&self) -> usize {
7902 self.line_start + self.text.len()
7903 }
7904}
7905
7906fn classify(c: char) -> Class {
7907 if c == '_' || c.is_alphanumeric() {
7908 Class::Word
7909 } else if c.is_whitespace() {
7910 Class::Space
7911 } else {
7912 Class::Other
7913 }
7914}
7915
7916/// The offset at the end of the next word to the right of `i` (⌥→ / Ctrl+→):
7917/// skip any leading separators, then consume the following word run.
7918fn next_word(s: &str, i: usize) -> usize {
7919 let mut off = i;
7920 let mut in_word = false;
7921 for c in s[i..].chars() {
7922 if classify(c) == Class::Word {
7923 in_word = true;
7924 } else if in_word {
7925 break;
7926 }
7927 off += c.len_utf8();
7928 }
7929 off
7930}
7931
7932/// The offset at the start of the word to the left of `i` (⌥← / Ctrl+←):
7933/// skip separators walking left, then consume the preceding word run.
7934fn prev_word(s: &str, i: usize) -> usize {
7935 let mut off = i;
7936 let mut in_word = false;
7937 for c in s[..i].chars().rev() {
7938 if classify(c) == Class::Word {
7939 in_word = true;
7940 } else if in_word {
7941 break;
7942 }
7943 off -= c.len_utf8();
7944 }
7945 off
7946}
7947
7948/// The `[start, end)` run of same-class characters surrounding `off` — the
7949/// word (or whitespace/punctuation run) a double-click selects. At end-of-text
7950/// the run ending there is used.
7951fn word_range_at(s: &str, off: usize) -> (usize, usize) {
7952 if s.is_empty() {
7953 return (0, 0);
7954 }
7955 let off = off.min(s.len());
7956 let reference = if off < s.len() {
7957 s[off..].chars().next()
7958 } else {
7959 s[..off].chars().next_back()
7960 };
7961 let Some(rc) = reference else {
7962 return (off, off);
7963 };
7964 let class = classify(rc);
7965
7966 let mut start = off;
7967 for c in s[..start].chars().rev() {
7968 if classify(c) == class {
7969 start -= c.len_utf8();
7970 } else {
7971 break;
7972 }
7973 }
7974 let mut end = off;
7975 for c in s[end..].chars() {
7976 if classify(c) == class {
7977 end += c.len_utf8();
7978 } else {
7979 break;
7980 }
7981 }
7982 (start, end)
7983}
7984
7985/// `(row, col)` of byte offset `off`, `col` counted in *display columns* from
7986/// the line's start — terminal cells, not characters, so the column names the
7987/// cell the caret is drawn in even on a line of `你好` or emoji.
7988fn offset_to_row_col(s: &str, off: usize) -> (usize, usize) {
7989 let off = off.min(s.len());
7990 let mut row = 0;
7991 let mut line_start = 0;
7992 for (i, &b) in s.as_bytes().iter().enumerate() {
7993 if i >= off {
7994 break;
7995 }
7996 if b == b'\n' {
7997 row += 1;
7998 line_start = i + 1;
7999 }
8000 }
8001 (row, wysiwyg::text_width(&s[line_start..off]))
8002}
8003
8004/// The byte offset at display column `col` of `row` (clamped to that line's
8005/// end) — the inverse of [`offset_to_row_col`], which it has to agree with.
8006///
8007/// A column landing *inside* a character — the second cell of `你`, or any cell
8008/// but the first of an emoji — resolves to that character's start, which is the
8009/// column the caret would have been drawn at to begin with. So both cells of a
8010/// wide character mean the character, and every offset survives the round trip
8011/// out to a column and back. The walk steps by grapheme cluster for the same
8012/// reason the caret does: a cluster is the character, and the cells belong to it
8013/// rather than to the codepoints spelling it.
8014fn row_col_to_offset(s: &str, row: usize, col: usize) -> usize {
8015 let start = line_start(s, row);
8016 let end = line_end_from(s, start);
8017 let mut off = start;
8018 let mut at = 0; // the display column `off` sits at
8019 while off < end {
8020 let next = next_boundary(s, off).min(end);
8021 let cells = wysiwyg::text_width(&s[off..next]);
8022 if at + cells > col {
8023 break; // `col` is one of this cluster's own cells
8024 }
8025 at += cells;
8026 off = next;
8027 }
8028 off
8029}
8030
8031fn line_start(s: &str, row: usize) -> usize {
8032 if row == 0 {
8033 return 0;
8034 }
8035 let mut r = 0;
8036 for (i, &b) in s.as_bytes().iter().enumerate() {
8037 if b == b'\n' {
8038 r += 1;
8039 if r == row {
8040 return i + 1;
8041 }
8042 }
8043 }
8044 s.len()
8045}
8046
8047fn line_end_from(s: &str, start: usize) -> usize {
8048 s[start..].find('\n').map(|p| start + p).unwrap_or(s.len())
8049}
8050
8051/// twig's node-kind name for an inline mark, back to the [`InlineKind`] a
8052/// frontend names when it calls [`Doc::toggle`] — the inverse of the mapping
8053/// twig applies writing the mark out, so the toolbar can light the same button
8054/// that made the node.
8055///
8056/// `None` for every other kind, including the inline nodes that aren't marks at
8057/// all (`str`, `link`, `image`, the math and break kinds): they're things a
8058/// caret stands in, not formatting a button toggles.
8059/// Whether a match from an ancestor chain is an inline run whose delimiters
8060/// the rich view draws nothing for — a mark (`**`, `_`, `==`), or an
8061/// attributed span: `<span data-size="large">…</span>`, djot's `[…]{…}`. The
8062/// span is a [`Kind::Container`], which the kind alone cannot tell from a
8063/// block `<div>`, so the chain's caller passes [`Doc::run_span_ids`] and the
8064/// answer is the node's own. Every delete and caret step that walks over a
8065/// `**` walks over a span's tags by this test; without it Backspace after
8066/// `</span>` took the `>` and left the paragraph unparseable.
8067fn hides_delims(m: &QueryMatch, run_spans: &[NodeId]) -> bool {
8068 inline_kind(&m.kind).is_some() || run_spans.contains(&NodeId(m.node_id))
8069}
8070
8071fn inline_kind(kind: &Kind) -> Option<InlineKind> {
8072 Some(match kind {
8073 Kind::Strong => InlineKind::Strong,
8074 Kind::Emph => InlineKind::Emph,
8075 Kind::Verbatim => InlineKind::Verbatim,
8076 Kind::Mark => InlineKind::Mark,
8077 Kind::Superscript => InlineKind::Superscript,
8078 Kind::Subscript => InlineKind::Subscript,
8079 Kind::Insert => InlineKind::Insert,
8080 Kind::Delete => InlineKind::Delete,
8081 _ => return None,
8082 })
8083}
8084
8085/// leaf's [`MarkColor`] as twig's — the palette twig writes as the emoji after
8086/// a highlight's opening `==`.
8087///
8088/// Two enums for one closed vocabulary, and the duplication is the boundary
8089/// working: core's is what a *frontend* names (`style::MarkColor`, beside the
8090/// [`Role`](crate::Role) that carries it into the glyph map) and twig's is what
8091/// the editor writes. Spelled as a match rather than routed through the two
8092/// crates' name strings so that a colour added on either side is a compile
8093/// error here, where the pairing is decided, rather than a runtime `None` that
8094/// would read as "clear the colour".
8095fn twig_mark_color(color: MarkColor) -> twig::MarkColor {
8096 match color {
8097 MarkColor::Red => twig::MarkColor::Red,
8098 MarkColor::Orange => twig::MarkColor::Orange,
8099 MarkColor::Yellow => twig::MarkColor::Yellow,
8100 MarkColor::Green => twig::MarkColor::Green,
8101 MarkColor::Blue => twig::MarkColor::Blue,
8102 MarkColor::Purple => twig::MarkColor::Purple,
8103 MarkColor::Brown => twig::MarkColor::Brown,
8104 }
8105}
8106
8107/// Where an offset lands after a splice it didn't make — twig's own rule, from
8108/// [`Change`]: shift anything at or past the replaced range's end by the length
8109/// the replacement gained or lost, and leave anything before it alone.
8110///
8111/// An offset *inside* the replaced range has no text of its own to ride any
8112/// more, and lands at the end of what replaced it: for
8113/// [`Doc::set_mark_color`] that is a caret standing on the colour prefix when
8114/// the prefix is cleared, which then sits where the highlighted text begins.
8115/// One node's attribute list, twig's own `(key, value)` pairs owned — what
8116/// every presentation gesture reads, edits one key of, and passes back whole.
8117type Attrs = Vec<(String, Option<String>)>;
8118
8119/// The name of the leaf directive a page break is — [`Doc::insert_page_break`]
8120/// writes it and the walker draws it, and a frontend that paginates matches a
8121/// [`DirectiveMark`](crate::wysiwyg::DirectiveMark) against it. One spelling,
8122/// stated once.
8123pub const PAGE_BREAK: &str = "page-break";
8124
8125/// `attrs` with `key` set to `value`, or removed when `value` is `None`, and
8126/// every other attribute kept in its place — the read-edit-write half of twig's
8127/// replace-not-merge contract for a `data-` key.
8128///
8129/// **A key that is already there is rewritten where it stands**, and only a key
8130/// the node did not have goes on the end. That is what makes the proposal's
8131/// worked example true: `class="lead center" id="intro"
8132/// data-line-height="1.5"`, right-aligned, is `class="lead right" id="intro"
8133/// data-line-height="1.5"` — the same document with one token changed, and a
8134/// one-line diff. Removing the key and pushing it back would reorder the
8135/// author's attributes on every press, so a document that passed through the
8136/// editor came out shuffled even where nothing about it had changed.
8137///
8138/// A duplicate key — which no format leaf opens can spell, but twig reports
8139/// verbatim — collapses onto the first of its copies, since twig is handed one
8140/// value for one key either way.
8141fn with_attr(attrs: &[(String, Option<String>)], key: &str, value: Option<&str>) -> Attrs {
8142 let mut out: Attrs = Vec::with_capacity(attrs.len() + 1);
8143 let mut written = false;
8144 for (k, v) in attrs {
8145 if k != key {
8146 out.push((k.clone(), v.clone()));
8147 continue;
8148 }
8149 if let Some(new) = value.filter(|_| !written) {
8150 out.push((k.clone(), Some(new.to_string())));
8151 written = true;
8152 }
8153 }
8154 if let Some(new) = value.filter(|_| !written) {
8155 out.push((key.to_string(), Some(new.to_string())));
8156 }
8157 out
8158}
8159
8160/// [`with_attr`] for a `class` token: every token `mine` claims is removed, and
8161/// `token` added, with the rest of the list kept in order.
8162///
8163/// `class` is a space-separated token list, and leaf owns three of the tokens in
8164/// it. A paragraph that arrives as `class="lead center"` and is right-aligned
8165/// goes out as `class="lead right"`; one whose last owned token goes and which
8166/// carried nothing else loses the key, so a block that has lost its whole
8167/// vocabulary is spelled bare again. `class` itself keeps its place among the
8168/// attributes, because [`with_attr`] does the writing.
8169fn with_class_token(
8170 attrs: &[(String, Option<String>)],
8171 mine: impl Fn(&str) -> bool,
8172 token: Option<&str>,
8173) -> Attrs {
8174 let kept: Vec<&str> = attrs
8175 .iter()
8176 .find(|(k, _)| k == "class")
8177 .and_then(|(_, v)| v.as_deref())
8178 .unwrap_or_default()
8179 .split_whitespace()
8180 .filter(|t| !mine(t))
8181 .collect();
8182 let class = kept.into_iter().chain(token).collect::<Vec<_>>().join(" ");
8183 with_attr(
8184 attrs,
8185 "class",
8186 (!class.is_empty()).then_some(class.as_str()),
8187 )
8188}
8189
8190/// An owned attribute list as the borrowed pairs twig's two attribute ops take.
8191///
8192/// A **bare** attribute — one twig reports with no value, such as HTML's `<p
8193/// hidden>` — is passed back as an empty one. Twig refuses a `None` outright
8194/// (djot has no bare attribute, so no format reads one back everywhere), and
8195/// `hidden=""` is the same document where `hidden` is; dropping it instead
8196/// would lose what the author wrote, which is the one thing these gestures
8197/// promise not to do.
8198fn attr_pairs(attrs: &[(String, Option<String>)]) -> Vec<(&str, Option<&str>)> {
8199 attrs
8200 .iter()
8201 .map(|(k, v)| (k.as_str(), Some(v.as_deref().unwrap_or_default())))
8202 .collect()
8203}
8204
8205fn reanchor(off: usize, change: &Change) -> usize {
8206 if off < change.old.start {
8207 return off;
8208 }
8209 if off < change.old.end {
8210 return change.new.end;
8211 }
8212 (off + change.new.end).saturating_sub(change.old.end)
8213}
8214
8215/// [`reanchor`] for an edit that respells the markup *around* a block and
8216/// leaves the block's own bytes alone — which is every attribute gesture.
8217///
8218/// `block` is that block's content span before and after the splice, so an
8219/// offset standing in the text keeps its distance from the text's start and how
8220/// many bytes twig wrote above it never enters the arithmetic. That is the whole
8221/// rule, and it is why nothing here knows how long a `<div …>` is: a second key
8222/// on the same div lengthens the attribute line, clearing the last one takes the
8223/// div away entirely, and both are the same sum. `None` where the splice named
8224/// no block at either end, which is every djot case — the `{…}` line is written
8225/// above the block, and the block itself only shifts past it.
8226///
8227/// Anywhere else it is `reanchor`'s own answer: untouched before the splice,
8228/// shifted by its delta after it, and at the splice's end for an offset that
8229/// stood in markup being rewritten — a caret inside djot's `{…}` line has no
8230/// text to keep.
8231fn reanchor_in_block(
8232 off: usize,
8233 change: &Change,
8234 block: Option<(&Range<usize>, &Range<usize>)>,
8235) -> usize {
8236 if let Some((was, now)) = block
8237 && was.start <= off
8238 && off <= was.end
8239 {
8240 return now.start + (off - was.start).min(now.end - now.start);
8241 }
8242 reanchor(off, change)
8243}
8244
8245/// A watermark for a file's contents (see `Doc::disk_hash`).
8246///
8247/// `DefaultHasher` is not stable across Rust releases, which doesn't matter: a
8248/// watermark is compared only against one taken by the same process moments
8249/// earlier, and never outlives it. 64 bits leaves a collision — an external edit
8250/// that hashes to exactly what leaf wrote — at odds no filesystem race gets near.
8251fn hash_bytes(bytes: &[u8]) -> u64 {
8252 use std::hash::{Hash, Hasher};
8253 let mut h = std::collections::hash_map::DefaultHasher::new();
8254 bytes.hash(&mut h);
8255 h.finish()
8256}
8257
8258#[cfg(feature = "fs")]
8259fn detect_format(path: &Path) -> Result<Format> {
8260 let ext = path
8261 .extension()
8262 .and_then(|e| e.to_str())
8263 .unwrap_or("")
8264 .to_ascii_lowercase();
8265 Ok(match ext.as_str() {
8266 "dj" | "djot" => Format::Djot,
8267 "md" | "markdown" => Format::Markdown,
8268 "xml" => Format::Xml,
8269 "html" | "htm" => Format::Html,
8270 other => return Err(anyhow!("unknown document extension: .{other}")),
8271 })
8272}
8273
8274#[cfg(test)]
8275mod tests {
8276 use super::*;
8277 use crate::style::{FontFamily, LineSpacing, SizeStep};
8278
8279 /// A document open in `view`. WYSIWYG motion reads the visual map, which the
8280 /// renderer stamps each frame, so the map is built here too — a WYSIWYG doc
8281 /// without one is a view no user is ever in.
8282 fn doc_in(view: View, name: &str, body: &str) -> Doc {
8283 // The fixture name doubles as the temp file's, so two tests picking the
8284 // same one raced under the parallel runner and read each other's body —
8285 // a green suite proving the wrong thing. The counter makes that
8286 // unreachable rather than asking every future caller to notice.
8287 static SEQ: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
8288 let seq = SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
8289 let mut p = std::env::temp_dir();
8290 p.push(format!("leaf_test_{name}_{seq}.md"));
8291 std::fs::write(&p, body).unwrap();
8292 let mut d = Doc::open(p).unwrap();
8293 d.view = view;
8294 if view == View::Wysiwyg {
8295 d.build_visual(80);
8296 }
8297 d
8298 }
8299
8300 // Source-view document for the source-behaviour tests. `Doc::open` now
8301 // defaults to WYSIWYG (leaf's default view), so pin the source view here;
8302 // `wysiwyg_doc` builds the rich-text variant on top of this.
8303 fn doc_with(name: &str, body: &str) -> Doc {
8304 doc_in(View::Source, name, body)
8305 }
8306
8307 /// Every visual row's drawn text — what the reader actually sees, which is
8308 /// the only thing the reveal preference is supposed to change.
8309 fn drawn_rows(d: &Doc) -> Vec<String> {
8310 d.vmap
8311 .rows
8312 .iter()
8313 .map(|r| r.glyphs.iter().map(|g| g.ch).collect())
8314 .collect()
8315 }
8316
8317 /// Put the caret at the first byte of `needle` and rebuild, so the row under
8318 /// it becomes the revealed line.
8319 fn caret_at(d: &mut Doc, needle: &str) {
8320 d.caret = d.source.find(needle).expect("needle in source");
8321 d.build_visual(80);
8322 }
8323
8324 #[test]
8325 fn blockquote_after_a_list_is_not_bulleted() {
8326 // twig nests a following top-level block quote under the `bullet_list`
8327 // (a direct child, not a `list_item`). The map must render it de-nested —
8328 // `│ quote`, never `• │ quote` — with a blank separator, like any block
8329 // that follows a list. Regression for the "combined list + blockquote" bug.
8330 let mut d = doc_in(View::Wysiwyg, "bq_after_list", "- item\n\n> quote\n");
8331 d.build_visual(80);
8332 let rows: Vec<String> = d
8333 .vmap
8334 .rows
8335 .iter()
8336 .map(|r| r.glyphs.iter().map(|g| g.ch).collect())
8337 .collect();
8338 assert!(
8339 rows.iter().any(|r| r == "│ quote"),
8340 "block quote should render on its own gutter, got rows: {rows:?}"
8341 );
8342 assert!(
8343 !rows.iter().any(|r| r.contains('•') && r.contains('│')),
8344 "no row should carry both a bullet and a quote gutter, got rows: {rows:?}"
8345 );
8346 }
8347
8348 // ── the map is built at most once per (revision, wrap) ───────────────────
8349 //
8350 // A frontend repaints for reasons that have nothing to do with the text — a
8351 // blinking caret, a scroll — and rebuilding the map is O(document). These
8352 // pin *that the cache fires*, which a passing suite can't tell you: a cache
8353 // that never hits is invisible to every other test in this file.
8354 //
8355 // The probe is to wreck the built map and ask for it again. A rebuild
8356 // repairs it; a cache hit hands the wreckage straight back. Nothing else
8357 // can distinguish the two from outside.
8358
8359 #[test]
8360 fn a_rebuild_with_nothing_changed_reuses_the_map() {
8361 let mut d = doc_in(View::Wysiwyg, "cache_hit", "# Title\n\nbody\n");
8362 d.build_visual(80);
8363 assert!(!d.vmap.rows.is_empty());
8364 d.vmap.rows.clear(); // wreck it
8365 d.build_visual(80);
8366 assert!(
8367 d.vmap.rows.is_empty(),
8368 "the map was rebuilt though nothing changed — the cache never fired"
8369 );
8370 }
8371
8372 #[test]
8373 fn an_edit_rebuilds_the_map() {
8374 let mut d = doc_in(View::Wysiwyg, "cache_edit", "# Title\n\nbody\n");
8375 d.build_visual(80);
8376 let before = d.revision();
8377 d.vmap.rows.clear();
8378 d.insert("x");
8379 d.build_visual(80);
8380 assert!(d.revision() > before, "an edit must move the revision");
8381 assert!(
8382 !d.vmap.rows.is_empty(),
8383 "an edited document must not paint from a stale map"
8384 );
8385 }
8386
8387 #[test]
8388 fn a_width_change_rebuilds_the_map() {
8389 // The map is a function of the wrap width too, so a resize is a miss
8390 // even though the text is untouched.
8391 let mut d = doc_in(
8392 View::Wysiwyg,
8393 "cache_width",
8394 "one two three four five six\n",
8395 );
8396 d.build_visual(80);
8397 d.vmap.rows.clear();
8398 d.build_visual(12);
8399 assert!(!d.vmap.rows.is_empty(), "a resize must rebuild the map");
8400 // And the unwrapped map is its own key, not the same as any width.
8401 d.vmap.rows.clear();
8402 d.build_visual_unwrapped();
8403 assert!(!d.vmap.rows.is_empty(), "unwrapped is a different map");
8404 }
8405
8406 #[test]
8407 fn a_motion_does_not_rebuild_the_map() {
8408 // The whole point: moving the caret changes nothing the map is built
8409 // from. If a motion bumped the revision, every arrow key would cost a
8410 // full rebuild and the cache would be worthless.
8411 let mut d = doc_in(View::Wysiwyg, "cache_motion", "# Title\n\nbody text\n");
8412 d.build_visual(80);
8413 let rev = d.revision();
8414 d.move_right(false);
8415 d.move_right(true);
8416 d.move_down(false);
8417 assert_eq!(d.revision(), rev, "a motion must not move the revision");
8418 d.vmap.rows.clear();
8419 d.build_visual(80);
8420 assert!(
8421 d.vmap.rows.is_empty(),
8422 "a motion should not rebuild the map"
8423 );
8424 }
8425
8426 #[test]
8427 fn saving_does_not_rebuild_the_map() {
8428 // Saving changes `dirty`, not the text.
8429 let mut d = doc_in(View::Wysiwyg, "cache_save", "# Title\n\nbody\n");
8430 d.insert("x");
8431 d.build_visual(80);
8432 let rev = d.revision();
8433 d.save();
8434 assert_eq!(d.revision(), rev, "a save must not move the revision");
8435 assert!(!d.dirty, "the save should have cleaned the document");
8436 }
8437
8438 #[test]
8439 fn a_reload_rebuilds_the_map() {
8440 // Reload replaces the text without going through `refresh`, so it has to
8441 // move the revision itself — else the editor paints the old file.
8442 let mut d = doc_in(View::Wysiwyg, "cache_reload", "# Title\n\nbody\n");
8443 d.build_visual(80);
8444 let rev = d.revision();
8445 std::fs::write(&d.path, "# Other\n\nwholly new\n").unwrap();
8446 d.reload();
8447 assert!(d.revision() > rev, "a reload must move the revision");
8448 d.build_visual(80);
8449 let text: String = d
8450 .vmap
8451 .rows
8452 .iter()
8453 .flat_map(|r| r.glyphs.iter().map(|g| g.ch))
8454 .collect();
8455 assert!(
8456 text.contains("wholly new"),
8457 "the reloaded text should be on screen, got {text:?}"
8458 );
8459 }
8460
8461 // ── golden-case harness ──────────────────────────────────────────────────
8462 // The pattern the whole parity suite can reuse: write a fixture with the
8463 // caret marked by `|`, run one action, and compare the rendered result —
8464 // also caret-marked — against the expected string. One readable line per
8465 // behavior, and it exercises the exact `Doc` ops both frontends call.
8466
8467 /// Split a `|`-marked fixture into `(source, caret_offset)`.
8468 fn parse_caret(marked: &str) -> (String, usize) {
8469 let caret = marked.find('|').expect("fixture needs a `|` caret marker");
8470 (marked.replacen('|', "", 1), caret)
8471 }
8472
8473 /// Render a doc's source with `|` at the caret (and `[`…`]` around any
8474 /// selection) so a result reads like the fixtures.
8475 fn render_caret(d: &Doc) -> String {
8476 // (offset, rank, char); rank keeps coincident markers ordered `[ | ]`
8477 // so the caret always renders inside its own selection.
8478 let mut marks: Vec<(usize, u8, char)> = vec![(d.caret, 1, '|')];
8479 if let Some((s, e)) = d.selection() {
8480 marks.push((s, 0, '['));
8481 marks.push((e, 2, ']'));
8482 }
8483 // Insert right-to-left: descending offset, then descending rank.
8484 marks.sort_by(|a, b| b.0.cmp(&a.0).then(b.1.cmp(&a.1)));
8485 let mut out = d.source.clone();
8486 for (at, _, ch) in marks {
8487 out.insert(at, ch);
8488 }
8489 out
8490 }
8491
8492 /// Load a `|`-marked fixture, run `action`, return the caret-marked result.
8493 fn golden(name: &str, marked: &str, action: impl FnOnce(&mut Doc)) -> String {
8494 golden_in(View::Source, name, marked, action)
8495 }
8496
8497 /// [`golden`] in a chosen view — the editing ops are the view's to share, so
8498 /// the same fixture has to read the same way in both.
8499 fn golden_in(view: View, name: &str, marked: &str, action: impl FnOnce(&mut Doc)) -> String {
8500 let (src, caret) = parse_caret(marked);
8501 let mut d = doc_in(view, name, &src);
8502 d.caret = caret;
8503 action(&mut d);
8504 render_caret(&d)
8505 }
8506
8507 #[test]
8508 fn word_motion_walks_word_by_word() {
8509 let g = |m, f: fn(&mut Doc)| golden("word_motion", m, f);
8510 assert_eq!(
8511 g("hello wor|ld", |d| d.move_word_left(false)),
8512 "hello |world"
8513 );
8514 assert_eq!(
8515 g("hello| world", |d| d.move_word_left(false)),
8516 "|hello world"
8517 );
8518 assert_eq!(
8519 g("hel|lo world", |d| d.move_word_right(false)),
8520 "hello| world"
8521 );
8522 assert_eq!(
8523 g("hello| world", |d| d.move_word_right(false)),
8524 "hello world|"
8525 );
8526 // Punctuation is its own class, so motion stops at the boundary.
8527 assert_eq!(g("|foo.bar", |d| d.move_word_right(false)), "foo|.bar");
8528 }
8529
8530 #[test]
8531 fn word_motion_extends_the_selection_when_asked() {
8532 assert_eq!(
8533 golden("word_sel", "hello |world", |d| d.move_word_right(true)),
8534 "hello [world|]"
8535 );
8536 }
8537
8538 #[test]
8539 fn delete_word_removes_a_whole_word() {
8540 let g = |m, f: fn(&mut Doc)| golden("del_word", m, f);
8541 assert_eq!(g("hello world|", |d| d.delete_word_back()), "hello |");
8542 assert_eq!(g("hello |world", |d| d.delete_word_forward()), "hello |");
8543 assert_eq!(g("foo |bar baz", |d| d.delete_word_back()), "|bar baz");
8544 }
8545
8546 // ── Home / End ───────────────────────────────────────────────────────────
8547
8548 #[test]
8549 fn home_toggles_between_the_line_s_text_and_its_margin() {
8550 // Source: the indentation is what the toggle is for. WYSIWYG resolves an
8551 // indent to the markup it spells everywhere it means one, so the fixture
8552 // with whitespace left to walk is a code block, which is verbatim.
8553 let g = |m, f: fn(&mut Doc)| golden("smart_home", m, f);
8554 assert_eq!(g(" inden|ted", |d| d.move_home(false)), " |indented");
8555 assert_eq!(g(" |indented", |d| d.move_home(false)), "| indented");
8556 assert_eq!(g("| indented", |d| d.move_home(false)), " |indented");
8557 // A line with no indentation has one place to go, so the toggle is a
8558 // no-op rather than a trip to nowhere.
8559 assert_eq!(g("hel|lo", |d| d.move_home(false)), "|hello");
8560 assert_eq!(g("|hello", |d| d.move_home(false)), "|hello");
8561
8562 let mut d = wysiwyg_doc("smart_home_wys", "```\n indented\n```\n");
8563 let indent = d.source.find(" indented").unwrap();
8564 d.caret = indent + 6; // inside "indented"
8565 d.move_home(false);
8566 assert_eq!(
8567 d.caret,
8568 indent + 4,
8569 "wysiwyg: Home aims at the code line's text"
8570 );
8571 d.move_home(false);
8572 assert_eq!(
8573 d.caret, indent,
8574 "wysiwyg: the second press takes the indent"
8575 );
8576 d.move_home(false);
8577 assert_eq!(d.caret, indent + 4, "wysiwyg: the toggle swaps back");
8578 }
8579
8580 #[test]
8581 fn end_takes_the_line_the_view_is_showing() {
8582 // The line differs by view for the same document, and that is the point:
8583 // a bare newline inside a paragraph is a soft break, which WYSIWYG draws
8584 // as a space on one row and the source view as two lines.
8585 let mut d = doc_with("end_src", "one two\nthree\n");
8586 d.caret = 1;
8587 d.move_end(false);
8588 assert_eq!(d.caret, 7, "source: the end of the source line");
8589
8590 let mut d = wysiwyg_doc("end_wys", "one two\nthree\n");
8591 d.caret = 1;
8592 d.move_end(false);
8593 assert_eq!(
8594 d.caret, 13,
8595 "wysiwyg: the end of the row, soft break and all"
8596 );
8597 }
8598
8599 #[test]
8600 fn home_and_end_extend_the_selection_when_asked() {
8601 for (view, tag) in VIEWS {
8602 let mut d = doc_in(view, &format!("home_end_ext_{tag}"), "hello world");
8603 d.caret = 6;
8604 d.move_end(true);
8605 assert_eq!(d.selection(), Some((6, 11)), "{tag}: End extends");
8606 let mut d = doc_in(view, &format!("home_ext_{tag}"), "hello world");
8607 d.caret = 6;
8608 d.move_home(true);
8609 assert_eq!(d.selection(), Some((0, 6)), "{tag}: Home extends");
8610 }
8611 }
8612
8613 // ── kill to the line's start / end ───────────────────────────────────────
8614
8615 #[test]
8616 fn kill_to_the_line_start_and_end_in_both_views() {
8617 for (view, tag) in VIEWS {
8618 // The gap that reads as a paragraph break in each view: the source
8619 // view's lines are the renderer's rows only where the source says so.
8620 let gap = if view == View::Source { "\n" } else { "\n\n" };
8621 let mut d = doc_in(
8622 view,
8623 &format!("kill_end_{tag}"),
8624 &format!("one two{gap}three\n"),
8625 );
8626 d.caret = 3;
8627 d.delete_to_line_end();
8628 assert_eq!(
8629 d.source,
8630 format!("one{gap}three\n"),
8631 "{tag}: ^K to the line's end"
8632 );
8633 assert_eq!(d.caret, 3, "{tag}: the caret stays where it kills from");
8634
8635 let mut d = doc_in(
8636 view,
8637 &format!("kill_start_{tag}"),
8638 &format!("one two{gap}three\n"),
8639 );
8640 d.caret = 7; // the end of the first line
8641 d.delete_to_line_start();
8642 assert_eq!(
8643 d.source,
8644 format!("{gap}three\n"),
8645 "{tag}: ⌘⌫ to the line's start"
8646 );
8647 assert_eq!(d.caret, 0, "{tag}");
8648 }
8649 }
8650
8651 #[test]
8652 fn a_kill_at_the_line_s_edge_leaves_the_lines_joined() {
8653 // The decision: at the boundary both kills do nothing, rather than
8654 // eating the line break. "Line" is the view's own — in WYSIWYG it ends
8655 // at a soft wrap as often as at a newline, where there is nothing
8656 // written to delete — and a source newline is only half of the blank
8657 // line between two paragraphs, so taking it leaves a soft break rather
8658 // than the join it looks like. Backspace and Delete are the keys for it.
8659 for (view, tag) in VIEWS {
8660 let gap = if view == View::Source { "\n" } else { "\n\n" };
8661 let src = format!("one{gap}three\n");
8662 let mut d = doc_in(view, &format!("kill_edge_end_{tag}"), &src);
8663 d.caret = 3; // the end of "one"
8664 d.delete_to_line_end();
8665 assert_eq!(
8666 d.source, src,
8667 "{tag}: ^K at the line's end joined it to the next"
8668 );
8669
8670 let mut d = doc_in(view, &format!("kill_edge_start_{tag}"), &src);
8671 d.caret = 3 + gap.len(); // the start of "three"
8672 d.delete_to_line_start();
8673 assert_eq!(
8674 d.source, src,
8675 "{tag}: ⌘⌫ at the line's start joined it to the last"
8676 );
8677 }
8678 }
8679
8680 #[test]
8681 fn a_kill_takes_the_selection_when_there_is_one() {
8682 // What every other delete here does with one, so these two as well.
8683 for (view, tag) in VIEWS {
8684 for (name, kill) in [
8685 (
8686 "end",
8687 (|d: &mut Doc| d.delete_to_line_end()) as fn(&mut Doc),
8688 ),
8689 ("start", |d: &mut Doc| d.delete_to_line_start()),
8690 ] {
8691 let mut d = doc_in(view, &format!("kill_sel_{name}_{tag}"), "one two three\n");
8692 d.anchor = Some(4);
8693 d.caret = 7; // "two"
8694 kill(&mut d);
8695 assert_eq!(
8696 d.source, "one three\n",
8697 "{tag}: {name} ignored the selection"
8698 );
8699 assert_eq!(d.selection(), None, "{tag}: {name}");
8700 }
8701 }
8702 }
8703
8704 #[test]
8705 fn a_kill_takes_the_markup_it_empties_with_it() {
8706 // The same hazard a word-delete has: a WYSIWYG range covers what the
8707 // user can see, which for `**bold**` is the word and never the
8708 // delimiters, so a kill that stopped at the text would leave `a ****` —
8709 // markup wrapped around nothing.
8710 let mut d = wysiwyg_doc("kill_widen", "a **bold**\n");
8711 d.caret = d.source.find("bold").unwrap();
8712 d.delete_to_line_end();
8713 assert_eq!(d.source, "a \n");
8714 }
8715
8716 #[test]
8717 fn a_kill_is_undone_in_one_step() {
8718 for (view, tag) in VIEWS {
8719 let mut d = doc_in(view, &format!("kill_undo_{tag}"), "one two three\n");
8720 d.caret = 3;
8721 d.delete_to_line_end();
8722 assert_eq!(d.source, "one\n", "{tag}");
8723 d.undo();
8724 assert_eq!(d.source, "one two three\n", "{tag}: a kill takes one undo");
8725 }
8726 }
8727
8728 #[test]
8729 fn select_block_grabs_the_whole_paragraph_from_any_wrapped_row() {
8730 // Regression: triple-click used move_home/move_end over visual rows, so
8731 // it only worked on a paragraph's first row (a wrap-boundary offset maps
8732 // to the earlier row). select_block_at reads the AST, so every offset in
8733 // the paragraph selects the whole thing.
8734 let body = "one two three four five six seven eight\n";
8735 let mut d = doc_with("sel_block", body);
8736 d.view = View::Wysiwyg;
8737 d.build_visual(12); // force the paragraph to wrap into several rows
8738 assert!(d.vmap.num_rows() > 1, "test needs a wrapped paragraph");
8739 let para = (0, "one two three four five six seven eight".len());
8740 for off in [0usize, 8, 19, 28, 38] {
8741 d.caret = 0;
8742 d.anchor = None;
8743 d.select_block_at(off);
8744 assert_eq!(
8745 d.selection(),
8746 Some(para),
8747 "offset {off} should select the paragraph"
8748 );
8749 }
8750 }
8751
8752 #[test]
8753 fn select_block_uses_content_span_for_a_heading() {
8754 let mut d = doc_with("sel_head", "# Title\n\nbody\n");
8755 d.select_block_at(4); // inside "Title"
8756 // content_span excludes the "# " marker.
8757 assert_eq!(d.selected_text(), Some("Title"));
8758 d.select_block_at(10); // inside "body"
8759 assert_eq!(d.selected_text(), Some("body"));
8760 }
8761
8762 #[test]
8763 fn select_all_spans_the_document() {
8764 let mut d = doc_with("sel_all", "abc\n\ndef\n");
8765 d.select_all();
8766 assert_eq!(d.selection(), Some((0, d.source.len())));
8767 }
8768
8769 #[test]
8770 fn select_word_at_picks_the_surrounding_word() {
8771 let mut d = doc_with("sel_word", "hello world\n");
8772 d.select_word_at(8); // inside "world"
8773 assert_eq!(d.selection(), Some((6, 11)));
8774 // Double-clicking at end-of-word still grabs the word to its left.
8775 d.select_word_at(5); // the space between the words
8776 assert_eq!(d.selection(), Some((5, 6)));
8777 }
8778
8779 #[test]
8780 fn word_helpers_respect_utf8_boundaries() {
8781 // "café" is 5 bytes ('é' is two); motion must land on char boundaries.
8782 assert_eq!(
8783 golden("utf8", "|café ok", |d| d.move_word_right(false)),
8784 "café| ok"
8785 );
8786 assert_eq!(golden("utf8b", "café |ok", |d| d.delete_word_back()), "|ok");
8787 }
8788
8789 #[test]
8790 fn typing_inserts_at_the_caret_and_advances_it() {
8791 let mut d = doc_with("type", "hello\n");
8792 d.insert("Hi ");
8793 assert_eq!(d.source, "Hi hello\n");
8794 assert_eq!(d.caret, 3);
8795 assert!(d.dirty);
8796 }
8797
8798 #[test]
8799 fn backspace_deletes_the_char_before_the_caret() {
8800 let mut d = doc_with("bs", "hello\n");
8801 d.caret = 3; // after "hel"
8802 d.backspace();
8803 assert_eq!(d.source, "helo\n");
8804 assert_eq!(d.caret, 2);
8805 }
8806
8807 #[test]
8808 fn typing_replaces_the_selection() {
8809 let mut d = doc_with("replace", "a word b\n");
8810 d.anchor = Some(2);
8811 d.caret = 6; // "word" selected
8812 d.insert("X");
8813 assert_eq!(d.source, "a X b\n");
8814 assert_eq!(d.caret, 3);
8815 assert_eq!(d.anchor, None);
8816 }
8817
8818 #[test]
8819 fn toggle_bold_wraps_then_unwraps_the_selection() {
8820 let mut d = doc_with("bold", "a word b\n");
8821 d.anchor = Some(2);
8822 d.caret = 6;
8823 d.toggle(InlineKind::Strong);
8824 assert_eq!(d.source, "a **word** b\n");
8825 // The toggled region stays selected, so a second toggle reverses it.
8826 d.toggle(InlineKind::Strong);
8827 assert_eq!(d.source, "a word b\n");
8828 d.toggle(InlineKind::Strong);
8829 assert_eq!(d.source, "a **word** b\n");
8830 }
8831
8832 #[test]
8833 fn toggle_code_wraps_then_unwraps_the_selection() {
8834 let mut d = doc_with("code_rt", "a word b\n");
8835 d.anchor = Some(2);
8836 d.caret = 6;
8837 d.toggle(InlineKind::Verbatim);
8838 assert_eq!(d.source, "a `word` b\n");
8839 d.toggle(InlineKind::Verbatim);
8840 assert_eq!(d.source, "a word b\n");
8841 }
8842
8843 #[test]
8844 fn sticky_bold_with_no_selection_wraps_the_next_typed_text() {
8845 // ⌘b at a bare caret, then type: the text comes out bold with no
8846 // selection ever made — the word-processor "start bold here" gesture.
8847 let mut d = doc_with("sticky_wrap", "xy\n");
8848 d.caret = 1; // between x and y
8849 d.toggle(InlineKind::Strong);
8850 assert_eq!(d.source, "xy\n", "arming a mark must not edit the document");
8851 d.insert("A");
8852 assert_eq!(d.source, "x**A**y\n");
8853 }
8854
8855 #[test]
8856 fn sticky_bold_lights_the_toolbar_before_any_typing() {
8857 // The button must light the instant ⌘b is pressed, or the mode is
8858 // invisible until the first character lands.
8859 let mut d = doc_with("sticky_light", "xy\n");
8860 d.caret = 1;
8861 assert!(!d.active_inline_marks().contains(InlineKind::Strong));
8862 d.toggle(InlineKind::Strong);
8863 assert!(d.active_inline_marks().contains(InlineKind::Strong));
8864 }
8865
8866 #[test]
8867 fn sticky_bold_toggled_off_types_normally_again() {
8868 // ⌘b, type, ⌘b, type: the first run is bold, the second is not — all
8869 // in the flow of typing, the exact sequence the user described.
8870 let mut d = doc_with("sticky_off", "\n");
8871 d.caret = 0;
8872 d.toggle(InlineKind::Strong);
8873 d.insert("a");
8874 d.insert("b"); // continues inside the run, no re-arming
8875 assert_eq!(d.source, "**ab**\n");
8876 d.toggle(InlineKind::Strong); // ⌘b again — shed bold
8877 d.insert("c");
8878 assert_eq!(d.source, "**ab**c\n");
8879 }
8880
8881 #[test]
8882 fn continued_typing_after_a_sticky_run_stays_in_the_run() {
8883 // Once a mark is realised the caret sits inside the run, so plain typing
8884 // extends it rather than starting a second, adjacent bold span.
8885 let mut d = doc_with("sticky_cont", "\n");
8886 d.caret = 0;
8887 d.toggle(InlineKind::Emph);
8888 d.insert("h");
8889 d.insert("i");
8890 assert_eq!(d.source, "*hi*\n");
8891 }
8892
8893 #[test]
8894 fn moving_the_caret_disarms_a_sticky_mark() {
8895 // Arming a mark and then moving away must not style text elsewhere.
8896 let mut d = doc_with("sticky_disarm", "xy\n");
8897 d.caret = 0;
8898 d.toggle(InlineKind::Strong);
8899 d.move_right(false); // caret 0 → 1, disarms
8900 assert!(!d.active_inline_marks().contains(InlineKind::Strong));
8901 d.insert("A");
8902 assert_eq!(d.source, "xAy\n", "the mark must not follow the caret");
8903 }
8904
8905 #[test]
8906 fn stacked_sticky_marks_apply_together() {
8907 // ⌘b then ⌘i before typing: the text comes out both bold and italic.
8908 let mut d = doc_with("sticky_stack", "\n");
8909 d.caret = 0;
8910 d.toggle(InlineKind::Strong);
8911 d.toggle(InlineKind::Emph);
8912 d.insert("x");
8913 // Land the caret on the styled character and confirm both marks are live.
8914 d.anchor = Some(d.source.find('x').unwrap());
8915 d.caret = d.anchor.unwrap() + 1;
8916 let marks = d.active_inline_marks();
8917 assert!(marks.contains(InlineKind::Strong), "bold: {}", d.source);
8918 assert!(marks.contains(InlineKind::Emph), "italic: {}", d.source);
8919 }
8920
8921 // ── the mark-edge rule (see `Doc::splice`) ───────────────────────────────
8922
8923 #[test]
8924 fn a_space_typed_in_a_bold_run_never_leaves_the_delimiters_showing() {
8925 // The reported bug, keystroke for keystroke: ⌘b, "bold", space, "hey".
8926 // The space inside the run made `**bold **`, which is *not* bold — four
8927 // literal asterisks — so the rich view drew them, correctly and
8928 // uselessly, until the next character happened to close the run again.
8929 let mut d = wysiwyg_doc("edge_typing", "a \n");
8930 d.caret = 2;
8931 d.toggle(InlineKind::Strong);
8932 for c in "bold".chars() {
8933 d.insert(&c.to_string());
8934 }
8935 assert_eq!(d.source, "a **bold**\n");
8936 d.insert(" ");
8937 assert_eq!(
8938 d.source, "a **bold** \n",
8939 "the space belongs outside the run"
8940 );
8941 assert!(
8942 d.active_inline_marks().contains(InlineKind::Strong),
8943 "bold is still what's being typed, so the button stays lit"
8944 );
8945 // What the writer is looking at while all this happens: their words.
8946 d.build_visual(80);
8947 let drawn: String = d.vmap.rows[0].glyphs.iter().map(|g| g.ch).collect();
8948 assert_eq!(drawn, "a bold ", "no delimiter ever surfaces: {}", d.source);
8949 for c in "hey".chars() {
8950 d.insert(&c.to_string());
8951 }
8952 assert_eq!(
8953 d.source, "a **bold hey**\n",
8954 "one bold phrase, not two runs"
8955 );
8956 }
8957
8958 #[test]
8959 fn typing_past_a_space_can_still_leave_the_bold_behind() {
8960 // The other half: the marks stay armed across the space, so ⌘b turns
8961 // them off again there and the next word is plain — the run isn't
8962 // rejoined by a caret that was told not to.
8963 let mut d = wysiwyg_doc("edge_shed", "\n");
8964 d.caret = 0;
8965 d.toggle(InlineKind::Strong);
8966 for c in "bold ".chars() {
8967 d.insert(&c.to_string());
8968 }
8969 assert_eq!(d.source, "**bold** \n");
8970 d.toggle(InlineKind::Strong);
8971 assert!(!d.active_inline_marks().contains(InlineKind::Strong));
8972 d.insert("x");
8973 assert_eq!(d.source, "**bold** x\n");
8974 }
8975
8976 #[test]
8977 fn a_space_typed_first_of_all_still_leaves_the_mark_armed() {
8978 // ⌘b and then a space before any word: the space is not marked (nothing
8979 // is), and the word after it is.
8980 let mut d = wysiwyg_doc("edge_space_first", "a\n");
8981 d.caret = 1;
8982 d.toggle(InlineKind::Strong);
8983 d.insert(" ");
8984 assert_eq!(d.source, "a \n");
8985 assert!(d.active_inline_marks().contains(InlineKind::Strong));
8986 d.insert("b");
8987 assert_eq!(d.source, "a **b**\n");
8988 }
8989
8990 #[test]
8991 fn a_space_typed_at_either_edge_of_an_existing_mark_steps_outside_it() {
8992 let mut d = wysiwyg_doc("edge_tail", "x **bold**\n");
8993 d.caret = 8; // the caret's home at the end of the run's text
8994 d.insert(" ");
8995 assert_eq!(
8996 d.source, "x **bold** \n",
8997 "the space lands past the delimiters"
8998 );
8999 assert_eq!(d.caret, 11, "and the caret stands past it, outside the run");
9000
9001 let mut d = wysiwyg_doc("edge_head", "x **bold** y\n");
9002 d.caret = 4; // in front of the "b"
9003 d.insert(" ");
9004 assert_eq!(d.source, "x **bold** y\n");
9005 assert_eq!(d.caret, 3, "in front of the run, where the space was typed");
9006 }
9007
9008 #[test]
9009 fn a_delete_that_backs_a_space_onto_a_delimiter_moves_the_delimiter() {
9010 // Backspace over the last letter of a bold phrase.
9011 let mut d = wysiwyg_doc("edge_bksp", "a **bold h**\n");
9012 d.caret = 10; // past the "h"
9013 d.backspace();
9014 assert_eq!(d.source, "a **bold** \n");
9015 assert_eq!(d.caret, 11, "the caret keeps the place on screen it had");
9016 assert!(d.active_inline_marks().contains(InlineKind::Strong));
9017 d.insert("x");
9018 assert_eq!(d.source, "a **bold x**\n", "and typing rejoins the run");
9019 }
9020
9021 #[test]
9022 fn deleting_the_last_of_a_run_takes_its_delimiters_with_it() {
9023 // `**b**` with the `b` gone is `****`: two delimiters with nothing to
9024 // mark, which is only text. The marks live on in the caret instead.
9025 let mut d = wysiwyg_doc("edge_empty", "a **b** c\n");
9026 d.caret = 5;
9027 d.backspace();
9028 assert_eq!(d.source, "a c\n");
9029 assert!(d.active_inline_marks().contains(InlineKind::Strong));
9030 d.insert("x");
9031 assert_eq!(d.source, "a **x** c\n");
9032 }
9033
9034 #[test]
9035 fn typing_over_a_whole_bold_word_keeps_it_bold() {
9036 let mut d = wysiwyg_doc("edge_replace", "a **bold** c\n");
9037 d.anchor = Some(4);
9038 d.caret = 8; // the word, not its delimiters
9039 d.insert("x");
9040 assert_eq!(d.source, "a **x** c\n");
9041 }
9042
9043 #[test]
9044 fn a_code_span_keeps_the_space_it_is_given() {
9045 // Backticks are not whitespace-sensitive the way `**` is: `` `code ` ``
9046 // is still verbatim, so nothing is re-spelt. The repair asks the parser
9047 // rather than a table of kinds, and this is the answer it gets.
9048 let mut d = wysiwyg_doc("edge_code", "a `code` c\n");
9049 d.caret = 7;
9050 d.insert(" ");
9051 assert_eq!(d.source, "a `code ` c\n");
9052 }
9053
9054 #[test]
9055 fn a_delete_from_a_runs_outer_edge_reaches_into_the_run() {
9056 // A run's closing delimiter has a caret home on each side of it, one
9057 // column apart on screen — and a plain ← off the space after a bold word
9058 // lands on the outer one. The character drawn behind the caret there is
9059 // still the last letter of the phrase, so that is what Backspace takes;
9060 // the byte behind it is a `*` nobody can see.
9061 let mut d = wysiwyg_doc("edge_outer_close", "**bold** x\n");
9062 d.caret = 9;
9063 d.move_left(false);
9064 assert_eq!(d.caret, 8, "← rests past the delimiters, not inside them");
9065 d.backspace();
9066 assert_eq!(
9067 d.source, "**bol** x\n",
9068 "a letter of the phrase, not its `*`"
9069 );
9070 assert_eq!(d.caret, 5);
9071
9072 // And the mirror in front of the opening delimiter, where Delete's
9073 // character is the first letter of the run.
9074 let mut d = wysiwyg_doc("edge_outer_open", "x**bold**\n");
9075 d.caret = 1;
9076 d.delete_forward();
9077 assert_eq!(d.source, "x**old**\n");
9078 assert_eq!(d.caret, 3, "inside the run, in front of what is left of it");
9079 }
9080
9081 #[test]
9082 fn a_delete_at_a_run_edge_never_eats_a_delimiter() {
9083 // The byte beside the caret at either edge of a bold word is a `*` the
9084 // rich view draws nothing for. Taking it is not the character delete the
9085 // key was pressed for — it unspells the run and puts a literal asterisk
9086 // on screen (`a *bold** c`). The visible character is the one that goes.
9087 let mut d = wysiwyg_doc("edge_open_bksp", "a **bold** c\n");
9088 d.caret = 4; // in front of the "b"
9089 d.backspace();
9090 assert_eq!(d.source, "a**bold** c\n", "the space goes, the run stands");
9091
9092 let mut d = wysiwyg_doc("edge_close_del", "a **bold** c\n");
9093 d.caret = 8; // past the "d"
9094 d.delete_forward();
9095 assert_eq!(d.source, "a **bold**c\n");
9096 assert_eq!(d.caret, 8, "and the caret stays inside the run");
9097 d.insert("x");
9098 assert_eq!(d.source, "a **boldx**c\n");
9099
9100 // A code span's backticks are hidden the same way, so they are covered
9101 // by the same rule and not by a list of kinds.
9102 let mut d = wysiwyg_doc("edge_open_code", "a `code` c\n");
9103 d.caret = 3;
9104 d.backspace();
9105 assert_eq!(d.source, "a`code` c\n");
9106 }
9107
9108 #[test]
9109 fn the_source_view_deletes_the_delimiter_byte_it_is_shown() {
9110 // The asterisks are on the screen there and the caret can stand between
9111 // them, so a delete takes exactly the byte it is aimed at.
9112 let mut d = doc_with("edge_open_src", "a **bold** c\n");
9113 d.caret = 4;
9114 d.backspace();
9115 assert_eq!(d.source, "a *bold** c\n");
9116
9117 let mut d = doc_with("edge_close_src", "a **bold** c\n");
9118 d.caret = 8;
9119 d.delete_forward();
9120 assert_eq!(d.source, "a **bold* c\n");
9121 }
9122
9123 #[test]
9124 fn backspacing_the_space_out_of_a_bold_phrase_leaves_the_caret_in_it() {
9125 // The reported bug, keystroke for keystroke: ⌘b, "bold", space, Backspace.
9126 // The space had stepped outside the run (the mark-edge rule), taking the
9127 // caret with it, so the delete put it back down on the far side of the
9128 // closing `**` — one place on screen, and the wrong side of it. Typing
9129 // came out plain and the toolbar went dark, with nothing to see.
9130 let mut d = wysiwyg_doc("edge_bksp_space", "\n");
9131 d.caret = 0;
9132 d.toggle(InlineKind::Strong);
9133 for c in "bold".chars() {
9134 d.insert(&c.to_string());
9135 }
9136 d.insert(" ");
9137 assert_eq!(d.source, "**bold** \n");
9138 d.backspace();
9139 assert_eq!(
9140 d.source, "**bold**\n",
9141 "the space goes, the delimiters stay"
9142 );
9143 assert_eq!(d.caret, 6, "and the caret comes back inside the run");
9144 assert!(
9145 d.active_inline_marks().contains(InlineKind::Strong),
9146 "so the button is still lit"
9147 );
9148 d.insert("x");
9149 assert_eq!(
9150 d.source, "**boldx**\n",
9151 "and the next character is still bold"
9152 );
9153 }
9154
9155 #[test]
9156 fn a_second_backspace_there_deletes_a_letter_of_the_phrase() {
9157 // What the stranded caret did next: the byte behind it was the closing
9158 // `*`, so a second press took that instead of a letter — `**bold*`, the
9159 // styling gone and an asterisk on the screen where the word had been.
9160 let mut d = wysiwyg_doc("edge_bksp_twice", "\n");
9161 d.caret = 0;
9162 d.toggle(InlineKind::Strong);
9163 for c in "bold ".chars() {
9164 d.insert(&c.to_string());
9165 }
9166 assert_eq!(d.source, "**bold** \n");
9167 d.backspace();
9168 d.backspace();
9169 assert_eq!(d.source, "**bol**\n", "the delete lands inside the run");
9170 assert_eq!(d.caret, 5);
9171 }
9172
9173 #[test]
9174 fn a_delete_that_ends_at_a_nested_run_settles_inside_every_delimiter() {
9175 // `***both***` closes two runs with one stack of asterisks: the caret has
9176 // to walk in through all of them, or it lands between the emph and the
9177 // strong and types half-marked.
9178 let mut d = wysiwyg_doc("edge_bksp_nested", "***both*** \n");
9179 d.caret = 11;
9180 d.backspace();
9181 assert_eq!(d.source, "***both***\n");
9182 assert_eq!(d.caret, 7, "past the last letter, inside both runs");
9183 d.insert("x");
9184 assert_eq!(d.source, "***bothx***\n");
9185 }
9186
9187 #[test]
9188 fn a_delete_that_ends_mid_run_leaves_the_caret_where_it_fell() {
9189 // The settle only moves a caret a run actually closed over. Ordinary
9190 // deletes — inside a run, or in plain prose — are untouched.
9191 let mut d = wysiwyg_doc("edge_bksp_mid", "a **bold** c\n");
9192 d.caret = 8;
9193 d.backspace();
9194 assert_eq!(d.source, "a **bol** c\n");
9195 assert_eq!(d.caret, 7);
9196
9197 let mut d = wysiwyg_doc("edge_bksp_plain", "plain\n");
9198 d.caret = 5;
9199 d.backspace();
9200 assert_eq!(d.source, "plai\n");
9201 assert_eq!(d.caret, 4);
9202 }
9203
9204 #[test]
9205 fn the_source_view_leaves_a_delete_where_it_landed() {
9206 // The delimiters are on the screen there, so the offset past them is a
9207 // place the caret can be seen to be — nothing to settle.
9208 let mut d = doc_with("edge_bksp_src", "**bold** \n");
9209 d.caret = 9;
9210 d.backspace();
9211 assert_eq!(d.source, "**bold**\n");
9212 assert_eq!(d.caret, 8);
9213 }
9214
9215 #[test]
9216 fn the_mark_edge_rule_clears_every_delimiter_of_a_nested_run() {
9217 // `***both***` closes two runs with one stack of asterisks; a space that
9218 // clears only the inner one lands against the outer's and breaks that
9219 // instead.
9220 let mut d = wysiwyg_doc("edge_nested", "a ***both***\n");
9221 d.caret = 9;
9222 d.insert(" ");
9223 assert_eq!(d.source, "a ***both*** \n");
9224 assert_eq!(d.caret, 13);
9225 d.insert("x");
9226 assert_eq!(d.source, "a ***both x***\n");
9227 }
9228
9229 #[test]
9230 fn the_mark_edge_repair_undoes_with_the_keystroke_that_caused_it() {
9231 // The delimiter shuffle is not an edit the writer made, so it is not a
9232 // step they have to undo past.
9233 let mut d = wysiwyg_doc("edge_undo", "a **bold**\n");
9234 d.caret = 8;
9235 d.insert(" ");
9236 assert_eq!(d.source, "a **bold** \n");
9237 d.undo();
9238 assert_eq!(d.source, "a **bold**\n");
9239 }
9240
9241 #[test]
9242 fn the_source_view_types_the_space_where_it_was_asked_to() {
9243 // The rule is a rich-view courtesy. In the source view the delimiters are
9244 // on the screen and the user is editing the bytes they can see.
9245 let mut d = doc_with("edge_src", "a **bold** c\n");
9246 d.caret = 8;
9247 d.insert(" ");
9248 assert_eq!(d.source, "a **bold ** c\n");
9249 }
9250
9251 #[test]
9252 fn toggling_a_mark_over_a_selection_leaves_its_edge_whitespace_out() {
9253 // Double-clicking a word takes the space after it; bolding that must not
9254 // spell `**word **`, which is not bold at all.
9255 let mut d = wysiwyg_doc("edge_sel", "a word b\n");
9256 d.anchor = Some(2);
9257 d.caret = 7; // "word "
9258 d.toggle(InlineKind::Strong);
9259 assert_eq!(d.source, "a **word** b\n");
9260 d.toggle(InlineKind::Strong);
9261 assert_eq!(d.source, "a word b\n");
9262 d.toggle(InlineKind::Strong);
9263 assert_eq!(
9264 d.source, "a **word** b\n",
9265 "reapplying the mark must not wrap stale delimiter offsets"
9266 );
9267 // And a selection of nothing but whitespace has no word to mark.
9268 let mut d = wysiwyg_doc("edge_sel_ws", "a word b\n");
9269 d.anchor = Some(6);
9270 d.caret = 7;
9271 d.toggle(InlineKind::Strong);
9272 assert_eq!(d.source, "a word b\n");
9273 assert!(d.status.is_some());
9274 }
9275
9276 #[test]
9277 fn set_block_turns_a_paragraph_into_a_heading_at_the_caret() {
9278 let mut d = doc_with("head_set", "hello\n");
9279 d.caret = 2; // caret inside the paragraph, no selection
9280 d.set_block(BlockKind::Heading(1));
9281 assert_eq!(d.source, "# hello\n");
9282 }
9283
9284 #[test]
9285 fn set_block_heading_works_in_wysiwyg_view() {
9286 // The app defaults to WYSIWYG; the caret is a source offset either way.
9287 let mut d = wysiwyg_doc("head_wys", "hello\n");
9288 d.caret = 2;
9289 d.set_block(BlockKind::Heading(1));
9290 assert_eq!(d.source, "# hello\n");
9291 }
9292
9293 #[test]
9294 fn toggle_heading_applies_switches_and_reverts() {
9295 let mut d = doc_with("head_toggle", "hello\n");
9296 d.caret = 2;
9297 d.toggle_heading(1);
9298 assert_eq!(d.source, "# hello\n"); // paragraph → H1
9299 d.toggle_heading(2);
9300 assert_eq!(d.source, "## hello\n"); // H1 → H2 (different level switches)
9301 d.toggle_heading(2);
9302 assert_eq!(d.source, "hello\n"); // same level reverts to paragraph
9303 }
9304
9305 #[test]
9306 fn preserve_enter_at_a_line_end_lands_the_caret_on_the_new_blank_line() {
9307 // Regression: Enter at the end of a soft-break line (mid-paragraph) opened
9308 // the blank line but the caret rendered on the *next* line, because the
9309 // separator was a non-navigable decoration row. In Preserve flow that
9310 // blank line is a real caret home — the caret must resolve onto it, and
9311 // typing there makes the soft break that continues the paragraph.
9312 let src = "line one:\nsecond line\n";
9313 let mut d = wysiwyg_doc("pre_enter_lineend", src);
9314 d.set_line_flow(LineFlow::Preserve);
9315 d.build_visual_unwrapped(); // the GUI path (pixel-wrapped)
9316 d.caret = 9; // the visual end of row 0, at the soft-break '\n'
9317 d.newline();
9318 d.build_visual_unwrapped();
9319 assert_eq!(d.source, "line one:\n\nsecond line\n");
9320 assert_eq!(
9321 d.caret, 10,
9322 "caret sits on the new blank line, not the next line"
9323 );
9324 // The blank line is row 1, and the caret resolves onto it — not row 2.
9325 assert_eq!(
9326 d.vmap.pos_of_offset(10),
9327 (1, 0),
9328 "caret renders on the blank row"
9329 );
9330 assert!(
9331 !d.vmap.rows[1].decoration,
9332 "the blank line is navigable in Preserve"
9333 );
9334 // Typing there makes a soft break: one paragraph, three lines.
9335 d.insert("new clause,");
9336 assert_eq!(d.source, "line one:\nnew clause,\nsecond line\n");
9337 }
9338
9339 #[test]
9340 fn preserve_enter_makes_a_soft_break_not_a_paragraph() {
9341 // Mid-paragraph: Enter splits the line with a single `\n`, a soft break
9342 // that keeps it one paragraph — where Fold would open a second paragraph.
9343 let mut d = wysiwyg_doc("pre_enter_mid", "abcdef\n");
9344 d.set_line_flow(LineFlow::Preserve);
9345 d.caret = 3;
9346 d.newline();
9347 assert_eq!(d.source, "abc\ndef\n", "mid-line Enter is a soft break");
9348
9349 // End-of-paragraph: Enter then typing continues the same paragraph on a
9350 // new line (a soft break), not a fresh paragraph.
9351 let mut d = wysiwyg_doc("pre_enter_end", "abc\n");
9352 d.set_line_flow(LineFlow::Preserve);
9353 d.caret = 3;
9354 d.newline();
9355 d.insert("def");
9356 assert_eq!(
9357 d.source, "abc\ndef\n",
9358 "end-of-line Enter + typing is a soft break"
9359 );
9360 }
9361
9362 #[test]
9363 fn preserve_double_enter_still_makes_a_paragraph() {
9364 // Two Enters in a row promote to a real paragraph break: the second lands
9365 // on the blank line the first opened and takes the empty-line branch.
9366 let mut d = wysiwyg_doc("pre_enter_dbl", "abc\n");
9367 d.set_line_flow(LineFlow::Preserve);
9368 d.caret = 3;
9369 d.newline();
9370 d.newline();
9371 d.insert("def");
9372 assert_eq!(
9373 d.source, "abc\n\ndef\n",
9374 "double Enter is a paragraph break"
9375 );
9376 }
9377
9378 #[test]
9379 fn preserve_backspace_joins_across_a_soft_break() {
9380 // Backspace is the symmetric undo of a Preserve Enter: over the `\n` of a
9381 // soft break it deletes the single newline and joins the two lines.
9382 let mut d = wysiwyg_doc("pre_bs", "abc\ndef\n");
9383 d.set_line_flow(LineFlow::Preserve);
9384 d.build_visual(80);
9385 d.caret = 4; // start of "def", just past the soft break
9386 d.backspace();
9387 assert_eq!(
9388 d.source, "abcdef\n",
9389 "Backspace joins across the soft break"
9390 );
9391 assert_eq!(d.caret, 3, "caret lands where the lines meet");
9392 }
9393
9394 #[test]
9395 fn fold_enter_still_starts_a_new_paragraph() {
9396 // The default flow is unchanged: a lone `\n` would render as an invisible
9397 // space, so Enter keeps opening the paragraph break that actually shows.
9398 let mut d = wysiwyg_doc("fold_enter", "abcdef\n");
9399 d.caret = 3;
9400 d.newline();
9401 assert_eq!(
9402 d.source, "abc\n\ndef\n",
9403 "Fold mid-line Enter is a paragraph break"
9404 );
9405 }
9406
9407 #[test]
9408 fn wysiwyg_one_enter_starts_a_new_paragraph() {
9409 // Regression: one Enter left the caret between the two newlines, so typing
9410 // made a soft break (one paragraph) and you needed a second Enter.
9411 let mut d = wysiwyg_doc("wys_enter", "abc\n");
9412 d.caret = 3;
9413 d.newline();
9414 d.insert("def");
9415 assert_eq!(d.source, "abc\n\ndef\n"); // two paragraphs, not "abc\ndef\n"
9416 }
9417
9418 #[test]
9419 fn enter_at_the_end_of_a_bold_run_keeps_its_closing_delimiter_attached() {
9420 // Regression: Enter at the caret's natural End-of-line resting place
9421 // after a bold run with nothing following it (on screen: right after
9422 // "bold", before the hidden closing "**") spliced the paragraph break
9423 // at that very byte offset — which sits *before* the closing "**" in
9424 // the source, since the delimiter is hidden and emits no glyph of its
9425 // own for `push_row`'s "end of row" fallback to count. That severed the
9426 // mark: "**bold**\n" became "**bold\n\n**\n", stranding the closing
9427 // "**" alone on the new line instead of leaving "**bold**" intact with
9428 // a fresh empty paragraph after it.
9429 let mut d = wysiwyg_doc("bold_eol_enter", "**bold**\n");
9430 d.move_end(false); // the WYSIWYG End key, from caret 0
9431 assert_eq!(
9432 d.caret, 6,
9433 "caret rests right after \"bold\", before the hidden \"**\""
9434 );
9435 d.newline();
9436 assert!(
9437 d.source.starts_with("**bold**"),
9438 "the closing ** must stay attached to \"bold\": got {:?}",
9439 d.source
9440 );
9441 assert_eq!(
9442 d.source, "**bold**\n\n\n",
9443 "a fresh empty paragraph follows the still-intact bold run"
9444 );
9445 }
9446
9447 #[test]
9448 fn source_view_enter_is_a_single_newline() {
9449 let mut d = doc_with("src_enter", "abc\n");
9450 d.caret = 3;
9451 d.newline();
9452 assert_eq!(d.source, "abc\n\n");
9453 }
9454
9455 #[test]
9456 fn heading_applies_at_the_end_of_a_paragraph() {
9457 // The caret at a line end sits at the doc level; set_block must still find
9458 // the block on that line.
9459 let mut d = doc_with("head_end", "abc\n");
9460 d.caret = 3; // end of "abc"
9461 d.toggle_heading(1);
9462 assert_eq!(d.source, "# abc\n");
9463 }
9464
9465 #[test]
9466 fn heading_on_an_empty_new_paragraph_creates_one() {
9467 let mut d = wysiwyg_doc("head_empty", "abc\n");
9468 d.caret = 3;
9469 d.newline(); // caret now on a fresh, empty paragraph
9470 d.toggle_heading(1);
9471 d.insert("Title");
9472 assert!(d.source.contains("# Title"), "got {:?}", d.source);
9473 }
9474
9475 #[test]
9476 fn a_heading_typed_on_a_blank_line_keeps_the_caret_on_its_own_row() {
9477 // The reported bug, end to end: click a blank line with another one under
9478 // it, press H1, type. The text landed in the heading and the caret's
9479 // offset was right (the source view drew it there), but the rich view
9480 // drew it two rows lower, on the trailing blank line — the empty `# `
9481 // heading had left every row below it short by the marker's two bytes,
9482 // and the blank line ended up claiming the heading's own end offset.
9483 let mut d = wysiwyg_doc("head_blank", "one\n\ntwo\n\n\n\n");
9484 d.build_visual_unwrapped();
9485 d.caret = d.vmap.offset_of_pos(4, 0); // the first of the two blank lines
9486 d.toggle_heading(1);
9487 for c in "title".chars() {
9488 d.insert(&c.to_string());
9489 d.build_visual_unwrapped(); // as a frontend does, one frame per key
9490 }
9491 assert_eq!(d.source, "one\n\ntwo\n\n# title\n\n");
9492 assert_eq!(
9493 d.caret_pos(),
9494 (4, 5),
9495 "the caret draws at the end of the heading"
9496 );
9497 }
9498
9499 #[test]
9500 fn clicking_an_empty_heading_types_after_its_marker() {
9501 // The same anchor from the other side: the empty heading's row is its own
9502 // caret home, so a click on it must land past the hidden `# `. Landing in
9503 // front of the hashes made the first keystroke un-heading the line.
9504 let mut d = wysiwyg_doc("head_click", "# \n");
9505 d.build_visual_unwrapped();
9506 d.caret = d.vmap.offset_of_pos(0, 0);
9507 d.insert("x");
9508 assert_eq!(d.source, "# x\n");
9509 }
9510
9511 #[test]
9512 fn wysiwyg_enter_after_a_heading_makes_a_paragraph() {
9513 let mut d = wysiwyg_doc("head_enter", "# Title\n");
9514 d.caret = 7; // end of the heading
9515 d.newline();
9516 d.insert("body");
9517 assert_eq!(d.source, "# Title\n\nbody\n");
9518 }
9519
9520 #[test]
9521 fn wysiwyg_enter_continues_a_bullet_list() {
9522 let mut d = wysiwyg_doc("wys_bullet", "- item\n");
9523 d.caret = 6; // end of "item"
9524 d.newline();
9525 d.insert("two");
9526 assert_eq!(d.source, "- item\n- two\n");
9527 }
9528
9529 #[test]
9530 fn wysiwyg_enter_increments_an_ordered_list() {
9531 let mut d = wysiwyg_doc("wys_ol", "1. one\n");
9532 d.caret = 6; // end of "one"
9533 d.newline();
9534 d.insert("two");
9535 assert_eq!(d.source, "1. one\n2. two\n");
9536 }
9537
9538 #[test]
9539 fn wysiwyg_backspace_after_leaving_a_list_collapses_the_gap_cleanly() {
9540 // Regression for the "extra newline" left between a list and the paragraph
9541 // below it. Enter, Enter leaves the list on a fresh empty paragraph
9542 // (`- item\n\n\n\nnext`, a navigable blank between the two blocks); one
9543 // Backspace should then take the caret cleanly back to the end of the list
9544 // item, `- item\n\nnext`, not delete a single newline and strand it on the
9545 // odd `- item\n\n\nnext` — a blank line the eye reads as one separator but
9546 // no caret can land on. The map is rebuilt between keystrokes exactly as a
9547 // frontend does, since Backspace reads the stop table to place the delete.
9548 let mut d = wysiwyg_doc("wys_exit_bksp", "- item\n\nnext\n");
9549 d.caret = 6; // end of "item"
9550 d.newline();
9551 d.build_visual(80);
9552 d.newline(); // leave the list onto a fresh empty paragraph
9553 d.build_visual(80);
9554 assert_eq!(
9555 d.source, "- item\n\n\n\nnext\n",
9556 "double-Enter opens the empty paragraph"
9557 );
9558 d.backspace();
9559 assert_eq!(
9560 d.source, "- item\n\nnext\n",
9561 "one Backspace collapses the whole gap"
9562 );
9563 assert_eq!(
9564 d.caret, 6,
9565 "and lands the caret back at the end of the list item"
9566 );
9567 }
9568
9569 #[test]
9570 fn wysiwyg_backspace_on_stacked_blank_lines_still_removes_just_one() {
9571 // The stop-wise delete must not over-reach when there is no block boundary
9572 // to cross: two blank lines in a row are one caret stop apart, so pressing
9573 // Enter on an empty line and then Backspace removes exactly the one newline
9574 // it added — the lone-Enter / lone-Backspace symmetry, preserved.
9575 let mut d = wysiwyg_doc("wys_stack", "abc\n\n\n");
9576 d.caret = 5; // the empty paragraph the first Enter already opened
9577 d.build_visual(80);
9578 d.newline();
9579 d.build_visual(80);
9580 assert_eq!(
9581 d.source, "abc\n\n\n\n",
9582 "Enter on the blank line adds one newline"
9583 );
9584 d.backspace();
9585 assert_eq!(
9586 d.source, "abc\n\n\n",
9587 "Backspace takes back exactly that one newline"
9588 );
9589 }
9590
9591 #[test]
9592 fn wysiwyg_enter_on_an_empty_list_item_exits_the_list() {
9593 let mut d = wysiwyg_doc("wys_exit", "- a\n- \n");
9594 d.caret = 6; // end of the empty "- " item
9595 d.newline();
9596 d.insert("p");
9597 assert_eq!(d.source, "- a\n\np\n");
9598 }
9599
9600 #[test]
9601 fn wysiwyg_enter_does_not_mistake_a_setext_underline_for_a_list() {
9602 // `text\n- \n` is a setext heading — the `- ` is its underline, not a
9603 // list item, though it reads as a `- ` marker byte-for-byte. Enter must
9604 // not take the list-exit path (which would splice the `- ` away as if
9605 // leaving an empty item); the AST guard sends it to a normal break and
9606 // leaves the underline intact.
9607 let mut d = wysiwyg_doc("wys_setext", "text\n- \n");
9608 assert!(
9609 d.nodes().iter().any(|n| n.kind == Kind::Heading),
9610 "precondition: twig parses this as a heading, not a list",
9611 );
9612 d.caret = 7; // on the `- ` underline line
9613 d.newline();
9614 assert!(
9615 d.source.contains("- "),
9616 "the setext underline survives, not spliced away as a list item: {:?}",
9617 d.source,
9618 );
9619 }
9620
9621 #[test]
9622 fn wysiwyg_enter_in_a_code_block_is_a_literal_newline() {
9623 let mut d = wysiwyg_doc("wys_code", "```\nabc\n```\n");
9624 d.caret = 7; // end of "abc" inside the fence
9625 d.newline();
9626 d.insert("def");
9627 assert_eq!(d.source, "```\nabc\ndef\n```\n");
9628 }
9629
9630 #[test]
9631 fn wysiwyg_enter_continues_a_block_quote() {
9632 // Enter opens a new *paragraph* inside the quote, not a second line of
9633 // the same one. `> quote\n> more` is a soft break, which under
9634 // `LineFlow::Fold` renders as a space — the keystroke would look like it
9635 // did nothing. The quoted blank line is what makes the break visible, and
9636 // it's the same thing Enter does in running prose.
9637 let mut d = wysiwyg_doc("wys_quote", "> quote\n");
9638 d.caret = 7; // end of "quote"
9639 d.newline();
9640 d.insert("more");
9641 assert_eq!(d.source, "> quote\n>\n> more\n");
9642 // Still one quote, now holding two paragraphs — not a quote and a stray
9643 // line that fell out of it.
9644 let quotes = d
9645 .nodes()
9646 .iter()
9647 .filter(|n| n.kind == Kind::BlockQuote)
9648 .count();
9649 assert_eq!(quotes, 1);
9650 }
9651
9652 #[test]
9653 fn set_block_makes_a_heading_at_the_caret() {
9654 let mut d = doc_with("head", "Title\n\nbody\n");
9655 d.caret = 0;
9656 d.set_block(BlockKind::Heading(2));
9657 assert_eq!(d.source, "## Title\n\nbody\n");
9658 d.set_block(BlockKind::Paragraph);
9659 assert_eq!(d.source, "Title\n\nbody\n");
9660 }
9661
9662 // ── block containers (quote / list) ──────────────────────────────────────
9663
9664 #[test]
9665 fn toggle_blockquote_wraps_the_block_at_the_caret_and_reverses() {
9666 let g = |m, f: fn(&mut Doc)| golden("quote", m, f);
9667 assert_eq!(g("hel|lo\n", |d| d.toggle_blockquote()), "> hel|lo\n");
9668 assert_eq!(g("> hel|lo\n", |d| d.toggle_blockquote()), "hel|lo\n");
9669 // A caret at a line end sits at the doc level; the block is still found.
9670 assert_eq!(g("hello|\n", |d| d.toggle_blockquote()), "> hello|\n");
9671 }
9672
9673 #[test]
9674 fn toggle_blockquote_keeps_the_caret_in_a_hard_wrapped_paragraph() {
9675 // Every source line of the paragraph gets its own `> `, so a caret left
9676 // on its old byte offset falls one prefix per line above it too far
9677 // back — inside the markup it just asked for rather than in its word.
9678 assert_eq!(
9679 golden("quote_wrap", "aaa\nb|bb\nccc\n", |d| d.toggle_blockquote()),
9680 "> aaa\n> b|bb\n> ccc\n"
9681 );
9682 }
9683
9684 #[test]
9685 fn toggle_blockquote_works_in_wysiwyg_view() {
9686 let g = |n, m, f: fn(&mut Doc)| golden_in(View::Wysiwyg, n, m, f);
9687 assert_eq!(
9688 g("q_wys", "hel|lo\n", |d| d.toggle_blockquote()),
9689 "> hel|lo\n"
9690 );
9691 assert_eq!(
9692 g("q_wys2", "> hel|lo\n", |d| d.toggle_blockquote()),
9693 "hel|lo\n"
9694 );
9695 }
9696
9697 #[test]
9698 fn toggle_list_makes_a_list_and_converts_between_the_kinds() {
9699 let g = |m, f: fn(&mut Doc)| golden("list", m, f);
9700 assert_eq!(g("hel|lo\n", |d| d.toggle_list(false)), "- hel|lo\n");
9701 assert_eq!(g("hel|lo\n", |d| d.toggle_list(true)), "1. hel|lo\n");
9702 // The *other* kind converts in place instead of nesting, which is what
9703 // makes the two buttons one three-state control.
9704 assert_eq!(g("- hel|lo\n", |d| d.toggle_list(true)), "1. hel|lo\n");
9705 assert_eq!(g("1. hel|lo\n", |d| d.toggle_list(false)), "- hel|lo\n");
9706 // Its own kind, over the only item the list holds, takes it off.
9707 assert_eq!(g("- hel|lo\n", |d| d.toggle_list(false)), "hel|lo\n");
9708 }
9709
9710 #[test]
9711 fn toggle_list_works_in_wysiwyg_view() {
9712 let g = |n, m, f: fn(&mut Doc)| golden_in(View::Wysiwyg, n, m, f);
9713 assert_eq!(
9714 g("l_wys", "hel|lo\n", |d| d.toggle_list(true)),
9715 "1. hel|lo\n"
9716 );
9717 assert_eq!(
9718 g("l_wys2", "1. hel|lo\n", |d| d.toggle_list(false)),
9719 "- hel|lo\n"
9720 );
9721 assert_eq!(
9722 g("l_wys3", "- hel|lo\n", |d| d.toggle_list(false)),
9723 "hel|lo\n"
9724 );
9725 }
9726
9727 #[test]
9728 fn a_list_over_a_selection_numbers_each_block_and_stays_selected() {
9729 // The selection has to grow with the markup: twig takes a container off
9730 // only a range covering every block it holds, so the second press can
9731 // reverse the first only if the result is what's selected.
9732 let mut d = doc_with("list_sel", "abc\n\ndef\n");
9733 d.select_all();
9734 d.toggle_list(true);
9735 assert_eq!(d.source, "1. abc\n\n2. def\n");
9736 assert_eq!(d.selection(), Some((0, d.source.len())));
9737 d.toggle_list(true);
9738 assert_eq!(d.source, "abc\n\ndef\n");
9739 }
9740
9741 #[test]
9742 fn toggle_blockquote_nests_a_partly_covered_quote() {
9743 // twig's rule: covering only some of a container's blocks nests, because
9744 // taking the quote off would drag its uncovered siblings out with it.
9745 let mut d = doc_with("quote_nest", "> a\n>\n> b\n");
9746 d.caret = 2; // in the first quoted paragraph only
9747 d.toggle_blockquote();
9748 assert_eq!(d.source, "> > a\n>\n> b\n");
9749 }
9750
9751 #[test]
9752 fn a_container_toggle_opens_an_empty_one_on_a_blank_line() {
9753 // A blank line used to be no block for twig to wrap —
9754 // `toggle_block_container` answered `NotFound` — so Quote and the list
9755 // buttons did nothing on the very line the H1 button works on, and leaf
9756 // lent twig a scratch paragraph to wrap and took it back out again.
9757 // twig 3.2.0 opens an empty container there itself, so what is left here
9758 // is where the caret lands: inside the marker that was just written.
9759 let mut d = doc_with("quote_blank", "\nabc\n");
9760 d.caret = 0;
9761 d.toggle_blockquote();
9762 assert_eq!(d.source, "> \nabc\n");
9763 assert_eq!(
9764 d.caret, 2,
9765 "the caret belongs inside the quote it just opened"
9766 );
9767 assert!(d.status.is_none(), "{:?}", d.status);
9768 assert!(d.dirty);
9769
9770 // And the paragraph below is still its own block: an empty container one
9771 // soft break from `abc` would take that paragraph into the quote with it.
9772 let mut d = wysiwyg_doc("quote_blank_rows", "\nabc\n");
9773 d.caret = 0;
9774 d.toggle_blockquote();
9775 d.build_visual(80);
9776 assert_eq!(drawn_rows(&d), ["│ ", "", "abc"]);
9777
9778 // The same from the other side: a blank line directly under a paragraph
9779 // earns the blank line an empty block needs, rather than being read as a
9780 // soft break inside that paragraph.
9781 let mut d = doc_with("list_blank_below", "abc\n");
9782 d.caret = 4;
9783 d.toggle_list(false);
9784 assert_eq!(d.source, "abc\n\n- ");
9785 assert_eq!(d.caret, 7);
9786 }
9787
9788 #[test]
9789 fn enter_at_the_end_of_a_quote_stays_in_the_quote() {
9790 // The gesture the rendering fix is for. `newline` inside a quote already
9791 // wrote the right source — `> a\n` becomes `> a\n>\n> \n`, twig's own
9792 // spelling — but the two marker lines it adds belonged to no node until
9793 // twig 3.2.0, so the gutter stopped at `a` and the line the writer had
9794 // just made drew as plain prose under the quote.
9795 let mut d = wysiwyg_doc("quote_enter", "> a\n");
9796 d.caret = 3; // past `a`, at the end of the quoted line
9797 d.newline();
9798 assert_eq!(d.source, "> a\n>\n> \n");
9799 d.build_visual(80);
9800 assert_eq!(drawn_rows(&d), ["│ a", "│ ", "│ "]);
9801 // And the caret is on the new line, not stranded on the old one.
9802 assert_eq!(d.caret, 8);
9803 }
9804
9805 #[test]
9806 fn opening_a_container_on_a_blank_line_is_one_undo_step() {
9807 // It was three edits — scratch, wrap, unscratch — coalesced into one, and
9808 // now it is twig's single edit. Either way one ⌘z has to put the blank
9809 // line back rather than undoing into a half-built document.
9810 for open in [
9811 &(|d: &mut Doc| d.toggle_blockquote()) as &dyn Fn(&mut Doc),
9812 &|d: &mut Doc| d.toggle_list(false),
9813 &|d: &mut Doc| d.toggle_list(true),
9814 ] {
9815 let mut d = doc_with("container_blank_undo", "a\n\n\n\nb\n");
9816 d.caret = 3;
9817 open(&mut d);
9818 assert_ne!(d.source, "a\n\n\n\nb\n");
9819 d.undo();
9820 assert_eq!(d.source, "a\n\n\n\nb\n");
9821 }
9822 }
9823
9824 #[test]
9825 fn a_container_toggle_is_one_undo_step() {
9826 let mut d = doc_with("quote_undo", "hello\n");
9827 d.caret = 3;
9828 d.insert("X"); // a typing run the structural edit must not fold into
9829 d.toggle_blockquote();
9830 assert_eq!(d.source, "> helXlo\n");
9831 d.undo();
9832 assert_eq!(d.source, "helXlo\n");
9833 }
9834
9835 // ── links ────────────────────────────────────────────────────────────────
9836
9837 #[test]
9838 fn insert_link_wraps_the_selection_and_leaves_its_text_selected() {
9839 let mut d = doc_with("link_sel", "word here\n");
9840 d.anchor = Some(0);
9841 d.caret = 4;
9842 d.insert_link("http://x.dev");
9843 assert_eq!(d.source, "[word](http://x.dev) here\n");
9844 // The text, not the destination — so a second press re-points the link
9845 // the first one made rather than nesting one inside it.
9846 assert_eq!(d.selected_text(), Some("word"));
9847 d.insert_link("http://y.dev");
9848 assert_eq!(d.source, "[word](http://y.dev) here\n");
9849 assert_eq!(d.selected_text(), Some("word"));
9850 }
9851
9852 #[test]
9853 fn insert_image_at_the_caret_spells_the_markup_and_lands_past_it() {
9854 let mut d = doc_with("img_caret", "before after\n");
9855 d.caret = 7; // between "before " and "after"
9856 d.insert_image("cat.png", "a cat");
9857 assert_eq!(d.source, "before after\n");
9858 // The caret sits just past the inserted image, nothing selected.
9859 assert_eq!(d.selection(), None);
9860 assert_eq!(d.caret, 7 + "".len());
9861 }
9862
9863 /// The bug a real vault hit: a filename with spaces in it. Markdown ends a
9864 /// destination at the first space, so the `format!` this used to be wrote
9865 /// something that was not an image at all — and the reader saw the markup as
9866 /// text. twig owns the spelling now, and moves it into the angle form.
9867 #[test]
9868 fn insert_image_spells_a_destination_with_spaces_so_it_stays_an_image() {
9869 let mut d = doc_with("img_space", "x\n");
9870 d.caret = 0;
9871 d.insert_image("Jesus Commands the Apostles to Rest.jpg", "");
9872 assert_eq!(
9873 d.source,
9874 "x\n"
9875 );
9876 // And it reads back as an image pointing at the unescaped path — the angle
9877 // brackets are spelling, not part of the destination.
9878 d.caret = 2;
9879 assert_eq!(
9880 d.image_destination_at_caret(),
9881 Some("Jesus Commands the Apostles to Rest.jpg".to_string())
9882 );
9883 }
9884
9885 /// A `)` in a caption or a filename must not close the image early.
9886 #[test]
9887 fn insert_image_escapes_a_paren_in_either_half() {
9888 let mut d = doc_with("img_paren", "x\n");
9889 d.caret = 0;
9890 d.insert_image("a)b.png", "");
9891 assert_eq!(d.source, "b.png)x\n");
9892 d.caret = 2;
9893 assert_eq!(d.image_destination_at_caret(), Some("a)b.png".to_string()));
9894 }
9895
9896 #[test]
9897 fn insert_image_uses_the_selection_as_alt_text() {
9898 let mut d = doc_with("img_sel", "caption here\n");
9899 d.anchor = Some(0);
9900 d.caret = 7; // "caption"
9901 d.insert_image("p.png", "ignored fallback");
9902 assert_eq!(d.source, " here\n");
9903 }
9904
9905 #[test]
9906 fn insert_image_with_no_alt_leaves_empty_brackets() {
9907 let mut d = doc_with("img_noalt", "\n");
9908 d.caret = 0;
9909 d.insert_image("logo.svg", "");
9910 assert_eq!(d.source, "\n");
9911 }
9912
9913 #[test]
9914 fn insert_media_spells_a_video_as_html_and_reads_it_back_as_a_block() {
9915 // The round trip is the point: it's no use writing markup the reader
9916 // can't pick up again. This is the pair that only holds from twig 2.5.1
9917 // on — before it, the one-line form went in fine and came back as a
9918 // paragraph of raw tags, publishing no media at all.
9919 let mut d = doc_with("vid_rt", "\n");
9920 d.caret = 0;
9921 d.insert_media(MediaKind::Video, "clip.mp4", "a clip");
9922 assert_eq!(
9923 d.source,
9924 "<video src=\"clip.mp4\" controls>a clip</video>\n"
9925 );
9926
9927 d.build_visual(80);
9928 assert_eq!(d.vmap.media.len(), 1, "reads back as one block media");
9929 assert_eq!(d.vmap.media[0].kind, MediaKind::Video);
9930 assert_eq!(d.vmap.media[0].destination, "clip.mp4");
9931 assert_eq!(d.vmap.media[0].alt, "a clip");
9932 }
9933
9934 #[test]
9935 fn insert_media_spells_audio_with_its_own_tag() {
9936 let mut d = doc_with("aud_rt", "\n");
9937 d.caret = 0;
9938 d.insert_media(MediaKind::Audio, "take.mp3", "");
9939 assert_eq!(d.source, "<audio src=\"take.mp3\" controls></audio>\n");
9940 d.build_visual(80);
9941 assert_eq!(d.vmap.media[0].kind, MediaKind::Audio);
9942 }
9943
9944 #[test]
9945 fn insert_media_uses_the_selection_as_fallback_text() {
9946 // The same courtesy `insert_image` does with alt: select a caption,
9947 // insert, and the caption labels the thing rather than being replaced.
9948 let mut d = doc_with("vid_sel", "the talk here\n");
9949 d.anchor = Some(0);
9950 d.caret = 8; // "the talk"
9951 d.insert_media(MediaKind::Video, "talk.mp4", "ignored fallback");
9952 assert_eq!(
9953 d.source,
9954 "<video src=\"talk.mp4\" controls>the talk</video> here\n"
9955 );
9956 }
9957
9958 #[test]
9959 fn insert_media_with_an_image_kind_is_just_insert_image() {
9960 let mut d = doc_with("img_via_media", "\n");
9961 d.caret = 0;
9962 d.insert_media(MediaKind::Image, "logo.svg", "x");
9963 assert_eq!(d.source, "\n");
9964 }
9965
9966 // ── thematic breaks ─────────────────────────────────────────────────────
9967
9968 /// The node the source parses as at `caret` — what confirms an inserted
9969 /// `---` actually reads back as a rule, not stray text or a setext heading.
9970 ///
9971 /// The *narrowest* node covering the offset. Every ancestor covers it too,
9972 /// and since twig 2.8 that includes the `doc` root, which now carries a real
9973 /// span (it reported none before, so taking the first match used to land on
9974 /// the block by luck and now always answers `"doc"`).
9975 fn kind_at(d: &mut Doc, caret: usize) -> Option<Kind> {
9976 d.nodes()
9977 .into_iter()
9978 .filter(|n| n.span.start <= caret && caret < n.span.end)
9979 .min_by_key(|n| n.span.end - n.span.start)
9980 .map(|n| n.kind)
9981 }
9982
9983 #[test]
9984 fn a_task_box_toggles_at_the_caret_and_reads_back() {
9985 let mut d = doc_with("task_toggle", "- [ ] todo\n- [x] done\n");
9986 d.caret = 8; // inside "todo"
9987 assert_eq!(d.task_checked_at_caret(), Some(false));
9988 d.toggle_task_checked();
9989 assert_eq!(d.source, "- [x] todo\n- [x] done\n");
9990 assert_eq!(d.task_checked_at_caret(), Some(true));
9991 d.toggle_task_checked();
9992 assert_eq!(d.source, "- [ ] todo\n- [x] done\n");
9993 }
9994
9995 #[test]
9996 fn a_click_toggles_a_box_without_taking_the_caret_with_it() {
9997 // The whole reason `toggle_task_at` exists apart from the caret form:
9998 // ticking a box elsewhere must not move the cursor out of what's being
9999 // typed.
10000 let mut d = doc_with("task_click", "- [ ] first\n- [ ] second\n");
10001 d.caret = 8; // inside "first"
10002 let second = d.source.find("second").unwrap();
10003 d.toggle_task_at(second);
10004 assert_eq!(d.source, "- [ ] first\n- [x] second\n");
10005 assert_eq!(d.caret, 8, "the caret stayed in the first item");
10006 }
10007
10008 #[test]
10009 fn a_plain_item_gains_and_loses_a_box() {
10010 let mut d = doc_with("task_mint", "- plain\n");
10011 d.caret = 4;
10012 assert_eq!(d.task_checked_at_caret(), None);
10013 d.toggle_task_item();
10014 assert_eq!(d.source, "- [ ] plain\n");
10015 assert_eq!(
10016 d.task_checked_at_caret(),
10017 Some(false),
10018 "a new box arrives unticked"
10019 );
10020 d.toggle_task_item();
10021 assert_eq!(d.source, "- plain\n");
10022 }
10023
10024 #[test]
10025 fn ticking_a_box_that_isnt_there_reports_rather_than_minting_one() {
10026 // `set checked` must not silently convert a bullet into a task — that is
10027 // `toggle_task_item`'s job, and twig refuses it here.
10028 let mut d = doc_with("task_none", "- plain\n");
10029 d.caret = 4;
10030 d.toggle_task_checked();
10031 assert_eq!(d.source, "- plain\n", "nothing written");
10032 assert!(
10033 d.status.is_some(),
10034 "the refusal should reach the status line"
10035 );
10036 }
10037
10038 #[test]
10039 fn a_task_item_in_a_quote_is_found_past_the_quote_marker() {
10040 let mut d = doc_with("task_quote", "> - [ ] nested\n");
10041 d.caret = d.source.find("nested").unwrap();
10042 assert_eq!(d.task_checked_at_caret(), Some(false));
10043 d.toggle_task_checked();
10044 assert_eq!(d.source, "> - [x] nested\n");
10045 }
10046
10047 #[test]
10048 fn insert_thematic_break_parts_the_paragraph_around_the_caret() {
10049 // A rule is a block, so twig's `insert_thematic_break` alone lands it
10050 // after the whole paragraph. `split_block` parts the paragraph first and
10051 // the rule is aimed at the *first* half, which is what a rule button is
10052 // understood to do — and what leaf spelled by hand until twig grew both
10053 // halves of the gesture.
10054 let mut d = doc_with("hr_mid", "before after\n");
10055 d.caret = 7; // between "before " and "after"
10056 d.insert_thematic_break();
10057 assert_eq!(d.source, "before \n\n---\n\nafter\n");
10058 assert_eq!(d.selection(), None);
10059 assert_eq!(
10060 kind_at(&mut d, "before \n\n".len()),
10061 Some(Kind::ThematicBreak)
10062 );
10063 }
10064
10065 #[test]
10066 fn insert_thematic_break_at_a_paragraph_s_end_splits_nothing() {
10067 // At the end there is nothing to part, and a split there writes the
10068 // separator anyway — a blank line and the empty slot the next paragraph
10069 // would fill — which the rule then landed above: `para\n\n* * *\n\n\n`,
10070 // two blank lines nothing fills. Now the rule lands after the paragraph,
10071 // where the split-and-aim was sending it regardless. Both formats, and
10072 // both shapes of a last line — terminated, and still being typed —
10073 // because the two reach the split through different doors: Markdown's
10074 // paragraph span stops before its newline, so `para\n` at 4 never split
10075 // there, but `para` at 4 did.
10076 for (fmt, rule) in [(Format::Markdown, "---"), (Format::Djot, "* * *")] {
10077 for src in ["para\n", "para"] {
10078 let mut d = Doc::from_source(src.into(), fmt).unwrap();
10079 d.caret = 4;
10080 d.insert_thematic_break();
10081 assert_eq!(d.source, format!("para\n\n{rule}\n"), "{fmt:?} {src:?}");
10082 assert_eq!(d.caret, d.source.len());
10083 }
10084 // Mid-document the slot sat between the rule and the next block.
10085 let mut d = Doc::from_source("para\n\nnext\n".into(), fmt).unwrap();
10086 d.caret = 4;
10087 d.insert_thematic_break();
10088 assert_eq!(d.source, format!("para\n\n{rule}\n\nnext\n"), "{fmt:?}");
10089 // Trailing whitespace is nothing to part either.
10090 let mut d = Doc::from_source("para \n".into(), fmt).unwrap();
10091 d.caret = 4;
10092 d.insert_thematic_break();
10093 assert_eq!(d.source, format!("para \n\n{rule}\n"), "{fmt:?}");
10094 }
10095 }
10096
10097 #[test]
10098 fn insert_thematic_break_at_a_paragraph_s_start_lands_before_it() {
10099 // The split at the start parts nothing, but it is kept on purpose:
10100 // `|para` becomes `\npara` with the caret on a blank line, and twig
10101 // (3.5.2) writes a rule aimed at a blank line ON that line — the only
10102 // way "before the paragraph" is reachable through a gesture that only
10103 // places after. Before 3.5.2 this came out as `\n\n---\n\npara`.
10104 for (fmt, rule) in [(Format::Markdown, "---"), (Format::Djot, "* * *")] {
10105 let mut d = Doc::from_source("para\n".into(), fmt).unwrap();
10106 d.caret = 0;
10107 d.insert_thematic_break();
10108 assert_eq!(d.source, format!("{rule}\n\npara\n"), "{fmt:?}");
10109 let mut d = Doc::from_source("prev\n\npara\n".into(), fmt).unwrap();
10110 d.caret = 6;
10111 d.insert_thematic_break();
10112 assert_eq!(d.source, format!("prev\n\n{rule}\n\npara\n"), "{fmt:?}");
10113 }
10114 }
10115
10116 #[test]
10117 fn insert_thematic_break_on_a_blank_line_takes_that_line() {
10118 // The gap between two blocks is where a click lands the caret; the
10119 // rule goes on the blank, one blank each side.
10120 let mut d = doc_with("hr_gap", "a\n\nb\n");
10121 d.caret = 2;
10122 d.insert_thematic_break();
10123 assert_eq!(d.source, "a\n\n---\n\nb\n");
10124 }
10125
10126 #[test]
10127 fn insert_table_at_a_paragraph_s_end_splits_nothing() {
10128 // The same door as the rule's, through the placement they share.
10129 let mut d = Doc::from_source("para\n".into(), Format::Djot).unwrap();
10130 d.caret = 4;
10131 d.insert_table(1, 1);
10132 assert_eq!(d.source, "para\n\n| |\n|---|\n| |\n");
10133 let mut d = doc_with("table_end_typed", "para");
10134 d.caret = 4;
10135 d.insert_table(1, 1);
10136 assert_eq!(d.source, "para\n\n| |\n| --- |\n| |\n");
10137 assert!(d.caret_in_table());
10138 }
10139
10140 #[test]
10141 fn insert_thematic_break_spells_the_rule_the_format_s_own_way() {
10142 // The whole point of delegating: `---` is Markdown's, `* * *` is djot's,
10143 // and leaf wrote the first into both until twig started spelling it.
10144 let mut md = doc_with("hr_md", "para\n");
10145 md.caret = 2;
10146 md.insert_thematic_break();
10147 assert_eq!(md.source, "pa\n\n---\n\nra\n");
10148
10149 let mut dj = Doc::from_source("para\n".into(), Format::Djot).unwrap();
10150 dj.caret = 2;
10151 dj.insert_thematic_break();
10152 assert_eq!(dj.source, "pa\n\n* * *\n\nra\n");
10153 }
10154
10155 #[test]
10156 fn insert_table_parts_the_paragraph_and_lands_in_the_first_header_cell() {
10157 // The table goes *at* the caret the way the rule does: the paragraph is
10158 // parted first, and twig writes the grid after its first half. The
10159 // caret then sits in the first header cell — selected, as Tab would
10160 // leave it — so the next keystroke is the heading.
10161 let mut d = doc_with("table_mid", "before after\n");
10162 d.caret = 7;
10163 d.insert_table(2, 3);
10164 assert_eq!(
10165 d.source,
10166 "before \n\n| | | |\n| --- | --- | --- |\n| | | |\n| | | |\n\nafter\n"
10167 );
10168 assert!(d.caret_in_table());
10169 let first_bar = d.source.find('|').unwrap();
10170 assert!(
10171 d.caret > first_bar && d.caret < d.source.find("| ---").unwrap(),
10172 "caret {} is not in the header row",
10173 d.caret
10174 );
10175 d.insert("Name");
10176 assert!(d.source.starts_with("before \n\n| Name | | |\n"));
10177 // And the grid the table was written into is one the table keys walk
10178 // (over the map a frontend rebuilds after every edit).
10179 d.build_visual(80);
10180 assert!(d.cell_tab(true));
10181 d.insert("Qty");
10182 assert!(d.source.starts_with("before \n\n| Name | Qty | |\n"));
10183 }
10184
10185 #[test]
10186 fn insert_table_spells_the_grid_the_format_s_own_way() {
10187 // Djot's delimiter row is unpadded, and leaf never has to know that.
10188 let mut dj = Doc::from_source("para\n".into(), Format::Djot).unwrap();
10189 dj.caret = 2;
10190 dj.insert_table(1, 2);
10191 assert_eq!(dj.source, "pa\n\n| | |\n|---|---|\n| | |\n\nra\n");
10192 assert!(dj.caret_in_table());
10193 }
10194
10195 #[test]
10196 fn insert_table_refuses_where_the_format_spells_no_table() {
10197 let mut d = Doc::from_source("<p>ab</p>\n".into(), Format::Html).unwrap();
10198 d.caret = 4;
10199 d.insert_table(1, 1);
10200 assert_eq!(d.source, "<p>ab</p>\n");
10201 assert!(d.status.as_deref().unwrap_or("").contains("not supported"));
10202 assert!(!d.capabilities().table);
10203 }
10204
10205 #[test]
10206 fn insert_table_reports_a_zero_shape_and_writes_nothing() {
10207 let mut d = doc_with("table_zero", "para\n");
10208 d.caret = 2;
10209 d.insert_table(0, 2);
10210 assert_eq!(d.source, "para\n");
10211 assert!(d.status.as_deref().unwrap_or("").starts_with("table:"));
10212 }
10213
10214 #[test]
10215 fn clicking_below_a_final_thematic_break_can_type_after_it() {
10216 let mut d = wysiwyg_doc("hr_final_click", "---\n");
10217 d.build_visual(80);
10218 d.click(d.vmap.num_rows() + 2, 0, false);
10219 assert_eq!(d.caret, d.source.len(), "the caret belongs after the rule");
10220 d.insert("after");
10221 assert_eq!(d.source, "---\nafter");
10222 }
10223
10224 #[test]
10225 fn enter_in_a_nested_list_item_keeps_the_new_item_nested() {
10226 // The same bytes are two documents. In Markdown ` - b` is a nested item
10227 // and the next one belongs beside it, at its indent. In Djot a list
10228 // marker can't interrupt a paragraph, so those bytes are literal text in
10229 // item `a` and there is only one item — writing ` - ` under it would add
10230 // no item at all, just more text, and the new sibling has to go to
10231 // column zero. Both spellings come out of the *enclosing item's* line.
10232 let mut md = wysiwyg_doc("enter_nested_md", "- a\n - b\n");
10233 md.caret = "- a\n - b".len();
10234 md.newline();
10235 assert_eq!(md.source, "- a\n - b\n - \n");
10236 assert_eq!(list_items(&mut md), 3);
10237
10238 let mut dj = Doc::from_source("- a\n - b\n".into(), Format::Djot).unwrap();
10239 dj.view = View::Wysiwyg;
10240 dj.build_visual(80);
10241 dj.caret = "- a\n - b".len();
10242 dj.newline();
10243 assert_eq!(dj.source, "- a\n - b\n- \n");
10244 assert_eq!(list_items(&mut dj), 2);
10245
10246 // Where Djot's nesting is real — opened by a blank line — the indent is
10247 // reproduced there too, and the two formats agree again.
10248 let mut dj = Doc::from_source("- a\n\n - b\n".into(), Format::Djot).unwrap();
10249 dj.view = View::Wysiwyg;
10250 dj.build_visual(80);
10251 dj.caret = "- a\n\n - b".len();
10252 dj.newline();
10253 assert_eq!(dj.source, "- a\n\n - b\n - \n");
10254 assert_eq!(list_items(&mut dj), 3);
10255 }
10256
10257 #[test]
10258 fn tab_nests_an_item_at_the_column_its_own_marker_asks_for() {
10259 // Tab replaces the line's whole prefix with the one twig spells, so the
10260 // quote markers, the parent's indent and an ordered marker's extra
10261 // column are all its answer rather than leaf's arithmetic.
10262 for (name, body, caret, want) in [
10263 ("bullet", "- a\n- b\n", 6, "- a\n - b\n"),
10264 ("ordered", "1. a\n2. b\n", 8, "1. a\n 1. b\n"),
10265 ("quoted", "> - a\n> - b\n", 10, "> - a\n> - b\n"),
10266 // A checkbox is markup the item's own text wraps past, but a nested
10267 // list may only open at the *list* marker's column — four in from
10268 // there is a paragraph continuation, and `- [ ] a\n - [ ] b`
10269 // parses as one item, not two.
10270 ("task", "- [ ] a\n- [ ] b\n", 14, "- [ ] a\n - [ ] b\n"),
10271 (
10272 "quoted task",
10273 "> - [ ] a\n> - [ ] b\n",
10274 18,
10275 "> - [ ] a\n> - [ ] b\n",
10276 ),
10277 ] {
10278 let mut doc = wysiwyg_doc(name, body);
10279 doc.caret = caret;
10280 doc.indent();
10281 assert_eq!(doc.source, want, "{name}");
10282 // The nesting is real, not just indented text.
10283 assert_eq!(list_items(&mut doc), 2, "{name}");
10284 }
10285 }
10286
10287 #[test]
10288 fn backspace_only_outdents_where_the_format_says_there_is_an_item() {
10289 // The same bytes, the two formats disagreeing, and a gesture that used
10290 // to read the bytes. ` - b` is a nested item in Markdown, so Backspace
10291 // at its marker outdents. In Djot a marker can't interrupt a paragraph,
10292 // so those bytes are literal text inside item `a` — there is nothing to
10293 // outdent, and treating them as a marker turned one item into two, a
10294 // structural edit from a keystroke that should delete one character.
10295 //
10296 // twig's `line_prefix` is what tells them apart: it reports the marker
10297 // on the Markdown line and nothing on the Djot one, which is a
10298 // continuation. No byte scan can reach that answer.
10299 let src = "- a\n - b\n";
10300 let at = "- a\n - ".len();
10301
10302 let mut md = Doc::from_source(src.into(), Format::Markdown).unwrap();
10303 md.view = View::Wysiwyg;
10304 md.build_visual(80);
10305 md.caret = at;
10306 md.backspace();
10307 assert_eq!(md.source, "- a\n- b\n");
10308 assert_eq!(list_items(&mut md), 2);
10309
10310 let mut dj = Doc::from_source(src.into(), Format::Djot).unwrap();
10311 dj.view = View::Wysiwyg;
10312 dj.build_visual(80);
10313 dj.caret = at;
10314 dj.backspace();
10315 assert_eq!(dj.source, "- a\n -b\n"); // an ordinary character delete
10316 assert_eq!(list_items(&mut dj), 1); // and the structure is untouched
10317 }
10318
10319 #[test]
10320 fn enter_in_a_checklist_item_starts_another_unchecked_one() {
10321 // Leaf used to spell the next item from the marker bytes it scanned, and
10322 // its scanner stopped at the bullet — so Enter in a checklist wrote `- `
10323 // and dropped out of the checklist. twig reproduces the whole
10324 // continuation, and a fresh item is always unticked however the one above
10325 // it stands.
10326 for (name, body, want) in [
10327 ("unchecked", "- [ ] a\n", "- [ ] a\n- [ ] \n"),
10328 ("checked", "- [x] a\n", "- [x] a\n- [ ] \n"),
10329 ] {
10330 let mut doc = wysiwyg_doc(name, body);
10331 doc.caret = body.trim_end_matches('\n').len();
10332 doc.newline();
10333 assert_eq!(doc.source, want, "{name}");
10334 // Both items are checklist items — the new one is a box, not the
10335 // plain bullet the old marker scan left behind — and it is unticked
10336 // whichever way the one above it faces.
10337 let boxes: Vec<Option<bool>> = doc
10338 .nodes()
10339 .iter()
10340 .filter(|n| n.kind == Kind::TaskListItem)
10341 .map(|n| n.checked)
10342 .collect();
10343 assert_eq!(boxes.len(), 2, "{name}");
10344 assert_eq!(boxes[1], Some(false), "{name}");
10345 }
10346 }
10347
10348 #[test]
10349 fn a_split_takes_the_space_the_caret_was_in_front_of() {
10350 // Splicing a break at the caret strands the space the words were parted
10351 // at on the head of the second block, where it reads as an indent nobody
10352 // typed. twig's split consumes it.
10353 for (name, body, caret, want) in [
10354 ("para", "one two\n", 3, "one\n\ntwo\n"),
10355 ("item", "- one two\n", 5, "- one\n- two\n"),
10356 ("quote", "> one two\n", 5, "> one\n>\n> two\n"),
10357 // A heading takes leaf's own path, which has to match.
10358 ("heading", "# one two\n", 5, "# one\n\ntwo\n"),
10359 ] {
10360 let mut doc = wysiwyg_doc(name, body);
10361 doc.caret = caret;
10362 doc.newline();
10363 assert_eq!(doc.source, want, "{name}");
10364 }
10365 }
10366
10367 #[test]
10368 fn enter_at_the_end_of_a_heading_opens_a_paragraph() {
10369 // The one place leaf keeps its own break: `split_block` repeats the `#`,
10370 // and Enter after a title is how the body under it is asked for.
10371 let mut doc = wysiwyg_doc("head_enter", "# Title\n");
10372 doc.caret = "# Title".len();
10373 doc.newline();
10374 doc.insert("body");
10375 assert_eq!(doc.source, "# Title\n\nbody\n");
10376 assert_eq!(
10377 doc.nodes()
10378 .iter()
10379 .filter(|n| n.kind == Kind::Heading)
10380 .count(),
10381 1
10382 );
10383 }
10384
10385 #[test]
10386 fn enter_in_a_quoted_list_item_starts_the_next_quoted_item() {
10387 // A quoted item's marker doesn't open its line, so a scan that starts at
10388 // column zero finds a `>` where it wanted a bullet, calls the line "not a
10389 // list" and hands Enter to the plain-quote branch — which writes `> ` and
10390 // drops the list. The next item has to carry the whole prefix.
10391 for (name, body, want) in [
10392 ("flat", "> - a\n", "> - a\n> - \n"),
10393 ("sibling", "> - a\n> - b\n", "> - a\n> - b\n> - \n"),
10394 ("nested", "> - a\n> - b\n", "> - a\n> - b\n> - \n"),
10395 ("ordered", "> 1. a\n> 2. b\n", "> 1. a\n> 2. b\n> 3. \n"),
10396 ("twice quoted", "> > - a\n", "> > - a\n> > - \n"),
10397 ] {
10398 let mut doc = wysiwyg_doc(name, body);
10399 doc.caret = body.trim_end_matches('\n').len();
10400 doc.newline();
10401 assert_eq!(doc.source, want, "{name}");
10402 // The marker isn't just spelled right, it parses as an item.
10403 assert_eq!(list_items(&mut doc), body.lines().count() + 1, "{name}");
10404 }
10405 }
10406
10407 #[test]
10408 fn an_empty_quoted_item_leaves_the_list_and_stays_in_the_quote() {
10409 // Double-Enter exits the list. Unquoted that means a blank line, but a
10410 // *bare* blank line would end the quote too and drop the caret out of it,
10411 // so the separator keeps its `>` and the caret's line keeps its `> `.
10412 let mut doc = wysiwyg_doc("quoted_exit", "> - a\n> - \n");
10413 doc.caret = "> - a\n> - ".len();
10414 doc.newline();
10415 assert_eq!(doc.source, "> - a\n>\n> \n");
10416 assert_eq!(list_items(&mut doc), 1);
10417 // What "still in the quote" means for the next keystroke: the caret sits
10418 // behind the prefix, and what's typed there lands inside the quote as a
10419 // paragraph of its own — not as more of item `a`.
10420 doc.insert("x");
10421 assert_eq!(doc.source, "> - a\n>\n> x\n");
10422 assert!(
10423 doc.editor
10424 .ancestors_at(doc.caret - 1)
10425 .is_ok_and(|c| c.into_iter().any(|m| m.kind == Kind::BlockQuote))
10426 );
10427 }
10428
10429 #[test]
10430 fn backspace_at_a_quoted_marker_takes_the_marker_and_leaves_the_quote() {
10431 // The marker is hidden block markup, so Backspace over it is structural —
10432 // but only the marker is the list's. Splicing from the line start would
10433 // take the `>` with it and silently unquote the line.
10434 let mut doc = wysiwyg_doc("quoted_bksp", "> - a\n");
10435 doc.caret = "> - ".len();
10436 doc.backspace();
10437 assert_eq!(doc.source, "> a\n");
10438 assert_eq!(list_items(&mut doc), 0);
10439
10440 // A nested one outdents instead, moving the bullet within the quote
10441 // rather than moving the quote.
10442 let mut doc = wysiwyg_doc("quoted_outdent", "> - a\n> - b\n");
10443 doc.caret = "> - a\n> - ".len();
10444 doc.backspace();
10445 assert_eq!(doc.source, "> - a\n> - b\n");
10446 assert_eq!(list_items(&mut doc), 2);
10447 }
10448
10449 #[test]
10450 fn only_a_bare_paragraph_is_parted_around_the_caret() {
10451 // The split is deliberately narrow. Parting a fenced block would leave
10452 // two fences with a rule between them, and parting a list item would
10453 // mint an item nobody asked for on the way to a rule that lands after
10454 // the list either way — so both keep the whole block intact and take the
10455 // rule after it. A caret in a quote is likewise left alone.
10456 for (name, body, caret, want) in [
10457 (
10458 "code",
10459 "```\nfn x() {}\n```\n",
10460 8,
10461 "```\nfn x() {}\n```\n\n---\n",
10462 ),
10463 ("list", "- one two\n", 6, "- one two\n\n---\n"),
10464 ("quote", "> one two\n", 6, "> one two\n>\n> ---\n"),
10465 ] {
10466 let mut d = doc_with(&format!("hr_narrow_{name}"), body);
10467 d.caret = caret;
10468 d.insert_thematic_break();
10469 assert_eq!(d.source, want, "{name}: the block should stay whole");
10470 }
10471 }
10472
10473 #[test]
10474 fn insert_thematic_break_replaces_the_selection() {
10475 // Now that the rule lands *at* the caret again, replacing the selection
10476 // is coherent once more: the text goes, and the rule takes its place.
10477 // The space the deletion left leading the second half is consumed by the
10478 // split rather than opening the new paragraph with it.
10479 let mut d = doc_with("hr_sel", "one two three\n");
10480 d.anchor = Some(4);
10481 d.caret = 7; // "two"
10482 d.insert_thematic_break();
10483 assert_eq!(d.source, "one \n\n---\n\nthree\n");
10484 assert_eq!(d.selection(), None);
10485 }
10486
10487 #[test]
10488 fn insert_thematic_break_clears_a_code_block_and_a_table_rather_than_refusing() {
10489 // Both are blocks the rule lands *after*. Leaf used to refuse a fence,
10490 // because writing `---` into one is code, not a rule — twig now walks out
10491 // to the block that owns the caret's line, so there is nothing to refuse.
10492 let mut code = doc_with("hr_code", "```\nfn x() {}\n```\n");
10493 code.caret = 5; // inside the fenced code
10494 code.insert_thematic_break();
10495 assert_eq!(code.source, "```\nfn x() {}\n```\n\n---\n");
10496 assert_eq!(code.status, None, "no refusal to report any more");
10497
10498 let mut table = doc_with("hr_table", "| a | b |\n|---|---|\n| 1 | 2 |\n");
10499 table.caret = 3; // in the header row
10500 table.insert_thematic_break();
10501 assert_eq!(table.source, "| a | b |\n|---|---|\n| 1 | 2 |\n\n---\n");
10502 }
10503
10504 #[test]
10505 fn insert_thematic_break_in_a_list_item_ends_the_list() {
10506 // The un-indented rule cannot continue the list, so it closes the list
10507 // and lands at the top level rather than nested inside it.
10508 let mut d = doc_with("hr_list", "- one\n- two\n");
10509 d.caret = "- one\n- tw".len(); // mid "two"
10510 d.insert_thematic_break();
10511 d.build_visual(80);
10512 let rule_at = d.source.find("---").unwrap();
10513 assert_eq!(kind_at(&mut d, rule_at), Some(Kind::ThematicBreak));
10514 assert!(
10515 !d.nodes().iter().any(|n| n.kind == Kind::BulletList
10516 && n.span.start <= rule_at
10517 && rule_at < n.span.end),
10518 "the rule must not be nested inside the list"
10519 );
10520 }
10521
10522 #[test]
10523 fn insert_thematic_break_in_a_blockquote_stays_in_the_quote() {
10524 // Leaf used to end the quote. twig gives the rule the quote's own prefix,
10525 // which is the document the gesture was actually asked for.
10526 let mut d = doc_with("hr_quote", "> hello\n");
10527 d.caret = 4; // inside the quoted text
10528 d.insert_thematic_break();
10529 assert_eq!(d.source, "> hello\n>\n> ---\n");
10530 d.build_visual(80);
10531 let rule_at = d.source.find("---").unwrap();
10532 assert_eq!(kind_at(&mut d, rule_at), Some(Kind::ThematicBreak));
10533 assert!(
10534 d.nodes().iter().any(|n| n.kind == Kind::BlockQuote
10535 && n.span.start <= rule_at
10536 && rule_at < n.span.end),
10537 "the rule belongs to the quote it was asked for"
10538 );
10539 }
10540
10541 // ── typing against a block picture ────────────────────────────────────────
10542
10543 /// A rendered-view document with the caret parked on one of the picture's two
10544 /// stops, and the map already built — the state a frontend is in between
10545 /// drawing a frame and the next keystroke.
10546 fn doc_at_picture(name: &str, src: &str, side: MediaStop) -> Doc {
10547 let mut d = doc_in(View::Wysiwyg, name, src);
10548 d.build_visual_unwrapped();
10549 let start = src.find("".len(),
10553 };
10554 d
10555 }
10556
10557 /// The block media the map publishes, after rebuilding it — "is this still a
10558 /// picture, or has it become a line of text with an image in it?"
10559 fn media_count(d: &mut Doc) -> usize {
10560 d.build_visual_unwrapped();
10561 d.vmap.media.len()
10562 }
10563
10564 #[test]
10565 fn typing_past_a_block_picture_opens_a_paragraph_under_it() {
10566 // The accident this prevents: tap the blank page under a photo (which
10567 // lands on the picture's trailing stop), type, and `xy` is a
10568 // paragraph with an *inline* image — the photo stops being drawn.
10569 let mut d = doc_at_picture("pic_after", "hi\n\n\n", MediaStop::After);
10570 d.insert("xy");
10571 assert_eq!(d.source, "hi\n\n\n\nxy\n");
10572 assert_eq!(media_count(&mut d), 1, "still a picture");
10573 }
10574
10575 #[test]
10576 fn typing_in_front_of_a_block_picture_opens_a_paragraph_above_it() {
10577 let mut d = doc_at_picture("pic_before", "hi\n\n\n", MediaStop::Before);
10578 d.insert("xy");
10579 assert_eq!(d.source, "hi\n\nxy\n\n\n");
10580 assert_eq!(media_count(&mut d), 1);
10581 }
10582
10583 #[test]
10584 fn a_picture_that_opens_the_document_still_takes_a_paragraph_above_it() {
10585 let mut d = doc_at_picture("pic_first", "\n", MediaStop::Before);
10586 d.insert("x");
10587 assert_eq!(d.source, "x\n\n\n");
10588 assert_eq!(media_count(&mut d), 1);
10589 }
10590
10591 #[test]
10592 fn one_undo_puts_the_picture_back_the_way_it_was_found() {
10593 // The opened paragraph is part of the keystroke, not an edit the writer
10594 // made — so it undoes with the character, not a step later.
10595 let mut d = doc_at_picture("pic_undo", "hi\n\n\n", MediaStop::After);
10596 d.insert("x");
10597 assert_eq!(d.source, "hi\n\n\n\nx\n");
10598 d.undo();
10599 assert_eq!(d.source, "hi\n\n\n");
10600 }
10601
10602 #[test]
10603 fn pasting_against_a_block_picture_opens_a_paragraph_too() {
10604 // ⌘V dissolves the picture exactly as a keystroke does.
10605 let mut d = doc_at_picture("pic_paste", "hi\n\n\n", MediaStop::After);
10606 d.paste("pasted");
10607 assert_eq!(d.source, "hi\n\n\n\npasted\n");
10608 assert_eq!(media_count(&mut d), 1);
10609 }
10610
10611 #[test]
10612 fn typing_beside_an_inline_image_is_ordinary_editing() {
10613 // An inline image has no placeholder row and no stops of its own. Opening
10614 // a paragraph mid-sentence would be the bug, not the fix.
10615 let mut d = doc_in(View::Wysiwyg, "pic_inline", "see  here\n");
10616 d.build_visual_unwrapped();
10617 d.caret = "see ".len();
10618 d.insert("!");
10619 assert_eq!(d.source, "see ! here\n");
10620 }
10621
10622 #[test]
10623 fn source_view_types_raw_markup_against_an_image_untouched() {
10624 // Source view is for writing the markup itself; a break inserted behind
10625 // the writer's back there would be the editor arguing with them.
10626 let mut d = doc_in(View::Source, "pic_src", "\n");
10627 d.caret = "".len();
10628 d.insert("x");
10629 assert_eq!(d.source, "x\n");
10630 }
10631
10632 #[test]
10633 fn typing_over_a_selection_that_starts_at_a_picture_stop_replaces_it() {
10634 // A selection is replaced, not joined into, so there is nothing to
10635 // protect: the range takes the picture with it.
10636 let mut d = doc_at_picture("pic_sel", "hi\n\n\n", MediaStop::Before);
10637 d.anchor = Some(d.caret);
10638 d.caret = d.source.find("".len();
10639 d.insert("x");
10640 assert_eq!(d.source, "hi\n\nx\n");
10641 }
10642
10643 #[test]
10644 fn backspace_past_a_block_picture_deletes_the_picture_not_its_last_byte() {
10645 // What this actually cost: a real vault's photo, to one stray Backspace.
10646 // The caret past `` was deleting the closing paren — invisible
10647 // in the rendered view — and the photo became the text `\n", MediaStop::After);
10649 d.backspace();
10650 assert_eq!(d.source, "hi\n");
10651 assert_eq!(media_count(&mut d), 0, "the picture went, in one piece");
10652 d.undo();
10653 assert_eq!(
10654 d.source, "hi\n\n\n",
10655 "and comes back in one piece"
10656 );
10657 }
10658
10659 #[test]
10660 fn backspace_in_front_of_a_block_picture_steps_out_instead_of_merging_it() {
10661 // Deleting the break here would join the picture to the paragraph above,
10662 // where it is an *inline* image and stops being drawn. Step over the
10663 // boundary; the next press deletes in the paragraph the caret reached.
10664 let mut d = doc_at_picture("pic_bs_before", "hi\n\n\n", MediaStop::Before);
10665 d.backspace();
10666 assert_eq!(d.source, "hi\n\n\n", "nothing deleted");
10667 assert_eq!(d.caret, 2, "the caret stepped up to the end of `hi`");
10668 d.backspace();
10669 assert_eq!(d.source, "h\n\n\n", "and now it deletes there");
10670 assert_eq!(media_count(&mut d), 1, "the picture was never at risk");
10671 }
10672
10673 #[test]
10674 fn forward_delete_in_front_of_a_block_picture_deletes_the_picture() {
10675 // The mirror. A byte-step here eats the `!` and leaves a link.
10676 let mut d = doc_at_picture("pic_del", "hi\n\n\n\nbye\n", MediaStop::Before);
10677 d.delete_forward();
10678 assert_eq!(d.source, "hi\n\nbye\n");
10679 assert_eq!(media_count(&mut d), 0);
10680 }
10681
10682 #[test]
10683 fn forward_delete_past_a_block_picture_steps_over_the_boundary() {
10684 let mut d = doc_at_picture(
10685 "pic_del_after",
10686 "hi\n\n\n\nbye\n",
10687 MediaStop::After,
10688 );
10689 d.delete_forward();
10690 assert_eq!(d.source, "hi\n\n\n\nbye\n", "nothing deleted");
10691 assert_eq!(
10692 d.caret,
10693 d.source.find("bye").unwrap(),
10694 "the caret stepped down to `bye`"
10695 );
10696 }
10697
10698 #[test]
10699 fn a_picture_that_is_the_whole_document_still_deletes_cleanly() {
10700 let mut d = doc_at_picture("pic_only", "\n", MediaStop::After);
10701 d.backspace();
10702 assert_eq!(d.source, "\n");
10703 assert_eq!(media_count(&mut d), 0);
10704 }
10705
10706 #[test]
10707 fn a_word_delete_takes_the_picture_whole_or_steps_out_of_it() {
10708 // ⌥⌫ past a picture would otherwise eat a "word" of its markup.
10709 let mut d = doc_at_picture("pic_wordbs", "hi there\n\n\n", MediaStop::After);
10710 d.delete_word_back();
10711 assert_eq!(d.source, "hi there\n");
10712
10713 // And in front of one it runs *through* the paragraph break into the
10714 // prose above, which merges the picture inline — so it steps out first,
10715 // and the second press deletes the word it was aimed at.
10716 let mut d = doc_at_picture("pic_wordbs2", "hi there\n\n\n", MediaStop::Before);
10717 d.delete_word_back();
10718 assert_eq!(d.source, "hi there\n\n\n");
10719 d.delete_word_back();
10720 assert_eq!(
10721 d.source, "hi \n\n\n",
10722 "the word above went, the picture stayed"
10723 );
10724 assert_eq!(media_count(&mut d), 1);
10725 }
10726
10727 #[test]
10728 fn source_view_deletes_raw_markup_against_an_image_untouched() {
10729 let mut d = doc_in(View::Source, "pic_src_del", "\n");
10730 d.caret = "".len();
10731 d.backspace();
10732 assert_eq!(d.source, ";
10733 }
10734
10735 #[test]
10736 fn image_destination_at_caret_reads_the_image_under_the_caret() {
10737 let mut d = doc_with("img_read", "\n");
10738 d.caret = 3; // inside the image markup
10739 assert_eq!(d.image_destination_at_caret(), Some("cat.png".to_string()));
10740 // Past the image, the caret is in no image.
10741 d.caret = "".len();
10742 assert_eq!(d.image_destination_at_caret(), None);
10743 }
10744
10745 #[test]
10746 fn set_media_rows_reserves_blank_filler_rows_the_frontend_paints_over() {
10747 // The image is one placeholder row by default, and `set_media_rows` grows
10748 // it to the height the frontend measured: the label row plus blank
10749 // `decoration` fillers that hold the vertical space a raster is drawn into.
10750 let mut d = wysiwyg_doc("img_rows", "intro\n\n\n\nend\n");
10751 assert_eq!(d.vmap.media.len(), 1);
10752 let img_row = d.vmap.media[0].rows_span.start;
10753 assert_eq!(
10754 d.vmap.media[0].rows_span,
10755 img_row..img_row + 1,
10756 "default is one row"
10757 );
10758
10759 d.set_media_rows(HashMap::from([("cat.png".to_string(), 4)]));
10760 d.build_visual(80);
10761 assert_eq!(d.vmap.media.len(), 1, "still one image, now taller");
10762 let span = d.vmap.media[0].rows_span.clone();
10763 assert_eq!(span.end - span.start, 4, "reserves the four rows asked for");
10764 // The label row carries the mark and its glyphs; the three below are blank
10765 // decoration — drawn, but no caret and no text.
10766 assert!(
10767 d.vmap.rows[span.start].media.is_some(),
10768 "mark rides the first row"
10769 );
10770 for r in (span.start + 1)..span.end {
10771 assert!(d.vmap.rows[r].decoration, "filler row {r} is decoration");
10772 assert!(d.vmap.rows[r].glyphs.is_empty(), "filler row {r} is blank");
10773 assert!(
10774 d.vmap.rows[r].media.is_none(),
10775 "only the first row is marked"
10776 );
10777 }
10778 }
10779
10780 #[test]
10781 fn a_taller_image_adds_no_caret_stops_and_motion_steps_over_its_fillers() {
10782 // The extra rows are pure spacers: the caret's only homes stay the stop in
10783 // front of the image and the one just past it, so walking the document top
10784 // to bottom visits the same offsets whether the image is 1 row or 5.
10785 let body = "ab\n\n\n\ncd\n";
10786 let stops_at = |rows: usize| -> Vec<usize> {
10787 let mut d = wysiwyg_doc("img_stops", body);
10788 if rows > 1 {
10789 d.set_media_rows(HashMap::from([("p.png".to_string(), rows)]));
10790 d.build_visual(80);
10791 }
10792 d.caret = 0;
10793 let mut seen = vec![d.caret];
10794 loop {
10795 d.move_right(false);
10796 if *seen.last().unwrap() == d.caret {
10797 break;
10798 }
10799 seen.push(d.caret);
10800 }
10801 seen
10802 };
10803 assert_eq!(
10804 stops_at(1),
10805 stops_at(5),
10806 "reserving rows must not add stops"
10807 );
10808 }
10809
10810 #[test]
10811 fn insert_link_repoints_the_link_at_a_bare_caret() {
10812 let mut d = doc_with("link_repoint", "[word](http://x.dev)\n");
10813 d.caret = 3; // in the link's text, nothing selected
10814 d.insert_link("http://y.dev");
10815 assert_eq!(d.source, "[word](http://y.dev)\n");
10816 assert_eq!(d.selected_text(), Some("word"));
10817 }
10818
10819 #[test]
10820 fn insert_link_on_an_empty_range_autolinks_a_url() {
10821 // A link with no text of its own is an autolink, and twig spells it —
10822 // `<…>` is the canonical form and needs no text typed into it, so the
10823 // caret lands after it rather than selecting a finished link.
10824 let mut d = doc_with("link_empty", "\n");
10825 d.caret = 0;
10826 d.insert_link("http://x.dev");
10827 assert_eq!(d.source, "<http://x.dev>\n");
10828 assert_eq!(d.selection(), None);
10829 assert_eq!(d.caret, 14);
10830 }
10831
10832 #[test]
10833 fn insert_link_on_an_empty_range_falls_back_for_a_non_url() {
10834 // `<./notes.md>` is literal text in both formats and `<foo>` is raw HTML
10835 // in Markdown, so a destination that can't autolink doubles as the text
10836 // instead — which is then selected, ready to be typed over.
10837 let mut d = doc_with("link_rel", "\n");
10838 d.caret = 0;
10839 d.insert_link("./notes.md");
10840 assert_eq!(d.source, "[./notes.md](./notes.md)\n");
10841 assert_eq!(d.selection(), Some((1, 11)));
10842 d.insert("Notes");
10843 assert_eq!(d.source, "[Notes](./notes.md)\n");
10844 }
10845
10846 #[test]
10847 fn insert_link_repoints_the_autolink_the_caret_stands_in() {
10848 // The autolink's text is its URL, so re-pointing replaces the whole
10849 // node — the caret must not splice a second link inside the first.
10850 let mut d = doc_with("link_repoint_auto", "see <https://x.dev> ok\n");
10851 d.caret = 10;
10852 d.insert_link("https://y.dev");
10853 assert_eq!(d.source, "see <https://y.dev> ok\n");
10854 }
10855
10856 #[test]
10857 fn code_language_reads_and_edits_through_the_fence() {
10858 let mut d = doc_with("code_lang", "```rust\nlet x = 1;\n```\n");
10859 d.caret = 10; // inside the code body
10860 assert_eq!(d.code_language_at_caret().as_deref(), Some("rust"));
10861 assert!(d.caret_in_fenced_code());
10862
10863 d.set_code_language("python");
10864 assert!(
10865 d.source.starts_with("```python\n"),
10866 "source: {:?}",
10867 d.source
10868 );
10869 assert_eq!(d.code_language_at_caret().as_deref(), Some("python"));
10870
10871 // Clearing it leaves a bare fence and no label.
10872 d.set_code_language("");
10873 assert!(d.source.starts_with("```\n"), "source: {:?}", d.source);
10874 assert_eq!(d.code_language_at_caret(), None);
10875
10876 // A caret outside any code block edits nothing.
10877 let mut p = doc_with("code_lang_none", "just prose\n");
10878 assert!(!p.caret_in_fenced_code());
10879 p.set_code_language("rust");
10880 assert_eq!(p.source, "just prose\n");
10881 }
10882
10883 #[test]
10884 fn a_language_the_fence_cannot_carry_is_refused_not_written() {
10885 // Markdown's info string ends at whitespace, so `two words` would write
10886 // a fence that reads back with a different language than the one asked
10887 // for. twig refuses it; leaf reports that and leaves the source alone.
10888 // The old splice trimmed the ends and wrote whatever was left.
10889 let mut d = doc_with("code_lang_bad", "```rust\nx\n```\n");
10890 d.caret = 10;
10891 d.set_code_language("two words");
10892 assert_eq!(d.source, "```rust\nx\n```\n", "source should be untouched");
10893 assert!(d.status.is_some(), "the refusal should be reported");
10894 assert_eq!(d.code_language_at_caret().as_deref(), Some("rust"));
10895 }
10896
10897 #[test]
10898 fn link_destination_at_caret_reads_both_spellings() {
10899 let mut d = doc_with("link_dest", "see [t](https://x.dev) ok\n");
10900 d.caret = 5;
10901 assert_eq!(
10902 d.link_destination_at_caret().as_deref(),
10903 Some("https://x.dev")
10904 );
10905 d.caret = 0;
10906 assert_eq!(d.link_destination_at_caret(), None);
10907
10908 // An autolink has no `destination`; its text is the URL.
10909 let mut a = doc_with("link_dest_auto", "see <https://x.dev> ok\n");
10910 a.caret = 10;
10911 assert_eq!(
10912 a.link_destination_at_caret().as_deref(),
10913 Some("https://x.dev")
10914 );
10915 a.caret = 21;
10916 assert_eq!(a.link_destination_at_caret(), None);
10917 }
10918
10919 #[test]
10920 fn locate_finds_the_block_a_declared_id_names() {
10921 // The Book of Mormon shape: one document per chapter, one `{#v…}` per
10922 // verse. The locator has to land on the *verse*, which is the whole
10923 // reason a link carries one.
10924 let src = "{#v1}\nI, Nephi, having been born of goodly parents.\n\n\
10925 {#v2}\nYea, I make a record in the language of my father.\n";
10926 let mut d = Doc::from_source(src.to_string(), Format::Djot).unwrap();
10927 let v2 = d.locate("v2").expect("the document declares `{#v2}`");
10928 assert_eq!(
10929 d.source[v2.start..v2.end].trim_end(),
10930 "Yea, I make a record in the language of my father."
10931 );
10932 // The attribute line is not part of it: `start` is a place to put a
10933 // caret, and `{#v2}` is markup the caret has no business landing in.
10934 assert!(d.source[..v2.start].ends_with("{#v2}\n"));
10935 assert_eq!(d.locate("v99"), None);
10936 }
10937
10938 #[test]
10939 fn locate_reads_a_heading_by_its_words_when_the_format_mints_no_ids() {
10940 // Markdown has no ids at all — twig mints none, and `{#custom}` in a
10941 // Markdown heading is literal text. So `#the-second-part` can only be
10942 // the heading's own words, which is the rule every Markdown renderer
10943 // already follows and therefore the one a link was authored against.
10944 let src = "# Title\n\nintro\n\n## The Second Part\n\nbody\n\n## Third\n\nmore\n";
10945 let mut d = doc_with("locate_md", src);
10946 let hit = d.locate("the-second-part").expect("the heading's slug");
10947 assert!(d.source[hit.start..].starts_with("## The Second Part"));
10948 // Bounded by the next heading that isn't under it, so a peek shows the
10949 // section rather than only its title.
10950 assert_eq!(
10951 &d.source[hit.start..hit.end],
10952 "## The Second Part\n\nbody\n\n"
10953 );
10954
10955 // A subsection does not end its parent: `# Title` runs to `## Third`'s
10956 // sibling only because there is no other `#`, so it covers the lot.
10957 let title = d.locate("title").expect("the top heading");
10958 assert_eq!(title.end, d.source.len());
10959 }
10960
10961 #[test]
10962 fn locate_reads_a_djot_auto_id_however_the_link_spelled_it() {
10963 // djot mints `Some-Heading-Here`; a link to it is written
10964 // `#some-heading-here` by nearly everything that writes links. Both
10965 // spellings are one question.
10966 let src = "## Some Heading Here\n\nbody\n";
10967 let mut d = Doc::from_source(src.to_string(), Format::Djot).unwrap();
10968 let exact = d.locate("Some-Heading-Here").expect("djot's own spelling");
10969 let slugged = d.locate("some-heading-here").expect("the link's spelling");
10970 assert_eq!(exact, slugged);
10971 // The section, not the heading line — there is more to show than a title.
10972 assert_eq!(&d.source[exact.start..exact.end], src);
10973 }
10974
10975 #[test]
10976 fn locate_ignores_an_empty_locator_and_one_that_slugs_to_nothing() {
10977 let mut d = doc_with("locate_empty", "# Title\n\nbody\n");
10978 assert_eq!(d.locate(""), None);
10979 assert_eq!(d.locate(" "), None);
10980 // All punctuation: it names nothing, and must not be read as "match the
10981 // first heading whose slug is also empty".
10982 assert_eq!(d.locate("!!!"), None);
10983 }
10984
10985 #[test]
10986 fn locate_gives_a_duplicated_id_to_the_first_block_that_claims_it() {
10987 // The document's mistake, and the answer every other anchor
10988 // implementation gives — the alternative is for a link to mean whichever
10989 // of the two a walk happened to reach first.
10990 let src = "{#dup}\nfirst.\n\n{#dup}\nsecond.\n";
10991 let mut d = Doc::from_source(src.to_string(), Format::Djot).unwrap();
10992 let hit = d.locate("dup").expect("the first `{#dup}`");
10993 assert_eq!(d.source[hit.start..hit.end].trim_end(), "first.");
10994 }
10995
10996 #[test]
10997 fn insert_footnote_writes_both_halves_and_lands_the_caret_in_the_note() {
10998 // The button's whole job: a reference where the caret was, a definition
10999 // to give it meaning, and the caret waiting in the empty note so the
11000 // next keystroke is the note's first word.
11001 let mut d = doc_with("fn_insert", "A claim and more.\n");
11002 d.caret = 7; // just past "A claim"
11003 d.insert_footnote();
11004 assert!(
11005 d.source.starts_with("A claim[^1] and more."),
11006 "{:?}",
11007 d.source
11008 );
11009 assert!(
11010 d.source.contains("[^1]:"),
11011 "the definition too: {:?}",
11012 d.source
11013 );
11014 assert_eq!(d.status, None);
11015
11016 let reference = d.source.find("[^1]").unwrap();
11017 let note = d
11018 .footnote_at(reference + 2)
11019 .expect("the reference just written");
11020 assert_eq!(note.label, "1");
11021 assert_eq!(note.text.as_deref(), Some(""), "the note starts empty");
11022 assert_eq!(Some(d.caret), note.offset, "the caret waits in the note");
11023 // …and typing there is typing into the note, not near it.
11024 d.insert("the note");
11025 assert_eq!(
11026 d.footnote_at(reference + 2).and_then(|f| f.text),
11027 Some("the note".to_string())
11028 );
11029 }
11030
11031 #[test]
11032 fn insert_footnote_numbers_past_the_notes_already_written() {
11033 // A second press must not hand back a label somebody else is using: twig
11034 // reuses a defined label rather than appending a rival definition, so a
11035 // repeat of `1` would quietly point the new reference at the old note.
11036 let mut d = doc_with("fn_insert_number", "One[^1] two.\n\n[^1]: first\n");
11037 d.caret = 7; // past `[^1]`, before " two."
11038 d.insert_footnote();
11039 assert!(d.source.starts_with("One[^1][^2] two."), "{:?}", d.source);
11040 assert_eq!(d.source.matches("[^2]:").count(), 1);
11041 }
11042
11043 #[test]
11044 fn insert_footnote_counts_a_dangling_reference_and_ignores_a_named_one() {
11045 // `[^2]` with no definition is still a 2 that means something to whoever
11046 // wrote it — stepping over it would mint a note for their reference. A
11047 // word label takes no number, so it blocks none.
11048 let mut d = doc_with("fn_insert_dangling", "a[^2] b[^why] c\n\n[^why]: named\n");
11049 d.caret = d.source.find(" c").unwrap();
11050 d.insert_footnote();
11051 assert!(d.source.contains("[^1]:"), "1 is free: {:?}", d.source);
11052 assert!(
11053 d.source.starts_with("a[^2] b[^why][^1] c"),
11054 "{:?}",
11055 d.source
11056 );
11057 }
11058
11059 #[test]
11060 fn insert_footnote_marks_the_selection_rather_than_replacing_it() {
11061 // A reference annotates the words before it. Consuming the selection —
11062 // which is what an insert normally does — would delete the very claim
11063 // the author selected in order to footnote.
11064 let mut d = doc_with("fn_insert_sel", "A claim and more.\n");
11065 d.anchor = Some(2);
11066 d.caret = 7; // "claim" selected
11067 d.insert_footnote();
11068 assert!(
11069 d.source.starts_with("A claim[^1] and more."),
11070 "{:?}",
11071 d.source
11072 );
11073 }
11074
11075 #[test]
11076 fn a_note_just_written_still_knows_where_its_reference_is() {
11077 // The authoring loop in one test: press the button, type the note, ask to
11078 // go back. The caret ends at the note's last byte — which is the *end* of
11079 // the definition's span, the one offset the query used to exclude — so
11080 // this is where the round trip either works or doesn't.
11081 let mut d = doc_with("fn_insert_return", "A claim and more.\n");
11082 d.caret = 7;
11083 d.insert_footnote();
11084 d.insert("the note");
11085 assert_eq!(d.source, "A claim[^1] and more.\n\n[^1]: the note\n");
11086 let back = d
11087 .footnote_definition_at_caret()
11088 .expect("still in the note we just typed");
11089 assert_eq!(back.label, "1");
11090 // …and following it lands on the reference's label, where a reader's
11091 // return leg lands.
11092 assert_eq!(back.offset, Some(9));
11093 assert_eq!(&d.source[9..10], "1");
11094 }
11095
11096 #[test]
11097 fn insert_footnote_takes_one_undo_for_both_halves() {
11098 // twig writes the pair as a single edit; the point of that is here.
11099 let before = "A claim and more.\n";
11100 let mut d = doc_with("fn_insert_undo", before);
11101 d.caret = 7;
11102 d.insert_footnote();
11103 assert_ne!(d.source, before);
11104 d.undo();
11105 assert_eq!(d.source, before, "one undo takes back both halves");
11106 }
11107
11108 #[test]
11109 fn insert_footnote_refuses_a_format_that_cannot_spell_one() {
11110 // HTML is authorable — it spells the inline marks — and has no footnote.
11111 // The refusal says so rather than writing brackets that would render as
11112 // brackets.
11113 let src = "<p>A claim.</p>\n";
11114 let mut d = Doc::from_source(src.to_string(), Format::Html).unwrap();
11115 assert!(!Capabilities::of(Format::Html).footnote);
11116 d.caret = 5;
11117 d.insert_footnote();
11118 assert_eq!(d.source, src, "nothing written");
11119 assert!(d.status.is_some_and(|s| s.starts_with("footnote:")));
11120 }
11121
11122 #[test]
11123 fn insert_footnote_leaves_the_caret_on_a_real_stop_in_the_rich_view() {
11124 // The empty body is the one place this could go wrong: the definition
11125 // renders as a `[1] ` marker the caret cannot occupy, so a caret aimed a
11126 // byte early would draw up in the paragraph above the note it belongs to.
11127 let mut d = doc_in(View::Wysiwyg, "fn_insert_stop", "A claim and more.\n");
11128 d.place_caret(7, false);
11129 d.insert_footnote();
11130 d.build_visual(80); // the frame a frontend draws after the edit
11131 assert_eq!(
11132 d.vmap.snap_to_stop(d.caret),
11133 d.caret,
11134 "the caret sits on a stop"
11135 );
11136 let (row, _) = d.caret_pos();
11137 assert!(
11138 drawn_rows(&d)[row].contains("[1]"),
11139 "the caret is on the note's row, not above it: {:?}",
11140 drawn_rows(&d)
11141 );
11142 }
11143
11144 #[test]
11145 fn footnote_at_caret_resolves_a_reference_to_its_note() {
11146 // `[^1]` spans 7..11; its label byte is at 9. The definition follows a
11147 // blank line, as one has to.
11148 let mut d = doc_with("fn_at_caret", "A claim[^1] and more.\n\n[^1]: the note\n");
11149 d.caret = 9;
11150 let f = d
11151 .footnote_at_caret()
11152 .expect("the caret stands in a reference");
11153 assert_eq!(f.label, "1");
11154 assert_eq!(f.text.as_deref(), Some("the note"));
11155 // The offset points at the note's first word, not at the definition's
11156 // `[` — the marker is decoration with no caret stop on it.
11157 assert_eq!(f.offset, Some(29));
11158 assert_eq!(&d.source[29..37], "the note");
11159 // …and `end` closes the range, so a frontend can ask which rendered rows
11160 // the note occupies rather than re-deriving them from the text.
11161 assert_eq!(f.end, Some(37));
11162 assert_eq!(&d.source[f.offset.unwrap()..f.end.unwrap()], "the note");
11163 }
11164
11165 /// Two definitions in a row: each is its own note, and neither reaches into
11166 /// the other.
11167 ///
11168 /// A djot definition's span used to run past the blank line into the first
11169 /// byte of whatever followed, so this answered `"first note.\n\n["` — and the
11170 /// offsets named the *next* note's rows too, showing a reader two footnotes
11171 /// when they had asked about one. twig 3.1 ends the span after the block's
11172 /// own last line; the test outlives the workaround leaf carried for it.
11173 #[test]
11174 fn footnote_at_stops_a_note_at_the_definition_after_it() {
11175 let src = "Claim[^2a] and [^2b].\n\n[^2a]: first note.\n\n[^2b]: second note.\n";
11176 for format in [Format::Markdown, Format::Djot] {
11177 let mut d = Doc::from_source(src.to_string(), format).unwrap();
11178 d.caret = 7;
11179 let f = d.footnote_at_caret().expect("a reference");
11180 assert_eq!(f.text.as_deref(), Some("first note."), "in {format:?}");
11181 assert_eq!(
11182 &src[f.offset.unwrap()..f.end.unwrap()],
11183 "first note.",
11184 "in {format:?}"
11185 );
11186 }
11187 }
11188
11189 /// The other side of that boundary: a blank line *inside* a definition is
11190 /// interior to it, and the note keeps its second paragraph.
11191 ///
11192 /// This is what the old body scan cost. It stopped at the first line not
11193 /// indented under the note — a blank line is not — so a two-paragraph note
11194 /// came back as its first paragraph, and "go to note" framed half of it.
11195 /// Reading the span twig gives is both simpler and right.
11196 #[test]
11197 fn footnote_at_keeps_a_notes_second_paragraph() {
11198 let src = "Claim[^1].\n\n[^1]: first para.\n\n second para.\n\nAfter.\n";
11199 let mut d = Doc::from_source(src.to_string(), Format::Djot).unwrap();
11200 d.caret = 7;
11201 let f = d.footnote_at_caret().expect("a reference");
11202 assert_eq!(f.text.as_deref(), Some("first para.\n\n second para."));
11203 // And it stops there — `After.` is the next block, not more note.
11204 assert_eq!(
11205 &src[f.offset.unwrap()..f.end.unwrap()],
11206 f.text.as_deref().unwrap()
11207 );
11208 assert!(!f.text.as_deref().unwrap().contains("After"));
11209 }
11210
11211 #[test]
11212 fn footnote_at_bounds_a_note_whose_body_is_empty() {
11213 // `[^1]:` with nothing after it. The range is empty rather than
11214 // inverted, and still points inside the definition — which is what keeps
11215 // a frontend's row lookup from walking off into the block above.
11216 let src = "A claim[^1].\n\n[^1]:\n";
11217 let mut d = doc_with("fn_empty_body", src);
11218 d.caret = 9;
11219 let f = d.footnote_at_caret().expect("a reference");
11220 assert_eq!(f.text.as_deref(), Some(""));
11221 assert_eq!(f.offset, f.end, "an empty note is an empty range");
11222 assert!(f.offset.unwrap() >= src.find("[^1]:").unwrap());
11223 }
11224
11225 #[test]
11226 fn footnote_at_caret_ignores_a_caret_that_stands_in_no_reference() {
11227 let mut d = doc_with(
11228 "fn_at_caret_none",
11229 "A claim[^1] and more.\n\n[^1]: the note\n",
11230 );
11231 d.caret = 2; // in the prose
11232 assert_eq!(d.footnote_at_caret(), None);
11233 }
11234
11235 #[test]
11236 fn footnote_at_caret_is_not_a_link_query_and_vice_versa() {
11237 // The two are deliberately separate: a reference names a note in this
11238 // document, a link names somewhere to leave for, and answering one with
11239 // the other is what made a reference click do nothing at all.
11240 let mut d = doc_with("fn_vs_link", "a[^1] b [t](https://x.dev)\n\n[^1]: note\n");
11241 d.caret = 3; // the `1` of `[^1]`
11242 assert!(d.footnote_at_caret().is_some());
11243 assert_eq!(
11244 d.link_destination_at_caret(),
11245 None,
11246 "a reference is not a link"
11247 );
11248
11249 d.caret = 10; // inside the link's label
11250 assert_eq!(d.footnote_at_caret(), None, "a link is not a reference");
11251 assert_eq!(
11252 d.link_destination_at_caret().as_deref(),
11253 Some("https://x.dev")
11254 );
11255 }
11256
11257 #[test]
11258 fn footnote_at_caret_reports_an_undefined_reference_rather_than_nothing() {
11259 // A `[^99]` the document never defines is a real state — a note deleted
11260 // out from under its reference — and the label is what lets a frontend
11261 // say so. `None` here would be indistinguishable from "not on a
11262 // reference", which is the wrong thing to tell a reader.
11263 let mut d = doc_with("fn_undefined", "A claim[^99] and more.\n");
11264 d.caret = 9;
11265 let f = d
11266 .footnote_at_caret()
11267 .expect("the reference is still a reference");
11268 assert_eq!(f.label, "99");
11269 assert_eq!(f.text, None);
11270 assert_eq!(f.offset, None);
11271 }
11272
11273 #[test]
11274 fn footnote_at_caret_reads_a_word_label_and_a_multiline_note() {
11275 // Labels are not always numbers, and a note's body runs past its first
11276 // line — the indented continuation belongs to the note, so it comes back
11277 // with it (source bytes, verbatim, as documented).
11278 let src = "see[^note] here\n\n[^note]: first line\n second line\n";
11279 let mut d = doc_with("fn_word_label", src);
11280 d.caret = 6;
11281 let f = d
11282 .footnote_at_caret()
11283 .expect("the caret stands in a reference");
11284 assert_eq!(f.label, "note");
11285 assert_eq!(f.text.as_deref(), Some("first line\n second line"));
11286 }
11287
11288 #[test]
11289 fn footnote_at_answers_for_an_offset_the_caret_is_nowhere_near() {
11290 // The point of the offset form: a pointer hovering a reference asks what
11291 // note it names, and must not drag the caret along to ask.
11292 let mut d = doc_with("fn_at_off", "A claim[^1] and more.\n\n[^1]: the note\n");
11293 d.caret = 0;
11294 let f = d.footnote_at(9).expect("offset 9 stands in the reference");
11295 assert_eq!(f.label, "1");
11296 assert_eq!(f.text.as_deref(), Some("the note"));
11297 assert_eq!(d.caret, 0, "asking must not move the caret");
11298 assert_eq!(d.footnote_at(2), None, "offset 2 is prose");
11299 }
11300
11301 #[test]
11302 fn footnote_definition_at_caret_points_back_at_the_reference() {
11303 // The return leg. `[^1]` spans 7..11, so its label — the only byte of it
11304 // the caret can rest on — is at 9.
11305 let mut d = doc_with("fn_def", "A claim[^1] and more.\n\n[^1]: the note\n");
11306 d.caret = 30; // inside the note's body
11307 let f = d
11308 .footnote_definition_at_caret()
11309 .expect("the caret stands in a definition");
11310 assert_eq!(f.label, "1");
11311 assert_eq!(f.offset, Some(9));
11312 assert_eq!(&d.source[7..11], "[^1]");
11313 }
11314
11315 #[test]
11316 fn footnote_definition_at_covers_where_a_go_to_note_actually_lands() {
11317 // The two legs have to meet: wherever `footnote_at` sends the caret, the
11318 // definition query must answer for — otherwise arriving at a note leaves
11319 // the reader somewhere the way back isn't offered.
11320 let src = "A claim[^1] and more.\n\n[^1]: the note\n";
11321 let mut d = doc_with("fn_def_marker", src);
11322 let landed = d.footnote_at(9).unwrap().offset.unwrap();
11323 assert_eq!(
11324 d.footnote_definition_at(landed).and_then(|f| f.offset),
11325 Some(9),
11326 "the note a reference sends you to offers the way back"
11327 );
11328 }
11329
11330 #[test]
11331 fn footnote_definition_at_caret_ignores_prose_and_the_reference_itself() {
11332 // The two queries answer for disjoint places, which is what lets one
11333 // gesture mean "down to the note" in one and "back up" in the other
11334 // without either having to remember which way the reader is going.
11335 let mut d = doc_with("fn_def_none", "A claim[^1] and more.\n\n[^1]: the note\n");
11336 d.caret = 2; // prose
11337 assert_eq!(d.footnote_definition_at_caret(), None);
11338 d.caret = 9; // the reference
11339 assert_eq!(d.footnote_definition_at_caret(), None);
11340 assert!(
11341 d.footnote_at_caret().is_some(),
11342 "which is the reference's own query"
11343 );
11344 }
11345
11346 #[test]
11347 fn footnote_definition_at_caret_reports_an_orphan_note_rather_than_nothing() {
11348 // Nothing cites `[^2]`. Answering `None` would say "you are not in a
11349 // note", which is false and leaves a frontend unable to explain why the
11350 // way back is missing.
11351 let src = "A claim[^1].\n\n[^1]: cited\n\n[^2]: orphan\n";
11352 let mut d = doc_with("fn_def_orphan", src);
11353 d.caret = src.find("orphan").unwrap();
11354 let f = d
11355 .footnote_definition_at_caret()
11356 .expect("an orphan is still a definition");
11357 assert_eq!(f.label, "2");
11358 assert_eq!(f.offset, None);
11359 }
11360
11361 #[test]
11362 fn footnote_definition_at_caret_returns_to_the_first_of_repeated_references() {
11363 // One label, cited twice. The first is where the reader most likely came
11364 // from, and the only answer that doesn't depend on how they got here.
11365 let src = "One[^a] and two[^a].\n\n[^a]: the note\n";
11366 let mut d = doc_with("fn_def_repeat", src);
11367 d.caret = src.find("the note").unwrap();
11368 let f = d.footnote_definition_at_caret().expect("a definition");
11369 assert_eq!(
11370 f.offset,
11371 Some(5),
11372 "the first `[^a]`'s label, not the second's"
11373 );
11374 assert_eq!(&src[3..7], "[^a]");
11375 }
11376
11377 #[test]
11378 fn footnote_navigation_is_a_round_trip_through_placed_carets() {
11379 // Down and back up, each leg found from the document rather than from a
11380 // memory of the other — so it still works for a reader who scrolled to
11381 // the notes instead of jumping there.
11382 //
11383 // `place_caret` rather than assigning `caret`, because that is what a
11384 // frontend calls: it snaps to a real caret stop, and a jump that lands
11385 // on a byte the caret can't rest on would arrive somewhere the return
11386 // leg no longer answers for. `build_map` first, since snapping is a
11387 // no-op until the map exists — which is exactly how this went unnoticed
11388 // when the offsets pointed at the `[^` markers.
11389 let mut d = doc_with("fn_round", "A claim[^1] and more.\n\n[^1]: the note\n");
11390 d.build_map(None);
11391 d.place_caret(9, false);
11392 let down = d
11393 .footnote_at_caret()
11394 .expect("a reference")
11395 .offset
11396 .expect("a note");
11397 d.place_caret(down, false);
11398 let up = d
11399 .footnote_definition_at_caret()
11400 .expect("a definition")
11401 .offset
11402 .expect("a reference");
11403 d.place_caret(up, false);
11404 assert_eq!(d.caret, up, "the way back is a stop the caret can occupy");
11405 assert_eq!(
11406 d.footnote_at_caret().expect("back on the reference").label,
11407 "1"
11408 );
11409 }
11410
11411 #[test]
11412 fn insert_link_hands_the_destination_to_twig_raw() {
11413 // Escaping is twig's, and format-specific: Markdown ends a destination
11414 // at the first space and needs the `<…>` form, where djot would read
11415 // those angle brackets as part of the URL.
11416 let mut d = doc_with("link_space", "word\n");
11417 d.anchor = Some(0);
11418 d.caret = 4;
11419 d.insert_link("a b");
11420 assert_eq!(d.source, "[word](<a b>)\n");
11421 }
11422
11423 #[test]
11424 fn insert_link_reports_a_destination_no_format_can_carry() {
11425 let mut d = doc_with("link_bad", "word\n");
11426 d.anchor = Some(0);
11427 d.caret = 4;
11428 d.insert_link("a\nb");
11429 assert_eq!(d.source, "word\n"); // untouched, not quietly rewritten
11430 assert!(
11431 d.status.is_some(),
11432 "InvalidArgument should reach the status line"
11433 );
11434 assert!(!d.dirty);
11435 }
11436
11437 #[test]
11438 fn insert_link_works_in_wysiwyg_view() {
11439 let mut d = wysiwyg_doc("link_wys", "word here\n");
11440 d.anchor = Some(0);
11441 d.caret = 4;
11442 d.insert_link("http://x.dev");
11443 assert_eq!(d.source, "[word](http://x.dev) here\n");
11444 assert_eq!(d.selected_text(), Some("word"));
11445 // The map the caret has to keep riding is rebuilt each frame; motion
11446 // over the fresh one must still land on a real stop (the debug_assert).
11447 d.build_visual(80);
11448 d.move_right(false);
11449 d.move_left(false);
11450 }
11451
11452 #[test]
11453 fn click_maps_a_row_col_to_a_byte_offset() {
11454 let mut d = doc_with("click", "ab\ncd\n");
11455 d.click(1, 1, false); // row 1 ("cd"), col 1 -> the 'd'
11456 assert_eq!(d.caret, 4);
11457 }
11458
11459 // A pixel-hit-test placement (the GUI's `place_caret`) must land on a caret
11460 // stop just as the `(row, col)` click path does, so the caret can never come
11461 // to rest in the blank gap between two paragraphs — where it would draw in one
11462 // place and type in another.
11463 #[test]
11464 fn place_caret_snaps_out_of_the_blank_gap_between_paragraphs() {
11465 // "A\n\nB": offset 2 is the gap the paragraph break is drawn with, not a
11466 // caret stop (stops are 0,1,3,4).
11467 let mut d = wysiwyg_doc("place_gap", "A\n\nB");
11468 assert!(!d.vmap.is_stop(2), "offset 2 should be an unreachable gap");
11469 d.place_caret(2, false);
11470 assert!(d.vmap.is_stop(d.caret), "caret {} is not a stop", d.caret);
11471 assert_eq!(d.caret, 1, "should snap to the end of the paragraph above");
11472 }
11473
11474 #[test]
11475 fn place_caret_dragging_through_the_gap_keeps_selection_on_stops() {
11476 let mut d = wysiwyg_doc("place_gap_drag", "A\n\nB");
11477 d.place_caret(0, false); // anchor at the start of "A"
11478 d.place_caret(2, true); // drag into the gap
11479 assert!(d.vmap.is_stop(d.caret), "caret {} is not a stop", d.caret);
11480 let (s, e) = d.selection().expect("a selection");
11481 assert!(
11482 d.vmap.is_stop(s) && d.vmap.is_stop(e),
11483 "selection {s}..{e} off a stop"
11484 );
11485 }
11486
11487 #[test]
11488 fn place_caret_on_a_real_stop_is_left_untouched() {
11489 let mut d = wysiwyg_doc("place_stop", "A\n\nB");
11490 d.place_caret(3, false); // the start of "B" — a genuine stop
11491 assert_eq!(d.caret, 3);
11492 }
11493
11494 // An *empty paragraph* (two blank lines, an intentional blank line the user
11495 // opened) is a real caret stop, unlike the gap — a click into it must stay.
11496 #[test]
11497 fn place_caret_rests_in_an_empty_paragraph() {
11498 let mut d = wysiwyg_doc("place_empty_para", "A\n\n\n\nB");
11499 let empty = 3; // the navigable empty row's offset (stops: 0,1,3,5,6)
11500 assert!(d.vmap.is_stop(empty));
11501 d.place_caret(empty, false);
11502 assert_eq!(d.caret, empty);
11503 }
11504
11505 // The content end of a hidden mark is a home too (`VisualMap::mark_ends`):
11506 // a drag over the word `bold` ends there, and a caret placed there stays.
11507 #[test]
11508 fn place_caret_rests_at_the_end_of_a_hidden_marks_content() {
11509 let src = "| A | B |\n| --- | --- |\n| **bold** | other |\n";
11510 let mut d = wysiwyg_doc("place_mark_end", src);
11511 let start = src.find("bold").unwrap();
11512 d.place_caret(start, false);
11513 d.place_caret(start + 4, true);
11514 assert_eq!(d.selection(), Some((start, start + 4)), "the whole word");
11515 d.toggle(InlineKind::Strong);
11516 assert_eq!(d.source, src.replace("**bold**", "bold"));
11517 }
11518
11519 #[test]
11520 fn right_steps_onto_the_end_of_a_mark_and_then_past_its_delimiter() {
11521 let mut d = wysiwyg_doc("right_mark_end", "a **bold** b");
11522 d.caret = 7; // before the `d`
11523 d.move_right(false);
11524 assert_eq!(d.caret, 8, "onto the end of the bold");
11525 assert!(d.active_inline_marks().contains(InlineKind::Strong));
11526 d.move_right(false);
11527 assert_eq!(d.caret, 10, "past the closing `**`");
11528 assert!(!d.active_inline_marks().contains(InlineKind::Strong));
11529 d.move_left(false);
11530 assert_eq!(d.caret, 8);
11531 d.move_left(false);
11532 assert_eq!(d.caret, 7);
11533 // Typing at the inner home extends the bold.
11534 d.caret = 8;
11535 d.insert("!");
11536 assert_eq!(d.source, "a **bold!** b");
11537 }
11538
11539 #[test]
11540 fn a_marks_end_home_follows_an_edit_through_the_incremental_map() {
11541 // The splice path shifts the home with the block it is in, and the
11542 // re-rendered block finds its own again.
11543 let mut d = wysiwyg_doc("mark_end_splice", "x\n\na **bold** b\n\ny\n");
11544 d.build_visual_unwrapped();
11545 d.edit(0, 0, "zz");
11546 d.build_visual_unwrapped();
11547 wysiwyg::assert_maps_eq(&d.vmap, &reference_map(&d.source), "after a shift");
11548 assert!(d.vmap.is_stop(d.source.find("bold").unwrap() + 4));
11549 let at = d.source.find("bold").unwrap();
11550 d.edit(at, at, "very ");
11551 d.build_visual_unwrapped();
11552 wysiwyg::assert_maps_eq(&d.vmap, &reference_map(&d.source), "after a re-render");
11553 assert!(d.vmap.is_stop(d.source.find("bold").unwrap() + 4));
11554 }
11555
11556 fn wysiwyg_doc(name: &str, body: &str) -> Doc {
11557 doc_in(View::Wysiwyg, name, body)
11558 }
11559
11560 /// How many list items the source actually parses into — the check that a
11561 /// marker Leaf wrote is a marker the format agrees is one.
11562 fn list_items(doc: &mut Doc) -> usize {
11563 doc.editor
11564 .nodes()
11565 .unwrap()
11566 .iter()
11567 .filter(|n| n.kind == Kind::ListItem || n.kind == Kind::TaskListItem)
11568 .count()
11569 }
11570
11571 /// A from-scratch, cache-free WYSIWYG map for `source` — the ground truth the
11572 /// incremental (`build_spliced` / `build_cached`) path must always match.
11573 fn reference_map(source: &str) -> crate::wysiwyg::VisualMap {
11574 reference_map_revealing(source, None)
11575 }
11576
11577 /// [`reference_map`] with a reveal line — the ground truth for the
11578 /// `MarkupMode::Full` builds, where the map is a function of the caret's
11579 /// line as well as the text.
11580 fn reference_map_revealing(source: &str, reveal: Option<Reveal>) -> crate::wysiwyg::VisualMap {
11581 // The same parse `Doc` uses. With twig's plain defaults instead, the two
11582 // sides disagree on what the *document* is before the renderer is even
11583 // reached — a bare `:word` is a text directive to one and prose to the
11584 // other — and the mismatch reads as a splice bug that isn't one.
11585 let mut ed =
11586 twig::Editor::new_ext(source.as_bytes(), Format::Markdown, parse_extensions()).unwrap();
11587 let nodes = ed.nodes().unwrap();
11588 crate::wysiwyg::build(
11589 &nodes,
11590 source,
11591 None,
11592 false,
11593 &wysiwyg::Surface::default(),
11594 reveal,
11595 )
11596 }
11597
11598 fn maps_differ(a: &crate::wysiwyg::VisualMap, b: &crate::wysiwyg::VisualMap) -> bool {
11599 if a.rows.len() != b.rows.len() {
11600 return true;
11601 }
11602 for (ra, rb) in a.rows.iter().zip(&b.rows) {
11603 if ra.end_src != rb.end_src || ra.glyphs.len() != rb.glyphs.len() {
11604 return true;
11605 }
11606 for (ga, gb) in ra.glyphs.iter().zip(&rb.glyphs) {
11607 if ga.ch != gb.ch || ga.src != gb.src {
11608 return true;
11609 }
11610 }
11611 }
11612 false
11613 }
11614
11615 #[test]
11616 fn incremental_build_matches_a_fresh_build_across_edits() {
11617 // Every `Doc` edit rebuilds through `build_spliced` (the single-block
11618 // fast path, gated on twig's `dirty_range`) or falls back to
11619 // `build_cached`. After each edit the map must be byte-identical to a
11620 // from-scratch build — this is the correctness net under the splice.
11621 let docs = [
11622 "# Title\n\nThe quick brown fox jumps.\n\nAnother paragraph here.\n\n- a\n- b\n",
11623 "para one\n\n> quote **bold** text\n> continued line\n\ntail paragraph\n",
11624 "alpha\n\nbeta\n\ngamma\n\ndelta\n\nepsilon\n\nzeta\n",
11625 // A footnote definition is a root beside `doc`, merged back into the
11626 // top-level list by `wysiwyg::top_blocks`. The random edits below
11627 // make and unmake definitions as they go (a deleted `:` turns one
11628 // back into a paragraph, and vice versa), which is exactly the
11629 // structural churn the splice path has to notice and bail out of.
11630 "text[^1] here\n\n[^1]: the note\n\nmore text[^b]\n\n[^b]: second\n",
11631 // A comment is a top-level block that draws no rows — a layout entry
11632 // at zero rows either side of blocks that do. The edits below type
11633 // into the blocks around it (a splice past a hidden block), and
11634 // break the comment open into prose and back (a structural change).
11635 "intro\n\n<!-- exec -->\n```\ncode\n```\n\nafter the comment\n\n<!-- trail -->\n",
11636 // Link reference definitions: a hidden block that an edit can turn
11637 // into a paragraph (a deleted `:`) and back, and whose own bytes an
11638 // edit can land in.
11639 "see [a] and [b]\n\n[a]: /a\n\nmid text\n\n[b]: /b\n",
11640 ];
11641 // A deterministic mix: mostly single characters (which stay inside one
11642 // block → splice), plus edits that reshape structure (a paragraph break,
11643 // a heading marker, a code fence → fallback), so both paths are exercised.
11644 let inserts = ["x", "y", "\n\n", "#", "`", " ", "z"];
11645 for src in docs {
11646 let mut d = wysiwyg_doc("diff", src);
11647 d.build_visual_unwrapped();
11648 wysiwyg::assert_maps_eq(&d.vmap, &reference_map(&d.source), "initial");
11649
11650 for step in 0..60usize {
11651 let len = d.source.len();
11652 let raw = (step * 13 + 5) % (len + 1);
11653 let pos = (raw..=len).find(|&i| d.source.is_char_boundary(i)).unwrap();
11654 let pre = d.source.clone();
11655 let action;
11656 if step % 3 == 0 && pos < len {
11657 let end = (pos + 1..=len)
11658 .find(|&i| d.source.is_char_boundary(i))
11659 .unwrap();
11660 action = format!("delete [{pos},{end})");
11661 d.edit(pos, end, "");
11662 } else {
11663 let ins = inserts[step % inserts.len()];
11664 action = format!("insert {ins:?} @ {pos}");
11665 d.edit(pos, pos, ins);
11666 }
11667 d.build_visual_unwrapped();
11668 if maps_differ(&d.vmap, &reference_map(&d.source)) {
11669 panic!(
11670 "FIRST MISMATCH at step {step}: {action}\n pre = {pre:?}\n post = {:?}",
11671 d.source
11672 );
11673 }
11674 }
11675 }
11676 }
11677
11678 /// A frontend is handed [`Doc::vmap`] and may present it differently:
11679 /// leaf-ratatui splices blank filler rows under an oversized heading so the
11680 /// raster it paints there has somewhere to stand, and leaves them in the map
11681 /// because the caret and the mouse both read it between frames. The splice
11682 /// path addresses that map by *row index*, against the block layout the last
11683 /// build recorded — so handed a map with rows in it that no block owns, it
11684 /// laid the re-rendered block over one of the fillers and carried the rows
11685 /// the block really occupied into the suffix. One stranded copy of the
11686 /// edited line, and everything below it a row further down, per keystroke.
11687 ///
11688 /// A map that isn't the one the layout describes is a map this path can't
11689 /// patch, whoever changed it and for whatever reason. It rebuilds instead.
11690 #[test]
11691 fn an_edit_over_a_map_a_frontend_reshaped_rebuilds_it_whole() {
11692 let mut d = wysiwyg_doc("reshaped", "# Title\n\nThe quick brown fox jumps.\n");
11693 d.build_visual_unwrapped();
11694
11695 // Stand in for the heading filler rows: two blank rows past the heading
11696 // that no block accounts for. Cloning a real row keeps every field
11697 // plausible — it is the row *count* the splice can't survive.
11698 let filler = d.vmap.rows[0].clone();
11699 d.vmap.rows.insert(1, filler.clone());
11700 d.vmap.rows.insert(1, filler);
11701
11702 // An edit inside the last block: the single-block case the splice path
11703 // is for, and the one the frontend hits on every keystroke.
11704 let at = d.source.len() - 1;
11705 d.edit(at, at, "!");
11706 d.build_visual_unwrapped();
11707
11708 wysiwyg::assert_maps_eq(&d.vmap, &reference_map(&d.source), "after the edit");
11709 }
11710
11711 /// A glyph's [`FaceId`] has to mean the same thing however its row was
11712 /// built. A row comes three ways — a fresh walk, a [`BlockCache`] hit
11713 /// cloned at a shifted offset, and a previous map's rows a splice kept
11714 /// untouched — and only the first of those walks a `data-font` at all. An
11715 /// index into a per-build table would have had the same glyph naming two
11716 /// families the moment a second one appeared; the id is the name's own
11717 /// hash, so nothing is remapped and the table is merged rather than rebuilt.
11718 ///
11719 /// Two families, because one cannot tell a wrong id from a right one.
11720 ///
11721 /// [`FaceId`]: crate::style::FaceId
11722 /// [`BlockCache`]: crate::wysiwyg::BlockCache
11723 #[test]
11724 fn a_spliced_rebuild_still_says_which_family_each_glyph_is_set_in() {
11725 use crate::style::{FaceId, FaceRef};
11726 let garamond = FaceId::of("Garamond");
11727 let futura = FaceId::of("Futura");
11728 let mut d = wysiwyg_doc(
11729 "two_faces",
11730 "x <span data-font=\"Garamond\">alpha</span>\n\ny <span data-font=\"Futura\">beta</span>\n",
11731 );
11732 d.build_visual_unwrapped();
11733
11734 // What the map has to keep saying, whichever path built it.
11735 let check = |d: &Doc, ctx: &str| {
11736 let face_of = |ch: char| {
11737 d.vmap
11738 .rows
11739 .iter()
11740 .flat_map(|r| r.glyphs.iter())
11741 .find(|g| g.ch == ch)
11742 .map(|g| g.style.font)
11743 };
11744 assert_eq!(face_of('a'), Some(Some(FaceRef::Named(garamond))), "{ctx}");
11745 assert_eq!(face_of('b'), Some(Some(FaceRef::Named(futura))), "{ctx}");
11746 assert_eq!(d.vmap.face_name(garamond), Some("Garamond"), "{ctx}");
11747 assert_eq!(d.vmap.face_name(futura), Some("Futura"), "{ctx}");
11748 assert_eq!(d.vmap.face_name(FaceId::of("Bodoni")), None, "{ctx}");
11749 };
11750 check(&d, "fresh");
11751
11752 // An edit inside the second block: the single-block case the splice
11753 // path is for. The first block's rows are carried over untouched, so
11754 // its glyphs' ids are the previous build's and the table has to be too.
11755 let at = d.source.find("beta").unwrap();
11756 d.edit(at, at, "z");
11757 d.build_visual_unwrapped();
11758 check(&d, "after an edit in the second block");
11759 wysiwyg::assert_maps_eq(&d.vmap, &reference_map(&d.source), "spliced");
11760
11761 // And the other way round, so the block that was kept is the one that
11762 // is now re-rendered.
11763 let at = d.source.find("alpha").unwrap();
11764 d.edit(at, at, "z");
11765 d.build_visual_unwrapped();
11766 check(&d, "after an edit in the first block");
11767 wysiwyg::assert_maps_eq(&d.vmap, &reference_map(&d.source), "spliced again");
11768
11769 // A structural edit is one the splice bails out of, so the map is
11770 // reassembled by `build_cached` — where an untouched block is a *cache
11771 // hit* and its rows are cloned without a `data-font` being walked
11772 // again. The names the entry stored are what keeps the table honest
11773 // there.
11774 let at = d.source.find("\n\ny ").unwrap();
11775 d.edit(at, at, "\n\nmiddle");
11776 d.build_visual_unwrapped();
11777 wysiwyg::assert_maps_eq(&d.vmap, &reference_map(&d.source), "cached");
11778 // Twice, because that first `build_cached` is what stores the entries:
11779 // this one is the build where the Garamond block is a *hit*, its rows
11780 // cloned with their ids and no attribute walked to explain them.
11781 let at = d.source.len() - 1;
11782 d.edit(at, at, "\n\ntail");
11783 d.build_visual_unwrapped();
11784 check(&d, "after a structural edit, through the block cache");
11785 wysiwyg::assert_maps_eq(&d.vmap, &reference_map(&d.source), "cached again");
11786
11787 // A family the edit took the last glyph of leaves the glyphs with no
11788 // face and the table with a name nothing asks for — harmless, and the
11789 // price of not walking the rows the splice exists to avoid walking.
11790 let span = d.source.find("<span data-font=\"Futura\">").unwrap();
11791 let end = d.source.rfind("</span>").unwrap() + "</span>".len();
11792 d.edit(span, end, "beta");
11793 d.build_visual_unwrapped();
11794 assert!(!d.source.contains("Futura"), "{:?}", d.source);
11795 wysiwyg::assert_maps_eq(&d.vmap, &reference_map(&d.source), "the face removed");
11796 }
11797
11798 #[test]
11799 fn incremental_build_matches_a_fresh_build_under_full_reveal() {
11800 // The same correctness net as `incremental_build_matches_a_fresh_build_
11801 // across_edits`, under `MarkupMode::Full` — where the map depends on
11802 // the caret's *line* as well as the text, so the two caches have a new
11803 // way to be wrong. Both are exercised: the block cache can hand back
11804 // rows built for a line that is no longer the revealed one, and the
11805 // splice path can reuse a suffix that still has yesterday's line raw.
11806 //
11807 // Caret motion is interleaved with the edits deliberately, because a
11808 // caret that only ever moved with the edit would never cross a line
11809 // without also dirtying it — the case where a stale reveal survives.
11810 let docs = [
11811 "# Title\n\n*one* and **two**\n\n[lk](http://x) and `code`\n\n- a *b*\n",
11812 "para *em* one\n\n> quote **bold** text\n\ntail ~~del~~ paragraph\n",
11813 ];
11814 let inserts = ["x", "*", "\n\n", "#", "`", " ", "_"];
11815 for src in docs {
11816 let mut d = wysiwyg_doc("reveal_diff", src);
11817 d.set_markup_mode(MarkupMode::Full);
11818
11819 for step in 0..60usize {
11820 let len = d.source.len();
11821 let raw = (step * 13 + 5) % (len + 1);
11822 let pos = (raw..=len).find(|&i| d.source.is_char_boundary(i)).unwrap();
11823 let pre = d.source.clone();
11824 let action;
11825 if step % 3 == 0 && pos < len {
11826 let end = (pos + 1..=len)
11827 .find(|&i| d.source.is_char_boundary(i))
11828 .unwrap();
11829 action = format!("delete [{pos},{end})");
11830 d.edit(pos, end, "");
11831 } else {
11832 let ins = inserts[step % inserts.len()];
11833 action = format!("insert {ins:?} @ {pos}");
11834 d.edit(pos, pos, ins);
11835 }
11836 // Walk the caret somewhere else in the document, independently
11837 // of where the edit landed.
11838 let want = (step * 29 + 11) % (d.source.len() + 1);
11839 d.caret = (want..=d.source.len())
11840 .find(|&i| d.source.is_char_boundary(i))
11841 .unwrap();
11842 d.build_visual_unwrapped();
11843
11844 let want = reference_map_revealing(&d.source, d.reveal_line());
11845 if maps_differ(&d.vmap, &want) {
11846 panic!(
11847 "FIRST MISMATCH at step {step}: {action}, caret {}\n pre = {pre:?}\n post = {:?}",
11848 d.caret, d.source
11849 );
11850 }
11851 }
11852 }
11853 }
11854
11855 #[test]
11856 fn caret_motion_across_lines_rebuilds_only_under_full() {
11857 // The cache-key change has to earn its keep in both directions: `Full`
11858 // must rebuild when the caret changes line (or the reveal would never
11859 // move), and the hidden modes must *not* (or every arrow key would pay
11860 // for a feature they don't use). The existing `cache_motion` test pins
11861 // the second for the default mode; this pins the pair against a mode
11862 // change alone.
11863 let body = "*one* here\n\n*two* there\n";
11864
11865 let mut full = doc_in(View::Wysiwyg, "motion_full", body);
11866 full.set_markup_mode(MarkupMode::Full);
11867 caret_at(&mut full, "one");
11868 let before = full.revision();
11869 caret_at(&mut full, "two");
11870 assert_eq!(full.revision(), before, "motion is not an edit");
11871 assert!(
11872 drawn_rows(&full).iter().any(|r| r == "*two* there"),
11873 "the map followed the caret: {:?}",
11874 drawn_rows(&full)
11875 );
11876
11877 let mut hidden = doc_in(View::Wysiwyg, "motion_hidden", body);
11878 caret_at(&mut hidden, "one");
11879 let key = hidden.vmap_key.clone();
11880 caret_at(&mut hidden, "two");
11881 assert_eq!(
11882 hidden.vmap_key, key,
11883 "a hidden mode rebuilds nothing on motion"
11884 );
11885 }
11886
11887 #[test]
11888 fn wysiwyg_down_crosses_a_paragraph_boundary() {
11889 // Regression: the blank separator row used to share the previous
11890 // paragraph's end offset, so Down got pinned at the boundary (while Up
11891 // still crossed). Both directions must step through it symmetrically.
11892 //
11893 // It's now stepped *over* rather than onto: the blank line between two
11894 // paragraphs is the boundary being drawn, not a line of the document, so
11895 // one press of Down crosses it. The goal column survives the crossing —
11896 // col 3 at the end of "abc" is col 3 at the end of "def".
11897 let mut d = wysiwyg_doc("wys_down", "abc\n\ndef\n");
11898 d.caret = 3; // end of "abc" (row 0)
11899 d.move_down(false);
11900 assert_eq!(d.caret_pos().0, 2, "Down should reach the second paragraph");
11901 assert_eq!(d.caret, 8); // end of "def", col 3 kept
11902 d.move_up(false);
11903 assert_eq!(d.caret_pos().0, 0, "Up should come back symmetrically");
11904 assert_eq!(d.caret, 3);
11905 }
11906
11907 #[test]
11908 fn wysiwyg_up_and_down_are_inverse_across_paragraphs() {
11909 // The second Up and the second Down here run off the ends of the
11910 // document, which is no longer a place a press is swallowed: they carry
11911 // the caret to the start and the end of the text. The claim in the
11912 // middle — that a Down retraces the Up that crossed the paragraph gap —
11913 // is the one this test is for, and it is asserted where it is made.
11914 let mut d = wysiwyg_doc("wys_updown", "abc\n\ndef\n");
11915 d.caret = 5; // start of "def"
11916 let start = d.caret_pos();
11917 d.move_up(false);
11918 assert_eq!(d.caret_pos().0, 0, "Up reaches the first paragraph");
11919 d.move_up(false);
11920 assert_eq!(d.caret, 0, "a second Up runs on to the document's start");
11921 d.move_down(false);
11922 assert_eq!(d.caret_pos(), start, "Down retraces Up exactly");
11923 d.move_down(false);
11924 assert_eq!(d.caret, 8, "a second Down runs on to the document's end");
11925 }
11926
11927 #[test]
11928 fn wysiwyg_new_paragraph_shows_before_typing() {
11929 // Regression: two Enters at the end of a paragraph produced trailing
11930 // newlines with no AST node, so the caret appeared stuck on the old line
11931 // until a character was typed. It must ride down onto the new line now.
11932 let mut d = doc_with("wys_newpara", "abc\n");
11933 d.view = View::Wysiwyg;
11934 d.caret = 3;
11935 d.insert("\n");
11936 d.insert("\n"); // source is now "abc\n\n\n", caret at 5
11937 assert_eq!(d.source, "abc\n\n\n");
11938 d.build_visual(80);
11939 let (row, _) = d.caret_pos();
11940 assert!(
11941 row >= 2,
11942 "caret should have moved down to the new line, got row {row}"
11943 );
11944 assert!(
11945 d.vmap.num_rows() >= 3,
11946 "the blank lines should render as rows"
11947 );
11948 }
11949
11950 #[test]
11951 fn wysiwyg_enter_between_paragraphs_lands_on_an_empty_line() {
11952 // The reported bug: Enter at the end of a paragraph that has another
11953 // paragraph below put the caret at the *start of the next paragraph* —
11954 // the empty paragraph it opened had no row, so the caret snapped onto
11955 // "World". It must now sit on its own empty line, with a blank spacer
11956 // above it (the paragraph gap).
11957 let mut d = wysiwyg_doc("wys_gap_mid", "Hello\n\nWorld\n");
11958 d.caret = 5; // end of "Hello"
11959 d.newline();
11960 d.build_visual(80);
11961 let (row, col) = d.caret_pos();
11962 assert_eq!(col, 0, "caret should start an empty line, not sit in text");
11963 assert_eq!(
11964 d.vmap.row_width(row),
11965 0,
11966 "caret's row must be empty, not 'World'"
11967 );
11968 assert!(
11969 row >= 2,
11970 "a blank spacer row should sit above the caret, got row {row}"
11971 );
11972 // The row above the caret is a real (empty) gap, and "Hello" stays put.
11973 assert_eq!(
11974 d.vmap.row_width(row - 1),
11975 0,
11976 "the row above the caret is a gap"
11977 );
11978 let row0: String = d.vmap.rows[0].glyphs.iter().map(|g| g.ch).collect();
11979 assert_eq!(row0, "Hello", "the paragraph above the caret must not move");
11980 }
11981
11982 #[test]
11983 fn wysiwyg_enter_at_eof_shows_a_gap_before_typing() {
11984 // At the document end a single Enter must also show the paragraph gap —
11985 // a blank spacer row above the caret — so the layout already matches how
11986 // it will look once the new paragraph has text.
11987 let mut d = wysiwyg_doc("wys_gap_eof", "Hello");
11988 d.caret = 5; // end of "Hello", no trailing newline
11989 d.newline(); // source becomes "Hello\n\n"
11990 d.build_visual(80);
11991 let (row, col) = d.caret_pos();
11992 assert_eq!(col, 0);
11993 assert!(
11994 row >= 2,
11995 "caret should sit below a blank spacer, got row {row}"
11996 );
11997 assert_eq!(
11998 d.vmap.row_width(row - 1),
11999 0,
12000 "the row above the caret is a gap"
12001 );
12002 }
12003
12004 #[test]
12005 fn wysiwyg_typing_after_enter_does_not_shift_the_caret_row() {
12006 // The spacer is view-only: typing the new paragraph must not reflow the
12007 // caret onto a different row — the transient view already matched the
12008 // settled one.
12009 let mut d = wysiwyg_doc("wys_no_reflow", "Hello\n\nWorld\n");
12010 d.caret = 5;
12011 d.newline();
12012 d.build_visual(80);
12013 let before = d.caret_pos();
12014 d.insert("New");
12015 d.build_visual(80);
12016 let after = d.caret_pos();
12017 assert_eq!(
12018 after.0, before.0,
12019 "typing must not move the caret to another row ({before:?} -> {after:?})"
12020 );
12021 }
12022
12023 #[test]
12024 fn wysiwyg_return_on_the_last_code_line_keeps_the_caret_in_the_block() {
12025 // Return at the end of the block's last line writes an empty line the
12026 // map used to drop, so the caret landed on `after` and the next
12027 // keystroke went into the paragraph below instead of into the code.
12028 let mut d = wysiwyg_doc("code_return", "prose\n\n```\nalpha\nbeta\n```\n\nafter\n");
12029 d.caret = d.source.find("beta").unwrap() + "beta".len();
12030 d.build_visual(80);
12031 let before = d.caret_pos().0;
12032
12033 d.newline();
12034 d.build_visual(80);
12035 assert_eq!(d.source, "prose\n\n```\nalpha\nbeta\n\n```\n\nafter\n");
12036
12037 let (row, col) = d.caret_pos();
12038 assert_eq!(row, before + 1, "the caret moves down one row");
12039 assert_eq!(col, 0, "onto the head of the empty line");
12040 let span = d.vmap.code_blocks[0].rows_span.clone();
12041 assert!(
12042 span.contains(&row),
12043 "caret row {row} is outside the block's rows {span:?}"
12044 );
12045
12046 // The whole point: what is typed next is code.
12047 d.insert("gamma");
12048 assert_eq!(d.source, "prose\n\n```\nalpha\nbeta\ngamma\n```\n\nafter\n");
12049 }
12050
12051 #[test]
12052 fn wysiwyg_hides_frontmatter_from_the_caret_and_copy() {
12053 let fm = "---\ntitle: hi\n---\n";
12054 let body = format!("{fm}# leaf\n\nbody\n");
12055 let mut d = wysiwyg_doc("wys_fm", &body);
12056 // Opening lifts the caret out of the now-hidden frontmatter.
12057 assert_eq!(
12058 d.caret,
12059 fm.len(),
12060 "caret should start at the first real block"
12061 );
12062 // Left at the content start can't step back into frontmatter.
12063 d.move_left(false);
12064 assert_eq!(d.caret, fm.len(), "left must not enter frontmatter");
12065 // Doc-start lands on the content floor, not offset 0.
12066 d.move_doc_start(false);
12067 assert_eq!(d.caret, fm.len());
12068 // Select-all + copy never include the frontmatter bytes.
12069 d.select_all();
12070 let sel = d.selected_text().unwrap().to_string();
12071 assert!(!sel.contains("title"), "copy leaked frontmatter: {sel:?}");
12072 assert!(
12073 sel.starts_with("# leaf"),
12074 "selection should begin at content: {sel:?}"
12075 );
12076 }
12077
12078 #[test]
12079 fn typing_in_a_frontmatter_only_document_lands_after_the_frontmatter() {
12080 // A fresh note is frontmatter and nothing else. With no rendered block
12081 // to floor the caret it opened at offset 0 — before the opening `---` —
12082 // so the first keystroke wrote itself in front of the metadata and the
12083 // file came out as `This---\ntitle: …`.
12084 let fm = "---\ntitle: 2026-08-29\nid: f8s32cd\n---\n";
12085 let mut d = wysiwyg_doc("wys_fm_only", fm);
12086 assert_eq!(d.caret, fm.len(), "caret must open past the frontmatter");
12087 // Nothing is rendered, so the caret draws at the origin of an empty view
12088 // — the same place an empty document puts it.
12089 assert_eq!(d.caret_pos(), (0, 0));
12090 d.insert("This");
12091 assert_eq!(d.source, format!("{fm}This"));
12092 }
12093
12094 /// `select_range` is the verb for a range a host already knows the bytes of,
12095 /// so it must not snap — and must still hold every invariant `place_caret`
12096 /// holds, the frontmatter floor above all.
12097 #[test]
12098 fn select_range_takes_the_range_as_given_but_still_floors_it() {
12099 let fm = "---\ntitle: foo\n---\n\n";
12100 let body = format!("{fm}body foo here\n");
12101 let mut d = wysiwyg_doc("wys_select_range", &body);
12102
12103 // The `foo` in the body: taken exactly, not snapped to a caret stop.
12104 let at = body.rfind("foo").unwrap();
12105 d.select_range(at, at + 3);
12106 assert_eq!(d.selection(), Some((at, at + 3)));
12107 assert_eq!(d.selected_text(), Some("foo"));
12108
12109 // The `foo` in the hidden frontmatter: below the floor, so both ends
12110 // come up to it rather than parking the caret in the metadata, where a
12111 // later keystroke would rewrite `title:`.
12112 let hidden = body.find("foo").unwrap();
12113 assert!(hidden < d.vmap.content_start);
12114 d.select_range(hidden, hidden + 3);
12115 assert!(
12116 d.caret >= d.vmap.content_start && d.anchor.unwrap() >= d.vmap.content_start,
12117 "a range under the floor must not leave the caret in the frontmatter"
12118 );
12119
12120 // Past the end, and mid-character, are both brought back to something
12121 // sliceable rather than panicking the next reader of the range.
12122 let multi = wysiwyg_doc("wys_select_range_utf8", "héllo\n");
12123 let mut d = multi;
12124 d.select_range(2, 9_999);
12125 assert_eq!(d.caret, d.source.len());
12126 assert!(d.source.is_char_boundary(d.anchor.unwrap()));
12127 assert!(d.source.is_char_boundary(d.caret));
12128 }
12129
12130 /// The bug `select_range` exists for: a match butting up against a hidden
12131 /// delimiter. `place_caret` snaps to the nearest *visible* stop, which is
12132 /// the one before the `**`.
12133 #[test]
12134 fn select_range_does_not_snap_off_a_hidden_delimiter() {
12135 let mut d = wysiwyg_doc("wys_select_range_bold", "a **needle** in it\n");
12136 let at = d.source.find("needle").unwrap();
12137 d.select_range(at, at + 6);
12138 assert_eq!(d.selected_text(), Some("needle"), "not \"needl\"");
12139 }
12140
12141 #[test]
12142 fn wysiwyg_backspace_at_content_start_leaves_frontmatter_intact() {
12143 // Backspace deletes `prev_boundary..caret` directly; at the first real
12144 // block that boundary is inside the hidden frontmatter, so it must be a
12145 // no-op rather than eating the closing `---`.
12146 let fm = "---\ntitle: hi\n---\n";
12147 let body = format!("{fm}leaf\n");
12148 let mut d = wysiwyg_doc("wys_fm_bs", &body);
12149 assert_eq!(d.caret, fm.len());
12150 d.backspace();
12151 assert_eq!(d.source, body, "backspace must not touch frontmatter");
12152 d.delete_word_back();
12153 assert_eq!(
12154 d.source, body,
12155 "word-delete must not touch frontmatter either"
12156 );
12157 }
12158
12159 #[test]
12160 fn wysiwyg_edits_inside_a_vis_directive_block_without_disturbing_its_fences() {
12161 // diaryx's `:::vis{.audience}` visibility block — any `:::name{.class}`
12162 // fenced div, really, since core parses these on for every document
12163 // now (`parse_extensions`). The container is a `directive` node, an
12164 // `is_block_container` kind like `block_quote`, so the caret works
12165 // inside its child paragraph exactly as it would inside a quote: typing
12166 // edits the paragraph, and the `:::vis{...}` / `:::` fences round-trip
12167 // untouched.
12168 let body = ":::vis{.public .family}\nhello\n:::\nafter\n";
12169 let mut d = wysiwyg_doc("wys_vis", body);
12170 d.caret = body.find("hello").unwrap() + "hello".len();
12171 d.insert("!");
12172 assert_eq!(
12173 d.source, ":::vis{.public .family}\nhello!\n:::\nafter\n",
12174 "typing inside the block edits its content in place"
12175 );
12176 assert!(
12177 d.source.contains(":::vis{.public .family}"),
12178 "opening fence survives"
12179 );
12180 assert!(d.source.contains(":::\nafter"), "closing fence survives");
12181 }
12182
12183 #[test]
12184 fn source_view_still_reaches_frontmatter() {
12185 // The metadata is only *hidden*, never lost: the source view edits and
12186 // selects it in full, and it's always preserved on save.
12187 let fm = "---\ntitle: hi\n---\n";
12188 let body = format!("{fm}# leaf\n");
12189 let mut d = doc_with("src_fm", &body);
12190 d.select_all();
12191 let sel = d.selected_text().unwrap();
12192 assert!(
12193 sel.contains("title"),
12194 "source view should select everything"
12195 );
12196 d.move_doc_start(false);
12197 assert_eq!(d.caret, 0, "source view can reach offset 0");
12198 }
12199
12200 const TABLE: &str = "| Name | Qty |\n|:-----|----:|\n| Pear | 3 |\n| Fig | 12 |\n";
12201
12202 #[test]
12203 fn wysiwyg_right_crosses_a_cell_border_without_stalling() {
12204 // The border and padding between two cells all share one source offset,
12205 // so a column-stepping caret would sit on `│` and then stall there
12206 // forever. Right must step: end of "Name" -> start of "Qty".
12207 let mut d = wysiwyg_doc("tbl_right", TABLE);
12208 d.caret = TABLE.find("Name").unwrap() + 4; // just after "Name"
12209 d.move_right(false);
12210 assert_eq!(
12211 d.caret,
12212 TABLE.find("Qty").unwrap(),
12213 "should land in the next cell"
12214 );
12215 let (r, c) = d.caret_pos();
12216 assert_eq!(d.vmap.rows[r].glyphs[c].ch, 'Q');
12217 }
12218
12219 #[test]
12220 fn wysiwyg_left_crosses_back_to_the_previous_cell() {
12221 let mut d = wysiwyg_doc("tbl_left", TABLE);
12222 d.caret = TABLE.find("Qty").unwrap();
12223 d.move_left(false);
12224 assert_eq!(
12225 d.caret,
12226 TABLE.find("Name").unwrap() + 4,
12227 "end of the previous cell"
12228 );
12229 }
12230
12231 #[test]
12232 fn wysiwyg_down_steps_over_a_table_rule() {
12233 // Between the header and the first body row sits a `├───┼───┤` rule.
12234 // It's drawn but holds no caret, so one Down must reach "Pear".
12235 let mut d = wysiwyg_doc("tbl_down", TABLE);
12236 d.caret = TABLE.find("Name").unwrap();
12237 d.move_down(false);
12238 assert_eq!(
12239 d.caret,
12240 TABLE.find("Pear").unwrap(),
12241 "one Down reaches the body row"
12242 );
12243 d.move_down(false);
12244 assert_eq!(d.caret, TABLE.find("Fig").unwrap());
12245 }
12246
12247 #[test]
12248 fn wysiwyg_tab_walks_the_cells_and_shift_tab_walks_back() {
12249 let mut d = wysiwyg_doc("tbl_tab", TABLE);
12250 d.caret = TABLE.find("Name").unwrap();
12251 // A hop lands with the destination cell's whole content selected, the
12252 // caret at its end — so typing replaces the cell like a form field.
12253 assert!(d.cell_hop(true));
12254 assert_eq!(
12255 d.selected_text(),
12256 Some("Qty"),
12257 "the target cell comes up selected"
12258 );
12259 assert_eq!(d.caret, TABLE.find("Qty").unwrap() + "Qty".len());
12260 assert!(d.cell_hop(true), "Tab wraps onto the next row's first cell");
12261 assert_eq!(d.selected_text(), Some("Pear"));
12262 assert!(d.cell_hop(false));
12263 assert_eq!(d.selected_text(), Some("Qty"));
12264 }
12265
12266 #[test]
12267 fn tab_outside_a_table_is_not_a_cell_hop() {
12268 // `cell_hop` reports false so the frontend can indent as usual.
12269 let mut d = wysiwyg_doc("tbl_none", "just a paragraph\n");
12270 d.caret = 4;
12271 assert!(!d.cell_hop(true));
12272 assert_eq!(d.caret, 4, "a refused hop leaves the caret alone");
12273 }
12274
12275 #[test]
12276 fn tab_at_the_last_cell_declines_rather_than_leaving_the_table() {
12277 let mut d = wysiwyg_doc("tbl_edge", TABLE);
12278 d.caret = TABLE.rfind("12").unwrap(); // the final cell
12279 assert!(!d.cell_hop(true), "no cell after the last one");
12280 d.caret = TABLE.find("Name").unwrap();
12281 assert!(!d.cell_hop(false), "no cell before the first one");
12282 }
12283
12284 #[test]
12285 fn wysiwyg_vertical_cell_motion_holds_the_column() {
12286 // Down/Up step to the cell above/below in the *same column*, not back to
12287 // the top-left the way a naive row/col motion over the picture would.
12288 let mut d = wysiwyg_doc("tbl_vert", TABLE);
12289 d.caret = TABLE.find("Qty").unwrap();
12290 // Each vertical hop selects the destination cell, holding the column.
12291 assert!(d.cell_move_vertical(true));
12292 assert_eq!(d.selected_text(), Some("3"), "Down holds column 1");
12293 assert!(d.cell_move_vertical(true));
12294 assert_eq!(d.selected_text(), Some("12"), "Down again, still column 1");
12295 assert!(!d.cell_move_vertical(true), "no row below the last");
12296 assert!(d.cell_move_vertical(false));
12297 assert_eq!(d.selected_text(), Some("3"), "Up holds column 1");
12298 assert!(d.cell_move_vertical(false));
12299 assert_eq!(d.selected_text(), Some("Qty"), "Up onto the header");
12300 assert!(!d.cell_move_vertical(false), "no row above the header");
12301 }
12302
12303 #[test]
12304 fn tab_off_the_last_cell_grows_a_row_and_enters_it() {
12305 let mut d = wysiwyg_doc("tbl_grow", TABLE);
12306 d.caret = TABLE.rfind("12").unwrap();
12307 let rows_before = d.source.matches('\n').count();
12308 assert!(d.cell_tab(true), "acts as a table key");
12309 assert_eq!(
12310 d.source.matches('\n').count(),
12311 rows_before + 1,
12312 "a fresh row was appended"
12313 );
12314 assert!(d.caret_in_table(), "the caret entered the new row");
12315 // The caret sits in the new row's first cell — past the old last cell.
12316 assert!(d.caret > TABLE.rfind("12").unwrap());
12317 }
12318
12319 #[test]
12320 fn return_in_a_table_drops_a_cell_and_grows_a_row_at_the_bottom() {
12321 let mut d = wysiwyg_doc("tbl_ret", TABLE);
12322 d.caret = TABLE.find("Name").unwrap();
12323 assert!(d.cell_return(), "acts as a table key");
12324 assert_eq!(
12325 d.selected_text(),
12326 Some("Pear"),
12327 "Return drops one cell, selecting it"
12328 );
12329 // From the last row, Return appends a row and enters it.
12330 d.caret = TABLE.rfind("Fig").unwrap();
12331 let rows_before = d.source.matches('\n').count();
12332 assert!(d.cell_return());
12333 assert_eq!(d.source.matches('\n').count(), rows_before + 1);
12334 assert!(d.caret_in_table());
12335 }
12336
12337 #[test]
12338 fn return_and_tab_outside_a_table_decline() {
12339 let mut d = wysiwyg_doc("tbl_decline", "just a paragraph\n");
12340 d.caret = 4;
12341 assert!(!d.cell_return(), "no table: the frontend inserts a newline");
12342 assert!(!d.cell_tab(true), "no table: the frontend indents");
12343 assert!(
12344 !d.cell_line_break(),
12345 "no table: the frontend breaks the line"
12346 );
12347 }
12348
12349 #[test]
12350 fn a_click_under_a_trailing_table_lands_past_it_and_enter_opens_a_line() {
12351 // A document that ends in a table used to end *inside* it: nothing
12352 // past the last cell was a caret stop, so a click in the blank space
12353 // under the grid snapped back into the table and there was no way to
12354 // write a line after it. The bottom border's end is that stop now.
12355 let mut d = wysiwyg_doc("tbl_trail", TABLE);
12356 let rows = d.vmap.num_rows();
12357 d.click(rows + 3, 0, false);
12358 let end = TABLE.trim_end_matches('\n').len();
12359 assert_eq!(d.caret, end, "the caret stands just past the table");
12360 assert!(!d.caret_in_table(), "past the table is outside it");
12361 assert!(!d.cell_return(), "Return there is the frontend's newline");
12362 d.newline();
12363 d.insert("after");
12364 assert_eq!(
12365 d.source,
12366 format!("{TABLE}\nafter\n"),
12367 "Enter opens a paragraph under the table"
12368 );
12369 }
12370
12371 #[test]
12372 fn typing_at_a_table_s_trailing_stop_opens_a_paragraph_first() {
12373 // The stop sits at the end of the table's last source line, and a
12374 // line glued under a table is a row of it — `| Fig | 12 |x` would be a
12375 // three-cell row. So the text gets a paragraph of its own, as it does
12376 // beside a block picture.
12377 let mut d = wysiwyg_doc("tbl_type", TABLE);
12378 d.caret = TABLE.trim_end_matches('\n').len();
12379 d.insert("x");
12380 assert_eq!(d.source, format!("{TABLE}\nx\n"));
12381 assert_eq!(d.caret, TABLE.len() + 2, "the caret follows the text");
12382 // And a paste, which joins the block exactly as typing would.
12383 let mut d = wysiwyg_doc("tbl_paste", TABLE);
12384 d.caret = TABLE.trim_end_matches('\n').len();
12385 d.paste("pasted");
12386 assert_eq!(d.source, format!("{TABLE}\npasted\n"));
12387 }
12388
12389 #[test]
12390 fn right_leaves_a_table_by_its_trailing_stop_and_backspace_steps_back_in() {
12391 let mut d = wysiwyg_doc("tbl_edge", TABLE);
12392 let last_cell_end = TABLE.rfind("12").unwrap() + 2;
12393 let end = TABLE.trim_end_matches('\n').len();
12394 d.caret = last_cell_end;
12395 d.move_right(false);
12396 assert_eq!(d.caret, end, "Right from the last cell leaves the table");
12397 // Backspace there takes no byte: the one behind the caret is the row's
12398 // closing `|`, which the rich view never drew. It steps back instead.
12399 d.backspace();
12400 assert_eq!(d.source, TABLE, "nothing deleted");
12401 assert_eq!(d.caret, last_cell_end, "back into the last cell");
12402 // Down from the last row lands on the same stop, and Up returns.
12403 d.move_down(false);
12404 assert_eq!(d.caret, end, "Down from the last row leaves the table");
12405 d.move_up(false);
12406 assert_eq!(d.caret, last_cell_end);
12407 }
12408
12409 #[test]
12410 fn a_table_s_trailing_stop_sits_between_it_and_the_text_below() {
12411 // With prose under the table, the stop is one hop between the last
12412 // cell and the paragraph — the shape a block picture's second stop has.
12413 let src = format!("{TABLE}\nafter\n");
12414 let mut d = wysiwyg_doc("tbl_mid", &src);
12415 d.caret = TABLE.rfind("12").unwrap() + 2;
12416 d.move_right(false);
12417 assert_eq!(d.caret, TABLE.trim_end_matches('\n').len());
12418 d.move_right(false);
12419 assert_eq!(d.caret, src.find("after").unwrap());
12420 // Typing at the stop still opens a paragraph, and the text below keeps
12421 // its own.
12422 d.move_left(false);
12423 d.insert("x");
12424 assert_eq!(d.source, format!("{TABLE}\nx\n\nafter\n"));
12425 }
12426
12427 #[test]
12428 fn shift_return_inserts_an_in_cell_break_the_renderer_reads_as_a_line() {
12429 let mut d = wysiwyg_doc("tbl_break", TABLE);
12430 d.caret = TABLE.find("Pear").unwrap() + 4; // just after "Pear"
12431 assert!(d.cell_line_break(), "acts as a table key");
12432 assert!(
12433 d.source.contains("Pear<br>"),
12434 "spelled as an inline <br>: {}",
12435 d.source
12436 );
12437 assert!(d.caret_in_table(), "still in the cell, past the break");
12438 // The break renders as a real line: the "Pear" cell now draws two lines,
12439 // so the table's picture is one row taller than a single-line table.
12440 d.build_visual(80);
12441 let table = &d.vmap.tables[0];
12442 let cell = &table.grid[1].cells[0]; // first body row, first column
12443 assert!(
12444 cell.glyphs.iter().any(|g| g.ch == '\n'),
12445 "the cell carries the break as a newline glyph for the frontend to split"
12446 );
12447 }
12448
12449 #[test]
12450 fn shift_return_in_a_markdown_cell_leaves_a_semantic_hard_break_not_raw_html() {
12451 // twig promotes the in-cell `<br>` to a `hard_break`, so the break reads
12452 // back as structure — the whole point of routing through insert_line_break
12453 // instead of splicing raw `<br>` bytes.
12454 let mut d = wysiwyg_doc("tbl_break_semantic", TABLE);
12455 d.caret = TABLE.find("Pear").unwrap() + 4;
12456 assert!(d.cell_line_break());
12457 let kinds: Vec<Kind> = d
12458 .editor
12459 .nodes()
12460 .unwrap()
12461 .iter()
12462 .map(|n| n.kind.clone())
12463 .collect();
12464 assert!(kinds.contains(&Kind::HardBreak), "got {kinds:?}");
12465 assert!(
12466 !kinds.contains(&Kind::RawInline),
12467 "still raw HTML: {kinds:?}"
12468 );
12469 }
12470
12471 #[test]
12472 fn backspace_over_an_in_cell_break_deletes_the_whole_br_not_a_byte() {
12473 // The `<br>` draws as one newline glyph, so Backspace over it must take
12474 // all four bytes — a one-byte delete would strand a visible `<br` in the
12475 // cell (the reported bug).
12476 let mut d = wysiwyg_doc("tbl_break_bs", TABLE);
12477 d.caret = TABLE.find("Pear").unwrap() + 4;
12478 assert!(d.cell_line_break());
12479 assert!(d.source.contains("Pear<br>"), "precondition: {}", d.source);
12480 d.backspace(); // caret sits just past the break
12481 assert!(
12482 !d.source.contains("<br"),
12483 "no half-deleted <br left: {}",
12484 d.source
12485 );
12486 assert!(
12487 d.source.contains("| Pear |"),
12488 "the cell is back to one line: {}",
12489 d.source
12490 );
12491 }
12492
12493 #[test]
12494 fn delete_forward_over_an_in_cell_break_deletes_the_whole_br() {
12495 let mut d = wysiwyg_doc("tbl_break_del", TABLE);
12496 d.caret = TABLE.find("Pear").unwrap() + 4;
12497 assert!(d.cell_line_break());
12498 d.caret = TABLE.find("Pear").unwrap() + 4; // back onto the break's start
12499 d.delete_forward();
12500 assert!(
12501 !d.source.contains("<br"),
12502 "no half-deleted <br: {}",
12503 d.source
12504 );
12505 assert!(
12506 d.source.contains("| Pear |"),
12507 "cell back to one line: {}",
12508 d.source
12509 );
12510 }
12511
12512 #[test]
12513 fn shift_return_in_a_djot_cell_is_swallowed_and_leaves_the_row_intact() {
12514 // Djot has no idiomatic in-cell break, so twig refuses it. The gesture is
12515 // still consumed (a real newline would split the one-line row), but the
12516 // cell must be left exactly as it was — no non-idiomatic `<br>` spliced in.
12517 let src = "| Name | Qty |\n|:-----|----:|\n| Pear | 3 |\n";
12518 let mut d = Doc::from_source(src.to_string(), Format::Djot).unwrap();
12519 d.caret = src.find("Pear").unwrap() + 4;
12520 assert!(d.caret_in_table(), "caret should be inside the djot table");
12521 assert!(
12522 d.cell_line_break(),
12523 "the key is consumed, not passed to the frontend"
12524 );
12525 assert_eq!(d.source, src, "the djot cell is left untouched");
12526 assert!(
12527 !d.source.contains("<br>"),
12528 "no non-idiomatic <br> spliced into djot"
12529 );
12530 assert!(
12531 d.status.is_some(),
12532 "the refusal is surfaced on the status line"
12533 );
12534 }
12535
12536 #[test]
12537 fn typing_in_a_cell_edits_that_cell() {
12538 // Editing comes free once offsets map correctly: the caret is a source
12539 // offset, so a normal splice lands inside the pipe table.
12540 let mut d = wysiwyg_doc("tbl_type", TABLE);
12541 d.caret = TABLE.find("Pear").unwrap() + 4;
12542 d.insert("s");
12543 assert!(d.source.contains("| Pears | 3 |"), "got {:?}", d.source);
12544 }
12545
12546 #[test]
12547 fn motion_and_delete_treat_an_emoji_as_one_character() {
12548 // 👨👩👧 is a single grapheme built from three emoji joined by ZWJ — 18
12549 // bytes, several codepoints. Right-arrow must clear it in one step, and
12550 // backspace must remove the whole cluster, not a stray joiner.
12551 let family = "👨👩👧";
12552 let mut d = doc_with("emoji", &format!("a{family}b\n"));
12553 d.caret = 1; // just after 'a', before the emoji
12554 d.move_right(false);
12555 assert_eq!(
12556 d.caret,
12557 1 + family.len(),
12558 "one step clears the whole cluster"
12559 );
12560 assert_eq!(&d.source[d.caret..d.caret + 1], "b");
12561
12562 d.backspace(); // delete the emoji as a unit
12563 assert_eq!(d.source, "ab\n");
12564 assert_eq!(d.caret, 1);
12565 }
12566
12567 #[test]
12568 fn motion_handles_a_combining_accent_as_one_character() {
12569 // "e" + U+0301 (combining acute) renders as one é.
12570 let mut d = doc_with("combining", "e\u{0301}x\n");
12571 d.caret = 0;
12572 d.move_right(false);
12573 assert_eq!(
12574 d.caret,
12575 "e\u{0301}".len(),
12576 "steps past base + combining mark"
12577 );
12578 }
12579
12580 #[test]
12581 fn undo_then_redo_round_trips_an_edit() {
12582 let mut d = doc_with("undo", "hello\n");
12583 d.caret = 5;
12584 d.insert("!");
12585 assert_eq!(d.source, "hello!\n");
12586 d.undo();
12587 assert_eq!(d.source, "hello\n");
12588 assert_eq!(d.caret, 5, "undo restores the caret");
12589 d.redo();
12590 assert_eq!(d.source, "hello!\n");
12591 }
12592
12593 #[test]
12594 fn a_run_of_typing_undoes_as_one_step() {
12595 let mut d = doc_with("coalesce", "\n");
12596 d.caret = 0;
12597 d.insert("a");
12598 d.insert("b");
12599 d.insert("c");
12600 assert_eq!(d.source, "abc\n");
12601 d.undo(); // the whole typed run, not just "c"
12602 assert_eq!(d.source, "\n");
12603 d.undo(); // nothing left — the run was one step
12604 assert_eq!(d.source, "\n");
12605 assert_eq!(d.status.as_deref(), Some("nothing to undo"));
12606 }
12607
12608 // ── IME composition ──────────────────────────────────────────────────────
12609
12610 #[test]
12611 fn a_composition_run_undoes_as_one_step() {
12612 let mut d = doc_with("compose", "\n");
12613 d.caret = 0;
12614 // What an IME does: each step replaces the last one's provisional bytes.
12615 d.edit_composing(0, 0, "k");
12616 d.edit_composing(0, 1, "か");
12617 d.edit_composing(0, 3, "かん");
12618 d.edit_composing(0, 6, "感"); // the commit
12619 d.end_composition();
12620 assert_eq!(d.source, "感\n");
12621 d.undo(); // the whole composition, not its last keystroke
12622 assert_eq!(d.source, "\n");
12623 assert_eq!(d.status.as_deref(), None, "the run was a single step");
12624 }
12625
12626 #[test]
12627 fn two_compositions_are_two_undo_steps() {
12628 let mut d = doc_with("compose_two", "\n");
12629 d.caret = 0;
12630 d.edit_composing(0, 0, "か");
12631 d.edit_composing(0, 3, "蚊");
12632 d.end_composition();
12633 d.edit_composing(3, 3, "き");
12634 d.edit_composing(3, 6, "木");
12635 d.end_composition();
12636 assert_eq!(d.source, "蚊木\n");
12637 d.undo();
12638 assert_eq!(d.source, "蚊\n", "only the second composition");
12639 d.undo();
12640 assert_eq!(d.source, "\n");
12641 }
12642
12643 #[test]
12644 fn a_composition_does_not_fold_into_the_typing_around_it() {
12645 let mut d = doc_with("compose_typing", "\n");
12646 d.caret = 0;
12647 d.insert("a");
12648 d.insert("b");
12649 d.edit_composing(2, 2, "か");
12650 d.edit_composing(2, 5, "蚊");
12651 d.end_composition();
12652 d.insert("c");
12653 assert_eq!(d.source, "ab蚊c\n");
12654 d.undo();
12655 assert_eq!(d.source, "ab蚊\n");
12656 d.undo();
12657 assert_eq!(d.source, "ab\n");
12658 d.undo();
12659 assert_eq!(d.source, "\n");
12660 }
12661
12662 #[test]
12663 fn ending_a_composition_that_never_began_leaves_a_typing_run_alone() {
12664 let mut d = doc_with("compose_spurious", "\n");
12665 d.caret = 0;
12666 d.insert("a");
12667 d.end_composition(); // an IME unmarking unprompted
12668 d.insert("b");
12669 assert_eq!(d.source, "ab\n");
12670 d.undo();
12671 assert_eq!(d.source, "\n", "still one typed run");
12672 }
12673
12674 // ── the clipboard's rich flavor ──────────────────────────────────────────
12675
12676 #[test]
12677 fn an_inline_selection_publishes_html_without_a_paragraph_wrapper() {
12678 let mut d = doc_with("sel_inline", "a **bold** c\n");
12679 d.anchor = Some(2);
12680 d.caret = 10; // `**bold**`, inside the paragraph
12681 assert_eq!(d.selection_html().as_deref(), Some("<strong>bold</strong>"));
12682 }
12683
12684 #[test]
12685 fn a_whole_block_selection_keeps_its_paragraph() {
12686 let mut d = doc_with("sel_block", "a **bold** c\n");
12687 d.anchor = Some(0);
12688 d.caret = 12; // the entire paragraph
12689 assert_eq!(
12690 d.selection_html().as_deref(),
12691 Some("<p>a <strong>bold</strong> c</p>")
12692 );
12693 }
12694
12695 #[test]
12696 fn a_multi_block_selection_keeps_its_structure() {
12697 let mut d = doc_with("sel_multi", "para\n\n- one\n- two\n");
12698 d.select_all();
12699 let html = d.selection_html().expect("renders");
12700 assert!(html.contains("<p>para</p>"), "{html:?}");
12701 assert!(html.contains("<li>one</li>"), "{html:?}");
12702 }
12703
12704 #[test]
12705 fn a_word_inside_a_heading_publishes_as_text_not_a_heading() {
12706 // The fragment `Head` is a paragraph standalone; the *document* says it
12707 // sits inside one block, so the wrapper is an artifact either way.
12708 let mut d = doc_with("sel_heading", "# Head line\n");
12709 d.anchor = Some(2);
12710 d.caret = 6;
12711 assert_eq!(d.selection_html().as_deref(), Some("Head"));
12712 }
12713
12714 #[test]
12715 fn no_selection_publishes_no_html() {
12716 let mut d = doc_with("sel_none", "a b\n");
12717 d.caret = 1;
12718 assert_eq!(d.selection_html(), None);
12719 }
12720
12721 #[test]
12722 fn pasting_html_converts_it_and_is_one_undo_step() {
12723 let mut d = doc_with("paste_html", "x\n");
12724 d.caret = 1;
12725 assert!(d.paste_html("<p>a <strong>b</strong> c</p>"));
12726 assert_eq!(d.source, "xa **b** c\n");
12727 d.undo();
12728 assert_eq!(d.source, "x\n", "the whole paste, in one step");
12729 }
12730
12731 #[test]
12732 fn pasting_html_replaces_the_selection() {
12733 let mut d = doc_with("paste_html_sel", "keep drop\n");
12734 d.anchor = Some(5);
12735 d.caret = 9;
12736 assert!(d.paste_html("<em>new</em>"));
12737 assert_eq!(d.source, "keep *new*\n");
12738 }
12739
12740 #[test]
12741 fn html_that_would_paste_garbage_declines_so_the_caller_falls_back() {
12742 let mut d = doc_with("paste_html_bad", "x\n");
12743 d.caret = 1;
12744 // twig builds no table from HTML; raw `<table>` in prose is worse than
12745 // the plain flavor the caller still holds.
12746 assert!(!d.paste_html("<table><tr><td>a</td></tr></table>"));
12747 assert_eq!(d.source, "x\n", "declined edits nothing");
12748 }
12749
12750 #[test]
12751 fn copy_then_paste_round_trips_through_the_html_flavor() {
12752 let mut d = doc_with("clip_round", "a **b** and [l](https://x.dev)\n");
12753 d.select_all();
12754 let html = d.selection_html().expect("renders");
12755 let mut into = doc_with("clip_round_dst", "\n");
12756 into.caret = 0;
12757 assert!(into.paste_html(&html));
12758 assert_eq!(into.source, "a **b** and [l](https://x.dev)\n");
12759 }
12760
12761 #[test]
12762 fn moving_the_caret_starts_a_new_undo_group() {
12763 let mut d = doc_with("break", "\n");
12764 d.caret = 0;
12765 d.insert("a");
12766 d.insert("b"); // "ab\n", caret at 2
12767 d.move_left(false); // breaks the run
12768 d.insert("X"); // "aXb\n"
12769 assert_eq!(d.source, "aXb\n");
12770 d.undo();
12771 assert_eq!(
12772 d.source, "ab\n",
12773 "first undo removes only the post-move insert"
12774 );
12775 d.undo();
12776 assert_eq!(d.source, "\n", "second undo removes the earlier run");
12777 }
12778
12779 #[test]
12780 fn undo_reverses_a_format_toggle() {
12781 let mut d = doc_with("fmt_undo", "a word b\n");
12782 d.anchor = Some(2);
12783 d.caret = 6;
12784 d.toggle(InlineKind::Strong);
12785 assert_eq!(d.source, "a **word** b\n");
12786 d.undo();
12787 assert_eq!(d.source, "a word b\n");
12788 }
12789
12790 #[test]
12791 fn undo_back_to_the_saved_state_clears_dirty() {
12792 let mut d = doc_with("dirty_undo", "hello\n");
12793 assert!(!d.dirty);
12794 d.caret = 5;
12795 d.insert("!");
12796 assert!(d.dirty);
12797 d.undo();
12798 assert!(
12799 !d.dirty,
12800 "undoing to the saved source is not a modification"
12801 );
12802 }
12803
12804 #[test]
12805 fn a_new_edit_invalidates_redo() {
12806 let mut d = doc_with("redo_inv", "\n");
12807 d.caret = 0;
12808 d.insert("a");
12809 d.undo();
12810 d.insert("b"); // diverges — the redo of "a" is now gone
12811 d.redo();
12812 assert_eq!(d.source, "b\n");
12813 }
12814
12815 #[test]
12816 fn can_undo_and_can_redo_follow_the_history_a_menu_would_enable_by() {
12817 let mut d = doc_with("can_undo", "hello\n");
12818 assert!(
12819 !d.can_undo() && !d.can_redo(),
12820 "a fresh document has no history"
12821 );
12822 d.caret = 5;
12823 d.insert("!");
12824 assert!(
12825 d.can_undo() && !d.can_redo(),
12826 "an edit is a step to take back"
12827 );
12828 d.undo();
12829 assert!(!d.can_undo() && d.can_redo(), "undone: only redo remains");
12830 d.redo();
12831 assert!(d.can_undo() && !d.can_redo(), "redone: back to undoable");
12832 d.undo();
12833 d.insert("?");
12834 assert!(
12835 d.can_undo() && !d.can_redo(),
12836 "a fresh edit ends the redo chain"
12837 );
12838 // A coalesced run over-counts steps — the bound is what a menu needs,
12839 // and it reconciles the moment twig reports the history empty.
12840 d.insert("a");
12841 d.insert("b");
12842 while d.can_undo() {
12843 d.undo();
12844 }
12845 assert_eq!(d.source, "hello\n");
12846 assert!(!d.can_undo());
12847 // A reading surface has nothing to undo, whatever the history holds.
12848 d.redo();
12849 d.set_read_only(true);
12850 assert!(!d.can_undo() && !d.can_redo());
12851 }
12852
12853 #[test]
12854 fn undo_on_empty_history_is_a_no_op() {
12855 let mut d = doc_with("undo_empty", "hi\n");
12856 d.undo();
12857 assert_eq!(d.source, "hi\n");
12858 assert_eq!(d.status.as_deref(), Some("nothing to undo"));
12859 }
12860
12861 #[test]
12862 fn a_one_character_paste_is_its_own_undo_step() {
12863 for view in [View::Source, View::Wysiwyg] {
12864 let mut d = doc_in(view, "paste_step", "ab\n");
12865 d.caret = 0;
12866 d.insert("x");
12867 d.insert("y"); // a run of typing
12868 d.paste("z"); // one character, but pasted — not part of that run
12869 assert_eq!(d.source, "xyzab\n");
12870 d.undo();
12871 assert_eq!(d.source, "xyab\n", "the paste undoes on its own");
12872 assert_eq!(d.caret, 2, "and hands back the caret it found");
12873 d.undo();
12874 assert_eq!(d.source, "ab\n", "the typed run is still one step under it");
12875 }
12876 }
12877
12878 #[test]
12879 fn the_same_character_typed_still_joins_the_run() {
12880 // The other half of the pair: `z` is a keystroke here and a paste above,
12881 // and the two undo differently. Nothing about the *string* says which —
12882 // which is why provenance has to come from the door the caller uses.
12883 for view in [View::Source, View::Wysiwyg] {
12884 let mut d = doc_in(view, "typed_run", "ab\n");
12885 d.caret = 0;
12886 d.insert("x");
12887 d.insert("y");
12888 d.insert("z");
12889 d.undo();
12890 assert_eq!(d.source, "ab\n", "one run, one step");
12891 }
12892 }
12893
12894 #[test]
12895 fn undo_restores_the_caret_to_where_it_was_not_to_the_edit_site() {
12896 for view in [View::Source, View::Wysiwyg] {
12897 let mut d = doc_in(view, "undo_caret", "hello world\n");
12898 d.caret = 11; // standing at the end of "world", away from the edit
12899 d.edit(0, 5, "goodbye");
12900 assert_eq!(d.source, "goodbye world\n");
12901 d.undo();
12902 assert_eq!(d.source, "hello world\n");
12903 // The undone edit ends at offset 5; the user was at 11.
12904 assert_eq!(d.caret, 11, "the caret comes back with the bytes");
12905 }
12906 }
12907
12908 #[test]
12909 fn undo_restores_the_selection_the_edit_replaced() {
12910 for view in [View::Source, View::Wysiwyg] {
12911 let mut d = doc_in(view, "undo_sel", "a word b\n");
12912 d.anchor = Some(2);
12913 d.caret = 6; // "word" selected
12914 d.insert("X");
12915 assert_eq!(d.source, "a X b\n");
12916 d.undo();
12917 assert_eq!(d.source, "a word b\n");
12918 assert_eq!(d.selection(), Some((2, 6)), "the selection comes back too");
12919 }
12920 }
12921
12922 #[test]
12923 fn redo_restores_the_caret_the_edit_left_behind() {
12924 for view in [View::Source, View::Wysiwyg] {
12925 let mut d = doc_in(view, "redo_caret", "hello world\n");
12926 d.caret = 11;
12927 d.edit(0, 5, "goodbye");
12928 assert_eq!(d.caret, 7, "the edit left the caret after its new text");
12929 d.undo();
12930 d.redo();
12931 assert_eq!(d.source, "goodbye world\n");
12932 assert_eq!(d.caret, 7, "redo puts it back where the edit had it");
12933 }
12934 }
12935
12936 #[test]
12937 fn undoing_a_typed_run_restores_the_caret_from_before_the_whole_run() {
12938 for view in [View::Source, View::Wysiwyg] {
12939 let mut d = doc_in(view, "run_caret", "hi\n");
12940 d.caret = 2;
12941 d.insert("a");
12942 d.insert("b");
12943 d.insert("c");
12944 assert_eq!(d.source, "hiabc\n");
12945 d.undo();
12946 assert_eq!(d.source, "hi\n");
12947 assert_eq!(d.caret, 2, "before the run, not before its last keystroke");
12948 d.redo();
12949 assert_eq!(d.caret, 5, "and redo restores the end of the whole run");
12950 }
12951 }
12952
12953 #[test]
12954 fn undo_restores_the_caret_across_a_format_toggle() {
12955 // A toggle reaches twig without going through `splice`, so it has to
12956 // record its own step — miss it and every stack depth below it is off by
12957 // one, and undo starts handing back another edit's caret.
12958 for view in [View::Source, View::Wysiwyg] {
12959 let mut d = doc_in(view, "fmt_caret", "a word b\n");
12960 d.caret = 8;
12961 d.anchor = Some(2);
12962 d.caret = 6;
12963 d.toggle(InlineKind::Strong);
12964 assert_eq!(d.source, "a **word** b\n");
12965 d.undo();
12966 assert_eq!(d.source, "a word b\n");
12967 assert_eq!(
12968 d.selection(),
12969 Some((2, 6)),
12970 "the toggled selection comes back"
12971 );
12972 }
12973 }
12974
12975 #[test]
12976 fn an_edit_after_an_undo_truncates_the_caret_history_with_twigs() {
12977 // The drift that would never announce itself: twig drops its redo stack
12978 // on any fresh edit, so a leaf redo entry that outlives it would restore
12979 // a caret from the timeline that edit abandoned.
12980 for view in [View::Source, View::Wysiwyg] {
12981 let mut d = doc_in(view, "redo_trunc", "hello world\n");
12982 d.caret = 11;
12983 d.edit(0, 5, "goodbye"); // step A, caret 11 → 7
12984 d.undo();
12985 assert_eq!(d.caret, 11);
12986 d.caret = 0;
12987 d.insert("X"); // diverges: A's redo is gone from twig
12988 assert_eq!(d.source, "Xhello world\n");
12989
12990 d.redo();
12991 assert_eq!(d.source, "Xhello world\n", "nothing to redo onto");
12992 assert_eq!(d.status.as_deref(), Some("nothing to redo"));
12993 d.undo();
12994 assert_eq!(d.source, "hello world\n");
12995 assert_eq!(
12996 d.caret, 0,
12997 "the surviving step's caret, not the dropped one"
12998 );
12999 }
13000 }
13001
13002 #[test]
13003 fn indent_and_outdent_move_the_caret_line_with_its_text() {
13004 for view in [View::Source, View::Wysiwyg] {
13005 let g = |m, f: fn(&mut Doc)| golden_in(view, "indent_line", m, f);
13006 assert_eq!(g("he|llo\n", |d| d.indent()), " he|llo\n");
13007 assert_eq!(g(" he|llo\n", |d| d.outdent()), "he|llo\n");
13008 // Indentation the caret is standing *in* collapses to the line start
13009 // rather than dragging the caret into the text.
13010 assert_eq!(g("| hello\n", |d| d.outdent()), "|hello\n");
13011 // A line with none to give back is left exactly as it was.
13012 assert_eq!(g("he|llo\n", |d| d.outdent()), "he|llo\n");
13013 // Less than a full level gives back what it has.
13014 assert_eq!(g(" he|llo\n", |d| d.outdent()), "he|llo\n");
13015 // A tab is one level however many spaces it isn't.
13016 assert_eq!(g("\the|llo\n", |d| d.outdent()), "he|llo\n");
13017 }
13018 }
13019
13020 #[test]
13021 fn one_indent_level_leaves_a_paragraph_a_paragraph() {
13022 // Why the level is two spaces and not the four both frontends type
13023 // today. Four is markdown's indented-code-block marker, so a Tab on a
13024 // paragraph would silently restyle it as code — a width that changes
13025 // what the document *means* isn't an indent. Pinned because the number
13026 // is the kind of thing a later list-aware pass would reach for.
13027 let mut d = doc_with("indent_kind", "hello\n");
13028 d.caret = 2;
13029 d.indent();
13030 assert_eq!(d.source, " hello\n");
13031 assert!(
13032 d.nodes().iter().any(|n| n.kind == Kind::Para),
13033 "still prose after a Tab"
13034 );
13035 assert!(!d.nodes().iter().any(|n| n.kind == Kind::CodeBlock));
13036
13037 // The four-space level this replaces, for contrast: same text, and twig
13038 // reparses the paragraph into a code block.
13039 let mut wide = doc_with("indent_kind_4", " hello\n");
13040 wide.build_visual(80);
13041 assert!(
13042 wide.nodes().iter().any(|n| n.kind == Kind::CodeBlock),
13043 "four spaces is a code block, not an indented paragraph"
13044 );
13045 }
13046
13047 #[test]
13048 fn indent_nests_a_list_item_under_its_parent() {
13049 // Tab indents a list item by its own marker width, landing its marker at
13050 // the parent's content column so twig reparses it as a nested list.
13051 for view in [View::Source, View::Wysiwyg] {
13052 let mut d = doc_in(view, "indent_nest", "- a\n- b\n");
13053 d.caret = 6; // on the second item
13054 d.indent();
13055 assert_eq!(d.source, "- a\n - b\n");
13056 let lists = d
13057 .nodes()
13058 .iter()
13059 .filter(|n| n.kind == Kind::BulletList)
13060 .count();
13061 assert_eq!(lists, 2, "the indented item is a nested list");
13062 }
13063 }
13064
13065 #[test]
13066 fn indent_nests_an_ordered_item_at_its_marker_width() {
13067 // An ordered marker `1. ` is three columns wide, so a two-space step
13068 // (which nests a bullet) leaves it flat. Regression: Tab must use the
13069 // marker width, three, so the item actually nests — and the source
13070 // renumbers so the sub-list restarts at 1 and the outer list resumes.
13071 for view in [View::Source, View::Wysiwyg] {
13072 let mut d = doc_in(view, "indent_ord", "1. a\n2. b\n3. c\n");
13073 d.caret = d.source.find('b').unwrap();
13074 d.indent();
13075 assert_eq!(d.source, "1. a\n 1. b\n2. c\n");
13076 let lists = d
13077 .nodes()
13078 .iter()
13079 .filter(|n| n.kind == Kind::OrderedList)
13080 .count();
13081 assert_eq!(lists, 2, "the indented item is a nested ordered list");
13082 }
13083 }
13084
13085 #[test]
13086 fn indent_leaves_a_lists_first_item_put() {
13087 // The first item of a list has no sibling above it to nest under, so Tab
13088 // is a no-op there — the marker stays at column zero rather than being
13089 // shoved into indentation twig can't read as a sub-list.
13090 for view in [View::Source, View::Wysiwyg] {
13091 let mut d = doc_in(view, "indent_first", "- a\n- b\n");
13092 d.caret = 1; // on the FIRST item
13093 d.indent();
13094 assert_eq!(d.source, "- a\n- b\n", "the first item doesn't nest");
13095 // The sibling below still nests, proving the guard is per-item.
13096 d.caret = d.source.find('b').unwrap();
13097 d.indent();
13098 assert_eq!(d.source, "- a\n - b\n");
13099 }
13100 }
13101
13102 #[test]
13103 fn hidden_mode_keeps_typed_markup_literal() {
13104 // The Diaryx default: typing `*hi*` gives the characters, not emphasis —
13105 // twig escapes what would open markup, so the source is `\*hi\*` and the
13106 // AST is a plain string. Formatting is the commands' job in this mode.
13107 let mut d = doc_in(View::Wysiwyg, "hidden_literal", "");
13108 d.insert("*hi*");
13109 assert_eq!(d.source, "\\*hi\\*");
13110 assert!(
13111 d.nodes()
13112 .iter()
13113 .all(|n| n.kind != Kind::Emph && n.kind != Kind::Strong)
13114 );
13115 }
13116
13117 #[test]
13118 fn hidden_mode_escapes_a_line_start_block_marker() {
13119 // A `#`/`-`/`>` at a line start would open a block, so Hidden mode keeps
13120 // it literal too — a Diaryx user's "# 1 idea" stays prose, not a heading.
13121 let mut d = doc_in(View::Wysiwyg, "hidden_block", "");
13122 d.insert("# hi");
13123 assert_eq!(d.source, "\\# hi");
13124 assert!(d.nodes().iter().all(|n| n.kind != Kind::Heading));
13125 }
13126
13127 #[test]
13128 fn authoring_modes_keep_typed_markup_live() {
13129 // Both authoring rungs of the ladder: typing `*hi*` really is emphasis
13130 // (no escape), the same as source view — escaping is `None`'s alone, and
13131 // it's the axis, not the reveal, that decides.
13132 for (view, mode) in [
13133 (View::Wysiwyg, MarkupMode::Shortcuts),
13134 (View::Wysiwyg, MarkupMode::Full),
13135 (View::Source, MarkupMode::None),
13136 ] {
13137 let mut d = doc_in(view, "live_markup", "");
13138 d.set_markup_mode(mode);
13139 d.insert("*hi*");
13140 assert_eq!(d.source, "*hi*", "{mode:?} in {view:?} types raw markup");
13141 }
13142 }
13143
13144 #[test]
13145 fn hidden_mode_overwrite_undoes_in_one_step() {
13146 // Typing over a selection escapes the replacement *and* stays a single
13147 // undo — the selection-delete and the literal insert fold together, so
13148 // one undo brings the whole selection back, like a plain overwrite.
13149 let mut d = doc_in(View::Wysiwyg, "hidden_overwrite", "a word b\n");
13150 d.anchor = Some(2);
13151 d.caret = 6; // "word"
13152 d.insert("*");
13153 assert_eq!(d.source, "a \\* b\n", "the replacement is escaped");
13154 d.undo();
13155 assert_eq!(d.source, "a word b\n");
13156 assert_eq!(d.selection(), Some((2, 6)), "one undo, selection restored");
13157 }
13158
13159 #[test]
13160 fn backspace_over_an_escaped_char_takes_the_hidden_backslash_too() {
13161 // Type `*` in Hidden mode → `\*` (drawn as one `*`); one Backspace clears
13162 // the whole visual character, never stranding the hidden `\`.
13163 let mut d = doc_in(View::Wysiwyg, "bsp_escape", "");
13164 d.insert("*");
13165 assert_eq!(d.source, "\\*");
13166 d.backspace();
13167 assert_eq!(d.source, "", "the escape backslash went with the *");
13168 // A *literal* backslash (source view, no escape) is an ordinary char.
13169 let mut s = doc_in(View::Source, "bsp_lit", "a\\b\n");
13170 s.caret = 3; // after `b`
13171 s.backspace();
13172 assert_eq!(s.source, "a\\\n", "only the b is deleted, the \\ stays");
13173 }
13174
13175 #[test]
13176 fn hidden_mode_leaves_structural_markup_alone() {
13177 // Enter continues a bullet list by writing a real `- ` marker (an
13178 // `insert_raw`, not the typing path), so Hidden mode's escaping never
13179 // touches it — the list keeps working.
13180 let mut d = doc_in(View::Wysiwyg, "hidden_struct", "- item\n");
13181 d.caret = 6;
13182 d.newline();
13183 d.insert("two");
13184 assert_eq!(d.source, "- item\n- two\n");
13185 }
13186
13187 #[test]
13188 fn markup_mode_defaults_to_none_and_round_trips() {
13189 // Diaryx's default is the clean `None` surface; a markup-fluent
13190 // frontend can climb the ladder, and the choice sticks.
13191 let mut d = doc_in(View::Wysiwyg, "markup_mode", "hi\n");
13192 assert_eq!(d.markup_mode(), MarkupMode::None, "None by default");
13193 for mode in [MarkupMode::Shortcuts, MarkupMode::Full, MarkupMode::None] {
13194 d.set_markup_mode(mode);
13195 assert_eq!(d.markup_mode(), mode);
13196 }
13197 }
13198
13199 #[test]
13200 fn full_mode_reveals_only_the_caret_line() {
13201 // The mode's whole claim: the caret's line shows its raw delimiters and
13202 // every other line stays resolved. Two paragraphs with identical markup
13203 // so the only difference between the rows is where the caret is.
13204 let mut d = doc_in(
13205 View::Wysiwyg,
13206 "reveal_caret_line",
13207 "*one* here\n\n*two* there\n",
13208 );
13209 d.set_markup_mode(MarkupMode::Full);
13210
13211 caret_at(&mut d, "one");
13212 let rows = drawn_rows(&d);
13213 assert!(
13214 rows.iter().any(|r| r == "*one* here"),
13215 "caret's line raw: {rows:?}"
13216 );
13217 assert!(
13218 rows.iter().any(|r| r == "two there"),
13219 "other line resolved: {rows:?}"
13220 );
13221
13222 // Move to the other paragraph: the reveal follows, and the line just
13223 // left goes back to being resolved.
13224 caret_at(&mut d, "two");
13225 let rows = drawn_rows(&d);
13226 assert!(
13227 rows.iter().any(|r| r == "*two* there"),
13228 "caret's line raw: {rows:?}"
13229 );
13230 assert!(
13231 rows.iter().any(|r| r == "one here"),
13232 "left line resolved: {rows:?}"
13233 );
13234 }
13235
13236 #[test]
13237 fn revealing_a_coloured_highlight_shows_the_emoji_that_spelled_it() {
13238 // The emoji is a delimiter, not content — so `MarkupMode::Full` owes it
13239 // the same treatment as an emphasis's `*`: hidden while the caret is
13240 // elsewhere, shown in full where the caret lands. That falls out of
13241 // `delims` reading the bytes between the mark's span and its content
13242 // span, which is exactly `==🔴 ` and `==`, rather than from a table
13243 // of spellings — so the no-space form `==🟢green==` reveals right too.
13244 let mut d = doc_in(
13245 View::Wysiwyg,
13246 "reveal_coloured_mark",
13247 "a ==🔴 red== one\n\nb ==plain== two\n",
13248 );
13249 d.set_markup_mode(MarkupMode::Full);
13250
13251 caret_at(&mut d, "red");
13252 let rows = drawn_rows(&d);
13253 assert!(
13254 rows.iter().any(|r| r == "a ==🔴 red== one"),
13255 "the caret's line shows the colour it was written with: {rows:?}"
13256 );
13257 assert!(
13258 rows.iter().any(|r| r == "b plain two"),
13259 "and every other line stays resolved: {rows:?}"
13260 );
13261
13262 // Away from it, the emoji goes back to being markup — the reader sees
13263 // the words and the wash.
13264 caret_at(&mut d, "two");
13265 let rows = drawn_rows(&d);
13266 assert!(
13267 rows.iter().any(|r| r == "a red one"),
13268 "resolved again: {rows:?}"
13269 );
13270 }
13271
13272 #[test]
13273 fn hidden_modes_never_reveal_wherever_the_caret_is() {
13274 // The two rungs below `Full` share a rendering: delimiters stay hidden
13275 // even under the caret. `Shortcuts` differing from `None` only in what
13276 // typing does is exactly the point of splitting the axes.
13277 for mode in [MarkupMode::None, MarkupMode::Shortcuts] {
13278 let mut d = doc_in(View::Wysiwyg, "reveal_hidden", "*one* here\n");
13279 d.set_markup_mode(mode);
13280 caret_at(&mut d, "one");
13281 let rows = drawn_rows(&d);
13282 assert!(
13283 rows.iter().any(|r| r == "one here"),
13284 "{mode:?} hides: {rows:?}"
13285 );
13286 assert!(
13287 !rows.iter().any(|r| r.contains('*')),
13288 "{mode:?} shows no `*`: {rows:?}"
13289 );
13290 }
13291 }
13292
13293 #[test]
13294 fn revealed_delimiters_are_the_authors_own_spelling() {
13295 // Delimiters are re-read from the source rather than synthesized per
13296 // kind, so a line comes back spelled the way it was written: `_em_` does
13297 // not turn into `*em*`, and a two-backtick fence keeps both backticks.
13298 let body = "_em_ and __st__ and ``lit ` tick`` and [lk](http://x) and ~~del~~\n";
13299 let mut d = doc_in(View::Wysiwyg, "reveal_spelling", body);
13300 d.set_markup_mode(MarkupMode::Full);
13301 caret_at(&mut d, "em");
13302 let rows = drawn_rows(&d);
13303 assert!(
13304 rows.iter().any(|r| r == body.trim_end()),
13305 "the revealed line is its own source: {rows:?}"
13306 );
13307 }
13308
13309 #[test]
13310 fn revealed_heading_shows_its_hashes() {
13311 // The `# ` marker is a block-level prefix, not an inline delimiter, so
13312 // it takes its own path — but it reveals on the same rule.
13313 let mut d = doc_in(View::Wysiwyg, "reveal_heading", "# Title\n\nbody\n");
13314 d.set_markup_mode(MarkupMode::Full);
13315
13316 caret_at(&mut d, "Title");
13317 assert!(
13318 drawn_rows(&d).iter().any(|r| r == "# Title"),
13319 "{:?}",
13320 drawn_rows(&d)
13321 );
13322
13323 caret_at(&mut d, "body");
13324 let rows = drawn_rows(&d);
13325 assert!(
13326 rows.iter().any(|r| r == "Title"),
13327 "hashes hidden again: {rows:?}"
13328 );
13329 }
13330
13331 #[test]
13332 fn revealed_delimiters_are_caret_stops() {
13333 // A delimiter that is drawn but can't be reached is worse than one
13334 // that's hidden: the mode exists so the markup can be *edited*. Every
13335 // revealed byte must be somewhere the caret can stand.
13336 let mut d = doc_in(View::Wysiwyg, "reveal_stops", "*em* x\n");
13337 d.set_markup_mode(MarkupMode::Full);
13338 caret_at(&mut d, "em");
13339 let opener = d.source.find('*').unwrap();
13340 assert!(d.vmap.is_stop(opener), "the opening `*` is a caret stop");
13341 assert!(
13342 d.vmap.is_stop(opener + 3),
13343 "the closing `*` is a caret stop"
13344 );
13345 }
13346
13347 #[test]
13348 fn setext_heading_reveals_nothing_across_its_newline() {
13349 // A setext heading's underline is on another line, so it is not the
13350 // caret line's to reveal — and emitting it would inject a `\n` glyph
13351 // that splits the row where the author wrote no break.
13352 let mut d = doc_in(View::Wysiwyg, "reveal_setext", "Title\n=====\n\nbody\n");
13353 d.set_markup_mode(MarkupMode::Full);
13354 caret_at(&mut d, "Title");
13355 let rows = drawn_rows(&d);
13356 assert!(
13357 rows.iter().any(|r| r == "Title"),
13358 "title renders alone: {rows:?}"
13359 );
13360 assert!(
13361 !rows.iter().any(|r| r.contains('=')),
13362 "no underline leaks in: {rows:?}"
13363 );
13364 }
13365
13366 #[test]
13367 fn markup_mode_axes_split_the_ladder() {
13368 // The two behaviours the ladder spells: `Shortcuts` is the middle rung
13369 // that authors markup but still hides it, and it's the only rung where
13370 // the two axes disagree.
13371 assert!(!MarkupMode::None.authors());
13372 assert!(!MarkupMode::None.reveals_caret_line());
13373 assert!(MarkupMode::Shortcuts.authors());
13374 assert!(!MarkupMode::Shortcuts.reveals_caret_line());
13375 assert!(MarkupMode::Full.authors());
13376 assert!(MarkupMode::Full.reveals_caret_line());
13377 }
13378
13379 #[test]
13380 fn indenting_an_empty_dash_item_under_text_dodges_the_setext_collapse() {
13381 // Tabbing an empty `- ` under a text line would spell `- hello\n - `,
13382 // which twig (correctly, per CommonMark — pandoc agrees) reparses as a
13383 // setext H2. leaf swaps the dash for a `*` so the item stays an empty
13384 // nested bullet and `hello` stays prose: the file round-trips instead of
13385 // hiding a heading the user never asked for.
13386 for view in [View::Source, View::Wysiwyg] {
13387 let mut d = doc_in(view, "setext_guard", "- hello\n- \n");
13388 d.caret = d.source.find("- \n").unwrap() + 2; // after the empty marker
13389 d.indent();
13390 assert_eq!(d.source, "- hello\n * \n");
13391 assert!(
13392 d.nodes().iter().all(|n| n.kind != Kind::Heading),
13393 "no heading"
13394 );
13395 // And it's genuinely a nested list, not a flat one.
13396 assert_eq!(
13397 d.nodes()
13398 .iter()
13399 .filter(|n| n.kind == Kind::BulletList)
13400 .count(),
13401 2
13402 );
13403 }
13404 }
13405
13406 #[test]
13407 fn indenting_a_dash_item_with_content_keeps_its_dash() {
13408 // With content, `- x` can't be a setext underline, so there's nothing to
13409 // dodge: the marker stays a dash and nests as an ordinary sub-bullet.
13410 let mut d = doc_in(View::Wysiwyg, "setext_ok", "- hello\n- x\n");
13411 d.caret = d.source.find('x').unwrap();
13412 d.indent();
13413 assert_eq!(d.source, "- hello\n - x\n");
13414 }
13415
13416 #[test]
13417 fn the_setext_swap_undoes_as_one_step_with_the_indent() {
13418 // The dash→`*` repair coalesces into the Tab, so a single undo restores
13419 // the whole pre-Tab state rather than stranding a half-collapsed doc.
13420 let mut d = doc_in(View::Wysiwyg, "setext_undo", "- hello\n- \n");
13421 d.caret = d.source.find("- \n").unwrap() + 2;
13422 d.indent();
13423 assert_eq!(d.source, "- hello\n * \n");
13424 d.undo();
13425 assert_eq!(d.source, "- hello\n- \n", "one undo, not two");
13426 }
13427
13428 #[test]
13429 fn indent_leaves_a_nested_lists_first_item_put_too() {
13430 // The guard is about siblings, not depth: the first item of an *inner*
13431 // list (already nested under `a`) still has nothing before it at its own
13432 // level, so Tab can't take it deeper.
13433 let mut d = doc_in(View::Wysiwyg, "indent_first_nested", "- a\n - b\n - c\n");
13434 d.caret = d.source.find('b').unwrap();
13435 d.indent();
13436 assert_eq!(d.source, "- a\n - b\n - c\n", "inner first item holds");
13437 // But `c` (a sibling of `b`) nests under `b`.
13438 d.caret = d.source.find('c').unwrap();
13439 d.indent();
13440 assert_eq!(d.source, "- a\n - b\n - c\n");
13441 }
13442
13443 #[test]
13444 fn backspace_at_a_nested_item_start_outdents_it() {
13445 // Backspace with the caret right after a nested item's marker gives back
13446 // one level of nesting, the mirror of Tab — and renumbers the flattened
13447 // ordered list back to a clean run.
13448 let mut d = doc_in(View::Wysiwyg, "bsp_outdent", "1. a\n 1. b\n2. c\n");
13449 d.caret = d.source.find('b').unwrap(); // start of the nested item's content
13450 d.backspace();
13451 assert_eq!(d.source, "1. a\n2. b\n3. c\n");
13452 }
13453
13454 #[test]
13455 fn backspace_at_a_top_level_item_start_strips_the_marker() {
13456 // At the outermost level there's no nesting left to give back, so the same
13457 // keystroke drops the bullet and leaves a plain paragraph.
13458 let mut d = doc_in(View::Wysiwyg, "bsp_strip", "- a\n- b\n");
13459 d.caret = d.source.find('b').unwrap(); // right after `- `
13460 d.backspace();
13461 assert_eq!(d.source, "- a\nb\n", "the marker is gone, the text stays");
13462 }
13463
13464 #[test]
13465 fn backspace_mid_item_still_deletes_a_character() {
13466 // The list behaviour is armed only at the item's content start; anywhere
13467 // else Backspace is the ordinary character delete.
13468 let mut d = doc_in(View::Wysiwyg, "bsp_mid", "- ab\n");
13469 d.caret = d.source.find('b').unwrap(); // between `a` and `b`
13470 d.backspace();
13471 assert_eq!(d.source, "- b\n");
13472 }
13473
13474 #[test]
13475 fn backspace_at_a_heading_start_strips_the_marker() {
13476 // The `# ` is markup the rich view hides, so Backspace over it takes the
13477 // whole marker and leaves a paragraph. Deleting a byte of it instead left
13478 // `#Title` — no longer a heading, with the hash now literal text the user
13479 // never typed and has to delete again.
13480 let mut d = doc_in(View::Wysiwyg, "bsp_head", "## Title\n");
13481 d.caret = d.source.find('T').unwrap(); // right after `## `
13482 d.backspace();
13483 assert_eq!(d.source, "Title\n");
13484 assert_eq!(
13485 d.caret, 0,
13486 "the caret stays with the text it was in front of"
13487 );
13488 }
13489
13490 #[test]
13491 fn backspace_at_a_heading_start_keeps_the_block_around_it() {
13492 // Only the heading's own marker goes — the quote (or list) it sits in is
13493 // untouched, exactly as un-heading it should be.
13494 let mut d = doc_in(View::Wysiwyg, "bsp_head_quote", "> # Title\n");
13495 d.caret = d.source.find('T').unwrap();
13496 d.backspace();
13497 assert_eq!(d.source, "> Title\n");
13498 }
13499
13500 #[test]
13501 fn backspace_at_a_heading_start_takes_its_closing_sequence_too() {
13502 // `# Title #`'s trailing hashes are hidden at the other end; leaving them
13503 // behind would surface the same stray hash the marker delete just avoided.
13504 let mut d = doc_in(View::Wysiwyg, "bsp_head_closed", "# Title #\n");
13505 d.caret = d.source.find('T').unwrap();
13506 d.backspace();
13507 assert_eq!(d.source, "Title\n");
13508 // And it's one edit: a single undo puts the whole heading back.
13509 d.undo();
13510 assert_eq!(d.source, "# Title #\n");
13511 }
13512
13513 #[test]
13514 fn backspace_mid_heading_still_deletes_a_character() {
13515 // The heading behaviour is armed only at the content's start; anywhere
13516 // else Backspace is the ordinary character delete.
13517 let mut d = doc_in(View::Wysiwyg, "bsp_head_mid", "# ab\n");
13518 d.caret = d.source.find('b').unwrap();
13519 d.backspace();
13520 assert_eq!(d.source, "# b\n");
13521 }
13522
13523 #[test]
13524 fn source_view_backspace_still_edits_the_heading_marker_literally() {
13525 // In source view the `# ` is text on the screen the user is deleting a
13526 // byte of, so it keeps its literal meaning — the same split the list
13527 // ladder and Enter draw between the two views.
13528 let mut d = doc_with("bsp_head_src", "# Title\n");
13529 d.caret = d.source.find('T').unwrap();
13530 d.backspace();
13531 assert_eq!(d.source, "#Title\n");
13532 }
13533
13534 #[test]
13535 fn outdent_unnests_an_ordered_item_in_one_press() {
13536 // Shift+Tab gives back exactly the marker width the indent added, so a
13537 // nested ordered item unnests in a single press, and the flattened list
13538 // renumbers back to a clean 1, 2, 3.
13539 let mut d = doc_with("outdent_ord", "1. a\n 2. b\n3. c\n");
13540 d.caret = d.source.find('b').unwrap();
13541 d.outdent();
13542 assert_eq!(d.source, "1. a\n2. b\n3. c\n");
13543 let lists = d
13544 .nodes()
13545 .iter()
13546 .filter(|n| n.kind == Kind::OrderedList)
13547 .count();
13548 assert_eq!(lists, 1, "back to one flat list");
13549 }
13550
13551 #[test]
13552 fn table_insert_row_adds_a_row_below_the_caret() {
13553 let mut d = doc_with("tbl_ins_row", "| a | b |\n| --- | --- |\n| 1 | 2 |\n");
13554 d.caret = d.source.find('1').unwrap(); // in the body row
13555 d.table_insert_row(true);
13556 assert_eq!(d.source, "| a | b |\n| --- | --- |\n| 1 | 2 |\n| | |\n");
13557 }
13558
13559 #[test]
13560 fn table_insert_and_delete_column_at_the_caret() {
13561 let mut d = doc_with("tbl_col", "| a | b |\n| --- | --- |\n| 1 | 2 |\n");
13562 d.caret = d.source.find('a').unwrap(); // column 0
13563 d.table_insert_column(true); // add a column to the right of `a`
13564 assert_eq!(
13565 d.source,
13566 "| a | | b |\n| --- | --- | --- |\n| 1 | | 2 |\n"
13567 );
13568 d.caret = d.source.find('b').unwrap(); // now the third column
13569 d.table_delete_column();
13570 assert_eq!(d.source, "| a | |\n| --- | --- |\n| 1 | |\n");
13571 }
13572
13573 // ── ragged formats ───────────────────────────────────────────────────────
13574 // No format spells every gesture. HTML writes the inline marks as a tag pair
13575 // and no heading, list, quote or link; Markdown spells five of the eight
13576 // marks — the highlight only because leaf parses with `highlight`, which is
13577 // why the question is asked with the extensions; djot spells all eight and
13578 // no in-cell break. leaf asks twig per
13579 // gesture (`Doc::supports`) and refuses at the door, rather than letting each
13580 // op discover the fact on its own — one of them didn't.
13581
13582 /// An HTML document in the rich view, ready for a gesture.
13583 fn html_doc(body: &str) -> Doc {
13584 let mut d = Doc::from_source(body.to_string(), Format::Html).unwrap();
13585 d.view = View::Wysiwyg;
13586 d.build_visual(80);
13587 d
13588 }
13589
13590 #[test]
13591 fn a_table_gesture_leaves_an_html_table_alone() {
13592 // The regression this guard exists for. twig's table editor consults no
13593 // `Syntax` table — it spells a grid, not a delimiter — so it rebuilt an
13594 // HTML `<table>` as a *pipe table* and reported success: the whole
13595 // element replaced by `| a | b |`, silently, on one press of a toolbar
13596 // button. Every grid op went the same way.
13597 let src = "<table><tr><td>a</td><td>b</td></tr><tr><td>c</td><td>d</td></tr></table>\n";
13598 // A table of named operations, which is what it looks like.
13599 #[allow(clippy::type_complexity)]
13600 let ops: [(&str, &dyn Fn(&mut Doc)); 7] = [
13601 ("insert row", &|d: &mut Doc| d.table_insert_row(true)),
13602 ("delete row", &|d: &mut Doc| d.table_delete_row()),
13603 ("insert column", &|d: &mut Doc| d.table_insert_column(true)),
13604 ("delete column", &|d: &mut Doc| d.table_delete_column()),
13605 ("align", &|d: &mut Doc| {
13606 d.table_set_alignment(Alignment::Right)
13607 }),
13608 ("move row", &|d: &mut Doc| d.table_move_row(true)),
13609 ("move column", &|d: &mut Doc| d.table_move_column(true)),
13610 ];
13611 for (name, op) in ops {
13612 let mut d = html_doc(src);
13613 d.caret = d.source.find('a').unwrap();
13614 assert!(d.caret_in_table(), "{name}: the caret really is in a table");
13615 op(&mut d);
13616 assert_eq!(d.source, src, "{name} rewrote an HTML table");
13617 assert!(
13618 !d.dirty,
13619 "{name} marked the document dirty without editing it"
13620 );
13621 assert!(d.status.is_some(), "{name} refused without saying why");
13622 }
13623 }
13624
13625 #[test]
13626 fn the_block_gestures_html_cannot_spell_are_refused_with_a_reason() {
13627 // A task box is a form control in HTML and a footnote has no native
13628 // spelling at all — the two gestures twig 3.5 still spells nothing
13629 // for, now that a quote, a list, a link and an image print through
13630 // its renderer (see the test below).
13631 let src = "<h1>Title</h1>\n<p>Hello world</p>\n<ul><li>one</li></ul>\n";
13632 // A table of named operations, which is what it looks like.
13633 #[allow(clippy::type_complexity)]
13634 let ops: [(&str, &dyn Fn(&mut Doc)); 3] = [
13635 ("task item", &|d: &mut Doc| d.toggle_task_item()),
13636 ("task tick", &|d: &mut Doc| d.toggle_task_checked()),
13637 ("footnote", &|d: &mut Doc| d.insert_footnote()),
13638 ];
13639 for (name, op) in ops {
13640 let mut d = html_doc(src);
13641 let at = d.source.find("Hello").unwrap();
13642 d.caret = at;
13643 d.anchor = Some(at + 5); // a selection, for the ops that want one
13644 op(&mut d);
13645 assert_eq!(d.source, src, "{name} edited an HTML document");
13646 assert!(
13647 !d.dirty,
13648 "{name} marked the document dirty without editing it"
13649 );
13650 let status = d.status.as_deref().unwrap_or("");
13651 assert!(
13652 status.contains("html"),
13653 "{name}: the refusal should name the format, got {status:?}"
13654 );
13655 }
13656 }
13657
13658 #[test]
13659 fn html_spells_a_quote_a_list_a_link_and_an_image_through_the_renderer() {
13660 // twig 3.5: where HTML has no marker alphabet it prints the fresh
13661 // node — a `<blockquote>` around the paragraph, a `<ul>`/`<ol>` with
13662 // the paragraph as its item, an `<a>` or `<img>` over the selection.
13663 // Until then every one of these was a refusal; now each is a real
13664 // edit, which is what the toolbar's capability flags say too.
13665 let src = "<h1>Title</h1>\n<p>Hello world</p>\n<ul><li>one</li></ul>\n";
13666 #[allow(clippy::type_complexity)]
13667 let ops: [(&str, &dyn Fn(&mut Doc), &str); 5] = [
13668 (
13669 "quote",
13670 &|d: &mut Doc| d.toggle_blockquote(),
13671 "<blockquote>",
13672 ),
13673 ("list", &|d: &mut Doc| d.toggle_list(false), "<ul>\n<li>"),
13674 (
13675 "ordered list",
13676 &|d: &mut Doc| d.toggle_list(true),
13677 "<ol>\n<li>",
13678 ),
13679 (
13680 "link",
13681 &|d: &mut Doc| d.insert_link("https://example.dev"),
13682 "<a href=\"https://example.dev\">Hello</a>",
13683 ),
13684 (
13685 "image",
13686 &|d: &mut Doc| d.insert_image("pic.png", "alt"),
13687 "<img alt=\"Hello\" src=\"pic.png\">",
13688 ),
13689 ];
13690 for (name, op, expect) in ops {
13691 let mut d = html_doc(src);
13692 let at = d.source.find("Hello").unwrap();
13693 d.caret = at;
13694 d.anchor = Some(at + 5);
13695 op(&mut d);
13696 assert!(d.source.contains(expect), "{name}: got {:?}", d.source);
13697 assert!(d.dirty, "{name}: a real edit");
13698 assert_eq!(
13699 d.status, None,
13700 "{name}: a supported gesture reports nothing"
13701 );
13702 }
13703 }
13704
13705 #[test]
13706 fn html_spells_a_heading_as_its_tag_pair() {
13707 // twig 3.4 rebuilds a heading or paragraph as its tag pair, attributes
13708 // along — the one block gesture whose HTML shape it can write. So ⌘2
13709 // in an HTML document is a real edit, and ⌘0 takes it back.
13710 let src = "<h1>Title</h1>\n<p>Hello world</p>\n";
13711 let mut d = html_doc(src);
13712 d.caret = d.source.find("Hello").unwrap();
13713 d.toggle_heading(2);
13714 assert_eq!(d.source, "<h1>Title</h1>\n<h2>Hello world</h2>\n");
13715 assert!(d.dirty);
13716 assert_eq!(d.status, None, "a supported gesture reports nothing");
13717 d.toggle_heading(2);
13718 assert_eq!(d.source, src, "the same level again is back to a paragraph");
13719 }
13720
13721 #[test]
13722 fn html_spells_the_inline_marks_and_the_rule() {
13723 // The other half, and why one per-document flag stopped being enough:
13724 // ⌘B in an HTML document writes `<strong>` — the tag the serializer
13725 // already emits and the parser reads straight back as the same mark —
13726 // and the rule button writes an `<hr>`. Refusing these on the old
13727 // "HTML is parse-only" reading would now be leaf's own limitation.
13728 let mut d = html_doc("<p>Hello world</p>\n");
13729 let at = d.source.find("world").unwrap();
13730 d.caret = at;
13731 d.anchor = Some(at + 5);
13732 d.toggle(InlineKind::Strong);
13733 assert_eq!(d.source, "<p>Hello <strong>world</strong></p>\n");
13734 assert!(d.dirty);
13735 assert_eq!(d.status, None, "a supported gesture reports nothing");
13736
13737 // And off again — the toggle reverses, which is the property that makes
13738 // authoring in HTML worth offering rather than a one-way trip.
13739 d.toggle(InlineKind::Strong);
13740 assert_eq!(d.source, "<p>Hello world</p>\n");
13741
13742 let mut d = html_doc("<p>Hello world</p>\n");
13743 d.caret = d.source.find("world").unwrap();
13744 d.insert_thematic_break();
13745 assert!(d.source.contains("<hr>"), "got {:?}", d.source);
13746 }
13747
13748 #[test]
13749 fn a_mark_the_format_cannot_spell_arms_nothing() {
13750 // `toggle` with a collapsed caret doesn't reach twig at all — it arms a
13751 // sticky mark for the next text typed. Guarding only the twig call
13752 // leaves that path live, promising a mark the gesture will not write and
13753 // then swallowing the error inside `insert`.
13754 //
13755 // Markdown carries this, on the superscript now rather than on the
13756 // highlight: `^x^` is text there in any configuration, whereas twig
13757 // 3.3.1 authors `==x==` for an editor holding the `highlight` extension,
13758 // which every leaf document does.
13759 let mut d = doc_with("mark", "Hello world\n");
13760 d.view = View::Wysiwyg;
13761 d.build_visual(80);
13762 d.caret = d.source.find("world").unwrap();
13763 d.toggle(InlineKind::Superscript);
13764 assert!(d.pending_marks.is_empty(), "no mark should be armed");
13765 assert!(d.status.as_deref().unwrap_or("").contains("markdown"));
13766 d.insert("X");
13767 assert_eq!(d.source, "Hello Xworld\n");
13768 }
13769
13770 #[test]
13771 fn markdown_authors_a_highlight_and_a_strikethrough() {
13772 // twig 3.3.1: the two marks Markdown reads and, until it, refused to
13773 // write. `==x==` is authorable because leaf's own `parse_extensions`
13774 // turns `highlight` on — twig will only mint bytes this editor's reparse
13775 // reads back — and `~~x~~` because GFM strikethrough is parsed by
13776 // default, so the refusal there was never right for any leaf document.
13777 for (kind, marked) in [
13778 (InlineKind::Mark, "a ==word== b\n"),
13779 (InlineKind::Delete, "a ~~word~~ b\n"),
13780 ] {
13781 let mut d = doc_with("author_mark", "a word b\n");
13782 d.anchor = Some(2);
13783 d.caret = 6;
13784 d.toggle(kind);
13785 assert_eq!(d.source, marked, "{kind:?}");
13786 assert_eq!(d.status, None, "{kind:?}: a supported gesture is silent");
13787 assert!(d.dirty, "{kind:?}");
13788 // The region stays selected, so the second press reverses it — the
13789 // property that separates authoring from a one-way trip.
13790 d.toggle(kind);
13791 assert_eq!(d.source, "a word b\n", "{kind:?}");
13792 }
13793 }
13794
13795 #[test]
13796 fn an_authored_highlight_reads_back_as_a_mark() {
13797 // The round trip the extension gate exists to protect: what the toggle
13798 // writes, the reparse must read back as a `mark` rather than as two
13799 // literal `=` pairs. A `Role::Mark` glyph is that answer, taken from the
13800 // rebuilt map rather than from the source text.
13801 let mut d = doc_with("mark_roundtrip", "a word b\n");
13802 d.view = View::Wysiwyg;
13803 d.build_visual(80);
13804 d.anchor = Some(2);
13805 d.caret = 6;
13806 d.toggle(InlineKind::Mark);
13807 assert_eq!(d.source, "a ==word== b\n");
13808 d.build_visual(80);
13809 let w = d
13810 .vmap
13811 .rows
13812 .iter()
13813 .flat_map(|r| r.glyphs.iter())
13814 .find(|g| g.ch == 'w')
13815 .expect("the highlighted word");
13816 assert_eq!(w.style.role, crate::Role::Mark(None));
13817 }
13818
13819 #[test]
13820 fn a_highlight_takes_a_colour_changes_it_and_gives_it_back() {
13821 // The three states of one gesture, in the order a palette is pressed:
13822 // an uncoloured highlight takes the prefix, a coloured one has it
13823 // replaced, and `None` takes it away with the space that was part of the
13824 // spelling.
13825 let mut d = doc_with("mark_colour", "a ==word== b\n");
13826 d.caret = d.source.find("word").unwrap();
13827 d.set_mark_color(Some(MarkColor::Red));
13828 assert_eq!(d.source, "a ==🔴 word== b\n");
13829 assert_eq!(d.status, None);
13830 assert!(d.dirty);
13831
13832 d.set_mark_color(Some(MarkColor::Blue));
13833 assert_eq!(d.source, "a ==🔵 word== b\n");
13834
13835 d.set_mark_color(None);
13836 assert_eq!(d.source, "a ==word== b\n");
13837 }
13838
13839 #[test]
13840 fn the_caret_keeps_its_place_in_the_text_across_a_colour() {
13841 // The prefix is written *before* the word, so an offset in the word has
13842 // to ride its width — a caret that stayed put would be a caret that
13843 // walked backwards through the text it was standing in.
13844 let mut d = doc_with("mark_colour_caret", "a ==word== b\n");
13845 let word = d.source.find("word").unwrap();
13846 d.caret = word + 2; // between `wo` and `rd`
13847 d.set_mark_color(Some(MarkColor::Red));
13848 assert_eq!(&d.source[d.caret..d.caret + 2], "rd", "still before `rd`");
13849
13850 // And back the other way when the prefix goes.
13851 d.set_mark_color(None);
13852 assert_eq!(&d.source[d.caret..d.caret + 2], "rd");
13853 }
13854
13855 #[test]
13856 fn the_colour_at_the_caret_is_what_the_palette_lights() {
13857 let mut d = doc_with("mark_colour_read", "a ==🔴 red== and ==plain== b\n");
13858 d.caret = d.source.find("red").unwrap();
13859 assert!(d.caret_in_mark());
13860 assert_eq!(d.mark_color_at_caret(), Some(MarkColor::Red));
13861
13862 d.caret = d.source.find("plain").unwrap();
13863 assert!(d.caret_in_mark(), "a highlight with no colour is still one");
13864 assert_eq!(d.mark_color_at_caret(), None);
13865
13866 d.caret = d.source.find(" and ").unwrap() + 2;
13867 assert!(!d.caret_in_mark());
13868 assert_eq!(d.mark_color_at_caret(), None);
13869 }
13870
13871 #[test]
13872 fn a_colour_without_a_highlight_says_so_and_writes_nothing() {
13873 // The gesture colours a highlight that exists; it does not make one.
13874 // Two presses is the price of a coloured highlight from bare text, and
13875 // the reason is undo — one press that spliced twice would take two
13876 // presses to take back.
13877 let mut d = doc_with("mark_colour_none", "a word b\n");
13878 d.caret = d.source.find("word").unwrap();
13879 d.set_mark_color(Some(MarkColor::Red));
13880 assert_eq!(d.source, "a word b\n");
13881 assert!(d.status.is_some(), "it should say why");
13882 assert!(!d.dirty);
13883
13884 // Clearing where there is nothing to clear is the same refusal, not a
13885 // quiet success — the caret is in no highlight either way.
13886 d.status = None;
13887 d.set_mark_color(None);
13888 assert_eq!(d.source, "a word b\n");
13889 assert!(d.status.is_some());
13890 }
13891
13892 #[test]
13893 fn clearing_an_uncoloured_highlight_is_a_quiet_no_op() {
13894 // twig answers this one *successfully* with a `Change` describing some
13895 // earlier edit, so a caller that trusted the change would jump the caret
13896 // to wherever that was. Core answers it before asking.
13897 let mut d = doc_with("mark_colour_noop", "a ==word== b\n");
13898 d.toggle(InlineKind::Strong); // an earlier edit for a stale change to name
13899 d.caret = d.source.find("word").unwrap();
13900 let (source, caret) = (d.source.clone(), d.caret);
13901 d.set_mark_color(None);
13902 assert_eq!(d.source, source);
13903 assert_eq!(
13904 d.caret, caret,
13905 "the caret must not ride a change that isn't one"
13906 );
13907 assert_eq!(d.status, None, "and it is not an error either");
13908 }
13909
13910 #[test]
13911 fn djot_spells_the_highlight_and_not_its_colour() {
13912 // The reason the palette is its own capability rather than the Highlight
13913 // button's: `{=word=}` is a highlight djot writes happily, and there is
13914 // no djot spelling for a colour on it.
13915 assert!(Capabilities::of(Format::Djot).mark);
13916 assert!(!Capabilities::of(Format::Djot).mark_color);
13917 assert!(Capabilities::of(Format::Markdown).mark_color);
13918
13919 let mut d = Doc::from_source("a {=word=} b\n".into(), Format::Djot).unwrap();
13920 d.caret = d.source.find("word").unwrap();
13921 assert!(
13922 d.caret_in_mark(),
13923 "the caret is in a highlight all the same"
13924 );
13925 d.set_mark_color(Some(MarkColor::Red));
13926 assert_eq!(d.source, "a {=word=} b\n");
13927 assert!(
13928 d.status.as_deref().unwrap_or("").contains("djot"),
13929 "and the refusal names the document's format: {:?}",
13930 d.status
13931 );
13932 }
13933
13934 #[test]
13935 fn a_coloured_highlight_is_one_undo_step_and_reads_back_as_its_colour() {
13936 // The round trip that matters for a palette: the bytes twig writes are
13937 // bytes its own reparse reads back as a colour, so the swatch that was
13938 // pressed is the swatch that lights afterwards.
13939 let mut d = doc_with("mark_colour_undo", "a word b\n");
13940 d.anchor = Some(2);
13941 d.caret = 6;
13942 d.toggle(InlineKind::Mark);
13943 d.caret = d.source.find("word").unwrap();
13944 d.set_mark_color(Some(MarkColor::Green));
13945 assert_eq!(d.source, "a ==🟢 word== b\n");
13946 assert_eq!(d.mark_color_at_caret(), Some(MarkColor::Green));
13947
13948 // One splice, one step: the colour comes off and the highlight stays.
13949 d.undo();
13950 assert_eq!(d.source, "a ==word== b\n");
13951 d.undo();
13952 assert_eq!(d.source, "a word b\n");
13953 }
13954
13955 #[test]
13956 fn every_colour_leaf_names_is_one_twig_writes() {
13957 // The two enums are one vocabulary, and this is what says so: each of
13958 // leaf's colours writes an emoji twig's reparse reads back as *that*
13959 // colour, so `twig_mark_color`'s table cannot quietly pair red with
13960 // orange.
13961 for color in MarkColor::ALL {
13962 let mut d = doc_with("mark_colour_all", "a ==word== b\n");
13963 d.caret = d.source.find("word").unwrap();
13964 d.set_mark_color(Some(color));
13965 assert_eq!(d.status, None, "{color:?}");
13966 assert_eq!(d.mark_color_at_caret(), Some(color), "{color:?}");
13967 }
13968 }
13969
13970 #[test]
13971 fn a_fresh_highlight_takes_a_colour_without_moving_the_caret_first() {
13972 // The two presses a coloured highlight is made of, in the state the
13973 // first one leaves: `toggle` selects the whole `==word==` and puts the
13974 // caret one past the closing `==`, which is *not* in the mark. Asking at
13975 // the caret alone would refuse to colour the highlight just written —
13976 // the selection's start is what answers.
13977 let mut d = doc_with("mark_colour_fresh", "a word b\n");
13978 d.anchor = Some(2);
13979 d.caret = 6;
13980 d.toggle(InlineKind::Mark);
13981 assert_eq!(d.source, "a ==word== b\n");
13982 assert_eq!(d.caret, 10, "the caret twig leaves, past the closing `==`");
13983
13984 assert!(d.caret_in_mark(), "the selected highlight is the one meant");
13985 d.set_mark_color(Some(MarkColor::Yellow));
13986 assert_eq!(d.source, "a ==🟡 word== b\n");
13987 assert_eq!(d.status, None);
13988 }
13989
13990 #[test]
13991 fn one_press_highlights_a_selection_and_colours_it() {
13992 // What a toolbar swatch means over a plain selection, and the undo it
13993 // has to have: one press, one step. Two steps would leave an uncoloured
13994 // highlight behind on the way back, which is a state the author never
13995 // asked for and never saw.
13996 let mut d = doc_with("highlight_one", "a word b\n");
13997 d.anchor = Some(2);
13998 d.caret = 6;
13999 d.highlight(Some(MarkColor::Purple));
14000 assert_eq!(d.source, "a ==\u{1F7E3} word== b\n");
14001 assert_eq!(d.status, None);
14002
14003 d.undo();
14004 assert_eq!(d.source, "a word b\n", "one press, one undo");
14005 }
14006
14007 #[test]
14008 fn one_press_on_an_existing_highlight_only_recolours_it() {
14009 // The other half: inside a highlight there is nothing to make, so the
14010 // compound is the plain gesture and the text is untouched.
14011 let mut d = doc_with("highlight_recolour", "a ==\u{1F534} word== b\n");
14012 d.caret = d.source.find("word").unwrap();
14013 d.highlight(Some(MarkColor::Blue));
14014 assert_eq!(d.source, "a ==\u{1F535} word== b\n");
14015 d.undo();
14016 assert_eq!(d.source, "a ==\u{1F534} word== b\n", "the highlight stays");
14017 }
14018
14019 #[test]
14020 fn one_press_with_no_colour_over_a_selection_just_highlights_it() {
14021 // `None` means "no colour", and over bare text that is the Highlight
14022 // button's own job. The fold must not happen here — there is no second
14023 // splice, and folding would take the *previous* edit into this one.
14024 let mut d = doc_with("highlight_none", "a word b and more\n");
14025 d.caret = d.source.find("more").unwrap() + 4; // after "more"
14026 d.insert("!"); // an earlier edit for a wrong fold to swallow
14027 d.anchor = Some(2);
14028 d.caret = 6;
14029 d.highlight(None);
14030 assert_eq!(d.source, "a ==word== b and more!\n");
14031
14032 d.undo();
14033 assert_eq!(
14034 d.source, "a word b and more!\n",
14035 "only the highlight came off"
14036 );
14037 d.undo();
14038 assert_eq!(
14039 d.source, "a word b and more\n",
14040 "and the edit before it survived"
14041 );
14042 }
14043
14044 #[test]
14045 fn one_press_at_a_bare_caret_in_no_highlight_writes_nothing() {
14046 // `toggle` at a collapsed caret arms a mark for text not yet typed, and
14047 // a colour cannot be armed with it — so the compound declines rather
14048 // than leaving half a promise.
14049 let mut d = doc_with("highlight_bare", "a word b\n");
14050 d.caret = 4;
14051 d.highlight(Some(MarkColor::Red));
14052 assert_eq!(d.source, "a word b\n");
14053 assert!(d.pending_marks.is_empty(), "and nothing armed");
14054 assert!(d.status.is_some());
14055 }
14056
14057 #[test]
14058 fn a_read_only_document_takes_no_colour() {
14059 let mut d = doc_with("mark_colour_ro", "a ==word== b\n");
14060 d.caret = d.source.find("word").unwrap();
14061 d.set_read_only(true);
14062 d.set_mark_color(Some(MarkColor::Red));
14063 assert_eq!(d.source, "a ==word== b\n");
14064 }
14065
14066 #[test]
14067 fn a_sticky_highlight_wraps_the_next_typed_text_in_markdown() {
14068 // The other door into `toggle`: no selection, so nothing reaches twig
14069 // until `insert` realises the armed mark. It is armed now — the guard
14070 // above asks `Doc::supports`, which asks with the extensions — and what
14071 // it writes is the same `==…==`.
14072 let mut d = doc_with("sticky_mark", "xy\n");
14073 d.caret = 1;
14074 d.toggle(InlineKind::Mark);
14075 assert!(d.pending_marks.contains(InlineKind::Mark));
14076 d.insert("Z");
14077 assert_eq!(d.source, "x==Z==y\n");
14078 }
14079
14080 #[test]
14081 fn html_documents_still_take_typed_text() {
14082 // The guard covers *markup* gestures and must not touch plain editing:
14083 // twig's splicer is language-neutral, and typing into an HTML document
14084 // is the thing that does work today.
14085 let mut d = html_doc("<p>Hello world</p>\n");
14086 d.caret = d.source.find("world").unwrap();
14087 d.insert("big ");
14088 assert_eq!(d.source, "<p>Hello big world</p>\n");
14089 assert!(d.dirty);
14090 d.backspace();
14091 assert_eq!(d.source, "<p>Hello bigworld</p>\n");
14092 d.undo();
14093 d.undo();
14094 assert_eq!(d.source, "<p>Hello world</p>\n");
14095 }
14096
14097 #[test]
14098 fn authorable_is_the_coarse_question_and_capabilities_the_useful_one() {
14099 // `authorable` only separates "there is a door in" from "there is not",
14100 // and HTML is on the near side of that line — which is exactly why a
14101 // toolbar must not be built from it.
14102 let html = Doc::from_source("<p>x</p>\n".into(), Format::Html).unwrap();
14103 assert!(html.authorable());
14104 assert!(
14105 !Doc::from_source("<r>x</r>".into(), Format::Xml)
14106 .unwrap()
14107 .authorable()
14108 );
14109
14110 let caps = html.capabilities();
14111 assert!(caps.bold && caps.italic && caps.code && caps.mark);
14112 assert!(caps.thematic_break && caps.cell_line_break);
14113 // A heading is a tag pair twig rebuilds (3.4), and since 3.5 so are a
14114 // quote, a list, a code block's language, a link and an image — each
14115 // printed as a fresh node where HTML has no marker to rewrite. A task
14116 // box is a form control and a footnote has no spelling, so those two
14117 // are what keeps the record ragged.
14118 assert!(caps.heading && caps.blockquote && caps.bullet_list);
14119 assert!(caps.link && caps.image && caps.code_language);
14120 assert!(!caps.task && !caps.footnote);
14121 // The one flag that isn't twig's answer: an HTML `<table>` is a grid
14122 // twig's table editor would happily re-emit as `| a | b |`.
14123 assert!(!caps.table);
14124
14125 // The two lightweight formats spell everything leaf offers — and still
14126 // differ from each other, which is the other half of why one boolean
14127 // can't serve.
14128 for fmt in [Format::Markdown, Format::Djot] {
14129 let caps = Capabilities::of(fmt);
14130 assert!(
14131 caps.heading && caps.blockquote && caps.ordered_list,
14132 "{fmt:?}"
14133 );
14134 assert!(
14135 caps.task && caps.link && caps.image && caps.table,
14136 "{fmt:?}"
14137 );
14138 }
14139 // Both spell the highlight and the strikethrough: djot natively, and
14140 // Markdown because `Capabilities` asks with `parse_extensions` rather
14141 // than with twig's defaults — `==x==` is text under those, and a mark
14142 // under the `highlight` leaf always parses with.
14143 for fmt in [Format::Markdown, Format::Djot] {
14144 let caps = Capabilities::of(fmt);
14145 assert!(caps.mark && caps.strike, "{fmt:?}");
14146 }
14147 // What still separates them, now that the highlight doesn't: djot has
14148 // no in-cell break, and Markdown spells neither of the scripts.
14149 assert!(Capabilities::of(Format::Djot).superscript);
14150 assert!(!Capabilities::of(Format::Markdown).superscript);
14151 assert!(Capabilities::of(Format::Markdown).cell_line_break);
14152 assert!(!Capabilities::of(Format::Djot).cell_line_break);
14153
14154 // A parse-only format answers no to every one of them, so the coarse
14155 // predicate and the record agree there.
14156 let caps = Capabilities::of(Format::Xml);
14157 assert!(!caps.bold && !caps.heading && !caps.table && !caps.thematic_break);
14158 }
14159
14160 #[test]
14161 fn a_refused_gesture_says_so_where_twig_would_have_said_it() {
14162 // The guard exists to name the *document's* format rather than twig's
14163 // internals, so the message has to survive being one leaf writes itself.
14164 // Checked against a gesture twig also refuses, since that is the pair
14165 // most at risk of drifting apart — the task box, once the code
14166 // language stopped being one (twig 3.5).
14167 let mut d = html_doc("<p>Hello</p>\n");
14168 d.caret = d.source.find("Hello").unwrap();
14169 d.toggle_task_item();
14170 assert_eq!(d.status.as_deref(), Some("task: not supported in html"));
14171 assert!(!d.dirty);
14172 }
14173
14174 #[test]
14175 fn table_set_alignment_respells_the_delimiter() {
14176 let mut d = doc_with("tbl_align", "| a | b |\n| --- | --- |\n| 1 | 2 |\n");
14177 d.caret = d.source.find('b').unwrap();
14178 d.table_set_alignment(Alignment::Right);
14179 assert_eq!(d.source, "| a | b |\n| --- | ---: |\n| 1 | 2 |\n");
14180 }
14181
14182 #[test]
14183 fn each_empty_table_cell_has_its_own_editable_home() {
14184 // Regression: an empty cell has no twig content_span, so both cells of a
14185 // `| | |` row collapsed onto the row's start (before the first `│`).
14186 // Typing there inserted *before* the table (`hello| | |`); nav couldn't
14187 // tell the cells apart. Each empty cell must now have a distinct home
14188 // inside it.
14189 let mut d = wysiwyg_doc("tbl_empty", "| a | b |\n| --- | --- |\n| | |\n");
14190 let (c0, c1) = {
14191 let cells = &d.vmap.tables[0].grid[1].cells;
14192 (cells[0].start, cells[1].start)
14193 };
14194 assert!(
14195 c0 < c1,
14196 "the two empty cells have distinct homes: {c0} < {c1}"
14197 );
14198 d.caret = c0;
14199 d.insert("x");
14200 assert_eq!(
14201 d.source, "| a | b |\n| --- | --- |\n| x | |\n",
14202 "typed inside the cell"
14203 );
14204 }
14205
14206 #[test]
14207 fn arrows_step_into_each_empty_table_cell() {
14208 let mut d = wysiwyg_doc("tbl_empty_nav", "| a | b |\n| --- | --- |\n| | |\n");
14209 let (c0, c1) = {
14210 let cells = &d.vmap.tables[0].grid[1].cells;
14211 (cells[0].start, cells[1].start)
14212 };
14213 d.caret = d.source.find('b').unwrap(); // in the header's second cell
14214 let mut seen = std::collections::HashSet::new();
14215 for _ in 0..6 {
14216 d.move_right(false);
14217 seen.insert(d.caret);
14218 }
14219 assert!(
14220 seen.contains(&c0),
14221 "right arrow reaches the first empty cell"
14222 );
14223 assert!(
14224 seen.contains(&c1),
14225 "right arrow reaches the second empty cell"
14226 );
14227 }
14228
14229 #[test]
14230 fn table_op_off_a_table_is_a_no_op_with_a_status() {
14231 let mut d = doc_with("tbl_none", "just text\n");
14232 d.caret = 3;
14233 d.table_insert_row(true);
14234 assert_eq!(d.source, "just text\n", "nothing changed");
14235 assert!(d.status.is_some(), "a status explains why");
14236 assert!(!d.caret_in_table());
14237 }
14238
14239 #[test]
14240 fn enter_in_an_ordered_list_renumbers_the_following_items() {
14241 // Inserting an item mid-list left the source markers stale (`1. 2. 2. 3.`);
14242 // the renumber pass keeps them sequential, matching what the view draws.
14243 let mut d = wysiwyg_doc("enter_renumber", "1. a\n2. b\n3. c\n");
14244 d.caret = d.source.find('a').unwrap() + 1; // end of item a
14245 d.newline();
14246 d.insert("x");
14247 assert_eq!(d.source, "1. a\n2. x\n3. b\n4. c\n");
14248 }
14249
14250 #[test]
14251 fn outdent_with_nothing_to_give_back_records_no_undo_step() {
14252 for view in [View::Source, View::Wysiwyg] {
14253 let mut d = doc_in(view, "outdent_noop", "hello\n");
14254 d.caret = 2;
14255 d.outdent();
14256 assert_eq!(d.source, "hello\n");
14257 assert!(!d.dirty, "a no-op is not a modification");
14258 d.undo();
14259 assert_eq!(
14260 d.status.as_deref(),
14261 Some("nothing to undo"),
14262 "spends no undo step"
14263 );
14264 assert_eq!(d.source, "hello\n");
14265 }
14266 }
14267
14268 #[test]
14269 fn indent_shifts_every_selected_line_and_keeps_them_selected() {
14270 for view in [View::Source, View::Wysiwyg] {
14271 let mut d = doc_in(view, "indent_sel", "one\n\ntwo\n");
14272 d.anchor = Some(0);
14273 d.caret = 7; // through "two"
14274 d.indent();
14275 assert_eq!(
14276 d.source, " one\n\n two\n",
14277 "the blank line keeps no trailing pad"
14278 );
14279 // Selected, so a second Tab lands on the same lines rather than on
14280 // whatever the shifted offsets now cover.
14281 assert_eq!(d.selection(), Some((0, 12)));
14282 d.indent();
14283 assert_eq!(d.source, " one\n\n two\n");
14284 }
14285 }
14286
14287 #[test]
14288 fn outdent_takes_what_each_line_has_and_leaves_the_rest_alone() {
14289 for view in [View::Source, View::Wysiwyg] {
14290 let mut d = doc_in(view, "outdent_sel", " two\n one\nnone\n");
14291 d.anchor = Some(0);
14292 d.caret = 15;
14293 d.outdent();
14294 assert_eq!(d.source, "two\none\nnone\n");
14295 }
14296 }
14297
14298 #[test]
14299 fn a_tab_undoes_as_one_step_however_many_lines_it_moved() {
14300 for view in [View::Source, View::Wysiwyg] {
14301 let mut d = doc_in(view, "indent_undo", "one\n\ntwo\n");
14302 d.anchor = Some(0);
14303 d.caret = 7;
14304 d.indent();
14305 assert_eq!(d.source, " one\n\n two\n");
14306 d.undo();
14307 assert_eq!(d.source, "one\n\ntwo\n", "one step, not one per line");
14308 assert_eq!(
14309 d.selection(),
14310 Some((0, 7)),
14311 "with the selection it was aimed at"
14312 );
14313 d.redo();
14314 assert_eq!(d.source, " one\n\n two\n");
14315 assert_eq!(
14316 d.selection(),
14317 Some((0, 12)),
14318 "redo replays the caret the indent placed, not the one splice left"
14319 );
14320 }
14321 }
14322
14323 #[test]
14324 fn vertical_motion_keeps_the_column() {
14325 let mut d = doc_with("move", "abcd\nef\n");
14326 d.caret = 3; // "abc|d" on row 0, col 3
14327 d.move_down(false); // row 1 "ef" only has cols 0..2 -> clamps to end
14328 assert_eq!(d.caret, 7); // just after "ef"
14329 }
14330
14331 // ── goal column ──────────────────────────────────────────────────────────
14332
14333 #[test]
14334 fn vertical_motion_goal_column_survives_a_short_line() {
14335 // Regression: re-deriving the column from the clamped position on
14336 // every step permanently forgets it once a short line clamps it.
14337 // Down through "xy" (2 cols) and into "ghijkl" must return to col 4.
14338 let g = |m, f: fn(&mut Doc)| golden("goalcol", m, f);
14339 assert_eq!(
14340 g("abcd|ef\nxy\nghijkl\n", |d| {
14341 d.move_down(false); // clamps to end of "xy"
14342 d.move_down(false); // restores col 4 on the long line
14343 }),
14344 "abcdef\nxy\nghij|kl\n"
14345 );
14346 }
14347
14348 #[test]
14349 fn goal_column_state_is_set_by_vertical_motion_and_cleared_by_horizontal() {
14350 let mut d = doc_with("goalcol_state", "abcdef\nxy\nghijkl\n");
14351 assert_eq!(d.goal_col, None);
14352 d.caret = 4; // row 0, col 4
14353 d.move_down(false); // clamps into "xy"; goal stays the original col
14354 assert_eq!(d.goal_col, Some(4));
14355 assert_eq!(d.caret_pos(), (1, 2));
14356
14357 // A horizontal motion drops the goal column...
14358 d.move_left(false);
14359 assert_eq!(d.goal_col, None);
14360
14361 // ...so the next vertical motion picks up the *new* column (1), not
14362 // the stale one (4).
14363 d.move_down(false);
14364 assert_eq!(d.goal_col, Some(1));
14365 assert_eq!(d.caret_pos(), (2, 1));
14366 }
14367
14368 #[test]
14369 fn editing_clears_the_goal_column() {
14370 let mut d = doc_with("goalcol_edit", "abcdef\nxy\nghijkl\n");
14371 d.caret = 4;
14372 d.move_down(false);
14373 assert_eq!(d.goal_col, Some(4));
14374 d.insert("Z");
14375 assert_eq!(d.goal_col, None);
14376 }
14377
14378 #[test]
14379 fn vertical_motion_on_an_empty_document_is_a_no_op() {
14380 let mut d = doc_with("empty_vert", "");
14381 d.move_down(false);
14382 assert_eq!(d.caret, 0);
14383 d.move_up(false);
14384 assert_eq!(d.caret, 0);
14385 }
14386
14387 // ── the document's edges ─────────────────────────────────────────────────
14388
14389 #[test]
14390 fn vertical_motion_at_the_document_edges_runs_to_them_in_both_views() {
14391 // The reproduction, and the disagreement: Down on the last line ran to
14392 // the end of the document in the source view — by accident, an
14393 // out-of-range row clamping to the end of the string — and did nothing
14394 // whatever in the view leaf opens in. One rule now, in both.
14395 for (view, tag) in VIEWS {
14396 let mut d = doc_in(view, &format!("edge_{tag}"), "abc");
14397 d.caret = 1;
14398 d.move_down(false);
14399 assert_eq!(d.caret, 3, "{tag}: Down on the last line runs to the end");
14400 d.move_up(false);
14401 assert_eq!(d.caret, 0, "{tag}: Up on the first line runs to the start");
14402 }
14403 }
14404
14405 #[test]
14406 fn vertical_motion_at_the_edges_carries_the_column_across_the_lines_between() {
14407 // Down off the bottom is a motion like any other, so it latches a goal
14408 // column — and Up comes back to the column the caret left, not to the
14409 // one the document's end happened to be in.
14410 for (view, tag) in VIEWS {
14411 let gap = if view == View::Source { "\n" } else { "\n\n" };
14412 let src = format!("abcdef{gap}ghijkl");
14413 let mut d = doc_in(view, &format!("edge_goal_{tag}"), &src);
14414 d.caret = 2; // row 0, col 2
14415 d.move_down(false);
14416 assert_eq!(d.caret_pos().1, 2, "{tag}: Down keeps the column");
14417 d.move_down(false);
14418 assert_eq!(
14419 d.caret,
14420 src.len(),
14421 "{tag}: Down off the bottom reaches the end"
14422 );
14423 d.move_up(false);
14424 assert_eq!(
14425 d.caret_pos().1,
14426 2,
14427 "{tag}: Up returns to the column Down left"
14428 );
14429 }
14430 }
14431
14432 #[test]
14433 fn vertical_motion_with_nowhere_to_go_latches_no_goal_column() {
14434 // `goal_col.get_or_insert` ran *before* the early return at row 0, so an
14435 // Up that did nothing still armed a goal column, and the next Down aimed
14436 // at a column the caret had never been in.
14437 for (view, tag) in VIEWS {
14438 let mut d = doc_in(view, &format!("noop_goal_{tag}"), "abc\n\ndef");
14439 d.caret = 0;
14440 d.move_up(false);
14441 assert_eq!(d.caret, 0, "{tag}: already at the start");
14442 assert_eq!(d.goal_col, None, "{tag}: a no-op Up latched a goal column");
14443
14444 d.caret = d.source.len();
14445 d.move_down(false);
14446 assert_eq!(d.caret, d.source.len(), "{tag}: already at the end");
14447 assert_eq!(
14448 d.goal_col, None,
14449 "{tag}: a no-op Down latched a goal column"
14450 );
14451 }
14452 }
14453
14454 // ── soft wrap ────────────────────────────────────────────────────────────
14455 // Every other test here builds the map at 80 columns, where no fixture is
14456 // long enough to fold. A wrap is where one offset belongs to two rows at
14457 // once, and it broke everything that asks the caret what row it is on.
14458
14459 /// The wrapped fixture these cases share, folded at 12 columns into
14460 /// `one two ` / `three four ` / `five six ` / `seven eight`.
14461 fn wrapped_doc(name: &str) -> Doc {
14462 let mut d = wysiwyg_doc(name, "one two three four five six seven eight");
14463 d.build_visual(12);
14464 d
14465 }
14466
14467 #[test]
14468 fn home_and_end_work_from_a_wrapped_row() {
14469 // The reproduction: offset 19 is the `f` of "five", the first character
14470 // of the third row — and also the offset the second row ends at. It
14471 // resolved to the *second* row, so End aimed at a place the caret was
14472 // already in and did nothing, while Home walked backwards onto a row the
14473 // caret had left.
14474 let mut d = wrapped_doc("wrap_home_end");
14475 d.caret = 19;
14476 assert_eq!(
14477 d.caret_pos(),
14478 (2, 0),
14479 "the wrap boundary opens the third row"
14480 );
14481 d.move_end(false);
14482 assert_eq!(d.caret, 27, "End stalled at the wrap boundary");
14483 d.move_home(false);
14484 assert_eq!(d.caret, 19, "Home left the row the caret was on");
14485 }
14486
14487 #[test]
14488 fn end_of_a_wrapped_row_stays_put_when_pressed_again() {
14489 // The row's end is the last offset that is only ever its own: the offset
14490 // past it opens the row below, and aiming there would send a second
14491 // press on to *that* row's end, and a third to the next — End walking
14492 // down the paragraph rather than sitting where it landed.
14493 let mut d = wrapped_doc("wrap_end_twice");
14494 d.caret = 12; // inside "three", on the second row
14495 d.move_end(false);
14496 assert_eq!(
14497 d.caret, 18,
14498 "the end of `three four`, before the space the wrap ate"
14499 );
14500 assert_eq!(d.caret_pos(), (1, 10), "drawn on the row it is the end of");
14501 d.move_end(false);
14502 assert_eq!(d.caret, 18, "a second End moved the caret");
14503 d.move_home(false);
14504 assert_eq!(d.caret, 8, "Home takes the row's own start");
14505 }
14506
14507 #[test]
14508 fn vertical_motion_crosses_a_soft_wrap() {
14509 // Down aimed at the row below's column 0, an offset that resolved *up*
14510 // to the row above's end — so it landed on the offset it already had and
14511 // the caret could never leave a paragraph's first row.
14512 let mut d = wrapped_doc("wrap_down");
14513 d.caret = 0;
14514 for (want, row) in [(8, 1), (19, 2), (28, 3), (39, 3)] {
14515 d.move_down(false);
14516 assert_eq!(d.caret, want, "Down stalled");
14517 assert_eq!(d.caret_pos().0, row, "Down landed on the wrong row");
14518 }
14519 d.move_down(false);
14520 assert_eq!(d.caret, 39, "the last row's Down runs to the end and stops");
14521
14522 // ...and back up, one row per press. The goal column is the end of the
14523 // last row, past every other row's width, so each press clamps to the
14524 // row's own last offset rather than to the one that opens the next.
14525 let mut d = wrapped_doc("wrap_up");
14526 d.caret = 39;
14527 for (want, pos) in [(27, (2, 8)), (18, (1, 10)), (7, (0, 7)), (0, (0, 0))] {
14528 d.move_up(false);
14529 assert_eq!(d.caret, want, "Up stalled");
14530 assert_eq!(d.caret_pos(), pos, "Up landed on the wrong row");
14531 }
14532 }
14533
14534 #[test]
14535 fn a_kill_on_a_wrapped_row_stops_at_the_row() {
14536 // The kills take the same line Home and End do, so in WYSIWYG they take
14537 // the visual row — and a soft wrap has no newline in it to delete, so
14538 // nothing is joined by reaching the end of one.
14539 let mut d = wrapped_doc("wrap_kill");
14540 d.caret = 19; // the `f` of "five", opening the third row
14541 d.delete_to_line_end();
14542 // The space the wrap ate goes with the row it was drawn on: sparing it
14543 // would leave "four seven", two spaces where the row had been.
14544 assert_eq!(d.source, "one two three four seven eight");
14545
14546 // Backwards from the row's last caret position — which is *before* that
14547 // space, so this one survives, being on the far side of the caret.
14548 let mut d = wrapped_doc("wrap_kill_back");
14549 d.caret = 27;
14550 d.delete_to_line_start();
14551 assert_eq!(d.source, "one two three four seven eight");
14552 }
14553
14554 // ── document start / end ────────────────────────────────────────────────
14555
14556 #[test]
14557 fn move_doc_start_and_end_jump_to_the_edges() {
14558 let g = |m, f: fn(&mut Doc)| golden("doc_edges", m, f);
14559 assert_eq!(
14560 g("hello\nwor|ld\n", |d| d.move_doc_start(false)),
14561 "|hello\nworld\n"
14562 );
14563 assert_eq!(
14564 g("hel|lo\nworld\n", |d| d.move_doc_end(false)),
14565 "hello\nworld\n|"
14566 );
14567 // Already at the edge: a no-op.
14568 assert_eq!(g("|hello\n", |d| d.move_doc_start(false)), "|hello\n");
14569 assert_eq!(g("hello|\n", |d| d.move_doc_end(false)), "hello\n|");
14570 }
14571
14572 #[test]
14573 fn move_doc_start_and_end_extend_the_selection() {
14574 assert_eq!(
14575 golden("doc_edges_ext_end", "hello wor|ld\n", |d| d
14576 .move_doc_end(true)),
14577 "hello wor[ld\n|]"
14578 );
14579 assert_eq!(
14580 golden("doc_edges_ext_start", "hello wor|ld\n", |d| d
14581 .move_doc_start(true)),
14582 "[|hello wor]ld\n"
14583 );
14584 }
14585
14586 #[test]
14587 fn move_doc_start_and_end_on_an_empty_document_are_a_no_op() {
14588 let mut d = doc_with("empty_edges", "");
14589 d.move_doc_end(false);
14590 assert_eq!(d.caret, 0);
14591 d.move_doc_start(false);
14592 assert_eq!(d.caret, 0);
14593 }
14594
14595 // ── arrow collapses an active selection ─────────────────────────────────
14596
14597 #[test]
14598 fn arrow_collapses_selection_to_its_near_edge() {
14599 let mut d = doc_with("collapse", "hello world\n");
14600
14601 // Forward selection (anchor before caret): Right -> end, Left -> start.
14602 d.anchor = Some(2);
14603 d.caret = 7;
14604 d.move_right(false);
14605 assert_eq!((d.caret, d.anchor), (7, None));
14606
14607 d.anchor = Some(2);
14608 d.caret = 7;
14609 d.move_left(false);
14610 assert_eq!((d.caret, d.anchor), (2, None));
14611
14612 // Backward selection (anchor after caret): edges are the same
14613 // regardless of which end the caret started on.
14614 d.anchor = Some(7);
14615 d.caret = 2;
14616 d.move_right(false);
14617 assert_eq!((d.caret, d.anchor), (7, None));
14618
14619 d.anchor = Some(7);
14620 d.caret = 2;
14621 d.move_left(false);
14622 assert_eq!((d.caret, d.anchor), (2, None));
14623 }
14624
14625 #[test]
14626 fn arrow_with_extend_keeps_growing_the_selection() {
14627 let mut d = doc_with("collapse_extend", "hello world\n");
14628 d.anchor = Some(2);
14629 d.caret = 7;
14630 d.move_right(true); // extend: no collapse, caret steps one further
14631 assert_eq!((d.caret, d.anchor), (8, Some(2)));
14632 }
14633
14634 #[test]
14635 fn arrow_without_a_selection_moves_one_character_as_before() {
14636 let mut d = doc_with("no_collapse", "hello\n");
14637 d.caret = 2;
14638 d.move_right(false);
14639 assert_eq!(d.caret, 3);
14640 d.move_left(false);
14641 assert_eq!(d.caret, 2);
14642 }
14643
14644 /// Press Right until it stops, collecting the offsets walked through. Every
14645 /// caret bug in the WYSIWYG view shows up here as a walk that ends early:
14646 /// two stops sharing one source offset can't be moved between, so the caret
14647 /// stalls on the first of them and the walk never reaches the rest.
14648 fn walk_right(d: &mut Doc) -> Vec<usize> {
14649 let mut seen = vec![d.caret];
14650 for _ in 0..2000 {
14651 let before = d.caret;
14652 d.move_right(false);
14653 if d.caret == before {
14654 break;
14655 }
14656 seen.push(d.caret);
14657 }
14658 seen
14659 }
14660
14661 #[test]
14662 fn the_caret_crosses_a_soft_break() {
14663 // A newline inside a paragraph is a `soft_break`, which twig gives no
14664 // span of its own — the space it renders as used to borrow the offset of
14665 // the character before it, and a caret can't move without changing
14666 // offset. Right must walk clean off the end of the first line.
14667 let mut d = wysiwyg_doc("soft_break_walk", "one two\nthree four\n");
14668 d.caret = 0;
14669 let seen = walk_right(&mut d);
14670 assert_eq!(seen, (0..=18).collect::<Vec<_>>(), "walk stalled: {seen:?}");
14671 }
14672
14673 #[test]
14674 fn line_flow_preserve_resplits_the_map_and_defaults_to_fold() {
14675 // The paragraph holds one soft break. Folded (the default) it lays out as
14676 // a single reflowed row; Preserve re-lays it as a row per source line.
14677 // The setter must invalidate the cached map for the change to show, and
14678 // again on the way back — so a round trip returns to the folded layout.
14679 let mut d = wysiwyg_doc("line_flow", "one two\nthree four\n");
14680 assert_eq!(d.line_flow(), LineFlow::Fold, "fold is the default");
14681 d.build_visual(80);
14682 assert_eq!(d.vmap.num_rows(), 1, "fold: one flowing row");
14683
14684 d.set_line_flow(LineFlow::Preserve);
14685 d.build_visual(80);
14686 assert_eq!(d.vmap.num_rows(), 2, "preserve: a row per source line");
14687
14688 d.set_line_flow(LineFlow::Fold);
14689 d.build_visual(80);
14690 assert_eq!(d.vmap.num_rows(), 1, "fold again: back to one row");
14691 }
14692
14693 #[test]
14694 fn the_caret_still_crosses_a_preserved_soft_break() {
14695 // Preserve renders the soft break as a row boundary rather than a space,
14696 // but the caret must still reach every offset — the break's own offset is
14697 // the first row's end stop, so Right walks clean off the end of line one
14698 // onto line two, exactly as it does when the break is folded.
14699 let mut d = wysiwyg_doc("preserve_walk", "one two\nthree four\n");
14700 d.set_line_flow(LineFlow::Preserve);
14701 d.build_visual(80);
14702 d.caret = 0;
14703 let seen = walk_right(&mut d);
14704 assert_eq!(seen, (0..=18).collect::<Vec<_>>(), "walk stalled: {seen:?}");
14705 }
14706
14707 #[test]
14708 fn the_caret_walks_a_code_block() {
14709 // Every glyph of a code block used to map to the block's start, so the
14710 // whole block was a single offset and the caret couldn't move inside it.
14711 let src = "```rust\nlet x = 1;\nfn f() {}\n```\n";
14712 let mut d = wysiwyg_doc("code_walk", src);
14713 d.caret = 0;
14714 let seen = walk_right(&mut d);
14715 // The fences are markup: hidden, and no caret stop. The code between
14716 // them is reached a character at a time.
14717 let code = src.find("let").unwrap()..src.find("\n```").unwrap();
14718 for off in code.clone() {
14719 assert!(seen.contains(&off), "offset {off} unreachable: {seen:?}");
14720 }
14721 assert!(seen.contains(&code.end), "no stop after the last line");
14722 }
14723
14724 #[test]
14725 fn the_caret_walks_an_indented_code_block() {
14726 // An indented block's text has the four-space indent stripped, so it
14727 // isn't a verbatim slice and its lines have to be re-found. The caret
14728 // lands on the code, never in the indent.
14729 let src = " indented\n code\n";
14730 let mut d = wysiwyg_doc("indent_code_walk", src);
14731 d.caret = 0;
14732 let seen = walk_right(&mut d);
14733 assert!(seen.contains(&src.find("indented").unwrap()));
14734 assert!(seen.contains(&src.find("code").unwrap()));
14735 assert!(
14736 !seen.contains(&0) || seen[0] == 0,
14737 "the caret starts where it was put"
14738 );
14739 // Nothing in the stripped indent is a stop.
14740 for off in [1, 2, 3] {
14741 assert!(!seen.contains(&off), "landed in the indent at {off}");
14742 }
14743 }
14744
14745 #[test]
14746 fn the_caret_leaves_a_tight_heading() {
14747 // "# H" with text directly under it: the heading row's end and the
14748 // separator row's end are the same offset. Right used to find the
14749 // separator's copy, set the caret to where it already was, and stop.
14750 let mut d = wysiwyg_doc("tight_heading_walk", "# H\ntext\n");
14751 d.caret = 2; // the "H"
14752 let seen = walk_right(&mut d);
14753 assert!(
14754 seen.len() > 2,
14755 "Right stalled at the heading's end: {seen:?}"
14756 );
14757 assert!(
14758 seen.contains(&8),
14759 "never reached the end of \"text\": {seen:?}"
14760 );
14761 }
14762
14763 #[test]
14764 fn the_caret_skips_the_gap_between_two_paragraphs() {
14765 // The blank line between two paragraphs is the boundary itself. The
14766 // caret used to be able to sit on it, and typing there landed in the
14767 // previous paragraph — "A\n\nB" became "A\nx\nB", one paragraph with a
14768 // soft break, so the text visibly snapped back up.
14769 let mut d = wysiwyg_doc("gap_skip", "A\n\nB\n");
14770 d.caret = 1; // the end of "A"
14771 d.move_right(false);
14772 assert_eq!(d.caret, 3, "Right stopped in the gap");
14773 d.insert("x");
14774 assert_eq!(d.source, "A\n\nxB\n", "typing landed outside B");
14775 }
14776
14777 #[test]
14778 fn down_from_a_paragraph_lands_on_the_next_one() {
14779 let mut d = wysiwyg_doc("gap_down", "A\n\nB\n");
14780 d.caret = 0;
14781 d.move_down(false);
14782 assert_eq!(d.caret, 3, "Down stopped in the gap");
14783 }
14784
14785 #[test]
14786 fn clicking_the_gap_lands_on_real_text() {
14787 // A click can still *reach* the gap — it's drawn, so it's clickable.
14788 // It has to resolve to somewhere the caret can be.
14789 let mut d = wysiwyg_doc("gap_click", "A\n\nB\n");
14790 d.click(1, 0, false); // the gap row
14791 assert!(
14792 d.caret == 1 || d.caret == 3,
14793 "click left the caret in the gap at {}",
14794 d.caret
14795 );
14796 d.insert("x");
14797 // Either edge of the boundary is a fair place to land; inside it isn't.
14798 assert!(
14799 d.source == "Ax\n\nB\n" || d.source == "A\n\nxB\n",
14800 "click in the gap typed into the boundary: {:?}",
14801 d.source
14802 );
14803 }
14804
14805 #[test]
14806 fn enter_opens_an_empty_paragraph_the_caret_can_type_into() {
14807 // Enter inserts a paragraph break, which leaves a blank line spare on
14808 // either side of a new one. That middle line is a real empty paragraph:
14809 // the caret lands there, and typing makes a paragraph rather than
14810 // extending a neighbour.
14811 let mut d = wysiwyg_doc("gap_enter", "A\n\nB\n");
14812 d.caret = 1;
14813 d.newline();
14814 assert_eq!(d.source, "A\n\n\n\nB\n");
14815 d.build_visual(80);
14816 let (row, _) = d.caret_pos();
14817 assert!(
14818 d.vmap.row_is_navigable(row),
14819 "the caret landed on a gap row"
14820 );
14821 d.insert("x");
14822 assert_eq!(
14823 d.source, "A\n\nx\n\nB\n",
14824 "the new paragraph merged into a neighbour"
14825 );
14826 }
14827
14828 #[test]
14829 fn enter_at_the_end_of_the_document_opens_a_paragraph_too() {
14830 let mut d = wysiwyg_doc("gap_eof", "A\n");
14831 d.caret = 1;
14832 d.newline();
14833 d.build_visual(80);
14834 let (row, _) = d.caret_pos();
14835 assert!(
14836 d.vmap.row_is_navigable(row),
14837 "the caret landed on a gap row"
14838 );
14839 d.insert("x");
14840 assert!(
14841 d.source.starts_with("A\n\n") && d.source.contains('x'),
14842 "typing at the end merged into A: {:?}",
14843 d.source
14844 );
14845 }
14846
14847 // ── click_past_end ───────────────────────────────────────────────────────
14848
14849 /// [`Doc::click_past_end`] on `body`, and the source it left, with the caret
14850 /// rendered as `|`.
14851 fn past_end(name: &str, body: &str) -> (Doc, String) {
14852 let mut d = wysiwyg_doc(name, body);
14853 d.click_past_end();
14854 let out = render_caret(&d);
14855 (d, out)
14856 }
14857
14858 #[test]
14859 fn click_past_end_opens_an_empty_paragraph_under_the_last_block() {
14860 let (mut d, out) = past_end("pe_para", "A\n");
14861 assert_eq!(out, "A\n\n|");
14862 d.build_visual(80);
14863 let (row, _) = d.caret_pos();
14864 assert!(
14865 d.vmap.row_is_navigable(row),
14866 "the caret landed on a gap row"
14867 );
14868 d.insert("x");
14869 assert_eq!(d.source, "A\n\nx", "typing merged into A");
14870 }
14871
14872 #[test]
14873 fn click_past_end_writes_both_newlines_when_the_file_has_none() {
14874 assert_eq!(past_end("pe_bare", "A").1, "A\n\n|");
14875 }
14876
14877 #[test]
14878 fn click_past_end_is_only_a_caret_move_when_the_paragraph_is_already_there() {
14879 let (d, out) = past_end("pe_there", "A\n\n");
14880 assert_eq!(out, "A\n\n|");
14881 assert!(!d.dirty, "a click wrote to a document it did not need to");
14882 assert!(
14883 !d.can_undo(),
14884 "a click that changed nothing left an undo step"
14885 );
14886 }
14887
14888 #[test]
14889 fn click_past_end_leaves_a_closed_fence() {
14890 let (mut d, out) = past_end("pe_fence", "```\ncode\n```\n");
14891 assert_eq!(out, "```\ncode\n```\n\n|");
14892 d.build_visual(80);
14893 let (row, _) = d.caret_pos();
14894 assert!(!d.vmap.rows[row].code, "the caret is still on a code row");
14895 d.insert("x");
14896 assert_eq!(d.source, "```\ncode\n```\n\nx");
14897 }
14898
14899 #[test]
14900 fn click_past_end_closes_an_unclosed_fence_first() {
14901 assert_eq!(past_end("pe_open", "```\ncode\n").1, "```\ncode\n```\n\n|");
14902 assert_eq!(
14903 past_end("pe_open2", "````\ncode").1,
14904 "````\ncode\n````\n\n|"
14905 );
14906 assert_eq!(past_end("pe_tilde", "~~~\ncode\n").1, "~~~\ncode\n~~~\n\n|");
14907 assert_eq!(
14908 past_end("pe_quoted", "> ```\n> code\n").1,
14909 "> ```\n> code\n> ```\n\n|"
14910 );
14911 }
14912
14913 #[test]
14914 fn click_past_end_does_not_close_an_indented_block() {
14915 assert_eq!(past_end("pe_indent", " code\n").1, " code\n\n|");
14916 }
14917
14918 #[test]
14919 fn click_past_end_under_a_list_and_a_table_leaves_them() {
14920 let (mut d, out) = past_end("pe_list", "- a\n- b\n");
14921 assert_eq!(out, "- a\n- b\n\n|");
14922 d.insert("x");
14923 assert_eq!(d.source, "- a\n- b\n\nx", "typed into the list");
14924 assert_eq!(
14925 past_end("pe_table", "| a |\n|---|\n| b |\n").1,
14926 "| a |\n|---|\n| b |\n\n|"
14927 );
14928 }
14929
14930 #[test]
14931 fn click_past_end_on_an_empty_document_writes_nothing() {
14932 let (d, out) = past_end("pe_empty", "");
14933 assert_eq!(out, "|");
14934 assert!(!d.dirty);
14935 }
14936
14937 #[test]
14938 fn click_past_end_is_one_undo_step() {
14939 let (mut d, _) = past_end("pe_undo", "A\n");
14940 d.undo();
14941 assert_eq!(d.source, "A\n");
14942 }
14943
14944 #[test]
14945 fn click_past_end_in_the_source_view_only_moves_the_caret() {
14946 let mut d = doc_with("pe_source", "A\n");
14947 d.click_past_end();
14948 assert_eq!(render_caret(&d), "A\n|");
14949 assert!(!d.dirty);
14950 }
14951
14952 #[test]
14953 fn click_past_end_on_a_read_only_document_only_moves_the_caret() {
14954 let mut d = wysiwyg_doc("pe_ro", "A\n");
14955 d.set_read_only(true);
14956 d.click_past_end();
14957 assert_eq!(render_caret(&d), "A\n|");
14958 }
14959
14960 // ── toggle_code_block ────────────────────────────────────────────────────
14961
14962 #[test]
14963 fn toggle_code_block_fences_the_paragraph_at_the_caret_and_reverses() {
14964 let g = |n, m| golden_in(View::Wysiwyg, n, m, |d| d.toggle_code_block());
14965 assert_eq!(g("cb_on", "hel|lo\n"), "```\nhel|lo\n```\n");
14966 assert_eq!(g("cb_off", "```\nhel|lo\n```\n"), "hel|lo\n");
14967 assert_eq!(g("cb_end", "hello|\n"), "```\nhello|\n```\n");
14968 assert_eq!(
14969 g("cb_wrap", "aaa\nb|bb\nccc\n"),
14970 "```\naaa\nb|bb\nccc\n```\n"
14971 );
14972 assert_eq!(
14973 g("cb_unwrap", "```\naaa\nb|bb\nccc\n```\n"),
14974 "aaa\nb|bb\nccc\n"
14975 );
14976 // An indented block dedents, and the lines stay one-to-one.
14977 assert_eq!(g("cb_indent", " co|de\n"), "co|de\n");
14978 // A quote's marker is kept on every line, the fence's included.
14979 assert_eq!(g("cb_quote", "> hel|lo\n"), "> ```\n> hel|lo\n> ```\n");
14980 }
14981
14982 #[test]
14983 fn toggle_code_block_on_a_blank_line_opens_an_empty_fence() {
14984 let g = |n, m| golden_in(View::Wysiwyg, n, m, |d| d.toggle_code_block());
14985 assert_eq!(g("cb_blank", "A\n\n|"), "A\n\n```\n|\n```");
14986 assert_eq!(
14987 g("cb_blank_mid", "A\n\n|\n\nB\n"),
14988 "A\n\n```\n|\n```\n\nB\n"
14989 );
14990 // Tight against a neighbour, a blank line goes in on that side.
14991 assert_eq!(g("cb_blank_tight", "A\n|\nB\n"), "A\n\n```\n|\n```\n\nB\n");
14992 // Inside a quote, on a quoted blank line.
14993 assert_eq!(
14994 g("cb_blank_quote", "> A\n>\n> |\n"),
14995 "> A\n>\n> ```\n> |\n> ```\n"
14996 );
14997 }
14998
14999 #[test]
15000 fn toggle_code_block_from_a_blank_line_is_a_block_the_caret_can_type_into() {
15001 let mut d = wysiwyg_doc("cb_type", "A\n\n");
15002 d.click_past_end();
15003 d.toggle_code_block();
15004 assert!(
15005 d.caret_in_code_block(),
15006 "the caret is not in the block it opened"
15007 );
15008 d.insert("let x = 1;");
15009 d.newline();
15010 d.insert("x");
15011 assert_eq!(d.source, "A\n\n```\nlet x = 1;\nx\n```");
15012 d.build_visual(80);
15013 let (row, _) = d.caret_pos();
15014 assert!(d.vmap.rows[row].code, "typed text is not on a code row");
15015 // And back out: the button reverses what it did, text kept.
15016 d.toggle_code_block();
15017 assert_eq!(d.source, "A\n\nlet x = 1;\nx\n");
15018 assert!(!d.caret_in_code_block());
15019 }
15020
15021 #[test]
15022 fn toggle_code_block_over_a_selection_fences_it_whole_and_keeps_it_selected() {
15023 let mut d = wysiwyg_doc("cb_sel", "one\n\ntwo\n\nthree\n");
15024 d.anchor = Some(1);
15025 d.caret = 7;
15026 d.toggle_code_block();
15027 assert_eq!(d.source, "```\none\n\ntwo\n```\n\nthree\n");
15028 assert!(d.selection().is_some(), "the block came back unselected");
15029 d.toggle_code_block();
15030 assert_eq!(
15031 d.source, "one\n\ntwo\n\nthree\n",
15032 "a second press did not reverse the first"
15033 );
15034 }
15035
15036 #[test]
15037 fn toggle_code_block_inside_a_list_item_is_refused_and_reported() {
15038 let mut d = wysiwyg_doc("cb_list", "- it|em\n".replace('|', "").as_str());
15039 d.caret = 3;
15040 d.toggle_code_block();
15041 assert_eq!(d.source, "- item\n");
15042 assert!(
15043 d.status
15044 .as_deref()
15045 .is_some_and(|s| s.starts_with("code block:"))
15046 );
15047 }
15048
15049 #[test]
15050 fn caret_in_code_block_reads_fenced_and_indented_blocks() {
15051 let mut d = wysiwyg_doc("cb_in", "para\n\n```\ncode\n```\n\n more\n");
15052 d.caret = 2;
15053 assert!(!d.caret_in_code_block());
15054 d.caret = 11;
15055 assert!(d.caret_in_code_block());
15056 d.caret = 25;
15057 assert!(d.caret_in_code_block(), "an indented block is a code block");
15058 }
15059
15060 #[test]
15061 fn toggle_code_block_is_one_undo_step() {
15062 let mut d = wysiwyg_doc("cb_undo", "hello\n");
15063 d.caret = 2;
15064 d.toggle_code_block();
15065 d.undo();
15066 assert_eq!(render_caret(&d), "he|llo\n");
15067 }
15068
15069 #[test]
15070 fn triple_click_selects_a_paragraph_across_its_soft_breaks() {
15071 // A paragraph broken over two source lines is one paragraph. Selecting
15072 // it must not stop at the newline inside it — that newline is markup the
15073 // rich-text view exists to hide.
15074 let src = "one two\nthree four\n\nnext\n";
15075 let mut d = wysiwyg_doc("triple_para", src);
15076 d.select_block_at(2);
15077 assert_eq!(
15078 d.selected_text(),
15079 Some("one two\nthree four"),
15080 "stopped at the soft break"
15081 );
15082 }
15083
15084 #[test]
15085 fn the_wheel_can_scroll_away_from_a_caret_that_stays_put() {
15086 // The reader scrolls down past the caret's row. Nothing moved the
15087 // caret, so the view must stay where it was put — the old code revealed
15088 // the caret every frame, which dragged the view straight back and made
15089 // the document unscrollable past the caret.
15090 let mut d = wysiwyg_doc("scroll_free", "a\n\nb\n\nc\n\nd\n\ne\n");
15091 d.caret = 0;
15092 d.follow_caret(0, 3, 9); // first frame: the caret is at the top
15093 d.scroll = 4; // the wheel
15094 d.follow_caret(0, 3, 9);
15095 assert_eq!(
15096 d.scroll, 4,
15097 "the wheel was overruled by a caret that never moved"
15098 );
15099 }
15100
15101 #[test]
15102 fn moving_the_caret_brings_the_view_back_to_it() {
15103 let mut d = wysiwyg_doc("scroll_follow", "a\n\nb\n\nc\n\nd\n\ne\n");
15104 d.caret = 0;
15105 d.follow_caret(0, 3, 9);
15106 d.scroll = 6; // scrolled away
15107 d.move_right(false); // ...and now the caret moves
15108 let (row, _) = d.caret_pos();
15109 d.follow_caret(row, 3, 9);
15110 assert!(
15111 d.scroll <= row && row < d.scroll + 3,
15112 "caret row {row} off screen at scroll {}",
15113 d.scroll
15114 );
15115 }
15116
15117 #[test]
15118 fn scrolling_stops_at_the_last_row() {
15119 let mut d = wysiwyg_doc("scroll_clamp", "a\n\nb\n");
15120 d.caret = 0;
15121 d.follow_caret(0, 3, 3); // a first frame, so the caret isn't "new"
15122 d.scroll = 999; // the wheel, spun hard
15123 d.follow_caret(0, 3, 3);
15124 assert_eq!(d.scroll, 2, "scrolled into the void past the document");
15125 }
15126
15127 #[test]
15128 fn every_cell_of_a_wide_table_is_reachable() {
15129 // A table whose cells are far wider than the surface: the columns are
15130 // cut to fit and the text wraps inside them, so no cell hangs off the
15131 // right edge where the caret can never go.
15132 let src = "| Ingredient | Notes |\n|---|---|\n\
15133 | flour milled coarse | sift it twice before folding it in |\n";
15134 let mut d = wysiwyg_doc("wide_table_walk", src);
15135 d.build_visual(30);
15136 d.caret = 0;
15137 let seen = walk_right(&mut d);
15138 for word in ["Ingredient", "Notes", "coarse", "folding"] {
15139 let at = src.find(word).unwrap();
15140 assert!(seen.contains(&at), "{word:?} at {at} unreachable: {seen:?}");
15141 }
15142 }
15143
15144 // ── view parity ──────────────────────────────────────────────────────────
15145 // `doc_with` pins the source view, so everything above tests a view users
15146 // never start in — `Doc::open` opens in WYSIWYG. These run the motion and
15147 // deletion golden cases through *both*, plus the WYSIWYG cases the two
15148 // can't share: where the source carries markup the rendered text is a
15149 // different string, and the views agreeing would itself be the bug.
15150
15151 const VIEWS: [(View, &str); 2] = [(View::Source, "source"), (View::Wysiwyg, "wysiwyg")];
15152
15153 /// Run `action` in both views on one `|`-marked fixture and assert they
15154 /// agree. Plain prose only: with no markup to hide, WYSIWYG renders the
15155 /// source verbatim, so the two views are looking at the same text and any
15156 /// disagreement is one of them having lost the plot.
15157 fn both_views(name: &str, marked: &str, action: fn(&mut Doc)) -> String {
15158 let (src, caret) = parse_caret(marked);
15159 let run = |view: View, tag: &str| {
15160 let mut d = doc_in(view, &format!("{name}_{tag}"), &src);
15161 d.caret = caret;
15162 action(&mut d);
15163 render_caret(&d)
15164 };
15165 let source = run(VIEWS[0].0, VIEWS[0].1);
15166 let wysiwyg = run(VIEWS[1].0, VIEWS[1].1);
15167 assert_eq!(source, wysiwyg, "the views disagree on {marked:?}");
15168 source
15169 }
15170
15171 #[test]
15172 fn word_motion_agrees_across_the_views_on_plain_prose() {
15173 let g = both_views;
15174 assert_eq!(
15175 g("par_wl", "hello wor|ld", |d| d.move_word_left(false)),
15176 "hello |world"
15177 );
15178 assert_eq!(
15179 g("par_wl2", "hello| world", |d| d.move_word_left(false)),
15180 "|hello world"
15181 );
15182 assert_eq!(
15183 g("par_wr", "hel|lo world", |d| d.move_word_right(false)),
15184 "hello| world"
15185 );
15186 assert_eq!(
15187 g("par_wr2", "hello| world", |d| d.move_word_right(false)),
15188 "hello world|"
15189 );
15190 assert_eq!(
15191 g("par_punct", "|foo.bar", |d| d.move_word_right(false)),
15192 "foo|.bar"
15193 );
15194 assert_eq!(
15195 g("par_ext", "hello |world", |d| d.move_word_right(true)),
15196 "hello [world|]"
15197 );
15198 }
15199
15200 #[test]
15201 fn word_deletion_agrees_across_the_views_on_plain_prose() {
15202 let g = both_views;
15203 assert_eq!(
15204 g("par_db", "hello world|", |d| d.delete_word_back()),
15205 "hello |"
15206 );
15207 assert_eq!(
15208 g("par_df", "hello |world", |d| d.delete_word_forward()),
15209 "hello |"
15210 );
15211 assert_eq!(
15212 g("par_db2", "foo |bar baz", |d| d.delete_word_back()),
15213 "|bar baz"
15214 );
15215 assert_eq!(g("par_utf8", "café |ok", |d| d.delete_word_back()), "|ok");
15216 }
15217
15218 #[test]
15219 fn character_motion_and_deletion_agree_across_the_views_on_plain_prose() {
15220 let g = both_views;
15221 assert_eq!(g("par_r", "he|llo", |d| d.move_right(false)), "hel|lo");
15222 assert_eq!(g("par_l", "he|llo", |d| d.move_left(false)), "h|ello");
15223 assert_eq!(g("par_bs", "hel|lo", |d| d.backspace()), "he|lo");
15224 assert_eq!(g("par_del", "hel|lo", |d| d.delete_forward()), "hel|o");
15225 }
15226
15227 #[test]
15228 fn wysiwyg_motion_steps_a_grapheme_cluster_the_way_the_source_view_does() {
15229 // The reproduction: the stop table was built one stop per `char`, so
15230 // Right parked the caret 4 bytes into a ZWJ sequence — a place the
15231 // source view, which steps by grapheme, can't reach and backspace can't
15232 // survive. The two views must land on the same offset.
15233 let family = "👨👩👧"; // three emoji strung together with joiners: one cluster
15234 for (view, tag) in VIEWS {
15235 let mut d = doc_in(view, &format!("cluster_{tag}"), &format!("a{family}b\n"));
15236 d.caret = 1;
15237 d.move_right(false);
15238 assert_eq!(d.caret, 1 + family.len(), "{tag} parked inside the cluster");
15239
15240 // ...and the edit that used to sever a joiner off the front of it.
15241 d.backspace();
15242 assert_eq!(d.source, "ab\n", "{tag} split the cluster");
15243 assert_eq!(d.caret, 1);
15244 }
15245 }
15246
15247 #[test]
15248 fn wysiwyg_motion_treats_a_combining_accent_as_one_character() {
15249 for (view, tag) in VIEWS {
15250 let mut d = doc_in(view, &format!("combining_{tag}"), "e\u{0301}x\n");
15251 d.caret = 0;
15252 d.move_right(false);
15253 assert_eq!(
15254 d.caret,
15255 "e\u{0301}".len(),
15256 "{tag} stopped on the combining mark"
15257 );
15258 }
15259 }
15260
15261 #[test]
15262 fn no_wysiwyg_motion_can_park_the_caret_inside_a_cluster() {
15263 // The general form: whatever route the caret takes through a document
15264 // full of clusters, it never lands between the codepoints of one — so no
15265 // motion-then-backspace sequence can leave a dangling joiner behind.
15266 use unicode_segmentation::UnicodeSegmentation;
15267
15268 let src = "a👨👩👧b e\u{0301}mo👨👩👧ji\n\nnext 👩🚀 line\n";
15269 let mut d = wysiwyg_doc("cluster_walk", src);
15270 d.caret = 0;
15271 let boundaries: Vec<usize> = src
15272 .grapheme_indices(true)
15273 .map(|(i, _)| i)
15274 .chain(std::iter::once(src.len()))
15275 .collect();
15276 for off in walk_right(&mut d) {
15277 assert!(
15278 boundaries.contains(&off),
15279 "Right stopped at {off}, inside a grapheme cluster"
15280 );
15281 }
15282 }
15283
15284 #[test]
15285 fn wysiwyg_word_motion_stays_out_of_hidden_delimiters() {
15286 // The reproduction: ⌥→ from inside the opening `**` computed its
15287 // boundary over the raw source and landed on byte 8 — inside the
15288 // *closing* `**`, which `caret_pos` draws at column 6, immediately after
15289 // "bold". The caret drew past the bold word and sat inside it.
15290 let mut d = wysiwyg_doc("wys_word_delim", "a **bold** c\n");
15291 d.caret = 2;
15292 d.move_word_right(false);
15293 assert!(
15294 d.vmap.is_stop(d.caret),
15295 "landed at {}, not a caret stop",
15296 d.caret
15297 );
15298 assert_eq!(d.caret, 10, "should land on the space after \"bold\"");
15299 // The rendered row is "a bold c": column 6 is the space just past "bold",
15300 // and now the caret is really there rather than only drawn there.
15301 assert_eq!(d.caret_pos(), (0, 6));
15302
15303 // ...and back again: ⌥← returns to the "b", not into the opening `**`.
15304 d.move_word_left(false);
15305 assert_eq!(d.caret, 4);
15306 assert_eq!(d.caret_pos(), (0, 2));
15307 }
15308
15309 #[test]
15310 fn wysiwyg_word_delete_takes_the_markup_with_the_word() {
15311 // The reproduction: ⌥⌫ from after "bold" walked the raw source, stopped
15312 // inside the closing `**`, and left "a ** c\n" — delimiters with no
15313 // opener. Glyph space covers the word alone, which would leave
15314 // "a **** c": markup wrapped around nothing. The word and the styling
15315 // that was only ever the word's go together.
15316 let mut d = wysiwyg_doc("wys_word_del_back", "a **bold** c\n");
15317 d.caret = 10;
15318 d.delete_word_back();
15319 assert_eq!(d.source, "a c\n");
15320 assert_eq!(d.caret, 2);
15321
15322 let mut d = wysiwyg_doc("wys_word_del_fwd", "a **bold** c\n");
15323 d.caret = 4; // the "b"
15324 d.delete_word_forward();
15325 assert_eq!(d.source, "a c\n");
15326 }
15327
15328 #[test]
15329 fn wysiwyg_word_delete_empties_a_nested_mark_and_a_code_span_too() {
15330 let src = "a ***bold*** c\n";
15331 let mut d = wysiwyg_doc("wys_word_del_nest", src);
15332 d.caret = src.find(" c").unwrap();
15333 d.delete_word_back();
15334 assert_eq!(
15335 d.source, "a c\n",
15336 "the emph inside the strong empties it too"
15337 );
15338
15339 let src = "a `code` c\n";
15340 let mut d = wysiwyg_doc("wys_word_del_code", src);
15341 d.caret = src.find(" c").unwrap();
15342 d.delete_word_back();
15343 assert_eq!(d.source, "a c\n");
15344 }
15345
15346 #[test]
15347 fn wysiwyg_word_delete_keeps_a_mark_that_still_has_text() {
15348 // Only an *emptied* node goes. Take one word of two and the `**` still
15349 // has a job to do — over the word that's left, with the space the delete
15350 // pushed against the opening delimiter moved out in front of it, or the
15351 // run would be no run at all (`** words**` is literal asterisks — see
15352 // the mark-edge rule on `splice`).
15353 let src = "a **two words** c\n";
15354 let mut d = wysiwyg_doc("wys_word_del_partial", src);
15355 d.caret = src.find(" words").unwrap();
15356 d.delete_word_back();
15357 assert_eq!(d.source, "a **words** c\n");
15358 }
15359
15360 #[test]
15361 fn source_view_word_motion_still_walks_the_markup() {
15362 // The other half of the decision: in the source view the `**` are
15363 // characters like any other — they're on the screen, so word motion has
15364 // to stop at them and a word-delete has to leave them behind. Only
15365 // WYSIWYG hides them, so only WYSIWYG steps over them.
15366 let g = |n, m, f: fn(&mut Doc)| golden(n, m, f);
15367 assert_eq!(
15368 g("src_word_motion", "a |**bold** c\n", |d| d
15369 .move_word_right(false)),
15370 "a **bold|** c\n"
15371 );
15372 // The same caret as the WYSIWYG reproduction, and the opposite outcome:
15373 // here "a ** c\n" is right, because `bold**` is what's to the left of it.
15374 assert_eq!(
15375 g("src_word_del", "a **bold**| c\n", |d| d.delete_word_back()),
15376 "a **| c\n"
15377 );
15378 }
15379
15380 #[test]
15381 fn every_wysiwyg_motion_lands_on_a_caret_stop() {
15382 // The single invariant both bugs violated: the caret draws and edits at
15383 // the same place only when it's on a stop. `debug_assert_on_a_stop`
15384 // makes the same claim in-place; this pins it from the outside, over a
15385 // document with every kind of thing the map has to be careful about.
15386 // At two widths: the wide one every other test builds at, where no
15387 // fixture folds, and one narrow enough that they all do. A soft wrap is
15388 // where an offset stops being on exactly one row, and testing only the
15389 // width that never wraps is how the caret came to be pinned at the first
15390 // one Down reached.
15391 let src = "# Title\n\na **bold** e\u{0301}mo👨👩👧ji `x` c\n\n\
15392 - item one\n\n| A | B |\n|---|---|\n| x | y |\n";
15393 // A table of named operations, which is what it looks like.
15394 #[allow(clippy::type_complexity)]
15395 let motions: [(&str, fn(&mut Doc)); 8] = [
15396 ("right", |d| d.move_right(false)),
15397 ("left", |d| d.move_left(false)),
15398 ("word_right", |d| d.move_word_right(false)),
15399 ("word_left", |d| d.move_word_left(false)),
15400 ("down", |d| d.move_down(false)),
15401 ("up", |d| d.move_up(false)),
15402 ("home", |d| d.move_home(false)),
15403 ("end", |d| d.move_end(false)),
15404 ];
15405 for width in [80, 12] {
15406 let mut d = wysiwyg_doc("stop_invariant", src);
15407 d.build_visual(width);
15408 let stops: Vec<usize> = (0..=src.len()).filter(|&o| d.vmap.is_stop(o)).collect();
15409 assert!(stops.len() > 20, "fixture should have plenty of stops");
15410 for start in stops {
15411 for (name, motion) in &motions {
15412 d.caret = start;
15413 d.anchor = None;
15414 motion(&mut d);
15415 assert!(
15416 d.vmap.is_stop(d.caret),
15417 "{name} from {start} at width {width} landed at {} — not a caret stop",
15418 d.caret
15419 );
15420 }
15421 }
15422 }
15423 }
15424
15425 #[test]
15426 fn no_wysiwyg_motion_is_a_dead_end() {
15427 // Down held to the bottom of a document reaches the bottom, and Up held
15428 // to the top reaches the top — from anywhere, at a width that wraps. The
15429 // invariant above says a motion lands somewhere legal; this one says it
15430 // gets somewhere at all, which is what a caret pinned at a wrap boundary
15431 // was quietly failing to do while every assertion around it held.
15432 let src = "# Title\n\none two three four five six seven eight nine ten\n\n\
15433 - item one two three four five\n\nlast\n";
15434 for width in [80, 12] {
15435 let mut d = wysiwyg_doc("no_dead_end", src);
15436 d.build_visual(width);
15437 let stops: Vec<usize> = (0..=src.len()).filter(|&o| d.vmap.is_stop(o)).collect();
15438 let (first, last) = (stops[0], stops[stops.len() - 1]);
15439 for &start in &stops {
15440 for (name, motion, want) in [
15441 (
15442 "down",
15443 (|d: &mut Doc| d.move_down(false)) as fn(&mut Doc),
15444 last,
15445 ),
15446 ("up", |d: &mut Doc| d.move_up(false), first),
15447 ] {
15448 d.caret = start;
15449 d.anchor = None;
15450 d.goal_col = None;
15451 // Every row, plus the presses the edges take, plus slack.
15452 for _ in 0..d.vmap.num_rows() + 4 {
15453 motion(&mut d);
15454 }
15455 assert_eq!(
15456 d.caret, want,
15457 "{name} held from {start} at width {width} never arrived"
15458 );
15459 }
15460 }
15461 }
15462 }
15463 // ── display columns ──────────────────────────────────────────────────────
15464 // A `col` is a terminal cell, not a character. The two are the same number
15465 // for the ASCII the fixtures above are written in, which is how they came
15466 // apart in the first place: `你` is one character drawn in two cells, so a
15467 // column counted in characters names a cell the text isn't in — one earlier
15468 // for every wide character to its left.
15469
15470 #[test]
15471 fn a_wide_character_is_two_columns_wide() {
15472 // The reproduction: `你` is one char and two cells, so the caret just
15473 // past it drew at column 1 — inside the character it had already left.
15474 for (view, tag) in VIEWS {
15475 let mut d = doc_in(view, &format!("wide_col_{tag}"), "你好\n");
15476 d.caret = "你".len();
15477 assert_eq!(d.caret_pos(), (0, 2), "{tag}: caret drew inside 你");
15478 d.caret = "你好".len();
15479 assert_eq!(d.caret_pos(), (0, 4), "{tag}");
15480 }
15481 }
15482
15483 #[test]
15484 fn a_cluster_is_as_wide_as_it_is_drawn_not_as_its_codepoints_measure() {
15485 // `👨👩👧` is five codepoints — two-cell, joiner, two-cell, joiner,
15486 // two-cell — measuring six cells one at a time, but the character they
15487 // spell is drawn in two. Width belongs to the cluster, not the glyph,
15488 // and the frontends measure it the same way.
15489 let family = "👨👩👧";
15490 for (view, tag) in VIEWS {
15491 let src = format!("a{family}b\n");
15492 let mut d = doc_in(view, &format!("wide_cluster_{tag}"), &src);
15493 d.caret = 1 + family.len();
15494 assert_eq!(
15495 d.caret_pos(),
15496 (0, 3),
15497 "{tag}: 'a' is one cell, the family two"
15498 );
15499 }
15500 }
15501
15502 #[test]
15503 fn both_cells_of_a_wide_character_mean_the_character() {
15504 // Clicking the far half of `好` is still clicking `好`: half a character
15505 // is not a place the caret can be, so it comes to rest at the
15506 // character's start — the column it would have been drawn at anyway.
15507 for (view, tag) in VIEWS {
15508 let mut d = doc_in(view, &format!("wide_click_{tag}"), "你好\n");
15509 for col in [2, 3] {
15510 d.caret = 0;
15511 d.click(0, col, false);
15512 assert_eq!(d.caret, "你".len(), "{tag}: click at col {col}");
15513 assert_eq!(d.caret_pos(), (0, 2), "{tag}: click at col {col}");
15514 }
15515 // Past the last cell is the line's end, as it is for ASCII.
15516 d.click(0, 9, false);
15517 assert_eq!(d.caret, "你好".len(), "{tag}: click past the end");
15518 }
15519 }
15520
15521 #[test]
15522 fn every_offset_survives_the_trip_out_to_a_column_and_back() {
15523 // The mapping is only a mapping if it inverts: the cell the caret is
15524 // drawn in has to be the cell that brings it back to the same offset.
15525 // Over a fixture where a character may be one cell or two, and one
15526 // codepoint or five.
15527 use unicode_segmentation::UnicodeSegmentation;
15528
15529 let src = "ab 你好 c\n\n👨👩👧 e\u{0301}x 漢字\n\nplain ascii\n";
15530
15531 let mut d = doc_in(View::Source, "roundtrip_source", src);
15532 // Every offset the source view's caret can occupy: it steps by grapheme
15533 // cluster, so those are its boundaries.
15534 for (off, _) in src
15535 .grapheme_indices(true)
15536 .chain(std::iter::once((src.len(), "")))
15537 {
15538 d.caret = off;
15539 let (row, col) = d.caret_pos();
15540 d.click(row, col, false);
15541 assert_eq!(d.caret, off, "source: {off} → ({row}, {col}) → {}", d.caret);
15542 }
15543
15544 // And in WYSIWYG, where the offsets the caret can occupy are the map's
15545 // stops rather than every boundary.
15546 let mut d = doc_in(View::Wysiwyg, "roundtrip_wysiwyg", src);
15547 let stops: Vec<usize> = (0..=src.len()).filter(|&o| d.vmap.is_stop(o)).collect();
15548 assert!(stops.len() > 20, "fixture should have plenty of stops");
15549 for off in stops {
15550 d.caret = off;
15551 let (row, col) = d.caret_pos();
15552 d.click(row, col, false);
15553 assert_eq!(
15554 d.caret, off,
15555 "wysiwyg: {off} → ({row}, {col}) → {}",
15556 d.caret
15557 );
15558 }
15559 }
15560
15561 #[test]
15562 fn vertical_motion_aims_at_a_column_the_reader_can_see() {
15563 // Down from under `世` lands under the glyph in that cell, not two
15564 // characters further along the line. The goal is a column, so a line of
15565 // wide characters and a line of ASCII line up the way they're drawn.
15566 //
15567 // The gap differs by view: a bare newline inside a paragraph is a soft
15568 // break, which WYSIWYG draws as a space on a single row. The views share
15569 // a grid only where the source's lines are the renderer's rows too.
15570 for (view, tag) in VIEWS {
15571 let gap = if view == View::Source { "\n" } else { "\n\n" };
15572 let src = format!("你好世{gap}abcdef\n");
15573 let mut d = doc_in(view, &format!("goal_wide_{tag}"), &src);
15574 d.caret = "你好".len();
15575 assert_eq!(d.caret_pos().1, 4, "{tag}: `世` is drawn at column 4");
15576 d.move_down(false);
15577 assert_eq!(d.caret_pos().1, 4, "{tag}: goal column lost");
15578 assert!(
15579 d.source[d.caret..].starts_with('e'),
15580 "{tag}: landed on the wrong glyph"
15581 );
15582 }
15583 }
15584
15585 #[test]
15586 fn a_goal_column_landing_inside_a_wide_character_lands_on_it() {
15587 // Down from column 3 onto `你好`, whose characters start at columns 0
15588 // and 2: column 3 is the *second* cell of `好`. There is nowhere to be
15589 // between the cells of one character, so the caret rests on it — and on
15590 // its start, which is the only offset there that is a caret stop.
15591 for (view, tag) in VIEWS {
15592 let gap = if view == View::Source { "\n" } else { "\n\n" };
15593 let src = format!("abcdef{gap}你好\n");
15594 let mut d = doc_in(view, &format!("goal_inside_{tag}"), &src);
15595 let line = src.find('你').unwrap();
15596 d.caret = 3;
15597 d.move_down(false);
15598 assert_eq!(d.caret, line + "你".len(), "{tag}: landed off `好`'s start");
15599 assert_eq!(d.caret_pos().1, 2, "{tag}: drew between `好`'s cells");
15600 }
15601 }
15602
15603 #[test]
15604 fn a_caret_in_a_table_cell_of_wide_text_draws_where_the_text_is() {
15605 // The column the cell's text is laid out in is measured in cells, so the
15606 // caret walking that text has to be too — the two agreeing is the whole
15607 // point of the grid staying square.
15608 let mut d = wysiwyg_doc("table_wide", "| A | B |\n|---|---|\n| 你好 | y |\n");
15609 let at = d.source.find("你").unwrap();
15610 d.caret = at;
15611 let (row, col) = d.caret_pos();
15612 // `│ ` opens the row, so the cell's text starts at column 2; `好` is two
15613 // cells further along.
15614 assert_eq!(col, 2, "the cell's first character");
15615 d.move_right(false);
15616 assert_eq!(
15617 d.caret_pos(),
15618 (row, 4),
15619 "`好` is drawn past `你`'s two cells"
15620 );
15621 assert_eq!(d.caret, at + "你".len());
15622 }
15623
15624 // ── active inline marks ───────────────────────────────────────────────────
15625
15626 /// The marks at a `|`-marked fixture's caret, in `InlineMarks::iter` order.
15627 fn marks(view: View, name: &str, marked: &str) -> Vec<InlineKind> {
15628 let (src, caret) = parse_caret(marked);
15629 let mut d = doc_in(view, name, &src);
15630 d.caret = caret;
15631 d.active_inline_marks().iter().collect()
15632 }
15633
15634 /// The marks over the selection `[start, end)`.
15635 fn marks_over(view: View, name: &str, src: &str, start: usize, end: usize) -> Vec<InlineKind> {
15636 let mut d = doc_in(view, name, src);
15637 d.anchor = Some(start);
15638 d.caret = end;
15639 d.active_inline_marks().iter().collect()
15640 }
15641
15642 #[test]
15643 fn a_caret_in_a_mark_reports_it() {
15644 for (view, tag) in VIEWS {
15645 let m = |marked| marks(view, &format!("marks_in_{tag}"), marked);
15646 assert_eq!(m("a **bo|ld** b"), [InlineKind::Strong], "{tag}");
15647 assert_eq!(m("a *it|alic* b"), [InlineKind::Emph], "{tag}");
15648 assert_eq!(m("a `co|de` b"), [InlineKind::Verbatim], "{tag}");
15649 // Plain text under no mark lights nothing — the toolbar's resting state.
15650 assert_eq!(m("a| **bold** b"), [], "{tag}");
15651 assert!(m("plain t|ext").is_empty(), "{tag}");
15652 }
15653 }
15654
15655 #[test]
15656 fn nested_marks_all_report() {
15657 // Bold *and* italic: a toolbar lights both buttons, so the set has both —
15658 // the ancestor chain is a chain, and every mark on it is in force.
15659 for (view, tag) in VIEWS {
15660 assert_eq!(
15661 marks(
15662 view,
15663 &format!("marks_nested_{tag}"),
15664 "**bold and *bo|th*** end"
15665 ),
15666 [InlineKind::Strong, InlineKind::Emph],
15667 "{tag}"
15668 );
15669 }
15670 }
15671
15672 #[test]
15673 fn the_caret_at_a_marks_edge_reports_it_where_typing_would_extend_it() {
15674 // The offsets a WYSIWYG caret actually reaches at a bold run's edges are
15675 // the first byte of its text and the byte after its last — both inside
15676 // the mark's span, both places typing lands inside the bold. The offset
15677 // past the closing delimiter is the next text, and reports nothing.
15678 let src = "a **bold** b";
15679 let inner_start = src.find("bold").unwrap(); // 4
15680 let inner_end = inner_start + "bold".len(); // 8, on the closing `**`
15681 for (view, tag) in VIEWS {
15682 let mut d = doc_in(view, &format!("marks_edge_{tag}"), src);
15683 for off in [2, 3, inner_start, inner_end, 9] {
15684 d.caret = off;
15685 assert!(
15686 d.active_inline_marks().contains(InlineKind::Strong),
15687 "{tag}: offset {off} is inside the strong span"
15688 );
15689 }
15690 for off in [0, 1, 10, 11, 12] {
15691 d.caret = off;
15692 assert!(
15693 !d.active_inline_marks().contains(InlineKind::Strong),
15694 "{tag}: offset {off} is outside the strong run"
15695 );
15696 }
15697 }
15698 }
15699
15700 #[test]
15701 fn a_mark_ends_the_same_way_at_the_end_of_the_buffer_as_in_the_middle() {
15702 // Regression: twig resolves an offset that is one node's end and the
15703 // next one's start to the node that *starts* there, so `**bold**|\n`
15704 // isn't bold. With nothing following there's no tie to break and the
15705 // chain still ended at the mark, which made a trailing `\n` — not the
15706 // text — decide whether the caret after a bold word reported bold. It's
15707 // the offset past the mark either way, and typing there is plain either
15708 // way. A blank document typed into is exactly this shape.
15709 for (view, tag) in VIEWS {
15710 let m = |name: String, marked| marks(view, &name, marked);
15711 assert_eq!(
15712 m(format!("marks_eob_{tag}"), "**bold**|"),
15713 [],
15714 "{tag}: no trailing newline"
15715 );
15716 assert_eq!(
15717 m(format!("marks_eol_{tag}"), "**bold**|\n"),
15718 [],
15719 "{tag}: with one"
15720 );
15721 // And the last offset that *is* in the mark still is.
15722 assert_eq!(
15723 m(format!("marks_eob_in_{tag}"), "**bold*|*"),
15724 [InlineKind::Strong],
15725 "{tag}"
15726 );
15727 }
15728 }
15729
15730 #[test]
15731 fn a_selection_reports_a_mark_only_when_it_covers_the_whole_thing() {
15732 let src = "a **bold** b";
15733 let (b, d_) = (src.find("bold").unwrap(), src.find("bold").unwrap() + 4);
15734 for (view, tag) in VIEWS {
15735 let m = |s, e| marks_over(view, &format!("marks_sel_{tag}"), src, s, e);
15736 // The whole bold word, and a slice of it.
15737 assert_eq!(m(b, d_), [InlineKind::Strong], "{tag}: the whole word");
15738 assert_eq!(m(b + 1, d_ - 1), [InlineKind::Strong], "{tag}: a slice");
15739 // Ending exactly at the closing delimiter's start is still all-bold:
15740 // an exclusive end sits *past* the last selected character, so the
15741 // question is asked of the character, not the boundary.
15742 assert_eq!(
15743 m(b, d_ + 2),
15744 [InlineKind::Strong],
15745 "{tag}: through the close"
15746 );
15747 // Half in, half out: Bold lit here would claim a press turns it off.
15748 assert_eq!(m(0, d_), [], "{tag}: leading plain text");
15749 assert_eq!(m(b, src.len()), [], "{tag}: trailing plain text");
15750 }
15751 }
15752
15753 #[test]
15754 fn a_selection_across_two_runs_of_the_same_mark_reports_nothing() {
15755 // Both ends are bold, but the space between them isn't — two runs are two
15756 // nodes, which is exactly what the node id catches and a kind-only
15757 // comparison would not.
15758 let src = "**one** **two**";
15759 for (view, tag) in VIEWS {
15760 let m = marks_over(view, &format!("marks_runs_{tag}"), src, 2, 13);
15761 assert_eq!(m, [], "{tag}: `one** **two` is not all bold");
15762 }
15763 }
15764
15765 #[test]
15766 fn marks_read_the_document_as_it_is_edited() {
15767 // The point of asking twig every frame instead of caching: the answer has
15768 // to follow the toggle that changed it.
15769 let mut d = wysiwyg_doc("marks_live", "one two\n");
15770 d.anchor = Some(0);
15771 d.caret = 3;
15772 assert!(d.active_inline_marks().is_empty(), "plain to start");
15773 d.toggle(InlineKind::Strong);
15774 assert_eq!(d.source, "**one** two\n");
15775 // `toggle` leaves the bolded text selected, so the button it lit stays lit.
15776 assert!(d.active_inline_marks().contains(InlineKind::Strong));
15777 d.toggle(InlineKind::Strong);
15778 assert!(d.active_inline_marks().is_empty(), "and off again");
15779 }
15780
15781 #[test]
15782 fn a_link_is_not_an_inline_mark() {
15783 // `link`/`str` are inline nodes, but nothing on the inline toolbar
15784 // toggles them — a set with a "link mark" in it would have no button.
15785 for (view, tag) in VIEWS {
15786 assert_eq!(
15787 marks(view, &format!("marks_link_{tag}"), "a [te|xt](u) b"),
15788 [],
15789 "{tag}"
15790 );
15791 }
15792 }
15793
15794 // ── blank documents ───────────────────────────────────────────────────────
15795
15796 #[test]
15797 fn a_blank_document_is_untitled_empty_and_markdown() {
15798 let mut d = Doc::blank().unwrap();
15799 assert!(d.is_untitled());
15800 assert_eq!(d.path, PathBuf::new());
15801 assert_eq!(
15802 d.file_name(),
15803 "untitled",
15804 "the header has to show something"
15805 );
15806 assert_eq!(d.format_name(), "markdown");
15807 assert_eq!(d.source, "");
15808 assert!(!d.dirty, "nothing typed yet is nothing to lose");
15809 assert_eq!(d.disk_state(), DiskState::Untitled);
15810 // And it's a document you can be in: the default view renders it.
15811 d.build_visual(80);
15812 assert_eq!(d.caret, 0);
15813 }
15814
15815 #[test]
15816 fn saving_an_untitled_document_asks_for_a_name_instead_of_writing() {
15817 let mut d = Doc::blank().unwrap();
15818 d.insert("hello");
15819 assert!(d.dirty);
15820 d.save();
15821 assert_eq!(d.status.as_deref(), Some("untitled — save as…"));
15822 assert!(d.dirty, "it must not come away believing it saved");
15823 assert!(d.is_untitled(), "and it still has no file");
15824 }
15825
15826 #[test]
15827 fn a_blank_document_becomes_a_real_one_at_the_first_save_as() {
15828 let p = temp_path("blank_save_as");
15829 let mut d = Doc::blank().unwrap();
15830 // Plain text — a blank doc opens in Hidden mode, where a typed `#` would
15831 // be kept literal (`\#`); this test is about save-as, not escaping (which
15832 // has its own test), so it types nothing that escaping would touch.
15833 d.insert("hi");
15834 d.save_as(p.clone());
15835 assert_eq!(std::fs::read_to_string(&p).unwrap(), "hi");
15836 assert!(!d.is_untitled());
15837 assert!(!d.dirty);
15838 assert_eq!(d.file_name(), p.file_name().unwrap().to_string_lossy());
15839 assert_eq!(
15840 d.disk_state(),
15841 DiskState::Unchanged,
15842 "the watermark is stamped"
15843 );
15844 // And ⌘S is a plain save from here on.
15845 d.insert("!");
15846 d.save();
15847 assert_eq!(std::fs::read_to_string(&p).unwrap(), "hi!");
15848 let _ = std::fs::remove_file(&p);
15849 }
15850
15851 // ── a file that isn't there yet ───────────────────────────────────────────
15852
15853 /// A unique path in the temp dir with the given extension, guaranteed not to
15854 /// exist — what `leaf notes.md` is handed when the file has never been made.
15855 fn missing_path(name: &str, ext: &str) -> PathBuf {
15856 static SEQ: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
15857 let seq = SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
15858 let mut p = std::env::temp_dir();
15859 p.push(format!("leaf_test_new_{name}_{seq}.{ext}"));
15860 let _ = std::fs::remove_file(&p);
15861 p
15862 }
15863
15864 #[test]
15865 fn a_file_that_doesnt_exist_opens_as_an_empty_named_document() {
15866 let p = missing_path("named", "md");
15867 let mut d = Doc::open_or_create(p.clone()).unwrap();
15868
15869 assert_eq!(d.source, "", "nothing was read, so there's nothing in it");
15870 assert!(!d.dirty, "an untouched new buffer has nothing to lose");
15871 assert!(
15872 !d.is_untitled(),
15873 "it has the name the user asked for — ^S must not detour to Save As"
15874 );
15875 assert_eq!(d.file_name(), p.file_name().unwrap().to_str().unwrap());
15876 assert!(d.path.is_absolute(), "the same absolute path `open` stores");
15877 assert!(!p.exists(), "and opening it wrote nothing");
15878 // And it's a document you can be in.
15879 d.build_visual(80);
15880 assert_eq!(d.caret, 0);
15881 }
15882
15883 #[test]
15884 fn a_new_file_is_created_by_its_first_save() {
15885 let p = missing_path("first_save", "md");
15886 let mut d = Doc::open_or_create(p.clone()).unwrap();
15887 d.insert("hello\n");
15888 assert!(d.dirty);
15889 d.save();
15890
15891 assert_eq!(
15892 std::fs::read_to_string(&p).unwrap(),
15893 "hello\n",
15894 "a plain ^S wrote it — no Save As, no name to invent"
15895 );
15896 assert!(!d.dirty);
15897 assert_eq!(d.disk_state(), DiskState::Unchanged);
15898 let _ = std::fs::remove_file(&p);
15899 }
15900
15901 #[test]
15902 fn a_new_file_takes_its_format_from_the_extension() {
15903 // The one thing `blank` can't do: with no name it has to assume Markdown,
15904 // and typing djot into a Markdown parse is the wrong buffer.
15905 let dj = missing_path("format", "dj");
15906 assert_eq!(Doc::open_or_create(dj).unwrap().format_name(), "djot");
15907 let md = missing_path("format", "md");
15908 assert_eq!(Doc::open_or_create(md).unwrap().format_name(), "markdown");
15909 }
15910
15911 #[test]
15912 fn a_new_file_reports_itself_missing_until_it_is_saved() {
15913 // Not `Untitled` — that's the answer for a document with no path, and it
15914 // would tell a frontend there is nothing a save could collide with. Here
15915 // there is a path, and the file simply isn't at it yet.
15916 let p = missing_path("disk_state", "md");
15917 let mut d = Doc::open_or_create(p.clone()).unwrap();
15918 assert_eq!(d.disk_state(), DiskState::Missing);
15919
15920 // Somebody else creates it while the buffer is open: that's an overwrite
15921 // the frontend has to be able to prompt about, exactly as for an opened
15922 // file. Their bytes, not ours, so `Changed`.
15923 std::fs::write(&p, "theirs\n").unwrap();
15924 assert_eq!(d.disk_state(), DiskState::Changed);
15925
15926 // Saving makes the file ours and re-stamps the watermark.
15927 d.insert("ours\n");
15928 d.save();
15929 assert_eq!(d.disk_state(), DiskState::Unchanged);
15930 assert_eq!(std::fs::read_to_string(&p).unwrap(), "ours\n");
15931 let _ = std::fs::remove_file(&p);
15932 }
15933
15934 #[test]
15935 fn open_or_create_still_opens_a_file_that_is_there() {
15936 let d = doc_with("open_or_create_existing", "body\n");
15937 let reopened = Doc::open_or_create(d.path.clone()).unwrap();
15938 assert_eq!(reopened.source, "body\n");
15939 assert_eq!(reopened.disk_state(), DiskState::Unchanged);
15940 }
15941
15942 #[test]
15943 fn a_missing_file_with_no_readable_extension_is_still_an_error() {
15944 // A mistyped flag or a stray argument must not become a buffer promising
15945 // to save somewhere — the same refusal `open` gives a real file.
15946 let mut p = std::env::temp_dir();
15947 p.push("leaf_test_new_bad_ext.wat");
15948 assert!(Doc::open_or_create(p).is_err());
15949 let mut none = std::env::temp_dir();
15950 none.push("leaf_test_new_no_ext");
15951 assert!(Doc::open_or_create(none).is_err());
15952 }
15953
15954 #[test]
15955 fn a_new_file_in_a_directory_that_doesnt_exist_opens_but_wont_save() {
15956 // Opening reads nothing, so there is nothing to fail on yet; the write is
15957 // where it fails, and it says so rather than claiming a save.
15958 let p = std::env::temp_dir().join("leaf_test_no_such_dir_c41/doc.md");
15959 let mut d = Doc::open_or_create(p).unwrap();
15960 d.insert("x");
15961 d.save();
15962 assert!(
15963 d.status.as_deref().unwrap().starts_with("save failed:"),
15964 "got {:?}",
15965 d.status
15966 );
15967 assert!(d.dirty, "it must not come away believing it saved");
15968 }
15969
15970 // ── save as ───────────────────────────────────────────────────────────────
15971
15972 /// A unique path in the temp dir that no fixture wrote — a Save As target.
15973 fn temp_path(name: &str) -> PathBuf {
15974 static SEQ: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
15975 let seq = SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
15976 let mut p = std::env::temp_dir();
15977 p.push(format!("leaf_test_target_{name}_{seq}.md"));
15978 let _ = std::fs::remove_file(&p);
15979 p
15980 }
15981
15982 #[test]
15983 fn save_as_moves_the_document_and_leaves_the_old_file_alone() {
15984 let mut d = doc_with("save_as_move", "original\n");
15985 let old = d.path.clone();
15986 let new = temp_path("save_as_move");
15987 d.insert("edited: ");
15988 d.save_as(new.clone());
15989
15990 assert_eq!(std::fs::read_to_string(&new).unwrap(), "edited: original\n");
15991 assert_eq!(
15992 std::fs::read_to_string(&old).unwrap(),
15993 "original\n",
15994 "Save As doesn't touch the file it came from"
15995 );
15996 assert_eq!(d.path, new, "the document moved");
15997 assert!(!d.dirty);
15998 assert_eq!(
15999 d.status.as_deref(),
16000 Some(&*format!("saved {}", d.file_name()))
16001 );
16002
16003 // Every later save follows it, which is the whole difference from a copy.
16004 d.caret = 0;
16005 d.insert("re-");
16006 d.save();
16007 assert_eq!(
16008 std::fs::read_to_string(&new).unwrap(),
16009 "re-edited: original\n"
16010 );
16011 assert_eq!(std::fs::read_to_string(&old).unwrap(), "original\n");
16012 let _ = std::fs::remove_file(&new);
16013 }
16014
16015 #[test]
16016 fn save_as_overwrites_an_existing_target() {
16017 // The picker already asked; asking again down here is the same question
16018 // twice, and the second one has no way to be answered.
16019 let new = temp_path("save_as_over");
16020 std::fs::write(&new, "theirs\n").unwrap();
16021 let mut d = doc_with("save_as_over", "ours\n");
16022 d.save_as(new.clone());
16023 assert_eq!(std::fs::read_to_string(&new).unwrap(), "ours\n");
16024 let _ = std::fs::remove_file(&new);
16025 }
16026
16027 #[test]
16028 fn a_save_as_that_fails_leaves_the_document_where_it_was() {
16029 let mut d = doc_with("save_as_fail", "body\n");
16030 let old = d.path.clone();
16031 d.insert("x");
16032 // A directory that doesn't exist: the write can't land.
16033 let bad = std::env::temp_dir().join("leaf_test_no_such_dir_9f2/doc.md");
16034 d.save_as(bad);
16035
16036 assert_eq!(
16037 d.path, old,
16038 "the document must not move to a file that isn't there"
16039 );
16040 assert!(d.dirty, "and must not believe it saved");
16041 assert!(
16042 d.status.as_deref().unwrap().starts_with("save failed:"),
16043 "the same failure a plain save reports, got {:?}",
16044 d.status
16045 );
16046 // The original is still the document's file, and still saveable.
16047 d.save();
16048 assert_eq!(std::fs::read_to_string(&old).unwrap(), "xbody\n");
16049 assert!(!d.dirty);
16050 }
16051
16052 #[test]
16053 fn save_as_renames_without_reparsing_the_format() {
16054 // `.dj` on the name doesn't make the buffer djot: it was parsed as
16055 // Markdown and still is, and saying otherwise would be a conversion the
16056 // user never asked for (and an undo history thrown away to do it).
16057 let mut d = doc_with("save_as_format", "**b**\n");
16058 let mut new = temp_path("save_as_format");
16059 new.set_extension("dj");
16060 d.save_as(new.clone());
16061 assert_eq!(d.format_name(), "markdown");
16062 let _ = std::fs::remove_file(&new);
16063 }
16064
16065 // ── external change / reload ──────────────────────────────────────────────
16066
16067 #[test]
16068 fn an_untouched_file_reports_unchanged() {
16069 let mut d = doc_with("disk_clean", "body\n");
16070 assert_eq!(d.disk_state(), DiskState::Unchanged);
16071 // Editing the buffer is not editing the file.
16072 d.insert("x");
16073 assert_eq!(d.disk_state(), DiskState::Unchanged);
16074 assert!(d.dirty);
16075 // Saving re-stamps the watermark rather than reporting our own bytes back.
16076 d.save();
16077 assert_eq!(d.disk_state(), DiskState::Unchanged);
16078 }
16079
16080 #[test]
16081 fn a_file_written_underneath_reports_changed() {
16082 let mut d = doc_with("disk_changed", "body\n");
16083 std::fs::write(&d.path, "someone else\n").unwrap();
16084 assert_eq!(d.disk_state(), DiskState::Changed);
16085 // Dirty *and* changed is the clobber: both halves are readable, and
16086 // leaf-core takes neither side.
16087 d.insert("x");
16088 assert!(d.dirty && d.disk_state() == DiskState::Changed);
16089 // Saving anyway is allowed — the frontend asked, or chose not to.
16090 d.save();
16091 assert_eq!(std::fs::read_to_string(&d.path).unwrap(), "xbody\n");
16092 assert_eq!(d.disk_state(), DiskState::Unchanged);
16093 }
16094
16095 #[test]
16096 fn a_file_rewritten_with_the_same_bytes_is_unchanged() {
16097 // The hash is what makes this honest: the file was written (a fresh
16098 // mtime), and nothing about the document is stale.
16099 let d = doc_with("disk_same_bytes", "body\n");
16100 std::fs::write(&d.path, "body\n").unwrap();
16101 assert_eq!(d.disk_state(), DiskState::Unchanged);
16102 }
16103
16104 #[test]
16105 fn a_deleted_file_reports_missing() {
16106 let mut d = doc_with("disk_missing", "body\n");
16107 std::fs::remove_file(&d.path).unwrap();
16108 assert_eq!(d.disk_state(), DiskState::Missing);
16109 // A save recreates it, and the document is whole again.
16110 d.save();
16111 assert_eq!(d.disk_state(), DiskState::Unchanged);
16112 assert_eq!(std::fs::read_to_string(&d.path).unwrap(), "body\n");
16113 }
16114
16115 #[test]
16116 fn reload_replaces_the_document_with_the_file() {
16117 for (view, tag) in VIEWS {
16118 let mut d = doc_in(view, &format!("reload_{tag}"), "one\n\ntwo\n");
16119 d.insert("edited ");
16120 assert!(d.dirty);
16121 std::fs::write(&d.path, "one\n\ntwo\n\nthree\n").unwrap();
16122 d.reload();
16123
16124 assert_eq!(d.source, "one\n\ntwo\n\nthree\n", "{tag}");
16125 assert!(!d.dirty, "{tag}: the file is what we have");
16126 assert_eq!(d.disk_state(), DiskState::Unchanged, "{tag}");
16127 assert_eq!(
16128 d.status.as_deref(),
16129 Some(&*format!("reloaded {}", d.file_name()))
16130 );
16131 // The reloaded tree is live, not the old parse.
16132 d.caret = d.source.find("three").unwrap();
16133 assert_eq!(d.breadcrumb(), "doc › para › str", "{tag}");
16134 }
16135 }
16136
16137 #[test]
16138 fn reload_clamps_the_caret_and_drops_the_selection() {
16139 let mut d = doc_with("reload_caret", "a long first line\n");
16140 d.caret = 12;
16141 d.anchor = Some(4);
16142 std::fs::write(&d.path, "short\n").unwrap();
16143 d.reload();
16144 assert_eq!(d.caret, d.source.len(), "clamped into the shorter file");
16145 assert_eq!(
16146 d.anchor, None,
16147 "a selection over bytes that changed is a lie"
16148 );
16149 assert!(d.selection().is_none());
16150
16151 // A caret the file still has room for stays put.
16152 let mut d = doc_with("reload_caret_keep", "one\n\ntwo\n");
16153 d.caret = 2;
16154 std::fs::write(&d.path, "one\n\ntwo\n\nthree\n").unwrap();
16155 d.reload();
16156 assert_eq!(d.caret, 2);
16157 }
16158
16159 /// A silent reload is something that happened *to* a reader — a formatter,
16160 /// a `git checkout` — so it has to be undoable like anything else that
16161 /// changes the document, and undoable as one step rather than as however
16162 /// many the file happens to differ by.
16163 #[test]
16164 fn reload_is_one_undo_step_and_keeps_the_history_under_it() {
16165 let mut d = doc_with("reload_undo", "body\n");
16166 d.insert("x");
16167 assert_eq!(d.source, "xbody\n");
16168 std::fs::write(&d.path, "replaced\n").unwrap();
16169 d.reload();
16170 assert_eq!(d.source, "replaced\n");
16171 assert!(!d.dirty, "a reload lands clean");
16172
16173 // One ^Z takes the whole swap off, and hands back the unsaved work it
16174 // replaced — which is unsaved again, because the file no longer says it.
16175 d.undo();
16176 assert_eq!(d.source, "xbody\n", "the reload comes off in one step");
16177 assert!(d.dirty, "and what it comes back to is unsaved");
16178 // …and the history under it is still there.
16179 d.undo();
16180 assert_eq!(
16181 d.source, "body\n",
16182 "the typing before the reload undoes too"
16183 );
16184 // Redo walks back up through the reload.
16185 d.redo();
16186 d.redo();
16187 assert_eq!(d.source, "replaced\n");
16188 }
16189
16190 /// A file rewritten with the bytes it already had is not an edit, so it
16191 /// must not leave an undo step behind for something nobody did.
16192 #[test]
16193 fn reloading_identical_bytes_pushes_no_undo_step() {
16194 let mut d = doc_with("reload_same", "body\n");
16195 d.insert("x");
16196 std::fs::write(&d.path, "xbody\n").unwrap();
16197 d.reload();
16198 assert_eq!(d.source, "xbody\n");
16199 assert!(!d.dirty, "the file now says what the buffer does");
16200 d.undo();
16201 assert_eq!(
16202 d.source, "body\n",
16203 "one step back is the typing, not a no-op"
16204 );
16205 }
16206
16207 #[test]
16208 fn a_reload_that_cant_read_leaves_the_document_alone() {
16209 let mut d = doc_with("reload_gone", "body\n");
16210 d.insert("x");
16211 std::fs::remove_file(&d.path).unwrap();
16212 d.reload();
16213 assert_eq!(d.source, "xbody\n", "the unsaved work is still here");
16214 assert!(d.dirty);
16215 assert!(
16216 d.status.as_deref().unwrap().starts_with("reload failed:"),
16217 "{:?}",
16218 d.status
16219 );
16220
16221 // And an untitled document has nothing to reload from.
16222 let mut d = Doc::blank().unwrap();
16223 d.insert("typed");
16224 d.reload();
16225 assert_eq!(d.source, "typed");
16226 assert_eq!(d.status.as_deref(), Some("no file to reload"));
16227 }
16228
16229 #[test]
16230 fn a_read_only_document_refuses_every_door() {
16231 let mut d = doc_with("readonly", "one two three\n");
16232 d.insert("x");
16233 assert!(d.dirty, "writable first, so the undo step exists");
16234 d.set_read_only(true);
16235 let before = d.source.clone();
16236 d.insert("y");
16237 d.backspace();
16238 d.undo();
16239 d.redo();
16240 assert_eq!(d.source, before, "no door moved a byte");
16241 d.set_read_only(false);
16242 d.undo();
16243 assert_ne!(d.source, before, "off again, the same doors work");
16244 }
16245
16246 /// The doors that go to twig's own verbs rather than through the splice.
16247 /// Typed text in the rendered view under the default markup mode is the
16248 /// everyday one — it is what a keystroke in leaf-web or the Apple views
16249 /// becomes — and it walked straight past the gate.
16250 #[test]
16251 fn a_read_only_document_refuses_the_doors_around_the_splice() {
16252 let mut d = wysiwyg_doc(
16253 "readonly-doors",
16254 "one two three\n\n| a | b |\n|---|---|\n| c | d |\n",
16255 );
16256 d.set_markup_mode(MarkupMode::None);
16257 d.set_read_only(true);
16258 let before = d.source.clone();
16259 d.place_caret(3, false);
16260 d.insert("y");
16261 d.insert_link("https://example.com");
16262 d.insert_image("a.png", "alt");
16263 d.insert_thematic_break();
16264 d.insert_footnote();
16265 d.place_caret(0, false);
16266 d.place_caret(3, true);
16267 d.toggle(InlineKind::Strong);
16268 d.toggle_heading(2);
16269 d.set_block(BlockKind::Paragraph);
16270 d.toggle_list(false);
16271 d.toggle_blockquote();
16272 d.toggle_task_item();
16273 d.newline();
16274 d.indent();
16275 d.set_code_language("rust");
16276 let in_cell = d.source.find("| c").unwrap() + 2;
16277 d.place_caret(in_cell, false);
16278 assert!(d.caret_in_table(), "the caret is in the grid");
16279 assert!(!d.cell_line_break(), "the cell break reports the refusal");
16280 assert_eq!(d.source, before, "no door moved a byte");
16281 assert!(!d.dirty, "nothing to save");
16282 d.set_read_only(false);
16283 d.place_caret(3, false);
16284 d.insert("y");
16285 assert_ne!(d.source, before, "off again, the same doors work");
16286 }
16287
16288 #[test]
16289 fn a_selection_quote_carries_its_context_on_char_boundaries() {
16290 let mut d = doc_with("quote", "before 你好 exact 世界 after\n");
16291 let start = d.source.find("exact").unwrap();
16292 d.place_caret(start, false);
16293 d.place_caret(start + "exact".len(), true);
16294 let q = d.selection_quote(3).unwrap();
16295 assert_eq!(q.exact, "exact");
16296 assert_eq!(
16297 q.prefix, "你好 ",
16298 "chars, not bytes — the multibyte pair counts as two"
16299 );
16300 assert_eq!(q.suffix, " 世界");
16301 assert_eq!(&d.source[q.start..q.end], "exact");
16302 // At the edges the context clips rather than erring.
16303 d.place_caret(0, false);
16304 d.place_caret(6, true);
16305 let q = d.selection_quote(40).unwrap();
16306 assert_eq!(q.prefix, "");
16307 assert_eq!(q.exact, "before");
16308 // No selection is no quote.
16309 d.place_caret(0, false);
16310 assert!(d.selection_quote(3).is_none());
16311 }
16312
16313 #[test]
16314 fn highlights_are_kept_sorted_and_answer_point_queries() {
16315 let mut d = doc_with("hl", "one two three\n");
16316 d.set_highlights(vec![
16317 Highlight {
16318 start: 8,
16319 end: 13,
16320 id: "b".into(),
16321 color: None,
16322 marker: None,
16323 },
16324 Highlight {
16325 start: 0,
16326 end: 3,
16327 id: "a".into(),
16328 color: Some("#ffe066".into()),
16329 marker: None,
16330 },
16331 Highlight {
16332 start: 5,
16333 end: 5,
16334 id: "empty".into(),
16335 color: None,
16336 marker: None,
16337 },
16338 ]);
16339 assert_eq!(
16340 d.highlights()
16341 .iter()
16342 .map(|h| h.id.as_str())
16343 .collect::<Vec<_>>(),
16344 ["a", "b"],
16345 "sorted by start, the empty range dropped"
16346 );
16347 assert_eq!(d.highlight_at(1).map(|h| h.id.as_str()), Some("a"));
16348 assert_eq!(d.highlight_at(3), None, "end is exclusive");
16349 assert_eq!(d.highlight_at(8).map(|h| h.id.as_str()), Some("b"));
16350 d.set_highlights(Vec::new());
16351 assert!(d.highlights().is_empty(), "a replace is a replace");
16352 }
16353
16354 /// `Highlight::covering` and the cursor over it are what both painters ask
16355 /// per glyph, so they have to answer the same as the scan they replaced —
16356 /// including in the gaps, which is where most glyphs are.
16357 #[test]
16358 fn covering_answers_from_a_sorted_list_without_scanning_all_of_it() {
16359 let hl = |start: usize, end: usize, id: &str| Highlight {
16360 start,
16361 end,
16362 id: id.into(),
16363 color: None,
16364 marker: None,
16365 };
16366 // Disjoint, as search hits are: in a range, in a gap, and past the end.
16367 let hits: Vec<Highlight> = (0..20).map(|i| hl(i * 10, i * 10 + 3, "hit")).collect();
16368 assert_eq!(Highlight::covering(&hits, 0).map(|h| h.start), Some(0));
16369 assert_eq!(Highlight::covering(&hits, 102).map(|h| h.start), Some(100));
16370 assert_eq!(
16371 Highlight::covering(&hits, 105),
16372 None,
16373 "a gap covers nothing"
16374 );
16375 assert_eq!(Highlight::covering(&hits, 103), None, "end is exclusive");
16376 assert_eq!(Highlight::covering(&hits, 9_999), None);
16377 assert_eq!(Highlight::covering(&[], 0), None);
16378
16379 // Nested: first by start, so a hit inside an annotation still resolves
16380 // to the annotation — and the range that stops short doesn't mask it.
16381 let nested = vec![hl(0, 20, "outer"), hl(5, 10, "inner")];
16382 assert_eq!(
16383 Highlight::covering(&nested, 7).map(|h| h.id.as_str()),
16384 Some("outer")
16385 );
16386 assert_eq!(
16387 Highlight::covering(&nested, 15).map(|h| h.id.as_str()),
16388 Some("outer")
16389 );
16390 }
16391
16392 /// The cursor is an optimisation, so the only thing worth asserting is that
16393 /// it is not also a change of answer — at every offset, over a list with a
16394 /// nest in it, walked forwards and then backwards.
16395 #[test]
16396 fn the_highlight_cursor_answers_exactly_what_a_fresh_scan_would() {
16397 let hl = |start: usize, end: usize, id: &str| Highlight {
16398 start,
16399 end,
16400 id: id.into(),
16401 color: None,
16402 marker: None,
16403 };
16404 let mut list = vec![
16405 hl(0, 20, "outer"),
16406 hl(5, 10, "inner"),
16407 hl(30, 33, "hit"),
16408 hl(40, 43, "hit"),
16409 ];
16410 list.sort_by_key(|h| (h.start, h.end));
16411
16412 let mut cursor = HighlightCursor::new(&list);
16413 for offset in 0..50 {
16414 assert_eq!(
16415 cursor.at(offset).map(|h| h.id.as_str()),
16416 Highlight::covering(&list, offset).map(|h| h.id.as_str()),
16417 "cursor disagrees at {offset}"
16418 );
16419 }
16420 // Backwards: the cursor re-seats rather than answering from where it
16421 // had got to, so a painter that revisits a row is still told the truth.
16422 for offset in (0..50).rev() {
16423 assert_eq!(
16424 cursor.at(offset).map(|h| h.id.as_str()),
16425 Highlight::covering(&list, offset).map(|h| h.id.as_str()),
16426 "cursor disagrees walking back at {offset}"
16427 );
16428 }
16429 }
16430
16431 // ── the presentation vocabulary ─────────────────────────────────────────
16432
16433 /// A document in `format`, for the gesture tests that want more than the
16434 /// Markdown `doc_with` writes.
16435 fn fmt_doc(body: &str, format: Format) -> Doc {
16436 Doc::from_source(body.to_string(), format).unwrap()
16437 }
16438
16439 /// Alignment is a block property, so the gesture is `set_block_attrs` on
16440 /// the caret's block whatever is selected — and each format spells it its
16441 /// own way: djot's `{…}` line above the block, a `<div>` around it in
16442 /// Markdown (the format has nowhere else to put it), the tag in HTML.
16443 #[test]
16444 fn set_alignment_spells_the_class_the_format_s_own_way() {
16445 let mut dj = fmt_doc("hello\n", Format::Djot);
16446 dj.caret = 1;
16447 dj.set_alignment(Some(Align::Center));
16448 assert_eq!(dj.source, "{.center}\nhello\n");
16449 assert!(dj.dirty);
16450 assert_eq!(dj.status, None);
16451
16452 let mut md = fmt_doc("hello\n", Format::Markdown);
16453 md.caret = 1;
16454 md.set_alignment(Some(Align::Right));
16455 assert_eq!(md.source, "<div class=\"right\">\n\nhello\n\n</div>\n");
16456
16457 let mut html = fmt_doc("<p>hello</p>\n", Format::Html);
16458 html.caret = html.source.find("hello").unwrap();
16459 html.set_alignment(Some(Align::Justify));
16460 assert_eq!(html.source, "<p class=\"justify\">hello</p>\n");
16461 }
16462
16463 /// Each gesture edits **one key and keeps the rest** — twig's contract is
16464 /// replace-not-merge, so leaf reads the node's attributes, edits its own
16465 /// key out of them, and passes the list back whole. A document from
16466 /// elsewhere passes through the editor unharmed.
16467 #[test]
16468 fn a_presentation_gesture_keeps_every_attribute_it_did_not_write() {
16469 let mut d = fmt_doc(
16470 "{.lead .center #intro data-line-height=\"1.5\"}\nhello\n",
16471 Format::Djot,
16472 );
16473 d.caret = d.source.find("hello").unwrap();
16474 d.set_alignment(Some(Align::Right));
16475 // `center` goes, `lead` stays, and neither the id nor the spacing is
16476 // touched.
16477 // The serializer picks the order; what matters is which keys survive.
16478 assert!(d.source.contains(".lead"), "{:?}", d.source);
16479 assert!(d.source.contains(".right"), "{:?}", d.source);
16480 assert!(!d.source.contains(".center"), "{:?}", d.source);
16481 assert!(d.source.contains("#intro"), "{:?}", d.source);
16482 assert!(
16483 d.source.contains("data-line-height=\"1.5\""),
16484 "{:?}",
16485 d.source
16486 );
16487 assert_eq!(d.alignment_at_caret(), Some(Align::Right));
16488 assert_eq!(
16489 d.line_spacing_at_caret(),
16490 Some(LineHeight::Step(LineSpacing::OneHalf))
16491 );
16492
16493 // And the other way round: the spacing gesture leaves the classes be.
16494 d.set_line_spacing(Some(LineHeight::Step(LineSpacing::Double)));
16495 assert!(d.source.contains(".lead"), "{:?}", d.source);
16496 assert!(d.source.contains(".right"), "{:?}", d.source);
16497 assert_eq!(
16498 d.line_spacing_at_caret(),
16499 Some(LineHeight::Step(LineSpacing::Double))
16500 );
16501 }
16502
16503 /// Clearing is the same gesture with `None`: the key goes, the tokens leaf
16504 /// owns go out of `class`, and a block left with nothing at all is spelled
16505 /// bare again — in Markdown by unwrapping the div twig wrapped it in.
16506 #[test]
16507 fn none_clears_a_key_and_an_empty_set_unwraps_the_block() {
16508 let mut dj = fmt_doc("{.lead .center}\nhello\n", Format::Djot);
16509 dj.caret = dj.source.find("hello").unwrap();
16510 dj.set_alignment(None);
16511 assert_eq!(dj.source, "{.lead}\nhello\n", "the foreign class stays");
16512 assert_eq!(dj.alignment_at_caret(), None);
16513
16514 let mut bare = fmt_doc("{.center}\nhello\n", Format::Djot);
16515 bare.caret = bare.source.find("hello").unwrap();
16516 bare.set_alignment(None);
16517 assert_eq!(
16518 bare.source, "hello\n",
16519 "the last key takes the line with it"
16520 );
16521
16522 let mut md = fmt_doc("hello\n", Format::Markdown);
16523 md.caret = 1;
16524 md.set_alignment(Some(Align::Center));
16525 assert_eq!(md.source, "<div class=\"center\">\n\nhello\n\n</div>\n");
16526 md.caret = md.source.find("hello").unwrap();
16527 md.set_line_spacing(Some(LineHeight::Step(LineSpacing::OneFifteen)));
16528 assert_eq!(
16529 md.source, "<div class=\"center\" data-line-height=\"1.15\">\n\nhello\n\n</div>\n",
16530 "the second key rewrites the div rather than nesting a second"
16531 );
16532 md.caret = md.source.find("hello").unwrap();
16533 md.set_alignment(None);
16534 md.caret = md.source.find("hello").unwrap();
16535 md.set_line_spacing(None);
16536 assert_eq!(md.source, "hello\n", "an empty set unwraps the div");
16537 }
16538
16539 /// Size, face and colour are the run's over a selection and the block's
16540 /// with none — so "make this paragraph larger" is a click with the caret in
16541 /// it rather than a select-all first.
16542 #[test]
16543 fn a_run_gesture_wraps_a_selection_and_sets_the_block_without_one() {
16544 // With a selection: a span, in each format's own spelling.
16545 let mut dj = fmt_doc("a big b\n", Format::Djot);
16546 dj.anchor = Some(2);
16547 dj.caret = 5;
16548 dj.set_font_size(Some(FontSize::Step(SizeStep::Large)));
16549 assert_eq!(dj.source, "a [big]{data-size=\"large\"} b\n");
16550 assert_eq!(
16551 dj.font_size_at_caret(),
16552 Some(FontSize::Step(SizeStep::Large))
16553 );
16554
16555 let mut md = fmt_doc("a big b\n", Format::Markdown);
16556 md.anchor = Some(2);
16557 md.caret = 5;
16558 md.set_text_color(Some(TextColor::Named(MarkColor::Blue)));
16559 assert_eq!(md.source, "a <span data-color=\"blue\">big</span> b\n");
16560 assert_eq!(
16561 md.text_color_at_caret(),
16562 Some(TextColor::Named(MarkColor::Blue))
16563 );
16564
16565 // Without one: the caret's block, through the block gesture.
16566 let mut block = fmt_doc("a big b\n", Format::Djot);
16567 block.caret = 3;
16568 block.set_font_family(Some(FontFace::Generic(FontFamily::Monospace)));
16569 assert_eq!(block.source, "{data-font=\"monospace\"}\na big b\n");
16570 assert_eq!(
16571 block.font_family_at_caret(),
16572 Some(FontFace::Generic(FontFamily::Monospace))
16573 );
16574 }
16575
16576 /// The *Other…* row of each of the four menus: a value goes into the
16577 /// document in its canonical spelling and comes back out of the query as
16578 /// the same value. One round trip per property, because the four go out
16579 /// through different doors — two block gestures, and the run three through
16580 /// the span that `wrap_range_attrs` mints.
16581 #[test]
16582 fn an_exact_value_round_trips_through_the_gesture_and_the_query() {
16583 // Size: the run three, over a selection.
16584 let mut d = fmt_doc("a big b\n", Format::Djot);
16585 d.anchor = Some(2);
16586 d.caret = 5;
16587 d.set_font_size(FontSize::points(14.0));
16588 assert_eq!(d.source, "a [big]{data-size=\"14pt\"} b\n");
16589 assert_eq!(d.font_size_at_caret(), FontSize::points(14.0));
16590
16591 // Colour, onto the same span — the gesture keeps the size it finds.
16592 d.set_text_color(Some(TextColor::Rgb {
16593 r: 0xc0,
16594 g: 0x30,
16595 b: 0x30,
16596 }));
16597 assert_eq!(
16598 d.source,
16599 "a [big]{data-size=\"14pt\" data-color=\"#c03030\"} b\n"
16600 );
16601 assert_eq!(
16602 d.text_color_at_caret(),
16603 Some(TextColor::Rgb {
16604 r: 0xc0,
16605 g: 0x30,
16606 b: 0x30
16607 })
16608 );
16609
16610 // Face: a family name, as given.
16611 d.set_font_family(Some(FontFace::Named("Garamond".into())));
16612 assert!(
16613 d.source.contains("data-font=\"Garamond\""),
16614 "{:?}",
16615 d.source
16616 );
16617 assert_eq!(
16618 d.font_family_at_caret(),
16619 Some(FontFace::Named("Garamond".into()))
16620 );
16621
16622 // Line spacing: a block gesture, and an exact ratio.
16623 let mut block = fmt_doc("hello\n", Format::Djot);
16624 block.caret = 1;
16625 block.set_line_spacing(LineHeight::ratio(1.3));
16626 assert_eq!(block.source, "{data-line-height=\"1.3\"}\nhello\n");
16627 assert_eq!(block.line_spacing_at_caret(), LineHeight::ratio(1.3));
16628
16629 // And a value spelled long is written back short, so the same press
16630 // twice writes the same bytes: `14.0pt` in, `14pt` out.
16631 let mut long = fmt_doc("{data-size=\"14.0pt\"}\nhello\n", Format::Djot);
16632 long.caret = long.source.find("hello").unwrap();
16633 assert_eq!(long.font_size_at_caret(), FontSize::points(14.0));
16634 let in_force = long.font_size_at_caret();
16635 long.set_font_size(in_force);
16636 assert_eq!(long.source, "{data-size=\"14pt\"}\nhello\n");
16637 }
16638
16639 /// A value the grammar does not cover is what it was before the vocabulary
16640 /// opened: carried untouched by the document, answered `None` by the query
16641 /// so the menu ticks *Default*, and rewritten only by a gesture on its own
16642 /// key. leaf is not going to grow a CSS parser to guess at `1.3em`.
16643 #[test]
16644 fn a_value_outside_the_grammar_is_carried_and_the_menu_ticks_the_default() {
16645 let src =
16646 "{data-size=\"huge\" data-color=\"rgb(1,2,3)\" data-line-height=\"1.3em\"}\nhello\n";
16647 let mut d = fmt_doc(src, Format::Djot);
16648 d.caret = d.source.find("hello").unwrap();
16649 assert_eq!(d.font_size_at_caret(), None);
16650 assert_eq!(d.text_color_at_caret(), None);
16651 assert_eq!(d.line_spacing_at_caret(), None);
16652
16653 // The keys are still there, untouched, after a gesture on a *different*
16654 // key — "edit one key and keep the rest" holds for a value it cannot
16655 // read as readily as for one it can.
16656 d.set_alignment(Some(Align::Center));
16657 assert!(d.source.contains("data-size=\"huge\""), "{:?}", d.source);
16658 assert!(
16659 d.source.contains("data-color=\"rgb(1,2,3)\""),
16660 "{:?}",
16661 d.source
16662 );
16663 assert!(
16664 d.source.contains("data-line-height=\"1.3em\""),
16665 "{:?}",
16666 d.source
16667 );
16668 // And the gesture on its *own* key replaces it, which is the one way a
16669 // carried value ever changes.
16670 d.caret = d.source.find("hello").unwrap();
16671 d.set_font_size(FontSize::points(12.0));
16672 assert!(d.source.contains("data-size=\"12pt\""), "{:?}", d.source);
16673 assert!(!d.source.contains("huge"), "{:?}", d.source);
16674 }
16675
16676 /// The nearest node wins whichever *form* either node wrote: a value inside
16677 /// a name, a name inside a value. The fold has one rule and does not learn
16678 /// a second one for exact values.
16679 #[test]
16680 fn the_nearest_node_wins_whether_it_named_a_size_or_measured_one() {
16681 // A value inside a name: the block says `small`, the span says `14pt`.
16682 let mut d = fmt_doc(
16683 "{data-size=\"small\"}\nx [y]{data-size=\"14pt\"} z\n",
16684 Format::Djot,
16685 );
16686 d.caret = d.source.find('y').unwrap();
16687 assert_eq!(d.font_size_at_caret(), FontSize::points(14.0));
16688 d.caret = d.source.find('x').unwrap();
16689 assert_eq!(
16690 d.font_size_at_caret(),
16691 Some(FontSize::Step(SizeStep::Small))
16692 );
16693
16694 // And a name inside a value, which is the same rule read the other way.
16695 let mut e = fmt_doc(
16696 "{data-size=\"14pt\" data-color=\"#c03030\"}\nx [y]{data-size=\"small\"} z\n",
16697 Format::Djot,
16698 );
16699 e.caret = e.source.find('y').unwrap();
16700 assert_eq!(
16701 e.font_size_at_caret(),
16702 Some(FontSize::Step(SizeStep::Small))
16703 );
16704 assert_eq!(
16705 e.text_color_at_caret(),
16706 Some(TextColor::Rgb {
16707 r: 0xc0,
16708 g: 0x30,
16709 b: 0x30
16710 }),
16711 "the block's colour still reaches the span"
16712 );
16713 e.caret = e.source.find('x').unwrap();
16714 assert_eq!(e.font_size_at_caret(), FontSize::points(14.0));
16715 }
16716
16717 /// twig re-styles the span a range already lies in rather than nesting a
16718 /// second, and an empty set unwraps it — so a second press of the menu
16719 /// fixes the size instead of building `[[big]{.a}]{.b}`, and the entry that
16720 /// means "the theme's own" takes the span away.
16721 #[test]
16722 fn a_second_run_gesture_re_styles_the_span_and_none_unwraps_it() {
16723 let mut d = fmt_doc("a big b\n", Format::Djot);
16724 d.anchor = Some(2);
16725 d.caret = 5;
16726 d.set_font_size(Some(FontSize::Step(SizeStep::Large)));
16727 assert_eq!(d.source, "a [big]{data-size=\"large\"} b\n");
16728
16729 // The selection `wrap_range_attrs` left behind covers the whole span;
16730 // colouring it now keeps the size, because the gesture reads the span's
16731 // attributes before it edits its own key.
16732 d.set_text_color(Some(TextColor::Named(MarkColor::Red)));
16733 assert_eq!(
16734 d.source, "a [big]{data-size=\"large\" data-color=\"red\"} b\n",
16735 "one span, both keys"
16736 );
16737 assert_eq!(
16738 d.font_size_at_caret(),
16739 Some(FontSize::Step(SizeStep::Large))
16740 );
16741 assert_eq!(
16742 d.text_color_at_caret(),
16743 Some(TextColor::Named(MarkColor::Red))
16744 );
16745
16746 d.set_text_color(None);
16747 assert_eq!(d.source, "a [big]{data-size=\"large\"} b\n");
16748 d.set_font_size(None);
16749 assert_eq!(d.source, "a big b\n", "the last key unwraps the span");
16750 assert_eq!(d.font_size_at_caret(), None);
16751 }
16752
16753 /// The queries read the nearest node that names the property: the span the
16754 /// caret is in, then its block, then the `div`s around it.
16755 #[test]
16756 fn a_presentation_query_reads_the_nearest_node_that_names_it() {
16757 let mut d = fmt_doc(
16758 "{.center data-size=\"small\" data-font=\"serif\"}\nx [y]{data-size=\"xx-large\"} z\n",
16759 Format::Djot,
16760 );
16761 // In the span: its own size, the block's face and alignment.
16762 d.caret = d.source.find('y').unwrap();
16763 assert_eq!(
16764 d.font_size_at_caret(),
16765 Some(FontSize::Step(SizeStep::XxLarge))
16766 );
16767 assert_eq!(
16768 d.font_family_at_caret(),
16769 Some(FontFace::Generic(FontFamily::Serif))
16770 );
16771 assert_eq!(d.alignment_at_caret(), Some(Align::Center));
16772 assert_eq!(d.line_spacing_at_caret(), None);
16773 assert_eq!(d.text_color_at_caret(), None);
16774
16775 // Outside it: the block's size.
16776 d.caret = d.source.find('x').unwrap();
16777 assert_eq!(
16778 d.font_size_at_caret(),
16779 Some(FontSize::Step(SizeStep::Small))
16780 );
16781
16782 // And through a Markdown div, which is where a Markdown block's
16783 // attributes live.
16784 let mut md = fmt_doc(
16785 "<div class=\"center\" data-size=\"large\">\n\nhello\n\n</div>\n",
16786 Format::Markdown,
16787 );
16788 md.caret = md.source.find("hello").unwrap();
16789 assert_eq!(md.alignment_at_caret(), Some(Align::Center));
16790 assert_eq!(
16791 md.font_size_at_caret(),
16792 Some(FontSize::Step(SizeStep::Large))
16793 );
16794
16795 // A document that names none of it answers `None` everywhere, which is
16796 // "the theme's own" and what every toolbar draws unlit.
16797 let mut plain = doc_with("plain_presentation", "hello\n");
16798 plain.caret = 1;
16799 assert_eq!(plain.alignment_at_caret(), None);
16800 assert_eq!(plain.line_spacing_at_caret(), None);
16801 assert_eq!(plain.font_size_at_caret(), None);
16802 assert_eq!(plain.font_family_at_caret(), None);
16803 assert_eq!(plain.text_color_at_caret(), None);
16804 }
16805
16806 /// A djot fenced div is anonymous the way an attributed span is, and is a
16807 /// block all the same — the *form* is the whole of what tells them apart.
16808 /// Read as a span it poisoned both halves: the run gesture copied the div's
16809 /// entire attribute set onto the span it minted, duplicating the `id`, and
16810 /// the run and block queries answered off a node the walker draws nothing
16811 /// for.
16812 #[test]
16813 fn a_djot_fenced_div_is_not_an_attributed_span() {
16814 let src = "{.center data-size=\"small\" #box}\n:::\nhello world\n:::\n";
16815 let mut d = fmt_doc(src, Format::Djot);
16816 let at = d.source.find("world").unwrap();
16817 d.anchor = Some(at);
16818 d.caret = at + "world".len();
16819 d.set_text_color(Some(TextColor::Named(MarkColor::Red)));
16820 assert_eq!(
16821 d.source,
16822 "{.center data-size=\"small\" #box}\n:::\nhello [world]{data-color=\"red\"}\n:::\n",
16823 "the span carries its own key and nothing of the div's"
16824 );
16825
16826 // And the queries stop at the block: a djot div is not a `<div>`, the
16827 // walker lends its keys to nothing inside it, and a query that said
16828 // otherwise would tick a menu entry no glyph on screen obeys.
16829 assert_eq!(
16830 d.text_color_at_caret(),
16831 Some(TextColor::Named(MarkColor::Red))
16832 );
16833 assert_eq!(d.font_size_at_caret(), None);
16834 assert_eq!(d.alignment_at_caret(), None);
16835 }
16836
16837 /// Clearing a property the block does not name and a `div` around it does
16838 /// would write nothing and change nothing — twig's `set_block_attrs`
16839 /// reaches one node, and the div is not it. The gesture says so instead of
16840 /// leaving the author pressing an entry that never ticks.
16841 #[test]
16842 fn clearing_a_property_an_enclosing_div_names_says_so_and_writes_nothing() {
16843 // Markdown, two paragraphs in one div: not the sole-child shape twig
16844 // writes, so `block_attrs_at_caret` reads the paragraph and the
16845 // paragraph names none of it.
16846 let src = "<div class=\"center\" data-line-height=\"1.5\" data-size=\"large\">\n\nhello\n\nworld\n\n</div>\n";
16847 let mut md = fmt_doc(src, Format::Markdown);
16848 md.caret = md.source.find("hello").unwrap();
16849 assert_eq!(md.alignment_at_caret(), Some(Align::Center));
16850
16851 md.set_alignment(None);
16852 assert_eq!(md.source, src, "nothing written");
16853 assert!(!md.dirty);
16854 assert_eq!(
16855 md.status.as_deref(),
16856 Some("alignment: set on the div around the block")
16857 );
16858 assert_eq!(md.alignment_at_caret(), Some(Align::Center));
16859
16860 // The same for a `data-` key, at both levels — the block pair and the
16861 // run three, the run three at a bare caret being the block gesture.
16862 md.set_line_spacing(None);
16863 assert_eq!(md.source, src);
16864 assert_eq!(
16865 md.status.as_deref(),
16866 Some("line spacing: set on the div around the block")
16867 );
16868 md.set_font_size(None);
16869 assert_eq!(md.source, src);
16870 assert_eq!(
16871 md.status.as_deref(),
16872 Some("size: set on the div around the block")
16873 );
16874
16875 // HTML has no sole-child fold at all: a block's attributes go on the
16876 // block, so the div around one is always out of reach.
16877 let html_src = "<div class=\"center\"><p>hi</p></div>\n";
16878 let mut html = fmt_doc(html_src, Format::Html);
16879 html.caret = html.source.find("hi").unwrap();
16880 assert_eq!(html.alignment_at_caret(), Some(Align::Center));
16881 html.set_alignment(None);
16882 assert_eq!(html.source, html_src);
16883 assert!(!html.dirty);
16884 assert_eq!(
16885 html.status.as_deref(),
16886 Some("alignment: set on the div around the block")
16887 );
16888
16889 // And it is a refusal, not a rule against clearing: a block that names
16890 // the property itself still loses it, div or no div.
16891 let mut own = fmt_doc(
16892 "<div class=\"center\"><p class=\"right\">hi</p></div>\n",
16893 Format::Html,
16894 );
16895 own.caret = own.source.find("hi").unwrap();
16896 own.set_alignment(None);
16897 assert_eq!(own.source, "<div class=\"center\"><p>hi</p></div>\n");
16898 assert_eq!(own.status, None);
16899 }
16900
16901 /// An edited key is rewritten **where it stands**. The proposal's worked
16902 /// example is the test: a paragraph that came in as `id="intro"
16903 /// class="lead center" data-line-height="1.5"` and is right-aligned goes
16904 /// out as the same list with one token changed. Removing the key and
16905 /// pushing it back shuffled a document's attributes on every press.
16906 #[test]
16907 fn an_edited_key_keeps_its_place_among_the_attributes() {
16908 let mut html = fmt_doc(
16909 "<p id=\"intro\" class=\"lead center\" data-line-height=\"1.5\">hello</p>\n",
16910 Format::Html,
16911 );
16912 html.caret = html.source.find("hello").unwrap();
16913 html.set_alignment(Some(Align::Right));
16914 assert_eq!(
16915 html.source,
16916 "<p id=\"intro\" class=\"lead right\" data-line-height=\"1.5\">hello</p>\n"
16917 );
16918
16919 // A `data-` key the same way, and a key the block did not have still
16920 // goes on the end.
16921 html.caret = html.source.find("hello").unwrap();
16922 html.set_line_spacing(Some(LineHeight::Step(LineSpacing::Double)));
16923 assert_eq!(
16924 html.source,
16925 "<p id=\"intro\" class=\"lead right\" data-line-height=\"2\">hello</p>\n"
16926 );
16927 html.caret = html.source.find("hello").unwrap();
16928 html.set_font_size(Some(FontSize::Step(SizeStep::Large)));
16929 assert_eq!(
16930 html.source,
16931 "<p id=\"intro\" class=\"lead right\" data-line-height=\"2\" data-size=\"large\">hello</p>\n"
16932 );
16933
16934 // Djot writes the same list in its own spelling, and the order is the
16935 // author's there too.
16936 let mut dj = fmt_doc(
16937 "{#intro .lead .center data-line-height=\"1.5\"}\nhello\n",
16938 Format::Djot,
16939 );
16940 dj.caret = dj.source.find("hello").unwrap();
16941 dj.set_alignment(Some(Align::Right));
16942 assert_eq!(
16943 dj.source,
16944 "{#intro .lead .right data-line-height=\"1.5\"}\nhello\n"
16945 );
16946 }
16947
16948 /// A page break is a block, so twig alone lands one after the caret's whole
16949 /// block; the paragraph is parted at the caret first, exactly as
16950 /// `insert_thematic_break` parts it, and each format spells the directive
16951 /// its own way.
16952 #[test]
16953 fn insert_page_break_parts_the_paragraph_and_spells_the_directive() {
16954 let mut md = doc_with("page_break_md", "hello world\n");
16955 md.caret = 5;
16956 md.insert_page_break();
16957 assert_eq!(md.source, "hello\n\n::page-break\n\nworld\n");
16958 assert!(md.dirty);
16959 assert_eq!(md.status, None);
16960
16961 let mut dj = fmt_doc("hello world\n", Format::Djot);
16962 dj.caret = 5;
16963 dj.insert_page_break();
16964 assert_eq!(dj.source, "hello\n\n::: page-break\n:::\n\nworld\n");
16965
16966 // At a block's end there is no second half to mint, so the break simply
16967 // follows the block — the rule the rule button already has.
16968 let mut end = doc_with("page_break_end", "hello\n");
16969 end.caret = 5;
16970 end.insert_page_break();
16971 assert_eq!(end.source, "hello\n\n::page-break\n");
16972
16973 // And it reaches the map as the placeholder row a frontend paginates on.
16974 end.view = View::Wysiwyg;
16975 end.build_visual(80);
16976 assert_eq!(
16977 end.vmap
16978 .rows
16979 .iter()
16980 .find_map(|r| r.leaf_directive.as_ref())
16981 .map(|m| m.name.as_str()),
16982 Some(PAGE_BREAK)
16983 );
16984 }
16985
16986 /// The vocabulary's capabilities, per format. The two block properties are
16987 /// `SetBlockAttrs` and the three run ones `WrapRangeAttrs`, which is why
16988 /// AsciiDoc can align a paragraph and not size a run: its `[#id.role]#text#`
16989 /// keeps an id and a role and has no slot for a `data-` key.
16990 #[test]
16991 fn the_presentation_capabilities_are_ragged_per_format() {
16992 for fmt in [Format::Markdown, Format::Djot, Format::Html] {
16993 let c = Capabilities::of(fmt);
16994 assert!(c.alignment, "{fmt:?} alignment");
16995 assert!(c.line_spacing, "{fmt:?} line spacing");
16996 assert!(c.font_size, "{fmt:?} size");
16997 assert!(c.font_family, "{fmt:?} face");
16998 assert!(c.text_color, "{fmt:?} colour");
16999 }
17000 // Markdown spells both only under the extensions leaf parses with — a
17001 // `<div>` and a `<span>` read back as containers under `html_elements`,
17002 // and `::page-break` as a directive under `directives`. Ask twig's own
17003 // defaults and the answer is no, which is why `Capabilities` is built
17004 // with `supports_with`.
17005 assert!(!Format::Markdown.supports(Gesture::SetBlockAttrs));
17006 assert!(!Format::Markdown.supports(Gesture::WrapRangeAttrs));
17007 assert!(!Format::Markdown.supports(Gesture::InsertDirective));
17008
17009 let adoc = Capabilities::of(Format::Asciidoc);
17010 assert!(adoc.alignment && adoc.line_spacing, "AsciiDoc's `[…]` line");
17011 assert!(
17012 !adoc.font_size && !adoc.font_family && !adoc.text_color,
17013 "AsciiDoc has no inline spelling that keeps a data- key"
17014 );
17015
17016 // XML spells none of it, and neither page break.
17017 let xml = Capabilities::of(Format::Xml);
17018 assert!(!xml.alignment && !xml.font_size && !xml.page_break);
17019 assert!(Capabilities::of(Format::Markdown).page_break);
17020 assert!(Capabilities::of(Format::Djot).page_break);
17021
17022 // And those two *only*, though twig spells the gesture in HTML and
17023 // AsciiDoc as well: it spells it differently there —
17024 // `<page-break></page-break>` and `<<<` — and the walker reads neither,
17025 // so the button would write a break that draws as nothing at all in
17026 // HTML and as an empty unlabelled row in AsciiDoc. The flag describes
17027 // what leaf can show, not what twig can write. See
17028 // `docs/tasks/page-break-in-html-and-asciidoc.md`.
17029 let exts = parse_extensions();
17030 assert!(Format::Html.supports_with(exts, Gesture::InsertDirective));
17031 assert!(Format::Asciidoc.supports_with(exts, Gesture::InsertDirective));
17032 assert!(!Capabilities::of(Format::Html).page_break);
17033 assert!(!Capabilities::of(Format::Asciidoc).page_break);
17034 }
17035
17036 /// A format that cannot spell a property refuses in its own words and
17037 /// writes nothing — the guard every other gesture has.
17038 #[test]
17039 fn a_presentation_gesture_a_format_cannot_spell_is_refused_with_a_reason() {
17040 let src = "<doc><p>hello</p></doc>\n";
17041 #[allow(clippy::type_complexity)]
17042 let ops: [(&str, &dyn Fn(&mut Doc)); 6] = [
17043 ("alignment", &|d: &mut Doc| {
17044 d.set_alignment(Some(Align::Center))
17045 }),
17046 ("line spacing", &|d: &mut Doc| {
17047 d.set_line_spacing(Some(LineHeight::Step(LineSpacing::Double)))
17048 }),
17049 ("size", &|d: &mut Doc| {
17050 d.set_font_size(Some(FontSize::Step(SizeStep::Large)))
17051 }),
17052 ("face", &|d: &mut Doc| {
17053 d.set_font_family(Some(FontFace::Generic(FontFamily::Serif)))
17054 }),
17055 ("colour", &|d: &mut Doc| {
17056 d.set_text_color(Some(TextColor::Named(MarkColor::Red)))
17057 }),
17058 ("page break", &|d: &mut Doc| d.insert_page_break()),
17059 ];
17060 for (name, op) in ops {
17061 let mut d = fmt_doc(src, Format::Xml);
17062 let at = d.source.find("hello").unwrap();
17063 d.caret = at;
17064 d.anchor = Some(at + 5);
17065 op(&mut d);
17066 assert_eq!(d.source, src, "{name} edited an XML document");
17067 assert!(!d.dirty, "{name} marked the document dirty");
17068 let status = d.status.as_deref().unwrap_or("");
17069 assert!(
17070 status.contains("xml"),
17071 "{name}: the refusal should name the format, got {status:?}"
17072 );
17073 }
17074
17075 // AsciiDoc is the ragged one: the block gesture works where the run
17076 // gesture does not, and a *selection* is what tells the two apart.
17077 let mut adoc = fmt_doc("hello world\n", Format::Asciidoc);
17078 adoc.anchor = Some(0);
17079 adoc.caret = 5;
17080 adoc.set_font_size(Some(FontSize::Step(SizeStep::Large)));
17081 assert_eq!(adoc.source, "hello world\n", "no inline spelling");
17082 assert!(adoc.status.is_some());
17083 }
17084
17085 /// A read-only document takes none of it, and a caret on a blank line has
17086 /// no block to carry an attribute — both say so rather than writing.
17087 #[test]
17088 fn a_presentation_gesture_respects_read_only_and_a_blank_line() {
17089 let mut ro = fmt_doc("hello\n", Format::Djot);
17090 ro.read_only = true;
17091 ro.caret = 1;
17092 ro.set_alignment(Some(Align::Center));
17093 assert_eq!(ro.source, "hello\n");
17094
17095 let mut blank = fmt_doc("a\n\n\nb\n", Format::Djot);
17096 blank.caret = 2; // the empty line between the two paragraphs
17097 blank.set_alignment(Some(Align::Center));
17098 assert_eq!(blank.source, "a\n\n\nb\n");
17099 assert!(
17100 blank.status.as_deref().unwrap_or("").contains("no block"),
17101 "got {:?}",
17102 blank.status
17103 );
17104 }
17105
17106 /// A block attribute gesture keeps the caret on the **text** it was on, not
17107 /// on the byte offset it had. Markdown has nowhere to put a paragraph's
17108 /// attributes but a `<div>` around it, and twig splices the div and the
17109 /// block it wraps as one region — so a caret that kept its offset landed in
17110 /// the markup, and every press after the first answered "no block at the
17111 /// caret" with the toolbar's queries reading nothing.
17112 #[test]
17113 fn a_markdown_block_gesture_keeps_the_caret_on_its_text() {
17114 let word = |d: &Doc| d.caret - d.source.find("brown").unwrap();
17115 let mut md = fmt_doc("the quick brown fox\n", Format::Markdown);
17116 md.caret = md.source.find("brown").unwrap() + 2; // "br|own"
17117
17118 // Wrapping: the div and two blank lines open above the block.
17119 md.set_alignment(Some(Align::Center));
17120 assert_eq!(
17121 md.source,
17122 "<div class=\"center\">\n\nthe quick brown fox\n\n</div>\n"
17123 );
17124 assert_eq!(word(&md), 2, "the caret left its word: {}", md.caret);
17125 assert_eq!(md.alignment_at_caret(), Some(Align::Center));
17126
17127 // Re-styling: the attribute line changes length under the same caret,
17128 // and the second press reaches the same block rather than nothing.
17129 md.set_alignment(Some(Align::Right));
17130 assert_eq!(
17131 md.source, "<div class=\"right\">\n\nthe quick brown fox\n\n</div>\n",
17132 "a second press re-styles the div"
17133 );
17134 assert_eq!(md.status, None);
17135 assert_eq!(word(&md), 2);
17136
17137 // A second key on the same div — the line grows, the caret rides it.
17138 md.set_line_spacing(Some(LineHeight::Step(LineSpacing::Double)));
17139 assert_eq!(
17140 md.source,
17141 "<div class=\"right\" data-line-height=\"2\">\n\nthe quick brown fox\n\n</div>\n"
17142 );
17143 assert_eq!(word(&md), 2);
17144 assert_eq!(
17145 md.line_spacing_at_caret(),
17146 Some(LineHeight::Step(LineSpacing::Double))
17147 );
17148
17149 // Unwrapping: the line shrinks, and then the div goes altogether.
17150 md.set_alignment(None);
17151 assert_eq!(
17152 md.source,
17153 "<div data-line-height=\"2\">\n\nthe quick brown fox\n\n</div>\n"
17154 );
17155 assert_eq!(word(&md), 2);
17156 md.set_line_spacing(None);
17157 assert_eq!(md.source, "the quick brown fox\n", "the last key unwraps");
17158 assert_eq!(word(&md), 2, "the caret came back down with the block");
17159 assert_eq!(md.alignment_at_caret(), None);
17160 assert_eq!(md.status, None);
17161 }
17162
17163 /// The same rule in djot, where the spelling is a `{…}` line *above* the
17164 /// block rather than a wrapper around it: inserting it pushes the block
17165 /// down, re-styling it changes the line's length, and clearing the last key
17166 /// takes the line away again. The caret rides all three.
17167 #[test]
17168 fn a_djot_attribute_line_keeps_the_caret_on_its_text() {
17169 let word = |d: &Doc| d.caret - d.source.find("brown").unwrap();
17170 let mut dj = fmt_doc("the quick brown fox\n", Format::Djot);
17171 dj.caret = dj.source.find("brown").unwrap() + 2;
17172
17173 dj.set_alignment(Some(Align::Center));
17174 assert_eq!(dj.source, "{.center}\nthe quick brown fox\n");
17175 assert_eq!(word(&dj), 2);
17176 assert_eq!(dj.alignment_at_caret(), Some(Align::Center));
17177
17178 dj.set_line_spacing(Some(LineHeight::Step(LineSpacing::Double)));
17179 assert_eq!(
17180 dj.source, "{.center data-line-height=\"2\"}\nthe quick brown fox\n",
17181 "a second press edits the line the first wrote"
17182 );
17183 assert_eq!(word(&dj), 2);
17184
17185 dj.set_alignment(None);
17186 assert_eq!(dj.source, "{data-line-height=\"2\"}\nthe quick brown fox\n");
17187 assert_eq!(word(&dj), 2);
17188
17189 dj.set_line_spacing(None);
17190 assert_eq!(dj.source, "the quick brown fox\n");
17191 assert_eq!(word(&dj), 2);
17192 assert_eq!(dj.status, None);
17193 }
17194
17195 /// The run gestures with no selection are the block gesture, so they keep
17196 /// the caret the same way — and a heading keeps it inside the heading's own
17197 /// text, past the `# ` its content span starts after. A selection rides
17198 /// along whole: a block gesture is not a run gesture, and what was selected
17199 /// before the press is still selected after it.
17200 #[test]
17201 fn a_block_gesture_carries_a_selection_and_a_heading_caret_too() {
17202 // No selection: the run gesture goes through the block door.
17203 let mut md = fmt_doc("the quick brown fox\n", Format::Markdown);
17204 md.caret = md.source.find("brown").unwrap() + 2;
17205 md.set_font_size(Some(FontSize::Step(SizeStep::Large)));
17206 assert_eq!(
17207 md.source,
17208 "<div data-size=\"large\">\n\nthe quick brown fox\n\n</div>\n"
17209 );
17210 assert_eq!(md.caret - md.source.find("brown").unwrap(), 2);
17211 assert_eq!(
17212 md.font_size_at_caret(),
17213 Some(FontSize::Step(SizeStep::Large))
17214 );
17215 md.set_font_size(Some(FontSize::Step(SizeStep::Small)));
17216 assert_eq!(
17217 md.font_size_at_caret(),
17218 Some(FontSize::Step(SizeStep::Small)),
17219 "the second press reached the same block"
17220 );
17221
17222 // A selection: alignment is the block's whatever is selected, and the
17223 // words stay selected.
17224 let mut sel = fmt_doc("the quick brown fox\n", Format::Markdown);
17225 let at = sel.source.find("brown").unwrap();
17226 sel.anchor = Some(at);
17227 sel.caret = at + 5;
17228 sel.set_alignment(Some(Align::Center));
17229 let now = sel.source.find("brown").unwrap();
17230 assert_eq!(sel.selection(), Some((now, now + 5)), "the words moved out");
17231
17232 // A heading: the content span starts past the `# `.
17233 let mut h = fmt_doc("# hi there\n\nbody\n", Format::Markdown);
17234 h.caret = h.source.find("there").unwrap() + 1;
17235 h.set_alignment(Some(Align::Right));
17236 assert_eq!(
17237 h.source,
17238 "<div class=\"right\">\n\n# hi there\n\n</div>\n\nbody\n"
17239 );
17240 assert_eq!(h.caret, h.source.find("there").unwrap() + 1);
17241 assert_eq!(h.alignment_at_caret(), Some(Align::Right));
17242 }
17243
17244 /// A djot document open in the rich view, with its map built as
17245 /// [`wysiwyg_doc`] builds a Markdown one's.
17246 fn wysiwyg_djot(body: &str) -> Doc {
17247 let mut d = fmt_doc(body, Format::Djot);
17248 d.view = View::Wysiwyg;
17249 d.build_visual(80);
17250 d
17251 }
17252
17253 /// Backspace at the start of a block whose presentation is spelled as
17254 /// hidden markup before it strips that presentation, the way Backspace at
17255 /// a heading's start strips its `#`. The ordinary delete fused djot's
17256 /// `{.center}` line onto the text and took the blank line a Markdown div
17257 /// needs between its tag and its paragraph.
17258 #[test]
17259 fn backspace_at_the_start_of_a_centred_paragraph_strips_its_attributes() {
17260 let mut md = wysiwyg_doc(
17261 "wys_attr_bksp",
17262 "above\n\n<div class=\"center\">\n\nhello\n\n</div>\n\nbelow\n",
17263 );
17264 md.caret = md.source.find("hello").unwrap();
17265 md.backspace();
17266 assert_eq!(
17267 md.source, "above\n\nhello\n\nbelow\n",
17268 "the div is unwrapped"
17269 );
17270 assert_eq!(md.caret, 7, "the caret stays at the start of its text");
17271 md.backspace();
17272 assert_eq!(
17273 md.source, "above\nhello\n\nbelow\n",
17274 "the next press joins the paragraphs, as it always did"
17275 );
17276
17277 let mut dj = wysiwyg_djot("above\n\n{.center}\nhello\n\nbelow\n");
17278 dj.caret = dj.source.find("hello").unwrap();
17279 dj.backspace();
17280 assert_eq!(
17281 dj.source, "above\n\nhello\n\nbelow\n",
17282 "the attribute line goes"
17283 );
17284 assert_eq!(dj.caret, 7);
17285
17286 // A heading's own marker is the nearer hidden markup, and goes first;
17287 // the attributes are the next press's.
17288 let mut dj = wysiwyg_djot("{.center}\n# Title\n");
17289 dj.caret = dj.source.find("Title").unwrap();
17290 dj.backspace();
17291 assert_eq!(dj.source, "{.center}\nTitle\n", "the `#` first");
17292 dj.build_visual(80);
17293 dj.backspace();
17294 assert_eq!(dj.source, "Title\n", "then the attributes");
17295 assert_eq!(dj.caret, 0);
17296 }
17297
17298 /// A div around several blocks has no sole child for twig to unwrap, so
17299 /// at its first block the caret steps back to the stop before rather than
17300 /// taking the div apart; a later block has an ordinary paragraph above it
17301 /// and joins as any paragraph does.
17302 #[test]
17303 fn backspace_at_the_first_of_a_div_s_blocks_steps_back_and_a_later_one_joins() {
17304 let src = "above\n\n<div class=\"center\">\n\nhello\n\nworld\n\n</div>\n";
17305 let mut d = wysiwyg_doc("wys_div_first", src);
17306 d.caret = d.source.find("hello").unwrap();
17307 d.backspace();
17308 assert_eq!(d.source, src, "nothing is deleted");
17309 assert_eq!(d.caret, 5, "the caret steps back to the end of `above`");
17310
17311 let mut d = wysiwyg_doc("wys_div_later", src);
17312 d.caret = d.source.find("world").unwrap();
17313 d.backspace();
17314 assert_eq!(
17315 d.source, "above\n\n<div class=\"center\">\n\nhello\nworld\n\n</div>\n",
17316 "a later block joins the one above it"
17317 );
17318 }
17319
17320 /// Backspace at the start of the paragraph after a Markdown div joins it
17321 /// into the div's last paragraph — the join any two paragraphs make, with
17322 /// the hidden `</div>` carried past the joined text. The ordinary delete
17323 /// took the newline under the tag, which drew nothing different, and the
17324 /// next press took the `>` and left the div unclosed.
17325 #[test]
17326 fn backspace_after_a_div_joins_the_paragraph_into_it() {
17327 let src = "above\n\n<div class=\"center\">\n\nhello\n\n</div>\n\nbelow\n";
17328 let mut d = wysiwyg_doc("wys_div_join", src);
17329 d.caret = d.source.find("below").unwrap();
17330 d.backspace();
17331 assert_eq!(
17332 d.source,
17333 "above\n\n<div class=\"center\">\n\nhello\nbelow\n\n</div>\n"
17334 );
17335 assert_eq!(
17336 d.caret,
17337 d.source.find("below").unwrap(),
17338 "the caret stays at the start of the joined text"
17339 );
17340 d.build_visual(80);
17341 assert_eq!(
17342 d.alignment_at_caret(),
17343 Some(Align::Center),
17344 "and is centred now"
17345 );
17346 d.undo();
17347 assert_eq!(d.source, src, "one undo step");
17348
17349 // A list closes the div: the paragraph joins the last item's text,
17350 // under the item's continuation indent, inside the div.
17351 let src = "<div class=\"center\">\n\n- item\n\n</div>\n\nbelow\n";
17352 let mut d = wysiwyg_doc("wys_div_list", src);
17353 d.caret = d.source.find("below").unwrap();
17354 d.backspace();
17355 assert_eq!(
17356 d.source, "<div class=\"center\">\n\n- item\n below\n\n</div>\n",
17357 "the paragraph joins the item"
17358 );
17359 assert_eq!(d.caret, d.source.find("below").unwrap());
17360
17361 // And where twig has nothing to join into — a code block above — the
17362 // caret steps back to the stop before, and nothing is deleted.
17363 let src = "```\ncode\n```\n\nbelow\n";
17364 let mut d = wysiwyg_doc("wys_code_then_para", src);
17365 d.caret = d.source.find("below").unwrap();
17366 d.backspace();
17367 assert_eq!(d.source, src, "nothing is deleted");
17368 assert!(
17369 d.caret < d.source.find("below").unwrap(),
17370 "the caret stepped back"
17371 );
17372 }
17373
17374 /// Backspace on a blank line collapses to the stop before it — but not
17375 /// across hidden markup, which that collapse deleted whole: a `</div>`,
17376 /// or a comment between two blocks. There the blank line goes alone, and
17377 /// the caret lands where the collapse would have put it.
17378 #[test]
17379 fn backspace_on_a_blank_line_after_hidden_markup_keeps_the_markup() {
17380 let mut d = wysiwyg_doc(
17381 "wys_div_blank",
17382 "<div class=\"center\">\n\nhello\n\n</div>\n\n\n\nbelow\n",
17383 );
17384 d.caret = d.source.find("below").unwrap() - 2; // the empty paragraph
17385 assert!(
17386 d.vmap.is_stop(d.caret),
17387 "the empty paragraph is a caret home"
17388 );
17389 d.backspace();
17390 assert_eq!(
17391 d.source, "<div class=\"center\">\n\nhello\n\n</div>\n\nbelow\n",
17392 "the blank line goes and the div stays closed"
17393 );
17394 assert_eq!(
17395 d.caret,
17396 d.source.find("hello").unwrap() + 5,
17397 "onto the end of `hello`"
17398 );
17399
17400 let mut d = wysiwyg_doc("wys_comment_blank", "above\n\n<!-- note -->\n\n\n\nbelow\n");
17401 d.caret = d.source.find("below").unwrap() - 2;
17402 assert!(d.vmap.is_stop(d.caret));
17403 d.backspace();
17404 assert_eq!(
17405 d.source, "above\n\n<!-- note -->\n\nbelow\n",
17406 "the comment stays"
17407 );
17408 assert_eq!(d.caret, 5);
17409 }
17410
17411 /// Backspace at the end of an attributed span steps inside its hidden
17412 /// closing tag the way it steps inside a `**`, and takes the span with
17413 /// its last letter. Before, the byte-step took the `>` of `</span>`,
17414 /// which left the paragraph unparseable: it vanished from the rich view,
17415 /// and the Backspace after that joined the next block into the wreck.
17416 #[test]
17417 fn backspace_walks_into_a_sized_span_and_takes_the_emptied_span_with_its_space() {
17418 let src = "above\n\nThis <span data-size=\"x-large\">is</span> a test\n\nTest 2\n";
17419 let mut d = wysiwyg_doc("wys_span_bs", src);
17420 d.caret = d.source.find("a test").unwrap() + 6;
17421 for _ in 0..7 {
17422 d.backspace();
17423 }
17424 assert_eq!(
17425 d.source,
17426 "above\n\nThis <span data-size=\"x-large\">is</span>\n\nTest 2\n"
17427 );
17428 d.backspace();
17429 assert_eq!(
17430 d.source, "above\n\nThis <span data-size=\"x-large\">i</span>\n\nTest 2\n",
17431 "the first Backspace after the tag takes the letter, not the `>`"
17432 );
17433 d.backspace();
17434 assert_eq!(
17435 d.source, "above\n\nThis \n\nTest 2\n",
17436 "the last letter takes the span with it"
17437 );
17438 assert_eq!(d.caret, 12, "the caret is where the letter was");
17439 d.backspace();
17440 assert_eq!(d.source, "above\n\nThis\n\nTest 2\n");
17441 assert_eq!(d.caret, 11);
17442 d.build_visual(80);
17443 assert!(
17444 d.vmap
17445 .rows
17446 .iter()
17447 .any(|r| r.glyphs.iter().map(|g| g.ch).collect::<String>() == "This"),
17448 "the paragraph is still drawn"
17449 );
17450 }
17451
17452 #[test]
17453 fn backspace_walks_into_a_djot_sized_span_too() {
17454 let mut d = wysiwyg_djot("This [is]{data-size=\"x-large\"}\n\nTest 2\n");
17455 d.caret = d.source.find("\n\nTest 2").unwrap();
17456 // The caret home at the paragraph's end is inside the span, before
17457 // its `]`: the map offers no stop after `]{…}`.
17458 d.build_visual(80);
17459 assert_eq!(d.vmap.stop_before(31), Some(8));
17460 d.caret = 8;
17461 d.backspace();
17462 assert_eq!(d.source, "This [i]{data-size=\"x-large\"}\n\nTest 2\n");
17463 d.backspace();
17464 assert_eq!(
17465 d.source, "This \n\nTest 2\n",
17466 "the attribute block outside the span goes with it"
17467 );
17468 d.backspace();
17469 assert_eq!(d.source, "This\n\nTest 2\n");
17470 assert_eq!(d.caret, 4);
17471 }
17472
17473 /// A span that is empty as the file was written has no stop of its own;
17474 /// Backspace reaching it from behind takes it with the character before
17475 /// it, the character the key looked aimed at.
17476 #[test]
17477 fn backspace_over_an_already_empty_span_takes_it_with_the_character_before() {
17478 let src = "This <span data-size=\"x-large\"></span> a test\n";
17479 let mut d = wysiwyg_doc("wys_span_empty", src);
17480 d.caret = d.source.find(" a test").unwrap();
17481 d.backspace();
17482 assert_eq!(d.source, "This a test\n");
17483 assert_eq!(d.caret, 4);
17484 }
17485
17486 /// The mirror: Delete in front of a span's opening tag takes its first
17487 /// letter, and the span with its last.
17488 #[test]
17489 fn delete_walks_into_a_sized_span_and_takes_the_span_with_its_last_letter() {
17490 let src = "This <span data-size=\"x-large\">is</span> a test\n";
17491 let mut d = wysiwyg_doc("wys_span_del", src);
17492 d.caret = 5;
17493 d.delete_forward();
17494 assert_eq!(
17495 d.source,
17496 "This <span data-size=\"x-large\">s</span> a test\n"
17497 );
17498 d.delete_forward();
17499 assert_eq!(d.source, "This a test\n");
17500 assert_eq!(d.caret, 5);
17501 d.delete_forward();
17502 assert_eq!(d.source, "This a test\n");
17503 assert_eq!(d.caret, 5);
17504 }
17505
17506 /// The block version of the span's emptying rule. Centre a one-letter
17507 /// paragraph — Markdown spells that as a `<div>` around it — and
17508 /// Backspace the letter: the div goes with it, leaving a plain blank line
17509 /// the caret is at home on, in the incremental map and the from-scratch
17510 /// one alike. Before, the letter went alone; the emptied div drew a
17511 /// caret home only the stale map had, and the next Backspace collapsed
17512 /// the line and left `<div class="center">\n\n</div>` standing invisibly
17513 /// in the file.
17514 #[test]
17515 fn backspace_that_empties_a_centred_paragraph_takes_its_div_with_the_letter() {
17516 let mut d = wysiwyg_doc("wys_div_empty", "Try the toolbar.\n\nT\n");
17517 d.caret = d.source.len() - 1;
17518 d.set_alignment(Some(Align::Center));
17519 assert_eq!(
17520 d.source,
17521 "Try the toolbar.\n\n<div class=\"center\">\n\nT\n\n</div>\n"
17522 );
17523 d.backspace();
17524 assert_eq!(d.source, "Try the toolbar.\n\n\n");
17525 assert_eq!(d.caret, 18, "on the blank line where the letter was");
17526 // The host rebuilds the map after every key; the spliced map and a
17527 // fresh one both give the line a caret home.
17528 d.build_visual_unwrapped();
17529 assert!(d.vmap.is_stop(18));
17530 wysiwyg::assert_maps_eq(&d.vmap, &reference_map(&d.source), "after the div goes");
17531 d.backspace();
17532 assert_eq!(
17533 d.source, "Try the toolbar.\n",
17534 "then the blank line collapses"
17535 );
17536 assert_eq!(d.caret, 16);
17537
17538 // With a block after the div the blank line keeps a gap each side.
17539 let mut d = wysiwyg_doc(
17540 "wys_div_empty_mid",
17541 "Try the toolbar.\n\n<div class=\"center\">\n\nT\n\n</div>\n\nbelow\n",
17542 );
17543 d.caret = d.source.find("T\n").unwrap() + 1;
17544 d.backspace();
17545 assert_eq!(d.source, "Try the toolbar.\n\n\n\nbelow\n");
17546 assert_eq!(d.caret, 18);
17547 d.build_visual_unwrapped();
17548 assert!(d.vmap.is_stop(18));
17549 wysiwyg::assert_maps_eq(&d.vmap, &reference_map(&d.source), "after the div goes");
17550 }
17551
17552 /// djot spells the same paragraph as a `{.center}` line above it, and
17553 /// twig has no node at all for that line once the paragraph is gone —
17554 /// so the line goes with the letter too.
17555 #[test]
17556 fn backspace_that_empties_a_centred_paragraph_takes_its_djot_attrs_line_too() {
17557 let mut d = fmt_doc("Try the toolbar.\n\nT\n", Format::Djot);
17558 d.build_visual(80);
17559 d.caret = d.source.len() - 1;
17560 d.set_alignment(Some(Align::Center));
17561 assert_eq!(d.source, "Try the toolbar.\n\n{.center}\nT\n");
17562 d.backspace();
17563 assert_eq!(d.source, "Try the toolbar.\n\n\n");
17564 assert_eq!(d.caret, 18);
17565 d.build_visual(80);
17566 assert!(d.vmap.is_stop(18));
17567 }
17568
17569 /// The rule is for a block that would be no block: a div holding more
17570 /// keeps its tags, and an emptied heading is still a heading.
17571 #[test]
17572 fn emptying_a_paragraph_keeps_a_div_that_holds_more_and_a_heading_its_marker() {
17573 let mut d = wysiwyg_doc(
17574 "wys_div_more",
17575 "<div class=\"center\">\n\nText\n\nT\n\n</div>\n",
17576 );
17577 d.caret = d.source.find("T\n").unwrap() + 1;
17578 d.backspace();
17579 assert_eq!(d.source, "<div class=\"center\">\n\nText\n\n\n\n</div>\n");
17580 assert_eq!(d.caret, 28, "the blank line inside the div, as after Enter");
17581
17582 let mut d = wysiwyg_doc(
17583 "wys_div_heading",
17584 "<div class=\"center\">\n\n# T\n\n</div>\n",
17585 );
17586 d.caret = d.source.find("T\n").unwrap() + 1;
17587 d.backspace();
17588 assert_eq!(d.source, "<div class=\"center\">\n\n# \n\n</div>\n");
17589 assert_eq!(d.caret, 24);
17590 d.build_visual(80);
17591 assert!(
17592 d.vmap.is_stop(24),
17593 "the empty heading is still a caret home"
17594 );
17595 }
17596
17597 /// The mirror: Delete in front of the letter takes the div with it.
17598 #[test]
17599 fn delete_that_empties_a_centred_paragraph_takes_its_div_with_the_letter() {
17600 let mut d = wysiwyg_doc(
17601 "wys_div_empty_del",
17602 "Try the toolbar.\n\n<div class=\"center\">\n\nT\n\n</div>\n",
17603 );
17604 d.caret = d.source.find("T\n").unwrap();
17605 d.delete_forward();
17606 assert_eq!(d.source, "Try the toolbar.\n\n\n");
17607 assert_eq!(d.caret, 18);
17608 d.build_visual(80);
17609 assert!(d.vmap.is_stop(18));
17610
17611 let mut d = fmt_doc("Try the toolbar.\n\n{.center}\nT\n", Format::Djot);
17612 d.build_visual(80);
17613 d.caret = d.source.find("T\n").unwrap();
17614 d.delete_forward();
17615 assert_eq!(d.source, "Try the toolbar.\n\n\n");
17616 assert_eq!(d.caret, 18);
17617 }
17618
17619 /// Backspace at a block's start is twig's join, spelled per format — so
17620 /// the cases the one-newline delete got wrong come out right: a
17621 /// paragraph joins onto a heading's line, HTML's `</p><p>` goes as one,
17622 /// and a quote's prefix is written on the joined line.
17623 #[test]
17624 fn backspace_at_a_block_start_joins_it_the_format_s_way() {
17625 let mut d = wysiwyg_doc("wys_join_heading", "# Title\n\nbelow\n");
17626 d.caret = d.source.find("below").unwrap();
17627 d.backspace();
17628 assert_eq!(d.source, "# Title below\n", "onto the heading's line");
17629 assert_eq!(d.caret, d.source.find("below").unwrap());
17630 d.undo();
17631 assert_eq!(d.source, "# Title\n\nbelow\n", "one undo step");
17632
17633 let mut d = wysiwyg_doc("wys_join_quote", "> a\n\nb\n");
17634 d.caret = d.source.find('b').unwrap();
17635 d.backspace();
17636 assert_eq!(d.source, "> a\n> b\n", "into the quote, with its prefix");
17637 assert_eq!(d.caret, d.source.find('b').unwrap());
17638
17639 let mut h = fmt_doc("<p>above</p>\n<p class=\"x\">below</p>\n", Format::Html);
17640 h.view = View::Wysiwyg;
17641 h.build_visual(80);
17642 h.caret = h.source.find("below").unwrap();
17643 h.backspace();
17644 assert_eq!(
17645 h.source, "<p>above\nbelow</p>\n",
17646 "one paragraph, the tag gone whole"
17647 );
17648 assert_eq!(h.caret, h.source.find("below").unwrap());
17649 }
17650
17651 /// Delete at the end of a block's content is the same join aimed at the
17652 /// block after it, and the caret stays where the joined text now begins.
17653 #[test]
17654 fn delete_at_a_block_end_joins_the_next_block_into_it() {
17655 let src = "above\n\n<div class=\"center\">\n\nhello\n\n</div>\n\nbelow\n";
17656 let mut d = wysiwyg_doc("wys_del_join", src);
17657 d.caret = d.source.find("hello").unwrap() + 5;
17658 d.delete_forward();
17659 assert_eq!(
17660 d.source, "above\n\n<div class=\"center\">\n\nhello\nbelow\n\n</div>\n",
17661 "below joins hello inside the div"
17662 );
17663 assert_eq!(
17664 d.caret,
17665 d.source.find("hello").unwrap() + 5,
17666 "the caret stays"
17667 );
17668
17669 let mut d = wysiwyg_doc("wys_del_join_head", "above\n\n# Title\n");
17670 d.caret = 5;
17671 d.delete_forward();
17672 assert_eq!(
17673 d.source, "above\nTitle\n",
17674 "the heading's marker goes with the join"
17675 );
17676 assert_eq!(d.caret, 5);
17677
17678 // A code block after the paragraph: nothing to join, the caret steps
17679 // forward onto the next stop and nothing is deleted.
17680 let src = "above\n\n```\ncode\n```\n";
17681 let mut d = wysiwyg_doc("wys_del_code", src);
17682 d.caret = 5;
17683 d.delete_forward();
17684 assert_eq!(d.source, src);
17685 assert!(d.caret > 5, "the caret stepped forward");
17686 }
17687
17688 // ── Text statistics ──────────────────────────────────────────────────────
17689
17690 /// The counts of `body`, from a document open in the WYSIWYG view — the
17691 /// shape every case below starts from.
17692 fn counts_of(name: &str, body: &str) -> TextCounts {
17693 doc_in(View::Wysiwyg, name, body).counts()
17694 }
17695
17696 #[test]
17697 fn counts_tally_plain_prose() {
17698 let c = counts_of(
17699 "counts_prose",
17700 "The quick brown fox jumps over the lazy dog.\n",
17701 );
17702 assert_eq!(
17703 c,
17704 TextCounts {
17705 words: 9,
17706 characters: 44,
17707 characters_without_spaces: 36,
17708 paragraphs: 1,
17709 }
17710 );
17711 }
17712
17713 #[test]
17714 fn counts_read_the_text_and_not_the_markup() {
17715 // The `**` are four bytes of source and no part of the word.
17716 assert_eq!(
17717 counts_of("counts_marks", "a **bold** word\n"),
17718 TextCounts {
17719 words: 3,
17720 characters: 11,
17721 characters_without_spaces: 9,
17722 paragraphs: 1,
17723 }
17724 );
17725 // A link is its label; the destination is plumbing, however long.
17726 assert_eq!(
17727 counts_of(
17728 "counts_link",
17729 "see [the label](https://example.com/a/b/c) here\n"
17730 ),
17731 TextCounts {
17732 words: 4,
17733 characters: 18,
17734 characters_without_spaces: 15,
17735 paragraphs: 1,
17736 }
17737 );
17738 }
17739
17740 #[test]
17741 fn counts_spend_nothing_on_a_picture() {
17742 // A block image renders as a `🖼 alt` placeholder — a picture, not a
17743 // sentence, and not a paragraph either.
17744 assert_eq!(
17745 counts_of("counts_image", "\n"),
17746 TextCounts::default()
17747 );
17748 // And it adds nothing to the prose around it.
17749 assert_eq!(
17750 counts_of("counts_image_prose", "text\n\n\n"),
17751 TextCounts {
17752 words: 1,
17753 characters: 4,
17754 characters_without_spaces: 4,
17755 paragraphs: 1,
17756 }
17757 );
17758 }
17759
17760 #[test]
17761 fn counts_spend_nothing_on_drawn_furniture() {
17762 // A thematic break is drawn, not written, and an empty paragraph has
17763 // nothing in it — neither is a paragraph of the document.
17764 assert_eq!(
17765 counts_of("counts_rule", "one\n\n---\n\ntwo\n"),
17766 TextCounts {
17767 words: 2,
17768 characters: 6,
17769 characters_without_spaces: 6,
17770 paragraphs: 2,
17771 }
17772 );
17773 }
17774
17775 #[test]
17776 fn counts_measure_characters_as_a_reader_does() {
17777 // Four Han characters (each its own word under UAX#29), one ZWJ emoji
17778 // family that is a single grapheme cluster, and two letters.
17779 let c = counts_of(
17780 "counts_graphemes",
17781 "你好世界 👩\u{200d}👩\u{200d}👧\u{200d}👦 ok\n",
17782 );
17783 assert_eq!(
17784 c,
17785 TextCounts {
17786 words: 5,
17787 characters: 9,
17788 characters_without_spaces: 7,
17789 paragraphs: 1,
17790 }
17791 );
17792 }
17793
17794 #[test]
17795 fn counts_take_a_code_block_as_one_paragraph() {
17796 let c = counts_of("counts_code", "```rust\nlet x = 1;\n\nlet y = 2;\n```\n");
17797 assert_eq!(
17798 c,
17799 TextCounts {
17800 words: 6,
17801 characters: 20,
17802 characters_without_spaces: 14,
17803 paragraphs: 1,
17804 }
17805 );
17806 }
17807
17808 #[test]
17809 fn counts_take_a_table_as_one_paragraph() {
17810 // The box-drawn borders and the column padding are the renderer's, not
17811 // the author's; the cells are what was written.
17812 let c = counts_of("counts_table", "| a b | c |\n| - | - |\n| d | e |\n");
17813 assert_eq!(
17814 c,
17815 TextCounts {
17816 words: 5,
17817 characters: 6,
17818 characters_without_spaces: 5,
17819 paragraphs: 1,
17820 }
17821 );
17822 }
17823
17824 #[test]
17825 fn counts_give_every_item_and_every_quoted_paragraph_its_own_paragraph() {
17826 let c = counts_of(
17827 "counts_blocks",
17828 "- one\n- two\n- three\n\n> first quoted\n>\n> second quoted\n",
17829 );
17830 assert_eq!(
17831 c,
17832 TextCounts {
17833 words: 7,
17834 characters: 36,
17835 characters_without_spaces: 34,
17836 paragraphs: 5,
17837 }
17838 );
17839 }
17840
17841 #[test]
17842 fn counts_leave_the_frontmatter_out() {
17843 // The WYSIWYG view doesn't render it and a writer didn't write it.
17844 let c = counts_of(
17845 "counts_frontmatter",
17846 "---\ntitle: Hidden\n---\n\nvisible words here\n",
17847 );
17848 assert_eq!(
17849 c,
17850 TextCounts {
17851 words: 3,
17852 characters: 18,
17853 characters_without_spaces: 16,
17854 paragraphs: 1,
17855 }
17856 );
17857 }
17858
17859 #[test]
17860 fn counts_of_an_empty_document_are_all_zero() {
17861 assert_eq!(counts_of("counts_empty", ""), TextCounts::default());
17862 }
17863
17864 /// UAX#29 puts a boundary at the hyphen, so a hyphenated compound is two
17865 /// words. Recorded rather than corrected: it is what the algorithm says,
17866 /// and what every other UAX#29 counter reports.
17867 #[test]
17868 fn counts_split_a_hyphenated_compound_in_two() {
17869 let c = counts_of("counts_hyphen", "well-known example\n");
17870 assert_eq!(c.words, 3);
17871 assert_eq!(c.characters, 18);
17872 // Punctuation on its own is no word, and an apostrophe doesn't split one.
17873 assert_eq!(counts_of("counts_punct", "don't ... stop\n").words, 2);
17874 }
17875
17876 #[test]
17877 fn selection_counts_measure_the_selection_and_nothing_without_one() {
17878 let src = "alpha beta\n\ngamma delta\n";
17879 let mut d = doc_in(View::Wysiwyg, "counts_sel", src);
17880 assert_eq!(d.selection_counts(), None, "no selection, no counts");
17881
17882 // From the `b` of `beta` to the end of `gamma`: two blocks clipped.
17883 d.select_range(6, 17);
17884 assert_eq!(
17885 d.selection_counts(),
17886 Some(TextCounts {
17887 words: 2,
17888 characters: 9,
17889 characters_without_spaces: 9,
17890 paragraphs: 2,
17891 })
17892 );
17893 }
17894
17895 /// The count is of the document, not of the window it is shown in — so
17896 /// ⌘E must not move it, and neither must a resize or an edit made with no
17897 /// map built at all.
17898 #[test]
17899 fn counts_agree_across_the_views() {
17900 let src =
17901 "# Head\n\nA **bold** word, a [label](http://x), and more.\n\n- item one\n- item two\n";
17902 let mut d = doc_in(View::Wysiwyg, "counts_views", src);
17903 let wysiwyg = d.counts();
17904 assert!(wysiwyg.words > 0 && wysiwyg.paragraphs == 4);
17905
17906 d.toggle_view();
17907 assert_eq!(d.view, View::Source);
17908 d.build_source();
17909 assert_eq!(d.counts(), wysiwyg, "the source view counts the same text");
17910
17911 // A narrower measure is a narrower window, not a shorter document.
17912 d.toggle_view();
17913 d.build_visual(24);
17914 assert_eq!(d.counts(), wysiwyg, "wrapping is not an input");
17915
17916 // And an edit made in the source view, with the visual map left stale,
17917 // still counts the document as it now stands.
17918 d.toggle_view();
17919 d.caret = d.source.len();
17920 d.insert("\n\ntail words\n");
17921 let after = d.counts();
17922 assert_eq!(after.paragraphs, wysiwyg.paragraphs + 1);
17923 assert_eq!(after.words, wysiwyg.words + 2);
17924 }
17925
17926 // ── math ─────────────────────────────────────────────────────────────────
17927
17928 #[test]
17929 fn a_formula_reveals_on_the_caret_line_in_the_hidden_modes() {
17930 // The rule the proposal states: a formula's content is its TeX, not
17931 // its picture, so it reveals on the caret's line in *every* mode —
17932 // and nothing else on that line does outside `Full`.
17933 let mut d = doc_in(
17934 View::Wysiwyg,
17935 "math_reveal",
17936 "*one* $x+y$ here\n\ntwo there\n",
17937 );
17938 d.set_inline_pictures(true);
17939 assert_eq!(d.markup_mode(), MarkupMode::None);
17940
17941 // Away from the formula's line: the atom, and no reveal at all.
17942 caret_at(&mut d, "two");
17943 assert!(
17944 drawn_rows(&d).iter().any(|r| r == "one ∑ here"),
17945 "{:?}",
17946 drawn_rows(&d)
17947 );
17948 assert_eq!(d.vmap.math.len(), 1);
17949 assert_eq!(d.reveal_line(), None, "a line with no math keys nothing");
17950
17951 // On it: the formula is its source, the emphasis is still resolved.
17952 caret_at(&mut d, "here");
17953 assert!(
17954 drawn_rows(&d).iter().any(|r| r == "one $x+y$ here"),
17955 "{:?}",
17956 drawn_rows(&d)
17957 );
17958 assert!(d.vmap.math.is_empty());
17959 assert_eq!(d.reveal_line(), Some(Reveal::math(0..16)));
17960
17961 // Off again, and the picture is back.
17962 caret_at(&mut d, "two");
17963 assert!(drawn_rows(&d).iter().any(|r| r == "one ∑ here"));
17964
17965 // The same in Shortcuts; and Full reveals the emphasis too.
17966 d.set_markup_mode(MarkupMode::Shortcuts);
17967 caret_at(&mut d, "here");
17968 assert!(drawn_rows(&d).iter().any(|r| r == "one $x+y$ here"));
17969 d.set_markup_mode(MarkupMode::Full);
17970 caret_at(&mut d, "here");
17971 assert!(drawn_rows(&d).iter().any(|r| r == "*one* $x+y$ here"));
17972 }
17973
17974 #[test]
17975 fn a_formula_closed_by_typing_reveals_at_once() {
17976 // The reveal line is decided from the last build's layout, which
17977 // across an edit is stale: the keystroke that closes a `$…$` asks a
17978 // layout that knew no math. `build_map` asks again once the new
17979 // layout is in, so the formula does not snap to its picture under
17980 // the caret.
17981 let mut d = doc_in(View::Wysiwyg, "math_typed", "say \n");
17982 d.set_inline_pictures(true);
17983 d.set_markup_mode(MarkupMode::Shortcuts);
17984 d.caret = 4;
17985 for ch in ["$", "x", "$"] {
17986 d.insert(ch);
17987 d.build_visual(80);
17988 }
17989 assert_eq!(d.source, "say $x$\n");
17990 assert_eq!(
17991 drawn_rows(&d)[0],
17992 "say $x$",
17993 "source, not a picture, under the caret"
17994 );
17995 assert!(d.vmap.math.is_empty());
17996 // Leaving the line folds it — there is only one line, so add one.
17997 d.newline();
17998 d.insert("more");
17999 d.build_visual(80);
18000 assert_eq!(drawn_rows(&d)[0], "say ∑");
18001 assert_eq!(d.vmap.math.len(), 1);
18002 // And deleting the formula while revealed drops the reveal with it.
18003 d.caret = 7;
18004 d.build_visual(80);
18005 assert_eq!(drawn_rows(&d)[0], "say $x$");
18006 for _ in 0..3 {
18007 d.backspace();
18008 }
18009 d.build_visual(80);
18010 assert_eq!(d.source, "say \n\nmore\n");
18011 assert_eq!(d.reveal_line(), None);
18012 }
18013
18014 #[test]
18015 fn a_display_block_is_edited_where_it_stands() {
18016 let mut d = doc_in(
18017 View::Wysiwyg,
18018 "math_block",
18019 "intro\n\n$$\n\\int_0^1 x\n$$\n\nend\n",
18020 );
18021 caret_at(&mut d, "end");
18022 assert_eq!(
18023 drawn_rows(&d),
18024 vec!["intro", "", "∑ \\int_0^1 x", "", "end"]
18025 );
18026 // Up from `end` lands on the placeholder, whose glyphs all carry the
18027 // block's start — which is on its `$$` line, so the block reveals.
18028 d.move_up(false);
18029 d.build_visual(80);
18030 assert_eq!(d.caret, 7);
18031 assert_eq!(
18032 drawn_rows(&d),
18033 vec!["intro", "", "$$", "\\int_0^1 x", "$$", "", "end"]
18034 );
18035 // Down walks the source lines, still revealed; typing edits the TeX.
18036 d.move_down(false);
18037 d.build_visual(80);
18038 assert_eq!(d.caret, 10);
18039 d.move_end(false);
18040 d.insert("^2");
18041 d.build_visual(80);
18042 assert_eq!(d.source, "intro\n\n$$\n\\int_0^1 x^2\n$$\n\nend\n");
18043 assert_eq!(drawn_rows(&d)[3], "\\int_0^1 x^2");
18044 // Out below, and it folds to the placeholder with the new TeX.
18045 d.move_down(false);
18046 d.move_down(false);
18047 d.move_down(false);
18048 d.build_visual(80);
18049 assert_eq!(drawn_rows(&d)[2], "∑ \\int_0^1 x^2");
18050 assert_eq!(d.vmap.math[0].tex, "\n\\int_0^1 x^2\n");
18051 }
18052
18053 #[test]
18054 fn set_math_rows_reserves_filler_rows_by_tex() {
18055 let mut d = doc_in(View::Wysiwyg, "math_rows", "$$\nx\n$$\n\nend\n");
18056 caret_at(&mut d, "end");
18057 assert_eq!(d.vmap.math[0].rows_span, 0..1);
18058 d.set_math_rows(HashMap::from([(d.vmap.math[0].tex.clone(), 3)]));
18059 d.build_visual(80);
18060 assert_eq!(d.vmap.math[0].rows_span, 0..3);
18061 assert_eq!(drawn_rows(&d)[..3], ["∑ x", "", ""]);
18062 // Cheap when nothing changed.
18063 let key = d.visual_key();
18064 d.set_math_rows(HashMap::from([(d.vmap.math[0].tex.clone(), 3)]));
18065 d.build_visual(80);
18066 assert_eq!(d.visual_key(), key);
18067 }
18068
18069 #[test]
18070 fn a_dollar_typed_in_shortcuts_authors_math() {
18071 // (In `None` the same keystrokes *also* mint a formula for now: twig's
18072 // `insert_literal` does not yet escape `$` under the math extension —
18073 // see `docs/tasks/a-typed-dollar-mints-math-in-the-hidden-mode.md`.)
18074 let mut d = doc_in(View::Wysiwyg, "math_dollar_sc", "\n");
18075 d.set_markup_mode(MarkupMode::Shortcuts);
18076 d.caret = 0;
18077 d.insert("$x$");
18078 assert_eq!(d.source, "$x$\n");
18079 d.caret = 1;
18080 assert_eq!(d.breadcrumb(), "doc › para › inline_math");
18081 }
18082
18083 #[test]
18084 fn counts_see_a_formula_as_a_picture_however_it_is_written() {
18085 let c = counts_of(
18086 "counts_math",
18087 "the sum $\\sum_i x_i$ and\n\n$$\ny = mx + c\n$$\n",
18088 );
18089 // `the sum … and` is three words; neither formula counts, and the
18090 // display block is not a paragraph of text.
18091 assert_eq!(c.words, 3);
18092 assert_eq!(c.paragraphs, 1);
18093 }
18094}