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