1use std::{ops::Range, time::Duration};
2
3use gpui::{
4 AnyElement, App, Context, ElementId, Entity, FollowMode, Hsla, InteractiveElement as _,
5 IntoElement, ListAlignment, ListOffset, ListState, ParentElement as _, RenderOnce, Role,
6 SharedString, StatefulInteractiveElement as _, StyleRefinement, Styled, Window, div,
7 linear_color_stop, linear_gradient, list, prelude::FluentBuilder as _, px, rems,
8};
9use gpui_base::motion::{Transition, transition};
10
11use crate::{ActiveTheme as _, Disableable as _, IconName, StyledExt as _, button::Button};
12use crate::{button::ButtonVariants as _, scroll::ScrollableElement as _};
13
14const LIST_OVERDRAW: gpui::Pixels = px(400.);
15const JUMP_BUTTON_TRANSITION: Duration = Duration::from_millis(200);
16const BOTTOM_FADE_TRANSITION: Duration = Duration::from_millis(200);
17
18pub struct MessageScrollerState {
24 list_state: ListState,
25}
26
27impl MessageScrollerState {
28 pub fn new(item_count: usize, cx: &mut Context<Self>) -> Self {
34 let list_state = ListState::new(item_count, ListAlignment::Top, LIST_OVERDRAW);
35 list_state.set_follow_mode(FollowMode::Tail);
36
37 let weak_state = cx.weak_entity();
38 list_state.set_scroll_handler(move |_, _, cx| {
39 let weak_state = weak_state.clone();
40
41 cx.defer(move |cx| {
42 let _ = weak_state.update(cx, |_, cx| cx.notify());
43 });
44 });
45
46 Self { list_state }
47 }
48
49 pub fn item_count(&self) -> usize {
51 self.list_state.item_count()
52 }
53
54 pub fn is_scrolled_up(&self) -> bool {
56 self.list_state.max_offset_for_scrollbar().y > px(0.)
57 && !self.list_state.is_following_tail()
58 && !self.list_state.is_scrolled_to_end().unwrap_or(false)
59 }
60
61 pub fn is_following_tail(&self) -> bool {
63 self.list_state.is_following_tail()
64 }
65
66 pub fn logical_scroll_top(&self) -> ListOffset {
74 self.list_state.logical_scroll_top()
75 }
76
77 pub fn reset(&mut self, item_count: usize, cx: &mut Context<Self>) {
79 self.list_state.reset(item_count);
80 self.list_state.set_follow_mode(FollowMode::Tail);
81 cx.notify();
82 }
83
84 pub fn splice(
89 &mut self,
90 old_range: Range<usize>,
91 count: usize,
92 cx: &mut Context<Self>,
93 ) -> bool {
94 if !self.valid_range(&old_range) {
95 return false;
96 }
97
98 let neighbor = old_range.start.checked_sub(1);
99 self.list_state.splice(old_range, count);
100
101 if let Some(last) = self.list_state.item_count().checked_sub(1) {
106 self.list_state.remeasure_items(last..last + 1);
107 if let Some(neighbor) = neighbor.filter(|neighbor| *neighbor != last) {
108 self.list_state.remeasure_items(neighbor..neighbor + 1);
109 }
110 }
111
112 cx.notify();
113 true
114 }
115
116 pub fn append(&mut self, count: usize, cx: &mut Context<Self>) -> bool {
118 let item_count = self.list_state.item_count();
119 self.splice(item_count..item_count, count, cx)
120 }
121
122 pub fn prepend(&mut self, count: usize, cx: &mut Context<Self>) -> bool {
124 self.splice(0..0, count, cx)
125 }
126
127 pub fn remeasure(&mut self, cx: &mut Context<Self>) {
129 self.list_state.remeasure();
130 cx.notify();
131 }
132
133 pub fn remeasure_items(&mut self, range: Range<usize>, cx: &mut Context<Self>) -> bool {
137 if !self.valid_range(&range) {
138 return false;
139 }
140
141 self.list_state.remeasure_items(range);
142 cx.notify();
143 true
144 }
145
146 pub fn scroll_to_item(&mut self, index: usize, cx: &mut Context<Self>) -> bool {
148 if index >= self.list_state.item_count() {
149 return false;
150 }
151
152 self.list_state.scroll_to(ListOffset {
153 item_ix: index,
154 offset_in_item: px(0.),
155 });
156 cx.notify();
157 true
158 }
159
160 pub fn scroll_to_end(&mut self, cx: &mut Context<Self>) {
162 self.list_state.set_follow_mode(FollowMode::Tail);
163 self.list_state.scroll_to_end();
164 cx.notify();
165 }
166
167 fn valid_range(&self, range: &Range<usize>) -> bool {
168 range.start <= range.end && range.end <= self.list_state.item_count()
169 }
170}
171
172#[derive(IntoElement)]
174pub struct MessageScroller {
175 id: ElementId,
176 state: Entity<MessageScrollerState>,
177 renderer: Box<dyn FnMut(usize, &mut Window, &mut App) -> AnyElement + 'static>,
178 style: StyleRefinement,
179 content_style: StyleRefinement,
180 list_style: StyleRefinement,
181 row_style: StyleRefinement,
182 jump_button_style: StyleRefinement,
183 jump_button_renderer: Option<Box<dyn FnOnce(Button) -> Button>>,
184 jump_button_transition: Duration,
185 bottom_fade: Option<Hsla>,
186 scrollbar: bool,
187 jump_button: bool,
188 jump_button_label: SharedString,
189}
190
191impl MessageScroller {
192 pub fn new<E>(
194 id: impl Into<ElementId>,
195 state: Entity<MessageScrollerState>,
196 renderer: impl FnMut(usize, &mut Window, &mut App) -> E + 'static,
197 ) -> Self
198 where
199 E: IntoElement,
200 {
201 let mut renderer = renderer;
202 Self {
203 id: id.into(),
204 state,
205 renderer: Box::new(move |index, window, cx| {
206 renderer(index, window, cx).into_any_element()
207 }),
208 style: StyleRefinement::default(),
209 content_style: StyleRefinement::default(),
210 list_style: StyleRefinement::default(),
211 row_style: StyleRefinement::default(),
212 jump_button_style: StyleRefinement::default(),
213 jump_button_renderer: None,
214 jump_button_transition: JUMP_BUTTON_TRANSITION,
215 bottom_fade: None,
216 scrollbar: true,
217 jump_button: true,
218 jump_button_label: "Jump to latest".into(),
219 }
220 }
221
222 pub fn scrollbar(mut self, scrollbar: bool) -> Self {
224 self.scrollbar = scrollbar;
225 self
226 }
227
228 pub fn jump_button(mut self, jump_button: bool) -> Self {
230 self.jump_button = jump_button;
231 self
232 }
233
234 pub fn with_jump_button_label(mut self, label: impl Into<SharedString>) -> Self {
236 self.jump_button_label = label.into();
237 self
238 }
239
240 pub fn with_content_style(mut self, style: StyleRefinement) -> Self {
242 self.content_style = style;
243 self
244 }
245
246 pub fn with_list_style(mut self, style: StyleRefinement) -> Self {
248 self.list_style = style;
249 self
250 }
251
252 pub fn with_row_style(mut self, style: StyleRefinement) -> Self {
254 self.row_style = style;
255 self
256 }
257
258 pub fn with_jump_button_style(mut self, style: StyleRefinement) -> Self {
260 self.jump_button_style = style;
261 self
262 }
263
264 pub fn with_jump_button_renderer(
269 mut self,
270 renderer: impl FnOnce(Button) -> Button + 'static,
271 ) -> Self {
272 self.jump_button_renderer = Some(Box::new(renderer));
273 self
274 }
275
276 pub fn with_jump_button_transition(mut self, duration: Duration) -> Self {
281 self.jump_button_transition = duration;
282 self
283 }
284
285 pub fn with_bottom_fade(mut self, color: impl Into<Hsla>) -> Self {
293 self.bottom_fade = Some(color.into());
294 self
295 }
296}
297
298impl Styled for MessageScroller {
299 fn style(&mut self) -> &mut StyleRefinement {
300 &mut self.style
301 }
302}
303
304impl RenderOnce for MessageScroller {
305 fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
306 let root_id = self.id.clone();
307 let (list_state, scrolled_up) = {
308 let state = self.state.read(cx);
309 (state.list_state.clone(), state.is_scrolled_up())
310 };
311 let show_jump_button = self.jump_button && scrolled_up;
312 let jump_button_visibility = if self.jump_button {
313 transition(
314 (root_id.clone(), "jump-button-visibility"),
315 if show_jump_button { 1. } else { 0. },
316 Transition::new(self.jump_button_transition),
317 window,
318 cx,
319 )
320 } else {
321 0.
322 };
323 let bottom_fade_visibility = if self.bottom_fade.is_some() {
326 transition(
327 (root_id.clone(), "bottom-fade-visibility"),
328 if scrolled_up { 1. } else { 0. },
329 Transition::new(BOTTOM_FADE_TRANSITION),
330 window,
331 cx,
332 )
333 } else {
334 0.
335 };
336 let tokens = cx.theme().semantic_tokens();
337 let row_style = self.row_style;
338 let jump_button_style = self.jump_button_style;
339 let jump_button_renderer = self.jump_button_renderer;
340 let mut renderer = self.renderer;
341
342 let mut list_style = self.list_style;
346 let row_inset_left = list_style.padding.left.take();
347 let row_inset_right = list_style.padding.right.take();
348
349 let item_count = list_state.item_count();
353 let list = list(list_state.clone(), move |index, window, cx| {
354 div()
355 .w_full()
356 .min_w_0()
357 .px_3()
358 .when(index + 1 < item_count, |this| this.pb_8())
361 .when_some(row_inset_left, |this, left| this.pl(left))
362 .when_some(row_inset_right, |this, right| this.pr(right))
363 .refine_style(&row_style)
364 .child(renderer(index, window, cx))
365 .into_any_element()
366 })
367 .size_full()
368 .min_h_0()
369 .py_2()
370 .refine_style(&list_style);
371
372 let viewport = div()
373 .id((root_id.clone(), "viewport"))
374 .role(Role::Log)
377 .size_full()
378 .min_h_0()
379 .min_w_0()
380 .child(
381 gpui_base::ScrollBounce::new((root_id.clone(), "bounce"), &list_state, list)
384 .on_scroll({
385 let state = self.state.downgrade();
386 move |_, cx| {
387 let _ = state.update(cx, |_, cx| cx.notify());
388 }
389 }),
390 )
391 .when_some(
394 self.bottom_fade.filter(|_| bottom_fade_visibility > 0.),
395 |this, color| {
396 this.child(
397 div()
398 .absolute()
399 .left_0()
400 .right_0()
401 .bottom_0()
402 .h(rems(3.))
403 .opacity(bottom_fade_visibility)
404 .bg(linear_gradient(
405 180.,
406 linear_color_stop(color.opacity(0.), 0.),
407 linear_color_stop(color, 1.),
408 )),
409 )
410 },
411 )
412 .when(self.scrollbar, |this| this.vertical_scrollbar(&list_state))
413 .refine_style(&self.content_style);
414
415 div()
416 .id(root_id.clone())
417 .relative()
418 .size_full()
419 .min_h_0()
420 .overflow_hidden()
421 .child(viewport)
422 .when(self.jump_button && jump_button_visibility > 0., |this| {
423 let state = self.state.clone();
424
425 this.child(
426 div()
427 .absolute()
428 .left_0()
429 .right_0()
430 .bottom(rems(0.5 + jump_button_visibility * 0.5))
431 .flex()
432 .justify_center()
433 .opacity(jump_button_visibility)
434 .child(
435 Button::new((root_id, "jump-to-latest"))
440 .secondary()
441 .icon(IconName::ArrowDown)
442 .tooltip(self.jump_button_label)
443 .rounded(cx.theme().radius_full())
444 .border_1()
445 .border_color(tokens.colors.border)
446 .bg(tokens.colors.background)
447 .text_color(tokens.colors.foreground)
448 .refine_style(&jump_button_style)
449 .on_click(move |_, _, cx| {
450 state.update(cx, |state, cx| state.scroll_to_end(cx));
451 })
452 .when_some(jump_button_renderer, |button, renderer| {
453 renderer(button)
454 })
455 .when(!show_jump_button, |button| button.disabled(true)),
456 ),
457 )
458 })
459 .refine_style(&self.style)
460 }
461}
462
463#[cfg(test)]
464mod tests {
465 use super::*;
466 use crate::Sizable as _;
467 use gpui::AppContext as _;
468
469 #[gpui::test]
470 fn test_message_scroller_state_builder(cx: &mut gpui::TestAppContext) {
471 let state = cx.new(|cx| MessageScrollerState::new(3, cx));
472
473 cx.update(|cx| {
474 assert_eq!(state.read(cx).item_count(), 3);
475 assert!(!state.read(cx).is_scrolled_up());
476 assert!(state.read(cx).is_following_tail());
477
478 state.update(cx, |state, cx| {
479 assert!(!state.scroll_to_item(3, cx));
480 assert!(state.append(2, cx));
481 assert_eq!(state.item_count(), 5);
482 assert!(state.prepend(1, cx));
483 assert_eq!(state.item_count(), 6);
484 assert!(!state.splice(5..7, 0, cx));
485 assert!(state.remeasure_items(0..6, cx));
486 assert!(!state.remeasure_items(6..7, cx));
487 assert!(state.scroll_to_item(2, cx));
488 assert!(!state.is_scrolled_up());
489 assert!(!state.is_following_tail());
490 state.scroll_to_end(cx);
491 assert!(state.is_following_tail());
492 state.reset(2, cx);
493 assert_eq!(state.item_count(), 2);
494 assert!(state.is_following_tail());
495 });
496 });
497 }
498
499 struct ScrollerHarness {
500 state: Entity<MessageScrollerState>,
501 }
502
503 impl gpui::Render for ScrollerHarness {
504 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
505 div().size(px(400.)).child(
506 MessageScroller::new("messages", self.state.clone(), |_, _, _| div().h(px(40.)))
507 .scrollbar(false)
508 .jump_button(false),
509 )
510 }
511 }
512
513 #[gpui::test]
514 fn test_logical_scroll_top_reports_the_row_at_the_top(cx: &mut gpui::TestAppContext) {
515 cx.update(crate::init);
516 let state = cx.new(|cx| MessageScrollerState::new(30, cx));
517 let (_, cx) = cx.add_window_view({
518 let state = state.clone();
519 move |_, _| ScrollerHarness { state }
520 });
521 let draw = |cx: &mut gpui::VisualTestContext| {
522 cx.run_until_parked();
523 cx.update(|window, cx| {
524 _ = window.draw(cx);
525 });
526 };
527 let top = |cx: &mut gpui::VisualTestContext| {
528 cx.update(|_, cx| state.read(cx).logical_scroll_top())
529 };
530 draw(cx);
531
532 let tail = top(cx);
534 assert!(
535 tail.item_ix > 0 && tail.item_ix < 30,
536 "the row at the top while following the tail: {tail:?}"
537 );
538
539 cx.update(|_, cx| state.update(cx, |state, cx| state.scroll_to_item(10, cx)));
540 draw(cx);
541 assert_eq!(top(cx).item_ix, 10);
542
543 cx.simulate_event(gpui::ScrollWheelEvent {
545 position: gpui::point(px(200.), px(200.)),
546 delta: gpui::ScrollDelta::Pixels(gpui::point(px(0.), px(-72.))),
547 ..Default::default()
548 });
549 let after_wheel = top(cx);
550 assert_eq!(
551 (after_wheel.item_ix, after_wheel.offset_in_item),
552 (11, px(0.))
553 );
554 }
555
556 #[gpui::test]
557 fn test_message_scroller_builder(cx: &mut gpui::TestAppContext) {
558 let state = cx.new(|cx| MessageScrollerState::new(0, cx));
559 let scroller = MessageScroller::new("message-scroller", state, |_, _, _| div())
560 .scrollbar(false)
561 .jump_button(false)
562 .with_jump_button_label("Latest")
563 .with_content_style(StyleRefinement::default())
564 .with_list_style(StyleRefinement::default())
565 .with_row_style(StyleRefinement::default())
566 .with_jump_button_style(StyleRefinement::default())
567 .with_jump_button_renderer(|button| button.large())
568 .with_jump_button_transition(Duration::from_millis(300))
569 .with_bottom_fade(gpui::white());
570
571 assert!(!scroller.scrollbar);
572 assert!(!scroller.jump_button);
573 assert_eq!(scroller.jump_button_label, "Latest");
574 assert!(scroller.jump_button_renderer.is_some());
575 assert_eq!(scroller.jump_button_transition, Duration::from_millis(300));
576 assert_eq!(scroller.bottom_fade, Some(gpui::white()));
577 }
578}