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