Skip to repository content

tenant.openagents/omega

No repository description is available.

OpenAgents Git authority 2026-07-28T04:28:59.008Z Public web read
NIP-34 coordinate30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omega
MaintainersHidden in public view
References2 branches · 1 tag
Read-only clonegit clone https://openagents.com/git/tenant.openagents/omega.git
Browse files

scroll.rs

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