use std::sync::Arc; use std::time::Duration; use std::time::Instant; use gpui::prelude::FluentBuilder as _; use gpui::{ App, Bounds, DispatchPhase, ElementId, Entity, EntityId, InteractiveElement as _, IntoElement, MouseDownEvent, MouseMoveEvent, MouseUpEvent, ParentElement, Pixels, RenderOnce, ScrollEvent, ScrollHandle, SharedString, StatefulInteractiveElement as _, Styled, Task, Window, canvas, div, fill, point, }; use gpui_native_theme::ActiveTheme as _; use crate::common::StyledExt; /// Which directions the content scrolls in. #[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] pub enum ScrollAxes { /// Vertical only; the default. #[default] Vertical, /// Horizontal only. Horizontal, /// Both directions. Both, } /// When an overlay scrollbar shows. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] pub enum ScrollbarVisibility { /// The running platform's behavior, from the theme. #[default] Platform, Always, Autohide, } /// How long an autohide thumb stays fully visible before its fade begins. const AUTOHIDE_DELAY: Duration = Duration::from_millis(700); /// How long the autohide fade runs. const AUTOHIDE_FADE: Duration = Duration::from_millis(250); /// How often the fade repaints while it runs. const AUTOHIDE_FADE_STEP: Duration = Duration::from_millis(50); /// The shared autohide timeline for an overlay scrollbar. #[derive(Clone)] pub(crate) struct Autohide { view: EntityId, state: Entity, } struct AutohideState { last_activity: Option, hide_task: Option>, } impl Autohide { pub(crate) fn new(key: ElementId, window: &mut Window, cx: &mut App) -> Self { let view = window.current_view(); let state = window.use_keyed_state(key, cx, |_, _| AutohideState { last_activity: None, hide_task: None, }); Self { view, state } } /// Marks interaction. pub(crate) fn touch(&self, window: &mut Window, cx: &mut App) { let view = self.view; let reduce_motion = cx.theme().accessibility.reduce_motion; let task = window.spawn(cx, async move |cx| { if reduce_motion { cx.background_executor() .timer(AUTOHIDE_DELAY + AUTOHIDE_FADE) .await; cx.update(|_, cx| cx.notify(view)).ok(); return; } let steps = (AUTOHIDE_FADE.as_millis() / AUTOHIDE_FADE_STEP.as_millis()).max(1); cx.background_executor().timer(AUTOHIDE_DELAY).await; for _ in 0..steps { cx.update(|_, cx| cx.notify(view)).ok(); cx.background_executor().timer(AUTOHIDE_FADE_STEP).await; } cx.update(|_, cx| cx.notify(view)).ok(); }); self.state.update(cx, |state, _| { state.last_activity = Some(Instant::now()); state.hide_task = Some(task); }); } /// The thumb's opacity. pub(crate) fn alpha(&self, cx: &App, dragging: bool) -> f32 { if dragging { return 1.0; } let Some(activity) = self.state.read(cx).last_activity else { return 0.0; }; let idle = activity.elapsed(); if idle <= AUTOHIDE_DELAY { return 1.0; } let fade = idle.saturating_sub(AUTOHIDE_DELAY); if fade >= AUTOHIDE_FADE { return 0.0; } 1.0 - fade.as_secs_f32() / AUTOHIDE_FADE.as_secs_f32() } } /// An overlay scrollbar thumb's style for one paint: `None` hides it. pub(crate) fn thumb_style(color: gpui::Hsla, alpha: f32) -> Option { (alpha > 0.0).then_some(gpui::Hsla { a: color.a * alpha, ..color }) } /// A scrollable container with an overlay scrollbar. #[derive(IntoElement)] pub struct ScrollArea { id: ElementId, handle: Option, axes: ScrollAxes, visibility: ScrollbarVisibility, a11y_label: Option, style: gpui::StyleRefinement, children: Vec, } impl ParentElement for ScrollArea { fn extend(&mut self, children: impl IntoIterator) { self.children.extend(children); } } impl Styled for ScrollArea { fn style(&mut self) -> &mut gpui::StyleRefinement { &mut self.style } } impl ScrollArea { /// Creates a vertically scrolling area. pub fn new(id: I) -> Self where I: Into, { Self { id: id.into(), handle: None, axes: ScrollAxes::Vertical, visibility: ScrollbarVisibility::default(), a11y_label: None, style: gpui::StyleRefinement::default(), children: Vec::new(), } } /// Sets which directions the content scrolls in. pub fn axes(mut self, axes: ScrollAxes) -> Self { self.axes = axes; self } /// Overrides when the scrollbar shows, instead of the platform default. pub fn visibility(mut self, visibility: ScrollbarVisibility) -> Self { self.visibility = visibility; self } /// Scrolls with a handle held on the owning view. pub fn handle(mut self, handle: ScrollHandle) -> Self { self.handle = Some(handle); self } /// Overrides the accessibility label of the container. pub fn accessibility_label(mut self, label: L) -> Self where L: Into, { self.a11y_label = Some(label.into()); self } } /// Names keyed state under the scrollbar's own id. pub fn scrollbar_state_key(name: &'static str, id: &ElementId) -> ElementId { ElementId::NamedChild(Arc::new(id.clone()), SharedString::from(name)) } /// Pure thumb geometry for an overlay scrollbar. pub(crate) fn thumb_geometry( viewport: Pixels, content: Pixels, offset: Pixels, track: Pixels, min_thumb: Pixels, ) -> (Pixels, Pixels) { if content <= viewport || content <= Pixels::ZERO { return (track, Pixels::ZERO); } let length = (viewport / content * track).max(min_thumb).min(track); let overflow = content - viewport; let progress = (-offset / overflow).clamp(0., 1.); let travel = track - length; (length, travel * progress) } impl RenderOnce for ScrollArea { fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement { let mut pane = div() .id(self.id.clone()) .overflow_hidden() .when(self.axes != ScrollAxes::Horizontal, |area| { area.overflow_y_scroll() }) .when(self.axes != ScrollAxes::Vertical, |area| { area.overflow_x_scroll() }) .when_some(self.handle.clone(), |area, handle| { area.track_scroll(&handle) }) .when_some(self.a11y_label, |area, label| area.aria_label(label)) .on_scroll(|_, _, cx| { // Keep wheel events from scrolling an outer scroll area too. cx.stop_propagation(); }) .refine_style(&self.style); // Extend with children pane.extend(self.children); let mut wrapper = div().relative().refine_style(&self.style); wrapper.extend(std::iter::once(pane.into_any_element())); if let Some(handle) = self.handle { let autohide = match self.visibility { ScrollbarVisibility::Platform => cx.theme().scrollbar.autohide, ScrollbarVisibility::Always => false, ScrollbarVisibility::Autohide => true, }; let id = self.id.clone(); wrapper.extend(std::iter::once( canvas(|_, _, _| {}, { let handle = handle.clone(); move |_, _, window, cx| { paint_scrollbar(&handle, &id, autohide, window, cx); } }) .absolute() .inset_0() .into_any_element(), )); } wrapper } } /// Paints the overlay scrollbar thumb. fn paint_scrollbar( handle: &ScrollHandle, key: &ElementId, autohide: bool, window: &mut Window, cx: &mut App, ) { let theme = cx.theme(); let view = window.current_view(); let color = theme.colors.scrollbar_thumb; let width = theme.scrollbar.width; let inset = theme.scrollbar.inset; let min_thumb = theme.scrollbar.min_thumb; let max_offset = handle.max_offset().y; let bounds = handle.bounds(); if max_offset <= Pixels::ZERO || bounds.size.height <= Pixels::ZERO { return; } let autohide_state = autohide.then(|| Autohide::new(scrollbar_state_key("autohide", key), window, cx)); let drag = window.use_keyed_state(scrollbar_state_key("thumb-drag", key), cx, |_, _| { Option::<(Pixels, Pixels)>::None }); let dragging = drag.read(cx).is_some(); let alpha = match (&autohide_state, autohide) { (Some(state), true) => state.alpha(cx, dragging), _ => 1.0, }; let track = bounds.size.height - inset * 2.0; let (thumb_length, thumb_top) = thumb_geometry( bounds.size.height, bounds.size.height + max_offset, handle.offset().y, track, min_thumb, ); let thumb_x = bounds.right() - width - inset; let thumb_bounds = Bounds::from_corners( point(thumb_x, bounds.top() + inset + thumb_top), point( thumb_x + width, bounds.top() + inset + thumb_top + thumb_length, ), ); if let Some(color) = thumb_style(color, alpha) { window.paint_quad(fill(thumb_bounds, color).corner_radii(width / 2.0)); } if let Some(state) = &autohide_state { window.on_mouse_event({ let state = state.clone(); move |event: &ScrollEvent, phase, window, cx| { if phase != DispatchPhase::Bubble || !bounds.contains(&event.position) { return; } state.touch(window, cx); } }); } window.on_mouse_event({ let handle = handle.clone(); let drag = drag.clone(); let autohide_state = autohide_state.clone(); move |event: &MouseDownEvent, phase, window, cx| { if phase != DispatchPhase::Bubble || event.button != gpui::MouseButton::Left || !thumb_bounds.contains(&event.position) { return; } drag.update(cx, |drag, _| { *drag = Some((event.position.y, handle.offset().y)); }); if let Some(state) = &autohide_state { state.touch(window, cx); } cx.stop_propagation(); } }); window.on_mouse_event({ let drag = drag.clone(); let autohide_state = autohide_state.clone(); move |event: &MouseUpEvent, phase, window, cx| { if phase != DispatchPhase::Bubble || event.button != gpui::MouseButton::Left { return; } if drag.read(cx).is_none() { return; } drag.update(cx, |drag, _| { *drag = None; }); if let Some(state) = &autohide_state { state.touch(window, cx); } } }); window.on_mouse_event({ let handle = handle.clone(); let drag = drag.clone(); let autohide_state = autohide_state.clone(); move |event: &MouseMoveEvent, phase, window, cx| { if phase != DispatchPhase::Bubble { return; } if let Some(state) = &autohide_state && drag.read(cx).is_some() { state.touch(window, cx); } if event.pressed_button != Some(gpui::MouseButton::Left) { return; } let Some((start_y, start_offset)) = *drag.read(cx) else { return; }; if max_offset > Pixels::ZERO && track > thumb_length { let scale = max_offset / (track - thumb_length); let offset = (start_offset - (event.position.y - start_y) * scale) .clamp(-max_offset, Pixels::ZERO); handle.set_offset(point(handle.offset().x, offset)); cx.notify(view); } } }); }