use gpui::{
HitboxBehavior, IntoElement as _, ListState, Pixels, ScrollWheelEvent, Styled as _, canvas, px,
};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum ScrollbarVisibility {
Scrolling,
Hover,
#[default]
Always,
}
impl ScrollbarVisibility {
fn upstream(self) -> gpui_component::scroll::ScrollbarMode {
match self {
Self::Scrolling => gpui_component::scroll::ScrollbarMode::Scrolling,
Self::Hover => gpui_component::scroll::ScrollbarMode::Hover,
Self::Always => gpui_component::scroll::ScrollbarMode::Always,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum ScrollbarPlacement {
#[default]
Overlay,
Gutter,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ScrollbarTokens {
visibility: ScrollbarVisibility,
placement: ScrollbarPlacement,
}
impl gpui::Global for ScrollbarTokens {}
impl Default for ScrollbarTokens {
fn default() -> Self {
Self::DEFAULT
}
}
impl ScrollbarTokens {
pub const DEFAULT: Self = Self {
visibility: ScrollbarVisibility::Always,
placement: ScrollbarPlacement::Overlay,
};
pub fn read(cx: &gpui::App) -> Self {
cx.try_global::<Self>().copied().unwrap_or(Self::DEFAULT)
}
pub fn set(self, cx: &mut gpui::App) {
cx.set_global(self);
self.project_onto_theme(cx);
}
fn project_onto_theme(self, cx: &mut gpui::App) {
if !cx.has_global::<gpui_component::Theme>() {
return;
}
gpui_component::Theme::set_scrollbar_mode(self.visibility.upstream(), cx);
gpui_component::Theme::sync_base(cx);
}
pub const fn visibility(&self) -> ScrollbarVisibility {
self.visibility
}
pub const fn placement(&self) -> ScrollbarPlacement {
self.placement
}
pub const fn with_visibility(mut self, visibility: ScrollbarVisibility) -> Self {
self.visibility = visibility;
self
}
pub const fn with_placement(mut self, placement: ScrollbarPlacement) -> Self {
self.placement = placement;
self
}
}
pub(crate) fn install(cx: &mut gpui::App) {
let tokens = ScrollbarTokens::read(cx);
tokens.set(cx);
}
fn scrollbar_gutter(cx: &gpui::App) -> Pixels {
gpui_base::Theme::global(cx).tokens.spacing.lg
}
pub(crate) fn control_lane(cx: &gpui::App) -> Pixels {
match ScrollbarTokens::read(cx).placement() {
ScrollbarPlacement::Overlay => gpui_base::Scrollbar::width(),
ScrollbarPlacement::Gutter => Pixels::ZERO,
}
}
pub(crate) trait PolicyScrollbarExt: Sized {
fn policy_vertical_scrollbar<H>(self, handle: &H, cx: &gpui::App) -> Self
where
H: gpui_component::scroll::ScrollbarHandle + Clone + 'static;
fn policy_horizontal_scrollbar<H>(self, handle: &H, cx: &gpui::App) -> Self
where
H: gpui_component::scroll::ScrollbarHandle + Clone + 'static;
}
impl<E> PolicyScrollbarExt for E
where
E: gpui::ParentElement + gpui::Styled + Sized,
{
fn policy_vertical_scrollbar<H>(self, handle: &H, cx: &gpui::App) -> Self
where
H: gpui_component::scroll::ScrollbarHandle + Clone + 'static,
{
let tokens = ScrollbarTokens::read(cx);
let element = match tokens.placement() {
ScrollbarPlacement::Overlay => self,
ScrollbarPlacement::Gutter => self.pr(scrollbar_gutter(cx)),
};
element.child(gpui::ParentElement::child(
gpui::div().absolute().inset_0(),
gpui_component::scroll::Scrollbar::new(handle)
.axis(gpui_component::scroll::ScrollbarAxis::Vertical)
.mode(tokens.visibility().upstream())
.viewport_from_layout(),
))
}
fn policy_horizontal_scrollbar<H>(self, handle: &H, cx: &gpui::App) -> Self
where
H: gpui_component::scroll::ScrollbarHandle + Clone + 'static,
{
let tokens = ScrollbarTokens::read(cx);
let element = match tokens.placement() {
ScrollbarPlacement::Overlay => self,
ScrollbarPlacement::Gutter => self.pb(scrollbar_gutter(cx)),
};
element.child(gpui::ParentElement::child(
gpui::div().absolute().inset_0(),
gpui_component::scroll::Scrollbar::new(handle)
.axis(gpui_component::scroll::ScrollbarAxis::Horizontal)
.mode(tokens.visibility().upstream())
.viewport_from_layout(),
))
}
}
pub(crate) fn list_scroll_mask(state: &ListState) -> impl gpui::IntoElement {
let state = state.clone();
canvas(
|bounds, window, _| window.insert_hitbox(bounds, HitboxBehavior::Normal),
move |_, hitbox, window, _| {
let line_height = window.line_height();
let view_id = window.current_view();
let hitbox_id = hitbox.id;
window.on_mouse_event(move |event: &ScrollWheelEvent, phase, window, cx| {
if !(phase.capture() && hitbox_id.should_handle_scroll(window)) {
return;
}
let delta_y = event.delta.pixel_delta(line_height).y;
let leaving_tail = delta_y > Pixels::ZERO && state.is_following_tail();
if !leaving_tail && ScrollRoom::from_list_state(&state).can_absorb(delta_y) {
state.scroll_by(-delta_y);
cx.notify(view_id);
cx.stop_propagation();
}
});
},
)
.absolute()
.inset_0()
.into_any_element()
}
pub const WHEEL_SENSITIVITY: f32 = 1.0;
pub const WHEEL_FAST_SENSITIVITY: f32 = 4.0;
pub const WHEEL_ACCEL_NOTCHES_TO_FULL: f32 = 5.0;
pub const WHEEL_ACCEL_MAX_MULTIPLIER: f32 = 3.0;
pub const WHEEL_ACCEL_DECAY_SECONDS: f32 = 0.35;
pub const LINE_HEIGHT_PX: f32 = 20.0;
pub const AUTOSCROLL_FULL_SPEED_DISTANCE_PX: f32 = 140.0;
pub const MAX_AUTOSCROLL_SPEED_PX_PER_SEC: f32 = 900.0;
#[derive(Debug, Clone, Copy)]
pub struct WheelAccelerator {
cadence: f32,
decayed: bool,
}
impl Default for WheelAccelerator {
fn default() -> Self {
Self::new()
}
}
impl WheelAccelerator {
pub fn new() -> Self {
Self {
cadence: 0.0,
decayed: true,
}
}
pub fn line_notch(&mut self, dy: f32, fast: bool) -> Pixels {
let now_decaying = self.decayed;
self.decayed = false;
if now_decaying {
self.cadence = 0.0;
}
if dy * self.cadence < 0.0 {
self.cadence = 0.0;
}
self.cadence += dy.signum();
let strength = (self.cadence.abs() / WHEEL_ACCEL_NOTCHES_TO_FULL).min(1.0);
let accel = 1.0 + (WHEEL_ACCEL_MAX_MULTIPLIER - 1.0) * strength;
let sensitivity = if fast {
WHEEL_FAST_SENSITIVITY
} else {
WHEEL_SENSITIVITY
};
let boost = -dy * sensitivity * (accel - 1.0);
gpui::px(boost * LINE_HEIGHT_PX)
}
pub fn rest(&mut self) {
self.cadence = 0.0;
self.decayed = true;
}
pub fn cadence(&self) -> f32 {
self.cadence
}
}
#[derive(Debug, Clone, Copy)]
pub struct Autoscroll {
anchor_y: Pixels,
pointer_y: Pixels,
}
impl Autoscroll {
pub fn start(anchor: gpui::Point<Pixels>) -> Self {
Self {
anchor_y: anchor.y,
pointer_y: anchor.y,
}
}
pub fn track(&mut self, pointer: gpui::Point<Pixels>) {
self.pointer_y = pointer.y;
}
pub fn tick(&mut self, delta_seconds: f32) -> Pixels {
let offset = self.pointer_y - self.anchor_y;
let distance = offset.abs();
if distance < gpui::px(1.) {
return gpui::px(0.);
}
let direction = offset.signum();
let speed = ((distance / gpui::px(AUTOSCROLL_FULL_SPEED_DISTANCE_PX)).min(1.0)
* MAX_AUTOSCROLL_SPEED_PX_PER_SEC) as f32;
gpui::px(direction * speed * delta_seconds)
}
}
#[derive(Debug, Clone, Copy)]
pub struct ScrollRoom {
pub offset: Pixels,
pub max_offset: Pixels,
}
impl ScrollRoom {
pub fn new(offset: Pixels, max_offset: Pixels) -> Self {
Self {
offset,
max_offset: max_offset.max(px(0.)),
}
}
pub fn can_absorb(&self, delta_y: Pixels) -> bool {
let zero = Pixels::ZERO;
if self.max_offset <= zero {
return false;
}
if delta_y < zero {
self.offset > -self.max_offset + px(0.5)
} else if delta_y > zero {
self.offset < -px(0.5)
} else {
false
}
}
pub fn from_handle(handle: &gpui::ScrollHandle) -> Self {
Self::new(handle.offset().y, handle.max_offset().y)
}
pub fn from_list_state(state: &gpui::ListState) -> Self {
Self::new(
state.scroll_px_offset_for_scrollbar().y,
state.max_offset_for_scrollbar().y,
)
}
}
#[cfg(test)]
mod scrollbar_policy_tests {
use super::*;
use gpui::TestAppContext;
#[gpui::test]
fn the_default_policy_projects_always_visible_onto_the_theme(cx: &mut TestAppContext) {
cx.update(crate::init);
cx.update(|cx| {
assert_eq!(
ScrollbarTokens::read(cx).visibility(),
ScrollbarVisibility::Always
);
assert_eq!(
gpui_base::Theme::global(cx).scrollbar.mode(),
gpui_component::scroll::ScrollbarMode::Always
);
});
}
#[gpui::test]
fn the_projection_survives_a_theme_change(cx: &mut TestAppContext) {
cx.update(crate::init);
cx.update(|cx| {
gpui_component::Theme::change(gpui_component::ThemeMode::Dark, None, cx);
assert_eq!(
gpui_base::Theme::global(cx).scrollbar.mode(),
gpui_component::scroll::ScrollbarMode::Always,
"a theme swap must not silently drop the scrollbar policy"
);
});
}
#[gpui::test]
fn install_respects_a_policy_the_application_chose_first(cx: &mut TestAppContext) {
cx.update(|cx| {
ScrollbarTokens::default()
.with_visibility(ScrollbarVisibility::Scrolling)
.set(cx);
crate::init(cx);
});
cx.update(|cx| {
assert_eq!(
ScrollbarTokens::read(cx).visibility(),
ScrollbarVisibility::Scrolling
);
assert_eq!(
gpui_base::Theme::global(cx).scrollbar.mode(),
gpui_component::scroll::ScrollbarMode::Scrolling
);
});
}
}
#[cfg(test)]
mod tests {
use super::*;
fn px_f(value: Pixels) -> f32 {
value.as_f32()
}
#[test]
fn first_notch_applies_ramp_in_boost() {
let mut wheel = WheelAccelerator::new();
let expected =
((WHEEL_ACCEL_MAX_MULTIPLIER - 1.0) / WHEEL_ACCEL_NOTCHES_TO_FULL) * LINE_HEIGHT_PX;
assert!((px_f(wheel.line_notch(-1.0, false)) - expected).abs() < 0.01);
}
#[test]
fn cadence_ramps_toward_max_multiplier() {
let mut wheel = WheelAccelerator::new();
wheel.line_notch(-1.0, false);
let mut boosts = Vec::new();
for _ in 0..6 {
boosts.push(px_f(wheel.line_notch(-1.0, false)));
}
assert!(boosts.windows(2).all(|pair| pair[0] <= pair[1]));
assert!(boosts[0] > 0.0);
let expected_full = (WHEEL_ACCEL_MAX_MULTIPLIER - 1.0) * LINE_HEIGHT_PX;
assert!((boosts[5] - expected_full).abs() < 0.01);
}
#[test]
fn direction_reversal_resets_cadence() {
let mut wheel = WheelAccelerator::new();
for _ in 0..8 {
wheel.line_notch(-1.0, false);
}
assert!(wheel.cadence() < 0.0);
let up_boost = px_f(wheel.line_notch(1.0, false));
let fresh = px_f(WheelAccelerator::new().line_notch(1.0, false));
assert!((up_boost - fresh).abs() < 0.01);
}
#[test]
fn rest_clears_cadence() {
let mut wheel = WheelAccelerator::new();
for _ in 0..8 {
wheel.line_notch(-1.0, false);
}
assert!(wheel.cadence() < 0.0);
wheel.rest();
assert_eq!(wheel.cadence(), 0.0);
let expected =
((WHEEL_ACCEL_MAX_MULTIPLIER - 1.0) / WHEEL_ACCEL_NOTCHES_TO_FULL) * LINE_HEIGHT_PX;
assert!((px_f(wheel.line_notch(-1.0, false)) - expected).abs() < 0.01);
}
#[test]
fn fast_multiplies_the_ramp() {
let mut base = WheelAccelerator::new();
let mut fast = WheelAccelerator::new();
base.line_notch(-1.0, false);
fast.line_notch(-1.0, false);
let base_boost = px_f(base.line_notch(-1.0, false));
let fast_boost = px_f(fast.line_notch(-1.0, true));
assert!(
(fast_boost / base_boost - WHEEL_FAST_SENSITIVITY / WHEEL_SENSITIVITY).abs() < 0.01
);
}
#[test]
fn autoscroll_speed_scales_with_distance_then_caps() {
let anchor = gpui::point(gpui::px(100.), gpui::px(200.));
let mut session = Autoscroll::start(anchor);
session.track(gpui::point(gpui::px(100.), gpui::px(240.)));
let near = px_f(session.tick(0.016));
session.track(gpui::point(gpui::px(100.), gpui::px(480.)));
let far = px_f(session.tick(0.016));
session.track(gpui::point(gpui::px(100.), gpui::px(900.)));
let farther = px_f(session.tick(0.016));
assert!(near > 0.0 && far > near && farther >= far);
let capped_a = px_f(session.tick(0.016));
session.track(gpui::point(gpui::px(100.), gpui::px(1200.)));
let capped_b = px_f(session.tick(0.016));
assert!((capped_a - capped_b).abs() < 0.001);
}
#[test]
fn autoscroll_near_anchor_is_zero_and_sign_follows_pointer() {
let anchor = gpui::point(gpui::px(50.), gpui::px(500.));
let mut session = Autoscroll::start(anchor);
assert_eq!(session.tick(0.016), gpui::px(0.));
session.track(gpui::point(gpui::px(50.), gpui::px(600.)));
assert!(px_f(session.tick(0.016)) > 0.0);
session.track(gpui::point(gpui::px(50.), gpui::px(400.)));
assert!(px_f(session.tick(0.016)) < 0.0);
}
#[test]
fn scroll_room_absorbs_only_when_room_remains_in_direction() {
let mid = ScrollRoom::new(px(-100.), px(300.));
assert!(mid.can_absorb(px(-40.)));
assert!(mid.can_absorb(px(40.)));
let at_top = ScrollRoom::new(px(0.), px(300.));
assert!(!at_top.can_absorb(px(40.)), "at top, upward must chain out");
assert!(at_top.can_absorb(px(-40.)));
let at_bottom = ScrollRoom::new(px(-300.), px(300.));
assert!(
!at_bottom.can_absorb(px(-40.)),
"at bottom, downward must chain out"
);
assert!(at_bottom.can_absorb(px(40.)));
let empty = ScrollRoom::new(px(0.), px(0.));
assert!(!empty.can_absorb(px(-40.)));
assert!(!empty.can_absorb(px(40.)));
}
}