From 2836540260acd83b26c8374fd4e8d67d450f997f Mon Sep 17 00:00:00 2001 From: Jason Lee Date: Tue, 10 Dec 2024 16:18:38 +0800 Subject: [PATCH] scrollbar: Improve scrollbar behaviour. (#481) - Only show scroll when recent scrolled. - Keep showing scrollbar when is visible and hovered on. - Delay 3s to fade out. - If not visible, do not handle mouse down event. - Add `cx.theme().scrollbar_show` option to control scrollbar show `[Hover, Scrolling]`, default: `Scrolling`. Close #472 ![CleanShot 2024-12-10 at 15 43 43](https://github.com/user-attachments/assets/17e5f46a-5602-4544-9a5c-82ec68a24dd4) --- crates/app/src/story_workspace.rs | 43 +++++- crates/ui/src/input/input.rs | 2 +- crates/ui/src/scroll/scrollbar.rs | 231 ++++++++++++++++++++---------- crates/ui/src/theme.rs | 5 + 4 files changed, 198 insertions(+), 83 deletions(-) diff --git a/crates/app/src/story_workspace.rs b/crates/app/src/story_workspace.rs index 4de88e66..43904f36 100644 --- a/crates/app/src/story_workspace.rs +++ b/crates/app/src/story_workspace.rs @@ -14,6 +14,7 @@ use ui::{ dock::{DockArea, DockAreaState, DockEvent, DockItem, DockPlacement}, h_flex, popup_menu::PopupMenuExt, + scroll::ScrollbarShow, theme::{ActiveTheme, Theme}, ContextModal, IconName, Root, Sizable, TitleBar, }; @@ -25,6 +26,9 @@ const MAIN_DOCK_AREA: DockAreaTab = DockAreaTab { version: 5, }; +#[derive(Clone, PartialEq, Eq, Deserialize)] +struct SelectScrollbarShow(ScrollbarShow); + #[derive(Clone, PartialEq, Eq, Deserialize)] struct SelectLocale(SharedString); @@ -34,7 +38,10 @@ struct SelectFont(usize); #[derive(Clone, PartialEq, Eq, Deserialize)] struct AddPanel(DockPlacement); -impl_actions!(story, [SelectLocale, SelectFont, AddPanel]); +impl_actions!( + story, + [SelectLocale, SelectFont, AddPanel, SelectScrollbarShow] +); actions!(workspace, [Open, CloseWindow]); @@ -573,30 +580,52 @@ impl FontSizeSelector { } } - fn on_select(&mut self, font_size: &SelectFont, cx: &mut ViewContext) { + fn on_select_font(&mut self, font_size: &SelectFont, cx: &mut ViewContext) { Theme::global_mut(cx).font_size = font_size.0 as f32; cx.refresh(); } + + fn on_select_scrollbar_show(&mut self, show: &SelectScrollbarShow, cx: &mut ViewContext) { + Theme::global_mut(cx).scrollbar_show = show.0; + cx.refresh(); + } } impl Render for FontSizeSelector { fn render(&mut self, cx: &mut ViewContext) -> impl IntoElement { let focus_handle = self.focus_handle.clone(); let font_size = cx.theme().font_size as i32; + let scroll_show = cx.theme().scrollbar_show; div() .id("font-size-selector") .track_focus(&focus_handle) - .on_action(cx.listener(Self::on_select)) + .on_action(cx.listener(Self::on_select_font)) + .on_action(cx.listener(Self::on_select_scrollbar_show)) .child( Button::new("btn") .small() .ghost() - .icon(IconName::ALargeSmall) + .icon(IconName::Settings2) .popup_menu(move |this, _| { - this.menu_with_check("Large", font_size == 18, Box::new(SelectFont(18))) - .menu_with_check("Default", font_size == 16, Box::new(SelectFont(16))) - .menu_with_check("Small", font_size == 14, Box::new(SelectFont(14))) + this.menu_with_check( + "Font Large", + font_size == 18, + Box::new(SelectFont(18)), + ) + .menu_with_check("Font Default", font_size == 16, Box::new(SelectFont(16))) + .menu_with_check("Font Small", font_size == 14, Box::new(SelectFont(14))) + .separator() + .menu_with_check( + "Scrolling to show Scrollbar", + scroll_show == ScrollbarShow::Scrolling, + Box::new(SelectScrollbarShow(ScrollbarShow::Scrolling)), + ) + .menu_with_check( + "Hover to show Scrollbar", + scroll_show == ScrollbarShow::Hover, + Box::new(SelectScrollbarShow(ScrollbarShow::Hover)), + ) }) .anchor(AnchorCorner::TopRight), ) diff --git a/crates/ui/src/input/input.rs b/crates/ui/src/input/input.rs index 46274b56..2f5589ff 100644 --- a/crates/ui/src/input/input.rs +++ b/crates/ui/src/input/input.rs @@ -1249,7 +1249,7 @@ impl Render for TextInput { .absolute() .top_0() .left_0() - .right_0() + .right(px(1.)) .bottom_0() .child( Scrollbar::vertical( diff --git a/crates/ui/src/scroll/scrollbar.rs b/crates/ui/src/scroll/scrollbar.rs index 5c2ff344..ce02800c 100644 --- a/crates/ui/src/scroll/scrollbar.rs +++ b/crates/ui/src/scroll/scrollbar.rs @@ -2,14 +2,32 @@ use std::{cell::Cell, rc::Rc, time::Instant}; use crate::theme::ActiveTheme; use gpui::{ - fill, point, px, relative, Bounds, ContentMask, CursorStyle, Edges, Element, EntityId, Hitbox, - Hsla, IntoElement, MouseDownEvent, MouseMoveEvent, MouseUpEvent, PaintQuad, Pixels, Point, - Position, ScrollHandle, ScrollWheelEvent, Style, UniformListScrollHandle, + fill, point, px, relative, AppContext, Bounds, ContentMask, CursorStyle, Edges, Element, + EntityId, Hitbox, Hsla, IntoElement, MouseDownEvent, MouseMoveEvent, MouseUpEvent, PaintQuad, + Pixels, Point, Position, ScrollHandle, ScrollWheelEvent, Style, UniformListScrollHandle, }; +use serde::{Deserialize, Serialize}; + +/// Scrollbar show mode. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Hash, Default)] +pub enum ScrollbarShow { + #[default] + Scrolling, + Hover, +} + +impl ScrollbarShow { + fn is_hover(&self) -> bool { + matches!(self, Self::Hover) + } +} const MIN_THUMB_SIZE: f32 = 80.; const THUMB_RADIUS: Pixels = Pixels(3.0); const THUMB_INSET: Pixels = Pixels(4.); +const FADE_OUT_DURATION: f32 = 3.0; +const FADE_OUT_DELAY: f32 = 2.0; +const NORMAL_OPACITY: f32 = 0.6; pub trait ScrollHandleOffsetable { fn offset(&self) -> Point; @@ -92,6 +110,9 @@ impl ScrollbarState { fn with_hovered(&self, axis: Option) -> Self { let mut state = *self; state.hovered_axis = axis; + if self.is_scrollbar_visible() { + state.last_scroll_time = Some(Instant::now()); + } state } @@ -111,6 +132,21 @@ impl ScrollbarState { state.last_scroll_time = last_scroll_time; state } + + fn with_last_scroll_time(&self, t: Option) -> Self { + let mut state = *self; + state.last_scroll_time = t; + state + } + + fn is_scrollbar_visible(&self) -> bool { + if let Some(last_time) = self.last_scroll_time { + let elapsed = Instant::now().duration_since(last_time).as_secs_f32(); + elapsed < FADE_OUT_DURATION + } else { + false + } + } } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -257,6 +293,52 @@ impl Scrollbar { self.axis = axis; self } + + fn style_for_default(cx: &AppContext) -> (Hsla, Hsla, Hsla, Pixels, Pixels) { + ( + cx.theme().scrollbar_thumb, + cx.theme().scrollbar, + cx.theme().border, + THUMB_INSET - px(1.), + THUMB_RADIUS, + ) + } + + fn style_for_hovered_thumb(cx: &AppContext) -> (Hsla, Hsla, Hsla, Pixels, Pixels) { + ( + cx.theme().scrollbar_thumb, + cx.theme().scrollbar, + cx.theme().border, + THUMB_INSET - px(1.), + THUMB_RADIUS, + ) + } + + fn style_for_hovered_bar(cx: &AppContext) -> (Hsla, Hsla, Hsla, Pixels, Pixels) { + let (inset, radius) = if cx.theme().scrollbar_show.is_hover() { + (THUMB_INSET, THUMB_RADIUS - px(1.)) + } else { + (THUMB_INSET - px(1.), THUMB_RADIUS) + }; + + ( + cx.theme().scrollbar_thumb.opacity(NORMAL_OPACITY), + gpui::transparent_black(), + gpui::transparent_black(), + inset, + radius, + ) + } + + fn style_for_idle(_: &AppContext) -> (Hsla, Hsla, Hsla, Pixels, Pixels) { + ( + gpui::transparent_black(), + gpui::transparent_black(), + gpui::transparent_black(), + THUMB_INSET, + THUMB_RADIUS - px(1.), + ) + } } impl IntoElement for Scrollbar { @@ -326,7 +408,6 @@ impl Element for Scrollbar { let mut states = vec![]; let mut has_both = self.axis.is_both(); - const NORMAL_OPACITY: f32 = 0.6; for axis in self.axis.all().into_iter() { let is_vertical = axis.is_vertical(); @@ -390,49 +471,45 @@ impl Element for Scrollbar { }; let state = self.state.clone(); + let is_hover_to_show = cx.theme().scrollbar_show.is_hover(); + let is_hovered_on_bar = state.get().hovered_axis == Some(axis); + let is_hovered_on_thumb = state.get().hovered_on_thumb == Some(axis); + let (thumb_bg, bar_bg, bar_border, inset, radius) = if state.get().dragged_axis == Some(axis) { - ( - cx.theme().scrollbar_thumb, - cx.theme().scrollbar, - cx.theme().border, - THUMB_INSET - px(1.), - THUMB_RADIUS, - ) - } else if state.get().hovered_axis == Some(axis) { - if state.get().hovered_on_thumb == Some(axis) { - ( - cx.theme().scrollbar_thumb, - cx.theme().scrollbar, - cx.theme().border, - THUMB_INSET - px(1.), - THUMB_RADIUS, - ) + Self::style_for_default(cx) + } else if is_hover_to_show && is_hovered_on_bar { + if is_hovered_on_thumb { + Self::style_for_hovered_thumb(cx) } else { - ( - cx.theme().scrollbar_thumb.opacity(NORMAL_OPACITY), - gpui::transparent_black(), - gpui::transparent_black(), - THUMB_INSET, - THUMB_RADIUS, - ) + Self::style_for_hovered_bar(cx) } } else { - let mut idle_state = ( - gpui::transparent_black(), - gpui::transparent_black(), - gpui::transparent_black(), - THUMB_INSET, - THUMB_RADIUS - px(1.), - ); + let mut idle_state = Self::style_for_idle(cx); + // Delay 2s to fade out the scrollbar thumb (in 1s) if let Some(last_time) = state.get().last_scroll_time { let elapsed = Instant::now().duration_since(last_time).as_secs_f32(); - if elapsed < 1.0 { - let y_value = NORMAL_OPACITY - elapsed.powi(10); // y = 1 - x^10 - idle_state.0 = cx.theme().scrollbar_thumb.opacity(y_value); - cx.request_animation_frame(); + if elapsed < FADE_OUT_DURATION { + if is_hovered_on_bar { + state.set(state.get().with_last_scroll_time(Some(Instant::now()))); + idle_state = if is_hovered_on_thumb { + Self::style_for_hovered_thumb(cx) + } else { + Self::style_for_hovered_bar(cx) + }; + } else { + let y_value = if elapsed < FADE_OUT_DELAY { + NORMAL_OPACITY + } else { + // y = 1 - (x - 2)^10 + NORMAL_OPACITY - (elapsed - FADE_OUT_DELAY).powi(10) + }; + idle_state.0 = cx.theme().scrollbar_thumb.opacity(y_value); + cx.request_animation_frame(); + } } } + idle_state }; @@ -494,6 +571,8 @@ impl Element for Scrollbar { cx: &mut gpui::WindowContext, ) { let hitbox_bounds = prepaint.hitbox.bounds; + let is_visible = self.state.get().is_scrollbar_visible(); + let is_hover_to_show = cx.theme().scrollbar_show.is_hover(); for state in prepaint.states.iter() { let axis = state.axis; @@ -557,52 +636,54 @@ impl Element for Scrollbar { let safe_range = (-scroll_area_size + container_size)..px(0.); - cx.on_mouse_event({ - let state = self.state.clone(); - let view_id = self.view_id; - let scroll_handle = self.scroll_handle.clone(); + if is_hover_to_show || is_visible { + cx.on_mouse_event({ + let state = self.state.clone(); + let view_id = self.view_id; + let scroll_handle = self.scroll_handle.clone(); - move |event: &MouseDownEvent, phase, cx| { - if phase.bubble() && bounds.contains(&event.position) { - cx.stop_propagation(); + move |event: &MouseDownEvent, phase, cx| { + if phase.bubble() && bounds.contains(&event.position) { + cx.stop_propagation(); - if thumb_bounds.contains(&event.position) { - // click on the thumb bar, set the drag position - let pos = event.position - thumb_bounds.origin; + if thumb_bounds.contains(&event.position) { + // click on the thumb bar, set the drag position + let pos = event.position - thumb_bounds.origin; - state.set(state.get().with_drag_pos(axis, pos)); + state.set(state.get().with_drag_pos(axis, pos)); - cx.notify(Some(view_id)); - } else { - // click on the scrollbar, jump to the position - // Set the thumb bar center to the click position - let offset = scroll_handle.offset(); - let percentage = if is_vertical { - (event.position.y - thumb_size / 2. - bounds.origin.y) - / (bounds.size.height - thumb_size) + cx.notify(Some(view_id)); } else { - (event.position.x - thumb_size / 2. - bounds.origin.x) - / (bounds.size.width - thumb_size) - } - .min(1.); + // click on the scrollbar, jump to the position + // Set the thumb bar center to the click position + let offset = scroll_handle.offset(); + let percentage = if is_vertical { + (event.position.y - thumb_size / 2. - bounds.origin.y) + / (bounds.size.height - thumb_size) + } else { + (event.position.x - thumb_size / 2. - bounds.origin.x) + / (bounds.size.width - thumb_size) + } + .min(1.); - if is_vertical { - scroll_handle.set_offset(point( - offset.x, - (-scroll_area_size * percentage) - .clamp(safe_range.start, safe_range.end), - )); - } else { - scroll_handle.set_offset(point( - (-scroll_area_size * percentage) - .clamp(safe_range.start, safe_range.end), - offset.y, - )); + if is_vertical { + scroll_handle.set_offset(point( + offset.x, + (-scroll_area_size * percentage) + .clamp(safe_range.start, safe_range.end), + )); + } else { + scroll_handle.set_offset(point( + (-scroll_area_size * percentage) + .clamp(safe_range.start, safe_range.end), + offset.y, + )); + } } } } - } - }); + }); + } cx.on_mouse_event({ let scroll_handle = self.scroll_handle.clone(); diff --git a/crates/ui/src/theme.rs b/crates/ui/src/theme.rs index fb7d2877..cd51b1ad 100644 --- a/crates/ui/src/theme.rs +++ b/crates/ui/src/theme.rs @@ -5,6 +5,8 @@ use gpui::{ ViewContext, WindowAppearance, WindowContext, }; +use crate::scroll::ScrollbarShow; + pub fn init(cx: &mut AppContext) { Theme::sync_system_appearance(cx) } @@ -373,6 +375,8 @@ pub struct Theme { pub radius: f32, pub shadow: bool, pub transparent: Hsla, + /// Show the scrollbar mode, default: Scrolling + pub scrollbar_show: ScrollbarShow, } impl Deref for Theme { @@ -518,6 +522,7 @@ impl From for Theme { }, radius: 4.0, shadow: true, + scrollbar_show: ScrollbarShow::default(), colors, } }