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