From dbd8efe129e2d3424e8bf146a0c3d19057b24c00 Mon Sep 17 00:00:00 2001 From: Finn Evers Date: Thu, 2 Oct 2025 19:11:46 +0200 Subject: [PATCH] ui: Implement graceful autohiding for scrollbars (#39225) How it looks: https://github.com/user-attachments/assets/9a355807-5461-4e8d-b7a8-9efb98cea67a Idea behind this is to reduce flickering in areas where nothing is happening - whenever these hide, the user is specifically not interacting with them, hence it can be distracting to have something flicker in the side of your eye. This PR tackles this. Release Notes: - Added graceful autohiding to scrollbars outside of the editor --------- Co-authored-by: Danilo Leal <67129314+danilo-leal@users.noreply.github.com> --- crates/editor/src/signature_help.rs | 2 +- crates/ui/src/components/scrollbar.rs | 274 +++++++++++++++++++------- 2 files changed, 200 insertions(+), 76 deletions(-) diff --git a/crates/editor/src/signature_help.rs b/crates/editor/src/signature_help.rs index 54d8a50115b7104a2e4469c3619ef366fa229c9d..150044391a397cc2c35ffc8a85311c1470668ab1 100644 --- a/crates/editor/src/signature_help.rs +++ b/crates/editor/src/signature_help.rs @@ -389,7 +389,7 @@ impl SignatureHelpPopover { ) }), ) - .vertical_scrollbar(window, cx); + .vertical_scrollbar_for(self.scroll_handle.clone(), window, cx); let controls = if self.signatures.len() > 1 { let prev_button = IconButton::new("signature_help_prev", IconName::ChevronUp) diff --git a/crates/ui/src/components/scrollbar.rs b/crates/ui/src/components/scrollbar.rs index f3c81ab2bf9d6b6799f8a16d2ee72bfbc497eb1b..4a3de725d6a58abe89d18220bae6c5c82797348b 100644 --- a/crates/ui/src/components/scrollbar.rs +++ b/crates/ui/src/components/scrollbar.rs @@ -1,4 +1,9 @@ -use std::{any::Any, fmt::Debug, ops::Not, time::Duration}; +use std::{ + any::Any, + fmt::Debug, + ops::Not, + time::{Duration, Instant}, +}; use gpui::{ Along, App, AppContext as _, Axis as ScrollbarAxis, BorderStyle, Bounds, ContentMask, Context, @@ -7,7 +12,8 @@ use gpui::{ LayoutId, ListState, MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, Negate, ParentElement, Pixels, Point, Position, Render, ScrollHandle, ScrollWheelEvent, Size, Stateful, StatefulInteractiveElement, Style, Styled, Task, UniformListDecoration, - UniformListScrollHandle, Window, prelude::FluentBuilder as _, px, quad, relative, size, + UniformListScrollHandle, Window, ease_in_out, prelude::FluentBuilder as _, px, quad, relative, + size, }; use settings::SettingsStore; use smallvec::SmallVec; @@ -16,9 +22,12 @@ use util::ResultExt; use std::ops::Range; -use crate::scrollbars::{ScrollbarVisibility, ShowScrollbar}; +use crate::scrollbars::{ScrollbarAutoHide, ScrollbarVisibility, ShowScrollbar}; + +const SCROLLBAR_HIDE_DELAY_INTERVAL: Duration = Duration::from_secs(1); +const SCROLLBAR_HIDE_DURATION: Duration = Duration::from_millis(400); +const SCROLLBAR_SHOW_DURATION: Duration = Duration::from_millis(50); -const SCROLLBAR_SHOW_INTERVAL: Duration = Duration::from_millis(1500); const SCROLLBAR_PADDING: Pixels = px(4.); pub mod scrollbars { @@ -56,20 +65,6 @@ pub mod scrollbars { } } - impl ShowScrollbar { - pub(super) fn show(&self) -> bool { - *self != Self::Never - } - - pub(super) fn should_auto_hide(&self, cx: &mut App) -> bool { - match self { - Self::Auto => true, - Self::System => cx.default_global::().should_hide(), - _ => false, - } - } - } - pub trait GlobalSetting { fn get_value(cx: &App) -> &Self; } @@ -127,23 +122,24 @@ pub trait WithScrollbar: Sized { where T: ScrollableHandle; - #[track_caller] - fn horizontal_scrollbar(self, window: &mut Window, cx: &mut App) -> Self::Output { - self.custom_scrollbars( - Scrollbars::new(ScrollAxes::Horizontal).ensure_id(core::panic::Location::caller()), - window, - cx, - ) - } - - #[track_caller] - fn vertical_scrollbar(self, window: &mut Window, cx: &mut App) -> Self::Output { - self.custom_scrollbars( - Scrollbars::new(ScrollAxes::Vertical).ensure_id(core::panic::Location::caller()), - window, - cx, - ) - } + // TODO: account for these cases properly + // #[track_caller] + // fn horizontal_scrollbar(self, window: &mut Window, cx: &mut App) -> Self::Output { + // self.custom_scrollbars( + // Scrollbars::new(ScrollAxes::Horizontal).ensure_id(core::panic::Location::caller()), + // window, + // cx, + // ) + // } + + // #[track_caller] + // fn vertical_scrollbar(self, window: &mut Window, cx: &mut App) -> Self::Output { + // self.custom_scrollbars( + // Scrollbars::new(ScrollAxes::Vertical).ensure_id(core::panic::Location::caller()), + // window, + // cx, + // ) + // } #[track_caller] fn vertical_scrollbar_for( @@ -290,6 +286,30 @@ impl UniformListDecoration for ScrollbarStateWrapper { // } // } +#[derive(Copy, Clone, PartialEq, Eq)] +enum ShowBehavior { + Always, + Autohide, + Never, +} + +impl ShowBehavior { + fn from_setting(setting: ShowScrollbar, cx: &mut App) -> Self { + match setting { + ShowScrollbar::Never => Self::Never, + ShowScrollbar::Auto => Self::Autohide, + ShowScrollbar::System => { + if cx.default_global::().should_hide() { + Self::Autohide + } else { + Self::Always + } + } + ShowScrollbar::Always => Self::Always, + } + } +} + pub enum ScrollAxes { Horizontal, Vertical, @@ -460,35 +480,103 @@ impl Scrollbars { } } -#[derive(PartialEq, Eq)] +#[derive(PartialEq, Clone, Debug)] enum VisibilityState { Visible, + Animating { showing: bool, delta: f32 }, Hidden, Disabled, } +const DELTA_MAX: f32 = 1.0; + impl VisibilityState { - fn from_show_setting(show_setting: ShowScrollbar) -> Self { - if show_setting.show() { - Self::Visible - } else { - Self::Disabled + fn from_behavior(behavior: ShowBehavior) -> Self { + match behavior { + ShowBehavior::Always => Self::Visible, + ShowBehavior::Never => Self::Disabled, + ShowBehavior::Autohide => Self::for_show(), + } + } + + fn for_show() -> Self { + Self::Animating { + showing: true, + delta: Default::default(), + } + } + + fn for_autohide() -> Self { + Self::Animating { + showing: Default::default(), + delta: Default::default(), } } fn is_visible(&self) -> bool { - *self == VisibilityState::Visible + matches!(self, Self::Visible | Self::Animating { .. }) } #[inline] fn is_disabled(&self) -> bool { *self == VisibilityState::Disabled } + + fn animation_progress(&self) -> Option<(f32, Duration, bool)> { + match self { + Self::Animating { showing, delta } => Some(( + *delta, + if *showing { + SCROLLBAR_SHOW_DURATION + } else { + SCROLLBAR_HIDE_DURATION + }, + *showing, + )), + _ => None, + } + } + + fn set_delta(&mut self, new_delta: f32) { + match self { + Self::Animating { showing, .. } if new_delta >= DELTA_MAX => { + if *showing { + *self = Self::Visible; + } else { + *self = Self::Hidden; + } + } + Self::Animating { delta, .. } => *delta = new_delta, + _ => {} + } + } + + fn toggle_visible(&self, show_behavior: ShowBehavior) -> Self { + match self { + Self::Hidden => { + if show_behavior == ShowBehavior::Autohide { + Self::for_show() + } else { + Self::Visible + } + } + Self::Animating { + showing: false, + delta: progress, + } => Self::Animating { + showing: true, + delta: DELTA_MAX - progress, + }, + _ => self.clone(), + } + } } -enum ParentHovered { - Yes(bool), - No(bool), +enum ParentHoverEvent { + Within, + Entered, + Exited, + Outside, } /// This is used to ensure notifies within the state do not notify the parent @@ -502,7 +590,7 @@ struct ScrollbarState { manually_added: bool, scroll_handle: T, width: ScrollbarWidth, - show_setting: ShowScrollbar, + show_behavior: ShowBehavior, get_visibility: fn(&App) -> ShowScrollbar, visibility: Point, show_state: VisibilityState, @@ -526,7 +614,7 @@ impl ScrollbarState { Handle::Untracked(func) => (false, func()), }; - let show_setting = (config.get_visibility)(cx); + let show_behavior = ShowBehavior::from_setting((config.get_visibility)(cx), cx); ScrollbarState { thumb_state: Default::default(), notify_id: config.tracked_entity.map(|id| id.unwrap_or(parent_id)), @@ -534,9 +622,9 @@ impl ScrollbarState { scroll_handle, width: config.scrollbar_width, visibility: config.visibility, - show_setting, + show_behavior, get_visibility: config.get_visibility, - show_state: VisibilityState::from_show_setting(show_setting), + show_state: VisibilityState::from_behavior(show_behavior), mouse_in_parent: true, last_prepaint_state: None, _auto_hide_task: None, @@ -544,22 +632,26 @@ impl ScrollbarState { } fn settings_changed(&mut self, window: &mut Window, cx: &mut Context) { - self.set_show_scrollbar((self.get_visibility)(cx), window, cx); + self.set_show_behavior( + ShowBehavior::from_setting((self.get_visibility)(cx), cx), + window, + cx, + ); } /// Schedules a scrollbar auto hide if no auto hide is currently in progress yet. fn schedule_auto_hide(&mut self, window: &mut Window, cx: &mut Context) { if self._auto_hide_task.is_none() { - self._auto_hide_task = - (self.visible() && self.show_setting.should_auto_hide(cx)).then(|| { + self._auto_hide_task = (self.visible() && self.show_behavior == ShowBehavior::Autohide) + .then(|| { cx.spawn_in(window, async move |scrollbar_state, cx| { cx.background_executor() - .timer(SCROLLBAR_SHOW_INTERVAL) + .timer(SCROLLBAR_HIDE_DELAY_INTERVAL) .await; scrollbar_state .update(cx, |state, cx| { if state.thumb_state == ThumbState::Inactive { - state.set_visibility(VisibilityState::Hidden, cx); + state.set_visibility(VisibilityState::for_autohide(), cx); } state._auto_hide_task.take(); }) @@ -570,20 +662,21 @@ impl ScrollbarState { } fn show_scrollbars(&mut self, window: &mut Window, cx: &mut Context) { - self.set_visibility(VisibilityState::Visible, cx); + let visibility = self.show_state.toggle_visible(self.show_behavior); + self.set_visibility(visibility, cx); self._auto_hide_task.take(); self.schedule_auto_hide(window, cx); } - fn set_show_scrollbar( + fn set_show_behavior( &mut self, - show: ShowScrollbar, + behavior: ShowBehavior, window: &mut Window, cx: &mut Context, ) { - if self.show_setting != show { - self.show_setting = show; - self.set_visibility(VisibilityState::from_show_setting(show), cx); + if self.show_behavior != behavior { + self.show_behavior = behavior; + self.set_visibility(VisibilityState::from_behavior(behavior), cx); self.schedule_auto_hide(window, cx); cx.notify(); } @@ -679,7 +772,7 @@ impl ScrollbarState { if state == ThumbState::Inactive { self.schedule_auto_hide(window, cx); } else { - self.set_visibility(VisibilityState::Visible, cx); + self.set_visibility(self.show_state.toggle_visible(self.show_behavior), cx); self._auto_hide_task.take(); } self.thumb_state = state; @@ -687,13 +780,15 @@ impl ScrollbarState { } } - fn update_parent_hovered(&mut self, position: &Point) -> ParentHovered { + fn update_parent_hovered(&mut self, position: &Point) -> ParentHoverEvent { let last_parent_hovered = self.mouse_in_parent; self.mouse_in_parent = self.parent_hovered(position); let state_changed = self.mouse_in_parent != last_parent_hovered; - match self.mouse_in_parent { - true => ParentHovered::Yes(state_changed), - false => ParentHovered::No(state_changed), + match (self.mouse_in_parent, state_changed) { + (true, true) => ParentHoverEvent::Entered, + (true, false) => ParentHoverEvent::Within, + (false, true) => ParentHoverEvent::Exited, + (false, false) => ParentHoverEvent::Outside, } } @@ -963,10 +1058,10 @@ impl PartialEq for ScrollbarPrepaintState { impl Element for ScrollbarElement { type RequestLayoutState = (); - type PrepaintState = Option; + type PrepaintState = Option<(ScrollbarPrepaintState, Option)>; fn id(&self) -> Option { - None + Some(("scrollbar_animation", self.state.entity_id()).into()) } fn source_location(&self) -> Option<&'static core::panic::Location<'static>> { @@ -992,7 +1087,7 @@ impl Element for ScrollbarElement { fn prepaint( &mut self, - _id: Option<&GlobalElementId>, + id: Option<&GlobalElementId>, _inspector_id: Option<&gpui::InspectorElementId>, bounds: Bounds, _request_layout: &mut Self::RequestLayoutState, @@ -1090,7 +1185,31 @@ impl Element for ScrollbarElement { .update(cx, |state, cx| state.show_scrollbars(window, cx)); } - prepaint_state + prepaint_state.map(|state| { + let autohide_delta = self.state.read(cx).show_state.animation_progress().map( + |(delta, delta_duration, should_invert)| { + window.with_element_state(id.unwrap(), |state, window| { + let state = state.unwrap_or_else(|| Instant::now()); + let current = Instant::now(); + + let new_delta = DELTA_MAX + .min(delta + (current - state).div_duration_f32(delta_duration)); + self.state + .update(cx, |state, _| state.show_state.set_delta(new_delta)); + + window.request_animation_frame(); + let delta = if should_invert { + DELTA_MAX - delta + } else { + delta + }; + (ease_in_out(delta), current) + }) + }, + ); + + (state, autohide_delta) + }) } fn paint( @@ -1103,7 +1222,7 @@ impl Element for ScrollbarElement { window: &mut Window, cx: &mut App, ) { - let Some(prepaint_state) = prepaint_state.take() else { + let Some((prepaint_state, autohide_fade)) = prepaint_state.take() else { return; }; @@ -1142,7 +1261,11 @@ impl Element for ScrollbarElement { blend_color.min(blend_color.alpha(MAXIMUM_OPACITY)) }; - let thumb_background = blending_color.blend(thumb_base_color); + let mut thumb_color = blending_color.blend(thumb_base_color); + + if !hovered && let Some(fade) = autohide_fade { + thumb_color.fade_out(fade); + } if let Some((track_bounds, color)) = track_background { window.paint_quad(quad( @@ -1158,7 +1281,7 @@ impl Element for ScrollbarElement { window.paint_quad(quad( *thumb_bounds, Corners::all(Pixels::MAX).clamp_radii_for_quad_size(thumb_bounds.size), - thumb_background, + thumb_color, Edges::default(), Hsla::transparent_black(), BorderStyle::default(), @@ -1253,10 +1376,11 @@ impl Element for ScrollbarElement { } _ => state.update(cx, |state, cx| { match state.update_parent_hovered(&event.position) { - ParentHovered::Yes(state_changed) + hover @ ParentHoverEvent::Entered + | hover @ ParentHoverEvent::Within if event.pressed_button.is_none() => { - if state_changed { + if matches!(hover, ParentHoverEvent::Entered) { state.show_scrollbars(window, cx); } state.update_hovered_thumb(&event.position, window, cx); @@ -1264,7 +1388,7 @@ impl Element for ScrollbarElement { cx.stop_propagation(); } } - ParentHovered::No(state_changed) if state_changed => { + ParentHoverEvent::Exited => { state.set_thumb_state(ThumbState::Inactive, window, cx); } _ => {}