kimun_notes/components/text_editor/view.rs
1use super::markdown::{MarkdownSpanner, ParsedBuffer, opener_shape};
2use super::word_wrap::WordWrapLayout;
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;
10use std::ops::Range;
11use std::sync::OnceLock;
12use unicode_width::UnicodeWidthStr;
13
14/// Terminal cursor shape the editor requests while focused.
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16pub enum CursorShape {
17 Bar,
18 Block,
19}
20
21/// Describes how `view.update`'s Gate 1 modified the parse caches this
22/// frame. Read by Gate 2 to decide what subset of `rendered_cache` and
23/// `WordWrapLayout` needs to be rebuilt.
24#[derive(Debug, Clone)]
25enum TextChangeKind {
26 /// No text change this frame (cursor-only update). Gate 2 may keep
27 /// its caches and only refresh the cursor-row entry.
28 None,
29 /// Gate 1 took the incremental splice path; only rows in this
30 /// range had their ParsedLine entries replaced. Gate 2 should
31 /// rebuild rendered_cache only for these rows + the cursor rows.
32 Incremental(std::ops::Range<usize>),
33 /// Full rebuild (initial parse, line-count change, cap trip,
34 /// structural-marker change, post-slice verification miss). Gate 2
35 /// must rebuild rendered_cache for every row.
36 Full,
37}
38
39enum RenderedCacheRebuild {
40 Full,
41 Rows(Vec<usize>),
42 None,
43}
44
45#[derive(Clone)]
46pub struct MarkdownEditorView {
47 pub layout: WordWrapLayout,
48 visual_scroll_offset: usize,
49 pub lines_snapshot: Vec<String>,
50 pub cursor_snapshot: (usize, usize),
51 /// Line ranges of every fenced code block in the buffer. Text-keyed
52 /// (rebuilt only when `text_revision` changes); `is_in_code_block`
53 /// does a cheap point lookup against this list per row so all fenced
54 /// blocks render `force_raw` regardless of where the cursor is.
55 fence_ranges: Vec<Range<usize>>,
56 /// Per-logical-row code-box width (display cols), or `None` when the row
57 /// is not in a code block. All rows of one block share the block's
58 /// widest-rendered-line width, capped at the editor width. Rebuilt in
59 /// `update()` whenever text or width changes.
60 code_box_width: Vec<Option<u16>>,
61 /// Per-logical-row left gutter width (display cols) for the blockquote
62 /// bar: `depth + 1` on blockquote rows that are NOT the cursor row, else
63 /// 0. Cursor-dependent (the cursor row reveals raw `> `), so rebuilt with
64 /// the same cursor-affected-row logic as `rendered_cache`.
65 gutter_insets: Vec<usize>,
66 /// Cursor's last on-screen position (col, row), or `None` when the
67 /// cursor was scrolled off-screen or the view was unfocused at the
68 /// time of the previous `render`. Used as the anchor for floating
69 /// overlays like the autocomplete popup, which is drawn after the
70 /// editor itself.
71 pub last_cursor_screen: Option<(u16, u16)>,
72 /// Cursor style last written to the terminal, or `None` when the
73 /// terminal is on the user's default shape. The terminal cursor style
74 /// is global state, so on focus loss we must emit an explicit reset —
75 /// otherwise the editor's bar/block shape leaks into every other text
76 /// input (search sidebar, dialogs).
77 applied_cursor_style: Option<CursorShape>,
78 /// Per-line parse cache built in `update()`. Eliminates redundant pulldown-cmark
79 /// invocations across `render()`, cursor placement, and click mapping.
80 /// Either a Real or Placeholder parse — see [`ParseState`].
81 parse_state: ParseState,
82 /// Last `text_revision` seen — gates the lines clone and parse-cache rebuild.
83 /// Cursor-only moves do not bump `text_revision`, so navigating with the
84 /// arrow keys reuses the parse cache instead of re-running pulldown-cmark
85 /// over the whole buffer.
86 last_seen_generation: u64,
87 /// `text_revision`/width/cursor at which the layout was last computed.
88 /// Used to skip `WordWrapLayout::compute()` when nothing affecting wrap has changed:
89 /// horizontal cursor movement within the same element (or plain text) is free.
90 last_layout_generation: u64,
91 last_layout_width: u16,
92 last_layout_cursor: (usize, usize),
93 /// Visual row of the cursor, cached after layout so `render()` doesn't call
94 /// `logical_to_visual` a second time.
95 cursor_vrow: usize,
96 /// Per-line rendered-position bitmask, cached between layout recomputes.
97 /// Only the two cursor rows (old and new) are rebuilt when just the cursor row changes;
98 /// all rows are rebuilt when content or width changes.
99 rendered_cache: Vec<Vec<bool>>,
100 /// Current selection range in logical (row, byte-col) coordinates.
101 /// `None` when no selection is active.
102 selection: Option<((usize, usize), (usize, usize))>,
103 /// Diagnostic: true when the most recent Gate 1 invocation used the
104 /// incremental splice path, false when it took the full-parse fallback.
105 /// Read by tests; not part of the production observable surface.
106 last_parse_was_incremental: bool,
107 /// Diagnostic: which widener tier (`Strict` / `Heuristic`)
108 /// produced the most recent successful incremental
109 /// splice. `None` when no incremental splice has happened yet
110 /// (first parse or full-rebuild fallbacks). Read by unit tests
111 /// asserting the chosen widener path.
112 last_splice_path: Option<SplicePath>,
113 /// Tracks how Gate 1 changed (or did not change) the parse caches.
114 /// Gate 2 reads this to decide the scope of rendered_cache rebuild.
115 last_text_change: TextChangeKind,
116}
117
118/// True when `KIMUN_VIEW_VERIFY_INCREMENTAL=1` is set. Reads the
119/// env var once per process and caches. Gates the debug-only
120/// full-kinds assertion in Gate 1 that compares every incremental
121/// splice against a fresh whole-buffer parse. (The per-splice
122/// undamaged-row verify on the heuristic path runs in release
123/// unconditionally — see `try_incremental_parse`.)
124fn verify_incremental_enabled() -> bool {
125 static VERIFY: OnceLock<bool> = OnceLock::new();
126 *VERIFY.get_or_init(|| {
127 std::env::var("KIMUN_VIEW_VERIFY_INCREMENTAL")
128 .map(|v| !v.is_empty() && v != "0")
129 .unwrap_or(false)
130 })
131}
132
133/// Which widener produced the splice for the most recent successful
134/// incremental parse. Test telemetry — read by `last_splice_path`
135/// in unit tests to assert the chosen path. Mirror of
136/// [`SuccessPath`] but kept private since callers shouldn't depend
137/// on widener internals.
138#[derive(Debug, Clone, Copy, PartialEq, Eq)]
139pub enum SplicePath {
140 /// Strict reset-boundary widener (`reset_boundaries`) succeeded.
141 Strict,
142 /// `widen_to_safe` heuristic succeeded after the strict
143 /// reset-boundary widener returned `FullRebuild`.
144 Heuristic,
145}
146
147/// The editor's per-buffer parse cache: either a fully-styled **Real
148/// parse** or an unstyled **Placeholder parse** awaiting a background
149/// full parse (see `CONTEXT.md`). Modelling the distinction as a type
150/// makes the wrong-splice hazard unrepresentable: splicing is only
151/// reachable through [`ParseState::splice_real`], whose `Placeholder`
152/// arm is unreachable because Gate 1 declines the incremental path for
153/// placeholders. The placeholder's all-`Plain` line kinds would
154/// otherwise defeat the structural guards and accept a wrong splice.
155#[derive(Clone)]
156enum ParseState {
157 Real(ParsedBuffer),
158 /// `generation` is the `content_revision` the placeholder was
159 /// installed for — handed to the owning component so it knows which
160 /// buffer to parse on the background task. `spawned` flips true once
161 /// that task has been requested, so `take_pending_full_parse` hands
162 /// the generation out exactly once.
163 Placeholder {
164 buf: ParsedBuffer,
165 generation: u64,
166 spawned: bool,
167 },
168}
169
170impl ParseState {
171 /// State-agnostic buffer access. Render and Gate 2 read the buffer
172 /// in both states — the placeholder has valid row counts, so the
173 /// downstream path stays in-bounds; only the markdown styling is
174 /// missing while it is a placeholder.
175 fn buf(&self) -> &ParsedBuffer {
176 match self {
177 Self::Real(b) | Self::Placeholder { buf: b, .. } => b,
178 }
179 }
180
181 fn is_placeholder(&self) -> bool {
182 matches!(self, Self::Placeholder { .. })
183 }
184
185 /// Splice an incremental slice into a Real parse. Called only after
186 /// the `is_placeholder()` gate in Gate 1 has declined the
187 /// incremental path for placeholders, so the `Placeholder` arm is
188 /// unreachable.
189 fn splice_real(&mut self, range: std::ops::Range<usize>, slice: ParsedBuffer) {
190 match self {
191 Self::Real(b) => b.splice(range, slice),
192 Self::Placeholder { .. } => {
193 debug_assert!(false, "splice on placeholder parse");
194 }
195 }
196 }
197}
198
199impl MarkdownEditorView {
200 pub fn new() -> Self {
201 Self {
202 layout: WordWrapLayout::default(),
203 visual_scroll_offset: 0,
204 lines_snapshot: Vec::new(),
205 cursor_snapshot: (0, 0),
206 fence_ranges: Vec::new(),
207 code_box_width: Vec::new(),
208 gutter_insets: Vec::new(),
209 last_cursor_screen: None,
210 applied_cursor_style: None,
211 // Empty buffer, spliceable — preserves the previous
212 // `placeholder_active: false` initial state.
213 parse_state: ParseState::Real(ParsedBuffer::placeholder(&[])),
214 last_seen_generation: u64::MAX, // force rebuild on first update
215 last_layout_generation: u64::MAX,
216 last_layout_width: 0,
217 last_layout_cursor: (usize::MAX, usize::MAX),
218 cursor_vrow: 0,
219 rendered_cache: Vec::new(),
220 selection: None,
221 last_parse_was_incremental: false,
222 last_splice_path: None,
223 last_text_change: TextChangeKind::Full, // first update is a full rebuild
224 }
225 }
226
227 /// Threshold above which a fallback to full parse runs
228 /// asynchronously instead of blocking the typing thread. On
229 /// buffers below this size the full parse is fast enough
230 /// (<2ms for a paragraph-only 1000-line buffer per bench) that
231 /// blocking is preferable to the one-frame-of-unstyled-text
232 /// the async path imposes.
233 const LARGE_BUFFER_THRESHOLD: usize = 1000;
234
235 /// Returns `Some(generation)` if Gate 1 just installed a
236 /// placeholder `ParsedBuffer` and the owning component should
237 /// spawn a background full parse for this generation. Consumes
238 /// the flag so the owner does not spawn twice; the owner is
239 /// responsible for calling `install_full_parse` when the task
240 /// completes.
241 /// Whether the most recent Gate 1 invocation took the incremental
242 /// splice path. Read-only diagnostic for the incremental-parse
243 /// property tests (`tui/tests/incremental_property.rs`); not part
244 /// of the production render path.
245 pub fn last_parse_was_incremental(&self) -> bool {
246 self.last_parse_was_incremental
247 }
248
249 pub fn take_pending_full_parse(&mut self) -> Option<u64> {
250 if let ParseState::Placeholder {
251 generation,
252 spawned,
253 ..
254 } = &mut self.parse_state
255 && !*spawned
256 {
257 *spawned = true;
258 return Some(*generation);
259 }
260 None
261 }
262
263 /// Install the result of a background full parse. No-op when
264 /// the editor has advanced past `generation` — that result is
265 /// stale and a fresh spawn is already in flight. Invalidates the
266 /// layout + rendered_cache so the next `update()` rebuilds Gate
267 /// 2 against the fresh `ParsedBuffer`.
268 pub fn install_full_parse(&mut self, generation: u64, buf: ParsedBuffer) {
269 if generation != self.last_seen_generation {
270 return; // stale
271 }
272 self.parse_state = ParseState::Real(buf);
273 self.fence_ranges =
274 super::parse_incremental::fence_ranges_from_kinds(&self.parse_state.buf().kinds);
275 // Force Gate 2 full rebuild on the next update: the
276 // placeholder's all-Plain kinds produced different fence
277 // ranges and rendered masks than the real parse will.
278 self.last_text_change = TextChangeKind::Full;
279 self.last_layout_generation = u64::MAX;
280 }
281
282 pub fn update(
283 &mut self,
284 snap: &super::snapshot::EditorSnapshot<'_>,
285 rect: Rect,
286 selection: Option<((usize, usize), (usize, usize))>,
287 ) {
288 // Snapshot owns the (cursor, lines, content_revision) atomicity
289 // — readers below can index `parsed_buffer.lines[cursor.0]`
290 // without `.get()` guards once Gate 1 has rebuilt the parse
291 // cache from these same `lines`.
292 let lines: &[String] = &snap.lines;
293 let cursor = snap.cursor;
294 let generation = snap.content_revision.get();
295 self.selection = selection;
296 if rect.height == 0 {
297 return;
298 }
299
300 // Gate 1: content changed — rebuild parse cache and snapshots.
301 if generation != self.last_seen_generation {
302 let incremental = if self.parse_state.is_placeholder() {
303 None
304 } else {
305 self.try_incremental_parse(lines, cursor)
306 };
307 self.last_text_change = match incremental {
308 Some((range, slice, path)) => {
309 self.parse_state.splice_real(range.clone(), slice);
310 self.last_parse_was_incremental = true;
311 self.last_splice_path = Some(path);
312 TextChangeKind::Incremental(range)
313 }
314 None => {
315 if lines.len() >= Self::LARGE_BUFFER_THRESHOLD {
316 // Async fallback: install a structurally-
317 // correct but unstyled placeholder so this
318 // frame can paint immediately; defer the
319 // real pulldown parse to a background tokio
320 // task spawned by the owning component (see
321 // `take_pending_full_parse` / `install_full_parse`).
322 // The placeholder has the same row count as
323 // `lines`, so the downstream Gate 2 / render
324 // path stays in-bounds; only the markdown
325 // styling is missing for one frame.
326 self.parse_state = ParseState::Placeholder {
327 buf: ParsedBuffer::placeholder(lines),
328 generation,
329 spawned: false,
330 };
331 } else {
332 self.parse_state = ParseState::Real(ParsedBuffer::parse(lines));
333 }
334 self.last_parse_was_incremental = false;
335 self.last_splice_path = None;
336 TextChangeKind::Full
337 }
338 };
339 #[cfg(debug_assertions)]
340 if self.last_parse_was_incremental && verify_incremental_enabled() {
341 let fresh = ParsedBuffer::parse(lines);
342 assert_eq!(
343 self.parse_state.buf().kinds,
344 fresh.kinds,
345 "incremental kinds diverge from full parse at generation={generation}"
346 );
347 assert_eq!(
348 self.parse_state.buf().lazy_depth,
349 fresh.lazy_depth,
350 "incremental lazy_depth diverges from full parse at generation={generation}"
351 );
352 assert_eq!(
353 self.parse_state.buf().reset_boundaries,
354 fresh.reset_boundaries,
355 "incremental reset_boundaries diverge from full parse at generation={generation}"
356 );
357 assert_eq!(
358 self.parse_state.buf().lines.len(),
359 fresh.lines.len(),
360 "incremental lines.len() diverges from full parse at generation={generation}"
361 );
362 for (i, (got, exp)) in self
363 .parse_state
364 .buf()
365 .lines
366 .iter()
367 .zip(fresh.lines.iter())
368 .enumerate()
369 {
370 got.debug_assert_eq_to(exp, i);
371 }
372 }
373 self.fence_ranges =
374 super::parse_incremental::fence_ranges_from_kinds(&self.parse_state.buf().kinds);
375 // Incremental update of `lines_snapshot` mirrors the parse
376 // path: on the splice path only the rows in `range` can
377 // have changed (try_incremental_parse already bails when
378 // line count differs); on the full-parse fallback we lose
379 // damage info, so re-clone everything.
380 //
381 // `String::clone_from` reuses the destination's existing
382 // allocation when capacity permits, so the typical
383 // single-char insert costs one String reallocation
384 // (often zero — capacity stays put) instead of N.
385 match &self.last_text_change {
386 TextChangeKind::Incremental(range) => {
387 debug_assert_eq!(
388 self.lines_snapshot.len(),
389 lines.len(),
390 "incremental path requires equal line counts"
391 );
392 for i in range.clone() {
393 self.lines_snapshot[i].clone_from(&lines[i]);
394 }
395 }
396 TextChangeKind::Full | TextChangeKind::None => {
397 if self.lines_snapshot.len() == lines.len() {
398 for (dst, src) in self.lines_snapshot.iter_mut().zip(lines.iter()) {
399 dst.clone_from(src);
400 }
401 } else {
402 self.lines_snapshot.clear();
403 self.lines_snapshot.extend(lines.iter().cloned());
404 }
405 }
406 }
407 self.last_seen_generation = generation;
408 } else {
409 self.last_text_change = TextChangeKind::None;
410 }
411
412 self.cursor_snapshot = cursor;
413
414 // Gate 2: layout rebuild.
415 // Skip when content, width, and the *effective element expansion* are all unchanged.
416 // Horizontal cursor movement within the same element (or plain text with no elements)
417 // does not change any wrap boundary — no recompute needed.
418 let new_expanded = self
419 .parse_state
420 .buf()
421 .lines
422 .get(cursor.0)
423 .and_then(|p| p.elem_at(cursor.1));
424 let old_expanded = self
425 .parse_state
426 .buf()
427 .lines
428 .get(self.last_layout_cursor.0)
429 .and_then(|p| p.elem_at(self.last_layout_cursor.1));
430 let need_layout = generation != self.last_layout_generation
431 || rect.width != self.last_layout_width
432 || cursor.0 != self.last_layout_cursor.0
433 || new_expanded != old_expanded;
434
435 if need_layout {
436 let width_changed = rect.width != self.last_layout_width;
437 let cursor_changed = cursor.0 != self.last_layout_cursor.0;
438 let expanded_changed = new_expanded != old_expanded;
439 // Rows whose rendered mask depends on cursor state and may
440 // have flipped this frame: the old and new cursor rows
441 // when the cursor moved between rows, OR the cursor row
442 // when an inline element (link/bold/etc.) was just expanded
443 // or collapsed by a within-row cursor move. Both shapes
444 // change `visible_positions_with`'s `expanded` argument,
445 // so both rendered_cache AND wrap need to re-derive that
446 // row's mask + visual-line splits.
447 let cursor_affected_rows: Vec<usize> = if cursor_changed {
448 let mut rows = vec![self.last_layout_cursor.0, cursor.0];
449 rows.sort();
450 rows.dedup();
451 rows
452 } else if expanded_changed {
453 vec![cursor.0]
454 } else {
455 vec![]
456 };
457 // Drop any row past the current buffer end — happens when a
458 // stale snapshot's cursor row exceeds `lines.len()`. Both
459 // rendered_cache and wrap splices require in-range rows.
460 let cursor_affected_rows: Vec<usize> = cursor_affected_rows
461 .into_iter()
462 .filter(|&r| r < lines.len())
463 .collect();
464 // Determine the set of rows to rebuild in rendered_cache.
465 let rebuild_strategy = if self.rendered_cache.len() != lines.len() {
466 // Line count differs → full rebuild required.
467 RenderedCacheRebuild::Full
468 } else {
469 match &self.last_text_change {
470 TextChangeKind::Full => RenderedCacheRebuild::Full,
471 TextChangeKind::Incremental(range) => {
472 let mut rows: Vec<usize> = range.clone().collect();
473 rows.extend(cursor_affected_rows.iter().copied());
474 rows.sort();
475 rows.dedup();
476 RenderedCacheRebuild::Rows(rows)
477 }
478 TextChangeKind::None => {
479 if cursor_affected_rows.is_empty() {
480 RenderedCacheRebuild::None
481 } else {
482 RenderedCacheRebuild::Rows(cursor_affected_rows.clone())
483 }
484 }
485 }
486 };
487
488 // Width-only change: masks are width-independent; skip rendered_cache rebuild.
489 let _ = width_changed; // acknowledged: width doesn't affect rendered_cache
490 match rebuild_strategy {
491 RenderedCacheRebuild::Full => {
492 self.rendered_cache = lines
493 .iter()
494 .enumerate()
495 .map(|(i, l)| {
496 let force_raw = self.is_in_code_block(i);
497 let cursor_col = if i == cursor.0 { Some(cursor.1) } else { None };
498 MarkdownSpanner::visible_positions_with(
499 l,
500 &self.parse_state.buf().lines[i],
501 cursor_col,
502 force_raw,
503 )
504 })
505 .collect();
506 }
507 RenderedCacheRebuild::Rows(rows) => {
508 for row in rows {
509 if row >= lines.len() {
510 continue; // defensive
511 }
512 let force_raw = self.is_in_code_block(row);
513 let cursor_col = if row == cursor.0 {
514 Some(cursor.1)
515 } else {
516 None
517 };
518 let new_entry = MarkdownSpanner::visible_positions_with(
519 &lines[row],
520 &self.parse_state.buf().lines[row],
521 cursor_col,
522 force_raw,
523 );
524 if let Some(entry) = self.rendered_cache.get_mut(row) {
525 *entry = new_entry;
526 }
527 }
528 }
529 RenderedCacheRebuild::None => {
530 // Width-only change or no change: masks are width-independent; nothing to rebuild.
531 }
532 }
533
534 // Width-aware wrap path:
535 // - Width change or line-count change: full recompute (wrap
536 // depends on width; visual_lines indexing depends on row count).
537 // - TextChangeKind::Full: full recompute.
538 // - TextChangeKind::Incremental(range): splice the edited
539 // rows plus any cursor-affected rows whose mask flipped.
540 // - TextChangeKind::None: splice only the cursor-affected
541 // rows. Wrap depends on the rendered mask
542 // (`wrap_one_row` reads `rendered_row`), and the mask is
543 // cursor-position-sensitive whenever the cursor crosses
544 // an inline element boundary — same row or different
545 // row.
546 self.rebuild_gutter_insets(lines, cursor.0);
547 let line_count_changed = self.layout.row_starts_len() != lines.len();
548 if width_changed || line_count_changed {
549 self.layout = WordWrapLayout::compute(
550 lines,
551 rect.width,
552 &self.rendered_cache,
553 &self.gutter_insets,
554 );
555 } else {
556 match &self.last_text_change {
557 TextChangeKind::Full => {
558 self.layout = WordWrapLayout::compute(
559 lines,
560 rect.width,
561 &self.rendered_cache,
562 &self.gutter_insets,
563 );
564 }
565 TextChangeKind::Incremental(range) => {
566 let start = range
567 .start
568 .min(cursor_affected_rows.first().copied().unwrap_or(range.start));
569 let end = range.end.max(
570 cursor_affected_rows
571 .last()
572 .copied()
573 .map(|r| r + 1)
574 .unwrap_or(range.end),
575 );
576 self.layout.splice_range(
577 lines,
578 rect.width,
579 &self.rendered_cache,
580 &self.gutter_insets,
581 start..end,
582 );
583 }
584 TextChangeKind::None => {
585 if let (Some(&first), Some(&last)) =
586 (cursor_affected_rows.first(), cursor_affected_rows.last())
587 {
588 self.layout.splice_range(
589 lines,
590 rect.width,
591 &self.rendered_cache,
592 &self.gutter_insets,
593 first..last + 1,
594 );
595 }
596 }
597 }
598 }
599 // Code-box widths depend only on text content and the wrap width,
600 // not the cursor — so skip the (grapheme-walking) rebuild on
601 // cursor-only moves, where neither changed.
602 if !matches!(self.last_text_change, TextChangeKind::None) || width_changed {
603 self.rebuild_code_box_width(lines, rect.width);
604 }
605 self.last_layout_generation = generation;
606 self.last_layout_width = rect.width;
607 self.last_layout_cursor = cursor;
608 }
609
610 // Cache cursor_vrow for render() — avoids a second logical_to_visual call.
611 self.cursor_vrow = self.layout.logical_to_visual(cursor.0, cursor.1).0;
612 let height = rect.height as usize;
613 if self.cursor_vrow < self.visual_scroll_offset {
614 self.visual_scroll_offset = self.cursor_vrow;
615 } else if self.cursor_vrow >= self.visual_scroll_offset + height {
616 self.visual_scroll_offset = self.cursor_vrow - height + 1;
617 }
618 }
619
620 /// Attempt an incremental Gate-1 parse.
621 ///
622 /// Returns `Some((range, slice, path))` when the damage can be
623 /// cheaply isolated and widened to safe boundaries; `None` when
624 /// the caller should fall back to a fresh full-buffer
625 /// `ParsedBuffer::parse`. The `path` indicates which widener
626 /// tier produced the splice (see [`SplicePath`]).
627 fn try_incremental_parse(
628 &self,
629 lines: &[String],
630 cursor: (usize, usize),
631 ) -> Option<(std::ops::Range<usize>, ParsedBuffer, SplicePath)> {
632 use super::parse_incremental::{
633 LineConstructKind, WidenResult, compute_damage_range, expand_to_reset_boundary,
634 widen_to_safe,
635 };
636 use super::widener_metrics::{BailReason, METRICS, SuccessPath};
637
638 if self.parse_state.buf().lines.is_empty() {
639 return None; // First parse — no snapshot to diff against. Uncategorised.
640 }
641 // Line count changes (insertions/deletions) require a full rebuild:
642 // the widened range covers the same number of lines in the new buffer
643 // as in the old kinds array, so a splice cannot reconcile the length
644 // mismatch.
645 if lines.len() != self.parse_state.buf().lines.len() {
646 return METRICS.bail(BailReason::LineCountChange);
647 }
648 let Some(damaged) = compute_damage_range(&self.lines_snapshot, lines, cursor.0) else {
649 return METRICS.bail(BailReason::NoDamage);
650 };
651
652 // Structural-marker change guard: any edit that converts a fence
653 // marker line into a non-marker (or vice versa) can shift the
654 // fence's extent beyond the widening window. Same for setext
655 // underlines. Conservative fallback to full parse for correctness.
656 for row in damaged.clone() {
657 let old_kind = self.parse_state.buf().kinds[row];
658 let old_line = self.lines_snapshot[row].as_str();
659 let new_line = lines[row].as_str();
660
661 // Old kind was a structural marker whose role an in-place edit
662 // can change (fence opener↔closer↔content, setext underline
663 // re-heading the line above) or which lazy-extends past the
664 // widening window (indented code / HTML block per CommonMark
665 // §4.4 / §4.6). These read pulldown's real classification, so
666 // any edit on such a row punts to a full parse.
667 if matches!(
668 old_kind,
669 LineConstructKind::FenceMarker
670 | LineConstructKind::SetextUnderline
671 | LineConstructKind::IndentedCode
672 | LineConstructKind::HtmlBlock
673 ) {
674 return METRICS.bail(BailReason::KindGuard);
675 }
676 // Context-free block-opener shape flip: the edit gained or lost
677 // a fence / setext / indented-code / HTML / list / blockquote
678 // opener shape. Any such flip can open or close a (possibly
679 // lazy-continuable) construct that reshapes the document beyond
680 // the widening window — e.g. `"x"` → `"* x"` next to a
681 // blank-separated list leaks a loose-list merge. Comparing the
682 // whole `OpenerShape` catches a flip in any field at once.
683 if opener_shape(new_line) != opener_shape(old_line) {
684 return METRICS.bail(BailReason::KindGuard);
685 }
686
687 // V2 lazy-construct neighbourhood guard: edit at row R
688 // can re-shape a lazy construct open at R-1, R, or R+1.
689 // R-1: blockquote paragraph lazy-continuation across a
690 // former blank (§5.1). R: edit inside the construct. R+1:
691 // paragraph eating a would-be IndentedCode start.
692 //
693 // §3.0 conditional relaxation (intra-construct-reset-boundaries):
694 // when the damaged row's old kind is ListMarker AND
695 // lazy_depth[row] == 1 (a top-level list, not nested inside
696 // an outer lazy construct), the bail is skipped. List-marker
697 // content edits are safe by construction: per-row
698 // ListMarker/ListContinuation classification stays identical
699 // across slice-vs-parent, and rows past widened.end are
700 // unaffected by the slice's list-vs-non-list determination.
701 // The widener's heuristic tier (widen_to_safe over the
702 // loose-list blanks; or, on small buffers, the strict tier
703 // widening to the whole buffer) takes the splice. The
704 // post-slice verify backs this. The opener-shape /
705 // blank-transition flips run as
706 // separate guards above and below this check, so the relax
707 // only ever fires on pure content edits.
708 //
709 // Initial relaxation also accepted ListContinuation +
710 // Blockquote + Plain and arbitrary lazy_depth; both unlocks
711 // reverted after the 100k proptest soak exposed downstream-
712 // row-classification flips past widened.end that the
713 // post-slice verify (which only covers rows INSIDE widened)
714 // doesn't catch. The deeper fix is a post-widening sanity
715 // check on `widened.end + 1` — see the design doc's
716 // "Blockquote/Plain/ListContinuation unlocks" follow-up.
717 let lazy = &self.parse_state.buf().lazy_depth;
718 if lazy.is_empty() {
719 // Defensive: invariant violation (lazy_depth.len() should
720 // match lines.len()). Count as KindGuard to keep the
721 // attempted-vs-success accounting consistent.
722 return METRICS.bail(BailReason::KindGuard);
723 }
724 let lo = row.saturating_sub(1);
725 let hi = (row + 1).min(lazy.len() - 1);
726 if lazy[lo..=hi].iter().any(|&d| d > 0) {
727 // §3.0 conditional relaxation — TIGHT VERSION.
728 // Qualifying conditions (narrowed across two soak
729 // rounds — see openspec change for the rationale):
730 // - old_kind == ListMarker (NOT ListContinuation)
731 // - lazy_depth[row] == 1 (top-level list only)
732 //
733 // ListContinuation rows are excluded after the 100k
734 // soak surfaced a case where an edit on a
735 // ListContinuation row (specifically a `> ` row
736 // inside a list, lazy_depth=1) caused the row AT
737 // `damaged.end` (a blank, lazy_depth=0 in pre-edit)
738 // to flip to ListContinuation in post-edit fresh
739 // parse. The strict reset boundary at that row was
740 // valid pre-edit but became invalid post-edit, and
741 // the splice chose a widened range based on
742 // pre-edit boundaries that didn't capture the new
743 // row past `widened.end`.
744 //
745 // ListMarker rows are immune: a content edit on
746 // "- a" → "- aX" cannot change row+1's classification
747 // because the row+1 was either (a) Plain → became
748 // ListContinuation via the post-pass regardless of
749 // the edit, or (b) Blank/something-else that's outside
750 // the list and unaffected by item-content changes.
751 //
752 // The depth==1 clause blocks edits on lists nested
753 // inside another lazy construct (a list inside a
754 // blockquote) where the OUTER construct can shift.
755 //
756 // Blockquote / Plain / ListContinuation unlocks are
757 // deferred to a follow-up that adds a post-widening
758 // sanity check on `widened.end + 1` (cheap re-parse
759 // of one extra row to detect downstream flips).
760 let kind_qualifies = matches!(old_kind, LineConstructKind::ListMarker);
761 let depth_qualifies = row < lazy.len() && lazy[row] == 1;
762 if kind_qualifies && depth_qualifies {
763 // Don't bail — let blank-transition guard run
764 // and reach the widener stage.
765 } else {
766 return METRICS.bail(BailReason::LazyDepth);
767 }
768 }
769
770 // V2 blank-transition guard: a row flipping between blank
771 // and non-blank invalidates the pre-edit reset boundary
772 // at that row in the post-edit world (paragraph lazy-
773 // continuation, empty list-item shapes like `*` that
774 // parse as ListMarker in slice but as paragraph
775 // continuation in full). Use the pre-edit `kinds` for
776 // the "blank" classification instead of `line.trim()` so
777 // the predicate matches the parser's view exactly.
778 let old_blank = matches!(old_kind, LineConstructKind::Blank);
779 let new_blank = new_line.trim().is_empty();
780 if old_blank != new_blank {
781 let above_non_blank = row > 0
782 && !matches!(
783 self.parse_state.buf().kinds[row - 1],
784 LineConstructKind::Blank
785 );
786 let below_non_blank = row + 1 < self.parse_state.buf().kinds.len()
787 && !matches!(
788 self.parse_state.buf().kinds[row + 1],
789 LineConstructKind::Blank
790 );
791 if above_non_blank || below_non_blank {
792 return METRICS.bail(BailReason::BlankTransition);
793 }
794 }
795 }
796
797 // Two-tier widener:
798 //
799 // 1. `expand_to_reset_boundary(reset_boundaries, ...)` —
800 // strict. Provably equivalent to a fresh parse; no
801 // post-slice verify needed.
802 // 2. `widen_to_safe` — heuristic fallback. NOT provably
803 // equivalent; the post-slice verify (below, release-on)
804 // is the correctness mechanism and bails to a full
805 // rebuild on any divergence.
806 //
807 // After a §3.0 relax fires the strict widener usually
808 // cap-trips (lazy_depth > 0 around the edit means no nearby
809 // blank-with-depth-0 reset boundary), but we still try strict
810 // first — it costs only a binary search and succeeds in
811 // degenerate cases (e.g. small buffers where strict widens
812 // safely to the whole buffer). On failure we fall to
813 // widen_to_safe.
814 //
815 // A former middle tier (`intra_construct_boundaries`, the V3
816 // "IntraConstruct" path) was removed: it fired only on loose-
817 // list edits and `widen_to_safe` covers every such case with
818 // zero extra full rebuilds (measured), differing only in
819 // reparse span (~11 vs ~2 rows — both far under the 256 cap).
820 let mut splice_path = SplicePath::Strict;
821 let widened = match expand_to_reset_boundary(
822 &self.parse_state.buf().reset_boundaries,
823 self.parse_state.buf().lines.len(),
824 damaged.clone(),
825 ) {
826 WidenResult::Widened(r) => r,
827 WidenResult::FullRebuild => {
828 match widen_to_safe(&self.parse_state.buf().kinds, damaged.clone()) {
829 WidenResult::Widened(r) => {
830 splice_path = SplicePath::Heuristic;
831 r
832 }
833 WidenResult::FullRebuild => return METRICS.bail(BailReason::CapTrip),
834 }
835 }
836 };
837 let slice = ParsedBuffer::parse_range(lines, widened.clone());
838
839 // Post-slice undamaged-row verification.
840 //
841 // - Strict path: skipped. Provably equivalent to a fresh
842 // parse (see `reset_boundaries` docstring).
843 // - Heuristic path: NOT provably equivalent, so this verify
844 // is the correctness mechanism and runs in release. It is
845 // cheap: `slice` was already parsed above
846 // (unconditionally), and the loop only compares
847 // kinds/elements.len()/content_vis over the `widened` rows —
848 // bounded by the widen cap (≤256), negligible against the
849 // parse_range that already ran. A divergence (e.g. a pulldown
850 // version bump changing tokenisation) bails to a full rebuild
851 // rather than shipping a corrupt splice. The 600k proptest
852 // cases (100k × 6 strategies, 0 verify_failed) stay in the
853 // regression harness; this guard is the release backstop.
854 let verify_eligible_path = matches!(splice_path, SplicePath::Heuristic);
855 if verify_eligible_path {
856 for row in widened.clone() {
857 if damaged.contains(&row) {
858 continue; // Damaged row: kind change is expected/irrelevant.
859 }
860 let idx = row - widened.start;
861 if slice.kinds[idx] != self.parse_state.buf().kinds[row] {
862 return METRICS.bail(BailReason::VerifyFailed);
863 }
864 if slice.lines[idx].elements.len()
865 != self.parse_state.buf().lines[row].elements.len()
866 {
867 return METRICS.bail(BailReason::VerifyFailed);
868 }
869 if slice.lines[idx].content_vis != self.parse_state.buf().lines[row].content_vis {
870 return METRICS.bail(BailReason::VerifyFailed);
871 }
872 }
873 }
874
875 METRICS.ok(match splice_path {
876 SplicePath::Strict => SuccessPath::ResetBoundary,
877 SplicePath::Heuristic => SuccessPath::WidenToSafe,
878 });
879 Some((widened, slice, splice_path))
880 }
881
882 pub fn render(
883 &mut self,
884 f: &mut Frame,
885 rect: Rect,
886 theme: &Theme,
887 focused: bool,
888 cursor_shape: Option<CursorShape>,
889 ) {
890 if rect.height == 0 {
891 return;
892 }
893 let lines = &self.lines_snapshot;
894 let cursor = self.cursor_snapshot;
895 let scroll = self.visual_scroll_offset;
896 let height = rect.height as usize;
897 let vlines = self.layout.visual_lines();
898
899 let selection = self.selection;
900 let parsed_lines = &self.parse_state.buf().lines;
901 let fence_ranges = &self.fence_ranges;
902
903 let visible: Vec<Line> = vlines
904 .iter()
905 .skip(scroll)
906 .take(height)
907 .map(|vl| {
908 let cursor_col = if vl.logical_row == cursor.0 {
909 Some(cursor.1)
910 } else {
911 None
912 };
913 let force_raw = fence_ranges.iter().any(|r| r.contains(&vl.logical_row));
914 // Snapshot invariant: every `vl.logical_row` is < lines.len()
915 // because `layout` and `lines_snapshot` were rebuilt from
916 // the same `EditorSnapshot` in the last `update()`.
917 let logical_line = lines[vl.logical_row].as_str();
918 let parsed = &parsed_lines[vl.logical_row];
919 let content = vl.content(logical_line);
920 let spans = MarkdownSpanner::render_with(
921 content,
922 logical_line,
923 parsed,
924 vl.start_col,
925 cursor_col,
926 vl.is_first_visual_line,
927 force_raw,
928 rect.width,
929 theme,
930 );
931
932 // Apply code-block background before selection so selection bg wins on selected text.
933 let spans =
934 if let Some(bw) = self.code_box_width.get(vl.logical_row).copied().flatten() {
935 apply_code_box(spans, bw, theme)
936 } else {
937 spans
938 };
939
940 // Apply selection highlight if this visual line is within the selection.
941 let spans = if let Some(((sel_sr, sel_sc), (sel_er, sel_ec))) = selection {
942 let row = vl.logical_row;
943 if row >= sel_sr && row <= sel_er {
944 // The blockquote bar gutter occupies `gutter_off` screen
945 // columns to the left of the content. On the first visual
946 // line `rendered_cursor_col_with` already accounts for it
947 // (it counts the revealed `> ` sigil, whose width equals
948 // the gutter), so only continuation rows need the offset
949 // added — they carry the gutter but no sigil to count.
950 let gutter_off = if vl.is_first_visual_line {
951 0
952 } else {
953 self.gutter_insets.get(vl.logical_row).copied().unwrap_or(0)
954 };
955 let start_rendered = if row == sel_sr {
956 MarkdownSpanner::rendered_cursor_col_with(
957 logical_line,
958 parsed,
959 vl.start_col,
960 sel_sc,
961 vl.is_first_visual_line,
962 force_raw,
963 ) + gutter_off
964 } else {
965 0
966 };
967 let end_rendered = if row == sel_er {
968 MarkdownSpanner::rendered_cursor_col_with(
969 logical_line,
970 parsed,
971 vl.start_col,
972 sel_ec,
973 vl.is_first_visual_line,
974 force_raw,
975 ) + gutter_off
976 } else {
977 // Entire line is selected; use a sentinel larger than any line width.
978 u16::MAX as usize
979 };
980 apply_selection_highlight(spans, start_rendered..end_rendered, theme)
981 } else {
982 spans
983 }
984 } else {
985 spans
986 };
987
988 Line::from(spans)
989 })
990 .collect();
991
992 f.render_widget(
993 Paragraph::new(Text::from(visible)).style(theme.base_style()),
994 rect,
995 );
996
997 // Draw terminal cursor when focused. The `EditorSnapshot` the
998 // last `update()` consumed guarantees `cursor.0` is in-bounds
999 // for `parsed_buffer.lines` and `layout.visual_lines()` —
1000 // both were rebuilt from the same snapshot. The single
1001 // remaining edge case is an empty buffer (no rows at all),
1002 // handled by the early `is_empty` short-circuit below; the
1003 // previous defensive `.get()` chain (commit c03dc728) was
1004 // there to absorb stale Nvim snapshots where cursor outran
1005 // lines, which the snapshot invariant now rules out.
1006 self.last_cursor_screen = None;
1007 let mut desired_style: Option<CursorShape> = None;
1008 if focused
1009 && !self.parse_state.buf().lines.is_empty()
1010 && !self.layout.visual_lines().is_empty()
1011 {
1012 let cursor_vrow = self.cursor_vrow;
1013 if cursor_vrow >= scroll && cursor_vrow < scroll + height {
1014 let vl = &self.layout.visual_lines()[cursor_vrow];
1015 let parsed = &self.parse_state.buf().lines[cursor.0];
1016 // Snapshot invariant + outer `!is_empty()` guard: cursor.0
1017 // is in-bounds for `lines_snapshot` here.
1018 let logical_line = lines[cursor.0].as_str();
1019 let force_raw = self.is_in_code_block(cursor.0);
1020 let rendered_col = MarkdownSpanner::rendered_cursor_col_with(
1021 logical_line,
1022 parsed,
1023 vl.start_col,
1024 cursor.1,
1025 vl.is_first_visual_line,
1026 force_raw,
1027 );
1028 let cx = rect.x + rendered_col as u16;
1029 let cy = rect.y + (cursor_vrow - scroll) as u16;
1030 f.set_cursor_position(Position { x: cx, y: cy });
1031 self.last_cursor_screen = Some((cx, cy));
1032 desired_style = cursor_shape;
1033 }
1034 }
1035 if desired_style != self.applied_cursor_style {
1036 use ratatui::crossterm::cursor::SetCursorStyle;
1037 let style = match desired_style {
1038 Some(CursorShape::Block) => SetCursorStyle::SteadyBlock,
1039 Some(CursorShape::Bar) => SetCursorStyle::SteadyBar,
1040 None => SetCursorStyle::DefaultUserShape,
1041 };
1042 let _ = ratatui::crossterm::execute!(std::io::stdout(), style);
1043 self.applied_cursor_style = desired_style;
1044 }
1045 }
1046
1047 /// Test accessor: the kinds vector of the current parsed buffer.
1048 /// Used by the proptest harness to assert incremental = full parse.
1049 pub fn parsed_buffer_kinds(&self) -> &[super::parse_incremental::LineConstructKind] {
1050 &self.parse_state.buf().kinds
1051 }
1052
1053 /// Test accessor: the parsed lines of the current parsed buffer.
1054 pub fn parsed_buffer_lines(&self) -> &[super::markdown::ParsedLine] {
1055 &self.parse_state.buf().lines
1056 }
1057
1058 /// Test accessor: the rendered-position bitmask cache.
1059 /// Used by tests to construct a fresh `WordWrapLayout` from the same
1060 /// masks the view is using, for equivalence checks.
1061 #[cfg(test)]
1062 pub(crate) fn rendered_cache_for_testing(&self) -> &[Vec<bool>] {
1063 &self.rendered_cache
1064 }
1065
1066 #[cfg(test)]
1067 pub(crate) fn code_box_width_for_testing(&self) -> &[Option<u16>] {
1068 &self.code_box_width
1069 }
1070
1071 #[cfg(test)]
1072 pub(crate) fn gutter_insets_for_testing(&self) -> &[usize] {
1073 &self.gutter_insets
1074 }
1075
1076 fn is_in_code_block(&self, row: usize) -> bool {
1077 // Every line inside any fenced block renders force-raw (no markdown
1078 // re-styling, distinct fg color). Previously this checked only the
1079 // fence the cursor was sitting in, so fenced blocks elsewhere in
1080 // the buffer looked like plain text until the cursor moved into
1081 // them.
1082 self.fence_ranges.iter().any(|r| r.contains(&row))
1083 }
1084
1085 /// Rebuild `code_box_width` from the current parse kinds and snapshot
1086 /// lines. Box width per block = max rendered display width of its lines,
1087 /// capped at `width`.
1088 fn rebuild_code_box_width(&mut self, lines: &[String], width: u16) {
1089 let mut out = vec![None; lines.len()];
1090 let ranges =
1091 super::parse_incremental::code_block_ranges_from_kinds(&self.parse_state.buf().kinds);
1092 for r in ranges {
1093 let mut max_w = 0usize;
1094 for row in r.clone() {
1095 if let Some(line) = lines.get(row) {
1096 max_w = max_w.max(super::markdown::raw_display_width(line));
1097 }
1098 }
1099 let boxed = (max_w.min(width as usize)) as u16;
1100 for row in r {
1101 if row < out.len() {
1102 out[row] = Some(boxed);
1103 }
1104 }
1105 }
1106 self.code_box_width = out;
1107 }
1108
1109 /// Rebuild `gutter_insets` from parse state + cursor. A blockquote row
1110 /// that is not the cursor row reserves `depth + 1` cols for the bar; the
1111 /// cursor row reserves 0 (its markers are revealed raw).
1112 fn rebuild_gutter_insets(&mut self, lines: &[String], cursor_row: usize) {
1113 let parsed = &self.parse_state.buf().lines;
1114 self.gutter_insets = (0..lines.len())
1115 .map(|row| {
1116 if row == cursor_row {
1117 return 0;
1118 }
1119 match parsed.get(row).and_then(|p| p.blockquote_depth()) {
1120 Some(d) => super::markdown::blockquote_gutter_width(d),
1121 None => 0,
1122 }
1123 })
1124 .collect();
1125 }
1126
1127 /// Markdown-aware mouse click: maps a rendered screen column to
1128 /// the correct logical column, accounting for hidden markdown
1129 /// sigils (links, bold markers, etc.).
1130 ///
1131 /// Reads `self`'s view-internal caches (`layout`, `lines_snapshot`,
1132 /// `parsed_buffer`), all rebuilt from the same `EditorSnapshot`
1133 /// in the last `update()` call. The snapshot invariant guarantees
1134 /// `vl.logical_row` is a valid index into both `lines_snapshot`
1135 /// and `parsed_buffer.lines`, so direct indexing is safe — the
1136 /// previous defensive `(Some, Some) else fallback` block (Fix #2
1137 /// in the holistic review) is no longer needed.
1138 /// Map a screen-relative click (row/col offset from the editor's
1139 /// top-left corner) to logical (row, col). Owns the
1140 /// visual-scroll-offset arithmetic so callers do not reach into
1141 /// `visual_scroll_offset` — the view knows where it is scrolled.
1142 pub fn click_at_screen(&self, screen_row: usize, screen_col: usize) -> (u16, u16) {
1143 let vrow = screen_row + self.visual_scroll_offset;
1144 self.click_to_logical_u16(vrow, screen_col)
1145 }
1146
1147 fn click_to_logical_u16(&self, vrow: usize, vcol: usize) -> (u16, u16) {
1148 let vlines = self.layout.visual_lines();
1149 if vlines.is_empty() {
1150 return (0, 0);
1151 }
1152 let vrow = vrow.min(vlines.len() - 1);
1153 let vl = &vlines[vrow];
1154 let row_u16 = vl.logical_row.min(u16::MAX as usize) as u16;
1155 let logical_line = self.lines_snapshot[vl.logical_row].as_str();
1156 let parsed = &self.parse_state.buf().lines[vl.logical_row];
1157 let force_raw = self.is_in_code_block(vl.logical_row);
1158 let gutter = self.gutter_insets.get(vl.logical_row).copied().unwrap_or(0);
1159 let vcol = vcol.saturating_sub(gutter);
1160 // When a blockquote gutter is drawn (gutter > 0), the ">" and space
1161 // sigil chars are hidden and replaced by the "│ " bar. On the first
1162 // visual line, skip those hidden sigil chars so that rendered_col 0
1163 // maps to the first content char, not to the hidden ">".
1164 let effective_start_col = if gutter > 0 && vl.is_first_visual_line {
1165 parsed.blockquote_sigil_end().unwrap_or(vl.start_col)
1166 } else {
1167 vl.start_col
1168 };
1169 let logical_col = MarkdownSpanner::rendered_col_to_logical_with(
1170 logical_line,
1171 parsed,
1172 effective_start_col,
1173 vcol,
1174 vl.is_first_visual_line,
1175 force_raw,
1176 );
1177 let col = logical_col.min(u16::MAX as usize) as u16;
1178 (row_u16, col)
1179 }
1180
1181 #[cfg(test)]
1182 pub(crate) fn click_to_logical_for_testing(&self, vrow: usize, vcol: usize) -> (u16, u16) {
1183 self.click_to_logical_u16(vrow, vcol)
1184 }
1185}
1186
1187impl Default for MarkdownEditorView {
1188 fn default() -> Self {
1189 Self::new()
1190 }
1191}
1192
1193/// Returns the byte offset into `s` after consuming exactly `target_width` display columns.
1194/// If `target_width` exceeds the string's display width, returns `s.len()`.
1195fn byte_offset_for_display_width(s: &str, target_width: usize) -> usize {
1196 let mut consumed = 0usize;
1197 for (byte_pos, ch) in s.char_indices() {
1198 if consumed >= target_width {
1199 return byte_pos;
1200 }
1201 consumed += unicode_width::UnicodeWidthChar::width(ch).unwrap_or(0);
1202 }
1203 s.len()
1204}
1205
1206/// Re-style spans to apply `selection_bg` over the given rendered-column range.
1207///
1208/// `sel_cols` is a range of rendered (screen) column offsets within the visual line.
1209/// Spans that overlap the range are split at the boundaries; the overlapping portion
1210/// receives `.bg(theme.selection_bg)`. Non-overlapping portions keep their original style.
1211fn apply_selection_highlight<'a>(
1212 spans: Vec<ratatui::text::Span<'a>>,
1213 sel_cols: std::ops::Range<usize>,
1214 theme: &Theme,
1215) -> Vec<ratatui::text::Span<'a>> {
1216 if sel_cols.is_empty() {
1217 return spans;
1218 }
1219
1220 let highlight_bg = theme.selection_bg.to_ratatui();
1221 let mut result = Vec::new();
1222 let mut col = 0usize;
1223
1224 for span in spans {
1225 let content: &str = &span.content;
1226 let span_width = content.width();
1227 let span_end = col + span_width;
1228
1229 let overlap_start = sel_cols.start.max(col);
1230 let overlap_end = sel_cols.end.min(span_end);
1231
1232 if overlap_start >= overlap_end {
1233 // No overlap — emit as-is.
1234 result.push(span);
1235 } else {
1236 // Walk grapheme clusters by display width to find byte boundaries.
1237 let prefix_width = overlap_start - col;
1238 let selected_width = overlap_end - overlap_start;
1239
1240 let prefix_byte = byte_offset_for_display_width(content, prefix_width);
1241 let selected_byte_end =
1242 byte_offset_for_display_width(&content[prefix_byte..], selected_width)
1243 + prefix_byte;
1244
1245 // Prefix (before selection)
1246 if prefix_byte > 0 {
1247 result.push(ratatui::text::Span::styled(
1248 content[..prefix_byte].to_string(),
1249 span.style,
1250 ));
1251 }
1252 // Selected portion
1253 result.push(ratatui::text::Span::styled(
1254 content[prefix_byte..selected_byte_end].to_string(),
1255 span.style.bg(highlight_bg),
1256 ));
1257 // Suffix (after selection)
1258 if selected_byte_end < content.len() {
1259 result.push(ratatui::text::Span::styled(
1260 content[selected_byte_end..].to_string(),
1261 span.style,
1262 ));
1263 }
1264 }
1265
1266 col = span_end;
1267 }
1268
1269 result
1270}
1271
1272/// Paint `code_bg` behind every span of a code-block visual line and pad the
1273/// line with bg-colored spaces up to `box_width` display columns, producing a
1274/// solid rectangle hugging the block's widest line. Content already wider than
1275/// the box (the box was capped at editor width; wider rows wrap) is left as-is.
1276fn apply_code_box<'a>(
1277 spans: Vec<ratatui::text::Span<'a>>,
1278 box_width: u16,
1279 theme: &Theme,
1280) -> Vec<ratatui::text::Span<'a>> {
1281 use ratatui::text::Span;
1282 let bg = theme.code_bg.to_ratatui();
1283 let mut width = 0usize;
1284 let mut out: Vec<Span<'a>> = spans
1285 .into_iter()
1286 .map(|s| {
1287 width += s.content.width();
1288 let style = s.style.bg(bg);
1289 Span::styled(s.content, style)
1290 })
1291 .collect();
1292 let target = box_width as usize;
1293 if width < target {
1294 out.push(Span::styled(
1295 " ".repeat(target - width),
1296 Style::default().bg(bg),
1297 ));
1298 }
1299 out
1300}
1301
1302#[cfg(test)]
1303mod tests {
1304 use super::*;
1305 use ratatui::layout::Rect;
1306 use std::num::NonZeroU64;
1307
1308 fn rect(h: u16) -> Rect {
1309 Rect {
1310 x: 0,
1311 y: 0,
1312 width: 40,
1313 height: h,
1314 }
1315 }
1316
1317 /// Test-only wrapper that builds an `EditorSnapshot::borrowed`
1318 /// from the legacy `(lines, cursor, generation)` shape, so the
1319 /// hundreds of existing call sites don't each have to construct
1320 /// the snapshot inline.
1321 ///
1322 /// Mirrors `snapshot_from_backend`'s producer-side cursor clamp,
1323 /// so tests that pass an intentionally-stale `cursor` (e.g. the
1324 /// regression for the Nvim shrink panic) still exercise the
1325 /// real production path: producer clamps, render trusts.
1326 fn update_view(
1327 v: &mut MarkdownEditorView,
1328 lines: &[String],
1329 cursor: (usize, usize),
1330 rect: Rect,
1331 generation: u64,
1332 selection: Option<((usize, usize), (usize, usize))>,
1333 ) {
1334 let rev = NonZeroU64::new(generation.max(1)).unwrap();
1335 let clamped = if lines.is_empty() {
1336 (0, 0)
1337 } else {
1338 (cursor.0.min(lines.len() - 1), cursor.1)
1339 };
1340 let snap = super::super::snapshot::EditorSnapshot::borrowed(lines, clamped, rev);
1341 v.update(&snap, rect, selection);
1342 }
1343
1344 /// Build a freshly-updated view from `lines` with the cursor at
1345 /// `cursor` and the given editor `width`, using the real snapshot +
1346 /// `update()` path. Height is fixed at 24.
1347 fn make_view_for_lines(
1348 lines: &[String],
1349 cursor: (usize, usize),
1350 width: u16,
1351 ) -> MarkdownEditorView {
1352 let mut v = MarkdownEditorView::new();
1353 let r = Rect {
1354 x: 0,
1355 y: 0,
1356 width,
1357 height: 24,
1358 };
1359 update_view(&mut v, lines, cursor, r, 1, None);
1360 v
1361 }
1362
1363 #[test]
1364 fn code_box_background_reaches_rendered_cells() {
1365 use ratatui::Terminal;
1366 use ratatui::backend::TestBackend;
1367 let lines = vec![
1368 "```".to_string(),
1369 "let x = 1;".to_string(),
1370 "```".to_string(),
1371 "plain".to_string(),
1372 ];
1373 let theme = crate::settings::themes::Theme::gruvbox_dark();
1374 let mut view = make_view_for_lines(&lines, (3, 0), 40);
1375 let mut terminal = Terminal::new(TestBackend::new(40, 5)).unwrap();
1376 terminal
1377 .draw(|f| view.render(f, f.area(), &theme, true, Some(CursorShape::Bar)))
1378 .unwrap();
1379 let buf = terminal.backend().buffer().clone();
1380 let code_bg = theme.code_bg.to_ratatui();
1381 let cell = |x: u16, y: u16| &buf.content[(y as usize) * 40 + (x as usize)];
1382
1383 // A cell on the fenced code content row carries the code-box bg...
1384 assert_eq!(
1385 cell(0, 1).bg,
1386 code_bg,
1387 "code content cell must have code_bg"
1388 );
1389 // ...including the padding past the text (box is a solid rectangle).
1390 assert_eq!(cell(8, 1).bg, code_bg, "code-box padding must have code_bg");
1391 // A prose row outside the block does NOT get the code bg.
1392 assert_ne!(cell(0, 3).bg, code_bg, "prose row must not have code_bg");
1393 }
1394
1395 #[test]
1396 fn blockquote_gutter_inset_off_cursor_row_only() {
1397 // Two blockquote lines; cursor on row 0.
1398 let lines = vec!["> first".to_string(), ">> second".to_string()];
1399 let view = make_view_for_lines(&lines, (0, 1), 80);
1400 let g = view.gutter_insets_for_testing();
1401 assert_eq!(g[0], 0); // cursor row → revealed, no gutter
1402 assert_eq!(g[1], 3); // depth 2 → 2 bars + 1 space
1403 }
1404
1405 #[test]
1406 fn code_box_width_is_block_max_capped_to_width() {
1407 let lines = vec![
1408 "```".to_string(),
1409 "let x = 1;".to_string(), // 10
1410 "let yy = 222;".to_string(), // 13 (widest)
1411 "```".to_string(),
1412 "plain".to_string(),
1413 ];
1414 let view = make_view_for_lines(&lines, (0, 0), 80); // width 80
1415 let w = view.code_box_width_for_testing();
1416 assert_eq!(w[0], Some(13));
1417 assert_eq!(w[1], Some(13));
1418 assert_eq!(w[2], Some(13));
1419 assert_eq!(w[3], Some(13));
1420 assert_eq!(w[4], None);
1421 }
1422
1423 #[test]
1424 fn new_has_zero_scroll() {
1425 assert_eq!(MarkdownEditorView::new().visual_scroll_offset, 0);
1426 }
1427
1428 #[test]
1429 fn zero_height_rect_does_not_panic() {
1430 let mut v = MarkdownEditorView::new();
1431 update_view(&mut v, &["hello".to_string()], (0, 0), rect(0), 1, None);
1432 }
1433
1434 #[test]
1435 fn scroll_follows_cursor_down() {
1436 let mut v = MarkdownEditorView::new();
1437 let lines: Vec<String> = (0..5).map(|i| format!("line{}", i)).collect();
1438 update_view(&mut v, &lines, (4, 0), rect(3), 1, None);
1439 assert!(v.visual_scroll_offset >= 2);
1440 }
1441
1442 #[test]
1443 fn scroll_follows_cursor_up() {
1444 let mut v = MarkdownEditorView::new();
1445 let lines: Vec<String> = (0..5).map(|i| format!("line{}", i)).collect();
1446 update_view(&mut v, &lines, (4, 0), rect(3), 1, None);
1447 update_view(&mut v, &lines, (0, 0), rect(3), 1, None); // same generation — scroll still adjusts
1448 assert_eq!(v.visual_scroll_offset, 0);
1449 }
1450
1451 #[test]
1452 fn visual_to_logical_u16_accounts_for_scroll() {
1453 let mut v = MarkdownEditorView::new();
1454 let lines: Vec<String> = (0..10).map(|i| format!("line{}", i)).collect();
1455 update_view(&mut v, &lines, (5, 0), rect(3), 1, None);
1456 let scroll = v.visual_scroll_offset;
1457 let (row, _col) = v.click_to_logical_u16(scroll, 0);
1458 assert_eq!(row as usize, scroll);
1459 }
1460
1461 #[test]
1462 fn code_block_detection_cursor_inside() {
1463 let lines = vec![
1464 "text".to_string(),
1465 "```rust".to_string(),
1466 "let x = 1;".to_string(),
1467 "```".to_string(),
1468 "more".to_string(),
1469 ];
1470 let pb = ParsedBuffer::parse(&lines);
1471 let ranges = super::super::parse_incremental::fence_ranges_from_kinds(&pb.kinds);
1472 let block = ranges.iter().find(|r| r.contains(&2)).cloned();
1473 assert!(block.is_some());
1474 let r = block.unwrap();
1475 assert_eq!(r.start, 1);
1476 assert_eq!(r.end, 4);
1477 }
1478
1479 #[test]
1480 fn code_block_detection_cursor_outside() {
1481 let lines = vec![
1482 "text".to_string(),
1483 "```".to_string(),
1484 "code".to_string(),
1485 "```".to_string(),
1486 ];
1487 let pb = ParsedBuffer::parse(&lines);
1488 let ranges = super::super::parse_incremental::fence_ranges_from_kinds(&pb.kinds);
1489 assert!(ranges.iter().find(|r| r.contains(&0)).is_none());
1490 }
1491
1492 #[test]
1493 fn click_to_logical_does_not_panic_on_stale_layout() {
1494 // Regression: click_to_logical_u16 raw-indexed parsed_buffer.lines
1495 // by vl.logical_row. A stale layout whose visual_lines outlive a
1496 // shrink of parsed_buffer.lines would panic on mouse click. The
1497 // guard now falls back to a raw visual-col mapping.
1498 let mut v = MarkdownEditorView::new();
1499 let long: Vec<String> = (0..20).map(|i| format!("line{}", i)).collect();
1500 update_view(&mut v, &long, (0, 0), rect(10), 1, None);
1501 // Drive a shrink so layout.visual_lines outruns parsed_buffer.lines
1502 // briefly. update() rebuilds layout from the new lines, so the
1503 // pure shrink shouldn't desynchronize them — but we still want a
1504 // black-box test that simulates a click against the last vrow.
1505 let vrows = v.layout.visual_lines().len();
1506 if vrows > 0 {
1507 let _ = v.click_to_logical_u16(vrows.saturating_sub(1), 0);
1508 let _ = v.click_to_logical_u16(vrows + 5, 0);
1509 }
1510 }
1511
1512 #[test]
1513 fn render_does_not_panic_on_stale_cursor_past_line_count() {
1514 // Regression: render() previously did self.parsed_cache[cursor.0]
1515 // and self.layout.visual_lines()[cursor_vrow] directly. A stale
1516 // Nvim snapshot whose cursor row landed past the new line count
1517 // would panic the render thread. Now the test exercises the
1518 // producer-side clamp (via `update_view`'s mirror of
1519 // `snapshot_from_backend`): the snapshot constructor clamps
1520 // the cursor, render trusts the invariant, and direct
1521 // indexing is safe.
1522 use ratatui::Terminal;
1523 use ratatui::backend::TestBackend;
1524 let theme = Theme::gruvbox_dark();
1525 let backend = TestBackend::new(40, 10);
1526 let mut terminal = Terminal::new(backend).unwrap();
1527
1528 let mut v = MarkdownEditorView::new();
1529 // Populate with 2 lines and a valid cursor first so parsed_cache /
1530 // layout are non-empty.
1531 update_view(
1532 &mut v,
1533 &["alpha".to_string(), "beta".to_string()],
1534 (0, 0),
1535 rect(8),
1536 1,
1537 None,
1538 );
1539 // Now feed a cursor row that exceeds the line count for this update
1540 // (simulates a stale snapshot arriving after a shrink). update() at
1541 // line 277 already uses `lines.get(cursor.0)` so it won't panic; the
1542 // real risk was the [] indexes inside render(). cursor_snapshot ends
1543 // up at (5, 0) which exceeds the parsed_cache len of 2 below.
1544 update_view(
1545 &mut v,
1546 &["alpha".to_string(), "beta".to_string()],
1547 (5, 0),
1548 rect(8),
1549 1,
1550 None,
1551 );
1552 // Render with focus so the cursor branch runs.
1553 terminal
1554 .draw(|f| v.render(f, f.area(), &theme, true, Some(CursorShape::Bar)))
1555 .expect("render must not panic on stale cursor");
1556 }
1557
1558 #[test]
1559 fn cursor_into_link_refreshes_layout_for_same_row() {
1560 // Regression: when the cursor moves within a row, crossing into
1561 // or out of an expandable inline element (link/bold/etc.), the
1562 // rendered mask flips (the element reveals or hides its hidden
1563 // sigils). Both rendered_cache and the wrap layout depend on
1564 // the mask. Previously Gate 2 took the `TextChangeKind::None`
1565 // wrap branch and skipped re-splicing, leaving stale visual
1566 // lines until the next text edit.
1567 //
1568 // Use a link whose hidden URL is long enough that revealing it
1569 // forces an extra wrap line at width 40 — that lets us
1570 // black-box detect the mask flip via visual_lines.len().
1571 let mut v = MarkdownEditorView::new();
1572 let lines =
1573 vec more".to_string()];
1574 // First update: cursor outside the link (col 0).
1575 update_view(&mut v, &lines, (0, 0), rect(5), 1, None);
1576 let n_outside = v.layout.visual_lines().len();
1577
1578 // Second update: cursor inside the link element.
1579 update_view(&mut v, &lines, (0, 8), rect(5), 1, None);
1580 let layout_inside = v.layout.visual_lines().to_vec();
1581
1582 // Fresh view with cursor already inside must produce the same layout.
1583 let mut fresh = MarkdownEditorView::new();
1584 update_view(&mut fresh, &lines, (0, 8), rect(5), 1, None);
1585 let layout_fresh = fresh.layout.visual_lines().to_vec();
1586 assert_eq!(
1587 layout_inside, layout_fresh,
1588 "post-move layout must match a fresh full-recompute"
1589 );
1590 assert!(
1591 layout_inside.len() > n_outside,
1592 "expanding the link's hidden URL must produce more visual lines"
1593 );
1594 }
1595
1596 #[test]
1597 fn try_incremental_parse_falls_back_on_indented_code_flip() {
1598 // Regression: a Plain row flipping to IndentedCode (4 leading
1599 // spaces) can lazy-extend an indented-code block across the
1600 // following Plain rows in the full buffer. The widened slice
1601 // can't see that context. Guard must trip fallback.
1602 let mut v = MarkdownEditorView::new();
1603 let lines = vec!["alpha".to_string(), "beta".to_string(), "gamma".to_string()];
1604 update_view(&mut v, &lines, (0, 0), rect(20), 1, None);
1605 let new_lines = vec![
1606 "alpha".to_string(),
1607 " beta".to_string(),
1608 "gamma".to_string(),
1609 ];
1610 // try_incremental_parse must return None (full-rebuild signal).
1611 assert!(
1612 v.try_incremental_parse(&new_lines, (1, 0)).is_none(),
1613 "indented-code flip must force a full rebuild"
1614 );
1615 }
1616
1617 /// V2 structural guard regression. Buffer `[" code", "",
1618 /// " more"]` has lazy_depth `[1, 1, 1]` (indented code
1619 /// multi-chunk per CommonMark §4.4). An edit at row 1 (the blank
1620 /// inside the block) must trigger fallback, even though the row
1621 /// is itself Blank and would otherwise be a safe-looking
1622 /// boundary candidate.
1623 #[test]
1624 fn try_incremental_parse_falls_back_when_damaged_row_is_inside_lazy_block() {
1625 let mut v = MarkdownEditorView::new();
1626 let lines = vec![
1627 " code".to_string(),
1628 "".to_string(),
1629 " more".to_string(),
1630 ];
1631 update_view(&mut v, &lines, (0, 0), rect(20), 1, None);
1632 assert_eq!(
1633 v.parse_state.buf().lazy_depth,
1634 vec![1, 1, 1],
1635 "precondition: parsed_buffer.lazy_depth must mark all three rows as inside the block"
1636 );
1637 let new_lines = vec![
1638 " code".to_string(),
1639 "x".to_string(),
1640 " more".to_string(),
1641 ];
1642 assert!(
1643 v.try_incremental_parse(&new_lines, (1, 1)).is_none(),
1644 "edit inside an open lazy-continuable block must force a full rebuild"
1645 );
1646 }
1647
1648 #[test]
1649 fn try_incremental_parse_falls_back_on_html_block_flip() {
1650 // Regression: a Plain row flipping to an HTML-block opener
1651 // (`<div>`) starts a block that lazy-extends through subsequent
1652 // Plain rows in the full buffer.
1653 let mut v = MarkdownEditorView::new();
1654 let lines = vec!["alpha".to_string(), "beta".to_string(), "gamma".to_string()];
1655 update_view(&mut v, &lines, (0, 0), rect(20), 1, None);
1656 let new_lines = vec![
1657 "alpha".to_string(),
1658 "<div>".to_string(),
1659 "gamma".to_string(),
1660 ];
1661 assert!(
1662 v.try_incremental_parse(&new_lines, (1, 0)).is_none(),
1663 "HTML-block opener flip must force a full rebuild"
1664 );
1665 }
1666
1667 #[test]
1668 fn is_in_code_block_returns_true_for_any_fence_regardless_of_cursor() {
1669 // Regression: after commit cceef444, every fenced block renders
1670 // force-raw — not just the one the cursor sits in. Verify by
1671 // probing `is_in_code_block` for a row in a fence while the
1672 // cursor is positioned elsewhere.
1673 let mut v = MarkdownEditorView::new();
1674 let lines = vec![
1675 "intro".to_string(),
1676 "```".to_string(),
1677 "code".to_string(),
1678 "```".to_string(),
1679 "outro".to_string(),
1680 ];
1681 // Cursor on the prose line; fence interior must still report in-block.
1682 update_view(&mut v, &lines, (4, 0), rect(10), 1, None);
1683 assert!(v.is_in_code_block(2), "fence interior is in-block");
1684 assert!(!v.is_in_code_block(0), "prose line is not in-block");
1685 assert!(!v.is_in_code_block(4), "trailing prose is not in-block");
1686 }
1687
1688 #[test]
1689 fn parsed_cache_populated_after_update() {
1690 let mut v = MarkdownEditorView::new();
1691 let lines = vec!["hello".to_string(), "**bold**".to_string()];
1692 update_view(&mut v, &lines, (0, 0), rect(10), 1, None);
1693 assert_eq!(v.parse_state.buf().lines.len(), 2);
1694 }
1695
1696 #[test]
1697 fn layout_skipped_on_horizontal_cursor_move_in_plain_text() {
1698 let mut v = MarkdownEditorView::new();
1699 let lines = vec!["hello world".to_string()];
1700 update_view(&mut v, &lines, (0, 0), rect(40), 1, None);
1701 let layout_gen_after_first = v.last_layout_generation;
1702 // Move cursor right — same row, no elements, same generation → layout must be skipped.
1703 update_view(&mut v, &lines, (0, 5), rect(40), 1, None);
1704 assert_eq!(
1705 v.last_layout_cursor,
1706 (0, 0),
1707 "layout cursor unchanged = layout was skipped"
1708 );
1709 assert_eq!(v.last_layout_generation, layout_gen_after_first);
1710 }
1711
1712 #[test]
1713 fn layout_recomputed_on_row_change() {
1714 let mut v = MarkdownEditorView::new();
1715 let lines: Vec<String> = (0..3).map(|i| format!("line{}", i)).collect();
1716 update_view(&mut v, &lines, (0, 0), rect(40), 1, None);
1717 update_view(&mut v, &lines, (1, 0), rect(40), 1, None); // cursor moves to row 1
1718 assert_eq!(v.last_layout_cursor.0, 1, "layout recomputed on row change");
1719 }
1720
1721 #[test]
1722 fn layout_recomputed_on_width_change() {
1723 let mut v = MarkdownEditorView::new();
1724 let lines = vec!["hello world foo bar".to_string()];
1725 update_view(&mut v, &lines, (0, 0), rect(40), 1, None);
1726 update_view(
1727 &mut v,
1728 &lines,
1729 (0, 0),
1730 Rect {
1731 x: 0,
1732 y: 0,
1733 width: 10,
1734 height: 10,
1735 },
1736 1,
1737 None,
1738 );
1739 assert_eq!(v.last_layout_width, 10);
1740 }
1741
1742 #[test]
1743 fn same_generation_skips_snapshot_rebuild() {
1744 let mut v = MarkdownEditorView::new();
1745 let lines = vec!["original".to_string()];
1746 update_view(&mut v, &lines, (0, 0), rect(10), 1, None);
1747 // Update with different content but same generation — snapshot must NOT change.
1748 let lines2 = vec!["changed".to_string()];
1749 update_view(&mut v, &lines2, (0, 0), rect(10), 1, None);
1750 assert_eq!(v.lines_snapshot, vec!["original".to_string()]);
1751 }
1752
1753 #[test]
1754 fn new_generation_triggers_snapshot_rebuild() {
1755 let mut v = MarkdownEditorView::new();
1756 let lines = vec!["original".to_string()];
1757 update_view(&mut v, &lines, (0, 0), rect(10), 1, None);
1758 let lines2 = vec!["changed".to_string()];
1759 update_view(&mut v, &lines2, (0, 0), rect(10), 2, None);
1760 assert_eq!(v.lines_snapshot, vec!["changed".to_string()]);
1761 }
1762
1763 #[test]
1764 fn update_stores_selection() {
1765 let mut v = MarkdownEditorView::new();
1766 let lines = vec!["hello world".to_string()];
1767 update_view(&mut v, &lines, (0, 0), rect(40), 1, Some(((0, 0), (0, 5))));
1768 assert_eq!(v.selection, Some(((0, 0), (0, 5))));
1769 }
1770
1771 #[test]
1772 fn update_clears_selection_when_none() {
1773 let mut v = MarkdownEditorView::new();
1774 let lines = vec!["hello world".to_string()];
1775 update_view(&mut v, &lines, (0, 0), rect(40), 1, Some(((0, 0), (0, 5))));
1776 update_view(&mut v, &lines, (0, 0), rect(40), 1, None);
1777 assert_eq!(v.selection, None);
1778 }
1779
1780 #[test]
1781 fn typing_single_char_in_long_buffer_uses_incremental_path() {
1782 let mut v = MarkdownEditorView::new();
1783 let mut lines: Vec<String> = (0..1000).map(|i| format!("paragraph {i}")).collect();
1784 update_view(&mut v, &lines, (500, 0), rect(40), 1, None);
1785 // The 1000-line buffer takes the async-parse placeholder path on
1786 // first parse. Simulate the background task completing before the
1787 // edit so the next update splices against a real (non-placeholder)
1788 // buffer; Gate 1 deliberately refuses to incrementally splice the
1789 // all-`Plain` placeholder.
1790 v.install_full_parse(1, ParsedBuffer::parse(&lines));
1791
1792 // Single-char insert at row 500.
1793 lines[500].push('x');
1794 let edited_len = lines[500].len();
1795 update_view(&mut v, &lines, (500, edited_len), rect(40), 2, None);
1796
1797 // The spliced result must equal a fresh full parse.
1798 let fresh = ParsedBuffer::parse(&lines);
1799 assert_eq!(v.parse_state.buf().lines.len(), fresh.lines.len());
1800 assert_eq!(v.parse_state.buf().kinds, fresh.kinds);
1801 // Regression: the heuristic widener splices a slice whose
1802 // local sentinel boundaries (slice rows 0 and len) are NOT
1803 // genuine reset boundaries of the merged buffer. splice must
1804 // not promote them — a 1000-line single-paragraph buffer has
1805 // reset boundaries only at [0, 1000].
1806 assert_eq!(
1807 v.parse_state.buf().reset_boundaries,
1808 fresh.reset_boundaries,
1809 "heuristic splice must not introduce spurious reset boundaries"
1810 );
1811 // And the incremental path was actually taken.
1812 assert!(
1813 v.last_parse_was_incremental,
1814 "single-char paragraph edit should take incremental path"
1815 );
1816 }
1817
1818 #[test]
1819 fn edit_while_placeholder_active_refuses_incremental_and_rearms() {
1820 // Regression: a large-buffer edit installs an unstyled placeholder
1821 // (all-`Plain` kinds) pending a background full parse. If the next
1822 // edit lands before the parse completes, Gate 1 must NOT splice the
1823 // placeholder — its all-`Plain` kinds defeat the structural guards
1824 // and would lock in a wrong parse that install_full_parse then drops
1825 // as stale. The edit must re-install a placeholder + re-arm pending.
1826 let mut v = MarkdownEditorView::new();
1827 let mut lines: Vec<String> = (0..1000).map(|i| format!("paragraph {i}")).collect();
1828 update_view(&mut v, &lines, (0, 0), rect(40), 1, None);
1829 assert!(
1830 v.parse_state.is_placeholder(),
1831 "first parse installs placeholder"
1832 );
1833 assert_eq!(v.take_pending_full_parse(), Some(1));
1834
1835 // Edit before the background parse resolves the placeholder.
1836 lines[0].push_str("```");
1837 update_view(&mut v, &lines, (0, lines[0].len()), rect(40), 2, None);
1838 assert!(
1839 !v.last_parse_was_incremental,
1840 "must not splice the placeholder"
1841 );
1842 assert!(
1843 v.parse_state.is_placeholder(),
1844 "still placeholder pending parse"
1845 );
1846 assert_eq!(
1847 v.take_pending_full_parse(),
1848 Some(2),
1849 "re-armed for new generation"
1850 );
1851
1852 // Background parse for the latest generation completes.
1853 v.install_full_parse(2, ParsedBuffer::parse(&lines));
1854 assert!(
1855 !v.parse_state.is_placeholder(),
1856 "placeholder cleared on install"
1857 );
1858 assert_eq!(v.parse_state.buf().kinds, ParsedBuffer::parse(&lines).kinds);
1859 }
1860
1861 #[test]
1862 #[should_panic(expected = "splice on placeholder parse")]
1863 fn splice_real_on_placeholder_is_rejected() {
1864 // The type makes the wrong-splice hazard unrepresentable on the
1865 // Gate 1 path; this guards the `ParseState::splice_real` contract
1866 // directly so a future caller can't route a splice into a
1867 // placeholder without tripping the assert.
1868 let mut state = ParseState::Placeholder {
1869 buf: ParsedBuffer::placeholder(&["x".to_string()]),
1870 generation: 1,
1871 spawned: false,
1872 };
1873 state.splice_real(0..1, ParsedBuffer::parse(&["y".to_string()]));
1874 }
1875
1876 #[test]
1877 fn fence_toggle_triggers_full_rebuild_fallback() {
1878 let mut v = MarkdownEditorView::new();
1879 // Use 700 lines so that an unclosed fence at row 350 widens to
1880 // end-of-buffer (~351 rows), exceeding the absolute cap (256).
1881 // Below the perf #9 LARGE_BUFFER_THRESHOLD (1000), so the
1882 // fallback runs synchronously and `parsed_buffer.kinds`
1883 // matches a fresh full parse immediately.
1884 let mut lines: Vec<String> = (0..700).map(|i| format!("paragraph {i}")).collect();
1885 update_view(&mut v, &lines, (350, 0), rect(40), 1, None);
1886
1887 // Open a fence mid-buffer — structurally invasive, line count changes.
1888 lines.insert(350, "```".to_string());
1889 update_view(&mut v, &lines, (350, 3), rect(40), 2, None);
1890
1891 let fresh = ParsedBuffer::parse(&lines);
1892 assert_eq!(
1893 v.parse_state.buf().kinds,
1894 fresh.kinds,
1895 "spliced kinds must equal fresh full parse"
1896 );
1897 // The unclosed fence at row 350 widens to end-of-buffer (~351 lines,
1898 // > 256 cap_abs), so the cap trips and the fallback fires.
1899 assert!(
1900 !v.last_parse_was_incremental,
1901 "fence toggle (unclosed fence, 700-line buffer) should fall back to full rebuild"
1902 );
1903 // Buffer < LARGE_BUFFER_THRESHOLD → sync fallback, no
1904 // pending-async signal.
1905 assert!(
1906 v.take_pending_full_parse().is_none(),
1907 "small-buffer fallback must NOT defer to async"
1908 );
1909 }
1910
1911 #[test]
1912 fn fence_toggle_on_large_buffer_defers_to_async_fallback() {
1913 // Regression for perf #9: above LARGE_BUFFER_THRESHOLD, the
1914 // fallback installs a placeholder ParsedBuffer + signals
1915 // pending instead of blocking the typing thread on
1916 // ParsedBuffer::parse. The owning component spawns the real
1917 // parse on tokio and calls install_full_parse when done.
1918 let mut v = MarkdownEditorView::new();
1919 let mut lines: Vec<String> = (0..1500).map(|i| format!("paragraph {i}")).collect();
1920 update_view(&mut v, &lines, (750, 0), rect(40), 1, None);
1921
1922 // Force a fallback path on a large buffer.
1923 lines.insert(750, "```".to_string());
1924 update_view(&mut v, &lines, (750, 3), rect(40), 2, None);
1925
1926 assert!(
1927 !v.last_parse_was_incremental,
1928 "fence toggle on 1500-line buffer should fall back"
1929 );
1930 let pending = v.take_pending_full_parse();
1931 assert!(
1932 pending.is_some(),
1933 "large-buffer fallback must signal pending async parse"
1934 );
1935 // Placeholder kinds: every row is Plain — no fence detection yet.
1936 assert!(
1937 v.parse_state
1938 .buf()
1939 .kinds
1940 .iter()
1941 .all(|k| matches!(k, super::super::parse_incremental::LineConstructKind::Plain)),
1942 "placeholder must classify every row as Plain"
1943 );
1944 assert_eq!(
1945 v.parse_state.buf().lines.len(),
1946 lines.len(),
1947 "placeholder row count must match input"
1948 );
1949
1950 // Caller (TextEditorComponent in production) spawns the real
1951 // parse and installs the result. Simulate that here.
1952 let real = ParsedBuffer::parse(&lines);
1953 let generation = pending.unwrap();
1954 v.install_full_parse(generation, real);
1955 let fresh = ParsedBuffer::parse(&lines);
1956 assert_eq!(
1957 v.parse_state.buf().kinds,
1958 fresh.kinds,
1959 "post-install kinds must match fresh full parse"
1960 );
1961 }
1962
1963 fn full_rebuild_equals_view_state(v: &MarkdownEditorView, lines: &[String]) {
1964 let fresh = ParsedBuffer::parse(lines);
1965 assert_eq!(v.parse_state.buf().kinds, fresh.kinds, "kinds diverge");
1966 assert_eq!(
1967 v.parse_state.buf().lines.len(),
1968 fresh.lines.len(),
1969 "row count diverge"
1970 );
1971 for (i, (got, exp)) in v
1972 .parse_state
1973 .buf()
1974 .lines
1975 .iter()
1976 .zip(fresh.lines.iter())
1977 .enumerate()
1978 {
1979 got.debug_assert_eq_to(exp, i);
1980 }
1981 }
1982
1983 #[test]
1984 fn incremental_falls_back_when_fence_marker_modified() {
1985 // Regression: editing a row that is currently a FenceMarker can
1986 // change the fence's extent across the rest of the buffer.
1987 // Incremental parsing's window-bounded widening cannot capture
1988 // this, so we must fall back to a full parse.
1989 let mut v = MarkdownEditorView::new();
1990 let mut lines = vec!["```".to_string(), "".to_string(), "```".to_string()];
1991 // Fill out the buffer with blank lines so the cap doesn't trip first.
1992 for _ in 0..31 {
1993 lines.push(String::new());
1994 }
1995 update_view(&mut v, &lines, (2, 0), rect(40), 1, None);
1996
1997 // Edit the closing fence marker — append a char so it's no longer a closer.
1998 let mut new_lines = lines.clone();
1999 new_lines[2].push('0');
2000 update_view(&mut v, &new_lines, (2, 4), rect(40), 2, None);
2001
2002 assert!(
2003 !v.last_parse_was_incremental,
2004 "fence-marker edit must trigger full-rebuild fallback"
2005 );
2006 // And the resulting state must equal a fresh parse (which the
2007 // fallback path does anyway, but assert defensively).
2008 full_rebuild_equals_view_state(&v, &new_lines);
2009 }
2010
2011 #[test]
2012 fn incremental_paste_large_block_falls_back() {
2013 let mut v = MarkdownEditorView::new();
2014 let mut lines: Vec<String> = (0..50).map(|i| format!("line {i}")).collect();
2015 update_view(&mut v, &lines, (25, 0), rect(40), 1, None);
2016
2017 // Insert 300 lines at row 25.
2018 let payload: Vec<String> = (0..300).map(|i| format!("pasted {i}")).collect();
2019 for (offset, p) in payload.into_iter().enumerate() {
2020 lines.insert(25 + offset, p);
2021 }
2022 update_view(&mut v, &lines, (25, 0), rect(40), 2, None);
2023 assert!(
2024 !v.last_parse_was_incremental,
2025 "300-line paste must fall back"
2026 );
2027 full_rebuild_equals_view_state(&v, &lines);
2028 }
2029
2030 #[test]
2031 fn incremental_enter_at_line_end() {
2032 let mut v = MarkdownEditorView::new();
2033 let lines = vec!["alpha".to_string(), "beta".to_string()];
2034 update_view(&mut v, &lines, (0, 5), rect(40), 1, None);
2035
2036 // Press Enter at end of "alpha".
2037 let new_lines = vec!["alpha".to_string(), "".to_string(), "beta".to_string()];
2038 update_view(&mut v, &new_lines, (1, 0), rect(40), 2, None);
2039 full_rebuild_equals_view_state(&v, &new_lines);
2040 }
2041
2042 #[test]
2043 fn incremental_backspace_merging_lines() {
2044 let mut v = MarkdownEditorView::new();
2045 let lines = vec!["alpha".to_string(), "beta".to_string()];
2046 update_view(&mut v, &lines, (1, 0), rect(40), 1, None);
2047
2048 // Backspace at start of "beta" merges into "alphabeta".
2049 let new_lines = vec!["alphabeta".to_string()];
2050 update_view(&mut v, &new_lines, (0, 5), rect(40), 2, None);
2051 full_rebuild_equals_view_state(&v, &new_lines);
2052 }
2053
2054 #[test]
2055 fn incremental_inside_fence_widens_both_markers() {
2056 let mut v = MarkdownEditorView::new();
2057 let lines = vec![
2058 "intro".to_string(),
2059 "".to_string(),
2060 "```rust".to_string(),
2061 "let x = 1;".to_string(),
2062 "let y = 2;".to_string(),
2063 "```".to_string(),
2064 "".to_string(),
2065 "outro".to_string(),
2066 ];
2067 update_view(&mut v, &lines, (3, 0), rect(40), 1, None);
2068
2069 // Edit inside the fence (same-length, no line-count change).
2070 let mut new_lines = lines.clone();
2071 new_lines[3] = "let x = 999;".to_string();
2072 update_view(&mut v, &new_lines, (3, 8), rect(40), 2, None);
2073 full_rebuild_equals_view_state(&v, &new_lines);
2074 }
2075
2076 #[test]
2077 fn incremental_list_continuation_widens_to_outer_marker() {
2078 let mut v = MarkdownEditorView::new();
2079 let lines = vec![
2080 "- top".to_string(),
2081 " body of top".to_string(),
2082 " - nested".to_string(),
2083 " body of nested".to_string(),
2084 " body two".to_string(),
2085 "".to_string(),
2086 "outro".to_string(),
2087 ];
2088 update_view(&mut v, &lines, (4, 0), rect(40), 1, None);
2089
2090 // Edit the nested continuation line.
2091 let mut new_lines = lines.clone();
2092 new_lines[4] = " body two changed".to_string();
2093 update_view(&mut v, &new_lines, (4, 10), rect(40), 2, None);
2094 full_rebuild_equals_view_state(&v, &new_lines);
2095 }
2096
2097 #[test]
2098 fn incremental_setext_underline_edit() {
2099 let mut v = MarkdownEditorView::new();
2100 let lines = vec![
2101 "heading text".to_string(),
2102 "====".to_string(),
2103 "".to_string(),
2104 "body".to_string(),
2105 ];
2106 update_view(&mut v, &lines, (1, 0), rect(40), 1, None);
2107
2108 // Edit the underline (same line count).
2109 let mut new_lines = lines.clone();
2110 new_lines[1] = "======".to_string();
2111 update_view(&mut v, &new_lines, (1, 6), rect(40), 2, None);
2112 full_rebuild_equals_view_state(&v, &new_lines);
2113 }
2114
2115 #[test]
2116 fn incremental_blockquote_paragraph_edit() {
2117 let mut v = MarkdownEditorView::new();
2118 let lines = vec![
2119 "intro".to_string(),
2120 "".to_string(),
2121 "> quoted line one".to_string(),
2122 "> quoted line two".to_string(),
2123 "> quoted line three".to_string(),
2124 "".to_string(),
2125 "outro".to_string(),
2126 ];
2127 update_view(&mut v, &lines, (3, 0), rect(40), 1, None);
2128
2129 let mut new_lines = lines.clone();
2130 new_lines[3] = "> quoted line TWO".to_string();
2131 update_view(&mut v, &new_lines, (3, 17), rect(40), 2, None);
2132 full_rebuild_equals_view_state(&v, &new_lines);
2133 }
2134
2135 #[test]
2136 fn incremental_html_block_edit() {
2137 let mut v = MarkdownEditorView::new();
2138 let lines = vec![
2139 "before".to_string(),
2140 "".to_string(),
2141 "<div>".to_string(),
2142 "body".to_string(),
2143 "</div>".to_string(),
2144 "".to_string(),
2145 "after".to_string(),
2146 ];
2147 update_view(&mut v, &lines, (3, 0), rect(40), 1, None);
2148
2149 let mut new_lines = lines.clone();
2150 new_lines[3] = "body changed".to_string();
2151 update_view(&mut v, &new_lines, (3, 12), rect(40), 2, None);
2152 full_rebuild_equals_view_state(&v, &new_lines);
2153 }
2154
2155 #[test]
2156 fn g1_nested_list_three_indent_continuation() {
2157 // Deeply nested continuation: damaged range touches a 3-indent
2158 // continuation line. Widening must reach the outermost col-0
2159 // ListMarker — otherwise parse_range sees ` text` as
2160 // IndentedCode.
2161 let mut v = MarkdownEditorView::new();
2162 let lines = vec![
2163 "intro".to_string(),
2164 "".to_string(),
2165 "- level 0".to_string(),
2166 " - level 1".to_string(),
2167 " - level 2".to_string(),
2168 " continuation at 6 indent".to_string(),
2169 "".to_string(),
2170 "after".to_string(),
2171 ];
2172 update_view(&mut v, &lines, (5, 0), rect(40), 1, None);
2173
2174 let mut new_lines = lines.clone();
2175 new_lines[5] = " continuation at 6 indent EDITED".to_string();
2176 update_view(&mut v, &new_lines, (5, 30), rect(40), 2, None);
2177 full_rebuild_equals_view_state(&v, &new_lines);
2178 }
2179
2180 #[test]
2181 fn g3_hashtag_inside_fence_not_labeled_after_incremental_edit() {
2182 // `#tag` inside a fenced code block must NOT produce a Label element.
2183 // After an incremental edit fully inside the fence, the widened
2184 // slice includes both fence markers — the label-suppression scan
2185 // sees the fence and skips. This test verifies the round-trip.
2186 let mut v = MarkdownEditorView::new();
2187 let lines = vec![
2188 "intro".to_string(),
2189 "".to_string(),
2190 "```".to_string(),
2191 "let s = \"#tag\";".to_string(),
2192 "// another #tag".to_string(),
2193 "```".to_string(),
2194 "".to_string(),
2195 "outro".to_string(),
2196 ];
2197 update_view(&mut v, &lines, (4, 0), rect(40), 1, None);
2198
2199 use crate::components::text_editor::markdown::ElementKind;
2200
2201 // Pre-condition: no Label elements in the fence interior.
2202 for row in 3..5 {
2203 let has_label = v.parse_state.buf().lines[row]
2204 .elements
2205 .iter()
2206 .any(|e| matches!(e.kind, ElementKind::Label));
2207 assert!(
2208 !has_label,
2209 "row {row} should have no Label inside the fence"
2210 );
2211 }
2212
2213 // Edit one of the in-fence lines.
2214 let mut new_lines = lines.clone();
2215 new_lines[4] = "// edited #tag here".to_string();
2216 update_view(&mut v, &new_lines, (4, 19), rect(40), 2, None);
2217
2218 // Post-condition: still no Label elements in the fence interior.
2219 for row in 3..5 {
2220 let has_label = v.parse_state.buf().lines[row]
2221 .elements
2222 .iter()
2223 .any(|e| matches!(e.kind, ElementKind::Label));
2224 assert!(
2225 !has_label,
2226 "row {row} should still have no Label after incremental edit"
2227 );
2228 }
2229 full_rebuild_equals_view_state(&v, &new_lines);
2230 }
2231
2232 #[test]
2233 fn g8a_typing_into_empty_buffer() {
2234 let mut v = MarkdownEditorView::new();
2235 let empty = vec!["".to_string()];
2236 update_view(&mut v, &empty, (0, 0), rect(40), 1, None);
2237
2238 let one = vec!["h".to_string()];
2239 update_view(&mut v, &one, (0, 1), rect(40), 2, None);
2240 full_rebuild_equals_view_state(&v, &one);
2241
2242 let two = vec!["he".to_string()];
2243 update_view(&mut v, &two, (0, 2), rect(40), 3, None);
2244 full_rebuild_equals_view_state(&v, &two);
2245
2246 let many = vec!["hello world".to_string()];
2247 update_view(&mut v, &many, (0, 11), rect(40), 4, None);
2248 full_rebuild_equals_view_state(&v, &many);
2249 }
2250
2251 #[test]
2252 fn g8b_delete_last_char_one_line_buffer() {
2253 let mut v = MarkdownEditorView::new();
2254 let one = vec!["h".to_string()];
2255 update_view(&mut v, &one, (0, 1), rect(40), 1, None);
2256
2257 let empty = vec!["".to_string()];
2258 update_view(&mut v, &empty, (0, 0), rect(40), 2, None);
2259 full_rebuild_equals_view_state(&v, &empty);
2260 }
2261
2262 #[test]
2263 fn incremental_text_change_produces_same_layout_as_full_recompute() {
2264 let mut v = MarkdownEditorView::new();
2265 let lines: Vec<String> = (0..200)
2266 .map(|i| format!("paragraph {i} with some text that may wrap depending on width"))
2267 .collect();
2268 update_view(&mut v, &lines, (100, 0), rect(40), 1, None);
2269 let baseline_visual_lines = v.layout.visual_lines().to_vec();
2270
2271 // Edit a paragraph mid-buffer (no line count change).
2272 let mut edited = lines.clone();
2273 edited[100].push_str(" extra text");
2274 update_view(&mut v, &edited, (100, edited[100].len()), rect(40), 2, None);
2275
2276 // After incremental wrap, layout must equal a fresh compute of the edited buffer.
2277 let fresh_layout =
2278 WordWrapLayout::compute(&edited, 40, v.rendered_cache_for_testing(), &[]);
2279
2280 let actual = v.layout.visual_lines();
2281 let fresh = fresh_layout.visual_lines();
2282 assert_eq!(actual.len(), fresh.len(), "visual_lines count diverges");
2283 for (i, (a, f)) in actual.iter().zip(fresh.iter()).enumerate() {
2284 assert_eq!(a, f, "visual line {i} diverges");
2285 }
2286
2287 // Sanity: a row outside the edit should have unchanged visual lines.
2288 let row_50_before = baseline_visual_lines
2289 .iter()
2290 .filter(|vl| vl.logical_row == 50)
2291 .count();
2292 let row_50_after = v
2293 .layout
2294 .visual_lines()
2295 .iter()
2296 .filter(|vl| vl.logical_row == 50)
2297 .count();
2298 assert_eq!(
2299 row_50_before, row_50_after,
2300 "row 50 visual_lines count should be unchanged"
2301 );
2302
2303 assert!(v.last_parse_was_incremental, "expected incremental path");
2304 }
2305
2306 #[test]
2307 fn incremental_text_change_does_not_rebuild_all_of_rendered_cache() {
2308 // Verify that after an incremental text edit, rendered_cache rows
2309 // outside the widened range are NOT re-derived from scratch. We
2310 // can't directly observe the rebuild, but we CAN verify the cache
2311 // contents stay correct (matching a full rebuild's output).
2312 let mut v = MarkdownEditorView::new();
2313 let lines: Vec<String> = (0..200)
2314 .map(|i| format!("paragraph {i} with some text"))
2315 .collect();
2316 update_view(&mut v, &lines, (100, 0), rect(40), 1, None);
2317
2318 // Snapshot rendered_cache before the edit.
2319 let before: Vec<Vec<bool>> = v
2320 .rendered_cache
2321 .iter()
2322 .enumerate()
2323 .filter(|(i, _)| *i < 50 || *i > 150)
2324 .map(|(_, v)| v.clone())
2325 .collect();
2326
2327 // Edit a paragraph in the middle.
2328 let mut edited = lines.clone();
2329 edited[100].push('x');
2330 update_view(&mut v, &edited, (100, edited[100].len()), rect(40), 2, None);
2331
2332 // Rows far outside the damaged range must be byte-identical.
2333 let after: Vec<Vec<bool>> = v
2334 .rendered_cache
2335 .iter()
2336 .enumerate()
2337 .filter(|(i, _)| *i < 50 || *i > 150)
2338 .map(|(_, v)| v.clone())
2339 .collect();
2340 assert_eq!(
2341 before, after,
2342 "rendered_cache rows outside damaged range must be unchanged"
2343 );
2344
2345 // The incremental path must have been taken.
2346 assert!(v.last_parse_was_incremental);
2347 }
2348
2349 // §3.4 — heuristic widener fires on an in-list content edit.
2350 //
2351 // Needs a buffer big enough that strict widener (which on a
2352 // loose list with no interior reset boundaries expands to
2353 // `[0, lines.len()]`) cap-trips, so the edit falls to
2354 // widen_to_safe over the loose-list blanks. With
2355 // MAX_INCREMENTAL_LINES=256 we use ~500 items.
2356
2357 fn make_loose_list(n_items: usize) -> Vec<String> {
2358 let mut out = Vec::with_capacity(n_items * 2);
2359 for i in 0..n_items {
2360 out.push(format!("- item {i}"));
2361 if i + 1 < n_items {
2362 out.push(String::new());
2363 }
2364 }
2365 out
2366 }
2367
2368 #[test]
2369 fn try_incremental_parse_uses_heuristic_on_in_list_edit() {
2370 let mut v = MarkdownEditorView::new();
2371 let lines = make_loose_list(300);
2372 let mid_row = 200;
2373 update_view(&mut v, &lines, (mid_row, 0), rect(20), 1, None);
2374
2375 let mut edited = lines.clone();
2376 edited[mid_row].push('x');
2377 update_view(
2378 &mut v,
2379 &edited,
2380 (mid_row, edited[mid_row].len()),
2381 rect(20),
2382 2,
2383 None,
2384 );
2385
2386 assert!(
2387 v.last_parse_was_incremental,
2388 "edit inside large loose list must take incremental path \
2389 (lazy-guard relaxation + widen_to_safe over the loose-list blanks)"
2390 );
2391 assert_eq!(
2392 v.last_splice_path,
2393 Some(SplicePath::Heuristic),
2394 "expected Heuristic path on large loose list edit, got {:?}",
2395 v.last_splice_path
2396 );
2397 }
2398
2399 // §3.5 — lazy-guard relaxation must NOT skip when the edit is a
2400 // list-marker flip. The marker-flip guard above the lazy guard
2401 // should bail first, and even if it didn't, the lazy guard's
2402 // kind_qualifies check should also bail since ListMarker is the
2403 // OLD kind but the new line is a different marker (still a list
2404 // marker, so the `looks_like_list_marker` flip check passes —
2405 // both old and new look like list markers; the lazy guard would
2406 // relax). However the kinds-comparison test ensures the edit
2407 // becomes a divergent classification only via the verify path.
2408 //
2409 // Actually re-reading: marker-style flip "- a" → "* a" does NOT
2410 // change `looks_like_list_marker` (both return true). The lazy
2411 // guard relaxation lets it through. The widener attempts splice.
2412 // If the slice's per-row kinds match the parent's, no divergence;
2413 // splice succeeds. If marker-style switches the classification,
2414 // verify catches it.
2415 //
2416 // The §3.5 spec scenario "- a" → "* a" produces ListMarker in
2417 // both. Slice parses "* a" alone as a list with `*` marker;
2418 // kinds[0] = ListMarker. Parent had ListMarker too. No
2419 // divergence. Splice succeeds via the heuristic widener.
2420 //
2421 // This test instead asserts the negative: a more-aggressive
2422 // structural change (e.g. removing the marker entirely, turning
2423 // a list row into a Plain row) must bail via the existing
2424 // looks_like_list_marker flip guard (KindGuard bail).
2425 #[test]
2426 fn try_incremental_parse_lazy_guard_still_bails_on_marker_removal() {
2427 let mut v = MarkdownEditorView::new();
2428 let lines: Vec<String> = vec!["- a".into(), "".into(), "- b".into()];
2429 update_view(&mut v, &lines, (0, 3), rect(20), 1, None);
2430
2431 let mut edited = lines.clone();
2432 edited[0] = "a".into(); // remove marker — `- a` → `a`
2433 update_view(&mut v, &edited, (0, 1), rect(20), 2, None);
2434
2435 // The looks_like_list_marker flip guard above the lazy guard
2436 // must bail this case (KindGuard). The lazy-guard relaxation
2437 // never sees it.
2438 assert!(
2439 !v.last_parse_was_incremental,
2440 "list-marker removal must NOT take incremental path \
2441 — looks_like_list_marker flip guard bails first"
2442 );
2443 }
2444
2445 #[test]
2446 fn apply_code_box_sets_bg_and_pads_to_width() {
2447 use ratatui::text::Span;
2448 let theme = crate::settings::themes::Theme::gruvbox_dark();
2449 let spans = vec![Span::raw("ab")]; // 2 cols
2450 let out = super::apply_code_box(spans, 5, &theme);
2451 let total: usize = out.iter().map(|s| s.content.chars().count()).sum();
2452 assert_eq!(total, 5); // padded to box width
2453 let bg = theme.code_bg.to_ratatui();
2454 assert!(out.iter().all(|s| s.style.bg == Some(bg)));
2455 }
2456
2457 #[test]
2458 fn click_on_barred_blockquote_maps_past_gutter() {
2459 // Blockquote on row 0 is NOT the cursor row (cursor parked on row 1),
2460 // so row 0 renders "│ hello". vrow 0 is that row's single visual line.
2461 let lines = vec!["> hello".to_string(), "tail".to_string()];
2462 let view = make_view_for_lines(&lines, (1, 0), 80);
2463 // Click screen col 2 ('h' after the 2-col "│ " gutter) → logical col 2.
2464 let (row, col) = view.click_to_logical_for_testing(0, 2);
2465 assert_eq!((row, col), (0, 2));
2466 }
2467}