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},
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<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;
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 {
fn render(&mut self, cx: &mut ViewContext<Self>) -> 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),
)

View file

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

View file

@ -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<Pixels>;
@ -92,6 +110,9 @@ impl ScrollbarState {
fn with_hovered(&self, axis: Option<ScrollbarAxis>) -> 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<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)]
@ -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();

View file

@ -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<ThemeColor> for Theme {
},
radius: 4.0,
shadow: true,
scrollbar_show: ScrollbarShow::default(),
colors,
}
}