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