scroll.rs

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