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