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