Skip to main content

kimun_notes/components/text_editor/
view.rs

1use super::markdown::{MarkdownSpanner, ParsedBuffer, opener_shape};
2use crate::ropetext::{Column, Layout, Metrics, RowHints, Viewport, motion};
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;
10
11use super::rope_buffer::RopeBuffer;
12use std::ops::Range;
13
14/// A styled range of logical columns on one row (see CONTEXT.md **Overlay**).
15///
16/// Every highlight the editor paints over a rendered line has this shape. They
17/// arrive in logical coordinates so producers never reason about rendered
18/// columns — markdown conceals sigils, so the two differ — and the mapping
19/// happens once, here.
20#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21pub struct Overlay {
22    pub row: usize,
23    /// Logical char column where the overlay starts.
24    pub start: usize,
25    /// Logical char column just past its end.
26    pub end: usize,
27    pub kind: OverlayKind,
28}
29
30impl Overlay {
31    pub fn new(row: usize, start: usize, end: usize, kind: OverlayKind) -> Self {
32        Self {
33            row,
34            start,
35            end,
36            kind,
37        }
38    }
39}
40
41/// What an [`Overlay`] means, and — by declaration order — how it stacks.
42///
43/// Later kinds paint over earlier ones. That order used to be implicit in
44/// statement order across `view.rs`'s render loop and `mod.rs`'s cell post-pass,
45/// which meant reasoning it out by hand for each new highlight.
46#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
47pub enum OverlayKind {
48    /// A task checkbox: `- [ ]` / `- [x]`.
49    TaskBox,
50    /// The text of a completed task, struck through.
51    TaskDone,
52    /// A vault-search **needle** carried in from the query that opened the note.
53    Needle,
54    /// A **find pattern** match.
55    Match,
56    /// The editor's selection.
57    Selection,
58    /// The **current match** — where the next find-bar action lands.
59    CurrentMatch,
60    /// Text the **replace preview** is showing in place of a match.
61    Preview,
62    /// The previewed **current match**.
63    PreviewCurrent,
64}
65
66impl OverlayKind {
67    /// How this kind restyles the spans it covers.
68    ///
69    /// The one place presentation for overlays lives: producers carry a kind,
70    /// never a `Style`, so a find bar cannot hold an opinion about colour that
71    /// has to be kept in sync with anything.
72    fn restyle(self, theme: &Theme, style: ratatui::style::Style) -> ratatui::style::Style {
73        use ratatui::style::Modifier;
74        match self {
75            OverlayKind::TaskBox => style.fg(theme.accent.to_ratatui()),
76            OverlayKind::TaskDone => style.add_modifier(Modifier::DIM | Modifier::CROSSED_OUT),
77            OverlayKind::Needle | OverlayKind::Match => style
78                .fg(theme.color_search_match.to_ratatui())
79                .add_modifier(Modifier::BOLD),
80            OverlayKind::Selection | OverlayKind::CurrentMatch => {
81                style.bg(theme.selection_bg.to_ratatui())
82            }
83            OverlayKind::Preview => style.bg(theme.color_replace_preview.to_ratatui()),
84            // A foreground override, not a modifier: BOLD is a no-op on text
85            // that is already bold, which once left the current match
86            // indistinguishable from the rest.
87            OverlayKind::PreviewCurrent => style
88                .bg(theme.color_replace_preview.to_ratatui())
89                .fg(cursor_fg(theme))
90                .add_modifier(Modifier::BOLD),
91        }
92    }
93}
94
95/// The `cursor` role, substituting a chromatic colour when the theme defers to
96/// the terminal — `Reset` foreground on `Reset` body text marks nothing.
97fn cursor_fg(theme: &Theme) -> ratatui::style::Color {
98    match theme.cursor {
99        crate::settings::themes::ThemeColor::Reset => theme.fg_bright.to_ratatui(),
100        _ => theme.cursor.to_ratatui(),
101    }
102}
103
104/// Terminal cursor shape the editor requests while focused.
105#[derive(Debug, Clone, Copy, PartialEq, Eq)]
106pub enum CursorShape {
107    Bar,
108    Block,
109}
110
111/// Describes how `view.update`'s Gate 1 modified the parse caches this
112/// frame. Read by Gate 2 to decide what subset of `rendered_cache` and
113/// `WordWrapLayout` needs to be rebuilt.
114#[derive(Debug, Clone)]
115enum TextChangeKind {
116    /// No text change this frame (cursor-only update). Gate 2 may keep
117    /// its caches and only refresh the cursor-row entry.
118    None,
119    /// Gate 1 took the incremental splice path; only rows in this
120    /// range had their ParsedLine entries replaced. Gate 2 should
121    /// rebuild rendered_cache only for these rows + the cursor rows.
122    Incremental(std::ops::Range<usize>),
123    /// Full rebuild (initial parse, line-count change, cap trip,
124    /// structural-marker change, post-slice verification miss). Gate 2
125    /// must rebuild rendered_cache for every row.
126    Full,
127}
128
129enum RenderedCacheRebuild {
130    Full,
131    Rows(Vec<usize>),
132    None,
133}
134
135#[derive(Clone)]
136pub struct MarkdownEditorView {
137    pub layout: Layout,
138    /// The visible rows. Its height is the last `update`'s, so overlay
139    /// derivation can bound itself to what is on screen.
140    viewport: Viewport,
141    /// Set by a wheel scroll that moved the view: `update` stops dragging the
142    /// viewport to the cursor, so the reader can look elsewhere without the
143    /// cursor moving. Cleared by [`Self::follow_cursor`], or by `update` seeing
144    /// a different `last_drawn`.
145    detached: bool,
146    /// (revision, cursor, width) of the last `update`. Any change — an edit, a
147    /// cursor jump from outside the keyboard path, a rewrap — re-attaches the
148    /// viewport, since the detached top no longer means what it did.
149    last_drawn: (u64, (usize, usize), u16),
150    /// The text the caches below were built from.
151    ///
152    /// Held rather than borrowed because `render` runs without the frame's
153    /// snapshot in scope. Keeping it costs nothing: a `Text` clone shares its
154    /// structure, so this is the same text rather than a copy of it — which is why
155    /// the twenty lines that used to copy changed rows into a `Vec<String>` are
156    /// now one assignment.
157    pub text_snapshot: crate::ropetext::Text,
158    pub cursor_snapshot: (usize, usize),
159    /// Line ranges of every fenced code block in the buffer. Text-keyed
160    /// (rebuilt only when `text_revision` changes); `is_in_code_block`
161    /// does a cheap point lookup against this list per row so all fenced
162    /// blocks render `force_raw` regardless of where the cursor is.
163    fence_ranges: Vec<Range<usize>>,
164    /// Per-logical-row code-box width (display cols), or `None` when the row
165    /// is not in a code block. All rows of one block share the block's
166    /// widest-rendered-line width, capped at the editor width. Rebuilt in
167    /// `update()` whenever text or width changes.
168    code_box_width: Vec<Option<u16>>,
169    /// Per-logical-row left gutter width (display cols) for the blockquote
170    /// bar: `depth + 1` on blockquote rows that are NOT the cursor row, else
171    /// 0. Cursor-dependent (the cursor row reveals raw `> `), so rebuilt with
172    /// the same cursor-affected-row logic as `rendered_cache`.
173    gutter_insets: Vec<usize>,
174    /// Cursor's last on-screen position (col, row), or `None` when the
175    /// cursor was scrolled off-screen or the view was unfocused at the
176    /// time of the previous `render`. Used as the anchor for floating
177    /// overlays like the autocomplete popup, which is drawn after the
178    /// editor itself.
179    pub last_cursor_screen: Option<(u16, u16)>,
180    /// Cursor style last written to the terminal, or `None` when the
181    /// terminal is on the user's default shape. The terminal cursor style
182    /// is global state, so on focus loss we must emit an explicit reset —
183    /// otherwise the editor's bar/block shape leaks into every other text
184    /// input (search sidebar, dialogs).
185    applied_cursor_style: Option<CursorShape>,
186    /// Per-line parse cache built in `update()`. Eliminates redundant pulldown-cmark
187    /// invocations across `render()`, cursor placement, and click mapping.
188    /// Either a Real or Placeholder parse — see [`ParseState`].
189    parse_state: ParseState,
190    /// Last `text_revision` seen — gates the lines clone and parse-cache rebuild.
191    /// Cursor-only moves do not bump `text_revision`, so navigating with the
192    /// arrow keys reuses the parse cache instead of re-running pulldown-cmark
193    /// over the whole buffer.
194    last_seen_generation: u64,
195    /// `text_revision`/width/cursor at which the layout was last computed.
196    /// Used to skip `WordWrapLayout::compute()` when nothing affecting wrap has changed:
197    /// horizontal cursor movement within the same element (or plain text) is free.
198    last_layout_generation: u64,
199    last_layout_width: u16,
200    last_layout_cursor: (usize, usize),
201    /// Visual row of the cursor, cached after layout so `render()` doesn't call
202    /// `logical_to_visual` a second time.
203    cursor_vrow: usize,
204    /// Per-line rendered-position bitmask, cached between layout recomputes.
205    /// Only the two cursor rows (old and new) are rebuilt when just the cursor row changes;
206    /// all rows are rebuilt when content or width changes.
207    rendered_cache: Vec<Vec<bool>>,
208    /// Every **overlay** to paint this frame, from outside. The view derives
209    /// task and needle overlays itself (they come from the lines it already
210    /// holds, and only visible rows are worth scanning).
211    overlays: Vec<Overlay>,
212    /// Vault-search **needles** to emphasise, lower-cased.
213    needles: Vec<String>,
214    /// Set when the next update follows an edit that touched rows the cursor
215    /// does not identify — a **replace all**, not a keystroke. Consumed by the
216    /// next `update`, which then skips `compute_damage_range`'s cursor fast
217    /// path: that path assumes the cursor row is the only edited row, and a
218    /// bulk edit violates it silently, leaving distant rows with a stale parse.
219    bulk_edit_pending: bool,
220    /// Diagnostic: true when the most recent Gate 1 invocation used the
221    /// incremental splice path, false when it took the full-parse fallback.
222    /// Read by tests; not part of the production observable surface.
223    last_parse_was_incremental: bool,
224    /// Diagnostic: which widener tier (`Strict` / `Heuristic`)
225    /// produced the most recent successful incremental
226    /// splice. `None` when no incremental splice has happened yet
227    /// (first parse or full-rebuild fallbacks). Read by unit tests
228    /// asserting the chosen widener path.
229    last_splice_path: Option<SplicePath>,
230    /// Tracks how Gate 1 changed (or did not change) the parse caches.
231    /// Gate 2 reads this to decide the scope of rendered_cache rebuild.
232    last_text_change: TextChangeKind,
233    /// The cell a run of ↑/↓ is aiming at.
234    ///
235    /// Vim calls it `curswant`: without it, passing through a short drawn line
236    /// clamps the column and the next press continues from there, so a column is
237    /// lost permanently rather than borrowed. Cleared by any other cursor move —
238    /// the component says when, because only it sees the other keys.
239    visual_goal: Option<usize>,
240    /// Rows the last edits changed, as the **rope buffer** reported them.
241    ///
242    /// Consumed by the next `update`, which then has no reason to compare the
243    /// buffer against a copy of its previous self. `None` means nobody told us —
244    /// the **nvim** backend hands over lines rather than changes, and whole-buffer
245    /// replacements report nothing — and the diff is the fallback for exactly
246    /// those.
247    reported_damage: Option<std::ops::Range<usize>>,
248    /// Set when Gate 2 installed a cheap `Layout::unwrapped` stub instead of
249    /// blocking on `Layout::compute`, mirroring `ParseState::Placeholder`.
250    /// While this is `Some`, every content-changing edit re-stubs and
251    /// re-arms for the new generation rather than relaying just the edited
252    /// rows — the same discipline Gate 1 applies via `is_placeholder()`, so
253    /// a run of edits can never leave the untouched rows of a large buffer
254    /// permanently unwrapped because one of them happened to parse
255    /// incrementally. Cleared by `install_full_layout`.
256    layout_pending: Option<PendingLayout>,
257}
258
259/// A `Layout::unwrapped` stub awaiting a background `Layout::compute`, the
260/// layout-side twin of `ParseState::Placeholder`. `generation` is the
261/// `content_revision` the stub was installed for; `spawned` flips true once
262/// `take_pending_full_layout` has handed the job out, so it is claimed
263/// exactly once per stub.
264#[derive(Debug, Clone, Copy)]
265struct PendingLayout {
266    generation: u64,
267    spawned: bool,
268}
269
270/// Everything a background task needs to compute the real `Layout` for a
271/// stubbed generation, fully owned so it can move into `tokio::spawn`.
272/// `RowHints` borrows, so it is rebuilt from `rendered_cache`/`gutter_insets`
273/// *inside* the task rather than carried across the boundary itself.
274pub struct PendingLayoutJob {
275    pub generation: u64,
276    pub text: crate::ropetext::Text,
277    pub width: usize,
278    pub rendered_cache: Vec<Vec<bool>>,
279    pub gutter_insets: Vec<usize>,
280}
281
282/// True when `KIMUN_VIEW_VERIFY_INCREMENTAL=1` is set. Reads the
283/// env var once per process and caches. Gates the debug-only
284/// full-kinds assertion in Gate 1 that compares every incremental
285/// splice against a fresh whole-buffer parse. (The per-splice
286/// undamaged-row verify on the heuristic path runs in release
287/// unconditionally — see `try_incremental_parse`.)
288///
289/// `cfg`-gated to match its only caller: without this, the release and bench
290/// profiles compile the function with the assertion it exists for gated out,
291/// and warn it is dead.
292#[cfg(debug_assertions)]
293fn verify_incremental_enabled() -> bool {
294    use std::sync::OnceLock;
295    static VERIFY: OnceLock<bool> = OnceLock::new();
296    *VERIFY.get_or_init(|| {
297        std::env::var("KIMUN_VIEW_VERIFY_INCREMENTAL")
298            .map(|v| !v.is_empty() && v != "0")
299            .unwrap_or(false)
300    })
301}
302
303/// Which widener produced the splice for the most recent successful
304/// incremental parse. Test telemetry — read by `last_splice_path`
305/// in unit tests to assert the chosen path. Mirror of the widener's
306/// own `SuccessPath` but kept separate since callers shouldn't depend
307/// on widener internals.
308#[derive(Debug, Clone, Copy, PartialEq, Eq)]
309pub enum SplicePath {
310    /// Strict reset-boundary widener (`reset_boundaries`) succeeded.
311    Strict,
312    /// `widen_to_safe` heuristic succeeded after the strict
313    /// reset-boundary widener returned `FullRebuild`.
314    Heuristic,
315}
316
317/// The editor's per-buffer parse cache: either a fully-styled **Real
318/// parse** or an unstyled **Placeholder parse** awaiting a background
319/// full parse (see `CONTEXT.md`). Modelling the distinction as a type
320/// makes the wrong-splice hazard unrepresentable: splicing is only
321/// reachable through [`ParseState::splice_real`], whose `Placeholder`
322/// arm is unreachable because Gate 1 declines the incremental path for
323/// placeholders. The placeholder's all-`Plain` line kinds would
324/// otherwise defeat the structural guards and accept a wrong splice.
325#[derive(Clone)]
326enum ParseState {
327    Real(ParsedBuffer),
328    /// `generation` is the `content_revision` the placeholder was
329    /// installed for — handed to the owning component so it knows which
330    /// buffer to parse on the background task. `spawned` flips true once
331    /// that task has been requested, so `take_pending_full_parse` hands
332    /// the generation out exactly once.
333    Placeholder {
334        buf: ParsedBuffer,
335        generation: u64,
336        spawned: bool,
337    },
338}
339
340impl ParseState {
341    /// State-agnostic buffer access. Render and Gate 2 read the buffer
342    /// in both states — the placeholder has valid row counts, so the
343    /// downstream path stays in-bounds; only the markdown styling is
344    /// missing while it is a placeholder.
345    fn buf(&self) -> &ParsedBuffer {
346        match self {
347            Self::Real(b) | Self::Placeholder { buf: b, .. } => b,
348        }
349    }
350
351    fn is_placeholder(&self) -> bool {
352        matches!(self, Self::Placeholder { .. })
353    }
354
355    /// Splice an incremental slice into a Real parse. Called only after
356    /// the `is_placeholder()` gate in Gate 1 has declined the
357    /// incremental path for placeholders, so the `Placeholder` arm is
358    /// unreachable.
359    fn splice_real(&mut self, range: std::ops::Range<usize>, slice: ParsedBuffer) {
360        match self {
361            Self::Real(b) => b.splice(range, slice),
362            Self::Placeholder { .. } => {
363                debug_assert!(false, "splice on placeholder parse");
364            }
365        }
366    }
367}
368
369impl MarkdownEditorView {
370    pub fn new() -> Self {
371        Self {
372            layout: Layout::compute(&crate::ropetext::Text::new(), 0, Metrics::default(), &[]),
373            viewport: Viewport::default(),
374            detached: false,
375            last_drawn: (0, (0, 0), 0),
376            text_snapshot: crate::ropetext::Text::new(),
377            cursor_snapshot: (0, 0),
378            fence_ranges: Vec::new(),
379            code_box_width: Vec::new(),
380            gutter_insets: Vec::new(),
381            last_cursor_screen: None,
382            applied_cursor_style: None,
383            // Empty buffer, spliceable — preserves the previous
384            // `placeholder_active: false` initial state.
385            parse_state: ParseState::Real(ParsedBuffer::placeholder(&crate::ropetext::Text::new())),
386            last_seen_generation: u64::MAX, // force rebuild on first update
387            last_layout_generation: u64::MAX,
388            last_layout_width: 0,
389            last_layout_cursor: (usize::MAX, usize::MAX),
390            cursor_vrow: 0,
391            rendered_cache: Vec::new(),
392            overlays: Vec::new(),
393            needles: Vec::new(),
394            bulk_edit_pending: false,
395            last_parse_was_incremental: false,
396            last_splice_path: None,
397            last_text_change: TextChangeKind::Full, // first update is a full rebuild
398            visual_goal: None,
399            reported_damage: None,
400            layout_pending: None,
401        }
402    }
403
404    /// Threshold above which a fallback to full parse runs
405    /// asynchronously instead of blocking the typing thread. On
406    /// buffers below this size the full parse is fast enough
407    /// (<2ms for a paragraph-only 1000-line buffer per bench) that
408    /// blocking is preferable to the one-frame-of-unstyled-text
409    /// the async path imposes.
410    const LARGE_BUFFER_THRESHOLD: usize = 1000;
411
412    /// Returns `Some(generation)` if Gate 1 just installed a
413    /// placeholder `ParsedBuffer` and the owning component should
414    /// spawn a background full parse for this generation. Consumes
415    /// the flag so the owner does not spawn twice; the owner is
416    /// responsible for calling `install_full_parse` when the task
417    /// completes.
418    /// Whether the most recent Gate 1 invocation took the incremental
419    /// splice path. Read-only diagnostic for the incremental-parse
420    /// property tests (`tui/tests/incremental_property.rs`); not part
421    /// of the production render path.
422    pub fn last_parse_was_incremental(&self) -> bool {
423        self.last_parse_was_incremental
424    }
425
426    pub fn take_pending_full_parse(&mut self) -> Option<u64> {
427        if let ParseState::Placeholder {
428            generation,
429            spawned,
430            ..
431        } = &mut self.parse_state
432            && !*spawned
433        {
434            *spawned = true;
435            return Some(*generation);
436        }
437        None
438    }
439
440    /// Install the result of a background full parse. No-op when
441    /// the editor has advanced past `generation` — that result is
442    /// stale and a fresh spawn is already in flight. Invalidates the
443    /// layout + rendered_cache so the next `update()` rebuilds Gate
444    /// 2 against the fresh `ParsedBuffer`.
445    pub fn install_full_parse(&mut self, generation: u64, buf: ParsedBuffer) {
446        if generation != self.last_seen_generation {
447            return; // stale
448        }
449        self.parse_state = ParseState::Real(buf);
450        self.fence_ranges =
451            super::parse_incremental::fence_ranges_from_kinds(&self.parse_state.buf().kinds);
452        // Force Gate 2 full rebuild on the next update: the
453        // placeholder's all-Plain kinds produced different fence
454        // ranges and rendered masks than the real parse will.
455        self.last_text_change = TextChangeKind::Full;
456        self.last_layout_generation = u64::MAX;
457    }
458
459    /// Returns `Some(job)` if Gate 2 just installed a `Layout::unwrapped`
460    /// stub and the owning component should spawn a background
461    /// `Layout::compute` for it. Consumes the flag so the owner does not
462    /// spawn twice; the owner is responsible for calling
463    /// `install_full_layout` when the task completes.
464    pub fn take_pending_full_layout(&mut self) -> Option<PendingLayoutJob> {
465        let pending = self.layout_pending.as_mut()?;
466        if pending.spawned {
467            return None;
468        }
469        pending.spawned = true;
470        Some(PendingLayoutJob {
471            generation: pending.generation,
472            text: self.text_snapshot.clone(),
473            width: self.last_layout_width as usize,
474            rendered_cache: self.rendered_cache.clone(),
475            gutter_insets: self.gutter_insets.clone(),
476        })
477    }
478
479    /// Install the result of a background `Layout::compute`. No-op when the
480    /// editor has advanced past `generation` (a fresh spawn is already in
481    /// flight — mirrors `install_full_parse`'s staleness gate) or when the
482    /// pane was resized since the job was captured (`layout.width()` no
483    /// longer matches `last_layout_width` — a generation match alone
484    /// cannot catch this, since a resize with no content change never
485    /// bumps `content_revision`).
486    pub fn install_full_layout(&mut self, generation: u64, layout: Layout) {
487        if generation != self.last_seen_generation
488            || layout.width() != self.last_layout_width as usize
489        {
490            return; // stale
491        }
492        self.layout = layout;
493        self.layout_pending = None;
494        // The wrapped layout numbers visual rows differently from the stub, so
495        // a detached top would now point somewhere else in the note.
496        self.detached = false;
497        self.last_layout_generation = generation;
498    }
499
500    /// Full (non-incremental) layout rebuild: synchronous on a small
501    /// buffer, deferred to a background task on a large one — the
502    /// layout-side twin of Gate 1's placeholder-parse fallback. Called
503    /// from every Gate 2 branch that would otherwise call
504    /// `Layout::compute` unconditionally.
505    fn full_layout_rebuild(
506        &mut self,
507        text: &crate::ropetext::Text,
508        width: u16,
509        row_count: usize,
510        generation: u64,
511    ) {
512        if row_count >= Self::LARGE_BUFFER_THRESHOLD {
513            self.layout = Layout::unwrapped(text);
514            self.layout_pending = Some(PendingLayout {
515                generation,
516                spawned: false,
517            });
518        } else {
519            let hints = row_hints(&self.rendered_cache, &self.gutter_insets);
520            self.layout = Layout::compute(text, width as usize, Metrics::default(), &hints);
521            self.layout_pending = None;
522        }
523    }
524
525    /// Hand the view this frame's **overlays**, in logical coordinates. Must be
526    /// called *after* `update`, which clears them.
527    ///
528    /// The view appends the two kinds it derives itself — task decorations and
529    /// **needle** emphasis. Those come from the lines it already holds, and it
530    /// is the only thing that knows which rows are visible, so scanning them
531    /// anywhere else would mean shipping the viewport outward.
532    pub fn set_overlays(&mut self, overlays: Vec<Overlay>) {
533        self.overlays = overlays;
534        self.derive_content_overlays();
535    }
536
537    /// Derive task and needle overlays for the visible rows only.
538    ///
539    /// This replaces a post-pass over drawn terminal cells, which reconstructed
540    /// row text with a byte→column map purely because it ran after render. That
541    /// put it in a different coordinate space from everything else, and cost a
542    /// defect: a find pattern targeting concealed markdown counted and stepped
543    /// to matches it could never paint.
544    fn derive_content_overlays(&mut self) {
545        let scroll = self.viewport.top();
546        let height = self.viewport.height();
547        let rows: Vec<usize> = self
548            .layout
549            .visual_lines()
550            .iter()
551            .skip(scroll)
552            .take(height)
553            .map(|vl| vl.logical_row)
554            .collect();
555        let mut seen = usize::MAX;
556        for row in rows {
557            if row == seen {
558                continue; // wrapped continuation of a row already handled
559            }
560            seen = row;
561            let Some(line) = self.text_snapshot.line(row) else {
562                continue;
563            };
564            // Task checkboxes: optional indent, then `- [ ] ` / `- [x] `.
565            let indent = line.len() - line.trim_start().len();
566            let after = &line[indent..];
567            let done = after.starts_with("- [x] ") || after.starts_with("- [X] ");
568            if done || after.starts_with("- [ ] ") {
569                let box_start = line[..indent].chars().count() + 2;
570                self.overlays.push(Overlay::new(
571                    row,
572                    box_start,
573                    box_start + 3,
574                    OverlayKind::TaskBox,
575                ));
576                if done {
577                    self.overlays.push(Overlay::new(
578                        row,
579                        box_start + 3,
580                        line.chars().count(),
581                        OverlayKind::TaskDone,
582                    ));
583                }
584            }
585            // Needle emphasis, over the logical line rather than drawn cells.
586            let line = line.as_ref();
587            for (s, e) in crate::components::preview_highlight::match_ranges(line, &self.needles) {
588                let start = line[..s].chars().count();
589                let end = start + line[s..e].chars().count();
590                self.overlays
591                    .push(Overlay::new(row, start, end, OverlayKind::Needle));
592            }
593        }
594    }
595
596    /// Vault-search **needles** to emphasise. Sticky across frames, unlike
597    /// overlays: they come from the query that opened the note.
598    pub fn set_needles(&mut self, needles: Vec<String>) {
599        self.needles = needles;
600    }
601
602    /// Declare that the edit just performed was a *bulk* one — it changed rows
603    /// the cursor does not point at.
604    ///
605    /// `compute_damage_range`'s fast path trusts the cursor row to be the only
606    /// edited row and will otherwise under-report the damage, leaving distant
607    /// rows rendered from a stale parse. Every edit that rewrites more than the
608    /// cursor's neighbourhood must call this.
609    /// Forget where a run of ↑/↓ was aiming. Any other cursor movement ends it.
610    pub fn clear_visual_goal(&mut self) {
611        self.visual_goal = None;
612    }
613
614    /// Move the cursor one *drawn* line, which is what an arrow key means in a
615    /// wrapped editor: one press moves one line the reader can see, not past the
616    /// whole remainder of a soft-wrapped paragraph.
617    ///
618    /// Lives on the view because only the view has the layout — the buffer holds
619    /// the text and the cursor, and neither alone can answer "which line is this
620    /// drawn on". That split is exactly why the incumbent could not do this.
621    ///
622    /// Returns `false` when the layout does not describe the buffer's current
623    /// text — an edit lands before the frame that re-lays it out — and the caller
624    /// falls back to a logical move rather than reading a stale layout.
625    pub fn move_cursor_visually(&mut self, buf: &mut RopeBuffer, down: bool, extend: bool) -> bool {
626        let text = buf.text().clone();
627        // Exactly, not approximately: comparing row counts missed every edit that
628        // stayed inside one row, and the stale byte ranges then sliced out of
629        // bounds — `end byte index 4 is out of bounds for string of length 1`.
630        if !self.layout.describes(&text) {
631            return false;
632        }
633        let Some(cursor) = text.position(buf.cursor().0, Column::new(buf.cursor().1)) else {
634            return false;
635        };
636        let hints = row_hints(&self.rendered_cache, &self.gutter_insets);
637        let goal = self
638            .visual_goal
639            .unwrap_or_else(|| self.layout.cell_of(&text, &hints, cursor).column);
640        let landed = motion::visual_vertical(
641            &text,
642            &self.layout,
643            &hints,
644            cursor,
645            if down { 1 } else { -1 },
646            motion::VisualGoal::Cell(goal),
647        );
648        self.visual_goal = Some(goal);
649
650        if extend {
651            if buf.selection_range().is_none() {
652                buf.start_selection();
653            }
654        } else {
655            buf.cancel_selection();
656        }
657        buf.move_to(landed);
658        true
659    }
660
661    /// Record which rows an edit changed, for the next `update` to act on.
662    ///
663    /// `line_delta` is what this edit did to the row count. Several edits can
664    /// land between two frames, and a range recorded before one of them that
665    /// moved rows no longer means what it said — so the accumulated hull is
666    /// brought into the new numbering first. This matters more than it used to:
667    /// the layout now patches across a line-count change rather than rebuilding,
668    /// so an under-reported hull leaves rows wrapped as they used to be.
669    pub fn note_damage(&mut self, rows: std::ops::Range<usize>, line_delta: isize) {
670        self.reported_damage = Some(match self.reported_damage.take() {
671            Some(seen) => {
672                let seen = super::rope_buffer::shift_rows(seen, rows.start, line_delta);
673                seen.start.min(rows.start)..seen.end.max(rows.end)
674            }
675            None => rows,
676        });
677    }
678
679    pub fn note_bulk_edit(&mut self) {
680        self.bulk_edit_pending = true;
681    }
682
683    pub fn update(&mut self, snap: &super::snapshot::EditorSnapshot, rect: Rect) {
684        self.viewport.set_height(rect.height as usize);
685        // Snapshot owns the (cursor, lines, content_revision) atomicity
686        // — readers below can index `parsed_buffer.lines[cursor.0]`
687        // without `.get()` guards once Gate 1 has rebuilt the parse
688        // cache from these same `lines`.
689        let text = &snap.text;
690        let row_count = text.line_count();
691        let cursor = snap.cursor;
692        let generation = snap.content_revision.get();
693        // Overlays belong to the snapshot they were built from. Clearing here
694        // means a caller that stops previewing (or closes the find bar) cannot
695        // leave stale ones painted over real text — it simply stops setting
696        // them.
697        self.overlays.clear();
698        if rect.height == 0 {
699            return;
700        }
701
702        // Gate 1: content changed — rebuild parse cache and snapshots.
703        //
704        // The layout gate below wants the same report. The parse cache cannot
705        // splice across a line-count change — `ParsedBuffer::splice` requires the
706        // replacement to have as many rows as it replaces — but the layout can,
707        // so the two must not share a verdict. This carries the report past Gate
708        // 1's `take` rather than re-deriving it.
709        let mut reported_for_layout: Option<std::ops::Range<usize>> = None;
710        if generation != self.last_seen_generation {
711            let reported = self.reported_damage.take();
712            reported_for_layout = reported.clone();
713            let incremental = if self.parse_state.is_placeholder() {
714                None
715            } else {
716                self.try_incremental_parse(text, cursor, reported)
717            };
718            // Consumed here, not inside `try_incremental_parse`: the flag must
719            // clear even on the placeholder path above, or a bulk edit
720            // followed by a keystroke would still be suppressing the hint.
721            self.bulk_edit_pending = false;
722            self.last_text_change = match incremental {
723                Some((range, slice, path)) => {
724                    self.parse_state.splice_real(range.clone(), slice);
725                    self.last_parse_was_incremental = true;
726                    self.last_splice_path = Some(path);
727                    TextChangeKind::Incremental(range)
728                }
729                None => {
730                    if row_count >= Self::LARGE_BUFFER_THRESHOLD {
731                        // Async fallback: install a structurally-
732                        // correct but unstyled placeholder so this
733                        // frame can paint immediately; defer the
734                        // real pulldown parse to a background tokio
735                        // task spawned by the owning component (see
736                        // `take_pending_full_parse` / `install_full_parse`).
737                        // The placeholder has the same row count as
738                        // `lines`, so the downstream Gate 2 / render
739                        // path stays in-bounds; only the markdown
740                        // styling is missing for one frame.
741                        self.parse_state = ParseState::Placeholder {
742                            buf: ParsedBuffer::placeholder(&snap.text),
743                            generation,
744                            spawned: false,
745                        };
746                    } else {
747                        self.parse_state = ParseState::Real(ParsedBuffer::parse(&snap.text));
748                    }
749                    self.last_parse_was_incremental = false;
750                    self.last_splice_path = None;
751                    TextChangeKind::Full
752                }
753            };
754            #[cfg(debug_assertions)]
755            if self.last_parse_was_incremental && verify_incremental_enabled() {
756                let fresh = ParsedBuffer::parse(&snap.text);
757                assert_eq!(
758                    self.parse_state.buf().kinds,
759                    fresh.kinds,
760                    "incremental kinds diverge from full parse at generation={generation}"
761                );
762                assert_eq!(
763                    self.parse_state.buf().lazy_depth,
764                    fresh.lazy_depth,
765                    "incremental lazy_depth diverges from full parse at generation={generation}"
766                );
767                assert_eq!(
768                    self.parse_state.buf().reset_boundaries,
769                    fresh.reset_boundaries,
770                    "incremental reset_boundaries diverge from full parse at generation={generation}"
771                );
772                assert_eq!(
773                    self.parse_state.buf().lines.len(),
774                    fresh.lines.len(),
775                    "incremental lines.len() diverges from full parse at generation={generation}"
776                );
777                for (i, (got, exp)) in self
778                    .parse_state
779                    .buf()
780                    .lines
781                    .iter()
782                    .zip(fresh.lines.iter())
783                    .enumerate()
784                {
785                    got.debug_assert_eq_to(exp, i);
786                }
787            }
788            // Skip on a successful incremental splice: `try_incremental_parse`
789            // already refuses to splice any edit that could flip a row into
790            // or out of a fence/indented-code/HTML-block role (the
791            // structural-marker and opener-shape guards bail to a full
792            // rebuild first) — so `fence_ranges` is provably identical to
793            // before, and re-scanning the whole `kinds` array to confirm
794            // that would defeat the point of having taken the fast path.
795            if !self.last_parse_was_incremental {
796                self.fence_ranges = super::parse_incremental::fence_ranges_from_kinds(
797                    &self.parse_state.buf().kinds,
798                );
799            }
800            // Incremental update of `lines_snapshot` mirrors the parse
801            // path: on the splice path only the rows in `range` can
802            // have changed (try_incremental_parse already bails when
803            // line count differs); on the full-parse fallback we lose
804            // damage info, so re-clone everything.
805            //
806            // `String::clone_from` reuses the destination's existing
807            // allocation when capacity permits, so the typical
808            // single-char insert costs one String reallocation
809            // (often zero — capacity stays put) instead of N.
810            match &self.last_text_change {
811                TextChangeKind::Incremental(_) | TextChangeKind::Full | TextChangeKind::None => {
812                    self.text_snapshot = snap.text.clone();
813                }
814            }
815            self.last_seen_generation = generation;
816        } else {
817            self.last_text_change = TextChangeKind::None;
818        }
819
820        self.cursor_snapshot = cursor;
821
822        // Gate 2: layout rebuild.
823        // Skip when content, width, and the *effective element expansion* are all unchanged.
824        // Horizontal cursor movement within the same element (or plain text with no elements)
825        // does not change any wrap boundary — no recompute needed.
826        let new_expanded = self
827            .parse_state
828            .buf()
829            .lines
830            .get(cursor.0)
831            .and_then(|p| p.elem_at(cursor.1));
832        let old_expanded = self
833            .parse_state
834            .buf()
835            .lines
836            .get(self.last_layout_cursor.0)
837            .and_then(|p| p.elem_at(self.last_layout_cursor.1));
838        let need_layout = generation != self.last_layout_generation
839            || rect.width != self.last_layout_width
840            || cursor.0 != self.last_layout_cursor.0
841            || new_expanded != old_expanded;
842
843        if need_layout {
844            let width_changed = rect.width != self.last_layout_width;
845            let cursor_changed = cursor.0 != self.last_layout_cursor.0;
846            let expanded_changed = new_expanded != old_expanded;
847            // Rows whose rendered mask depends on cursor state and may
848            // have flipped this frame: the old and new cursor rows
849            // when the cursor moved between rows, OR the cursor row
850            // when an inline element (link/bold/etc.) was just expanded
851            // or collapsed by a within-row cursor move. Both shapes
852            // change `visible_positions_with`'s `expanded` argument,
853            // so both rendered_cache AND wrap need to re-derive that
854            // row's mask + visual-line splits.
855            let cursor_affected_rows: Vec<usize> = if cursor_changed {
856                let mut rows = vec![self.last_layout_cursor.0, cursor.0];
857                rows.sort();
858                rows.dedup();
859                rows
860            } else if expanded_changed {
861                vec![cursor.0]
862            } else {
863                vec![]
864            };
865            // Drop any row past the current buffer end — happens when a
866            // stale snapshot's cursor row exceeds `lines.len()`. Both
867            // rendered_cache and wrap splices require in-range rows.
868            let cursor_affected_rows: Vec<usize> = cursor_affected_rows
869                .into_iter()
870                .filter(|&r| r < row_count)
871                .collect();
872            // Determine the set of rows to rebuild in rendered_cache.
873            let rebuild_strategy = if self.rendered_cache.len() != row_count {
874                // Line count differs → full rebuild required.
875                RenderedCacheRebuild::Full
876            } else {
877                match &self.last_text_change {
878                    TextChangeKind::Full => RenderedCacheRebuild::Full,
879                    TextChangeKind::Incremental(range) => {
880                        let mut rows: Vec<usize> = range.clone().collect();
881                        rows.extend(cursor_affected_rows.iter().copied());
882                        rows.sort();
883                        rows.dedup();
884                        RenderedCacheRebuild::Rows(rows)
885                    }
886                    TextChangeKind::None => {
887                        if cursor_affected_rows.is_empty() {
888                            RenderedCacheRebuild::None
889                        } else {
890                            RenderedCacheRebuild::Rows(cursor_affected_rows.clone())
891                        }
892                    }
893                }
894            };
895
896            // Width-only change: masks are width-independent; skip rendered_cache rebuild.
897            let _ = width_changed; // acknowledged: width doesn't affect rendered_cache
898            match rebuild_strategy {
899                RenderedCacheRebuild::Full => {
900                    self.rendered_cache = text
901                        .lines()
902                        .enumerate()
903                        .map(|(i, l)| {
904                            let force_raw = self.is_in_code_block(i);
905                            let cursor_col = if i == cursor.0 { Some(cursor.1) } else { None };
906                            MarkdownSpanner::visible_positions_with(
907                                &l,
908                                &self.parse_state.buf().lines[i],
909                                cursor_col,
910                                force_raw,
911                            )
912                        })
913                        .collect();
914                }
915                RenderedCacheRebuild::Rows(rows) => {
916                    for row in rows {
917                        if row >= row_count {
918                            continue; // defensive
919                        }
920                        let force_raw = self.is_in_code_block(row);
921                        let cursor_col = if row == cursor.0 {
922                            Some(cursor.1)
923                        } else {
924                            None
925                        };
926                        let new_entry = MarkdownSpanner::visible_positions_with(
927                            &text.line(row).unwrap_or_default(),
928                            &self.parse_state.buf().lines[row],
929                            cursor_col,
930                            force_raw,
931                        );
932                        if let Some(entry) = self.rendered_cache.get_mut(row) {
933                            *entry = new_entry;
934                        }
935                    }
936                }
937                RenderedCacheRebuild::None => {
938                    // Width-only change or no change: masks are width-independent; nothing to rebuild.
939                }
940            }
941
942            // Width-aware wrap path:
943            // - Width change or line-count change: full recompute (wrap
944            //   depends on width; visual_lines indexing depends on row count).
945            // - TextChangeKind::Full: full recompute.
946            // - TextChangeKind::Incremental(range): splice the edited
947            //   rows plus any cursor-affected rows whose mask flipped.
948            // - TextChangeKind::None: splice only the cursor-affected
949            //   rows. Wrap depends on the rendered mask
950            //   (`wrap_one_row` reads `rendered_row`), and the mask is
951            //   cursor-position-sensitive whenever the cursor crosses
952            //   an inline element boundary — same row or different
953            //   row.
954            // Full rebuild only on a genuine full-rebuild frame (or a
955            // length mismatch, defensively) — otherwise the structural
956            // guards that gate the incremental splice already guarantee no
957            // row's blockquote depth changed, so only the rows the cursor
958            // just left or entered need a fresh inset.
959            if matches!(self.last_text_change, TextChangeKind::Full)
960                || self.gutter_insets.len() != row_count
961            {
962                self.rebuild_gutter_insets(row_count, cursor.0);
963            } else if !cursor_affected_rows.is_empty() {
964                self.patch_gutter_insets(&cursor_affected_rows, cursor.0);
965            }
966            let line_count_changed = self.layout.row_count() != row_count;
967            // A stub is still outstanding from an earlier full rebuild and
968            // content changed again this frame: re-stub for the new
969            // generation rather than let an incremental relayout patch a
970            // couple of rows while the rest of the buffer stays permanently
971            // unwrapped waiting on a background result that will land too
972            // late (stale-generation) to matter. Mirrors Gate 1's
973            // `is_placeholder()` gate — a cursor-only frame (`None`) leaves
974            // an in-flight job alone rather than aborting it for nothing.
975            let stub_still_pending = self.layout_pending.is_some()
976                && !matches!(self.last_text_change, TextChangeKind::None);
977            // A line-count change used to force a full re-wrap. It does not have
978            // to: `relayout_rows` takes a delta and renumbers the rows after the
979            // edit, and the delta is knowable without plumbing — the layout knows
980            // how many rows it was built for, and the text knows how many it has
981            // now. What was missing is the damaged range, and Gate 1 was handed
982            // one. Only the *parser* is obliged to give up here.
983            let relayout_across_line_change = line_count_changed
984                .then(|| reported_for_layout.clone())
985                .flatten()
986                .filter(|rows| rows.end <= row_count);
987            if width_changed || stub_still_pending {
988                self.full_layout_rebuild(&snap.text, rect.width, row_count, generation);
989            } else if let Some(rows) = relayout_across_line_change {
990                let delta = row_count as isize - self.layout.row_count() as isize;
991                let hints = row_hints(&self.rendered_cache, &self.gutter_insets);
992                self.layout.relayout_rows(&snap.text, &hints, rows, delta);
993            } else if line_count_changed {
994                self.full_layout_rebuild(&snap.text, rect.width, row_count, generation);
995            } else {
996                match &self.last_text_change {
997                    TextChangeKind::Full => {
998                        self.full_layout_rebuild(&snap.text, rect.width, row_count, generation);
999                    }
1000                    TextChangeKind::Incremental(range) => {
1001                        let start = range
1002                            .start
1003                            .min(cursor_affected_rows.first().copied().unwrap_or(range.start));
1004                        let end = range.end.max(
1005                            cursor_affected_rows
1006                                .last()
1007                                .copied()
1008                                .map(|r| r + 1)
1009                                .unwrap_or(range.end),
1010                        );
1011                        let hints = row_hints(&self.rendered_cache, &self.gutter_insets);
1012                        // Line count is unchanged on this path — the caller above
1013                        // takes the full-recompute branch when it is not — so the
1014                        // relayout shifts nothing.
1015                        self.layout.relayout_rows(&snap.text, &hints, start..end, 0);
1016                    }
1017                    TextChangeKind::None => {
1018                        if let (Some(&first), Some(&last)) =
1019                            (cursor_affected_rows.first(), cursor_affected_rows.last())
1020                        {
1021                            let hints = row_hints(&self.rendered_cache, &self.gutter_insets);
1022                            self.layout
1023                                .relayout_rows(&snap.text, &hints, first..last + 1, 0);
1024                        }
1025                    }
1026                }
1027            }
1028            // Code-box widths depend only on text content and the wrap width,
1029            // not the cursor — so skip the (grapheme-walking) rebuild on
1030            // cursor-only moves, where neither changed. A width change caps
1031            // every block afresh regardless of content, so it always forces
1032            // the full rebuild; a successful incremental splice narrows to
1033            // just the block(s) overlapping the edited range — the same
1034            // structural guards mean any OTHER block's boundaries (and thus
1035            // whether it needs re-measuring at all) can't have moved.
1036            if width_changed
1037                || matches!(self.last_text_change, TextChangeKind::Full)
1038                || self.code_box_width.len() != row_count
1039            {
1040                self.rebuild_code_box_width(text, rect.width);
1041            } else if let TextChangeKind::Incremental(range) = &self.last_text_change {
1042                self.patch_code_box_width(text, rect.width, range.clone());
1043            }
1044            self.last_layout_generation = generation;
1045            self.last_layout_width = rect.width;
1046            self.last_layout_cursor = cursor;
1047        }
1048
1049        // Cache cursor_vrow for render() — avoids a second lookup there.
1050        //
1051        // Falling back to the last known row, not to zero. The text being asked
1052        // is not always the buffer's: `render` builds a snapshot from the
1053        // **replace preview**'s rows paired with the real cursor, and a
1054        // replacement shorter than what it replaces leaves that cursor past the
1055        // end of the previewed row. Answering "row 0" then scrolls the note to
1056        // the top while the user is still typing in the replace field. The
1057        // preview cannot place a cursor that does not belong to it, so the honest
1058        // answer is to leave the viewport where it was.
1059        self.cursor_vrow = snap
1060            .text
1061            .position(cursor.0, Column::new(cursor.1))
1062            .map(|at| self.layout.visual_row_of(at))
1063            .unwrap_or(self.cursor_vrow);
1064        let drawn = (generation, cursor, rect.width);
1065        if drawn != self.last_drawn {
1066            self.last_drawn = drawn;
1067            self.detached = false;
1068        }
1069        if !self.detached {
1070            self.viewport.follow_row(self.cursor_vrow);
1071        }
1072        // Never leave blank rows below the last line while content sits above
1073        // the viewport. Without this, a resize that grows the pane keeps the
1074        // top it had when small, showing only the tail of the note until the
1075        // cursor moves up. A followed cursor stays visible: it lies below the
1076        // old top and before the last row.
1077        self.viewport.clamp(&self.layout);
1078    }
1079
1080    /// Mouse-wheel scroll: move the viewport by `delta` visual rows without
1081    /// moving the cursor, never past the first or last line. Only a notch that
1082    /// moves the view detaches it — a saturated one must not stop the view
1083    /// following the cursor. Returns whether the view moved.
1084    pub fn scroll_by(&mut self, delta: isize) -> bool {
1085        let moved = self.viewport.scroll_by(&self.layout, delta);
1086        self.detached |= moved;
1087        moved
1088    }
1089
1090    /// Re-attach the viewport to the cursor: the next `update` scrolls the
1091    /// least amount that brings it on screen. For any input that acts on the
1092    /// cursor — including one that leaves it where it was, like a find-next
1093    /// wrapping onto the same match.
1094    pub fn follow_cursor(&mut self) {
1095        self.detached = false;
1096    }
1097
1098    /// Attempt an incremental Gate-1 parse.
1099    ///
1100    /// Returns `Some((range, slice, path))` when the damage can be
1101    /// cheaply isolated and widened to safe boundaries; `None` when
1102    /// the caller should fall back to a fresh full-buffer
1103    /// `ParsedBuffer::parse`. The `path` indicates which widener
1104    /// tier produced the splice (see [`SplicePath`]).
1105    fn try_incremental_parse(
1106        &self,
1107        text: &crate::ropetext::Text,
1108        cursor: (usize, usize),
1109        reported: Option<std::ops::Range<usize>>,
1110    ) -> Option<(std::ops::Range<usize>, ParsedBuffer, SplicePath)> {
1111        use super::parse_incremental::{
1112            LineConstructKind, WidenResult, compute_damage_range, expand_to_reset_boundary,
1113            widen_to_safe,
1114        };
1115        use super::widener_metrics::{BailReason, METRICS, SuccessPath};
1116
1117        if self.parse_state.buf().lines.is_empty() {
1118            return None; // First parse — no snapshot to diff against. Uncategorised.
1119        }
1120        // Line count changes (insertions/deletions) require a full rebuild:
1121        // the widened range covers the same number of lines in the new buffer
1122        // as in the old kinds array, so a splice cannot reconcile the length
1123        // mismatch.
1124        if text.line_count() != self.parse_state.buf().lines.len() {
1125            return METRICS.bail(BailReason::LineCountChange);
1126        }
1127        // The row-by-row guards below read the previous content, so it has to
1128        // describe the same buffer shape. It does not on the first update, and an
1129        // empty text still has one row — so "no previous state" cannot be inferred
1130        // from the parse cache being empty.
1131        if self.text_snapshot.line_count() != text.line_count() {
1132            return METRICS.bail(BailReason::LineCountChange);
1133        }
1134        // A bulk edit invalidates the cursor hint: pass `usize::MAX` so the
1135        // fast path's `cursor_row < old.len()` test fails and the LCP/LCS slow
1136        // path computes the real span. The flag is cleared by `update` whether
1137        // or not the incremental attempt gets this far.
1138        let hint = if self.bulk_edit_pending {
1139            usize::MAX
1140        } else {
1141            cursor.0
1142        };
1143        // Told, not found: the engine knows which rows its own edit touched, so the
1144        // only reason to compare the buffer with a copy of its previous self is
1145        // that nobody told us — a whole-buffer replacement, or the **nvim**
1146        // backend, which reports lines rather than changes.
1147        // Set when the lazy-depth relaxation admits a kind that is only safe
1148        // because of the downstream verify after the splice. `ListMarker` never
1149        // sets it: it was proven safe without one, and making it pay for the
1150        // verify regressed blank-free buffers ~7x, because a sparse boundary set
1151        // sends the verify to the end of the note.
1152        let mut needs_downstream_verify = false;
1153        let damaged = match reported {
1154            Some(rows) if rows.end <= text.line_count() => rows,
1155            _ => {
1156                // Only this path needs the previous content as rows, and only
1157                // because it has to compare. Materialising it here keeps that cost
1158                // where the comparison is, rather than on every edit.
1159                let previous: Vec<String> =
1160                    self.text_snapshot.lines().map(|l| l.to_string()).collect();
1161                let current: Vec<String> = text.lines().map(|l| l.to_string()).collect();
1162                let Some(damaged) = compute_damage_range(&previous, &current, hint) else {
1163                    return METRICS.bail(BailReason::NoDamage);
1164                };
1165                damaged
1166            }
1167        };
1168        if damaged.is_empty() {
1169            return METRICS.bail(BailReason::NoDamage);
1170        }
1171
1172        // Structural-marker change guard: any edit that converts a fence
1173        // marker line into a non-marker (or vice versa) can shift the
1174        // fence's extent beyond the widening window. Same for setext
1175        // underlines. Conservative fallback to full parse for correctness.
1176        for row in damaged.clone() {
1177            let old_kind = self.parse_state.buf().kinds[row];
1178            let previous_row = self.text_snapshot.line(row).unwrap_or_default();
1179            let old_line = previous_row.as_ref();
1180            let current_row = text.line(row).unwrap_or_default();
1181            let new_line = current_row.as_ref();
1182
1183            // Old kind was a structural marker whose role an in-place edit
1184            // can change (fence opener↔closer↔content, setext underline
1185            // re-heading the line above) or which lazy-extends past the
1186            // widening window (indented code / HTML block per CommonMark
1187            // §4.4 / §4.6). These read pulldown's real classification, so
1188            // any edit on such a row punts to a full parse.
1189            if matches!(
1190                old_kind,
1191                LineConstructKind::FenceMarker
1192                    | LineConstructKind::SetextUnderline
1193                    | LineConstructKind::IndentedCode
1194                    | LineConstructKind::HtmlBlock
1195            ) {
1196                return METRICS.bail(BailReason::KindGuard);
1197            }
1198            // Context-free block-opener shape flip: the edit gained or lost
1199            // a fence / setext / indented-code / HTML / list / blockquote
1200            // opener shape. Any such flip can open or close a (possibly
1201            // lazy-continuable) construct that reshapes the document beyond
1202            // the widening window — e.g. `"x"` → `"* x"` next to a
1203            // blank-separated list leaks a loose-list merge. Comparing the
1204            // whole `OpenerShape` catches a flip in any field at once.
1205            if opener_shape(new_line) != opener_shape(old_line) {
1206                return METRICS.bail(BailReason::KindGuard);
1207            }
1208
1209            // V2 lazy-construct neighbourhood guard: edit at row R
1210            // can re-shape a lazy construct open at R-1, R, or R+1.
1211            // R-1: blockquote paragraph lazy-continuation across a
1212            // former blank (CommonMark §5.1). R: edit inside the construct. R+1:
1213            // paragraph eating a would-be IndentedCode start.
1214            //
1215            // Conditional relaxation (intra-construct reset boundaries):
1216            // when the damaged row's old kind is ListMarker AND
1217            // lazy_depth[row] == 1 (a top-level list, not nested inside
1218            // an outer lazy construct), the bail is skipped. List-marker
1219            // content edits are safe by construction: per-row
1220            // ListMarker/ListContinuation classification stays identical
1221            // across slice-vs-parent, and rows past widened.end are
1222            // unaffected by the slice's list-vs-non-list determination.
1223            // The widener's heuristic tier (widen_to_safe over the
1224            // loose-list blanks; or, on small buffers, the strict tier
1225            // widening to the whole buffer) takes the splice. The
1226            // post-slice verify backs this. The opener-shape /
1227            // blank-transition flips run as
1228            // separate guards above and below this check, so the relax
1229            // only ever fires on pure content edits.
1230            //
1231            // Initial relaxation also accepted ListContinuation +
1232            // Blockquote + Plain and arbitrary lazy_depth; both unlocks
1233            // reverted after the 100k proptest soak exposed downstream-
1234            // row-classification flips past widened.end that the
1235            // post-slice verify (which only covers rows INSIDE widened)
1236            // doesn't catch. The deeper fix is a post-widening sanity
1237            // check on `widened.end + 1`; until then those unlocks stay off.
1238            let lazy = &self.parse_state.buf().lazy_depth;
1239            if lazy.is_empty() {
1240                // (see `needs_downstream_verify` below)
1241                // Defensive: invariant violation (lazy_depth.len() should
1242                // match lines.len()). Count as KindGuard to keep the
1243                // attempted-vs-success accounting consistent.
1244                return METRICS.bail(BailReason::KindGuard);
1245            }
1246            let lo = row.saturating_sub(1);
1247            let hi = (row + 1).min(lazy.len() - 1);
1248            if lazy[lo..=hi].iter().any(|&d| d > 0) {
1249                // Conditional relaxation — TIGHT VERSION.
1250                // Qualifying conditions (narrowed across two soak
1251                // rounds):
1252                //   - old_kind == ListMarker (NOT ListContinuation)
1253                //   - lazy_depth[row] == 1 (top-level list only)
1254                //
1255                // ListContinuation rows are excluded after the 100k
1256                // soak surfaced a case where an edit on a
1257                // ListContinuation row (specifically a `>     ` row
1258                // inside a list, lazy_depth=1) caused the row AT
1259                // `damaged.end` (a blank, lazy_depth=0 in pre-edit)
1260                // to flip to ListContinuation in post-edit fresh
1261                // parse. The strict reset boundary at that row was
1262                // valid pre-edit but became invalid post-edit, and
1263                // the splice chose a widened range based on
1264                // pre-edit boundaries that didn't capture the new
1265                // row past `widened.end`.
1266                //
1267                // ListMarker rows are immune: a content edit on
1268                // "- a" → "- aX" cannot change row+1's classification
1269                // because the row+1 was either (a) Plain → became
1270                // ListContinuation via the post-pass regardless of
1271                // the edit, or (b) Blank/something-else that's outside
1272                // the list and unaffected by item-content changes.
1273                //
1274                // The depth==1 clause blocks edits on lists nested
1275                // inside another lazy construct (a list inside a
1276                // blockquote) where the OUTER construct can shift.
1277                //
1278                // Blockquote / Plain / ListContinuation unlocks remain
1279                // deferred. A post-widening sanity check on
1280                // `widened.end + 1` was the proposed fix — re-parse one
1281                // extra row and compare it against the parent to catch a
1282                // downstream flip. It was built and measured against the
1283                // soak in `widener_soak` below, and it does NOT work:
1284                // with the unlock applied, the soak still diverges on
1285                // `lazy_depth` within a few thousand cases, whether the
1286                // check compares `kinds` alone or `kinds` and
1287                // `lazy_depth` together. The flip lands further out than
1288                // one row, so a fixed one-row lookahead cannot see it.
1289                // Whatever closes this has to bound how far a
1290                // reclassification can travel, or verify to the next
1291                // reset boundary rather than to the next row.
1292                //
1293                // Unlocked kinds and their price. `ListMarker` is safe on its
1294                // own — a content edit on `- a` cannot reclassify row+1 — and a
1295                // 100k soak backs that, so it pays nothing extra. `Blockquote`
1296                // and `ListContinuation` are not safe on their own: they
1297                // reclassify rows past `widened.end`, and they are admitted here
1298                // only because the downstream verify below covers exactly that
1299                // distance. The flag is what keeps that cost on the splices that
1300                // need it rather than on every splice.
1301                let relaxed_kind = matches!(
1302                    old_kind,
1303                    LineConstructKind::Blockquote(_) | LineConstructKind::ListContinuation
1304                );
1305                let kind_qualifies =
1306                    matches!(old_kind, LineConstructKind::ListMarker) || relaxed_kind;
1307                let depth_qualifies = row < lazy.len() && lazy[row] == 1;
1308                if kind_qualifies && depth_qualifies {
1309                    // Don't bail — let blank-transition guard run
1310                    // and reach the widener stage.
1311                    needs_downstream_verify = relaxed_kind;
1312                } else {
1313                    return METRICS.bail(BailReason::LazyDepth);
1314                }
1315            }
1316
1317            // V2 blank-transition guard: a row flipping between blank
1318            // and non-blank invalidates the pre-edit reset boundary
1319            // at that row in the post-edit world (paragraph lazy-
1320            // continuation, empty list-item shapes like `*` that
1321            // parse as ListMarker in slice but as paragraph
1322            // continuation in full). Use the pre-edit `kinds` for
1323            // the "blank" classification instead of `line.trim()` so
1324            // the predicate matches the parser's view exactly.
1325            let old_blank = matches!(old_kind, LineConstructKind::Blank);
1326            let new_blank = new_line.trim().is_empty();
1327            if old_blank != new_blank {
1328                let above_non_blank = row > 0
1329                    && !matches!(
1330                        self.parse_state.buf().kinds[row - 1],
1331                        LineConstructKind::Blank
1332                    );
1333                let below_non_blank = row + 1 < self.parse_state.buf().kinds.len()
1334                    && !matches!(
1335                        self.parse_state.buf().kinds[row + 1],
1336                        LineConstructKind::Blank
1337                    );
1338                if above_non_blank || below_non_blank {
1339                    return METRICS.bail(BailReason::BlankTransition);
1340                }
1341            }
1342        }
1343
1344        // Two-tier widener:
1345        //
1346        //   1. `expand_to_reset_boundary(reset_boundaries, ...)` —
1347        //      strict. Provably equivalent to a fresh parse; no
1348        //      post-slice verify needed.
1349        //   2. `widen_to_safe` — heuristic fallback. NOT provably
1350        //      equivalent; the post-slice verify (below, release-on)
1351        //      is the correctness mechanism and bails to a full
1352        //      rebuild on any divergence.
1353        //
1354        // After the relaxation fires the strict widener usually
1355        // cap-trips (lazy_depth > 0 around the edit means no nearby
1356        // blank-with-depth-0 reset boundary), but we still try strict
1357        // first — it costs only a binary search and succeeds in
1358        // degenerate cases (e.g. small buffers where strict widens
1359        // safely to the whole buffer). On failure we fall to
1360        // widen_to_safe.
1361        //
1362        // A former middle tier (`intra_construct_boundaries`, the V3
1363        // "IntraConstruct" path) was removed: it fired only on loose-
1364        // list edits and `widen_to_safe` covers every such case with
1365        // zero extra full rebuilds (measured), differing only in
1366        // reparse span (~11 vs ~2 rows — both far under the 256 cap).
1367        let mut splice_path = SplicePath::Strict;
1368        let widened = match expand_to_reset_boundary(
1369            &self.parse_state.buf().reset_boundaries,
1370            self.parse_state.buf().lines.len(),
1371            damaged.clone(),
1372        ) {
1373            WidenResult::Widened(r) => r,
1374            WidenResult::FullRebuild => {
1375                match widen_to_safe(&self.parse_state.buf().kinds, damaged.clone()) {
1376                    WidenResult::Widened(r) => {
1377                        splice_path = SplicePath::Heuristic;
1378                        r
1379                    }
1380                    WidenResult::FullRebuild => return METRICS.bail(BailReason::CapTrip),
1381                }
1382            }
1383        };
1384        let slice = ParsedBuffer::parse_range(text, widened.clone());
1385
1386        // Downstream verification, bounded by the next reset boundary.
1387        //
1388        // Only for the kinds admitted by the relaxation above. The in-window
1389        // verify below cannot see a row the splice does not replace, and a
1390        // one-row lookahead is not enough — measured, the flip travels further.
1391        // `reset_boundaries` is the bound that is not a guess: at such a row
1392        // pulldown's state is provably reset, so no reclassification crosses it.
1393        //
1394        // Evidence: with `Blockquote`/`ListContinuation` admitted and this block
1395        // removed, `widener_soak` diverges within a few thousand cases; with it,
1396        // 100 000 cases pass.
1397        if needs_downstream_verify {
1398            let next_boundary = self
1399                .parse_state
1400                .buf()
1401                .reset_boundaries
1402                .iter()
1403                .copied()
1404                .find(|&b| b > widened.end)
1405                .unwrap_or_else(|| text.line_count())
1406                .min(text.line_count());
1407            if next_boundary > widened.end {
1408                let probe = ParsedBuffer::parse_range(text, widened.start..next_boundary);
1409                let parent = self.parse_state.buf();
1410                for row in widened.end..next_boundary {
1411                    let idx = row - widened.start;
1412                    if probe.kinds[idx] != parent.kinds[row]
1413                        || probe.lazy_depth[idx] != parent.lazy_depth[row]
1414                    {
1415                        return METRICS.bail(BailReason::DownstreamFlip);
1416                    }
1417                }
1418            }
1419        }
1420
1421        // Post-slice undamaged-row verification.
1422        //
1423        // - Strict path: skipped. Provably equivalent to a fresh
1424        //   parse (see `reset_boundaries` docstring).
1425        // - Heuristic path: NOT provably equivalent, so this verify
1426        //   is the correctness mechanism and runs in release. It is
1427        //   cheap: `slice` was already parsed above
1428        //   (unconditionally), and the loop only compares
1429        //   kinds/elements.len()/content_vis over the `widened` rows —
1430        //   bounded by the widen cap (≤256), negligible against the
1431        //   parse_range that already ran. A divergence (e.g. a pulldown
1432        //   version bump changing tokenisation) bails to a full rebuild
1433        //   rather than shipping a corrupt splice. The 600k proptest
1434        //   cases (100k × 6 strategies, 0 verify_failed) stay in the
1435        //   regression harness; this guard is the release backstop.
1436        let verify_eligible_path = matches!(splice_path, SplicePath::Heuristic);
1437        if verify_eligible_path {
1438            for row in widened.clone() {
1439                if damaged.contains(&row) {
1440                    continue; // Damaged row: kind change is expected/irrelevant.
1441                }
1442                let idx = row - widened.start;
1443                if slice.kinds[idx] != self.parse_state.buf().kinds[row] {
1444                    return METRICS.bail(BailReason::VerifyFailed);
1445                }
1446                if slice.lines[idx].elements.len()
1447                    != self.parse_state.buf().lines[row].elements.len()
1448                {
1449                    return METRICS.bail(BailReason::VerifyFailed);
1450                }
1451                if slice.lines[idx].content_vis != self.parse_state.buf().lines[row].content_vis {
1452                    return METRICS.bail(BailReason::VerifyFailed);
1453                }
1454            }
1455        }
1456
1457        METRICS.ok(match splice_path {
1458            SplicePath::Strict => SuccessPath::ResetBoundary,
1459            SplicePath::Heuristic => SuccessPath::WidenToSafe,
1460        });
1461        Some((widened, slice, splice_path))
1462    }
1463
1464    pub fn render(
1465        &mut self,
1466        f: &mut Frame,
1467        rect: Rect,
1468        theme: &Theme,
1469        focused: bool,
1470        cursor_shape: Option<CursorShape>,
1471    ) {
1472        if rect.height == 0 {
1473            return;
1474        }
1475        let text = &self.text_snapshot;
1476        let cursor = self.cursor_snapshot;
1477        let scroll = self.viewport.top();
1478        let height = rect.height as usize;
1479        let vlines = self.layout.visual_lines();
1480
1481        let parsed_lines = &self.parse_state.buf().lines;
1482        let fence_ranges = &self.fence_ranges;
1483
1484        // The rows the visible lines draw from, materialised for this frame.
1485        // Bounded by the pane's height rather than the note's length, and needed
1486        // because the spans below borrow their row and outlive the closure that
1487        // builds them.
1488        let window: Vec<String> = vlines
1489            .iter()
1490            .skip(scroll)
1491            .take(height)
1492            .map(|vl| text.line(vl.logical_row).unwrap_or_default().into_owned())
1493            .collect();
1494
1495        let visible: Vec<Line> = vlines
1496            .iter()
1497            .skip(scroll)
1498            .take(height)
1499            .zip(window.iter())
1500            .map(|(vl, row_text)| {
1501                let cursor_col = if vl.logical_row == cursor.0 {
1502                    Some(cursor.1)
1503                } else {
1504                    None
1505                };
1506                let force_raw = fence_ranges.iter().any(|r| r.contains(&vl.logical_row));
1507                // Snapshot invariant: every `vl.logical_row` is < lines.len()
1508                // because `layout` and `lines_snapshot` were rebuilt from
1509                // the same `EditorSnapshot` in the last `update()`.
1510                let logical_line = row_text.as_str();
1511                let parsed = &parsed_lines[vl.logical_row];
1512                let content = &logical_line[vl.bytes.clone()];
1513                let spans = MarkdownSpanner::render_with(
1514                    content,
1515                    logical_line,
1516                    parsed,
1517                    vl.chars.start,
1518                    cursor_col,
1519                    vl.first,
1520                    force_raw,
1521                    rect.width,
1522                    theme,
1523                );
1524
1525                // Apply code-block background before selection so selection bg wins on selected text.
1526                let spans =
1527                    if let Some(bw) = self.code_box_width.get(vl.logical_row).copied().flatten() {
1528                        apply_code_box(spans, bw, theme)
1529                    } else {
1530                        spans
1531                    };
1532
1533                // Every highlight this row carries, painted in one pass.
1534                // `OverlayKind`'s declaration order is the stacking order, so
1535                // "preview wins over selection" is a property of the enum
1536                // rather than of where the code happens to sit.
1537                let spans = {
1538                    // Skip the hidden `> ` and add the bar back, rather than
1539                    // zeroing the offset and letting the mapper credit the
1540                    // sigils as the cells the bar occupies. The credit is exact
1541                    // only for clusters whose width is column-independent: a tab
1542                    // consumes it, measuring to a nearer stop from the inflated
1543                    // column, and every overlay at or after it lands short by
1544                    // the bar. `click_to_logical_u16` already skips rather than
1545                    // credits — this is the same basis, in the same direction.
1546                    let gutter_off = self.gutter_insets.get(vl.logical_row).copied().unwrap_or(0);
1547                    let effective_start_col = if gutter_off > 0 && vl.first {
1548                        parsed.blockquote_sigil_end().unwrap_or(vl.chars.start)
1549                    } else {
1550                        vl.chars.start
1551                    };
1552                    let to_rendered = |col: usize| {
1553                        MarkdownSpanner::rendered_col_with_reveal(
1554                            logical_line,
1555                            parsed,
1556                            effective_start_col,
1557                            col,
1558                            cursor_col,
1559                            vl.first,
1560                            force_raw,
1561                        ) + gutter_off
1562                    };
1563                    let mut row_overlays: Vec<&Overlay> = self
1564                        .overlays
1565                        .iter()
1566                        .filter(|o| o.row == vl.logical_row)
1567                        .collect();
1568                    row_overlays.sort_by_key(|o| o.kind);
1569
1570                    let mut spans = spans;
1571                    for o in row_overlays {
1572                        let start = to_rendered(o.start);
1573                        let mut end = to_rendered(o.end);
1574                        // A zero-width overlay would paint nothing — which is
1575                        // exactly the case where the user most needs to see
1576                        // where they are (an empty replacement previews a match
1577                        // as nothing at all). Give it one cell, like a caret.
1578                        if end == start && o.kind == OverlayKind::PreviewCurrent {
1579                            end = start + 1;
1580                        }
1581                        spans =
1582                            restyle_over_range(spans, start..end, &|st| o.kind.restyle(theme, st));
1583                    }
1584                    spans
1585                };
1586
1587                Line::from(spans)
1588            })
1589            .collect();
1590
1591        f.render_widget(
1592            Paragraph::new(Text::from(visible)).style(theme.base_style()),
1593            rect,
1594        );
1595
1596        // Draw terminal cursor when focused. The `EditorSnapshot` the
1597        // last `update()` consumed guarantees `cursor.0` is in-bounds
1598        // for `parsed_buffer.lines` and `layout.visual_lines()` —
1599        // both were rebuilt from the same snapshot. The single
1600        // remaining edge case is an empty buffer (no rows at all),
1601        // handled by the early `is_empty` short-circuit below; the
1602        // previous defensive `.get()` chain was
1603        // there to absorb stale Nvim snapshots where cursor outran
1604        // lines, which the snapshot invariant now rules out.
1605        self.last_cursor_screen = None;
1606        let mut desired_style: Option<CursorShape> = None;
1607        if focused
1608            && !self.parse_state.buf().lines.is_empty()
1609            && !self.layout.visual_lines().is_empty()
1610        {
1611            let cursor_vrow = self.cursor_vrow;
1612            if cursor_vrow >= scroll && cursor_vrow < scroll + height {
1613                let vl = &self.layout.visual_lines()[cursor_vrow];
1614                let parsed = &self.parse_state.buf().lines[cursor.0];
1615                // Snapshot invariant + outer `!is_empty()` guard: cursor.0
1616                // is in-bounds for `lines_snapshot` here.
1617                let row_text = text.line(cursor.0).unwrap_or_default();
1618                let logical_line = row_text.as_ref();
1619                let force_raw = self.is_in_code_block(cursor.0);
1620                let rendered_col = MarkdownSpanner::rendered_cursor_col_with(
1621                    logical_line,
1622                    parsed,
1623                    vl.chars.start,
1624                    cursor.1,
1625                    vl.first,
1626                    force_raw,
1627                );
1628                let cx = rect.x + rendered_col as u16;
1629                let cy = rect.y + (cursor_vrow - scroll) as u16;
1630                f.set_cursor_position(Position { x: cx, y: cy });
1631                self.last_cursor_screen = Some((cx, cy));
1632                desired_style = cursor_shape;
1633            }
1634        }
1635        if desired_style != self.applied_cursor_style {
1636            use ratatui::crossterm::cursor::SetCursorStyle;
1637            let style = match desired_style {
1638                Some(CursorShape::Block) => SetCursorStyle::SteadyBlock,
1639                Some(CursorShape::Bar) => SetCursorStyle::SteadyBar,
1640                None => SetCursorStyle::DefaultUserShape,
1641            };
1642            let _ = ratatui::crossterm::execute!(std::io::stdout(), style);
1643            self.applied_cursor_style = desired_style;
1644        }
1645    }
1646
1647    /// Test accessor: the kinds vector of the current parsed buffer.
1648    /// Used by the proptest harness to assert incremental = full parse.
1649    pub fn parsed_buffer_kinds(&self) -> &[super::parse_incremental::LineConstructKind] {
1650        &self.parse_state.buf().kinds
1651    }
1652
1653    /// Test accessor: the parsed lines of the current parsed buffer.
1654    pub fn parsed_buffer_lines(&self) -> &[super::markdown::ParsedLine] {
1655        &self.parse_state.buf().lines
1656    }
1657
1658    /// Test accessor: the rendered-position bitmask cache.
1659    /// Used by tests to construct a fresh `WordWrapLayout` from the same
1660    /// masks the view is using, for equivalence checks.
1661    #[cfg(test)]
1662    pub(crate) fn rendered_cache_for_testing(&self) -> &[Vec<bool>] {
1663        &self.rendered_cache
1664    }
1665
1666    #[cfg(test)]
1667    pub(crate) fn code_box_width_for_testing(&self) -> &[Option<u16>] {
1668        &self.code_box_width
1669    }
1670
1671    #[cfg(test)]
1672    pub(crate) fn gutter_insets_for_testing(&self) -> &[usize] {
1673        &self.gutter_insets
1674    }
1675
1676    fn is_in_code_block(&self, row: usize) -> bool {
1677        // Every line inside any fenced block renders force-raw (no markdown
1678        // re-styling, distinct fg color). Previously this checked only the
1679        // fence the cursor was sitting in, so fenced blocks elsewhere in
1680        // the buffer looked like plain text until the cursor moved into
1681        // them.
1682        self.fence_ranges.iter().any(|r| r.contains(&row))
1683    }
1684
1685    /// Rebuild `code_box_width` from the current parse kinds and snapshot
1686    /// lines. Box width per block = max rendered display width of its lines,
1687    /// capped at `width`.
1688    fn rebuild_code_box_width(&mut self, text: &crate::ropetext::Text, width: u16) {
1689        let mut out = vec![None; text.line_count()];
1690        let ranges =
1691            super::parse_incremental::code_block_ranges_from_kinds(&self.parse_state.buf().kinds);
1692        for r in ranges {
1693            let mut max_w = 0usize;
1694            for row in r.clone() {
1695                if let Some(line) = text.line(row) {
1696                    max_w = max_w.max(super::markdown::raw_display_width(&line));
1697                }
1698            }
1699            let boxed = (max_w.min(width as usize)) as u16;
1700            for row in r {
1701                if row < out.len() {
1702                    out[row] = Some(boxed);
1703                }
1704            }
1705        }
1706        self.code_box_width = out;
1707    }
1708
1709    /// Update `code_box_width` for just the code-block range(s) overlapping
1710    /// `damaged` — the incremental-path sibling of `rebuild_code_box_width`.
1711    /// Safe because the structural guards in `try_incremental_parse` already
1712    /// refuse to splice an edit that adds, removes, or moves a code-block
1713    /// boundary; a block that doesn't overlap the edit can only have kept
1714    /// the same lines it had before, so its width can't have changed. A
1715    /// block's own content growing or shrinking *can* change its width, and
1716    /// that only happens inside `damaged`.
1717    fn patch_code_box_width(
1718        &mut self,
1719        text: &crate::ropetext::Text,
1720        width: u16,
1721        damaged: std::ops::Range<usize>,
1722    ) {
1723        let ranges =
1724            super::parse_incremental::code_block_ranges_from_kinds(&self.parse_state.buf().kinds);
1725        for r in ranges {
1726            if r.start >= damaged.end || r.end <= damaged.start {
1727                continue; // no overlap — this block's width can't have changed
1728            }
1729            let mut max_w = 0usize;
1730            for row in r.clone() {
1731                if let Some(line) = text.line(row) {
1732                    max_w = max_w.max(super::markdown::raw_display_width(&line));
1733                }
1734            }
1735            let boxed = (max_w.min(width as usize)) as u16;
1736            for row in r {
1737                if let Some(entry) = self.code_box_width.get_mut(row) {
1738                    *entry = Some(boxed);
1739                }
1740            }
1741        }
1742    }
1743
1744    /// Rebuild `gutter_insets` from parse state + cursor. A blockquote row
1745    /// that is not the cursor row reserves `depth + 1` cols for the bar; the
1746    /// cursor row reserves 0 (its markers are revealed raw). Full
1747    /// `O(row_count)` rebuild — see `patch_gutter_insets` for the
1748    /// incremental-path sibling that only touches the rows that can
1749    /// plausibly have changed.
1750    fn rebuild_gutter_insets(&mut self, row_count: usize, cursor_row: usize) {
1751        let parsed = &self.parse_state.buf().lines;
1752        self.gutter_insets = (0..row_count)
1753            .map(|row| {
1754                if row == cursor_row {
1755                    return 0;
1756                }
1757                match parsed.get(row).and_then(|p| p.blockquote_depth()) {
1758                    Some(d) => super::markdown::blockquote_gutter_width(d),
1759                    None => 0,
1760                }
1761            })
1762            .collect();
1763    }
1764
1765    /// Update `gutter_insets` for exactly `rows`, in place. Safe whenever
1766    /// the parse took the incremental splice path: the structural guards in
1767    /// `try_incremental_parse` (opener-shape / lazy-depth) already refuse
1768    /// to splice an edit that could change a row's blockquote depth, so the
1769    /// only thing that can legitimately change `gutter_insets` between two
1770    /// incrementally-linked frames is which row the cursor is on.
1771    fn patch_gutter_insets(&mut self, rows: &[usize], cursor_row: usize) {
1772        let parsed = &self.parse_state.buf().lines;
1773        for &row in rows {
1774            let inset = if row == cursor_row {
1775                0
1776            } else {
1777                match parsed.get(row).and_then(|p| p.blockquote_depth()) {
1778                    Some(d) => super::markdown::blockquote_gutter_width(d),
1779                    None => 0,
1780                }
1781            };
1782            if let Some(entry) = self.gutter_insets.get_mut(row) {
1783                *entry = inset;
1784            }
1785        }
1786    }
1787
1788    /// Markdown-aware mouse click: maps a rendered screen column to
1789    /// the correct logical column, accounting for hidden markdown
1790    /// sigils (links, bold markers, etc.).
1791    ///
1792    /// Reads `self`'s view-internal caches (`layout`, `lines_snapshot`,
1793    /// `parsed_buffer`), all rebuilt from the same `EditorSnapshot`
1794    /// in the last `update()` call. The snapshot invariant guarantees
1795    /// `vl.logical_row` is a valid index into both `lines_snapshot`
1796    /// and `parsed_buffer.lines`, so direct indexing is safe — the
1797    /// previous defensive `(Some, Some) else fallback` block (Fix #2
1798    /// in the holistic review) is no longer needed.
1799    /// Map a screen-relative click (row/col offset from the editor's
1800    /// top-left corner) to logical (row, col). Owns the
1801    /// visual-scroll-offset arithmetic so callers do not reach into
1802    /// viewport's top — the view knows where it is scrolled.
1803    pub fn click_at_screen(&self, screen_row: usize, screen_col: usize) -> (u16, u16) {
1804        let vrow = screen_row + self.viewport.top();
1805        self.click_to_logical_u16(vrow, screen_col)
1806    }
1807
1808    fn click_to_logical_u16(&self, vrow: usize, vcol: usize) -> (u16, u16) {
1809        let vlines = self.layout.visual_lines();
1810        if vlines.is_empty() {
1811            return (0, 0);
1812        }
1813        let vrow = vrow.min(vlines.len() - 1);
1814        let vl = &vlines[vrow];
1815        let row_u16 = vl.logical_row.min(u16::MAX as usize) as u16;
1816        let row_text = self.text_snapshot.line(vl.logical_row).unwrap_or_default();
1817        let logical_line = row_text.as_ref();
1818        let parsed = &self.parse_state.buf().lines[vl.logical_row];
1819        let force_raw = self.is_in_code_block(vl.logical_row);
1820        let gutter = self
1821            .gutter_insets
1822            .get(vl.logical_row)
1823            .copied()
1824            .unwrap_or(self.cursor_vrow);
1825        let vcol = vcol.saturating_sub(gutter);
1826        // When a blockquote gutter is drawn (gutter > 0), the ">" and space
1827        // sigil chars are hidden and replaced by the "│ " bar. On the first
1828        // visual line, skip those hidden sigil chars so that rendered_col 0
1829        // maps to the first content char, not to the hidden ">".
1830        let effective_start_col = if gutter > 0 && vl.first {
1831            parsed.blockquote_sigil_end().unwrap_or(vl.chars.start)
1832        } else {
1833            vl.chars.start
1834        };
1835        // The same rule the render loop uses to decide `cursor_col`: only the
1836        // caret's own row is revealed, so only there does the mapping have to
1837        // account for a revealed element's sigils occupying cells.
1838        let reveal_col =
1839            (vl.logical_row == self.cursor_snapshot.0).then_some(self.cursor_snapshot.1);
1840        let logical_col = MarkdownSpanner::rendered_col_to_logical_with(
1841            logical_line,
1842            parsed,
1843            effective_start_col,
1844            vcol,
1845            reveal_col,
1846            vl.first,
1847            force_raw,
1848        );
1849        // Clamp to the visual line clicked. `rendered_col_to_logical_with` maps a
1850        // cell to a column in the whole logical row, so a click in the blank
1851        // space right of a soft-wrapped line walks straight into the span of the
1852        // line below and the cursor lands a row further on than the one under the
1853        // pointer. `crate::ropetext::Layout::position_at_cell` clamps for this reason;
1854        // this is the TUI's own mapper and had drifted from it.
1855        let logical_col = logical_col.min(vl.chars.end);
1856        let col = logical_col.min(u16::MAX as usize) as u16;
1857        (row_u16, col)
1858    }
1859
1860    #[cfg(test)]
1861    pub(crate) fn click_to_logical_for_testing(&self, vrow: usize, vcol: usize) -> (u16, u16) {
1862        self.click_to_logical_u16(vrow, vcol)
1863    }
1864}
1865
1866impl Default for MarkdownEditorView {
1867    fn default() -> Self {
1868        Self::new()
1869    }
1870}
1871
1872/// Returns the byte offset into `s` after consuming exactly `target_width` display columns.
1873/// If `target_width` exceeds the string's display width, returns `s.len()`.
1874///
1875/// Walks whole grapheme clusters (not codepoints) and measures each with
1876/// [`super::markdown::cluster_display_width`], so the result never lands mid-cluster (which would
1877/// split an emoji across two styled spans) and stays consistent with the width
1878/// model used by wrap and cursor math — an emoji presentation sequence (flag,
1879/// VS16 heart, keycap) counts as its full rendered width, not its first codepoint.
1880fn byte_offset_for_display_width(s: &str, target_width: usize) -> usize {
1881    use super::markdown::cluster_display_width;
1882    use unicode_segmentation::UnicodeSegmentation;
1883    let mut consumed = 0usize;
1884    for (byte_pos, g) in s.grapheme_indices(true) {
1885        if consumed >= target_width {
1886            return byte_pos;
1887        }
1888        consumed += cluster_display_width(g);
1889    }
1890    s.len()
1891}
1892
1893/// Split `spans` at the boundaries of a rendered-column range and apply
1894/// `restyle` to the overlapping portion. The one place column-to-byte
1895/// accounting for a partial restyle lives.
1896fn restyle_over_range<'a>(
1897    spans: Vec<ratatui::text::Span<'a>>,
1898    sel_cols: std::ops::Range<usize>,
1899    restyle: &dyn Fn(ratatui::style::Style) -> ratatui::style::Style,
1900) -> Vec<ratatui::text::Span<'a>> {
1901    if sel_cols.is_empty() {
1902        return spans;
1903    }
1904    let mut result = Vec::new();
1905    let mut col = 0usize;
1906
1907    for span in spans {
1908        let content: &str = &span.content;
1909        // Same cluster-based width model as `byte_offset_for_display_width`
1910        // below, so column accounting and the byte boundaries it computes can
1911        // never disagree on emoji presentation sequences.
1912        let span_width = super::markdown::string_display_width(content);
1913        let span_end = col + span_width;
1914
1915        let overlap_start = sel_cols.start.max(col);
1916        let overlap_end = sel_cols.end.min(span_end);
1917
1918        if overlap_start >= overlap_end {
1919            // No overlap — emit as-is.
1920            result.push(span);
1921        } else {
1922            // Walk grapheme clusters by display width to find byte boundaries.
1923            let prefix_width = overlap_start - col;
1924            let selected_width = overlap_end - overlap_start;
1925
1926            let prefix_byte = byte_offset_for_display_width(content, prefix_width);
1927            let selected_byte_end =
1928                byte_offset_for_display_width(&content[prefix_byte..], selected_width)
1929                    + prefix_byte;
1930
1931            // Prefix (before selection)
1932            if prefix_byte > 0 {
1933                result.push(ratatui::text::Span::styled(
1934                    content[..prefix_byte].to_string(),
1935                    span.style,
1936                ));
1937            }
1938            // Selected portion
1939            result.push(ratatui::text::Span::styled(
1940                content[prefix_byte..selected_byte_end].to_string(),
1941                restyle(span.style),
1942            ));
1943            // Suffix (after selection)
1944            if selected_byte_end < content.len() {
1945                result.push(ratatui::text::Span::styled(
1946                    content[selected_byte_end..].to_string(),
1947                    span.style,
1948                ));
1949            }
1950        }
1951
1952        col = span_end;
1953    }
1954
1955    result
1956}
1957
1958/// Paint `code_bg` behind every span of a code-block visual line and pad the
1959/// line with bg-colored spaces up to `box_width` display columns, producing a
1960/// solid rectangle hugging the block's widest line. Content already wider than
1961/// the box (the box was capped at editor width; wider rows wrap) is left as-is.
1962fn apply_code_box<'a>(
1963    spans: Vec<ratatui::text::Span<'a>>,
1964    box_width: u16,
1965    theme: &Theme,
1966) -> Vec<ratatui::text::Span<'a>> {
1967    use ratatui::text::Span;
1968    use unicode_segmentation::UnicodeSegmentation;
1969    let bg = theme.code_bg.to_ratatui();
1970    // Measure with the same cluster + tab-aware model as `raw_display_width`
1971    // (which sizes `box_width` in `rebuild_code_box_width`), so the padding
1972    // can never disagree with the target on emoji presentation sequences or
1973    // tabs. `cluster_width_at` needs the running column for tab stops.
1974    let mut width = 0usize;
1975    let mut out: Vec<Span<'a>> = spans
1976        .into_iter()
1977        .map(|s| {
1978            for g in s.content.graphemes(true) {
1979                width += super::markdown::cluster_width_at(g, width);
1980            }
1981            let style = s.style.bg(bg);
1982            Span::styled(s.content, style)
1983        })
1984        .collect();
1985    let target = box_width as usize;
1986    if width < target {
1987        out.push(Span::styled(
1988            " ".repeat(target - width),
1989            Style::default().bg(bg),
1990        ));
1991    }
1992    out
1993}
1994
1995/// Per-row hints for the layout: what the syntax layer draws, and how far each
1996/// row is inset by its gutter.
1997///
1998/// Built per rebuild rather than stored, because both halves already live on the
1999/// view and a third copy would be a third thing to keep in step.
2000///
2001/// `pub(super)`: the background wrap task spawned by the owning
2002/// `TextEditorComponent` (`mod.rs`) rebuilds the same hints from a
2003/// [`PendingLayoutJob`]'s owned `rendered_cache`/`gutter_insets` clones —
2004/// `RowHints` borrows, so it cannot cross the `tokio::spawn` boundary
2005/// itself and has to be reconstructed on the other side from owned data.
2006pub(super) fn row_hints<'a>(rendered: &'a [Vec<bool>], insets: &'a [usize]) -> Vec<RowHints<'a>> {
2007    let rows = rendered.len().max(insets.len());
2008    (0..rows)
2009        .map(|row| RowHints {
2010            visible: rendered.get(row).map(Vec::as_slice).unwrap_or(&[]),
2011            inset: insets.get(row).copied().unwrap_or(0),
2012        })
2013        .collect()
2014}
2015
2016#[cfg(test)]
2017mod tests {
2018    use super::*;
2019    use ratatui::layout::Rect;
2020    use std::num::NonZeroU64;
2021
2022    fn rect(h: u16) -> Rect {
2023        Rect {
2024            x: 0,
2025            y: 0,
2026            width: 40,
2027            height: h,
2028        }
2029    }
2030
2031    /// Test-only wrapper that builds an `EditorSnapshot::borrowed`
2032    /// from the legacy `(lines, cursor, generation)` shape, so the
2033    /// hundreds of existing call sites don't each have to construct
2034    /// the snapshot inline.
2035    ///
2036    /// Mirrors `snapshot_from_backend`'s producer-side cursor clamp,
2037    /// so tests that pass an intentionally-stale `cursor` (e.g. the
2038    /// regression for the Nvim shrink panic) still exercise the
2039    /// real production path: producer clamps, render trusts.
2040    /// Tests describe buffers as rows; the view takes the text they make up.
2041    fn text_of(lines: &[String]) -> crate::ropetext::Text {
2042        crate::ropetext::Text::from(lines.join("\n").as_str())
2043    }
2044
2045    /// Two reports between one pair of frames, the second changing the line
2046    /// count above the first — the first report's row has moved by the time the
2047    /// hull is used.
2048    #[test]
2049    fn damage_reported_twice_across_a_line_change_is_renumbered() {
2050        let mut v = MarkdownEditorView::new();
2051        // An edit at row 10, then a newline inserted at row 0, which pushes the
2052        // first edit's row down to 11.
2053        v.note_damage(10..11, 0);
2054        v.note_damage(0..2, 1);
2055        let hull = v.reported_damage.clone().expect("both edits were reported");
2056        assert!(
2057            hull.contains(&11),
2058            "row 10 became row 11; the hull reported was {hull:?}"
2059        );
2060    }
2061
2062    /// Does the engine's `position_at_cell` agree with the TUI's own click
2063    /// mapper?
2064    ///
2065    /// The two compute the same thing by different routes — the TUI walks
2066    /// rendered columns through `MarkdownSpanner`, the engine walks the same
2067    /// information as `RowHints` (a visibility mask plus a gutter inset). Keeping
2068    /// two of these in sync by hand is what let the wrap-clamp drift out of the
2069    /// TUI copy in the first place. This says where they still differ, and is the
2070    /// gate for deleting one of them.
2071    ///
2072    /// **Currently fails, with six disagreements of exactly two kinds** — and
2073    /// neither is an algorithmic mismatch. The engine does honour concealment;
2074    /// `cell_of` skips masked-out chars. What it lacks is data the TUI mapper
2075    /// holds:
2076    ///
2077    /// 1. **Blockquote sigil skip.** On `> quoted ...`, cells 0-2 map to column 2
2078    ///    in the TUI (it skips the `> ` via `blockquote_sigil_end` on a first
2079    ///    visual line) and to 0 in the engine, which applies only `inset`. Mark
2080    ///    those sigil chars invisible in `rendered_cache` and the engine reaches
2081    ///    2 on its own.
2082    /// 2. **Tie-breaking at the edge of a concealed run** — which side a click
2083    ///    between a hidden run and its neighbour falls to. Needs stating as a
2084    ///    rule in `position_at_cell`, not reproducing.
2085    ///
2086    /// A translation layer between the two mappers is the wrong answer — it adds
2087    /// the seam this exists to remove. But so, for now, is fixing the hints:
2088    /// `row_hints` feeds `Layout::compute`/`relayout_rows` at six sites, so the
2089    /// visibility mask is the **wrapping** input. Marking the sigil chars
2090    /// invisible would change where every line breaks, editor-wide, to fix where
2091    /// clicks land. The render snapshots would catch it, but that is a re-wrap,
2092    /// not a tidy-up.
2093    ///
2094    /// So this test's job is to be a tripwire, not a plan: it fails if the two
2095    /// mappers drift *further* apart. Unify them only when the mask has to change
2096    /// for some other reason, at which point it comes along nearly free. Run with
2097    /// `--ignored`.
2098    ///
2099    /// (An earlier version of this test reported 23 disagreements and concluded
2100    /// the engine ignored concealment. It parked the cursor on the row under
2101    /// test, and the cursor's row is *revealed* — so it compared concealment
2102    /// against its own suspension.)
2103    /// Now green, and it is the precondition for deleting `click_to_logical_u16`:
2104    /// the engine's mapper may replace the TUI's exactly when the two agree.
2105    /// Closing the last six took a change on each side — the TUI resolving a
2106    /// cell to the drawn column *after* a concealed run rather than to the run's
2107    /// head, and `position_at_cell` dropping a short circuit that returned the
2108    /// row's first char for any cell inside the inset, skipping the very loop
2109    /// that walks past a blockquote's hidden `> `.
2110    #[test]
2111    fn the_engine_and_the_tui_click_mappers_agree() {
2112        let corpus: Vec<Vec<String>> = vec![
2113            vec!["plain short".to_string()],
2114            vec!["a long paragraph that certainly wraps more than once here".to_string()],
2115            vec!["> quoted line that is long enough to wrap at this width".to_string()],
2116            vec!["- list item with enough text on it to wrap somewhere".to_string()],
2117            vec!["**bold** and *italic* markers that get concealed".to_string()],
2118            vec!["# heading that runs on long enough to wrap around".to_string()],
2119        ];
2120
2121        let mut disagreements = Vec::new();
2122        for lines in &corpus {
2123            // Park the cursor on an appended trailing row: the cursor's row is
2124            // *revealed* (sigils shown raw), so testing the row it sits on
2125            // compares concealment against its own suspension.
2126            let mut lines = lines.clone();
2127            lines.push(String::new());
2128            let park = lines.len() - 1;
2129            let mut v = MarkdownEditorView::new();
2130            update_view(&mut v, &lines, (park, 0), rect(20), 1, None);
2131            let text = v.text_snapshot.clone();
2132            let hints = row_hints(&v.rendered_cache, &v.gutter_insets);
2133            for vrow in 0..v.layout.visual_lines().len() {
2134                for vcol in 0..24 {
2135                    let (tui_row, tui_col) = v.click_to_logical_for_testing(vrow, vcol);
2136                    let engine = v.layout.position_at_cell(
2137                        &text,
2138                        &hints,
2139                        crate::ropetext::Cell {
2140                            row: vrow,
2141                            column: vcol,
2142                        },
2143                    );
2144                    let engine = engine.map(|p| (p.row() as u16, p.column().get() as u16));
2145                    if engine != Some((tui_row, tui_col)) {
2146                        disagreements.push(format!(
2147                            "{:?} vrow={vrow} vcol={vcol}: tui={:?} engine={:?}",
2148                            lines[0],
2149                            (tui_row, tui_col),
2150                            engine
2151                        ));
2152                    }
2153                }
2154            }
2155        }
2156        assert!(
2157            disagreements.is_empty(),
2158            "{} disagreements, first 5:\n{}",
2159            disagreements.len(),
2160            disagreements
2161                .iter()
2162                .take(5)
2163                .cloned()
2164                .collect::<Vec<_>>()
2165                .join("\n")
2166        );
2167    }
2168
2169    #[test]
2170    fn a_preview_that_cannot_place_the_cursor_leaves_the_viewport_alone() {
2171        // `render` pairs the replace preview's rows with the real buffer's
2172        // cursor. A replacement shorter than what it replaces puts that cursor
2173        // past the end of the previewed row, and answering "visual row 0" threw
2174        // the note to the top mid-keystroke.
2175        let mut lines: Vec<String> = (0..200).map(|i| format!("row {i} plain text")).collect();
2176        lines[150] = "  the configuration value goes here".to_string();
2177
2178        let mut v = MarkdownEditorView::new();
2179        update_view(&mut v, &lines, (150, 25), rect(20), 1, None);
2180        let scrolled = v.viewport.top();
2181        assert!(scrolled > 0, "fixture must have scrolled away from the top");
2182
2183        // The preview: that row shrinks below the cursor's column.
2184        let mut preview = lines.clone();
2185        preview[150] = "  the cfg value".to_string();
2186        update_view(&mut v, &preview, (150, 25), rect(20), 2, None);
2187
2188        assert_eq!(
2189            v.viewport.top(),
2190            scrolled,
2191            "the viewport must not jump to the top of the note"
2192        );
2193    }
2194
2195    #[test]
2196    fn a_click_past_the_end_of_a_wrapped_line_stays_on_that_line() {
2197        // Clicking the blank space to the right of a soft-wrapped line must land
2198        // at the end of the line clicked, not inside the continuation below it.
2199        // `crate::ropetext::Layout::position_at_cell` clamps for exactly this reason;
2200        // this mapper is the TUI's own copy and had drifted from it.
2201        let lines = vec![
2202            "a long paragraph that will certainly wrap more than once at this width".to_string(),
2203        ];
2204        let mut v = MarkdownEditorView::new();
2205        update_view(&mut v, &lines, (0, 0), rect(20), 1, None);
2206
2207        let first = v.layout.visual_lines()[0].clone();
2208        assert!(
2209            v.layout.visual_lines().len() > 1,
2210            "fixture must actually wrap"
2211        );
2212
2213        // Far to the right of anything drawn on the first visual line.
2214        let (row, col) = v.click_to_logical_for_testing(0, 60);
2215        assert_eq!(row, 0);
2216        assert!(
2217            (col as usize) <= first.chars.end,
2218            "clicked past visual line 0 (chars {:?}) and landed at column {col}",
2219            first.chars
2220        );
2221    }
2222
2223    /// A newline patched into the layout must give the same layout a fresh
2224    /// compute would.
2225    ///
2226    /// The layout no longer gives up when the line count changes — it patches the
2227    /// damaged rows and renumbers the rest by the delta. That renumbering is the
2228    /// part with no second opinion anywhere: a wrong `logical_row` on the rows
2229    /// *after* the edit paints the right text against the wrong line, and every
2230    /// existing test looks at the edited row.
2231    #[test]
2232    fn a_newline_patches_the_layout_to_match_a_fresh_one() {
2233        // Rows long enough to wrap at width 20, so the visual lines outnumber the
2234        // logical rows and a renumbering slip cannot hide.
2235        let lines: Vec<String> = (0..12)
2236            .map(|i| format!("row {i} with enough words on it to wrap at this width"))
2237            .collect();
2238        let mut split = lines.clone();
2239        let tail = split[5].split_off(10);
2240        split.insert(6, tail);
2241
2242        let mut patched = MarkdownEditorView::new();
2243        update_view(&mut patched, &lines, (5, 0), rect(20), 1, None);
2244        patched.note_damage(5..7, 1);
2245        update_view(&mut patched, &split, (6, 0), rect(20), 2, None);
2246
2247        let mut fresh = MarkdownEditorView::new();
2248        update_view(&mut fresh, &split, (6, 0), rect(20), 1, None);
2249
2250        let patched_lines: Vec<_> = patched
2251            .layout
2252            .visual_lines()
2253            .iter()
2254            .map(|vl| (vl.logical_row, vl.bytes.clone(), vl.first))
2255            .collect();
2256        let fresh_lines: Vec<_> = fresh
2257            .layout
2258            .visual_lines()
2259            .iter()
2260            .map(|vl| (vl.logical_row, vl.bytes.clone(), vl.first))
2261            .collect();
2262        assert_eq!(
2263            patched_lines, fresh_lines,
2264            "patching a newline must land where a full recompute would"
2265        );
2266    }
2267
2268    pub(super) fn update_view(
2269        v: &mut MarkdownEditorView,
2270        lines: &[String],
2271        cursor: (usize, usize),
2272        rect: Rect,
2273        generation: u64,
2274        selection: Option<((usize, usize), (usize, usize))>,
2275    ) {
2276        // Selection reaches the view as an **overlay** now.
2277        let rev = NonZeroU64::new(generation.max(1)).unwrap();
2278        let clamped = if lines.is_empty() {
2279            (0, 0)
2280        } else {
2281            (cursor.0.min(lines.len() - 1), cursor.1)
2282        };
2283        let snap = super::super::snapshot::EditorSnapshot::borrowed(lines, clamped, rev);
2284        v.update(&snap, rect);
2285        let overlays = match selection {
2286            Some(((sr, sc), (er, ec))) => (sr..=er)
2287                .map(|row| {
2288                    Overlay::new(
2289                        row,
2290                        if row == sr { sc } else { 0 },
2291                        if row == er { ec } else { usize::MAX },
2292                        OverlayKind::Selection,
2293                    )
2294                })
2295                .collect(),
2296            None => Vec::new(),
2297        };
2298        v.set_overlays(overlays);
2299    }
2300
2301    /// Build a freshly-updated view from `lines` with the cursor at
2302    /// `cursor` and the given editor `width`, using the real snapshot +
2303    /// `update()` path. Height is fixed at 24.
2304    fn make_view_for_lines(
2305        lines: &[String],
2306        cursor: (usize, usize),
2307        width: u16,
2308    ) -> MarkdownEditorView {
2309        let mut v = MarkdownEditorView::new();
2310        let r = Rect {
2311            x: 0,
2312            y: 0,
2313            width,
2314            height: 24,
2315        };
2316        update_view(&mut v, lines, cursor, r, 1, None);
2317        v
2318    }
2319
2320    #[test]
2321    fn selection_highlight_respects_emoji_cluster_width() {
2322        // Span "a❤️b" where ❤️ = U+2764 + VS16 renders as 2 display columns:
2323        // a=col0, ❤️=cols1..3, b=col3. Selecting cols 1..3 must highlight
2324        // exactly the heart cluster — not split it, and not bleed into 'b'.
2325        let theme = Theme::default();
2326        let sel_bg = theme.selection_bg.to_ratatui();
2327        let heart = "\u{2764}\u{FE0F}";
2328        let content = format!("a{heart}b");
2329        let spans = vec![ratatui::text::Span::raw(content)];
2330        let out = restyle_over_range(spans, 1..3, &|st| st.bg(sel_bg));
2331
2332        let highlighted: String = out
2333            .iter()
2334            .filter(|s| s.style.bg == Some(sel_bg))
2335            .map(|s| s.content.as_ref())
2336            .collect();
2337        assert_eq!(highlighted, heart, "selection must cover exactly the heart");
2338
2339        // No output span may split the cluster: every span's content must
2340        // recluster identically (the heart stays whole within one span).
2341        for s in &out {
2342            let c = s.content.as_ref();
2343            assert!(
2344                !c.contains('\u{2764}') || c.contains(heart),
2345                "emoji cluster split across spans: {c:?}"
2346            );
2347        }
2348    }
2349
2350    #[test]
2351    fn code_box_background_reaches_rendered_cells() {
2352        use ratatui::Terminal;
2353        use ratatui::backend::TestBackend;
2354        let lines = vec![
2355            "```".to_string(),
2356            "let x = 1;".to_string(),
2357            "```".to_string(),
2358            "plain".to_string(),
2359        ];
2360        let theme = crate::settings::themes::Theme::gruvbox_dark();
2361        let mut view = make_view_for_lines(&lines, (3, 0), 40);
2362        let mut terminal = Terminal::new(TestBackend::new(40, 5)).unwrap();
2363        terminal
2364            .draw(|f| view.render(f, f.area(), &theme, true, Some(CursorShape::Bar)))
2365            .unwrap();
2366        let buf = terminal.backend().buffer().clone();
2367        let code_bg = theme.code_bg.to_ratatui();
2368        let cell = |x: u16, y: u16| &buf.content[(y as usize) * 40 + (x as usize)];
2369
2370        // A cell on the fenced code content row carries the code-box bg...
2371        assert_eq!(
2372            cell(0, 1).bg,
2373            code_bg,
2374            "code content cell must have code_bg"
2375        );
2376        // ...including the padding past the text (box is a solid rectangle).
2377        assert_eq!(cell(8, 1).bg, code_bg, "code-box padding must have code_bg");
2378        // A prose row outside the block does NOT get the code bg.
2379        assert_ne!(cell(0, 3).bg, code_bg, "prose row must not have code_bg");
2380    }
2381
2382    #[test]
2383    fn blockquote_gutter_inset_off_cursor_row_only() {
2384        // Two blockquote lines; cursor on row 0.
2385        let lines = vec!["> first".to_string(), ">> second".to_string()];
2386        let view = make_view_for_lines(&lines, (0, 1), 80);
2387        let g = view.gutter_insets_for_testing();
2388        assert_eq!(g[0], 0); // cursor row → revealed, no gutter
2389        assert_eq!(g[1], 3); // depth 2 → 2 bars + 1 space
2390    }
2391
2392    #[test]
2393    fn code_box_width_is_block_max_capped_to_width() {
2394        let lines = vec![
2395            "```".to_string(),
2396            "let x = 1;".to_string(),    // 10
2397            "let yy = 222;".to_string(), // 13 (widest)
2398            "```".to_string(),
2399            "plain".to_string(),
2400        ];
2401        let view = make_view_for_lines(&lines, (0, 0), 80); // width 80
2402        let w = view.code_box_width_for_testing();
2403        assert_eq!(w[0], Some(13));
2404        assert_eq!(w[1], Some(13));
2405        assert_eq!(w[2], Some(13));
2406        assert_eq!(w[3], Some(13));
2407        assert_eq!(w[4], None);
2408    }
2409
2410    #[test]
2411    fn new_has_zero_scroll() {
2412        assert_eq!(MarkdownEditorView::new().viewport.top(), 0);
2413    }
2414
2415    #[test]
2416    fn zero_height_rect_does_not_panic() {
2417        let mut v = MarkdownEditorView::new();
2418        update_view(&mut v, &["hello".to_string()], (0, 0), rect(0), 1, None);
2419    }
2420
2421    #[test]
2422    fn scroll_follows_cursor_down() {
2423        let mut v = MarkdownEditorView::new();
2424        let lines: Vec<String> = (0..5).map(|i| format!("line{}", i)).collect();
2425        update_view(&mut v, &lines, (4, 0), rect(3), 1, None);
2426        assert!(v.viewport.top() >= 2);
2427    }
2428
2429    #[test]
2430    fn scroll_follows_cursor_up() {
2431        let mut v = MarkdownEditorView::new();
2432        let lines: Vec<String> = (0..5).map(|i| format!("line{}", i)).collect();
2433        update_view(&mut v, &lines, (4, 0), rect(3), 1, None);
2434        update_view(&mut v, &lines, (0, 0), rect(3), 1, None); // same generation — scroll still adjusts
2435        assert_eq!(v.viewport.top(), 0);
2436    }
2437
2438    #[test]
2439    fn wheel_scroll_moves_the_viewport_and_survives_the_next_frame() {
2440        let mut v = MarkdownEditorView::new();
2441        let lines: Vec<String> = (0..10).map(|i| format!("line{}", i)).collect();
2442        update_view(&mut v, &lines, (0, 0), rect(3), 1, None);
2443        v.scroll_by(4);
2444        assert_eq!(v.viewport.top(), 4);
2445        // Same cursor, text and size: the redraw must not snap back.
2446        update_view(&mut v, &lines, (0, 0), rect(3), 1, None);
2447        assert_eq!(v.viewport.top(), 4);
2448        v.scroll_by(-1);
2449        update_view(&mut v, &lines, (0, 0), rect(3), 1, None);
2450        assert_eq!(v.viewport.top(), 3);
2451    }
2452
2453    #[test]
2454    fn wheel_scroll_is_clamped_to_the_content() {
2455        let mut v = MarkdownEditorView::new();
2456        let lines: Vec<String> = (0..10).map(|i| format!("line{}", i)).collect();
2457        update_view(&mut v, &lines, (0, 0), rect(3), 1, None);
2458        v.scroll_by(100);
2459        assert_eq!(v.viewport.top(), 7, "last line sits at the bottom");
2460        v.scroll_by(-100);
2461        assert_eq!(v.viewport.top(), 0);
2462    }
2463
2464    #[test]
2465    fn moving_the_cursor_after_a_wheel_scroll_brings_it_back_into_view() {
2466        let mut v = MarkdownEditorView::new();
2467        let lines: Vec<String> = (0..10).map(|i| format!("line{}", i)).collect();
2468        update_view(&mut v, &lines, (0, 0), rect(3), 1, None);
2469        v.scroll_by(6);
2470        update_view(&mut v, &lines, (1, 0), rect(3), 1, None);
2471        assert_eq!(v.viewport.top(), 1);
2472    }
2473
2474    #[test]
2475    fn editing_after_a_wheel_scroll_brings_the_cursor_back_into_view() {
2476        let mut v = MarkdownEditorView::new();
2477        let mut lines: Vec<String> = (0..10).map(|i| format!("line{}", i)).collect();
2478        update_view(&mut v, &lines, (0, 0), rect(3), 1, None);
2479        v.scroll_by(6);
2480        lines[0].push('x');
2481        update_view(&mut v, &lines, (0, 0), rect(3), 2, None);
2482        assert_eq!(v.viewport.top(), 0);
2483    }
2484
2485    #[test]
2486    fn follow_cursor_brings_the_view_back_when_nothing_else_changed() {
2487        // Find-next wrapping onto the match the cursor already sits on changes
2488        // neither the cursor nor the text; the explicit re-attach must still
2489        // reveal it.
2490        let mut v = MarkdownEditorView::new();
2491        let lines: Vec<String> = (0..10).map(|i| format!("line{}", i)).collect();
2492        update_view(&mut v, &lines, (0, 0), rect(3), 1, None);
2493        v.scroll_by(6);
2494        v.follow_cursor();
2495        update_view(&mut v, &lines, (0, 0), rect(3), 1, None);
2496        assert_eq!(v.viewport.top(), 0);
2497    }
2498
2499    #[test]
2500    fn a_saturated_wheel_notch_does_not_detach_the_view() {
2501        let mut v = MarkdownEditorView::new();
2502        let lines: Vec<String> = (0..10).map(|i| format!("line{}", i)).collect();
2503        update_view(&mut v, &lines, (0, 0), rect(3), 1, None);
2504        assert!(!v.scroll_by(-3), "already at the top");
2505        assert!(!v.detached);
2506    }
2507
2508    #[test]
2509    fn a_wheel_scroll_survives_a_height_change_but_stays_within_content() {
2510        let mut v = MarkdownEditorView::new();
2511        let lines: Vec<String> = (0..10).map(|i| format!("line{}", i)).collect();
2512        update_view(&mut v, &lines, (0, 0), rect(3), 1, None);
2513        v.scroll_by(7);
2514        update_view(&mut v, &lines, (0, 0), rect(5), 1, None);
2515        assert_eq!(
2516            v.viewport.top(),
2517            5,
2518            "clamped so the last line is at the bottom"
2519        );
2520    }
2521
2522    #[test]
2523    fn growing_the_viewport_pulls_content_back_down() {
2524        // Shrinking to one row pins the cursor's (last) line at the top;
2525        // growing back must reveal the lines above it, not leave the last
2526        // line alone on screen with blank rows beneath.
2527        let mut v = MarkdownEditorView::new();
2528        let lines: Vec<String> = (0..5).map(|i| format!("line{}", i)).collect();
2529        update_view(&mut v, &lines, (4, 0), rect(1), 1, None);
2530        assert_eq!(v.viewport.top(), 4);
2531        update_view(&mut v, &lines, (4, 0), rect(3), 1, None);
2532        assert_eq!(v.viewport.top(), 2);
2533        update_view(&mut v, &lines, (4, 0), rect(10), 1, None);
2534        assert_eq!(v.viewport.top(), 0);
2535    }
2536
2537    #[test]
2538    fn visual_to_logical_u16_accounts_for_scroll() {
2539        let mut v = MarkdownEditorView::new();
2540        let lines: Vec<String> = (0..10).map(|i| format!("line{}", i)).collect();
2541        update_view(&mut v, &lines, (5, 0), rect(3), 1, None);
2542        let scroll = v.viewport.top();
2543        let (row, _col) = v.click_to_logical_u16(scroll, 0);
2544        assert_eq!(row as usize, scroll);
2545    }
2546
2547    #[test]
2548    fn code_block_detection_cursor_inside() {
2549        let lines = vec![
2550            "text".to_string(),
2551            "```rust".to_string(),
2552            "let x = 1;".to_string(),
2553            "```".to_string(),
2554            "more".to_string(),
2555        ];
2556        let pb = ParsedBuffer::parse_lines(&lines);
2557        let ranges = super::super::parse_incremental::fence_ranges_from_kinds(&pb.kinds);
2558        let block = ranges.iter().find(|r| r.contains(&2)).cloned();
2559        assert!(block.is_some());
2560        let r = block.unwrap();
2561        assert_eq!(r.start, 1);
2562        assert_eq!(r.end, 4);
2563    }
2564
2565    #[test]
2566    fn code_block_detection_cursor_outside() {
2567        let lines = vec![
2568            "text".to_string(),
2569            "```".to_string(),
2570            "code".to_string(),
2571            "```".to_string(),
2572        ];
2573        let pb = ParsedBuffer::parse_lines(&lines);
2574        let ranges = super::super::parse_incremental::fence_ranges_from_kinds(&pb.kinds);
2575        assert!(ranges.iter().find(|r| r.contains(&0)).is_none());
2576    }
2577
2578    #[test]
2579    fn click_to_logical_does_not_panic_on_stale_layout() {
2580        // Regression: click_to_logical_u16 raw-indexed parsed_buffer.lines
2581        // by vl.logical_row. A stale layout whose visual_lines outlive a
2582        // shrink of parsed_buffer.lines would panic on mouse click. The
2583        // guard now falls back to a raw visual-col mapping.
2584        let mut v = MarkdownEditorView::new();
2585        let long: Vec<String> = (0..20).map(|i| format!("line{}", i)).collect();
2586        update_view(&mut v, &long, (0, 0), rect(10), 1, None);
2587        // Drive a shrink so layout.visual_lines outruns parsed_buffer.lines
2588        // briefly. update() rebuilds layout from the new lines, so the
2589        // pure shrink shouldn't desynchronize them — but we still want a
2590        // black-box test that simulates a click against the last vrow.
2591        let vrows = v.layout.visual_lines().len();
2592        if vrows > 0 {
2593            let _ = v.click_to_logical_u16(vrows.saturating_sub(1), 0);
2594            let _ = v.click_to_logical_u16(vrows + 5, 0);
2595        }
2596    }
2597
2598    #[test]
2599    fn render_does_not_panic_on_stale_cursor_past_line_count() {
2600        // Regression: render() previously did self.parsed_cache[cursor.0]
2601        // and self.layout.visual_lines()[cursor_vrow] directly. A stale
2602        // Nvim snapshot whose cursor row landed past the new line count
2603        // would panic the render thread. Now the test exercises the
2604        // producer-side clamp (via `update_view`'s mirror of
2605        // `snapshot_from_backend`): the snapshot constructor clamps
2606        // the cursor, render trusts the invariant, and direct
2607        // indexing is safe.
2608        use ratatui::Terminal;
2609        use ratatui::backend::TestBackend;
2610        let theme = Theme::gruvbox_dark();
2611        let backend = TestBackend::new(40, 10);
2612        let mut terminal = Terminal::new(backend).unwrap();
2613
2614        let mut v = MarkdownEditorView::new();
2615        // Populate with 2 lines and a valid cursor first so parsed_cache /
2616        // layout are non-empty.
2617        update_view(
2618            &mut v,
2619            &["alpha".to_string(), "beta".to_string()],
2620            (0, 0),
2621            rect(8),
2622            1,
2623            None,
2624        );
2625        // Now feed a cursor row that exceeds the line count for this update
2626        // (simulates a stale snapshot arriving after a shrink). update() at
2627        // line 277 already uses `lines.get(cursor.0)` so it won't panic; the
2628        // real risk was the [] indexes inside render(). cursor_snapshot ends
2629        // up at (5, 0) which exceeds the parsed_cache len of 2 below.
2630        update_view(
2631            &mut v,
2632            &["alpha".to_string(), "beta".to_string()],
2633            (5, 0),
2634            rect(8),
2635            1,
2636            None,
2637        );
2638        // Render with focus so the cursor branch runs.
2639        terminal
2640            .draw(|f| v.render(f, f.area(), &theme, true, Some(CursorShape::Bar)))
2641            .expect("render must not panic on stale cursor");
2642    }
2643
2644    #[test]
2645    fn cursor_into_link_refreshes_layout_for_same_row() {
2646        // Regression: when the cursor moves within a row, crossing into
2647        // or out of an expandable inline element (link/bold/etc.), the
2648        // rendered mask flips (the element reveals or hides its hidden
2649        // sigils). Both rendered_cache and the wrap layout depend on
2650        // the mask. Previously Gate 2 took the `TextChangeKind::None`
2651        // wrap branch and skipped re-splicing, leaving stale visual
2652        // lines until the next text edit.
2653        //
2654        // Use a link whose hidden URL is long enough that revealing it
2655        // forces an extra wrap line at width 40 — that lets us
2656        // black-box detect the mask flip via visual_lines.len().
2657        let mut v = MarkdownEditorView::new();
2658        let lines =
2659            vec!["see [link](http://example.com/very/long/path/to/some/page) more".to_string()];
2660        // First update: cursor outside the link (col 0).
2661        update_view(&mut v, &lines, (0, 0), rect(5), 1, None);
2662        let n_outside = v.layout.visual_lines().len();
2663
2664        // Second update: cursor inside the link element.
2665        update_view(&mut v, &lines, (0, 8), rect(5), 1, None);
2666        let layout_inside = v.layout.visual_lines().to_vec();
2667
2668        // Fresh view with cursor already inside must produce the same layout.
2669        let mut fresh = MarkdownEditorView::new();
2670        update_view(&mut fresh, &lines, (0, 8), rect(5), 1, None);
2671        let layout_fresh = fresh.layout.visual_lines().to_vec();
2672        assert_eq!(
2673            layout_inside, layout_fresh,
2674            "post-move layout must match a fresh full-recompute"
2675        );
2676        assert!(
2677            layout_inside.len() > n_outside,
2678            "expanding the link's hidden URL must produce more visual lines"
2679        );
2680    }
2681
2682    #[test]
2683    fn reported_damage_agrees_with_the_diff_it_replaced() {
2684        // The engine tells the view which rows it changed, so the view no longer
2685        // compares the buffer against a copy of its previous self. The two must
2686        // reach the same answer, or "told" quietly means something else than
2687        // "found" and every parse after an edit is subtly wrong.
2688        // Blank lines between blocks, so widening stops at a paragraph boundary
2689        // rather than reaching the buffer's edges. Without them every damage range
2690        // widens to the whole buffer and this would pass whatever the report says,
2691        // proving nothing about it being read.
2692        let mut lines: Vec<String> = Vec::new();
2693        for block in 0..4 {
2694            lines.push(format!("block {block} first line"));
2695            lines.push(format!("block {block} second line"));
2696            lines.push(String::new());
2697        }
2698        let mut edited = lines.clone();
2699        edited[7].push_str(" more");
2700
2701        let mut found = MarkdownEditorView::new();
2702        update_view(&mut found, &lines, (7, 0), rect(20), 1, None);
2703        let by_diff = found.try_incremental_parse(&text_of(&edited), (7, 0), None);
2704
2705        let mut told = MarkdownEditorView::new();
2706        update_view(&mut told, &lines, (7, 0), rect(20), 1, None);
2707        let by_report = told.try_incremental_parse(&text_of(&edited), (7, 0), Some(7..8));
2708
2709        assert!(by_diff.is_some(), "the diff finds this edit incrementally");
2710        let (diff_range, diff_slice, _) = by_diff.expect("checked");
2711        let (report_range, report_slice, _) = by_report.expect("the report must too");
2712        assert_eq!(diff_range, report_range, "widened ranges diverge");
2713        assert_eq!(diff_slice.kinds, report_slice.kinds, "parsed kinds diverge");
2714    }
2715
2716    #[test]
2717    fn a_report_past_the_end_of_the_buffer_falls_back_to_the_diff() {
2718        // A stale report — rows that no longer exist — must not be trusted. The
2719        // guard is what keeps a report from indexing outside the buffer.
2720        let lines = vec!["alpha".to_string(), "beta".to_string()];
2721        let mut edited = lines.clone();
2722        edited[1].push_str(" more");
2723        let mut v = MarkdownEditorView::new();
2724        update_view(&mut v, &lines, (1, 0), rect(20), 1, None);
2725        assert!(
2726            v.try_incremental_parse(&text_of(&edited), (1, 0), Some(0..99))
2727                .is_some(),
2728            "an out-of-range report falls back rather than panicking"
2729        );
2730    }
2731
2732    #[test]
2733    fn try_incremental_parse_falls_back_on_indented_code_flip() {
2734        // Regression: a Plain row flipping to IndentedCode (4 leading
2735        // spaces) can lazy-extend an indented-code block across the
2736        // following Plain rows in the full buffer. The widened slice
2737        // can't see that context. Guard must trip fallback.
2738        let mut v = MarkdownEditorView::new();
2739        let lines = vec!["alpha".to_string(), "beta".to_string(), "gamma".to_string()];
2740        update_view(&mut v, &lines, (0, 0), rect(20), 1, None);
2741        let new_lines = vec![
2742            "alpha".to_string(),
2743            "    beta".to_string(),
2744            "gamma".to_string(),
2745        ];
2746        // try_incremental_parse must return None (full-rebuild signal).
2747        assert!(
2748            v.try_incremental_parse(&text_of(&new_lines), (1, 0), None)
2749                .is_none(),
2750            "indented-code flip must force a full rebuild"
2751        );
2752    }
2753
2754    /// V2 structural guard regression. Buffer `["    code", "",
2755    /// "    more"]` has lazy_depth `[1, 1, 1]` (indented code
2756    /// multi-chunk per CommonMark §4.4). An edit at row 1 (the blank
2757    /// inside the block) must trigger fallback, even though the row
2758    /// is itself Blank and would otherwise be a safe-looking
2759    /// boundary candidate.
2760    #[test]
2761    fn try_incremental_parse_falls_back_when_damaged_row_is_inside_lazy_block() {
2762        let mut v = MarkdownEditorView::new();
2763        let lines = vec![
2764            "    code".to_string(),
2765            "".to_string(),
2766            "    more".to_string(),
2767        ];
2768        update_view(&mut v, &lines, (0, 0), rect(20), 1, None);
2769        assert_eq!(
2770            v.parse_state.buf().lazy_depth,
2771            vec![1, 1, 1],
2772            "precondition: parsed_buffer.lazy_depth must mark all three rows as inside the block"
2773        );
2774        let new_lines = vec![
2775            "    code".to_string(),
2776            "x".to_string(),
2777            "    more".to_string(),
2778        ];
2779        assert!(
2780            v.try_incremental_parse(&text_of(&new_lines), (1, 1), None)
2781                .is_none(),
2782            "edit inside an open lazy-continuable block must force a full rebuild"
2783        );
2784    }
2785
2786    #[test]
2787    fn try_incremental_parse_falls_back_on_html_block_flip() {
2788        // Regression: a Plain row flipping to an HTML-block opener
2789        // (`<div>`) starts a block that lazy-extends through subsequent
2790        // Plain rows in the full buffer.
2791        let mut v = MarkdownEditorView::new();
2792        let lines = vec!["alpha".to_string(), "beta".to_string(), "gamma".to_string()];
2793        update_view(&mut v, &lines, (0, 0), rect(20), 1, None);
2794        let new_lines = vec![
2795            "alpha".to_string(),
2796            "<div>".to_string(),
2797            "gamma".to_string(),
2798        ];
2799        assert!(
2800            v.try_incremental_parse(&text_of(&new_lines), (1, 0), None)
2801                .is_none(),
2802            "HTML-block opener flip must force a full rebuild"
2803        );
2804    }
2805
2806    #[test]
2807    fn is_in_code_block_returns_true_for_any_fence_regardless_of_cursor() {
2808        // Regression: since the fence change, every fenced block renders
2809        // force-raw — not just the one the cursor sits in. Verify by
2810        // probing `is_in_code_block` for a row in a fence while the
2811        // cursor is positioned elsewhere.
2812        let mut v = MarkdownEditorView::new();
2813        let lines = vec![
2814            "intro".to_string(),
2815            "```".to_string(),
2816            "code".to_string(),
2817            "```".to_string(),
2818            "outro".to_string(),
2819        ];
2820        // Cursor on the prose line; fence interior must still report in-block.
2821        update_view(&mut v, &lines, (4, 0), rect(10), 1, None);
2822        assert!(v.is_in_code_block(2), "fence interior is in-block");
2823        assert!(!v.is_in_code_block(0), "prose line is not in-block");
2824        assert!(!v.is_in_code_block(4), "trailing prose is not in-block");
2825    }
2826
2827    #[test]
2828    fn parsed_cache_populated_after_update() {
2829        let mut v = MarkdownEditorView::new();
2830        let lines = vec!["hello".to_string(), "**bold**".to_string()];
2831        update_view(&mut v, &lines, (0, 0), rect(10), 1, None);
2832        assert_eq!(v.parse_state.buf().lines.len(), 2);
2833    }
2834
2835    #[test]
2836    fn layout_skipped_on_horizontal_cursor_move_in_plain_text() {
2837        let mut v = MarkdownEditorView::new();
2838        let lines = vec!["hello world".to_string()];
2839        update_view(&mut v, &lines, (0, 0), rect(40), 1, None);
2840        let layout_gen_after_first = v.last_layout_generation;
2841        // Move cursor right — same row, no elements, same generation → layout must be skipped.
2842        update_view(&mut v, &lines, (0, 5), rect(40), 1, None);
2843        assert_eq!(
2844            v.last_layout_cursor,
2845            (0, 0),
2846            "layout cursor unchanged = layout was skipped"
2847        );
2848        assert_eq!(v.last_layout_generation, layout_gen_after_first);
2849    }
2850
2851    #[test]
2852    fn layout_recomputed_on_row_change() {
2853        let mut v = MarkdownEditorView::new();
2854        let lines: Vec<String> = (0..3).map(|i| format!("line{}", i)).collect();
2855        update_view(&mut v, &lines, (0, 0), rect(40), 1, None);
2856        update_view(&mut v, &lines, (1, 0), rect(40), 1, None); // cursor moves to row 1
2857        assert_eq!(v.last_layout_cursor.0, 1, "layout recomputed on row change");
2858    }
2859
2860    #[test]
2861    fn layout_recomputed_on_width_change() {
2862        let mut v = MarkdownEditorView::new();
2863        let lines = vec!["hello world foo bar".to_string()];
2864        update_view(&mut v, &lines, (0, 0), rect(40), 1, None);
2865        update_view(
2866            &mut v,
2867            &lines,
2868            (0, 0),
2869            Rect {
2870                x: 0,
2871                y: 0,
2872                width: 10,
2873                height: 10,
2874            },
2875            1,
2876            None,
2877        );
2878        assert_eq!(v.last_layout_width, 10);
2879    }
2880
2881    #[test]
2882    fn same_generation_skips_snapshot_rebuild() {
2883        let mut v = MarkdownEditorView::new();
2884        let lines = vec!["original".to_string()];
2885        update_view(&mut v, &lines, (0, 0), rect(10), 1, None);
2886        // Update with different content but same generation — snapshot must NOT change.
2887        let lines2 = vec!["changed".to_string()];
2888        update_view(&mut v, &lines2, (0, 0), rect(10), 1, None);
2889        assert_eq!(v.text_snapshot.to_string(), "original");
2890    }
2891
2892    #[test]
2893    fn new_generation_triggers_snapshot_rebuild() {
2894        let mut v = MarkdownEditorView::new();
2895        let lines = vec!["original".to_string()];
2896        update_view(&mut v, &lines, (0, 0), rect(10), 1, None);
2897        let lines2 = vec!["changed".to_string()];
2898        update_view(&mut v, &lines2, (0, 0), rect(10), 2, None);
2899        assert_eq!(v.text_snapshot.to_string(), "changed");
2900    }
2901
2902    /// Task and needle decoration moved out of a cell-space post-pass and into
2903    /// overlay derivation. Same behaviour, logical coordinates, visible rows
2904    /// only — and now expressible without a terminal buffer.
2905    #[test]
2906    fn content_overlays_cover_needles_and_tasks() {
2907        let mut v = MarkdownEditorView::new();
2908        let lines = vec![
2909            "find the needle here".to_string(),
2910            "- [x] done task".to_string(),
2911            "- [ ] open task".to_string(),
2912        ];
2913        v.set_needles(vec!["needle".to_string()]);
2914        update_view(&mut v, &lines, (0, 0), rect(40), 1, None);
2915
2916        let kinds: Vec<_> = v.overlays.iter().map(|o| (o.row, o.kind)).collect();
2917        assert!(
2918            kinds.contains(&(0, OverlayKind::Needle)),
2919            "the needle must be emphasised, got {kinds:?}"
2920        );
2921        assert!(kinds.contains(&(1, OverlayKind::TaskBox)));
2922        assert!(
2923            kinds.contains(&(1, OverlayKind::TaskDone)),
2924            "a done task strikes its text"
2925        );
2926        assert!(kinds.contains(&(2, OverlayKind::TaskBox)));
2927        assert!(
2928            !kinds.contains(&(2, OverlayKind::TaskDone)),
2929            "an open task does not"
2930        );
2931
2932        // "needle" starts at logical char 9 — a logical column, not a cell.
2933        let needle = v
2934            .overlays
2935            .iter()
2936            .find(|o| o.kind == OverlayKind::Needle)
2937            .unwrap();
2938        assert_eq!((needle.start, needle.end), (9, 15));
2939    }
2940
2941    #[test]
2942    fn update_takes_a_selection_overlay() {
2943        let mut v = MarkdownEditorView::new();
2944        let lines = vec!["hello world".to_string()];
2945        update_view(&mut v, &lines, (0, 0), rect(40), 1, Some(((0, 0), (0, 5))));
2946        assert_eq!(
2947            v.overlays,
2948            vec![Overlay::new(0, 0, 5, OverlayKind::Selection)]
2949        );
2950    }
2951
2952    /// Overlays belong to the frame they were built for: `update` clears them,
2953    /// so a caller that stops producing one cannot leave it painted.
2954    #[test]
2955    fn update_clears_the_previous_frame_s_overlays() {
2956        let mut v = MarkdownEditorView::new();
2957        let lines = vec!["hello world".to_string()];
2958        update_view(&mut v, &lines, (0, 0), rect(40), 1, Some(((0, 0), (0, 5))));
2959        update_view(&mut v, &lines, (0, 0), rect(40), 1, None);
2960        assert!(v.overlays.is_empty());
2961    }
2962
2963    #[test]
2964    fn typing_single_char_in_long_buffer_uses_incremental_path() {
2965        let mut v = MarkdownEditorView::new();
2966        let mut lines: Vec<String> = (0..1000).map(|i| format!("paragraph {i}")).collect();
2967        update_view(&mut v, &lines, (500, 0), rect(40), 1, None);
2968        // The 1000-line buffer takes the async-parse placeholder path on
2969        // first parse. Simulate the background task completing before the
2970        // edit so the next update splices against a real (non-placeholder)
2971        // buffer; Gate 1 deliberately refuses to incrementally splice the
2972        // all-`Plain` placeholder.
2973        v.install_full_parse(1, ParsedBuffer::parse_lines(&lines));
2974
2975        // Single-char insert at row 500.
2976        lines[500].push('x');
2977        let edited_len = lines[500].len();
2978        update_view(&mut v, &lines, (500, edited_len), rect(40), 2, None);
2979
2980        // The spliced result must equal a fresh full parse.
2981        let fresh = ParsedBuffer::parse_lines(&lines);
2982        assert_eq!(v.parse_state.buf().lines.len(), fresh.lines.len());
2983        assert_eq!(v.parse_state.buf().kinds, fresh.kinds);
2984        // Regression: the heuristic widener splices a slice whose
2985        // local sentinel boundaries (slice rows 0 and len) are NOT
2986        // genuine reset boundaries of the merged buffer. splice must
2987        // not promote them — a 1000-line single-paragraph buffer has
2988        // reset boundaries only at [0, 1000].
2989        assert_eq!(
2990            v.parse_state.buf().reset_boundaries,
2991            fresh.reset_boundaries,
2992            "heuristic splice must not introduce spurious reset boundaries"
2993        );
2994        // And the incremental path was actually taken.
2995        assert!(
2996            v.last_parse_was_incremental,
2997            "single-char paragraph edit should take incremental path"
2998        );
2999    }
3000
3001    #[test]
3002    fn edit_while_placeholder_active_refuses_incremental_and_rearms() {
3003        // Regression: a large-buffer edit installs an unstyled placeholder
3004        // (all-`Plain` kinds) pending a background full parse. If the next
3005        // edit lands before the parse completes, Gate 1 must NOT splice the
3006        // placeholder — its all-`Plain` kinds defeat the structural guards
3007        // and would lock in a wrong parse that install_full_parse then drops
3008        // as stale. The edit must re-install a placeholder + re-arm pending.
3009        let mut v = MarkdownEditorView::new();
3010        let mut lines: Vec<String> = (0..1000).map(|i| format!("paragraph {i}")).collect();
3011        update_view(&mut v, &lines, (0, 0), rect(40), 1, None);
3012        assert!(
3013            v.parse_state.is_placeholder(),
3014            "first parse installs placeholder"
3015        );
3016        assert_eq!(v.take_pending_full_parse(), Some(1));
3017
3018        // Edit before the background parse resolves the placeholder.
3019        lines[0].push_str("```");
3020        update_view(&mut v, &lines, (0, lines[0].len()), rect(40), 2, None);
3021        assert!(
3022            !v.last_parse_was_incremental,
3023            "must not splice the placeholder"
3024        );
3025        assert!(
3026            v.parse_state.is_placeholder(),
3027            "still placeholder pending parse"
3028        );
3029        assert_eq!(
3030            v.take_pending_full_parse(),
3031            Some(2),
3032            "re-armed for new generation"
3033        );
3034
3035        // Background parse for the latest generation completes.
3036        v.install_full_parse(2, ParsedBuffer::parse_lines(&lines));
3037        assert!(
3038            !v.parse_state.is_placeholder(),
3039            "placeholder cleared on install"
3040        );
3041        assert_eq!(
3042            v.parse_state.buf().kinds,
3043            ParsedBuffer::parse_lines(&lines).kinds
3044        );
3045    }
3046
3047    #[test]
3048    #[should_panic(expected = "splice on placeholder parse")]
3049    fn splice_real_on_placeholder_is_rejected() {
3050        // The type makes the wrong-splice hazard unrepresentable on the
3051        // Gate 1 path; this guards the `ParseState::splice_real` contract
3052        // directly so a future caller can't route a splice into a
3053        // placeholder without tripping the assert.
3054        let mut state = ParseState::Placeholder {
3055            buf: ParsedBuffer::placeholder_lines(&["x".to_string()]),
3056            generation: 1,
3057            spawned: false,
3058        };
3059        state.splice_real(0..1, ParsedBuffer::parse_lines(&["y".to_string()]));
3060    }
3061
3062    #[test]
3063    fn fence_toggle_triggers_full_rebuild_fallback() {
3064        let mut v = MarkdownEditorView::new();
3065        // Use 700 lines so that an unclosed fence at row 350 widens to
3066        // end-of-buffer (~351 rows), exceeding the absolute cap (256).
3067        // Below the perf #9 LARGE_BUFFER_THRESHOLD (1000), so the
3068        // fallback runs synchronously and `parsed_buffer.kinds`
3069        // matches a fresh full parse immediately.
3070        let mut lines: Vec<String> = (0..700).map(|i| format!("paragraph {i}")).collect();
3071        update_view(&mut v, &lines, (350, 0), rect(40), 1, None);
3072
3073        // Open a fence mid-buffer — structurally invasive, line count changes.
3074        lines.insert(350, "```".to_string());
3075        update_view(&mut v, &lines, (350, 3), rect(40), 2, None);
3076
3077        let fresh = ParsedBuffer::parse_lines(&lines);
3078        assert_eq!(
3079            v.parse_state.buf().kinds,
3080            fresh.kinds,
3081            "spliced kinds must equal fresh full parse"
3082        );
3083        // The unclosed fence at row 350 widens to end-of-buffer (~351 lines,
3084        // > 256 cap_abs), so the cap trips and the fallback fires.
3085        assert!(
3086            !v.last_parse_was_incremental,
3087            "fence toggle (unclosed fence, 700-line buffer) should fall back to full rebuild"
3088        );
3089        // Buffer < LARGE_BUFFER_THRESHOLD → sync fallback, no
3090        // pending-async signal.
3091        assert!(
3092            v.take_pending_full_parse().is_none(),
3093            "small-buffer fallback must NOT defer to async"
3094        );
3095    }
3096
3097    #[test]
3098    fn fence_toggle_on_large_buffer_defers_to_async_fallback() {
3099        // Regression for perf #9: above LARGE_BUFFER_THRESHOLD, the
3100        // fallback installs a placeholder ParsedBuffer + signals
3101        // pending instead of blocking the typing thread on
3102        // ParsedBuffer::parse. The owning component spawns the real
3103        // parse on tokio and calls install_full_parse when done.
3104        let mut v = MarkdownEditorView::new();
3105        let mut lines: Vec<String> = (0..1500).map(|i| format!("paragraph {i}")).collect();
3106        update_view(&mut v, &lines, (750, 0), rect(40), 1, None);
3107
3108        // Force a fallback path on a large buffer.
3109        lines.insert(750, "```".to_string());
3110        update_view(&mut v, &lines, (750, 3), rect(40), 2, None);
3111
3112        assert!(
3113            !v.last_parse_was_incremental,
3114            "fence toggle on 1500-line buffer should fall back"
3115        );
3116        let pending = v.take_pending_full_parse();
3117        assert!(
3118            pending.is_some(),
3119            "large-buffer fallback must signal pending async parse"
3120        );
3121        // Placeholder kinds: every row is Plain — no fence detection yet.
3122        assert!(
3123            v.parse_state
3124                .buf()
3125                .kinds
3126                .iter()
3127                .all(|k| matches!(k, super::super::parse_incremental::LineConstructKind::Plain)),
3128            "placeholder must classify every row as Plain"
3129        );
3130        assert_eq!(
3131            v.parse_state.buf().lines.len(),
3132            lines.len(),
3133            "placeholder row count must match input"
3134        );
3135
3136        // Caller (TextEditorComponent in production) spawns the real
3137        // parse and installs the result. Simulate that here.
3138        let real = ParsedBuffer::parse_lines(&lines);
3139        let generation = pending.unwrap();
3140        v.install_full_parse(generation, real);
3141        let fresh = ParsedBuffer::parse_lines(&lines);
3142        assert_eq!(
3143            v.parse_state.buf().kinds,
3144            fresh.kinds,
3145            "post-install kinds must match fresh full parse"
3146        );
3147    }
3148
3149    /// Rows long enough that a real 40-wide wrap would split them into
3150    /// more than one visual line each — needed to tell a `Layout::unwrapped`
3151    /// stub (always exactly one visual line per row) apart from a real
3152    /// compute that happens not to have wrapped anything.
3153    fn make_long_lines(n: usize) -> Vec<String> {
3154        (0..n)
3155            .map(|i| {
3156                format!(
3157                    "paragraph number {i} with quite a bit of extra padding text \
3158                     so this row is longer than forty columns wide for sure"
3159                )
3160            })
3161            .collect()
3162    }
3163
3164    #[test]
3165    fn layout_defers_to_async_fallback_on_large_buffer() {
3166        // Layout-side twin of `fence_toggle_on_large_buffer_defers_to_async_fallback`:
3167        // above LARGE_BUFFER_THRESHOLD, a full-rebuild trigger installs a
3168        // `Layout::unwrapped` stub + signals pending instead of blocking
3169        // the typing thread on `Layout::compute`. The owning component
3170        // spawns the real wrap on tokio and calls install_full_layout
3171        // when done.
3172        let mut v = MarkdownEditorView::new();
3173        let mut lines = make_long_lines(1500);
3174        update_view(&mut v, &lines, (750, 0), rect(40), 1, None);
3175
3176        // Line-count change forces a full layout rebuild regardless of
3177        // what the parse decided.
3178        lines.insert(750, "```".to_string());
3179        update_view(&mut v, &lines, (750, 3), rect(40), 2, None);
3180
3181        let pending = v.take_pending_full_layout();
3182        assert!(
3183            pending.is_some(),
3184            "large-buffer full layout rebuild must signal pending async wrap"
3185        );
3186        assert_eq!(
3187            v.layout.row_count(),
3188            lines.len(),
3189            "stub row count must match input"
3190        );
3191        let real_visual_lines = {
3192            let hints = row_hints(&v.rendered_cache, &v.gutter_insets);
3193            Layout::compute(&v.text_snapshot, 40, Metrics::default(), &hints).visual_line_count()
3194        };
3195        assert!(
3196            v.layout.visual_line_count() < real_visual_lines,
3197            "the installed stub must not have wrapped these long rows yet \
3198             (stub: {}, real: {})",
3199            v.layout.visual_line_count(),
3200            real_visual_lines
3201        );
3202
3203        // Caller (TextEditorComponent in production) spawns the real wrap
3204        // and installs the result. Simulate that here.
3205        let job = pending.unwrap();
3206        let hints = row_hints(&job.rendered_cache, &job.gutter_insets);
3207        let real = Layout::compute(&job.text, job.width, Metrics::default(), &hints);
3208        let real_count = real.visual_line_count();
3209        v.install_full_layout(job.generation, real);
3210        assert!(v.layout_pending.is_none(), "pending cleared on install");
3211        assert_eq!(
3212            v.layout.visual_line_count(),
3213            real_count,
3214            "post-install layout must equal a fresh compute"
3215        );
3216    }
3217
3218    #[test]
3219    fn small_buffer_layout_stays_synchronous() {
3220        // Mirrors `fence_toggle_triggers_full_rebuild_fallback`: below
3221        // LARGE_BUFFER_THRESHOLD, layout rebuilds stay synchronous.
3222        let mut v = MarkdownEditorView::new();
3223        let mut lines = make_long_lines(700);
3224        update_view(&mut v, &lines, (350, 0), rect(40), 1, None);
3225        assert!(
3226            v.take_pending_full_layout().is_none(),
3227            "small buffer must not defer layout on first parse"
3228        );
3229
3230        lines.insert(350, "```".to_string());
3231        update_view(&mut v, &lines, (350, 3), rect(40), 2, None);
3232        assert!(
3233            v.take_pending_full_layout().is_none(),
3234            "small-buffer full rebuild must NOT defer layout to async"
3235        );
3236    }
3237
3238    #[test]
3239    fn edit_while_layout_pending_rearms() {
3240        // Layout-side twin of
3241        // `edit_while_placeholder_active_refuses_incremental_and_rearms`:
3242        // an edit landing before the async wrap resolves must re-stub and
3243        // re-arm for the new generation, and a stale (superseded)
3244        // install must be a no-op.
3245        let mut v = MarkdownEditorView::new();
3246        let mut lines = make_long_lines(1500);
3247        update_view(&mut v, &lines, (750, 0), rect(40), 1, None);
3248        assert!(
3249            v.layout_pending.is_some(),
3250            "first layout defers on a large buffer"
3251        );
3252        assert_eq!(v.take_pending_full_layout().map(|j| j.generation), Some(1));
3253
3254        // Edit before the background wrap resolves.
3255        lines[0].push('x');
3256        update_view(&mut v, &lines, (0, lines[0].len()), rect(40), 2, None);
3257        assert!(
3258            v.layout_pending.is_some(),
3259            "still pending — a content-changing edit must not silently keep the stale stub"
3260        );
3261        assert_eq!(
3262            v.take_pending_full_layout().map(|j| j.generation),
3263            Some(2),
3264            "re-armed for the new generation"
3265        );
3266
3267        // A stale install (superseded generation) must be dropped.
3268        let stale = Layout::compute(&text_of(&lines), 40, Metrics::default(), &[]);
3269        v.install_full_layout(1, stale);
3270        assert!(
3271            v.layout_pending.is_some(),
3272            "stale-generation install must be a no-op"
3273        );
3274
3275        // The current generation's install lands.
3276        let hints = row_hints(&v.rendered_cache, &v.gutter_insets);
3277        let real = Layout::compute(&v.text_snapshot, 40, Metrics::default(), &hints);
3278        v.install_full_layout(2, real);
3279        assert!(
3280            v.layout_pending.is_none(),
3281            "pending cleared on matching-generation install"
3282        );
3283    }
3284
3285    #[test]
3286    fn install_full_layout_rejects_width_mismatch_from_a_resize() {
3287        // A resize with no content change never bumps content_revision, so
3288        // the generation check alone cannot catch a wrap job computed for
3289        // a width the pane no longer has — `install_full_layout` must also
3290        // compare `layout.width()` against the current `last_layout_width`.
3291        let mut v = MarkdownEditorView::new();
3292        let lines = make_long_lines(1200);
3293        update_view(&mut v, &lines, (0, 0), rect(40), 1, None);
3294        let job = v
3295            .take_pending_full_layout()
3296            .expect("large buffer defers layout on first parse");
3297        assert_eq!(job.width, 40);
3298
3299        // Pane resized before the background wrap for width 40 lands.
3300        update_view(
3301            &mut v,
3302            &lines,
3303            (0, 0),
3304            Rect {
3305                width: 80,
3306                ..rect(40)
3307            },
3308            1,
3309            None,
3310        );
3311
3312        let hints = row_hints(&job.rendered_cache, &job.gutter_insets);
3313        let stale_width_layout = Layout::compute(&job.text, job.width, Metrics::default(), &hints);
3314        v.install_full_layout(job.generation, stale_width_layout);
3315        assert!(
3316            v.layout_pending.is_some(),
3317            "width-mismatched install must be rejected even though the generation matches"
3318        );
3319    }
3320
3321    /// Assert the view's cached parse matches a fresh one.
3322    ///
3323    /// The per-line comparison uses `debug_assert_eq_to`, which is
3324    /// `#[cfg(debug_assertions)]` like its one production caller — so the
3325    /// *body* is gated, not the function. Gating the whole helper would remove
3326    /// a symbol six tests call; leaving it ungated stops the lib-test target
3327    /// compiling under any profile with assertions off, which is why
3328    /// `cargo bench --no-run` did not build.
3329    fn full_rebuild_equals_view_state(v: &MarkdownEditorView, lines: &[String]) {
3330        #[cfg(not(debug_assertions))]
3331        let _ = (v, lines);
3332        #[cfg(debug_assertions)]
3333        {
3334            let fresh = ParsedBuffer::parse_lines(lines);
3335            assert_eq!(v.parse_state.buf().kinds, fresh.kinds, "kinds diverge");
3336            assert_eq!(
3337                v.parse_state.buf().lines.len(),
3338                fresh.lines.len(),
3339                "row count diverge"
3340            );
3341            for (i, (got, exp)) in v
3342                .parse_state
3343                .buf()
3344                .lines
3345                .iter()
3346                .zip(fresh.lines.iter())
3347                .enumerate()
3348            {
3349                got.debug_assert_eq_to(exp, i);
3350            }
3351        }
3352    }
3353
3354    #[test]
3355    fn incremental_falls_back_when_fence_marker_modified() {
3356        // Regression: editing a row that is currently a FenceMarker can
3357        // change the fence's extent across the rest of the buffer.
3358        // Incremental parsing's window-bounded widening cannot capture
3359        // this, so we must fall back to a full parse.
3360        let mut v = MarkdownEditorView::new();
3361        let mut lines = vec!["```".to_string(), "".to_string(), "```".to_string()];
3362        // Fill out the buffer with blank lines so the cap doesn't trip first.
3363        for _ in 0..31 {
3364            lines.push(String::new());
3365        }
3366        update_view(&mut v, &lines, (2, 0), rect(40), 1, None);
3367
3368        // Edit the closing fence marker — append a char so it's no longer a closer.
3369        let mut new_lines = lines.clone();
3370        new_lines[2].push('0');
3371        update_view(&mut v, &new_lines, (2, 4), rect(40), 2, None);
3372
3373        assert!(
3374            !v.last_parse_was_incremental,
3375            "fence-marker edit must trigger full-rebuild fallback"
3376        );
3377        // And the resulting state must equal a fresh parse (which the
3378        // fallback path does anyway, but assert defensively).
3379        full_rebuild_equals_view_state(&v, &new_lines);
3380    }
3381
3382    #[test]
3383    fn incremental_paste_large_block_falls_back() {
3384        let mut v = MarkdownEditorView::new();
3385        let mut lines: Vec<String> = (0..50).map(|i| format!("line {i}")).collect();
3386        update_view(&mut v, &lines, (25, 0), rect(40), 1, None);
3387
3388        // Insert 300 lines at row 25.
3389        let payload: Vec<String> = (0..300).map(|i| format!("pasted {i}")).collect();
3390        for (offset, p) in payload.into_iter().enumerate() {
3391            lines.insert(25 + offset, p);
3392        }
3393        update_view(&mut v, &lines, (25, 0), rect(40), 2, None);
3394        assert!(
3395            !v.last_parse_was_incremental,
3396            "300-line paste must fall back"
3397        );
3398        full_rebuild_equals_view_state(&v, &lines);
3399    }
3400
3401    #[test]
3402    fn incremental_enter_at_line_end() {
3403        let mut v = MarkdownEditorView::new();
3404        let lines = vec!["alpha".to_string(), "beta".to_string()];
3405        update_view(&mut v, &lines, (0, 5), rect(40), 1, None);
3406
3407        // Press Enter at end of "alpha".
3408        let new_lines = vec!["alpha".to_string(), "".to_string(), "beta".to_string()];
3409        update_view(&mut v, &new_lines, (1, 0), rect(40), 2, None);
3410        full_rebuild_equals_view_state(&v, &new_lines);
3411    }
3412
3413    #[test]
3414    fn incremental_backspace_merging_lines() {
3415        let mut v = MarkdownEditorView::new();
3416        let lines = vec!["alpha".to_string(), "beta".to_string()];
3417        update_view(&mut v, &lines, (1, 0), rect(40), 1, None);
3418
3419        // Backspace at start of "beta" merges into "alphabeta".
3420        let new_lines = vec!["alphabeta".to_string()];
3421        update_view(&mut v, &new_lines, (0, 5), rect(40), 2, None);
3422        full_rebuild_equals_view_state(&v, &new_lines);
3423    }
3424
3425    #[test]
3426    fn incremental_inside_fence_widens_both_markers() {
3427        let mut v = MarkdownEditorView::new();
3428        let lines = vec![
3429            "intro".to_string(),
3430            "".to_string(),
3431            "```rust".to_string(),
3432            "let x = 1;".to_string(),
3433            "let y = 2;".to_string(),
3434            "```".to_string(),
3435            "".to_string(),
3436            "outro".to_string(),
3437        ];
3438        update_view(&mut v, &lines, (3, 0), rect(40), 1, None);
3439
3440        // Edit inside the fence (same-length, no line-count change).
3441        let mut new_lines = lines.clone();
3442        new_lines[3] = "let x = 999;".to_string();
3443        update_view(&mut v, &new_lines, (3, 8), rect(40), 2, None);
3444        full_rebuild_equals_view_state(&v, &new_lines);
3445    }
3446
3447    #[test]
3448    fn incremental_list_continuation_widens_to_outer_marker() {
3449        let mut v = MarkdownEditorView::new();
3450        let lines = vec![
3451            "- top".to_string(),
3452            "  body of top".to_string(),
3453            "  - nested".to_string(),
3454            "    body of nested".to_string(),
3455            "    body two".to_string(),
3456            "".to_string(),
3457            "outro".to_string(),
3458        ];
3459        update_view(&mut v, &lines, (4, 0), rect(40), 1, None);
3460
3461        // Edit the nested continuation line.
3462        let mut new_lines = lines.clone();
3463        new_lines[4] = "    body two changed".to_string();
3464        update_view(&mut v, &new_lines, (4, 10), rect(40), 2, None);
3465        full_rebuild_equals_view_state(&v, &new_lines);
3466    }
3467
3468    #[test]
3469    fn incremental_setext_underline_edit() {
3470        let mut v = MarkdownEditorView::new();
3471        let lines = vec![
3472            "heading text".to_string(),
3473            "====".to_string(),
3474            "".to_string(),
3475            "body".to_string(),
3476        ];
3477        update_view(&mut v, &lines, (1, 0), rect(40), 1, None);
3478
3479        // Edit the underline (same line count).
3480        let mut new_lines = lines.clone();
3481        new_lines[1] = "======".to_string();
3482        update_view(&mut v, &new_lines, (1, 6), rect(40), 2, None);
3483        full_rebuild_equals_view_state(&v, &new_lines);
3484    }
3485
3486    #[test]
3487    fn incremental_blockquote_paragraph_edit() {
3488        let mut v = MarkdownEditorView::new();
3489        let lines = vec![
3490            "intro".to_string(),
3491            "".to_string(),
3492            "> quoted line one".to_string(),
3493            "> quoted line two".to_string(),
3494            "> quoted line three".to_string(),
3495            "".to_string(),
3496            "outro".to_string(),
3497        ];
3498        update_view(&mut v, &lines, (3, 0), rect(40), 1, None);
3499
3500        let mut new_lines = lines.clone();
3501        new_lines[3] = "> quoted line TWO".to_string();
3502        update_view(&mut v, &new_lines, (3, 17), rect(40), 2, None);
3503        full_rebuild_equals_view_state(&v, &new_lines);
3504    }
3505
3506    #[test]
3507    fn incremental_html_block_edit() {
3508        let mut v = MarkdownEditorView::new();
3509        let lines = vec![
3510            "before".to_string(),
3511            "".to_string(),
3512            "<div>".to_string(),
3513            "body".to_string(),
3514            "</div>".to_string(),
3515            "".to_string(),
3516            "after".to_string(),
3517        ];
3518        update_view(&mut v, &lines, (3, 0), rect(40), 1, None);
3519
3520        let mut new_lines = lines.clone();
3521        new_lines[3] = "body changed".to_string();
3522        update_view(&mut v, &new_lines, (3, 12), rect(40), 2, None);
3523        full_rebuild_equals_view_state(&v, &new_lines);
3524    }
3525
3526    #[test]
3527    fn g1_nested_list_three_indent_continuation() {
3528        // Deeply nested continuation: damaged range touches a 3-indent
3529        // continuation line. Widening must reach the outermost col-0
3530        // ListMarker — otherwise parse_range sees `      text` as
3531        // IndentedCode.
3532        let mut v = MarkdownEditorView::new();
3533        let lines = vec![
3534            "intro".to_string(),
3535            "".to_string(),
3536            "- level 0".to_string(),
3537            "  - level 1".to_string(),
3538            "    - level 2".to_string(),
3539            "      continuation at 6 indent".to_string(),
3540            "".to_string(),
3541            "after".to_string(),
3542        ];
3543        update_view(&mut v, &lines, (5, 0), rect(40), 1, None);
3544
3545        let mut new_lines = lines.clone();
3546        new_lines[5] = "      continuation at 6 indent EDITED".to_string();
3547        update_view(&mut v, &new_lines, (5, 30), rect(40), 2, None);
3548        full_rebuild_equals_view_state(&v, &new_lines);
3549    }
3550
3551    #[test]
3552    fn g3_hashtag_inside_fence_not_labeled_after_incremental_edit() {
3553        // `#tag` inside a fenced code block must NOT produce a Label element.
3554        // After an incremental edit fully inside the fence, the widened
3555        // slice includes both fence markers — the label-suppression scan
3556        // sees the fence and skips. This test verifies the round-trip.
3557        let mut v = MarkdownEditorView::new();
3558        let lines = vec![
3559            "intro".to_string(),
3560            "".to_string(),
3561            "```".to_string(),
3562            "let s = \"#tag\";".to_string(),
3563            "// another #tag".to_string(),
3564            "```".to_string(),
3565            "".to_string(),
3566            "outro".to_string(),
3567        ];
3568        update_view(&mut v, &lines, (4, 0), rect(40), 1, None);
3569
3570        use crate::components::text_editor::markdown::ElementKind;
3571
3572        // Pre-condition: no Label elements in the fence interior.
3573        for row in 3..5 {
3574            let has_label = v.parse_state.buf().lines[row]
3575                .elements
3576                .iter()
3577                .any(|e| matches!(e.kind, ElementKind::Label));
3578            assert!(
3579                !has_label,
3580                "row {row} should have no Label inside the fence"
3581            );
3582        }
3583
3584        // Edit one of the in-fence lines.
3585        let mut new_lines = lines.clone();
3586        new_lines[4] = "// edited #tag here".to_string();
3587        update_view(&mut v, &new_lines, (4, 19), rect(40), 2, None);
3588
3589        // Post-condition: still no Label elements in the fence interior.
3590        for row in 3..5 {
3591            let has_label = v.parse_state.buf().lines[row]
3592                .elements
3593                .iter()
3594                .any(|e| matches!(e.kind, ElementKind::Label));
3595            assert!(
3596                !has_label,
3597                "row {row} should still have no Label after incremental edit"
3598            );
3599        }
3600        full_rebuild_equals_view_state(&v, &new_lines);
3601    }
3602
3603    #[test]
3604    fn g8a_typing_into_empty_buffer() {
3605        let mut v = MarkdownEditorView::new();
3606        let empty = vec!["".to_string()];
3607        update_view(&mut v, &empty, (0, 0), rect(40), 1, None);
3608
3609        let one = vec!["h".to_string()];
3610        update_view(&mut v, &one, (0, 1), rect(40), 2, None);
3611        full_rebuild_equals_view_state(&v, &one);
3612
3613        let two = vec!["he".to_string()];
3614        update_view(&mut v, &two, (0, 2), rect(40), 3, None);
3615        full_rebuild_equals_view_state(&v, &two);
3616
3617        let many = vec!["hello world".to_string()];
3618        update_view(&mut v, &many, (0, 11), rect(40), 4, None);
3619        full_rebuild_equals_view_state(&v, &many);
3620    }
3621
3622    #[test]
3623    fn g8b_delete_last_char_one_line_buffer() {
3624        let mut v = MarkdownEditorView::new();
3625        let one = vec!["h".to_string()];
3626        update_view(&mut v, &one, (0, 1), rect(40), 1, None);
3627
3628        let empty = vec!["".to_string()];
3629        update_view(&mut v, &empty, (0, 0), rect(40), 2, None);
3630        full_rebuild_equals_view_state(&v, &empty);
3631    }
3632
3633    #[test]
3634    fn incremental_text_change_produces_same_layout_as_full_recompute() {
3635        let mut v = MarkdownEditorView::new();
3636        let lines: Vec<String> = (0..200)
3637            .map(|i| format!("paragraph {i} with some text that may wrap depending on width"))
3638            .collect();
3639        update_view(&mut v, &lines, (100, 0), rect(40), 1, None);
3640        let baseline_visual_lines = v.layout.visual_lines().to_vec();
3641
3642        // Edit a paragraph mid-buffer (no line count change).
3643        let mut edited = lines.clone();
3644        edited[100].push_str(" extra text");
3645        update_view(&mut v, &edited, (100, edited[100].len()), rect(40), 2, None);
3646
3647        // After incremental wrap, layout must equal a fresh compute of the edited buffer.
3648        let fresh_text = crate::ropetext::Text::from(edited.join("\n").as_str());
3649        let fresh_hints = row_hints(v.rendered_cache_for_testing(), &[]);
3650        let fresh_layout = Layout::compute(&fresh_text, 40, Metrics::default(), &fresh_hints);
3651
3652        let actual = v.layout.visual_lines();
3653        let fresh = fresh_layout.visual_lines();
3654        assert_eq!(actual.len(), fresh.len(), "visual_lines count diverges");
3655        for (i, (a, f)) in actual.iter().zip(fresh.iter()).enumerate() {
3656            assert_eq!(a, f, "visual line {i} diverges");
3657        }
3658
3659        // Sanity: a row outside the edit should have unchanged visual lines.
3660        let row_50_before = baseline_visual_lines
3661            .iter()
3662            .filter(|vl| vl.logical_row == 50)
3663            .count();
3664        let row_50_after = v
3665            .layout
3666            .visual_lines()
3667            .iter()
3668            .filter(|vl| vl.logical_row == 50)
3669            .count();
3670        assert_eq!(
3671            row_50_before, row_50_after,
3672            "row 50 visual_lines count should be unchanged"
3673        );
3674
3675        assert!(v.last_parse_was_incremental, "expected incremental path");
3676    }
3677
3678    #[test]
3679    fn incremental_edit_reuses_fence_ranges_without_rescanning() {
3680        // A fence block plus plain paragraphs elsewhere. An edit inside a
3681        // plain paragraph (not touching the fence) must take the
3682        // incremental path — at which point `fence_ranges` is skipped
3683        // rather than rescanned, per the structural guards that already
3684        // gate the splice. Verify it stays correct anyway.
3685        let mut v = MarkdownEditorView::new();
3686        let mut lines: Vec<String> = vec![
3687            "```".to_string(),
3688            "code line".to_string(),
3689            "```".to_string(),
3690        ];
3691        lines.extend((0..200).map(|i| format!("paragraph {i} with some text")));
3692        update_view(&mut v, &lines, (100, 0), rect(40), 1, None);
3693
3694        lines[100].push('x');
3695        update_view(&mut v, &lines, (100, lines[100].len()), rect(40), 2, None);
3696        assert!(v.last_parse_was_incremental, "expected incremental path");
3697
3698        let fresh = ParsedBuffer::parse_lines(&lines);
3699        assert_eq!(
3700            v.fence_ranges,
3701            super::super::parse_incremental::fence_ranges_from_kinds(&fresh.kinds),
3702            "fence_ranges must stay correct after a skipped recompute"
3703        );
3704    }
3705
3706    #[test]
3707    fn incremental_edit_patches_only_cursor_rows_of_gutter_insets() {
3708        // Blockquote rows at the top; a content edit far away (row 150)
3709        // combined with the cursor moving between two blockquote rows in
3710        // the same frame. The edit alone keeps the parse incremental; the
3711        // cursor move is what gutter_insets must still react to correctly
3712        // without re-walking every row.
3713        let mut v = MarkdownEditorView::new();
3714        // Blank line after the blockquote so the paragraph run below gets
3715        // its own reset boundary instead of lazily continuing the quote —
3716        // otherwise every row folds into one giant construct and even a
3717        // distant edit falls back to a full rebuild.
3718        let mut lines: Vec<String> = vec![
3719            "> quoted line 0".to_string(),
3720            "> quoted line 1".to_string(),
3721            "> quoted line 2".to_string(),
3722            String::new(),
3723        ];
3724        lines.extend((0..200).map(|i| format!("paragraph {i} with some text")));
3725        update_view(&mut v, &lines, (0, 0), rect(40), 1, None);
3726
3727        lines[151].push('x');
3728        update_view(&mut v, &lines, (1, 0), rect(40), 2, None);
3729        assert!(v.last_parse_was_incremental, "expected incremental path");
3730
3731        let mut fresh_view = MarkdownEditorView::new();
3732        update_view(&mut fresh_view, &lines, (1, 0), rect(40), 1, None);
3733        assert_eq!(
3734            v.gutter_insets_for_testing(),
3735            fresh_view.gutter_insets_for_testing(),
3736            "patched gutter_insets must match a full rebuild"
3737        );
3738    }
3739
3740    #[test]
3741    fn incremental_edit_patches_only_the_touched_code_block_of_code_box_width() {
3742        // Two fenced blocks. Growing a line inside the SECOND block must
3743        // not touch the first block's cached width, and the result must
3744        // match a full rebuild.
3745        let mut v = MarkdownEditorView::new();
3746        let mut lines: Vec<String> = vec![
3747            "```".to_string(),
3748            "short".to_string(),
3749            "```".to_string(),
3750            "paragraph between blocks".to_string(),
3751            "```".to_string(),
3752            "also short".to_string(),
3753            "```".to_string(),
3754        ];
3755        update_view(&mut v, &lines, (5, 0), rect(40), 1, None);
3756        let before_first_block = v.code_box_width_for_testing()[0..3].to_vec();
3757
3758        lines[5].push_str(" grown considerably wider now");
3759        update_view(&mut v, &lines, (5, lines[5].len()), rect(40), 2, None);
3760        assert!(v.last_parse_was_incremental, "expected incremental path");
3761
3762        assert_eq!(
3763            v.code_box_width_for_testing()[0..3],
3764            before_first_block[..],
3765            "the untouched first block's width must be unchanged"
3766        );
3767
3768        let mut fresh_view = MarkdownEditorView::new();
3769        update_view(
3770            &mut fresh_view,
3771            &lines,
3772            (5, lines[5].len()),
3773            rect(40),
3774            1,
3775            None,
3776        );
3777        assert_eq!(
3778            v.code_box_width_for_testing(),
3779            fresh_view.code_box_width_for_testing(),
3780            "patched code_box_width must match a full rebuild"
3781        );
3782    }
3783
3784    #[test]
3785    fn incremental_text_change_does_not_rebuild_all_of_rendered_cache() {
3786        // Verify that after an incremental text edit, rendered_cache rows
3787        // outside the widened range are NOT re-derived from scratch. We
3788        // can't directly observe the rebuild, but we CAN verify the cache
3789        // contents stay correct (matching a full rebuild's output).
3790        let mut v = MarkdownEditorView::new();
3791        let lines: Vec<String> = (0..200)
3792            .map(|i| format!("paragraph {i} with some text"))
3793            .collect();
3794        update_view(&mut v, &lines, (100, 0), rect(40), 1, None);
3795
3796        // Snapshot rendered_cache before the edit.
3797        let before: Vec<Vec<bool>> = v
3798            .rendered_cache
3799            .iter()
3800            .enumerate()
3801            .filter(|(i, _)| *i < 50 || *i > 150)
3802            .map(|(_, v)| v.clone())
3803            .collect();
3804
3805        // Edit a paragraph in the middle.
3806        let mut edited = lines.clone();
3807        edited[100].push('x');
3808        update_view(&mut v, &edited, (100, edited[100].len()), rect(40), 2, None);
3809
3810        // Rows far outside the damaged range must be byte-identical.
3811        let after: Vec<Vec<bool>> = v
3812            .rendered_cache
3813            .iter()
3814            .enumerate()
3815            .filter(|(i, _)| *i < 50 || *i > 150)
3816            .map(|(_, v)| v.clone())
3817            .collect();
3818        assert_eq!(
3819            before, after,
3820            "rendered_cache rows outside damaged range must be unchanged"
3821        );
3822
3823        // The incremental path must have been taken.
3824        assert!(v.last_parse_was_incremental);
3825    }
3826
3827    // Heuristic widener fires on an in-list content edit.
3828    //
3829    // Needs a buffer big enough that strict widener (which on a
3830    // loose list with no interior reset boundaries expands to
3831    // `[0, lines.len()]`) cap-trips, so the edit falls to
3832    // widen_to_safe over the loose-list blanks. With
3833    // MAX_INCREMENTAL_LINES=256 we use ~500 items.
3834
3835    fn make_loose_list(n_items: usize) -> Vec<String> {
3836        let mut out = Vec::with_capacity(n_items * 2);
3837        for i in 0..n_items {
3838            out.push(format!("- item {i}"));
3839            if i + 1 < n_items {
3840                out.push(String::new());
3841            }
3842        }
3843        out
3844    }
3845
3846    #[test]
3847    fn try_incremental_parse_uses_heuristic_on_in_list_edit() {
3848        let mut v = MarkdownEditorView::new();
3849        let lines = make_loose_list(300);
3850        let mid_row = 200;
3851        update_view(&mut v, &lines, (mid_row, 0), rect(20), 1, None);
3852
3853        let mut edited = lines.clone();
3854        edited[mid_row].push('x');
3855        update_view(
3856            &mut v,
3857            &edited,
3858            (mid_row, edited[mid_row].len()),
3859            rect(20),
3860            2,
3861            None,
3862        );
3863
3864        assert!(
3865            v.last_parse_was_incremental,
3866            "edit inside large loose list must take incremental path \
3867             (lazy-guard relaxation + widen_to_safe over the loose-list blanks)"
3868        );
3869        assert_eq!(
3870            v.last_splice_path,
3871            Some(SplicePath::Heuristic),
3872            "expected Heuristic path on large loose list edit, got {:?}",
3873            v.last_splice_path
3874        );
3875    }
3876
3877    // Lazy-guard relaxation must NOT skip when the edit is a
3878    // list-marker flip. The marker-flip guard above the lazy guard
3879    // should bail first, and even if it didn't, the lazy guard's
3880    // kind_qualifies check should also bail since ListMarker is the
3881    // OLD kind but the new line is a different marker (still a list
3882    // marker, so the `looks_like_list_marker` flip check passes —
3883    // both old and new look like list markers; the lazy guard would
3884    // relax). However the kinds-comparison test ensures the edit
3885    // becomes a divergent classification only via the verify path.
3886    //
3887    // Actually re-reading: marker-style flip "- a" → "* a" does NOT
3888    // change `looks_like_list_marker` (both return true). The lazy
3889    // guard relaxation lets it through. The widener attempts splice.
3890    // If the slice's per-row kinds match the parent's, no divergence;
3891    // splice succeeds. If marker-style switches the classification,
3892    // verify catches it.
3893    //
3894    // The scenario "- a" → "* a" produces ListMarker in
3895    // both. Slice parses "* a" alone as a list with `*` marker;
3896    // kinds[0] = ListMarker. Parent had ListMarker too. No
3897    // divergence. Splice succeeds via the heuristic widener.
3898    //
3899    // This test instead asserts the negative: a more-aggressive
3900    // structural change (e.g. removing the marker entirely, turning
3901    // a list row into a Plain row) must bail via the existing
3902    // looks_like_list_marker flip guard (KindGuard bail).
3903    #[test]
3904    fn try_incremental_parse_lazy_guard_still_bails_on_marker_removal() {
3905        let mut v = MarkdownEditorView::new();
3906        let lines: Vec<String> = vec!["- a".into(), "".into(), "- b".into()];
3907        update_view(&mut v, &lines, (0, 3), rect(20), 1, None);
3908
3909        let mut edited = lines.clone();
3910        edited[0] = "a".into(); // remove marker — `- a` → `a`
3911        update_view(&mut v, &edited, (0, 1), rect(20), 2, None);
3912
3913        // The looks_like_list_marker flip guard above the lazy guard
3914        // must bail this case (KindGuard). The lazy-guard relaxation
3915        // never sees it.
3916        assert!(
3917            !v.last_parse_was_incremental,
3918            "list-marker removal must NOT take incremental path \
3919             — looks_like_list_marker flip guard bails first"
3920        );
3921    }
3922
3923    #[test]
3924    fn apply_code_box_sets_bg_and_pads_to_width() {
3925        use ratatui::text::Span;
3926        let theme = crate::settings::themes::Theme::gruvbox_dark();
3927        let spans = vec![Span::raw("ab")]; // 2 cols
3928        let out = super::apply_code_box(spans, 5, &theme);
3929        let total: usize = out.iter().map(|s| s.content.chars().count()).sum();
3930        assert_eq!(total, 5); // padded to box width
3931        let bg = theme.code_bg.to_ratatui();
3932        assert!(out.iter().all(|s| s.style.bg == Some(bg)));
3933    }
3934
3935    #[test]
3936    fn apply_code_box_measures_emoji_cluster_at_full_width() {
3937        // Regression: padding must use the same cluster model as
3938        // `raw_display_width` (which sizes the box). "a❤️" = 'a' (1) + VS16 heart
3939        // (2) = 3 rendered cols. Per-codepoint width undercounts the heart as 1,
3940        // over-padding the box and overshooting box_width. With cluster width the
3941        // content already fills 3 cols, so a box_width of 3 needs zero padding.
3942        use ratatui::text::Span;
3943        let theme = crate::settings::themes::Theme::gruvbox_dark();
3944        let content = "a\u{2764}\u{FE0F}";
3945        assert_eq!(super::super::markdown::raw_display_width(content), 3);
3946        let out = super::apply_code_box(vec![Span::raw(content)], 3, &theme);
3947        // No padding span appended — content already 3 cols.
3948        assert_eq!(out.len(), 1);
3949        assert_eq!(out[0].content.as_ref(), content);
3950    }
3951
3952    #[test]
3953    fn click_on_barred_blockquote_maps_past_gutter() {
3954        // Blockquote on row 0 is NOT the cursor row (cursor parked on row 1),
3955        // so row 0 renders "│ hello". vrow 0 is that row's single visual line.
3956        let lines = vec!["> hello".to_string(), "tail".to_string()];
3957        let view = make_view_for_lines(&lines, (1, 0), 80);
3958        // Click screen col 2 ('h' after the 2-col "│ " gutter) → logical col 2.
3959        let (row, col) = view.click_to_logical_for_testing(0, 2);
3960        assert_eq!((row, col), (0, 2));
3961    }
3962}
3963
3964/// Differential soak for the incremental parse widener.
3965///
3966/// The widener's guards were narrowed to `ListMarker` because a 100 000-case run
3967/// found `Blockquote`, `Plain` and `ListContinuation` producing classifications
3968/// that disagreed with a fresh parse — usually on a row *past* `widened.end`,
3969/// where the in-window post-slice verify does not look. Nothing in the ordinary
3970/// suite reproduces that: unlocking `Blockquote` leaves every test green. This is
3971/// the harness that does not.
3972///
3973/// Ignored by default because 100k cases is minutes, not milliseconds:
3974///
3975/// ```text
3976/// SOAK_CASES=100000 cargo test -p kimun-notes --lib widener_soak -- --ignored --nocapture
3977/// ```
3978///
3979/// **To evaluate an unlock**, widen `kind_qualifies` in `try_incremental_parse`
3980/// to the kind under test and re-run. A green soak is the evidence the guard's
3981/// own comment asks for; anything else is a reason the kind stays excluded.
3982#[cfg(test)]
3983mod widener_soak {
3984    use super::tests::update_view;
3985    use super::*;
3986    use proptest::prelude::*;
3987
3988    /// Blocks, not independent rows.
3989    ///
3990    /// The failure this harness exists to reproduce needs a specific
3991    /// arrangement — a `>` row nested *inside* a list item (so `lazy_depth == 1`)
3992    /// with a blank immediately after it, which is where `damaged.end` lands and
3993    /// where the post-edit parse can flip the row to `ListContinuation`. Rows
3994    /// drawn independently produce blockquotes and lists constantly and that
3995    /// arrangement almost never, which is why the first version of this soak
3996    /// passed 20 000 cases of a configuration known to be wrong.
3997    fn block() -> impl Strategy<Value = Vec<String>> {
3998        prop_oneof![
3999            Just(vec!["plain paragraph text".to_string(), String::new()]),
4000            Just(vec![
4001                "plain paragraph".to_string(),
4002                "second line of it".to_string(),
4003                String::new(),
4004            ]),
4005            // A list holding a quote: the nested-lazy shape.
4006            Just(vec![
4007                "- list item".to_string(),
4008                "  > nested quote".to_string(),
4009                String::new(),
4010            ]),
4011            // The same with a marker carrying only whitespace — the exact row the
4012            // soak's original finding named.
4013            Just(vec![
4014                "- list item".to_string(),
4015                ">     ".to_string(),
4016                String::new(),
4017            ]),
4018            Just(vec![
4019                "- list item".to_string(),
4020                "  continuation".to_string(),
4021                "  > quote inside".to_string(),
4022                String::new(),
4023            ]),
4024            Just(vec![
4025                "> quoted line".to_string(),
4026                "lazy continuation".to_string(),
4027                String::new(),
4028            ]),
4029            Just(vec![">     ".to_string(), String::new()]),
4030            Just(vec!["# heading".to_string(), String::new()]),
4031            Just(vec![
4032                "```".to_string(),
4033                "fenced".to_string(),
4034                "```".to_string(),
4035                String::new(),
4036            ]),
4037            Just(vec!["    indented code".to_string(), String::new()]),
4038            Just(vec![
4039                "setext".to_string(),
4040                "=====".to_string(),
4041                String::new()
4042            ]),
4043        ]
4044    }
4045
4046    /// Edits that keep the row count. Marker-altering ones included: an edit that
4047    /// flips a kind is *supposed* to be refused by the guards above the
4048    /// relaxation, and a soak that only appends letters never tests that.
4049    fn edit(original: &str) -> Vec<String> {
4050        let mut out = vec![
4051            format!("{original}x"),
4052            format!("{original} "),
4053            format!("> {original}"),
4054            format!("  {original}"),
4055        ];
4056        if !original.is_empty() {
4057            out.push(original[..original.len() - 1].to_string());
4058            out.push(original.trim_start().to_string());
4059            out.push(original.replacen('>', " ", 1));
4060            out.push(original.replacen('-', " ", 1));
4061        }
4062        // Only variants that actually change the row. Several of these are
4063        // no-ops on some inputs — `trim_start` on an already-trimmed row,
4064        // `replacen('>')` on a row without one — and filtering here rather than
4065        // rejecting in the test is what keeps proptest from aborting on global
4066        // rejects long before it has explored anything.
4067        out.retain(|candidate| candidate != original);
4068        out.dedup();
4069        out
4070    }
4071
4072    fn cases() -> u32 {
4073        std::env::var("SOAK_CASES")
4074            .ok()
4075            .and_then(|v| v.parse().ok())
4076            .unwrap_or(256)
4077    }
4078
4079    proptest! {
4080        #![proptest_config(ProptestConfig { cases: cases(), ..ProptestConfig::default() })]
4081
4082        #[test]
4083        #[ignore = "soak: run explicitly with SOAK_CASES"]
4084        fn an_incremental_splice_agrees_with_a_fresh_parse(
4085            blocks in prop::collection::vec(block(), 2..8),
4086            row_pick in any::<prop::sample::Index>(),
4087            edit_pick in any::<prop::sample::Index>(),
4088        ) {
4089            let lines: Vec<String> = blocks.concat();
4090            prop_assume!(lines.len() >= 3);
4091            let target = row_pick.index(lines.len());
4092            let variants = edit(&lines[target]);
4093            prop_assume!(!variants.is_empty());
4094            let mut edited = lines.clone();
4095            edited[target] = variants[edit_pick.index(variants.len())].clone();
4096
4097            let mut view = MarkdownEditorView::new();
4098            update_view(&mut view, &lines, (target, 0), Rect::new(0, 0, 40, 20), 1, None);
4099            view.note_damage(target..target + 1, 0);
4100            update_view(&mut view, &edited, (target, 0), Rect::new(0, 0, 40, 20), 2, None);
4101
4102            // A full-parse fallback trivially agrees; only a *wrong splice* fails.
4103            let fresh = ParsedBuffer::parse_lines(&edited);
4104            prop_assert_eq!(
4105                &view.parse_state.buf().kinds,
4106                &fresh.kinds,
4107                "kinds diverged (incremental={}) for {:?} -> row {} = {:?}",
4108                view.last_parse_was_incremental(),
4109                lines,
4110                target,
4111                edited[target]
4112            );
4113            prop_assert_eq!(
4114                &view.parse_state.buf().lazy_depth,
4115                &fresh.lazy_depth,
4116                "lazy_depth diverged (incremental={})",
4117                view.last_parse_was_incremental()
4118            );
4119        }
4120    }
4121}