1use crate::Vim;
2use editor::{
3 DisplayPoint, Editor, EditorSettings, SelectionEffects,
4 display_map::{DisplayRow, ToDisplayPoint},
5 scroll::ScrollAmount,
6};
7use gpui::{Context, Window, actions};
8use language::Bias;
9use settings::Settings;
10
11actions!(
12 vim,
13 [
14 LineUp,
15 LineDown,
16 ColumnRight,
17 ColumnLeft,
18 ScrollUp,
19 ScrollDown,
20 PageUp,
21 PageDown
22 ]
23);
24
25pub fn register(editor: &mut Editor, cx: &mut Context<Vim>) {
26 Vim::action(editor, cx, |vim, _: &LineDown, window, cx| {
27 vim.scroll(false, window, cx, |c| ScrollAmount::Line(c.unwrap_or(1.)))
28 });
29 Vim::action(editor, cx, |vim, _: &LineUp, window, cx| {
30 vim.scroll(false, window, cx, |c| ScrollAmount::Line(-c.unwrap_or(1.)))
31 });
32 Vim::action(editor, cx, |vim, _: &ColumnRight, window, cx| {
33 vim.scroll(false, window, cx, |c| ScrollAmount::Column(c.unwrap_or(1.)))
34 });
35 Vim::action(editor, cx, |vim, _: &ColumnLeft, window, cx| {
36 vim.scroll(false, window, cx, |c| {
37 ScrollAmount::Column(-c.unwrap_or(1.))
38 })
39 });
40 Vim::action(editor, cx, |vim, _: &PageDown, window, cx| {
41 vim.scroll(false, window, cx, |c| ScrollAmount::Page(c.unwrap_or(1.)))
42 });
43 Vim::action(editor, cx, |vim, _: &PageUp, window, cx| {
44 vim.scroll(false, window, cx, |c| ScrollAmount::Page(-c.unwrap_or(1.)))
45 });
46 Vim::action(editor, cx, |vim, _: &ScrollDown, window, cx| {
47 vim.scroll(true, window, cx, |c| {
48 if let Some(c) = c {
49 ScrollAmount::Line(c)
50 } else {
51 ScrollAmount::Page(0.5)
52 }
53 })
54 });
55 Vim::action(editor, cx, |vim, _: &ScrollUp, window, cx| {
56 vim.scroll(true, window, cx, |c| {
57 if let Some(c) = c {
58 ScrollAmount::Line(-c)
59 } else {
60 ScrollAmount::Page(-0.5)
61 }
62 })
63 });
64}
65
66impl Vim {
67 fn scroll(
68 &mut self,
69 move_cursor: bool,
70 window: &mut Window,
71 cx: &mut Context<Self>,
72 by: fn(c: Option<f32>) -> ScrollAmount,
73 ) {
74 let amount = by(Vim::take_count(cx).map(|c| c as f32));
75 Vim::take_forced_motion(cx);
76 self.exit_temporary_normal(window, cx);
77 self.update_editor(window, cx, |_, editor, window, cx| {
78 scroll_editor(editor, move_cursor, &amount, window, cx)
79 });
80 }
81}
82
83fn scroll_editor(
84 editor: &mut Editor,
85 preserve_cursor_position: bool,
86 amount: &ScrollAmount,
87 window: &mut Window,
88 cx: &mut Context<Editor>,
89) {
90 let should_move_cursor = editor.newest_selection_on_screen(cx).is_eq();
91 let old_top_anchor = editor.scroll_manager.anchor().anchor;
92
93 if editor.scroll_hover(amount, window, cx) {
94 return;
95 }
96
97 let full_page_up = amount.is_full_page() && amount.direction().is_upwards();
98 let amount = match (amount.is_full_page(), editor.visible_line_count()) {
99 (true, Some(visible_line_count)) => {
100 if amount.direction().is_upwards() {
101 ScrollAmount::Line(amount.lines(visible_line_count) + 1.0)
102 } else {
103 ScrollAmount::Line(amount.lines(visible_line_count) - 1.0)
104 }
105 }
106 _ => amount.clone(),
107 };
108
109 editor.scroll_screen(&amount, window, cx);
110 if !should_move_cursor {
111 return;
112 }
113
114 let Some(visible_line_count) = editor.visible_line_count() else {
115 return;
116 };
117
118 let top_anchor = editor.scroll_manager.anchor().anchor;
119 let vertical_scroll_margin = EditorSettings::get_global(cx).vertical_scroll_margin;
120
121 editor.change_selections(
122 SelectionEffects::no_scroll().nav_history(false),
123 window,
124 cx,
125 |s| {
126 s.move_with(|map, selection| {
127 let mut head = selection.head();
128 let top = top_anchor.to_display_point(map);
129 let starting_column = head.column();
130
131 let vertical_scroll_margin =
132 (vertical_scroll_margin as u32).min(visible_line_count as u32 / 2);
133
134 if preserve_cursor_position {
135 let old_top = old_top_anchor.to_display_point(map);
136 let new_row = if old_top.row() == top.row() {
137 DisplayRow(
138 head.row()
139 .0
140 .saturating_add_signed(amount.lines(visible_line_count) as i32),
141 )
142 } else {
143 DisplayRow(top.row().0 + selection.head().row().0 - old_top.row().0)
144 };
145 head = map.clip_point(DisplayPoint::new(new_row, head.column()), Bias::Left)
146 }
147
148 let min_row = if top.row().0 == 0 {
149 DisplayRow(0)
150 } else {
151 DisplayRow(top.row().0 + vertical_scroll_margin)
152 };
153
154 let max_visible_row = top.row().0.saturating_add(
155 (visible_line_count as u32).saturating_sub(1 + vertical_scroll_margin),
156 );
157 // scroll off the end.
158 let max_row = if top.row().0 + visible_line_count as u32 >= map.max_point().row().0
159 {
160 map.max_point().row()
161 } else {
162 DisplayRow(
163 (top.row().0 + visible_line_count as u32)
164 .saturating_sub(1 + vertical_scroll_margin),
165 )
166 };
167
168 let new_row = if full_page_up {
169 // Special-casing ctrl-b/page-up, which is special-cased by Vim, it seems
170 // to always put the cursor on the last line of the page, even if the cursor
171 // was before that.
172 DisplayRow(max_visible_row)
173 } else if head.row() < min_row {
174 min_row
175 } else if head.row() > max_row {
176 max_row
177 } else {
178 head.row()
179 };
180 let new_head =
181 map.clip_point(DisplayPoint::new(new_row, starting_column), Bias::Left);
182
183 if selection.is_empty() {
184 selection.collapse_to(new_head, selection.goal)
185 } else {
186 selection.set_head(new_head, selection.goal)
187 };
188 })
189 },
190 );
191}
192
193#[cfg(test)]
194mod test {
195 use crate::{
196 state::Mode,
197 test::{NeovimBackedTestContext, VimTestContext},
198 };
199 use editor::{EditorSettings, ScrollBeyondLastLine};
200 use gpui::{AppContext as _, point, px, size};
201 use indoc::indoc;
202 use language::Point;
203 use settings::SettingsStore;
204
205 pub fn sample_text(rows: usize, cols: usize, start_char: char) -> String {
206 let mut text = String::new();
207 for row in 0..rows {
208 let c: char = (start_char as u32 + row as u32) as u8 as char;
209 let mut line = c.to_string().repeat(cols);
210 if row < rows - 1 {
211 line.push('\n');
212 }
213 text += &line;
214 }
215 text
216 }
217
218 #[gpui::test]
219 async fn test_scroll(cx: &mut gpui::TestAppContext) {
220 let mut cx = VimTestContext::new(cx, true).await;
221
222 let (line_height, visible_line_count) = cx.editor(|editor, window, _cx| {
223 (
224 editor
225 .style()
226 .unwrap()
227 .text
228 .line_height_in_pixels(window.rem_size()),
229 editor.visible_line_count().unwrap(),
230 )
231 });
232
233 let window = cx.window;
234 let margin = cx
235 .update_window(window, |_, window, _cx| {
236 window.viewport_size().height - line_height * visible_line_count
237 })
238 .unwrap();
239 cx.simulate_window_resize(
240 cx.window,
241 size(px(1000.), margin + 8. * line_height - px(1.0)),
242 );
243
244 cx.set_state(
245 indoc!(
246 "ˇone
247 two
248 three
249 four
250 five
251 six
252 seven
253 eight
254 nine
255 ten
256 eleven
257 twelve
258 "
259 ),
260 Mode::Normal,
261 );
262
263 cx.update_editor(|editor, window, cx| {
264 assert_eq!(editor.snapshot(window, cx).scroll_position(), point(0., 0.))
265 });
266 cx.simulate_keystrokes("ctrl-e");
267 cx.update_editor(|editor, window, cx| {
268 assert_eq!(editor.snapshot(window, cx).scroll_position(), point(0., 1.))
269 });
270 cx.simulate_keystrokes("2 ctrl-e");
271 cx.update_editor(|editor, window, cx| {
272 assert_eq!(editor.snapshot(window, cx).scroll_position(), point(0., 3.))
273 });
274 cx.simulate_keystrokes("ctrl-y");
275 cx.update_editor(|editor, window, cx| {
276 assert_eq!(editor.snapshot(window, cx).scroll_position(), point(0., 2.))
277 });
278
279 // does not select in normal mode
280 cx.simulate_keystrokes("g g");
281 cx.update_editor(|editor, window, cx| {
282 assert_eq!(editor.snapshot(window, cx).scroll_position(), point(0., 0.))
283 });
284 cx.simulate_keystrokes("ctrl-d");
285 cx.update_editor(|editor, window, cx| {
286 assert_eq!(
287 editor.snapshot(window, cx).scroll_position(),
288 point(0., 3.0)
289 );
290 assert_eq!(
291 editor.selections.newest(cx).range(),
292 Point::new(6, 0)..Point::new(6, 0)
293 )
294 });
295
296 // does select in visual mode
297 cx.simulate_keystrokes("g g");
298 cx.update_editor(|editor, window, cx| {
299 assert_eq!(editor.snapshot(window, cx).scroll_position(), point(0., 0.))
300 });
301 cx.simulate_keystrokes("v ctrl-d");
302 cx.update_editor(|editor, window, cx| {
303 assert_eq!(
304 editor.snapshot(window, cx).scroll_position(),
305 point(0., 3.0)
306 );
307 assert_eq!(
308 editor.selections.newest(cx).range(),
309 Point::new(0, 0)..Point::new(6, 1)
310 )
311 });
312 }
313
314 #[gpui::test]
315 async fn test_ctrl_d_u(cx: &mut gpui::TestAppContext) {
316 let mut cx = NeovimBackedTestContext::new(cx).await;
317
318 cx.set_scroll_height(10).await;
319
320 let content = "ˇ".to_owned() + &sample_text(26, 2, 'a');
321 cx.set_shared_state(&content).await;
322
323 // skip over the scrolloff at the top
324 // test ctrl-d
325 cx.simulate_shared_keystrokes("4 j ctrl-d").await;
326 cx.shared_state().await.assert_matches();
327 cx.simulate_shared_keystrokes("ctrl-d").await;
328 cx.shared_state().await.assert_matches();
329 cx.simulate_shared_keystrokes("g g ctrl-d").await;
330 cx.shared_state().await.assert_matches();
331
332 // test ctrl-u
333 cx.simulate_shared_keystrokes("ctrl-u").await;
334 cx.shared_state().await.assert_matches();
335 cx.simulate_shared_keystrokes("ctrl-d ctrl-d 4 j ctrl-u ctrl-u")
336 .await;
337 cx.shared_state().await.assert_matches();
338
339 // test returning to top
340 cx.simulate_shared_keystrokes("g g ctrl-d ctrl-u ctrl-u")
341 .await;
342 cx.shared_state().await.assert_matches();
343 }
344
345 #[gpui::test]
346 async fn test_ctrl_f_b(cx: &mut gpui::TestAppContext) {
347 let mut cx = NeovimBackedTestContext::new(cx).await;
348
349 let visible_lines = 10;
350 cx.set_scroll_height(visible_lines).await;
351
352 // First test without vertical scroll margin
353 cx.neovim.set_option(&format!("scrolloff={}", 0)).await;
354 cx.update_global(|store: &mut SettingsStore, cx| {
355 store.update_user_settings::<EditorSettings>(cx, |s| {
356 s.vertical_scroll_margin = Some(0.0)
357 });
358 });
359
360 let content = "ˇ".to_owned() + &sample_text(26, 2, 'a');
361 cx.set_shared_state(&content).await;
362
363 // scroll down: ctrl-f
364 cx.simulate_shared_keystrokes("ctrl-f").await;
365 cx.shared_state().await.assert_matches();
366
367 cx.simulate_shared_keystrokes("ctrl-f").await;
368 cx.shared_state().await.assert_matches();
369
370 // scroll up: ctrl-b
371 cx.simulate_shared_keystrokes("ctrl-b").await;
372 cx.shared_state().await.assert_matches();
373
374 cx.simulate_shared_keystrokes("ctrl-b").await;
375 cx.shared_state().await.assert_matches();
376
377 // Now go back to start of file, and test with vertical scroll margin
378 cx.simulate_shared_keystrokes("g g").await;
379 cx.shared_state().await.assert_matches();
380
381 cx.neovim.set_option(&format!("scrolloff={}", 3)).await;
382 cx.update_global(|store: &mut SettingsStore, cx| {
383 store.update_user_settings::<EditorSettings>(cx, |s| {
384 s.vertical_scroll_margin = Some(3.0)
385 });
386 });
387
388 // scroll down: ctrl-f
389 cx.simulate_shared_keystrokes("ctrl-f").await;
390 cx.shared_state().await.assert_matches();
391
392 cx.simulate_shared_keystrokes("ctrl-f").await;
393 cx.shared_state().await.assert_matches();
394
395 // scroll up: ctrl-b
396 cx.simulate_shared_keystrokes("ctrl-b").await;
397 cx.shared_state().await.assert_matches();
398
399 cx.simulate_shared_keystrokes("ctrl-b").await;
400 cx.shared_state().await.assert_matches();
401 }
402
403 #[gpui::test]
404 async fn test_scroll_beyond_last_line(cx: &mut gpui::TestAppContext) {
405 let mut cx = NeovimBackedTestContext::new(cx).await;
406
407 cx.set_scroll_height(10).await;
408
409 let content = "ˇ".to_owned() + &sample_text(26, 2, 'a');
410 cx.set_shared_state(&content).await;
411
412 cx.update_global(|store: &mut SettingsStore, cx| {
413 store.update_user_settings::<EditorSettings>(cx, |s| {
414 s.scroll_beyond_last_line = Some(ScrollBeyondLastLine::Off);
415 // s.vertical_scroll_margin = Some(0.);
416 });
417 });
418
419 // ctrl-d can reach the end and the cursor stays in the first column
420 cx.simulate_shared_keystrokes("shift-g k").await;
421 cx.shared_state().await.assert_matches();
422 cx.simulate_shared_keystrokes("ctrl-d").await;
423 cx.shared_state().await.assert_matches();
424
425 // ctrl-u from the last line
426 cx.simulate_shared_keystrokes("shift-g").await;
427 cx.shared_state().await.assert_matches();
428 cx.simulate_shared_keystrokes("ctrl-u").await;
429 cx.shared_state().await.assert_matches();
430 }
431
432 #[gpui::test]
433 async fn test_ctrl_y_e(cx: &mut gpui::TestAppContext) {
434 let mut cx = NeovimBackedTestContext::new(cx).await;
435
436 cx.set_scroll_height(10).await;
437
438 let content = "ˇ".to_owned() + &sample_text(26, 2, 'a');
439 cx.set_shared_state(&content).await;
440
441 for _ in 0..8 {
442 cx.simulate_shared_keystrokes("ctrl-e").await;
443 cx.shared_state().await.assert_matches();
444 }
445
446 for _ in 0..8 {
447 cx.simulate_shared_keystrokes("ctrl-y").await;
448 cx.shared_state().await.assert_matches();
449 }
450 }
451
452 #[gpui::test]
453 async fn test_scroll_jumps(cx: &mut gpui::TestAppContext) {
454 let mut cx = NeovimBackedTestContext::new(cx).await;
455
456 cx.set_scroll_height(20).await;
457
458 let content = "ˇ".to_owned() + &sample_text(52, 2, 'a');
459 cx.set_shared_state(&content).await;
460
461 cx.simulate_shared_keystrokes("shift-g g g").await;
462 cx.simulate_shared_keystrokes("ctrl-d ctrl-d ctrl-o").await;
463 cx.shared_state().await.assert_matches();
464 cx.simulate_shared_keystrokes("ctrl-o").await;
465 cx.shared_state().await.assert_matches();
466 }
467}