Skip to main content

kimun_notes/components/text_editor/
view.rs

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