Merge pull request #1715 from zed-industries/scrollbars

Max Brunsfeld created

Add scrollbars

Change summary

crates/editor/src/editor.rs              |  40 ++++++
crates/editor/src/element.rs             | 156 ++++++++++++++++++++++++--
crates/gpui/src/platform.rs              |   1 
crates/gpui/src/platform/mac/platform.rs |  10 +
crates/gpui/src/platform/test.rs         |   4 
crates/theme/src/theme.rs                |   9 +
styles/src/styleTree/editor.ts           |  19 +++
styles/src/themes/common/base16.ts       |   2 
8 files changed, 228 insertions(+), 13 deletions(-)

Detailed changes

crates/editor/src/editor.rs 🔗

@@ -76,6 +76,7 @@ use util::{post_inc, ResultExt, TryFutureExt};
 use workspace::{ItemNavHistory, Workspace};
 
 const CURSOR_BLINK_INTERVAL: Duration = Duration::from_millis(500);
+const SCROLLBAR_SHOW_INTERVAL: Duration = Duration::from_secs(1);
 const MAX_LINE_LEN: usize = 1024;
 const MIN_NAVIGATION_HISTORY_ROW_DELTA: i64 = 10;
 const MAX_SELECTION_HISTORY_LEN: usize = 1024;
@@ -238,6 +239,9 @@ pub enum Direction {
     Next,
 }
 
