Detailed changes
@@ -2575,4 +2575,9 @@ impl ScrollHandle {
pub fn set_logical_scroll_top(&self, ix: usize, px: Pixels) {
self.0.borrow_mut().requested_scroll_top = Some((ix, px));
}
+
+ /// Get the count of children for scrollable item.
+ pub fn children_count(&self) -> usize {
+ self.0.borrow().child_bounds.len()
+ }
}
@@ -1,8 +1,8 @@
mod project_panel_settings;
-mod scrollbar;
+
use client::{ErrorCode, ErrorExt};
-use scrollbar::ProjectPanelScrollbar;
use settings::{Settings, SettingsStore};
+use ui::{Scrollbar, ScrollbarState};
use db::kvp::KEY_VALUE_STORE;
use editor::{
@@ -14,16 +14,14 @@ use file_icons::FileIcons;
use anyhow::{anyhow, Context as _, Result};
use collections::{hash_map, BTreeSet, HashMap};
-use core::f32;
use git::repository::GitFileStatus;
use gpui::{
actions, anchored, deferred, div, impl_actions, px, uniform_list, Action, AnyElement,
AppContext, AssetSource, AsyncWindowContext, ClipboardItem, DismissEvent, Div, DragMoveEvent,
- Entity, EventEmitter, ExternalPaths, FocusHandle, FocusableView, InteractiveElement,
- KeyContext, ListHorizontalSizingBehavior, ListSizingBehavior, Model, MouseButton,
- MouseDownEvent, ParentElement, Pixels, Point, PromptLevel, Render, Stateful, Styled,
- Subscription, Task, UniformListScrollHandle, View, ViewContext, VisualContext as _, WeakView,
- WindowContext,
+ EventEmitter, ExternalPaths, FocusHandle, FocusableView, InteractiveElement, KeyContext,
+ ListHorizontalSizingBehavior, ListSizingBehavior, Model, MouseButton, MouseDownEvent,
+ ParentElement, Pixels, Point, PromptLevel, Render, Stateful, Styled, Subscription, Task,
+ UniformListScrollHandle, View, ViewContext, VisualContext as _, WeakView, WindowContext,
};
use indexmap::IndexMap;
use menu::{Confirm, SelectFirst, SelectLast, SelectNext, SelectPrev};
@@ -34,12 +32,11 @@ use project::{
use project_panel_settings::{ProjectPanelDockPosition, ProjectPanelSettings};
use serde::{Deserialize, Serialize};
use std::{
- cell::{Cell, OnceCell},
+ cell::OnceCell,
collections::HashSet,
ffi::OsStr,
ops::Range,
path::{Path, PathBuf},
- rc::Rc,
sync::Arc,
time::Duration,
};
@@ -59,8 +56,8 @@ const NEW_ENTRY_ID: ProjectEntryId = ProjectEntryId::MAX;
pub struct ProjectPanel {
project: Model<Project>,
fs: Arc<dyn Fs>,
- scroll_handle: UniformListScrollHandle,
focus_handle: FocusHandle,
+ scroll_handle: UniformListScrollHandle,
visible_entries: Vec<(WorktreeId, Vec<Entry>, OnceCell<HashSet<Arc<Path>>>)>,
/// Maps from leaf project entry ID to the currently selected ancestor.
/// Relevant only for auto-fold dirs, where a single project panel entry may actually consist of several
@@ -82,8 +79,8 @@ pub struct ProjectPanel {
width: Option<Pixels>,
pending_serialization: Task<Option<()>>,
show_scrollbar: bool,
- vertical_scrollbar_drag_thumb_offset: Rc<Cell<Option<f32>>>,
- horizontal_scrollbar_drag_thumb_offset: Rc<Cell<Option<f32>>>,
+ vertical_scrollbar_state: ScrollbarState,
+ horizontal_scrollbar_state: ScrollbarState,
hide_scrollbar_task: Option<Task<()>>,
max_width_item_index: Option<usize>,
}
@@ -297,10 +294,10 @@ impl ProjectPanel {
})
.detach();
+ let scroll_handle = UniformListScrollHandle::new();
let mut this = Self {
project: project.clone(),
fs: workspace.app_state().fs.clone(),
- scroll_handle: UniformListScrollHandle::new(),
focus_handle,
visible_entries: Default::default(),
ancestors: Default::default(),
@@ -320,9 +317,12 @@ impl ProjectPanel {
pending_serialization: Task::ready(None),
show_scrollbar: !Self::should_autohide_scrollbar(cx),
hide_scrollbar_task: None,
- vertical_scrollbar_drag_thumb_offset: Default::default(),
- horizontal_scrollbar_drag_thumb_offset: Default::default(),
+ vertical_scrollbar_state: ScrollbarState::new(scroll_handle.clone())
+ .parent_view(cx.view()),
+ horizontal_scrollbar_state: ScrollbarState::new(scroll_handle.clone())
+ .parent_view(cx.view()),
max_width_item_index: None,
+ scroll_handle,
};
this.update_visible_entries(None, cx);
@@ -2606,37 +2606,11 @@ impl ProjectPanel {
}
fn render_vertical_scrollbar(&self, cx: &mut ViewContext<Self>) -> Option<Stateful<Div>> {
- if !Self::should_show_scrollbar(cx) {
- return None;
- }
- let scroll_handle = self.scroll_handle.0.borrow();
- let total_list_length = scroll_handle
- .last_item_size
- .filter(|_| {
- self.show_scrollbar || self.vertical_scrollbar_drag_thumb_offset.get().is_some()
- })?
- .contents
- .height
- .0 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 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;
- }
- const MINIMUM_SCROLLBAR_PERCENTAGE_HEIGHT: f64 = 0.005;
- if percentage + MINIMUM_SCROLLBAR_PERCENTAGE_HEIGHT > 1.0 || end_offset > total_list_length
+ if !Self::should_show_scrollbar(cx)
+ || !(self.show_scrollbar || self.vertical_scrollbar_state.is_dragging())
{
return None;
}
- if total_list_length < scroll_handle.base_handle.bounds().size.height.0 as f64 {
- return None;
- }
- let end_offset = end_offset.clamp(percentage + MINIMUM_SCROLLBAR_PERCENTAGE_HEIGHT, 1.);
Some(
div()
.occlude()
@@ -2654,7 +2628,7 @@ impl ProjectPanel {
.on_mouse_up(
MouseButton::Left,
cx.listener(|this, _, cx| {
- if this.vertical_scrollbar_drag_thumb_offset.get().is_none()
+ if !this.vertical_scrollbar_state.is_dragging()
&& !this.focus_handle.contains_focused(cx)
{
this.hide_scrollbar(cx);
@@ -2674,48 +2648,20 @@ impl ProjectPanel {
.bottom_1()
.w(px(12.))
.cursor_default()
- .child(ProjectPanelScrollbar::vertical(
- percentage as f32..end_offset as f32,
- self.scroll_handle.clone(),
- self.vertical_scrollbar_drag_thumb_offset.clone(),
- cx.view().entity_id(),
+ .children(Scrollbar::vertical(
+ // percentage as f32..end_offset as f32,
+ self.vertical_scrollbar_state.clone(),
)),
)
}
fn render_horizontal_scrollbar(&self, cx: &mut ViewContext<Self>) -> Option<Stateful<Div>> {
- if !Self::should_show_scrollbar(cx) {
- return None;
- }
- let scroll_handle = self.scroll_handle.0.borrow();
- let longest_item_width = scroll_handle
- .last_item_size
- .filter(|_| {
- self.show_scrollbar || self.horizontal_scrollbar_drag_thumb_offset.get().is_some()
- })
- .filter(|size| size.contents.width > size.item.width)?
- .contents
- .width
- .0 as f64;
- let current_offset = scroll_handle.base_handle.offset().x.0.min(0.).abs() as f64;
- let mut percentage = current_offset / longest_item_width;
- let end_offset = (current_offset + scroll_handle.base_handle.bounds().size.width.0 as f64)
- / longest_item_width;
- // 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;
- }
- const MINIMUM_SCROLLBAR_PERCENTAGE_WIDTH: f64 = 0.005;
- if percentage + MINIMUM_SCROLLBAR_PERCENTAGE_WIDTH > 1.0 || end_offset > longest_item_width
+ if !Self::should_show_scrollbar(cx)
+ || !(self.show_scrollbar || self.horizontal_scrollbar_state.is_dragging())
{
return None;
}
- if longest_item_width < scroll_handle.base_handle.bounds().size.width.0 as f64 {
- return None;
- }
- let end_offset = end_offset.clamp(percentage + MINIMUM_SCROLLBAR_PERCENTAGE_WIDTH, 1.);
+
Some(
div()
.occlude()
@@ -2733,7 +2679,7 @@ impl ProjectPanel {
.on_mouse_up(
MouseButton::Left,
cx.listener(|this, _, cx| {
- if this.horizontal_scrollbar_drag_thumb_offset.get().is_none()
+ if !this.horizontal_scrollbar_state.is_dragging()
&& !this.focus_handle.contains_focused(cx)
{
this.hide_scrollbar(cx);
@@ -2754,11 +2700,9 @@ impl ProjectPanel {
.h(px(12.))
.cursor_default()
.when(self.width.is_some(), |this| {
- this.child(ProjectPanelScrollbar::horizontal(
- percentage as f32..end_offset as f32,
- self.scroll_handle.clone(),
- self.horizontal_scrollbar_drag_thumb_offset.clone(),
- cx.view().entity_id(),
+ this.children(Scrollbar::horizontal(
+ //percentage as f32..end_offset as f32,
+ self.horizontal_scrollbar_state.clone(),
))
}),
)
@@ -1,277 +0,0 @@
-use std::{cell::Cell, ops::Range, rc::Rc};
-
-use gpui::{
- point, quad, Bounds, ContentMask, Corners, Edges, EntityId, Hitbox, Hsla, MouseDownEvent,
- MouseMoveEvent, MouseUpEvent, ScrollWheelEvent, Style, UniformListScrollHandle,
-};
-use ui::{prelude::*, px, relative, IntoElement};
-
-#[derive(Debug, Clone, Copy, PartialEq, Eq)]
-pub(crate) enum ScrollbarKind {
- Horizontal,
- Vertical,
-}
-
-pub(crate) struct ProjectPanelScrollbar {
- thumb: Range<f32>,
- scroll: UniformListScrollHandle,
- // If Some(), there's an active drag, offset by percentage from the top of thumb.
- scrollbar_drag_state: Rc<Cell<Option<f32>>>,
- kind: ScrollbarKind,
- parent_id: EntityId,
-}
-
-impl ProjectPanelScrollbar {
- pub(crate) fn vertical(
- thumb: Range<f32>,
- scroll: UniformListScrollHandle,
- scrollbar_drag_state: Rc<Cell<Option<f32>>>,
- parent_id: EntityId,
- ) -> Self {
- Self {
- thumb,
- scroll,
- scrollbar_drag_state,
- kind: ScrollbarKind::Vertical,
- parent_id,
- }
- }
-
- pub(crate) fn horizontal(
- thumb: Range<f32>,
- scroll: UniformListScrollHandle,
- scrollbar_drag_state: Rc<Cell<Option<f32>>>,
- parent_id: EntityId,
- ) -> Self {
- Self {
- thumb,
- scroll,
- scrollbar_drag_state,
- kind: ScrollbarKind::Horizontal,
- parent_id,
- }
- }
-}
-
-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.;
- if self.kind == ScrollbarKind::Vertical {
- style.size.width = px(12.).into();
- style.size.height = relative(1.).into();
- } else {
- style.size.width = relative(1.).into();
- style.size.height = px(12.).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,
- ) {
- cx.with_content_mask(Some(ContentMask { bounds }), |cx| {
- let colors = cx.theme().colors();
- let thumb_background = colors.scrollbar_thumb_background;
- let is_vertical = self.kind == ScrollbarKind::Vertical;
- let extra_padding = px(5.0);
- let padded_bounds = if is_vertical {
- Bounds::from_corners(
- bounds.origin + point(Pixels::ZERO, extra_padding),
- bounds.lower_right() - point(Pixels::ZERO, extra_padding * 3),
- )
- } else {
- Bounds::from_corners(
- bounds.origin + point(extra_padding, Pixels::ZERO),
- bounds.lower_right() - point(extra_padding * 3, Pixels::ZERO),
- )
- };
-
- let mut thumb_bounds = if is_vertical {
- let thumb_offset = self.thumb.start * padded_bounds.size.height;
- let thumb_end = self.thumb.end * padded_bounds.size.height;
- let thumb_upper_left = point(
- padded_bounds.origin.x,
- padded_bounds.origin.y + thumb_offset,
- );
- let thumb_lower_right = point(
- padded_bounds.origin.x + padded_bounds.size.width,
- padded_bounds.origin.y + thumb_end,
- );
- Bounds::from_corners(thumb_upper_left, thumb_lower_right)
- } else {
- let thumb_offset = self.thumb.start * padded_bounds.size.width;
- let thumb_end = self.thumb.end * padded_bounds.size.width;
- let thumb_upper_left = point(
- padded_bounds.origin.x + thumb_offset,
- padded_bounds.origin.y,
- );
- let thumb_lower_right = point(
- padded_bounds.origin.x + thumb_end,
- padded_bounds.origin.y + padded_bounds.size.height,
- );
- Bounds::from_corners(thumb_upper_left, thumb_lower_right)
- };
- let corners = if is_vertical {
- thumb_bounds.size.width /= 1.5;
- Corners::all(thumb_bounds.size.width / 2.0)
- } else {
- thumb_bounds.size.height /= 1.5;
- Corners::all(thumb_bounds.size.height / 2.0)
- };
- cx.paint_quad(quad(
- thumb_bounds,
- corners,
- thumb_background,
- Edges::default(),
- Hsla::transparent_black(),
- ));
-
- let scroll = self.scroll.clone();
- let kind = self.kind;
- let thumb_percentage_size = self.thumb.end - self.thumb.start;
-
- cx.on_mouse_event({
- let scroll = self.scroll.clone();
- let is_dragging = self.scrollbar_drag_state.clone();
- move |event: &MouseDownEvent, phase, _cx| {
- if phase.bubble() && bounds.contains(&event.position) {
- if !thumb_bounds.contains(&event.position) {
- let scroll = scroll.0.borrow();
- if let Some(item_size) = scroll.last_item_size {
- match kind {
- ScrollbarKind::Horizontal => {
- let percentage = (event.position.x - bounds.origin.x)
- / bounds.size.width;
- let max_offset = item_size.contents.width;
- let percentage = percentage.min(1. - thumb_percentage_size);
- scroll.base_handle.set_offset(point(
- -max_offset * percentage,
- scroll.base_handle.offset().y,
- ));
- }
- ScrollbarKind::Vertical => {
- let percentage = (event.position.y - bounds.origin.y)
- / bounds.size.height;
- let max_offset = item_size.contents.height;
- let percentage = percentage.min(1. - thumb_percentage_size);
- scroll.base_handle.set_offset(point(
- scroll.base_handle.offset().x,
- -max_offset * percentage,
- ));
- }
- }
- }
- } else {
- let thumb_offset = if is_vertical {
- (event.position.y - thumb_bounds.origin.y) / bounds.size.height
- } else {
- (event.position.x - thumb_bounds.origin.x) / bounds.size.width
- };
- is_dragging.set(Some(thumb_offset));
- }
- }
- }
- });
- 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()));
- }
- }
- });
- let drag_state = self.scrollbar_drag_state.clone();
- let view_id = self.parent_id;
- let kind = self.kind;
- cx.on_mouse_event(move |event: &MouseMoveEvent, _, cx| {
- if let Some(drag_state) = drag_state.get().filter(|_| event.dragging()) {
- let scroll = scroll.0.borrow();
- if let Some(item_size) = scroll.last_item_size {
- match kind {
- ScrollbarKind::Horizontal => {
- let max_offset = item_size.contents.width;
- let percentage = (event.position.x - bounds.origin.x)
- / bounds.size.width
- - drag_state;
-
- let percentage = percentage.min(1. - thumb_percentage_size);
- scroll.base_handle.set_offset(point(
- -max_offset * percentage,
- scroll.base_handle.offset().y,
- ));
- }
- ScrollbarKind::Vertical => {
- let max_offset = item_size.contents.height;
- let percentage = (event.position.y - bounds.origin.y)
- / bounds.size.height
- - drag_state;
-
- let percentage = percentage.min(1. - thumb_percentage_size);
- scroll.base_handle.set_offset(point(
- scroll.base_handle.offset().x,
- -max_offset * percentage,
- ));
- }
- };
-
- cx.notify(view_id);
- }
- } else {
- drag_state.set(None);
- }
- });
- let is_dragging = self.scrollbar_drag_state.clone();
- cx.on_mouse_event(move |_event: &MouseUpEvent, phase, cx| {
- if phase.bubble() {
- is_dragging.set(None);
- cx.notify(view_id);
- }
- });
- })
- }
-}
-
-impl IntoElement for ProjectPanelScrollbar {
- type Element = Self;
-
- fn into_element(self) -> Self::Element {
- self
- }
-}
@@ -34,6 +34,8 @@ use task::HideStrategy;
use task::RevealStrategy;
use task::SpawnInTerminal;
use terminal_view::terminal_panel::TerminalPanel;
+use ui::Scrollbar;
+use ui::ScrollbarState;
use ui::Section;
use ui::{prelude::*, IconButtonShape, List, ListItem, ListSeparator, Modal, ModalHeader, Tooltip};
use util::ResultExt;
@@ -301,13 +303,19 @@ impl gpui::Render for ProjectPicker {
}
}
enum Mode {
- Default,
+ Default(ScrollbarState),
ViewServerOptions(usize, SshConnection),
EditNickname(EditNicknameState),
ProjectPicker(View<ProjectPicker>),
CreateDevServer(CreateDevServer),
}
+impl Mode {
+ fn default_mode() -> Self {
+ let handle = ScrollHandle::new();
+ Self::Default(ScrollbarState::new(handle))
+ }
+}
impl DevServerProjects {
pub fn register(workspace: &mut Workspace, _: &mut ViewContext<Workspace>) {
workspace.register_action(|workspace, _: &OpenRemote, cx| {
@@ -338,7 +346,7 @@ impl DevServerProjects {
});
Self {
- mode: Mode::Default,
+ mode: Mode::default_mode(),
focus_handle,
scroll_handle: ScrollHandle::new(),
dev_server_store,
@@ -349,13 +357,13 @@ impl DevServerProjects {
}
fn next_item(&mut self, _: &menu::SelectNext, cx: &mut ViewContext<Self>) {
- if !matches!(self.mode, Mode::Default | Mode::ViewServerOptions(_, _)) {
+ if !matches!(self.mode, Mode::Default(_) | Mode::ViewServerOptions(_, _)) {
return;
}
self.selectable_items.next(cx);
}
fn prev_item(&mut self, _: &menu::SelectPrev, cx: &mut ViewContext<Self>) {
- if !matches!(self.mode, Mode::Default | Mode::ViewServerOptions(_, _)) {
+ if !matches!(self.mode, Mode::Default(_) | Mode::ViewServerOptions(_, _)) {
return;
}
self.selectable_items.prev(cx);
@@ -431,7 +439,7 @@ impl DevServerProjects {
});
this.add_ssh_server(connection_options, cx);
- this.mode = Mode::Default;
+ this.mode = Mode::default_mode();
this.selectable_items.reset_selection();
cx.notify()
})
@@ -535,7 +543,7 @@ impl DevServerProjects {
fn confirm(&mut self, _: &menu::Confirm, cx: &mut ViewContext<Self>) {
match &self.mode {
- Mode::Default | Mode::ViewServerOptions(_, _) => {
+ Mode::Default(_) | Mode::ViewServerOptions(_, _) => {
let items = std::mem::take(&mut self.selectable_items);
items.confirm(self, cx);
self.selectable_items = items;
@@ -566,7 +574,7 @@ impl DevServerProjects {
}
}
});
- self.mode = Mode::Default;
+ self.mode = Mode::default_mode();
self.selectable_items.reset_selection();
self.focus_handle.focus(cx);
}
@@ -575,14 +583,14 @@ impl DevServerProjects {
fn cancel(&mut self, _: &menu::Cancel, cx: &mut ViewContext<Self>) {
match &self.mode {
- Mode::Default => cx.emit(DismissEvent),
+ Mode::Default(_) => cx.emit(DismissEvent),
Mode::CreateDevServer(state) if state.ssh_prompt.is_some() => {
self.mode = Mode::CreateDevServer(CreateDevServer::new(cx));
self.selectable_items.reset_selection();
cx.notify();
}
_ => {
- self.mode = Mode::Default;
+ self.mode = Mode::default_mode();
self.selectable_items.reset_selection();
self.focus_handle(cx).focus(cx);
cx.notify();
@@ -1012,7 +1020,7 @@ impl DevServerProjects {
move |cx| {
dev_servers.update(cx, |this, cx| {
this.delete_ssh_server(index, cx);
- this.mode = Mode::Default;
+ this.mode = Mode::default_mode();
cx.notify();
})
},
@@ -1055,7 +1063,7 @@ impl DevServerProjects {
.child({
self.selectable_items.add_item(Box::new({
move |this, cx| {
- this.mode = Mode::Default;
+ this.mode = Mode::default_mode();
cx.notify();
}
}));
@@ -1067,7 +1075,7 @@ impl DevServerProjects {
.start_slot(Icon::new(IconName::ArrowLeft).color(Color::Muted))
.child(Label::new("Go Back"))
.on_click(cx.listener(|this, _, cx| {
- this.mode = Mode::Default;
+ this.mode = Mode::default_mode();
cx.notify()
}))
}),
@@ -1099,7 +1107,12 @@ impl DevServerProjects {
.child(h_flex().p_2().child(state.editor.clone()))
}
- fn render_default(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
+ fn render_default(
+ &mut self,
+ scroll_state: ScrollbarState,
+ cx: &mut ViewContext<Self>,
+ ) -> impl IntoElement {
+ let scroll_state = scroll_state.parent_view(cx.view());
let dev_servers = self.dev_server_store.read(cx).dev_servers();
let ssh_connections = SshSettings::get_global(cx)
.ssh_connections()
@@ -1124,27 +1137,37 @@ impl DevServerProjects {
cx.notify();
}));
+ let ui::ScrollableHandle::NonUniform(scroll_handle) = scroll_state.scroll_handle() else {
+ unreachable!()
+ };
+
let mut modal_section = v_flex()
.id("ssh-server-list")
.overflow_y_scroll()
+ .track_scroll(&scroll_handle)
.size_full()
.child(connect_button)
.child(
- List::new()
- .empty_message(
- v_flex()
- .child(ListSeparator)
- .child(div().px_3().child(
- Label::new("No dev servers registered yet.").color(Color::Muted),
- ))
- .into_any_element(),
- )
- .children(ssh_connections.iter().cloned().enumerate().map(
- |(ix, connection)| {
- self.render_ssh_connection(ix, connection, cx)
- .into_any_element()
- },
- )),
+ h_flex().child(
+ List::new()
+ .empty_message(
+ v_flex()
+ .child(ListSeparator)
+ .child(
+ div().px_3().child(
+ Label::new("No dev servers registered yet.")
+ .color(Color::Muted),
+ ),
+ )
+ .into_any_element(),
+ )
+ .children(ssh_connections.iter().cloned().enumerate().map(
+ |(ix, connection)| {
+ self.render_ssh_connection(ix, connection, cx)
+ .into_any_element()
+ },
+ )),
+ ),
)
.into_any_element();
@@ -1162,26 +1185,37 @@ impl DevServerProjects {
)
.section(
Section::new().padded(false).child(
- v_flex()
+ h_flex()
.min_h(rems(20.))
- .flex_1()
.size_full()
- .child(ListSeparator)
.child(
- canvas(
- |bounds, cx| {
- modal_section.prepaint_as_root(
- bounds.origin,
- bounds.size.into(),
- cx,
- );
- modal_section
- },
- |_, mut modal_section, cx| {
- modal_section.paint(cx);
- },
- )
- .size_full(),
+ v_flex().size_full().child(ListSeparator).child(
+ canvas(
+ |bounds, cx| {
+ modal_section.prepaint_as_root(
+ bounds.origin,
+ bounds.size.into(),
+ cx,
+ );
+ modal_section
+ },
+ |_, mut modal_section, cx| {
+ modal_section.paint(cx);
+ },
+ )
+ .size_full(),
+ ),
+ )
+ .child(
+ div()
+ .occlude()
+ .h_full()
+ .absolute()
+ .right_1()
+ .top_1()
+ .bottom_1()
+ .w(px(12.))
+ .children(Scrollbar::vertical(scroll_state)),
),
),
)
@@ -1217,13 +1251,13 @@ impl Render for DevServerProjects {
this.focus_handle(cx).focus(cx);
}))
.on_mouse_down_out(cx.listener(|this, _, cx| {
- if matches!(this.mode, Mode::Default) {
+ if matches!(this.mode, Mode::Default(_)) {
cx.emit(DismissEvent)
}
}))
.w(rems(34.))
.child(match &self.mode {
- Mode::Default => self.render_default(cx).into_any_element(),
+ Mode::Default(state) => self.render_default(state.clone(), cx).into_any_element(),
Mode::ViewServerOptions(index, connection) => self
.render_view_options(*index, connection.clone(), cx)
.into_any_element(),
@@ -18,6 +18,7 @@ mod popover;
mod popover_menu;
mod radio;
mod right_click_menu;
+mod scrollbar;
mod settings_container;
mod settings_group;
mod stack;
@@ -49,6 +50,7 @@ pub use popover::*;
pub use popover_menu::*;
pub use radio::*;
pub use right_click_menu::*;
+pub use scrollbar::*;
pub use settings_container::*;
pub use settings_group::*;
pub use stack::*;
@@ -0,0 +1,396 @@
+#![allow(missing_docs)]
+use std::{cell::Cell, ops::Range, rc::Rc};
+
+use crate::{prelude::*, px, relative, IntoElement};
+use gpui::{
+ point, quad, Along, Axis as ScrollbarAxis, Bounds, ContentMask, Corners, Edges, Element,
+ ElementId, Entity, EntityId, GlobalElementId, Hitbox, Hsla, LayoutId, MouseDownEvent,
+ MouseMoveEvent, MouseUpEvent, Pixels, Point, ScrollHandle, ScrollWheelEvent, Size, Style,
+ UniformListScrollHandle, View, WindowContext,
+};
+
+pub struct Scrollbar {
+ thumb: Range<f32>,
+ state: ScrollbarState,
+ kind: ScrollbarAxis,
+}
+
+/// Wrapper around scroll handles.
+#[derive(Clone)]
+pub enum ScrollableHandle {
+ Uniform(UniformListScrollHandle),
+ NonUniform(ScrollHandle),
+}
+
+#[derive(Debug)]
+struct ContentSize {
+ size: Size<Pixels>,
+ scroll_adjustment: Option<Point<Pixels>>,
+}
+
+impl ScrollableHandle {
+ fn content_size(&self) -> Option<ContentSize> {
+ match self {
+ ScrollableHandle::Uniform(handle) => Some(ContentSize {
+ size: handle.0.borrow().last_item_size.map(|size| size.contents)?,
+ scroll_adjustment: None,
+ }),
+ ScrollableHandle::NonUniform(handle) => {
+ let last_children_index = handle.children_count().checked_sub(1)?;
+ // todo: PO: this is slightly wrong for horizontal scrollbar, as the last item is not necessarily the longest one.
+ let mut last_item = handle.bounds_for_item(last_children_index)?;
+ last_item.size.height += last_item.origin.y;
+ last_item.size.width += last_item.origin.x;
+ let mut scroll_adjustment = None;
+ if last_children_index != 0 {
+ let first_item = handle.bounds_for_item(0)?;
+
+ scroll_adjustment = Some(first_item.origin);
+ last_item.size.height -= first_item.origin.y;
+ last_item.size.width -= first_item.origin.x;
+ }
+ Some(ContentSize {
+ size: last_item.size,
+ scroll_adjustment,
+ })
+ }
+ }
+ }
+ fn set_offset(&self, point: Point<Pixels>) {
+ let base_handle = match self {
+ ScrollableHandle::Uniform(handle) => &handle.0.borrow().base_handle,
+ ScrollableHandle::NonUniform(handle) => &handle,
+ };
+ base_handle.set_offset(point);
+ }
+ fn offset(&self) -> Point<Pixels> {
+ let base_handle = match self {
+ ScrollableHandle::Uniform(handle) => &handle.0.borrow().base_handle,
+ ScrollableHandle::NonUniform(handle) => &handle,
+ };
+ base_handle.offset()
+ }
+ fn viewport(&self) -> Bounds<Pixels> {
+ let base_handle = match self {
+ ScrollableHandle::Uniform(handle) => &handle.0.borrow().base_handle,
+ ScrollableHandle::NonUniform(handle) => &handle,
+ };
+ base_handle.bounds()
+ }
+}
+impl From<UniformListScrollHandle> for ScrollableHandle {
+ fn from(value: UniformListScrollHandle) -> Self {
+ Self::Uniform(value)
+ }
+}
+
+impl From<ScrollHandle> for ScrollableHandle {
+ fn from(value: ScrollHandle) -> Self {
+ Self::NonUniform(value)
+ }
+}
+
+/// A scrollbar state that should be persisted across frames.
+#[derive(Clone)]
+pub struct ScrollbarState {
+ // If Some(), there's an active drag, offset by percentage from the origin of a thumb.
+ drag: Rc<Cell<Option<f32>>>,
+ parent_id: Option<EntityId>,
+ scroll_handle: ScrollableHandle,
+}
+
+impl ScrollbarState {
+ pub fn new(scroll: impl Into<ScrollableHandle>) -> Self {
+ Self {
+ drag: Default::default(),
+ parent_id: None,
+ scroll_handle: scroll.into(),
+ }
+ }
+
+ /// Set a parent view which should be notified whenever this Scrollbar gets a scroll event.
+ pub fn parent_view<V: 'static>(mut self, v: &View<V>) -> Self {
+ self.parent_id = Some(v.entity_id());
+ self
+ }
+
+ pub fn scroll_handle(&self) -> ScrollableHandle {
+ self.scroll_handle.clone()
+ }
+
+ pub fn is_dragging(&self) -> bool {
+ self.drag.get().is_some()
+ }
+
+ fn thumb_range(&self, axis: ScrollbarAxis) -> Option<Range<f32>> {
+ const MINIMUM_SCROLLBAR_PERCENTAGE_SIZE: f32 = 0.005;
+ let ContentSize {
+ size: main_dimension_size,
+ scroll_adjustment,
+ } = self.scroll_handle.content_size()?;
+ let main_dimension_size = main_dimension_size.along(axis).0;
+ let mut current_offset = self.scroll_handle.offset().along(axis).min(px(0.)).abs().0;
+ if let Some(adjustment) = scroll_adjustment.and_then(|adjustment| {
+ let adjust = adjustment.along(axis).0;
+ if adjust < 0.0 {
+ Some(adjust)
+ } else {
+ None
+ }
+ }) {
+ current_offset -= adjustment;
+ }
+ let mut percentage = current_offset / main_dimension_size;
+ let viewport_size = self.scroll_handle.viewport().size;
+
+ let end_offset = (current_offset + viewport_size.along(axis).0) / main_dimension_size;
+ // 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 + MINIMUM_SCROLLBAR_PERCENTAGE_SIZE > 1.0 || end_offset > main_dimension_size
+ {
+ return None;
+ }
+ if main_dimension_size < viewport_size.along(axis).0 {
+ return None;
+ }
+ let end_offset = end_offset.clamp(percentage + MINIMUM_SCROLLBAR_PERCENTAGE_SIZE, 1.);
+ Some(percentage..end_offset)
+ }
+}
+
+impl Scrollbar {
+ pub fn vertical(state: ScrollbarState) -> Option<Self> {
+ Self::new(state, ScrollbarAxis::Vertical)
+ }
+
+ pub fn horizontal(state: ScrollbarState) -> Option<Self> {
+ Self::new(state, ScrollbarAxis::Horizontal)
+ }
+ fn new(state: ScrollbarState, kind: ScrollbarAxis) -> Option<Self> {
+ let thumb = state.thumb_range(kind)?;
+ Some(Self { thumb, state, kind })
+ }
+}
+
+impl Element for Scrollbar {
+ type RequestLayoutState = ();
+
+ type PrepaintState = Hitbox;
+
+ fn id(&self) -> Option<ElementId> {
+ None
+ }
+
+ fn request_layout(
+ &mut self,
+ _id: Option<&GlobalElementId>,
+ cx: &mut WindowContext,
+ ) -> (LayoutId, Self::RequestLayoutState) {
+ let mut style = Style::default();
+ style.flex_grow = 1.;
+ style.flex_shrink = 1.;
+
+ if self.kind == ScrollbarAxis::Vertical {
+ style.size.width = px(12.).into();
+ style.size.height = relative(1.).into();
+ } else {
+ style.size.width = relative(1.).into();
+ style.size.height = px(12.).into();
+ }
+
+ (cx.request_layout(style, None), ())
+ }
+
+ fn prepaint(
+ &mut self,
+ _id: Option<&GlobalElementId>,
+ bounds: Bounds<Pixels>,
+ _request_layout: &mut Self::RequestLayoutState,
+ cx: &mut WindowContext,
+ ) -> Self::PrepaintState {
+ cx.with_content_mask(Some(ContentMask { bounds }), |cx| {
+ cx.insert_hitbox(bounds, false)
+ })
+ }
+
+ fn paint(
+ &mut self,
+ _id: Option<&GlobalElementId>,
+ bounds: Bounds<Pixels>,
+ _request_layout: &mut Self::RequestLayoutState,
+ _prepaint: &mut Self::PrepaintState,
+ cx: &mut WindowContext,
+ ) {
+ cx.with_content_mask(Some(ContentMask { bounds }), |cx| {
+ let colors = cx.theme().colors();
+ let thumb_background = colors.scrollbar_thumb_background;
+ let is_vertical = self.kind == ScrollbarAxis::Vertical;
+ let extra_padding = px(5.0);
+ let padded_bounds = if is_vertical {
+ Bounds::from_corners(
+ bounds.origin + point(Pixels::ZERO, extra_padding),
+ bounds.lower_right() - point(Pixels::ZERO, extra_padding * 3),
+ )
+ } else {
+ Bounds::from_corners(
+ bounds.origin + point(extra_padding, Pixels::ZERO),
+ bounds.lower_right() - point(extra_padding * 3, Pixels::ZERO),
+ )
+ };
+
+ let mut thumb_bounds = if is_vertical {
+ let thumb_offset = self.thumb.start * padded_bounds.size.height;
+ let thumb_end = self.thumb.end * padded_bounds.size.height;
+ let thumb_upper_left = point(
+ padded_bounds.origin.x,
+ padded_bounds.origin.y + thumb_offset,
+ );
+ let thumb_lower_right = point(
+ padded_bounds.origin.x + padded_bounds.size.width,
+ padded_bounds.origin.y + thumb_end,
+ );
+ Bounds::from_corners(thumb_upper_left, thumb_lower_right)
+ } else {
+ let thumb_offset = self.thumb.start * padded_bounds.size.width;
+ let thumb_end = self.thumb.end * padded_bounds.size.width;
+ let thumb_upper_left = point(
+ padded_bounds.origin.x + thumb_offset,
+ padded_bounds.origin.y,
+ );
+ let thumb_lower_right = point(
+ padded_bounds.origin.x + thumb_end,
+ padded_bounds.origin.y + padded_bounds.size.height,
+ );
+ Bounds::from_corners(thumb_upper_left, thumb_lower_right)
+ };
+ let corners = if is_vertical {
+ thumb_bounds.size.width /= 1.5;
+ Corners::all(thumb_bounds.size.width / 2.0)
+ } else {
+ thumb_bounds.size.height /= 1.5;
+ Corners::all(thumb_bounds.size.height / 2.0)
+ };
+ cx.paint_quad(quad(
+ thumb_bounds,
+ corners,
+ thumb_background,
+ Edges::default(),
+ Hsla::transparent_black(),
+ ));
+
+ let scroll = self.state.scroll_handle.clone();
+ let kind = self.kind;
+ let thumb_percentage_size = self.thumb.end - self.thumb.start;
+
+ cx.on_mouse_event({
+ let scroll = scroll.clone();
+ let state = self.state.clone();
+ let axis = self.kind;
+ move |event: &MouseDownEvent, phase, _cx| {
+ if !(phase.bubble() && bounds.contains(&event.position)) {
+ return;
+ }
+
+ if thumb_bounds.contains(&event.position) {
+ let thumb_offset = (event.position.along(axis)
+ - thumb_bounds.origin.along(axis))
+ / bounds.size.along(axis);
+ state.drag.set(Some(thumb_offset));
+ } else if let Some(ContentSize {
+ size: item_size, ..
+ }) = scroll.content_size()
+ {
+ match kind {
+ ScrollbarAxis::Horizontal => {
+ let percentage =
+ (event.position.x - bounds.origin.x) / bounds.size.width;
+ let max_offset = item_size.width;
+ let percentage = percentage.min(1. - thumb_percentage_size);
+ scroll
+ .set_offset(point(-max_offset * percentage, scroll.offset().y));
+ }
+ ScrollbarAxis::Vertical => {
+ let percentage =
+ (event.position.y - bounds.origin.y) / bounds.size.height;
+ let max_offset = item_size.height;
+ let percentage = percentage.min(1. - thumb_percentage_size);
+ scroll
+ .set_offset(point(scroll.offset().x, -max_offset * percentage));
+ }
+ }
+ }
+ }
+ });
+ cx.on_mouse_event({
+ let scroll = scroll.clone();
+ move |event: &ScrollWheelEvent, phase, cx| {
+ if phase.bubble() && bounds.contains(&event.position) {
+ let current_offset = scroll.offset();
+ scroll
+ .set_offset(current_offset + event.delta.pixel_delta(cx.line_height()));
+ }
+ }
+ });
+ let state = self.state.clone();
+ let kind = self.kind;
+ cx.on_mouse_event(move |event: &MouseMoveEvent, _, cx| {
+ if let Some(drag_state) = state.drag.get().filter(|_| event.dragging()) {
+ if let Some(ContentSize {
+ size: item_size, ..
+ }) = scroll.content_size()
+ {
+ match kind {
+ ScrollbarAxis::Horizontal => {
+ let max_offset = item_size.width;
+ let percentage = (event.position.x - bounds.origin.x)
+ / bounds.size.width
+ - drag_state;
+
+ let percentage = percentage.min(1. - thumb_percentage_size);
+ scroll
+ .set_offset(point(-max_offset * percentage, scroll.offset().y));
+ }
+ ScrollbarAxis::Vertical => {
+ let max_offset = item_size.height;
+ let percentage = (event.position.y - bounds.origin.y)
+ / bounds.size.height
+ - drag_state;
+
+ let percentage = percentage.min(1. - thumb_percentage_size);
+ scroll
+ .set_offset(point(scroll.offset().x, -max_offset * percentage));
+ }
+ };
+
+ if let Some(id) = state.parent_id {
+ cx.notify(id);
+ }
+ }
+ } else {
+ state.drag.set(None);
+ }
+ });
+ let state = self.state.clone();
+ cx.on_mouse_event(move |_event: &MouseUpEvent, phase, cx| {
+ if phase.bubble() {
+ state.drag.take();
+ if let Some(id) = state.parent_id {
+ cx.notify(id);
+ }
+ }
+ });
+ })
+ }
+}
+
+impl IntoElement for Scrollbar {
+ type Element = Self;
+
+ fn into_element(self) -> Self::Element {
+ self
+ }
+}