Skip to main content

kimun_notes/components/text_editor/
mod.rs

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