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