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 let max_row = DisplayRow(map.max_point().row().0.max(max_visible_row));
136
137 let new_row = if full_page_up {
138 // Special-casing ctrl-b/page-up, which is special-cased by Vim, it seems
139 // to always put the cursor on the last line of the page, even if the cursor
140 // was before that.
141 DisplayRow(max_visible_row)
142 } else if head.row() < min_row {
143 min_row
144 } else if head.row() > max_row {
145 max_row
146 } else {
147 head.row()
148 };
149 let new_head = map.clip_point(DisplayPoint::new(new_row, starting_column), Bias::Left);
150
151 if selection.is_empty() {
152 selection.collapse_to(new_head, selection.goal)
153 } else {
154 selection.set_head(new_head, selection.goal)
155 };
156 })
157 });
158}
159
160#[cfg(test)]
161mod test {
162 use crate::{
163 state::Mode,
164 test::{NeovimBackedTestContext, VimTestContext},
165 };
166 use editor::{EditorSettings, ScrollBeyondLastLine};
167 use gpui::{AppContext as _, point, px, size};
168 use indoc::indoc;
169 use language::Point;
170 use settings::SettingsStore;
171
172 pub fn sample_text(rows: usize, cols: usize, start_char: char) -> String {
173 let mut text = String::new();
174 for row in 0..rows {
175 let c: char = (start_char as u32 + row as u32) as u8 as char;
176 let mut line = c.to_string().repeat(cols);
177 if row < rows - 1 {
178 line.push('\n');
179 }
180 text += &line;
181 }
182 text
183 }
184
185 #[gpui::test]
186 async fn test_scroll(cx: &mut gpui::TestAppContext) {
187 let mut cx = VimTestContext::new(cx, true).await;
188
189 let (line_height, visible_line_count) = cx.editor(|editor, window, _cx| {
190 (
191 editor
192 .style()
193 .unwrap()
194 .text
195 .line_height_in_pixels(window.rem_size()),
196 editor.visible_line_count().unwrap(),
197 )
198 });
199
200 let window = cx.window;
201 let margin = cx
202 .update_window(window, |_, window, _cx| {
203 window.viewport_size().height - line_height * visible_line_count
204 })
205 .unwrap();
206 cx.simulate_window_resize(
207 cx.window,
208 size(px(1000.), margin + 8. * line_height - px(1.0)),
209 );
210
211 cx.set_state(
212 indoc!(
213 "ˇone
214 two
215 three
216 four
217 five
218 six
219 seven
220 eight
221 nine
222 ten
223 eleven
224 twelve
225 "
226 ),
227 Mode::Normal,
228 );
229
230 cx.update_editor(|editor, window, cx| {
231 assert_eq!(editor.snapshot(window, cx).scroll_position(), point(0., 0.))
232 });
233 cx.simulate_keystrokes("ctrl-e");
234 cx.update_editor(|editor, window, cx| {
235 assert_eq!(editor.snapshot(window, cx).scroll_position(), point(0., 1.))
236 });
237 cx.simulate_keystrokes("2 ctrl-e");
238 cx.update_editor(|editor, window, cx| {
239 assert_eq!(editor.snapshot(window, cx).scroll_position(), point(0., 3.))
240 });
241 cx.simulate_keystrokes("ctrl-y");
242 cx.update_editor(|editor, window, cx| {
243 assert_eq!(editor.snapshot(window, cx).scroll_position(), point(0., 2.))
244 });
245
246 // does not select in normal mode
247 cx.simulate_keystrokes("g g");
248 cx.update_editor(|editor, window, cx| {
249 assert_eq!(editor.snapshot(window, cx).scroll_position(), point(0., 0.))
250 });
251 cx.simulate_keystrokes("ctrl-d");
252 cx.update_editor(|editor, window, cx| {
253 assert_eq!(
254 editor.snapshot(window, cx).scroll_position(),
255 point(0., 3.0)
256 );
257 assert_eq!(
258 editor.selections.newest(cx).range(),
259 Point::new(6, 0)..Point::new(6, 0)
260 )
261 });
262
263 // does select in visual mode
264 cx.simulate_keystrokes("g g");
265 cx.update_editor(|editor, window, cx| {
266 assert_eq!(editor.snapshot(window, cx).scroll_position(), point(0., 0.))
267 });
268 cx.simulate_keystrokes("v ctrl-d");
269 cx.update_editor(|editor, window, cx| {
270 assert_eq!(
271 editor.snapshot(window, cx).scroll_position(),
272 point(0., 3.0)
273 );
274 assert_eq!(
275 editor.selections.newest(cx).range(),
276 Point::new(0, 0)..Point::new(6, 1)
277 )
278 });
279 }
280
281 #[gpui::test]
282 async fn test_ctrl_d_u(cx: &mut gpui::TestAppContext) {
283 let mut cx = NeovimBackedTestContext::new(cx).await;
284
285 cx.set_scroll_height(10).await;
286
287 let content = "ˇ".to_owned() + &sample_text(26, 2, 'a');
288 cx.set_shared_state(&content).await;
289
290 // skip over the scrolloff at the top
291 // test ctrl-d
292 cx.simulate_shared_keystrokes("4 j ctrl-d").await;
293 cx.shared_state().await.assert_matches();
294 cx.simulate_shared_keystrokes("ctrl-d").await;
295 cx.shared_state().await.assert_matches();
296 cx.simulate_shared_keystrokes("g g ctrl-d").await;
297 cx.shared_state().await.assert_matches();
298
299 // test ctrl-u
300 cx.simulate_shared_keystrokes("ctrl-u").await;
301 cx.shared_state().await.assert_matches();
302 cx.simulate_shared_keystrokes("ctrl-d ctrl-d 4 j ctrl-u ctrl-u")
303 .await;
304 cx.shared_state().await.assert_matches();
305
306 // test returning to top
307 cx.simulate_shared_keystrokes("g g ctrl-d ctrl-u ctrl-u")
308 .await;
309 cx.shared_state().await.assert_matches();
310 }
311
312 #[gpui::test]
313 async fn test_ctrl_f_b(cx: &mut gpui::TestAppContext) {
314 let mut cx = NeovimBackedTestContext::new(cx).await;
315
316 let visible_lines = 10;
317 cx.set_scroll_height(visible_lines).await;
318
319 // First test without vertical scroll margin
320 cx.neovim.set_option(&format!("scrolloff={}", 0)).await;
321 cx.update_global(|store: &mut SettingsStore, cx| {
322 store.update_user_settings::<EditorSettings>(cx, |s| {
323 s.vertical_scroll_margin = Some(0.0)
324 });
325 });
326
327 let content = "ˇ".to_owned() + &sample_text(26, 2, 'a');
328 cx.set_shared_state(&content).await;
329
330 // scroll down: ctrl-f
331 cx.simulate_shared_keystrokes("ctrl-f").await;
332 cx.shared_state().await.assert_matches();
333
334 cx.simulate_shared_keystrokes("ctrl-f").await;
335 cx.shared_state().await.assert_matches();
336
337 // scroll up: ctrl-b
338 cx.simulate_shared_keystrokes("ctrl-b").await;
339 cx.shared_state().await.assert_matches();
340
341 cx.simulate_shared_keystrokes("ctrl-b").await;
342 cx.shared_state().await.assert_matches();
343
344 // Now go back to start of file, and test with vertical scroll margin
345 cx.simulate_shared_keystrokes("g g").await;
346 cx.shared_state().await.assert_matches();
347
348 cx.neovim.set_option(&format!("scrolloff={}", 3)).await;
349 cx.update_global(|store: &mut SettingsStore, cx| {
350 store.update_user_settings::<EditorSettings>(cx, |s| {
351 s.vertical_scroll_margin = Some(3.0)
352 });
353 });
354
355 // scroll down: ctrl-f
356 cx.simulate_shared_keystrokes("ctrl-f").await;
357 cx.shared_state().await.assert_matches();
358
359 cx.simulate_shared_keystrokes("ctrl-f").await;
360 cx.shared_state().await.assert_matches();
361
362 // scroll up: ctrl-b
363 cx.simulate_shared_keystrokes("ctrl-b").await;
364 cx.shared_state().await.assert_matches();
365
366 cx.simulate_shared_keystrokes("ctrl-b").await;
367 cx.shared_state().await.assert_matches();
368 }
369
370 #[gpui::test]
371 async fn test_scroll_beyond_last_line(cx: &mut gpui::TestAppContext) {
372 let mut cx = NeovimBackedTestContext::new(cx).await;
373
374 cx.set_scroll_height(10).await;
375 cx.neovim.set_option(&format!("scrolloff={}", 0)).await;
376
377 let content = "ˇ".to_owned() + &sample_text(26, 2, 'a');
378 cx.set_shared_state(&content).await;
379
380 cx.update_global(|store: &mut SettingsStore, cx| {
381 store.update_user_settings::<EditorSettings>(cx, |s| {
382 s.scroll_beyond_last_line = Some(ScrollBeyondLastLine::Off)
383 });
384 });
385
386 // ctrl-d can reach the end and the cursor stays in the first column
387 cx.simulate_shared_keystrokes("shift-g k").await;
388 cx.shared_state().await.assert_matches();
389 cx.simulate_shared_keystrokes("ctrl-d").await;
390 cx.shared_state().await.assert_matches();
391
392 // ctrl-u from the last line
393 cx.simulate_shared_keystrokes("shift-g").await;
394 cx.shared_state().await.assert_matches();
395 cx.simulate_shared_keystrokes("ctrl-u").await;
396 cx.shared_state().await.assert_matches();
397 }
398}