Skip to main content

kimun_notes/components/text_editor/
mod.rs

1pub mod autocomplete_glue;
2pub mod backend;
3pub mod find_bar;
4pub mod find_replace;
5pub mod markdown;
6pub mod markdown_edits;
7pub mod nvim_decode;
8pub mod nvim_host;
9pub mod nvim_rpc;
10pub mod parse_incremental;
11pub mod plain_keys;
12mod revisions;
13pub mod rope_buffer;
14pub mod typing_run;
15use revisions::Revisions;
16pub mod snapshot;
17pub mod text_coords;
18pub mod view;
19mod vim;
20mod vim_objects;
21pub mod widener_metrics;
22
23use self::rope_buffer::CursorMove;
24use ratatui::Frame;
25use ratatui::crossterm::event::{KeyCode, KeyModifiers, MouseButton, MouseEventKind};
26use ratatui::layout::Rect;
27use ratatui::style::{Modifier, Style};
28use std::num::NonZeroU64;
29
30/// Build an `EditorSnapshot` from the editor's backend + content
31/// revision. Free function (not a method on `TextEditorComponent`) so
32/// production callers that need to mutate other fields of
33/// `TextEditorComponent` afterwards can pass `&self.backend` and
34/// `self.revs.current()` directly — the borrow checker can split
35/// borrows across distinct fields but not across method calls.
36fn snapshot_from_backend(backend: &BackendState, content_revision: NonZeroU64) -> EditorSnapshot {
37    match backend {
38        BackendState::Textarea(tb) => {
39            let cursor = tb.ta.cursor();
40            EditorSnapshot::of_buffer(tb.ta.text().clone(), cursor, content_revision)
41        }
42        BackendState::Nvim(nvim) => {
43            let snap = nvim.snapshot();
44            let lines_len = snap.lines.len();
45            let cursor_row = if lines_len == 0 {
46                0
47            } else {
48                snap.cursor.0.min(lines_len - 1)
49            };
50            let cursor = (cursor_row, snap.cursor.1);
51            let lines = snap.lines.clone();
52            let rev = Revisions::rev_from_gen(snap.content_gen);
53            drop(snap);
54            EditorSnapshot::owned(lines, cursor, rev)
55        }
56    }
57}
58
59/// Identity for a **replace preview**'s snapshot: the real content revision
60/// folded together with the previewed text.
61///
62/// The view gates parse-cache rebuilds on `content_revision`, so a preview must
63/// not reuse the buffer's — it would show a parse of text that is not on
64/// screen. Deriving it from the previewed lines means an unchanged preview
65/// keeps its cache entry across frames, and any change to the pattern, the
66/// replacement, or the buffer produces a new one.
67fn preview_revision(base: NonZeroU64, lines: &[String]) -> NonZeroU64 {
68    use std::collections::hash_map::DefaultHasher;
69    use std::hash::{Hash, Hasher};
70    let mut h = DefaultHasher::new();
71    base.get().hash(&mut h);
72    lines.hash(&mut h);
73    NonZeroU64::new(h.finish()).unwrap_or(NonZeroU64::MIN)
74}
75
76/// Returns true if any autocomplete trigger char (`[` for `[[wikilink`,
77/// `#` for `#hashtag`) appears between the start of `line` and the
78/// cursor's char column. Walks backwards from the cursor so the common
79/// "user just typed inside a trigger" case short-circuits quickly. The
80/// scan stays within one row because triggers can't cross a newline.
81///
82/// UTF-8 safe: takes a char column and never slices on a byte that is
83/// not a codepoint boundary. Wikilinks can contain spaces
84/// (`[[my note title`), so the walk does NOT stop at whitespace — only
85/// the trigger char or start-of-row halts it.
86fn has_trigger_before_cursor(line: &str, col: usize) -> bool {
87    let cursor_byte = line
88        .char_indices()
89        .nth(col)
90        .map(|(b, _)| b)
91        .unwrap_or(line.len());
92    line[..cursor_byte]
93        .chars()
94        .rev()
95        .any(|c| c == '[' || c == '#')
96}
97
98use self::backend::BackendState;
99#[cfg(test)]
100use self::find_bar::{BarFocus, SearchStatus};
101use self::markdown::ParsedBuffer;
102use self::nvim_host::NvimHost;
103use self::rope_buffer::RopeBuffer;
104use self::snapshot::EditorSnapshot;
105use self::view::MarkdownEditorView;
106use crate::util::single_slot_task::SingleSlotTask;
107
108/// Convert a 0-based character column into a byte offset within `line`.
109/// Out-of-range columns return `line.len()`.
110pub(super) fn char_col_to_byte(line: &str, char_col: usize) -> usize {
111    line.char_indices()
112        .nth(char_col)
113        .map(|(b, _)| b)
114        .unwrap_or(line.len())
115}
116
117/// Owned RGBA image data lifted from the system clipboard. Returned by
118/// [`TextEditorComponent::take_clipboard_image`] so the screen layer can
119/// encode + persist without holding the editor's clipboard borrow.
120#[derive(Debug, Clone)]
121pub struct ClipboardImage {
122    pub width: usize,
123    pub height: usize,
124    pub rgba: Vec<u8>,
125}
126
127/// Schemes the paste-over-selection flow recognises as "linkable" — broader
128/// than `core::note::scan::is_remote_url` (http/https only) because users routinely paste
129/// `mailto:` and FTP links and expect them wrapped as markdown links too.
130const LINKABLE_PASTE_SCHEMES: &[&str] = &["http", "https", "ftp", "ftps", "mailto"];
131
132/// Visual rows one mouse-wheel notch scrolls the editor.
133const WHEEL_SCROLL_ROWS: isize = 1;
134
135fn linkable_url(s: &str) -> Option<&str> {
136    kimun_core::note::scan::url_with_allowed_scheme(s, LINKABLE_PASTE_SCHEMES)
137}
138
139/// If `clip` is a linkable URL and `selection` is non-empty, returns
140/// `Some("[escaped_selection](url)")`. Otherwise returns `None`, signalling the
141/// caller to insert `clip` verbatim.
142fn try_build_markdown_link(clip: &str, selection: Option<&str>) -> Option<String> {
143    let url = linkable_url(clip)?;
144    let sel = selection.filter(|s| !s.is_empty())?;
145    let escaped = sel.replace('\\', r"\\").replace(']', r"\]");
146    Some(format!("[{escaped}]({url})"))
147}
148
149use std::sync::Arc;
150
151use kimun_core::NoteVault;
152
153use crate::components::Component;
154use crate::components::autocomplete::{
155    self, AutocompleteController, AutocompleteHost, AutocompleteMode, HandleKeyOutcome,
156};
157use crate::components::event_state::EventState;
158use crate::components::events::AppEvent;
159use crate::components::events::AppTx;
160use crate::components::events::InputEvent;
161use crate::components::events::redraw_callback;
162use crate::components::text_editor::autocomplete_glue::apply_accept_to_textarea;
163use crate::keys::KeyBindings;
164use crate::keys::action_shortcuts::TextAction;
165use crate::settings::AppSettings;
166use crate::settings::themes::Theme;
167
168/// What following resolves to — the **follow target** under the cursor.
169///
170/// Named for the action rather than the destination, because the destination is
171/// not known here: a `Link` is still the raw string as written in the note, and
172/// only `EditorScreen::follow_link` decides whether it names a vault note, an
173/// external URL, or an attachment. Wider than a **note link**, which is
174/// note→note only.
175#[derive(Debug, Clone, PartialEq)]
176pub enum FollowTarget {
177    /// A wiki-link or markdown link, with the raw target string.
178    Link(String),
179    /// A hashtag label with the name **without** the leading `#`.
180    Label(String),
181}
182
183/// Which editor-internal surface currently holds input — the **editor claim**.
184///
185/// Read into the `Intent` classifier's snapshot so ownership is decided once,
186/// there, instead of being re-asserted per event kind further down. The holder
187/// is named rather than merely counted because what a claim blocks differs by
188/// holder: the find bar blocks a paste, a click and a bare Space; the popup
189/// wants all three.
190#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
191pub enum EditorClaim {
192    #[default]
193    None,
194    FindBar,
195    Autocomplete,
196}
197
198/// Snapshot used to satisfy `AutocompleteHost`. Wraps an
199/// `EditorSnapshot` (Cow-borrowed from the textarea on the common
200/// path — perf #8) plus the cursor's last-rendered screen
201/// position. The host's `cache_key` mirrors the editor's
202/// `content_revision`; `None` is reserved for hosts whose buffer
203/// has no stable identity (the search-box modal).
204struct EditorHostSnapshot {
205    snap: EditorSnapshot,
206    cursor_screen: Option<(u16, u16)>,
207    cache_key: Option<NonZeroU64>,
208}
209
210impl AutocompleteHost for EditorHostSnapshot {
211    fn buffer_snapshot(&self) -> EditorSnapshot {
212        // Re-package the inner snap as a fresh view tied
213        // to `&self`. `Cow::as_ref` works for both Borrowed and
214        // Owned variants — the latter only occurs on the Nvim path
215        // where the inner snapshot already paid the clone cost.
216        EditorSnapshot::of_buffer(
217            self.snap.text.clone(),
218            self.snap.cursor,
219            self.snap.content_revision,
220        )
221    }
222    fn cache_key(&self) -> Option<NonZeroU64> {
223        self.cache_key
224    }
225    fn screen_anchor_for(&self, _byte_offset: usize) -> Option<(u16, u16)> {
226        // Anchor at the cursor's last-rendered screen position. The
227        // controller passes `anchor_col` (byte offset of the start of
228        // the typed query) but visually anchoring at the cursor is
229        // fine — the popup sits adjacent to the typed text either way
230        // and avoids re-walking the wrap layout for an arbitrary byte
231        // offset.
232        //
233        // When `cursor_screen` is None (no prior render — e.g. the
234        // user opens a note and types `[[` before the first frame),
235        // return a placeholder so the controller still opens the
236        // popup. The editor's render path skips drawing it until
237        // `view.last_cursor_screen` is available, then re-anchors and
238        // draws with the correct position.
239        Some(self.cursor_screen.unwrap_or((0, 0)))
240    }
241}
242
243/// Free-function builder for `EditorHostSnapshot`. Production
244/// callers pass `&self.backend`, `self.revs.current()`,
245/// `self.view.last_cursor_screen` directly so the borrow checker
246/// can split borrows from `&mut self.autocomplete`. Returns `None`
247/// on the Nvim backend (autocomplete is Textarea-only).
248fn build_editor_host_snapshot(
249    backend: &BackendState,
250    content_revision: NonZeroU64,
251    cursor_screen: Option<(u16, u16)>,
252) -> Option<EditorHostSnapshot> {
253    if !backend.is_textarea() {
254        return None;
255    }
256    Some(EditorHostSnapshot {
257        snap: snapshot_from_backend(backend, content_revision),
258        cursor_screen,
259        cache_key: Some(content_revision),
260    })
261}
262
263/// Snapshot of the textarea backend used to classify a key event as a
264/// text edit (text differs) vs. a pure cursor move (text same, cursor
265/// moved) vs. a no-op (both same).
266pub struct TextEditorComponent {
267    backend: BackendState,
268    /// The note on screen, set by [`Self::open_note`]. Whether the next text
269    /// is a *reload* of this note or *another* note is decided by this, never
270    /// by comparing texts: two notes can hold the same text (two empty ones,
271    /// two made from one template), and state that belongs to one — undo
272    /// history, selection, `gv` marks — must not leak into the other.
273    note: Option<kimun_core::nfs::VaultPath>,
274    /// Where the current mouse drag started, so each drag event rebuilds the
275    /// selection from it rather than from a selection the last event reshaped.
276    drag_origin: Option<(usize, usize)>,
277    /// Tracks the rendered rect to map mouse click coordinates.
278    rect: Rect,
279    key_bindings: KeyBindings,
280    view: MarkdownEditorView,
281    /// The one revision clock plus its comparison snapshots (saved,
282    /// needles) — see [`Revisions`]. `revs.current()` advances iff the
283    /// buffer text changes: `bump_content` on the textarea backend, the
284    /// per-frame `adopt` of the snapshot's revision on the nvim backend
285    /// (the snapshot derives it from the backend's `content_gen` under a
286    /// single lock — the only `content_gen → NonZeroU64` site). Cursor
287    /// moves never touch it, so an in-flight autosave's revision token
288    /// survives navigation, and `view.update` reuses its parse cache.
289    revs: Revisions,
290    /// Current selection range in logical (row, byte-col) coordinates.
291    /// Only tracked for the Textarea backend; always `None` for Nvim.
292    selection: Option<((usize, usize), (usize, usize))>,
293    /// Host-side state and policy for the Nvim backend (pending-Z intercept,
294    /// frame sync). See [`nvim_host`].
295    nvim_host: NvimHost,
296    /// Active Ctrl+F find bar; `None` when not searching.
297    search: Option<find_bar::FindBar>,
298    /// Wikilink/hashtag autocomplete. Only populated for the textarea
299    /// backend after `set_vault` is called; remains `None` for the Nvim
300    /// backend (nvim users have their own completion ecosystem).
301    autocomplete: Option<AutocompleteController>,
302    /// Vault handle stored at `set_vault` time. Kept even on the Nvim
303    /// backend so `maybe_recover_from_dead_nvim` can spin up the
304    /// autocomplete controller after the fallback to Textarea.
305    autocomplete_vault: Option<Arc<NoteVault>>,
306    /// Whether the autocomplete controller's redraw callback has been
307    /// bound to the app event bus. Bound lazily on the first
308    /// `handle_input` because `AppTx` is not available at
309    /// construction.
310    autocomplete_redraw_bound: bool,
311    /// Background full-parse fallback for large buffers (perf #9).
312    /// The view installs a placeholder `ParsedBuffer` and signals
313    /// pending; this slot owns the spawned tokio task that runs
314    /// the real `ParsedBuffer::parse`. `SingleSlotTask` aborts the
315    /// previous spawn on a fresh edit, so a burst of edits resolves
316    /// against the latest content.
317    full_parse_task: SingleSlotTask<()>,
318    /// Background full-wrap fallback for large buffers, the layout-side
319    /// twin of `full_parse_task`. The view installs a `Layout::unwrapped`
320    /// stub and signals pending; this slot owns the spawned tokio task
321    /// that runs the real `Layout::compute`. `SingleSlotTask` aborts the
322    /// previous spawn on a fresh edit, so a burst of edits resolves
323    /// against the latest content.
324    layout_task: SingleSlotTask<()>,
325    /// Whether the last key was handled while vim was in Insert. A change either
326    /// way ends the open **undo group**: leaving Insert closes vim's session,
327    /// entering it starts a fresh one.
328    last_insert_session: bool,
329    /// Set by a right-click with no selection: the host (which owns the note
330    /// path) opens the note's context menu and clears the flag.
331    pub wants_context_menu: bool,
332    /// Lowercased needles to emphasize in the rendered buffer — set when the
333    /// note was opened from a query result ("search match"), and
334    /// dropped on the first edit (`revs.needles_stale()`).
335    search_needles: Vec<String>,
336    full_parse_tx: tokio::sync::mpsc::UnboundedSender<(u64, ParsedBuffer)>,
337    full_parse_rx: tokio::sync::mpsc::UnboundedReceiver<(u64, ParsedBuffer)>,
338    layout_tx: tokio::sync::mpsc::UnboundedSender<(u64, crate::ropetext::Layout)>,
339    layout_rx: tokio::sync::mpsc::UnboundedReceiver<(u64, crate::ropetext::Layout)>,
340    /// `AppTx` clone bound the first time `handle_input` runs, so the
341    /// spawned full-parse/full-wrap tasks can post `AppEvent::Redraw` on
342    /// completion without waiting for the next user keystroke.
343    redraw_tx: Option<AppTx>,
344}
345
346impl TextEditorComponent {
347    pub fn new(key_bindings: KeyBindings, settings: &AppSettings) -> Self {
348        let (full_parse_tx, full_parse_rx) = tokio::sync::mpsc::unbounded_channel();
349        let (layout_tx, layout_rx) = tokio::sync::mpsc::unbounded_channel();
350        Self {
351            backend: BackendState::from_settings(
352                &settings.editor_backend,
353                settings.nvim_path.as_ref(),
354            ),
355            rect: Rect::default(),
356            key_bindings,
357            view: MarkdownEditorView::new(),
358            revs: Revisions::new(),
359            selection: None,
360            nvim_host: NvimHost::new(),
361            search: None,
362            note: None,
363            drag_origin: None,
364            autocomplete: None,
365            autocomplete_vault: None,
366            autocomplete_redraw_bound: false,
367            full_parse_task: SingleSlotTask::empty(),
368            layout_task: SingleSlotTask::empty(),
369            last_insert_session: false,
370            wants_context_menu: false,
371            search_needles: Vec::new(),
372            full_parse_tx,
373            full_parse_rx,
374            layout_tx,
375            layout_rx,
376            redraw_tx: None,
377        }
378    }
379
380    /// Attach a vault so autocomplete can query notes/tags. Activates
381    /// the controller immediately on the textarea backend; on Nvim, the
382    /// vault is stashed and the controller is spun up later if
383    /// `maybe_recover_from_dead_nvim` falls back to Textarea.
384    pub fn set_vault(&mut self, vault: Arc<NoteVault>) {
385        self.autocomplete_vault = Some(vault.clone());
386        if self.backend.is_textarea() {
387            self.autocomplete = Some(AutocompleteController::new(
388                std::sync::Arc::new(crate::components::search_list::VaultSuggestions { vault }),
389                AutocompleteMode::Both,
390            ));
391        }
392    }
393
394    /// Spin up the autocomplete controller if a vault was previously
395    /// stashed and the controller isn't already running. Called after
396    /// the Nvim → Textarea fallback so the post-crash session has the
397    /// popup available.
398    fn ensure_autocomplete_for_textarea(&mut self) {
399        if self.autocomplete.is_some() {
400            return;
401        }
402        if !self.backend.is_textarea() {
403            return;
404        }
405        let Some(vault) = self.autocomplete_vault.clone() else {
406            return;
407        };
408        self.autocomplete = Some(AutocompleteController::new(
409            std::sync::Arc::new(crate::components::search_list::VaultSuggestions { vault }),
410            AutocompleteMode::Both,
411        ));
412        // Fresh controller — `bind_autocomplete_redraw` must rebind
413        // on the next handle_input.
414        self.autocomplete_redraw_bound = false;
415    }
416
417    /// Build a snapshot view of the editor state for the autocomplete
418    /// controller. Method form wraps `build_editor_host_snapshot` for
419    /// callers that do not need to split borrows; production hot
420    /// paths (`refresh_autocomplete_if_open`, `sync_autocomplete`)
421    /// inline the free function instead so `&self.backend` and
422    /// `&mut self.autocomplete` can coexist.
423    #[allow(dead_code)]
424    fn autocomplete_host_snapshot(&self) -> Option<EditorHostSnapshot> {
425        build_editor_host_snapshot(
426            &self.backend,
427            self.revs.current(),
428            self.view.last_cursor_screen,
429        )
430    }
431
432    /// Pull the latest async query results into the popup state. Called
433    /// once per render before drawing the overlay.
434    fn poll_autocomplete(&mut self) {
435        if let Some(controller) = self.autocomplete.as_mut() {
436            controller.poll_results();
437        }
438    }
439
440    /// Cheap cursor read — `None` for the Nvim backend. Used by `handle_input`
441    /// to diff cursor position across a key event without materialising the
442    /// whole buffer.
443    fn textarea_cursor(&self) -> Option<(usize, usize)> {
444        let ta = self.backend.as_textarea()?;
445        Some(ta.cursor())
446    }
447
448    fn refresh_autocomplete_if_open(&mut self) {
449        // No controller (e.g. Nvim backend) or popup closed → nothing to refresh.
450        if !self.autocomplete.as_ref().is_some_and(|c| c.is_open()) {
451            return;
452        }
453        // Inline the snapshot via the free function so `&self.backend`
454        // (the snapshot's borrow source) and `&mut self.autocomplete`
455        // (the controller below) can coexist via field-disjoint borrows.
456        let Some(snapshot) = build_editor_host_snapshot(
457            &self.backend,
458            self.revs.current(),
459            self.view.last_cursor_screen,
460        ) else {
461            self.close_autocomplete();
462            return;
463        };
464        if let Some(controller) = self.autocomplete.as_mut() {
465            controller.refresh_if_open(&snapshot);
466        }
467    }
468
469    /// Recompute the popup's trigger context from the current buffer and
470    /// cursor. Call after any mutating key handle (typed letter, paste,
471    /// backspace, cursor movement, etc.).
472    fn sync_autocomplete(&mut self) {
473        let Some(controller) = self.autocomplete.as_ref() else {
474            return; // Nvim backend or no controller
475        };
476
477        // Fast-path bail: when the popup is closed AND no trigger character
478        // appears between the cursor and the start of the current row, no
479        // reconcile can open a popup. Skip the expensive buffer snapshot +
480        // pulldown-cmark scan.
481        //
482        // Trigger chars: `[` (for `[[wikilink`) and `#` (for `#hashtag`).
483        // Wikilinks can contain spaces (`[[my note title`), so the scan
484        // walks back to the start of the row, not to the nearest whitespace.
485        // The walk short-circuits on the first trigger char, so for typical
486        // lines it touches only a handful of chars before bailing or
487        // promoting to the slow path. Using `char_indices().rev()` keeps
488        // the walk UTF-8-safe — never slices mid-codepoint.
489        if !controller.is_open() {
490            let Some(ta) = self.backend.as_textarea() else {
491                return;
492            };
493            let (row, col) = ta.cursor();
494            let line = ta.row(row).unwrap_or_default();
495            if !has_trigger_before_cursor(&line, col) {
496                return;
497            }
498        }
499
500        // Slow path: build the borrowed snapshot for the controller to
501        // reconcile. Free function so `&self.backend` and
502        // `&mut self.autocomplete` can coexist.
503        let Some(snapshot) = build_editor_host_snapshot(
504            &self.backend,
505            self.revs.current(),
506            self.view.last_cursor_screen,
507        ) else {
508            if let Some(c) = self.autocomplete.as_mut() {
509                c.close();
510            }
511            return;
512        };
513        if let Some(controller) = self.autocomplete.as_mut() {
514            controller.sync(&snapshot);
515        }
516    }
517
518    /// Returns the buffer lines for direct access.
519    ///
520    /// For the Textarea backend, returns the live lines.
521    /// For the Nvim backend, returns an empty slice — use `get_text()` instead,
522    /// which reads from the snapshot.
523    /// The open note's text. Empty for the nvim backend, which owns its own.
524    pub fn text(&self) -> crate::ropetext::Text {
525        match &self.backend {
526            BackendState::Textarea(tb) => tb.ta.text().clone(),
527            BackendState::Nvim(_) => crate::ropetext::Text::new(),
528        }
529    }
530
531    /// Single producer for the editor's atomic `(lines, cursor,
532    /// content_revision)` view. Downstream consumers (`MarkdownEditorView`,
533    /// `click_to_logical_u16`, the autocomplete host) take a
534    /// `&EditorSnapshot` and stop guarding against drift between cursor
535    /// and lines on every leaf access — the snapshot owns that
536    /// invariant at construction time.
537    ///
538    /// On the Textarea backend the snapshot borrows live lines (no
539    /// clone) and the cursor is already in-bounds. On the Nvim backend
540    /// the lines are cloned out from behind the `Mutex` (same cost as
541    /// today's render path) and the cursor row is clamped to
542    /// `lines.len() - 1` before the snapshot is returned.
543    ///
544    /// Production hot paths that also need `&mut self.view` (notably
545    /// `render`) must instead inline the snapshot via
546    /// `snapshot_from_backend(&self.backend, self.revs.current())`
547    /// so the borrow checker can split the borrows across distinct
548    /// fields.
549    pub fn view_snapshot(&self) -> EditorSnapshot {
550        snapshot_from_backend(&self.backend, self.revs.current())
551    }
552
553    /// The cursor's (row, col) without materialising a snapshot — the Nvim
554    /// path of `view_snapshot` clones every buffer line, far too heavy for
555    /// per-frame consumers that only want the position (status-bar ln/col).
556    pub fn cursor_pos(&self) -> (usize, usize) {
557        self.backend.cursor()
558    }
559
560    /// Set the search needles to emphasize in the rendered buffer (the note
561    /// was opened from a query result). Cleared automatically on the first
562    /// edit.
563    pub fn set_search_needles(&mut self, needles: Vec<String>) {
564        self.search_needles = needles
565            .into_iter()
566            .map(|n| n.to_lowercase())
567            .filter(|n| !n.is_empty())
568            .collect();
569        self.revs.arm_needles();
570    }
571
572    /// Show `note` with `text`.
573    ///
574    /// The note already on screen is reloaded ([`Self::set_text`]): unchanged
575    /// text keeps everything — history, selection, scroll, `gv`. Any other
576    /// note is loaded from scratch even when its text is identical, so nothing
577    /// of the previous note carries over.
578    pub fn open_note(&mut self, note: kimun_core::nfs::VaultPath, text: String) {
579        if self.note.as_ref().is_some_and(|open| open.is_like(&note)) {
580            self.set_text(text);
581            return;
582        }
583        self.note = Some(note);
584        self.load(text);
585    }
586
587    /// The note on screen was renamed to `note`: still the same note, so
588    /// nothing is reset, and a later [`Self::open_note`] under the new name is
589    /// a reload.
590    pub fn renamed_to(&mut self, note: kimun_core::nfs::VaultPath) {
591        self.note = Some(note);
592    }
593
594    /// Reload the note on screen with `text`.
595    ///
596    /// A no-op when the buffer would be identical — preserves view scroll,
597    /// selection, edit generation cache, and an open autocomplete popup.
598    /// Saves the expensive lines clone too. Still normalises the saved
599    /// marker: if the buffer was flagged dirty by a previous divergent
600    /// save, reloading the same content from disk should clear that
601    /// flag rather than persist a phantom `[+]` in the title bar.
602    ///
603    /// That shortcut is only right for the *same* note; switching notes goes
604    /// through [`Self::open_note`].
605    pub fn set_text(&mut self, text: String) {
606        if text == self.get_text() {
607            self.revs.mark_saved_current();
608            if let Some(nvim) = self.backend.as_nvim() {
609                nvim.mark_clean();
610            }
611            return;
612        }
613        self.load(text);
614    }
615
616    /// Replace the buffer with `text` and drop everything that described the
617    /// old one: history, selection, input state, popups.
618    fn load(&mut self, text: String) {
619        // A drag under way belonged to the old text.
620        self.drag_origin = None;
621        match &mut self.backend {
622            BackendState::Textarea(tb) => {
623                tb.ta.replace(crate::ropetext::Text::from(text.as_str()));
624            }
625            BackendState::Nvim(nvim) => {
626                nvim.set_text(&text);
627            }
628        }
629        self.backend.reset_input_state();
630        self.bump_content();
631        let reconstructed = self.get_text();
632        self.mark_saved(reconstructed);
633        // Buffer replaced — close any open autocomplete popup so it does
634        // not linger over the new note (e.g. after Ctrl+G follow-link).
635        self.close_autocomplete();
636        // Everything below described the OLD buffer. A textarea swap installs
637        // a fresh, empty history, so recorded **undo groups** now point at
638        // states this history cannot reach — and a group whose `after` is an
639        // empty buffer would hash-match any empty note, popping an extra entry
640        // against unrelated history. The find bar is worse: `armed_empty`
641        // surviving a note swap means one Ctrl+A deletes every match in a note
642        // the user never armed, skipping the confirmation the flag exists to
643        // force. `self.selection` would likewise still describe the old text.
644        self.search = None;
645        self.selection = None;
646        // The whole buffer was replaced. The cursor resets to the top, so if
647        // the line count happens to match and row 0 differs, the damage fast
648        // path would report `0..1` and leave the rest of the note parsed as the
649        // previous one.
650        self.view.note_bulk_edit();
651    }
652
653    pub fn get_text(&self) -> String {
654        self.backend.text()
655    }
656
657    /// Current content revision. Bumped on every text-mutating handler;
658    /// stable across cursor moves and idle frames. Used by the autosave
659    /// path to record "this snapshot was saved" without rebuilding the
660    /// buffer text on completion. `NonZeroU64` makes 0 unrepresentable
661    /// so callers can express "no revision" as `Option<NonZeroU64>::None`
662    /// without a magic-value sentinel.
663    pub fn content_revision(&self) -> NonZeroU64 {
664        self.revs.current()
665    }
666
667    /// Mark the buffer as clean iff its current revision still matches
668    /// `rev` (i.e. no edits landed between the save being issued and
669    /// completing). Diverged revision → no-op: leave the saved snapshot
670    /// alone, because some OTHER mechanism (a synchronous `try_save`
671    /// racing this completion) may have already marked a NEWER revision
672    /// clean, and a stale completion must not clobber that. `is_dirty`
673    /// already reads true when the saved snapshot mismatches the current
674    /// revision, so doing nothing on a mismatch keeps the editor correctly
675    /// dirty without overwriting a legitimately-newer saved snapshot.
676    pub fn mark_saved_at_revision(&mut self, rev: NonZeroU64) {
677        if !self.revs.mark_saved_at(rev) {
678            return;
679        }
680        // Below the guard on purpose: a stale completion marks nothing, and an
681        // action that did nothing must not close the group. One undo after a
682        // real save lands on exactly what is on disk (CONTEXT.md).
683        self.interrupt_typing();
684        if let Some(nvim) = self.backend.as_nvim() {
685            nvim.mark_clean();
686        }
687    }
688
689    /// Synchronous mark-saved used by `try_save` and `set_text`. Unlike
690    /// `mark_saved_at_revision` (which no-ops on a stale revision because
691    /// it can race a sync mark_saved), this one CLOBBERS the saved snapshot
692    /// to `None` when the supplied text diverges: the sync caller holds
693    /// `&mut self` for the whole save, so there is no concurrent newer
694    /// clean state to preserve, and the user typing between
695    /// `get_text()` and this call must show as dirty.
696    pub fn mark_saved(&mut self, text: String) {
697        self.interrupt_typing();
698        let matches = text == self.get_text();
699        if matches {
700            if let Some(nvim) = self.backend.as_nvim() {
701                nvim.mark_clean();
702            }
703            self.revs.mark_saved_current();
704        } else {
705            // Textarea: divergent save → stay dirty.
706            // Nvim: snapshot's `dirty` was untouched anyway; the saved
707            // snapshot in `revs` is what is_dirty consults on the
708            // Textarea backend, and we explicitly forget it here.
709            self.revs.mark_diverged();
710        }
711    }
712
713    /// Something happened that is not a continuation of typing.
714    ///
715    /// One entry point for every path that is not a keystroke — a click, a
716    /// find, an autocomplete accept, a save, a vim motion — because the state it
717    /// closes is kept on the component while those paths reach the buffer by
718    /// four different routes, and a rule applied on one of them is a rule the
719    /// other three forget.
720    ///
721    /// The two halves are deliberately not gated alike:
722    ///
723    /// - The **goal cell** belongs to a run of `↑`/`↓` and to nothing else, so
724    ///   any other action forgets it, in every mode.
725    /// - The **undo group** is closed only outside a vim Insert session. There
726    ///   the session *is* the group (CONTEXT.md), so an autosave landing
727    ///   mid-word, or an auto-surround typed inside Insert, must not split what
728    ///   one `u` is supposed to take back.
729    fn interrupt_typing(&mut self) {
730        self.view.clear_visual_goal();
731        if self.backend.modal_is_insert().unwrap_or(false) {
732            return;
733        }
734        if let Some((_, run)) = self.backend.as_textarea_parts_mut() {
735            run.end();
736        }
737    }
738
739    /// Notice that vim entered or left Insert, and close the group if it did.
740    ///
741    /// This marks a session's *start*: the first key of a session arrives here
742    /// with the flag still reading the old mode. The session's *end* is closed by
743    /// [`Self::interrupt_typing`] on the engine path, because `Esc` is consumed
744    /// there and never reaches this function at all.
745    fn sync_insert_session(&mut self) {
746        let in_insert = self.backend.modal_is_insert().unwrap_or(false);
747        if self.last_insert_session == in_insert {
748            return;
749        }
750        self.last_insert_session = in_insert;
751        if let Some((_, run)) = self.backend.as_textarea_parts_mut() {
752            run.end();
753        }
754    }
755
756    pub fn is_dirty(&self) -> bool {
757        match &self.backend {
758            BackendState::Textarea(_) => self.revs.is_dirty(),
759            BackendState::Nvim(nvim) => nvim.snapshot().dirty,
760        }
761    }
762
763    /// Whether a bare Space should start the leader (vim Normal mode only).
764    /// Returns `false` for the direct textarea backend, the nvim backend,
765    /// vim Insert/Visual modes, and any pending state.
766    ///
767    /// A pure vim-mode fact. It used to also return false while the find bar
768    /// was open — the bar's claim smuggled through the nearest differently
769    /// named field, because the snapshot had nowhere to put it. That is now
770    /// [`Self::claim`]'s job.
771    pub fn space_leads(&self) -> bool {
772        self.backend.space_leads()
773    }
774
775    /// Whether a press in this editor moves the cursor to the cell under it.
776    ///
777    /// False on the **nvim** backend, where the terminal and nvim own the
778    /// mouse: this component's `handle_mouse` returns `NotConsumed` before any
779    /// `jump_to`. Anything that treats a click as pointing *at* something —
780    /// following a link, most obviously — has to ask this first, or it reads a
781    /// cursor the click never moved.
782    pub fn mouse_drives_cursor(&self) -> bool {
783        self.backend.is_textarea()
784    }
785
786    /// Whether this cell is one this editor would place the cursor on.
787    ///
788    /// Narrower than the editor *column*, which the panel set hit-tests: the
789    /// column includes the frame drawn around this component, and `self.rect`
790    /// is the interior it was handed at render — minus the find-bar row, which
791    /// `render` already excludes. `handle_mouse` bounds-checks against exactly
792    /// this and returns `NotConsumed` outside it, so a press anywhere else
793    /// leaves the cursor where it was. Callers that read the cursor *after* a
794    /// press have to ask, or they read a position the press never set.
795    pub fn covers(&self, column: u16, row: u16) -> bool {
796        self.rect
797            .contains(ratatui::layout::Position::new(column, row))
798    }
799
800    /// Which editor-internal surface currently holds input.
801    ///
802    /// The find bar outranks the popup because opening the bar closes it
803    /// (`open_or_advance_search`), so the two cannot genuinely coexist.
804    pub fn claim(&self) -> EditorClaim {
805        if self.search.is_some() {
806            EditorClaim::FindBar
807        } else if self.autocomplete.as_ref().is_some_and(|c| c.is_open()) {
808            EditorClaim::Autocomplete
809        } else {
810            EditorClaim::None
811        }
812    }
813
814    /// Returns the link or label target under the cursor, or `None` if the
815    /// cursor is not inside a wikilink, markdown link, or hashtag span.
816    pub fn follow_target_at_cursor(&self) -> Option<FollowTarget> {
817        let (_row, col, line) = match &self.backend {
818            BackendState::Textarea(tb) => {
819                let (row, col) = tb.ta.cursor();
820                let line = tb.ta.row(row)?.into_owned();
821                (row, col, line)
822            }
823            BackendState::Nvim(nvim) => {
824                let snap = nvim.snapshot();
825                let (row, col) = snap.cursor;
826                let line = snap.lines.get(row)?.to_string();
827                (row, col, line)
828            }
829        };
830
831        // F5: Check wiki-link / markdown-link spans first; Link wins over Label
832        // even if a future edit accidentally lets a Label slip through a Link range.
833        if let Some(span) = kimun_core::note::scan::link_char_spans(&line)
834            .into_iter()
835            .find(|s| s.start <= col && col < s.end)
836        {
837            return Some(FollowTarget::Link(span.target));
838        }
839
840        // Fallback: check for a hashtag label (via the markdown parser).
841        let parsed = self::markdown::ParsedLine::parse(&line);
842        parsed
843            .elements
844            .iter()
845            .find(|e| {
846                e.kind == self::markdown::ElementKind::Label
847                    && col >= e.start_char
848                    && col < e.end_char
849            })
850            .map(|e| {
851                let span: String = line
852                    .chars()
853                    .skip(e.start_char)
854                    .take(e.end_char - e.start_char)
855                    .collect();
856                let name = span.trim_start_matches('#').to_string();
857                FollowTarget::Label(name)
858            })
859    }
860
861    /// Copy selected text to the OS clipboard, flashing the outcome.
862    ///
863    /// Routed through the shared [`crate::components::yank`] seam so a clipboard
864    /// failure is reported rather than swallowed, and so "nothing was selected"
865    /// is distinguishable from "the copy failed".
866    fn copy_selection_to_clipboard(&mut self, tx: &AppTx) {
867        // A vim Visual selection copies exactly as the Ctrl-C chord does —
868        // one path for both gestures.
869        if let Some(text) = self.backend.copy_visual() {
870            crate::components::yank(text, "copied", tx);
871            return;
872        }
873        let text = {
874            // Match the highlighted range in vim charwise Visual mode: the
875            // textarea selection is half-open, but the cursor's char is part of
876            // the visual selection, so copy it too (right-click copy reaches
877            // here after a mouse drag that flipped the engine into Visual).
878            // Read-only — must NOT move the cursor or grow the live selection,
879            // since copy leaves the selection active (repeated copy would drift
880            // wider). `extend_visual_selection_inclusive` is for one-shot
881            // consumers (paste/wrap) that collapse the selection afterwards.
882            let selected = self
883                .backend
884                .selection_as_shown()
885                .zip(self.backend.as_textarea())
886                .and_then(|(range, ta)| ta.text_between(range.0, range.1));
887            match selected {
888                Some(t) if !t.is_empty() => t,
889                _ => {
890                    tx.send(AppEvent::FlashMessage("nothing to copy".into()))
891                        .ok();
892                    return;
893                }
894            }
895        };
896        crate::components::yank(text, "copied", tx);
897    }
898
899    /// Paint the selection as the user sees it: under vim's Visual modes the
900    /// range Visual covers (`VimEngine::visual_range` decides what that is),
901    /// otherwise the buffer's own selection.
902    ///
903    /// One painting choice on top: a `V` selection's last row is painted to
904    /// the pane's edge rather than to its last character, so linewise reads as
905    /// whole lines. Which rows are covered is still the engine's range.
906    fn sync_highlight(&mut self) {
907        self.selection = self.backend.selection_as_shown();
908        if self.backend.is_visual_line()
909            && let Some((start, (er, _))) = self.selection
910        {
911            self.selection = Some((start, (er, usize::MAX)));
912        }
913    }
914
915    /// Paste text from the OS clipboard at the cursor, replacing any active
916    /// selection. Every failure is reported — silence here is what made the
917    /// vim-mode paste bug so hard to place.
918    fn paste_from_clipboard(&mut self, tx: &AppTx) {
919        let text = match crate::components::with_clipboard(|c| c.get_text()) {
920            Ok(t) if !t.is_empty() => t,
921            Ok(_) => {
922                tx.send(AppEvent::FlashMessage("clipboard is empty".into()))
923                    .ok();
924                return;
925            }
926            Err(e) => {
927                tx.send(AppEvent::FlashMessage(format!("clipboard: {e}")))
928                    .ok();
929                return;
930            }
931        };
932        self.paste_text(&text, tx);
933        // Report the paste like every other clipboard action. Without this the
934        // footer keeps the raw chord echo, so Ctrl+V was the one clipboard key
935        // that never said what it did.
936        tx.send(AppEvent::FlashMessage("pasted".into())).ok();
937    }
938
939    /// Under vim's Visual modes the live textarea selection is not what the
940    /// highlight shows: charwise it excludes the char under the cursor (vim's
941    /// selection is inclusive), linewise it spans anchor-to-cursor columns
942    /// rather than whole rows. Re-select the range Visual covers
943    /// ([`vim::VimEngine::visual_range`]) so out-of-engine consumers
944    /// (paste-over-selection, bold/italic/strikethrough wrap) act on all of
945    /// it. No-op outside Visual (Direct/Insert/Nvim), where the buffer's
946    /// selection is already what callers want.
947    fn extend_visual_selection_inclusive(&mut self) {
948        if let Some((start, end)) = self.backend.visual_range()
949            && let Some(ta) = self.backend.as_textarea_mut()
950        {
951            ta.set_selection(start, end);
952        }
953    }
954
955    /// Inserts `text` at the cursor, replacing any active selection. When `text`
956    /// is a URL (http/https/ftp/ftps/mailto) and a selection is active, the
957    /// selection is wrapped as a markdown link `[selection](url)` instead of
958    /// being replaced by the raw URL.
959    ///
960    /// On the Nvim backend the URL-wrap shortcut is skipped (would require
961    /// reading the visual selection from nvim) — `text` is forwarded via
962    /// `nvim_paste`, which honours the current mode (insert/normal/visual).
963    pub fn paste_text(&mut self, text: &str, tx: &AppTx) {
964        if text.is_empty() {
965            return;
966        }
967        // While the **find bar** is open it owns input — but that was only
968        // implemented for key events, so a bracketed paste used to land in the
969        // buffer behind the bar, leaving the match count and the highlighted
970        // current match describing text that no longer exists. Route it into
971        // the focused field instead, which is what the user meant: pasting a
972        // term to search for or to replace with.
973        if self.search.is_some() {
974            if let (Some(bar), BackendState::Textarea(tb)) =
975                (self.search.as_mut(), &mut self.backend)
976            {
977                bar.paste(text, &mut tb.ta);
978            }
979            self.apply_edit_outcome();
980            return;
981        }
982        // The paste consumes a Visual selection: record it for `gv` before the
983        // edit moves the text, and leave Visual after (as the image paste does).
984        self.backend.conclude_visual();
985        self.extend_visual_selection_inclusive();
986        match &mut self.backend {
987            BackendState::Textarea(tb) => {
988                let selection = linkable_url(text).and_then(|_| tb.ta.selection_text());
989                let wrapped = try_build_markdown_link(text, selection.as_deref());
990                let insert = wrapped.as_deref().unwrap_or(text).to_string();
991                // Replacing a selection is a cut plus an insert — one paste,
992                // one undo.
993                tb.ta.edit(|ta| {
994                    if ta.selection_range().is_some() {
995                        ta.cut();
996                    }
997                    ta.insert_str(insert);
998                });
999                self.backend.sync_mouse_selection(false);
1000                self.after_edit();
1001            }
1002            BackendState::Nvim(nvim) => {
1003                nvim.paste(text, tx.clone());
1004                self.bump_content();
1005            }
1006        }
1007        // The buffer just changed under the popup's feet; reconcile
1008        // the trigger context so a stale replace_range cannot survive
1009        // into the next Accept.
1010        self.bind_autocomplete_redraw(tx);
1011        self.sync_autocomplete();
1012    }
1013
1014    /// Inserts `text` at the cursor, replacing any active selection. Routes
1015    /// through `nvim_paste` on the Nvim backend (delegates to [`Self::paste_text`]
1016    /// for that case — URL-wrap is a no-op when nothing in the supplied text
1017    /// matches `linkable_url`, so the two paths are equivalent on Nvim).
1018    pub fn insert_at_cursor(&mut self, text: &str, tx: &AppTx) {
1019        if matches!(self.backend, BackendState::Nvim(_)) {
1020            self.paste_text(text, tx);
1021            return;
1022        }
1023        // Replacing the selection happens HERE, atomically with the insert, and
1024        // not earlier when the paste was merely started: the image encode and
1025        // the attachment save can both fail (disk full, read-only vault, a sync
1026        // conflict), and a cut done up front would leave the user's selected
1027        // text destroyed with nothing in its place and only a save error to
1028        // explain it.
1029        self.take_selection_for_external_paste();
1030        if let Some(ta) = self.backend.as_textarea_mut() {
1031            ta.insert_str(text);
1032            self.after_edit();
1033        }
1034        // See `paste_text` — out-of-band buffer mutation must
1035        // re-reconcile the popup state.
1036        self.bind_autocomplete_redraw(tx);
1037        self.sync_autocomplete();
1038    }
1039
1040    /// Snapshot of the system clipboard image, if any. Returns owned RGBA bytes
1041    /// plus the image dimensions. The screen layer is responsible for encoding
1042    /// (e.g. PNG) and persisting via the vault.
1043    ///
1044    /// Reads go through the same shared handle as writes — not for
1045    /// ownership (only writes need that) but so there is one connection and one
1046    /// reconnect policy. No flash here: this is a *probe* run ahead of every
1047    /// Ctrl+V, and "no image on the clipboard" is the ordinary case, not a
1048    /// failure to report.
1049    pub fn take_clipboard_image(&mut self) -> Option<ClipboardImage> {
1050        let img = crate::components::with_clipboard(|c| c.get_image()).ok()?;
1051        Some(ClipboardImage {
1052            width: img.width,
1053            height: img.height,
1054            rgba: img.bytes.into_owned(),
1055        })
1056    }
1057
1058    /// Prepare the buffer for content arriving from *outside* the editor's own
1059    /// key path — today only the clipboard-image paste, which the screen layer
1060    /// owns because it alone can reach the vault.
1061    ///
1062    /// Removes the active selection (the incoming content replaces it, as with
1063    /// every other paste) and reconciles the vim engine out of Visual, through
1064    /// the same door the mouse path uses. Without this the engine keeps a mode
1065    /// that the buffer no longer supports: still Visual, selection gone.
1066    pub fn take_selection_for_external_paste(&mut self) {
1067        // Before the cut, so `gv` marks move with it rather than being set
1068        // from coordinates the cut has just invalidated.
1069        self.backend.conclude_visual();
1070        self.extend_visual_selection_inclusive();
1071        let cut = if let Some(ta) = self.backend.as_textarea_mut() {
1072            let cut = ta.selection_range().is_some() && ta.cut();
1073            self.selection = ta.selection_range();
1074            cut
1075        } else {
1076            false
1077        };
1078        if cut {
1079            self.apply_edit_outcome();
1080        }
1081        // `false` = no live selection, so a modal engine returns to Normal.
1082        // A no-op for Insert and for the non-modal backends.
1083        self.backend.sync_mouse_selection(false);
1084    }
1085
1086    /// Auto-surround: wrap the selection in `open`…`close`. A vim charwise
1087    /// Visual selection is inclusive, so it is widened by one char first
1088    /// (otherwise `ve` then Bold yields `**hell**o`). `false` — and nothing
1089    /// touched — without a selection or on the Nvim backend.
1090    fn wrap_selection(&mut self, open: &str, close: &str) -> bool {
1091        self.extend_visual_selection_inclusive();
1092        let Some(ta) = self.backend.as_textarea_mut() else {
1093            return false;
1094        };
1095        if !markdown_edits::wrap_selection(ta, open, close) {
1096            return false;
1097        }
1098        // The inner text stays selected so wraps chain; under vim that is a
1099        // Visual selection, or the mode and the highlight disagree.
1100        self.backend.adopt_host_selection();
1101        // Only here, past every `return false` above: this function is consulted
1102        // for each bare `( [ { < " ' ` * _ ~` keystroke, and the declining ones
1103        // fall through to ordinary typing, which must keep its run.
1104        self.after_edit();
1105        true
1106    }
1107
1108    /// Bold / Italic / Strikethrough: wrap the selection in the marker, or with
1109    /// nothing selected insert a pair and leave the cursor between. No-op for
1110    /// other actions and on the Nvim backend.
1111    pub fn apply_text_action(&mut self, action: TextAction) {
1112        let Some(marker) = markdown_edits::emphasis_marker(action) else {
1113            return;
1114        };
1115        // A Visual selection with nothing in it (an empty row): nothing to
1116        // wrap, and inserting a marker pair would type into a selection.
1117        if self.backend.visual_is_empty() {
1118            return;
1119        }
1120        if self.wrap_selection(marker, marker) {
1121            return;
1122        }
1123        let Some(ta) = self.backend.as_textarea_mut() else {
1124            return;
1125        };
1126        markdown_edits::insert_pair(ta, marker, marker);
1127        self.after_edit();
1128    }
1129
1130    /// Smart Enter — [`markdown_edits::smart_enter`] on the live buffer. `true`
1131    /// when handled, so the caller does not insert a plain newline; always
1132    /// `false` on the Nvim backend.
1133    pub fn smart_enter(&mut self) -> bool {
1134        let Some(ta) = self.backend.as_textarea_mut() else {
1135            return false;
1136        };
1137        if !markdown_edits::smart_enter(ta) {
1138            return false;
1139        }
1140        self.after_edit();
1141        true
1142    }
1143
1144    /// The OUTLINE drawer's jump — [`markdown_edits::jump_to_row`] on the
1145    /// live buffer. No-op on the Nvim backend.
1146    pub fn jump_to_row(&mut self, row: usize) {
1147        let Some(ta) = self.backend.as_textarea_mut() else {
1148            return;
1149        };
1150        if markdown_edits::jump_to_row(ta, row) {
1151            self.sync_highlight();
1152        }
1153    }
1154
1155    /// Indent or dedent whole rows by one **indent step** — the cursor's row,
1156    /// or every row a selection touches. A selection ending at column 0 of a
1157    /// later row does not visually include that row, so it is left alone.
1158    /// No-op on the Nvim backend.
1159    pub fn indent_lines(&mut self, dedent: bool) {
1160        let Some(ta) = self.backend.as_textarea_mut() else {
1161            return;
1162        };
1163        let rows = match ta.selection_range() {
1164            Some(((sr, _), (er, ec))) => {
1165                let last = if ec == 0 && er > sr { er - 1 } else { er };
1166                sr..=last
1167            }
1168            None => {
1169                let (row, _) = ta.cursor();
1170                row..=row
1171            }
1172        };
1173        if ta.indent_rows(rows, dedent) {
1174            self.after_edit();
1175        }
1176    }
1177}
1178
1179impl TextEditorComponent {
1180    /// Advances the revision clock. Use at every site that mutates the
1181    /// buffer (insert, delete, paste, undo/redo, autocomplete accept) on
1182    /// the Textarea backend. `handle_input` uses the revision delta to
1183    /// detect a real text change without materialising the buffer.
1184    ///
1185    /// Not called by the Nvim path — the reverse-refresh task in
1186    /// `backend.rs` bumps `snap.content_gen` on real diffs, the frame
1187    /// snapshot derives its revision from that, and `render` adopts the
1188    /// snapshot's value (see the `revs` field doc).
1189    #[inline]
1190    fn bump_content(&mut self) {
1191        self.revs.bump();
1192    }
1193
1194    /// If the Nvim process has died, fall back to a Textarea with the last known content.
1195    fn maybe_recover_from_dead_nvim(&mut self) {
1196        if self.backend.recover_from_dead_nvim() {
1197            // Spin up the autocomplete controller now that we're on the
1198            // textarea backend — set_vault was a no-op at startup when
1199            // we were still on Nvim.
1200            self.ensure_autocomplete_for_textarea();
1201        }
1202    }
1203
1204    /// Handle a key event when using the Nvim backend.
1205    ///
1206    /// Returns `Some(EventState)` if the event was handled (or should be),
1207    /// `None` if the backend is not Nvim and the caller should fall through.
1208    fn handle_nvim_key(
1209        &mut self,
1210        key: &ratatui::crossterm::event::KeyEvent,
1211        tx: &AppTx,
1212    ) -> Option<EventState> {
1213        // FocusSidebar / FocusEditor shortcuts are intercepted at the
1214        // EditorScreen level for directional navigation. The pending-Z
1215        // intercept and quit-command policy live in `nvim_host`.
1216        let nvim = self.backend.as_nvim()?;
1217        // No revision bump here: navigation keys don't change the buffer,
1218        // and content changes surface through the reverse-refresh task's
1219        // `content_gen`, adopted from the frame snapshot in `render` — so
1220        // an in-flight save's revision token survives navigation.
1221        self.nvim_host.handle_key(nvim, key, tx);
1222        Some(EventState::Consumed)
1223    }
1224
1225    /// Open the find bar; if already open, advance to the next match. No-op on
1226    /// the Nvim backend, which has its own `/` search. Policy lives here
1227    /// because only the editor knows which backend is active.
1228    pub fn open_or_advance_search(&mut self) {
1229        if !self.backend.is_textarea() {
1230            return;
1231        }
1232        if self.search.is_some() {
1233            self.dispatch_bar(|bar, buf| {
1234                bar.advance(buf, false);
1235                find_bar::KeyOutcome::default()
1236            });
1237            return;
1238        }
1239        // Yield key focus to the bar — close the autocomplete popup so it stops
1240        // intercepting Esc / Up / Down / Tab / Enter, which belong to the bar.
1241        self.close_autocomplete();
1242        self.search = Some(find_bar::FindBar::new());
1243    }
1244
1245    /// Open the find bar with the **replace field** already revealed, or reveal
1246    /// it on an already-open bar.
1247    pub fn open_replace(&mut self) {
1248        if !self.backend.is_textarea() {
1249            return;
1250        }
1251        if self.search.is_none() {
1252            self.close_autocomplete();
1253            self.search = Some(find_bar::FindBar::new());
1254        }
1255        if let Some(bar) = self.search.as_mut() {
1256            bar.reveal_replace();
1257        }
1258    }
1259
1260    /// Repeat the last search (vim `n`/`N`) using the buffer's persisted
1261    /// pattern, even when the bar is closed.
1262    fn search_repeat(&mut self, backward: bool) {
1263        let BackendState::Textarea(tb) = &mut self.backend else {
1264            return;
1265        };
1266        // Paint the match `n`/`N` landed on. The bar is closed here, so the
1267        // buffer answers — which is why `match_at_cursor` lives on it.
1268        self.selection = if tb.ta.search_repeat(backward) {
1269            tb.ta.match_at_cursor()
1270        } else {
1271            None
1272        };
1273    }
1274
1275    /// Run `f` against the open bar and its buffer, then apply what the buffer
1276    /// measured. Returns `false` when no bar is open.
1277    fn dispatch_bar(
1278        &mut self,
1279        f: impl FnOnce(&mut find_bar::FindBar, &mut RopeBuffer) -> find_bar::KeyOutcome,
1280    ) -> bool {
1281        let BackendState::Textarea(tb) = &mut self.backend else {
1282            return false;
1283        };
1284        let Some(bar) = self.search.as_mut() else {
1285            return false;
1286        };
1287        let outcome = f(bar, &mut tb.ta);
1288        if outcome.close {
1289            self.search = None;
1290            // Clear the anchor as well as the mirrored range. The bar's cursor
1291            // jumps (`search_forward`) move the cursor without touching
1292            // `selection_start`, so a selection that existed before the search
1293            // is left live but unpainted — and the next keystroke silently
1294            // deletes it.
1295            tb.ta.cancel_selection();
1296            self.selection = None;
1297        }
1298        self.apply_edit_outcome();
1299        true
1300    }
1301
1302    /// Feed a key to the open bar. The bar consumes every key it sees.
1303    fn dispatch_to_find_bar(&mut self, key: &ratatui::crossterm::event::KeyEvent) -> bool {
1304        self.dispatch_bar(|bar, buf| bar.handle_key(key, buf))
1305    }
1306
1307    /// The **replace preview** for this frame, when a bar is open. Test-facing:
1308    /// production reads it through `FindBar::overlay`.
1309    #[cfg(test)]
1310    fn replace_preview(&self) -> Option<find_replace::Preview> {
1311        let bar = self.search.as_ref()?;
1312        let buf = self.backend.as_textarea()?;
1313        bar.preview(buf)
1314    }
1315
1316    /// Close the autocomplete popup, if any. Cheap; safe on any backend
1317    /// (no-op when `autocomplete` is None). Use whenever focus moves
1318    /// away from the editor or another overlay takes over key input.
1319    pub fn close_autocomplete(&mut self) {
1320        if let Some(c) = self.autocomplete.as_mut() {
1321            c.close();
1322        }
1323    }
1324
1325    /// Bind the redraw channel up front (e.g. on note open) so the
1326    /// background full-parse task can wake the event-driven render loop
1327    /// on the FIRST render of a large buffer, before any keystroke has
1328    /// run `handle_input`. No-op after the first successful bind.
1329    pub fn set_redraw_tx(&mut self, tx: &AppTx) {
1330        self.bind_autocomplete_redraw(tx);
1331    }
1332
1333    /// Bind the autocomplete controller's redraw callback AND the
1334    /// editor's background-full-parse redraw signal to the app
1335    /// event bus. Called from `handle_input` (the first place where
1336    /// the editor has access to `AppTx`). The autocomplete piece is
1337    /// a no-op after the first successful bind; the redraw_tx clone
1338    /// is set unconditionally so a reset autocomplete controller
1339    /// (e.g. after Nvim → Textarea fallback) doesn't lose the
1340    /// editor's redraw channel.
1341    fn bind_autocomplete_redraw(&mut self, tx: &AppTx) {
1342        if self.redraw_tx.is_none() {
1343            self.redraw_tx = Some(tx.clone());
1344        }
1345        if self.autocomplete_redraw_bound {
1346            return;
1347        }
1348        if let Some(c) = self.autocomplete.as_mut() {
1349            c.set_redraw_callback(redraw_callback(tx.clone()));
1350            self.autocomplete_redraw_bound = true;
1351        }
1352    }
1353
1354    /// What every edit made through the buffer owes the component: the typing
1355    /// run ends (any non-typing action closes the open **undo group**), the
1356    /// selection mirror the renderer reads is refreshed, and the outcome is
1357    /// drained — bumping the revision and telling the view what to re-parse.
1358    /// Reports whether the text changed.
1359    fn after_edit(&mut self) -> bool {
1360        self.interrupt_typing();
1361        self.sync_highlight();
1362        self.apply_edit_outcome()
1363    }
1364
1365    /// Drain the **rope buffer**'s measured outcome and apply it.
1366    ///
1367    /// The one place a text change turns into a revision bump and a
1368    /// parse-damage signal. Both facts are derived by the buffer from the
1369    /// content either side of the edit, so neither can be predicted wrongly
1370    /// (an `insert_str` that returns `false` after deleting) or simply
1371    /// forgotten at one of 22 sites.
1372    ///
1373    /// The revision clock stays on the component because it serves the nvim
1374    /// backend too, which has no edit buffer.
1375    fn apply_edit_outcome(&mut self) -> bool {
1376        let Some(outcome) = self.backend.as_textarea_mut().map(|ta| ta.take_outcome()) else {
1377            return false;
1378        };
1379        if outcome.changed {
1380            self.bump_content();
1381        }
1382        if outcome.bulk {
1383            self.view.note_bulk_edit();
1384        }
1385        if let Some(rows) = outcome.damage {
1386            self.view.note_damage(rows, outcome.line_delta);
1387        }
1388        outcome.changed
1389    }
1390
1391    /// Undo one *user action*. The **edit buffer** replays history to the
1392    /// state the action started from, so nothing here counts entries.
1393    fn undo_grouped(&mut self) -> bool {
1394        let moved = self.backend.as_textarea_mut().is_some_and(|ta| ta.undo());
1395        if moved {
1396            self.sync_highlight();
1397        }
1398        moved
1399    }
1400
1401    /// Redo one *user action*. Mirror of [`Self::undo_grouped`].
1402    fn redo_grouped(&mut self) -> bool {
1403        let moved = self.backend.as_textarea_mut().is_some_and(|ta| ta.redo());
1404        if moved {
1405            self.sync_highlight();
1406        }
1407        moved
1408    }
1409
1410    /// Handle a key event when using the Textarea backend.
1411    fn handle_textarea_key(
1412        &mut self,
1413        key: &ratatui::crossterm::event::KeyEvent,
1414        tx: &AppTx,
1415    ) -> EventState {
1416        // No find-bar check here: `handle_input` — this function's one
1417        // production caller — already routes to the bar before the vim engine,
1418        // so a second check could never fire. It survived only because tests
1419        // call this function directly.
1420
1421        // Whether this key types. Decided here rather than in the plain-key
1422        // section below, because a key claimed earlier — Ctrl+Z, a clipboard
1423        // chord, Tab — returns before ever reaching it, and a run left open
1424        // across an undo would try to extend a group that was just taken back.
1425        let stroke = plain_keys::operation(*key).and_then(|op| match op {
1426            plain_keys::Operation::Insert(c) => Some(typing_run::Stroke::Insert(c)),
1427            plain_keys::Operation::InsertNewline => Some(typing_run::Stroke::Insert('\n')),
1428            plain_keys::Operation::DeleteBack | plain_keys::Operation::DeleteForward => {
1429                Some(typing_run::Stroke::Delete)
1430            }
1431            _ => None,
1432        });
1433        if stroke.is_none()
1434            && let Some((_, run)) = self.backend.as_textarea_parts_mut()
1435        {
1436            run.end();
1437        }
1438
1439        // System clipboard shortcuts — intercept before passing to textarea.
1440        if key.modifiers == KeyModifiers::CONTROL {
1441            match key.code {
1442                KeyCode::Char('c') => {
1443                    self.copy_selection_to_clipboard(tx);
1444                    return EventState::Consumed;
1445                }
1446                KeyCode::Char('v') => {
1447                    self.paste_from_clipboard(tx);
1448                    return EventState::Consumed;
1449                }
1450                KeyCode::Char('x') => {
1451                    self.copy_selection_to_clipboard(tx);
1452                    let cut = if let Some(ta) = self.backend.as_textarea_mut() {
1453                        // `ta.cut()` returns `false` when the selection was
1454                        // empty / nothing to remove. Use its return value
1455                        // directly rather than pre-checking selection_range —
1456                        // one source of truth, no spurious view rebuild on
1457                        // no-op Ctrl+X.
1458                        let cut = ta.cut();
1459                        self.selection = ta.selection_range();
1460                        cut
1461                    } else {
1462                        false
1463                    };
1464                    if cut {
1465                        self.apply_edit_outcome();
1466                    }
1467                    return EventState::Consumed;
1468                }
1469                _ => {}
1470            }
1471        }
1472
1473        // Undo / Redo (Ctrl+Z / Ctrl+Y / Ctrl+Shift+Z). Handled before the
1474        // textarea borrow below because the **undo group** bookkeeping lives on
1475        // the component, and `as_textarea_mut` borrows all of `self`. A replace
1476        // is two history entries and must cost one Ctrl+Z, not two.
1477        if key.modifiers & !KeyModifiers::SHIFT == KeyModifiers::CONTROL {
1478            match key.code {
1479                KeyCode::Char('z') if !key.modifiers.contains(KeyModifiers::SHIFT) => {
1480                    if self.undo_grouped() {
1481                        self.apply_edit_outcome();
1482                    }
1483                    return EventState::Consumed;
1484                }
1485                KeyCode::Char('y') | KeyCode::Char('Z') => {
1486                    if self.redo_grouped() {
1487                        self.apply_edit_outcome();
1488                    }
1489                    return EventState::Consumed;
1490                }
1491                _ => {}
1492            }
1493        }
1494
1495        // FocusSidebar / FocusEditor shortcuts are intercepted at the
1496        // EditorScreen level for directional navigation.
1497
1498        // Standard text-editor shortcuts.
1499        // `input_without_shortcuts` only handles chars, backspace, delete, tab, newline —
1500        // all navigation and editing shortcuts must be mapped explicitly.
1501        // Outcome tracks whether the handled shortcut mutated the buffer, only
1502        // moved the cursor, or did literally nothing (e.g. Ctrl+Z on an empty
1503        // undo stack) — so the revision clock is not
1504        // bumped on true no-ops.
1505        // BackTab is what most terminals emit for Shift+Tab.
1506        match (key.modifiers, key.code) {
1507            (m, KeyCode::Tab)
1508                if !m.contains(KeyModifiers::CONTROL) && !m.contains(KeyModifiers::ALT) =>
1509            {
1510                self.indent_lines(m.contains(KeyModifiers::SHIFT));
1511                return EventState::Consumed;
1512            }
1513            (_, KeyCode::BackTab) => {
1514                self.indent_lines(true);
1515                return EventState::Consumed;
1516            }
1517            _ => {}
1518        }
1519        if key.code == KeyCode::Enter && key.modifiers.is_empty() && self.smart_enter() {
1520            return EventState::Consumed;
1521        }
1522
1523        // Auto-surround: an opening/symmetric pair char typed over a selection
1524        // wraps it instead of replacing it (see CONTEXT.md "Auto-surround").
1525        // Shift is allowed (most opening chars are shifted keys); Ctrl/Alt
1526        // chords fall through. The selection lands on the inner text so wraps
1527        // chain: `[` `[` builds a wikilink — and `handle_input`'s post-key
1528        // sync legitimately opens the wikilink popup on the chained wrap.
1529        if let KeyCode::Char(c) = key.code
1530            && (key.modifiers & !KeyModifiers::SHIFT).is_empty()
1531            && let Some((open, close)) = markdown_edits::surround_pair(c)
1532            && self.wrap_selection(open, close)
1533        {
1534            return EventState::Consumed;
1535        }
1536
1537        // A change of modal state ends whatever run was open: leaving Insert
1538        // closes vim's session, and entering it starts a fresh one. Also read in
1539        // `handle_input` for the keys the engine consumes, which never arrive
1540        // here — this call is what keeps the direct path (which the tests drive)
1541        // bracketed too.
1542        self.sync_insert_session();
1543
1544        // Read before the buffer is borrowed below.
1545        let in_insert_session = self.last_insert_session;
1546        let Some((ta, run)) = self.backend.as_textarea_parts_mut() else {
1547            unreachable!("handle_textarea_key called with non-Textarea backend")
1548        };
1549        // Last: what the key means to the plain backend. It runs *after* the
1550        // component's own claims — `Tab` indents rows, `Enter` may continue a
1551        // list, an opening bracket over a selection wraps it — because those are
1552        // the same keys and the component's reading of them wins.
1553        if let Some(op) = plain_keys::operation(*key) {
1554            // ↑/↓ move by *drawn* line, so they need the layout — which lives on
1555            // the view, not the buffer. Everything else the buffer can answer
1556            // alone. A run of them keeps its goal cell; anything else ends the run.
1557            let vertical = match op {
1558                plain_keys::Operation::Move {
1559                    to: CursorMove::Up,
1560                    extend,
1561                } => Some((false, extend)),
1562                plain_keys::Operation::Move {
1563                    to: CursorMove::Down,
1564                    extend,
1565                } => Some((true, extend)),
1566                _ => None,
1567            };
1568            if vertical.is_none() {
1569                self.view.clear_visual_goal();
1570            }
1571            // Does this keystroke continue the last one's **undo group**? The
1572            // policy is the backend's; the engine only offers a group
1573            // that can span keystrokes. Everything that is not typing ends the
1574            // run, which is what makes the idle rule correct without a timer:
1575            // undo is itself one of those things.
1576            if let Some(stroke) = stroke {
1577                {
1578                    let now = std::time::Instant::now();
1579                    // In vim's Insert mode the session is the group: `u` takes
1580                    // back everything typed since `i`, so neither a word boundary
1581                    // nor a pause may break it. `sync_insert_session` above ended
1582                    // the run at the boundary, which marks a session's start.
1583                    let carries_on = if in_insert_session {
1584                        run.continues_session(stroke, now)
1585                    } else {
1586                        run.continues(stroke, now)
1587                    };
1588                    if carries_on {
1589                        ta.continue_group();
1590                    }
1591                }
1592            }
1593
1594            let changed = match vertical {
1595                // A stale layout — an edit landed before the frame that re-lays
1596                // it out — falls back to the logical move rather than reading it.
1597                Some((down, extend)) if self.view.move_cursor_visually(ta, down, extend) => false,
1598                _ => plain_keys::apply(op, ta),
1599            };
1600            self.selection = ta.selection_range();
1601            if changed {
1602                self.apply_edit_outcome();
1603            }
1604        }
1605        // A key the table declines — a function key, a modifier-only release, an
1606        // IME composition event — leaves the buffer alone, so a harmless keypress
1607        // cannot mark the note dirty and trigger an autosave.
1608        EventState::Consumed
1609    }
1610
1611    /// Act on what the autocomplete popup did with a key or a click — one
1612    /// path, so an accept edits the buffer the same way from either. `None`:
1613    /// the popup passed, the event is the editor's.
1614    fn apply_popup_outcome(&mut self, outcome: HandleKeyOutcome) -> Option<EventState> {
1615        match outcome {
1616            HandleKeyOutcome::Accepted(action) => {
1617                self.interrupt_typing();
1618                if let Some(ta) = self.backend.as_textarea_mut() {
1619                    ta.edit(|ta| apply_accept_to_textarea(ta, &action));
1620                }
1621                self.after_edit();
1622                Some(EventState::Consumed)
1623            }
1624            HandleKeyOutcome::Dismissed | HandleKeyOutcome::Consumed => Some(EventState::Consumed),
1625            HandleKeyOutcome::NotHandled => None,
1626        }
1627    }
1628
1629    /// Handle a mouse event (Textarea backend only).
1630    fn handle_mouse(
1631        &mut self,
1632        mouse: &ratatui::crossterm::event::MouseEvent,
1633        tx: &AppTx,
1634    ) -> EventState {
1635        if !self.covers(mouse.column, mouse.row) {
1636            return EventState::NotConsumed;
1637        }
1638        // Kept for the screen→layout conversion below, which is only reachable
1639        // past the bounds check that `covers` just made.
1640        let r = self.rect;
1641        // Past the bounds check the event is ours, so it is an action: a click
1642        // moves the cursor, and even a scroll means attention moved. Placed above
1643        // the context-menu return below so a right-click counts too.
1644        self.interrupt_typing();
1645        // Right-click: with a selection it copies (unchanged behavior);
1646        // without one it asks the host to open the note's context menu
1647        // (file & note ops).
1648        if matches!(mouse.kind, MouseEventKind::Down(MouseButton::Right))
1649            && self.selection.is_none_or(|(start, end)| start == end)
1650        {
1651            self.wants_context_menu = true;
1652            return EventState::Consumed;
1653        }
1654        // Everything below drives the textarea backend directly; on Nvim the
1655        // terminal/nvim own the mouse (only the context-menu ask above is
1656        // backend-independent).
1657        if !self.backend.is_textarea() {
1658            return EventState::NotConsumed;
1659        }
1660        // Handle right-click clipboard copy in its own scope to avoid borrow conflicts.
1661        if matches!(mouse.kind, MouseEventKind::Down(MouseButton::Right)) {
1662            self.copy_selection_to_clipboard(tx);
1663            self.sync_highlight();
1664            return EventState::Consumed;
1665        }
1666        // A drag under vim selects what it covers, rebuilt from where it began.
1667        // With no press in this editor behind it (one that began elsewhere, or
1668        // before another note was opened) there is nothing it could select.
1669        if matches!(mouse.kind, MouseEventKind::Drag(_)) {
1670            let (lrow, lcol) = self
1671                .view
1672                .click_at_screen((mouse.row - r.y) as usize, (mouse.column - r.x) as usize);
1673            match self.drag_origin {
1674                Some(origin)
1675                    if self
1676                        .backend
1677                        .select_dragged(origin, (lrow as usize, lcol as usize)) =>
1678                {
1679                    self.sync_highlight();
1680                    return EventState::Consumed;
1681                }
1682                None if self.backend.is_vim() => return EventState::Consumed,
1683                _ => {}
1684            }
1685        }
1686        // The drag, if any, is over.
1687        if matches!(mouse.kind, MouseEventKind::Up(_)) {
1688            self.drag_origin = None;
1689        }
1690        // Now extract ta for remaining mouse operations.
1691        let Some(ta) = self.backend.as_textarea_mut() else {
1692            unreachable!()
1693        };
1694        match mouse.kind {
1695            MouseEventKind::Down(_) => {
1696                self.view.follow_cursor();
1697                ta.cancel_selection();
1698                let (lrow, lcol) = self
1699                    .view
1700                    .click_at_screen((mouse.row - r.y) as usize, (mouse.column - r.x) as usize);
1701                ta.jump_to(lrow as usize, lcol as usize);
1702                ta.start_selection();
1703                self.drag_origin = Some(ta.cursor());
1704            }
1705            MouseEventKind::Drag(_) => {
1706                let (lrow, lcol) = self
1707                    .view
1708                    .click_at_screen((mouse.row - r.y) as usize, (mouse.column - r.x) as usize);
1709                ta.jump_to(lrow as usize, lcol as usize);
1710            }
1711            // The wheel moves kimün's own viewport, never the cursor — the
1712            // find bar relies on that to let the user read elsewhere mid-search.
1713            MouseEventKind::ScrollUp | MouseEventKind::ScrollDown => {
1714                let delta = if mouse.kind == MouseEventKind::ScrollUp {
1715                    -WHEEL_SCROLL_ROWS
1716                } else {
1717                    WHEEL_SCROLL_ROWS
1718                };
1719                // The popup is anchored to the cursor, which may now be off
1720                // screen: left open, it would take keys the user cannot see.
1721                if self.view.scroll_by(delta)
1722                    && let Some(c) = self.autocomplete.as_mut()
1723                {
1724                    c.close();
1725                }
1726            }
1727            // Everything else is somebody else's. The incumbent forwarded these
1728            // to the widget, which scrolled a viewport kimün never renders from.
1729            _ => {}
1730        }
1731        self.selection = ta.selection_range();
1732        // Mouse handling moves the cursor / selection but does not insert
1733        // text — click, drag and scroll are all it produces.
1734        EventState::Consumed
1735    }
1736}
1737
1738/// Viewport post-pass: emphasize search-needle matches
1739/// (`color_search_match`, bold) and style task checkboxes — `[ ]` accent,
1740/// `[x]` rows dimmed + struck. Operates on the rendered buffer
1741/// rows, so cost is bounded by the visible area regardless of note size.
1742impl Component for TextEditorComponent {
1743    fn handle_input(&mut self, event: &InputEvent, tx: &AppTx) -> EventState {
1744        self.maybe_recover_from_dead_nvim();
1745        self.bind_autocomplete_redraw(tx);
1746
1747        match event {
1748            InputEvent::Key(key) => {
1749                // Every key acts at the cursor, so a wheel-scrolled view comes
1750                // back to it — even when the key leaves the cursor where it was
1751                // (find-next onto the same match, a motion at the buffer edge).
1752                self.view.follow_cursor();
1753                // Cheap popup-open probe first. The snapshot is now a
1754                // Cow-borrowed view of the textarea's lines (zero
1755                // allocation on the Textarea path — perf #8), so
1756                // idle keystrokes pay nothing here even when popup
1757                // checks fire. The free-function form lets `&self.backend`
1758                // and `&mut self.autocomplete` coexist via field-disjoint
1759                // borrows.
1760                let popup_open = self.autocomplete.as_ref().is_some_and(|c| c.is_open());
1761                if popup_open
1762                    && let Some(host) = build_editor_host_snapshot(
1763                        &self.backend,
1764                        self.revs.current(),
1765                        self.view.last_cursor_screen,
1766                    )
1767                    && let Some(controller) = self.autocomplete.as_mut()
1768                {
1769                    let outcome = controller.handle_key(*key, &host);
1770                    if let Some(state) = self.apply_popup_outcome(outcome) {
1771                        return state;
1772                    }
1773                }
1774                // Find bar intercepts all keys while active. Must run before the
1775                // vim engine, which would otherwise consume keys in Normal mode
1776                // (the textarea backend also intercepts inside handle_textarea_key,
1777                // but the vim Normal-mode path never reaches that).
1778                if self.dispatch_to_find_bar(key) {
1779                    // Here rather than inside `dispatch_bar`: that runs for every
1780                    // key whether or not a bar is open, so interrupting there
1781                    // would make each keystroke its own undo group.
1782                    self.interrupt_typing();
1783                    return EventState::Consumed;
1784                }
1785                // Vim interpreter: Normal/Visual consume the key here; Insert
1786                // mode returns PassThrough and falls into the direct path below
1787                // so typing, autocomplete, auto-surround and smart-Enter all
1788                // keep working.
1789                if let Some(outcome) = self.backend.vim_handle_key(key) {
1790                    use self::vim::VimKeyOutcome;
1791                    // Anything the engine consumed is an action rather than a
1792                    // continuation of typing. PassThrough is the exception: that
1793                    // key falls through to the direct path below, where the plain
1794                    // handler decides — and where a run of ↑/↓ keeps its goal.
1795                    if !matches!(outcome, VimKeyOutcome::PassThrough) {
1796                        self.interrupt_typing();
1797                    }
1798                    // Whatever the engine did, the buffer measured it. One
1799                    // drain replaces the group handshake, the pre-dispatch
1800                    // clone and the hand-placed revision bump.
1801                    self.apply_edit_outcome();
1802                    match outcome {
1803                        VimKeyOutcome::TextMutated => {
1804                            // No bump here — the drain above already applied
1805                            // what the buffer measured.
1806                            self.selection = None;
1807                            return EventState::Consumed;
1808                        }
1809                        VimKeyOutcome::CursorOnly => {
1810                            // Mirror the textarea's selection into self.selection so
1811                            // Visual mode renders through the existing selection pipeline.
1812                            // For non-visual CursorOnly (plain motion), selection_range()
1813                            // returns None → self.selection = None (no regression).
1814                            self.sync_highlight();
1815                            self.refresh_autocomplete_if_open();
1816                            return EventState::Consumed;
1817                        }
1818                        VimKeyOutcome::NoOp => return EventState::Consumed,
1819                        VimKeyOutcome::PassThrough => { /* fall through to direct path */ }
1820                        VimKeyOutcome::Host(action) => {
1821                            use self::vim::VimHostAction;
1822                            match action {
1823                                VimHostAction::OpenPalette => {
1824                                    // Reuse the existing palette gateway.
1825                                    tx.send(AppEvent::ExecuteLeaderAction(
1826                                        crate::keys::leader::LeaderAction::Palette,
1827                                    ))
1828                                    .ok();
1829                                }
1830                                VimHostAction::OpenSearch { forward: _ } => {
1831                                    // `/` and `?` open the existing find bar.
1832                                    // (`?` backward-first is a later refinement;
1833                                    // n/N still navigate both directions.)
1834                                    self.open_or_advance_search();
1835                                }
1836                                VimHostAction::SearchNext => self.search_repeat(false),
1837                                VimHostAction::SearchPrev => self.search_repeat(true),
1838                                // Copy and Cut: the engine already did the
1839                                // editing and the mode transition; all that is
1840                                // left is the I/O and reporting it.
1841                                VimHostAction::ClipboardCopy(text) => {
1842                                    self.selection = None;
1843                                    crate::components::yank(text, "copied", tx);
1844                                }
1845                                VimHostAction::ClipboardCut(text) => {
1846                                    self.selection = None;
1847                                    crate::components::yank(text, "cut", tx);
1848                                }
1849                                // Paste is different: the engine deliberately
1850                                // left the range SELECTED rather than cutting
1851                                // it, so the replacement is atomic. Keep the
1852                                // selection live — `paste_text` consumes it, and
1853                                // a failed/empty read must leave it untouched.
1854                                VimHostAction::ClipboardPaste => {
1855                                    self.selection = self
1856                                        .backend
1857                                        .as_textarea()
1858                                        .and_then(|ta| ta.selection_range());
1859                                    self.paste_from_clipboard(tx);
1860                                }
1861                            }
1862                            return EventState::Consumed;
1863                        }
1864                    }
1865                }
1866                if let Some(state) = self.handle_nvim_key(key, tx) {
1867                    return state;
1868                }
1869                // Diff before/after using cheap counters instead of cloning
1870                // the whole buffer. `text_revision` only bumps when the
1871                // buffer actually changed (handlers call `bump_text`);
1872                // cursor position is two `usize`s. Three outcomes:
1873                //   - text changed → sync (may open a fresh popup)
1874                //   - text unchanged, cursor moved → refresh (close
1875                //     popup if cursor left the trigger range; never
1876                //     open new popup just because the cursor passed
1877                //     over an existing wikilink/hashtag)
1878                //   - both unchanged → no autocomplete work needed
1879                let text_rev_before = self.revs.current();
1880                let cursor_before = self.textarea_cursor();
1881                let result = self.handle_textarea_key(key, tx);
1882                let cursor_after = self.textarea_cursor();
1883                if self.revs.current() != text_rev_before {
1884                    self.sync_autocomplete();
1885                } else if cursor_before != cursor_after {
1886                    self.refresh_autocomplete_if_open();
1887                }
1888                result
1889            }
1890            InputEvent::Mouse(mouse) => {
1891                // An open popup gets first crack: a click on a suggestion
1892                // accepts it — the same edit Tab/Enter make — and the wheel
1893                // over it moves the highlight instead of the buffer.
1894                if self.autocomplete.as_ref().is_some_and(|c| c.is_open())
1895                    && let Some(host) = build_editor_host_snapshot(
1896                        &self.backend,
1897                        self.revs.current(),
1898                        self.view.last_cursor_screen,
1899                    )
1900                    && let Some(controller) = self.autocomplete.as_mut()
1901                {
1902                    let outcome = controller.handle_mouse(mouse, &host);
1903                    if let Some(state) = self.apply_popup_outcome(outcome) {
1904                        return state;
1905                    }
1906                }
1907                let text_rev_before = self.revs.current();
1908                let cursor_before = self.textarea_cursor();
1909                let result = self.handle_mouse(mouse, tx);
1910                let cursor_after = self.textarea_cursor();
1911                // Mouse clicks typically only move the cursor — refresh
1912                // (which may close the popup) but do not auto-open.
1913                if self.revs.current() != text_rev_before {
1914                    self.sync_autocomplete();
1915                } else if cursor_before != cursor_after {
1916                    self.refresh_autocomplete_if_open();
1917                }
1918                // A press only places the cursor. Following is a double-click
1919                // (see `app_screen::click_run`) and is not decided here: it classifies to
1920                // `EditorIntent::FollowLink` — the same intent Ctrl+N produces
1921                // — and the editor screen executes it against this cursor.
1922                // The press that placed the cursor is the *first* of the pair,
1923                // which is why one path can serve both.
1924
1925                // Reconcile the vim engine's mode by gesture, not by the shape
1926                // the selection happens to have: a left press ends Visual, and a
1927                // drag selects (`select_dragged`, in `handle_mouse`); scroll,
1928                // release and right-click leave the mode alone. Insert and
1929                // Replace, and the other backends, are untouched either way.
1930                if matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left)) {
1931                    self.backend.sync_mouse_selection(false);
1932                }
1933                self.sync_highlight();
1934                result
1935            }
1936            // Bracketed paste is intercepted by EditorScreen so it can run the
1937            // image-paste flow first. It never reaches us here.
1938            InputEvent::Paste(_) => EventState::NotConsumed,
1939        }
1940    }
1941
1942    fn render(&mut self, f: &mut Frame, rect: Rect, theme: &Theme, focused: bool) {
1943        // Reserve the bottom row(s) for the find bar when active — one while
1944        // finding, two once a **replace field** is revealed (row one is the
1945        // pattern and what it matches, row two the replacement and what
1946        // happens to it).
1947        let bar_rows: u16 = self.search.as_ref().map_or(0, |bar| bar.rows());
1948        // Clamp rather than drop: with `rect.height == bar_rows` the old
1949        // `>` left the bar unrendered while it was still open and still
1950        // swallowing every key — an invisible modal. Better to show it and
1951        // give the editor whatever is left, even if that is nothing.
1952        let bar_rows = bar_rows.min(rect.height);
1953        let (editor_rect, search_rect) = if bar_rows > 0 {
1954            (
1955                Rect {
1956                    height: rect.height - bar_rows,
1957                    ..rect
1958                },
1959                Some(Rect {
1960                    y: rect.y + rect.height - bar_rows,
1961                    height: bar_rows,
1962                    ..rect
1963                }),
1964            )
1965        } else {
1966            (rect, None)
1967        };
1968        // Store the editor area (not the full rect) so mouse hit-testing ignores
1969        // clicks on the find-bar row.
1970        self.rect = editor_rect;
1971        // Phase 1: gather the per-backend selection (and, on Nvim, run the
1972        // frame housekeeping — resize). The revision is NOT read here: the
1973        // snapshot below is the single producer, and `revs` adopts its
1974        // value, so dirty tracking and the view always agree in a frame.
1975        let selection = match &self.backend {
1976            // While the bar is open the **current match** is what gets painted
1977            // as the selection — the bar owns it rather than writing this field.
1978            BackendState::Textarea(_) => match self.search.as_ref() {
1979                Some(bar) => bar.current_match(),
1980                None => self.selection,
1981            },
1982            BackendState::Nvim(nvim) => {
1983                self.nvim_host
1984                    .frame_sync(nvim, editor_rect.width, editor_rect.height)
1985            }
1986        };
1987        // Drain any completed background full-parse results BEFORE
1988        // running view.update so a just-finished async parse lands
1989        // before Gate 1 has a chance to install another placeholder.
1990        // Generation mismatches drop silently (the spawned task's
1991        // input is older than the current buffer).
1992        while let Ok((generation, buf)) = self.full_parse_rx.try_recv() {
1993            self.view.install_full_parse(generation, buf);
1994        }
1995        // Same drain, for a just-finished background full wrap. Order
1996        // relative to the parse drain above does not matter — the two
1997        // are staleness-gated independently on `generation`.
1998        while let Ok((generation, layout)) = self.layout_rx.try_recv() {
1999            self.view.install_full_layout(generation, layout);
2000        }
2001
2002        // Phase 2: single producer for the atomic snapshot. Borrowed
2003        // on Textarea (zero clone), owned on Nvim (lines cloned out
2004        // from behind the Mutex). Use the free function so the borrow
2005        // checker can split `&self.backend` from `&mut self.view`.
2006        // The **replace preview** is computed before the snapshot borrow so it
2007        // owns its lines outright. The buffer is never touched — only this
2008        // frame's view of it is substituted, which is what makes the preview
2009        // structurally incapable of committing.
2010        // One call gets everything the bar wants painted: preview lines and
2011        // spans, match spans, and the current match (a candidate seam).
2012        let overlay = match (self.search.as_ref(), self.backend.as_textarea()) {
2013            (Some(bar), Some(buf)) => bar.overlay(buf),
2014            _ => find_bar::BarOverlay::default(),
2015        };
2016        let preview = overlay.preview;
2017        let snap = snapshot_from_backend(&self.backend, self.revs.current());
2018        // One revision domain: adopt the snapshot's value (the nvim arm
2019        // derived it from the backend's `content_gen` under one lock; the
2020        // textarea arm passed `revs.current()` through — a no-op adopt).
2021        // Adopt from the REAL snapshot, never the preview's synthetic
2022        // revision, or dirty tracking would follow the preview.
2023        self.revs.adopt(snap.content_revision);
2024        // The lines the view actually draws this frame: the preview's when one
2025        // is showing, the buffer's otherwise. Kept in scope because the
2026        // deferred full-parse below must parse *these*, not the buffer's.
2027        let (view_lines, preview_spans) = match preview {
2028            None => (None, Vec::new()),
2029            Some(p) => (Some(p.lines), p.spans),
2030        };
2031        match &view_lines {
2032            None => self.view.update(&snap, editor_rect),
2033            Some(lines) => {
2034                // The parse cache keys on `content_revision`, so the preview
2035                // carries an identity of its own — derived from the real
2036                // revision plus what is being previewed. Same preview, same
2037                // key: the cache still works instead of thrashing per frame.
2038                let rev = preview_revision(snap.content_revision, lines);
2039                let view_snap = EditorSnapshot::borrowed(lines, snap.cursor, rev);
2040                self.view.update(&view_snap, editor_rect);
2041            }
2042        }
2043        // Needles reach the view as **overlays** now; the cell-space post-pass
2044        // that used to paint them is gone, and with it the coordinate split
2045        // that let a find pattern match text it could never highlight.
2046        if self.revs.needles_stale() {
2047            self.search_needles.clear();
2048            self.revs.disarm_needles();
2049        }
2050        self.view.set_needles(self.search_needles.clone());
2051
2052        // Assemble this frame's **overlays**. The view appends the two kinds it
2053        // derives from content (tasks, needles) for the visible rows.
2054        let mut overlays: Vec<view::Overlay> = Vec::new();
2055        if let Some(((sr, sc), (er, ec))) = selection {
2056            // A multi-row selection becomes one overlay per row; the middle
2057            // rows run to their full width, which `restyle_over_range` clamps.
2058            for row in sr..=er {
2059                let start = if row == sr { sc } else { 0 };
2060                let end = if row == er { ec } else { usize::MAX };
2061                overlays.push(view::Overlay::new(
2062                    row,
2063                    start,
2064                    end,
2065                    view::OverlayKind::Selection,
2066                ));
2067            }
2068        }
2069        overlays.extend(preview_spans.iter().map(|p| {
2070            view::Overlay::new(
2071                p.row,
2072                p.start,
2073                p.end,
2074                if p.is_current {
2075                    view::OverlayKind::PreviewCurrent
2076                } else {
2077                    view::OverlayKind::Preview
2078                },
2079            )
2080        }));
2081        // Find-bar matches. Skipped while previewing: those columns already
2082        // carry the preview colour, which is the more important fact.
2083        if view_lines.is_none() {
2084            overlays.extend(overlay.matches.iter().map(|&(row, start, end)| {
2085                view::Overlay::new(row, start, end, view::OverlayKind::Match)
2086            }));
2087        }
2088        self.view.set_overlays(overlays);
2089
2090        // If `view.update` cap-tripped on a large buffer it
2091        // installed a placeholder + pending-flag instead of running
2092        // ParsedBuffer::parse synchronously. Spawn the real parse
2093        // here so subsequent frames pick up the rich result via the
2094        // drain loop above. `SingleSlotTask::spawn` aborts the prior
2095        // task, so a burst of large-buffer edits resolves against
2096        // the latest content.
2097        if let Some(generation) = self.view.take_pending_full_parse() {
2098            // Parse the lines the view is DRAWING, not the buffer's. Under a
2099            // **replace preview** those differ, and the placeholder this
2100            // generation came from was keyed on the preview's synthetic
2101            // revision — so handing over the buffer's lines would install a
2102            // parse of text that is not on screen and sail through
2103            // `install_full_parse`'s staleness check, styling a large note by
2104            // element boundaries computed against a different string.
2105            // The task gets the text itself. A clone shares its structure, so
2106            // handing a 5000-row note to a background parse costs a pointer
2107            // rather than a copy of the note — where this used to clone every
2108            // row.
2109            let text = match &view_lines {
2110                Some(lines) => crate::ropetext::Text::from(lines.join("\n").as_str()),
2111                None => snap.text.clone(),
2112            };
2113            let tx = self.full_parse_tx.clone();
2114            let redraw = self.redraw_tx.clone();
2115            self.full_parse_task.spawn(async move {
2116                let buf = ParsedBuffer::parse(&text);
2117                let _ = tx.send((generation, buf));
2118                // Wake the render loop so the rich parse lands
2119                // without waiting for the next keystroke.
2120                if let Some(redraw) = redraw {
2121                    let _ = redraw.send(AppEvent::Redraw);
2122                }
2123            });
2124        }
2125        // Same shape, for the layout side: `view.update` may have installed
2126        // a `Layout::unwrapped` stub instead of blocking on `Layout::compute`.
2127        // The job already carries its own `Text`/`rendered_cache`/
2128        // `gutter_insets` clones — nothing here needs `view_lines`/`snap`.
2129        if let Some(job) = self.view.take_pending_full_layout() {
2130            let tx = self.layout_tx.clone();
2131            let redraw = self.redraw_tx.clone();
2132            self.layout_task.spawn(async move {
2133                let hints = view::row_hints(&job.rendered_cache, &job.gutter_insets);
2134                let layout = crate::ropetext::Layout::compute(
2135                    &job.text,
2136                    job.width,
2137                    crate::ropetext::Metrics::default(),
2138                    &hints,
2139                );
2140                let _ = tx.send((job.generation, layout));
2141                if let Some(redraw) = redraw {
2142                    let _ = redraw.send(AppEvent::Redraw);
2143                }
2144            });
2145        }
2146        // When the find bar is active, draw it AFTER the editor so its caret
2147        // (set via set_cursor_position) wins over the editor's caret call.
2148        let bar_focused = self.search.is_some() && focused;
2149        let editor_focused = focused && !bar_focused;
2150        use self::view::CursorShape;
2151        let cursor_shape = match self.backend.modal_is_insert() {
2152            None => None, // Direct textarea — leave terminal default
2153            Some(true) => Some(CursorShape::Bar),
2154            Some(false) => Some(CursorShape::Block),
2155        };
2156        self.view
2157            .render(f, editor_rect, theme, editor_focused, cursor_shape);
2158
2159        // Search-match emphasis: paint needle matches and task
2160        // checkboxes over the rendered viewport. Buffer-level post-pass —
2161        // viewport-only, so large notes pay nothing beyond the visible rows.
2162        if self.revs.needles_stale() {
2163            self.search_needles.clear();
2164            self.revs.disarm_needles();
2165        }
2166
2167        // Empty-note tip: dim ghost text in a fresh/empty buffer,
2168        // gone the instant the first character lands (the buffer stops being
2169        // empty). Drawn after the view so it sits over the blank canvas.
2170        if snap.text.len_bytes() == 0 && editor_rect.height > 0 {
2171            let leader = self
2172                .key_bindings
2173                .first_combo_for(&crate::keys::action_shortcuts::ActionShortcuts::Leader)
2174                .unwrap_or_else(|| "leader".to_string());
2175            f.render_widget(
2176                ratatui::widgets::Paragraph::new(format!(
2177                    "Type to start · [[ to link · # to tag · {leader} for commands"
2178                ))
2179                .style(
2180                    Style::default()
2181                        .fg(theme.gray.to_ratatui())
2182                        .add_modifier(Modifier::ITALIC),
2183                ),
2184                Rect {
2185                    x: editor_rect.x.saturating_add(2),
2186                    width: editor_rect.width.saturating_sub(2),
2187                    height: 1,
2188                    ..editor_rect
2189                },
2190            );
2191        }
2192        if let (Some(state), Some(bar_rect)) = (self.search.as_mut(), search_rect) {
2193            state.render(f, bar_rect, theme, bar_focused);
2194        }
2195
2196        // Autocomplete popup sits on top of the editor. Drain async
2197        // query results first so the popup reflects the latest prefix,
2198        // then re-anchor on the cursor's freshly-rendered screen
2199        // position (otherwise the anchor lags one frame behind on the
2200        // very first popup-opening keystroke). Clamp against
2201        // `editor_rect`, not the full `rect`, so the popup never lands
2202        // on the find-bar row.
2203        self.poll_autocomplete();
2204        // The popup anchors on the cursor's just-rendered screen
2205        // position. When the cursor is off-screen
2206        // (`last_cursor_screen == None`) we skip rendering entirely
2207        // rather than draw at a stale anchor — the popup state is
2208        // preserved, so the popup reappears at the correct position
2209        // once the cursor scrolls back into view.
2210        let live_anchor = self.view.last_cursor_screen;
2211        if let Some(state) = self.autocomplete.as_mut().and_then(|c| c.state_mut()) {
2212            match live_anchor {
2213                Some(anchor) => {
2214                    state.anchor = anchor;
2215                    autocomplete::render(f, state, editor_rect, theme);
2216                }
2217                // Not drawn this frame, so nothing of it may be clickable.
2218                None => state.rows_rect = Rect::default(),
2219            }
2220        }
2221    }
2222
2223    fn hint_shortcuts(&self) -> Vec<(String, String)> {
2224        use crate::keys::action_shortcuts::ActionShortcuts;
2225
2226        // Prepend the modal-mode label (nvim or vim) as the first "hint".
2227        // When the vim interpreter has a pending command sequence (e.g. "2d",
2228        // "f", ">"), append it to the label so the user can see what they have
2229        // typed so far.
2230        if let Some(mut label) = self.backend.mode_label() {
2231            if let Some(p) = self.backend.pending_input_hint() {
2232                label = format!("{label}  {p}");
2233            }
2234            let mut hints = vec![(String::new(), label)];
2235            hints.extend(
2236                [
2237                    (ActionShortcuts::FocusSidebar, "\u{2190} focus left"),
2238                    (ActionShortcuts::FocusEditor, "focus right \u{2192}"),
2239                    (ActionShortcuts::FileOperations, "file ops"),
2240                ]
2241                .iter()
2242                .filter_map(|(action, label)| {
2243                    self.key_bindings
2244                        .first_combo_for(action)
2245                        .map(|k| (k, label.to_string()))
2246                }),
2247            );
2248            return hints;
2249        }
2250
2251        // Cursor-context hints come first: what the cursor is on decides the
2252        // most relevant action.
2253        let mut hints: Vec<(String, String)> = Vec::new();
2254        match self.follow_target_at_cursor() {
2255            Some(FollowTarget::Link(_)) => {
2256                if let Some(k) = self
2257                    .key_bindings
2258                    .first_combo_for(&ActionShortcuts::FollowLink)
2259                {
2260                    hints.push((k, "follow link".to_string()));
2261                }
2262            }
2263            Some(FollowTarget::Label(_)) => {
2264                if let Some(k) = self
2265                    .key_bindings
2266                    .first_combo_for(&ActionShortcuts::FollowLink)
2267                {
2268                    hints.push((k, "browse tag".to_string()));
2269                }
2270            }
2271            None => {}
2272        }
2273        hints.extend(crate::components::hints::hints_for(
2274            &self.key_bindings,
2275            &[
2276                (ActionShortcuts::FocusSidebar, "\u{2190} focus left"),
2277                (ActionShortcuts::FocusEditor, "focus right \u{2192}"),
2278                (ActionShortcuts::FileOperations, "file ops"),
2279                (ActionShortcuts::FindInBuffer, "find"),
2280            ],
2281        ));
2282        hints
2283    }
2284}
2285
2286#[cfg(test)]
2287mod tests {
2288    use super::snapshot::EditorMode;
2289    use super::*;
2290    use crate::keys::KeyBindings;
2291
2292    fn make_editor() -> TextEditorComponent {
2293        TextEditorComponent::new(
2294            KeyBindings::empty(),
2295            &crate::settings::AppSettings::default(),
2296        )
2297    }
2298
2299    fn dummy_tx() -> AppTx {
2300        tokio::sync::mpsc::unbounded_channel().0
2301    }
2302
2303    /// A click on a suggestion in the editor's popup inserts it — the same
2304    /// edit Tab makes.
2305    #[tokio::test]
2306    async fn clicking_an_autocomplete_suggestion_inserts_it() {
2307        use crate::components::search_list::{SuggestionItem, SuggestionSource};
2308        use ratatui::{Terminal, backend::TestBackend};
2309        struct Tags;
2310        #[async_trait::async_trait]
2311        impl SuggestionSource for Tags {
2312            async fn notes_by_prefix(&self, _p: &str, _n: usize) -> Vec<SuggestionItem> {
2313                vec![]
2314            }
2315            async fn tags_by_prefix(&self, p: &str, _n: usize) -> Vec<SuggestionItem> {
2316                if "projects".starts_with(p) {
2317                    vec![SuggestionItem::plain("projects")]
2318                } else {
2319                    vec![]
2320                }
2321            }
2322        }
2323        let mut editor = make_editor();
2324        editor.autocomplete = Some(
2325            AutocompleteController::new(std::sync::Arc::new(Tags), AutocompleteMode::Both)
2326                .with_debounce(std::time::Duration::ZERO),
2327        );
2328        let tx = dummy_tx();
2329        let theme = Theme::default();
2330        let mut t = Terminal::new(TestBackend::new(60, 20)).unwrap();
2331        let draw = |editor: &mut TextEditorComponent, t: &mut Terminal<TestBackend>| {
2332            t.draw(|f| editor.render(f, f.area(), &theme, true))
2333                .unwrap();
2334        };
2335        draw(&mut editor, &mut t);
2336        for c in ['#', 'p', 'r', 'o'] {
2337            editor.handle_input(
2338                &InputEvent::Key(ratatui::crossterm::event::KeyEvent::from(
2339                    ratatui::crossterm::event::KeyCode::Char(c),
2340                )),
2341                &tx,
2342            );
2343            draw(&mut editor, &mut t);
2344        }
2345        for _ in 0..50 {
2346            tokio::task::yield_now().await;
2347            draw(&mut editor, &mut t);
2348        }
2349        let rows = editor
2350            .autocomplete
2351            .as_ref()
2352            .and_then(|c| c.state())
2353            .map(|st| st.rows_rect)
2354            .filter(|r| !r.is_empty())
2355            .expect("popup drawn");
2356        editor.handle_input(&crate::test_support::mouse_down_at(rows.x + 1, rows.y), &tx);
2357        assert_eq!(editor.get_text(), "#projects");
2358    }
2359
2360    fn get_ta(editor: &mut TextEditorComponent) -> &mut RopeBuffer {
2361        match &mut editor.backend {
2362            BackendState::Textarea(tb) => &mut tb.ta,
2363            _ => panic!("expected Textarea backend"),
2364        }
2365    }
2366
2367    #[test]
2368    fn has_trigger_before_cursor_finds_bracket() {
2369        assert!(has_trigger_before_cursor("hello [[foo", 11));
2370        assert!(has_trigger_before_cursor("[[a b c", 7));
2371    }
2372
2373    #[test]
2374    fn has_trigger_before_cursor_finds_hashtag() {
2375        assert!(has_trigger_before_cursor("text #tag", 9));
2376    }
2377
2378    #[test]
2379    fn has_trigger_before_cursor_no_trigger_bails() {
2380        assert!(!has_trigger_before_cursor("plain prose here", 16));
2381        assert!(!has_trigger_before_cursor("", 0));
2382    }
2383
2384    #[test]
2385    fn has_trigger_before_cursor_handles_multibyte_no_panic() {
2386        // Regression: the previous 64-byte saturating_sub slice could
2387        // land mid-codepoint and panic on CJK / emoji / accented lines.
2388        let line = "你好世界".to_string() + &"a".repeat(80);
2389        let col = line.chars().count();
2390        assert!(!has_trigger_before_cursor(&line, col));
2391
2392        let with_emoji = "🦀".repeat(20) + "[[note";
2393        let col = with_emoji.chars().count();
2394        assert!(has_trigger_before_cursor(&with_emoji, col));
2395
2396        let accented = "é".repeat(100);
2397        let col = accented.chars().count();
2398        assert!(!has_trigger_before_cursor(&accented, col));
2399    }
2400
2401    #[test]
2402    fn has_trigger_before_cursor_ignores_chars_after_cursor() {
2403        // Trigger AFTER cursor must not match.
2404        assert!(!has_trigger_before_cursor("foo [[bar", 3));
2405    }
2406
2407    #[test]
2408    fn has_trigger_before_cursor_wikilink_with_spaces() {
2409        // Wikilink contents can contain spaces; we must still detect the
2410        // opening bracket far back on the line.
2411        assert!(has_trigger_before_cursor("[[my note title", 15));
2412    }
2413
2414    #[test]
2415    fn fresh_editor_is_not_dirty() {
2416        let editor = make_editor();
2417        assert!(!editor.is_dirty());
2418    }
2419
2420    #[test]
2421    fn after_set_text_not_dirty() {
2422        let mut editor = make_editor();
2423        editor.set_text("hello world".to_string());
2424        assert!(!editor.is_dirty());
2425    }
2426
2427    #[test]
2428    fn get_text_returns_loaded_content() {
2429        let mut editor = make_editor();
2430        editor.set_text("line one\nline two".to_string());
2431        assert_eq!(editor.get_text(), "line one\nline two");
2432    }
2433
2434    #[test]
2435    fn mark_saved_clears_dirty() {
2436        let mut editor = make_editor();
2437        editor.set_text("initial".to_string());
2438        let text = editor.get_text();
2439        editor.mark_saved(text.clone() + "x"); // saved state diverges
2440        assert!(editor.is_dirty());
2441        editor.mark_saved(text); // saved state matches again
2442        assert!(!editor.is_dirty());
2443    }
2444
2445    #[test]
2446    fn trailing_newline_does_not_cause_false_dirty() {
2447        let mut editor = make_editor();
2448        editor.set_text("content\n".to_string());
2449        assert!(
2450            !editor.is_dirty(),
2451            "trailing newline should not make editor dirty after load"
2452        );
2453    }
2454
2455    #[test]
2456    fn cursor_move_does_not_dirty_buffer() {
2457        let mut editor = make_editor();
2458        editor.set_text("hello world".to_string());
2459        assert!(!editor.is_dirty());
2460        let tx = dummy_tx();
2461        // Send a cursor-only key (Right arrow). It must NOT advance the
2462        // revision clock, so `is_dirty` stays false.
2463        let key = ratatui::crossterm::event::KeyEvent::new(KeyCode::Right, KeyModifiers::NONE);
2464        let _ = editor.handle_input(&InputEvent::Key(key), &tx);
2465        assert!(
2466            !editor.is_dirty(),
2467            "cursor move must not mark the editor as dirty"
2468        );
2469    }
2470
2471    #[test]
2472    fn empty_stack_undo_redo_does_not_dirty_or_bump_revision() {
2473        // Regression: ShortcutOutcome::NoOp must apply for Ctrl+Z / Ctrl+Y
2474        // when the undo/redo stack is empty. Both is_dirty and the
2475        // raw content_revision counter stay put.
2476        let mut editor = make_editor();
2477        editor.set_text("foo".to_string());
2478        let rev_before = editor.content_revision();
2479        assert!(!editor.is_dirty());
2480        let tx = dummy_tx();
2481        for key_code in [KeyCode::Char('z'), KeyCode::Char('y')] {
2482            let key = ratatui::crossterm::event::KeyEvent::new(key_code, KeyModifiers::CONTROL);
2483            let _ = editor.handle_input(&InputEvent::Key(key), &tx);
2484        }
2485        assert!(
2486            !editor.is_dirty(),
2487            "empty-stack undo/redo must not flip is_dirty"
2488        );
2489        assert_eq!(
2490            editor.content_revision(),
2491            rev_before,
2492            "empty-stack undo/redo must not bump content_revision"
2493        );
2494    }
2495
2496    #[test]
2497    fn fresh_editor_content_revision_is_nonzero() {
2498        // Regression: content_revision is typed `NonZeroU64`, which
2499        // makes the "do not cache" sentinel for `AutocompleteHost`
2500        // expressible as `Option::None` without a magic value.
2501        // `NonZeroU64::get()` is always >= 1 by construction; this
2502        // test is now a tautological smoke test that the constructor
2503        // initialises the field.
2504        let editor = make_editor();
2505        assert!(editor.content_revision().get() >= 1);
2506    }
2507
2508    #[test]
2509    fn mouse_down_clears_selection() {
2510        let mut editor = make_editor();
2511        editor.set_text("hello world".to_string());
2512        let ta = get_ta(&mut editor);
2513        ta.start_selection();
2514        ta.move_cursor(CursorMove::WordForward);
2515        assert!(ta.selection_range().is_some());
2516        ta.cancel_selection();
2517        editor.selection = if let BackendState::Textarea(tb) = &editor.backend {
2518            tb.ta.selection_range()
2519        } else {
2520            None
2521        };
2522        assert!(editor.selection.is_none());
2523    }
2524
2525    #[test]
2526    fn ctrl_c_copies_selected_text() {
2527        let mut editor = make_editor();
2528        editor.set_text("hello world".to_string());
2529        let ta = get_ta(&mut editor);
2530        ta.move_cursor(CursorMove::Head);
2531        ta.start_selection();
2532        ta.move_cursor(CursorMove::WordForward);
2533        let range = ta.selection_range().unwrap();
2534        let ((sr, sc), (er, ec)) = range;
2535        let lines = ta.rows();
2536        let selected = if sr == er {
2537            lines[sr][sc..ec].to_string()
2538        } else {
2539            lines[sr][sc..].to_string()
2540        };
2541        assert_eq!(selected, "hello ");
2542    }
2543
2544    /// Selects the char-coordinate range `start..end` in the editor's textarea.
2545    fn select_range(editor: &mut TextEditorComponent, start: (usize, usize), end: (usize, usize)) {
2546        let ta = get_ta(editor);
2547        ta.cancel_selection();
2548        ta.move_cursor(CursorMove::Jump(start.0, start.1));
2549        ta.start_selection();
2550        ta.move_cursor(CursorMove::Jump(end.0, end.1));
2551        assert!(ta.selection_range().is_some());
2552    }
2553
2554    fn send_char(editor: &mut TextEditorComponent, c: char) {
2555        let tx = dummy_tx();
2556        let key = ratatui::crossterm::event::KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE);
2557        let _ = editor.handle_input(&InputEvent::Key(key), &tx);
2558    }
2559
2560    #[test]
2561    fn typing_open_paren_with_selection_wraps_it() {
2562        let mut editor = make_editor();
2563        editor.set_text("hello world".to_string());
2564        select_range(&mut editor, (0, 0), (0, 5)); // "hello"
2565        send_char(&mut editor, '(');
2566        assert_eq!(editor.get_text(), "(hello) world");
2567        assert!(editor.is_dirty(), "wrap must mark the buffer dirty");
2568    }
2569
2570    #[test]
2571    fn wrap_keeps_selection_on_inner_text() {
2572        let mut editor = make_editor();
2573        editor.set_text("hello world".to_string());
2574        select_range(&mut editor, (0, 0), (0, 5));
2575        send_char(&mut editor, '(');
2576        // Selection must cover "hello" inside the parens so wraps chain.
2577        assert_eq!(editor.selection, Some(((0, 1), (0, 6))));
2578    }
2579
2580    #[test]
2581    fn chained_brackets_build_a_wikilink() {
2582        let mut editor = make_editor();
2583        editor.set_text("my note".to_string());
2584        select_range(&mut editor, (0, 0), (0, 7));
2585        send_char(&mut editor, '[');
2586        send_char(&mut editor, '[');
2587        assert_eq!(editor.get_text(), "[[my note]]");
2588        assert_eq!(editor.selection, Some(((0, 2), (0, 9))));
2589    }
2590
2591    #[test]
2592    fn symmetric_chars_wrap_and_chain() {
2593        let mut editor = make_editor();
2594        editor.set_text("bold".to_string());
2595        select_range(&mut editor, (0, 0), (0, 4));
2596        send_char(&mut editor, '*');
2597        assert_eq!(editor.get_text(), "*bold*");
2598        send_char(&mut editor, '*');
2599        assert_eq!(editor.get_text(), "**bold**");
2600        assert_eq!(editor.selection, Some(((0, 2), (0, 6))));
2601    }
2602
2603    #[test]
2604    fn closing_char_replaces_selection() {
2605        let mut editor = make_editor();
2606        editor.set_text("hello world".to_string());
2607        select_range(&mut editor, (0, 0), (0, 5));
2608        send_char(&mut editor, ')');
2609        assert_eq!(editor.get_text(), ") world");
2610    }
2611
2612    #[test]
2613    fn open_char_without_selection_inserts_normally() {
2614        let mut editor = make_editor();
2615        editor.set_text("hello".to_string());
2616        let ta = get_ta(&mut editor);
2617        ta.move_cursor(CursorMove::End);
2618        send_char(&mut editor, '(');
2619        assert_eq!(editor.get_text(), "hello(");
2620    }
2621
2622    #[test]
2623    fn text_action_keeps_selection_on_inner_text() {
2624        // Bold/Italic/Strikethrough route through the same wrap mechanism as
2625        // auto-surround: the inner text stays selected so wraps chain.
2626        let mut editor = make_editor();
2627        editor.set_text("bold word".to_string());
2628        select_range(&mut editor, (0, 0), (0, 4));
2629        editor.apply_text_action(TextAction::Bold);
2630        assert_eq!(editor.get_text(), "**bold** word");
2631        assert_eq!(editor.selection, Some(((0, 2), (0, 6))));
2632    }
2633
2634    #[test]
2635    fn bold_undo_is_one_step_back_to_original() {
2636        // The sibling of the wrap: `apply_text_action` reaches the same
2637        // `wrap_selection`, so bolding a selection is one entry for the same
2638        // reason. Pinned separately because it is the path a toolbar action
2639        // takes, and nothing else would catch it regressing on its own.
2640        let mut editor = make_editor();
2641        editor.set_text("hello world".to_string());
2642        select_range(&mut editor, (0, 0), (0, 5));
2643        editor.apply_text_action(TextAction::Bold);
2644        assert_eq!(editor.get_text(), "**hello** world");
2645        assert!(get_ta(&mut editor).undo(), "the bold is one entry");
2646        assert_eq!(editor.get_text(), "hello world");
2647        assert!(
2648            !get_ta(&mut editor).undo(),
2649            "and has no second half left to take back"
2650        );
2651    }
2652
2653    #[test]
2654    fn linkable_url_accepts_supported_schemes() {
2655        assert_eq!(
2656            linkable_url("https://example.com"),
2657            Some("https://example.com")
2658        );
2659        assert_eq!(
2660            linkable_url("http://example.com/path?q=1#frag"),
2661            Some("http://example.com/path?q=1#frag"),
2662        );
2663        assert_eq!(
2664            linkable_url("  https://example.com  "),
2665            Some("https://example.com")
2666        );
2667        assert_eq!(
2668            linkable_url("ftp://files.example.com/x"),
2669            Some("ftp://files.example.com/x"),
2670        );
2671        assert_eq!(
2672            linkable_url("ftps://files.example.com/x"),
2673            Some("ftps://files.example.com/x"),
2674        );
2675        assert_eq!(
2676            linkable_url("mailto:user@example.com"),
2677            Some("mailto:user@example.com"),
2678        );
2679        assert_eq!(
2680            linkable_url("mailto:user@example.com?subject=hi"),
2681            Some("mailto:user@example.com?subject=hi"),
2682        );
2683    }
2684
2685    #[test]
2686    fn linkable_url_rejects_other_schemes_and_plain_text() {
2687        assert_eq!(linkable_url("file:///etc/passwd"), None);
2688        assert_eq!(linkable_url("ssh://host"), None);
2689        assert_eq!(linkable_url("javascript:alert(1)"), None);
2690        assert_eq!(linkable_url("example.com"), None);
2691        assert_eq!(linkable_url("not a url"), None);
2692        assert_eq!(linkable_url(""), None);
2693        assert_eq!(linkable_url("https://example.com\nmore"), None);
2694    }
2695
2696    #[test]
2697    fn try_build_markdown_link_wraps_selection_when_clip_is_url() {
2698        assert_eq!(
2699            try_build_markdown_link("https://example.com", Some("click here")).as_deref(),
2700            Some("[click here](https://example.com)"),
2701        );
2702    }
2703
2704    #[test]
2705    fn try_build_markdown_link_trims_url_whitespace() {
2706        assert_eq!(
2707            try_build_markdown_link("  https://example.com\n", Some("link")).as_deref(),
2708            Some("[link](https://example.com)"),
2709        );
2710    }
2711
2712    #[test]
2713    fn try_build_markdown_link_returns_none_when_no_selection() {
2714        assert_eq!(try_build_markdown_link("https://example.com", None), None);
2715    }
2716
2717    #[test]
2718    fn try_build_markdown_link_returns_none_when_not_url() {
2719        assert_eq!(try_build_markdown_link("plain text", Some("sel")), None);
2720    }
2721
2722    #[test]
2723    fn try_build_markdown_link_returns_none_when_selection_empty() {
2724        assert_eq!(
2725            try_build_markdown_link("https://example.com", Some("")),
2726            None
2727        );
2728    }
2729
2730    #[test]
2731    fn try_build_markdown_link_escapes_close_bracket_in_selection() {
2732        assert_eq!(
2733            try_build_markdown_link("https://example.com", Some("a]b")).as_deref(),
2734            Some(r"[a\]b](https://example.com)"),
2735        );
2736    }
2737
2738    #[test]
2739    fn try_build_markdown_link_wraps_ftp_url() {
2740        assert_eq!(
2741            try_build_markdown_link("ftp://files.example.com/x", Some("download")).as_deref(),
2742            Some("[download](ftp://files.example.com/x)"),
2743        );
2744    }
2745
2746    fn key(code: KeyCode, mods: KeyModifiers) -> ratatui::crossterm::event::KeyEvent {
2747        ratatui::crossterm::event::KeyEvent::new(code, mods)
2748    }
2749
2750    /// Arrive-from-query needles survive until the first edit.
2751    #[test]
2752    fn search_needles_clear_on_edit() {
2753        let settings = crate::settings::AppSettings::default();
2754        let mut ed = TextEditorComponent::new(settings.key_bindings.clone(), &settings);
2755        ed.set_text("alpha beta".to_string());
2756        ed.set_search_needles(vec!["Alpha".to_string()]);
2757        assert_eq!(ed.search_needles, vec!["alpha"]);
2758        assert!(!ed.revs.needles_stale());
2759
2760        // An edit bumps the revision; the render-side guard would clear.
2761        ed.set_text("alpha beta gamma".to_string());
2762        assert!(ed.revs.needles_stale());
2763    }
2764
2765    #[test]
2766    fn open_or_advance_search_opens_find_bar_with_empty_query() {
2767        let mut editor = make_editor();
2768        editor.set_text("hello world".to_string());
2769        editor.open_or_advance_search();
2770        let state = editor.search.as_ref().expect("find bar opened");
2771        assert!(state.input.is_empty());
2772        assert!(matches!(state.status, SearchStatus::Empty));
2773    }
2774
2775    #[test]
2776    fn open_or_advance_search_advances_when_already_open() {
2777        let mut editor = make_editor();
2778        editor.set_text("ab ab ab".to_string());
2779        let tx = dummy_tx();
2780        editor.open_or_advance_search();
2781        editor.handle_input(
2782            &InputEvent::Key(key(KeyCode::Char('a'), KeyModifiers::NONE)),
2783            &tx,
2784        );
2785        editor.handle_input(
2786            &InputEvent::Key(key(KeyCode::Char('b'), KeyModifiers::NONE)),
2787            &tx,
2788        );
2789        // Cursor now at first match (col 0). Re-invoking advances to second.
2790        editor.open_or_advance_search();
2791        let (_, col) = get_ta(&mut editor).cursor();
2792        assert_eq!(col, 3, "second invocation advances to next match");
2793    }
2794
2795    #[test]
2796    fn typing_in_find_bar_jumps_cursor_to_first_match() {
2797        let mut editor = make_editor();
2798        editor.set_text("foo bar baz".to_string());
2799        let tx = dummy_tx();
2800        editor.open_or_advance_search();
2801        for ch in ['b', 'a', 'r'] {
2802            editor.handle_input(
2803                &InputEvent::Key(key(KeyCode::Char(ch), KeyModifiers::NONE)),
2804                &tx,
2805            );
2806        }
2807        let state = editor.search.as_ref().unwrap();
2808        assert_eq!(state.input.value(), "bar");
2809        assert!(matches!(state.status, SearchStatus::Match));
2810        let (_, col) = get_ta(&mut editor).cursor();
2811        assert_eq!(col, 4, "cursor jumped to start of 'bar'");
2812    }
2813
2814    #[test]
2815    fn enter_in_find_bar_advances_to_next_match() {
2816        let mut editor = make_editor();
2817        editor.set_text("ab ab ab".to_string());
2818        let tx = dummy_tx();
2819        editor.open_or_advance_search();
2820        editor.handle_input(
2821            &InputEvent::Key(key(KeyCode::Char('a'), KeyModifiers::NONE)),
2822            &tx,
2823        );
2824        editor.handle_input(
2825            &InputEvent::Key(key(KeyCode::Char('b'), KeyModifiers::NONE)),
2826            &tx,
2827        );
2828        // first match is at col 0 (match_cursor=true on type)
2829        editor.handle_input(
2830            &InputEvent::Key(key(KeyCode::Enter, KeyModifiers::NONE)),
2831            &tx,
2832        );
2833        let (_, col) = get_ta(&mut editor).cursor();
2834        assert_eq!(col, 3, "Enter advances to second match");
2835    }
2836
2837    #[test]
2838    fn match_is_highlighted_as_selection_after_search() {
2839        let mut editor = make_editor();
2840        editor.set_text("foo bar baz".to_string());
2841        let tx = dummy_tx();
2842        editor.open_or_advance_search();
2843        for ch in ['b', 'a', 'r'] {
2844            editor.handle_input(
2845                &InputEvent::Key(key(KeyCode::Char(ch), KeyModifiers::NONE)),
2846                &tx,
2847            );
2848        }
2849        // "bar" lives at cols 4..7 on row 0. The **current match** belongs to
2850        // the bar now, not to the editor's selection.
2851        assert_eq!(
2852            editor.search.as_ref().unwrap().current_match(),
2853            Some(((0, 4), (0, 7)))
2854        );
2855    }
2856
2857    #[test]
2858    fn no_match_clears_selection() {
2859        let mut editor = make_editor();
2860        editor.set_text("hello".to_string());
2861        let tx = dummy_tx();
2862        editor.open_or_advance_search();
2863        editor.handle_input(
2864            &InputEvent::Key(key(KeyCode::Char('z'), KeyModifiers::NONE)),
2865            &tx,
2866        );
2867        assert_eq!(editor.selection, None);
2868    }
2869
2870    #[test]
2871    fn esc_in_find_bar_clears_selection_highlight() {
2872        let mut editor = make_editor();
2873        editor.set_text("foo bar".to_string());
2874        let tx = dummy_tx();
2875        editor.open_or_advance_search();
2876        editor.handle_input(
2877            &InputEvent::Key(key(KeyCode::Char('b'), KeyModifiers::NONE)),
2878            &tx,
2879        );
2880        editor.handle_input(
2881            &InputEvent::Key(key(KeyCode::Char('a'), KeyModifiers::NONE)),
2882            &tx,
2883        );
2884        editor.handle_input(
2885            &InputEvent::Key(key(KeyCode::Char('r'), KeyModifiers::NONE)),
2886            &tx,
2887        );
2888        assert!(
2889            editor
2890                .search
2891                .as_ref()
2892                .is_some_and(|b| b.current_match().is_some())
2893        );
2894        editor.handle_input(&InputEvent::Key(key(KeyCode::Esc, KeyModifiers::NONE)), &tx);
2895        // Esc drops the bar, and the current match goes with it.
2896        assert!(editor.search.is_none());
2897        assert!(editor.selection.is_none());
2898    }
2899
2900    #[test]
2901    fn esc_in_find_bar_closes_it() {
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        assert!(editor.search.is_some());
2907        editor.handle_input(&InputEvent::Key(key(KeyCode::Esc, KeyModifiers::NONE)), &tx);
2908        assert!(editor.search.is_none());
2909    }
2910
2911    #[test]
2912    fn find_bar_consumes_typing_so_editor_text_is_unchanged() {
2913        let mut editor = make_editor();
2914        editor.set_text("hello".to_string());
2915        let tx = dummy_tx();
2916        editor.open_or_advance_search();
2917        editor.handle_input(
2918            &InputEvent::Key(key(KeyCode::Char('x'), KeyModifiers::NONE)),
2919            &tx,
2920        );
2921        assert_eq!(editor.get_text(), "hello");
2922    }
2923
2924    #[test]
2925    fn no_match_status_when_query_absent() {
2926        let mut editor = make_editor();
2927        editor.set_text("hello".to_string());
2928        let tx = dummy_tx();
2929        editor.open_or_advance_search();
2930        editor.handle_input(
2931            &InputEvent::Key(key(KeyCode::Char('z'), KeyModifiers::NONE)),
2932            &tx,
2933        );
2934        let state = editor.search.as_ref().unwrap();
2935        assert!(matches!(state.status, SearchStatus::NoMatch));
2936    }
2937
2938    #[test]
2939    fn try_build_markdown_link_wraps_mailto_url() {
2940        assert_eq!(
2941            try_build_markdown_link("mailto:user@example.com", Some("email me")).as_deref(),
2942            Some("[email me](mailto:user@example.com)"),
2943        );
2944    }
2945
2946    #[test]
2947    fn insert_at_cursor_appends_text() {
2948        let mut editor = make_editor();
2949        editor.set_text("hello".to_string());
2950        {
2951            let ta = get_ta(&mut editor);
2952            ta.move_cursor(CursorMove::End);
2953        }
2954        editor.insert_at_cursor(" world", &dummy_tx());
2955        assert_eq!(editor.get_text(), "hello world");
2956    }
2957
2958    #[test]
2959    fn insert_at_cursor_replaces_selection() {
2960        let mut editor = make_editor();
2961        editor.set_text("hello world".to_string());
2962        {
2963            let ta = get_ta(&mut editor);
2964            ta.move_cursor(CursorMove::Head);
2965            ta.start_selection();
2966            ta.move_cursor(CursorMove::WordForward);
2967        }
2968        editor.insert_at_cursor("HEY ", &dummy_tx());
2969        assert_eq!(editor.get_text(), "HEY world");
2970    }
2971
2972    #[test]
2973    fn paste_inserts_text_at_cursor() {
2974        let mut editor = make_editor();
2975        editor.set_text("hello".to_string());
2976        let ta = get_ta(&mut editor);
2977        ta.move_cursor(CursorMove::End);
2978        ta.insert_str(" world");
2979        assert_eq!(editor.get_text(), "hello world");
2980    }
2981
2982    #[test]
2983    fn a_selection_ending_at_column_zero_leaves_that_row_alone() {
2984        let mut editor = make_editor();
2985        editor.set_text("foo\nbar\nbaz".to_string());
2986        select_range(&mut editor, (0, 0), (2, 0));
2987        editor.indent_lines(false);
2988        assert_eq!(get_ta(&mut editor).rows(), &["    foo", "    bar", "baz"]);
2989        assert_eq!(editor.selection, Some(((0, 4), (2, 0))));
2990    }
2991
2992    #[test]
2993    fn unsupported_text_action_is_noop() {
2994        let mut editor = make_editor();
2995        editor.set_text("hello".to_string());
2996        editor.apply_text_action(TextAction::Underline);
2997        assert_eq!(editor.get_text(), "hello");
2998    }
2999
3000    #[test]
3001    fn bold_action_with_no_selection_inserts_a_pair_through_the_component() {
3002        // The wrap declines without a selection, and the component falls
3003        // through to the pair insert — the one branch the module tests cannot see.
3004        let mut editor = make_editor();
3005        editor.set_text("hello".to_string());
3006        {
3007            let ta = get_ta(&mut editor);
3008            ta.move_cursor(CursorMove::End);
3009        }
3010        editor.apply_text_action(TextAction::Bold);
3011        assert_eq!(editor.get_text(), "hello****");
3012        assert_eq!(get_ta(&mut editor).cursor(), (0, 7));
3013        assert!(editor.is_dirty(), "the insert is an edit");
3014    }
3015
3016    #[test]
3017    fn jump_to_row_moves_the_view_cursor() {
3018        // The OUTLINE drawer reads the position back through the view snapshot.
3019        let mut ed = make_editor();
3020        ed.set_text("intro\n# Top\nbody\n## Sub One\nmore\n".to_string());
3021        ed.jump_to_row(3);
3022        assert_eq!(ed.view_snapshot().cursor.0, 3);
3023        // A click's zero-width selection must not turn the jump into a span
3024        // the next keystroke replaces.
3025        select_range(&mut ed, (2, 1), (2, 1));
3026        ed.jump_to_row(1);
3027        assert_eq!(ed.selection, None);
3028        assert_eq!(ed.view_snapshot().cursor.0, 1);
3029    }
3030
3031    #[test]
3032    fn tab_indents_the_cursor_row_and_backtab_dedents_it() {
3033        let mut editor = make_editor();
3034        editor.set_text("foo".to_string());
3035        let tx = dummy_tx();
3036        let tab = key(KeyCode::Tab, KeyModifiers::NONE);
3037        let _ = editor.handle_input(&InputEvent::Key(tab), &tx);
3038        assert_eq!(editor.get_text(), "    foo");
3039        assert!(editor.is_dirty(), "an indent is an edit");
3040        let back = key(KeyCode::BackTab, KeyModifiers::SHIFT);
3041        let _ = editor.handle_input(&InputEvent::Key(back), &tx);
3042        assert_eq!(editor.get_text(), "foo");
3043    }
3044
3045    #[test]
3046    fn enter_at_the_end_of_a_list_item_continues_it() {
3047        let mut editor = make_editor();
3048        editor.set_text("- foo".to_string());
3049        {
3050            let ta = get_ta(&mut editor);
3051            ta.move_cursor(CursorMove::End);
3052        }
3053        let tx = dummy_tx();
3054        let enter = key(KeyCode::Enter, KeyModifiers::NONE);
3055        let _ = editor.handle_input(&InputEvent::Key(enter), &tx);
3056        assert_eq!(editor.get_text(), "- foo\n- ");
3057        assert!(editor.is_dirty());
3058    }
3059
3060    /// Regression: Enter in vim **Normal** mode (cursor merely navigated onto
3061    /// the row, never typed into it this session) must continue a list the
3062    /// same way Insert mode's PassThrough does, and — like `o`/`O` — drop
3063    /// into Insert so the next item can be typed right away. Normal mode had
3064    /// no arm for `KeyCode::Enter` at all, so the key fell to the catch-all
3065    /// `NoOp` and was swallowed before ever reaching `smart_enter` —
3066    /// reproduces the "pressing Enter doesn't continue the list" report,
3067    /// which only shows up once the cursor sits in Normal mode (e.g. after a
3068    /// mouse click, or Esc back from Insert) rather than mid-typing.
3069    #[test]
3070    fn vim_normal_mode_enter_at_the_end_of_a_list_item_continues_it() {
3071        let mut editor = make_vim_editor();
3072        editor.set_text("- foo".to_string());
3073        assert_eq!(
3074            vim_mode(&editor),
3075            EditorMode::Normal,
3076            "vim starts in Normal"
3077        );
3078        {
3079            let ta = get_ta(&mut editor);
3080            ta.move_cursor(CursorMove::End);
3081        }
3082        let tx = dummy_tx();
3083        let enter = key(KeyCode::Enter, KeyModifiers::NONE);
3084        let _ = editor.handle_input(&InputEvent::Key(enter), &tx);
3085        assert_eq!(editor.get_text(), "- foo\n- ");
3086        assert!(editor.is_dirty());
3087        assert_eq!(
3088            vim_mode(&editor),
3089            EditorMode::Insert,
3090            "continuing a list is also where o/O leave you: ready to type the next item"
3091        );
3092    }
3093
3094    /// Enter on an ordinary row — where `smart_enter` has nothing to
3095    /// continue — falls back to a plain split at the cursor, same as Insert
3096    /// mode's own Enter. It must not stay a no-op: a note-taking app's Enter
3097    /// always makes a new line, continuing a list only when there is one.
3098    #[test]
3099    fn vim_normal_mode_enter_on_a_plain_row_splits_it_like_insert_mode() {
3100        let mut editor = make_vim_editor();
3101        editor.set_text("foobar".to_string());
3102        {
3103            let ta = get_ta(&mut editor);
3104            ta.jump_to(0, 3);
3105        }
3106        let tx = dummy_tx();
3107        let enter = key(KeyCode::Enter, KeyModifiers::NONE);
3108        let _ = editor.handle_input(&InputEvent::Key(enter), &tx);
3109        assert_eq!(editor.get_text(), "foo\nbar");
3110        assert_eq!(vim_mode(&editor), EditorMode::Insert);
3111    }
3112
3113    /// Guardrail: a pending operator keeps its own meaning for Enter rather
3114    /// than SmartEnter guessing one (`d<CR>` is unmapped here, not a
3115    /// surprise edit). Enter must stay inert and the operator must stay
3116    /// pending, exactly like today's behaviour for any other unmapped key.
3117    #[test]
3118    fn vim_normal_mode_enter_with_a_pending_operator_stays_unmapped() {
3119        let mut editor = make_vim_editor();
3120        editor.set_text("- foo".to_string());
3121        let tx = dummy_tx();
3122        let _ = editor.handle_input(
3123            &InputEvent::Key(key(KeyCode::Char('d'), KeyModifiers::NONE)),
3124            &tx,
3125        );
3126        let _ = editor.handle_input(
3127            &InputEvent::Key(key(KeyCode::Enter, KeyModifiers::NONE)),
3128            &tx,
3129        );
3130        assert_eq!(editor.get_text(), "- foo", "d<CR> must not edit");
3131        assert_eq!(vim_mode(&editor), EditorMode::Normal);
3132        // The pending `d` is still live: `d` again completes `dd`.
3133        let _ = editor.handle_input(
3134            &InputEvent::Key(key(KeyCode::Char('d'), KeyModifiers::NONE)),
3135            &tx,
3136        );
3137        assert_eq!(
3138            editor.get_text(),
3139            "",
3140            "the pending operator must have survived Enter"
3141        );
3142    }
3143
3144    /// Guardrail: a pending count keeps its own meaning too (`3<CR>` is
3145    /// unmapped here, not "continue the list 3 times").
3146    #[test]
3147    fn vim_normal_mode_enter_with_a_pending_count_stays_unmapped() {
3148        let mut editor = make_vim_editor();
3149        editor.set_text("- foo".to_string());
3150        {
3151            let ta = get_ta(&mut editor);
3152            ta.move_cursor(CursorMove::End);
3153        }
3154        let tx = dummy_tx();
3155        let _ = editor.handle_input(
3156            &InputEvent::Key(key(KeyCode::Char('3'), KeyModifiers::NONE)),
3157            &tx,
3158        );
3159        let _ = editor.handle_input(
3160            &InputEvent::Key(key(KeyCode::Enter, KeyModifiers::NONE)),
3161            &tx,
3162        );
3163        assert_eq!(editor.get_text(), "- foo", "3<CR> must not edit");
3164        assert_eq!(vim_mode(&editor), EditorMode::Normal);
3165    }
3166
3167    /// `.` must reproduce the Enter action — not skip it — the same way it
3168    /// reproduces `o`. The natural workflow: continue a list, type the item,
3169    /// Esc, `.` to add another — with NO repositioning in between. Esc's
3170    /// usual one-column step back (vim: cursor settles on the last typed
3171    /// char, not past it) must not make the replay's `smart_enter` call
3172    /// decline for being "mid-row" and silently split the word instead.
3173    #[test]
3174    fn vim_dot_repeats_the_enter_action() {
3175        let mut editor = make_vim_editor();
3176        editor.set_text("- foo".to_string());
3177        {
3178            let ta = get_ta(&mut editor);
3179            ta.move_cursor(CursorMove::End);
3180        }
3181        let tx = dummy_tx();
3182        let _ = editor.handle_input(
3183            &InputEvent::Key(key(KeyCode::Enter, KeyModifiers::NONE)),
3184            &tx,
3185        );
3186        assert_eq!(editor.get_text(), "- foo\n- ");
3187        for c in "bar".chars() {
3188            send_char(&mut editor, c);
3189        }
3190        assert_eq!(editor.get_text(), "- foo\n- bar");
3191        let _ = editor.handle_input(&InputEvent::Key(key(KeyCode::Esc, KeyModifiers::NONE)), &tx);
3192        assert_eq!(vim_mode(&editor), EditorMode::Normal);
3193        assert_eq!(
3194            get_ta(&mut editor).cursor(),
3195            (1, 4),
3196            "Esc lands one column short of the row's true end (len 5) — the exact state that broke dot-repeat"
3197        );
3198
3199        let _ = editor.handle_input(
3200            &InputEvent::Key(key(KeyCode::Char('.'), KeyModifiers::NONE)),
3201            &tx,
3202        );
3203        assert_eq!(editor.get_text(), "- foo\n- bar\n- bar");
3204    }
3205
3206    /// Dot-repeat where nothing was typed before Esc: replaying at the
3207    /// current row re-evaluates `smart_enter` fresh there, same as a second,
3208    /// literal Enter press would. The row IS the just-opened, still-empty
3209    /// bullet, so that re-evaluation clears it rather than opening a third —
3210    /// correct (it matches a real second keypress at that spot), not a
3211    /// repeat of the previous continuation.
3212    #[test]
3213    fn vim_dot_repeats_an_empty_enter_by_clearing_the_still_empty_bullet() {
3214        let mut editor = make_vim_editor();
3215        editor.set_text("- foo".to_string());
3216        {
3217            let ta = get_ta(&mut editor);
3218            ta.move_cursor(CursorMove::End);
3219        }
3220        let tx = dummy_tx();
3221        let _ = editor.handle_input(
3222            &InputEvent::Key(key(KeyCode::Enter, KeyModifiers::NONE)),
3223            &tx,
3224        );
3225        let _ = editor.handle_input(&InputEvent::Key(key(KeyCode::Esc, KeyModifiers::NONE)), &tx);
3226        let _ = editor.handle_input(
3227            &InputEvent::Key(key(KeyCode::Char('.'), KeyModifiers::NONE)),
3228            &tx,
3229        );
3230        assert_eq!(editor.get_text(), "- foo\n");
3231    }
3232
3233    #[test]
3234    fn a_letter_typed_after_smart_enter_starts_its_own_undo_group() {
3235        let mut editor = make_editor();
3236        editor.set_text(String::new());
3237        for c in "- foo".chars() {
3238            send_char(&mut editor, c);
3239        }
3240        let tx = dummy_tx();
3241        let enter = key(KeyCode::Enter, KeyModifiers::NONE);
3242        let _ = editor.handle_input(&InputEvent::Key(enter), &tx);
3243        send_char(&mut editor, 'b');
3244        assert_eq!(editor.get_text(), "- foo\n- b");
3245        assert!(get_ta(&mut editor).undo());
3246        assert_eq!(
3247            editor.get_text(),
3248            "- foo\n- ",
3249            "the letter after a list continuation is its own undo group"
3250        );
3251    }
3252
3253    #[test]
3254    fn textarea_hint_shortcuts_has_no_mode_indicator() {
3255        let editor = make_editor();
3256        let hints = editor.hint_shortcuts();
3257        // None of the hint labels should be "NORMAL", "INSERT", etc.
3258        assert!(
3259            !hints
3260                .iter()
3261                .any(|(_, label)| label == "NORMAL" || label == "INSERT")
3262        );
3263    }
3264
3265    // ── follow_target_at_cursor: label detection ──────────────────────────────────────
3266
3267    /// Helper: place cursor at a specific column on the first row.
3268    fn place_cursor_at_col(editor: &mut TextEditorComponent, col: usize) {
3269        let ta = get_ta(editor);
3270        ta.move_cursor(CursorMove::Head);
3271        for _ in 0..col {
3272            ta.move_cursor(CursorMove::Forward);
3273        }
3274    }
3275
3276    #[test]
3277    fn follow_target_at_cursor_returns_label_when_cursor_on_hashtag() {
3278        let mut editor = make_editor();
3279        editor.set_text("see #rust now".to_string());
3280        // "#rust" starts at col 4, ends at col 9 (5 chars). Place cursor at col 5 (inside).
3281        place_cursor_at_col(&mut editor, 5);
3282        assert_eq!(
3283            editor.follow_target_at_cursor(),
3284            Some(FollowTarget::Label("rust".into())),
3285        );
3286    }
3287
3288    #[test]
3289    fn follow_target_at_cursor_returns_label_at_hash_char() {
3290        let mut editor = make_editor();
3291        editor.set_text("see #rust now".to_string());
3292        // Cursor exactly on '#' (col 4).
3293        place_cursor_at_col(&mut editor, 4);
3294        assert_eq!(
3295            editor.follow_target_at_cursor(),
3296            Some(FollowTarget::Label("rust".into())),
3297        );
3298    }
3299
3300    #[test]
3301    fn follow_target_at_cursor_returns_none_outside_hashtag() {
3302        let mut editor = make_editor();
3303        editor.set_text("see #rust now".to_string());
3304        // Cursor at col 0 ("s") — not on a hashtag.
3305        place_cursor_at_col(&mut editor, 0);
3306        assert_eq!(editor.follow_target_at_cursor(), None);
3307    }
3308
3309    #[test]
3310    fn follow_target_at_cursor_returns_link_for_wikilink() {
3311        let mut editor = make_editor();
3312        editor.set_text("open [[my note]] please".to_string());
3313        // "my note" is inside [[…]]; cursor at col 7 (inside link text).
3314        place_cursor_at_col(&mut editor, 7);
3315        let result = editor.follow_target_at_cursor();
3316        assert!(
3317            matches!(result, Some(FollowTarget::Link(_))),
3318            "expected Link variant, got {result:?}"
3319        );
3320    }
3321
3322    // ── F5: follow_target_at_cursor prioritises Link over Label ────────────────────────
3323
3324    #[test]
3325    fn follow_target_at_cursor_returns_link_for_markdown_link_with_fragment() {
3326        // "[see docs](#section)" — cursor on `#section` should return Note, not Label.
3327        // After F3, the Label inside a link is never emitted, so the bug is
3328        // structurally prevented. This test guards F5: even if a future edit
3329        // accidentally adds a Label, Link wins because link_char_spans is checked first.
3330        let line = "[see docs](#section)";
3331        let mut editor = make_editor();
3332        editor.set_text(line.to_string());
3333        // "#section" starts at byte/char offset 11 (after "[see docs](").
3334        let cursor = "[see docs](#sec".chars().count(); // col 15, inside #section
3335        place_cursor_at_col(&mut editor, cursor);
3336        let result = editor.follow_target_at_cursor();
3337        assert!(
3338            matches!(result, Some(FollowTarget::Link(_))),
3339            "expected Link variant for markdown link fragment, got {result:?}"
3340        );
3341    }
3342
3343    #[test]
3344    fn vim_normal_i_then_typing_inserts_text() {
3345        let mut settings = crate::settings::AppSettings::default();
3346        settings.editor_backend = crate::settings::EditorBackendSetting::Vim;
3347        let mut editor = TextEditorComponent::new(KeyBindings::empty(), &settings);
3348        let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
3349        // In Normal mode, 'x' is unmapped → no text change.
3350        editor.handle_input(
3351            &InputEvent::Key(key(KeyCode::Char('x'), KeyModifiers::NONE)),
3352            &tx,
3353        );
3354        assert_eq!(editor.get_text(), "");
3355        // 'i' enters Insert; then 'x' types a literal x via the direct path.
3356        editor.handle_input(
3357            &InputEvent::Key(key(KeyCode::Char('i'), KeyModifiers::NONE)),
3358            &tx,
3359        );
3360        editor.handle_input(
3361            &InputEvent::Key(key(KeyCode::Char('x'), KeyModifiers::NONE)),
3362            &tx,
3363        );
3364        assert_eq!(editor.get_text(), "x");
3365    }
3366
3367    // ── Find and replace ─────────────────────
3368
3369    /// Drive the find bar: open it, type `pattern`, reveal the replace field
3370    /// with Tab, type `replacement`. Leaves the bar open and focused.
3371    fn open_replace_bar(
3372        editor: &mut TextEditorComponent,
3373        tx: &AppTx,
3374        pattern: &str,
3375        replacement: &str,
3376    ) {
3377        editor.open_or_advance_search();
3378        for c in pattern.chars() {
3379            editor.handle_input(
3380                &InputEvent::Key(key(KeyCode::Char(c), KeyModifiers::NONE)),
3381                tx,
3382            );
3383        }
3384        editor.handle_input(&InputEvent::Key(key(KeyCode::Tab, KeyModifiers::NONE)), tx);
3385        for c in replacement.chars() {
3386            editor.handle_input(
3387                &InputEvent::Key(key(KeyCode::Char(c), KeyModifiers::NONE)),
3388                tx,
3389            );
3390        }
3391    }
3392
3393    #[test]
3394    fn tab_reveals_the_replace_field_and_then_cycles_focus() {
3395        let mut editor = make_editor();
3396        let tx = dummy_tx();
3397        editor.set_text("todo".to_string());
3398        editor.open_or_advance_search();
3399        assert!(
3400            !editor.search.as_ref().unwrap().is_replacing(),
3401            "a find-only bar must not start with a replace field"
3402        );
3403
3404        editor.handle_input(&InputEvent::Key(key(KeyCode::Tab, KeyModifiers::NONE)), &tx);
3405        let s = editor.search.as_ref().unwrap();
3406        assert!(s.is_replacing(), "Tab must reveal the replace field");
3407        // Pattern is empty, so focus stays in the find field — you cannot type
3408        // a replacement for nothing.
3409        assert_eq!(s.focus, BarFocus::Find);
3410
3411        editor.handle_input(&InputEvent::Key(key(KeyCode::Tab, KeyModifiers::NONE)), &tx);
3412        assert_eq!(editor.search.as_ref().unwrap().focus, BarFocus::Replace);
3413        editor.handle_input(&InputEvent::Key(key(KeyCode::Tab, KeyModifiers::NONE)), &tx);
3414        assert_eq!(editor.search.as_ref().unwrap().focus, BarFocus::Find);
3415    }
3416
3417    #[test]
3418    fn typing_in_the_replace_field_does_not_touch_the_buffer() {
3419        let mut editor = make_editor();
3420        let tx = dummy_tx();
3421        editor.set_text("todo and todo".to_string());
3422        open_replace_bar(&mut editor, &tx, "todo", "done");
3423        assert_eq!(
3424            editor.get_text(),
3425            "todo and todo",
3426            "the preview is a view of the note, never a write to it"
3427        );
3428    }
3429
3430    #[test]
3431    fn enter_replaces_the_current_match_and_advances() {
3432        let mut editor = make_editor();
3433        let tx = dummy_tx();
3434        editor.set_text("todo and todo".to_string());
3435        open_replace_bar(&mut editor, &tx, "todo", "done");
3436        editor.handle_input(
3437            &InputEvent::Key(key(KeyCode::Enter, KeyModifiers::NONE)),
3438            &tx,
3439        );
3440        assert_eq!(editor.get_text(), "done and todo");
3441    }
3442
3443    #[test]
3444    fn replacing_a_match_that_ends_inside_a_cluster_is_refused_not_corrupted() {
3445        // "e\u{301}f" is a decomposed é followed by f. Searching `e` matches a
3446        // scalar whose END sits inside the cluster, which is not an addressable
3447        // column — so the second jump does nothing, the selection stays empty,
3448        // and the replacement used to be INSERTED beside the match rather than
3449        // over it, leaving "xe\u{301}f". Refusing is the contract.
3450        let mut editor = make_editor();
3451        let tx = dummy_tx();
3452        editor.set_text("e\u{301}f".to_string());
3453        open_replace_bar(&mut editor, &tx, "e", "x");
3454        editor.handle_input(
3455            &InputEvent::Key(key(KeyCode::Enter, KeyModifiers::NONE)),
3456            &tx,
3457        );
3458        assert_eq!(
3459            editor.get_text(),
3460            "e\u{301}f",
3461            "the note is left alone rather than half-rewritten"
3462        );
3463    }
3464
3465    #[test]
3466    fn ctrl_a_replaces_every_match() {
3467        let mut editor = make_editor();
3468        let tx = dummy_tx();
3469        editor.set_text("todo and todo\nmore todo".to_string());
3470        open_replace_bar(&mut editor, &tx, "todo", "done");
3471        editor.handle_input(
3472            &InputEvent::Key(key(KeyCode::Char('a'), KeyModifiers::CONTROL)),
3473            &tx,
3474        );
3475        assert_eq!(editor.get_text(), "done and done\nmore done");
3476    }
3477
3478    #[test]
3479    fn replace_all_keeps_the_reading_position() {
3480        let mut editor = make_editor();
3481        let tx = dummy_tx();
3482        editor.set_text("todo\nxx\ntodo\nyy".to_string());
3483        open_replace_bar(&mut editor, &tx, "todo", "done");
3484        // Park the cursor on row 3 AFTER the bar is set up — incremental
3485        // search legitimately moves it to the first match while typing, so
3486        // parking beforehand would prove nothing.
3487        if let Some(ta) = editor.backend.as_textarea_mut() {
3488            ta.move_cursor(CursorMove::Jump(3, 1));
3489        }
3490        editor.handle_input(
3491            &InputEvent::Key(key(KeyCode::Char('a'), KeyModifiers::CONTROL)),
3492            &tx,
3493        );
3494        assert_eq!(editor.get_text(), "done\nxx\ndone\nyy");
3495        let (row, _) = editor.cursor_pos();
3496        assert_eq!(
3497            row, 3,
3498            "replace all must not throw the cursor to the end of the note"
3499        );
3500    }
3501
3502    #[test]
3503    fn an_empty_replacement_arms_before_it_deletes() {
3504        let mut editor = make_editor();
3505        let tx = dummy_tx();
3506        editor.set_text("todo and todo".to_string());
3507        open_replace_bar(&mut editor, &tx, "todo ", "");
3508
3509        let ctrl_a = InputEvent::Key(key(KeyCode::Char('a'), KeyModifiers::CONTROL));
3510        editor.handle_input(&ctrl_a, &tx);
3511        assert_eq!(
3512            editor.get_text(),
3513            "todo and todo",
3514            "the first Ctrl+A on an empty replacement must arm, not delete"
3515        );
3516        assert!(editor.search.as_ref().unwrap().armed_empty);
3517
3518        editor.handle_input(&ctrl_a, &tx);
3519        assert_eq!(editor.get_text(), "and todo");
3520    }
3521
3522    #[test]
3523    fn esc_disarms_an_empty_replace_all_without_closing_the_bar() {
3524        let mut editor = make_editor();
3525        let tx = dummy_tx();
3526        editor.set_text("todo".to_string());
3527        open_replace_bar(&mut editor, &tx, "todo", "");
3528        editor.handle_input(
3529            &InputEvent::Key(key(KeyCode::Char('a'), KeyModifiers::CONTROL)),
3530            &tx,
3531        );
3532        editor.handle_input(&InputEvent::Key(key(KeyCode::Esc, KeyModifiers::NONE)), &tx);
3533        let s = editor
3534            .search
3535            .as_ref()
3536            .expect("Esc disarms before it closes");
3537        assert!(!s.armed_empty);
3538        assert_eq!(editor.get_text(), "todo");
3539    }
3540
3541    #[test]
3542    fn one_ctrl_z_undoes_a_whole_replace_all() {
3543        let mut editor = make_editor();
3544        let tx = dummy_tx();
3545        editor.set_text("todo and todo".to_string());
3546        open_replace_bar(&mut editor, &tx, "todo", "done");
3547        editor.handle_input(
3548            &InputEvent::Key(key(KeyCode::Char('a'), KeyModifiers::CONTROL)),
3549            &tx,
3550        );
3551        assert_eq!(editor.get_text(), "done and done");
3552
3553        // Close the bar so Ctrl+Z reaches the editor rather than the bar.
3554        editor.handle_input(&InputEvent::Key(key(KeyCode::Esc, KeyModifiers::NONE)), &tx);
3555        editor.handle_input(
3556            &InputEvent::Key(key(KeyCode::Char('z'), KeyModifiers::CONTROL)),
3557            &tx,
3558        );
3559        assert_eq!(
3560            editor.get_text(),
3561            "todo and todo",
3562            "a replace is two history entries and must cost ONE undo — \
3563             popping half leaves the note with a hole in it"
3564        );
3565    }
3566
3567    #[test]
3568    fn one_ctrl_z_undoes_a_single_replace_step() {
3569        let mut editor = make_editor();
3570        let tx = dummy_tx();
3571        editor.set_text("todo and todo".to_string());
3572        open_replace_bar(&mut editor, &tx, "todo", "done");
3573        editor.handle_input(
3574            &InputEvent::Key(key(KeyCode::Enter, KeyModifiers::NONE)),
3575            &tx,
3576        );
3577        assert_eq!(editor.get_text(), "done and todo");
3578        editor.handle_input(&InputEvent::Key(key(KeyCode::Esc, KeyModifiers::NONE)), &tx);
3579        editor.handle_input(
3580            &InputEvent::Key(key(KeyCode::Char('z'), KeyModifiers::CONTROL)),
3581            &tx,
3582        );
3583        assert_eq!(editor.get_text(), "todo and todo");
3584    }
3585
3586    #[test]
3587    fn redo_regroups_the_replace() {
3588        let mut editor = make_editor();
3589        let tx = dummy_tx();
3590        editor.set_text("todo".to_string());
3591        open_replace_bar(&mut editor, &tx, "todo", "done");
3592        editor.handle_input(
3593            &InputEvent::Key(key(KeyCode::Char('a'), KeyModifiers::CONTROL)),
3594            &tx,
3595        );
3596        editor.handle_input(&InputEvent::Key(key(KeyCode::Esc, KeyModifiers::NONE)), &tx);
3597        editor.handle_input(
3598            &InputEvent::Key(key(KeyCode::Char('z'), KeyModifiers::CONTROL)),
3599            &tx,
3600        );
3601        assert_eq!(editor.get_text(), "todo");
3602        editor.handle_input(
3603            &InputEvent::Key(key(KeyCode::Char('y'), KeyModifiers::CONTROL)),
3604            &tx,
3605        );
3606        assert_eq!(
3607            editor.get_text(),
3608            "done",
3609            "one redo must restore the whole replace"
3610        );
3611    }
3612
3613    #[test]
3614    fn smartcase_drives_both_the_count_and_the_replace() {
3615        let mut editor = make_editor();
3616        let tx = dummy_tx();
3617        editor.set_text("todo Todo TODO".to_string());
3618        open_replace_bar(&mut editor, &tx, "todo", "x");
3619        assert_eq!(
3620            editor.search.as_ref().unwrap().match_count,
3621            3,
3622            "an all-lowercase pattern matches any case"
3623        );
3624        editor.handle_input(
3625            &InputEvent::Key(key(KeyCode::Char('a'), KeyModifiers::CONTROL)),
3626            &tx,
3627        );
3628        assert_eq!(editor.get_text(), "x x x");
3629    }
3630
3631    #[test]
3632    fn an_uppercase_pattern_is_case_sensitive() {
3633        let mut editor = make_editor();
3634        let tx = dummy_tx();
3635        editor.set_text("todo Todo TODO".to_string());
3636        open_replace_bar(&mut editor, &tx, "Todo", "x");
3637        assert_eq!(editor.search.as_ref().unwrap().match_count, 1);
3638        editor.handle_input(
3639            &InputEvent::Key(key(KeyCode::Char('a'), KeyModifiers::CONTROL)),
3640            &tx,
3641        );
3642        assert_eq!(editor.get_text(), "todo x TODO");
3643    }
3644
3645    #[test]
3646    fn the_preview_substitutes_lines_without_writing_them() {
3647        let mut editor = make_editor();
3648        let tx = dummy_tx();
3649        editor.set_text("todo and todo".to_string());
3650        open_replace_bar(&mut editor, &tx, "todo", "done");
3651        let preview = editor.replace_preview().expect("a preview must be built");
3652        assert_eq!(preview.lines, vec!["done and done".to_string()]);
3653        assert_eq!(preview.spans.len(), 2);
3654        assert!(
3655            preview.spans.iter().any(|s| s.is_current),
3656            "the match under the cursor must be flagged so Enter's target is visible"
3657        );
3658        assert_eq!(
3659            editor.get_text(),
3660            "todo and todo",
3661            "building a preview must never mutate the buffer"
3662        );
3663    }
3664
3665    /// The find bar owns the terminal caret while it is open, so the editor
3666    /// draws none — the flagged current span is the only thing on screen
3667    /// saying where in the note you are. It must survive an empty
3668    /// replacement, where the previewed match has zero width.
3669    #[test]
3670    fn a_deletion_preview_still_marks_the_current_match() {
3671        let mut editor = make_editor();
3672        let tx = dummy_tx();
3673        editor.set_text("todo and todo".to_string());
3674        open_replace_bar(&mut editor, &tx, "todo", "");
3675        let preview = editor.replace_preview().expect("a preview must be built");
3676        assert_eq!(preview.lines, vec![" and ".to_string()]);
3677        let current = preview
3678            .spans
3679            .iter()
3680            .find(|s| s.is_current)
3681            .expect("the current match must stay flagged when it previews as nothing");
3682        assert_eq!(
3683            current.start, current.end,
3684            "an empty replacement previews as a zero-width span — the renderer \
3685             widens it to a caret cell so the marker cannot vanish"
3686        );
3687    }
3688
3689    /// A mouse drag while the bar is open leaves a multi-row range in
3690    /// `self.selection` — `handle_mouse` has no find-bar guard. Reading the
3691    /// span from there dropped the end row and handed `replace_range` an
3692    /// inverted byte range, panicking the whole TUI.
3693    #[test]
3694    fn a_multi_row_selection_cannot_derail_an_interactive_replace() {
3695        let mut editor = make_editor();
3696        let tx = dummy_tx();
3697        editor.set_text("alpha beta\nxy".to_string());
3698        open_replace_bar(&mut editor, &tx, "beta", "Z");
3699        // Exactly what a drag from row 0 col 6 to row 1 col 1 leaves behind.
3700        editor.selection = Some(((0, 6), (1, 1)));
3701        editor.handle_input(
3702            &InputEvent::Key(key(KeyCode::Enter, KeyModifiers::NONE)),
3703            &tx,
3704        );
3705        assert_eq!(editor.get_text(), "alpha Z\nxy");
3706    }
3707
3708    /// `insert_str("")` deletes the selection and still returns `false`
3709    /// (`insert_piece` bails on the empty string), so trusting its bool left
3710    /// the buffer modified while the note read clean — never saved, and still
3711    /// rendering the pre-deletion text.
3712    #[test]
3713    fn deleting_a_match_marks_the_note_dirty() {
3714        let mut editor = make_editor();
3715        let tx = dummy_tx();
3716        editor.set_text("todo and todo".to_string());
3717        editor.mark_saved("todo and todo".to_string());
3718        assert!(!editor.is_dirty());
3719
3720        open_replace_bar(&mut editor, &tx, "todo ", "");
3721        editor.handle_input(
3722            &InputEvent::Key(key(KeyCode::Enter, KeyModifiers::NONE)),
3723            &tx,
3724        );
3725        assert_eq!(editor.get_text(), "and todo");
3726        assert!(
3727            editor.is_dirty(),
3728            "a deletion is an edit — if the revision does not move, autosave \
3729             never writes it and the change is silently lost"
3730        );
3731    }
3732
3733    /// The same trap on the bulk path, where the result is an empty buffer.
3734    #[test]
3735    fn emptying_the_note_via_replace_all_marks_it_dirty_and_is_undoable() {
3736        let mut editor = make_editor();
3737        let tx = dummy_tx();
3738        editor.set_text("todo".to_string());
3739        editor.mark_saved("todo".to_string());
3740        open_replace_bar(&mut editor, &tx, "todo", "");
3741        let ctrl_a = InputEvent::Key(key(KeyCode::Char('a'), KeyModifiers::CONTROL));
3742        editor.handle_input(&ctrl_a, &tx); // arms
3743        editor.handle_input(&ctrl_a, &tx); // commits
3744        assert_eq!(editor.get_text(), "");
3745        assert!(editor.is_dirty());
3746
3747        editor.handle_input(
3748            &InputEvent::Key(key(KeyCode::Char('z'), KeyModifiers::CONTROL)),
3749            &tx,
3750        );
3751        assert_eq!(editor.get_text(), "todo");
3752    }
3753
3754    /// Ctrl+Z must work from inside the bar. The bar consumes every key, so
3755    /// without an explicit route the user is stranded on a note it just
3756    /// rewrote until they think to press Esc first.
3757    #[test]
3758    fn ctrl_z_works_without_closing_the_bar_first() {
3759        let mut editor = make_editor();
3760        let tx = dummy_tx();
3761        editor.set_text("todo and todo".to_string());
3762        open_replace_bar(&mut editor, &tx, "todo", "done");
3763        editor.handle_input(
3764            &InputEvent::Key(key(KeyCode::Char('a'), KeyModifiers::CONTROL)),
3765            &tx,
3766        );
3767        assert_eq!(editor.get_text(), "done and done");
3768        editor.handle_input(
3769            &InputEvent::Key(key(KeyCode::Char('z'), KeyModifiers::CONTROL)),
3770            &tx,
3771        );
3772        assert_eq!(editor.get_text(), "todo and todo");
3773        assert!(editor.search.is_some(), "undo must not close the bar");
3774    }
3775
3776    /// A zero-width match (`\b`, `x*`) makes the selection empty, so
3777    /// `delete_selection` pushes no history entry and the action is ONE entry,
3778    /// not two. Recording two made the next Ctrl+Z pop an unrelated edit.
3779    #[test]
3780    fn a_zero_width_match_does_not_over_claim_history_entries() {
3781        let mut editor = make_editor();
3782        let tx = dummy_tx();
3783        editor.set_text("ab".to_string());
3784        open_replace_bar(&mut editor, &tx, r"\b", "|");
3785        editor.handle_input(
3786            &InputEvent::Key(key(KeyCode::Enter, KeyModifiers::NONE)),
3787            &tx,
3788        );
3789        assert_eq!(editor.get_text(), "|ab");
3790        editor.handle_input(
3791            &InputEvent::Key(key(KeyCode::Char('z'), KeyModifiers::CONTROL)),
3792            &tx,
3793        );
3794        assert_eq!(
3795            editor.get_text(),
3796            "ab",
3797            "one undo must land exactly on the pre-replace text, not past it"
3798        );
3799    }
3800
3801    /// A note swap must not carry the previous note's find-bar state across.
3802    /// `armed_empty` surviving means one Ctrl+A deletes every match in a note
3803    /// the user never armed.
3804    #[test]
3805    fn a_note_swap_resets_the_find_bar_and_its_undo_groups() {
3806        let mut editor = make_editor();
3807        let tx = dummy_tx();
3808        editor.set_text("todo".to_string());
3809        open_replace_bar(&mut editor, &tx, "todo", "");
3810        editor.handle_input(
3811            &InputEvent::Key(key(KeyCode::Char('a'), KeyModifiers::CONTROL)),
3812            &tx,
3813        );
3814        assert!(editor.search.as_ref().unwrap().armed_empty);
3815
3816        editor.set_text("todo elsewhere".to_string());
3817        assert!(editor.search.is_none(), "the bar belonged to the old note");
3818        // The buffer's groups went with it: `set_text` replaces the textarea,
3819        // and `RopeBuffer::replace` drops states the new history cannot reach.
3820        assert!(
3821            !editor.backend.as_textarea_mut().unwrap().undo(),
3822            "the new note's history has nothing to undo"
3823        );
3824    }
3825
3826    /// Find-match highlighting is built from logical coordinates, so it
3827    /// describes the same matches the count and the stepping do. The old
3828    /// post-pass matched against text reconstructed from drawn cells, where
3829    /// markdown sigils are already concealed — so a pattern targeting a sigil
3830    /// counted and stepped to matches it could never paint.
3831    #[test]
3832    fn concealed_markdown_still_highlights_what_it_counts() {
3833        let mut editor = make_editor();
3834        let tx = dummy_tx();
3835        editor.set_text("# Heading\n[[note]]".to_string());
3836        editor.open_or_advance_search();
3837        for c in r"\[\[".chars() {
3838            editor.handle_input(
3839                &InputEvent::Key(key(KeyCode::Char(c), KeyModifiers::NONE)),
3840                &tx,
3841            );
3842        }
3843        let state = editor.search.as_ref().unwrap();
3844        assert_eq!(state.match_count, 1, "the `[[` sigil is a real match");
3845        let spans = state
3846            .pattern
3847            .as_ref()
3848            .unwrap()
3849            .match_spans(editor.backend.as_textarea().unwrap().text().lines());
3850        assert_eq!(
3851            spans,
3852            vec![(1, 0, 2)],
3853            "and it must be reported as a paintable span, not silently dropped \
3854             because the rendered row conceals it"
3855        );
3856    }
3857
3858    /// A bracketed paste used to land in the buffer behind the open bar,
3859    /// leaving the match count and the highlighted match describing text that
3860    /// no longer existed. It belongs in the focused field — that is the
3861    /// holder's own behaviour, which survives the claim refactor.
3862    #[test]
3863    fn paste_goes_into_the_focused_bar_field() {
3864        let mut editor = make_editor();
3865        let tx = dummy_tx();
3866        editor.set_text("todo".to_string());
3867        open_replace_bar(&mut editor, &tx, "todo", "");
3868        editor.paste_text("done", &tx);
3869        assert_eq!(editor.get_text(), "todo", "the buffer is untouched");
3870        assert_eq!(editor.search.as_ref().unwrap().replacement(), "done");
3871    }
3872
3873    #[test]
3874    fn a_multiline_paste_collapses_to_its_first_line() {
3875        let mut editor = make_editor();
3876        let tx = dummy_tx();
3877        editor.set_text("x".to_string());
3878        editor.open_or_advance_search();
3879        editor.paste_text("first\nsecond", &tx);
3880        assert_eq!(editor.search.as_ref().unwrap().input.value(), "first");
3881    }
3882
3883    /// With the pane exactly as tall as the bar, the old `>` comparison left
3884    /// the bar unrendered while it was still open and still consuming keys —
3885    /// an invisible modal.
3886    #[test]
3887    fn the_bar_is_never_an_invisible_modal() {
3888        use ratatui::Terminal;
3889        use ratatui::backend::TestBackend;
3890        let mut editor = make_editor();
3891        editor.set_text("todo".to_string());
3892        let theme = Theme::default();
3893        let mut term = Terminal::new(TestBackend::new(40, 1)).unwrap();
3894        let area = Rect::new(0, 0, 40, 1);
3895        editor.open_or_advance_search();
3896        term.draw(|f| editor.render(f, area, &theme, true)).unwrap();
3897        let row: String = (0..40)
3898            .filter_map(|x| {
3899                term.backend()
3900                    .buffer()
3901                    .cell(ratatui::layout::Position::new(x, 0))
3902                    .map(|c| c.symbol().to_string())
3903            })
3904            .collect();
3905        assert!(
3906            row.contains("Find:"),
3907            "an open bar must be drawn even when it costs the whole pane, got {row:?}"
3908        );
3909    }
3910
3911    /// End-to-end: after a replace all, a rewritten row far from the cursor
3912    /// must render from a fresh parse, not the pre-replace one. The construct
3913    /// has to be one the renderer *conceals* (a wikilink), because a stale
3914    /// parse is only visible where parsing changes what is drawn.
3915    ///
3916    /// Honest caveat: this passes with `note_bulk_edit` removed, because the
3917    /// widener cap-trips to a full parse on a damage range this far from a
3918    /// reset boundary. It guards the user-visible outcome, not the mechanism —
3919    /// the mechanism is pinned by
3920    /// `the_cursor_hint_under_reports_a_two_place_edit` in
3921    /// `parse_incremental`, which does discriminate.
3922    #[test]
3923    fn a_row_far_from_the_cursor_reparses_after_replace_all() {
3924        use ratatui::Terminal;
3925        use ratatui::backend::TestBackend;
3926        let mut editor = make_editor();
3927        let tx = dummy_tx();
3928        let mut lines: Vec<String> = (0..400).map(|i| format!("filler {i}")).collect();
3929        lines[0] = "todo".to_string();
3930        lines[398] = "todo".to_string();
3931        editor.set_text(lines.join("\n"));
3932        let theme = Theme::default();
3933        let mut term = Terminal::new(TestBackend::new(20, 8)).unwrap();
3934        let area = Rect::new(0, 0, 20, 8);
3935        term.draw(|f| editor.render(f, area, &theme, true)).unwrap();
3936
3937        open_replace_bar(&mut editor, &tx, "todo", "[[x]]");
3938        // Cursor on the LAST match, so the damage hint points 398 rows away
3939        // from the first one.
3940        if let Some(ta) = editor.backend.as_textarea_mut() {
3941            ta.move_cursor(CursorMove::Jump(398, 0));
3942        }
3943        editor.handle_input(
3944            &InputEvent::Key(key(KeyCode::Char('a'), KeyModifiers::CONTROL)),
3945            &tx,
3946        );
3947        // Back to the top, cursor OFF row 0 — a cursor inside the link would
3948        // reveal it legitimately and prove nothing.
3949        if let Some(ta) = editor.backend.as_textarea_mut() {
3950            ta.move_cursor(CursorMove::Jump(1, 0));
3951        }
3952        term.draw(|f| editor.render(f, area, &theme, true)).unwrap();
3953        let row0: String = (0..20)
3954            .filter_map(|x| {
3955                term.backend()
3956                    .buffer()
3957                    .cell(ratatui::layout::Position::new(x, 0))
3958                    .map(|c| c.symbol().to_string())
3959            })
3960            .collect::<String>()
3961            .trim_end()
3962            .to_string();
3963        assert_eq!(
3964            row0, "x",
3965            "row 0 must render as a parsed wikilink; `[[x]]` would mean it \
3966             kept the parse of the text that was there before the replace"
3967        );
3968    }
3969
3970    /// Indenting N lines is 2N history entries, so before the **rope buffer**
3971    /// grouped it, one Ctrl+Z un-indented only the last line and the user had
3972    /// to press it N times. Same class as `guu`, and fixed by the same move.
3973    #[test]
3974    fn indenting_a_block_undoes_in_one_step() {
3975        let mut editor = make_editor();
3976        let tx = dummy_tx();
3977        editor.set_text("a\nb\nc".to_string());
3978        get_ta(&mut editor).move_cursor(CursorMove::Jump(0, 0));
3979        get_ta(&mut editor).start_selection();
3980        get_ta(&mut editor).move_cursor(CursorMove::Jump(2, 1));
3981        editor.indent_lines(false);
3982        assert_eq!(editor.get_text(), "    a\n    b\n    c");
3983
3984        editor.handle_input(
3985            &InputEvent::Key(key(KeyCode::Char('z'), KeyModifiers::CONTROL)),
3986            &tx,
3987        );
3988        assert_eq!(
3989            editor.get_text(),
3990            "a\nb\nc",
3991            "one undo must revert the whole block, not just the last line"
3992        );
3993    }
3994
3995    /// A paste over a selection is a cut plus an insert — one action.
3996    #[test]
3997    fn pasting_over_a_selection_undoes_in_one_step() {
3998        let mut editor = make_editor();
3999        let tx = dummy_tx();
4000        editor.set_text("hello world".to_string());
4001        get_ta(&mut editor).move_cursor(CursorMove::Jump(0, 0));
4002        get_ta(&mut editor).start_selection();
4003        get_ta(&mut editor).move_cursor(CursorMove::Jump(0, 5));
4004        editor.paste_text("goodbye", &tx);
4005        assert_eq!(editor.get_text(), "goodbye world");
4006
4007        editor.handle_input(
4008            &InputEvent::Key(key(KeyCode::Char('z'), KeyModifiers::CONTROL)),
4009            &tx,
4010        );
4011        assert_eq!(editor.get_text(), "hello world");
4012    }
4013
4014    /// Closing the bar must clear the editor's selection, as `close_search`
4015    /// did before the bar became a module. A stale mouse-drag range otherwise
4016    /// suppresses the right-click context menu, which reads `self.selection`.
4017    #[test]
4018    fn closing_the_bar_clears_a_stale_selection() {
4019        let mut editor = make_editor();
4020        let tx = dummy_tx();
4021        editor.set_text("alpha beta".to_string());
4022        editor.selection = Some(((0, 0), (0, 5)));
4023        editor.open_or_advance_search();
4024        for c in "beta".chars() {
4025            editor.handle_input(
4026                &InputEvent::Key(key(KeyCode::Char(c), KeyModifiers::NONE)),
4027                &tx,
4028            );
4029        }
4030        editor.handle_input(&InputEvent::Key(key(KeyCode::Esc, KeyModifiers::NONE)), &tx);
4031        assert!(editor.search.is_none());
4032        assert_eq!(
4033            editor.selection, None,
4034            "a selection from before the search must not outlive the bar"
4035        );
4036    }
4037
4038    /// An undo inside the bar changes the text the **current match** pointed
4039    /// at, so the highlight must be re-derived rather than left over it.
4040    #[test]
4041    fn undo_inside_the_bar_rederives_the_current_match() {
4042        let mut editor = make_editor();
4043        let tx = dummy_tx();
4044        editor.set_text("foo foo".to_string());
4045        open_replace_bar(&mut editor, &tx, "foo", "xy");
4046        editor.handle_input(
4047            &InputEvent::Key(key(KeyCode::Enter, KeyModifiers::NONE)),
4048            &tx,
4049        );
4050        assert_eq!(editor.get_text(), "xy foo");
4051        editor.handle_input(
4052            &InputEvent::Key(key(KeyCode::Char('z'), KeyModifiers::CONTROL)),
4053            &tx,
4054        );
4055        assert_eq!(editor.get_text(), "foo foo");
4056        let current = editor.search.as_ref().unwrap().current_match();
4057        if let Some(((row, start), (_, end))) = current {
4058            let line = &editor.get_text()[..];
4059            let text: String = line
4060                .lines()
4061                .nth(row)
4062                .unwrap()
4063                .chars()
4064                .skip(start)
4065                .take(end - start)
4066                .collect();
4067            assert_eq!(
4068                text, "foo",
4069                "the highlight must sit on a real match, got {text:?}"
4070            );
4071        }
4072    }
4073
4074    /// vim `n` repeats the search with the bar closed, and must paint what it
4075    /// landed on — the highlight moved onto the bar when the module was
4076    /// extracted, and the closed-bar path lost it.
4077    #[test]
4078    fn vim_n_highlights_the_match_it_lands_on() {
4079        let mut editor = make_vim_editor();
4080        let tx = dummy_tx();
4081        editor.set_text("lo xx lo".to_string());
4082        editor.open_or_advance_search();
4083        for c in "lo".chars() {
4084            editor.handle_input(
4085                &InputEvent::Key(key(KeyCode::Char(c), KeyModifiers::NONE)),
4086                &tx,
4087            );
4088        }
4089        editor.handle_input(&InputEvent::Key(key(KeyCode::Esc, KeyModifiers::NONE)), &tx);
4090        editor.handle_input(
4091            &InputEvent::Key(key(KeyCode::Char('n'), KeyModifiers::NONE)),
4092            &tx,
4093        );
4094        assert_eq!(
4095            editor.selection,
4096            Some(((0, 6), (0, 8))),
4097            "`n` must paint the match it jumped to"
4098        );
4099    }
4100
4101    /// Closing the bar must clear the buffer's selection ANCHOR, not just the
4102    /// mirrored range. `search_forward` moves the cursor without touching
4103    /// `selection_start`, so a selection made before the search stays live but
4104    /// unpainted — and the next keystroke silently deletes it.
4105    #[test]
4106    fn closing_the_bar_cannot_leave_an_invisible_selection() {
4107        let mut editor = make_editor();
4108        let tx = dummy_tx();
4109        editor.set_text("foo bar baz".to_string());
4110        // Select the whole note, as Ctrl+A does.
4111        get_ta(&mut editor).select_all();
4112        editor.open_or_advance_search();
4113        for c in "bar".chars() {
4114            editor.handle_input(
4115                &InputEvent::Key(key(KeyCode::Char(c), KeyModifiers::NONE)),
4116                &tx,
4117            );
4118        }
4119        editor.handle_input(&InputEvent::Key(key(KeyCode::Esc, KeyModifiers::NONE)), &tx);
4120        editor.handle_input(
4121            &InputEvent::Key(key(KeyCode::Char('x'), KeyModifiers::NONE)),
4122            &tx,
4123        );
4124        assert!(
4125            editor.get_text().contains("foo"),
4126            "typing after the bar closed must not eat unhighlighted text, got {:?}",
4127            editor.get_text()
4128        );
4129    }
4130
4131    /// vim `>` over a selection pushes one history entry per row, so it took N
4132    /// undos. One vim command is one undo.
4133    #[test]
4134    fn vim_visual_indent_undoes_in_one_step() {
4135        let mut editor = make_vim_editor();
4136        let tx = dummy_tx();
4137        editor.set_text("a\nb\nc".to_string());
4138        for c in ['V', 'j', 'j', '>'] {
4139            editor.handle_input(
4140                &InputEvent::Key(key(KeyCode::Char(c), KeyModifiers::NONE)),
4141                &tx,
4142            );
4143        }
4144        let indented = editor.get_text();
4145        assert_ne!(indented, "a\nb\nc", "`>` must indent the selection");
4146        editor.handle_input(
4147            &InputEvent::Key(key(KeyCode::Char('u'), KeyModifiers::NONE)),
4148            &tx,
4149        );
4150        assert_eq!(
4151            editor.get_text(),
4152            "a\nb\nc",
4153            "one `u` must revert the whole indent, not one row"
4154        );
4155    }
4156
4157    /// vim: `>` over a charwise selection shifts every row it touches, with
4158    /// the ends mid-row.
4159    #[test]
4160    fn vim_charwise_visual_indent_shifts_the_touched_rows() {
4161        let mut editor = make_vim_editor();
4162        let tx = dummy_tx();
4163        editor.set_text("one\ntwo\nthree".to_string());
4164        for c in ['l', 'v', 'j'] {
4165            editor.handle_input(
4166                &InputEvent::Key(key(KeyCode::Char(c), KeyModifiers::NONE)),
4167                &tx,
4168            );
4169        }
4170        // Terminals report `>` as the shifted key it is.
4171        editor.handle_input(
4172            &InputEvent::Key(key(KeyCode::Char('>'), KeyModifiers::SHIFT)),
4173            &tx,
4174        );
4175        assert_eq!(editor.get_text(), "    one\n    two\nthree");
4176        assert_eq!(vim_mode(&editor), EditorMode::Normal);
4177        assert_eq!(editor.selection, None);
4178    }
4179
4180    /// Opening another note forgets the last Visual selection: `gv` there
4181    /// must not select rows the user never selected in that note.
4182    #[test]
4183    fn vim_gv_does_not_cross_notes() {
4184        let mut editor = make_vim_editor();
4185        let tx = dummy_tx();
4186        editor.set_text("a\nb\nc".to_string());
4187        for c in ['V', 'j'] {
4188            editor.handle_input(
4189                &InputEvent::Key(key(KeyCode::Char(c), KeyModifiers::NONE)),
4190                &tx,
4191            );
4192        }
4193        editor.handle_input(&InputEvent::Key(key(KeyCode::Esc, KeyModifiers::NONE)), &tx);
4194        editor.set_text("x\ny\nz".to_string());
4195        for c in ['g', 'v'] {
4196            editor.handle_input(
4197                &InputEvent::Key(key(KeyCode::Char(c), KeyModifiers::NONE)),
4198                &tx,
4199            );
4200        }
4201        assert_eq!(vim_mode(&editor), EditorMode::Normal);
4202        assert_eq!(editor.selection, None);
4203    }
4204
4205    fn press(editor: &mut TextEditorComponent, keys: &str) {
4206        let tx = dummy_tx();
4207        for c in keys.chars() {
4208            editor.handle_input(
4209                &InputEvent::Key(key(KeyCode::Char(c), KeyModifiers::NONE)),
4210                &tx,
4211            );
4212        }
4213    }
4214
4215    fn note(name: &str) -> kimun_core::nfs::VaultPath {
4216        kimun_core::nfs::VaultPath::note_path_from(name)
4217    }
4218
4219    /// Another note is another note, whatever its text: the one on screen
4220    /// before must leave no undo history behind. With the text-equality
4221    /// shortcut, `u` in note B restored note A's text — and autosave would
4222    /// have written it to B.
4223    #[test]
4224    fn a_note_with_the_same_text_does_not_inherit_undo_history() {
4225        let mut editor = make_vim_editor();
4226        editor.open_note(note("alpha"), "one".to_string());
4227        press(&mut editor, "x");
4228        assert_eq!(editor.get_text(), "ne");
4229        editor.open_note(note("beta"), "ne".to_string());
4230        press(&mut editor, "u");
4231        assert_eq!(editor.get_text(), "ne", "beta has nothing to undo");
4232    }
4233
4234    /// The common case of identical text: two empty notes. Redo must not
4235    /// reach into the other one either.
4236    #[test]
4237    fn empty_notes_do_not_share_history() {
4238        let mut editor = make_vim_editor();
4239        let tx = dummy_tx();
4240        editor.open_note(note("alpha"), String::new());
4241        press(&mut editor, "ix");
4242        editor.handle_input(&InputEvent::Key(key(KeyCode::Esc, KeyModifiers::NONE)), &tx);
4243        press(&mut editor, "u");
4244        assert_eq!(editor.get_text(), "");
4245        editor.open_note(note("beta"), String::new());
4246        editor.handle_input(
4247            &InputEvent::Key(key(KeyCode::Char('r'), KeyModifiers::CONTROL)),
4248            &tx,
4249        );
4250        assert_eq!(editor.get_text(), "", "beta has nothing to redo");
4251    }
4252
4253    /// Nor the selection, its highlight, or `gv`.
4254    #[test]
4255    fn a_note_with_the_same_text_does_not_inherit_the_selection() {
4256        let mut editor = make_vim_editor();
4257        let tx = dummy_tx();
4258        editor.open_note(note("alpha"), "a\nb\nc".to_string());
4259        press(&mut editor, "Vj");
4260        editor.handle_input(&InputEvent::Key(key(KeyCode::Esc, KeyModifiers::NONE)), &tx);
4261        press(&mut editor, "Vj");
4262        editor.open_note(note("beta"), "a\nb\nc".to_string());
4263        assert_eq!(vim_mode(&editor), EditorMode::Normal);
4264        assert_eq!(editor.selection, None);
4265        press(&mut editor, "gv");
4266        assert_eq!(
4267            vim_mode(&editor),
4268            EditorMode::Normal,
4269            "no selection to reselect"
4270        );
4271    }
4272
4273    /// The top screen row after rendering, trimmed.
4274    fn top_row(term: &ratatui::Terminal<ratatui::backend::TestBackend>, width: u16) -> String {
4275        (0..width)
4276            .filter_map(|x| term.backend().buffer().cell((x, 0)).map(|c| c.symbol()))
4277            .collect::<String>()
4278            .trim_end()
4279            .to_string()
4280    }
4281
4282    /// Another note opens scrolled to its top — also when its text is the
4283    /// same as the note that was scrolled down.
4284    #[test]
4285    fn another_note_opens_scrolled_to_the_top() {
4286        use ratatui::Terminal;
4287        use ratatui::backend::TestBackend;
4288        let mut editor = make_vim_editor();
4289        let theme = Theme::default();
4290        let mut term = Terminal::new(TestBackend::new(20, 8)).unwrap();
4291        let area = Rect::new(0, 0, 20, 8);
4292        let body: String = (0..400).map(|i| format!("line {i}\n")).collect();
4293        editor.open_note(note("alpha"), body.clone());
4294        press(&mut editor, "300G");
4295        term.draw(|f| editor.render(f, area, &theme, true)).unwrap();
4296        assert_ne!(top_row(&term, 20), "line 0", "alpha is scrolled down");
4297
4298        editor.open_note(note("beta"), body);
4299        term.draw(|f| editor.render(f, area, &theme, true)).unwrap();
4300        assert_eq!(top_row(&term, 20), "line 0");
4301    }
4302
4303    /// Opening the note already on screen is a reload, not a switch: its
4304    /// undo history (and `gv`) stay.
4305    #[test]
4306    fn reopening_the_same_note_keeps_its_history() {
4307        let mut editor = make_vim_editor();
4308        editor.open_note(note("alpha"), "one".to_string());
4309        press(&mut editor, "x");
4310        editor.open_note(note("alpha"), "ne".to_string());
4311        press(&mut editor, "u");
4312        assert_eq!(editor.get_text(), "one");
4313    }
4314
4315    /// A rename is the same note under a new name: history survives it, and
4316    /// opening it under the new name afterwards is still the same note.
4317    #[test]
4318    fn renaming_the_open_note_keeps_its_history() {
4319        let mut editor = make_vim_editor();
4320        editor.open_note(note("alpha"), "one".to_string());
4321        press(&mut editor, "x");
4322        editor.renamed_to(note("gamma"));
4323        editor.set_text("ne".to_string());
4324        editor.open_note(note("gamma"), "ne".to_string());
4325        press(&mut editor, "u");
4326        assert_eq!(editor.get_text(), "one");
4327    }
4328
4329    /// A paste from outside the key path (clipboard image) replaces the
4330    /// selection; `gv` afterwards lands where it was, not on stale numbers.
4331    #[test]
4332    fn vim_gv_after_external_paste_lands_where_the_selection_was() {
4333        let mut editor = make_vim_editor();
4334        let tx = dummy_tx();
4335        editor.set_text("a\nb\nc".to_string());
4336        for c in ['j', 'V', 'j'] {
4337            editor.handle_input(
4338                &InputEvent::Key(key(KeyCode::Char(c), KeyModifiers::NONE)),
4339                &tx,
4340            );
4341        }
4342        editor.take_selection_for_external_paste();
4343        for c in ['g', 'v'] {
4344            editor.handle_input(
4345                &InputEvent::Key(key(KeyCode::Char(c), KeyModifiers::NONE)),
4346                &tx,
4347            );
4348        }
4349        assert_eq!(vim_mode(&editor), EditorMode::VisualLine);
4350        let ta = get_ta(&mut editor);
4351        assert_eq!(ta.cursor().0, 1);
4352    }
4353
4354    /// `gv` from Normal must repaint the highlight, not only put the engine
4355    /// back in Visual.
4356    #[test]
4357    fn vim_gv_repaints_the_last_selection() {
4358        let mut editor = make_vim_editor();
4359        let tx = dummy_tx();
4360        editor.set_text("a\nb\nc".to_string());
4361        for c in ['V', 'j', '>', 'g', 'v'] {
4362            editor.handle_input(
4363                &InputEvent::Key(key(KeyCode::Char(c), KeyModifiers::NONE)),
4364                &tx,
4365            );
4366        }
4367        assert_eq!(vim_mode(&editor), EditorMode::VisualLine);
4368        assert!(editor.selection.is_some(), "gv must paint the highlight");
4369    }
4370
4371    /// End-to-end for the anchor invariant: `n` moved the cursor while a
4372    /// selection anchor was live, turning it into an unpainted selection that
4373    /// the next keystroke deleted. `"foo bar foo"` became `"Xfoo"`.
4374    #[test]
4375    fn vim_n_cannot_leave_an_invisible_selection() {
4376        let mut editor = make_vim_editor();
4377        let tx = dummy_tx();
4378        editor.set_text("foo bar foo".to_string());
4379        editor.open_or_advance_search();
4380        for c in "foo".chars() {
4381            editor.handle_input(
4382                &InputEvent::Key(key(KeyCode::Char(c), KeyModifiers::NONE)),
4383                &tx,
4384            );
4385        }
4386        editor.handle_input(&InputEvent::Key(key(KeyCode::Esc, KeyModifiers::NONE)), &tx);
4387        // A selection made after the bar closed, then `n`.
4388        get_ta(&mut editor).move_cursor(CursorMove::Jump(0, 0));
4389        get_ta(&mut editor).start_selection();
4390        get_ta(&mut editor).move_cursor(CursorMove::Jump(0, 3));
4391        editor.handle_input(
4392            &InputEvent::Key(key(KeyCode::Char('n'), KeyModifiers::NONE)),
4393            &tx,
4394        );
4395        for c in ['i', 'X'] {
4396            editor.handle_input(
4397                &InputEvent::Key(key(KeyCode::Char(c), KeyModifiers::NONE)),
4398                &tx,
4399            );
4400        }
4401        assert!(
4402            editor.get_text().contains("bar"),
4403            "typing after `n` must not eat unhighlighted text, got {:?}",
4404            editor.get_text()
4405        );
4406    }
4407
4408    /// End-to-end for the overlay move: task and needle decoration used to be
4409    /// painted from drawn cells and is now mapped from logical columns. The
4410    /// rendered result must be the same, which is the whole point — a list
4411    /// bullet is rendered, so the two coordinate spaces do not coincide.
4412    #[test]
4413    fn overlays_paint_where_the_post_pass_used_to() {
4414        use ratatui::Terminal;
4415        use ratatui::backend::TestBackend;
4416        use ratatui::layout::Position;
4417        let mut editor = make_editor();
4418        editor.set_text("find the needle here\n- [x] done task\n- [ ] open task".to_string());
4419        editor.set_search_needles(vec!["needle".to_string()]);
4420        let theme = Theme::default();
4421        let mut term = Terminal::new(TestBackend::new(40, 6)).unwrap();
4422        let area = Rect::new(0, 0, 40, 6);
4423        term.draw(|f| editor.render(f, area, &theme, false))
4424            .unwrap();
4425        let buf = term.backend().buffer();
4426
4427        let row: String = (0..40)
4428            .filter_map(|x| {
4429                buf.cell(Position::new(x, 0))
4430                    .map(|c| c.symbol().to_string())
4431            })
4432            .collect();
4433        let at = row.find("needle").expect("needle is on screen");
4434        let cell = buf.cell(Position::new(at as u16, 0)).unwrap();
4435        assert_eq!(
4436            cell.fg,
4437            theme.color_search_match.to_ratatui(),
4438            "the needle must still be emphasised"
4439        );
4440
4441        // The done task's text is struck; the open one's is not.
4442        let struck = |y: u16| {
4443            (0..40).any(|x| {
4444                buf.cell(Position::new(x, y)).is_some_and(|c| {
4445                    c.style()
4446                        .add_modifier
4447                        .contains(ratatui::style::Modifier::CROSSED_OUT)
4448                })
4449            })
4450        };
4451        assert!(struck(1), "a done task strikes its text");
4452        assert!(!struck(2), "an open task does not");
4453
4454        // And the checkbox itself carries the accent colour on both rows.
4455        for y in [1u16, 2] {
4456            assert!(
4457                (0..40).any(|x| buf
4458                    .cell(Position::new(x, y))
4459                    .is_some_and(|c| c.fg == theme.accent.to_ratatui())),
4460                "row {y} must have an accent-coloured checkbox"
4461            );
4462        }
4463    }
4464
4465    #[test]
4466    fn no_preview_without_a_replace_field() {
4467        let mut editor = make_editor();
4468        let tx = dummy_tx();
4469        editor.set_text("todo".to_string());
4470        editor.open_or_advance_search();
4471        for c in "todo".chars() {
4472            editor.handle_input(
4473                &InputEvent::Key(key(KeyCode::Char(c), KeyModifiers::NONE)),
4474                &tx,
4475            );
4476        }
4477        assert!(
4478            editor.replace_preview().is_none(),
4479            "a find-only bar previews nothing"
4480        );
4481    }
4482
4483    #[test]
4484    fn capture_expansion_is_gated_on_the_pattern_capturing() {
4485        let mut editor = make_editor();
4486        let tx = dummy_tx();
4487        // No capture group: `$1` is literal text, not an empty expansion.
4488        editor.set_text("cost".to_string());
4489        open_replace_bar(&mut editor, &tx, "cost", "$1");
4490        editor.handle_input(
4491            &InputEvent::Key(key(KeyCode::Char('a'), KeyModifiers::CONTROL)),
4492            &tx,
4493        );
4494        assert_eq!(editor.get_text(), "$1");
4495    }
4496
4497    #[test]
4498    fn the_bar_reserves_two_rows_only_while_replacing() {
4499        use ratatui::Terminal;
4500        use ratatui::backend::TestBackend;
4501        let mut editor = make_editor();
4502        let tx = dummy_tx();
4503        editor.set_text("todo".to_string());
4504        let theme = Theme::default();
4505        let mut term = Terminal::new(TestBackend::new(40, 10)).unwrap();
4506        let area = Rect::new(0, 0, 40, 10);
4507
4508        editor.open_or_advance_search();
4509        term.draw(|f| editor.render(f, area, &theme, true)).unwrap();
4510        assert_eq!(editor.rect.height, 9, "a find-only bar takes one row");
4511
4512        editor.handle_input(&InputEvent::Key(key(KeyCode::Tab, KeyModifiers::NONE)), &tx);
4513        term.draw(|f| editor.render(f, area, &theme, true)).unwrap();
4514        assert_eq!(
4515            editor.rect.height, 8,
4516            "the replace field takes a second row"
4517        );
4518    }
4519
4520    /// `guu` is a cut plus an insert, so it always landed in history as two
4521    /// entries and took two `u` presses to revert — `guu_undoes_in_one_step`
4522    /// in vim.rs documents that with a comment rather than fixing it. Now that
4523    /// grouping exists, the case operators use it and the name is true.
4524    #[test]
4525    fn guu_really_does_undo_in_one_step() {
4526        let mut editor = make_vim_editor();
4527        let tx = dummy_tx();
4528        editor.set_text("Mixed Case Line".to_string());
4529        for c in "guu".chars() {
4530            editor.handle_input(
4531                &InputEvent::Key(key(KeyCode::Char(c), KeyModifiers::NONE)),
4532                &tx,
4533            );
4534        }
4535        assert_eq!(editor.get_text(), "mixed case line");
4536        editor.handle_input(
4537            &InputEvent::Key(key(KeyCode::Char('u'), KeyModifiers::NONE)),
4538            &tx,
4539        );
4540        assert_eq!(editor.get_text(), "Mixed Case Line");
4541    }
4542
4543    /// Vim's `u` must also take a whole **undo group**. The engine performs the
4544    /// undo inside its own command apply, so the host has to peek before
4545    /// dispatch and finish the group afterwards — a path the Ctrl+Z tests
4546    /// above do not touch.
4547    #[test]
4548    fn vim_u_undoes_a_whole_replace() {
4549        let mut editor = make_vim_editor();
4550        let tx = dummy_tx();
4551        editor.set_text("todo and todo".to_string());
4552        open_replace_bar(&mut editor, &tx, "todo", "done");
4553        editor.handle_input(
4554            &InputEvent::Key(key(KeyCode::Char('a'), KeyModifiers::CONTROL)),
4555            &tx,
4556        );
4557        assert_eq!(editor.get_text(), "done and done");
4558
4559        // Esc closes the bar, returning keys to the vim engine in Normal mode.
4560        editor.handle_input(&InputEvent::Key(key(KeyCode::Esc, KeyModifiers::NONE)), &tx);
4561        editor.handle_input(
4562            &InputEvent::Key(key(KeyCode::Char('u'), KeyModifiers::NONE)),
4563            &tx,
4564        );
4565        assert_eq!(editor.get_text(), "todo and todo");
4566    }
4567
4568    // ── Undo grouping ────────────────────────────────────────────────────────
4569
4570    fn type_out(editor: &mut TextEditorComponent, tx: &AppTx, text: &str) {
4571        use ratatui::crossterm::event::KeyEvent;
4572        for c in text.chars() {
4573            let code = if c == '\n' {
4574                KeyCode::Enter
4575            } else {
4576                KeyCode::Char(c)
4577            };
4578            editor.handle_textarea_key(&KeyEvent::new(code, KeyModifiers::NONE), tx);
4579        }
4580    }
4581
4582    #[test]
4583    fn undo_takes_back_a_word_not_a_letter() {
4584        // The incumbent recorded one history entry per character, so leaving a
4585        // sentence took as many presses as it had letters.
4586        let mut editor = make_editor();
4587        let tx = dummy_tx();
4588        editor.set_text(String::new());
4589        type_out(&mut editor, &tx, "hello world");
4590        assert_eq!(editor.get_text(), "hello world");
4591
4592        assert!(get_ta(&mut editor).undo());
4593        assert_eq!(editor.get_text(), "hello ", "the last word goes whole");
4594        assert!(get_ta(&mut editor).undo());
4595        assert_eq!(editor.get_text(), "", "and so does the first");
4596    }
4597
4598    #[test]
4599    fn a_cursor_move_separates_two_runs() {
4600        let mut editor = make_editor();
4601        let tx = dummy_tx();
4602        editor.set_text(String::new());
4603        type_out(&mut editor, &tx, "ab");
4604        arrow(&mut editor, &tx, KeyCode::Home);
4605        type_out(&mut editor, &tx, "cd");
4606        assert_eq!(editor.get_text(), "cdab");
4607
4608        assert!(get_ta(&mut editor).undo());
4609        assert_eq!(
4610            editor.get_text(),
4611            "ab",
4612            "only what was typed after the move comes back off"
4613        );
4614    }
4615
4616    #[test]
4617    fn backspacing_to_fix_a_typo_is_its_own_action() {
4618        use ratatui::crossterm::event::KeyEvent;
4619        let mut editor = make_editor();
4620        let tx = dummy_tx();
4621        editor.set_text(String::new());
4622        type_out(&mut editor, &tx, "helllo");
4623        editor.handle_textarea_key(&KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE), &tx);
4624        assert_eq!(editor.get_text(), "helll");
4625
4626        assert!(get_ta(&mut editor).undo());
4627        assert_eq!(
4628            editor.get_text(),
4629            "helllo",
4630            "the delete undoes on its own, without taking the typing with it"
4631        );
4632    }
4633
4634    #[test]
4635    fn an_undo_between_two_runs_separates_them() {
4636        // Ctrl+Z is claimed before the plain key table, so the run has to be ended
4637        // where every key passes rather than where typing is applied.
4638        let mut editor = make_editor();
4639        let tx = dummy_tx();
4640        editor.set_text(String::new());
4641        type_out(&mut editor, &tx, "ab");
4642        assert!(get_ta(&mut editor).undo());
4643        assert_eq!(editor.get_text(), "");
4644        type_out(&mut editor, &tx, "cd");
4645        assert_eq!(editor.get_text(), "cd");
4646        assert!(get_ta(&mut editor).undo());
4647        assert_eq!(
4648            editor.get_text(),
4649            "",
4650            "the second run is its own group, not an extension of an undone one"
4651        );
4652    }
4653
4654    #[test]
4655    fn a_save_closes_the_open_group() {
4656        // CONTEXT.md: "a group never spans a save, and one undo after saving
4657        // lands on exactly what is on disk". The save arrives by a path that is
4658        // not a keystroke, so nothing on the key path could have closed it.
4659        let mut editor = make_editor();
4660        let tx = dummy_tx();
4661        type_out(&mut editor, &tx, "abc");
4662        let saved = editor.get_text();
4663        editor.mark_saved(saved);
4664        // Immediately, well inside the idle window, and mid-"word" so the
4665        // boundary rule cannot close the run either.
4666        type_out(&mut editor, &tx, "def");
4667
4668        assert!(get_ta(&mut editor).undo());
4669        assert_eq!(
4670            editor.get_text(),
4671            "abc",
4672            "one undo lands on what was saved, not before it"
4673        );
4674    }
4675
4676    #[test]
4677    fn a_stale_save_completion_does_not_close_the_group() {
4678        // The other half of the same rule: `mark_saved_at_revision` is a
4679        // documented no-op when the revision moved on, and an action that did
4680        // nothing must not split the user's word.
4681        let mut editor = make_editor();
4682        let tx = dummy_tx();
4683        type_out(&mut editor, &tx, "abc");
4684        let stale = NonZeroU64::new(1).expect("nonzero");
4685        editor.mark_saved_at_revision(stale);
4686        type_out(&mut editor, &tx, "def");
4687
4688        assert!(get_ta(&mut editor).undo());
4689        assert_eq!(
4690            editor.get_text(),
4691            "",
4692            "the run carried on across a completion that marked nothing"
4693        );
4694    }
4695
4696    #[test]
4697    fn a_second_vim_insert_session_is_its_own_group() {
4698        // The session flag was refreshed only on the pass-through path, which
4699        // `Esc` never takes, so it latched true on the first `i` and every later
4700        // session folded into whatever entry preceded it.
4701        use ratatui::crossterm::event::KeyEvent;
4702        let mut editor = make_vim_editor();
4703        let tx = dummy_tx();
4704        editor.set_text(String::new());
4705        let press = |editor: &mut TextEditorComponent, code| {
4706            let _ = editor.handle_input(
4707                &InputEvent::Key(KeyEvent::new(code, KeyModifiers::NONE)),
4708                &tx,
4709            );
4710        };
4711        press(&mut editor, KeyCode::Char('i'));
4712        for c in "one".chars() {
4713            press(&mut editor, KeyCode::Char(c));
4714        }
4715        press(&mut editor, KeyCode::Esc);
4716        press(&mut editor, KeyCode::Char('i'));
4717        for c in "two".chars() {
4718            press(&mut editor, KeyCode::Char(c));
4719        }
4720        press(&mut editor, KeyCode::Esc);
4721        // `Esc` steps the cursor left, so the second `i` inserts before the
4722        // final `e` — the position is incidental, the grouping is the point.
4723        assert_eq!(editor.get_text(), "ontwoe");
4724
4725        assert!(get_ta(&mut editor).undo());
4726        assert_eq!(
4727            editor.get_text(),
4728            "one",
4729            "`u` takes back the second session only"
4730        );
4731    }
4732
4733    #[test]
4734    fn a_vim_insert_session_undoes_whole() {
4735        use ratatui::crossterm::event::KeyEvent;
4736        let mut editor = make_vim_editor();
4737        let tx = dummy_tx();
4738        editor.set_text(String::new());
4739        // `i` enters Insert; the text then flows through the same plain key path.
4740        let press = |editor: &mut TextEditorComponent, code| {
4741            let _ = editor.handle_input(
4742                &InputEvent::Key(KeyEvent::new(code, KeyModifiers::NONE)),
4743                &tx,
4744            );
4745        };
4746        press(&mut editor, KeyCode::Char('i'));
4747        for c in "hello world".chars() {
4748            press(&mut editor, KeyCode::Char(c));
4749        }
4750        press(&mut editor, KeyCode::Esc);
4751        assert_eq!(editor.get_text(), "hello world");
4752
4753        assert!(get_ta(&mut editor).undo());
4754        assert_eq!(
4755            editor.get_text(),
4756            "",
4757            "vim's `u` takes back the whole session, word boundaries included"
4758        );
4759    }
4760
4761    // ── Arrow keys move by drawn line ────────────────────────────────────────
4762
4763    /// Render once so the view has a layout for the width under test.
4764    fn lay_out(editor: &mut TextEditorComponent, width: u16, height: u16) {
4765        use ratatui::Terminal;
4766        use ratatui::backend::TestBackend;
4767        let theme = Theme::default();
4768        let mut term = Terminal::new(TestBackend::new(width, height)).unwrap();
4769        let area = Rect::new(0, 0, width, height);
4770        term.draw(|f| editor.render(f, area, &theme, true)).unwrap();
4771    }
4772
4773    fn arrow(editor: &mut TextEditorComponent, tx: &AppTx, code: KeyCode) {
4774        use ratatui::crossterm::event::KeyEvent;
4775        editor.handle_textarea_key(&KeyEvent::new(code, KeyModifiers::NONE), tx);
4776    }
4777
4778    #[test]
4779    fn down_moves_one_drawn_line_not_one_row() {
4780        // The whole point of owning both the cursor and the layout. A paragraph
4781        // that wraps into four drawn lines takes four presses to leave, not one.
4782        let mut editor = make_editor();
4783        let tx = dummy_tx();
4784        editor.set_text(
4785            "aaaa bbbb cccc dddd
4786second row"
4787                .to_string(),
4788        );
4789        lay_out(&mut editor, 6, 10);
4790
4791        get_ta(&mut editor).jump_to(0, 0);
4792        arrow(&mut editor, &tx, KeyCode::Down);
4793        assert_eq!(
4794            get_ta(&mut editor).cursor(),
4795            (0, 5),
4796            "still inside the first row, on its second drawn line"
4797        );
4798        arrow(&mut editor, &tx, KeyCode::Down);
4799        assert_eq!(get_ta(&mut editor).cursor(), (0, 10));
4800        arrow(&mut editor, &tx, KeyCode::Down);
4801        assert_eq!(get_ta(&mut editor).cursor(), (0, 15));
4802        arrow(&mut editor, &tx, KeyCode::Down);
4803        assert_eq!(
4804            get_ta(&mut editor).cursor().0,
4805            1,
4806            "and only the fourth press reaches the next row"
4807        );
4808    }
4809
4810    #[test]
4811    fn up_and_down_are_symmetric_across_a_wrap() {
4812        let mut editor = make_editor();
4813        let tx = dummy_tx();
4814        editor.set_text("aaaa bbbb cccc".to_string());
4815        lay_out(&mut editor, 6, 10);
4816
4817        get_ta(&mut editor).jump_to(0, 0);
4818        arrow(&mut editor, &tx, KeyCode::Down);
4819        let middle = get_ta(&mut editor).cursor();
4820        arrow(&mut editor, &tx, KeyCode::Up);
4821        assert_eq!(get_ta(&mut editor).cursor(), (0, 0));
4822        assert_eq!(middle, (0, 5));
4823    }
4824
4825    #[test]
4826    fn an_arrow_against_a_stale_layout_falls_back_instead_of_panicking() {
4827        // The App loop (`app::run_app`) drains queued input without redrawing between events, so an
4828        // edit and an arrow can be processed in one batch. Shrinking a row does
4829        // not change the row COUNT, which is all the old guard compared — and the
4830        // layout's byte ranges then sliced past the end of the shortened row.
4831        let mut editor = make_editor();
4832        let tx = dummy_tx();
4833        editor.set_text("abcd\nefgh".to_string());
4834        lay_out(&mut editor, 20, 10);
4835
4836        get_ta(&mut editor).jump_to(0, 4);
4837        for _ in 0..3 {
4838            get_ta(&mut editor).delete_char();
4839        }
4840        assert_eq!(get_ta(&mut editor).rows(), &["a", "efgh"]);
4841
4842        // The move falls back to a logical one rather than reading the layout.
4843        arrow(&mut editor, &tx, KeyCode::Down);
4844        assert_eq!(get_ta(&mut editor).cursor().0, 1, "still moved down a row");
4845    }
4846
4847    #[test]
4848    fn an_action_between_arrows_forgets_the_goal_cell() {
4849        // The other side of `a_run_of_arrows_keeps_its_goal_cell`: the column is
4850        // borrowed for a run of arrows and for nothing else, so anything that is
4851        // not one forgets it. Driven here through a save, because that is a path
4852        // with no keystroke on it at all — the same choke point serves the click,
4853        // the find and the vim motion.
4854        let mut editor = make_editor();
4855        let tx = dummy_tx();
4856        editor.set_text(
4857            "aaaaaaaa
4858bb
4859cccccccc"
4860                .to_string(),
4861        );
4862        lay_out(&mut editor, 20, 10);
4863
4864        get_ta(&mut editor).jump_to(0, 7);
4865        arrow(&mut editor, &tx, KeyCode::Down);
4866        assert_eq!(
4867            get_ta(&mut editor).cursor(),
4868            (1, 2),
4869            "clamped to the short row"
4870        );
4871
4872        let saved = editor.get_text();
4873        editor.mark_saved(saved);
4874
4875        arrow(&mut editor, &tx, KeyCode::Down);
4876        assert_eq!(
4877            get_ta(&mut editor).cursor(),
4878            (2, 2),
4879            "the goal was forgotten, so the third row keeps the clamped column"
4880        );
4881    }
4882
4883    #[test]
4884    fn a_run_of_arrows_keeps_its_goal_cell() {
4885        // Passing through a shorter drawn line clamps, but does not forget: the
4886        // column is borrowed for one line rather than lost.
4887        let mut editor = make_editor();
4888        let tx = dummy_tx();
4889        editor.set_text(
4890            "aaaaaaaa
4891bb
4892cccccccc"
4893                .to_string(),
4894        );
4895        lay_out(&mut editor, 20, 10);
4896
4897        get_ta(&mut editor).jump_to(0, 7);
4898        arrow(&mut editor, &tx, KeyCode::Down);
4899        assert_eq!(
4900            get_ta(&mut editor).cursor(),
4901            (1, 2),
4902            "clamped to the short row"
4903        );
4904        arrow(&mut editor, &tx, KeyCode::Down);
4905        assert_eq!(
4906            get_ta(&mut editor).cursor(),
4907            (2, 7),
4908            "and back out to the cell the run still wants"
4909        );
4910    }
4911
4912    #[test]
4913    fn another_key_ends_the_run() {
4914        let mut editor = make_editor();
4915        let tx = dummy_tx();
4916        editor.set_text(
4917            "aaaaaaaa
4918bb
4919cccccccc"
4920                .to_string(),
4921        );
4922        lay_out(&mut editor, 20, 10);
4923
4924        get_ta(&mut editor).jump_to(0, 7);
4925        arrow(&mut editor, &tx, KeyCode::Down);
4926        arrow(&mut editor, &tx, KeyCode::Home);
4927        arrow(&mut editor, &tx, KeyCode::Down);
4928        assert_eq!(
4929            get_ta(&mut editor).cursor(),
4930            (2, 0),
4931            "Home set a new goal; the old one is gone"
4932        );
4933    }
4934
4935    #[test]
4936    fn shift_down_extends_by_a_drawn_line() {
4937        let mut editor = make_editor();
4938        let tx = dummy_tx();
4939        editor.set_text("aaaa bbbb cccc".to_string());
4940        lay_out(&mut editor, 6, 10);
4941
4942        get_ta(&mut editor).jump_to(0, 0);
4943        editor.handle_textarea_key(
4944            &ratatui::crossterm::event::KeyEvent::new(KeyCode::Down, KeyModifiers::SHIFT),
4945            &tx,
4946        );
4947        assert_eq!(
4948            get_ta(&mut editor).selection_range(),
4949            Some(((0, 0), (0, 5)))
4950        );
4951    }
4952
4953    /// Helper: construct a vim-backend editor.
4954    fn make_vim_editor() -> TextEditorComponent {
4955        let mut settings = crate::settings::AppSettings::default();
4956        settings.editor_backend = crate::settings::EditorBackendSetting::Vim;
4957        TextEditorComponent::new(KeyBindings::empty(), &settings)
4958    }
4959
4960    /// Helper: extract the current vim EditorMode, panicking if the backend
4961    /// is not a vim textarea (so test failures are obvious).
4962    fn vim_mode(editor: &TextEditorComponent) -> EditorMode {
4963        match &editor.backend {
4964            BackendState::Textarea(tb) => match &tb.input {
4965                backend::InputInterpreter::Vim(e) => e.mode().clone(),
4966                _ => panic!("expected Vim input interpreter"),
4967            },
4968            _ => panic!("expected Textarea backend"),
4969        }
4970    }
4971
4972    /// Regression: pasting a URL over a vim charwise Visual selection made with
4973    /// `ve` (cursor lands ON the last char) must wrap the WHOLE word as a
4974    /// markdown link. ratatui's `selection_range()` is half-open and stops
4975    /// before the char under the cursor, so without the inclusive extension in
4976    /// `paste_text` the last letter was left dangling (`[hell](url)o`).
4977    #[test]
4978    fn vim_visual_paste_url_wraps_whole_selected_word() {
4979        let mut editor = make_vim_editor();
4980        let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
4981        editor.set_text("hello world".to_string());
4982        // `v` enters charwise Visual at col 0, `e` extends to the end of the
4983        // word — cursor ends ON the 'o' of "hello".
4984        editor.handle_input(
4985            &InputEvent::Key(key(KeyCode::Char('v'), KeyModifiers::NONE)),
4986            &tx,
4987        );
4988        editor.handle_input(
4989            &InputEvent::Key(key(KeyCode::Char('e'), KeyModifiers::NONE)),
4990            &tx,
4991        );
4992        assert_eq!(vim_mode(&editor), EditorMode::Visual);
4993        editor.paste_text("https://example.com", &tx);
4994        assert_eq!(
4995            editor.get_text(),
4996            "[hello](https://example.com) world",
4997            "the whole selected word (including the char under the cursor) must be wrapped"
4998        );
4999    }
5000
5001    /// Regression: applying Bold over a vim charwise Visual selection made with
5002    /// `ve` must wrap the WHOLE word. The formatting action is dispatched at the
5003    /// app-screen keybinding layer (before the vim engine), so it reads the
5004    /// half-open textarea selection directly — without the inclusive extension
5005    /// the last letter was left outside the markers (`**hell**o`).
5006    #[test]
5007    fn vim_visual_bold_wraps_whole_selected_word() {
5008        let mut editor = make_vim_editor();
5009        let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
5010        editor.set_text("hello world".to_string());
5011        editor.handle_input(
5012            &InputEvent::Key(key(KeyCode::Char('v'), KeyModifiers::NONE)),
5013            &tx,
5014        );
5015        editor.handle_input(
5016            &InputEvent::Key(key(KeyCode::Char('e'), KeyModifiers::NONE)),
5017            &tx,
5018        );
5019        assert_eq!(vim_mode(&editor), EditorMode::Visual);
5020        editor.apply_text_action(TextAction::Bold);
5021        assert_eq!(
5022            editor.get_text(),
5023            "**hello** world",
5024            "the whole selected word (including the char under the cursor) must be wrapped"
5025        );
5026    }
5027
5028    /// Regression: copy is read-only over a vim charwise Visual selection.
5029    /// Every clipboard action reports its outcome, paste included. Before this,
5030    /// Ctrl+V was the only one that said nothing, so the footer was left showing
5031    /// the raw chord echo — indistinguishable from an unbound key.
5032    ///
5033    /// Headless CI has no clipboard, so accept either the success message or a
5034    /// clipboard error; what must never happen is silence.
5035    #[test]
5036    fn paste_reports_its_outcome() {
5037        let mut editor = make_editor();
5038        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
5039        editor.set_text("x".to_string());
5040        editor.paste_from_clipboard(&tx);
5041        let reported = std::iter::from_fn(|| rx.try_recv().ok()).any(|e| {
5042            matches!(e, AppEvent::FlashMessage(m)
5043                if m == "pasted" || m == "clipboard is empty" || m.starts_with("clipboard: "))
5044        });
5045        assert!(reported, "a paste attempt must always report something");
5046    }
5047
5048    /// The image-paste path bypasses the editor's key handling entirely (the
5049    /// screen layer owns it, because only it can reach the vault), so it has to
5050    /// reconcile the engine itself. Before this, an image pasted in Visual mode
5051    /// left the engine in Visual with a selection that no longer existed —
5052    /// every subsequent motion silently extended a ghost.
5053    #[test]
5054    fn external_paste_drops_the_selection_and_leaves_visual() {
5055        let mut editor = make_vim_editor();
5056        let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
5057        editor.set_text("hello world".to_string());
5058        editor.handle_input(
5059            &InputEvent::Key(key(KeyCode::Char('v'), KeyModifiers::NONE)),
5060            &tx,
5061        );
5062        editor.handle_input(
5063            &InputEvent::Key(key(KeyCode::Char('e'), KeyModifiers::NONE)),
5064            &tx,
5065        );
5066        assert_eq!(vim_mode(&editor), EditorMode::Visual);
5067
5068        editor.take_selection_for_external_paste();
5069
5070        assert_eq!(
5071            vim_mode(&editor),
5072            EditorMode::Normal,
5073            "the engine must not keep believing it is in Visual"
5074        );
5075        assert_eq!(
5076            get_ta(&mut editor).selection_range(),
5077            None,
5078            "the selection the incoming content replaces must be gone"
5079        );
5080        assert_eq!(
5081            editor.get_text(),
5082            " world",
5083            "the inclusive visual range is what gets replaced"
5084        );
5085    }
5086
5087    /// Linewise Visual covers whole rows, so a paste from outside the key
5088    /// path replaces all of them — not the anchor-to-cursor columns the
5089    /// buffer's selection happens to span.
5090    #[test]
5091    fn external_paste_over_visual_line_replaces_the_whole_rows() {
5092        let mut editor = make_vim_editor();
5093        let tx = dummy_tx();
5094        editor.set_text("a\nbb\ncc\nd".to_string());
5095        for c in ['j', 'l', 'V', 'j'] {
5096            editor.handle_input(
5097                &InputEvent::Key(key(KeyCode::Char(c), KeyModifiers::NONE)),
5098                &tx,
5099            );
5100        }
5101        editor.take_selection_for_external_paste();
5102        assert_eq!(editor.get_text(), "a\n\nd");
5103        assert_eq!(vim_mode(&editor), EditorMode::Normal);
5104    }
5105
5106    /// A terminal paste over `V` rows replaces them, as Ctrl+V does.
5107    #[test]
5108    fn bracketed_paste_over_visual_line_replaces_the_whole_rows() {
5109        let mut editor = make_vim_editor();
5110        let tx = dummy_tx();
5111        editor.set_text("a\nbb\ncc\nd".to_string());
5112        for c in ['j', 'l', 'V', 'j'] {
5113            editor.handle_input(
5114                &InputEvent::Key(key(KeyCode::Char(c), KeyModifiers::NONE)),
5115                &tx,
5116            );
5117        }
5118        editor.paste_text("X", &tx);
5119        assert_eq!(editor.get_text(), "a\nX\nd");
5120    }
5121
5122    /// Charwise: the char under the cursor is inside the selection (vim), so
5123    /// it is inside the wrap.
5124    #[test]
5125    fn surround_over_charwise_visual_includes_the_cursor_char() {
5126        let mut editor = make_vim_editor();
5127        let tx = dummy_tx();
5128        editor.set_text("hello world".to_string());
5129        for c in ['v', 'e', '*'] {
5130            editor.handle_input(
5131                &InputEvent::Key(key(KeyCode::Char(c), KeyModifiers::NONE)),
5132                &tx,
5133            );
5134        }
5135        assert_eq!(editor.get_text(), "*hello* world");
5136    }
5137
5138    /// `v` on a multi-codepoint emoji then `*` wraps the whole emoji — not
5139    /// half of it, and not a literal `*` typed in Normal.
5140    #[test]
5141    fn surround_over_an_emoji_wraps_all_of_it() {
5142        let mut editor = make_vim_editor();
5143        let tx = dummy_tx();
5144        editor.set_text("a❤️b".to_string());
5145        for c in ['l', 'v', '*'] {
5146            editor.handle_input(
5147                &InputEvent::Key(key(KeyCode::Char(c), KeyModifiers::NONE)),
5148                &tx,
5149            );
5150        }
5151        assert_eq!(editor.get_text(), "a*❤️*b");
5152    }
5153
5154    /// The charwise highlight covers the whole character under the cursor.
5155    #[test]
5156    fn visual_highlight_covers_the_whole_emoji() {
5157        let mut editor = make_vim_editor();
5158        let tx = dummy_tx();
5159        editor.set_text("a👍🏽b".to_string());
5160        for c in ['l', 'v'] {
5161            editor.handle_input(
5162                &InputEvent::Key(key(KeyCode::Char(c), KeyModifiers::NONE)),
5163                &tx,
5164            );
5165        }
5166        assert_eq!(editor.selection, Some(((0, 1), (0, 3))));
5167    }
5168
5169    /// A terminal paste over a Visual selection ends Visual: afterwards `x`
5170    /// is a Normal-mode `x`, not an operator on an empty selection that takes
5171    /// the whole line.
5172    #[test]
5173    fn bracketed_paste_over_visual_returns_to_normal() {
5174        let mut editor = make_vim_editor();
5175        let tx = dummy_tx();
5176        editor.set_text("keep\nabc".to_string());
5177        press(&mut editor, "jV");
5178        editor.paste_text("xyz", &tx);
5179        assert_eq!(vim_mode(&editor), EditorMode::Normal);
5180        assert_eq!(editor.get_text(), "keep\nxyz");
5181        press(&mut editor, "0x");
5182        assert_eq!(editor.get_text(), "keep\nyz");
5183    }
5184
5185    /// Emphasis over a charwise selection keeps the inner text selected in
5186    /// Visual, so a second emphasis wraps exactly it — not its closing marker.
5187    #[test]
5188    fn two_emphases_over_a_visual_selection_nest_cleanly() {
5189        let mut editor = make_vim_editor();
5190        editor.set_text("hello world".to_string());
5191        press(&mut editor, "ve");
5192        editor.apply_text_action(TextAction::Bold);
5193        assert_eq!(vim_mode(&editor), EditorMode::Visual);
5194        editor.apply_text_action(TextAction::Strikethrough);
5195        assert_eq!(editor.get_text(), "**~~hello~~** world");
5196    }
5197
5198    /// `[` `[` over a Visual selection builds a wikilink: the first wrap
5199    /// leaves the inner text selected *in Visual*, where the second `[` wraps
5200    /// again.
5201    #[test]
5202    fn surround_chains_into_a_wikilink_from_visual() {
5203        let mut editor = make_vim_editor();
5204        editor.set_text("hello world".to_string());
5205        press(&mut editor, "ve[");
5206        assert_eq!(vim_mode(&editor), EditorMode::Visual);
5207        press(&mut editor, "[");
5208        assert!(
5209            editor.get_text().starts_with("[[hello]]"),
5210            "{:?}",
5211            editor.get_text()
5212        );
5213    }
5214
5215    /// After the wrap, the mode and the highlight agree: a motion extends a
5216    /// Visual selection, not an invisible one under a Normal footer.
5217    #[test]
5218    fn a_motion_after_a_surround_extends_the_visual_selection() {
5219        let mut editor = make_vim_editor();
5220        editor.set_text("hello world".to_string());
5221        press(&mut editor, "ve*");
5222        assert_eq!(editor.get_text(), "*hello* world");
5223        assert_eq!(vim_mode(&editor), EditorMode::Visual);
5224        assert_eq!(editor.selection, Some(((0, 1), (0, 6))), "the inner text");
5225    }
5226
5227    /// A wrap typed in Insert (over a mouse selection) stays in Insert —
5228    /// only a Visual hand-off becomes a Visual selection.
5229    #[test]
5230    fn a_surround_typed_in_insert_stays_in_insert() {
5231        let mut editor = make_vim_editor();
5232        editor.set_text("hello world".to_string());
5233        press(&mut editor, "i");
5234        {
5235            let ta = get_ta(&mut editor);
5236            ta.move_cursor(CursorMove::Jump(0, 0));
5237            ta.start_selection();
5238            ta.move_cursor(CursorMove::Jump(0, 5));
5239        }
5240        press(&mut editor, "(");
5241        assert_eq!(editor.get_text(), "(hello) world");
5242        assert_eq!(vim_mode(&editor), EditorMode::Insert);
5243    }
5244
5245    fn mouse(kind: ratatui::crossterm::event::MouseEventKind, column: u16) -> InputEvent {
5246        InputEvent::Mouse(ratatui::crossterm::event::MouseEvent {
5247            kind,
5248            column,
5249            row: 0,
5250            modifiers: KeyModifiers::NONE,
5251        })
5252    }
5253
5254    fn rendered_vim_editor(text: &str) -> TextEditorComponent {
5255        use ratatui::Terminal;
5256        use ratatui::backend::TestBackend;
5257        let mut editor = make_vim_editor();
5258        editor.set_text(text.to_string());
5259        let mut term = Terminal::new(TestBackend::new(30, 4)).unwrap();
5260        term.draw(|f| editor.render(f, Rect::new(0, 0, 30, 4), &Theme::default(), true))
5261            .unwrap();
5262        editor
5263    }
5264
5265    /// Right-click in Visual is Ctrl-C: same copy, and the same return to
5266    /// Normal — for `v` and for `V`, an empty line included.
5267    #[test]
5268    fn right_click_in_visual_copies_like_ctrl_c() {
5269        use ratatui::crossterm::event::{MouseButton, MouseEventKind};
5270        let tx = dummy_tx();
5271        for (text, keys) in [("hello world", "ve"), ("hello", "V"), ("\nx", "V")] {
5272            let mut editor = rendered_vim_editor(text);
5273            press(&mut editor, keys);
5274            editor.handle_input(&mouse(MouseEventKind::Down(MouseButton::Right), 1), &tx);
5275            assert_eq!(
5276                vim_mode(&editor),
5277                EditorMode::Normal,
5278                "after `{keys}` on {text:?}"
5279            );
5280            assert_eq!(editor.selection, None);
5281        }
5282    }
5283
5284    /// A drag selects what was dragged over: the highlight, and what `d`
5285    /// takes, end where the mouse stopped — forwards and backwards.
5286    #[test]
5287    fn a_drag_selects_exactly_what_was_dragged() {
5288        use ratatui::crossterm::event::{MouseButton, MouseEventKind};
5289        let tx = dummy_tx();
5290        for (from, to) in [(0, 5), (5, 0)] {
5291            let mut editor = rendered_vim_editor("hello world");
5292            editor.handle_input(&mouse(MouseEventKind::Down(MouseButton::Left), from), &tx);
5293            editor.handle_input(&mouse(MouseEventKind::Drag(MouseButton::Left), to), &tx);
5294            assert_eq!(vim_mode(&editor), EditorMode::Visual);
5295            assert_eq!(editor.selection, Some(((0, 0), (0, 5))), "drag {from}→{to}");
5296            press(&mut editor, "d");
5297            assert_eq!(editor.get_text(), " world", "drag {from}→{to}");
5298        }
5299    }
5300
5301    /// A left click ends Visual, as it always did: the click's empty
5302    /// selection is not a one-character (or one-row) Visual selection.
5303    #[test]
5304    fn a_click_leaves_visual() {
5305        use ratatui::crossterm::event::{MouseButton, MouseEventKind};
5306        let tx = dummy_tx();
5307        for keys in ["vll", "V"] {
5308            let mut editor = rendered_vim_editor("hello world");
5309            press(&mut editor, keys);
5310            editor.handle_input(&mouse(MouseEventKind::Down(MouseButton::Left), 8), &tx);
5311            assert_eq!(vim_mode(&editor), EditorMode::Normal, "after `{keys}`");
5312            press(&mut editor, "x");
5313            assert_eq!(
5314                editor.get_text(),
5315                "hello wold",
5316                "after `{keys}`: a plain `x`"
5317            );
5318        }
5319    }
5320
5321    /// The click also leaves `gv` the selection it ended.
5322    #[test]
5323    fn gv_after_a_click_reselects_what_the_click_ended() {
5324        use ratatui::crossterm::event::{MouseButton, MouseEventKind};
5325        let tx = dummy_tx();
5326        let mut editor = rendered_vim_editor("hello world");
5327        press(&mut editor, "vll");
5328        editor.handle_input(&mouse(MouseEventKind::Down(MouseButton::Left), 8), &tx);
5329        press(&mut editor, "gv");
5330        assert_eq!(vim_mode(&editor), EditorMode::Visual);
5331        assert_eq!(editor.selection, Some(((0, 0), (0, 3))));
5332    }
5333
5334    /// Scrolling is not a selection gesture: Visual survives it, even over
5335    /// an empty row, where the Visual range itself is empty.
5336    #[test]
5337    fn scrolling_keeps_visual() {
5338        use ratatui::crossterm::event::MouseEventKind;
5339        let tx = dummy_tx();
5340        for keys in ["jV", "jv", "ve"] {
5341            let mut editor = rendered_vim_editor("a\n\nb");
5342            press(&mut editor, keys);
5343            let mode = vim_mode(&editor);
5344            editor.handle_input(&mouse(MouseEventKind::ScrollDown, 1), &tx);
5345            assert_eq!(vim_mode(&editor), mode, "after `{keys}`");
5346        }
5347    }
5348
5349    /// A drag with no press in this editor behind it — one that began
5350    /// elsewhere, or before another note was opened — selects nothing.
5351    #[test]
5352    fn a_drag_without_its_own_press_selects_nothing() {
5353        use ratatui::crossterm::event::{MouseButton, MouseEventKind};
5354        let tx = dummy_tx();
5355        let mut editor = rendered_vim_editor("hello world");
5356        editor.open_note(note("alpha"), "hello world".to_string());
5357        editor.handle_input(&mouse(MouseEventKind::Down(MouseButton::Left), 2), &tx);
5358        editor.open_note(note("beta"), "other text".to_string());
5359        editor.handle_input(&mouse(MouseEventKind::Drag(MouseButton::Left), 8), &tx);
5360        assert_eq!(vim_mode(&editor), EditorMode::Normal);
5361        assert!(
5362            get_ta(&mut editor)
5363                .selection_range()
5364                .is_none_or(|(s, e)| s == e)
5365        );
5366
5367        let mut editor = rendered_vim_editor("hello world");
5368        editor.handle_input(&mouse(MouseEventKind::Down(MouseButton::Left), 2), &tx);
5369        editor.handle_input(&mouse(MouseEventKind::Up(MouseButton::Left), 2), &tx);
5370        get_ta(&mut editor).cancel_selection();
5371        editor.handle_input(&mouse(MouseEventKind::Drag(MouseButton::Left), 8), &tx);
5372        assert_eq!(
5373            vim_mode(&editor),
5374            EditorMode::Normal,
5375            "a drag after the release"
5376        );
5377    }
5378
5379    /// A drag over one character selects that character, and stays Visual.
5380    #[test]
5381    fn a_one_character_drag_selects_it() {
5382        use ratatui::crossterm::event::{MouseButton, MouseEventKind};
5383        let tx = dummy_tx();
5384        let mut editor = rendered_vim_editor("hello");
5385        editor.handle_input(&mouse(MouseEventKind::Down(MouseButton::Left), 1), &tx);
5386        editor.handle_input(&mouse(MouseEventKind::Drag(MouseButton::Left), 2), &tx);
5387        assert_eq!(vim_mode(&editor), EditorMode::Visual);
5388        assert_eq!(editor.selection, Some(((0, 1), (0, 2))));
5389        press(&mut editor, "d");
5390        assert_eq!(editor.get_text(), "hllo");
5391    }
5392
5393    /// Dragging back and forth never loses a character: each event selects
5394    /// from where the drag began.
5395    #[test]
5396    fn a_drag_that_turns_back_selects_from_its_origin() {
5397        use ratatui::crossterm::event::{MouseButton, MouseEventKind};
5398        let tx = dummy_tx();
5399        let mut editor = rendered_vim_editor("hello world");
5400        editor.handle_input(&mouse(MouseEventKind::Down(MouseButton::Left), 6), &tx);
5401        for col in [3, 1, 0, 9, 11] {
5402            editor.handle_input(&mouse(MouseEventKind::Drag(MouseButton::Left), col), &tx);
5403        }
5404        assert_eq!(editor.selection, Some(((0, 6), (0, 11))));
5405        press(&mut editor, "d");
5406        assert_eq!(editor.get_text(), "hello ");
5407    }
5408
5409    /// `V` then a pair char on an empty row has nothing to wrap: nothing
5410    /// happens — no literal char typed while in Normal.
5411    #[test]
5412    fn a_pair_char_over_an_empty_visual_row_does_nothing() {
5413        for keys in ["jV*", "jv*"] {
5414            let mut editor = make_vim_editor();
5415            editor.set_text("a\n\nb".to_string());
5416            press(&mut editor, keys);
5417            assert_eq!(editor.get_text(), "a\n\nb", "{keys}");
5418            assert_ne!(
5419                vim_mode(&editor),
5420                EditorMode::Normal,
5421                "{keys}: still selecting"
5422            );
5423        }
5424    }
5425
5426    #[test]
5427    fn emphasis_over_an_empty_visual_row_does_nothing() {
5428        let mut editor = make_vim_editor();
5429        editor.set_text("a\n\nb".to_string());
5430        press(&mut editor, "jv");
5431        editor.apply_text_action(TextAction::Bold);
5432        assert_eq!(editor.get_text(), "a\n\nb");
5433        assert_eq!(vim_mode(&editor), EditorMode::Visual);
5434    }
5435
5436    /// A wrap whose text ends in a line break cannot stay selected in Visual
5437    /// (a Visual range cannot end on a newline); it lands in Normal rather
5438    /// than chaining into an unbalanced second wrap.
5439    #[test]
5440    fn a_wrap_ending_in_a_line_break_does_not_chain() {
5441        let mut editor = make_vim_editor();
5442        editor.set_text("a\n\nz".to_string());
5443        press(&mut editor, "vj*");
5444        assert_eq!(editor.get_text(), "*a\n*\nz");
5445        assert_eq!(vim_mode(&editor), EditorMode::Normal);
5446        press(&mut editor, "*");
5447        assert_eq!(editor.get_text(), "*a\n*\nz");
5448    }
5449
5450    /// Emphasis from `V` wraps the row and keeps its text selected in Visual.
5451    #[test]
5452    fn emphasis_from_visual_line_keeps_the_row_text_selected() {
5453        let mut editor = make_vim_editor();
5454        editor.set_text("hello\nx".to_string());
5455        press(&mut editor, "V");
5456        editor.apply_text_action(TextAction::Bold);
5457        assert_eq!(editor.get_text(), "**hello**\nx");
5458        assert_eq!(vim_mode(&editor), EditorMode::Visual);
5459        assert_eq!(editor.selection, Some(((0, 2), (0, 7))));
5460    }
5461
5462    /// Auto-surround over a `V` row wraps the row, matching the highlight.
5463    #[test]
5464    fn surround_over_visual_line_wraps_the_whole_row() {
5465        let mut editor = make_vim_editor();
5466        let tx = dummy_tx();
5467        editor.set_text("a\nbold\nc".to_string());
5468        for c in ['j', 'l', 'V', '*'] {
5469            editor.handle_input(
5470                &InputEvent::Key(key(KeyCode::Char(c), KeyModifiers::NONE)),
5471                &tx,
5472            );
5473        }
5474        assert_eq!(editor.get_text(), "a\n*bold*\nc");
5475    }
5476
5477    /// The same call outside Visual must not eat anything — Ctrl+V with an
5478    /// image on the clipboard is an ordinary insert-at-cursor in Normal mode.
5479    #[test]
5480    fn external_paste_without_a_selection_leaves_the_buffer_alone() {
5481        let mut editor = make_vim_editor();
5482        editor.set_text("hello world".to_string());
5483        editor.take_selection_for_external_paste();
5484        assert_eq!(editor.get_text(), "hello world");
5485        assert_eq!(vim_mode(&editor), EditorMode::Normal);
5486    }
5487
5488    /// Copying a Visual selection from the host (right-click) is the Ctrl-C
5489    /// chord: it covers the inclusive range the highlight shows ("hello", see
5490    /// `copy_visual_takes_the_inclusive_range_and_leaves_visual`) and leaves
5491    /// Visual. That also settles the old drift, where each repeated copy grew
5492    /// the live selection one char: after the first copy there is none left
5493    /// to grow.
5494    #[test]
5495    fn vim_visual_copy_leaves_visual_and_nothing_to_grow() {
5496        let mut editor = make_vim_editor();
5497        let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
5498        editor.set_text("hello world".to_string());
5499        press(&mut editor, "ve");
5500        assert_eq!(
5501            editor.backend.selection_as_shown(),
5502            Some(((0, 0), (0, 5))),
5503            "the copy reads the inclusive range including the cursor char"
5504        );
5505        editor.copy_selection_to_clipboard(&tx);
5506        assert_eq!(vim_mode(&editor), EditorMode::Normal);
5507        assert_eq!(get_ta(&mut editor).selection_range(), None);
5508        editor.copy_selection_to_clipboard(&tx);
5509        assert_eq!(get_ta(&mut editor).selection_range(), None);
5510        assert_eq!(
5511            get_ta(&mut editor).cursor(),
5512            (0, 0),
5513            "at the copied range's start"
5514        );
5515    }
5516
5517    /// Regression: `V` (linewise Visual) must keep the WHOLE line highlighted
5518    /// no matter where the cursor moves within it afterwards — column position
5519    /// is irrelevant to a linewise selection in vim. `self.selection` used to
5520    /// mirror the textarea's raw (charwise) selection range verbatim, which
5521    /// only looked like a full line right after `V` because it happens to run
5522    /// Head..End; moving the cursor back then shrank the highlight to
5523    /// "start of line .. cursor".
5524    #[test]
5525    fn vim_visual_line_selection_stays_full_width_after_cursor_moves_back() {
5526        let mut editor = make_vim_editor();
5527        let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
5528        editor.set_text("hello world".to_string());
5529        editor.handle_input(
5530            &InputEvent::Key(key(KeyCode::Char('V'), KeyModifiers::NONE)),
5531            &tx,
5532        );
5533        assert_eq!(vim_mode(&editor), EditorMode::VisualLine);
5534        assert_eq!(
5535            editor.selection,
5536            Some(((0, 0), (0, usize::MAX))),
5537            "V must highlight the full line right away"
5538        );
5539        for _ in 0..5 {
5540            editor.handle_input(
5541                &InputEvent::Key(key(KeyCode::Left, KeyModifiers::NONE)),
5542                &tx,
5543            );
5544        }
5545        assert_eq!(
5546            editor.selection,
5547            Some(((0, 0), (0, usize::MAX))),
5548            "moving the cursor back must not shrink the linewise highlight"
5549        );
5550    }
5551
5552    /// Regression: a Visual `<` with nothing to remove (the lines are already
5553    /// flush left) still cancels the buffer selection and drops to Normal, so
5554    /// the painted highlight must go with it. The engine reported the
5555    /// no-change case as `NoOp`, and the host's `NoOp` arm returns without
5556    /// touching `self.selection` — leaving the rows painted as selected after
5557    /// Visual mode had already ended.
5558    #[test]
5559    fn vim_visual_outdent_with_nothing_to_remove_clears_the_highlight() {
5560        let mut editor = make_vim_editor();
5561        let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
5562        editor.set_text("alpha\nbeta".to_string());
5563        for c in ['V', 'j'] {
5564            editor.handle_input(
5565                &InputEvent::Key(key(KeyCode::Char(c), KeyModifiers::NONE)),
5566                &tx,
5567            );
5568        }
5569        assert_eq!(vim_mode(&editor), EditorMode::VisualLine);
5570        assert!(
5571            editor.selection.is_some(),
5572            "V over two rows must paint a highlight"
5573        );
5574
5575        editor.handle_input(
5576            &InputEvent::Key(key(KeyCode::Char('<'), KeyModifiers::NONE)),
5577            &tx,
5578        );
5579        assert_eq!(
5580            editor.get_text(),
5581            "alpha\nbeta",
5582            "there is no indentation to remove"
5583        );
5584        assert_eq!(vim_mode(&editor), EditorMode::Normal);
5585        assert_eq!(
5586            editor.selection, None,
5587            "leaving Visual mode must clear the highlight even when the outdent changed nothing"
5588        );
5589    }
5590
5591    /// Regression: a bare left click (Down with no Drag) must NOT flip
5592    /// vim Normal → Visual.  The textarea's Down arm calls `start_selection()`
5593    /// which leaves a collapsed (start==end) selection; the fix at ~line 2124
5594    /// uses `.is_some_and(|(s, e)| s != e)` to require a non-empty selection
5595    /// before treating it as "real" (mirrors the same guard at ~line 1014).
5596    ///
5597    /// We test `sync_mouse_selection` directly (the exact code that was
5598    /// broken) rather than routing through `handle_input` → `handle_mouse`,
5599    /// which needs a fully rendered view to resolve screen→logical coordinates.
5600    #[test]
5601    fn vim_sync_collapsed_sel_stays_normal() {
5602        let mut editor = make_vim_editor();
5603        editor.set_text("hello world".to_string());
5604
5605        // Sanity: starts in Normal.
5606        assert_eq!(vim_mode(&editor), EditorMode::Normal);
5607
5608        // A bare click leaves has_sel == false (collapsed selection filtered
5609        // out by the is_some_and guard).  Sync with no selection must keep Normal.
5610        editor.backend.sync_mouse_selection(false);
5611        assert_eq!(
5612            vim_mode(&editor),
5613            EditorMode::Normal,
5614            "collapsed (bare click) selection must not enter Visual mode"
5615        );
5616    }
5617
5618    /// A drag that creates a real (non-empty) selection DOES enter Visual mode.
5619    #[test]
5620    fn vim_sync_real_sel_enters_visual() {
5621        let mut editor = make_vim_editor();
5622        editor.set_text("hello world".to_string());
5623
5624        // Sanity: starts in Normal.
5625        assert_eq!(vim_mode(&editor), EditorMode::Normal);
5626
5627        // A drag with start != end yields has_sel == true.
5628        editor.backend.sync_mouse_selection(true);
5629        assert_eq!(
5630            vim_mode(&editor),
5631            EditorMode::Visual,
5632            "real drag selection must enter Visual mode"
5633        );
5634    }
5635
5636    /// Regression: with the find bar open in vim Normal mode, typed keys must
5637    /// go into the find query, NOT be processed by the vim engine (which would
5638    /// treat 'l'/'o' as motions and move the cursor).
5639    #[test]
5640    fn vim_find_bar_captures_typing_not_cursor() {
5641        let mut editor = make_vim_editor();
5642        editor.set_text("hello world\nsecond line".to_string());
5643        let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
5644
5645        // Open the find bar (same path as the '/' key: OpenSearch → open_or_advance_search).
5646        editor.open_or_advance_search();
5647        assert!(editor.search.is_some(), "find bar must be open");
5648
5649        // Type "lo" — should go into the find query, not be processed as vim motions.
5650        editor.handle_input(
5651            &InputEvent::Key(key(KeyCode::Char('l'), KeyModifiers::NONE)),
5652            &tx,
5653        );
5654        editor.handle_input(
5655            &InputEvent::Key(key(KeyCode::Char('o'), KeyModifiers::NONE)),
5656            &tx,
5657        );
5658
5659        // Find query must capture "lo". This proves keys went to the find bar
5660        // and not the vim engine (which would treat 'l' as a rightward motion
5661        // and 'o' as Open-line-below, mutating the buffer).
5662        let q = editor
5663            .search
5664            .as_ref()
5665            .map(|s| s.input.value().to_string())
5666            .unwrap_or_default();
5667        assert_eq!(q, "lo", "find query must capture typed characters");
5668
5669        // Buffer must be unchanged — 'o' in vim Normal mode inserts a new line,
5670        // so a mutated buffer means the key escaped to the vim engine.
5671        assert_eq!(
5672            editor.get_text(),
5673            "hello world\nsecond line",
5674            "buffer must not be modified while find bar is open"
5675        );
5676
5677        // The cursor is allowed to move to the first search match (that is
5678        // correct search behaviour — refresh_search_pattern jumps to the hit).
5679        // What must NOT happen is a vim motion: 'l' in Normal mode would leave
5680        // the cursor at col 1 with no query update; here it must be at the
5681        // "lo" match col instead (3 — the second 'l' in "hello").
5682        assert_eq!(
5683            editor.cursor_pos().1,
5684            3,
5685            "cursor must jump to the search match (col 3), not to a vim motion position"
5686        );
5687    }
5688
5689    /// Vim `/pattern`: Enter steps to the next match (same as the textarea
5690    /// backend — one key map on both), `Esc` closes the bar, and
5691    /// `n` / `N` keep working afterwards because closing no longer wipes the
5692    /// pattern.
5693    #[test]
5694    fn vim_search_enter_steps_and_esc_keeps_the_pattern_for_n() {
5695        let mut editor = make_vim_editor();
5696        // Three "lo" at cols 0, 6, 12 on a single line.
5697        editor.set_text("lo xx lo yy lo".to_string());
5698        let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
5699
5700        // Open the find bar (same path as the '/' key: OpenSearch → open_or_advance_search).
5701        editor.open_or_advance_search();
5702        assert!(editor.search.is_some(), "find bar must open");
5703
5704        // Type "lo" — keys go into the find query (incremental search).
5705        editor.handle_input(
5706            &InputEvent::Key(key(KeyCode::Char('l'), KeyModifiers::NONE)),
5707            &tx,
5708        );
5709        editor.handle_input(
5710            &InputEvent::Key(key(KeyCode::Char('o'), KeyModifiers::NONE)),
5711            &tx,
5712        );
5713
5714        // Enter steps to the next match and the bar STAYS OPEN — incremental
5715        // search parked the cursor on the first "lo" (col 0), so this lands on
5716        // the second (col 6).
5717        editor.handle_input(
5718            &InputEvent::Key(key(KeyCode::Enter, KeyModifiers::NONE)),
5719            &tx,
5720        );
5721        assert!(
5722            editor.search.is_some(),
5723            "find bar stays open on Enter — it steps, it does not confirm"
5724        );
5725        let (_, c1) = editor.cursor_pos();
5726        assert_eq!(c1, 6, "Enter must step to the 2nd 'lo' at col 6");
5727
5728        // Esc closes the bar. It must NOT wipe the pattern: that was the only
5729        // difference between closing with Esc and closing with Enter, and it
5730        // silently killed n/N.
5731        editor.handle_input(&InputEvent::Key(key(KeyCode::Esc, KeyModifiers::NONE)), &tx);
5732        assert!(editor.search.is_none(), "Esc must close the find bar");
5733
5734        // 'n' must navigate, not type into the (now-closed) bar.
5735        editor.handle_input(
5736            &InputEvent::Key(key(KeyCode::Char('n'), KeyModifiers::NONE)),
5737            &tx,
5738        );
5739        let (_, c2) = editor.cursor_pos();
5740        assert_eq!(c2, 12, "'n' must jump to the 3rd 'lo' at col 12");
5741
5742        // The buffer must never have been modified.
5743        assert_eq!(editor.get_text(), "lo xx lo yy lo");
5744    }
5745}