scroll.rs

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