diff --git a/crates/gpui/src/elements/div.rs b/crates/gpui/src/elements/div.rs index d24c5d25da9881c16ef547234c521f99c86e761d..6c3b577e4c5770fc251541d07c218d075da08d9b 100644 --- a/crates/gpui/src/elements/div.rs +++ b/crates/gpui/src/elements/div.rs @@ -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() + } } diff --git a/crates/project_panel/src/project_panel.rs b/crates/project_panel/src/project_panel.rs index 08a0ef4b4060b41b9a54b8d4895f2e546d64963e..121238b7d3dd028b0611ed90ea3fe52c82f183be 100644 --- a/crates/project_panel/src/project_panel.rs +++ b/crates/project_panel/src/project_panel.rs @@ -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, fs: Arc, - scroll_handle: UniformListScrollHandle, focus_handle: FocusHandle, + scroll_handle: UniformListScrollHandle, visible_entries: Vec<(WorktreeId, Vec, OnceCell>>)>, /// 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, pending_serialization: Task>, show_scrollbar: bool, - vertical_scrollbar_drag_thumb_offset: Rc>>, - horizontal_scrollbar_drag_thumb_offset: Rc>>, + vertical_scrollbar_state: ScrollbarState, + horizontal_scrollbar_state: ScrollbarState, hide_scrollbar_task: Option>, max_width_item_index: Option, } @@ -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) -> Option> { - 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) -> Option> { - 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(), )) }), ) diff --git a/crates/project_panel/src/scrollbar.rs b/crates/project_panel/src/scrollbar.rs deleted file mode 100644 index cb7b15386c20700771a26879e2ebe97153b73c71..0000000000000000000000000000000000000000 --- a/crates/project_panel/src/scrollbar.rs +++ /dev/null @@ -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, - scroll: UniformListScrollHandle, - // If Some(), there's an active drag, offset by percentage from the top of thumb. - scrollbar_drag_state: Rc>>, - kind: ScrollbarKind, - parent_id: EntityId, -} - -impl ProjectPanelScrollbar { - pub(crate) fn vertical( - thumb: Range, - scroll: UniformListScrollHandle, - scrollbar_drag_state: Rc>>, - parent_id: EntityId, - ) -> Self { - Self { - thumb, - scroll, - scrollbar_drag_state, - kind: ScrollbarKind::Vertical, - parent_id, - } - } - - pub(crate) fn horizontal( - thumb: Range, - scroll: UniformListScrollHandle, - scrollbar_drag_state: Rc>>, - 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 { - 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, - _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, - ) { - 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 - } -} diff --git a/crates/recent_projects/src/dev_servers.rs b/crates/recent_projects/src/dev_servers.rs index 89772a1fe81f95aa6c5529b348a6dcb86d9f87c5..7c98500ea841e20c3bdd00a6142ad40e54c1bc4b 100644 --- a/crates/recent_projects/src/dev_servers.rs +++ b/crates/recent_projects/src/dev_servers.rs @@ -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), 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.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) { - 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) { - 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) { 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) { 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) -> impl IntoElement { + fn render_default( + &mut self, + scroll_state: ScrollbarState, + cx: &mut ViewContext, + ) -> 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(), diff --git a/crates/ui/src/components.rs b/crates/ui/src/components.rs index fe63b035027afbf40f0f74f5608de337daeb2548..98d103e163b45b0965e55ac880f48e589546fb14 100644 --- a/crates/ui/src/components.rs +++ b/crates/ui/src/components.rs @@ -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::*; diff --git a/crates/ui/src/components/scrollbar.rs b/crates/ui/src/components/scrollbar.rs new file mode 100644 index 0000000000000000000000000000000000000000..5f25547b432fc1430ff98264caf5fcc04341b663 --- /dev/null +++ b/crates/ui/src/components/scrollbar.rs @@ -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, + state: ScrollbarState, + kind: ScrollbarAxis, +} + +/// Wrapper around scroll handles. +#[derive(Clone)] +pub enum ScrollableHandle { + Uniform(UniformListScrollHandle), + NonUniform(ScrollHandle), +} + +#[derive(Debug)] +struct ContentSize { + size: Size, + scroll_adjustment: Option>, +} + +impl ScrollableHandle { + fn content_size(&self) -> Option { + 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) { + 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 { + let base_handle = match self { + ScrollableHandle::Uniform(handle) => &handle.0.borrow().base_handle, + ScrollableHandle::NonUniform(handle) => &handle, + }; + base_handle.offset() + } + fn viewport(&self) -> Bounds { + let base_handle = match self { + ScrollableHandle::Uniform(handle) => &handle.0.borrow().base_handle, + ScrollableHandle::NonUniform(handle) => &handle, + }; + base_handle.bounds() + } +} +impl From for ScrollableHandle { + fn from(value: UniformListScrollHandle) -> Self { + Self::Uniform(value) + } +} + +impl From 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>>, + parent_id: Option, + scroll_handle: ScrollableHandle, +} + +impl ScrollbarState { + pub fn new(scroll: impl Into) -> 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(mut self, v: &View) -> 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> { + 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::new(state, ScrollbarAxis::Vertical) + } + + pub fn horizontal(state: ScrollbarState) -> Option { + Self::new(state, ScrollbarAxis::Horizontal) + } + fn new(state: ScrollbarState, kind: ScrollbarAxis) -> Option { + let thumb = state.thumb_range(kind)?; + Some(Self { thumb, state, kind }) + } +} + +impl Element for Scrollbar { + type RequestLayoutState = (); + + type PrepaintState = Hitbox; + + fn id(&self) -> Option { + 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, + _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, + _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 + } +}