From 66bedc33b46bf4f929af47ea6804ab0b7141cb85 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=B4ng=20Tr=E1=BA=A7n?= Date: Thu, 1 Oct 2026 21:52:47 +0700 Subject: [PATCH] feat(input): select the next occurrence, or all of them, as more selections SelectNextOccurrence selects the word under a caret, then adds the next occurrence of the selected text as another selection, wrapping past the end; SelectAllOccurrences adds every one. Both are code-editor and textarea actions with no default key, so an application binds them. lsp_types is re-exported, so a provider names the types it speaks without a dependency of its own to keep in step. Co-Authored-By: Claude Code --- crates/base/src/input/base/occurrences.rs | 242 ++++++++++++++++++++++ crates/base/src/input/base/state.rs | 8 + crates/base/src/input/mod.rs | 6 + crates/component/src/input/mod.rs | 3 +- 4 files changed, 258 insertions(+), 1 deletion(-) create mode 100644 crates/base/src/input/base/occurrences.rs diff --git a/crates/base/src/input/base/occurrences.rs b/crates/base/src/input/base/occurrences.rs new file mode 100644 index 0000000000..ae67b05381 --- /dev/null +++ b/crates/base/src/input/base/occurrences.rs @@ -0,0 +1,242 @@ +//! Selecting the occurrences of a selection: the next one added as another selection, or all +//! of them at once. With only a caret, the first press selects the word under it. + +use gpui::{Context, Window, actions}; + +use super::cursor::CursorSelection; +use super::{InputBaseState, InputModeKind, RopeExt as _}; + +actions!( + input, + [ + /// Select the word under the caret, or add the next occurrence of the selected text as + /// another selection, wrapping past the end. + SelectNextOccurrence, + /// Select the word under the caret, then every occurrence of the selected text. + SelectAllOccurrences, + ] +); + +impl InputBaseState { + /// The word under the newest caret selected in its place, when every selection is a caret. + /// Returns whether it did, so the press does nothing more. + fn select_word_under_caret(&mut self, cx: &mut Context) -> bool { + if self.selections.iter().any(|s| !s.is_empty()) { + return false; + } + let caret = self.active_selection().cursor_offset(); + // At a word's end the caret is past it: the word is the one just before. + let word = self.text.word_range(caret).or_else(|| { + caret + .checked_sub(1) + .and_then(|before| self.text.word_range(before)) + .filter(|word| word.end == caret) + }); + let Some(word) = word else { return true }; + self.selections.remove_all_but_active(); + let active = self.active_selection_mut(); + active.start = word.start; + active.end = word.end; + active.reversed = false; + active.column_anchor = None; + cx.notify(); + true + } + + /// The selected text the occurrences are of: the newest selection's. + fn occurrence_needle(&self) -> Option<(String, usize)> { + let newest = self.selections.iter().last()?; + (!newest.is_empty()).then(|| { + ( + self.text.slice(newest.start..newest.end).to_string(), + newest.end, + ) + }) + } + + /// Whether `start..end` overlaps a selection already made. + fn overlaps_selection(&self, start: usize, end: usize) -> bool { + self.selections + .iter() + .any(|s| start < s.end && s.start < end) + } + + fn add_occurrence(&mut self, start: usize, end: usize) { + let id = self.selections.generate_id(); + self.selections.add(CursorSelection::new(id, start, end)); + } + + pub(super) fn select_next_occurrence( + &mut self, + _: &SelectNextOccurrence, + _: &mut Window, + cx: &mut Context, + ) { + if !self.is_multi_line() { + return; + } + self.pause_blink_cursor(cx); + self.undo_manager.break_transaction_coalescing(); + if self.select_word_under_caret(cx) { + return; + } + let Some((needle, from)) = self.occurrence_needle() else { + return; + }; + let text = self.text.to_string(); + let after = text + .get(from..) + .unwrap_or_default() + .match_indices(&needle) + .map(|(at, _)| at + from); + let before = text + .get(..from) + .unwrap_or_default() + .match_indices(&needle) + .map(|(at, _)| at); + let next = after + .chain(before) + .find(|&at| !self.overlaps_selection(at, at + needle.len())); + if let Some(start) = next { + self.add_occurrence(start, start + needle.len()); + self.scroll_to(start + needle.len(), None, cx); + } + cx.notify(); + } + + pub(super) fn select_all_occurrences( + &mut self, + _: &SelectAllOccurrences, + _: &mut Window, + cx: &mut Context, + ) { + if !self.is_multi_line() { + return; + } + self.pause_blink_cursor(cx); + self.undo_manager.break_transaction_coalescing(); + self.select_word_under_caret(cx); + let Some((needle, _)) = self.occurrence_needle() else { + return; + }; + let text = self.text.to_string(); + let starts: Vec = text.match_indices(&needle).map(|(at, _)| at).collect(); + for start in starts { + if !self.overlaps_selection(start, start + needle.len()) { + self.add_occurrence(start, start + needle.len()); + } + } + cx.notify(); + } +} + +#[cfg(test)] +mod tests { + use gpui::{ + AppContext as _, Context, Entity, IntoElement, Render, TestAppContext, VisualTestContext, + Window, + }; + + use crate::input::{EditorMode, InputBaseState}; + + use super::{SelectAllOccurrences, SelectNextOccurrence}; + + struct View(Entity>); + + impl Render for View { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + self.0.clone() + } + } + + fn editor<'a>( + cx: &'a mut TestAppContext, + text: &str, + caret: usize, + ) -> ( + Entity>, + &'a mut VisualTestContext, + ) { + cx.update(|cx| { + cx.set_global(crate::Theme::default()); + crate::input::init(cx); + }); + let text = text.to_owned(); + let (view, cx) = cx.add_window_view(|window, cx| { + let state = cx.new(|cx| { + let mut state = InputBaseState::::new(window, cx); + state.set_value(text.clone(), window, cx); + state.set_selected_range(caret..caret, cx); + state + }); + View(state) + }); + let state = view.read_with(cx, |v, _| v.0.clone()); + (state, cx) + } + + fn selected( + state: &Entity>, + cx: &VisualTestContext, + ) -> Vec<(usize, usize)> { + state.read_with(cx, |s, _| { + let mut out: Vec<_> = s.selections.iter().map(|s| (s.start, s.end)).collect(); + out.sort_unstable(); + out + }) + } + + #[gpui::test] + fn test_select_next_occurrence_starts_with_the_word_and_wraps(cx: &mut TestAppContext) { + let (state, cx) = editor(cx, "foo bar foo baz foo", 9); + let next = |cx: &mut VisualTestContext| { + cx.update(|window, cx| { + state.update(cx, |s, cx| { + s.select_next_occurrence(&SelectNextOccurrence, window, cx) + }); + }); + }; + next(cx); + assert_eq!(selected(&state, cx), [(8, 11)], "the word under the caret"); + next(cx); + assert_eq!( + selected(&state, cx), + [(8, 11), (16, 19)], + "the next one after it" + ); + next(cx); + assert_eq!( + selected(&state, cx), + [(0, 3), (8, 11), (16, 19)], + "wrapping past the end" + ); + next(cx); + assert_eq!( + selected(&state, cx).len(), + 3, + "none left that is not selected" + ); + } + + #[gpui::test] + fn test_select_all_occurrences_selects_every_one(cx: &mut TestAppContext) { + let (state, cx) = editor(cx, "ab x ab y ab", 0); + cx.update(|window, cx| { + state.update(cx, |s, cx| { + s.select_all_occurrences(&SelectAllOccurrences, window, cx) + }); + }); + assert_eq!(selected(&state, cx), [(0, 2), (5, 7), (10, 12)]); + } + + #[gpui::test] + fn test_a_caret_off_any_word_selects_nothing(cx: &mut TestAppContext) { + let (state, cx) = editor(cx, "a b", 2); + cx.update(|window, cx| { + state.update(cx, |s, cx| { + s.select_next_occurrence(&SelectNextOccurrence, window, cx) + }); + }); + assert_eq!(selected(&state, cx), [(2, 2)]); + } +} diff --git a/crates/base/src/input/base/state.rs b/crates/base/src/input/base/state.rs index e8084272ff..bec6ced51c 100644 --- a/crates/base/src/input/base/state.rs +++ b/crates/base/src/input/base/state.rs @@ -4473,6 +4473,14 @@ impl Render for InputBaseState { .on_action(Self::key_action(&entity, InputBaseState::page_down)) .on_action(Self::key_action(&entity, InputBaseState::add_cursor_above)) .on_action(Self::key_action(&entity, InputBaseState::add_cursor_below)) + .on_action(Self::key_action( + &entity, + InputBaseState::select_next_occurrence, + )) + .on_action(Self::key_action( + &entity, + InputBaseState::select_all_occurrences, + )) }) .on_action(Self::key_action( &entity, diff --git a/crates/base/src/input/mod.rs b/crates/base/src/input/mod.rs index bb2ae494cf..867d655d1c 100644 --- a/crates/base/src/input/mod.rs +++ b/crates/base/src/input/mod.rs @@ -60,6 +60,9 @@ mod mode; mod movement; #[path = "base/native.rs"] mod native; +#[path = "base/occurrences.rs"] +mod occurrences; +pub use occurrences::{SelectAllOccurrences, SelectNextOccurrence}; #[path = "base/rope_ext.rs"] mod rope_ext; #[path = "editor/search.rs"] @@ -111,6 +114,9 @@ pub use lsp::{ ShowDocumentHandler, }; pub use lsp_types::Position; +/// The LSP types the providers speak ([`CompletionProvider`] and the others), at the version +/// this crate builds with, so a provider needs no `lsp-types` of its own to match it. +pub use lsp_types; pub use mask_pattern::MaskPattern; #[cfg(target_os = "macos")] #[doc(hidden)] diff --git a/crates/component/src/input/mod.rs b/crates/component/src/input/mod.rs index 0e2466be7b..b5c5e3d356 100644 --- a/crates/component/src/input/mod.rs +++ b/crates/component/src/input/mod.rs @@ -32,7 +32,8 @@ pub use gpui_base::input::{ Lsp, MaskPattern, MoveDown, MoveEnd, MoveHome, MoveLeft, MovePageDown, MovePageUp, MoveRight, MoveToEnd, MoveToEndOfLine, MoveToNextWord, MoveToPreviousWord, MoveToStart, MoveToStartOfLine, MoveUp, Outdent, OutdentInline, Paste, Point, RangeDecoration, RangeDecorationCollection, - RangeDecorationStyle, Redo, Replace, Rope, RopeExt, RopeLines, Search, SelectAll, SelectToEnd, + RangeDecorationStyle, Redo, Replace, Rope, RopeExt, RopeLines, Search, SelectAll, + SelectAllOccurrences, SelectNextOccurrence, SelectToEnd, SelectToEndOfLine, SelectToNextWordEnd, SelectToPreviousWordStart, SelectToStart, SelectToStartOfLine, Selection, ShowCharacterPalette, ShowDocumentHandler, TabSize, TextDecoration, TextDecorationCollection, TextareaState, ToggleCodeActions, Undo,