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