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