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