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