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