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