scroll.rs

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