Add MovePageUp and MovePageDown editor commands

Max Brunsfeld and Mikayla Maki created

Co-authored-by: Mikayla Maki <mikayla@zed.dev>

Change summary

crates/editor/src/editor.rs             |  57 +++++++++++++
crates/editor/src/editor_tests.rs       | 114 +++++++++++++++++++++++++++
crates/editor/src/movement.rs           |  28 +++++
crates/gpui/src/app/test_app_context.rs |  20 +++
crates/gpui/src/platform/test.rs        |   4 
5 files changed, 213 insertions(+), 10 deletions(-)

Detailed changes

crates/editor/src/editor.rs 🔗

@@ -108,6 +108,18 @@ pub struct SelectToBeginningOfLine {
     stop_at_soft_wraps: bool,
 }
 
+#[derive(Clone, Default, Deserialize, PartialEq)]
+pub struct MovePageUp {
+    #[serde(default)]
+    center_cursor: bool,
+}
+
+#[derive(Clone, Default, Deserialize, PartialEq)]
+pub struct MovePageDown {
+    #[serde(default)]
+    center_cursor: bool,
+}
+
 #[derive(Clone, Deserialize, PartialEq)]
 pub struct SelectToEndOfLine {
     #[serde(default)]
@@ -222,6 +234,8 @@ impl_actions!(
         SelectToBeginningOfLine,
         SelectToEndOfLine,
         ToggleCodeActions,
+        MovePageUp,
+        MovePageDown,
         ConfirmCompletion,
         ConfirmCodeAction,
     ]
@@ -5536,6 +5550,49 @@ impl Editor {
         }
     }
 
+    // Vscode style + emacs style
+    pub fn move_page_down(&mut self, _: &MovePageDown, cx: &mut ViewContext<Self>) {
+        let row_count = match self.visible_line_count {
+            Some(row_count) => row_count as u32 - 1,
+            None => return,
+        };
+
+        self.change_selections(Some(Autoscroll::Fit), cx, |s| {
+            let line_mode = s.line_mode;
+            s.move_with(|map, selection| {
+                if !selection.is_empty() && !line_mode {
+                    selection.goal = SelectionGoal::None;
+                }
+                let (cursor, goal) =
+                    movement::down_by_rows(map, selection.end, row_count, selection.goal, false);
+                eprintln!(
+                    "{:?} down by rows {} = {:?}",
+                    selection.end, row_count, cursor
+                );
+                selection.collapse_to(cursor, goal);
+            });
+        });
+    }
+
+    pub fn move_page_up(&mut self, _: &MovePageUp, cx: &mut ViewContext<Self>) {
+        let row_count = match self.visible_line_count {
+            Some(row_count) => row_count as u32 - 1,
+            None => return,
+        };
+
+        self.change_selections(Some(Autoscroll::Fit), cx, |s| {
+            let line_mode = s.line_mode;
+            s.move_with(|map, selection| {
+                if !selection.is_empty() && !line_mode {
+                    selection.goal = SelectionGoal::None;
+                }
+                let (cursor, goal) =
+                    movement::up_by_rows(map, selection.end, row_count, selection.goal, false);
+                selection.collapse_to(cursor, goal);
+            });
+        });
+    }
+
     pub fn page_up(&mut self, _: &PageUp, cx: &mut ViewContext<Self>) {
         let lines = match self.visible_line_count {
             Some(lines) => lines,

crates/editor/src/editor_tests.rs 🔗

@@ -1194,6 +1194,120 @@ fn test_prev_next_word_bounds_with_soft_wrap(cx: &mut gpui::MutableAppContext) {
     });
 }
 
