diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 00000000..1c565ebe --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "rust-analyzer.check.command": "clippy" +} diff --git a/Cargo.lock b/Cargo.lock index d37e22cb..e5809bc7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2246,6 +2246,19 @@ dependencies = [ "xkbcommon", ] +[[package]] +name = "gpui-app" +version = "0.1.0" +dependencies = [ + "anyhow", + "gpui", + "log", + "rust-embed", + "story", + "ui", + "workspace", +] + [[package]] name = "gpui_macros" version = "0.1.0" @@ -2898,19 +2911,6 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" -[[package]] -name = "main-app" -version = "0.1.0" -dependencies = [ - "anyhow", - "gpui", - "log", - "rust-embed", - "story", - "ui", - "workspace", -] - [[package]] name = "malloc_buf" version = "0.0.6" diff --git a/README.md b/README.md index 3b31f4b5..689bee95 100644 --- a/README.md +++ b/README.md @@ -91,7 +91,6 @@ cargo run There have a part of UI components from [Zed](https://github.com/zed-industries/zed/tree/main/crates/ui), that are under GPL v3.0 license. - workspace -- scrollbar Other UI components are under Apache License. diff --git a/crates/app/Cargo.toml b/crates/app/Cargo.toml index 3dfec390..1b7c5d42 100644 --- a/crates/app/Cargo.toml +++ b/crates/app/Cargo.toml @@ -1,5 +1,5 @@ [package] -name = "main-app" +name = "gpui-app" version = "0.1.0" edition = "2021" diff --git a/crates/app/src/story_workspace.rs b/crates/app/src/story_workspace.rs index c70eb4bf..eb98f36d 100644 --- a/crates/app/src/story_workspace.rs +++ b/crates/app/src/story_workspace.rs @@ -2,8 +2,8 @@ use gpui::*; use prelude::FluentBuilder as _; use story::{ ButtonStory, CheckboxStory, DropdownStory, ImageStory, InputStory, ListStory, PickerStory, - PopoverStory, ProgressStory, ResizableStory, StoryContainer, SwitchStory, TableStory, - TooltipStory, + PopoverStory, ProgressStory, ResizableStory, ScrollableStory, StoryContainer, SwitchStory, + TableStory, TooltipStory, }; use workspace::{dock::DockPosition, TitleBar, Workspace}; @@ -20,7 +20,6 @@ actions!(workspace, [Open, CloseWindow]); pub fn init(_app_state: Arc, cx: &mut AppContext) { cx.on_action(|_action: &Open, _cx: &mut AppContext| {}); - cx.on_action(|_action: &CloseWindow, _cx| std::process::exit(0)); Theme::init(cx); ui::init(cx); @@ -156,6 +155,15 @@ impl StoryWorkspace { ) .detach(); + StoryContainer::add_pane( + "Scrollable", + "A scrollable area with scroll bar", + ScrollableStory::view(cx).into(), + workspace.clone(), + cx, + ) + .detach(); + Self { workspace } } @@ -239,13 +247,7 @@ impl Render for StoryWorkspace { }) }) // left side - .child( - div() - .flex() - .items_center() - .on_mouse_move(|_, cx| cx.stop_propagation()) - .child("GPUI App"), - ) + .child(div().flex().items_center().child("GPUI App")) .child( div() .flex() diff --git a/crates/story/src/lib.rs b/crates/story/src/lib.rs index fc9a74a6..d83e1b13 100644 --- a/crates/story/src/lib.rs +++ b/crates/story/src/lib.rs @@ -8,6 +8,7 @@ mod picker_story; mod popover_story; mod progress_story; mod resizable_story; +mod scrollable_story; mod switch_story; mod table_story; mod tooltip_story; @@ -22,6 +23,7 @@ pub use picker_story::PickerStory; pub use popover_story::PopoverStory; pub use progress_story::ProgressStory; pub use resizable_story::ResizableStory; +pub use scrollable_story::ScrollableStory; pub use switch_story::SwitchStory; pub use table_story::TableStory; pub use tooltip_story::TooltipStory; diff --git a/crates/story/src/picker_story.rs b/crates/story/src/picker_story.rs index c9431bd1..c50bfa31 100644 --- a/crates/story/src/picker_story.rs +++ b/crates/story/src/picker_story.rs @@ -1,9 +1,9 @@ use std::sync::Arc; use gpui::{ - deferred, div, prelude::FluentBuilder as _, px, FocusHandle, FocusableView, - InteractiveElement as _, IntoElement, ParentElement, Render, Styled, View, ViewContext, - VisualContext as _, WeakView, WindowContext, + div, prelude::FluentBuilder as _, px, FocusHandle, FocusableView, InteractiveElement as _, + IntoElement, ParentElement, Render, Styled, View, ViewContext, VisualContext as _, WeakView, + WindowContext, }; use ui::{ @@ -201,7 +201,7 @@ impl Render for PickerStory { ) }) .when(self.open, |this| { - this.child(deferred( + this.child( div().absolute().size_full().top_0().left_0().child( v_flex().flex().flex_col().items_center().child( v_flex() @@ -215,7 +215,7 @@ impl Render for PickerStory { })), ), ), - )) + ) }) } } diff --git a/crates/story/src/scrollable_story.rs b/crates/story/src/scrollable_story.rs new file mode 100644 index 00000000..d486229f --- /dev/null +++ b/crates/story/src/scrollable_story.rs @@ -0,0 +1,166 @@ +use std::cell::Cell; +use std::rc::Rc; + +use gpui::{ + canvas, deferred, div, px, InteractiveElement, ParentElement, Pixels, Render, ScrollHandle, + StatefulInteractiveElement as _, Styled, View, ViewContext, VisualContext, WindowContext, +}; +use ui::button::Button; +use ui::divider::Divider; +use ui::scroll::{Scrollbar, ScrollbarAxis, ScrollbarState}; +use ui::theme::ActiveTheme; +use ui::{h_flex, v_flex, Clickable}; + +pub struct ScrollableStory { + scroll_handle: ScrollHandle, + scroll_size: gpui::Size, + scroll_state: Rc>, + items: Vec, + test_width: Pixels, + axis: ScrollbarAxis, +} + +impl ScrollableStory { + fn new() -> Self { + Self { + scroll_handle: ScrollHandle::new(), + scroll_state: Rc::new(Cell::new(ScrollbarState::default())), + scroll_size: gpui::Size::default(), + items: (0..500).map(|i| format!("Item {}", i)).collect::>(), + test_width: px(3000.), + axis: ScrollbarAxis::Both, + } + } + + pub fn view(cx: &mut WindowContext) -> View { + cx.new_view(|_| Self::new()) + } + + pub fn change_test_cases(&mut self, n: usize, cx: &mut ViewContext) { + if n == 0 { + self.items = (0..500).map(|i| format!("Item {}", i)).collect::>(); + self.test_width = px(3000.); + } else if n == 1 { + self.items = (0..100).map(|i| format!("Item {}", i)).collect::>(); + self.test_width = px(10000.); + } else { + self.items = (0..500).map(|i| format!("Item {}", i)).collect::>(); + self.test_width = px(10000.); + } + self.scroll_state.set(ScrollbarState::default()); + cx.notify(); + } + + pub fn change_axis(&mut self, axis: ScrollbarAxis, cx: &mut ViewContext) { + self.axis = axis; + cx.notify(); + } +} + +impl Render for ScrollableStory { + fn render(&mut self, cx: &mut gpui::ViewContext) -> impl gpui::IntoElement { + let view = cx.view().clone(); + + v_flex() + .gap_4() + .child( + h_flex() + .gap_1() + .child( + Button::new("test-0", cx) + .label("Size 0") + .on_click(cx.listener(|view, _, cx| { + view.change_test_cases(0, cx); + })), + ) + .child( + Button::new("test-1", cx) + .label("Size 1") + .on_click(cx.listener(|view, _, cx| { + view.change_test_cases(1, cx); + })), + ) + .child( + Button::new("test-2", cx) + .label("Size 2") + .on_click(cx.listener(|view, _, cx| { + view.change_test_cases(2, cx); + })), + ) + .child(Divider::vertical()) + .child( + Button::new("test-axis-both", cx) + .label("Both Scrollbar") + .on_click( + cx.listener(|view, _, cx| { + view.change_axis(ScrollbarAxis::Both, cx) + }), + ), + ) + .child( + Button::new("test-axis-vertical", cx) + .label("Vertical") + .on_click(cx.listener(|view, _, cx| { + view.change_axis(ScrollbarAxis::Vertical, cx) + })), + ) + .child( + Button::new("test-axis-both", cx) + .label("Horizontal") + .on_click(cx.listener(|view, _, cx| { + view.change_axis(ScrollbarAxis::Horizontal, cx) + })), + ), + ) + .child( + div() + .w_full() + .border_1() + .border_color(cx.theme().border) + .child( + div() + .relative() + .w_full() + .h(px(400.)) + .child(deferred( + Scrollbar::both( + view, + self.scroll_state.clone(), + self.scroll_handle.clone(), + self.scroll_size, + ) + .axis(self.axis), + )) + .child( + div() + .id("scroll-story") + .overflow_scroll() + .p_4() + .size_full() + .track_scroll(&self.scroll_handle) + .child( + v_flex() + .gap_1() + .w(self.test_width) + .children(self.items.iter().map(|s| { + div().bg(cx.theme().card).child(s.clone()) + })) + .child({ + let view = cx.view().clone(); + canvas( + move |bounds, cx| { + view.update(cx, |r, _| { + r.scroll_size = bounds.size + }) + }, + |_, _, _| {}, + ) + .absolute() + .size_full() + }), + ), + ), + ), + ) + } +} diff --git a/crates/ui/src/lib.rs b/crates/ui/src/lib.rs index 965b59fe..9b3f37b5 100644 --- a/crates/ui/src/lib.rs +++ b/crates/ui/src/lib.rs @@ -5,8 +5,6 @@ mod event; mod focusable; mod icon; -mod scroll; -mod scrollbar; mod selectable; mod stack; mod styled_ext; @@ -26,6 +24,7 @@ pub mod prelude; pub mod progress; pub mod radio; pub mod resizable; +pub mod scroll; pub mod switch; pub mod tab; pub mod table; diff --git a/crates/ui/src/list/list.rs b/crates/ui/src/list/list.rs index f3a7cf9d..dc6afb19 100644 --- a/crates/ui/src/list/list.rs +++ b/crates/ui/src/list/list.rs @@ -1,16 +1,17 @@ -use std::{cell::Cell, rc::Rc, time::Duration}; +use std::{cell::Cell, rc::Rc}; use gpui::prelude::FluentBuilder as _; use crate::input::{TextEvent, TextInput}; +use crate::scroll::ScrollbarState; use crate::theme::{ActiveTheme, Colorize as _}; -use crate::{scrollbar::Scrollbar, v_flex}; +use crate::{scroll::Scrollbar, v_flex}; use crate::{Icon, IconName}; use gpui::{ - actions, div, px, uniform_list, AppContext, FocusHandle, FocusableView, + actions, deferred, div, px, uniform_list, AppContext, FocusHandle, FocusableView, InteractiveElement as _, IntoElement, KeyBinding, Length, ListSizingBehavior, MouseButton, - ParentElement as _, Render, StatefulInteractiveElement as _, Styled as _, Task, - UniformListScrollHandle, View, ViewContext, VisualContext as _, + ParentElement as _, Render, Styled as _, UniformListScrollHandle, View, ViewContext, + VisualContext as _, }; actions!(list, [Cancel, Confirm, SelectPrev, SelectNext]); @@ -53,9 +54,7 @@ pub struct List { enable_scrollbar: bool, vertical_scroll_handle: UniformListScrollHandle, - scrollbar_drag_state: Rc>>, - show_scrollbar: bool, - hide_scrollbar_task: Option>, + scrollbar_state: Rc>, selected_index: Option, } @@ -81,9 +80,7 @@ where query_input: Some(query_input), selected_index: None, vertical_scroll_handle: UniformListScrollHandle::new(), - scrollbar_drag_state: Rc::new(Cell::new(None)), - show_scrollbar: false, - hide_scrollbar_task: None, + scrollbar_state: Rc::new(Cell::new(ScrollbarState::new())), max_height: None, enable_scrollbar: true, } @@ -120,55 +117,16 @@ where if !self.enable_scrollbar { return None; } - if !self.show_scrollbar { - return None; - } - Scrollbar::new( - cx.view().clone(), - self.vertical_scroll_handle.clone(), - self.scrollbar_drag_state.clone(), - self.delegate.items_count(), - true, + Some( + deferred(Scrollbar::uniform_scroll( + cx.view().clone(), + self.scrollbar_state.clone(), + self.vertical_scroll_handle.clone(), + self.delegate.items_count(), + )) + .with_priority(2), ) - .map(|bar| { - div() - .occlude() - .absolute() - .h_full() - .left_auto() - .top_0() - .right_0() - .w(px(bar.width())) - .bottom_0() - .child(bar) - }) - } - - fn hide_scrollbar(&mut self, cx: &mut ViewContext) { - self.show_scrollbar = false; - self.hide_scrollbar_task = Some(cx.spawn(|this, mut cx| async move { - cx.background_executor().timer(Duration::from_secs(1)).await; - this.update(&mut cx, |this, cx| { - this.show_scrollbar = false; - cx.notify(); - }) - .ok(); - })) - } - - fn on_hover_to_autohide_scrollbar(&mut self, hovered: &bool, cx: &mut ViewContext) { - if !self.enable_scrollbar { - return; - } - - if *hovered { - self.show_scrollbar = true; - self.hide_scrollbar_task.take(); - cx.notify(); - } else if !self.focus_handle.is_focused(cx) { - self.hide_scrollbar(cx); - } } fn scroll_to_selected_item(&mut self, _cx: &mut ViewContext) { @@ -262,12 +220,12 @@ where .id("list") .track_focus(&self.focus_handle) .size_full() + .relative() .overflow_hidden() .on_action(cx.listener(Self::action_cancel)) .on_action(cx.listener(Self::action_confirm)) .on_action(cx.listener(Self::action_select_next)) .on_action(cx.listener(Self::action_select_prev)) - .on_hover(cx.listener(Self::on_hover_to_autohide_scrollbar)) .when_some(self.query_input.clone(), |this, input| { this.child( div() @@ -280,9 +238,11 @@ where .child( v_flex() .flex_grow() + .relative() .min_h(px(100.)) .when_some(self.max_height, |this, h| this.max_h(h)) .overflow_hidden() + .children(self.render_scrollbar(cx)) .child( uniform_list(view, "uniform-list", items_count, { move |list, visible_range, cx| { @@ -315,8 +275,7 @@ where .with_sizing_behavior(sizing_behavior) .track_scroll(vertical_scroll_handle) .into_any_element(), - ) - .children(self.render_scrollbar(cx)), + ), ) } } diff --git a/crates/ui/src/scroll/mod.rs b/crates/ui/src/scroll/mod.rs index d34b7deb..453b191d 100644 --- a/crates/ui/src/scroll/mod.rs +++ b/crates/ui/src/scroll/mod.rs @@ -1,3 +1,5 @@ mod scrollable; +mod scrollbar; pub use scrollable::*; +pub use scrollbar::*; diff --git a/crates/ui/src/scroll/scrollable.rs b/crates/ui/src/scroll/scrollable.rs index 96f240ad..5604603d 100644 --- a/crates/ui/src/scroll/scrollable.rs +++ b/crates/ui/src/scroll/scrollable.rs @@ -7,7 +7,7 @@ use gpui::{ /// The scroll axis direction. #[allow(dead_code)] #[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum ScrollAxis { +pub enum ScrollableAxis { /// Horizontal scroll. Horizontal, /// Vertical scroll. @@ -21,14 +21,18 @@ pub enum ScrollAxis { /// This is only can handle once axis scrolling. pub struct ScrollableMask { view: AnyView, - axis: ScrollAxis, + axis: ScrollableAxis, scroll_handle: ScrollHandle, debug: Option, } impl ScrollableMask { /// Create a new scrollable mask element. - pub fn new(view: impl Into, axis: ScrollAxis, scroll_handle: &ScrollHandle) -> Self { + pub fn new( + view: impl Into, + axis: ScrollableAxis, + scroll_handle: &ScrollHandle, + ) -> Self { Self { view: view.into(), scroll_handle: scroll_handle.clone(), @@ -123,7 +127,7 @@ impl Element for ScrollableMask { let scroll_handle = self.scroll_handle.clone(); let old_offset = scroll_handle.offset(); let view_id = self.view.entity_id(); - let is_horizontal = self.axis == ScrollAxis::Horizontal; + let is_horizontal = self.axis == ScrollableAxis::Horizontal; move |event: &ScrollWheelEvent, _, cx| { if bounds.contains(&mouse_position) { diff --git a/crates/ui/src/scroll/scrollbar.rs b/crates/ui/src/scroll/scrollbar.rs new file mode 100644 index 00000000..e6854194 --- /dev/null +++ b/crates/ui/src/scroll/scrollbar.rs @@ -0,0 +1,498 @@ +use std::{cell::Cell, rc::Rc}; + +use crate::theme::ActiveTheme; +use gpui::{ + fill, point, px, relative, AnyView, Bounds, ContentMask, Element, Hitbox, IntoElement, + MouseDownEvent, MouseMoveEvent, MouseUpEvent, Pixels, Point, Position, ScrollHandle, Style, + UniformListScrollHandle, +}; + +const MIN_THUMB_SIZE: f32 = 80.; +const THUMB_RADIUS: Pixels = Pixels(5.0); +const THUMB_INSET: Pixels = Pixels(2.5); + +pub trait ScrollHandleOffsetable { + fn offset(&self) -> Point; + fn set_offset(&self, offset: Point); + fn is_uniform_list(&self) -> bool { + false + } +} + +impl ScrollHandleOffsetable for ScrollHandle { + fn offset(&self) -> Point { + self.offset() + } + + fn set_offset(&self, offset: Point) { + self.set_offset(offset); + } +} + +impl ScrollHandleOffsetable for UniformListScrollHandle { + fn offset(&self) -> Point { + self.0.borrow().base_handle.offset() + } + + fn set_offset(&self, offset: Point) { + self.0.borrow_mut().base_handle.set_offset(offset) + } + + fn is_uniform_list(&self) -> bool { + true + } +} + +#[derive(Debug, Clone, Copy)] +pub struct ScrollbarState { + hovered_axis: Option, + dragged_axis: Option, + drag_pos: Point, + visible: bool, +} + +impl Default for ScrollbarState { + fn default() -> Self { + Self { + hovered_axis: None, + dragged_axis: None, + drag_pos: point(px(0.), px(0.)), + visible: true, + } + } +} + +impl ScrollbarState { + pub fn new() -> Self { + Self::default() + } + + fn with_drag_pos(&self, axis: ScrollbarAxis, pos: Point) -> Self { + let mut state = *self; + if axis.is_vertical() { + state.drag_pos.y = pos.y; + } else { + state.drag_pos.x = pos.x; + } + + state.dragged_axis = Some(axis); + state + } + + fn with_unset_drag_pos(&self) -> Self { + let mut state = *self; + state.dragged_axis = None; + state + } + + fn with_hovered(&self, axis: Option) -> Self { + let mut state = *self; + state.hovered_axis = axis; + state + } + + fn with_visiable(&self, visiable: bool) -> Self { + let mut state = *self; + state.visible = visiable; + state + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ScrollbarAxis { + Vertical, + Horizontal, + Both, +} + +impl ScrollbarAxis { + #[inline] + fn is_vertical(&self) -> bool { + matches!(self, Self::Vertical) + } + + #[inline] + fn is_both(&self) -> bool { + matches!(self, Self::Both) + } + + #[inline] + pub fn has_vertical(&self) -> bool { + matches!(self, Self::Vertical | Self::Both) + } + + #[inline] + pub fn has_horizontal(&self) -> bool { + matches!(self, Self::Horizontal | Self::Both) + } + + #[inline] + fn all(&self) -> Vec { + match self { + Self::Vertical => vec![Self::Vertical], + Self::Horizontal => vec![Self::Horizontal], + Self::Both => vec![Self::Vertical, Self::Horizontal], + } + } +} + +/// Scrollbar control for scroll-area or a uniform-list. +pub struct Scrollbar { + view: AnyView, + axis: ScrollbarAxis, + /// When is vertical, this is the height of the scrollbar. + width: Pixels, + scroll_handle: Rc>, + scroll_size: gpui::Size, + state: Rc>, +} + +impl Scrollbar { + fn new( + view: AnyView, + state: Rc>, + axis: ScrollbarAxis, + scroll_handle: impl ScrollHandleOffsetable + 'static, + scroll_size: gpui::Size, + ) -> Self { + Self { + view, + state, + axis, + scroll_size, + width: px(11.), + scroll_handle: Rc::new(Box::new(scroll_handle)), + } + } + + /// Create with vertical and horizontal scrollbar. + pub fn both( + view: impl Into, + state: Rc>, + scroll_handle: impl ScrollHandleOffsetable + 'static, + scroll_size: gpui::Size, + ) -> Self { + Self::new( + view.into(), + state, + ScrollbarAxis::Both, + scroll_handle, + scroll_size, + ) + } + + /// Create with horizontal scrollbar. + pub fn horizontal( + view: impl Into, + state: Rc>, + scroll_handle: impl ScrollHandleOffsetable + 'static, + scroll_size: gpui::Size, + ) -> Self { + Self::new( + view.into(), + state, + ScrollbarAxis::Horizontal, + scroll_handle, + scroll_size, + ) + } + + /// Create with vertical scrollbar. + pub fn vertical( + view: impl Into, + state: Rc>, + scroll_handle: impl ScrollHandleOffsetable + 'static, + scroll_size: gpui::Size, + ) -> Self { + Self::new( + view.into(), + state, + ScrollbarAxis::Vertical, + scroll_handle, + scroll_size, + ) + } + + /// Create vertical scrollbar for uniform list. + pub fn uniform_scroll( + view: impl Into, + state: Rc>, + scroll_handle: UniformListScrollHandle, + items_count: usize, + ) -> Self { + let last_item_height = scroll_handle.0.borrow().last_item_height.unwrap_or(px(10.)); + let max_height = items_count as f32 * last_item_height; + let scroll_size = gpui::size(px(0.), max_height); + + Self::new( + view.into(), + state, + ScrollbarAxis::Vertical, + scroll_handle, + scroll_size, + ) + } + + /// Set scrollbar axis. + pub fn axis(mut self, axis: ScrollbarAxis) -> Self { + self.axis = axis; + self + } +} + +impl IntoElement for Scrollbar { + type Element = Self; + + fn into_element(self) -> Self::Element { + self + } +} + +impl Element for Scrollbar { + type RequestLayoutState = (); + + type PrepaintState = Hitbox; + + fn id(&self) -> Option { + None + } + + fn request_layout( + &mut self, + _: Option<&gpui::GlobalElementId>, + cx: &mut gpui::WindowContext, + ) -> (gpui::LayoutId, Self::RequestLayoutState) { + let mut style = Style::default(); + style.position = Position::Absolute; + style.flex_grow = 1.0; + style.flex_shrink = 1.0; + style.size.width = relative(1.).into(); + style.size.height = relative(1.).into(); + + (cx.request_layout(style, None), ()) + } + + fn prepaint( + &mut self, + _: Option<&gpui::GlobalElementId>, + bounds: Bounds, + _: &mut Self::RequestLayoutState, + cx: &mut gpui::WindowContext, + ) -> Self::PrepaintState { + cx.with_content_mask(Some(ContentMask { bounds }), |cx| { + cx.insert_hitbox(bounds, false) + }) + } + + fn paint( + &mut self, + _: Option<&gpui::GlobalElementId>, + _: Bounds, + _: &mut Self::RequestLayoutState, + hitbox: &mut Self::PrepaintState, + cx: &mut gpui::WindowContext, + ) { + let hitbox_bounds = hitbox.bounds; + let is_both = self.axis.is_both(); + + cx.with_content_mask( + Some(ContentMask { + bounds: hitbox_bounds, + }), + |cx| { + for axis in self.axis.all().into_iter() { + let is_vertical = axis.is_vertical(); + let (scroll_area_size, container_size, scroll_position) = if is_vertical { + ( + self.scroll_size.height, + hitbox_bounds.size.height, + self.scroll_handle.offset().y, + ) + } else { + ( + self.scroll_size.width, + hitbox_bounds.size.width, + self.scroll_handle.offset().x, + ) + }; + + // The horizontal scrollbar is set avoid overlapping with the vertical scrollbar, if the vertical scrollbar is visible. + let margin_end = if !is_vertical && is_both { + self.width + } else { + px(0.) + }; + + let thumb_length = (container_size / scroll_area_size * container_size) + .max(px(MIN_THUMB_SIZE)); + let thumb_start = -(scroll_position / (scroll_area_size - container_size) + * (container_size - margin_end - thumb_length)); + let thumb_end = (thumb_start + thumb_length).min(container_size - margin_end); + + let bounds = Bounds { + origin: if is_vertical { + point( + hitbox_bounds.origin.x + hitbox_bounds.size.width - self.width, + hitbox_bounds.origin.y, + ) + } else { + point( + hitbox_bounds.origin.x, + hitbox_bounds.origin.y + hitbox_bounds.size.height - self.width, + ) + }, + size: gpui::Size { + width: if is_vertical { + self.width + } else { + hitbox_bounds.size.width + }, + height: if is_vertical { + hitbox_bounds.size.height + } else { + self.width + }, + }, + }; + + let thumb_bg = cx.theme().scrollbar_thumb; + let state = self.state.clone(); + let (thumb_bg, bar_bg, inset) = if state.get().dragged_axis == Some(axis) { + (thumb_bg, cx.theme().scrollbar, px(0.)) + } else if state.get().hovered_axis == Some(axis) { + (thumb_bg, cx.theme().scrollbar, px(0.)) + } else { + (thumb_bg, cx.theme().transparent, THUMB_INSET) + }; + + let thumb_bounds = if is_vertical { + Bounds::from_corners( + point( + bounds.origin.x + inset, + bounds.origin.y + thumb_start + inset, + ), + point( + bounds.origin.x + self.width - inset, + bounds.origin.y + thumb_end - (inset * 2), + ), + ) + } else { + Bounds::from_corners( + point( + bounds.origin.x + thumb_start + inset, + bounds.origin.y + inset, + ), + point( + bounds.origin.x + thumb_end - (inset * 2), + bounds.origin.y + self.width - inset, + ), + ) + }; + + if state.get().visible { + cx.paint_quad(fill(bounds, bar_bg)); + cx.paint_quad( + fill(thumb_bounds, thumb_bg).corner_radii(THUMB_RADIUS - inset), + ); + } + + cx.on_mouse_event({ + let state = self.state.clone(); + let view_id = self.view.entity_id(); + + move |event: &MouseDownEvent, phase, cx| { + if phase.bubble() && thumb_bounds.contains(&event.position) { + let drag_pos = if is_vertical { + point( + state.get().drag_pos.x, + event.position.y - thumb_bounds.origin.y, + ) + } else { + point( + event.position.x - thumb_bounds.origin.x, + state.get().drag_pos.y, + ) + }; + + cx.stop_propagation(); + state.set(state.get().with_drag_pos(axis, drag_pos)); + cx.notify(view_id); + } + } + }); + + cx.on_mouse_event({ + let scroll_handle = self.scroll_handle.clone(); + let state = self.state.clone(); + let view_id = self.view.entity_id(); + + move |event: &MouseMoveEvent, _, cx| { + if thumb_bounds.contains(&event.position) { + if state.get().hovered_axis != Some(axis) { + state.set(state.get().with_hovered(Some(axis))); + cx.notify(view_id); + } + } else { + if state.get().hovered_axis == Some(axis) { + if state.get().hovered_axis.is_some() { + state.set(state.get().with_hovered(None)); + cx.notify(view_id); + } + } + } + + // If mouse out of the bounds, hide scrollbar + if hitbox_bounds.contains(&event.position) + || state.get().dragged_axis.is_some() + { + if !state.get().visible { + state.set(state.get().with_visiable(true)); + cx.notify(view_id); + } + } else { + if state.get().visible { + state.set(state.get().with_visiable(false)); + cx.notify(view_id); + } + } + + // Move thumb position on dragging + if state.get().dragged_axis == Some(axis) && event.dragging() { + let drag_pos = state.get().drag_pos; + let percentage = if is_vertical { + (event.position.y - bounds.origin.y - drag_pos.y) + / container_size + } else { + (event.position.x - bounds.origin.x - drag_pos.x) + / container_size + } + .min(1.); + + let offset = if is_vertical { + point(scroll_handle.offset().x, -percentage * scroll_area_size) + } else { + point(-percentage * scroll_area_size, scroll_handle.offset().y) + }; + + scroll_handle.set_offset(offset); + cx.notify(view_id); + } + } + }); + + cx.on_mouse_event({ + let view_id = self.view.entity_id(); + let state = self.state.clone(); + + move |_event: &MouseUpEvent, phase, cx| { + if phase.bubble() { + state.set(state.get().with_unset_drag_pos()); + cx.notify(view_id); + } + } + }); + } + }, + ); + } +} diff --git a/crates/ui/src/scrollbar.rs b/crates/ui/src/scrollbar.rs deleted file mode 100644 index 1b3afce2..00000000 --- a/crates/ui/src/scrollbar.rs +++ /dev/null @@ -1,252 +0,0 @@ -use std::{cell::Cell, ops::Range, rc::Rc}; - -use crate::theme::{ActiveTheme, Colorize}; -use gpui::{ - fill, point, px, relative, AnyView, Bounds, ContentMask, Element, Hitbox, IntoElement, - MouseDownEvent, MouseMoveEvent, MouseUpEvent, Pixels, ScrollWheelEvent, Style, - UniformListScrollHandle, -}; - -const MIN_THUMB_PERCENTAGE_HEIGHT: f64 = 0.05; -const THUMB_RADIUS: Pixels = Pixels(5.0); -const THUMB_INSET: Pixels = Pixels(0.8); - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum MouseState { - Normal, - Down, -} - -pub struct Scrollbar { - width: f32, - view: AnyView, - handle: UniformListScrollHandle, - /// This is the state of the scrollbar thumb when it is being dragged. - /// It must ref from the parent view. - drag_state: Rc>>, - mouse_state: Rc>, - items_count: usize, - thumb: Range, -} - -impl Scrollbar { - pub fn new( - view: impl Into, - handle: UniformListScrollHandle, - drag_state: Rc>>, - items_count: usize, - show_scrollbar: bool, - ) -> Option { - let cloned_handle = handle.clone(); - - let scroll_state = handle.0.borrow(); - let last_item_height = scroll_state - .last_item_height - .filter(|_| show_scrollbar && items_count > 0)?; - let list_height = items_count as f64 * last_item_height.0 as f64; - let current_offset = scroll_state.base_handle.offset().y.0.min(0.).abs() as f64; - let mut percentage = current_offset / list_height; - let end_offset: f64 = - (current_offset + scroll_state.base_handle.bounds().size.height.0 as f64) / list_height; - let overshoot = (end_offset - 1.).clamp(0., 1.); - if overshoot > 0. { - percentage -= overshoot; - } - - if percentage + MIN_THUMB_PERCENTAGE_HEIGHT > 1.0 || end_offset > list_height { - return None; - } - if list_height < scroll_state.base_handle.bounds().size.height.0 as f64 { - return None; - } - - let end_offset = end_offset.clamp(percentage + MIN_THUMB_PERCENTAGE_HEIGHT, 1.); - - let thumb = percentage as f32..end_offset as f32; - - Some(Self { - view: view.into(), - items_count, - width: 12.0, - thumb, - handle: cloned_handle, - drag_state, - mouse_state: Rc::new(Cell::new(MouseState::Normal)), - }) - } - - pub fn width(&self) -> f32 { - self.width - } -} - -impl IntoElement for Scrollbar { - type Element = Self; - fn into_element(self) -> Self::Element { - self - } -} - -pub struct ScrollbarState {} - -impl Element for Scrollbar { - type RequestLayoutState = ScrollbarState; - - type PrepaintState = Hitbox; - - fn id(&self) -> Option { - None - } - - fn request_layout( - &mut self, - _: Option<&gpui::GlobalElementId>, - cx: &mut gpui::WindowContext, - ) -> (gpui::LayoutId, Self::RequestLayoutState) { - let mut style = Style::default(); - style.flex_grow = 0.0; - style.flex_shrink = 1.; - style.size.width = px(self.width).into(); - style.size.height = relative(1.).into(); - - (cx.request_layout(style, None), ScrollbarState {}) - } - - fn prepaint( - &mut self, - _: Option<&gpui::GlobalElementId>, - bounds: gpui::Bounds, - _: &mut Self::RequestLayoutState, - cx: &mut gpui::WindowContext, - ) -> Self::PrepaintState { - cx.with_content_mask(Some(ContentMask { bounds }), |cx| { - cx.insert_hitbox(bounds, false) - }) - } - - fn paint( - &mut self, - _: Option<&gpui::GlobalElementId>, - bounds: gpui::Bounds, - _: &mut Self::RequestLayoutState, - _: &mut Self::PrepaintState, - cx: &mut gpui::WindowContext, - ) { - cx.with_content_mask(Some(ContentMask { bounds }), |cx| { - let is_draging = self.drag_state.get().is_some(); - let is_active = self.mouse_state.get() == MouseState::Down; - - let bar_bg = cx.theme().scrollbar; - let thumb_bg = cx.theme().scrollbar_thumb; - let thumb_bg = if is_draging || is_active { - thumb_bg.darken(0.3) - } else { - thumb_bg - }; - - let thumb_top = self.thumb.start * bounds.size.height; - let thumb_bottom = self.thumb.end * bounds.size.height; - let thumb_percentage_size = self.thumb.end - self.thumb.start; - let thumb_bounds = Bounds::from_corners( - point( - bounds.origin.x + THUMB_INSET, - bounds.origin.y + thumb_top + THUMB_INSET, - ), - point( - bounds.origin.x + bounds.size.width - (THUMB_INSET * 2), - bounds.origin.y + thumb_bottom - (THUMB_INSET * 2), - ), - ); - - cx.paint_quad(fill(bounds, bar_bg)); - cx.paint_quad(fill(thumb_bounds, thumb_bg).corner_radii(THUMB_RADIUS)); - - let handle = self.handle.clone(); - let items_count = self.items_count; - - cx.on_mouse_event({ - let scroll = self.handle.clone(); - let drag_state = self.drag_state.clone(); - let mouse_state = self.mouse_state.clone(); - let view_id = self.view.entity_id(); - - move |event: &MouseDownEvent, phase, cx| { - if phase.bubble() && bounds.contains(&event.position) { - if thumb_bounds.contains(&event.position) { - let thumb_top_offset = - (event.position.y - thumb_bounds.origin.y) / bounds.size.height; - drag_state.set(Some(thumb_top_offset)); - mouse_state.set(MouseState::Down); - cx.notify(view_id); - } else { - let scroll = scroll.0.borrow(); - if let Some(last_height) = scroll.last_item_height { - let max_offset = items_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.handle.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({ - let drag_state = self.drag_state.clone(); - let view_id = self.view.entity_id(); - - move |event: &MouseMoveEvent, _, cx| { - if let Some(drag_state) = drag_state.get().filter(|_| event.dragging()) { - let scroll = handle.0.borrow(); - if let Some(last_height) = scroll.last_item_height { - let max_offset = items_count as f32 * last_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(px(0.), -max_offset * percentage)); - cx.notify(view_id); - } - } else { - drag_state.set(None); - } - } - }); - - cx.on_mouse_event({ - let view_id = self.view.entity_id(); - let mouse_state = self.mouse_state.clone(); - let drag_state = self.drag_state.clone(); - - move |_event: &MouseUpEvent, phase, cx| { - if phase.bubble() { - drag_state.set(None); - mouse_state.set(MouseState::Normal); - cx.notify(view_id); - } - } - }); - }) - } -} diff --git a/crates/ui/src/skeleton.rs b/crates/ui/src/skeleton.rs new file mode 100644 index 00000000..1aa33e9d --- /dev/null +++ b/crates/ui/src/skeleton.rs @@ -0,0 +1,73 @@ +use std::time::Duration; + +use gpui::{ + div, Animation, AnimationExt, Div, Element, InteractiveElement, Interactivity, IntoElement, + ParentElement as _, RenderOnce, Stateful, Styled, +}; + +pub struct Skeleton { + base: Stateful
, +} + +impl Skeleton { + pub fn new() -> Self { + Self { + base: div().id("skeleton").w_full().h_4(), + } + } +} + +impl Styled for Skeleton { + fn style(&mut self) -> &mut gpui::StyleRefinement { + self.base.style() + } +} + +impl IntoElement for Skeleton { + type Element = Self; + + fn into_element(self) -> Self::Element { + self + } +} + +impl Element for Skeleton { + type RequestLayoutState = (); + + type PrepaintState = (); + + fn id(&self) -> Option { + None + } + + fn request_layout( + &mut self, + id: Option<&gpui::GlobalElementId>, + cx: &mut gpui::WindowContext, + ) -> (gpui::LayoutId, Self::RequestLayoutState) { + let (layout_id, _) = self.base.request_layout(id, cx); + (layout_id, ()) + } + + fn prepaint( + &mut self, + id: Option<&gpui::GlobalElementId>, + bounds: gpui::Bounds, + request_layout: &mut Self::RequestLayoutState, + cx: &mut gpui::WindowContext, + ) -> Self::PrepaintState { + self.base.prepaint(id, bounds, request_layout, cx); + () + } + + fn paint( + &mut self, + id: Option<&gpui::GlobalElementId>, + bounds: gpui::Bounds, + request_layout: &mut Self::RequestLayoutState, + prepaint: &mut Self::PrepaintState, + cx: &mut gpui::WindowContext, + ) { + todo!() + } +} diff --git a/crates/ui/src/table.rs b/crates/ui/src/table.rs index 1af876e4..8b57d41d 100644 --- a/crates/ui/src/table.rs +++ b/crates/ui/src/table.rs @@ -1,9 +1,8 @@ -use std::{cell::Cell, rc::Rc, time::Duration}; +use std::{cell::Cell, rc::Rc}; use crate::{ h_flex, - scroll::{ScrollAxis, ScrollableMask}, - scrollbar::Scrollbar, + scroll::{ScrollableAxis, ScrollableMask, Scrollbar, ScrollbarState}, theme::{ActiveTheme, Colorize}, v_flex, }; @@ -11,7 +10,7 @@ use gpui::{ actions, deferred, div, prelude::FluentBuilder as _, px, uniform_list, AppContext, Div, FocusHandle, FocusableView, InteractiveElement as _, IntoElement, KeyBinding, MouseButton, ParentElement as _, Render, ScrollHandle, SharedString, StatefulInteractiveElement as _, - Styled, Task, UniformListScrollHandle, ViewContext, WindowContext, + Styled, UniformListScrollHandle, ViewContext, WindowContext, }; actions!( @@ -52,9 +51,8 @@ pub struct Table { horizontal_scroll_handle: ScrollHandle, vertical_scroll_handle: UniformListScrollHandle, col_groups: Vec, - show_scrollbar: bool, - hide_scrollbar_task: Option>, - scrollbar_drag_state: Rc>>, + + scrollbar_state: Rc>, selection_state: SelectionState, selected_row: Option, @@ -112,9 +110,7 @@ where col_groups: Vec::new(), horizontal_scroll_handle: ScrollHandle::new(), vertical_scroll_handle: UniformListScrollHandle::new(), - show_scrollbar: false, - hide_scrollbar_task: None, - scrollbar_drag_state: Rc::new(Cell::new(None)), + scrollbar_state: Rc::new(Cell::new(ScrollbarState::new())), selection_state: SelectionState::Row, selected_row: None, selected_col: None, @@ -141,43 +137,6 @@ where cx.notify(); } - fn render_scrollbar(&self, cx: &mut ViewContext) -> Option { - Scrollbar::new( - cx.view().clone(), - self.vertical_scroll_handle.clone(), - self.scrollbar_drag_state.clone(), - self.delegate.rows_count(), - true, - ) - .map(|bar| { - deferred( - div() - .occlude() - .absolute() - .h_full() - .left_auto() - .top_0() - .right_0() - .w(px(bar.width())) - .bottom_0() - .child(bar), - ) - .with_priority(1) - }) - } - - fn hide_scrollbar(&mut self, cx: &mut ViewContext) { - self.show_scrollbar = false; - self.hide_scrollbar_task = Some(cx.spawn(|this, mut cx| async move { - cx.background_executor().timer(Duration::from_secs(1)).await; - this.update(&mut cx, |this, cx| { - this.show_scrollbar = false; - cx.notify(); - }) - .ok(); - })) - } - fn scroll_to_selected_row(&mut self, _cx: &mut ViewContext) { if let Some(row_ix) = self.selected_row { self.vertical_scroll_handle.scroll_to_item(row_ix); @@ -191,16 +150,6 @@ where } } - fn on_hover_to_autohide_scrollbar(&mut self, hovered: &bool, cx: &mut ViewContext) { - if *hovered { - self.show_scrollbar = true; - self.hide_scrollbar_task.take(); - cx.notify(); - } else if !self.focus_handle.is_focused(cx) { - self.hide_scrollbar(cx); - } - } - fn on_row_click(&mut self, row_ix: usize, cx: &mut ViewContext) { self.selection_state = SelectionState::Row; self.selected_row = Some(row_ix); @@ -301,6 +250,18 @@ where div() } } + + fn render_scrollbar(&self, cx: &mut ViewContext) -> Option { + let view = cx.view().clone(); + let state = self.scrollbar_state.clone(); + + Some(deferred(Scrollbar::uniform_scroll( + view, + state, + self.vertical_scroll_handle.clone(), + self.delegate.rows_count(), + ))) + } } impl FocusableView for Table @@ -338,10 +299,8 @@ where .on_action(cx.listener(Self::action_select_prev)) .on_action(cx.listener(Self::action_select_next_column)) .on_action(cx.listener(Self::action_select_prev_column)) - .on_hover(cx.listener(Self::on_hover_to_autohide_scrollbar)) .size_full() .overflow_hidden() - .children(self.render_scrollbar(cx)) .child( v_flex() .flex_grow() @@ -449,10 +408,11 @@ where .border_1() .border_color(cx.theme().border) .bg(cx.theme().card) + .children(self.render_scrollbar(cx)) .child(inner_table) .child(ScrollableMask::new( cx.view().clone(), - ScrollAxis::Horizontal, + ScrollableAxis::Horizontal, &horizontal_scroll_handle, )) } diff --git a/crates/ui/src/theme.rs b/crates/ui/src/theme.rs index 8a13c8bb..2d1ce606 100644 --- a/crates/ui/src/theme.rs +++ b/crates/ui/src/theme.rs @@ -192,8 +192,8 @@ impl Colors { input: hsl(240.0, 5.9, 90.0), ring: hsl(240.0, 5.9, 65.0), selection: hsl(211.0, 97.0, 85.0), - scrollbar: Hsla::transparent_black(), - scrollbar_thumb: hsl(240.0, 5.9, 85.0).opacity(0.7), + scrollbar: hsl(0., 0., 98.), + scrollbar_thumb: hsl(0., 0., 49.), panel: hsl(0.0, 0.0, 100.0), tab_bar: hsl(240.0, 4.8, 95.9), } @@ -251,8 +251,8 @@ impl Colors { input: hsl(240.0, 3.7, 15.9), ring: hsl(240.0, 4.9, 83.9), selection: hsl(211.0, 97.0, 22.0), - scrollbar: Hsla::transparent_black(), - scrollbar_thumb: hsl(240.0, 3.7, 15.9).opacity(0.7), + scrollbar: hsl(240., 1., 15.), + scrollbar_thumb: hsl(0., 0., 58.), panel: hsl(299.0, 2., 9.), tab_bar: hsl(299.0, 2., 9.), }