1use std::cell::RefCell;
19use std::collections::HashMap;
20use std::rc::Rc;
21
22use gpui::{
23 AnyElement, App, Global, InteractiveElement, IntoElement, MouseButton, ParentElement, Pixels,
24 Point, RenderOnce, ScrollHandle, SharedString, StatefulInteractiveElement, Styled, Window, div,
25 prelude::FluentBuilder, px, relative,
26};
27use gpui_kit_semantics::{NodeSpec, Role, Semantic};
28use gpui_kit_theme::{ActiveTheme, Theme};
29
30use crate::foundation::Ident;
31use crate::layout::measure;
32use crate::motion::ScrollLink;
33use crate::strings::{ActiveStrings, StringKey};
34
35const TRACK: f32 = 10.0;
38const THUMB: f32 = 6.0;
39
40const MIN_THUMB: f32 = 24.0;
43
44#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
46pub enum ScrollAxis {
47 #[default]
48 Vertical,
49 Horizontal,
50 Both,
51}
52
53impl ScrollAxis {
54 pub fn has_vertical(self) -> bool {
55 matches!(self, Self::Vertical | Self::Both)
56 }
57
58 pub fn has_horizontal(self) -> bool {
59 matches!(self, Self::Horizontal | Self::Both)
60 }
61}
62
63#[derive(IntoElement)]
65pub struct ScrollArea {
66 ident: Ident,
67 axis: ScrollAxis,
68 label: Option<SharedString>,
69 width: Option<f32>,
70 height: Option<f32>,
71 fit_height: bool,
74 content: Option<AnyElement>,
75}
76
77impl std::fmt::Debug for ScrollArea {
78 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
79 formatter
80 .debug_struct("ScrollArea")
81 .field("ident", &self.ident)
82 .field("axis", &self.axis)
83 .field("label", &self.label)
84 .field("size", &(self.width, self.height))
85 .finish()
86 }
87}
88
89impl ScrollArea {
90 pub fn new(ident: impl Into<Ident>) -> Self {
91 Self {
92 ident: ident.into(),
93 axis: ScrollAxis::Vertical,
94 label: None,
95 width: None,
96 height: None,
97 fit_height: false,
98 content: None,
99 }
100 }
101
102 pub fn axis(mut self, axis: ScrollAxis) -> Self {
103 self.axis = axis;
104 self
105 }
106
107 pub fn vertical(self) -> Self {
108 self.axis(ScrollAxis::Vertical)
109 }
110
111 pub fn horizontal(self) -> Self {
112 self.axis(ScrollAxis::Horizontal)
113 }
114
115 pub fn both(self) -> Self {
116 self.axis(ScrollAxis::Both)
117 }
118
119 pub fn label(mut self, label: impl Into<SharedString>) -> Self {
121 self.label = Some(label.into());
122 self
123 }
124
125 pub fn width(mut self, width: f32) -> Self {
128 self.width = Some(width);
129 self
130 }
131
132 pub fn fit_height(mut self) -> Self {
141 self.fit_height = true;
142 self
143 }
144
145 pub fn height(mut self, height: f32) -> Self {
146 self.height = Some(height);
147 self
148 }
149
150 pub fn child(mut self, content: impl IntoElement) -> Self {
151 self.content = Some(content.into_any_element());
152 self
153 }
154}
155
156impl RenderOnce for ScrollArea {
157 fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
158 let theme = cx.theme().clone();
159 let handle = scroll_handle(&self.ident, cx);
160 let offset = handle.offset();
161 let max = handle.max_offset();
162 let measured = measure::cell(&self.ident.child("viewport").semantic_id(), cx);
166 let viewport = measured.get().size;
167
168 let content = div()
169 .id(self.ident.child("content").element_id())
170 .when(self.axis.has_vertical(), |element| element.min_h(px(0.0)))
171 .when(!self.axis.has_horizontal(), |element| element.w_full())
172 .semantic_in(
173 cx,
174 NodeSpec::new(self.ident.child("content").semantic_id(), Role::Group)
175 .parent(self.ident.semantic_id()),
176 )
177 .children(self.content);
178
179 let fit_height = self.fit_height;
180 let viewport_element = div()
181 .id(self.ident.child("viewport").element_id())
182 .w_full()
183 .when(!fit_height, |element| element.h_full())
184 .when(self.axis.has_vertical(), |element| {
185 element.overflow_y_scroll()
186 })
187 .when(self.axis.has_horizontal(), |element| {
188 element.overflow_x_scroll()
189 })
190 .track_scroll(&handle)
191 .child(content);
192
193 let shade = self.axis.has_vertical().then(|| {
199 ScrollLink::over(px(theme.effects.edge_fade_band)).progress(px(-f32::from(offset.y)))
200 });
201 let top_shadow = shade.filter(|shade| *shade > 0.0).map(|shade| {
202 div()
203 .absolute()
204 .top_0()
205 .left_0()
206 .right_0()
207 .h(px(theme.borders.hairline))
208 .bg(theme.colors.hairline_strong.opacity(shade))
209 });
210
211 let viewport_frame = div()
212 .relative()
213 .on_children_prepainted({
214 let measured = Rc::clone(&measured);
215 move |bounds, window, _| {
216 if let Some(first) = bounds.first() {
217 measure::record(&measured, *first, window);
218 }
219 }
220 })
221 .when(!self.fit_height, |element| element.flex_1())
222 .min_w(px(0.0))
223 .min_h(px(0.0))
224 .child(viewport_element)
228 .children(top_shadow);
229
230 let vertical = self.axis.has_vertical().then(|| {
231 bar(
232 &self.ident,
233 "vertical",
234 true,
235 f32::from(viewport.height),
236 f32::from(max.y),
237 -f32::from(offset.y),
238 &handle,
239 offset,
240 &theme,
241 cx,
242 )
243 });
244 let horizontal = self.axis.has_horizontal().then(|| {
245 bar(
246 &self.ident,
247 "horizontal",
248 false,
249 f32::from(viewport.width),
250 f32::from(max.x),
251 -f32::from(offset.x),
252 &handle,
253 offset,
254 &theme,
255 cx,
256 )
257 });
258
259 let body = div()
260 .flex()
261 .flex_row()
262 .items_stretch()
263 .flex_1()
264 .min_h(px(0.0))
265 .child(viewport_frame)
266 .children(vertical);
267
268 div()
269 .id(self.ident.element_id())
270 .flex()
271 .flex_col()
272 .when_some(self.width, |element, width| element.w(px(width)))
273 .when_some(self.height, |element, height| element.h(px(height)))
274 .when(
275 self.width.is_none() && self.height.is_none() && !self.fit_height,
276 |element| element.size_full(),
277 )
278 .when(self.fit_height && self.width.is_none(), |element| {
279 element.w_full()
280 })
281 .child(body)
282 .children(horizontal)
283 .semantic_in(cx, {
284 let mut spec = NodeSpec::new(self.ident.semantic_id(), Role::Region);
285 if let Some(label) = self.label.clone() {
286 spec = spec.text(label);
287 }
288 spec
289 })
290 }
291}
292
293#[allow(clippy::too_many_arguments)]
299fn bar(
300 ident: &Ident,
301 axis: &str,
302 vertical: bool,
303 viewport: f32,
304 max: f32,
305 scrolled: f32,
306 handle: &ScrollHandle,
307 offset: Point<Pixels>,
308 theme: &Theme,
309 cx: &mut App,
310) -> AnyElement {
311 let bar_ident = ident.child("scrollbar").child(axis);
312 let content = viewport + max;
313 let overflowing = max > 0.5 && viewport > 0.0;
314 let track = measure::cell(&bar_ident.semantic_id(), cx);
315
316 let fraction = if content > 0.0 {
317 (viewport / content).clamp(0.0, 1.0)
318 } else {
319 1.0
320 };
321 let position = if max > 0.0 {
322 (scrolled / max).clamp(0.0, 1.0)
323 } else {
324 0.0
325 };
326
327 let thumb = overflowing.then(|| {
328 div()
329 .absolute()
330 .rounded_full()
331 .bg(theme.colors.hairline_strong)
332 .when(vertical, |element| {
333 element
334 .w(px(THUMB))
335 .left(px((TRACK - THUMB) / 2.0))
336 .min_h(px(MIN_THUMB))
337 .h(relative(fraction))
338 .top(relative(position * (1.0 - fraction)))
339 })
340 .when(!vertical, |element| {
341 element
342 .h(px(THUMB))
343 .top(px((TRACK - THUMB) / 2.0))
344 .min_w(px(MIN_THUMB))
345 .w(relative(fraction))
346 .left(relative(position * (1.0 - fraction)))
347 })
348 });
349
350 let mut gutter = div()
351 .id(bar_ident.element_id())
352 .relative()
353 .size_full()
354 .bg(theme.colors.panel)
355 .children(thumb);
356
357 if overflowing {
358 let handle = handle.clone();
359 let track = Rc::clone(&track);
360 gutter = gutter.on_mouse_move(move |event, window, _| {
361 if event.pressed_button != Some(MouseButton::Left) {
362 return;
363 }
364 let bounds = track.get();
365 let (origin, extent, pointer) = if vertical {
366 (
367 f32::from(bounds.top()),
368 f32::from(bounds.size.height),
369 f32::from(event.position.y),
370 )
371 } else {
372 (
373 f32::from(bounds.left()),
374 f32::from(bounds.size.width),
375 f32::from(event.position.x),
376 )
377 };
378 if extent <= 0.0 {
379 return;
380 }
381 let travel = (extent * (1.0 - fraction)).max(f32::EPSILON);
382 let next = (((pointer - origin) - travel * fraction / 2.0) / travel).clamp(0.0, 1.0);
383 let scrolled = -next * max;
384 handle.set_offset(if vertical {
385 gpui::point(offset.x, px(scrolled))
386 } else {
387 gpui::point(px(scrolled), offset.y)
388 });
389 window.refresh();
390 });
391 }
392
393 if overflowing {
396 gutter = gutter.semantic_in(
397 cx,
398 NodeSpec::new(bar_ident.semantic_id(), Role::Scrollbar)
399 .parent(ident.semantic_id())
400 .text(cx.strings().text(if vertical {
401 StringKey::ScrollbarVertical
402 } else {
403 StringKey::ScrollbarHorizontal
404 }))
405 .value(format!("{scrolled:.0} of {max:.0}"))
406 .range(0.0, max, scrolled.clamp(0.0, max)),
407 );
408 }
409
410 div()
411 .on_children_prepainted({
412 let track = Rc::clone(&track);
413 move |bounds, window, _| {
414 if let Some(first) = bounds.first() {
415 measure::record(&track, *first, window);
416 }
417 }
418 })
419 .flex_none()
420 .when(vertical, |element| element.w(px(TRACK)).h_full())
421 .when(!vertical, |element| element.h(px(TRACK)).w_full())
422 .child(gutter)
423 .into_any_element()
424}
425
426#[derive(Default)]
427struct ScrollHandles(RefCell<HashMap<SharedString, ScrollHandle>>);
428
429impl Global for ScrollHandles {}
430
431pub fn scroll_offset(ident: impl Into<Ident>, cx: &mut App) -> Point<Pixels> {
439 let offset = scroll_handle(&ident.into(), cx).offset();
440 gpui::point(-offset.x, -offset.y)
441}
442
443pub fn scroll_to(ident: impl Into<Ident>, offset: Point<Pixels>, cx: &mut App) {
451 scroll_handle(&ident.into(), cx).set_offset(gpui::point(-offset.x, -offset.y));
452}
453
454fn scroll_handle(ident: &Ident, cx: &mut App) -> ScrollHandle {
456 if !cx.has_global::<ScrollHandles>() {
457 cx.set_global(ScrollHandles::default());
458 }
459 let mut handles = cx.global::<ScrollHandles>().0.borrow_mut();
460 handles.entry(ident.semantic_id()).or_default().clone()
461}
462
463#[cfg(test)]
464mod tests {
465 use super::*;
466
467 #[test]
468 fn an_axis_knows_which_gutters_it_reserves() {
469 assert!(ScrollAxis::Vertical.has_vertical());
470 assert!(!ScrollAxis::Vertical.has_horizontal());
471 assert!(ScrollAxis::Both.has_vertical() && ScrollAxis::Both.has_horizontal());
472 }
473}