From d183bacc738fb5ede22894eda07e3e9d080c6a05 Mon Sep 17 00:00:00 2001 From: Jason Lee Date: Sun, 30 Jun 2024 23:31:16 +0800 Subject: [PATCH] Add Scrollbar (#9) https://github.com/huacnlee/gpui-component/assets/5518/5e4b7809-f2d0-4e45-a0f2-b9578323a11e --- README.md | 1 + crates/ui-story/src/list_story.rs | 25 +-- crates/ui-story/src/picker_story.rs | 17 ++- crates/ui/src/button.rs | 6 +- crates/ui/src/dropdown.rs | 1 + crates/ui/src/lib.rs | 1 + crates/ui/src/picker.rs | 92 ++++++++++- crates/ui/src/scrollbar.rs | 227 ++++++++++++++++++++++++++++ crates/ui/src/theme.rs | 14 +- 9 files changed, 356 insertions(+), 28 deletions(-) create mode 100644 crates/ui/src/scrollbar.rs diff --git a/README.md b/README.md index 85c3bbb2..b85ad520 100644 --- a/README.md +++ b/README.md @@ -60,6 +60,7 @@ There have a part of UI components from [Zed](https://github.com/zed-industries/ - title_bar - picker +- scrollbar > I think we can discuss them with Zed team to change the license to MIT or Apache License for share to community. diff --git a/crates/ui-story/src/list_story.rs b/crates/ui-story/src/list_story.rs index 194acad9..84f5943a 100644 --- a/crates/ui-story/src/list_story.rs +++ b/crates/ui-story/src/list_story.rs @@ -1,11 +1,10 @@ use core::time; -use std::{cell::RefCell, fmt::format, rc::Rc}; use fake::Fake; use gpui::{ - actions, div, impl_actions, prelude::FluentBuilder as _, px, Action, ElementId, - InteractiveElement, IntoElement, ParentElement, Render, RenderOnce, Styled, Timer, View, - ViewContext, VisualContext, WindowContext, + actions, div, prelude::FluentBuilder as _, px, ElementId, InteractiveElement, IntoElement, + ParentElement, Render, RenderOnce, Styled, Timer, View, ViewContext, VisualContext, + WindowContext, }; use ui::{ @@ -86,7 +85,6 @@ impl RenderOnce for CompanyListItem { self.base .px_3() .py_1() - .rounded_lg() .overflow_x_hidden() .bg(bg_color) .child( @@ -210,6 +208,7 @@ impl ListStory { }, cx, ) + // .max_height(Some(px(350.0).into())) .no_query() }); @@ -218,7 +217,7 @@ impl ListStory { loop { Timer::after(time::Duration::from_secs_f64(0.5)).await; this.update(&mut cx, |this, cx| { - this.company_list.update(cx, |picker, cx| { + this.company_list.update(cx, |picker, _| { picker .delegate_mut() .companies @@ -257,13 +256,13 @@ impl Render for ListStory { .gap_4() .mb_4() .child( - div() + v_flex() .h_full() .border_1() .border_color(cx.theme().border) - .p_1() .rounded_md() .w(px(400.)) + .occlude() .child(self.company_list.clone()), ) .child( @@ -280,7 +279,15 @@ impl Render for ListStory { div() .flex_1() .gap_2() - .child(div().text_3xl().mb_6().child(company.name.clone())) + .child( + h_flex() + .items_start() + .justify_between() + .child( + div().text_3xl().mb_6().child(company.name.clone()), + ) + .child(format!("{:.2}", company.last_done)), + ) .child(company.industry.clone()) .child(company.description.clone()), ) diff --git a/crates/ui-story/src/picker_story.rs b/crates/ui-story/src/picker_story.rs index cc8638b5..8e7288cf 100644 --- a/crates/ui-story/src/picker_story.rs +++ b/crates/ui-story/src/picker_story.rs @@ -172,8 +172,8 @@ impl PickerStory { }, cx, ) - .modal(true) - .max_height(Some(px(350.0).into())); + .modal(true); + picker.focus(cx); picker.set_query("c", cx); picker @@ -213,12 +213,13 @@ impl Render for PickerStory { .when(self.open, |this| { this.child( div().absolute().size_full().top_0().left_0().child( - v_flex() - .top_10() - .flex() - .flex_col() - .items_center() - .child(h_flex().w(px(450.)).occlude().child(self.picker.clone())), + v_flex().top_10().flex().flex_col().items_center().child( + div() + .w(px(450.)) + .h(px(350.)) + .occlude() + .child(self.picker.clone()), + ), ), ) }) diff --git a/crates/ui/src/button.rs b/crates/ui/src/button.rs index 2bfcb737..ff8bfaf6 100644 --- a/crates/ui/src/button.rs +++ b/crates/ui/src/button.rs @@ -266,9 +266,9 @@ impl ButtonStyle { fn hovered(&self, cx: &WindowContext) -> ButtonStyles { // Hover color = color/90 - let bg = self.bg_color(cx).divide(0.9); - let border = self.border_color(cx).divide(0.9); - let fg = self.text_color(cx).divide(0.9); + let bg = self.bg_color(cx); + let border = self.border_color(cx); + let fg = self.text_color(cx); ButtonStyles { bg, border, fg } } diff --git a/crates/ui/src/dropdown.rs b/crates/ui/src/dropdown.rs index 6611560d..b1fe8043 100644 --- a/crates/ui/src/dropdown.rs +++ b/crates/ui/src/dropdown.rs @@ -139,6 +139,7 @@ where let picker = cx.new_view(|cx| { Picker::uniform_list(picker_delegate, cx) .no_query() + .scrollbar_enable(false) .max_height(Some(rems(20.).into())) }); Self { diff --git a/crates/ui/src/lib.rs b/crates/ui/src/lib.rs index 1f91b4a0..d5e4830c 100644 --- a/crates/ui/src/lib.rs +++ b/crates/ui/src/lib.rs @@ -4,6 +4,7 @@ mod event; mod icon; mod platform; mod prelude; +mod scrollbar; mod selectable; mod stock; mod styled_ext; diff --git a/crates/ui/src/picker.rs b/crates/ui/src/picker.rs index 914674b1..a3bff37e 100644 --- a/crates/ui/src/picker.rs +++ b/crates/ui/src/picker.rs @@ -1,8 +1,10 @@ +use std::{cell::Cell, rc::Rc, time::Duration}; + use anyhow::Result; use gpui::{ - actions, div, list, prelude::FluentBuilder as _, px, rems, uniform_list, AppContext, - ClickEvent, DismissEvent, Div, EventEmitter, FocusHandle, FocusableView, InteractiveElement, - IntoElement, KeyBinding, Length, ListSizingBehavior, ListState, MouseButton, MouseUpEvent, + actions, div, list, prelude::FluentBuilder as _, px, uniform_list, AppContext, ClickEvent, + DismissEvent, Div, EventEmitter, FocusHandle, FocusableView, InteractiveElement, IntoElement, + KeyBinding, Length, ListSizingBehavior, ListState, MouseButton, MouseUpEvent, ParentElement as _, Render, SharedString, StatefulInteractiveElement as _, Styled as _, Task, UniformListScrollHandle, View, ViewContext, VisualContext as _, WindowContext, }; @@ -34,6 +36,7 @@ use crate::{ divider::Divider, empty::Empty, input::{TextEvent, TextInput}, + scrollbar::Scrollbar, stock::*, theme::ActiveTheme, StyledExt as _, @@ -149,6 +152,10 @@ pub struct Picker { /// Just a empty view for holding the focus head: View, pending_update_matches: Option, + scrollbar_enable: bool, + show_scrollbar: bool, + hide_scrollbar_task: Option>, + scrollbar_drag_state: Rc>>, } impl Picker { @@ -190,6 +197,10 @@ impl Picker { max_height: None, element_container, pending_update_matches: None, + scrollbar_enable: true, + show_scrollbar: false, + hide_scrollbar_task: None, + scrollbar_drag_state: Rc::new(Cell::new(None)), } } @@ -249,6 +260,11 @@ impl Picker { self } + pub fn scrollbar_enable(mut self, enable: bool) -> Self { + self.scrollbar_enable = enable; + self + } + pub fn set_query(&mut self, query: &str, cx: &mut ViewContext) { if let Some(input) = &self.query_input { input.update(cx, |this, cx| this.set_text(query.to_string(), cx)); @@ -327,6 +343,46 @@ impl Picker { ) } + fn render_scrollbar(&self, cx: &mut ViewContext) -> Option { + if !self.scrollbar_enable { + return None; + } + if !self.show_scrollbar { + return None; + } + + if let Some(scroll_handle) = match &self.element_container { + ElementContainer::List(_state) => None, + ElementContainer::UniformList(scroll_handle) => Some(scroll_handle.clone()), + } { + Scrollbar::new( + cx.view().clone().into(), + scroll_handle, + self.scrollbar_drag_state.clone(), + self.delegate.match_count(), + true, + ) + } else { + None + } + } + + fn hide_scrollbar(&mut self, cx: &mut ViewContext) { + const SCROLLBAR_SHOW_INTERVAL: Duration = Duration::from_secs(1); + self.show_scrollbar = false; + self.hide_scrollbar_task = Some(cx.spawn(|panel, mut cx| async move { + cx.background_executor() + .timer(SCROLLBAR_SHOW_INTERVAL) + .await; + panel + .update(&mut cx, |panel, cx| { + panel.show_scrollbar = false; + cx.notify(); + }) + .ok(); + })) + } + fn on_click(&mut self, ix: usize, secondary: bool, cx: &mut ViewContext) { cx.stop_propagation(); cx.prevent_default(); @@ -487,7 +543,9 @@ impl Render for Picker { let focus_handle = self.focus_handle(cx); v_flex() + .id("picker") .key_context("Picker") + .group("picker-group") .size_full() .track_focus(&focus_handle) .when_some(self.width, |el, width| el.w(width)) @@ -499,19 +557,41 @@ impl Render for Picker { .on_action(cx.listener(Self::select_last)) .on_action(cx.listener(Self::cancel)) .on_action(cx.listener(Self::confirm)) + .on_hover(cx.listener(move |this, hovered: &bool, cx| { + if *hovered { + this.show_scrollbar = true; + this.hide_scrollbar_task.take(); + cx.notify(); + } else if !focus_handle.is_focused(cx) { + this.hide_scrollbar(cx); + } + })) // Render Query Input header .child(match self.query_input { Some(ref input) => self.delegate.render_query(input, cx), None => div().child(self.head.clone()), }) - .when(self.delegate.match_count() > 0, |el| { - el.child( + .when(self.delegate.match_count() > 0, |this| { + this.child( v_flex() .flex_grow() .min_h(px(100.)) .when_some(self.max_height, |div, max_h| div.max_h(max_h)) .overflow_hidden() - .child(self.render_element_container(cx)), + .child(self.render_element_container(cx)) + .children(self.render_scrollbar(cx).map(|bar| { + // This for let the scrollbar can render on top of the list container. + div() + .occlude() + .absolute() + .h_full() + .left_auto() + .top_0() + .right_0() + .w(px(bar.width())) + .bottom_0() + .child(bar) + })), ) }) .when(self.delegate.match_count() == 0, |el| { diff --git a/crates/ui/src/scrollbar.rs b/crates/ui/src/scrollbar.rs new file mode 100644 index 00000000..1ea0f191 --- /dev/null +++ b/crates/ui/src/scrollbar.rs @@ -0,0 +1,227 @@ +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.03; +const THUMB_RADIUS: Pixels = Pixels(5.0); +const THUMB_INSET: Pixels = Pixels(0.8); + +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>>, + items_count: usize, + thumb: Range, +} + +impl Scrollbar { + pub fn new( + view: AnyView, + handle: UniformListScrollHandle, + drag_state: Rc>>, + items_count: usize, + show_scrollbar: bool, + ) -> Option { + let cloned_handle = handle.clone(); + + // Ref from: Zed crates\project_panel\src\project_panel.rs + // render_scrollbar method + 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, + items_count, + width: 12.0, + thumb, + handle: cloned_handle, + drag_state, + }) + } + + 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 bar_bg = cx.theme().scrollbar; + let thumb_bg = cx.theme().scrollbar_thumb; + let thumb_bg = if is_draging { + thumb_bg.darken(0.1) + } 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; + + let drag_state = self.drag_state.clone(); + cx.on_mouse_event({ + let scroll = self.handle.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(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)); + } + } else { + let thumb_top_offset = + (event.position.y - thumb_bounds.origin.y) / bounds.size.height; + drag_state.set(Some(thumb_top_offset)); + } + } + } + }); + 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())); + } + } + }); + + let view_id = self.view.entity_id(); + let drag_state = self.drag_state.clone(); + cx.on_mouse_event(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); + } + }); + + let drag_state = self.drag_state.clone(); + cx.on_mouse_event(move |_event: &MouseUpEvent, phase, cx| { + if phase.bubble() { + drag_state.set(None); + cx.notify(view_id); + } + }); + }) + } +} diff --git a/crates/ui/src/theme.rs b/crates/ui/src/theme.rs index e5d10be3..3023afc9 100644 --- a/crates/ui/src/theme.rs +++ b/crates/ui/src/theme.rs @@ -68,13 +68,13 @@ impl Colorize for Hsla { } fn lighten(&self, amount: f32) -> Hsla { - let l = (self.l + amount).min(1.0); + let l = (self.l * (1.0 + amount)).min(1.0); Hsla { l, ..*self } } fn darken(&self, amount: f32) -> Hsla { - let l = (self.l - amount).max(0.0); + let l = (self.l * (1.0 - amount)).max(0.0); Hsla { l, ..*self } } @@ -104,6 +104,8 @@ struct Colors { pub input: Hsla, pub ring: Hsla, pub selection: Hsla, + pub scrollbar: Hsla, + pub scrollbar_thumb: Hsla, } impl Colors { @@ -154,6 +156,8 @@ impl Colors { input: hsl(240.0, 5.9, 90.0), ring: hsl(240.0, 5.9, 10.0), selection: hsl(211.0, 97.0, 85.0), + scrollbar: Hsla::transparent_black(), + scrollbar_thumb: hsl(240.0, 5.9, 90.0), } } @@ -203,6 +207,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, 85.0), + scrollbar: Hsla::transparent_black(), + scrollbar_thumb: hsl(240.0, 3.7, 15.9), } } } @@ -234,6 +240,8 @@ pub struct Theme { pub input: Hsla, pub ring: Hsla, pub selection: Hsla, + pub scrollbar: Hsla, + pub scrollbar_thumb: Hsla, pub radius: f32, } @@ -272,6 +280,8 @@ impl From for Theme { border: colors.border, input: colors.input, ring: colors.ring, + scrollbar: colors.scrollbar, + scrollbar_thumb: colors.scrollbar_thumb, selection: colors.selection, } }