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.
258 lines
8.1 KiB
Rust
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))
|
|
}
|
|
}
|