Skip to main content

kimun_notes/components/text_editor/
mod.rs

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