+#[gpui::test]
+async fn test_move_page_up_page_down(cx: &mut gpui::TestAppContext) {
+    let mut cx = EditorTestContext::new(cx);
+
+    let line_height = cx.editor(|editor, cx| editor.style(cx).text.line_height(cx.font_cache()));
+    cx.simulate_window_resize(cx.window_id, vec2f(100., 4. * line_height));
+
+    cx.set_state(
+        &r#"
+        ˇone
+        two
+        threeˇ
+        four
+        five
+        six
+        seven
+        eight
+        nine
+        ten
+        "#
+        .unindent(),
+    );
+
+    cx.update_editor(|editor, cx| editor.move_page_down(&MovePageDown::default(), cx));
+    cx.assert_editor_state(
+        &r#"
+        one
+        two
+        three
+        ˇfour
+        five
+        sixˇ
+        seven
+        eight
+        nine
+        tenx
+        "#
+        .unindent(),
+    );
+
+    cx.update_editor(|editor, cx| editor.move_page_down(&MovePageDown::default(), cx));
+    cx.assert_editor_state(
+        &r#"
+        one
+        two
+        three
+        four
+        five
+        six
+        ˇseven
+        eight
+        nineˇ
+        ten
+        "#
+        .unindent(),
+    );
+
+    cx.update_editor(|editor, cx| editor.move_page_up(&MovePageUp::default(), cx));
+    cx.assert_editor_state(
+        &r#"
+        one
+        two
+        three
+        ˇfour
+        five
+        sixˇ
+        seven
+        eight
+        nine
+        ten
+        "#
+        .unindent(),
+    );
+
+    cx.update_editor(|editor, cx| editor.move_page_up(&MovePageUp::default(), cx));
+    cx.assert_editor_state(
+        &r#"
+        ˇone
+        two
+        threeˇ
+        four
+        five
+        six
+        seven
+        eight
+        nine
+        ten
+        "#
+        .unindent(),
+    );
+
+    // Test select collapsing
+    cx.update_editor(|editor, cx| {
+        editor.move_page_down(&MovePageDown::default(), cx);
+        editor.move_page_down(&MovePageDown::default(), cx);
+        editor.move_page_down(&MovePageDown::default(), cx);
+    });
+    cx.assert_editor_state(
+        &r#"
+        one
+        two
+        three
+        four
+        five
+        six
+        seven
+        eight
+        nine
+        ˇten
+        ˇ"#
+        .unindent(),
+    );
+}
+
 #[gpui::test]
 async fn test_delete_to_beginning_of_line(cx: &mut gpui::TestAppContext) {
     let mut cx = EditorTestContext::new(cx);

crates/editor/src/movement.rs 🔗

@@ -29,6 +29,25 @@ pub fn up(
     start: DisplayPoint,
     goal: SelectionGoal,
     preserve_column_at_start: bool,
+) -> (DisplayPoint, SelectionGoal) {
+    up_by_rows(map, start, 1, goal, preserve_column_at_start)
+}
+
+pub fn down(
+    map: &DisplaySnapshot,
+    start: DisplayPoint,
+    goal: SelectionGoal,
+    preserve_column_at_end: bool,
+) -> (DisplayPoint, SelectionGoal) {
+    down_by_rows(map, start, 1, goal, preserve_column_at_end)
+}
+
+pub fn up_by_rows(
+    map: &DisplaySnapshot,
+    start: DisplayPoint,
+    row_count: u32,
+    goal: SelectionGoal,
+    preserve_column_at_start: bool,
 ) -> (DisplayPoint, SelectionGoal) {
     let mut goal_column = if let SelectionGoal::Column(column) = goal {
         column
@@ -36,7 +55,7 @@ pub fn up(
         map.column_to_chars(start.row(), start.column())
     };
 
-    let prev_row = start.row().saturating_sub(1);
+    let prev_row = start.row().saturating_sub(row_count);
     let mut point = map.clip_point(
         DisplayPoint::new(prev_row, map.line_len(prev_row)),
         Bias::Left,
@@ -62,9 +81,10 @@ pub fn up(
     )
 }
 
-pub fn down(
+pub fn down_by_rows(
     map: &DisplaySnapshot,
     start: DisplayPoint,
+    row_count: u32,
     goal: SelectionGoal,
     preserve_column_at_end: bool,
 ) -> (DisplayPoint, SelectionGoal) {
@@ -74,8 +94,8 @@ pub fn down(
         map.column_to_chars(start.row(), start.column())
     };
 
-    let next_row = start.row() + 1;
-    let mut point = map.clip_point(DisplayPoint::new(next_row, 0), Bias::Right);
+    let new_row = start.row() + row_count;
+    let mut point = map.clip_point(DisplayPoint::new(new_row, 0), Bias::Right);
     if point.row() > start.row() {
         *point.column_mut() = map.column_from_chars(point.row(), goal_column);
     } else if preserve_column_at_end {

crates/gpui/src/app/test_app_context.rs 🔗

@@ -17,10 +17,11 @@ use parking_lot::{Mutex, RwLock};
 use smol::stream::StreamExt;
 
 use crate::{
-    executor, keymap::Keystroke, platform, Action, AnyViewHandle, AppContext, Appearance, Entity,
-    Event, FontCache, InputHandler, KeyDownEvent, LeakDetector, ModelContext, ModelHandle,
-    MutableAppContext, Platform, ReadModelWith, ReadViewWith, RenderContext, Task, UpdateModel,
-    UpdateView, View, ViewContext, ViewHandle, WeakHandle, WindowInputHandler,
+    executor, geometry::vector::Vector2F, keymap::Keystroke, platform, Action, AnyViewHandle,
+    AppContext, Appearance, Entity, Event, FontCache, InputHandler, KeyDownEvent, LeakDetector,
+    ModelContext, ModelHandle, MutableAppContext, Platform, ReadModelWith, ReadViewWith,
+    RenderContext, Task, UpdateModel, UpdateView, View, ViewContext, ViewHandle, WeakHandle,
+    WindowInputHandler,
 };
 use collections::BTreeMap;
 
@@ -275,6 +276,17 @@ impl TestAppContext {
         }
     }
 
+    pub fn simulate_window_resize(&self, window_id: usize, size: Vector2F) {
+        let mut window = self.window_mut(window_id);
+        window.size = size;
+        let mut handlers = mem::take(&mut window.resize_handlers);
+        drop(window);
+        for handler in &mut handlers {
+            handler();
+        }
+        self.window_mut(window_id).resize_handlers = handlers;
+    }
+
     pub fn simulate_window_activation(&self, to_activate: Option<usize>) {
         let mut handlers = BTreeMap::new();
         {

crates/gpui/src/platform/test.rs 🔗

@@ -34,11 +34,11 @@ pub struct ForegroundPlatform {
 struct Dispatcher;
 
 pub struct Window {
-    size: Vector2F,
+    pub(crate) size: Vector2F,
     scale_factor: f32,
     current_scene: Option<crate::Scene>,
     event_handlers: Vec<Box<dyn FnMut(super::Event) -> bool>>,
-    resize_handlers: Vec<Box<dyn FnMut()>>,
+    pub(crate) resize_handlers: Vec<Box<dyn FnMut()>>,
     close_handlers: Vec<Box<dyn FnOnce()>>,
     fullscreen_handlers: Vec<Box<dyn FnMut(bool)>>,
     pub(crate) active_status_change_handlers: Vec<Box<dyn FnMut(bool)>>,