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)
This commit is contained in:
Jason Lee 2024-12-10 16:18:38 +08:00 committed by GitHub
parent 26024ba2b5
commit 2836540260
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 198 additions and 83 deletions

View file

@ -14,6 +14,7 @@ use ui::{
dock::{DockArea, DockAreaState, DockEvent, DockItem, DockPlacement}, dock::{DockArea, DockAreaState, DockEvent, DockItem, DockPlacement},
h_flex, h_flex,
popup_menu::PopupMenuExt, popup_menu::PopupMenuExt,
scroll::ScrollbarShow,
theme::{ActiveTheme, Theme}, theme::{ActiveTheme, Theme},
ContextModal, IconName, Root, Sizable, TitleBar, ContextModal, IconName, Root, Sizable, TitleBar,
}; };
@ -25,6 +26,9 @@ const MAIN_DOCK_AREA: DockAreaTab = DockAreaTab {
version: 5, version: 5,
}; };
#[derive(Clone, PartialEq, Eq, Deserialize)]
struct SelectScrollbarShow(ScrollbarShow);
#[derive(Clone, PartialEq, Eq, Deserialize)] #[derive(Clone, PartialEq, Eq, Deserialize)]
struct SelectLocale(SharedString); struct SelectLocale(SharedString);
@ -34,7 +38,10 @@ struct SelectFont(usize);
#[derive(Clone, PartialEq, Eq, Deserialize)] #[derive(Clone, PartialEq, Eq, Deserialize)]
struct AddPanel(DockPlacement); struct AddPanel(DockPlacement);
impl_actions!(story, [SelectLocale, SelectFont, AddPanel]); impl_actions!(
story,
[SelectLocale, SelectFont, AddPanel, SelectScrollbarShow]
);
actions!(workspace, [Open, CloseWindow]); actions!(workspace, [Open, CloseWindow]);
@ -573,30 +580,52 @@ impl FontSizeSelector {
} }
} }
fn on_select(&mut self, font_size: &SelectFont, cx: &mut ViewContext<Self>) { fn on_select_font(&mut self, font_size: &SelectFont, cx: &mut ViewContext<Self>) {
Theme::global_mut(cx).font_size = font_size.0 as f32; Theme::global_mut(cx).font_size = font_size.0 as f32;
cx.refresh(); cx.refresh();
} }
fn on_select_scrollbar_show(&mut self, show: &SelectScrollbarShow, cx: &mut ViewContext<Self>) {
Theme::global_mut(cx).scrollbar_show = show.0;
cx.refresh();
}
} }
impl Render for FontSizeSelector { impl Render for FontSizeSelector {
fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement { fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
let focus_handle = self.focus_handle.clone(); let focus_handle = self.focus_handle.clone();
let font_size = cx.theme().font_size as i32; let font_size = cx.theme().font_size as i32;
let scroll_show = cx.theme().scrollbar_show;
div() div()
.id("font-size-selector") .id("font-size-selector")
.track_focus(&focus_handle) .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( .child(
Button::new("btn") Button::new("btn")
.small() .small()
.ghost() .ghost()
.icon(IconName::ALargeSmall) .icon(IconName::Settings2)
.popup_menu(move |this, _| { .popup_menu(move |this, _| {
this.menu_with_check("Large", font_size == 18, Box::new(SelectFont(18))) this.menu_with_check(
.menu_with_check("Default", font_size == 16, Box::new(SelectFont(16))) "Font Large",
.menu_with_check("Small", font_size == 14, Box::new(SelectFont(14))) 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), .anchor(AnchorCorner::TopRight),
) )

View file

@ -1249,7 +1249,7 @@ impl Render for TextInput {
.absolute() .absolute()
.top_0() .top_0()
.left_0() .left_0()
.right_0() .right(px(1.))
.bottom_0() .bottom_0()
.child( .child(
Scrollbar::vertical( Scrollbar::vertical(

View file

@ -2,14 +2,32 @@ use std::{cell::Cell, rc::Rc, time::Instant};
use crate::theme::ActiveTheme; use crate::theme::ActiveTheme;
use gpui::{ use gpui::{
fill, point, px, relative, Bounds, ContentMask, CursorStyle, Edges, Element, EntityId, Hitbox, fill, point, px, relative, AppContext, Bounds, ContentMask, CursorStyle, Edges, Element,
Hsla, IntoElement, MouseDownEvent, MouseMoveEvent, MouseUpEvent, PaintQuad, Pixels, Point, EntityId, Hitbox, Hsla, IntoElement, MouseDownEvent, MouseMoveEvent, MouseUpEvent, PaintQuad,
Position, ScrollHandle, ScrollWheelEvent, Style, UniformListScrollHandle, 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 MIN_THUMB_SIZE: f32 = 80.;
const THUMB_RADIUS: Pixels = Pixels(3.0); const THUMB_RADIUS: Pixels = Pixels(3.0);
const THUMB_INSET: Pixels = Pixels(4.); 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 { pub trait ScrollHandleOffsetable {
fn offset(&self) -> Point<Pixels>; fn offset(&self) -> Point<Pixels>;
@ -92,6 +110,9 @@ impl ScrollbarState {
fn with_hovered(&self, axis: Option<ScrollbarAxis>) -> Self { fn with_hovered(&self, axis: Option<ScrollbarAxis>) -> Self {
let mut state = *self; let mut state = *self;
state.hovered_axis = axis; state.hovered_axis = axis;
if self.is_scrollbar_visible() {
state.last_scroll_time = Some(Instant::now());
}
state state
} }
@ -111,6 +132,21 @@ impl ScrollbarState {
state.last_scroll_time = last_scroll_time; state.last_scroll_time = last_scroll_time;
state state
} }
fn with_last_scroll_time(&self, t: Option<Instant>) -> 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)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
@ -257,6 +293,52 @@ impl Scrollbar {
self.axis = axis; self.axis = axis;
self 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 { impl IntoElement for Scrollbar {
@ -326,7 +408,6 @@ impl Element for Scrollbar {
let mut states = vec![]; let mut states = vec![];
let mut has_both = self.axis.is_both(); let mut has_both = self.axis.is_both();
const NORMAL_OPACITY: f32 = 0.6;
for axis in self.axis.all().into_iter() { for axis in self.axis.all().into_iter() {
let is_vertical = axis.is_vertical(); let is_vertical = axis.is_vertical();
@ -390,49 +471,45 @@ impl Element for Scrollbar {
}; };
let state = self.state.clone(); 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) = let (thumb_bg, bar_bg, bar_border, inset, radius) =
if state.get().dragged_axis == Some(axis) { if state.get().dragged_axis == Some(axis) {
( Self::style_for_default(cx)
cx.theme().scrollbar_thumb, } else if is_hover_to_show && is_hovered_on_bar {
cx.theme().scrollbar, if is_hovered_on_thumb {
cx.theme().border, Self::style_for_hovered_thumb(cx)
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,
)
} else { } else {
( Self::style_for_hovered_bar(cx)
cx.theme().scrollbar_thumb.opacity(NORMAL_OPACITY),
gpui::transparent_black(),
gpui::transparent_black(),
THUMB_INSET,
THUMB_RADIUS,
)
} }
} else { } else {
let mut idle_state = ( let mut idle_state = Self::style_for_idle(cx);
gpui::transparent_black(), // Delay 2s to fade out the scrollbar thumb (in 1s)
gpui::transparent_black(),
gpui::transparent_black(),
THUMB_INSET,
THUMB_RADIUS - px(1.),
);
if let Some(last_time) = state.get().last_scroll_time { if let Some(last_time) = state.get().last_scroll_time {
let elapsed = Instant::now().duration_since(last_time).as_secs_f32(); let elapsed = Instant::now().duration_since(last_time).as_secs_f32();
if elapsed < 1.0 { if elapsed < FADE_OUT_DURATION {
let y_value = NORMAL_OPACITY - elapsed.powi(10); // y = 1 - x^10 if is_hovered_on_bar {
idle_state.0 = cx.theme().scrollbar_thumb.opacity(y_value); state.set(state.get().with_last_scroll_time(Some(Instant::now())));
cx.request_animation_frame(); 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 idle_state
}; };
@ -494,6 +571,8 @@ impl Element for Scrollbar {
cx: &mut gpui::WindowContext, cx: &mut gpui::WindowContext,
) { ) {
let hitbox_bounds = prepaint.hitbox.bounds; 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() { for state in prepaint.states.iter() {
let axis = state.axis; let axis = state.axis;
@ -557,52 +636,54 @@ impl Element for Scrollbar {
let safe_range = (-scroll_area_size + container_size)..px(0.); let safe_range = (-scroll_area_size + container_size)..px(0.);
cx.on_mouse_event({ if is_hover_to_show || is_visible {
let state = self.state.clone(); cx.on_mouse_event({
let view_id = self.view_id; let state = self.state.clone();
let scroll_handle = self.scroll_handle.clone(); let view_id = self.view_id;
let scroll_handle = self.scroll_handle.clone();
move |event: &MouseDownEvent, phase, cx| { move |event: &MouseDownEvent, phase, cx| {
if phase.bubble() && bounds.contains(&event.position) { if phase.bubble() && bounds.contains(&event.position) {
cx.stop_propagation(); cx.stop_propagation();
if thumb_bounds.contains(&event.position) { if thumb_bounds.contains(&event.position) {
// click on the thumb bar, set the drag position // click on the thumb bar, set the drag position
let pos = event.position - thumb_bounds.origin; 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)); 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)
} else { } else {
(event.position.x - thumb_size / 2. - bounds.origin.x) // click on the scrollbar, jump to the position
/ (bounds.size.width - thumb_size) // Set the thumb bar center to the click position
} let offset = scroll_handle.offset();
.min(1.); 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 { if is_vertical {
scroll_handle.set_offset(point( scroll_handle.set_offset(point(
offset.x, offset.x,
(-scroll_area_size * percentage) (-scroll_area_size * percentage)
.clamp(safe_range.start, safe_range.end), .clamp(safe_range.start, safe_range.end),
)); ));
} else { } else {
scroll_handle.set_offset(point( scroll_handle.set_offset(point(
(-scroll_area_size * percentage) (-scroll_area_size * percentage)
.clamp(safe_range.start, safe_range.end), .clamp(safe_range.start, safe_range.end),
offset.y, offset.y,
)); ));
}
} }
} }
} }
} });
}); }
cx.on_mouse_event({ cx.on_mouse_event({
let scroll_handle = self.scroll_handle.clone(); let scroll_handle = self.scroll_handle.clone();

View file

@ -5,6 +5,8 @@ use gpui::{
ViewContext, WindowAppearance, WindowContext, ViewContext, WindowAppearance, WindowContext,
}; };
use crate::scroll::ScrollbarShow;
pub fn init(cx: &mut AppContext) { pub fn init(cx: &mut AppContext) {
Theme::sync_system_appearance(cx) Theme::sync_system_appearance(cx)
} }
@ -373,6 +375,8 @@ pub struct Theme {
pub radius: f32, pub radius: f32,
pub shadow: bool, pub shadow: bool,
pub transparent: Hsla, pub transparent: Hsla,
/// Show the scrollbar mode, default: Scrolling
pub scrollbar_show: ScrollbarShow,
} }
impl Deref for Theme { impl Deref for Theme {
@ -518,6 +522,7 @@ impl From<ThemeColor> for Theme {
}, },
radius: 4.0, radius: 4.0,
shadow: true, shadow: true,
scrollbar_show: ScrollbarShow::default(),
colors, colors,
} }
} }