Add page up/down bindings to the Markdown preview (#33403)

Daniel Sauble created

First time contributor here. 😊

I settled on markdown::MovePageUp and markdown::MovePageDown to match
the names the editor uses for the same functionality.

Closes #30246

Release Notes:

- Support PgUp/PgDown in Markdown previews

Change summary

assets/keymaps/default-linux.json                    |  7 +
assets/keymaps/default-macos.json                    |  7 +
crates/gpui/src/elements/list.rs                     | 73 ++++++++++++++
crates/markdown_preview/src/markdown_preview.rs      |  8 +
crates/markdown_preview/src/markdown_preview_view.rs | 28 ++++
5 files changed, 119 insertions(+), 4 deletions(-)

Detailed changes

assets/keymaps/default-linux.json 🔗

@@ -1068,6 +1068,13 @@
       "ctrl-shift-tab": "pane::ActivatePreviousItem"
     }
   },
+  {
+    "context": "MarkdownPreview",
+    "bindings": {
+      "pageup": "markdown::MovePageUp",
+      "pagedown": "markdown::MovePageDown"
+    }
+  },
   {
     "context": "KeymapEditor",
     "use_key_equivalents": true,

assets/keymaps/default-macos.json 🔗

@@ -1168,6 +1168,13 @@
       "ctrl-shift-tab": "pane::ActivatePreviousItem"
     }
   },
+  {
+    "context": "MarkdownPreview",
+    "bindings": {
+      "pageup": "markdown::MovePageUp",
+      "pagedown": "markdown::MovePageDown"
+    }
+  },
   {
     "context": "KeymapEditor",
     "use_key_equivalents": true,

crates/gpui/src/elements/list.rs 🔗

@@ -291,6 +291,31 @@ impl ListState {
         self.0.borrow().logical_scroll_top()
     }
 
+    /// Scroll the list by the given offset
+    pub fn scroll_by(&self, distance: Pixels) {
+        if distance == px(0.) {
+            return;
+        }
+
+        let current_offset = self.logical_scroll_top();
+        let state = &mut *self.0.borrow_mut();
+        let mut cursor = state.items.cursor::<ListItemSummary>(&());
+        cursor.seek(&Count(current_offset.item_ix), Bias::Right, &());
+
+        let start_pixel_offset = cursor.start().height + current_offset.offset_in_item;
+        let new_pixel_offset = (start_pixel_offset + distance).max(px(0.));
+        if new_pixel_offset > start_pixel_offset {
+            cursor.seek_forward(&Height(new_pixel_offset), Bias::Right, &());
+        } else {
+            cursor.seek(&Height(new_pixel_offset), Bias::Right, &());
+        }
+
+        state.logical_scroll_top = Some(ListOffset {
+            item_ix: cursor.start().count,
+            offset_in_item: new_pixel_offset - cursor.start().height,
+        });
+    }
+
     /// Scroll the list to the given offset
     pub fn scroll_to(&self, mut scroll_top: ListOffset) {
         let state = &mut *self.0.borrow_mut();
@@ -1119,4 +1144,52 @@ mod test {
         assert_eq!(state.logical_scroll_top().item_ix, 0);
         assert_eq!(state.logical_scroll_top().offset_in_item, px(0.));
     }
+
+    #[gpui::test]
+    fn test_scroll_by_positive_and_negative_distance(cx: &mut TestAppContext) {
+        use crate::{
+            AppContext, Context, Element, IntoElement, ListState, Render, Styled, Window, div,
+            list, point, px, size,
+        };
+
+        let cx = cx.add_empty_window();
+
+        let state = ListState::new(5, crate::ListAlignment::Top, px(10.), |_, _, _| {
+            div().h(px(20.)).w_full().into_any()
+        });
+
+        struct TestView(ListState);
+        impl Render for TestView {
+            fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
+                list(self.0.clone()).w_full().h_full()
+            }
+        }
+
+        // Paint
+        cx.draw(point(px(0.), px(0.)), size(px(100.), px(100.)), |_, cx| {
+            cx.new(|_| TestView(state.clone()))
+        });
+
+        // Test positive distance: start at item 1, move down 30px
+        state.scroll_by(px(30.));
+
+        // Should move to item 2
+        let offset = state.logical_scroll_top();
+        assert_eq!(offset.item_ix, 1);
+        assert_eq!(offset.offset_in_item, px(10.));
+
+        // Test negative distance: start at item 2, move up 30px
+        state.scroll_by(px(-30.));
+
+        // Should move back to item 1
+        let offset = state.logical_scroll_top();
+        assert_eq!(offset.item_ix, 0);
+        assert_eq!(offset.offset_in_item, px(0.));
+
+        // Test zero distance
+        state.scroll_by(px(0.));
+        let offset = state.logical_scroll_top();
+        assert_eq!(offset.item_ix, 0);
+        assert_eq!(offset.offset_in_item, px(0.));
+    }
 }

crates/markdown_preview/src/markdown_preview.rs 🔗

@@ -8,7 +8,13 @@ pub mod markdown_renderer;
 
 actions!(
     markdown,
-    [OpenPreview, OpenPreviewToTheSide, OpenFollowingPreview]
+    [
+        MovePageUp,
+        MovePageDown,
+        OpenPreview,
+        OpenPreviewToTheSide,
+        OpenFollowingPreview
+    ]
 );
 
 pub fn init(cx: &mut App) {

crates/markdown_preview/src/markdown_preview_view.rs 🔗

@@ -7,8 +7,8 @@ use editor::scroll::Autoscroll;
 use editor::{Editor, EditorEvent, SelectionEffects};
 use gpui::{
     App, ClickEvent, Context, Entity, EventEmitter, FocusHandle, Focusable, InteractiveElement,
-    IntoElement, ListState, ParentElement, Render, RetainAllImageCache, Styled, Subscription, Task,
-    WeakEntity, Window, list,
+    IntoElement, IsZero, ListState, ParentElement, Render, RetainAllImageCache, Styled,
+    Subscription, Task, WeakEntity, Window, list,
 };
 use language::LanguageRegistry;
 use settings::Settings;
@@ -19,7 +19,7 @@ use workspace::{Pane, Workspace};
 
 use crate::markdown_elements::ParsedMarkdownElement;
 use crate::{
-    OpenFollowingPreview, OpenPreview, OpenPreviewToTheSide,
+    MovePageDown, MovePageUp, OpenFollowingPreview, OpenPreview, OpenPreviewToTheSide,
     markdown_elements::ParsedMarkdown,
     markdown_parser::parse_markdown,
     markdown_renderer::{RenderContext, render_markdown_block},
@@ -530,6 +530,26 @@ impl MarkdownPreviewView {
     ) -> bool {
         !(current_block.is_list_item() && next_block.map(|b| b.is_list_item()).unwrap_or(false))
     }
+
+    fn scroll_page_up(&mut self, _: &MovePageUp, _window: &mut Window, cx: &mut Context<Self>) {
+        let viewport_height = self.list_state.viewport_bounds().size.height;
+        if viewport_height.is_zero() {
+            return;
+        }
+
+        self.list_state.scroll_by(-viewport_height);
+        cx.notify();
+    }
+
+    fn scroll_page_down(&mut self, _: &MovePageDown, _window: &mut Window, cx: &mut Context<Self>) {
+        let viewport_height = self.list_state.viewport_bounds().size.height;
+        if viewport_height.is_zero() {
+            return;
+        }
+
+        self.list_state.scroll_by(viewport_height);
+        cx.notify();
+    }
 }
 
 impl Focusable for MarkdownPreviewView {
@@ -580,6 +600,8 @@ impl Render for MarkdownPreviewView {
             .id("MarkdownPreview")
             .key_context("MarkdownPreview")
             .track_focus(&self.focus_handle(cx))
+            .on_action(cx.listener(MarkdownPreviewView::scroll_page_up))
+            .on_action(cx.listener(MarkdownPreviewView::scroll_page_down))
             .size_full()
             .bg(cx.theme().colors().editor_background)
             .p_4()