diff --git a/assets/settings/default.json b/assets/settings/default.json index dc86eaa8f1..8fcb289830 100644 --- a/assets/settings/default.json +++ b/assets/settings/default.json @@ -312,7 +312,14 @@ "auto_reveal_entries": true, /// Whether to fold directories automatically /// when a directory has only one directory inside. - "auto_fold_dirs": false + "auto_fold_dirs": false, + /// Scrollbar-related settings + "scrollbar": { + /// When to show the scrollbar in the project panel. + /// + /// Default: always + "show": "always" + } }, "outline_panel": { // Whether to show the outline panel button in the status bar diff --git a/crates/gpui/src/elements/div.rs b/crates/gpui/src/elements/div.rs index e80102166e..ad6d7a162c 100644 --- a/crates/gpui/src/elements/div.rs +++ b/crates/gpui/src/elements/div.rs @@ -2493,6 +2493,11 @@ impl ScrollHandle { self.0.borrow().bounds } + /// Set the bounds into which this child is painted + pub(super) fn set_bounds(&self, bounds: Bounds) { + self.0.borrow_mut().bounds = bounds; + } + /// Get the bounds for a specific child. pub fn bounds_for_item(&self, ix: usize) -> Option> { self.0.borrow().child_bounds.get(ix).cloned() diff --git a/crates/gpui/src/elements/uniform_list.rs b/crates/gpui/src/elements/uniform_list.rs index a08acf95b0..76c6b2677b 100644 --- a/crates/gpui/src/elements/uniform_list.rs +++ b/crates/gpui/src/elements/uniform_list.rs @@ -79,31 +79,37 @@ pub struct UniformListFrameState { /// A handle for controlling the scroll position of a uniform list. /// This should be stored in your view and passed to the uniform_list on each frame. -#[derive(Clone, Default)] -pub struct UniformListScrollHandle { - base_handle: ScrollHandle, - deferred_scroll_to_item: Rc>>, +#[derive(Clone, Debug, Default)] +pub struct UniformListScrollHandle(pub Rc>); + +#[derive(Clone, Debug, Default)] +#[allow(missing_docs)] +pub struct UniformListScrollState { + pub base_handle: ScrollHandle, + pub deferred_scroll_to_item: Option, + pub last_item_height: Option, } impl UniformListScrollHandle { /// Create a new scroll handle to bind to a uniform list. pub fn new() -> Self { - Self { + Self(Rc::new(RefCell::new(UniformListScrollState { base_handle: ScrollHandle::new(), - deferred_scroll_to_item: Rc::new(RefCell::new(None)), - } + deferred_scroll_to_item: None, + last_item_height: None, + }))) } /// Scroll the list to the given item index. pub fn scroll_to_item(&mut self, ix: usize) { - self.deferred_scroll_to_item.replace(Some(ix)); + self.0.borrow_mut().deferred_scroll_to_item = Some(ix); } /// Get the index of the topmost visible child. pub fn logical_scroll_top_index(&self) -> usize { - self.deferred_scroll_to_item - .borrow() - .unwrap_or_else(|| self.base_handle.logical_scroll_top().0) + let this = self.0.borrow(); + this.deferred_scroll_to_item + .unwrap_or_else(|| this.base_handle.logical_scroll_top().0) } } @@ -195,10 +201,11 @@ impl Element for UniformList { let shared_scroll_offset = self.interactivity.scroll_offset.clone().unwrap(); let item_height = self.measure_item(Some(padded_bounds.size.width), cx).height; - let shared_scroll_to_item = self - .scroll_handle - .as_mut() - .and_then(|handle| handle.deferred_scroll_to_item.take()); + let shared_scroll_to_item = self.scroll_handle.as_mut().and_then(|handle| { + let mut handle = handle.0.borrow_mut(); + handle.last_item_height = Some(item_height); + handle.deferred_scroll_to_item.take() + }); self.interactivity.prepaint( global_id, @@ -214,6 +221,10 @@ impl Element for UniformList { bounds.lower_right() - point(border.right + padding.right, border.bottom), ); + if let Some(handle) = self.scroll_handle.as_mut() { + handle.0.borrow_mut().base_handle.set_bounds(bounds); + } + if self.item_count > 0 { let content_height = item_height * self.item_count + padding.top + padding.bottom; @@ -326,7 +337,7 @@ impl UniformList { /// Track and render scroll state of this list with reference to the given scroll handle. pub fn track_scroll(mut self, handle: UniformListScrollHandle) -> Self { - self.interactivity.tracked_scroll_handle = Some(handle.base_handle.clone()); + self.interactivity.tracked_scroll_handle = Some(handle.0.borrow().base_handle.clone()); self.scroll_handle = Some(handle); self } diff --git a/crates/project_panel/src/project_panel.rs b/crates/project_panel/src/project_panel.rs index c7e2ee260c..ae9d096539 100644 --- a/crates/project_panel/src/project_panel.rs +++ b/crates/project_panel/src/project_panel.rs @@ -1,9 +1,15 @@ mod project_panel_settings; +mod scrollbar; use client::{ErrorCode, ErrorExt}; +use scrollbar::ProjectPanelScrollbar; use settings::{Settings, SettingsStore}; use db::kvp::KEY_VALUE_STORE; -use editor::{items::entry_git_aware_label_color, scroll::Autoscroll, Editor}; +use editor::{ + items::entry_git_aware_label_color, + scroll::{Autoscroll, ScrollbarAutoHide}, + Editor, +}; use file_icons::FileIcons; use anyhow::{anyhow, Result}; @@ -19,7 +25,7 @@ use gpui::{ }; use menu::{Confirm, SelectFirst, SelectLast, SelectNext, SelectPrev}; use project::{Entry, EntryKind, Fs, Project, ProjectEntryId, ProjectPath, Worktree, WorktreeId}; -use project_panel_settings::{ProjectPanelDockPosition, ProjectPanelSettings}; +use project_panel_settings::{ProjectPanelDockPosition, ProjectPanelSettings, ShowScrollbar}; use serde::{Deserialize, Serialize}; use std::{ cell::OnceCell, @@ -28,6 +34,7 @@ use std::{ ops::Range, path::{Path, PathBuf}, sync::Arc, + time::Duration, }; use theme::ThemeSettings; use ui::{prelude::*, v_flex, ContextMenu, Icon, KeyBinding, Label, ListItem, Tooltip}; @@ -63,6 +70,8 @@ pub struct ProjectPanel { workspace: WeakView, width: Option, pending_serialization: Task>, + show_scrollbar: bool, + hide_scrollbar_task: Option>, } #[derive(Clone, Debug)] @@ -188,7 +197,10 @@ impl ProjectPanel { let project_panel = cx.new_view(|cx: &mut ViewContext| { let focus_handle = cx.focus_handle(); cx.on_focus(&focus_handle, Self::focus_in).detach(); - + cx.on_focus_out(&focus_handle, |this, _, cx| { + this.hide_scrollbar(cx); + }) + .detach(); cx.subscribe(&project, |this, project, event, cx| match event { project::Event::ActiveEntryChanged(Some(entry_id)) => { if ProjectPanelSettings::get_global(cx).auto_reveal_entries { @@ -273,6 +285,8 @@ impl ProjectPanel { workspace: workspace.weak_handle(), width: None, pending_serialization: Task::ready(None), + show_scrollbar: !Self::should_autohide_scrollbar(cx), + hide_scrollbar_task: None, }; this.update_visible_entries(None, cx); @@ -2201,6 +2215,73 @@ impl ProjectPanel { ) } + fn render_scrollbar( + &self, + items_count: usize, + cx: &mut ViewContext, + ) -> Option> { + let settings = ProjectPanelSettings::get_global(cx); + if settings.scrollbar.show == ShowScrollbar::Never { + return None; + } + let scroll_handle = self.scroll_handle.0.borrow(); + + let height = scroll_handle + .last_item_height + .filter(|_| self.show_scrollbar)?; + + let total_list_length = height.0 as f64 * items_count as f64; + let current_offset = scroll_handle.base_handle.offset().y.0.min(0.).abs() as f64; + let mut percentage = current_offset / total_list_length; + let mut end_offset = (current_offset + + scroll_handle.base_handle.bounds().size.height.0 as f64) + / total_list_length; + // Uniform scroll handle might briefly report an offset greater than the length of a list; + // in such case we'll adjust the starting offset as well to keep the scrollbar thumb length stable. + let overshoot = (end_offset - 1.).clamp(0., 1.); + if overshoot > 0. { + percentage -= overshoot; + } + if percentage + 0.005 > 1.0 || end_offset > total_list_length { + return None; + } + if total_list_length < scroll_handle.base_handle.bounds().size.height.0 as f64 { + percentage = 0.; + end_offset = 1.; + } + let end_offset = end_offset.clamp(percentage + 0.005, 1.); + Some( + div() + .occlude() + .id("project-panel-scroll") + .on_mouse_move(cx.listener(|_, _, cx| { + cx.notify(); + cx.stop_propagation() + })) + .on_hover(|_, cx| { + cx.stop_propagation(); + }) + .on_any_mouse_down(|_, cx| { + cx.stop_propagation(); + }) + .on_scroll_wheel(cx.listener(|_, _, cx| { + cx.notify(); + })) + .h_full() + .absolute() + .right_0() + .top_0() + .bottom_0() + .w_3() + .cursor_default() + .child(ProjectPanelScrollbar::new( + percentage as f32..end_offset as f32, + self.scroll_handle.clone(), + items_count, + )), + ) + } + fn dispatch_context(&self, cx: &ViewContext) -> KeyContext { let mut dispatch_context = KeyContext::new_with_defaults(); dispatch_context.add("ProjectPanel"); @@ -2216,6 +2297,29 @@ impl ProjectPanel { dispatch_context } + fn should_autohide_scrollbar(cx: &AppContext) -> bool { + cx.try_global::() + .map_or_else(|| cx.should_auto_hide_scrollbars(), |autohide| autohide.0) + } + + fn hide_scrollbar(&mut self, cx: &mut ViewContext) { + const SCROLLBAR_SHOW_INTERVAL: Duration = Duration::from_secs(1); + if !Self::should_autohide_scrollbar(cx) { + return; + } + self.hide_scrollbar_task = Some(cx.spawn(|panel, mut cx| async move { + cx.background_executor() + .timer(SCROLLBAR_SHOW_INTERVAL) + .await; + panel + .update(&mut cx, |editor, cx| { + editor.show_scrollbar = false; + cx.notify(); + }) + .log_err(); + })) + } + fn reveal_entry( &mut self, project: Model, @@ -2249,10 +2353,26 @@ impl Render for ProjectPanel { let project = self.project.read(cx); if has_worktree { + let items_count = self + .visible_entries + .iter() + .map(|(_, worktree_entries, _)| worktree_entries.len()) + .sum(); + h_flex() .id("project-panel") + .group("project-panel") .size_full() .relative() + .on_hover(cx.listener(|this, hovered, cx| { + if *hovered { + this.show_scrollbar = true; + this.hide_scrollbar_task.take(); + cx.notify(); + } else if !this.focus_handle.contains_focused(cx) { + this.hide_scrollbar(cx); + } + })) .key_context(self.dispatch_context(cx)) .on_action(cx.listener(Self::select_next)) .on_action(cx.listener(Self::select_prev)) @@ -2298,27 +2418,20 @@ impl Render for ProjectPanel { ) .track_focus(&self.focus_handle) .child( - uniform_list( - cx.view().clone(), - "entries", - self.visible_entries - .iter() - .map(|(_, worktree_entries, _)| worktree_entries.len()) - .sum(), - { - |this, range, cx| { - let mut items = Vec::new(); - this.for_each_visible_entry(range, cx, |id, details, cx| { - items.push(this.render_entry(id, details, cx)); - }); - items - } - }, - ) + uniform_list(cx.view().clone(), "entries", items_count, { + |this, range, cx| { + let mut items = Vec::new(); + this.for_each_visible_entry(range, cx, |id, details, cx| { + items.push(this.render_entry(id, details, cx)); + }); + items + } + }) .size_full() .with_sizing_behavior(ListSizingBehavior::Infer) .track_scroll(self.scroll_handle.clone()), ) + .children(self.render_scrollbar(items_count, cx)) .children(self.context_menu.as_ref().map(|(menu, position, _)| { deferred( anchored() diff --git a/crates/project_panel/src/project_panel_settings.rs b/crates/project_panel/src/project_panel_settings.rs index 0a41c6ea6c..ae565ba2ca 100644 --- a/crates/project_panel/src/project_panel_settings.rs +++ b/crates/project_panel/src/project_panel_settings.rs @@ -22,6 +22,36 @@ pub struct ProjectPanelSettings { pub indent_size: f32, pub auto_reveal_entries: bool, pub auto_fold_dirs: bool, + pub scrollbar: ScrollbarSettings, +} + +/// When to show the scrollbar in the project panel. +/// +/// Default: always +#[derive(Copy, Clone, Debug, Default, Serialize, Deserialize, JsonSchema, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum ShowScrollbar { + #[default] + /// Always show the scrollbar. + Always, + /// Never show the scrollbar. + Never, +} + +#[derive(Copy, Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Eq)] +pub struct ScrollbarSettings { + /// When to show the scrollbar in the project panel. + /// + /// Default: always + pub show: ShowScrollbar, +} + +#[derive(Copy, Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Eq)] +pub struct ScrollbarSettingsContent { + /// When to show the scrollbar in the project panel. + /// + /// Default: always + pub show: Option, } #[derive(Clone, Default, Serialize, Deserialize, JsonSchema, Debug)] @@ -65,6 +95,8 @@ pub struct ProjectPanelSettingsContent { /// /// Default: false pub auto_fold_dirs: Option, + /// Scrollbar-related settings + pub scrollbar: Option, } impl Settings for ProjectPanelSettings { diff --git a/crates/project_panel/src/scrollbar.rs b/crates/project_panel/src/scrollbar.rs new file mode 100644 index 0000000000..8670e54325 --- /dev/null +++ b/crates/project_panel/src/scrollbar.rs @@ -0,0 +1,152 @@ +use std::ops::Range; + +use gpui::{ + point, Bounds, ContentMask, Hitbox, MouseDownEvent, MouseMoveEvent, ScrollWheelEvent, Style, + UniformListScrollHandle, +}; +use ui::{prelude::*, px, relative, IntoElement}; + +pub(crate) struct ProjectPanelScrollbar { + thumb: Range, + scroll: UniformListScrollHandle, + item_count: usize, +} + +impl ProjectPanelScrollbar { + pub(crate) fn new( + thumb: Range, + scroll: UniformListScrollHandle, + item_count: usize, + ) -> Self { + Self { + thumb, + scroll, + item_count, + } + } +} + +impl gpui::Element for ProjectPanelScrollbar { + type RequestLayoutState = (); + + type PrepaintState = Hitbox; + + fn id(&self) -> Option { + None + } + + fn request_layout( + &mut self, + _id: Option<&gpui::GlobalElementId>, + cx: &mut ui::WindowContext, + ) -> (gpui::LayoutId, Self::RequestLayoutState) { + let mut style = Style::default(); + style.flex_grow = 1.; + style.flex_shrink = 1.; + style.size.width = px(12.).into(); + style.size.height = relative(1.).into(); + (cx.request_layout(style, None), ()) + } + + fn prepaint( + &mut self, + _id: Option<&gpui::GlobalElementId>, + bounds: Bounds, + _request_layout: &mut Self::RequestLayoutState, + cx: &mut ui::WindowContext, + ) -> Self::PrepaintState { + cx.with_content_mask(Some(ContentMask { bounds }), |cx| { + cx.insert_hitbox(bounds, false) + }) + } + + fn paint( + &mut self, + _id: Option<&gpui::GlobalElementId>, + bounds: Bounds, + _request_layout: &mut Self::RequestLayoutState, + _prepaint: &mut Self::PrepaintState, + cx: &mut ui::WindowContext, + ) { + let hitbox_id = _prepaint.id; + cx.with_content_mask(Some(ContentMask { bounds }), |cx| { + let colors = cx.theme().colors(); + let scrollbar_background = colors.scrollbar_track_border; + let thumb_background = colors.scrollbar_thumb_background; + cx.paint_quad(gpui::fill(bounds, scrollbar_background)); + + let thumb_offset = self.thumb.start * bounds.size.height; + let thumb_end = self.thumb.end * bounds.size.height; + let thumb_upper_left = point(bounds.origin.x, bounds.origin.y + thumb_offset); + let thumb_lower_right = point( + bounds.origin.x + bounds.size.width, + bounds.origin.y + thumb_end, + ); + let thumb_percentage_size = self.thumb.end - self.thumb.start; + cx.paint_quad(gpui::fill( + Bounds::from_corners(thumb_upper_left, thumb_lower_right), + thumb_background, + )); + let scroll = self.scroll.clone(); + let item_count = self.item_count; + cx.on_mouse_event({ + let scroll = self.scroll.clone(); + move |event: &MouseDownEvent, phase, _cx| { + if phase.bubble() && bounds.contains(&event.position) { + let scroll = scroll.0.borrow(); + if let Some(last_height) = scroll.last_item_height { + let max_offset = item_count as f32 * last_height; + let percentage = + (event.position.y - bounds.origin.y) / bounds.size.height; + + let percentage = percentage.min(1. - thumb_percentage_size); + scroll + .base_handle + .set_offset(point(px(0.), -max_offset * percentage)); + } + } + } + }); + cx.on_mouse_event({ + let scroll = self.scroll.clone(); + move |event: &ScrollWheelEvent, phase, cx| { + if phase.bubble() && bounds.contains(&event.position) { + let scroll = scroll.0.borrow_mut(); + let current_offset = scroll.base_handle.offset(); + scroll + .base_handle + .set_offset(current_offset + event.delta.pixel_delta(cx.line_height())); + } + } + }); + + cx.on_mouse_event(move |event: &MouseMoveEvent, phase, cx| { + if phase.bubble() && bounds.contains(&event.position) && hitbox_id.is_hovered(cx) { + if event.dragging() { + let scroll = scroll.0.borrow(); + if let Some(last_height) = scroll.last_item_height { + let max_offset = item_count as f32 * last_height; + let percentage = + (event.position.y - bounds.origin.y) / bounds.size.height; + + let percentage = percentage.min(1. - thumb_percentage_size); + scroll + .base_handle + .set_offset(point(px(0.), -max_offset * percentage)); + } + } else { + cx.stop_propagation(); + } + } + }); + }) + } +} + +impl IntoElement for ProjectPanelScrollbar { + type Element = Self; + + fn into_element(self) -> Self::Element { + self + } +}