Detailed changes
@@ -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
@@ -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<Pixels>) {
+ self.0.borrow_mut().bounds = bounds;
+ }
+
/// Get the bounds for a specific child.
pub fn bounds_for_item(&self, ix: usize) -> Option<Bounds<Pixels>> {
self.0.borrow().child_bounds.get(ix).cloned()
@@ -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<RefCell<Option<usize>>>,
+#[derive(Clone, Debug, Default)]
+pub struct UniformListScrollHandle(pub Rc<RefCell<UniformListScrollState>>);
+
+#[derive(Clone, Debug, Default)]
+#[allow(missing_docs)]
+pub struct UniformListScrollState {
+ pub base_handle: ScrollHandle,
+ pub deferred_scroll_to_item: Option<usize>,
+ pub last_item_height: Option<Pixels>,
}
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
}
@@ -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<Workspace>,
width: Option<Pixels>,
pending_serialization: Task<Option<()>>,
+ show_scrollbar: bool,
+ hide_scrollbar_task: Option<Task<()>>,
}
#[derive(Clone, Debug)]
@@ -188,7 +197,10 @@ impl ProjectPanel {
let project_panel = cx.new_view(|cx: &mut ViewContext<Self>| {
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<Self>,
+ ) -> Option<Stateful<Div>> {
+ 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<Self>) -> 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::<ScrollbarAutoHide>()
+ .map_or_else(|| cx.should_auto_hide_scrollbars(), |autohide| autohide.0)
+ }
+
+ fn hide_scrollbar(&mut self, cx: &mut ViewContext<Self>) {
+ 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<Project>,
@@ -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()
@@ -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<ShowScrollbar>,
}
#[derive(Clone, Default, Serialize, Deserialize, JsonSchema, Debug)]
@@ -65,6 +95,8 @@ pub struct ProjectPanelSettingsContent {
///
/// Default: false
pub auto_fold_dirs: Option<bool>,
+ /// Scrollbar-related settings
+ pub scrollbar: Option<ScrollbarSettingsContent>,
}
impl Settings for ProjectPanelSettings {
@@ -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<f32>,
+ scroll: UniformListScrollHandle,
+ item_count: usize,
+}
+
+impl ProjectPanelScrollbar {
+ pub(crate) fn new(
+ thumb: Range<f32>,
+ 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<ui::ElementId> {
+ 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<ui::Pixels>,
+ _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<ui::Pixels>,
+ _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
+ }
+}