Skip to main content

kimun_notes/components/text_editor/
mod.rs

1pub mod autocomplete_glue;
2pub mod backend;
3pub mod markdown;
4pub mod nvim_decode;
5pub mod nvim_host;
6pub mod nvim_rpc;
7pub mod parse_incremental;
8pub mod snapshot;
9pub mod text_coords;
10pub mod view;
11mod vim;
12pub mod widener_metrics;
13pub mod word_wrap;
14
15use arboard::Clipboard;
16use ratatui::Frame;
17use ratatui::crossterm::event::{KeyCode, KeyModifiers, MouseButton, MouseEventKind};
18use ratatui::layout::Rect;
19use ratatui::style::{Modifier, Style};
20use ratatui::text::{Line, Span};
21use ratatui::widgets::Paragraph;
22use ratatui_textarea::{CursorMove, DataCursor, TextArea};
23use std::num::NonZeroU64;
24
25/// Convert `TextArea::cursor()` from the library's `DataCursor` newtype to a
26/// plain `(row, col)` tuple — the neutral interchange type shared with the
27/// Nvim backend (whose `NvimSnapshot::cursor` is already a tuple).
28pub(crate) fn cursor_tuple(ta: &TextArea<'_>) -> (usize, usize) {
29    let DataCursor(r, c) = ta.cursor();
30    (r, c)
31}
32
33/// Build an `EditorSnapshot` from the editor's backend + content
34/// revision. Free function (not a method on `TextEditorComponent`) so
35/// production callers that need to mutate other fields of
36/// `TextEditorComponent` afterwards can pass `&self.backend` and
37/// `self.content_revision` directly — the borrow checker can split
38/// borrows across distinct fields but not across method calls.
39fn snapshot_from_backend(
40    backend: &BackendState,
41    content_revision: NonZeroU64,
42) -> EditorSnapshot<'_> {
43    match backend {
44        BackendState::Textarea(tb) => {
45            let cursor = cursor_tuple(&tb.ta);
46            EditorSnapshot::borrowed(tb.ta.lines(), cursor, content_revision)
47        }
48        BackendState::Nvim(nvim) => {
49            let snap = nvim.snapshot();
50            let lines_len = snap.lines.len();
51            let cursor_row = if lines_len == 0 {
52                0
53            } else {
54                snap.cursor.0.min(lines_len - 1)
55            };
56            let cursor = (cursor_row, snap.cursor.1);
57            let lines = snap.lines.clone();
58            let rev = NonZeroU64::new(snap.content_gen.saturating_add(1))
59                .unwrap_or_else(|| NonZeroU64::new(1).unwrap());
60            drop(snap);
61            EditorSnapshot::owned(lines, cursor, rev)
62        }
63    }
64}
65
66/// Returns true if any autocomplete trigger char (`[` for `[[wikilink`,
67/// `#` for `#hashtag`) appears between the start of `line` and the
68/// cursor's char column. Walks backwards from the cursor so the common
69/// "user just typed inside a trigger" case short-circuits quickly. The
70/// scan stays within one row because triggers can't cross a newline.
71///
72/// UTF-8 safe: takes a char column and never slices on a byte that is
73/// not a codepoint boundary. Wikilinks can contain spaces
74/// (`[[my note title`), so the walk does NOT stop at whitespace — only
75/// the trigger char or start-of-row halts it.
76fn has_trigger_before_cursor(line: &str, col: usize) -> bool {
77    let cursor_byte = line
78        .char_indices()
79        .nth(col)
80        .map(|(b, _)| b)
81        .unwrap_or(line.len());
82    line[..cursor_byte]
83        .chars()
84        .rev()
85        .any(|c| c == '[' || c == '#')
86}
87
88/// Move or extend the selection by `movement`.
89///
90/// If `shift` is held and no selection is currently active, anchors the selection
91/// first; otherwise the existing anchor is kept. Without `shift`, any active
92/// selection is cancelled before the cursor moves.
93macro_rules! cursor_move {
94    ($ta:expr, $mv:expr, $shift:expr) => {{
95        if $shift {
96            if $ta.selection_range().is_none() {
97                $ta.start_selection();
98            }
99        } else {
100            $ta.cancel_selection();
101        }
102        $ta.move_cursor($mv);
103    }};
104}
105
106use self::backend::BackendState;
107use self::markdown::ParsedBuffer;
108use self::nvim_host::{NvimHost, NvimKeyResult};
109use self::snapshot::EditorSnapshot;
110use self::view::MarkdownEditorView;
111use crate::util::single_slot_task::SingleSlotTask;
112
113/// If `marker` is an ordered-list marker like `"3. "`, returns the next marker
114/// (`"4. "`). Returns `None` for unordered markers or unrecognized input.
115fn increment_ordered_marker(marker: &str) -> Option<String> {
116    let trimmed = marker.trim_end_matches(' ');
117    let dot = trimmed.strip_suffix('.')?;
118    let n: u32 = dot.parse().ok()?;
119    Some(format!("{}. ", n + 1))
120}
121
122/// Convert a 0-based character column into a byte offset within `line`.
123/// Out-of-range columns return `line.len()`.
124fn char_col_to_byte(line: &str, char_col: usize) -> usize {
125    line.char_indices()
126        .nth(char_col)
127        .map(|(b, _)| b)
128        .unwrap_or(line.len())
129}
130
131/// Returns the text covered by the textarea's current selection, or `None` if
132/// there is no selection or the range is empty.
133///
134/// `selection_range()` returns char-column coordinates, so they must be
135/// converted to byte offsets before slicing to support multi-byte UTF-8 text.
136fn selection_text(ta: &TextArea<'_>) -> Option<String> {
137    let ((sr, sc), (er, ec)) = ta.selection_range()?;
138    if sr == er && sc == ec {
139        return None;
140    }
141    let lines = ta.lines();
142    Some(if sr == er {
143        let line = &lines[sr];
144        let sb = char_col_to_byte(line, sc);
145        let eb = char_col_to_byte(line, ec);
146        line[sb..eb].to_string()
147    } else {
148        let first = &lines[sr];
149        let sb = char_col_to_byte(first, sc);
150        let mut parts = vec![first[sb..].to_string()];
151        for line in &lines[(sr + 1)..er] {
152            parts.push(line.clone());
153        }
154        let last = &lines[er];
155        let eb = char_col_to_byte(last, ec);
156        parts.push(last[..eb].to_string());
157        parts.join("\n")
158    })
159}
160
161/// Auto-surround pair for `c`: typing an opening pair character or a
162/// symmetric one while a selection is active wraps the selection instead of
163/// replacing it. Closing characters return `None` — they replace, like any
164/// other key. See CONTEXT.md "Auto-surround".
165fn surround_pair(c: char) -> Option<(&'static str, &'static str)> {
166    match c {
167        '(' => Some(("(", ")")),
168        '[' => Some(("[", "]")),
169        '{' => Some(("{", "}")),
170        '<' => Some(("<", ">")),
171        '"' => Some(("\"", "\"")),
172        '\'' => Some(("'", "'")),
173        '`' => Some(("`", "`")),
174        '*' => Some(("*", "*")),
175        '_' => Some(("_", "_")),
176        '~' => Some(("~", "~")),
177        _ => None,
178    }
179}
180
181/// Re-establishes the textarea selection over `start..end` (char-based data
182/// coordinates, as returned by `selection_range`). `Jump` clamps, so the
183/// saturating casts degrade gracefully on pathologically large buffers.
184fn set_selection(ta: &mut TextArea<'_>, start: (usize, usize), end: (usize, usize)) {
185    let jump = |(row, col): (usize, usize)| {
186        CursorMove::Jump(
187            u16::try_from(row).unwrap_or(u16::MAX),
188            u16::try_from(col).unwrap_or(u16::MAX),
189        )
190    };
191    ta.cancel_selection();
192    ta.move_cursor(jump(start));
193    ta.start_selection();
194    ta.move_cursor(jump(end));
195}
196
197/// Owned RGBA image data lifted from the system clipboard. Returned by
198/// [`TextEditorComponent::take_clipboard_image`] so the screen layer can
199/// encode + persist without holding the editor's clipboard borrow.
200#[derive(Debug, Clone)]
201pub struct ClipboardImage {
202    pub width: usize,
203    pub height: usize,
204    pub rgba: Vec<u8>,
205}
206
207/// Schemes the paste-over-selection flow recognises as "linkable" — broader
208/// than `core::note::scan::is_remote_url` (http/https only) because users routinely paste
209/// `mailto:` and FTP links and expect them wrapped as markdown links too.
210const LINKABLE_PASTE_SCHEMES: &[&str] = &["http", "https", "ftp", "ftps", "mailto"];
211
212fn linkable_url(s: &str) -> Option<&str> {
213    kimun_core::note::scan::url_with_allowed_scheme(s, LINKABLE_PASTE_SCHEMES)
214}
215
216/// If `clip` is a linkable URL and `selection` is non-empty, returns
217/// `Some("[escaped_selection](url)")`. Otherwise returns `None`, signalling the
218/// caller to insert `clip` verbatim.
219fn try_build_markdown_link(clip: &str, selection: Option<&str>) -> Option<String> {
220    let url = linkable_url(clip)?;
221    let sel = selection.filter(|s| !s.is_empty())?;
222    let escaped = sel.replace('\\', r"\\").replace(']', r"\]");
223    Some(format!("[{escaped}]({url})"))
224}
225
226use std::sync::Arc;
227
228use kimun_core::NoteVault;
229
230use crate::components::Component;
231use crate::components::autocomplete::{
232    self, AutocompleteController, AutocompleteHost, AutocompleteMode, HandleKeyOutcome,
233};
234use crate::components::event_state::EventState;
235use crate::components::events::AppEvent;
236use crate::components::events::AppTx;
237use crate::components::events::InputEvent;
238use crate::components::events::redraw_callback;
239use crate::components::single_line_input::{InputOutcome, SingleLineInput};
240use crate::components::text_editor::autocomplete_glue::apply_accept_to_textarea;
241use crate::keys::KeyBindings;
242use crate::keys::action_shortcuts::TextAction;
243use crate::settings::AppSettings;
244use crate::settings::themes::Theme;
245
246/// The resolved target of a cursor follow-link action.
247#[derive(Debug, Clone, PartialEq)]
248pub enum LinkTarget {
249    /// A note reference (wiki-link or markdown link) with the raw target string.
250    Note(String),
251    /// A hashtag label with the name **without** the leading `#`.
252    Label(String),
253}
254
255struct SearchState {
256    input: SingleLineInput,
257    status: SearchStatus,
258}
259
260enum SearchStatus {
261    Empty,
262    Match,
263    NoMatch,
264    Invalid(String),
265}
266
267impl SearchStatus {
268    fn from_found(found: bool) -> Self {
269        if found { Self::Match } else { Self::NoMatch }
270    }
271}
272
273const FIND_PROMPT: &str = "Find: ";
274const FIND_HINTS: &str = "  [Enter] next  [Shift+Enter] prev  [Esc] close";
275
276fn render_search_bar(
277    f: &mut Frame,
278    rect: Rect,
279    state: &mut SearchState,
280    theme: &Theme,
281    focused: bool,
282) {
283    let base = theme.base_style();
284    let muted = Style::default()
285        .fg(theme.gray.to_ratatui())
286        .bg(theme.bg.to_ratatui());
287    let err = Style::default()
288        .fg(theme.red.to_ratatui())
289        .bg(theme.bg.to_ratatui());
290    let prompt_cols = unicode_width::UnicodeWidthStr::width(FIND_PROMPT) as u16;
291    // Tail sits after the full value (in display columns, accounting for
292    // wide/CJK chars), not after the caret — otherwise it would overlap the
293    // trailing characters when the user moves the cursor mid-string.
294    let value_total_cols = state.input.display_width() as u16;
295    let tail: Option<(String, Style)> = match &state.status {
296        SearchStatus::Empty => None,
297        SearchStatus::Match => Some((FIND_HINTS.to_string(), muted)),
298        SearchStatus::NoMatch => Some(("  no match".to_string(), err)),
299        SearchStatus::Invalid(msg) => Some((format!("  invalid regex: {msg}"), err)),
300    };
301    f.render_widget(
302        Paragraph::new(Line::from(Span::styled(
303            FIND_PROMPT,
304            base.add_modifier(Modifier::BOLD),
305        )))
306        .style(base),
307        Rect {
308            width: prompt_cols.min(rect.width),
309            ..rect
310        },
311    );
312    state.input.render(f, rect, base, prompt_cols, focused);
313    if let Some((text, style)) = tail {
314        let consumed = prompt_cols.saturating_add(value_total_cols);
315        let tail_rect = Rect {
316            x: rect.x.saturating_add(consumed),
317            width: rect.width.saturating_sub(consumed),
318            ..rect
319        };
320        f.render_widget(Paragraph::new(text).style(style), tail_rect);
321    }
322}
323
324/// Snapshot used to satisfy `AutocompleteHost`. Wraps an
325/// `EditorSnapshot` (Cow-borrowed from the textarea on the common
326/// path — perf #8) plus the cursor's last-rendered screen
327/// position. The host's `cache_key` mirrors the editor's
328/// `content_revision`; `None` is reserved for hosts whose buffer
329/// has no stable identity (the search-box modal).
330struct EditorHostSnapshot<'a> {
331    snap: EditorSnapshot<'a>,
332    cursor_screen: Option<(u16, u16)>,
333    cache_key: Option<NonZeroU64>,
334}
335
336impl<'a> AutocompleteHost for EditorHostSnapshot<'a> {
337    fn buffer_snapshot(&self) -> EditorSnapshot<'_> {
338        // Re-package the inner snap as a fresh borrowed view tied
339        // to `&self`. `Cow::as_ref` works for both Borrowed and
340        // Owned variants — the latter only occurs on the Nvim path
341        // where the inner snapshot already paid the clone cost.
342        EditorSnapshot::borrowed(
343            self.snap.lines.as_ref(),
344            self.snap.cursor,
345            self.snap.content_revision,
346        )
347    }
348    fn cache_key(&self) -> Option<NonZeroU64> {
349        self.cache_key
350    }
351    fn screen_anchor_for(&self, _byte_offset: usize) -> Option<(u16, u16)> {
352        // Anchor at the cursor's last-rendered screen position. The
353        // controller passes `anchor_col` (byte offset of the start of
354        // the typed query) but visually anchoring at the cursor is
355        // fine — the popup sits adjacent to the typed text either way
356        // and avoids re-walking the wrap layout for an arbitrary byte
357        // offset.
358        //
359        // When `cursor_screen` is None (no prior render — e.g. the
360        // user opens a note and types `[[` before the first frame),
361        // return a placeholder so the controller still opens the
362        // popup. The editor's render path skips drawing it until
363        // `view.last_cursor_screen` is available, then re-anchors and
364        // draws with the correct position.
365        Some(self.cursor_screen.unwrap_or((0, 0)))
366    }
367}
368
369/// Free-function builder for `EditorHostSnapshot`. Production
370/// callers pass `&self.backend`, `self.content_revision`,
371/// `self.view.last_cursor_screen` directly so the borrow checker
372/// can split borrows from `&mut self.autocomplete`. Returns `None`
373/// on the Nvim backend (autocomplete is Textarea-only).
374fn build_editor_host_snapshot<'a>(
375    backend: &'a BackendState,
376    content_revision: NonZeroU64,
377    cursor_screen: Option<(u16, u16)>,
378) -> Option<EditorHostSnapshot<'a>> {
379    if !backend.is_textarea() {
380        return None;
381    }
382    Some(EditorHostSnapshot {
383        snap: snapshot_from_backend(backend, content_revision),
384        cursor_screen,
385        cache_key: Some(content_revision),
386    })
387}
388
389/// Snapshot of the textarea backend used to classify a key event as a
390/// text edit (text differs) vs. a pure cursor move (text same, cursor
391/// moved) vs. a no-op (both same).
392pub struct TextEditorComponent {
393    backend: BackendState,
394    /// Tracks the rendered rect to map mouse click coordinates.
395    rect: Rect,
396    key_bindings: KeyBindings,
397    /// `content_revision` snapshot that matches the on-disk content.
398    /// `Some(content_revision)` after a successful save (or after
399    /// `set_text` loaded a note); `None` when the saved snapshot
400    /// diverges from the current buffer. Compared against
401    /// `content_revision` by `is_dirty()` so the per-frame title bar
402    /// avoids materialising the buffer and so cursor moves (which bump
403    /// `edit_generation` but not `content_revision`) don't flag the
404    /// buffer as dirty.
405    saved_content_rev: Option<NonZeroU64>,
406    view: MarkdownEditorView,
407    /// Incremented on every input event that may affect rendering — text
408    /// edits AND cursor/selection moves. Drives view-cache invalidation in
409    /// non-perf-critical paths; do NOT use for dirty tracking (cursor moves
410    /// bump this too).
411    edit_generation: u64,
412    /// Incremented only when the buffer text actually changes (insert,
413    /// delete, paste, undo/redo, autocomplete accept). Cursor-only
414    /// shortcuts (arrows, Home/End, select-all) do NOT bump this. On
415    /// the Nvim backend, `handle_key` does not bump either — the
416    /// reverse-refresh task in `backend.rs` sees `snap.lines` change
417    /// and bumps `snap.content_gen`; the editor mirrors that value
418    /// into `content_revision` at the render sync point. Consumers:
419    ///   - `handle_input` diffs it across a key event to classify the
420    ///     event as a text edit vs. a cursor move without materialising
421    ///     the buffer.
422    ///   - `view.update()` uses the value as the cache-invalidation
423    ///     key, so arrow-key navigation reuses the per-line parse cache
424    ///     instead of rebuilding it.
425    ///   - `AutocompleteHost::content_revision` exposes it as a
426    ///     `NonZeroU64` cache key.
427    ///   - `mark_saved_at_revision` / `is_dirty` use it as the
428    ///     save-correlation token; navigation keys never invalidate a
429    ///     save in flight.
430    ///
431    /// `NonZeroU64` because `Option<NonZeroU64>` is the cleanest way
432    /// to express "no cacheable revision" without a magic-value
433    /// sentinel and without a separate field.
434    content_revision: NonZeroU64,
435    /// Current selection range in logical (row, byte-col) coordinates.
436    /// Only tracked for the Textarea backend; always `None` for Nvim.
437    selection: Option<((usize, usize), (usize, usize))>,
438    /// System clipboard handle. `None` if the clipboard is unavailable (e.g. headless CI).
439    clipboard: Option<Clipboard>,
440    /// Host-side state and policy for the Nvim backend (pending-Z intercept,
441    /// frame sync). See [`nvim_host`].
442    nvim_host: NvimHost,
443    /// Active Ctrl+F find bar; `None` when not searching.
444    search: Option<SearchState>,
445    /// Wikilink/hashtag autocomplete. Only populated for the textarea
446    /// backend after `set_vault` is called; remains `None` for the Nvim
447    /// backend (nvim users have their own completion ecosystem).
448    autocomplete: Option<AutocompleteController>,
449    /// Vault handle stored at `set_vault` time. Kept even on the Nvim
450    /// backend so `maybe_recover_from_dead_nvim` can spin up the
451    /// autocomplete controller after the fallback to Textarea.
452    autocomplete_vault: Option<Arc<NoteVault>>,
453    /// Whether the autocomplete controller's redraw callback has been
454    /// bound to the app event bus. Bound lazily on the first
455    /// `handle_input` because `AppTx` is not available at
456    /// construction.
457    autocomplete_redraw_bound: bool,
458    /// Background full-parse fallback for large buffers (perf #9).
459    /// The view installs a placeholder `ParsedBuffer` and signals
460    /// pending; this slot owns the spawned tokio task that runs
461    /// the real `ParsedBuffer::parse`. `SingleSlotTask` aborts the
462    /// previous spawn on a fresh edit, so a burst of edits resolves
463    /// against the latest content.
464    full_parse_task: SingleSlotTask<()>,
465    /// Set by a right-click with no selection: the host (which owns the note
466    /// path) opens the note's context menu and clears the flag.
467    pub wants_context_menu: bool,
468    /// Lowercased needles to emphasize in the rendered buffer — set when the
469    /// note was opened from a query result (spec §5.1 "search match"), and
470    /// dropped on the first edit (`needles_revision` mismatch).
471    search_needles: Vec<String>,
472    /// The content revision `search_needles` was set against.
473    needles_revision: Option<NonZeroU64>,
474    full_parse_tx: tokio::sync::mpsc::UnboundedSender<(u64, ParsedBuffer)>,
475    full_parse_rx: tokio::sync::mpsc::UnboundedReceiver<(u64, ParsedBuffer)>,
476    /// `AppTx` clone bound the first time `handle_input` runs, so the
477    /// spawned full-parse task can post `AppEvent::Redraw` on
478    /// completion without waiting for the next user keystroke.
479    redraw_tx: Option<AppTx>,
480}
481
482impl TextEditorComponent {
483    pub fn new(key_bindings: KeyBindings, settings: &AppSettings) -> Self {
484        let (full_parse_tx, full_parse_rx) = tokio::sync::mpsc::unbounded_channel();
485        Self {
486            backend: BackendState::from_settings(
487                &settings.editor_backend,
488                settings.nvim_path.as_ref(),
489            ),
490            rect: Rect::default(),
491            key_bindings,
492            saved_content_rev: NonZeroU64::new(1),
493            view: MarkdownEditorView::new(),
494            edit_generation: 0,
495            content_revision: NonZeroU64::new(1).unwrap(),
496            selection: None,
497            clipboard: Clipboard::new().ok(),
498            nvim_host: NvimHost::new(),
499            search: None,
500            autocomplete: None,
501            autocomplete_vault: None,
502            autocomplete_redraw_bound: false,
503            full_parse_task: SingleSlotTask::empty(),
504            wants_context_menu: false,
505            search_needles: Vec::new(),
506            needles_revision: None,
507            full_parse_tx,
508            full_parse_rx,
509            redraw_tx: None,
510        }
511    }
512
513    /// Attach a vault so autocomplete can query notes/tags. Activates
514    /// the controller immediately on the textarea backend; on Nvim, the
515    /// vault is stashed and the controller is spun up later if
516    /// `maybe_recover_from_dead_nvim` falls back to Textarea.
517    pub fn set_vault(&mut self, vault: Arc<NoteVault>) {
518        self.autocomplete_vault = Some(vault.clone());
519        if self.backend.is_textarea() {
520            self.autocomplete = Some(AutocompleteController::new(
521                std::sync::Arc::new(crate::components::search_list::VaultSuggestions { vault }),
522                AutocompleteMode::Both,
523            ));
524        }
525    }
526
527    /// Spin up the autocomplete controller if a vault was previously
528    /// stashed and the controller isn't already running. Called after
529    /// the Nvim → Textarea fallback so the post-crash session has the
530    /// popup available.
531    fn ensure_autocomplete_for_textarea(&mut self) {
532        if self.autocomplete.is_some() {
533            return;
534        }
535        if !self.backend.is_textarea() {
536            return;
537        }
538        let Some(vault) = self.autocomplete_vault.clone() else {
539            return;
540        };
541        self.autocomplete = Some(AutocompleteController::new(
542            std::sync::Arc::new(crate::components::search_list::VaultSuggestions { vault }),
543            AutocompleteMode::Both,
544        ));
545        // Fresh controller — `bind_autocomplete_redraw` must rebind
546        // on the next handle_input.
547        self.autocomplete_redraw_bound = false;
548    }
549
550    /// Build a snapshot view of the editor state for the autocomplete
551    /// controller. Method form wraps `build_editor_host_snapshot` for
552    /// callers that do not need to split borrows; production hot
553    /// paths (`refresh_autocomplete_if_open`, `sync_autocomplete`)
554    /// inline the free function instead so `&self.backend` and
555    /// `&mut self.autocomplete` can coexist.
556    #[allow(dead_code)]
557    fn autocomplete_host_snapshot(&self) -> Option<EditorHostSnapshot<'_>> {
558        build_editor_host_snapshot(
559            &self.backend,
560            self.content_revision,
561            self.view.last_cursor_screen,
562        )
563    }
564
565    /// Pull the latest async query results into the popup state. Called
566    /// once per render before drawing the overlay.
567    fn poll_autocomplete(&mut self) {
568        if let Some(controller) = self.autocomplete.as_mut() {
569            controller.poll_results();
570        }
571    }
572
573    /// Cheap cursor read — `None` for the Nvim backend. Used by `handle_input`
574    /// to diff cursor position across a key event without materialising the
575    /// whole buffer.
576    fn textarea_cursor(&self) -> Option<(usize, usize)> {
577        let ta = self.backend.as_textarea()?;
578        Some(cursor_tuple(ta))
579    }
580
581    fn refresh_autocomplete_if_open(&mut self) {
582        // No controller (e.g. Nvim backend) or popup closed → nothing to refresh.
583        if !self.autocomplete.as_ref().is_some_and(|c| c.is_open()) {
584            return;
585        }
586        // Inline the snapshot via the free function so `&self.backend`
587        // (the snapshot's borrow source) and `&mut self.autocomplete`
588        // (the controller below) can coexist via field-disjoint borrows.
589        let Some(snapshot) = build_editor_host_snapshot(
590            &self.backend,
591            self.content_revision,
592            self.view.last_cursor_screen,
593        ) else {
594            self.close_autocomplete();
595            return;
596        };
597        if let Some(controller) = self.autocomplete.as_mut() {
598            controller.refresh_if_open(&snapshot);
599        }
600    }
601
602    /// Recompute the popup's trigger context from the current buffer and
603    /// cursor. Call after any mutating key handle (typed letter, paste,
604    /// backspace, cursor movement, etc.).
605    fn sync_autocomplete(&mut self) {
606        let Some(controller) = self.autocomplete.as_ref() else {
607            return; // Nvim backend or no controller
608        };
609
610        // Fast-path bail: when the popup is closed AND no trigger character
611        // appears between the cursor and the start of the current row, no
612        // reconcile can open a popup. Skip the expensive buffer snapshot +
613        // pulldown-cmark scan.
614        //
615        // Trigger chars: `[` (for `[[wikilink`) and `#` (for `#hashtag`).
616        // Wikilinks can contain spaces (`[[my note title`), so the scan
617        // walks back to the start of the row, not to the nearest whitespace.
618        // The walk short-circuits on the first trigger char, so for typical
619        // lines it touches only a handful of chars before bailing or
620        // promoting to the slow path. Using `char_indices().rev()` keeps
621        // the walk UTF-8-safe — never slices mid-codepoint.
622        if !controller.is_open() {
623            let Some(ta) = self.backend.as_textarea() else {
624                return;
625            };
626            let (row, col) = cursor_tuple(ta);
627            let line = ta.lines().get(row).map(|s| s.as_str()).unwrap_or("");
628            if !has_trigger_before_cursor(line, col) {
629                return;
630            }
631        }
632
633        // Slow path: build the borrowed snapshot for the controller to
634        // reconcile. Free function so `&self.backend` and
635        // `&mut self.autocomplete` can coexist.
636        let Some(snapshot) = build_editor_host_snapshot(
637            &self.backend,
638            self.content_revision,
639            self.view.last_cursor_screen,
640        ) else {
641            if let Some(c) = self.autocomplete.as_mut() {
642                c.close();
643            }
644            return;
645        };
646        if let Some(controller) = self.autocomplete.as_mut() {
647            controller.sync(&snapshot);
648        }
649    }
650
651    /// Returns the buffer lines for direct access.
652    ///
653    /// For the Textarea backend, returns the live lines.
654    /// For the Nvim backend, returns an empty slice — use `get_text()` instead,
655    /// which reads from the snapshot.
656    pub fn lines(&self) -> &[String] {
657        match &self.backend {
658            BackendState::Textarea(tb) => tb.ta.lines(),
659            BackendState::Nvim(_) => &[],
660        }
661    }
662
663    /// Single producer for the editor's atomic `(lines, cursor,
664    /// content_revision)` view. Downstream consumers (`MarkdownEditorView`,
665    /// `click_to_logical_u16`, the autocomplete host) take a
666    /// `&EditorSnapshot` and stop guarding against drift between cursor
667    /// and lines on every leaf access — the snapshot owns that
668    /// invariant at construction time.
669    ///
670    /// On the Textarea backend the snapshot borrows live lines (no
671    /// clone) and the cursor is already in-bounds. On the Nvim backend
672    /// the lines are cloned out from behind the `Mutex` (same cost as
673    /// today's render path) and the cursor row is clamped to
674    /// `lines.len() - 1` before the snapshot is returned.
675    ///
676    /// Production hot paths that also need `&mut self.view` (notably
677    /// `render`) must instead inline the snapshot via
678    /// `snapshot_from_backend(&self.backend, self.content_revision)`
679    /// so the borrow checker can split the borrows across distinct
680    /// fields.
681    pub fn view_snapshot(&self) -> EditorSnapshot<'_> {
682        snapshot_from_backend(&self.backend, self.content_revision)
683    }
684
685    /// The cursor's (row, col) without materialising a snapshot — the Nvim
686    /// path of `view_snapshot` clones every buffer line, far too heavy for
687    /// per-frame consumers that only want the position (status-bar ln/col).
688    pub fn cursor_pos(&self) -> (usize, usize) {
689        self.backend.cursor()
690    }
691
692    /// Set the search needles to emphasize in the rendered buffer (the note
693    /// was opened from a query result). Cleared automatically on the first
694    /// edit.
695    pub fn set_search_needles(&mut self, needles: Vec<String>) {
696        self.search_needles = needles
697            .into_iter()
698            .map(|n| n.to_lowercase())
699            .filter(|n| !n.is_empty())
700            .collect();
701        self.needles_revision = Some(self.content_revision);
702    }
703
704    pub fn set_text(&mut self, text: String) {
705        // No-op when the buffer would be identical — preserves view scroll,
706        // selection, edit generation cache, and an open autocomplete popup.
707        // Saves the expensive lines clone too. Still normalises the saved
708        // marker: if the buffer was flagged dirty by a previous divergent
709        // save, reloading the same content from disk should clear that
710        // flag rather than persist a phantom `[+]` in the title bar.
711        if text == self.get_text() {
712            self.saved_content_rev = Some(self.content_revision);
713            if let Some(nvim) = self.backend.as_nvim() {
714                nvim.mark_clean();
715            }
716            return;
717        }
718        match &mut self.backend {
719            BackendState::Textarea(tb) => {
720                let lines = text.lines();
721                tb.ta = TextArea::from(lines);
722            }
723            BackendState::Nvim(nvim) => {
724                nvim.set_text(&text);
725            }
726        }
727        self.backend.vim_reset_to_normal();
728        self.bump_content();
729        let reconstructed = self.get_text();
730        self.mark_saved(reconstructed);
731        // Buffer replaced — close any open autocomplete popup so it does
732        // not linger over the new note (e.g. after Ctrl+G follow-link).
733        self.close_autocomplete();
734    }
735
736    pub fn get_text(&self) -> String {
737        self.backend.text()
738    }
739
740    /// Current content revision. Bumped on every text-mutating handler;
741    /// stable across cursor moves and idle frames. Used by the autosave
742    /// path to record "this snapshot was saved" without rebuilding the
743    /// buffer text on completion. `NonZeroU64` makes 0 unrepresentable
744    /// so callers can express "no revision" as `Option<NonZeroU64>::None`
745    /// without a magic-value sentinel.
746    pub fn content_revision(&self) -> NonZeroU64 {
747        self.content_revision
748    }
749
750    /// Mark the buffer as clean iff its current revision still matches
751    /// `rev` (i.e. no edits landed between the save being issued and
752    /// completing). Diverged revision → no-op: leave `saved_content_rev`
753    /// alone, because some OTHER mechanism (a synchronous `try_save`
754    /// racing this completion) may have already marked a NEWER revision
755    /// clean, and a stale completion must not clobber that. `is_dirty`
756    /// already reads true when `saved_content_rev != Some(self.content_revision)`,
757    /// so doing nothing on a mismatch keeps the editor correctly dirty
758    /// without overwriting a legitimately-newer saved snapshot.
759    pub fn mark_saved_at_revision(&mut self, rev: NonZeroU64) {
760        if rev != self.content_revision {
761            return;
762        }
763        if let Some(nvim) = self.backend.as_nvim() {
764            nvim.mark_clean();
765        }
766        self.saved_content_rev = Some(rev);
767    }
768
769    /// Synchronous mark-saved used by `try_save` and `set_text`. Unlike
770    /// `mark_saved_at_revision` (which no-ops on a stale revision because
771    /// it can race a sync mark_saved), this one CLOBBERS `saved_content_rev`
772    /// to `None` when the supplied text diverges: the sync caller holds
773    /// `&mut self` for the whole save, so there is no concurrent newer
774    /// clean state to preserve, and the user typing between
775    /// `get_text()` and this call must show as dirty.
776    pub fn mark_saved(&mut self, text: String) {
777        let matches = text == self.get_text();
778        if matches {
779            if let Some(nvim) = self.backend.as_nvim() {
780                nvim.mark_clean();
781            }
782            self.saved_content_rev = Some(self.content_revision);
783        } else {
784            // Textarea: divergent save → stay dirty.
785            // Nvim: snapshot's `dirty` was untouched anyway; the Textarea
786            // dirty signal (saved_content_rev) is what is_dirty consults
787            // on the Textarea backend, and we explicitly mark it None here.
788            self.saved_content_rev = None;
789        }
790    }
791
792    pub fn is_dirty(&self) -> bool {
793        match &self.backend {
794            BackendState::Textarea(_) => self.saved_content_rev != Some(self.content_revision),
795            BackendState::Nvim(nvim) => nvim.snapshot().dirty,
796        }
797    }
798
799    /// Whether a bare Space should start the leader (vim Normal mode only).
800    /// Returns `false` for the direct textarea backend, the nvim backend,
801    /// vim Insert/Visual modes, and any pending state.
802    pub fn vim_space_leads(&self) -> bool {
803        self.backend.vim_space_leads()
804    }
805
806    /// Returns the link or label target under the cursor, or `None` if the
807    /// cursor is not inside a wikilink, markdown link, or hashtag span.
808    pub fn link_at_cursor(&self) -> Option<LinkTarget> {
809        let (_row, col, line) = match &self.backend {
810            BackendState::Textarea(tb) => {
811                let (row, col) = cursor_tuple(&tb.ta);
812                let line = tb.ta.lines().get(row)?.to_string();
813                (row, col, line)
814            }
815            BackendState::Nvim(nvim) => {
816                let snap = nvim.snapshot();
817                let (row, col) = snap.cursor;
818                let line = snap.lines.get(row)?.to_string();
819                (row, col, line)
820            }
821        };
822
823        // F5: Check wiki-link / markdown-link spans first; Link wins over Label
824        // even if a future edit accidentally lets a Label slip through a Link range.
825        if let Some(span) = kimun_core::note::scan::link_char_spans(&line)
826            .into_iter()
827            .find(|s| s.start <= col && col < s.end)
828        {
829            return Some(LinkTarget::Note(span.target));
830        }
831
832        // Fallback: check for a hashtag label (via the markdown parser).
833        let parsed = self::markdown::ParsedLine::parse(&line);
834        parsed
835            .elements
836            .iter()
837            .find(|e| {
838                e.kind == self::markdown::ElementKind::Label
839                    && col >= e.start_char
840                    && col < e.end_char
841            })
842            .map(|e| {
843                let span: String = line
844                    .chars()
845                    .skip(e.start_char)
846                    .take(e.end_char - e.start_char)
847                    .collect();
848                let name = span.trim_start_matches('#').to_string();
849                LinkTarget::Label(name)
850            })
851    }
852
853    /// Copy selected text to the system clipboard.
854    fn copy_selection_to_clipboard(&mut self) {
855        let text = {
856            let Some(ta) = self.backend.as_textarea() else {
857                return;
858            };
859            match selection_text(ta) {
860                Some(t) => t,
861                None => return,
862            }
863        };
864        if let Some(cb) = &mut self.clipboard {
865            let _ = cb.set_text(text);
866        }
867    }
868
869    /// Paste text from the system clipboard at the cursor, replacing any active selection.
870    fn paste_from_clipboard(&mut self, tx: &AppTx) {
871        let text = match &mut self.clipboard {
872            Some(cb) => match cb.get_text() {
873                Ok(t) if !t.is_empty() => t,
874                _ => return,
875            },
876            None => return,
877        };
878        self.paste_text(&text, tx);
879    }
880
881    /// Inserts `text` at the cursor, replacing any active selection. When `text`
882    /// is a URL (http/https/ftp/ftps/mailto) and a selection is active, the
883    /// selection is wrapped as a markdown link `[selection](url)` instead of
884    /// being replaced by the raw URL.
885    ///
886    /// On the Nvim backend the URL-wrap shortcut is skipped (would require
887    /// reading the visual selection from nvim) — `text` is forwarded via
888    /// `nvim_paste`, which honours the current mode (insert/normal/visual).
889    pub fn paste_text(&mut self, text: &str, tx: &AppTx) {
890        if text.is_empty() {
891            return;
892        }
893        match &mut self.backend {
894            BackendState::Textarea(tb) => {
895                let selection = linkable_url(text).and_then(|_| selection_text(&tb.ta));
896                let wrapped = try_build_markdown_link(text, selection.as_deref());
897                if tb.ta.selection_range().is_some() {
898                    tb.ta.cut();
899                }
900                tb.ta.insert_str(wrapped.as_deref().unwrap_or(text));
901                self.selection = tb.ta.selection_range();
902                self.bump_content();
903            }
904            BackendState::Nvim(nvim) => {
905                nvim.paste(text, tx.clone());
906                self.bump_content();
907            }
908        }
909        // The buffer just changed under the popup's feet; reconcile
910        // the trigger context so a stale replace_range cannot survive
911        // into the next Accept.
912        self.bind_autocomplete_redraw(tx);
913        self.sync_autocomplete();
914    }
915
916    /// Inserts `text` at the cursor, replacing any active selection. Routes
917    /// through `nvim_paste` on the Nvim backend (delegates to [`paste_text`]
918    /// for that case — URL-wrap is a no-op when nothing in the supplied text
919    /// matches `linkable_url`, so the two paths are equivalent on Nvim).
920    pub fn insert_at_cursor(&mut self, text: &str, tx: &AppTx) {
921        if matches!(self.backend, BackendState::Nvim(_)) {
922            self.paste_text(text, tx);
923            return;
924        }
925        if let Some(ta) = self.backend.as_textarea_mut() {
926            if ta.selection_range().is_some() {
927                ta.cut();
928            }
929            ta.insert_str(text);
930            self.selection = ta.selection_range();
931            self.bump_content();
932        }
933        // See `paste_text` — out-of-band buffer mutation must
934        // re-reconcile the popup state.
935        self.bind_autocomplete_redraw(tx);
936        self.sync_autocomplete();
937    }
938
939    /// Snapshot of the system clipboard image, if any. Returns owned RGBA bytes
940    /// plus the image dimensions. The screen layer is responsible for encoding
941    /// (e.g. PNG) and persisting via the vault.
942    pub fn take_clipboard_image(&mut self) -> Option<ClipboardImage> {
943        let cb = self.clipboard.as_mut()?;
944        let img = cb.get_image().ok()?;
945        Some(ClipboardImage {
946            width: img.width,
947            height: img.height,
948            rgba: img.bytes.into_owned(),
949        })
950    }
951
952    /// Wraps the active selection in `open`/`close` and re-selects the inner
953    /// text so wraps chain (see CONTEXT.md "Auto-surround"). Returns `false`
954    /// without touching the buffer when there is no (non-empty) selection or
955    /// on the Nvim backend. Callers on the key path don't reconcile the
956    /// autocomplete popup — `handle_input` re-syncs on any content bump.
957    fn wrap_selection(&mut self, open: &str, close: &str) -> bool {
958        let Some(ta) = self.backend.as_textarea_mut() else {
959            return false;
960        };
961        let Some(((sr, sc), (er, ec))) = ta.selection_range() else {
962            return false;
963        };
964        let Some(text) = selection_text(ta) else {
965            return false;
966        };
967        ta.insert_str(format!("{open}{text}{close}"));
968        // Reselect the inner text. The open marker shifts cols on the first
969        // selected line only; coordinates are char-based, matching
970        // `selection_range`.
971        let shift = open.chars().count();
972        let inner_end_col = if sr == er { ec + shift } else { ec };
973        set_selection(ta, (sr, sc + shift), (er, inner_end_col));
974        self.selection = ta.selection_range();
975        self.bump_content();
976        true
977    }
978
979    /// Wrap a selection in (or insert at the cursor) markdown markers for
980    /// Bold/Italic/Strikethrough. No-op for other actions and on the Nvim backend.
981    pub fn apply_text_action(&mut self, action: TextAction) {
982        let marker = match action {
983            TextAction::Bold => "**",
984            TextAction::Italic => "*",
985            TextAction::Strikethrough => "~~",
986            _ => return,
987        };
988        if self.wrap_selection(marker, marker) {
989            return;
990        }
991        let Some(ta) = self.backend.as_textarea_mut() else {
992            return;
993        };
994        ta.insert_str(format!("{marker}{marker}"));
995        for _ in 0..marker.len() {
996            ta.move_cursor(CursorMove::Back);
997        }
998        self.selection = ta.selection_range();
999        self.bump_content();
1000    }
1001
1002    /// Smart Enter: continue list markers, preserve indent, dedent on empty
1003    /// indent-only lines, clear empty list markers. Returns `true` if handled
1004    /// (caller should not insert a plain newline). Always `false` on Nvim
1005    /// backend or when there is an active selection.
1006    pub fn smart_enter(&mut self) -> bool {
1007        enum Action {
1008            ClearLine { chars: usize },
1009            InsertPrefix(String),
1010            Dedent,
1011        }
1012        let action = {
1013            let Some(ta) = self.backend.as_textarea() else {
1014                return false;
1015            };
1016            // A mouse click leaves a zero-width selection active (handle_mouse
1017            // calls start_selection on Down), so only bail on a non-empty one.
1018            if ta
1019                .selection_range()
1020                .is_some_and(|(start, end)| start != end)
1021            {
1022                return false;
1023            }
1024            let (row, col) = cursor_tuple(ta);
1025            let Some(line) = ta.lines().get(row) else {
1026                return false;
1027            };
1028            let total_chars = line.chars().count();
1029            if col != total_chars {
1030                return false;
1031            }
1032            // ASCII whitespace, so byte index == char index here.
1033            let ws_end = markdown::leading_ws_byte_len(line);
1034            let (ws, after_ws) = line.split_at(ws_end);
1035            if let Some(marker_len) = markdown::list_marker_len(after_ws) {
1036                if after_ws.len() == marker_len {
1037                    // Empty list item: dedent first if indented, then clear
1038                    // the marker once fully unindented.
1039                    if ws_end > 0 {
1040                        Action::Dedent
1041                    } else {
1042                        Action::ClearLine { chars: total_chars }
1043                    }
1044                } else {
1045                    let marker_str = &after_ws[..marker_len];
1046                    let next_marker = increment_ordered_marker(marker_str)
1047                        .unwrap_or_else(|| marker_str.to_string());
1048                    Action::InsertPrefix(format!("{ws}{next_marker}"))
1049                }
1050            } else if ws_end > 0 && total_chars == ws_end {
1051                Action::Dedent
1052            } else if ws_end > 0 {
1053                Action::InsertPrefix(ws.to_string())
1054            } else {
1055                return false;
1056            }
1057        };
1058
1059        match action {
1060            Action::Dedent => {
1061                self.indent_lines(true);
1062                return true;
1063            }
1064            Action::ClearLine { chars } => {
1065                let Some(ta) = self.backend.as_textarea_mut() else {
1066                    unreachable!()
1067                };
1068                ta.move_cursor(CursorMove::Head);
1069                ta.delete_str(chars);
1070            }
1071            Action::InsertPrefix(prefix) => {
1072                let Some(ta) = self.backend.as_textarea_mut() else {
1073                    unreachable!()
1074                };
1075                ta.insert_newline();
1076                ta.insert_str(prefix);
1077            }
1078        }
1079        let Some(ta) = self.backend.as_textarea() else {
1080            unreachable!()
1081        };
1082        self.selection = ta.selection_range();
1083        self.bump_content();
1084        true
1085    }
1086
1087    /// Move the cursor to the first markdown heading line whose text equals
1088    /// `heading` (any level), e.g. for the OUTLINE drawer's jump. No-op when
1089    /// the heading is not found, and on the Nvim backend (same policy as
1090    /// [`Self::indent_lines`]).
1091    pub fn jump_to_heading(&mut self, heading: &str) {
1092        let Some(ta) = self.backend.as_textarea_mut() else {
1093            return;
1094        };
1095        // The OUTLINE entries carry the extractor-rendered heading text
1096        // (inline markup resolved, closing ATX `#` dropped), so normalise
1097        // both sides before comparing: strip the ATX markers and the
1098        // common inline-emphasis characters.
1099        fn normalise(text: &str) -> String {
1100            text.trim()
1101                .trim_end_matches('#')
1102                .trim()
1103                .replace(['*', '_', '`'], "")
1104        }
1105        let wanted = normalise(heading);
1106        let row = ta.lines().iter().position(|l| {
1107            let t = l.trim_start();
1108            let stripped = t.trim_start_matches('#');
1109            stripped.len() != t.len() && normalise(stripped) == wanted
1110        });
1111        if let Some(row) = row {
1112            ta.move_cursor(CursorMove::Jump(row as u16, 0));
1113            self.bump_cursor();
1114        }
1115    }
1116
1117    /// Indent or dedent whole lines. Tab unit is `\t` if `hard_tab_indent` is
1118    /// on, else `tab_length` spaces. Dedent counts a leading tab as one unit.
1119    /// No-op on Nvim backend.
1120    pub fn indent_lines(&mut self, dedent: bool) {
1121        let Some(ta) = self.backend.as_textarea_mut() else {
1122            return;
1123        };
1124        let tab_len = ta.tab_length() as usize;
1125        let hard_tab = ta.hard_tab_indent();
1126        let indent: String = if hard_tab {
1127            "\t".to_string()
1128        } else {
1129            " ".repeat(tab_len)
1130        };
1131        if indent.is_empty() {
1132            return;
1133        }
1134        let indent_chars = indent.len();
1135
1136        let sel = ta.selection_range();
1137        let saved_cursor = if sel.is_none() {
1138            Some(cursor_tuple(ta))
1139        } else {
1140            None
1141        };
1142        let (start_row, end_row) = match sel {
1143            Some(((sr, _), (er, ec))) => {
1144                // A selection that ends at column 0 of a row visually doesn't
1145                // include that row, so don't indent it.
1146                let last = if ec == 0 && er > sr { er - 1 } else { er };
1147                (sr, last)
1148            }
1149            None => {
1150                let (r, _) = saved_cursor.unwrap();
1151                (r, r)
1152            }
1153        };
1154
1155        let row_count = end_row.saturating_sub(start_row) + 1;
1156        let mut row_deltas: Vec<isize> = Vec::with_capacity(row_count);
1157        let mut any_change = false;
1158
1159        // Drop the live selection before mutating: with the anchor still set,
1160        // `move_cursor(Jump(row, 0))` re-anchors the selection from the start
1161        // column back to col 0, so `insert_str`/`delete_str` would replace the
1162        // text before the selection. The selection is restored at the end.
1163        ta.cancel_selection();
1164
1165        for row in start_row..=end_row {
1166            if dedent {
1167                let count = {
1168                    let line = ta.lines().get(row).map(|s| s.as_str()).unwrap_or("");
1169                    let max_remove = if hard_tab { 1 } else { tab_len };
1170                    let mut count = 0usize;
1171                    for (i, c) in line.chars().enumerate() {
1172                        if i >= max_remove {
1173                            break;
1174                        }
1175                        if c == '\t' {
1176                            count += 1;
1177                            break;
1178                        } else if c == ' ' && !hard_tab {
1179                            count += 1;
1180                        } else {
1181                            break;
1182                        }
1183                    }
1184                    count
1185                };
1186                if count > 0 {
1187                    ta.move_cursor(CursorMove::Jump(row as u16, 0));
1188                    ta.delete_str(count);
1189                    any_change = true;
1190                }
1191                row_deltas.push(-(count as isize));
1192            } else {
1193                ta.move_cursor(CursorMove::Jump(row as u16, 0));
1194                ta.insert_str(&indent);
1195                row_deltas.push(indent_chars as isize);
1196                any_change = true;
1197            }
1198        }
1199
1200        let adj = |row: usize, col: usize| -> usize {
1201            if row >= start_row && row <= end_row {
1202                let d = row_deltas[row - start_row];
1203                if d >= 0 {
1204                    col + d as usize
1205                } else {
1206                    col.saturating_sub((-d) as usize)
1207                }
1208            } else {
1209                col
1210            }
1211        };
1212
1213        match sel {
1214            Some(((ssr, ssc), (ser, sec))) => {
1215                set_selection(ta, (ssr, adj(ssr, ssc)), (ser, adj(ser, sec)));
1216            }
1217            None => {
1218                let (cr, cc) = saved_cursor.expect("captured when sel is None");
1219                let new_col = adj(cr, cc);
1220                ta.move_cursor(CursorMove::Jump(cr as u16, new_col as u16));
1221            }
1222        }
1223
1224        if any_change {
1225            self.selection = ta.selection_range();
1226            self.bump_content();
1227        }
1228    }
1229}
1230
1231impl TextEditorComponent {
1232    /// Bumps `edit_generation` only (cursor/selection moves, mouse clicks
1233    /// that do not touch the buffer text). Lets the view invalidate its
1234    /// cursor-dependent caches without telling the autocomplete controller
1235    /// that the buffer changed.
1236    #[inline]
1237    fn bump_cursor(&mut self) {
1238        self.edit_generation = self.edit_generation.wrapping_add(1);
1239    }
1240
1241    /// Bumps both `edit_generation` and `content_revision`. Use at every
1242    /// site that mutates the buffer (insert, delete, paste, undo/redo,
1243    /// autocomplete accept) on the Textarea backend. `handle_input` uses
1244    /// the `content_revision` delta to detect a real text change without
1245    /// materialising the buffer.
1246    ///
1247    /// Not called by the Nvim path — the reverse-refresh task in
1248    /// `backend.rs` bumps `snap.content_gen` on real diffs and the
1249    /// editor mirrors that value into `content_revision` at render time.
1250    #[inline]
1251    fn bump_content(&mut self) {
1252        self.edit_generation = self.edit_generation.wrapping_add(1);
1253        // NonZeroU64 enforces the skip-zero invariant for free: on
1254        // wrap-around from u64::MAX, `NonZeroU64::new(0)` returns None
1255        // and we substitute 1. 2^64 edits is astronomical but the
1256        // invariant is type-checkable.
1257        let next = self.content_revision.get().wrapping_add(1);
1258        self.content_revision = NonZeroU64::new(next).unwrap_or(NonZeroU64::new(1).unwrap());
1259    }
1260
1261    /// If the Nvim process has died, fall back to a Textarea with the last known content.
1262    fn maybe_recover_from_dead_nvim(&mut self) {
1263        if self.backend.recover_from_dead_nvim() {
1264            // Spin up the autocomplete controller now that we're on the
1265            // textarea backend — set_vault was a no-op at startup when
1266            // we were still on Nvim.
1267            self.ensure_autocomplete_for_textarea();
1268        }
1269    }
1270
1271    /// Handle a key event when using the Nvim backend.
1272    ///
1273    /// Returns `Some(EventState)` if the event was handled (or should be),
1274    /// `None` if the backend is not Nvim and the caller should fall through.
1275    fn handle_nvim_key(
1276        &mut self,
1277        key: &ratatui::crossterm::event::KeyEvent,
1278        tx: &AppTx,
1279    ) -> Option<EventState> {
1280        // FocusSidebar / FocusEditor shortcuts are intercepted at the
1281        // EditorScreen level for directional navigation. The pending-Z
1282        // intercept and quit-command policy live in `nvim_host`.
1283        let nvim = self.backend.as_nvim()?;
1284        let result = self.nvim_host.handle_key(nvim, key, tx);
1285
1286        // `bump_cursor` bumps only `edit_generation` (the any-input view-cache
1287        // counter), and only when a key was actually forwarded. `content_revision`
1288        // is mirrored separately in `NvimHost::frame_sync` — see there for why
1289        // navigation keys don't invalidate an in-flight save's revision token.
1290        if result == NvimKeyResult::Forwarded {
1291            self.bump_cursor();
1292        }
1293        Some(EventState::Consumed)
1294    }
1295
1296    /// Open the find bar; if already open, advance to the next match. No-op
1297    /// on the Nvim backend (which has its own `/` search). Public so
1298    /// `EditorScreen` can route the configurable `FindInBuffer` shortcut here.
1299    pub fn open_or_advance_search(&mut self) {
1300        if !self.backend.is_textarea() {
1301            return;
1302        }
1303        if self.search.is_some() {
1304            self.search_advance(false);
1305            return;
1306        }
1307        // Yield key focus to the find bar — close the autocomplete popup
1308        // so it stops intercepting Esc / Up / Down / Tab / Enter, which
1309        // belong to the find bar while it is active.
1310        self.close_autocomplete();
1311        self.search = Some(SearchState {
1312            input: SingleLineInput::new(),
1313            status: SearchStatus::Empty,
1314        });
1315    }
1316
1317    /// Close the autocomplete popup, if any. Cheap; safe on any backend
1318    /// (no-op when `autocomplete` is None). Use whenever focus moves
1319    /// away from the editor or another overlay takes over key input.
1320    pub fn close_autocomplete(&mut self) {
1321        if let Some(c) = self.autocomplete.as_mut() {
1322            c.close();
1323        }
1324    }
1325
1326    /// Bind the redraw channel up front (e.g. on note open) so the
1327    /// background full-parse task can wake the event-driven render loop
1328    /// on the FIRST render of a large buffer, before any keystroke has
1329    /// run `handle_input`. No-op after the first successful bind.
1330    pub fn set_redraw_tx(&mut self, tx: &AppTx) {
1331        self.bind_autocomplete_redraw(tx);
1332    }
1333
1334    /// Bind the autocomplete controller's redraw callback AND the
1335    /// editor's background-full-parse redraw signal to the app
1336    /// event bus. Called from `handle_input` (the first place where
1337    /// the editor has access to `AppTx`). The autocomplete piece is
1338    /// a no-op after the first successful bind; the redraw_tx clone
1339    /// is set unconditionally so a reset autocomplete controller
1340    /// (e.g. after Nvim → Textarea fallback) doesn't lose the
1341    /// editor's redraw channel.
1342    fn bind_autocomplete_redraw(&mut self, tx: &AppTx) {
1343        if self.redraw_tx.is_none() {
1344            self.redraw_tx = Some(tx.clone());
1345        }
1346        if self.autocomplete_redraw_bound {
1347            return;
1348        }
1349        if let Some(c) = self.autocomplete.as_mut() {
1350            c.set_redraw_callback(redraw_callback(tx.clone()));
1351            self.autocomplete_redraw_bound = true;
1352        }
1353    }
1354
1355    fn close_search(&mut self) {
1356        if let Some(ta) = self.backend.as_textarea_mut() {
1357            let _ = ta.set_search_pattern("");
1358        }
1359        self.search = None;
1360        self.selection = None;
1361    }
1362
1363    /// Push pattern to the textarea. When `jump` is true and the query compiles,
1364    /// also jumps to the first match at or after the cursor (live preview).
1365    fn refresh_search_pattern(&mut self, jump: bool) {
1366        let Some(state) = self.search.as_mut() else {
1367            return;
1368        };
1369        let Some(ta) = self.backend.as_textarea_mut() else {
1370            return;
1371        };
1372        if state.input.is_empty() {
1373            let _ = ta.set_search_pattern("");
1374            state.status = SearchStatus::Empty;
1375            self.selection = None;
1376            return;
1377        }
1378        if let Err(e) = ta.set_search_pattern(state.input.value()) {
1379            state.status = SearchStatus::Invalid(e.to_string());
1380            self.selection = None;
1381            return;
1382        }
1383        if !jump {
1384            state.status = SearchStatus::Match;
1385            return;
1386        }
1387        let found = ta.search_forward(true);
1388        state.status = SearchStatus::from_found(found);
1389        self.highlight_current_match(found);
1390    }
1391
1392    fn search_advance(&mut self, backward: bool) {
1393        let Some(state) = self.search.as_mut() else {
1394            return;
1395        };
1396        if state.input.is_empty() {
1397            return;
1398        }
1399        let Some(ta) = self.backend.as_textarea_mut() else {
1400            return;
1401        };
1402        let found = if backward {
1403            ta.search_back(false)
1404        } else {
1405            ta.search_forward(false)
1406        };
1407        state.status = SearchStatus::from_found(found);
1408        self.highlight_current_match(found);
1409    }
1410
1411    /// After a search step, paint the match at the textarea's cursor as the
1412    /// editor selection so the user can see where the match is — our custom
1413    /// `MarkdownEditorView` does not render the textarea library's built-in
1414    /// search highlights.
1415    fn highlight_current_match(&mut self, found: bool) {
1416        self.selection = if found {
1417            self.compute_match_selection()
1418        } else {
1419            None
1420        };
1421    }
1422
1423    /// Locate the regex match starting at the textarea cursor and return its
1424    /// span as a `(row, char_col)` pair. Returns `None` when no pattern is set,
1425    /// the cursor is out of range, or the cursor is not on a match — guards
1426    /// against stale cursor/pattern state if callers ever invoke without a
1427    /// fresh search step.
1428    fn compute_match_selection(&self) -> Option<((usize, usize), (usize, usize))> {
1429        let ta = self.backend.as_textarea()?;
1430        let re = ta.search_pattern()?;
1431        let DataCursor(row, col_chars) = ta.cursor();
1432        let line = ta.lines().get(row)?;
1433        let byte_off = char_col_to_byte(line, col_chars);
1434        let m = re.find_at(line, byte_off)?;
1435        if m.start() != byte_off {
1436            return None;
1437        }
1438        let match_chars = line[m.range()].chars().count();
1439        Some(((row, col_chars), (row, col_chars + match_chars)))
1440    }
1441
1442    /// Returns `true` when the key was consumed by the find bar.
1443    fn handle_search_key(&mut self, key: &ratatui::crossterm::event::KeyEvent) -> bool {
1444        let Some(state) = self.search.as_mut() else {
1445            return false;
1446        };
1447        let shift = key.modifiers.contains(KeyModifiers::SHIFT);
1448        let outcome = state.input.handle_key(key);
1449        match outcome {
1450            InputOutcome::Cancel => self.close_search(),
1451            InputOutcome::Submit => {
1452                if self.backend.is_vim() {
1453                    // Vim confirm: keep the textarea search pattern so n/N can
1454                    // use it, but close the find bar. Incremental search already
1455                    // placed the cursor on the first match — do NOT advance again.
1456                    self.search = None;
1457                } else {
1458                    self.search_advance(shift);
1459                }
1460            }
1461            InputOutcome::Changed => self.refresh_search_pattern(true),
1462            InputOutcome::Consumed | InputOutcome::NotConsumed => {}
1463        }
1464        true
1465    }
1466
1467    /// Repeat the last search (vim `n`/`N`) using the textarea's persisted
1468    /// pattern, even when the find bar is closed.
1469    fn vim_search_repeat(&mut self, backward: bool) {
1470        let found = {
1471            let Some(ta) = self.backend.as_textarea_mut() else {
1472                return;
1473            };
1474            if backward {
1475                ta.search_back(false)
1476            } else {
1477                ta.search_forward(false)
1478            }
1479        };
1480        self.highlight_current_match(found);
1481    }
1482
1483    /// Handle a key event when using the Textarea backend.
1484    fn handle_textarea_key(
1485        &mut self,
1486        key: &ratatui::crossterm::event::KeyEvent,
1487        tx: &AppTx,
1488    ) -> EventState {
1489        // Find bar — intercept ALL keys while active.
1490        if self.handle_search_key(key) {
1491            return EventState::Consumed;
1492        }
1493
1494        // System clipboard shortcuts — intercept before passing to textarea.
1495        if key.modifiers == KeyModifiers::CONTROL {
1496            match key.code {
1497                KeyCode::Char('c') => {
1498                    self.copy_selection_to_clipboard();
1499                    return EventState::Consumed;
1500                }
1501                KeyCode::Char('v') => {
1502                    self.paste_from_clipboard(tx);
1503                    return EventState::Consumed;
1504                }
1505                KeyCode::Char('x') => {
1506                    self.copy_selection_to_clipboard();
1507                    let cut = if let Some(ta) = self.backend.as_textarea_mut() {
1508                        // `ta.cut()` returns `false` when the selection was
1509                        // empty / nothing to remove. Use its return value
1510                        // directly rather than pre-checking selection_range —
1511                        // one source of truth, no spurious view rebuild on
1512                        // no-op Ctrl+X.
1513                        let cut = ta.cut();
1514                        self.selection = ta.selection_range();
1515                        cut
1516                    } else {
1517                        false
1518                    };
1519                    if cut {
1520                        self.bump_content();
1521                    }
1522                    return EventState::Consumed;
1523                }
1524                _ => {}
1525            }
1526        }
1527
1528        let Some(ta) = self.backend.as_textarea_mut() else {
1529            unreachable!("handle_textarea_key called with non-Textarea backend")
1530        };
1531
1532        // macOS-style navigation shortcuts not handled by ratatui-textarea.
1533        let shift = key.modifiers.contains(KeyModifiers::SHIFT);
1534        let handled = match (key.modifiers & !KeyModifiers::SHIFT, key.code) {
1535            (KeyModifiers::ALT, KeyCode::Left) => {
1536                cursor_move!(ta, CursorMove::WordBack, shift);
1537                true
1538            }
1539            (KeyModifiers::ALT, KeyCode::Right) => {
1540                cursor_move!(ta, CursorMove::WordForward, shift);
1541                true
1542            }
1543            // Emacs-style word motions. macOS terminals (Terminal.app, Ghostty)
1544            // translate Option+Left/Right into `Esc b` / `Esc f` by default,
1545            // which crossterm reports as Alt+b / Alt+f. The shifted variants
1546            // arrive as the uppercase char (with SHIFT set, so `shift` holds).
1547            (KeyModifiers::ALT, KeyCode::Char('b') | KeyCode::Char('B')) => {
1548                cursor_move!(ta, CursorMove::WordBack, shift);
1549                true
1550            }
1551            (KeyModifiers::ALT, KeyCode::Char('f') | KeyCode::Char('F')) => {
1552                cursor_move!(ta, CursorMove::WordForward, shift);
1553                true
1554            }
1555            (KeyModifiers::SUPER, KeyCode::Left) => {
1556                cursor_move!(ta, CursorMove::Head, shift);
1557                true
1558            }
1559            (KeyModifiers::SUPER, KeyCode::Right) => {
1560                cursor_move!(ta, CursorMove::End, shift);
1561                true
1562            }
1563            (KeyModifiers::SUPER, KeyCode::Up) => {
1564                cursor_move!(ta, CursorMove::Top, shift);
1565                true
1566            }
1567            (KeyModifiers::SUPER, KeyCode::Down) => {
1568                cursor_move!(ta, CursorMove::Bottom, shift);
1569                true
1570            }
1571            _ => false,
1572        };
1573        if handled {
1574            self.selection = ta.selection_range();
1575            self.bump_cursor();
1576            return EventState::Consumed;
1577        }
1578
1579        // FocusSidebar / FocusEditor shortcuts are intercepted at the
1580        // EditorScreen level for directional navigation.
1581
1582        // Standard text-editor shortcuts.
1583        // `input_without_shortcuts` only handles chars, backspace, delete, tab, newline —
1584        // all navigation and editing shortcuts must be mapped explicitly.
1585        // Outcome tracks whether the handled shortcut mutated the buffer, only
1586        // moved the cursor, or did literally nothing (e.g. Ctrl+Z on an empty
1587        // undo stack) — so neither `text_revision` nor `edit_generation` is
1588        // bumped on true no-ops.
1589        enum ShortcutOutcome {
1590            NoOp,
1591            CursorOnly,
1592            TextMutated,
1593        }
1594        let outcome: Option<ShortcutOutcome> =
1595            match (key.modifiers & !KeyModifiers::SHIFT, key.code) {
1596                // --- Cursor movement (Shift extends the selection) ---
1597                (KeyModifiers::NONE, KeyCode::Left) => {
1598                    cursor_move!(ta, CursorMove::Back, shift);
1599                    Some(ShortcutOutcome::CursorOnly)
1600                }
1601                (KeyModifiers::NONE, KeyCode::Right) => {
1602                    cursor_move!(ta, CursorMove::Forward, shift);
1603                    Some(ShortcutOutcome::CursorOnly)
1604                }
1605                (KeyModifiers::NONE, KeyCode::Up) => {
1606                    cursor_move!(ta, CursorMove::Up, shift);
1607                    Some(ShortcutOutcome::CursorOnly)
1608                }
1609                (KeyModifiers::NONE, KeyCode::Down) => {
1610                    cursor_move!(ta, CursorMove::Down, shift);
1611                    Some(ShortcutOutcome::CursorOnly)
1612                }
1613                (KeyModifiers::NONE, KeyCode::Home) => {
1614                    cursor_move!(ta, CursorMove::Head, shift);
1615                    Some(ShortcutOutcome::CursorOnly)
1616                }
1617                (KeyModifiers::NONE, KeyCode::End) => {
1618                    cursor_move!(ta, CursorMove::End, shift);
1619                    Some(ShortcutOutcome::CursorOnly)
1620                }
1621                (KeyModifiers::NONE, KeyCode::PageUp) => {
1622                    cursor_move!(ta, CursorMove::ParagraphBack, shift);
1623                    Some(ShortcutOutcome::CursorOnly)
1624                }
1625                (KeyModifiers::NONE, KeyCode::PageDown) => {
1626                    cursor_move!(ta, CursorMove::ParagraphForward, shift);
1627                    Some(ShortcutOutcome::CursorOnly)
1628                }
1629                // Word navigation (Ctrl+arrow, Windows/Linux style)
1630                (KeyModifiers::CONTROL, KeyCode::Left) => {
1631                    cursor_move!(ta, CursorMove::WordBack, shift);
1632                    Some(ShortcutOutcome::CursorOnly)
1633                }
1634                (KeyModifiers::CONTROL, KeyCode::Right) => {
1635                    cursor_move!(ta, CursorMove::WordForward, shift);
1636                    Some(ShortcutOutcome::CursorOnly)
1637                }
1638                // Document start / end
1639                (KeyModifiers::CONTROL, KeyCode::Home) => {
1640                    cursor_move!(ta, CursorMove::Top, shift);
1641                    Some(ShortcutOutcome::CursorOnly)
1642                }
1643                (KeyModifiers::CONTROL, KeyCode::End) => {
1644                    cursor_move!(ta, CursorMove::Bottom, shift);
1645                    Some(ShortcutOutcome::CursorOnly)
1646                }
1647                // Undo / Redo (Ctrl+Z / Ctrl+Y / Ctrl+Shift+Z). The textarea
1648                // returns `false` when the stack is empty — no buffer change AND
1649                // no cursor change, so emit NoOp and skip the view-cache bump.
1650                (KeyModifiers::CONTROL, KeyCode::Char('z')) => {
1651                    if ta.undo() {
1652                        Some(ShortcutOutcome::TextMutated)
1653                    } else {
1654                        Some(ShortcutOutcome::NoOp)
1655                    }
1656                }
1657                (KeyModifiers::CONTROL, KeyCode::Char('y'))
1658                | (KeyModifiers::CONTROL, KeyCode::Char('Z')) => {
1659                    if ta.redo() {
1660                        Some(ShortcutOutcome::TextMutated)
1661                    } else {
1662                        Some(ShortcutOutcome::NoOp)
1663                    }
1664                }
1665                // Select all
1666                (KeyModifiers::CONTROL, KeyCode::Char('a')) => {
1667                    ta.move_cursor(CursorMove::Top);
1668                    ta.start_selection();
1669                    ta.move_cursor(CursorMove::Bottom);
1670                    Some(ShortcutOutcome::CursorOnly)
1671                }
1672                // Delete word before / after cursor. Returns `false` when at a
1673                // word boundary with nothing to delete — no buffer/cursor change.
1674                (KeyModifiers::CONTROL, KeyCode::Backspace)
1675                | (KeyModifiers::ALT, KeyCode::Backspace) => {
1676                    if ta.delete_word() {
1677                        Some(ShortcutOutcome::TextMutated)
1678                    } else {
1679                        Some(ShortcutOutcome::NoOp)
1680                    }
1681                }
1682                (KeyModifiers::CONTROL, KeyCode::Delete) | (KeyModifiers::ALT, KeyCode::Delete) => {
1683                    if ta.delete_next_word() {
1684                        Some(ShortcutOutcome::TextMutated)
1685                    } else {
1686                        Some(ShortcutOutcome::NoOp)
1687                    }
1688                }
1689                _ => None,
1690            };
1691        if let Some(kind) = outcome {
1692            self.selection = ta.selection_range();
1693            match kind {
1694                ShortcutOutcome::NoOp => {}
1695                ShortcutOutcome::CursorOnly => self.bump_cursor(),
1696                ShortcutOutcome::TextMutated => self.bump_content(),
1697            }
1698            return EventState::Consumed;
1699        }
1700
1701        // BackTab is what most terminals emit for Shift+Tab.
1702        match (key.modifiers, key.code) {
1703            (m, KeyCode::Tab)
1704                if !m.contains(KeyModifiers::CONTROL) && !m.contains(KeyModifiers::ALT) =>
1705            {
1706                self.indent_lines(m.contains(KeyModifiers::SHIFT));
1707                return EventState::Consumed;
1708            }
1709            (_, KeyCode::BackTab) => {
1710                self.indent_lines(true);
1711                return EventState::Consumed;
1712            }
1713            _ => {}
1714        }
1715        if key.code == KeyCode::Enter && key.modifiers.is_empty() && self.smart_enter() {
1716            return EventState::Consumed;
1717        }
1718
1719        // Auto-surround: an opening/symmetric pair char typed over a selection
1720        // wraps it instead of replacing it (see CONTEXT.md "Auto-surround").
1721        // Shift is allowed (most opening chars are shifted keys); Ctrl/Alt
1722        // chords fall through. The selection lands on the inner text so wraps
1723        // chain: `[` `[` builds a wikilink — and `handle_input`'s post-key
1724        // sync legitimately opens the wikilink popup on the chained wrap.
1725        if let KeyCode::Char(c) = key.code
1726            && (key.modifiers & !KeyModifiers::SHIFT).is_empty()
1727            && let Some((open, close)) = surround_pair(c)
1728            && self.wrap_selection(open, close)
1729        {
1730            return EventState::Consumed;
1731        }
1732
1733        let Some(ta) = self.backend.as_textarea_mut() else {
1734            unreachable!("handle_textarea_key called with non-Textarea backend")
1735        };
1736        // `input_without_shortcuts` returns `false` for keys the textarea
1737        // ignores (F1-F12, KeyCode::Null, modifier-only releases, IME
1738        // composing events). Only bump `text_revision` when the buffer
1739        // actually changed — otherwise harmless keys would silently flip
1740        // the editor to dirty and trigger needless autosaves.
1741        let mutated = ta.input_without_shortcuts(*key);
1742        self.selection = ta.selection_range();
1743        if mutated {
1744            self.bump_content();
1745        } else {
1746            self.bump_cursor();
1747        }
1748        EventState::Consumed
1749    }
1750
1751    /// Handle a mouse event (Textarea backend only).
1752    fn handle_mouse(&mut self, mouse: &ratatui::crossterm::event::MouseEvent) -> EventState {
1753        let r = &self.rect;
1754        let in_bounds = mouse.column >= r.x
1755            && mouse.column < r.x + r.width
1756            && mouse.row >= r.y
1757            && mouse.row < r.y + r.height;
1758        if !in_bounds {
1759            return EventState::NotConsumed;
1760        }
1761        // Right-click: with a selection it copies (unchanged behavior);
1762        // without one it asks the host to open the note's context menu
1763        // (spec §10 — file & note ops).
1764        if matches!(mouse.kind, MouseEventKind::Down(MouseButton::Right))
1765            && self.selection.is_none_or(|(start, end)| start == end)
1766        {
1767            self.wants_context_menu = true;
1768            return EventState::Consumed;
1769        }
1770        // Everything below drives the textarea backend directly; on Nvim the
1771        // terminal/nvim own the mouse (only the context-menu ask above is
1772        // backend-independent).
1773        if !self.backend.is_textarea() {
1774            return EventState::NotConsumed;
1775        }
1776        // Handle right-click clipboard copy in its own scope to avoid borrow conflicts.
1777        if matches!(mouse.kind, MouseEventKind::Down(MouseButton::Right)) {
1778            self.copy_selection_to_clipboard();
1779            self.selection = if let Some(ta) = self.backend.as_textarea() {
1780                ta.selection_range()
1781            } else {
1782                None
1783            };
1784            self.bump_cursor();
1785            return EventState::Consumed;
1786        }
1787        // Now extract ta for remaining mouse operations.
1788        let Some(ta) = self.backend.as_textarea_mut() else {
1789            unreachable!()
1790        };
1791        match mouse.kind {
1792            MouseEventKind::Down(_) => {
1793                ta.cancel_selection();
1794                let (lrow, lcol) = self
1795                    .view
1796                    .click_at_screen((mouse.row - r.y) as usize, (mouse.column - r.x) as usize);
1797                ta.move_cursor(CursorMove::Jump(lrow, lcol));
1798                ta.start_selection();
1799            }
1800            MouseEventKind::Drag(_) => {
1801                let (lrow, lcol) = self
1802                    .view
1803                    .click_at_screen((mouse.row - r.y) as usize, (mouse.column - r.x) as usize);
1804                ta.move_cursor(CursorMove::Jump(lrow, lcol));
1805            }
1806            _ => {
1807                ta.input(*mouse);
1808            }
1809        }
1810        self.selection = ta.selection_range();
1811        // Mouse handling moves the cursor / selection but does not insert
1812        // text — `ratatui-textarea` mouse handling is click/drag/scroll only.
1813        self.bump_cursor();
1814        EventState::Consumed
1815    }
1816}
1817
1818/// Viewport post-pass: emphasize search-needle matches
1819/// (`color_search_match`, bold) and style task checkboxes — `[ ]` accent,
1820/// `[x]` rows dimmed + struck (spec §5.1). Operates on the rendered buffer
1821/// rows, so cost is bounded by the visible area regardless of note size.
1822fn paint_viewport_extras(
1823    buf: &mut ratatui::buffer::Buffer,
1824    area: Rect,
1825    needles: &[String],
1826    theme: &Theme,
1827) {
1828    use ratatui::layout::Position;
1829    let match_fg = theme.color_search_match.to_ratatui();
1830    let checkbox_fg = theme.accent.to_ratatui();
1831
1832    for y in area.y..area.bottom() {
1833        // Cheap pre-pass: with no needles, only task rows need the full
1834        // string reconstruction — peek at the leading cells for a `- [`
1835        // prefix and skip the row otherwise. Keeps the per-keystroke cost
1836        // of an idle buffer near zero.
1837        if needles.is_empty() {
1838            let mut lead = String::new();
1839            for x in area.x..area.right().min(area.x + 16) {
1840                if let Some(cell) = buf.cell(Position::new(x, y)) {
1841                    lead.push_str(cell.symbol());
1842                }
1843            }
1844            if !lead.trim_start().starts_with("- [") {
1845                continue;
1846            }
1847        }
1848        // Reconstruct the row text with a byte→column map (multi-width
1849        // symbols occupy one cell + skipped continuation cells).
1850        let mut row_text = String::new();
1851        let mut byte_to_col: Vec<(usize, u16)> = Vec::new();
1852        for x in area.x..area.right() {
1853            let Some(cell) = buf.cell(Position::new(x, y)) else {
1854                continue;
1855            };
1856            let sym = cell.symbol();
1857            if sym.is_empty() {
1858                continue;
1859            }
1860            byte_to_col.push((row_text.len(), x));
1861            row_text.push_str(sym);
1862        }
1863        if row_text.trim().is_empty() {
1864            continue;
1865        }
1866        let lower = row_text.to_lowercase();
1867        let fold_safe = lower.len() == row_text.len();
1868
1869        let mut restyle =
1870            |from_byte: usize, to_byte: usize, f: &mut dyn FnMut(&mut ratatui::buffer::Cell)| {
1871                for (b, x) in &byte_to_col {
1872                    if *b >= from_byte
1873                        && *b < to_byte
1874                        && let Some(cell) = buf.cell_mut(Position::new(*x, y))
1875                    {
1876                        f(cell);
1877                    }
1878                }
1879            };
1880
1881        // Task checkboxes: optional indent, `- [ ] ` / `- [x] `.
1882        let trimmed_start = row_text.len() - row_text.trim_start().len();
1883        let after_indent = &row_text[trimmed_start..];
1884        let is_done = after_indent.starts_with("- [x] ") || after_indent.starts_with("- [X] ");
1885        let is_open = after_indent.starts_with("- [ ] ");
1886        if is_done || is_open {
1887            let box_start = trimmed_start + 2;
1888            let box_end = box_start + 3;
1889            restyle(box_start, box_end, &mut |cell| {
1890                cell.set_fg(checkbox_fg);
1891            });
1892            if is_done {
1893                restyle(box_end, row_text.len(), &mut |cell| {
1894                    let style = cell
1895                        .style()
1896                        .add_modifier(Modifier::DIM | Modifier::CROSSED_OUT);
1897                    cell.set_style(style);
1898                });
1899            }
1900        }
1901
1902        // Needle emphasis (skip rows whose case-fold changes length).
1903        if fold_safe {
1904            for needle in needles {
1905                for (start, m) in lower.match_indices(needle.as_str()) {
1906                    restyle(start, start + m.len(), &mut |cell| {
1907                        let style = cell.style().fg(match_fg).add_modifier(Modifier::BOLD);
1908                        cell.set_style(style);
1909                    });
1910                }
1911            }
1912        }
1913    }
1914}
1915
1916impl Component for TextEditorComponent {
1917    fn handle_input(&mut self, event: &InputEvent, tx: &AppTx) -> EventState {
1918        self.maybe_recover_from_dead_nvim();
1919        self.bind_autocomplete_redraw(tx);
1920
1921        match event {
1922            InputEvent::Key(key) => {
1923                // Cheap popup-open probe first. The snapshot is now a
1924                // Cow-borrowed view of the textarea's lines (zero
1925                // allocation on the Textarea path — perf #8), so
1926                // idle keystrokes pay nothing here even when popup
1927                // checks fire. The free-function form lets `&self.backend`
1928                // and `&mut self.autocomplete` coexist via field-disjoint
1929                // borrows.
1930                let popup_open = self.autocomplete.as_ref().is_some_and(|c| c.is_open());
1931                if popup_open
1932                    && let Some(host) = build_editor_host_snapshot(
1933                        &self.backend,
1934                        self.content_revision,
1935                        self.view.last_cursor_screen,
1936                    )
1937                    && let Some(controller) = self.autocomplete.as_mut()
1938                {
1939                    match controller.handle_key(*key, &host) {
1940                        HandleKeyOutcome::Accepted(action) => {
1941                            if let Some(ta) = self.backend.as_textarea_mut() {
1942                                apply_accept_to_textarea(ta, &action);
1943                                self.selection = ta.selection_range();
1944                            }
1945                            self.bump_content();
1946                            return EventState::Consumed;
1947                        }
1948                        HandleKeyOutcome::Dismissed | HandleKeyOutcome::Consumed => {
1949                            return EventState::Consumed;
1950                        }
1951                        HandleKeyOutcome::NotHandled => {}
1952                    }
1953                }
1954                // Find bar intercepts all keys while active. Must run before the
1955                // vim engine, which would otherwise consume keys in Normal mode
1956                // (the textarea backend also intercepts inside handle_textarea_key,
1957                // but the vim Normal-mode path never reaches that).
1958                if self.search.is_some() && self.handle_search_key(key) {
1959                    return EventState::Consumed;
1960                }
1961                // Vim interpreter: Normal/Visual consume the key here; Insert
1962                // mode returns PassThrough and falls into the direct path below
1963                // so typing, autocomplete, auto-surround and smart-Enter all
1964                // keep working (adr/0012).
1965                if let Some(outcome) = self.backend.vim_handle_key(key) {
1966                    use self::vim::VimKeyOutcome;
1967                    match outcome {
1968                        VimKeyOutcome::TextMutated => {
1969                            self.selection = None;
1970                            self.bump_content();
1971                            return EventState::Consumed;
1972                        }
1973                        VimKeyOutcome::CursorOnly => {
1974                            // Mirror the textarea's selection into self.selection so
1975                            // Visual mode renders through the existing selection pipeline.
1976                            // For non-visual CursorOnly (plain motion), selection_range()
1977                            // returns None → self.selection = None (no regression).
1978                            self.selection = self
1979                                .backend
1980                                .as_textarea()
1981                                .and_then(|ta| ta.selection_range());
1982                            // Charwise Visual highlight: extend end col by 1 so the
1983                            // char under the cursor is visually included (vim inclusive).
1984                            // VisualLine uses a separate rendering path (full-line) and
1985                            // is left unchanged.
1986                            if self.backend.vim_is_charwise_visual()
1987                                && let Some(((sr, sc), (er, ec))) = self.selection
1988                            {
1989                                let len = self
1990                                    .backend
1991                                    .as_textarea()
1992                                    .and_then(|ta| ta.lines().get(er))
1993                                    .map(|l| l.chars().count())
1994                                    .unwrap_or(ec);
1995                                self.selection = Some(((sr, sc), (er, (ec + 1).min(len))));
1996                            }
1997                            self.refresh_autocomplete_if_open();
1998                            self.edit_generation = self.edit_generation.wrapping_add(1);
1999                            return EventState::Consumed;
2000                        }
2001                        VimKeyOutcome::NoOp => return EventState::Consumed,
2002                        VimKeyOutcome::PassThrough => { /* fall through to direct path */ }
2003                        VimKeyOutcome::Host(action) => {
2004                            use self::vim::VimHostAction;
2005                            match action {
2006                                VimHostAction::OpenPalette => {
2007                                    // Reuse the existing palette gateway.
2008                                    tx.send(AppEvent::ExecuteLeaderAction(
2009                                        crate::keys::leader::LeaderAction::Palette,
2010                                    ))
2011                                    .ok();
2012                                }
2013                                VimHostAction::OpenSearch { forward: _ } => {
2014                                    // `/` and `?` open the existing find bar.
2015                                    // (`?` backward-first is a later refinement;
2016                                    // n/N still navigate both directions.)
2017                                    self.open_or_advance_search();
2018                                }
2019                                VimHostAction::SearchNext => self.vim_search_repeat(false),
2020                                VimHostAction::SearchPrev => self.vim_search_repeat(true),
2021                            }
2022                            return EventState::Consumed;
2023                        }
2024                    }
2025                }
2026                if let Some(state) = self.handle_nvim_key(key, tx) {
2027                    return state;
2028                }
2029                // Diff before/after using cheap counters instead of cloning
2030                // the whole buffer. `text_revision` only bumps when the
2031                // buffer actually changed (handlers call `bump_text`);
2032                // cursor position is two `usize`s. Three outcomes:
2033                //   - text changed → sync (may open a fresh popup)
2034                //   - text unchanged, cursor moved → refresh (close
2035                //     popup if cursor left the trigger range; never
2036                //     open new popup just because the cursor passed
2037                //     over an existing wikilink/hashtag)
2038                //   - both unchanged → no autocomplete work needed
2039                let text_rev_before = self.content_revision;
2040                let cursor_before = self.textarea_cursor();
2041                let result = self.handle_textarea_key(key, tx);
2042                let cursor_after = self.textarea_cursor();
2043                if self.content_revision != text_rev_before {
2044                    self.sync_autocomplete();
2045                } else if cursor_before != cursor_after {
2046                    self.refresh_autocomplete_if_open();
2047                }
2048                result
2049            }
2050            InputEvent::Mouse(mouse) => {
2051                let text_rev_before = self.content_revision;
2052                let cursor_before = self.textarea_cursor();
2053                let result = self.handle_mouse(mouse);
2054                let cursor_after = self.textarea_cursor();
2055                // Mouse clicks typically only move the cursor — refresh
2056                // (which may close the popup) but do not auto-open.
2057                if self.content_revision != text_rev_before {
2058                    self.sync_autocomplete();
2059                } else if cursor_before != cursor_after {
2060                    self.refresh_autocomplete_if_open();
2061                }
2062                // Spec §10: a left click landing on a wikilink follows it and
2063                // a click on a #tag runs its query. The cursor has already
2064                // been placed by `handle_mouse`, so `link_at_cursor` reads
2065                // the clicked position.
2066                if result == EventState::Consumed
2067                    && matches!(
2068                        mouse.kind,
2069                        ratatui::crossterm::event::MouseEventKind::Down(
2070                            ratatui::crossterm::event::MouseButton::Left
2071                        )
2072                    )
2073                {
2074                    match self.link_at_cursor() {
2075                        Some(LinkTarget::Note(target)) => {
2076                            tx.send(AppEvent::FollowLink(target)).ok();
2077                        }
2078                        Some(LinkTarget::Label(name)) => {
2079                            tx.send(AppEvent::FollowLabel(name)).ok();
2080                        }
2081                        None => {}
2082                    }
2083                }
2084                // Plan 3 Task 5: reconcile the vim engine mode from whether the
2085                // textarea selection is live after the mouse event. A drag that
2086                // creates a selection enters Visual; a click that clears one
2087                // returns to Normal. Insert mode is left untouched (the engine
2088                // match arm is a no-op for all modes other than Normal/Visual).
2089                // A bare click leaves a collapsed (zero-width) selection active
2090                // because handle_mouse's Down arm calls start_selection().
2091                // Only treat a NON-EMPTY selection as "real" to avoid flipping
2092                // vim Normal→Visual on a plain click.  Mirrors the same guard
2093                // at ~line 1014 which protects auto-indent from collapsed sel.
2094                let has_sel = self
2095                    .backend
2096                    .as_textarea()
2097                    .and_then(|ta| ta.selection_range())
2098                    .is_some_and(|(s, e)| s != e);
2099                self.backend.vim_sync_mouse_selection(has_sel);
2100                result
2101            }
2102            // Bracketed paste is intercepted by EditorScreen so it can run the
2103            // image-paste flow first. It never reaches us here.
2104            InputEvent::Paste(_) => EventState::NotConsumed,
2105        }
2106    }
2107
2108    fn render(&mut self, f: &mut Frame, rect: Rect, theme: &Theme, focused: bool) {
2109        // Reserve the bottom row for the find bar when active.
2110        let (editor_rect, search_rect) = if self.search.is_some() && rect.height > 1 {
2111            (
2112                Rect {
2113                    height: rect.height - 1,
2114                    ..rect
2115                },
2116                Some(Rect {
2117                    y: rect.y + rect.height - 1,
2118                    height: 1,
2119                    ..rect
2120                }),
2121            )
2122        } else {
2123            (rect, None)
2124        };
2125        // Store the editor area (not the full rect) so mouse hit-testing ignores
2126        // clicks on the find-bar row.
2127        self.rect = editor_rect;
2128        // Phase 1: gather per-backend selection + (Nvim only) the
2129        // content_gen the refresh task observed. Done before
2130        // `view_snapshot()` so the Nvim path's content_revision mirror
2131        // lands first.
2132        let (selection, nvim_rev_to_mirror) = match &self.backend {
2133            BackendState::Textarea(_) => (self.selection, None),
2134            BackendState::Nvim(nvim) => {
2135                let fs = self
2136                    .nvim_host
2137                    .frame_sync(nvim, editor_rect.width, editor_rect.height);
2138                (fs.selection, fs.rev)
2139            }
2140        };
2141        if let Some(rev) = nvim_rev_to_mirror {
2142            self.content_revision = rev;
2143        }
2144        // Drain any completed background full-parse results BEFORE
2145        // running view.update so a just-finished async parse lands
2146        // before Gate 1 has a chance to install another placeholder.
2147        // Generation mismatches drop silently (the spawned task's
2148        // input is older than the current buffer).
2149        while let Ok((generation, buf)) = self.full_parse_rx.try_recv() {
2150            self.view.install_full_parse(generation, buf);
2151        }
2152
2153        // Phase 2: single producer for the atomic snapshot. Borrowed
2154        // on Textarea (zero clone), owned on Nvim (lines cloned out
2155        // from behind the Mutex). Use the free function so the borrow
2156        // checker can split `&self.backend` from `&mut self.view`.
2157        let snap = snapshot_from_backend(&self.backend, self.content_revision);
2158        self.view.update(&snap, editor_rect, selection);
2159
2160        // If `view.update` cap-tripped on a large buffer it
2161        // installed a placeholder + pending-flag instead of running
2162        // ParsedBuffer::parse synchronously. Spawn the real parse
2163        // here so subsequent frames pick up the rich result via the
2164        // drain loop above. `SingleSlotTask::spawn` aborts the prior
2165        // task, so a burst of large-buffer edits resolves against
2166        // the latest content.
2167        if let Some(generation) = self.view.take_pending_full_parse() {
2168            let lines: Vec<String> = snap.lines.iter().cloned().collect();
2169            let tx = self.full_parse_tx.clone();
2170            let redraw = self.redraw_tx.clone();
2171            self.full_parse_task.spawn(async move {
2172                let buf = ParsedBuffer::parse(&lines);
2173                let _ = tx.send((generation, buf));
2174                // Wake the render loop so the rich parse lands
2175                // without waiting for the next keystroke.
2176                if let Some(redraw) = redraw {
2177                    let _ = redraw.send(AppEvent::Redraw);
2178                }
2179            });
2180        }
2181        // When the find bar is active, draw it AFTER the editor so its caret
2182        // (set via set_cursor_position) wins over the editor's caret call.
2183        let bar_focused = self.search.is_some() && focused;
2184        let editor_focused = focused && !bar_focused;
2185        use self::view::CursorShape;
2186        let cursor_shape = match self.backend.modal_is_insert() {
2187            None => None, // Direct textarea — leave terminal default
2188            Some(true) => Some(CursorShape::Bar),
2189            Some(false) => Some(CursorShape::Block),
2190        };
2191        self.view
2192            .render(f, editor_rect, theme, editor_focused, cursor_shape);
2193
2194        // Search-match emphasis (spec §5.1): paint needle matches and task
2195        // checkboxes over the rendered viewport. Buffer-level post-pass —
2196        // viewport-only, so large notes pay nothing beyond the visible rows.
2197        if self
2198            .needles_revision
2199            .is_some_and(|r| r != self.content_revision)
2200        {
2201            self.search_needles.clear();
2202            self.needles_revision = None;
2203        }
2204        let mut emphasis_needles = self.search_needles.clone();
2205        if let Some(state) = &self.search {
2206            let q = state.input.value().trim().to_lowercase();
2207            if !q.is_empty() {
2208                emphasis_needles.push(q);
2209            }
2210        }
2211        paint_viewport_extras(f.buffer_mut(), editor_rect, &emphasis_needles, theme);
2212
2213        // Empty-note tip (spec §5.2): dim ghost text in a fresh/empty buffer,
2214        // gone the instant the first character lands (the buffer stops being
2215        // empty). Drawn after the view so it sits over the blank canvas.
2216        if snap.lines.iter().all(|l| l.is_empty()) && editor_rect.height > 0 {
2217            let leader = self
2218                .key_bindings
2219                .first_combo_for(&crate::keys::action_shortcuts::ActionShortcuts::Leader)
2220                .unwrap_or_else(|| "leader".to_string());
2221            f.render_widget(
2222                ratatui::widgets::Paragraph::new(format!(
2223                    "Type to start · [[ to link · # to tag · {leader} for commands"
2224                ))
2225                .style(
2226                    Style::default()
2227                        .fg(theme.gray.to_ratatui())
2228                        .add_modifier(Modifier::ITALIC),
2229                ),
2230                Rect {
2231                    x: editor_rect.x.saturating_add(2),
2232                    width: editor_rect.width.saturating_sub(2),
2233                    height: 1,
2234                    ..editor_rect
2235                },
2236            );
2237        }
2238        if let (Some(state), Some(bar_rect)) = (self.search.as_mut(), search_rect) {
2239            render_search_bar(f, bar_rect, state, theme, bar_focused);
2240        }
2241
2242        // Autocomplete popup sits on top of the editor. Drain async
2243        // query results first so the popup reflects the latest prefix,
2244        // then re-anchor on the cursor's freshly-rendered screen
2245        // position (otherwise the anchor lags one frame behind on the
2246        // very first popup-opening keystroke). Clamp against
2247        // `editor_rect`, not the full `rect`, so the popup never lands
2248        // on the find-bar row.
2249        self.poll_autocomplete();
2250        // The popup anchors on the cursor's just-rendered screen
2251        // position. When the cursor is off-screen
2252        // (`last_cursor_screen == None`) we skip rendering entirely
2253        // rather than draw at a stale anchor — the popup state is
2254        // preserved, so the popup reappears at the correct position
2255        // once the cursor scrolls back into view.
2256        if let (Some(controller), Some(live_anchor)) =
2257            (self.autocomplete.as_mut(), self.view.last_cursor_screen)
2258        {
2259            if let Some(state) = controller.state_mut() {
2260                state.anchor = live_anchor;
2261            }
2262            if let Some(state) = controller.state() {
2263                autocomplete::render(f, state, editor_rect, theme);
2264            }
2265        }
2266    }
2267
2268    fn hint_shortcuts(&self) -> Vec<(String, String)> {
2269        use crate::keys::action_shortcuts::ActionShortcuts;
2270
2271        // Prepend the modal-mode label (nvim or vim) as the first "hint".
2272        // When the vim interpreter has a pending command sequence (e.g. "2d",
2273        // "f", ">"), append it to the label so the user can see what they have
2274        // typed so far.
2275        if let Some(mut label) = self.backend.mode_label() {
2276            if let Some(p) = self.backend.vim_pending_hint() {
2277                label = format!("{label}  {p}");
2278            }
2279            let mut hints = vec![(String::new(), label)];
2280            hints.extend(
2281                [
2282                    (ActionShortcuts::FocusSidebar, "\u{2190} focus left"),
2283                    (ActionShortcuts::FocusEditor, "focus right \u{2192}"),
2284                    (ActionShortcuts::FileOperations, "file ops"),
2285                ]
2286                .iter()
2287                .filter_map(|(action, label)| {
2288                    self.key_bindings
2289                        .first_combo_for(action)
2290                        .map(|k| (k, label.to_string()))
2291                }),
2292            );
2293            return hints;
2294        }
2295
2296        // Cursor-context hints come first: what the cursor is on decides the
2297        // most relevant action (spec §5.2).
2298        let mut hints: Vec<(String, String)> = Vec::new();
2299        match self.link_at_cursor() {
2300            Some(LinkTarget::Note(_)) => {
2301                if let Some(k) = self
2302                    .key_bindings
2303                    .first_combo_for(&ActionShortcuts::FollowLink)
2304                {
2305                    hints.push((k, "follow link".to_string()));
2306                }
2307            }
2308            Some(LinkTarget::Label(_)) => {
2309                if let Some(k) = self
2310                    .key_bindings
2311                    .first_combo_for(&ActionShortcuts::FollowLink)
2312                {
2313                    hints.push((k, "browse tag".to_string()));
2314                }
2315            }
2316            None => {}
2317        }
2318        hints.extend(crate::components::hints::hints_for(
2319            &self.key_bindings,
2320            &[
2321                (ActionShortcuts::FocusSidebar, "\u{2190} focus left"),
2322                (ActionShortcuts::FocusEditor, "focus right \u{2192}"),
2323                (ActionShortcuts::FileOperations, "file ops"),
2324                (ActionShortcuts::FindInBuffer, "find"),
2325            ],
2326        ));
2327        hints
2328    }
2329}
2330
2331#[cfg(test)]
2332mod tests {
2333    use super::snapshot::EditorMode;
2334    use super::*;
2335    use crate::keys::KeyBindings;
2336
2337    fn make_editor() -> TextEditorComponent {
2338        TextEditorComponent::new(
2339            KeyBindings::empty(),
2340            &crate::settings::AppSettings::default(),
2341        )
2342    }
2343
2344    fn dummy_tx() -> AppTx {
2345        tokio::sync::mpsc::unbounded_channel().0
2346    }
2347
2348    fn get_ta(editor: &mut TextEditorComponent) -> &mut TextArea<'static> {
2349        match &mut editor.backend {
2350            BackendState::Textarea(tb) => &mut tb.ta,
2351            _ => panic!("expected Textarea backend"),
2352        }
2353    }
2354
2355    #[test]
2356    fn has_trigger_before_cursor_finds_bracket() {
2357        assert!(has_trigger_before_cursor("hello [[foo", 11));
2358        assert!(has_trigger_before_cursor("[[a b c", 7));
2359    }
2360
2361    #[test]
2362    fn has_trigger_before_cursor_finds_hashtag() {
2363        assert!(has_trigger_before_cursor("text #tag", 9));
2364    }
2365
2366    #[test]
2367    fn has_trigger_before_cursor_no_trigger_bails() {
2368        assert!(!has_trigger_before_cursor("plain prose here", 16));
2369        assert!(!has_trigger_before_cursor("", 0));
2370    }
2371
2372    #[test]
2373    fn has_trigger_before_cursor_handles_multibyte_no_panic() {
2374        // Regression: the previous 64-byte saturating_sub slice could
2375        // land mid-codepoint and panic on CJK / emoji / accented lines.
2376        let line = "你好世界".to_string() + &"a".repeat(80);
2377        let col = line.chars().count();
2378        assert!(!has_trigger_before_cursor(&line, col));
2379
2380        let with_emoji = "🦀".repeat(20) + "[[note";
2381        let col = with_emoji.chars().count();
2382        assert!(has_trigger_before_cursor(&with_emoji, col));
2383
2384        let accented = "é".repeat(100);
2385        let col = accented.chars().count();
2386        assert!(!has_trigger_before_cursor(&accented, col));
2387    }
2388
2389    #[test]
2390    fn has_trigger_before_cursor_ignores_chars_after_cursor() {
2391        // Trigger AFTER cursor must not match.
2392        assert!(!has_trigger_before_cursor("foo [[bar", 3));
2393    }
2394
2395    #[test]
2396    fn has_trigger_before_cursor_wikilink_with_spaces() {
2397        // Wikilink contents can contain spaces; we must still detect the
2398        // opening bracket far back on the line.
2399        assert!(has_trigger_before_cursor("[[my note title", 15));
2400    }
2401
2402    #[test]
2403    fn fresh_editor_is_not_dirty() {
2404        let editor = make_editor();
2405        assert!(!editor.is_dirty());
2406    }
2407
2408    #[test]
2409    fn after_set_text_not_dirty() {
2410        let mut editor = make_editor();
2411        editor.set_text("hello world".to_string());
2412        assert!(!editor.is_dirty());
2413    }
2414
2415    #[test]
2416    fn get_text_returns_loaded_content() {
2417        let mut editor = make_editor();
2418        editor.set_text("line one\nline two".to_string());
2419        assert_eq!(editor.get_text(), "line one\nline two");
2420    }
2421
2422    #[test]
2423    fn mark_saved_clears_dirty() {
2424        let mut editor = make_editor();
2425        editor.set_text("initial".to_string());
2426        let text = editor.get_text();
2427        editor.mark_saved(text.clone() + "x"); // saved state diverges
2428        assert!(editor.is_dirty());
2429        editor.mark_saved(text); // saved state matches again
2430        assert!(!editor.is_dirty());
2431    }
2432
2433    #[test]
2434    fn trailing_newline_does_not_cause_false_dirty() {
2435        let mut editor = make_editor();
2436        editor.set_text("content\n".to_string());
2437        assert!(
2438            !editor.is_dirty(),
2439            "trailing newline should not make editor dirty after load"
2440        );
2441    }
2442
2443    #[test]
2444    fn cursor_move_does_not_dirty_buffer() {
2445        let mut editor = make_editor();
2446        editor.set_text("hello world".to_string());
2447        assert!(!editor.is_dirty());
2448        let tx = dummy_tx();
2449        // Send a cursor-only key (Right arrow). It must bump `edit_generation`
2450        // for view-cache invalidation but must NOT bump `text_revision`, so
2451        // `is_dirty` stays false.
2452        let key = ratatui::crossterm::event::KeyEvent::new(KeyCode::Right, KeyModifiers::NONE);
2453        let _ = editor.handle_input(&InputEvent::Key(key), &tx);
2454        assert!(
2455            !editor.is_dirty(),
2456            "cursor move must not mark the editor as dirty"
2457        );
2458    }
2459
2460    #[test]
2461    fn empty_stack_undo_redo_does_not_dirty_or_bump_revision() {
2462        // Regression: ShortcutOutcome::NoOp must apply for Ctrl+Z / Ctrl+Y
2463        // when the undo/redo stack is empty. Both is_dirty and the
2464        // raw content_revision counter stay put.
2465        let mut editor = make_editor();
2466        editor.set_text("foo".to_string());
2467        let rev_before = editor.content_revision();
2468        assert!(!editor.is_dirty());
2469        let tx = dummy_tx();
2470        for key_code in [KeyCode::Char('z'), KeyCode::Char('y')] {
2471            let key = ratatui::crossterm::event::KeyEvent::new(key_code, KeyModifiers::CONTROL);
2472            let _ = editor.handle_input(&InputEvent::Key(key), &tx);
2473        }
2474        assert!(
2475            !editor.is_dirty(),
2476            "empty-stack undo/redo must not flip is_dirty"
2477        );
2478        assert_eq!(
2479            editor.content_revision(),
2480            rev_before,
2481            "empty-stack undo/redo must not bump content_revision"
2482        );
2483    }
2484
2485    #[test]
2486    fn fresh_editor_content_revision_is_nonzero() {
2487        // Regression: content_revision is typed `NonZeroU64`, which
2488        // makes the "do not cache" sentinel for `AutocompleteHost`
2489        // expressible as `Option::None` without a magic value.
2490        // `NonZeroU64::get()` is always >= 1 by construction; this
2491        // test is now a tautological smoke test that the constructor
2492        // initialises the field.
2493        let editor = make_editor();
2494        assert!(editor.content_revision().get() >= 1);
2495    }
2496
2497    #[test]
2498    fn mouse_down_clears_selection() {
2499        let mut editor = make_editor();
2500        editor.set_text("hello world".to_string());
2501        let ta = get_ta(&mut editor);
2502        ta.start_selection();
2503        ta.move_cursor(ratatui_textarea::CursorMove::WordForward);
2504        assert!(ta.selection_range().is_some());
2505        ta.cancel_selection();
2506        editor.selection = if let BackendState::Textarea(tb) = &editor.backend {
2507            tb.ta.selection_range()
2508        } else {
2509            None
2510        };
2511        assert!(editor.selection.is_none());
2512    }
2513
2514    #[test]
2515    fn ctrl_c_copies_selected_text() {
2516        let mut editor = make_editor();
2517        editor.set_text("hello world".to_string());
2518        let ta = get_ta(&mut editor);
2519        ta.move_cursor(ratatui_textarea::CursorMove::Head);
2520        ta.start_selection();
2521        ta.move_cursor(ratatui_textarea::CursorMove::WordForward);
2522        let range = ta.selection_range().unwrap();
2523        let ((sr, sc), (er, ec)) = range;
2524        let lines = ta.lines();
2525        let selected = if sr == er {
2526            lines[sr][sc..ec].to_string()
2527        } else {
2528            lines[sr][sc..].to_string()
2529        };
2530        assert_eq!(selected, "hello ");
2531    }
2532
2533    /// Selects the char-coordinate range `start..end` in the editor's textarea.
2534    fn select_range(editor: &mut TextEditorComponent, start: (u16, u16), end: (u16, u16)) {
2535        let ta = get_ta(editor);
2536        ta.cancel_selection();
2537        ta.move_cursor(CursorMove::Jump(start.0, start.1));
2538        ta.start_selection();
2539        ta.move_cursor(CursorMove::Jump(end.0, end.1));
2540        assert!(ta.selection_range().is_some());
2541    }
2542
2543    fn send_char(editor: &mut TextEditorComponent, c: char) {
2544        let tx = dummy_tx();
2545        let key = ratatui::crossterm::event::KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE);
2546        let _ = editor.handle_input(&InputEvent::Key(key), &tx);
2547    }
2548
2549    #[test]
2550    fn surround_pair_maps_open_and_symmetric_chars() {
2551        assert_eq!(surround_pair('('), Some(("(", ")")));
2552        assert_eq!(surround_pair('['), Some(("[", "]")));
2553        assert_eq!(surround_pair('{'), Some(("{", "}")));
2554        assert_eq!(surround_pair('<'), Some(("<", ">")));
2555        assert_eq!(surround_pair('"'), Some(("\"", "\"")));
2556        assert_eq!(surround_pair('\''), Some(("'", "'")));
2557        assert_eq!(surround_pair('`'), Some(("`", "`")));
2558        assert_eq!(surround_pair('*'), Some(("*", "*")));
2559        assert_eq!(surround_pair('_'), Some(("_", "_")));
2560        assert_eq!(surround_pair('~'), Some(("~", "~")));
2561        // Closing chars and plain chars never wrap.
2562        assert_eq!(surround_pair(')'), None);
2563        assert_eq!(surround_pair(']'), None);
2564        assert_eq!(surround_pair('}'), None);
2565        assert_eq!(surround_pair('>'), None);
2566        assert_eq!(surround_pair('a'), None);
2567    }
2568
2569    #[test]
2570    fn typing_open_paren_with_selection_wraps_it() {
2571        let mut editor = make_editor();
2572        editor.set_text("hello world".to_string());
2573        select_range(&mut editor, (0, 0), (0, 5)); // "hello"
2574        send_char(&mut editor, '(');
2575        assert_eq!(editor.get_text(), "(hello) world");
2576        assert!(editor.is_dirty(), "wrap must mark the buffer dirty");
2577    }
2578
2579    #[test]
2580    fn wrap_keeps_selection_on_inner_text() {
2581        let mut editor = make_editor();
2582        editor.set_text("hello world".to_string());
2583        select_range(&mut editor, (0, 0), (0, 5));
2584        send_char(&mut editor, '(');
2585        // Selection must cover "hello" inside the parens so wraps chain.
2586        assert_eq!(editor.selection, Some(((0, 1), (0, 6))));
2587    }
2588
2589    #[test]
2590    fn chained_brackets_build_a_wikilink() {
2591        let mut editor = make_editor();
2592        editor.set_text("my note".to_string());
2593        select_range(&mut editor, (0, 0), (0, 7));
2594        send_char(&mut editor, '[');
2595        send_char(&mut editor, '[');
2596        assert_eq!(editor.get_text(), "[[my note]]");
2597        assert_eq!(editor.selection, Some(((0, 2), (0, 9))));
2598    }
2599
2600    #[test]
2601    fn symmetric_chars_wrap_and_chain() {
2602        let mut editor = make_editor();
2603        editor.set_text("bold".to_string());
2604        select_range(&mut editor, (0, 0), (0, 4));
2605        send_char(&mut editor, '*');
2606        assert_eq!(editor.get_text(), "*bold*");
2607        send_char(&mut editor, '*');
2608        assert_eq!(editor.get_text(), "**bold**");
2609        assert_eq!(editor.selection, Some(((0, 2), (0, 6))));
2610    }
2611
2612    #[test]
2613    fn closing_char_replaces_selection() {
2614        let mut editor = make_editor();
2615        editor.set_text("hello world".to_string());
2616        select_range(&mut editor, (0, 0), (0, 5));
2617        send_char(&mut editor, ')');
2618        assert_eq!(editor.get_text(), ") world");
2619    }
2620
2621    #[test]
2622    fn open_char_without_selection_inserts_normally() {
2623        let mut editor = make_editor();
2624        editor.set_text("hello".to_string());
2625        let ta = get_ta(&mut editor);
2626        ta.move_cursor(CursorMove::End);
2627        send_char(&mut editor, '(');
2628        assert_eq!(editor.get_text(), "hello(");
2629    }
2630
2631    #[test]
2632    fn wrap_spans_multiline_selection() {
2633        let mut editor = make_editor();
2634        editor.set_text("abc\ndef".to_string());
2635        select_range(&mut editor, (0, 0), (1, 3));
2636        send_char(&mut editor, '(');
2637        assert_eq!(editor.get_text(), "(abc\ndef)");
2638        // Inner selection: open char shifts only the first line.
2639        assert_eq!(editor.selection, Some(((0, 1), (1, 3))));
2640    }
2641
2642    #[test]
2643    fn wrap_handles_multibyte_selection() {
2644        let mut editor = make_editor();
2645        editor.set_text("héllo🦀 x".to_string());
2646        select_range(&mut editor, (0, 0), (0, 6)); // "héllo🦀" = 6 chars
2647        send_char(&mut editor, '`');
2648        assert_eq!(editor.get_text(), "`héllo🦀` x");
2649        assert_eq!(editor.selection, Some(((0, 1), (0, 7))));
2650    }
2651
2652    #[test]
2653    fn wrap_with_reversed_selection_direction() {
2654        // Selection made right-to-left must wrap the same way.
2655        let mut editor = make_editor();
2656        editor.set_text("hello world".to_string());
2657        select_range(&mut editor, (0, 5), (0, 0));
2658        send_char(&mut editor, '(');
2659        assert_eq!(editor.get_text(), "(hello) world");
2660        assert_eq!(editor.selection, Some(((0, 1), (0, 6))));
2661    }
2662
2663    #[test]
2664    fn text_action_keeps_selection_on_inner_text() {
2665        // Bold/Italic/Strikethrough route through the same wrap mechanism as
2666        // auto-surround: the inner text stays selected so wraps chain.
2667        let mut editor = make_editor();
2668        editor.set_text("bold word".to_string());
2669        select_range(&mut editor, (0, 0), (0, 4));
2670        editor.apply_text_action(TextAction::Bold);
2671        assert_eq!(editor.get_text(), "**bold** word");
2672        assert_eq!(editor.selection, Some(((0, 2), (0, 6))));
2673    }
2674
2675    #[test]
2676    fn wrap_undo_is_two_steps_back_to_original() {
2677        // Documented trade-off: ratatui-textarea has no edit grouping, so a
2678        // wrap is delete+insert = two history entries (same as bold/italic
2679        // via apply_text_action). Two undos must restore the original text.
2680        let mut editor = make_editor();
2681        editor.set_text("hello world".to_string());
2682        select_range(&mut editor, (0, 0), (0, 5));
2683        send_char(&mut editor, '(');
2684        assert_eq!(editor.get_text(), "(hello) world");
2685        let ta = get_ta(&mut editor);
2686        ta.undo();
2687        ta.undo();
2688        assert_eq!(editor.get_text(), "hello world");
2689    }
2690
2691    #[test]
2692    fn linkable_url_accepts_supported_schemes() {
2693        assert_eq!(
2694            linkable_url("https://example.com"),
2695            Some("https://example.com")
2696        );
2697        assert_eq!(
2698            linkable_url("http://example.com/path?q=1#frag"),
2699            Some("http://example.com/path?q=1#frag"),
2700        );
2701        assert_eq!(
2702            linkable_url("  https://example.com  "),
2703            Some("https://example.com")
2704        );
2705        assert_eq!(
2706            linkable_url("ftp://files.example.com/x"),
2707            Some("ftp://files.example.com/x"),
2708        );
2709        assert_eq!(
2710            linkable_url("ftps://files.example.com/x"),
2711            Some("ftps://files.example.com/x"),
2712        );
2713        assert_eq!(
2714            linkable_url("mailto:user@example.com"),
2715            Some("mailto:user@example.com"),
2716        );
2717        assert_eq!(
2718            linkable_url("mailto:user@example.com?subject=hi"),
2719            Some("mailto:user@example.com?subject=hi"),
2720        );
2721    }
2722
2723    #[test]
2724    fn linkable_url_rejects_other_schemes_and_plain_text() {
2725        assert_eq!(linkable_url("file:///etc/passwd"), None);
2726        assert_eq!(linkable_url("ssh://host"), None);
2727        assert_eq!(linkable_url("javascript:alert(1)"), None);
2728        assert_eq!(linkable_url("example.com"), None);
2729        assert_eq!(linkable_url("not a url"), None);
2730        assert_eq!(linkable_url(""), None);
2731        assert_eq!(linkable_url("https://example.com\nmore"), None);
2732    }
2733
2734    #[test]
2735    fn try_build_markdown_link_wraps_selection_when_clip_is_url() {
2736        assert_eq!(
2737            try_build_markdown_link("https://example.com", Some("click here")).as_deref(),
2738            Some("[click here](https://example.com)"),
2739        );
2740    }
2741
2742    #[test]
2743    fn try_build_markdown_link_trims_url_whitespace() {
2744        assert_eq!(
2745            try_build_markdown_link("  https://example.com\n", Some("link")).as_deref(),
2746            Some("[link](https://example.com)"),
2747        );
2748    }
2749
2750    #[test]
2751    fn try_build_markdown_link_returns_none_when_no_selection() {
2752        assert_eq!(try_build_markdown_link("https://example.com", None), None);
2753    }
2754
2755    #[test]
2756    fn try_build_markdown_link_returns_none_when_not_url() {
2757        assert_eq!(try_build_markdown_link("plain text", Some("sel")), None);
2758    }
2759
2760    #[test]
2761    fn try_build_markdown_link_returns_none_when_selection_empty() {
2762        assert_eq!(
2763            try_build_markdown_link("https://example.com", Some("")),
2764            None
2765        );
2766    }
2767
2768    #[test]
2769    fn try_build_markdown_link_escapes_close_bracket_in_selection() {
2770        assert_eq!(
2771            try_build_markdown_link("https://example.com", Some("a]b")).as_deref(),
2772            Some(r"[a\]b](https://example.com)"),
2773        );
2774    }
2775
2776    #[test]
2777    fn try_build_markdown_link_wraps_ftp_url() {
2778        assert_eq!(
2779            try_build_markdown_link("ftp://files.example.com/x", Some("download")).as_deref(),
2780            Some("[download](ftp://files.example.com/x)"),
2781        );
2782    }
2783
2784    fn key(code: KeyCode, mods: KeyModifiers) -> ratatui::crossterm::event::KeyEvent {
2785        ratatui::crossterm::event::KeyEvent::new(code, mods)
2786    }
2787
2788    /// Buffer post-pass: needles painted, task rows styled.
2789    #[test]
2790    fn paint_viewport_extras_emphasizes_needles_and_tasks() {
2791        use ratatui::buffer::Buffer;
2792        use ratatui::layout::Position;
2793        let theme = crate::settings::themes::Theme::default();
2794        let area = Rect::new(0, 0, 30, 3);
2795        let mut buf = Buffer::empty(area);
2796        buf.set_string(0, 0, "find the needle here", Style::default());
2797        buf.set_string(0, 1, "- [x] done task", Style::default());
2798        buf.set_string(0, 2, "- [ ] open task", Style::default());
2799
2800        paint_viewport_extras(&mut buf, area, &["needle".to_string()], &theme);
2801
2802        // "needle" starts at col 9 on row 0.
2803        let cell = buf.cell(Position::new(9, 0)).unwrap();
2804        assert_eq!(cell.fg, theme.color_search_match.to_ratatui());
2805        assert!(cell.style().add_modifier.contains(Modifier::BOLD));
2806        // Done-task text is dimmed + struck.
2807        let cell = buf.cell(Position::new(8, 1)).unwrap();
2808        assert!(cell.style().add_modifier.contains(Modifier::CROSSED_OUT));
2809        // Open-task text is NOT struck; its checkbox is accent-colored.
2810        let cell = buf.cell(Position::new(8, 2)).unwrap();
2811        assert!(!cell.style().add_modifier.contains(Modifier::CROSSED_OUT));
2812        let cb = buf.cell(Position::new(3, 2)).unwrap();
2813        assert_eq!(cb.fg, theme.accent.to_ratatui());
2814    }
2815
2816    /// Arrive-from-query needles survive until the first edit.
2817    #[test]
2818    fn search_needles_clear_on_edit() {
2819        let settings = crate::settings::AppSettings::default();
2820        let mut ed = TextEditorComponent::new(settings.key_bindings.clone(), &settings);
2821        ed.set_text("alpha beta".to_string());
2822        ed.set_search_needles(vec!["Alpha".to_string()]);
2823        assert_eq!(ed.search_needles, vec!["alpha"]);
2824        assert_eq!(ed.needles_revision, Some(ed.content_revision));
2825
2826        // An edit bumps the revision; the render-side guard would clear.
2827        ed.set_text("alpha beta gamma".to_string());
2828        assert_ne!(ed.needles_revision, Some(ed.content_revision));
2829    }
2830
2831    #[test]
2832    fn jump_to_heading_moves_cursor_to_heading_line() {
2833        let settings = crate::settings::AppSettings::default();
2834        let mut ed = TextEditorComponent::new(settings.key_bindings.clone(), &settings);
2835        ed.set_text("intro\n# Top\nbody\n## Sub One\nmore\n".to_string());
2836
2837        ed.jump_to_heading("Sub One");
2838        assert_eq!(ed.view_snapshot().cursor.0, 3);
2839
2840        ed.jump_to_heading("Top");
2841        assert_eq!(ed.view_snapshot().cursor.0, 1);
2842
2843        // Unknown heading: cursor stays.
2844        ed.jump_to_heading("Nope");
2845        assert_eq!(ed.view_snapshot().cursor.0, 1);
2846    }
2847
2848    #[test]
2849    fn open_or_advance_search_opens_find_bar_with_empty_query() {
2850        let mut editor = make_editor();
2851        editor.set_text("hello world".to_string());
2852        editor.open_or_advance_search();
2853        let state = editor.search.as_ref().expect("find bar opened");
2854        assert!(state.input.is_empty());
2855        assert!(matches!(state.status, SearchStatus::Empty));
2856    }
2857
2858    #[test]
2859    fn open_or_advance_search_advances_when_already_open() {
2860        let mut editor = make_editor();
2861        editor.set_text("ab ab ab".to_string());
2862        let tx = dummy_tx();
2863        editor.open_or_advance_search();
2864        editor.handle_textarea_key(&key(KeyCode::Char('a'), KeyModifiers::NONE), &tx);
2865        editor.handle_textarea_key(&key(KeyCode::Char('b'), KeyModifiers::NONE), &tx);
2866        // Cursor now at first match (col 0). Re-invoking advances to second.
2867        editor.open_or_advance_search();
2868        let DataCursor(_, col) = get_ta(&mut editor).cursor();
2869        assert_eq!(col, 3, "second invocation advances to next match");
2870    }
2871
2872    #[test]
2873    fn typing_in_find_bar_jumps_cursor_to_first_match() {
2874        let mut editor = make_editor();
2875        editor.set_text("foo bar baz".to_string());
2876        let tx = dummy_tx();
2877        editor.open_or_advance_search();
2878        for ch in ['b', 'a', 'r'] {
2879            editor.handle_textarea_key(&key(KeyCode::Char(ch), KeyModifiers::NONE), &tx);
2880        }
2881        let state = editor.search.as_ref().unwrap();
2882        assert_eq!(state.input.value(), "bar");
2883        assert!(matches!(state.status, SearchStatus::Match));
2884        let DataCursor(_, col) = get_ta(&mut editor).cursor();
2885        assert_eq!(col, 4, "cursor jumped to start of 'bar'");
2886    }
2887
2888    #[test]
2889    fn enter_in_find_bar_advances_to_next_match() {
2890        let mut editor = make_editor();
2891        editor.set_text("ab ab ab".to_string());
2892        let tx = dummy_tx();
2893        editor.open_or_advance_search();
2894        editor.handle_textarea_key(&key(KeyCode::Char('a'), KeyModifiers::NONE), &tx);
2895        editor.handle_textarea_key(&key(KeyCode::Char('b'), KeyModifiers::NONE), &tx);
2896        // first match is at col 0 (match_cursor=true on type)
2897        editor.handle_textarea_key(&key(KeyCode::Enter, KeyModifiers::NONE), &tx);
2898        let DataCursor(_, col) = get_ta(&mut editor).cursor();
2899        assert_eq!(col, 3, "Enter advances to second match");
2900    }
2901
2902    #[test]
2903    fn match_is_highlighted_as_selection_after_search() {
2904        let mut editor = make_editor();
2905        editor.set_text("foo bar baz".to_string());
2906        let tx = dummy_tx();
2907        editor.open_or_advance_search();
2908        for ch in ['b', 'a', 'r'] {
2909            editor.handle_textarea_key(&key(KeyCode::Char(ch), KeyModifiers::NONE), &tx);
2910        }
2911        // "bar" lives at cols 4..7 on row 0.
2912        assert_eq!(editor.selection, Some(((0, 4), (0, 7))));
2913    }
2914
2915    #[test]
2916    fn no_match_clears_selection() {
2917        let mut editor = make_editor();
2918        editor.set_text("hello".to_string());
2919        let tx = dummy_tx();
2920        editor.open_or_advance_search();
2921        editor.handle_textarea_key(&key(KeyCode::Char('z'), KeyModifiers::NONE), &tx);
2922        assert_eq!(editor.selection, None);
2923    }
2924
2925    #[test]
2926    fn esc_in_find_bar_clears_selection_highlight() {
2927        let mut editor = make_editor();
2928        editor.set_text("foo bar".to_string());
2929        let tx = dummy_tx();
2930        editor.open_or_advance_search();
2931        editor.handle_textarea_key(&key(KeyCode::Char('b'), KeyModifiers::NONE), &tx);
2932        editor.handle_textarea_key(&key(KeyCode::Char('a'), KeyModifiers::NONE), &tx);
2933        editor.handle_textarea_key(&key(KeyCode::Char('r'), KeyModifiers::NONE), &tx);
2934        assert!(editor.selection.is_some());
2935        editor.handle_textarea_key(&key(KeyCode::Esc, KeyModifiers::NONE), &tx);
2936        assert!(editor.selection.is_none());
2937    }
2938
2939    #[test]
2940    fn esc_in_find_bar_closes_it() {
2941        let mut editor = make_editor();
2942        editor.set_text("hello".to_string());
2943        let tx = dummy_tx();
2944        editor.open_or_advance_search();
2945        assert!(editor.search.is_some());
2946        editor.handle_textarea_key(&key(KeyCode::Esc, KeyModifiers::NONE), &tx);
2947        assert!(editor.search.is_none());
2948    }
2949
2950    #[test]
2951    fn find_bar_consumes_typing_so_editor_text_is_unchanged() {
2952        let mut editor = make_editor();
2953        editor.set_text("hello".to_string());
2954        let tx = dummy_tx();
2955        editor.open_or_advance_search();
2956        editor.handle_textarea_key(&key(KeyCode::Char('x'), KeyModifiers::NONE), &tx);
2957        assert_eq!(editor.get_text(), "hello");
2958    }
2959
2960    #[test]
2961    fn no_match_status_when_query_absent() {
2962        let mut editor = make_editor();
2963        editor.set_text("hello".to_string());
2964        let tx = dummy_tx();
2965        editor.open_or_advance_search();
2966        editor.handle_textarea_key(&key(KeyCode::Char('z'), KeyModifiers::NONE), &tx);
2967        let state = editor.search.as_ref().unwrap();
2968        assert!(matches!(state.status, SearchStatus::NoMatch));
2969    }
2970
2971    #[test]
2972    fn try_build_markdown_link_wraps_mailto_url() {
2973        assert_eq!(
2974            try_build_markdown_link("mailto:user@example.com", Some("email me")).as_deref(),
2975            Some("[email me](mailto:user@example.com)"),
2976        );
2977    }
2978
2979    #[test]
2980    fn insert_at_cursor_appends_text() {
2981        let mut editor = make_editor();
2982        editor.set_text("hello".to_string());
2983        {
2984            let ta = get_ta(&mut editor);
2985            ta.move_cursor(ratatui_textarea::CursorMove::End);
2986        }
2987        editor.insert_at_cursor(" world", &dummy_tx());
2988        assert_eq!(editor.get_text(), "hello world");
2989    }
2990
2991    #[test]
2992    fn insert_at_cursor_replaces_selection() {
2993        let mut editor = make_editor();
2994        editor.set_text("hello world".to_string());
2995        {
2996            let ta = get_ta(&mut editor);
2997            ta.move_cursor(ratatui_textarea::CursorMove::Head);
2998            ta.start_selection();
2999            ta.move_cursor(ratatui_textarea::CursorMove::WordForward);
3000        }
3001        editor.insert_at_cursor("HEY ", &dummy_tx());
3002        assert_eq!(editor.get_text(), "HEY world");
3003    }
3004
3005    #[test]
3006    fn paste_inserts_text_at_cursor() {
3007        let mut editor = make_editor();
3008        editor.set_text("hello".to_string());
3009        let ta = get_ta(&mut editor);
3010        ta.move_cursor(ratatui_textarea::CursorMove::End);
3011        ta.insert_str(" world");
3012        assert_eq!(editor.get_text(), "hello world");
3013    }
3014
3015    #[test]
3016    fn bold_action_with_no_selection_inserts_pair_and_centers_cursor() {
3017        let mut editor = make_editor();
3018        editor.set_text("hello".to_string());
3019        {
3020            let ta = get_ta(&mut editor);
3021            ta.move_cursor(ratatui_textarea::CursorMove::End);
3022        }
3023        editor.apply_text_action(TextAction::Bold);
3024        assert_eq!(editor.get_text(), "hello****");
3025        let ta = get_ta(&mut editor);
3026        assert_eq!(ta.cursor(), (0, 7));
3027    }
3028
3029    #[test]
3030    fn italic_action_with_no_selection_inserts_single_pair() {
3031        let mut editor = make_editor();
3032        editor.set_text(String::new());
3033        editor.apply_text_action(TextAction::Italic);
3034        assert_eq!(editor.get_text(), "**");
3035        let ta = get_ta(&mut editor);
3036        assert_eq!(ta.cursor(), (0, 1));
3037    }
3038
3039    #[test]
3040    fn strikethrough_action_with_selection_wraps_text() {
3041        let mut editor = make_editor();
3042        editor.set_text("hello world".to_string());
3043        {
3044            let ta = get_ta(&mut editor);
3045            ta.move_cursor(ratatui_textarea::CursorMove::Head);
3046            ta.start_selection();
3047            ta.move_cursor(ratatui_textarea::CursorMove::WordForward);
3048        }
3049        editor.apply_text_action(TextAction::Strikethrough);
3050        assert_eq!(editor.get_text(), "~~hello ~~world");
3051    }
3052
3053    #[test]
3054    fn bold_action_wraps_non_ascii_selection() {
3055        let mut editor = make_editor();
3056        editor.set_text("hello 你好 world".to_string());
3057        {
3058            let ta = get_ta(&mut editor);
3059            ta.move_cursor(ratatui_textarea::CursorMove::Head);
3060            ta.move_cursor(ratatui_textarea::CursorMove::WordForward);
3061            ta.start_selection();
3062            ta.move_cursor(ratatui_textarea::CursorMove::WordForward);
3063        }
3064        editor.apply_text_action(TextAction::Bold);
3065        assert_eq!(editor.get_text(), "hello **你好 **world");
3066    }
3067
3068    #[test]
3069    fn bold_action_wraps_selected_text() {
3070        let mut editor = make_editor();
3071        editor.set_text("foo bar".to_string());
3072        {
3073            let ta = get_ta(&mut editor);
3074            ta.move_cursor(ratatui_textarea::CursorMove::Head);
3075            ta.start_selection();
3076            ta.move_cursor(ratatui_textarea::CursorMove::WordForward);
3077        }
3078        editor.apply_text_action(TextAction::Bold);
3079        assert_eq!(editor.get_text(), "**foo **bar");
3080    }
3081
3082    #[test]
3083    fn indent_no_selection_indents_current_line() {
3084        let mut editor = make_editor();
3085        editor.set_text("foo\nbar".to_string());
3086        {
3087            let ta = get_ta(&mut editor);
3088            ta.move_cursor(ratatui_textarea::CursorMove::Bottom);
3089        }
3090        editor.indent_lines(false);
3091        let lines = get_ta(&mut editor).lines();
3092        assert_eq!(lines[0], "foo");
3093        assert!(lines[1].starts_with(' ') || lines[1].starts_with('\t'));
3094        assert!(lines[1].trim_start() == "bar");
3095    }
3096
3097    #[test]
3098    fn indent_midline_selection_keeps_text_before_and_selection() {
3099        let mut editor = make_editor();
3100        editor.set_text("hello world".to_string());
3101        {
3102            let ta = get_ta(&mut editor);
3103            ta.move_cursor(ratatui_textarea::CursorMove::Jump(0, 6));
3104            ta.start_selection();
3105            ta.move_cursor(ratatui_textarea::CursorMove::End);
3106        }
3107        editor.indent_lines(false);
3108        let ta = get_ta(&mut editor);
3109        // Text before the selection must survive; only a leading indent added.
3110        assert_eq!(ta.lines()[0].trim_start(), "hello world");
3111        // Selection preserved, shifted right by the inserted indent.
3112        let indent = ta.lines()[0].len() - "hello world".len();
3113        assert_eq!(
3114            ta.selection_range(),
3115            Some(((0, 6 + indent), (0, 11 + indent)))
3116        );
3117    }
3118
3119    #[test]
3120    fn indent_with_selection_indents_all_touched_lines() {
3121        let mut editor = make_editor();
3122        editor.set_text("foo\nbar\nbaz".to_string());
3123        {
3124            let ta = get_ta(&mut editor);
3125            ta.move_cursor(ratatui_textarea::CursorMove::Top);
3126            ta.start_selection();
3127            ta.move_cursor(ratatui_textarea::CursorMove::Down);
3128            ta.move_cursor(ratatui_textarea::CursorMove::End);
3129        }
3130        editor.indent_lines(false);
3131        let lines: Vec<String> = get_ta(&mut editor).lines().to_vec();
3132        assert_eq!(lines[0].trim_start(), "foo");
3133        assert_eq!(lines[1].trim_start(), "bar");
3134        assert_eq!(lines[2], "baz");
3135        assert!(lines[0].len() > 3);
3136        assert!(lines[1].len() > 3);
3137    }
3138
3139    #[test]
3140    fn dedent_removes_leading_indent() {
3141        let mut editor = make_editor();
3142        editor.set_text("    foo\n  bar\nbaz".to_string());
3143        let tab_len = get_ta(&mut editor).tab_length() as usize;
3144        {
3145            let ta = get_ta(&mut editor);
3146            ta.move_cursor(ratatui_textarea::CursorMove::Top);
3147            ta.start_selection();
3148            ta.move_cursor(ratatui_textarea::CursorMove::Bottom);
3149            ta.move_cursor(ratatui_textarea::CursorMove::End);
3150        }
3151        editor.indent_lines(true);
3152        let lines: Vec<String> = get_ta(&mut editor).lines().to_vec();
3153        // line 0 had 4 leading spaces; up to tab_len removed.
3154        assert_eq!(lines[0], format!("{}foo", " ".repeat(4 - tab_len.min(4))));
3155        // line 1 had 2 leading spaces; up to min(2, tab_len) removed.
3156        assert_eq!(
3157            lines[1],
3158            format!("{}bar", " ".repeat(2usize.saturating_sub(tab_len)))
3159        );
3160        assert_eq!(lines[2], "baz");
3161    }
3162
3163    #[test]
3164    fn dedent_no_leading_whitespace_is_noop_for_that_line() {
3165        let mut editor = make_editor();
3166        editor.set_text("foo".to_string());
3167        editor.indent_lines(true);
3168        assert_eq!(editor.get_text(), "foo");
3169    }
3170
3171    #[test]
3172    fn smart_enter_continues_unordered_list() {
3173        let mut editor = make_editor();
3174        editor.set_text("- foo".to_string());
3175        {
3176            let ta = get_ta(&mut editor);
3177            ta.move_cursor(ratatui_textarea::CursorMove::End);
3178        }
3179        assert!(editor.smart_enter());
3180        assert_eq!(editor.get_text(), "- foo\n- ");
3181    }
3182
3183    #[test]
3184    fn smart_enter_continues_ordered_list_increments() {
3185        let mut editor = make_editor();
3186        editor.set_text("1. foo".to_string());
3187        {
3188            let ta = get_ta(&mut editor);
3189            ta.move_cursor(ratatui_textarea::CursorMove::End);
3190        }
3191        assert!(editor.smart_enter());
3192        assert_eq!(editor.get_text(), "1. foo\n2. ");
3193    }
3194
3195    #[test]
3196    fn smart_enter_on_empty_list_marker_clears_line() {
3197        let mut editor = make_editor();
3198        editor.set_text("- ".to_string());
3199        {
3200            let ta = get_ta(&mut editor);
3201            ta.move_cursor(ratatui_textarea::CursorMove::End);
3202        }
3203        assert!(editor.smart_enter());
3204        assert_eq!(editor.get_text(), "");
3205    }
3206
3207    #[test]
3208    fn smart_enter_preserves_indent() {
3209        let mut editor = make_editor();
3210        editor.set_text("    body".to_string());
3211        {
3212            let ta = get_ta(&mut editor);
3213            ta.move_cursor(ratatui_textarea::CursorMove::End);
3214        }
3215        assert!(editor.smart_enter());
3216        assert_eq!(editor.get_text(), "    body\n    ");
3217    }
3218
3219    #[test]
3220    fn smart_enter_on_empty_indent_dedents() {
3221        let mut editor = make_editor();
3222        editor.set_text("    ".to_string());
3223        {
3224            let ta = get_ta(&mut editor);
3225            ta.move_cursor(ratatui_textarea::CursorMove::End);
3226        }
3227        let tab_len = get_ta(&mut editor).tab_length() as usize;
3228        assert!(editor.smart_enter());
3229        assert_eq!(
3230            editor.get_text(),
3231            " ".repeat(4usize.saturating_sub(tab_len))
3232        );
3233    }
3234
3235    #[test]
3236    fn smart_enter_no_indent_no_marker_returns_false() {
3237        let mut editor = make_editor();
3238        editor.set_text("plain".to_string());
3239        {
3240            let ta = get_ta(&mut editor);
3241            ta.move_cursor(ratatui_textarea::CursorMove::End);
3242        }
3243        assert!(!editor.smart_enter());
3244        assert_eq!(editor.get_text(), "plain");
3245    }
3246
3247    #[test]
3248    fn smart_enter_mid_line_returns_false() {
3249        let mut editor = make_editor();
3250        editor.set_text("- foo".to_string());
3251        {
3252            let ta = get_ta(&mut editor);
3253            ta.move_cursor(ratatui_textarea::CursorMove::Head);
3254            ta.move_cursor(ratatui_textarea::CursorMove::Forward);
3255            ta.move_cursor(ratatui_textarea::CursorMove::Forward);
3256        }
3257        assert!(!editor.smart_enter());
3258    }
3259
3260    #[test]
3261    fn smart_enter_on_empty_indented_list_marker_dedents_keeping_marker() {
3262        let mut editor = make_editor();
3263        let tab_len = get_ta(&mut editor).tab_length() as usize;
3264        let indent = " ".repeat(tab_len);
3265        editor.set_text(format!("{indent}- "));
3266        {
3267            let ta = get_ta(&mut editor);
3268            ta.move_cursor(ratatui_textarea::CursorMove::End);
3269        }
3270        assert!(editor.smart_enter());
3271        assert_eq!(editor.get_text(), "- ");
3272    }
3273
3274    #[test]
3275    fn smart_enter_on_empty_list_marker_clears_line_after_full_dedent() {
3276        let mut editor = make_editor();
3277        let tab_len = get_ta(&mut editor).tab_length() as usize;
3278        let indent = " ".repeat(tab_len);
3279        editor.set_text(format!("{indent}- "));
3280        {
3281            let ta = get_ta(&mut editor);
3282            ta.move_cursor(ratatui_textarea::CursorMove::End);
3283        }
3284        // First Enter: dedent to "- ".
3285        assert!(editor.smart_enter());
3286        assert_eq!(editor.get_text(), "- ");
3287        // Second Enter at column == end-of-line: now cursor is at col 2 (end of "- ").
3288        // Need to position cursor at end after the dedent.
3289        {
3290            let ta = get_ta(&mut editor);
3291            ta.move_cursor(ratatui_textarea::CursorMove::End);
3292        }
3293        assert!(editor.smart_enter());
3294        assert_eq!(editor.get_text(), "");
3295    }
3296
3297    #[test]
3298    fn smart_enter_continues_list_with_non_ascii_content() {
3299        let mut editor = make_editor();
3300        editor.set_text("- 你好".to_string());
3301        {
3302            let ta = get_ta(&mut editor);
3303            ta.move_cursor(ratatui_textarea::CursorMove::End);
3304        }
3305        assert!(editor.smart_enter());
3306        assert_eq!(editor.get_text(), "- 你好\n- ");
3307    }
3308
3309    #[test]
3310    fn smart_enter_preserves_tab_indent() {
3311        let mut editor = make_editor();
3312        editor.set_text("\tbody".to_string());
3313        {
3314            let ta = get_ta(&mut editor);
3315            ta.move_cursor(ratatui_textarea::CursorMove::End);
3316        }
3317        assert!(editor.smart_enter());
3318        assert_eq!(editor.get_text(), "\tbody\n\t");
3319    }
3320
3321    #[test]
3322    fn smart_enter_on_tab_only_line_dedents() {
3323        let mut editor = make_editor();
3324        editor.set_text("\t\t".to_string());
3325        {
3326            let ta = get_ta(&mut editor);
3327            ta.move_cursor(ratatui_textarea::CursorMove::End);
3328        }
3329        assert!(editor.smart_enter());
3330        // tab counts as one indent unit, regardless of tab_length spaces.
3331        assert_eq!(editor.get_text(), "\t");
3332    }
3333
3334    #[test]
3335    fn smart_enter_continues_indented_list() {
3336        let mut editor = make_editor();
3337        editor.set_text("  - foo".to_string());
3338        {
3339            let ta = get_ta(&mut editor);
3340            ta.move_cursor(ratatui_textarea::CursorMove::End);
3341        }
3342        assert!(editor.smart_enter());
3343        assert_eq!(editor.get_text(), "  - foo\n  - ");
3344    }
3345
3346    #[test]
3347    fn unsupported_text_action_is_noop() {
3348        let mut editor = make_editor();
3349        editor.set_text("hello".to_string());
3350        editor.apply_text_action(TextAction::Underline);
3351        assert_eq!(editor.get_text(), "hello");
3352    }
3353
3354    #[test]
3355    fn textarea_hint_shortcuts_has_no_mode_indicator() {
3356        let editor = make_editor();
3357        let hints = editor.hint_shortcuts();
3358        // None of the hint labels should be "NORMAL", "INSERT", etc.
3359        assert!(
3360            !hints
3361                .iter()
3362                .any(|(_, label)| label == "NORMAL" || label == "INSERT")
3363        );
3364    }
3365
3366    // ── link_at_cursor: label detection ──────────────────────────────────────
3367
3368    /// Helper: place cursor at a specific column on the first row.
3369    fn place_cursor_at_col(editor: &mut TextEditorComponent, col: usize) {
3370        let ta = get_ta(editor);
3371        ta.move_cursor(ratatui_textarea::CursorMove::Head);
3372        for _ in 0..col {
3373            ta.move_cursor(ratatui_textarea::CursorMove::Forward);
3374        }
3375    }
3376
3377    #[test]
3378    fn link_at_cursor_returns_label_when_cursor_on_hashtag() {
3379        let mut editor = make_editor();
3380        editor.set_text("see #rust now".to_string());
3381        // "#rust" starts at col 4, ends at col 9 (5 chars). Place cursor at col 5 (inside).
3382        place_cursor_at_col(&mut editor, 5);
3383        assert_eq!(
3384            editor.link_at_cursor(),
3385            Some(LinkTarget::Label("rust".into())),
3386        );
3387    }
3388
3389    #[test]
3390    fn link_at_cursor_returns_label_at_hash_char() {
3391        let mut editor = make_editor();
3392        editor.set_text("see #rust now".to_string());
3393        // Cursor exactly on '#' (col 4).
3394        place_cursor_at_col(&mut editor, 4);
3395        assert_eq!(
3396            editor.link_at_cursor(),
3397            Some(LinkTarget::Label("rust".into())),
3398        );
3399    }
3400
3401    #[test]
3402    fn link_at_cursor_returns_none_outside_hashtag() {
3403        let mut editor = make_editor();
3404        editor.set_text("see #rust now".to_string());
3405        // Cursor at col 0 ("s") — not on a hashtag.
3406        place_cursor_at_col(&mut editor, 0);
3407        assert_eq!(editor.link_at_cursor(), None);
3408    }
3409
3410    #[test]
3411    fn link_at_cursor_returns_note_for_wikilink() {
3412        let mut editor = make_editor();
3413        editor.set_text("open [[my note]] please".to_string());
3414        // "my note" is inside [[…]]; cursor at col 7 (inside link text).
3415        place_cursor_at_col(&mut editor, 7);
3416        let result = editor.link_at_cursor();
3417        assert!(
3418            matches!(result, Some(LinkTarget::Note(_))),
3419            "expected Note variant, got {result:?}"
3420        );
3421    }
3422
3423    // ── F5: link_at_cursor prioritises Link over Label ────────────────────────
3424
3425    #[test]
3426    fn link_at_cursor_returns_note_for_markdown_link_with_fragment() {
3427        // "[see docs](#section)" — cursor on `#section` should return Note, not Label.
3428        // After F3, the Label inside a link is never emitted, so the bug is
3429        // structurally prevented. This test guards F5: even if a future edit
3430        // accidentally adds a Label, Link wins because link_char_spans is checked first.
3431        let line = "[see docs](#section)";
3432        let mut editor = make_editor();
3433        editor.set_text(line.to_string());
3434        // "#section" starts at byte/char offset 11 (after "[see docs](").
3435        let cursor = "[see docs](#sec".chars().count(); // col 15, inside #section
3436        place_cursor_at_col(&mut editor, cursor);
3437        let result = editor.link_at_cursor();
3438        assert!(
3439            matches!(result, Some(LinkTarget::Note(_))),
3440            "expected Note variant for markdown link fragment, got {result:?}"
3441        );
3442    }
3443
3444    #[test]
3445    fn vim_normal_i_then_typing_inserts_text() {
3446        let mut settings = crate::settings::AppSettings::default();
3447        settings.editor_backend = crate::settings::EditorBackendSetting::Vim;
3448        let mut editor = TextEditorComponent::new(KeyBindings::empty(), &settings);
3449        let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
3450        // In Normal mode, 'x' is unmapped → no text change.
3451        editor.handle_input(
3452            &InputEvent::Key(key(KeyCode::Char('x'), KeyModifiers::NONE)),
3453            &tx,
3454        );
3455        assert_eq!(editor.get_text(), "");
3456        // 'i' enters Insert; then 'x' types a literal x via the direct path.
3457        editor.handle_input(
3458            &InputEvent::Key(key(KeyCode::Char('i'), KeyModifiers::NONE)),
3459            &tx,
3460        );
3461        editor.handle_input(
3462            &InputEvent::Key(key(KeyCode::Char('x'), KeyModifiers::NONE)),
3463            &tx,
3464        );
3465        assert_eq!(editor.get_text(), "x");
3466    }
3467
3468    /// Helper: construct a vim-backend editor.
3469    fn make_vim_editor() -> TextEditorComponent {
3470        let mut settings = crate::settings::AppSettings::default();
3471        settings.editor_backend = crate::settings::EditorBackendSetting::Vim;
3472        TextEditorComponent::new(KeyBindings::empty(), &settings)
3473    }
3474
3475    /// Helper: extract the current vim EditorMode, panicking if the backend
3476    /// is not a vim textarea (so test failures are obvious).
3477    fn vim_mode(editor: &TextEditorComponent) -> EditorMode {
3478        match &editor.backend {
3479            BackendState::Textarea(tb) => match &tb.input {
3480                backend::InputInterpreter::Vim(e) => e.mode().clone(),
3481                _ => panic!("expected Vim input interpreter"),
3482            },
3483            _ => panic!("expected Textarea backend"),
3484        }
3485    }
3486
3487    /// Regression: a bare left click (Down with no Drag) must NOT flip
3488    /// vim Normal → Visual.  The textarea's Down arm calls `start_selection()`
3489    /// which leaves a collapsed (start==end) selection; the fix at ~line 2124
3490    /// uses `.is_some_and(|(s, e)| s != e)` to require a non-empty selection
3491    /// before treating it as "real" (mirrors the same guard at ~line 1014).
3492    ///
3493    /// We test `vim_sync_mouse_selection` directly (the exact code that was
3494    /// broken) rather than routing through `handle_input` → `handle_mouse`,
3495    /// which needs a fully rendered view to resolve screen→logical coordinates.
3496    #[test]
3497    fn vim_sync_collapsed_sel_stays_normal() {
3498        let mut editor = make_vim_editor();
3499        editor.set_text("hello world".to_string());
3500
3501        // Sanity: starts in Normal.
3502        assert_eq!(vim_mode(&editor), EditorMode::Normal);
3503
3504        // A bare click leaves has_sel == false (collapsed selection filtered
3505        // out by the is_some_and guard).  Sync with no selection must keep Normal.
3506        editor.backend.vim_sync_mouse_selection(false);
3507        assert_eq!(
3508            vim_mode(&editor),
3509            EditorMode::Normal,
3510            "collapsed (bare click) selection must not enter Visual mode"
3511        );
3512    }
3513
3514    /// A drag that creates a real (non-empty) selection DOES enter Visual mode.
3515    #[test]
3516    fn vim_sync_real_sel_enters_visual() {
3517        let mut editor = make_vim_editor();
3518        editor.set_text("hello world".to_string());
3519
3520        // Sanity: starts in Normal.
3521        assert_eq!(vim_mode(&editor), EditorMode::Normal);
3522
3523        // A drag with start != end yields has_sel == true.
3524        editor.backend.vim_sync_mouse_selection(true);
3525        assert_eq!(
3526            vim_mode(&editor),
3527            EditorMode::Visual,
3528            "real drag selection must enter Visual mode"
3529        );
3530    }
3531
3532    /// Regression: with the find bar open in vim Normal mode, typed keys must
3533    /// go into the find query, NOT be processed by the vim engine (which would
3534    /// treat 'l'/'o' as motions and move the cursor).
3535    #[test]
3536    fn vim_find_bar_captures_typing_not_cursor() {
3537        let mut editor = make_vim_editor();
3538        editor.set_text("hello world\nsecond line".to_string());
3539        let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
3540
3541        // Open the find bar (same path as the '/' key: OpenSearch → open_or_advance_search).
3542        editor.open_or_advance_search();
3543        assert!(editor.search.is_some(), "find bar must be open");
3544
3545        // Type "lo" — should go into the find query, not be processed as vim motions.
3546        editor.handle_input(
3547            &InputEvent::Key(key(KeyCode::Char('l'), KeyModifiers::NONE)),
3548            &tx,
3549        );
3550        editor.handle_input(
3551            &InputEvent::Key(key(KeyCode::Char('o'), KeyModifiers::NONE)),
3552            &tx,
3553        );
3554
3555        // Find query must capture "lo". This proves keys went to the find bar
3556        // and not the vim engine (which would treat 'l' as a rightward motion
3557        // and 'o' as Open-line-below, mutating the buffer).
3558        let q = editor
3559            .search
3560            .as_ref()
3561            .map(|s| s.input.value().to_string())
3562            .unwrap_or_default();
3563        assert_eq!(q, "lo", "find query must capture typed characters");
3564
3565        // Buffer must be unchanged — 'o' in vim Normal mode inserts a new line,
3566        // so a mutated buffer means the key escaped to the vim engine.
3567        assert_eq!(
3568            editor.get_text(),
3569            "hello world\nsecond line",
3570            "buffer must not be modified while find bar is open"
3571        );
3572
3573        // The cursor is allowed to move to the first search match (that is
3574        // correct search behaviour — refresh_search_pattern jumps to the hit).
3575        // What must NOT happen is a vim motion: 'l' in Normal mode would leave
3576        // the cursor at col 1 with no query update; here it must be at the
3577        // "lo" match col instead (3 — the second 'l' in "hello").
3578        assert_eq!(
3579            editor.cursor_pos().1,
3580            3,
3581            "cursor must jump to the search match (col 3), not to a vim motion position"
3582        );
3583    }
3584
3585    /// Vim `/pattern` then Enter confirms the search: the find bar closes, the
3586    /// cursor stays on the first match, and `n` / `N` navigate subsequent matches.
3587    #[test]
3588    fn vim_search_enter_confirms_and_n_navigates() {
3589        let mut editor = make_vim_editor();
3590        // Three "lo" at cols 0, 6, 12 on a single line.
3591        editor.set_text("lo xx lo yy lo".to_string());
3592        let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
3593
3594        // Open the find bar (same path as the '/' key: OpenSearch → open_or_advance_search).
3595        editor.open_or_advance_search();
3596        assert!(editor.search.is_some(), "find bar must open");
3597
3598        // Type "lo" — keys go into the find query (incremental search).
3599        editor.handle_input(
3600            &InputEvent::Key(key(KeyCode::Char('l'), KeyModifiers::NONE)),
3601            &tx,
3602        );
3603        editor.handle_input(
3604            &InputEvent::Key(key(KeyCode::Char('o'), KeyModifiers::NONE)),
3605            &tx,
3606        );
3607
3608        // Enter confirms in vim mode: closes the bar, cursor stays on match.
3609        editor.handle_input(
3610            &InputEvent::Key(key(KeyCode::Enter, KeyModifiers::NONE)),
3611            &tx,
3612        );
3613        assert!(
3614            editor.search.is_none(),
3615            "find bar must close after Enter in vim mode"
3616        );
3617
3618        // After confirming, 'n' must navigate to the NEXT match, not type into
3619        // the (now-closed) find bar. Incremental search left the cursor at the
3620        // first "lo" (col 0); 'n' should jump to the second one (col 6).
3621        editor.handle_input(
3622            &InputEvent::Key(key(KeyCode::Char('n'), KeyModifiers::NONE)),
3623            &tx,
3624        );
3625        let (_, c1) = editor.cursor_pos();
3626        assert_eq!(c1, 6, "'n' must jump to the 2nd 'lo' at col 6");
3627
3628        editor.handle_input(
3629            &InputEvent::Key(key(KeyCode::Char('n'), KeyModifiers::NONE)),
3630            &tx,
3631        );
3632        let (_, c2) = editor.cursor_pos();
3633        assert_eq!(c2, 12, "'n' must jump to the 3rd 'lo' at col 12");
3634
3635        // The buffer must never have been modified.
3636        assert_eq!(editor.get_text(), "lo xx lo yy lo");
3637    }
3638}