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