+#[derive(Default)]
+struct ScrollbarAutoHide(bool);
+
 pub fn init(cx: &mut MutableAppContext) {
     cx.add_action(Editor::new_file);
     cx.add_action(|this: &mut Editor, action: &Scroll, cx| this.set_scroll_position(action.0, cx));
@@ -427,6 +431,8 @@ pub struct Editor {
     focused: bool,
     show_local_cursors: bool,
     show_local_selections: bool,
+    show_scrollbars: bool,
+    hide_scrollbar_task: Option<Task<()>>,
     blink_epoch: usize,
     blinking_paused: bool,
     mode: EditorMode,
@@ -1029,6 +1035,8 @@ impl Editor {
             focused: false,
             show_local_cursors: false,
             show_local_selections: true,
+            show_scrollbars: true,
+            hide_scrollbar_task: None,
             blink_epoch: 0,
             blinking_paused: false,
             mode,
@@ -1061,10 +1069,16 @@ impl Editor {
             ],
         };
         this.end_selection(cx);
+        this.make_scrollbar_visible(cx);
 
         let editor_created_event = EditorCreated(cx.handle());
         cx.emit_global(editor_created_event);
 
+        if mode == EditorMode::Full {
+            let should_auto_hide_scrollbars = cx.platform().should_auto_hide_scrollbars();
+            cx.set_global(ScrollbarAutoHide(should_auto_hide_scrollbars));
+        }
+
         this.report_event("open editor", cx);
         this
     }
@@ -1181,6 +1195,7 @@ impl Editor {
             self.scroll_top_anchor = anchor;
         }
 
+        self.make_scrollbar_visible(cx);
         self.autoscroll_request.take();
         hide_hover(self, cx);
 
@@ -5952,6 +5967,31 @@ impl Editor {
         self.show_local_cursors && self.focused
     }
 
+    pub fn show_scrollbars(&self) -> bool {
+        self.show_scrollbars
+    }
+
+    fn make_scrollbar_visible(&mut self, cx: &mut ViewContext<Self>) {
+        if !self.show_scrollbars {
+            self.show_scrollbars = true;
+            cx.notify();
+        }
+
+        if cx.default_global::<ScrollbarAutoHide>().0 {
+            self.hide_scrollbar_task = Some(cx.spawn_weak(|this, mut cx| async move {
+                Timer::after(SCROLLBAR_SHOW_INTERVAL).await;
+                if let Some(this) = this.upgrade(&cx) {
+                    this.update(&mut cx, |this, cx| {
+                        this.show_scrollbars = false;
+                        cx.notify();
+                    });
+                }
+            }));
+        } else {
+            self.hide_scrollbar_task = None;
+        }
+    }
+
     fn on_buffer_changed(&mut self, _: ModelHandle<MultiBuffer>, cx: &mut ViewContext<Self>) {
         cx.notify();
     }

crates/editor/src/element.rs 🔗

@@ -43,7 +43,7 @@ use std::{
     cmp::{self, Ordering},
     fmt::Write,
     iter,
-    ops::Range,
+    ops::{DerefMut, Range},
     sync::Arc,
 };
 use theme::DiffStyle;
@@ -454,7 +454,6 @@ impl EditorElement {
         let bounds = gutter_bounds.union_rect(text_bounds);
         let scroll_top =
             layout.position_map.snapshot.scroll_position().y() * layout.position_map.line_height;
-        let editor = self.view(cx.app);
         cx.scene.push_quad(Quad {
             bounds: gutter_bounds,
             background: Some(self.style.gutter_background),
@@ -468,7 +467,7 @@ impl EditorElement {
             corner_radius: 0.,
         });
 
-        if let EditorMode::Full = editor.mode {
+        if let EditorMode::Full = layout.mode {
             let mut active_rows = layout.active_rows.iter().peekable();
             while let Some((start_row, contains_non_empty_selection)) = active_rows.next() {
                 let mut end_row = *start_row;
@@ -909,6 +908,125 @@ impl EditorElement {
         cx.scene.pop_layer();
     }
 
+    fn paint_scrollbar(&mut self, bounds: RectF, layout: &mut LayoutState, cx: &mut PaintContext) {
+        enum ScrollbarMouseHandlers {}
+        if layout.mode != EditorMode::Full {
+            return;
+        }
+
+        let view = self.view.clone();
+        let style = &self.style.theme.scrollbar;
+        let min_thumb_height =
+            style.min_height_factor * cx.font_cache.line_height(self.style.text.font_size);
+
+        let top = bounds.min_y();
+        let bottom = bounds.max_y();
+        let right = bounds.max_x();
+        let left = right - style.width;
+        let height = bounds.height();
+        let row_range = &layout.scrollbar_row_range;
+        let max_row = layout.max_row + ((row_range.end - row_range.start) as u32);
+        let scrollbar_start = row_range.start as f32 / max_row as f32;
+        let scrollbar_end = row_range.end as f32 / max_row as f32;
+
+        let mut thumb_top = top + scrollbar_start * height;
+        let mut thumb_bottom = top + scrollbar_end * height;
+        let thumb_center = (thumb_top + thumb_bottom) / 2.0;
+
+        if thumb_bottom - thumb_top < min_thumb_height {
+            thumb_top = thumb_center - min_thumb_height / 2.0;
+            thumb_bottom = thumb_center + min_thumb_height / 2.0;
+            if thumb_top < top {
+                thumb_top = top;
+                thumb_bottom = top + min_thumb_height;
+            }
+            if thumb_bottom > bottom {
+                thumb_bottom = bottom;
+                thumb_top = bottom - min_thumb_height;
+            }
+        }
+
+        let track_bounds = RectF::from_points(vec2f(left, top), vec2f(right, bottom));
+        let thumb_bounds = RectF::from_points(vec2f(left, thumb_top), vec2f(right, thumb_bottom));
+
+        if layout.show_scrollbars {
+            cx.scene.push_quad(Quad {
+                bounds: track_bounds,
+                border: style.track.border,
+                background: style.track.background_color,
+                ..Default::default()
+            });
+            cx.scene.push_quad(Quad {
+                bounds: thumb_bounds,
+                border: style.thumb.border,
+                background: style.thumb.background_color,
+                corner_radius: style.thumb.corner_radius,
+            });
+        }
+
+        cx.scene.push_cursor_region(CursorRegion {
+            bounds: track_bounds,
+            style: CursorStyle::Arrow,
+        });
+        cx.scene.push_mouse_region(
+            MouseRegion::new::<ScrollbarMouseHandlers>(view.id(), view.id(), track_bounds)
+                .on_move({
+                    let view = view.clone();
+                    move |_, cx| {
+                        if let Some(view) = view.upgrade(cx.deref_mut()) {
+                            view.update(cx.deref_mut(), |view, cx| {
+                                view.make_scrollbar_visible(cx);
+                            });
+                        }
+                    }
+                })
+                .on_down(MouseButton::Left, {
+                    let view = view.clone();
+                    let row_range = row_range.clone();
+                    move |e, cx| {
+                        let y = e.position.y();
+                        if let Some(view) = view.upgrade(cx.deref_mut()) {
+                            view.update(cx.deref_mut(), |view, cx| {
+                                if y < thumb_top || thumb_bottom < y {
+                                    let center_row =
+                                        ((y - top) * max_row as f32 / height).round() as u32;
+                                    let top_row = center_row.saturating_sub(
+                                        (row_range.end - row_range.start) as u32 / 2,
+                                    );
+                                    let mut position = view.scroll_position(cx);
+                                    position.set_y(top_row as f32);
+                                    view.set_scroll_position(position, cx);
+                                } else {
+                                    view.make_scrollbar_visible(cx);
+                                }
+                            });
+                        }
+                    }
+                })
+                .on_drag(MouseButton::Left, {
+                    let view = view.clone();
+                    move |e, cx| {
+                        let y = e.prev_mouse_position.y();
+                        let new_y = e.position.y();
+                        if thumb_top < y && y < thumb_bottom {
+                            if let Some(view) = view.upgrade(cx.deref_mut()) {
+                                view.update(cx.deref_mut(), |view, cx| {
+                                    let mut position = view.scroll_position(cx);
+                                    position.set_y(
+                                        position.y() + (new_y - y) * (max_row as f32) / height,
+                                    );
+                                    if position.y() < 0.0 {
+                                        position.set_y(0.);
+                                    }
+                                    view.set_scroll_position(position, cx);
+                                });
+                            }
+                        }
+                    }
+                }),
+        );
+    }
+
     #[allow(clippy::too_many_arguments)]
     fn paint_highlighted_range(
         &self,
@@ -1469,13 +1587,11 @@ impl Element for EditorElement {
         // The scroll position is a fractional point, the whole number of which represents
         // the top of the window in terms of display rows.
         let start_row = scroll_position.y() as u32;
-        let scroll_top = scroll_position.y() * line_height;
+        let visible_row_count = (size.y() / line_height).ceil() as u32;
+        let max_row = snapshot.max_point().row();
 
         // Add 1 to ensure selections bleed off screen
-        let end_row = 1 + cmp::min(
-            ((scroll_top + size.y()) / line_height).ceil() as u32,
-            snapshot.max_point().row(),
-        );
+        let end_row = 1 + cmp::min(start_row + visible_row_count, max_row);
 
         let start_anchor = if start_row == 0 {
             Anchor::min()
@@ -1484,7 +1600,7 @@ impl Element for EditorElement {
                 .buffer_snapshot
                 .anchor_before(DisplayPoint::new(start_row, 0).to_offset(&snapshot, Bias::Left))
         };
-        let end_anchor = if end_row > snapshot.max_point().row() {
+        let end_anchor = if end_row > max_row {
             Anchor::max()
         } else {
             snapshot
@@ -1496,6 +1612,7 @@ impl Element for EditorElement {
         let mut active_rows = BTreeMap::new();
         let mut highlighted_rows = None;
         let mut highlighted_ranges = Vec::new();
+        let mut show_scrollbars = false;
         self.update_view(cx.app, |view, cx| {
             let display_map = view.display_map.update(cx, |map, cx| map.snapshot(cx));
 
@@ -1556,6 +1673,8 @@ impl Element for EditorElement {
                         .collect(),
                 ));
             }
+
+            show_scrollbars = view.show_scrollbars();
         });
 
         let line_number_layouts =
@@ -1566,6 +1685,9 @@ impl Element for EditorElement {
             .git_diff_hunks_in_range(start_row..end_row)
             .collect();
 
+        let scrollbar_row_range =
+            scroll_position.y()..(scroll_position.y() + visible_row_count as f32);
+
         let mut max_visible_line_width = 0.0;
         let line_layouts = self.layout_lines(start_row..end_row, &snapshot, cx);
         for line in &line_layouts {
@@ -1599,7 +1721,6 @@ impl Element for EditorElement {
             cx,
         );
 
-        let max_row = snapshot.max_point().row();
         let scroll_max = vec2f(
             ((scroll_width - text_size.x()) / em_width).max(0.0),
             max_row.saturating_sub(1) as f32,
@@ -1629,6 +1750,7 @@ impl Element for EditorElement {
         let mut context_menu = None;
         let mut code_actions_indicator = None;
         let mut hover = None;
+        let mut mode = EditorMode::Full;
         cx.render(&self.view.upgrade(cx).unwrap(), |view, cx| {
             let newest_selection_head = view
                 .selections
@@ -1650,6 +1772,7 @@ impl Element for EditorElement {
 
             let visible_rows = start_row..start_row + line_layouts.len() as u32;
             hover = view.hover_state.render(&snapshot, &style, visible_rows, cx);
+            mode = view.mode;
         });
 
         if let Some((_, context_menu)) = context_menu.as_mut() {
@@ -1697,6 +1820,7 @@ impl Element for EditorElement {
         (
             size,
             LayoutState {
+                mode,
                 position_map: Arc::new(PositionMap {
                     size,
                     scroll_max,
@@ -1709,6 +1833,9 @@ impl Element for EditorElement {
                 gutter_size,
                 gutter_padding,
                 text_size,
+                scrollbar_row_range,
+                show_scrollbars,
+                max_row,
                 gutter_margin,
                 active_rows,
                 highlighted_rows,
@@ -1756,11 +1883,12 @@ impl Element for EditorElement {
         }
         self.paint_text(text_bounds, visible_bounds, layout, cx);
 
+        cx.scene.push_layer(Some(bounds));
         if !layout.blocks.is_empty() {
-            cx.scene.push_layer(Some(bounds));
             self.paint_blocks(bounds, visible_bounds, layout, cx);
-            cx.scene.pop_layer();
         }
+        self.paint_scrollbar(bounds, layout, cx);
+        cx.scene.pop_layer();
 
         cx.scene.pop_layer();
     }
@@ -1846,12 +1974,16 @@ pub struct LayoutState {
     gutter_padding: f32,
     gutter_margin: f32,
     text_size: Vector2F,
+    mode: EditorMode,
     active_rows: BTreeMap<u32, bool>,
     highlighted_rows: Option<Range<u32>>,
     line_number_layouts: Vec<Option<text_layout::Line>>,
     blocks: Vec<BlockLayout>,
     highlighted_ranges: Vec<(Range<DisplayPoint>, Color)>,
     selections: Vec<(ReplicaId, Vec<SelectionLayout>)>,
+    scrollbar_row_range: Range<f32>,
+    show_scrollbars: bool,
+    max_row: u32,
     context_menu: Option<(DisplayPoint, ElementBox)>,
     diff_hunks: Vec<DiffHunk<u32>>,
     code_actions_indicator: Option<(u32, ElementBox)>,

crates/gpui/src/platform.rs 🔗

@@ -65,6 +65,7 @@ pub trait Platform: Send + Sync {
     fn delete_credentials(&self, url: &str) -> Result<()>;
 
     fn set_cursor_style(&self, style: CursorStyle);
+    fn should_auto_hide_scrollbars(&self) -> bool;
 
     fn local_timezone(&self) -> UtcOffset;
 

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

@@ -709,6 +709,16 @@ impl platform::Platform for MacPlatform {
         }
     }
 
+    fn should_auto_hide_scrollbars(&self) -> bool {
+        #[allow(non_upper_case_globals)]
+        const NSScrollerStyleOverlay: NSInteger = 1;
+
+        unsafe {
+            let style: NSInteger = msg_send![class!(NSScroller), preferredScrollerStyle];
+            style == NSScrollerStyleOverlay
+        }
+    }
+
     fn local_timezone(&self) -> UtcOffset {
         unsafe {
             let local_timezone: id = msg_send![class!(NSTimeZone), localTimeZone];

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

@@ -181,6 +181,10 @@ impl super::Platform for Platform {
         *self.cursor.lock() = style;
     }
 
+    fn should_auto_hide_scrollbars(&self) -> bool {
+        false
+    }
+
     fn local_timezone(&self) -> UtcOffset {
         UtcOffset::UTC
     }

crates/theme/src/theme.rs 🔗

@@ -554,6 +554,15 @@ pub struct Editor {
     pub link_definition: HighlightStyle,
     pub composition_mark: HighlightStyle,
     pub jump_icon: Interactive<IconButton>,
+    pub scrollbar: Scrollbar,
+}
+
+#[derive(Clone, Deserialize, Default)]
+pub struct Scrollbar {
+    pub track: ContainerStyle,
+    pub thumb: ContainerStyle,
+    pub width: f32,
+    pub min_height_factor: f32,
 }
 
 #[derive(Clone, Deserialize, Default)]

styles/src/styleTree/editor.ts 🔗

@@ -1,4 +1,5 @@
 import Theme from "../themes/common/theme";
+import { withOpacity } from "../utils/color";
 import {
   backgroundColor,
   border,
@@ -170,6 +171,24 @@ export default function editor(theme: Theme) {
         background: backgroundColor(theme, "on500"),
       },
     },
+    scrollbar: {
+      width: 12,
+      minHeightFactor: 1.0,
+      track: {
+        border: {
+          left: true,
+          width: 1,
+          color: borderColor(theme, "secondary"),
+        },
+      },
+      thumb: {
+        background: withOpacity(borderColor(theme, "secondary"), 0.5),
+        border: {
+          width: 1,
+          color: withOpacity(borderColor(theme, 'muted'), 0.5),
+        }
+      }
+    },
     compositionMark: {
       underline: {
         thickness: 1.0,

styles/src/themes/common/base16.ts 🔗

@@ -123,7 +123,7 @@ export function createTheme(
   const borderColor = {
     primary: sample(ramps.neutral, isLight ? 1.5 : 0),
     secondary: sample(ramps.neutral, isLight ? 1.25 : 1),
-    muted: sample(ramps.neutral, isLight ? 1 : 3),
+    muted: sample(ramps.neutral, isLight ? 1.25 : 3),
     active: sample(ramps.neutral, isLight ? 4 : 3),
     onMedia: withOpacity(darkest, 0.1),
     ok: sample(ramps.green, 0.3),