kimun_notes/components/text_editor/view.rs
1use super::markdown::{MarkdownSpanner, ParsedBuffer, opener_shape};
2use crate::ropetext::{Column, Layout, Metrics, RowHints, Viewport, motion};
3use crate::settings::themes::Theme;
4use ratatui::Frame;
5use ratatui::layout::Position;
6use ratatui::layout::Rect;
7use ratatui::style::Style;
8use ratatui::text::{Line, Text};
9use ratatui::widgets::Paragraph;
10
11use super::rope_buffer::RopeBuffer;
12use std::ops::Range;
13
14/// A styled range of logical columns on one row (see CONTEXT.md **Overlay**).
15///
16/// Every highlight the editor paints over a rendered line has this shape. They
17/// arrive in logical coordinates so producers never reason about rendered
18/// columns — markdown conceals sigils, so the two differ — and the mapping
19/// happens once, here.
20#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21pub struct Overlay {
22 pub row: usize,
23 /// Logical char column where the overlay starts.
24 pub start: usize,
25 /// Logical char column just past its end.
26 pub end: usize,
27 pub kind: OverlayKind,
28}
29
30impl Overlay {
31 pub fn new(row: usize, start: usize, end: usize, kind: OverlayKind) -> Self {
32 Self {
33 row,
34 start,
35 end,
36 kind,
37 }
38 }
39}
40
41/// What an [`Overlay`] means, and — by declaration order — how it stacks.
42///
43/// Later kinds paint over earlier ones. That order used to be implicit in
44/// statement order across `view.rs`'s render loop and `mod.rs`'s cell post-pass,
45/// which meant reasoning it out by hand for each new highlight.
46#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
47pub enum OverlayKind {
48 /// A task checkbox: `- [ ]` / `- [x]`.
49 TaskBox,
50 /// The text of a completed task, struck through.
51 TaskDone,
52 /// A vault-search **needle** carried in from the query that opened the note.
53 Needle,
54 /// A **find pattern** match.
55 Match,
56 /// The editor's selection.
57 Selection,
58 /// The **current match** — where the next find-bar action lands.
59 CurrentMatch,
60 /// Text the **replace preview** is showing in place of a match.
61 Preview,
62 /// The previewed **current match**.
63 PreviewCurrent,
64}
65
66impl OverlayKind {
67 /// How this kind restyles the spans it covers.
68 ///
69 /// The one place presentation for overlays lives: producers carry a kind,
70 /// never a `Style`, so a find bar cannot hold an opinion about colour that
71 /// has to be kept in sync with anything.
72 fn restyle(self, theme: &Theme, style: ratatui::style::Style) -> ratatui::style::Style {
73 use ratatui::style::Modifier;
74 match self {
75 OverlayKind::TaskBox => style.fg(theme.accent.to_ratatui()),
76 OverlayKind::TaskDone => style.add_modifier(Modifier::DIM | Modifier::CROSSED_OUT),
77 OverlayKind::Needle | OverlayKind::Match => style
78 .fg(theme.color_search_match.to_ratatui())
79 .add_modifier(Modifier::BOLD),
80 OverlayKind::Selection | OverlayKind::CurrentMatch => {
81 style.bg(theme.selection_bg.to_ratatui())
82 }
83 OverlayKind::Preview => style.bg(theme.color_replace_preview.to_ratatui()),
84 // A foreground override, not a modifier: BOLD is a no-op on text
85 // that is already bold, which once left the current match
86 // indistinguishable from the rest.
87 OverlayKind::PreviewCurrent => style
88 .bg(theme.color_replace_preview.to_ratatui())
89 .fg(cursor_fg(theme))
90 .add_modifier(Modifier::BOLD),
91 }
92 }
93}
94
95/// The `cursor` role, substituting a chromatic colour when the theme defers to
96/// the terminal — `Reset` foreground on `Reset` body text marks nothing.
97fn cursor_fg(theme: &Theme) -> ratatui::style::Color {
98 match theme.cursor {
99 crate::settings::themes::ThemeColor::Reset => theme.fg_bright.to_ratatui(),
100 _ => theme.cursor.to_ratatui(),
101 }
102}
103
104/// Terminal cursor shape the editor requests while focused.
105#[derive(Debug, Clone, Copy, PartialEq, Eq)]
106pub enum CursorShape {
107 Bar,
108 Block,
109}
110
111/// Describes how `view.update`'s Gate 1 modified the parse caches this
112/// frame. Read by Gate 2 to decide what subset of `rendered_cache` and
113/// `WordWrapLayout` needs to be rebuilt.
114#[derive(Debug, Clone)]
115enum TextChangeKind {
116 /// No text change this frame (cursor-only update). Gate 2 may keep
117 /// its caches and only refresh the cursor-row entry.
118 None,
119 /// Gate 1 took the incremental splice path; only rows in this
120 /// range had their ParsedLine entries replaced. Gate 2 should
121 /// rebuild rendered_cache only for these rows + the cursor rows.
122 Incremental(std::ops::Range<usize>),
123 /// Full rebuild (initial parse, line-count change, cap trip,
124 /// structural-marker change, post-slice verification miss). Gate 2
125 /// must rebuild rendered_cache for every row.
126 Full,
127}
128
129enum RenderedCacheRebuild {
130 Full,
131 Rows(Vec<usize>),
132 None,
133}
134
135#[derive(Clone)]
136pub struct MarkdownEditorView {
137 pub layout: Layout,
138 /// The visible rows. Its height is the last `update`'s, so overlay
139 /// derivation can bound itself to what is on screen.
140 viewport: Viewport,
141 /// Set by a wheel scroll that moved the view: `update` stops dragging the
142 /// viewport to the cursor, so the reader can look elsewhere without the
143 /// cursor moving. Cleared by [`Self::follow_cursor`], or by `update` seeing
144 /// a different `last_drawn`.
145 detached: bool,
146 /// (revision, cursor, width) of the last `update`. Any change — an edit, a
147 /// cursor jump from outside the keyboard path, a rewrap — re-attaches the
148 /// viewport, since the detached top no longer means what it did.
149 last_drawn: (u64, (usize, usize), u16),
150 /// The text the caches below were built from.
151 ///
152 /// Held rather than borrowed because `render` runs without the frame's
153 /// snapshot in scope. Keeping it costs nothing: a `Text` clone shares its
154 /// structure, so this is the same text rather than a copy of it — which is why
155 /// the twenty lines that used to copy changed rows into a `Vec<String>` are
156 /// now one assignment.
157 pub text_snapshot: crate::ropetext::Text,
158 pub cursor_snapshot: (usize, usize),
159 /// Line ranges of every fenced code block in the buffer. Text-keyed
160 /// (rebuilt only when `text_revision` changes); `is_in_code_block`
161 /// does a cheap point lookup against this list per row so all fenced
162 /// blocks render `force_raw` regardless of where the cursor is.
163 fence_ranges: Vec<Range<usize>>,
164 /// Per-logical-row code-box width (display cols), or `None` when the row
165 /// is not in a code block. All rows of one block share the block's
166 /// widest-rendered-line width, capped at the editor width. Rebuilt in
167 /// `update()` whenever text or width changes.
168 code_box_width: Vec<Option<u16>>,
169 /// Per-logical-row left gutter width (display cols) for the blockquote
170 /// bar: `depth + 1` on blockquote rows that are NOT the cursor row, else
171 /// 0. Cursor-dependent (the cursor row reveals raw `> `), so rebuilt with
172 /// the same cursor-affected-row logic as `rendered_cache`.
173 gutter_insets: Vec<usize>,
174 /// Cursor's last on-screen position (col, row), or `None` when the
175 /// cursor was scrolled off-screen or the view was unfocused at the
176 /// time of the previous `render`. Used as the anchor for floating
177 /// overlays like the autocomplete popup, which is drawn after the
178 /// editor itself.
179 pub last_cursor_screen: Option<(u16, u16)>,
180 /// Cursor style last written to the terminal, or `None` when the
181 /// terminal is on the user's default shape. The terminal cursor style
182 /// is global state, so on focus loss we must emit an explicit reset —
183 /// otherwise the editor's bar/block shape leaks into every other text
184 /// input (search sidebar, dialogs).
185 applied_cursor_style: Option<CursorShape>,
186 /// Per-line parse cache built in `update()`. Eliminates redundant pulldown-cmark
187 /// invocations across `render()`, cursor placement, and click mapping.
188 /// Either a Real or Placeholder parse — see [`ParseState`].
189 parse_state: ParseState,
190 /// Last `text_revision` seen — gates the lines clone and parse-cache rebuild.
191 /// Cursor-only moves do not bump `text_revision`, so navigating with the
192 /// arrow keys reuses the parse cache instead of re-running pulldown-cmark
193 /// over the whole buffer.
194 last_seen_generation: u64,
195 /// `text_revision`/width/cursor at which the layout was last computed.
196 /// Used to skip `WordWrapLayout::compute()` when nothing affecting wrap has changed:
197 /// horizontal cursor movement within the same element (or plain text) is free.
198 last_layout_generation: u64,
199 last_layout_width: u16,
200 last_layout_cursor: (usize, usize),
201 /// Visual row of the cursor, cached after layout so `render()` doesn't call
202 /// `logical_to_visual` a second time.
203 cursor_vrow: usize,
204 /// Per-line rendered-position bitmask, cached between layout recomputes.
205 /// Only the two cursor rows (old and new) are rebuilt when just the cursor row changes;
206 /// all rows are rebuilt when content or width changes.
207 rendered_cache: Vec<Vec<bool>>,
208 /// Every **overlay** to paint this frame, from outside. The view derives
209 /// task and needle overlays itself (they come from the lines it already
210 /// holds, and only visible rows are worth scanning).
211 overlays: Vec<Overlay>,
212 /// Vault-search **needles** to emphasise, lower-cased.
213 needles: Vec<String>,
214 /// Set when the next update follows an edit that touched rows the cursor
215 /// does not identify — a **replace all**, not a keystroke. Consumed by the
216 /// next `update`, which then skips `compute_damage_range`'s cursor fast
217 /// path: that path assumes the cursor row is the only edited row, and a
218 /// bulk edit violates it silently, leaving distant rows with a stale parse.
219 bulk_edit_pending: bool,
220 /// Diagnostic: true when the most recent Gate 1 invocation used the
221 /// incremental splice path, false when it took the full-parse fallback.
222 /// Read by tests; not part of the production observable surface.
223 last_parse_was_incremental: bool,
224 /// Diagnostic: which widener tier (`Strict` / `Heuristic`)
225 /// produced the most recent successful incremental
226 /// splice. `None` when no incremental splice has happened yet
227 /// (first parse or full-rebuild fallbacks). Read by unit tests
228 /// asserting the chosen widener path.
229 last_splice_path: Option<SplicePath>,
230 /// Tracks how Gate 1 changed (or did not change) the parse caches.
231 /// Gate 2 reads this to decide the scope of rendered_cache rebuild.
232 last_text_change: TextChangeKind,
233 /// The cell a run of ↑/↓ is aiming at.
234 ///
235 /// Vim calls it `curswant`: without it, passing through a short drawn line
236 /// clamps the column and the next press continues from there, so a column is
237 /// lost permanently rather than borrowed. Cleared by any other cursor move —
238 /// the component says when, because only it sees the other keys.
239 visual_goal: Option<usize>,
240 /// Rows the last edits changed, as the **rope buffer** reported them.
241 ///
242 /// Consumed by the next `update`, which then has no reason to compare the
243 /// buffer against a copy of its previous self. `None` means nobody told us —
244 /// the **nvim** backend hands over lines rather than changes, and whole-buffer
245 /// replacements report nothing — and the diff is the fallback for exactly
246 /// those.
247 reported_damage: Option<std::ops::Range<usize>>,
248 /// Set when Gate 2 installed a cheap `Layout::unwrapped` stub instead of
249 /// blocking on `Layout::compute`, mirroring `ParseState::Placeholder`.
250 /// While this is `Some`, every content-changing edit re-stubs and
251 /// re-arms for the new generation rather than relaying just the edited
252 /// rows — the same discipline Gate 1 applies via `is_placeholder()`, so
253 /// a run of edits can never leave the untouched rows of a large buffer
254 /// permanently unwrapped because one of them happened to parse
255 /// incrementally. Cleared by `install_full_layout`.
256 layout_pending: Option<PendingLayout>,
257}
258
259/// A `Layout::unwrapped` stub awaiting a background `Layout::compute`, the
260/// layout-side twin of `ParseState::Placeholder`. `generation` is the
261/// `content_revision` the stub was installed for; `spawned` flips true once
262/// `take_pending_full_layout` has handed the job out, so it is claimed
263/// exactly once per stub.
264#[derive(Debug, Clone, Copy)]
265struct PendingLayout {
266 generation: u64,
267 spawned: bool,
268}
269
270/// Everything a background task needs to compute the real `Layout` for a
271/// stubbed generation, fully owned so it can move into `tokio::spawn`.
272/// `RowHints` borrows, so it is rebuilt from `rendered_cache`/`gutter_insets`
273/// *inside* the task rather than carried across the boundary itself.
274pub struct PendingLayoutJob {
275 pub generation: u64,
276 pub text: crate::ropetext::Text,
277 pub width: usize,
278 pub rendered_cache: Vec<Vec<bool>>,
279 pub gutter_insets: Vec<usize>,
280}
281
282/// True when `KIMUN_VIEW_VERIFY_INCREMENTAL=1` is set. Reads the
283/// env var once per process and caches. Gates the debug-only
284/// full-kinds assertion in Gate 1 that compares every incremental
285/// splice against a fresh whole-buffer parse. (The per-splice
286/// undamaged-row verify on the heuristic path runs in release
287/// unconditionally — see `try_incremental_parse`.)
288///
289/// `cfg`-gated to match its only caller: without this, the release and bench
290/// profiles compile the function with the assertion it exists for gated out,
291/// and warn it is dead.
292#[cfg(debug_assertions)]
293fn verify_incremental_enabled() -> bool {
294 use std::sync::OnceLock;
295 static VERIFY: OnceLock<bool> = OnceLock::new();
296 *VERIFY.get_or_init(|| {
297 std::env::var("KIMUN_VIEW_VERIFY_INCREMENTAL")
298 .map(|v| !v.is_empty() && v != "0")
299 .unwrap_or(false)
300 })
301}
302
303/// Which widener produced the splice for the most recent successful
304/// incremental parse. Test telemetry — read by `last_splice_path`
305/// in unit tests to assert the chosen path. Mirror of the widener's
306/// own `SuccessPath` but kept separate since callers shouldn't depend
307/// on widener internals.
308#[derive(Debug, Clone, Copy, PartialEq, Eq)]
309pub enum SplicePath {
310 /// Strict reset-boundary widener (`reset_boundaries`) succeeded.
311 Strict,
312 /// `widen_to_safe` heuristic succeeded after the strict
313 /// reset-boundary widener returned `FullRebuild`.
314 Heuristic,
315}
316
317/// The editor's per-buffer parse cache: either a fully-styled **Real
318/// parse** or an unstyled **Placeholder parse** awaiting a background
319/// full parse (see `CONTEXT.md`). Modelling the distinction as a type
320/// makes the wrong-splice hazard unrepresentable: splicing is only
321/// reachable through [`ParseState::splice_real`], whose `Placeholder`
322/// arm is unreachable because Gate 1 declines the incremental path for
323/// placeholders. The placeholder's all-`Plain` line kinds would
324/// otherwise defeat the structural guards and accept a wrong splice.
325#[derive(Clone)]
326enum ParseState {
327 Real(ParsedBuffer),
328 /// `generation` is the `content_revision` the placeholder was
329 /// installed for — handed to the owning component so it knows which
330 /// buffer to parse on the background task. `spawned` flips true once
331 /// that task has been requested, so `take_pending_full_parse` hands
332 /// the generation out exactly once.
333 Placeholder {
334 buf: ParsedBuffer,
335 generation: u64,
336 spawned: bool,
337 },
338}
339
340impl ParseState {
341 /// State-agnostic buffer access. Render and Gate 2 read the buffer
342 /// in both states — the placeholder has valid row counts, so the
343 /// downstream path stays in-bounds; only the markdown styling is
344 /// missing while it is a placeholder.
345 fn buf(&self) -> &ParsedBuffer {
346 match self {
347 Self::Real(b) | Self::Placeholder { buf: b, .. } => b,
348 }
349 }
350
351 fn is_placeholder(&self) -> bool {
352 matches!(self, Self::Placeholder { .. })
353 }
354
355 /// Splice an incremental slice into a Real parse. Called only after
356 /// the `is_placeholder()` gate in Gate 1 has declined the
357 /// incremental path for placeholders, so the `Placeholder` arm is
358 /// unreachable.
359 fn splice_real(&mut self, range: std::ops::Range<usize>, slice: ParsedBuffer) {
360 match self {
361 Self::Real(b) => b.splice(range, slice),
362 Self::Placeholder { .. } => {
363 debug_assert!(false, "splice on placeholder parse");
364 }
365 }
366 }
367}
368
369impl MarkdownEditorView {
370 pub fn new() -> Self {
371 Self {
372 layout: Layout::compute(&crate::ropetext::Text::new(), 0, Metrics::default(), &[]),
373 viewport: Viewport::default(),
374 detached: false,
375 last_drawn: (0, (0, 0), 0),
376 text_snapshot: crate::ropetext::Text::new(),
377 cursor_snapshot: (0, 0),
378 fence_ranges: Vec::new(),
379 code_box_width: Vec::new(),
380 gutter_insets: Vec::new(),
381 last_cursor_screen: None,
382 applied_cursor_style: None,
383 // Empty buffer, spliceable — preserves the previous
384 // `placeholder_active: false` initial state.
385 parse_state: ParseState::Real(ParsedBuffer::placeholder(&crate::ropetext::Text::new())),
386 last_seen_generation: u64::MAX, // force rebuild on first update
387 last_layout_generation: u64::MAX,
388 last_layout_width: 0,
389 last_layout_cursor: (usize::MAX, usize::MAX),
390 cursor_vrow: 0,
391 rendered_cache: Vec::new(),
392 overlays: Vec::new(),
393 needles: Vec::new(),
394 bulk_edit_pending: false,
395 last_parse_was_incremental: false,
396 last_splice_path: None,
397 last_text_change: TextChangeKind::Full, // first update is a full rebuild
398 visual_goal: None,
399 reported_damage: None,
400 layout_pending: None,
401 }
402 }
403
404 /// Threshold above which a fallback to full parse runs
405 /// asynchronously instead of blocking the typing thread. On
406 /// buffers below this size the full parse is fast enough
407 /// (<2ms for a paragraph-only 1000-line buffer per bench) that
408 /// blocking is preferable to the one-frame-of-unstyled-text
409 /// the async path imposes.
410 const LARGE_BUFFER_THRESHOLD: usize = 1000;
411
412 /// Returns `Some(generation)` if Gate 1 just installed a
413 /// placeholder `ParsedBuffer` and the owning component should
414 /// spawn a background full parse for this generation. Consumes
415 /// the flag so the owner does not spawn twice; the owner is
416 /// responsible for calling `install_full_parse` when the task
417 /// completes.
418 /// Whether the most recent Gate 1 invocation took the incremental
419 /// splice path. Read-only diagnostic for the incremental-parse
420 /// property tests (`tui/tests/incremental_property.rs`); not part
421 /// of the production render path.
422 pub fn last_parse_was_incremental(&self) -> bool {
423 self.last_parse_was_incremental
424 }
425
426 pub fn take_pending_full_parse(&mut self) -> Option<u64> {
427 if let ParseState::Placeholder {
428 generation,
429 spawned,
430 ..
431 } = &mut self.parse_state
432 && !*spawned
433 {
434 *spawned = true;
435 return Some(*generation);
436 }
437 None
438 }
439
440 /// Install the result of a background full parse. No-op when
441 /// the editor has advanced past `generation` — that result is
442 /// stale and a fresh spawn is already in flight. Invalidates the
443 /// layout + rendered_cache so the next `update()` rebuilds Gate
444 /// 2 against the fresh `ParsedBuffer`.
445 pub fn install_full_parse(&mut self, generation: u64, buf: ParsedBuffer) {
446 if generation != self.last_seen_generation {
447 return; // stale
448 }
449 self.parse_state = ParseState::Real(buf);
450 self.fence_ranges =
451 super::parse_incremental::fence_ranges_from_kinds(&self.parse_state.buf().kinds);
452 // Force Gate 2 full rebuild on the next update: the
453 // placeholder's all-Plain kinds produced different fence
454 // ranges and rendered masks than the real parse will.
455 self.last_text_change = TextChangeKind::Full;
456 self.last_layout_generation = u64::MAX;
457 }
458
459 /// Returns `Some(job)` if Gate 2 just installed a `Layout::unwrapped`
460 /// stub and the owning component should spawn a background
461 /// `Layout::compute` for it. Consumes the flag so the owner does not
462 /// spawn twice; the owner is responsible for calling
463 /// `install_full_layout` when the task completes.
464 pub fn take_pending_full_layout(&mut self) -> Option<PendingLayoutJob> {
465 let pending = self.layout_pending.as_mut()?;
466 if pending.spawned {
467 return None;
468 }
469 pending.spawned = true;
470 Some(PendingLayoutJob {
471 generation: pending.generation,
472 text: self.text_snapshot.clone(),
473 width: self.last_layout_width as usize,
474 rendered_cache: self.rendered_cache.clone(),
475 gutter_insets: self.gutter_insets.clone(),
476 })
477 }
478
479 /// Install the result of a background `Layout::compute`. No-op when the
480 /// editor has advanced past `generation` (a fresh spawn is already in
481 /// flight — mirrors `install_full_parse`'s staleness gate) or when the
482 /// pane was resized since the job was captured (`layout.width()` no
483 /// longer matches `last_layout_width` — a generation match alone
484 /// cannot catch this, since a resize with no content change never
485 /// bumps `content_revision`).
486 pub fn install_full_layout(&mut self, generation: u64, layout: Layout) {
487 if generation != self.last_seen_generation
488 || layout.width() != self.last_layout_width as usize
489 {
490 return; // stale
491 }
492 self.layout = layout;
493 self.layout_pending = None;
494 // The wrapped layout numbers visual rows differently from the stub, so
495 // a detached top would now point somewhere else in the note.
496 self.detached = false;
497 self.last_layout_generation = generation;
498 }
499
500 /// Full (non-incremental) layout rebuild: synchronous on a small
501 /// buffer, deferred to a background task on a large one — the
502 /// layout-side twin of Gate 1's placeholder-parse fallback. Called
503 /// from every Gate 2 branch that would otherwise call
504 /// `Layout::compute` unconditionally.
505 fn full_layout_rebuild(
506 &mut self,
507 text: &crate::ropetext::Text,
508 width: u16,
509 row_count: usize,
510 generation: u64,
511 ) {
512 if row_count >= Self::LARGE_BUFFER_THRESHOLD {
513 self.layout = Layout::unwrapped(text);
514 self.layout_pending = Some(PendingLayout {
515 generation,
516 spawned: false,
517 });
518 } else {
519 let hints = row_hints(&self.rendered_cache, &self.gutter_insets);
520 self.layout = Layout::compute(text, width as usize, Metrics::default(), &hints);
521 self.layout_pending = None;
522 }
523 }
524
525 /// Hand the view this frame's **overlays**, in logical coordinates. Must be
526 /// called *after* `update`, which clears them.
527 ///
528 /// The view appends the two kinds it derives itself — task decorations and
529 /// **needle** emphasis. Those come from the lines it already holds, and it
530 /// is the only thing that knows which rows are visible, so scanning them
531 /// anywhere else would mean shipping the viewport outward.
532 pub fn set_overlays(&mut self, overlays: Vec<Overlay>) {
533 self.overlays = overlays;
534 self.derive_content_overlays();
535 }
536
537 /// Derive task and needle overlays for the visible rows only.
538 ///
539 /// This replaces a post-pass over drawn terminal cells, which reconstructed
540 /// row text with a byte→column map purely because it ran after render. That
541 /// put it in a different coordinate space from everything else, and cost a
542 /// defect: a find pattern targeting concealed markdown counted and stepped
543 /// to matches it could never paint.
544 fn derive_content_overlays(&mut self) {
545 let scroll = self.viewport.top();
546 let height = self.viewport.height();
547 let rows: Vec<usize> = self
548 .layout
549 .visual_lines()
550 .iter()
551 .skip(scroll)
552 .take(height)
553 .map(|vl| vl.logical_row)
554 .collect();
555 let mut seen = usize::MAX;
556 for row in rows {
557 if row == seen {
558 continue; // wrapped continuation of a row already handled
559 }
560 seen = row;
561 let Some(line) = self.text_snapshot.line(row) else {
562 continue;
563 };
564 // Task checkboxes: optional indent, then `- [ ] ` / `- [x] `.
565 let indent = line.len() - line.trim_start().len();
566 let after = &line[indent..];
567 let done = after.starts_with("- [x] ") || after.starts_with("- [X] ");
568 if done || after.starts_with("- [ ] ") {
569 let box_start = line[..indent].chars().count() + 2;
570 self.overlays.push(Overlay::new(
571 row,
572 box_start,
573 box_start + 3,
574 OverlayKind::TaskBox,
575 ));
576 if done {
577 self.overlays.push(Overlay::new(
578 row,
579 box_start + 3,
580 line.chars().count(),
581 OverlayKind::TaskDone,
582 ));
583 }
584 }
585 // Needle emphasis, over the logical line rather than drawn cells.
586 let line = line.as_ref();
587 for (s, e) in crate::components::preview_highlight::match_ranges(line, &self.needles) {
588 let start = line[..s].chars().count();
589 let end = start + line[s..e].chars().count();
590 self.overlays
591 .push(Overlay::new(row, start, end, OverlayKind::Needle));
592 }
593 }
594 }
595
596 /// Vault-search **needles** to emphasise. Sticky across frames, unlike
597 /// overlays: they come from the query that opened the note.
598 pub fn set_needles(&mut self, needles: Vec<String>) {
599 self.needles = needles;
600 }
601
602 /// Declare that the edit just performed was a *bulk* one — it changed rows
603 /// the cursor does not point at.
604 ///
605 /// `compute_damage_range`'s fast path trusts the cursor row to be the only
606 /// edited row and will otherwise under-report the damage, leaving distant
607 /// rows rendered from a stale parse. Every edit that rewrites more than the
608 /// cursor's neighbourhood must call this.
609 /// Forget where a run of ↑/↓ was aiming. Any other cursor movement ends it.
610 pub fn clear_visual_goal(&mut self) {
611 self.visual_goal = None;
612 }
613
614 /// Move the cursor one *drawn* line, which is what an arrow key means in a
615 /// wrapped editor: one press moves one line the reader can see, not past the
616 /// whole remainder of a soft-wrapped paragraph.
617 ///
618 /// Lives on the view because only the view has the layout — the buffer holds
619 /// the text and the cursor, and neither alone can answer "which line is this
620 /// drawn on". That split is exactly why the incumbent could not do this.
621 ///
622 /// Returns `false` when the layout does not describe the buffer's current
623 /// text — an edit lands before the frame that re-lays it out — and the caller
624 /// falls back to a logical move rather than reading a stale layout.
625 pub fn move_cursor_visually(&mut self, buf: &mut RopeBuffer, down: bool, extend: bool) -> bool {
626 let text = buf.text().clone();
627 // Exactly, not approximately: comparing row counts missed every edit that
628 // stayed inside one row, and the stale byte ranges then sliced out of
629 // bounds — `end byte index 4 is out of bounds for string of length 1`.
630 if !self.layout.describes(&text) {
631 return false;
632 }
633 let Some(cursor) = text.position(buf.cursor().0, Column::new(buf.cursor().1)) else {
634 return false;
635 };
636 let hints = row_hints(&self.rendered_cache, &self.gutter_insets);
637 let goal = self
638 .visual_goal
639 .unwrap_or_else(|| self.layout.cell_of(&text, &hints, cursor).column);
640 let landed = motion::visual_vertical(
641 &text,
642 &self.layout,
643 &hints,
644 cursor,
645 if down { 1 } else { -1 },
646 motion::VisualGoal::Cell(goal),
647 );
648 self.visual_goal = Some(goal);
649
650 if extend {
651 if buf.selection_range().is_none() {
652 buf.start_selection();
653 }
654 } else {
655 buf.cancel_selection();
656 }
657 buf.move_to(landed);
658 true
659 }
660
661 /// Record which rows an edit changed, for the next `update` to act on.
662 ///
663 /// `line_delta` is what this edit did to the row count. Several edits can
664 /// land between two frames, and a range recorded before one of them that
665 /// moved rows no longer means what it said — so the accumulated hull is
666 /// brought into the new numbering first. This matters more than it used to:
667 /// the layout now patches across a line-count change rather than rebuilding,
668 /// so an under-reported hull leaves rows wrapped as they used to be.
669 pub fn note_damage(&mut self, rows: std::ops::Range<usize>, line_delta: isize) {
670 self.reported_damage = Some(match self.reported_damage.take() {
671 Some(seen) => {
672 let seen = super::rope_buffer::shift_rows(seen, rows.start, line_delta);
673 seen.start.min(rows.start)..seen.end.max(rows.end)
674 }
675 None => rows,
676 });
677 }
678
679 pub fn note_bulk_edit(&mut self) {
680 self.bulk_edit_pending = true;
681 }
682
683 pub fn update(&mut self, snap: &super::snapshot::EditorSnapshot, rect: Rect) {
684 self.viewport.set_height(rect.height as usize);
685 // Snapshot owns the (cursor, lines, content_revision) atomicity
686 // — readers below can index `parsed_buffer.lines[cursor.0]`
687 // without `.get()` guards once Gate 1 has rebuilt the parse
688 // cache from these same `lines`.
689 let text = &snap.text;
690 let row_count = text.line_count();
691 let cursor = snap.cursor;
692 let generation = snap.content_revision.get();
693 // Overlays belong to the snapshot they were built from. Clearing here
694 // means a caller that stops previewing (or closes the find bar) cannot
695 // leave stale ones painted over real text — it simply stops setting
696 // them.
697 self.overlays.clear();
698 if rect.height == 0 {
699 return;
700 }
701
702 // Gate 1: content changed — rebuild parse cache and snapshots.
703 //
704 // The layout gate below wants the same report. The parse cache cannot
705 // splice across a line-count change — `ParsedBuffer::splice` requires the
706 // replacement to have as many rows as it replaces — but the layout can,
707 // so the two must not share a verdict. This carries the report past Gate
708 // 1's `take` rather than re-deriving it.
709 let mut reported_for_layout: Option<std::ops::Range<usize>> = None;
710 if generation != self.last_seen_generation {
711 let reported = self.reported_damage.take();
712 reported_for_layout = reported.clone();
713 let incremental = if self.parse_state.is_placeholder() {
714 None
715 } else {
716 self.try_incremental_parse(text, cursor, reported)
717 };
718 // Consumed here, not inside `try_incremental_parse`: the flag must
719 // clear even on the placeholder path above, or a bulk edit
720 // followed by a keystroke would still be suppressing the hint.
721 self.bulk_edit_pending = false;
722 self.last_text_change = match incremental {
723 Some((range, slice, path)) => {
724 self.parse_state.splice_real(range.clone(), slice);
725 self.last_parse_was_incremental = true;
726 self.last_splice_path = Some(path);
727 TextChangeKind::Incremental(range)
728 }
729 None => {
730 if row_count >= Self::LARGE_BUFFER_THRESHOLD {
731 // Async fallback: install a structurally-
732 // correct but unstyled placeholder so this
733 // frame can paint immediately; defer the
734 // real pulldown parse to a background tokio
735 // task spawned by the owning component (see
736 // `take_pending_full_parse` / `install_full_parse`).
737 // The placeholder has the same row count as
738 // `lines`, so the downstream Gate 2 / render
739 // path stays in-bounds; only the markdown
740 // styling is missing for one frame.
741 self.parse_state = ParseState::Placeholder {
742 buf: ParsedBuffer::placeholder(&snap.text),
743 generation,
744 spawned: false,
745 };
746 } else {
747 self.parse_state = ParseState::Real(ParsedBuffer::parse(&snap.text));
748 }
749 self.last_parse_was_incremental = false;
750 self.last_splice_path = None;
751 TextChangeKind::Full
752 }
753 };
754 #[cfg(debug_assertions)]
755 if self.last_parse_was_incremental && verify_incremental_enabled() {
756 let fresh = ParsedBuffer::parse(&snap.text);
757 assert_eq!(
758 self.parse_state.buf().kinds,
759 fresh.kinds,
760 "incremental kinds diverge from full parse at generation={generation}"
761 );
762 assert_eq!(
763 self.parse_state.buf().lazy_depth,
764 fresh.lazy_depth,
765 "incremental lazy_depth diverges from full parse at generation={generation}"
766 );
767 assert_eq!(
768 self.parse_state.buf().reset_boundaries,
769 fresh.reset_boundaries,
770 "incremental reset_boundaries diverge from full parse at generation={generation}"
771 );
772 assert_eq!(
773 self.parse_state.buf().lines.len(),
774 fresh.lines.len(),
775 "incremental lines.len() diverges from full parse at generation={generation}"
776 );
777 for (i, (got, exp)) in self
778 .parse_state
779 .buf()
780 .lines
781 .iter()
782 .zip(fresh.lines.iter())
783 .enumerate()
784 {
785 got.debug_assert_eq_to(exp, i);
786 }
787 }
788 // Skip on a successful incremental splice: `try_incremental_parse`
789 // already refuses to splice any edit that could flip a row into
790 // or out of a fence/indented-code/HTML-block role (the
791 // structural-marker and opener-shape guards bail to a full
792 // rebuild first) — so `fence_ranges` is provably identical to
793 // before, and re-scanning the whole `kinds` array to confirm
794 // that would defeat the point of having taken the fast path.
795 if !self.last_parse_was_incremental {
796 self.fence_ranges = super::parse_incremental::fence_ranges_from_kinds(
797 &self.parse_state.buf().kinds,
798 );
799 }
800 // Incremental update of `lines_snapshot` mirrors the parse
801 // path: on the splice path only the rows in `range` can
802 // have changed (try_incremental_parse already bails when
803 // line count differs); on the full-parse fallback we lose
804 // damage info, so re-clone everything.
805 //
806 // `String::clone_from` reuses the destination's existing
807 // allocation when capacity permits, so the typical
808 // single-char insert costs one String reallocation
809 // (often zero — capacity stays put) instead of N.
810 match &self.last_text_change {
811 TextChangeKind::Incremental(_) | TextChangeKind::Full | TextChangeKind::None => {
812 self.text_snapshot = snap.text.clone();
813 }
814 }
815 self.last_seen_generation = generation;
816 } else {
817 self.last_text_change = TextChangeKind::None;
818 }
819
820 self.cursor_snapshot = cursor;
821
822 // Gate 2: layout rebuild.
823 // Skip when content, width, and the *effective element expansion* are all unchanged.
824 // Horizontal cursor movement within the same element (or plain text with no elements)
825 // does not change any wrap boundary — no recompute needed.
826 let new_expanded = self
827 .parse_state
828 .buf()
829 .lines
830 .get(cursor.0)
831 .and_then(|p| p.elem_at(cursor.1));
832 let old_expanded = self
833 .parse_state
834 .buf()
835 .lines
836 .get(self.last_layout_cursor.0)
837 .and_then(|p| p.elem_at(self.last_layout_cursor.1));
838 let need_layout = generation != self.last_layout_generation
839 || rect.width != self.last_layout_width
840 || cursor.0 != self.last_layout_cursor.0
841 || new_expanded != old_expanded;
842
843 if need_layout {
844 let width_changed = rect.width != self.last_layout_width;
845 let cursor_changed = cursor.0 != self.last_layout_cursor.0;
846 let expanded_changed = new_expanded != old_expanded;
847 // Rows whose rendered mask depends on cursor state and may
848 // have flipped this frame: the old and new cursor rows
849 // when the cursor moved between rows, OR the cursor row
850 // when an inline element (link/bold/etc.) was just expanded
851 // or collapsed by a within-row cursor move. Both shapes
852 // change `visible_positions_with`'s `expanded` argument,
853 // so both rendered_cache AND wrap need to re-derive that
854 // row's mask + visual-line splits.
855 let cursor_affected_rows: Vec<usize> = if cursor_changed {
856 let mut rows = vec![self.last_layout_cursor.0, cursor.0];
857 rows.sort();
858 rows.dedup();
859 rows
860 } else if expanded_changed {
861 vec![cursor.0]
862 } else {
863 vec![]
864 };
865 // Drop any row past the current buffer end — happens when a
866 // stale snapshot's cursor row exceeds `lines.len()`. Both
867 // rendered_cache and wrap splices require in-range rows.
868 let cursor_affected_rows: Vec<usize> = cursor_affected_rows
869 .into_iter()
870 .filter(|&r| r < row_count)
871 .collect();
872 // Determine the set of rows to rebuild in rendered_cache.
873 let rebuild_strategy = if self.rendered_cache.len() != row_count {
874 // Line count differs → full rebuild required.
875 RenderedCacheRebuild::Full
876 } else {
877 match &self.last_text_change {
878 TextChangeKind::Full => RenderedCacheRebuild::Full,
879 TextChangeKind::Incremental(range) => {
880 let mut rows: Vec<usize> = range.clone().collect();
881 rows.extend(cursor_affected_rows.iter().copied());
882 rows.sort();
883 rows.dedup();
884 RenderedCacheRebuild::Rows(rows)
885 }
886 TextChangeKind::None => {
887 if cursor_affected_rows.is_empty() {
888 RenderedCacheRebuild::None
889 } else {
890 RenderedCacheRebuild::Rows(cursor_affected_rows.clone())
891 }
892 }
893 }
894 };
895
896 // Width-only change: masks are width-independent; skip rendered_cache rebuild.
897 let _ = width_changed; // acknowledged: width doesn't affect rendered_cache
898 match rebuild_strategy {
899 RenderedCacheRebuild::Full => {
900 self.rendered_cache = text
901 .lines()
902 .enumerate()
903 .map(|(i, l)| {
904 let force_raw = self.is_in_code_block(i);
905 let cursor_col = if i == cursor.0 { Some(cursor.1) } else { None };
906 MarkdownSpanner::visible_positions_with(
907 &l,
908 &self.parse_state.buf().lines[i],
909 cursor_col,
910 force_raw,
911 )
912 })
913 .collect();
914 }
915 RenderedCacheRebuild::Rows(rows) => {
916 for row in rows {
917 if row >= row_count {
918 continue; // defensive
919 }
920 let force_raw = self.is_in_code_block(row);
921 let cursor_col = if row == cursor.0 {
922 Some(cursor.1)
923 } else {
924 None
925 };
926 let new_entry = MarkdownSpanner::visible_positions_with(
927 &text.line(row).unwrap_or_default(),
928 &self.parse_state.buf().lines[row],
929 cursor_col,
930 force_raw,
931 );
932 if let Some(entry) = self.rendered_cache.get_mut(row) {
933 *entry = new_entry;
934 }
935 }
936 }
937 RenderedCacheRebuild::None => {
938 // Width-only change or no change: masks are width-independent; nothing to rebuild.
939 }
940 }
941
942 // Width-aware wrap path:
943 // - Width change or line-count change: full recompute (wrap
944 // depends on width; visual_lines indexing depends on row count).
945 // - TextChangeKind::Full: full recompute.
946 // - TextChangeKind::Incremental(range): splice the edited
947 // rows plus any cursor-affected rows whose mask flipped.
948 // - TextChangeKind::None: splice only the cursor-affected
949 // rows. Wrap depends on the rendered mask
950 // (`wrap_one_row` reads `rendered_row`), and the mask is
951 // cursor-position-sensitive whenever the cursor crosses
952 // an inline element boundary — same row or different
953 // row.
954 // Full rebuild only on a genuine full-rebuild frame (or a
955 // length mismatch, defensively) — otherwise the structural
956 // guards that gate the incremental splice already guarantee no
957 // row's blockquote depth changed, so only the rows the cursor
958 // just left or entered need a fresh inset.
959 if matches!(self.last_text_change, TextChangeKind::Full)
960 || self.gutter_insets.len() != row_count
961 {
962 self.rebuild_gutter_insets(row_count, cursor.0);
963 } else if !cursor_affected_rows.is_empty() {
964 self.patch_gutter_insets(&cursor_affected_rows, cursor.0);
965 }
966 let line_count_changed = self.layout.row_count() != row_count;
967 // A stub is still outstanding from an earlier full rebuild and
968 // content changed again this frame: re-stub for the new
969 // generation rather than let an incremental relayout patch a
970 // couple of rows while the rest of the buffer stays permanently
971 // unwrapped waiting on a background result that will land too
972 // late (stale-generation) to matter. Mirrors Gate 1's
973 // `is_placeholder()` gate — a cursor-only frame (`None`) leaves
974 // an in-flight job alone rather than aborting it for nothing.
975 let stub_still_pending = self.layout_pending.is_some()
976 && !matches!(self.last_text_change, TextChangeKind::None);
977 // A line-count change used to force a full re-wrap. It does not have
978 // to: `relayout_rows` takes a delta and renumbers the rows after the
979 // edit, and the delta is knowable without plumbing — the layout knows
980 // how many rows it was built for, and the text knows how many it has
981 // now. What was missing is the damaged range, and Gate 1 was handed
982 // one. Only the *parser* is obliged to give up here.
983 let relayout_across_line_change = line_count_changed
984 .then(|| reported_for_layout.clone())
985 .flatten()
986 .filter(|rows| rows.end <= row_count);
987 if width_changed || stub_still_pending {
988 self.full_layout_rebuild(&snap.text, rect.width, row_count, generation);
989 } else if let Some(rows) = relayout_across_line_change {
990 let delta = row_count as isize - self.layout.row_count() as isize;
991 let hints = row_hints(&self.rendered_cache, &self.gutter_insets);
992 self.layout.relayout_rows(&snap.text, &hints, rows, delta);
993 } else if line_count_changed {
994 self.full_layout_rebuild(&snap.text, rect.width, row_count, generation);
995 } else {
996 match &self.last_text_change {
997 TextChangeKind::Full => {
998 self.full_layout_rebuild(&snap.text, rect.width, row_count, generation);
999 }
1000 TextChangeKind::Incremental(range) => {
1001 let start = range
1002 .start
1003 .min(cursor_affected_rows.first().copied().unwrap_or(range.start));
1004 let end = range.end.max(
1005 cursor_affected_rows
1006 .last()
1007 .copied()
1008 .map(|r| r + 1)
1009 .unwrap_or(range.end),
1010 );
1011 let hints = row_hints(&self.rendered_cache, &self.gutter_insets);
1012 // Line count is unchanged on this path — the caller above
1013 // takes the full-recompute branch when it is not — so the
1014 // relayout shifts nothing.
1015 self.layout.relayout_rows(&snap.text, &hints, start..end, 0);
1016 }
1017 TextChangeKind::None => {
1018 if let (Some(&first), Some(&last)) =
1019 (cursor_affected_rows.first(), cursor_affected_rows.last())
1020 {
1021 let hints = row_hints(&self.rendered_cache, &self.gutter_insets);
1022 self.layout
1023 .relayout_rows(&snap.text, &hints, first..last + 1, 0);
1024 }
1025 }
1026 }
1027 }
1028 // Code-box widths depend only on text content and the wrap width,
1029 // not the cursor — so skip the (grapheme-walking) rebuild on
1030 // cursor-only moves, where neither changed. A width change caps
1031 // every block afresh regardless of content, so it always forces
1032 // the full rebuild; a successful incremental splice narrows to
1033 // just the block(s) overlapping the edited range — the same
1034 // structural guards mean any OTHER block's boundaries (and thus
1035 // whether it needs re-measuring at all) can't have moved.
1036 if width_changed
1037 || matches!(self.last_text_change, TextChangeKind::Full)
1038 || self.code_box_width.len() != row_count
1039 {
1040 self.rebuild_code_box_width(text, rect.width);
1041 } else if let TextChangeKind::Incremental(range) = &self.last_text_change {
1042 self.patch_code_box_width(text, rect.width, range.clone());
1043 }
1044 self.last_layout_generation = generation;
1045 self.last_layout_width = rect.width;
1046 self.last_layout_cursor = cursor;
1047 }
1048
1049 // Cache cursor_vrow for render() — avoids a second lookup there.
1050 //
1051 // Falling back to the last known row, not to zero. The text being asked
1052 // is not always the buffer's: `render` builds a snapshot from the
1053 // **replace preview**'s rows paired with the real cursor, and a
1054 // replacement shorter than what it replaces leaves that cursor past the
1055 // end of the previewed row. Answering "row 0" then scrolls the note to
1056 // the top while the user is still typing in the replace field. The
1057 // preview cannot place a cursor that does not belong to it, so the honest
1058 // answer is to leave the viewport where it was.
1059 self.cursor_vrow = snap
1060 .text
1061 .position(cursor.0, Column::new(cursor.1))
1062 .map(|at| self.layout.visual_row_of(at))
1063 .unwrap_or(self.cursor_vrow);
1064 let drawn = (generation, cursor, rect.width);
1065 if drawn != self.last_drawn {
1066 self.last_drawn = drawn;
1067 self.detached = false;
1068 }
1069 if !self.detached {
1070 self.viewport.follow_row(self.cursor_vrow);
1071 }
1072 // Never leave blank rows below the last line while content sits above
1073 // the viewport. Without this, a resize that grows the pane keeps the
1074 // top it had when small, showing only the tail of the note until the
1075 // cursor moves up. A followed cursor stays visible: it lies below the
1076 // old top and before the last row.
1077 self.viewport.clamp(&self.layout);
1078 }
1079
1080 /// Mouse-wheel scroll: move the viewport by `delta` visual rows without
1081 /// moving the cursor, never past the first or last line. Only a notch that
1082 /// moves the view detaches it — a saturated one must not stop the view
1083 /// following the cursor. Returns whether the view moved.
1084 pub fn scroll_by(&mut self, delta: isize) -> bool {
1085 let moved = self.viewport.scroll_by(&self.layout, delta);
1086 self.detached |= moved;
1087 moved
1088 }
1089
1090 /// Re-attach the viewport to the cursor: the next `update` scrolls the
1091 /// least amount that brings it on screen. For any input that acts on the
1092 /// cursor — including one that leaves it where it was, like a find-next
1093 /// wrapping onto the same match.
1094 pub fn follow_cursor(&mut self) {
1095 self.detached = false;
1096 }
1097
1098 /// Attempt an incremental Gate-1 parse.
1099 ///
1100 /// Returns `Some((range, slice, path))` when the damage can be
1101 /// cheaply isolated and widened to safe boundaries; `None` when
1102 /// the caller should fall back to a fresh full-buffer
1103 /// `ParsedBuffer::parse`. The `path` indicates which widener
1104 /// tier produced the splice (see [`SplicePath`]).
1105 fn try_incremental_parse(
1106 &self,
1107 text: &crate::ropetext::Text,
1108 cursor: (usize, usize),
1109 reported: Option<std::ops::Range<usize>>,
1110 ) -> Option<(std::ops::Range<usize>, ParsedBuffer, SplicePath)> {
1111 use super::parse_incremental::{
1112 LineConstructKind, WidenResult, compute_damage_range, expand_to_reset_boundary,
1113 widen_to_safe,
1114 };
1115 use super::widener_metrics::{BailReason, METRICS, SuccessPath};
1116
1117 if self.parse_state.buf().lines.is_empty() {
1118 return None; // First parse — no snapshot to diff against. Uncategorised.
1119 }
1120 // Line count changes (insertions/deletions) require a full rebuild:
1121 // the widened range covers the same number of lines in the new buffer
1122 // as in the old kinds array, so a splice cannot reconcile the length
1123 // mismatch.
1124 if text.line_count() != self.parse_state.buf().lines.len() {
1125 return METRICS.bail(BailReason::LineCountChange);
1126 }
1127 // The row-by-row guards below read the previous content, so it has to
1128 // describe the same buffer shape. It does not on the first update, and an
1129 // empty text still has one row — so "no previous state" cannot be inferred
1130 // from the parse cache being empty.
1131 if self.text_snapshot.line_count() != text.line_count() {
1132 return METRICS.bail(BailReason::LineCountChange);
1133 }
1134 // A bulk edit invalidates the cursor hint: pass `usize::MAX` so the
1135 // fast path's `cursor_row < old.len()` test fails and the LCP/LCS slow
1136 // path computes the real span. The flag is cleared by `update` whether
1137 // or not the incremental attempt gets this far.
1138 let hint = if self.bulk_edit_pending {
1139 usize::MAX
1140 } else {
1141 cursor.0
1142 };
1143 // Told, not found: the engine knows which rows its own edit touched, so the
1144 // only reason to compare the buffer with a copy of its previous self is
1145 // that nobody told us — a whole-buffer replacement, or the **nvim**
1146 // backend, which reports lines rather than changes.
1147 // Set when the lazy-depth relaxation admits a kind that is only safe
1148 // because of the downstream verify after the splice. `ListMarker` never
1149 // sets it: it was proven safe without one, and making it pay for the
1150 // verify regressed blank-free buffers ~7x, because a sparse boundary set
1151 // sends the verify to the end of the note.
1152 let mut needs_downstream_verify = false;
1153 let damaged = match reported {
1154 Some(rows) if rows.end <= text.line_count() => rows,
1155 _ => {
1156 // Only this path needs the previous content as rows, and only
1157 // because it has to compare. Materialising it here keeps that cost
1158 // where the comparison is, rather than on every edit.
1159 let previous: Vec<String> =
1160 self.text_snapshot.lines().map(|l| l.to_string()).collect();
1161 let current: Vec<String> = text.lines().map(|l| l.to_string()).collect();
1162 let Some(damaged) = compute_damage_range(&previous, ¤t, hint) else {
1163 return METRICS.bail(BailReason::NoDamage);
1164 };
1165 damaged
1166 }
1167 };
1168 if damaged.is_empty() {
1169 return METRICS.bail(BailReason::NoDamage);
1170 }
1171
1172 // Structural-marker change guard: any edit that converts a fence
1173 // marker line into a non-marker (or vice versa) can shift the
1174 // fence's extent beyond the widening window. Same for setext
1175 // underlines. Conservative fallback to full parse for correctness.
1176 for row in damaged.clone() {
1177 let old_kind = self.parse_state.buf().kinds[row];
1178 let previous_row = self.text_snapshot.line(row).unwrap_or_default();
1179 let old_line = previous_row.as_ref();
1180 let current_row = text.line(row).unwrap_or_default();
1181 let new_line = current_row.as_ref();
1182
1183 // Old kind was a structural marker whose role an in-place edit
1184 // can change (fence opener↔closer↔content, setext underline
1185 // re-heading the line above) or which lazy-extends past the
1186 // widening window (indented code / HTML block per CommonMark
1187 // §4.4 / §4.6). These read pulldown's real classification, so
1188 // any edit on such a row punts to a full parse.
1189 if matches!(
1190 old_kind,
1191 LineConstructKind::FenceMarker
1192 | LineConstructKind::SetextUnderline
1193 | LineConstructKind::IndentedCode
1194 | LineConstructKind::HtmlBlock
1195 ) {
1196 return METRICS.bail(BailReason::KindGuard);
1197 }
1198 // Context-free block-opener shape flip: the edit gained or lost
1199 // a fence / setext / indented-code / HTML / list / blockquote
1200 // opener shape. Any such flip can open or close a (possibly
1201 // lazy-continuable) construct that reshapes the document beyond
1202 // the widening window — e.g. `"x"` → `"* x"` next to a
1203 // blank-separated list leaks a loose-list merge. Comparing the
1204 // whole `OpenerShape` catches a flip in any field at once.
1205 if opener_shape(new_line) != opener_shape(old_line) {
1206 return METRICS.bail(BailReason::KindGuard);
1207 }
1208
1209 // V2 lazy-construct neighbourhood guard: edit at row R
1210 // can re-shape a lazy construct open at R-1, R, or R+1.
1211 // R-1: blockquote paragraph lazy-continuation across a
1212 // former blank (§5.1). R: edit inside the construct. R+1:
1213 // paragraph eating a would-be IndentedCode start.
1214 //
1215 // §3.0 conditional relaxation (intra-construct-reset-boundaries):
1216 // when the damaged row's old kind is ListMarker AND
1217 // lazy_depth[row] == 1 (a top-level list, not nested inside
1218 // an outer lazy construct), the bail is skipped. List-marker
1219 // content edits are safe by construction: per-row
1220 // ListMarker/ListContinuation classification stays identical
1221 // across slice-vs-parent, and rows past widened.end are
1222 // unaffected by the slice's list-vs-non-list determination.
1223 // The widener's heuristic tier (widen_to_safe over the
1224 // loose-list blanks; or, on small buffers, the strict tier
1225 // widening to the whole buffer) takes the splice. The
1226 // post-slice verify backs this. The opener-shape /
1227 // blank-transition flips run as
1228 // separate guards above and below this check, so the relax
1229 // only ever fires on pure content edits.
1230 //
1231 // Initial relaxation also accepted ListContinuation +
1232 // Blockquote + Plain and arbitrary lazy_depth; both unlocks
1233 // reverted after the 100k proptest soak exposed downstream-
1234 // row-classification flips past widened.end that the
1235 // post-slice verify (which only covers rows INSIDE widened)
1236 // doesn't catch. The deeper fix is a post-widening sanity
1237 // check on `widened.end + 1` — see the design doc's
1238 // "Blockquote/Plain/ListContinuation unlocks" follow-up.
1239 let lazy = &self.parse_state.buf().lazy_depth;
1240 if lazy.is_empty() {
1241 // (see `needs_downstream_verify` below)
1242 // Defensive: invariant violation (lazy_depth.len() should
1243 // match lines.len()). Count as KindGuard to keep the
1244 // attempted-vs-success accounting consistent.
1245 return METRICS.bail(BailReason::KindGuard);
1246 }
1247 let lo = row.saturating_sub(1);
1248 let hi = (row + 1).min(lazy.len() - 1);
1249 if lazy[lo..=hi].iter().any(|&d| d > 0) {
1250 // §3.0 conditional relaxation — TIGHT VERSION.
1251 // Qualifying conditions (narrowed across two soak
1252 // rounds — see openspec change for the rationale):
1253 // - old_kind == ListMarker (NOT ListContinuation)
1254 // - lazy_depth[row] == 1 (top-level list only)
1255 //
1256 // ListContinuation rows are excluded after the 100k
1257 // soak surfaced a case where an edit on a
1258 // ListContinuation row (specifically a `> ` row
1259 // inside a list, lazy_depth=1) caused the row AT
1260 // `damaged.end` (a blank, lazy_depth=0 in pre-edit)
1261 // to flip to ListContinuation in post-edit fresh
1262 // parse. The strict reset boundary at that row was
1263 // valid pre-edit but became invalid post-edit, and
1264 // the splice chose a widened range based on
1265 // pre-edit boundaries that didn't capture the new
1266 // row past `widened.end`.
1267 //
1268 // ListMarker rows are immune: a content edit on
1269 // "- a" → "- aX" cannot change row+1's classification
1270 // because the row+1 was either (a) Plain → became
1271 // ListContinuation via the post-pass regardless of
1272 // the edit, or (b) Blank/something-else that's outside
1273 // the list and unaffected by item-content changes.
1274 //
1275 // The depth==1 clause blocks edits on lists nested
1276 // inside another lazy construct (a list inside a
1277 // blockquote) where the OUTER construct can shift.
1278 //
1279 // Blockquote / Plain / ListContinuation unlocks remain
1280 // deferred. A post-widening sanity check on
1281 // `widened.end + 1` was the proposed fix — re-parse one
1282 // extra row and compare it against the parent to catch a
1283 // downstream flip. It was built and measured against the
1284 // soak in `widener_soak` below, and it does NOT work:
1285 // with the unlock applied, the soak still diverges on
1286 // `lazy_depth` within a few thousand cases, whether the
1287 // check compares `kinds` alone or `kinds` and
1288 // `lazy_depth` together. The flip lands further out than
1289 // one row, so a fixed one-row lookahead cannot see it.
1290 // Whatever closes this has to bound how far a
1291 // reclassification can travel, or verify to the next
1292 // reset boundary rather than to the next row.
1293 //
1294 // Unlocked kinds and their price. `ListMarker` is safe on its
1295 // own — a content edit on `- a` cannot reclassify row+1 — and a
1296 // 100k soak backs that, so it pays nothing extra. `Blockquote`
1297 // and `ListContinuation` are not safe on their own: they
1298 // reclassify rows past `widened.end`, and they are admitted here
1299 // only because the downstream verify below covers exactly that
1300 // distance. The flag is what keeps that cost on the splices that
1301 // need it rather than on every splice.
1302 let relaxed_kind = matches!(
1303 old_kind,
1304 LineConstructKind::Blockquote(_) | LineConstructKind::ListContinuation
1305 );
1306 let kind_qualifies =
1307 matches!(old_kind, LineConstructKind::ListMarker) || relaxed_kind;
1308 let depth_qualifies = row < lazy.len() && lazy[row] == 1;
1309 if kind_qualifies && depth_qualifies {
1310 // Don't bail — let blank-transition guard run
1311 // and reach the widener stage.
1312 needs_downstream_verify = relaxed_kind;
1313 } else {
1314 return METRICS.bail(BailReason::LazyDepth);
1315 }
1316 }
1317
1318 // V2 blank-transition guard: a row flipping between blank
1319 // and non-blank invalidates the pre-edit reset boundary
1320 // at that row in the post-edit world (paragraph lazy-
1321 // continuation, empty list-item shapes like `*` that
1322 // parse as ListMarker in slice but as paragraph
1323 // continuation in full). Use the pre-edit `kinds` for
1324 // the "blank" classification instead of `line.trim()` so
1325 // the predicate matches the parser's view exactly.
1326 let old_blank = matches!(old_kind, LineConstructKind::Blank);
1327 let new_blank = new_line.trim().is_empty();
1328 if old_blank != new_blank {
1329 let above_non_blank = row > 0
1330 && !matches!(
1331 self.parse_state.buf().kinds[row - 1],
1332 LineConstructKind::Blank
1333 );
1334 let below_non_blank = row + 1 < self.parse_state.buf().kinds.len()
1335 && !matches!(
1336 self.parse_state.buf().kinds[row + 1],
1337 LineConstructKind::Blank
1338 );
1339 if above_non_blank || below_non_blank {
1340 return METRICS.bail(BailReason::BlankTransition);
1341 }
1342 }
1343 }
1344
1345 // Two-tier widener:
1346 //
1347 // 1. `expand_to_reset_boundary(reset_boundaries, ...)` —
1348 // strict. Provably equivalent to a fresh parse; no
1349 // post-slice verify needed.
1350 // 2. `widen_to_safe` — heuristic fallback. NOT provably
1351 // equivalent; the post-slice verify (below, release-on)
1352 // is the correctness mechanism and bails to a full
1353 // rebuild on any divergence.
1354 //
1355 // After a §3.0 relax fires the strict widener usually
1356 // cap-trips (lazy_depth > 0 around the edit means no nearby
1357 // blank-with-depth-0 reset boundary), but we still try strict
1358 // first — it costs only a binary search and succeeds in
1359 // degenerate cases (e.g. small buffers where strict widens
1360 // safely to the whole buffer). On failure we fall to
1361 // widen_to_safe.
1362 //
1363 // A former middle tier (`intra_construct_boundaries`, the V3
1364 // "IntraConstruct" path) was removed: it fired only on loose-
1365 // list edits and `widen_to_safe` covers every such case with
1366 // zero extra full rebuilds (measured), differing only in
1367 // reparse span (~11 vs ~2 rows — both far under the 256 cap).
1368 let mut splice_path = SplicePath::Strict;
1369 let widened = match expand_to_reset_boundary(
1370 &self.parse_state.buf().reset_boundaries,
1371 self.parse_state.buf().lines.len(),
1372 damaged.clone(),
1373 ) {
1374 WidenResult::Widened(r) => r,
1375 WidenResult::FullRebuild => {
1376 match widen_to_safe(&self.parse_state.buf().kinds, damaged.clone()) {
1377 WidenResult::Widened(r) => {
1378 splice_path = SplicePath::Heuristic;
1379 r
1380 }
1381 WidenResult::FullRebuild => return METRICS.bail(BailReason::CapTrip),
1382 }
1383 }
1384 };
1385 let slice = ParsedBuffer::parse_range(text, widened.clone());
1386
1387 // Downstream verification, bounded by the next reset boundary.
1388 //
1389 // Only for the kinds admitted by the relaxation above. The in-window
1390 // verify below cannot see a row the splice does not replace, and a
1391 // one-row lookahead is not enough — measured, the flip travels further.
1392 // `reset_boundaries` is the bound that is not a guess: at such a row
1393 // pulldown's state is provably reset, so no reclassification crosses it.
1394 //
1395 // Evidence: with `Blockquote`/`ListContinuation` admitted and this block
1396 // removed, `widener_soak` diverges within a few thousand cases; with it,
1397 // 100 000 cases pass.
1398 if needs_downstream_verify {
1399 let next_boundary = self
1400 .parse_state
1401 .buf()
1402 .reset_boundaries
1403 .iter()
1404 .copied()
1405 .find(|&b| b > widened.end)
1406 .unwrap_or_else(|| text.line_count())
1407 .min(text.line_count());
1408 if next_boundary > widened.end {
1409 let probe = ParsedBuffer::parse_range(text, widened.start..next_boundary);
1410 let parent = self.parse_state.buf();
1411 for row in widened.end..next_boundary {
1412 let idx = row - widened.start;
1413 if probe.kinds[idx] != parent.kinds[row]
1414 || probe.lazy_depth[idx] != parent.lazy_depth[row]
1415 {
1416 return METRICS.bail(BailReason::DownstreamFlip);
1417 }
1418 }
1419 }
1420 }
1421
1422 // Post-slice undamaged-row verification.
1423 //
1424 // - Strict path: skipped. Provably equivalent to a fresh
1425 // parse (see `reset_boundaries` docstring).
1426 // - Heuristic path: NOT provably equivalent, so this verify
1427 // is the correctness mechanism and runs in release. It is
1428 // cheap: `slice` was already parsed above
1429 // (unconditionally), and the loop only compares
1430 // kinds/elements.len()/content_vis over the `widened` rows —
1431 // bounded by the widen cap (≤256), negligible against the
1432 // parse_range that already ran. A divergence (e.g. a pulldown
1433 // version bump changing tokenisation) bails to a full rebuild
1434 // rather than shipping a corrupt splice. The 600k proptest
1435 // cases (100k × 6 strategies, 0 verify_failed) stay in the
1436 // regression harness; this guard is the release backstop.
1437 let verify_eligible_path = matches!(splice_path, SplicePath::Heuristic);
1438 if verify_eligible_path {
1439 for row in widened.clone() {
1440 if damaged.contains(&row) {
1441 continue; // Damaged row: kind change is expected/irrelevant.
1442 }
1443 let idx = row - widened.start;
1444 if slice.kinds[idx] != self.parse_state.buf().kinds[row] {
1445 return METRICS.bail(BailReason::VerifyFailed);
1446 }
1447 if slice.lines[idx].elements.len()
1448 != self.parse_state.buf().lines[row].elements.len()
1449 {
1450 return METRICS.bail(BailReason::VerifyFailed);
1451 }
1452 if slice.lines[idx].content_vis != self.parse_state.buf().lines[row].content_vis {
1453 return METRICS.bail(BailReason::VerifyFailed);
1454 }
1455 }
1456 }
1457
1458 METRICS.ok(match splice_path {
1459 SplicePath::Strict => SuccessPath::ResetBoundary,
1460 SplicePath::Heuristic => SuccessPath::WidenToSafe,
1461 });
1462 Some((widened, slice, splice_path))
1463 }
1464
1465 pub fn render(
1466 &mut self,
1467 f: &mut Frame,
1468 rect: Rect,
1469 theme: &Theme,
1470 focused: bool,
1471 cursor_shape: Option<CursorShape>,
1472 ) {
1473 if rect.height == 0 {
1474 return;
1475 }
1476 let text = &self.text_snapshot;
1477 let cursor = self.cursor_snapshot;
1478 let scroll = self.viewport.top();
1479 let height = rect.height as usize;
1480 let vlines = self.layout.visual_lines();
1481
1482 let parsed_lines = &self.parse_state.buf().lines;
1483 let fence_ranges = &self.fence_ranges;
1484
1485 // The rows the visible lines draw from, materialised for this frame.
1486 // Bounded by the pane's height rather than the note's length, and needed
1487 // because the spans below borrow their row and outlive the closure that
1488 // builds them.
1489 let window: Vec<String> = vlines
1490 .iter()
1491 .skip(scroll)
1492 .take(height)
1493 .map(|vl| text.line(vl.logical_row).unwrap_or_default().into_owned())
1494 .collect();
1495
1496 let visible: Vec<Line> = vlines
1497 .iter()
1498 .skip(scroll)
1499 .take(height)
1500 .zip(window.iter())
1501 .map(|(vl, row_text)| {
1502 let cursor_col = if vl.logical_row == cursor.0 {
1503 Some(cursor.1)
1504 } else {
1505 None
1506 };
1507 let force_raw = fence_ranges.iter().any(|r| r.contains(&vl.logical_row));
1508 // Snapshot invariant: every `vl.logical_row` is < lines.len()
1509 // because `layout` and `lines_snapshot` were rebuilt from
1510 // the same `EditorSnapshot` in the last `update()`.
1511 let logical_line = row_text.as_str();
1512 let parsed = &parsed_lines[vl.logical_row];
1513 let content = &logical_line[vl.bytes.clone()];
1514 let spans = MarkdownSpanner::render_with(
1515 content,
1516 logical_line,
1517 parsed,
1518 vl.chars.start,
1519 cursor_col,
1520 vl.first,
1521 force_raw,
1522 rect.width,
1523 theme,
1524 );
1525
1526 // Apply code-block background before selection so selection bg wins on selected text.
1527 let spans =
1528 if let Some(bw) = self.code_box_width.get(vl.logical_row).copied().flatten() {
1529 apply_code_box(spans, bw, theme)
1530 } else {
1531 spans
1532 };
1533
1534 // Every highlight this row carries, painted in one pass.
1535 // `OverlayKind`'s declaration order is the stacking order, so
1536 // "preview wins over selection" is a property of the enum
1537 // rather than of where the code happens to sit.
1538 let spans = {
1539 // Skip the hidden `> ` and add the bar back, rather than
1540 // zeroing the offset and letting the mapper credit the
1541 // sigils as the cells the bar occupies. The credit is exact
1542 // only for clusters whose width is column-independent: a tab
1543 // consumes it, measuring to a nearer stop from the inflated
1544 // column, and every overlay at or after it lands short by
1545 // the bar. `click_to_logical_u16` already skips rather than
1546 // credits — this is the same basis, in the same direction.
1547 let gutter_off = self.gutter_insets.get(vl.logical_row).copied().unwrap_or(0);
1548 let effective_start_col = if gutter_off > 0 && vl.first {
1549 parsed.blockquote_sigil_end().unwrap_or(vl.chars.start)
1550 } else {
1551 vl.chars.start
1552 };
1553 let to_rendered = |col: usize| {
1554 MarkdownSpanner::rendered_col_with_reveal(
1555 logical_line,
1556 parsed,
1557 effective_start_col,
1558 col,
1559 cursor_col,
1560 vl.first,
1561 force_raw,
1562 ) + gutter_off
1563 };
1564 let mut row_overlays: Vec<&Overlay> = self
1565 .overlays
1566 .iter()
1567 .filter(|o| o.row == vl.logical_row)
1568 .collect();
1569 row_overlays.sort_by_key(|o| o.kind);
1570
1571 let mut spans = spans;
1572 for o in row_overlays {
1573 let start = to_rendered(o.start);
1574 let mut end = to_rendered(o.end);
1575 // A zero-width overlay would paint nothing — which is
1576 // exactly the case where the user most needs to see
1577 // where they are (an empty replacement previews a match
1578 // as nothing at all). Give it one cell, like a caret.
1579 if end == start && o.kind == OverlayKind::PreviewCurrent {
1580 end = start + 1;
1581 }
1582 spans =
1583 restyle_over_range(spans, start..end, &|st| o.kind.restyle(theme, st));
1584 }
1585 spans
1586 };
1587
1588 Line::from(spans)
1589 })
1590 .collect();
1591
1592 f.render_widget(
1593 Paragraph::new(Text::from(visible)).style(theme.base_style()),
1594 rect,
1595 );
1596
1597 // Draw terminal cursor when focused. The `EditorSnapshot` the
1598 // last `update()` consumed guarantees `cursor.0` is in-bounds
1599 // for `parsed_buffer.lines` and `layout.visual_lines()` —
1600 // both were rebuilt from the same snapshot. The single
1601 // remaining edge case is an empty buffer (no rows at all),
1602 // handled by the early `is_empty` short-circuit below; the
1603 // previous defensive `.get()` chain (commit c03dc728) was
1604 // there to absorb stale Nvim snapshots where cursor outran
1605 // lines, which the snapshot invariant now rules out.
1606 self.last_cursor_screen = None;
1607 let mut desired_style: Option<CursorShape> = None;
1608 if focused
1609 && !self.parse_state.buf().lines.is_empty()
1610 && !self.layout.visual_lines().is_empty()
1611 {
1612 let cursor_vrow = self.cursor_vrow;
1613 if cursor_vrow >= scroll && cursor_vrow < scroll + height {
1614 let vl = &self.layout.visual_lines()[cursor_vrow];
1615 let parsed = &self.parse_state.buf().lines[cursor.0];
1616 // Snapshot invariant + outer `!is_empty()` guard: cursor.0
1617 // is in-bounds for `lines_snapshot` here.
1618 let row_text = text.line(cursor.0).unwrap_or_default();
1619 let logical_line = row_text.as_ref();
1620 let force_raw = self.is_in_code_block(cursor.0);
1621 let rendered_col = MarkdownSpanner::rendered_cursor_col_with(
1622 logical_line,
1623 parsed,
1624 vl.chars.start,
1625 cursor.1,
1626 vl.first,
1627 force_raw,
1628 );
1629 let cx = rect.x + rendered_col as u16;
1630 let cy = rect.y + (cursor_vrow - scroll) as u16;
1631 f.set_cursor_position(Position { x: cx, y: cy });
1632 self.last_cursor_screen = Some((cx, cy));
1633 desired_style = cursor_shape;
1634 }
1635 }
1636 if desired_style != self.applied_cursor_style {
1637 use ratatui::crossterm::cursor::SetCursorStyle;
1638 let style = match desired_style {
1639 Some(CursorShape::Block) => SetCursorStyle::SteadyBlock,
1640 Some(CursorShape::Bar) => SetCursorStyle::SteadyBar,
1641 None => SetCursorStyle::DefaultUserShape,
1642 };
1643 let _ = ratatui::crossterm::execute!(std::io::stdout(), style);
1644 self.applied_cursor_style = desired_style;
1645 }
1646 }
1647
1648 /// Test accessor: the kinds vector of the current parsed buffer.
1649 /// Used by the proptest harness to assert incremental = full parse.
1650 pub fn parsed_buffer_kinds(&self) -> &[super::parse_incremental::LineConstructKind] {
1651 &self.parse_state.buf().kinds
1652 }
1653
1654 /// Test accessor: the parsed lines of the current parsed buffer.
1655 pub fn parsed_buffer_lines(&self) -> &[super::markdown::ParsedLine] {
1656 &self.parse_state.buf().lines
1657 }
1658
1659 /// Test accessor: the rendered-position bitmask cache.
1660 /// Used by tests to construct a fresh `WordWrapLayout` from the same
1661 /// masks the view is using, for equivalence checks.
1662 #[cfg(test)]
1663 pub(crate) fn rendered_cache_for_testing(&self) -> &[Vec<bool>] {
1664 &self.rendered_cache
1665 }
1666
1667 #[cfg(test)]
1668 pub(crate) fn code_box_width_for_testing(&self) -> &[Option<u16>] {
1669 &self.code_box_width
1670 }
1671
1672 #[cfg(test)]
1673 pub(crate) fn gutter_insets_for_testing(&self) -> &[usize] {
1674 &self.gutter_insets
1675 }
1676
1677 fn is_in_code_block(&self, row: usize) -> bool {
1678 // Every line inside any fenced block renders force-raw (no markdown
1679 // re-styling, distinct fg color). Previously this checked only the
1680 // fence the cursor was sitting in, so fenced blocks elsewhere in
1681 // the buffer looked like plain text until the cursor moved into
1682 // them.
1683 self.fence_ranges.iter().any(|r| r.contains(&row))
1684 }
1685
1686 /// Rebuild `code_box_width` from the current parse kinds and snapshot
1687 /// lines. Box width per block = max rendered display width of its lines,
1688 /// capped at `width`.
1689 fn rebuild_code_box_width(&mut self, text: &crate::ropetext::Text, width: u16) {
1690 let mut out = vec![None; text.line_count()];
1691 let ranges =
1692 super::parse_incremental::code_block_ranges_from_kinds(&self.parse_state.buf().kinds);
1693 for r in ranges {
1694 let mut max_w = 0usize;
1695 for row in r.clone() {
1696 if let Some(line) = text.line(row) {
1697 max_w = max_w.max(super::markdown::raw_display_width(&line));
1698 }
1699 }
1700 let boxed = (max_w.min(width as usize)) as u16;
1701 for row in r {
1702 if row < out.len() {
1703 out[row] = Some(boxed);
1704 }
1705 }
1706 }
1707 self.code_box_width = out;
1708 }
1709
1710 /// Update `code_box_width` for just the code-block range(s) overlapping
1711 /// `damaged` — the incremental-path sibling of `rebuild_code_box_width`.
1712 /// Safe because the structural guards in `try_incremental_parse` already
1713 /// refuse to splice an edit that adds, removes, or moves a code-block
1714 /// boundary; a block that doesn't overlap the edit can only have kept
1715 /// the same lines it had before, so its width can't have changed. A
1716 /// block's own content growing or shrinking *can* change its width, and
1717 /// that only happens inside `damaged`.
1718 fn patch_code_box_width(
1719 &mut self,
1720 text: &crate::ropetext::Text,
1721 width: u16,
1722 damaged: std::ops::Range<usize>,
1723 ) {
1724 let ranges =
1725 super::parse_incremental::code_block_ranges_from_kinds(&self.parse_state.buf().kinds);
1726 for r in ranges {
1727 if r.start >= damaged.end || r.end <= damaged.start {
1728 continue; // no overlap — this block's width can't have changed
1729 }
1730 let mut max_w = 0usize;
1731 for row in r.clone() {
1732 if let Some(line) = text.line(row) {
1733 max_w = max_w.max(super::markdown::raw_display_width(&line));
1734 }
1735 }
1736 let boxed = (max_w.min(width as usize)) as u16;
1737 for row in r {
1738 if let Some(entry) = self.code_box_width.get_mut(row) {
1739 *entry = Some(boxed);
1740 }
1741 }
1742 }
1743 }
1744
1745 /// Rebuild `gutter_insets` from parse state + cursor. A blockquote row
1746 /// that is not the cursor row reserves `depth + 1` cols for the bar; the
1747 /// cursor row reserves 0 (its markers are revealed raw). Full
1748 /// `O(row_count)` rebuild — see `patch_gutter_insets` for the
1749 /// incremental-path sibling that only touches the rows that can
1750 /// plausibly have changed.
1751 fn rebuild_gutter_insets(&mut self, row_count: usize, cursor_row: usize) {
1752 let parsed = &self.parse_state.buf().lines;
1753 self.gutter_insets = (0..row_count)
1754 .map(|row| {
1755 if row == cursor_row {
1756 return 0;
1757 }
1758 match parsed.get(row).and_then(|p| p.blockquote_depth()) {
1759 Some(d) => super::markdown::blockquote_gutter_width(d),
1760 None => 0,
1761 }
1762 })
1763 .collect();
1764 }
1765
1766 /// Update `gutter_insets` for exactly `rows`, in place. Safe whenever
1767 /// the parse took the incremental splice path: the structural guards in
1768 /// `try_incremental_parse` (opener-shape / lazy-depth) already refuse
1769 /// to splice an edit that could change a row's blockquote depth, so the
1770 /// only thing that can legitimately change `gutter_insets` between two
1771 /// incrementally-linked frames is which row the cursor is on.
1772 fn patch_gutter_insets(&mut self, rows: &[usize], cursor_row: usize) {
1773 let parsed = &self.parse_state.buf().lines;
1774 for &row in rows {
1775 let inset = if row == cursor_row {
1776 0
1777 } else {
1778 match parsed.get(row).and_then(|p| p.blockquote_depth()) {
1779 Some(d) => super::markdown::blockquote_gutter_width(d),
1780 None => 0,
1781 }
1782 };
1783 if let Some(entry) = self.gutter_insets.get_mut(row) {
1784 *entry = inset;
1785 }
1786 }
1787 }
1788
1789 /// Markdown-aware mouse click: maps a rendered screen column to
1790 /// the correct logical column, accounting for hidden markdown
1791 /// sigils (links, bold markers, etc.).
1792 ///
1793 /// Reads `self`'s view-internal caches (`layout`, `lines_snapshot`,
1794 /// `parsed_buffer`), all rebuilt from the same `EditorSnapshot`
1795 /// in the last `update()` call. The snapshot invariant guarantees
1796 /// `vl.logical_row` is a valid index into both `lines_snapshot`
1797 /// and `parsed_buffer.lines`, so direct indexing is safe — the
1798 /// previous defensive `(Some, Some) else fallback` block (Fix #2
1799 /// in the holistic review) is no longer needed.
1800 /// Map a screen-relative click (row/col offset from the editor's
1801 /// top-left corner) to logical (row, col). Owns the
1802 /// visual-scroll-offset arithmetic so callers do not reach into
1803 /// viewport's top — the view knows where it is scrolled.
1804 pub fn click_at_screen(&self, screen_row: usize, screen_col: usize) -> (u16, u16) {
1805 let vrow = screen_row + self.viewport.top();
1806 self.click_to_logical_u16(vrow, screen_col)
1807 }
1808
1809 fn click_to_logical_u16(&self, vrow: usize, vcol: usize) -> (u16, u16) {
1810 let vlines = self.layout.visual_lines();
1811 if vlines.is_empty() {
1812 return (0, 0);
1813 }
1814 let vrow = vrow.min(vlines.len() - 1);
1815 let vl = &vlines[vrow];
1816 let row_u16 = vl.logical_row.min(u16::MAX as usize) as u16;
1817 let row_text = self.text_snapshot.line(vl.logical_row).unwrap_or_default();
1818 let logical_line = row_text.as_ref();
1819 let parsed = &self.parse_state.buf().lines[vl.logical_row];
1820 let force_raw = self.is_in_code_block(vl.logical_row);
1821 let gutter = self
1822 .gutter_insets
1823 .get(vl.logical_row)
1824 .copied()
1825 .unwrap_or(self.cursor_vrow);
1826 let vcol = vcol.saturating_sub(gutter);
1827 // When a blockquote gutter is drawn (gutter > 0), the ">" and space
1828 // sigil chars are hidden and replaced by the "│ " bar. On the first
1829 // visual line, skip those hidden sigil chars so that rendered_col 0
1830 // maps to the first content char, not to the hidden ">".
1831 let effective_start_col = if gutter > 0 && vl.first {
1832 parsed.blockquote_sigil_end().unwrap_or(vl.chars.start)
1833 } else {
1834 vl.chars.start
1835 };
1836 // The same rule the render loop uses to decide `cursor_col`: only the
1837 // caret's own row is revealed, so only there does the mapping have to
1838 // account for a revealed element's sigils occupying cells.
1839 let reveal_col =
1840 (vl.logical_row == self.cursor_snapshot.0).then_some(self.cursor_snapshot.1);
1841 let logical_col = MarkdownSpanner::rendered_col_to_logical_with(
1842 logical_line,
1843 parsed,
1844 effective_start_col,
1845 vcol,
1846 reveal_col,
1847 vl.first,
1848 force_raw,
1849 );
1850 // Clamp to the visual line clicked. `rendered_col_to_logical_with` maps a
1851 // cell to a column in the whole logical row, so a click in the blank
1852 // space right of a soft-wrapped line walks straight into the span of the
1853 // line below and the cursor lands a row further on than the one under the
1854 // pointer. `crate::ropetext::Layout::position_at_cell` clamps for this reason;
1855 // this is the TUI's own mapper and had drifted from it.
1856 let logical_col = logical_col.min(vl.chars.end);
1857 let col = logical_col.min(u16::MAX as usize) as u16;
1858 (row_u16, col)
1859 }
1860
1861 #[cfg(test)]
1862 pub(crate) fn click_to_logical_for_testing(&self, vrow: usize, vcol: usize) -> (u16, u16) {
1863 self.click_to_logical_u16(vrow, vcol)
1864 }
1865}
1866
1867impl Default for MarkdownEditorView {
1868 fn default() -> Self {
1869 Self::new()
1870 }
1871}
1872
1873/// Returns the byte offset into `s` after consuming exactly `target_width` display columns.
1874/// If `target_width` exceeds the string's display width, returns `s.len()`.
1875///
1876/// Walks whole grapheme clusters (not codepoints) and measures each with
1877/// [`super::markdown::cluster_display_width`], so the result never lands mid-cluster (which would
1878/// split an emoji across two styled spans) and stays consistent with the width
1879/// model used by wrap and cursor math — an emoji presentation sequence (flag,
1880/// VS16 heart, keycap) counts as its full rendered width, not its first codepoint.
1881fn byte_offset_for_display_width(s: &str, target_width: usize) -> usize {
1882 use super::markdown::cluster_display_width;
1883 use unicode_segmentation::UnicodeSegmentation;
1884 let mut consumed = 0usize;
1885 for (byte_pos, g) in s.grapheme_indices(true) {
1886 if consumed >= target_width {
1887 return byte_pos;
1888 }
1889 consumed += cluster_display_width(g);
1890 }
1891 s.len()
1892}
1893
1894/// Split `spans` at the boundaries of a rendered-column range and apply
1895/// `restyle` to the overlapping portion. The one place column-to-byte
1896/// accounting for a partial restyle lives.
1897fn restyle_over_range<'a>(
1898 spans: Vec<ratatui::text::Span<'a>>,
1899 sel_cols: std::ops::Range<usize>,
1900 restyle: &dyn Fn(ratatui::style::Style) -> ratatui::style::Style,
1901) -> Vec<ratatui::text::Span<'a>> {
1902 if sel_cols.is_empty() {
1903 return spans;
1904 }
1905 let mut result = Vec::new();
1906 let mut col = 0usize;
1907
1908 for span in spans {
1909 let content: &str = &span.content;
1910 // Same cluster-based width model as `byte_offset_for_display_width`
1911 // below, so column accounting and the byte boundaries it computes can
1912 // never disagree on emoji presentation sequences.
1913 let span_width = super::markdown::string_display_width(content);
1914 let span_end = col + span_width;
1915
1916 let overlap_start = sel_cols.start.max(col);
1917 let overlap_end = sel_cols.end.min(span_end);
1918
1919 if overlap_start >= overlap_end {
1920 // No overlap — emit as-is.
1921 result.push(span);
1922 } else {
1923 // Walk grapheme clusters by display width to find byte boundaries.
1924 let prefix_width = overlap_start - col;
1925 let selected_width = overlap_end - overlap_start;
1926
1927 let prefix_byte = byte_offset_for_display_width(content, prefix_width);
1928 let selected_byte_end =
1929 byte_offset_for_display_width(&content[prefix_byte..], selected_width)
1930 + prefix_byte;
1931
1932 // Prefix (before selection)
1933 if prefix_byte > 0 {
1934 result.push(ratatui::text::Span::styled(
1935 content[..prefix_byte].to_string(),
1936 span.style,
1937 ));
1938 }
1939 // Selected portion
1940 result.push(ratatui::text::Span::styled(
1941 content[prefix_byte..selected_byte_end].to_string(),
1942 restyle(span.style),
1943 ));
1944 // Suffix (after selection)
1945 if selected_byte_end < content.len() {
1946 result.push(ratatui::text::Span::styled(
1947 content[selected_byte_end..].to_string(),
1948 span.style,
1949 ));
1950 }
1951 }
1952
1953 col = span_end;
1954 }
1955
1956 result
1957}
1958
1959/// Paint `code_bg` behind every span of a code-block visual line and pad the
1960/// line with bg-colored spaces up to `box_width` display columns, producing a
1961/// solid rectangle hugging the block's widest line. Content already wider than
1962/// the box (the box was capped at editor width; wider rows wrap) is left as-is.
1963fn apply_code_box<'a>(
1964 spans: Vec<ratatui::text::Span<'a>>,
1965 box_width: u16,
1966 theme: &Theme,
1967) -> Vec<ratatui::text::Span<'a>> {
1968 use ratatui::text::Span;
1969 use unicode_segmentation::UnicodeSegmentation;
1970 let bg = theme.code_bg.to_ratatui();
1971 // Measure with the same cluster + tab-aware model as `raw_display_width`
1972 // (which sizes `box_width` in `rebuild_code_box_width`), so the padding
1973 // can never disagree with the target on emoji presentation sequences or
1974 // tabs. `cluster_width_at` needs the running column for tab stops.
1975 let mut width = 0usize;
1976 let mut out: Vec<Span<'a>> = spans
1977 .into_iter()
1978 .map(|s| {
1979 for g in s.content.graphemes(true) {
1980 width += super::markdown::cluster_width_at(g, width);
1981 }
1982 let style = s.style.bg(bg);
1983 Span::styled(s.content, style)
1984 })
1985 .collect();
1986 let target = box_width as usize;
1987 if width < target {
1988 out.push(Span::styled(
1989 " ".repeat(target - width),
1990 Style::default().bg(bg),
1991 ));
1992 }
1993 out
1994}
1995
1996/// Per-row hints for the layout: what the syntax layer draws, and how far each
1997/// row is inset by its gutter.
1998///
1999/// Built per rebuild rather than stored, because both halves already live on the
2000/// view and a third copy would be a third thing to keep in step.
2001///
2002/// `pub(super)`: the background wrap task spawned by the owning
2003/// `TextEditorComponent` (`mod.rs`) rebuilds the same hints from a
2004/// [`PendingLayoutJob`]'s owned `rendered_cache`/`gutter_insets` clones —
2005/// `RowHints` borrows, so it cannot cross the `tokio::spawn` boundary
2006/// itself and has to be reconstructed on the other side from owned data.
2007pub(super) fn row_hints<'a>(rendered: &'a [Vec<bool>], insets: &'a [usize]) -> Vec<RowHints<'a>> {
2008 let rows = rendered.len().max(insets.len());
2009 (0..rows)
2010 .map(|row| RowHints {
2011 visible: rendered.get(row).map(Vec::as_slice).unwrap_or(&[]),
2012 inset: insets.get(row).copied().unwrap_or(0),
2013 })
2014 .collect()
2015}
2016
2017#[cfg(test)]
2018mod tests {
2019 use super::*;
2020 use ratatui::layout::Rect;
2021 use std::num::NonZeroU64;
2022
2023 fn rect(h: u16) -> Rect {
2024 Rect {
2025 x: 0,
2026 y: 0,
2027 width: 40,
2028 height: h,
2029 }
2030 }
2031
2032 /// Test-only wrapper that builds an `EditorSnapshot::borrowed`
2033 /// from the legacy `(lines, cursor, generation)` shape, so the
2034 /// hundreds of existing call sites don't each have to construct
2035 /// the snapshot inline.
2036 ///
2037 /// Mirrors `snapshot_from_backend`'s producer-side cursor clamp,
2038 /// so tests that pass an intentionally-stale `cursor` (e.g. the
2039 /// regression for the Nvim shrink panic) still exercise the
2040 /// real production path: producer clamps, render trusts.
2041 /// Tests describe buffers as rows; the view takes the text they make up.
2042 fn text_of(lines: &[String]) -> crate::ropetext::Text {
2043 crate::ropetext::Text::from(lines.join("\n").as_str())
2044 }
2045
2046 /// Two reports between one pair of frames, the second changing the line
2047 /// count above the first — the first report's row has moved by the time the
2048 /// hull is used.
2049 #[test]
2050 fn damage_reported_twice_across_a_line_change_is_renumbered() {
2051 let mut v = MarkdownEditorView::new();
2052 // An edit at row 10, then a newline inserted at row 0, which pushes the
2053 // first edit's row down to 11.
2054 v.note_damage(10..11, 0);
2055 v.note_damage(0..2, 1);
2056 let hull = v.reported_damage.clone().expect("both edits were reported");
2057 assert!(
2058 hull.contains(&11),
2059 "row 10 became row 11; the hull reported was {hull:?}"
2060 );
2061 }
2062
2063 /// Does the engine's `position_at_cell` agree with the TUI's own click
2064 /// mapper?
2065 ///
2066 /// The two compute the same thing by different routes — the TUI walks
2067 /// rendered columns through `MarkdownSpanner`, the engine walks the same
2068 /// information as `RowHints` (a visibility mask plus a gutter inset). Keeping
2069 /// two of these in sync by hand is what let the wrap-clamp drift out of the
2070 /// TUI copy in the first place. This says where they still differ, and is the
2071 /// gate for deleting one of them.
2072 ///
2073 /// **Currently fails, with six disagreements of exactly two kinds** — and
2074 /// neither is an algorithmic mismatch. The engine does honour concealment;
2075 /// `cell_of` skips masked-out chars. What it lacks is data the TUI mapper
2076 /// holds:
2077 ///
2078 /// 1. **Blockquote sigil skip.** On `> quoted ...`, cells 0-2 map to column 2
2079 /// in the TUI (it skips the `> ` via `blockquote_sigil_end` on a first
2080 /// visual line) and to 0 in the engine, which applies only `inset`. Mark
2081 /// those sigil chars invisible in `rendered_cache` and the engine reaches
2082 /// 2 on its own.
2083 /// 2. **Tie-breaking at the edge of a concealed run** — which side a click
2084 /// between a hidden run and its neighbour falls to. Needs stating as a
2085 /// rule in `position_at_cell`, not reproducing.
2086 ///
2087 /// A translation layer between the two mappers is the wrong answer — it adds
2088 /// the seam this exists to remove. But so, for now, is fixing the hints:
2089 /// `row_hints` feeds `Layout::compute`/`relayout_rows` at six sites, so the
2090 /// visibility mask is the **wrapping** input. Marking the sigil chars
2091 /// invisible would change where every line breaks, editor-wide, to fix where
2092 /// clicks land. The render snapshots would catch it, but that is a re-wrap,
2093 /// not a tidy-up.
2094 ///
2095 /// So this test's job is to be a tripwire, not a plan: it fails if the two
2096 /// mappers drift *further* apart. Unify them only when the mask has to change
2097 /// for some other reason, at which point it comes along nearly free. Run with
2098 /// `--ignored`.
2099 ///
2100 /// (An earlier version of this test reported 23 disagreements and concluded
2101 /// the engine ignored concealment. It parked the cursor on the row under
2102 /// test, and the cursor's row is *revealed* — so it compared concealment
2103 /// against its own suspension.)
2104 /// Now green, and it is the precondition for deleting `click_to_logical_u16`:
2105 /// the engine's mapper may replace the TUI's exactly when the two agree.
2106 /// Closing the last six took a change on each side — the TUI resolving a
2107 /// cell to the drawn column *after* a concealed run rather than to the run's
2108 /// head, and `position_at_cell` dropping a short circuit that returned the
2109 /// row's first char for any cell inside the inset, skipping the very loop
2110 /// that walks past a blockquote's hidden `> `.
2111 #[test]
2112 fn the_engine_and_the_tui_click_mappers_agree() {
2113 let corpus: Vec<Vec<String>> = vec![
2114 vec!["plain short".to_string()],
2115 vec!["a long paragraph that certainly wraps more than once here".to_string()],
2116 vec!["> quoted line that is long enough to wrap at this width".to_string()],
2117 vec!["- list item with enough text on it to wrap somewhere".to_string()],
2118 vec!["**bold** and *italic* markers that get concealed".to_string()],
2119 vec!["# heading that runs on long enough to wrap around".to_string()],
2120 ];
2121
2122 let mut disagreements = Vec::new();
2123 for lines in &corpus {
2124 // Park the cursor on an appended trailing row: the cursor's row is
2125 // *revealed* (sigils shown raw), so testing the row it sits on
2126 // compares concealment against its own suspension.
2127 let mut lines = lines.clone();
2128 lines.push(String::new());
2129 let park = lines.len() - 1;
2130 let mut v = MarkdownEditorView::new();
2131 update_view(&mut v, &lines, (park, 0), rect(20), 1, None);
2132 let text = v.text_snapshot.clone();
2133 let hints = row_hints(&v.rendered_cache, &v.gutter_insets);
2134 for vrow in 0..v.layout.visual_lines().len() {
2135 for vcol in 0..24 {
2136 let (tui_row, tui_col) = v.click_to_logical_for_testing(vrow, vcol);
2137 let engine = v.layout.position_at_cell(
2138 &text,
2139 &hints,
2140 crate::ropetext::Cell {
2141 row: vrow,
2142 column: vcol,
2143 },
2144 );
2145 let engine = engine.map(|p| (p.row() as u16, p.column().get() as u16));
2146 if engine != Some((tui_row, tui_col)) {
2147 disagreements.push(format!(
2148 "{:?} vrow={vrow} vcol={vcol}: tui={:?} engine={:?}",
2149 lines[0],
2150 (tui_row, tui_col),
2151 engine
2152 ));
2153 }
2154 }
2155 }
2156 }
2157 assert!(
2158 disagreements.is_empty(),
2159 "{} disagreements, first 5:\n{}",
2160 disagreements.len(),
2161 disagreements
2162 .iter()
2163 .take(5)
2164 .cloned()
2165 .collect::<Vec<_>>()
2166 .join("\n")
2167 );
2168 }
2169
2170 #[test]
2171 fn a_preview_that_cannot_place_the_cursor_leaves_the_viewport_alone() {
2172 // `render` pairs the replace preview's rows with the real buffer's
2173 // cursor. A replacement shorter than what it replaces puts that cursor
2174 // past the end of the previewed row, and answering "visual row 0" threw
2175 // the note to the top mid-keystroke.
2176 let mut lines: Vec<String> = (0..200).map(|i| format!("row {i} plain text")).collect();
2177 lines[150] = " the configuration value goes here".to_string();
2178
2179 let mut v = MarkdownEditorView::new();
2180 update_view(&mut v, &lines, (150, 25), rect(20), 1, None);
2181 let scrolled = v.viewport.top();
2182 assert!(scrolled > 0, "fixture must have scrolled away from the top");
2183
2184 // The preview: that row shrinks below the cursor's column.
2185 let mut preview = lines.clone();
2186 preview[150] = " the cfg value".to_string();
2187 update_view(&mut v, &preview, (150, 25), rect(20), 2, None);
2188
2189 assert_eq!(
2190 v.viewport.top(),
2191 scrolled,
2192 "the viewport must not jump to the top of the note"
2193 );
2194 }
2195
2196 #[test]
2197 fn a_click_past_the_end_of_a_wrapped_line_stays_on_that_line() {
2198 // Clicking the blank space to the right of a soft-wrapped line must land
2199 // at the end of the line clicked, not inside the continuation below it.
2200 // `crate::ropetext::Layout::position_at_cell` clamps for exactly this reason;
2201 // this mapper is the TUI's own copy and had drifted from it.
2202 let lines = vec![
2203 "a long paragraph that will certainly wrap more than once at this width".to_string(),
2204 ];
2205 let mut v = MarkdownEditorView::new();
2206 update_view(&mut v, &lines, (0, 0), rect(20), 1, None);
2207
2208 let first = v.layout.visual_lines()[0].clone();
2209 assert!(
2210 v.layout.visual_lines().len() > 1,
2211 "fixture must actually wrap"
2212 );
2213
2214 // Far to the right of anything drawn on the first visual line.
2215 let (row, col) = v.click_to_logical_for_testing(0, 60);
2216 assert_eq!(row, 0);
2217 assert!(
2218 (col as usize) <= first.chars.end,
2219 "clicked past visual line 0 (chars {:?}) and landed at column {col}",
2220 first.chars
2221 );
2222 }
2223
2224 /// A newline patched into the layout must give the same layout a fresh
2225 /// compute would.
2226 ///
2227 /// The layout no longer gives up when the line count changes — it patches the
2228 /// damaged rows and renumbers the rest by the delta. That renumbering is the
2229 /// part with no second opinion anywhere: a wrong `logical_row` on the rows
2230 /// *after* the edit paints the right text against the wrong line, and every
2231 /// existing test looks at the edited row.
2232 #[test]
2233 fn a_newline_patches_the_layout_to_match_a_fresh_one() {
2234 // Rows long enough to wrap at width 20, so the visual lines outnumber the
2235 // logical rows and a renumbering slip cannot hide.
2236 let lines: Vec<String> = (0..12)
2237 .map(|i| format!("row {i} with enough words on it to wrap at this width"))
2238 .collect();
2239 let mut split = lines.clone();
2240 let tail = split[5].split_off(10);
2241 split.insert(6, tail);
2242
2243 let mut patched = MarkdownEditorView::new();
2244 update_view(&mut patched, &lines, (5, 0), rect(20), 1, None);
2245 patched.note_damage(5..7, 1);
2246 update_view(&mut patched, &split, (6, 0), rect(20), 2, None);
2247
2248 let mut fresh = MarkdownEditorView::new();
2249 update_view(&mut fresh, &split, (6, 0), rect(20), 1, None);
2250
2251 let patched_lines: Vec<_> = patched
2252 .layout
2253 .visual_lines()
2254 .iter()
2255 .map(|vl| (vl.logical_row, vl.bytes.clone(), vl.first))
2256 .collect();
2257 let fresh_lines: Vec<_> = fresh
2258 .layout
2259 .visual_lines()
2260 .iter()
2261 .map(|vl| (vl.logical_row, vl.bytes.clone(), vl.first))
2262 .collect();
2263 assert_eq!(
2264 patched_lines, fresh_lines,
2265 "patching a newline must land where a full recompute would"
2266 );
2267 }
2268
2269 pub(super) fn update_view(
2270 v: &mut MarkdownEditorView,
2271 lines: &[String],
2272 cursor: (usize, usize),
2273 rect: Rect,
2274 generation: u64,
2275 selection: Option<((usize, usize), (usize, usize))>,
2276 ) {
2277 // Selection reaches the view as an **overlay** now.
2278 let rev = NonZeroU64::new(generation.max(1)).unwrap();
2279 let clamped = if lines.is_empty() {
2280 (0, 0)
2281 } else {
2282 (cursor.0.min(lines.len() - 1), cursor.1)
2283 };
2284 let snap = super::super::snapshot::EditorSnapshot::borrowed(lines, clamped, rev);
2285 v.update(&snap, rect);
2286 let overlays = match selection {
2287 Some(((sr, sc), (er, ec))) => (sr..=er)
2288 .map(|row| {
2289 Overlay::new(
2290 row,
2291 if row == sr { sc } else { 0 },
2292 if row == er { ec } else { usize::MAX },
2293 OverlayKind::Selection,
2294 )
2295 })
2296 .collect(),
2297 None => Vec::new(),
2298 };
2299 v.set_overlays(overlays);
2300 }
2301
2302 /// Build a freshly-updated view from `lines` with the cursor at
2303 /// `cursor` and the given editor `width`, using the real snapshot +
2304 /// `update()` path. Height is fixed at 24.
2305 fn make_view_for_lines(
2306 lines: &[String],
2307 cursor: (usize, usize),
2308 width: u16,
2309 ) -> MarkdownEditorView {
2310 let mut v = MarkdownEditorView::new();
2311 let r = Rect {
2312 x: 0,
2313 y: 0,
2314 width,
2315 height: 24,
2316 };
2317 update_view(&mut v, lines, cursor, r, 1, None);
2318 v
2319 }
2320
2321 #[test]
2322 fn selection_highlight_respects_emoji_cluster_width() {
2323 // Span "a❤️b" where ❤️ = U+2764 + VS16 renders as 2 display columns:
2324 // a=col0, ❤️=cols1..3, b=col3. Selecting cols 1..3 must highlight
2325 // exactly the heart cluster — not split it, and not bleed into 'b'.
2326 let theme = Theme::default();
2327 let sel_bg = theme.selection_bg.to_ratatui();
2328 let heart = "\u{2764}\u{FE0F}";
2329 let content = format!("a{heart}b");
2330 let spans = vec![ratatui::text::Span::raw(content)];
2331 let out = restyle_over_range(spans, 1..3, &|st| st.bg(sel_bg));
2332
2333 let highlighted: String = out
2334 .iter()
2335 .filter(|s| s.style.bg == Some(sel_bg))
2336 .map(|s| s.content.as_ref())
2337 .collect();
2338 assert_eq!(highlighted, heart, "selection must cover exactly the heart");
2339
2340 // No output span may split the cluster: every span's content must
2341 // recluster identically (the heart stays whole within one span).
2342 for s in &out {
2343 let c = s.content.as_ref();
2344 assert!(
2345 !c.contains('\u{2764}') || c.contains(heart),
2346 "emoji cluster split across spans: {c:?}"
2347 );
2348 }
2349 }
2350
2351 #[test]
2352 fn code_box_background_reaches_rendered_cells() {
2353 use ratatui::Terminal;
2354 use ratatui::backend::TestBackend;
2355 let lines = vec![
2356 "```".to_string(),
2357 "let x = 1;".to_string(),
2358 "```".to_string(),
2359 "plain".to_string(),
2360 ];
2361 let theme = crate::settings::themes::Theme::gruvbox_dark();
2362 let mut view = make_view_for_lines(&lines, (3, 0), 40);
2363 let mut terminal = Terminal::new(TestBackend::new(40, 5)).unwrap();
2364 terminal
2365 .draw(|f| view.render(f, f.area(), &theme, true, Some(CursorShape::Bar)))
2366 .unwrap();
2367 let buf = terminal.backend().buffer().clone();
2368 let code_bg = theme.code_bg.to_ratatui();
2369 let cell = |x: u16, y: u16| &buf.content[(y as usize) * 40 + (x as usize)];
2370
2371 // A cell on the fenced code content row carries the code-box bg...
2372 assert_eq!(
2373 cell(0, 1).bg,
2374 code_bg,
2375 "code content cell must have code_bg"
2376 );
2377 // ...including the padding past the text (box is a solid rectangle).
2378 assert_eq!(cell(8, 1).bg, code_bg, "code-box padding must have code_bg");
2379 // A prose row outside the block does NOT get the code bg.
2380 assert_ne!(cell(0, 3).bg, code_bg, "prose row must not have code_bg");
2381 }
2382
2383 #[test]
2384 fn blockquote_gutter_inset_off_cursor_row_only() {
2385 // Two blockquote lines; cursor on row 0.
2386 let lines = vec!["> first".to_string(), ">> second".to_string()];
2387 let view = make_view_for_lines(&lines, (0, 1), 80);
2388 let g = view.gutter_insets_for_testing();
2389 assert_eq!(g[0], 0); // cursor row → revealed, no gutter
2390 assert_eq!(g[1], 3); // depth 2 → 2 bars + 1 space
2391 }
2392
2393 #[test]
2394 fn code_box_width_is_block_max_capped_to_width() {
2395 let lines = vec![
2396 "```".to_string(),
2397 "let x = 1;".to_string(), // 10
2398 "let yy = 222;".to_string(), // 13 (widest)
2399 "```".to_string(),
2400 "plain".to_string(),
2401 ];
2402 let view = make_view_for_lines(&lines, (0, 0), 80); // width 80
2403 let w = view.code_box_width_for_testing();
2404 assert_eq!(w[0], Some(13));
2405 assert_eq!(w[1], Some(13));
2406 assert_eq!(w[2], Some(13));
2407 assert_eq!(w[3], Some(13));
2408 assert_eq!(w[4], None);
2409 }
2410
2411 #[test]
2412 fn new_has_zero_scroll() {
2413 assert_eq!(MarkdownEditorView::new().viewport.top(), 0);
2414 }
2415
2416 #[test]
2417 fn zero_height_rect_does_not_panic() {
2418 let mut v = MarkdownEditorView::new();
2419 update_view(&mut v, &["hello".to_string()], (0, 0), rect(0), 1, None);
2420 }
2421
2422 #[test]
2423 fn scroll_follows_cursor_down() {
2424 let mut v = MarkdownEditorView::new();
2425 let lines: Vec<String> = (0..5).map(|i| format!("line{}", i)).collect();
2426 update_view(&mut v, &lines, (4, 0), rect(3), 1, None);
2427 assert!(v.viewport.top() >= 2);
2428 }
2429
2430 #[test]
2431 fn scroll_follows_cursor_up() {
2432 let mut v = MarkdownEditorView::new();
2433 let lines: Vec<String> = (0..5).map(|i| format!("line{}", i)).collect();
2434 update_view(&mut v, &lines, (4, 0), rect(3), 1, None);
2435 update_view(&mut v, &lines, (0, 0), rect(3), 1, None); // same generation — scroll still adjusts
2436 assert_eq!(v.viewport.top(), 0);
2437 }
2438
2439 #[test]
2440 fn wheel_scroll_moves_the_viewport_and_survives_the_next_frame() {
2441 let mut v = MarkdownEditorView::new();
2442 let lines: Vec<String> = (0..10).map(|i| format!("line{}", i)).collect();
2443 update_view(&mut v, &lines, (0, 0), rect(3), 1, None);
2444 v.scroll_by(4);
2445 assert_eq!(v.viewport.top(), 4);
2446 // Same cursor, text and size: the redraw must not snap back.
2447 update_view(&mut v, &lines, (0, 0), rect(3), 1, None);
2448 assert_eq!(v.viewport.top(), 4);
2449 v.scroll_by(-1);
2450 update_view(&mut v, &lines, (0, 0), rect(3), 1, None);
2451 assert_eq!(v.viewport.top(), 3);
2452 }
2453
2454 #[test]
2455 fn wheel_scroll_is_clamped_to_the_content() {
2456 let mut v = MarkdownEditorView::new();
2457 let lines: Vec<String> = (0..10).map(|i| format!("line{}", i)).collect();
2458 update_view(&mut v, &lines, (0, 0), rect(3), 1, None);
2459 v.scroll_by(100);
2460 assert_eq!(v.viewport.top(), 7, "last line sits at the bottom");
2461 v.scroll_by(-100);
2462 assert_eq!(v.viewport.top(), 0);
2463 }
2464
2465 #[test]
2466 fn moving_the_cursor_after_a_wheel_scroll_brings_it_back_into_view() {
2467 let mut v = MarkdownEditorView::new();
2468 let lines: Vec<String> = (0..10).map(|i| format!("line{}", i)).collect();
2469 update_view(&mut v, &lines, (0, 0), rect(3), 1, None);
2470 v.scroll_by(6);
2471 update_view(&mut v, &lines, (1, 0), rect(3), 1, None);
2472 assert_eq!(v.viewport.top(), 1);
2473 }
2474
2475 #[test]
2476 fn editing_after_a_wheel_scroll_brings_the_cursor_back_into_view() {
2477 let mut v = MarkdownEditorView::new();
2478 let mut lines: Vec<String> = (0..10).map(|i| format!("line{}", i)).collect();
2479 update_view(&mut v, &lines, (0, 0), rect(3), 1, None);
2480 v.scroll_by(6);
2481 lines[0].push('x');
2482 update_view(&mut v, &lines, (0, 0), rect(3), 2, None);
2483 assert_eq!(v.viewport.top(), 0);
2484 }
2485
2486 #[test]
2487 fn follow_cursor_brings_the_view_back_when_nothing_else_changed() {
2488 // Find-next wrapping onto the match the cursor already sits on changes
2489 // neither the cursor nor the text; the explicit re-attach must still
2490 // reveal it.
2491 let mut v = MarkdownEditorView::new();
2492 let lines: Vec<String> = (0..10).map(|i| format!("line{}", i)).collect();
2493 update_view(&mut v, &lines, (0, 0), rect(3), 1, None);
2494 v.scroll_by(6);
2495 v.follow_cursor();
2496 update_view(&mut v, &lines, (0, 0), rect(3), 1, None);
2497 assert_eq!(v.viewport.top(), 0);
2498 }
2499
2500 #[test]
2501 fn a_saturated_wheel_notch_does_not_detach_the_view() {
2502 let mut v = MarkdownEditorView::new();
2503 let lines: Vec<String> = (0..10).map(|i| format!("line{}", i)).collect();
2504 update_view(&mut v, &lines, (0, 0), rect(3), 1, None);
2505 assert!(!v.scroll_by(-3), "already at the top");
2506 assert!(!v.detached);
2507 }
2508
2509 #[test]
2510 fn a_wheel_scroll_survives_a_height_change_but_stays_within_content() {
2511 let mut v = MarkdownEditorView::new();
2512 let lines: Vec<String> = (0..10).map(|i| format!("line{}", i)).collect();
2513 update_view(&mut v, &lines, (0, 0), rect(3), 1, None);
2514 v.scroll_by(7);
2515 update_view(&mut v, &lines, (0, 0), rect(5), 1, None);
2516 assert_eq!(
2517 v.viewport.top(),
2518 5,
2519 "clamped so the last line is at the bottom"
2520 );
2521 }
2522
2523 #[test]
2524 fn growing_the_viewport_pulls_content_back_down() {
2525 // Shrinking to one row pins the cursor's (last) line at the top;
2526 // growing back must reveal the lines above it, not leave the last
2527 // line alone on screen with blank rows beneath.
2528 let mut v = MarkdownEditorView::new();
2529 let lines: Vec<String> = (0..5).map(|i| format!("line{}", i)).collect();
2530 update_view(&mut v, &lines, (4, 0), rect(1), 1, None);
2531 assert_eq!(v.viewport.top(), 4);
2532 update_view(&mut v, &lines, (4, 0), rect(3), 1, None);
2533 assert_eq!(v.viewport.top(), 2);
2534 update_view(&mut v, &lines, (4, 0), rect(10), 1, None);
2535 assert_eq!(v.viewport.top(), 0);
2536 }
2537
2538 #[test]
2539 fn visual_to_logical_u16_accounts_for_scroll() {
2540 let mut v = MarkdownEditorView::new();
2541 let lines: Vec<String> = (0..10).map(|i| format!("line{}", i)).collect();
2542 update_view(&mut v, &lines, (5, 0), rect(3), 1, None);
2543 let scroll = v.viewport.top();
2544 let (row, _col) = v.click_to_logical_u16(scroll, 0);
2545 assert_eq!(row as usize, scroll);
2546 }
2547
2548 #[test]
2549 fn code_block_detection_cursor_inside() {
2550 let lines = vec![
2551 "text".to_string(),
2552 "```rust".to_string(),
2553 "let x = 1;".to_string(),
2554 "```".to_string(),
2555 "more".to_string(),
2556 ];
2557 let pb = ParsedBuffer::parse_lines(&lines);
2558 let ranges = super::super::parse_incremental::fence_ranges_from_kinds(&pb.kinds);
2559 let block = ranges.iter().find(|r| r.contains(&2)).cloned();
2560 assert!(block.is_some());
2561 let r = block.unwrap();
2562 assert_eq!(r.start, 1);
2563 assert_eq!(r.end, 4);
2564 }
2565
2566 #[test]
2567 fn code_block_detection_cursor_outside() {
2568 let lines = vec![
2569 "text".to_string(),
2570 "```".to_string(),
2571 "code".to_string(),
2572 "```".to_string(),
2573 ];
2574 let pb = ParsedBuffer::parse_lines(&lines);
2575 let ranges = super::super::parse_incremental::fence_ranges_from_kinds(&pb.kinds);
2576 assert!(ranges.iter().find(|r| r.contains(&0)).is_none());
2577 }
2578
2579 #[test]
2580 fn click_to_logical_does_not_panic_on_stale_layout() {
2581 // Regression: click_to_logical_u16 raw-indexed parsed_buffer.lines
2582 // by vl.logical_row. A stale layout whose visual_lines outlive a
2583 // shrink of parsed_buffer.lines would panic on mouse click. The
2584 // guard now falls back to a raw visual-col mapping.
2585 let mut v = MarkdownEditorView::new();
2586 let long: Vec<String> = (0..20).map(|i| format!("line{}", i)).collect();
2587 update_view(&mut v, &long, (0, 0), rect(10), 1, None);
2588 // Drive a shrink so layout.visual_lines outruns parsed_buffer.lines
2589 // briefly. update() rebuilds layout from the new lines, so the
2590 // pure shrink shouldn't desynchronize them — but we still want a
2591 // black-box test that simulates a click against the last vrow.
2592 let vrows = v.layout.visual_lines().len();
2593 if vrows > 0 {
2594 let _ = v.click_to_logical_u16(vrows.saturating_sub(1), 0);
2595 let _ = v.click_to_logical_u16(vrows + 5, 0);
2596 }
2597 }
2598
2599 #[test]
2600 fn render_does_not_panic_on_stale_cursor_past_line_count() {
2601 // Regression: render() previously did self.parsed_cache[cursor.0]
2602 // and self.layout.visual_lines()[cursor_vrow] directly. A stale
2603 // Nvim snapshot whose cursor row landed past the new line count
2604 // would panic the render thread. Now the test exercises the
2605 // producer-side clamp (via `update_view`'s mirror of
2606 // `snapshot_from_backend`): the snapshot constructor clamps
2607 // the cursor, render trusts the invariant, and direct
2608 // indexing is safe.
2609 use ratatui::Terminal;
2610 use ratatui::backend::TestBackend;
2611 let theme = Theme::gruvbox_dark();
2612 let backend = TestBackend::new(40, 10);
2613 let mut terminal = Terminal::new(backend).unwrap();
2614
2615 let mut v = MarkdownEditorView::new();
2616 // Populate with 2 lines and a valid cursor first so parsed_cache /
2617 // layout are non-empty.
2618 update_view(
2619 &mut v,
2620 &["alpha".to_string(), "beta".to_string()],
2621 (0, 0),
2622 rect(8),
2623 1,
2624 None,
2625 );
2626 // Now feed a cursor row that exceeds the line count for this update
2627 // (simulates a stale snapshot arriving after a shrink). update() at
2628 // line 277 already uses `lines.get(cursor.0)` so it won't panic; the
2629 // real risk was the [] indexes inside render(). cursor_snapshot ends
2630 // up at (5, 0) which exceeds the parsed_cache len of 2 below.
2631 update_view(
2632 &mut v,
2633 &["alpha".to_string(), "beta".to_string()],
2634 (5, 0),
2635 rect(8),
2636 1,
2637 None,
2638 );
2639 // Render with focus so the cursor branch runs.
2640 terminal
2641 .draw(|f| v.render(f, f.area(), &theme, true, Some(CursorShape::Bar)))
2642 .expect("render must not panic on stale cursor");
2643 }
2644
2645 #[test]
2646 fn cursor_into_link_refreshes_layout_for_same_row() {
2647 // Regression: when the cursor moves within a row, crossing into
2648 // or out of an expandable inline element (link/bold/etc.), the
2649 // rendered mask flips (the element reveals or hides its hidden
2650 // sigils). Both rendered_cache and the wrap layout depend on
2651 // the mask. Previously Gate 2 took the `TextChangeKind::None`
2652 // wrap branch and skipped re-splicing, leaving stale visual
2653 // lines until the next text edit.
2654 //
2655 // Use a link whose hidden URL is long enough that revealing it
2656 // forces an extra wrap line at width 40 — that lets us
2657 // black-box detect the mask flip via visual_lines.len().
2658 let mut v = MarkdownEditorView::new();
2659 let lines =
2660 vec more".to_string()];
2661 // First update: cursor outside the link (col 0).
2662 update_view(&mut v, &lines, (0, 0), rect(5), 1, None);
2663 let n_outside = v.layout.visual_lines().len();
2664
2665 // Second update: cursor inside the link element.
2666 update_view(&mut v, &lines, (0, 8), rect(5), 1, None);
2667 let layout_inside = v.layout.visual_lines().to_vec();
2668
2669 // Fresh view with cursor already inside must produce the same layout.
2670 let mut fresh = MarkdownEditorView::new();
2671 update_view(&mut fresh, &lines, (0, 8), rect(5), 1, None);
2672 let layout_fresh = fresh.layout.visual_lines().to_vec();
2673 assert_eq!(
2674 layout_inside, layout_fresh,
2675 "post-move layout must match a fresh full-recompute"
2676 );
2677 assert!(
2678 layout_inside.len() > n_outside,
2679 "expanding the link's hidden URL must produce more visual lines"
2680 );
2681 }
2682
2683 #[test]
2684 fn reported_damage_agrees_with_the_diff_it_replaced() {
2685 // The engine tells the view which rows it changed, so the view no longer
2686 // compares the buffer against a copy of its previous self. The two must
2687 // reach the same answer, or "told" quietly means something else than
2688 // "found" and every parse after an edit is subtly wrong.
2689 // Blank lines between blocks, so widening stops at a paragraph boundary
2690 // rather than reaching the buffer's edges. Without them every damage range
2691 // widens to the whole buffer and this would pass whatever the report says,
2692 // proving nothing about it being read.
2693 let mut lines: Vec<String> = Vec::new();
2694 for block in 0..4 {
2695 lines.push(format!("block {block} first line"));
2696 lines.push(format!("block {block} second line"));
2697 lines.push(String::new());
2698 }
2699 let mut edited = lines.clone();
2700 edited[7].push_str(" more");
2701
2702 let mut found = MarkdownEditorView::new();
2703 update_view(&mut found, &lines, (7, 0), rect(20), 1, None);
2704 let by_diff = found.try_incremental_parse(&text_of(&edited), (7, 0), None);
2705
2706 let mut told = MarkdownEditorView::new();
2707 update_view(&mut told, &lines, (7, 0), rect(20), 1, None);
2708 let by_report = told.try_incremental_parse(&text_of(&edited), (7, 0), Some(7..8));
2709
2710 assert!(by_diff.is_some(), "the diff finds this edit incrementally");
2711 let (diff_range, diff_slice, _) = by_diff.expect("checked");
2712 let (report_range, report_slice, _) = by_report.expect("the report must too");
2713 assert_eq!(diff_range, report_range, "widened ranges diverge");
2714 assert_eq!(diff_slice.kinds, report_slice.kinds, "parsed kinds diverge");
2715 }
2716
2717 #[test]
2718 fn a_report_past_the_end_of_the_buffer_falls_back_to_the_diff() {
2719 // A stale report — rows that no longer exist — must not be trusted. The
2720 // guard is what keeps a report from indexing outside the buffer.
2721 let lines = vec!["alpha".to_string(), "beta".to_string()];
2722 let mut edited = lines.clone();
2723 edited[1].push_str(" more");
2724 let mut v = MarkdownEditorView::new();
2725 update_view(&mut v, &lines, (1, 0), rect(20), 1, None);
2726 assert!(
2727 v.try_incremental_parse(&text_of(&edited), (1, 0), Some(0..99))
2728 .is_some(),
2729 "an out-of-range report falls back rather than panicking"
2730 );
2731 }
2732
2733 #[test]
2734 fn try_incremental_parse_falls_back_on_indented_code_flip() {
2735 // Regression: a Plain row flipping to IndentedCode (4 leading
2736 // spaces) can lazy-extend an indented-code block across the
2737 // following Plain rows in the full buffer. The widened slice
2738 // can't see that context. Guard must trip fallback.
2739 let mut v = MarkdownEditorView::new();
2740 let lines = vec!["alpha".to_string(), "beta".to_string(), "gamma".to_string()];
2741 update_view(&mut v, &lines, (0, 0), rect(20), 1, None);
2742 let new_lines = vec![
2743 "alpha".to_string(),
2744 " beta".to_string(),
2745 "gamma".to_string(),
2746 ];
2747 // try_incremental_parse must return None (full-rebuild signal).
2748 assert!(
2749 v.try_incremental_parse(&text_of(&new_lines), (1, 0), None)
2750 .is_none(),
2751 "indented-code flip must force a full rebuild"
2752 );
2753 }
2754
2755 /// V2 structural guard regression. Buffer `[" code", "",
2756 /// " more"]` has lazy_depth `[1, 1, 1]` (indented code
2757 /// multi-chunk per CommonMark §4.4). An edit at row 1 (the blank
2758 /// inside the block) must trigger fallback, even though the row
2759 /// is itself Blank and would otherwise be a safe-looking
2760 /// boundary candidate.
2761 #[test]
2762 fn try_incremental_parse_falls_back_when_damaged_row_is_inside_lazy_block() {
2763 let mut v = MarkdownEditorView::new();
2764 let lines = vec![
2765 " code".to_string(),
2766 "".to_string(),
2767 " more".to_string(),
2768 ];
2769 update_view(&mut v, &lines, (0, 0), rect(20), 1, None);
2770 assert_eq!(
2771 v.parse_state.buf().lazy_depth,
2772 vec![1, 1, 1],
2773 "precondition: parsed_buffer.lazy_depth must mark all three rows as inside the block"
2774 );
2775 let new_lines = vec![
2776 " code".to_string(),
2777 "x".to_string(),
2778 " more".to_string(),
2779 ];
2780 assert!(
2781 v.try_incremental_parse(&text_of(&new_lines), (1, 1), None)
2782 .is_none(),
2783 "edit inside an open lazy-continuable block must force a full rebuild"
2784 );
2785 }
2786
2787 #[test]
2788 fn try_incremental_parse_falls_back_on_html_block_flip() {
2789 // Regression: a Plain row flipping to an HTML-block opener
2790 // (`<div>`) starts a block that lazy-extends through subsequent
2791 // Plain rows in the full buffer.
2792 let mut v = MarkdownEditorView::new();
2793 let lines = vec!["alpha".to_string(), "beta".to_string(), "gamma".to_string()];
2794 update_view(&mut v, &lines, (0, 0), rect(20), 1, None);
2795 let new_lines = vec![
2796 "alpha".to_string(),
2797 "<div>".to_string(),
2798 "gamma".to_string(),
2799 ];
2800 assert!(
2801 v.try_incremental_parse(&text_of(&new_lines), (1, 0), None)
2802 .is_none(),
2803 "HTML-block opener flip must force a full rebuild"
2804 );
2805 }
2806
2807 #[test]
2808 fn is_in_code_block_returns_true_for_any_fence_regardless_of_cursor() {
2809 // Regression: after commit cceef444, every fenced block renders
2810 // force-raw — not just the one the cursor sits in. Verify by
2811 // probing `is_in_code_block` for a row in a fence while the
2812 // cursor is positioned elsewhere.
2813 let mut v = MarkdownEditorView::new();
2814 let lines = vec![
2815 "intro".to_string(),
2816 "```".to_string(),
2817 "code".to_string(),
2818 "```".to_string(),
2819 "outro".to_string(),
2820 ];
2821 // Cursor on the prose line; fence interior must still report in-block.
2822 update_view(&mut v, &lines, (4, 0), rect(10), 1, None);
2823 assert!(v.is_in_code_block(2), "fence interior is in-block");
2824 assert!(!v.is_in_code_block(0), "prose line is not in-block");
2825 assert!(!v.is_in_code_block(4), "trailing prose is not in-block");
2826 }
2827
2828 #[test]
2829 fn parsed_cache_populated_after_update() {
2830 let mut v = MarkdownEditorView::new();
2831 let lines = vec!["hello".to_string(), "**bold**".to_string()];
2832 update_view(&mut v, &lines, (0, 0), rect(10), 1, None);
2833 assert_eq!(v.parse_state.buf().lines.len(), 2);
2834 }
2835
2836 #[test]
2837 fn layout_skipped_on_horizontal_cursor_move_in_plain_text() {
2838 let mut v = MarkdownEditorView::new();
2839 let lines = vec!["hello world".to_string()];
2840 update_view(&mut v, &lines, (0, 0), rect(40), 1, None);
2841 let layout_gen_after_first = v.last_layout_generation;
2842 // Move cursor right — same row, no elements, same generation → layout must be skipped.
2843 update_view(&mut v, &lines, (0, 5), rect(40), 1, None);
2844 assert_eq!(
2845 v.last_layout_cursor,
2846 (0, 0),
2847 "layout cursor unchanged = layout was skipped"
2848 );
2849 assert_eq!(v.last_layout_generation, layout_gen_after_first);
2850 }
2851
2852 #[test]
2853 fn layout_recomputed_on_row_change() {
2854 let mut v = MarkdownEditorView::new();
2855 let lines: Vec<String> = (0..3).map(|i| format!("line{}", i)).collect();
2856 update_view(&mut v, &lines, (0, 0), rect(40), 1, None);
2857 update_view(&mut v, &lines, (1, 0), rect(40), 1, None); // cursor moves to row 1
2858 assert_eq!(v.last_layout_cursor.0, 1, "layout recomputed on row change");
2859 }
2860
2861 #[test]
2862 fn layout_recomputed_on_width_change() {
2863 let mut v = MarkdownEditorView::new();
2864 let lines = vec!["hello world foo bar".to_string()];
2865 update_view(&mut v, &lines, (0, 0), rect(40), 1, None);
2866 update_view(
2867 &mut v,
2868 &lines,
2869 (0, 0),
2870 Rect {
2871 x: 0,
2872 y: 0,
2873 width: 10,
2874 height: 10,
2875 },
2876 1,
2877 None,
2878 );
2879 assert_eq!(v.last_layout_width, 10);
2880 }
2881
2882 #[test]
2883 fn same_generation_skips_snapshot_rebuild() {
2884 let mut v = MarkdownEditorView::new();
2885 let lines = vec!["original".to_string()];
2886 update_view(&mut v, &lines, (0, 0), rect(10), 1, None);
2887 // Update with different content but same generation — snapshot must NOT change.
2888 let lines2 = vec!["changed".to_string()];
2889 update_view(&mut v, &lines2, (0, 0), rect(10), 1, None);
2890 assert_eq!(v.text_snapshot.to_string(), "original");
2891 }
2892
2893 #[test]
2894 fn new_generation_triggers_snapshot_rebuild() {
2895 let mut v = MarkdownEditorView::new();
2896 let lines = vec!["original".to_string()];
2897 update_view(&mut v, &lines, (0, 0), rect(10), 1, None);
2898 let lines2 = vec!["changed".to_string()];
2899 update_view(&mut v, &lines2, (0, 0), rect(10), 2, None);
2900 assert_eq!(v.text_snapshot.to_string(), "changed");
2901 }
2902
2903 /// Task and needle decoration moved out of a cell-space post-pass and into
2904 /// overlay derivation. Same behaviour, logical coordinates, visible rows
2905 /// only — and now expressible without a terminal buffer.
2906 #[test]
2907 fn content_overlays_cover_needles_and_tasks() {
2908 let mut v = MarkdownEditorView::new();
2909 let lines = vec![
2910 "find the needle here".to_string(),
2911 "- [x] done task".to_string(),
2912 "- [ ] open task".to_string(),
2913 ];
2914 v.set_needles(vec!["needle".to_string()]);
2915 update_view(&mut v, &lines, (0, 0), rect(40), 1, None);
2916
2917 let kinds: Vec<_> = v.overlays.iter().map(|o| (o.row, o.kind)).collect();
2918 assert!(
2919 kinds.contains(&(0, OverlayKind::Needle)),
2920 "the needle must be emphasised, got {kinds:?}"
2921 );
2922 assert!(kinds.contains(&(1, OverlayKind::TaskBox)));
2923 assert!(
2924 kinds.contains(&(1, OverlayKind::TaskDone)),
2925 "a done task strikes its text"
2926 );
2927 assert!(kinds.contains(&(2, OverlayKind::TaskBox)));
2928 assert!(
2929 !kinds.contains(&(2, OverlayKind::TaskDone)),
2930 "an open task does not"
2931 );
2932
2933 // "needle" starts at logical char 9 — a logical column, not a cell.
2934 let needle = v
2935 .overlays
2936 .iter()
2937 .find(|o| o.kind == OverlayKind::Needle)
2938 .unwrap();
2939 assert_eq!((needle.start, needle.end), (9, 15));
2940 }
2941
2942 #[test]
2943 fn update_takes_a_selection_overlay() {
2944 let mut v = MarkdownEditorView::new();
2945 let lines = vec!["hello world".to_string()];
2946 update_view(&mut v, &lines, (0, 0), rect(40), 1, Some(((0, 0), (0, 5))));
2947 assert_eq!(
2948 v.overlays,
2949 vec![Overlay::new(0, 0, 5, OverlayKind::Selection)]
2950 );
2951 }
2952
2953 /// Overlays belong to the frame they were built for: `update` clears them,
2954 /// so a caller that stops producing one cannot leave it painted.
2955 #[test]
2956 fn update_clears_the_previous_frame_s_overlays() {
2957 let mut v = MarkdownEditorView::new();
2958 let lines = vec!["hello world".to_string()];
2959 update_view(&mut v, &lines, (0, 0), rect(40), 1, Some(((0, 0), (0, 5))));
2960 update_view(&mut v, &lines, (0, 0), rect(40), 1, None);
2961 assert!(v.overlays.is_empty());
2962 }
2963
2964 #[test]
2965 fn typing_single_char_in_long_buffer_uses_incremental_path() {
2966 let mut v = MarkdownEditorView::new();
2967 let mut lines: Vec<String> = (0..1000).map(|i| format!("paragraph {i}")).collect();
2968 update_view(&mut v, &lines, (500, 0), rect(40), 1, None);
2969 // The 1000-line buffer takes the async-parse placeholder path on
2970 // first parse. Simulate the background task completing before the
2971 // edit so the next update splices against a real (non-placeholder)
2972 // buffer; Gate 1 deliberately refuses to incrementally splice the
2973 // all-`Plain` placeholder.
2974 v.install_full_parse(1, ParsedBuffer::parse_lines(&lines));
2975
2976 // Single-char insert at row 500.
2977 lines[500].push('x');
2978 let edited_len = lines[500].len();
2979 update_view(&mut v, &lines, (500, edited_len), rect(40), 2, None);
2980
2981 // The spliced result must equal a fresh full parse.
2982 let fresh = ParsedBuffer::parse_lines(&lines);
2983 assert_eq!(v.parse_state.buf().lines.len(), fresh.lines.len());
2984 assert_eq!(v.parse_state.buf().kinds, fresh.kinds);
2985 // Regression: the heuristic widener splices a slice whose
2986 // local sentinel boundaries (slice rows 0 and len) are NOT
2987 // genuine reset boundaries of the merged buffer. splice must
2988 // not promote them — a 1000-line single-paragraph buffer has
2989 // reset boundaries only at [0, 1000].
2990 assert_eq!(
2991 v.parse_state.buf().reset_boundaries,
2992 fresh.reset_boundaries,
2993 "heuristic splice must not introduce spurious reset boundaries"
2994 );
2995 // And the incremental path was actually taken.
2996 assert!(
2997 v.last_parse_was_incremental,
2998 "single-char paragraph edit should take incremental path"
2999 );
3000 }
3001
3002 #[test]
3003 fn edit_while_placeholder_active_refuses_incremental_and_rearms() {
3004 // Regression: a large-buffer edit installs an unstyled placeholder
3005 // (all-`Plain` kinds) pending a background full parse. If the next
3006 // edit lands before the parse completes, Gate 1 must NOT splice the
3007 // placeholder — its all-`Plain` kinds defeat the structural guards
3008 // and would lock in a wrong parse that install_full_parse then drops
3009 // as stale. The edit must re-install a placeholder + re-arm pending.
3010 let mut v = MarkdownEditorView::new();
3011 let mut lines: Vec<String> = (0..1000).map(|i| format!("paragraph {i}")).collect();
3012 update_view(&mut v, &lines, (0, 0), rect(40), 1, None);
3013 assert!(
3014 v.parse_state.is_placeholder(),
3015 "first parse installs placeholder"
3016 );
3017 assert_eq!(v.take_pending_full_parse(), Some(1));
3018
3019 // Edit before the background parse resolves the placeholder.
3020 lines[0].push_str("```");
3021 update_view(&mut v, &lines, (0, lines[0].len()), rect(40), 2, None);
3022 assert!(
3023 !v.last_parse_was_incremental,
3024 "must not splice the placeholder"
3025 );
3026 assert!(
3027 v.parse_state.is_placeholder(),
3028 "still placeholder pending parse"
3029 );
3030 assert_eq!(
3031 v.take_pending_full_parse(),
3032 Some(2),
3033 "re-armed for new generation"
3034 );
3035
3036 // Background parse for the latest generation completes.
3037 v.install_full_parse(2, ParsedBuffer::parse_lines(&lines));
3038 assert!(
3039 !v.parse_state.is_placeholder(),
3040 "placeholder cleared on install"
3041 );
3042 assert_eq!(
3043 v.parse_state.buf().kinds,
3044 ParsedBuffer::parse_lines(&lines).kinds
3045 );
3046 }
3047
3048 #[test]
3049 #[should_panic(expected = "splice on placeholder parse")]
3050 fn splice_real_on_placeholder_is_rejected() {
3051 // The type makes the wrong-splice hazard unrepresentable on the
3052 // Gate 1 path; this guards the `ParseState::splice_real` contract
3053 // directly so a future caller can't route a splice into a
3054 // placeholder without tripping the assert.
3055 let mut state = ParseState::Placeholder {
3056 buf: ParsedBuffer::placeholder_lines(&["x".to_string()]),
3057 generation: 1,
3058 spawned: false,
3059 };
3060 state.splice_real(0..1, ParsedBuffer::parse_lines(&["y".to_string()]));
3061 }
3062
3063 #[test]
3064 fn fence_toggle_triggers_full_rebuild_fallback() {
3065 let mut v = MarkdownEditorView::new();
3066 // Use 700 lines so that an unclosed fence at row 350 widens to
3067 // end-of-buffer (~351 rows), exceeding the absolute cap (256).
3068 // Below the perf #9 LARGE_BUFFER_THRESHOLD (1000), so the
3069 // fallback runs synchronously and `parsed_buffer.kinds`
3070 // matches a fresh full parse immediately.
3071 let mut lines: Vec<String> = (0..700).map(|i| format!("paragraph {i}")).collect();
3072 update_view(&mut v, &lines, (350, 0), rect(40), 1, None);
3073
3074 // Open a fence mid-buffer — structurally invasive, line count changes.
3075 lines.insert(350, "```".to_string());
3076 update_view(&mut v, &lines, (350, 3), rect(40), 2, None);
3077
3078 let fresh = ParsedBuffer::parse_lines(&lines);
3079 assert_eq!(
3080 v.parse_state.buf().kinds,
3081 fresh.kinds,
3082 "spliced kinds must equal fresh full parse"
3083 );
3084 // The unclosed fence at row 350 widens to end-of-buffer (~351 lines,
3085 // > 256 cap_abs), so the cap trips and the fallback fires.
3086 assert!(
3087 !v.last_parse_was_incremental,
3088 "fence toggle (unclosed fence, 700-line buffer) should fall back to full rebuild"
3089 );
3090 // Buffer < LARGE_BUFFER_THRESHOLD → sync fallback, no
3091 // pending-async signal.
3092 assert!(
3093 v.take_pending_full_parse().is_none(),
3094 "small-buffer fallback must NOT defer to async"
3095 );
3096 }
3097
3098 #[test]
3099 fn fence_toggle_on_large_buffer_defers_to_async_fallback() {
3100 // Regression for perf #9: above LARGE_BUFFER_THRESHOLD, the
3101 // fallback installs a placeholder ParsedBuffer + signals
3102 // pending instead of blocking the typing thread on
3103 // ParsedBuffer::parse. The owning component spawns the real
3104 // parse on tokio and calls install_full_parse when done.
3105 let mut v = MarkdownEditorView::new();
3106 let mut lines: Vec<String> = (0..1500).map(|i| format!("paragraph {i}")).collect();
3107 update_view(&mut v, &lines, (750, 0), rect(40), 1, None);
3108
3109 // Force a fallback path on a large buffer.
3110 lines.insert(750, "```".to_string());
3111 update_view(&mut v, &lines, (750, 3), rect(40), 2, None);
3112
3113 assert!(
3114 !v.last_parse_was_incremental,
3115 "fence toggle on 1500-line buffer should fall back"
3116 );
3117 let pending = v.take_pending_full_parse();
3118 assert!(
3119 pending.is_some(),
3120 "large-buffer fallback must signal pending async parse"
3121 );
3122 // Placeholder kinds: every row is Plain — no fence detection yet.
3123 assert!(
3124 v.parse_state
3125 .buf()
3126 .kinds
3127 .iter()
3128 .all(|k| matches!(k, super::super::parse_incremental::LineConstructKind::Plain)),
3129 "placeholder must classify every row as Plain"
3130 );
3131 assert_eq!(
3132 v.parse_state.buf().lines.len(),
3133 lines.len(),
3134 "placeholder row count must match input"
3135 );
3136
3137 // Caller (TextEditorComponent in production) spawns the real
3138 // parse and installs the result. Simulate that here.
3139 let real = ParsedBuffer::parse_lines(&lines);
3140 let generation = pending.unwrap();
3141 v.install_full_parse(generation, real);
3142 let fresh = ParsedBuffer::parse_lines(&lines);
3143 assert_eq!(
3144 v.parse_state.buf().kinds,
3145 fresh.kinds,
3146 "post-install kinds must match fresh full parse"
3147 );
3148 }
3149
3150 /// Rows long enough that a real 40-wide wrap would split them into
3151 /// more than one visual line each — needed to tell a `Layout::unwrapped`
3152 /// stub (always exactly one visual line per row) apart from a real
3153 /// compute that happens not to have wrapped anything.
3154 fn make_long_lines(n: usize) -> Vec<String> {
3155 (0..n)
3156 .map(|i| {
3157 format!(
3158 "paragraph number {i} with quite a bit of extra padding text \
3159 so this row is longer than forty columns wide for sure"
3160 )
3161 })
3162 .collect()
3163 }
3164
3165 #[test]
3166 fn layout_defers_to_async_fallback_on_large_buffer() {
3167 // Layout-side twin of `fence_toggle_on_large_buffer_defers_to_async_fallback`:
3168 // above LARGE_BUFFER_THRESHOLD, a full-rebuild trigger installs a
3169 // `Layout::unwrapped` stub + signals pending instead of blocking
3170 // the typing thread on `Layout::compute`. The owning component
3171 // spawns the real wrap on tokio and calls install_full_layout
3172 // when done.
3173 let mut v = MarkdownEditorView::new();
3174 let mut lines = make_long_lines(1500);
3175 update_view(&mut v, &lines, (750, 0), rect(40), 1, None);
3176
3177 // Line-count change forces a full layout rebuild regardless of
3178 // what the parse decided.
3179 lines.insert(750, "```".to_string());
3180 update_view(&mut v, &lines, (750, 3), rect(40), 2, None);
3181
3182 let pending = v.take_pending_full_layout();
3183 assert!(
3184 pending.is_some(),
3185 "large-buffer full layout rebuild must signal pending async wrap"
3186 );
3187 assert_eq!(
3188 v.layout.row_count(),
3189 lines.len(),
3190 "stub row count must match input"
3191 );
3192 let real_visual_lines = {
3193 let hints = row_hints(&v.rendered_cache, &v.gutter_insets);
3194 Layout::compute(&v.text_snapshot, 40, Metrics::default(), &hints).visual_line_count()
3195 };
3196 assert!(
3197 v.layout.visual_line_count() < real_visual_lines,
3198 "the installed stub must not have wrapped these long rows yet \
3199 (stub: {}, real: {})",
3200 v.layout.visual_line_count(),
3201 real_visual_lines
3202 );
3203
3204 // Caller (TextEditorComponent in production) spawns the real wrap
3205 // and installs the result. Simulate that here.
3206 let job = pending.unwrap();
3207 let hints = row_hints(&job.rendered_cache, &job.gutter_insets);
3208 let real = Layout::compute(&job.text, job.width, Metrics::default(), &hints);
3209 let real_count = real.visual_line_count();
3210 v.install_full_layout(job.generation, real);
3211 assert!(v.layout_pending.is_none(), "pending cleared on install");
3212 assert_eq!(
3213 v.layout.visual_line_count(),
3214 real_count,
3215 "post-install layout must equal a fresh compute"
3216 );
3217 }
3218
3219 #[test]
3220 fn small_buffer_layout_stays_synchronous() {
3221 // Mirrors `fence_toggle_triggers_full_rebuild_fallback`: below
3222 // LARGE_BUFFER_THRESHOLD, layout rebuilds stay synchronous.
3223 let mut v = MarkdownEditorView::new();
3224 let mut lines = make_long_lines(700);
3225 update_view(&mut v, &lines, (350, 0), rect(40), 1, None);
3226 assert!(
3227 v.take_pending_full_layout().is_none(),
3228 "small buffer must not defer layout on first parse"
3229 );
3230
3231 lines.insert(350, "```".to_string());
3232 update_view(&mut v, &lines, (350, 3), rect(40), 2, None);
3233 assert!(
3234 v.take_pending_full_layout().is_none(),
3235 "small-buffer full rebuild must NOT defer layout to async"
3236 );
3237 }
3238
3239 #[test]
3240 fn edit_while_layout_pending_rearms() {
3241 // Layout-side twin of
3242 // `edit_while_placeholder_active_refuses_incremental_and_rearms`:
3243 // an edit landing before the async wrap resolves must re-stub and
3244 // re-arm for the new generation, and a stale (superseded)
3245 // install must be a no-op.
3246 let mut v = MarkdownEditorView::new();
3247 let mut lines = make_long_lines(1500);
3248 update_view(&mut v, &lines, (750, 0), rect(40), 1, None);
3249 assert!(
3250 v.layout_pending.is_some(),
3251 "first layout defers on a large buffer"
3252 );
3253 assert_eq!(v.take_pending_full_layout().map(|j| j.generation), Some(1));
3254
3255 // Edit before the background wrap resolves.
3256 lines[0].push('x');
3257 update_view(&mut v, &lines, (0, lines[0].len()), rect(40), 2, None);
3258 assert!(
3259 v.layout_pending.is_some(),
3260 "still pending — a content-changing edit must not silently keep the stale stub"
3261 );
3262 assert_eq!(
3263 v.take_pending_full_layout().map(|j| j.generation),
3264 Some(2),
3265 "re-armed for the new generation"
3266 );
3267
3268 // A stale install (superseded generation) must be dropped.
3269 let stale = Layout::compute(&text_of(&lines), 40, Metrics::default(), &[]);
3270 v.install_full_layout(1, stale);
3271 assert!(
3272 v.layout_pending.is_some(),
3273 "stale-generation install must be a no-op"
3274 );
3275
3276 // The current generation's install lands.
3277 let hints = row_hints(&v.rendered_cache, &v.gutter_insets);
3278 let real = Layout::compute(&v.text_snapshot, 40, Metrics::default(), &hints);
3279 v.install_full_layout(2, real);
3280 assert!(
3281 v.layout_pending.is_none(),
3282 "pending cleared on matching-generation install"
3283 );
3284 }
3285
3286 #[test]
3287 fn install_full_layout_rejects_width_mismatch_from_a_resize() {
3288 // A resize with no content change never bumps content_revision, so
3289 // the generation check alone cannot catch a wrap job computed for
3290 // a width the pane no longer has — `install_full_layout` must also
3291 // compare `layout.width()` against the current `last_layout_width`.
3292 let mut v = MarkdownEditorView::new();
3293 let lines = make_long_lines(1200);
3294 update_view(&mut v, &lines, (0, 0), rect(40), 1, None);
3295 let job = v
3296 .take_pending_full_layout()
3297 .expect("large buffer defers layout on first parse");
3298 assert_eq!(job.width, 40);
3299
3300 // Pane resized before the background wrap for width 40 lands.
3301 update_view(
3302 &mut v,
3303 &lines,
3304 (0, 0),
3305 Rect {
3306 width: 80,
3307 ..rect(40)
3308 },
3309 1,
3310 None,
3311 );
3312
3313 let hints = row_hints(&job.rendered_cache, &job.gutter_insets);
3314 let stale_width_layout = Layout::compute(&job.text, job.width, Metrics::default(), &hints);
3315 v.install_full_layout(job.generation, stale_width_layout);
3316 assert!(
3317 v.layout_pending.is_some(),
3318 "width-mismatched install must be rejected even though the generation matches"
3319 );
3320 }
3321
3322 /// Assert the view's cached parse matches a fresh one.
3323 ///
3324 /// The per-line comparison uses `debug_assert_eq_to`, which is
3325 /// `#[cfg(debug_assertions)]` like its one production caller — so the
3326 /// *body* is gated, not the function. Gating the whole helper would remove
3327 /// a symbol six tests call; leaving it ungated stops the lib-test target
3328 /// compiling under any profile with assertions off, which is why
3329 /// `cargo bench --no-run` did not build.
3330 fn full_rebuild_equals_view_state(v: &MarkdownEditorView, lines: &[String]) {
3331 #[cfg(not(debug_assertions))]
3332 let _ = (v, lines);
3333 #[cfg(debug_assertions)]
3334 {
3335 let fresh = ParsedBuffer::parse_lines(lines);
3336 assert_eq!(v.parse_state.buf().kinds, fresh.kinds, "kinds diverge");
3337 assert_eq!(
3338 v.parse_state.buf().lines.len(),
3339 fresh.lines.len(),
3340 "row count diverge"
3341 );
3342 for (i, (got, exp)) in v
3343 .parse_state
3344 .buf()
3345 .lines
3346 .iter()
3347 .zip(fresh.lines.iter())
3348 .enumerate()
3349 {
3350 got.debug_assert_eq_to(exp, i);
3351 }
3352 }
3353 }
3354
3355 #[test]
3356 fn incremental_falls_back_when_fence_marker_modified() {
3357 // Regression: editing a row that is currently a FenceMarker can
3358 // change the fence's extent across the rest of the buffer.
3359 // Incremental parsing's window-bounded widening cannot capture
3360 // this, so we must fall back to a full parse.
3361 let mut v = MarkdownEditorView::new();
3362 let mut lines = vec!["```".to_string(), "".to_string(), "```".to_string()];
3363 // Fill out the buffer with blank lines so the cap doesn't trip first.
3364 for _ in 0..31 {
3365 lines.push(String::new());
3366 }
3367 update_view(&mut v, &lines, (2, 0), rect(40), 1, None);
3368
3369 // Edit the closing fence marker — append a char so it's no longer a closer.
3370 let mut new_lines = lines.clone();
3371 new_lines[2].push('0');
3372 update_view(&mut v, &new_lines, (2, 4), rect(40), 2, None);
3373
3374 assert!(
3375 !v.last_parse_was_incremental,
3376 "fence-marker edit must trigger full-rebuild fallback"
3377 );
3378 // And the resulting state must equal a fresh parse (which the
3379 // fallback path does anyway, but assert defensively).
3380 full_rebuild_equals_view_state(&v, &new_lines);
3381 }
3382
3383 #[test]
3384 fn incremental_paste_large_block_falls_back() {
3385 let mut v = MarkdownEditorView::new();
3386 let mut lines: Vec<String> = (0..50).map(|i| format!("line {i}")).collect();
3387 update_view(&mut v, &lines, (25, 0), rect(40), 1, None);
3388
3389 // Insert 300 lines at row 25.
3390 let payload: Vec<String> = (0..300).map(|i| format!("pasted {i}")).collect();
3391 for (offset, p) in payload.into_iter().enumerate() {
3392 lines.insert(25 + offset, p);
3393 }
3394 update_view(&mut v, &lines, (25, 0), rect(40), 2, None);
3395 assert!(
3396 !v.last_parse_was_incremental,
3397 "300-line paste must fall back"
3398 );
3399 full_rebuild_equals_view_state(&v, &lines);
3400 }
3401
3402 #[test]
3403 fn incremental_enter_at_line_end() {
3404 let mut v = MarkdownEditorView::new();
3405 let lines = vec!["alpha".to_string(), "beta".to_string()];
3406 update_view(&mut v, &lines, (0, 5), rect(40), 1, None);
3407
3408 // Press Enter at end of "alpha".
3409 let new_lines = vec!["alpha".to_string(), "".to_string(), "beta".to_string()];
3410 update_view(&mut v, &new_lines, (1, 0), rect(40), 2, None);
3411 full_rebuild_equals_view_state(&v, &new_lines);
3412 }
3413
3414 #[test]
3415 fn incremental_backspace_merging_lines() {
3416 let mut v = MarkdownEditorView::new();
3417 let lines = vec!["alpha".to_string(), "beta".to_string()];
3418 update_view(&mut v, &lines, (1, 0), rect(40), 1, None);
3419
3420 // Backspace at start of "beta" merges into "alphabeta".
3421 let new_lines = vec!["alphabeta".to_string()];
3422 update_view(&mut v, &new_lines, (0, 5), rect(40), 2, None);
3423 full_rebuild_equals_view_state(&v, &new_lines);
3424 }
3425
3426 #[test]
3427 fn incremental_inside_fence_widens_both_markers() {
3428 let mut v = MarkdownEditorView::new();
3429 let lines = vec![
3430 "intro".to_string(),
3431 "".to_string(),
3432 "```rust".to_string(),
3433 "let x = 1;".to_string(),
3434 "let y = 2;".to_string(),
3435 "```".to_string(),
3436 "".to_string(),
3437 "outro".to_string(),
3438 ];
3439 update_view(&mut v, &lines, (3, 0), rect(40), 1, None);
3440
3441 // Edit inside the fence (same-length, no line-count change).
3442 let mut new_lines = lines.clone();
3443 new_lines[3] = "let x = 999;".to_string();
3444 update_view(&mut v, &new_lines, (3, 8), rect(40), 2, None);
3445 full_rebuild_equals_view_state(&v, &new_lines);
3446 }
3447
3448 #[test]
3449 fn incremental_list_continuation_widens_to_outer_marker() {
3450 let mut v = MarkdownEditorView::new();
3451 let lines = vec![
3452 "- top".to_string(),
3453 " body of top".to_string(),
3454 " - nested".to_string(),
3455 " body of nested".to_string(),
3456 " body two".to_string(),
3457 "".to_string(),
3458 "outro".to_string(),
3459 ];
3460 update_view(&mut v, &lines, (4, 0), rect(40), 1, None);
3461
3462 // Edit the nested continuation line.
3463 let mut new_lines = lines.clone();
3464 new_lines[4] = " body two changed".to_string();
3465 update_view(&mut v, &new_lines, (4, 10), rect(40), 2, None);
3466 full_rebuild_equals_view_state(&v, &new_lines);
3467 }
3468
3469 #[test]
3470 fn incremental_setext_underline_edit() {
3471 let mut v = MarkdownEditorView::new();
3472 let lines = vec![
3473 "heading text".to_string(),
3474 "====".to_string(),
3475 "".to_string(),
3476 "body".to_string(),
3477 ];
3478 update_view(&mut v, &lines, (1, 0), rect(40), 1, None);
3479
3480 // Edit the underline (same line count).
3481 let mut new_lines = lines.clone();
3482 new_lines[1] = "======".to_string();
3483 update_view(&mut v, &new_lines, (1, 6), rect(40), 2, None);
3484 full_rebuild_equals_view_state(&v, &new_lines);
3485 }
3486
3487 #[test]
3488 fn incremental_blockquote_paragraph_edit() {
3489 let mut v = MarkdownEditorView::new();
3490 let lines = vec![
3491 "intro".to_string(),
3492 "".to_string(),
3493 "> quoted line one".to_string(),
3494 "> quoted line two".to_string(),
3495 "> quoted line three".to_string(),
3496 "".to_string(),
3497 "outro".to_string(),
3498 ];
3499 update_view(&mut v, &lines, (3, 0), rect(40), 1, None);
3500
3501 let mut new_lines = lines.clone();
3502 new_lines[3] = "> quoted line TWO".to_string();
3503 update_view(&mut v, &new_lines, (3, 17), rect(40), 2, None);
3504 full_rebuild_equals_view_state(&v, &new_lines);
3505 }
3506
3507 #[test]
3508 fn incremental_html_block_edit() {
3509 let mut v = MarkdownEditorView::new();
3510 let lines = vec![
3511 "before".to_string(),
3512 "".to_string(),
3513 "<div>".to_string(),
3514 "body".to_string(),
3515 "</div>".to_string(),
3516 "".to_string(),
3517 "after".to_string(),
3518 ];
3519 update_view(&mut v, &lines, (3, 0), rect(40), 1, None);
3520
3521 let mut new_lines = lines.clone();
3522 new_lines[3] = "body changed".to_string();
3523 update_view(&mut v, &new_lines, (3, 12), rect(40), 2, None);
3524 full_rebuild_equals_view_state(&v, &new_lines);
3525 }
3526
3527 #[test]
3528 fn g1_nested_list_three_indent_continuation() {
3529 // Deeply nested continuation: damaged range touches a 3-indent
3530 // continuation line. Widening must reach the outermost col-0
3531 // ListMarker — otherwise parse_range sees ` text` as
3532 // IndentedCode.
3533 let mut v = MarkdownEditorView::new();
3534 let lines = vec![
3535 "intro".to_string(),
3536 "".to_string(),
3537 "- level 0".to_string(),
3538 " - level 1".to_string(),
3539 " - level 2".to_string(),
3540 " continuation at 6 indent".to_string(),
3541 "".to_string(),
3542 "after".to_string(),
3543 ];
3544 update_view(&mut v, &lines, (5, 0), rect(40), 1, None);
3545
3546 let mut new_lines = lines.clone();
3547 new_lines[5] = " continuation at 6 indent EDITED".to_string();
3548 update_view(&mut v, &new_lines, (5, 30), rect(40), 2, None);
3549 full_rebuild_equals_view_state(&v, &new_lines);
3550 }
3551
3552 #[test]
3553 fn g3_hashtag_inside_fence_not_labeled_after_incremental_edit() {
3554 // `#tag` inside a fenced code block must NOT produce a Label element.
3555 // After an incremental edit fully inside the fence, the widened
3556 // slice includes both fence markers — the label-suppression scan
3557 // sees the fence and skips. This test verifies the round-trip.
3558 let mut v = MarkdownEditorView::new();
3559 let lines = vec![
3560 "intro".to_string(),
3561 "".to_string(),
3562 "```".to_string(),
3563 "let s = \"#tag\";".to_string(),
3564 "// another #tag".to_string(),
3565 "```".to_string(),
3566 "".to_string(),
3567 "outro".to_string(),
3568 ];
3569 update_view(&mut v, &lines, (4, 0), rect(40), 1, None);
3570
3571 use crate::components::text_editor::markdown::ElementKind;
3572
3573 // Pre-condition: no Label elements in the fence interior.
3574 for row in 3..5 {
3575 let has_label = v.parse_state.buf().lines[row]
3576 .elements
3577 .iter()
3578 .any(|e| matches!(e.kind, ElementKind::Label));
3579 assert!(
3580 !has_label,
3581 "row {row} should have no Label inside the fence"
3582 );
3583 }
3584
3585 // Edit one of the in-fence lines.
3586 let mut new_lines = lines.clone();
3587 new_lines[4] = "// edited #tag here".to_string();
3588 update_view(&mut v, &new_lines, (4, 19), rect(40), 2, None);
3589
3590 // Post-condition: still no Label elements in the fence interior.
3591 for row in 3..5 {
3592 let has_label = v.parse_state.buf().lines[row]
3593 .elements
3594 .iter()
3595 .any(|e| matches!(e.kind, ElementKind::Label));
3596 assert!(
3597 !has_label,
3598 "row {row} should still have no Label after incremental edit"
3599 );
3600 }
3601 full_rebuild_equals_view_state(&v, &new_lines);
3602 }
3603
3604 #[test]
3605 fn g8a_typing_into_empty_buffer() {
3606 let mut v = MarkdownEditorView::new();
3607 let empty = vec!["".to_string()];
3608 update_view(&mut v, &empty, (0, 0), rect(40), 1, None);
3609
3610 let one = vec!["h".to_string()];
3611 update_view(&mut v, &one, (0, 1), rect(40), 2, None);
3612 full_rebuild_equals_view_state(&v, &one);
3613
3614 let two = vec!["he".to_string()];
3615 update_view(&mut v, &two, (0, 2), rect(40), 3, None);
3616 full_rebuild_equals_view_state(&v, &two);
3617
3618 let many = vec!["hello world".to_string()];
3619 update_view(&mut v, &many, (0, 11), rect(40), 4, None);
3620 full_rebuild_equals_view_state(&v, &many);
3621 }
3622
3623 #[test]
3624 fn g8b_delete_last_char_one_line_buffer() {
3625 let mut v = MarkdownEditorView::new();
3626 let one = vec!["h".to_string()];
3627 update_view(&mut v, &one, (0, 1), rect(40), 1, None);
3628
3629 let empty = vec!["".to_string()];
3630 update_view(&mut v, &empty, (0, 0), rect(40), 2, None);
3631 full_rebuild_equals_view_state(&v, &empty);
3632 }
3633
3634 #[test]
3635 fn incremental_text_change_produces_same_layout_as_full_recompute() {
3636 let mut v = MarkdownEditorView::new();
3637 let lines: Vec<String> = (0..200)
3638 .map(|i| format!("paragraph {i} with some text that may wrap depending on width"))
3639 .collect();
3640 update_view(&mut v, &lines, (100, 0), rect(40), 1, None);
3641 let baseline_visual_lines = v.layout.visual_lines().to_vec();
3642
3643 // Edit a paragraph mid-buffer (no line count change).
3644 let mut edited = lines.clone();
3645 edited[100].push_str(" extra text");
3646 update_view(&mut v, &edited, (100, edited[100].len()), rect(40), 2, None);
3647
3648 // After incremental wrap, layout must equal a fresh compute of the edited buffer.
3649 let fresh_text = crate::ropetext::Text::from(edited.join("\n").as_str());
3650 let fresh_hints = row_hints(v.rendered_cache_for_testing(), &[]);
3651 let fresh_layout = Layout::compute(&fresh_text, 40, Metrics::default(), &fresh_hints);
3652
3653 let actual = v.layout.visual_lines();
3654 let fresh = fresh_layout.visual_lines();
3655 assert_eq!(actual.len(), fresh.len(), "visual_lines count diverges");
3656 for (i, (a, f)) in actual.iter().zip(fresh.iter()).enumerate() {
3657 assert_eq!(a, f, "visual line {i} diverges");
3658 }
3659
3660 // Sanity: a row outside the edit should have unchanged visual lines.
3661 let row_50_before = baseline_visual_lines
3662 .iter()
3663 .filter(|vl| vl.logical_row == 50)
3664 .count();
3665 let row_50_after = v
3666 .layout
3667 .visual_lines()
3668 .iter()
3669 .filter(|vl| vl.logical_row == 50)
3670 .count();
3671 assert_eq!(
3672 row_50_before, row_50_after,
3673 "row 50 visual_lines count should be unchanged"
3674 );
3675
3676 assert!(v.last_parse_was_incremental, "expected incremental path");
3677 }
3678
3679 #[test]
3680 fn incremental_edit_reuses_fence_ranges_without_rescanning() {
3681 // A fence block plus plain paragraphs elsewhere. An edit inside a
3682 // plain paragraph (not touching the fence) must take the
3683 // incremental path — at which point `fence_ranges` is skipped
3684 // rather than rescanned, per the structural guards that already
3685 // gate the splice. Verify it stays correct anyway.
3686 let mut v = MarkdownEditorView::new();
3687 let mut lines: Vec<String> = vec![
3688 "```".to_string(),
3689 "code line".to_string(),
3690 "```".to_string(),
3691 ];
3692 lines.extend((0..200).map(|i| format!("paragraph {i} with some text")));
3693 update_view(&mut v, &lines, (100, 0), rect(40), 1, None);
3694
3695 lines[100].push('x');
3696 update_view(&mut v, &lines, (100, lines[100].len()), rect(40), 2, None);
3697 assert!(v.last_parse_was_incremental, "expected incremental path");
3698
3699 let fresh = ParsedBuffer::parse_lines(&lines);
3700 assert_eq!(
3701 v.fence_ranges,
3702 super::super::parse_incremental::fence_ranges_from_kinds(&fresh.kinds),
3703 "fence_ranges must stay correct after a skipped recompute"
3704 );
3705 }
3706
3707 #[test]
3708 fn incremental_edit_patches_only_cursor_rows_of_gutter_insets() {
3709 // Blockquote rows at the top; a content edit far away (row 150)
3710 // combined with the cursor moving between two blockquote rows in
3711 // the same frame. The edit alone keeps the parse incremental; the
3712 // cursor move is what gutter_insets must still react to correctly
3713 // without re-walking every row.
3714 let mut v = MarkdownEditorView::new();
3715 // Blank line after the blockquote so the paragraph run below gets
3716 // its own reset boundary instead of lazily continuing the quote —
3717 // otherwise every row folds into one giant construct and even a
3718 // distant edit falls back to a full rebuild.
3719 let mut lines: Vec<String> = vec![
3720 "> quoted line 0".to_string(),
3721 "> quoted line 1".to_string(),
3722 "> quoted line 2".to_string(),
3723 String::new(),
3724 ];
3725 lines.extend((0..200).map(|i| format!("paragraph {i} with some text")));
3726 update_view(&mut v, &lines, (0, 0), rect(40), 1, None);
3727
3728 lines[151].push('x');
3729 update_view(&mut v, &lines, (1, 0), rect(40), 2, None);
3730 assert!(v.last_parse_was_incremental, "expected incremental path");
3731
3732 let mut fresh_view = MarkdownEditorView::new();
3733 update_view(&mut fresh_view, &lines, (1, 0), rect(40), 1, None);
3734 assert_eq!(
3735 v.gutter_insets_for_testing(),
3736 fresh_view.gutter_insets_for_testing(),
3737 "patched gutter_insets must match a full rebuild"
3738 );
3739 }
3740
3741 #[test]
3742 fn incremental_edit_patches_only_the_touched_code_block_of_code_box_width() {
3743 // Two fenced blocks. Growing a line inside the SECOND block must
3744 // not touch the first block's cached width, and the result must
3745 // match a full rebuild.
3746 let mut v = MarkdownEditorView::new();
3747 let mut lines: Vec<String> = vec![
3748 "```".to_string(),
3749 "short".to_string(),
3750 "```".to_string(),
3751 "paragraph between blocks".to_string(),
3752 "```".to_string(),
3753 "also short".to_string(),
3754 "```".to_string(),
3755 ];
3756 update_view(&mut v, &lines, (5, 0), rect(40), 1, None);
3757 let before_first_block = v.code_box_width_for_testing()[0..3].to_vec();
3758
3759 lines[5].push_str(" grown considerably wider now");
3760 update_view(&mut v, &lines, (5, lines[5].len()), rect(40), 2, None);
3761 assert!(v.last_parse_was_incremental, "expected incremental path");
3762
3763 assert_eq!(
3764 v.code_box_width_for_testing()[0..3],
3765 before_first_block[..],
3766 "the untouched first block's width must be unchanged"
3767 );
3768
3769 let mut fresh_view = MarkdownEditorView::new();
3770 update_view(
3771 &mut fresh_view,
3772 &lines,
3773 (5, lines[5].len()),
3774 rect(40),
3775 1,
3776 None,
3777 );
3778 assert_eq!(
3779 v.code_box_width_for_testing(),
3780 fresh_view.code_box_width_for_testing(),
3781 "patched code_box_width must match a full rebuild"
3782 );
3783 }
3784
3785 #[test]
3786 fn incremental_text_change_does_not_rebuild_all_of_rendered_cache() {
3787 // Verify that after an incremental text edit, rendered_cache rows
3788 // outside the widened range are NOT re-derived from scratch. We
3789 // can't directly observe the rebuild, but we CAN verify the cache
3790 // contents stay correct (matching a full rebuild's output).
3791 let mut v = MarkdownEditorView::new();
3792 let lines: Vec<String> = (0..200)
3793 .map(|i| format!("paragraph {i} with some text"))
3794 .collect();
3795 update_view(&mut v, &lines, (100, 0), rect(40), 1, None);
3796
3797 // Snapshot rendered_cache before the edit.
3798 let before: Vec<Vec<bool>> = v
3799 .rendered_cache
3800 .iter()
3801 .enumerate()
3802 .filter(|(i, _)| *i < 50 || *i > 150)
3803 .map(|(_, v)| v.clone())
3804 .collect();
3805
3806 // Edit a paragraph in the middle.
3807 let mut edited = lines.clone();
3808 edited[100].push('x');
3809 update_view(&mut v, &edited, (100, edited[100].len()), rect(40), 2, None);
3810
3811 // Rows far outside the damaged range must be byte-identical.
3812 let after: Vec<Vec<bool>> = v
3813 .rendered_cache
3814 .iter()
3815 .enumerate()
3816 .filter(|(i, _)| *i < 50 || *i > 150)
3817 .map(|(_, v)| v.clone())
3818 .collect();
3819 assert_eq!(
3820 before, after,
3821 "rendered_cache rows outside damaged range must be unchanged"
3822 );
3823
3824 // The incremental path must have been taken.
3825 assert!(v.last_parse_was_incremental);
3826 }
3827
3828 // §3.4 — heuristic widener fires on an in-list content edit.
3829 //
3830 // Needs a buffer big enough that strict widener (which on a
3831 // loose list with no interior reset boundaries expands to
3832 // `[0, lines.len()]`) cap-trips, so the edit falls to
3833 // widen_to_safe over the loose-list blanks. With
3834 // MAX_INCREMENTAL_LINES=256 we use ~500 items.
3835
3836 fn make_loose_list(n_items: usize) -> Vec<String> {
3837 let mut out = Vec::with_capacity(n_items * 2);
3838 for i in 0..n_items {
3839 out.push(format!("- item {i}"));
3840 if i + 1 < n_items {
3841 out.push(String::new());
3842 }
3843 }
3844 out
3845 }
3846
3847 #[test]
3848 fn try_incremental_parse_uses_heuristic_on_in_list_edit() {
3849 let mut v = MarkdownEditorView::new();
3850 let lines = make_loose_list(300);
3851 let mid_row = 200;
3852 update_view(&mut v, &lines, (mid_row, 0), rect(20), 1, None);
3853
3854 let mut edited = lines.clone();
3855 edited[mid_row].push('x');
3856 update_view(
3857 &mut v,
3858 &edited,
3859 (mid_row, edited[mid_row].len()),
3860 rect(20),
3861 2,
3862 None,
3863 );
3864
3865 assert!(
3866 v.last_parse_was_incremental,
3867 "edit inside large loose list must take incremental path \
3868 (lazy-guard relaxation + widen_to_safe over the loose-list blanks)"
3869 );
3870 assert_eq!(
3871 v.last_splice_path,
3872 Some(SplicePath::Heuristic),
3873 "expected Heuristic path on large loose list edit, got {:?}",
3874 v.last_splice_path
3875 );
3876 }
3877
3878 // §3.5 — lazy-guard relaxation must NOT skip when the edit is a
3879 // list-marker flip. The marker-flip guard above the lazy guard
3880 // should bail first, and even if it didn't, the lazy guard's
3881 // kind_qualifies check should also bail since ListMarker is the
3882 // OLD kind but the new line is a different marker (still a list
3883 // marker, so the `looks_like_list_marker` flip check passes —
3884 // both old and new look like list markers; the lazy guard would
3885 // relax). However the kinds-comparison test ensures the edit
3886 // becomes a divergent classification only via the verify path.
3887 //
3888 // Actually re-reading: marker-style flip "- a" → "* a" does NOT
3889 // change `looks_like_list_marker` (both return true). The lazy
3890 // guard relaxation lets it through. The widener attempts splice.
3891 // If the slice's per-row kinds match the parent's, no divergence;
3892 // splice succeeds. If marker-style switches the classification,
3893 // verify catches it.
3894 //
3895 // The §3.5 spec scenario "- a" → "* a" produces ListMarker in
3896 // both. Slice parses "* a" alone as a list with `*` marker;
3897 // kinds[0] = ListMarker. Parent had ListMarker too. No
3898 // divergence. Splice succeeds via the heuristic widener.
3899 //
3900 // This test instead asserts the negative: a more-aggressive
3901 // structural change (e.g. removing the marker entirely, turning
3902 // a list row into a Plain row) must bail via the existing
3903 // looks_like_list_marker flip guard (KindGuard bail).
3904 #[test]
3905 fn try_incremental_parse_lazy_guard_still_bails_on_marker_removal() {
3906 let mut v = MarkdownEditorView::new();
3907 let lines: Vec<String> = vec!["- a".into(), "".into(), "- b".into()];
3908 update_view(&mut v, &lines, (0, 3), rect(20), 1, None);
3909
3910 let mut edited = lines.clone();
3911 edited[0] = "a".into(); // remove marker — `- a` → `a`
3912 update_view(&mut v, &edited, (0, 1), rect(20), 2, None);
3913
3914 // The looks_like_list_marker flip guard above the lazy guard
3915 // must bail this case (KindGuard). The lazy-guard relaxation
3916 // never sees it.
3917 assert!(
3918 !v.last_parse_was_incremental,
3919 "list-marker removal must NOT take incremental path \
3920 — looks_like_list_marker flip guard bails first"
3921 );
3922 }
3923
3924 #[test]
3925 fn apply_code_box_sets_bg_and_pads_to_width() {
3926 use ratatui::text::Span;
3927 let theme = crate::settings::themes::Theme::gruvbox_dark();
3928 let spans = vec![Span::raw("ab")]; // 2 cols
3929 let out = super::apply_code_box(spans, 5, &theme);
3930 let total: usize = out.iter().map(|s| s.content.chars().count()).sum();
3931 assert_eq!(total, 5); // padded to box width
3932 let bg = theme.code_bg.to_ratatui();
3933 assert!(out.iter().all(|s| s.style.bg == Some(bg)));
3934 }
3935
3936 #[test]
3937 fn apply_code_box_measures_emoji_cluster_at_full_width() {
3938 // Regression: padding must use the same cluster model as
3939 // `raw_display_width` (which sizes the box). "a❤️" = 'a' (1) + VS16 heart
3940 // (2) = 3 rendered cols. Per-codepoint width undercounts the heart as 1,
3941 // over-padding the box and overshooting box_width. With cluster width the
3942 // content already fills 3 cols, so a box_width of 3 needs zero padding.
3943 use ratatui::text::Span;
3944 let theme = crate::settings::themes::Theme::gruvbox_dark();
3945 let content = "a\u{2764}\u{FE0F}";
3946 assert_eq!(super::super::markdown::raw_display_width(content), 3);
3947 let out = super::apply_code_box(vec![Span::raw(content)], 3, &theme);
3948 // No padding span appended — content already 3 cols.
3949 assert_eq!(out.len(), 1);
3950 assert_eq!(out[0].content.as_ref(), content);
3951 }
3952
3953 #[test]
3954 fn click_on_barred_blockquote_maps_past_gutter() {
3955 // Blockquote on row 0 is NOT the cursor row (cursor parked on row 1),
3956 // so row 0 renders "│ hello". vrow 0 is that row's single visual line.
3957 let lines = vec!["> hello".to_string(), "tail".to_string()];
3958 let view = make_view_for_lines(&lines, (1, 0), 80);
3959 // Click screen col 2 ('h' after the 2-col "│ " gutter) → logical col 2.
3960 let (row, col) = view.click_to_logical_for_testing(0, 2);
3961 assert_eq!((row, col), (0, 2));
3962 }
3963}
3964
3965/// Differential soak for the incremental parse widener.
3966///
3967/// The widener's guards were narrowed to `ListMarker` because a 100 000-case run
3968/// found `Blockquote`, `Plain` and `ListContinuation` producing classifications
3969/// that disagreed with a fresh parse — usually on a row *past* `widened.end`,
3970/// where the in-window post-slice verify does not look. Nothing in the ordinary
3971/// suite reproduces that: unlocking `Blockquote` leaves every test green. This is
3972/// the harness that does not.
3973///
3974/// Ignored by default because 100k cases is minutes, not milliseconds:
3975///
3976/// ```text
3977/// SOAK_CASES=100000 cargo test -p kimun-notes --lib widener_soak -- --ignored --nocapture
3978/// ```
3979///
3980/// **To evaluate an unlock**, widen `kind_qualifies` in `try_incremental_parse`
3981/// to the kind under test and re-run. A green soak is the evidence the guard's
3982/// own comment asks for; anything else is a reason the kind stays excluded.
3983#[cfg(test)]
3984mod widener_soak {
3985 use super::tests::update_view;
3986 use super::*;
3987 use proptest::prelude::*;
3988
3989 /// Blocks, not independent rows.
3990 ///
3991 /// The failure this harness exists to reproduce needs a specific
3992 /// arrangement — a `>` row nested *inside* a list item (so `lazy_depth == 1`)
3993 /// with a blank immediately after it, which is where `damaged.end` lands and
3994 /// where the post-edit parse can flip the row to `ListContinuation`. Rows
3995 /// drawn independently produce blockquotes and lists constantly and that
3996 /// arrangement almost never, which is why the first version of this soak
3997 /// passed 20 000 cases of a configuration known to be wrong.
3998 fn block() -> impl Strategy<Value = Vec<String>> {
3999 prop_oneof![
4000 Just(vec!["plain paragraph text".to_string(), String::new()]),
4001 Just(vec![
4002 "plain paragraph".to_string(),
4003 "second line of it".to_string(),
4004 String::new(),
4005 ]),
4006 // A list holding a quote: the nested-lazy shape.
4007 Just(vec![
4008 "- list item".to_string(),
4009 " > nested quote".to_string(),
4010 String::new(),
4011 ]),
4012 // The same with a marker carrying only whitespace — the exact row the
4013 // soak's original finding named.
4014 Just(vec![
4015 "- list item".to_string(),
4016 "> ".to_string(),
4017 String::new(),
4018 ]),
4019 Just(vec![
4020 "- list item".to_string(),
4021 " continuation".to_string(),
4022 " > quote inside".to_string(),
4023 String::new(),
4024 ]),
4025 Just(vec![
4026 "> quoted line".to_string(),
4027 "lazy continuation".to_string(),
4028 String::new(),
4029 ]),
4030 Just(vec!["> ".to_string(), String::new()]),
4031 Just(vec!["# heading".to_string(), String::new()]),
4032 Just(vec![
4033 "```".to_string(),
4034 "fenced".to_string(),
4035 "```".to_string(),
4036 String::new(),
4037 ]),
4038 Just(vec![" indented code".to_string(), String::new()]),
4039 Just(vec![
4040 "setext".to_string(),
4041 "=====".to_string(),
4042 String::new()
4043 ]),
4044 ]
4045 }
4046
4047 /// Edits that keep the row count. Marker-altering ones included: an edit that
4048 /// flips a kind is *supposed* to be refused by the guards above the
4049 /// relaxation, and a soak that only appends letters never tests that.
4050 fn edit(original: &str) -> Vec<String> {
4051 let mut out = vec![
4052 format!("{original}x"),
4053 format!("{original} "),
4054 format!("> {original}"),
4055 format!(" {original}"),
4056 ];
4057 if !original.is_empty() {
4058 out.push(original[..original.len() - 1].to_string());
4059 out.push(original.trim_start().to_string());
4060 out.push(original.replacen('>', " ", 1));
4061 out.push(original.replacen('-', " ", 1));
4062 }
4063 // Only variants that actually change the row. Several of these are
4064 // no-ops on some inputs — `trim_start` on an already-trimmed row,
4065 // `replacen('>')` on a row without one — and filtering here rather than
4066 // rejecting in the test is what keeps proptest from aborting on global
4067 // rejects long before it has explored anything.
4068 out.retain(|candidate| candidate != original);
4069 out.dedup();
4070 out
4071 }
4072
4073 fn cases() -> u32 {
4074 std::env::var("SOAK_CASES")
4075 .ok()
4076 .and_then(|v| v.parse().ok())
4077 .unwrap_or(256)
4078 }
4079
4080 proptest! {
4081 #![proptest_config(ProptestConfig { cases: cases(), ..ProptestConfig::default() })]
4082
4083 #[test]
4084 #[ignore = "soak: run explicitly with SOAK_CASES"]
4085 fn an_incremental_splice_agrees_with_a_fresh_parse(
4086 blocks in prop::collection::vec(block(), 2..8),
4087 row_pick in any::<prop::sample::Index>(),
4088 edit_pick in any::<prop::sample::Index>(),
4089 ) {
4090 let lines: Vec<String> = blocks.concat();
4091 prop_assume!(lines.len() >= 3);
4092 let target = row_pick.index(lines.len());
4093 let variants = edit(&lines[target]);
4094 prop_assume!(!variants.is_empty());
4095 let mut edited = lines.clone();
4096 edited[target] = variants[edit_pick.index(variants.len())].clone();
4097
4098 let mut view = MarkdownEditorView::new();
4099 update_view(&mut view, &lines, (target, 0), Rect::new(0, 0, 40, 20), 1, None);
4100 view.note_damage(target..target + 1, 0);
4101 update_view(&mut view, &edited, (target, 0), Rect::new(0, 0, 40, 20), 2, None);
4102
4103 // A full-parse fallback trivially agrees; only a *wrong splice* fails.
4104 let fresh = ParsedBuffer::parse_lines(&edited);
4105 prop_assert_eq!(
4106 &view.parse_state.buf().kinds,
4107 &fresh.kinds,
4108 "kinds diverged (incremental={}) for {:?} -> row {} = {:?}",
4109 view.last_parse_was_incremental(),
4110 lines,
4111 target,
4112 edited[target]
4113 );
4114 prop_assert_eq!(
4115 &view.parse_state.buf().lazy_depth,
4116 &fresh.lazy_depth,
4117 "lazy_depth diverged (incremental={})",
4118 view.last_parse_was_incremental()
4119 );
4120 }
4121 }
4122}