motion.rs

  1use editor::{
  2    char_kind,
  3    display_map::{DisplaySnapshot, ToDisplayPoint},
  4    movement, Bias, CharKind, DisplayPoint,
  5};
  6use gpui::{actions, impl_actions, MutableAppContext};
  7use language::{Selection, SelectionGoal};
  8use serde::Deserialize;
  9use workspace::Workspace;
 10
 11use crate::{
 12    normal::normal_motion,
 13    state::{Mode, Operator},
 14    visual::visual_motion,
 15    Vim,
 16};
 17
 18#[derive(Copy, Clone, Debug)]
 19pub enum Motion {
 20    Left,
 21    Down,
 22    Up,
 23    Right,
 24    NextWordStart { ignore_punctuation: bool },
 25    NextWordEnd { ignore_punctuation: bool },
 26    PreviousWordStart { ignore_punctuation: bool },
 27    FirstNonWhitespace,
 28    CurrentLine,
 29    StartOfLine,
 30    EndOfLine,
 31    StartOfDocument,
 32    EndOfDocument,
 33    Matching,
 34}
 35
 36#[derive(Clone, Deserialize, PartialEq)]
 37#[serde(rename_all = "camelCase")]
 38struct NextWordStart {
 39    #[serde(default)]
 40    ignore_punctuation: bool,
 41}
 42
 43#[derive(Clone, Deserialize, PartialEq)]
 44#[serde(rename_all = "camelCase")]
 45struct NextWordEnd {
 46    #[serde(default)]
 47    ignore_punctuation: bool,
 48}
 49
 50#[derive(Clone, Deserialize, PartialEq)]
 51#[serde(rename_all = "camelCase")]
 52struct PreviousWordStart {
 53    #[serde(default)]
 54    ignore_punctuation: bool,
 55}
 56
 57actions!(
 58    vim,
 59    [
 60        Left,
 61        Down,
 62        Up,
 63        Right,
 64        FirstNonWhitespace,
 65        StartOfLine,
 66        EndOfLine,
 67        CurrentLine,
 68        StartOfDocument,
 69        EndOfDocument,
 70        Matching,
 71    ]
 72);
 73impl_actions!(vim, [NextWordStart, NextWordEnd, PreviousWordStart]);
 74
 75pub fn init(cx: &mut MutableAppContext) {
 76    cx.add_action(|_: &mut Workspace, _: &Left, cx: _| motion(Motion::Left, cx));
 77    cx.add_action(|_: &mut Workspace, _: &Down, cx: _| motion(Motion::Down, cx));
 78    cx.add_action(|_: &mut Workspace, _: &Up, cx: _| motion(Motion::Up, cx));
 79    cx.add_action(|_: &mut Workspace, _: &Right, cx: _| motion(Motion::Right, cx));
 80    cx.add_action(|_: &mut Workspace, _: &FirstNonWhitespace, cx: _| {
 81        motion(Motion::FirstNonWhitespace, cx)
 82    });
 83    cx.add_action(|_: &mut Workspace, _: &StartOfLine, cx: _| motion(Motion::StartOfLine, cx));
 84    cx.add_action(|_: &mut Workspace, _: &EndOfLine, cx: _| motion(Motion::EndOfLine, cx));
 85    cx.add_action(|_: &mut Workspace, _: &CurrentLine, cx: _| motion(Motion::CurrentLine, cx));
 86    cx.add_action(|_: &mut Workspace, _: &StartOfDocument, cx: _| {
 87        motion(Motion::StartOfDocument, cx)
 88    });
 89    cx.add_action(|_: &mut Workspace, _: &EndOfDocument, cx: _| motion(Motion::EndOfDocument, cx));
 90    cx.add_action(|_: &mut Workspace, _: &Matching, cx: _| motion(Motion::Matching, cx));
 91
 92    cx.add_action(
 93        |_: &mut Workspace, &NextWordStart { ignore_punctuation }: &NextWordStart, cx: _| {
 94            motion(Motion::NextWordStart { ignore_punctuation }, cx)
 95        },
 96    );
 97    cx.add_action(
 98        |_: &mut Workspace, &NextWordEnd { ignore_punctuation }: &NextWordEnd, cx: _| {
 99            motion(Motion::NextWordEnd { ignore_punctuation }, cx)
100        },
101    );
102    cx.add_action(
103        |_: &mut Workspace,
104         &PreviousWordStart { ignore_punctuation }: &PreviousWordStart,
105         cx: _| { motion(Motion::PreviousWordStart { ignore_punctuation }, cx) },
106    );
107}
108
109fn motion(motion: Motion, cx: &mut MutableAppContext) {
110    Vim::update(cx, |vim, cx| {
111        if let Some(Operator::Namespace(_)) = vim.active_operator() {
112            vim.pop_operator(cx);
113        }
114    });
115    match Vim::read(cx).state.mode {
116        Mode::Normal => normal_motion(motion, cx),
117        Mode::Visual { .. } => visual_motion(motion, cx),
118        Mode::Insert => {
119            // Shouldn't execute a motion in insert mode. Ignoring
120        }
121    }
122}
123
124// Motion handling is specified here:
125// https://github.com/vim/vim/blob/master/runtime/doc/motion.txt
126impl Motion {
127    pub fn linewise(self) -> bool {
128        use Motion::*;
129        matches!(
130            self,
131            Down | Up | StartOfDocument | EndOfDocument | CurrentLine
132        )
133    }
134
135    pub fn inclusive(self) -> bool {
136        use Motion::*;
137        if self.linewise() {
138            return true;
139        }
140
141        match self {
142            EndOfLine | NextWordEnd { .. } | Matching => true,
143            Left | Right | StartOfLine | NextWordStart { .. } | PreviousWordStart { .. } => false,
144            _ => panic!("Exclusivity not defined for {self:?}"),
145        }
146    }
147
148    pub fn move_point(
149        self,
150        map: &DisplaySnapshot,
151        point: DisplayPoint,
152        goal: SelectionGoal,
153    ) -> (DisplayPoint, SelectionGoal) {
154        use Motion::*;
155        match self {
156            Left => (left(map, point), SelectionGoal::None),
157            Down => movement::down(map, point, goal, true),
158            Up => movement::up(map, point, goal, true),
159            Right => (right(map, point), SelectionGoal::None),
160            NextWordStart { ignore_punctuation } => (
161                next_word_start(map, point, ignore_punctuation),
162                SelectionGoal::None,
163            ),
164            NextWordEnd { ignore_punctuation } => (
165                next_word_end(map, point, ignore_punctuation),
166                SelectionGoal::None,
167            ),
168            PreviousWordStart { ignore_punctuation } => (
169                previous_word_start(map, point, ignore_punctuation),
170                SelectionGoal::None,
171            ),
172            FirstNonWhitespace => (first_non_whitespace(map, point), SelectionGoal::None),
173            StartOfLine => (start_of_line(map, point), SelectionGoal::None),
174            EndOfLine => (end_of_line(map, point), SelectionGoal::None),
175            CurrentLine => (end_of_line(map, point), SelectionGoal::None),
176            StartOfDocument => (start_of_document(map, point), SelectionGoal::None),
177            EndOfDocument => (end_of_document(map, point), SelectionGoal::None),
178            Matching => (matching(map, point), SelectionGoal::None),
179        }
180    }
181
182    // Expands a selection using self motion for an operator
183    pub fn expand_selection(
184        self,
185        map: &DisplaySnapshot,
186        selection: &mut Selection<DisplayPoint>,
187        expand_to_surrounding_newline: bool,
188    ) {
189        let (head, goal) = self.move_point(map, selection.head(), selection.goal);
190        selection.set_head(head, goal);
191
192        if self.linewise() {
193            selection.start = map.prev_line_boundary(selection.start.to_point(map)).1;
194
195            if expand_to_surrounding_newline {
196                if selection.end.row() < map.max_point().row() {
197                    *selection.end.row_mut() += 1;
198                    *selection.end.column_mut() = 0;
199                    selection.end = map.clip_point(selection.end, Bias::Right);
200                    // Don't reset the end here
201                    return;
202                } else if selection.start.row() > 0 {
203                    *selection.start.row_mut() -= 1;
204                    *selection.start.column_mut() = map.line_len(selection.start.row());
205                    selection.start = map.clip_point(selection.start, Bias::Left);
206                }
207            }
208
209            selection.end = map.next_line_boundary(selection.end.to_point(map)).1;
210        } else {
211            // If the motion is exclusive and the end of the motion is in column 1, the
212            // end of the motion is moved to the end of the previous line and the motion
213            // becomes inclusive. Example: "}" moves to the first line after a paragraph,
214            // but "d}" will not include that line.
215            let mut inclusive = self.inclusive();
216            if !inclusive
217                && selection.end.row() > selection.start.row()
218                && selection.end.column() == 0
219                && selection.end.row() > 0
220            {
221                inclusive = true;
222                *selection.end.row_mut() -= 1;
223                *selection.end.column_mut() = 0;
224                selection.end = map.clip_point(
225                    map.next_line_boundary(selection.end.to_point(map)).1,
226                    Bias::Left,
227                );
228            }
229
230            if inclusive && selection.end.column() < map.line_len(selection.end.row()) {
231                *selection.end.column_mut() += 1;
232            }
233        }
234    }
235}
236
237fn left(map: &DisplaySnapshot, mut point: DisplayPoint) -> DisplayPoint {
238    *point.column_mut() = point.column().saturating_sub(1);
239    map.clip_point(point, Bias::Left)
240}
241
242fn right(map: &DisplaySnapshot, mut point: DisplayPoint) -> DisplayPoint {
243    *point.column_mut() += 1;
244    map.clip_point(point, Bias::Right)
245}
246
247fn next_word_start(
248    map: &DisplaySnapshot,
249    point: DisplayPoint,
250    ignore_punctuation: bool,
251) -> DisplayPoint {
252    let mut crossed_newline = false;
253    movement::find_boundary(map, point, |left, right| {
254        let left_kind = char_kind(left).coerce_punctuation(ignore_punctuation);
255        let right_kind = char_kind(right).coerce_punctuation(ignore_punctuation);
256        let at_newline = right == '\n';
257
258        let found = (left_kind != right_kind && !right.is_whitespace())
259            || at_newline && crossed_newline
260            || at_newline && left == '\n'; // Prevents skipping repeated empty lines
261
262        if at_newline {
263            crossed_newline = true;
264        }
265        found
266    })
267}
268
269fn next_word_end(
270    map: &DisplaySnapshot,
271    mut point: DisplayPoint,
272    ignore_punctuation: bool,
273) -> DisplayPoint {
274    *point.column_mut() += 1;
275    point = movement::find_boundary(map, point, |left, right| {
276        let left_kind = char_kind(left).coerce_punctuation(ignore_punctuation);
277        let right_kind = char_kind(right).coerce_punctuation(ignore_punctuation);
278
279        left_kind != right_kind && !left.is_whitespace()
280    });
281    // find_boundary clips, so if the character after the next character is a newline or at the end of the document, we know
282    // we have backtraced already
283    if !map
284        .chars_at(point)
285        .nth(1)
286        .map(|c| c == '\n')
287        .unwrap_or(true)
288    {
289        *point.column_mut() = point.column().saturating_sub(1);
290    }
291    map.clip_point(point, Bias::Left)
292}
293
294fn previous_word_start(
295    map: &DisplaySnapshot,
296    mut point: DisplayPoint,
297    ignore_punctuation: bool,
298) -> DisplayPoint {
299    // This works even though find_preceding_boundary is called for every character in the line containing
300    // cursor because the newline is checked only once.
301    point = movement::find_preceding_boundary(map, point, |left, right| {
302        let left_kind = char_kind(left).coerce_punctuation(ignore_punctuation);
303        let right_kind = char_kind(right).coerce_punctuation(ignore_punctuation);
304
305        (left_kind != right_kind && !right.is_whitespace()) || left == '\n'
306    });
307    point
308}
309
310fn first_non_whitespace(map: &DisplaySnapshot, mut point: DisplayPoint) -> DisplayPoint {
311    let mut column = 0;
312    for ch in map.chars_at(DisplayPoint::new(point.row(), 0)) {
313        if ch == '\n' {
314            return point;
315        }
316
317        if char_kind(ch) != CharKind::Whitespace {
318            break;
319        }
320
321        column += ch.len_utf8() as u32;
322    }
323
324    *point.column_mut() = column;
325    map.clip_point(point, Bias::Left)
326}
327
328fn start_of_line(map: &DisplaySnapshot, point: DisplayPoint) -> DisplayPoint {
329    map.prev_line_boundary(point.to_point(map)).1
330}
331
332fn end_of_line(map: &DisplaySnapshot, point: DisplayPoint) -> DisplayPoint {
333    map.clip_point(map.next_line_boundary(point.to_point(map)).1, Bias::Left)
334}
335
336fn start_of_document(map: &DisplaySnapshot, point: DisplayPoint) -> DisplayPoint {
337    let mut new_point = 0usize.to_display_point(map);
338    *new_point.column_mut() = point.column();
339    map.clip_point(new_point, Bias::Left)
340}
341
342fn end_of_document(map: &DisplaySnapshot, point: DisplayPoint) -> DisplayPoint {
343    let mut new_point = map.max_point();
344    *new_point.column_mut() = point.column();
345    map.clip_point(new_point, Bias::Left)
346}
347
348fn matching(map: &DisplaySnapshot, point: DisplayPoint) -> DisplayPoint {
349    let offset = point.to_offset(map, Bias::Left);
350    if let Some((open_range, close_range)) =
351        map.buffer_snapshot.enclosing_bracket_ranges(offset..offset)
352    {
353        if open_range.contains(&offset) {
354            close_range.start.to_display_point(map)
355        } else {
356            open_range.start.to_display_point(map)
357        }
358    } else {
359        point
360    }
361}