use std::{cell::Cell, ops::Range, rc::Rc, time::Duration};
use gpui::{
Animation, AnimationExt, App, DragMoveEvent, ElementId, Empty, MouseButton, Pixels,
ScrollHandle, SharedString, Window, canvas, div, point, prelude::*, px,
};
use motion::Painter;
use theme::ink;
pub const MIN_THUMB: Pixels = px(25.0);
const TRACK: f32 = 10.0;
const THUMB: f32 = 6.0;
const MARK: f32 = 16.0;
const MARK_THICK: f32 = 2.0;
const MARK_GAP: f32 = TRACK;
const RAIL_INSET: f32 = 12.0;
pub const RAIL_ROOM: f32 = RAIL_INSET + MARK;
pub fn thumb(
viewport: Pixels,
max_offset: Pixels,
offset: Pixels,
min: Pixels,
) -> Option<Range<Pixels>> {
if viewport <= px(0.0) || max_offset <= px(0.0) {
return None;
}
let content = viewport + max_offset;
let size = min.max(viewport * (viewport / content));
if size > viewport {
return None;
}
let travelled = offset.clamp(-max_offset, px(0.0)).abs();
let start = (travelled / max_offset) * (viewport - size);
Some(start..start + size)
}
pub fn offset_for_thumb(top: Pixels, viewport: Pixels, max_offset: Pixels, size: Pixels) -> Pixels {
let travel = viewport - size;
if travel <= px(0.0) || max_offset <= px(0.0) {
return px(0.0);
}
-(max_offset * (top / travel).clamp(0.0, 1.0))
}
#[derive(Clone)]
pub struct ScrollbarDrag(pub SharedString);
#[derive(Clone)]
pub struct ScrollbarState {
grab: Rc<Cell<Option<Pixels>>>,
painter: Painter,
}
impl ScrollbarState {
pub fn new(painter: Painter) -> Self {
Self {
grab: Rc::new(Cell::new(None)),
painter,
}
}
pub fn dragging(&self) -> bool {
self.grab.get().is_some()
}
fn drag(
&self,
track_id: &SharedString,
handle: &ScrollHandle,
event: &DragMoveEvent<ScrollbarDrag>,
cx: &mut App,
) {
if event.drag(cx).0 != *track_id {
return;
}
let viewport = handle.bounds().size.height;
let max_offset = handle.max_offset().y;
let Some(range) = thumb(viewport, max_offset, handle.offset().y, MIN_THUMB) else {
return;
};
let size = range.end - range.start;
let pointer = event.event.position.y - event.bounds.top();
let grab = self.grab.get().unwrap_or_else(|| {
let grab = (pointer - range.start).clamp(px(0.0), size);
self.grab.set(Some(grab));
grab
});
let offset = offset_for_thumb(pointer - grab, viewport, max_offset, size);
handle.set_offset(point(handle.offset().x, offset));
self.painter.notify(cx);
}
}
pub fn scrollbar(
id: impl Into<SharedString>,
handle: &ScrollHandle,
state: &ScrollbarState,
) -> gpui::AnyElement {
let id = id.into();
let viewport = handle.bounds().size.height;
let max_offset = handle.max_offset().y;
let Some(range) = thumb(viewport, max_offset, handle.offset().y, MIN_THUMB) else {
return Empty.into_any_element();
};
let size = range.end - range.start;
let dragging = state.dragging();
let track_id = id.clone();
let drag_handle = handle.clone();
let drag_state = state.clone();
let release_state = state.clone();
let released = move |_: &gpui::MouseUpEvent, _: &mut Window, _: &mut App| {
release_state.grab.set(None);
};
div()
.id(SharedString::from(format!("{id}-track")))
.absolute()
.top_0()
.right_0()
.bottom_0()
.w(px(TRACK))
.flex()
.justify_center()
.on_drag_move(move |event, _, cx| {
drag_state.drag(&track_id, &drag_handle, event, cx);
})
.on_mouse_up(MouseButton::Left, released.clone())
.on_mouse_up_out(MouseButton::Left, released)
.child(
div()
.id(SharedString::from(format!("{id}-thumb")))
.absolute()
.top(range.start)
.h(size)
.w(px(THUMB))
.rounded_full()
.bg(if dragging { ink(0.38) } else { ink(0.2) })
.hover(|s| s.bg(ink(0.32)))
.on_drag(ScrollbarDrag(id.clone()), |_, _, _, cx| cx.new(|_| Empty)),
)
.child(
canvas(
move |bounds, window, _| {
if (bounds.size.height - viewport).abs() > px(0.5) {
window.request_animation_frame();
}
},
|_, _, _, _| {},
)
.absolute()
.size_full(),
)
.into_any_element()
}
pub fn rail(
id: impl Into<SharedString>,
handle: &ScrollHandle,
count: usize,
room: Pixels,
) -> gpui::AnyElement {
if count == 0 || room < px(RAIL_ROOM) {
return Empty.into_any_element();
}
let id = id.into();
let at = handle.top_item();
div()
.absolute()
.top_0()
.bottom_0()
.left(px(RAIL_INSET))
.flex()
.flex_col()
.items_center()
.justify_center()
.gap(px(MARK_GAP))
.overflow_hidden()
.children((0..count).map(|ix| {
let handle = handle.clone();
div()
.id(SharedString::from(format!("{id}-{ix}")))
.w(px(MARK))
.h(px(MARK_THICK))
.rounded_full()
.bg(if ix == at { ink(0.6) } else { ink(0.2) })
.cursor_pointer()
.hover(|mark| mark.bg(ink(0.32)))
.on_click(move |_, window, _| {
handle.scroll_to_item(ix);
window.refresh();
})
}))
.into_any_element()
}
pub const TRANSIENT_IDLE: Duration = Duration::from_millis(1000);
#[derive(Clone)]
pub struct TransientState {
cell: Rc<Cell<(Pixels, Pixels, u64, bool)>>,
bar: ScrollbarState,
}
impl TransientState {
pub fn new(painter: Painter) -> Self {
Self {
cell: Rc::new(Cell::new(Default::default())),
bar: ScrollbarState::new(painter),
}
}
}
pub fn transient(
id: impl Into<SharedString>,
handle: &ScrollHandle,
state: &TransientState,
reduce_motion: bool,
) -> gpui::AnyElement {
let id = id.into();
let viewport = handle.bounds().size.height;
let max_offset = handle.max_offset().y;
let Some(range) = thumb(viewport, max_offset, handle.offset().y, MIN_THUMB) else {
return Empty.into_any_element();
};
let size = range.end - range.start;
let mut cell = state.cell.get();
if (handle.offset().y - cell.0).abs() > px(0.5) || (max_offset - cell.1).abs() > px(0.5) {
cell.0 = handle.offset().y;
cell.1 = max_offset;
cell.2 += 1;
state.cell.set(cell);
}
let generation = cell.2;
let dragging = state.bar.dragging();
let track_id = id.clone();
let drag_handle = handle.clone();
let drag_state = state.clone();
let release_state = state.clone();
let released = move |_: &gpui::MouseUpEvent, _: &mut Window, _: &mut App| {
release_state.bar.grab.set(None);
};
let track = div()
.id(SharedString::from(format!("{id}-track")))
.absolute()
.top_0()
.right_0()
.bottom_0()
.w(px(TRACK))
.flex()
.justify_center()
.on_drag_move(move |event, _, cx| {
drag_state.bar.drag(&track_id, &drag_handle, event, cx);
})
.on_mouse_up(MouseButton::Left, released.clone())
.on_mouse_up_out(MouseButton::Left, released)
.map(|track| {
if reduce_motion {
track
} else {
let hover_state = state.clone();
let hover_painter = state.bar.painter;
track.on_hover(move |hovered: &bool, _, cx: &mut App| {
let mut cell = hover_state.cell.get();
if cell.3 == *hovered {
return;
}
cell.3 = *hovered;
cell.2 += 1;
hover_state.cell.set(cell);
hover_painter.notify(cx);
})
}
});
let thumb = div()
.id(SharedString::from(format!("{id}-thumb")))
.absolute()
.top(range.start)
.h(size)
.w(px(THUMB))
.rounded_full()
.bg(if dragging { ink(0.38) } else { ink(0.2) })
.hover(|s| s.bg(ink(0.32)))
.on_drag(ScrollbarDrag(id.clone()), |_, _, _, cx| cx.new(|_| Empty));
let thumb: gpui::AnyElement = if reduce_motion {
thumb.into_any_element()
} else {
let anim = state.clone();
thumb
.with_animation(
ElementId::from(format!("{id}-fade-{generation}")),
Animation::new(TRANSIENT_IDLE),
move |el, p| {
let cell = anim.cell.get();
if cell.3 || anim.bar.dragging() {
el
} else if p < 1.0 {
el.opacity(1.0 - p)
} else {
el.hidden()
}
},
)
.into_any_element()
};
track
.child(thumb)
.child(
canvas(
move |bounds, window, _| {
if (bounds.size.height - viewport).abs() > px(0.5) {
window.request_animation_frame();
}
},
|_, _, _, _| {},
)
.absolute()
.size_full(),
)
.into_any_element()
}
pub const FOLLOW_SLACK: Pixels = px(4.0);
pub fn at_bottom(max_offset: Pixels, offset: Pixels, slack: Pixels) -> bool {
if max_offset <= px(0.0) {
return true;
}
let travelled = offset.clamp(-max_offset, px(0.0)).abs();
max_offset - travelled <= slack
}
#[derive(Clone)]
pub struct FollowState(Rc<Cell<(bool, Pixels)>>);
impl Default for FollowState {
fn default() -> Self {
Self(Rc::new(Cell::new((true, px(0.0)))))
}
}
impl FollowState {
pub fn new() -> Self {
Self::default()
}
pub fn following(&self) -> bool {
self.0.get().0
}
pub fn follow(&self) {
let (_, last) = self.0.get();
self.0.set((true, last));
}
}
pub fn follow(handle: &ScrollHandle, state: &FollowState) -> gpui::AnyElement {
let handle = handle.clone();
let state = state.clone();
canvas(
move |_, window, _| {
let max_offset = handle.max_offset().y;
let offset = handle.offset().y;
let (was_pinned, last_max) = state.0.get();
let pinned = if (max_offset - last_max).abs() > px(0.5) {
was_pinned
} else {
at_bottom(max_offset, offset, FOLLOW_SLACK)
};
if pinned && (offset + max_offset).abs() > px(0.5) {
handle.set_offset(point(handle.offset().x, -max_offset));
window.request_animation_frame();
}
state.0.set((pinned, max_offset));
},
|_, _, _, _| {},
)
.absolute()
.size_full()
.into_any_element()
}