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 <huacnlee@gmail.com>
This commit is contained in:
parent
2cba6e8be9
commit
9228dbfb13
3 changed files with 171 additions and 56 deletions
|
|
@ -16,6 +16,7 @@ mod rope_ext;
|
|||
mod search;
|
||||
mod state;
|
||||
mod text_wrapper;
|
||||
mod selection;
|
||||
|
||||
pub(crate) use clear_button::*;
|
||||
pub use cursor::*;
|
||||
|
|
|
|||
170
crates/ui/src/input/selection.rs
Normal file
170
crates/ui/src/input/selection.rs
Normal file
|
|
@ -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<char> 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<Self>) {
|
||||
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<Range<usize>> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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<Self>) {
|
||||
#[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<Self>) {
|
||||
let offset = self.cursor();
|
||||
|
|
|
|||
Loading…
Reference in a new issue