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