Skip to main content

kimun_notes/components/text_editor/
find_bar.rs

1//! The **find bar**: searching and replacing inside the open buffer.
2//!
3//! A module rather than a cluster of methods on the editor. The bar reaches
4//! outside itself for exactly one thing — the **edit buffer** — so it takes one
5//! as a parameter and the editor is left owning policy (which backend may open
6//! a bar), layout, and wiring the bar's overlay into the view.
7//!
8//! The bar owns its **current match** rather than writing the editor's
9//! selection. A current match is not a selection: it cannot be extended,
10//! copied, or typed over, and rendering it as one is why a mouse drag could
11//! hand the bar a multi-row range it had no way to represent.
12
13use ratatui::Frame;
14use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
15use ratatui::layout::Rect;
16use ratatui::style::{Modifier, Style};
17use ratatui::text::{Line, Span};
18use ratatui::widgets::Paragraph;
19
20use super::char_col_to_byte;
21use super::find_replace;
22use super::rope_buffer::{CursorMove, RopeBuffer};
23use crate::components::single_line_input::{InputOutcome, SingleLineInput};
24use crate::settings::themes::Theme;
25
26/// What the editor must do after handing the bar a key.
27#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
28pub struct KeyOutcome {
29    /// The bar is finished; the editor should drop it.
30    pub close: bool,
31}
32
33/// Everything the bar wants painted this frame, in one value.
34#[derive(Debug, Default)]
35pub struct BarOverlay {
36    /// The **replace preview**'s substituted lines, when one is showing.
37    pub preview: Option<find_replace::Preview>,
38    /// **Find pattern** matches, in logical buffer coordinates.
39    pub matches: Vec<(usize, usize, usize)>,
40}
41
42impl Default for FindBar {
43    fn default() -> Self {
44        Self::new()
45    }
46}
47
48/// Which of the find bar's inputs owns the keyboard. Only meaningful once a
49/// **replace field** has been revealed — a find-only bar is always `Find`.
50#[derive(Debug, Clone, Copy, PartialEq, Eq)]
51pub(super) enum BarFocus {
52    Find,
53    Replace,
54}
55
56pub struct FindBar {
57    // `pub(super)` on the state fields is a concession, not a design: the
58    // editor's own tests still assert against them. Migrating those tests into
59    // this module — where the bar can be driven directly against an
60    // `RopeBuffer` — is the follow-up that lets these go private again.
61    pub(super) input: SingleLineInput,
62    /// `Some` once the **replace field** is revealed. Its presence is what
63    /// puts the bar in replace mode.
64    pub(super) replace: Option<SingleLineInput>,
65    pub(super) focus: BarFocus,
66    pub(super) status: SearchStatus,
67    /// The compiled **find pattern**. `None` while the query is empty or does
68    /// not compile — the one place the regex lives, so the highlighter, the
69    /// counter and the replacer can never disagree about what matches.
70    pub(super) pattern: Option<find_replace::FindPattern>,
71    /// Matches across the whole buffer, refreshed with the pattern. Shown
72    /// before a **replace all** so the keystroke is an informed one.
73    pub(super) match_count: usize,
74    /// The **current match** — the occurrence the cursor sits on, which the bar
75    /// owns rather than writing into the editor's selection.
76    pub(super) current: Option<((usize, usize), (usize, usize))>,
77    /// Set when a **replace all** with an empty replacement was requested but
78    /// not yet confirmed. An empty field is indistinguishable from "still
79    /// typing", so that one destructive case arms rather than commits.
80    pub(super) armed_empty: bool,
81}
82
83impl FindBar {
84    pub fn new() -> Self {
85        Self {
86            input: SingleLineInput::new(),
87            replace: None,
88            focus: BarFocus::Find,
89            status: SearchStatus::Empty,
90            pattern: None,
91            match_count: 0,
92            current: None,
93            armed_empty: false,
94        }
95    }
96
97    /// Reveal the **replace field**, putting the bar in replace mode. Focus
98    /// lands in the find field while the pattern is still empty — you cannot
99    /// usefully type a replacement for nothing.
100    pub fn reveal_replace(&mut self) {
101        if self.replace.is_none() {
102            self.replace = Some(SingleLineInput::new());
103        }
104        self.focus = if self.input.is_empty() {
105            BarFocus::Find
106        } else {
107            BarFocus::Replace
108        };
109    }
110
111    /// Insert clipboard text into the focused field.
112    ///
113    /// The fields are single-line; a multi-line clipboard collapses to its
114    /// first line rather than silently pasting nothing.
115    pub fn paste(&mut self, text: &str, buf: &mut RopeBuffer) {
116        let line = text.lines().next().unwrap_or_default().to_string();
117        let focus = self.focus;
118        let input = self.focused_input_mut();
119        let at = input.cursor_byte();
120        input.replace_range_bytes(at..at, &line, at + line.len());
121        if focus == BarFocus::Find {
122            self.refresh_pattern(buf);
123        }
124    }
125
126    /// Re-derive what an undo or redo invalidated. The **current match**
127    /// pointed at text the history step just changed, so recompute it against
128    /// the cursor rather than leaving a highlight over whatever now sits there.
129    fn after_history_step(&mut self, buf: &RopeBuffer) {
130        self.refresh_match_count(buf);
131        self.current = buf.match_at_cursor();
132    }
133
134    /// Everything the bar wants painted this frame.
135    /// Costs one O(rows) pass per frame while the bar is open — the preview when
136    /// replacing, the match scan otherwise, never both. That is inherent to
137    /// the replace preview being a synthetic whole-buffer snapshot.
138    ///
139    /// Memoizing on (revision, pattern, replacement, current) was considered and
140    /// left undone: the bar redraws on input, and find-bar input almost always
141    /// changes the pattern, so it would miss on the common case and help only on
142    /// redraws driven by something else (autosave, a background parse or layout
143    /// install). A hit would still clone the preview's rows, so it reduces the
144    /// constant rather than the order — and the call site holds `search` and
145    /// `backend` borrowed together, so it needs interior mutability to fit.
146    /// Measure it with a bench that renders with the bar open before building it.
147    pub fn overlay(&self, buf: &RopeBuffer) -> BarOverlay {
148        let preview = self.preview(buf);
149        let matches = if preview.is_none() {
150            self.pattern
151                .as_ref()
152                .map(|p| p.match_spans(buf.text().lines()))
153                .unwrap_or_default()
154        } else {
155            // Those columns already carry the preview colour, which is the
156            // more important fact about them.
157            Vec::new()
158        };
159        BarOverlay { preview, matches }
160    }
161
162    /// The **current match**, for the editor to hand the view as its selection.
163    pub fn current_match(&self) -> Option<((usize, usize), (usize, usize))> {
164        self.current
165    }
166
167    /// The compiled **find pattern**, when the query compiles and is non-empty.
168    pub fn pattern(&self) -> Option<&find_replace::FindPattern> {
169        self.pattern.as_ref()
170    }
171
172    pub fn is_replacing(&self) -> bool {
173        self.replace.is_some()
174    }
175
176    /// The replacement text, or `""` when the field is revealed but empty
177    /// (which means deletion, not inaction).
178    pub(super) fn replacement(&self) -> &str {
179        self.replace.as_ref().map(|r| r.value()).unwrap_or("")
180    }
181
182    /// The input the keyboard is currently driving.
183    fn focused_input_mut(&mut self) -> &mut SingleLineInput {
184        match self.focus {
185            BarFocus::Replace if self.replace.is_some() => {
186                self.replace.as_mut().expect("checked above")
187            }
188            _ => &mut self.input,
189        }
190    }
191}
192
193pub(super) enum SearchStatus {
194    Empty,
195    Match,
196    NoMatch,
197    Invalid(String),
198}
199
200impl SearchStatus {
201    fn from_found(found: bool) -> Self {
202        if found { Self::Match } else { Self::NoMatch }
203    }
204}
205
206const FIND_PROMPT: &str = "Find: ";
207const REPLACE_PROMPT: &str = "Replace: ";
208const FIND_HINTS: &str = "  [Enter] next  [Shift+Enter] prev  [Tab] replace  [Esc] close";
209const REPLACE_HINTS: &str =
210    "  [Enter] replace  [Shift+Enter] skip  [Ctrl+A] all  [Tab] field  [Esc] close";
211const REPLACE_HINTS_ARMED: &str = "  delete every match? [Ctrl+A] confirm  [Esc] cancel";
212
213/// Render one prompt-plus-input row, returning the columns the prompt and
214/// value consumed so the caller can place a tail after them.
215fn render_bar_row(
216    f: &mut Frame,
217    rect: Rect,
218    prompt: &str,
219    input: &mut SingleLineInput,
220    theme: &Theme,
221    focused: bool,
222) -> u16 {
223    let base = theme.base_style();
224    let prompt_cols = unicode_width::UnicodeWidthStr::width(prompt) as u16;
225    f.render_widget(
226        Paragraph::new(Line::from(Span::styled(
227            prompt,
228            base.add_modifier(Modifier::BOLD),
229        )))
230        .style(base),
231        Rect {
232            width: prompt_cols.min(rect.width),
233            ..rect
234        },
235    );
236    input.render(f, rect, base, prompt_cols, focused);
237    // Tail sits after the full value (in display columns, accounting for
238    // wide/CJK chars), not after the caret — otherwise it would overlap the
239    // trailing characters when the user moves the cursor mid-string.
240    prompt_cols.saturating_add(input.display_width() as u16)
241}
242
243fn render_tail(f: &mut Frame, rect: Rect, consumed: u16, text: &str, style: Style) {
244    let tail_rect = Rect {
245        x: rect.x.saturating_add(consumed),
246        width: rect.width.saturating_sub(consumed),
247        ..rect
248    };
249    f.render_widget(Paragraph::new(text.to_string()).style(style), tail_rect);
250}
251
252/// Draw the find bar. `rect` is one row while finding, two once a **replace
253/// field** is revealed: row one is the pattern and what it matches, row two is
254/// the replacement and what will happen to it.
255impl FindBar {
256    /// Rows this bar occupies: one while finding, two once a **replace field**
257    /// is revealed.
258    pub fn rows(&self) -> u16 {
259        if self.is_replacing() { 2 } else { 1 }
260    }
261
262    pub fn render(&mut self, f: &mut Frame, rect: Rect, theme: &Theme, focused: bool) {
263        let muted = Style::default()
264            .fg(theme.gray.to_ratatui())
265            .bg(theme.bg.to_ratatui());
266        let err = Style::default()
267            .fg(theme.red.to_ratatui())
268            .bg(theme.bg.to_ratatui());
269
270        let replacing = self.is_replacing();
271        let find_focused = focused && self.focus == BarFocus::Find;
272        let find_row = Rect { height: 1, ..rect };
273
274        // Row 1 tail: what the pattern matches. Smartcase is never silent — the
275        // indicator says which way it resolved.
276        let case_note = match &self.pattern {
277            Some(p) if p.case_sensitive() => "  exact case",
278            Some(_) => "  any case",
279            None => "",
280        };
281        let tail: Option<(String, Style)> = match &self.status {
282            SearchStatus::Empty => None,
283            SearchStatus::Invalid(msg) => Some((format!("  invalid regex: {msg}"), err)),
284            SearchStatus::NoMatch => Some((format!("  no match{case_note}"), err)),
285            SearchStatus::Match => {
286                let n = self.match_count;
287                let plural = if n == 1 { "match" } else { "matches" };
288                let hints = if replacing { "" } else { FIND_HINTS };
289                Some((format!("  {n} {plural}{case_note}{hints}"), muted))
290            }
291        };
292
293        let consumed = render_bar_row(
294            f,
295            find_row,
296            FIND_PROMPT,
297            &mut self.input,
298            theme,
299            find_focused,
300        );
301        if let Some((text, style)) = tail {
302            render_tail(f, find_row, consumed, &text, style);
303        }
304
305        if !replacing || rect.height < 2 {
306            return;
307        }
308        let replace_row = Rect {
309            y: rect.y + 1,
310            height: 1,
311            ..rect
312        };
313        let armed = self.armed_empty;
314        let replace_focused = focused && self.focus == BarFocus::Replace;
315        let consumed = {
316            let input = self.replace.as_mut().expect("replacing");
317            render_bar_row(
318                f,
319                replace_row,
320                REPLACE_PROMPT,
321                input,
322                theme,
323                replace_focused,
324            )
325        };
326        let (hints, style) = if armed {
327            (REPLACE_HINTS_ARMED, err)
328        } else {
329            (REPLACE_HINTS, muted)
330        };
331        render_tail(f, replace_row, consumed, hints, style);
332    }
333}
334impl FindBar {
335    /// Recompile the **find pattern** under smartcase, refresh the match count,
336    /// and push it to the textarea so its stepping uses the same regex the
337    /// highlighter and the replacer do. When `jump` is true, also move to the
338    /// first match at or after the cursor (live preview).
339    fn refresh_pattern(&mut self, buf: &mut RopeBuffer) {
340        self.armed_empty = false;
341        if self.input.is_empty() {
342            let _ = buf.set_search_pattern("");
343            self.pattern = None;
344            self.match_count = 0;
345            self.status = SearchStatus::Empty;
346            self.current = None;
347            return;
348        }
349        let compiled = match find_replace::FindPattern::compile(self.input.value()) {
350            Ok(p) => p,
351            Err(e) => {
352                let _ = buf.set_search_pattern("");
353                self.pattern = None;
354                self.match_count = 0;
355                self.status = SearchStatus::Invalid(e.to_string());
356                self.current = None;
357                return;
358            }
359        };
360        // Hand the textarea the *effective* pattern (smartcase already baked
361        // in), not the raw query — otherwise its stepping and our highlighting
362        // would disagree about case, which is exactly the split the bar's own
363        // case model sets out to close.
364        let _ = buf.set_search_pattern(compiled.as_regex().as_str());
365        self.match_count = compiled.count_matches(buf.text().lines());
366        self.pattern = Some(compiled);
367        if self.match_count == 0 {
368            self.status = SearchStatus::NoMatch;
369            self.current = None;
370            return;
371        }
372        let found = buf.search_forward(true);
373        self.status = SearchStatus::from_found(found);
374        self.highlight_current_match(buf, found);
375    }
376    pub fn advance(&mut self, buf: &mut RopeBuffer, backward: bool) {
377        if self.input.is_empty() {
378            return;
379        }
380        let found = if backward {
381            buf.search_back(false)
382        } else {
383            buf.search_forward(false)
384        };
385        self.status = SearchStatus::from_found(found);
386        self.highlight_current_match(buf, found);
387    }
388    /// Work out what an interactive replace would do, without touching the
389    /// buffer: the match's row and char-column span, the expanded replacement,
390    /// and the lines before and after.
391    ///
392    /// Returns `None` when the cursor is not sitting exactly on a match.
393    #[allow(clippy::type_complexity)]
394    fn plan_replace_current(
395        &self,
396        buf: &RopeBuffer,
397    ) -> Option<(usize, usize, usize, String, Vec<String>, Vec<String>)> {
398        let pattern = self.pattern.as_ref()?;
399        let replacement = self.replacement();
400        let (row, start_col) = buf.cursor();
401        let line = buf.row(row)?;
402        let start_byte = char_col_to_byte(&line, start_col);
403        let caps = pattern.as_regex().captures_at(&line, start_byte)?;
404        let m = caps.get(0)?;
405        // `captures_at` finds the next match at OR AFTER the offset; only a
406        // match starting exactly here is the current one.
407        if m.start() != start_byte {
408            return None;
409        }
410        let expanded = pattern.expand(&caps, replacement);
411        let end_col = start_col + line[m.range()].chars().count();
412        let before: Vec<String> = buf.rows();
413        let mut after = before.clone();
414        after[row].replace_range(m.range(), &expanded);
415        Some((row, start_col, end_col, expanded, before, after))
416    }
417    /// Replace the **current match** and step to the next one — the `Enter`
418    /// action while a **replace field** is revealed.
419    fn replace_current(&mut self, buf: &mut RopeBuffer) {
420        // Derive the span from the pattern at the cursor rather than reading
421        // `self.current`. That field is shared with the visual-mode and mouse
422        // selection, and `handle_mouse` has no find-bar guard, so a drag can
423        // leave a MULTI-ROW range in it while the bar is open — which this used
424        // to collapse to one row by discarding the end row, then hand
425        // `replace_range` an inverted byte range. A span derived from the match
426        // is single-row by construction.
427        let Some((row, start_col, end_col, expanded, before, after)) =
428            self.plan_replace_current(buf)
429        else {
430            // Nothing usable under the cursor — step first, so the next Enter
431            // has something to act on.
432            self.advance(buf, false);
433            return;
434        };
435        // Both ends have to be addressable before anything is edited. A match can
436        // END inside a grapheme cluster — searching `e` over a decomposed `é`
437        // does exactly that — and `Jump` refuses such a column by design
438        // (a position the buffer cannot address is a no-op, never a
439        // clamp). Discovering that half-way through the edit below leaves the
440        // selection empty, so the replacement is *inserted* beside the match
441        // instead of replacing it. Refuse rather than corrupt; `advance` below
442        // still steps past it.
443        //
444        // This replaced a guard that read `let Some(x) = Some(x)` — a tautology
445        // left behind when `Jump` stopped taking a `u16`, checking nothing.
446        let text = buf.text().clone();
447        let addressable = |col: usize| {
448            text.position(row, crate::ropetext::Column::new(col))
449                .is_some()
450        };
451        if !addressable(start_col) || !addressable(end_col) {
452            return;
453        }
454        let (row_u16, start_u16, end_u16) = (row, start_col, end_col);
455        // One `edit()` scope: select the match and overwrite it as a single
456        // **undo group**, however many history entries that turns out to be.
457        // Nothing here predicts the count, and nothing reads `insert_str`'s
458        // bool — with an empty replacement it deletes and still returns false.
459        buf.edit(|buf| {
460            buf.move_cursor(CursorMove::Jump(row_u16, start_u16));
461            buf.start_selection();
462            buf.move_cursor(CursorMove::Jump(row_u16, end_u16));
463            buf.insert_str(&expanded);
464            buf.cancel_selection();
465        });
466        debug_assert_eq!(
467            buf.text().to_string(),
468            after.join("\n"),
469            "replace_current wrote what it planned"
470        );
471        let _ = before;
472        // Land the cursor just past the replacement so the step below cannot
473        // re-match inside text we just wrote.
474        self.refresh_match_count(buf);
475        self.advance(buf, false);
476    }
477    /// Rewrite every match in the buffer — the `Ctrl+A` action. Returns the
478    /// number replaced, or `None` when there was nothing to do.
479    pub fn replace_all(&mut self, buf: &mut RopeBuffer) -> Option<usize> {
480        let pattern = self.pattern.as_ref()?;
481        let replacement = self.replacement().to_string();
482
483        let before: Vec<String> = buf.rows();
484        let (after, count) = find_replace::replace_all(pattern, &before, &replacement)?;
485
486        // Restore the reading position afterwards. The naive path leaves the
487        // cursor at the end of the inserted chunk — i.e. the bottom of the
488        // note — which turns a bulk edit into a navigation. The row is always
489        // still valid: the pattern cannot span a newline and the replacement
490        // is single-line, so a replace all never changes the line count.
491        let (cur_row, cur_col) = buf.cursor();
492
493        let joined = after.join("\n");
494        // One **undo group** spanning the whole rewrite. The buffer also
495        // derives `bulk` from it — a replace all rewrites rows the cursor does
496        // not point at, which is exactly what `compute_damage_range`'s cursor
497        // fast path assumes cannot happen.
498        buf.edit(|buf| {
499            buf.select_all();
500            buf.insert_str(&joined);
501            buf.cancel_selection();
502        });
503        if buf.text().to_string() != after.join("\n") {
504            return None;
505        }
506        let _ = &before;
507
508        // Restore the reading position. The row stays valid because a replace all
509        // never changes the line count: the pattern cannot span a newline and the
510        // replace field is single-line.
511        let row = cur_row.min(after.len().saturating_sub(1));
512        let col = cur_col.min(after[row].chars().count());
513        buf.move_cursor(CursorMove::Jump(row, col));
514
515        self.current = None;
516        self.refresh_match_count(buf);
517        Some(count)
518    }
519    /// `Ctrl+A` inside the bar. Replace-all commits immediately — the match
520    /// count is on screen beforehand and undo is one keystroke — except with
521    /// an empty replacement, where the keystroke carries no evidence the user
522    /// finished typing, so the first press arms and the second commits.
523    fn replace_all_key(&mut self, buf: &mut RopeBuffer) {
524        if !self.is_replacing() || self.pattern.is_none() {
525            return;
526        }
527        if self.replacement().is_empty() && !self.armed_empty {
528            self.armed_empty = true;
529            return;
530        }
531        self.armed_empty = false;
532        self.replace_all(buf);
533    }
534    /// Recount matches against the current buffer. Cheap — `find_iter` over
535    /// lines the editor already holds.
536    fn refresh_match_count(&mut self, buf: &RopeBuffer) {
537        let Some(pattern) = self.pattern.as_ref() else {
538            return;
539        };
540        self.match_count = pattern.count_matches(buf.text().lines());
541    }
542    /// Build the **replace preview** for this frame: the note as it would read
543    /// with every match replaced, plus where each replacement landed.
544    ///
545    /// Returns `None` whenever there is nothing to preview, in which case the
546    /// caller renders the real buffer.
547    pub(super) fn preview(&self, buf: &RopeBuffer) -> Option<find_replace::Preview> {
548        if !self.is_replacing() {
549            return None;
550        }
551        let pattern = self.pattern.as_ref()?;
552        let current = self.current.map(|((row, col), _)| (row, col));
553        let preview =
554            // `text().lines()` rather than a materialised vector: this runs on
555            // every frame the preview is showing, and building a copy of the note
556            // to read it doubled the cost of a feature that already rebuilds one.
557            find_replace::build_preview(pattern, buf.text().lines(), self.replacement(), current);
558        if preview.spans.is_empty() {
559            return None;
560        }
561        Some(preview)
562    }
563    /// After a search step, paint the match at the textarea's cursor as the
564    /// editor selection so the user can see where the match is — our custom
565    /// `MarkdownEditorView` does not render the textarea library's built-in
566    /// search highlights.
567    fn highlight_current_match(&mut self, buf: &RopeBuffer, found: bool) {
568        self.current = if found { buf.match_at_cursor() } else { None };
569    }
570    /// Handle one key. The bar owns every key while it holds the **editor
571    /// claim**, so this always consumes; the outcome says only whether the
572    /// editor should drop the bar.
573    ///
574    /// The key map is the same on both backends — the vim emulation's old
575    /// "Enter confirms and closes" special case is gone, because two Enters
576    /// over one widget is what made the bar ambiguous.
577    pub fn handle_key(&mut self, key: &KeyEvent, buf: &mut RopeBuffer) -> KeyOutcome {
578        let shift = key.modifiers.contains(KeyModifiers::SHIFT);
579        let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
580        let stay = KeyOutcome::default();
581
582        // `Tab` reveals the replace field, then cycles focus between the two.
583        // `SingleLineInput` never consumes it, so it is ours to take.
584        if key.code == KeyCode::Tab {
585            if self.replace.is_none() {
586                self.reveal_replace();
587            } else {
588                self.focus = match self.focus {
589                    BarFocus::Find => BarFocus::Replace,
590                    BarFocus::Replace => BarFocus::Find,
591                };
592            }
593            return stay;
594        }
595
596        // Replace all. `SingleLineInput` deliberately bubbles Ctrl-modified
597        // chars rather than typing them, so this is the documented seam. The
598        // chord is shared with the editor's select-all and resolved by focus,
599        // as the row-declared yank does for Ctrl+Y.
600        if ctrl && matches!(key.code, KeyCode::Char('a') | KeyCode::Char('A')) {
601            self.replace_all_key(buf);
602            return stay;
603        }
604
605        // Undo / redo of the bar's OWN edits. The bar consumes every key, so
606        // without this it swallows Ctrl+Z and strands the user on a note it
607        // just rewrote — undo would only work after they thought to press Esc.
608        if ctrl {
609            match key.code {
610                KeyCode::Char('z') if !shift => {
611                    if buf.undo() {
612                        self.after_history_step(buf);
613                    }
614                    return stay;
615                }
616                KeyCode::Char('y') | KeyCode::Char('Z') => {
617                    if buf.redo() {
618                        self.after_history_step(buf);
619                    }
620                    return stay;
621                }
622                _ => {}
623            }
624        }
625
626        let replacing = self.is_replacing();
627        match self.focused_input_mut().handle_key(key) {
628            InputOutcome::Cancel => {
629                // Esc disarms first, so a mis-aimed Ctrl+A never costs the bar.
630                if self.armed_empty {
631                    self.armed_empty = false;
632                } else {
633                    // The editor drops the bar and clears its selection; the
634                    // pattern stays on the buffer so vim's `n`/`N` still work.
635                    return KeyOutcome { close: true };
636                }
637            }
638            InputOutcome::Submit => {
639                if replacing {
640                    if shift {
641                        // Skip: advance without writing.
642                        self.advance(buf, false);
643                    } else {
644                        self.replace_current(buf);
645                    }
646                } else {
647                    self.advance(buf, shift);
648                }
649            }
650            InputOutcome::Changed => {
651                // Editing either field disarms a pending confirm and
652                // invalidates the preview, which rebuilds from state anyway.
653                if self.focus == BarFocus::Find {
654                    self.refresh_pattern(buf);
655                } else {
656                    self.armed_empty = false;
657                }
658            }
659            InputOutcome::Consumed | InputOutcome::NotConsumed => {}
660        }
661        stay
662    }
663}