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