use std::cell::RefCell;
use std::collections::HashMap;
use std::rc::Rc;
use gpui::{
AnyElement, App, Global, InteractiveElement, IntoElement, MouseButton, ParentElement, Pixels,
Point, RenderOnce, ScrollHandle, SharedString, StatefulInteractiveElement, Styled, Window, div,
prelude::FluentBuilder, px, relative,
};
use gpui_kit_semantics::{NodeSpec, Role, Semantic};
use gpui_kit_theme::{ActiveTheme, Theme};
use crate::foundation::Ident;
use crate::layout::measure;
use crate::motion::ScrollLink;
use crate::strings::{ActiveStrings, StringKey};
const TRACK: f32 = 10.0;
const THUMB: f32 = 6.0;
const MIN_THUMB: f32 = 24.0;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum ScrollAxis {
#[default]
Vertical,
Horizontal,
Both,
}
impl ScrollAxis {
pub fn has_vertical(self) -> bool {
matches!(self, Self::Vertical | Self::Both)
}
pub fn has_horizontal(self) -> bool {
matches!(self, Self::Horizontal | Self::Both)
}
}
#[derive(IntoElement)]
pub struct ScrollArea {
ident: Ident,
axis: ScrollAxis,
label: Option<SharedString>,
width: Option<f32>,
height: Option<f32>,
fit_height: bool,
content: Option<AnyElement>,
}
impl std::fmt::Debug for ScrollArea {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter
.debug_struct("ScrollArea")
.field("ident", &self.ident)
.field("axis", &self.axis)
.field("label", &self.label)
.field("size", &(self.width, self.height))
.finish()
}
}
impl ScrollArea {
pub fn new(ident: impl Into<Ident>) -> Self {
Self {
ident: ident.into(),
axis: ScrollAxis::Vertical,
label: None,
width: None,
height: None,
fit_height: false,
content: None,
}
}
pub fn axis(mut self, axis: ScrollAxis) -> Self {
self.axis = axis;
self
}
pub fn vertical(self) -> Self {
self.axis(ScrollAxis::Vertical)
}
pub fn horizontal(self) -> Self {
self.axis(ScrollAxis::Horizontal)
}
pub fn both(self) -> Self {
self.axis(ScrollAxis::Both)
}
pub fn label(mut self, label: impl Into<SharedString>) -> Self {
self.label = Some(label.into());
self
}
pub fn width(mut self, width: f32) -> Self {
self.width = Some(width);
self
}
pub fn fit_height(mut self) -> Self {
self.fit_height = true;
self
}
pub fn height(mut self, height: f32) -> Self {
self.height = Some(height);
self
}
pub fn child(mut self, content: impl IntoElement) -> Self {
self.content = Some(content.into_any_element());
self
}
}
impl RenderOnce for ScrollArea {
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
let theme = cx.theme().clone();
let handle = scroll_handle(&self.ident, cx);
let offset = handle.offset();
let max = handle.max_offset();
let measured = measure::cell(&self.ident.child("viewport").semantic_id(), cx);
let viewport = measured.get().size;
let content = div()
.id(self.ident.child("content").element_id())
.when(self.axis.has_vertical(), |element| element.min_h(px(0.0)))
.when(!self.axis.has_horizontal(), |element| element.w_full())
.semantic_in(
cx,
NodeSpec::new(self.ident.child("content").semantic_id(), Role::Group)
.parent(self.ident.semantic_id()),
)
.children(self.content);
let fit_height = self.fit_height;
let viewport_element = div()
.id(self.ident.child("viewport").element_id())
.w_full()
.when(!fit_height, |element| element.h_full())
.when(self.axis.has_vertical(), |element| {
element.overflow_y_scroll()
})
.when(self.axis.has_horizontal(), |element| {
element.overflow_x_scroll()
})
.track_scroll(&handle)
.child(content);
let shade = self.axis.has_vertical().then(|| {
ScrollLink::over(px(theme.effects.edge_fade_band)).progress(px(-f32::from(offset.y)))
});
let top_shadow = shade.filter(|shade| *shade > 0.0).map(|shade| {
div()
.absolute()
.top_0()
.left_0()
.right_0()
.h(px(theme.borders.hairline))
.bg(theme.colors.hairline_strong.opacity(shade))
});
let viewport_frame = div()
.relative()
.on_children_prepainted({
let measured = Rc::clone(&measured);
move |bounds, window, _| {
if let Some(first) = bounds.first() {
measure::record(&measured, *first, window);
}
}
})
.when(!self.fit_height, |element| element.flex_1())
.min_w(px(0.0))
.min_h(px(0.0))
.child(viewport_element)
.children(top_shadow);
let vertical = self.axis.has_vertical().then(|| {
bar(
&self.ident,
"vertical",
true,
f32::from(viewport.height),
f32::from(max.y),
-f32::from(offset.y),
&handle,
offset,
&theme,
cx,
)
});
let horizontal = self.axis.has_horizontal().then(|| {
bar(
&self.ident,
"horizontal",
false,
f32::from(viewport.width),
f32::from(max.x),
-f32::from(offset.x),
&handle,
offset,
&theme,
cx,
)
});
let body = div()
.flex()
.flex_row()
.items_stretch()
.flex_1()
.min_h(px(0.0))
.child(viewport_frame)
.children(vertical);
div()
.id(self.ident.element_id())
.flex()
.flex_col()
.when_some(self.width, |element, width| element.w(px(width)))
.when_some(self.height, |element, height| element.h(px(height)))
.when(
self.width.is_none() && self.height.is_none() && !self.fit_height,
|element| element.size_full(),
)
.when(self.fit_height && self.width.is_none(), |element| {
element.w_full()
})
.child(body)
.children(horizontal)
.semantic_in(cx, {
let mut spec = NodeSpec::new(self.ident.semantic_id(), Role::Region);
if let Some(label) = self.label.clone() {
spec = spec.text(label);
}
spec
})
}
}
#[allow(clippy::too_many_arguments)]
fn bar(
ident: &Ident,
axis: &str,
vertical: bool,
viewport: f32,
max: f32,
scrolled: f32,
handle: &ScrollHandle,
offset: Point<Pixels>,
theme: &Theme,
cx: &mut App,
) -> AnyElement {
let bar_ident = ident.child("scrollbar").child(axis);
let content = viewport + max;
let overflowing = max > 0.5 && viewport > 0.0;
let track = measure::cell(&bar_ident.semantic_id(), cx);
let fraction = if content > 0.0 {
(viewport / content).clamp(0.0, 1.0)
} else {
1.0
};
let position = if max > 0.0 {
(scrolled / max).clamp(0.0, 1.0)
} else {
0.0
};
let thumb = overflowing.then(|| {
div()
.absolute()
.rounded_full()
.bg(theme.colors.hairline_strong)
.when(vertical, |element| {
element
.w(px(THUMB))
.left(px((TRACK - THUMB) / 2.0))
.min_h(px(MIN_THUMB))
.h(relative(fraction))
.top(relative(position * (1.0 - fraction)))
})
.when(!vertical, |element| {
element
.h(px(THUMB))
.top(px((TRACK - THUMB) / 2.0))
.min_w(px(MIN_THUMB))
.w(relative(fraction))
.left(relative(position * (1.0 - fraction)))
})
});
let mut gutter = div()
.id(bar_ident.element_id())
.relative()
.size_full()
.bg(theme.colors.panel)
.children(thumb);
if overflowing {
let handle = handle.clone();
let track = Rc::clone(&track);
gutter = gutter.on_mouse_move(move |event, window, _| {
if event.pressed_button != Some(MouseButton::Left) {
return;
}
let bounds = track.get();
let (origin, extent, pointer) = if vertical {
(
f32::from(bounds.top()),
f32::from(bounds.size.height),
f32::from(event.position.y),
)
} else {
(
f32::from(bounds.left()),
f32::from(bounds.size.width),
f32::from(event.position.x),
)
};
if extent <= 0.0 {
return;
}
let travel = (extent * (1.0 - fraction)).max(f32::EPSILON);
let next = (((pointer - origin) - travel * fraction / 2.0) / travel).clamp(0.0, 1.0);
let scrolled = -next * max;
handle.set_offset(if vertical {
gpui::point(offset.x, px(scrolled))
} else {
gpui::point(px(scrolled), offset.y)
});
window.refresh();
});
}
if overflowing {
gutter = gutter.semantic_in(
cx,
NodeSpec::new(bar_ident.semantic_id(), Role::Scrollbar)
.parent(ident.semantic_id())
.text(cx.strings().text(if vertical {
StringKey::ScrollbarVertical
} else {
StringKey::ScrollbarHorizontal
}))
.value(format!("{scrolled:.0} of {max:.0}"))
.range(0.0, max, scrolled.clamp(0.0, max)),
);
}
div()
.on_children_prepainted({
let track = Rc::clone(&track);
move |bounds, window, _| {
if let Some(first) = bounds.first() {
measure::record(&track, *first, window);
}
}
})
.flex_none()
.when(vertical, |element| element.w(px(TRACK)).h_full())
.when(!vertical, |element| element.h(px(TRACK)).w_full())
.child(gutter)
.into_any_element()
}
#[derive(Default)]
struct ScrollHandles(RefCell<HashMap<SharedString, ScrollHandle>>);
impl Global for ScrollHandles {}
pub fn scroll_offset(ident: impl Into<Ident>, cx: &mut App) -> Point<Pixels> {
let offset = scroll_handle(&ident.into(), cx).offset();
gpui::point(-offset.x, -offset.y)
}
pub fn scroll_to(ident: impl Into<Ident>, offset: Point<Pixels>, cx: &mut App) {
scroll_handle(&ident.into(), cx).set_offset(gpui::point(-offset.x, -offset.y));
}
fn scroll_handle(ident: &Ident, cx: &mut App) -> ScrollHandle {
if !cx.has_global::<ScrollHandles>() {
cx.set_global(ScrollHandles::default());
}
let mut handles = cx.global::<ScrollHandles>().0.borrow_mut();
handles.entry(ident.semantic_id()).or_default().clone()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn an_axis_knows_which_gutters_it_reserves() {
assert!(ScrollAxis::Vertical.has_vertical());
assert!(!ScrollAxis::Vertical.has_horizontal());
assert!(ScrollAxis::Both.has_vertical() && ScrollAxis::Both.has_horizontal());
}
}