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