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