From 9a9298901bcff5353c0ed96c62b051c281ab07f9 Mon Sep 17 00:00:00 2001 From: honda Date: Thu, 15 Oct 2026 17:42:06 +0900 Subject: [PATCH] Add Shift+F3 to search backwards Add TextBuffer::find_and_select_prev, which selects the last match before the selection/cursor and wraps around to the end of the buffer. Bind it to Shift+F3, and parse modifiers for CSI-encoded F1-F4 (e.g. ESC [ 1;2 R) so the key works on xterm-style terminals. Co-Authored-By: Claude Sonnet 5.5 --- crates/edit/src/bin/edit/draw_editor.rs | 4 + crates/edit/src/bin/edit/main.rs | 2 + crates/edit/src/buffer/mod.rs | 113 ++++++++++++++++++++++++ crates/edit/src/input.rs | 6 ++ 4 files changed, 125 insertions(+) diff --git a/crates/edit/src/bin/edit/draw_editor.rs b/crates/edit/src/bin/edit/draw_editor.rs index 3e4ce72c979..30f60a790b0 100644 --- a/crates/edit/src/bin/edit/draw_editor.rs +++ b/crates/edit/src/bin/edit/draw_editor.rs @@ -161,6 +161,7 @@ fn draw_search(ctx: &mut Context, state: &mut State) { pub enum SearchAction { Search, + SearchPrev, Replace, ReplaceAll, } @@ -174,6 +175,9 @@ pub fn search_execute(ctx: &mut Context, state: &mut State, action: SearchAction SearchAction::Search => { doc.buffer.borrow_mut().find_and_select(&state.search_needle, state.search_options) } + SearchAction::SearchPrev => { + doc.buffer.borrow_mut().find_and_select_prev(&state.search_needle, state.search_options) + } SearchAction::Replace => doc.buffer.borrow_mut().find_and_replace( &state.search_needle, state.search_options, diff --git a/crates/edit/src/bin/edit/main.rs b/crates/edit/src/bin/edit/main.rs index c1128c5b571..5b1d5fd97f4 100644 --- a/crates/edit/src/bin/edit/main.rs +++ b/crates/edit/src/bin/edit/main.rs @@ -408,6 +408,8 @@ fn draw(tui: &mut Tui, input: Option, state: &mut State) { state.wants_search.focus = true; } else if key == vk::F3 { search_execute(ctx, state, SearchAction::Search); + } else if key == kbmod::SHIFT | vk::F3 { + search_execute(ctx, state, SearchAction::SearchPrev); } else { return; } diff --git a/crates/edit/src/buffer/mod.rs b/crates/edit/src/buffer/mod.rs index 2019c9513a1..67002aff6c2 100644 --- a/crates/edit/src/buffer/mod.rs +++ b/crates/edit/src/buffer/mod.rs @@ -1156,6 +1156,25 @@ impl TextBuffer { /// Find the next occurrence of the given `pattern` and select it. pub fn find_and_select(&mut self, pattern: &str, options: SearchOptions) -> icu::Result<()> { + self.find_and_select_impl(pattern, options, false) + } + + /// Find the previous occurrence of the given `pattern` and select it. + /// Wraps around to the end of the buffer if there's no match before the cursor. + pub fn find_and_select_prev( + &mut self, + pattern: &str, + options: SearchOptions, + ) -> icu::Result<()> { + self.find_and_select_impl(pattern, options, true) + } + + fn find_and_select_impl( + &mut self, + pattern: &str, + options: SearchOptions, + backwards: bool, + ) -> icu::Result<()> { if let Some(search) = &mut self.search { let search = search.get_mut(); // When the search input changes we must reset the search. @@ -1190,6 +1209,18 @@ impl TextBuffer { return Ok(()); } + if backwards { + // Search backwards from the start of the selection, or otherwise the cursor. + let limit = match self.selection { + Some(TextBufferSelection { beg, end }) => { + self.cursor_move_to_logical_internal(self.cursor, beg.min(end)).offset + } + _ => self.cursor.offset, + }; + self.find_select_prev(search, limit); + return Ok(()); + } + // If the user moved the cursor since the last search, but the needle remained the same, // we still need to move the start of the search to the new cursor position. let next_search_offset = if self.selection_generation == search.selection_generation { @@ -1352,6 +1383,57 @@ impl TextBuffer { }) } + /// Selects the last match that starts before `limit`. + /// If there is none, the last match in the entire buffer is selected. + fn find_select_prev( + &mut self, + search: &mut ActiveSearch, + limit: usize, + ) -> Option> { + // Scan the entire buffer from the start. ICU has no backwards search. + if search.buffer_generation != self.buffer.generation() { + unsafe { search.regex.set_text(&mut search.text, 0) }; + search.buffer_generation = self.buffer.generation(); + } else { + search.regex.reset(0); + } + + let mut before = None; + let mut last = None; + while let Some(range) = search.regex.next() { + if range.start < limit { + before = Some(range.clone()); + } else if before.is_some() { + break; + } + last = Some(range); + } + + let hit = before.or(last); + + search.selection_generation = if let Some(range) = &hit { + // Continue forward searches from the end of this match. + search.next_search_offset = range.end; + search.regex.reset(range.end); + + let beg = self.cursor_move_to_offset_internal(self.cursor, range.start); + let end = self.cursor_move_to_offset_internal(beg, range.end); + + unsafe { self.set_cursor(end) }; + self.make_cursor_visible(); + + self.set_selection(Some(TextBufferSelection { + beg: beg.logical_pos, + end: end.logical_pos, + })) + } else { + search.no_matches = true; + self.set_selection(None) + }; + + hit + } + fn find_select_next( &mut self, search: &mut ActiveSearch, @@ -4190,4 +4272,35 @@ mod tests { assert_eq!(buffer_stored_bytes(&buf), b"ax\nbx\nx\n"); } + + // Searching backwards must step through matches from the cursor towards the start, + // wrap around to the last match, and interoperate with forward searching. + #[test] + fn test_find_prev() { + let mut buf = prep_buffer("ab ab ab"); + let opts = SearchOptions::default(); + buf.cursor_move_to_logical(Point { x: 8, y: 0 }); + + let sel_x = |buf: &TextBuffer| { + let s = buf.selection.unwrap(); + (s.beg.x, s.end.x) + }; + + buf.find_and_select_prev("ab", opts).unwrap(); + assert_eq!(sel_x(&buf), (6, 8)); + buf.find_and_select_prev("ab", opts).unwrap(); + assert_eq!(sel_x(&buf), (3, 5)); + buf.find_and_select_prev("ab", opts).unwrap(); + assert_eq!(sel_x(&buf), (0, 2)); + // Reached the start: wrap around to the end. + buf.find_and_select_prev("ab", opts).unwrap(); + assert_eq!(sel_x(&buf), (6, 8)); + // Forward search continues from the current match and wraps to the start. + buf.find_and_select("ab", opts).unwrap(); + assert_eq!(sel_x(&buf), (0, 2)); + buf.find_and_select("ab", opts).unwrap(); + assert_eq!(sel_x(&buf), (3, 5)); + buf.find_and_select_prev("ab", opts).unwrap(); + assert_eq!(sel_x(&buf), (0, 2)); + } } diff --git a/crates/edit/src/input.rs b/crates/edit/src/input.rs index bbc1a7b6456..42182066547 100644 --- a/crates/edit/src/input.rs +++ b/crates/edit/src/input.rs @@ -385,6 +385,12 @@ impl<'input> Iterator for Stream<'_, '_, 'input> { )); } } + 'P'..='S' => { + let key = vk::F1.value() + csi.final_byte as u32 - 'P' as u32; + return Some(Input::Keyboard( + InputKey::new(key) | Self::parse_modifiers(csi), + )); + } 'Z' => return Some(Input::Keyboard(kbmod::SHIFT | vk::TAB)), '~' => { const LUT: [u8; 35] = [