Skip to main content

kimun_notes/components/text_editor/
mod.rs

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