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