1use crate::{Vim, state::Mode};
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;
10use text::SelectionGoal;
11
12actions!(
13 vim,
14 [
15 /// Scrolls up by one line.
16 LineUp,
17 /// Scrolls down by one line.
18 LineDown,
19 /// Scrolls right by one column.
20 ColumnRight,
21 /// Scrolls left by one column.
22 ColumnLeft,
23 /// Scrolls up by half a page.
24 ScrollUp,
25 /// Scrolls down by half a page.
26 ScrollDown,
27 /// Scrolls up by one page.
28 PageUp,
29 /// Scrolls down by one page.
30 PageDown,
31 /// Scrolls right by half a page's width.
32 HalfPageRight,
33 /// Scrolls left by half a page's width.
34 HalfPageLeft,
35 ]
36);
37
38pub fn register(editor: &mut Editor, cx: &mut Context<Vim>) {
39 Vim::action(editor, cx, |vim, _: &LineDown, window, cx| {
40 vim.scroll(false, window, cx, |c| ScrollAmount::Line(c.unwrap_or(1.)))
41 });
42 Vim::action(editor, cx, |vim, _: &LineUp, window, cx| {
43 vim.scroll(false, window, cx, |c| ScrollAmount::Line(-c.unwrap_or(1.)))
44 });
45 Vim::action(editor, cx, |vim, _: &ColumnRight, window, cx| {
46 vim.scroll(false, window, cx, |c| ScrollAmount::Column(c.unwrap_or(1.)))
47 });
48 Vim::action(editor, cx, |vim, _: &ColumnLeft, window, cx| {
49 vim.scroll(false, window, cx, |c| {
50 ScrollAmount::Column(-c.unwrap_or(1.))
51 })
52 });
53 Vim::action(editor, cx, |vim, _: &PageDown, window, cx| {
54 vim.scroll(false, window, cx, |c| ScrollAmount::Page(c.unwrap_or(1.)))
55 });
56 Vim::action(editor, cx, |vim, _: &PageUp, window, cx| {
57 vim.scroll(false, window, cx, |c| ScrollAmount::Page(-c.unwrap_or(1.)))
58 });
59 Vim::action(editor, cx, |vim, _: &HalfPageRight, window, cx| {
60 vim.scroll(false, window, cx, |c| {
61 ScrollAmount::PageWidth(c.unwrap_or(0.5))
62 })
63 });
64 Vim::action(editor, cx, |vim, _: &HalfPageLeft, window, cx| {
65 vim.scroll(false, window, cx, |c| {
66 ScrollAmount::PageWidth(-c.unwrap_or(0.5))
67 })
68 });
69 Vim::action(editor, cx, |vim, _: &ScrollDown, window, cx| {
70 vim.scroll(true, window, cx, |c| {
71 if let Some(c) = c {
72 ScrollAmount::Line(c)
73 } else {
74 ScrollAmount::Page(0.5)
75 }
76 })
77 });
78 Vim::action(editor, cx, |vim, _: &ScrollUp, window, cx| {
79 vim.scroll(true, window, cx, |c| {
80 if let Some(c) = c {
81 ScrollAmount::Line(-c)
82 } else {
83 ScrollAmount::Page(-0.5)
84 }
85 })
86 });
87}
88
89impl Vim {
90 fn scroll(
91 &mut self,
92 move_cursor: bool,
93 window: &mut Window,
94 cx: &mut Context<Self>,
95 by: fn(c: Option<f32>) -> ScrollAmount,
96 ) {
97 let amount = by(Vim::take_count(cx).map(|c| c as f32));
98 let mode = self.mode;
99 Vim::take_forced_motion(cx);
100 self.exit_temporary_normal(window, cx);
101 self.update_editor(cx, |_, editor, cx| {
102 scroll_editor(editor, mode, move_cursor, amount, window, cx)
103 });
104 }
105}
106
107fn scroll_editor(
108 editor: &mut Editor,
109 mode: Mode,
110 preserve_cursor_position: bool,
111 amount: ScrollAmount,
112 window: &mut Window,
113 cx: &mut Context<Editor>,
114) {
115 let should_move_cursor = editor.newest_selection_on_screen(cx).is_eq();
116 let old_top_anchor = editor.scroll_manager.anchor().anchor;
117
118 if editor.scroll_hover(amount, window, cx) {
119 return;
120 }
121
122 let full_page_up = amount.is_full_page() && amount.direction().is_upwards();
123 let amount = match (amount.is_full_page(), editor.visible_line_count()) {
124 (true, Some(visible_line_count)) => {
125 if amount.direction().is_upwards() {
126 ScrollAmount::Line((amount.lines(visible_line_count) + 1.0) as f32)
127 } else {
128 ScrollAmount::Line((amount.lines(visible_line_count) - 1.0) as f32)
129 }
130 }
131 _ => amount,
132 };
133
134 editor.scroll_screen(&amount, window, cx);
135 if !should_move_cursor {
136 return;
137 }
138
139 let Some(visible_line_count) = editor.visible_line_count() else {
140 return;
141 };
142
143 let Some(visible_column_count) = editor.visible_column_count() else {
144 return;
145 };
146
147 let top_anchor = editor.scroll_manager.anchor().anchor;
148 let vertical_scroll_margin = EditorSettings::get_global(cx).vertical_scroll_margin;
149
150 editor.change_selections(
151 SelectionEffects::no_scroll().nav_history(false),
152 window,
153 cx,
154 |s| {
155 s.move_with(|map, selection| {
156 // TODO: Improve the logic and function calls below to be dependent on
157 // the `amount`. If the amount is vertical, we don't care about
158 // columns, while if it's horizontal, we don't care about rows,
159 // so we don't need to calculate both and deal with logic for
160 // both.
161 let mut head = selection.head();
162 let top = top_anchor.to_display_point(map);
163 let max_point = map.max_point();
164 let starting_column = head.column();
165
166 let vertical_scroll_margin =
167 (vertical_scroll_margin as u32).min(visible_line_count as u32 / 2);
168
169 if preserve_cursor_position {
170 let old_top = old_top_anchor.to_display_point(map);
171 let new_row = if old_top.row() == top.row() {
172 DisplayRow(
173 head.row()
174 .0
175 .saturating_add_signed(amount.lines(visible_line_count) as i32),
176 )
177 } else {
178 DisplayRow(top.row().0 + selection.head().row().0 - old_top.row().0)
179 };
180 head = map.clip_point(DisplayPoint::new(new_row, head.column()), Bias::Left)
181 }
182
183 let min_row = if top.row().0 == 0 {
184 DisplayRow(0)
185 } else {
186 DisplayRow(top.row().0 + vertical_scroll_margin)
187 };
188
189 let max_visible_row = top.row().0.saturating_add(
190 (visible_line_count as u32).saturating_sub(1 + vertical_scroll_margin),
191 );
192 // scroll off the end.
193 let max_row = if top.row().0 + visible_line_count as u32 >= max_point.row().0 {
194 max_point.row()
195 } else {
196 DisplayRow(
197 (top.row().0 + visible_line_count as u32)
198 .saturating_sub(1 + vertical_scroll_margin),
199 )
200 };
201
202 let new_row = if full_page_up {
203 // Special-casing ctrl-b/page-up, which is special-cased by Vim, it seems
204 // to always put the cursor on the last line of the page, even if the cursor
205 // was before that.
206 DisplayRow(max_visible_row)
207 } else if head.row() < min_row {
208 min_row
209 } else if head.row() > max_row {
210 max_row
211 } else {
212 head.row()
213 };
214
215 // The minimum column position that the cursor position can be
216 // at is either the scroll manager's anchor column, which is the
217 // left-most column in the visible area, or the scroll manager's
218 // old anchor column, in case the cursor position is being
219 // preserved. This is necessary for motions like `ctrl-d` in
220 // case there's not enough content to scroll half page down, in
221 // which case the scroll manager's anchor column will be the
222 // maximum column for the current line, so the minimum column
223 // would end up being the same as the maximum column.
224 let min_column = match preserve_cursor_position {
225 true => old_top_anchor.to_display_point(map).column(),
226 false => top.column(),
227 };
228
229 // As for the maximum column position, that should be either the
230 // right-most column in the visible area, which we can easily
231 // calculate by adding the visible column count to the minimum
232 // column position, or the right-most column in the current
233 // line, seeing as the cursor might be in a short line, in which
234 // case we don't want to go past its last column.
235 let max_row_column = if new_row <= map.max_point().row() {
236 map.line_len(new_row)
237 } else {
238 0
239 };
240 let max_column = match min_column + visible_column_count as u32 {
241 max_column if max_column >= max_row_column => max_row_column,
242 max_column => max_column,
243 };
244
245 // Ensure that the cursor's column stays within the visible
246 // area, otherwise clip it at either the left or right edge of
247 // the visible area.
248 let new_column = match (min_column, max_column) {
249 (min_column, _) if starting_column < min_column => min_column,
250 (_, max_column) if starting_column > max_column => max_column,
251 _ => starting_column,
252 };
253
254 let new_head = map.clip_point(DisplayPoint::new(new_row, new_column), Bias::Left);
255 let goal = match amount {
256 ScrollAmount::Column(_) | ScrollAmount::PageWidth(_) => SelectionGoal::None,
257 _ => selection.goal,
258 };
259
260 if selection.is_empty() || !mode.is_visual() {
261 selection.collapse_to(new_head, goal)
262 } else {
263 selection.set_head(new_head, goal)
264 };
265 })
266 },
267 );
268}
269
270#[cfg(test)]
271mod test {
272 use crate::{
273 state::Mode,
274 test::{NeovimBackedTestContext, VimTestContext},
275 };
276 use editor::ScrollBeyondLastLine;
277 use gpui::{AppContext as _, point, px, size};
278 use indoc::indoc;
279 use language::Point;
280 use settings::SettingsStore;
281
282 pub fn sample_text(rows: usize, cols: usize, start_char: char) -> String {
283 let mut text = String::new();
284 for row in 0..rows {
285 let c: char = (start_char as u32 + row as u32) as u8 as char;
286 let mut line = c.to_string().repeat(cols);
287 if row < rows - 1 {
288 line.push('\n');
289 }
290 text += &line;
291 }
292 text
293 }
294
295 #[gpui::test]
296 async fn test_scroll(cx: &mut gpui::TestAppContext) {
297 let mut cx = VimTestContext::new(cx, true).await;
298
299 let (line_height, visible_line_count) = cx.update_editor(|editor, window, cx| {
300 (
301 editor
302 .style(cx)
303 .text
304 .line_height_in_pixels(window.rem_size()),
305 editor.visible_line_count().unwrap(),
306 )
307 });
308
309 let window = cx.window;
310 let margin = cx
311 .update_window(window, |_, window, _cx| {
312 window.viewport_size().height - line_height * visible_line_count as f32
313 })
314 .unwrap();
315 cx.simulate_window_resize(
316 cx.window,
317 size(px(1000.), margin + 8. * line_height - px(1.0)),
318 );
319
320 cx.set_state(
321 indoc!(
322 "ˇone
323 two
324 three
325 four
326 five
327 six
328 seven
329 eight
330 nine
331 ten
332 eleven
333 twelve
334 "
335 ),
336 Mode::Normal,
337 );
338
339 cx.update_editor(|editor, window, cx| {
340 assert_eq!(editor.snapshot(window, cx).scroll_position(), point(0., 0.))
341 });
342 cx.simulate_keystrokes("ctrl-e");
343 cx.update_editor(|editor, window, cx| {
344 assert_eq!(editor.snapshot(window, cx).scroll_position(), point(0., 1.))
345 });
346 cx.simulate_keystrokes("2 ctrl-e");
347 cx.update_editor(|editor, window, cx| {
348 assert_eq!(editor.snapshot(window, cx).scroll_position(), point(0., 3.))
349 });
350 cx.simulate_keystrokes("ctrl-y");
351 cx.update_editor(|editor, window, cx| {
352 assert_eq!(editor.snapshot(window, cx).scroll_position(), point(0., 2.))
353 });
354
355 // does not select in normal mode
356 cx.simulate_keystrokes("g g");
357 cx.update_editor(|editor, window, cx| {
358 assert_eq!(editor.snapshot(window, cx).scroll_position(), point(0., 0.))
359 });
360 cx.simulate_keystrokes("ctrl-d");
361 cx.update_editor(|editor, window, cx| {
362 assert_eq!(
363 editor.snapshot(window, cx).scroll_position(),
364 point(0., 3.0)
365 );
366 assert_eq!(
367 editor
368 .selections
369 .newest(&editor.display_snapshot(cx))
370 .range(),
371 Point::new(6, 0)..Point::new(6, 0)
372 )
373 });
374
375 // does select in visual mode
376 cx.simulate_keystrokes("g g");
377 cx.update_editor(|editor, window, cx| {
378 assert_eq!(editor.snapshot(window, cx).scroll_position(), point(0., 0.))
379 });
380 cx.simulate_keystrokes("v ctrl-d");
381 cx.update_editor(|editor, window, cx| {
382 assert_eq!(
383 editor.snapshot(window, cx).scroll_position(),
384 point(0., 3.0)
385 );
386 assert_eq!(
387 editor
388 .selections
389 .newest(&editor.display_snapshot(cx))
390 .range(),
391 Point::new(0, 0)..Point::new(6, 1)
392 )
393 });
394 }
395
396 #[gpui::test]
397 async fn test_ctrl_d_u(cx: &mut gpui::TestAppContext) {
398 let mut cx = NeovimBackedTestContext::new(cx).await;
399
400 cx.set_scroll_height(10).await;
401
402 let content = "ˇ".to_owned() + &sample_text(26, 2, 'a');
403 cx.set_shared_state(&content).await;
404
405 // skip over the scrolloff at the top
406 // test ctrl-d
407 cx.simulate_shared_keystrokes("4 j ctrl-d").await;
408 cx.shared_state().await.assert_matches();
409 cx.simulate_shared_keystrokes("ctrl-d").await;
410 cx.shared_state().await.assert_matches();
411 cx.simulate_shared_keystrokes("g g ctrl-d").await;
412 cx.shared_state().await.assert_matches();
413
414 // test ctrl-u
415 cx.simulate_shared_keystrokes("ctrl-u").await;
416 cx.shared_state().await.assert_matches();
417 cx.simulate_shared_keystrokes("ctrl-d ctrl-d 4 j ctrl-u ctrl-u")
418 .await;
419 cx.shared_state().await.assert_matches();
420
421 // test returning to top
422 cx.simulate_shared_keystrokes("g g ctrl-d ctrl-u ctrl-u")
423 .await;
424 cx.shared_state().await.assert_matches();
425 }
426
427 #[gpui::test]
428 async fn test_ctrl_f_b(cx: &mut gpui::TestAppContext) {
429 let mut cx = NeovimBackedTestContext::new(cx).await;
430
431 let visible_lines = 10;
432 cx.set_scroll_height(visible_lines).await;
433
434 // First test without vertical scroll margin
435 cx.neovim.set_option(&format!("scrolloff={}", 0)).await;
436 cx.update_global(|store: &mut SettingsStore, cx| {
437 store.update_user_settings(cx, |s| s.editor.vertical_scroll_margin = Some(0.0));
438 });
439
440 let content = "ˇ".to_owned() + &sample_text(26, 2, 'a');
441 cx.set_shared_state(&content).await;
442
443 // scroll down: ctrl-f
444 cx.simulate_shared_keystrokes("ctrl-f").await;
445 cx.shared_state().await.assert_matches();
446
447 cx.simulate_shared_keystrokes("ctrl-f").await;
448 cx.shared_state().await.assert_matches();
449
450 // scroll up: ctrl-b
451 cx.simulate_shared_keystrokes("ctrl-b").await;
452 cx.shared_state().await.assert_matches();
453
454 cx.simulate_shared_keystrokes("ctrl-b").await;
455 cx.shared_state().await.assert_matches();
456
457 // Now go back to start of file, and test with vertical scroll margin
458 cx.simulate_shared_keystrokes("g g").await;
459 cx.shared_state().await.assert_matches();
460
461 cx.neovim.set_option(&format!("scrolloff={}", 3)).await;
462 cx.update_global(|store: &mut SettingsStore, cx| {
463 store.update_user_settings(cx, |s| s.editor.vertical_scroll_margin = Some(3.0));
464 });
465
466 // scroll down: ctrl-f
467 cx.simulate_shared_keystrokes("ctrl-f").await;
468 cx.shared_state().await.assert_matches();
469
470 cx.simulate_shared_keystrokes("ctrl-f").await;
471 cx.shared_state().await.assert_matches();
472
473 // scroll up: ctrl-b
474 cx.simulate_shared_keystrokes("ctrl-b").await;
475 cx.shared_state().await.assert_matches();
476
477 cx.simulate_shared_keystrokes("ctrl-b").await;
478 cx.shared_state().await.assert_matches();
479 }
480
481 #[gpui::test]
482 async fn test_scroll_beyond_last_line(cx: &mut gpui::TestAppContext) {
483 let mut cx = NeovimBackedTestContext::new(cx).await;
484
485 cx.set_scroll_height(10).await;
486
487 let content = "ˇ".to_owned() + &sample_text(26, 2, 'a');
488 cx.set_shared_state(&content).await;
489
490 cx.update_global(|store: &mut SettingsStore, cx| {
491 store.update_user_settings(cx, |s| {
492 s.editor.scroll_beyond_last_line = Some(ScrollBeyondLastLine::Off);
493 });
494 });
495
496 // ctrl-d can reach the end and the cursor stays in the first column
497 cx.simulate_shared_keystrokes("shift-g k").await;
498 cx.shared_state().await.assert_matches();
499 cx.simulate_shared_keystrokes("ctrl-d").await;
500 cx.shared_state().await.assert_matches();
501
502 // ctrl-u from the last line
503 cx.simulate_shared_keystrokes("shift-g").await;
504 cx.shared_state().await.assert_matches();
505 cx.simulate_shared_keystrokes("ctrl-u").await;
506 cx.shared_state().await.assert_matches();
507 }
508
509 #[gpui::test]
510 async fn test_ctrl_y_e(cx: &mut gpui::TestAppContext) {
511 let mut cx = NeovimBackedTestContext::new(cx).await;
512
513 cx.set_scroll_height(10).await;
514
515 let content = "ˇ".to_owned() + &sample_text(26, 2, 'a');
516 cx.set_shared_state(&content).await;
517
518 for _ in 0..8 {
519 cx.simulate_shared_keystrokes("ctrl-e").await;
520 cx.shared_state().await.assert_matches();
521 }
522
523 for _ in 0..8 {
524 cx.simulate_shared_keystrokes("ctrl-y").await;
525 cx.shared_state().await.assert_matches();
526 }
527 }
528
529 #[gpui::test]
530 async fn test_scroll_jumps(cx: &mut gpui::TestAppContext) {
531 let mut cx = NeovimBackedTestContext::new(cx).await;
532
533 cx.set_scroll_height(20).await;
534
535 let content = "ˇ".to_owned() + &sample_text(52, 2, 'a');
536 cx.set_shared_state(&content).await;
537
538 cx.simulate_shared_keystrokes("shift-g g g").await;
539 cx.simulate_shared_keystrokes("ctrl-d ctrl-d ctrl-o").await;
540 cx.shared_state().await.assert_matches();
541 cx.simulate_shared_keystrokes("ctrl-o").await;
542 cx.shared_state().await.assert_matches();
543 }
544
545 #[gpui::test]
546 async fn test_horizontal_scroll(cx: &mut gpui::TestAppContext) {
547 let mut cx = NeovimBackedTestContext::new(cx).await;
548
549 cx.set_scroll_height(20).await;
550 cx.set_shared_wrap(12).await;
551 cx.set_neovim_option("nowrap").await;
552
553 let content = "ˇ01234567890123456789";
554 cx.set_shared_state(content).await;
555
556 cx.simulate_shared_keystrokes("z shift-l").await;
557 cx.shared_state().await.assert_eq("012345ˇ67890123456789");
558
559 // At this point, `z h` should not move the cursor as it should still be
560 // visible within the 12 column width.
561 cx.simulate_shared_keystrokes("z h").await;
562 cx.shared_state().await.assert_eq("012345ˇ67890123456789");
563
564 let content = "ˇ01234567890123456789";
565 cx.set_shared_state(content).await;
566
567 cx.simulate_shared_keystrokes("z l").await;
568 cx.shared_state().await.assert_eq("0ˇ1234567890123456789");
569 }
570}