mod overlay;
pub use overlay::{Overlay, Viewport, Visibility, set_visibility, visibility};
use std::{cell::Cell, ops::Range, rc::Rc, time::Duration};
use gpui::{
Animation, AnimationExt, App, Axis, Div, DragMoveEvent, ElementId, Empty, MouseButton, Pixels,
Point, ScrollHandle, SharedString, Stateful, Window, canvas, div, point, prelude::*, px,
};
use motion::Painter;
use theme::ink;
use web_time::Instant;
pub const MIN_THUMB: Pixels = px(25.0);
const INSET: f32 = 4.0;
const BAR_INSET: Pixels = px(INSET);
const TRACK: f32 = 10.0;
const CHANNEL: f32 = 2.0 * INSET + TRACK;
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;
const RAIL_ROOM: f32 = RAIL_INSET + MARK;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Axes {
Vertical,
Horizontal,
Both,
}
impl Axes {
pub fn vertical(self) -> bool {
matches!(self, Axes::Vertical | Axes::Both)
}
pub fn horizontal(self) -> bool {
matches!(self, Axes::Horizontal | Axes::Both)
}
pub fn axis(self) -> Option<Axis> {
match self {
Axes::Vertical => Some(Axis::Vertical),
Axes::Horizontal => Some(Axis::Horizontal),
Axes::Both => None,
}
}
}
pub fn pane(id: impl Into<ElementId>, axes: Axes) -> Stateful<Div> {
scrolls(div().id(id), axes)
}
pub fn claiming_pane(
id: impl Into<ElementId>,
axes: Axes,
window: &mut Window,
cx: &mut App,
) -> Stateful<Div> {
let id = id.into();
let held = window.use_keyed_state(id.clone(), cx, |_, _| Claiming::default());
let (handle, state) = {
let held = held.read(cx);
(held.handle.clone(), held.state.clone())
};
claim_wheel(pane(id, axes).track_scroll(&handle), &handle, axes, &state)
}
#[derive(Default)]
struct Claiming {
handle: ScrollHandle,
state: ClaimState,
}
pub fn scrolls<E: gpui::StatefulInteractiveElement>(el: E, axes: Axes) -> E {
let el = match axes {
Axes::Vertical => el.overflow_y_scroll(),
Axes::Horizontal => el.overflow_x_scroll(),
Axes::Both => el.overflow_scroll(),
}
.restrict_scroll_to_axis();
match axes.horizontal() {
true => contain_sideways(el),
false => el,
}
}
pub fn contain_sideways<E: gpui::InteractiveElement>(el: E) -> E {
contain_wheel(el, Axes::Horizontal)
}
pub fn contain_wheel<E: gpui::InteractiveElement>(el: E, axes: Axes) -> E {
el.on_scroll_wheel(move |event, window, cx| {
let delta = event.delta.pixel_delta(window.line_height());
let sideways = delta.x.abs() > delta.y.abs();
if (sideways && axes.horizontal()) || (!sideways && axes.vertical()) {
cx.stop_propagation();
}
})
}
fn track(id: &SharedString, place: Place, axis: Axis) -> Stateful<Div> {
let debug_id = id.clone();
let el = div()
.debug_selector(move || format!("{debug_id}-track"))
.id(SharedString::from(format!("{id}-track")))
.block_mouse_except_scroll()
.absolute()
.flex();
match axis {
Axis::Vertical => el
.top(BAR_INSET)
.right(place.near())
.bottom(BAR_INSET + place.end)
.w(px(TRACK))
.justify_center(),
Axis::Horizontal => el
.left(BAR_INSET)
.right(BAR_INSET + place.end)
.bottom(place.near())
.h(px(TRACK))
.items_center(),
}
}
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(crate) fn thumb_in_track(
viewport: Pixels,
max_offset: Pixels,
offset: Pixels,
track: Pixels,
) -> Option<Range<Pixels>> {
if track <= px(0.) {
return None;
}
let scale = track / viewport;
let range = thumb(viewport, max_offset, offset, MIN_THUMB / scale)?;
Some(range.start * scale..range.end * scale)
}
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,
}
}
fn begin(&self, handle: &ScrollHandle, event: &gpui::MouseDownEvent, end_inset: Pixels) {
let viewport = handle.bounds().size.height;
if let Some(range) = thumb_in_track(
viewport,
handle.max_offset().y,
handle.offset().y,
viewport - 2. * BAR_INSET - end_inset,
) {
self.grab.set(Some(
(event.position.y - handle.bounds().top() - BAR_INSET - range.start)
.clamp(px(0.), range.end - range.start),
));
}
}
pub fn dragging(&self) -> bool {
self.grab.get().is_some()
}
fn drag(
&self,
track_id: &SharedString,
handle: &ScrollHandle,
event: &DragMoveEvent<ScrollbarDrag>,
end_inset: Pixels,
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_in_track(
viewport,
max_offset,
handle.offset().y,
viewport - 2. * BAR_INSET - end_inset,
) 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 - 2. * BAR_INSET - end_inset,
max_offset,
size,
);
handle.set_offset(point(handle.offset().x, offset));
self.painter.notify(cx);
}
}
#[derive(Clone, Copy)]
struct Place {
end: Pixels,
channel: Pixels,
}
impl Default for Place {
fn default() -> Self {
Self {
end: px(0.),
channel: px(CHANNEL),
}
}
}
impl Place {
fn near(self) -> Pixels {
((self.channel - px(TRACK)) * 0.5).max(px(0.))
}
}
pub fn scrollbar(
id: impl Into<SharedString>,
handle: &ScrollHandle,
state: &ScrollbarState,
) -> gpui::AnyElement {
scrollbar_placed(id.into(), handle, state, Place::default())
}
fn scrollbar_placed(
id: SharedString,
handle: &ScrollHandle,
state: &ScrollbarState,
place: Place,
) -> gpui::AnyElement {
let end_inset = place.end;
let viewport = handle.bounds().size.height;
let max_offset = handle.max_offset().y;
let Some(range) = thumb_in_track(
viewport,
max_offset,
handle.offset().y,
viewport - 2. * BAR_INSET - end_inset,
) 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 press_state = state.clone();
let press_handle = handle.clone();
let released = move |_: &gpui::MouseUpEvent, _: &mut Window, _: &mut App| {
release_state.grab.set(None);
};
let thumb_debug_id = id.clone();
track(&id, place, Axis::Vertical)
.on_drag_move(move |event, _, cx| {
drag_state.drag(&track_id, &drag_handle, event, end_inset, cx);
})
.on_mouse_up(MouseButton::Left, released.clone())
.on_mouse_up_out(MouseButton::Left, released)
.child(
div()
.debug_selector(move || format!("{thumb_debug_id}-thumb"))
.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_mouse_down(MouseButton::Left, move |event, _, cx| {
press_state.begin(&press_handle, event, end_inset);
press_state.painter.notify(cx);
})
.on_drag(ScrollbarDrag(id.clone()), |_, _, _, cx| cx.new(|_| Empty)),
)
.child(
canvas(
move |bounds, window, _| {
if (bounds.size.height + 2. * BAR_INSET + end_inset - viewport).abs() > px(0.5)
{
window.request_animation_frame();
}
},
|_, _, _, _| {},
)
.absolute()
.size_full(),
)
.into_any_element()
}
pub fn rail_fits(room: Pixels) -> bool {
room >= px(RAIL_ROOM)
}
pub fn rail(
id: impl Into<SharedString>,
handle: &ScrollHandle,
count: usize,
room: Pixels,
) -> gpui::AnyElement {
if count == 0 || !rail_fits(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 {
transient_placed(id.into(), handle, state, reduce_motion, Place::default())
}
fn transient_placed(
id: SharedString,
handle: &ScrollHandle,
state: &TransientState,
reduce_motion: bool,
place: Place,
) -> gpui::AnyElement {
let end_inset = place.end;
let viewport = handle.bounds().size.height;
let max_offset = handle.max_offset().y;
let Some(range) = thumb_in_track(
viewport,
max_offset,
handle.offset().y,
viewport - 2. * BAR_INSET - end_inset,
) 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 press_state = state.clone();
let press_handle = handle.clone();
let released = move |_: &gpui::MouseUpEvent, _: &mut Window, _: &mut App| {
release_state.bar.grab.set(None);
};
let thumb_debug_id = id.clone();
let track = track(&id, place, Axis::Vertical)
.on_drag_move(move |event, _, cx| {
drag_state
.bar
.drag(&track_id, &drag_handle, event, end_inset, 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()
.debug_selector(move || format!("{thumb_debug_id}-thumb"))
.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_mouse_down(MouseButton::Left, move |event, _, cx| {
press_state.bar.begin(&press_handle, event, end_inset);
press_state.bar.painter.notify(cx);
})
.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 + 2. * BAR_INSET + end_inset - viewport).abs() > px(0.5)
{
window.request_animation_frame();
}
},
|_, _, _, _| {},
)
.absolute()
.size_full(),
)
.into_any_element()
}
#[derive(Clone)]
pub struct ClaimState(Rc<Cell<Point<Pixels>>>);
impl Default for ClaimState {
fn default() -> Self {
Self::new()
}
}
impl ClaimState {
pub fn new() -> Self {
Self(Rc::new(Cell::new(point(px(0.0), px(0.0)))))
}
}
pub fn claim_wheel<E: gpui::StatefulInteractiveElement>(
el: E,
handle: &ScrollHandle,
axes: Axes,
state: &ClaimState,
) -> E {
let handle = handle.clone();
let state = state.0.clone();
state.set(travel(&handle, axes));
el.on_scroll_wheel(move |_, _, cx| {
let now = travel(&handle, axes);
if now != state.get() {
state.set(now);
cx.stop_propagation();
}
})
}
fn travel(handle: &ScrollHandle, axes: Axes) -> Point<Pixels> {
let (offset, max) = (handle.offset(), handle.max_offset());
let seen = |offset: Pixels, max: Pixels| offset.clamp(-max.max(px(0.0)), px(0.0));
point(
match axes.horizontal() {
true => seen(offset.x, max.x),
false => px(0.0),
},
match axes.vertical() {
true => seen(offset.y, max.y),
false => px(0.0),
},
)
}
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()
}
pub const DRIFT_EDGE: Pixels = px(96.0);
pub const DRIFT_SPEED: f32 = 1320.0;
const DRIFT_STEP: Duration = Duration::from_millis(50);
pub fn drift_velocity(pointer: Pixels, start: Pixels, end: Pixels) -> f32 {
let edge = DRIFT_EDGE.as_f32().min((end - start).as_f32() / 2.0);
if edge <= 0.0 {
return 0.0;
}
let (from_start, from_end) = ((pointer - start).as_f32(), (end - pointer).as_f32());
let near = from_start.min(from_end);
if near >= edge {
return 0.0;
}
let ramp = 1.0 - near.max(0.0) / edge;
match from_start < from_end {
true => DRIFT_SPEED * ramp,
false => -DRIFT_SPEED * ramp,
}
}
#[derive(Clone, Copy, Default)]
struct Drift {
aim: Option<Point<Pixels>>,
since: Option<Instant>,
}
#[derive(Clone, Default)]
pub struct DriftState(Rc<Cell<Drift>>);
impl DriftState {
pub fn new() -> Self {
Self::default()
}
pub fn aim(&self, pointer: Point<Pixels>) {
let drift = self.0.get();
self.0.set(Drift {
aim: Some(pointer),
..drift
});
}
}
pub fn drift(handle: &ScrollHandle, state: &DriftState, axes: Axes) -> gpui::AnyElement {
let handle = handle.clone();
let state = state.clone();
canvas(
move |_, window, cx: &mut App| {
let drift = state.0.get();
let Some(pointer) = drift.aim.filter(|_| cx.has_active_drag()) else {
state.0.set(Drift::default());
return;
};
let bounds = handle.bounds();
let mut velocity = point(0.0, 0.0);
if axes.horizontal() && (bounds.top()..=bounds.bottom()).contains(&pointer.y) {
velocity.x = drift_velocity(pointer.x, bounds.left(), bounds.right());
}
if axes.vertical() && (bounds.left()..=bounds.right()).contains(&pointer.x) {
velocity.y = drift_velocity(pointer.y, bounds.top(), bounds.bottom());
}
if velocity.x == 0.0 && velocity.y == 0.0 {
state.0.set(Drift {
aim: Some(pointer),
since: None,
});
return;
}
let now = cx.background_executor().now();
state.0.set(Drift {
aim: Some(pointer),
since: Some(now),
});
let Some(last) = drift.since else {
window.request_animation_frame();
return;
};
let step = (now - last).min(DRIFT_STEP).as_secs_f32();
let (offset, max) = (handle.offset(), handle.max_offset());
let moved = point(
(offset.x + px(velocity.x * step)).clamp(-max.x, px(0.0)),
(offset.y + px(velocity.y * step)).clamp(-max.y, px(0.0)),
);
if moved == offset {
return;
}
handle.set_offset(moved);
window.request_animation_frame();
},
|_, _, _, _| {},
)
.absolute()
.size_full()
.into_any_element()
}