project panel: Add vertical scrollbar (#13358)
Fixes #4865 Release Notes: - Added vertical scrollbar to project panel
This commit is contained in:
parent
5c93506e9f
commit
d272e402ea
6 changed files with 357 additions and 37 deletions
|
@ -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 {
|
||||
|
|
152
crates/project_panel/src/scrollbar.rs
Normal file
152
crates/project_panel/src/scrollbar.rs
Normal file
|
@ -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
|
||||
}
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue