Skip to main content

kimun_notes/components/text_editor/
mod.rs

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