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 (§5.1). R: edit inside the construct. R+1:
1213            // paragraph eating a would-be IndentedCode start.
1214            //
1215            // §3.0 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` — see the design doc's
1238            // "Blockquote/Plain/ListContinuation unlocks" follow-up.
1239            let lazy = &self.parse_state.buf().lazy_depth;
1240            if lazy.is_empty() {
1241                // (see `needs_downstream_verify` below)
1242                // Defensive: invariant violation (lazy_depth.len() should
1243                // match lines.len()). Count as KindGuard to keep the
1244                // attempted-vs-success accounting consistent.
1245                return METRICS.bail(BailReason::KindGuard);
1246            }
1247            let lo = row.saturating_sub(1);
1248            let hi = (row + 1).min(lazy.len() - 1);
1249            if lazy[lo..=hi].iter().any(|&d| d > 0) {
1250                // §3.0 conditional relaxation — TIGHT VERSION.
1251                // Qualifying conditions (narrowed across two soak
1252                // rounds — see openspec change for the rationale):
1253                //   - old_kind == ListMarker (NOT ListContinuation)
1254                //   - lazy_depth[row] == 1 (top-level list only)
1255                //
1256                // ListContinuation rows are excluded after the 100k
1257                // soak surfaced a case where an edit on a
1258                // ListContinuation row (specifically a `>     ` row
1259                // inside a list, lazy_depth=1) caused the row AT
1260                // `damaged.end` (a blank, lazy_depth=0 in pre-edit)
1261                // to flip to ListContinuation in post-edit fresh
1262                // parse. The strict reset boundary at that row was
1263                // valid pre-edit but became invalid post-edit, and
1264                // the splice chose a widened range based on
1265                // pre-edit boundaries that didn't capture the new
1266                // row past `widened.end`.
1267                //
1268                // ListMarker rows are immune: a content edit on
1269                // "- a" → "- aX" cannot change row+1's classification
1270                // because the row+1 was either (a) Plain → became
1271                // ListContinuation via the post-pass regardless of
1272                // the edit, or (b) Blank/something-else that's outside
1273                // the list and unaffected by item-content changes.
1274                //
1275                // The depth==1 clause blocks edits on lists nested
1276                // inside another lazy construct (a list inside a
1277                // blockquote) where the OUTER construct can shift.
1278                //
1279                // Blockquote / Plain / ListContinuation unlocks remain
1280                // deferred. A post-widening sanity check on
1281                // `widened.end + 1` was the proposed fix — re-parse one
1282                // extra row and compare it against the parent to catch a
1283                // downstream flip. It was built and measured against the
1284                // soak in `widener_soak` below, and it does NOT work:
1285                // with the unlock applied, the soak still diverges on
1286                // `lazy_depth` within a few thousand cases, whether the
1287                // check compares `kinds` alone or `kinds` and
1288                // `lazy_depth` together. The flip lands further out than
1289                // one row, so a fixed one-row lookahead cannot see it.
1290                // Whatever closes this has to bound how far a
1291                // reclassification can travel, or verify to the next
1292                // reset boundary rather than to the next row.
1293                //
1294                // Unlocked kinds and their price. `ListMarker` is safe on its
1295                // own — a content edit on `- a` cannot reclassify row+1 — and a
1296                // 100k soak backs that, so it pays nothing extra. `Blockquote`
1297                // and `ListContinuation` are not safe on their own: they
1298                // reclassify rows past `widened.end`, and they are admitted here
1299                // only because the downstream verify below covers exactly that
1300                // distance. The flag is what keeps that cost on the splices that
1301                // need it rather than on every splice.
1302                let relaxed_kind = matches!(
1303                    old_kind,
1304                    LineConstructKind::Blockquote(_) | LineConstructKind::ListContinuation
1305                );
1306                let kind_qualifies =
1307                    matches!(old_kind, LineConstructKind::ListMarker) || relaxed_kind;
1308                let depth_qualifies = row < lazy.len() && lazy[row] == 1;
1309                if kind_qualifies && depth_qualifies {
1310                    // Don't bail — let blank-transition guard run
1311                    // and reach the widener stage.
1312                    needs_downstream_verify = relaxed_kind;
1313                } else {
1314                    return METRICS.bail(BailReason::LazyDepth);
1315                }
1316            }
1317
1318            // V2 blank-transition guard: a row flipping between blank
1319            // and non-blank invalidates the pre-edit reset boundary
1320            // at that row in the post-edit world (paragraph lazy-
1321            // continuation, empty list-item shapes like `*` that
1322            // parse as ListMarker in slice but as paragraph
1323            // continuation in full). Use the pre-edit `kinds` for
1324            // the "blank" classification instead of `line.trim()` so
1325            // the predicate matches the parser's view exactly.
1326            let old_blank = matches!(old_kind, LineConstructKind::Blank);
1327            let new_blank = new_line.trim().is_empty();
1328            if old_blank != new_blank {
1329                let above_non_blank = row > 0
1330                    && !matches!(
1331                        self.parse_state.buf().kinds[row - 1],
1332                        LineConstructKind::Blank
1333                    );
1334                let below_non_blank = row + 1 < self.parse_state.buf().kinds.len()
1335                    && !matches!(
1336                        self.parse_state.buf().kinds[row + 1],
1337                        LineConstructKind::Blank
1338                    );
1339                if above_non_blank || below_non_blank {
1340                    return METRICS.bail(BailReason::BlankTransition);
1341                }
1342            }
1343        }
1344
1345        // Two-tier widener:
1346        //
1347        //   1. `expand_to_reset_boundary(reset_boundaries, ...)` —
1348        //      strict. Provably equivalent to a fresh parse; no
1349        //      post-slice verify needed.
1350        //   2. `widen_to_safe` — heuristic fallback. NOT provably
1351        //      equivalent; the post-slice verify (below, release-on)
1352        //      is the correctness mechanism and bails to a full
1353        //      rebuild on any divergence.
1354        //
1355        // After a §3.0 relax fires the strict widener usually
1356        // cap-trips (lazy_depth > 0 around the edit means no nearby
1357        // blank-with-depth-0 reset boundary), but we still try strict
1358        // first — it costs only a binary search and succeeds in
1359        // degenerate cases (e.g. small buffers where strict widens
1360        // safely to the whole buffer). On failure we fall to
1361        // widen_to_safe.
1362        //
1363        // A former middle tier (`intra_construct_boundaries`, the V3
1364        // "IntraConstruct" path) was removed: it fired only on loose-
1365        // list edits and `widen_to_safe` covers every such case with
1366        // zero extra full rebuilds (measured), differing only in
1367        // reparse span (~11 vs ~2 rows — both far under the 256 cap).
1368        let mut splice_path = SplicePath::Strict;
1369        let widened = match expand_to_reset_boundary(
1370            &self.parse_state.buf().reset_boundaries,
1371            self.parse_state.buf().lines.len(),
1372            damaged.clone(),
1373        ) {
1374            WidenResult::Widened(r) => r,
1375            WidenResult::FullRebuild => {
1376                match widen_to_safe(&self.parse_state.buf().kinds, damaged.clone()) {
1377                    WidenResult::Widened(r) => {
1378                        splice_path = SplicePath::Heuristic;
1379                        r
1380                    }
1381                    WidenResult::FullRebuild => return METRICS.bail(BailReason::CapTrip),
1382                }
1383            }
1384        };
1385        let slice = ParsedBuffer::parse_range(text, widened.clone());
1386
1387        // Downstream verification, bounded by the next reset boundary.
1388        //
1389        // Only for the kinds admitted by the relaxation above. The in-window
1390        // verify below cannot see a row the splice does not replace, and a
1391        // one-row lookahead is not enough — measured, the flip travels further.
1392        // `reset_boundaries` is the bound that is not a guess: at such a row
1393        // pulldown's state is provably reset, so no reclassification crosses it.
1394        //
1395        // Evidence: with `Blockquote`/`ListContinuation` admitted and this block
1396        // removed, `widener_soak` diverges within a few thousand cases; with it,
1397        // 100 000 cases pass.
1398        if needs_downstream_verify {
1399            let next_boundary = self
1400                .parse_state
1401                .buf()
1402                .reset_boundaries
1403                .iter()
1404                .copied()
1405                .find(|&b| b > widened.end)
1406                .unwrap_or_else(|| text.line_count())
1407                .min(text.line_count());
1408            if next_boundary > widened.end {
1409                let probe = ParsedBuffer::parse_range(text, widened.start..next_boundary);
1410                let parent = self.parse_state.buf();
1411                for row in widened.end..next_boundary {
1412                    let idx = row - widened.start;
1413                    if probe.kinds[idx] != parent.kinds[row]
1414                        || probe.lazy_depth[idx] != parent.lazy_depth[row]
1415                    {
1416                        return METRICS.bail(BailReason::DownstreamFlip);
1417                    }
1418                }
1419            }
1420        }
1421
1422        // Post-slice undamaged-row verification.
1423        //
1424        // - Strict path: skipped. Provably equivalent to a fresh
1425        //   parse (see `reset_boundaries` docstring).
1426        // - Heuristic path: NOT provably equivalent, so this verify
1427        //   is the correctness mechanism and runs in release. It is
1428        //   cheap: `slice` was already parsed above
1429        //   (unconditionally), and the loop only compares
1430        //   kinds/elements.len()/content_vis over the `widened` rows —
1431        //   bounded by the widen cap (≤256), negligible against the
1432        //   parse_range that already ran. A divergence (e.g. a pulldown
1433        //   version bump changing tokenisation) bails to a full rebuild
1434        //   rather than shipping a corrupt splice. The 600k proptest
1435        //   cases (100k × 6 strategies, 0 verify_failed) stay in the
1436        //   regression harness; this guard is the release backstop.
1437        let verify_eligible_path = matches!(splice_path, SplicePath::Heuristic);
1438        if verify_eligible_path {
1439            for row in widened.clone() {
1440                if damaged.contains(&row) {
1441                    continue; // Damaged row: kind change is expected/irrelevant.
1442                }
1443                let idx = row - widened.start;
1444                if slice.kinds[idx] != self.parse_state.buf().kinds[row] {
1445                    return METRICS.bail(BailReason::VerifyFailed);
1446                }
1447                if slice.lines[idx].elements.len()
1448                    != self.parse_state.buf().lines[row].elements.len()
1449                {
1450                    return METRICS.bail(BailReason::VerifyFailed);
1451                }
1452                if slice.lines[idx].content_vis != self.parse_state.buf().lines[row].content_vis {
1453                    return METRICS.bail(BailReason::VerifyFailed);
1454                }
1455            }
1456        }
1457
1458        METRICS.ok(match splice_path {
1459            SplicePath::Strict => SuccessPath::ResetBoundary,
1460            SplicePath::Heuristic => SuccessPath::WidenToSafe,
1461        });
1462        Some((widened, slice, splice_path))
1463    }
1464
1465    pub fn render(
1466        &mut self,
1467        f: &mut Frame,
1468        rect: Rect,
1469        theme: &Theme,
1470        focused: bool,
1471        cursor_shape: Option<CursorShape>,
1472    ) {
1473        if rect.height == 0 {
1474            return;
1475        }
1476        let text = &self.text_snapshot;
1477        let cursor = self.cursor_snapshot;
1478        let scroll = self.viewport.top();
1479        let height = rect.height as usize;
1480        let vlines = self.layout.visual_lines();
1481
1482        let parsed_lines = &self.parse_state.buf().lines;
1483        let fence_ranges = &self.fence_ranges;
1484
1485        // The rows the visible lines draw from, materialised for this frame.
1486        // Bounded by the pane's height rather than the note's length, and needed
1487        // because the spans below borrow their row and outlive the closure that
1488        // builds them.
1489        let window: Vec<String> = vlines
1490            .iter()
1491            .skip(scroll)
1492            .take(height)
1493            .map(|vl| text.line(vl.logical_row).unwrap_or_default().into_owned())
1494            .collect();
1495
1496        let visible: Vec<Line> = vlines
1497            .iter()
1498            .skip(scroll)
1499            .take(height)
1500            .zip(window.iter())
1501            .map(|(vl, row_text)| {
1502                let cursor_col = if vl.logical_row == cursor.0 {
1503                    Some(cursor.1)
1504                } else {
1505                    None
1506                };
1507                let force_raw = fence_ranges.iter().any(|r| r.contains(&vl.logical_row));
1508                // Snapshot invariant: every `vl.logical_row` is < lines.len()
1509                // because `layout` and `lines_snapshot` were rebuilt from
1510                // the same `EditorSnapshot` in the last `update()`.
1511                let logical_line = row_text.as_str();
1512                let parsed = &parsed_lines[vl.logical_row];
1513                let content = &logical_line[vl.bytes.clone()];
1514                let spans = MarkdownSpanner::render_with(
1515                    content,
1516                    logical_line,
1517                    parsed,
1518                    vl.chars.start,
1519                    cursor_col,
1520                    vl.first,
1521                    force_raw,
1522                    rect.width,
1523                    theme,
1524                );
1525
1526                // Apply code-block background before selection so selection bg wins on selected text.
1527                let spans =
1528                    if let Some(bw) = self.code_box_width.get(vl.logical_row).copied().flatten() {
1529                        apply_code_box(spans, bw, theme)
1530                    } else {
1531                        spans
1532                    };
1533
1534                // Every highlight this row carries, painted in one pass.
1535                // `OverlayKind`'s declaration order is the stacking order, so
1536                // "preview wins over selection" is a property of the enum
1537                // rather than of where the code happens to sit.
1538                let spans = {
1539                    // Skip the hidden `> ` and add the bar back, rather than
1540                    // zeroing the offset and letting the mapper credit the
1541                    // sigils as the cells the bar occupies. The credit is exact
1542                    // only for clusters whose width is column-independent: a tab
1543                    // consumes it, measuring to a nearer stop from the inflated
1544                    // column, and every overlay at or after it lands short by
1545                    // the bar. `click_to_logical_u16` already skips rather than
1546                    // credits — this is the same basis, in the same direction.
1547                    let gutter_off = self.gutter_insets.get(vl.logical_row).copied().unwrap_or(0);
1548                    let effective_start_col = if gutter_off > 0 && vl.first {
1549                        parsed.blockquote_sigil_end().unwrap_or(vl.chars.start)
1550                    } else {
1551                        vl.chars.start
1552                    };
1553                    let to_rendered = |col: usize| {
1554                        MarkdownSpanner::rendered_col_with_reveal(
1555                            logical_line,
1556                            parsed,
1557                            effective_start_col,
1558                            col,
1559                            cursor_col,
1560                            vl.first,
1561                            force_raw,
1562                        ) + gutter_off
1563                    };
1564                    let mut row_overlays: Vec<&Overlay> = self
1565                        .overlays
1566                        .iter()
1567                        .filter(|o| o.row == vl.logical_row)
1568                        .collect();
1569                    row_overlays.sort_by_key(|o| o.kind);
1570
1571                    let mut spans = spans;
1572                    for o in row_overlays {
1573                        let start = to_rendered(o.start);
1574                        let mut end = to_rendered(o.end);
1575                        // A zero-width overlay would paint nothing — which is
1576                        // exactly the case where the user most needs to see
1577                        // where they are (an empty replacement previews a match
1578                        // as nothing at all). Give it one cell, like a caret.
1579                        if end == start && o.kind == OverlayKind::PreviewCurrent {
1580                            end = start + 1;
1581                        }
1582                        spans =
1583                            restyle_over_range(spans, start..end, &|st| o.kind.restyle(theme, st));
1584                    }
1585                    spans
1586                };
1587
1588                Line::from(spans)
1589            })
1590            .collect();
1591
1592        f.render_widget(
1593            Paragraph::new(Text::from(visible)).style(theme.base_style()),
1594            rect,
1595        );
1596
1597        // Draw terminal cursor when focused. The `EditorSnapshot` the
1598        // last `update()` consumed guarantees `cursor.0` is in-bounds
1599        // for `parsed_buffer.lines` and `layout.visual_lines()` —
1600        // both were rebuilt from the same snapshot. The single
1601        // remaining edge case is an empty buffer (no rows at all),
1602        // handled by the early `is_empty` short-circuit below; the
1603        // previous defensive `.get()` chain (commit c03dc728) was
1604        // there to absorb stale Nvim snapshots where cursor outran
1605        // lines, which the snapshot invariant now rules out.
1606        self.last_cursor_screen = None;
1607        let mut desired_style: Option<CursorShape> = None;
1608        if focused
1609            && !self.parse_state.buf().lines.is_empty()
1610            && !self.layout.visual_lines().is_empty()
1611        {
1612            let cursor_vrow = self.cursor_vrow;
1613            if cursor_vrow >= scroll && cursor_vrow < scroll + height {
1614                let vl = &self.layout.visual_lines()[cursor_vrow];
1615                let parsed = &self.parse_state.buf().lines[cursor.0];
1616                // Snapshot invariant + outer `!is_empty()` guard: cursor.0
1617                // is in-bounds for `lines_snapshot` here.
1618                let row_text = text.line(cursor.0).unwrap_or_default();
1619                let logical_line = row_text.as_ref();
1620                let force_raw = self.is_in_code_block(cursor.0);
1621                let rendered_col = MarkdownSpanner::rendered_cursor_col_with(
1622                    logical_line,
1623                    parsed,
1624                    vl.chars.start,
1625                    cursor.1,
1626                    vl.first,
1627                    force_raw,
1628                );
1629                let cx = rect.x + rendered_col as u16;
1630                let cy = rect.y + (cursor_vrow - scroll) as u16;
1631                f.set_cursor_position(Position { x: cx, y: cy });
1632                self.last_cursor_screen = Some((cx, cy));
1633                desired_style = cursor_shape;
1634            }
1635        }
1636        if desired_style != self.applied_cursor_style {
1637            use ratatui::crossterm::cursor::SetCursorStyle;
1638            let style = match desired_style {
1639                Some(CursorShape::Block) => SetCursorStyle::SteadyBlock,
1640                Some(CursorShape::Bar) => SetCursorStyle::SteadyBar,
1641                None => SetCursorStyle::DefaultUserShape,
1642            };
1643            let _ = ratatui::crossterm::execute!(std::io::stdout(), style);
1644            self.applied_cursor_style = desired_style;
1645        }
1646    }
1647
1648    /// Test accessor: the kinds vector of the current parsed buffer.
1649    /// Used by the proptest harness to assert incremental = full parse.
1650    pub fn parsed_buffer_kinds(&self) -> &[super::parse_incremental::LineConstructKind] {
1651        &self.parse_state.buf().kinds
1652    }
1653
1654    /// Test accessor: the parsed lines of the current parsed buffer.
1655    pub fn parsed_buffer_lines(&self) -> &[super::markdown::ParsedLine] {
1656        &self.parse_state.buf().lines
1657    }
1658
1659    /// Test accessor: the rendered-position bitmask cache.
1660    /// Used by tests to construct a fresh `WordWrapLayout` from the same
1661    /// masks the view is using, for equivalence checks.
1662    #[cfg(test)]
1663    pub(crate) fn rendered_cache_for_testing(&self) -> &[Vec<bool>] {
1664        &self.rendered_cache
1665    }
1666
1667    #[cfg(test)]
1668    pub(crate) fn code_box_width_for_testing(&self) -> &[Option<u16>] {
1669        &self.code_box_width
1670    }
1671
1672    #[cfg(test)]
1673    pub(crate) fn gutter_insets_for_testing(&self) -> &[usize] {
1674        &self.gutter_insets
1675    }
1676
1677    fn is_in_code_block(&self, row: usize) -> bool {
1678        // Every line inside any fenced block renders force-raw (no markdown
1679        // re-styling, distinct fg color). Previously this checked only the
1680        // fence the cursor was sitting in, so fenced blocks elsewhere in
1681        // the buffer looked like plain text until the cursor moved into
1682        // them.
1683        self.fence_ranges.iter().any(|r| r.contains(&row))
1684    }
1685
1686    /// Rebuild `code_box_width` from the current parse kinds and snapshot
1687    /// lines. Box width per block = max rendered display width of its lines,
1688    /// capped at `width`.
1689    fn rebuild_code_box_width(&mut self, text: &crate::ropetext::Text, width: u16) {
1690        let mut out = vec![None; text.line_count()];
1691        let ranges =
1692            super::parse_incremental::code_block_ranges_from_kinds(&self.parse_state.buf().kinds);
1693        for r in ranges {
1694            let mut max_w = 0usize;
1695            for row in r.clone() {
1696                if let Some(line) = text.line(row) {
1697                    max_w = max_w.max(super::markdown::raw_display_width(&line));
1698                }
1699            }
1700            let boxed = (max_w.min(width as usize)) as u16;
1701            for row in r {
1702                if row < out.len() {
1703                    out[row] = Some(boxed);
1704                }
1705            }
1706        }
1707        self.code_box_width = out;
1708    }
1709
1710    /// Update `code_box_width` for just the code-block range(s) overlapping
1711    /// `damaged` — the incremental-path sibling of `rebuild_code_box_width`.
1712    /// Safe because the structural guards in `try_incremental_parse` already
1713    /// refuse to splice an edit that adds, removes, or moves a code-block
1714    /// boundary; a block that doesn't overlap the edit can only have kept
1715    /// the same lines it had before, so its width can't have changed. A
1716    /// block's own content growing or shrinking *can* change its width, and
1717    /// that only happens inside `damaged`.
1718    fn patch_code_box_width(
1719        &mut self,
1720        text: &crate::ropetext::Text,
1721        width: u16,
1722        damaged: std::ops::Range<usize>,
1723    ) {
1724        let ranges =
1725            super::parse_incremental::code_block_ranges_from_kinds(&self.parse_state.buf().kinds);
1726        for r in ranges {
1727            if r.start >= damaged.end || r.end <= damaged.start {
1728                continue; // no overlap — this block's width can't have changed
1729            }
1730            let mut max_w = 0usize;
1731            for row in r.clone() {
1732                if let Some(line) = text.line(row) {
1733                    max_w = max_w.max(super::markdown::raw_display_width(&line));
1734                }
1735            }
1736            let boxed = (max_w.min(width as usize)) as u16;
1737            for row in r {
1738                if let Some(entry) = self.code_box_width.get_mut(row) {
1739                    *entry = Some(boxed);
1740                }
1741            }
1742        }
1743    }
1744
1745    /// Rebuild `gutter_insets` from parse state + cursor. A blockquote row
1746    /// that is not the cursor row reserves `depth + 1` cols for the bar; the
1747    /// cursor row reserves 0 (its markers are revealed raw). Full
1748    /// `O(row_count)` rebuild — see `patch_gutter_insets` for the
1749    /// incremental-path sibling that only touches the rows that can
1750    /// plausibly have changed.
1751    fn rebuild_gutter_insets(&mut self, row_count: usize, cursor_row: usize) {
1752        let parsed = &self.parse_state.buf().lines;
1753        self.gutter_insets = (0..row_count)
1754            .map(|row| {
1755                if row == cursor_row {
1756                    return 0;
1757                }
1758                match parsed.get(row).and_then(|p| p.blockquote_depth()) {
1759                    Some(d) => super::markdown::blockquote_gutter_width(d),
1760                    None => 0,
1761                }
1762            })
1763            .collect();
1764    }
1765
1766    /// Update `gutter_insets` for exactly `rows`, in place. Safe whenever
1767    /// the parse took the incremental splice path: the structural guards in
1768    /// `try_incremental_parse` (opener-shape / lazy-depth) already refuse
1769    /// to splice an edit that could change a row's blockquote depth, so the
1770    /// only thing that can legitimately change `gutter_insets` between two
1771    /// incrementally-linked frames is which row the cursor is on.
1772    fn patch_gutter_insets(&mut self, rows: &[usize], cursor_row: usize) {
1773        let parsed = &self.parse_state.buf().lines;
1774        for &row in rows {
1775            let inset = if row == cursor_row {
1776                0
1777            } else {
1778                match parsed.get(row).and_then(|p| p.blockquote_depth()) {
1779                    Some(d) => super::markdown::blockquote_gutter_width(d),
1780                    None => 0,
1781                }
1782            };
1783            if let Some(entry) = self.gutter_insets.get_mut(row) {
1784                *entry = inset;
1785            }
1786        }
1787    }
1788
1789    /// Markdown-aware mouse click: maps a rendered screen column to
1790    /// the correct logical column, accounting for hidden markdown
1791    /// sigils (links, bold markers, etc.).
1792    ///
1793    /// Reads `self`'s view-internal caches (`layout`, `lines_snapshot`,
1794    /// `parsed_buffer`), all rebuilt from the same `EditorSnapshot`
1795    /// in the last `update()` call. The snapshot invariant guarantees
1796    /// `vl.logical_row` is a valid index into both `lines_snapshot`
1797    /// and `parsed_buffer.lines`, so direct indexing is safe — the
1798    /// previous defensive `(Some, Some) else fallback` block (Fix #2
1799    /// in the holistic review) is no longer needed.
1800    /// Map a screen-relative click (row/col offset from the editor's
1801    /// top-left corner) to logical (row, col). Owns the
1802    /// visual-scroll-offset arithmetic so callers do not reach into
1803    /// viewport's top — the view knows where it is scrolled.
1804    pub fn click_at_screen(&self, screen_row: usize, screen_col: usize) -> (u16, u16) {
1805        let vrow = screen_row + self.viewport.top();
1806        self.click_to_logical_u16(vrow, screen_col)
1807    }
1808
1809    fn click_to_logical_u16(&self, vrow: usize, vcol: usize) -> (u16, u16) {
1810        let vlines = self.layout.visual_lines();
1811        if vlines.is_empty() {
1812            return (0, 0);
1813        }
1814        let vrow = vrow.min(vlines.len() - 1);
1815        let vl = &vlines[vrow];
1816        let row_u16 = vl.logical_row.min(u16::MAX as usize) as u16;
1817        let row_text = self.text_snapshot.line(vl.logical_row).unwrap_or_default();
1818        let logical_line = row_text.as_ref();
1819        let parsed = &self.parse_state.buf().lines[vl.logical_row];
1820        let force_raw = self.is_in_code_block(vl.logical_row);
1821        let gutter = self
1822            .gutter_insets
1823            .get(vl.logical_row)
1824            .copied()
1825            .unwrap_or(self.cursor_vrow);
1826        let vcol = vcol.saturating_sub(gutter);
1827        // When a blockquote gutter is drawn (gutter > 0), the ">" and space
1828        // sigil chars are hidden and replaced by the "│ " bar. On the first
1829        // visual line, skip those hidden sigil chars so that rendered_col 0
1830        // maps to the first content char, not to the hidden ">".
1831        let effective_start_col = if gutter > 0 && vl.first {
1832            parsed.blockquote_sigil_end().unwrap_or(vl.chars.start)
1833        } else {
1834            vl.chars.start
1835        };
1836        // The same rule the render loop uses to decide `cursor_col`: only the
1837        // caret's own row is revealed, so only there does the mapping have to
1838        // account for a revealed element's sigils occupying cells.
1839        let reveal_col =
1840            (vl.logical_row == self.cursor_snapshot.0).then_some(self.cursor_snapshot.1);
1841        let logical_col = MarkdownSpanner::rendered_col_to_logical_with(
1842            logical_line,
1843            parsed,
1844            effective_start_col,
1845            vcol,
1846            reveal_col,
1847            vl.first,
1848            force_raw,
1849        );
1850        // Clamp to the visual line clicked. `rendered_col_to_logical_with` maps a
1851        // cell to a column in the whole logical row, so a click in the blank
1852        // space right of a soft-wrapped line walks straight into the span of the
1853        // line below and the cursor lands a row further on than the one under the
1854        // pointer. `crate::ropetext::Layout::position_at_cell` clamps for this reason;
1855        // this is the TUI's own mapper and had drifted from it.
1856        let logical_col = logical_col.min(vl.chars.end);
1857        let col = logical_col.min(u16::MAX as usize) as u16;
1858        (row_u16, col)
1859    }
1860
1861    #[cfg(test)]
1862    pub(crate) fn click_to_logical_for_testing(&self, vrow: usize, vcol: usize) -> (u16, u16) {
1863        self.click_to_logical_u16(vrow, vcol)
1864    }
1865}
1866
1867impl Default for MarkdownEditorView {
1868    fn default() -> Self {
1869        Self::new()
1870    }
1871}
1872
1873/// Returns the byte offset into `s` after consuming exactly `target_width` display columns.
1874/// If `target_width` exceeds the string's display width, returns `s.len()`.
1875///
1876/// Walks whole grapheme clusters (not codepoints) and measures each with
1877/// [`super::markdown::cluster_display_width`], so the result never lands mid-cluster (which would
1878/// split an emoji across two styled spans) and stays consistent with the width
1879/// model used by wrap and cursor math — an emoji presentation sequence (flag,
1880/// VS16 heart, keycap) counts as its full rendered width, not its first codepoint.
1881fn byte_offset_for_display_width(s: &str, target_width: usize) -> usize {
1882    use super::markdown::cluster_display_width;
1883    use unicode_segmentation::UnicodeSegmentation;
1884    let mut consumed = 0usize;
1885    for (byte_pos, g) in s.grapheme_indices(true) {
1886        if consumed >= target_width {
1887            return byte_pos;
1888        }
1889        consumed += cluster_display_width(g);
1890    }
1891    s.len()
1892}
1893
1894/// Split `spans` at the boundaries of a rendered-column range and apply
1895/// `restyle` to the overlapping portion. The one place column-to-byte
1896/// accounting for a partial restyle lives.
1897fn restyle_over_range<'a>(
1898    spans: Vec<ratatui::text::Span<'a>>,
1899    sel_cols: std::ops::Range<usize>,
1900    restyle: &dyn Fn(ratatui::style::Style) -> ratatui::style::Style,
1901) -> Vec<ratatui::text::Span<'a>> {
1902    if sel_cols.is_empty() {
1903        return spans;
1904    }
1905    let mut result = Vec::new();
1906    let mut col = 0usize;
1907
1908    for span in spans {
1909        let content: &str = &span.content;
1910        // Same cluster-based width model as `byte_offset_for_display_width`
1911        // below, so column accounting and the byte boundaries it computes can
1912        // never disagree on emoji presentation sequences.
1913        let span_width = super::markdown::string_display_width(content);
1914        let span_end = col + span_width;
1915
1916        let overlap_start = sel_cols.start.max(col);
1917        let overlap_end = sel_cols.end.min(span_end);
1918
1919        if overlap_start >= overlap_end {
1920            // No overlap — emit as-is.
1921            result.push(span);
1922        } else {
1923            // Walk grapheme clusters by display width to find byte boundaries.
1924            let prefix_width = overlap_start - col;
1925            let selected_width = overlap_end - overlap_start;
1926
1927            let prefix_byte = byte_offset_for_display_width(content, prefix_width);
1928            let selected_byte_end =
1929                byte_offset_for_display_width(&content[prefix_byte..], selected_width)
1930                    + prefix_byte;
1931
1932            // Prefix (before selection)
1933            if prefix_byte > 0 {
1934                result.push(ratatui::text::Span::styled(
1935                    content[..prefix_byte].to_string(),
1936                    span.style,
1937                ));
1938            }
1939            // Selected portion
1940            result.push(ratatui::text::Span::styled(
1941                content[prefix_byte..selected_byte_end].to_string(),
1942                restyle(span.style),
1943            ));
1944            // Suffix (after selection)
1945            if selected_byte_end < content.len() {
1946                result.push(ratatui::text::Span::styled(
1947                    content[selected_byte_end..].to_string(),
1948                    span.style,
1949                ));
1950            }
1951        }
1952
1953        col = span_end;
1954    }
1955
1956    result
1957}
1958
1959/// Paint `code_bg` behind every span of a code-block visual line and pad the
1960/// line with bg-colored spaces up to `box_width` display columns, producing a
1961/// solid rectangle hugging the block's widest line. Content already wider than
1962/// the box (the box was capped at editor width; wider rows wrap) is left as-is.
1963fn apply_code_box<'a>(
1964    spans: Vec<ratatui::text::Span<'a>>,
1965    box_width: u16,
1966    theme: &Theme,
1967) -> Vec<ratatui::text::Span<'a>> {
1968    use ratatui::text::Span;
1969    use unicode_segmentation::UnicodeSegmentation;
1970    let bg = theme.code_bg.to_ratatui();
1971    // Measure with the same cluster + tab-aware model as `raw_display_width`
1972    // (which sizes `box_width` in `rebuild_code_box_width`), so the padding
1973    // can never disagree with the target on emoji presentation sequences or
1974    // tabs. `cluster_width_at` needs the running column for tab stops.
1975    let mut width = 0usize;
1976    let mut out: Vec<Span<'a>> = spans
1977        .into_iter()
1978        .map(|s| {
1979            for g in s.content.graphemes(true) {
1980                width += super::markdown::cluster_width_at(g, width);
1981            }
1982            let style = s.style.bg(bg);
1983            Span::styled(s.content, style)
1984        })
1985        .collect();
1986    let target = box_width as usize;
1987    if width < target {
1988        out.push(Span::styled(
1989            " ".repeat(target - width),
1990            Style::default().bg(bg),
1991        ));
1992    }
1993    out
1994}
1995
1996/// Per-row hints for the layout: what the syntax layer draws, and how far each
1997/// row is inset by its gutter.
1998///
1999/// Built per rebuild rather than stored, because both halves already live on the
2000/// view and a third copy would be a third thing to keep in step.
2001///
2002/// `pub(super)`: the background wrap task spawned by the owning
2003/// `TextEditorComponent` (`mod.rs`) rebuilds the same hints from a
2004/// [`PendingLayoutJob`]'s owned `rendered_cache`/`gutter_insets` clones —
2005/// `RowHints` borrows, so it cannot cross the `tokio::spawn` boundary
2006/// itself and has to be reconstructed on the other side from owned data.
2007pub(super) fn row_hints<'a>(rendered: &'a [Vec<bool>], insets: &'a [usize]) -> Vec<RowHints<'a>> {
2008    let rows = rendered.len().max(insets.len());
2009    (0..rows)
2010        .map(|row| RowHints {
2011            visible: rendered.get(row).map(Vec::as_slice).unwrap_or(&[]),
2012            inset: insets.get(row).copied().unwrap_or(0),
2013        })
2014        .collect()
2015}
2016
2017#[cfg(test)]
2018mod tests {
2019    use super::*;
2020    use ratatui::layout::Rect;
2021    use std::num::NonZeroU64;
2022
2023    fn rect(h: u16) -> Rect {
2024        Rect {
2025            x: 0,
2026            y: 0,
2027            width: 40,
2028            height: h,
2029        }
2030    }
2031
2032    /// Test-only wrapper that builds an `EditorSnapshot::borrowed`
2033    /// from the legacy `(lines, cursor, generation)` shape, so the
2034    /// hundreds of existing call sites don't each have to construct
2035    /// the snapshot inline.
2036    ///
2037    /// Mirrors `snapshot_from_backend`'s producer-side cursor clamp,
2038    /// so tests that pass an intentionally-stale `cursor` (e.g. the
2039    /// regression for the Nvim shrink panic) still exercise the
2040    /// real production path: producer clamps, render trusts.
2041    /// Tests describe buffers as rows; the view takes the text they make up.
2042    fn text_of(lines: &[String]) -> crate::ropetext::Text {
2043        crate::ropetext::Text::from(lines.join("\n").as_str())
2044    }
2045
2046    /// Two reports between one pair of frames, the second changing the line
2047    /// count above the first — the first report's row has moved by the time the
2048    /// hull is used.
2049    #[test]
2050    fn damage_reported_twice_across_a_line_change_is_renumbered() {
2051        let mut v = MarkdownEditorView::new();
2052        // An edit at row 10, then a newline inserted at row 0, which pushes the
2053        // first edit's row down to 11.
2054        v.note_damage(10..11, 0);
2055        v.note_damage(0..2, 1);
2056        let hull = v.reported_damage.clone().expect("both edits were reported");
2057        assert!(
2058            hull.contains(&11),
2059            "row 10 became row 11; the hull reported was {hull:?}"
2060        );
2061    }
2062
2063    /// Does the engine's `position_at_cell` agree with the TUI's own click
2064    /// mapper?
2065    ///
2066    /// The two compute the same thing by different routes — the TUI walks
2067    /// rendered columns through `MarkdownSpanner`, the engine walks the same
2068    /// information as `RowHints` (a visibility mask plus a gutter inset). Keeping
2069    /// two of these in sync by hand is what let the wrap-clamp drift out of the
2070    /// TUI copy in the first place. This says where they still differ, and is the
2071    /// gate for deleting one of them.
2072    ///
2073    /// **Currently fails, with six disagreements of exactly two kinds** — and
2074    /// neither is an algorithmic mismatch. The engine does honour concealment;
2075    /// `cell_of` skips masked-out chars. What it lacks is data the TUI mapper
2076    /// holds:
2077    ///
2078    /// 1. **Blockquote sigil skip.** On `> quoted ...`, cells 0-2 map to column 2
2079    ///    in the TUI (it skips the `> ` via `blockquote_sigil_end` on a first
2080    ///    visual line) and to 0 in the engine, which applies only `inset`. Mark
2081    ///    those sigil chars invisible in `rendered_cache` and the engine reaches
2082    ///    2 on its own.
2083    /// 2. **Tie-breaking at the edge of a concealed run** — which side a click
2084    ///    between a hidden run and its neighbour falls to. Needs stating as a
2085    ///    rule in `position_at_cell`, not reproducing.
2086    ///
2087    /// A translation layer between the two mappers is the wrong answer — it adds
2088    /// the seam this exists to remove. But so, for now, is fixing the hints:
2089    /// `row_hints` feeds `Layout::compute`/`relayout_rows` at six sites, so the
2090    /// visibility mask is the **wrapping** input. Marking the sigil chars
2091    /// invisible would change where every line breaks, editor-wide, to fix where
2092    /// clicks land. The render snapshots would catch it, but that is a re-wrap,
2093    /// not a tidy-up.
2094    ///
2095    /// So this test's job is to be a tripwire, not a plan: it fails if the two
2096    /// mappers drift *further* apart. Unify them only when the mask has to change
2097    /// for some other reason, at which point it comes along nearly free. Run with
2098    /// `--ignored`.
2099    ///
2100    /// (An earlier version of this test reported 23 disagreements and concluded
2101    /// the engine ignored concealment. It parked the cursor on the row under
2102    /// test, and the cursor's row is *revealed* — so it compared concealment
2103    /// against its own suspension.)
2104    /// Now green, and it is the precondition for deleting `click_to_logical_u16`:
2105    /// the engine's mapper may replace the TUI's exactly when the two agree.
2106    /// Closing the last six took a change on each side — the TUI resolving a
2107    /// cell to the drawn column *after* a concealed run rather than to the run's
2108    /// head, and `position_at_cell` dropping a short circuit that returned the
2109    /// row's first char for any cell inside the inset, skipping the very loop
2110    /// that walks past a blockquote's hidden `> `.
2111    #[test]
2112    fn the_engine_and_the_tui_click_mappers_agree() {
2113        let corpus: Vec<Vec<String>> = vec![
2114            vec!["plain short".to_string()],
2115            vec!["a long paragraph that certainly wraps more than once here".to_string()],
2116            vec!["> quoted line that is long enough to wrap at this width".to_string()],
2117            vec!["- list item with enough text on it to wrap somewhere".to_string()],
2118            vec!["**bold** and *italic* markers that get concealed".to_string()],
2119            vec!["# heading that runs on long enough to wrap around".to_string()],
2120        ];
2121
2122        let mut disagreements = Vec::new();
2123        for lines in &corpus {
2124            // Park the cursor on an appended trailing row: the cursor's row is
2125            // *revealed* (sigils shown raw), so testing the row it sits on
2126            // compares concealment against its own suspension.
2127            let mut lines = lines.clone();
2128            lines.push(String::new());
2129            let park = lines.len() - 1;
2130            let mut v = MarkdownEditorView::new();
2131            update_view(&mut v, &lines, (park, 0), rect(20), 1, None);
2132            let text = v.text_snapshot.clone();
2133            let hints = row_hints(&v.rendered_cache, &v.gutter_insets);
2134            for vrow in 0..v.layout.visual_lines().len() {
2135                for vcol in 0..24 {
2136                    let (tui_row, tui_col) = v.click_to_logical_for_testing(vrow, vcol);
2137                    let engine = v.layout.position_at_cell(
2138                        &text,
2139                        &hints,
2140                        crate::ropetext::Cell {
2141                            row: vrow,
2142                            column: vcol,
2143                        },
2144                    );
2145                    let engine = engine.map(|p| (p.row() as u16, p.column().get() as u16));
2146                    if engine != Some((tui_row, tui_col)) {
2147                        disagreements.push(format!(
2148                            "{:?} vrow={vrow} vcol={vcol}: tui={:?} engine={:?}",
2149                            lines[0],
2150                            (tui_row, tui_col),
2151                            engine
2152                        ));
2153                    }
2154                }
2155            }
2156        }
2157        assert!(
2158            disagreements.is_empty(),
2159            "{} disagreements, first 5:\n{}",
2160            disagreements.len(),
2161            disagreements
2162                .iter()
2163                .take(5)
2164                .cloned()
2165                .collect::<Vec<_>>()
2166                .join("\n")
2167        );
2168    }
2169
2170    #[test]
2171    fn a_preview_that_cannot_place_the_cursor_leaves_the_viewport_alone() {
2172        // `render` pairs the replace preview's rows with the real buffer's
2173        // cursor. A replacement shorter than what it replaces puts that cursor
2174        // past the end of the previewed row, and answering "visual row 0" threw
2175        // the note to the top mid-keystroke.
2176        let mut lines: Vec<String> = (0..200).map(|i| format!("row {i} plain text")).collect();
2177        lines[150] = "  the configuration value goes here".to_string();
2178
2179        let mut v = MarkdownEditorView::new();
2180        update_view(&mut v, &lines, (150, 25), rect(20), 1, None);
2181        let scrolled = v.viewport.top();
2182        assert!(scrolled > 0, "fixture must have scrolled away from the top");
2183
2184        // The preview: that row shrinks below the cursor's column.
2185        let mut preview = lines.clone();
2186        preview[150] = "  the cfg value".to_string();
2187        update_view(&mut v, &preview, (150, 25), rect(20), 2, None);
2188
2189        assert_eq!(
2190            v.viewport.top(),
2191            scrolled,
2192            "the viewport must not jump to the top of the note"
2193        );
2194    }
2195
2196    #[test]
2197    fn a_click_past_the_end_of_a_wrapped_line_stays_on_that_line() {
2198        // Clicking the blank space to the right of a soft-wrapped line must land
2199        // at the end of the line clicked, not inside the continuation below it.
2200        // `crate::ropetext::Layout::position_at_cell` clamps for exactly this reason;
2201        // this mapper is the TUI's own copy and had drifted from it.
2202        let lines = vec![
2203            "a long paragraph that will certainly wrap more than once at this width".to_string(),
2204        ];
2205        let mut v = MarkdownEditorView::new();
2206        update_view(&mut v, &lines, (0, 0), rect(20), 1, None);
2207
2208        let first = v.layout.visual_lines()[0].clone();
2209        assert!(
2210            v.layout.visual_lines().len() > 1,
2211            "fixture must actually wrap"
2212        );
2213
2214        // Far to the right of anything drawn on the first visual line.
2215        let (row, col) = v.click_to_logical_for_testing(0, 60);
2216        assert_eq!(row, 0);
2217        assert!(
2218            (col as usize) <= first.chars.end,
2219            "clicked past visual line 0 (chars {:?}) and landed at column {col}",
2220            first.chars
2221        );
2222    }
2223
2224    /// A newline patched into the layout must give the same layout a fresh
2225    /// compute would.
2226    ///
2227    /// The layout no longer gives up when the line count changes — it patches the
2228    /// damaged rows and renumbers the rest by the delta. That renumbering is the
2229    /// part with no second opinion anywhere: a wrong `logical_row` on the rows
2230    /// *after* the edit paints the right text against the wrong line, and every
2231    /// existing test looks at the edited row.
2232    #[test]
2233    fn a_newline_patches_the_layout_to_match_a_fresh_one() {
2234        // Rows long enough to wrap at width 20, so the visual lines outnumber the
2235        // logical rows and a renumbering slip cannot hide.
2236        let lines: Vec<String> = (0..12)
2237            .map(|i| format!("row {i} with enough words on it to wrap at this width"))
2238            .collect();
2239        let mut split = lines.clone();
2240        let tail = split[5].split_off(10);
2241        split.insert(6, tail);
2242
2243        let mut patched = MarkdownEditorView::new();
2244        update_view(&mut patched, &lines, (5, 0), rect(20), 1, None);
2245        patched.note_damage(5..7, 1);
2246        update_view(&mut patched, &split, (6, 0), rect(20), 2, None);
2247
2248        let mut fresh = MarkdownEditorView::new();
2249        update_view(&mut fresh, &split, (6, 0), rect(20), 1, None);
2250
2251        let patched_lines: Vec<_> = patched
2252            .layout
2253            .visual_lines()
2254            .iter()
2255            .map(|vl| (vl.logical_row, vl.bytes.clone(), vl.first))
2256            .collect();
2257        let fresh_lines: Vec<_> = fresh
2258            .layout
2259            .visual_lines()
2260            .iter()
2261            .map(|vl| (vl.logical_row, vl.bytes.clone(), vl.first))
2262            .collect();
2263        assert_eq!(
2264            patched_lines, fresh_lines,
2265            "patching a newline must land where a full recompute would"
2266        );
2267    }
2268
2269    pub(super) fn update_view(
2270        v: &mut MarkdownEditorView,
2271        lines: &[String],
2272        cursor: (usize, usize),
2273        rect: Rect,
2274        generation: u64,
2275        selection: Option<((usize, usize), (usize, usize))>,
2276    ) {
2277        // Selection reaches the view as an **overlay** now.
2278        let rev = NonZeroU64::new(generation.max(1)).unwrap();
2279        let clamped = if lines.is_empty() {
2280            (0, 0)
2281        } else {
2282            (cursor.0.min(lines.len() - 1), cursor.1)
2283        };
2284        let snap = super::super::snapshot::EditorSnapshot::borrowed(lines, clamped, rev);
2285        v.update(&snap, rect);
2286        let overlays = match selection {
2287            Some(((sr, sc), (er, ec))) => (sr..=er)
2288                .map(|row| {
2289                    Overlay::new(
2290                        row,
2291                        if row == sr { sc } else { 0 },
2292                        if row == er { ec } else { usize::MAX },
2293                        OverlayKind::Selection,
2294                    )
2295                })
2296                .collect(),
2297            None => Vec::new(),
2298        };
2299        v.set_overlays(overlays);
2300    }
2301
2302    /// Build a freshly-updated view from `lines` with the cursor at
2303    /// `cursor` and the given editor `width`, using the real snapshot +
2304    /// `update()` path. Height is fixed at 24.
2305    fn make_view_for_lines(
2306        lines: &[String],
2307        cursor: (usize, usize),
2308        width: u16,
2309    ) -> MarkdownEditorView {
2310        let mut v = MarkdownEditorView::new();
2311        let r = Rect {
2312            x: 0,
2313            y: 0,
2314            width,
2315            height: 24,
2316        };
2317        update_view(&mut v, lines, cursor, r, 1, None);
2318        v
2319    }
2320
2321    #[test]
2322    fn selection_highlight_respects_emoji_cluster_width() {
2323        // Span "a❤️b" where ❤️ = U+2764 + VS16 renders as 2 display columns:
2324        // a=col0, ❤️=cols1..3, b=col3. Selecting cols 1..3 must highlight
2325        // exactly the heart cluster — not split it, and not bleed into 'b'.
2326        let theme = Theme::default();
2327        let sel_bg = theme.selection_bg.to_ratatui();
2328        let heart = "\u{2764}\u{FE0F}";
2329        let content = format!("a{heart}b");
2330        let spans = vec![ratatui::text::Span::raw(content)];
2331        let out = restyle_over_range(spans, 1..3, &|st| st.bg(sel_bg));
2332
2333        let highlighted: String = out
2334            .iter()
2335            .filter(|s| s.style.bg == Some(sel_bg))
2336            .map(|s| s.content.as_ref())
2337            .collect();
2338        assert_eq!(highlighted, heart, "selection must cover exactly the heart");
2339
2340        // No output span may split the cluster: every span's content must
2341        // recluster identically (the heart stays whole within one span).
2342        for s in &out {
2343            let c = s.content.as_ref();
2344            assert!(
2345                !c.contains('\u{2764}') || c.contains(heart),
2346                "emoji cluster split across spans: {c:?}"
2347            );
2348        }
2349    }
2350
2351    #[test]
2352    fn code_box_background_reaches_rendered_cells() {
2353        use ratatui::Terminal;
2354        use ratatui::backend::TestBackend;
2355        let lines = vec![
2356            "```".to_string(),
2357            "let x = 1;".to_string(),
2358            "```".to_string(),
2359            "plain".to_string(),
2360        ];
2361        let theme = crate::settings::themes::Theme::gruvbox_dark();
2362        let mut view = make_view_for_lines(&lines, (3, 0), 40);
2363        let mut terminal = Terminal::new(TestBackend::new(40, 5)).unwrap();
2364        terminal
2365            .draw(|f| view.render(f, f.area(), &theme, true, Some(CursorShape::Bar)))
2366            .unwrap();
2367        let buf = terminal.backend().buffer().clone();
2368        let code_bg = theme.code_bg.to_ratatui();
2369        let cell = |x: u16, y: u16| &buf.content[(y as usize) * 40 + (x as usize)];
2370
2371        // A cell on the fenced code content row carries the code-box bg...
2372        assert_eq!(
2373            cell(0, 1).bg,
2374            code_bg,
2375            "code content cell must have code_bg"
2376        );
2377        // ...including the padding past the text (box is a solid rectangle).
2378        assert_eq!(cell(8, 1).bg, code_bg, "code-box padding must have code_bg");
2379        // A prose row outside the block does NOT get the code bg.
2380        assert_ne!(cell(0, 3).bg, code_bg, "prose row must not have code_bg");
2381    }
2382
2383    #[test]
2384    fn blockquote_gutter_inset_off_cursor_row_only() {
2385        // Two blockquote lines; cursor on row 0.
2386        let lines = vec!["> first".to_string(), ">> second".to_string()];
2387        let view = make_view_for_lines(&lines, (0, 1), 80);
2388        let g = view.gutter_insets_for_testing();
2389        assert_eq!(g[0], 0); // cursor row → revealed, no gutter
2390        assert_eq!(g[1], 3); // depth 2 → 2 bars + 1 space
2391    }
2392
2393    #[test]
2394    fn code_box_width_is_block_max_capped_to_width() {
2395        let lines = vec![
2396            "```".to_string(),
2397            "let x = 1;".to_string(),    // 10
2398            "let yy = 222;".to_string(), // 13 (widest)
2399            "```".to_string(),
2400            "plain".to_string(),
2401        ];
2402        let view = make_view_for_lines(&lines, (0, 0), 80); // width 80
2403        let w = view.code_box_width_for_testing();
2404        assert_eq!(w[0], Some(13));
2405        assert_eq!(w[1], Some(13));
2406        assert_eq!(w[2], Some(13));
2407        assert_eq!(w[3], Some(13));
2408        assert_eq!(w[4], None);
2409    }
2410
2411    #[test]
2412    fn new_has_zero_scroll() {
2413        assert_eq!(MarkdownEditorView::new().viewport.top(), 0);
2414    }
2415
2416    #[test]
2417    fn zero_height_rect_does_not_panic() {
2418        let mut v = MarkdownEditorView::new();
2419        update_view(&mut v, &["hello".to_string()], (0, 0), rect(0), 1, None);
2420    }
2421
2422    #[test]
2423    fn scroll_follows_cursor_down() {
2424        let mut v = MarkdownEditorView::new();
2425        let lines: Vec<String> = (0..5).map(|i| format!("line{}", i)).collect();
2426        update_view(&mut v, &lines, (4, 0), rect(3), 1, None);
2427        assert!(v.viewport.top() >= 2);
2428    }
2429
2430    #[test]
2431    fn scroll_follows_cursor_up() {
2432        let mut v = MarkdownEditorView::new();
2433        let lines: Vec<String> = (0..5).map(|i| format!("line{}", i)).collect();
2434        update_view(&mut v, &lines, (4, 0), rect(3), 1, None);
2435        update_view(&mut v, &lines, (0, 0), rect(3), 1, None); // same generation — scroll still adjusts
2436        assert_eq!(v.viewport.top(), 0);
2437    }
2438
2439    #[test]
2440    fn wheel_scroll_moves_the_viewport_and_survives_the_next_frame() {
2441        let mut v = MarkdownEditorView::new();
2442        let lines: Vec<String> = (0..10).map(|i| format!("line{}", i)).collect();
2443        update_view(&mut v, &lines, (0, 0), rect(3), 1, None);
2444        v.scroll_by(4);
2445        assert_eq!(v.viewport.top(), 4);
2446        // Same cursor, text and size: the redraw must not snap back.
2447        update_view(&mut v, &lines, (0, 0), rect(3), 1, None);
2448        assert_eq!(v.viewport.top(), 4);
2449        v.scroll_by(-1);
2450        update_view(&mut v, &lines, (0, 0), rect(3), 1, None);
2451        assert_eq!(v.viewport.top(), 3);
2452    }
2453
2454    #[test]
2455    fn wheel_scroll_is_clamped_to_the_content() {
2456        let mut v = MarkdownEditorView::new();
2457        let lines: Vec<String> = (0..10).map(|i| format!("line{}", i)).collect();
2458        update_view(&mut v, &lines, (0, 0), rect(3), 1, None);
2459        v.scroll_by(100);
2460        assert_eq!(v.viewport.top(), 7, "last line sits at the bottom");
2461        v.scroll_by(-100);
2462        assert_eq!(v.viewport.top(), 0);
2463    }
2464
2465    #[test]
2466    fn moving_the_cursor_after_a_wheel_scroll_brings_it_back_into_view() {
2467        let mut v = MarkdownEditorView::new();
2468        let lines: Vec<String> = (0..10).map(|i| format!("line{}", i)).collect();
2469        update_view(&mut v, &lines, (0, 0), rect(3), 1, None);
2470        v.scroll_by(6);
2471        update_view(&mut v, &lines, (1, 0), rect(3), 1, None);
2472        assert_eq!(v.viewport.top(), 1);
2473    }
2474
2475    #[test]
2476    fn editing_after_a_wheel_scroll_brings_the_cursor_back_into_view() {
2477        let mut v = MarkdownEditorView::new();
2478        let mut lines: Vec<String> = (0..10).map(|i| format!("line{}", i)).collect();
2479        update_view(&mut v, &lines, (0, 0), rect(3), 1, None);
2480        v.scroll_by(6);
2481        lines[0].push('x');
2482        update_view(&mut v, &lines, (0, 0), rect(3), 2, None);
2483        assert_eq!(v.viewport.top(), 0);
2484    }
2485
2486    #[test]
2487    fn follow_cursor_brings_the_view_back_when_nothing_else_changed() {
2488        // Find-next wrapping onto the match the cursor already sits on changes
2489        // neither the cursor nor the text; the explicit re-attach must still
2490        // reveal it.
2491        let mut v = MarkdownEditorView::new();
2492        let lines: Vec<String> = (0..10).map(|i| format!("line{}", i)).collect();
2493        update_view(&mut v, &lines, (0, 0), rect(3), 1, None);
2494        v.scroll_by(6);
2495        v.follow_cursor();
2496        update_view(&mut v, &lines, (0, 0), rect(3), 1, None);
2497        assert_eq!(v.viewport.top(), 0);
2498    }
2499
2500    #[test]
2501    fn a_saturated_wheel_notch_does_not_detach_the_view() {
2502        let mut v = MarkdownEditorView::new();
2503        let lines: Vec<String> = (0..10).map(|i| format!("line{}", i)).collect();
2504        update_view(&mut v, &lines, (0, 0), rect(3), 1, None);
2505        assert!(!v.scroll_by(-3), "already at the top");
2506        assert!(!v.detached);
2507    }
2508
2509    #[test]
2510    fn a_wheel_scroll_survives_a_height_change_but_stays_within_content() {
2511        let mut v = MarkdownEditorView::new();
2512        let lines: Vec<String> = (0..10).map(|i| format!("line{}", i)).collect();
2513        update_view(&mut v, &lines, (0, 0), rect(3), 1, None);
2514        v.scroll_by(7);
2515        update_view(&mut v, &lines, (0, 0), rect(5), 1, None);
2516        assert_eq!(
2517            v.viewport.top(),
2518            5,
2519            "clamped so the last line is at the bottom"
2520        );
2521    }
2522
2523    #[test]
2524    fn growing_the_viewport_pulls_content_back_down() {
2525        // Shrinking to one row pins the cursor's (last) line at the top;
2526        // growing back must reveal the lines above it, not leave the last
2527        // line alone on screen with blank rows beneath.
2528        let mut v = MarkdownEditorView::new();
2529        let lines: Vec<String> = (0..5).map(|i| format!("line{}", i)).collect();
2530        update_view(&mut v, &lines, (4, 0), rect(1), 1, None);
2531        assert_eq!(v.viewport.top(), 4);
2532        update_view(&mut v, &lines, (4, 0), rect(3), 1, None);
2533        assert_eq!(v.viewport.top(), 2);
2534        update_view(&mut v, &lines, (4, 0), rect(10), 1, None);
2535        assert_eq!(v.viewport.top(), 0);
2536    }
2537
2538    #[test]
2539    fn visual_to_logical_u16_accounts_for_scroll() {
2540        let mut v = MarkdownEditorView::new();
2541        let lines: Vec<String> = (0..10).map(|i| format!("line{}", i)).collect();
2542        update_view(&mut v, &lines, (5, 0), rect(3), 1, None);
2543        let scroll = v.viewport.top();
2544        let (row, _col) = v.click_to_logical_u16(scroll, 0);
2545        assert_eq!(row as usize, scroll);
2546    }
2547
2548    #[test]
2549    fn code_block_detection_cursor_inside() {
2550        let lines = vec![
2551            "text".to_string(),
2552            "```rust".to_string(),
2553            "let x = 1;".to_string(),
2554            "```".to_string(),
2555            "more".to_string(),
2556        ];
2557        let pb = ParsedBuffer::parse_lines(&lines);
2558        let ranges = super::super::parse_incremental::fence_ranges_from_kinds(&pb.kinds);
2559        let block = ranges.iter().find(|r| r.contains(&2)).cloned();
2560        assert!(block.is_some());
2561        let r = block.unwrap();
2562        assert_eq!(r.start, 1);
2563        assert_eq!(r.end, 4);
2564    }
2565
2566    #[test]
2567    fn code_block_detection_cursor_outside() {
2568        let lines = vec![
2569            "text".to_string(),
2570            "```".to_string(),
2571            "code".to_string(),
2572            "```".to_string(),
2573        ];
2574        let pb = ParsedBuffer::parse_lines(&lines);
2575        let ranges = super::super::parse_incremental::fence_ranges_from_kinds(&pb.kinds);
2576        assert!(ranges.iter().find(|r| r.contains(&0)).is_none());
2577    }
2578
2579    #[test]
2580    fn click_to_logical_does_not_panic_on_stale_layout() {
2581        // Regression: click_to_logical_u16 raw-indexed parsed_buffer.lines
2582        // by vl.logical_row. A stale layout whose visual_lines outlive a
2583        // shrink of parsed_buffer.lines would panic on mouse click. The
2584        // guard now falls back to a raw visual-col mapping.
2585        let mut v = MarkdownEditorView::new();
2586        let long: Vec<String> = (0..20).map(|i| format!("line{}", i)).collect();
2587        update_view(&mut v, &long, (0, 0), rect(10), 1, None);
2588        // Drive a shrink so layout.visual_lines outruns parsed_buffer.lines
2589        // briefly. update() rebuilds layout from the new lines, so the
2590        // pure shrink shouldn't desynchronize them — but we still want a
2591        // black-box test that simulates a click against the last vrow.
2592        let vrows = v.layout.visual_lines().len();
2593        if vrows > 0 {
2594            let _ = v.click_to_logical_u16(vrows.saturating_sub(1), 0);
2595            let _ = v.click_to_logical_u16(vrows + 5, 0);
2596        }
2597    }
2598
2599    #[test]
2600    fn render_does_not_panic_on_stale_cursor_past_line_count() {
2601        // Regression: render() previously did self.parsed_cache[cursor.0]
2602        // and self.layout.visual_lines()[cursor_vrow] directly. A stale
2603        // Nvim snapshot whose cursor row landed past the new line count
2604        // would panic the render thread. Now the test exercises the
2605        // producer-side clamp (via `update_view`'s mirror of
2606        // `snapshot_from_backend`): the snapshot constructor clamps
2607        // the cursor, render trusts the invariant, and direct
2608        // indexing is safe.
2609        use ratatui::Terminal;
2610        use ratatui::backend::TestBackend;
2611        let theme = Theme::gruvbox_dark();
2612        let backend = TestBackend::new(40, 10);
2613        let mut terminal = Terminal::new(backend).unwrap();
2614
2615        let mut v = MarkdownEditorView::new();
2616        // Populate with 2 lines and a valid cursor first so parsed_cache /
2617        // layout are non-empty.
2618        update_view(
2619            &mut v,
2620            &["alpha".to_string(), "beta".to_string()],
2621            (0, 0),
2622            rect(8),
2623            1,
2624            None,
2625        );
2626        // Now feed a cursor row that exceeds the line count for this update
2627        // (simulates a stale snapshot arriving after a shrink). update() at
2628        // line 277 already uses `lines.get(cursor.0)` so it won't panic; the
2629        // real risk was the [] indexes inside render(). cursor_snapshot ends
2630        // up at (5, 0) which exceeds the parsed_cache len of 2 below.
2631        update_view(
2632            &mut v,
2633            &["alpha".to_string(), "beta".to_string()],
2634            (5, 0),
2635            rect(8),
2636            1,
2637            None,
2638        );
2639        // Render with focus so the cursor branch runs.
2640        terminal
2641            .draw(|f| v.render(f, f.area(), &theme, true, Some(CursorShape::Bar)))
2642            .expect("render must not panic on stale cursor");
2643    }
2644
2645    #[test]
2646    fn cursor_into_link_refreshes_layout_for_same_row() {
2647        // Regression: when the cursor moves within a row, crossing into
2648        // or out of an expandable inline element (link/bold/etc.), the
2649        // rendered mask flips (the element reveals or hides its hidden
2650        // sigils). Both rendered_cache and the wrap layout depend on
2651        // the mask. Previously Gate 2 took the `TextChangeKind::None`
2652        // wrap branch and skipped re-splicing, leaving stale visual
2653        // lines until the next text edit.
2654        //
2655        // Use a link whose hidden URL is long enough that revealing it
2656        // forces an extra wrap line at width 40 — that lets us
2657        // black-box detect the mask flip via visual_lines.len().
2658        let mut v = MarkdownEditorView::new();
2659        let lines =
2660            vec!["see [link](http://example.com/very/long/path/to/some/page) more".to_string()];
2661        // First update: cursor outside the link (col 0).
2662        update_view(&mut v, &lines, (0, 0), rect(5), 1, None);
2663        let n_outside = v.layout.visual_lines().len();
2664
2665        // Second update: cursor inside the link element.
2666        update_view(&mut v, &lines, (0, 8), rect(5), 1, None);
2667        let layout_inside = v.layout.visual_lines().to_vec();
2668
2669        // Fresh view with cursor already inside must produce the same layout.
2670        let mut fresh = MarkdownEditorView::new();
2671        update_view(&mut fresh, &lines, (0, 8), rect(5), 1, None);
2672        let layout_fresh = fresh.layout.visual_lines().to_vec();
2673        assert_eq!(
2674            layout_inside, layout_fresh,
2675            "post-move layout must match a fresh full-recompute"
2676        );
2677        assert!(
2678            layout_inside.len() > n_outside,
2679            "expanding the link's hidden URL must produce more visual lines"
2680        );
2681    }
2682
2683    #[test]
2684    fn reported_damage_agrees_with_the_diff_it_replaced() {
2685        // The engine tells the view which rows it changed, so the view no longer
2686        // compares the buffer against a copy of its previous self. The two must
2687        // reach the same answer, or "told" quietly means something else than
2688        // "found" and every parse after an edit is subtly wrong.
2689        // Blank lines between blocks, so widening stops at a paragraph boundary
2690        // rather than reaching the buffer's edges. Without them every damage range
2691        // widens to the whole buffer and this would pass whatever the report says,
2692        // proving nothing about it being read.
2693        let mut lines: Vec<String> = Vec::new();
2694        for block in 0..4 {
2695            lines.push(format!("block {block} first line"));
2696            lines.push(format!("block {block} second line"));
2697            lines.push(String::new());
2698        }
2699        let mut edited = lines.clone();
2700        edited[7].push_str(" more");
2701
2702        let mut found = MarkdownEditorView::new();
2703        update_view(&mut found, &lines, (7, 0), rect(20), 1, None);
2704        let by_diff = found.try_incremental_parse(&text_of(&edited), (7, 0), None);
2705
2706        let mut told = MarkdownEditorView::new();
2707        update_view(&mut told, &lines, (7, 0), rect(20), 1, None);
2708        let by_report = told.try_incremental_parse(&text_of(&edited), (7, 0), Some(7..8));
2709
2710        assert!(by_diff.is_some(), "the diff finds this edit incrementally");
2711        let (diff_range, diff_slice, _) = by_diff.expect("checked");
2712        let (report_range, report_slice, _) = by_report.expect("the report must too");
2713        assert_eq!(diff_range, report_range, "widened ranges diverge");
2714        assert_eq!(diff_slice.kinds, report_slice.kinds, "parsed kinds diverge");
2715    }
2716
2717    #[test]
2718    fn a_report_past_the_end_of_the_buffer_falls_back_to_the_diff() {
2719        // A stale report — rows that no longer exist — must not be trusted. The
2720        // guard is what keeps a report from indexing outside the buffer.
2721        let lines = vec!["alpha".to_string(), "beta".to_string()];
2722        let mut edited = lines.clone();
2723        edited[1].push_str(" more");
2724        let mut v = MarkdownEditorView::new();
2725        update_view(&mut v, &lines, (1, 0), rect(20), 1, None);
2726        assert!(
2727            v.try_incremental_parse(&text_of(&edited), (1, 0), Some(0..99))
2728                .is_some(),
2729            "an out-of-range report falls back rather than panicking"
2730        );
2731    }
2732
2733    #[test]
2734    fn try_incremental_parse_falls_back_on_indented_code_flip() {
2735        // Regression: a Plain row flipping to IndentedCode (4 leading
2736        // spaces) can lazy-extend an indented-code block across the
2737        // following Plain rows in the full buffer. The widened slice
2738        // can't see that context. Guard must trip fallback.
2739        let mut v = MarkdownEditorView::new();
2740        let lines = vec!["alpha".to_string(), "beta".to_string(), "gamma".to_string()];
2741        update_view(&mut v, &lines, (0, 0), rect(20), 1, None);
2742        let new_lines = vec![
2743            "alpha".to_string(),
2744            "    beta".to_string(),
2745            "gamma".to_string(),
2746        ];
2747        // try_incremental_parse must return None (full-rebuild signal).
2748        assert!(
2749            v.try_incremental_parse(&text_of(&new_lines), (1, 0), None)
2750                .is_none(),
2751            "indented-code flip must force a full rebuild"
2752        );
2753    }
2754
2755    /// V2 structural guard regression. Buffer `["    code", "",
2756    /// "    more"]` has lazy_depth `[1, 1, 1]` (indented code
2757    /// multi-chunk per CommonMark §4.4). An edit at row 1 (the blank
2758    /// inside the block) must trigger fallback, even though the row
2759    /// is itself Blank and would otherwise be a safe-looking
2760    /// boundary candidate.
2761    #[test]
2762    fn try_incremental_parse_falls_back_when_damaged_row_is_inside_lazy_block() {
2763        let mut v = MarkdownEditorView::new();
2764        let lines = vec![
2765            "    code".to_string(),
2766            "".to_string(),
2767            "    more".to_string(),
2768        ];
2769        update_view(&mut v, &lines, (0, 0), rect(20), 1, None);
2770        assert_eq!(
2771            v.parse_state.buf().lazy_depth,
2772            vec![1, 1, 1],
2773            "precondition: parsed_buffer.lazy_depth must mark all three rows as inside the block"
2774        );
2775        let new_lines = vec![
2776            "    code".to_string(),
2777            "x".to_string(),
2778            "    more".to_string(),
2779        ];
2780        assert!(
2781            v.try_incremental_parse(&text_of(&new_lines), (1, 1), None)
2782                .is_none(),
2783            "edit inside an open lazy-continuable block must force a full rebuild"
2784        );
2785    }
2786
2787    #[test]
2788    fn try_incremental_parse_falls_back_on_html_block_flip() {
2789        // Regression: a Plain row flipping to an HTML-block opener
2790        // (`<div>`) starts a block that lazy-extends through subsequent
2791        // Plain rows in the full buffer.
2792        let mut v = MarkdownEditorView::new();
2793        let lines = vec!["alpha".to_string(), "beta".to_string(), "gamma".to_string()];
2794        update_view(&mut v, &lines, (0, 0), rect(20), 1, None);
2795        let new_lines = vec![
2796            "alpha".to_string(),
2797            "<div>".to_string(),
2798            "gamma".to_string(),
2799        ];
2800        assert!(
2801            v.try_incremental_parse(&text_of(&new_lines), (1, 0), None)
2802                .is_none(),
2803            "HTML-block opener flip must force a full rebuild"
2804        );
2805    }
2806
2807    #[test]
2808    fn is_in_code_block_returns_true_for_any_fence_regardless_of_cursor() {
2809        // Regression: after commit cceef444, every fenced block renders
2810        // force-raw — not just the one the cursor sits in. Verify by
2811        // probing `is_in_code_block` for a row in a fence while the
2812        // cursor is positioned elsewhere.
2813        let mut v = MarkdownEditorView::new();
2814        let lines = vec![
2815            "intro".to_string(),
2816            "```".to_string(),
2817            "code".to_string(),
2818            "```".to_string(),
2819            "outro".to_string(),
2820        ];
2821        // Cursor on the prose line; fence interior must still report in-block.
2822        update_view(&mut v, &lines, (4, 0), rect(10), 1, None);
2823        assert!(v.is_in_code_block(2), "fence interior is in-block");
2824        assert!(!v.is_in_code_block(0), "prose line is not in-block");
2825        assert!(!v.is_in_code_block(4), "trailing prose is not in-block");
2826    }
2827
2828    #[test]
2829    fn parsed_cache_populated_after_update() {
2830        let mut v = MarkdownEditorView::new();
2831        let lines = vec!["hello".to_string(), "**bold**".to_string()];
2832        update_view(&mut v, &lines, (0, 0), rect(10), 1, None);
2833        assert_eq!(v.parse_state.buf().lines.len(), 2);
2834    }
2835
2836    #[test]
2837    fn layout_skipped_on_horizontal_cursor_move_in_plain_text() {
2838        let mut v = MarkdownEditorView::new();
2839        let lines = vec!["hello world".to_string()];
2840        update_view(&mut v, &lines, (0, 0), rect(40), 1, None);
2841        let layout_gen_after_first = v.last_layout_generation;
2842        // Move cursor right — same row, no elements, same generation → layout must be skipped.
2843        update_view(&mut v, &lines, (0, 5), rect(40), 1, None);
2844        assert_eq!(
2845            v.last_layout_cursor,
2846            (0, 0),
2847            "layout cursor unchanged = layout was skipped"
2848        );
2849        assert_eq!(v.last_layout_generation, layout_gen_after_first);
2850    }
2851
2852    #[test]
2853    fn layout_recomputed_on_row_change() {
2854        let mut v = MarkdownEditorView::new();
2855        let lines: Vec<String> = (0..3).map(|i| format!("line{}", i)).collect();
2856        update_view(&mut v, &lines, (0, 0), rect(40), 1, None);
2857        update_view(&mut v, &lines, (1, 0), rect(40), 1, None); // cursor moves to row 1
2858        assert_eq!(v.last_layout_cursor.0, 1, "layout recomputed on row change");
2859    }
2860
2861    #[test]
2862    fn layout_recomputed_on_width_change() {
2863        let mut v = MarkdownEditorView::new();
2864        let lines = vec!["hello world foo bar".to_string()];
2865        update_view(&mut v, &lines, (0, 0), rect(40), 1, None);
2866        update_view(
2867            &mut v,
2868            &lines,
2869            (0, 0),
2870            Rect {
2871                x: 0,
2872                y: 0,
2873                width: 10,
2874                height: 10,
2875            },
2876            1,
2877            None,
2878        );
2879        assert_eq!(v.last_layout_width, 10);
2880    }
2881
2882    #[test]
2883    fn same_generation_skips_snapshot_rebuild() {
2884        let mut v = MarkdownEditorView::new();
2885        let lines = vec!["original".to_string()];
2886        update_view(&mut v, &lines, (0, 0), rect(10), 1, None);
2887        // Update with different content but same generation — snapshot must NOT change.
2888        let lines2 = vec!["changed".to_string()];
2889        update_view(&mut v, &lines2, (0, 0), rect(10), 1, None);
2890        assert_eq!(v.text_snapshot.to_string(), "original");
2891    }
2892
2893    #[test]
2894    fn new_generation_triggers_snapshot_rebuild() {
2895        let mut v = MarkdownEditorView::new();
2896        let lines = vec!["original".to_string()];
2897        update_view(&mut v, &lines, (0, 0), rect(10), 1, None);
2898        let lines2 = vec!["changed".to_string()];
2899        update_view(&mut v, &lines2, (0, 0), rect(10), 2, None);
2900        assert_eq!(v.text_snapshot.to_string(), "changed");
2901    }
2902
2903    /// Task and needle decoration moved out of a cell-space post-pass and into
2904    /// overlay derivation. Same behaviour, logical coordinates, visible rows
2905    /// only — and now expressible without a terminal buffer.
2906    #[test]
2907    fn content_overlays_cover_needles_and_tasks() {
2908        let mut v = MarkdownEditorView::new();
2909        let lines = vec![
2910            "find the needle here".to_string(),
2911            "- [x] done task".to_string(),
2912            "- [ ] open task".to_string(),
2913        ];
2914        v.set_needles(vec!["needle".to_string()]);
2915        update_view(&mut v, &lines, (0, 0), rect(40), 1, None);
2916
2917        let kinds: Vec<_> = v.overlays.iter().map(|o| (o.row, o.kind)).collect();
2918        assert!(
2919            kinds.contains(&(0, OverlayKind::Needle)),
2920            "the needle must be emphasised, got {kinds:?}"
2921        );
2922        assert!(kinds.contains(&(1, OverlayKind::TaskBox)));
2923        assert!(
2924            kinds.contains(&(1, OverlayKind::TaskDone)),
2925            "a done task strikes its text"
2926        );
2927        assert!(kinds.contains(&(2, OverlayKind::TaskBox)));
2928        assert!(
2929            !kinds.contains(&(2, OverlayKind::TaskDone)),
2930            "an open task does not"
2931        );
2932
2933        // "needle" starts at logical char 9 — a logical column, not a cell.
2934        let needle = v
2935            .overlays
2936            .iter()
2937            .find(|o| o.kind == OverlayKind::Needle)
2938            .unwrap();
2939        assert_eq!((needle.start, needle.end), (9, 15));
2940    }
2941
2942    #[test]
2943    fn update_takes_a_selection_overlay() {
2944        let mut v = MarkdownEditorView::new();
2945        let lines = vec!["hello world".to_string()];
2946        update_view(&mut v, &lines, (0, 0), rect(40), 1, Some(((0, 0), (0, 5))));
2947        assert_eq!(
2948            v.overlays,
2949            vec![Overlay::new(0, 0, 5, OverlayKind::Selection)]
2950        );
2951    }
2952
2953    /// Overlays belong to the frame they were built for: `update` clears them,
2954    /// so a caller that stops producing one cannot leave it painted.
2955    #[test]
2956    fn update_clears_the_previous_frame_s_overlays() {
2957        let mut v = MarkdownEditorView::new();
2958        let lines = vec!["hello world".to_string()];
2959        update_view(&mut v, &lines, (0, 0), rect(40), 1, Some(((0, 0), (0, 5))));
2960        update_view(&mut v, &lines, (0, 0), rect(40), 1, None);
2961        assert!(v.overlays.is_empty());
2962    }
2963
2964    #[test]
2965    fn typing_single_char_in_long_buffer_uses_incremental_path() {
2966        let mut v = MarkdownEditorView::new();
2967        let mut lines: Vec<String> = (0..1000).map(|i| format!("paragraph {i}")).collect();
2968        update_view(&mut v, &lines, (500, 0), rect(40), 1, None);
2969        // The 1000-line buffer takes the async-parse placeholder path on
2970        // first parse. Simulate the background task completing before the
2971        // edit so the next update splices against a real (non-placeholder)
2972        // buffer; Gate 1 deliberately refuses to incrementally splice the
2973        // all-`Plain` placeholder.
2974        v.install_full_parse(1, ParsedBuffer::parse_lines(&lines));
2975
2976        // Single-char insert at row 500.
2977        lines[500].push('x');
2978        let edited_len = lines[500].len();
2979        update_view(&mut v, &lines, (500, edited_len), rect(40), 2, None);
2980
2981        // The spliced result must equal a fresh full parse.
2982        let fresh = ParsedBuffer::parse_lines(&lines);
2983        assert_eq!(v.parse_state.buf().lines.len(), fresh.lines.len());
2984        assert_eq!(v.parse_state.buf().kinds, fresh.kinds);
2985        // Regression: the heuristic widener splices a slice whose
2986        // local sentinel boundaries (slice rows 0 and len) are NOT
2987        // genuine reset boundaries of the merged buffer. splice must
2988        // not promote them — a 1000-line single-paragraph buffer has
2989        // reset boundaries only at [0, 1000].
2990        assert_eq!(
2991            v.parse_state.buf().reset_boundaries,
2992            fresh.reset_boundaries,
2993            "heuristic splice must not introduce spurious reset boundaries"
2994        );
2995        // And the incremental path was actually taken.
2996        assert!(
2997            v.last_parse_was_incremental,
2998            "single-char paragraph edit should take incremental path"
2999        );
3000    }
3001
3002    #[test]
3003    fn edit_while_placeholder_active_refuses_incremental_and_rearms() {
3004        // Regression: a large-buffer edit installs an unstyled placeholder
3005        // (all-`Plain` kinds) pending a background full parse. If the next
3006        // edit lands before the parse completes, Gate 1 must NOT splice the
3007        // placeholder — its all-`Plain` kinds defeat the structural guards
3008        // and would lock in a wrong parse that install_full_parse then drops
3009        // as stale. The edit must re-install a placeholder + re-arm pending.
3010        let mut v = MarkdownEditorView::new();
3011        let mut lines: Vec<String> = (0..1000).map(|i| format!("paragraph {i}")).collect();
3012        update_view(&mut v, &lines, (0, 0), rect(40), 1, None);
3013        assert!(
3014            v.parse_state.is_placeholder(),
3015            "first parse installs placeholder"
3016        );
3017        assert_eq!(v.take_pending_full_parse(), Some(1));
3018
3019        // Edit before the background parse resolves the placeholder.
3020        lines[0].push_str("```");
3021        update_view(&mut v, &lines, (0, lines[0].len()), rect(40), 2, None);
3022        assert!(
3023            !v.last_parse_was_incremental,
3024            "must not splice the placeholder"
3025        );
3026        assert!(
3027            v.parse_state.is_placeholder(),
3028            "still placeholder pending parse"
3029        );
3030        assert_eq!(
3031            v.take_pending_full_parse(),
3032            Some(2),
3033            "re-armed for new generation"
3034        );
3035
3036        // Background parse for the latest generation completes.
3037        v.install_full_parse(2, ParsedBuffer::parse_lines(&lines));
3038        assert!(
3039            !v.parse_state.is_placeholder(),
3040            "placeholder cleared on install"
3041        );
3042        assert_eq!(
3043            v.parse_state.buf().kinds,
3044            ParsedBuffer::parse_lines(&lines).kinds
3045        );
3046    }
3047
3048    #[test]
3049    #[should_panic(expected = "splice on placeholder parse")]
3050    fn splice_real_on_placeholder_is_rejected() {
3051        // The type makes the wrong-splice hazard unrepresentable on the
3052        // Gate 1 path; this guards the `ParseState::splice_real` contract
3053        // directly so a future caller can't route a splice into a
3054        // placeholder without tripping the assert.
3055        let mut state = ParseState::Placeholder {
3056            buf: ParsedBuffer::placeholder_lines(&["x".to_string()]),
3057            generation: 1,
3058            spawned: false,
3059        };
3060        state.splice_real(0..1, ParsedBuffer::parse_lines(&["y".to_string()]));
3061    }
3062
3063    #[test]
3064    fn fence_toggle_triggers_full_rebuild_fallback() {
3065        let mut v = MarkdownEditorView::new();
3066        // Use 700 lines so that an unclosed fence at row 350 widens to
3067        // end-of-buffer (~351 rows), exceeding the absolute cap (256).
3068        // Below the perf #9 LARGE_BUFFER_THRESHOLD (1000), so the
3069        // fallback runs synchronously and `parsed_buffer.kinds`
3070        // matches a fresh full parse immediately.
3071        let mut lines: Vec<String> = (0..700).map(|i| format!("paragraph {i}")).collect();
3072        update_view(&mut v, &lines, (350, 0), rect(40), 1, None);
3073
3074        // Open a fence mid-buffer — structurally invasive, line count changes.
3075        lines.insert(350, "```".to_string());
3076        update_view(&mut v, &lines, (350, 3), rect(40), 2, None);
3077
3078        let fresh = ParsedBuffer::parse_lines(&lines);
3079        assert_eq!(
3080            v.parse_state.buf().kinds,
3081            fresh.kinds,
3082            "spliced kinds must equal fresh full parse"
3083        );
3084        // The unclosed fence at row 350 widens to end-of-buffer (~351 lines,
3085        // > 256 cap_abs), so the cap trips and the fallback fires.
3086        assert!(
3087            !v.last_parse_was_incremental,
3088            "fence toggle (unclosed fence, 700-line buffer) should fall back to full rebuild"
3089        );
3090        // Buffer < LARGE_BUFFER_THRESHOLD → sync fallback, no
3091        // pending-async signal.
3092        assert!(
3093            v.take_pending_full_parse().is_none(),
3094            "small-buffer fallback must NOT defer to async"
3095        );
3096    }
3097
3098    #[test]
3099    fn fence_toggle_on_large_buffer_defers_to_async_fallback() {
3100        // Regression for perf #9: above LARGE_BUFFER_THRESHOLD, the
3101        // fallback installs a placeholder ParsedBuffer + signals
3102        // pending instead of blocking the typing thread on
3103        // ParsedBuffer::parse. The owning component spawns the real
3104        // parse on tokio and calls install_full_parse when done.
3105        let mut v = MarkdownEditorView::new();
3106        let mut lines: Vec<String> = (0..1500).map(|i| format!("paragraph {i}")).collect();
3107        update_view(&mut v, &lines, (750, 0), rect(40), 1, None);
3108
3109        // Force a fallback path on a large buffer.
3110        lines.insert(750, "```".to_string());
3111        update_view(&mut v, &lines, (750, 3), rect(40), 2, None);
3112
3113        assert!(
3114            !v.last_parse_was_incremental,
3115            "fence toggle on 1500-line buffer should fall back"
3116        );
3117        let pending = v.take_pending_full_parse();
3118        assert!(
3119            pending.is_some(),
3120            "large-buffer fallback must signal pending async parse"
3121        );
3122        // Placeholder kinds: every row is Plain — no fence detection yet.
3123        assert!(
3124            v.parse_state
3125                .buf()
3126                .kinds
3127                .iter()
3128                .all(|k| matches!(k, super::super::parse_incremental::LineConstructKind::Plain)),
3129            "placeholder must classify every row as Plain"
3130        );
3131        assert_eq!(
3132            v.parse_state.buf().lines.len(),
3133            lines.len(),
3134            "placeholder row count must match input"
3135        );
3136
3137        // Caller (TextEditorComponent in production) spawns the real
3138        // parse and installs the result. Simulate that here.
3139        let real = ParsedBuffer::parse_lines(&lines);
3140        let generation = pending.unwrap();
3141        v.install_full_parse(generation, real);
3142        let fresh = ParsedBuffer::parse_lines(&lines);
3143        assert_eq!(
3144            v.parse_state.buf().kinds,
3145            fresh.kinds,
3146            "post-install kinds must match fresh full parse"
3147        );
3148    }
3149
3150    /// Rows long enough that a real 40-wide wrap would split them into
3151    /// more than one visual line each — needed to tell a `Layout::unwrapped`
3152    /// stub (always exactly one visual line per row) apart from a real
3153    /// compute that happens not to have wrapped anything.
3154    fn make_long_lines(n: usize) -> Vec<String> {
3155        (0..n)
3156            .map(|i| {
3157                format!(
3158                    "paragraph number {i} with quite a bit of extra padding text \
3159                     so this row is longer than forty columns wide for sure"
3160                )
3161            })
3162            .collect()
3163    }
3164
3165    #[test]
3166    fn layout_defers_to_async_fallback_on_large_buffer() {
3167        // Layout-side twin of `fence_toggle_on_large_buffer_defers_to_async_fallback`:
3168        // above LARGE_BUFFER_THRESHOLD, a full-rebuild trigger installs a
3169        // `Layout::unwrapped` stub + signals pending instead of blocking
3170        // the typing thread on `Layout::compute`. The owning component
3171        // spawns the real wrap on tokio and calls install_full_layout
3172        // when done.
3173        let mut v = MarkdownEditorView::new();
3174        let mut lines = make_long_lines(1500);
3175        update_view(&mut v, &lines, (750, 0), rect(40), 1, None);
3176
3177        // Line-count change forces a full layout rebuild regardless of
3178        // what the parse decided.
3179        lines.insert(750, "```".to_string());
3180        update_view(&mut v, &lines, (750, 3), rect(40), 2, None);
3181
3182        let pending = v.take_pending_full_layout();
3183        assert!(
3184            pending.is_some(),
3185            "large-buffer full layout rebuild must signal pending async wrap"
3186        );
3187        assert_eq!(
3188            v.layout.row_count(),
3189            lines.len(),
3190            "stub row count must match input"
3191        );
3192        let real_visual_lines = {
3193            let hints = row_hints(&v.rendered_cache, &v.gutter_insets);
3194            Layout::compute(&v.text_snapshot, 40, Metrics::default(), &hints).visual_line_count()
3195        };
3196        assert!(
3197            v.layout.visual_line_count() < real_visual_lines,
3198            "the installed stub must not have wrapped these long rows yet \
3199             (stub: {}, real: {})",
3200            v.layout.visual_line_count(),
3201            real_visual_lines
3202        );
3203
3204        // Caller (TextEditorComponent in production) spawns the real wrap
3205        // and installs the result. Simulate that here.
3206        let job = pending.unwrap();
3207        let hints = row_hints(&job.rendered_cache, &job.gutter_insets);
3208        let real = Layout::compute(&job.text, job.width, Metrics::default(), &hints);
3209        let real_count = real.visual_line_count();
3210        v.install_full_layout(job.generation, real);
3211        assert!(v.layout_pending.is_none(), "pending cleared on install");
3212        assert_eq!(
3213            v.layout.visual_line_count(),
3214            real_count,
3215            "post-install layout must equal a fresh compute"
3216        );
3217    }
3218
3219    #[test]
3220    fn small_buffer_layout_stays_synchronous() {
3221        // Mirrors `fence_toggle_triggers_full_rebuild_fallback`: below
3222        // LARGE_BUFFER_THRESHOLD, layout rebuilds stay synchronous.
3223        let mut v = MarkdownEditorView::new();
3224        let mut lines = make_long_lines(700);
3225        update_view(&mut v, &lines, (350, 0), rect(40), 1, None);
3226        assert!(
3227            v.take_pending_full_layout().is_none(),
3228            "small buffer must not defer layout on first parse"
3229        );
3230
3231        lines.insert(350, "```".to_string());
3232        update_view(&mut v, &lines, (350, 3), rect(40), 2, None);
3233        assert!(
3234            v.take_pending_full_layout().is_none(),
3235            "small-buffer full rebuild must NOT defer layout to async"
3236        );
3237    }
3238
3239    #[test]
3240    fn edit_while_layout_pending_rearms() {
3241        // Layout-side twin of
3242        // `edit_while_placeholder_active_refuses_incremental_and_rearms`:
3243        // an edit landing before the async wrap resolves must re-stub and
3244        // re-arm for the new generation, and a stale (superseded)
3245        // install must be a no-op.
3246        let mut v = MarkdownEditorView::new();
3247        let mut lines = make_long_lines(1500);
3248        update_view(&mut v, &lines, (750, 0), rect(40), 1, None);
3249        assert!(
3250            v.layout_pending.is_some(),
3251            "first layout defers on a large buffer"
3252        );
3253        assert_eq!(v.take_pending_full_layout().map(|j| j.generation), Some(1));
3254
3255        // Edit before the background wrap resolves.
3256        lines[0].push('x');
3257        update_view(&mut v, &lines, (0, lines[0].len()), rect(40), 2, None);
3258        assert!(
3259            v.layout_pending.is_some(),
3260            "still pending — a content-changing edit must not silently keep the stale stub"
3261        );
3262        assert_eq!(
3263            v.take_pending_full_layout().map(|j| j.generation),
3264            Some(2),
3265            "re-armed for the new generation"
3266        );
3267
3268        // A stale install (superseded generation) must be dropped.
3269        let stale = Layout::compute(&text_of(&lines), 40, Metrics::default(), &[]);
3270        v.install_full_layout(1, stale);
3271        assert!(
3272            v.layout_pending.is_some(),
3273            "stale-generation install must be a no-op"
3274        );
3275
3276        // The current generation's install lands.
3277        let hints = row_hints(&v.rendered_cache, &v.gutter_insets);
3278        let real = Layout::compute(&v.text_snapshot, 40, Metrics::default(), &hints);
3279        v.install_full_layout(2, real);
3280        assert!(
3281            v.layout_pending.is_none(),
3282            "pending cleared on matching-generation install"
3283        );
3284    }
3285
3286    #[test]
3287    fn install_full_layout_rejects_width_mismatch_from_a_resize() {
3288        // A resize with no content change never bumps content_revision, so
3289        // the generation check alone cannot catch a wrap job computed for
3290        // a width the pane no longer has — `install_full_layout` must also
3291        // compare `layout.width()` against the current `last_layout_width`.
3292        let mut v = MarkdownEditorView::new();
3293        let lines = make_long_lines(1200);
3294        update_view(&mut v, &lines, (0, 0), rect(40), 1, None);
3295        let job = v
3296            .take_pending_full_layout()
3297            .expect("large buffer defers layout on first parse");
3298        assert_eq!(job.width, 40);
3299
3300        // Pane resized before the background wrap for width 40 lands.
3301        update_view(
3302            &mut v,
3303            &lines,
3304            (0, 0),
3305            Rect {
3306                width: 80,
3307                ..rect(40)
3308            },
3309            1,
3310            None,
3311        );
3312
3313        let hints = row_hints(&job.rendered_cache, &job.gutter_insets);
3314        let stale_width_layout = Layout::compute(&job.text, job.width, Metrics::default(), &hints);
3315        v.install_full_layout(job.generation, stale_width_layout);
3316        assert!(
3317            v.layout_pending.is_some(),
3318            "width-mismatched install must be rejected even though the generation matches"
3319        );
3320    }
3321
3322    /// Assert the view's cached parse matches a fresh one.
3323    ///
3324    /// The per-line comparison uses `debug_assert_eq_to`, which is
3325    /// `#[cfg(debug_assertions)]` like its one production caller — so the
3326    /// *body* is gated, not the function. Gating the whole helper would remove
3327    /// a symbol six tests call; leaving it ungated stops the lib-test target
3328    /// compiling under any profile with assertions off, which is why
3329    /// `cargo bench --no-run` did not build.
3330    fn full_rebuild_equals_view_state(v: &MarkdownEditorView, lines: &[String]) {
3331        #[cfg(not(debug_assertions))]
3332        let _ = (v, lines);
3333        #[cfg(debug_assertions)]
3334        {
3335            let fresh = ParsedBuffer::parse_lines(lines);
3336            assert_eq!(v.parse_state.buf().kinds, fresh.kinds, "kinds diverge");
3337            assert_eq!(
3338                v.parse_state.buf().lines.len(),
3339                fresh.lines.len(),
3340                "row count diverge"
3341            );
3342            for (i, (got, exp)) in v
3343                .parse_state
3344                .buf()
3345                .lines
3346                .iter()
3347                .zip(fresh.lines.iter())
3348                .enumerate()
3349            {
3350                got.debug_assert_eq_to(exp, i);
3351            }
3352        }
3353    }
3354
3355    #[test]
3356    fn incremental_falls_back_when_fence_marker_modified() {
3357        // Regression: editing a row that is currently a FenceMarker can
3358        // change the fence's extent across the rest of the buffer.
3359        // Incremental parsing's window-bounded widening cannot capture
3360        // this, so we must fall back to a full parse.
3361        let mut v = MarkdownEditorView::new();
3362        let mut lines = vec!["```".to_string(), "".to_string(), "```".to_string()];
3363        // Fill out the buffer with blank lines so the cap doesn't trip first.
3364        for _ in 0..31 {
3365            lines.push(String::new());
3366        }
3367        update_view(&mut v, &lines, (2, 0), rect(40), 1, None);
3368
3369        // Edit the closing fence marker — append a char so it's no longer a closer.
3370        let mut new_lines = lines.clone();
3371        new_lines[2].push('0');
3372        update_view(&mut v, &new_lines, (2, 4), rect(40), 2, None);
3373
3374        assert!(
3375            !v.last_parse_was_incremental,
3376            "fence-marker edit must trigger full-rebuild fallback"
3377        );
3378        // And the resulting state must equal a fresh parse (which the
3379        // fallback path does anyway, but assert defensively).
3380        full_rebuild_equals_view_state(&v, &new_lines);
3381    }
3382
3383    #[test]
3384    fn incremental_paste_large_block_falls_back() {
3385        let mut v = MarkdownEditorView::new();
3386        let mut lines: Vec<String> = (0..50).map(|i| format!("line {i}")).collect();
3387        update_view(&mut v, &lines, (25, 0), rect(40), 1, None);
3388
3389        // Insert 300 lines at row 25.
3390        let payload: Vec<String> = (0..300).map(|i| format!("pasted {i}")).collect();
3391        for (offset, p) in payload.into_iter().enumerate() {
3392            lines.insert(25 + offset, p);
3393        }
3394        update_view(&mut v, &lines, (25, 0), rect(40), 2, None);
3395        assert!(
3396            !v.last_parse_was_incremental,
3397            "300-line paste must fall back"
3398        );
3399        full_rebuild_equals_view_state(&v, &lines);
3400    }
3401
3402    #[test]
3403    fn incremental_enter_at_line_end() {
3404        let mut v = MarkdownEditorView::new();
3405        let lines = vec!["alpha".to_string(), "beta".to_string()];
3406        update_view(&mut v, &lines, (0, 5), rect(40), 1, None);
3407
3408        // Press Enter at end of "alpha".
3409        let new_lines = vec!["alpha".to_string(), "".to_string(), "beta".to_string()];
3410        update_view(&mut v, &new_lines, (1, 0), rect(40), 2, None);
3411        full_rebuild_equals_view_state(&v, &new_lines);
3412    }
3413
3414    #[test]
3415    fn incremental_backspace_merging_lines() {
3416        let mut v = MarkdownEditorView::new();
3417        let lines = vec!["alpha".to_string(), "beta".to_string()];
3418        update_view(&mut v, &lines, (1, 0), rect(40), 1, None);
3419
3420        // Backspace at start of "beta" merges into "alphabeta".
3421        let new_lines = vec!["alphabeta".to_string()];
3422        update_view(&mut v, &new_lines, (0, 5), rect(40), 2, None);
3423        full_rebuild_equals_view_state(&v, &new_lines);
3424    }
3425
3426    #[test]
3427    fn incremental_inside_fence_widens_both_markers() {
3428        let mut v = MarkdownEditorView::new();
3429        let lines = vec![
3430            "intro".to_string(),
3431            "".to_string(),
3432            "```rust".to_string(),
3433            "let x = 1;".to_string(),
3434            "let y = 2;".to_string(),
3435            "```".to_string(),
3436            "".to_string(),
3437            "outro".to_string(),
3438        ];
3439        update_view(&mut v, &lines, (3, 0), rect(40), 1, None);
3440
3441        // Edit inside the fence (same-length, no line-count change).
3442        let mut new_lines = lines.clone();
3443        new_lines[3] = "let x = 999;".to_string();
3444        update_view(&mut v, &new_lines, (3, 8), rect(40), 2, None);
3445        full_rebuild_equals_view_state(&v, &new_lines);
3446    }
3447
3448    #[test]
3449    fn incremental_list_continuation_widens_to_outer_marker() {
3450        let mut v = MarkdownEditorView::new();
3451        let lines = vec![
3452            "- top".to_string(),
3453            "  body of top".to_string(),
3454            "  - nested".to_string(),
3455            "    body of nested".to_string(),
3456            "    body two".to_string(),
3457            "".to_string(),
3458            "outro".to_string(),
3459        ];
3460        update_view(&mut v, &lines, (4, 0), rect(40), 1, None);
3461
3462        // Edit the nested continuation line.
3463        let mut new_lines = lines.clone();
3464        new_lines[4] = "    body two changed".to_string();
3465        update_view(&mut v, &new_lines, (4, 10), rect(40), 2, None);
3466        full_rebuild_equals_view_state(&v, &new_lines);
3467    }
3468
3469    #[test]
3470    fn incremental_setext_underline_edit() {
3471        let mut v = MarkdownEditorView::new();
3472        let lines = vec![
3473            "heading text".to_string(),
3474            "====".to_string(),
3475            "".to_string(),
3476            "body".to_string(),
3477        ];
3478        update_view(&mut v, &lines, (1, 0), rect(40), 1, None);
3479
3480        // Edit the underline (same line count).
3481        let mut new_lines = lines.clone();
3482        new_lines[1] = "======".to_string();
3483        update_view(&mut v, &new_lines, (1, 6), rect(40), 2, None);
3484        full_rebuild_equals_view_state(&v, &new_lines);
3485    }
3486
3487    #[test]
3488    fn incremental_blockquote_paragraph_edit() {
3489        let mut v = MarkdownEditorView::new();
3490        let lines = vec![
3491            "intro".to_string(),
3492            "".to_string(),
3493            "> quoted line one".to_string(),
3494            "> quoted line two".to_string(),
3495            "> quoted line three".to_string(),
3496            "".to_string(),
3497            "outro".to_string(),
3498        ];
3499        update_view(&mut v, &lines, (3, 0), rect(40), 1, None);
3500
3501        let mut new_lines = lines.clone();
3502        new_lines[3] = "> quoted line TWO".to_string();
3503        update_view(&mut v, &new_lines, (3, 17), rect(40), 2, None);
3504        full_rebuild_equals_view_state(&v, &new_lines);
3505    }
3506
3507    #[test]
3508    fn incremental_html_block_edit() {
3509        let mut v = MarkdownEditorView::new();
3510        let lines = vec![
3511            "before".to_string(),
3512            "".to_string(),
3513            "<div>".to_string(),
3514            "body".to_string(),
3515            "</div>".to_string(),
3516            "".to_string(),
3517            "after".to_string(),
3518        ];
3519        update_view(&mut v, &lines, (3, 0), rect(40), 1, None);
3520
3521        let mut new_lines = lines.clone();
3522        new_lines[3] = "body changed".to_string();
3523        update_view(&mut v, &new_lines, (3, 12), rect(40), 2, None);
3524        full_rebuild_equals_view_state(&v, &new_lines);
3525    }
3526
3527    #[test]
3528    fn g1_nested_list_three_indent_continuation() {
3529        // Deeply nested continuation: damaged range touches a 3-indent
3530        // continuation line. Widening must reach the outermost col-0
3531        // ListMarker — otherwise parse_range sees `      text` as
3532        // IndentedCode.
3533        let mut v = MarkdownEditorView::new();
3534        let lines = vec![
3535            "intro".to_string(),
3536            "".to_string(),
3537            "- level 0".to_string(),
3538            "  - level 1".to_string(),
3539            "    - level 2".to_string(),
3540            "      continuation at 6 indent".to_string(),
3541            "".to_string(),
3542            "after".to_string(),
3543        ];
3544        update_view(&mut v, &lines, (5, 0), rect(40), 1, None);
3545
3546        let mut new_lines = lines.clone();
3547        new_lines[5] = "      continuation at 6 indent EDITED".to_string();
3548        update_view(&mut v, &new_lines, (5, 30), rect(40), 2, None);
3549        full_rebuild_equals_view_state(&v, &new_lines);
3550    }
3551
3552    #[test]
3553    fn g3_hashtag_inside_fence_not_labeled_after_incremental_edit() {
3554        // `#tag` inside a fenced code block must NOT produce a Label element.
3555        // After an incremental edit fully inside the fence, the widened
3556        // slice includes both fence markers — the label-suppression scan
3557        // sees the fence and skips. This test verifies the round-trip.
3558        let mut v = MarkdownEditorView::new();
3559        let lines = vec![
3560            "intro".to_string(),
3561            "".to_string(),
3562            "```".to_string(),
3563            "let s = \"#tag\";".to_string(),
3564            "// another #tag".to_string(),
3565            "```".to_string(),
3566            "".to_string(),
3567            "outro".to_string(),
3568        ];
3569        update_view(&mut v, &lines, (4, 0), rect(40), 1, None);
3570
3571        use crate::components::text_editor::markdown::ElementKind;
3572
3573        // Pre-condition: no Label elements in the fence interior.
3574        for row in 3..5 {
3575            let has_label = v.parse_state.buf().lines[row]
3576                .elements
3577                .iter()
3578                .any(|e| matches!(e.kind, ElementKind::Label));
3579            assert!(
3580                !has_label,
3581                "row {row} should have no Label inside the fence"
3582            );
3583        }
3584
3585        // Edit one of the in-fence lines.
3586        let mut new_lines = lines.clone();
3587        new_lines[4] = "// edited #tag here".to_string();
3588        update_view(&mut v, &new_lines, (4, 19), rect(40), 2, None);
3589
3590        // Post-condition: still no Label elements in the fence interior.
3591        for row in 3..5 {
3592            let has_label = v.parse_state.buf().lines[row]
3593                .elements
3594                .iter()
3595                .any(|e| matches!(e.kind, ElementKind::Label));
3596            assert!(
3597                !has_label,
3598                "row {row} should still have no Label after incremental edit"
3599            );
3600        }
3601        full_rebuild_equals_view_state(&v, &new_lines);
3602    }
3603
3604    #[test]
3605    fn g8a_typing_into_empty_buffer() {
3606        let mut v = MarkdownEditorView::new();
3607        let empty = vec!["".to_string()];
3608        update_view(&mut v, &empty, (0, 0), rect(40), 1, None);
3609
3610        let one = vec!["h".to_string()];
3611        update_view(&mut v, &one, (0, 1), rect(40), 2, None);
3612        full_rebuild_equals_view_state(&v, &one);
3613
3614        let two = vec!["he".to_string()];
3615        update_view(&mut v, &two, (0, 2), rect(40), 3, None);
3616        full_rebuild_equals_view_state(&v, &two);
3617
3618        let many = vec!["hello world".to_string()];
3619        update_view(&mut v, &many, (0, 11), rect(40), 4, None);
3620        full_rebuild_equals_view_state(&v, &many);
3621    }
3622
3623    #[test]
3624    fn g8b_delete_last_char_one_line_buffer() {
3625        let mut v = MarkdownEditorView::new();
3626        let one = vec!["h".to_string()];
3627        update_view(&mut v, &one, (0, 1), rect(40), 1, None);
3628
3629        let empty = vec!["".to_string()];
3630        update_view(&mut v, &empty, (0, 0), rect(40), 2, None);
3631        full_rebuild_equals_view_state(&v, &empty);
3632    }
3633
3634    #[test]
3635    fn incremental_text_change_produces_same_layout_as_full_recompute() {
3636        let mut v = MarkdownEditorView::new();
3637        let lines: Vec<String> = (0..200)
3638            .map(|i| format!("paragraph {i} with some text that may wrap depending on width"))
3639            .collect();
3640        update_view(&mut v, &lines, (100, 0), rect(40), 1, None);
3641        let baseline_visual_lines = v.layout.visual_lines().to_vec();
3642
3643        // Edit a paragraph mid-buffer (no line count change).
3644        let mut edited = lines.clone();
3645        edited[100].push_str(" extra text");
3646        update_view(&mut v, &edited, (100, edited[100].len()), rect(40), 2, None);
3647
3648        // After incremental wrap, layout must equal a fresh compute of the edited buffer.
3649        let fresh_text = crate::ropetext::Text::from(edited.join("\n").as_str());
3650        let fresh_hints = row_hints(v.rendered_cache_for_testing(), &[]);
3651        let fresh_layout = Layout::compute(&fresh_text, 40, Metrics::default(), &fresh_hints);
3652
3653        let actual = v.layout.visual_lines();
3654        let fresh = fresh_layout.visual_lines();
3655        assert_eq!(actual.len(), fresh.len(), "visual_lines count diverges");
3656        for (i, (a, f)) in actual.iter().zip(fresh.iter()).enumerate() {
3657            assert_eq!(a, f, "visual line {i} diverges");
3658        }
3659
3660        // Sanity: a row outside the edit should have unchanged visual lines.
3661        let row_50_before = baseline_visual_lines
3662            .iter()
3663            .filter(|vl| vl.logical_row == 50)
3664            .count();
3665        let row_50_after = v
3666            .layout
3667            .visual_lines()
3668            .iter()
3669            .filter(|vl| vl.logical_row == 50)
3670            .count();
3671        assert_eq!(
3672            row_50_before, row_50_after,
3673            "row 50 visual_lines count should be unchanged"
3674        );
3675
3676        assert!(v.last_parse_was_incremental, "expected incremental path");
3677    }
3678
3679    #[test]
3680    fn incremental_edit_reuses_fence_ranges_without_rescanning() {
3681        // A fence block plus plain paragraphs elsewhere. An edit inside a
3682        // plain paragraph (not touching the fence) must take the
3683        // incremental path — at which point `fence_ranges` is skipped
3684        // rather than rescanned, per the structural guards that already
3685        // gate the splice. Verify it stays correct anyway.
3686        let mut v = MarkdownEditorView::new();
3687        let mut lines: Vec<String> = vec![
3688            "```".to_string(),
3689            "code line".to_string(),
3690            "```".to_string(),
3691        ];
3692        lines.extend((0..200).map(|i| format!("paragraph {i} with some text")));
3693        update_view(&mut v, &lines, (100, 0), rect(40), 1, None);
3694
3695        lines[100].push('x');
3696        update_view(&mut v, &lines, (100, lines[100].len()), rect(40), 2, None);
3697        assert!(v.last_parse_was_incremental, "expected incremental path");
3698
3699        let fresh = ParsedBuffer::parse_lines(&lines);
3700        assert_eq!(
3701            v.fence_ranges,
3702            super::super::parse_incremental::fence_ranges_from_kinds(&fresh.kinds),
3703            "fence_ranges must stay correct after a skipped recompute"
3704        );
3705    }
3706
3707    #[test]
3708    fn incremental_edit_patches_only_cursor_rows_of_gutter_insets() {
3709        // Blockquote rows at the top; a content edit far away (row 150)
3710        // combined with the cursor moving between two blockquote rows in
3711        // the same frame. The edit alone keeps the parse incremental; the
3712        // cursor move is what gutter_insets must still react to correctly
3713        // without re-walking every row.
3714        let mut v = MarkdownEditorView::new();
3715        // Blank line after the blockquote so the paragraph run below gets
3716        // its own reset boundary instead of lazily continuing the quote —
3717        // otherwise every row folds into one giant construct and even a
3718        // distant edit falls back to a full rebuild.
3719        let mut lines: Vec<String> = vec![
3720            "> quoted line 0".to_string(),
3721            "> quoted line 1".to_string(),
3722            "> quoted line 2".to_string(),
3723            String::new(),
3724        ];
3725        lines.extend((0..200).map(|i| format!("paragraph {i} with some text")));
3726        update_view(&mut v, &lines, (0, 0), rect(40), 1, None);
3727
3728        lines[151].push('x');
3729        update_view(&mut v, &lines, (1, 0), rect(40), 2, None);
3730        assert!(v.last_parse_was_incremental, "expected incremental path");
3731
3732        let mut fresh_view = MarkdownEditorView::new();
3733        update_view(&mut fresh_view, &lines, (1, 0), rect(40), 1, None);
3734        assert_eq!(
3735            v.gutter_insets_for_testing(),
3736            fresh_view.gutter_insets_for_testing(),
3737            "patched gutter_insets must match a full rebuild"
3738        );
3739    }
3740
3741    #[test]
3742    fn incremental_edit_patches_only_the_touched_code_block_of_code_box_width() {
3743        // Two fenced blocks. Growing a line inside the SECOND block must
3744        // not touch the first block's cached width, and the result must
3745        // match a full rebuild.
3746        let mut v = MarkdownEditorView::new();
3747        let mut lines: Vec<String> = vec![
3748            "```".to_string(),
3749            "short".to_string(),
3750            "```".to_string(),
3751            "paragraph between blocks".to_string(),
3752            "```".to_string(),
3753            "also short".to_string(),
3754            "```".to_string(),
3755        ];
3756        update_view(&mut v, &lines, (5, 0), rect(40), 1, None);
3757        let before_first_block = v.code_box_width_for_testing()[0..3].to_vec();
3758
3759        lines[5].push_str(" grown considerably wider now");
3760        update_view(&mut v, &lines, (5, lines[5].len()), rect(40), 2, None);
3761        assert!(v.last_parse_was_incremental, "expected incremental path");
3762
3763        assert_eq!(
3764            v.code_box_width_for_testing()[0..3],
3765            before_first_block[..],
3766            "the untouched first block's width must be unchanged"
3767        );
3768
3769        let mut fresh_view = MarkdownEditorView::new();
3770        update_view(
3771            &mut fresh_view,
3772            &lines,
3773            (5, lines[5].len()),
3774            rect(40),
3775            1,
3776            None,
3777        );
3778        assert_eq!(
3779            v.code_box_width_for_testing(),
3780            fresh_view.code_box_width_for_testing(),
3781            "patched code_box_width must match a full rebuild"
3782        );
3783    }
3784
3785    #[test]
3786    fn incremental_text_change_does_not_rebuild_all_of_rendered_cache() {
3787        // Verify that after an incremental text edit, rendered_cache rows
3788        // outside the widened range are NOT re-derived from scratch. We
3789        // can't directly observe the rebuild, but we CAN verify the cache
3790        // contents stay correct (matching a full rebuild's output).
3791        let mut v = MarkdownEditorView::new();
3792        let lines: Vec<String> = (0..200)
3793            .map(|i| format!("paragraph {i} with some text"))
3794            .collect();
3795        update_view(&mut v, &lines, (100, 0), rect(40), 1, None);
3796
3797        // Snapshot rendered_cache before the edit.
3798        let before: Vec<Vec<bool>> = v
3799            .rendered_cache
3800            .iter()
3801            .enumerate()
3802            .filter(|(i, _)| *i < 50 || *i > 150)
3803            .map(|(_, v)| v.clone())
3804            .collect();
3805
3806        // Edit a paragraph in the middle.
3807        let mut edited = lines.clone();
3808        edited[100].push('x');
3809        update_view(&mut v, &edited, (100, edited[100].len()), rect(40), 2, None);
3810
3811        // Rows far outside the damaged range must be byte-identical.
3812        let after: Vec<Vec<bool>> = v
3813            .rendered_cache
3814            .iter()
3815            .enumerate()
3816            .filter(|(i, _)| *i < 50 || *i > 150)
3817            .map(|(_, v)| v.clone())
3818            .collect();
3819        assert_eq!(
3820            before, after,
3821            "rendered_cache rows outside damaged range must be unchanged"
3822        );
3823
3824        // The incremental path must have been taken.
3825        assert!(v.last_parse_was_incremental);
3826    }
3827
3828    // §3.4 — heuristic widener fires on an in-list content edit.
3829    //
3830    // Needs a buffer big enough that strict widener (which on a
3831    // loose list with no interior reset boundaries expands to
3832    // `[0, lines.len()]`) cap-trips, so the edit falls to
3833    // widen_to_safe over the loose-list blanks. With
3834    // MAX_INCREMENTAL_LINES=256 we use ~500 items.
3835
3836    fn make_loose_list(n_items: usize) -> Vec<String> {
3837        let mut out = Vec::with_capacity(n_items * 2);
3838        for i in 0..n_items {
3839            out.push(format!("- item {i}"));
3840            if i + 1 < n_items {
3841                out.push(String::new());
3842            }
3843        }
3844        out
3845    }
3846
3847    #[test]
3848    fn try_incremental_parse_uses_heuristic_on_in_list_edit() {
3849        let mut v = MarkdownEditorView::new();
3850        let lines = make_loose_list(300);
3851        let mid_row = 200;
3852        update_view(&mut v, &lines, (mid_row, 0), rect(20), 1, None);
3853
3854        let mut edited = lines.clone();
3855        edited[mid_row].push('x');
3856        update_view(
3857            &mut v,
3858            &edited,
3859            (mid_row, edited[mid_row].len()),
3860            rect(20),
3861            2,
3862            None,
3863        );
3864
3865        assert!(
3866            v.last_parse_was_incremental,
3867            "edit inside large loose list must take incremental path \
3868             (lazy-guard relaxation + widen_to_safe over the loose-list blanks)"
3869        );
3870        assert_eq!(
3871            v.last_splice_path,
3872            Some(SplicePath::Heuristic),
3873            "expected Heuristic path on large loose list edit, got {:?}",
3874            v.last_splice_path
3875        );
3876    }
3877
3878    // §3.5 — lazy-guard relaxation must NOT skip when the edit is a
3879    // list-marker flip. The marker-flip guard above the lazy guard
3880    // should bail first, and even if it didn't, the lazy guard's
3881    // kind_qualifies check should also bail since ListMarker is the
3882    // OLD kind but the new line is a different marker (still a list
3883    // marker, so the `looks_like_list_marker` flip check passes —
3884    // both old and new look like list markers; the lazy guard would
3885    // relax). However the kinds-comparison test ensures the edit
3886    // becomes a divergent classification only via the verify path.
3887    //
3888    // Actually re-reading: marker-style flip "- a" → "* a" does NOT
3889    // change `looks_like_list_marker` (both return true). The lazy
3890    // guard relaxation lets it through. The widener attempts splice.
3891    // If the slice's per-row kinds match the parent's, no divergence;
3892    // splice succeeds. If marker-style switches the classification,
3893    // verify catches it.
3894    //
3895    // The §3.5 spec scenario "- a" → "* a" produces ListMarker in
3896    // both. Slice parses "* a" alone as a list with `*` marker;
3897    // kinds[0] = ListMarker. Parent had ListMarker too. No
3898    // divergence. Splice succeeds via the heuristic widener.
3899    //
3900    // This test instead asserts the negative: a more-aggressive
3901    // structural change (e.g. removing the marker entirely, turning
3902    // a list row into a Plain row) must bail via the existing
3903    // looks_like_list_marker flip guard (KindGuard bail).
3904    #[test]
3905    fn try_incremental_parse_lazy_guard_still_bails_on_marker_removal() {
3906        let mut v = MarkdownEditorView::new();
3907        let lines: Vec<String> = vec!["- a".into(), "".into(), "- b".into()];
3908        update_view(&mut v, &lines, (0, 3), rect(20), 1, None);
3909
3910        let mut edited = lines.clone();
3911        edited[0] = "a".into(); // remove marker — `- a` → `a`
3912        update_view(&mut v, &edited, (0, 1), rect(20), 2, None);
3913
3914        // The looks_like_list_marker flip guard above the lazy guard
3915        // must bail this case (KindGuard). The lazy-guard relaxation
3916        // never sees it.
3917        assert!(
3918            !v.last_parse_was_incremental,
3919            "list-marker removal must NOT take incremental path \
3920             — looks_like_list_marker flip guard bails first"
3921        );
3922    }
3923
3924    #[test]
3925    fn apply_code_box_sets_bg_and_pads_to_width() {
3926        use ratatui::text::Span;
3927        let theme = crate::settings::themes::Theme::gruvbox_dark();
3928        let spans = vec![Span::raw("ab")]; // 2 cols
3929        let out = super::apply_code_box(spans, 5, &theme);
3930        let total: usize = out.iter().map(|s| s.content.chars().count()).sum();
3931        assert_eq!(total, 5); // padded to box width
3932        let bg = theme.code_bg.to_ratatui();
3933        assert!(out.iter().all(|s| s.style.bg == Some(bg)));
3934    }
3935
3936    #[test]
3937    fn apply_code_box_measures_emoji_cluster_at_full_width() {
3938        // Regression: padding must use the same cluster model as
3939        // `raw_display_width` (which sizes the box). "a❤️" = 'a' (1) + VS16 heart
3940        // (2) = 3 rendered cols. Per-codepoint width undercounts the heart as 1,
3941        // over-padding the box and overshooting box_width. With cluster width the
3942        // content already fills 3 cols, so a box_width of 3 needs zero padding.
3943        use ratatui::text::Span;
3944        let theme = crate::settings::themes::Theme::gruvbox_dark();
3945        let content = "a\u{2764}\u{FE0F}";
3946        assert_eq!(super::super::markdown::raw_display_width(content), 3);
3947        let out = super::apply_code_box(vec![Span::raw(content)], 3, &theme);
3948        // No padding span appended — content already 3 cols.
3949        assert_eq!(out.len(), 1);
3950        assert_eq!(out[0].content.as_ref(), content);
3951    }
3952
3953    #[test]
3954    fn click_on_barred_blockquote_maps_past_gutter() {
3955        // Blockquote on row 0 is NOT the cursor row (cursor parked on row 1),
3956        // so row 0 renders "│ hello". vrow 0 is that row's single visual line.
3957        let lines = vec!["> hello".to_string(), "tail".to_string()];
3958        let view = make_view_for_lines(&lines, (1, 0), 80);
3959        // Click screen col 2 ('h' after the 2-col "│ " gutter) → logical col 2.
3960        let (row, col) = view.click_to_logical_for_testing(0, 2);
3961        assert_eq!((row, col), (0, 2));
3962    }
3963}
3964
3965/// Differential soak for the incremental parse widener.
3966///
3967/// The widener's guards were narrowed to `ListMarker` because a 100 000-case run
3968/// found `Blockquote`, `Plain` and `ListContinuation` producing classifications
3969/// that disagreed with a fresh parse — usually on a row *past* `widened.end`,
3970/// where the in-window post-slice verify does not look. Nothing in the ordinary
3971/// suite reproduces that: unlocking `Blockquote` leaves every test green. This is
3972/// the harness that does not.
3973///
3974/// Ignored by default because 100k cases is minutes, not milliseconds:
3975///
3976/// ```text
3977/// SOAK_CASES=100000 cargo test -p kimun-notes --lib widener_soak -- --ignored --nocapture
3978/// ```
3979///
3980/// **To evaluate an unlock**, widen `kind_qualifies` in `try_incremental_parse`
3981/// to the kind under test and re-run. A green soak is the evidence the guard's
3982/// own comment asks for; anything else is a reason the kind stays excluded.
3983#[cfg(test)]
3984mod widener_soak {
3985    use super::tests::update_view;
3986    use super::*;
3987    use proptest::prelude::*;
3988
3989    /// Blocks, not independent rows.
3990    ///
3991    /// The failure this harness exists to reproduce needs a specific
3992    /// arrangement — a `>` row nested *inside* a list item (so `lazy_depth == 1`)
3993    /// with a blank immediately after it, which is where `damaged.end` lands and
3994    /// where the post-edit parse can flip the row to `ListContinuation`. Rows
3995    /// drawn independently produce blockquotes and lists constantly and that
3996    /// arrangement almost never, which is why the first version of this soak
3997    /// passed 20 000 cases of a configuration known to be wrong.
3998    fn block() -> impl Strategy<Value = Vec<String>> {
3999        prop_oneof![
4000            Just(vec!["plain paragraph text".to_string(), String::new()]),
4001            Just(vec![
4002                "plain paragraph".to_string(),
4003                "second line of it".to_string(),
4004                String::new(),
4005            ]),
4006            // A list holding a quote: the nested-lazy shape.
4007            Just(vec![
4008                "- list item".to_string(),
4009                "  > nested quote".to_string(),
4010                String::new(),
4011            ]),
4012            // The same with a marker carrying only whitespace — the exact row the
4013            // soak's original finding named.
4014            Just(vec![
4015                "- list item".to_string(),
4016                ">     ".to_string(),
4017                String::new(),
4018            ]),
4019            Just(vec![
4020                "- list item".to_string(),
4021                "  continuation".to_string(),
4022                "  > quote inside".to_string(),
4023                String::new(),
4024            ]),
4025            Just(vec![
4026                "> quoted line".to_string(),
4027                "lazy continuation".to_string(),
4028                String::new(),
4029            ]),
4030            Just(vec![">     ".to_string(), String::new()]),
4031            Just(vec!["# heading".to_string(), String::new()]),
4032            Just(vec![
4033                "```".to_string(),
4034                "fenced".to_string(),
4035                "```".to_string(),
4036                String::new(),
4037            ]),
4038            Just(vec!["    indented code".to_string(), String::new()]),
4039            Just(vec![
4040                "setext".to_string(),
4041                "=====".to_string(),
4042                String::new()
4043            ]),
4044        ]
4045    }
4046
4047    /// Edits that keep the row count. Marker-altering ones included: an edit that
4048    /// flips a kind is *supposed* to be refused by the guards above the
4049    /// relaxation, and a soak that only appends letters never tests that.
4050    fn edit(original: &str) -> Vec<String> {
4051        let mut out = vec![
4052            format!("{original}x"),
4053            format!("{original} "),
4054            format!("> {original}"),
4055            format!("  {original}"),
4056        ];
4057        if !original.is_empty() {
4058            out.push(original[..original.len() - 1].to_string());
4059            out.push(original.trim_start().to_string());
4060            out.push(original.replacen('>', " ", 1));
4061            out.push(original.replacen('-', " ", 1));
4062        }
4063        // Only variants that actually change the row. Several of these are
4064        // no-ops on some inputs — `trim_start` on an already-trimmed row,
4065        // `replacen('>')` on a row without one — and filtering here rather than
4066        // rejecting in the test is what keeps proptest from aborting on global
4067        // rejects long before it has explored anything.
4068        out.retain(|candidate| candidate != original);
4069        out.dedup();
4070        out
4071    }
4072
4073    fn cases() -> u32 {
4074        std::env::var("SOAK_CASES")
4075            .ok()
4076            .and_then(|v| v.parse().ok())
4077            .unwrap_or(256)
4078    }
4079
4080    proptest! {
4081        #![proptest_config(ProptestConfig { cases: cases(), ..ProptestConfig::default() })]
4082
4083        #[test]
4084        #[ignore = "soak: run explicitly with SOAK_CASES"]
4085        fn an_incremental_splice_agrees_with_a_fresh_parse(
4086            blocks in prop::collection::vec(block(), 2..8),
4087            row_pick in any::<prop::sample::Index>(),
4088            edit_pick in any::<prop::sample::Index>(),
4089        ) {
4090            let lines: Vec<String> = blocks.concat();
4091            prop_assume!(lines.len() >= 3);
4092            let target = row_pick.index(lines.len());
4093            let variants = edit(&lines[target]);
4094            prop_assume!(!variants.is_empty());
4095            let mut edited = lines.clone();
4096            edited[target] = variants[edit_pick.index(variants.len())].clone();
4097
4098            let mut view = MarkdownEditorView::new();
4099            update_view(&mut view, &lines, (target, 0), Rect::new(0, 0, 40, 20), 1, None);
4100            view.note_damage(target..target + 1, 0);
4101            update_view(&mut view, &edited, (target, 0), Rect::new(0, 0, 40, 20), 2, None);
4102
4103            // A full-parse fallback trivially agrees; only a *wrong splice* fails.
4104            let fresh = ParsedBuffer::parse_lines(&edited);
4105            prop_assert_eq!(
4106                &view.parse_state.buf().kinds,
4107                &fresh.kinds,
4108                "kinds diverged (incremental={}) for {:?} -> row {} = {:?}",
4109                view.last_parse_was_incremental(),
4110                lines,
4111                target,
4112                edited[target]
4113            );
4114            prop_assert_eq!(
4115                &view.parse_state.buf().lazy_depth,
4116                &fresh.lazy_depth,
4117                "lazy_depth diverged (incremental={})",
4118                view.last_parse_was_incremental()
4119            );
4120        }
4121    }
4122}