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/// twig's cap on retained undo steps (`MAX_UNDO` in its splicer), which
387/// [`Doc::can_undo`]'s count follows: past it twig drops the oldest step.
388const UNDO_CAP: usize = 200;
389
390/// An open [`Doc::begin_undo_group`]: how deeply it is nested, and whether an
391/// edit made inside it has put its one step on the history yet.
392#[derive(Clone, Copy, Debug)]
393struct UndoGroup {
394 depth: usize,
395 has_step: bool,
396}
397
398/// What [`Doc::set_unrevealed`] sets aside: the screen's map, what it was
399/// built from, and the caret state a paper build's clamp must not move.
400struct ScreenMap {
401 vmap: VisualMap,
402 key: Option<(u64, Option<usize>, Option<Reveal>)>,
403 caret: usize,
404 anchor: Option<usize>,
405 goal_col: Option<usize>,
406}
407
408/// Where a locator lands — the answer to [`Doc::locate`].
409///
410/// A locator (the `v2` of a `chapter.dj#v2`) names a *place* rather than a
411/// document, and a place is a span rather than a point: a reader following one
412/// wants the caret at its first byte, and a reader merely *peeking* at one wants
413/// the block it covers drawn. Both are served by carrying the whole span, and
414/// only one of the two can be recovered from an offset alone.
415#[derive(Clone, PartialEq, Eq, Debug)]
416pub struct Landing {
417 /// The first byte of the block the locator names — where a caret goes.
418 pub start: usize,
419 /// One past its last byte, so a frontend can map the pair through
420 /// [`VisualMap::row_range_for`](crate::wysiwyg::VisualMap::row_range_for) to the rendered rows the block occupies and draw
421 /// those, the way a footnote peek draws a note ([`FootnoteRef::end`]).
422 pub end: usize,
423}
424
425/// The heading a place in the document sits under — the answer to
426/// [`Doc::heading_at`].
427///
428/// What a host writing a link *to* a position needs: the `#its-slug` half of a
429/// reference is made from `text`, and a peek at the target draws `span`.
430#[derive(Clone, PartialEq, Eq, Debug)]
431pub struct Heading {
432 /// The heading's inline content with its markup stripped — `A *big* day`
433 /// is `A big day` — which is what a slug is made from.
434 pub text: String,
435 /// 1 for `#`, 2 for `##`, and so on.
436 pub level: u32,
437 /// The heading block's own source span, marker and all — the heading, not
438 /// the section it opens.
439 pub span: Range<usize>,
440}
441
442/// Where a dragged block would land — the answer to [`Doc::drop_target_at`].
443///
444/// A drop is aimed at a *row* (the one under the pointer) and lands at a
445/// *boundary* (between two blocks), and a frontend needs both halves: the
446/// offset to hand [`Doc::move_block`], and the row to draw the indicator
447/// above — which is not the row aimed at, since a drop on the lower half of a
448/// block lands below it.
449#[derive(Clone, Copy, PartialEq, Eq, Debug)]
450pub struct DropTarget {
451 /// The boundary offset — `move_block`'s `to`.
452 pub offset: usize,
453 /// The rendered row the indicator is drawn above; `rows.len()` for a drop
454 /// below everything.
455 pub row: usize,
456}
457
458/// A selection cited out of the source: the text itself, up to a requested
459/// number of characters either side, and the byte range it came from. See
460/// [`Doc::selection_quote`].
461///
462/// The prefix and suffix are what make the quote *re-findable*: the same text
463/// can occur twice, and a little of what surrounded it is how a later reader —
464/// or the same document after an edit — tells the occurrences apart. The Web
465/// Annotation model calls this a `TextQuoteSelector`; the shape is older than
466/// the name.
467#[derive(Debug, Clone, PartialEq, Eq)]
468pub struct Quote {
469 /// The selected source, verbatim.
470 pub exact: String,
471 /// What immediately preceded it — possibly empty, at the document's start.
472 pub prefix: String,
473 /// What immediately followed it — possibly empty, at the document's end.
474 pub suffix: String,
475 /// Byte offset in the source where the selection begins.
476 pub start: usize,
477 /// Byte offset where it ends (exclusive).
478 pub end: usize,
479}
480
481/// A host-painted range of the source — an annotation's footprint, a search
482/// hit, a reviewer's mark. Leaf renders it (a background wash behind the
483/// glyphs whose source falls inside it) and hands back the `id` when the
484/// reader activates it; what the range *means* is entirely the host's.
485///
486/// Ranges are source bytes, like the caret and the selection, so a host that
487/// anchors quotes against the source ([`Doc::selection_quote`] is the other
488/// half of that loop) can paint what it found without any coordinate
489/// conversion. A range that drifts off the text it meant is the host's to
490/// re-anchor; leaf draws what it is told.
491#[derive(Debug, Clone, PartialEq, Eq)]
492pub struct Highlight {
493 /// Byte offset in the source where the wash begins.
494 pub start: usize,
495 /// Byte offset where it ends (exclusive).
496 pub end: usize,
497 /// The host's name for it, handed back on activation. Opaque to leaf.
498 pub id: String,
499 /// A rendering hint the frontend maps — a `#RRGGBB` hex string, or
500 /// nothing for the theme's default wash.
501 pub color: Option<String>,
502 /// A margin glyph's name, or nothing for wash-only ink. A highlight with
503 /// a marker gets a small glyph in the margin beside its first line, and
504 /// the glyph — not the wash — is what activates it: the wash is ink, the
505 /// marker is the control, which is what lets a reader put a caret in (or
506 /// copy from) annotated text without a card leaping at them. The name is
507 /// opaque to leaf; an Apple frontend reads it as an SF Symbol, a web one
508 /// as a class.
509 pub marker: Option<String>,
510}
511
512impl Highlight {
513 /// The range covering source `offset` in a list [`Doc::set_highlights`]
514 /// sorted, first by start where several overlap — the one place that
515 /// question is answered, for the frontends that paint by asking it as well
516 /// as for [`Doc::highlight_at`].
517 ///
518 /// The list is sorted by `(start, end)`, so the scan can stop at the first
519 /// range starting past `offset` rather than running to the end. A painter
520 /// asking once per glyph wants [`HighlightCursor`] instead; this is the
521 /// one-shot form, for the host asking what the reader just activated.
522 pub fn covering(highlights: &[Highlight], offset: usize) -> Option<&Highlight> {
523 highlights
524 .iter()
525 .take_while(|h| h.start <= offset)
526 .find(|h| offset < h.end)
527 }
528}
529
530/// [`Highlight::covering`] for a caller walking the document in order — which
531/// is every painter, since a frontend draws rows top to bottom and glyphs left
532/// to right.
533///
534/// The one-shot form is a scan from the front of the list per glyph, and a
535/// document with two hundred search hits pays that two hundred times a row. A
536/// range that ends at or before an offset can never cover that offset *or any
537/// later one*, so the cursor retires those permanently and each glyph costs the
538/// ranges that actually reach it. The answer is identical to
539/// [`Highlight::covering`]'s, offset for offset — this is the same scan with
540/// the part that was being redone dropped, not a cheaper approximation.
541///
542/// Offsets are expected to arrive non-decreasing. One that goes backwards is
543/// still answered correctly: the cursor re-seats to the front, since a painter
544/// that revisits a row is asking a question the retired ranges may own again.
545pub struct HighlightCursor<'a> {
546 highlights: &'a [Highlight],
547 /// The first range not yet retired.
548 at: usize,
549 /// The last offset asked about, to notice a caller going backwards.
550 last: usize,
551}
552
553impl<'a> HighlightCursor<'a> {
554 pub fn new(highlights: &'a [Highlight]) -> Self {
555 HighlightCursor {
556 highlights,
557 at: 0,
558 last: 0,
559 }
560 }
561
562 /// The range covering `offset`, advancing the cursor past every range that
563 /// can no longer cover anything.
564 pub fn at(&mut self, offset: usize) -> Option<&'a Highlight> {
565 if offset < self.last {
566 self.at = 0;
567 }
568 self.last = offset;
569 while self
570 .highlights
571 .get(self.at)
572 .is_some_and(|h| h.end <= offset)
573 {
574 self.at += 1;
575 }
576 Highlight::covering(&self.highlights[self.at..], offset)
577 }
578}
579
580/// The identity of a built [`VisualMap`] — see [`Doc::visual_key`]. Opaque on
581/// purpose: the only useful question is whether two of them are the same map,
582/// and what is behind it — which `Doc` built it, and the (revision, wrap,
583/// reveal line) it was built from — is core's business.
584///
585/// The document is part of it because the rest is not unique to one: two
586/// documents opened at the same width are both at revision zero with no reveal
587/// line, and a frontend holding one copy of a map across the two would take
588/// the second's key for the first's and paint the wrong document.
589#[derive(Clone, PartialEq, Eq, Debug)]
590pub struct VisualKey(u64, Option<(u64, Option<usize>, Option<Reveal>)>);
591
592pub struct Doc {
593 editor: Editor,
594 pub format: Format,
595 pub path: PathBuf,
596 /// Current source, refreshed from the editor after every successful edit.
597 pub source: String,
598 /// The caret, as a byte offset into `source` (always on a char boundary).
599 pub caret: usize,
600 /// The selection's fixed end, if a selection is active; the moving end is
601 /// the caret. `None` means no selection.
602 pub anchor: Option<usize>,
603 pub dirty: bool,
604 pub status: Option<String>,
605 pub view: View,
606 /// Whether the document refuses to change — a *reading* surface over the
607 /// same rendering, selection, and navigation the editor has.
608 ///
609 /// Enforced here rather than by each frontend hiding its input paths,
610 /// because every mutation funnels through a few doors —
611 /// [`splice_exact`](Self::splice_exact), [`undo`](Self::undo),
612 /// [`redo`](Self::redo), and the handful of inserts that go to twig's own
613 /// verbs directly rather than through the splice (a typed literal, a link,
614 /// an image, a rule, a footnote, a cell's line break) — and guarded doors
615 /// are a guarantee where a frontend's suppressed keyboard is a hope. A
616 /// gated door reports exactly like a rolled-back splice, a path every
617 /// caller already handles. `a_read_only_document_refuses_every_door` is
618 /// the list; a new `self.editor.insert_*` call belongs on it.
619 read_only: bool,
620 /// The host-painted ranges, kept sorted by start — see [`Highlight`].
621 /// State like the selection rather than like the text: no edit history,
622 /// no dirty bit, redrawn from whatever the host last set.
623 highlights: Vec<Highlight>,
624 /// How much of the source markup the rich view exposes — a frontend preference (see
625 /// [`MarkupMode`]). Its two axes are read apart: the rendering one by
626 /// [`reveal_line`](Self::reveal_line), the editing one by
627 /// [`insert`](Self::insert).
628 markup_mode: MarkupMode,
629 /// Whether soft breaks fold into the reflowed paragraph or render where
630 /// they were written (see [`LineFlow`]) — an independent frontend
631 /// preference the WYSIWYG builder consults when it lays out a block.
632 line_flow: LineFlow,
633 /// The kind of the last edit, for coalescing: twig owns the undo *history*
634 /// (see `undo`/`redo`), but "what counts as one undo step" is a frontend-UX
635 /// call, so leaf decides when a run continues and tells twig to coalesce.
636 last_edit_kind: Option<EditKind>,
637 /// The inline marks the user has toggled *at a collapsed caret* with no
638 /// selection — "start typing bold here". Held as the XOR delta from the marks
639 /// already in force at [`pending_at`](Self::pending_at): a set bit means
640 /// "flip this kind for the next typed text", so it both turns a mark on where
641 /// none is (type into bold) and off where one already covers the caret (type
642 /// past the bold you're standing in). [`Doc::insert`] realises it onto the
643 /// freshly typed text and then clears it — a mark once realised is carried by
644 /// the caret sitting inside the run, not by this delta.
645 pending_marks: InlineMarks,
646 /// The caret offset [`pending_marks`](Self::pending_marks) applies to. The
647 /// delta is live only while the caret still stands here with no selection;
648 /// any motion or edit ([`move_to`](Self::move_to), a splice, a click) drops
649 /// it, so a toggled-but-never-typed format doesn't leak onto text elsewhere.
650 pending_at: Option<usize>,
651 /// The source as of the last open/save — `dirty` is `source != clean_source`,
652 /// so undoing back to the saved state correctly clears the modified flag.
653 clean_source: String,
654 /// A hash of the bytes leaf last read from `path` or wrote to it; `None`
655 /// while the document has no file behind it. [`Doc::disk_state`] compares
656 /// the file against this to catch an edit made *outside* leaf before a save
657 /// silently overwrites it — `clean_source` only knows what leaf itself did.
658 ///
659 /// A hash, not an mtime: mtime is the cheap answer and the wrong one — two
660 /// writes inside one filesystem timestamp tick are indistinguishable, a
661 /// clock that steps backwards (or a writer that restores an mtime) hides a
662 /// real change, and a `touch` invents one. The whole point of the watermark
663 /// is to not clobber someone's work, so it reads the bytes and compares what
664 /// is actually there. That costs a file read per question, which is why the
665 /// question is asked on a user event (focus, save) and not every frame.
666 disk_hash: Option<u64>,
667 /// The "sticky" display column vertical motion aims for, in the active
668 /// view's grid. Set on the first `move_up`/`move_down` of a run and
669 /// reused by every subsequent one in that run, so passing through a
670 /// shorter line doesn't permanently forget the original column. Any
671 /// horizontal motion or edit clears it.
672 ///
673 /// A column, not a character index: dropping down a line of `你好` onto one
674 /// of ASCII has to land under the glyph the caret was drawn beneath, which
675 /// is the only thing the user can see to aim by. Where the goal falls inside
676 /// a wide character on the target line, the mapping resolves it to that
677 /// character — the caret lands on it rather than between its cells.
678 goal_col: Option<usize>,
679 /// The rendered map for the WYSIWYG view; empty in the source view. Movement
680 /// and clicks read it to stay in visible space.
681 pub vmap: VisualMap,
682 /// The syntax map for the source view; empty in the WYSIWYG view, which
683 /// styles resolved glyphs instead. Built by [`Doc::build_source`] — a
684 /// frontend that never calls it paints raw source unstyled, which is what
685 /// every frontend did before this map existed.
686 pub smap: SourceMap,
687 /// The revision `smap` was built from, or `None` before the first build.
688 /// The map is a pure function of the text alone — no width, no caret, no
689 /// reveal line — so unlike [`vmap_key`](Self::vmap_key) the revision is the
690 /// whole key.
691 smap_key: Option<u64>,
692 /// Everything the map is built from, as one number: bumped whenever the
693 /// document's text changes, and never by a motion, a selection, or a save.
694 /// A frontend can hold work against it — see [`Doc::revision`].
695 revision: u64,
696 /// How many history steps stand behind the caret, and how many ahead of
697 /// it — the answer to a native Edit menu's "may Undo be enabled?", which
698 /// twig's history does not answer itself. A mirror of twig's two stacks,
699 /// kept exact by following every move twig makes to them: a step onto the
700 /// undo stack at [`refresh`](Self::refresh), the funnel every edit comes
701 /// through, capped where twig caps it ([`UNDO_CAP`]); one fewer at each
702 /// [`coalesce_last_undo`](Self::coalesce_last_undo), under twig's own
703 /// two-step rule; and moved back and forth by [`undo`](Self::undo) and
704 /// [`redo`](Self::redo). A counter that only ever went up — which this was
705 /// — answered `true` after a coalesced run had been undone whole. Should
706 /// it ever drift anyway, the first undo twig refuses sets it to zero.
707 undo_steps: usize,
708 redo_steps: usize,
709 /// The undo group a host has open, if any — see
710 /// [`begin_undo_group`](Self::begin_undo_group). `None` outside one.
711 undo_group: Option<UndoGroup>,
712 /// Make the next literal insert fail as twig would refuse one — a test's
713 /// way to reach the rollback no document provokes.
714 #[cfg(test)]
715 refuse_next_literal: bool,
716 /// What `vmap` was built from, or `None` before the first build. The map is
717 /// a pure function of `(revision, wrap, reveal line)`, so when those haven't
718 /// moved, rebuilding it produces the identical map — see
719 /// [`Doc::build_visual`].
720 ///
721 /// The reveal line ([`Doc::reveal_line`]) is the caret's, and is `None` in
722 /// every mode but [`MarkupMode::Full`] — so outside that mode the key is
723 /// text and width alone, and a caret motion still rebuilds nothing.
724 vmap_key: Option<(u64, Option<usize>, Option<Reveal>)>,
725 /// The screen's map, set aside while [`Doc::set_unrevealed`] lays the
726 /// document out as paper, and put back when it stops — `Some` exactly
727 /// while the map in `vmap` is the page's.
728 screen: Option<Box<ScreenMap>>,
729 /// Which `Doc` this is, distinct from every other one built in this
730 /// process. Folded into [`VisualKey`] so that a map stashed by a frontend
731 /// can never be mistaken for another document's — see
732 /// [`Doc::visual_key`]. Nothing else reads it.
733 identity: u64,
734 /// Per-block row cache backing the incremental rebuild: when the text
735 /// changes, only the top-level blocks whose bytes moved are re-rendered and
736 /// the rest are reused shifted (see [`wysiwyg::BlockCache`]). Persists across
737 /// builds; a pure accelerator, so it's never read for correctness.
738 block_cache: wysiwyg::BlockCache,
739 /// What the frontend has said about itself — how tall its pictures came
740 /// out, keyed by destination or by TeX, and whether it paints a picture in
741 /// a line — set through [`Doc::set_media_rows`], [`Doc::set_math_rows`],
742 /// [`Doc::set_directive_rows`] and [`Doc::set_inline_pictures`]. Core does no I/O and lays out in glyphs,
743 /// so this is the only way it learns a height or a capability. Threaded
744 /// into every build; a change drops both caches, since none of it is in a
745 /// block's bytes.
746 surface: wysiwyg::Surface,
747
748 // View geometry the renderer stamps each frame, so mouse events can map a
749 // screen cell back to a byte offset.
750 pub scroll: usize,
751 pub body_origin: (u16, u16),
752 /// Width of the body rectangle last painted by the frontend. Zero means
753 /// unknown (used by tests or a frontend that has not drawn yet).
754 pub body_width: u16,
755 pub body_height: u16,
756 /// The caret as of the last frame drawn, or `None` before the first.
757 ///
758 /// Scrolling is the viewport's business, not the caret's: the view follows
759 /// the caret when the caret *moves*, but a wheel that doesn't touch the
760 /// caret has to be free to scroll away from it — otherwise the view is
761 /// pinned to the caret and stops dead at the edge of the document you can
762 /// see. Comparing against this is what tells the two apart, and it catches a
763 /// caret set by any route, including a frontend assigning the field itself.
764 pub drawn_caret: Option<usize>,
765}
766
767/// The Markdown extensions every leaf document is parsed with — five of them,
768/// each departing from twig's defaults for a reason leaf can state.
769///
770/// `html_elements` promotes embedded raw HTML (`<img>`, `<picture>`,
771/// `<source>`, …) into semantic AST nodes, so a picture becomes a real `image`
772/// node the frontends can frame and rasterize instead of opaque `raw_block`
773/// text. `directives` turns on generic `:::name{.class}` fenced-div containers
774/// (`directive` nodes), which a host app uses for its own semantics (diaryx's
775/// `:::vis{.audience}` visibility blocks) — core renders any directive as a
776/// plain tinted container, agnostic of `name`.
777///
778/// `highlight` and `highlight_colors` are the pair that makes Markdown read
779/// `==text==` as a `mark` node, and `==🔴 text==` as one carrying a
780/// `data-color`. leaf already had somewhere to put both: the
781/// [`Mark`](crate::Role::Mark) role and the ⌘⇧M highlight button predate them,
782/// and until twig 3.3 a Markdown document could only ever *receive* a highlight
783/// from a Djot one it was converted from — the button wrote `==…==` and the
784/// reparse read it straight back as text.
785/// They are on together because a colour is inert without the highlight itself,
786/// and a document that writes `==🔴 x==` means the colour by it.
787///
788/// `math` makes Markdown read `$…$` as an `inline_math` node and `$$…$$` as
789/// a `display_math` one — what djot reads natively and what leaf has
790/// somewhere to put: a formula typesets to a picture, or reveals to its TeX
791/// on the caret's line. Without it a `$$` block is a paragraph whose `\,`
792/// twig has already read as an escaped comma, and an author who types a
793/// backslash in it is authoring Markdown, not TeX. The flag is bounded by
794/// twig's own rule that a dollar followed by whitespace never opens math, so
795/// `$5 and $6` stays prose.
796///
797/// Every flag is inert for non-Markdown formats, so it's safe to pass them
798/// unconditionally. Threading this through every constructor (not just `open`)
799/// keeps `from_source`, `blank`, and `reload` parsing the same document the same
800/// way — twig reparses with these same flags after each edit.
801pub(crate) fn parse_extensions() -> MarkdownExtensions {
802 MarkdownExtensions {
803 html_elements: true,
804 directives: true,
805 highlight: true,
806 highlight_colors: true,
807 math: true,
808 }
809}
810
811/// Build an editor over `bytes` in `format` with leaf's [`parse_extensions`],
812/// mapping twig's error into the `anyhow` context every constructor shares.
813fn new_editor(bytes: &[u8], format: Format) -> Result<Editor> {
814 Editor::new_ext(bytes, format, parse_extensions()).map_err(|e| anyhow!("twig parse: {e}"))
815}
816
817/// Does `format` spell a table as a **pipe table** — the one grid twig's table
818/// editor knows how to emit?
819///
820/// This is the single capability leaf still has to answer for itself, and the
821/// only hand-maintained format list left in this file. Every other gesture is
822/// [`Format::supports`], which is twig's own answer read across the C ABI — but
823/// twig deliberately leaves the table ops out of that query, because they read
824/// no `Syntax` table at all. They rewrite a grid that is already in the source
825/// and refuse on *position*, never on format. Handed a caret inside an HTML
826/// `<table>`, `table_insert_row` therefore re-emits the whole element as
827/// `| a | b |` and reports success — a real splice, a clean reparse, an honest
828/// `dirty` flag, and nothing downstream able to tell it from a good edit.
829///
830/// So the list is narrow on purpose. `Format` is `#[non_exhaustive]`, and the
831/// wildcard answers "no" for a format leaf has never heard of: a new twig
832/// language that *does* spell pipe tables loses its grid controls until this
833/// line is updated, which shows up as a missing button. The other default hands
834/// it to [`Doc::table_op`], which rewrites documents it cannot spell.
835fn spells_pipe_tables(format: Format) -> bool {
836 matches!(format, Format::Markdown | Format::Djot)
837}
838
839/// Which of leaf's authoring controls this document's format can actually
840/// spell — one flag per toolbar button, resolved once so a frontend can build
841/// its chrome instead of discovering each refusal on a click.
842///
843/// Every field but [`table`](Self::table) is `Format::supports_with` on the
844/// gesture the matching [`Doc`] method calls, so this record cannot drift from
845/// what the ops do; `table` is [`spells_pipe_tables`], the one answer twig
846/// doesn't export.
847///
848/// `supports_with` rather than `supports` because two of these are facts about
849/// the *parse options* as much as about the format. `Format::supports` answers
850/// for twig's defaults, and leaf never parses with those — it parses with
851/// [`parse_extensions`], and a document's toolbar has to describe the document
852/// it is over. A Markdown editor holding `highlight` authors `==text==`; one
853/// without it would mint bytes its own reparse hands back as plain text, which
854/// is why twig asks before it writes.
855///
856/// **The formats are ragged, and that is the point.** A single per-document
857/// boolean was enough while the two authorable formats were Markdown and djot
858/// and everything else spelled nothing. HTML is neither: it writes seven of the
859/// eight inline marks as a tag pair, plus `<code>`, `<hr>`, an in-cell
860/// `<br>`, and — since twig 3.4 — a heading or paragraph rebuilt as its tag
861/// pair, and since 3.5 a quote, a list, a code block, a link and an image
862/// printed as fresh nodes; it spells no task box (a form control there) and
863/// no footnote, and its `<table>` is one twig reads but will not write. So
864/// ⌘B, ⌘1 and the quote button work in an HTML document and the task and
865/// table buttons do not, and no one flag can say that. Markdown and djot
866/// differ from each other too:
867/// `^superscript^` is djot-only, and an in-cell `<br>` is Markdown-only.
868#[derive(Clone, Copy, Debug, Eq, PartialEq)]
869pub struct Capabilities {
870 /// ⌘B — `InlineKind::Strong`.
871 pub bold: bool,
872 /// ⌘I — `InlineKind::Emph`.
873 pub italic: bool,
874 /// Inline code — `InlineKind::Verbatim`.
875 pub code: bool,
876 /// Highlight — `InlineKind::Mark`. Djot spells it, and so does Markdown
877 /// under the `highlight` extension [`parse_extensions`] turns on: the
878 /// button writes `==text==`, which is what the reparse reads back.
879 pub mark: bool,
880 /// ⌘U — `InlineKind::Insert`, which every format that marks at all spells.
881 pub underline: bool,
882 /// Strikethrough — `InlineKind::Delete`. Markdown spells GFM's `~~text~~`
883 /// out of the box, since twig parses it out of the box.
884 pub strike: bool,
885 /// The highlight *palette* — [`Doc::set_mark_color`]. Narrower than
886 /// [`mark`](Self::mark) and deliberately its own flag: Markdown spells a
887 /// colour on a highlight (`==🔴 text==`) and djot spells only the highlight,
888 /// so a toolbar offering the swatches wherever the button lights would offer
889 /// them in a document that cannot write one. Pair with
890 /// [`Doc::caret_in_mark`], which asks the other question — the palette needs
891 /// a highlight to colour as much as a format that spells one.
892 pub mark_color: bool,
893 pub superscript: bool,
894 pub subscript: bool,
895 /// Heading levels and "make this a paragraph" — [`Doc::set_block`].
896 pub heading: bool,
897 pub blockquote: bool,
898 pub bullet_list: bool,
899 pub ordered_list: bool,
900 /// The checkbox controls: giving an item a box, and ticking one.
901 pub task: bool,
902 pub link: bool,
903 /// Covers [`Doc::insert_media`] too — see the note there on why the three
904 /// media kinds stand or fall together.
905 pub image: bool,
906 /// The horizontal-rule button. HTML spells this one (`<hr>`).
907 pub thematic_break: bool,
908 /// The footnote button — [`Doc::insert_footnote`]. Markdown and djot spell
909 /// the pair; HTML has no footnote of its own, so the button goes away rather
910 /// than writing brackets that would render as brackets.
911 pub footnote: bool,
912 /// The code-block button — [`Doc::toggle_code_block`], twig's
913 /// `Gesture::ToggleCodeBlock`. Markdown and djot spell the fence; HTML
914 /// rebuilds the block as `<pre><code>`.
915 pub code_block: bool,
916 /// Setting a fenced block's language — a control only ever offered with the
917 /// caret already in a fence.
918 pub code_language: bool,
919 /// The grid controls: insert/delete/move a row or column, set a column's
920 /// alignment. Pair with [`Doc::caret_in_table`], which asks the other
921 /// question — an HTML `<table>` holds the caret and still can't be edited.
922 pub table: bool,
923 /// Shift+Return inside a cell. Markdown and HTML spell it; djot has no
924 /// idiomatic in-cell break.
925 pub cell_line_break: bool,
926 /// The alignment control — [`Doc::set_alignment`], twig's
927 /// `Gesture::SetBlockAttrs`. Every format leaf opens but XML spells a
928 /// block's attributes, Markdown under the `html_elements`
929 /// [`parse_extensions`] turns on (a `<div>` around the block) and AsciiDoc
930 /// through its `[…]` line.
931 pub alignment: bool,
932 /// The line-spacing menu — [`Doc::set_line_spacing`]. The same gesture as
933 /// [`alignment`](Self::alignment) and so the same answer, and its own flag
934 /// because a toolbar dims controls one at a time and the pair may yet
935 /// diverge.
936 pub line_spacing: bool,
937 /// The size menu — [`Doc::set_font_size`], twig's `Gesture::WrapRangeAttrs`
938 /// over a selection. **Narrower than the block pair**: AsciiDoc's
939 /// `[#id.role]#text#` keeps an id and a role and has no slot for a
940 /// `data-` key, so twig refuses the span there and this is `false` while
941 /// [`alignment`](Self::alignment) is `true`. The block-level form of the
942 /// same property — the caret in a paragraph, no selection — goes through
943 /// `SetBlockAttrs` and still works, which is why the flag describes the
944 /// control rather than the caret.
945 pub font_size: bool,
946 /// The face menu — [`Doc::set_font_family`]. `WrapRangeAttrs`, as
947 /// [`font_size`](Self::font_size) is.
948 pub font_family: bool,
949 /// The text-colour swatches — [`Doc::set_text_color`]. `WrapRangeAttrs`,
950 /// and not to be confused with [`mark_color`](Self::mark_color): that is a
951 /// highlight's background and rides the `mark` node twig already owns,
952 /// this is a run's foreground and rides an attributed span.
953 pub text_color: bool,
954 /// The page-break button — [`Doc::insert_page_break`], twig's
955 /// `Gesture::InsertDirective`. Markdown under the `directives` extension
956 /// [`parse_extensions`] turns on (`::page-break`), djot, which spells it
957 /// as an empty `::: page-break` fence, HTML (`<page-break></page-break>`)
958 /// and AsciiDoc (`<<<`) — each drawn as the same placeholder row.
959 pub page_break: bool,
960 /// The directives a host names — [`Doc::insert_directive`], twig's
961 /// `Gesture::InsertDirective` for any name. **Narrower than
962 /// [`page_break`](Self::page_break)**, which is the same gesture: twig
963 /// spells an arbitrary directive in HTML (a custom element) and AsciiDoc
964 /// (an open block with a style), and the walker reads back neither as a
965 /// directive — it reads their spellings of `page-break` and no other name.
966 /// So this is Markdown under the `directives` extension and djot, the two
967 /// formats where what is written comes back in
968 /// [`VisualMap::directives`](crate::wysiwyg::VisualMap::directives) with
969 /// its name. djot has no leaf directive label: see
970 /// [`Doc::insert_directive`].
971 pub directives: bool,
972 /// Moving a block — [`Doc::move_block`] and the Alt+↑/↓ pair, twig's
973 /// `Gesture::MoveBlock`. Every format with blocks a caret can name; XML
974 /// has none.
975 pub move_block: bool,
976}
977
978impl Capabilities {
979 /// Resolve every flag for `format`, as leaf parses it. Pure and cheap —
980 /// twig computes each from a static table — but a frontend that wants to
981 /// hold them can.
982 ///
983 /// The extensions are not a parameter because they are not a choice a
984 /// caller makes: every leaf document is parsed with [`parse_extensions`],
985 /// so the format is the whole of what varies.
986 pub fn of(format: Format) -> Self {
987 let exts = parse_extensions();
988 let supports = |g| format.supports_with(exts, g);
989 let inline = |k| supports(Gesture::ToggleInline(k));
990 let container = |k| supports(Gesture::ToggleBlockContainer(k));
991 Self {
992 bold: inline(InlineKind::Strong),
993 italic: inline(InlineKind::Emph),
994 code: inline(InlineKind::Verbatim),
995 mark: inline(InlineKind::Mark),
996 underline: inline(InlineKind::Insert),
997 strike: inline(InlineKind::Delete),
998 mark_color: supports(Gesture::SetMarkColor),
999 superscript: inline(InlineKind::Superscript),
1000 subscript: inline(InlineKind::Subscript),
1001 heading: supports(Gesture::SetBlock),
1002 blockquote: container(BlockContainerKind::BlockQuote),
1003 bullet_list: container(BlockContainerKind::BulletList),
1004 ordered_list: container(BlockContainerKind::OrderedList),
1005 // Both halves of the checkbox story, and leaf offers no control that
1006 // needs only one: the item gesture mints the box, the checked one
1007 // ticks it, and a format spelling a `task_marker` spells both.
1008 task: supports(Gesture::ToggleTaskItem) && supports(Gesture::ToggleTaskChecked),
1009 link: supports(Gesture::InsertLink),
1010 image: supports(Gesture::InsertImage),
1011 thematic_break: supports(Gesture::InsertThematicBreak),
1012 footnote: supports(Gesture::InsertFootnote),
1013 code_block: supports(Gesture::ToggleCodeBlock),
1014 code_language: supports(Gesture::SetCodeLanguage),
1015 table: spells_pipe_tables(format),
1016 cell_line_break: supports(Gesture::InsertLineBreak),
1017 // The presentation vocabulary, one gesture per level: the two
1018 // line-level properties are a block's attributes and the three
1019 // run-level ones a span's. They are asked separately because the
1020 // formats answer differently — AsciiDoc spells the block and not
1021 // the span — and a toolbar that dimmed all five together would dim
1022 // three controls that work.
1023 alignment: supports(Gesture::SetBlockAttrs),
1024 line_spacing: supports(Gesture::SetBlockAttrs),
1025 font_size: supports(Gesture::WrapRangeAttrs),
1026 font_family: supports(Gesture::WrapRangeAttrs),
1027 text_color: supports(Gesture::WrapRangeAttrs),
1028 // Every format twig spells the directive in: the walker draws
1029 // HTML's `<page-break>` and AsciiDoc's `<<<` as the same
1030 // placeholder Markdown's `::page-break` and djot's fence get.
1031 page_break: supports(Gesture::InsertDirective),
1032 // Only where the walker reads an arbitrary name back — see the
1033 // field. twig's own answer is also `true` for HTML and AsciiDoc.
1034 directives: supports(Gesture::InsertDirective)
1035 && matches!(format, Format::Markdown | Format::Djot),
1036 move_block: supports(Gesture::MoveBlock),
1037 }
1038 }
1039}
1040
1041/// The source of [`Doc::identity`], one per document ever built.
1042static NEXT_IDENTITY: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
1043
1044impl Doc {
1045 #[cfg(feature = "fs")]
1046 pub fn open(path: PathBuf) -> Result<Self> {
1047 let bytes = std::fs::read(&path).with_context(|| format!("reading {}", path.display()))?;
1048 Self::from_disk_bytes(path, bytes)
1049 }
1050
1051 /// An empty document *named* `path`, for a file that isn't there yet — what
1052 /// every other terminal editor gives you when you name a file that doesn't
1053 /// exist. It is a real named document, not a [`Doc::blank`]: `is_untitled`
1054 /// is false, so ⌘S writes straight to `path` with no Save As detour, and
1055 /// the header shows the name the user asked for.
1056 ///
1057 /// The format comes from the extension, exactly as [`Doc::open`] reads it —
1058 /// so `leaf notes.dj` starts a djot buffer rather than the Markdown
1059 /// [`Doc::blank`] has to assume for want of a name. An extension leaf can't
1060 /// parse is still an error: a mistyped flag or a stray argument should say
1061 /// so, not open a buffer promising to save somewhere.
1062 ///
1063 /// The watermark is the hash of *no bytes*, not `None`, and that is the
1064 /// whole trick: `None` means untitled, and would leave [`Doc::disk_state`]
1065 /// answering [`DiskState::Untitled`] for a document that has a path and
1066 /// intends to write to it. Hashing `""` instead makes the answers the true
1067 /// ones — [`DiskState::Missing`] while the file still isn't there (a save
1068 /// recreates it, which is exactly what this is for), and
1069 /// [`DiskState::Changed`] if somebody creates it underneath us between
1070 /// launch and save, so the frontend's overwrite prompt guards a new file as
1071 /// it guards an opened one.
1072 ///
1073 /// Nothing is written here. A buffer that is never typed into never touches
1074 /// the filesystem, and a `path` whose directory doesn't exist is allowed to
1075 /// open — the write is where that fails, and it says so then.
1076 #[cfg(feature = "fs")]
1077 pub fn create(path: PathBuf) -> Result<Self> {
1078 Self::from_disk_bytes(path, Vec::new())
1079 }
1080
1081 /// [`Doc::open`] when the file is there, [`Doc::create`] when it isn't —
1082 /// the call a CLI frontend wants for its path argument.
1083 ///
1084 /// The decision is made from the failed read itself rather than a `exists()`
1085 /// check first, so there is no window between the two for the file to appear
1086 /// or vanish in. Only `NotFound` opens a new buffer: a permissions error or
1087 /// a directory in the way is still an error, because pretending those are
1088 /// "no file yet" would offer to save over something leaf couldn't read.
1089 #[cfg(feature = "fs")]
1090 pub fn open_or_create(path: PathBuf) -> Result<Self> {
1091 match std::fs::read(&path) {
1092 Ok(bytes) => Self::from_disk_bytes(path, bytes),
1093 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Self::create(path),
1094 Err(e) => Err(e).with_context(|| format!("reading {}", path.display())),
1095 }
1096 }
1097
1098 /// The shared body of [`Doc::open`] and [`Doc::create`]: bytes that are (or
1099 /// stand in for) the file at `path`, parsed as the format its extension
1100 /// names. Keeping the two on one path is what makes a new file's document
1101 /// identical in every respect to an opened one but its contents.
1102 #[cfg(feature = "fs")]
1103 fn from_disk_bytes(path: PathBuf, bytes: Vec<u8>) -> Result<Self> {
1104 let format = detect_format(&path)?;
1105 let editor = new_editor(&bytes, format)?;
1106 let source = String::from_utf8(bytes).map_err(|_| anyhow!("document is not UTF-8"))?;
1107 let disk_hash = Some(hash_bytes(source.as_bytes()));
1108 // Store the document's *absolute* path. A relative one (`leaf README.md`)
1109 // has an empty parent, so a frontend can't resolve a relative image
1110 // destination (``) against the document's directory and the
1111 // picture silently falls back to its text placeholder. `absolute` is
1112 // purely lexical — it prefixes the current directory and normalizes, but
1113 // reads nothing and resolves no symlinks — so `file_name` and save are
1114 // unchanged; it only gives `path.parent()` something to join against.
1115 let path = std::path::absolute(&path).unwrap_or(path);
1116 Ok(Doc::from_parts(editor, format, path, source, disk_hash))
1117 }
1118
1119 /// Build a document from an in-memory string, the format named explicitly —
1120 /// the portable, filesystem-free counterpart to [`Doc::open`] (which reads a
1121 /// path and sniffs the format from its extension). A wasm or FFI host, which
1122 /// has no path to read, uses this: it hands over bytes it fetched however it
1123 /// could, and later persists [`Doc::source`] however it can (a browser
1124 /// download, `localStorage`, a backend `PUT`) and calls [`Doc::mark_saved`].
1125 ///
1126 /// No file backs the result, so it starts untitled ([`Doc::is_untitled`] is
1127 /// true) exactly like a [`Doc::blank`] that has been given content.
1128 pub fn from_source(source: String, format: Format) -> Result<Self> {
1129 let editor = new_editor(source.as_bytes(), format)?;
1130 Ok(Doc::from_parts(
1131 editor,
1132 format,
1133 PathBuf::new(),
1134 source,
1135 None,
1136 ))
1137 }
1138
1139 /// An untitled, empty document — the `+` button and a `leaf` launched with
1140 /// no file argument. Nothing on disk backs it until a [`Doc::save_as`].
1141 ///
1142 /// It is Markdown, because a format has to be chosen before a name exists to
1143 /// read one from: `detect_format` reads the extension and an untitled
1144 /// document has neither. Markdown is what leaf's own files are, what its
1145 /// block markers are already written for (`insert_block_prefix`), and the
1146 /// extension a Save As will overwhelmingly pick — a wrong guess here would
1147 /// mean typing djot into a buffer parsing it as Markdown. Note that Save As
1148 /// *doesn't* revisit this: see [`Doc::save_as`].
1149 pub fn blank() -> Result<Self> {
1150 let format = Format::Markdown;
1151 let editor = new_editor(b"", format)?;
1152 // An empty `path` is the untitled marker (`path` is a public `PathBuf`
1153 // field two frontends already read; making it an `Option` to say this
1154 // would break both). `is_untitled` is the question to ask, not the
1155 // representation to copy.
1156 Ok(Doc::from_parts(
1157 editor,
1158 format,
1159 PathBuf::new(),
1160 String::new(),
1161 None,
1162 ))
1163 }
1164
1165 /// The fields every constructor agrees on, so `open` and `blank` can't drift
1166 /// apart in the ones neither of them has an opinion about.
1167 // `identity` is taken from a counter rather than from the `Doc`'s address,
1168 // which moves — a session that holds one is moved into and out of
1169 // containers freely, and an identity that changed with it would defeat the
1170 // one comparison it exists for.
1171 fn from_parts(
1172 editor: Editor,
1173 format: Format,
1174 path: PathBuf,
1175 source: String,
1176 disk_hash: Option<u64>,
1177 ) -> Self {
1178 Doc {
1179 editor,
1180 format,
1181 path,
1182 disk_hash,
1183 clean_source: source.clone(),
1184 source,
1185 caret: 0,
1186 anchor: None,
1187 dirty: false,
1188 status: None,
1189 read_only: false,
1190 highlights: Vec::new(),
1191 // leaf opens in the rich-text (WYSIWYG) view by default — the
1192 // markup-resolved surface is leaf's differentiator. Frontends can
1193 // still start in source view explicitly (e.g. a CLI flag), and ⌘e/⌥w
1194 // toggles at runtime.
1195 view: View::Wysiwyg,
1196 // `None` by default — the clean surface Diaryx ships, with typed
1197 // syntax kept literal; a markup-fluent frontend can climb the
1198 // ladder to `Shortcuts` or `Full`.
1199 markup_mode: MarkupMode::default(),
1200 // Fold by default — flowing prose that reflows to the viewport, the
1201 // behaviour every frontend had before this preference existed.
1202 line_flow: LineFlow::default(),
1203 last_edit_kind: None,
1204 pending_marks: InlineMarks::empty(),
1205 pending_at: None,
1206 goal_col: None,
1207 vmap: VisualMap::default(),
1208 smap: SourceMap::default(),
1209 // No map yet — the first `build_source` always builds.
1210 smap_key: None,
1211 revision: 0,
1212 undo_steps: 0,
1213 redo_steps: 0,
1214 undo_group: None,
1215 #[cfg(test)]
1216 refuse_next_literal: false,
1217 // No map yet — the first `build_visual` always builds.
1218 vmap_key: None,
1219 screen: None,
1220 identity: NEXT_IDENTITY.fetch_add(1, std::sync::atomic::Ordering::Relaxed),
1221 block_cache: wysiwyg::BlockCache::default(),
1222 surface: wysiwyg::Surface::default(),
1223 scroll: 0,
1224 body_origin: (0, 0),
1225 body_width: 0,
1226 body_height: 0,
1227 drawn_caret: None,
1228 }
1229 }
1230
1231 /// Whether this document has no file behind it yet — a [`Doc::blank`] that
1232 /// has never been saved. The question a ⌘S handler asks to know it should
1233 /// open a Save As picker instead ([`Doc::save`] won't guess a name), and the
1234 /// header asks to know the name it shows is a placeholder.
1235 pub fn is_untitled(&self) -> bool {
1236 self.path.as_os_str().is_empty()
1237 }
1238
1239 pub fn toggle_view(&mut self) {
1240 self.view = match self.view {
1241 View::Source => View::Wysiwyg,
1242 View::Wysiwyg => View::Source,
1243 };
1244 self.scroll = 0;
1245 self.status = None;
1246 // Entering WYSIWYG, the caret may be sitting in now-hidden frontmatter;
1247 // lift it to the first rendered offset.
1248 self.clamp_caret();
1249 }
1250
1251 /// The current markup-exposure preference (see [`MarkupMode`]).
1252 pub fn markup_mode(&self) -> MarkupMode {
1253 self.markup_mode
1254 }
1255
1256 /// Set the markup-exposure preference. Both of its axes take effect at
1257 /// once: the editing one on the next [`insert`](Self::insert), and the
1258 /// rendering one on the next build — which is why this drops the cached
1259 /// visual map and the per-block render cache, exactly as
1260 /// [`set_line_flow`](Self::set_line_flow) does.
1261 pub fn set_markup_mode(&mut self, mode: MarkupMode) {
1262 if self.markup_mode == mode {
1263 return;
1264 }
1265 self.markup_mode = mode;
1266 // Neither cache is keyed on the mode, and moving between `Full` and the
1267 // hidden modes changes every row the caret's line renders to — so
1268 // invalidate both explicitly.
1269 self.vmap_key = None;
1270 self.block_cache = wysiwyg::BlockCache::default();
1271 }
1272
1273 /// The line the caret sits on, when that line should render something
1274 /// raw — `None` when nothing on it would, which is what the builder reads
1275 /// as "reveal nothing" and what keeps caret motion from costing a build.
1276 ///
1277 /// Two things ask for it. Under [`MarkupMode::Full`] every delimiter on
1278 /// the caret's line shows ([`Reveal::full`]). In the two hidden modes a
1279 /// *formula* on it still shows its TeX ([`Reveal::math`]), because a
1280 /// formula's content is not its picture and hiding the `$` alone would
1281 /// leave nothing to edit; there the line is threaded through only when it
1282 /// meets a block that holds one, which the last build's layout knows
1283 /// ([`wysiwyg::BlockCache::math_meets`]) — so a document with no math
1284 /// keeps the `None` it always had, and one with math pays a rebuild only
1285 /// while the caret is in the formula's block.
1286 ///
1287 /// A *source* line (newline to newline), not a visual row: a wrapped
1288 /// paragraph and a `LineFlow::Preserve` soft break both split one source
1289 /// line across several rows, and revealing half a delimiter pair because the
1290 /// other half wrapped would be worse than revealing neither. The range
1291 /// excludes the terminating newline and is empty-but-present on a blank
1292 /// line, which reveals nothing but still keys the caches correctly.
1293 ///
1294 /// Only in [`View::Wysiwyg`]: source view already shows every byte, so
1295 /// there is nothing there to reveal.
1296 pub(crate) fn reveal_line(&self) -> Option<Reveal> {
1297 // A page has no caret, so no line of it is the caret's.
1298 if self.view != View::Wysiwyg || self.screen.is_some() {
1299 return None;
1300 }
1301 let line = source_line_range(&self.source, self.caret);
1302 if self.markup_mode.reveals_caret_line() {
1303 return Some(Reveal::full(line));
1304 }
1305 self.block_cache
1306 .math_meets(&line)
1307 .then_some(Reveal::math(line))
1308 }
1309
1310 /// The current soft-break flow preference (see [`LineFlow`]).
1311 pub fn line_flow(&self) -> LineFlow {
1312 self.line_flow
1313 }
1314
1315 /// Set the soft-break flow preference. The mode changes how every block lays
1316 /// out, so a change drops the cached visual map and the per-block render
1317 /// cache, forcing the next [`build_visual`] to rebuild under the new flow.
1318 ///
1319 /// [`build_visual`]: Self::build_visual
1320 pub fn set_line_flow(&mut self, mode: LineFlow) {
1321 if self.line_flow == mode {
1322 return;
1323 }
1324 self.line_flow = mode;
1325 // Both caches are keyed on `(revision, wrap)`, neither of which moved —
1326 // so invalidate them explicitly, or the next build would reuse rows laid
1327 // out under the old flow.
1328 self.vmap_key = None;
1329 self.block_cache = wysiwyg::BlockCache::default();
1330 }
1331
1332 pub fn view_name(&self) -> &'static str {
1333 match self.view {
1334 View::Source => "source",
1335 View::Wysiwyg => "wysiwyg",
1336 }
1337 }
1338
1339 /// Rebuild the WYSIWYG visual map for the current tree at `width` columns
1340 /// (called by the renderer each frame it's in the WYSIWYG view).
1341 /// Build the WYSIWYG map, wrapped at `width` display columns.
1342 ///
1343 /// Cheap to call every frame, which is what both frontends do: the map is a
1344 /// pure function of the document and the wrap width, so a call that would
1345 /// rebuild the same map returns the one already built. Only an edit (or a
1346 /// resize) pays.
1347 ///
1348 /// That isn't a micro-optimisation. A frontend repaints for reasons that have
1349 /// nothing to do with the text — a blinking caret, a scroll, a focus change —
1350 /// and rebuilding here is O(document): 23 ms on a 1 MB file, of which 5 ms is
1351 /// marshalling twig's AST across the C ABI. Paid twice a second by the GUI's
1352 /// blink timer, that was 14% of a core spent redrawing an unchanged document.
1353 /// (`cargo run --release -p leaf-core --example bench` for the numbers.)
1354 pub fn build_visual(&mut self, width: usize) {
1355 self.build_map(Some(width));
1356 }
1357
1358 /// Build the WYSIWYG map with each block as a single unwrapped row — for a
1359 /// frontend (the GUI) that wraps at its own proportional pixel width rather
1360 /// than a fixed character column.
1361 pub fn build_visual_unwrapped(&mut self) {
1362 self.build_map(None);
1363 }
1364
1365 /// Lay the document out as paper shows it, with no line revealed — or,
1366 /// with `false`, go back to the screen's map.
1367 ///
1368 /// The reveal is core's, folded into the rows before a frontend sees them:
1369 /// under [`MarkupMode::Full`] the caret's line shows its delimiters, and in
1370 /// every mode a formula on it is its TeX rather than its picture. A page
1371 /// has no caret, so a PDF or a printout laid out from the screen's map
1372 /// printed the one line the reader happened to be standing on as source.
1373 /// While this is on, [`build_visual`](Self::build_visual) and its twin
1374 /// build as though nothing were revealed.
1375 ///
1376 /// Kept apart from the screen's build rather than replacing it: turning
1377 /// this on sets the screen's map aside — and the caret, which a build
1378 /// clamps against the map it builds — and turning it off puts both back
1379 /// as they were, so the screen's next build costs nothing it would not
1380 /// have cost anyway. Meant to bracket one read of the map, on and then
1381 /// off; an edit in between is not the paper's to make.
1382 pub fn set_unrevealed(&mut self, on: bool) {
1383 match (on, self.screen.take()) {
1384 (true, None) => {
1385 self.screen = Some(Box::new(ScreenMap {
1386 vmap: std::mem::take(&mut self.vmap),
1387 key: self.vmap_key.take(),
1388 caret: self.caret,
1389 anchor: self.anchor,
1390 goal_col: self.goal_col,
1391 }));
1392 }
1393 (false, Some(screen)) => {
1394 let ScreenMap {
1395 vmap,
1396 key,
1397 caret,
1398 anchor,
1399 goal_col,
1400 } = *screen;
1401 self.vmap = vmap;
1402 self.vmap_key = key;
1403 self.caret = caret;
1404 self.anchor = anchor;
1405 self.goal_col = goal_col;
1406 }
1407 // Already in the state asked for.
1408 (_, screen) => self.screen = screen,
1409 }
1410 }
1411
1412 /// Build the source view's syntax map ([`Doc::smap`]) — the styling for
1413 /// [`View::Source`], the way [`build_visual`](Self::build_visual) is the
1414 /// styling for [`View::Wysiwyg`].
1415 ///
1416 /// A frontend calls this before painting raw source. One that doesn't gets
1417 /// an empty map and paints unstyled text, so this is additive: nothing
1418 /// breaks by not calling it.
1419 ///
1420 /// Built at most once per revision, and the revision is the whole key — the
1421 /// map has no width and no caret in it, so it survives every resize, every
1422 /// motion, and every selection change.
1423 ///
1424 /// The builds it does do cost a whole-arena marshal, which is precisely what
1425 /// the WYSIWYG path works to avoid, so this has no incremental path where
1426 /// that one has two. From `cargo run --release -p leaf-core --example
1427 /// bench`, per keystroke, against the WYSIWYG build the source view is
1428 /// *not* doing:
1429 ///
1430 /// | size | nodes | marshal | `source::build` | (`wysiwyg::build`) |
1431 /// |------:|-------:|--------:|----------------:|-------------------:|
1432 /// | 10 KB| 613 | 0.16 ms| 0.07 ms | 0.28 ms |
1433 /// | 100 KB| 6 097 | 0.84 ms| 0.38 ms | 2.43 ms |
1434 /// | 1 MB| 60 601 | 5.67 ms| 3.12 ms | 23.39 ms |
1435 ///
1436 /// Linear, two thirds of it the marshal, and the build itself five to seven
1437 /// times cheaper than the one it stands in for at every size. Comfortable
1438 /// well past any document a person edits in a terminal — a megabyte is where
1439 /// it would want [`Editor::dirty_range`] and the same splice treatment
1440 /// `build_spliced` gives the other map. The door is open; nothing has needed
1441 /// it yet.
1442 pub fn build_source(&mut self) {
1443 if self.smap_key == Some(self.revision) {
1444 return;
1445 }
1446 let nodes = self.nodes();
1447 self.smap = source::build(&nodes, &self.source);
1448 self.smap_key = Some(self.revision);
1449 }
1450
1451 /// Tell the model how many visual rows each block image should reserve, keyed
1452 /// by the image's destination. A terminal frontend calls this once it has
1453 /// decoded and measured its pictures — core does no image I/O, so this is the
1454 /// only way it learns a height — and the next [`Doc::build_visual`] lays each
1455 /// placeholder out that tall (the label row plus blank filler rows the
1456 /// frontend paints the raster over). A destination left out of the map falls
1457 /// back to the bare one-row placeholder, which is also what a frontend that
1458 /// can't draw pictures (or lays them out in its own units, like the GUI) gets
1459 /// by never calling this.
1460 ///
1461 /// Cheap to call every frame with the same map: only a *change* invalidates
1462 /// the built map (and the block-row cache, since a height isn't part of a
1463 /// block's bytes and so wouldn't otherwise re-render it). Steady state is a
1464 /// no-op, so a frontend can just hand over its current measurements each frame.
1465 pub fn set_media_rows(&mut self, rows: HashMap<String, usize>) {
1466 if self.surface.media_rows == rows {
1467 return;
1468 }
1469 self.surface.media_rows = rows;
1470 self.surface_changed();
1471 }
1472
1473 /// Tell the model how many visual rows each leaf directive should reserve,
1474 /// keyed by its [`DirectiveKey`](wysiwyg::DirectiveKey) — its name, label
1475 /// and attributes, as [`DirectiveInfo::key`](wysiwyg::DirectiveInfo::key)
1476 /// hands it over. The peer of [`set_media_rows`](Self::set_media_rows) for
1477 /// a terminal host that draws a directive as lines of its own: it asks its
1478 /// renderer for the lines, counts them, and reports back, and the next
1479 /// build lays the placeholder out that tall — the label row plus blank
1480 /// filler rows the host's lines are drawn over, holding no caret. A
1481 /// directive left out of the map is the one-row placeholder, which is what
1482 /// a host with no drawing for it, or a frontend laying directives out in
1483 /// its own units, gets by never calling this.
1484 ///
1485 /// Keyed by what the directive says rather than where it stands, as a
1486 /// picture is keyed by its destination, so the height holds across an
1487 /// edit above it and two directives spelled alike share one entry. Cheap
1488 /// to call every frame with the same map: only a change drops the caches.
1489 pub fn set_directive_rows(&mut self, rows: HashMap<wysiwyg::DirectiveKey, usize>) {
1490 if self.surface.directive_rows == rows {
1491 return;
1492 }
1493 self.surface.directive_rows = rows;
1494 self.surface_changed();
1495 }
1496
1497 /// Tell the model how many visual rows each display formula should
1498 /// reserve, keyed by the formula's TeX exactly as the map's
1499 /// [`MathInfo::tex`](wysiwyg::MathInfo::tex) handed it over. The peer of
1500 /// [`set_media_rows`](Self::set_media_rows) for the terminal, which
1501 /// typesets the picture, measures it in cells, and reports back; a
1502 /// frontend that lays formulas out in pixels never calls this and gets
1503 /// the one-row placeholder to paint over.
1504 pub fn set_math_rows(&mut self, rows: HashMap<String, usize>) {
1505 if self.surface.math_rows == rows {
1506 return;
1507 }
1508 self.surface.math_rows = rows;
1509 self.surface_changed();
1510 }
1511
1512 /// Tell the model whether the frontend can paint a picture *inside* a line
1513 /// of text. When it can, an inline formula renders to one atom glyph the
1514 /// frontend draws its typeset picture over — see
1515 /// [`MathInfo`](wysiwyg::MathInfo) — and when it cannot (a terminal), to
1516 /// the code-styled TeX it always showed. Off until a frontend says
1517 /// otherwise, so a host that has not caught up sees what it saw.
1518 pub fn set_inline_pictures(&mut self, on: bool) {
1519 if self.surface.inline_pictures == on {
1520 return;
1521 }
1522 self.surface.inline_pictures = on;
1523 self.surface_changed();
1524 }
1525
1526 /// A height or a capability lives outside a block's source bytes, so the
1527 /// content-keyed block cache would hand back the old rows on a hit. Drop
1528 /// it (and the splice layout it carries) so the next build re-renders
1529 /// every block against the new surface, and force that build by clearing
1530 /// the map key.
1531 fn surface_changed(&mut self) {
1532 self.block_cache = wysiwyg::BlockCache::default();
1533 self.vmap_key = None;
1534 }
1535
1536 /// The revision the document's text is at — bumped by every edit, undo,
1537 /// redo, and reload, and by nothing else. A frontend caches against this to
1538 /// tell a repaint that needs new work from one that doesn't.
1539 ///
1540 /// It counts *edits*, not distinct texts: typing `x` and deleting it again
1541 /// lands on the same text two revisions later. Work is only ever rebuilt
1542 /// needlessly, never wrongly reused.
1543 pub fn revision(&self) -> u64 {
1544 self.revision
1545 }
1546
1547 /// The identity of the map presently in [`vmap`](Self::vmap) — what the last
1548 /// [`build_visual`](Self::build_visual) built it from, or the identity of an
1549 /// unbuilt map before the first one.
1550 ///
1551 /// This is *not* [`revision`](Self::revision). The revision says where the
1552 /// text is; this says where the map is, and the two part company the moment
1553 /// an edit lands, until something rebuilds. A frontend that keeps its own
1554 /// copy of the map — leaf-ratatui stashes core's before splicing filler rows
1555 /// under an oversized heading — compares this against the value it held when
1556 /// it took the copy, and learns whether `vmap` is still the map it stashed
1557 /// or one somebody else has since rebuilt. Restoring a copy over a newer
1558 /// map would paint a stale document; restoring nothing hands core's
1559 /// incremental rebuild a map it never built.
1560 ///
1561 /// "Somebody else" includes another document. The key names the `Doc`
1562 /// as well as the build, so a frontend that draws two documents through
1563 /// one stash — a host with several buffers, or one that opens the next
1564 /// document where the last one stood — never has the copy it took of one
1565 /// accepted by the other, however alike their builds are.
1566 pub fn visual_key(&self) -> VisualKey {
1567 VisualKey(self.identity, self.vmap_key.clone())
1568 }
1569
1570 /// The map, built at most once per `(revision, wrap)`. `clamp_caret` still
1571 /// runs on every call: the caret moves without the document changing, and
1572 /// keeping it on a legal stop is this function's job either way.
1573 fn build_map(&mut self, wrap: Option<usize>) {
1574 // Under `MarkupMode::Full` the map is a function of the caret's *line*
1575 // as well as the text, so the line joins the key: moving within a line
1576 // still reuses the map, and crossing into another one rebuilds it. In
1577 // every other mode `reveal_line` is `None` and the key is what it was,
1578 // so caret motion goes on costing nothing.
1579 let reveal = self.reveal_line();
1580 let key = (self.revision, wrap, reveal.clone());
1581 if self.vmap_key.as_ref() != Some(&key) {
1582 self.build_map_with(wrap, reveal);
1583 self.vmap_key = Some(key);
1584 // In a hidden mode the reveal line was decided from the *previous*
1585 // build's layout, whose spans are stale across an edit: the
1586 // keystroke that closes a new `$…$` on the caret's line asked "is
1587 // there math here?" of a layout that had none, and the formula
1588 // would snap to its picture under the caret until the next
1589 // motion. Ask again of the layout just built, and go once more if
1590 // the answer moved. Between edits the first answer is exact and
1591 // this is one comparison.
1592 let again = self.reveal_line();
1593 if again != self.vmap_key.as_ref().and_then(|k| k.2.clone()) {
1594 self.build_map_with(wrap, again.clone());
1595 self.vmap_key = Some((self.revision, wrap, again));
1596 }
1597 }
1598 self.clamp_caret();
1599 }
1600
1601 /// One build of the map at `wrap` under `reveal`, incremental where it can
1602 /// be — the body of [`build_map`](Self::build_map), which decides whether
1603 /// to call it.
1604 fn build_map_with(&mut self, wrap: Option<usize>, reveal: Option<Reveal>) {
1605 {
1606 // Enumerate the top-level blocks cheaply — no whole-arena marshal.
1607 // A subtree is pulled only for the block(s) that actually changed, so
1608 // the FFI marshal shrinks from O(document) to O(edited block).
1609 let top = self.top_blocks();
1610
1611 // Fast path: when twig reports a dirty byte range, try to patch the
1612 // previous map in place — a single-block edit moves the prefix,
1613 // shifts the suffix, and re-renders only one block. `build_spliced`
1614 // returns `None` (and we fall back to the always-correct full rebuild)
1615 // whenever the edit reshaped the block structure, hit a table, or
1616 // there's no previous map to patch.
1617 // Preserve soft breaks as written when the flow preference asks for
1618 // it — the builder renders each as its own visual row instead of
1619 // folding it into the reflowed paragraph.
1620 let preserve_soft = self.line_flow == LineFlow::Preserve;
1621 let spliced = match self.editor.dirty_range() {
1622 Some(dirty) => {
1623 let prev = std::mem::take(&mut self.vmap);
1624 let source = &self.source;
1625 let cache = &mut self.block_cache;
1626 let surface = &self.surface;
1627 let editor = &mut self.editor;
1628 wysiwyg::build_spliced(
1629 prev,
1630 source,
1631 wrap,
1632 preserve_soft,
1633 &top,
1634 dirty,
1635 surface,
1636 reveal.clone(),
1637 cache,
1638 |id| editor.subtree(NodeId(id)).unwrap_or_default(),
1639 )
1640 }
1641 None => None,
1642 };
1643 self.vmap = spliced.unwrap_or_else(|| {
1644 let source = &self.source;
1645 let cache = &mut self.block_cache;
1646 let surface = &self.surface;
1647 let editor = &mut self.editor;
1648 wysiwyg::build_cached(
1649 &top,
1650 source,
1651 wrap,
1652 preserve_soft,
1653 surface,
1654 reveal,
1655 cache,
1656 |id| editor.subtree(NodeId(id)).unwrap_or_default(),
1657 )
1658 });
1659 // Acknowledge the dirty range so the next edit's range starts fresh.
1660 self.editor.clear_dirty();
1661 }
1662 }
1663
1664 fn nodes(&mut self) -> Vec<FlatNode> {
1665 self.editor.nodes().unwrap_or_default()
1666 }
1667
1668 /// The document's top-level blocks for the incremental render. See
1669 /// [`wysiwyg::top_blocks`] for why this isn't simply `child_spans(None)`.
1670 fn top_blocks(&mut self) -> Vec<QueryMatch> {
1671 wysiwyg::top_blocks(&mut self.editor)
1672 }
1673
1674 pub fn format_name(&self) -> &'static str {
1675 // `Format` is `#[non_exhaustive]` as of twig 3.0, so the wildcard is
1676 // required. It also covers `Asciidoc`, which twig parses but cannot
1677 // serialize — leaf never opens a document in it (see `Doc::open`).
1678 match self.format {
1679 Format::Djot => "djot",
1680 Format::Markdown => "markdown",
1681 Format::Xml => "xml",
1682 Format::Html => "html",
1683 _ => "unknown",
1684 }
1685 }
1686
1687 /// Whether this document's format offers *any* door in — `false` only for a
1688 /// wholly parse-only format (XML, AsciiDoc), where every gesture refuses and
1689 /// a frontend may as well open the file read-only.
1690 ///
1691 /// This is a much weaker claim than the name suggests, and driving per-button
1692 /// state from it is exactly the mistake to avoid: HTML answers `true` because
1693 /// it spells the inline marks with a tag pair (`<strong>`, `<em>`, `<code>`)
1694 /// while a heading, a quote, a list, a task box, a link and a code fence all
1695 /// remain unspellable there. Ask [`capabilities`](Self::capabilities) — or
1696 /// [`supports`](Self::supports) — per control.
1697 pub fn authorable(&self) -> bool {
1698 self.format.is_authorable()
1699 }
1700
1701 /// Whether this document can spell `gesture`, which is twig's own answer
1702 /// rather than a copy of it: `Format::supports_with` reads the same
1703 /// `Syntax` table the `Editor` method consults before refusing, chosen by
1704 /// the very [`parse_extensions`] this document's editor reparses with — so
1705 /// what the toolbar offers and what the splice will accept are one table.
1706 ///
1707 /// It is a fact about the *document*, not about the caret. `true` does not
1708 /// promise the gesture succeeds where it is standing — a link over a table
1709 /// border still fails — only that it will not fail with
1710 /// `UnsupportedFormat`. Gray out on `false`; don't read `true` as "this
1711 /// will work here".
1712 pub fn supports(&self, gesture: Gesture) -> bool {
1713 self.format.supports_with(parse_extensions(), gesture)
1714 }
1715
1716 /// Every control's enabled state in one read — what a toolbar builds itself
1717 /// from when a document opens or its format changes. See [`Capabilities`].
1718 pub fn capabilities(&self) -> Capabilities {
1719 Capabilities::of(self.format)
1720 }
1721
1722 /// Refuse a gesture this document's format cannot spell, saying so in the
1723 /// status line. `true` means the caller must return without calling twig.
1724 ///
1725 /// Most of these refusals duplicate one twig would make anyway, and they are
1726 /// made here regardless because a message naming the *document's* format
1727 /// reads better than one naming twig's internals. Two of them are not
1728 /// duplicates and are the reason this is a guard rather than an error
1729 /// translation:
1730 ///
1731 /// - The table family (see [`table_op`](Self::table_op)) consults no
1732 /// `Syntax` table, so twig does not refuse it at all.
1733 /// - [`toggle`](Self::toggle) at a collapsed caret never reaches twig — it
1734 /// arms a sticky mark for text not yet typed, which is a promise `insert`
1735 /// could not keep.
1736 fn refuse_unsupported(&mut self, what: &str, gesture: Gesture) -> bool {
1737 self.refuse_unless(what, self.supports(gesture))
1738 }
1739
1740 /// [`refuse_unsupported`](Self::refuse_unsupported) against a capability leaf
1741 /// answers itself — today only [`spells_pipe_tables`].
1742 fn refuse_unless(&mut self, what: &str, supported: bool) -> bool {
1743 if supported {
1744 return false;
1745 }
1746 self.status = Some(format!("{what}: not supported in {}", self.format_name()));
1747 true
1748 }
1749
1750 /// The name to show for this document. An untitled one has no file to name
1751 /// it, and both frontends put this straight on screen — an empty path
1752 /// renders as an empty header, so it says so instead.
1753 pub fn file_name(&self) -> String {
1754 if self.is_untitled() {
1755 return "untitled".into();
1756 }
1757 self.path
1758 .file_name()
1759 .map(|s| s.to_string_lossy().into_owned())
1760 .unwrap_or_else(|| self.path.display().to_string())
1761 }
1762
1763 /// The selection as an ordered `[start, end)` byte range, or `None` when the
1764 /// caret and anchor coincide (an empty selection is no selection).
1765 pub fn selection(&self) -> Option<(usize, usize)> {
1766 self.anchor
1767 .map(|a| (a.min(self.caret), a.max(self.caret)))
1768 .filter(|(s, e)| s != e)
1769 }
1770
1771 /// The selected text, or `None` when there's no selection — the source
1772 /// slice a copy/cut hands to the system clipboard.
1773 pub fn selected_text(&self) -> Option<&str> {
1774 self.selection().map(|(s, e)| &self.source[s..e])
1775 }
1776
1777 /// The selection as a quote with a little of what surrounds it — the shape
1778 /// a host that cites, annotates, or searches for a passage wants, cut from
1779 /// the **source** rather than from anything rendered, so the quote is
1780 /// findable in the document again by plain string search.
1781 ///
1782 /// `context` is a count of characters (not bytes) on each side, clipped at
1783 /// the document's edges; the slices land on char boundaries by
1784 /// construction. `None` when nothing is selected.
1785 pub fn selection_quote(&self, context: usize) -> Option<Quote> {
1786 let (start, end) = self.selection()?;
1787 let mut before = start;
1788 for _ in 0..context {
1789 match self.source[..before].chars().next_back() {
1790 Some(c) => before -= c.len_utf8(),
1791 None => break,
1792 }
1793 }
1794 let mut after = end;
1795 for _ in 0..context {
1796 match self.source[after..].chars().next() {
1797 Some(c) => after += c.len_utf8(),
1798 None => break,
1799 }
1800 }
1801 Some(Quote {
1802 exact: self.source[start..end].to_string(),
1803 prefix: self.source[before..start].to_string(),
1804 suffix: self.source[end..after].to_string(),
1805 start,
1806 end,
1807 })
1808 }
1809
1810 /// Words, characters, and paragraphs over the whole document — the numbers
1811 /// a status bar or an inspector puts next to a piece of writing.
1812 ///
1813 /// Counted over the text a **reader** sees, not the markup that spells it:
1814 /// `**bold**` is one word and four characters, a link is its label and not
1815 /// its destination, a block picture's `🖼 alt` placeholder is a picture and
1816 /// counts nothing, and leading frontmatter — which the WYSIWYG view does
1817 /// not render at all — is not writing. [`crate::counts`] states the rules
1818 /// in full; [`TextCounts`] states them per field.
1819 ///
1820 /// The same numbers in both views. They have to be: a word count that fell
1821 /// when you pressed ⌘E would be telling you the view had changed, which
1822 /// you knew already. So this reads neither [`Doc::view`] nor the map the
1823 /// frontend last built — it renders the source afresh, unwrapped, with
1824 /// soft breaks folded and no line revealed, and counts that. A narrower
1825 /// window, a different [`MarkupMode`], a different [`LineFlow`], and the
1826 /// source view all give the identical answer, because none of them is an
1827 /// input.
1828 ///
1829 /// That costs a reparse and an unwrapped layout — O(document), about 4 ms
1830 /// on a 45 KB file in release and 36 ms on half a megabyte. Fine on a
1831 /// settle and wrong in a paint loop, so a frontend should ask when the
1832 /// typing stops rather than once a keystroke. Caching it against
1833 /// [`revision`](Self::revision) is the obvious next move if that is ever
1834 /// not enough; nothing has needed it yet.
1835 pub fn counts(&self) -> TextCounts {
1836 self.count_over(None)
1837 }
1838
1839 /// The same statistics over the selection alone — `None` when nothing is
1840 /// selected, since an empty selection is no selection.
1841 ///
1842 /// Same rules, over the same rendering, narrowed to the glyphs whose
1843 /// source byte falls inside [`selection`](Self::selection)'s range. A
1844 /// block the selection only clips still counts as one paragraph, and one
1845 /// it enters without catching a visible character counts as none — a
1846 /// selection that starts on a hidden `**` gains no paragraph from it.
1847 pub fn selection_counts(&self) -> Option<TextCounts> {
1848 let (start, end) = self.selection()?;
1849 Some(self.count_over(Some(start..end)))
1850 }
1851
1852 /// The rendering both counters tally, and the tally itself.
1853 ///
1854 /// A fresh parse rather than `self.editor`, because these take `&self` and
1855 /// twig's arena is reached through `&mut`. A document that will not
1856 /// reparse is a "cannot happen" — the source came out of an editor that
1857 /// had already accepted it — and answers zero rather than panicking in
1858 /// what is very likely a paint path.
1859 fn count_over(&self, range: Option<Range<usize>>) -> TextCounts {
1860 let Ok(mut editor) = new_editor(self.source.as_bytes(), self.format) else {
1861 return TextCounts::default();
1862 };
1863 let Ok(nodes) = editor.nodes() else {
1864 return TextCounts::default();
1865 };
1866 // A surface that paints pictures in a line, so an inline formula is
1867 // an atom here and never its TeX: a formula is a picture to a reader
1868 // whichever way it is written, and the count says so consistently.
1869 let surface = wysiwyg::Surface {
1870 inline_pictures: true,
1871 ..Default::default()
1872 };
1873 let map = wysiwyg::build(&nodes, &self.source, None, false, &surface, None);
1874 counts::tally(&map, range)
1875 }
1876
1877 /// Whether the document refuses to change — see the field.
1878 pub fn read_only(&self) -> bool {
1879 self.read_only
1880 }
1881
1882 /// Turn the read-only gate on or off. A frontend preference like
1883 /// [`set_markup_mode`](Self::set_markup_mode): nothing about the document
1884 /// itself changes, only what may be done to it from here on.
1885 pub fn set_read_only(&mut self, on: bool) {
1886 self.read_only = on;
1887 }
1888
1889 /// The host-painted ranges, sorted by start — see [`Highlight`].
1890 pub fn highlights(&self) -> &[Highlight] {
1891 &self.highlights
1892 }
1893
1894 /// Replace the host-painted ranges wholesale. The whole set each time,
1895 /// rather than add/remove verbs: the host owns the list (it derives it
1896 /// from its own state — annotations, search hits), and a replace can
1897 /// never leave the two disagreeing about what should be on screen.
1898 pub fn set_highlights(&mut self, mut highlights: Vec<Highlight>) {
1899 highlights.retain(|h| h.start < h.end);
1900 highlights.sort_by_key(|h| (h.start, h.end));
1901 self.highlights = highlights;
1902 }
1903
1904 /// The highlight covering source `offset`, if one does — first by start
1905 /// when several overlap, which makes overlapping washes resolvable rather
1906 /// than undefined. What a frontend asks when the reader activates a spot.
1907 ///
1908 /// [`Highlight::covering`] is the whole of it: the frontends paint by
1909 /// asking the same question per glyph, against a slice they were handed
1910 /// rather than against a `Doc`, and one answer for both is what keeps a
1911 /// wash and an activation agreeing about which range a spot is in.
1912 pub fn highlight_at(&self, offset: usize) -> Option<&Highlight> {
1913 Highlight::covering(&self.highlights, offset)
1914 }
1915
1916 /// The AST breadcrumb at the caret (root → deepest), e.g.
1917 /// `doc › para › strong`. Read live from twig via `ancestors_at`.
1918 pub fn breadcrumb(&mut self) -> String {
1919 match self.editor.ancestors_at(self.caret) {
1920 Ok(chain) => chain
1921 .iter()
1922 .map(|m| m.kind.as_str())
1923 .collect::<Vec<_>>()
1924 .join(" › "),
1925 Err(_) => String::new(),
1926 }
1927 }
1928
1929 // ── editing ──────────────────────────────────────────────────────────────
1930
1931 /// Replace the byte range `[start, end)` with `text`, re-anchoring the caret
1932 /// after it. The public form of the internal splice — a pixel frontend that
1933 /// hit-tests to a byte offset (or an IME that hands back an explicit range)
1934 /// edits through this, the same twig `edit_range` the caret ops use.
1935 pub fn edit(&mut self, start: usize, end: usize, text: &str) {
1936 self.splice(start, end, text, EditKind::Other);
1937 }
1938
1939 /// Replace `[start, end)` with `text` in place, behind the caret — an
1940 /// automatic substitution a host makes as the user types: a misspelling
1941 /// corrected, a text replacement expanded, a straight quote curled, a `--`
1942 /// made a dash. Whether it did: `false` for a read-only document, a range
1943 /// that is not one, or an edit twig refused.
1944 ///
1945 /// Unlike [`edit`](Self::edit), the caret and the selection stay where they
1946 /// were, moved along by the edit — a word corrected behind the caret does
1947 /// not pull the caret back to it — and so does a sticky mark armed at the
1948 /// caret. It is an undo step of its own, folded into neither the typing
1949 /// before it nor the typing after, so one undo puts back exactly what was
1950 /// typed, with the caret where it stood. The bytes are replaced exactly,
1951 /// snapping neither end, so a word at the edge of `**bold**` keeps its
1952 /// delimiters. `text` is written the way typing writes it: literally, with
1953 /// anything that would open markup escaped, where typing is literal
1954 /// ([`MarkupMode::None`] in the rendered view).
1955 pub fn substitute(&mut self, start: usize, end: usize, text: &str) -> bool {
1956 if self.read_only
1957 || start > end
1958 || end > self.source.len()
1959 || !self.source.is_char_boundary(start)
1960 || !self.source.is_char_boundary(end)
1961 {
1962 return false;
1963 }
1964 let before = self.source.len();
1965 let (caret, anchor) = (self.caret, self.anchor);
1966 let (pending_marks, pending_at) = (self.pending_marks, self.pending_at);
1967 let group_had_step = self.undo_group.map(|g| g.has_step);
1968 self.last_edit_kind = None;
1969 let literal = !self.markup_mode.authors()
1970 && self.view == View::Wysiwyg
1971 && !text.is_empty()
1972 && self.supports(Gesture::InsertLiteral);
1973 let landed = if literal {
1974 let deleted = self.source[start..end].to_owned();
1975 if start != end && !self.splice_exact(start, end, "", EditKind::Other) {
1976 false
1977 } else if self.insert_literal_at(start, text, EditKind::Other, start != end) {
1978 true
1979 } else {
1980 // The deletion landed and the text did not: take the deletion
1981 // back rather than leave half a substitution.
1982 if start != end {
1983 self.restore_deleted(start, &deleted, group_had_step);
1984 }
1985 false
1986 }
1987 } else {
1988 self.splice_exact(start, end, text, EditKind::Other)
1989 };
1990 if !landed {
1991 self.caret = caret;
1992 self.anchor = anchor;
1993 self.record_caret();
1994 return false;
1995 }
1996 self.carry_selection(
1997 caret,
1998 anchor,
1999 (pending_marks, pending_at),
2000 start,
2001 end,
2002 before,
2003 );
2004 true
2005 }
2006
2007 /// Bring the source to `text` in one step that leaves the caret where it
2008 /// was — what a host does when the file changed under an open editor, as
2009 /// another device's edit arriving does.
2010 ///
2011 /// Only the span that differs is replaced: the longest prefix and suffix
2012 /// the two share are kept, so a caret or selection outside the change
2013 /// stays on the characters it was on, and one inside it goes to the end
2014 /// of what replaced it, as [`substitute`](Self::substitute) moves them.
2015 /// Unlike a substitution, `text` is source and written exactly — nothing
2016 /// in it is escaped — since it is the document as it now stands, not
2017 /// something typed. An undo step of its own. `true` with nothing done
2018 /// when the source is already `text`; `false` when the document is
2019 /// read-only or the edit was refused.
2020 pub fn replace_source(&mut self, text: &str) -> bool {
2021 if self.source == text {
2022 return true;
2023 }
2024 if self.read_only {
2025 return false;
2026 }
2027 let (start, end, with) = differing_span(&self.source, text);
2028 let with = with.to_owned();
2029 let before = self.source.len();
2030 let (caret, anchor) = (self.caret, self.anchor);
2031 let pending = (self.pending_marks, self.pending_at);
2032 self.last_edit_kind = None;
2033 if !self.splice_exact(start, end, &with, EditKind::Other) {
2034 self.caret = caret;
2035 self.anchor = anchor;
2036 self.record_caret();
2037 return false;
2038 }
2039 self.carry_selection(caret, anchor, pending, start, end, before);
2040 true
2041 }
2042
2043 /// Put the caret and selection back after an edit of `[start, end)` that
2044 /// left them wherever the edit did, moved along by it — see
2045 /// [`substitute`](Self::substitute). `before` is the source's length
2046 /// before the edit; `pending` the sticky marks armed at the old caret.
2047 fn carry_selection(
2048 &mut self,
2049 caret: usize,
2050 anchor: Option<usize>,
2051 pending: (InlineMarks, Option<usize>),
2052 start: usize,
2053 end: usize,
2054 before: usize,
2055 ) {
2056 let (pending_marks, pending_at) = pending;
2057 let delta = self.source.len() as isize - before as isize;
2058 let new_end = (end as isize + delta).max(start as isize) as usize;
2059 // A place after the range moves with it, one inside it goes to the end
2060 // of what replaced it, and one before it stays.
2061 fn shift(p: usize, start: usize, end: usize, new_end: usize, delta: isize) -> usize {
2062 if p >= end {
2063 (p as isize + delta).max(0) as usize
2064 } else if p > start {
2065 new_end
2066 } else {
2067 p
2068 }
2069 }
2070 self.caret = shift(caret, start, end, new_end, delta);
2071 self.anchor = anchor
2072 .map(|a| shift(a, start, end, new_end, delta))
2073 .filter(|&a| a != self.caret);
2074 if pending_at == Some(caret) {
2075 self.pending_marks = pending_marks;
2076 self.pending_at = Some(self.caret);
2077 }
2078 self.goal_col = None;
2079 self.last_edit_kind = None;
2080 self.clamp_caret();
2081 // The caret the step leaves, so a redo puts it back here too.
2082 self.record_caret();
2083 }
2084
2085 /// Put back `deleted`, the bytes a half-made [`substitute`](Self::substitute)
2086 /// took out at `at`, and leave nothing of the pair on the history.
2087 ///
2088 /// Not [`undo`](Self::undo): that closes an open group and takes the whole
2089 /// of it back — every substitution of the keystroke made before this one
2090 /// — and leaves a redo step that would delete the bytes again. Instead the
2091 /// bytes go back as an edit of their own, and the two edits, which change
2092 /// nothing together, are folded away: inside a group that already had a
2093 /// step they fold into it as they land; as a group's first step, or
2094 /// outside a group, into the step before. With no step before it there is
2095 /// nothing to fold into, and one step that changes nothing is left.
2096 /// `group_had_step` is what the group said before the deletion — `None`
2097 /// outside one.
2098 fn restore_deleted(&mut self, at: usize, deleted: &str, group_had_step: Option<bool>) {
2099 if !self.splice_exact(at, at, deleted, EditKind::Other) {
2100 // twig will not take its own bytes back: the history's way, which
2101 // at least leaves the document as it was.
2102 self.undo();
2103 return;
2104 }
2105 match group_had_step {
2106 Some(true) => {}
2107 Some(false) => {
2108 let steps = self.undo_steps;
2109 self.fold_last_undo();
2110 if self.undo_steps < steps
2111 && let Some(g) = &mut self.undo_group
2112 {
2113 g.has_step = false;
2114 }
2115 }
2116 None => {
2117 self.fold_last_undo();
2118 self.fold_last_undo();
2119 }
2120 }
2121 self.last_edit_kind = None;
2122 }
2123
2124 /// Insert typed `text` at the caret, replacing the selection if there is one.
2125 /// A single typed character coalesces with the run of typing before it; a
2126 /// newline or a multi-character insert is its own undo step.
2127 ///
2128 /// Typed input only — clipboard text goes through [`paste`](Self::paste).
2129 pub fn insert(&mut self, text: &str) {
2130 // The read-only gate, up front: the paths below reach twig by several
2131 // verbs, not all of them through the splice — see the field.
2132 if self.read_only {
2133 return;
2134 }
2135 // Typing against a block picture would dissolve it, and typing past a
2136 // table would grow it a row — see `open_paragraph_at_block_edge`. Give
2137 // the text a paragraph first, so what the caret was standing beside
2138 // stays what it was.
2139 self.open_paragraph_at_block_edge(text);
2140 // Armed sticky marks (⌘b with no selection) turn the next typed text
2141 // bold/italic/… and then retire — see `insert_with_marks`. Whitespace is
2142 // the exception: it takes no mark of its own and keeps the delta armed
2143 // for the character behind it — see `insert_space_with_marks`.
2144 let pending = self.pending_here();
2145 if !pending.is_empty() && self.selection().is_none() && !text.is_empty() {
2146 if text.trim().is_empty() {
2147 self.insert_space_with_marks(self.caret, text, pending);
2148 } else {
2149 self.insert_with_marks(self.caret, text, pending);
2150 }
2151 return;
2152 }
2153 // `MarkupMode::None`: typed syntax stays literal — twig escapes
2154 // anything that would open markup, so a Diaryx user never mints
2155 // formatting by keyboard (it comes from commands instead). The other two
2156 // rungs of the ladder author markup from what you type, which is the
2157 // whole difference between them and this one. Only in the rendered view
2158 // (source view is for typing raw markup) and only where the format has a
2159 // literal spelling at all: escaping is a backslash before a byte from the
2160 // format's own alphabet, and a format with no such alphabet (HTML escapes
2161 // with entities, XML spells nothing) would have `\&` written into it,
2162 // which is two literal characters and not an escape. Marks (⌘b) still
2163 // format — that path returned above; and leaf's own structural inserts go
2164 // through `insert_raw`, never here, so a list marker or quote gutter is
2165 // written as the markup it is.
2166 if !self.markup_mode.authors()
2167 && self.view == View::Wysiwyg
2168 && !text.is_empty()
2169 && self.supports(Gesture::InsertLiteral)
2170 {
2171 self.insert_literal_typed(text);
2172 return;
2173 }
2174 self.insert_raw(text);
2175 }
2176
2177 /// Insert `text` verbatim at the caret (replacing any selection) — the plain
2178 /// path with no Hidden-mode literal escaping. leaf's own structural inserts
2179 /// (a list marker, a quote gutter, an in-cell `<br>`) call this: they ARE
2180 /// markup by design and must not be escaped.
2181 fn insert_raw(&mut self, text: &str) {
2182 let (s, e) = self.selection().unwrap_or((self.caret, self.caret));
2183 self.splice(s, e, text, typed_edit_kind(text));
2184 }
2185
2186 /// Open a paragraph for text about to be inserted at one of a block media's
2187 /// two caret stops, or at a table's trailing stop, and leave the caret
2188 /// standing in it.
2189 ///
2190 /// A block image is a paragraph whose entire content is the picture, and the
2191 /// caret's only homes on it are in front of it and just past it (see
2192 /// [`VisualMap::block_media_stop`]). Text inserted at either offset joins
2193 /// *that* paragraph — and a paragraph holding anything besides the image is
2194 /// no longer a block image but a line of text with an inline one in it. The
2195 /// frontend that was painting a photo there paints a text run instead; the
2196 /// picture is still in the file, and nothing said a word. Those two offsets
2197 /// are also exactly where a click on the picture lands, so the whole accident
2198 /// is one tap and one keystroke.
2199 ///
2200 /// So the break goes in first and the text lands in the new empty paragraph —
2201 /// what pressing Return before typing would have done, which is a habit no
2202 /// one should have to learn from losing a photo. A no-op everywhere else, and
2203 /// over a selection (which is replaced, not joined into).
2204 ///
2205 /// A picture inside a quote or a list leaves its container, because `\n\n`
2206 /// ends the block. The alternative is worse: the `\n> ` / next-item
2207 /// continuation [`newline`](Self::newline) writes stays in the same
2208 /// *paragraph*, which is the thing being prevented.
2209 ///
2210 /// A table's trailing stop ([`VisualMap::table_end_stop`]) is the same
2211 /// accident from the other side of a different block: the stop sits at the
2212 /// end of the table's last source line, and a line glued under a table is
2213 /// a row of it — `| 1 | 2 |x` is a three-cell row, not a paragraph. So the
2214 /// break goes in there too, and the text lands under the table.
2215 ///
2216 /// A block leaf directive (`::embed{src=x}`, `::page-break`, djot's empty
2217 /// `::: name` fence) is drawn on the picture's recipe and dissolves the
2218 /// same way — `X::embed{src=x}` and `::embed{src=x}X` are paragraphs of
2219 /// raw source — so its two stops ([`VisualMap::block_directive_stop`])
2220 /// open a paragraph too. Two directives in a row with nothing between
2221 /// them get one between them, from either side.
2222 ///
2223 /// Only in the rendered view. Source view is for typing raw markup, where
2224 /// putting a character against an image is exactly what it looks like.
2225 fn open_paragraph_at_block_edge(&mut self, text: &str) {
2226 if self.view != View::Wysiwyg || text.is_empty() || text == "\n" {
2227 return;
2228 }
2229 if self.selection().is_some() {
2230 return;
2231 }
2232 // The map may be a revision behind (nothing has drawn since the last
2233 // edit), and this asks it about offsets — a stale answer would splice a
2234 // break into the wrong place. Free when it is already current, which it
2235 // is whenever a frontend drew a frame between keystrokes.
2236 self.rebuild_map();
2237 let at = self.caret;
2238 let (side, span) = match self.block_atom_stop(at) {
2239 Some(stop) => stop,
2240 None if self.vmap.table_end_stop(at) => (MediaStop::After, at..at),
2241 None => return,
2242 };
2243 // In front of the block, the break goes in front of all of it — a
2244 // djot `{…}` line above it included, or the typed text would take
2245 // the block's attributes.
2246 let at = match side {
2247 MediaStop::Before => span.start,
2248 MediaStop::After => at,
2249 };
2250 if !self.splice(at, at, "\n\n", EditKind::Other) {
2251 return;
2252 }
2253 // The break is part of the keystroke, not an edit of its own: leave the
2254 // run marked as typing so the character about to arrive folds into it and
2255 // one undo puts the document back the way it was found. (A paste, or a
2256 // multi-character insert, is `EditKind::Other` and stays its own step —
2257 // as it would have been anywhere else in the document.)
2258 self.last_edit_kind = Some(EditKind::Insert);
2259 if side == MediaStop::Before {
2260 // The break went in above the picture and the caret rode to the end
2261 // of it — which is still hard against the picture. Step back onto the
2262 // blank line it opened, so the text lands above rather than in front.
2263 self.caret = at;
2264 }
2265 }
2266
2267 /// Which of a block atom's two caret stops `at` is — a block picture's
2268 /// ([`VisualMap::block_media_stop`]) or a block leaf directive's
2269 /// ([`VisualMap::block_directive_stop`]) — with the span that is the atom
2270 /// whole. In djot that span reaches back over a `{…}` attribute line
2271 /// written above the block: the line is the block's markup, drawn as
2272 /// nothing, and a paragraph opened between it and the block would take
2273 /// the attributes, as a delete that left it would hand them to the block
2274 /// below.
2275 ///
2276 /// Reads the map, so the caller rebuilds it first.
2277 fn block_atom_stop(&mut self, at: usize) -> Option<(MediaStop, Range<usize>)> {
2278 let (side, mut span) = self
2279 .vmap
2280 .block_media_stop(at)
2281 .or_else(|| self.vmap.block_directive_stop(at))?;
2282 if self.format == Format::Djot {
2283 let starts_here: Vec<NodeId> = self
2284 .nodes()
2285 .into_iter()
2286 .filter(|n| n.span.start == span.start)
2287 .map(|n| n.id)
2288 .collect();
2289 let attrs = self.editor.document().ok().and_then(|mut d| {
2290 starts_here
2291 .into_iter()
2292 .find_map(|id| d.attrs_span(id).ok().flatten())
2293 });
2294 if let Some(a) = attrs.filter(|a| a.end <= span.start) {
2295 span.start = a.start;
2296 }
2297 }
2298 Some((side, span))
2299 }
2300
2301 /// A delete key pressed at one of a block picture's or a block leaf
2302 /// directive's two caret stops, handled as the block being an *atom* rather
2303 /// than a run of bytes. Returns whether the key was consumed.
2304 ///
2305 /// The caret rests in front of a block image and just past it, never inside
2306 /// its markup — which the rendered view doesn't show. So the byte a delete
2307 /// key nominally takes there is one the writer cannot see, and taking it
2308 /// leaves the picture as broken markup rather than as anything anyone asked
2309 /// for: Backspace at the stop past `` removes the closing paren, and
2310 /// a photo becomes the literal text `
2313 /// prevents from the typing side, and it cost this repository's own test vault
2314 /// a photo before it was found.
2315 ///
2316 /// So the key aimed *at* the picture deletes the picture, whole — Backspace
2317 /// when it is behind the caret, Delete when it is in front — which is what
2318 /// every editor does with an embed, and one undo away. The key aimed *away*
2319 /// from it would otherwise delete the paragraph break and merge a neighbour
2320 /// into the picture's own paragraph, which dissolves it just as surely; it
2321 /// steps the caret over the boundary instead and leaves the
2322 /// next press to delete in the block it has reached — the same "first press
2323 /// steps out of the atom, second press deletes" every delete key here gets,
2324 /// word-deletes included (⌥⌫ in front of a picture is aimed at the prose
2325 /// above, and reaches it on the second press rather than taking the break and
2326 /// the picture with it on the first).
2327 fn delete_around_block_atom(&mut self, forward: bool) -> bool {
2328 // The map answers about offsets, so it has to be this revision's — see
2329 // the same call in `open_paragraph_at_block_edge`.
2330 self.rebuild_map();
2331 let Some((side, span)) = self.block_atom_stop(self.caret) else {
2332 return false;
2333 };
2334 let aimed_at_it = side
2335 == if forward {
2336 MediaStop::Before
2337 } else {
2338 MediaStop::After
2339 };
2340 if !aimed_at_it {
2341 let over = if forward {
2342 self.vmap.stop_after(self.caret)
2343 } else {
2344 self.vmap.stop_before(self.caret)
2345 };
2346 if let Some(off) = over.filter(|&o| o >= self.caret_floor()) {
2347 self.caret = off;
2348 self.anchor = None;
2349 self.goal_col = None;
2350 }
2351 return true;
2352 }
2353 // Take the break that held the picture apart from its neighbour with it,
2354 // so the delete doesn't leave a blank paragraph standing where the
2355 // picture was. The last arm is a picture that is the whole document.
2356 let (from, to) = if self.source[..span.start].ends_with("\n\n") {
2357 (span.start - 2, span.end)
2358 } else if self.source[span.end..].starts_with("\n\n") {
2359 (span.start, span.end + 2)
2360 } else {
2361 (span.start, span.end)
2362 };
2363 self.splice(from.max(self.caret_floor()), to, "", EditKind::Other);
2364 true
2365 }
2366
2367 /// The Hidden-mode typing path: replace any selection, then insert `text`
2368 /// escaped so it stays literal. When it replaces a selection the two edits
2369 /// fold into one undo step, so an overwrite undoes atomically (and restores
2370 /// the selection) exactly as a plain one does.
2371 fn insert_literal_typed(&mut self, text: &str) {
2372 let kind = typed_edit_kind(text);
2373 match self.selection() {
2374 Some((s, e)) => {
2375 if !self.splice(s, e, "", EditKind::Other) {
2376 return;
2377 }
2378 // Typing over a whole marked run takes its delimiters with it
2379 // (the empty content couldn't hold them — see
2380 // `repair_mark_edges`) and leaves its marks armed at the caret.
2381 // The text taking the run's place inherits them, exactly as it
2382 // would have by landing inside a run that survived.
2383 let pending = self.pending_here();
2384 if !pending.is_empty() && !text.trim().is_empty() {
2385 self.insert_with_marks(self.caret, text, pending);
2386 return;
2387 }
2388 self.insert_literal_at(self.caret, text, kind, true);
2389 }
2390 None => {
2391 self.insert_literal_at(self.caret, text, kind, false);
2392 }
2393 }
2394 }
2395
2396 /// The sticky-mark delta that is live right now: the marks armed by [`toggle`]
2397 /// at a collapsed caret, but only while the caret still stands where they
2398 /// were armed and nothing is selected. Empty otherwise, so a stale delta
2399 /// never styles text it wasn't meant for.
2400 fn pending_here(&self) -> InlineMarks {
2401 if self.anchor.is_none() && self.pending_at == Some(self.caret) {
2402 self.pending_marks
2403 } else {
2404 InlineMarks::empty()
2405 }
2406 }
2407
2408 /// Drop the armed sticky marks — any caret motion, selection, or edit does
2409 /// this, so "start bold here" only ever applies at the exact spot it was
2410 /// asked for.
2411 fn clear_pending(&mut self) {
2412 self.pending_marks = InlineMarks::empty();
2413 self.pending_at = None;
2414 }
2415
2416 /// Insert `text` at `at` carrying the armed sticky `marks`: a mark not yet in
2417 /// force is wrapped around the freshly typed text; a mark the caret already
2418 /// stands inside is *shed* — the text is inserted past the run's end so it
2419 /// lands unmarked ("type normally again"). The caret comes to rest inside any
2420 /// added runs, so continued typing inherits the marks with no re-wrapping,
2421 /// and the delta is cleared: the marks now live in the document, not here.
2422 fn insert_with_marks(&mut self, at: usize, text: &str, marks: InlineMarks) {
2423 let base = self.mark_spans_at(at);
2424 let base_set: InlineMarks = base.iter().map(|(k, _)| *k).collect();
2425 // Nothing to shed, and a run of exactly these marks standing just behind
2426 // the caret: carry on writing *that* run rather than opening a second
2427 // one beside it.
2428 if base_set.is_empty() && self.rejoin_run(at, text, marks) {
2429 return;
2430 }
2431 // Shed the marks we're turning off: step the insertion point past the
2432 // end of each run the caret sits in, so the new text falls outside it.
2433 let mut ins_at = at;
2434 for (kind, span) in &base {
2435 if marks.contains(*kind) {
2436 ins_at = ins_at.max(span.end);
2437 }
2438 }
2439 if !self.splice_exact(ins_at, ins_at, text, EditKind::Other) {
2440 return;
2441 }
2442 // The plain splice inserted exactly `text` at `ins_at`; that byte range
2443 // is the content every added mark wraps.
2444 let (mut cs, mut ce) = (ins_at, ins_at + text.len());
2445 for kind in marks.iter() {
2446 if !base_set.contains(kind) {
2447 let (ncs, nce) = self.wrap_span(cs, ce, kind);
2448 cs = ncs;
2449 ce = nce;
2450 }
2451 }
2452 self.caret = ce.min(self.source.len());
2453 self.anchor = None;
2454 self.last_edit_kind = None;
2455 // Realised: the marks are in the document now, and the caret sits inside
2456 // them, so there is no delta left to carry. Arm nothing, but remember the
2457 // spot so a *further* toggle before typing starts a clean delta here.
2458 self.pending_marks = InlineMarks::empty();
2459 self.pending_at = Some(self.caret);
2460 self.clamp_caret();
2461 self.record_caret();
2462 }
2463
2464 /// Carry on the marked run just behind `at` — moving its closing delimiters
2465 /// out past the new text — instead of opening a second run of the same marks
2466 /// beside it. Returns whether it did.
2467 ///
2468 /// This is the far half of the mark-edge rule (see [`splice`](Self::splice)).
2469 /// A space typed after a bold word steps the caret out of the run, because
2470 /// `**bold **` is not bold; the next character has to step back *in*, or the
2471 /// writer who typed one bold phrase is left with `**bold** **and**` — two
2472 /// runs that read the same to a reader but spell the file in a way nobody
2473 /// wrote. Only whitespace may stand in the gap (a run doesn't reach across
2474 /// words it isn't marking), and the marks behind it must be exactly the ones
2475 /// armed — a run of *some* other kind is a neighbour, not this phrase.
2476 fn rejoin_run(&mut self, at: usize, text: &str, marks: InlineMarks) -> bool {
2477 if text.is_empty() || text.trim() != text {
2478 return false;
2479 }
2480 let gap_at = self.source[..at].trim_end_matches([' ', '\t']).len();
2481 // Walk in through the delimiters stacked at that point, innermost last:
2482 // `***both*** ` closes two runs with one `***`, and rejoining means
2483 // getting behind all of them.
2484 let (mut cut, mut kinds) = (gap_at, InlineMarks::empty());
2485 while let Some((kind, content_end)) = self
2486 .editor
2487 .ancestors_at(prev_boundary(&self.source, cut))
2488 .unwrap_or_default()
2489 .into_iter()
2490 .filter(|m| m.span.end == cut)
2491 .find_map(|m| Some((inline_kind(&m.kind)?, m.content_span.clone()?.end)))
2492 {
2493 if content_end >= cut {
2494 break; // a mark with no closing delimiter to step behind
2495 }
2496 kinds.insert(kind);
2497 cut = content_end;
2498 }
2499 if cut == gap_at || kinds != marks {
2500 return false;
2501 }
2502 // Re-spell the tail: the gap, then the new text, then the delimiters that
2503 // used to close in front of them — read out of the document rather than
2504 // written from a table, so whatever twig spells them with is what moves.
2505 let tail = format!(
2506 "{}{text}{}",
2507 &self.source[gap_at..at],
2508 &self.source[cut..gap_at]
2509 );
2510 if !self.splice_exact(cut, at, &tail, EditKind::Other) {
2511 return false;
2512 }
2513 self.caret = (cut + (at - gap_at) + text.len()).min(self.source.len());
2514 self.anchor = None;
2515 self.last_edit_kind = None;
2516 self.pending_marks = InlineMarks::empty();
2517 self.pending_at = Some(self.caret);
2518 self.clamp_caret();
2519 self.record_caret();
2520 true
2521 }
2522
2523 /// Insert typed whitespace at a caret with sticky marks armed. Whitespace is
2524 /// never itself wrapped: a mark around a space draws nothing a reader can
2525 /// see, and in Markdown and Djot it draws its own delimiters instead
2526 /// (`** **`). So the space goes in unmarked — outside any run the armed
2527 /// marks are shedding — and the marks stay armed for the character after it,
2528 /// which rejoins the run (see [`rejoin_run`](Self::rejoin_run)).
2529 fn insert_space_with_marks(&mut self, at: usize, text: &str, marks: InlineMarks) {
2530 let base = self.mark_spans_at(at);
2531 // What the *next* character carries: the armed delta resolved against the
2532 // marks in force here, which the space must not quietly drop.
2533 let want = base
2534 .iter()
2535 .map(|(k, _)| *k)
2536 .collect::<InlineMarks>()
2537 .xor(marks);
2538 let mut ins_at = at;
2539 for (kind, span) in &base {
2540 if marks.contains(*kind) {
2541 ins_at = ins_at.max(span.end);
2542 }
2543 }
2544 if !self.splice(ins_at, ins_at, text, typed_edit_kind(text)) {
2545 return;
2546 }
2547 self.rearm(want);
2548 self.record_caret();
2549 }
2550
2551 /// Wrap `[s, e)` in `kind` via twig and return the byte span the *content*
2552 /// (not the delimiters) occupies afterwards. Markdown/Djot inline delimiters
2553 /// are symmetric (`**`…`**`, `_`…`_`, `` ` ``…`` ` ``), so the bytes twig
2554 /// added split evenly around the content — half the growth on each side.
2555 fn wrap_span(&mut self, s: usize, e: usize, kind: InlineKind) -> (usize, usize) {
2556 // The read-only gate — this door reaches twig without the splice.
2557 if self.read_only {
2558 return (s, e);
2559 }
2560 match self.editor.toggle_inline(s, e, kind) {
2561 Ok(change) => {
2562 self.last_edit_kind = None;
2563 self.refresh();
2564 self.dirty = self.source != self.clean_source;
2565 let added = (change.new.end - change.new.start).saturating_sub(e - s);
2566 let half = added / 2;
2567 (change.new.start + half, change.new.end - half)
2568 }
2569 // Unsupported here (e.g. mark on Markdown): leave the text unwrapped
2570 // rather than lose the keystroke.
2571 Err(e2) => {
2572 self.status = Some(format!("{kind:?}: {e2}"));
2573 (s, e)
2574 }
2575 }
2576 }
2577
2578 /// The safe offset to splice a block-level break at, given a caret that may
2579 /// sit exactly between an inline mark's content and its own closing
2580 /// delimiter (`content_span.end == off < span.end` for some enclosing mark
2581 /// — the WYSIWYG caret's natural resting place at the end of `**bold**`
2582 /// with nothing following it on the line: the closing `**` renders no
2583 /// glyph of its own, so the caret's "end of line" offset lands right
2584 /// before it). Splicing a paragraph/list/quote break at `off` itself would
2585 /// sever the delimiter from its content, stranding it alone on the new
2586 /// line. Walks out to the *outermost* such mark's `span.end` instead, so
2587 /// nested marks closing at the same point (`**_x_**`) all clear together.
2588 /// A no-op everywhere else — mid-run, or past real trailing content, no
2589 /// mark's `content_span` ends exactly at `off`.
2590 fn skip_trailing_close_delims(&mut self, off: usize) -> usize {
2591 let off = off.min(self.source.len());
2592 let runs = self.run_span_ids();
2593 self.editor
2594 .ancestors_at(off)
2595 .unwrap_or_default()
2596 .into_iter()
2597 .filter(|m| hides_delims(m, &runs))
2598 .filter(|m| off < m.span.end && m.content_span.as_ref().is_some_and(|c| c.end == off))
2599 .map(|m| m.span.end)
2600 .max()
2601 .unwrap_or(off)
2602 }
2603
2604 /// The offset a *delete* aimed at the character before `off` should stop at,
2605 /// when `off` is the start of a run's text and the bytes behind it are that
2606 /// run's opening delimiter. The rich view draws no glyph for a `**`, so the
2607 /// byte behind the caret at the start of a bold word is not a character the
2608 /// writer can see, let alone one they aimed Backspace at: taking it leaves
2609 /// `a *bold** c` — the styling gone and a literal asterisk in its place. The
2610 /// delete steps over the whole delimiter to the visible character in front of
2611 /// it instead. Walks out to the *outermost* mark opening there, so
2612 /// `**_x_**` clears every delimiter at once, and is a no-op anywhere else.
2613 fn skip_leading_open_delims(&mut self, off: usize) -> usize {
2614 let off = off.min(self.source.len());
2615 let runs = self.run_span_ids();
2616 self.editor
2617 .ancestors_at(off)
2618 .unwrap_or_default()
2619 .into_iter()
2620 .filter(|m| hides_delims(m, &runs))
2621 .filter(|m| {
2622 m.span.start < off && m.content_span.as_ref().is_some_and(|c| c.start == off)
2623 })
2624 .map(|m| m.span.start)
2625 .min()
2626 .unwrap_or(off)
2627 }
2628
2629 /// `off` moved *inside* the run whose closing delimiters end there — the
2630 /// other offset the rich view draws in the same place, since a `**` renders
2631 /// no glyph of its own. `**bold**` has a caret home on each side of its
2632 /// closing delimiter, one column apart on screen and eight bytes and a whole
2633 /// run apart in the file, and a plain ← lands on the outer one whenever a
2634 /// space follows the phrase. The inner one is what the writer is pointing at
2635 /// there: the end of their bold word. Walks in through every mark closing at
2636 /// that point, innermost last, so `***both***` lands inside both. A no-op
2637 /// anywhere else — mid-run, or in prose, no mark's span ends at `off`.
2638 fn step_inside_close_delims(&mut self, off: usize) -> usize {
2639 let mut off = off.min(self.source.len());
2640 let runs = self.run_span_ids();
2641 loop {
2642 let inner = self
2643 .editor
2644 .ancestors_at(prev_boundary(&self.source, off))
2645 .unwrap_or_default()
2646 .into_iter()
2647 .filter(|m| hides_delims(m, &runs) && m.span.end == off)
2648 .filter_map(|m| m.content_span.clone().map(|c| c.end))
2649 .filter(|&end| end < off)
2650 .max();
2651 match inner {
2652 Some(end) => off = end,
2653 None => return off,
2654 }
2655 }
2656 }
2657
2658 /// The mirror at the opening edge: `off` moved inside the run whose
2659 /// delimiters *start* there, onto the first character of its text. See
2660 /// [`step_inside_close_delims`](Self::step_inside_close_delims).
2661 fn step_inside_open_delims(&mut self, off: usize) -> usize {
2662 let mut off = off.min(self.source.len());
2663 let runs = self.run_span_ids();
2664 loop {
2665 let inner = self
2666 .editor
2667 .ancestors_at(off)
2668 .unwrap_or_default()
2669 .into_iter()
2670 .filter(|m| hides_delims(m, &runs) && m.span.start == off)
2671 .filter_map(|m| m.content_span.clone().map(|c| c.start))
2672 .filter(|&start| start > off)
2673 .min();
2674 match inner {
2675 Some(start) => off = start,
2676 None => return off,
2677 }
2678 }
2679 }
2680
2681 /// The ids of the document's attributed run spans — the inline
2682 /// `Container`s [`wysiwyg::is_run_span`] picks out — for [`hides_delims`],
2683 /// which sees an ancestor chain and so only a kind. Read once per gesture,
2684 /// not once per step of a walk.
2685 fn run_span_ids(&mut self) -> Vec<NodeId> {
2686 self.nodes()
2687 .iter()
2688 .filter(|n| wysiwyg::is_run_span(n))
2689 .map(|n| n.id)
2690 .collect()
2691 }
2692
2693 /// The attributed span whose text is exactly `content` — the whole of
2694 /// `<span …>i</span>`'s `i`, or nothing at all when `content` is empty
2695 /// and sits between the tags of `<span …></span>` — as the whole range
2696 /// spelling the span: the node's span, widened to its attribute block
2697 /// where the format writes that outside the node, as djot's
2698 /// `[i]{data-size="large"}` does. `None` for any other range, including
2699 /// part of a span's text.
2700 ///
2701 /// An empty span has an interior of no bytes, or no known interior at
2702 /// all: twig gives Markdown's `<span …></span>` the first and djot's
2703 /// `[]{…}` the second, and the chain already says the offset is inside.
2704 fn run_span_of_content(&mut self, content: Range<usize>) -> Option<Range<usize>> {
2705 let runs = self.run_span_ids();
2706 let m = self
2707 .editor
2708 .ancestors_at(content.start)
2709 .unwrap_or_default()
2710 .into_iter()
2711 .filter(|m| runs.contains(&NodeId(m.node_id)))
2712 .find(|m| match &m.content_span {
2713 Some(c) => *c == content,
2714 None => content.is_empty(),
2715 })?;
2716 let mut range = m.span;
2717 if let Some(attrs) = self
2718 .editor
2719 .document()
2720 .ok()
2721 .and_then(|mut d| d.attrs_span(NodeId(m.node_id)).ok().flatten())
2722 {
2723 range.start = range.start.min(attrs.start);
2724 range.end = range.end.max(attrs.end);
2725 }
2726 Some(range)
2727 }
2728
2729 /// The attributed block whose whole text is exactly `content` — the `T`
2730 /// of Markdown's `<div class="center">\n\nT\n\n</div>` or djot's
2731 /// `{.center}\nT` — as the range a delete that takes that text takes with
2732 /// it: the whole `<div>` when the block is all the div holds, or the
2733 /// `{…}` line down to the end of the text. The block version of
2734 /// [`run_span_of_content`](Self::run_span_of_content), for the same
2735 /// reason: a paragraph with no text is no block, so the div would stand
2736 /// around nothing and the `{…}` line above nothing, and a from-scratch
2737 /// map gives neither a caret home — the `T`'s row is gone with the `T`.
2738 /// `None` for a block with more text, a div holding more, a heading (an
2739 /// empty `# ` is still a heading), and a format whose attributes are the
2740 /// block's own tag (HTML's `<p class="center"></p>` is still a
2741 /// paragraph).
2742 fn attributed_block_of_content(&mut self, content: Range<usize>) -> Option<Range<usize>> {
2743 if content.is_empty() || !matches!(self.format, Format::Markdown | Format::Djot) {
2744 return None;
2745 }
2746 let nodes = self.nodes();
2747 let block = nodes
2748 .iter()
2749 .filter(|n| n.kind == Kind::Para)
2750 .find(|n| n.content_span.as_ref() == Some(&content))?;
2751 match self.format {
2752 Format::Djot => {
2753 let attrs = self
2754 .editor
2755 .document()
2756 .ok()
2757 .and_then(|mut d| d.attrs_span(block.id).ok().flatten())?;
2758 (attrs.end <= block.span.start).then_some(attrs.start..content.end)
2759 }
2760 _ => {
2761 let div = block
2762 .parent
2763 .and_then(|p| nodes.iter().find(|n| n.id == p))
2764 .filter(|p| wysiwyg::element_tag(p) == Some("div"))?;
2765 let alone = nodes.iter().filter(|n| n.parent == Some(div.id)).count() == 1;
2766 alone.then(|| div.span.clone())
2767 }
2768 }
2769 }
2770
2771 /// The inline mark kinds whose span covers `off`, each with that span — the
2772 /// span-carrying sibling of [`marks_at`](Self::marks_at), which reports node
2773 /// ids instead. Used to shed a mark by stepping past the end of its run.
2774 fn mark_spans_at(&mut self, off: usize) -> Vec<(InlineKind, std::ops::Range<usize>)> {
2775 let off = off.min(self.source.len());
2776 self.editor
2777 .ancestors_at(off)
2778 .unwrap_or_default()
2779 .into_iter()
2780 .filter(|m| off < m.span.end)
2781 .filter_map(|m| inline_kind(&m.kind).map(|k| (k, m.span.clone())))
2782 .collect()
2783 }
2784
2785 /// Insert clipboard `text` at the caret, replacing the selection if there is
2786 /// one — always its own undo step, whatever its length.
2787 ///
2788 /// Provenance is the whole point, and only the caller has it. `insert` reads
2789 /// a lone character as a keystroke and folds it into the run around it,
2790 /// which is right for typing and wrong for a one-character paste: that paste
2791 /// would vanish mid-run on an undo it was never part of, and the characters
2792 /// the user actually typed would go with it. Length can't tell the two
2793 /// apart — `⌘V` of `x` and typing `x` are the same string — so the door the
2794 /// caller comes through is what says which happened.
2795 pub fn paste(&mut self, text: &str) {
2796 // Pasting against a block picture or a table's end joins the block
2797 // exactly as typing does, and for the same reason — see
2798 // `open_paragraph_at_block_edge`.
2799 self.open_paragraph_at_block_edge(text);
2800 let (s, e) = self.selection().unwrap_or((self.caret, self.caret));
2801 self.splice(s, e, text, EditKind::Other);
2802 }
2803
2804 /// Replace `[start, end)` with `text` as one step of an IME composition —
2805 /// the same splice as [`edit`](Self::edit), but marked so the run of steps
2806 /// folds into a single undo.
2807 ///
2808 /// A composition is *one* act of writing. Typing `かんじ` and picking 感じ is a
2809 /// dozen calls here, each replacing the last one's provisional bytes, and an
2810 /// undo step per call means undoing a word means pressing ⌘Z until the reading
2811 /// unspools backwards through kana — the intermediate states were never text
2812 /// the user wrote. Only the frontend knows a call is provisional (the bytes
2813 /// look like any other edit), so the door the caller comes through is what
2814 /// says so, exactly as it is for [`paste`](Self::paste) versus
2815 /// [`insert`](Self::insert).
2816 ///
2817 /// Pair with [`end_composition`](Self::end_composition), or the *next*
2818 /// composition folds into this one.
2819 pub fn edit_composing(&mut self, start: usize, end: usize, text: &str) {
2820 self.splice(start, end, text, EditKind::Compose);
2821 }
2822
2823 /// Close the open composition run, so the next one is its own undo step.
2824 /// Call when the IME commits or withdraws a composition.
2825 ///
2826 /// Only clears a *composition* run: a frontend that reports an end it never
2827 /// began (some IMEs unmark unprompted) would otherwise split the run of
2828 /// typing around it into two undo steps for no reason the user can see.
2829 pub fn end_composition(&mut self) {
2830 if self.last_edit_kind == Some(EditKind::Compose) {
2831 self.last_edit_kind = None;
2832 }
2833 }
2834
2835 // ── the clipboard's rich flavor ──────────────────────────────────────────
2836
2837 /// The selection rendered as HTML, for the clipboard's `text/html` flavor —
2838 /// what lets a paste into Docs/Mail/Slack keep its formatting. `None` when
2839 /// nothing is selected, or when the selection doesn't render (the caller
2840 /// still has [`selected_text`](Self::selected_text), which is what to publish
2841 /// as `text/plain` either way).
2842 ///
2843 /// **The fragment is a source substring, and that is the honest limit here.**
2844 /// It's parsed standalone, so a selection whose meaning depends on its
2845 /// surroundings converts as what it literally says rather than what it looks
2846 /// like on screen: half a list item is a paragraph, a row torn out of a table
2847 /// is the text of a row, the `**` of a bold run selected without its closing
2848 /// `**` is two asterisks. Every one of those still *renders* — there's no
2849 /// error to report — it just renders as the fragment and not as the document.
2850 /// Widening the range to whole blocks would publish text the user didn't
2851 /// select, which is a worse lie than a fragment being a fragment; the plain
2852 /// flavor has the same substring, so the two flavors at least agree.
2853 pub fn selection_html(&mut self) -> Option<String> {
2854 let (start, end) = self.selection()?;
2855 let inline = self.selection_is_inline(start, end);
2856 let html = html::render_fragment(&self.source[start..end], self.format)?;
2857 Some(match inline {
2858 true => html::strip_sole_paragraph(html),
2859 false => html,
2860 })
2861 }
2862
2863 /// Paste the clipboard's `text/html` flavor, converting it to this document's
2864 /// format first. Its own undo step, like any [`paste`](Self::paste).
2865 ///
2866 /// Returns whether it landed. `false` means the HTML didn't convert to
2867 /// anything worth pasting — the caller should fall back to the plain flavor
2868 /// rather than treat it as an error. The `html` module has the full list of
2869 /// what that covers: a table twig won't build, markup it doesn't recognise,
2870 /// an empty result.
2871 pub fn paste_html(&mut self, html: &str) -> bool {
2872 match html::parse_fragment(html, self.format) {
2873 Some(source) => {
2874 self.paste(&source);
2875 true
2876 }
2877 None => false,
2878 }
2879 }
2880
2881 /// Does the selection live *inside* a single top-level block?
2882 ///
2883 /// The question [`selection_html`](Self::selection_html) needs and the
2884 /// fragment can't answer: `**bold**` renders as `<p><strong>bold</strong></p>`
2885 /// whether the user selected one word of a sentence or a whole paragraph, and
2886 /// only the document knows which. Selecting a word and pasting into Docs
2887 /// should extend the line you paste into; selecting the paragraph should make
2888 /// a paragraph. So a selection strictly within one block is inline (its `<p>`
2889 /// is an artifact of standalone parsing), and one that covers a whole block —
2890 /// or spans two — keeps its structure.
2891 ///
2892 /// Reads the block from twig rather than guessing from the bytes:
2893 /// `ancestors_at` is `[doc, block, …inline]`, so index 1 is the top-level
2894 /// block containing an offset, and two ends inside the same one cannot have
2895 /// crossed a block boundary.
2896 fn selection_is_inline(&mut self, start: usize, end: usize) -> bool {
2897 // The last *character*, not `end - 1`: the selection's end is exclusive
2898 // and may sit mid-codepoint's-worth of bytes past the last char.
2899 let Some((off, _)) = self.source[start..end].char_indices().next_back() else {
2900 return false;
2901 };
2902 let (Some(head), Some(tail)) =
2903 (self.top_block_span(start), self.top_block_span(start + off))
2904 else {
2905 return false;
2906 };
2907 head == tail && !(start <= head.start && end >= head.end)
2908 }
2909
2910 /// The byte span of the top-level block containing `offset`, or `None` at an
2911 /// offset that belongs to no block (the blank line between two of them).
2912 fn top_block_span(&mut self, offset: usize) -> Option<std::ops::Range<usize>> {
2913 self.editor
2914 .ancestors_at(offset)
2915 .ok()?
2916 .get(1)
2917 .map(|m| m.span.clone())
2918 }
2919
2920 // ── indentation ──────────────────────────────────────────────────────────
2921
2922 /// One indent level.
2923 ///
2924 /// Two spaces, not the four both frontends type for Tab today, because in a
2925 /// markdown document four columns isn't a width — it's a *meaning*. Four
2926 /// spaces at the head of a line is markdown's indented-code-block marker, so
2927 /// one Tab on a paragraph would reparse it into code and style it as such;
2928 /// two cannot, and the line stays the prose it was. Two is also exactly
2929 /// where a `- ` bullet's content starts, so an indented line lands under its
2930 /// parent item's text instead of beside it — the column a list-aware indent
2931 /// has to hit anyway, which keeps this width from being relitigated later.
2932 const INDENT: &'static str = " ";
2933
2934 /// Indent the selected lines — or the caret's line, with no selection — by
2935 /// one level (Tab).
2936 pub fn indent(&mut self) {
2937 self.reindent(true);
2938 // Nesting changes an ordered list's numbering (the nested item restarts,
2939 // its old siblings resume) — keep the source markers in step.
2940 self.renumber_here();
2941 // Nesting an empty `-` item under a text line reparses that text as a
2942 // setext heading; swap the dash for a `*` before it can (a no-op unless
2943 // the collapse actually happened).
2944 self.avoid_setext_collapse();
2945 }
2946
2947 /// Take one indent level back off the selected lines, or the caret's line
2948 /// (Shift+Tab). A line with no indentation is left exactly as it is.
2949 ///
2950 /// A line with *less* than a full level gives back what it has rather than
2951 /// refusing: outdent's job is to walk a line left, and real documents — hand
2952 /// written, or reflowed by some other editor — are full of indentation that
2953 /// was never a clean multiple of anything. Refusing there would strand the
2954 /// line at a depth Shift+Tab couldn't undo.
2955 pub fn outdent(&mut self) {
2956 self.reindent(false);
2957 self.renumber_here();
2958 }
2959
2960 /// The body of [`indent`](Self::indent) / [`outdent`](Self::outdent).
2961 ///
2962 /// One splice across the whole line range, never one per line: a Tab is one
2963 /// thing the user did, so it has to be one undo step and one reparse. Per
2964 /// line, twig would reparse the document once per line and leave a stack of
2965 /// steps that Shift+⌘Z walks back one line at a time.
2966 fn reindent(&mut self, add: bool) {
2967 let (sel_start, sel_end) = self.selection().unwrap_or((self.caret, self.caret));
2968 let start = source_line_range(&self.source, sel_start).start;
2969 let end = source_line_range(&self.source, sel_end).end;
2970 let region = self.source[start..end].to_string();
2971 let lines: Vec<&str> = region.split('\n').collect();
2972 // A blank line has no text to move, and padding it would leave nothing
2973 // but trailing whitespace — but Tab on a blank line *is* a request for
2974 // indentation to type into, so the skip only applies where the op has
2975 // other lines to do real work on.
2976 let skip_blank = add && lines.len() > 1;
2977
2978 let mut out = String::with_capacity(region.len() + lines.len() * Self::INDENT.len());
2979 let mut deltas: Vec<isize> = Vec::with_capacity(lines.len());
2980 let mut line_off = start;
2981 for (i, full) in lines.iter().enumerate() {
2982 if i > 0 {
2983 out.push('\n');
2984 }
2985 // A list item moves by having its whole leading prefix *replaced*,
2986 // never by having spaces pushed in front of the line. twig spells
2987 // both prefixes, so the quote markers, the parent's indent and an
2988 // ordered marker's extra column all come out right without leaf
2989 // measuring any of them — and a line that only looks like an item
2990 // (a Djot continuation) reports no marker and is left to the plain
2991 // path, where a Tab is just a Tab.
2992 let marker = self.list_marker_on_line(line_off);
2993 let own = marker
2994 .as_ref()
2995 .map(|m| m.marker_start - m.line_start)
2996 .unwrap_or(0);
2997 let delta = if add {
2998 if skip_blank && full.trim().is_empty() {
2999 out.push_str(full);
3000 0
3001 } else if marker.is_some() && self.first_item_of_list(line_off) {
3002 // The first item of a list has no preceding sibling to nest
3003 // under, so a Tab here can't spell a sub-list — twig would
3004 // reparse the shoved-over marker as the same list, only
3005 // indented, which Shift+Tab then can't cleanly undo. Leave the
3006 // item where it is, the way every list editor refuses to
3007 // over-indent a list's first line.
3008 out.push_str(full);
3009 0
3010 } else if marker.is_some() {
3011 // Nesting means standing where a *continuation* of this line
3012 // would stand: past the parent's marker, inside its content
3013 // column. That is `continuation_prefix`, less a checkbox.
3014 let new = self.nesting_prefix_at(line_off);
3015 let delta = new.len() as isize - own as isize;
3016 out.push_str(&new);
3017 out.push_str(&full[own..]);
3018 delta
3019 } else {
3020 out.push_str(Self::INDENT);
3021 out.push_str(full);
3022 Self::INDENT.len() as isize
3023 }
3024 } else if marker.is_some() {
3025 // Unnesting is the mirror: stand where the parent item's own
3026 // line starts, which drops exactly the level it contributed.
3027 let new = self.outdent_prefix_at(line_off);
3028 let delta = new.len() as isize - own as isize;
3029 out.push_str(&new);
3030 out.push_str(&full[own..]);
3031 delta
3032 } else {
3033 // A plain line gives back the ordinary step.
3034 let strip = outdent_width(full, Self::INDENT.len());
3035 out.push_str(&full[strip..]);
3036 -(strip as isize)
3037 };
3038 deltas.push(delta);
3039 line_off += full.len() + 1;
3040 }
3041 // Nothing to give back. Returning before the splice keeps an outdent at
3042 // column zero from spending an undo step on a document it never changed.
3043 if deltas.iter().all(|d| *d == 0) {
3044 return;
3045 }
3046
3047 // Every line's text keeps its offset *within the line*, so the caret is
3048 // remapped by its column, not by its byte offset — which the prefixes on
3049 // the lines above it have already invalidated.
3050 let remap = |off: usize| -> usize {
3051 let (mut old_ls, mut new_ls) = (start, start);
3052 for (line, delta) in lines.iter().zip(&deltas) {
3053 let old_le = old_ls + line.len();
3054 let new_len = (line.len() as isize + delta) as usize;
3055 if off <= old_le {
3056 let col = (off - old_ls) as isize;
3057 return new_ls + ((col + delta).max(0) as usize).min(new_len);
3058 }
3059 old_ls = old_le + 1;
3060 new_ls += new_len + 1;
3061 }
3062 start + out.len()
3063 };
3064 let placed = match self.selection() {
3065 // Keep the rewritten region selected, the way a container toggle
3066 // keeps its own: it leaves a second Tab aimed at the same lines
3067 // rather than at whatever the shifted offsets now happen to cover.
3068 Some(_) => (start + out.len(), Some(start)),
3069 None => (remap(self.caret), None),
3070 };
3071
3072 // A rolled-back splice leaves the old source in place, where every offset
3073 // computed above addresses text that was never written.
3074 if !self.splice(start, end, &out, EditKind::Other) {
3075 return;
3076 }
3077 // `splice` re-anchors to the end of the `Change`, which for a whole-region
3078 // rewrite is the last line's end — nowhere the caret was. Place it, then
3079 // re-record the caret so this is the state redo restores, not the one
3080 // `splice` left behind from the `Change`.
3081 self.caret = placed.0.min(self.source.len());
3082 self.anchor = placed.1;
3083 self.clamp_caret();
3084 self.record_caret();
3085 }
3086
3087 /// The Enter key.
3088 ///
3089 /// In source view it's a literal newline. In WYSIWYG it's **AST-aware**: a
3090 /// bare `\n` is only a markdown soft break (same paragraph), so the block the
3091 /// caret is in decides what actually gets written.
3092 ///
3093 /// - paragraph → twig's [`Editor::split_block`], which parts the
3094 /// block at the caret and reopens its container
3095 /// - list item → likewise: the next item, its indent, quote
3096 /// prefix and `[ ]` box all reproduced by twig —
3097 /// except an *empty* item, which exits the list
3098 /// - block quote → likewise: a new paragraph inside the quote
3099 /// - heading → a new *paragraph*, not another heading
3100 /// - code block → a literal newline (stay in the block)
3101 /// - thematic break → the line under the rule, opened if it has
3102 /// none ([`stand_under_block`](Self::stand_under_block))
3103 /// - blank line → a literal newline (one Backspace undoes it)
3104 /// - [`LineFlow::Preserve`] → a single soft break, which renders as a
3105 /// visible line
3106 ///
3107 /// Where `split_block` is used it replaces markup leaf used to spell by hand,
3108 /// and it is better at it: it drops the whitespace the caret was sitting in
3109 /// front of instead of stranding it at the head of the second half, and it
3110 /// knows continuations leaf's marker scan never covered — a checklist item
3111 /// continues as an *unchecked* checklist item rather than a plain bullet.
3112 ///
3113 /// The exceptions above are exceptions because `split_block` is either wrong
3114 /// there or refuses: parting a fence yields two fences with the code split
3115 /// between them, parting a heading yields a second heading where every editor
3116 /// gives a paragraph, and a blank line, an empty item, a setext heading and a
3117 /// table all report an error rather than a split.
3118 pub fn newline(&mut self) {
3119 if self.view == View::Source {
3120 self.insert_raw("\n");
3121 return;
3122 }
3123 // Enter over a selection replaces it with a paragraph break.
3124 if let Some((s, e)) = self.selection() {
3125 self.splice(s, e, "\n\n", EditKind::Other);
3126 return;
3127 }
3128 // On a blank page there is no block to push down, and a blank line
3129 // with nothing under it is not something Fold flow draws. Enter wrote
3130 // one anyway: an edit, and an undo step, that showed nothing.
3131 if self.line_flow == LineFlow::Fold && self.source[self.caret_floor()..].trim().is_empty() {
3132 return;
3133 }
3134 // A caret resting exactly between an inline mark's content and its own
3135 // closing delimiter (`**bold**` with nothing after it on the line —
3136 // the WYSIWYG caret's natural end-of-line position) must not splice a
3137 // block break there: every path below eventually does via
3138 // `insert_raw`/`self.caret`, and splicing before the hidden closing
3139 // delimiter would strand it alone on the new line.
3140 self.caret = self.skip_trailing_close_delims(self.caret);
3141 // The block the caret is in. `block_offset_for_caret` nudges off a line
3142 // end (where the caret sits at the doc level); on a bare line (e.g. an
3143 // empty list item) fall back to the caret so the enclosing list/quote is
3144 // still visible in the ancestors.
3145 let off = self.block_offset_for_caret().unwrap_or(self.caret);
3146 let kinds: Vec<Kind> = self
3147 .editor
3148 .ancestors_at(off)
3149 .map(|c| c.into_iter().map(|m| m.kind).collect())
3150 .unwrap_or_default();
3151 let has = |k: Kind| kinds.contains(&k);
3152
3153 if has(Kind::CodeBlock) {
3154 self.insert_raw("\n");
3155 return;
3156 }
3157 // An *empty* list item exits the list — the standard double-Enter — which
3158 // `split_block` reports as an error rather than a split (there is no
3159 // content to part), so it stays leaf's. `list_marker_on_line` is itself
3160 // the AST gate — it answers from the tree, so a `- ` that reads as a
3161 // marker byte-for-byte but opens no item (a setext underline, a Djot
3162 // continuation line) never reaches here.
3163 if let Some(marker) = self.list_marker_on_line(self.caret)
3164 && self.item_is_empty(&marker)
3165 {
3166 self.exit_list(&marker);
3167 return;
3168 }
3169 // Beside a rule, at the home past it, the caret's source line is the
3170 // blank one under the rule, which the branch below would take for an
3171 // empty paragraph. Mid-document its lone newline left the caret on the
3172 // next block's first line, and what was typed there joined that block.
3173 if let Some(end) = self.rule_above_caret() {
3174 self.stand_under_block(end);
3175 return;
3176 }
3177 // On an *empty* paragraph line, a lone Enter should add a single blank line,
3178 // not another full paragraph break — so it moves down one line and one
3179 // Backspace undoes it, not two. (`split_block` errors here too.)
3180 let line_start = self.source[..self.caret].rfind('\n').map_or(0, |i| i + 1);
3181 let line_end = self.source[self.caret..]
3182 .find('\n')
3183 .map_or(self.source.len(), |i| self.caret + i);
3184 if self.source[line_start..line_end].trim().is_empty() {
3185 self.insert_raw("\n");
3186 return;
3187 }
3188 // At the start of a paragraph with a block above it, twig's split
3189 // writes a lone newline ahead of the paragraph, which Fold draws as one
3190 // more gap: the first Enter showed nothing. A heading's branch below
3191 // parted it after its `#`, leaving an empty heading over a paragraph
3192 // that had been the heading's text. Both open an empty paragraph above
3193 // instead, the caret staying with the text. In Preserve flow too: its
3194 // lone newline went inside a div, where nothing drew it, and between
3195 // a mark's delimiters, where it cut the mark.
3196 if let Some((at, text)) = self.paragraph_opening_at_caret() {
3197 let caret = self.caret;
3198 if self.splice(at, at, text, EditKind::Other) {
3199 self.caret = caret + text.len();
3200 self.record_caret();
3201 }
3202 return;
3203 }
3204 // In `Preserve` flow a soft break is a *visible* line the author means to
3205 // make, so Enter writes a single `\n` and typing continues the same
3206 // paragraph on the next line — the behaviour of an ordinary text editor.
3207 // A second Enter then lands on the blank line above and takes the
3208 // empty-line branch, so double-Enter still promotes to a full paragraph
3209 // break; and Backspace, which deletes a lone `\n` over a soft break,
3210 // undoes a single Enter symmetrically. In `Fold` flow a lone `\n` would
3211 // render as an invisible space, so Enter keeps making the paragraph break
3212 // that actually shows.
3213 //
3214 // Only in running prose. A list or a quote has a continuation of its own
3215 // to write, and a `\n` there is not a soft line but a lost container.
3216 // Nor in a heading, which a newline ends in Markdown and runs on
3217 // unseen in djot: it takes the heading's own branch below.
3218 let in_container = has(Kind::ListItem) || has(Kind::TaskListItem) || has(Kind::BlockQuote);
3219 if self.line_flow == LineFlow::Preserve && !in_container && !has(Kind::Heading) {
3220 self.insert_raw("\n");
3221 return;
3222 }
3223 // A heading gets a *paragraph*, never a second heading: Enter at the end
3224 // of a title is how every editor is asked for the body under it, and
3225 // `split_block` would repeat the `#` instead. Whitespace at the split
3226 // point goes with the break rather than opening the new paragraph, which
3227 // is what `split_block` does everywhere else.
3228 if has(Kind::Heading) {
3229 let mut end = self.caret;
3230 while self.source.as_bytes().get(end) == Some(&b' ') {
3231 end += 1;
3232 }
3233 self.splice(self.caret, end, "\n\n", EditKind::Other);
3234 return;
3235 }
3236 self.split_block_here();
3237 }
3238
3239 /// Where Enter writes to open an empty paragraph above the paragraph or
3240 /// heading the caret starts, and what — `None` when the caret does not
3241 /// start one.
3242 ///
3243 /// The caret starts a paragraph that is in no list item, quote, fence or
3244 /// table when no text of the paragraph stands before it: at its first
3245 /// byte, or past only the opening delimiters of marks that begin with it
3246 /// (`**`, a colour's `<span …>`), which is where a tap before the first
3247 /// letter lands. A heading's `#` marker is such a delimiter. Parted there, twig's split cut the mark in two. The break
3248 /// goes in front of those delimiters, and in front of any `<div>` or
3249 /// fenced div the paragraph opens — the view draws no row for a blank line
3250 /// above a container's first block, so a centred paragraph's empty line
3251 /// has to open outside its div.
3252 ///
3253 /// Inside a list twig's split already writes a marked line, which draws,
3254 /// and so it does in a quote, except above the quote's first line.
3255 ///
3256 /// What is written is what it takes to draw one more empty row. Between
3257 /// two blocks the first and last blank lines are the gap, so a paragraph
3258 /// break goes where there are fewer than two and a newline where the gap
3259 /// is already there. Above the first block only the last blank line is
3260 /// the gap, so a newline goes where there is one already. Preserve flow
3261 /// draws every blank line, so a newline is always enough there, but under
3262 /// frontmatter, whose own blank line draws nothing.
3263 fn paragraph_opening_at_caret(&mut self) -> Option<(usize, &'static str)> {
3264 let caret = self.caret.min(self.source.len());
3265 let chain = self.editor.ancestors_at(caret).ok()?;
3266 if chain.iter().any(|m| {
3267 matches!(
3268 m.kind,
3269 Kind::ListItem | Kind::TaskListItem | Kind::CodeBlock | Kind::Table
3270 )
3271 }) {
3272 return None;
3273 }
3274 let para = chain
3275 .iter()
3276 .find(|m| matches!(m.kind, Kind::Para | Kind::Heading))?;
3277 // An empty heading has nothing to push down: Enter there is still
3278 // the body under it.
3279 let end = para.span.end.min(self.source.len());
3280 if self.source[caret.min(end)..end].trim().is_empty() {
3281 return None;
3282 }
3283 let mut at = para.span.start;
3284 if at != caret {
3285 let text_before = self
3286 .editor
3287 .subtree(twig::NodeId(para.node_id))
3288 .ok()?
3289 .iter()
3290 .any(|n| {
3291 n.span.start < caret
3292 && (n.kind == Kind::Image
3293 || n.text.as_deref().is_some_and(|t| !t.trim().is_empty()))
3294 });
3295 if text_before {
3296 return None;
3297 }
3298 }
3299 // Out past each container this paragraph opens, innermost first: one
3300 // whose source before it is its opening line and blank lines. A quote
3301 // opens on its first paragraph's own line, and draws no line above
3302 // that paragraph: the break goes above the quote. Further down a
3303 // quote twig's split writes a quoted line, which draws.
3304 let mut outer: Vec<_> = chain
3305 .iter()
3306 .filter(|m| matches!(m.kind, Kind::Container | Kind::BlockQuote) && m.span.start < at)
3307 .collect();
3308 outer.sort_by_key(|m| std::cmp::Reverse(m.span.start));
3309 for c in outer {
3310 let before = &self.source[c.span.start..at];
3311 let opens = match c.kind {
3312 Kind::BlockQuote => !before.contains('\n'),
3313 _ => before.lines().skip(1).all(|l| l.trim().is_empty()),
3314 };
3315 if !opens {
3316 break;
3317 }
3318 at = c.span.start;
3319 }
3320 if chain
3321 .iter()
3322 .any(|m| m.kind == Kind::BlockQuote && m.span.start < at)
3323 {
3324 return None;
3325 }
3326 let content = self.source[..at].trim_end().len();
3327 let newlines = self.source[content..at].matches('\n').count();
3328 // Every newline starts a blank line but one that ends the line of
3329 // what stands above, a block or hidden frontmatter.
3330 let blank = if content == 0 {
3331 newlines
3332 } else {
3333 newlines.saturating_sub(1)
3334 };
3335 let first = self
3336 .top_blocks()
3337 .iter()
3338 .all(|m| m.kind == Kind::Metadata || m.span.start >= at);
3339 // Preserve flow draws every blank line, but the one under frontmatter.
3340 let gap = match (self.line_flow, first) {
3341 (LineFlow::Preserve, true) => usize::from(content > 0),
3342 (LineFlow::Preserve, false) => 0,
3343 (LineFlow::Fold, true) => 1,
3344 (LineFlow::Fold, false) => 2,
3345 };
3346 Some((at, if blank >= gap { "\n" } else { "\n\n" }))
3347 }
3348
3349 /// Part the block at the caret with twig's [`Editor::split_block`], leaving
3350 /// the caret in the second half.
3351 ///
3352 /// twig reopens whatever the first half was inside of — the bullet with its
3353 /// indent, the quote's `>`, a checklist item's `[ ]` — which is the whole
3354 /// reason this replaced the markup leaf used to spell from the line's bytes.
3355 /// It renumbers nothing, though: a new item mid-list is written with its
3356 /// neighbour's number, so [`renumber_here`](Self::renumber_here) still runs
3357 /// behind it, folded into the same undo step.
3358 ///
3359 /// Falls back to a plain paragraph break if twig declines, so an unhandled
3360 /// shape still moves the caret down rather than swallowing the keystroke.
3361 fn split_block_here(&mut self) {
3362 // The read-only gate — this door reaches twig without the splice.
3363 if self.read_only {
3364 return;
3365 }
3366 match self.editor.split_block(self.caret) {
3367 Ok(change) => {
3368 self.last_edit_kind = None;
3369 self.refresh();
3370 self.anchor = None;
3371 self.caret = change.new.end;
3372 self.dirty = self.source != self.clean_source;
3373 self.status = None;
3374 self.clamp_caret();
3375 self.record_caret();
3376 // Aimed at the new block's *start*: the caret twig leaves is one
3377 // past the marker it wrote, where there is no list in reach.
3378 self.renumber_at(change.new.start);
3379 }
3380 Err(_) => self.insert_raw("\n\n"),
3381 }
3382 }
3383
3384 /// Whether the item on the marker's line carries no content — the shape
3385 /// double-Enter reads as "I'm done with this list."
3386 fn item_is_empty(&self, line: &ListMarker) -> bool {
3387 let content_start = line.content_start().min(self.source.len());
3388 let line_end = self.source[self.caret..]
3389 .find('\n')
3390 .map(|i| self.caret + i)
3391 .unwrap_or(self.source.len());
3392 self.source[content_start..line_end.max(content_start)]
3393 .trim()
3394 .is_empty()
3395 }
3396
3397 /// Leave the list: replace the empty item's marker with a blank line, so the
3398 /// caret lands in a fresh paragraph below it.
3399 ///
3400 /// Inside a quote the blank line has to stay quoted (a bare one would end the
3401 /// quote), and the caret's new line keeps the `> ` it was already behind —
3402 /// leaving the list without also leaving the quote.
3403 fn exit_list(&mut self, line: &ListMarker) {
3404 let prefix = self.quote_prefix_at(line.marker_start);
3405 let blank = prefix.trim_end();
3406 self.splice(
3407 line.line_start,
3408 self.caret,
3409 &format!("{blank}\n{prefix}"),
3410 EditKind::Other,
3411 );
3412 }
3413
3414 /// What a line continuing the containers at `off` has to open with — the
3415 /// quote markers reproduced, each enclosing item's marker as its width in
3416 /// spaces. Also the column a nested item's marker stands in, which is what
3417 /// makes it Tab's answer.
3418 fn continuation_prefix_at(&mut self, off: usize) -> String {
3419 self.editor
3420 .document()
3421 .and_then(|mut d| d.continuation_prefix(off))
3422 .map(|p| p.text)
3423 .unwrap_or_default()
3424 }
3425
3426 /// The column a *nested list* may open at inside the item at `off` — which
3427 /// is not always where the item's own text continues.
3428 ///
3429 /// twig counts a task item's `[ ] ` box as part of its marker, correctly:
3430 /// it is markup a rich view hides, and the item's own wrapped text does
3431 /// stand past it. But a nested list may only open at the *list* marker's
3432 /// column, and four columns further in is an indented continuation of the
3433 /// paragraph instead — `- [ ] a` + ` - [ ] b` is one item, not two.
3434 /// So the box's own width goes back.
3435 ///
3436 /// The one place leaf still reads a checkbox's spelling. It goes when twig
3437 /// reports the list marker's column apart from the box; `checked` is what
3438 /// says a box is there at all, so only its width is being measured here.
3439 fn nesting_prefix_at(&mut self, off: usize) -> String {
3440 let cont = self.continuation_prefix_at(off);
3441 let Some(item) = self.innermost_list_item(off) else {
3442 return cont;
3443 };
3444 if item.checked.is_none() {
3445 return cont;
3446 }
3447 let box_width = item
3448 .marker_span
3449 .and_then(|m| self.source.get(m))
3450 .and_then(|marker| marker.rfind('[').map(|i| marker.len() - i))
3451 .unwrap_or(0);
3452 // The trailing columns are the ones the item's own marker contributed,
3453 // so trimming from the end leaves any quote prefix standing.
3454 cont[..cont.len().saturating_sub(box_width)].to_string()
3455 }
3456
3457 /// Where the line of the item *containing* the item at `off` begins — the
3458 /// prefix Shift+Tab moves back to, which gives up exactly the level the
3459 /// parent contributed. The quote prefix alone for a top-level item, which
3460 /// has no level left to give.
3461 fn outdent_prefix_at(&mut self, off: usize) -> String {
3462 let items: Vec<usize> = self
3463 .editor
3464 .document()
3465 .and_then(|mut d| d.ancestors_at_caret(off))
3466 .map(|c| {
3467 c.into_iter()
3468 .filter(|m| m.kind == Kind::ListItem || m.kind == Kind::TaskListItem)
3469 .map(|m| m.span.start)
3470 .collect()
3471 })
3472 .unwrap_or_default();
3473 // The second-innermost item is the parent; its own line's indent is the
3474 // target. `list_marker_on_line` gives that line's prefix directly.
3475 let parent = items.len().checked_sub(2).map(|i| items[i]);
3476 match parent.and_then(|p| self.list_marker_on_line(p)) {
3477 Some(m) => self.source[m.line_start..m.marker_start].to_string(),
3478 None => self.quote_prefix_at(off),
3479 }
3480 }
3481
3482 /// The block-quote prefix in force at `off` — `""` outside a quote, `"> "`
3483 /// inside one, `"> > "` inside two.
3484 ///
3485 /// Assembled from each enclosing quote's own [`FlatNode::marker_span`], so
3486 /// the `>` and the space after it are twig's spelling rather than leaf's.
3487 /// The whole line prefix can't answer this: it also carries the indent of
3488 /// whatever the quote holds, which a blank separator line must *not* repeat.
3489 fn quote_prefix_at(&mut self, off: usize) -> String {
3490 let Ok(chain) = self
3491 .editor
3492 .document()
3493 .and_then(|mut d| d.ancestors_at_caret(off))
3494 else {
3495 return String::new();
3496 };
3497 let quotes: Vec<usize> = chain
3498 .iter()
3499 .filter(|m| m.kind == Kind::BlockQuote)
3500 .map(|m| m.node_id as usize)
3501 .collect();
3502 let Ok(nodes) = self.editor.nodes() else {
3503 return String::new();
3504 };
3505 quotes
3506 .iter()
3507 .filter_map(|id| nodes.get(*id)?.marker_span.clone())
3508 .filter_map(|s| self.source.get(s))
3509 .collect()
3510 }
3511
3512 /// Whether the item at `off` sits inside another one — the test Backspace
3513 /// uses to choose between outdenting and dropping the marker.
3514 ///
3515 /// Counted from the AST rather than from the line's leading whitespace,
3516 /// which is indentation in Markdown and, in Djot, may be nothing at all.
3517 fn item_is_nested(&mut self, off: usize) -> bool {
3518 self.editor
3519 .document()
3520 .and_then(|mut d| d.ancestors_at_caret(off))
3521 .map(|c| {
3522 c.into_iter()
3523 .filter(|m| m.kind == Kind::ListItem || m.kind == Kind::TaskListItem)
3524 .count()
3525 > 1
3526 })
3527 .unwrap_or(false)
3528 }
3529
3530 /// The innermost list item containing `probe`, under twig's **caret**
3531 /// containment rule — a block's end is inside it.
3532 ///
3533 /// Half-open containment can't answer this. An empty item's span is exactly
3534 /// its marker, so the caret sitting after `- ` is one past the end and the
3535 /// item it is plainly in tests as out of reach; that is the shape
3536 /// double-Enter has to recognise to leave the list.
3537 fn innermost_list_item(&mut self, probe: usize) -> Option<FlatNode> {
3538 let chain = self
3539 .editor
3540 .document()
3541 .and_then(|mut d| d.ancestors_at_caret(probe))
3542 .ok()?;
3543 let id = chain
3544 .iter()
3545 .rev()
3546 .find(|m| m.kind == Kind::ListItem || m.kind == Kind::TaskListItem)?
3547 .node_id as usize;
3548 self.editor.nodes().ok()?.get(id).cloned()
3549 }
3550
3551 /// The list marker opening `off`'s line, per twig — `None` when that line
3552 /// opens no list item.
3553 ///
3554 /// [`Document::line_prefix`] is the whole hidden run from the line start:
3555 /// `> 1. ` is a quote's marker, an indent, and an item's marker together,
3556 /// and it is `None` on a *continuation* line, which opens nothing. That last
3557 /// case is the one leaf could never get right by reading bytes. `- a\n - b`
3558 /// is two items in Markdown and one in Djot, where a marker cannot interrupt
3559 /// a paragraph and ` - b` is literal text — identical bytes, and only the
3560 /// parser knows which document it is looking at.
3561 ///
3562 /// The item's own marker is separated out via its
3563 /// [`FlatNode::marker_span`], so `marker_start` splits the prefix into what
3564 /// the containers around it contribute and what the item does.
3565 fn list_marker_on_line(&mut self, off: usize) -> Option<ListMarker> {
3566 let off = off.min(self.source.len());
3567 let prefix = self.editor.document().ok()?.line_prefix(off).ok()??;
3568 // The prefix belongs to a list only when an item's marker closes it —
3569 // a heading's `# ` or a bare quote's `> ` is a prefix too.
3570 let item = self.innermost_list_item(prefix.end.min(self.source.len()))?;
3571 let marker = item.marker_span.clone()?;
3572 if marker.end != prefix.end {
3573 return None;
3574 }
3575 Some(ListMarker {
3576 line_start: prefix.start,
3577 marker_start: marker.start,
3578 text: self.source.get(prefix)?.to_string(),
3579 })
3580 }
3581
3582 /// Whether the list item on `line_start`'s line is the **first item** of its
3583 /// list — the one Tab must not nest, because nesting needs a preceding
3584 /// sibling to become the new parent and a first item has none. `false` for a
3585 /// line that isn't a list item, and for an item with a sibling above it (the
3586 /// one Tab *can* nest). Gated on the AST, not the marker bytes: `- ` reads
3587 /// the same in a setext underline that opens no list at all.
3588 fn first_item_of_list(&mut self, line_start: usize) -> bool {
3589 let Some(marker) = self.list_marker_on_line(line_start) else {
3590 return false;
3591 };
3592 // Probe just inside the marker, where the item's own node is in reach —
3593 // the marker offset itself can resolve to the enclosing list, not the
3594 // `list_item`, whose span starts at the marker.
3595 let probe = marker.content_start().min(self.source.len());
3596 let Some(item) = self.innermost_list_item(probe) else {
3597 return false;
3598 };
3599 let Ok(nodes) = self.editor.nodes() else {
3600 return false;
3601 };
3602 match item.parent {
3603 // First when the parent list opens with this very item.
3604 Some(pid) => nodes
3605 .get(pid.0 as usize)
3606 .is_some_and(|p| p.first_child == Some(item.id)),
3607 // A parentless item is trivially the first (and only) one.
3608 None => true,
3609 }
3610 }
3611
3612 pub fn backspace(&mut self) {
3613 if let Some((s, e)) = self.selection() {
3614 self.splice(s, e, "", EditKind::Other);
3615 return;
3616 }
3617 // WYSIWYG: Backspace at the very start of a list item's content is a
3618 // structural key, not a character delete — it walks the "un-indent, then
3619 // un-list" ladder every list editor gives that keystroke (outdent a
3620 // nested item, strip a top-level one's marker to a paragraph). In source
3621 // view the `- ` is visible text the user is deleting a byte of, so it
3622 // keeps its literal meaning there, like Enter does.
3623 if self.view != View::Source && self.backspace_list_start() {
3624 return;
3625 }
3626 // WYSIWYG: and the same at the start of a heading's content — the `# `
3627 // there is markup the rich view hides, not text the user typed.
3628 if self.view != View::Source && self.backspace_heading_start() {
3629 return;
3630 }
3631 // WYSIWYG: and at the start of a block whose presentation is spelled
3632 // as hidden markup before it — djot's `{.center}` line, Markdown's
3633 // `<div class="center">` — Backspace takes that markup, the way it
3634 // takes a heading's `#`, rather than a byte out of it.
3635 if self.view != View::Source && self.backspace_attributed_block_start() {
3636 return;
3637 }
3638 // WYSIWYG: at a block picture's stops, a byte-at-a-time delete would take
3639 // the markup apart under a caret that cannot see it — see
3640 // `delete_around_block_atom`.
3641 if self.view != View::Source && self.delete_around_block_atom(false) {
3642 return;
3643 }
3644 // WYSIWYG: Backspace at a table's trailing stop steps back into its last
3645 // cell rather than taking the byte behind the caret — the row's closing
3646 // `|`, which the rich view never drew, so the key would have looked like
3647 // it did nothing. The stop before is the last cell's end.
3648 if self.view != View::Source && self.backspace_at_table_end() {
3649 return;
3650 }
3651 // WYSIWYG: a table cell's start is a wall. The bytes behind it are the
3652 // padding and the `|` that make the grid, and taking them merges two
3653 // cells — a structural edit nobody asked for, and one no table editor
3654 // gives the key. Tab and Shift+Tab are how the caret leaves a cell.
3655 if self.view != View::Source && self.at_cell_wall(BreakEdge::Backward) {
3656 return;
3657 }
3658 // WYSIWYG: at the start of a block's content, the byte behind the caret
3659 // is a block boundary, and Backspace over one is a join — twig's, so
3660 // that what a join is in each format is not this file's to know. After
3661 // the picture and table cases, which are block starts with their own
3662 // answers.
3663 if self.view != View::Source && self.backspace_joins_block() {
3664 return;
3665 }
3666 // WYSIWYG: Backspace on a *blank line* deletes back to the previous caret
3667 // stop, not a single newline. On a line with no text of its own, the byte
3668 // before the caret is a `\n` that spells part of a block boundary — the gap
3669 // between two blocks, drawn but never a caret home. Removing just it strands
3670 // the caret in that gap and leaves an odd blank line the eye reads as one
3671 // separator but the caret can't land on: the "extra newline" left behind
3672 // after leaving a list (Enter, Enter) or a paragraph and pressing Backspace.
3673 // Deleting to the previous stop instead collapses the whole break at once,
3674 // landing the caret at the end of the block above. Two blank lines in a row
3675 // are one stop apart, so this still removes exactly one — the lone-Enter /
3676 // lone-Backspace symmetry the empty-line case is built on is untouched.
3677 if self.view != View::Source
3678 && self.caret > self.caret_floor()
3679 && self.caret_on_blank_line()
3680 && let Some(stop) = self.vmap.stop_before(self.caret)
3681 {
3682 let stop = stop.max(self.caret_floor());
3683 if stop < self.caret {
3684 if self.source[stop..self.caret].trim().is_empty() {
3685 self.splice(stop, self.caret, "", EditKind::Delete);
3686 } else {
3687 // Hidden markup stands between the stop and the caret — a
3688 // `</div>`, a comment, a link reference definition — and
3689 // collapsing to the stop would delete it. Take the blank
3690 // line alone, with the newline that opened it, and land
3691 // the caret where the collapse would have.
3692 self.delete_blank_line_to(stop);
3693 }
3694 return;
3695 }
3696 }
3697 if self.caret > self.caret_floor() {
3698 // An in-cell `<br>` draws as one newline glyph, so Backspace over it
3699 // takes the whole tag — a single-byte step would leave a broken `<br`
3700 // showing in the cell. Rich view only (source view edits the literal).
3701 if self.view != View::Source
3702 && let Some((start, end)) = self.cell_break_at(BreakEdge::Backward)
3703 {
3704 let start = start.max(self.caret_floor());
3705 if start < end {
3706 self.splice(start, end, "", EditKind::Delete);
3707 return;
3708 }
3709 }
3710 // Aim the delete at the character the writer can *see* behind the
3711 // caret, never at a delimiter the rich view drew nothing for. Two
3712 // steps, and either can apply: from the far side of a run's closing
3713 // `**` step back into the run (the caret is drawn at the end of its
3714 // word), and at the start of a run's text step out past its opening
3715 // `**` to the character in front of it, leaving the run standing.
3716 // Without them a plain Backspace unspells the phrase it is editing
3717 // and leaves a literal asterisk on screen.
3718 let end = if self.view == View::Source {
3719 self.caret
3720 } else {
3721 let inside = self.step_inside_close_delims(self.caret);
3722 // An attributed span with no text — `<span …></span>` as the
3723 // file was written — is hidden markup around nothing, and a
3724 // byte-step here would take its `>`. Backspace takes the span
3725 // whole, with the character before it: the character the key
3726 // looks aimed at, since the span draws nothing.
3727 if let Some(span) = self.run_span_of_content(inside..inside) {
3728 let from = if self.source[..span.start].ends_with('\n') {
3729 span.start
3730 } else {
3731 prev_boundary(&self.source, span.start)
3732 };
3733 let from = from.max(self.caret_floor());
3734 self.splice(from, span.end, "", EditKind::Delete);
3735 return;
3736 }
3737 self.skip_leading_open_delims(inside)
3738 .max(self.caret_floor())
3739 };
3740 // Never delete back across the floor — that would eat hidden
3741 // frontmatter the WYSIWYG caret can't even see.
3742 let mut prev = prev_boundary(&self.source, end).max(self.caret_floor());
3743 // Take a hidden escape backslash with the char it escapes: the rich
3744 // view draws `\*` as a single `*`, so Backspace over it must delete
3745 // both bytes, never strand the `\` as a lone visible backslash (the
3746 // mirror of the Hidden-mode typing that wrote the escape). Source view
3747 // shows the `\`, so there it is an ordinary character.
3748 if self.view != View::Source
3749 && prev > self.caret_floor()
3750 && self.is_hidden_escape(prev - 1)
3751 {
3752 prev -= 1;
3753 }
3754 // The delete that takes the last of a span's text takes the span
3755 // with it, in the same edit: `<span …>i</span>` losing its `i`
3756 // would leave an empty span the map has no stop inside, so the
3757 // caret would draw at the next stop — a line away — until a
3758 // further key removed the span. Landing on the span's start is
3759 // where the letter was.
3760 if self.view != View::Source
3761 && let Some(span) = self.run_span_of_content(prev..end)
3762 {
3763 self.splice(span.start, span.end, "", EditKind::Delete);
3764 return;
3765 }
3766 // And the same for a block: the letter that was all of a centred
3767 // paragraph's text goes with the `<div>` around it, or the `{…}`
3768 // line above it, leaving a plain blank line where the letter was.
3769 // A paragraph with no text is no block, so the markup would stand
3770 // around nothing, the map would give it no caret home, and the
3771 // next key would take the tag apart.
3772 if self.view != View::Source
3773 && let Some(block) = self.attributed_block_of_content(prev..end)
3774 {
3775 self.splice(block.start, block.end, "", EditKind::Delete);
3776 return;
3777 }
3778 if prev < end {
3779 self.splice(prev, end, "", EditKind::Delete);
3780 }
3781 }
3782 }
3783
3784 /// Remove the blank line the caret is on — its own newline and the one
3785 /// that ended the line before it — and put the caret on `stop`, the caret
3786 /// stop before it. The [`backspace`](Self::backspace) blank-line rule for a
3787 /// blank line that hidden markup separates from the block above: the
3788 /// navigable blank row after a `</div>` is always one of at least three
3789 /// newlines under the tag (the drawn separators either side of it), so
3790 /// taking two leaves the blank line the tag needs under it.
3791 fn delete_blank_line_to(&mut self, stop: usize) {
3792 let caret = self.caret;
3793 let line_start = self.source[..caret].rfind('\n').map_or(0, |i| i + 1);
3794 let line_end = self.source[caret..]
3795 .find('\n')
3796 .map_or(self.source.len(), |i| caret + i);
3797 let from = line_start.saturating_sub(1).max(stop);
3798 let to = (line_end + 1).min(self.source.len());
3799 self.splice(from, to, "", EditKind::Delete);
3800 self.caret = stop;
3801 self.anchor = None;
3802 self.goal_col = None;
3803 self.record_caret();
3804 }
3805
3806 /// Move the caret to the stop before it and consume the key — what
3807 /// Backspace does where the byte behind the caret is hidden markup it
3808 /// has no structural answer for, rather than take that markup apart.
3809 fn step_back_to_stop(&mut self) {
3810 // The map answers about offsets, so it has to be this revision's — see
3811 // `open_paragraph_at_block_edge`.
3812 self.rebuild_map();
3813 if let Some(off) = self
3814 .vmap
3815 .stop_before(self.caret)
3816 .filter(|&o| o >= self.caret_floor())
3817 {
3818 self.caret = off;
3819 self.anchor = None;
3820 self.goal_col = None;
3821 }
3822 }
3823
3824 /// Backspace's presentation behaviour: with the caret exactly at the start
3825 /// of a block's content, and that block's attributes spelled as hidden
3826 /// markup before it, strip the attributes. The peer of
3827 /// [`backspace_heading_start`](Self::backspace_heading_start), and the same
3828 /// reasoning: the `{.center}` line above a djot block and the
3829 /// `<div class="center">` around a Markdown one are what the byte behind
3830 /// the caret belongs to, and the rich view draws neither. The ordinary
3831 /// delete took the newline out of `{.center}\nhello` and left
3832 /// `{.center}hello` — the attribute line fused onto the text as prose —
3833 /// and out of `<div …>\n\nhello` it took the blank line the div needs.
3834 ///
3835 /// The whole attribute set goes, the way the whole `#` marker does — the
3836 /// press is over the line that spells it, not over one key of it — and
3837 /// twig's `set_block_attrs` with an empty list is the edit: it removes the
3838 /// djot line and unwraps the Markdown div. Where the block is the first of
3839 /// several in a div, twig has no sole child to unwrap and answers with a
3840 /// no-op, so the caret steps back to the stop before instead, as it does
3841 /// at a table's end. A later child of the div has an ordinary paragraph
3842 /// above it and is not this rule's.
3843 ///
3844 /// Returns whether it acted; `false` leaves Backspace its character delete.
3845 fn backspace_attributed_block_start(&mut self) -> bool {
3846 if !matches!(self.format, Format::Markdown | Format::Djot) {
3847 return false;
3848 }
3849 let caret = self.caret;
3850 let nodes = self.nodes();
3851 let Some(block) = nodes
3852 .iter()
3853 .filter(|n| matches!(n.kind, Kind::Para | Kind::Heading))
3854 .find(|n| n.content_span.as_ref().map_or(n.span.start, |c| c.start) == caret)
3855 else {
3856 return false;
3857 };
3858 match self.format {
3859 Format::Djot => {
3860 // twig records where the `{…}` block was written, so this is
3861 // the parser's own answer and not a scan for a `{` above the
3862 // block; `None` (a synthesized or merged set) is not a line
3863 // the caret is standing after.
3864 let spelled = self
3865 .editor
3866 .document()
3867 .ok()
3868 .and_then(|mut d| d.attrs_span(block.id).ok().flatten())
3869 .is_some_and(|s| s.end <= caret);
3870 if !spelled {
3871 return false;
3872 }
3873 }
3874 _ => {
3875 let Some(div) = block
3876 .parent
3877 .and_then(|p| nodes.iter().find(|n| n.id == p))
3878 .filter(|p| wysiwyg::element_tag(p) == Some("div"))
3879 else {
3880 return false;
3881 };
3882 let mut kids = nodes.iter().filter(|n| n.parent == Some(div.id));
3883 if kids.clone().any(|k| k.span.start < block.span.start) {
3884 return false;
3885 }
3886 if kids.nth(1).is_some() {
3887 self.step_back_to_stop();
3888 return true;
3889 }
3890 }
3891 }
3892 self.write_block_attrs("block attributes", Vec::new());
3893 true
3894 }
3895
3896 /// Backspace at the start of a block's content: join the block into the
3897 /// block before it, as one gesture — twig's `join_blocks`, the inverse of
3898 /// the split Enter makes, spelled the format's way. Two paragraphs join
3899 /// on a soft break; a paragraph under a marker heading joins onto the
3900 /// heading's line; a paragraph after a Markdown `<div>` moves inside it,
3901 /// the hidden `</div>` carried past the joined text; HTML's `</p><p>` is
3902 /// taken as one; a quote's or an item's continuation prefix is written.
3903 /// The joined text takes the block above's presentation and containers,
3904 /// which is the rule every editor with a centred paragraph follows.
3905 ///
3906 /// Leaf used to join by deleting the one newline behind the caret, which
3907 /// is the right bytes for two Markdown paragraphs and nothing else: under
3908 /// a heading it left two blocks, in HTML it took the `>` off a tag, and
3909 /// after a div it took the newline under the hidden `</div>`, which drew
3910 /// nothing different and took the tag apart on the next press. What a
3911 /// join is in each format is twig's to know, and now it does.
3912 ///
3913 /// Where twig refuses — the block above is a code block, a table or a
3914 /// rule with no text to join into, or the caret's block would have to
3915 /// leave a div that holds more after it — the caret steps back to the
3916 /// stop before instead, as it does at a table's end: the key moves the
3917 /// caret and takes no markup apart. Where nothing precedes the block, or
3918 /// the format cannot join at all, Backspace keeps its character delete.
3919 ///
3920 /// Returns whether it acted.
3921 fn backspace_joins_block(&mut self) -> bool {
3922 let caret = self.caret;
3923 if caret <= self.caret_floor() {
3924 return false;
3925 }
3926 let Some(text) = self.text_block_opening_at(caret) else {
3927 return false;
3928 };
3929 match self.join_blocks(caret) {
3930 Ok(change) => {
3931 // The caret keeps its place at the start of the text it stood
3932 // on, wherever the join put that text — after a soft break,
3933 // a space, or a quote's prefix. Found by the bytes, as
3934 // `block_content_in` finds a re-spelled block.
3935 let region = &self.source[change.new.clone()];
3936 let at = region
3937 .find(&text)
3938 .map_or(change.new.start, |i| change.new.start + i);
3939 self.land_after_join(at);
3940 true
3941 }
3942 Err(twig::Error::NotEditable) => {
3943 self.step_back_to_stop();
3944 true
3945 }
3946 Err(twig::Error::NotFound | twig::Error::UnsupportedFormat) => false,
3947 Err(e) => {
3948 self.status = Some(format!("join: {e}"));
3949 true
3950 }
3951 }
3952 }
3953
3954 /// Delete at the end of a block's content: join the block after it into
3955 /// this one — [`backspace_joins_block`](Self::backspace_joins_block)'s
3956 /// mirror, and the same twig gesture aimed at the next block. The caret
3957 /// stays where it was, which is where the joined text now begins after
3958 /// the separator. Where twig refuses, the caret steps forward to the next
3959 /// stop instead; where no block follows, Delete keeps its character
3960 /// delete.
3961 fn delete_forward_joins_block(&mut self) -> bool {
3962 let caret = self.caret;
3963 let at_end = self
3964 .nodes()
3965 .iter()
3966 .filter(|n| matches!(n.kind, Kind::Para | Kind::Heading))
3967 .any(|n| n.content_span.as_ref().is_some_and(|c| c.end == caret));
3968 if !at_end {
3969 return false;
3970 }
3971 // The next stop, across a line end, in a block: what Delete at a
3972 // block's end points at. On the same line it is a hidden delimiter's
3973 // far side, which the ordinary delete handles; on a blank line it is
3974 // the empty paragraph the byte delete has always closed.
3975 self.rebuild_map();
3976 let Some(stop) = self.vmap.stop_after(caret) else {
3977 return false;
3978 };
3979 if !self.source[caret..stop].contains('\n') || !self.has_block_at(stop) {
3980 return false;
3981 }
3982 match self.join_blocks(stop) {
3983 Ok(change) => {
3984 self.land_after_join(change.old.start);
3985 true
3986 }
3987 Err(twig::Error::NotEditable | twig::Error::NotFound) => {
3988 self.caret = stop;
3989 self.anchor = None;
3990 self.goal_col = None;
3991 true
3992 }
3993 Err(twig::Error::UnsupportedFormat) => false,
3994 Err(e) => {
3995 self.status = Some(format!("join: {e}"));
3996 true
3997 }
3998 }
3999 }
4000
4001 /// The content bytes of the paragraph or heading whose content opens
4002 /// exactly at `off` — the block a Backspace there is at the start of.
4003 fn text_block_opening_at(&mut self, off: usize) -> Option<String> {
4004 self.nodes()
4005 .into_iter()
4006 .filter(|n| matches!(n.kind, Kind::Para | Kind::Heading))
4007 .find_map(|n| {
4008 let c = n.content_span?;
4009 (c.start == off).then(|| self.source[c].to_string())
4010 })
4011 }
4012
4013 /// Hand the block at `offset` to twig's `join_blocks`, with the undo
4014 /// plumbing every structural gesture has; the caret is the caller's to
4015 /// place from the change, via [`land_after_join`](Self::land_after_join).
4016 fn join_blocks(&mut self, offset: usize) -> Result<Change, twig::Error> {
4017 if self.read_only {
4018 return Err(twig::Error::NotEditable);
4019 }
4020 self.record_caret();
4021 let change = self.editor.join_blocks(offset)?;
4022 self.last_edit_kind = None; // structural edit is its own undo step
4023 self.refresh();
4024 Ok(change)
4025 }
4026
4027 /// Finish a join: the caret at `at`, no selection, the map this
4028 /// revision's before the clamp — see `write_block_attrs` for why.
4029 fn land_after_join(&mut self, at: usize) {
4030 self.caret = at;
4031 self.anchor = None;
4032 self.goal_col = None;
4033 self.dirty = self.source != self.clean_source;
4034 self.status = None;
4035 self.rebuild_map();
4036 self.clamp_caret();
4037 self.record_caret();
4038 }
4039
4040 // ── moving a block ─────────────────────────────────────────────────────────
4041
4042 /// The block a move picks up at `offset` — the same one
4043 /// [`select_block_at`](Self::select_block_at) selects (the deepest node
4044 /// that is neither inline nor a multi-block container), widened to the
4045 /// list item when it is the item's first block, because that is what
4046 /// twig's `move_block` moves: a bullet's text is the bullet, and dragging
4047 /// it takes the item and everything under it. A block later in an item's
4048 /// tail moves alone. `None` on a blank line, and past the source.
4049 fn movable_block_at(&mut self, offset: usize) -> Option<FlatNode> {
4050 let off = offset.min(self.source.len());
4051 let chain = self.editor.ancestors_at(off).ok()?;
4052 let block = chain
4053 .iter()
4054 .rev()
4055 .find(|m| !wysiwyg::is_inline_kind(&m.kind) && !is_block_container(&m.kind))?;
4056 let nodes = self.nodes();
4057 let block = nodes.get(block.node_id as usize)?.clone();
4058 let item = block
4059 .parent
4060 .and_then(|p| nodes.get(p.0 as usize))
4061 .filter(|p| matches!(p.kind, Kind::ListItem | Kind::TaskListItem))
4062 .filter(|p| p.first_child == Some(block.id));
4063 Some(item.cloned().unwrap_or(block))
4064 }
4065
4066 /// The source range of the block a move would pick up at `offset` — for
4067 /// the outline of the block being carried, without moving the caret. The
4068 /// range [`select_block_at`](Self::select_block_at) would select, except
4069 /// that it is the whole item for a bullet's text, as
4070 /// [`move_block`](Self::move_block) is.
4071 pub fn block_range_at(&mut self, offset: usize) -> Option<Range<usize>> {
4072 self.movable_block_at(offset).map(|b| b.span)
4073 }
4074
4075 /// Move the block at `from` to the boundary `to` — twig's `move_block`,
4076 /// with the undo plumbing every structural gesture has and the caret
4077 /// riding the block to its new place. `from` is any offset inside the
4078 /// block (the one [`block_range_at`](Self::block_range_at) finds); `to`
4079 /// is a position *between* blocks — a block's first byte lands before it,
4080 /// its last after it, a blank line is itself a boundary, and the source's
4081 /// length is the document's end. The block takes the line prefixes of
4082 /// the container the boundary is inside — a `> ` on the way into a quote,
4083 /// none on the way out — and the blank lines a person would have typed
4084 /// are written and removed; twig spells all of that.
4085 ///
4086 /// A move that would move nothing — `to` inside the block, or on the
4087 /// boundary it already sits on — is a quiet no-op rather than an error,
4088 /// since it is what a block dropped back where it was asks for. Any other
4089 /// refusal reaches the status line: a `to` interior to a fence or a table,
4090 /// or a format with no blocks a caret could name.
4091 ///
4092 /// In WYSIWYG the frontmatter is hidden, and a boundary above it is not
4093 /// one a person can see: `to` is raised to the first rendered offset.
4094 ///
4095 /// `true` when the document changed. A move twig accepts that rewrites
4096 /// nothing — a one-item list sent past a paragraph is still a one-item
4097 /// list past that paragraph — is taken back rather than left as an undo
4098 /// step with no difference in it, and is `false` too.
4099 pub fn move_block(&mut self, from: usize, to: usize) -> bool {
4100 if self.read_only || self.refuse_unsupported("move block", Gesture::MoveBlock) {
4101 return false;
4102 }
4103 let len = self.source.len();
4104 let from = from.min(len);
4105 let to = to.clamp(self.caret_floor().min(len), len);
4106 let Some(block) = self.movable_block_at(from) else {
4107 self.status = Some("move block: no block here".into());
4108 return false;
4109 };
4110 // Where the caret stands in the block, as (line within the block,
4111 // bytes back from that line's end) — the shape that survives a
4112 // prefix being added or stripped on the way through a container.
4113 let caret = self.caret.clamp(block.span.start, block.span.end);
4114 let block_line = self.source[block.span.start..caret].matches('\n').count();
4115 let line_end = self.source[caret..block.span.end]
4116 .find('\n')
4117 .map_or(block.span.end, |i| caret + i);
4118 let tail = line_end - caret;
4119 let block_lines = self.source[block.span.start..block.span.end]
4120 .matches('\n')
4121 .count()
4122 + 1;
4123 let upward = to <= block.span.start;
4124 self.record_caret();
4125 match self.editor.move_block(from, to) {
4126 Ok(_) if self.editor.source_str().ok().as_deref() == Some(self.source.as_str()) => {
4127 let _ = self.editor.undo();
4128 self.status = None;
4129 false
4130 }
4131 Ok(change) => {
4132 self.last_edit_kind = None; // structural edit is its own undo step
4133 self.refresh();
4134 let at = self.moved_block_caret(&change.new, upward, block_lines, block_line, tail);
4135 self.caret = at;
4136 self.anchor = None;
4137 self.goal_col = None;
4138 self.dirty = self.source != self.clean_source;
4139 self.status = None;
4140 self.rebuild_map();
4141 self.clamp_caret();
4142 self.record_caret();
4143 true
4144 }
4145 // Nothing to move: the block dropped back onto its own boundary.
4146 Err(twig::Error::InvalidArgument) => {
4147 self.status = None;
4148 false
4149 }
4150 Err(e) => {
4151 self.status = Some(format!("move block: {e}"));
4152 false
4153 }
4154 }
4155 }
4156
4157 /// The caret's place in the rewritten region after a move: the moved
4158 /// block's lines open the region when it went up (below the blank line it
4159 /// was dropped on, when it was) and close it (above the separator twig
4160 /// wrote after it) when it went down, and within them the
4161 /// caret keeps its line and its distance from that line's end. Clamped
4162 /// into the region rather than trusted, since a block can lose a line on
4163 /// the way — a quote it was the only content of goes with it.
4164 fn moved_block_caret(
4165 &self,
4166 new: &Range<usize>,
4167 upward: bool,
4168 block_lines: usize,
4169 block_line: usize,
4170 tail: usize,
4171 ) -> usize {
4172 let region = &self.source[new.start.min(self.source.len())..new.end.min(self.source.len())];
4173 let mut lines: Vec<(usize, usize)> = Vec::new();
4174 let mut start = 0;
4175 loop {
4176 match region[start..].find('\n') {
4177 Some(i) => {
4178 lines.push((start, start + i));
4179 start += i + 1;
4180 }
4181 None => {
4182 lines.push((start, region.len()));
4183 break;
4184 }
4185 }
4186 }
4187 // A separator line is blank, or a quote's bare `>`; the region can
4188 // open with one (the blank line the block was dropped on) or close
4189 // with one (the one twig wrote after it).
4190 let separator = |&(s, e): &(usize, usize)| {
4191 region[s..e]
4192 .trim()
4193 .trim_start_matches('>')
4194 .trim()
4195 .is_empty()
4196 };
4197 let first = if upward {
4198 lines.iter().position(|l| !separator(l)).unwrap_or(0)
4199 } else {
4200 let filled = lines
4201 .iter()
4202 .rposition(|l| !separator(l))
4203 .map_or(0, |i| i + 1);
4204 filled.saturating_sub(block_lines)
4205 };
4206 let (s, e) = lines[(first + block_line).min(lines.len() - 1)];
4207 new.start + e.saturating_sub(tail).max(s)
4208 }
4209
4210 /// Move the caret's block one place up — Alt+↑: above the block before it,
4211 /// and out of its container, to just above it, when it is the first block
4212 /// there. Nothing above the document's first block, and nothing to do for
4213 /// a caret on a blank line; both say so in the status line.
4214 pub fn move_block_up(&mut self) {
4215 self.move_block_by(true);
4216 }
4217
4218 /// Move the caret's block one place down — Alt+↓, the mirror of
4219 /// [`move_block_up`](Self::move_block_up): below the block after it, and
4220 /// out of its container when it is the last block there.
4221 pub fn move_block_down(&mut self) {
4222 self.move_block_by(false);
4223 }
4224
4225 fn move_block_by(&mut self, up: bool) {
4226 if self.read_only || self.refuse_unsupported("move block", Gesture::MoveBlock) {
4227 return;
4228 }
4229 let Some(from) = self.block_offset_for_caret() else {
4230 self.status = Some("move block: no block here".into());
4231 return;
4232 };
4233 let Some(block) = self.movable_block_at(from) else {
4234 self.status = Some("move block: no block here".into());
4235 return;
4236 };
4237 let nodes = self.nodes();
4238 let to = if up {
4239 self.boundary_above(&nodes, &block)
4240 } else {
4241 self.boundary_below(&nodes, &block)
4242 };
4243 if to.is_none_or(|to| !self.move_block(from, to)) && self.status.is_none() {
4244 self.status = Some(if up {
4245 "move block: nothing above".into()
4246 } else {
4247 "move block: nothing below".into()
4248 });
4249 }
4250 }
4251
4252 /// The boundary one step above `block`: before its previous sibling, or
4253 /// — for the first block in a container — before the container itself.
4254 /// `None` for the document's first block.
4255 fn boundary_above(&self, nodes: &[FlatNode], block: &FlatNode) -> Option<usize> {
4256 let parent = block.parent.and_then(|p| nodes.get(p.0 as usize))?;
4257 let previous = nodes
4258 .iter()
4259 .filter(|n| n.parent == Some(parent.id))
4260 .take_while(|n| n.id != block.id)
4261 .last();
4262 match previous {
4263 Some(p) => self.before(p),
4264 None if parent.kind == Kind::Doc => None,
4265 // An item leaving a nested list upward goes before the item
4266 // holding that list, as an item of the outer one; the list's own
4267 // first byte is inside the holding item's tail.
4268 None if matches!(
4269 parent.kind,
4270 Kind::BulletList | Kind::OrderedList | Kind::TaskList
4271 ) =>
4272 {
4273 match parent.parent.and_then(|p| nodes.get(p.0 as usize)) {
4274 Some(item) if matches!(item.kind, Kind::ListItem | Kind::TaskListItem) => {
4275 self.before(item)
4276 }
4277 _ => self.before(parent),
4278 }
4279 }
4280 None => self.before(parent),
4281 }
4282 }
4283
4284 /// The boundary one step below `block`: after its next sibling, or — for
4285 /// the last block in a container — just past the container, at its
4286 /// parent's level ([`exit_below`](Self::exit_below)). `None` for the
4287 /// document's last block.
4288 fn boundary_below(&self, nodes: &[FlatNode], block: &FlatNode) -> Option<usize> {
4289 let parent = block.parent.and_then(|p| nodes.get(p.0 as usize))?;
4290 if let Some(n) = block.next_sibling.and_then(|n| nodes.get(n.0 as usize)) {
4291 return Some(self.after(n));
4292 }
4293 match parent.kind {
4294 Kind::Doc => None,
4295 _ => self.exit_below(nodes, parent),
4296 }
4297 }
4298
4299 /// The boundary just past `container` at its parent's level: before its
4300 /// next sibling, or past its parent when it is the last thing there —
4301 /// the document's end at the top. Leaving a list item is the exception
4302 /// that keeps a block in the list: the next item's tail, which is what
4303 /// [`after`](Self::after) an item names.
4304 fn exit_below(&self, nodes: &[FlatNode], container: &FlatNode) -> Option<usize> {
4305 let item = matches!(container.kind, Kind::ListItem | Kind::TaskListItem);
4306 match container.next_sibling.and_then(|n| nodes.get(n.0 as usize)) {
4307 Some(n) if item => Some(self.after(n)),
4308 Some(n) => self.before(n),
4309 None => {
4310 let parent = container.parent.and_then(|p| nodes.get(p.0 as usize))?;
4311 match parent.kind {
4312 Kind::Doc => Some(self.source.len()),
4313 _ => self.exit_below(nodes, parent),
4314 }
4315 }
4316 }
4317 }
4318
4319 /// The boundary before `node`: its first byte. twig reads a container's
4320 /// opening as before it — a quote's marker, a fence's first byte — so the
4321 /// span's start is the boundary at the parent's level for every kind.
4322 fn before(&self, node: &FlatNode) -> Option<usize> {
4323 Some(node.span.start)
4324 }
4325
4326 /// The boundary after `node`: its own end, which for a container proper
4327 /// (a quote, a list, a fenced or tagged container) is the end of its last
4328 /// line — after its last block, still inside it, where a block arriving
4329 /// from outside joins it. Past the container is the next line's start: a
4330 /// blank line, the next block, or the document's end.
4331 fn after(&self, node: &FlatNode) -> usize {
4332 let container = is_block_container(&node.kind)
4333 && !matches!(node.kind, Kind::ListItem | Kind::TaskListItem);
4334 let end = node.span.end.min(self.source.len());
4335 if container && self.source.as_bytes().get(end) == Some(&b'\n') {
4336 end + 1
4337 } else {
4338 end
4339 }
4340 }
4341
4342 /// Where a block dragged over rendered row `row` would land — the
4343 /// boundary before the row's block when the row is in its upper half, the
4344 /// boundary after it otherwise, and the document's end for a row below
4345 /// everything. `None` for a row that holds no block (a decoration row is
4346 /// resolved to the block under it, so this is a table's bottom rule with
4347 /// nothing after it, or a map with no rows). The frontend draws its
4348 /// indicator above [`DropTarget::row`] and hands
4349 /// [`DropTarget::offset`] to [`move_block`](Self::move_block).
4350 ///
4351 /// The block is the deepest one, not the widened item: a drop on a
4352 /// bullet's text lands before or after that bullet, and its nested
4353 /// children — rows of their own — answer for themselves.
4354 pub fn drop_target_at(&mut self, row: usize) -> Option<DropTarget> {
4355 let rows = self.vmap.rows.len();
4356 if row >= rows {
4357 return Some(DropTarget {
4358 offset: self.source.len(),
4359 row: rows,
4360 });
4361 }
4362 // A gap or a rule is not a block; the block under it is the one meant.
4363 let row = (row..rows).find(|&r| self.vmap.row_is_navigable(r))?;
4364 let start = self.vmap.row_start(row)?;
4365 let chain = self.editor.ancestors_at(start).ok()?;
4366 let block = chain
4367 .iter()
4368 .rev()
4369 .find(|m| !wysiwyg::is_inline_kind(&m.kind) && !is_block_container(&m.kind))?;
4370 let span = block.span.clone();
4371 let (first, last) = self.vmap.row_range_for(span.clone());
4372 if row <= usize::midpoint(first, last) {
4373 Some(DropTarget {
4374 offset: span.start,
4375 row: first,
4376 })
4377 } else {
4378 Some(DropTarget {
4379 offset: span.end.min(self.source.len()),
4380 row: last + 1,
4381 })
4382 }
4383 }
4384
4385 /// Backspace at a table's trailing stop: move onto the stop before it (the
4386 /// last cell's end) and consume the key. `false` anywhere else. See
4387 /// [`VisualMap::table_end_stop`] for why the byte behind the caret there is
4388 /// not one to delete.
4389 fn backspace_at_table_end(&mut self) -> bool {
4390 // The map answers about offsets, so it has to be this revision's — see
4391 // `open_paragraph_at_block_edge`.
4392 self.rebuild_map();
4393 if !self.vmap.table_end_stop(self.caret) {
4394 return false;
4395 }
4396 if let Some(off) = self
4397 .vmap
4398 .stop_before(self.caret)
4399 .filter(|&o| o >= self.caret_floor())
4400 {
4401 self.caret = off;
4402 self.anchor = None;
4403 self.goal_col = None;
4404 }
4405 true
4406 }
4407
4408 /// Whether the caret stands at a table cell's wall on the `edge` side — at
4409 /// or before its first caret stop for Backspace, at or past its last for
4410 /// Delete — where the only bytes between it and the neighbouring cell are
4411 /// the padding and the `|` the rich view draws as the grid. The padding
4412 /// counts as the cell's: a caret placed between `| ` and the text is at the
4413 /// same wall, so the first press cannot eat the space either. An in-cell
4414 /// `<br>` at the edge is not a wall; the delete takes it whole, as ever.
4415 fn at_cell_wall(&mut self, edge: BreakEdge) -> bool {
4416 // The map answers about offsets, so it has to be this revision's.
4417 self.rebuild_map();
4418 if self.cell_break_at(edge).is_some() {
4419 return false;
4420 }
4421 let caret = self.caret;
4422 let src = self.source.as_bytes();
4423 let pad = |b: &u8| *b == b' ' || *b == b'\t';
4424 self.vmap
4425 .tables
4426 .iter()
4427 .flat_map(|t| &t.grid)
4428 .flat_map(|row| &row.cells)
4429 .any(|cell| {
4430 let lo = cell.start
4431 - src[..cell.start]
4432 .iter()
4433 .rev()
4434 .take_while(|b| pad(b))
4435 .count();
4436 let hi = cell.end + src[cell.end..].iter().take_while(|b| pad(b)).count();
4437 (lo..=hi).contains(&caret)
4438 && match edge {
4439 BreakEdge::Backward => {
4440 self.vmap.stop_before(caret).is_none_or(|s| s < cell.start)
4441 }
4442 BreakEdge::Forward => {
4443 self.vmap.stop_after(caret).is_none_or(|s| s > cell.end)
4444 }
4445 }
4446 })
4447 }
4448
4449 /// Whether the caret's own source line holds nothing but whitespace — an
4450 /// empty paragraph, or the blank line a block boundary is spelled with. The
4451 /// test for [`backspace`](Self::backspace)'s stop-wise delete: such a line has
4452 /// no text of its own, so the newline before the caret belongs to the gap
4453 /// between blocks rather than to any word the caret is editing.
4454 fn caret_on_blank_line(&self) -> bool {
4455 let line_start = self.source[..self.caret].rfind('\n').map_or(0, |i| i + 1);
4456 let line_end = self.source[self.caret..]
4457 .find('\n')
4458 .map_or(self.source.len(), |i| self.caret + i);
4459 self.source[line_start..line_end].trim().is_empty()
4460 }
4461
4462 /// The source span of an in-cell hard break (`<br>`) touching the caret on the
4463 /// `edge` side — the byte range to delete whole. A table row is one source
4464 /// line, so its break is spelled `<br>` yet drawn as a single newline glyph
4465 /// (see `wysiwyg.rs`); a delete over it must take every byte, or a one-byte
4466 /// step strands a broken `<br` in the cell. `Backward` matches a break ending
4467 /// at the caret (Backspace), `Forward` one starting at it (Delete). `None`
4468 /// when no such break is adjacent. Only the in-cell break is spelled `<br>`
4469 /// (an ordinary hard break is ` \n`), so the leading `<` alone tells them
4470 /// apart — no ancestor walk needed. Rich view only; source view shows the
4471 /// literal tag and deletes it a byte at a time.
4472 fn cell_break_at(&mut self, edge: BreakEdge) -> Option<(usize, usize)> {
4473 let caret = self.caret;
4474 let nodes = self.nodes();
4475 let src = self.source.as_bytes();
4476 nodes
4477 .iter()
4478 .find(|n| {
4479 n.kind == Kind::HardBreak
4480 && n.span.start < n.span.end
4481 && src.get(n.span.start) == Some(&b'<')
4482 && match edge {
4483 BreakEdge::Backward => n.span.end == caret,
4484 BreakEdge::Forward => n.span.start == caret,
4485 }
4486 })
4487 .map(|n| (n.span.start, n.span.end))
4488 }
4489
4490 /// Whether the source byte at `off` is a backslash twig consumed as an escape
4491 /// (hidden in the rich view), as against a literal backslash (drawn). A
4492 /// backslash escapes exactly an ASCII-punctuation character (the CommonMark /
4493 /// Djot rule twig follows), so `\` + punctuation is the whole test — no AST
4494 /// round-trip needed.
4495 fn is_hidden_escape(&self, off: usize) -> bool {
4496 let b = self.source.as_bytes();
4497 b.get(off) == Some(&b'\\') && b.get(off + 1).is_some_and(u8::is_ascii_punctuation)
4498 }
4499
4500 /// Backspace's list behaviour: when the caret sits exactly at the start of a
4501 /// list item's content (right after its marker), outdent the item if it's
4502 /// nested, else strip the marker so it becomes a paragraph. Returns whether
4503 /// it acted — `false` leaves Backspace its ordinary character delete.
4504 fn backspace_list_start(&mut self) -> bool {
4505 let Some(marker) = self.list_marker_on_line(self.caret) else {
4506 return false;
4507 };
4508 // Only right after the marker. That the line opens a real item is
4509 // already settled: `list_marker_on_line` answers from the tree.
4510 if self.caret != marker.content_start() {
4511 return false;
4512 }
4513 if self.item_is_nested(marker.marker_start) {
4514 // Nested: give back one level, keeping the marker and carrying the
4515 // caret with it.
4516 self.outdent();
4517 } else {
4518 // Top level: drop the marker, leaving a paragraph, then renumber the
4519 // siblings the removed item was counted among. Only the marker goes —
4520 // a quote prefix in front of it still has a quote to hold up.
4521 self.splice(marker.marker_start, self.caret, "", EditKind::Other);
4522 self.renumber_here();
4523 }
4524 true
4525 }
4526
4527 /// Backspace's heading behaviour: with the caret exactly at the start of an
4528 /// ATX heading's content — right after the `#` marker the rich view hides —
4529 /// strip the marker so the line becomes a paragraph. The peer of
4530 /// [`backspace_list_start`](Self::backspace_list_start)'s ladder, and the same
4531 /// reasoning: hidden block markup is structure, so the keystroke over it is
4532 /// structural.
4533 ///
4534 /// Without this the ordinary delete takes the space out of `# Title` and
4535 /// leaves `#Title`, which is no longer a heading at all — the hash the view
4536 /// had been hiding surfaces as literal text the user has to delete a second
4537 /// time, having never typed it. A closing sequence (`# Title #`, hidden at the
4538 /// other end) goes with the marker for the same reason.
4539 ///
4540 /// Returns whether it acted; `false` leaves Backspace its character delete.
4541 fn backspace_heading_start(&mut self) -> bool {
4542 let caret = self.caret;
4543 // The heading whose content opens exactly at the caret. A bare `#` has no
4544 // content span at all — its content starts (and ends) where the line does.
4545 let Some((span, content_end, marker)) = self.nodes().iter().find_map(|n| {
4546 let (start, end) = match &n.content_span {
4547 Some(c) => (c.start, c.end),
4548 None => (n.span.end, n.span.end),
4549 };
4550 (n.kind == Kind::Heading && start == caret)
4551 .then(|| (n.span.clone(), end, n.marker_span.clone()))
4552 }) else {
4553 return false;
4554 };
4555 // twig reports the marker's own extent, so there is nothing to walk back
4556 // over and no `#` in this file. A setext heading has no marker — its
4557 // content opens the line — so it falls through to the ordinary delete,
4558 // as does anything else sitting at a content start.
4559 // `m.end == caret` is what excludes a setext heading, whose marker is the
4560 // underline *after* the content rather than a prefix before it.
4561 let Some(marker) = marker.filter(|m| m.end == caret) else {
4562 return false;
4563 };
4564 let start = marker.start;
4565 // A closing `#` sequence is hidden too, so it can't be left behind. Only
4566 // when the tail really is one: trailing spaces alone are nothing to strip.
4567 let tail = &self.source[content_end..span.end];
4568 if tail.contains('#') && tail.chars().all(|c| c == '#' || c.is_whitespace()) {
4569 let kept = self.source[caret..content_end].to_string();
4570 self.splice(start, span.end, &kept, EditKind::Other);
4571 // The splice leaves the caret past the text it re-wrote; the caret
4572 // belongs where the content now starts, which is where it already was.
4573 self.caret = start;
4574 self.record_caret();
4575 } else {
4576 self.splice(start, caret, "", EditKind::Other);
4577 }
4578 true
4579 }
4580
4581 pub fn delete_forward(&mut self) {
4582 if let Some((s, e)) = self.selection() {
4583 self.splice(s, e, "", EditKind::Other);
4584 } else if self.caret < self.source.len() {
4585 // The mirror of Backspace's: forward-delete in front of a picture
4586 // would eat the `!` off its markup and leave a link where a photo was.
4587 if self.view != View::Source && self.delete_around_block_atom(true) {
4588 return;
4589 }
4590 // And of Backspace's cell wall: Delete at a cell's end would take
4591 // the padding and the `|` after it.
4592 if self.view != View::Source && self.at_cell_wall(BreakEdge::Forward) {
4593 return;
4594 }
4595 // And of Backspace's join: at the end of a block's content, Delete
4596 // joins the next block into this one.
4597 if self.view != View::Source && self.delete_forward_joins_block() {
4598 return;
4599 }
4600 // Delete forward over an in-cell `<br>` takes the whole tag, the mirror
4601 // of Backspace's swallow (see `cell_break_at`) — else a byte-step
4602 // strands a broken `<br` in the cell.
4603 if self.view != View::Source
4604 && let Some((start, end)) = self.cell_break_at(BreakEdge::Forward)
4605 {
4606 self.splice(start, end, "", EditKind::Delete);
4607 return;
4608 }
4609 // The mirror of Backspace's two steps: from in front of a run's
4610 // opening `**` step into it, onto the first letter of its text, and
4611 // at the end of a run's text step out past its closing `**` to the
4612 // character beyond. Either way Delete takes the character it looks
4613 // like it is pointing at, and never a delimiter drawn as nothing.
4614 // The caret then settles back inside the run it was standing in —
4615 // see `settle_inside_close_delims`.
4616 let from = if self.view == View::Source {
4617 self.caret
4618 } else {
4619 let inside = self.step_inside_open_delims(self.caret);
4620 // The mirror of Backspace's empty-span rule: an attributed
4621 // span with no text goes whole, with the character after it.
4622 if let Some(span) = self.run_span_of_content(inside..inside) {
4623 let to = if self.source[span.end..].starts_with('\n') {
4624 span.end
4625 } else {
4626 next_boundary(&self.source, span.end)
4627 };
4628 self.splice(span.start, to, "", EditKind::Delete);
4629 return;
4630 }
4631 self.skip_trailing_close_delims(inside)
4632 };
4633 let next = next_boundary(&self.source, from);
4634 // And of its emptying rules: the span goes with its last letter,
4635 // and so does the block's div or `{…}` line.
4636 if self.view != View::Source
4637 && let Some(span) = self.run_span_of_content(from..next)
4638 {
4639 self.splice(span.start, span.end, "", EditKind::Delete);
4640 return;
4641 }
4642 if self.view != View::Source
4643 && let Some(block) = self.attributed_block_of_content(from..next)
4644 {
4645 self.splice(block.start, block.end, "", EditKind::Delete);
4646 return;
4647 }
4648 if from < next {
4649 self.splice(from, next, "", EditKind::Delete);
4650 }
4651 }
4652 }
4653
4654 /// Delete from the caret back to the start of the previous word (⌥⌫ /
4655 /// Ctrl+⌫). Deletes the selection instead when one is active.
4656 pub fn delete_word_back(&mut self) {
4657 if let Some((s, e)) = self.selection() {
4658 self.splice(s, e, "", EditKind::Other);
4659 } else {
4660 // A word back from just past a picture is a word *of its markup*, and
4661 // a word back from in front of one runs through the paragraph break
4662 // into the prose above — dissolving the picture either way. See
4663 // `delete_around_block_atom`.
4664 if self.view != View::Source && self.delete_around_block_atom(false) {
4665 return;
4666 }
4667 let start = self.word_left_from(self.caret).max(self.caret_floor());
4668 if start < self.caret {
4669 let (s, e) = self.widen_over_emptied_inlines(start, self.caret);
4670 self.splice(s, e, "", EditKind::Delete);
4671 }
4672 }
4673 }
4674
4675 /// Delete from the caret forward to the end of the next word (⌥⌦ /
4676 /// Ctrl+Del). Deletes the selection instead when one is active.
4677 pub fn delete_word_forward(&mut self) {
4678 if let Some((s, e)) = self.selection() {
4679 self.splice(s, e, "", EditKind::Other);
4680 } else {
4681 // The mirror: a word forward from in front of a picture is its markup.
4682 if self.view != View::Source && self.delete_around_block_atom(true) {
4683 return;
4684 }
4685 let end = self.word_right_from(self.caret);
4686 if end > self.caret {
4687 let (s, e) = self.widen_over_emptied_inlines(self.caret, end);
4688 self.splice(s, e, "", EditKind::Delete);
4689 }
4690 }
4691 }
4692
4693 /// Delete from the caret back to the start of its line (⌘⌫). Deletes the
4694 /// selection instead when one is active, as every other delete here does.
4695 ///
4696 /// The line is the view's own — the one Home and End work on, so in WYSIWYG
4697 /// a soft-wrapped row is a line. It is not Home's *target*, though: Home
4698 /// stops at the first character and this takes the indentation with it, the
4699 /// way Cocoa's `deleteToBeginningOfLine:` does. Stopping at the text would
4700 /// leave an indent behind that nothing can then ask to delete, where a caret
4701 /// left at column 0 is one press of Home away from either.
4702 pub fn delete_to_line_start(&mut self) {
4703 if let Some((s, e)) = self.selection() {
4704 self.splice(s, e, "", EditKind::Other);
4705 return;
4706 }
4707 // Never back across the floor: hidden frontmatter isn't on this line, or
4708 // on any line the WYSIWYG caret can see.
4709 let (start, _) = self.line_span();
4710 let start = start.max(self.caret_floor());
4711 if start < self.caret {
4712 let (s, e) = self.widen_over_emptied_inlines(start, self.caret);
4713 self.splice(s, e, "", EditKind::Delete);
4714 }
4715 }
4716
4717 /// Kill from the caret to the end of its line (^K). Deletes the selection
4718 /// instead when one is active.
4719 ///
4720 /// At the end of the line it does nothing, rather than pulling the line
4721 /// below up into this one. Joining has no meaning to give it in both views
4722 /// at once: a WYSIWYG line ends at a soft wrap as often as at a newline, and
4723 /// there is nothing there to delete, while the newline a *source* line ends
4724 /// with is only half of the blank line that separates two paragraphs —
4725 /// deleting one leaves a soft break, which is not the join it looks like.
4726 /// The views agreeing is worth more than emacs' second press, and Delete is
4727 /// already the key that joins.
4728 pub fn delete_to_line_end(&mut self) {
4729 if let Some((s, e)) = self.selection() {
4730 self.splice(s, e, "", EditKind::Other);
4731 return;
4732 }
4733 let (_, end) = self.line_span();
4734 if end > self.caret {
4735 let (s, e) = self.widen_over_emptied_inlines(self.caret, end);
4736 self.splice(s, e, "", EditKind::Delete);
4737 }
4738 }
4739
4740 /// Grow a WYSIWYG word-delete to swallow any inline node it empties.
4741 ///
4742 /// A glyph-space range covers what the user can see, which for `**bold**` is
4743 /// the word and never the delimiters around it — so deleting the word on its
4744 /// own leaves `a **** c`, markup wrapped around nothing. They asked for the
4745 /// word, and the styling was the word's; the two go together. Only the
4746 /// node's delimiters are taken, and those are hidden here anyway, so nothing
4747 /// visible outside the range is lost.
4748 ///
4749 /// Repeated to a fixed point: emptying `***bold***` empties the emph inside
4750 /// the strong, and only then is the strong empty too.
4751 fn widen_over_emptied_inlines(&mut self, start: usize, end: usize) -> (usize, usize) {
4752 if self.view == View::Source {
4753 return (start, end);
4754 }
4755 let nodes = self.nodes();
4756 let (mut s, mut e) = (start, end);
4757 loop {
4758 let mut grew = false;
4759 for n in nodes.iter().filter(|n| wysiwyg::is_inline(n)) {
4760 let Some(text) = inline_content_span(n, &self.source) else {
4761 continue;
4762 };
4763 // Some of its text survives, so the node still has a job.
4764 if text.start < s || text.end > e {
4765 continue;
4766 }
4767 if n.span.start < s || n.span.end > e {
4768 s = s.min(n.span.start);
4769 e = e.max(n.span.end);
4770 grew = true;
4771 }
4772 }
4773 if !grew {
4774 return (s, e);
4775 }
4776 }
4777 }
4778
4779 /// One splice of document text, keeping the **mark-edge rule**: an inline
4780 /// mark's content never begins or ends with whitespace. In Markdown and Djot
4781 /// a delimiter standing against a space is not a delimiter at all — `**bold **`
4782 /// is four literal asterisks around a word, and a rich view drawing the
4783 /// document faithfully has no choice but to show them. That is correct
4784 /// rendering of what the file says, and nobody typing a space after a bold
4785 /// word meant to say it.
4786 ///
4787 /// So the space goes *outside* the run instead — `**bold** ` — which is the
4788 /// same document to a reader and a live one to a parser. The caret follows it
4789 /// out and keeps the marks armed (see [`rearm`](Self::rearm)), so the next
4790 /// character rejoins the run (see [`rejoin_run`](Self::rejoin_run)) and the
4791 /// writer sees one unbroken bold phrase, never a flash of raw syntax.
4792 ///
4793 /// Every ordinary edit — typing, deleting, pasting, an IME step — comes
4794 /// through here, so the rule holds however the whitespace arrives at the
4795 /// edge. The repair is decided *after* the plain edit, by asking whether the
4796 /// mark actually died: a code span's backticks aren't whitespace-sensitive
4797 /// (`` `code ` `` is still code), and nothing is re-spelled when nothing broke.
4798 fn splice(&mut self, start: usize, end: usize, text: &str, kind: EditKind) -> bool {
4799 let fix = self.mark_edge_fix(start, end, text);
4800 if !self.splice_exact(start, end, text, kind) {
4801 return false;
4802 }
4803 if let Some(fix) = fix {
4804 self.repair_mark_edges(fix);
4805 }
4806 if text.is_empty() && end > start {
4807 self.settle_inside_close_delims();
4808 }
4809 true
4810 }
4811
4812 /// After a delete, take a caret left standing past a run's closing delimiters
4813 /// back inside the run.
4814 ///
4815 /// A delete leaves the caret where the deleted bytes began, and when those
4816 /// bytes were the last thing after a marked phrase — the space the mark-edge
4817 /// rule pushed out of `**bold** `, say — that spot is the far side of the
4818 /// closing `**`. The rich view has nothing to draw there: the delimiters are
4819 /// hidden, so the caret shows at the end of the word either way, and the two
4820 /// offsets are one place on screen with two different meanings. Typing at the
4821 /// outer one lands past the run, so the writer who backspaced a space out of
4822 /// their bold phrase watches the next character come out plain, and the
4823 /// toolbar button go dark, with the caret never appearing to move.
4824 ///
4825 /// The end of the run's text is the caret's home there — a delete that took
4826 /// away everything after a phrase leaves the caret at the end of that phrase,
4827 /// which is inside it — so it settles onto that
4828 /// ([`step_inside_close_delims`](Self::step_inside_close_delims) does the
4829 /// walk, through every mark closing at the point): the word stays bold, the
4830 /// button stays lit, and the next character carries on the phrase.
4831 ///
4832 /// Rich view only, and only where a mark really closes at the caret — mid-run
4833 /// or in plain prose no span ends there and the caret stays put. The opening
4834 /// edge is left alone on purpose: a caret in front of a run inherits from the
4835 /// text on its left, which is the plain text outside.
4836 fn settle_inside_close_delims(&mut self) {
4837 if self.view != View::Wysiwyg {
4838 return;
4839 }
4840 let at = self.step_inside_close_delims(self.caret);
4841 if at != self.caret {
4842 self.caret = at;
4843 self.clear_pending();
4844 self.record_caret();
4845 }
4846 }
4847
4848 /// The splice exactly as asked, with no mark-edge repair — for the callers
4849 /// that are *writing* the delimiters themselves ([`insert_with_marks`](Self::insert_with_marks)
4850 /// and [`rejoin_run`](Self::rejoin_run)) and place their own offsets around
4851 /// the bytes they inserted.
4852 ///
4853 /// One `edit_range` through twig, then re-anchor the caret from the returned
4854 /// `Change` and refresh the cached source. A reparse-breaking edit (rare for
4855 /// Markdown/Djot) leaves the document untouched and reports.
4856 ///
4857 /// Returns whether the edit landed — for a caller that has offsets of its
4858 /// own to place afterwards, which a rolled-back splice would leave pointing
4859 /// into text that never came to exist.
4860 fn splice_exact(&mut self, start: usize, end: usize, text: &str, kind: EditKind) -> bool {
4861 // The read-only gate, for every edit at once — see the field.
4862 if self.read_only {
4863 return false;
4864 }
4865 // twig records an undo step for every edit; when this one continues a
4866 // run of the same kind (typing, deleting), tell twig to fold it into the
4867 // step before it so the whole run undoes at once.
4868 let coalesce = kind != EditKind::Other && self.last_edit_kind == Some(kind);
4869 // Hand twig the pre-edit caret before the splice, so the undo step it
4870 // retires carries where the caret was standing.
4871 self.record_caret();
4872 match self.editor.edit_range(start, end, text) {
4873 Ok(change) => {
4874 self.last_edit_kind = Some(kind);
4875 // Counted first, so the fold has the step it folds.
4876 self.refresh();
4877 if coalesce {
4878 self.coalesce_last_undo();
4879 }
4880 self.caret = change.new.end;
4881 self.anchor = None;
4882 self.goal_col = None;
4883 self.clear_pending();
4884 self.dirty = self.source != self.clean_source;
4885 self.status = None;
4886 // And the post-edit caret, so a later redo restores it.
4887 self.record_caret();
4888 true
4889 }
4890 // The edit was rolled back, so twig's history did not move and
4891 // neither may ours: pushing here would leave a step with no edit
4892 // under it and shift every later undo onto the wrong caret.
4893 Err(e) => {
4894 self.status = Some(format!("edit: {e}"));
4895 false
4896 }
4897 }
4898 }
4899
4900 /// The re-spelling that would keep the mark-edge rule for the edit
4901 /// `[start, end)` → `text`, or `None` when the edit leaves no whitespace
4902 /// against a delimiter and the plain splice is already right. Computed
4903 /// *before* the edit, while the run's spans and delimiters can still be read
4904 /// off the document; applied afterwards, and only if the mark really died —
4905 /// see [`repair_mark_edges`](Self::repair_mark_edges).
4906 ///
4907 /// Rich view only. Source view is for typing raw markup, where a space put
4908 /// against a `**` is exactly the character it looks like.
4909 fn mark_edge_fix(&mut self, start: usize, end: usize, text: &str) -> Option<MarkEdgeFix> {
4910 if self.view != View::Wysiwyg || start > end || end > self.source.len() {
4911 return None;
4912 }
4913 // Every inline mark standing over the edit, outermost first, with the
4914 // content span that says where its delimiters are.
4915 let chain: Vec<(InlineKind, std::ops::Range<usize>, std::ops::Range<usize>)> = self
4916 .editor
4917 .ancestors_at(start)
4918 .unwrap_or_default()
4919 .into_iter()
4920 .filter_map(|m| {
4921 let kind = inline_kind(&m.kind)?;
4922 let content = m.content_span.clone()?;
4923 Some((kind, m.span.clone(), content))
4924 })
4925 .collect();
4926 // The innermost run whose *content* holds the whole edit: the one whose
4927 // text is being changed, rather than one the edit merely sits under.
4928 let (kind, span, content) = chain
4929 .iter()
4930 .rev()
4931 .find(|(_, _, c)| c.start <= start && end <= c.end)?
4932 .clone();
4933 // What that content becomes. Whitespace at either end of it is what
4934 // would put out the mark.
4935 let body = format!(
4936 "{}{text}{}",
4937 &self.source[content.start..start],
4938 &self.source[end..content.end]
4939 );
4940 let (lead, trail) = if body.trim().is_empty() {
4941 // Nothing but whitespace left: there is no content to mark at all,
4942 // and the delimiters go with it rather than closing on a space.
4943 (body.len(), 0)
4944 } else {
4945 (
4946 body.len() - body.trim_start().len(),
4947 body.len() - body.trim_end().len(),
4948 )
4949 };
4950 // Nothing against a delimiter, and something still between them: the
4951 // plain edit stands. An emptied run is broken just as surely (`**b**`
4952 // with the `b` deleted is the literal `****`) and is re-spelt as the
4953 // nothing it now says.
4954 if lead == 0 && trail == 0 && !body.is_empty() {
4955 return None;
4956 }
4957 // Marks that open or close exactly where this one does — `***both***` is
4958 // two runs sharing an edge — spell their delimiters as one run of bytes,
4959 // so the whitespace has to clear all of them together.
4960 let (mut open_at, mut close_at) = (span.start, span.end);
4961 for _ in 0..chain.len() {
4962 match chain.iter().find(|(_, _, c)| c.start == open_at) {
4963 Some((_, s, _)) => open_at = s.start,
4964 None => break,
4965 }
4966 }
4967 for _ in 0..chain.len() {
4968 match chain.iter().find(|(_, _, c)| c.end == close_at) {
4969 Some((_, s, _)) => close_at = s.end,
4970 None => break,
4971 }
4972 }
4973 let open = &self.source[open_at..content.start];
4974 let close = &self.source[content.end..close_at];
4975 let core = &body[lead..body.len() - trail];
4976 let respelt = if core.is_empty() {
4977 body.clone()
4978 } else {
4979 format!(
4980 "{}{open}{core}{close}{}",
4981 &body[..lead],
4982 &body[body.len() - trail..]
4983 )
4984 };
4985 // The caret sits just past the inserted text within the new content —
4986 // which, when that lands in the whitespace, is now outside the delimiters.
4987 let pos = (start - content.start) + text.len();
4988 let caret = if core.is_empty() || pos <= lead {
4989 open_at + pos
4990 } else if pos >= lead + core.len() {
4991 open_at + lead + open.len() + core.len() + close.len() + (pos - lead - core.len())
4992 } else {
4993 open_at + lead + open.len() + (pos - lead)
4994 };
4995 Some(MarkEdgeFix {
4996 kind,
4997 probe: content.start,
4998 start: open_at,
4999 end: close_at + text.len() - (end - start),
5000 text: respelt,
5001 caret,
5002 // The marks in force here, resolved against any armed sticky delta —
5003 // what the writer is typing in, and so what has to still be true on
5004 // the far side of the delimiter the caret just stepped over.
5005 want: chain
5006 .iter()
5007 .filter(|(_, s, _)| start < s.end)
5008 .map(|(k, _, _)| *k)
5009 .collect::<InlineMarks>()
5010 .xor(self.pending_here()),
5011 })
5012 }
5013
5014 /// Apply a [`MarkEdgeFix`] — but only if the edit it was computed for really
5015 /// did break the mark. Whether whitespace at a delimiter is fatal is the
5016 /// format's business, not leaf's: `**bold **` is no longer strong, while
5017 /// `` `code ` `` is still perfectly good verbatim, and Djot's braced spellings
5018 /// don't care either. Asking the parser afterwards settles it for every kind
5019 /// and format at once, and costs a re-spelling only where one is due.
5020 ///
5021 /// The repair rides along with the edit that caused it — one undo step puts
5022 /// back what the writer typed, not a delimiter shuffle they never saw.
5023 fn repair_mark_edges(&mut self, fix: MarkEdgeFix) {
5024 if fix.end > self.source.len() {
5025 return;
5026 }
5027 if self.marks_at(fix.probe).iter().any(|(k, _)| *k == fix.kind) {
5028 return; // still a mark: these delimiters don't mind the whitespace
5029 }
5030 let resumed = self.last_edit_kind;
5031 if !self.splice_exact(fix.start, fix.end, &fix.text, EditKind::Other) {
5032 return;
5033 }
5034 self.coalesce_last_undo();
5035 // The keystroke owns the undo step, so the run of typing it belongs to
5036 // keeps coalescing over the repair rather than breaking in two here.
5037 self.last_edit_kind = resumed;
5038 self.caret = fix.caret.min(self.source.len());
5039 self.anchor = None;
5040 self.goal_col = None;
5041 self.rearm(fix.want);
5042 self.clamp_caret();
5043 self.record_caret();
5044 }
5045
5046 /// Arm whatever sticky delta reproduces `want` at the caret — the marks the
5047 /// writer is typing in, carried across an edit that moved the caret out of
5048 /// the run holding them. Arms nothing when the caret already stands in
5049 /// exactly those marks, but still remembers the spot, so a further ⌘b starts
5050 /// a clean delta here (see [`toggle`](Self::toggle)).
5051 fn rearm(&mut self, want: InlineMarks) {
5052 let here: InlineMarks = self
5053 .marks_at(self.caret)
5054 .into_iter()
5055 .map(|(k, _)| k)
5056 .collect();
5057 self.pending_marks = want.xor(here);
5058 self.pending_at = Some(self.caret);
5059 }
5060
5061 /// Insert `text` at `at` as a *literal* run via twig's `insert_literal`,
5062 /// which backslash-escapes any character that would otherwise open markup in
5063 /// this format and position (`*` → `\*`, a line-start `#` → `\#`). The mirror
5064 /// of [`splice`](Self::splice) for the Hidden reveal mode's typing path, with
5065 /// the same caret re-anchor, coalescing, and rollback contract. `at` must be
5066 /// a collapsed point — a selection is deleted by the caller first, since
5067 /// `insert_literal` inserts rather than replaces.
5068 fn insert_literal_at(
5069 &mut self,
5070 at: usize,
5071 text: &str,
5072 kind: EditKind,
5073 force_coalesce: bool,
5074 ) -> bool {
5075 // The read-only gate: this door goes to twig directly, not through
5076 // `splice_exact`, so it guards itself — see the field.
5077 if self.read_only {
5078 return false;
5079 }
5080 // `force_coalesce` folds this into the immediately preceding edit (the
5081 // selection-delete of an overwrite) so the pair is one undo step; else it
5082 // coalesces only when it continues a run of the same-kind typing.
5083 let coalesce =
5084 force_coalesce || (kind != EditKind::Other && self.last_edit_kind == Some(kind));
5085 // The mark-edge rule holds for typed text however it is spelled — see
5086 // `splice`. Only an insert twig passed through unchanged can use it,
5087 // since a fix is measured in the bytes that actually land, and an escape
5088 // adds bytes this couldn't have counted.
5089 let fix = self.mark_edge_fix(at, at, text);
5090 #[cfg(test)]
5091 if std::mem::take(&mut self.refuse_next_literal) {
5092 self.status = Some("edit: refused".into());
5093 return false;
5094 }
5095 self.record_caret();
5096 match self.editor.insert_literal(at, text) {
5097 Ok(change) => {
5098 self.last_edit_kind = Some(kind);
5099 // Counted first, so the fold has the step it folds.
5100 self.refresh();
5101 if coalesce {
5102 self.coalesce_last_undo();
5103 }
5104 self.caret = change.new.end;
5105 self.anchor = None;
5106 self.goal_col = None;
5107 self.clear_pending();
5108 self.dirty = self.source != self.clean_source;
5109 self.status = None;
5110 self.record_caret();
5111 if let Some(fix) = fix.filter(|_| change.new.end - change.new.start == text.len()) {
5112 self.repair_mark_edges(fix);
5113 }
5114 true
5115 }
5116 Err(e) => {
5117 self.status = Some(format!("edit: {e}"));
5118 false
5119 }
5120 }
5121 }
5122
5123 /// After a structural list edit (a new item, a nest/unnest), renumber the
5124 /// ordered list the caret sits in so its source markers run `1, 2, 3, …`
5125 /// again — a raw splice leaves them stale (`1. 2. 2. 3.`). twig does the
5126 /// renumber as its own edit; fold it into the edit that triggered it so the
5127 /// two undo as one, and only when it actually changed the source (a no-op or
5128 /// a caret outside any ordered list must not coalesce the real edit into the
5129 /// step before it).
5130 fn renumber_here(&mut self) {
5131 self.renumber_at(self.caret);
5132 }
5133
5134 /// [`renumber_here`](Self::renumber_here) aimed somewhere other than the
5135 /// caret — for an edit that leaves the caret one past the item it just wrote,
5136 /// where twig resolves no list to renumber.
5137 fn renumber_at(&mut self, off: usize) {
5138 // The read-only gate — this door reaches twig without the splice.
5139 if self.read_only {
5140 return;
5141 }
5142 let before = self.source.clone();
5143 if self.editor.renumber_ordered_lists(off).is_err() {
5144 return; // not inside an ordered list — nothing to renumber
5145 }
5146 self.refresh();
5147 if self.source != before {
5148 self.coalesce_last_undo();
5149 self.dirty = self.source != self.clean_source;
5150 self.clamp_caret();
5151 self.record_caret();
5152 }
5153 }
5154
5155 /// Repair the one trap a list edit can spring on itself. An *empty* `-`
5156 /// sub-item written directly beneath a text line reparses that text as a
5157 /// setext heading — `- hello\n - ` is `<h2>hello</h2>`, because a lone `-`
5158 /// is also a setext-H2 underline (twig is right; pandoc agrees). `*` and `+`
5159 /// bullets can't underline anything, so swap the dash for a `*`: the item
5160 /// stays an empty nested bullet, the parent stays prose, and the source
5161 /// round-trips instead of hiding a heading the user never asked for. Folded
5162 /// into the triggering edit's undo step, the way renumbering is.
5163 ///
5164 /// Gated on the collapse having actually happened (the swapped dash was
5165 /// swallowed into a `heading`), so a real setext heading the author wrote —
5166 /// or a `- x` with content, which can't underline anything — is never
5167 /// touched. This has to live in the *edit*, not the renderer: leaving the
5168 /// hazardous bytes on disk and only painting over them would ship a file
5169 /// every other CommonMark tool reads as a heading.
5170 ///
5171 /// This one keeps its own byte scan, and has to: the hazard is precisely
5172 /// that the dash stopped being a list marker, so [`list_marker_on_line`] —
5173 /// which asks twig which lines open an item — reports nothing here. There is
5174 /// no node to ask about. It is also the last Markdown spelling leaf writes on
5175 /// purpose rather than for want of an answer; once twig spells continuations
5176 /// itself, avoiding the trap becomes twig's, and this goes.
5177 ///
5178 /// [`list_marker_on_line`]: Self::list_marker_on_line
5179 fn avoid_setext_collapse(&mut self) {
5180 let caret = self.caret.min(self.source.len());
5181 let line_start = self.source[..caret].rfind('\n').map_or(0, |i| i + 1);
5182 let bytes = self.source.as_bytes();
5183 let mut dash = line_start;
5184 while matches!(bytes.get(dash), Some(b' ' | b'\t')) {
5185 dash += 1;
5186 }
5187 // A dash bullet is the only marker that doubles as a setext underline.
5188 if bytes.get(dash) != Some(&b'-') {
5189 return;
5190 }
5191 // Only an *empty* item is a bare underline; `- x` carries content and
5192 // can't fold the line above into a heading.
5193 let line_end = self.source[dash..]
5194 .find('\n')
5195 .map_or(self.source.len(), |i| dash + i);
5196 if !self.source[dash + 1..line_end].trim().is_empty() {
5197 return;
5198 }
5199 // The tell: that dash was swallowed into a `heading`. A properly nested
5200 // empty item sits under a `list_item`, with no heading in reach. Probe
5201 // the dash byte itself (well inside the heading), not the caret, whose
5202 // end-of-line offset can fall on the half-open span boundary.
5203 let collapsed = self
5204 .editor
5205 .ancestors_at(dash)
5206 .map(|c| c.into_iter().any(|m| m.kind == Kind::Heading))
5207 .unwrap_or(false);
5208 if !collapsed {
5209 return;
5210 }
5211 let caret = self.caret;
5212 if self.splice(dash, dash + 1, "*", EditKind::Other) {
5213 // Same width, so the caret keeps its column; fold into the edit that
5214 // triggered this so Tab stays one undo step.
5215 self.coalesce_last_undo();
5216 self.caret = caret.min(self.source.len());
5217 self.clamp_caret();
5218 self.record_caret();
5219 }
5220 }
5221
5222 fn snapshot(&self) -> CaretState {
5223 CaretState {
5224 caret: self.caret,
5225 anchor: self.anchor,
5226 }
5227 }
5228
5229 /// Hand twig the current caret and selection as the blob for the live
5230 /// document state. Called before an edit — so the step twig retires records
5231 /// where the caret was, and undo can restore it — and again once the op has
5232 /// placed the caret, so redo restores where the edit left it.
5233 ///
5234 /// This is the whole of leaf's undo-caret bookkeeping now. twig carries the
5235 /// caret through its own history, so coalescing falls out for free (folding
5236 /// two twig steps into one drops the intermediate blob, keeping the run's
5237 /// first) and the parallel stacks that had to march in lockstep — and could
5238 /// silently drift out of it — are gone.
5239 fn record_caret(&mut self) {
5240 let _ = self.editor.set_caret_blob(&self.snapshot().to_blob());
5241 }
5242
5243 /// Toggle an inline mark over the selection (Bold / Italic / Code / …). Keeps
5244 /// the toggled region selected so a second press cleanly reverses it.
5245 pub fn toggle(&mut self, kind: InlineKind) {
5246 // The read-only gate — this door reaches twig without the splice.
5247 if self.read_only {
5248 return;
5249 }
5250 // Ahead of the no-selection branch below: arming a mark for text not yet
5251 // typed is a promise `insert` cannot keep in a format with no delimiters
5252 // to spell it with. Per *kind*, not per format — Markdown spells five
5253 // of the eight marks (highlight among them, under the `highlight`
5254 // extension leaf parses with), djot all eight, HTML seven.
5255 if self.refuse_unsupported(&format!("{kind:?}"), Gesture::ToggleInline(kind)) {
5256 return;
5257 }
5258 let Some((s, e)) = self.selection() else {
5259 // No selection: arm the mark for the next text typed here, the way a
5260 // word processor does. `⌘b`, type, `⌘b` again toggles bold on and off
5261 // in the flow of typing without ever selecting anything — the delta
5262 // is realised onto the freshly typed text by `insert`. A fresh caret
5263 // position starts the delta over from the marks actually in force.
5264 if self.pending_at != Some(self.caret) {
5265 self.pending_marks = InlineMarks::empty();
5266 self.pending_at = Some(self.caret);
5267 }
5268 self.pending_marks.flip(kind);
5269 self.status = None;
5270 return;
5271 };
5272 // Whitespace at the edge of a selection is not part of what was chosen —
5273 // a double-click takes the space after the word with it — and a mark
5274 // cannot close against one anyway: `**word **` is four literal asterisks
5275 // (the mark-edge rule, see `splice`). Mark the words, leave the spaces.
5276 let picked = &self.source[s..e];
5277 let (s, e) = (
5278 s + (picked.len() - picked.trim_start().len()),
5279 e - (picked.len() - picked.trim_end().len()),
5280 );
5281 if s >= e {
5282 self.status = Some(format!("{kind:?}: nothing selected to mark"));
5283 return;
5284 }
5285 // Styling a selection is a one-shot act, not a sticky mode.
5286 self.clear_pending();
5287 self.record_caret();
5288 match self.editor.toggle_inline(s, e, kind) {
5289 Ok(change) => {
5290 self.last_edit_kind = None; // structural edit is its own undo step
5291 self.refresh();
5292 self.anchor = Some(change.new.start);
5293 self.caret = change.new.end;
5294 self.dirty = self.source != self.clean_source;
5295 self.status = None;
5296 self.record_caret();
5297 }
5298 Err(e) => self.status = Some(format!("{kind:?}: {e}")),
5299 }
5300 }
5301
5302 /// Whether the caret stands in a highlight — what a frontend asks to enable
5303 /// or disable its highlight-colour controls, the way
5304 /// [`caret_in_table`](Self::caret_in_table) gates the grid ones.
5305 ///
5306 /// A fact about the *caret*, and the other half of
5307 /// [`Capabilities::mark_color`], which is the fact about the format. A
5308 /// frontend needs both: djot spells a highlight and no colour for it, so a
5309 /// caret standing in `{=word=}` answers `true` here and still has no palette
5310 /// to offer.
5311 ///
5312 /// The rule is [`active_inline_marks`](Self::active_inline_marks)' rule, so
5313 /// the palette appears exactly where the Highlight button is lit — with one
5314 /// deliberate exception: a mark *armed* at a bare caret and not yet typed
5315 /// into lights the button and answers `false` here, because there is no node
5316 /// to colour until the text exists.
5317 pub fn caret_in_mark(&mut self) -> bool {
5318 self.mark_offset().is_some()
5319 }
5320
5321 /// The offset [`set_mark_color`](Self::set_mark_color) speaks for — the one
5322 /// standing in the highlight the gesture means — or `None` when neither end
5323 /// of what is selected is in one.
5324 ///
5325 /// The caret first, and the selection's *start* after it, because of what
5326 /// [`toggle`](Self::toggle) leaves behind: a fresh `==word==` is selected
5327 /// whole, with the caret at its far edge, one past the closing `==` and so
5328 /// (by `marks_at`' half-open rule) not in the mark at all. Highlight a word
5329 /// and colour it — the two presses a coloured highlight is made of — would
5330 /// otherwise refuse on the second, having just written the highlight the
5331 /// author is pointing at.
5332 fn mark_offset(&mut self) -> Option<usize> {
5333 let in_mark = |d: &mut Self, off: usize| {
5334 d.marks_at(off)
5335 .into_iter()
5336 .any(|(k, _)| k == InlineKind::Mark)
5337 .then_some(off)
5338 };
5339 let caret = self.caret.min(self.source.len());
5340 in_mark(self, caret).or_else(|| {
5341 let start = self.selection()?.0;
5342 in_mark(self, start)
5343 })
5344 }
5345
5346 /// The colour of the highlight at the caret — `None` both when the caret is
5347 /// in no highlight and when the highlight it is in names no colour, which
5348 /// are the same answer to "which swatch is lit".
5349 ///
5350 /// The innermost mark, by span, for the same reason
5351 /// [`current_heading_level`](Self::current_heading_level) walks the tree:
5352 /// what the caret is *in* is the deepest node containing it. A `data-color`
5353 /// naming a colour this build has no variant for reads as `None` — the
5354 /// renderer already draws that as a plain highlight rather than guessing,
5355 /// and the toolbar agrees with the renderer.
5356 pub fn mark_color_at_caret(&mut self) -> Option<MarkColor> {
5357 let at = self.mark_offset()?;
5358 self.mark_color_at(at)
5359 }
5360
5361 /// [`mark_color_at_caret`](Self::mark_color_at_caret) at a given offset —
5362 /// the innermost `mark` covering it, and the colour it names.
5363 fn mark_color_at(&mut self, off: usize) -> Option<MarkColor> {
5364 self.nodes()
5365 .into_iter()
5366 .filter(|n| n.kind == Kind::Mark)
5367 .filter(|n| n.span.start <= off && off < n.span.end)
5368 .min_by_key(|n| n.span.end - n.span.start)
5369 .and_then(|n| MarkColor::from_attrs(&n.attrs))
5370 }
5371
5372 /// Colour the highlight at the caret, or clear its colour with `None` — the
5373 /// palette behind a toolbar's Highlight button.
5374 ///
5375 /// Markdown only, and the one gesture whose availability is a fact about the
5376 /// *parse extensions* rather than about the format alone: the colour is
5377 /// spelled `==🔴 text==`, an emoji twig reads back out of the content and
5378 /// records as the mark's `data-color`, and only an editor parsing with
5379 /// `highlight_colors` (which [`parse_extensions`] turns on for every leaf
5380 /// document) reads it back that way. Djot spells the highlight and no colour
5381 /// for it, so this refuses there — see [`Capabilities::mark_color`].
5382 ///
5383 /// **A colour is a property of a highlight that already exists.** There is
5384 /// no "highlight this in red" here, because that is two splices and would be
5385 /// two undo steps under one press; a frontend that wants it calls
5386 /// [`toggle`](Self::toggle) with [`InlineKind::Mark`] first, which is the
5387 /// order the two buttons already sit in. With no highlight at the caret this
5388 /// says so in the status line and writes nothing.
5389 ///
5390 /// The caret keeps its place in the *text*: the splice is entirely in the
5391 /// prefix between the opening `==` and the first word, so an offset past it
5392 /// rides the emoji's width, and one standing on the prefix itself lands
5393 /// where the prefix now ends.
5394 pub fn set_mark_color(&mut self, color: Option<MarkColor>) {
5395 // The read-only gate — this door reaches twig without the splice.
5396 if self.read_only {
5397 return;
5398 }
5399 if self.refuse_unsupported("highlight colour", Gesture::SetMarkColor) {
5400 return;
5401 }
5402 let Some(at) = self.mark_offset() else {
5403 self.status = Some("highlight colour: no highlight at the caret".into());
5404 return;
5405 };
5406 // Clearing a colour a highlight hasn't got is twig's one *successful*
5407 // no-op, and the `Change` it hands back then describes whatever edit came
5408 // before it — a stale span that would drag the caret somewhere it never
5409 // was. Answer it here, where the question is cheap, rather than trusting
5410 // a change that isn't one.
5411 if color.is_none() && self.mark_color_at(at).is_none() {
5412 self.status = None;
5413 return;
5414 }
5415 self.record_caret();
5416 match self.editor.set_mark_color(at, color.map(twig_mark_color)) {
5417 Ok(change) => {
5418 // Re-anchored from the offsets as they were, *before* `refresh`
5419 // sees the new bytes: the caret it clamps is one standing inside
5420 // a prefix that didn't exist a moment ago, and walking it back to
5421 // a char boundary of the emoji loses the place this is restoring.
5422 let caret = reanchor(self.caret, &change);
5423 let anchor = self.anchor.map(|a| reanchor(a, &change));
5424 self.last_edit_kind = None; // structural edit is its own undo step
5425 self.refresh();
5426 self.caret = caret;
5427 self.anchor = anchor;
5428 self.dirty = self.source != self.clean_source;
5429 self.status = None;
5430 self.clamp_caret();
5431 self.record_caret();
5432 }
5433 Err(e) => self.status = Some(format!("highlight colour: {e}")),
5434 }
5435 }
5436
5437 /// One press of a colour swatch: colour the highlight at the caret, or —
5438 /// over a selection that isn't highlighted yet — highlight it and colour it,
5439 /// as **one** undo step.
5440 ///
5441 /// [`set_mark_color`](Self::set_mark_color) is the exact gesture and stays
5442 /// one splice; this is the compound every toolbar actually presses, and it
5443 /// lives here rather than in each frontend because the rule it encodes —
5444 /// what a swatch means when there is no highlight under it yet — is one
5445 /// answer, not one per frontend. The two splices are folded into a single
5446 /// history step, so the press that made a red highlight is taken back by a
5447 /// single undo rather than leaving an uncoloured one behind.
5448 ///
5449 /// `None` clears the colour, and over an unhighlighted selection means
5450 /// simply "highlight this" — the same thing the Highlight button does.
5451 /// A bare caret in no highlight is left alone with a status line, because
5452 /// [`toggle`](Self::toggle) there arms a mark for text not yet typed and a
5453 /// colour cannot be armed with it.
5454 pub fn highlight(&mut self, color: Option<MarkColor>) {
5455 if self.caret_in_mark() || self.selection().is_none() {
5456 self.set_mark_color(color);
5457 return;
5458 }
5459 self.toggle(InlineKind::Mark);
5460 // The format may not spell a highlight at all (`toggle` said so), and
5461 // there is nothing to colour if it doesn't.
5462 if self.status.is_some() {
5463 return;
5464 }
5465 let before = self.revision;
5466 self.set_mark_color(color);
5467 // Only fold when the colour really spliced. `highlight(None)` over a
5468 // fresh highlight is a no-op by design, and coalescing there would eat
5469 // the *previous* edit into the toggle instead.
5470 if self.revision != before {
5471 self.coalesce_last_undo();
5472 }
5473 }
5474
5475 // ── the presentation vocabulary ─────────────────────────────────────────
5476 //
5477 // Six gestures and five queries over twig's two attribute ops. Each gesture
5478 // edits **one key and keeps the rest**: it reads the node's attributes,
5479 // removes its own key (and, for alignment, its own tokens out of `class`),
5480 // adds the new value or nothing, and passes the list back whole — twig's
5481 // contract is replace-not-merge, so the read is the caller's job. A
5482 // paragraph that came in as `class="lead center" id="intro"
5483 // data-line-height="1.5"` and is right-aligned goes out as `class="lead
5484 // right" id="intro" data-line-height="1.5"`. Nothing leaf did not write is
5485 // touched, which is what lets a document from elsewhere pass through the
5486 // editor unharmed.
5487 //
5488 // Clearing is the same gesture with `None`: the key goes, and an empty list
5489 // at the end unwraps the span or the Markdown div, which twig does.
5490
5491 /// Set — or with `None` clear — the alignment of the block the caret is in.
5492 ///
5493 /// A block property, so the gesture is `set_block_attrs` on the caret's
5494 /// block **whatever is selected**: a line is a block's, and "centre this"
5495 /// with three words selected means the paragraph, not the words. The
5496 /// vocabulary is [`Align`], written as `class` tokens; other tokens on the
5497 /// same `class` are kept.
5498 ///
5499 /// In Markdown the attributes live on a `<div>` around the block — twig has
5500 /// no paragraph attribute syntax to write — and this reads them back off
5501 /// that div when the block is its sole child, so a second press rewrites
5502 /// the div rather than nesting a second one.
5503 pub fn set_alignment(&mut self, align: Option<Align>) {
5504 let attrs = self.block_attrs_at_caret();
5505 if align.is_none()
5506 && self.refuse_clear_from_div("alignment", &attrs, |a| Align::from_attrs(a).is_some())
5507 {
5508 return;
5509 }
5510 let attrs = with_class_token(
5511 &attrs,
5512 |t| Align::from_token(t).is_some(),
5513 align.map(Align::name),
5514 );
5515 self.write_block_attrs("alignment", attrs);
5516 }
5517
5518 /// Set — or with `None` clear — the line spacing of the block the caret is
5519 /// in. [`set_alignment`](Self::set_alignment)'s peer in every respect but
5520 /// the key: [`LineHeight`] under `data-line-height`, one of the menu's
5521 /// three names or an exact ratio, written in its canonical spelling.
5522 pub fn set_line_spacing(&mut self, spacing: Option<LineHeight>) {
5523 let attrs = self.block_attrs_at_caret();
5524 if spacing.is_none()
5525 && self.refuse_clear_from_div("line spacing", &attrs, |a| {
5526 LineHeight::from_attrs(a).is_some()
5527 })
5528 {
5529 return;
5530 }
5531 let spelling = spacing.map(LineHeight::name);
5532 let attrs = with_attr(&attrs, "data-line-height", spelling.as_deref());
5533 self.write_block_attrs("line spacing", attrs);
5534 }
5535
5536 /// Set — or with `None` clear — the size of the selected run, or of the
5537 /// caret's whole block when nothing is selected.
5538 ///
5539 /// Size, face and colour are the *run's*, and the block's when no run is
5540 /// chosen. With a selection the gesture is `wrap_range_attrs`, which wraps
5541 /// the range in an attributed span or re-styles the span it already lies in
5542 /// (never nesting a second, and unwrapping it when the last key goes). With
5543 /// no selection it is `set_block_attrs` on the caret's block, so that "make
5544 /// this paragraph larger" is a click with the caret in it rather than a
5545 /// select-all first.
5546 ///
5547 /// The walker reads the key at both levels with the nearer winning, so a
5548 /// span's `data-size` inside a block carrying its own applies to the span.
5549 ///
5550 /// The vocabulary is [`FontSize`]: one of CSS's seven keywords, which is
5551 /// what a menu offers first because a step reads as a step up under every
5552 /// theme, or the point size an author asked for, which is exact and is all
5553 /// it is. Either is written in its canonical spelling, so a size set twice
5554 /// from the same field writes the same bytes both times.
5555 pub fn set_font_size(&mut self, size: Option<FontSize>) {
5556 let spelling = size.map(FontSize::name);
5557 self.set_run_attr("size", "data-size", spelling.as_deref());
5558 }
5559
5560 /// Set — or with `None` clear — the face of the selected run, or of the
5561 /// caret's whole block. [`set_font_size`](Self::set_font_size)'s peer, with
5562 /// [`FontFace`] under `data-font` — one of the four generics, or the family
5563 /// the author named, which the frontends resolve through the platform's
5564 /// font registry and fall back to the body face without.
5565 pub fn set_font_family(&mut self, font: Option<FontFace>) {
5566 let spelling = font.as_ref().map(FontFace::name);
5567 self.set_run_attr("font", "data-font", spelling.as_deref());
5568 }
5569
5570 /// Set — or with `None` clear — the *text* colour of the selected run, or of
5571 /// the caret's whole block. [`set_font_size`](Self::set_font_size)'s peer,
5572 /// with [`TextColor`] under `data-color` — one of the seven names, whose
5573 /// two inks the theme owns, or the triple the author picked, which is
5574 /// painted as written in both appearances.
5575 ///
5576 /// The same key and the same seven names [`set_mark_color`](Self::set_mark_color)
5577 /// writes, and a different thing: that one colours a highlight's
5578 /// *background* and rides the `mark` node twig owns the spelling of, this
5579 /// one colours the letters and rides an attributed span. The two never
5580 /// collide, because a `mark` is a `mark` and a span is a span — and they
5581 /// share a vocabulary on purpose, so that a frontend with a red for a
5582 /// highlight has a red for text and both are *that* red.
5583 pub fn set_text_color(&mut self, color: Option<TextColor>) {
5584 let spelling = color.map(TextColor::name);
5585 self.set_run_attr("text colour", "data-color", spelling.as_deref());
5586 }
5587
5588 /// Insert a page break at the caret — `::page-break`, a leaf directive with
5589 /// no label and no attributes, which twig spells in every format that names
5590 /// a leaf container (Markdown under the `directives` extension
5591 /// [`parse_extensions`] turns on, and djot, where it is an empty `:::
5592 /// page-break` fence), and HTML and AsciiDoc in spellings of their own that
5593 /// the walker reads back for this one name.
5594 ///
5595 /// [`insert_directive`](Self::insert_directive) with [`PAGE_BREAK`], gated
5596 /// on [`Capabilities::page_break`] rather than
5597 /// [`Capabilities::directives`], since the walker reads HTML's and
5598 /// AsciiDoc's page break back and no other name of theirs.
5599 ///
5600 /// The frontends that paginate read the row's
5601 /// [`DirectiveMark`](crate::wysiwyg::DirectiveMark) and open a page there;
5602 /// the ones that do not draw the `⧉ page-break` placeholder every leaf
5603 /// directive gets.
5604 pub fn insert_page_break(&mut self) {
5605 let supported = self.capabilities().page_break;
5606 self.write_directive("page break", supported, PAGE_BREAK, None, &[]);
5607 }
5608
5609 /// Insert the leaf directive `name` at the caret — `::name[label]{attrs}`
5610 /// in Markdown, an empty `::: name` fence in djot — for a host whose
5611 /// vocabulary it is. leaf draws it as the `⧉` placeholder row and publishes
5612 /// it in [`VisualMap::directives`](crate::wysiwyg::VisualMap::directives),
5613 /// where a host that knows the name paints the real thing.
5614 ///
5615 /// Placed exactly as [`insert_thematic_break`](Self::insert_thematic_break)
5616 /// places a rule, and for the same reason: a directive is a block, so twig
5617 /// alone has nowhere to put one mid-paragraph and lands it after the
5618 /// caret's whole block. A bare paragraph is therefore parted at the caret
5619 /// first and the directive aimed at the *first* half, and the caret lands
5620 /// on a line under it. A selection is replaced by it. See that method for
5621 /// the whole of the rule, including why a code block, a list item, a table
5622 /// and a setext heading are left unsplit.
5623 ///
5624 /// `attrs` are `(key, value)` pairs in the order they are written, a `None`
5625 /// value a bare attribute — the shape
5626 /// [`DirectiveMark::attrs`](crate::wysiwyg::DirectiveMark::attrs) reads
5627 /// back.
5628 ///
5629 /// Refused, with a status and nothing written, where
5630 /// [`Capabilities::directives`] is `false`, and for a `name` or `label`
5631 /// twig will not write: a name is an ASCII letter followed by letters,
5632 /// digits, `-` and `_`, and a label may hold no line end and no square
5633 /// bracket. twig is the judge of both, asked before anything is parted.
5634 ///
5635 /// djot needs two things of its own, since its spelling is a fence under
5636 /// an attribute line. A bare attribute is written `key=""`: twig writes
5637 /// `{wide}`, which djot does not read as an attribute at all, and
5638 /// Markdown reads its own `{wide}` back as `wide=""` regardless. And a
5639 /// label is refused, because djot has nowhere to put one: twig writes it
5640 /// as the fence's body, which reads back as a container holding a
5641 /// paragraph and not as a directive.
5642 pub fn insert_directive(
5643 &mut self,
5644 name: &str,
5645 label: Option<&str>,
5646 attrs: &[(String, Option<String>)],
5647 ) {
5648 let supported = self.capabilities().directives;
5649 if self.format != Format::Djot {
5650 self.write_directive("directive", supported, name, label, attrs);
5651 return;
5652 }
5653 if label.is_some_and(|l| !l.is_empty()) {
5654 if !self.read_only {
5655 self.status = Some("directive: djot has no label for a leaf directive".into());
5656 }
5657 return;
5658 }
5659 let attrs: Attrs = attrs
5660 .iter()
5661 .map(|(k, v)| (k.clone(), Some(v.clone().unwrap_or_default())))
5662 .collect();
5663 self.write_directive("directive", supported, name, None, &attrs);
5664 }
5665
5666 /// The gesture behind [`insert_directive`](Self::insert_directive) and
5667 /// [`insert_page_break`](Self::insert_page_break), which differ in the
5668 /// word a refusal uses and in the capability that gates them.
5669 fn write_directive(
5670 &mut self,
5671 what: &str,
5672 supported: bool,
5673 name: &str,
5674 label: Option<&str>,
5675 attrs: &[(String, Option<String>)],
5676 ) {
5677 if self.read_only || self.refuse_unless(what, supported) {
5678 return;
5679 }
5680 let attrs: Vec<(&str, Option<&str>)> = attrs
5681 .iter()
5682 .map(|(k, v)| (k.as_str(), v.as_deref()))
5683 .collect();
5684 // Ask twig first, on an empty document of the same format, so that a
5685 // name or label it refuses is refused before the selection is cut or
5686 // the paragraph parted: the refusal writes nothing at all. An empty
5687 // document is a fine place for any directive the format spells, so
5688 // the only thing this can refuse is the name, the label or the
5689 // attributes.
5690 if let Err(e) = new_editor(b"", self.format)
5691 .map_err(|e| e.to_string())
5692 .and_then(|mut scratch| {
5693 scratch
5694 .insert_directive(0, name, label, &attrs)
5695 .map_err(|e| e.to_string())
5696 })
5697 {
5698 self.status = Some(format!("{what}: {e}"));
5699 return;
5700 }
5701 self.caret = self.skip_trailing_close_delims(self.caret);
5702 // A selection is replaced by the directive, as a rule replaces one.
5703 if let Some((s, e)) = self.selection() {
5704 self.splice(s, e, "", EditKind::Other);
5705 }
5706 self.anchor = None;
5707 self.record_caret();
5708 let at = self.caret;
5709 // A failure here is not fatal: the directive still lands after the
5710 // block, which is what this call was trying to improve on.
5711 let parted = self.part_for_block(at);
5712 match self.editor.insert_directive(at, name, label, &attrs) {
5713 Ok(change) => {
5714 self.last_edit_kind = None;
5715 self.refresh();
5716 if parted {
5717 self.coalesce_last_undo();
5718 }
5719 self.anchor = None;
5720 self.caret = change.new.end;
5721 self.dirty = self.source != self.clean_source;
5722 self.status = None;
5723 self.clamp_caret();
5724 self.record_caret();
5725 self.caret_under_written_block(change.new, parted);
5726 }
5727 Err(e) => self.status = Some(format!("{what}: {e}")),
5728 }
5729 }
5730
5731 /// The alignment in force at the caret, or `None` for the theme's default —
5732 /// which swatch of an alignment control is lit.
5733 ///
5734 /// Read off the nearest node that names one: the block the caret is in, and
5735 /// the `div`s around it after that. [`mark_color_at_caret`](Self::mark_color_at_caret)'s
5736 /// shape, one property along.
5737 pub fn alignment_at_caret(&mut self) -> Option<Align> {
5738 self.presentation_chain()
5739 .iter()
5740 .find_map(|attrs| Align::from_attrs(attrs))
5741 }
5742
5743 /// The line spacing in force at the caret, or `None` for the theme's own.
5744 /// [`alignment_at_caret`](Self::alignment_at_caret)'s peer.
5745 pub fn line_spacing_at_caret(&mut self) -> Option<LineHeight> {
5746 self.presentation_chain()
5747 .iter()
5748 .find_map(|attrs| LineHeight::from_attrs(attrs))
5749 }
5750
5751 /// The size in force at the caret, or `None` for the theme's own — the
5752 /// entry a size menu shows ticked.
5753 ///
5754 /// Run-level, so the chain starts one node deeper: the attributed span the
5755 /// caret stands in, then its block, then the `div`s around it. The nearest
5756 /// wins, which is the rule the walker draws by.
5757 ///
5758 /// A name or a value, whichever the nearest node wrote. A `data-size` the
5759 /// grammar does not cover — a `huge` from elsewhere — is not a size this
5760 /// can answer, so the answer is `None` and the menu ticks *Default*, the
5761 /// same thing it did before the vocabulary opened.
5762 pub fn font_size_at_caret(&mut self) -> Option<FontSize> {
5763 self.presentation_chain()
5764 .iter()
5765 .find_map(|attrs| FontSize::from_attrs(attrs))
5766 }
5767
5768 /// The face in force at the caret, or `None` for the theme's body face.
5769 /// [`font_size_at_caret`](Self::font_size_at_caret)'s peer.
5770 pub fn font_family_at_caret(&mut self) -> Option<FontFace> {
5771 self.presentation_chain()
5772 .iter()
5773 .find_map(|attrs| FontFace::from_attrs(attrs))
5774 }
5775
5776 /// The *text* colour in force at the caret, or `None` for the theme's.
5777 /// [`font_size_at_caret`](Self::font_size_at_caret)'s peer, and not
5778 /// [`mark_color_at_caret`](Self::mark_color_at_caret) — that one reads a
5779 /// highlight's background off a `mark`, and a `mark` is never in this chain.
5780 pub fn text_color_at_caret(&mut self) -> Option<TextColor> {
5781 self.presentation_chain()
5782 .iter()
5783 .find_map(|attrs| TextColor::from_attrs(attrs))
5784 }
5785
5786 /// The selection-or-caret half of the three run-level gestures: a span over
5787 /// a real selection, the caret's block over none.
5788 fn set_run_attr(&mut self, what: &str, key: &str, value: Option<&str>) {
5789 match self.selection() {
5790 Some((start, end)) => {
5791 let attrs = with_attr(&self.run_attrs_over(start, end), key, value);
5792 self.write_run_attrs(what, start, end, attrs);
5793 }
5794 None => {
5795 let own = self.block_attrs_at_caret();
5796 if value.is_none()
5797 && self.refuse_clear_from_div(what, &own, |a| a.iter().any(|(k, _)| k == key))
5798 {
5799 return;
5800 }
5801 let attrs = with_attr(&own, key, value);
5802 self.write_block_attrs(what, attrs);
5803 }
5804 }
5805 }
5806
5807 /// A clear this gesture cannot carry out, said out loud instead of written:
5808 /// the node it rewrites — the caret's block, or the `<div>` around it that
5809 /// [`block_attrs_at_caret`](Self::block_attrs_at_caret) folds to in Markdown
5810 /// — does not name the property at all, and a `div` further out does.
5811 ///
5812 /// Handing twig the block's attributes with the key already absent changes
5813 /// no byte, and the query goes on answering `Some` off the div: the menu
5814 /// entry the author pressed stays unticked, and nothing says why. Twig's
5815 /// `set_block_attrs` reaches one node, so leaf cannot clear a key it did not
5816 /// write on a node it is not rewriting — the honest answer is the status
5817 /// line, in the voice the other refusals use.
5818 ///
5819 /// `names` is the property's own reading of an attribute list, because
5820 /// alignment lives in a `class` token rather than a key of its own. Spans
5821 /// are skipped: one inside the block is not what a *block* gesture writes
5822 /// either, but neither is it "the div around the block", and the run-level
5823 /// gestures reach it through a selection.
5824 fn refuse_clear_from_div(
5825 &mut self,
5826 what: &str,
5827 own: &Attrs,
5828 names: impl Fn(&Attrs) -> bool,
5829 ) -> bool {
5830 if names(own) {
5831 return false;
5832 }
5833 let caret = self.caret.min(self.source.len());
5834 if !self
5835 .attr_chain_at(caret)
5836 .iter()
5837 .any(|(span, attrs)| !span && names(attrs))
5838 {
5839 return false;
5840 }
5841 self.status = Some(format!("{what}: set on the div around the block"));
5842 true
5843 }
5844
5845 /// Hand `attrs` to twig as the caret's block's whole attribute set, with the
5846 /// status, undo and caret plumbing [`set_mark_color`](Self::set_mark_color)
5847 /// has.
5848 ///
5849 /// **The caret keeps its place in the text, not its byte offset.** How a
5850 /// format spells a block's attributes is markup written *around* the block
5851 /// — djot's `{…}` line above it, a `<div …>` and two blank lines in front of
5852 /// it in Markdown, a longer opening tag in HTML — and every one of those
5853 /// grows or shrinks above the author's own bytes. Where twig's change
5854 /// rewrites the block whole (Markdown's div is spliced as one region, block
5855 /// included) the plain arithmetic of [`reanchor`] has nothing to shift by
5856 /// and parks the caret at the end of the splice, past the closing `</div>`:
5857 /// the caret is then in no block at all, so a second press of the same menu
5858 /// answers "no block at the caret" and the toolbar's queries read nothing.
5859 /// [`reanchor_in_block`] is what carries it across instead — the block's
5860 /// content span before and after, which is the one thing the respelling
5861 /// leaves alone.
5862 ///
5863 /// Read *before* the splice and applied *after* `refresh`, because both
5864 /// halves of that mapping are facts about a tree twig is between: the
5865 /// block's old bytes are gone once the edit lands, and its new ones are not
5866 /// in `self.source` until the refresh puts them there.
5867 fn write_block_attrs(&mut self, what: &str, attrs: Attrs) {
5868 if self.read_only || self.refuse_unsupported(what, Gesture::SetBlockAttrs) {
5869 return;
5870 }
5871 // A blank line has no block to carry an attribute, and twig answers
5872 // `NotFound` there — say so in leaf's own words instead.
5873 let Some(at) = self.block_offset_for_caret() else {
5874 self.status = Some(format!("{what}: no block at the caret"));
5875 return;
5876 };
5877 self.record_caret();
5878 let pairs = attr_pairs(&attrs);
5879 let was = self.block_content_at(at);
5880 let text = was.clone().map(|s| self.source[s].to_string());
5881 match self.editor.set_block_attrs(at, &pairs) {
5882 Ok(change) => {
5883 let (caret, anchor) = (self.caret, self.anchor);
5884 self.last_edit_kind = None; // structural edit is its own undo step
5885 self.refresh();
5886 let now = self.block_content_in(&change.new, text.as_deref());
5887 // A block the two halves cannot both name — a code block, a
5888 // caret in a list's marker — takes the plain arithmetic, which
5889 // is what it had before.
5890 let block = was.as_ref().zip(now.as_ref());
5891 self.caret = reanchor_in_block(caret, &change, block);
5892 self.anchor = anchor.map(|a| reanchor_in_block(a, &change, block));
5893 self.dirty = self.source != self.clean_source;
5894 self.status = None;
5895 // The clamp reads the caret floor off the map, and this edit
5896 // can move the floor: taking the `{…}` line off a djot
5897 // document's first block moves the first rendered offset to 0,
5898 // and a floor read from the old map stood the caret past the
5899 // block's text. So the map is this revision's before the clamp
5900 // — see `open_paragraph_at_block_edge`.
5901 self.rebuild_map();
5902 self.clamp_caret();
5903 self.record_caret();
5904 }
5905 Err(e) => self.status = Some(format!("{what}: {e}")),
5906 }
5907 }
5908
5909 /// The content span of the innermost paragraph or heading covering `off` —
5910 /// the author's own bytes, without the `# ` or the `<p>` that spells the
5911 /// block around them.
5912 ///
5913 /// The same two kinds [`block_attrs_at_caret`](Self::block_attrs_at_caret)
5914 /// reads, so that what a gesture re-anchors by is the block it wrote to.
5915 fn block_content_at(&mut self, off: usize) -> Option<Range<usize>> {
5916 self.nodes()
5917 .into_iter()
5918 .filter(|n| matches!(n.kind, Kind::Para | Kind::Heading))
5919 .filter(|n| n.span.start <= off && off <= n.span.end)
5920 .min_by_key(|n| n.span.end - n.span.start)
5921 .map(|n| n.content_span.unwrap_or(n.span))
5922 }
5923
5924 /// [`block_content_at`](Self::block_content_at)'s other half: the content
5925 /// span of the block `region` holds now, found by the bytes it held before.
5926 ///
5927 /// Matched on the text rather than taken as the first block in the region,
5928 /// because a rewritten region is markup and all — `<div class="center">`
5929 /// carries words of its own — and because the block this gesture moved is
5930 /// the one whose content the respelling did not touch. `None` where the
5931 /// region holds no block at all, which is djot's every case: the `{…}` line
5932 /// is spliced above the block and the block itself never moves through the
5933 /// change at all, only past it.
5934 fn block_content_in(
5935 &mut self,
5936 region: &Range<usize>,
5937 text: Option<&str>,
5938 ) -> Option<Range<usize>> {
5939 let text = text?;
5940 let spans: Vec<Range<usize>> = self
5941 .nodes()
5942 .into_iter()
5943 .filter(|n| matches!(n.kind, Kind::Para | Kind::Heading))
5944 .filter(|n| region.start <= n.span.start && n.span.end <= region.end)
5945 .map(|n| n.content_span.unwrap_or(n.span))
5946 .collect();
5947 spans
5948 .into_iter()
5949 .find(|s| self.source.get(s.clone()) == Some(text))
5950 }
5951
5952 /// Hand `attrs` to twig as the attribute set of the span over `[start,
5953 /// end)` — wrapping one, or re-styling the one the range already lies in,
5954 /// or unwrapping it when `attrs` is empty.
5955 ///
5956 /// What the splice leaves selected is the span's **content** — the author's
5957 /// words — and not the whole of `change.new`, which is markup and all:
5958 /// `[big]{data-size="large"}` in djot, `<span …>big</span>` in Markdown. A
5959 /// selection reaching past the node's own span lies in no span at all, so a
5960 /// second press of the menu would nest a fresh one instead of re-styling
5961 /// the one just written.
5962 fn write_run_attrs(&mut self, what: &str, start: usize, end: usize, attrs: Attrs) {
5963 if self.read_only || self.refuse_unsupported(what, Gesture::WrapRangeAttrs) {
5964 return;
5965 }
5966 self.record_caret();
5967 let pairs = attr_pairs(&attrs);
5968 match self.editor.wrap_range_attrs(start, end, &pairs) {
5969 Ok(change) => {
5970 self.last_edit_kind = None;
5971 self.refresh();
5972 let content = self.span_content_in(&change.new);
5973 self.anchor = Some(content.start);
5974 self.caret = content.end;
5975 self.dirty = self.source != self.clean_source;
5976 self.status = None;
5977 self.clamp_caret();
5978 self.record_caret();
5979 }
5980 Err(e) => self.status = Some(format!("{what}: {e}")),
5981 }
5982 }
5983
5984 /// The content range of the attributed span `spliced` now holds — the
5985 /// outermost one inside it, since that is the one just written — or
5986 /// `spliced` itself where the splice left no span, which is what an unwrap
5987 /// leaves behind.
5988 fn span_content_in(&mut self, spliced: &Range<usize>) -> Range<usize> {
5989 self.nodes()
5990 .into_iter()
5991 .filter(wysiwyg::is_run_span)
5992 .filter(|n| spliced.start <= n.span.start && n.span.end <= spliced.end)
5993 .max_by_key(|n| n.span.end - n.span.start)
5994 .and_then(|n| n.content_span)
5995 .unwrap_or_else(|| spliced.clone())
5996 }
5997
5998 /// The attribute set `set_block_attrs` is about to **replace** at the caret
5999 /// — which is the block's own, except in Markdown, where twig writes a
6000 /// block's attributes onto a `<div>` around it and rewrites that div when
6001 /// the block is its sole child. Reading the paragraph there would hand back
6002 /// an empty list and quietly drop everything the div said.
6003 ///
6004 /// Empty when the caret is in no block at all, which is the same list a
6005 /// block carrying no attributes gives — and the right one either way, since
6006 /// the gesture then refuses on its own.
6007 fn block_attrs_at_caret(&mut self) -> Attrs {
6008 let Some(off) = self.block_offset_for_caret() else {
6009 return Vec::new();
6010 };
6011 let nodes = self.nodes();
6012 let Some(block) = nodes
6013 .iter()
6014 .filter(|n| matches!(n.kind, Kind::Para | Kind::Heading))
6015 .filter(|n| n.span.start <= off && off <= n.span.end)
6016 .min_by_key(|n| n.span.end - n.span.start)
6017 else {
6018 return Vec::new();
6019 };
6020 if self.format == Format::Markdown
6021 && let Some(parent) = block.parent.and_then(|p| nodes.iter().find(|n| n.id == p))
6022 && wysiwyg::element_tag(parent) == Some("div")
6023 && nodes.iter().filter(|n| n.parent == Some(parent.id)).count() == 1
6024 {
6025 return parent.attrs.clone();
6026 }
6027 block.attrs.clone()
6028 }
6029
6030 /// The attribute set `wrap_range_attrs` is about to **replace** over
6031 /// `[start, end)` — the innermost attributed span the range lies inside,
6032 /// which twig re-styles rather than nesting a second one in. Empty when the
6033 /// range lies in no span, where the gesture mints a fresh one.
6034 fn run_attrs_over(&mut self, start: usize, end: usize) -> Attrs {
6035 self.nodes()
6036 .into_iter()
6037 .filter(wysiwyg::is_run_span)
6038 .filter(|n| n.span.start <= start && end <= n.span.end)
6039 .min_by_key(|n| n.span.end - n.span.start)
6040 .map(|n| n.attrs)
6041 .unwrap_or_default()
6042 }
6043
6044 /// The attribute lists that bear on a presentation query, **nearest first**:
6045 /// the attributed spans the caret stands in (innermost first), then its
6046 /// block, then the `div`s around it. A `find_map` down this is the whole of
6047 /// each query, and the order is the rule the walker draws by.
6048 ///
6049 /// Read at the caret, and at the selection's *start* when the caret stands
6050 /// in no span there. [`write_run_attrs`](Self::write_run_attrs) leaves the
6051 /// caret one past the span it just wrote — `toggle`'s convention — so
6052 /// asking the menu which entry that press just ticked must not answer
6053 /// `None`. Exactly the reason [`mark_offset`](Self::mark_offset) tries both.
6054 fn presentation_chain(&mut self) -> Vec<Attrs> {
6055 let caret = self.caret.min(self.source.len());
6056 let mut chain = self.attr_chain_at(caret);
6057 if !chain.iter().any(|(span, _)| *span)
6058 && let Some((start, _)) = self.selection()
6059 {
6060 let alt = self.attr_chain_at(start);
6061 if alt.iter().any(|(span, _)| *span) {
6062 chain = alt;
6063 }
6064 }
6065 chain.into_iter().map(|(_, attrs)| attrs).collect()
6066 }
6067
6068 /// [`presentation_chain`](Self::presentation_chain) at one offset — every
6069 /// node bearing the vocabulary that covers it, innermost first, each paired
6070 /// with whether it is an attributed span (which is what tells the caller
6071 /// its run-level answer came from a run).
6072 ///
6073 /// Sorted by span length, which *is* the nesting order: a span lies inside
6074 /// its block and a block inside its div, so shortest-first is
6075 /// nearest-first without a second tree walk.
6076 fn attr_chain_at(&mut self, off: usize) -> Vec<(bool, Attrs)> {
6077 let off = off.min(self.source.len());
6078 let mut hits: Vec<(usize, bool, Attrs)> = Vec::new();
6079 for n in self.nodes() {
6080 let span = wysiwyg::is_run_span(&n);
6081 let block = matches!(n.kind, Kind::Para | Kind::Heading);
6082 let div = wysiwyg::element_tag(&n) == Some("div");
6083 if !(span || block || div) {
6084 continue;
6085 }
6086 // A span is half-open, the way a mark is: the offset one past it is
6087 // the text after it. A block and a div claim their end too, so a
6088 // caret resting at the end of a line still reads its paragraph.
6089 let inside = if span {
6090 n.span.start <= off && off < n.span.end
6091 } else {
6092 n.span.start <= off && off <= n.span.end
6093 };
6094 if !inside {
6095 continue;
6096 }
6097 hits.push((n.span.end - n.span.start, span, n.attrs));
6098 }
6099 hits.sort_by_key(|(len, _, _)| *len);
6100 hits.into_iter()
6101 .map(|(_, span, attrs)| (span, attrs))
6102 .collect()
6103 }
6104
6105 /// Convert the block at the caret to a heading level or paragraph.
6106 pub fn set_block(&mut self, kind: BlockKind) {
6107 // The read-only gate — this door reaches twig without the splice.
6108 if self.read_only {
6109 return;
6110 }
6111 if self.refuse_unsupported(&format!("{kind:?}"), Gesture::SetBlock) {
6112 return;
6113 }
6114 self.record_caret();
6115 // A blank line has no node to convert, and twig opens a block there
6116 // rather than declining — so the caret's own offset is the right thing
6117 // to hand it when `block_offset_for_caret` finds nothing.
6118 let offset = self.block_offset_for_caret().unwrap_or(self.caret);
6119 match self.editor.set_block(offset, kind) {
6120 Ok(change) => {
6121 self.last_edit_kind = None;
6122 self.refresh();
6123 // Opening a block on a blank line writes a marker the caret
6124 // belongs *after*; converting an existing one moves nothing.
6125 self.caret = self.caret.max(change.new.end);
6126 self.clamp_caret();
6127 self.anchor = None;
6128 self.dirty = self.source != self.clean_source;
6129 self.status = None;
6130 self.record_caret();
6131 }
6132 Err(e) => self.status = Some(format!("{kind:?}: {e}")),
6133 }
6134 }
6135
6136 /// Whether `off` is inside a text block (paragraph, heading, code block…).
6137 fn has_block_at(&mut self, off: usize) -> bool {
6138 self.editor.ancestors_at(off).ok().is_some_and(|chain| {
6139 chain
6140 .iter()
6141 .any(|m| !wysiwyg::is_inline_kind(&m.kind) && !is_block_container(&m.kind))
6142 })
6143 }
6144
6145 /// The offset to hand twig's `set_block`: the caret when it is already inside
6146 /// a block, otherwise nudged onto the previous character (a caret at a line
6147 /// end sits at the doc level, outside the block). `None` when the caret is on
6148 /// a blank line — a new paragraph with no block node to convert.
6149 fn block_offset_for_caret(&mut self) -> Option<usize> {
6150 let caret = self.caret.min(self.source.len());
6151 if self.has_block_at(caret) {
6152 return Some(caret);
6153 }
6154 // Nudge to the previous character — but never across a newline: that would
6155 // target the previous block, and a blank line genuinely has no block.
6156 if let Some((i, ch)) = self.source[..caret].char_indices().next_back()
6157 && ch != '\n'
6158 && self.has_block_at(i)
6159 {
6160 return Some(i);
6161 }
6162 None
6163 }
6164
6165 /// The heading level of the text block at the caret, or `None` when that
6166 /// block is not a heading.
6167 pub fn current_heading_level(&mut self) -> Option<u32> {
6168 let caret = self.caret;
6169 self.nodes()
6170 .into_iter()
6171 .filter(|n| n.kind == Kind::Heading)
6172 .find(|n| n.span.start <= caret && caret <= n.span.end)
6173 .and_then(|n| n.level)
6174 }
6175
6176 /// The inline marks in force at the caret (or over the selection) — what a
6177 /// toolbar draws lit, and the block-level [`Doc::current_heading_level`]'s
6178 /// inline counterpart. Cheap enough to call every frame: one twig
6179 /// `ancestors_at` query per caret (two with a selection), each walking root
6180 /// → deepest node at one offset. It never snapshots the tree the way
6181 /// `current_heading_level` does, and the returned set is a `Copy` bitset, so
6182 /// the only allocation is twig's own small ancestor `Vec`.
6183 ///
6184 /// **A selection reports a mark only when the mark covers *all* of it.**
6185 /// That's what every real toolbar means by an active button — Bold lit over
6186 /// a half-bold selection would claim a press turns bold *off*, when
6187 /// [`Doc::toggle`] hands the range to twig and gets the whole thing bolded.
6188 /// Whole-coverage is asked as "is the same mark node standing over both the
6189 /// first and the last character?": inline nodes are contiguous, so one node
6190 /// covering both ends covers every byte between them. Two touching runs
6191 /// (`**a****b**`) are two nodes, and correctly light nothing.
6192 ///
6193 /// At a bare caret a mark is active when the caret stands inside the mark's
6194 /// span — `span.start <= caret < span.end`, delimiters included, which is
6195 /// what makes the boundaries behave. In `a **bold** b` the offsets from the
6196 /// opening `*` (2) through the last byte of the closing `**` (9) are all
6197 /// bold, so the WYSIWYG caret both before `b` and after `d` (the delimiters
6198 /// are hidden, and those offsets are 4 and 8) reports bold — matching where
6199 /// typing would actually land inside the marked run. The offset one past the
6200 /// mark (10) is the text after it and reports nothing, at the end of the
6201 /// buffer exactly as in the middle.
6202 pub fn active_inline_marks(&mut self) -> InlineMarks {
6203 let Some((start, end)) = self.selection() else {
6204 // The marks actually in force at the caret, flipped by any armed
6205 // sticky delta — so `⌘b` at a bare caret lights the Bold button
6206 // immediately, before a single character is typed.
6207 let base: InlineMarks = self
6208 .marks_at(self.caret)
6209 .into_iter()
6210 .map(|(k, _)| k)
6211 .collect();
6212 return base.xor(self.pending_here());
6213 };
6214 // The selection's *last character*, not its exclusive end: `end` is the
6215 // offset one past the selection, which for a selection ending exactly at
6216 // a mark's close is already outside it (`[4,10)` of `a **bold** b` is
6217 // entirely bold, but offset 10 is the space after).
6218 let last = prev_boundary(&self.source, end);
6219 let head = self.marks_at(start);
6220 let tail = self.marks_at(last);
6221 head.into_iter()
6222 .filter(|m| tail.contains(m))
6223 .map(|(k, _)| k)
6224 .collect()
6225 }
6226
6227 /// The inline marks whose span covers `off`, each with the id of the node
6228 /// carrying it — the id is what lets a selection tell one mark node from
6229 /// another of the same kind.
6230 fn marks_at(&mut self, off: usize) -> Vec<(InlineKind, u32)> {
6231 let off = off.min(self.source.len());
6232 self.editor
6233 .ancestors_at(off)
6234 .unwrap_or_default()
6235 .into_iter()
6236 // `span.end` is the offset one *past* the mark, so it isn't in it.
6237 // twig already resolves a boundary to whatever starts there — in
6238 // `**bold** x` offset 8 is the following text, not the strong — but
6239 // when nothing follows, the tie has nobody to break for and the
6240 // chain still ends at the mark. That would make the answer at the
6241 // last offset of the document depend on whether the file happens to
6242 // end in a newline; the rule is `span.start <= off < span.end`, and
6243 // it's the same rule at the end of a buffer as in the middle.
6244 .filter(|m| off < m.span.end)
6245 .filter_map(|m| inline_kind(&m.kind).map(|k| (k, m.node_id)))
6246 .collect()
6247 }
6248
6249 /// Toggle a heading at the caret: if the block is already this heading level,
6250 /// revert it to a paragraph; otherwise convert it to this heading level.
6251 /// This gives the heading commands the same toggle feel as bold/italic/code —
6252 /// re-applying a heading a line already has turns it back into body text.
6253 pub fn toggle_heading(&mut self, level: u32) {
6254 if self.current_heading_level() == Some(level) {
6255 self.set_block(BlockKind::Paragraph);
6256 } else {
6257 self.set_block(BlockKind::Heading(level));
6258 }
6259 }
6260
6261 /// Toggle a block quote around the selection, or around the block at the
6262 /// caret — the toolbar's Quote button.
6263 pub fn toggle_blockquote(&mut self) {
6264 self.toggle_container(BlockContainerKind::BlockQuote);
6265 }
6266
6267 /// Toggle a numbered (`ordered`) or bulleted list over the selection, or
6268 /// over the block at the caret — one op with the kind as a flag, the way
6269 /// `toggle_heading` takes its level, so a frontend needs no twig type to
6270 /// name the two buttons.
6271 ///
6272 /// Pressing the *other* list's button while in a list converts in place
6273 /// rather than nesting, so the pair reads as one three-state control
6274 /// (bulleted / numbered / neither) rather than two independent wrappers.
6275 pub fn toggle_list(&mut self, ordered: bool) {
6276 self.toggle_container(if ordered {
6277 BlockContainerKind::OrderedList
6278 } else {
6279 BlockContainerKind::BulletList
6280 });
6281 }
6282
6283 /// Toggle a fenced code block over the selection, or over the block at the
6284 /// caret — the toolbar's Code Block button, and the only way the rich view
6285 /// offers to open one: a typed backtick is escaped there, since it is a
6286 /// character the author wrote and not markup they meant.
6287 ///
6288 /// Fencing is twig's ([`Editor::toggle_code_block`]): it measures the fence
6289 /// against the body, keeps a quote's `> ` on every line, and peels a fence
6290 /// (or dedents an indented block) on the way back. What is leaf's is the
6291 /// shape twig declines: a blank line holds no block to fence, and the
6292 /// gesture nobody should have to learn is "type something first" — so leaf
6293 /// spells the empty fence itself, with the caret on the empty line inside it
6294 /// and a blank line either side, which is what typing into a new block and
6295 /// then Backspacing out of it would have left.
6296 ///
6297 /// Inside a list item twig fences at the item's content column and keeps
6298 /// the marker (a task item's box too) on the opening fence's line, so the
6299 /// block stays the item's; unfencing puts the marker back on the text.
6300 /// What twig still refuses — an AsciiDoc item's own first line — is
6301 /// reported rather than worked around.
6302 pub fn toggle_code_block(&mut self) {
6303 // The read-only gate — this door reaches twig without the splice.
6304 if self.read_only {
6305 return;
6306 }
6307 if self.refuse_unsupported("code block", Gesture::ToggleCodeBlock) {
6308 return;
6309 }
6310 let selected = self.selection();
6311 // The caret at a code block's rows resolves to its *content*, and twig
6312 // finds the block from any offset inside its span — but a caret parked
6313 // on the closing fence's own line end is past it, so the block's start
6314 // is handed over whenever the caret is in one at all.
6315 let (start, end) = match selected {
6316 Some(range) => range,
6317 None => match self.code_block_start_at_caret() {
6318 Some(start) => (start, start),
6319 None => match self.block_offset_for_caret() {
6320 Some(off) => (off, off),
6321 None => return self.open_empty_code_block(),
6322 },
6323 },
6324 };
6325 self.record_caret();
6326 match self.editor.toggle_code_block(start, end, None) {
6327 Ok(change) => {
6328 // Read the caret's place out of the *pre-edit* source, before
6329 // `refresh` swaps it out — and how many lines the region held,
6330 // which is what says whether a fence line went in above the
6331 // caret's line or came off it.
6332 let place = selected
6333 .is_none()
6334 .then(|| self.caret_line_tail(&change.old));
6335 let old_lines = self.source[change.old.clone()].matches('\n').count();
6336 self.last_edit_kind = None; // structural edit is its own undo step
6337 self.refresh();
6338 match place {
6339 // From a selection: keep the block selected, so a second
6340 // press reverses the first (twig unfences from any offset in
6341 // the fence's span, the region's start included).
6342 None => {
6343 self.anchor = Some(change.new.start);
6344 self.caret = change.new.end;
6345 }
6346 // From a caret: the same line, the same distance from its
6347 // end, shifted by the opening fence that was written above
6348 // it (or peeled off). Dedenting an indented block keeps the
6349 // lines one-to-one, and shifts nothing.
6350 Some((line, tail)) => {
6351 let new_lines = self.source[change.new.start.min(self.source.len())
6352 ..change.new.end.min(self.source.len())]
6353 .matches('\n')
6354 .count();
6355 let line = match new_lines.cmp(&old_lines) {
6356 std::cmp::Ordering::Greater => line + 1,
6357 std::cmp::Ordering::Less => line.saturating_sub(1),
6358 std::cmp::Ordering::Equal => line,
6359 };
6360 self.anchor = None;
6361 self.caret = self.line_tail_offset(&change.new, (line, tail));
6362 }
6363 }
6364 self.dirty = self.source != self.clean_source;
6365 self.status = None;
6366 self.clamp_caret();
6367 self.record_caret();
6368 }
6369 Err(e) => self.status = Some(format!("code block: {e}")),
6370 }
6371 }
6372
6373 /// Write an empty fenced block on the blank line the caret stands on, and
6374 /// put the caret on the empty line inside it — the half of
6375 /// [`toggle_code_block`](Self::toggle_code_block) that is leaf's, because
6376 /// twig reports `NotFound` for a range no block covers.
6377 ///
6378 /// Inside a quote the fence lines and the empty line keep the quote's
6379 /// prefix, as twig's own fencing does. A blank line goes in on whichever
6380 /// side has text against it, since a fence may interrupt a paragraph in
6381 /// Markdown but a block standing tight against its neighbours is not the
6382 /// document any editor writes.
6383 fn open_empty_code_block(&mut self) {
6384 let caret = self.caret.min(self.source.len());
6385 let prefix = self.quote_prefix_at(caret);
6386 let line_start = self.source[..caret].rfind('\n').map_or(0, |i| i + 1);
6387 let line_end = self.source[caret..]
6388 .find('\n')
6389 .map_or(self.source.len(), |i| caret + i);
6390 let prev_blank = line_start == 0
6391 || self.source[..line_start - 1]
6392 .rsplit('\n')
6393 .next()
6394 .is_some_and(|l| l.trim_start_matches(['>', ' ']).is_empty());
6395 let next_blank = line_end >= self.source.len()
6396 || self.source[line_end + 1..]
6397 .split('\n')
6398 .next()
6399 .is_some_and(|l| l.trim_start_matches(['>', ' ']).is_empty());
6400 // A blank line inside a quote is a bare `>`: the space after it is
6401 // the content's, and there is none.
6402 let blank = prefix.trim_end();
6403 let mut text = String::new();
6404 if !prev_blank {
6405 text.push_str(blank);
6406 text.push('\n');
6407 }
6408 text.push_str(&prefix);
6409 text.push_str("```\n");
6410 text.push_str(&prefix);
6411 let inside = text.len();
6412 text.push('\n');
6413 text.push_str(&prefix);
6414 text.push_str("```");
6415 if !next_blank {
6416 text.push('\n');
6417 text.push_str(blank);
6418 }
6419 // Over whatever the blank line held (its quote prefix, trailing
6420 // spaces), so the block's own prefix is the one twig will read back.
6421 let at = line_start;
6422 if !self.splice(at, line_end, &text, EditKind::Other) {
6423 return;
6424 }
6425 self.caret = at + inside;
6426 self.anchor = None;
6427 self.clamp_caret();
6428 self.record_caret();
6429 }
6430
6431 /// Whether the caret stands in a code block, fenced or indented — what
6432 /// lights the Code Block button. Wider than
6433 /// [`caret_in_fenced_code`](Self::caret_in_fenced_code), which asks the
6434 /// narrower question a language prompt needs.
6435 pub fn caret_in_code_block(&mut self) -> bool {
6436 self.code_block_start_at_caret().is_some()
6437 }
6438
6439 /// Whether the caret stands anywhere inside a block quote, however deep —
6440 /// what lights the Block Quote button. A quote is a container rather than
6441 /// a block kind, so this is true alongside a heading or a code block, not
6442 /// instead of one.
6443 pub fn caret_in_blockquote(&mut self) -> bool {
6444 let off = self.caret.min(self.source.len());
6445 // A quote's span stops short of its closing newline, so the end of its
6446 // last line is `span.end` itself, and has to count.
6447 self.nodes()
6448 .into_iter()
6449 .any(|n| n.kind == Kind::BlockQuote && n.span.start <= off && off <= n.span.end)
6450 }
6451
6452 // ── Task list items ──────────────────────────────────────────────────────
6453 // The checkbox in `- [x] done`. twig owns all three gestures: the box is
6454 // inline content of the item's first paragraph rather than part of its
6455 // marker, so adding or removing one must leave the item's continuation
6456 // indentation alone, and an item inside a quote is found past the quote
6457 // markers. leaf names the gesture and the offset; the spelling is twig's.
6458
6459 /// Whether the list item at the caret carries a checkbox, and which way it
6460 /// faces — `Some(true)` ticked, `Some(false)` empty, `None` for a plain list
6461 /// item or no item at all. What a toolbar reads to light its checkbox button.
6462 pub fn task_checked_at_caret(&mut self) -> Option<bool> {
6463 self.task_checked_at(self.caret)
6464 }
6465
6466 /// [`task_checked_at_caret`](Self::task_checked_at_caret) for an arbitrary
6467 /// offset — what a frontend asks before deciding a click landed on a box.
6468 pub fn task_checked_at(&mut self, offset: usize) -> Option<bool> {
6469 self.innermost_list_item(offset.min(self.source.len()))?
6470 .checked
6471 }
6472
6473 /// Tick or untick the task item at the caret (the checkbox's keyboard half).
6474 /// A no-op with a reported reason when the caret is in no task item — minting
6475 /// a box here is [`toggle_task_item`](Self::toggle_task_item)'s job.
6476 pub fn toggle_task_checked(&mut self) {
6477 self.toggle_task_at(self.caret);
6478 }
6479
6480 /// Tick or untick the task item covering `offset` — what a *click* on a
6481 /// rendered checkbox is. Separate from the caret form because a click carries
6482 /// its own offset and must not first move the caret there: ticking a box
6483 /// three paragraphs away should not take the cursor with it.
6484 pub fn toggle_task_at(&mut self, offset: usize) {
6485 // The read-only gate — this door reaches twig without the splice.
6486 if self.read_only {
6487 return;
6488 }
6489 if self.refuse_unsupported("task", Gesture::ToggleTaskChecked) {
6490 return;
6491 }
6492 let offset = offset.min(self.source.len());
6493 self.record_caret();
6494 match self.editor.toggle_task_checked(offset) {
6495 Ok(_) => self.after_task_edit(),
6496 Err(e) => self.status = Some(format!("task: {e}")),
6497 }
6498 }
6499
6500 /// Give the list item at the caret a checkbox, or take its checkbox away —
6501 /// the gesture that converts between a plain bullet and a task. A new box
6502 /// arrives unticked.
6503 ///
6504 /// Outside a list the caret's block becomes a bullet first, and the box
6505 /// goes on the new item — one undo step. twig's gesture only converts an
6506 /// item that is already there, and a Checklist button that did nothing on
6507 /// a paragraph read as broken.
6508 pub fn toggle_task_item(&mut self) {
6509 // The read-only gate — this door reaches twig without the splice.
6510 if self.read_only {
6511 return;
6512 }
6513 if self.refuse_unsupported("task", Gesture::ToggleTaskItem) {
6514 return;
6515 }
6516 if self
6517 .innermost_list_item(self.caret.min(self.source.len()))
6518 .is_none()
6519 {
6520 if self.refuse_unsupported(
6521 "task",
6522 Gesture::ToggleBlockContainer(BlockContainerKind::BulletList),
6523 ) {
6524 return;
6525 }
6526 self.begin_undo_group();
6527 self.toggle_list(false);
6528 if self
6529 .innermost_list_item(self.caret.min(self.source.len()))
6530 .is_some()
6531 {
6532 self.box_item_at_caret();
6533 }
6534 self.end_undo_group();
6535 return;
6536 }
6537 self.box_item_at_caret();
6538 }
6539
6540 /// twig's half of [`toggle_task_item`](Self::toggle_task_item): the box on
6541 /// the item the caret is already in.
6542 fn box_item_at_caret(&mut self) {
6543 let caret = self.caret.min(self.source.len());
6544 self.record_caret();
6545 match self.editor.toggle_task_item(caret) {
6546 Ok(_) => self.after_task_edit(),
6547 Err(e) => self.status = Some(format!("task: {e}")),
6548 }
6549 }
6550
6551 /// Settle after a task gesture. The caret rides its old byte offset and is
6552 /// clamped back in: a box is three or four bytes on the item's first line, so
6553 /// text after it shifts by that much at most, and `clamp_caret` lands it on a
6554 /// real stop either way.
6555 fn after_task_edit(&mut self) {
6556 self.last_edit_kind = None;
6557 self.refresh();
6558 self.anchor = None;
6559 self.dirty = self.source != self.clean_source;
6560 self.status = None;
6561 self.clamp_caret();
6562 self.record_caret();
6563 }
6564
6565 // ── Tables ───────────────────────────────────────────────────────────────
6566 // A table is a grid, and twig edits it as one — add/remove/move a row or
6567 // column, set a column's alignment — re-spelling the whole table in a single
6568 // splice. Every gesture is anchored at the caret's cell. leaf just names the
6569 // gesture and re-reads the result; the whole table's numbering, borders, and
6570 // delimiter are twig's to keep straight.
6571
6572 /// Whether the caret is inside a table — what a frontend asks to enable or
6573 /// disable its table controls.
6574 ///
6575 /// An HTML `<table>` still answers `true`: the caret really is in a table,
6576 /// and the reason the grid controls stay dark there is
6577 /// [`Capabilities::table`], which is a fact about the document's format
6578 /// rather than about the caret. A frontend needs both.
6579 pub fn caret_in_table(&mut self) -> bool {
6580 let caret = self.caret.min(self.source.len());
6581 self.editor
6582 .ancestors_at(caret)
6583 .map(|c| c.into_iter().any(|m| m.kind == Kind::Table))
6584 .unwrap_or(false)
6585 }
6586
6587 /// One grid op, guarded and settled — the shared body of the seven below.
6588 ///
6589 /// The guard is why this exists rather than seven copies of the same three
6590 /// lines, and it is the one guard leaf cannot delegate to twig. The table
6591 /// editor is the gesture family that consults no `Syntax` table (it spells a
6592 /// grid, not a delimiter) and therefore the one twig's `Format::supports`
6593 /// deliberately has no variant for: handed an HTML `<table>` it rebuilds the
6594 /// grid as a *pipe table* and reports success, swapping the element out for
6595 /// `| a | b |` and taking the rest of the document's markup with it. Nothing
6596 /// downstream could tell that from a successful edit — the splice is real,
6597 /// the reparse succeeds, `dirty` is honest — which is what makes it worth
6598 /// stopping at the door rather than detecting after the fact. See
6599 /// [`spells_pipe_tables`].
6600 fn table_op(
6601 &mut self,
6602 what: &str,
6603 op: impl FnOnce(&mut Editor, usize) -> Result<(), twig::Error>,
6604 ) {
6605 if self.refuse_unless(what, spells_pipe_tables(self.format)) {
6606 return;
6607 }
6608 self.record_caret();
6609 let at = self.caret;
6610 let r = op(&mut self.editor, at);
6611 self.apply_table(r, what);
6612 }
6613
6614 /// Insert an empty row below (`below`) or above the caret's row.
6615 pub fn table_insert_row(&mut self, below: bool) {
6616 self.table_op("table row", |e, at| e.table_insert_row(at, below));
6617 }
6618
6619 /// Delete the caret's row (not the header, not the last body row).
6620 pub fn table_delete_row(&mut self) {
6621 self.table_op("table row", |e, at| e.table_delete_row(at));
6622 }
6623
6624 /// Insert an empty column right (`right`) or left of the caret's column.
6625 pub fn table_insert_column(&mut self, right: bool) {
6626 self.table_op("table column", |e, at| e.table_insert_column(at, right));
6627 }
6628
6629 /// Delete the caret's column (unless it is the only one).
6630 pub fn table_delete_column(&mut self) {
6631 self.table_op("table column", |e, at| e.table_delete_column(at));
6632 }
6633
6634 /// Set the caret's column to `alignment`.
6635 pub fn table_set_alignment(&mut self, alignment: Alignment) {
6636 self.table_op("table alignment", |e, at| {
6637 e.table_set_alignment(at, alignment)
6638 });
6639 }
6640
6641 /// Move the caret's row one place down (`down`) or up, within the body rows.
6642 pub fn table_move_row(&mut self, down: bool) {
6643 self.table_op("table row", |e, at| e.table_move_row(at, down));
6644 }
6645
6646 /// Move the caret's column one place right (`right`) or left.
6647 pub fn table_move_column(&mut self, right: bool) {
6648 self.table_op("table column", |e, at| e.table_move_column(at, right));
6649 }
6650
6651 /// Settle the caret and document flags after a table op (or report its
6652 /// error). twig re-spells the whole table, so the caret rides its old byte
6653 /// offset and is clamped back into the rebuilt bytes — near enough to where
6654 /// it was, since the op preserves the cells' content and order around it.
6655 fn apply_table(&mut self, result: Result<(), twig::Error>, what: &str) {
6656 match result {
6657 Ok(()) => {
6658 self.last_edit_kind = None;
6659 self.refresh();
6660 self.anchor = None;
6661 self.clamp_caret();
6662 self.dirty = self.source != self.clean_source;
6663 self.status = None;
6664 self.record_caret();
6665 }
6666 Err(e) => self.status = Some(format!("{what}: {e}")),
6667 }
6668 }
6669
6670 /// One `toggle_block_container` over the block-level target.
6671 ///
6672 /// leaf says *where*; twig decides everything else — which blocks the range
6673 /// covers, whether that means wrapping, unwrapping, nesting or converting,
6674 /// and how this document's format spells the prefix. The rule that a
6675 /// container only comes off when the range covers every block it holds is
6676 /// what the re-anchoring below is built around.
6677 fn toggle_container(&mut self, kind: BlockContainerKind) {
6678 // The read-only gate — this door reaches twig without the splice.
6679 if self.read_only {
6680 return;
6681 }
6682 if self.refuse_unsupported(&format!("{kind:?}"), Gesture::ToggleBlockContainer(kind)) {
6683 return;
6684 }
6685 let selected = self.selection();
6686 // A blank line holds no block, and twig opens an *empty* container on one
6687 // — since 3.2.0; it used to decline the range with `NotFound`, which is
6688 // why this used to lend it a scratch paragraph to wrap. Worth knowing
6689 // here because the line-for-line caret mapping below cannot describe it:
6690 // opening one under a paragraph writes the blank line the format needs
6691 // above the marker too, so the rewritten region has a line the old one
6692 // didn't, and "the same line, the same distance from its end" lands on
6693 // that new blank instead of in the container.
6694 let opened_empty = selected.is_none() && self.block_offset_for_caret().is_none();
6695 // Without a selection the target is the caret's own block, resolved the
6696 // way `set_block` resolves it — a caret at a line end sits at the doc
6697 // level and has to be nudged back onto the block it looks like it's in.
6698 // An empty range is enough: twig widens to the whole lines it touches.
6699 let (start, end) = match selected {
6700 Some(range) => range,
6701 None => {
6702 let off = self.block_offset_for_caret().unwrap_or(self.caret);
6703 (off, off)
6704 }
6705 };
6706 self.record_caret();
6707 match self.editor.toggle_block_container(start, end, kind) {
6708 Ok(change) => {
6709 // Read the caret's place out of the *pre-edit* source, before
6710 // `refresh` swaps that source out from under it.
6711 let place = (selected.is_none() && !opened_empty)
6712 .then(|| self.caret_line_tail(&change.old));
6713 self.last_edit_kind = None; // structural edit is its own undo step
6714 self.refresh();
6715 match place {
6716 // Both land the caret at the far end of what twig wrote, and
6717 // differ only in what they leave selected.
6718 //
6719 // From a selection: select what the container now holds, the
6720 // way `toggle` keeps its marked region selected — and for a
6721 // stronger reason than symmetry: a container comes *off* only
6722 // a range covering every block it holds, so a selection left
6723 // on its old bytes (now short by a prefix per line) would nest
6724 // on the second press instead of reversing the first.
6725 //
6726 // From a blank line: nothing to select, and the end of the
6727 // region is exactly past the bare `> ` / `- ` twig wrote —
6728 // the caret standing inside the container that was asked for.
6729 None => {
6730 self.anchor = (!opened_empty).then_some(change.new.start);
6731 self.caret = change.new.end;
6732 }
6733 Some(place) => {
6734 self.anchor = None;
6735 self.caret = self.line_tail_offset(&change.new, place);
6736 }
6737 }
6738 self.dirty = self.source != self.clean_source;
6739 self.status = None;
6740 self.clamp_caret();
6741 self.record_caret();
6742 }
6743 Err(e) => self.status = Some(format!("{kind:?}: {e}")),
6744 }
6745 }
6746
6747 /// The caret's place inside the region a container toggle is rewriting, in
6748 /// the only terms the rewrite preserves: which of the region's lines it sits
6749 /// on, and how many bytes of that line lie ahead of it.
6750 ///
6751 /// A container's markup goes in at column 0 and never touches what follows
6752 /// on the line, so that pair survives the edit exactly where a byte offset
6753 /// does not — a caret left on its old offset slides back by one prefix per
6754 /// line above it, which on a hard-wrapped paragraph parks it *inside* the
6755 /// `> ` it just asked for.
6756 fn caret_line_tail(&self, old: &std::ops::Range<usize>) -> (usize, usize) {
6757 let caret = self.caret.clamp(old.start, old.end);
6758 let line = self.source[old.start..caret].matches('\n').count();
6759 let end = self.source[caret..old.end]
6760 .find('\n')
6761 .map_or(old.end, |i| caret + i);
6762 (line, end - caret)
6763 }
6764
6765 /// [`caret_line_tail`](Self::caret_line_tail) undone against the rewritten
6766 /// region: the offset `tail` bytes back from the end of the region's `line`.
6767 ///
6768 /// Both walks are clamped rather than trusted, because the one op that does
6769 /// *not* keep a region's lines one-to-one is stripping a list — twig blows
6770 /// the items back apart with blank lines between them — and a caret landing
6771 /// on the nearest line of the right item beats one landing out of the region
6772 /// entirely.
6773 fn line_tail_offset(
6774 &self,
6775 new: &std::ops::Range<usize>,
6776 (line, tail): (usize, usize),
6777 ) -> usize {
6778 let region = &self.source[new.start.min(self.source.len())..new.end.min(self.source.len())];
6779 let mut start = 0;
6780 for _ in 0..line {
6781 match region[start..].find('\n') {
6782 Some(i) => start += i + 1,
6783 None => break,
6784 }
6785 }
6786 let end = region[start..]
6787 .find('\n')
6788 .map_or(region.len(), |i| start + i);
6789 new.start + end.saturating_sub(tail).max(start)
6790 }
6791
6792 /// Link the selection to `destination` — the toolbar's Link button. With no
6793 /// selection it acts at the caret, which re-points a link the caret is
6794 /// already standing in (twig replaces an existing link's destination and
6795 /// keeps its text) and otherwise spells a link that has no text of its own:
6796 /// an autolink (`<https://x.dev>`) where the destination is one, and
6797 /// `[destination](destination)` where it isn't.
6798 ///
6799 /// `destination` reaches twig raw. Escaping it is format knowledge and the
6800 /// two formats genuinely disagree — Markdown ends a destination at the first
6801 /// space and moves it into `<…>`, djot reads that `<…>` as part of the URL
6802 /// itself — so the side holding the document is the side that gets to spell
6803 /// it. A destination twig can't carry at all (one with a newline) comes back
6804 /// as an error rather than a quietly rewritten URL.
6805 pub fn insert_link(&mut self, destination: &str) {
6806 if self.read_only || self.refuse_unsupported("link", Gesture::InsertLink) {
6807 return;
6808 }
6809 let (start, end) = self.selection().unwrap_or((self.caret, self.caret));
6810 self.record_caret();
6811 match self.editor.insert_link(start, end, destination) {
6812 Ok(change) => {
6813 self.last_edit_kind = None;
6814 self.refresh();
6815 match self.link_text_span(change.new.start) {
6816 // A link with text of its own: select it, so typing replaces
6817 // a `[dest](dest)`'s stand-in label and a second press
6818 // re-points what the first one linked.
6819 Some(text) => {
6820 self.anchor = (text.start != text.end).then_some(text.start);
6821 self.caret = text.end;
6822 }
6823 // An autolink is finished the moment it's written — its text
6824 // *is* the URL. Leaving it selected would aim the next press
6825 // at the one shape twig still wraps instead of re-points.
6826 None => {
6827 self.anchor = None;
6828 self.caret = change.new.end;
6829 }
6830 }
6831 self.dirty = self.source != self.clean_source;
6832 self.status = None;
6833 self.clamp_caret();
6834 self.record_caret();
6835 }
6836 Err(e) => self.status = Some(format!("link: {e}")),
6837 }
6838 }
6839
6840 /// Insert a block-level image at the caret: ``. Any
6841 /// selection becomes the alt text (so "select a caption, insert image" labels
6842 /// it); with no selection, `alt` is used — empty for none. The caret lands
6843 /// just past the inserted image.
6844 ///
6845 /// Both halves go through twig (`insert_literal` for the alt text,
6846 /// `insert_image` for the image), so neither is spelled here. That used to be a
6847 /// `format!`, and it was wrong the first time an app inserted a real filename:
6848 /// Markdown ends a destination at the first space, so `` is
6849 /// not an image at all — and the fix is per-format, since moving into the
6850 /// `<…>` form is exactly wrong for Djot, where `<…>` becomes the URL itself.
6851 pub fn insert_image(&mut self, destination: &str, alt: &str) {
6852 if self.read_only || self.refuse_unsupported("image", Gesture::InsertImage) {
6853 return;
6854 }
6855 let (start, end) = self.selection().unwrap_or((self.caret, self.caret));
6856 self.record_caret();
6857 // With no selection and an explicit `alt`, the alt text has to exist in the
6858 // document before it can be the image's — and it is raw caller input, so
6859 // it goes in through `insert_literal`, which escapes it for the format
6860 // rather than letting a `]` in someone's caption close the image early.
6861 let (start, end) = if start == end && !alt.is_empty() {
6862 match self.editor.insert_literal(start, alt) {
6863 Ok(change) => (change.new.start, change.new.end),
6864 Err(e) => {
6865 self.status = Some(format!("image: {e}"));
6866 return;
6867 }
6868 }
6869 } else {
6870 (start, end)
6871 };
6872 match self.editor.insert_image(start, end, destination) {
6873 Ok(change) => {
6874 self.last_edit_kind = None;
6875 self.refresh();
6876 // Just past the image, nothing selected — where a caret belongs
6877 // after inserting one.
6878 self.anchor = None;
6879 self.caret = change.new.end;
6880 self.dirty = self.source != self.clean_source;
6881 self.status = None;
6882 self.clamp_caret();
6883 self.record_caret();
6884 }
6885 Err(e) => self.status = Some(format!("image: {e}")),
6886 }
6887 }
6888
6889 /// Insert a block-level image, video, or audio at the caret. The image case
6890 /// is [`insert_image`](Self::insert_image); video and audio are spelled as
6891 /// HTML elements, which is the only spelling Markdown and Djot have for them:
6892 ///
6893 /// ```text
6894 /// <video src="clip.mp4" controls>alt</video>
6895 /// <audio src="take.mp3" controls>alt</audio>
6896 /// ```
6897 ///
6898 /// HTML rather than a `::video{…}` directive deliberately. A directive means
6899 /// something only to an app that knows the vocabulary, so the document would
6900 /// read as literal punctuation everywhere else; `<video>` is what every other
6901 /// renderer already understands, and what leaf's own reader picks back up
6902 /// through `html_elements` promotion (see [`parse_extensions`]).
6903 ///
6904 /// The one-line spelling needs twig ≥ 2.5.1, which widened CommonMark's
6905 /// HTML-block tag list to cover `<video>`/`<audio>`/`<picture>` under
6906 /// `html_elements`. Before that only the multi-line form parsed as a block at
6907 /// all, and this wrote three lines to work around it.
6908 ///
6909 /// `controls` is always written: a player with no transport is a still frame
6910 /// the reader can't do anything with. Any selection becomes the element's
6911 /// fallback text, exactly as it becomes an image's alt.
6912 ///
6913 /// The same verbatim-insertion caveat as [`insert_image`](Self::insert_image)
6914 /// applies, and bites harder here: a `"` in `destination` closes the
6915 /// attribute. A frontend taking these from a file picker is fine; one taking
6916 /// them from free text should keep them tame.
6917 ///
6918 /// [`MediaInfo`]: crate::MediaInfo
6919 pub fn insert_media(&mut self, kind: MediaKind, destination: &str, alt: &str) {
6920 if kind == MediaKind::Image {
6921 return self.insert_image(destination, alt);
6922 }
6923 // Gated on the *image* gesture, not on one of its own — there isn't one,
6924 // since the bytes below are spelled here rather than by twig, and an HTML
6925 // document would in fact parse them. The button is one control with three
6926 // kinds behind it, and two of them working in a format where the third
6927 // cannot is a worse surface than three that agree — especially as
6928 // `insert_image` is the kind anyone reaches for first.
6929 if self.refuse_unsupported("media", Gesture::InsertImage) {
6930 return;
6931 }
6932 let (start, end) = self.selection().unwrap_or((self.caret, self.caret));
6933 let alt_text = self
6934 .selected_text()
6935 .map(str::to_string)
6936 .unwrap_or_else(|| alt.to_string());
6937 let tag = match kind {
6938 MediaKind::Audio => "audio",
6939 _ => "video",
6940 };
6941 let markup = format!("<{tag} src=\"{destination}\" controls>{alt_text}</{tag}>");
6942 self.edit(start, end, &markup);
6943 }
6944
6945 /// Append media at the **end** of the document, as a block of its own —
6946 /// the verb for a picture that *arrives* rather than one the writer
6947 /// places: an attachment imported while the caret was wherever it last
6948 /// was, a drawing placed from a tray under the body. At the caret it
6949 /// would land inline in front of whatever word the caret happened to be
6950 /// beside, which for an editor nobody has tapped yet is the first word
6951 /// of the document.
6952 ///
6953 /// The document is ended with a blank line first, where it does not
6954 /// already end with one, so the media is a paragraph of its own rather
6955 /// than a lazy continuation of the last one; an empty document needs no
6956 /// separator. Then [`insert_media`](Self::insert_media) at the new end,
6957 /// with everything that means: the same markup per kind, the same
6958 /// refusal on a format without images. The selection is dropped — the
6959 /// verb is about the end of the document, not about what was selected —
6960 /// and the caret is left past the media, as `insert_media` leaves it.
6961 pub fn append_media(&mut self, kind: MediaKind, destination: &str, alt: &str) {
6962 if self.read_only || self.refuse_unsupported("media", Gesture::InsertImage) {
6963 return;
6964 }
6965 let separator = if self.source.is_empty() || self.source.ends_with("\n\n") {
6966 ""
6967 } else if self.source.ends_with('\n') {
6968 "\n"
6969 } else {
6970 "\n\n"
6971 };
6972 let end = self.source.len();
6973 self.anchor = None;
6974 self.caret = end;
6975 if !separator.is_empty() {
6976 self.edit(end, end, separator);
6977 }
6978 self.anchor = None;
6979 self.caret = self.source.len();
6980 self.insert_media(kind, destination, alt);
6981 }
6982
6983 /// Insert a thematic break at the caret — the toolbar's Horizontal Rule
6984 /// button. Spelling and placement are both twig's; leaf used to write `---`
6985 /// itself, which was the Markdown spelling in a djot document too.
6986 ///
6987 /// A rule is a block, so `insert_thematic_break` alone has nowhere to put one
6988 /// mid-paragraph and lands it after the caret's whole block. To get a rule
6989 /// *at* the caret — the paragraph parted in two around it, which is what a
6990 /// rule button is understood to do — the paragraph is first divided with
6991 /// `split_block` and the rule then aimed at the **first** half. Aiming it at
6992 /// the offset `split_block` returns puts the rule after the *second* half
6993 /// instead, which is a rule in the right document and the wrong place.
6994 ///
6995 /// Only a plain paragraph is split, and only where there is something to
6996 /// part: at the paragraph's end the split has no second half to mint and
6997 /// would write the separator anyway — a blank line and the empty slot Enter
6998 /// leaves for the next paragraph, which the rule then lands above and
6999 /// nothing fills — so there the rule goes straight after the paragraph,
7000 /// which is where the split-and-aim was sending it regardless. At the
7001 /// paragraph's *start* the split is kept, though it parts nothing either:
7002 /// `|para` becomes `\npara` with the caret on the new blank line, and a
7003 /// rule aimed at a blank line is written on it (twig ≥ 3.5.2), which is how
7004 /// "before the paragraph" is said through a gesture that only knows
7005 /// "after" — `---\n\npara`, and `prev\n\n---\n\npara` mid-document. Everywhere
7006 /// else the rule simply lands after the block, which is both twig's own
7007 /// answer and the better one: splitting a fenced code block would leave two
7008 /// fences with a rule between them, and splitting a list item would mint an
7009 /// item nobody asked for on the way to a rule that lands after the list
7010 /// regardless. A table and a setext heading refuse the split outright, so
7011 /// they take the same path by themselves.
7012 ///
7013 /// The caret ends on the line under the rule, which is where the writer
7014 /// goes on: at the start of the paragraph's second half when the rule
7015 /// parted one, and otherwise on an empty line opened under the rule — see
7016 /// [`stand_under_block`](Self::stand_under_block). It used to be left at
7017 /// the end of what twig wrote, which is the rule's own row: the caret was
7018 /// drawn beside the rule, and mid-document the next keystroke joined the
7019 /// block below.
7020 pub fn insert_thematic_break(&mut self) {
7021 if self.read_only || self.refuse_unsupported("thematic break", Gesture::InsertThematicBreak)
7022 {
7023 return;
7024 }
7025 self.caret = self.skip_trailing_close_delims(self.caret);
7026 // A selection is replaced by the rule, so collapse it first and let the
7027 // split-and-rule below run from the caret it leaves behind.
7028 if let Some((s, e)) = self.selection() {
7029 self.splice(s, e, "", EditKind::Other);
7030 }
7031 self.anchor = None;
7032 self.record_caret();
7033 let at = self.caret;
7034 // A failure here is not fatal: the rule still lands after the block,
7035 // which is exactly what this call was trying to improve on.
7036 let parted = self.part_for_block(at);
7037 match self.editor.insert_thematic_break(at) {
7038 Ok(change) => {
7039 self.last_edit_kind = None;
7040 self.refresh();
7041 if parted {
7042 self.coalesce_last_undo();
7043 }
7044 self.anchor = None;
7045 self.caret = change.new.end;
7046 self.dirty = self.source != self.clean_source;
7047 self.status = None;
7048 self.clamp_caret();
7049 self.record_caret();
7050 self.caret_under_written_block(change.new, parted);
7051 }
7052 Err(e) => self.status = Some(format!("thematic break: {e}")),
7053 }
7054 }
7055
7056 /// Part the bare paragraph at `at` for a block gesture, where
7057 /// [`caret_parts_bare_paragraph`](Self::caret_parts_bare_paragraph) says
7058 /// to, and say whether it was parted.
7059 ///
7060 /// The split is counted as a step of its own, so the gesture can fold the
7061 /// block it writes next into it. It used to go to twig uncounted: twig
7062 /// kept it as a step and leaf's count did not, so one Undo took back the
7063 /// block and left the paragraph parted, with Undo then reporting nothing
7064 /// left to undo.
7065 fn part_for_block(&mut self, at: usize) -> bool {
7066 if !self.caret_parts_bare_paragraph() || self.editor.split_block(at).is_err() {
7067 return false;
7068 }
7069 self.refresh();
7070 true
7071 }
7072
7073 /// Where a block gesture leaves the caret once twig has written the block
7074 /// `new` spans — a rule, a page break: on the line under it. That is the
7075 /// start of the paragraph's second half when the gesture `parted` one,
7076 /// and otherwise the line [`stand_under_block`](Self::stand_under_block)
7077 /// finds or writes, folded into the gesture's step.
7078 ///
7079 /// The block is the outermost node twig's change opens, whatever its
7080 /// kind, since the kind a leaf directive comes back as is the format's.
7081 fn caret_under_written_block(&mut self, new: Range<usize>, parted: bool) {
7082 let block = self
7083 .nodes()
7084 .into_iter()
7085 .filter(|n| n.kind != Kind::Doc && new.contains(&n.span.start))
7086 .min_by_key(|n| (n.span.start, std::cmp::Reverse(n.span.end)));
7087 let Some(block) = block else {
7088 return;
7089 };
7090 let (end, _) = wysiwyg::block_line(&self.source, block.span.end);
7091 if parted {
7092 // The second half starts at the first text under the block; the
7093 // split left it no leading whitespace.
7094 let rest = &self.source[end..];
7095 self.caret = end + (rest.len() - rest.trim_start().len());
7096 self.record_caret();
7097 } else if self.stand_under_block(end) {
7098 self.coalesce_last_undo();
7099 }
7100 }
7101
7102 /// Stand the caret on the line under the block whose last line's text ends
7103 /// at `end` — a thematic break, a page break — first writing that line if
7104 /// the block has none: where the rule and page-break buttons leave the
7105 /// caret, and where Enter on a rule takes it. Whether anything was
7106 /// written, so a caller can fold it into its own step.
7107 ///
7108 /// The caret's line is the first under the block that the view gives a
7109 /// home: the second blank line in [`LineFlow::Fold`], which draws the
7110 /// first as the gap closing the block, and the first in
7111 /// [`LineFlow::Preserve`], where every blank line is somewhere to type.
7112 /// When anything follows, one more blank line has to stand between the
7113 /// caret's line and it, or what is typed there runs on into the next
7114 /// block. Only what that shape is missing is written. A block twig wrote on
7115 /// a blank line still has the blank lines that were under the caret and
7116 /// needs one line more, or none, where a rule written under a paragraph
7117 /// needs two.
7118 ///
7119 /// Blank means blank inside the block's container: a quote's blank lines
7120 /// keep their `>`, and the caret's line wears the quote's whole prefix, as
7121 /// the line Enter opens in a quote does. The document's last line, when no
7122 /// newline ends it, counts only once a blank line stands above it, since
7123 /// only then does the view draw it as a row. Fold always writes the gap
7124 /// above it if it isn't there, so there a block ending the document takes
7125 /// one newline more and the caret its end.
7126 fn stand_under_block(&mut self, end: usize) -> bool {
7127 let prefix = self.continuation_prefix_at(end);
7128 let blank = prefix.trim_end();
7129 let gap = usize::from(self.line_flow == LineFlow::Fold);
7130 // The blank lines directly under the block's own, by where each starts,
7131 // and whether the line that ends the run has anything on it.
7132 let mut blanks = Vec::new();
7133 let mut follows = false;
7134 let mut at = end;
7135 while let Some(nl) = self.source[at..].find('\n') {
7136 let start = at + nl + 1;
7137 let len = self.source[start..].find('\n');
7138 let line = &self.source[start..len.map_or(self.source.len(), |len| start + len)];
7139 let line = line.trim_end();
7140 if line != blank {
7141 follows = !line.trim().is_empty();
7142 break;
7143 }
7144 let Some(len) = len else {
7145 if gap > 0 || !blanks.is_empty() {
7146 blanks.push(start);
7147 }
7148 break;
7149 };
7150 blanks.push(start);
7151 at = start + len;
7152 }
7153 let missing = (gap + 1 + usize::from(follows)).saturating_sub(blanks.len());
7154 // What is missing goes in at the rule's end, above the blank lines
7155 // already there, so the caret's line is the same one counted from
7156 // the rule either way: written here, or one of those, moved down.
7157 let mut text = String::new();
7158 let mut caret = None;
7159 for line in 1..=missing {
7160 text.push('\n');
7161 if line == gap + 1 {
7162 text.push_str(&prefix);
7163 caret = Some(end + text.len());
7164 } else {
7165 text.push_str(blank);
7166 }
7167 }
7168 let caret = caret.unwrap_or_else(|| {
7169 let start = blanks[gap - missing];
7170 let line = self.source[start..].split('\n').next().unwrap_or("");
7171 start + text.len() + line.trim_end_matches('\r').len().min(prefix.len())
7172 });
7173 if !text.is_empty() && !self.splice(end, end, &text, EditKind::Other) {
7174 return false;
7175 }
7176 self.caret = caret.min(self.source.len());
7177 self.anchor = None;
7178 self.goal_col = None;
7179 self.record_caret();
7180 !text.is_empty()
7181 }
7182
7183 /// The thematic break the caret stands under, as its text's end, when
7184 /// Enter there belongs to the rule: the caret at the rule's home past it
7185 /// (the start of the line under it), that line blank, and the caret drawn
7186 /// on the rule's own row.
7187 ///
7188 /// Drawn on the rule's row, because the home past a rule is also the start
7189 /// of the line under it, and which of the two the view draws it on is the
7190 /// flow's. [`LineFlow::Fold`] draws that line as the gap under the rule,
7191 /// no caret's home, so the caret is beside the rule. [`LineFlow::Preserve`]
7192 /// makes every blank line a home, so it draws the caret there and Enter is
7193 /// an ordinary blank line's — unless it is the document's unterminated
7194 /// last line, which is no row in either flow.
7195 fn rule_above_caret(&mut self) -> Option<usize> {
7196 let caret = self.caret.min(self.source.len());
7197 let above = self.source[..caret].strip_suffix('\n')?;
7198 let above_start = above.rfind('\n').map_or(0, |i| i + 1);
7199 let text = above[above_start..].trim_end();
7200 let (last, _) = text.char_indices().next_back()?;
7201 let rule = self
7202 .editor
7203 .ancestors_at(above_start + last)
7204 .ok()?
7205 .into_iter()
7206 .find(|m| m.kind == Kind::ThematicBreak)?;
7207 let (end, home) = wysiwyg::block_line(&self.source, rule.span.end);
7208 if home != caret {
7209 return None;
7210 }
7211 let blank = self.continuation_prefix_at(end);
7212 let line_end = self.source[caret..].find('\n').map(|i| caret + i);
7213 let line = &self.source[caret..line_end.unwrap_or(self.source.len())];
7214 if line.trim_end() != blank.trim_end() && !line.trim().is_empty() {
7215 return None;
7216 }
7217 (self.line_flow == LineFlow::Fold || line_end.is_none()).then_some(end)
7218 }
7219
7220 /// Insert a fresh table at the caret — the toolbar's Table button. One
7221 /// header row, `rows` empty body rows, `cols` columns, spelled by twig in
7222 /// the document's own dialect and placed the way its thematic break is:
7223 /// after the caret's block, blank-separated. A bare paragraph is parted
7224 /// around the caret first, exactly as
7225 /// [`insert_thematic_break`](Self::insert_thematic_break) parts it, so the
7226 /// table lands *at* the caret rather than after everything the caret's
7227 /// paragraph says.
7228 ///
7229 /// The caret ends in the first header cell, selected the way Tab selects
7230 /// a cell — the natural next act is to type the heading, and Tab then
7231 /// walks the grid. That cell is read back from the rebuilt table map
7232 /// rather than computed from the splice, because twig's blank line and
7233 /// quote prefix put the first bar at an offset only the reparse knows.
7234 ///
7235 /// The shape is the caller's: a menu offers a few, a dialog asks. Zero
7236 /// rows or columns is twig's refusal (a header with nothing under it is
7237 /// what its row delete refuses to leave), reported through `status` —
7238 /// and refused here before the paragraph is parted, which would otherwise
7239 /// be left parted around a table never written.
7240 pub fn insert_table(&mut self, rows: usize, cols: usize) {
7241 if self.read_only || self.refuse_unsupported("table", Gesture::InsertTable) {
7242 return;
7243 }
7244 // twig's one refusal of a shape, asked before the paragraph is parted
7245 // for a table that would never be written into it.
7246 if rows == 0 || cols == 0 {
7247 self.status = Some("table: needs at least one row and one column".into());
7248 return;
7249 }
7250 self.caret = self.skip_trailing_close_delims(self.caret);
7251 if let Some((s, e)) = self.selection() {
7252 self.splice(s, e, "", EditKind::Other);
7253 }
7254 self.anchor = None;
7255 self.record_caret();
7256 let at = self.caret;
7257 let parted = self.part_for_block(at);
7258 match self.editor.insert_table(at, rows, cols) {
7259 Ok(change) => {
7260 self.last_edit_kind = None;
7261 self.refresh();
7262 if parted {
7263 self.coalesce_last_undo();
7264 }
7265 self.anchor = None;
7266 self.caret = change.new.end;
7267 self.dirty = self.source != self.clean_source;
7268 self.status = None;
7269 self.clamp_caret();
7270 // Into the first header cell of the table just written: the
7271 // first table whose grid begins inside the splice.
7272 self.rebuild_map();
7273 let first_cell = self
7274 .vmap
7275 .tables
7276 .iter()
7277 .filter_map(|t| t.grid.first().and_then(|row| row.cells.first()))
7278 .find(|cell| cell.start >= change.new.start && cell.start < change.new.end)
7279 .map(|cell| (cell.start, cell.end));
7280 if let Some((start, end)) = first_cell {
7281 self.select_cell(start, end);
7282 }
7283 self.record_caret();
7284 }
7285 Err(e) => self.status = Some(format!("table: {e}")),
7286 }
7287 }
7288
7289 /// Whether the caret sits in a paragraph and nothing else — no list item, no
7290 /// quote, no fence, no table — with paragraph text still ahead of it. The
7291 /// one shape where parting the block around the caret is unambiguously what
7292 /// a rule button means; see
7293 /// [`insert_thematic_break`](Self::insert_thematic_break) for why every other
7294 /// container is left to take the rule after itself.
7295 ///
7296 /// The "text ahead" half is what keeps `split_block` from running at the
7297 /// one edge where its output composes badly. At a paragraph's end twig
7298 /// cannot mint the empty second half (no format spells an empty
7299 /// paragraph), so it writes only the separator — a blank line and the
7300 /// slot Enter leaves for the paragraph to come — and a block then aimed at
7301 /// the first half lands above a slot that nothing fills: `para\n` with the
7302 /// caret at 4 came out as `para\n\n* * *\n\n\n`. Trailing whitespace counts
7303 /// as nothing ahead, since the split would shed it as the second half's
7304 /// leading indent and leave the same slot. Which end of the newline a
7305 /// paragraph's span stops at differs between the formats (Markdown before
7306 /// it, djot after), which is why this reads the remaining bytes rather
7307 /// than comparing offsets. The paragraph's
7308 /// start is deliberately not the same case — see
7309 /// [`insert_thematic_break`](Self::insert_thematic_break) for why that
7310 /// split is kept.
7311 fn caret_parts_bare_paragraph(&mut self) -> bool {
7312 let caret = self.caret.min(self.source.len());
7313 let Ok(chain) = self.editor.ancestors_at(caret) else {
7314 return false;
7315 };
7316 let mut para_end = None;
7317 for m in chain {
7318 match m.kind {
7319 Kind::Para => para_end = Some(m.span.end.min(self.source.len())),
7320 Kind::ListItem
7321 | Kind::TaskListItem
7322 | Kind::BlockQuote
7323 | Kind::CodeBlock
7324 | Kind::Table => return false,
7325 _ => {}
7326 }
7327 }
7328 match para_end {
7329 Some(end) if end > caret => !self.source[caret..end].trim().is_empty(),
7330 _ => false,
7331 }
7332 }
7333
7334 /// The destination of the link under the caret — what a Link prompt shows so
7335 /// ⌘K on an existing link edits its URL instead of asking for it again.
7336 /// `None` when the caret stands in no link.
7337 ///
7338 /// An autolink carries no separate destination: its text *is* the URL, so
7339 /// that's what comes back for one.
7340 pub fn link_destination_at_caret(&mut self) -> Option<String> {
7341 self.link_destination_at(self.caret)
7342 }
7343
7344 /// The destination of the link at `off`.
7345 /// [`link_destination_at_caret`](Self::link_destination_at_caret) for a place
7346 /// the caret isn't.
7347 ///
7348 /// The offset form exists for the same reason
7349 /// [`footnote_at`](Self::footnote_at)'s does: a frontend drawing a *piece* of
7350 /// the document somewhere else — a footnote's text in a popover, say — has
7351 /// rows and runs but no caret in them, and still needs to know which of those
7352 /// runs a reader can follow.
7353 pub fn link_destination_at(&mut self, off: usize) -> Option<String> {
7354 self.nodes()
7355 .into_iter()
7356 .filter(|n| matches!(n.kind.as_str(), "link" | "url" | "email"))
7357 .filter(|n| n.span.start <= off && off < n.span.end)
7358 .max_by_key(|n| n.span.start)
7359 .and_then(|n| n.destination.or(n.text))
7360 }
7361
7362 /// The heading the caret is under — the nearest heading at or above it,
7363 /// whatever its level. See [`heading_at`](Self::heading_at).
7364 pub fn heading_at_caret(&mut self) -> Option<Heading> {
7365 self.heading_at(self.caret)
7366 }
7367
7368 /// The heading `off` is under: the last heading that starts at or before
7369 /// it, or the one `off` stands in. `None` above the first heading.
7370 ///
7371 /// The question between [`link_destination_at`](Self::link_destination_at)
7372 /// ("which link is this") and [`locate`](Self::locate) ("where does this
7373 /// fragment land"): a host writing a link *to* a place needs the heading
7374 /// that place is under, to name it by `#slug`. Nearest, not enclosing —
7375 /// a level-3 heading under a level-2 is the answer for the text below the
7376 /// level-3, because it is the finer place to land.
7377 pub fn heading_at(&mut self, off: usize) -> Option<Heading> {
7378 let nodes = self.nodes();
7379 let h = nodes
7380 .iter()
7381 .filter(|n| n.kind == Kind::Heading && n.span.start <= off)
7382 .max_by_key(|n| n.span.start)?;
7383 let mut text = String::new();
7384 inline_text(&nodes, h.first_child, &mut text);
7385 Some(Heading {
7386 text: text.trim().to_string(),
7387 level: h.level.unwrap_or(1),
7388 span: h.span.clone(),
7389 })
7390 }
7391
7392 /// Where the locator `id` lands in this document — the `#v2` half of a
7393 /// `chapter.dj#v2`, resolved to the block it names. `None` when nothing here
7394 /// answers to it.
7395 ///
7396 /// The other end of a link, and the reason this exists: without it a
7397 /// destination has only file granularity, so following a citation into a
7398 /// chapter drops the reader at the top of it to hunt for the verse. Which is
7399 /// also why it is a *document* query rather than a caret one — the document
7400 /// being asked is usually not the one the reader is in.
7401 ///
7402 /// Three readings, tried in order, because the same `#some-heading` is
7403 /// written three ways across the formats leaf opens:
7404 ///
7405 /// 1. **A declared id**, exactly as written: djot's `{#v1}` on a block, and
7406 /// the auto-ids djot mints for its headings. The only exact answer, so it
7407 /// goes first — a document that says `{#v1}` has settled the question.
7408 /// 2. **A declared id, slugged.** djot spells a heading's auto-id
7409 /// `Some-Heading-Here`; nearly every tool that *writes* a link to one
7410 /// spells it `#some-heading-here`. Comparing slugs is what lets a link
7411 /// authored anywhere land on a djot heading.
7412 /// 3. **A heading's text, slugged.** Markdown has no ids at all — twig mints
7413 /// none and `{#custom}` is literal text in a Markdown heading — so for
7414 /// the format most vaults are written in, the heading's own words are the
7415 /// only thing a fragment can name. This is the rule every Markdown
7416 /// renderer already follows, which is what makes `#a-heading` mean in
7417 /// diaryx what it means on the web.
7418 ///
7419 /// Ties go to the earliest match, then to the widest: a duplicated id is the
7420 /// document's mistake and the first one is the answer every anchor
7421 /// implementation gives, while preferring the wider span picks the section
7422 /// over the heading that opens it — more for a peek to show, same place to
7423 /// land.
7424 pub fn locate(&mut self, id: &str) -> Option<Landing> {
7425 let id = id.trim();
7426 if id.is_empty() {
7427 return None;
7428 }
7429 let nodes = self.nodes();
7430
7431 // Earliest wins, then widest. `Reverse` on the end because `min_by_key`
7432 // is picking, among nodes that start together, the one that ends last.
7433 let pick = |matches: &mut dyn Iterator<Item = &FlatNode>| {
7434 matches
7435 .min_by_key(|n| (n.span.start, std::cmp::Reverse(n.span.end)))
7436 .map(|n| Landing {
7437 start: n.span.start,
7438 end: n.span.end,
7439 })
7440 };
7441
7442 if let Some(landing) = pick(&mut nodes.iter().filter(|n| declared_id(n) == Some(id))) {
7443 return Some(landing);
7444 }
7445 let want = slug(id);
7446 if want.is_empty() {
7447 return None;
7448 }
7449 if let Some(landing) = pick(
7450 &mut nodes
7451 .iter()
7452 .filter(|n| declared_id(n).map(slug).as_deref() == Some(&*want)),
7453 ) {
7454 return Some(landing);
7455 }
7456
7457 // A heading by its words. Its span is one line, so the end comes from
7458 // where the *section* it opens gives out — the next heading that is not
7459 // under it, or the end of the document. A Markdown heading has no
7460 // section node to ask (twig only builds those for djot), and a peek that
7461 // showed the heading alone would answer "what does that say" with the
7462 // title of the thing it says.
7463 let heading = nodes
7464 .iter()
7465 .filter(|n| n.kind == Kind::Heading)
7466 .filter(|n| {
7467 n.content_span
7468 .clone()
7469 .and_then(|s| self.source.get(s))
7470 .is_some_and(|text| slug(text) == want)
7471 })
7472 .min_by_key(|n| n.span.start)?;
7473 let level = heading.level.unwrap_or(u32::MAX);
7474 let end = nodes
7475 .iter()
7476 .filter(|n| n.kind == Kind::Heading)
7477 .filter(|n| n.span.start > heading.span.start)
7478 .filter(|n| n.level.unwrap_or(u32::MAX) <= level)
7479 .map(|n| n.span.start)
7480 .min()
7481 .unwrap_or(self.source.len());
7482 Some(Landing {
7483 start: heading.span.start,
7484 end,
7485 })
7486 }
7487
7488 /// Write a footnote at the caret — the toolbar's Footnote button, and the
7489 /// one gesture in the footnote story that *authors* rather than follows.
7490 ///
7491 /// Both halves go in as one twig edit: the `[^1]` where the caret is, and
7492 /// the `[^1]:` definition at the end of the document. Half a footnote is not
7493 /// a footnote — a bare reference with nothing defining it renders as literal
7494 /// brackets — so a single button that wrote only the reference would leave
7495 /// the author to hand-spell the other half in a document that had just
7496 /// stopped showing them what the first half meant. One edit also means one
7497 /// undo takes both back.
7498 ///
7499 /// The definition's body is left empty and **the caret lands in it**, which
7500 /// is the whole point of pressing the button: nobody wants a reference to a
7501 /// note they have not written yet. Getting back to where they were writing
7502 /// is [`footnote_definition_at_caret`](Self::footnote_definition_at_caret) —
7503 /// the same return leg a reader following a reference already uses, so the
7504 /// author is left standing on the near end of a round trip that works.
7505 ///
7506 /// A selection collapses to its *end* rather than being replaced: a
7507 /// reference annotates the words before it, so "select the claim, add a
7508 /// footnote" should mark that claim, not consume it.
7509 pub fn insert_footnote(&mut self) {
7510 if self.read_only || self.refuse_unsupported("footnote", Gesture::InsertFootnote) {
7511 return;
7512 }
7513 let at = self.selection().map_or(self.caret, |(_, end)| end);
7514 self.anchor = None;
7515 self.caret = at;
7516 self.record_caret();
7517 let label = self.next_footnote_label();
7518 match self.editor.insert_footnote(at, &label) {
7519 Ok(change) => {
7520 self.last_edit_kind = None;
7521 self.refresh();
7522 self.anchor = None;
7523 // `change.new` runs from the reference to the end of the
7524 // document, so its start is the `[^1]` just written and
7525 // `footnote_at` resolves it to the note the same way a reader's
7526 // tap does — and to the note's *body*, which is already a caret
7527 // stop even when it is empty (the `[^1]:` marker draws as `[1] `
7528 // and has none), so this needs no snap on top. The fallback is
7529 // the reference's own offset: a format that spelled the pair some
7530 // way leaf can't read back should still leave the caret on the
7531 // edit rather than at the far end of a document it just grew.
7532 self.caret = self
7533 .footnote_at(change.new.start)
7534 .and_then(|note| note.offset)
7535 .unwrap_or(change.new.start);
7536 self.dirty = self.source != self.clean_source;
7537 self.status = None;
7538 self.clamp_caret();
7539 self.record_caret();
7540 }
7541 Err(e) => self.status = Some(format!("footnote: {e}")),
7542 }
7543 }
7544
7545 /// The label to give a footnote the author has not named: the lowest counting
7546 /// number no footnote in the document is already wearing.
7547 ///
7548 /// twig takes the label rather than minting one, because it holds no opinion
7549 /// about what a document's footnotes should be called — and it is right not
7550 /// to. Numbering them is what every author of a numbered note expects, and
7551 /// re-using a taken number would silently point the new reference at somebody
7552 /// else's note (twig reuses an existing definition rather than appending a
7553 /// second one, which is the right rule for citing a note twice on purpose and
7554 /// exactly the wrong accident to have by default).
7555 ///
7556 /// *References* are counted alongside definitions, not just definitions: a
7557 /// document carrying a dangling `[^2]` has a 2 that means something to
7558 /// whoever wrote it, and minting a definition for it here would answer a
7559 /// question nobody asked. Non-numeric labels (`[^why]`) are left out of the
7560 /// count entirely — they take no number, so they block none.
7561 fn next_footnote_label(&mut self) -> String {
7562 let mut taken: Vec<u32> = wysiwyg::footnote_definitions(&mut self.editor)
7563 .into_iter()
7564 .filter_map(|note| wysiwyg::footnote_label(&self.source, note.span.start))
7565 .filter_map(|label| label.parse().ok())
7566 .collect();
7567 taken.extend(
7568 self.nodes()
7569 .into_iter()
7570 .filter(|n| n.kind == Kind::FootnoteReference)
7571 .filter_map(|n| wysiwyg::footnote_reference_label(&self.source, n.span))
7572 .filter_map(|label| label.parse::<u32>().ok()),
7573 );
7574 (1..).find(|n| !taken.contains(n)).unwrap_or(1).to_string()
7575 }
7576
7577 /// The footnote reference under the caret, resolved to the note it names.
7578 /// [`footnote_at`](Self::footnote_at) at the caret's offset.
7579 pub fn footnote_at_caret(&mut self) -> Option<FootnoteRef> {
7580 self.footnote_at(self.caret)
7581 }
7582
7583 /// The footnote reference at `off`, resolved to the note it names — what a
7584 /// frontend shows when a reader activates a `[^1]`.
7585 ///
7586 /// A reference is not a link node, so
7587 /// [`link_destination_at_caret`](Self::link_destination_at_caret) does not
7588 /// (and should not) answer for one: a link names a destination to leave for,
7589 /// a reference names a note that is already in this document. Following one
7590 /// is a move within the page, which is why this hands back an `offset`
7591 /// rather than something to open.
7592 ///
7593 /// Offset-based rather than caret-only because the gesture that wants this
7594 /// most is the one that must not move the caret: a pointer hovering a `[1]`
7595 /// asks what note it names without disturbing where the reader was typing.
7596 /// The caret is just the offset a click already placed —
7597 /// [`footnote_at_caret`](Self::footnote_at_caret) passes it.
7598 ///
7599 /// `None` when `off` stands in no reference. A reference whose note the
7600 /// document never defines is *not* `None` — it answers with the label it
7601 /// looked for and no text, which is what lets a frontend say so instead of
7602 /// silently doing nothing.
7603 pub fn footnote_at(&mut self, off: usize) -> Option<FootnoteRef> {
7604 // Innermost-wins by latest start, the rule its link sibling uses.
7605 let span = self
7606 .nodes()
7607 .into_iter()
7608 .filter(|n| n.kind == Kind::FootnoteReference)
7609 .filter(|n| n.span.start <= off && off < n.span.end)
7610 .max_by_key(|n| n.span.start)?
7611 .span;
7612 let label = wysiwyg::footnote_reference_label(&self.source, span)?.to_string();
7613
7614 // The note itself. Definitions are roots beside `doc` rather than
7615 // children of it, so they're asked for directly — see
7616 // `wysiwyg::footnote_definitions`.
7617 let note = wysiwyg::footnote_definitions(&mut self.editor)
7618 .into_iter()
7619 .find(|m| wysiwyg::footnote_label(&self.source, m.span.start) == Some(&label));
7620 let Some(note) = note else {
7621 return Some(FootnoteRef {
7622 label,
7623 text: None,
7624 offset: None,
7625 end: None,
7626 });
7627 };
7628 let body = wysiwyg::footnote_body_span(&self.source, note.span.clone());
7629 Some(FootnoteRef {
7630 label,
7631 text: body
7632 .clone()
7633 .and_then(|b| self.source.get(b))
7634 .map(str::to_string),
7635 // The body's start, not the definition's — see `FootnoteRef::offset`.
7636 offset: body.clone().map(|b| b.start),
7637 end: body.map(|b| b.end),
7638 })
7639 }
7640
7641 /// The footnote *definition* the caret stands in, and where the reference
7642 /// that names it is. [`footnote_definition_at`](Self::footnote_definition_at)
7643 /// at the caret's offset.
7644 pub fn footnote_definition_at_caret(&mut self) -> Option<FootnoteDef> {
7645 self.footnote_definition_at(self.caret)
7646 }
7647
7648 /// The footnote definition spanning `off`, and where the reference that
7649 /// names it is — the return leg of [`footnote_at`](Self::footnote_at).
7650 ///
7651 /// The mirror image, deliberately: the same gesture that takes a reader from
7652 /// `[1]` down to the note takes them from the note back up to `[1]`, so
7653 /// following a footnote is a round trip rather than a fall. It needs no
7654 /// memory of how the reader arrived — the document says where the reference
7655 /// is — which is what makes it work for a reader who scrolled to the notes
7656 /// themselves, and what keeps it right after an edit moves either end.
7657 ///
7658 /// `None` when `off` stands in no definition. A definition nothing cites is
7659 /// *not* `None`, for [`FootnoteRef`]'s reason in reverse: it answers with
7660 /// its label and no offset, so a frontend can say "nothing refers to this"
7661 /// rather than offer a jump that goes nowhere.
7662 pub fn footnote_definition_at(&mut self, off: usize) -> Option<FootnoteDef> {
7663 // Definitions are roots beside `doc`, so `nodes()` — which walks the
7664 // document body — never reports one. They're asked for directly, the way
7665 // `footnote_at` asks for the note it resolves to.
7666 //
7667 // Closed at the end, unlike the half-open test its neighbours use. A
7668 // definition's span stops at its last content byte — the newline ending
7669 // the line is outside it — so `span.end` is the caret stop at the end of
7670 // the note's own row, not the first byte of anything after. Excluding it
7671 // meant the one caret an author is guaranteed to have, the one left
7672 // sitting at the end of the note they just typed, was in no definition at
7673 // all: writing a note and then asking to go back to its reference
7674 // answered nothing. Two definitions in a row still can't both match —
7675 // there is a blank line between them — and `max_by_key` decides anyway.
7676 let note = wysiwyg::footnote_definitions(&mut self.editor)
7677 .into_iter()
7678 .filter(|m| m.span.start <= off && off <= m.span.end)
7679 .max_by_key(|m| m.span.start)?;
7680 let label = wysiwyg::footnote_label(&self.source, note.span.start)?.to_string();
7681
7682 // The earliest reference carrying this label. `min` rather than a `find`,
7683 // because `nodes()` reports a flattened walk whose order is twig's
7684 // business, not document order. Bound first: the walk needs `&mut self`
7685 // and reading the labels back out needs `&self.source`.
7686 let nodes = self.nodes();
7687 let offset = nodes
7688 .into_iter()
7689 .filter(|n| n.kind == Kind::FootnoteReference)
7690 .filter(|n| {
7691 wysiwyg::footnote_reference_label(&self.source, n.span.clone()) == Some(&*label)
7692 })
7693 // Past the `[^`, onto the label — see `FootnoteDef::offset`.
7694 .map(|n| n.span.start + 2)
7695 .min();
7696 Some(FootnoteDef { label, offset })
7697 }
7698
7699 /// The destination of the image under the caret — what an image prompt shows
7700 /// so editing an existing image starts from its current URL instead of blank,
7701 /// the image analogue of [`link_destination_at_caret`](Self::link_destination_at_caret).
7702 /// `None` when the caret stands in no image. A caret resting just after a
7703 /// block image (its trailing stop) is still "in" it — the half-open span test
7704 /// excludes that offset, which is the intended precision: past the image is
7705 /// past it.
7706 pub fn image_destination_at_caret(&mut self) -> Option<String> {
7707 let off = self.caret;
7708 self.nodes()
7709 .into_iter()
7710 .filter(|n| n.kind == Kind::Image)
7711 .filter(|n| n.span.start <= off && off < n.span.end)
7712 .max_by_key(|n| n.span.start)
7713 .and_then(|n| n.destination)
7714 }
7715
7716 /// The language of the fenced code block the caret stands in — what a
7717 /// language prompt shows so editing it starts from the current value rather
7718 /// than blank. `None` when the caret is in no code block, or in one whose
7719 /// fence carries no language (or an indented block, which has no fence).
7720 pub fn code_language_at_caret(&mut self) -> Option<String> {
7721 let start = self.code_block_start_at_caret()?;
7722 wysiwyg::code_language(&self.source, start)
7723 }
7724
7725 /// Whether the caret stands in a fenced code block — the one a language
7726 /// prompt could edit. A frontend gates its "set language" affordance on this
7727 /// (an indented block, which can't carry a language, reports `false`).
7728 pub fn caret_in_fenced_code(&mut self) -> bool {
7729 self.code_block_start_at_caret()
7730 .is_some_and(|start| wysiwyg::code_info_span(&self.source, start).is_some())
7731 }
7732
7733 /// Set (or clear, with `""`) the language of the fenced code block the caret
7734 /// is in — the prompt's confirm. A no-op when the caret is in no fenced
7735 /// block, and a reported error for a language the format's fence cannot
7736 /// carry.
7737 ///
7738 /// twig rewrites the info string, so the fence's own width — measured
7739 /// against a body neither side touches — is kept, and a language holding a
7740 /// space, a line end or the fence character is refused rather than written
7741 /// out to reparse as something else. Leaf used to splice over the info span
7742 /// itself and `trim()` the input, which handled the one bad case it had
7743 /// thought of.
7744 pub fn set_code_language(&mut self, lang: &str) {
7745 // The read-only gate — this door reaches twig without the splice.
7746 if self.read_only {
7747 return;
7748 }
7749 if self.refuse_unsupported("code language", Gesture::SetCodeLanguage) {
7750 return;
7751 }
7752 if self.code_block_start_at_caret().is_none() {
7753 return;
7754 }
7755 let lang = lang.trim();
7756 // `None` clears the info string; `Some("")` asks for an empty one. Both
7757 // write a bare fence, and the prompt's empty value means "clear".
7758 let want = (!lang.is_empty()).then_some(lang);
7759 self.record_caret();
7760 match self.editor.set_code_language(self.caret, want) {
7761 Ok(_) => {
7762 self.last_edit_kind = None;
7763 self.refresh();
7764 self.anchor = None;
7765 self.dirty = self.source != self.clean_source;
7766 self.status = None;
7767 self.clamp_caret();
7768 self.record_caret();
7769 }
7770 Err(e) => self.status = Some(format!("code language: {e}")),
7771 }
7772 }
7773
7774 /// The `span.start` of the code block covering the caret — the anchor
7775 /// [`wysiwyg::code_info_span`] reads the fence from. `None` when the caret is
7776 /// in none.
7777 fn code_block_start_at_caret(&mut self) -> Option<usize> {
7778 let off = self.caret;
7779 self.nodes()
7780 .into_iter()
7781 .filter(|n| n.kind == Kind::CodeBlock && n.span.start <= off && off <= n.span.end)
7782 .max_by_key(|n| n.span.start)
7783 .map(|n| n.span.start)
7784 }
7785
7786 /// The source range of the text inside the link covering `off` — what sits
7787 /// between its `[` and `]`. `None` when twig reports no link there.
7788 fn link_text_span(&mut self, off: usize) -> Option<std::ops::Range<usize>> {
7789 self.nodes()
7790 .into_iter()
7791 // Two links can touch (`[a](x)[b](y)`), and then one's `span.end` is
7792 // the other's `span.start`; the link that starts latest at or before
7793 // `off` is the one `off` is actually in.
7794 .filter(|n| n.kind == Kind::Link && n.span.start <= off && off < n.span.end)
7795 .max_by_key(|n| n.span.start)
7796 .and_then(|n| n.content_span)
7797 }
7798
7799 // ── undo / redo ───────────────────────────────────────────────────────────
7800 // twig owns the history of *bytes* (it owns the buffer) and now carries the
7801 // caret through it too: `record_caret` stashes each state's caret in twig's
7802 // opaque per-step blob, and undo/redo hand it back with the source they
7803 // restore. So leaf keeps no history of its own — no parallel stacks to march
7804 // in lockstep and silently drift out of it.
7805
7806 /// Undo the last edit step (⌘Z / ^Z), putting the caret and selection back
7807 /// where they were when that step began. Whether it did: `false` when
7808 /// there was nothing to undo, the document is read-only, or twig refused —
7809 /// the answer a host composing several histories into one walks on by.
7810 pub fn undo(&mut self) -> bool {
7811 if self.read_only {
7812 return false;
7813 }
7814 // Stepping through the history ends a group: what comes after is not
7815 // part of the step just taken back.
7816 self.close_undo_group();
7817 let (undone, redoable) = (self.undo_steps, self.redo_steps);
7818 match self.editor.undo() {
7819 Ok(Some(change)) => {
7820 self.after_history(change);
7821 // `refresh` counted the restore as an edit; it was a step back.
7822 self.undo_steps = undone.saturating_sub(1);
7823 self.redo_steps = redoable + 1;
7824 true
7825 }
7826 Ok(None) => {
7827 self.undo_steps = 0;
7828 self.status = Some("nothing to undo".into());
7829 false
7830 }
7831 Err(e) => {
7832 self.status = Some(format!("undo: {e}"));
7833 false
7834 }
7835 }
7836 }
7837
7838 /// Redo the last undone edit step (⇧⌘Z / ^Y), putting the caret and
7839 /// selection back where that step originally left them. Whether it did,
7840 /// as [`undo`](Self::undo) reports.
7841 pub fn redo(&mut self) -> bool {
7842 if self.read_only {
7843 return false;
7844 }
7845 self.close_undo_group();
7846 let (undone, redoable) = (self.undo_steps, self.redo_steps);
7847 match self.editor.redo() {
7848 Ok(Some(change)) => {
7849 self.after_history(change);
7850 // `refresh` counted the restore as an edit; it was a step forward.
7851 self.undo_steps = (undone + 1).min(UNDO_CAP);
7852 self.redo_steps = redoable.saturating_sub(1);
7853 true
7854 }
7855 Ok(None) => {
7856 self.redo_steps = 0;
7857 self.status = Some("nothing to redo".into());
7858 false
7859 }
7860 Err(e) => {
7861 self.status = Some(format!("redo: {e}"));
7862 false
7863 }
7864 }
7865 }
7866
7867 /// Fold the edit just made into the undo step before it — twig's
7868 /// `coalesce_last_undo`, with the `undo_steps` mirror following
7869 /// it. twig merges only when there are two steps to merge. Call it after
7870 /// [`refresh`](Self::refresh) has counted the edit being folded.
7871 ///
7872 /// Inside an [undo group](Self::begin_undo_group) it does nothing: the
7873 /// group's edits are already one step, which [`refresh`](Self::refresh)
7874 /// folds as they come, so a fold here could only reach across the group's
7875 /// start into the step before it.
7876 fn coalesce_last_undo(&mut self) {
7877 if self.undo_group.is_some() {
7878 return;
7879 }
7880 self.fold_last_undo();
7881 }
7882
7883 /// twig's `coalesce_last_undo`, unconditionally, with the mirror following.
7884 fn fold_last_undo(&mut self) {
7885 if self.editor.coalesce_last_undo().is_ok() && self.undo_steps >= 2 {
7886 self.undo_steps -= 1;
7887 }
7888 }
7889
7890 /// Open an undo group: every edit from here to the matching
7891 /// [`end_undo_group`](Self::end_undo_group) is one step, which a single
7892 /// [`undo`](Self::undo) takes back whole and a single [`redo`](Self::redo)
7893 /// puts back — a Replace All over twelve matches, or a Writing Tools
7894 /// session, rather than twelve presses of ⌘Z.
7895 ///
7896 /// Groups nest, and only the outermost end closes one. The step is folded
7897 /// as each edit lands (twig's `coalesce_last_undo`, one edit at a time),
7898 /// so a group of a thousand edits holds one step on the history and never
7899 /// meets twig's cap. Nothing typed before the group folds into it, and
7900 /// nothing typed after: it is a step of its own, even when it is a single
7901 /// edit. A group no edit landed in leaves no step at all.
7902 ///
7903 /// An [`undo`](Self::undo) or [`redo`](Self::redo) closes any open group,
7904 /// whatever its depth — the history has moved, and an edit made after it
7905 /// is not part of the step it moved past. A later `end_undo_group` is
7906 /// then a no-op.
7907 pub fn begin_undo_group(&mut self) {
7908 match &mut self.undo_group {
7909 Some(g) => g.depth += 1,
7910 None => {
7911 self.undo_group = Some(UndoGroup {
7912 depth: 1,
7913 has_step: false,
7914 });
7915 // The first edit inside is not a continuation of the typing
7916 // before it.
7917 self.last_edit_kind = None;
7918 }
7919 }
7920 }
7921
7922 /// Close the undo group [`begin_undo_group`](Self::begin_undo_group)
7923 /// opened. A no-op when none is open.
7924 pub fn end_undo_group(&mut self) {
7925 let Some(g) = &mut self.undo_group else {
7926 return;
7927 };
7928 if g.depth > 1 {
7929 g.depth -= 1;
7930 } else {
7931 self.close_undo_group();
7932 }
7933 }
7934
7935 /// Whether an undo group is open.
7936 pub fn in_undo_group(&self) -> bool {
7937 self.undo_group.is_some()
7938 }
7939
7940 /// Close any open group, however deeply nested.
7941 fn close_undo_group(&mut self) {
7942 if self.undo_group.take().is_some() {
7943 // Typing after the group is not a continuation of the last edit
7944 // in it.
7945 self.last_edit_kind = None;
7946 }
7947 }
7948
7949 /// Refresh the cached source and put the caret back where the step being
7950 /// undone/redone had it, clearing any active run.
7951 ///
7952 /// The caret comes from twig's blob for the restored state (what
7953 /// `record_caret` stored). `change` is only the fallback for a state with no
7954 /// blob — a caret at the end of the restored text, which is where this always
7955 /// landed before the blobs were kept. It is the edit site, not where the user
7956 /// was standing, so it's a floor and not the behaviour: undoing should hand
7957 /// back the document *and* the place you were working, which for an edit made
7958 /// anywhere but under the caret are two different places.
7959 fn after_history(&mut self, change: Change) {
7960 self.refresh();
7961 match self
7962 .editor
7963 .caret_blob()
7964 .ok()
7965 .and_then(|b| CaretState::from_blob(&b))
7966 {
7967 Some(state) => {
7968 self.caret = state.caret.min(self.source.len());
7969 self.anchor = state.anchor.map(|a| a.min(self.source.len()));
7970 }
7971 None => {
7972 self.caret = change.new.end.min(self.source.len());
7973 self.anchor = None;
7974 }
7975 }
7976 self.goal_col = None;
7977 self.last_edit_kind = None;
7978 self.dirty = self.source != self.clean_source;
7979 self.status = None;
7980 self.clamp_caret();
7981 }
7982
7983 // ── the file ──────────────────────────────────────────────────────────────
7984
7985 #[cfg(feature = "fs")]
7986 pub fn save(&mut self) {
7987 if self.is_untitled() {
7988 // No path to write and no name to invent: ⌘S on an untitled document
7989 // is a Save As, and only a frontend has a picker to ask with. Say so
7990 // rather than failing at the filesystem with an empty path.
7991 self.status = Some("untitled — save as…".into());
7992 return;
7993 }
7994 let path = self.path.clone();
7995 if self.write(&path) {
7996 self.mark_saved();
7997 }
7998 }
7999
8000 /// Save As: write the document to `path` and *move* it there — `self.path`
8001 /// becomes `path`, and every later [`Doc::save`] writes the new file. That's
8002 /// what Save As means; a copy would leave the user editing a document whose
8003 /// name is no longer where their keystrokes go.
8004 ///
8005 /// The move only happens if the bytes actually landed. A failed write leaves
8006 /// the path, `dirty`, and the disk watermark exactly as they were, with the
8007 /// same `save failed: …` status a failed [`Doc::save`] sets — the document
8008 /// must never come away believing it was saved.
8009 ///
8010 /// An existing `path` is overwritten, and the caller is the one that knows
8011 /// whether to ask first: a Save As picker has already run that prompt, and a
8012 /// second confirmation from down here would be the same question twice.
8013 ///
8014 /// `format` does **not** follow the new extension. The buffer is parsed as
8015 /// the format it was opened with, and re-reading it as another one is a
8016 /// conversion — a different, lossy operation that would throw away the undo
8017 /// history — not a rename. So `notes.md` saved as `notes.dj` holds Markdown
8018 /// in a `.dj` file, and `format_name()` keeps honestly saying `markdown`
8019 /// until it's reopened.
8020 #[cfg(feature = "fs")]
8021 pub fn save_as(&mut self, path: PathBuf) {
8022 if !self.write(&path) {
8023 return;
8024 }
8025 self.path = path;
8026 self.mark_saved();
8027 }
8028
8029 /// Put `source` on disk at `path`, reporting whether it got there. The one
8030 /// place leaf writes a document, so a save and a Save As can't disagree
8031 /// about what a failure looks like.
8032 #[cfg(feature = "fs")]
8033 fn write(&mut self, path: &Path) -> bool {
8034 match std::fs::write(path, self.source.as_bytes()) {
8035 Ok(()) => true,
8036 Err(e) => {
8037 self.status = Some(format!("save failed: {e}"));
8038 false
8039 }
8040 }
8041 }
8042
8043 /// Re-base the document's saved watermark to the current bytes: clears
8044 /// `dirty`, records `source` as the new clean state (so undoing back to here
8045 /// clears the flag again), and re-stamps the on-disk hash.
8046 ///
8047 /// [`Doc::save`]/[`Doc::save_as`] call this after a write lands. It is also
8048 /// the hook a **filesystem-free host** calls itself once it has persisted
8049 /// [`Doc::source`] its own way (a browser download, `localStorage`, a backend
8050 /// `PUT`) — which is why it is public and touches no filesystem: the bytes
8051 /// are already where that host wants them, and this just tells the model they
8052 /// are safe.
8053 pub fn mark_saved(&mut self) {
8054 self.clean_source = self.source.clone();
8055 self.dirty = false;
8056 // The bytes on disk are now ours, so this is the new watermark: without
8057 // re-stamping it, every save would report its own work as an external
8058 // change forever after.
8059 self.disk_hash = Some(hash_bytes(self.source.as_bytes()));
8060 self.status = Some(format!("saved {}", self.file_name()));
8061 }
8062
8063 /// [`Doc::mark_saved`] for a host whose write is not instantaneous: `saved`
8064 /// is the source it read before writing, and the edits made since are
8065 /// still unsaved.
8066 ///
8067 /// A host that reads [`Doc::source`], hands it to a write that takes a
8068 /// while, and then calls `mark_saved` loses whatever was typed meanwhile:
8069 /// the flag is cleared over text that never reached the disk, so nothing
8070 /// saves it again. Here `saved` becomes the clean state and the disk
8071 /// watermark, and `dirty` is whether the buffer has moved on from it.
8072 pub fn mark_saved_as(&mut self, saved: &str) {
8073 self.clean_source = saved.to_string();
8074 self.dirty = self.source != self.clean_source;
8075 self.disk_hash = Some(hash_bytes(saved.as_bytes()));
8076 if !self.dirty {
8077 self.status = Some(format!("saved {}", self.file_name()));
8078 }
8079 }
8080
8081 /// What the file looks like now against the bytes leaf last read or wrote.
8082 ///
8083 /// Reads the file and hashes it (see `disk_hash` for why it isn't an mtime),
8084 /// so this is a filesystem round-trip, not a per-frame question — ask it
8085 /// when a window regains focus, on a timer, or before a save.
8086 ///
8087 /// This *only* reports the file. Whether the document also has unsaved edits
8088 /// is `dirty`, and the interesting case is the conjunction: `dirty` plus
8089 /// [`DiskState::Changed`] means a save overwrites someone's work and a
8090 /// [`Doc::reload`] discards the user's. leaf-core deliberately won't choose —
8091 /// it has no way to ask — so it hands a frontend both halves and lets it put
8092 /// the question to the person who can answer it.
8093 #[cfg(feature = "fs")]
8094 pub fn disk_state(&self) -> DiskState {
8095 let Some(want) = self.disk_hash else {
8096 return DiskState::Untitled;
8097 };
8098 match std::fs::read(&self.path) {
8099 Ok(bytes) if hash_bytes(&bytes) == want => DiskState::Unchanged,
8100 Ok(_) => DiskState::Changed,
8101 Err(e) if e.kind() == std::io::ErrorKind::NotFound => DiskState::Missing,
8102 Err(_) => DiskState::Unreadable,
8103 }
8104 }
8105
8106 /// Re-read the file and replace the document with what's there — the other
8107 /// answer to a [`DiskState::Changed`].
8108 ///
8109 /// **Discards unsaved changes, unconditionally.** It doesn't check `dirty`
8110 /// first: a frontend that wants to protect unsaved work asks (`dirty` +
8111 /// [`Doc::disk_state`]) *before* calling this, and one reloading a clean
8112 /// document shouldn't have to argue with a guard.
8113 ///
8114 /// **The undo history survives, and the reload is one step in it.** The
8115 /// whole buffer is spliced with the file's bytes through the same door every
8116 /// other edit goes through, as an [`EditKind::Other`] that coalesces with
8117 /// nothing on either side — so ^Z after a formatter or a `git checkout` has
8118 /// swapped the document out from under a reader gives them back what they
8119 /// were looking at, marked dirty, and ^Z again carries on into whatever they
8120 /// had done before it. This used to build a fresh parse and drop the stack,
8121 /// on the reasoning that twig's history belongs to the buffer and these are
8122 /// different bytes; that is true of *rebasing* a step onto them and not of
8123 /// recording the swap itself as one, which is all this is. A splice twig
8124 /// won't take falls back to the fresh parse, and only that path still costs
8125 /// the history.
8126 ///
8127 /// The caret keeps its byte offset, clamped to the new length; the selection
8128 /// is dropped. Anything cleverer would be a lie: leaf doesn't know how the
8129 /// file changed, so it can't know where the caret "still" is. Clamping keeps
8130 /// it where the user left it in the common case (a change further down the
8131 /// file, or none in the text they're sitting in), and never puts it
8132 /// somewhere invalid. A selection has two such offsets and no such excuse —
8133 /// silently reinterpreting one over changed bytes would arm the *next*
8134 /// keystroke to delete something the user never selected.
8135 ///
8136 /// Nothing is touched unless the whole reload succeeds; a failure leaves the
8137 /// document alone with a status.
8138 #[cfg(feature = "fs")]
8139 pub fn reload(&mut self) {
8140 if self.is_untitled() {
8141 self.status = Some("no file to reload".into());
8142 return;
8143 }
8144 let bytes = match std::fs::read(&self.path) {
8145 Ok(b) => b,
8146 Err(e) => {
8147 self.status = Some(format!("reload failed: {e}"));
8148 return;
8149 }
8150 };
8151 let Ok(source) = String::from_utf8(bytes) else {
8152 self.status = Some("reload failed: file is not UTF-8".into());
8153 return;
8154 };
8155 // Already these bytes — someone saved a file back unchanged, or leaf's
8156 // own write is being read back. Re-baseline against it and stop: a
8157 // splice of the text onto itself would put an undo step on the stack for
8158 // something nobody did.
8159 if source == self.source {
8160 self.disk_hash = Some(hash_bytes(source.as_bytes()));
8161 self.clean_source = source;
8162 self.dirty = false;
8163 self.status = Some(format!("reloaded {}", self.file_name()));
8164 return;
8165 }
8166 let caret = self.caret;
8167 // The pre-reload caret, so undoing the swap puts it back where the
8168 // reader was standing — the same bracketing `splice_exact` does.
8169 self.record_caret();
8170 if self
8171 .editor
8172 .edit_range(0, self.source.len(), &source)
8173 .is_ok()
8174 {
8175 self.refresh();
8176 } else {
8177 // twig wouldn't take the splice. Start over from the bytes, which is
8178 // what this always did, and is the one path that still costs the
8179 // history — `format` is the format this document *is*, not what the
8180 // (unchanged) name now says, see `save_as`.
8181 match new_editor(source.as_bytes(), self.format) {
8182 Ok(editor) => {
8183 self.editor = editor;
8184 // A fresh editor, and so a fresh history — with no
8185 // step in it for an open group to fold into.
8186 self.undo_steps = 0;
8187 self.redo_steps = 0;
8188 if let Some(g) = &mut self.undo_group {
8189 g.has_step = false;
8190 }
8191 self.source = source.clone();
8192 // Not going through `refresh`, so the revision has to move
8193 // here or every frontend keeps painting the old file from
8194 // cache.
8195 self.revision += 1;
8196 }
8197 Err(e) => {
8198 self.status = Some(format!("reload failed: {e}"));
8199 return;
8200 }
8201 }
8202 }
8203 self.disk_hash = Some(hash_bytes(source.as_bytes()));
8204 self.clean_source = self.source.clone();
8205 self.caret = caret.min(self.source.len());
8206 self.anchor = None;
8207 self.goal_col = None;
8208 self.last_edit_kind = None;
8209 self.dirty = false;
8210 self.status = Some(format!("reloaded {}", self.file_name()));
8211 self.clamp_caret();
8212 // And the post-reload caret, so a redo restores it.
8213 self.record_caret();
8214 }
8215
8216 /// Re-read the source from twig after it has changed the document. The one
8217 /// funnel every edit, undo, and redo comes through — so it's where the
8218 /// revision moves, and anything cached against the text dies here.
8219 fn refresh(&mut self) {
8220 if let Ok(s) = self.editor.source_str() {
8221 self.source = s;
8222 }
8223 self.revision += 1;
8224 // An edit is a step onto the history and the end of anything undone;
8225 // `undo`/`redo` come through here too and correct this after.
8226 self.undo_steps = (self.undo_steps + 1).min(UNDO_CAP);
8227 self.redo_steps = 0;
8228 // Inside a group, fold each step into the group's first as it lands.
8229 // Not for `undo`/`redo`, which close the group before they get here.
8230 if let Some(g) = &mut self.undo_group {
8231 if g.has_step {
8232 self.fold_last_undo();
8233 } else {
8234 g.has_step = true;
8235 }
8236 }
8237 self.clamp_caret();
8238 }
8239
8240 /// Whether [`undo`](Self::undo) has a step to take back — for a native
8241 /// Edit menu to enable its item by, and exactly when `undo` would move.
8242 pub fn can_undo(&self) -> bool {
8243 !self.read_only && self.undo_steps > 0
8244 }
8245
8246 /// Whether [`redo`](Self::redo) has an undone step to restore.
8247 pub fn can_redo(&self) -> bool {
8248 !self.read_only && self.redo_steps > 0
8249 }
8250
8251 // ── caret movement ─────────────────────────────────────────────────────────
8252 // `extend` grows the selection (Shift+motion): it pins the anchor on the
8253 // first extended step and moves only the caret; an un-extended motion drops
8254 // the selection.
8255
8256 /// Place the caret at byte `offset` (clamped to a char boundary), extending
8257 /// the selection when `extend` is set. The public form of `move_to`, for a
8258 /// frontend that hit-tests pixels straight to a source offset.
8259 pub fn place_caret(&mut self, offset: usize, extend: bool) {
8260 self.goal_col = None;
8261 let before = self.caret;
8262 // A pixel hit-test can land between the visible caret stops — in the
8263 // blank gap a paragraph break is drawn with, or inside a hidden delimiter.
8264 // Snap to the nearest real stop so the caret can't come to rest where it
8265 // would draw in one place and type in another. The `(row, col)` click
8266 // path (`click`) already snaps this way through `offset_of_pos`; the
8267 // source view reaches every byte, so it snaps to nothing.
8268 let target = match self.view {
8269 View::Wysiwyg => self.vmap.snap_to_stop(offset.min(self.source.len())),
8270 // The source view reaches every byte, so there is no stop to snap
8271 // to — but "every byte" still means every *character* boundary. A
8272 // caret resting inside a multi-byte character draws nowhere real
8273 // and panics the next time anything slices there.
8274 View::Source => self.char_boundary_at_or_before(offset),
8275 };
8276 self.move_to(target, extend);
8277 self.clamp_caret();
8278 self.debug_assert_on_a_stop(before);
8279 }
8280
8281 /// Select the whole document (⌘A / Ctrl+A) — everything reachable in the
8282 /// active view, so in WYSIWYG it starts below hidden frontmatter (copy won't
8283 /// grab the metadata) while the source view still selects the literal whole.
8284 pub fn select_all(&mut self) {
8285 self.anchor = Some(self.caret_floor());
8286 self.caret = self.source.len();
8287 self.goal_col = None;
8288 self.last_edit_kind = None;
8289 self.status = None;
8290 }
8291
8292 /// Select the word (or whitespace / punctuation run) at `offset` — the
8293 /// double-click gesture. Anchors on the run's start with the caret at its
8294 /// end so a following Shift-motion extends from the far edge.
8295 pub fn select_word_at(&mut self, offset: usize) {
8296 let (s, e) = word_range_at(&self.source, offset.min(self.source.len()));
8297 self.anchor = Some(s);
8298 self.caret = e;
8299 self.goal_col = None;
8300 self.last_edit_kind = None;
8301 self.status = None;
8302 self.clamp_caret();
8303 }
8304
8305 /// Select the whole enclosing text block (paragraph, heading, list item's
8306 /// text…) at `offset` — the triple-click gesture. Reads the range straight
8307 /// from the AST (twig's `content_span`), so it selects the entire *logical*
8308 /// paragraph even when that paragraph soft-wraps across several visual rows —
8309 /// where a visual-row-based select breaks down, because one source offset at
8310 /// a wrap boundary belongs to two rows at once.
8311 pub fn select_block_at(&mut self, offset: usize) {
8312 let off = offset.min(self.source.len());
8313 let range = self
8314 .editor
8315 .ancestors_at(off)
8316 .ok()
8317 .and_then(|chain| {
8318 // Ancestors run root → deepest; the deepest node that is neither
8319 // an inline span nor a multi-block container is the text block
8320 // the caret sits in (a paragraph, a heading, a code block…).
8321 chain
8322 .into_iter()
8323 .rev()
8324 .find(|m| !wysiwyg::is_inline_kind(&m.kind) && !is_block_container(&m.kind))
8325 .map(|m| m.content_span.unwrap_or(m.span))
8326 })
8327 .unwrap_or_else(|| source_line_range(&self.source, off));
8328 self.anchor = Some(range.start.min(self.source.len()));
8329 self.caret = range.end.min(self.source.len());
8330 self.goal_col = None;
8331 self.last_edit_kind = None;
8332 self.status = None;
8333 self.clamp_caret();
8334 }
8335
8336 /// Select the exact source range `[start, end)` — anchor at `start`, caret
8337 /// at `end` — without snapping either end to a visible caret stop.
8338 ///
8339 /// The one caret verb that takes a range it was *handed* rather than one it
8340 /// worked out, for a host that already knows the bytes it means: a search
8341 /// hit, an annotation's footprint, a quote re-anchored through
8342 /// [`Doc::selection_quote`]. [`place_caret`](Self::place_caret) is the
8343 /// wrong tool for that, and not by a little — it snaps to the nearest
8344 /// *visible* stop, and where a range butts up against a hidden delimiter
8345 /// the nearest stop is the one before it, so selecting the "needle" of
8346 /// `**needle**` comes back with "needl" and an edit against it strands the
8347 /// "e".
8348 ///
8349 /// What `place_caret` does that is bookkeeping rather than snapping still
8350 /// happens here, because a host handing in a range is not asking to opt out
8351 /// of the invariants:
8352 ///
8353 /// - both ends are clamped into the document and up to
8354 /// [`caret_floor`](Self::caret_floor) — in WYSIWYG the leading
8355 /// frontmatter is hidden, and a caret parked in it draws nowhere and
8356 /// types into the metadata;
8357 /// - both land on character boundaries, so nothing slices a `é` in half;
8358 /// - the sticky vertical goal column is dropped, and any armed inline mark
8359 /// disarmed, since a range from outside inherits neither.
8360 ///
8361 /// An empty range is a caret rather than a selection —
8362 /// [`selection`](Self::selection) reports `None` for it, as it does for any
8363 /// anchor that has met the caret.
8364 pub fn select_range(&mut self, start: usize, end: usize) {
8365 let floor = self.caret_floor();
8366 let anchor = self.char_boundary_at_or_before(start.clamp(floor, self.source.len()));
8367 let caret = self.char_boundary_at_or_before(end.clamp(floor, self.source.len()));
8368 self.anchor = Some(anchor);
8369 self.caret = caret;
8370 self.goal_col = None;
8371 self.status = None;
8372 self.last_edit_kind = None;
8373 self.clear_pending();
8374 }
8375
8376 /// `offset` itself if it is a character boundary, else the boundary before
8377 /// it. An offset that isn't one draws nowhere real and panics the next time
8378 /// anything slices there.
8379 fn char_boundary_at_or_before(&self, offset: usize) -> usize {
8380 let mut o = offset.min(self.source.len());
8381 while o > 0 && !self.source.is_char_boundary(o) {
8382 o -= 1;
8383 }
8384 o
8385 }
8386
8387 /// The lowest source offset the caret may occupy in the active view. In
8388 /// WYSIWYG, leading frontmatter is hidden and unreachable, so the floor is
8389 /// the first rendered offset; the source view reaches everything, so it's 0.
8390 fn caret_floor(&self) -> usize {
8391 match self.view {
8392 View::Wysiwyg => self.vmap.content_start.min(self.source.len()),
8393 View::Source => 0,
8394 }
8395 }
8396
8397 /// Land in a table cell with its whole content selected — the anchor at the
8398 /// cell's start, the caret at its end — so a Tab/Return hop into a cell reads
8399 /// like tabbing into a form field: the text comes up selected, so typing
8400 /// replaces it and an arrow collapses to an edge. An empty cell (`start ==
8401 /// end`) collapses to a plain caret home (an empty selection is no selection).
8402 fn select_cell(&mut self, start: usize, end: usize) {
8403 self.select_range(start, end);
8404 }
8405
8406 fn move_to(&mut self, offset: usize, extend: bool) {
8407 if extend {
8408 if self.anchor.is_none() {
8409 self.anchor = Some(self.caret);
8410 }
8411 } else {
8412 self.anchor = None;
8413 }
8414 self.caret = offset.min(self.source.len()).max(self.caret_floor());
8415 self.status = None;
8416 // A caret move ends the current typing/deletion run, so the next edit
8417 // starts a fresh undo group rather than coalescing across the gap.
8418 self.last_edit_kind = None;
8419 // Moving away disarms any sticky mark — "start bold" applies only where
8420 // it was asked for, not wherever the caret next lands.
8421 self.clear_pending();
8422 }
8423
8424 // In the source view, motion walks source bytes / source lines. In the
8425 // WYSIWYG view it walks the rendered glyph grid (the visual map), which is
8426 // what steps the caret cleanly over hidden delimiters.
8427
8428 pub fn move_left(&mut self, extend: bool) {
8429 self.goal_col = None;
8430 if !extend && let Some((s, _e)) = self.selection() {
8431 self.move_to(s, false);
8432 return;
8433 }
8434 let target = match self.view {
8435 View::Source => {
8436 if self.caret > 0 {
8437 prev_boundary(&self.source, self.caret)
8438 } else {
8439 0
8440 }
8441 }
8442 // Walks caret *stops*, not columns: decoration (a table border, a
8443 // cell's padding) is stepped over in one press, and a hidden
8444 // delimiter never holds the caret up — though the end of a mark's
8445 // content is a stop of its own (`VisualMap::mark_ends`), so
8446 // leaving `**bold**` from past its `**` is a press onto the end of
8447 // the bold and another onto the `d`.
8448 View::Wysiwyg => self
8449 .vmap
8450 .caret_stop_before(self.caret)
8451 .unwrap_or(self.caret),
8452 };
8453 let before = self.caret;
8454 self.move_to(target, extend);
8455 self.debug_assert_on_a_stop(before);
8456 }
8457
8458 pub fn move_right(&mut self, extend: bool) {
8459 self.goal_col = None;
8460 if !extend && let Some((_s, e)) = self.selection() {
8461 self.move_to(e, false);
8462 return;
8463 }
8464 let target = match self.view {
8465 View::Source => {
8466 if self.caret < self.source.len() {
8467 next_boundary(&self.source, self.caret)
8468 } else {
8469 self.caret
8470 }
8471 }
8472 View::Wysiwyg => self.vmap.caret_stop_after(self.caret).unwrap_or(self.caret),
8473 };
8474 let before = self.caret;
8475 self.move_to(target, extend);
8476 self.debug_assert_on_a_stop(before);
8477 }
8478
8479 /// Move to the start of the previous word (⌥← / Ctrl+←).
8480 pub fn move_word_left(&mut self, extend: bool) {
8481 self.goal_col = None;
8482 let before = self.caret;
8483 let target = self.word_left_from(self.caret);
8484 self.move_to(target, extend);
8485 self.debug_assert_on_a_stop(before);
8486 }
8487
8488 /// Move to the end of the next word (⌥→ / Ctrl+→).
8489 pub fn move_word_right(&mut self, extend: bool) {
8490 self.goal_col = None;
8491 let before = self.caret;
8492 let target = self.word_right_from(self.caret);
8493 self.move_to(target, extend);
8494 self.debug_assert_on_a_stop(before);
8495 }
8496
8497 // Word boundaries are found in the space the *view* is in. The source view
8498 // walks the source, because there the source is what's rendered. WYSIWYG
8499 // walks the rendered text instead: `**` is invisible to the user, so it has
8500 // to be invisible to word motion too — a caret parked inside one draws in
8501 // the column after `bold` and types two bytes earlier, and a word-delete
8502 // that stops there shreds the markup into `a ** c`.
8503
8504 /// The word boundary to the left of `off` in the active view's space.
8505 fn word_left_from(&self, off: usize) -> usize {
8506 match self.view {
8507 View::Source => prev_word(&self.source, off),
8508 View::Wysiwyg => self.glyph_word_left(off),
8509 }
8510 }
8511
8512 /// The word boundary to the right of `off` in the active view's space.
8513 fn word_right_from(&self, off: usize) -> usize {
8514 match self.view {
8515 View::Source => next_word(&self.source, off),
8516 View::Wysiwyg => self.glyph_word_right(off),
8517 }
8518 }
8519
8520 /// The character class of the glyph drawn at stop `off`.
8521 ///
8522 /// Read from the source, because a stop points at the source byte its glyph
8523 /// came from — the source *is* where the rendered character is written. What
8524 /// makes the walk glyph space rather than source space is that it only ever
8525 /// visits stops, and the hidden bytes between them have none.
8526 fn class_at(&self, off: usize) -> Class {
8527 self.source
8528 .get(off..)
8529 .and_then(|s| s.chars().next())
8530 .map_or(Class::Space, classify)
8531 }
8532
8533 /// [`next_word`] in glyph space: skip any leading separators, then consume
8534 /// the following word run, with the stop table standing in for the source's
8535 /// characters.
8536 fn glyph_word_right(&self, from: usize) -> usize {
8537 let Some(mut off) = self.vmap.stop_at_or_after(from) else {
8538 return from;
8539 };
8540 let mut in_word = false;
8541 loop {
8542 match self.class_at(off) {
8543 Class::Word => in_word = true,
8544 _ if in_word => return off,
8545 _ => {}
8546 }
8547 match self.vmap.stop_after(off) {
8548 Some(next) => off = next,
8549 None => return off,
8550 }
8551 }
8552 }
8553
8554 /// [`prev_word`] in glyph space: skip separators walking left, then consume
8555 /// the preceding word run.
8556 fn glyph_word_left(&self, from: usize) -> usize {
8557 let Some(mut off) = self.vmap.stop_at_or_before(from) else {
8558 return from;
8559 };
8560 let mut in_word = false;
8561 while let Some(prev) = self.vmap.stop_before(off) {
8562 match self.class_at(prev) {
8563 Class::Word => in_word = true,
8564 _ if in_word => return off,
8565 _ => {}
8566 }
8567 off = prev;
8568 }
8569 off
8570 }
8571
8572 /// After a motion that walks the visual map, the caret must be *on* the map.
8573 /// A stop is the only offset where the caret draws and edits in the same
8574 /// place, and it's the invariant both a caret parked inside an emoji and one
8575 /// parked inside a `**` were quietly breaking.
8576 ///
8577 /// Only when the caret actually moved: a walk with nowhere to go leaves it
8578 /// where it was, which is wherever the floor or a frontend put it rather
8579 /// than somewhere this motion chose.
8580 fn debug_assert_on_a_stop(&self, before: usize) {
8581 debug_assert!(
8582 self.view != View::Wysiwyg
8583 || self.vmap.num_rows() == 0
8584 || self.caret == before
8585 || self.vmap.is_stop(self.caret),
8586 "motion left the caret at {}, which is not a caret stop: it would draw in \
8587 one place and type in another",
8588 self.caret
8589 );
8590 }
8591
8592 // Up and Down run off the ends of the document rather than stopping dead at
8593 // them: Up from the first row lands at the document's start, Down from the
8594 // last at its end. That's Cocoa's rule (`moveUp:`/`moveDown:` past the edge
8595 // are `moveToBeginningOfDocument:`/`moveToEndOfDocument:`), and holding ↓
8596 // reaching the end of the text is what a reader means by it.
8597 //
8598 // The views used to disagree here by accident rather than by decision: the
8599 // source view fell into the edge behaviour through `row_col_to_offset`
8600 // clamping an out-of-range row to the end of the string, while WYSIWYG had
8601 // no row below to walk to and did nothing at all. They share the rule now,
8602 // each in its own space — the source view reaches every byte, WYSIWYG only
8603 // the offsets it draws.
8604
8605 pub fn move_up(&mut self, extend: bool) {
8606 let (row, col) = self.caret_pos();
8607 let goal = self.goal_col.unwrap_or(col);
8608 let target = match self.view {
8609 View::Source => match row.checked_sub(1) {
8610 Some(r) => row_col_to_offset(&self.source, r, goal),
8611 None => self.reachable_start(),
8612 },
8613 // A table's border rules are drawn but hold no caret, so Up steps
8614 // over them to the row that does.
8615 View::Wysiwyg => match self.vmap.navigable_above(row) {
8616 Some(r) => self.row_target(r, goal),
8617 None => self.reachable_start(),
8618 },
8619 };
8620 self.step_vertical(target, goal, extend);
8621 }
8622
8623 pub fn move_down(&mut self, extend: bool) {
8624 let (row, col) = self.caret_pos();
8625 let goal = self.goal_col.unwrap_or(col);
8626 let target = match self.view {
8627 View::Source => match self.source_row_below(row) {
8628 Some(r) => row_col_to_offset(&self.source, r, goal),
8629 None => self.reachable_end(),
8630 },
8631 View::Wysiwyg => match self.vmap.navigable_below(row) {
8632 Some(r) => self.row_target(r, goal),
8633 None => self.reachable_end(),
8634 },
8635 };
8636 self.step_vertical(target, goal, extend);
8637 }
8638
8639 /// Land a vertical motion at `target`, latching the `goal` column it aimed
8640 /// with so the rest of the run keeps aiming there.
8641 ///
8642 /// A motion with nowhere to go changes *nothing*, the goal column included:
8643 /// the latch used to run before the early return at the top of the document,
8644 /// so an Up that did nothing still armed a column, and the next Down aimed
8645 /// at one the caret had never been in.
8646 fn step_vertical(&mut self, target: usize, goal: usize, extend: bool) {
8647 let before = self.caret;
8648 if target == before {
8649 return;
8650 }
8651 self.goal_col = Some(goal);
8652 self.move_to(target, extend);
8653 self.debug_assert_on_a_stop(before);
8654 }
8655
8656 /// The source line below `row`, or `None` when `row` is the last one. Lines
8657 /// are counted by newline, so a trailing one leaves a real, empty last line
8658 /// for the caret to sit on — the document ends below it, not on it.
8659 fn source_row_below(&self, row: usize) -> Option<usize> {
8660 let last = self.source.bytes().filter(|&b| b == b'\n').count();
8661 (row < last).then_some(row + 1)
8662 }
8663
8664 /// Where a vertical motion aiming at the `goal` column lands on visual row
8665 /// `r`: the column clamped to the row, mapped to its offset, then held
8666 /// inside the row's own [bounds](Self::row_bounds) — a wrapped row's last
8667 /// column belongs to the row below, and a gutter's column 0 points at the
8668 /// block rather than at this row.
8669 fn row_target(&self, r: usize, goal: usize) -> usize {
8670 let (start, end) = self.row_bounds(r);
8671 self.vmap
8672 .offset_of_pos(r, goal.min(self.vmap.row_width(r)))
8673 .clamp(start, end)
8674 }
8675
8676 /// The first and last offsets the caret can reach in the active view.
8677 ///
8678 /// Not the same span in both: the source view shows every byte, so it can
8679 /// reach every byte. WYSIWYG reaches only what it draws — hidden frontmatter
8680 /// sits below the first stop, and a document's trailing newline is drawn
8681 /// nowhere and so sits past the last.
8682 fn reachable_start(&self) -> usize {
8683 match self.view {
8684 View::Source => 0,
8685 View::Wysiwyg => self.vmap.stop_at_or_after(0).unwrap_or(self.caret),
8686 }
8687 }
8688
8689 fn reachable_end(&self) -> usize {
8690 match self.view {
8691 View::Source => self.source.len(),
8692 View::Wysiwyg => self
8693 .vmap
8694 .stop_at_or_before(self.source.len())
8695 .unwrap_or(self.caret),
8696 }
8697 }
8698
8699 /// The `[start, end]` offsets visual row `r` *draws* — everything on it,
8700 /// including the space a soft wrap ate off its end, which is drawn on this
8701 /// row however much the offset past it belongs to the next one.
8702 fn row_span(&self, r: usize) -> (usize, usize) {
8703 let start = self
8704 .vmap
8705 .row_start(r)
8706 .unwrap_or_else(|| self.vmap.offset_of_pos(r, 0));
8707 let end = self.vmap.offset_of_pos(r, self.vmap.row_width(r));
8708 (start.min(end), end)
8709 }
8710
8711 /// [`row_span`](Self::row_span) narrowed to where the caret can stand: a
8712 /// soft wrap's shared offset opens the row below (see `pos_of_offset`), so
8713 /// this row's last position is the one before it — the offset before the
8714 /// space the wrap ate, where the caret draws just past the row's last word
8715 /// and types there too.
8716 ///
8717 /// Aiming at the shared offset instead is what stalled End: it is the row's
8718 /// last *column*, so End pressed on the row reached it and then read back as
8719 /// the row below's start, where a second press ran on to that row's end and
8720 /// the next to the one after — End walking down the paragraph a row a press.
8721 fn row_bounds(&self, r: usize) -> (usize, usize) {
8722 let (start, end) = self.row_span(r);
8723 let wraps = self
8724 .vmap
8725 .navigable_below(r)
8726 .and_then(|b| self.vmap.row_start(b))
8727 .is_some_and(|off| off == end);
8728 match wraps {
8729 true => (start, self.vmap.stop_before(end).unwrap_or(end).max(start)),
8730 false => (start, end),
8731 }
8732 }
8733
8734 /// The `[start, end]` of the line Home and End aim at: the visual row in
8735 /// WYSIWYG, the logical line in the source view. Both ends are caret stops.
8736 ///
8737 /// A soft-wrapped row is a line here, because it is one to the eye and the
8738 /// eye is what these keys are aimed by — a reader pressing End means the end
8739 /// of the line they can see. (`select_block_at` wants the opposite and reads
8740 /// the AST for it: a triple-click grabs the whole paragraph, however many
8741 /// rows it folds into.)
8742 fn line_bounds(&self) -> (usize, usize) {
8743 let (row, _) = self.caret_pos();
8744 match self.view {
8745 View::Source => {
8746 let start = line_start(&self.source, row);
8747 (start, line_end_from(&self.source, start))
8748 }
8749 View::Wysiwyg => self.row_bounds(row),
8750 }
8751 }
8752
8753 /// The same line as [`line_bounds`](Self::line_bounds), as far as it is
8754 /// *drawn* — what a kill takes.
8755 ///
8756 /// The two part only at a soft wrap, over the space the wrap ate: the caret
8757 /// can't stand after it (that offset opens the row below, and End stopping
8758 /// there would walk), but it is on this row, and a kill that spared it would
8759 /// leave a double space behind where the row's text had been. Deleting it
8760 /// joins nothing — a wrap is drawn, not written.
8761 fn line_span(&self) -> (usize, usize) {
8762 let (row, _) = self.caret_pos();
8763 match self.view {
8764 View::Source => self.line_bounds(),
8765 View::Wysiwyg => self.row_span(row),
8766 }
8767 }
8768
8769 /// The first offset in `[start, end]` holding something other than
8770 /// whitespace, or `end` when the line holds nothing else — where Home aims.
8771 ///
8772 /// Walks the space the view is in, as word motion does: WYSIWYG steps stops,
8773 /// so a hidden delimiter is never taken for the line's first character (nor
8774 /// landed on), and the source view steps the source it is showing.
8775 fn first_non_space(&self, start: usize, end: usize) -> usize {
8776 let mut off = start;
8777 while off < end {
8778 if self.class_at(off) != Class::Space {
8779 return off;
8780 }
8781 off = match self.view {
8782 View::Source => next_boundary(&self.source, off),
8783 View::Wysiwyg => match self.vmap.stop_after(off) {
8784 Some(next) => next,
8785 None => return end,
8786 },
8787 };
8788 }
8789 end
8790 }
8791
8792 /// Home: to the first character on the line, or to column 0 when the caret
8793 /// is already on it — the two-press toggle every editor spells this way.
8794 /// The indentation is somewhere the caret has to be able to reach and almost
8795 /// never where a reader is headed, so it costs the second press.
8796 pub fn move_home(&mut self, extend: bool) {
8797 self.goal_col = None;
8798 let (start, end) = self.line_bounds();
8799 let text = self.first_non_space(start, end);
8800 let target = if self.caret == text { start } else { text };
8801 let before = self.caret;
8802 self.move_to(target, extend);
8803 self.debug_assert_on_a_stop(before);
8804 }
8805
8806 /// End: to the end of the line.
8807 pub fn move_end(&mut self, extend: bool) {
8808 self.goal_col = None;
8809 let (_, end) = self.line_bounds();
8810 let before = self.caret;
8811 self.move_to(end, extend);
8812 self.debug_assert_on_a_stop(before);
8813 }
8814
8815 /// Hop to the next (Tab) or previous (Shift+Tab) table cell, landing with the
8816 /// cell's whole content selected (see [`Self::select_cell`]). Returns `false`
8817 /// when the caret isn't in a table, or is already in the last/first cell — the
8818 /// frontend then does whatever Tab normally does (indent), so Tab keeps its
8819 /// meaning everywhere else.
8820 pub fn cell_hop(&mut self, forward: bool) -> bool {
8821 let Some((grid, r, c)) = self.table_grid_at(self.caret) else {
8822 return false;
8823 };
8824 // Flatten to document (row-major) order and step one cell either way.
8825 let i: usize = grid[..r].iter().map(Vec::len).sum::<usize>() + c;
8826 let flat: Vec<(usize, usize)> = grid.into_iter().flatten().collect();
8827 let next = if forward {
8828 i.checked_add(1)
8829 } else {
8830 i.checked_sub(1)
8831 };
8832 let Some(&(start, end)) = next.and_then(|j| flat.get(j)) else {
8833 return false; // at the table's edge; leave Tab to the frontend
8834 };
8835 self.select_cell(start, end);
8836 true
8837 }
8838
8839 /// Move the caret to the cell directly above (`down == false`) or below in
8840 /// the same column, landing with the cell's whole content selected (see
8841 /// [`Self::select_cell`]). Returns `false` at the grid's top/bottom edge (or
8842 /// when the caret isn't in a table), so the frontend can fall through — the
8843 /// vertical counterpart of [`Self::cell_hop`].
8844 ///
8845 /// A ragged row that is short a column clamps to its last cell, so Down never
8846 /// falls out of the table over a gap the row above happened to have.
8847 pub fn cell_move_vertical(&mut self, down: bool) -> bool {
8848 let Some((grid, r, c)) = self.table_grid_at(self.caret) else {
8849 return false;
8850 };
8851 let target = match down {
8852 true => r + 1,
8853 false if r == 0 => return false,
8854 false => r - 1,
8855 };
8856 let Some(row) = grid.get(target) else {
8857 return false;
8858 };
8859 let Some(&(start, end)) = row.get(c).or_else(|| row.last()) else {
8860 return false;
8861 };
8862 self.select_cell(start, end);
8863 true
8864 }
8865
8866 /// The table containing `off` as a row-major grid of `(start, end)` cell
8867 /// caret homes, plus the `(row, col)` the caret sits in — `None` when `off`
8868 /// isn't in a table. Read straight off the visual map's laid-out grid, so
8869 /// every cell (an empty one included, whose derived home twig gives no
8870 /// `content_span` for) is present and in the order Tab walks them.
8871 // Grid, row, column — three returns that only ever travel together, and a
8872 // named type for the pair of them would be read at one call site.
8873 #[allow(clippy::type_complexity)]
8874 fn table_grid_at(&self, off: usize) -> Option<(Vec<Vec<(usize, usize)>>, usize, usize)> {
8875 for t in &self.vmap.tables {
8876 let mut pos = None;
8877 let grid: Vec<Vec<(usize, usize)>> = t
8878 .grid
8879 .iter()
8880 .enumerate()
8881 .map(|(r, row)| {
8882 row.cells
8883 .iter()
8884 .enumerate()
8885 .map(|(c, cell)| {
8886 if pos.is_none() && off >= cell.start && off <= cell.end {
8887 pos = Some((r, c));
8888 }
8889 (cell.start, cell.end)
8890 })
8891 .collect()
8892 })
8893 .collect();
8894 if let Some((r, c)) = pos {
8895 return Some((grid, r, c));
8896 }
8897 }
8898 None
8899 }
8900
8901 // ── table key policy ──────────────────────────────────────────────────────
8902 // The three keys a table gives its own meaning — Tab, Return, Shift+Return —
8903 // as one policy every frontend shares, rather than each re-deriving it. Each
8904 // reports whether it acted *as a table key*; a `false` hands the key back to
8905 // the frontend's ordinary handling (indent, newline) so it keeps its meaning
8906 // everywhere else.
8907
8908 /// Tab / Shift+Tab inside a table. Tab steps to the next cell, appending a
8909 /// fresh row and entering it when it runs off the last one; Shift+Tab steps
8910 /// back and simply stays put at the very first cell. `false` when the caret
8911 /// isn't in a table.
8912 pub fn cell_tab(&mut self, forward: bool) -> bool {
8913 if !self.caret_in_table() {
8914 return false;
8915 }
8916 if self.cell_hop(forward) {
8917 return true;
8918 }
8919 // Off the last cell: grow the table by a row and step into its first
8920 // cell. (Shift+Tab at the first cell has nowhere to go and just holds.)
8921 if forward {
8922 self.append_row_and_enter(0);
8923 }
8924 true
8925 }
8926
8927 /// Return inside a table: drop to the cell below in the same column,
8928 /// appending a new row when the caret is already in the last one. `false`
8929 /// when the caret isn't in a table, so the frontend inserts a newline.
8930 pub fn cell_return(&mut self) -> bool {
8931 if !self.caret_in_table() {
8932 return false;
8933 }
8934 if self.cell_move_vertical(true) {
8935 return true;
8936 }
8937 // Already on the last row: grow one below and drop into the same column.
8938 let col = self.table_grid_at(self.caret).map_or(0, |(_, _, c)| c);
8939 self.append_row_and_enter(col);
8940 true
8941 }
8942
8943 /// Append a row below the caret's (last) row and land in `col` of it. The
8944 /// caret is in the last row, so twig's "insert below" makes the fresh row the
8945 /// table's new last — but twig re-spells the whole table, moving every byte,
8946 /// so the destination is read back from the rebuilt grid by the table's
8947 /// position (stable across a row insert), not from the pre-edit caret.
8948 fn append_row_and_enter(&mut self, col: usize) {
8949 let table = self.caret_table_index();
8950 self.table_insert_row(true);
8951 self.rebuild_map();
8952 let Some((start, end)) = table
8953 .and_then(|ti| self.vmap.tables.get(ti))
8954 .and_then(|t| t.grid.last())
8955 .and_then(|row| row.cells.get(col.min(row.cells.len().saturating_sub(1))))
8956 .map(|cell| (cell.start, cell.end))
8957 else {
8958 return;
8959 };
8960 self.select_cell(start, end);
8961 }
8962
8963 /// The index, among the document's tables, of the one the caret sits in —
8964 /// `None` when it's in none. Used to re-find a table after an edit re-spells
8965 /// it (a row insert leaves the table order unchanged).
8966 fn caret_table_index(&self) -> Option<usize> {
8967 let off = self.caret;
8968 self.vmap.tables.iter().position(|t| {
8969 t.grid
8970 .iter()
8971 .any(|row| row.cells.iter().any(|c| off >= c.start && off <= c.end))
8972 })
8973 }
8974
8975 /// Shift+Return inside a table: insert a hard line break *within* the current
8976 /// cell, via twig's `insert_line_break`. `false` when the caret isn't in a
8977 /// table, so the frontend inserts an ordinary line break.
8978 ///
8979 /// A table row is a single source line, so the newline-spelled hard break
8980 /// can't live in a cell. twig spells the in-cell break the format's way
8981 /// (`<br>` for Markdown) and reparses it as a *semantic* `hard_break`, so the
8982 /// break round-trips as structure the renderer reads back as a line — not the
8983 /// opaque raw HTML the old raw-splice left behind.
8984 ///
8985 /// Djot has no idiomatic in-cell break, so twig refuses it
8986 /// (`UnsupportedFormat`) rather than emit a `<br>` that any other djot reader
8987 /// would render as the literal text `<br>`. The gesture is still *consumed*
8988 /// there — returning `false` would let the frontend insert a real newline,
8989 /// which splits the one-line row — it just leaves the cell unchanged and says
8990 /// so on the status line. A rollback (`EditConflict`) is swallowed the same.
8991 ///
8992 /// Which formats refuse is [`Capabilities::cell_line_break`], and the two
8993 /// have to be read together: djot is not the only `false`, and naming it in
8994 /// the message was already a guess that HTML — which spells the break as its
8995 /// own `<br>` — would have made wrong.
8996 pub fn cell_line_break(&mut self) -> bool {
8997 if self.read_only || !self.caret_in_table() {
8998 return false;
8999 }
9000 self.record_caret();
9001 match self.editor.insert_line_break(self.caret) {
9002 Ok(change) => {
9003 self.last_edit_kind = None;
9004 self.refresh();
9005 self.caret = change.new.end;
9006 self.anchor = None;
9007 self.goal_col = None;
9008 self.clamp_caret();
9009 self.dirty = self.source != self.clean_source;
9010 self.status = None;
9011 self.record_caret();
9012 }
9013 Err(twig::Error::UnsupportedFormat) => {
9014 self.status = Some(format!(
9015 "in-cell line breaks aren't supported in {}",
9016 self.format_name()
9017 ));
9018 }
9019 Err(_) => {}
9020 }
9021 true
9022 }
9023
9024 /// Rebuild the visual map at the width the last build used. A structural edit
9025 /// bumps the revision and swaps the source in, but leaves the *map* stale;
9026 /// when a single gesture edits and then moves over the result (Tab appending
9027 /// a row, then stepping into it), the move needs the map to already show the
9028 /// edit rather than waiting for the frontend's next frame.
9029 fn rebuild_map(&mut self) {
9030 let wrap = self.vmap_key.as_ref().and_then(|(_, w, _)| *w);
9031 self.build_map(wrap);
9032 }
9033
9034 /// Move the caret to the very start of the document (⌘↑ on macOS,
9035 /// Ctrl+Home on Windows/Linux).
9036 pub fn move_doc_start(&mut self, extend: bool) {
9037 self.goal_col = None;
9038 self.move_to(0, extend);
9039 }
9040
9041 /// Move the caret to the very end of the document (⌘↓ on macOS,
9042 /// Ctrl+End on Windows/Linux).
9043 pub fn move_doc_end(&mut self, extend: bool) {
9044 self.goal_col = None;
9045 let end = self.source.len();
9046 self.move_to(end, extend);
9047 }
9048
9049 /// Point the caret at the body cell `(row, col)` the mouse landed on —
9050 /// `col` being a cell of the terminal grid, which is what a display column
9051 /// is. A click on the far cell of a wide character lands at that
9052 /// character's start; the mapping's own doc-comments carry the rule.
9053 pub fn click(&mut self, row: usize, col: usize, extend: bool) {
9054 self.goal_col = None;
9055 let target = match self.view {
9056 View::Source => row_col_to_offset(&self.source, row, col),
9057 View::Wysiwyg => self.vmap.offset_of_pos(row, col),
9058 };
9059 let before = self.caret;
9060 self.move_to(target, extend);
9061 self.debug_assert_on_a_stop(before);
9062 }
9063
9064 /// A click in the blank space under the document's last block.
9065 ///
9066 /// Not a click *on* anything, so it lands on nothing in particular: the
9067 /// caret goes onto an empty paragraph under the last block, wherever the
9068 /// pointer was horizontally — and if the document does not end with one,
9069 /// one is opened, which is the only way to get out from under a block Enter
9070 /// cannot leave. Enter inside a fenced code block is a literal newline (see
9071 /// [`newline`](Self::newline)), so a document that *ends* in a fence had no
9072 /// way out at all; and a click under any last block used to land at the
9073 /// pointer's x on the block's last line, which is what a click on that line
9074 /// means and not what a click under it does.
9075 ///
9076 /// "Ends with an empty paragraph" is two trailing newlines: the first closes
9077 /// the last line and the second opens the blank line the visual map lays
9078 /// out as a navigable empty row (see `emit_trailing_blank_lines`). A
9079 /// document ending inside an *unclosed* fence gets the fence closed first,
9080 /// since a newline written there would only be another line of code. An
9081 /// empty document has nothing to be under, and the caret simply goes to its
9082 /// start.
9083 ///
9084 /// In the source view the gesture is the ordinary one: the caret goes to the
9085 /// end of the source, and nothing is written. A read-only document likewise.
9086 pub fn click_past_end(&mut self) {
9087 self.goal_col = None;
9088 let len = self.source.len();
9089 if self.view == View::Source || self.read_only || self.source.trim().is_empty() {
9090 self.move_to(len, false);
9091 return;
9092 }
9093 let mut tail = String::new();
9094 if let Some(fence) = self.unclosed_fence_at_end() {
9095 if !self.source.ends_with('\n') {
9096 tail.push('\n');
9097 }
9098 tail.push_str(&fence);
9099 tail.push('\n');
9100 }
9101 let joined = format!("{}{tail}", self.source);
9102 let trailing = joined.len() - joined.trim_end_matches('\n').len();
9103 for _ in trailing..2 {
9104 tail.push('\n');
9105 }
9106 if !tail.is_empty() && !self.splice(len, len, &tail, EditKind::Other) {
9107 return;
9108 }
9109 let end = self.source.len();
9110 self.move_to(end, false);
9111 }
9112
9113 /// The closing fence a document ending inside an unclosed fenced code block
9114 /// needs — the opening fence's own run, behind the quote prefix the block
9115 /// wears — or `None` when the last block is closed, indented, or not a code
9116 /// block at all.
9117 ///
9118 /// Unclosed is when twig's content span reaches the block's end: a closing
9119 /// fence line would lie between the two. `code_info_span`'s read of the
9120 /// fence is not used because it starts at the block's span, which inside a
9121 /// quote is the quote marker rather than the fence.
9122 fn unclosed_fence_at_end(&mut self) -> Option<String> {
9123 let content_end = self.source.trim_end_matches('\n').len();
9124 let block = self
9125 .nodes()
9126 .into_iter()
9127 .filter(|n| n.kind == Kind::CodeBlock && n.span.end >= content_end)
9128 .max_by_key(|n| n.span.start)?;
9129 if block.content_span.as_ref()?.end < block.span.end {
9130 return None;
9131 }
9132 let line = self.source[block.span.start..].lines().next()?;
9133 let opening = line.trim_start_matches(['>', ' ', '\t']);
9134 let fence = opening.chars().next().filter(|c| matches!(c, '`' | '~'))?;
9135 let run: String = opening.chars().take_while(|&c| c == fence).collect();
9136 let prefix = self.quote_prefix_at(block.span.start);
9137 Some(format!("{prefix}{run}"))
9138 }
9139
9140 /// Settle `scroll` for a frame about to be drawn: follow the caret onto the
9141 /// screen if it has moved since the last frame, and never scroll past the
9142 /// last of `rows`.
9143 ///
9144 /// Only if it has *moved* — that's the whole point. Revealing the caret on
9145 /// every frame ties the viewport to it, and a scroll wheel that fights the
9146 /// caret for the viewport loses: the view snaps back the instant it tries to
9147 /// pass the caret's row, so the document can't be scrolled beyond what's
9148 /// already on screen. A caret move is the frontend's cue to follow; a scroll
9149 /// with the caret sitting still is the reader's cue to leave it alone.
9150 pub fn follow_caret(&mut self, caret_row: usize, height: usize, rows: usize) {
9151 if self.drawn_caret != Some(self.caret) {
9152 if caret_row < self.scroll {
9153 self.scroll = caret_row;
9154 } else if height > 0 && caret_row >= self.scroll + height {
9155 self.scroll = caret_row + 1 - height;
9156 }
9157 self.drawn_caret = Some(self.caret);
9158 }
9159 self.scroll = self.scroll.min(rows.saturating_sub(1));
9160 }
9161
9162 /// The caret's screen position `(row, col)` in the active view's grid, with
9163 /// `col` a display column: the cell to draw the caret in, which on a line of
9164 /// `你好` or emoji is not the count of characters before it.
9165 pub fn caret_pos(&self) -> (usize, usize) {
9166 match self.view {
9167 View::Source => offset_to_row_col(&self.source, self.caret),
9168 View::Wysiwyg => self.vmap.pos_of_offset(self.caret),
9169 }
9170 }
9171
9172 fn clamp_caret(&mut self) {
9173 if self.caret > self.source.len() {
9174 self.caret = self.source.len();
9175 }
9176 // In WYSIWYG the caret can't sit inside hidden frontmatter; lift it (and
9177 // any selection anchor) to the first rendered offset.
9178 let floor = self.caret_floor();
9179 if self.caret < floor {
9180 self.caret = floor;
9181 }
9182 if let Some(a) = self.anchor
9183 && a < floor
9184 {
9185 self.anchor = Some(floor);
9186 }
9187 while self.caret > 0 && !self.source.is_char_boundary(self.caret) {
9188 self.caret -= 1;
9189 }
9190 }
9191}
9192
9193// ── byte-offset ⇄ (row, col) helpers ─────────────────────────────────────────
9194
9195// Left/right motion and backspace/delete step by *grapheme cluster*, not
9196// codepoint, so an emoji (a ZWJ sequence) or a base letter plus its combining
9197// marks moves and deletes as the single character a user sees. Grapheme
9198// boundaries are a superset of char boundaries, so the caret stays valid for twig.
9199
9200/// How an insert of `text` groups for undo: a single typed character folds into
9201/// the run of typing around it, while a newline or a multi-character insert is a
9202/// step of its own.
9203fn typed_edit_kind(text: &str) -> EditKind {
9204 if text.chars().take(2).count() == 1 && text != "\n" {
9205 EditKind::Insert
9206 } else {
9207 EditKind::Other
9208 }
9209}
9210
9211fn prev_boundary(s: &str, i: usize) -> usize {
9212 let mut cursor = GraphemeCursor::new(i, s.len(), true);
9213 cursor.prev_boundary(s, 0).ok().flatten().unwrap_or(0)
9214}
9215
9216fn next_boundary(s: &str, i: usize) -> usize {
9217 let mut cursor = GraphemeCursor::new(i, s.len(), true);
9218 cursor.next_boundary(s, 0).ok().flatten().unwrap_or(s.len())
9219}
9220
9221// ── word boundaries ──────────────────────────────────────────────────────────
9222// The shared primitive behind word-wise motion, word deletion, and
9223// double-click-to-select-a-word. A "word" is a maximal run of one character
9224// class; whitespace and punctuation are their own classes, so motion skips
9225// cleanly between them the way native text fields do.
9226
9227#[derive(PartialEq, Eq, Clone, Copy)]
9228enum Class {
9229 Word,
9230 Space,
9231 Other,
9232}
9233
9234/// The source range of an inline node's own visible text — the part of it a
9235/// WYSIWYG caret can reach, as against the delimiters that only spell it.
9236/// `None` for a node with no interior to empty (a `str`, a break).
9237///
9238/// twig reports no `content_span` for `verbatim`/`inline_math`, whose text sits
9239/// one delimiter in from the span — the same place the renderer maps it to. A
9240/// longer fence (`` ``a`` ``) breaks that assumption, so the guess is checked
9241/// against the source rather than trusted: a range guessed wrong here is text
9242/// deleted wrong.
9243fn inline_content_span(n: &FlatNode, source: &str) -> Option<std::ops::Range<usize>> {
9244 if let Some(span) = n.content_span.clone() {
9245 return Some(span);
9246 }
9247 match n.kind.as_str() {
9248 "verbatim" | "inline_math" => {
9249 let text = n.text.as_ref()?;
9250 let start = n.span.start + 1;
9251 let range = start..start + text.len();
9252 (source.get(range.clone()) == Some(text.as_str())).then_some(range)
9253 }
9254 _ => None,
9255 }
9256}
9257
9258/// The `id` a node declares, or `None` for one that declares none — the
9259/// attribute djot writes for a `{#v1}` and mints for a heading.
9260///
9261/// A bare attribute (`{#v1 hidden}`'s `hidden`) has no value, and a bare `id`
9262/// names nothing, so it reads as absent rather than as the empty string.
9263fn declared_id(n: &FlatNode) -> Option<&str> {
9264 n.attrs.iter().find(|(k, _)| k == "id")?.1.as_deref()
9265}
9266
9267/// A heading's words reduced to the form a link fragment spells them in:
9268/// lowercase, runs of anything else collapsed to a single `-`, with none left
9269/// dangling at either end. `## Some Heading Here` → `some-heading-here`.
9270///
9271/// The rule every Markdown renderer follows, and applied to djot's own auto-ids
9272/// too so that `#some-heading-here` and `#Some-Heading-Here` are one question.
9273/// Unicode-aware (`is_alphanumeric`, not an ASCII test), because a heading in
9274/// any other language is still a heading someone will link to. Underscores
9275/// survive for the same reason they do on the web: they are word characters
9276/// wherever identifiers are written.
9277fn slug(text: &str) -> String {
9278 let mut out = String::new();
9279 let mut pending = false;
9280 for c in text.chars() {
9281 if c.is_alphanumeric() || c == '_' {
9282 if pending && !out.is_empty() {
9283 out.push('-');
9284 }
9285 pending = false;
9286 out.extend(c.to_lowercase());
9287 } else {
9288 pending = true;
9289 }
9290 }
9291 out
9292}
9293
9294/// The text of the inline run starting at `first` and its siblings, markup
9295/// stripped: each leaf's payload in order, a line break as a space. `nodes` is
9296/// the arena `Doc::nodes` returns, indexed by id.
9297fn inline_text(nodes: &[FlatNode], first: Option<NodeId>, out: &mut String) {
9298 let mut next = first;
9299 while let Some(id) = next {
9300 let Some(n) = nodes.get(id.0 as usize) else {
9301 return;
9302 };
9303 match (&n.text, &n.kind) {
9304 (Some(text), _) => out.push_str(text),
9305 (None, Kind::SoftBreak | Kind::HardBreak) => out.push(' '),
9306 (None, _) => inline_text(nodes, n.first_child, out),
9307 }
9308 next = n.next_sibling;
9309 }
9310}
9311
9312fn is_block_container(kind: &Kind) -> bool {
9313 matches!(
9314 kind,
9315 Kind::Doc
9316 | Kind::Section
9317 | Kind::BlockQuote
9318 | Kind::BulletList
9319 | Kind::OrderedList
9320 | Kind::TaskList
9321 | Kind::ListItem
9322 | Kind::TaskListItem
9323 // Every `container` — a directive in any of its three forms, or a
9324 // promoted HTML element. A *text* directive is really inline, so
9325 // claiming it here is a small overreach, and the deliberate one this
9326 // function's kind-only peer `is_inline_kind` documents: the pair is
9327 // consulted together, and answering "block container" for something
9328 // inline is what keeps an ancestor walk from stopping short of the
9329 // paragraph that actually holds it.
9330 | Kind::Container
9331 )
9332}
9333
9334/// The `[start, end)` byte range of the source line containing `off` (newline
9335/// excluded) — the fallback when `off` sits outside any AST block (e.g. a blank
9336/// line between paragraphs).
9337fn source_line_range(s: &str, off: usize) -> std::ops::Range<usize> {
9338 let off = off.min(s.len());
9339 let start = s[..off].rfind('\n').map(|p| p + 1).unwrap_or(0);
9340 let end = s[off..].find('\n').map(|p| off + p).unwrap_or(s.len());
9341 start..end
9342}
9343
9344/// How many leading bytes an outdent takes off `line`: a whole indent level
9345/// where the line has one, and whatever it has where it has less.
9346///
9347/// A leading tab counts as a level on its own. It's indentation some other
9348/// editor wrote, and one tab is one level everywhere it came from — measuring it
9349/// in spaces it doesn't contain would leave it untouchable.
9350fn outdent_width(line: &str, unit: usize) -> usize {
9351 if line.starts_with('\t') {
9352 return 1;
9353 }
9354 line.bytes().take(unit).take_while(|b| *b == b' ').count()
9355}
9356
9357/// A list marker found at the head of a line, together with everything before it
9358/// that a sibling line has to repeat.
9359///
9360/// The three offsets differ only inside a block quote, where `> - b` opens with
9361/// a `> ` quote marker the line's own text doesn't own. Outside one they collapse:
9362/// `line_start == marker_start`, and `text` is the plain `" - "`.
9363#[derive(Clone, Debug)]
9364struct ListMarker {
9365 /// The line's first byte.
9366 line_start: usize,
9367 /// Where the marker proper begins, past any quote prefix. The offset to hand
9368 /// the AST: a quoted item's span opens at its bullet, not at the `>`.
9369 marker_start: usize,
9370 /// `line_start` through the marker's trailing space — quote prefix, indent
9371 /// and bullet together, which is what the next item's line opens with.
9372 text: String,
9373}
9374
9375impl ListMarker {
9376 /// Where the item's content starts — one past the marker's trailing space.
9377 fn content_start(&self) -> usize {
9378 self.line_start + self.text.len()
9379 }
9380}
9381
9382fn classify(c: char) -> Class {
9383 if c == '_' || c.is_alphanumeric() {
9384 Class::Word
9385 } else if c.is_whitespace() {
9386 Class::Space
9387 } else {
9388 Class::Other
9389 }
9390}
9391
9392/// The offset at the end of the next word to the right of `i` (⌥→ / Ctrl+→):
9393/// skip any leading separators, then consume the following word run.
9394fn next_word(s: &str, i: usize) -> usize {
9395 let mut off = i;
9396 let mut in_word = false;
9397 for c in s[i..].chars() {
9398 if classify(c) == Class::Word {
9399 in_word = true;
9400 } else if in_word {
9401 break;
9402 }
9403 off += c.len_utf8();
9404 }
9405 off
9406}
9407
9408/// The offset at the start of the word to the left of `i` (⌥← / Ctrl+←):
9409/// skip separators walking left, then consume the preceding word run.
9410fn prev_word(s: &str, i: usize) -> usize {
9411 let mut off = i;
9412 let mut in_word = false;
9413 for c in s[..i].chars().rev() {
9414 if classify(c) == Class::Word {
9415 in_word = true;
9416 } else if in_word {
9417 break;
9418 }
9419 off -= c.len_utf8();
9420 }
9421 off
9422}
9423
9424/// The `[start, end)` run of same-class characters surrounding `off` — the
9425/// word (or whitespace/punctuation run) a double-click selects. At end-of-text
9426/// the run ending there is used.
9427fn word_range_at(s: &str, off: usize) -> (usize, usize) {
9428 if s.is_empty() {
9429 return (0, 0);
9430 }
9431 let off = off.min(s.len());
9432 let reference = if off < s.len() {
9433 s[off..].chars().next()
9434 } else {
9435 s[..off].chars().next_back()
9436 };
9437 let Some(rc) = reference else {
9438 return (off, off);
9439 };
9440 let class = classify(rc);
9441
9442 let mut start = off;
9443 for c in s[..start].chars().rev() {
9444 if classify(c) == class {
9445 start -= c.len_utf8();
9446 } else {
9447 break;
9448 }
9449 }
9450 let mut end = off;
9451 for c in s[end..].chars() {
9452 if classify(c) == class {
9453 end += c.len_utf8();
9454 } else {
9455 break;
9456 }
9457 }
9458 (start, end)
9459}
9460
9461/// `(row, col)` of byte offset `off`, `col` counted in *display columns* from
9462/// the line's start — terminal cells, not characters, so the column names the
9463/// cell the caret is drawn in even on a line of `你好` or emoji.
9464fn offset_to_row_col(s: &str, off: usize) -> (usize, usize) {
9465 let off = off.min(s.len());
9466 let mut row = 0;
9467 let mut line_start = 0;
9468 for (i, &b) in s.as_bytes().iter().enumerate() {
9469 if i >= off {
9470 break;
9471 }
9472 if b == b'\n' {
9473 row += 1;
9474 line_start = i + 1;
9475 }
9476 }
9477 (row, wysiwyg::text_width(&s[line_start..off]))
9478}
9479
9480/// The byte offset at display column `col` of `row` (clamped to that line's
9481/// end) — the inverse of [`offset_to_row_col`], which it has to agree with.
9482///
9483/// A column landing *inside* a character — the second cell of `你`, or any cell
9484/// but the first of an emoji — resolves to that character's start, which is the
9485/// column the caret would have been drawn at to begin with. So both cells of a
9486/// wide character mean the character, and every offset survives the round trip
9487/// out to a column and back. The walk steps by grapheme cluster for the same
9488/// reason the caret does: a cluster is the character, and the cells belong to it
9489/// rather than to the codepoints spelling it.
9490fn row_col_to_offset(s: &str, row: usize, col: usize) -> usize {
9491 let start = line_start(s, row);
9492 let end = line_end_from(s, start);
9493 let mut off = start;
9494 let mut at = 0; // the display column `off` sits at
9495 while off < end {
9496 let next = next_boundary(s, off).min(end);
9497 let cells = wysiwyg::text_width(&s[off..next]);
9498 if at + cells > col {
9499 break; // `col` is one of this cluster's own cells
9500 }
9501 at += cells;
9502 off = next;
9503 }
9504 off
9505}
9506
9507fn line_start(s: &str, row: usize) -> usize {
9508 if row == 0 {
9509 return 0;
9510 }
9511 let mut r = 0;
9512 for (i, &b) in s.as_bytes().iter().enumerate() {
9513 if b == b'\n' {
9514 r += 1;
9515 if r == row {
9516 return i + 1;
9517 }
9518 }
9519 }
9520 s.len()
9521}
9522
9523fn line_end_from(s: &str, start: usize) -> usize {
9524 s[start..].find('\n').map(|p| start + p).unwrap_or(s.len())
9525}
9526
9527/// twig's node-kind name for an inline mark, back to the [`InlineKind`] a
9528/// frontend names when it calls [`Doc::toggle`] — the inverse of the mapping
9529/// twig applies writing the mark out, so the toolbar can light the same button
9530/// that made the node.
9531///
9532/// `None` for every other kind, including the inline nodes that aren't marks at
9533/// all (`str`, `link`, `image`, the math and break kinds): they're things a
9534/// caret stands in, not formatting a button toggles.
9535/// Whether a match from an ancestor chain is an inline run whose delimiters
9536/// the rich view draws nothing for — a mark (`**`, `_`, `==`), or an
9537/// attributed span: `<span data-size="large">…</span>`, djot's `[…]{…}`. The
9538/// span is a [`Kind::Container`], which the kind alone cannot tell from a
9539/// block `<div>`, so the chain's caller passes [`Doc::run_span_ids`] and the
9540/// answer is the node's own. Every delete and caret step that walks over a
9541/// `**` walks over a span's tags by this test; without it Backspace after
9542/// `</span>` took the `>` and left the paragraph unparseable.
9543fn hides_delims(m: &QueryMatch, run_spans: &[NodeId]) -> bool {
9544 inline_kind(&m.kind).is_some() || run_spans.contains(&NodeId(m.node_id))
9545}
9546
9547fn inline_kind(kind: &Kind) -> Option<InlineKind> {
9548 Some(match kind {
9549 Kind::Strong => InlineKind::Strong,
9550 Kind::Emph => InlineKind::Emph,
9551 Kind::Verbatim => InlineKind::Verbatim,
9552 Kind::Mark => InlineKind::Mark,
9553 Kind::Superscript => InlineKind::Superscript,
9554 Kind::Subscript => InlineKind::Subscript,
9555 Kind::Insert => InlineKind::Insert,
9556 Kind::Delete => InlineKind::Delete,
9557 _ => return None,
9558 })
9559}
9560
9561/// leaf's [`MarkColor`] as twig's — the palette twig writes as the emoji after
9562/// a highlight's opening `==`.
9563///
9564/// Two enums for one closed vocabulary, and the duplication is the boundary
9565/// working: core's is what a *frontend* names (`style::MarkColor`, beside the
9566/// [`Role`](crate::Role) that carries it into the glyph map) and twig's is what
9567/// the editor writes. Spelled as a match rather than routed through the two
9568/// crates' name strings so that a colour added on either side is a compile
9569/// error here, where the pairing is decided, rather than a runtime `None` that
9570/// would read as "clear the colour".
9571fn twig_mark_color(color: MarkColor) -> twig::MarkColor {
9572 match color {
9573 MarkColor::Red => twig::MarkColor::Red,
9574 MarkColor::Orange => twig::MarkColor::Orange,
9575 MarkColor::Yellow => twig::MarkColor::Yellow,
9576 MarkColor::Green => twig::MarkColor::Green,
9577 MarkColor::Blue => twig::MarkColor::Blue,
9578 MarkColor::Purple => twig::MarkColor::Purple,
9579 MarkColor::Brown => twig::MarkColor::Brown,
9580 }
9581}
9582
9583/// Where an offset lands after a splice it didn't make — twig's own rule, from
9584/// [`Change`]: shift anything at or past the replaced range's end by the length
9585/// the replacement gained or lost, and leave anything before it alone.
9586///
9587/// An offset *inside* the replaced range has no text of its own to ride any
9588/// more, and lands at the end of what replaced it: for
9589/// [`Doc::set_mark_color`] that is a caret standing on the colour prefix when
9590/// the prefix is cleared, which then sits where the highlighted text begins.
9591/// One node's attribute list, twig's own `(key, value)` pairs owned — what
9592/// every presentation gesture reads, edits one key of, and passes back whole.
9593type Attrs = Vec<(String, Option<String>)>;
9594
9595/// The name of the leaf directive a page break is — [`Doc::insert_page_break`]
9596/// writes it and the walker draws it, and a frontend that paginates matches a
9597/// [`DirectiveMark`](crate::wysiwyg::DirectiveMark) against it. One spelling,
9598/// stated once.
9599pub const PAGE_BREAK: &str = "page-break";
9600
9601/// `attrs` with `key` set to `value`, or removed when `value` is `None`, and
9602/// every other attribute kept in its place — the read-edit-write half of twig's
9603/// replace-not-merge contract for a `data-` key.
9604///
9605/// **A key that is already there is rewritten where it stands**, and only a key
9606/// the node did not have goes on the end. That is what makes the proposal's
9607/// worked example true: `class="lead center" id="intro"
9608/// data-line-height="1.5"`, right-aligned, is `class="lead right" id="intro"
9609/// data-line-height="1.5"` — the same document with one token changed, and a
9610/// one-line diff. Removing the key and pushing it back would reorder the
9611/// author's attributes on every press, so a document that passed through the
9612/// editor came out shuffled even where nothing about it had changed.
9613///
9614/// A duplicate key — which no format leaf opens can spell, but twig reports
9615/// verbatim — collapses onto the first of its copies, since twig is handed one
9616/// value for one key either way.
9617fn with_attr(attrs: &[(String, Option<String>)], key: &str, value: Option<&str>) -> Attrs {
9618 let mut out: Attrs = Vec::with_capacity(attrs.len() + 1);
9619 let mut written = false;
9620 for (k, v) in attrs {
9621 if k != key {
9622 out.push((k.clone(), v.clone()));
9623 continue;
9624 }
9625 if let Some(new) = value.filter(|_| !written) {
9626 out.push((k.clone(), Some(new.to_string())));
9627 written = true;
9628 }
9629 }
9630 if let Some(new) = value.filter(|_| !written) {
9631 out.push((key.to_string(), Some(new.to_string())));
9632 }
9633 out
9634}
9635
9636/// [`with_attr`] for a `class` token: every token `mine` claims is removed, and
9637/// `token` added, with the rest of the list kept in order.
9638///
9639/// `class` is a space-separated token list, and leaf owns three of the tokens in
9640/// it. A paragraph that arrives as `class="lead center"` and is right-aligned
9641/// goes out as `class="lead right"`; one whose last owned token goes and which
9642/// carried nothing else loses the key, so a block that has lost its whole
9643/// vocabulary is spelled bare again. `class` itself keeps its place among the
9644/// attributes, because [`with_attr`] does the writing.
9645fn with_class_token(
9646 attrs: &[(String, Option<String>)],
9647 mine: impl Fn(&str) -> bool,
9648 token: Option<&str>,
9649) -> Attrs {
9650 let kept: Vec<&str> = attrs
9651 .iter()
9652 .find(|(k, _)| k == "class")
9653 .and_then(|(_, v)| v.as_deref())
9654 .unwrap_or_default()
9655 .split_whitespace()
9656 .filter(|t| !mine(t))
9657 .collect();
9658 let class = kept.into_iter().chain(token).collect::<Vec<_>>().join(" ");
9659 with_attr(
9660 attrs,
9661 "class",
9662 (!class.is_empty()).then_some(class.as_str()),
9663 )
9664}
9665
9666/// An owned attribute list as the borrowed pairs twig's two attribute ops take.
9667///
9668/// A **bare** attribute — one twig reports with no value, such as HTML's `<p
9669/// hidden>` — is passed back as an empty one. Twig refuses a `None` outright
9670/// (djot has no bare attribute, so no format reads one back everywhere), and
9671/// `hidden=""` is the same document where `hidden` is; dropping it instead
9672/// would lose what the author wrote, which is the one thing these gestures
9673/// promise not to do.
9674fn attr_pairs(attrs: &[(String, Option<String>)]) -> Vec<(&str, Option<&str>)> {
9675 attrs
9676 .iter()
9677 .map(|(k, v)| (k.as_str(), Some(v.as_deref().unwrap_or_default())))
9678 .collect()
9679}
9680
9681fn reanchor(off: usize, change: &Change) -> usize {
9682 if off < change.old.start {
9683 return off;
9684 }
9685 if off < change.old.end {
9686 return change.new.end;
9687 }
9688 (off + change.new.end).saturating_sub(change.old.end)
9689}
9690
9691/// [`reanchor`] for an edit that respells the markup *around* a block and
9692/// leaves the block's own bytes alone — which is every attribute gesture.
9693///
9694/// `block` is that block's content span before and after the splice, so an
9695/// offset standing in the text keeps its distance from the text's start and how
9696/// many bytes twig wrote above it never enters the arithmetic. That is the whole
9697/// rule, and it is why nothing here knows how long a `<div …>` is: a second key
9698/// on the same div lengthens the attribute line, clearing the last one takes the
9699/// div away entirely, and both are the same sum. `None` where the splice named
9700/// no block at either end, which is every djot case — the `{…}` line is written
9701/// above the block, and the block itself only shifts past it.
9702///
9703/// Anywhere else it is `reanchor`'s own answer: untouched before the splice,
9704/// shifted by its delta after it, and at the splice's end for an offset that
9705/// stood in markup being rewritten — a caret inside djot's `{…}` line has no
9706/// text to keep.
9707fn reanchor_in_block(
9708 off: usize,
9709 change: &Change,
9710 block: Option<(&Range<usize>, &Range<usize>)>,
9711) -> usize {
9712 if let Some((was, now)) = block
9713 && was.start <= off
9714 && off <= was.end
9715 {
9716 return now.start + (off - was.start).min(now.end - now.start);
9717 }
9718 reanchor(off, change)
9719}
9720
9721/// A watermark for a file's contents (see `Doc::disk_hash`).
9722///
9723/// `DefaultHasher` is not stable across Rust releases, which doesn't matter: a
9724/// watermark is compared only against one taken by the same process moments
9725/// earlier, and never outlives it. 64 bits leaves a collision — an external edit
9726/// that hashes to exactly what leaf wrote — at odds no filesystem race gets near.
9727fn hash_bytes(bytes: &[u8]) -> u64 {
9728 use std::hash::{Hash, Hasher};
9729 let mut h = std::collections::hash_map::DefaultHasher::new();
9730 bytes.hash(&mut h);
9731 h.finish()
9732}
9733
9734#[cfg(feature = "fs")]
9735fn detect_format(path: &Path) -> Result<Format> {
9736 let ext = path
9737 .extension()
9738 .and_then(|e| e.to_str())
9739 .unwrap_or("")
9740 .to_ascii_lowercase();
9741 Ok(match ext.as_str() {
9742 "dj" | "djot" => Format::Djot,
9743 "md" | "markdown" => Format::Markdown,
9744 "xml" => Format::Xml,
9745 "html" | "htm" => Format::Html,
9746 other => return Err(anyhow!("unknown document extension: .{other}")),
9747 })
9748}
9749
9750/// The one span `from` and `to` differ in: `[start, end)` of `from`, and
9751/// what `to` holds there. The longest shared prefix, then the longest shared
9752/// suffix of what is left — never overlapping it — each ended on a character
9753/// boundary of both.
9754fn differing_span<'a>(from: &str, to: &'a str) -> (usize, usize, &'a str) {
9755 let (a, b) = (from.as_bytes(), to.as_bytes());
9756 let mut prefix = a.iter().zip(b).take_while(|(x, y)| x == y).count();
9757 while !(from.is_char_boundary(prefix) && to.is_char_boundary(prefix)) {
9758 prefix -= 1;
9759 }
9760 let most = a.len().min(b.len()) - prefix;
9761 let mut suffix = a
9762 .iter()
9763 .rev()
9764 .zip(b.iter().rev())
9765 .take(most)
9766 .take_while(|(x, y)| x == y)
9767 .count();
9768 while !(from.is_char_boundary(a.len() - suffix) && to.is_char_boundary(b.len() - suffix)) {
9769 suffix -= 1;
9770 }
9771 (prefix, a.len() - suffix, &to[prefix..b.len() - suffix])
9772}
9773
9774#[cfg(test)]
9775mod tests {
9776 use super::*;
9777 use crate::style::{FontFamily, LineSpacing, SizeStep};
9778
9779 /// A document open in `view`. WYSIWYG motion reads the visual map, which the
9780 /// renderer stamps each frame, so the map is built here too — a WYSIWYG doc
9781 /// without one is a view no user is ever in.
9782 fn doc_in(view: View, name: &str, body: &str) -> Doc {
9783 // The fixture name doubles as the temp file's, so two tests picking the
9784 // same one raced under the parallel runner and read each other's body —
9785 // a green suite proving the wrong thing. The counter makes that
9786 // unreachable rather than asking every future caller to notice.
9787 static SEQ: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
9788 let seq = SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
9789 let mut p = std::env::temp_dir();
9790 p.push(format!("leaf_test_{name}_{seq}.md"));
9791 std::fs::write(&p, body).unwrap();
9792 let mut d = Doc::open(p).unwrap();
9793 d.view = view;
9794 if view == View::Wysiwyg {
9795 d.build_visual(80);
9796 }
9797 d
9798 }
9799
9800 // Source-view document for the source-behaviour tests. `Doc::open` now
9801 // defaults to WYSIWYG (leaf's default view), so pin the source view here;
9802 // `wysiwyg_doc` builds the rich-text variant on top of this.
9803 fn doc_with(name: &str, body: &str) -> Doc {
9804 doc_in(View::Source, name, body)
9805 }
9806
9807 /// Every visual row's drawn text — what the reader actually sees, which is
9808 /// the only thing the reveal preference is supposed to change.
9809 fn drawn_rows(d: &Doc) -> Vec<String> {
9810 d.vmap
9811 .rows
9812 .iter()
9813 .map(|r| r.glyphs.iter().map(|g| g.ch).collect())
9814 .collect()
9815 }
9816
9817 /// Put the caret at the first byte of `needle` and rebuild, so the row under
9818 /// it becomes the revealed line.
9819 fn caret_at(d: &mut Doc, needle: &str) {
9820 d.caret = d.source.find(needle).expect("needle in source");
9821 d.build_visual(80);
9822 }
9823
9824 #[test]
9825 fn blockquote_after_a_list_is_not_bulleted() {
9826 // twig nests a following top-level block quote under the `bullet_list`
9827 // (a direct child, not a `list_item`). The map must render it de-nested —
9828 // `│ quote`, never `• │ quote` — with a blank separator, like any block
9829 // that follows a list. Regression for the "combined list + blockquote" bug.
9830 let mut d = doc_in(View::Wysiwyg, "bq_after_list", "- item\n\n> quote\n");
9831 d.build_visual(80);
9832 let rows: Vec<String> = d
9833 .vmap
9834 .rows
9835 .iter()
9836 .map(|r| r.glyphs.iter().map(|g| g.ch).collect())
9837 .collect();
9838 assert!(
9839 rows.iter().any(|r| r == "│ quote"),
9840 "block quote should render on its own gutter, got rows: {rows:?}"
9841 );
9842 assert!(
9843 !rows.iter().any(|r| r.contains('•') && r.contains('│')),
9844 "no row should carry both a bullet and a quote gutter, got rows: {rows:?}"
9845 );
9846 }
9847
9848 // ── the map is built at most once per (revision, wrap) ───────────────────
9849 //
9850 // A frontend repaints for reasons that have nothing to do with the text — a
9851 // blinking caret, a scroll — and rebuilding the map is O(document). These
9852 // pin *that the cache fires*, which a passing suite can't tell you: a cache
9853 // that never hits is invisible to every other test in this file.
9854 //
9855 // The probe is to wreck the built map and ask for it again. A rebuild
9856 // repairs it; a cache hit hands the wreckage straight back. Nothing else
9857 // can distinguish the two from outside.
9858
9859 #[test]
9860 fn a_rebuild_with_nothing_changed_reuses_the_map() {
9861 let mut d = doc_in(View::Wysiwyg, "cache_hit", "# Title\n\nbody\n");
9862 d.build_visual(80);
9863 assert!(!d.vmap.rows.is_empty());
9864 d.vmap.rows.clear(); // wreck it
9865 d.build_visual(80);
9866 assert!(
9867 d.vmap.rows.is_empty(),
9868 "the map was rebuilt though nothing changed — the cache never fired"
9869 );
9870 }
9871
9872 #[test]
9873 fn an_edit_rebuilds_the_map() {
9874 let mut d = doc_in(View::Wysiwyg, "cache_edit", "# Title\n\nbody\n");
9875 d.build_visual(80);
9876 let before = d.revision();
9877 d.vmap.rows.clear();
9878 d.insert("x");
9879 d.build_visual(80);
9880 assert!(d.revision() > before, "an edit must move the revision");
9881 assert!(
9882 !d.vmap.rows.is_empty(),
9883 "an edited document must not paint from a stale map"
9884 );
9885 }
9886
9887 #[test]
9888 fn a_width_change_rebuilds_the_map() {
9889 // The map is a function of the wrap width too, so a resize is a miss
9890 // even though the text is untouched.
9891 let mut d = doc_in(
9892 View::Wysiwyg,
9893 "cache_width",
9894 "one two three four five six\n",
9895 );
9896 d.build_visual(80);
9897 d.vmap.rows.clear();
9898 d.build_visual(12);
9899 assert!(!d.vmap.rows.is_empty(), "a resize must rebuild the map");
9900 // And the unwrapped map is its own key, not the same as any width.
9901 d.vmap.rows.clear();
9902 d.build_visual_unwrapped();
9903 assert!(!d.vmap.rows.is_empty(), "unwrapped is a different map");
9904 }
9905
9906 #[test]
9907 fn a_motion_does_not_rebuild_the_map() {
9908 // The whole point: moving the caret changes nothing the map is built
9909 // from. If a motion bumped the revision, every arrow key would cost a
9910 // full rebuild and the cache would be worthless.
9911 let mut d = doc_in(View::Wysiwyg, "cache_motion", "# Title\n\nbody text\n");
9912 d.build_visual(80);
9913 let rev = d.revision();
9914 d.move_right(false);
9915 d.move_right(true);
9916 d.move_down(false);
9917 assert_eq!(d.revision(), rev, "a motion must not move the revision");
9918 d.vmap.rows.clear();
9919 d.build_visual(80);
9920 assert!(
9921 d.vmap.rows.is_empty(),
9922 "a motion should not rebuild the map"
9923 );
9924 }
9925
9926 #[test]
9927 fn saving_does_not_rebuild_the_map() {
9928 // Saving changes `dirty`, not the text.
9929 let mut d = doc_in(View::Wysiwyg, "cache_save", "# Title\n\nbody\n");
9930 d.insert("x");
9931 d.build_visual(80);
9932 let rev = d.revision();
9933 d.save();
9934 assert_eq!(d.revision(), rev, "a save must not move the revision");
9935 assert!(!d.dirty, "the save should have cleaned the document");
9936 }
9937
9938 #[test]
9939 fn a_reload_rebuilds_the_map() {
9940 // Reload replaces the text without going through `refresh`, so it has to
9941 // move the revision itself — else the editor paints the old file.
9942 let mut d = doc_in(View::Wysiwyg, "cache_reload", "# Title\n\nbody\n");
9943 d.build_visual(80);
9944 let rev = d.revision();
9945 std::fs::write(&d.path, "# Other\n\nwholly new\n").unwrap();
9946 d.reload();
9947 assert!(d.revision() > rev, "a reload must move the revision");
9948 d.build_visual(80);
9949 let text: String = d
9950 .vmap
9951 .rows
9952 .iter()
9953 .flat_map(|r| r.glyphs.iter().map(|g| g.ch))
9954 .collect();
9955 assert!(
9956 text.contains("wholly new"),
9957 "the reloaded text should be on screen, got {text:?}"
9958 );
9959 }
9960
9961 // ── golden-case harness ──────────────────────────────────────────────────
9962 // The pattern the whole parity suite can reuse: write a fixture with the
9963 // caret marked by `|`, run one action, and compare the rendered result —
9964 // also caret-marked — against the expected string. One readable line per
9965 // behavior, and it exercises the exact `Doc` ops both frontends call.
9966
9967 /// Split a `|`-marked fixture into `(source, caret_offset)`.
9968 fn parse_caret(marked: &str) -> (String, usize) {
9969 let caret = marked.find('|').expect("fixture needs a `|` caret marker");
9970 (marked.replacen('|', "", 1), caret)
9971 }
9972
9973 /// Render a doc's source with `|` at the caret (and `[`…`]` around any
9974 /// selection) so a result reads like the fixtures.
9975 fn render_caret(d: &Doc) -> String {
9976 // (offset, rank, char); rank keeps coincident markers ordered `[ | ]`
9977 // so the caret always renders inside its own selection.
9978 let mut marks: Vec<(usize, u8, char)> = vec![(d.caret, 1, '|')];
9979 if let Some((s, e)) = d.selection() {
9980 marks.push((s, 0, '['));
9981 marks.push((e, 2, ']'));
9982 }
9983 // Insert right-to-left: descending offset, then descending rank.
9984 marks.sort_by(|a, b| b.0.cmp(&a.0).then(b.1.cmp(&a.1)));
9985 let mut out = d.source.clone();
9986 for (at, _, ch) in marks {
9987 out.insert(at, ch);
9988 }
9989 out
9990 }
9991
9992 /// Load a `|`-marked fixture, run `action`, return the caret-marked result.
9993 fn golden(name: &str, marked: &str, action: impl FnOnce(&mut Doc)) -> String {
9994 golden_in(View::Source, name, marked, action)
9995 }
9996
9997 /// [`golden`] in a chosen view — the editing ops are the view's to share, so
9998 /// the same fixture has to read the same way in both.
9999 fn golden_in(view: View, name: &str, marked: &str, action: impl FnOnce(&mut Doc)) -> String {
10000 let (src, caret) = parse_caret(marked);
10001 let mut d = doc_in(view, name, &src);
10002 d.caret = caret;
10003 action(&mut d);
10004 render_caret(&d)
10005 }
10006
10007 #[test]
10008 fn word_motion_walks_word_by_word() {
10009 let g = |m, f: fn(&mut Doc)| golden("word_motion", m, f);
10010 assert_eq!(
10011 g("hello wor|ld", |d| d.move_word_left(false)),
10012 "hello |world"
10013 );
10014 assert_eq!(
10015 g("hello| world", |d| d.move_word_left(false)),
10016 "|hello world"
10017 );
10018 assert_eq!(
10019 g("hel|lo world", |d| d.move_word_right(false)),
10020 "hello| world"
10021 );
10022 assert_eq!(
10023 g("hello| world", |d| d.move_word_right(false)),
10024 "hello world|"
10025 );
10026 // Punctuation is its own class, so motion stops at the boundary.
10027 assert_eq!(g("|foo.bar", |d| d.move_word_right(false)), "foo|.bar");
10028 }
10029
10030 #[test]
10031 fn word_motion_extends_the_selection_when_asked() {
10032 assert_eq!(
10033 golden("word_sel", "hello |world", |d| d.move_word_right(true)),
10034 "hello [world|]"
10035 );
10036 }
10037
10038 #[test]
10039 fn delete_word_removes_a_whole_word() {
10040 let g = |m, f: fn(&mut Doc)| golden("del_word", m, f);
10041 assert_eq!(g("hello world|", |d| d.delete_word_back()), "hello |");
10042 assert_eq!(g("hello |world", |d| d.delete_word_forward()), "hello |");
10043 assert_eq!(g("foo |bar baz", |d| d.delete_word_back()), "|bar baz");
10044 }
10045
10046 // ── Home / End ───────────────────────────────────────────────────────────
10047
10048 #[test]
10049 fn home_toggles_between_the_line_s_text_and_its_margin() {
10050 // Source: the indentation is what the toggle is for. WYSIWYG resolves an
10051 // indent to the markup it spells everywhere it means one, so the fixture
10052 // with whitespace left to walk is a code block, which is verbatim.
10053 let g = |m, f: fn(&mut Doc)| golden("smart_home", m, f);
10054 assert_eq!(g(" inden|ted", |d| d.move_home(false)), " |indented");
10055 assert_eq!(g(" |indented", |d| d.move_home(false)), "| indented");
10056 assert_eq!(g("| indented", |d| d.move_home(false)), " |indented");
10057 // A line with no indentation has one place to go, so the toggle is a
10058 // no-op rather than a trip to nowhere.
10059 assert_eq!(g("hel|lo", |d| d.move_home(false)), "|hello");
10060 assert_eq!(g("|hello", |d| d.move_home(false)), "|hello");
10061
10062 let mut d = wysiwyg_doc("smart_home_wys", "```\n indented\n```\n");
10063 let indent = d.source.find(" indented").unwrap();
10064 d.caret = indent + 6; // inside "indented"
10065 d.move_home(false);
10066 assert_eq!(
10067 d.caret,
10068 indent + 4,
10069 "wysiwyg: Home aims at the code line's text"
10070 );
10071 d.move_home(false);
10072 assert_eq!(
10073 d.caret, indent,
10074 "wysiwyg: the second press takes the indent"
10075 );
10076 d.move_home(false);
10077 assert_eq!(d.caret, indent + 4, "wysiwyg: the toggle swaps back");
10078 }
10079
10080 #[test]
10081 fn end_takes_the_line_the_view_is_showing() {
10082 // The line differs by view for the same document, and that is the point:
10083 // a bare newline inside a paragraph is a soft break, which WYSIWYG draws
10084 // as a space on one row and the source view as two lines.
10085 let mut d = doc_with("end_src", "one two\nthree\n");
10086 d.caret = 1;
10087 d.move_end(false);
10088 assert_eq!(d.caret, 7, "source: the end of the source line");
10089
10090 let mut d = wysiwyg_doc("end_wys", "one two\nthree\n");
10091 d.caret = 1;
10092 d.move_end(false);
10093 assert_eq!(
10094 d.caret, 13,
10095 "wysiwyg: the end of the row, soft break and all"
10096 );
10097 }
10098
10099 #[test]
10100 fn home_and_end_extend_the_selection_when_asked() {
10101 for (view, tag) in VIEWS {
10102 let mut d = doc_in(view, &format!("home_end_ext_{tag}"), "hello world");
10103 d.caret = 6;
10104 d.move_end(true);
10105 assert_eq!(d.selection(), Some((6, 11)), "{tag}: End extends");
10106 let mut d = doc_in(view, &format!("home_ext_{tag}"), "hello world");
10107 d.caret = 6;
10108 d.move_home(true);
10109 assert_eq!(d.selection(), Some((0, 6)), "{tag}: Home extends");
10110 }
10111 }
10112
10113 // ── kill to the line's start / end ───────────────────────────────────────
10114
10115 #[test]
10116 fn kill_to_the_line_start_and_end_in_both_views() {
10117 for (view, tag) in VIEWS {
10118 // The gap that reads as a paragraph break in each view: the source
10119 // view's lines are the renderer's rows only where the source says so.
10120 let gap = if view == View::Source { "\n" } else { "\n\n" };
10121 let mut d = doc_in(
10122 view,
10123 &format!("kill_end_{tag}"),
10124 &format!("one two{gap}three\n"),
10125 );
10126 d.caret = 3;
10127 d.delete_to_line_end();
10128 assert_eq!(
10129 d.source,
10130 format!("one{gap}three\n"),
10131 "{tag}: ^K to the line's end"
10132 );
10133 assert_eq!(d.caret, 3, "{tag}: the caret stays where it kills from");
10134
10135 let mut d = doc_in(
10136 view,
10137 &format!("kill_start_{tag}"),
10138 &format!("one two{gap}three\n"),
10139 );
10140 d.caret = 7; // the end of the first line
10141 d.delete_to_line_start();
10142 assert_eq!(
10143 d.source,
10144 format!("{gap}three\n"),
10145 "{tag}: ⌘⌫ to the line's start"
10146 );
10147 assert_eq!(d.caret, 0, "{tag}");
10148 }
10149 }
10150
10151 #[test]
10152 fn a_kill_at_the_line_s_edge_leaves_the_lines_joined() {
10153 // The decision: at the boundary both kills do nothing, rather than
10154 // eating the line break. "Line" is the view's own — in WYSIWYG it ends
10155 // at a soft wrap as often as at a newline, where there is nothing
10156 // written to delete — and a source newline is only half of the blank
10157 // line between two paragraphs, so taking it leaves a soft break rather
10158 // than the join it looks like. Backspace and Delete are the keys for it.
10159 for (view, tag) in VIEWS {
10160 let gap = if view == View::Source { "\n" } else { "\n\n" };
10161 let src = format!("one{gap}three\n");
10162 let mut d = doc_in(view, &format!("kill_edge_end_{tag}"), &src);
10163 d.caret = 3; // the end of "one"
10164 d.delete_to_line_end();
10165 assert_eq!(
10166 d.source, src,
10167 "{tag}: ^K at the line's end joined it to the next"
10168 );
10169
10170 let mut d = doc_in(view, &format!("kill_edge_start_{tag}"), &src);
10171 d.caret = 3 + gap.len(); // the start of "three"
10172 d.delete_to_line_start();
10173 assert_eq!(
10174 d.source, src,
10175 "{tag}: ⌘⌫ at the line's start joined it to the last"
10176 );
10177 }
10178 }
10179
10180 #[test]
10181 fn a_kill_takes_the_selection_when_there_is_one() {
10182 // What every other delete here does with one, so these two as well.
10183 for (view, tag) in VIEWS {
10184 for (name, kill) in [
10185 (
10186 "end",
10187 (|d: &mut Doc| d.delete_to_line_end()) as fn(&mut Doc),
10188 ),
10189 ("start", |d: &mut Doc| d.delete_to_line_start()),
10190 ] {
10191 let mut d = doc_in(view, &format!("kill_sel_{name}_{tag}"), "one two three\n");
10192 d.anchor = Some(4);
10193 d.caret = 7; // "two"
10194 kill(&mut d);
10195 assert_eq!(
10196 d.source, "one three\n",
10197 "{tag}: {name} ignored the selection"
10198 );
10199 assert_eq!(d.selection(), None, "{tag}: {name}");
10200 }
10201 }
10202 }
10203
10204 #[test]
10205 fn a_kill_takes_the_markup_it_empties_with_it() {
10206 // The same hazard a word-delete has: a WYSIWYG range covers what the
10207 // user can see, which for `**bold**` is the word and never the
10208 // delimiters, so a kill that stopped at the text would leave `a ****` —
10209 // markup wrapped around nothing.
10210 let mut d = wysiwyg_doc("kill_widen", "a **bold**\n");
10211 d.caret = d.source.find("bold").unwrap();
10212 d.delete_to_line_end();
10213 assert_eq!(d.source, "a \n");
10214 }
10215
10216 #[test]
10217 fn a_kill_is_undone_in_one_step() {
10218 for (view, tag) in VIEWS {
10219 let mut d = doc_in(view, &format!("kill_undo_{tag}"), "one two three\n");
10220 d.caret = 3;
10221 d.delete_to_line_end();
10222 assert_eq!(d.source, "one\n", "{tag}");
10223 d.undo();
10224 assert_eq!(d.source, "one two three\n", "{tag}: a kill takes one undo");
10225 }
10226 }
10227
10228 #[test]
10229 fn select_block_grabs_the_whole_paragraph_from_any_wrapped_row() {
10230 // Regression: triple-click used move_home/move_end over visual rows, so
10231 // it only worked on a paragraph's first row (a wrap-boundary offset maps
10232 // to the earlier row). select_block_at reads the AST, so every offset in
10233 // the paragraph selects the whole thing.
10234 let body = "one two three four five six seven eight\n";
10235 let mut d = doc_with("sel_block", body);
10236 d.view = View::Wysiwyg;
10237 d.build_visual(12); // force the paragraph to wrap into several rows
10238 assert!(d.vmap.num_rows() > 1, "test needs a wrapped paragraph");
10239 let para = (0, "one two three four five six seven eight".len());
10240 for off in [0usize, 8, 19, 28, 38] {
10241 d.caret = 0;
10242 d.anchor = None;
10243 d.select_block_at(off);
10244 assert_eq!(
10245 d.selection(),
10246 Some(para),
10247 "offset {off} should select the paragraph"
10248 );
10249 }
10250 }
10251
10252 #[test]
10253 fn select_block_uses_content_span_for_a_heading() {
10254 let mut d = doc_with("sel_head", "# Title\n\nbody\n");
10255 d.select_block_at(4); // inside "Title"
10256 // content_span excludes the "# " marker.
10257 assert_eq!(d.selected_text(), Some("Title"));
10258 d.select_block_at(10); // inside "body"
10259 assert_eq!(d.selected_text(), Some("body"));
10260 }
10261
10262 #[test]
10263 fn select_all_spans_the_document() {
10264 let mut d = doc_with("sel_all", "abc\n\ndef\n");
10265 d.select_all();
10266 assert_eq!(d.selection(), Some((0, d.source.len())));
10267 }
10268
10269 #[test]
10270 fn select_word_at_picks_the_surrounding_word() {
10271 let mut d = doc_with("sel_word", "hello world\n");
10272 d.select_word_at(8); // inside "world"
10273 assert_eq!(d.selection(), Some((6, 11)));
10274 // Double-clicking at end-of-word still grabs the word to its left.
10275 d.select_word_at(5); // the space between the words
10276 assert_eq!(d.selection(), Some((5, 6)));
10277 }
10278
10279 #[test]
10280 fn word_helpers_respect_utf8_boundaries() {
10281 // "café" is 5 bytes ('é' is two); motion must land on char boundaries.
10282 assert_eq!(
10283 golden("utf8", "|café ok", |d| d.move_word_right(false)),
10284 "café| ok"
10285 );
10286 assert_eq!(golden("utf8b", "café |ok", |d| d.delete_word_back()), "|ok");
10287 }
10288
10289 #[test]
10290 fn typing_inserts_at_the_caret_and_advances_it() {
10291 let mut d = doc_with("type", "hello\n");
10292 d.insert("Hi ");
10293 assert_eq!(d.source, "Hi hello\n");
10294 assert_eq!(d.caret, 3);
10295 assert!(d.dirty);
10296 }
10297
10298 #[test]
10299 fn backspace_deletes_the_char_before_the_caret() {
10300 let mut d = doc_with("bs", "hello\n");
10301 d.caret = 3; // after "hel"
10302 d.backspace();
10303 assert_eq!(d.source, "helo\n");
10304 assert_eq!(d.caret, 2);
10305 }
10306
10307 #[test]
10308 fn typing_replaces_the_selection() {
10309 let mut d = doc_with("replace", "a word b\n");
10310 d.anchor = Some(2);
10311 d.caret = 6; // "word" selected
10312 d.insert("X");
10313 assert_eq!(d.source, "a X b\n");
10314 assert_eq!(d.caret, 3);
10315 assert_eq!(d.anchor, None);
10316 }
10317
10318 #[test]
10319 fn toggle_bold_wraps_then_unwraps_the_selection() {
10320 let mut d = doc_with("bold", "a word b\n");
10321 d.anchor = Some(2);
10322 d.caret = 6;
10323 d.toggle(InlineKind::Strong);
10324 assert_eq!(d.source, "a **word** b\n");
10325 // The toggled region stays selected, so a second toggle reverses it.
10326 d.toggle(InlineKind::Strong);
10327 assert_eq!(d.source, "a word b\n");
10328 d.toggle(InlineKind::Strong);
10329 assert_eq!(d.source, "a **word** b\n");
10330 }
10331
10332 #[test]
10333 fn toggle_code_wraps_then_unwraps_the_selection() {
10334 let mut d = doc_with("code_rt", "a word b\n");
10335 d.anchor = Some(2);
10336 d.caret = 6;
10337 d.toggle(InlineKind::Verbatim);
10338 assert_eq!(d.source, "a `word` b\n");
10339 d.toggle(InlineKind::Verbatim);
10340 assert_eq!(d.source, "a word b\n");
10341 }
10342
10343 #[test]
10344 fn sticky_bold_with_no_selection_wraps_the_next_typed_text() {
10345 // ⌘b at a bare caret, then type: the text comes out bold with no
10346 // selection ever made — the word-processor "start bold here" gesture.
10347 let mut d = doc_with("sticky_wrap", "xy\n");
10348 d.caret = 1; // between x and y
10349 d.toggle(InlineKind::Strong);
10350 assert_eq!(d.source, "xy\n", "arming a mark must not edit the document");
10351 d.insert("A");
10352 assert_eq!(d.source, "x**A**y\n");
10353 }
10354
10355 #[test]
10356 fn sticky_bold_lights_the_toolbar_before_any_typing() {
10357 // The button must light the instant ⌘b is pressed, or the mode is
10358 // invisible until the first character lands.
10359 let mut d = doc_with("sticky_light", "xy\n");
10360 d.caret = 1;
10361 assert!(!d.active_inline_marks().contains(InlineKind::Strong));
10362 d.toggle(InlineKind::Strong);
10363 assert!(d.active_inline_marks().contains(InlineKind::Strong));
10364 }
10365
10366 #[test]
10367 fn sticky_bold_toggled_off_types_normally_again() {
10368 // ⌘b, type, ⌘b, type: the first run is bold, the second is not — all
10369 // in the flow of typing, the exact sequence the user described.
10370 let mut d = doc_with("sticky_off", "\n");
10371 d.caret = 0;
10372 d.toggle(InlineKind::Strong);
10373 d.insert("a");
10374 d.insert("b"); // continues inside the run, no re-arming
10375 assert_eq!(d.source, "**ab**\n");
10376 d.toggle(InlineKind::Strong); // ⌘b again — shed bold
10377 d.insert("c");
10378 assert_eq!(d.source, "**ab**c\n");
10379 }
10380
10381 #[test]
10382 fn continued_typing_after_a_sticky_run_stays_in_the_run() {
10383 // Once a mark is realised the caret sits inside the run, so plain typing
10384 // extends it rather than starting a second, adjacent bold span.
10385 let mut d = doc_with("sticky_cont", "\n");
10386 d.caret = 0;
10387 d.toggle(InlineKind::Emph);
10388 d.insert("h");
10389 d.insert("i");
10390 assert_eq!(d.source, "*hi*\n");
10391 }
10392
10393 #[test]
10394 fn moving_the_caret_disarms_a_sticky_mark() {
10395 // Arming a mark and then moving away must not style text elsewhere.
10396 let mut d = doc_with("sticky_disarm", "xy\n");
10397 d.caret = 0;
10398 d.toggle(InlineKind::Strong);
10399 d.move_right(false); // caret 0 → 1, disarms
10400 assert!(!d.active_inline_marks().contains(InlineKind::Strong));
10401 d.insert("A");
10402 assert_eq!(d.source, "xAy\n", "the mark must not follow the caret");
10403 }
10404
10405 #[test]
10406 fn stacked_sticky_marks_apply_together() {
10407 // ⌘b then ⌘i before typing: the text comes out both bold and italic.
10408 let mut d = doc_with("sticky_stack", "\n");
10409 d.caret = 0;
10410 d.toggle(InlineKind::Strong);
10411 d.toggle(InlineKind::Emph);
10412 d.insert("x");
10413 // Land the caret on the styled character and confirm both marks are live.
10414 d.anchor = Some(d.source.find('x').unwrap());
10415 d.caret = d.anchor.unwrap() + 1;
10416 let marks = d.active_inline_marks();
10417 assert!(marks.contains(InlineKind::Strong), "bold: {}", d.source);
10418 assert!(marks.contains(InlineKind::Emph), "italic: {}", d.source);
10419 }
10420
10421 // ── the mark-edge rule (see `Doc::splice`) ───────────────────────────────
10422
10423 #[test]
10424 fn a_space_typed_in_a_bold_run_never_leaves_the_delimiters_showing() {
10425 // The reported bug, keystroke for keystroke: ⌘b, "bold", space, "hey".
10426 // The space inside the run made `**bold **`, which is *not* bold — four
10427 // literal asterisks — so the rich view drew them, correctly and
10428 // uselessly, until the next character happened to close the run again.
10429 let mut d = wysiwyg_doc("edge_typing", "a \n");
10430 d.caret = 2;
10431 d.toggle(InlineKind::Strong);
10432 for c in "bold".chars() {
10433 d.insert(&c.to_string());
10434 }
10435 assert_eq!(d.source, "a **bold**\n");
10436 d.insert(" ");
10437 assert_eq!(
10438 d.source, "a **bold** \n",
10439 "the space belongs outside the run"
10440 );
10441 assert!(
10442 d.active_inline_marks().contains(InlineKind::Strong),
10443 "bold is still what's being typed, so the button stays lit"
10444 );
10445 // What the writer is looking at while all this happens: their words.
10446 d.build_visual(80);
10447 let drawn: String = d.vmap.rows[0].glyphs.iter().map(|g| g.ch).collect();
10448 assert_eq!(drawn, "a bold ", "no delimiter ever surfaces: {}", d.source);
10449 for c in "hey".chars() {
10450 d.insert(&c.to_string());
10451 }
10452 assert_eq!(
10453 d.source, "a **bold hey**\n",
10454 "one bold phrase, not two runs"
10455 );
10456 }
10457
10458 #[test]
10459 fn typing_past_a_space_can_still_leave_the_bold_behind() {
10460 // The other half: the marks stay armed across the space, so ⌘b turns
10461 // them off again there and the next word is plain — the run isn't
10462 // rejoined by a caret that was told not to.
10463 let mut d = wysiwyg_doc("edge_shed", "\n");
10464 d.caret = 0;
10465 d.toggle(InlineKind::Strong);
10466 for c in "bold ".chars() {
10467 d.insert(&c.to_string());
10468 }
10469 assert_eq!(d.source, "**bold** \n");
10470 d.toggle(InlineKind::Strong);
10471 assert!(!d.active_inline_marks().contains(InlineKind::Strong));
10472 d.insert("x");
10473 assert_eq!(d.source, "**bold** x\n");
10474 }
10475
10476 #[test]
10477 fn a_space_typed_first_of_all_still_leaves_the_mark_armed() {
10478 // ⌘b and then a space before any word: the space is not marked (nothing
10479 // is), and the word after it is.
10480 let mut d = wysiwyg_doc("edge_space_first", "a\n");
10481 d.caret = 1;
10482 d.toggle(InlineKind::Strong);
10483 d.insert(" ");
10484 assert_eq!(d.source, "a \n");
10485 assert!(d.active_inline_marks().contains(InlineKind::Strong));
10486 d.insert("b");
10487 assert_eq!(d.source, "a **b**\n");
10488 }
10489
10490 #[test]
10491 fn a_space_typed_at_either_edge_of_an_existing_mark_steps_outside_it() {
10492 let mut d = wysiwyg_doc("edge_tail", "x **bold**\n");
10493 d.caret = 8; // the caret's home at the end of the run's text
10494 d.insert(" ");
10495 assert_eq!(
10496 d.source, "x **bold** \n",
10497 "the space lands past the delimiters"
10498 );
10499 assert_eq!(d.caret, 11, "and the caret stands past it, outside the run");
10500
10501 let mut d = wysiwyg_doc("edge_head", "x **bold** y\n");
10502 d.caret = 4; // in front of the "b"
10503 d.insert(" ");
10504 assert_eq!(d.source, "x **bold** y\n");
10505 assert_eq!(d.caret, 3, "in front of the run, where the space was typed");
10506 }
10507
10508 #[test]
10509 fn a_delete_that_backs_a_space_onto_a_delimiter_moves_the_delimiter() {
10510 // Backspace over the last letter of a bold phrase.
10511 let mut d = wysiwyg_doc("edge_bksp", "a **bold h**\n");
10512 d.caret = 10; // past the "h"
10513 d.backspace();
10514 assert_eq!(d.source, "a **bold** \n");
10515 assert_eq!(d.caret, 11, "the caret keeps the place on screen it had");
10516 assert!(d.active_inline_marks().contains(InlineKind::Strong));
10517 d.insert("x");
10518 assert_eq!(d.source, "a **bold x**\n", "and typing rejoins the run");
10519 }
10520
10521 #[test]
10522 fn deleting_the_last_of_a_run_takes_its_delimiters_with_it() {
10523 // `**b**` with the `b` gone is `****`: two delimiters with nothing to
10524 // mark, which is only text. The marks live on in the caret instead.
10525 let mut d = wysiwyg_doc("edge_empty", "a **b** c\n");
10526 d.caret = 5;
10527 d.backspace();
10528 assert_eq!(d.source, "a c\n");
10529 assert!(d.active_inline_marks().contains(InlineKind::Strong));
10530 d.insert("x");
10531 assert_eq!(d.source, "a **x** c\n");
10532 }
10533
10534 #[test]
10535 fn typing_over_a_whole_bold_word_keeps_it_bold() {
10536 let mut d = wysiwyg_doc("edge_replace", "a **bold** c\n");
10537 d.anchor = Some(4);
10538 d.caret = 8; // the word, not its delimiters
10539 d.insert("x");
10540 assert_eq!(d.source, "a **x** c\n");
10541 }
10542
10543 #[test]
10544 fn a_code_span_keeps_the_space_it_is_given() {
10545 // Backticks are not whitespace-sensitive the way `**` is: `` `code ` ``
10546 // is still verbatim, so nothing is re-spelt. The repair asks the parser
10547 // rather than a table of kinds, and this is the answer it gets.
10548 let mut d = wysiwyg_doc("edge_code", "a `code` c\n");
10549 d.caret = 7;
10550 d.insert(" ");
10551 assert_eq!(d.source, "a `code ` c\n");
10552 }
10553
10554 #[test]
10555 fn a_delete_from_a_runs_outer_edge_reaches_into_the_run() {
10556 // A run's closing delimiter has a caret home on each side of it, one
10557 // column apart on screen — and a plain ← off the space after a bold word
10558 // lands on the outer one. The character drawn behind the caret there is
10559 // still the last letter of the phrase, so that is what Backspace takes;
10560 // the byte behind it is a `*` nobody can see.
10561 let mut d = wysiwyg_doc("edge_outer_close", "**bold** x\n");
10562 d.caret = 9;
10563 d.move_left(false);
10564 assert_eq!(d.caret, 8, "← rests past the delimiters, not inside them");
10565 d.backspace();
10566 assert_eq!(
10567 d.source, "**bol** x\n",
10568 "a letter of the phrase, not its `*`"
10569 );
10570 assert_eq!(d.caret, 5);
10571
10572 // And the mirror in front of the opening delimiter, where Delete's
10573 // character is the first letter of the run.
10574 let mut d = wysiwyg_doc("edge_outer_open", "x**bold**\n");
10575 d.caret = 1;
10576 d.delete_forward();
10577 assert_eq!(d.source, "x**old**\n");
10578 assert_eq!(d.caret, 3, "inside the run, in front of what is left of it");
10579 }
10580
10581 #[test]
10582 fn a_delete_at_a_run_edge_never_eats_a_delimiter() {
10583 // The byte beside the caret at either edge of a bold word is a `*` the
10584 // rich view draws nothing for. Taking it is not the character delete the
10585 // key was pressed for — it unspells the run and puts a literal asterisk
10586 // on screen (`a *bold** c`). The visible character is the one that goes.
10587 let mut d = wysiwyg_doc("edge_open_bksp", "a **bold** c\n");
10588 d.caret = 4; // in front of the "b"
10589 d.backspace();
10590 assert_eq!(d.source, "a**bold** c\n", "the space goes, the run stands");
10591
10592 let mut d = wysiwyg_doc("edge_close_del", "a **bold** c\n");
10593 d.caret = 8; // past the "d"
10594 d.delete_forward();
10595 assert_eq!(d.source, "a **bold**c\n");
10596 assert_eq!(d.caret, 8, "and the caret stays inside the run");
10597 d.insert("x");
10598 assert_eq!(d.source, "a **boldx**c\n");
10599
10600 // A code span's backticks are hidden the same way, so they are covered
10601 // by the same rule and not by a list of kinds.
10602 let mut d = wysiwyg_doc("edge_open_code", "a `code` c\n");
10603 d.caret = 3;
10604 d.backspace();
10605 assert_eq!(d.source, "a`code` c\n");
10606 }
10607
10608 #[test]
10609 fn the_source_view_deletes_the_delimiter_byte_it_is_shown() {
10610 // The asterisks are on the screen there and the caret can stand between
10611 // them, so a delete takes exactly the byte it is aimed at.
10612 let mut d = doc_with("edge_open_src", "a **bold** c\n");
10613 d.caret = 4;
10614 d.backspace();
10615 assert_eq!(d.source, "a *bold** c\n");
10616
10617 let mut d = doc_with("edge_close_src", "a **bold** c\n");
10618 d.caret = 8;
10619 d.delete_forward();
10620 assert_eq!(d.source, "a **bold* c\n");
10621 }
10622
10623 #[test]
10624 fn backspacing_the_space_out_of_a_bold_phrase_leaves_the_caret_in_it() {
10625 // The reported bug, keystroke for keystroke: ⌘b, "bold", space, Backspace.
10626 // The space had stepped outside the run (the mark-edge rule), taking the
10627 // caret with it, so the delete put it back down on the far side of the
10628 // closing `**` — one place on screen, and the wrong side of it. Typing
10629 // came out plain and the toolbar went dark, with nothing to see.
10630 let mut d = wysiwyg_doc("edge_bksp_space", "\n");
10631 d.caret = 0;
10632 d.toggle(InlineKind::Strong);
10633 for c in "bold".chars() {
10634 d.insert(&c.to_string());
10635 }
10636 d.insert(" ");
10637 assert_eq!(d.source, "**bold** \n");
10638 d.backspace();
10639 assert_eq!(
10640 d.source, "**bold**\n",
10641 "the space goes, the delimiters stay"
10642 );
10643 assert_eq!(d.caret, 6, "and the caret comes back inside the run");
10644 assert!(
10645 d.active_inline_marks().contains(InlineKind::Strong),
10646 "so the button is still lit"
10647 );
10648 d.insert("x");
10649 assert_eq!(
10650 d.source, "**boldx**\n",
10651 "and the next character is still bold"
10652 );
10653 }
10654
10655 #[test]
10656 fn a_second_backspace_there_deletes_a_letter_of_the_phrase() {
10657 // What the stranded caret did next: the byte behind it was the closing
10658 // `*`, so a second press took that instead of a letter — `**bold*`, the
10659 // styling gone and an asterisk on the screen where the word had been.
10660 let mut d = wysiwyg_doc("edge_bksp_twice", "\n");
10661 d.caret = 0;
10662 d.toggle(InlineKind::Strong);
10663 for c in "bold ".chars() {
10664 d.insert(&c.to_string());
10665 }
10666 assert_eq!(d.source, "**bold** \n");
10667 d.backspace();
10668 d.backspace();
10669 assert_eq!(d.source, "**bol**\n", "the delete lands inside the run");
10670 assert_eq!(d.caret, 5);
10671 }
10672
10673 #[test]
10674 fn a_delete_that_ends_at_a_nested_run_settles_inside_every_delimiter() {
10675 // `***both***` closes two runs with one stack of asterisks: the caret has
10676 // to walk in through all of them, or it lands between the emph and the
10677 // strong and types half-marked.
10678 let mut d = wysiwyg_doc("edge_bksp_nested", "***both*** \n");
10679 d.caret = 11;
10680 d.backspace();
10681 assert_eq!(d.source, "***both***\n");
10682 assert_eq!(d.caret, 7, "past the last letter, inside both runs");
10683 d.insert("x");
10684 assert_eq!(d.source, "***bothx***\n");
10685 }
10686
10687 #[test]
10688 fn a_delete_that_ends_mid_run_leaves_the_caret_where_it_fell() {
10689 // The settle only moves a caret a run actually closed over. Ordinary
10690 // deletes — inside a run, or in plain prose — are untouched.
10691 let mut d = wysiwyg_doc("edge_bksp_mid", "a **bold** c\n");
10692 d.caret = 8;
10693 d.backspace();
10694 assert_eq!(d.source, "a **bol** c\n");
10695 assert_eq!(d.caret, 7);
10696
10697 let mut d = wysiwyg_doc("edge_bksp_plain", "plain\n");
10698 d.caret = 5;
10699 d.backspace();
10700 assert_eq!(d.source, "plai\n");
10701 assert_eq!(d.caret, 4);
10702 }
10703
10704 #[test]
10705 fn the_source_view_leaves_a_delete_where_it_landed() {
10706 // The delimiters are on the screen there, so the offset past them is a
10707 // place the caret can be seen to be — nothing to settle.
10708 let mut d = doc_with("edge_bksp_src", "**bold** \n");
10709 d.caret = 9;
10710 d.backspace();
10711 assert_eq!(d.source, "**bold**\n");
10712 assert_eq!(d.caret, 8);
10713 }
10714
10715 #[test]
10716 fn the_mark_edge_rule_clears_every_delimiter_of_a_nested_run() {
10717 // `***both***` closes two runs with one stack of asterisks; a space that
10718 // clears only the inner one lands against the outer's and breaks that
10719 // instead.
10720 let mut d = wysiwyg_doc("edge_nested", "a ***both***\n");
10721 d.caret = 9;
10722 d.insert(" ");
10723 assert_eq!(d.source, "a ***both*** \n");
10724 assert_eq!(d.caret, 13);
10725 d.insert("x");
10726 assert_eq!(d.source, "a ***both x***\n");
10727 }
10728
10729 #[test]
10730 fn the_mark_edge_repair_undoes_with_the_keystroke_that_caused_it() {
10731 // The delimiter shuffle is not an edit the writer made, so it is not a
10732 // step they have to undo past.
10733 let mut d = wysiwyg_doc("edge_undo", "a **bold**\n");
10734 d.caret = 8;
10735 d.insert(" ");
10736 assert_eq!(d.source, "a **bold** \n");
10737 d.undo();
10738 assert_eq!(d.source, "a **bold**\n");
10739 }
10740
10741 #[test]
10742 fn the_source_view_types_the_space_where_it_was_asked_to() {
10743 // The rule is a rich-view courtesy. In the source view the delimiters are
10744 // on the screen and the user is editing the bytes they can see.
10745 let mut d = doc_with("edge_src", "a **bold** c\n");
10746 d.caret = 8;
10747 d.insert(" ");
10748 assert_eq!(d.source, "a **bold ** c\n");
10749 }
10750
10751 #[test]
10752 fn toggling_a_mark_over_a_selection_leaves_its_edge_whitespace_out() {
10753 // Double-clicking a word takes the space after it; bolding that must not
10754 // spell `**word **`, which is not bold at all.
10755 let mut d = wysiwyg_doc("edge_sel", "a word b\n");
10756 d.anchor = Some(2);
10757 d.caret = 7; // "word "
10758 d.toggle(InlineKind::Strong);
10759 assert_eq!(d.source, "a **word** b\n");
10760 d.toggle(InlineKind::Strong);
10761 assert_eq!(d.source, "a word b\n");
10762 d.toggle(InlineKind::Strong);
10763 assert_eq!(
10764 d.source, "a **word** b\n",
10765 "reapplying the mark must not wrap stale delimiter offsets"
10766 );
10767 // And a selection of nothing but whitespace has no word to mark.
10768 let mut d = wysiwyg_doc("edge_sel_ws", "a word b\n");
10769 d.anchor = Some(6);
10770 d.caret = 7;
10771 d.toggle(InlineKind::Strong);
10772 assert_eq!(d.source, "a word b\n");
10773 assert!(d.status.is_some());
10774 }
10775
10776 #[test]
10777 fn set_block_turns_a_paragraph_into_a_heading_at_the_caret() {
10778 let mut d = doc_with("head_set", "hello\n");
10779 d.caret = 2; // caret inside the paragraph, no selection
10780 d.set_block(BlockKind::Heading(1));
10781 assert_eq!(d.source, "# hello\n");
10782 }
10783
10784 #[test]
10785 fn set_block_heading_works_in_wysiwyg_view() {
10786 // The app defaults to WYSIWYG; the caret is a source offset either way.
10787 let mut d = wysiwyg_doc("head_wys", "hello\n");
10788 d.caret = 2;
10789 d.set_block(BlockKind::Heading(1));
10790 assert_eq!(d.source, "# hello\n");
10791 }
10792
10793 #[test]
10794 fn toggle_heading_applies_switches_and_reverts() {
10795 let mut d = doc_with("head_toggle", "hello\n");
10796 d.caret = 2;
10797 d.toggle_heading(1);
10798 assert_eq!(d.source, "# hello\n"); // paragraph → H1
10799 d.toggle_heading(2);
10800 assert_eq!(d.source, "## hello\n"); // H1 → H2 (different level switches)
10801 d.toggle_heading(2);
10802 assert_eq!(d.source, "hello\n"); // same level reverts to paragraph
10803 }
10804
10805 #[test]
10806 fn preserve_enter_at_a_line_end_lands_the_caret_on_the_new_blank_line() {
10807 // Regression: Enter at the end of a soft-break line (mid-paragraph) opened
10808 // the blank line but the caret rendered on the *next* line, because the
10809 // separator was a non-navigable decoration row. In Preserve flow that
10810 // blank line is a real caret home — the caret must resolve onto it, and
10811 // typing there makes the soft break that continues the paragraph.
10812 let src = "line one:\nsecond line\n";
10813 let mut d = wysiwyg_doc("pre_enter_lineend", src);
10814 d.set_line_flow(LineFlow::Preserve);
10815 d.build_visual_unwrapped(); // the GUI path (pixel-wrapped)
10816 d.caret = 9; // the visual end of row 0, at the soft-break '\n'
10817 d.newline();
10818 d.build_visual_unwrapped();
10819 assert_eq!(d.source, "line one:\n\nsecond line\n");
10820 assert_eq!(
10821 d.caret, 10,
10822 "caret sits on the new blank line, not the next line"
10823 );
10824 // The blank line is row 1, and the caret resolves onto it — not row 2.
10825 assert_eq!(
10826 d.vmap.pos_of_offset(10),
10827 (1, 0),
10828 "caret renders on the blank row"
10829 );
10830 assert!(
10831 !d.vmap.rows[1].decoration,
10832 "the blank line is navigable in Preserve"
10833 );
10834 // Typing there makes a soft break: one paragraph, three lines.
10835 d.insert("new clause,");
10836 assert_eq!(d.source, "line one:\nnew clause,\nsecond line\n");
10837 }
10838
10839 #[test]
10840 fn preserve_enter_makes_a_soft_break_not_a_paragraph() {
10841 // Mid-paragraph: Enter splits the line with a single `\n`, a soft break
10842 // that keeps it one paragraph — where Fold would open a second paragraph.
10843 let mut d = wysiwyg_doc("pre_enter_mid", "abcdef\n");
10844 d.set_line_flow(LineFlow::Preserve);
10845 d.caret = 3;
10846 d.newline();
10847 assert_eq!(d.source, "abc\ndef\n", "mid-line Enter is a soft break");
10848
10849 // End-of-paragraph: Enter then typing continues the same paragraph on a
10850 // new line (a soft break), not a fresh paragraph.
10851 let mut d = wysiwyg_doc("pre_enter_end", "abc\n");
10852 d.set_line_flow(LineFlow::Preserve);
10853 d.caret = 3;
10854 d.newline();
10855 d.insert("def");
10856 assert_eq!(
10857 d.source, "abc\ndef\n",
10858 "end-of-line Enter + typing is a soft break"
10859 );
10860 }
10861
10862 #[test]
10863 fn preserve_double_enter_still_makes_a_paragraph() {
10864 // Two Enters in a row promote to a real paragraph break: the second lands
10865 // on the blank line the first opened and takes the empty-line branch.
10866 let mut d = wysiwyg_doc("pre_enter_dbl", "abc\n");
10867 d.set_line_flow(LineFlow::Preserve);
10868 d.caret = 3;
10869 d.newline();
10870 d.newline();
10871 d.insert("def");
10872 assert_eq!(
10873 d.source, "abc\n\ndef\n",
10874 "double Enter is a paragraph break"
10875 );
10876 }
10877
10878 #[test]
10879 fn preserve_backspace_joins_across_a_soft_break() {
10880 // Backspace is the symmetric undo of a Preserve Enter: over the `\n` of a
10881 // soft break it deletes the single newline and joins the two lines.
10882 let mut d = wysiwyg_doc("pre_bs", "abc\ndef\n");
10883 d.set_line_flow(LineFlow::Preserve);
10884 d.build_visual(80);
10885 d.caret = 4; // start of "def", just past the soft break
10886 d.backspace();
10887 assert_eq!(
10888 d.source, "abcdef\n",
10889 "Backspace joins across the soft break"
10890 );
10891 assert_eq!(d.caret, 3, "caret lands where the lines meet");
10892 }
10893
10894 #[test]
10895 fn fold_enter_still_starts_a_new_paragraph() {
10896 // The default flow is unchanged: a lone `\n` would render as an invisible
10897 // space, so Enter keeps opening the paragraph break that actually shows.
10898 let mut d = wysiwyg_doc("fold_enter", "abcdef\n");
10899 d.caret = 3;
10900 d.newline();
10901 assert_eq!(
10902 d.source, "abc\n\ndef\n",
10903 "Fold mid-line Enter is a paragraph break"
10904 );
10905 }
10906
10907 #[test]
10908 fn wysiwyg_one_enter_starts_a_new_paragraph() {
10909 // Regression: one Enter left the caret between the two newlines, so typing
10910 // made a soft break (one paragraph) and you needed a second Enter.
10911 let mut d = wysiwyg_doc("wys_enter", "abc\n");
10912 d.caret = 3;
10913 d.newline();
10914 d.insert("def");
10915 assert_eq!(d.source, "abc\n\ndef\n"); // two paragraphs, not "abc\ndef\n"
10916 }
10917
10918 #[test]
10919 fn enter_at_the_end_of_a_bold_run_keeps_its_closing_delimiter_attached() {
10920 // Regression: Enter at the caret's natural End-of-line resting place
10921 // after a bold run with nothing following it (on screen: right after
10922 // "bold", before the hidden closing "**") spliced the paragraph break
10923 // at that very byte offset — which sits *before* the closing "**" in
10924 // the source, since the delimiter is hidden and emits no glyph of its
10925 // own for `push_row`'s "end of row" fallback to count. That severed the
10926 // mark: "**bold**\n" became "**bold\n\n**\n", stranding the closing
10927 // "**" alone on the new line instead of leaving "**bold**" intact with
10928 // a fresh empty paragraph after it.
10929 let mut d = wysiwyg_doc("bold_eol_enter", "**bold**\n");
10930 d.move_end(false); // the WYSIWYG End key, from caret 0
10931 assert_eq!(
10932 d.caret, 6,
10933 "caret rests right after \"bold\", before the hidden \"**\""
10934 );
10935 d.newline();
10936 assert!(
10937 d.source.starts_with("**bold**"),
10938 "the closing ** must stay attached to \"bold\": got {:?}",
10939 d.source
10940 );
10941 assert_eq!(
10942 d.source, "**bold**\n\n\n",
10943 "a fresh empty paragraph follows the still-intact bold run"
10944 );
10945 }
10946
10947 #[test]
10948 fn source_view_enter_is_a_single_newline() {
10949 let mut d = doc_with("src_enter", "abc\n");
10950 d.caret = 3;
10951 d.newline();
10952 assert_eq!(d.source, "abc\n\n");
10953 }
10954
10955 #[test]
10956 fn heading_applies_at_the_end_of_a_paragraph() {
10957 // The caret at a line end sits at the doc level; set_block must still find
10958 // the block on that line.
10959 let mut d = doc_with("head_end", "abc\n");
10960 d.caret = 3; // end of "abc"
10961 d.toggle_heading(1);
10962 assert_eq!(d.source, "# abc\n");
10963 }
10964
10965 #[test]
10966 fn heading_on_an_empty_new_paragraph_creates_one() {
10967 let mut d = wysiwyg_doc("head_empty", "abc\n");
10968 d.caret = 3;
10969 d.newline(); // caret now on a fresh, empty paragraph
10970 d.toggle_heading(1);
10971 d.insert("Title");
10972 assert!(d.source.contains("# Title"), "got {:?}", d.source);
10973 }
10974
10975 #[test]
10976 fn a_heading_typed_on_a_blank_line_keeps_the_caret_on_its_own_row() {
10977 // The reported bug, end to end: click a blank line with another one under
10978 // it, press H1, type. The text landed in the heading and the caret's
10979 // offset was right (the source view drew it there), but the rich view
10980 // drew it two rows lower, on the trailing blank line — the empty `# `
10981 // heading had left every row below it short by the marker's two bytes,
10982 // and the blank line ended up claiming the heading's own end offset.
10983 let mut d = wysiwyg_doc("head_blank", "one\n\ntwo\n\n\n\n");
10984 d.build_visual_unwrapped();
10985 d.caret = d.vmap.offset_of_pos(4, 0); // the first of the two blank lines
10986 d.toggle_heading(1);
10987 for c in "title".chars() {
10988 d.insert(&c.to_string());
10989 d.build_visual_unwrapped(); // as a frontend does, one frame per key
10990 }
10991 assert_eq!(d.source, "one\n\ntwo\n\n# title\n\n");
10992 assert_eq!(
10993 d.caret_pos(),
10994 (4, 5),
10995 "the caret draws at the end of the heading"
10996 );
10997 }
10998
10999 #[test]
11000 fn clicking_an_empty_heading_types_after_its_marker() {
11001 // The same anchor from the other side: the empty heading's row is its own
11002 // caret home, so a click on it must land past the hidden `# `. Landing in
11003 // front of the hashes made the first keystroke un-heading the line.
11004 let mut d = wysiwyg_doc("head_click", "# \n");
11005 d.build_visual_unwrapped();
11006 d.caret = d.vmap.offset_of_pos(0, 0);
11007 d.insert("x");
11008 assert_eq!(d.source, "# x\n");
11009 }
11010
11011 #[test]
11012 fn wysiwyg_enter_after_a_heading_makes_a_paragraph() {
11013 let mut d = wysiwyg_doc("head_enter", "# Title\n");
11014 d.caret = 7; // end of the heading
11015 d.newline();
11016 d.insert("body");
11017 assert_eq!(d.source, "# Title\n\nbody\n");
11018 }
11019
11020 #[test]
11021 fn wysiwyg_enter_continues_a_bullet_list() {
11022 let mut d = wysiwyg_doc("wys_bullet", "- item\n");
11023 d.caret = 6; // end of "item"
11024 d.newline();
11025 d.insert("two");
11026 assert_eq!(d.source, "- item\n- two\n");
11027 }
11028
11029 #[test]
11030 fn wysiwyg_enter_increments_an_ordered_list() {
11031 let mut d = wysiwyg_doc("wys_ol", "1. one\n");
11032 d.caret = 6; // end of "one"
11033 d.newline();
11034 d.insert("two");
11035 assert_eq!(d.source, "1. one\n2. two\n");
11036 }
11037
11038 #[test]
11039 fn wysiwyg_backspace_after_leaving_a_list_collapses_the_gap_cleanly() {
11040 // Regression for the "extra newline" left between a list and the paragraph
11041 // below it. Enter, Enter leaves the list on a fresh empty paragraph
11042 // (`- item\n\n\n\nnext`, a navigable blank between the two blocks); one
11043 // Backspace should then take the caret cleanly back to the end of the list
11044 // item, `- item\n\nnext`, not delete a single newline and strand it on the
11045 // odd `- item\n\n\nnext` — a blank line the eye reads as one separator but
11046 // no caret can land on. The map is rebuilt between keystrokes exactly as a
11047 // frontend does, since Backspace reads the stop table to place the delete.
11048 let mut d = wysiwyg_doc("wys_exit_bksp", "- item\n\nnext\n");
11049 d.caret = 6; // end of "item"
11050 d.newline();
11051 d.build_visual(80);
11052 d.newline(); // leave the list onto a fresh empty paragraph
11053 d.build_visual(80);
11054 assert_eq!(
11055 d.source, "- item\n\n\n\nnext\n",
11056 "double-Enter opens the empty paragraph"
11057 );
11058 d.backspace();
11059 assert_eq!(
11060 d.source, "- item\n\nnext\n",
11061 "one Backspace collapses the whole gap"
11062 );
11063 assert_eq!(
11064 d.caret, 6,
11065 "and lands the caret back at the end of the list item"
11066 );
11067 }
11068
11069 #[test]
11070 fn wysiwyg_backspace_on_stacked_blank_lines_still_removes_just_one() {
11071 // The stop-wise delete must not over-reach when there is no block boundary
11072 // to cross: two blank lines in a row are one caret stop apart, so pressing
11073 // Enter on an empty line and then Backspace removes exactly the one newline
11074 // it added — the lone-Enter / lone-Backspace symmetry, preserved.
11075 let mut d = wysiwyg_doc("wys_stack", "abc\n\n\n");
11076 d.caret = 5; // the empty paragraph the first Enter already opened
11077 d.build_visual(80);
11078 d.newline();
11079 d.build_visual(80);
11080 assert_eq!(
11081 d.source, "abc\n\n\n\n",
11082 "Enter on the blank line adds one newline"
11083 );
11084 d.backspace();
11085 assert_eq!(
11086 d.source, "abc\n\n\n",
11087 "Backspace takes back exactly that one newline"
11088 );
11089 }
11090
11091 #[test]
11092 fn wysiwyg_enter_on_an_empty_list_item_exits_the_list() {
11093 let mut d = wysiwyg_doc("wys_exit", "- a\n- \n");
11094 d.caret = 6; // end of the empty "- " item
11095 d.newline();
11096 d.insert("p");
11097 assert_eq!(d.source, "- a\n\np\n");
11098 }
11099
11100 #[test]
11101 fn wysiwyg_enter_does_not_mistake_a_setext_underline_for_a_list() {
11102 // `text\n- \n` is a setext heading — the `- ` is its underline, not a
11103 // list item, though it reads as a `- ` marker byte-for-byte. Enter must
11104 // not take the list-exit path (which would splice the `- ` away as if
11105 // leaving an empty item); the AST guard sends it to a normal break and
11106 // leaves the underline intact.
11107 let mut d = wysiwyg_doc("wys_setext", "text\n- \n");
11108 assert!(
11109 d.nodes().iter().any(|n| n.kind == Kind::Heading),
11110 "precondition: twig parses this as a heading, not a list",
11111 );
11112 d.caret = 7; // on the `- ` underline line
11113 d.newline();
11114 assert!(
11115 d.source.contains("- "),
11116 "the setext underline survives, not spliced away as a list item: {:?}",
11117 d.source,
11118 );
11119 }
11120
11121 #[test]
11122 fn wysiwyg_enter_in_a_code_block_is_a_literal_newline() {
11123 let mut d = wysiwyg_doc("wys_code", "```\nabc\n```\n");
11124 d.caret = 7; // end of "abc" inside the fence
11125 d.newline();
11126 d.insert("def");
11127 assert_eq!(d.source, "```\nabc\ndef\n```\n");
11128 }
11129
11130 #[test]
11131 fn wysiwyg_enter_continues_a_block_quote() {
11132 // Enter opens a new *paragraph* inside the quote, not a second line of
11133 // the same one. `> quote\n> more` is a soft break, which under
11134 // `LineFlow::Fold` renders as a space — the keystroke would look like it
11135 // did nothing. The quoted blank line is what makes the break visible, and
11136 // it's the same thing Enter does in running prose.
11137 let mut d = wysiwyg_doc("wys_quote", "> quote\n");
11138 d.caret = 7; // end of "quote"
11139 d.newline();
11140 d.insert("more");
11141 assert_eq!(d.source, "> quote\n>\n> more\n");
11142 // Still one quote, now holding two paragraphs — not a quote and a stray
11143 // line that fell out of it.
11144 let quotes = d
11145 .nodes()
11146 .iter()
11147 .filter(|n| n.kind == Kind::BlockQuote)
11148 .count();
11149 assert_eq!(quotes, 1);
11150 }
11151
11152 #[test]
11153 fn set_block_makes_a_heading_at_the_caret() {
11154 let mut d = doc_with("head", "Title\n\nbody\n");
11155 d.caret = 0;
11156 d.set_block(BlockKind::Heading(2));
11157 assert_eq!(d.source, "## Title\n\nbody\n");
11158 d.set_block(BlockKind::Paragraph);
11159 assert_eq!(d.source, "Title\n\nbody\n");
11160 }
11161
11162 // ── block containers (quote / list) ──────────────────────────────────────
11163
11164 #[test]
11165 fn toggle_blockquote_wraps_the_block_at_the_caret_and_reverses() {
11166 let g = |m, f: fn(&mut Doc)| golden("quote", m, f);
11167 assert_eq!(g("hel|lo\n", |d| d.toggle_blockquote()), "> hel|lo\n");
11168 assert_eq!(g("> hel|lo\n", |d| d.toggle_blockquote()), "hel|lo\n");
11169 // A caret at a line end sits at the doc level; the block is still found.
11170 assert_eq!(g("hello|\n", |d| d.toggle_blockquote()), "> hello|\n");
11171 }
11172
11173 #[test]
11174 fn toggle_blockquote_keeps_the_caret_in_a_hard_wrapped_paragraph() {
11175 // Every source line of the paragraph gets its own `> `, so a caret left
11176 // on its old byte offset falls one prefix per line above it too far
11177 // back — inside the markup it just asked for rather than in its word.
11178 assert_eq!(
11179 golden("quote_wrap", "aaa\nb|bb\nccc\n", |d| d.toggle_blockquote()),
11180 "> aaa\n> b|bb\n> ccc\n"
11181 );
11182 }
11183
11184 #[test]
11185 fn toggle_blockquote_works_in_wysiwyg_view() {
11186 let g = |n, m, f: fn(&mut Doc)| golden_in(View::Wysiwyg, n, m, f);
11187 assert_eq!(
11188 g("q_wys", "hel|lo\n", |d| d.toggle_blockquote()),
11189 "> hel|lo\n"
11190 );
11191 assert_eq!(
11192 g("q_wys2", "> hel|lo\n", |d| d.toggle_blockquote()),
11193 "hel|lo\n"
11194 );
11195 }
11196
11197 #[test]
11198 fn toggle_list_makes_a_list_and_converts_between_the_kinds() {
11199 let g = |m, f: fn(&mut Doc)| golden("list", m, f);
11200 assert_eq!(g("hel|lo\n", |d| d.toggle_list(false)), "- hel|lo\n");
11201 assert_eq!(g("hel|lo\n", |d| d.toggle_list(true)), "1. hel|lo\n");
11202 // The *other* kind converts in place instead of nesting, which is what
11203 // makes the two buttons one three-state control.
11204 assert_eq!(g("- hel|lo\n", |d| d.toggle_list(true)), "1. hel|lo\n");
11205 assert_eq!(g("1. hel|lo\n", |d| d.toggle_list(false)), "- hel|lo\n");
11206 // Its own kind, over the only item the list holds, takes it off.
11207 assert_eq!(g("- hel|lo\n", |d| d.toggle_list(false)), "hel|lo\n");
11208 }
11209
11210 #[test]
11211 fn toggle_list_works_in_wysiwyg_view() {
11212 let g = |n, m, f: fn(&mut Doc)| golden_in(View::Wysiwyg, n, m, f);
11213 assert_eq!(
11214 g("l_wys", "hel|lo\n", |d| d.toggle_list(true)),
11215 "1. hel|lo\n"
11216 );
11217 assert_eq!(
11218 g("l_wys2", "1. hel|lo\n", |d| d.toggle_list(false)),
11219 "- hel|lo\n"
11220 );
11221 assert_eq!(
11222 g("l_wys3", "- hel|lo\n", |d| d.toggle_list(false)),
11223 "hel|lo\n"
11224 );
11225 }
11226
11227 #[test]
11228 fn a_list_over_a_selection_numbers_each_block_and_stays_selected() {
11229 // The selection has to grow with the markup: twig takes a container off
11230 // only a range covering every block it holds, so the second press can
11231 // reverse the first only if the result is what's selected.
11232 let mut d = doc_with("list_sel", "abc\n\ndef\n");
11233 d.select_all();
11234 d.toggle_list(true);
11235 assert_eq!(d.source, "1. abc\n\n2. def\n");
11236 assert_eq!(d.selection(), Some((0, d.source.len())));
11237 d.toggle_list(true);
11238 assert_eq!(d.source, "abc\n\ndef\n");
11239 }
11240
11241 #[test]
11242 fn toggle_blockquote_nests_a_partly_covered_quote() {
11243 // twig's rule: covering only some of a container's blocks nests, because
11244 // taking the quote off would drag its uncovered siblings out with it.
11245 let mut d = doc_with("quote_nest", "> a\n>\n> b\n");
11246 d.caret = 2; // in the first quoted paragraph only
11247 d.toggle_blockquote();
11248 assert_eq!(d.source, "> > a\n>\n> b\n");
11249 }
11250
11251 #[test]
11252 fn a_container_toggle_opens_an_empty_one_on_a_blank_line() {
11253 // A blank line used to be no block for twig to wrap —
11254 // `toggle_block_container` answered `NotFound` — so Quote and the list
11255 // buttons did nothing on the very line the H1 button works on, and leaf
11256 // lent twig a scratch paragraph to wrap and took it back out again.
11257 // twig 3.2.0 opens an empty container there itself, so what is left here
11258 // is where the caret lands: inside the marker that was just written.
11259 let mut d = doc_with("quote_blank", "\nabc\n");
11260 d.caret = 0;
11261 d.toggle_blockquote();
11262 assert_eq!(d.source, "> \nabc\n");
11263 assert_eq!(
11264 d.caret, 2,
11265 "the caret belongs inside the quote it just opened"
11266 );
11267 assert!(d.status.is_none(), "{:?}", d.status);
11268 assert!(d.dirty);
11269
11270 // And the paragraph below is still its own block: an empty container one
11271 // soft break from `abc` would take that paragraph into the quote with it.
11272 let mut d = wysiwyg_doc("quote_blank_rows", "\nabc\n");
11273 d.caret = 0;
11274 d.toggle_blockquote();
11275 d.build_visual(80);
11276 assert_eq!(drawn_rows(&d), ["│ ", "", "abc"]);
11277
11278 // The same from the other side: a blank line directly under a paragraph
11279 // earns the blank line an empty block needs, rather than being read as a
11280 // soft break inside that paragraph.
11281 let mut d = doc_with("list_blank_below", "abc\n");
11282 d.caret = 4;
11283 d.toggle_list(false);
11284 assert_eq!(d.source, "abc\n\n- ");
11285 assert_eq!(d.caret, 7);
11286 }
11287
11288 #[test]
11289 fn enter_at_the_end_of_a_quote_stays_in_the_quote() {
11290 // The gesture the rendering fix is for. `newline` inside a quote already
11291 // wrote the right source — `> a\n` becomes `> a\n>\n> \n`, twig's own
11292 // spelling — but the two marker lines it adds belonged to no node until
11293 // twig 3.2.0, so the gutter stopped at `a` and the line the writer had
11294 // just made drew as plain prose under the quote.
11295 let mut d = wysiwyg_doc("quote_enter", "> a\n");
11296 d.caret = 3; // past `a`, at the end of the quoted line
11297 d.newline();
11298 assert_eq!(d.source, "> a\n>\n> \n");
11299 d.build_visual(80);
11300 assert_eq!(drawn_rows(&d), ["│ a", "│ ", "│ "]);
11301 // And the caret is on the new line, not stranded on the old one.
11302 assert_eq!(d.caret, 8);
11303 }
11304
11305 #[test]
11306 fn opening_a_container_on_a_blank_line_is_one_undo_step() {
11307 // It was three edits — scratch, wrap, unscratch — coalesced into one, and
11308 // now it is twig's single edit. Either way one ⌘z has to put the blank
11309 // line back rather than undoing into a half-built document.
11310 for open in [
11311 &(|d: &mut Doc| d.toggle_blockquote()) as &dyn Fn(&mut Doc),
11312 &|d: &mut Doc| d.toggle_list(false),
11313 &|d: &mut Doc| d.toggle_list(true),
11314 ] {
11315 let mut d = doc_with("container_blank_undo", "a\n\n\n\nb\n");
11316 d.caret = 3;
11317 open(&mut d);
11318 assert_ne!(d.source, "a\n\n\n\nb\n");
11319 d.undo();
11320 assert_eq!(d.source, "a\n\n\n\nb\n");
11321 }
11322 }
11323
11324 #[test]
11325 fn a_container_toggle_is_one_undo_step() {
11326 let mut d = doc_with("quote_undo", "hello\n");
11327 d.caret = 3;
11328 d.insert("X"); // a typing run the structural edit must not fold into
11329 d.toggle_blockquote();
11330 assert_eq!(d.source, "> helXlo\n");
11331 d.undo();
11332 assert_eq!(d.source, "helXlo\n");
11333 }
11334
11335 // ── links ────────────────────────────────────────────────────────────────
11336
11337 #[test]
11338 fn insert_link_wraps_the_selection_and_leaves_its_text_selected() {
11339 let mut d = doc_with("link_sel", "word here\n");
11340 d.anchor = Some(0);
11341 d.caret = 4;
11342 d.insert_link("http://x.dev");
11343 assert_eq!(d.source, "[word](http://x.dev) here\n");
11344 // The text, not the destination — so a second press re-points the link
11345 // the first one made rather than nesting one inside it.
11346 assert_eq!(d.selected_text(), Some("word"));
11347 d.insert_link("http://y.dev");
11348 assert_eq!(d.source, "[word](http://y.dev) here\n");
11349 assert_eq!(d.selected_text(), Some("word"));
11350 }
11351
11352 #[test]
11353 fn insert_image_at_the_caret_spells_the_markup_and_lands_past_it() {
11354 let mut d = doc_with("img_caret", "before after\n");
11355 d.caret = 7; // between "before " and "after"
11356 d.insert_image("cat.png", "a cat");
11357 assert_eq!(d.source, "before after\n");
11358 // The caret sits just past the inserted image, nothing selected.
11359 assert_eq!(d.selection(), None);
11360 assert_eq!(d.caret, 7 + "".len());
11361 }
11362
11363 /// The bug a real vault hit: a filename with spaces in it. Markdown ends a
11364 /// destination at the first space, so the `format!` this used to be wrote
11365 /// something that was not an image at all — and the reader saw the markup as
11366 /// text. twig owns the spelling now, and moves it into the angle form.
11367 #[test]
11368 fn insert_image_spells_a_destination_with_spaces_so_it_stays_an_image() {
11369 let mut d = doc_with("img_space", "x\n");
11370 d.caret = 0;
11371 d.insert_image("Jesus Commands the Apostles to Rest.jpg", "");
11372 assert_eq!(
11373 d.source,
11374 "x\n"
11375 );
11376 // And it reads back as an image pointing at the unescaped path — the angle
11377 // brackets are spelling, not part of the destination.
11378 d.caret = 2;
11379 assert_eq!(
11380 d.image_destination_at_caret(),
11381 Some("Jesus Commands the Apostles to Rest.jpg".to_string())
11382 );
11383 }
11384
11385 /// A `)` in a caption or a filename must not close the image early.
11386 #[test]
11387 fn insert_image_escapes_a_paren_in_either_half() {
11388 let mut d = doc_with("img_paren", "x\n");
11389 d.caret = 0;
11390 d.insert_image("a)b.png", "");
11391 assert_eq!(d.source, "b.png)x\n");
11392 d.caret = 2;
11393 assert_eq!(d.image_destination_at_caret(), Some("a)b.png".to_string()));
11394 }
11395
11396 #[test]
11397 fn insert_image_uses_the_selection_as_alt_text() {
11398 let mut d = doc_with("img_sel", "caption here\n");
11399 d.anchor = Some(0);
11400 d.caret = 7; // "caption"
11401 d.insert_image("p.png", "ignored fallback");
11402 assert_eq!(d.source, " here\n");
11403 }
11404
11405 #[test]
11406 fn insert_image_with_no_alt_leaves_empty_brackets() {
11407 let mut d = doc_with("img_noalt", "\n");
11408 d.caret = 0;
11409 d.insert_image("logo.svg", "");
11410 assert_eq!(d.source, "\n");
11411 }
11412
11413 // ── move_block ─────────────────────────────────────────────────────────────
11414
11415 /// A move, then its undo: one step takes the whole thing back.
11416 fn moved(name: &str, body: &str, from: usize, to: usize) -> (String, Doc) {
11417 let mut d = doc_with(name, body);
11418 d.caret = from;
11419 let steps = d.undo_steps;
11420 d.move_block(from, to);
11421 let after = d.source.clone();
11422 assert_eq!(d.undo_steps, steps + 1, "one undo step");
11423 assert!(d.dirty);
11424 d.undo();
11425 assert_eq!(d.source, body, "one undo restores the original");
11426 (after, d)
11427 }
11428
11429 #[test]
11430 fn move_block_carries_a_paragraph_between_two_paragraphs_and_to_the_end() {
11431 let body = "a\n\nb\n\nc\n";
11432 assert_eq!(moved("mv_p1", body, 6, 3).0, "a\n\nc\n\nb\n");
11433 assert_eq!(moved("mv_p2", body, 0, body.len()).0, "b\n\nc\n\na\n");
11434 assert_eq!(moved("mv_p3", body, 3, 0).0, "b\n\na\n\nc\n");
11435 }
11436
11437 #[test]
11438 fn move_block_carries_an_image_block() {
11439 let body = "a\n\n\n\nc\n";
11440 assert_eq!(moved("mv_img1", body, 4, 0).0, "\n\na\n\nc\n");
11441 assert_eq!(
11442 moved("mv_img2", body, 4, body.len()).0,
11443 "a\n\nc\n\n\n"
11444 );
11445 }
11446
11447 #[test]
11448 fn move_block_carries_a_table() {
11449 let body = "a\n\n| h |\n|---|\n| c |\n\nc\n";
11450 assert_eq!(
11451 moved("mv_tbl1", body, 5, 0).0,
11452 "| h |\n|---|\n| c |\n\na\n\nc\n"
11453 );
11454 assert_eq!(
11455 moved("mv_tbl2", body, 5, body.len()).0,
11456 "a\n\nc\n\n| h |\n|---|\n| c |\n"
11457 );
11458 }
11459
11460 #[test]
11461 fn move_block_carries_a_code_block() {
11462 let body = "a\n\n```rs\nx\n```\n\nc\n";
11463 assert_eq!(moved("mv_code1", body, 8, 0).0, "```rs\nx\n```\n\na\n\nc\n");
11464 assert_eq!(
11465 moved("mv_code2", body, 8, body.len()).0,
11466 "a\n\nc\n\n```rs\nx\n```\n"
11467 );
11468 }
11469
11470 #[test]
11471 fn move_block_onto_its_own_boundary_is_a_quiet_no_op() {
11472 let mut d = doc_with("mv_noop", "a\n\nb\n");
11473 let steps = d.undo_steps;
11474 d.move_block(0, 0);
11475 d.move_block(0, 3);
11476 assert_eq!(d.source, "a\n\nb\n");
11477 assert_eq!(d.undo_steps, steps);
11478 assert_eq!(d.status, None);
11479 assert!(!d.dirty);
11480 }
11481
11482 #[test]
11483 fn move_block_into_a_fence_is_refused_with_a_status() {
11484 let mut d = doc_with("mv_fence", "a\n\n```\nx\ny\n```\n");
11485 d.move_block(0, 7);
11486 assert_eq!(d.source, "a\n\n```\nx\ny\n```\n");
11487 assert!(
11488 d.status
11489 .as_deref()
11490 .is_some_and(|s| s.starts_with("move block"))
11491 );
11492 }
11493
11494 #[test]
11495 fn move_block_rides_the_caret_with_the_block() {
11496 // Down: "second" is line 0 of its block, caret 3 bytes from its end.
11497 let mut d = doc_with("mv_caret1", "first\n\nsecond\n\nthird\n");
11498 d.caret = 10; // "sec|ond"
11499 d.move_block(10, d.source.len());
11500 assert_eq!(d.source, "first\n\nthird\n\nsecond\n");
11501 assert_eq!(&d.source[d.caret..], "ond\n");
11502 // Up, across a wrapped paragraph's second line.
11503 let mut d = doc_with("mv_caret2", "first\n\nsecond\nline two\n");
11504 d.caret = 16; // "li|ne two"
11505 d.move_block(16, 0);
11506 assert_eq!(d.source, "second\nline two\n\nfirst\n");
11507 assert_eq!(&d.source[d.caret..], "ne two\n\nfirst\n");
11508 assert_eq!(d.selection(), None);
11509 }
11510
11511 #[test]
11512 fn move_block_keeps_the_caret_on_its_line_through_a_quotes_prefix() {
11513 let mut d = doc_with("mv_quote_caret", "para\n\n> a\n");
11514 d.caret = 2; // "pa|ra"
11515 d.move_block(2, 9); // after a, inside the quote
11516 assert_eq!(d.source, "> a\n>\n> para\n");
11517 assert_eq!(&d.source[d.caret..], "ra\n");
11518 }
11519
11520 #[test]
11521 fn move_block_up_and_down_step_over_siblings() {
11522 let mut d = doc_with("mv_updown", "a\n\nb\n\nc\n");
11523 d.caret = 3;
11524 d.move_block_down();
11525 assert_eq!(d.source, "a\n\nc\n\nb\n");
11526 assert_eq!(&d.source[d.caret..], "b\n");
11527 d.move_block_down();
11528 assert_eq!(d.source, "a\n\nc\n\nb\n", "nothing below the last block");
11529 assert_eq!(d.status.as_deref(), Some("move block: nothing below"));
11530 d.move_block_up();
11531 d.move_block_up();
11532 assert_eq!(d.source, "b\n\na\n\nc\n");
11533 assert_eq!(&d.source[d.caret..], "b\n\na\n\nc\n");
11534 d.move_block_up();
11535 assert_eq!(d.status.as_deref(), Some("move block: nothing above"));
11536 }
11537
11538 #[test]
11539 fn move_block_up_and_down_reorder_list_items_with_their_children() {
11540 let mut d = doc_with("mv_items", "- a\n - x\n- b\n- c\n");
11541 d.caret = 12; // in "b"
11542 d.move_block_up();
11543 assert_eq!(d.source, "- b\n- a\n - x\n- c\n");
11544 assert_eq!(&d.source[d.caret..], "b\n- a\n - x\n- c\n");
11545 d.caret = 6; // in "a"
11546 d.move_block_down();
11547 assert_eq!(d.source, "- b\n- c\n- a\n - x\n");
11548 assert_eq!(&d.source[d.caret..], "a\n - x\n");
11549 // A nested item leaves its list upward, as an item of the outer one.
11550 d.caret = 16; // in "x"
11551 d.move_block_up();
11552 assert_eq!(d.source, "- b\n- c\n- x\n- a\n");
11553 }
11554
11555 #[test]
11556 fn move_block_on_a_lone_item_that_stays_a_bullet_is_no_step() {
11557 let mut d = doc_with("mv_lone_item", "x\n\n- b\n\ny\n");
11558 d.caret = 5;
11559 let steps = d.undo_steps;
11560 d.move_block_up();
11561 assert_eq!(d.source, "x\n\n- b\n\ny\n");
11562 assert_eq!(
11563 d.undo_steps, steps,
11564 "a move that rewrote nothing is no undo step"
11565 );
11566 assert_eq!(d.status.as_deref(), Some("move block: nothing above"));
11567 }
11568
11569 #[test]
11570 fn move_block_up_leaves_a_container_at_its_first_block_and_down_at_its_last() {
11571 let mut d = doc_with("mv_leave", "x\n\n> a\n>\n> b\n\ny\n");
11572 d.caret = 5; // "a"
11573 d.move_block_up();
11574 assert_eq!(d.source, "x\n\na\n\n> b\n\ny\n");
11575 d.caret = 8; // "b"
11576 d.move_block_down();
11577 assert_eq!(d.source, "x\n\na\n\nb\n\ny\n");
11578 // A tail block leaves its item into the next item's tail, then the list.
11579 let mut d = doc_with("mv_tail", "- a\n\n t\n- b\n");
11580 d.caret = 7;
11581 d.move_block_down();
11582 assert_eq!(d.source, "- a\n- b\n\n t\n");
11583 d.move_block_down();
11584 assert_eq!(d.source, "- a\n- b\n\nt\n");
11585 // And a paragraph after a quote steps over the whole quote, not into it.
11586 let mut d = doc_with("mv_over", "x\n\n> a\n>\n> b\n\ny\n");
11587 d.caret = 14;
11588 d.move_block_up();
11589 assert_eq!(d.source, "x\n\ny\n\n> a\n>\n> b\n");
11590 assert_eq!(d.caret, 3);
11591 d.move_block_down();
11592 assert_eq!(d.status, None);
11593 assert_eq!(d.source, "x\n\n> a\n>\n> b\n\ny\n");
11594 // Above a quote that opens the document is offset 0 — before the
11595 // quote, not inside it.
11596 let mut d = doc_with("mv_over_top", "> a\n\ny\n");
11597 d.caret = 5;
11598 d.move_block_up();
11599 assert_eq!(d.source, "y\n\n> a\n");
11600 assert_eq!(d.caret, 0);
11601 }
11602
11603 #[test]
11604 fn move_block_up_from_the_first_block_of_a_quote_that_opens_the_document_leaves_it() {
11605 let mut d = doc_with("mv_top_quote", "> a\n>\n> b\n");
11606 d.caret = 2;
11607 d.move_block_up();
11608 assert_eq!(d.source, "a\n\n> b\n");
11609 assert_eq!(d.caret, 0);
11610 assert_eq!(d.status, None);
11611 }
11612
11613 #[test]
11614 fn move_block_on_a_blank_line_or_read_only_does_nothing() {
11615 let mut d = doc_with("mv_blank", "a\n\nb\n");
11616 d.caret = 2;
11617 d.move_block_down();
11618 assert_eq!(d.source, "a\n\nb\n");
11619 assert_eq!(d.status.as_deref(), Some("move block: no block here"));
11620 d.read_only = true;
11621 d.caret = 0;
11622 d.move_block_down();
11623 d.move_block(0, 5);
11624 assert_eq!(d.source, "a\n\nb\n");
11625 }
11626
11627 #[test]
11628 fn move_block_never_lands_above_hidden_frontmatter() {
11629 let mut d = wysiwyg_doc("mv_fm", "---\nt: x\n---\n\na\n\nb\n");
11630 let b = d.source.find('b').unwrap();
11631 d.move_block(b, 0);
11632 assert_eq!(d.source, "---\nt: x\n---\n\nb\n\na\n");
11633 }
11634
11635 #[test]
11636 fn block_range_at_is_the_block_a_move_picks_up() {
11637 let mut d = doc_with("mv_range", "a\n\n- b\n - c\n\nd\n");
11638 assert_eq!(d.block_range_at(0), Some(0..1));
11639 assert_eq!(d.block_range_at(5), Some(3..12), "the item with its child");
11640 assert_eq!(d.block_range_at(10), Some(7..12), "the nested item alone");
11641 assert_eq!(d.block_range_at(2), None, "a blank line");
11642 }
11643
11644 #[test]
11645 fn drop_target_at_splits_a_block_at_its_middle_row_and_ends_below_everything() {
11646 let long = "two ".repeat(40).trim_end().to_string(); // wraps to three rows at 80
11647 let mut d = wysiwyg_doc("drop", &format!("one\n\n{long} four\n\nfive\n"));
11648 let rows = d.vmap.rows.len();
11649 assert_eq!(rows, 7, "one, gap, three wrapped rows, gap, five");
11650 assert_eq!(d.drop_target_at(0), Some(DropTarget { offset: 0, row: 0 }));
11651 let two = d.source.find("two").unwrap();
11652 assert_eq!(
11653 d.drop_target_at(1),
11654 Some(DropTarget {
11655 offset: two,
11656 row: 2
11657 }),
11658 "the gap resolves to the block under it"
11659 );
11660 assert_eq!(
11661 d.drop_target_at(2),
11662 Some(DropTarget {
11663 offset: two,
11664 row: 2
11665 })
11666 );
11667 assert_eq!(
11668 d.drop_target_at(3),
11669 Some(DropTarget {
11670 offset: two,
11671 row: 2
11672 })
11673 );
11674 let four_end = d.source.find("four").unwrap() + 4;
11675 assert_eq!(
11676 d.drop_target_at(4),
11677 Some(DropTarget {
11678 offset: four_end,
11679 row: 5
11680 })
11681 );
11682 assert_eq!(
11683 d.drop_target_at(rows + 3),
11684 Some(DropTarget {
11685 offset: d.source.len(),
11686 row: rows
11687 })
11688 );
11689 // And the offsets are ones move_block takes.
11690 let five = d.source.find("five").unwrap();
11691 let t = d.drop_target_at(2).unwrap();
11692 d.move_block(five, t.offset);
11693 assert_eq!(d.source, format!("one\n\nfive\n\n{long} four\n"));
11694 }
11695
11696 #[test]
11697 fn append_media_lands_at_the_end_as_its_own_block() {
11698 let mut d = doc_with("append_mid", "first word and more\n");
11699 d.caret = 0; // an editor nobody has tapped: the caret is at the start
11700 d.append_media(MediaKind::Image, "cat.png", "");
11701 assert_eq!(d.source, "first word and more\n\n");
11702 assert_eq!(d.selection(), None);
11703 assert_eq!(d.caret, "first word and more\n\n".len());
11704 }
11705
11706 #[test]
11707 fn append_media_needs_no_separator_after_a_blank_line_or_in_an_empty_document() {
11708 let mut d = doc_with("append_blank", "para\n\n");
11709 d.append_media(MediaKind::Image, "a.png", "");
11710 assert_eq!(d.source, "para\n\n");
11711
11712 let mut e = doc_with("append_empty", "");
11713 e.append_media(MediaKind::Image, "b.png", "");
11714 assert_eq!(e.source, "");
11715
11716 let mut f = doc_with("append_noeol", "no newline at end");
11717 f.anchor = Some(0);
11718 f.caret = 2; // a selection, which the verb ignores
11719 f.append_media(MediaKind::Video, "clip.mp4", "");
11720 assert_eq!(
11721 f.source,
11722 "no newline at end\n\n<video src=\"clip.mp4\" controls></video>"
11723 );
11724 }
11725
11726 #[test]
11727 fn insert_media_spells_a_video_as_html_and_reads_it_back_as_a_block() {
11728 // The round trip is the point: it's no use writing markup the reader
11729 // can't pick up again. This is the pair that only holds from twig 2.5.1
11730 // on — before it, the one-line form went in fine and came back as a
11731 // paragraph of raw tags, publishing no media at all.
11732 let mut d = doc_with("vid_rt", "\n");
11733 d.caret = 0;
11734 d.insert_media(MediaKind::Video, "clip.mp4", "a clip");
11735 assert_eq!(
11736 d.source,
11737 "<video src=\"clip.mp4\" controls>a clip</video>\n"
11738 );
11739
11740 d.build_visual(80);
11741 assert_eq!(d.vmap.media.len(), 1, "reads back as one block media");
11742 assert_eq!(d.vmap.media[0].kind, MediaKind::Video);
11743 assert_eq!(d.vmap.media[0].destination, "clip.mp4");
11744 assert_eq!(d.vmap.media[0].alt, "a clip");
11745 }
11746
11747 #[test]
11748 fn insert_media_spells_audio_with_its_own_tag() {
11749 let mut d = doc_with("aud_rt", "\n");
11750 d.caret = 0;
11751 d.insert_media(MediaKind::Audio, "take.mp3", "");
11752 assert_eq!(d.source, "<audio src=\"take.mp3\" controls></audio>\n");
11753 d.build_visual(80);
11754 assert_eq!(d.vmap.media[0].kind, MediaKind::Audio);
11755 }
11756
11757 #[test]
11758 fn insert_media_uses_the_selection_as_fallback_text() {
11759 // The same courtesy `insert_image` does with alt: select a caption,
11760 // insert, and the caption labels the thing rather than being replaced.
11761 let mut d = doc_with("vid_sel", "the talk here\n");
11762 d.anchor = Some(0);
11763 d.caret = 8; // "the talk"
11764 d.insert_media(MediaKind::Video, "talk.mp4", "ignored fallback");
11765 assert_eq!(
11766 d.source,
11767 "<video src=\"talk.mp4\" controls>the talk</video> here\n"
11768 );
11769 }
11770
11771 #[test]
11772 fn insert_media_with_an_image_kind_is_just_insert_image() {
11773 let mut d = doc_with("img_via_media", "\n");
11774 d.caret = 0;
11775 d.insert_media(MediaKind::Image, "logo.svg", "x");
11776 assert_eq!(d.source, "\n");
11777 }
11778
11779 // ── thematic breaks ─────────────────────────────────────────────────────
11780
11781 /// The node the source parses as at `caret` — what confirms an inserted
11782 /// `---` actually reads back as a rule, not stray text or a setext heading.
11783 ///
11784 /// The *narrowest* node covering the offset. Every ancestor covers it too,
11785 /// and since twig 2.8 that includes the `doc` root, which now carries a real
11786 /// span (it reported none before, so taking the first match used to land on
11787 /// the block by luck and now always answers `"doc"`).
11788 fn kind_at(d: &mut Doc, caret: usize) -> Option<Kind> {
11789 d.nodes()
11790 .into_iter()
11791 .filter(|n| n.span.start <= caret && caret < n.span.end)
11792 .min_by_key(|n| n.span.end - n.span.start)
11793 .map(|n| n.kind)
11794 }
11795
11796 #[test]
11797 fn a_task_box_toggles_at_the_caret_and_reads_back() {
11798 let mut d = doc_with("task_toggle", "- [ ] todo\n- [x] done\n");
11799 d.caret = 8; // inside "todo"
11800 assert_eq!(d.task_checked_at_caret(), Some(false));
11801 d.toggle_task_checked();
11802 assert_eq!(d.source, "- [x] todo\n- [x] done\n");
11803 assert_eq!(d.task_checked_at_caret(), Some(true));
11804 d.toggle_task_checked();
11805 assert_eq!(d.source, "- [ ] todo\n- [x] done\n");
11806 }
11807
11808 #[test]
11809 fn a_click_toggles_a_box_without_taking_the_caret_with_it() {
11810 // The whole reason `toggle_task_at` exists apart from the caret form:
11811 // ticking a box elsewhere must not move the cursor out of what's being
11812 // typed.
11813 let mut d = doc_with("task_click", "- [ ] first\n- [ ] second\n");
11814 d.caret = 8; // inside "first"
11815 let second = d.source.find("second").unwrap();
11816 d.toggle_task_at(second);
11817 assert_eq!(d.source, "- [ ] first\n- [x] second\n");
11818 assert_eq!(d.caret, 8, "the caret stayed in the first item");
11819 }
11820
11821 #[test]
11822 fn a_paragraph_becomes_a_task_item_in_one_undo_step() {
11823 let mut d = doc_with("task_para", "first\n\nplain para\n");
11824 d.caret = d.source.find("para").unwrap();
11825 d.toggle_task_item();
11826 assert_eq!(d.source, "first\n\n- [ ] plain para\n");
11827 assert_eq!(d.task_checked_at_caret(), Some(false));
11828 assert_eq!(d.status, None);
11829 d.undo();
11830 assert_eq!(
11831 d.source, "first\n\nplain para\n",
11832 "one step takes both back"
11833 );
11834 }
11835
11836 #[test]
11837 fn a_blank_line_becomes_an_empty_task_item() {
11838 let mut d = doc_with("task_blank", "first\n\n");
11839 d.caret = d.source.len();
11840 d.toggle_task_item();
11841 assert_eq!(d.task_checked_at_caret(), Some(false), "{:?}", d.source);
11842 assert_eq!(d.status, None);
11843 }
11844
11845 #[test]
11846 fn a_plain_item_gains_and_loses_a_box() {
11847 let mut d = doc_with("task_mint", "- plain\n");
11848 d.caret = 4;
11849 assert_eq!(d.task_checked_at_caret(), None);
11850 d.toggle_task_item();
11851 assert_eq!(d.source, "- [ ] plain\n");
11852 assert_eq!(
11853 d.task_checked_at_caret(),
11854 Some(false),
11855 "a new box arrives unticked"
11856 );
11857 d.toggle_task_item();
11858 assert_eq!(d.source, "- plain\n");
11859 }
11860
11861 #[test]
11862 fn ticking_a_box_that_isnt_there_reports_rather_than_minting_one() {
11863 // `set checked` must not silently convert a bullet into a task — that is
11864 // `toggle_task_item`'s job, and twig refuses it here.
11865 let mut d = doc_with("task_none", "- plain\n");
11866 d.caret = 4;
11867 d.toggle_task_checked();
11868 assert_eq!(d.source, "- plain\n", "nothing written");
11869 assert!(
11870 d.status.is_some(),
11871 "the refusal should reach the status line"
11872 );
11873 }
11874
11875 #[test]
11876 fn a_task_item_in_a_quote_is_found_past_the_quote_marker() {
11877 let mut d = doc_with("task_quote", "> - [ ] nested\n");
11878 d.caret = d.source.find("nested").unwrap();
11879 assert_eq!(d.task_checked_at_caret(), Some(false));
11880 d.toggle_task_checked();
11881 assert_eq!(d.source, "> - [x] nested\n");
11882 }
11883
11884 #[test]
11885 fn insert_thematic_break_parts_the_paragraph_around_the_caret() {
11886 // A rule is a block, so twig's `insert_thematic_break` alone lands it
11887 // after the whole paragraph. `split_block` parts the paragraph first and
11888 // the rule is aimed at the *first* half, which is what a rule button is
11889 // understood to do — and what leaf spelled by hand until twig grew both
11890 // halves of the gesture.
11891 let mut d = doc_with("hr_mid", "before after\n");
11892 d.caret = 7; // between "before " and "after"
11893 d.insert_thematic_break();
11894 assert_eq!(d.source, "before \n\n---\n\nafter\n");
11895 assert_eq!(d.selection(), None);
11896 assert_eq!(
11897 kind_at(&mut d, "before \n\n".len()),
11898 Some(Kind::ThematicBreak)
11899 );
11900 }
11901
11902 #[test]
11903 fn insert_thematic_break_at_a_paragraph_s_end_splits_nothing() {
11904 // At the end there is nothing to part, and a split there writes the
11905 // separator anyway — a blank line and the empty slot the next paragraph
11906 // would fill — which the rule then landed above: `para\n\n* * *\n\n\n`,
11907 // two blank lines nothing fills. Now the rule lands after the paragraph,
11908 // where the split-and-aim was sending it regardless. Both formats, and
11909 // both shapes of a last line — terminated, and still being typed —
11910 // because the two reach the split through different doors: Markdown's
11911 // paragraph span stops before its newline, so `para\n` at 4 never split
11912 // there, but `para` at 4 did.
11913 //
11914 // What stands under the rule is the line the caret goes on to, not a
11915 // slot: nothing above the rule but the one blank line, and the caret on
11916 // the line beneath it.
11917 for (fmt, rule) in [(Format::Markdown, "---"), (Format::Djot, "* * *")] {
11918 for src in ["para\n", "para"] {
11919 let mut d = Doc::from_source(src.into(), fmt).unwrap();
11920 d.caret = 4;
11921 d.insert_thematic_break();
11922 assert_eq!(d.source, format!("para\n\n{rule}\n\n"), "{fmt:?} {src:?}");
11923 assert_eq!(d.caret, d.source.len());
11924 }
11925 // Mid-document the slot sat between the rule and the next block;
11926 // the caret's line does now, a blank line either side of it.
11927 let mut d = Doc::from_source("para\n\nnext\n".into(), fmt).unwrap();
11928 d.caret = 4;
11929 d.insert_thematic_break();
11930 assert_eq!(d.source, format!("para\n\n{rule}\n\n\n\nnext\n"), "{fmt:?}");
11931 // Trailing whitespace is nothing to part either.
11932 let mut d = Doc::from_source("para \n".into(), fmt).unwrap();
11933 d.caret = 4;
11934 d.insert_thematic_break();
11935 assert_eq!(d.source, format!("para \n\n{rule}\n\n"), "{fmt:?}");
11936 }
11937 }
11938
11939 #[test]
11940 fn insert_thematic_break_at_a_paragraph_s_start_lands_before_it() {
11941 // The split at the start parts nothing, but it is kept on purpose:
11942 // `|para` becomes `\npara` with the caret on a blank line, and twig
11943 // (3.5.2) writes a rule aimed at a blank line ON that line — the only
11944 // way "before the paragraph" is reachable through a gesture that only
11945 // places after. Before 3.5.2 this came out as `\n\n---\n\npara`.
11946 for (fmt, rule) in [(Format::Markdown, "---"), (Format::Djot, "* * *")] {
11947 let mut d = Doc::from_source("para\n".into(), fmt).unwrap();
11948 d.caret = 0;
11949 d.insert_thematic_break();
11950 assert_eq!(d.source, format!("{rule}\n\npara\n"), "{fmt:?}");
11951 let mut d = Doc::from_source("prev\n\npara\n".into(), fmt).unwrap();
11952 d.caret = 6;
11953 d.insert_thematic_break();
11954 assert_eq!(d.source, format!("prev\n\n{rule}\n\npara\n"), "{fmt:?}");
11955 }
11956 }
11957
11958 #[test]
11959 fn insert_thematic_break_on_a_blank_line_takes_that_line() {
11960 // The gap between two blocks is where a click lands the caret; the
11961 // rule goes on the blank, one blank each side, and the caret on the
11962 // line opened under it.
11963 let mut d = doc_with("hr_gap", "a\n\nb\n");
11964 d.caret = 2;
11965 d.insert_thematic_break();
11966 assert_eq!(d.source, "a\n\n---\n\n\n\nb\n");
11967 assert_eq!(d.caret, "a\n\n---\n\n".len());
11968 }
11969
11970 #[test]
11971 fn insert_thematic_break_leaves_the_caret_on_a_line_under_the_rule() {
11972 // The caret used to be left where twig's splice ended, the home past
11973 // the rule, which is drawn on the rule's own row: the writer saw the
11974 // caret beside the rule, and mid-document what they typed next ran
11975 // into the block below. Now it goes on to an empty row under the
11976 // rule's gap from every place a rule is asked for — the end of a
11977 // paragraph, the empty paragraph Enter opened, a list and the line
11978 // Enter leaves a list for, closing the document and before another
11979 // block — and what is typed there is a paragraph of its own.
11980 for (body, caret, typed) in [
11981 ("para\n", 4, "para\n\n---\n\nX"),
11982 ("para\n\n\n", 6, "para\n\n---\n\nX"),
11983 ("- one\n- two\n", 11, "- one\n- two\n\n---\n\nX"),
11984 ("- one\n- two\n\n\n", 13, "- one\n- two\n\n---\n\nX"),
11985 ("a\n\nb\n", 1, "a\n\n---\n\nX\n\nb\n"),
11986 ("a\n\n\n\nb\n", 3, "a\n\n---\n\nX\n\nb\n"),
11987 ] {
11988 let mut d = wysiwyg_doc("hr_caret", body);
11989 d.build_visual(80);
11990 d.caret = caret;
11991 d.insert_thematic_break();
11992 d.build_visual(80);
11993 let rule = d.vmap.pos_of_offset(d.source.find("---").unwrap()).0;
11994 let (row, col) = d.vmap.pos_of_offset(d.caret);
11995 assert_eq!((row, col), (rule + 2, 0), "{body:?} → {:?}", d.source);
11996 assert!(d.vmap.rows[rule + 1].decoration, "the gap under the rule");
11997 assert!(d.vmap.rows[row].glyphs.is_empty(), "an empty line");
11998 d.insert("X");
11999 assert_eq!(d.source, typed, "{body:?}");
12000 let rule_at = d.source.find("---").unwrap();
12001 assert_eq!(kind_at(&mut d, rule_at), Some(Kind::ThematicBreak));
12002 }
12003 // djot's rule takes in its newline; the line under it is the same.
12004 let mut d = Doc::from_source("a\n\nb\n".into(), Format::Djot).unwrap();
12005 d.view = View::Wysiwyg;
12006 d.caret = 1;
12007 d.insert_thematic_break();
12008 d.build_visual(80);
12009 assert_eq!(d.source, "a\n\n* * *\n\n\n\nb\n");
12010 assert_eq!(d.vmap.pos_of_offset(d.caret), (4, 0));
12011 }
12012
12013 #[test]
12014 fn a_rule_parting_a_paragraph_leaves_the_caret_before_its_second_half() {
12015 // The line under the rule is the paragraph's second half, so that is
12016 // where the caret goes: the text it was standing in front of is still
12017 // in front of it.
12018 let mut d = wysiwyg_doc("hr_parted", "before after\n");
12019 d.caret = 7;
12020 d.insert_thematic_break();
12021 assert_eq!(d.source, "before \n\n---\n\nafter\n");
12022 assert_eq!(d.caret, d.source.find("after").unwrap());
12023 // At the start of a paragraph too, the rule landing above it.
12024 let mut d = wysiwyg_doc("hr_parted_start", "prev\n\npara\n");
12025 d.caret = 6;
12026 d.insert_thematic_break();
12027 assert_eq!(d.caret, d.source.find("para").unwrap());
12028 }
12029
12030 #[test]
12031 fn the_line_under_a_new_rule_is_one_undo_step_with_it() {
12032 let mut d = wysiwyg_doc("hr_undo", "a\n\nb\n");
12033 d.caret = 1;
12034 d.insert_thematic_break();
12035 assert_eq!(d.source, "a\n\n---\n\n\n\nb\n");
12036 d.undo();
12037 assert_eq!(d.source, "a\n\nb\n");
12038 assert!(!d.can_undo());
12039 d.redo();
12040 assert_eq!(d.source, "a\n\n---\n\n\n\nb\n");
12041 }
12042
12043 #[test]
12044 fn a_rule_in_preserve_flow_leaves_the_caret_on_the_first_line_under_it() {
12045 // Preserve draws every blank line as somewhere to type, the first under
12046 // the rule included, so that is the caret's line and the one written.
12047 let mut d = wysiwyg_doc("hr_preserve", "para\n");
12048 d.set_line_flow(LineFlow::Preserve);
12049 d.caret = 4;
12050 d.insert_thematic_break();
12051 assert_eq!(d.source, "para\n\n---\n\n");
12052 assert_eq!(d.caret, "para\n\n---\n".len());
12053 d.build_visual(80);
12054 let rule = d.vmap.pos_of_offset(d.source.find("---").unwrap()).0;
12055 assert_eq!(d.vmap.pos_of_offset(d.caret), (rule + 1, 0));
12056 // Before another block, one blank line more keeps it off that block.
12057 let mut d = wysiwyg_doc("hr_preserve_mid", "a\n\nb\n");
12058 d.set_line_flow(LineFlow::Preserve);
12059 d.caret = 1;
12060 d.insert_thematic_break();
12061 assert_eq!(d.source, "a\n\n---\n\n\nb\n");
12062 assert_eq!(d.caret, "a\n\n---\n".len());
12063 }
12064
12065 #[test]
12066 fn insert_page_break_leaves_the_caret_on_a_line_under_it() {
12067 // The rule button's placement, for the other block the toolbar writes:
12068 // the caret was left at the end of twig's splice, beside the break at
12069 // the end of the document and on the next block's first letter before
12070 // one. djot spells the break as a fence of two lines; the line under
12071 // it is under the closing `:::`.
12072 for (fmt, body, want, typed) in [
12073 (
12074 Format::Markdown,
12075 "a\n\nb\n",
12076 "a\n\n::page-break\n\n\n\nb\n",
12077 "a\n\n::page-break\n\nX\n\nb\n",
12078 ),
12079 (
12080 Format::Markdown,
12081 "a\n",
12082 "a\n\n::page-break\n\n",
12083 "a\n\n::page-break\n\nX",
12084 ),
12085 (
12086 Format::Djot,
12087 "a\n\nb\n",
12088 "a\n\n::: page-break\n:::\n\n\n\nb\n",
12089 "a\n\n::: page-break\n:::\n\nX\n\nb\n",
12090 ),
12091 ] {
12092 let mut d = Doc::from_source(body.into(), fmt).unwrap();
12093 d.view = View::Wysiwyg;
12094 d.caret = 1;
12095 d.insert_page_break();
12096 assert_eq!(d.source, want, "{fmt:?}");
12097 d.build_visual(80);
12098 let (row, col) = d.vmap.pos_of_offset(d.caret);
12099 assert_eq!(col, 0);
12100 assert!(d.vmap.rows[row].glyphs.is_empty(), "{fmt:?}: an empty line");
12101 assert!(
12102 d.vmap.rows[row - 2].leaf_directive.is_some(),
12103 "{fmt:?}: under the break"
12104 );
12105 d.insert("X");
12106 assert_eq!(d.source, typed, "{fmt:?}");
12107 d.undo();
12108 d.undo();
12109 assert_eq!(
12110 d.source, body,
12111 "{fmt:?}: the break and its line are one step"
12112 );
12113 }
12114 }
12115
12116 #[test]
12117 fn a_block_parting_a_paragraph_is_one_undo_step() {
12118 // The split went to twig without leaf counting it, so the first Undo
12119 // took back the block, left the paragraph parted, and reported nothing
12120 // more to undo.
12121 let mut rule = wysiwyg_doc("part_undo_rule", "before after\n");
12122 rule.caret = 7;
12123 rule.insert_thematic_break();
12124 let mut table = wysiwyg_doc("part_undo_table", "before after\n");
12125 table.caret = 7;
12126 table.insert_table(1, 1);
12127 let mut page = wysiwyg_doc("part_undo_page", "before after\n");
12128 page.caret = 7;
12129 page.insert_page_break();
12130 for (name, d) in [
12131 ("rule", &mut rule),
12132 ("table", &mut table),
12133 ("page break", &mut page),
12134 ] {
12135 assert_ne!(d.source, "before after\n", "{name}");
12136 d.undo();
12137 assert_eq!(d.source, "before after\n", "{name}: one Undo");
12138 assert_eq!(d.caret, 7, "{name}: the caret back where it was");
12139 assert!(!d.can_undo(), "{name}");
12140 assert!(d.can_redo(), "{name}");
12141 d.redo();
12142 assert!(!d.source.starts_with("before after"), "{name}: one Redo");
12143 assert!(!d.can_redo(), "{name}");
12144 }
12145 // A table twig would refuse parts nothing: the refusal comes first.
12146 let mut d = wysiwyg_doc("part_undo_zero", "before after\n");
12147 d.caret = 7;
12148 d.insert_table(0, 1);
12149 assert_eq!(d.source, "before after\n");
12150 assert!(!d.can_undo());
12151 }
12152
12153 #[test]
12154 fn enter_at_a_paragraph_s_start_opens_an_empty_paragraph_above_it() {
12155 // twig's split at a paragraph's start writes one newline ahead of it,
12156 // which Fold draws as one more gap, so the first Enter showed nothing.
12157 // A paragraph break opens a line above, the caret staying before the
12158 // text. Past a mark's opening delimiters — where a tap before the
12159 // first letter lands — the split used to cut the mark in two
12160 // (`**\n\nb**`); the break goes in front of them, as it goes in
12161 // front of a heading's `#`, where the heading's own Enter left an
12162 // empty heading over its text as a paragraph. A paragraph that
12163 // opens a div gets its line outside the div, where it draws.
12164 for (body, caret, want, want_caret) in [
12165 ("a\n\nb\n", 3, "a\n\n\n\nb\n", 5),
12166 ("# H\n\nb\n", 5, "# H\n\n\n\nb\n", 7),
12167 ("a\n\n# H\n", 5, "a\n\n\n\n# H\n", 7),
12168 ("a\n\n**b** c\n", 3, "a\n\n\n\n**b** c\n", 5),
12169 ("a\n\n**b** c\n", 5, "a\n\n\n\n**b** c\n", 7),
12170 (
12171 "a\n\n<span data-color=\"orange\">b</span>\n",
12172 29,
12173 "a\n\n\n\n<span data-color=\"orange\">b</span>\n",
12174 31,
12175 ),
12176 (
12177 "a\n\n<div class=\"center\">\n\nb\n\n</div>\n",
12178 25,
12179 "a\n\n\n\n<div class=\"center\">\n\nb\n\n</div>\n",
12180 27,
12181 ),
12182 ] {
12183 let mut d = wysiwyg_doc("enter_para_start", body);
12184 d.build_visual(80);
12185 d.caret = caret;
12186 d.newline();
12187 assert_eq!(d.source, want, "{body:?}");
12188 assert_eq!(d.caret, want_caret, "{body:?}");
12189 d.build_visual(80);
12190 let (row, _) = d.vmap.pos_of_offset(d.caret);
12191 assert!(
12192 d.vmap.rows[row - 2].glyphs.is_empty(),
12193 "{body:?}: an empty line above"
12194 );
12195 assert!(
12196 !d.vmap.rows[row - 2].decoration,
12197 "{body:?}: one the caret can stand on"
12198 );
12199 }
12200 // Past the first letter it is an ordinary split.
12201 let mut d = wysiwyg_doc("enter_para_mid", "a\n\n**b** c\n");
12202 d.caret = 8;
12203 d.newline();
12204 assert_eq!(d.source, "a\n\n**b**\n\nc\n");
12205 }
12206
12207 #[test]
12208 fn enter_again_at_a_paragraph_s_start_opens_one_more_line() {
12209 // The first Enter's paragraph break leaves a gap either side of the
12210 // empty line. A second break drew two lines more; a newline draws one.
12211 let mut d = wysiwyg_doc("enter_para_again", "a\n\nb\n");
12212 d.build_visual(80);
12213 d.caret = 3;
12214 for (want, lines) in [("a\n\n\n\nb\n", 1), ("a\n\n\n\n\nb\n", 2)] {
12215 d.newline();
12216 assert_eq!(d.source, want);
12217 assert_eq!(&d.source[d.caret..], "b\n");
12218 d.build_visual(80);
12219 let empty = d
12220 .vmap
12221 .rows
12222 .iter()
12223 .filter(|r| r.glyphs.is_empty() && !r.decoration)
12224 .count();
12225 assert_eq!(empty, lines, "{want:?}");
12226 }
12227 }
12228
12229 #[test]
12230 fn enter_at_the_first_block_s_start_pushes_it_down() {
12231 // No row was drawn for a blank line above the first block, so Enter
12232 // there wrote a line and the text stayed where it was. Each Enter now
12233 // draws one empty line above it, the caret staying with the text;
12234 // past frontmatter, the line conventionally under it still draws
12235 // nothing until Enter adds to it.
12236 for (body, caret, wants) in [
12237 ("b\n", 0, ["\n\nb\n", "\n\n\nb\n"]),
12238 ("**b** c\n", 2, ["\n\n**b** c\n", "\n\n\n**b** c\n"]),
12239 ("# H\n", 2, ["\n\n# H\n", "\n\n\n# H\n"]),
12240 (
12241 "---\nt: x\n---\n\nb\n",
12242 14,
12243 ["---\nt: x\n---\n\n\nb\n", "---\nt: x\n---\n\n\n\nb\n"],
12244 ),
12245 ] {
12246 let mut d = wysiwyg_doc("enter_first_block", body);
12247 d.build_visual(80);
12248 let empty = |d: &Doc| {
12249 d.vmap
12250 .rows
12251 .iter()
12252 .filter(|r| r.glyphs.is_empty() && !r.decoration)
12253 .count()
12254 };
12255 assert_eq!(empty(&d), 0, "{body:?}: nothing above the text yet");
12256 d.caret = caret;
12257 for (lines, want) in wants.into_iter().enumerate() {
12258 d.newline();
12259 assert_eq!(d.source, want, "{body:?}");
12260 assert!(d.source[d.caret..].starts_with(&body[caret..]), "{body:?}");
12261 d.build_visual(80);
12262 assert_eq!(empty(&d), lines + 1, "{want:?}");
12263 }
12264 // The caret can go up onto the lines it opened.
12265 d.move_up(false);
12266 let (row, _) = d.vmap.pos_of_offset(d.caret);
12267 assert!(d.vmap.rows[row].glyphs.is_empty(), "{body:?}");
12268 assert!(!d.vmap.rows[row].decoration, "{body:?}");
12269 }
12270 }
12271
12272 /// What a reader sees of a map: each row's text, whether it is a gap,
12273 /// and the presentation it is drawn with. Offsets are left out, since an
12274 /// edit shifts them without anything on screen changing.
12275 fn drawn(d: &Doc) -> Vec<String> {
12276 d.vmap
12277 .rows
12278 .iter()
12279 .map(|r| {
12280 let text: String = r.glyphs.iter().map(|g| g.ch).collect();
12281 format!(
12282 "{}{text:?} h={:?} lh={:?} a={:?}",
12283 if r.decoration { "~" } else { "" },
12284 r.heading,
12285 r.line_height,
12286 r.align
12287 )
12288 })
12289 .collect()
12290 }
12291
12292 #[test]
12293 fn enter_and_backspace_anywhere_show_what_they_write_and_undo_in_one_step() {
12294 // Enter that writes what the view does not draw looks like a key that
12295 // did nothing, so it gets pressed again, and the lines pile up unseen
12296 // until something redraws them all at once. The rule, the page break,
12297 // a paragraph's start, the first block's start and the blank page
12298 // were each that bug once. So: at every place the caret can stand, in
12299 // both flows, Enter either changes what is drawn or writes nothing,
12300 // and one Undo takes back whatever it wrote. Enter and Backspace both
12301 // leave the view a fresh open of the text would draw, so nothing
12302 // moves when the view is next rebuilt from scratch.
12303 let cases: &[(Format, &str)] = &[
12304 (
12305 Format::Markdown,
12306 "I talk to myself\n\nI laugh\n\nI cry\n\nknees to nose\n\nlet me go\n",
12307 ),
12308 (Format::Markdown, "I talk to myself\nI laugh\nI cry\n"),
12309 (
12310 Format::Markdown,
12311 "<div data-line-height=\"1.5\">\n\nI laugh\n\nI cry\n\nknees to nose\n\n</div>\n",
12312 ),
12313 (
12314 Format::Markdown,
12315 "<div data-line-height=\"1.15\">\n\nI cry\n\n</div>\n\n<div data-line-height=\"1.15\">\n\nknees\n\n</div>\n",
12316 ),
12317 (
12318 Format::Markdown,
12319 "<div class=\"center\">\n\nmiddle\n\n</div>\n\nafter\n",
12320 ),
12321 (
12322 Format::Markdown,
12323 "---\ntitle: x\n---\n\n# Title\n\nbody **bold** and <span data-color=\"red\">red</span>\n",
12324 ),
12325 (Format::Markdown, "\n\n\nafter blank lines\n\n\n\nmid\n\n\n"),
12326 (
12327 Format::Markdown,
12328 "- one\n- two\n\n1. a\n2. b\n\n- [ ] task\n",
12329 ),
12330 (Format::Markdown, "> quoted\n> more\n\n> - in a quote\n"),
12331 (Format::Markdown, "a\n\n---\n\nb\n\n::page-break\n\nc\n"),
12332 (Format::Markdown, "Title\n=====\n\nSub\n---\n\ntext\n"),
12333 (
12334 Format::Markdown,
12335 "```\ncode\n```\n\n| a | b |\n|---|---|\n| 1 | 2 |\n",
12336 ),
12337 (Format::Djot, "I laugh\n\nI cry\n\nknees to nose\n"),
12338 (
12339 Format::Djot,
12340 "{data-line-height=\"2\"}\nI cry\n\n{data-line-height=\"2\"}\nknees to nose\n",
12341 ),
12342 (Format::Djot, "# Title\n\na\n\n* * *\n\nb\n"),
12343 // HTML is not here: Enter writes blank lines there, which the
12344 // view draws and HTML does not keep. See
12345 // docs/tasks/enter-in-html-writes-whitespace.md.
12346 ];
12347 let mut failures = Vec::new();
12348 for &(format, body) in cases {
12349 for flow in [LineFlow::Fold, LineFlow::Preserve] {
12350 let open = || {
12351 let mut d = Doc::from_source(body.into(), format).unwrap();
12352 d.view = View::Wysiwyg;
12353 d.set_line_flow(flow);
12354 d.build_visual_unwrapped();
12355 d
12356 };
12357 // The map a fresh open of the same text would draw. The
12358 // edited map differing from it is a view that changes on the
12359 // next full rebuild, seconds after the key, with no key.
12360 let fresh = |source: &str| {
12361 let mut ed =
12362 twig::Editor::new_ext(source.as_bytes(), format, parse_extensions())
12363 .unwrap();
12364 let nodes = ed.nodes().unwrap();
12365 crate::wysiwyg::build(
12366 &nodes,
12367 source,
12368 None,
12369 flow == LineFlow::Preserve,
12370 &wysiwyg::Surface::default(),
12371 None,
12372 )
12373 };
12374 let probe = open();
12375 let stops: Vec<usize> = (0..=body.len())
12376 .filter(|&o| probe.vmap.is_stop(o))
12377 .collect();
12378 for at in stops {
12379 for enter in [true, false] {
12380 let mut d = open();
12381 d.caret = at;
12382 d.anchor = None;
12383 let before = drawn(&d);
12384 if enter {
12385 d.newline();
12386 } else {
12387 d.backspace();
12388 }
12389 d.build_visual_unwrapped();
12390 let key = if enter { "enter" } else { "backspace" };
12391 let what = format!(
12392 "{key} {format:?} {flow:?} caret {at} in {body:?} -> {:?}",
12393 d.source
12394 );
12395 if maps_differ(&d.vmap, &fresh(&d.source)) {
12396 failures.push(format!("redraws differently: {what}"));
12397 }
12398 if d.source == body {
12399 continue;
12400 }
12401 if enter && drawn(&d) == before {
12402 failures.push(format!("drew nothing: {what}"));
12403 }
12404 d.undo();
12405 if d.source != body {
12406 failures.push(format!("undo left {:?}: {what}", d.source));
12407 }
12408 }
12409 }
12410 }
12411 }
12412 assert!(
12413 failures.is_empty(),
12414 "{} failures:\n{}",
12415 failures.len(),
12416 failures.join("\n")
12417 );
12418 }
12419
12420 #[test]
12421 fn enter_opens_a_line_that_draws_inside_a_div_a_quote_and_preserve_flow() {
12422 // Each of these wrote a line the view did not draw, so Enter looked
12423 // dead and every press left one more.
12424 let enter = |format, flow, body: &str, at: usize| {
12425 let mut d = Doc::from_source(body.into(), format).unwrap();
12426 d.view = View::Wysiwyg;
12427 d.set_line_flow(flow);
12428 d.build_visual(80);
12429 d.caret = at;
12430 d.newline();
12431 d.build_visual(80);
12432 d
12433 };
12434 let div = "<div data-line-height=\"1.5\">\n\nI cry\n\nknees\n\n</div>\n";
12435 let end = div.find("knees").unwrap() + "knees".len();
12436
12437 // At the end of a div's last paragraph, the empty paragraph opens
12438 // inside the div, drawn with its line height, and what is typed
12439 // there stays in it.
12440 let mut d = enter(Format::Markdown, LineFlow::Fold, div, end);
12441 let (row, _) = d.vmap.pos_of_offset(d.caret);
12442 let r = &d.vmap.rows[row];
12443 assert!(r.glyphs.is_empty() && !r.decoration, "{:?}", drawn(&d));
12444 assert!(
12445 r.line_height.is_some(),
12446 "the new line keeps the div's spacing"
12447 );
12448 d.insert("x");
12449 assert_eq!(
12450 d.source,
12451 "<div data-line-height=\"1.5\">\n\nI cry\n\nknees\n\nx\n\n</div>\n"
12452 );
12453 // Preserve flow's soft line there draws too.
12454 let d = enter(Format::Markdown, LineFlow::Preserve, div, end);
12455 assert_eq!(
12456 d.source,
12457 "<div data-line-height=\"1.5\">\n\nI cry\n\nknees\n\n\n</div>\n"
12458 );
12459 assert_eq!(d.vmap.rows.len(), 4, "{:?}", drawn(&d));
12460 // Between two of the div's paragraphs, the empty line takes its
12461 // spacing as well.
12462 let d = enter(
12463 Format::Markdown,
12464 LineFlow::Fold,
12465 div,
12466 div.find("knees").unwrap(),
12467 );
12468 let (row, _) = d.vmap.pos_of_offset(d.caret);
12469 assert!(
12470 d.vmap.rows[row - 2].line_height.is_some(),
12471 "{:?}",
12472 drawn(&d)
12473 );
12474
12475 // Under `</div>` the blank line is the one Markdown needs to end the
12476 // div. Preserve flow drew it as a line to type on, and Backspace
12477 // there glued the next paragraph to the tag (`</div>after`).
12478 let below = "<div class=\"center\">\n\nmiddle\n\n</div>\n\nafter\n";
12479 let mut d = Doc::from_source(below.into(), Format::Markdown).unwrap();
12480 d.view = View::Wysiwyg;
12481 d.set_line_flow(LineFlow::Preserve);
12482 d.build_visual(80);
12483 assert_eq!(d.vmap.rows.len(), 2, "{:?}", drawn(&d));
12484 d.caret = below.find("after").unwrap();
12485 d.backspace();
12486 assert!(!d.source.contains("</div>after"), "{:?}", d.source);
12487
12488 // At a quote's first line, the line opens above the quote; twig's
12489 // split wrote a quoted blank line above the text, which drew nothing.
12490 let d = enter(
12491 Format::Markdown,
12492 LineFlow::Fold,
12493 "a\n\n> quoted\n> more\n",
12494 5,
12495 );
12496 assert_eq!(d.source, "a\n\n\n\n> quoted\n> more\n");
12497 assert_eq!(&d.source[d.caret..], "quoted\n> more\n");
12498
12499 // Preserve flow at a paragraph's start: past a mark's delimiters its
12500 // newline cut the mark (`**\nb**`), and in djot a heading ran on
12501 // over it unseen.
12502 let d = enter(Format::Markdown, LineFlow::Preserve, "a\n\n**b** c\n", 5);
12503 assert_eq!(d.source, "a\n\n\n**b** c\n");
12504 let d = enter(Format::Djot, LineFlow::Preserve, "a\n\n# Title\n", 5);
12505 assert_eq!(d.source, "a\n\n\n# Title\n");
12506 // Inside a heading it parts the heading, as in Fold flow.
12507 let d = enter(Format::Djot, LineFlow::Preserve, "a\n\n# Title\n", 7);
12508 assert_eq!(d.source, "a\n\n# Ti\n\ntle\n");
12509 }
12510
12511 #[test]
12512 fn enter_on_a_blank_page_writes_nothing() {
12513 // With no block to push down, Fold flow draws no line for Enter to
12514 // open, and the newline it wrote was an edit and an undo step that
12515 // showed nothing. Preserve flow draws every line, so there it writes.
12516 for body in ["", "\n", "\n\n \n", "---\nt: x\n---\n"] {
12517 let mut d = wysiwyg_doc("enter_blank_page", body);
12518 d.build_visual(80);
12519 d.caret = d.source.len();
12520 d.newline();
12521 assert_eq!(d.source, body);
12522 assert!(!d.can_undo(), "{body:?}");
12523 assert!(!d.dirty, "{body:?}");
12524 }
12525 let mut d = wysiwyg_doc("enter_blank_page_preserve", "");
12526 d.set_line_flow(LineFlow::Preserve);
12527 d.build_visual(80);
12528 d.newline();
12529 assert_eq!(d.source, "\n");
12530 }
12531
12532 #[test]
12533 fn enter_beside_a_rule_opens_a_line_under_it() {
12534 // Beside a rule the caret stands at the home past it, which in source is
12535 // the start of the blank line under the rule, and Enter took it for an
12536 // empty paragraph: a lone newline, drawn as one more gap, and the caret
12537 // put on the next block's first line — where typing joined that block.
12538 // It goes on under the rule instead, as the rule button leaves it.
12539 let mut d = wysiwyg_doc("enter_rule", "a\n\n---\n\nb\n");
12540 d.build_visual(80);
12541 d.caret = "a\n\n---\n".len();
12542 d.newline();
12543 assert_eq!(d.source, "a\n\n---\n\n\n\nb\n");
12544 assert_eq!(d.caret, "a\n\n---\n\n".len());
12545 d.insert("X");
12546 assert_eq!(d.source, "a\n\n---\n\nX\n\nb\n");
12547
12548 // Closing the document.
12549 let mut d = wysiwyg_doc("enter_rule_end", "a\n\n---\n");
12550 d.caret = d.source.len();
12551 d.newline();
12552 assert_eq!(d.source, "a\n\n---\n\n");
12553 assert_eq!(d.caret, d.source.len());
12554
12555 // In a quote, where the lines it writes keep the quote's prefix.
12556 let mut d = wysiwyg_doc("enter_rule_quote", "> a\n>\n> ---\n>\n> b\n");
12557 d.caret = "> a\n>\n> ---\n".len();
12558 d.newline();
12559 assert_eq!(d.source, "> a\n>\n> ---\n>\n> \n>\n> b\n");
12560 d.insert("X");
12561 assert_eq!(d.source, "> a\n>\n> ---\n>\n> X\n>\n> b\n");
12562
12563 // Preserve draws the blank line under a rule as a line, and the caret
12564 // on it: Enter there is a blank line's, one line down.
12565 let mut d = wysiwyg_doc("enter_rule_preserve", "a\n\n---\n\nb\n");
12566 d.set_line_flow(LineFlow::Preserve);
12567 d.caret = "a\n\n---\n".len();
12568 d.newline();
12569 assert_eq!(d.source, "a\n\n---\n\n\nb\n");
12570 assert_eq!(d.caret, "a\n\n---\n\n".len());
12571 }
12572
12573 #[test]
12574 fn insert_table_at_a_paragraph_s_end_splits_nothing() {
12575 // The same door as the rule's, through the placement they share.
12576 let mut d = Doc::from_source("para\n".into(), Format::Djot).unwrap();
12577 d.caret = 4;
12578 d.insert_table(1, 1);
12579 assert_eq!(d.source, "para\n\n| |\n|---|\n| |\n");
12580 let mut d = doc_with("table_end_typed", "para");
12581 d.caret = 4;
12582 d.insert_table(1, 1);
12583 assert_eq!(d.source, "para\n\n| |\n| --- |\n| |\n");
12584 assert!(d.caret_in_table());
12585 }
12586
12587 #[test]
12588 fn insert_thematic_break_spells_the_rule_the_format_s_own_way() {
12589 // The whole point of delegating: `---` is Markdown's, `* * *` is djot's,
12590 // and leaf wrote the first into both until twig started spelling it.
12591 let mut md = doc_with("hr_md", "para\n");
12592 md.caret = 2;
12593 md.insert_thematic_break();
12594 assert_eq!(md.source, "pa\n\n---\n\nra\n");
12595
12596 let mut dj = Doc::from_source("para\n".into(), Format::Djot).unwrap();
12597 dj.caret = 2;
12598 dj.insert_thematic_break();
12599 assert_eq!(dj.source, "pa\n\n* * *\n\nra\n");
12600 }
12601
12602 #[test]
12603 fn insert_table_parts_the_paragraph_and_lands_in_the_first_header_cell() {
12604 // The table goes *at* the caret the way the rule does: the paragraph is
12605 // parted first, and twig writes the grid after its first half. The
12606 // caret then sits in the first header cell — selected, as Tab would
12607 // leave it — so the next keystroke is the heading.
12608 let mut d = doc_with("table_mid", "before after\n");
12609 d.caret = 7;
12610 d.insert_table(2, 3);
12611 assert_eq!(
12612 d.source,
12613 "before \n\n| | | |\n| --- | --- | --- |\n| | | |\n| | | |\n\nafter\n"
12614 );
12615 assert!(d.caret_in_table());
12616 let first_bar = d.source.find('|').unwrap();
12617 assert!(
12618 d.caret > first_bar && d.caret < d.source.find("| ---").unwrap(),
12619 "caret {} is not in the header row",
12620 d.caret
12621 );
12622 d.insert("Name");
12623 assert!(d.source.starts_with("before \n\n| Name | | |\n"));
12624 // And the grid the table was written into is one the table keys walk
12625 // (over the map a frontend rebuilds after every edit).
12626 d.build_visual(80);
12627 assert!(d.cell_tab(true));
12628 d.insert("Qty");
12629 assert!(d.source.starts_with("before \n\n| Name | Qty | |\n"));
12630 }
12631
12632 #[test]
12633 fn insert_table_spells_the_grid_the_format_s_own_way() {
12634 // Djot's delimiter row is unpadded, and leaf never has to know that.
12635 let mut dj = Doc::from_source("para\n".into(), Format::Djot).unwrap();
12636 dj.caret = 2;
12637 dj.insert_table(1, 2);
12638 assert_eq!(dj.source, "pa\n\n| | |\n|---|---|\n| | |\n\nra\n");
12639 assert!(dj.caret_in_table());
12640 }
12641
12642 #[test]
12643 fn insert_table_refuses_where_the_format_spells_no_table() {
12644 let mut d = Doc::from_source("<p>ab</p>\n".into(), Format::Html).unwrap();
12645 d.caret = 4;
12646 d.insert_table(1, 1);
12647 assert_eq!(d.source, "<p>ab</p>\n");
12648 assert!(d.status.as_deref().unwrap_or("").contains("not supported"));
12649 assert!(!d.capabilities().table);
12650 }
12651
12652 #[test]
12653 fn insert_table_reports_a_zero_shape_and_writes_nothing() {
12654 let mut d = doc_with("table_zero", "para\n");
12655 d.caret = 2;
12656 d.insert_table(0, 2);
12657 assert_eq!(d.source, "para\n");
12658 assert!(d.status.as_deref().unwrap_or("").starts_with("table:"));
12659 }
12660
12661 #[test]
12662 fn clicking_below_a_final_thematic_break_can_type_after_it() {
12663 let mut d = wysiwyg_doc("hr_final_click", "---\n");
12664 d.build_visual(80);
12665 d.click(d.vmap.num_rows() + 2, 0, false);
12666 assert_eq!(d.caret, d.source.len(), "the caret belongs after the rule");
12667 d.insert("after");
12668 assert_eq!(d.source, "---\nafter");
12669 }
12670
12671 #[test]
12672 fn enter_in_a_nested_list_item_keeps_the_new_item_nested() {
12673 // The same bytes are two documents. In Markdown ` - b` is a nested item
12674 // and the next one belongs beside it, at its indent. In Djot a list
12675 // marker can't interrupt a paragraph, so those bytes are literal text in
12676 // item `a` and there is only one item — writing ` - ` under it would add
12677 // no item at all, just more text, and the new sibling has to go to
12678 // column zero. Both spellings come out of the *enclosing item's* line.
12679 let mut md = wysiwyg_doc("enter_nested_md", "- a\n - b\n");
12680 md.caret = "- a\n - b".len();
12681 md.newline();
12682 assert_eq!(md.source, "- a\n - b\n - \n");
12683 assert_eq!(list_items(&mut md), 3);
12684
12685 let mut dj = Doc::from_source("- a\n - b\n".into(), Format::Djot).unwrap();
12686 dj.view = View::Wysiwyg;
12687 dj.build_visual(80);
12688 dj.caret = "- a\n - b".len();
12689 dj.newline();
12690 assert_eq!(dj.source, "- a\n - b\n- \n");
12691 assert_eq!(list_items(&mut dj), 2);
12692
12693 // Where Djot's nesting is real — opened by a blank line — the indent is
12694 // reproduced there too, and the two formats agree again.
12695 let mut dj = Doc::from_source("- a\n\n - b\n".into(), Format::Djot).unwrap();
12696 dj.view = View::Wysiwyg;
12697 dj.build_visual(80);
12698 dj.caret = "- a\n\n - b".len();
12699 dj.newline();
12700 assert_eq!(dj.source, "- a\n\n - b\n - \n");
12701 assert_eq!(list_items(&mut dj), 3);
12702 }
12703
12704 #[test]
12705 fn tab_nests_an_item_at_the_column_its_own_marker_asks_for() {
12706 // Tab replaces the line's whole prefix with the one twig spells, so the
12707 // quote markers, the parent's indent and an ordered marker's extra
12708 // column are all its answer rather than leaf's arithmetic.
12709 for (name, body, caret, want) in [
12710 ("bullet", "- a\n- b\n", 6, "- a\n - b\n"),
12711 ("ordered", "1. a\n2. b\n", 8, "1. a\n 1. b\n"),
12712 ("quoted", "> - a\n> - b\n", 10, "> - a\n> - b\n"),
12713 // A checkbox is markup the item's own text wraps past, but a nested
12714 // list may only open at the *list* marker's column — four in from
12715 // there is a paragraph continuation, and `- [ ] a\n - [ ] b`
12716 // parses as one item, not two.
12717 ("task", "- [ ] a\n- [ ] b\n", 14, "- [ ] a\n - [ ] b\n"),
12718 (
12719 "quoted task",
12720 "> - [ ] a\n> - [ ] b\n",
12721 18,
12722 "> - [ ] a\n> - [ ] b\n",
12723 ),
12724 ] {
12725 let mut doc = wysiwyg_doc(name, body);
12726 doc.caret = caret;
12727 doc.indent();
12728 assert_eq!(doc.source, want, "{name}");
12729 // The nesting is real, not just indented text.
12730 assert_eq!(list_items(&mut doc), 2, "{name}");
12731 }
12732 }
12733
12734 #[test]
12735 fn backspace_only_outdents_where_the_format_says_there_is_an_item() {
12736 // The same bytes, the two formats disagreeing, and a gesture that used
12737 // to read the bytes. ` - b` is a nested item in Markdown, so Backspace
12738 // at its marker outdents. In Djot a marker can't interrupt a paragraph,
12739 // so those bytes are literal text inside item `a` — there is nothing to
12740 // outdent, and treating them as a marker turned one item into two, a
12741 // structural edit from a keystroke that should delete one character.
12742 //
12743 // twig's `line_prefix` is what tells them apart: it reports the marker
12744 // on the Markdown line and nothing on the Djot one, which is a
12745 // continuation. No byte scan can reach that answer.
12746 let src = "- a\n - b\n";
12747 let at = "- a\n - ".len();
12748
12749 let mut md = Doc::from_source(src.into(), Format::Markdown).unwrap();
12750 md.view = View::Wysiwyg;
12751 md.build_visual(80);
12752 md.caret = at;
12753 md.backspace();
12754 assert_eq!(md.source, "- a\n- b\n");
12755 assert_eq!(list_items(&mut md), 2);
12756
12757 let mut dj = Doc::from_source(src.into(), Format::Djot).unwrap();
12758 dj.view = View::Wysiwyg;
12759 dj.build_visual(80);
12760 dj.caret = at;
12761 dj.backspace();
12762 assert_eq!(dj.source, "- a\n -b\n"); // an ordinary character delete
12763 assert_eq!(list_items(&mut dj), 1); // and the structure is untouched
12764 }
12765
12766 #[test]
12767 fn enter_in_a_checklist_item_starts_another_unchecked_one() {
12768 // Leaf used to spell the next item from the marker bytes it scanned, and
12769 // its scanner stopped at the bullet — so Enter in a checklist wrote `- `
12770 // and dropped out of the checklist. twig reproduces the whole
12771 // continuation, and a fresh item is always unticked however the one above
12772 // it stands.
12773 for (name, body, want) in [
12774 ("unchecked", "- [ ] a\n", "- [ ] a\n- [ ] \n"),
12775 ("checked", "- [x] a\n", "- [x] a\n- [ ] \n"),
12776 ] {
12777 let mut doc = wysiwyg_doc(name, body);
12778 doc.caret = body.trim_end_matches('\n').len();
12779 doc.newline();
12780 assert_eq!(doc.source, want, "{name}");
12781 // Both items are checklist items — the new one is a box, not the
12782 // plain bullet the old marker scan left behind — and it is unticked
12783 // whichever way the one above it faces.
12784 let boxes: Vec<Option<bool>> = doc
12785 .nodes()
12786 .iter()
12787 .filter(|n| n.kind == Kind::TaskListItem)
12788 .map(|n| n.checked)
12789 .collect();
12790 assert_eq!(boxes.len(), 2, "{name}");
12791 assert_eq!(boxes[1], Some(false), "{name}");
12792 }
12793 }
12794
12795 #[test]
12796 fn a_split_takes_the_space_the_caret_was_in_front_of() {
12797 // Splicing a break at the caret strands the space the words were parted
12798 // at on the head of the second block, where it reads as an indent nobody
12799 // typed. twig's split consumes it.
12800 for (name, body, caret, want) in [
12801 ("para", "one two\n", 3, "one\n\ntwo\n"),
12802 ("item", "- one two\n", 5, "- one\n- two\n"),
12803 ("quote", "> one two\n", 5, "> one\n>\n> two\n"),
12804 // A heading takes leaf's own path, which has to match.
12805 ("heading", "# one two\n", 5, "# one\n\ntwo\n"),
12806 ] {
12807 let mut doc = wysiwyg_doc(name, body);
12808 doc.caret = caret;
12809 doc.newline();
12810 assert_eq!(doc.source, want, "{name}");
12811 }
12812 }
12813
12814 #[test]
12815 fn enter_at_the_end_of_a_heading_opens_a_paragraph() {
12816 // The one place leaf keeps its own break: `split_block` repeats the `#`,
12817 // and Enter after a title is how the body under it is asked for.
12818 let mut doc = wysiwyg_doc("head_enter", "# Title\n");
12819 doc.caret = "# Title".len();
12820 doc.newline();
12821 doc.insert("body");
12822 assert_eq!(doc.source, "# Title\n\nbody\n");
12823 assert_eq!(
12824 doc.nodes()
12825 .iter()
12826 .filter(|n| n.kind == Kind::Heading)
12827 .count(),
12828 1
12829 );
12830 }
12831
12832 #[test]
12833 fn enter_in_a_quoted_list_item_starts_the_next_quoted_item() {
12834 // A quoted item's marker doesn't open its line, so a scan that starts at
12835 // column zero finds a `>` where it wanted a bullet, calls the line "not a
12836 // list" and hands Enter to the plain-quote branch — which writes `> ` and
12837 // drops the list. The next item has to carry the whole prefix.
12838 for (name, body, want) in [
12839 ("flat", "> - a\n", "> - a\n> - \n"),
12840 ("sibling", "> - a\n> - b\n", "> - a\n> - b\n> - \n"),
12841 ("nested", "> - a\n> - b\n", "> - a\n> - b\n> - \n"),
12842 ("ordered", "> 1. a\n> 2. b\n", "> 1. a\n> 2. b\n> 3. \n"),
12843 ("twice quoted", "> > - a\n", "> > - a\n> > - \n"),
12844 ] {
12845 let mut doc = wysiwyg_doc(name, body);
12846 doc.caret = body.trim_end_matches('\n').len();
12847 doc.newline();
12848 assert_eq!(doc.source, want, "{name}");
12849 // The marker isn't just spelled right, it parses as an item.
12850 assert_eq!(list_items(&mut doc), body.lines().count() + 1, "{name}");
12851 }
12852 }
12853
12854 #[test]
12855 fn an_empty_quoted_item_leaves_the_list_and_stays_in_the_quote() {
12856 // Double-Enter exits the list. Unquoted that means a blank line, but a
12857 // *bare* blank line would end the quote too and drop the caret out of it,
12858 // so the separator keeps its `>` and the caret's line keeps its `> `.
12859 let mut doc = wysiwyg_doc("quoted_exit", "> - a\n> - \n");
12860 doc.caret = "> - a\n> - ".len();
12861 doc.newline();
12862 assert_eq!(doc.source, "> - a\n>\n> \n");
12863 assert_eq!(list_items(&mut doc), 1);
12864 // What "still in the quote" means for the next keystroke: the caret sits
12865 // behind the prefix, and what's typed there lands inside the quote as a
12866 // paragraph of its own — not as more of item `a`.
12867 doc.insert("x");
12868 assert_eq!(doc.source, "> - a\n>\n> x\n");
12869 assert!(
12870 doc.editor
12871 .ancestors_at(doc.caret - 1)
12872 .is_ok_and(|c| c.into_iter().any(|m| m.kind == Kind::BlockQuote))
12873 );
12874 }
12875
12876 #[test]
12877 fn backspace_at_a_quoted_marker_takes_the_marker_and_leaves_the_quote() {
12878 // The marker is hidden block markup, so Backspace over it is structural —
12879 // but only the marker is the list's. Splicing from the line start would
12880 // take the `>` with it and silently unquote the line.
12881 let mut doc = wysiwyg_doc("quoted_bksp", "> - a\n");
12882 doc.caret = "> - ".len();
12883 doc.backspace();
12884 assert_eq!(doc.source, "> a\n");
12885 assert_eq!(list_items(&mut doc), 0);
12886
12887 // A nested one outdents instead, moving the bullet within the quote
12888 // rather than moving the quote.
12889 let mut doc = wysiwyg_doc("quoted_outdent", "> - a\n> - b\n");
12890 doc.caret = "> - a\n> - ".len();
12891 doc.backspace();
12892 assert_eq!(doc.source, "> - a\n> - b\n");
12893 assert_eq!(list_items(&mut doc), 2);
12894 }
12895
12896 #[test]
12897 fn only_a_bare_paragraph_is_parted_around_the_caret() {
12898 // The split is deliberately narrow. Parting a fenced block would leave
12899 // two fences with a rule between them, and parting a list item would
12900 // mint an item nobody asked for on the way to a rule that lands after
12901 // the list either way — so both keep the whole block intact and take the
12902 // rule after it. A caret in a quote is likewise left alone.
12903 for (name, body, caret, want) in [
12904 (
12905 "code",
12906 "```\nfn x() {}\n```\n",
12907 8,
12908 "```\nfn x() {}\n```\n\n---\n\n",
12909 ),
12910 ("list", "- one two\n", 6, "- one two\n\n---\n\n"),
12911 ("quote", "> one two\n", 6, "> one two\n>\n> ---\n>\n> \n"),
12912 ] {
12913 let mut d = doc_with(&format!("hr_narrow_{name}"), body);
12914 d.caret = caret;
12915 d.insert_thematic_break();
12916 assert_eq!(d.source, want, "{name}: the block should stay whole");
12917 }
12918 }
12919
12920 #[test]
12921 fn insert_thematic_break_replaces_the_selection() {
12922 // Now that the rule lands *at* the caret again, replacing the selection
12923 // is coherent once more: the text goes, and the rule takes its place.
12924 // The space the deletion left leading the second half is consumed by the
12925 // split rather than opening the new paragraph with it.
12926 let mut d = doc_with("hr_sel", "one two three\n");
12927 d.anchor = Some(4);
12928 d.caret = 7; // "two"
12929 d.insert_thematic_break();
12930 assert_eq!(d.source, "one \n\n---\n\nthree\n");
12931 assert_eq!(d.selection(), None);
12932 }
12933
12934 #[test]
12935 fn insert_thematic_break_clears_a_code_block_and_a_table_rather_than_refusing() {
12936 // Both are blocks the rule lands *after*. Leaf used to refuse a fence,
12937 // because writing `---` into one is code, not a rule — twig now walks out
12938 // to the block that owns the caret's line, so there is nothing to refuse.
12939 let mut code = doc_with("hr_code", "```\nfn x() {}\n```\n");
12940 code.caret = 5; // inside the fenced code
12941 code.insert_thematic_break();
12942 assert_eq!(code.source, "```\nfn x() {}\n```\n\n---\n\n");
12943 assert_eq!(code.status, None, "no refusal to report any more");
12944
12945 let mut table = doc_with("hr_table", "| a | b |\n|---|---|\n| 1 | 2 |\n");
12946 table.caret = 3; // in the header row
12947 table.insert_thematic_break();
12948 assert_eq!(table.source, "| a | b |\n|---|---|\n| 1 | 2 |\n\n---\n\n");
12949 }
12950
12951 #[test]
12952 fn insert_thematic_break_in_a_list_item_ends_the_list() {
12953 // The un-indented rule cannot continue the list, so it closes the list
12954 // and lands at the top level rather than nested inside it.
12955 let mut d = doc_with("hr_list", "- one\n- two\n");
12956 d.caret = "- one\n- tw".len(); // mid "two"
12957 d.insert_thematic_break();
12958 d.build_visual(80);
12959 let rule_at = d.source.find("---").unwrap();
12960 assert_eq!(kind_at(&mut d, rule_at), Some(Kind::ThematicBreak));
12961 assert!(
12962 !d.nodes().iter().any(|n| n.kind == Kind::BulletList
12963 && n.span.start <= rule_at
12964 && rule_at < n.span.end),
12965 "the rule must not be nested inside the list"
12966 );
12967 }
12968
12969 #[test]
12970 fn insert_thematic_break_in_a_blockquote_stays_in_the_quote() {
12971 // Leaf used to end the quote. twig gives the rule the quote's own prefix,
12972 // which is the document the gesture was actually asked for — and the
12973 // line the caret goes on to under it wears the prefix too, as the line
12974 // Enter opens in a quote does.
12975 let mut d = doc_with("hr_quote", "> hello\n");
12976 d.caret = 4; // inside the quoted text
12977 d.insert_thematic_break();
12978 assert_eq!(d.source, "> hello\n>\n> ---\n>\n> \n");
12979 assert_eq!(d.caret, "> hello\n>\n> ---\n>\n> ".len());
12980 d.build_visual(80);
12981 let rule_at = d.source.find("---").unwrap();
12982 assert_eq!(kind_at(&mut d, rule_at), Some(Kind::ThematicBreak));
12983 assert!(
12984 d.nodes().iter().any(|n| n.kind == Kind::BlockQuote
12985 && n.span.start <= rule_at
12986 && rule_at < n.span.end),
12987 "the rule belongs to the quote it was asked for"
12988 );
12989 }
12990
12991 // ── typing against a block picture ────────────────────────────────────────
12992
12993 /// A rendered-view document with the caret parked on one of the picture's two
12994 /// stops, and the map already built — the state a frontend is in between
12995 /// drawing a frame and the next keystroke.
12996 fn doc_at_picture(name: &str, src: &str, side: MediaStop) -> Doc {
12997 let mut d = doc_in(View::Wysiwyg, name, src);
12998 d.build_visual_unwrapped();
12999 let start = src.find("".len(),
13003 };
13004 d
13005 }
13006
13007 /// The block media the map publishes, after rebuilding it — "is this still a
13008 /// picture, or has it become a line of text with an image in it?"
13009 fn media_count(d: &mut Doc) -> usize {
13010 d.build_visual_unwrapped();
13011 d.vmap.media.len()
13012 }
13013
13014 #[test]
13015 fn typing_past_a_block_picture_opens_a_paragraph_under_it() {
13016 // The accident this prevents: tap the blank page under a photo (which
13017 // lands on the picture's trailing stop), type, and `xy` is a
13018 // paragraph with an *inline* image — the photo stops being drawn.
13019 let mut d = doc_at_picture("pic_after", "hi\n\n\n", MediaStop::After);
13020 d.insert("xy");
13021 assert_eq!(d.source, "hi\n\n\n\nxy\n");
13022 assert_eq!(media_count(&mut d), 1, "still a picture");
13023 }
13024
13025 #[test]
13026 fn typing_in_front_of_a_block_picture_opens_a_paragraph_above_it() {
13027 let mut d = doc_at_picture("pic_before", "hi\n\n\n", MediaStop::Before);
13028 d.insert("xy");
13029 assert_eq!(d.source, "hi\n\nxy\n\n\n");
13030 assert_eq!(media_count(&mut d), 1);
13031 }
13032
13033 #[test]
13034 fn a_picture_that_opens_the_document_still_takes_a_paragraph_above_it() {
13035 let mut d = doc_at_picture("pic_first", "\n", MediaStop::Before);
13036 d.insert("x");
13037 assert_eq!(d.source, "x\n\n\n");
13038 assert_eq!(media_count(&mut d), 1);
13039 }
13040
13041 #[test]
13042 fn one_undo_puts_the_picture_back_the_way_it_was_found() {
13043 // The opened paragraph is part of the keystroke, not an edit the writer
13044 // made — so it undoes with the character, not a step later.
13045 let mut d = doc_at_picture("pic_undo", "hi\n\n\n", MediaStop::After);
13046 d.insert("x");
13047 assert_eq!(d.source, "hi\n\n\n\nx\n");
13048 d.undo();
13049 assert_eq!(d.source, "hi\n\n\n");
13050 }
13051
13052 #[test]
13053 fn pasting_against_a_block_picture_opens_a_paragraph_too() {
13054 // ⌘V dissolves the picture exactly as a keystroke does.
13055 let mut d = doc_at_picture("pic_paste", "hi\n\n\n", MediaStop::After);
13056 d.paste("pasted");
13057 assert_eq!(d.source, "hi\n\n\n\npasted\n");
13058 assert_eq!(media_count(&mut d), 1);
13059 }
13060
13061 #[test]
13062 fn typing_beside_an_inline_image_is_ordinary_editing() {
13063 // An inline image has no placeholder row and no stops of its own. Opening
13064 // a paragraph mid-sentence would be the bug, not the fix.
13065 let mut d = doc_in(View::Wysiwyg, "pic_inline", "see  here\n");
13066 d.build_visual_unwrapped();
13067 d.caret = "see ".len();
13068 d.insert("!");
13069 assert_eq!(d.source, "see ! here\n");
13070 }
13071
13072 #[test]
13073 fn source_view_types_raw_markup_against_an_image_untouched() {
13074 // Source view is for writing the markup itself; a break inserted behind
13075 // the writer's back there would be the editor arguing with them.
13076 let mut d = doc_in(View::Source, "pic_src", "\n");
13077 d.caret = "".len();
13078 d.insert("x");
13079 assert_eq!(d.source, "x\n");
13080 }
13081
13082 #[test]
13083 fn typing_over_a_selection_that_starts_at_a_picture_stop_replaces_it() {
13084 // A selection is replaced, not joined into, so there is nothing to
13085 // protect: the range takes the picture with it.
13086 let mut d = doc_at_picture("pic_sel", "hi\n\n\n", MediaStop::Before);
13087 d.anchor = Some(d.caret);
13088 d.caret = d.source.find("".len();
13089 d.insert("x");
13090 assert_eq!(d.source, "hi\n\nx\n");
13091 }
13092
13093 #[test]
13094 fn backspace_past_a_block_picture_deletes_the_picture_not_its_last_byte() {
13095 // What this actually cost: a real vault's photo, to one stray Backspace.
13096 // The caret past `` was deleting the closing paren — invisible
13097 // in the rendered view — and the photo became the text `\n", MediaStop::After);
13099 d.backspace();
13100 assert_eq!(d.source, "hi\n");
13101 assert_eq!(media_count(&mut d), 0, "the picture went, in one piece");
13102 d.undo();
13103 assert_eq!(
13104 d.source, "hi\n\n\n",
13105 "and comes back in one piece"
13106 );
13107 }
13108
13109 #[test]
13110 fn backspace_in_front_of_a_block_picture_steps_out_instead_of_merging_it() {
13111 // Deleting the break here would join the picture to the paragraph above,
13112 // where it is an *inline* image and stops being drawn. Step over the
13113 // boundary; the next press deletes in the paragraph the caret reached.
13114 let mut d = doc_at_picture("pic_bs_before", "hi\n\n\n", MediaStop::Before);
13115 d.backspace();
13116 assert_eq!(d.source, "hi\n\n\n", "nothing deleted");
13117 assert_eq!(d.caret, 2, "the caret stepped up to the end of `hi`");
13118 d.backspace();
13119 assert_eq!(d.source, "h\n\n\n", "and now it deletes there");
13120 assert_eq!(media_count(&mut d), 1, "the picture was never at risk");
13121 }
13122
13123 #[test]
13124 fn forward_delete_in_front_of_a_block_picture_deletes_the_picture() {
13125 // The mirror. A byte-step here eats the `!` and leaves a link.
13126 let mut d = doc_at_picture("pic_del", "hi\n\n\n\nbye\n", MediaStop::Before);
13127 d.delete_forward();
13128 assert_eq!(d.source, "hi\n\nbye\n");
13129 assert_eq!(media_count(&mut d), 0);
13130 }
13131
13132 #[test]
13133 fn forward_delete_past_a_block_picture_steps_over_the_boundary() {
13134 let mut d = doc_at_picture(
13135 "pic_del_after",
13136 "hi\n\n\n\nbye\n",
13137 MediaStop::After,
13138 );
13139 d.delete_forward();
13140 assert_eq!(d.source, "hi\n\n\n\nbye\n", "nothing deleted");
13141 assert_eq!(
13142 d.caret,
13143 d.source.find("bye").unwrap(),
13144 "the caret stepped down to `bye`"
13145 );
13146 }
13147
13148 #[test]
13149 fn a_picture_that_is_the_whole_document_still_deletes_cleanly() {
13150 let mut d = doc_at_picture("pic_only", "\n", MediaStop::After);
13151 d.backspace();
13152 assert_eq!(d.source, "\n");
13153 assert_eq!(media_count(&mut d), 0);
13154 }
13155
13156 #[test]
13157 fn a_word_delete_takes_the_picture_whole_or_steps_out_of_it() {
13158 // ⌥⌫ past a picture would otherwise eat a "word" of its markup.
13159 let mut d = doc_at_picture("pic_wordbs", "hi there\n\n\n", MediaStop::After);
13160 d.delete_word_back();
13161 assert_eq!(d.source, "hi there\n");
13162
13163 // And in front of one it runs *through* the paragraph break into the
13164 // prose above, which merges the picture inline — so it steps out first,
13165 // and the second press deletes the word it was aimed at.
13166 let mut d = doc_at_picture("pic_wordbs2", "hi there\n\n\n", MediaStop::Before);
13167 d.delete_word_back();
13168 assert_eq!(d.source, "hi there\n\n\n");
13169 d.delete_word_back();
13170 assert_eq!(
13171 d.source, "hi \n\n\n",
13172 "the word above went, the picture stayed"
13173 );
13174 assert_eq!(media_count(&mut d), 1);
13175 }
13176
13177 #[test]
13178 fn source_view_deletes_raw_markup_against_an_image_untouched() {
13179 let mut d = doc_in(View::Source, "pic_src_del", "\n");
13180 d.caret = "".len();
13181 d.backspace();
13182 assert_eq!(d.source, ";
13183 }
13184
13185 // ── typing against a block leaf directive ─────────────────────────────────
13186
13187 /// A rendered-view `fmt` document with the caret on one of the stops of
13188 /// the directive spelled `mark`, and the map built — [`doc_at_picture`]
13189 /// for a directive, in either format.
13190 fn doc_at_directive(src: &str, fmt: Format, mark: &str, side: MediaStop) -> Doc {
13191 let mut d = Doc::from_source(src.into(), fmt).unwrap();
13192 d.view = View::Wysiwyg;
13193 d.build_visual_unwrapped();
13194 let start = src.find(mark).unwrap();
13195 d.caret = match side {
13196 MediaStop::Before => start,
13197 MediaStop::After => start + mark.len(),
13198 };
13199 assert!(d.vmap.is_stop(d.caret), "{src:?} {side:?}");
13200 d
13201 }
13202
13203 /// The leaf directives the map publishes, by name, after rebuilding it —
13204 /// "is this still a directive, or has it become a paragraph of source?"
13205 fn directive_names(d: &mut Doc) -> Vec<String> {
13206 d.build_visual_unwrapped();
13207 d.vmap.directives.iter().map(|i| i.name.clone()).collect()
13208 }
13209
13210 /// Markdown's `::embed{…}` and djot's empty fence, each with the source
13211 /// typing at its before and after stops should leave.
13212 const DIRECTIVE_CASES: [(Format, &str, &str, &str, &str); 3] = [
13213 (
13214 Format::Markdown,
13215 "hi\n\n::embed{src=x}\n",
13216 "::embed{src=x}",
13217 "hi\n\nB\n\n::embed{src=x}\n",
13218 "hi\n\n::embed{src=x}\n\nB\n",
13219 ),
13220 (
13221 Format::Markdown,
13222 "hi\n\n::page-break\n\nbye\n",
13223 "::page-break",
13224 "hi\n\nB\n\n::page-break\n\nbye\n",
13225 "hi\n\n::page-break\n\nB\n\nbye\n",
13226 ),
13227 (
13228 Format::Djot,
13229 "hi\n\n::: page-break\n:::\n\nbye\n",
13230 "::: page-break\n:::",
13231 "hi\n\nB\n\n::: page-break\n:::\n\nbye\n",
13232 "hi\n\n::: page-break\n:::\n\nB\n\nbye\n",
13233 ),
13234 ];
13235
13236 #[test]
13237 fn typing_past_a_leaf_directive_opens_a_paragraph_under_it() {
13238 // The task's accident: `::page[…]{…}TWO` is a paragraph of raw source.
13239 for (fmt, src, mark, _, after) in DIRECTIVE_CASES {
13240 let mut d = doc_at_directive(src, fmt, mark, MediaStop::After);
13241 let names = directive_names(&mut d);
13242 d.insert("B");
13243 assert_eq!(d.source, after, "{fmt:?}");
13244 assert_eq!(directive_names(&mut d), names, "{fmt:?}: still a directive");
13245 }
13246 }
13247
13248 #[test]
13249 fn typing_in_front_of_a_leaf_directive_opens_a_paragraph_above_it() {
13250 // Found in leaf-tui: `B` typed here made `B::embed{src=…}`.
13251 for (fmt, src, mark, before, _) in DIRECTIVE_CASES {
13252 let mut d = doc_at_directive(src, fmt, mark, MediaStop::Before);
13253 let names = directive_names(&mut d);
13254 d.insert("B");
13255 assert_eq!(d.source, before, "{fmt:?}");
13256 assert_eq!(directive_names(&mut d), names, "{fmt:?}: still a directive");
13257 }
13258 // And one that opens the document takes a paragraph above it too.
13259 let mut d = doc_at_directive(
13260 "::embed{src=x}\n",
13261 Format::Markdown,
13262 "::",
13263 MediaStop::Before,
13264 );
13265 d.insert("B");
13266 assert_eq!(d.source, "B\n\n::embed{src=x}\n");
13267 assert_eq!(directive_names(&mut d), ["embed"]);
13268 }
13269
13270 #[test]
13271 fn two_directives_in_a_row_take_a_paragraph_between_them_from_either_side() {
13272 // The task's document: no paragraph between the marks to click into, so
13273 // the end of the first mark's row, or the front of the second's, is the
13274 // only way in.
13275 let md = "::page[Page 1 of 2]{card=\"a\"}\n\n::page[Page 2 of 2]{card=\"b\"}\n";
13276 let md_want = "::page[Page 1 of 2]{card=\"a\"}\n\nTWO\n\n::page[Page 2 of 2]{card=\"b\"}\n";
13277 let dj = "::: page-break\n:::\n\n::: x-card\n:::\n";
13278 let dj_want = "::: page-break\n:::\n\nTWO\n\n::: x-card\n:::\n";
13279 for (fmt, src, first, second, want) in [
13280 (
13281 Format::Markdown,
13282 md,
13283 "::page[Page 1 of 2]{card=\"a\"}",
13284 "::page[Page 2",
13285 md_want,
13286 ),
13287 (
13288 Format::Djot,
13289 dj,
13290 "::: page-break\n:::",
13291 "::: x-card",
13292 dj_want,
13293 ),
13294 ] {
13295 for (mark, side) in [(first, MediaStop::After), (second, MediaStop::Before)] {
13296 let mut d = doc_at_directive(src, fmt, mark, side);
13297 let names = directive_names(&mut d);
13298 assert_eq!(names.len(), 2);
13299 d.insert("T");
13300 d.insert("W");
13301 d.insert("O");
13302 assert_eq!(d.source, want, "{fmt:?} {side:?}");
13303 assert_eq!(directive_names(&mut d), names, "{fmt:?} {side:?}");
13304 let labels: Vec<_> = d.vmap.directives.iter().map(|i| i.label.clone()).collect();
13305 if fmt == Format::Markdown {
13306 assert_eq!(labels, ["Page 1 of 2", "Page 2 of 2"]);
13307 assert_eq!(d.vmap.directives[1].attr("card"), Some("b"));
13308 }
13309 }
13310 }
13311 }
13312
13313 #[test]
13314 fn a_djot_directive_s_attribute_line_stays_with_it() {
13315 // The `{…}` line above the fence is the directive's markup: a paragraph
13316 // opened between the two would take the attributes, and a delete that
13317 // left the line would hand them to the paragraph below.
13318 let src = "hi\n\n{.wide src=\"x\"}\n::: x-card\n:::\n\nbye\n";
13319 let mark = "::: x-card\n:::";
13320 let mut d = doc_at_directive(src, Format::Djot, mark, MediaStop::Before);
13321 d.insert("B");
13322 assert_eq!(
13323 d.source,
13324 "hi\n\nB\n\n{.wide src=\"x\"}\n::: x-card\n:::\n\nbye\n"
13325 );
13326 assert_eq!(directive_names(&mut d), ["x-card"]);
13327 assert_eq!(d.vmap.directives[0].attr("src"), Some("x"));
13328
13329 let mut d = doc_at_directive(src, Format::Djot, mark, MediaStop::After);
13330 d.backspace();
13331 assert_eq!(
13332 d.source, "hi\n\nbye\n",
13333 "the directive went with its attributes"
13334 );
13335 let mut d = doc_at_directive(src, Format::Djot, mark, MediaStop::Before);
13336 d.delete_forward();
13337 assert_eq!(d.source, "hi\n\nbye\n");
13338
13339 // A djot block picture's attribute line is the same markup, kept the
13340 // same way.
13341 let pic = "hi\n\n{.wide}\n\n";
13342 let mut d = doc_at_directive(pic, Format::Djot, "", MediaStop::Before);
13343 d.insert("B");
13344 assert_eq!(d.source, "hi\n\nB\n\n{.wide}\n\n");
13345 assert_eq!(media_count(&mut d), 1);
13346 let mut d = doc_at_directive(pic, Format::Djot, "", MediaStop::After);
13347 d.backspace();
13348 assert_eq!(d.source, "hi\n");
13349
13350 // Where the attribute line opens the document, too.
13351 let src = "{.wide}\n::: x-card\n:::\n";
13352 let mut d = doc_at_directive(src, Format::Djot, mark, MediaStop::Before);
13353 d.insert("B");
13354 assert_eq!(d.source, "B\n\n{.wide}\n::: x-card\n:::\n");
13355 assert_eq!(directive_names(&mut d), ["x-card"]);
13356 }
13357
13358 #[test]
13359 fn a_directive_drawn_taller_still_takes_a_paragraph_under_it() {
13360 // Reserved filler rows are decoration: the after-stop is still the
13361 // directive's end, and a click on the drawing's lower part lands there.
13362 let src = "hi\n\n::embed{src=x}\n";
13363 let mut d = doc_at_directive(src, Format::Markdown, "::embed{src=x}", MediaStop::After);
13364 let key = d.vmap.directives[0].key();
13365 d.set_directive_rows(HashMap::from([(key, 4)]));
13366 d.build_visual_unwrapped();
13367 assert_eq!(d.vmap.directives[0].rows_span.len(), 4);
13368 d.insert("B");
13369 assert_eq!(d.source, "hi\n\n::embed{src=x}\n\nB\n");
13370 }
13371
13372 #[test]
13373 fn pasting_against_a_leaf_directive_opens_a_paragraph_too() {
13374 for (fmt, src, mark, before, after) in DIRECTIVE_CASES {
13375 for (side, want) in [(MediaStop::Before, before), (MediaStop::After, after)] {
13376 let mut d = doc_at_directive(src, fmt, mark, side);
13377 d.paste("pasted");
13378 assert_eq!(d.source, want.replace("B", "pasted"), "{fmt:?} {side:?}");
13379 }
13380 }
13381 }
13382
13383 #[test]
13384 fn one_undo_puts_a_directive_back_the_way_it_was_found() {
13385 for (fmt, src, mark, ..) in DIRECTIVE_CASES {
13386 for side in [MediaStop::Before, MediaStop::After] {
13387 let mut d = doc_at_directive(src, fmt, mark, side);
13388 d.insert("x");
13389 d.insert("y");
13390 assert_ne!(d.source, src);
13391 d.undo();
13392 assert_eq!(d.source, src, "{fmt:?} {side:?}");
13393 }
13394 }
13395 }
13396
13397 #[test]
13398 fn delete_keys_take_a_leaf_directive_whole_or_step_out_of_it() {
13399 // The picture's rule: the key aimed at the directive takes it whole, the
13400 // key aimed away steps over the boundary — never a byte of its markup.
13401 for (fmt, src, mark, ..) in DIRECTIVE_CASES {
13402 let gone = src
13403 .replace(&format!("{mark}\n\n"), "")
13404 .replace(&format!("\n\n{mark}"), "");
13405 let mut d = doc_at_directive(src, fmt, mark, MediaStop::After);
13406 d.backspace();
13407 assert_eq!(d.source, gone, "{fmt:?}: Backspace past it");
13408 d.undo();
13409 assert_eq!(d.source, src, "{fmt:?}: and back in one piece");
13410
13411 let mut d = doc_at_directive(src, fmt, mark, MediaStop::Before);
13412 d.delete_forward();
13413 assert_eq!(d.source, gone, "{fmt:?}: Delete in front of it");
13414
13415 let mut d = doc_at_directive(src, fmt, mark, MediaStop::Before);
13416 d.backspace();
13417 assert_eq!(d.source, src, "{fmt:?}: Backspace in front deletes nothing");
13418 assert_eq!(d.caret, 2, "{fmt:?}: it steps up to the end of `hi`");
13419
13420 let mut d = doc_at_directive(src, fmt, mark, MediaStop::After);
13421 let at = d.caret;
13422 d.delete_forward();
13423 assert_eq!(d.source, src, "{fmt:?}: Delete past it deletes nothing");
13424 assert!(
13425 d.caret > at || src.ends_with(&format!("{mark}\n")),
13426 "{fmt:?}"
13427 );
13428 }
13429 }
13430
13431 #[test]
13432 fn source_view_types_raw_markup_against_a_directive_untouched() {
13433 let mut d = doc_in(View::Source, "dir_src", "::embed{src=x}\n");
13434 d.caret = "::embed{src=x}".len();
13435 d.insert("x");
13436 assert_eq!(d.source, "::embed{src=x}x\n");
13437 }
13438
13439 #[test]
13440 fn image_destination_at_caret_reads_the_image_under_the_caret() {
13441 let mut d = doc_with("img_read", "\n");
13442 d.caret = 3; // inside the image markup
13443 assert_eq!(d.image_destination_at_caret(), Some("cat.png".to_string()));
13444 // Past the image, the caret is in no image.
13445 d.caret = "".len();
13446 assert_eq!(d.image_destination_at_caret(), None);
13447 }
13448
13449 #[test]
13450 fn set_media_rows_reserves_blank_filler_rows_the_frontend_paints_over() {
13451 // The image is one placeholder row by default, and `set_media_rows` grows
13452 // it to the height the frontend measured: the label row plus blank
13453 // `decoration` fillers that hold the vertical space a raster is drawn into.
13454 let mut d = wysiwyg_doc("img_rows", "intro\n\n\n\nend\n");
13455 assert_eq!(d.vmap.media.len(), 1);
13456 let img_row = d.vmap.media[0].rows_span.start;
13457 assert_eq!(
13458 d.vmap.media[0].rows_span,
13459 img_row..img_row + 1,
13460 "default is one row"
13461 );
13462
13463 d.set_media_rows(HashMap::from([("cat.png".to_string(), 4)]));
13464 d.build_visual(80);
13465 assert_eq!(d.vmap.media.len(), 1, "still one image, now taller");
13466 let span = d.vmap.media[0].rows_span.clone();
13467 assert_eq!(span.end - span.start, 4, "reserves the four rows asked for");
13468 // The label row carries the mark and its glyphs; the three below are blank
13469 // decoration — drawn, but no caret and no text.
13470 assert!(
13471 d.vmap.rows[span.start].media.is_some(),
13472 "mark rides the first row"
13473 );
13474 for r in (span.start + 1)..span.end {
13475 assert!(d.vmap.rows[r].decoration, "filler row {r} is decoration");
13476 assert!(d.vmap.rows[r].glyphs.is_empty(), "filler row {r} is blank");
13477 assert!(
13478 d.vmap.rows[r].media.is_none(),
13479 "only the first row is marked"
13480 );
13481 }
13482 }
13483
13484 #[test]
13485 fn a_taller_image_adds_no_caret_stops_and_motion_steps_over_its_fillers() {
13486 // The extra rows are pure spacers: the caret's only homes stay the stop in
13487 // front of the image and the one just past it, so walking the document top
13488 // to bottom visits the same offsets whether the image is 1 row or 5.
13489 let body = "ab\n\n\n\ncd\n";
13490 let stops_at = |rows: usize| -> Vec<usize> {
13491 let mut d = wysiwyg_doc("img_stops", body);
13492 if rows > 1 {
13493 d.set_media_rows(HashMap::from([("p.png".to_string(), rows)]));
13494 d.build_visual(80);
13495 }
13496 d.caret = 0;
13497 let mut seen = vec![d.caret];
13498 loop {
13499 d.move_right(false);
13500 if *seen.last().unwrap() == d.caret {
13501 break;
13502 }
13503 seen.push(d.caret);
13504 }
13505 seen
13506 };
13507 assert_eq!(
13508 stops_at(1),
13509 stops_at(5),
13510 "reserving rows must not add stops"
13511 );
13512 }
13513
13514 #[test]
13515 fn insert_link_repoints_the_link_at_a_bare_caret() {
13516 let mut d = doc_with("link_repoint", "[word](http://x.dev)\n");
13517 d.caret = 3; // in the link's text, nothing selected
13518 d.insert_link("http://y.dev");
13519 assert_eq!(d.source, "[word](http://y.dev)\n");
13520 assert_eq!(d.selected_text(), Some("word"));
13521 }
13522
13523 #[test]
13524 fn insert_link_on_an_empty_range_autolinks_a_url() {
13525 // A link with no text of its own is an autolink, and twig spells it —
13526 // `<…>` is the canonical form and needs no text typed into it, so the
13527 // caret lands after it rather than selecting a finished link.
13528 let mut d = doc_with("link_empty", "\n");
13529 d.caret = 0;
13530 d.insert_link("http://x.dev");
13531 assert_eq!(d.source, "<http://x.dev>\n");
13532 assert_eq!(d.selection(), None);
13533 assert_eq!(d.caret, 14);
13534 }
13535
13536 #[test]
13537 fn insert_link_on_an_empty_range_falls_back_for_a_non_url() {
13538 // `<./notes.md>` is literal text in both formats and `<foo>` is raw HTML
13539 // in Markdown, so a destination that can't autolink doubles as the text
13540 // instead — which is then selected, ready to be typed over.
13541 let mut d = doc_with("link_rel", "\n");
13542 d.caret = 0;
13543 d.insert_link("./notes.md");
13544 assert_eq!(d.source, "[./notes.md](./notes.md)\n");
13545 assert_eq!(d.selection(), Some((1, 11)));
13546 d.insert("Notes");
13547 assert_eq!(d.source, "[Notes](./notes.md)\n");
13548 }
13549
13550 #[test]
13551 fn insert_link_repoints_the_autolink_the_caret_stands_in() {
13552 // The autolink's text is its URL, so re-pointing replaces the whole
13553 // node — the caret must not splice a second link inside the first.
13554 let mut d = doc_with("link_repoint_auto", "see <https://x.dev> ok\n");
13555 d.caret = 10;
13556 d.insert_link("https://y.dev");
13557 assert_eq!(d.source, "see <https://y.dev> ok\n");
13558 }
13559
13560 #[test]
13561 fn code_language_reads_and_edits_through_the_fence() {
13562 let mut d = doc_with("code_lang", "```rust\nlet x = 1;\n```\n");
13563 d.caret = 10; // inside the code body
13564 assert_eq!(d.code_language_at_caret().as_deref(), Some("rust"));
13565 assert!(d.caret_in_fenced_code());
13566
13567 d.set_code_language("python");
13568 assert!(
13569 d.source.starts_with("```python\n"),
13570 "source: {:?}",
13571 d.source
13572 );
13573 assert_eq!(d.code_language_at_caret().as_deref(), Some("python"));
13574
13575 // Clearing it leaves a bare fence and no label.
13576 d.set_code_language("");
13577 assert!(d.source.starts_with("```\n"), "source: {:?}", d.source);
13578 assert_eq!(d.code_language_at_caret(), None);
13579
13580 // A caret outside any code block edits nothing.
13581 let mut p = doc_with("code_lang_none", "just prose\n");
13582 assert!(!p.caret_in_fenced_code());
13583 p.set_code_language("rust");
13584 assert_eq!(p.source, "just prose\n");
13585 }
13586
13587 #[test]
13588 fn code_language_reads_and_edits_through_a_quoted_fence() {
13589 let mut d = doc_with("code_lang_quote", "> ```rust\n> let x = 1;\n> ```\n");
13590 d.caret = d.source.find("let").unwrap();
13591 assert_eq!(d.code_language_at_caret().as_deref(), Some("rust"));
13592 assert!(d.caret_in_fenced_code());
13593 d.set_code_language("python");
13594 assert!(
13595 d.source.starts_with("> ```python\n"),
13596 "source: {:?}",
13597 d.source
13598 );
13599 assert_eq!(d.code_language_at_caret().as_deref(), Some("python"));
13600 }
13601
13602 #[test]
13603 fn a_language_the_fence_cannot_carry_is_refused_not_written() {
13604 // Markdown's info string ends at whitespace, so `two words` would write
13605 // a fence that reads back with a different language than the one asked
13606 // for. twig refuses it; leaf reports that and leaves the source alone.
13607 // The old splice trimmed the ends and wrote whatever was left.
13608 let mut d = doc_with("code_lang_bad", "```rust\nx\n```\n");
13609 d.caret = 10;
13610 d.set_code_language("two words");
13611 assert_eq!(d.source, "```rust\nx\n```\n", "source should be untouched");
13612 assert!(d.status.is_some(), "the refusal should be reported");
13613 assert_eq!(d.code_language_at_caret().as_deref(), Some("rust"));
13614 }
13615
13616 #[test]
13617 fn link_destination_at_caret_reads_both_spellings() {
13618 let mut d = doc_with("link_dest", "see [t](https://x.dev) ok\n");
13619 d.caret = 5;
13620 assert_eq!(
13621 d.link_destination_at_caret().as_deref(),
13622 Some("https://x.dev")
13623 );
13624 d.caret = 0;
13625 assert_eq!(d.link_destination_at_caret(), None);
13626
13627 // An autolink has no `destination`; its text is the URL.
13628 let mut a = doc_with("link_dest_auto", "see <https://x.dev> ok\n");
13629 a.caret = 10;
13630 assert_eq!(
13631 a.link_destination_at_caret().as_deref(),
13632 Some("https://x.dev")
13633 );
13634 a.caret = 21;
13635 assert_eq!(a.link_destination_at_caret(), None);
13636 }
13637
13638 #[test]
13639 fn heading_at_caret_is_the_nearest_heading_above() {
13640 let src = "intro\n\n# Title\n\n## The *Second* Part\n\nbody here\n";
13641 let mut d = doc_with("heading_at", src);
13642 // Under no heading at all.
13643 d.caret = 2;
13644 assert_eq!(d.heading_at_caret(), None);
13645 // In a paragraph under a level-2 heading: that heading, its markup
13646 // stripped for the slug, and its own span.
13647 d.caret = src.find("body").unwrap() + 2;
13648 let h = d.heading_at_caret().expect("under `## The Second Part`");
13649 assert_eq!(h.text, "The Second Part");
13650 assert_eq!(h.level, 2);
13651 assert_eq!(&src[h.span.clone()].trim_end(), &"## The *Second* Part");
13652 // Standing in a heading answers with that heading.
13653 let h = d.heading_at(src.find("Title").unwrap()).unwrap();
13654 assert_eq!((h.text.as_str(), h.level), ("Title", 1));
13655 // And the text is what `locate` lands a slug of.
13656 let landing = d.locate("the-second-part").unwrap();
13657 assert_eq!(landing.start, d.heading_at_caret().unwrap().span.start);
13658 }
13659
13660 #[test]
13661 fn locate_finds_the_block_a_declared_id_names() {
13662 // The Book of Mormon shape: one document per chapter, one `{#v…}` per
13663 // verse. The locator has to land on the *verse*, which is the whole
13664 // reason a link carries one.
13665 let src = "{#v1}\nI, Nephi, having been born of goodly parents.\n\n\
13666 {#v2}\nYea, I make a record in the language of my father.\n";
13667 let mut d = Doc::from_source(src.to_string(), Format::Djot).unwrap();
13668 let v2 = d.locate("v2").expect("the document declares `{#v2}`");
13669 assert_eq!(
13670 d.source[v2.start..v2.end].trim_end(),
13671 "Yea, I make a record in the language of my father."
13672 );
13673 // The attribute line is not part of it: `start` is a place to put a
13674 // caret, and `{#v2}` is markup the caret has no business landing in.
13675 assert!(d.source[..v2.start].ends_with("{#v2}\n"));
13676 assert_eq!(d.locate("v99"), None);
13677 }
13678
13679 #[test]
13680 fn locate_reads_a_heading_by_its_words_when_the_format_mints_no_ids() {
13681 // Markdown has no ids at all — twig mints none, and `{#custom}` in a
13682 // Markdown heading is literal text. So `#the-second-part` can only be
13683 // the heading's own words, which is the rule every Markdown renderer
13684 // already follows and therefore the one a link was authored against.
13685 let src = "# Title\n\nintro\n\n## The Second Part\n\nbody\n\n## Third\n\nmore\n";
13686 let mut d = doc_with("locate_md", src);
13687 let hit = d.locate("the-second-part").expect("the heading's slug");
13688 assert!(d.source[hit.start..].starts_with("## The Second Part"));
13689 // Bounded by the next heading that isn't under it, so a peek shows the
13690 // section rather than only its title.
13691 assert_eq!(
13692 &d.source[hit.start..hit.end],
13693 "## The Second Part\n\nbody\n\n"
13694 );
13695
13696 // A subsection does not end its parent: `# Title` runs to `## Third`'s
13697 // sibling only because there is no other `#`, so it covers the lot.
13698 let title = d.locate("title").expect("the top heading");
13699 assert_eq!(title.end, d.source.len());
13700 }
13701
13702 #[test]
13703 fn locate_reads_a_djot_auto_id_however_the_link_spelled_it() {
13704 // djot mints `Some-Heading-Here`; a link to it is written
13705 // `#some-heading-here` by nearly everything that writes links. Both
13706 // spellings are one question.
13707 let src = "## Some Heading Here\n\nbody\n";
13708 let mut d = Doc::from_source(src.to_string(), Format::Djot).unwrap();
13709 let exact = d.locate("Some-Heading-Here").expect("djot's own spelling");
13710 let slugged = d.locate("some-heading-here").expect("the link's spelling");
13711 assert_eq!(exact, slugged);
13712 // The section, not the heading line — there is more to show than a title.
13713 assert_eq!(&d.source[exact.start..exact.end], src);
13714 }
13715
13716 #[test]
13717 fn locate_ignores_an_empty_locator_and_one_that_slugs_to_nothing() {
13718 let mut d = doc_with("locate_empty", "# Title\n\nbody\n");
13719 assert_eq!(d.locate(""), None);
13720 assert_eq!(d.locate(" "), None);
13721 // All punctuation: it names nothing, and must not be read as "match the
13722 // first heading whose slug is also empty".
13723 assert_eq!(d.locate("!!!"), None);
13724 }
13725
13726 #[test]
13727 fn locate_gives_a_duplicated_id_to_the_first_block_that_claims_it() {
13728 // The document's mistake, and the answer every other anchor
13729 // implementation gives — the alternative is for a link to mean whichever
13730 // of the two a walk happened to reach first.
13731 let src = "{#dup}\nfirst.\n\n{#dup}\nsecond.\n";
13732 let mut d = Doc::from_source(src.to_string(), Format::Djot).unwrap();
13733 let hit = d.locate("dup").expect("the first `{#dup}`");
13734 assert_eq!(d.source[hit.start..hit.end].trim_end(), "first.");
13735 }
13736
13737 #[test]
13738 fn insert_footnote_writes_both_halves_and_lands_the_caret_in_the_note() {
13739 // The button's whole job: a reference where the caret was, a definition
13740 // to give it meaning, and the caret waiting in the empty note so the
13741 // next keystroke is the note's first word.
13742 let mut d = doc_with("fn_insert", "A claim and more.\n");
13743 d.caret = 7; // just past "A claim"
13744 d.insert_footnote();
13745 assert!(
13746 d.source.starts_with("A claim[^1] and more."),
13747 "{:?}",
13748 d.source
13749 );
13750 assert!(
13751 d.source.contains("[^1]:"),
13752 "the definition too: {:?}",
13753 d.source
13754 );
13755 assert_eq!(d.status, None);
13756
13757 let reference = d.source.find("[^1]").unwrap();
13758 let note = d
13759 .footnote_at(reference + 2)
13760 .expect("the reference just written");
13761 assert_eq!(note.label, "1");
13762 assert_eq!(note.text.as_deref(), Some(""), "the note starts empty");
13763 assert_eq!(Some(d.caret), note.offset, "the caret waits in the note");
13764 // …and typing there is typing into the note, not near it.
13765 d.insert("the note");
13766 assert_eq!(
13767 d.footnote_at(reference + 2).and_then(|f| f.text),
13768 Some("the note".to_string())
13769 );
13770 }
13771
13772 #[test]
13773 fn insert_footnote_numbers_past_the_notes_already_written() {
13774 // A second press must not hand back a label somebody else is using: twig
13775 // reuses a defined label rather than appending a rival definition, so a
13776 // repeat of `1` would quietly point the new reference at the old note.
13777 let mut d = doc_with("fn_insert_number", "One[^1] two.\n\n[^1]: first\n");
13778 d.caret = 7; // past `[^1]`, before " two."
13779 d.insert_footnote();
13780 assert!(d.source.starts_with("One[^1][^2] two."), "{:?}", d.source);
13781 assert_eq!(d.source.matches("[^2]:").count(), 1);
13782 }
13783
13784 #[test]
13785 fn insert_footnote_counts_a_dangling_reference_and_ignores_a_named_one() {
13786 // `[^2]` with no definition is still a 2 that means something to whoever
13787 // wrote it — stepping over it would mint a note for their reference. A
13788 // word label takes no number, so it blocks none.
13789 let mut d = doc_with("fn_insert_dangling", "a[^2] b[^why] c\n\n[^why]: named\n");
13790 d.caret = d.source.find(" c").unwrap();
13791 d.insert_footnote();
13792 assert!(d.source.contains("[^1]:"), "1 is free: {:?}", d.source);
13793 assert!(
13794 d.source.starts_with("a[^2] b[^why][^1] c"),
13795 "{:?}",
13796 d.source
13797 );
13798 }
13799
13800 #[test]
13801 fn insert_footnote_marks_the_selection_rather_than_replacing_it() {
13802 // A reference annotates the words before it. Consuming the selection —
13803 // which is what an insert normally does — would delete the very claim
13804 // the author selected in order to footnote.
13805 let mut d = doc_with("fn_insert_sel", "A claim and more.\n");
13806 d.anchor = Some(2);
13807 d.caret = 7; // "claim" selected
13808 d.insert_footnote();
13809 assert!(
13810 d.source.starts_with("A claim[^1] and more."),
13811 "{:?}",
13812 d.source
13813 );
13814 }
13815
13816 #[test]
13817 fn a_note_just_written_still_knows_where_its_reference_is() {
13818 // The authoring loop in one test: press the button, type the note, ask to
13819 // go back. The caret ends at the note's last byte — which is the *end* of
13820 // the definition's span, the one offset the query used to exclude — so
13821 // this is where the round trip either works or doesn't.
13822 let mut d = doc_with("fn_insert_return", "A claim and more.\n");
13823 d.caret = 7;
13824 d.insert_footnote();
13825 d.insert("the note");
13826 assert_eq!(d.source, "A claim[^1] and more.\n\n[^1]: the note\n");
13827 let back = d
13828 .footnote_definition_at_caret()
13829 .expect("still in the note we just typed");
13830 assert_eq!(back.label, "1");
13831 // …and following it lands on the reference's label, where a reader's
13832 // return leg lands.
13833 assert_eq!(back.offset, Some(9));
13834 assert_eq!(&d.source[9..10], "1");
13835 }
13836
13837 #[test]
13838 fn insert_footnote_takes_one_undo_for_both_halves() {
13839 // twig writes the pair as a single edit; the point of that is here.
13840 let before = "A claim and more.\n";
13841 let mut d = doc_with("fn_insert_undo", before);
13842 d.caret = 7;
13843 d.insert_footnote();
13844 assert_ne!(d.source, before);
13845 d.undo();
13846 assert_eq!(d.source, before, "one undo takes back both halves");
13847 }
13848
13849 #[test]
13850 fn insert_footnote_refuses_a_format_that_cannot_spell_one() {
13851 // HTML is authorable — it spells the inline marks — and has no footnote.
13852 // The refusal says so rather than writing brackets that would render as
13853 // brackets.
13854 let src = "<p>A claim.</p>\n";
13855 let mut d = Doc::from_source(src.to_string(), Format::Html).unwrap();
13856 assert!(!Capabilities::of(Format::Html).footnote);
13857 d.caret = 5;
13858 d.insert_footnote();
13859 assert_eq!(d.source, src, "nothing written");
13860 assert!(d.status.is_some_and(|s| s.starts_with("footnote:")));
13861 }
13862
13863 #[test]
13864 fn insert_footnote_leaves_the_caret_on_a_real_stop_in_the_rich_view() {
13865 // The empty body is the one place this could go wrong: the definition
13866 // renders as a `[1] ` marker the caret cannot occupy, so a caret aimed a
13867 // byte early would draw up in the paragraph above the note it belongs to.
13868 let mut d = doc_in(View::Wysiwyg, "fn_insert_stop", "A claim and more.\n");
13869 d.place_caret(7, false);
13870 d.insert_footnote();
13871 d.build_visual(80); // the frame a frontend draws after the edit
13872 assert_eq!(
13873 d.vmap.snap_to_stop(d.caret),
13874 d.caret,
13875 "the caret sits on a stop"
13876 );
13877 let (row, _) = d.caret_pos();
13878 assert!(
13879 drawn_rows(&d)[row].contains("[1]"),
13880 "the caret is on the note's row, not above it: {:?}",
13881 drawn_rows(&d)
13882 );
13883 }
13884
13885 #[test]
13886 fn footnote_at_caret_resolves_a_reference_to_its_note() {
13887 // `[^1]` spans 7..11; its label byte is at 9. The definition follows a
13888 // blank line, as one has to.
13889 let mut d = doc_with("fn_at_caret", "A claim[^1] and more.\n\n[^1]: the note\n");
13890 d.caret = 9;
13891 let f = d
13892 .footnote_at_caret()
13893 .expect("the caret stands in a reference");
13894 assert_eq!(f.label, "1");
13895 assert_eq!(f.text.as_deref(), Some("the note"));
13896 // The offset points at the note's first word, not at the definition's
13897 // `[` — the marker is decoration with no caret stop on it.
13898 assert_eq!(f.offset, Some(29));
13899 assert_eq!(&d.source[29..37], "the note");
13900 // …and `end` closes the range, so a frontend can ask which rendered rows
13901 // the note occupies rather than re-deriving them from the text.
13902 assert_eq!(f.end, Some(37));
13903 assert_eq!(&d.source[f.offset.unwrap()..f.end.unwrap()], "the note");
13904 }
13905
13906 /// Two definitions in a row: each is its own note, and neither reaches into
13907 /// the other.
13908 ///
13909 /// A djot definition's span used to run past the blank line into the first
13910 /// byte of whatever followed, so this answered `"first note.\n\n["` — and the
13911 /// offsets named the *next* note's rows too, showing a reader two footnotes
13912 /// when they had asked about one. twig 3.1 ends the span after the block's
13913 /// own last line; the test outlives the workaround leaf carried for it.
13914 #[test]
13915 fn footnote_at_stops_a_note_at_the_definition_after_it() {
13916 let src = "Claim[^2a] and [^2b].\n\n[^2a]: first note.\n\n[^2b]: second note.\n";
13917 for format in [Format::Markdown, Format::Djot] {
13918 let mut d = Doc::from_source(src.to_string(), format).unwrap();
13919 d.caret = 7;
13920 let f = d.footnote_at_caret().expect("a reference");
13921 assert_eq!(f.text.as_deref(), Some("first note."), "in {format:?}");
13922 assert_eq!(
13923 &src[f.offset.unwrap()..f.end.unwrap()],
13924 "first note.",
13925 "in {format:?}"
13926 );
13927 }
13928 }
13929
13930 /// The other side of that boundary: a blank line *inside* a definition is
13931 /// interior to it, and the note keeps its second paragraph.
13932 ///
13933 /// This is what the old body scan cost. It stopped at the first line not
13934 /// indented under the note — a blank line is not — so a two-paragraph note
13935 /// came back as its first paragraph, and "go to note" framed half of it.
13936 /// Reading the span twig gives is both simpler and right.
13937 #[test]
13938 fn footnote_at_keeps_a_notes_second_paragraph() {
13939 let src = "Claim[^1].\n\n[^1]: first para.\n\n second para.\n\nAfter.\n";
13940 let mut d = Doc::from_source(src.to_string(), Format::Djot).unwrap();
13941 d.caret = 7;
13942 let f = d.footnote_at_caret().expect("a reference");
13943 assert_eq!(f.text.as_deref(), Some("first para.\n\n second para."));
13944 // And it stops there — `After.` is the next block, not more note.
13945 assert_eq!(
13946 &src[f.offset.unwrap()..f.end.unwrap()],
13947 f.text.as_deref().unwrap()
13948 );
13949 assert!(!f.text.as_deref().unwrap().contains("After"));
13950 }
13951
13952 #[test]
13953 fn footnote_at_bounds_a_note_whose_body_is_empty() {
13954 // `[^1]:` with nothing after it. The range is empty rather than
13955 // inverted, and still points inside the definition — which is what keeps
13956 // a frontend's row lookup from walking off into the block above.
13957 let src = "A claim[^1].\n\n[^1]:\n";
13958 let mut d = doc_with("fn_empty_body", src);
13959 d.caret = 9;
13960 let f = d.footnote_at_caret().expect("a reference");
13961 assert_eq!(f.text.as_deref(), Some(""));
13962 assert_eq!(f.offset, f.end, "an empty note is an empty range");
13963 assert!(f.offset.unwrap() >= src.find("[^1]:").unwrap());
13964 }
13965
13966 #[test]
13967 fn footnote_at_caret_ignores_a_caret_that_stands_in_no_reference() {
13968 let mut d = doc_with(
13969 "fn_at_caret_none",
13970 "A claim[^1] and more.\n\n[^1]: the note\n",
13971 );
13972 d.caret = 2; // in the prose
13973 assert_eq!(d.footnote_at_caret(), None);
13974 }
13975
13976 #[test]
13977 fn footnote_at_caret_is_not_a_link_query_and_vice_versa() {
13978 // The two are deliberately separate: a reference names a note in this
13979 // document, a link names somewhere to leave for, and answering one with
13980 // the other is what made a reference click do nothing at all.
13981 let mut d = doc_with("fn_vs_link", "a[^1] b [t](https://x.dev)\n\n[^1]: note\n");
13982 d.caret = 3; // the `1` of `[^1]`
13983 assert!(d.footnote_at_caret().is_some());
13984 assert_eq!(
13985 d.link_destination_at_caret(),
13986 None,
13987 "a reference is not a link"
13988 );
13989
13990 d.caret = 10; // inside the link's label
13991 assert_eq!(d.footnote_at_caret(), None, "a link is not a reference");
13992 assert_eq!(
13993 d.link_destination_at_caret().as_deref(),
13994 Some("https://x.dev")
13995 );
13996 }
13997
13998 #[test]
13999 fn footnote_at_caret_reports_an_undefined_reference_rather_than_nothing() {
14000 // A `[^99]` the document never defines is a real state — a note deleted
14001 // out from under its reference — and the label is what lets a frontend
14002 // say so. `None` here would be indistinguishable from "not on a
14003 // reference", which is the wrong thing to tell a reader.
14004 let mut d = doc_with("fn_undefined", "A claim[^99] and more.\n");
14005 d.caret = 9;
14006 let f = d
14007 .footnote_at_caret()
14008 .expect("the reference is still a reference");
14009 assert_eq!(f.label, "99");
14010 assert_eq!(f.text, None);
14011 assert_eq!(f.offset, None);
14012 }
14013
14014 #[test]
14015 fn footnote_at_caret_reads_a_word_label_and_a_multiline_note() {
14016 // Labels are not always numbers, and a note's body runs past its first
14017 // line — the indented continuation belongs to the note, so it comes back
14018 // with it (source bytes, verbatim, as documented).
14019 let src = "see[^note] here\n\n[^note]: first line\n second line\n";
14020 let mut d = doc_with("fn_word_label", src);
14021 d.caret = 6;
14022 let f = d
14023 .footnote_at_caret()
14024 .expect("the caret stands in a reference");
14025 assert_eq!(f.label, "note");
14026 assert_eq!(f.text.as_deref(), Some("first line\n second line"));
14027 }
14028
14029 #[test]
14030 fn footnote_at_answers_for_an_offset_the_caret_is_nowhere_near() {
14031 // The point of the offset form: a pointer hovering a reference asks what
14032 // note it names, and must not drag the caret along to ask.
14033 let mut d = doc_with("fn_at_off", "A claim[^1] and more.\n\n[^1]: the note\n");
14034 d.caret = 0;
14035 let f = d.footnote_at(9).expect("offset 9 stands in the reference");
14036 assert_eq!(f.label, "1");
14037 assert_eq!(f.text.as_deref(), Some("the note"));
14038 assert_eq!(d.caret, 0, "asking must not move the caret");
14039 assert_eq!(d.footnote_at(2), None, "offset 2 is prose");
14040 }
14041
14042 #[test]
14043 fn footnote_definition_at_caret_points_back_at_the_reference() {
14044 // The return leg. `[^1]` spans 7..11, so its label — the only byte of it
14045 // the caret can rest on — is at 9.
14046 let mut d = doc_with("fn_def", "A claim[^1] and more.\n\n[^1]: the note\n");
14047 d.caret = 30; // inside the note's body
14048 let f = d
14049 .footnote_definition_at_caret()
14050 .expect("the caret stands in a definition");
14051 assert_eq!(f.label, "1");
14052 assert_eq!(f.offset, Some(9));
14053 assert_eq!(&d.source[7..11], "[^1]");
14054 }
14055
14056 #[test]
14057 fn footnote_definition_at_covers_where_a_go_to_note_actually_lands() {
14058 // The two legs have to meet: wherever `footnote_at` sends the caret, the
14059 // definition query must answer for — otherwise arriving at a note leaves
14060 // the reader somewhere the way back isn't offered.
14061 let src = "A claim[^1] and more.\n\n[^1]: the note\n";
14062 let mut d = doc_with("fn_def_marker", src);
14063 let landed = d.footnote_at(9).unwrap().offset.unwrap();
14064 assert_eq!(
14065 d.footnote_definition_at(landed).and_then(|f| f.offset),
14066 Some(9),
14067 "the note a reference sends you to offers the way back"
14068 );
14069 }
14070
14071 #[test]
14072 fn footnote_definition_at_caret_ignores_prose_and_the_reference_itself() {
14073 // The two queries answer for disjoint places, which is what lets one
14074 // gesture mean "down to the note" in one and "back up" in the other
14075 // without either having to remember which way the reader is going.
14076 let mut d = doc_with("fn_def_none", "A claim[^1] and more.\n\n[^1]: the note\n");
14077 d.caret = 2; // prose
14078 assert_eq!(d.footnote_definition_at_caret(), None);
14079 d.caret = 9; // the reference
14080 assert_eq!(d.footnote_definition_at_caret(), None);
14081 assert!(
14082 d.footnote_at_caret().is_some(),
14083 "which is the reference's own query"
14084 );
14085 }
14086
14087 #[test]
14088 fn footnote_definition_at_caret_reports_an_orphan_note_rather_than_nothing() {
14089 // Nothing cites `[^2]`. Answering `None` would say "you are not in a
14090 // note", which is false and leaves a frontend unable to explain why the
14091 // way back is missing.
14092 let src = "A claim[^1].\n\n[^1]: cited\n\n[^2]: orphan\n";
14093 let mut d = doc_with("fn_def_orphan", src);
14094 d.caret = src.find("orphan").unwrap();
14095 let f = d
14096 .footnote_definition_at_caret()
14097 .expect("an orphan is still a definition");
14098 assert_eq!(f.label, "2");
14099 assert_eq!(f.offset, None);
14100 }
14101
14102 #[test]
14103 fn footnote_definition_at_caret_returns_to_the_first_of_repeated_references() {
14104 // One label, cited twice. The first is where the reader most likely came
14105 // from, and the only answer that doesn't depend on how they got here.
14106 let src = "One[^a] and two[^a].\n\n[^a]: the note\n";
14107 let mut d = doc_with("fn_def_repeat", src);
14108 d.caret = src.find("the note").unwrap();
14109 let f = d.footnote_definition_at_caret().expect("a definition");
14110 assert_eq!(
14111 f.offset,
14112 Some(5),
14113 "the first `[^a]`'s label, not the second's"
14114 );
14115 assert_eq!(&src[3..7], "[^a]");
14116 }
14117
14118 #[test]
14119 fn footnote_navigation_is_a_round_trip_through_placed_carets() {
14120 // Down and back up, each leg found from the document rather than from a
14121 // memory of the other — so it still works for a reader who scrolled to
14122 // the notes instead of jumping there.
14123 //
14124 // `place_caret` rather than assigning `caret`, because that is what a
14125 // frontend calls: it snaps to a real caret stop, and a jump that lands
14126 // on a byte the caret can't rest on would arrive somewhere the return
14127 // leg no longer answers for. `build_map` first, since snapping is a
14128 // no-op until the map exists — which is exactly how this went unnoticed
14129 // when the offsets pointed at the `[^` markers.
14130 let mut d = doc_with("fn_round", "A claim[^1] and more.\n\n[^1]: the note\n");
14131 d.build_map(None);
14132 d.place_caret(9, false);
14133 let down = d
14134 .footnote_at_caret()
14135 .expect("a reference")
14136 .offset
14137 .expect("a note");
14138 d.place_caret(down, false);
14139 let up = d
14140 .footnote_definition_at_caret()
14141 .expect("a definition")
14142 .offset
14143 .expect("a reference");
14144 d.place_caret(up, false);
14145 assert_eq!(d.caret, up, "the way back is a stop the caret can occupy");
14146 assert_eq!(
14147 d.footnote_at_caret().expect("back on the reference").label,
14148 "1"
14149 );
14150 }
14151
14152 #[test]
14153 fn insert_link_hands_the_destination_to_twig_raw() {
14154 // Escaping is twig's, and format-specific: Markdown ends a destination
14155 // at the first space and needs the `<…>` form, where djot would read
14156 // those angle brackets as part of the URL.
14157 let mut d = doc_with("link_space", "word\n");
14158 d.anchor = Some(0);
14159 d.caret = 4;
14160 d.insert_link("a b");
14161 assert_eq!(d.source, "[word](<a b>)\n");
14162 }
14163
14164 #[test]
14165 fn insert_link_reports_a_destination_no_format_can_carry() {
14166 let mut d = doc_with("link_bad", "word\n");
14167 d.anchor = Some(0);
14168 d.caret = 4;
14169 d.insert_link("a\nb");
14170 assert_eq!(d.source, "word\n"); // untouched, not quietly rewritten
14171 assert!(
14172 d.status.is_some(),
14173 "InvalidArgument should reach the status line"
14174 );
14175 assert!(!d.dirty);
14176 }
14177
14178 #[test]
14179 fn insert_link_works_in_wysiwyg_view() {
14180 let mut d = wysiwyg_doc("link_wys", "word here\n");
14181 d.anchor = Some(0);
14182 d.caret = 4;
14183 d.insert_link("http://x.dev");
14184 assert_eq!(d.source, "[word](http://x.dev) here\n");
14185 assert_eq!(d.selected_text(), Some("word"));
14186 // The map the caret has to keep riding is rebuilt each frame; motion
14187 // over the fresh one must still land on a real stop (the debug_assert).
14188 d.build_visual(80);
14189 d.move_right(false);
14190 d.move_left(false);
14191 }
14192
14193 #[test]
14194 fn click_maps_a_row_col_to_a_byte_offset() {
14195 let mut d = doc_with("click", "ab\ncd\n");
14196 d.click(1, 1, false); // row 1 ("cd"), col 1 -> the 'd'
14197 assert_eq!(d.caret, 4);
14198 }
14199
14200 // A pixel-hit-test placement (the GUI's `place_caret`) must land on a caret
14201 // stop just as the `(row, col)` click path does, so the caret can never come
14202 // to rest in the blank gap between two paragraphs — where it would draw in one
14203 // place and type in another.
14204 #[test]
14205 fn place_caret_snaps_out_of_the_blank_gap_between_paragraphs() {
14206 // "A\n\nB": offset 2 is the gap the paragraph break is drawn with, not a
14207 // caret stop (stops are 0,1,3,4).
14208 let mut d = wysiwyg_doc("place_gap", "A\n\nB");
14209 assert!(!d.vmap.is_stop(2), "offset 2 should be an unreachable gap");
14210 d.place_caret(2, false);
14211 assert!(d.vmap.is_stop(d.caret), "caret {} is not a stop", d.caret);
14212 assert_eq!(d.caret, 1, "should snap to the end of the paragraph above");
14213 }
14214
14215 #[test]
14216 fn place_caret_dragging_through_the_gap_keeps_selection_on_stops() {
14217 let mut d = wysiwyg_doc("place_gap_drag", "A\n\nB");
14218 d.place_caret(0, false); // anchor at the start of "A"
14219 d.place_caret(2, true); // drag into the gap
14220 assert!(d.vmap.is_stop(d.caret), "caret {} is not a stop", d.caret);
14221 let (s, e) = d.selection().expect("a selection");
14222 assert!(
14223 d.vmap.is_stop(s) && d.vmap.is_stop(e),
14224 "selection {s}..{e} off a stop"
14225 );
14226 }
14227
14228 #[test]
14229 fn place_caret_on_a_real_stop_is_left_untouched() {
14230 let mut d = wysiwyg_doc("place_stop", "A\n\nB");
14231 d.place_caret(3, false); // the start of "B" — a genuine stop
14232 assert_eq!(d.caret, 3);
14233 }
14234
14235 // An *empty paragraph* (two blank lines, an intentional blank line the user
14236 // opened) is a real caret stop, unlike the gap — a click into it must stay.
14237 #[test]
14238 fn place_caret_rests_in_an_empty_paragraph() {
14239 let mut d = wysiwyg_doc("place_empty_para", "A\n\n\n\nB");
14240 let empty = 3; // the navigable empty row's offset (stops: 0,1,3,5,6)
14241 assert!(d.vmap.is_stop(empty));
14242 d.place_caret(empty, false);
14243 assert_eq!(d.caret, empty);
14244 }
14245
14246 // The content end of a hidden mark is a home too (`VisualMap::mark_ends`):
14247 // a drag over the word `bold` ends there, and a caret placed there stays.
14248 #[test]
14249 fn place_caret_rests_at_the_end_of_a_hidden_marks_content() {
14250 let src = "| A | B |\n| --- | --- |\n| **bold** | other |\n";
14251 let mut d = wysiwyg_doc("place_mark_end", src);
14252 let start = src.find("bold").unwrap();
14253 d.place_caret(start, false);
14254 d.place_caret(start + 4, true);
14255 assert_eq!(d.selection(), Some((start, start + 4)), "the whole word");
14256 d.toggle(InlineKind::Strong);
14257 assert_eq!(d.source, src.replace("**bold**", "bold"));
14258 }
14259
14260 #[test]
14261 fn right_steps_onto_the_end_of_a_mark_and_then_past_its_delimiter() {
14262 let mut d = wysiwyg_doc("right_mark_end", "a **bold** b");
14263 d.caret = 7; // before the `d`
14264 d.move_right(false);
14265 assert_eq!(d.caret, 8, "onto the end of the bold");
14266 assert!(d.active_inline_marks().contains(InlineKind::Strong));
14267 d.move_right(false);
14268 assert_eq!(d.caret, 10, "past the closing `**`");
14269 assert!(!d.active_inline_marks().contains(InlineKind::Strong));
14270 d.move_left(false);
14271 assert_eq!(d.caret, 8);
14272 d.move_left(false);
14273 assert_eq!(d.caret, 7);
14274 // Typing at the inner home extends the bold.
14275 d.caret = 8;
14276 d.insert("!");
14277 assert_eq!(d.source, "a **bold!** b");
14278 }
14279
14280 #[test]
14281 fn a_marks_end_home_follows_an_edit_through_the_incremental_map() {
14282 // The splice path shifts the home with the block it is in, and the
14283 // re-rendered block finds its own again.
14284 let mut d = wysiwyg_doc("mark_end_splice", "x\n\na **bold** b\n\ny\n");
14285 d.build_visual_unwrapped();
14286 d.edit(0, 0, "zz");
14287 d.build_visual_unwrapped();
14288 wysiwyg::assert_maps_eq(&d.vmap, &reference_map(&d.source), "after a shift");
14289 assert!(d.vmap.is_stop(d.source.find("bold").unwrap() + 4));
14290 let at = d.source.find("bold").unwrap();
14291 d.edit(at, at, "very ");
14292 d.build_visual_unwrapped();
14293 wysiwyg::assert_maps_eq(&d.vmap, &reference_map(&d.source), "after a re-render");
14294 assert!(d.vmap.is_stop(d.source.find("bold").unwrap() + 4));
14295 }
14296
14297 fn wysiwyg_doc(name: &str, body: &str) -> Doc {
14298 doc_in(View::Wysiwyg, name, body)
14299 }
14300
14301 /// How many list items the source actually parses into — the check that a
14302 /// marker Leaf wrote is a marker the format agrees is one.
14303 fn list_items(doc: &mut Doc) -> usize {
14304 doc.editor
14305 .nodes()
14306 .unwrap()
14307 .iter()
14308 .filter(|n| n.kind == Kind::ListItem || n.kind == Kind::TaskListItem)
14309 .count()
14310 }
14311
14312 /// A from-scratch, cache-free WYSIWYG map for `source` — the ground truth the
14313 /// incremental (`build_spliced` / `build_cached`) path must always match.
14314 fn reference_map(source: &str) -> crate::wysiwyg::VisualMap {
14315 reference_map_revealing(source, None)
14316 }
14317
14318 /// [`reference_map`] with a reveal line — the ground truth for the
14319 /// `MarkupMode::Full` builds, where the map is a function of the caret's
14320 /// line as well as the text.
14321 fn reference_map_revealing(source: &str, reveal: Option<Reveal>) -> crate::wysiwyg::VisualMap {
14322 // The same parse `Doc` uses. With twig's plain defaults instead, the two
14323 // sides disagree on what the *document* is before the renderer is even
14324 // reached — a bare `:word` is a text directive to one and prose to the
14325 // other — and the mismatch reads as a splice bug that isn't one.
14326 let mut ed =
14327 twig::Editor::new_ext(source.as_bytes(), Format::Markdown, parse_extensions()).unwrap();
14328 let nodes = ed.nodes().unwrap();
14329 crate::wysiwyg::build(
14330 &nodes,
14331 source,
14332 None,
14333 false,
14334 &wysiwyg::Surface::default(),
14335 reveal,
14336 )
14337 }
14338
14339 fn maps_differ(a: &crate::wysiwyg::VisualMap, b: &crate::wysiwyg::VisualMap) -> bool {
14340 if a.rows.len() != b.rows.len() {
14341 return true;
14342 }
14343 for (ra, rb) in a.rows.iter().zip(&b.rows) {
14344 if ra.end_src != rb.end_src || ra.glyphs.len() != rb.glyphs.len() {
14345 return true;
14346 }
14347 for (ga, gb) in ra.glyphs.iter().zip(&rb.glyphs) {
14348 if ga.ch != gb.ch || ga.src != gb.src {
14349 return true;
14350 }
14351 }
14352 }
14353 false
14354 }
14355
14356 #[test]
14357 fn incremental_build_matches_a_fresh_build_across_edits() {
14358 // Every `Doc` edit rebuilds through `build_spliced` (the single-block
14359 // fast path, gated on twig's `dirty_range`) or falls back to
14360 // `build_cached`. After each edit the map must be byte-identical to a
14361 // from-scratch build — this is the correctness net under the splice.
14362 let docs = [
14363 "# Title\n\nThe quick brown fox jumps.\n\nAnother paragraph here.\n\n- a\n- b\n",
14364 "para one\n\n> quote **bold** text\n> continued line\n\ntail paragraph\n",
14365 "alpha\n\nbeta\n\ngamma\n\ndelta\n\nepsilon\n\nzeta\n",
14366 // A footnote definition is a root beside `doc`, merged back into the
14367 // top-level list by `wysiwyg::top_blocks`. The random edits below
14368 // make and unmake definitions as they go (a deleted `:` turns one
14369 // back into a paragraph, and vice versa), which is exactly the
14370 // structural churn the splice path has to notice and bail out of.
14371 "text[^1] here\n\n[^1]: the note\n\nmore text[^b]\n\n[^b]: second\n",
14372 // A comment is a top-level block that draws no rows — a layout entry
14373 // at zero rows either side of blocks that do. The edits below type
14374 // into the blocks around it (a splice past a hidden block), and
14375 // break the comment open into prose and back (a structural change).
14376 "intro\n\n<!-- exec -->\n```\ncode\n```\n\nafter the comment\n\n<!-- trail -->\n",
14377 // Link reference definitions: a hidden block that an edit can turn
14378 // into a paragraph (a deleted `:`) and back, and whose own bytes an
14379 // edit can land in.
14380 "see [a] and [b]\n\n[a]: /a\n\nmid text\n\n[b]: /b\n",
14381 // Blank lines above the first block draw as rows, counted as its
14382 // separator; above it past frontmatter and past a comment too.
14383 "\n\nfirst pushed down\n\nsecond\n",
14384 "---\ntitle: x\n---\n\n\nafter frontmatter\n\nmore\n",
14385 "<!-- lead -->\n\n\nafter a comment\n\nmore\n",
14386 // No `<div>` here yet: an edit that takes the blank line under
14387 // `</div>` leaves a map no fresh build draws. See
14388 // docs/tasks/text-glued-under-a-closing-div.md.
14389 ];
14390 // A deterministic mix: mostly single characters (which stay inside one
14391 // block → splice), plus edits that reshape structure (a paragraph break,
14392 // a heading marker, a code fence → fallback), so both paths are exercised.
14393 let inserts = ["x", "y", "\n\n", "#", "`", " ", "z"];
14394 for src in docs {
14395 let mut d = wysiwyg_doc("diff", src);
14396 d.build_visual_unwrapped();
14397 wysiwyg::assert_maps_eq(&d.vmap, &reference_map(&d.source), "initial");
14398
14399 for step in 0..60usize {
14400 let len = d.source.len();
14401 let raw = (step * 13 + 5) % (len + 1);
14402 let pos = (raw..=len).find(|&i| d.source.is_char_boundary(i)).unwrap();
14403 let pre = d.source.clone();
14404 let action;
14405 if step % 3 == 0 && pos < len {
14406 let end = (pos + 1..=len)
14407 .find(|&i| d.source.is_char_boundary(i))
14408 .unwrap();
14409 action = format!("delete [{pos},{end})");
14410 d.edit(pos, end, "");
14411 } else {
14412 let ins = inserts[step % inserts.len()];
14413 action = format!("insert {ins:?} @ {pos}");
14414 d.edit(pos, pos, ins);
14415 }
14416 d.build_visual_unwrapped();
14417 if maps_differ(&d.vmap, &reference_map(&d.source)) {
14418 panic!(
14419 "FIRST MISMATCH at step {step}: {action}\n pre = {pre:?}\n post = {:?}",
14420 d.source
14421 );
14422 }
14423 }
14424 }
14425 }
14426
14427 /// A frontend is handed [`Doc::vmap`] and may present it differently:
14428 /// leaf-ratatui splices blank filler rows under an oversized heading so the
14429 /// raster it paints there has somewhere to stand, and leaves them in the map
14430 /// because the caret and the mouse both read it between frames. The splice
14431 /// path addresses that map by *row index*, against the block layout the last
14432 /// build recorded — so handed a map with rows in it that no block owns, it
14433 /// laid the re-rendered block over one of the fillers and carried the rows
14434 /// the block really occupied into the suffix. One stranded copy of the
14435 /// edited line, and everything below it a row further down, per keystroke.
14436 ///
14437 /// A map that isn't the one the layout describes is a map this path can't
14438 /// patch, whoever changed it and for whatever reason. It rebuilds instead.
14439 #[test]
14440 fn an_edit_over_a_map_a_frontend_reshaped_rebuilds_it_whole() {
14441 let mut d = wysiwyg_doc("reshaped", "# Title\n\nThe quick brown fox jumps.\n");
14442 d.build_visual_unwrapped();
14443
14444 // Stand in for the heading filler rows: two blank rows past the heading
14445 // that no block accounts for. Cloning a real row keeps every field
14446 // plausible — it is the row *count* the splice can't survive.
14447 let filler = d.vmap.rows[0].clone();
14448 d.vmap.rows.insert(1, filler.clone());
14449 d.vmap.rows.insert(1, filler);
14450
14451 // An edit inside the last block: the single-block case the splice path
14452 // is for, and the one the frontend hits on every keystroke.
14453 let at = d.source.len() - 1;
14454 d.edit(at, at, "!");
14455 d.build_visual_unwrapped();
14456
14457 wysiwyg::assert_maps_eq(&d.vmap, &reference_map(&d.source), "after the edit");
14458 }
14459
14460 /// A glyph's [`FaceId`] has to mean the same thing however its row was
14461 /// built. A row comes three ways — a fresh walk, a [`BlockCache`] hit
14462 /// cloned at a shifted offset, and a previous map's rows a splice kept
14463 /// untouched — and only the first of those walks a `data-font` at all. An
14464 /// index into a per-build table would have had the same glyph naming two
14465 /// families the moment a second one appeared; the id is the name's own
14466 /// hash, so nothing is remapped and the table is merged rather than rebuilt.
14467 ///
14468 /// Two families, because one cannot tell a wrong id from a right one.
14469 ///
14470 /// [`FaceId`]: crate::style::FaceId
14471 /// [`BlockCache`]: crate::wysiwyg::BlockCache
14472 #[test]
14473 fn a_spliced_rebuild_still_says_which_family_each_glyph_is_set_in() {
14474 use crate::style::{FaceId, FaceRef};
14475 let garamond = FaceId::of("Garamond");
14476 let futura = FaceId::of("Futura");
14477 let mut d = wysiwyg_doc(
14478 "two_faces",
14479 "x <span data-font=\"Garamond\">alpha</span>\n\ny <span data-font=\"Futura\">beta</span>\n",
14480 );
14481 d.build_visual_unwrapped();
14482
14483 // What the map has to keep saying, whichever path built it.
14484 let check = |d: &Doc, ctx: &str| {
14485 let face_of = |ch: char| {
14486 d.vmap
14487 .rows
14488 .iter()
14489 .flat_map(|r| r.glyphs.iter())
14490 .find(|g| g.ch == ch)
14491 .map(|g| g.style.font)
14492 };
14493 assert_eq!(face_of('a'), Some(Some(FaceRef::Named(garamond))), "{ctx}");
14494 assert_eq!(face_of('b'), Some(Some(FaceRef::Named(futura))), "{ctx}");
14495 assert_eq!(d.vmap.face_name(garamond), Some("Garamond"), "{ctx}");
14496 assert_eq!(d.vmap.face_name(futura), Some("Futura"), "{ctx}");
14497 assert_eq!(d.vmap.face_name(FaceId::of("Bodoni")), None, "{ctx}");
14498 };
14499 check(&d, "fresh");
14500
14501 // An edit inside the second block: the single-block case the splice
14502 // path is for. The first block's rows are carried over untouched, so
14503 // its glyphs' ids are the previous build's and the table has to be too.
14504 let at = d.source.find("beta").unwrap();
14505 d.edit(at, at, "z");
14506 d.build_visual_unwrapped();
14507 check(&d, "after an edit in the second block");
14508 wysiwyg::assert_maps_eq(&d.vmap, &reference_map(&d.source), "spliced");
14509
14510 // And the other way round, so the block that was kept is the one that
14511 // is now re-rendered.
14512 let at = d.source.find("alpha").unwrap();
14513 d.edit(at, at, "z");
14514 d.build_visual_unwrapped();
14515 check(&d, "after an edit in the first block");
14516 wysiwyg::assert_maps_eq(&d.vmap, &reference_map(&d.source), "spliced again");
14517
14518 // A structural edit is one the splice bails out of, so the map is
14519 // reassembled by `build_cached` — where an untouched block is a *cache
14520 // hit* and its rows are cloned without a `data-font` being walked
14521 // again. The names the entry stored are what keeps the table honest
14522 // there.
14523 let at = d.source.find("\n\ny ").unwrap();
14524 d.edit(at, at, "\n\nmiddle");
14525 d.build_visual_unwrapped();
14526 wysiwyg::assert_maps_eq(&d.vmap, &reference_map(&d.source), "cached");
14527 // Twice, because that first `build_cached` is what stores the entries:
14528 // this one is the build where the Garamond block is a *hit*, its rows
14529 // cloned with their ids and no attribute walked to explain them.
14530 let at = d.source.len() - 1;
14531 d.edit(at, at, "\n\ntail");
14532 d.build_visual_unwrapped();
14533 check(&d, "after a structural edit, through the block cache");
14534 wysiwyg::assert_maps_eq(&d.vmap, &reference_map(&d.source), "cached again");
14535
14536 // A family the edit took the last glyph of leaves the glyphs with no
14537 // face and the table with a name nothing asks for — harmless, and the
14538 // price of not walking the rows the splice exists to avoid walking.
14539 let span = d.source.find("<span data-font=\"Futura\">").unwrap();
14540 let end = d.source.rfind("</span>").unwrap() + "</span>".len();
14541 d.edit(span, end, "beta");
14542 d.build_visual_unwrapped();
14543 assert!(!d.source.contains("Futura"), "{:?}", d.source);
14544 wysiwyg::assert_maps_eq(&d.vmap, &reference_map(&d.source), "the face removed");
14545 }
14546
14547 #[test]
14548 fn incremental_build_matches_a_fresh_build_under_full_reveal() {
14549 // The same correctness net as `incremental_build_matches_a_fresh_build_
14550 // across_edits`, under `MarkupMode::Full` — where the map depends on
14551 // the caret's *line* as well as the text, so the two caches have a new
14552 // way to be wrong. Both are exercised: the block cache can hand back
14553 // rows built for a line that is no longer the revealed one, and the
14554 // splice path can reuse a suffix that still has yesterday's line raw.
14555 //
14556 // Caret motion is interleaved with the edits deliberately, because a
14557 // caret that only ever moved with the edit would never cross a line
14558 // without also dirtying it — the case where a stale reveal survives.
14559 let docs = [
14560 "# Title\n\n*one* and **two**\n\n[lk](http://x) and `code`\n\n- a *b*\n",
14561 "para *em* one\n\n> quote **bold** text\n\ntail ~~del~~ paragraph\n",
14562 ];
14563 let inserts = ["x", "*", "\n\n", "#", "`", " ", "_"];
14564 for src in docs {
14565 let mut d = wysiwyg_doc("reveal_diff", src);
14566 d.set_markup_mode(MarkupMode::Full);
14567
14568 for step in 0..60usize {
14569 let len = d.source.len();
14570 let raw = (step * 13 + 5) % (len + 1);
14571 let pos = (raw..=len).find(|&i| d.source.is_char_boundary(i)).unwrap();
14572 let pre = d.source.clone();
14573 let action;
14574 if step % 3 == 0 && pos < len {
14575 let end = (pos + 1..=len)
14576 .find(|&i| d.source.is_char_boundary(i))
14577 .unwrap();
14578 action = format!("delete [{pos},{end})");
14579 d.edit(pos, end, "");
14580 } else {
14581 let ins = inserts[step % inserts.len()];
14582 action = format!("insert {ins:?} @ {pos}");
14583 d.edit(pos, pos, ins);
14584 }
14585 // Walk the caret somewhere else in the document, independently
14586 // of where the edit landed.
14587 let want = (step * 29 + 11) % (d.source.len() + 1);
14588 d.caret = (want..=d.source.len())
14589 .find(|&i| d.source.is_char_boundary(i))
14590 .unwrap();
14591 d.build_visual_unwrapped();
14592
14593 let want = reference_map_revealing(&d.source, d.reveal_line());
14594 if maps_differ(&d.vmap, &want) {
14595 panic!(
14596 "FIRST MISMATCH at step {step}: {action}, caret {}\n pre = {pre:?}\n post = {:?}",
14597 d.caret, d.source
14598 );
14599 }
14600 }
14601 }
14602 }
14603
14604 #[test]
14605 fn caret_motion_across_lines_rebuilds_only_under_full() {
14606 // The cache-key change has to earn its keep in both directions: `Full`
14607 // must rebuild when the caret changes line (or the reveal would never
14608 // move), and the hidden modes must *not* (or every arrow key would pay
14609 // for a feature they don't use). The existing `cache_motion` test pins
14610 // the second for the default mode; this pins the pair against a mode
14611 // change alone.
14612 let body = "*one* here\n\n*two* there\n";
14613
14614 let mut full = doc_in(View::Wysiwyg, "motion_full", body);
14615 full.set_markup_mode(MarkupMode::Full);
14616 caret_at(&mut full, "one");
14617 let before = full.revision();
14618 caret_at(&mut full, "two");
14619 assert_eq!(full.revision(), before, "motion is not an edit");
14620 assert!(
14621 drawn_rows(&full).iter().any(|r| r == "*two* there"),
14622 "the map followed the caret: {:?}",
14623 drawn_rows(&full)
14624 );
14625
14626 let mut hidden = doc_in(View::Wysiwyg, "motion_hidden", body);
14627 caret_at(&mut hidden, "one");
14628 let key = hidden.vmap_key.clone();
14629 caret_at(&mut hidden, "two");
14630 assert_eq!(
14631 hidden.vmap_key, key,
14632 "a hidden mode rebuilds nothing on motion"
14633 );
14634 }
14635
14636 #[test]
14637 fn wysiwyg_down_crosses_a_paragraph_boundary() {
14638 // Regression: the blank separator row used to share the previous
14639 // paragraph's end offset, so Down got pinned at the boundary (while Up
14640 // still crossed). Both directions must step through it symmetrically.
14641 //
14642 // It's now stepped *over* rather than onto: the blank line between two
14643 // paragraphs is the boundary being drawn, not a line of the document, so
14644 // one press of Down crosses it. The goal column survives the crossing —
14645 // col 3 at the end of "abc" is col 3 at the end of "def".
14646 let mut d = wysiwyg_doc("wys_down", "abc\n\ndef\n");
14647 d.caret = 3; // end of "abc" (row 0)
14648 d.move_down(false);
14649 assert_eq!(d.caret_pos().0, 2, "Down should reach the second paragraph");
14650 assert_eq!(d.caret, 8); // end of "def", col 3 kept
14651 d.move_up(false);
14652 assert_eq!(d.caret_pos().0, 0, "Up should come back symmetrically");
14653 assert_eq!(d.caret, 3);
14654 }
14655
14656 #[test]
14657 fn wysiwyg_up_and_down_are_inverse_across_paragraphs() {
14658 // The second Up and the second Down here run off the ends of the
14659 // document, which is no longer a place a press is swallowed: they carry
14660 // the caret to the start and the end of the text. The claim in the
14661 // middle — that a Down retraces the Up that crossed the paragraph gap —
14662 // is the one this test is for, and it is asserted where it is made.
14663 let mut d = wysiwyg_doc("wys_updown", "abc\n\ndef\n");
14664 d.caret = 5; // start of "def"
14665 let start = d.caret_pos();
14666 d.move_up(false);
14667 assert_eq!(d.caret_pos().0, 0, "Up reaches the first paragraph");
14668 d.move_up(false);
14669 assert_eq!(d.caret, 0, "a second Up runs on to the document's start");
14670 d.move_down(false);
14671 assert_eq!(d.caret_pos(), start, "Down retraces Up exactly");
14672 d.move_down(false);
14673 assert_eq!(d.caret, 8, "a second Down runs on to the document's end");
14674 }
14675
14676 #[test]
14677 fn wysiwyg_new_paragraph_shows_before_typing() {
14678 // Regression: two Enters at the end of a paragraph produced trailing
14679 // newlines with no AST node, so the caret appeared stuck on the old line
14680 // until a character was typed. It must ride down onto the new line now.
14681 let mut d = doc_with("wys_newpara", "abc\n");
14682 d.view = View::Wysiwyg;
14683 d.caret = 3;
14684 d.insert("\n");
14685 d.insert("\n"); // source is now "abc\n\n\n", caret at 5
14686 assert_eq!(d.source, "abc\n\n\n");
14687 d.build_visual(80);
14688 let (row, _) = d.caret_pos();
14689 assert!(
14690 row >= 2,
14691 "caret should have moved down to the new line, got row {row}"
14692 );
14693 assert!(
14694 d.vmap.num_rows() >= 3,
14695 "the blank lines should render as rows"
14696 );
14697 }
14698
14699 #[test]
14700 fn wysiwyg_enter_between_paragraphs_lands_on_an_empty_line() {
14701 // The reported bug: Enter at the end of a paragraph that has another
14702 // paragraph below put the caret at the *start of the next paragraph* —
14703 // the empty paragraph it opened had no row, so the caret snapped onto
14704 // "World". It must now sit on its own empty line, with a blank spacer
14705 // above it (the paragraph gap).
14706 let mut d = wysiwyg_doc("wys_gap_mid", "Hello\n\nWorld\n");
14707 d.caret = 5; // end of "Hello"
14708 d.newline();
14709 d.build_visual(80);
14710 let (row, col) = d.caret_pos();
14711 assert_eq!(col, 0, "caret should start an empty line, not sit in text");
14712 assert_eq!(
14713 d.vmap.row_width(row),
14714 0,
14715 "caret's row must be empty, not 'World'"
14716 );
14717 assert!(
14718 row >= 2,
14719 "a blank spacer row should sit above the caret, got row {row}"
14720 );
14721 // The row above the caret is a real (empty) gap, and "Hello" stays put.
14722 assert_eq!(
14723 d.vmap.row_width(row - 1),
14724 0,
14725 "the row above the caret is a gap"
14726 );
14727 let row0: String = d.vmap.rows[0].glyphs.iter().map(|g| g.ch).collect();
14728 assert_eq!(row0, "Hello", "the paragraph above the caret must not move");
14729 }
14730
14731 #[test]
14732 fn wysiwyg_enter_at_eof_shows_a_gap_before_typing() {
14733 // At the document end a single Enter must also show the paragraph gap —
14734 // a blank spacer row above the caret — so the layout already matches how
14735 // it will look once the new paragraph has text.
14736 let mut d = wysiwyg_doc("wys_gap_eof", "Hello");
14737 d.caret = 5; // end of "Hello", no trailing newline
14738 d.newline(); // source becomes "Hello\n\n"
14739 d.build_visual(80);
14740 let (row, col) = d.caret_pos();
14741 assert_eq!(col, 0);
14742 assert!(
14743 row >= 2,
14744 "caret should sit below a blank spacer, got row {row}"
14745 );
14746 assert_eq!(
14747 d.vmap.row_width(row - 1),
14748 0,
14749 "the row above the caret is a gap"
14750 );
14751 }
14752
14753 #[test]
14754 fn wysiwyg_typing_after_enter_does_not_shift_the_caret_row() {
14755 // The spacer is view-only: typing the new paragraph must not reflow the
14756 // caret onto a different row — the transient view already matched the
14757 // settled one.
14758 let mut d = wysiwyg_doc("wys_no_reflow", "Hello\n\nWorld\n");
14759 d.caret = 5;
14760 d.newline();
14761 d.build_visual(80);
14762 let before = d.caret_pos();
14763 d.insert("New");
14764 d.build_visual(80);
14765 let after = d.caret_pos();
14766 assert_eq!(
14767 after.0, before.0,
14768 "typing must not move the caret to another row ({before:?} -> {after:?})"
14769 );
14770 }
14771
14772 #[test]
14773 fn wysiwyg_return_on_the_last_code_line_keeps_the_caret_in_the_block() {
14774 // Return at the end of the block's last line writes an empty line the
14775 // map used to drop, so the caret landed on `after` and the next
14776 // keystroke went into the paragraph below instead of into the code.
14777 let mut d = wysiwyg_doc("code_return", "prose\n\n```\nalpha\nbeta\n```\n\nafter\n");
14778 d.caret = d.source.find("beta").unwrap() + "beta".len();
14779 d.build_visual(80);
14780 let before = d.caret_pos().0;
14781
14782 d.newline();
14783 d.build_visual(80);
14784 assert_eq!(d.source, "prose\n\n```\nalpha\nbeta\n\n```\n\nafter\n");
14785
14786 let (row, col) = d.caret_pos();
14787 assert_eq!(row, before + 1, "the caret moves down one row");
14788 assert_eq!(col, 0, "onto the head of the empty line");
14789 let span = d.vmap.code_blocks[0].rows_span.clone();
14790 assert!(
14791 span.contains(&row),
14792 "caret row {row} is outside the block's rows {span:?}"
14793 );
14794
14795 // The whole point: what is typed next is code.
14796 d.insert("gamma");
14797 assert_eq!(d.source, "prose\n\n```\nalpha\nbeta\ngamma\n```\n\nafter\n");
14798 }
14799
14800 #[test]
14801 fn wysiwyg_hides_frontmatter_from_the_caret_and_copy() {
14802 let fm = "---\ntitle: hi\n---\n";
14803 let body = format!("{fm}# leaf\n\nbody\n");
14804 let mut d = wysiwyg_doc("wys_fm", &body);
14805 // Opening lifts the caret out of the now-hidden frontmatter.
14806 assert_eq!(
14807 d.caret,
14808 fm.len(),
14809 "caret should start at the first real block"
14810 );
14811 // Left at the content start can't step back into frontmatter.
14812 d.move_left(false);
14813 assert_eq!(d.caret, fm.len(), "left must not enter frontmatter");
14814 // Doc-start lands on the content floor, not offset 0.
14815 d.move_doc_start(false);
14816 assert_eq!(d.caret, fm.len());
14817 // Select-all + copy never include the frontmatter bytes.
14818 d.select_all();
14819 let sel = d.selected_text().unwrap().to_string();
14820 assert!(!sel.contains("title"), "copy leaked frontmatter: {sel:?}");
14821 assert!(
14822 sel.starts_with("# leaf"),
14823 "selection should begin at content: {sel:?}"
14824 );
14825 }
14826
14827 #[test]
14828 fn typing_in_a_frontmatter_only_document_lands_after_the_frontmatter() {
14829 // A fresh note is frontmatter and nothing else. With no rendered block
14830 // to floor the caret it opened at offset 0 — before the opening `---` —
14831 // so the first keystroke wrote itself in front of the metadata and the
14832 // file came out as `This---\ntitle: …`.
14833 let fm = "---\ntitle: 2026-08-29\nid: f8s32cd\n---\n";
14834 let mut d = wysiwyg_doc("wys_fm_only", fm);
14835 assert_eq!(d.caret, fm.len(), "caret must open past the frontmatter");
14836 // Nothing is rendered, so the caret draws at the origin of an empty view
14837 // — the same place an empty document puts it.
14838 assert_eq!(d.caret_pos(), (0, 0));
14839 d.insert("This");
14840 assert_eq!(d.source, format!("{fm}This"));
14841 }
14842
14843 /// `select_range` is the verb for a range a host already knows the bytes of,
14844 /// so it must not snap — and must still hold every invariant `place_caret`
14845 /// holds, the frontmatter floor above all.
14846 #[test]
14847 fn select_range_takes_the_range_as_given_but_still_floors_it() {
14848 let fm = "---\ntitle: foo\n---\n\n";
14849 let body = format!("{fm}body foo here\n");
14850 let mut d = wysiwyg_doc("wys_select_range", &body);
14851
14852 // The `foo` in the body: taken exactly, not snapped to a caret stop.
14853 let at = body.rfind("foo").unwrap();
14854 d.select_range(at, at + 3);
14855 assert_eq!(d.selection(), Some((at, at + 3)));
14856 assert_eq!(d.selected_text(), Some("foo"));
14857
14858 // The `foo` in the hidden frontmatter: below the floor, so both ends
14859 // come up to it rather than parking the caret in the metadata, where a
14860 // later keystroke would rewrite `title:`.
14861 let hidden = body.find("foo").unwrap();
14862 assert!(hidden < d.vmap.content_start);
14863 d.select_range(hidden, hidden + 3);
14864 assert!(
14865 d.caret >= d.vmap.content_start && d.anchor.unwrap() >= d.vmap.content_start,
14866 "a range under the floor must not leave the caret in the frontmatter"
14867 );
14868
14869 // Past the end, and mid-character, are both brought back to something
14870 // sliceable rather than panicking the next reader of the range.
14871 let multi = wysiwyg_doc("wys_select_range_utf8", "héllo\n");
14872 let mut d = multi;
14873 d.select_range(2, 9_999);
14874 assert_eq!(d.caret, d.source.len());
14875 assert!(d.source.is_char_boundary(d.anchor.unwrap()));
14876 assert!(d.source.is_char_boundary(d.caret));
14877 }
14878
14879 /// The bug `select_range` exists for: a match butting up against a hidden
14880 /// delimiter. `place_caret` snaps to the nearest *visible* stop, which is
14881 /// the one before the `**`.
14882 #[test]
14883 fn select_range_does_not_snap_off_a_hidden_delimiter() {
14884 let mut d = wysiwyg_doc("wys_select_range_bold", "a **needle** in it\n");
14885 let at = d.source.find("needle").unwrap();
14886 d.select_range(at, at + 6);
14887 assert_eq!(d.selected_text(), Some("needle"), "not \"needl\"");
14888 }
14889
14890 #[test]
14891 fn wysiwyg_backspace_at_content_start_leaves_frontmatter_intact() {
14892 // Backspace deletes `prev_boundary..caret` directly; at the first real
14893 // block that boundary is inside the hidden frontmatter, so it must be a
14894 // no-op rather than eating the closing `---`.
14895 let fm = "---\ntitle: hi\n---\n";
14896 let body = format!("{fm}leaf\n");
14897 let mut d = wysiwyg_doc("wys_fm_bs", &body);
14898 assert_eq!(d.caret, fm.len());
14899 d.backspace();
14900 assert_eq!(d.source, body, "backspace must not touch frontmatter");
14901 d.delete_word_back();
14902 assert_eq!(
14903 d.source, body,
14904 "word-delete must not touch frontmatter either"
14905 );
14906 }
14907
14908 #[test]
14909 fn wysiwyg_edits_inside_a_vis_directive_block_without_disturbing_its_fences() {
14910 // diaryx's `:::vis{.audience}` visibility block — any `:::name{.class}`
14911 // fenced div, really, since core parses these on for every document
14912 // now (`parse_extensions`). The container is a `directive` node, an
14913 // `is_block_container` kind like `block_quote`, so the caret works
14914 // inside its child paragraph exactly as it would inside a quote: typing
14915 // edits the paragraph, and the `:::vis{...}` / `:::` fences round-trip
14916 // untouched.
14917 let body = ":::vis{.public .family}\nhello\n:::\nafter\n";
14918 let mut d = wysiwyg_doc("wys_vis", body);
14919 d.caret = body.find("hello").unwrap() + "hello".len();
14920 d.insert("!");
14921 assert_eq!(
14922 d.source, ":::vis{.public .family}\nhello!\n:::\nafter\n",
14923 "typing inside the block edits its content in place"
14924 );
14925 assert!(
14926 d.source.contains(":::vis{.public .family}"),
14927 "opening fence survives"
14928 );
14929 assert!(d.source.contains(":::\nafter"), "closing fence survives");
14930 }
14931
14932 #[test]
14933 fn source_view_still_reaches_frontmatter() {
14934 // The metadata is only *hidden*, never lost: the source view edits and
14935 // selects it in full, and it's always preserved on save.
14936 let fm = "---\ntitle: hi\n---\n";
14937 let body = format!("{fm}# leaf\n");
14938 let mut d = doc_with("src_fm", &body);
14939 d.select_all();
14940 let sel = d.selected_text().unwrap();
14941 assert!(
14942 sel.contains("title"),
14943 "source view should select everything"
14944 );
14945 d.move_doc_start(false);
14946 assert_eq!(d.caret, 0, "source view can reach offset 0");
14947 }
14948
14949 const TABLE: &str = "| Name | Qty |\n|:-----|----:|\n| Pear | 3 |\n| Fig | 12 |\n";
14950
14951 #[test]
14952 fn wysiwyg_right_crosses_a_cell_border_without_stalling() {
14953 // The border and padding between two cells all share one source offset,
14954 // so a column-stepping caret would sit on `│` and then stall there
14955 // forever. Right must step: end of "Name" -> start of "Qty".
14956 let mut d = wysiwyg_doc("tbl_right", TABLE);
14957 d.caret = TABLE.find("Name").unwrap() + 4; // just after "Name"
14958 d.move_right(false);
14959 assert_eq!(
14960 d.caret,
14961 TABLE.find("Qty").unwrap(),
14962 "should land in the next cell"
14963 );
14964 let (r, c) = d.caret_pos();
14965 assert_eq!(d.vmap.rows[r].glyphs[c].ch, 'Q');
14966 }
14967
14968 #[test]
14969 fn wysiwyg_left_crosses_back_to_the_previous_cell() {
14970 let mut d = wysiwyg_doc("tbl_left", TABLE);
14971 d.caret = TABLE.find("Qty").unwrap();
14972 d.move_left(false);
14973 assert_eq!(
14974 d.caret,
14975 TABLE.find("Name").unwrap() + 4,
14976 "end of the previous cell"
14977 );
14978 }
14979
14980 #[test]
14981 fn wysiwyg_down_steps_over_a_table_rule() {
14982 // Between the header and the first body row sits a `├───┼───┤` rule.
14983 // It's drawn but holds no caret, so one Down must reach "Pear".
14984 let mut d = wysiwyg_doc("tbl_down", TABLE);
14985 d.caret = TABLE.find("Name").unwrap();
14986 d.move_down(false);
14987 assert_eq!(
14988 d.caret,
14989 TABLE.find("Pear").unwrap(),
14990 "one Down reaches the body row"
14991 );
14992 d.move_down(false);
14993 assert_eq!(d.caret, TABLE.find("Fig").unwrap());
14994 }
14995
14996 #[test]
14997 fn wysiwyg_tab_walks_the_cells_and_shift_tab_walks_back() {
14998 let mut d = wysiwyg_doc("tbl_tab", TABLE);
14999 d.caret = TABLE.find("Name").unwrap();
15000 // A hop lands with the destination cell's whole content selected, the
15001 // caret at its end — so typing replaces the cell like a form field.
15002 assert!(d.cell_hop(true));
15003 assert_eq!(
15004 d.selected_text(),
15005 Some("Qty"),
15006 "the target cell comes up selected"
15007 );
15008 assert_eq!(d.caret, TABLE.find("Qty").unwrap() + "Qty".len());
15009 assert!(d.cell_hop(true), "Tab wraps onto the next row's first cell");
15010 assert_eq!(d.selected_text(), Some("Pear"));
15011 assert!(d.cell_hop(false));
15012 assert_eq!(d.selected_text(), Some("Qty"));
15013 }
15014
15015 #[test]
15016 fn tab_outside_a_table_is_not_a_cell_hop() {
15017 // `cell_hop` reports false so the frontend can indent as usual.
15018 let mut d = wysiwyg_doc("tbl_none", "just a paragraph\n");
15019 d.caret = 4;
15020 assert!(!d.cell_hop(true));
15021 assert_eq!(d.caret, 4, "a refused hop leaves the caret alone");
15022 }
15023
15024 #[test]
15025 fn tab_at_the_last_cell_declines_rather_than_leaving_the_table() {
15026 let mut d = wysiwyg_doc("tbl_edge", TABLE);
15027 d.caret = TABLE.rfind("12").unwrap(); // the final cell
15028 assert!(!d.cell_hop(true), "no cell after the last one");
15029 d.caret = TABLE.find("Name").unwrap();
15030 assert!(!d.cell_hop(false), "no cell before the first one");
15031 }
15032
15033 #[test]
15034 fn wysiwyg_vertical_cell_motion_holds_the_column() {
15035 // Down/Up step to the cell above/below in the *same column*, not back to
15036 // the top-left the way a naive row/col motion over the picture would.
15037 let mut d = wysiwyg_doc("tbl_vert", TABLE);
15038 d.caret = TABLE.find("Qty").unwrap();
15039 // Each vertical hop selects the destination cell, holding the column.
15040 assert!(d.cell_move_vertical(true));
15041 assert_eq!(d.selected_text(), Some("3"), "Down holds column 1");
15042 assert!(d.cell_move_vertical(true));
15043 assert_eq!(d.selected_text(), Some("12"), "Down again, still column 1");
15044 assert!(!d.cell_move_vertical(true), "no row below the last");
15045 assert!(d.cell_move_vertical(false));
15046 assert_eq!(d.selected_text(), Some("3"), "Up holds column 1");
15047 assert!(d.cell_move_vertical(false));
15048 assert_eq!(d.selected_text(), Some("Qty"), "Up onto the header");
15049 assert!(!d.cell_move_vertical(false), "no row above the header");
15050 }
15051
15052 #[test]
15053 fn tab_off_the_last_cell_grows_a_row_and_enters_it() {
15054 let mut d = wysiwyg_doc("tbl_grow", TABLE);
15055 d.caret = TABLE.rfind("12").unwrap();
15056 let rows_before = d.source.matches('\n').count();
15057 assert!(d.cell_tab(true), "acts as a table key");
15058 assert_eq!(
15059 d.source.matches('\n').count(),
15060 rows_before + 1,
15061 "a fresh row was appended"
15062 );
15063 assert!(d.caret_in_table(), "the caret entered the new row");
15064 // The caret sits in the new row's first cell — past the old last cell.
15065 assert!(d.caret > TABLE.rfind("12").unwrap());
15066 }
15067
15068 #[test]
15069 fn return_in_a_table_drops_a_cell_and_grows_a_row_at_the_bottom() {
15070 let mut d = wysiwyg_doc("tbl_ret", TABLE);
15071 d.caret = TABLE.find("Name").unwrap();
15072 assert!(d.cell_return(), "acts as a table key");
15073 assert_eq!(
15074 d.selected_text(),
15075 Some("Pear"),
15076 "Return drops one cell, selecting it"
15077 );
15078 // From the last row, Return appends a row and enters it.
15079 d.caret = TABLE.rfind("Fig").unwrap();
15080 let rows_before = d.source.matches('\n').count();
15081 assert!(d.cell_return());
15082 assert_eq!(d.source.matches('\n').count(), rows_before + 1);
15083 assert!(d.caret_in_table());
15084 }
15085
15086 #[test]
15087 fn return_and_tab_outside_a_table_decline() {
15088 let mut d = wysiwyg_doc("tbl_decline", "just a paragraph\n");
15089 d.caret = 4;
15090 assert!(!d.cell_return(), "no table: the frontend inserts a newline");
15091 assert!(!d.cell_tab(true), "no table: the frontend indents");
15092 assert!(
15093 !d.cell_line_break(),
15094 "no table: the frontend breaks the line"
15095 );
15096 }
15097
15098 #[test]
15099 fn a_click_under_a_trailing_table_lands_past_it_and_enter_opens_a_line() {
15100 // A document that ends in a table used to end *inside* it: nothing
15101 // past the last cell was a caret stop, so a click in the blank space
15102 // under the grid snapped back into the table and there was no way to
15103 // write a line after it. The bottom border's end is that stop now.
15104 let mut d = wysiwyg_doc("tbl_trail", TABLE);
15105 let rows = d.vmap.num_rows();
15106 d.click(rows + 3, 0, false);
15107 let end = TABLE.trim_end_matches('\n').len();
15108 assert_eq!(d.caret, end, "the caret stands just past the table");
15109 assert!(!d.caret_in_table(), "past the table is outside it");
15110 assert!(!d.cell_return(), "Return there is the frontend's newline");
15111 d.newline();
15112 d.insert("after");
15113 assert_eq!(
15114 d.source,
15115 format!("{TABLE}\nafter\n"),
15116 "Enter opens a paragraph under the table"
15117 );
15118 }
15119
15120 #[test]
15121 fn typing_at_a_table_s_trailing_stop_opens_a_paragraph_first() {
15122 // The stop sits at the end of the table's last source line, and a
15123 // line glued under a table is a row of it — `| Fig | 12 |x` would be a
15124 // three-cell row. So the text gets a paragraph of its own, as it does
15125 // beside a block picture.
15126 let mut d = wysiwyg_doc("tbl_type", TABLE);
15127 d.caret = TABLE.trim_end_matches('\n').len();
15128 d.insert("x");
15129 assert_eq!(d.source, format!("{TABLE}\nx\n"));
15130 assert_eq!(d.caret, TABLE.len() + 2, "the caret follows the text");
15131 // And a paste, which joins the block exactly as typing would.
15132 let mut d = wysiwyg_doc("tbl_paste", TABLE);
15133 d.caret = TABLE.trim_end_matches('\n').len();
15134 d.paste("pasted");
15135 assert_eq!(d.source, format!("{TABLE}\npasted\n"));
15136 }
15137
15138 #[test]
15139 fn right_leaves_a_table_by_its_trailing_stop_and_backspace_steps_back_in() {
15140 let mut d = wysiwyg_doc("tbl_edge", TABLE);
15141 let last_cell_end = TABLE.rfind("12").unwrap() + 2;
15142 let end = TABLE.trim_end_matches('\n').len();
15143 d.caret = last_cell_end;
15144 d.move_right(false);
15145 assert_eq!(d.caret, end, "Right from the last cell leaves the table");
15146 // Backspace there takes no byte: the one behind the caret is the row's
15147 // closing `|`, which the rich view never drew. It steps back instead.
15148 d.backspace();
15149 assert_eq!(d.source, TABLE, "nothing deleted");
15150 assert_eq!(d.caret, last_cell_end, "back into the last cell");
15151 // Down from the last row lands on the same stop, and Up returns.
15152 d.move_down(false);
15153 assert_eq!(d.caret, end, "Down from the last row leaves the table");
15154 d.move_up(false);
15155 assert_eq!(d.caret, last_cell_end);
15156 }
15157
15158 #[test]
15159 fn a_table_s_trailing_stop_sits_between_it_and_the_text_below() {
15160 // With prose under the table, the stop is one hop between the last
15161 // cell and the paragraph — the shape a block picture's second stop has.
15162 let src = format!("{TABLE}\nafter\n");
15163 let mut d = wysiwyg_doc("tbl_mid", &src);
15164 d.caret = TABLE.rfind("12").unwrap() + 2;
15165 d.move_right(false);
15166 assert_eq!(d.caret, TABLE.trim_end_matches('\n').len());
15167 d.move_right(false);
15168 assert_eq!(d.caret, src.find("after").unwrap());
15169 // Typing at the stop still opens a paragraph, and the text below keeps
15170 // its own.
15171 d.move_left(false);
15172 d.insert("x");
15173 assert_eq!(d.source, format!("{TABLE}\nx\n\nafter\n"));
15174 }
15175
15176 #[test]
15177 fn shift_return_inserts_an_in_cell_break_the_renderer_reads_as_a_line() {
15178 let mut d = wysiwyg_doc("tbl_break", TABLE);
15179 d.caret = TABLE.find("Pear").unwrap() + 4; // just after "Pear"
15180 assert!(d.cell_line_break(), "acts as a table key");
15181 assert!(
15182 d.source.contains("Pear<br>"),
15183 "spelled as an inline <br>: {}",
15184 d.source
15185 );
15186 assert!(d.caret_in_table(), "still in the cell, past the break");
15187 // The break renders as a real line: the "Pear" cell now draws two lines,
15188 // so the table's picture is one row taller than a single-line table.
15189 d.build_visual(80);
15190 let table = &d.vmap.tables[0];
15191 let cell = &table.grid[1].cells[0]; // first body row, first column
15192 assert!(
15193 cell.glyphs.iter().any(|g| g.ch == '\n'),
15194 "the cell carries the break as a newline glyph for the frontend to split"
15195 );
15196 }
15197
15198 #[test]
15199 fn shift_return_in_a_markdown_cell_leaves_a_semantic_hard_break_not_raw_html() {
15200 // twig promotes the in-cell `<br>` to a `hard_break`, so the break reads
15201 // back as structure — the whole point of routing through insert_line_break
15202 // instead of splicing raw `<br>` bytes.
15203 let mut d = wysiwyg_doc("tbl_break_semantic", TABLE);
15204 d.caret = TABLE.find("Pear").unwrap() + 4;
15205 assert!(d.cell_line_break());
15206 let kinds: Vec<Kind> = d
15207 .editor
15208 .nodes()
15209 .unwrap()
15210 .iter()
15211 .map(|n| n.kind.clone())
15212 .collect();
15213 assert!(kinds.contains(&Kind::HardBreak), "got {kinds:?}");
15214 assert!(
15215 !kinds.contains(&Kind::RawInline),
15216 "still raw HTML: {kinds:?}"
15217 );
15218 }
15219
15220 #[test]
15221 fn backspace_over_an_in_cell_break_deletes_the_whole_br_not_a_byte() {
15222 // The `<br>` draws as one newline glyph, so Backspace over it must take
15223 // all four bytes — a one-byte delete would strand a visible `<br` in the
15224 // cell (the reported bug).
15225 let mut d = wysiwyg_doc("tbl_break_bs", TABLE);
15226 d.caret = TABLE.find("Pear").unwrap() + 4;
15227 assert!(d.cell_line_break());
15228 assert!(d.source.contains("Pear<br>"), "precondition: {}", d.source);
15229 d.backspace(); // caret sits just past the break
15230 assert!(
15231 !d.source.contains("<br"),
15232 "no half-deleted <br left: {}",
15233 d.source
15234 );
15235 assert!(
15236 d.source.contains("| Pear |"),
15237 "the cell is back to one line: {}",
15238 d.source
15239 );
15240 }
15241
15242 #[test]
15243 fn backspace_at_a_cell_start_is_a_wall() {
15244 // The task's repro: two presses used to take the padding and then the
15245 // `|`, merging `d` into `c`'s cell and leaving the row a column short.
15246 let src = "| a | b |\n| - | - |\n| c | d |\n";
15247 let mut d = wysiwyg_doc("tbl_wall_bs", src);
15248 d.place_caret(src.find("d |").unwrap(), false);
15249 for _ in 0..3 {
15250 d.backspace();
15251 }
15252 assert_eq!(d.source, src);
15253 // Inside the padding too — right after the `|`, before the space. Set
15254 // directly: `place_caret` would snap it onto the stop past the space.
15255 d.caret = src.find("| d").unwrap() + 1;
15256 d.backspace();
15257 assert_eq!(d.source, src);
15258 // The row's first cell, and an empty one, are walls the same.
15259 d.place_caret(src.find("c |").unwrap(), false);
15260 d.backspace();
15261 assert_eq!(d.source, src);
15262 // A cell that opens on hidden markup: the wall is the first letter
15263 // drawn, not the `**` in front of it.
15264 let bold = "| a | b |\n| - | - |\n| c | **d** |\n";
15265 let mut d = wysiwyg_doc("tbl_wall_bs_bold", bold);
15266 d.place_caret(bold.find("d**").unwrap(), false);
15267 d.backspace();
15268 assert_eq!(d.source, bold);
15269 let empty = "| a | b |\n| - | - |\n| c | |\n";
15270 let mut d = wysiwyg_doc("tbl_wall_bs_empty", empty);
15271 d.place_caret(empty.find("| |").unwrap() + 2, false);
15272 d.backspace();
15273 assert_eq!(d.source, empty);
15274 }
15275
15276 #[test]
15277 fn backspace_inside_a_cell_still_deletes_a_character() {
15278 let src = "| a | b |\n| - | - |\n| c | de |\n";
15279 let mut d = wysiwyg_doc("tbl_wall_bs_mid", src);
15280 d.place_caret(src.find("e |").unwrap(), false);
15281 d.backspace();
15282 assert_eq!(d.source, "| a | b |\n| - | - |\n| c | e |\n");
15283 }
15284
15285 #[test]
15286 fn delete_at_a_cell_end_is_a_wall() {
15287 // The mirror: Delete at `c`'s end would take the padding, then the `|`.
15288 let src = "| a | b |\n| - | - |\n| c | d |\n";
15289 let mut d = wysiwyg_doc("tbl_wall_del", src);
15290 d.place_caret(src.find("c |").unwrap() + 1, false);
15291 for _ in 0..3 {
15292 d.delete_forward();
15293 }
15294 assert_eq!(d.source, src);
15295 // And inside the trailing padding, set directly past the snap.
15296 d.caret = src.find("c |").unwrap() + 2;
15297 d.delete_forward();
15298 assert_eq!(d.source, src);
15299 // The row's last cell is a wall on its closing `|` as well.
15300 d.place_caret(src.find("d |").unwrap() + 1, false);
15301 d.delete_forward();
15302 assert_eq!(d.source, src);
15303 // Mid-cell Delete is untouched.
15304 d.place_caret(src.find("c |").unwrap(), false);
15305 d.delete_forward();
15306 assert_eq!(d.source, "| a | b |\n| - | - |\n| | d |\n");
15307 }
15308
15309 #[test]
15310 fn delete_forward_over_an_in_cell_break_deletes_the_whole_br() {
15311 let mut d = wysiwyg_doc("tbl_break_del", TABLE);
15312 d.caret = TABLE.find("Pear").unwrap() + 4;
15313 assert!(d.cell_line_break());
15314 d.caret = TABLE.find("Pear").unwrap() + 4; // back onto the break's start
15315 d.delete_forward();
15316 assert!(
15317 !d.source.contains("<br"),
15318 "no half-deleted <br: {}",
15319 d.source
15320 );
15321 assert!(
15322 d.source.contains("| Pear |"),
15323 "cell back to one line: {}",
15324 d.source
15325 );
15326 }
15327
15328 #[test]
15329 fn shift_return_in_a_djot_cell_is_swallowed_and_leaves_the_row_intact() {
15330 // Djot has no idiomatic in-cell break, so twig refuses it. The gesture is
15331 // still consumed (a real newline would split the one-line row), but the
15332 // cell must be left exactly as it was — no non-idiomatic `<br>` spliced in.
15333 let src = "| Name | Qty |\n|:-----|----:|\n| Pear | 3 |\n";
15334 let mut d = Doc::from_source(src.to_string(), Format::Djot).unwrap();
15335 d.caret = src.find("Pear").unwrap() + 4;
15336 assert!(d.caret_in_table(), "caret should be inside the djot table");
15337 assert!(
15338 d.cell_line_break(),
15339 "the key is consumed, not passed to the frontend"
15340 );
15341 assert_eq!(d.source, src, "the djot cell is left untouched");
15342 assert!(
15343 !d.source.contains("<br>"),
15344 "no non-idiomatic <br> spliced into djot"
15345 );
15346 assert!(
15347 d.status.is_some(),
15348 "the refusal is surfaced on the status line"
15349 );
15350 }
15351
15352 #[test]
15353 fn typing_in_a_cell_edits_that_cell() {
15354 // Editing comes free once offsets map correctly: the caret is a source
15355 // offset, so a normal splice lands inside the pipe table.
15356 let mut d = wysiwyg_doc("tbl_type", TABLE);
15357 d.caret = TABLE.find("Pear").unwrap() + 4;
15358 d.insert("s");
15359 assert!(d.source.contains("| Pears | 3 |"), "got {:?}", d.source);
15360 }
15361
15362 #[test]
15363 fn motion_and_delete_treat_an_emoji_as_one_character() {
15364 // 👨👩👧 is a single grapheme built from three emoji joined by ZWJ — 18
15365 // bytes, several codepoints. Right-arrow must clear it in one step, and
15366 // backspace must remove the whole cluster, not a stray joiner.
15367 let family = "👨👩👧";
15368 let mut d = doc_with("emoji", &format!("a{family}b\n"));
15369 d.caret = 1; // just after 'a', before the emoji
15370 d.move_right(false);
15371 assert_eq!(
15372 d.caret,
15373 1 + family.len(),
15374 "one step clears the whole cluster"
15375 );
15376 assert_eq!(&d.source[d.caret..d.caret + 1], "b");
15377
15378 d.backspace(); // delete the emoji as a unit
15379 assert_eq!(d.source, "ab\n");
15380 assert_eq!(d.caret, 1);
15381 }
15382
15383 #[test]
15384 fn motion_handles_a_combining_accent_as_one_character() {
15385 // "e" + U+0301 (combining acute) renders as one é.
15386 let mut d = doc_with("combining", "e\u{0301}x\n");
15387 d.caret = 0;
15388 d.move_right(false);
15389 assert_eq!(
15390 d.caret,
15391 "e\u{0301}".len(),
15392 "steps past base + combining mark"
15393 );
15394 }
15395
15396 #[test]
15397 fn undo_then_redo_round_trips_an_edit() {
15398 let mut d = doc_with("undo", "hello\n");
15399 d.caret = 5;
15400 d.insert("!");
15401 assert_eq!(d.source, "hello!\n");
15402 d.undo();
15403 assert_eq!(d.source, "hello\n");
15404 assert_eq!(d.caret, 5, "undo restores the caret");
15405 d.redo();
15406 assert_eq!(d.source, "hello!\n");
15407 }
15408
15409 #[test]
15410 fn a_run_of_typing_undoes_as_one_step() {
15411 let mut d = doc_with("coalesce", "\n");
15412 d.caret = 0;
15413 d.insert("a");
15414 d.insert("b");
15415 d.insert("c");
15416 assert_eq!(d.source, "abc\n");
15417 d.undo(); // the whole typed run, not just "c"
15418 assert_eq!(d.source, "\n");
15419 d.undo(); // nothing left — the run was one step
15420 assert_eq!(d.source, "\n");
15421 assert_eq!(d.status.as_deref(), Some("nothing to undo"));
15422 }
15423
15424 // ── IME composition ──────────────────────────────────────────────────────
15425
15426 #[test]
15427 fn a_composition_run_undoes_as_one_step() {
15428 let mut d = doc_with("compose", "\n");
15429 d.caret = 0;
15430 // What an IME does: each step replaces the last one's provisional bytes.
15431 d.edit_composing(0, 0, "k");
15432 d.edit_composing(0, 1, "か");
15433 d.edit_composing(0, 3, "かん");
15434 d.edit_composing(0, 6, "感"); // the commit
15435 d.end_composition();
15436 assert_eq!(d.source, "感\n");
15437 d.undo(); // the whole composition, not its last keystroke
15438 assert_eq!(d.source, "\n");
15439 assert_eq!(d.status.as_deref(), None, "the run was a single step");
15440 }
15441
15442 /// A Replace All the way a host does one: every match, last to first so
15443 /// the offsets still to go stay put, each a selection replaced by an
15444 /// insert, inside one undo group. `select_range` rather than the host's
15445 /// snapping `place_caret`, which would snap against a map these tests do
15446 /// not rebuild between edits.
15447 fn replace_all_in(d: &mut Doc, needle: &str, with: &str) {
15448 let hits: Vec<usize> = d.source.match_indices(needle).map(|(i, _)| i).collect();
15449 d.begin_undo_group();
15450 for at in hits.into_iter().rev() {
15451 d.select_range(at, at + needle.len());
15452 d.insert(with);
15453 }
15454 d.end_undo_group();
15455 }
15456
15457 #[test]
15458 fn a_replace_all_undoes_and_redoes_as_one_step() {
15459 let mut d = wysiwyg_doc("group", "a cat, a **cat**, a cat\n");
15460 d.caret = 0;
15461 d.insert("x");
15462 replace_all_in(&mut d, "cat", "dog");
15463 assert_eq!(d.source, "xa dog, a **dog**, a dog\n");
15464 assert!(d.undo(), "one undo");
15465 assert_eq!(
15466 d.source, "xa cat, a **cat**, a cat\n",
15467 "puts back every match"
15468 );
15469 assert!(d.redo(), "one redo");
15470 assert_eq!(
15471 d.source, "xa dog, a **dog**, a dog\n",
15472 "replaces them all again"
15473 );
15474 assert!(d.undo());
15475 assert!(d.undo(), "the typing before is its own step");
15476 assert_eq!(d.source, "a cat, a **cat**, a cat\n");
15477 assert!(!d.can_undo());
15478 }
15479
15480 #[test]
15481 fn typing_after_a_group_is_a_step_of_its_own() {
15482 // A one-character replacement is an insert like a keystroke, and a
15483 // keystroke after it would coalesce with it outside a group.
15484 let mut d = wysiwyg_doc("group", "a b a\n");
15485 replace_all_in(&mut d, "a", "c");
15486 d.caret = d.source.len() - 1;
15487 d.insert("d");
15488 assert_eq!(d.source, "c b cd\n");
15489 assert!(d.undo());
15490 assert_eq!(d.source, "c b c\n", "the typing alone");
15491 assert!(d.undo());
15492 assert_eq!(d.source, "a b a\n", "then the replacement, whole");
15493 }
15494
15495 #[test]
15496 fn a_group_of_more_edits_than_the_history_holds_is_still_one_step() {
15497 // twig keeps 200 steps; a group folds as it goes, so it never holds
15498 // more than one of them.
15499 let src = format!("start\n{}\n", "x ".repeat(300));
15500 let mut d = wysiwyg_doc("group", &src);
15501 d.caret = 5;
15502 d.insert("!");
15503 replace_all_in(&mut d, "x", "yy");
15504 assert_eq!(d.undo_steps, 2);
15505 assert!(d.undo());
15506 assert_eq!(d.source, src.replacen("start", "start!", 1));
15507 assert!(d.undo());
15508 assert_eq!(d.source, src);
15509 assert!(!d.can_undo());
15510 }
15511
15512 #[test]
15513 fn an_undo_closes_an_open_group() {
15514 let mut d = wysiwyg_doc("group", "one\n");
15515 d.caret = 3;
15516 d.begin_undo_group();
15517 d.begin_undo_group();
15518 d.insert("x");
15519 assert!(d.in_undo_group());
15520 assert!(d.undo());
15521 assert!(
15522 !d.in_undo_group(),
15523 "the history moved, so the group is over"
15524 );
15525 d.end_undo_group();
15526 d.end_undo_group();
15527 assert_eq!(d.source, "one\n");
15528 assert!(d.redo());
15529 assert_eq!(d.source, "onex\n");
15530 }
15531
15532 #[test]
15533 fn replacing_the_source_keeps_the_caret_on_its_characters() {
15534 let mut d = wysiwyg_doc("replace", "One.\n\nTwo.\n\nThree.\n");
15535 let on = d.source.find("Three").unwrap() + 2;
15536 d.caret = on;
15537 // Another device's edit, before the caret: the caret moves along.
15538 assert!(d.replace_source("One, and more.\n\nTwo.\n\nThree.\n"));
15539 assert_eq!(d.source, "One, and more.\n\nTwo.\n\nThree.\n");
15540 assert_eq!(&d.source[d.caret..], "ree.\n", "still inside Three");
15541 // After the caret: it stays.
15542 let at = d.caret;
15543 assert!(d.replace_source("One, and more.\n\nTwo.\n\nThree.\n\nFour.\n"));
15544 assert_eq!(d.caret, at);
15545 assert!(d.selection().is_none());
15546 // One undo step, the caret where it stood.
15547 assert!(d.undo());
15548 assert_eq!(d.source, "One, and more.\n\nTwo.\n\nThree.\n");
15549 assert_eq!(d.caret, at);
15550 }
15551
15552 #[test]
15553 fn replacing_the_source_writes_markup_as_markup() {
15554 let mut d = wysiwyg_doc("replace-markup", "plain\n");
15555 assert!(d.replace_source("**bold** and *it*\n"));
15556 assert_eq!(
15557 d.source, "**bold** and *it*\n",
15558 "source, not typing: nothing escaped"
15559 );
15560 assert!(d.replace_source("**bold** and *it*\n"), "already there");
15561 }
15562
15563 #[test]
15564 fn the_differing_span_ends_on_characters() {
15565 assert_eq!(differing_span("abc", "abc"), (3, 3, ""));
15566 assert_eq!(differing_span("abXc", "abYc"), (2, 3, "Y"));
15567 assert_eq!(differing_span("aaa", "aaaa"), (3, 3, "a"));
15568 assert_eq!(differing_span("aaaa", "aa"), (2, 4, ""));
15569 // é and è share their first byte; the span must not split it.
15570 assert_eq!(differing_span("café", "cafè"), (3, 5, "è"));
15571 assert_eq!(differing_span("", "x"), (0, 0, "x"));
15572 }
15573
15574 #[test]
15575 fn a_substitution_behind_the_caret_leaves_the_caret_and_is_its_own_step() {
15576 let mut d = wysiwyg_doc("substitute", "\n");
15577 d.caret = 0;
15578 for c in ["I", " ", "s", "a", "w", " ", "t", "e", "h", " "] {
15579 d.insert(c);
15580 }
15581 assert_eq!(d.source, "I saw teh \n");
15582 let at = d.source.find("teh").unwrap();
15583 assert!(d.substitute(at, at + 3, "the"));
15584 assert_eq!(d.source, "I saw the \n");
15585 assert_eq!(d.caret, 10, "still after the space");
15586 assert!(d.selection().is_none());
15587 d.insert("c");
15588 assert_eq!(d.source, "I saw the c\n");
15589 assert!(d.undo(), "the typing after it is a step of its own");
15590 assert_eq!(d.source, "I saw the \n");
15591 assert!(d.undo(), "the correction is one step");
15592 assert_eq!(d.source, "I saw teh \n", "what was typed");
15593 assert_eq!(d.caret, 10, "with the caret where it stood");
15594 assert!(d.redo());
15595 assert_eq!(d.source, "I saw the \n");
15596 assert!(d.undo());
15597 assert!(d.undo(), "and the typing before it is its own");
15598 assert_eq!(d.source, "\n");
15599 assert!(!d.can_undo());
15600 }
15601
15602 #[test]
15603 fn a_substitution_keeps_the_markup_it_stands_beside() {
15604 // A quote typed just past a bold run: only its own byte changes.
15605 let mut d = wysiwyg_doc("substitute", "A **bold** word\n");
15606 let at = d.source.find(" word").unwrap();
15607 d.caret = at;
15608 d.insert("\"");
15609 assert_eq!(d.source, "A **bold**\" word\n");
15610 assert!(d.substitute(at, at + 1, "\u{201d}"));
15611 assert_eq!(d.source, "A **bold**\u{201d} word\n");
15612 assert_eq!(d.caret, at + "\u{201d}".len());
15613 assert!(
15614 d.marks_at(d.source.find("bold").unwrap())
15615 .iter()
15616 .any(|(k, _)| *k == InlineKind::Strong)
15617 );
15618 // A range that is not one is refused, and changes nothing.
15619 let src = d.source.clone();
15620 assert!(!d.substitute(3, 2, "x"));
15621 assert!(!d.substitute(0, src.len() + 1, "x"));
15622 assert!(!d.substitute(d.source.find('\u{201d}').unwrap() + 1, d.source.len(), "x"));
15623 assert_eq!(d.source, src);
15624 }
15625
15626 #[test]
15627 fn a_substitution_that_half_lands_takes_back_only_its_own_deletion() {
15628 // The deletion lands and twig refuses the literal text: the deleted
15629 // bytes go back, and nothing else moves — not an earlier substitution
15630 // of the same keystroke, not the group it is in, and no redo is left
15631 // to delete them again.
15632 let mut d = wysiwyg_doc("substitute", "\n");
15633 d.set_markup_mode(MarkupMode::None);
15634 d.caret = 0;
15635 for c in ["a", "\"", "b", " ", "\""] {
15636 d.insert(c);
15637 }
15638 assert_eq!(d.source, "a\"b \"\n");
15639
15640 d.refuse_next_literal = true;
15641 assert!(!d.substitute(1, 2, "\u{201c}"));
15642 assert_eq!(d.source, "a\"b \"\n");
15643 assert_eq!(d.caret, 5, "the caret where it stood");
15644 assert!(!d.can_redo(), "no redo step left behind");
15645
15646 d.begin_undo_group();
15647 assert!(d.substitute(4, 5, "\u{201d}"));
15648 d.refuse_next_literal = true;
15649 assert!(!d.substitute(1, 2, "\u{201c}"));
15650 assert!(d.in_undo_group(), "the group is still open");
15651 assert_eq!(d.source, "a\"b \u{201d}\n", "the first substitution stands");
15652 d.end_undo_group();
15653 assert!(!d.can_redo());
15654 assert!(d.undo());
15655 assert_eq!(d.source, "a\"b \"\n", "one step takes the group back");
15656 assert!(d.undo());
15657 assert_eq!(d.source, "\n", "and the typing is still one step");
15658 assert!(!d.can_undo());
15659
15660 // A group whose first edit is the one that half lands has no step.
15661 let mut d = wysiwyg_doc("substitute", "\n");
15662 d.set_markup_mode(MarkupMode::None);
15663 d.caret = 0;
15664 d.insert("\"");
15665 d.begin_undo_group();
15666 d.refuse_next_literal = true;
15667 assert!(!d.substitute(0, 1, "\u{201c}"));
15668 assert!(d.substitute(0, 1, "\u{201c}"));
15669 d.end_undo_group();
15670 assert!(d.undo());
15671 assert_eq!(
15672 d.source, "\"\n",
15673 "the substitution that landed is the group's step"
15674 );
15675 assert!(d.undo());
15676 assert_eq!(d.source, "\n");
15677 }
15678
15679 #[test]
15680 fn a_substitution_is_written_the_way_typing_writes() {
15681 // Where typing is literal, so is a replacement's text.
15682 let mut d = wysiwyg_doc("substitute", "say hi\n");
15683 d.set_markup_mode(MarkupMode::None);
15684 assert!(d.substitute(4, 6, "*hi*"));
15685 assert_eq!(d.source, "say \\*hi\\*\n");
15686 assert!(d.undo());
15687 assert_eq!(d.source, "say hi\n", "one step, escapes and all");
15688 }
15689
15690 #[test]
15691 fn can_undo_is_false_once_a_coalesced_run_is_undone() {
15692 // The task's repro: five characters typed are one twig step, and the
15693 // counter used to say five.
15694 let mut d = wysiwyg_doc("undo_truth", "\n");
15695 d.caret = 0;
15696 for c in ["h", "e", "l", "l", "o"] {
15697 d.insert(c);
15698 }
15699 assert!(d.can_undo());
15700 assert!(d.undo(), "the run undoes");
15701 assert_eq!(d.source, "\n");
15702 assert!(!d.can_undo(), "and nothing is left behind it");
15703 let rev = d.revision();
15704 assert!(!d.undo(), "an undo that does not move says so");
15705 assert_eq!(d.revision(), rev);
15706 assert!(d.can_redo());
15707 assert!(d.redo());
15708 assert_eq!(d.source, "hello\n");
15709 assert!(!d.can_redo());
15710 assert!(!d.redo());
15711 }
15712
15713 #[test]
15714 fn can_undo_is_exact_across_the_gestures_that_coalesce() {
15715 // Each gesture below folds, or may fold, one twig step into another.
15716 // After it, the mirror must name exactly the number of undos twig
15717 // takes — and the same number of redos back.
15718 type Gesture = fn(&mut Doc);
15719 let cases: &[(&str, &str, Gesture)] = &[
15720 ("typing", "\n", |d| {
15721 d.caret = 0;
15722 d.insert("ab");
15723 d.insert("c");
15724 d.move_left(false);
15725 d.insert("x");
15726 }),
15727 ("backspaces", "abcdef\n", |d| {
15728 d.caret = 6;
15729 d.backspace();
15730 d.backspace();
15731 d.insert("z");
15732 d.backspace();
15733 }),
15734 ("ordered list item", "1. one\n2. two\n", |d| {
15735 d.caret = 6;
15736 d.newline();
15737 d.insert("x");
15738 }),
15739 ("list indent", "- one\n- two\n", |d| {
15740 d.caret = d.source.find("two").unwrap();
15741 d.indent();
15742 d.outdent();
15743 }),
15744 ("bold then type", "one two\n", |d| {
15745 d.select_range(0, 3);
15746 d.toggle(InlineKind::Strong);
15747 d.caret = d.source.len() - 1;
15748 d.insert("!");
15749 }),
15750 ("highlight", "one two\n", |d| {
15751 d.select_range(0, 3);
15752 d.highlight(None);
15753 d.highlight(Some(MarkColor::Green));
15754 }),
15755 ("heading and quote", "one\n", |d| {
15756 d.caret = 1;
15757 d.toggle_heading(2);
15758 d.toggle_blockquote();
15759 }),
15760 ("footnote", "one\n", |d| {
15761 d.caret = 3;
15762 d.insert_footnote();
15763 d.insert("note");
15764 }),
15765 ("blocks", "one\n\ntwo\n\nthree\n", |d| {
15766 d.caret = 1;
15767 d.move_block_down();
15768 d.toggle_list(true);
15769 d.set_alignment(Some(Align::Center));
15770 d.insert_page_break();
15771 d.insert_table(2, 2);
15772 }),
15773 ("paste and compose", "\n", |d| {
15774 d.caret = 0;
15775 d.paste("one two");
15776 d.delete_word_back();
15777 d.edit_composing(d.caret, d.caret, "か");
15778 d.edit_composing(d.caret - 3, d.caret, "蚊");
15779 d.end_composition();
15780 d.insert("z");
15781 }),
15782 ("table", "| a | b |\n| - | - |\n| c | d |\n", |d| {
15783 d.place_caret(d.source.find("d |").unwrap(), false);
15784 d.cell_tab(true);
15785 d.insert("e");
15786 d.cell_return();
15787 }),
15788 ("replace all", "cat cat cat\n", |d| {
15789 d.caret = 0;
15790 d.insert("a ");
15791 replace_all_in(d, "cat", "dog");
15792 d.insert("!");
15793 }),
15794 ("nested group", "one\n\ntwo\n", |d| {
15795 d.begin_undo_group();
15796 d.caret = 3;
15797 d.insert("x");
15798 d.begin_undo_group();
15799 d.toggle_heading(1);
15800 d.end_undo_group();
15801 d.insert("y");
15802 d.end_undo_group();
15803 d.backspace();
15804 }),
15805 ("group around a list", "1. one\n2. two\n", |d| {
15806 d.begin_undo_group();
15807 d.caret = 6;
15808 d.newline();
15809 d.insert("x");
15810 d.end_undo_group();
15811 }),
15812 ("substitution", "\n", |d| {
15813 d.caret = 0;
15814 d.insert("t");
15815 d.insert("e");
15816 d.insert("h");
15817 d.insert(" ");
15818 d.substitute(0, 3, "the");
15819 d.insert("c");
15820 }),
15821 ("empty group", "one\n", |d| {
15822 d.caret = 3;
15823 d.insert("x");
15824 d.begin_undo_group();
15825 d.end_undo_group();
15826 d.insert("y");
15827 }),
15828 ("undo inside a group", "one\n", |d| {
15829 d.caret = 3;
15830 d.begin_undo_group();
15831 d.insert("x");
15832 d.undo();
15833 d.insert("y");
15834 d.insert("z");
15835 d.end_undo_group();
15836 }),
15837 ];
15838 for (name, src, gesture) in cases {
15839 let mut d = wysiwyg_doc("undo_exact", src);
15840 gesture(&mut d);
15841 let (steps, after) = (d.undo_steps, d.source.clone());
15842 let mut undone = 0;
15843 while d.can_undo() {
15844 assert!(d.undo(), "{name}: can_undo said yes, undo moved nothing");
15845 undone += 1;
15846 }
15847 assert!(!d.undo(), "{name}: can_undo said no, undo moved");
15848 assert_eq!(undone, steps, "{name}");
15849 assert_eq!(d.source, *src, "{name}: back to the start");
15850 let mut redone = 0;
15851 while d.can_redo() {
15852 assert!(d.redo(), "{name}: can_redo said yes, redo moved nothing");
15853 redone += 1;
15854 }
15855 assert!(!d.redo(), "{name}: can_redo said no, redo moved");
15856 assert_eq!(redone, steps, "{name}");
15857 assert_eq!(d.source, after, "{name}: forward to the end");
15858 }
15859 }
15860
15861 #[test]
15862 fn two_compositions_are_two_undo_steps() {
15863 let mut d = doc_with("compose_two", "\n");
15864 d.caret = 0;
15865 d.edit_composing(0, 0, "か");
15866 d.edit_composing(0, 3, "蚊");
15867 d.end_composition();
15868 d.edit_composing(3, 3, "き");
15869 d.edit_composing(3, 6, "木");
15870 d.end_composition();
15871 assert_eq!(d.source, "蚊木\n");
15872 d.undo();
15873 assert_eq!(d.source, "蚊\n", "only the second composition");
15874 d.undo();
15875 assert_eq!(d.source, "\n");
15876 }
15877
15878 #[test]
15879 fn a_composition_does_not_fold_into_the_typing_around_it() {
15880 let mut d = doc_with("compose_typing", "\n");
15881 d.caret = 0;
15882 d.insert("a");
15883 d.insert("b");
15884 d.edit_composing(2, 2, "か");
15885 d.edit_composing(2, 5, "蚊");
15886 d.end_composition();
15887 d.insert("c");
15888 assert_eq!(d.source, "ab蚊c\n");
15889 d.undo();
15890 assert_eq!(d.source, "ab蚊\n");
15891 d.undo();
15892 assert_eq!(d.source, "ab\n");
15893 d.undo();
15894 assert_eq!(d.source, "\n");
15895 }
15896
15897 #[test]
15898 fn ending_a_composition_that_never_began_leaves_a_typing_run_alone() {
15899 let mut d = doc_with("compose_spurious", "\n");
15900 d.caret = 0;
15901 d.insert("a");
15902 d.end_composition(); // an IME unmarking unprompted
15903 d.insert("b");
15904 assert_eq!(d.source, "ab\n");
15905 d.undo();
15906 assert_eq!(d.source, "\n", "still one typed run");
15907 }
15908
15909 // ── the clipboard's rich flavor ──────────────────────────────────────────
15910
15911 #[test]
15912 fn an_inline_selection_publishes_html_without_a_paragraph_wrapper() {
15913 let mut d = doc_with("sel_inline", "a **bold** c\n");
15914 d.anchor = Some(2);
15915 d.caret = 10; // `**bold**`, inside the paragraph
15916 assert_eq!(d.selection_html().as_deref(), Some("<strong>bold</strong>"));
15917 }
15918
15919 #[test]
15920 fn a_whole_block_selection_keeps_its_paragraph() {
15921 let mut d = doc_with("sel_block", "a **bold** c\n");
15922 d.anchor = Some(0);
15923 d.caret = 12; // the entire paragraph
15924 assert_eq!(
15925 d.selection_html().as_deref(),
15926 Some("<p>a <strong>bold</strong> c</p>")
15927 );
15928 }
15929
15930 #[test]
15931 fn a_multi_block_selection_keeps_its_structure() {
15932 let mut d = doc_with("sel_multi", "para\n\n- one\n- two\n");
15933 d.select_all();
15934 let html = d.selection_html().expect("renders");
15935 assert!(html.contains("<p>para</p>"), "{html:?}");
15936 assert!(html.contains("<li>one</li>"), "{html:?}");
15937 }
15938
15939 #[test]
15940 fn a_word_inside_a_heading_publishes_as_text_not_a_heading() {
15941 // The fragment `Head` is a paragraph standalone; the *document* says it
15942 // sits inside one block, so the wrapper is an artifact either way.
15943 let mut d = doc_with("sel_heading", "# Head line\n");
15944 d.anchor = Some(2);
15945 d.caret = 6;
15946 assert_eq!(d.selection_html().as_deref(), Some("Head"));
15947 }
15948
15949 #[test]
15950 fn no_selection_publishes_no_html() {
15951 let mut d = doc_with("sel_none", "a b\n");
15952 d.caret = 1;
15953 assert_eq!(d.selection_html(), None);
15954 }
15955
15956 #[test]
15957 fn pasting_html_converts_it_and_is_one_undo_step() {
15958 let mut d = doc_with("paste_html", "x\n");
15959 d.caret = 1;
15960 assert!(d.paste_html("<p>a <strong>b</strong> c</p>"));
15961 assert_eq!(d.source, "xa **b** c\n");
15962 d.undo();
15963 assert_eq!(d.source, "x\n", "the whole paste, in one step");
15964 }
15965
15966 #[test]
15967 fn pasting_html_replaces_the_selection() {
15968 let mut d = doc_with("paste_html_sel", "keep drop\n");
15969 d.anchor = Some(5);
15970 d.caret = 9;
15971 assert!(d.paste_html("<em>new</em>"));
15972 assert_eq!(d.source, "keep *new*\n");
15973 }
15974
15975 #[test]
15976 fn html_that_would_paste_garbage_declines_so_the_caller_falls_back() {
15977 let mut d = doc_with("paste_html_bad", "x\n");
15978 d.caret = 1;
15979 // twig builds no table from HTML; raw `<table>` in prose is worse than
15980 // the plain flavor the caller still holds.
15981 assert!(!d.paste_html("<table><tr><td>a</td></tr></table>"));
15982 assert_eq!(d.source, "x\n", "declined edits nothing");
15983 }
15984
15985 #[test]
15986 fn copy_then_paste_round_trips_through_the_html_flavor() {
15987 let mut d = doc_with("clip_round", "a **b** and [l](https://x.dev)\n");
15988 d.select_all();
15989 let html = d.selection_html().expect("renders");
15990 let mut into = doc_with("clip_round_dst", "\n");
15991 into.caret = 0;
15992 assert!(into.paste_html(&html));
15993 assert_eq!(into.source, "a **b** and [l](https://x.dev)\n");
15994 }
15995
15996 #[test]
15997 fn moving_the_caret_starts_a_new_undo_group() {
15998 let mut d = doc_with("break", "\n");
15999 d.caret = 0;
16000 d.insert("a");
16001 d.insert("b"); // "ab\n", caret at 2
16002 d.move_left(false); // breaks the run
16003 d.insert("X"); // "aXb\n"
16004 assert_eq!(d.source, "aXb\n");
16005 d.undo();
16006 assert_eq!(
16007 d.source, "ab\n",
16008 "first undo removes only the post-move insert"
16009 );
16010 d.undo();
16011 assert_eq!(d.source, "\n", "second undo removes the earlier run");
16012 }
16013
16014 #[test]
16015 fn undo_reverses_a_format_toggle() {
16016 let mut d = doc_with("fmt_undo", "a word b\n");
16017 d.anchor = Some(2);
16018 d.caret = 6;
16019 d.toggle(InlineKind::Strong);
16020 assert_eq!(d.source, "a **word** b\n");
16021 d.undo();
16022 assert_eq!(d.source, "a word b\n");
16023 }
16024
16025 #[test]
16026 fn undo_back_to_the_saved_state_clears_dirty() {
16027 let mut d = doc_with("dirty_undo", "hello\n");
16028 assert!(!d.dirty);
16029 d.caret = 5;
16030 d.insert("!");
16031 assert!(d.dirty);
16032 d.undo();
16033 assert!(
16034 !d.dirty,
16035 "undoing to the saved source is not a modification"
16036 );
16037 }
16038
16039 #[test]
16040 fn marking_saved_as_what_was_written_keeps_later_typing_dirty() {
16041 let mut d = doc_with("saved_as", "Does this\n");
16042 d.caret = 9;
16043 d.insert(" ");
16044 // The host reads the source, and its write takes a while…
16045 let written = d.source.clone();
16046 // …during which the rest of the sentence is typed.
16047 d.insert("work?");
16048 d.mark_saved_as(&written);
16049 assert!(d.dirty, "what was typed during the write is not saved");
16050 d.undo();
16051 assert!(!d.dirty, "undoing to what was written is the saved state");
16052
16053 d.redo();
16054 let written = d.source.clone();
16055 d.mark_saved_as(&written);
16056 assert!(!d.dirty, "nothing typed meanwhile: saved");
16057 }
16058
16059 #[test]
16060 fn a_new_edit_invalidates_redo() {
16061 let mut d = doc_with("redo_inv", "\n");
16062 d.caret = 0;
16063 d.insert("a");
16064 d.undo();
16065 d.insert("b"); // diverges — the redo of "a" is now gone
16066 d.redo();
16067 assert_eq!(d.source, "b\n");
16068 }
16069
16070 #[test]
16071 fn can_undo_and_can_redo_follow_the_history_a_menu_would_enable_by() {
16072 let mut d = doc_with("can_undo", "hello\n");
16073 assert!(
16074 !d.can_undo() && !d.can_redo(),
16075 "a fresh document has no history"
16076 );
16077 d.caret = 5;
16078 d.insert("!");
16079 assert!(
16080 d.can_undo() && !d.can_redo(),
16081 "an edit is a step to take back"
16082 );
16083 d.undo();
16084 assert!(!d.can_undo() && d.can_redo(), "undone: only redo remains");
16085 d.redo();
16086 assert!(d.can_undo() && !d.can_redo(), "redone: back to undoable");
16087 d.undo();
16088 d.insert("?");
16089 assert!(
16090 d.can_undo() && !d.can_redo(),
16091 "a fresh edit ends the redo chain"
16092 );
16093 // A coalesced run over-counts steps — the bound is what a menu needs,
16094 // and it reconciles the moment twig reports the history empty.
16095 d.insert("a");
16096 d.insert("b");
16097 while d.can_undo() {
16098 d.undo();
16099 }
16100 assert_eq!(d.source, "hello\n");
16101 assert!(!d.can_undo());
16102 // A reading surface has nothing to undo, whatever the history holds.
16103 d.redo();
16104 d.set_read_only(true);
16105 assert!(!d.can_undo() && !d.can_redo());
16106 }
16107
16108 #[test]
16109 fn undo_on_empty_history_is_a_no_op() {
16110 let mut d = doc_with("undo_empty", "hi\n");
16111 d.undo();
16112 assert_eq!(d.source, "hi\n");
16113 assert_eq!(d.status.as_deref(), Some("nothing to undo"));
16114 }
16115
16116 #[test]
16117 fn a_one_character_paste_is_its_own_undo_step() {
16118 for view in [View::Source, View::Wysiwyg] {
16119 let mut d = doc_in(view, "paste_step", "ab\n");
16120 d.caret = 0;
16121 d.insert("x");
16122 d.insert("y"); // a run of typing
16123 d.paste("z"); // one character, but pasted — not part of that run
16124 assert_eq!(d.source, "xyzab\n");
16125 d.undo();
16126 assert_eq!(d.source, "xyab\n", "the paste undoes on its own");
16127 assert_eq!(d.caret, 2, "and hands back the caret it found");
16128 d.undo();
16129 assert_eq!(d.source, "ab\n", "the typed run is still one step under it");
16130 }
16131 }
16132
16133 #[test]
16134 fn the_same_character_typed_still_joins_the_run() {
16135 // The other half of the pair: `z` is a keystroke here and a paste above,
16136 // and the two undo differently. Nothing about the *string* says which —
16137 // which is why provenance has to come from the door the caller uses.
16138 for view in [View::Source, View::Wysiwyg] {
16139 let mut d = doc_in(view, "typed_run", "ab\n");
16140 d.caret = 0;
16141 d.insert("x");
16142 d.insert("y");
16143 d.insert("z");
16144 d.undo();
16145 assert_eq!(d.source, "ab\n", "one run, one step");
16146 }
16147 }
16148
16149 #[test]
16150 fn undo_restores_the_caret_to_where_it_was_not_to_the_edit_site() {
16151 for view in [View::Source, View::Wysiwyg] {
16152 let mut d = doc_in(view, "undo_caret", "hello world\n");
16153 d.caret = 11; // standing at the end of "world", away from the edit
16154 d.edit(0, 5, "goodbye");
16155 assert_eq!(d.source, "goodbye world\n");
16156 d.undo();
16157 assert_eq!(d.source, "hello world\n");
16158 // The undone edit ends at offset 5; the user was at 11.
16159 assert_eq!(d.caret, 11, "the caret comes back with the bytes");
16160 }
16161 }
16162
16163 #[test]
16164 fn undo_restores_the_selection_the_edit_replaced() {
16165 for view in [View::Source, View::Wysiwyg] {
16166 let mut d = doc_in(view, "undo_sel", "a word b\n");
16167 d.anchor = Some(2);
16168 d.caret = 6; // "word" selected
16169 d.insert("X");
16170 assert_eq!(d.source, "a X b\n");
16171 d.undo();
16172 assert_eq!(d.source, "a word b\n");
16173 assert_eq!(d.selection(), Some((2, 6)), "the selection comes back too");
16174 }
16175 }
16176
16177 #[test]
16178 fn redo_restores_the_caret_the_edit_left_behind() {
16179 for view in [View::Source, View::Wysiwyg] {
16180 let mut d = doc_in(view, "redo_caret", "hello world\n");
16181 d.caret = 11;
16182 d.edit(0, 5, "goodbye");
16183 assert_eq!(d.caret, 7, "the edit left the caret after its new text");
16184 d.undo();
16185 d.redo();
16186 assert_eq!(d.source, "goodbye world\n");
16187 assert_eq!(d.caret, 7, "redo puts it back where the edit had it");
16188 }
16189 }
16190
16191 #[test]
16192 fn undoing_a_typed_run_restores_the_caret_from_before_the_whole_run() {
16193 for view in [View::Source, View::Wysiwyg] {
16194 let mut d = doc_in(view, "run_caret", "hi\n");
16195 d.caret = 2;
16196 d.insert("a");
16197 d.insert("b");
16198 d.insert("c");
16199 assert_eq!(d.source, "hiabc\n");
16200 d.undo();
16201 assert_eq!(d.source, "hi\n");
16202 assert_eq!(d.caret, 2, "before the run, not before its last keystroke");
16203 d.redo();
16204 assert_eq!(d.caret, 5, "and redo restores the end of the whole run");
16205 }
16206 }
16207
16208 #[test]
16209 fn undo_restores_the_caret_across_a_format_toggle() {
16210 // A toggle reaches twig without going through `splice`, so it has to
16211 // record its own step — miss it and every stack depth below it is off by
16212 // one, and undo starts handing back another edit's caret.
16213 for view in [View::Source, View::Wysiwyg] {
16214 let mut d = doc_in(view, "fmt_caret", "a word b\n");
16215 d.caret = 8;
16216 d.anchor = Some(2);
16217 d.caret = 6;
16218 d.toggle(InlineKind::Strong);
16219 assert_eq!(d.source, "a **word** b\n");
16220 d.undo();
16221 assert_eq!(d.source, "a word b\n");
16222 assert_eq!(
16223 d.selection(),
16224 Some((2, 6)),
16225 "the toggled selection comes back"
16226 );
16227 }
16228 }
16229
16230 #[test]
16231 fn an_edit_after_an_undo_truncates_the_caret_history_with_twigs() {
16232 // The drift that would never announce itself: twig drops its redo stack
16233 // on any fresh edit, so a leaf redo entry that outlives it would restore
16234 // a caret from the timeline that edit abandoned.
16235 for view in [View::Source, View::Wysiwyg] {
16236 let mut d = doc_in(view, "redo_trunc", "hello world\n");
16237 d.caret = 11;
16238 d.edit(0, 5, "goodbye"); // step A, caret 11 → 7
16239 d.undo();
16240 assert_eq!(d.caret, 11);
16241 d.caret = 0;
16242 d.insert("X"); // diverges: A's redo is gone from twig
16243 assert_eq!(d.source, "Xhello world\n");
16244
16245 d.redo();
16246 assert_eq!(d.source, "Xhello world\n", "nothing to redo onto");
16247 assert_eq!(d.status.as_deref(), Some("nothing to redo"));
16248 d.undo();
16249 assert_eq!(d.source, "hello world\n");
16250 assert_eq!(
16251 d.caret, 0,
16252 "the surviving step's caret, not the dropped one"
16253 );
16254 }
16255 }
16256
16257 #[test]
16258 fn indent_and_outdent_move_the_caret_line_with_its_text() {
16259 for view in [View::Source, View::Wysiwyg] {
16260 let g = |m, f: fn(&mut Doc)| golden_in(view, "indent_line", m, f);
16261 assert_eq!(g("he|llo\n", |d| d.indent()), " he|llo\n");
16262 assert_eq!(g(" he|llo\n", |d| d.outdent()), "he|llo\n");
16263 // Indentation the caret is standing *in* collapses to the line start
16264 // rather than dragging the caret into the text.
16265 assert_eq!(g("| hello\n", |d| d.outdent()), "|hello\n");
16266 // A line with none to give back is left exactly as it was.
16267 assert_eq!(g("he|llo\n", |d| d.outdent()), "he|llo\n");
16268 // Less than a full level gives back what it has.
16269 assert_eq!(g(" he|llo\n", |d| d.outdent()), "he|llo\n");
16270 // A tab is one level however many spaces it isn't.
16271 assert_eq!(g("\the|llo\n", |d| d.outdent()), "he|llo\n");
16272 }
16273 }
16274
16275 #[test]
16276 fn one_indent_level_leaves_a_paragraph_a_paragraph() {
16277 // Why the level is two spaces and not the four both frontends type
16278 // today. Four is markdown's indented-code-block marker, so a Tab on a
16279 // paragraph would silently restyle it as code — a width that changes
16280 // what the document *means* isn't an indent. Pinned because the number
16281 // is the kind of thing a later list-aware pass would reach for.
16282 let mut d = doc_with("indent_kind", "hello\n");
16283 d.caret = 2;
16284 d.indent();
16285 assert_eq!(d.source, " hello\n");
16286 assert!(
16287 d.nodes().iter().any(|n| n.kind == Kind::Para),
16288 "still prose after a Tab"
16289 );
16290 assert!(!d.nodes().iter().any(|n| n.kind == Kind::CodeBlock));
16291
16292 // The four-space level this replaces, for contrast: same text, and twig
16293 // reparses the paragraph into a code block.
16294 let mut wide = doc_with("indent_kind_4", " hello\n");
16295 wide.build_visual(80);
16296 assert!(
16297 wide.nodes().iter().any(|n| n.kind == Kind::CodeBlock),
16298 "four spaces is a code block, not an indented paragraph"
16299 );
16300 }
16301
16302 #[test]
16303 fn indent_nests_a_list_item_under_its_parent() {
16304 // Tab indents a list item by its own marker width, landing its marker at
16305 // the parent's content column so twig reparses it as a nested list.
16306 for view in [View::Source, View::Wysiwyg] {
16307 let mut d = doc_in(view, "indent_nest", "- a\n- b\n");
16308 d.caret = 6; // on the second item
16309 d.indent();
16310 assert_eq!(d.source, "- a\n - b\n");
16311 let lists = d
16312 .nodes()
16313 .iter()
16314 .filter(|n| n.kind == Kind::BulletList)
16315 .count();
16316 assert_eq!(lists, 2, "the indented item is a nested list");
16317 }
16318 }
16319
16320 #[test]
16321 fn indent_nests_an_ordered_item_at_its_marker_width() {
16322 // An ordered marker `1. ` is three columns wide, so a two-space step
16323 // (which nests a bullet) leaves it flat. Regression: Tab must use the
16324 // marker width, three, so the item actually nests — and the source
16325 // renumbers so the sub-list restarts at 1 and the outer list resumes.
16326 for view in [View::Source, View::Wysiwyg] {
16327 let mut d = doc_in(view, "indent_ord", "1. a\n2. b\n3. c\n");
16328 d.caret = d.source.find('b').unwrap();
16329 d.indent();
16330 assert_eq!(d.source, "1. a\n 1. b\n2. c\n");
16331 let lists = d
16332 .nodes()
16333 .iter()
16334 .filter(|n| n.kind == Kind::OrderedList)
16335 .count();
16336 assert_eq!(lists, 2, "the indented item is a nested ordered list");
16337 }
16338 }
16339
16340 #[test]
16341 fn indent_leaves_a_lists_first_item_put() {
16342 // The first item of a list has no sibling above it to nest under, so Tab
16343 // is a no-op there — the marker stays at column zero rather than being
16344 // shoved into indentation twig can't read as a sub-list.
16345 for view in [View::Source, View::Wysiwyg] {
16346 let mut d = doc_in(view, "indent_first", "- a\n- b\n");
16347 d.caret = 1; // on the FIRST item
16348 d.indent();
16349 assert_eq!(d.source, "- a\n- b\n", "the first item doesn't nest");
16350 // The sibling below still nests, proving the guard is per-item.
16351 d.caret = d.source.find('b').unwrap();
16352 d.indent();
16353 assert_eq!(d.source, "- a\n - b\n");
16354 }
16355 }
16356
16357 #[test]
16358 fn hidden_mode_keeps_typed_markup_literal() {
16359 // The Diaryx default: typing `*hi*` gives the characters, not emphasis —
16360 // twig escapes what would open markup, so the source is `\*hi\*` and the
16361 // AST is a plain string. Formatting is the commands' job in this mode.
16362 let mut d = doc_in(View::Wysiwyg, "hidden_literal", "");
16363 d.insert("*hi*");
16364 assert_eq!(d.source, "\\*hi\\*");
16365 assert!(
16366 d.nodes()
16367 .iter()
16368 .all(|n| n.kind != Kind::Emph && n.kind != Kind::Strong)
16369 );
16370 }
16371
16372 #[test]
16373 fn hidden_mode_escapes_a_line_start_block_marker() {
16374 // A `#`/`-`/`>` at a line start would open a block, so Hidden mode keeps
16375 // it literal too — a Diaryx user's "# 1 idea" stays prose, not a heading.
16376 let mut d = doc_in(View::Wysiwyg, "hidden_block", "");
16377 d.insert("# hi");
16378 assert_eq!(d.source, "\\# hi");
16379 assert!(d.nodes().iter().all(|n| n.kind != Kind::Heading));
16380 }
16381
16382 #[test]
16383 fn authoring_modes_keep_typed_markup_live() {
16384 // Both authoring rungs of the ladder: typing `*hi*` really is emphasis
16385 // (no escape), the same as source view — escaping is `None`'s alone, and
16386 // it's the axis, not the reveal, that decides.
16387 for (view, mode) in [
16388 (View::Wysiwyg, MarkupMode::Shortcuts),
16389 (View::Wysiwyg, MarkupMode::Full),
16390 (View::Source, MarkupMode::None),
16391 ] {
16392 let mut d = doc_in(view, "live_markup", "");
16393 d.set_markup_mode(mode);
16394 d.insert("*hi*");
16395 assert_eq!(d.source, "*hi*", "{mode:?} in {view:?} types raw markup");
16396 }
16397 }
16398
16399 #[test]
16400 fn hidden_mode_overwrite_undoes_in_one_step() {
16401 // Typing over a selection escapes the replacement *and* stays a single
16402 // undo — the selection-delete and the literal insert fold together, so
16403 // one undo brings the whole selection back, like a plain overwrite.
16404 let mut d = doc_in(View::Wysiwyg, "hidden_overwrite", "a word b\n");
16405 d.anchor = Some(2);
16406 d.caret = 6; // "word"
16407 d.insert("*");
16408 assert_eq!(d.source, "a \\* b\n", "the replacement is escaped");
16409 d.undo();
16410 assert_eq!(d.source, "a word b\n");
16411 assert_eq!(d.selection(), Some((2, 6)), "one undo, selection restored");
16412 }
16413
16414 #[test]
16415 fn backspace_over_an_escaped_char_takes_the_hidden_backslash_too() {
16416 // Type `*` in Hidden mode → `\*` (drawn as one `*`); one Backspace clears
16417 // the whole visual character, never stranding the hidden `\`.
16418 let mut d = doc_in(View::Wysiwyg, "bsp_escape", "");
16419 d.insert("*");
16420 assert_eq!(d.source, "\\*");
16421 d.backspace();
16422 assert_eq!(d.source, "", "the escape backslash went with the *");
16423 // A *literal* backslash (source view, no escape) is an ordinary char.
16424 let mut s = doc_in(View::Source, "bsp_lit", "a\\b\n");
16425 s.caret = 3; // after `b`
16426 s.backspace();
16427 assert_eq!(s.source, "a\\\n", "only the b is deleted, the \\ stays");
16428 }
16429
16430 #[test]
16431 fn hidden_mode_leaves_structural_markup_alone() {
16432 // Enter continues a bullet list by writing a real `- ` marker (an
16433 // `insert_raw`, not the typing path), so Hidden mode's escaping never
16434 // touches it — the list keeps working.
16435 let mut d = doc_in(View::Wysiwyg, "hidden_struct", "- item\n");
16436 d.caret = 6;
16437 d.newline();
16438 d.insert("two");
16439 assert_eq!(d.source, "- item\n- two\n");
16440 }
16441
16442 #[test]
16443 fn markup_mode_defaults_to_none_and_round_trips() {
16444 // Diaryx's default is the clean `None` surface; a markup-fluent
16445 // frontend can climb the ladder, and the choice sticks.
16446 let mut d = doc_in(View::Wysiwyg, "markup_mode", "hi\n");
16447 assert_eq!(d.markup_mode(), MarkupMode::None, "None by default");
16448 for mode in [MarkupMode::Shortcuts, MarkupMode::Full, MarkupMode::None] {
16449 d.set_markup_mode(mode);
16450 assert_eq!(d.markup_mode(), mode);
16451 }
16452 }
16453
16454 #[test]
16455 fn full_mode_reveals_only_the_caret_line() {
16456 // The mode's whole claim: the caret's line shows its raw delimiters and
16457 // every other line stays resolved. Two paragraphs with identical markup
16458 // so the only difference between the rows is where the caret is.
16459 let mut d = doc_in(
16460 View::Wysiwyg,
16461 "reveal_caret_line",
16462 "*one* here\n\n*two* there\n",
16463 );
16464 d.set_markup_mode(MarkupMode::Full);
16465
16466 caret_at(&mut d, "one");
16467 let rows = drawn_rows(&d);
16468 assert!(
16469 rows.iter().any(|r| r == "*one* here"),
16470 "caret's line raw: {rows:?}"
16471 );
16472 assert!(
16473 rows.iter().any(|r| r == "two there"),
16474 "other line resolved: {rows:?}"
16475 );
16476
16477 // Move to the other paragraph: the reveal follows, and the line just
16478 // left goes back to being resolved.
16479 caret_at(&mut d, "two");
16480 let rows = drawn_rows(&d);
16481 assert!(
16482 rows.iter().any(|r| r == "*two* there"),
16483 "caret's line raw: {rows:?}"
16484 );
16485 assert!(
16486 rows.iter().any(|r| r == "one here"),
16487 "left line resolved: {rows:?}"
16488 );
16489 }
16490
16491 #[test]
16492 fn revealing_a_coloured_highlight_shows_the_emoji_that_spelled_it() {
16493 // The emoji is a delimiter, not content — so `MarkupMode::Full` owes it
16494 // the same treatment as an emphasis's `*`: hidden while the caret is
16495 // elsewhere, shown in full where the caret lands. That falls out of
16496 // `delims` reading the bytes between the mark's span and its content
16497 // span, which is exactly `==🔴 ` and `==`, rather than from a table
16498 // of spellings — so the no-space form `==🟢green==` reveals right too.
16499 let mut d = doc_in(
16500 View::Wysiwyg,
16501 "reveal_coloured_mark",
16502 "a ==🔴 red== one\n\nb ==plain== two\n",
16503 );
16504 d.set_markup_mode(MarkupMode::Full);
16505
16506 caret_at(&mut d, "red");
16507 let rows = drawn_rows(&d);
16508 assert!(
16509 rows.iter().any(|r| r == "a ==🔴 red== one"),
16510 "the caret's line shows the colour it was written with: {rows:?}"
16511 );
16512 assert!(
16513 rows.iter().any(|r| r == "b plain two"),
16514 "and every other line stays resolved: {rows:?}"
16515 );
16516
16517 // Away from it, the emoji goes back to being markup — the reader sees
16518 // the words and the wash.
16519 caret_at(&mut d, "two");
16520 let rows = drawn_rows(&d);
16521 assert!(
16522 rows.iter().any(|r| r == "a red one"),
16523 "resolved again: {rows:?}"
16524 );
16525 }
16526
16527 #[test]
16528 fn hidden_modes_never_reveal_wherever_the_caret_is() {
16529 // The two rungs below `Full` share a rendering: delimiters stay hidden
16530 // even under the caret. `Shortcuts` differing from `None` only in what
16531 // typing does is exactly the point of splitting the axes.
16532 for mode in [MarkupMode::None, MarkupMode::Shortcuts] {
16533 let mut d = doc_in(View::Wysiwyg, "reveal_hidden", "*one* here\n");
16534 d.set_markup_mode(mode);
16535 caret_at(&mut d, "one");
16536 let rows = drawn_rows(&d);
16537 assert!(
16538 rows.iter().any(|r| r == "one here"),
16539 "{mode:?} hides: {rows:?}"
16540 );
16541 assert!(
16542 !rows.iter().any(|r| r.contains('*')),
16543 "{mode:?} shows no `*`: {rows:?}"
16544 );
16545 }
16546 }
16547
16548 #[test]
16549 fn revealed_delimiters_are_the_authors_own_spelling() {
16550 // Delimiters are re-read from the source rather than synthesized per
16551 // kind, so a line comes back spelled the way it was written: `_em_` does
16552 // not turn into `*em*`, and a two-backtick fence keeps both backticks.
16553 let body = "_em_ and __st__ and ``lit ` tick`` and [lk](http://x) and ~~del~~\n";
16554 let mut d = doc_in(View::Wysiwyg, "reveal_spelling", body);
16555 d.set_markup_mode(MarkupMode::Full);
16556 caret_at(&mut d, "em");
16557 let rows = drawn_rows(&d);
16558 assert!(
16559 rows.iter().any(|r| r == body.trim_end()),
16560 "the revealed line is its own source: {rows:?}"
16561 );
16562 }
16563
16564 #[test]
16565 fn revealed_heading_shows_its_hashes() {
16566 // The `# ` marker is a block-level prefix, not an inline delimiter, so
16567 // it takes its own path — but it reveals on the same rule.
16568 let mut d = doc_in(View::Wysiwyg, "reveal_heading", "# Title\n\nbody\n");
16569 d.set_markup_mode(MarkupMode::Full);
16570
16571 caret_at(&mut d, "Title");
16572 assert!(
16573 drawn_rows(&d).iter().any(|r| r == "# Title"),
16574 "{:?}",
16575 drawn_rows(&d)
16576 );
16577
16578 caret_at(&mut d, "body");
16579 let rows = drawn_rows(&d);
16580 assert!(
16581 rows.iter().any(|r| r == "Title"),
16582 "hashes hidden again: {rows:?}"
16583 );
16584 }
16585
16586 #[test]
16587 fn revealed_delimiters_are_caret_stops() {
16588 // A delimiter that is drawn but can't be reached is worse than one
16589 // that's hidden: the mode exists so the markup can be *edited*. Every
16590 // revealed byte must be somewhere the caret can stand.
16591 let mut d = doc_in(View::Wysiwyg, "reveal_stops", "*em* x\n");
16592 d.set_markup_mode(MarkupMode::Full);
16593 caret_at(&mut d, "em");
16594 let opener = d.source.find('*').unwrap();
16595 assert!(d.vmap.is_stop(opener), "the opening `*` is a caret stop");
16596 assert!(
16597 d.vmap.is_stop(opener + 3),
16598 "the closing `*` is a caret stop"
16599 );
16600 }
16601
16602 #[test]
16603 fn setext_heading_reveals_nothing_across_its_newline() {
16604 // A setext heading's underline is on another line, so it is not the
16605 // caret line's to reveal — and emitting it would inject a `\n` glyph
16606 // that splits the row where the author wrote no break.
16607 let mut d = doc_in(View::Wysiwyg, "reveal_setext", "Title\n=====\n\nbody\n");
16608 d.set_markup_mode(MarkupMode::Full);
16609 caret_at(&mut d, "Title");
16610 let rows = drawn_rows(&d);
16611 assert!(
16612 rows.iter().any(|r| r == "Title"),
16613 "title renders alone: {rows:?}"
16614 );
16615 assert!(
16616 !rows.iter().any(|r| r.contains('=')),
16617 "no underline leaks in: {rows:?}"
16618 );
16619 }
16620
16621 #[test]
16622 fn markup_mode_axes_split_the_ladder() {
16623 // The two behaviours the ladder spells: `Shortcuts` is the middle rung
16624 // that authors markup but still hides it, and it's the only rung where
16625 // the two axes disagree.
16626 assert!(!MarkupMode::None.authors());
16627 assert!(!MarkupMode::None.reveals_caret_line());
16628 assert!(MarkupMode::Shortcuts.authors());
16629 assert!(!MarkupMode::Shortcuts.reveals_caret_line());
16630 assert!(MarkupMode::Full.authors());
16631 assert!(MarkupMode::Full.reveals_caret_line());
16632 }
16633
16634 #[test]
16635 fn indenting_an_empty_dash_item_under_text_dodges_the_setext_collapse() {
16636 // Tabbing an empty `- ` under a text line would spell `- hello\n - `,
16637 // which twig (correctly, per CommonMark — pandoc agrees) reparses as a
16638 // setext H2. leaf swaps the dash for a `*` so the item stays an empty
16639 // nested bullet and `hello` stays prose: the file round-trips instead of
16640 // hiding a heading the user never asked for.
16641 for view in [View::Source, View::Wysiwyg] {
16642 let mut d = doc_in(view, "setext_guard", "- hello\n- \n");
16643 d.caret = d.source.find("- \n").unwrap() + 2; // after the empty marker
16644 d.indent();
16645 assert_eq!(d.source, "- hello\n * \n");
16646 assert!(
16647 d.nodes().iter().all(|n| n.kind != Kind::Heading),
16648 "no heading"
16649 );
16650 // And it's genuinely a nested list, not a flat one.
16651 assert_eq!(
16652 d.nodes()
16653 .iter()
16654 .filter(|n| n.kind == Kind::BulletList)
16655 .count(),
16656 2
16657 );
16658 }
16659 }
16660
16661 #[test]
16662 fn indenting_a_dash_item_with_content_keeps_its_dash() {
16663 // With content, `- x` can't be a setext underline, so there's nothing to
16664 // dodge: the marker stays a dash and nests as an ordinary sub-bullet.
16665 let mut d = doc_in(View::Wysiwyg, "setext_ok", "- hello\n- x\n");
16666 d.caret = d.source.find('x').unwrap();
16667 d.indent();
16668 assert_eq!(d.source, "- hello\n - x\n");
16669 }
16670
16671 #[test]
16672 fn the_setext_swap_undoes_as_one_step_with_the_indent() {
16673 // The dash→`*` repair coalesces into the Tab, so a single undo restores
16674 // the whole pre-Tab state rather than stranding a half-collapsed doc.
16675 let mut d = doc_in(View::Wysiwyg, "setext_undo", "- hello\n- \n");
16676 d.caret = d.source.find("- \n").unwrap() + 2;
16677 d.indent();
16678 assert_eq!(d.source, "- hello\n * \n");
16679 d.undo();
16680 assert_eq!(d.source, "- hello\n- \n", "one undo, not two");
16681 }
16682
16683 #[test]
16684 fn indent_leaves_a_nested_lists_first_item_put_too() {
16685 // The guard is about siblings, not depth: the first item of an *inner*
16686 // list (already nested under `a`) still has nothing before it at its own
16687 // level, so Tab can't take it deeper.
16688 let mut d = doc_in(View::Wysiwyg, "indent_first_nested", "- a\n - b\n - c\n");
16689 d.caret = d.source.find('b').unwrap();
16690 d.indent();
16691 assert_eq!(d.source, "- a\n - b\n - c\n", "inner first item holds");
16692 // But `c` (a sibling of `b`) nests under `b`.
16693 d.caret = d.source.find('c').unwrap();
16694 d.indent();
16695 assert_eq!(d.source, "- a\n - b\n - c\n");
16696 }
16697
16698 #[test]
16699 fn backspace_at_a_nested_item_start_outdents_it() {
16700 // Backspace with the caret right after a nested item's marker gives back
16701 // one level of nesting, the mirror of Tab — and renumbers the flattened
16702 // ordered list back to a clean run.
16703 let mut d = doc_in(View::Wysiwyg, "bsp_outdent", "1. a\n 1. b\n2. c\n");
16704 d.caret = d.source.find('b').unwrap(); // start of the nested item's content
16705 d.backspace();
16706 assert_eq!(d.source, "1. a\n2. b\n3. c\n");
16707 }
16708
16709 #[test]
16710 fn backspace_at_a_top_level_item_start_strips_the_marker() {
16711 // At the outermost level there's no nesting left to give back, so the same
16712 // keystroke drops the bullet and leaves a plain paragraph.
16713 let mut d = doc_in(View::Wysiwyg, "bsp_strip", "- a\n- b\n");
16714 d.caret = d.source.find('b').unwrap(); // right after `- `
16715 d.backspace();
16716 assert_eq!(d.source, "- a\nb\n", "the marker is gone, the text stays");
16717 }
16718
16719 #[test]
16720 fn backspace_mid_item_still_deletes_a_character() {
16721 // The list behaviour is armed only at the item's content start; anywhere
16722 // else Backspace is the ordinary character delete.
16723 let mut d = doc_in(View::Wysiwyg, "bsp_mid", "- ab\n");
16724 d.caret = d.source.find('b').unwrap(); // between `a` and `b`
16725 d.backspace();
16726 assert_eq!(d.source, "- b\n");
16727 }
16728
16729 #[test]
16730 fn backspace_at_a_heading_start_strips_the_marker() {
16731 // The `# ` is markup the rich view hides, so Backspace over it takes the
16732 // whole marker and leaves a paragraph. Deleting a byte of it instead left
16733 // `#Title` — no longer a heading, with the hash now literal text the user
16734 // never typed and has to delete again.
16735 let mut d = doc_in(View::Wysiwyg, "bsp_head", "## Title\n");
16736 d.caret = d.source.find('T').unwrap(); // right after `## `
16737 d.backspace();
16738 assert_eq!(d.source, "Title\n");
16739 assert_eq!(
16740 d.caret, 0,
16741 "the caret stays with the text it was in front of"
16742 );
16743 }
16744
16745 #[test]
16746 fn backspace_at_a_heading_start_keeps_the_block_around_it() {
16747 // Only the heading's own marker goes — the quote (or list) it sits in is
16748 // untouched, exactly as un-heading it should be.
16749 let mut d = doc_in(View::Wysiwyg, "bsp_head_quote", "> # Title\n");
16750 d.caret = d.source.find('T').unwrap();
16751 d.backspace();
16752 assert_eq!(d.source, "> Title\n");
16753 }
16754
16755 #[test]
16756 fn backspace_at_a_heading_start_takes_its_closing_sequence_too() {
16757 // `# Title #`'s trailing hashes are hidden at the other end; leaving them
16758 // behind would surface the same stray hash the marker delete just avoided.
16759 let mut d = doc_in(View::Wysiwyg, "bsp_head_closed", "# Title #\n");
16760 d.caret = d.source.find('T').unwrap();
16761 d.backspace();
16762 assert_eq!(d.source, "Title\n");
16763 // And it's one edit: a single undo puts the whole heading back.
16764 d.undo();
16765 assert_eq!(d.source, "# Title #\n");
16766 }
16767
16768 #[test]
16769 fn backspace_mid_heading_still_deletes_a_character() {
16770 // The heading behaviour is armed only at the content's start; anywhere
16771 // else Backspace is the ordinary character delete.
16772 let mut d = doc_in(View::Wysiwyg, "bsp_head_mid", "# ab\n");
16773 d.caret = d.source.find('b').unwrap();
16774 d.backspace();
16775 assert_eq!(d.source, "# b\n");
16776 }
16777
16778 #[test]
16779 fn source_view_backspace_still_edits_the_heading_marker_literally() {
16780 // In source view the `# ` is text on the screen the user is deleting a
16781 // byte of, so it keeps its literal meaning — the same split the list
16782 // ladder and Enter draw between the two views.
16783 let mut d = doc_with("bsp_head_src", "# Title\n");
16784 d.caret = d.source.find('T').unwrap();
16785 d.backspace();
16786 assert_eq!(d.source, "#Title\n");
16787 }
16788
16789 #[test]
16790 fn outdent_unnests_an_ordered_item_in_one_press() {
16791 // Shift+Tab gives back exactly the marker width the indent added, so a
16792 // nested ordered item unnests in a single press, and the flattened list
16793 // renumbers back to a clean 1, 2, 3.
16794 let mut d = doc_with("outdent_ord", "1. a\n 2. b\n3. c\n");
16795 d.caret = d.source.find('b').unwrap();
16796 d.outdent();
16797 assert_eq!(d.source, "1. a\n2. b\n3. c\n");
16798 let lists = d
16799 .nodes()
16800 .iter()
16801 .filter(|n| n.kind == Kind::OrderedList)
16802 .count();
16803 assert_eq!(lists, 1, "back to one flat list");
16804 }
16805
16806 #[test]
16807 fn table_insert_row_adds_a_row_below_the_caret() {
16808 let mut d = doc_with("tbl_ins_row", "| a | b |\n| --- | --- |\n| 1 | 2 |\n");
16809 d.caret = d.source.find('1').unwrap(); // in the body row
16810 d.table_insert_row(true);
16811 assert_eq!(d.source, "| a | b |\n| --- | --- |\n| 1 | 2 |\n| | |\n");
16812 }
16813
16814 #[test]
16815 fn table_insert_and_delete_column_at_the_caret() {
16816 let mut d = doc_with("tbl_col", "| a | b |\n| --- | --- |\n| 1 | 2 |\n");
16817 d.caret = d.source.find('a').unwrap(); // column 0
16818 d.table_insert_column(true); // add a column to the right of `a`
16819 assert_eq!(
16820 d.source,
16821 "| a | | b |\n| --- | --- | --- |\n| 1 | | 2 |\n"
16822 );
16823 d.caret = d.source.find('b').unwrap(); // now the third column
16824 d.table_delete_column();
16825 assert_eq!(d.source, "| a | |\n| --- | --- |\n| 1 | |\n");
16826 }
16827
16828 // ── ragged formats ───────────────────────────────────────────────────────
16829 // No format spells every gesture. HTML writes the inline marks as a tag pair
16830 // and no heading, list, quote or link; Markdown spells five of the eight
16831 // marks — the highlight only because leaf parses with `highlight`, which is
16832 // why the question is asked with the extensions; djot spells all eight and
16833 // no in-cell break. leaf asks twig per
16834 // gesture (`Doc::supports`) and refuses at the door, rather than letting each
16835 // op discover the fact on its own — one of them didn't.
16836
16837 /// An HTML document in the rich view, ready for a gesture.
16838 fn html_doc(body: &str) -> Doc {
16839 let mut d = Doc::from_source(body.to_string(), Format::Html).unwrap();
16840 d.view = View::Wysiwyg;
16841 d.build_visual(80);
16842 d
16843 }
16844
16845 #[test]
16846 fn a_table_gesture_leaves_an_html_table_alone() {
16847 // The regression this guard exists for. twig's table editor consults no
16848 // `Syntax` table — it spells a grid, not a delimiter — so it rebuilt an
16849 // HTML `<table>` as a *pipe table* and reported success: the whole
16850 // element replaced by `| a | b |`, silently, on one press of a toolbar
16851 // button. Every grid op went the same way.
16852 let src = "<table><tr><td>a</td><td>b</td></tr><tr><td>c</td><td>d</td></tr></table>\n";
16853 // A table of named operations, which is what it looks like.
16854 #[allow(clippy::type_complexity)]
16855 let ops: [(&str, &dyn Fn(&mut Doc)); 7] = [
16856 ("insert row", &|d: &mut Doc| d.table_insert_row(true)),
16857 ("delete row", &|d: &mut Doc| d.table_delete_row()),
16858 ("insert column", &|d: &mut Doc| d.table_insert_column(true)),
16859 ("delete column", &|d: &mut Doc| d.table_delete_column()),
16860 ("align", &|d: &mut Doc| {
16861 d.table_set_alignment(Alignment::Right)
16862 }),
16863 ("move row", &|d: &mut Doc| d.table_move_row(true)),
16864 ("move column", &|d: &mut Doc| d.table_move_column(true)),
16865 ];
16866 for (name, op) in ops {
16867 let mut d = html_doc(src);
16868 d.caret = d.source.find('a').unwrap();
16869 assert!(d.caret_in_table(), "{name}: the caret really is in a table");
16870 op(&mut d);
16871 assert_eq!(d.source, src, "{name} rewrote an HTML table");
16872 assert!(
16873 !d.dirty,
16874 "{name} marked the document dirty without editing it"
16875 );
16876 assert!(d.status.is_some(), "{name} refused without saying why");
16877 }
16878 }
16879
16880 #[test]
16881 fn the_block_gestures_html_cannot_spell_are_refused_with_a_reason() {
16882 // A task box is a form control in HTML and a footnote has no native
16883 // spelling at all — the two gestures twig 3.5 still spells nothing
16884 // for, now that a quote, a list, a link and an image print through
16885 // its renderer (see the test below).
16886 let src = "<h1>Title</h1>\n<p>Hello world</p>\n<ul><li>one</li></ul>\n";
16887 // A table of named operations, which is what it looks like.
16888 #[allow(clippy::type_complexity)]
16889 let ops: [(&str, &dyn Fn(&mut Doc)); 3] = [
16890 ("task item", &|d: &mut Doc| d.toggle_task_item()),
16891 ("task tick", &|d: &mut Doc| d.toggle_task_checked()),
16892 ("footnote", &|d: &mut Doc| d.insert_footnote()),
16893 ];
16894 for (name, op) in ops {
16895 let mut d = html_doc(src);
16896 let at = d.source.find("Hello").unwrap();
16897 d.caret = at;
16898 d.anchor = Some(at + 5); // a selection, for the ops that want one
16899 op(&mut d);
16900 assert_eq!(d.source, src, "{name} edited an HTML document");
16901 assert!(
16902 !d.dirty,
16903 "{name} marked the document dirty without editing it"
16904 );
16905 let status = d.status.as_deref().unwrap_or("");
16906 assert!(
16907 status.contains("html"),
16908 "{name}: the refusal should name the format, got {status:?}"
16909 );
16910 }
16911 }
16912
16913 #[test]
16914 fn html_spells_a_quote_a_list_a_link_and_an_image_through_the_renderer() {
16915 // twig 3.5: where HTML has no marker alphabet it prints the fresh
16916 // node — a `<blockquote>` around the paragraph, a `<ul>`/`<ol>` with
16917 // the paragraph as its item, an `<a>` or `<img>` over the selection.
16918 // Until then every one of these was a refusal; now each is a real
16919 // edit, which is what the toolbar's capability flags say too.
16920 let src = "<h1>Title</h1>\n<p>Hello world</p>\n<ul><li>one</li></ul>\n";
16921 #[allow(clippy::type_complexity)]
16922 let ops: [(&str, &dyn Fn(&mut Doc), &str); 5] = [
16923 (
16924 "quote",
16925 &|d: &mut Doc| d.toggle_blockquote(),
16926 "<blockquote>",
16927 ),
16928 ("list", &|d: &mut Doc| d.toggle_list(false), "<ul>\n<li>"),
16929 (
16930 "ordered list",
16931 &|d: &mut Doc| d.toggle_list(true),
16932 "<ol>\n<li>",
16933 ),
16934 (
16935 "link",
16936 &|d: &mut Doc| d.insert_link("https://example.dev"),
16937 "<a href=\"https://example.dev\">Hello</a>",
16938 ),
16939 (
16940 "image",
16941 &|d: &mut Doc| d.insert_image("pic.png", "alt"),
16942 "<img alt=\"Hello\" src=\"pic.png\">",
16943 ),
16944 ];
16945 for (name, op, expect) in ops {
16946 let mut d = html_doc(src);
16947 let at = d.source.find("Hello").unwrap();
16948 d.caret = at;
16949 d.anchor = Some(at + 5);
16950 op(&mut d);
16951 assert!(d.source.contains(expect), "{name}: got {:?}", d.source);
16952 assert!(d.dirty, "{name}: a real edit");
16953 assert_eq!(
16954 d.status, None,
16955 "{name}: a supported gesture reports nothing"
16956 );
16957 }
16958 }
16959
16960 #[test]
16961 fn html_spells_a_heading_as_its_tag_pair() {
16962 // twig 3.4 rebuilds a heading or paragraph as its tag pair, attributes
16963 // along — the one block gesture whose HTML shape it can write. So ⌘2
16964 // in an HTML document is a real edit, and ⌘0 takes it back.
16965 let src = "<h1>Title</h1>\n<p>Hello world</p>\n";
16966 let mut d = html_doc(src);
16967 d.caret = d.source.find("Hello").unwrap();
16968 d.toggle_heading(2);
16969 assert_eq!(d.source, "<h1>Title</h1>\n<h2>Hello world</h2>\n");
16970 assert!(d.dirty);
16971 assert_eq!(d.status, None, "a supported gesture reports nothing");
16972 d.toggle_heading(2);
16973 assert_eq!(d.source, src, "the same level again is back to a paragraph");
16974 }
16975
16976 #[test]
16977 fn html_spells_the_inline_marks_and_the_rule() {
16978 // The other half, and why one per-document flag stopped being enough:
16979 // ⌘B in an HTML document writes `<strong>` — the tag the serializer
16980 // already emits and the parser reads straight back as the same mark —
16981 // and the rule button writes an `<hr>`. Refusing these on the old
16982 // "HTML is parse-only" reading would now be leaf's own limitation.
16983 let mut d = html_doc("<p>Hello world</p>\n");
16984 let at = d.source.find("world").unwrap();
16985 d.caret = at;
16986 d.anchor = Some(at + 5);
16987 d.toggle(InlineKind::Strong);
16988 assert_eq!(d.source, "<p>Hello <strong>world</strong></p>\n");
16989 assert!(d.dirty);
16990 assert_eq!(d.status, None, "a supported gesture reports nothing");
16991
16992 // And off again — the toggle reverses, which is the property that makes
16993 // authoring in HTML worth offering rather than a one-way trip.
16994 d.toggle(InlineKind::Strong);
16995 assert_eq!(d.source, "<p>Hello world</p>\n");
16996
16997 let mut d = html_doc("<p>Hello world</p>\n");
16998 d.caret = d.source.find("world").unwrap();
16999 d.insert_thematic_break();
17000 assert!(d.source.contains("<hr>"), "got {:?}", d.source);
17001 }
17002
17003 #[test]
17004 fn a_mark_the_format_cannot_spell_arms_nothing() {
17005 // `toggle` with a collapsed caret doesn't reach twig at all — it arms a
17006 // sticky mark for the next text typed. Guarding only the twig call
17007 // leaves that path live, promising a mark the gesture will not write and
17008 // then swallowing the error inside `insert`.
17009 //
17010 // Markdown carries this, on the superscript now rather than on the
17011 // highlight: `^x^` is text there in any configuration, whereas twig
17012 // 3.3.1 authors `==x==` for an editor holding the `highlight` extension,
17013 // which every leaf document does.
17014 let mut d = doc_with("mark", "Hello world\n");
17015 d.view = View::Wysiwyg;
17016 d.build_visual(80);
17017 d.caret = d.source.find("world").unwrap();
17018 d.toggle(InlineKind::Superscript);
17019 assert!(d.pending_marks.is_empty(), "no mark should be armed");
17020 assert!(d.status.as_deref().unwrap_or("").contains("markdown"));
17021 d.insert("X");
17022 assert_eq!(d.source, "Hello Xworld\n");
17023 }
17024
17025 #[test]
17026 fn markdown_authors_a_highlight_and_a_strikethrough() {
17027 // twig 3.3.1: the two marks Markdown reads and, until it, refused to
17028 // write. `==x==` is authorable because leaf's own `parse_extensions`
17029 // turns `highlight` on — twig will only mint bytes this editor's reparse
17030 // reads back — and `~~x~~` because GFM strikethrough is parsed by
17031 // default, so the refusal there was never right for any leaf document.
17032 // twig 3.10 adds the underline, spelled `<u>x</u>` and paired back into
17033 // an `insert` under the `html_elements` leaf parses with.
17034 for (kind, marked) in [
17035 (InlineKind::Mark, "a ==word== b\n"),
17036 (InlineKind::Delete, "a ~~word~~ b\n"),
17037 (InlineKind::Insert, "a <u>word</u> b\n"),
17038 ] {
17039 let mut d = doc_with("author_mark", "a word b\n");
17040 d.anchor = Some(2);
17041 d.caret = 6;
17042 d.toggle(kind);
17043 assert_eq!(d.source, marked, "{kind:?}");
17044 assert_eq!(d.status, None, "{kind:?}: a supported gesture is silent");
17045 assert!(d.dirty, "{kind:?}");
17046 // The region stays selected, so the second press reverses it — the
17047 // property that separates authoring from a one-way trip.
17048 d.toggle(kind);
17049 assert_eq!(d.source, "a word b\n", "{kind:?}");
17050 }
17051 }
17052
17053 #[test]
17054 fn an_authored_highlight_reads_back_as_a_mark() {
17055 // The round trip the extension gate exists to protect: what the toggle
17056 // writes, the reparse must read back as a `mark` rather than as two
17057 // literal `=` pairs. A `Role::Mark` glyph is that answer, taken from the
17058 // rebuilt map rather than from the source text.
17059 let mut d = doc_with("mark_roundtrip", "a word b\n");
17060 d.view = View::Wysiwyg;
17061 d.build_visual(80);
17062 d.anchor = Some(2);
17063 d.caret = 6;
17064 d.toggle(InlineKind::Mark);
17065 assert_eq!(d.source, "a ==word== b\n");
17066 d.build_visual(80);
17067 let w = d
17068 .vmap
17069 .rows
17070 .iter()
17071 .flat_map(|r| r.glyphs.iter())
17072 .find(|g| g.ch == 'w')
17073 .expect("the highlighted word");
17074 assert_eq!(w.style.role, crate::Role::Mark(None));
17075 }
17076
17077 #[test]
17078 fn an_authored_underline_reads_back_as_an_insert() {
17079 // The same round trip for `<u>…</u>`: under `html_elements` the tag pair
17080 // is one `insert`, so the word is underlined and the tags are markup the
17081 // caret-off line hides — not two `raw_inline` runs drawn as literal
17082 // HTML around plain text.
17083 let mut d = doc_with("underline_roundtrip", "a word b\nnext\n");
17084 d.view = View::Wysiwyg;
17085 d.build_visual(80);
17086 d.anchor = Some(2);
17087 d.caret = 6;
17088 d.toggle(InlineKind::Insert);
17089 assert_eq!(d.source, "a <u>word</u> b\nnext\n");
17090 d.anchor = None;
17091 d.caret = d.source.find("next").unwrap();
17092 d.build_visual(80);
17093 let glyphs: Vec<_> = d.vmap.rows.iter().flat_map(|r| r.glyphs.iter()).collect();
17094 let w = glyphs
17095 .iter()
17096 .find(|g| g.ch == 'w')
17097 .expect("the underlined word");
17098 assert!(w.style.underline);
17099 assert!(!glyphs.iter().any(|g| g.ch == '<'), "the tags are hidden");
17100 }
17101
17102 #[test]
17103 fn a_highlight_takes_a_colour_changes_it_and_gives_it_back() {
17104 // The three states of one gesture, in the order a palette is pressed:
17105 // an uncoloured highlight takes the prefix, a coloured one has it
17106 // replaced, and `None` takes it away with the space that was part of the
17107 // spelling.
17108 let mut d = doc_with("mark_colour", "a ==word== b\n");
17109 d.caret = d.source.find("word").unwrap();
17110 d.set_mark_color(Some(MarkColor::Red));
17111 assert_eq!(d.source, "a ==🔴 word== b\n");
17112 assert_eq!(d.status, None);
17113 assert!(d.dirty);
17114
17115 d.set_mark_color(Some(MarkColor::Blue));
17116 assert_eq!(d.source, "a ==🔵 word== b\n");
17117
17118 d.set_mark_color(None);
17119 assert_eq!(d.source, "a ==word== b\n");
17120 }
17121
17122 #[test]
17123 fn the_caret_keeps_its_place_in_the_text_across_a_colour() {
17124 // The prefix is written *before* the word, so an offset in the word has
17125 // to ride its width — a caret that stayed put would be a caret that
17126 // walked backwards through the text it was standing in.
17127 let mut d = doc_with("mark_colour_caret", "a ==word== b\n");
17128 let word = d.source.find("word").unwrap();
17129 d.caret = word + 2; // between `wo` and `rd`
17130 d.set_mark_color(Some(MarkColor::Red));
17131 assert_eq!(&d.source[d.caret..d.caret + 2], "rd", "still before `rd`");
17132
17133 // And back the other way when the prefix goes.
17134 d.set_mark_color(None);
17135 assert_eq!(&d.source[d.caret..d.caret + 2], "rd");
17136 }
17137
17138 #[test]
17139 fn the_colour_at_the_caret_is_what_the_palette_lights() {
17140 let mut d = doc_with("mark_colour_read", "a ==🔴 red== and ==plain== b\n");
17141 d.caret = d.source.find("red").unwrap();
17142 assert!(d.caret_in_mark());
17143 assert_eq!(d.mark_color_at_caret(), Some(MarkColor::Red));
17144
17145 d.caret = d.source.find("plain").unwrap();
17146 assert!(d.caret_in_mark(), "a highlight with no colour is still one");
17147 assert_eq!(d.mark_color_at_caret(), None);
17148
17149 d.caret = d.source.find(" and ").unwrap() + 2;
17150 assert!(!d.caret_in_mark());
17151 assert_eq!(d.mark_color_at_caret(), None);
17152 }
17153
17154 #[test]
17155 fn a_colour_without_a_highlight_says_so_and_writes_nothing() {
17156 // The gesture colours a highlight that exists; it does not make one.
17157 // Two presses is the price of a coloured highlight from bare text, and
17158 // the reason is undo — one press that spliced twice would take two
17159 // presses to take back.
17160 let mut d = doc_with("mark_colour_none", "a word b\n");
17161 d.caret = d.source.find("word").unwrap();
17162 d.set_mark_color(Some(MarkColor::Red));
17163 assert_eq!(d.source, "a word b\n");
17164 assert!(d.status.is_some(), "it should say why");
17165 assert!(!d.dirty);
17166
17167 // Clearing where there is nothing to clear is the same refusal, not a
17168 // quiet success — the caret is in no highlight either way.
17169 d.status = None;
17170 d.set_mark_color(None);
17171 assert_eq!(d.source, "a word b\n");
17172 assert!(d.status.is_some());
17173 }
17174
17175 #[test]
17176 fn clearing_an_uncoloured_highlight_is_a_quiet_no_op() {
17177 // twig answers this one *successfully* with a `Change` describing some
17178 // earlier edit, so a caller that trusted the change would jump the caret
17179 // to wherever that was. Core answers it before asking.
17180 let mut d = doc_with("mark_colour_noop", "a ==word== b\n");
17181 d.toggle(InlineKind::Strong); // an earlier edit for a stale change to name
17182 d.caret = d.source.find("word").unwrap();
17183 let (source, caret) = (d.source.clone(), d.caret);
17184 d.set_mark_color(None);
17185 assert_eq!(d.source, source);
17186 assert_eq!(
17187 d.caret, caret,
17188 "the caret must not ride a change that isn't one"
17189 );
17190 assert_eq!(d.status, None, "and it is not an error either");
17191 }
17192
17193 #[test]
17194 fn djot_spells_the_highlight_and_not_its_colour() {
17195 // The reason the palette is its own capability rather than the Highlight
17196 // button's: `{=word=}` is a highlight djot writes happily, and there is
17197 // no djot spelling for a colour on it.
17198 assert!(Capabilities::of(Format::Djot).mark);
17199 assert!(!Capabilities::of(Format::Djot).mark_color);
17200 assert!(Capabilities::of(Format::Markdown).mark_color);
17201
17202 let mut d = Doc::from_source("a {=word=} b\n".into(), Format::Djot).unwrap();
17203 d.caret = d.source.find("word").unwrap();
17204 assert!(
17205 d.caret_in_mark(),
17206 "the caret is in a highlight all the same"
17207 );
17208 d.set_mark_color(Some(MarkColor::Red));
17209 assert_eq!(d.source, "a {=word=} b\n");
17210 assert!(
17211 d.status.as_deref().unwrap_or("").contains("djot"),
17212 "and the refusal names the document's format: {:?}",
17213 d.status
17214 );
17215 }
17216
17217 #[test]
17218 fn a_coloured_highlight_is_one_undo_step_and_reads_back_as_its_colour() {
17219 // The round trip that matters for a palette: the bytes twig writes are
17220 // bytes its own reparse reads back as a colour, so the swatch that was
17221 // pressed is the swatch that lights afterwards.
17222 let mut d = doc_with("mark_colour_undo", "a word b\n");
17223 d.anchor = Some(2);
17224 d.caret = 6;
17225 d.toggle(InlineKind::Mark);
17226 d.caret = d.source.find("word").unwrap();
17227 d.set_mark_color(Some(MarkColor::Green));
17228 assert_eq!(d.source, "a ==🟢 word== b\n");
17229 assert_eq!(d.mark_color_at_caret(), Some(MarkColor::Green));
17230
17231 // One splice, one step: the colour comes off and the highlight stays.
17232 d.undo();
17233 assert_eq!(d.source, "a ==word== b\n");
17234 d.undo();
17235 assert_eq!(d.source, "a word b\n");
17236 }
17237
17238 #[test]
17239 fn every_colour_leaf_names_is_one_twig_writes() {
17240 // The two enums are one vocabulary, and this is what says so: each of
17241 // leaf's colours writes an emoji twig's reparse reads back as *that*
17242 // colour, so `twig_mark_color`'s table cannot quietly pair red with
17243 // orange.
17244 for color in MarkColor::ALL {
17245 let mut d = doc_with("mark_colour_all", "a ==word== b\n");
17246 d.caret = d.source.find("word").unwrap();
17247 d.set_mark_color(Some(color));
17248 assert_eq!(d.status, None, "{color:?}");
17249 assert_eq!(d.mark_color_at_caret(), Some(color), "{color:?}");
17250 }
17251 }
17252
17253 #[test]
17254 fn a_fresh_highlight_takes_a_colour_without_moving_the_caret_first() {
17255 // The two presses a coloured highlight is made of, in the state the
17256 // first one leaves: `toggle` selects the whole `==word==` and puts the
17257 // caret one past the closing `==`, which is *not* in the mark. Asking at
17258 // the caret alone would refuse to colour the highlight just written —
17259 // the selection's start is what answers.
17260 let mut d = doc_with("mark_colour_fresh", "a word b\n");
17261 d.anchor = Some(2);
17262 d.caret = 6;
17263 d.toggle(InlineKind::Mark);
17264 assert_eq!(d.source, "a ==word== b\n");
17265 assert_eq!(d.caret, 10, "the caret twig leaves, past the closing `==`");
17266
17267 assert!(d.caret_in_mark(), "the selected highlight is the one meant");
17268 d.set_mark_color(Some(MarkColor::Yellow));
17269 assert_eq!(d.source, "a ==🟡 word== b\n");
17270 assert_eq!(d.status, None);
17271 }
17272
17273 #[test]
17274 fn one_press_highlights_a_selection_and_colours_it() {
17275 // What a toolbar swatch means over a plain selection, and the undo it
17276 // has to have: one press, one step. Two steps would leave an uncoloured
17277 // highlight behind on the way back, which is a state the author never
17278 // asked for and never saw.
17279 let mut d = doc_with("highlight_one", "a word b\n");
17280 d.anchor = Some(2);
17281 d.caret = 6;
17282 d.highlight(Some(MarkColor::Purple));
17283 assert_eq!(d.source, "a ==\u{1F7E3} word== b\n");
17284 assert_eq!(d.status, None);
17285
17286 d.undo();
17287 assert_eq!(d.source, "a word b\n", "one press, one undo");
17288 }
17289
17290 #[test]
17291 fn one_press_on_an_existing_highlight_only_recolours_it() {
17292 // The other half: inside a highlight there is nothing to make, so the
17293 // compound is the plain gesture and the text is untouched.
17294 let mut d = doc_with("highlight_recolour", "a ==\u{1F534} word== b\n");
17295 d.caret = d.source.find("word").unwrap();
17296 d.highlight(Some(MarkColor::Blue));
17297 assert_eq!(d.source, "a ==\u{1F535} word== b\n");
17298 d.undo();
17299 assert_eq!(d.source, "a ==\u{1F534} word== b\n", "the highlight stays");
17300 }
17301
17302 #[test]
17303 fn one_press_with_no_colour_over_a_selection_just_highlights_it() {
17304 // `None` means "no colour", and over bare text that is the Highlight
17305 // button's own job. The fold must not happen here — there is no second
17306 // splice, and folding would take the *previous* edit into this one.
17307 let mut d = doc_with("highlight_none", "a word b and more\n");
17308 d.caret = d.source.find("more").unwrap() + 4; // after "more"
17309 d.insert("!"); // an earlier edit for a wrong fold to swallow
17310 d.anchor = Some(2);
17311 d.caret = 6;
17312 d.highlight(None);
17313 assert_eq!(d.source, "a ==word== b and more!\n");
17314
17315 d.undo();
17316 assert_eq!(
17317 d.source, "a word b and more!\n",
17318 "only the highlight came off"
17319 );
17320 d.undo();
17321 assert_eq!(
17322 d.source, "a word b and more\n",
17323 "and the edit before it survived"
17324 );
17325 }
17326
17327 #[test]
17328 fn one_press_at_a_bare_caret_in_no_highlight_writes_nothing() {
17329 // `toggle` at a collapsed caret arms a mark for text not yet typed, and
17330 // a colour cannot be armed with it — so the compound declines rather
17331 // than leaving half a promise.
17332 let mut d = doc_with("highlight_bare", "a word b\n");
17333 d.caret = 4;
17334 d.highlight(Some(MarkColor::Red));
17335 assert_eq!(d.source, "a word b\n");
17336 assert!(d.pending_marks.is_empty(), "and nothing armed");
17337 assert!(d.status.is_some());
17338 }
17339
17340 #[test]
17341 fn a_read_only_document_takes_no_colour() {
17342 let mut d = doc_with("mark_colour_ro", "a ==word== b\n");
17343 d.caret = d.source.find("word").unwrap();
17344 d.set_read_only(true);
17345 d.set_mark_color(Some(MarkColor::Red));
17346 assert_eq!(d.source, "a ==word== b\n");
17347 }
17348
17349 #[test]
17350 fn a_sticky_highlight_wraps_the_next_typed_text_in_markdown() {
17351 // The other door into `toggle`: no selection, so nothing reaches twig
17352 // until `insert` realises the armed mark. It is armed now — the guard
17353 // above asks `Doc::supports`, which asks with the extensions — and what
17354 // it writes is the same `==…==`.
17355 let mut d = doc_with("sticky_mark", "xy\n");
17356 d.caret = 1;
17357 d.toggle(InlineKind::Mark);
17358 assert!(d.pending_marks.contains(InlineKind::Mark));
17359 d.insert("Z");
17360 assert_eq!(d.source, "x==Z==y\n");
17361 }
17362
17363 #[test]
17364 fn html_documents_still_take_typed_text() {
17365 // The guard covers *markup* gestures and must not touch plain editing:
17366 // twig's splicer is language-neutral, and typing into an HTML document
17367 // is the thing that does work today.
17368 let mut d = html_doc("<p>Hello world</p>\n");
17369 d.caret = d.source.find("world").unwrap();
17370 d.insert("big ");
17371 assert_eq!(d.source, "<p>Hello big world</p>\n");
17372 assert!(d.dirty);
17373 d.backspace();
17374 assert_eq!(d.source, "<p>Hello bigworld</p>\n");
17375 d.undo();
17376 d.undo();
17377 assert_eq!(d.source, "<p>Hello world</p>\n");
17378 }
17379
17380 #[test]
17381 fn authorable_is_the_coarse_question_and_capabilities_the_useful_one() {
17382 // `authorable` only separates "there is a door in" from "there is not",
17383 // and HTML is on the near side of that line — which is exactly why a
17384 // toolbar must not be built from it.
17385 let html = Doc::from_source("<p>x</p>\n".into(), Format::Html).unwrap();
17386 assert!(html.authorable());
17387 assert!(
17388 !Doc::from_source("<r>x</r>".into(), Format::Xml)
17389 .unwrap()
17390 .authorable()
17391 );
17392
17393 let caps = html.capabilities();
17394 assert!(caps.bold && caps.italic && caps.code && caps.mark);
17395 assert!(caps.thematic_break && caps.cell_line_break);
17396 // A heading is a tag pair twig rebuilds (3.4), and since 3.5 so are a
17397 // quote, a list, a code block's language, a link and an image — each
17398 // printed as a fresh node where HTML has no marker to rewrite. A task
17399 // box is a form control and a footnote has no spelling, so those two
17400 // are what keeps the record ragged.
17401 assert!(caps.heading && caps.blockquote && caps.bullet_list);
17402 assert!(caps.link && caps.image && caps.code_language);
17403 assert!(!caps.task && !caps.footnote);
17404 // The one flag that isn't twig's answer: an HTML `<table>` is a grid
17405 // twig's table editor would happily re-emit as `| a | b |`.
17406 assert!(!caps.table);
17407
17408 // The two lightweight formats spell everything leaf offers — and still
17409 // differ from each other, which is the other half of why one boolean
17410 // can't serve.
17411 for fmt in [Format::Markdown, Format::Djot] {
17412 let caps = Capabilities::of(fmt);
17413 assert!(
17414 caps.heading && caps.blockquote && caps.ordered_list,
17415 "{fmt:?}"
17416 );
17417 assert!(
17418 caps.task && caps.link && caps.image && caps.table,
17419 "{fmt:?}"
17420 );
17421 }
17422 // Both spell the highlight, the strikethrough and the underline: djot
17423 // natively, and Markdown because `Capabilities` asks with
17424 // `parse_extensions` rather than with twig's defaults — `==x==` is text
17425 // under those, and a mark under the `highlight` leaf always parses
17426 // with; `<u>x</u>` likewise pairs into an `insert` only under
17427 // `html_elements`.
17428 for fmt in [Format::Markdown, Format::Djot] {
17429 let caps = Capabilities::of(fmt);
17430 assert!(caps.mark && caps.strike && caps.underline, "{fmt:?}");
17431 }
17432 // What still separates them, now that the highlight doesn't: djot has
17433 // no in-cell break, and Markdown spells neither of the scripts.
17434 assert!(Capabilities::of(Format::Djot).superscript);
17435 assert!(!Capabilities::of(Format::Markdown).superscript);
17436 assert!(Capabilities::of(Format::Markdown).cell_line_break);
17437 assert!(!Capabilities::of(Format::Djot).cell_line_break);
17438
17439 // A parse-only format answers no to every one of them, so the coarse
17440 // predicate and the record agree there.
17441 let caps = Capabilities::of(Format::Xml);
17442 assert!(!caps.bold && !caps.heading && !caps.table && !caps.thematic_break);
17443 }
17444
17445 #[test]
17446 fn a_refused_gesture_says_so_where_twig_would_have_said_it() {
17447 // The guard exists to name the *document's* format rather than twig's
17448 // internals, so the message has to survive being one leaf writes itself.
17449 // Checked against a gesture twig also refuses, since that is the pair
17450 // most at risk of drifting apart — the task box, once the code
17451 // language stopped being one (twig 3.5).
17452 let mut d = html_doc("<p>Hello</p>\n");
17453 d.caret = d.source.find("Hello").unwrap();
17454 d.toggle_task_item();
17455 assert_eq!(d.status.as_deref(), Some("task: not supported in html"));
17456 assert!(!d.dirty);
17457 }
17458
17459 #[test]
17460 fn table_set_alignment_respells_the_delimiter() {
17461 let mut d = doc_with("tbl_align", "| a | b |\n| --- | --- |\n| 1 | 2 |\n");
17462 d.caret = d.source.find('b').unwrap();
17463 d.table_set_alignment(Alignment::Right);
17464 assert_eq!(d.source, "| a | b |\n| --- | ---: |\n| 1 | 2 |\n");
17465 }
17466
17467 #[test]
17468 fn each_empty_table_cell_has_its_own_editable_home() {
17469 // Regression: an empty cell has no twig content_span, so both cells of a
17470 // `| | |` row collapsed onto the row's start (before the first `│`).
17471 // Typing there inserted *before* the table (`hello| | |`); nav couldn't
17472 // tell the cells apart. Each empty cell must now have a distinct home
17473 // inside it.
17474 let mut d = wysiwyg_doc("tbl_empty", "| a | b |\n| --- | --- |\n| | |\n");
17475 let (c0, c1) = {
17476 let cells = &d.vmap.tables[0].grid[1].cells;
17477 (cells[0].start, cells[1].start)
17478 };
17479 assert!(
17480 c0 < c1,
17481 "the two empty cells have distinct homes: {c0} < {c1}"
17482 );
17483 d.caret = c0;
17484 d.insert("x");
17485 assert_eq!(
17486 d.source, "| a | b |\n| --- | --- |\n| x | |\n",
17487 "typed inside the cell"
17488 );
17489 }
17490
17491 #[test]
17492 fn arrows_step_into_each_empty_table_cell() {
17493 let mut d = wysiwyg_doc("tbl_empty_nav", "| a | b |\n| --- | --- |\n| | |\n");
17494 let (c0, c1) = {
17495 let cells = &d.vmap.tables[0].grid[1].cells;
17496 (cells[0].start, cells[1].start)
17497 };
17498 d.caret = d.source.find('b').unwrap(); // in the header's second cell
17499 let mut seen = std::collections::HashSet::new();
17500 for _ in 0..6 {
17501 d.move_right(false);
17502 seen.insert(d.caret);
17503 }
17504 assert!(
17505 seen.contains(&c0),
17506 "right arrow reaches the first empty cell"
17507 );
17508 assert!(
17509 seen.contains(&c1),
17510 "right arrow reaches the second empty cell"
17511 );
17512 }
17513
17514 #[test]
17515 fn table_op_off_a_table_is_a_no_op_with_a_status() {
17516 let mut d = doc_with("tbl_none", "just text\n");
17517 d.caret = 3;
17518 d.table_insert_row(true);
17519 assert_eq!(d.source, "just text\n", "nothing changed");
17520 assert!(d.status.is_some(), "a status explains why");
17521 assert!(!d.caret_in_table());
17522 }
17523
17524 #[test]
17525 fn enter_in_an_ordered_list_renumbers_the_following_items() {
17526 // Inserting an item mid-list left the source markers stale (`1. 2. 2. 3.`);
17527 // the renumber pass keeps them sequential, matching what the view draws.
17528 let mut d = wysiwyg_doc("enter_renumber", "1. a\n2. b\n3. c\n");
17529 d.caret = d.source.find('a').unwrap() + 1; // end of item a
17530 d.newline();
17531 d.insert("x");
17532 assert_eq!(d.source, "1. a\n2. x\n3. b\n4. c\n");
17533 }
17534
17535 #[test]
17536 fn outdent_with_nothing_to_give_back_records_no_undo_step() {
17537 for view in [View::Source, View::Wysiwyg] {
17538 let mut d = doc_in(view, "outdent_noop", "hello\n");
17539 d.caret = 2;
17540 d.outdent();
17541 assert_eq!(d.source, "hello\n");
17542 assert!(!d.dirty, "a no-op is not a modification");
17543 d.undo();
17544 assert_eq!(
17545 d.status.as_deref(),
17546 Some("nothing to undo"),
17547 "spends no undo step"
17548 );
17549 assert_eq!(d.source, "hello\n");
17550 }
17551 }
17552
17553 #[test]
17554 fn indent_shifts_every_selected_line_and_keeps_them_selected() {
17555 for view in [View::Source, View::Wysiwyg] {
17556 let mut d = doc_in(view, "indent_sel", "one\n\ntwo\n");
17557 d.anchor = Some(0);
17558 d.caret = 7; // through "two"
17559 d.indent();
17560 assert_eq!(
17561 d.source, " one\n\n two\n",
17562 "the blank line keeps no trailing pad"
17563 );
17564 // Selected, so a second Tab lands on the same lines rather than on
17565 // whatever the shifted offsets now cover.
17566 assert_eq!(d.selection(), Some((0, 12)));
17567 d.indent();
17568 assert_eq!(d.source, " one\n\n two\n");
17569 }
17570 }
17571
17572 #[test]
17573 fn outdent_takes_what_each_line_has_and_leaves_the_rest_alone() {
17574 for view in [View::Source, View::Wysiwyg] {
17575 let mut d = doc_in(view, "outdent_sel", " two\n one\nnone\n");
17576 d.anchor = Some(0);
17577 d.caret = 15;
17578 d.outdent();
17579 assert_eq!(d.source, "two\none\nnone\n");
17580 }
17581 }
17582
17583 #[test]
17584 fn a_tab_undoes_as_one_step_however_many_lines_it_moved() {
17585 for view in [View::Source, View::Wysiwyg] {
17586 let mut d = doc_in(view, "indent_undo", "one\n\ntwo\n");
17587 d.anchor = Some(0);
17588 d.caret = 7;
17589 d.indent();
17590 assert_eq!(d.source, " one\n\n two\n");
17591 d.undo();
17592 assert_eq!(d.source, "one\n\ntwo\n", "one step, not one per line");
17593 assert_eq!(
17594 d.selection(),
17595 Some((0, 7)),
17596 "with the selection it was aimed at"
17597 );
17598 d.redo();
17599 assert_eq!(d.source, " one\n\n two\n");
17600 assert_eq!(
17601 d.selection(),
17602 Some((0, 12)),
17603 "redo replays the caret the indent placed, not the one splice left"
17604 );
17605 }
17606 }
17607
17608 #[test]
17609 fn vertical_motion_keeps_the_column() {
17610 let mut d = doc_with("move", "abcd\nef\n");
17611 d.caret = 3; // "abc|d" on row 0, col 3
17612 d.move_down(false); // row 1 "ef" only has cols 0..2 -> clamps to end
17613 assert_eq!(d.caret, 7); // just after "ef"
17614 }
17615
17616 // ── goal column ──────────────────────────────────────────────────────────
17617
17618 #[test]
17619 fn vertical_motion_goal_column_survives_a_short_line() {
17620 // Regression: re-deriving the column from the clamped position on
17621 // every step permanently forgets it once a short line clamps it.
17622 // Down through "xy" (2 cols) and into "ghijkl" must return to col 4.
17623 let g = |m, f: fn(&mut Doc)| golden("goalcol", m, f);
17624 assert_eq!(
17625 g("abcd|ef\nxy\nghijkl\n", |d| {
17626 d.move_down(false); // clamps to end of "xy"
17627 d.move_down(false); // restores col 4 on the long line
17628 }),
17629 "abcdef\nxy\nghij|kl\n"
17630 );
17631 }
17632
17633 #[test]
17634 fn goal_column_state_is_set_by_vertical_motion_and_cleared_by_horizontal() {
17635 let mut d = doc_with("goalcol_state", "abcdef\nxy\nghijkl\n");
17636 assert_eq!(d.goal_col, None);
17637 d.caret = 4; // row 0, col 4
17638 d.move_down(false); // clamps into "xy"; goal stays the original col
17639 assert_eq!(d.goal_col, Some(4));
17640 assert_eq!(d.caret_pos(), (1, 2));
17641
17642 // A horizontal motion drops the goal column...
17643 d.move_left(false);
17644 assert_eq!(d.goal_col, None);
17645
17646 // ...so the next vertical motion picks up the *new* column (1), not
17647 // the stale one (4).
17648 d.move_down(false);
17649 assert_eq!(d.goal_col, Some(1));
17650 assert_eq!(d.caret_pos(), (2, 1));
17651 }
17652
17653 #[test]
17654 fn editing_clears_the_goal_column() {
17655 let mut d = doc_with("goalcol_edit", "abcdef\nxy\nghijkl\n");
17656 d.caret = 4;
17657 d.move_down(false);
17658 assert_eq!(d.goal_col, Some(4));
17659 d.insert("Z");
17660 assert_eq!(d.goal_col, None);
17661 }
17662
17663 #[test]
17664 fn vertical_motion_on_an_empty_document_is_a_no_op() {
17665 let mut d = doc_with("empty_vert", "");
17666 d.move_down(false);
17667 assert_eq!(d.caret, 0);
17668 d.move_up(false);
17669 assert_eq!(d.caret, 0);
17670 }
17671
17672 // ── the document's edges ─────────────────────────────────────────────────
17673
17674 #[test]
17675 fn vertical_motion_at_the_document_edges_runs_to_them_in_both_views() {
17676 // The reproduction, and the disagreement: Down on the last line ran to
17677 // the end of the document in the source view — by accident, an
17678 // out-of-range row clamping to the end of the string — and did nothing
17679 // whatever in the view leaf opens in. One rule now, in both.
17680 for (view, tag) in VIEWS {
17681 let mut d = doc_in(view, &format!("edge_{tag}"), "abc");
17682 d.caret = 1;
17683 d.move_down(false);
17684 assert_eq!(d.caret, 3, "{tag}: Down on the last line runs to the end");
17685 d.move_up(false);
17686 assert_eq!(d.caret, 0, "{tag}: Up on the first line runs to the start");
17687 }
17688 }
17689
17690 #[test]
17691 fn vertical_motion_at_the_edges_carries_the_column_across_the_lines_between() {
17692 // Down off the bottom is a motion like any other, so it latches a goal
17693 // column — and Up comes back to the column the caret left, not to the
17694 // one the document's end happened to be in.
17695 for (view, tag) in VIEWS {
17696 let gap = if view == View::Source { "\n" } else { "\n\n" };
17697 let src = format!("abcdef{gap}ghijkl");
17698 let mut d = doc_in(view, &format!("edge_goal_{tag}"), &src);
17699 d.caret = 2; // row 0, col 2
17700 d.move_down(false);
17701 assert_eq!(d.caret_pos().1, 2, "{tag}: Down keeps the column");
17702 d.move_down(false);
17703 assert_eq!(
17704 d.caret,
17705 src.len(),
17706 "{tag}: Down off the bottom reaches the end"
17707 );
17708 d.move_up(false);
17709 assert_eq!(
17710 d.caret_pos().1,
17711 2,
17712 "{tag}: Up returns to the column Down left"
17713 );
17714 }
17715 }
17716
17717 #[test]
17718 fn vertical_motion_with_nowhere_to_go_latches_no_goal_column() {
17719 // `goal_col.get_or_insert` ran *before* the early return at row 0, so an
17720 // Up that did nothing still armed a goal column, and the next Down aimed
17721 // at a column the caret had never been in.
17722 for (view, tag) in VIEWS {
17723 let mut d = doc_in(view, &format!("noop_goal_{tag}"), "abc\n\ndef");
17724 d.caret = 0;
17725 d.move_up(false);
17726 assert_eq!(d.caret, 0, "{tag}: already at the start");
17727 assert_eq!(d.goal_col, None, "{tag}: a no-op Up latched a goal column");
17728
17729 d.caret = d.source.len();
17730 d.move_down(false);
17731 assert_eq!(d.caret, d.source.len(), "{tag}: already at the end");
17732 assert_eq!(
17733 d.goal_col, None,
17734 "{tag}: a no-op Down latched a goal column"
17735 );
17736 }
17737 }
17738
17739 // ── soft wrap ────────────────────────────────────────────────────────────
17740 // Every other test here builds the map at 80 columns, where no fixture is
17741 // long enough to fold. A wrap is where one offset belongs to two rows at
17742 // once, and it broke everything that asks the caret what row it is on.
17743
17744 /// The wrapped fixture these cases share, folded at 12 columns into
17745 /// `one two ` / `three four ` / `five six ` / `seven eight`.
17746 fn wrapped_doc(name: &str) -> Doc {
17747 let mut d = wysiwyg_doc(name, "one two three four five six seven eight");
17748 d.build_visual(12);
17749 d
17750 }
17751
17752 #[test]
17753 fn home_and_end_work_from_a_wrapped_row() {
17754 // The reproduction: offset 19 is the `f` of "five", the first character
17755 // of the third row — and also the offset the second row ends at. It
17756 // resolved to the *second* row, so End aimed at a place the caret was
17757 // already in and did nothing, while Home walked backwards onto a row the
17758 // caret had left.
17759 let mut d = wrapped_doc("wrap_home_end");
17760 d.caret = 19;
17761 assert_eq!(
17762 d.caret_pos(),
17763 (2, 0),
17764 "the wrap boundary opens the third row"
17765 );
17766 d.move_end(false);
17767 assert_eq!(d.caret, 27, "End stalled at the wrap boundary");
17768 d.move_home(false);
17769 assert_eq!(d.caret, 19, "Home left the row the caret was on");
17770 }
17771
17772 #[test]
17773 fn end_of_a_wrapped_row_stays_put_when_pressed_again() {
17774 // The row's end is the last offset that is only ever its own: the offset
17775 // past it opens the row below, and aiming there would send a second
17776 // press on to *that* row's end, and a third to the next — End walking
17777 // down the paragraph rather than sitting where it landed.
17778 let mut d = wrapped_doc("wrap_end_twice");
17779 d.caret = 12; // inside "three", on the second row
17780 d.move_end(false);
17781 assert_eq!(
17782 d.caret, 18,
17783 "the end of `three four`, before the space the wrap ate"
17784 );
17785 assert_eq!(d.caret_pos(), (1, 10), "drawn on the row it is the end of");
17786 d.move_end(false);
17787 assert_eq!(d.caret, 18, "a second End moved the caret");
17788 d.move_home(false);
17789 assert_eq!(d.caret, 8, "Home takes the row's own start");
17790 }
17791
17792 #[test]
17793 fn vertical_motion_crosses_a_soft_wrap() {
17794 // Down aimed at the row below's column 0, an offset that resolved *up*
17795 // to the row above's end — so it landed on the offset it already had and
17796 // the caret could never leave a paragraph's first row.
17797 let mut d = wrapped_doc("wrap_down");
17798 d.caret = 0;
17799 for (want, row) in [(8, 1), (19, 2), (28, 3), (39, 3)] {
17800 d.move_down(false);
17801 assert_eq!(d.caret, want, "Down stalled");
17802 assert_eq!(d.caret_pos().0, row, "Down landed on the wrong row");
17803 }
17804 d.move_down(false);
17805 assert_eq!(d.caret, 39, "the last row's Down runs to the end and stops");
17806
17807 // ...and back up, one row per press. The goal column is the end of the
17808 // last row, past every other row's width, so each press clamps to the
17809 // row's own last offset rather than to the one that opens the next.
17810 let mut d = wrapped_doc("wrap_up");
17811 d.caret = 39;
17812 for (want, pos) in [(27, (2, 8)), (18, (1, 10)), (7, (0, 7)), (0, (0, 0))] {
17813 d.move_up(false);
17814 assert_eq!(d.caret, want, "Up stalled");
17815 assert_eq!(d.caret_pos(), pos, "Up landed on the wrong row");
17816 }
17817 }
17818
17819 #[test]
17820 fn a_kill_on_a_wrapped_row_stops_at_the_row() {
17821 // The kills take the same line Home and End do, so in WYSIWYG they take
17822 // the visual row — and a soft wrap has no newline in it to delete, so
17823 // nothing is joined by reaching the end of one.
17824 let mut d = wrapped_doc("wrap_kill");
17825 d.caret = 19; // the `f` of "five", opening the third row
17826 d.delete_to_line_end();
17827 // The space the wrap ate goes with the row it was drawn on: sparing it
17828 // would leave "four seven", two spaces where the row had been.
17829 assert_eq!(d.source, "one two three four seven eight");
17830
17831 // Backwards from the row's last caret position — which is *before* that
17832 // space, so this one survives, being on the far side of the caret.
17833 let mut d = wrapped_doc("wrap_kill_back");
17834 d.caret = 27;
17835 d.delete_to_line_start();
17836 assert_eq!(d.source, "one two three four seven eight");
17837 }
17838
17839 // ── document start / end ────────────────────────────────────────────────
17840
17841 #[test]
17842 fn move_doc_start_and_end_jump_to_the_edges() {
17843 let g = |m, f: fn(&mut Doc)| golden("doc_edges", m, f);
17844 assert_eq!(
17845 g("hello\nwor|ld\n", |d| d.move_doc_start(false)),
17846 "|hello\nworld\n"
17847 );
17848 assert_eq!(
17849 g("hel|lo\nworld\n", |d| d.move_doc_end(false)),
17850 "hello\nworld\n|"
17851 );
17852 // Already at the edge: a no-op.
17853 assert_eq!(g("|hello\n", |d| d.move_doc_start(false)), "|hello\n");
17854 assert_eq!(g("hello|\n", |d| d.move_doc_end(false)), "hello\n|");
17855 }
17856
17857 #[test]
17858 fn move_doc_start_and_end_extend_the_selection() {
17859 assert_eq!(
17860 golden("doc_edges_ext_end", "hello wor|ld\n", |d| d
17861 .move_doc_end(true)),
17862 "hello wor[ld\n|]"
17863 );
17864 assert_eq!(
17865 golden("doc_edges_ext_start", "hello wor|ld\n", |d| d
17866 .move_doc_start(true)),
17867 "[|hello wor]ld\n"
17868 );
17869 }
17870
17871 #[test]
17872 fn move_doc_start_and_end_on_an_empty_document_are_a_no_op() {
17873 let mut d = doc_with("empty_edges", "");
17874 d.move_doc_end(false);
17875 assert_eq!(d.caret, 0);
17876 d.move_doc_start(false);
17877 assert_eq!(d.caret, 0);
17878 }
17879
17880 // ── arrow collapses an active selection ─────────────────────────────────
17881
17882 #[test]
17883 fn arrow_collapses_selection_to_its_near_edge() {
17884 let mut d = doc_with("collapse", "hello world\n");
17885
17886 // Forward selection (anchor before caret): Right -> end, Left -> start.
17887 d.anchor = Some(2);
17888 d.caret = 7;
17889 d.move_right(false);
17890 assert_eq!((d.caret, d.anchor), (7, None));
17891
17892 d.anchor = Some(2);
17893 d.caret = 7;
17894 d.move_left(false);
17895 assert_eq!((d.caret, d.anchor), (2, None));
17896
17897 // Backward selection (anchor after caret): edges are the same
17898 // regardless of which end the caret started on.
17899 d.anchor = Some(7);
17900 d.caret = 2;
17901 d.move_right(false);
17902 assert_eq!((d.caret, d.anchor), (7, None));
17903
17904 d.anchor = Some(7);
17905 d.caret = 2;
17906 d.move_left(false);
17907 assert_eq!((d.caret, d.anchor), (2, None));
17908 }
17909
17910 #[test]
17911 fn arrow_with_extend_keeps_growing_the_selection() {
17912 let mut d = doc_with("collapse_extend", "hello world\n");
17913 d.anchor = Some(2);
17914 d.caret = 7;
17915 d.move_right(true); // extend: no collapse, caret steps one further
17916 assert_eq!((d.caret, d.anchor), (8, Some(2)));
17917 }
17918
17919 #[test]
17920 fn arrow_without_a_selection_moves_one_character_as_before() {
17921 let mut d = doc_with("no_collapse", "hello\n");
17922 d.caret = 2;
17923 d.move_right(false);
17924 assert_eq!(d.caret, 3);
17925 d.move_left(false);
17926 assert_eq!(d.caret, 2);
17927 }
17928
17929 /// Press Right until it stops, collecting the offsets walked through. Every
17930 /// caret bug in the WYSIWYG view shows up here as a walk that ends early:
17931 /// two stops sharing one source offset can't be moved between, so the caret
17932 /// stalls on the first of them and the walk never reaches the rest.
17933 fn walk_right(d: &mut Doc) -> Vec<usize> {
17934 let mut seen = vec![d.caret];
17935 for _ in 0..2000 {
17936 let before = d.caret;
17937 d.move_right(false);
17938 if d.caret == before {
17939 break;
17940 }
17941 seen.push(d.caret);
17942 }
17943 seen
17944 }
17945
17946 #[test]
17947 fn the_caret_crosses_a_soft_break() {
17948 // A newline inside a paragraph is a `soft_break`, which twig gives no
17949 // span of its own — the space it renders as used to borrow the offset of
17950 // the character before it, and a caret can't move without changing
17951 // offset. Right must walk clean off the end of the first line.
17952 let mut d = wysiwyg_doc("soft_break_walk", "one two\nthree four\n");
17953 d.caret = 0;
17954 let seen = walk_right(&mut d);
17955 assert_eq!(seen, (0..=18).collect::<Vec<_>>(), "walk stalled: {seen:?}");
17956 }
17957
17958 #[test]
17959 fn line_flow_preserve_resplits_the_map_and_defaults_to_fold() {
17960 // The paragraph holds one soft break. Folded (the default) it lays out as
17961 // a single reflowed row; Preserve re-lays it as a row per source line.
17962 // The setter must invalidate the cached map for the change to show, and
17963 // again on the way back — so a round trip returns to the folded layout.
17964 let mut d = wysiwyg_doc("line_flow", "one two\nthree four\n");
17965 assert_eq!(d.line_flow(), LineFlow::Fold, "fold is the default");
17966 d.build_visual(80);
17967 assert_eq!(d.vmap.num_rows(), 1, "fold: one flowing row");
17968
17969 d.set_line_flow(LineFlow::Preserve);
17970 d.build_visual(80);
17971 assert_eq!(d.vmap.num_rows(), 2, "preserve: a row per source line");
17972
17973 d.set_line_flow(LineFlow::Fold);
17974 d.build_visual(80);
17975 assert_eq!(d.vmap.num_rows(), 1, "fold again: back to one row");
17976 }
17977
17978 #[test]
17979 fn the_caret_still_crosses_a_preserved_soft_break() {
17980 // Preserve renders the soft break as a row boundary rather than a space,
17981 // but the caret must still reach every offset — the break's own offset is
17982 // the first row's end stop, so Right walks clean off the end of line one
17983 // onto line two, exactly as it does when the break is folded.
17984 let mut d = wysiwyg_doc("preserve_walk", "one two\nthree four\n");
17985 d.set_line_flow(LineFlow::Preserve);
17986 d.build_visual(80);
17987 d.caret = 0;
17988 let seen = walk_right(&mut d);
17989 assert_eq!(seen, (0..=18).collect::<Vec<_>>(), "walk stalled: {seen:?}");
17990 }
17991
17992 #[test]
17993 fn the_caret_walks_a_code_block() {
17994 // Every glyph of a code block used to map to the block's start, so the
17995 // whole block was a single offset and the caret couldn't move inside it.
17996 let src = "```rust\nlet x = 1;\nfn f() {}\n```\n";
17997 let mut d = wysiwyg_doc("code_walk", src);
17998 d.caret = 0;
17999 let seen = walk_right(&mut d);
18000 // The fences are markup: hidden, and no caret stop. The code between
18001 // them is reached a character at a time.
18002 let code = src.find("let").unwrap()..src.find("\n```").unwrap();
18003 for off in code.clone() {
18004 assert!(seen.contains(&off), "offset {off} unreachable: {seen:?}");
18005 }
18006 assert!(seen.contains(&code.end), "no stop after the last line");
18007 }
18008
18009 #[test]
18010 fn the_caret_walks_an_indented_code_block() {
18011 // An indented block's text has the four-space indent stripped, so it
18012 // isn't a verbatim slice and its lines have to be re-found. The caret
18013 // lands on the code, never in the indent.
18014 let src = " indented\n code\n";
18015 let mut d = wysiwyg_doc("indent_code_walk", src);
18016 d.caret = 0;
18017 let seen = walk_right(&mut d);
18018 assert!(seen.contains(&src.find("indented").unwrap()));
18019 assert!(seen.contains(&src.find("code").unwrap()));
18020 assert!(
18021 !seen.contains(&0) || seen[0] == 0,
18022 "the caret starts where it was put"
18023 );
18024 // Nothing in the stripped indent is a stop.
18025 for off in [1, 2, 3] {
18026 assert!(!seen.contains(&off), "landed in the indent at {off}");
18027 }
18028 }
18029
18030 #[test]
18031 fn the_caret_leaves_a_tight_heading() {
18032 // "# H" with text directly under it: the heading row's end and the
18033 // separator row's end are the same offset. Right used to find the
18034 // separator's copy, set the caret to where it already was, and stop.
18035 let mut d = wysiwyg_doc("tight_heading_walk", "# H\ntext\n");
18036 d.caret = 2; // the "H"
18037 let seen = walk_right(&mut d);
18038 assert!(
18039 seen.len() > 2,
18040 "Right stalled at the heading's end: {seen:?}"
18041 );
18042 assert!(
18043 seen.contains(&8),
18044 "never reached the end of \"text\": {seen:?}"
18045 );
18046 }
18047
18048 #[test]
18049 fn the_caret_skips_the_gap_between_two_paragraphs() {
18050 // The blank line between two paragraphs is the boundary itself. The
18051 // caret used to be able to sit on it, and typing there landed in the
18052 // previous paragraph — "A\n\nB" became "A\nx\nB", one paragraph with a
18053 // soft break, so the text visibly snapped back up.
18054 let mut d = wysiwyg_doc("gap_skip", "A\n\nB\n");
18055 d.caret = 1; // the end of "A"
18056 d.move_right(false);
18057 assert_eq!(d.caret, 3, "Right stopped in the gap");
18058 d.insert("x");
18059 assert_eq!(d.source, "A\n\nxB\n", "typing landed outside B");
18060 }
18061
18062 #[test]
18063 fn down_from_a_paragraph_lands_on_the_next_one() {
18064 let mut d = wysiwyg_doc("gap_down", "A\n\nB\n");
18065 d.caret = 0;
18066 d.move_down(false);
18067 assert_eq!(d.caret, 3, "Down stopped in the gap");
18068 }
18069
18070 #[test]
18071 fn clicking_the_gap_lands_on_real_text() {
18072 // A click can still *reach* the gap — it's drawn, so it's clickable.
18073 // It has to resolve to somewhere the caret can be.
18074 let mut d = wysiwyg_doc("gap_click", "A\n\nB\n");
18075 d.click(1, 0, false); // the gap row
18076 assert!(
18077 d.caret == 1 || d.caret == 3,
18078 "click left the caret in the gap at {}",
18079 d.caret
18080 );
18081 d.insert("x");
18082 // Either edge of the boundary is a fair place to land; inside it isn't.
18083 assert!(
18084 d.source == "Ax\n\nB\n" || d.source == "A\n\nxB\n",
18085 "click in the gap typed into the boundary: {:?}",
18086 d.source
18087 );
18088 }
18089
18090 #[test]
18091 fn enter_opens_an_empty_paragraph_the_caret_can_type_into() {
18092 // Enter inserts a paragraph break, which leaves a blank line spare on
18093 // either side of a new one. That middle line is a real empty paragraph:
18094 // the caret lands there, and typing makes a paragraph rather than
18095 // extending a neighbour.
18096 let mut d = wysiwyg_doc("gap_enter", "A\n\nB\n");
18097 d.caret = 1;
18098 d.newline();
18099 assert_eq!(d.source, "A\n\n\n\nB\n");
18100 d.build_visual(80);
18101 let (row, _) = d.caret_pos();
18102 assert!(
18103 d.vmap.row_is_navigable(row),
18104 "the caret landed on a gap row"
18105 );
18106 d.insert("x");
18107 assert_eq!(
18108 d.source, "A\n\nx\n\nB\n",
18109 "the new paragraph merged into a neighbour"
18110 );
18111 }
18112
18113 #[test]
18114 fn enter_at_the_end_of_the_document_opens_a_paragraph_too() {
18115 let mut d = wysiwyg_doc("gap_eof", "A\n");
18116 d.caret = 1;
18117 d.newline();
18118 d.build_visual(80);
18119 let (row, _) = d.caret_pos();
18120 assert!(
18121 d.vmap.row_is_navigable(row),
18122 "the caret landed on a gap row"
18123 );
18124 d.insert("x");
18125 assert!(
18126 d.source.starts_with("A\n\n") && d.source.contains('x'),
18127 "typing at the end merged into A: {:?}",
18128 d.source
18129 );
18130 }
18131
18132 // ── click_past_end ───────────────────────────────────────────────────────
18133
18134 /// [`Doc::click_past_end`] on `body`, and the source it left, with the caret
18135 /// rendered as `|`.
18136 fn past_end(name: &str, body: &str) -> (Doc, String) {
18137 let mut d = wysiwyg_doc(name, body);
18138 d.click_past_end();
18139 let out = render_caret(&d);
18140 (d, out)
18141 }
18142
18143 #[test]
18144 fn click_past_end_opens_an_empty_paragraph_under_the_last_block() {
18145 let (mut d, out) = past_end("pe_para", "A\n");
18146 assert_eq!(out, "A\n\n|");
18147 d.build_visual(80);
18148 let (row, _) = d.caret_pos();
18149 assert!(
18150 d.vmap.row_is_navigable(row),
18151 "the caret landed on a gap row"
18152 );
18153 d.insert("x");
18154 assert_eq!(d.source, "A\n\nx", "typing merged into A");
18155 }
18156
18157 #[test]
18158 fn click_past_end_writes_both_newlines_when_the_file_has_none() {
18159 assert_eq!(past_end("pe_bare", "A").1, "A\n\n|");
18160 }
18161
18162 #[test]
18163 fn click_past_end_is_only_a_caret_move_when_the_paragraph_is_already_there() {
18164 let (d, out) = past_end("pe_there", "A\n\n");
18165 assert_eq!(out, "A\n\n|");
18166 assert!(!d.dirty, "a click wrote to a document it did not need to");
18167 assert!(
18168 !d.can_undo(),
18169 "a click that changed nothing left an undo step"
18170 );
18171 }
18172
18173 #[test]
18174 fn click_past_end_leaves_a_closed_fence() {
18175 let (mut d, out) = past_end("pe_fence", "```\ncode\n```\n");
18176 assert_eq!(out, "```\ncode\n```\n\n|");
18177 d.build_visual(80);
18178 let (row, _) = d.caret_pos();
18179 assert!(!d.vmap.rows[row].code, "the caret is still on a code row");
18180 d.insert("x");
18181 assert_eq!(d.source, "```\ncode\n```\n\nx");
18182 }
18183
18184 #[test]
18185 fn click_past_end_closes_an_unclosed_fence_first() {
18186 assert_eq!(past_end("pe_open", "```\ncode\n").1, "```\ncode\n```\n\n|");
18187 assert_eq!(
18188 past_end("pe_open2", "````\ncode").1,
18189 "````\ncode\n````\n\n|"
18190 );
18191 assert_eq!(past_end("pe_tilde", "~~~\ncode\n").1, "~~~\ncode\n~~~\n\n|");
18192 assert_eq!(
18193 past_end("pe_quoted", "> ```\n> code\n").1,
18194 "> ```\n> code\n> ```\n\n|"
18195 );
18196 }
18197
18198 #[test]
18199 fn click_past_end_does_not_close_an_indented_block() {
18200 assert_eq!(past_end("pe_indent", " code\n").1, " code\n\n|");
18201 }
18202
18203 #[test]
18204 fn click_past_end_under_a_list_and_a_table_leaves_them() {
18205 let (mut d, out) = past_end("pe_list", "- a\n- b\n");
18206 assert_eq!(out, "- a\n- b\n\n|");
18207 d.insert("x");
18208 assert_eq!(d.source, "- a\n- b\n\nx", "typed into the list");
18209 assert_eq!(
18210 past_end("pe_table", "| a |\n|---|\n| b |\n").1,
18211 "| a |\n|---|\n| b |\n\n|"
18212 );
18213 }
18214
18215 #[test]
18216 fn click_past_end_on_an_empty_document_writes_nothing() {
18217 let (d, out) = past_end("pe_empty", "");
18218 assert_eq!(out, "|");
18219 assert!(!d.dirty);
18220 }
18221
18222 #[test]
18223 fn click_past_end_is_one_undo_step() {
18224 let (mut d, _) = past_end("pe_undo", "A\n");
18225 d.undo();
18226 assert_eq!(d.source, "A\n");
18227 }
18228
18229 #[test]
18230 fn click_past_end_in_the_source_view_only_moves_the_caret() {
18231 let mut d = doc_with("pe_source", "A\n");
18232 d.click_past_end();
18233 assert_eq!(render_caret(&d), "A\n|");
18234 assert!(!d.dirty);
18235 }
18236
18237 #[test]
18238 fn click_past_end_on_a_read_only_document_only_moves_the_caret() {
18239 let mut d = wysiwyg_doc("pe_ro", "A\n");
18240 d.set_read_only(true);
18241 d.click_past_end();
18242 assert_eq!(render_caret(&d), "A\n|");
18243 }
18244
18245 // ── toggle_code_block ────────────────────────────────────────────────────
18246
18247 #[test]
18248 fn toggle_code_block_fences_the_paragraph_at_the_caret_and_reverses() {
18249 let g = |n, m| golden_in(View::Wysiwyg, n, m, |d| d.toggle_code_block());
18250 assert_eq!(g("cb_on", "hel|lo\n"), "```\nhel|lo\n```\n");
18251 assert_eq!(g("cb_off", "```\nhel|lo\n```\n"), "hel|lo\n");
18252 assert_eq!(g("cb_end", "hello|\n"), "```\nhello|\n```\n");
18253 assert_eq!(
18254 g("cb_wrap", "aaa\nb|bb\nccc\n"),
18255 "```\naaa\nb|bb\nccc\n```\n"
18256 );
18257 assert_eq!(
18258 g("cb_unwrap", "```\naaa\nb|bb\nccc\n```\n"),
18259 "aaa\nb|bb\nccc\n"
18260 );
18261 // An indented block dedents, and the lines stay one-to-one.
18262 assert_eq!(g("cb_indent", " co|de\n"), "co|de\n");
18263 // A quote's marker is kept on every line, the fence's included.
18264 assert_eq!(g("cb_quote", "> hel|lo\n"), "> ```\n> hel|lo\n> ```\n");
18265 }
18266
18267 #[test]
18268 fn toggle_code_block_on_a_blank_line_opens_an_empty_fence() {
18269 let g = |n, m| golden_in(View::Wysiwyg, n, m, |d| d.toggle_code_block());
18270 assert_eq!(g("cb_blank", "A\n\n|"), "A\n\n```\n|\n```");
18271 assert_eq!(
18272 g("cb_blank_mid", "A\n\n|\n\nB\n"),
18273 "A\n\n```\n|\n```\n\nB\n"
18274 );
18275 // Tight against a neighbour, a blank line goes in on that side.
18276 assert_eq!(g("cb_blank_tight", "A\n|\nB\n"), "A\n\n```\n|\n```\n\nB\n");
18277 // Inside a quote, on a quoted blank line.
18278 assert_eq!(
18279 g("cb_blank_quote", "> A\n>\n> |\n"),
18280 "> A\n>\n> ```\n> |\n> ```\n"
18281 );
18282 }
18283
18284 #[test]
18285 fn toggle_code_block_from_a_blank_line_is_a_block_the_caret_can_type_into() {
18286 let mut d = wysiwyg_doc("cb_type", "A\n\n");
18287 d.click_past_end();
18288 d.toggle_code_block();
18289 assert!(
18290 d.caret_in_code_block(),
18291 "the caret is not in the block it opened"
18292 );
18293 d.insert("let x = 1;");
18294 d.newline();
18295 d.insert("x");
18296 assert_eq!(d.source, "A\n\n```\nlet x = 1;\nx\n```");
18297 d.build_visual(80);
18298 let (row, _) = d.caret_pos();
18299 assert!(d.vmap.rows[row].code, "typed text is not on a code row");
18300 // And back out: the button reverses what it did, text kept.
18301 d.toggle_code_block();
18302 assert_eq!(d.source, "A\n\nlet x = 1;\nx\n");
18303 assert!(!d.caret_in_code_block());
18304 }
18305
18306 #[test]
18307 fn toggle_code_block_over_a_selection_fences_it_whole_and_keeps_it_selected() {
18308 let mut d = wysiwyg_doc("cb_sel", "one\n\ntwo\n\nthree\n");
18309 d.anchor = Some(1);
18310 d.caret = 7;
18311 d.toggle_code_block();
18312 assert_eq!(d.source, "```\none\n\ntwo\n```\n\nthree\n");
18313 assert!(d.selection().is_some(), "the block came back unselected");
18314 d.toggle_code_block();
18315 assert_eq!(
18316 d.source, "one\n\ntwo\n\nthree\n",
18317 "a second press did not reverse the first"
18318 );
18319 }
18320
18321 #[test]
18322 fn toggle_code_block_inside_a_list_item_fences_and_unfences_in_place() {
18323 for (name, before, fenced) in [
18324 ("cb_list", "- it|em\n", "- ```\n it|em\n ```\n"),
18325 ("cb_olist", "1. it|em\n", "1. ```\n it|em\n ```\n"),
18326 ("cb_task", "- [ ] it|em\n", "- [ ] ```\n it|em\n ```\n"),
18327 (
18328 "cb_nested",
18329 "- a\n - it|em\n",
18330 "- a\n - ```\n it|em\n ```\n",
18331 ),
18332 ] {
18333 let mut d = wysiwyg_doc(name, &before.replace('|', ""));
18334 d.caret = before.find('|').unwrap();
18335 d.toggle_code_block();
18336 assert_eq!(render_caret(&d), fenced, "{name}: fencing");
18337 assert!(
18338 d.caret_in_code_block(),
18339 "{name}: the caret is in the new block"
18340 );
18341 d.toggle_code_block();
18342 assert_eq!(
18343 render_caret(&d),
18344 before,
18345 "{name}: a second press reverses the first"
18346 );
18347 }
18348 }
18349
18350 #[test]
18351 fn toggle_code_block_in_a_list_item_is_one_undo_step() {
18352 let mut d = wysiwyg_doc("cb_list_undo", "- item\n");
18353 d.caret = 4;
18354 d.toggle_code_block();
18355 d.undo();
18356 assert_eq!(render_caret(&d), "- it|em\n");
18357 }
18358
18359 #[test]
18360 fn caret_in_code_block_reads_fenced_and_indented_blocks() {
18361 let mut d = wysiwyg_doc("cb_in", "para\n\n```\ncode\n```\n\n more\n");
18362 d.caret = 2;
18363 assert!(!d.caret_in_code_block());
18364 d.caret = 11;
18365 assert!(d.caret_in_code_block());
18366 d.caret = 25;
18367 assert!(d.caret_in_code_block(), "an indented block is a code block");
18368 }
18369
18370 #[test]
18371 fn caret_in_blockquote_reads_every_line_of_a_quote_and_nothing_after() {
18372 let src = "para\n\n> # Head\n> body|\n\nafter\n";
18373 let mut d = wysiwyg_doc("bq_in", &src.replace('|', ""));
18374 let quote_start = src.find('>').unwrap();
18375 let body_end = src.find('|').unwrap();
18376 let after = src.find("after").unwrap() - 1;
18377 for (at, inside) in [
18378 (2, false),
18379 (quote_start + 4, true),
18380 (body_end - 2, true),
18381 (body_end, true),
18382 (after, false),
18383 (after + 2, false),
18384 ] {
18385 d.caret = at;
18386 assert_eq!(d.caret_in_blockquote(), inside, "caret at {at}");
18387 }
18388 // A heading in a quote is both: the quote is a layer over the style.
18389 d.caret = quote_start + 4;
18390 assert_eq!(d.current_heading_level(), Some(1));
18391 }
18392
18393 #[test]
18394 fn caret_in_blockquote_follows_the_toggle() {
18395 let mut d = wysiwyg_doc("bq_toggle", "hello\n");
18396 d.caret = 2;
18397 assert!(!d.caret_in_blockquote());
18398 d.toggle_blockquote();
18399 assert!(d.caret_in_blockquote());
18400 d.toggle_blockquote();
18401 assert!(!d.caret_in_blockquote());
18402 }
18403
18404 #[test]
18405 fn toggle_code_block_is_one_undo_step() {
18406 let mut d = wysiwyg_doc("cb_undo", "hello\n");
18407 d.caret = 2;
18408 d.toggle_code_block();
18409 d.undo();
18410 assert_eq!(render_caret(&d), "he|llo\n");
18411 }
18412
18413 #[test]
18414 fn triple_click_selects_a_paragraph_across_its_soft_breaks() {
18415 // A paragraph broken over two source lines is one paragraph. Selecting
18416 // it must not stop at the newline inside it — that newline is markup the
18417 // rich-text view exists to hide.
18418 let src = "one two\nthree four\n\nnext\n";
18419 let mut d = wysiwyg_doc("triple_para", src);
18420 d.select_block_at(2);
18421 assert_eq!(
18422 d.selected_text(),
18423 Some("one two\nthree four"),
18424 "stopped at the soft break"
18425 );
18426 }
18427
18428 #[test]
18429 fn the_wheel_can_scroll_away_from_a_caret_that_stays_put() {
18430 // The reader scrolls down past the caret's row. Nothing moved the
18431 // caret, so the view must stay where it was put — the old code revealed
18432 // the caret every frame, which dragged the view straight back and made
18433 // the document unscrollable past the caret.
18434 let mut d = wysiwyg_doc("scroll_free", "a\n\nb\n\nc\n\nd\n\ne\n");
18435 d.caret = 0;
18436 d.follow_caret(0, 3, 9); // first frame: the caret is at the top
18437 d.scroll = 4; // the wheel
18438 d.follow_caret(0, 3, 9);
18439 assert_eq!(
18440 d.scroll, 4,
18441 "the wheel was overruled by a caret that never moved"
18442 );
18443 }
18444
18445 #[test]
18446 fn moving_the_caret_brings_the_view_back_to_it() {
18447 let mut d = wysiwyg_doc("scroll_follow", "a\n\nb\n\nc\n\nd\n\ne\n");
18448 d.caret = 0;
18449 d.follow_caret(0, 3, 9);
18450 d.scroll = 6; // scrolled away
18451 d.move_right(false); // ...and now the caret moves
18452 let (row, _) = d.caret_pos();
18453 d.follow_caret(row, 3, 9);
18454 assert!(
18455 d.scroll <= row && row < d.scroll + 3,
18456 "caret row {row} off screen at scroll {}",
18457 d.scroll
18458 );
18459 }
18460
18461 #[test]
18462 fn scrolling_stops_at_the_last_row() {
18463 let mut d = wysiwyg_doc("scroll_clamp", "a\n\nb\n");
18464 d.caret = 0;
18465 d.follow_caret(0, 3, 3); // a first frame, so the caret isn't "new"
18466 d.scroll = 999; // the wheel, spun hard
18467 d.follow_caret(0, 3, 3);
18468 assert_eq!(d.scroll, 2, "scrolled into the void past the document");
18469 }
18470
18471 #[test]
18472 fn every_cell_of_a_wide_table_is_reachable() {
18473 // A table whose cells are far wider than the surface: the columns are
18474 // cut to fit and the text wraps inside them, so no cell hangs off the
18475 // right edge where the caret can never go.
18476 let src = "| Ingredient | Notes |\n|---|---|\n\
18477 | flour milled coarse | sift it twice before folding it in |\n";
18478 let mut d = wysiwyg_doc("wide_table_walk", src);
18479 d.build_visual(30);
18480 d.caret = 0;
18481 let seen = walk_right(&mut d);
18482 for word in ["Ingredient", "Notes", "coarse", "folding"] {
18483 let at = src.find(word).unwrap();
18484 assert!(seen.contains(&at), "{word:?} at {at} unreachable: {seen:?}");
18485 }
18486 }
18487
18488 // ── view parity ──────────────────────────────────────────────────────────
18489 // `doc_with` pins the source view, so everything above tests a view users
18490 // never start in — `Doc::open` opens in WYSIWYG. These run the motion and
18491 // deletion golden cases through *both*, plus the WYSIWYG cases the two
18492 // can't share: where the source carries markup the rendered text is a
18493 // different string, and the views agreeing would itself be the bug.
18494
18495 const VIEWS: [(View, &str); 2] = [(View::Source, "source"), (View::Wysiwyg, "wysiwyg")];
18496
18497 /// Run `action` in both views on one `|`-marked fixture and assert they
18498 /// agree. Plain prose only: with no markup to hide, WYSIWYG renders the
18499 /// source verbatim, so the two views are looking at the same text and any
18500 /// disagreement is one of them having lost the plot.
18501 fn both_views(name: &str, marked: &str, action: fn(&mut Doc)) -> String {
18502 let (src, caret) = parse_caret(marked);
18503 let run = |view: View, tag: &str| {
18504 let mut d = doc_in(view, &format!("{name}_{tag}"), &src);
18505 d.caret = caret;
18506 action(&mut d);
18507 render_caret(&d)
18508 };
18509 let source = run(VIEWS[0].0, VIEWS[0].1);
18510 let wysiwyg = run(VIEWS[1].0, VIEWS[1].1);
18511 assert_eq!(source, wysiwyg, "the views disagree on {marked:?}");
18512 source
18513 }
18514
18515 #[test]
18516 fn word_motion_agrees_across_the_views_on_plain_prose() {
18517 let g = both_views;
18518 assert_eq!(
18519 g("par_wl", "hello wor|ld", |d| d.move_word_left(false)),
18520 "hello |world"
18521 );
18522 assert_eq!(
18523 g("par_wl2", "hello| world", |d| d.move_word_left(false)),
18524 "|hello world"
18525 );
18526 assert_eq!(
18527 g("par_wr", "hel|lo world", |d| d.move_word_right(false)),
18528 "hello| world"
18529 );
18530 assert_eq!(
18531 g("par_wr2", "hello| world", |d| d.move_word_right(false)),
18532 "hello world|"
18533 );
18534 assert_eq!(
18535 g("par_punct", "|foo.bar", |d| d.move_word_right(false)),
18536 "foo|.bar"
18537 );
18538 assert_eq!(
18539 g("par_ext", "hello |world", |d| d.move_word_right(true)),
18540 "hello [world|]"
18541 );
18542 }
18543
18544 #[test]
18545 fn word_deletion_agrees_across_the_views_on_plain_prose() {
18546 let g = both_views;
18547 assert_eq!(
18548 g("par_db", "hello world|", |d| d.delete_word_back()),
18549 "hello |"
18550 );
18551 assert_eq!(
18552 g("par_df", "hello |world", |d| d.delete_word_forward()),
18553 "hello |"
18554 );
18555 assert_eq!(
18556 g("par_db2", "foo |bar baz", |d| d.delete_word_back()),
18557 "|bar baz"
18558 );
18559 assert_eq!(g("par_utf8", "café |ok", |d| d.delete_word_back()), "|ok");
18560 }
18561
18562 #[test]
18563 fn character_motion_and_deletion_agree_across_the_views_on_plain_prose() {
18564 let g = both_views;
18565 assert_eq!(g("par_r", "he|llo", |d| d.move_right(false)), "hel|lo");
18566 assert_eq!(g("par_l", "he|llo", |d| d.move_left(false)), "h|ello");
18567 assert_eq!(g("par_bs", "hel|lo", |d| d.backspace()), "he|lo");
18568 assert_eq!(g("par_del", "hel|lo", |d| d.delete_forward()), "hel|o");
18569 }
18570
18571 #[test]
18572 fn wysiwyg_motion_steps_a_grapheme_cluster_the_way_the_source_view_does() {
18573 // The reproduction: the stop table was built one stop per `char`, so
18574 // Right parked the caret 4 bytes into a ZWJ sequence — a place the
18575 // source view, which steps by grapheme, can't reach and backspace can't
18576 // survive. The two views must land on the same offset.
18577 let family = "👨👩👧"; // three emoji strung together with joiners: one cluster
18578 for (view, tag) in VIEWS {
18579 let mut d = doc_in(view, &format!("cluster_{tag}"), &format!("a{family}b\n"));
18580 d.caret = 1;
18581 d.move_right(false);
18582 assert_eq!(d.caret, 1 + family.len(), "{tag} parked inside the cluster");
18583
18584 // ...and the edit that used to sever a joiner off the front of it.
18585 d.backspace();
18586 assert_eq!(d.source, "ab\n", "{tag} split the cluster");
18587 assert_eq!(d.caret, 1);
18588 }
18589 }
18590
18591 #[test]
18592 fn wysiwyg_motion_treats_a_combining_accent_as_one_character() {
18593 for (view, tag) in VIEWS {
18594 let mut d = doc_in(view, &format!("combining_{tag}"), "e\u{0301}x\n");
18595 d.caret = 0;
18596 d.move_right(false);
18597 assert_eq!(
18598 d.caret,
18599 "e\u{0301}".len(),
18600 "{tag} stopped on the combining mark"
18601 );
18602 }
18603 }
18604
18605 #[test]
18606 fn no_wysiwyg_motion_can_park_the_caret_inside_a_cluster() {
18607 // The general form: whatever route the caret takes through a document
18608 // full of clusters, it never lands between the codepoints of one — so no
18609 // motion-then-backspace sequence can leave a dangling joiner behind.
18610 use unicode_segmentation::UnicodeSegmentation;
18611
18612 let src = "a👨👩👧b e\u{0301}mo👨👩👧ji\n\nnext 👩🚀 line\n";
18613 let mut d = wysiwyg_doc("cluster_walk", src);
18614 d.caret = 0;
18615 let boundaries: Vec<usize> = src
18616 .grapheme_indices(true)
18617 .map(|(i, _)| i)
18618 .chain(std::iter::once(src.len()))
18619 .collect();
18620 for off in walk_right(&mut d) {
18621 assert!(
18622 boundaries.contains(&off),
18623 "Right stopped at {off}, inside a grapheme cluster"
18624 );
18625 }
18626 }
18627
18628 #[test]
18629 fn wysiwyg_word_motion_stays_out_of_hidden_delimiters() {
18630 // The reproduction: ⌥→ from inside the opening `**` computed its
18631 // boundary over the raw source and landed on byte 8 — inside the
18632 // *closing* `**`, which `caret_pos` draws at column 6, immediately after
18633 // "bold". The caret drew past the bold word and sat inside it.
18634 let mut d = wysiwyg_doc("wys_word_delim", "a **bold** c\n");
18635 d.caret = 2;
18636 d.move_word_right(false);
18637 assert!(
18638 d.vmap.is_stop(d.caret),
18639 "landed at {}, not a caret stop",
18640 d.caret
18641 );
18642 assert_eq!(d.caret, 10, "should land on the space after \"bold\"");
18643 // The rendered row is "a bold c": column 6 is the space just past "bold",
18644 // and now the caret is really there rather than only drawn there.
18645 assert_eq!(d.caret_pos(), (0, 6));
18646
18647 // ...and back again: ⌥← returns to the "b", not into the opening `**`.
18648 d.move_word_left(false);
18649 assert_eq!(d.caret, 4);
18650 assert_eq!(d.caret_pos(), (0, 2));
18651 }
18652
18653 #[test]
18654 fn wysiwyg_word_delete_takes_the_markup_with_the_word() {
18655 // The reproduction: ⌥⌫ from after "bold" walked the raw source, stopped
18656 // inside the closing `**`, and left "a ** c\n" — delimiters with no
18657 // opener. Glyph space covers the word alone, which would leave
18658 // "a **** c": markup wrapped around nothing. The word and the styling
18659 // that was only ever the word's go together.
18660 let mut d = wysiwyg_doc("wys_word_del_back", "a **bold** c\n");
18661 d.caret = 10;
18662 d.delete_word_back();
18663 assert_eq!(d.source, "a c\n");
18664 assert_eq!(d.caret, 2);
18665
18666 let mut d = wysiwyg_doc("wys_word_del_fwd", "a **bold** c\n");
18667 d.caret = 4; // the "b"
18668 d.delete_word_forward();
18669 assert_eq!(d.source, "a c\n");
18670 }
18671
18672 #[test]
18673 fn wysiwyg_word_delete_empties_a_nested_mark_and_a_code_span_too() {
18674 let src = "a ***bold*** c\n";
18675 let mut d = wysiwyg_doc("wys_word_del_nest", src);
18676 d.caret = src.find(" c").unwrap();
18677 d.delete_word_back();
18678 assert_eq!(
18679 d.source, "a c\n",
18680 "the emph inside the strong empties it too"
18681 );
18682
18683 let src = "a `code` c\n";
18684 let mut d = wysiwyg_doc("wys_word_del_code", src);
18685 d.caret = src.find(" c").unwrap();
18686 d.delete_word_back();
18687 assert_eq!(d.source, "a c\n");
18688 }
18689
18690 #[test]
18691 fn wysiwyg_word_delete_keeps_a_mark_that_still_has_text() {
18692 // Only an *emptied* node goes. Take one word of two and the `**` still
18693 // has a job to do — over the word that's left, with the space the delete
18694 // pushed against the opening delimiter moved out in front of it, or the
18695 // run would be no run at all (`** words**` is literal asterisks — see
18696 // the mark-edge rule on `splice`).
18697 let src = "a **two words** c\n";
18698 let mut d = wysiwyg_doc("wys_word_del_partial", src);
18699 d.caret = src.find(" words").unwrap();
18700 d.delete_word_back();
18701 assert_eq!(d.source, "a **words** c\n");
18702 }
18703
18704 #[test]
18705 fn source_view_word_motion_still_walks_the_markup() {
18706 // The other half of the decision: in the source view the `**` are
18707 // characters like any other — they're on the screen, so word motion has
18708 // to stop at them and a word-delete has to leave them behind. Only
18709 // WYSIWYG hides them, so only WYSIWYG steps over them.
18710 let g = |n, m, f: fn(&mut Doc)| golden(n, m, f);
18711 assert_eq!(
18712 g("src_word_motion", "a |**bold** c\n", |d| d
18713 .move_word_right(false)),
18714 "a **bold|** c\n"
18715 );
18716 // The same caret as the WYSIWYG reproduction, and the opposite outcome:
18717 // here "a ** c\n" is right, because `bold**` is what's to the left of it.
18718 assert_eq!(
18719 g("src_word_del", "a **bold**| c\n", |d| d.delete_word_back()),
18720 "a **| c\n"
18721 );
18722 }
18723
18724 #[test]
18725 fn every_wysiwyg_motion_lands_on_a_caret_stop() {
18726 // The single invariant both bugs violated: the caret draws and edits at
18727 // the same place only when it's on a stop. `debug_assert_on_a_stop`
18728 // makes the same claim in-place; this pins it from the outside, over a
18729 // document with every kind of thing the map has to be careful about.
18730 // At two widths: the wide one every other test builds at, where no
18731 // fixture folds, and one narrow enough that they all do. A soft wrap is
18732 // where an offset stops being on exactly one row, and testing only the
18733 // width that never wraps is how the caret came to be pinned at the first
18734 // one Down reached.
18735 let src = "# Title\n\na **bold** e\u{0301}mo👨👩👧ji `x` c\n\n\
18736 - item one\n\n| A | B |\n|---|---|\n| x | y |\n";
18737 // A table of named operations, which is what it looks like.
18738 #[allow(clippy::type_complexity)]
18739 let motions: [(&str, fn(&mut Doc)); 8] = [
18740 ("right", |d| d.move_right(false)),
18741 ("left", |d| d.move_left(false)),
18742 ("word_right", |d| d.move_word_right(false)),
18743 ("word_left", |d| d.move_word_left(false)),
18744 ("down", |d| d.move_down(false)),
18745 ("up", |d| d.move_up(false)),
18746 ("home", |d| d.move_home(false)),
18747 ("end", |d| d.move_end(false)),
18748 ];
18749 for width in [80, 12] {
18750 let mut d = wysiwyg_doc("stop_invariant", src);
18751 d.build_visual(width);
18752 let stops: Vec<usize> = (0..=src.len()).filter(|&o| d.vmap.is_stop(o)).collect();
18753 assert!(stops.len() > 20, "fixture should have plenty of stops");
18754 for start in stops {
18755 for (name, motion) in &motions {
18756 d.caret = start;
18757 d.anchor = None;
18758 motion(&mut d);
18759 assert!(
18760 d.vmap.is_stop(d.caret),
18761 "{name} from {start} at width {width} landed at {} — not a caret stop",
18762 d.caret
18763 );
18764 }
18765 }
18766 }
18767 }
18768
18769 #[test]
18770 fn no_wysiwyg_motion_is_a_dead_end() {
18771 // Down held to the bottom of a document reaches the bottom, and Up held
18772 // to the top reaches the top — from anywhere, at a width that wraps. The
18773 // invariant above says a motion lands somewhere legal; this one says it
18774 // gets somewhere at all, which is what a caret pinned at a wrap boundary
18775 // was quietly failing to do while every assertion around it held.
18776 let src = "# Title\n\none two three four five six seven eight nine ten\n\n\
18777 - item one two three four five\n\nlast\n";
18778 for width in [80, 12] {
18779 let mut d = wysiwyg_doc("no_dead_end", src);
18780 d.build_visual(width);
18781 let stops: Vec<usize> = (0..=src.len()).filter(|&o| d.vmap.is_stop(o)).collect();
18782 let (first, last) = (stops[0], stops[stops.len() - 1]);
18783 for &start in &stops {
18784 for (name, motion, want) in [
18785 (
18786 "down",
18787 (|d: &mut Doc| d.move_down(false)) as fn(&mut Doc),
18788 last,
18789 ),
18790 ("up", |d: &mut Doc| d.move_up(false), first),
18791 ] {
18792 d.caret = start;
18793 d.anchor = None;
18794 d.goal_col = None;
18795 // Every row, plus the presses the edges take, plus slack.
18796 for _ in 0..d.vmap.num_rows() + 4 {
18797 motion(&mut d);
18798 }
18799 assert_eq!(
18800 d.caret, want,
18801 "{name} held from {start} at width {width} never arrived"
18802 );
18803 }
18804 }
18805 }
18806 }
18807 // ── display columns ──────────────────────────────────────────────────────
18808 // A `col` is a terminal cell, not a character. The two are the same number
18809 // for the ASCII the fixtures above are written in, which is how they came
18810 // apart in the first place: `你` is one character drawn in two cells, so a
18811 // column counted in characters names a cell the text isn't in — one earlier
18812 // for every wide character to its left.
18813
18814 #[test]
18815 fn a_wide_character_is_two_columns_wide() {
18816 // The reproduction: `你` is one char and two cells, so the caret just
18817 // past it drew at column 1 — inside the character it had already left.
18818 for (view, tag) in VIEWS {
18819 let mut d = doc_in(view, &format!("wide_col_{tag}"), "你好\n");
18820 d.caret = "你".len();
18821 assert_eq!(d.caret_pos(), (0, 2), "{tag}: caret drew inside 你");
18822 d.caret = "你好".len();
18823 assert_eq!(d.caret_pos(), (0, 4), "{tag}");
18824 }
18825 }
18826
18827 #[test]
18828 fn a_cluster_is_as_wide_as_it_is_drawn_not_as_its_codepoints_measure() {
18829 // `👨👩👧` is five codepoints — two-cell, joiner, two-cell, joiner,
18830 // two-cell — measuring six cells one at a time, but the character they
18831 // spell is drawn in two. Width belongs to the cluster, not the glyph,
18832 // and the frontends measure it the same way.
18833 let family = "👨👩👧";
18834 for (view, tag) in VIEWS {
18835 let src = format!("a{family}b\n");
18836 let mut d = doc_in(view, &format!("wide_cluster_{tag}"), &src);
18837 d.caret = 1 + family.len();
18838 assert_eq!(
18839 d.caret_pos(),
18840 (0, 3),
18841 "{tag}: 'a' is one cell, the family two"
18842 );
18843 }
18844 }
18845
18846 #[test]
18847 fn both_cells_of_a_wide_character_mean_the_character() {
18848 // Clicking the far half of `好` is still clicking `好`: half a character
18849 // is not a place the caret can be, so it comes to rest at the
18850 // character's start — the column it would have been drawn at anyway.
18851 for (view, tag) in VIEWS {
18852 let mut d = doc_in(view, &format!("wide_click_{tag}"), "你好\n");
18853 for col in [2, 3] {
18854 d.caret = 0;
18855 d.click(0, col, false);
18856 assert_eq!(d.caret, "你".len(), "{tag}: click at col {col}");
18857 assert_eq!(d.caret_pos(), (0, 2), "{tag}: click at col {col}");
18858 }
18859 // Past the last cell is the line's end, as it is for ASCII.
18860 d.click(0, 9, false);
18861 assert_eq!(d.caret, "你好".len(), "{tag}: click past the end");
18862 }
18863 }
18864
18865 #[test]
18866 fn every_offset_survives_the_trip_out_to_a_column_and_back() {
18867 // The mapping is only a mapping if it inverts: the cell the caret is
18868 // drawn in has to be the cell that brings it back to the same offset.
18869 // Over a fixture where a character may be one cell or two, and one
18870 // codepoint or five.
18871 use unicode_segmentation::UnicodeSegmentation;
18872
18873 let src = "ab 你好 c\n\n👨👩👧 e\u{0301}x 漢字\n\nplain ascii\n";
18874
18875 let mut d = doc_in(View::Source, "roundtrip_source", src);
18876 // Every offset the source view's caret can occupy: it steps by grapheme
18877 // cluster, so those are its boundaries.
18878 for (off, _) in src
18879 .grapheme_indices(true)
18880 .chain(std::iter::once((src.len(), "")))
18881 {
18882 d.caret = off;
18883 let (row, col) = d.caret_pos();
18884 d.click(row, col, false);
18885 assert_eq!(d.caret, off, "source: {off} → ({row}, {col}) → {}", d.caret);
18886 }
18887
18888 // And in WYSIWYG, where the offsets the caret can occupy are the map's
18889 // stops rather than every boundary.
18890 let mut d = doc_in(View::Wysiwyg, "roundtrip_wysiwyg", src);
18891 let stops: Vec<usize> = (0..=src.len()).filter(|&o| d.vmap.is_stop(o)).collect();
18892 assert!(stops.len() > 20, "fixture should have plenty of stops");
18893 for off in stops {
18894 d.caret = off;
18895 let (row, col) = d.caret_pos();
18896 d.click(row, col, false);
18897 assert_eq!(
18898 d.caret, off,
18899 "wysiwyg: {off} → ({row}, {col}) → {}",
18900 d.caret
18901 );
18902 }
18903 }
18904
18905 #[test]
18906 fn vertical_motion_aims_at_a_column_the_reader_can_see() {
18907 // Down from under `世` lands under the glyph in that cell, not two
18908 // characters further along the line. The goal is a column, so a line of
18909 // wide characters and a line of ASCII line up the way they're drawn.
18910 //
18911 // The gap differs by view: a bare newline inside a paragraph is a soft
18912 // break, which WYSIWYG draws as a space on a single row. The views share
18913 // a grid only where the source's lines are the renderer's rows too.
18914 for (view, tag) in VIEWS {
18915 let gap = if view == View::Source { "\n" } else { "\n\n" };
18916 let src = format!("你好世{gap}abcdef\n");
18917 let mut d = doc_in(view, &format!("goal_wide_{tag}"), &src);
18918 d.caret = "你好".len();
18919 assert_eq!(d.caret_pos().1, 4, "{tag}: `世` is drawn at column 4");
18920 d.move_down(false);
18921 assert_eq!(d.caret_pos().1, 4, "{tag}: goal column lost");
18922 assert!(
18923 d.source[d.caret..].starts_with('e'),
18924 "{tag}: landed on the wrong glyph"
18925 );
18926 }
18927 }
18928
18929 #[test]
18930 fn a_goal_column_landing_inside_a_wide_character_lands_on_it() {
18931 // Down from column 3 onto `你好`, whose characters start at columns 0
18932 // and 2: column 3 is the *second* cell of `好`. There is nowhere to be
18933 // between the cells of one character, so the caret rests on it — and on
18934 // its start, which is the only offset there that is a caret stop.
18935 for (view, tag) in VIEWS {
18936 let gap = if view == View::Source { "\n" } else { "\n\n" };
18937 let src = format!("abcdef{gap}你好\n");
18938 let mut d = doc_in(view, &format!("goal_inside_{tag}"), &src);
18939 let line = src.find('你').unwrap();
18940 d.caret = 3;
18941 d.move_down(false);
18942 assert_eq!(d.caret, line + "你".len(), "{tag}: landed off `好`'s start");
18943 assert_eq!(d.caret_pos().1, 2, "{tag}: drew between `好`'s cells");
18944 }
18945 }
18946
18947 #[test]
18948 fn a_caret_in_a_table_cell_of_wide_text_draws_where_the_text_is() {
18949 // The column the cell's text is laid out in is measured in cells, so the
18950 // caret walking that text has to be too — the two agreeing is the whole
18951 // point of the grid staying square.
18952 let mut d = wysiwyg_doc("table_wide", "| A | B |\n|---|---|\n| 你好 | y |\n");
18953 let at = d.source.find("你").unwrap();
18954 d.caret = at;
18955 let (row, col) = d.caret_pos();
18956 // `│ ` opens the row, so the cell's text starts at column 2; `好` is two
18957 // cells further along.
18958 assert_eq!(col, 2, "the cell's first character");
18959 d.move_right(false);
18960 assert_eq!(
18961 d.caret_pos(),
18962 (row, 4),
18963 "`好` is drawn past `你`'s two cells"
18964 );
18965 assert_eq!(d.caret, at + "你".len());
18966 }
18967
18968 // ── active inline marks ───────────────────────────────────────────────────
18969
18970 /// The marks at a `|`-marked fixture's caret, in `InlineMarks::iter` order.
18971 fn marks(view: View, name: &str, marked: &str) -> Vec<InlineKind> {
18972 let (src, caret) = parse_caret(marked);
18973 let mut d = doc_in(view, name, &src);
18974 d.caret = caret;
18975 d.active_inline_marks().iter().collect()
18976 }
18977
18978 /// The marks over the selection `[start, end)`.
18979 fn marks_over(view: View, name: &str, src: &str, start: usize, end: usize) -> Vec<InlineKind> {
18980 let mut d = doc_in(view, name, src);
18981 d.anchor = Some(start);
18982 d.caret = end;
18983 d.active_inline_marks().iter().collect()
18984 }
18985
18986 #[test]
18987 fn a_caret_in_a_mark_reports_it() {
18988 for (view, tag) in VIEWS {
18989 let m = |marked| marks(view, &format!("marks_in_{tag}"), marked);
18990 assert_eq!(m("a **bo|ld** b"), [InlineKind::Strong], "{tag}");
18991 assert_eq!(m("a *it|alic* b"), [InlineKind::Emph], "{tag}");
18992 assert_eq!(m("a `co|de` b"), [InlineKind::Verbatim], "{tag}");
18993 // Plain text under no mark lights nothing — the toolbar's resting state.
18994 assert_eq!(m("a| **bold** b"), [], "{tag}");
18995 assert!(m("plain t|ext").is_empty(), "{tag}");
18996 }
18997 }
18998
18999 #[test]
19000 fn nested_marks_all_report() {
19001 // Bold *and* italic: a toolbar lights both buttons, so the set has both —
19002 // the ancestor chain is a chain, and every mark on it is in force.
19003 for (view, tag) in VIEWS {
19004 assert_eq!(
19005 marks(
19006 view,
19007 &format!("marks_nested_{tag}"),
19008 "**bold and *bo|th*** end"
19009 ),
19010 [InlineKind::Strong, InlineKind::Emph],
19011 "{tag}"
19012 );
19013 }
19014 }
19015
19016 #[test]
19017 fn the_caret_at_a_marks_edge_reports_it_where_typing_would_extend_it() {
19018 // The offsets a WYSIWYG caret actually reaches at a bold run's edges are
19019 // the first byte of its text and the byte after its last — both inside
19020 // the mark's span, both places typing lands inside the bold. The offset
19021 // past the closing delimiter is the next text, and reports nothing.
19022 let src = "a **bold** b";
19023 let inner_start = src.find("bold").unwrap(); // 4
19024 let inner_end = inner_start + "bold".len(); // 8, on the closing `**`
19025 for (view, tag) in VIEWS {
19026 let mut d = doc_in(view, &format!("marks_edge_{tag}"), src);
19027 for off in [2, 3, inner_start, inner_end, 9] {
19028 d.caret = off;
19029 assert!(
19030 d.active_inline_marks().contains(InlineKind::Strong),
19031 "{tag}: offset {off} is inside the strong span"
19032 );
19033 }
19034 for off in [0, 1, 10, 11, 12] {
19035 d.caret = off;
19036 assert!(
19037 !d.active_inline_marks().contains(InlineKind::Strong),
19038 "{tag}: offset {off} is outside the strong run"
19039 );
19040 }
19041 }
19042 }
19043
19044 #[test]
19045 fn a_mark_ends_the_same_way_at_the_end_of_the_buffer_as_in_the_middle() {
19046 // Regression: twig resolves an offset that is one node's end and the
19047 // next one's start to the node that *starts* there, so `**bold**|\n`
19048 // isn't bold. With nothing following there's no tie to break and the
19049 // chain still ended at the mark, which made a trailing `\n` — not the
19050 // text — decide whether the caret after a bold word reported bold. It's
19051 // the offset past the mark either way, and typing there is plain either
19052 // way. A blank document typed into is exactly this shape.
19053 for (view, tag) in VIEWS {
19054 let m = |name: String, marked| marks(view, &name, marked);
19055 assert_eq!(
19056 m(format!("marks_eob_{tag}"), "**bold**|"),
19057 [],
19058 "{tag}: no trailing newline"
19059 );
19060 assert_eq!(
19061 m(format!("marks_eol_{tag}"), "**bold**|\n"),
19062 [],
19063 "{tag}: with one"
19064 );
19065 // And the last offset that *is* in the mark still is.
19066 assert_eq!(
19067 m(format!("marks_eob_in_{tag}"), "**bold*|*"),
19068 [InlineKind::Strong],
19069 "{tag}"
19070 );
19071 }
19072 }
19073
19074 #[test]
19075 fn a_selection_reports_a_mark_only_when_it_covers_the_whole_thing() {
19076 let src = "a **bold** b";
19077 let (b, d_) = (src.find("bold").unwrap(), src.find("bold").unwrap() + 4);
19078 for (view, tag) in VIEWS {
19079 let m = |s, e| marks_over(view, &format!("marks_sel_{tag}"), src, s, e);
19080 // The whole bold word, and a slice of it.
19081 assert_eq!(m(b, d_), [InlineKind::Strong], "{tag}: the whole word");
19082 assert_eq!(m(b + 1, d_ - 1), [InlineKind::Strong], "{tag}: a slice");
19083 // Ending exactly at the closing delimiter's start is still all-bold:
19084 // an exclusive end sits *past* the last selected character, so the
19085 // question is asked of the character, not the boundary.
19086 assert_eq!(
19087 m(b, d_ + 2),
19088 [InlineKind::Strong],
19089 "{tag}: through the close"
19090 );
19091 // Half in, half out: Bold lit here would claim a press turns it off.
19092 assert_eq!(m(0, d_), [], "{tag}: leading plain text");
19093 assert_eq!(m(b, src.len()), [], "{tag}: trailing plain text");
19094 }
19095 }
19096
19097 #[test]
19098 fn a_selection_across_two_runs_of_the_same_mark_reports_nothing() {
19099 // Both ends are bold, but the space between them isn't — two runs are two
19100 // nodes, which is exactly what the node id catches and a kind-only
19101 // comparison would not.
19102 let src = "**one** **two**";
19103 for (view, tag) in VIEWS {
19104 let m = marks_over(view, &format!("marks_runs_{tag}"), src, 2, 13);
19105 assert_eq!(m, [], "{tag}: `one** **two` is not all bold");
19106 }
19107 }
19108
19109 #[test]
19110 fn marks_read_the_document_as_it_is_edited() {
19111 // The point of asking twig every frame instead of caching: the answer has
19112 // to follow the toggle that changed it.
19113 let mut d = wysiwyg_doc("marks_live", "one two\n");
19114 d.anchor = Some(0);
19115 d.caret = 3;
19116 assert!(d.active_inline_marks().is_empty(), "plain to start");
19117 d.toggle(InlineKind::Strong);
19118 assert_eq!(d.source, "**one** two\n");
19119 // `toggle` leaves the bolded text selected, so the button it lit stays lit.
19120 assert!(d.active_inline_marks().contains(InlineKind::Strong));
19121 d.toggle(InlineKind::Strong);
19122 assert!(d.active_inline_marks().is_empty(), "and off again");
19123 }
19124
19125 #[test]
19126 fn a_link_is_not_an_inline_mark() {
19127 // `link`/`str` are inline nodes, but nothing on the inline toolbar
19128 // toggles them — a set with a "link mark" in it would have no button.
19129 for (view, tag) in VIEWS {
19130 assert_eq!(
19131 marks(view, &format!("marks_link_{tag}"), "a [te|xt](u) b"),
19132 [],
19133 "{tag}"
19134 );
19135 }
19136 }
19137
19138 // ── blank documents ───────────────────────────────────────────────────────
19139
19140 #[test]
19141 fn a_blank_document_is_untitled_empty_and_markdown() {
19142 let mut d = Doc::blank().unwrap();
19143 assert!(d.is_untitled());
19144 assert_eq!(d.path, PathBuf::new());
19145 assert_eq!(
19146 d.file_name(),
19147 "untitled",
19148 "the header has to show something"
19149 );
19150 assert_eq!(d.format_name(), "markdown");
19151 assert_eq!(d.source, "");
19152 assert!(!d.dirty, "nothing typed yet is nothing to lose");
19153 assert_eq!(d.disk_state(), DiskState::Untitled);
19154 // And it's a document you can be in: the default view renders it.
19155 d.build_visual(80);
19156 assert_eq!(d.caret, 0);
19157 }
19158
19159 #[test]
19160 fn saving_an_untitled_document_asks_for_a_name_instead_of_writing() {
19161 let mut d = Doc::blank().unwrap();
19162 d.insert("hello");
19163 assert!(d.dirty);
19164 d.save();
19165 assert_eq!(d.status.as_deref(), Some("untitled — save as…"));
19166 assert!(d.dirty, "it must not come away believing it saved");
19167 assert!(d.is_untitled(), "and it still has no file");
19168 }
19169
19170 #[test]
19171 fn a_blank_document_becomes_a_real_one_at_the_first_save_as() {
19172 let p = temp_path("blank_save_as");
19173 let mut d = Doc::blank().unwrap();
19174 // Plain text — a blank doc opens in Hidden mode, where a typed `#` would
19175 // be kept literal (`\#`); this test is about save-as, not escaping (which
19176 // has its own test), so it types nothing that escaping would touch.
19177 d.insert("hi");
19178 d.save_as(p.clone());
19179 assert_eq!(std::fs::read_to_string(&p).unwrap(), "hi");
19180 assert!(!d.is_untitled());
19181 assert!(!d.dirty);
19182 assert_eq!(d.file_name(), p.file_name().unwrap().to_string_lossy());
19183 assert_eq!(
19184 d.disk_state(),
19185 DiskState::Unchanged,
19186 "the watermark is stamped"
19187 );
19188 // And ⌘S is a plain save from here on.
19189 d.insert("!");
19190 d.save();
19191 assert_eq!(std::fs::read_to_string(&p).unwrap(), "hi!");
19192 let _ = std::fs::remove_file(&p);
19193 }
19194
19195 // ── a file that isn't there yet ───────────────────────────────────────────
19196
19197 /// A unique path in the temp dir with the given extension, guaranteed not to
19198 /// exist — what `leaf notes.md` is handed when the file has never been made.
19199 fn missing_path(name: &str, ext: &str) -> PathBuf {
19200 static SEQ: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
19201 let seq = SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
19202 let mut p = std::env::temp_dir();
19203 p.push(format!("leaf_test_new_{name}_{seq}.{ext}"));
19204 let _ = std::fs::remove_file(&p);
19205 p
19206 }
19207
19208 #[test]
19209 fn a_file_that_doesnt_exist_opens_as_an_empty_named_document() {
19210 let p = missing_path("named", "md");
19211 let mut d = Doc::open_or_create(p.clone()).unwrap();
19212
19213 assert_eq!(d.source, "", "nothing was read, so there's nothing in it");
19214 assert!(!d.dirty, "an untouched new buffer has nothing to lose");
19215 assert!(
19216 !d.is_untitled(),
19217 "it has the name the user asked for — ^S must not detour to Save As"
19218 );
19219 assert_eq!(d.file_name(), p.file_name().unwrap().to_str().unwrap());
19220 assert!(d.path.is_absolute(), "the same absolute path `open` stores");
19221 assert!(!p.exists(), "and opening it wrote nothing");
19222 // And it's a document you can be in.
19223 d.build_visual(80);
19224 assert_eq!(d.caret, 0);
19225 }
19226
19227 #[test]
19228 fn a_new_file_is_created_by_its_first_save() {
19229 let p = missing_path("first_save", "md");
19230 let mut d = Doc::open_or_create(p.clone()).unwrap();
19231 d.insert("hello\n");
19232 assert!(d.dirty);
19233 d.save();
19234
19235 assert_eq!(
19236 std::fs::read_to_string(&p).unwrap(),
19237 "hello\n",
19238 "a plain ^S wrote it — no Save As, no name to invent"
19239 );
19240 assert!(!d.dirty);
19241 assert_eq!(d.disk_state(), DiskState::Unchanged);
19242 let _ = std::fs::remove_file(&p);
19243 }
19244
19245 #[test]
19246 fn a_new_file_takes_its_format_from_the_extension() {
19247 // The one thing `blank` can't do: with no name it has to assume Markdown,
19248 // and typing djot into a Markdown parse is the wrong buffer.
19249 let dj = missing_path("format", "dj");
19250 assert_eq!(Doc::open_or_create(dj).unwrap().format_name(), "djot");
19251 let md = missing_path("format", "md");
19252 assert_eq!(Doc::open_or_create(md).unwrap().format_name(), "markdown");
19253 }
19254
19255 #[test]
19256 fn a_new_file_reports_itself_missing_until_it_is_saved() {
19257 // Not `Untitled` — that's the answer for a document with no path, and it
19258 // would tell a frontend there is nothing a save could collide with. Here
19259 // there is a path, and the file simply isn't at it yet.
19260 let p = missing_path("disk_state", "md");
19261 let mut d = Doc::open_or_create(p.clone()).unwrap();
19262 assert_eq!(d.disk_state(), DiskState::Missing);
19263
19264 // Somebody else creates it while the buffer is open: that's an overwrite
19265 // the frontend has to be able to prompt about, exactly as for an opened
19266 // file. Their bytes, not ours, so `Changed`.
19267 std::fs::write(&p, "theirs\n").unwrap();
19268 assert_eq!(d.disk_state(), DiskState::Changed);
19269
19270 // Saving makes the file ours and re-stamps the watermark.
19271 d.insert("ours\n");
19272 d.save();
19273 assert_eq!(d.disk_state(), DiskState::Unchanged);
19274 assert_eq!(std::fs::read_to_string(&p).unwrap(), "ours\n");
19275 let _ = std::fs::remove_file(&p);
19276 }
19277
19278 #[test]
19279 fn open_or_create_still_opens_a_file_that_is_there() {
19280 let d = doc_with("open_or_create_existing", "body\n");
19281 let reopened = Doc::open_or_create(d.path.clone()).unwrap();
19282 assert_eq!(reopened.source, "body\n");
19283 assert_eq!(reopened.disk_state(), DiskState::Unchanged);
19284 }
19285
19286 #[test]
19287 fn a_missing_file_with_no_readable_extension_is_still_an_error() {
19288 // A mistyped flag or a stray argument must not become a buffer promising
19289 // to save somewhere — the same refusal `open` gives a real file.
19290 let mut p = std::env::temp_dir();
19291 p.push("leaf_test_new_bad_ext.wat");
19292 assert!(Doc::open_or_create(p).is_err());
19293 let mut none = std::env::temp_dir();
19294 none.push("leaf_test_new_no_ext");
19295 assert!(Doc::open_or_create(none).is_err());
19296 }
19297
19298 #[test]
19299 fn a_new_file_in_a_directory_that_doesnt_exist_opens_but_wont_save() {
19300 // Opening reads nothing, so there is nothing to fail on yet; the write is
19301 // where it fails, and it says so rather than claiming a save.
19302 let p = std::env::temp_dir().join("leaf_test_no_such_dir_c41/doc.md");
19303 let mut d = Doc::open_or_create(p).unwrap();
19304 d.insert("x");
19305 d.save();
19306 assert!(
19307 d.status.as_deref().unwrap().starts_with("save failed:"),
19308 "got {:?}",
19309 d.status
19310 );
19311 assert!(d.dirty, "it must not come away believing it saved");
19312 }
19313
19314 // ── save as ───────────────────────────────────────────────────────────────
19315
19316 /// A unique path in the temp dir that no fixture wrote — a Save As target.
19317 fn temp_path(name: &str) -> PathBuf {
19318 static SEQ: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
19319 let seq = SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
19320 let mut p = std::env::temp_dir();
19321 p.push(format!("leaf_test_target_{name}_{seq}.md"));
19322 let _ = std::fs::remove_file(&p);
19323 p
19324 }
19325
19326 #[test]
19327 fn save_as_moves_the_document_and_leaves_the_old_file_alone() {
19328 let mut d = doc_with("save_as_move", "original\n");
19329 let old = d.path.clone();
19330 let new = temp_path("save_as_move");
19331 d.insert("edited: ");
19332 d.save_as(new.clone());
19333
19334 assert_eq!(std::fs::read_to_string(&new).unwrap(), "edited: original\n");
19335 assert_eq!(
19336 std::fs::read_to_string(&old).unwrap(),
19337 "original\n",
19338 "Save As doesn't touch the file it came from"
19339 );
19340 assert_eq!(d.path, new, "the document moved");
19341 assert!(!d.dirty);
19342 assert_eq!(
19343 d.status.as_deref(),
19344 Some(&*format!("saved {}", d.file_name()))
19345 );
19346
19347 // Every later save follows it, which is the whole difference from a copy.
19348 d.caret = 0;
19349 d.insert("re-");
19350 d.save();
19351 assert_eq!(
19352 std::fs::read_to_string(&new).unwrap(),
19353 "re-edited: original\n"
19354 );
19355 assert_eq!(std::fs::read_to_string(&old).unwrap(), "original\n");
19356 let _ = std::fs::remove_file(&new);
19357 }
19358
19359 #[test]
19360 fn save_as_overwrites_an_existing_target() {
19361 // The picker already asked; asking again down here is the same question
19362 // twice, and the second one has no way to be answered.
19363 let new = temp_path("save_as_over");
19364 std::fs::write(&new, "theirs\n").unwrap();
19365 let mut d = doc_with("save_as_over", "ours\n");
19366 d.save_as(new.clone());
19367 assert_eq!(std::fs::read_to_string(&new).unwrap(), "ours\n");
19368 let _ = std::fs::remove_file(&new);
19369 }
19370
19371 #[test]
19372 fn a_save_as_that_fails_leaves_the_document_where_it_was() {
19373 let mut d = doc_with("save_as_fail", "body\n");
19374 let old = d.path.clone();
19375 d.insert("x");
19376 // A directory that doesn't exist: the write can't land.
19377 let bad = std::env::temp_dir().join("leaf_test_no_such_dir_9f2/doc.md");
19378 d.save_as(bad);
19379
19380 assert_eq!(
19381 d.path, old,
19382 "the document must not move to a file that isn't there"
19383 );
19384 assert!(d.dirty, "and must not believe it saved");
19385 assert!(
19386 d.status.as_deref().unwrap().starts_with("save failed:"),
19387 "the same failure a plain save reports, got {:?}",
19388 d.status
19389 );
19390 // The original is still the document's file, and still saveable.
19391 d.save();
19392 assert_eq!(std::fs::read_to_string(&old).unwrap(), "xbody\n");
19393 assert!(!d.dirty);
19394 }
19395
19396 #[test]
19397 fn save_as_renames_without_reparsing_the_format() {
19398 // `.dj` on the name doesn't make the buffer djot: it was parsed as
19399 // Markdown and still is, and saying otherwise would be a conversion the
19400 // user never asked for (and an undo history thrown away to do it).
19401 let mut d = doc_with("save_as_format", "**b**\n");
19402 let mut new = temp_path("save_as_format");
19403 new.set_extension("dj");
19404 d.save_as(new.clone());
19405 assert_eq!(d.format_name(), "markdown");
19406 let _ = std::fs::remove_file(&new);
19407 }
19408
19409 // ── external change / reload ──────────────────────────────────────────────
19410
19411 #[test]
19412 fn an_untouched_file_reports_unchanged() {
19413 let mut d = doc_with("disk_clean", "body\n");
19414 assert_eq!(d.disk_state(), DiskState::Unchanged);
19415 // Editing the buffer is not editing the file.
19416 d.insert("x");
19417 assert_eq!(d.disk_state(), DiskState::Unchanged);
19418 assert!(d.dirty);
19419 // Saving re-stamps the watermark rather than reporting our own bytes back.
19420 d.save();
19421 assert_eq!(d.disk_state(), DiskState::Unchanged);
19422 }
19423
19424 #[test]
19425 fn a_file_written_underneath_reports_changed() {
19426 let mut d = doc_with("disk_changed", "body\n");
19427 std::fs::write(&d.path, "someone else\n").unwrap();
19428 assert_eq!(d.disk_state(), DiskState::Changed);
19429 // Dirty *and* changed is the clobber: both halves are readable, and
19430 // leaf-core takes neither side.
19431 d.insert("x");
19432 assert!(d.dirty && d.disk_state() == DiskState::Changed);
19433 // Saving anyway is allowed — the frontend asked, or chose not to.
19434 d.save();
19435 assert_eq!(std::fs::read_to_string(&d.path).unwrap(), "xbody\n");
19436 assert_eq!(d.disk_state(), DiskState::Unchanged);
19437 }
19438
19439 #[test]
19440 fn a_file_rewritten_with_the_same_bytes_is_unchanged() {
19441 // The hash is what makes this honest: the file was written (a fresh
19442 // mtime), and nothing about the document is stale.
19443 let d = doc_with("disk_same_bytes", "body\n");
19444 std::fs::write(&d.path, "body\n").unwrap();
19445 assert_eq!(d.disk_state(), DiskState::Unchanged);
19446 }
19447
19448 #[test]
19449 fn a_deleted_file_reports_missing() {
19450 let mut d = doc_with("disk_missing", "body\n");
19451 std::fs::remove_file(&d.path).unwrap();
19452 assert_eq!(d.disk_state(), DiskState::Missing);
19453 // A save recreates it, and the document is whole again.
19454 d.save();
19455 assert_eq!(d.disk_state(), DiskState::Unchanged);
19456 assert_eq!(std::fs::read_to_string(&d.path).unwrap(), "body\n");
19457 }
19458
19459 #[test]
19460 fn reload_replaces_the_document_with_the_file() {
19461 for (view, tag) in VIEWS {
19462 let mut d = doc_in(view, &format!("reload_{tag}"), "one\n\ntwo\n");
19463 d.insert("edited ");
19464 assert!(d.dirty);
19465 std::fs::write(&d.path, "one\n\ntwo\n\nthree\n").unwrap();
19466 d.reload();
19467
19468 assert_eq!(d.source, "one\n\ntwo\n\nthree\n", "{tag}");
19469 assert!(!d.dirty, "{tag}: the file is what we have");
19470 assert_eq!(d.disk_state(), DiskState::Unchanged, "{tag}");
19471 assert_eq!(
19472 d.status.as_deref(),
19473 Some(&*format!("reloaded {}", d.file_name()))
19474 );
19475 // The reloaded tree is live, not the old parse.
19476 d.caret = d.source.find("three").unwrap();
19477 assert_eq!(d.breadcrumb(), "doc › para › str", "{tag}");
19478 }
19479 }
19480
19481 #[test]
19482 fn reload_clamps_the_caret_and_drops_the_selection() {
19483 let mut d = doc_with("reload_caret", "a long first line\n");
19484 d.caret = 12;
19485 d.anchor = Some(4);
19486 std::fs::write(&d.path, "short\n").unwrap();
19487 d.reload();
19488 assert_eq!(d.caret, d.source.len(), "clamped into the shorter file");
19489 assert_eq!(
19490 d.anchor, None,
19491 "a selection over bytes that changed is a lie"
19492 );
19493 assert!(d.selection().is_none());
19494
19495 // A caret the file still has room for stays put.
19496 let mut d = doc_with("reload_caret_keep", "one\n\ntwo\n");
19497 d.caret = 2;
19498 std::fs::write(&d.path, "one\n\ntwo\n\nthree\n").unwrap();
19499 d.reload();
19500 assert_eq!(d.caret, 2);
19501 }
19502
19503 /// A silent reload is something that happened *to* a reader — a formatter,
19504 /// a `git checkout` — so it has to be undoable like anything else that
19505 /// changes the document, and undoable as one step rather than as however
19506 /// many the file happens to differ by.
19507 #[test]
19508 fn reload_is_one_undo_step_and_keeps_the_history_under_it() {
19509 let mut d = doc_with("reload_undo", "body\n");
19510 d.insert("x");
19511 assert_eq!(d.source, "xbody\n");
19512 std::fs::write(&d.path, "replaced\n").unwrap();
19513 d.reload();
19514 assert_eq!(d.source, "replaced\n");
19515 assert!(!d.dirty, "a reload lands clean");
19516
19517 // One ^Z takes the whole swap off, and hands back the unsaved work it
19518 // replaced — which is unsaved again, because the file no longer says it.
19519 d.undo();
19520 assert_eq!(d.source, "xbody\n", "the reload comes off in one step");
19521 assert!(d.dirty, "and what it comes back to is unsaved");
19522 // …and the history under it is still there.
19523 d.undo();
19524 assert_eq!(
19525 d.source, "body\n",
19526 "the typing before the reload undoes too"
19527 );
19528 // Redo walks back up through the reload.
19529 d.redo();
19530 d.redo();
19531 assert_eq!(d.source, "replaced\n");
19532 }
19533
19534 /// A file rewritten with the bytes it already had is not an edit, so it
19535 /// must not leave an undo step behind for something nobody did.
19536 #[test]
19537 fn reloading_identical_bytes_pushes_no_undo_step() {
19538 let mut d = doc_with("reload_same", "body\n");
19539 d.insert("x");
19540 std::fs::write(&d.path, "xbody\n").unwrap();
19541 d.reload();
19542 assert_eq!(d.source, "xbody\n");
19543 assert!(!d.dirty, "the file now says what the buffer does");
19544 d.undo();
19545 assert_eq!(
19546 d.source, "body\n",
19547 "one step back is the typing, not a no-op"
19548 );
19549 }
19550
19551 #[test]
19552 fn a_reload_that_cant_read_leaves_the_document_alone() {
19553 let mut d = doc_with("reload_gone", "body\n");
19554 d.insert("x");
19555 std::fs::remove_file(&d.path).unwrap();
19556 d.reload();
19557 assert_eq!(d.source, "xbody\n", "the unsaved work is still here");
19558 assert!(d.dirty);
19559 assert!(
19560 d.status.as_deref().unwrap().starts_with("reload failed:"),
19561 "{:?}",
19562 d.status
19563 );
19564
19565 // And an untitled document has nothing to reload from.
19566 let mut d = Doc::blank().unwrap();
19567 d.insert("typed");
19568 d.reload();
19569 assert_eq!(d.source, "typed");
19570 assert_eq!(d.status.as_deref(), Some("no file to reload"));
19571 }
19572
19573 #[test]
19574 fn a_read_only_document_refuses_every_door() {
19575 let mut d = doc_with("readonly", "one two three\n");
19576 d.insert("x");
19577 assert!(d.dirty, "writable first, so the undo step exists");
19578 d.set_read_only(true);
19579 let before = d.source.clone();
19580 d.insert("y");
19581 d.backspace();
19582 d.undo();
19583 d.redo();
19584 assert_eq!(d.source, before, "no door moved a byte");
19585 d.set_read_only(false);
19586 d.undo();
19587 assert_ne!(d.source, before, "off again, the same doors work");
19588 }
19589
19590 /// The doors that go to twig's own verbs rather than through the splice.
19591 /// Typed text in the rendered view under the default markup mode is the
19592 /// everyday one — it is what a keystroke in leaf-web or the Apple views
19593 /// becomes — and it walked straight past the gate.
19594 #[test]
19595 fn a_read_only_document_refuses_the_doors_around_the_splice() {
19596 let mut d = wysiwyg_doc(
19597 "readonly-doors",
19598 "one two three\n\n| a | b |\n|---|---|\n| c | d |\n",
19599 );
19600 d.set_markup_mode(MarkupMode::None);
19601 d.set_read_only(true);
19602 let before = d.source.clone();
19603 d.place_caret(3, false);
19604 d.insert("y");
19605 d.insert_link("https://example.com");
19606 d.insert_image("a.png", "alt");
19607 d.insert_thematic_break();
19608 d.insert_footnote();
19609 d.place_caret(0, false);
19610 d.place_caret(3, true);
19611 d.toggle(InlineKind::Strong);
19612 d.toggle_heading(2);
19613 d.set_block(BlockKind::Paragraph);
19614 d.toggle_list(false);
19615 d.toggle_blockquote();
19616 d.toggle_task_item();
19617 d.newline();
19618 d.indent();
19619 d.set_code_language("rust");
19620 let in_cell = d.source.find("| c").unwrap() + 2;
19621 d.place_caret(in_cell, false);
19622 assert!(d.caret_in_table(), "the caret is in the grid");
19623 assert!(!d.cell_line_break(), "the cell break reports the refusal");
19624 assert_eq!(d.source, before, "no door moved a byte");
19625 assert!(!d.dirty, "nothing to save");
19626 d.set_read_only(false);
19627 d.place_caret(3, false);
19628 d.insert("y");
19629 assert_ne!(d.source, before, "off again, the same doors work");
19630 }
19631
19632 #[test]
19633 fn a_selection_quote_carries_its_context_on_char_boundaries() {
19634 let mut d = doc_with("quote", "before 你好 exact 世界 after\n");
19635 let start = d.source.find("exact").unwrap();
19636 d.place_caret(start, false);
19637 d.place_caret(start + "exact".len(), true);
19638 let q = d.selection_quote(3).unwrap();
19639 assert_eq!(q.exact, "exact");
19640 assert_eq!(
19641 q.prefix, "你好 ",
19642 "chars, not bytes — the multibyte pair counts as two"
19643 );
19644 assert_eq!(q.suffix, " 世界");
19645 assert_eq!(&d.source[q.start..q.end], "exact");
19646 // At the edges the context clips rather than erring.
19647 d.place_caret(0, false);
19648 d.place_caret(6, true);
19649 let q = d.selection_quote(40).unwrap();
19650 assert_eq!(q.prefix, "");
19651 assert_eq!(q.exact, "before");
19652 // No selection is no quote.
19653 d.place_caret(0, false);
19654 assert!(d.selection_quote(3).is_none());
19655 }
19656
19657 #[test]
19658 fn highlights_are_kept_sorted_and_answer_point_queries() {
19659 let mut d = doc_with("hl", "one two three\n");
19660 d.set_highlights(vec![
19661 Highlight {
19662 start: 8,
19663 end: 13,
19664 id: "b".into(),
19665 color: None,
19666 marker: None,
19667 },
19668 Highlight {
19669 start: 0,
19670 end: 3,
19671 id: "a".into(),
19672 color: Some("#ffe066".into()),
19673 marker: None,
19674 },
19675 Highlight {
19676 start: 5,
19677 end: 5,
19678 id: "empty".into(),
19679 color: None,
19680 marker: None,
19681 },
19682 ]);
19683 assert_eq!(
19684 d.highlights()
19685 .iter()
19686 .map(|h| h.id.as_str())
19687 .collect::<Vec<_>>(),
19688 ["a", "b"],
19689 "sorted by start, the empty range dropped"
19690 );
19691 assert_eq!(d.highlight_at(1).map(|h| h.id.as_str()), Some("a"));
19692 assert_eq!(d.highlight_at(3), None, "end is exclusive");
19693 assert_eq!(d.highlight_at(8).map(|h| h.id.as_str()), Some("b"));
19694 d.set_highlights(Vec::new());
19695 assert!(d.highlights().is_empty(), "a replace is a replace");
19696 }
19697
19698 /// `Highlight::covering` and the cursor over it are what both painters ask
19699 /// per glyph, so they have to answer the same as the scan they replaced —
19700 /// including in the gaps, which is where most glyphs are.
19701 #[test]
19702 fn covering_answers_from_a_sorted_list_without_scanning_all_of_it() {
19703 let hl = |start: usize, end: usize, id: &str| Highlight {
19704 start,
19705 end,
19706 id: id.into(),
19707 color: None,
19708 marker: None,
19709 };
19710 // Disjoint, as search hits are: in a range, in a gap, and past the end.
19711 let hits: Vec<Highlight> = (0..20).map(|i| hl(i * 10, i * 10 + 3, "hit")).collect();
19712 assert_eq!(Highlight::covering(&hits, 0).map(|h| h.start), Some(0));
19713 assert_eq!(Highlight::covering(&hits, 102).map(|h| h.start), Some(100));
19714 assert_eq!(
19715 Highlight::covering(&hits, 105),
19716 None,
19717 "a gap covers nothing"
19718 );
19719 assert_eq!(Highlight::covering(&hits, 103), None, "end is exclusive");
19720 assert_eq!(Highlight::covering(&hits, 9_999), None);
19721 assert_eq!(Highlight::covering(&[], 0), None);
19722
19723 // Nested: first by start, so a hit inside an annotation still resolves
19724 // to the annotation — and the range that stops short doesn't mask it.
19725 let nested = vec![hl(0, 20, "outer"), hl(5, 10, "inner")];
19726 assert_eq!(
19727 Highlight::covering(&nested, 7).map(|h| h.id.as_str()),
19728 Some("outer")
19729 );
19730 assert_eq!(
19731 Highlight::covering(&nested, 15).map(|h| h.id.as_str()),
19732 Some("outer")
19733 );
19734 }
19735
19736 /// The cursor is an optimisation, so the only thing worth asserting is that
19737 /// it is not also a change of answer — at every offset, over a list with a
19738 /// nest in it, walked forwards and then backwards.
19739 #[test]
19740 fn the_highlight_cursor_answers_exactly_what_a_fresh_scan_would() {
19741 let hl = |start: usize, end: usize, id: &str| Highlight {
19742 start,
19743 end,
19744 id: id.into(),
19745 color: None,
19746 marker: None,
19747 };
19748 let mut list = vec![
19749 hl(0, 20, "outer"),
19750 hl(5, 10, "inner"),
19751 hl(30, 33, "hit"),
19752 hl(40, 43, "hit"),
19753 ];
19754 list.sort_by_key(|h| (h.start, h.end));
19755
19756 let mut cursor = HighlightCursor::new(&list);
19757 for offset in 0..50 {
19758 assert_eq!(
19759 cursor.at(offset).map(|h| h.id.as_str()),
19760 Highlight::covering(&list, offset).map(|h| h.id.as_str()),
19761 "cursor disagrees at {offset}"
19762 );
19763 }
19764 // Backwards: the cursor re-seats rather than answering from where it
19765 // had got to, so a painter that revisits a row is still told the truth.
19766 for offset in (0..50).rev() {
19767 assert_eq!(
19768 cursor.at(offset).map(|h| h.id.as_str()),
19769 Highlight::covering(&list, offset).map(|h| h.id.as_str()),
19770 "cursor disagrees walking back at {offset}"
19771 );
19772 }
19773 }
19774
19775 // ── the presentation vocabulary ─────────────────────────────────────────
19776
19777 /// A document in `format`, for the gesture tests that want more than the
19778 /// Markdown `doc_with` writes.
19779 fn fmt_doc(body: &str, format: Format) -> Doc {
19780 Doc::from_source(body.to_string(), format).unwrap()
19781 }
19782
19783 /// Alignment is a block property, so the gesture is `set_block_attrs` on
19784 /// the caret's block whatever is selected — and each format spells it its
19785 /// own way: djot's `{…}` line above the block, a `<div>` around it in
19786 /// Markdown (the format has nowhere else to put it), the tag in HTML.
19787 #[test]
19788 fn set_alignment_spells_the_class_the_format_s_own_way() {
19789 let mut dj = fmt_doc("hello\n", Format::Djot);
19790 dj.caret = 1;
19791 dj.set_alignment(Some(Align::Center));
19792 assert_eq!(dj.source, "{.center}\nhello\n");
19793 assert!(dj.dirty);
19794 assert_eq!(dj.status, None);
19795
19796 let mut md = fmt_doc("hello\n", Format::Markdown);
19797 md.caret = 1;
19798 md.set_alignment(Some(Align::Right));
19799 assert_eq!(md.source, "<div class=\"right\">\n\nhello\n\n</div>\n");
19800
19801 let mut html = fmt_doc("<p>hello</p>\n", Format::Html);
19802 html.caret = html.source.find("hello").unwrap();
19803 html.set_alignment(Some(Align::Justify));
19804 assert_eq!(html.source, "<p class=\"justify\">hello</p>\n");
19805 }
19806
19807 /// Each gesture edits **one key and keeps the rest** — twig's contract is
19808 /// replace-not-merge, so leaf reads the node's attributes, edits its own
19809 /// key out of them, and passes the list back whole. A document from
19810 /// elsewhere passes through the editor unharmed.
19811 #[test]
19812 fn a_presentation_gesture_keeps_every_attribute_it_did_not_write() {
19813 let mut d = fmt_doc(
19814 "{.lead .center #intro data-line-height=\"1.5\"}\nhello\n",
19815 Format::Djot,
19816 );
19817 d.caret = d.source.find("hello").unwrap();
19818 d.set_alignment(Some(Align::Right));
19819 // `center` goes, `lead` stays, and neither the id nor the spacing is
19820 // touched.
19821 // The serializer picks the order; what matters is which keys survive.
19822 assert!(d.source.contains(".lead"), "{:?}", d.source);
19823 assert!(d.source.contains(".right"), "{:?}", d.source);
19824 assert!(!d.source.contains(".center"), "{:?}", d.source);
19825 assert!(d.source.contains("#intro"), "{:?}", d.source);
19826 assert!(
19827 d.source.contains("data-line-height=\"1.5\""),
19828 "{:?}",
19829 d.source
19830 );
19831 assert_eq!(d.alignment_at_caret(), Some(Align::Right));
19832 assert_eq!(
19833 d.line_spacing_at_caret(),
19834 Some(LineHeight::Step(LineSpacing::OneHalf))
19835 );
19836
19837 // And the other way round: the spacing gesture leaves the classes be.
19838 d.set_line_spacing(Some(LineHeight::Step(LineSpacing::Double)));
19839 assert!(d.source.contains(".lead"), "{:?}", d.source);
19840 assert!(d.source.contains(".right"), "{:?}", d.source);
19841 assert_eq!(
19842 d.line_spacing_at_caret(),
19843 Some(LineHeight::Step(LineSpacing::Double))
19844 );
19845 }
19846
19847 /// Clearing is the same gesture with `None`: the key goes, the tokens leaf
19848 /// owns go out of `class`, and a block left with nothing at all is spelled
19849 /// bare again — in Markdown by unwrapping the div twig wrapped it in.
19850 #[test]
19851 fn none_clears_a_key_and_an_empty_set_unwraps_the_block() {
19852 let mut dj = fmt_doc("{.lead .center}\nhello\n", Format::Djot);
19853 dj.caret = dj.source.find("hello").unwrap();
19854 dj.set_alignment(None);
19855 assert_eq!(dj.source, "{.lead}\nhello\n", "the foreign class stays");
19856 assert_eq!(dj.alignment_at_caret(), None);
19857
19858 let mut bare = fmt_doc("{.center}\nhello\n", Format::Djot);
19859 bare.caret = bare.source.find("hello").unwrap();
19860 bare.set_alignment(None);
19861 assert_eq!(
19862 bare.source, "hello\n",
19863 "the last key takes the line with it"
19864 );
19865
19866 let mut md = fmt_doc("hello\n", Format::Markdown);
19867 md.caret = 1;
19868 md.set_alignment(Some(Align::Center));
19869 assert_eq!(md.source, "<div class=\"center\">\n\nhello\n\n</div>\n");
19870 md.caret = md.source.find("hello").unwrap();
19871 md.set_line_spacing(Some(LineHeight::Step(LineSpacing::OneFifteen)));
19872 assert_eq!(
19873 md.source, "<div class=\"center\" data-line-height=\"1.15\">\n\nhello\n\n</div>\n",
19874 "the second key rewrites the div rather than nesting a second"
19875 );
19876 md.caret = md.source.find("hello").unwrap();
19877 md.set_alignment(None);
19878 md.caret = md.source.find("hello").unwrap();
19879 md.set_line_spacing(None);
19880 assert_eq!(md.source, "hello\n", "an empty set unwraps the div");
19881 }
19882
19883 /// Size, face and colour are the run's over a selection and the block's
19884 /// with none — so "make this paragraph larger" is a click with the caret in
19885 /// it rather than a select-all first.
19886 #[test]
19887 fn a_run_gesture_wraps_a_selection_and_sets_the_block_without_one() {
19888 // With a selection: a span, in each format's own spelling.
19889 let mut dj = fmt_doc("a big b\n", Format::Djot);
19890 dj.anchor = Some(2);
19891 dj.caret = 5;
19892 dj.set_font_size(Some(FontSize::Step(SizeStep::Large)));
19893 assert_eq!(dj.source, "a [big]{data-size=\"large\"} b\n");
19894 assert_eq!(
19895 dj.font_size_at_caret(),
19896 Some(FontSize::Step(SizeStep::Large))
19897 );
19898
19899 let mut md = fmt_doc("a big b\n", Format::Markdown);
19900 md.anchor = Some(2);
19901 md.caret = 5;
19902 md.set_text_color(Some(TextColor::Named(MarkColor::Blue)));
19903 assert_eq!(md.source, "a <span data-color=\"blue\">big</span> b\n");
19904 assert_eq!(
19905 md.text_color_at_caret(),
19906 Some(TextColor::Named(MarkColor::Blue))
19907 );
19908
19909 // Without one: the caret's block, through the block gesture.
19910 let mut block = fmt_doc("a big b\n", Format::Djot);
19911 block.caret = 3;
19912 block.set_font_family(Some(FontFace::Generic(FontFamily::Monospace)));
19913 assert_eq!(block.source, "{data-font=\"monospace\"}\na big b\n");
19914 assert_eq!(
19915 block.font_family_at_caret(),
19916 Some(FontFace::Generic(FontFamily::Monospace))
19917 );
19918 }
19919
19920 /// The *Other…* row of each of the four menus: a value goes into the
19921 /// document in its canonical spelling and comes back out of the query as
19922 /// the same value. One round trip per property, because the four go out
19923 /// through different doors — two block gestures, and the run three through
19924 /// the span that `wrap_range_attrs` mints.
19925 #[test]
19926 fn an_exact_value_round_trips_through_the_gesture_and_the_query() {
19927 // Size: the run three, over a selection.
19928 let mut d = fmt_doc("a big b\n", Format::Djot);
19929 d.anchor = Some(2);
19930 d.caret = 5;
19931 d.set_font_size(FontSize::points(14.0));
19932 assert_eq!(d.source, "a [big]{data-size=\"14pt\"} b\n");
19933 assert_eq!(d.font_size_at_caret(), FontSize::points(14.0));
19934
19935 // Colour, onto the same span — the gesture keeps the size it finds.
19936 d.set_text_color(Some(TextColor::Rgb {
19937 r: 0xc0,
19938 g: 0x30,
19939 b: 0x30,
19940 }));
19941 assert_eq!(
19942 d.source,
19943 "a [big]{data-size=\"14pt\" data-color=\"#c03030\"} b\n"
19944 );
19945 assert_eq!(
19946 d.text_color_at_caret(),
19947 Some(TextColor::Rgb {
19948 r: 0xc0,
19949 g: 0x30,
19950 b: 0x30
19951 })
19952 );
19953
19954 // Face: a family name, as given.
19955 d.set_font_family(Some(FontFace::Named("Garamond".into())));
19956 assert!(
19957 d.source.contains("data-font=\"Garamond\""),
19958 "{:?}",
19959 d.source
19960 );
19961 assert_eq!(
19962 d.font_family_at_caret(),
19963 Some(FontFace::Named("Garamond".into()))
19964 );
19965
19966 // Line spacing: a block gesture, and an exact ratio.
19967 let mut block = fmt_doc("hello\n", Format::Djot);
19968 block.caret = 1;
19969 block.set_line_spacing(LineHeight::ratio(1.3));
19970 assert_eq!(block.source, "{data-line-height=\"1.3\"}\nhello\n");
19971 assert_eq!(block.line_spacing_at_caret(), LineHeight::ratio(1.3));
19972
19973 // And a value spelled long is written back short, so the same press
19974 // twice writes the same bytes: `14.0pt` in, `14pt` out.
19975 let mut long = fmt_doc("{data-size=\"14.0pt\"}\nhello\n", Format::Djot);
19976 long.caret = long.source.find("hello").unwrap();
19977 assert_eq!(long.font_size_at_caret(), FontSize::points(14.0));
19978 let in_force = long.font_size_at_caret();
19979 long.set_font_size(in_force);
19980 assert_eq!(long.source, "{data-size=\"14pt\"}\nhello\n");
19981 }
19982
19983 /// A value the grammar does not cover is what it was before the vocabulary
19984 /// opened: carried untouched by the document, answered `None` by the query
19985 /// so the menu ticks *Default*, and rewritten only by a gesture on its own
19986 /// key. leaf is not going to grow a CSS parser to guess at `1.3em`.
19987 #[test]
19988 fn a_value_outside_the_grammar_is_carried_and_the_menu_ticks_the_default() {
19989 let src =
19990 "{data-size=\"huge\" data-color=\"rgb(1,2,3)\" data-line-height=\"1.3em\"}\nhello\n";
19991 let mut d = fmt_doc(src, Format::Djot);
19992 d.caret = d.source.find("hello").unwrap();
19993 assert_eq!(d.font_size_at_caret(), None);
19994 assert_eq!(d.text_color_at_caret(), None);
19995 assert_eq!(d.line_spacing_at_caret(), None);
19996
19997 // The keys are still there, untouched, after a gesture on a *different*
19998 // key — "edit one key and keep the rest" holds for a value it cannot
19999 // read as readily as for one it can.
20000 d.set_alignment(Some(Align::Center));
20001 assert!(d.source.contains("data-size=\"huge\""), "{:?}", d.source);
20002 assert!(
20003 d.source.contains("data-color=\"rgb(1,2,3)\""),
20004 "{:?}",
20005 d.source
20006 );
20007 assert!(
20008 d.source.contains("data-line-height=\"1.3em\""),
20009 "{:?}",
20010 d.source
20011 );
20012 // And the gesture on its *own* key replaces it, which is the one way a
20013 // carried value ever changes.
20014 d.caret = d.source.find("hello").unwrap();
20015 d.set_font_size(FontSize::points(12.0));
20016 assert!(d.source.contains("data-size=\"12pt\""), "{:?}", d.source);
20017 assert!(!d.source.contains("huge"), "{:?}", d.source);
20018 }
20019
20020 /// The nearest node wins whichever *form* either node wrote: a value inside
20021 /// a name, a name inside a value. The fold has one rule and does not learn
20022 /// a second one for exact values.
20023 #[test]
20024 fn the_nearest_node_wins_whether_it_named_a_size_or_measured_one() {
20025 // A value inside a name: the block says `small`, the span says `14pt`.
20026 let mut d = fmt_doc(
20027 "{data-size=\"small\"}\nx [y]{data-size=\"14pt\"} z\n",
20028 Format::Djot,
20029 );
20030 d.caret = d.source.find('y').unwrap();
20031 assert_eq!(d.font_size_at_caret(), FontSize::points(14.0));
20032 d.caret = d.source.find('x').unwrap();
20033 assert_eq!(
20034 d.font_size_at_caret(),
20035 Some(FontSize::Step(SizeStep::Small))
20036 );
20037
20038 // And a name inside a value, which is the same rule read the other way.
20039 let mut e = fmt_doc(
20040 "{data-size=\"14pt\" data-color=\"#c03030\"}\nx [y]{data-size=\"small\"} z\n",
20041 Format::Djot,
20042 );
20043 e.caret = e.source.find('y').unwrap();
20044 assert_eq!(
20045 e.font_size_at_caret(),
20046 Some(FontSize::Step(SizeStep::Small))
20047 );
20048 assert_eq!(
20049 e.text_color_at_caret(),
20050 Some(TextColor::Rgb {
20051 r: 0xc0,
20052 g: 0x30,
20053 b: 0x30
20054 }),
20055 "the block's colour still reaches the span"
20056 );
20057 e.caret = e.source.find('x').unwrap();
20058 assert_eq!(e.font_size_at_caret(), FontSize::points(14.0));
20059 }
20060
20061 /// twig re-styles the span a range already lies in rather than nesting a
20062 /// second, and an empty set unwraps it — so a second press of the menu
20063 /// fixes the size instead of building `[[big]{.a}]{.b}`, and the entry that
20064 /// means "the theme's own" takes the span away.
20065 #[test]
20066 fn a_second_run_gesture_re_styles_the_span_and_none_unwraps_it() {
20067 let mut d = fmt_doc("a big b\n", Format::Djot);
20068 d.anchor = Some(2);
20069 d.caret = 5;
20070 d.set_font_size(Some(FontSize::Step(SizeStep::Large)));
20071 assert_eq!(d.source, "a [big]{data-size=\"large\"} b\n");
20072
20073 // The selection `wrap_range_attrs` left behind covers the whole span;
20074 // colouring it now keeps the size, because the gesture reads the span's
20075 // attributes before it edits its own key.
20076 d.set_text_color(Some(TextColor::Named(MarkColor::Red)));
20077 assert_eq!(
20078 d.source, "a [big]{data-size=\"large\" data-color=\"red\"} b\n",
20079 "one span, both keys"
20080 );
20081 assert_eq!(
20082 d.font_size_at_caret(),
20083 Some(FontSize::Step(SizeStep::Large))
20084 );
20085 assert_eq!(
20086 d.text_color_at_caret(),
20087 Some(TextColor::Named(MarkColor::Red))
20088 );
20089
20090 d.set_text_color(None);
20091 assert_eq!(d.source, "a [big]{data-size=\"large\"} b\n");
20092 d.set_font_size(None);
20093 assert_eq!(d.source, "a big b\n", "the last key unwraps the span");
20094 assert_eq!(d.font_size_at_caret(), None);
20095 }
20096
20097 /// The queries read the nearest node that names the property: the span the
20098 /// caret is in, then its block, then the `div`s around it.
20099 #[test]
20100 fn a_presentation_query_reads_the_nearest_node_that_names_it() {
20101 let mut d = fmt_doc(
20102 "{.center data-size=\"small\" data-font=\"serif\"}\nx [y]{data-size=\"xx-large\"} z\n",
20103 Format::Djot,
20104 );
20105 // In the span: its own size, the block's face and alignment.
20106 d.caret = d.source.find('y').unwrap();
20107 assert_eq!(
20108 d.font_size_at_caret(),
20109 Some(FontSize::Step(SizeStep::XxLarge))
20110 );
20111 assert_eq!(
20112 d.font_family_at_caret(),
20113 Some(FontFace::Generic(FontFamily::Serif))
20114 );
20115 assert_eq!(d.alignment_at_caret(), Some(Align::Center));
20116 assert_eq!(d.line_spacing_at_caret(), None);
20117 assert_eq!(d.text_color_at_caret(), None);
20118
20119 // Outside it: the block's size.
20120 d.caret = d.source.find('x').unwrap();
20121 assert_eq!(
20122 d.font_size_at_caret(),
20123 Some(FontSize::Step(SizeStep::Small))
20124 );
20125
20126 // And through a Markdown div, which is where a Markdown block's
20127 // attributes live.
20128 let mut md = fmt_doc(
20129 "<div class=\"center\" data-size=\"large\">\n\nhello\n\n</div>\n",
20130 Format::Markdown,
20131 );
20132 md.caret = md.source.find("hello").unwrap();
20133 assert_eq!(md.alignment_at_caret(), Some(Align::Center));
20134 assert_eq!(
20135 md.font_size_at_caret(),
20136 Some(FontSize::Step(SizeStep::Large))
20137 );
20138
20139 // A document that names none of it answers `None` everywhere, which is
20140 // "the theme's own" and what every toolbar draws unlit.
20141 let mut plain = doc_with("plain_presentation", "hello\n");
20142 plain.caret = 1;
20143 assert_eq!(plain.alignment_at_caret(), None);
20144 assert_eq!(plain.line_spacing_at_caret(), None);
20145 assert_eq!(plain.font_size_at_caret(), None);
20146 assert_eq!(plain.font_family_at_caret(), None);
20147 assert_eq!(plain.text_color_at_caret(), None);
20148 }
20149
20150 /// A djot fenced div is anonymous the way an attributed span is, and is a
20151 /// block all the same — the *form* is the whole of what tells them apart.
20152 /// Read as a span it poisoned both halves: the run gesture copied the div's
20153 /// entire attribute set onto the span it minted, duplicating the `id`, and
20154 /// the run and block queries answered off a node the walker draws nothing
20155 /// for.
20156 #[test]
20157 fn a_djot_fenced_div_is_not_an_attributed_span() {
20158 let src = "{.center data-size=\"small\" #box}\n:::\nhello world\n:::\n";
20159 let mut d = fmt_doc(src, Format::Djot);
20160 let at = d.source.find("world").unwrap();
20161 d.anchor = Some(at);
20162 d.caret = at + "world".len();
20163 d.set_text_color(Some(TextColor::Named(MarkColor::Red)));
20164 assert_eq!(
20165 d.source,
20166 "{.center data-size=\"small\" #box}\n:::\nhello [world]{data-color=\"red\"}\n:::\n",
20167 "the span carries its own key and nothing of the div's"
20168 );
20169
20170 // And the queries stop at the block: a djot div is not a `<div>`, the
20171 // walker lends its keys to nothing inside it, and a query that said
20172 // otherwise would tick a menu entry no glyph on screen obeys.
20173 assert_eq!(
20174 d.text_color_at_caret(),
20175 Some(TextColor::Named(MarkColor::Red))
20176 );
20177 assert_eq!(d.font_size_at_caret(), None);
20178 assert_eq!(d.alignment_at_caret(), None);
20179 }
20180
20181 /// Clearing a property the block does not name and a `div` around it does
20182 /// would write nothing and change nothing — twig's `set_block_attrs`
20183 /// reaches one node, and the div is not it. The gesture says so instead of
20184 /// leaving the author pressing an entry that never ticks.
20185 #[test]
20186 fn clearing_a_property_an_enclosing_div_names_says_so_and_writes_nothing() {
20187 // Markdown, two paragraphs in one div: not the sole-child shape twig
20188 // writes, so `block_attrs_at_caret` reads the paragraph and the
20189 // paragraph names none of it.
20190 let src = "<div class=\"center\" data-line-height=\"1.5\" data-size=\"large\">\n\nhello\n\nworld\n\n</div>\n";
20191 let mut md = fmt_doc(src, Format::Markdown);
20192 md.caret = md.source.find("hello").unwrap();
20193 assert_eq!(md.alignment_at_caret(), Some(Align::Center));
20194
20195 md.set_alignment(None);
20196 assert_eq!(md.source, src, "nothing written");
20197 assert!(!md.dirty);
20198 assert_eq!(
20199 md.status.as_deref(),
20200 Some("alignment: set on the div around the block")
20201 );
20202 assert_eq!(md.alignment_at_caret(), Some(Align::Center));
20203
20204 // The same for a `data-` key, at both levels — the block pair and the
20205 // run three, the run three at a bare caret being the block gesture.
20206 md.set_line_spacing(None);
20207 assert_eq!(md.source, src);
20208 assert_eq!(
20209 md.status.as_deref(),
20210 Some("line spacing: set on the div around the block")
20211 );
20212 md.set_font_size(None);
20213 assert_eq!(md.source, src);
20214 assert_eq!(
20215 md.status.as_deref(),
20216 Some("size: set on the div around the block")
20217 );
20218
20219 // HTML has no sole-child fold at all: a block's attributes go on the
20220 // block, so the div around one is always out of reach.
20221 let html_src = "<div class=\"center\"><p>hi</p></div>\n";
20222 let mut html = fmt_doc(html_src, Format::Html);
20223 html.caret = html.source.find("hi").unwrap();
20224 assert_eq!(html.alignment_at_caret(), Some(Align::Center));
20225 html.set_alignment(None);
20226 assert_eq!(html.source, html_src);
20227 assert!(!html.dirty);
20228 assert_eq!(
20229 html.status.as_deref(),
20230 Some("alignment: set on the div around the block")
20231 );
20232
20233 // And it is a refusal, not a rule against clearing: a block that names
20234 // the property itself still loses it, div or no div.
20235 let mut own = fmt_doc(
20236 "<div class=\"center\"><p class=\"right\">hi</p></div>\n",
20237 Format::Html,
20238 );
20239 own.caret = own.source.find("hi").unwrap();
20240 own.set_alignment(None);
20241 assert_eq!(own.source, "<div class=\"center\"><p>hi</p></div>\n");
20242 assert_eq!(own.status, None);
20243 }
20244
20245 /// An edited key is rewritten **where it stands**. The proposal's worked
20246 /// example is the test: a paragraph that came in as `id="intro"
20247 /// class="lead center" data-line-height="1.5"` and is right-aligned goes
20248 /// out as the same list with one token changed. Removing the key and
20249 /// pushing it back shuffled a document's attributes on every press.
20250 #[test]
20251 fn an_edited_key_keeps_its_place_among_the_attributes() {
20252 let mut html = fmt_doc(
20253 "<p id=\"intro\" class=\"lead center\" data-line-height=\"1.5\">hello</p>\n",
20254 Format::Html,
20255 );
20256 html.caret = html.source.find("hello").unwrap();
20257 html.set_alignment(Some(Align::Right));
20258 assert_eq!(
20259 html.source,
20260 "<p id=\"intro\" class=\"lead right\" data-line-height=\"1.5\">hello</p>\n"
20261 );
20262
20263 // A `data-` key the same way, and a key the block did not have still
20264 // goes on the end.
20265 html.caret = html.source.find("hello").unwrap();
20266 html.set_line_spacing(Some(LineHeight::Step(LineSpacing::Double)));
20267 assert_eq!(
20268 html.source,
20269 "<p id=\"intro\" class=\"lead right\" data-line-height=\"2\">hello</p>\n"
20270 );
20271 html.caret = html.source.find("hello").unwrap();
20272 html.set_font_size(Some(FontSize::Step(SizeStep::Large)));
20273 assert_eq!(
20274 html.source,
20275 "<p id=\"intro\" class=\"lead right\" data-line-height=\"2\" data-size=\"large\">hello</p>\n"
20276 );
20277
20278 // Djot writes the same list in its own spelling, and the order is the
20279 // author's there too.
20280 let mut dj = fmt_doc(
20281 "{#intro .lead .center data-line-height=\"1.5\"}\nhello\n",
20282 Format::Djot,
20283 );
20284 dj.caret = dj.source.find("hello").unwrap();
20285 dj.set_alignment(Some(Align::Right));
20286 assert_eq!(
20287 dj.source,
20288 "{#intro .lead .right data-line-height=\"1.5\"}\nhello\n"
20289 );
20290 }
20291
20292 /// A page break is a block, so twig alone lands one after the caret's whole
20293 /// block; the paragraph is parted at the caret first, exactly as
20294 /// `insert_thematic_break` parts it, and each format spells the directive
20295 /// its own way.
20296 #[test]
20297 fn insert_page_break_parts_the_paragraph_and_spells_the_directive() {
20298 let mut md = doc_with("page_break_md", "hello world\n");
20299 md.caret = 5;
20300 md.insert_page_break();
20301 assert_eq!(md.source, "hello\n\n::page-break\n\nworld\n");
20302 assert!(md.dirty);
20303 assert_eq!(md.status, None);
20304
20305 let mut dj = fmt_doc("hello world\n", Format::Djot);
20306 dj.caret = 5;
20307 dj.insert_page_break();
20308 assert_eq!(dj.source, "hello\n\n::: page-break\n:::\n\nworld\n");
20309
20310 // At a block's end there is no second half to mint, so the break simply
20311 // follows the block — the rule the rule button already has.
20312 let mut end = doc_with("page_break_end", "hello\n");
20313 end.caret = 5;
20314 end.insert_page_break();
20315 assert_eq!(end.source, "hello\n\n::page-break\n\n");
20316 assert_eq!(end.caret, end.source.len(), "on the line under the break");
20317
20318 // And it reaches the map as the placeholder row a frontend paginates on.
20319 end.view = View::Wysiwyg;
20320 end.build_visual(80);
20321 assert_eq!(
20322 end.vmap
20323 .rows
20324 .iter()
20325 .find_map(|r| r.leaf_directive.as_ref())
20326 .map(|m| m.name.as_str()),
20327 Some(PAGE_BREAK)
20328 );
20329
20330 // HTML and AsciiDoc spell it their own way, and draw it the same.
20331 for (fmt, src, spelled) in [
20332 (Format::Html, "<p>hello</p>\n", "<page-break></page-break>"),
20333 (Format::Asciidoc, "hello\n", "<<<"),
20334 ] {
20335 let mut d = fmt_doc(src, fmt);
20336 d.caret = d.source.find("hello").unwrap() + 5;
20337 d.insert_page_break();
20338 assert!(d.source.contains(spelled), "{fmt:?}: {:?}", d.source);
20339 d.view = View::Wysiwyg;
20340 d.build_visual(80);
20341 let marks = d
20342 .vmap
20343 .rows
20344 .iter()
20345 .filter_map(|r| r.leaf_directive.as_ref())
20346 .map(|m| m.name.as_str())
20347 .collect::<Vec<_>>();
20348 assert_eq!(marks, [PAGE_BREAK], "{fmt:?}");
20349 }
20350 }
20351
20352 /// The general gesture a host's catalogue calls: a directive named by the
20353 /// host, with a label and attributes, written where the caret parts the
20354 /// paragraph, and read back into the map with all three — the way the
20355 /// view hands it to the host that draws it.
20356 #[test]
20357 fn insert_directive_round_trips_name_label_and_attrs() {
20358 let attrs: Attrs = vec![
20359 ("src".into(), Some("https://x.org/a".into())),
20360 ("title".into(), Some("a \"quoted\" b".into())),
20361 ("wide".into(), None),
20362 ];
20363 let mut md = doc_with("directive_md", "hello world\n");
20364 md.caret = 5;
20365 md.insert_directive("x-card", Some("A card"), &attrs);
20366 assert_eq!(md.status, None);
20367 assert_eq!(
20368 md.source,
20369 "hello\n\n::x-card[A card]{src=\"https://x.org/a\" title=\"a \\\"quoted\\\" b\" wide}\n\nworld\n"
20370 );
20371 assert!(md.dirty);
20372 md.view = View::Wysiwyg;
20373 md.build_visual(80);
20374 let [d] = md.vmap.directives.as_slice() else {
20375 panic!("one directive, got {:?}", md.vmap.directives)
20376 };
20377 assert_eq!(d.name, "x-card");
20378 assert_eq!(d.label, "A card");
20379 // A bare attribute reads back with an empty value: that is what twig
20380 // reports for Markdown's `{wide}`.
20381 assert_eq!(
20382 d.key().attrs,
20383 [
20384 ("src".to_string(), "https://x.org/a".to_string()),
20385 ("title".to_string(), "a \"quoted\" b".to_string()),
20386 ("wide".to_string(), String::new()),
20387 ]
20388 );
20389 assert_eq!(d.attr("src"), Some("https://x.org/a"));
20390
20391 // djot: the attributes on a line of their own over the fence, a bare
20392 // one spelled `key=""` so that djot reads it as an attribute at all,
20393 // and a class kept apart from the name the fence gives.
20394 let mut dj = fmt_doc("hello world\n", Format::Djot);
20395 dj.caret = 5;
20396 let dj_attrs: Attrs = vec![
20397 ("class".into(), Some("wide".into())),
20398 ("src".into(), Some("https://x.org/a".into())),
20399 ("flag".into(), None),
20400 ];
20401 dj.insert_directive("x-card", None, &dj_attrs);
20402 assert_eq!(dj.status, None);
20403 assert_eq!(
20404 dj.source,
20405 "hello\n\n{.wide src=\"https://x.org/a\" flag=\"\"}\n::: x-card\n:::\n\nworld\n"
20406 );
20407 dj.view = View::Wysiwyg;
20408 dj.build_visual(80);
20409 let [d] = dj.vmap.directives.as_slice() else {
20410 panic!("one directive, got {:?}", dj.vmap.directives)
20411 };
20412 assert_eq!(
20413 d.name, "x-card",
20414 "the fence's word, not the attribute line's class"
20415 );
20416 assert_eq!(d.label, "");
20417 assert_eq!(d.attr("class"), Some("wide"));
20418 assert_eq!(d.attr("src"), Some("https://x.org/a"));
20419 assert_eq!(d.attr("flag"), Some(""));
20420 }
20421
20422 /// `insert_page_break` is `insert_directive` with leaf's own name: the
20423 /// same bytes, the same caret, the same one undo step.
20424 #[test]
20425 fn insert_directive_places_the_caret_as_a_page_break_does() {
20426 for (fmt, body, caret) in [
20427 (Format::Markdown, "a\n\nb\n", 1),
20428 (Format::Markdown, "a\n", 1),
20429 (Format::Markdown, "hello world\n", 5),
20430 (Format::Djot, "a\n\nb\n", 1),
20431 (Format::Djot, "hello world\n", 5),
20432 ] {
20433 let run = |f: &dyn Fn(&mut Doc)| {
20434 let mut d = Doc::from_source(body.into(), fmt).unwrap();
20435 d.view = View::Wysiwyg;
20436 d.caret = caret;
20437 f(&mut d);
20438 (d.source.clone(), d.caret)
20439 };
20440 assert_eq!(
20441 run(&|d| d.insert_page_break()),
20442 run(&|d| d.insert_directive(PAGE_BREAK, None, &[])),
20443 "{fmt:?} {body:?}"
20444 );
20445 }
20446
20447 // And a host's name lands the caret on a line under it, typed into
20448 // there, and taken back in one step with that line.
20449 let mut d = Doc::from_source("a\n\nb\n".into(), Format::Markdown).unwrap();
20450 d.view = View::Wysiwyg;
20451 d.caret = 1;
20452 d.insert_directive("x-card", Some("A"), &[("k".into(), Some("v".into()))]);
20453 assert_eq!(d.source, "a\n\n::x-card[A]{k=v}\n\n\n\nb\n");
20454 d.build_visual(80);
20455 let (row, col) = d.vmap.pos_of_offset(d.caret);
20456 assert_eq!(col, 0);
20457 assert!(d.vmap.rows[row].glyphs.is_empty(), "an empty line");
20458 assert!(d.vmap.rows[row - 2].leaf_directive.is_some(), "under it");
20459 d.insert("X");
20460 assert_eq!(d.source, "a\n\n::x-card[A]{k=v}\n\nX\n\nb\n");
20461 d.undo();
20462 d.undo();
20463 assert_eq!(
20464 d.source, "a\n\nb\n",
20465 "the directive and its line are one step"
20466 );
20467 }
20468
20469 /// A name or a label twig will not write is refused before anything is
20470 /// touched — the selection it would have replaced and the paragraph it
20471 /// would have parted are both left as they were.
20472 #[test]
20473 fn insert_directive_refuses_a_bad_name_or_label_and_writes_nothing() {
20474 let src = "hello world\n";
20475 for (fmt, name, label) in [
20476 (Format::Markdown, "1x", None),
20477 (Format::Markdown, "a b", None),
20478 (Format::Markdown, "a:b", None),
20479 (Format::Markdown, "", None),
20480 (Format::Markdown, "ok", Some("a]b")),
20481 (Format::Markdown, "ok", Some("a\nb")),
20482 (Format::Djot, "a b", None),
20483 // djot has nowhere for a label to go.
20484 (Format::Djot, "ok", Some("A card")),
20485 ] {
20486 let mut d = fmt_doc(src, fmt);
20487 d.anchor = Some(2);
20488 d.caret = 5;
20489 d.insert_directive(name, label, &[]);
20490 assert_eq!(d.source, src, "{fmt:?} {name:?} {label:?}");
20491 assert!(!d.dirty, "{fmt:?} {name:?} {label:?}");
20492 assert!(!d.can_undo(), "{fmt:?} {name:?} {label:?}");
20493 let status = d.status.as_deref().unwrap_or("");
20494 assert!(
20495 status.starts_with("directive: "),
20496 "{fmt:?} {name:?} {label:?}: {status:?}"
20497 );
20498 }
20499 }
20500
20501 /// `directives` is claimed only where what is written comes back as a
20502 /// directive with its name. twig spells one in HTML and AsciiDoc too — and
20503 /// `page_break` is true there — but the walker reads their spellings of
20504 /// `page-break` alone, so an arbitrary name would be written and then
20505 /// drawn as something else, or not at all.
20506 #[test]
20507 fn directives_are_offered_only_where_the_walker_reads_them_back() {
20508 for (fmt, src) in [
20509 (Format::Markdown, "hello\n"),
20510 (Format::Djot, "hello\n"),
20511 (Format::Html, "<p>hello</p>\n"),
20512 (Format::Asciidoc, "hello\n"),
20513 ] {
20514 // What twig writes for a host's name, read back by the walker.
20515 let mut ed = twig::Editor::new_ext(src.as_bytes(), fmt, parse_extensions()).unwrap();
20516 ed.insert_directive(src.len() - 1, "x-card", None, &[("k", Some("v"))])
20517 .unwrap_or_else(|e| panic!("{fmt:?}: twig spells it: {e}"));
20518 let written = String::from_utf8(ed.source().unwrap()).unwrap();
20519 let mut d = fmt_doc(&written, fmt);
20520 d.view = View::Wysiwyg;
20521 d.build_visual(80);
20522 let read_back = d
20523 .vmap
20524 .directives
20525 .iter()
20526 .any(|m| m.name == "x-card" && m.attr("k") == Some("v"));
20527 let c = Capabilities::of(fmt);
20528 assert_eq!(c.directives, read_back, "{fmt:?}: {written:?}");
20529 assert!(c.page_break, "{fmt:?}");
20530
20531 // And where it is not offered, it is refused in the format's name.
20532 if !c.directives {
20533 let mut d = fmt_doc(src, fmt);
20534 d.caret = d.source.find("hello").unwrap() + 5;
20535 d.insert_directive("x-card", None, &[]);
20536 assert_eq!(d.source, src, "{fmt:?}");
20537 let status = d.status.as_deref().unwrap_or("");
20538 assert!(status.contains("not supported"), "{fmt:?}: {status:?}");
20539 }
20540 }
20541 assert!(!Capabilities::of(Format::Xml).directives);
20542 }
20543
20544 /// A terminal draws a host's directive as lines of its own and reports how
20545 /// many, keyed by what the directive says; the placeholder grows to that
20546 /// height the way a picture does, with blank fillers holding no caret.
20547 #[test]
20548 fn set_directive_rows_reserves_filler_rows_for_a_host_drawing() {
20549 let body = "intro\n\n::x-card[A]{src=\"u\"}\n\n::x-card[B]\n\nend\n";
20550 let mut d = wysiwyg_doc("directive_rows", body);
20551 assert_eq!(d.vmap.directives.len(), 2);
20552 assert!(d.vmap.directives.iter().all(|i| i.rows_span.len() == 1));
20553 let stops = |d: &mut Doc| {
20554 d.caret = 0;
20555 let mut seen = vec![d.caret];
20556 loop {
20557 d.move_right(false);
20558 if *seen.last().unwrap() == d.caret {
20559 break;
20560 }
20561 seen.push(d.caret);
20562 }
20563 seen
20564 };
20565 let before = stops(&mut d);
20566
20567 let key = d.vmap.directives[0].key();
20568 assert_eq!(
20569 key,
20570 wysiwyg::DirectiveKey {
20571 name: "x-card".into(),
20572 label: "A".into(),
20573 attrs: vec![("src".into(), "u".into())],
20574 }
20575 );
20576 d.set_directive_rows(HashMap::from([(key, 4)]));
20577 d.build_visual(80);
20578 let span = d.vmap.directives[0].rows_span.clone();
20579 assert_eq!(span.len(), 4, "the four rows asked for");
20580 assert_eq!(
20581 d.vmap.rows[span.start]
20582 .leaf_directive
20583 .as_ref()
20584 .map(|m| m.rows),
20585 Some(4)
20586 );
20587 for r in span.start + 1..span.end {
20588 let row = &d.vmap.rows[r];
20589 assert!(row.decoration && row.glyphs.is_empty(), "filler {r}");
20590 assert!(row.leaf_directive.is_none(), "only the first row is marked");
20591 }
20592 assert_eq!(
20593 d.vmap.directives[1].rows_span.len(),
20594 1,
20595 "another label is another key"
20596 );
20597 assert_eq!(stops(&mut d), before, "reserving rows adds no stops");
20598
20599 // Keyed by what it says, not where it stands: an edit above keeps it.
20600 d.caret = 0;
20601 d.insert("more ");
20602 d.build_visual(80);
20603 assert_eq!(d.vmap.directives[0].rows_span.len(), 4);
20604 }
20605
20606 /// The vocabulary's capabilities, per format. The two block properties are
20607 /// `SetBlockAttrs` and the three run ones `WrapRangeAttrs`, which is why
20608 /// AsciiDoc can align a paragraph and not size a run: its `[#id.role]#text#`
20609 /// keeps an id and a role and has no slot for a `data-` key.
20610 #[test]
20611 fn the_presentation_capabilities_are_ragged_per_format() {
20612 for fmt in [Format::Markdown, Format::Djot, Format::Html] {
20613 let c = Capabilities::of(fmt);
20614 assert!(c.alignment, "{fmt:?} alignment");
20615 assert!(c.line_spacing, "{fmt:?} line spacing");
20616 assert!(c.font_size, "{fmt:?} size");
20617 assert!(c.font_family, "{fmt:?} face");
20618 assert!(c.text_color, "{fmt:?} colour");
20619 }
20620 // Markdown spells both only under the extensions leaf parses with — a
20621 // `<div>` and a `<span>` read back as containers under `html_elements`,
20622 // and `::page-break` as a directive under `directives`. Ask twig's own
20623 // defaults and the answer is no, which is why `Capabilities` is built
20624 // with `supports_with`.
20625 assert!(!Format::Markdown.supports(Gesture::SetBlockAttrs));
20626 assert!(!Format::Markdown.supports(Gesture::WrapRangeAttrs));
20627 assert!(!Format::Markdown.supports(Gesture::InsertDirective));
20628
20629 let adoc = Capabilities::of(Format::Asciidoc);
20630 assert!(adoc.alignment && adoc.line_spacing, "AsciiDoc's `[…]` line");
20631 assert!(
20632 !adoc.font_size && !adoc.font_family && !adoc.text_color,
20633 "AsciiDoc has no inline spelling that keeps a data- key"
20634 );
20635
20636 // XML spells none of it, and neither page break — nor has it blocks a
20637 // caret could name for a move.
20638 let xml = Capabilities::of(Format::Xml);
20639 assert!(!xml.alignment && !xml.font_size && !xml.page_break && !xml.move_block);
20640 for f in [Format::Markdown, Format::Djot, Format::Html] {
20641 assert!(Capabilities::of(f).move_block, "{f:?} moves blocks");
20642 }
20643 for f in [
20644 Format::Markdown,
20645 Format::Djot,
20646 Format::Html,
20647 Format::Asciidoc,
20648 ] {
20649 assert!(Capabilities::of(f).page_break, "{f:?} breaks pages");
20650 }
20651 }
20652
20653 /// A format that cannot spell a property refuses in its own words and
20654 /// writes nothing — the guard every other gesture has.
20655 #[test]
20656 fn a_presentation_gesture_a_format_cannot_spell_is_refused_with_a_reason() {
20657 let src = "<doc><p>hello</p></doc>\n";
20658 #[allow(clippy::type_complexity)]
20659 let ops: [(&str, &dyn Fn(&mut Doc)); 6] = [
20660 ("alignment", &|d: &mut Doc| {
20661 d.set_alignment(Some(Align::Center))
20662 }),
20663 ("line spacing", &|d: &mut Doc| {
20664 d.set_line_spacing(Some(LineHeight::Step(LineSpacing::Double)))
20665 }),
20666 ("size", &|d: &mut Doc| {
20667 d.set_font_size(Some(FontSize::Step(SizeStep::Large)))
20668 }),
20669 ("face", &|d: &mut Doc| {
20670 d.set_font_family(Some(FontFace::Generic(FontFamily::Serif)))
20671 }),
20672 ("colour", &|d: &mut Doc| {
20673 d.set_text_color(Some(TextColor::Named(MarkColor::Red)))
20674 }),
20675 ("page break", &|d: &mut Doc| d.insert_page_break()),
20676 ];
20677 for (name, op) in ops {
20678 let mut d = fmt_doc(src, Format::Xml);
20679 let at = d.source.find("hello").unwrap();
20680 d.caret = at;
20681 d.anchor = Some(at + 5);
20682 op(&mut d);
20683 assert_eq!(d.source, src, "{name} edited an XML document");
20684 assert!(!d.dirty, "{name} marked the document dirty");
20685 let status = d.status.as_deref().unwrap_or("");
20686 assert!(
20687 status.contains("xml"),
20688 "{name}: the refusal should name the format, got {status:?}"
20689 );
20690 }
20691
20692 // AsciiDoc is the ragged one: the block gesture works where the run
20693 // gesture does not, and a *selection* is what tells the two apart.
20694 let mut adoc = fmt_doc("hello world\n", Format::Asciidoc);
20695 adoc.anchor = Some(0);
20696 adoc.caret = 5;
20697 adoc.set_font_size(Some(FontSize::Step(SizeStep::Large)));
20698 assert_eq!(adoc.source, "hello world\n", "no inline spelling");
20699 assert!(adoc.status.is_some());
20700 }
20701
20702 /// A read-only document takes none of it, and a caret on a blank line has
20703 /// no block to carry an attribute — both say so rather than writing.
20704 #[test]
20705 fn a_presentation_gesture_respects_read_only_and_a_blank_line() {
20706 let mut ro = fmt_doc("hello\n", Format::Djot);
20707 ro.read_only = true;
20708 ro.caret = 1;
20709 ro.set_alignment(Some(Align::Center));
20710 assert_eq!(ro.source, "hello\n");
20711
20712 let mut blank = fmt_doc("a\n\n\nb\n", Format::Djot);
20713 blank.caret = 2; // the empty line between the two paragraphs
20714 blank.set_alignment(Some(Align::Center));
20715 assert_eq!(blank.source, "a\n\n\nb\n");
20716 assert!(
20717 blank.status.as_deref().unwrap_or("").contains("no block"),
20718 "got {:?}",
20719 blank.status
20720 );
20721 }
20722
20723 /// A block attribute gesture keeps the caret on the **text** it was on, not
20724 /// on the byte offset it had. Markdown has nowhere to put a paragraph's
20725 /// attributes but a `<div>` around it, and twig splices the div and the
20726 /// block it wraps as one region — so a caret that kept its offset landed in
20727 /// the markup, and every press after the first answered "no block at the
20728 /// caret" with the toolbar's queries reading nothing.
20729 #[test]
20730 fn a_markdown_block_gesture_keeps_the_caret_on_its_text() {
20731 let word = |d: &Doc| d.caret - d.source.find("brown").unwrap();
20732 let mut md = fmt_doc("the quick brown fox\n", Format::Markdown);
20733 md.caret = md.source.find("brown").unwrap() + 2; // "br|own"
20734
20735 // Wrapping: the div and two blank lines open above the block.
20736 md.set_alignment(Some(Align::Center));
20737 assert_eq!(
20738 md.source,
20739 "<div class=\"center\">\n\nthe quick brown fox\n\n</div>\n"
20740 );
20741 assert_eq!(word(&md), 2, "the caret left its word: {}", md.caret);
20742 assert_eq!(md.alignment_at_caret(), Some(Align::Center));
20743
20744 // Re-styling: the attribute line changes length under the same caret,
20745 // and the second press reaches the same block rather than nothing.
20746 md.set_alignment(Some(Align::Right));
20747 assert_eq!(
20748 md.source, "<div class=\"right\">\n\nthe quick brown fox\n\n</div>\n",
20749 "a second press re-styles the div"
20750 );
20751 assert_eq!(md.status, None);
20752 assert_eq!(word(&md), 2);
20753
20754 // A second key on the same div — the line grows, the caret rides it.
20755 md.set_line_spacing(Some(LineHeight::Step(LineSpacing::Double)));
20756 assert_eq!(
20757 md.source,
20758 "<div class=\"right\" data-line-height=\"2\">\n\nthe quick brown fox\n\n</div>\n"
20759 );
20760 assert_eq!(word(&md), 2);
20761 assert_eq!(
20762 md.line_spacing_at_caret(),
20763 Some(LineHeight::Step(LineSpacing::Double))
20764 );
20765
20766 // Unwrapping: the line shrinks, and then the div goes altogether.
20767 md.set_alignment(None);
20768 assert_eq!(
20769 md.source,
20770 "<div data-line-height=\"2\">\n\nthe quick brown fox\n\n</div>\n"
20771 );
20772 assert_eq!(word(&md), 2);
20773 md.set_line_spacing(None);
20774 assert_eq!(md.source, "the quick brown fox\n", "the last key unwraps");
20775 assert_eq!(word(&md), 2, "the caret came back down with the block");
20776 assert_eq!(md.alignment_at_caret(), None);
20777 assert_eq!(md.status, None);
20778 }
20779
20780 /// The same rule in djot, where the spelling is a `{…}` line *above* the
20781 /// block rather than a wrapper around it: inserting it pushes the block
20782 /// down, re-styling it changes the line's length, and clearing the last key
20783 /// takes the line away again. The caret rides all three.
20784 #[test]
20785 fn a_djot_attribute_line_keeps_the_caret_on_its_text() {
20786 let word = |d: &Doc| d.caret - d.source.find("brown").unwrap();
20787 let mut dj = fmt_doc("the quick brown fox\n", Format::Djot);
20788 dj.caret = dj.source.find("brown").unwrap() + 2;
20789
20790 dj.set_alignment(Some(Align::Center));
20791 assert_eq!(dj.source, "{.center}\nthe quick brown fox\n");
20792 assert_eq!(word(&dj), 2);
20793 assert_eq!(dj.alignment_at_caret(), Some(Align::Center));
20794
20795 dj.set_line_spacing(Some(LineHeight::Step(LineSpacing::Double)));
20796 assert_eq!(
20797 dj.source, "{.center data-line-height=\"2\"}\nthe quick brown fox\n",
20798 "a second press edits the line the first wrote"
20799 );
20800 assert_eq!(word(&dj), 2);
20801
20802 dj.set_alignment(None);
20803 assert_eq!(dj.source, "{data-line-height=\"2\"}\nthe quick brown fox\n");
20804 assert_eq!(word(&dj), 2);
20805
20806 dj.set_line_spacing(None);
20807 assert_eq!(dj.source, "the quick brown fox\n");
20808 assert_eq!(word(&dj), 2);
20809 assert_eq!(dj.status, None);
20810 }
20811
20812 /// The run gestures with no selection are the block gesture, so they keep
20813 /// the caret the same way — and a heading keeps it inside the heading's own
20814 /// text, past the `# ` its content span starts after. A selection rides
20815 /// along whole: a block gesture is not a run gesture, and what was selected
20816 /// before the press is still selected after it.
20817 #[test]
20818 fn a_block_gesture_carries_a_selection_and_a_heading_caret_too() {
20819 // No selection: the run gesture goes through the block door.
20820 let mut md = fmt_doc("the quick brown fox\n", Format::Markdown);
20821 md.caret = md.source.find("brown").unwrap() + 2;
20822 md.set_font_size(Some(FontSize::Step(SizeStep::Large)));
20823 assert_eq!(
20824 md.source,
20825 "<div data-size=\"large\">\n\nthe quick brown fox\n\n</div>\n"
20826 );
20827 assert_eq!(md.caret - md.source.find("brown").unwrap(), 2);
20828 assert_eq!(
20829 md.font_size_at_caret(),
20830 Some(FontSize::Step(SizeStep::Large))
20831 );
20832 md.set_font_size(Some(FontSize::Step(SizeStep::Small)));
20833 assert_eq!(
20834 md.font_size_at_caret(),
20835 Some(FontSize::Step(SizeStep::Small)),
20836 "the second press reached the same block"
20837 );
20838
20839 // A selection: alignment is the block's whatever is selected, and the
20840 // words stay selected.
20841 let mut sel = fmt_doc("the quick brown fox\n", Format::Markdown);
20842 let at = sel.source.find("brown").unwrap();
20843 sel.anchor = Some(at);
20844 sel.caret = at + 5;
20845 sel.set_alignment(Some(Align::Center));
20846 let now = sel.source.find("brown").unwrap();
20847 assert_eq!(sel.selection(), Some((now, now + 5)), "the words moved out");
20848
20849 // A heading: the content span starts past the `# `.
20850 let mut h = fmt_doc("# hi there\n\nbody\n", Format::Markdown);
20851 h.caret = h.source.find("there").unwrap() + 1;
20852 h.set_alignment(Some(Align::Right));
20853 assert_eq!(
20854 h.source,
20855 "<div class=\"right\">\n\n# hi there\n\n</div>\n\nbody\n"
20856 );
20857 assert_eq!(h.caret, h.source.find("there").unwrap() + 1);
20858 assert_eq!(h.alignment_at_caret(), Some(Align::Right));
20859 }
20860
20861 /// A djot document open in the rich view, with its map built as
20862 /// [`wysiwyg_doc`] builds a Markdown one's.
20863 fn wysiwyg_djot(body: &str) -> Doc {
20864 let mut d = fmt_doc(body, Format::Djot);
20865 d.view = View::Wysiwyg;
20866 d.build_visual(80);
20867 d
20868 }
20869
20870 /// Backspace at the start of a block whose presentation is spelled as
20871 /// hidden markup before it strips that presentation, the way Backspace at
20872 /// a heading's start strips its `#`. The ordinary delete fused djot's
20873 /// `{.center}` line onto the text and took the blank line a Markdown div
20874 /// needs between its tag and its paragraph.
20875 #[test]
20876 fn backspace_at_the_start_of_a_centred_paragraph_strips_its_attributes() {
20877 let mut md = wysiwyg_doc(
20878 "wys_attr_bksp",
20879 "above\n\n<div class=\"center\">\n\nhello\n\n</div>\n\nbelow\n",
20880 );
20881 md.caret = md.source.find("hello").unwrap();
20882 md.backspace();
20883 assert_eq!(
20884 md.source, "above\n\nhello\n\nbelow\n",
20885 "the div is unwrapped"
20886 );
20887 assert_eq!(md.caret, 7, "the caret stays at the start of its text");
20888 md.backspace();
20889 assert_eq!(
20890 md.source, "above\nhello\n\nbelow\n",
20891 "the next press joins the paragraphs, as it always did"
20892 );
20893
20894 let mut dj = wysiwyg_djot("above\n\n{.center}\nhello\n\nbelow\n");
20895 dj.caret = dj.source.find("hello").unwrap();
20896 dj.backspace();
20897 assert_eq!(
20898 dj.source, "above\n\nhello\n\nbelow\n",
20899 "the attribute line goes"
20900 );
20901 assert_eq!(dj.caret, 7);
20902
20903 // A heading's own marker is the nearer hidden markup, and goes first;
20904 // the attributes are the next press's.
20905 let mut dj = wysiwyg_djot("{.center}\n# Title\n");
20906 dj.caret = dj.source.find("Title").unwrap();
20907 dj.backspace();
20908 assert_eq!(dj.source, "{.center}\nTitle\n", "the `#` first");
20909 dj.build_visual(80);
20910 dj.backspace();
20911 assert_eq!(dj.source, "Title\n", "then the attributes");
20912 assert_eq!(dj.caret, 0);
20913 }
20914
20915 /// A div around several blocks has no sole child for twig to unwrap, so
20916 /// at its first block the caret steps back to the stop before rather than
20917 /// taking the div apart; a later block has an ordinary paragraph above it
20918 /// and joins as any paragraph does.
20919 #[test]
20920 fn backspace_at_the_first_of_a_div_s_blocks_steps_back_and_a_later_one_joins() {
20921 let src = "above\n\n<div class=\"center\">\n\nhello\n\nworld\n\n</div>\n";
20922 let mut d = wysiwyg_doc("wys_div_first", src);
20923 d.caret = d.source.find("hello").unwrap();
20924 d.backspace();
20925 assert_eq!(d.source, src, "nothing is deleted");
20926 assert_eq!(d.caret, 5, "the caret steps back to the end of `above`");
20927
20928 let mut d = wysiwyg_doc("wys_div_later", src);
20929 d.caret = d.source.find("world").unwrap();
20930 d.backspace();
20931 assert_eq!(
20932 d.source, "above\n\n<div class=\"center\">\n\nhello\nworld\n\n</div>\n",
20933 "a later block joins the one above it"
20934 );
20935 }
20936
20937 /// Backspace at the start of the paragraph after a Markdown div joins it
20938 /// into the div's last paragraph — the join any two paragraphs make, with
20939 /// the hidden `</div>` carried past the joined text. The ordinary delete
20940 /// took the newline under the tag, which drew nothing different, and the
20941 /// next press took the `>` and left the div unclosed.
20942 #[test]
20943 fn backspace_after_a_div_joins_the_paragraph_into_it() {
20944 let src = "above\n\n<div class=\"center\">\n\nhello\n\n</div>\n\nbelow\n";
20945 let mut d = wysiwyg_doc("wys_div_join", src);
20946 d.caret = d.source.find("below").unwrap();
20947 d.backspace();
20948 assert_eq!(
20949 d.source,
20950 "above\n\n<div class=\"center\">\n\nhello\nbelow\n\n</div>\n"
20951 );
20952 assert_eq!(
20953 d.caret,
20954 d.source.find("below").unwrap(),
20955 "the caret stays at the start of the joined text"
20956 );
20957 d.build_visual(80);
20958 assert_eq!(
20959 d.alignment_at_caret(),
20960 Some(Align::Center),
20961 "and is centred now"
20962 );
20963 d.undo();
20964 assert_eq!(d.source, src, "one undo step");
20965
20966 // A list closes the div: the paragraph joins the last item's text,
20967 // under the item's continuation indent, inside the div.
20968 let src = "<div class=\"center\">\n\n- item\n\n</div>\n\nbelow\n";
20969 let mut d = wysiwyg_doc("wys_div_list", src);
20970 d.caret = d.source.find("below").unwrap();
20971 d.backspace();
20972 assert_eq!(
20973 d.source, "<div class=\"center\">\n\n- item\n below\n\n</div>\n",
20974 "the paragraph joins the item"
20975 );
20976 assert_eq!(d.caret, d.source.find("below").unwrap());
20977
20978 // And where twig has nothing to join into — a code block above — the
20979 // caret steps back to the stop before, and nothing is deleted.
20980 let src = "```\ncode\n```\n\nbelow\n";
20981 let mut d = wysiwyg_doc("wys_code_then_para", src);
20982 d.caret = d.source.find("below").unwrap();
20983 d.backspace();
20984 assert_eq!(d.source, src, "nothing is deleted");
20985 assert!(
20986 d.caret < d.source.find("below").unwrap(),
20987 "the caret stepped back"
20988 );
20989 }
20990
20991 /// Backspace on a blank line collapses to the stop before it — but not
20992 /// across hidden markup, which that collapse deleted whole: a `</div>`,
20993 /// or a comment between two blocks. There the blank line goes alone, and
20994 /// the caret lands where the collapse would have put it.
20995 #[test]
20996 fn backspace_on_a_blank_line_after_hidden_markup_keeps_the_markup() {
20997 let mut d = wysiwyg_doc(
20998 "wys_div_blank",
20999 "<div class=\"center\">\n\nhello\n\n</div>\n\n\n\nbelow\n",
21000 );
21001 d.caret = d.source.find("below").unwrap() - 2; // the empty paragraph
21002 assert!(
21003 d.vmap.is_stop(d.caret),
21004 "the empty paragraph is a caret home"
21005 );
21006 d.backspace();
21007 assert_eq!(
21008 d.source, "<div class=\"center\">\n\nhello\n\n</div>\n\nbelow\n",
21009 "the blank line goes and the div stays closed"
21010 );
21011 assert_eq!(
21012 d.caret,
21013 d.source.find("hello").unwrap() + 5,
21014 "onto the end of `hello`"
21015 );
21016
21017 let mut d = wysiwyg_doc("wys_comment_blank", "above\n\n<!-- note -->\n\n\n\nbelow\n");
21018 d.caret = d.source.find("below").unwrap() - 2;
21019 assert!(d.vmap.is_stop(d.caret));
21020 d.backspace();
21021 assert_eq!(
21022 d.source, "above\n\n<!-- note -->\n\nbelow\n",
21023 "the comment stays"
21024 );
21025 assert_eq!(d.caret, 5);
21026 }
21027
21028 /// Backspace at the end of an attributed span steps inside its hidden
21029 /// closing tag the way it steps inside a `**`, and takes the span with
21030 /// its last letter. Before, the byte-step took the `>` of `</span>`,
21031 /// which left the paragraph unparseable: it vanished from the rich view,
21032 /// and the Backspace after that joined the next block into the wreck.
21033 #[test]
21034 fn backspace_walks_into_a_sized_span_and_takes_the_emptied_span_with_its_space() {
21035 let src = "above\n\nThis <span data-size=\"x-large\">is</span> a test\n\nTest 2\n";
21036 let mut d = wysiwyg_doc("wys_span_bs", src);
21037 d.caret = d.source.find("a test").unwrap() + 6;
21038 for _ in 0..7 {
21039 d.backspace();
21040 }
21041 assert_eq!(
21042 d.source,
21043 "above\n\nThis <span data-size=\"x-large\">is</span>\n\nTest 2\n"
21044 );
21045 d.backspace();
21046 assert_eq!(
21047 d.source, "above\n\nThis <span data-size=\"x-large\">i</span>\n\nTest 2\n",
21048 "the first Backspace after the tag takes the letter, not the `>`"
21049 );
21050 d.backspace();
21051 assert_eq!(
21052 d.source, "above\n\nThis \n\nTest 2\n",
21053 "the last letter takes the span with it"
21054 );
21055 assert_eq!(d.caret, 12, "the caret is where the letter was");
21056 d.backspace();
21057 assert_eq!(d.source, "above\n\nThis\n\nTest 2\n");
21058 assert_eq!(d.caret, 11);
21059 d.build_visual(80);
21060 assert!(
21061 d.vmap
21062 .rows
21063 .iter()
21064 .any(|r| r.glyphs.iter().map(|g| g.ch).collect::<String>() == "This"),
21065 "the paragraph is still drawn"
21066 );
21067 }
21068
21069 #[test]
21070 fn backspace_walks_into_a_djot_sized_span_too() {
21071 let mut d = wysiwyg_djot("This [is]{data-size=\"x-large\"}\n\nTest 2\n");
21072 d.caret = d.source.find("\n\nTest 2").unwrap();
21073 // The caret home at the paragraph's end is inside the span, before
21074 // its `]`: the map offers no stop after `]{…}`.
21075 d.build_visual(80);
21076 assert_eq!(d.vmap.stop_before(31), Some(8));
21077 d.caret = 8;
21078 d.backspace();
21079 assert_eq!(d.source, "This [i]{data-size=\"x-large\"}\n\nTest 2\n");
21080 d.backspace();
21081 assert_eq!(
21082 d.source, "This \n\nTest 2\n",
21083 "the attribute block outside the span goes with it"
21084 );
21085 d.backspace();
21086 assert_eq!(d.source, "This\n\nTest 2\n");
21087 assert_eq!(d.caret, 4);
21088 }
21089
21090 /// A span that is empty as the file was written has no stop of its own;
21091 /// Backspace reaching it from behind takes it with the character before
21092 /// it, the character the key looked aimed at.
21093 #[test]
21094 fn backspace_over_an_already_empty_span_takes_it_with_the_character_before() {
21095 let src = "This <span data-size=\"x-large\"></span> a test\n";
21096 let mut d = wysiwyg_doc("wys_span_empty", src);
21097 d.caret = d.source.find(" a test").unwrap();
21098 d.backspace();
21099 assert_eq!(d.source, "This a test\n");
21100 assert_eq!(d.caret, 4);
21101 }
21102
21103 /// The mirror: Delete in front of a span's opening tag takes its first
21104 /// letter, and the span with its last.
21105 #[test]
21106 fn delete_walks_into_a_sized_span_and_takes_the_span_with_its_last_letter() {
21107 let src = "This <span data-size=\"x-large\">is</span> a test\n";
21108 let mut d = wysiwyg_doc("wys_span_del", src);
21109 d.caret = 5;
21110 d.delete_forward();
21111 assert_eq!(
21112 d.source,
21113 "This <span data-size=\"x-large\">s</span> a test\n"
21114 );
21115 d.delete_forward();
21116 assert_eq!(d.source, "This a test\n");
21117 assert_eq!(d.caret, 5);
21118 d.delete_forward();
21119 assert_eq!(d.source, "This a test\n");
21120 assert_eq!(d.caret, 5);
21121 }
21122
21123 /// The block version of the span's emptying rule. Centre a one-letter
21124 /// paragraph — Markdown spells that as a `<div>` around it — and
21125 /// Backspace the letter: the div goes with it, leaving a plain blank line
21126 /// the caret is at home on, in the incremental map and the from-scratch
21127 /// one alike. Before, the letter went alone; the emptied div drew a
21128 /// caret home only the stale map had, and the next Backspace collapsed
21129 /// the line and left `<div class="center">\n\n</div>` standing invisibly
21130 /// in the file.
21131 #[test]
21132 fn backspace_that_empties_a_centred_paragraph_takes_its_div_with_the_letter() {
21133 let mut d = wysiwyg_doc("wys_div_empty", "Try the toolbar.\n\nT\n");
21134 d.caret = d.source.len() - 1;
21135 d.set_alignment(Some(Align::Center));
21136 assert_eq!(
21137 d.source,
21138 "Try the toolbar.\n\n<div class=\"center\">\n\nT\n\n</div>\n"
21139 );
21140 d.backspace();
21141 assert_eq!(d.source, "Try the toolbar.\n\n\n");
21142 assert_eq!(d.caret, 18, "on the blank line where the letter was");
21143 // The host rebuilds the map after every key; the spliced map and a
21144 // fresh one both give the line a caret home.
21145 d.build_visual_unwrapped();
21146 assert!(d.vmap.is_stop(18));
21147 wysiwyg::assert_maps_eq(&d.vmap, &reference_map(&d.source), "after the div goes");
21148 d.backspace();
21149 assert_eq!(
21150 d.source, "Try the toolbar.\n",
21151 "then the blank line collapses"
21152 );
21153 assert_eq!(d.caret, 16);
21154
21155 // With a block after the div the blank line keeps a gap each side.
21156 let mut d = wysiwyg_doc(
21157 "wys_div_empty_mid",
21158 "Try the toolbar.\n\n<div class=\"center\">\n\nT\n\n</div>\n\nbelow\n",
21159 );
21160 d.caret = d.source.find("T\n").unwrap() + 1;
21161 d.backspace();
21162 assert_eq!(d.source, "Try the toolbar.\n\n\n\nbelow\n");
21163 assert_eq!(d.caret, 18);
21164 d.build_visual_unwrapped();
21165 assert!(d.vmap.is_stop(18));
21166 wysiwyg::assert_maps_eq(&d.vmap, &reference_map(&d.source), "after the div goes");
21167 }
21168
21169 /// djot spells the same paragraph as a `{.center}` line above it, and
21170 /// twig has no node at all for that line once the paragraph is gone —
21171 /// so the line goes with the letter too.
21172 #[test]
21173 fn backspace_that_empties_a_centred_paragraph_takes_its_djot_attrs_line_too() {
21174 let mut d = fmt_doc("Try the toolbar.\n\nT\n", Format::Djot);
21175 d.build_visual(80);
21176 d.caret = d.source.len() - 1;
21177 d.set_alignment(Some(Align::Center));
21178 assert_eq!(d.source, "Try the toolbar.\n\n{.center}\nT\n");
21179 d.backspace();
21180 assert_eq!(d.source, "Try the toolbar.\n\n\n");
21181 assert_eq!(d.caret, 18);
21182 d.build_visual(80);
21183 assert!(d.vmap.is_stop(18));
21184 }
21185
21186 /// The rule is for a block that would be no block: a div holding more
21187 /// keeps its tags, and an emptied heading is still a heading.
21188 #[test]
21189 fn emptying_a_paragraph_keeps_a_div_that_holds_more_and_a_heading_its_marker() {
21190 let mut d = wysiwyg_doc(
21191 "wys_div_more",
21192 "<div class=\"center\">\n\nText\n\nT\n\n</div>\n",
21193 );
21194 d.caret = d.source.find("T\n").unwrap() + 1;
21195 d.backspace();
21196 assert_eq!(d.source, "<div class=\"center\">\n\nText\n\n\n\n</div>\n");
21197 assert_eq!(d.caret, 28, "the blank line inside the div, as after Enter");
21198
21199 let mut d = wysiwyg_doc(
21200 "wys_div_heading",
21201 "<div class=\"center\">\n\n# T\n\n</div>\n",
21202 );
21203 d.caret = d.source.find("T\n").unwrap() + 1;
21204 d.backspace();
21205 assert_eq!(d.source, "<div class=\"center\">\n\n# \n\n</div>\n");
21206 assert_eq!(d.caret, 24);
21207 d.build_visual(80);
21208 assert!(
21209 d.vmap.is_stop(24),
21210 "the empty heading is still a caret home"
21211 );
21212 }
21213
21214 /// The mirror: Delete in front of the letter takes the div with it.
21215 #[test]
21216 fn delete_that_empties_a_centred_paragraph_takes_its_div_with_the_letter() {
21217 let mut d = wysiwyg_doc(
21218 "wys_div_empty_del",
21219 "Try the toolbar.\n\n<div class=\"center\">\n\nT\n\n</div>\n",
21220 );
21221 d.caret = d.source.find("T\n").unwrap();
21222 d.delete_forward();
21223 assert_eq!(d.source, "Try the toolbar.\n\n\n");
21224 assert_eq!(d.caret, 18);
21225 d.build_visual(80);
21226 assert!(d.vmap.is_stop(18));
21227
21228 let mut d = fmt_doc("Try the toolbar.\n\n{.center}\nT\n", Format::Djot);
21229 d.build_visual(80);
21230 d.caret = d.source.find("T\n").unwrap();
21231 d.delete_forward();
21232 assert_eq!(d.source, "Try the toolbar.\n\n\n");
21233 assert_eq!(d.caret, 18);
21234 }
21235
21236 /// Backspace at a block's start is twig's join, spelled per format — so
21237 /// the cases the one-newline delete got wrong come out right: a
21238 /// paragraph joins onto a heading's line, HTML's `</p><p>` goes as one,
21239 /// and a quote's prefix is written on the joined line.
21240 #[test]
21241 fn backspace_at_a_block_start_joins_it_the_format_s_way() {
21242 let mut d = wysiwyg_doc("wys_join_heading", "# Title\n\nbelow\n");
21243 d.caret = d.source.find("below").unwrap();
21244 d.backspace();
21245 assert_eq!(d.source, "# Title below\n", "onto the heading's line");
21246 assert_eq!(d.caret, d.source.find("below").unwrap());
21247 d.undo();
21248 assert_eq!(d.source, "# Title\n\nbelow\n", "one undo step");
21249
21250 let mut d = wysiwyg_doc("wys_join_quote", "> a\n\nb\n");
21251 d.caret = d.source.find('b').unwrap();
21252 d.backspace();
21253 assert_eq!(d.source, "> a\n> b\n", "into the quote, with its prefix");
21254 assert_eq!(d.caret, d.source.find('b').unwrap());
21255
21256 let mut h = fmt_doc("<p>above</p>\n<p class=\"x\">below</p>\n", Format::Html);
21257 h.view = View::Wysiwyg;
21258 h.build_visual(80);
21259 h.caret = h.source.find("below").unwrap();
21260 h.backspace();
21261 assert_eq!(
21262 h.source, "<p>above\nbelow</p>\n",
21263 "one paragraph, the tag gone whole"
21264 );
21265 assert_eq!(h.caret, h.source.find("below").unwrap());
21266 }
21267
21268 /// Delete at the end of a block's content is the same join aimed at the
21269 /// block after it, and the caret stays where the joined text now begins.
21270 #[test]
21271 fn delete_at_a_block_end_joins_the_next_block_into_it() {
21272 let src = "above\n\n<div class=\"center\">\n\nhello\n\n</div>\n\nbelow\n";
21273 let mut d = wysiwyg_doc("wys_del_join", src);
21274 d.caret = d.source.find("hello").unwrap() + 5;
21275 d.delete_forward();
21276 assert_eq!(
21277 d.source, "above\n\n<div class=\"center\">\n\nhello\nbelow\n\n</div>\n",
21278 "below joins hello inside the div"
21279 );
21280 assert_eq!(
21281 d.caret,
21282 d.source.find("hello").unwrap() + 5,
21283 "the caret stays"
21284 );
21285
21286 let mut d = wysiwyg_doc("wys_del_join_head", "above\n\n# Title\n");
21287 d.caret = 5;
21288 d.delete_forward();
21289 assert_eq!(
21290 d.source, "above\nTitle\n",
21291 "the heading's marker goes with the join"
21292 );
21293 assert_eq!(d.caret, 5);
21294
21295 // A code block after the paragraph: nothing to join, the caret steps
21296 // forward onto the next stop and nothing is deleted.
21297 let src = "above\n\n```\ncode\n```\n";
21298 let mut d = wysiwyg_doc("wys_del_code", src);
21299 d.caret = 5;
21300 d.delete_forward();
21301 assert_eq!(d.source, src);
21302 assert!(d.caret > 5, "the caret stepped forward");
21303 }
21304
21305 // ── Text statistics ──────────────────────────────────────────────────────
21306
21307 /// The counts of `body`, from a document open in the WYSIWYG view — the
21308 /// shape every case below starts from.
21309 fn counts_of(name: &str, body: &str) -> TextCounts {
21310 doc_in(View::Wysiwyg, name, body).counts()
21311 }
21312
21313 #[test]
21314 fn counts_tally_plain_prose() {
21315 let c = counts_of(
21316 "counts_prose",
21317 "The quick brown fox jumps over the lazy dog.\n",
21318 );
21319 assert_eq!(
21320 c,
21321 TextCounts {
21322 words: 9,
21323 characters: 44,
21324 characters_without_spaces: 36,
21325 paragraphs: 1,
21326 }
21327 );
21328 }
21329
21330 #[test]
21331 fn counts_read_the_text_and_not_the_markup() {
21332 // The `**` are four bytes of source and no part of the word.
21333 assert_eq!(
21334 counts_of("counts_marks", "a **bold** word\n"),
21335 TextCounts {
21336 words: 3,
21337 characters: 11,
21338 characters_without_spaces: 9,
21339 paragraphs: 1,
21340 }
21341 );
21342 // A link is its label; the destination is plumbing, however long.
21343 assert_eq!(
21344 counts_of(
21345 "counts_link",
21346 "see [the label](https://example.com/a/b/c) here\n"
21347 ),
21348 TextCounts {
21349 words: 4,
21350 characters: 18,
21351 characters_without_spaces: 15,
21352 paragraphs: 1,
21353 }
21354 );
21355 }
21356
21357 #[test]
21358 fn counts_spend_nothing_on_a_picture() {
21359 // A block image renders as a `🖼 alt` placeholder — a picture, not a
21360 // sentence, and not a paragraph either.
21361 assert_eq!(
21362 counts_of("counts_image", "\n"),
21363 TextCounts::default()
21364 );
21365 // And it adds nothing to the prose around it.
21366 assert_eq!(
21367 counts_of("counts_image_prose", "text\n\n\n"),
21368 TextCounts {
21369 words: 1,
21370 characters: 4,
21371 characters_without_spaces: 4,
21372 paragraphs: 1,
21373 }
21374 );
21375 }
21376
21377 #[test]
21378 fn counts_spend_nothing_on_drawn_furniture() {
21379 // A thematic break is drawn, not written, and an empty paragraph has
21380 // nothing in it — neither is a paragraph of the document.
21381 assert_eq!(
21382 counts_of("counts_rule", "one\n\n---\n\ntwo\n"),
21383 TextCounts {
21384 words: 2,
21385 characters: 6,
21386 characters_without_spaces: 6,
21387 paragraphs: 2,
21388 }
21389 );
21390 }
21391
21392 #[test]
21393 fn counts_measure_characters_as_a_reader_does() {
21394 // Four Han characters (each its own word under UAX#29), one ZWJ emoji
21395 // family that is a single grapheme cluster, and two letters.
21396 let c = counts_of(
21397 "counts_graphemes",
21398 "你好世界 👩\u{200d}👩\u{200d}👧\u{200d}👦 ok\n",
21399 );
21400 assert_eq!(
21401 c,
21402 TextCounts {
21403 words: 5,
21404 characters: 9,
21405 characters_without_spaces: 7,
21406 paragraphs: 1,
21407 }
21408 );
21409 }
21410
21411 #[test]
21412 fn counts_take_a_code_block_as_one_paragraph() {
21413 let c = counts_of("counts_code", "```rust\nlet x = 1;\n\nlet y = 2;\n```\n");
21414 assert_eq!(
21415 c,
21416 TextCounts {
21417 words: 6,
21418 characters: 20,
21419 characters_without_spaces: 14,
21420 paragraphs: 1,
21421 }
21422 );
21423 }
21424
21425 #[test]
21426 fn counts_take_a_table_as_one_paragraph() {
21427 // The box-drawn borders and the column padding are the renderer's, not
21428 // the author's; the cells are what was written.
21429 let c = counts_of("counts_table", "| a b | c |\n| - | - |\n| d | e |\n");
21430 assert_eq!(
21431 c,
21432 TextCounts {
21433 words: 5,
21434 characters: 6,
21435 characters_without_spaces: 5,
21436 paragraphs: 1,
21437 }
21438 );
21439 }
21440
21441 #[test]
21442 fn counts_give_every_item_and_every_quoted_paragraph_its_own_paragraph() {
21443 let c = counts_of(
21444 "counts_blocks",
21445 "- one\n- two\n- three\n\n> first quoted\n>\n> second quoted\n",
21446 );
21447 assert_eq!(
21448 c,
21449 TextCounts {
21450 words: 7,
21451 characters: 36,
21452 characters_without_spaces: 34,
21453 paragraphs: 5,
21454 }
21455 );
21456 }
21457
21458 #[test]
21459 fn counts_leave_the_frontmatter_out() {
21460 // The WYSIWYG view doesn't render it and a writer didn't write it.
21461 let c = counts_of(
21462 "counts_frontmatter",
21463 "---\ntitle: Hidden\n---\n\nvisible words here\n",
21464 );
21465 assert_eq!(
21466 c,
21467 TextCounts {
21468 words: 3,
21469 characters: 18,
21470 characters_without_spaces: 16,
21471 paragraphs: 1,
21472 }
21473 );
21474 }
21475
21476 #[test]
21477 fn counts_of_an_empty_document_are_all_zero() {
21478 assert_eq!(counts_of("counts_empty", ""), TextCounts::default());
21479 }
21480
21481 /// UAX#29 puts a boundary at the hyphen, so a hyphenated compound is two
21482 /// words. Recorded rather than corrected: it is what the algorithm says,
21483 /// and what every other UAX#29 counter reports.
21484 #[test]
21485 fn counts_split_a_hyphenated_compound_in_two() {
21486 let c = counts_of("counts_hyphen", "well-known example\n");
21487 assert_eq!(c.words, 3);
21488 assert_eq!(c.characters, 18);
21489 // Punctuation on its own is no word, and an apostrophe doesn't split one.
21490 assert_eq!(counts_of("counts_punct", "don't ... stop\n").words, 2);
21491 }
21492
21493 #[test]
21494 fn selection_counts_measure_the_selection_and_nothing_without_one() {
21495 let src = "alpha beta\n\ngamma delta\n";
21496 let mut d = doc_in(View::Wysiwyg, "counts_sel", src);
21497 assert_eq!(d.selection_counts(), None, "no selection, no counts");
21498
21499 // From the `b` of `beta` to the end of `gamma`: two blocks clipped.
21500 d.select_range(6, 17);
21501 assert_eq!(
21502 d.selection_counts(),
21503 Some(TextCounts {
21504 words: 2,
21505 characters: 9,
21506 characters_without_spaces: 9,
21507 paragraphs: 2,
21508 })
21509 );
21510 }
21511
21512 /// The count is of the document, not of the window it is shown in — so
21513 /// ⌘E must not move it, and neither must a resize or an edit made with no
21514 /// map built at all.
21515 #[test]
21516 fn counts_agree_across_the_views() {
21517 let src =
21518 "# Head\n\nA **bold** word, a [label](http://x), and more.\n\n- item one\n- item two\n";
21519 let mut d = doc_in(View::Wysiwyg, "counts_views", src);
21520 let wysiwyg = d.counts();
21521 assert!(wysiwyg.words > 0 && wysiwyg.paragraphs == 4);
21522
21523 d.toggle_view();
21524 assert_eq!(d.view, View::Source);
21525 d.build_source();
21526 assert_eq!(d.counts(), wysiwyg, "the source view counts the same text");
21527
21528 // A narrower measure is a narrower window, not a shorter document.
21529 d.toggle_view();
21530 d.build_visual(24);
21531 assert_eq!(d.counts(), wysiwyg, "wrapping is not an input");
21532
21533 // And an edit made in the source view, with the visual map left stale,
21534 // still counts the document as it now stands.
21535 d.toggle_view();
21536 d.caret = d.source.len();
21537 d.insert("\n\ntail words\n");
21538 let after = d.counts();
21539 assert_eq!(after.paragraphs, wysiwyg.paragraphs + 1);
21540 assert_eq!(after.words, wysiwyg.words + 2);
21541 }
21542
21543 // ── math ─────────────────────────────────────────────────────────────────
21544
21545 #[test]
21546 fn a_formula_reveals_on_the_caret_line_in_the_hidden_modes() {
21547 // The rule the proposal states: a formula's content is its TeX, not
21548 // its picture, so it reveals on the caret's line in *every* mode —
21549 // and nothing else on that line does outside `Full`.
21550 let mut d = doc_in(
21551 View::Wysiwyg,
21552 "math_reveal",
21553 "*one* $x+y$ here\n\ntwo there\n",
21554 );
21555 d.set_inline_pictures(true);
21556 assert_eq!(d.markup_mode(), MarkupMode::None);
21557
21558 // Away from the formula's line: the atom, and no reveal at all.
21559 caret_at(&mut d, "two");
21560 assert!(
21561 drawn_rows(&d).iter().any(|r| r == "one ∑ here"),
21562 "{:?}",
21563 drawn_rows(&d)
21564 );
21565 assert_eq!(d.vmap.math.len(), 1);
21566 assert_eq!(d.reveal_line(), None, "a line with no math keys nothing");
21567
21568 // On it: the formula is its source, the emphasis is still resolved.
21569 caret_at(&mut d, "here");
21570 assert!(
21571 drawn_rows(&d).iter().any(|r| r == "one $x+y$ here"),
21572 "{:?}",
21573 drawn_rows(&d)
21574 );
21575 assert!(d.vmap.math.is_empty());
21576 assert_eq!(d.reveal_line(), Some(Reveal::math(0..16)));
21577
21578 // Off again, and the picture is back.
21579 caret_at(&mut d, "two");
21580 assert!(drawn_rows(&d).iter().any(|r| r == "one ∑ here"));
21581
21582 // The same in Shortcuts; and Full reveals the emphasis too.
21583 d.set_markup_mode(MarkupMode::Shortcuts);
21584 caret_at(&mut d, "here");
21585 assert!(drawn_rows(&d).iter().any(|r| r == "one $x+y$ here"));
21586 d.set_markup_mode(MarkupMode::Full);
21587 caret_at(&mut d, "here");
21588 assert!(drawn_rows(&d).iter().any(|r| r == "*one* $x+y$ here"));
21589 }
21590
21591 #[test]
21592 fn an_unrevealed_map_shows_the_carets_line_as_a_page_does() {
21593 let mut d = doc_in(
21594 View::Wysiwyg,
21595 "math_unrevealed",
21596 "*one* $x+y$ here\n\ntwo there\n",
21597 );
21598 d.set_inline_pictures(true);
21599 d.set_markup_mode(MarkupMode::Full);
21600 caret_at(&mut d, "here");
21601 assert!(drawn_rows(&d).iter().any(|r| r == "*one* $x+y$ here"));
21602 let (screen, caret) = (d.visual_key(), d.caret);
21603
21604 // On paper: the delimiters hidden and the formula a picture, though
21605 // the caret has not moved off the line.
21606 d.set_unrevealed(true);
21607 d.build_visual(80);
21608 assert!(
21609 drawn_rows(&d).iter().any(|r| r == "one ∑ here"),
21610 "{:?}",
21611 drawn_rows(&d)
21612 );
21613 assert_eq!(d.vmap.math.len(), 1);
21614 assert_eq!(d.caret, caret);
21615
21616 // Off again: the screen's map is back as it was, and the next build
21617 // reuses it rather than building it over.
21618 d.set_unrevealed(false);
21619 assert_eq!(d.visual_key(), screen);
21620 d.build_visual(80);
21621 assert_eq!(d.visual_key(), screen);
21622 assert!(drawn_rows(&d).iter().any(|r| r == "*one* $x+y$ here"));
21623 assert_eq!(d.caret, caret);
21624 }
21625
21626 #[test]
21627 fn a_formula_closed_by_typing_reveals_at_once() {
21628 // The reveal line is decided from the last build's layout, which
21629 // across an edit is stale: the keystroke that closes a `$…$` asks a
21630 // layout that knew no math. `build_map` asks again once the new
21631 // layout is in, so the formula does not snap to its picture under
21632 // the caret.
21633 let mut d = doc_in(View::Wysiwyg, "math_typed", "say \n");
21634 d.set_inline_pictures(true);
21635 d.set_markup_mode(MarkupMode::Shortcuts);
21636 d.caret = 4;
21637 for ch in ["$", "x", "$"] {
21638 d.insert(ch);
21639 d.build_visual(80);
21640 }
21641 assert_eq!(d.source, "say $x$\n");
21642 assert_eq!(
21643 drawn_rows(&d)[0],
21644 "say $x$",
21645 "source, not a picture, under the caret"
21646 );
21647 assert!(d.vmap.math.is_empty());
21648 // Leaving the line folds it — there is only one line, so add one.
21649 d.newline();
21650 d.insert("more");
21651 d.build_visual(80);
21652 assert_eq!(drawn_rows(&d)[0], "say ∑");
21653 assert_eq!(d.vmap.math.len(), 1);
21654 // And deleting the formula while revealed drops the reveal with it.
21655 d.caret = 7;
21656 d.build_visual(80);
21657 assert_eq!(drawn_rows(&d)[0], "say $x$");
21658 for _ in 0..3 {
21659 d.backspace();
21660 }
21661 d.build_visual(80);
21662 assert_eq!(d.source, "say \n\nmore\n");
21663 assert_eq!(d.reveal_line(), None);
21664 }
21665
21666 #[test]
21667 fn a_display_block_is_edited_where_it_stands() {
21668 let mut d = doc_in(
21669 View::Wysiwyg,
21670 "math_block",
21671 "intro\n\n$$\n\\int_0^1 x\n$$\n\nend\n",
21672 );
21673 caret_at(&mut d, "end");
21674 assert_eq!(
21675 drawn_rows(&d),
21676 vec!["intro", "", "∑ \\int_0^1 x", "", "end"]
21677 );
21678 // Up from `end` lands on the placeholder, whose glyphs all carry the
21679 // block's start — which is on its `$$` line, so the block reveals.
21680 d.move_up(false);
21681 d.build_visual(80);
21682 assert_eq!(d.caret, 7);
21683 assert_eq!(
21684 drawn_rows(&d),
21685 vec!["intro", "", "$$", "\\int_0^1 x", "$$", "", "end"]
21686 );
21687 // Down walks the source lines, still revealed; typing edits the TeX.
21688 d.move_down(false);
21689 d.build_visual(80);
21690 assert_eq!(d.caret, 10);
21691 d.move_end(false);
21692 d.insert("^2");
21693 d.build_visual(80);
21694 assert_eq!(d.source, "intro\n\n$$\n\\int_0^1 x^2\n$$\n\nend\n");
21695 assert_eq!(drawn_rows(&d)[3], "\\int_0^1 x^2");
21696 // Out below, and it folds to the placeholder with the new TeX.
21697 d.move_down(false);
21698 d.move_down(false);
21699 d.move_down(false);
21700 d.build_visual(80);
21701 assert_eq!(drawn_rows(&d)[2], "∑ \\int_0^1 x^2");
21702 assert_eq!(d.vmap.math[0].tex, "\n\\int_0^1 x^2\n");
21703 }
21704
21705 #[test]
21706 fn set_math_rows_reserves_filler_rows_by_tex() {
21707 let mut d = doc_in(View::Wysiwyg, "math_rows", "$$\nx\n$$\n\nend\n");
21708 caret_at(&mut d, "end");
21709 assert_eq!(d.vmap.math[0].rows_span, 0..1);
21710 d.set_math_rows(HashMap::from([(d.vmap.math[0].tex.clone(), 3)]));
21711 d.build_visual(80);
21712 assert_eq!(d.vmap.math[0].rows_span, 0..3);
21713 assert_eq!(drawn_rows(&d)[..3], ["∑ x", "", ""]);
21714 // Cheap when nothing changed.
21715 let key = d.visual_key();
21716 d.set_math_rows(HashMap::from([(d.vmap.math[0].tex.clone(), 3)]));
21717 d.build_visual(80);
21718 assert_eq!(d.visual_key(), key);
21719 }
21720
21721 #[test]
21722 fn a_dollar_typed_in_shortcuts_authors_math() {
21723 let mut d = doc_in(View::Wysiwyg, "math_dollar_sc", "\n");
21724 d.set_markup_mode(MarkupMode::Shortcuts);
21725 d.caret = 0;
21726 d.insert("$x$");
21727 assert_eq!(d.source, "$x$\n");
21728 d.caret = 1;
21729 assert_eq!(d.breadcrumb(), "doc › para › inline_math");
21730
21731 // `None` keeps typed syntax literal, and under the math extension a
21732 // `$` is syntax: twig escapes it, and the paragraph stays text.
21733 let mut d = doc_in(View::Wysiwyg, "math_dollar_none", "\n");
21734 assert_eq!(d.markup_mode(), MarkupMode::None);
21735 d.caret = 0;
21736 d.insert("$x$");
21737 assert_eq!(d.source, "\\$x\\$\n");
21738 d.caret = 2;
21739 assert_eq!(d.breadcrumb(), "doc › para › str");
21740 }
21741
21742 #[test]
21743 fn counts_see_a_formula_as_a_picture_however_it_is_written() {
21744 let c = counts_of(
21745 "counts_math",
21746 "the sum $\\sum_i x_i$ and\n\n$$\ny = mx + c\n$$\n",
21747 );
21748 // `the sum … and` is three words; neither formula counts, and the
21749 // display block is not a paragraph of text.
21750 assert_eq!(c.words, 3);
21751 assert_eq!(c.paragraphs, 1);
21752 }
21753}