Skip to content

Commit c68fe1f

Browse files
authored
Add object selection (textobjects) (#385)
* Add textobjects for word * Add textobjects for surround characters * Apply clippy lints * Remove ThisWordPrevBound in favor of PrevWordEnd It's the same as PrevWordEnd except for taking the current char into account, so use a "flag" to capture that usecase * Add tests for PrevWordEnd movement * Remove ThisWord* movements They did not preserve anchor positions and were only used for textobject boundary search anyway so replace them with simple position finding functions * Rewrite tests of word textobject * Add tests for surround textobject * Add textobject docs * Refactor textobject word position functions * Apply clippy lints on textobject * Fix overflow error with textobjects
1 parent c5b2973 commit c68fe1f

7 files changed

Lines changed: 475 additions & 7 deletions

File tree

book/src/keymap.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -150,14 +150,17 @@ Jumps to various locations.
150150
## Match mode
151151

152152
Enter this mode using `m` from normal mode. See the relavant section
153-
in [Usage](./usage.md#surround) for an explanation about surround usage.
153+
in [Usage](./usage.md) for an explanation about [surround](./usage.md#surround)
154+
and [textobject](./usage.md#textobject) usage.
154155

155156
| Key | Description |
156157
| ----- | ----------- |
157158
| `m` | Goto matching bracket |
158159
| `s` `<char>` | Surround current selection with `<char>` |
159160
| `r` `<from><to>` | Replace surround character `<from>` with `<to>` |
160161
| `d` `<char>` | Delete surround character `<char>` |
162+
| `a` `<object>` | Select around textobject |
163+
| `i` `<object>` | Select inside textobject |
161164

162165
## Object mode
163166

book/src/usage.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,3 +24,19 @@ It can also act on multiple seletions (yay!). For example, to change every occur
2424
- `mr([` to replace the parens with square brackets
2525

2626
Multiple characters are currently not supported, but planned.
27+
28+
## Textobjects
29+
30+
Currently supported: `word`, `surround`.
31+
32+
![textobject-demo](https://user-images.githubusercontent.com/23398472/124231131-81a4bb00-db2d-11eb-9d10-8e577ca7b177.gif)
33+
34+
- `ma` - Select around the object (`va` in vim, `<alt-a>` in kakoune)
35+
- `mi` - Select inside the object (`vi` in vim, `<alt-i>` in kakoune)
36+
37+
| Key after `mi` or `ma` | Textobject selected |
38+
| --- | --- |
39+
| `w` | Word |
40+
| `(`, `[`, `'`, etc | Specified surround pairs |
41+
42+
Textobjects based on treesitter, like `function`, `class`, etc are planned.

helix-core/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ pub mod selection;
1818
mod state;
1919
pub mod surround;
2020
pub mod syntax;
21+
pub mod textobject;
2122
mod transaction;
2223

2324
pub mod unicode {

helix-core/src/movement.rs

Lines changed: 94 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,10 @@ pub fn move_prev_long_word_start(slice: RopeSlice, range: Range, count: usize) -
113113
word_move(slice, range, count, WordMotionTarget::PrevLongWordStart)
114114
}
115115

116+
pub fn move_prev_word_end(slice: RopeSlice, range: Range, count: usize) -> Range {
117+
word_move(slice, range, count, WordMotionTarget::PrevWordEnd)
118+
}
119+
116120
fn word_move(slice: RopeSlice, range: Range, count: usize, target: WordMotionTarget) -> Range {
117121
(0..count).fold(range, |range, _| {
118122
slice.chars_at(range.head).range_to_target(target, range)
@@ -159,6 +163,7 @@ pub enum WordMotionTarget {
159163
NextWordStart,
160164
NextWordEnd,
161165
PrevWordStart,
166+
PrevWordEnd,
162167
// A "Long word" (also known as a WORD in vim/kakoune) is strictly
163168
// delimited by whitespace, and can consist of punctuation as well
164169
// as alphanumerics.
@@ -181,7 +186,9 @@ impl CharHelpers for Chars<'_> {
181186
fn range_to_target(&mut self, target: WordMotionTarget, origin: Range) -> Range {
182187
// Characters are iterated forward or backwards depending on the motion direction.
183188
let characters: Box<dyn Iterator<Item = char>> = match target {
184-
WordMotionTarget::PrevWordStart | WordMotionTarget::PrevLongWordStart => {
189+
WordMotionTarget::PrevWordStart
190+
| WordMotionTarget::PrevLongWordStart
191+
| WordMotionTarget::PrevWordEnd => {
185192
self.next();
186193
Box::new(from_fn(|| self.prev()))
187194
}
@@ -190,9 +197,9 @@ impl CharHelpers for Chars<'_> {
190197

191198
// Index advancement also depends on the direction.
192199
let advance: &dyn Fn(&mut usize) = match target {
193-
WordMotionTarget::PrevWordStart | WordMotionTarget::PrevLongWordStart => {
194-
&|u| *u = u.saturating_sub(1)
195-
}
200+
WordMotionTarget::PrevWordStart
201+
| WordMotionTarget::PrevLongWordStart
202+
| WordMotionTarget::PrevWordEnd => &|u| *u = u.saturating_sub(1),
196203
_ => &|u| *u += 1,
197204
};
198205

@@ -265,7 +272,7 @@ fn reached_target(target: WordMotionTarget, peek: char, next_peek: Option<&char>
265272
};
266273

267274
match target {
268-
WordMotionTarget::NextWordStart => {
275+
WordMotionTarget::NextWordStart | WordMotionTarget::PrevWordEnd => {
269276
is_word_boundary(peek, *next_peek)
270277
&& (char_is_line_ending(*next_peek) || !next_peek.is_whitespace())
271278
}
@@ -913,6 +920,88 @@ mod test {
913920
}
914921
}
915922

923+
#[test]
924+
fn test_behaviour_when_moving_to_end_of_previous_words() {
925+
let tests = array::IntoIter::new([
926+
("Basic backward motion from the middle of a word",
927+
vec![(1, Range::new(9, 9), Range::new(9, 5))]),
928+
("Starting from after boundary retreats the anchor",
929+
vec![(1, Range::new(0, 13), Range::new(12, 8))]),
930+
("Jump to end of a word succeeded by whitespace",
931+
vec![(1, Range::new(10, 10), Range::new(10, 4))]),
932+
(" Jump to start of line from end of word preceded by whitespace",
933+
vec![(1, Range::new(7, 7), Range::new(7, 0))]),
934+
("Previous anchor is irrelevant for backward motions",
935+
vec![(1, Range::new(26, 12), Range::new(12, 8))]),
936+
(" Starting from whitespace moves to first space in sequence",
937+
vec![(1, Range::new(0, 3), Range::new(3, 0))]),
938+
("Test identifiers_with_underscores are considered a single word",
939+
vec![(1, Range::new(0, 25), Range::new(25, 4))]),
940+
("Jumping\n \nback through a newline selects whitespace",
941+
vec![(1, Range::new(0, 13), Range::new(11, 8))]),
942+
("Jumping to start of word from the end selects the whole word",
943+
vec![(1, Range::new(15, 15), Range::new(15, 10))]),
944+
("alphanumeric.!,and.?=punctuation are considered 'words' for the purposes of word motion",
945+
vec![
946+
(1, Range::new(30, 30), Range::new(30, 21)),
947+
(1, Range::new(30, 21), Range::new(20, 18)),
948+
(1, Range::new(20, 18), Range::new(17, 15))
949+
]),
950+
951+
("... ... punctuation and spaces behave as expected",
952+
vec![
953+
(1, Range::new(0, 10), Range::new(9, 9)),
954+
(1, Range::new(9, 6), Range::new(5, 3)),
955+
]),
956+
(".._.._ punctuation is not joined by underscores into a single block",
957+
vec![(1, Range::new(0, 5), Range::new(4, 3))]),
958+
("Newlines\n\nare bridged seamlessly.",
959+
vec![
960+
(1, Range::new(0, 10), Range::new(7, 0)),
961+
]),
962+
("Jumping \n\n\n\n\nback from within a newline group selects previous block",
963+
vec![
964+
(1, Range::new(0, 13), Range::new(10, 7)),
965+
]),
966+
("Failed motions do not modify the range",
967+
vec![
968+
(0, Range::new(3, 0), Range::new(3, 0)),
969+
]),
970+
("Multiple motions at once resolve correctly",
971+
vec![
972+
(3, Range::new(23, 23), Range::new(15, 8)),
973+
]),
974+
("Excessive motions are performed partially",
975+
vec![
976+
(999, Range::new(40, 40), Range::new(8, 0)),
977+
]),
978+
("", // Edge case of moving backwards in empty string
979+
vec![
980+
(1, Range::new(0, 0), Range::new(0, 0)),
981+
]),
982+
("\n\n\n\n\n", // Edge case of moving backwards in all newlines
983+
vec![
984+
(1, Range::new(0, 0), Range::new(0, 0)),
985+
]),
986+
(" \n \nJumping back through alternated space blocks and newlines selects the space blocks",
987+
vec![
988+
(1, Range::new(0, 7), Range::new(6, 4)),
989+
(1, Range::new(6, 4), Range::new(2, 0)),
990+
]),
991+
("Test ヒーリクス multibyte characters behave as normal characters",
992+
vec![
993+
(1, Range::new(0, 9), Range::new(9, 4)),
994+
]),
995+
]);
996+
997+
for (sample, scenario) in tests {
998+
for (count, begin, expected_end) in scenario.into_iter() {
999+
let range = move_prev_word_end(Rope::from(sample).slice(..), begin, count);
1000+
assert_eq!(range, expected_end, "Case failed: [{}]", sample);
1001+
}
1002+
}
1003+
}
1004+
9161005
#[test]
9171006
fn test_behaviour_when_moving_to_end_of_next_long_words() {
9181007
let tests = array::IntoIter::new([

helix-core/src/selection.rs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,16 @@ impl Range {
130130
}
131131
}
132132

133+
impl From<(usize, usize)> for Range {
134+
fn from(tuple: (usize, usize)) -> Self {
135+
Self {
136+
anchor: tuple.0,
137+
head: tuple.1,
138+
horiz: None,
139+
}
140+
}
141+
}
142+
133143
/// A selection consists of one or more selection ranges.
134144
/// invariant: A selection can never be empty (always contains at least primary range).
135145
#[derive(Debug, Clone, PartialEq, Eq)]

0 commit comments

Comments
 (0)