on master help sign in
Movement fixes, refactor and unit test suite (#217)

* Add convenience/clarity wrapper for Range initialization * Test horizontal moves * Add column jumping tests * Add failing movement conditions for multi-word moves * Refactor skip_over_next * Add complex forward movement unit tests * Add strict whitespace checks and edge case tests * Restore formatting * Remove unused function * Add empty test case for deletion and fix nth_prev_word_boundary * Add tests for backward motion * Refactor word movement * Address review comments and finish refactoring backwards move * Finish unit test suite * Fmt pass * Fix lint erors * Clean up diff restoring bad 'cargo fmt' actions * Simplify movement closures (thanks Pickfire) * Fmt pass * Replace index-based movement with iterator based movement, ensuring that each move incurs a single call to the RopeSlice API * Break down tuple function * Extract common logic to all movement functions * Split iterator helpers away into their own module * WIP reducing clones * Operate on spans * WIP simplifying iterators * Simplify motion helpers * Fix iterator * Fix all unit tests * Refactor and simplify * Simplify fold

commit86af55c379c531df2d5dbc72841e28a10fc7938e authorPabloMansanet <pablomansanet@gmail.com> dateFri 11 Jun 2021, 14:57 (5 years ago) parent0c2b993
7 files changed+688−319
book/src/keymap.md+21−21 helix-core/src/auto_pairs.rs+2−2 helix-core/src/lib.rs−1 helix-core/src/movement.rs+629−146 helix-core/src/selection.rs+4 helix-core/src/words.rs−68 helix-term/src/commands.rs+32−81
book/src/keymap.md+21−21
@@ -19,7 +19,7 @@
1919| F | find previous char |
2020| Home | move to the start of the line |
2121| End | move to the end of the line |
22−| m | Jump to matching bracket |
22+| m | Jump to matching bracket |
2323| PageUp | Move page up |
2424| PageDown | Move page down |
2525| ctrl-u | Move half page up |
@@ -42,10 +42,10 @@
4242| R | replace with yanked text |
4343| i | Insert before selection |
4444| a | Insert after selection (append) |
45−| I | Insert at the start of the line |
46−| A | Insert at the end of the line |
47−| o | Open new line below selection |
48−| o | Open new line above selection |
45+| I | Insert at the start of the line |
46+| A | Insert at the end of the line |
47+| o | Open new line below selection |
48+| o | Open new line above selection |
4949| u | Undo change |
5050| U | Redo change |
5151| y | Yank selection |
@@ -54,26 +54,26 @@
5454| > | Indent selection |
5555| < | Unindent selection |
5656| = | Format selection |
57−| d | Delete selection |
58−| c | Change selection (delete and enter insert mode) |
57+| d | Delete selection |
58+| c | Change selection (delete and enter insert mode) |
5959
6060### Selection manipulation
6161
6262| Key | Description |
6363|-----|-----------|
64−| s | Select all regex matches inside selections |
65−| S | Split selection into subselections on regex matches |
66−| alt-s | Split selection on newlines |
67−| ; | Collapse selection onto a single cursor |
68−| alt-; | Flip selection cursor and anchor |
69−| % | Select entire file |
70−| x | Select current line |
71−| X | Extend to next line |
72−| [ | Expand selection to parent syntax node TODO: pick a key |
64+| s | Select all regex matches inside selections |
65+| S | Split selection into subselections on regex matches |
66+| alt-s | Split selection on newlines |
67+| ; | Collapse selection onto a single cursor |
68+| alt-; | Flip selection cursor and anchor |
69+| % | Select entire file |
70+| x | Select current line |
71+| X | Extend to next line |
72+| [ | Expand selection to parent syntax node TODO: pick a key |
7373| J | join lines inside selection |
7474| K | keep selections matching the regex TODO: overlapped by hover help |
7575| space | keep only the primary selection TODO: overlapped by space mode |
76−| ctrl-c | Comment/uncomment the selections |
76+| ctrl-c | Comment/uncomment the selections |
7777
7878### Search
7979
@@ -82,10 +82,10 @@ in reverse, or searching via smartcase.
8282
8383| Key | Description |
8484|-----|-----------|
85−| / | Search for regex pattern |
86−| n | Select next search match |
87−| N | Add next search match to selection |
88−| * | Use current selection as the search pattern |
85+| / | Search for regex pattern |
86+| n | Select next search match |
87+| N | Add next search match to selection |
88+| * | Use current selection as the search pattern |
8989
9090### Diagnostics
9191
helix-core/src/auto_pairs.rs+2−2
@@ -67,7 +67,7 @@ fn handle_open(
6767
6868 let mut offs = 0;
6969
70− let mut transaction = Transaction::change_by_selection(doc, selection, |range| {
70+ let transaction = Transaction::change_by_selection(doc, selection, |range| {
7171 let pos = range.head;
7272 let next = next_char(doc, pos);
7373
@@ -109,7 +109,7 @@ fn handle_close(doc: &Rope, selection: &Selection, _open: char, close: char) ->
109109
110110 let mut offs = 0;
111111
112− let mut transaction = Transaction::change_by_selection(doc, selection, |range| {
112+ let transaction = Transaction::change_by_selection(doc, selection, |range| {
113113 let pos = range.head;
114114 let next = next_char(doc, pos);
115115
helix-core/src/lib.rs−1
@@ -16,7 +16,6 @@ pub mod selection;
1616mod state;
1717pub mod syntax;
1818mod transaction;
19−pub mod words;
2019
2120pub fn find_first_non_whitespace_char(line: RopeSlice) -> Option<usize> {
2221 line.chars().position(|ch| !ch.is_whitespace())
helix-core/src/movement.rs+629−146
@@ -1,5 +1,12 @@
1−use crate::graphemes::{nth_next_grapheme_boundary, nth_prev_grapheme_boundary, RopeGraphemes};
2−use crate::{coords_at_pos, pos_at_coords, ChangeSet, Position, Range, Rope, RopeSlice, Selection};
1+use std::iter::{self, from_fn, Peekable, SkipWhile};
2+
3+use ropey::iter::Chars;
4+
5+use crate::{
6+ coords_at_pos,
7+ graphemes::{nth_next_grapheme_boundary, nth_prev_grapheme_boundary},
8+ pos_at_coords, Position, Range, RopeSlice,
9+};
310
411#[derive(Debug, Copy, Clone, PartialEq, Eq)]
512pub enum Direction {
@@ -7,39 +14,49 @@ pub enum Direction {
714 Backward,
815}
916
17+#[derive(Copy, Clone, PartialEq, Eq)]
18+pub enum Movement {
19+ Extend,
20+ Move,
21+}
22+
1023pub fn move_horizontally(
11− text: RopeSlice,
24+ slice: RopeSlice,
1225 range: Range,
1326 dir: Direction,
1427 count: usize,
15− extend: bool,
28+ behaviour: Movement,
1629) -> Range {
1730 let pos = range.head;
18− let line = text.char_to_line(pos);
31+ let line = slice.char_to_line(pos);
1932 // TODO: we can optimize clamping by passing in RopeSlice limited to current line. that way
2033 // we stop calculating past start/end of line.
2134 let pos = match dir {
2235 Direction::Backward => {
23− let start = text.line_to_char(line);
24− nth_prev_grapheme_boundary(text, pos, count).max(start)
36+ let start = slice.line_to_char(line);
37+ nth_prev_grapheme_boundary(slice, pos, count).max(start)
2538 }
2639 Direction::Forward => {
2740 // Line end is pos at the start of next line - 1
28− let end = text.line_to_char(line + 1).saturating_sub(1);
29− nth_next_grapheme_boundary(text, pos, count).min(end)
41+ let end = slice.line_to_char(line + 1).saturating_sub(1);
42+ nth_next_grapheme_boundary(slice, pos, count).min(end)
3043 }
3144 };
32− Range::new(if extend { range.anchor } else { pos }, pos)
45+ let anchor = match behaviour {
46+ Movement::Extend => range.anchor,
47+ Movement::Move => pos,
48+ };
49+ Range::new(anchor, pos)
3350}
3451
3552pub fn move_vertically(
36− text: RopeSlice,
53+ slice: RopeSlice,
3754 range: Range,
3855 dir: Direction,
3956 count: usize,
40− extend: bool,
57+ behaviour: Movement,
4158) -> Range {
42− let Position { row, col } = coords_at_pos(text, range.head);
59+ let Position { row, col } = coords_at_pos(slice, range.head);
4360
4461 let horiz = range.horiz.unwrap_or(col as u32);
4562
@@ -47,138 +64,62 @@ pub fn move_vertically(
4764 Direction::Backward => row.saturating_sub(count),
4865 Direction::Forward => std::cmp::min(
4966 row.saturating_add(count),
50− text.len_lines().saturating_sub(2),
67+ slice.len_lines().saturating_sub(2),
5168 ),
5269 };
5370
5471 // convert to 0-indexed, subtract another 1 because len_chars() counts \n
55− let new_line_len = text.line(new_line).len_chars().saturating_sub(2);
72+ let new_line_len = slice.line(new_line).len_chars().saturating_sub(2);
5673
5774 let new_col = std::cmp::min(horiz as usize, new_line_len);
5875
59− let pos = pos_at_coords(text, Position::new(new_line, new_col));
76+ let pos = pos_at_coords(slice, Position::new(new_line, new_col));
6077
61− let mut range = Range::new(if extend { range.anchor } else { pos }, pos);
78+ let anchor = match behaviour {
79+ Movement::Extend => range.anchor,
80+ Movement::Move => pos,
81+ };
82+
83+ let mut range = Range::new(anchor, pos);
6284 range.horiz = Some(horiz);
6385 range
6486}
6587
66−pub fn move_next_word_start(slice: RopeSlice, mut begin: usize, count: usize) -> Option<Range> {
67− let mut end = begin;
68−
69− for _ in 0..count {
70− if begin + 1 == slice.len_chars() {
71− return None;
72− }
73−
74− let mut ch = slice.char(begin);
75− let next = slice.char(begin + 1);
76−
77− // if we're at the end of a word, or on whitespce right before new one
78− if categorize(ch) != categorize(next) {
79− begin += 1;
80− }
81−
82− if !skip_over_next(slice, &mut begin, |ch| ch == '\n') {
83− return None;
84− };
85− ch = slice.char(begin);
86−
87− end = begin + 1;
88−
89− if is_word(ch) {
90− skip_over_next(slice, &mut end, is_word);
91− } else if is_punctuation(ch) {
92− skip_over_next(slice, &mut end, is_punctuation);
93− }
94−
95− skip_over_next(slice, &mut end, char::is_whitespace);
96− }
97−
98− Some(Range::new(begin, end - 1))
88+pub fn move_next_word_start(slice: RopeSlice, range: Range, count: usize) -> Range {
89+ word_move(slice, range, count, WordMotionTarget::NextWordStart)
9990}
10091
101−pub fn move_prev_word_start(slice: RopeSlice, mut begin: usize, count: usize) -> Option<Range> {
102− let mut with_end = false;
103− let mut end = begin;
104−
105− for _ in 0..count {
106− if begin == 0 {
107− return None;
108− }
109−
110− let ch = slice.char(begin);
111− let prev = slice.char(begin - 1);
112−
113− if categorize(ch) != categorize(prev) {
114− begin -= 1;
115− }
116−
117− // return if not skip while?
118− skip_over_prev(slice, &mut begin, |ch| ch == '\n');
119−
120− end = begin;
121−
122− with_end = skip_over_prev(slice, &mut end, char::is_whitespace);
123−
124− // refetch
125− let ch = slice.char(end);
126−
127− if is_word(ch) {
128− with_end = skip_over_prev(slice, &mut end, is_word);
129− } else if is_punctuation(ch) {
130− with_end = skip_over_prev(slice, &mut end, is_punctuation);
131− }
132− }
133−
134− Some(Range::new(begin, if with_end { end } else { end + 1 }))
92+pub fn move_next_word_end(slice: RopeSlice, range: Range, count: usize) -> Range {
93+ word_move(slice, range, count, WordMotionTarget::NextWordEnd)
13594}
13695
137−pub fn move_next_word_end(slice: RopeSlice, mut begin: usize, count: usize) -> Option<Range> {
138− let mut end = begin;
139−
140− for _ in 0..count {
141− if begin + 2 >= slice.len_chars() {
142− return None;
143− }
144−
145− let ch = slice.char(begin);
146− let next = slice.char(begin + 1);
147−
148− if categorize(ch) != categorize(next) {
149− begin += 1;
150− }
151−
152− if !skip_over_next(slice, &mut begin, |ch| ch == '\n') {
153− return None;
154− };
155−
156− end = begin;
157−
158− skip_over_next(slice, &mut end, char::is_whitespace);
159−
160− // refetch
161− let ch = slice.char(end);
162−
163− if is_word(ch) {
164− skip_over_next(slice, &mut end, is_word);
165− } else if is_punctuation(ch) {
166− skip_over_next(slice, &mut end, is_punctuation);
167− }
168− }
96+pub fn move_prev_word_start(slice: RopeSlice, range: Range, count: usize) -> Range {
97+ word_move(slice, range, count, WordMotionTarget::PrevWordStart)
98+}
16999
170− Some(Range::new(begin, end - 1))
100+fn word_move(slice: RopeSlice, mut range: Range, count: usize, target: WordMotionTarget) -> Range {
101+ (0..count).fold(range, |range, _| {
102+ slice.chars_at(range.head).range_to_target(target, range)
103+ })
171104}
172105
173106// ---- util ------------
174−
175−// used for by-word movement
176−
177107#[inline]
178108pub(crate) fn is_word(ch: char) -> bool {
179109 ch.is_alphanumeric() || ch == '_'
180110}
181111
112+#[inline]
113+pub(crate) fn is_end_of_line(ch: char) -> bool {
114+ ch == '\n'
115+}
116+
117+#[inline]
118+// Whitespace, but not end of line
119+pub(crate) fn is_strict_whitespace(ch: char) -> bool {
120+ ch.is_whitespace() && !is_end_of_line(ch)
121+}
122+
182123#[inline]
183124pub(crate) fn is_punctuation(ch: char) -> bool {
184125 use unicode_general_category::{get_general_category, GeneralCategory};
@@ -199,7 +140,7 @@ pub(crate) fn is_punctuation(ch: char) -> bool {
199140}
200141
201142#[derive(Debug, Eq, PartialEq)]
202−pub(crate) enum Category {
143+pub enum Category {
203144 Whitespace,
204145 Eol,
205146 Word,
@@ -209,7 +150,7 @@ pub(crate) enum Category {
209150
210151#[inline]
211152pub(crate) fn categorize(ch: char) -> Category {
212− if ch == '\n' {
153+ if is_end_of_line(ch) {
213154 Category::Eol
214155 } else if ch.is_whitespace() {
215156 Category::Whitespace
@@ -223,46 +164,160 @@ pub(crate) fn categorize(ch: char) -> Category {
223164}
224165
225166#[inline]
226−/// Returns true if there are more characters left after the new position.
227−pub fn skip_over_next<F>(slice: RopeSlice, pos: &mut usize, fun: F) -> bool
167+/// Returns first index that doesn't satisfy a given predicate when
168+/// advancing the character index.
169+///
170+/// Returns none if all characters satisfy the predicate.
171+pub fn skip_while<F>(slice: RopeSlice, pos: usize, fun: F) -> Option<usize>
228172where
229173 F: Fn(char) -> bool,
230174{
231− let mut chars = slice.chars_at(*pos);
232−
233− #[allow(clippy::while_let_on_iterator)]
234− while let Some(ch) = chars.next() {
235− if !fun(ch) {
236− break;
237− }
238− *pos += 1;
239− }
240− chars.next().is_some()
175+ let mut chars = slice.chars_at(pos).enumerate();
176+ chars.find_map(|(i, c)| if !fun(c) { Some(pos + i) } else { None })
241177}
242178
243179#[inline]
244−/// Returns true if the final pos matches the predicate.
245−pub fn skip_over_prev<F>(slice: RopeSlice, pos: &mut usize, fun: F) -> bool
180+/// Returns first index that doesn't satisfy a given predicate when
181+/// retreating the character index, saturating if all elements satisfy
182+/// the condition.
183+pub fn backwards_skip_while<F>(slice: RopeSlice, pos: usize, fun: F) -> Option<usize>
246184where
247185 F: Fn(char) -> bool,
248186{
249− // need to +1 so that prev() includes current char
250− let mut chars = slice.chars_at(*pos + 1);
187+ let mut chars_starting_from_next = slice.chars_at(pos + 1);
188+ let mut backwards = iter::from_fn(|| chars_starting_from_next.prev()).enumerate();
189+ backwards.find_map(|(i, c)| {
190+ if !fun(c) {
191+ Some(pos.saturating_sub(i))
192+ } else {
193+ None
194+ }
195+ })
196+}
251197
252− #[allow(clippy::while_let_on_iterator)]
253− while let Some(ch) = chars.prev() {
254− if !fun(ch) {
255− break;
198+/// Possible targets of a word motion
199+#[derive(Copy, Clone, Debug)]
200+pub enum WordMotionTarget {
201+ NextWordStart,
202+ NextWordEnd,
203+ PrevWordStart,
204+}
205+
206+pub trait CharHelpers {
207+ fn range_to_target(&mut self, target: WordMotionTarget, origin: Range) -> Range;
208+}
209+
210+enum WordMotionPhase {
211+ Start,
212+ SkipNewlines,
213+ ReachTarget,
214+}
215+
216+impl CharHelpers for Chars<'_> {
217+ fn range_to_target(&mut self, target: WordMotionTarget, origin: Range) -> Range {
218+ let range = origin;
219+ // Characters are iterated forward or backwards depending on the motion direction.
220+ let characters: Box<dyn Iterator<Item = char>> = match target {
221+ WordMotionTarget::PrevWordStart => {
222+ self.next();
223+ Box::new(from_fn(|| self.prev()))
224+ }
225+ _ => Box::new(self),
226+ };
227+
228+ // Index advancement also depends on the direction.
229+ let advance: &dyn Fn(&mut usize) = match target {
230+ WordMotionTarget::PrevWordStart => &|u| *u = u.saturating_sub(1),
231+ _ => &|u| *u += 1,
232+ };
233+
234+ let mut characters = characters.peekable();
235+ let mut phase = WordMotionPhase::Start;
236+ let mut head = origin.head;
237+ let mut anchor: Option<usize> = None;
238+ let is_boundary = |a: char, b: Option<char>| categorize(a) != categorize(b.unwrap_or(a));
239+ while let Some(peek) = characters.peek().copied() {
240+ phase = match phase {
241+ WordMotionPhase::Start => {
242+ characters.next();
243+ if characters.peek().is_none() {
244+ break; // We're at the end, so there's nothing to do.
245+ }
246+ // Anchor may remain here if the head wasn't at a boundary
247+ if !is_boundary(peek, characters.peek().copied()) && !is_end_of_line(peek) {
248+ anchor = Some(head);
249+ }
250+ // First character is always skipped by the head
251+ advance(&mut head);
252+ WordMotionPhase::SkipNewlines
253+ }
254+ WordMotionPhase::SkipNewlines => {
255+ if is_end_of_line(peek) {
256+ characters.next();
257+ if characters.peek().is_some() {
258+ advance(&mut head);
259+ }
260+ WordMotionPhase::SkipNewlines
261+ } else {
262+ WordMotionPhase::ReachTarget
263+ }
264+ }
265+ WordMotionPhase::ReachTarget => {
266+ characters.next();
267+ anchor = anchor.or(Some(head));
268+ if reached_target(target, peek, characters.peek()) {
269+ break;
270+ } else {
271+ advance(&mut head);
272+ }
273+ WordMotionPhase::ReachTarget
274+ }
275+ }
276+ }
277+ Range::new(anchor.unwrap_or(origin.anchor), head)
278+ }
279+}
280+
281+fn reached_target(target: WordMotionTarget, peek: char, next_peek: Option<&char>) -> bool {
282+ let next_peek = match next_peek {
283+ Some(next_peek) => next_peek,
284+ None => return true,
285+ };
286+
287+ match target {
288+ WordMotionTarget::NextWordStart => {
289+ ((categorize(peek) != categorize(*next_peek))
290+ && (is_end_of_line(*next_peek) || !next_peek.is_whitespace()))
291+ }
292+ WordMotionTarget::NextWordEnd | WordMotionTarget::PrevWordStart => {
293+ ((categorize(peek) != categorize(*next_peek))
294+ && (!peek.is_whitespace() || is_end_of_line(*next_peek)))
256295 }
257− *pos = pos.saturating_sub(1);
258296 }
259− fun(slice.char(*pos))
260297}
261298
262299#[cfg(test)]
263300mod test {
301+ use std::array::{self, IntoIter};
302+
303+ use ropey::Rope;
304+
264305 use super::*;
265306
307+ const SINGLE_LINE_SAMPLE: &str = "This is a simple alphabetic line";
308+ const MULTILINE_SAMPLE: &str = "\
309+ Multiline\n\
310+ text sample\n\
311+ which\n\
312+ is merely alphabetic\n\
313+ and whitespaced\n\
314+ ";
315+
316+ const MULTIBYTE_CHARACTER_SAMPLE: &str = "\
317+ パーティーへ行かないか\n\
318+ The text above is Japanese\n\
319+ ";
320+
266321 #[test]
267322 fn test_vertical_move() {
268323 let text = Rope::from("abcd\nefg\nwrs");
@@ -273,17 +328,445 @@ mod test {
273328 assert_eq!(
274329 coords_at_pos(
275330 slice,
276− move_vertically(slice, range, Direction::Forward, 1, false).head
331+ move_vertically(slice, range, Direction::Forward, 1, Movement::Move).head
277332 ),
278333 (1, 2).into()
279334 );
280335 }
281336
337+ #[test]
338+ fn horizontal_moves_through_single_line_in_single_line_text() {
339+ let text = Rope::from(SINGLE_LINE_SAMPLE);
340+ let slice = text.slice(..);
341+ let position = pos_at_coords(slice, (0, 0).into());
342+
343+ let mut range = Range::point(position);
344+
345+ let moves_and_expected_coordinates = [
346+ ((Direction::Forward, 1usize), (0, 1)),
347+ ((Direction::Forward, 2usize), (0, 3)),
348+ ((Direction::Forward, 0usize), (0, 3)),
349+ ((Direction::Forward, 999usize), (0, 31)),
350+ ((Direction::Forward, 999usize), (0, 31)),
351+ ((Direction::Backward, 999usize), (0, 0)),
352+ ];
353+
354+ for ((direction, amount), coordinates) in IntoIter::new(moves_and_expected_coordinates) {
355+ range = move_horizontally(slice, range, direction, amount, Movement::Move);
356+ assert_eq!(coords_at_pos(slice, range.head), coordinates.into())
357+ }
358+ }
359+
360+ #[test]
361+ fn horizontal_moves_through_single_line_in_multiline_text() {
362+ let text = Rope::from(MULTILINE_SAMPLE);
363+ let slice = text.slice(..);
364+ let position = pos_at_coords(slice, (0, 0).into());
365+
366+ let mut range = Range::point(position);
367+
368+ let moves_and_expected_coordinates = IntoIter::new([
369+ ((Direction::Forward, 1usize), (0, 1)), // M_ltiline
370+ ((Direction::Forward, 2usize), (0, 3)), // Mul_iline
371+ ((Direction::Backward, 6usize), (0, 0)), // _ultiline
372+ ((Direction::Backward, 999usize), (0, 0)), // _ultiline
373+ ((Direction::Forward, 3usize), (0, 3)), // Mul_iline
374+ ((Direction::Forward, 0usize), (0, 3)), // Mul_iline
375+ ((Direction::Backward, 0usize), (0, 3)), // Mul_iline
376+ ((Direction::Forward, 999usize), (0, 9)), // Multilin_
377+ ((Direction::Forward, 999usize), (0, 9)), // Multilin_
378+ ]);
379+
380+ for ((direction, amount), coordinates) in moves_and_expected_coordinates {
381+ range = move_horizontally(slice, range, direction, amount, Movement::Move);
382+ assert_eq!(coords_at_pos(slice, range.head), coordinates.into());
383+ assert_eq!(range.head, range.anchor);
384+ }
385+ }
386+
387+ #[test]
388+ fn selection_extending_moves_in_single_line_text() {
389+ let text = Rope::from(SINGLE_LINE_SAMPLE);
390+ let slice = text.slice(..);
391+ let position = pos_at_coords(slice, (0, 0).into());
392+
393+ let mut range = Range::point(position);
394+ let original_anchor = range.anchor;
395+
396+ let moves = IntoIter::new([
397+ (Direction::Forward, 1usize),
398+ (Direction::Forward, 5usize),
399+ (Direction::Backward, 3usize),
400+ ]);
401+
402+ for (direction, amount) in moves {
403+ range = move_horizontally(slice, range, direction, amount, Movement::Extend);
404+ assert_eq!(range.anchor, original_anchor);
405+ }
406+ }
407+
408+ #[test]
409+ fn vertical_moves_in_single_column() {
410+ let text = Rope::from(MULTILINE_SAMPLE);
411+ let slice = dbg!(&text).slice(..);
412+ let position = pos_at_coords(slice, (0, 0).into());
413+ let mut range = Range::point(position);
414+ let moves_and_expected_coordinates = IntoIter::new([
415+ ((Direction::Forward, 1usize), (1, 0)),
416+ ((Direction::Forward, 2usize), (3, 0)),
417+ ((Direction::Backward, 999usize), (0, 0)),
418+ ((Direction::Forward, 3usize), (3, 0)),
419+ ((Direction::Forward, 0usize), (3, 0)),
420+ ((Direction::Backward, 0usize), (3, 0)),
421+ ((Direction::Forward, 5), (4, 0)),
422+ ((Direction::Forward, 999usize), (4, 0)),
423+ ]);
424+
425+ for ((direction, amount), coordinates) in moves_and_expected_coordinates {
426+ range = move_vertically(slice, range, direction, amount, Movement::Move);
427+ assert_eq!(coords_at_pos(slice, range.head), coordinates.into());
428+ assert_eq!(range.head, range.anchor);
429+ }
430+ }
431+
432+ #[test]
433+ fn vertical_moves_jumping_column() {
434+ let text = Rope::from(MULTILINE_SAMPLE);
435+ let slice = text.slice(..);
436+ let position = pos_at_coords(slice, (0, 0).into());
437+ let mut range = Range::point(position);
438+
439+ enum Axis {
440+ H,
441+ V,
442+ }
443+ let moves_and_expected_coordinates = IntoIter::new([
444+ // Places cursor at the end of line
445+ ((Axis::H, Direction::Forward, 8usize), (0, 8)),
446+ // First descent preserves column as the target line is wider
447+ ((Axis::V, Direction::Forward, 1usize), (1, 8)),
448+ // Second descent clamps column as the target line is shorter
449+ ((Axis::V, Direction::Forward, 1usize), (2, 4)),
450+ // Third descent restores the original column
451+ ((Axis::V, Direction::Forward, 1usize), (3, 8)),
452+ // Behaviour is preserved even through long jumps
453+ ((Axis::V, Direction::Backward, 999usize), (0, 8)),
454+ ((Axis::V, Direction::Forward, 999usize), (4, 8)),
455+ ]);
456+
457+ for ((axis, direction, amount), coordinates) in moves_and_expected_coordinates {
458+ range = match axis {
459+ Axis::H => move_horizontally(slice, range, direction, amount, Movement::Move),
460+ Axis::V => move_vertically(slice, range, direction, amount, Movement::Move),
461+ };
462+ assert_eq!(coords_at_pos(slice, range.head), coordinates.into());
463+ assert_eq!(range.head, range.anchor);
464+ }
465+ }
466+
467+ #[test]
468+ fn multibyte_character_column_jumps() {
469+ let text = Rope::from(MULTIBYTE_CHARACTER_SAMPLE);
470+ let slice = text.slice(..);
471+ let position = pos_at_coords(slice, (0, 0).into());
472+ let mut range = Range::point(position);
473+
474+ // FIXME: The behaviour captured in this test diverges from both Kakoune and Vim. These
475+ // will attempt to preserve the horizontal position of the cursor, rather than
476+ // placing it at the same character index.
477+ enum Axis {
478+ H,
479+ V,
480+ }
481+ let moves_and_expected_coordinates = IntoIter::new([
482+ // Places cursor at the fourth kana
483+ ((Axis::H, Direction::Forward, 4), (0, 4)),
484+ // Descent places cursor at the fourth character.
485+ ((Axis::V, Direction::Forward, 1usize), (1, 4)),
486+ ]);
487+
488+ for ((axis, direction, amount), coordinates) in moves_and_expected_coordinates {
489+ range = match axis {
490+ Axis::H => move_horizontally(slice, range, direction, amount, Movement::Move),
491+ Axis::V => move_vertically(slice, range, direction, amount, Movement::Move),
492+ };
493+ assert_eq!(coords_at_pos(slice, range.head), coordinates.into());
494+ assert_eq!(range.head, range.anchor);
495+ }
496+ }
497+
498+ #[test]
499+ #[should_panic]
500+ fn nonsensical_ranges_panic_on_forward_movement_attempt_in_debug_mode() {
501+ move_next_word_start(Rope::from("Sample").slice(..), Range::point(99999999), 1);
502+ }
503+
504+ #[test]
505+ #[should_panic]
506+ fn nonsensical_ranges_panic_on_forward_to_end_movement_attempt_in_debug_mode() {
507+ move_next_word_end(Rope::from("Sample").slice(..), Range::point(99999999), 1);
508+ }
509+
510+ #[test]
511+ #[should_panic]
512+ fn nonsensical_ranges_panic_on_backwards_movement_attempt_in_debug_mode() {
513+ move_prev_word_start(Rope::from("Sample").slice(..), Range::point(99999999), 1);
514+ }
515+
516+ #[test]
517+ fn test_behaviour_when_moving_to_start_of_next_words() {
518+ let tests = array::IntoIter::new([
519+ ("Basic forward motion stops at the first space",
520+ vec![(1, Range::new(0, 0), Range::new(0, 5))]),
521+ (" Starting from a boundary advances the anchor",
522+ vec![(1, Range::new(0, 0), Range::new(1, 9))]),
523+ ("Long whitespace gap is bridged by the head",
524+ vec![(1, Range::new(0, 0), Range::new(0, 10))]),
525+ ("Previous anchor is irrelevant for forward motions",
526+ vec![(1, Range::new(12, 0), Range::new(0, 8))]),
527+ (" Starting from whitespace moves to last space in sequence",
528+ vec![(1, Range::new(0, 0), Range::new(0, 3))]),
529+ ("Starting from mid-word leaves anchor at start position and moves head",
530+ vec![(1, Range::new(3, 3), Range::new(3, 8))]),
531+ ("Identifiers_with_underscores are considered a single word",
532+ vec![(1, Range::new(0, 0), Range::new(0, 28))]),
533+ ("Jumping\n into starting whitespace selects the spaces before 'into'",
534+ vec![(1, Range::new(0, 6), Range::new(8, 11))]),
535+ ("alphanumeric.!,and.?=punctuation are considered 'words' for the purposes of word motion",
536+ vec![
537+ (1, Range::new(0, 0), Range::new(0, 11)),
538+ (1, Range::new(0, 11), Range::new(12, 14)),
539+ (1, Range::new(12, 14), Range::new(15, 17))
540+ ]),
541+ ("... ... punctuation and spaces behave as expected",
542+ vec![
543+ (1, Range::new(0, 0), Range::new(0, 5)),
544+ (1, Range::new(0, 5), Range::new(6, 9)),
545+ ]),
546+ (".._.._ punctuation is not joined by underscores into a single block",
547+ vec![(1, Range::new(0, 0), Range::new(0, 1))]),
548+ ("Newlines\n\nare bridged seamlessly.",
549+ vec![
550+ (1, Range::new(0, 0), Range::new(0, 7)),
551+ (1, Range::new(0, 7), Range::new(10, 13)),
552+ ]),
553+ ("Jumping\n\n\n\n\n\n from newlines to whitespace selects whitespace.",
554+ vec![
555+ (1, Range::new(0, 8), Range::new(13, 15)),
556+ ]),
557+ ("A failed motion does not modify the range",
558+ vec![
559+ (3, Range::new(37, 41), Range::new(37, 41)),
560+ ]),
561+ ("oh oh oh two character words!",
562+ vec![
563+ (1, Range::new(0, 0), Range::new(0, 2)),
564+ (1, Range::new(0, 2), Range::new(3, 5)),
565+ (1, Range::new(0, 1), Range::new(2, 2)),
566+ ]),
567+ ("Multiple motions at once resolve correctly",
568+ vec![
569+ (3, Range::new(0, 0), Range::new(17, 19)),
570+ ]),
571+ ("Excessive motions are performed partially",
572+ vec![
573+ (999, Range::new(0, 0), Range::new(32, 40)),
574+ ]),
575+ ("", // Edge case of moving forward in empty string
576+ vec![
577+ (1, Range::new(0, 0), Range::new(0, 0)),
578+ ]),
579+ ("\n\n\n\n\n", // Edge case of moving forward in all newlines
580+ vec![
581+ (1, Range::new(0, 0), Range::new(0, 4)),
582+ ]),
583+ ("\n \n \n Jumping through alternated space blocks and newlines selects the space blocks",
584+ vec![
585+ (1, Range::new(0, 0), Range::new(1, 3)),
586+ (1, Range::new(1, 3), Range::new(5, 7)),
587+ ]),
588+ ("ヒーリクス multibyte characters behave as normal characters",
589+ vec![
590+ (1, Range::new(0, 0), Range::new(0, 5)),
591+ ]),
592+ ]);
593+
594+ for (sample, scenario) in tests {
595+ for (count, begin, expected_end) in scenario.into_iter() {
596+ let range = move_next_word_start(Rope::from(sample).slice(..), begin, count);
597+ assert_eq!(range, expected_end, "Case failed: [{}]", sample);
598+ }
599+ }
600+ }
601+
602+ #[test]
603+ fn test_behaviour_when_moving_to_start_of_previous_words() {
604+ let tests = array::IntoIter::new([
605+ ("Basic backward motion from the middle of a word",
606+ vec![(1, Range::new(3, 3), Range::new(3, 0))]),
607+ ("Starting from after boundary retreats the anchor",
608+ vec![(1, Range::new(0, 8), Range::new(7, 0))]),
609+ (" Jump to start of a word preceded by whitespace",
610+ vec![(1, Range::new(5, 5), Range::new(5, 4))]),
611+ (" Jump to start of line from start of word preceded by whitespace",
612+ vec![(1, Range::new(4, 4), Range::new(3, 0))]),
613+ ("Previous anchor is irrelevant for backward motions",
614+ vec![(1, Range::new(12, 5), Range::new(5, 0))]),
615+ (" Starting from whitespace moves to first space in sequence",
616+ vec![(1, Range::new(0, 3), Range::new(3, 0))]),
617+ ("Identifiers_with_underscores are considered a single word",
618+ vec![(1, Range::new(0, 20), Range::new(20, 0))]),
619+ ("Jumping\n \nback through a newline selects whitespace",
620+ vec![(1, Range::new(0, 13), Range::new(11, 8))]),
621+ ("Jumping to start of word from the end selects the word",
622+ vec![(1, Range::new(6, 6), Range::new(6, 0))]),
623+ ("alphanumeric.!,and.?=punctuation are considered 'words' for the purposes of word motion",
624+ vec![
625+ (1, Range::new(30, 30), Range::new(30, 21)),
626+ (1, Range::new(30, 21), Range::new(20, 18)),
627+ (1, Range::new(20, 18), Range::new(17, 15))
628+ ]),
629+
630+ ("... ... punctuation and spaces behave as expected",
631+ vec![
632+ (1, Range::new(0, 10), Range::new(9, 6)),
633+ (1, Range::new(9, 6), Range::new(5, 0)),
634+ ]),
635+ (".._.._ punctuation is not joined by underscores into a single block",
636+ vec![(1, Range::new(0, 5), Range::new(4, 3))]),
637+ ("Newlines\n\nare bridged seamlessly.",
638+ vec![
639+ (1, Range::new(0, 10), Range::new(7, 0)),
640+ ]),
641+ ("Jumping \n\n\n\n\nback from within a newline group selects previous block",
642+ vec![
643+ (1, Range::new(0, 13), Range::new(10, 0)),
644+ ]),
645+ ("Failed motions do not modify the range",
646+ vec![
647+ (0, Range::new(3, 0), Range::new(3, 0)),
648+ ]),
649+ ("Multiple motions at once resolve correctly",
650+ vec![
651+ (3, Range::new(18, 18), Range::new(8, 0)),
652+ ]),
653+ ("Excessive motions are performed partially",
654+ vec![
655+ (999, Range::new(40, 40), Range::new(9, 0)),
656+ ]),
657+ ("", // Edge case of moving backwards in empty string
658+ vec![
659+ (1, Range::new(0, 0), Range::new(0, 0)),
660+ ]),
661+ ("\n\n\n\n\n", // Edge case of moving backwards in all newlines
662+ vec![
663+ (1, Range::new(0, 0), Range::new(0, 0)),
664+ ]),
665+ (" \n \nJumping back through alternated space blocks and newlines selects the space blocks",
666+ vec![
667+ (1, Range::new(0, 7), Range::new(6, 4)),
668+ (1, Range::new(6, 4), Range::new(2, 0)),
669+ ]),
670+ ("ヒーリクス multibyte characters behave as normal characters",
671+ vec![
672+ (1, Range::new(0, 5), Range::new(4, 0)),
673+ ]),
674+ ]);
675+
676+ for (sample, scenario) in tests {
677+ for (count, begin, expected_end) in scenario.into_iter() {
678+ let range = move_prev_word_start(Rope::from(sample).slice(..), begin, count);
679+ assert_eq!(range, expected_end, "Case failed: [{}]", sample);
680+ }
681+ }
682+ }
683+
684+ #[test]
685+ fn test_behaviour_when_moving_to_end_of_next_words() {
686+ let tests = array::IntoIter::new([
687+ ("Basic forward motion from the start of a word to the end of it",
688+ vec![(1, Range::new(0, 0), Range::new(0, 4))]),
689+ ("Basic forward motion from the end of a word to the end of the next",
690+ vec![(1, Range::new(0, 4), Range::new(5, 12))]),
691+ ("Basic forward motion from the middle of a word to the end of it",
692+ vec![(1, Range::new(2, 2), Range::new(2, 4))]),
693+ (" Jumping to end of a word preceded by whitespace",
694+ vec![(1, Range::new(0, 0), Range::new(0, 10))]),
695+ (" Starting from a boundary advances the anchor",
696+ vec![(1, Range::new(0, 0), Range::new(1, 8))]),
697+ ("Previous anchor is irrelevant for end of word motion",
698+ vec![(1, Range::new(12, 2), Range::new(2, 7))]),
699+ ("Identifiers_with_underscores are considered a single word",
700+ vec![(1, Range::new(0, 0), Range::new(0, 27))]),
701+ ("Jumping\n into starting whitespace selects up to the end of next word",
702+ vec![(1, Range::new(0, 6), Range::new(8, 15))]),
703+ ("alphanumeric.!,and.?=punctuation are considered 'words' for the purposes of word motion",
704+ vec![
705+ (1, Range::new(0, 0), Range::new(0, 11)),
706+ (1, Range::new(0, 11), Range::new(12, 14)),
707+ (1, Range::new(12, 14), Range::new(15, 17))
708+ ]),
709+ ("... ... punctuation and spaces behave as expected",
710+ vec![
711+ (1, Range::new(0, 0), Range::new(0, 2)),
712+ (1, Range::new(0, 2), Range::new(3, 8)),
713+ ]),
714+ (".._.._ punctuation is not joined by underscores into a single block",
715+ vec![(1, Range::new(0, 0), Range::new(0, 1))]),
716+ ("Newlines\n\nare bridged seamlessly.",
717+ vec![
718+ (1, Range::new(0, 0), Range::new(0, 7)),
719+ (1, Range::new(0, 7), Range::new(10, 12)),
720+ ]),
721+ ("Jumping\n\n\n\n\n\n from newlines to whitespace selects to end of next word.",
722+ vec![
723+ (1, Range::new(0, 8), Range::new(13, 19)),
724+ ]),
725+ ("A failed motion does not modify the range",
726+ vec![
727+ (3, Range::new(37, 41), Range::new(37, 41)),
728+ ]),
729+ ("Multiple motions at once resolve correctly",
730+ vec![
731+ (3, Range::new(0, 0), Range::new(16, 18)),
732+ ]),
733+ ("Excessive motions are performed partially",
734+ vec![
735+ (999, Range::new(0, 0), Range::new(31, 40)),
736+ ]),
737+ ("", // Edge case of moving forward in empty string
738+ vec![
739+ (1, Range::new(0, 0), Range::new(0, 0)),
740+ ]),
741+ ("\n\n\n\n\n", // Edge case of moving forward in all newlines
742+ vec![
743+ (1, Range::new(0, 0), Range::new(0, 4)),
744+ ]),
745+ ("\n \n \n Jumping through alternated space blocks and newlines selects the space blocks",
746+ vec![
747+ (1, Range::new(0, 0), Range::new(1, 3)),
748+ (1, Range::new(1, 3), Range::new(5, 7)),
749+ ]),
750+ ("ヒーリクス multibyte characters behave as normal characters",
751+ vec![
752+ (1, Range::new(0, 0), Range::new(0, 4)),
753+ ]),
754+ ]);
755+
756+ for (sample, scenario) in tests {
757+ for (count, begin, expected_end) in scenario.into_iter() {
758+ let range = move_next_word_end(Rope::from(sample).slice(..), begin, count);
759+ assert_eq!(range, expected_end, "Case failed: [{}]", sample);
760+ }
761+ }
762+ }
763+
282764 #[test]
283765 fn test_categorize() {
284766 const WORD_TEST_CASE: &'static str =
285767 "_hello_world_あいうえおー12345678901234567890";
286− const PUNCTUATION_TEST_CASE: &'static str = "!\"#$%&\'()*+,-./:;<=>?@[\\]^`{|}~!”#$%&’()*+、。:;<=>?@「」^`{|}~";
768+ const PUNCTUATION_TEST_CASE: &'static str =
769+ "!\"#$%&\'()*+,-./:;<=>?@[\\]^`{|}~!”#$%&’()*+、。:;<=>?@「」^`{|}~";
287770 const WHITESPACE_TEST_CASE: &'static str = "      ";
288771
289772 assert_eq!(Category::Eol, categorize('\n'));
helix-core/src/selection.rs+4
@@ -35,6 +35,10 @@ impl Range {
3535 }
3636 }
3737
38+ pub fn point(head: usize) -> Self {
39+ Self::new(head, head)
40+ }
41+
3842 /// Start of the range.
3943 #[inline]
4044 #[must_use]
helix-core/src/words.rsdeleted−68
@@ -1,68 +0,0 @@
1−use crate::movement::{categorize, is_punctuation, is_word, skip_over_prev};
2−use ropey::RopeSlice;
3−
4−#[must_use]
5−pub fn nth_prev_word_boundary(slice: RopeSlice, mut char_idx: usize, count: usize) -> usize {
6− let mut with_end = false;
7−
8− for _ in 0..count {
9− if char_idx == 0 {
10− break;
11− }
12−
13− // return if not skip while?
14− skip_over_prev(slice, &mut char_idx, |ch| ch == '\n');
15−
16− with_end = skip_over_prev(slice, &mut char_idx, char::is_whitespace);
17−
18− // refetch
19− let ch = slice.char(char_idx);
20−
21− if is_word(ch) {
22− with_end = skip_over_prev(slice, &mut char_idx, is_word);
23− } else if is_punctuation(ch) {
24− with_end = skip_over_prev(slice, &mut char_idx, is_punctuation);
25− }
26− }
27−
28− if with_end || char_idx == 0 {
29− char_idx
30− } else {
31− char_idx + 1
32− }
33−}
34−
35−#[test]
36−fn different_prev_word_boundary() {
37− use ropey::Rope;
38− let t = |x, y| {
39− let text = Rope::from(x);
40− let out = nth_prev_word_boundary(text.slice(..), text.len_chars().saturating_sub(1), 1);
41− assert_eq!(text.slice(..out), y, r#"from "{}""#, x);
42− };
43− t("abcd\nefg\nwrs", "abcd\nefg\n");
44− t("abcd\nefg\n", "abcd\n");
45− t("abcd\n", "");
46− t("hello, world!", "hello, world");
47− t("hello, world", "hello, ");
48− t("hello, ", "hello");
49− t("hello", "");
50− t(",", "");
51− t("こんにちは、世界!", "こんにちは、世界");
52− t("こんにちは、世界", "こんにちは、");
53− t("こんにちは、", "こんにちは");
54− t("こんにちは", "");
55− t("この世界。", "この世界");
56− t("この世界", "");
57− t("お前はもう死んでいる", "");
58− t("その300円です", ""); // TODO: should stop at 300
59− t("唱k", ""); // TODO: should stop at 唱
60− t(",", "");
61− t("1 + 1 = 2", "1 + 1 = ");
62− t("1 + 1 =", "1 + 1 ");
63− t("1 + 1", "1 + ");
64− t("1 + ", "1 ");
65− t("1 ", "");
66− t("1+1=2", "1+1=");
67− t("", "");
68−}
helix-term/src/commands.rs+32−81
@@ -4,8 +4,8 @@ use helix_core::{
44 movement::{self, Direction},
55 object, pos_at_coords,
66 regex::{self, Regex},
7− register, search, selection, words, Change, ChangeSet, Position, Range, Rope, RopeSlice,
8− Selection, SmallVec, Tendril, Transaction,
7+ register, search, selection, Change, ChangeSet, Position, Range, Rope, RopeSlice, Selection,
8+ SmallVec, Tendril, Transaction,
99};
1010
1111use helix_view::{
@@ -19,6 +19,7 @@ use helix_lsp::{
1919 util::{lsp_pos_to_pos, pos_to_lsp_pos, range_to_lsp_range},
2020 OffsetEncoding,
2121};
22+use movement::Movement;
2223
2324use crate::{
2425 compositor::{Callback, Component, Compositor},
@@ -29,8 +30,10 @@ use crate::application::{LspCallbackWrapper, LspCallbacks};
2930use futures_util::FutureExt;
3031use std::future::Future;
3132
32−use std::borrow::Cow;
33−use std::path::{Path, PathBuf};
33+use std::{
34+ borrow::Cow,
35+ path::{Path, PathBuf},
36+};
3437
3538use crossterm::event::{KeyCode, KeyEvent};
3639use once_cell::sync::Lazy;
@@ -132,13 +135,7 @@ pub fn move_char_left(cx: &mut Context) {
132135 let (view, doc) = cx.current();
133136 let text = doc.text().slice(..);
134137 let selection = doc.selection(view.id).transform(|range| {
135− movement::move_horizontally(
136− text,
137− range,
138− Direction::Backward,
139− count,
140− false, /* extend */
141− )
138+ movement::move_horizontally(text, range, Direction::Backward, count, Movement::Move)
142139 });
143140 doc.set_selection(view.id, selection);
144141}
@@ -148,13 +145,7 @@ pub fn move_char_right(cx: &mut Context) {
148145 let (view, doc) = cx.current();
149146 let text = doc.text().slice(..);
150147 let selection = doc.selection(view.id).transform(|range| {
151− movement::move_horizontally(
152− text,
153− range,
154− Direction::Forward,
155− count,
156− false, /* extend */
157− )
148+ movement::move_horizontally(text, range, Direction::Forward, count, Movement::Move)
158149 });
159150 doc.set_selection(view.id, selection);
160151}
@@ -164,13 +155,7 @@ pub fn move_line_up(cx: &mut Context) {
164155 let (view, doc) = cx.current();
165156 let text = doc.text().slice(..);
166157 let selection = doc.selection(view.id).transform(|range| {
167− movement::move_vertically(
168− text,
169− range,
170− Direction::Backward,
171− count,
172− false, /* extend */
173− )
158+ movement::move_vertically(text, range, Direction::Backward, count, Movement::Move)
174159 });
175160 doc.set_selection(view.id, selection);
176161}
@@ -180,13 +165,7 @@ pub fn move_line_down(cx: &mut Context) {
180165 let (view, doc) = cx.current();
181166 let text = doc.text().slice(..);
182167 let selection = doc.selection(view.id).transform(|range| {
183− movement::move_vertically(
184− text,
185− range,
186− Direction::Forward,
187− count,
188− false, /* extend */
189− )
168+ movement::move_vertically(text, range, Direction::Forward, count, Movement::Move)
190169 });
191170 doc.set_selection(view.id, selection);
192171}
@@ -249,9 +228,9 @@ pub fn move_next_word_start(cx: &mut Context) {
249228 let (view, doc) = cx.current();
250229 let text = doc.text().slice(..);
251230
252− let selection = doc.selection(view.id).transform(|range| {
253− movement::move_next_word_start(text, range.head, count).unwrap_or(range)
254− });
231+ let selection = doc
232+ .selection(view.id)
233+ .transform(|range| movement::move_next_word_start(text, range, count));
255234
256235 doc.set_selection(view.id, selection);
257236}
@@ -261,9 +240,9 @@ pub fn move_prev_word_start(cx: &mut Context) {
261240 let (view, doc) = cx.current();
262241 let text = doc.text().slice(..);
263242
264− let selection = doc.selection(view.id).transform(|range| {
265− movement::move_prev_word_start(text, range.head, count).unwrap_or(range)
266− });
243+ let selection = doc
244+ .selection(view.id)
245+ .transform(|range| movement::move_prev_word_start(text, range, count));
267246
268247 doc.set_selection(view.id, selection);
269248}
@@ -275,7 +254,7 @@ pub fn move_next_word_end(cx: &mut Context) {
275254
276255 let selection = doc
277256 .selection(view.id)
278− .transform(|range| movement::move_next_word_end(text, range.head, count).unwrap_or(range));
257+ .transform(|range| movement::move_next_word_end(text, range, count));
279258
280259 doc.set_selection(view.id, selection);
281260}
@@ -300,7 +279,7 @@ pub fn extend_next_word_start(cx: &mut Context) {
300279 let text = doc.text().slice(..);
301280
302281 let selection = doc.selection(view.id).transform(|mut range| {
303− let word = movement::move_next_word_start(text, range.head, count).unwrap_or(range);
282+ let word = movement::move_next_word_start(text, range, count);
304283 let pos = word.head;
305284 Range::new(range.anchor, pos)
306285 });
@@ -314,7 +293,7 @@ pub fn extend_prev_word_start(cx: &mut Context) {
314293 let text = doc.text().slice(..);
315294
316295 let selection = doc.selection(view.id).transform(|mut range| {
317− let word = movement::move_prev_word_start(text, range.head, count).unwrap_or(range);
296+ let word = movement::move_prev_word_start(text, range, count);
318297 let pos = word.head;
319298 Range::new(range.anchor, pos)
320299 });
@@ -327,7 +306,7 @@ pub fn extend_next_word_end(cx: &mut Context) {
327306 let text = doc.text().slice(..);
328307
329308 let selection = doc.selection(view.id).transform(|mut range| {
330− let word = movement::move_next_word_end(text, range.head, count).unwrap_or(range);
309+ let word = movement::move_next_word_end(text, range, count);
331310 let pos = word.head;
332311 Range::new(range.anchor, pos)
333312 });
@@ -578,13 +557,7 @@ pub fn extend_char_left(cx: &mut Context) {
578557 let (view, doc) = cx.current();
579558 let text = doc.text().slice(..);
580559 let selection = doc.selection(view.id).transform(|range| {
581− movement::move_horizontally(
582− text,
583− range,
584− Direction::Backward,
585− count,
586− true, /* extend */
587− )
560+ movement::move_horizontally(text, range, Direction::Backward, count, Movement::Extend)
588561 });
589562 doc.set_selection(view.id, selection);
590563}
@@ -594,13 +567,7 @@ pub fn extend_char_right(cx: &mut Context) {
594567 let (view, doc) = cx.current();
595568 let text = doc.text().slice(..);
596569 let selection = doc.selection(view.id).transform(|range| {
597− movement::move_horizontally(
598− text,
599− range,
600− Direction::Forward,
601− count,
602− true, /* extend */
603− )
570+ movement::move_horizontally(text, range, Direction::Forward, count, Movement::Extend)
604571 });
605572 doc.set_selection(view.id, selection);
606573}
@@ -610,13 +577,7 @@ pub fn extend_line_up(cx: &mut Context) {
610577 let (view, doc) = cx.current();
611578 let text = doc.text().slice(..);
612579 let selection = doc.selection(view.id).transform(|range| {
613− movement::move_vertically(
614− text,
615− range,
616− Direction::Backward,
617− count,
618− true, /* extend */
619− )
580+ movement::move_vertically(text, range, Direction::Backward, count, Movement::Extend)
620581 });
621582 doc.set_selection(view.id, selection);
622583}
@@ -626,13 +587,7 @@ pub fn extend_line_down(cx: &mut Context) {
626587 let (view, doc) = cx.current();
627588 let text = doc.text().slice(..);
628589 let selection = doc.selection(view.id).transform(|range| {
629− movement::move_vertically(
630− text,
631− range,
632− Direction::Forward,
633− count,
634− true, /* extend */
635− )
590+ movement::move_vertically(text, range, Direction::Forward, count, Movement::Extend)
636591 });
637592 doc.set_selection(view.id, selection);
638593}
@@ -1920,15 +1875,11 @@ pub mod insert {
19201875 let count = cx.count();
19211876 let (view, doc) = cx.current();
19221877 let text = doc.text().slice(..);
1923− let transaction =
1924− Transaction::change_by_selection(doc.text(), doc.selection(view.id), |range| {
1925− (
1926− words::nth_prev_word_boundary(text, range.head, count),
1927− range.head,
1928− None,
1929− )
1930− });
1931− doc.apply(&transaction, view.id);
1878+ let selection = doc
1879+ .selection(view.id)
1880+ .transform(|range| movement::move_prev_word_start(text, range, count));
1881+ doc.set_selection(view.id, selection);
1882+ delete_selection(cx)
19321883 }
19331884}
19341885
@@ -2183,7 +2134,7 @@ pub fn format_selections(cx: &mut Context) {
21832134}
21842135
21852136pub fn join_selections(cx: &mut Context) {
2186− use movement::skip_over_next;
2137+ use movement::skip_while;
21872138 let (view, doc) = cx.current();
21882139 let text = doc.text();
21892140 let slice = doc.text().slice(..);
@@ -2204,7 +2155,7 @@ pub fn join_selections(cx: &mut Context) {
22042155 for line in lines {
22052156 let mut start = text.line_to_char(line + 1).saturating_sub(1);
22062157 let mut end = start + 1;
2207− skip_over_next(slice, &mut end, |ch| matches!(ch, ' ' | '\t'));
2158+ end = skip_while(slice, end, |ch| matches!(ch, ' ' | '\t')).unwrap_or(end);
22082159
22092160 // need to skip from start, not end
22102161 let change = (start, end, Some(fragment.clone()));