Skip to main content

kimun_notes/components/text_editor/
mod.rs

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