From 9228dbfb1395ff40c69c7765e2f1b030a07da667 Mon Sep 17 00:00:00 2001 From: FlyingYu Date: Mon, 10 Nov 2025 13:02:02 +0800 Subject: [PATCH] editor: Improve double-click for word selection (#1491) Close #876 https://github.com/user-attachments/assets/bb04b3ab-05ca-456c-bb14-b812a846701d --------- Co-authored-by: Jason Lee --- crates/ui/src/input/mod.rs | 1 + crates/ui/src/input/selection.rs | 170 +++++++++++++++++++++++++++++++ crates/ui/src/input/state.rs | 56 ---------- 3 files changed, 171 insertions(+), 56 deletions(-) create mode 100644 crates/ui/src/input/selection.rs diff --git a/crates/ui/src/input/mod.rs b/crates/ui/src/input/mod.rs index e10d4dcd..18c4292f 100644 --- a/crates/ui/src/input/mod.rs +++ b/crates/ui/src/input/mod.rs @@ -16,6 +16,7 @@ mod rope_ext; mod search; mod state; mod text_wrapper; +mod selection; pub(crate) use clear_button::*; pub use cursor::*; diff --git a/crates/ui/src/input/selection.rs b/crates/ui/src/input/selection.rs new file mode 100644 index 00000000..1c8d7880 --- /dev/null +++ b/crates/ui/src/input/selection.rs @@ -0,0 +1,170 @@ +use std::{char, ops::Range}; + +use gpui::{Context, Window}; +use ropey::Rope; +use sum_tree::Bias; + +use crate::{input::InputState, RopeExt as _}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum CharType { + /// a-z, A-Z, 0-9, _ + Word, + /// '\t', ' ', '\u{00A0}' etc. + Whitespace, + /// \n, \r + Newline, + /// . , ; : ( ) [ ] { } ... or CJK characters: `汉`, `πŸŽ‰` etc. + Other, +} + +impl From for CharType { + fn from(c: char) -> Self { + match c { + '_' => CharType::Word, + c if c.is_ascii_alphanumeric() => CharType::Word, + c if c == '\n' || c == '\r' => CharType::Newline, + c if c.is_whitespace() => CharType::Whitespace, + _ => CharType::Other, + } + } +} + +impl CharType { + /// Check if two CharTypes are connectable + fn is_connectable(self, c: char) -> bool { + let other = CharType::from(c); + match (self, other) { + (CharType::Word, CharType::Word) => true, + (CharType::Whitespace, CharType::Whitespace) => true, + _ => false, + } + } +} + +impl InputState { + /// Select the word at the given offset on double-click. + /// + /// The offset is the UTF-8 offset. + pub(super) fn select_word(&mut self, offset: usize, _: &mut Window, cx: &mut Context) { + let Some(range) = TextSelector::word_range(&self.text, offset) else { + return; + }; + + self.selected_range = (range.start..range.end).into(); + self.selected_word_range = Some(self.selected_range); + cx.notify() + } +} + +struct TextSelector; +impl TextSelector { + /// Select a word in the given text at the specified offset. + /// + /// The offset is the UTF-8 offset. + /// + /// Returns the start and end offsets of the selected word. + pub fn word_range(text: &Rope, offset: usize) -> Option> { + let offset = text.clip_offset(offset, Bias::Left); + let Some(char) = text.char_at(offset) else { + return None; + }; + + let char_type = CharType::from(char); + let mut start = offset; + let mut end = offset + char.len_utf8(); + let prev_chars = text.chars_at(start).reversed().take(128); + let next_chars = text.chars_at(end).take(128); + + for ch in prev_chars { + if char_type.is_connectable(ch) { + start -= ch.len_utf8(); + } else { + break; + } + } + + for ch in next_chars { + if char_type.is_connectable(ch) { + end += ch.len_utf8(); + } else { + break; + } + } + + Some(start..end) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use ropey::Rope; + + #[test] + fn test_char_type_from_char() { + assert_eq!(CharType::from('a'), CharType::Word); + assert_eq!(CharType::from('Z'), CharType::Word); + assert_eq!(CharType::from('0'), CharType::Word); + assert_eq!(CharType::from('_'), CharType::Word); + assert_eq!(CharType::from('.'), CharType::Other); + assert_eq!(CharType::from(','), CharType::Other); + assert_eq!(CharType::from(';'), CharType::Other); + assert_eq!(CharType::from('!'), CharType::Other); + assert_eq!(CharType::from('?'), CharType::Other); + assert_eq!(CharType::from('['), CharType::Other); + assert_eq!(CharType::from('{'), CharType::Other); + assert_eq!(CharType::from(' '), CharType::Whitespace); + assert_eq!(CharType::from('\t'), CharType::Whitespace); + assert_eq!(CharType::from('\u{00A0}'), CharType::Whitespace); + assert_eq!(CharType::from('\n'), CharType::Newline); + assert_eq!(CharType::from('\r'), CharType::Newline); + assert_eq!(CharType::from('汉'), CharType::Other); + assert_eq!(CharType::from('Γ©'), CharType::Other); + } + + #[test] + fn test_word_range() { + use indoc::indoc; + + let rope = Rope::from(indoc! { + r#" + test text: + abcde δΈ­ζ–‡πŸŽ‰ test + hello[()] + test_connector ____ + Rope + "# + }); + + let tests = vec![ + (0, 0, Some("test")), + (0, 4, Some(" ")), + (1, 0, Some("abcde")), + (1, 4, Some("abcde")), + (1, 5, Some(" ")), + (1, 6, Some("δΈ­")), + (1, 9, Some("ζ–‡")), + (1, 13, Some("πŸŽ‰")), + (1, 20, Some("test")), + (2, 5, Some("[")), + (2, 6, Some("(")), + (2, 7, Some(")")), + (2, 8, Some("]")), + (3, 5, Some("test_connector")), + (3, 14, Some(" ")), + (3, 16, Some("____")), + (4, 0, Some("Rope")), + ]; + + for (line, column, expected) in tests { + let line_start_offset = rope.line_start_offset(line); + let offset = line_start_offset + column; + let range = TextSelector::word_range(&rope, offset); + + let actual = range.map(|r| rope.slice(r).to_string()); + let expect = expected.map(|s| s.to_string()); + assert_eq!(actual, expect, "line {}, column {}", line, column); + } + } +} diff --git a/crates/ui/src/input/state.rs b/crates/ui/src/input/state.rs index edaa80bc..6ac8dc1d 100644 --- a/crates/ui/src/input/state.rs +++ b/crates/ui/src/input/state.rs @@ -1607,62 +1607,6 @@ impl InputState { cx.notify() } - /// Select the word at the given offset. - /// - /// The offset is the UTF-8 offset. - /// - /// FIXME: When click on a non-word character, the word is not selected. - fn select_word(&mut self, offset: usize, window: &mut Window, cx: &mut Context) { - #[inline(always)] - fn is_word(c: char) -> bool { - c.is_alphanumeric() || matches!(c, '_') - } - - let mut start = offset; - let mut end = start; - let prev_text = self - .text_for_range(self.range_to_utf16(&(0..start)), &mut None, window, cx) - .unwrap_or_default(); - let next_text = self - .text_for_range( - self.range_to_utf16(&(end..self.text.len())), - &mut None, - window, - cx, - ) - .unwrap_or_default(); - - let prev_chars = prev_text.chars().rev(); - let next_chars = next_text.chars(); - - let pre_chars_count = prev_chars.clone().count(); - for (ix, c) in prev_chars.enumerate() { - if !is_word(c) { - break; - } - - if ix < pre_chars_count { - start = start.saturating_sub(c.len_utf8()); - } - } - - for (_, c) in next_chars.enumerate() { - if !is_word(c) { - break; - } - - end += c.len_utf8(); - } - - if start == end { - return; - } - - self.selected_range = (start..end).into(); - self.selected_word_range = Some(self.selected_range); - cx.notify() - } - /// Unselects the currently selected text. pub fn unselect(&mut self, _: &mut Window, cx: &mut Context) { let offset = self.cursor();