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}