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