Files
Ely-GPUI-Components/src/layout/scroll.rs
T
ZacharyZhang-NY 1f1166fef0 Tables: focused headers scroll into view; long tables scroll sideways
A sortable header is a tracked Tab stop; once focused it sets its
table's sideways offset whole from its painted bounds and asks one
frame. The virtualized branch scrolls sideways too, and its list
keeps a plain wheel to its rows. layout::on_axis keeps a scroll box's
wheel to its axes; tables, code blocks, the thumbnail strip and the
gallery's page use it. Footer figures move to tables/foot.rs; the
profiler's ruler takes theme.chart().label. HumanInputRequest's
answered state gets a test.
2026-09-26 12:00:27 -04:00

258 lines
8.1 KiB
Rust

use std::{
cell::Cell,
rc::Rc,
time::{Duration, Instant},
};
use gpui::{
AnyElement, App, Axis, Div, ElementId, EmptyView, Entity, EntityId, InteractiveElement,
IntoElement, ParentElement, Pixels, Point, RenderOnce, ScrollHandle,
StatefulInteractiveElement, StyleRefinement, Styled, Window, div, point, prelude::*,
};
use smallvec::SmallVec;
use super::ScrollShadow;
use crate::{motion, theme::ActiveTheme};
const IDLE: Duration = Duration::from_millis(900);
/// Keeps a scroll box's wheel to its own axes; gpui turns a wheel along the other axis onto a box that scrolls one way, while the page scrolls too.
pub fn on_axis<E: Styled>(mut element: E) -> E {
element.style().restrict_scroll_to_axis = Some(true);
element
}
struct Activity {
handle: ScrollHandle,
last: Point<Pixels>,
active_at: Option<Instant>,
hovered: bool,
}
/// Visibility 0..=1: full while active, fading after `IDLE`.
fn presence(activity: &Activity, fade: Duration) -> f32 {
if activity.hovered {
return 1.0;
}
let Some(at) = activity.active_at else {
return 0.0;
};
let quiet = at.elapsed().saturating_sub(IDLE);
1.0 - (quiet.as_secs_f32() / fade.as_secs_f32()).min(1.0)
}
/// Thumb start at press, and the pointer's first position.
struct ThumbDrag {
owner: EntityId,
start: Pixels,
anchor: Rc<Cell<Option<Pixels>>>,
}
/// Overlay thumb for a `ScrollHandle`; drag it to scroll.
#[derive(IntoElement)]
pub struct Scrollbar {
id: ElementId,
handle: ScrollHandle,
axis: Axis,
presence: f32,
}
impl Scrollbar {
pub fn new(id: impl Into<ElementId>, handle: &ScrollHandle, axis: Axis) -> Self {
Self {
id: id.into(),
handle: handle.clone(),
axis,
presence: 1.0,
}
}
/// Fades the thumb; 0 hides it.
pub fn presence(mut self, presence: f32) -> Self {
self.presence = presence.clamp(0.0, 1.0);
self
}
}
impl RenderOnce for Scrollbar {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let owner = window
.use_keyed_state(self.id.clone(), cx, |_, _| ())
.entity_id();
let theme = cx.theme();
let rem = window.rem_size();
let thickness = theme.scrollbar_thickness().to_pixels(rem);
let min_thumb = theme.scrollbar_min_thumb().to_pixels(rem);
let (view, max, offset) = (
self.handle.bounds().size,
self.handle.max_offset(),
self.handle.offset(),
);
let (span, reach, scrolled) = match self.axis {
Axis::Vertical => (view.height, max.height, -offset.y),
Axis::Horizontal => (view.width, max.width, -offset.x),
};
let length = (span * (span / (span + reach))).max(min_thumb).min(span);
let travel = span - length;
if reach <= Pixels::ZERO || travel <= Pixels::ZERO || self.presence <= 0.0 {
return div().into_any_element();
}
let start = travel * (scrolled / reach).clamp(0.0, 1.0);
let axis = self.axis;
let handle = self.handle.clone();
let (idle, busy) = (theme.colors.fg.opacity(0.22), theme.colors.fg.opacity(0.42));
let thumb = div()
.id("thumb")
.absolute()
.rounded_full()
.bg(idle)
.hover(|style| style.bg(busy))
.on_drag(
ThumbDrag {
owner,
start,
anchor: Rc::new(Cell::new(None)),
},
|_, _, _, cx| cx.new(|_| EmptyView),
)
.map(|thumb| match axis {
Axis::Vertical => thumb.top(start).left_0().w(thickness).h(length),
Axis::Horizontal => thumb.left(start).top_0().h(thickness).w(length),
});
let track = div()
.id(self.id)
.absolute()
.opacity(self.presence)
.on_drag_move(move |event: &gpui::DragMoveEvent<ThumbDrag>, window, cx| {
let drag = event.drag(cx);
if drag.owner != owner {
return;
}
let local = event.event.position - event.bounds.origin;
let along = match axis {
Axis::Vertical => local.y,
Axis::Horizontal => local.x,
};
let anchor = match drag.anchor.get() {
Some(anchor) => anchor,
None => {
drag.anchor.set(Some(along));
along
}
};
let ratio = ((drag.start + along - anchor) / travel).clamp(0.0, 1.0);
let target = -(reach * ratio);
let current = handle.offset();
handle.set_offset(match axis {
Axis::Vertical => point(current.x, target),
Axis::Horizontal => point(target, current.y),
});
window.refresh();
});
match axis {
Axis::Vertical => track
.top_0()
.bottom_0()
.right_0()
.w(thickness * 2.0)
.pl(thickness * 0.5),
Axis::Horizontal => track
.left_0()
.right_0()
.bottom_0()
.h(thickness * 2.0)
.pt(thickness * 0.5),
}
.child(thumb)
.into_any_element()
}
}
/// Scroll region with overlay scrollbars that show while in use.
#[derive(IntoElement)]
pub struct ScrollArea {
id: ElementId,
base: Div,
shadows: bool,
body: SmallVec<[AnyElement; 2]>,
}
impl ScrollArea {
pub fn new(id: impl Into<ElementId>) -> Self {
Self {
id: id.into(),
base: div(),
shadows: false,
body: SmallVec::new(),
}
}
/// Soft edges where content continues.
pub fn shadows(mut self) -> Self {
self.shadows = true;
self
}
}
impl Styled for ScrollArea {
fn style(&mut self) -> &mut StyleRefinement {
self.base.style()
}
}
impl ParentElement for ScrollArea {
fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
self.body.extend(elements);
}
}
fn track_activity(state: &Entity<Activity>, window: &mut Window, cx: &mut App) -> f32 {
let offset = state.read(cx).handle.offset();
if state.read(cx).last != offset {
state.update(cx, |activity, _| {
activity.last = offset;
activity.active_at = Some(Instant::now());
});
}
let fade = motion::duration(motion::SLOW, cx);
let presence = presence(state.read(cx), fade);
if presence > 0.0 && !state.read(cx).hovered {
window.request_animation_frame();
}
presence
}
impl RenderOnce for ScrollArea {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let state = window.use_keyed_state(self.id.clone(), cx, |_, _| Activity {
handle: ScrollHandle::new(),
last: Point::default(),
active_at: None,
hovered: false,
});
let handle = state.read(cx).handle.clone();
let presence = track_activity(&state, window, cx);
self.base
.id(self.id)
.relative()
.overflow_hidden()
.on_hover(move |hovered, _, cx| {
state.update(cx, |activity, cx| {
activity.hovered = *hovered;
cx.notify();
})
})
.child(
div()
.id("scroll-body")
.size_full()
.overflow_scroll()
.track_scroll(&handle)
.children(self.body),
)
.when(self.shadows, |area| area.child(ScrollShadow::new(&handle)))
.child(Scrollbar::new("scrollbar-y", &handle, Axis::Vertical).presence(presence))
.child(Scrollbar::new("scrollbar-x", &handle, Axis::Horizontal).presence(presence))
}
}