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