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 let touches_start = old_range.start == 0;
100 self.list_state.splice(old_range, count);
101
102 if let Some(last) = self.list_state.item_count().checked_sub(1) {
107 self.list_state.remeasure_items(last..last + 1);
108 if let Some(neighbor) = neighbor.filter(|neighbor| *neighbor != last) {
109 self.list_state.remeasure_items(neighbor..neighbor + 1);
110 }
111 }
112
113 if touches_start {
116 for index in [0, count] {
117 if index < self.list_state.item_count() {
118 self.list_state.remeasure_items(index..index + 1);
119 }
120 }
121 }
122
123 cx.notify();
124 true
125 }
126
127 pub fn append(&mut self, count: usize, cx: &mut Context<Self>) -> bool {
129 let item_count = self.list_state.item_count();
130 self.splice(item_count..item_count, count, cx)
131 }
132
133 pub fn prepend(&mut self, count: usize, cx: &mut Context<Self>) -> bool {
135 self.splice(0..0, count, cx)
136 }
137
138 pub fn remeasure(&mut self, cx: &mut Context<Self>) {
140 self.list_state.remeasure();
141 cx.notify();
142 }
143
144 pub fn remeasure_items(&mut self, range: Range<usize>, cx: &mut Context<Self>) -> bool {
148 if !self.valid_range(&range) {
149 return false;
150 }
151
152 self.list_state.remeasure_items(range);
153 cx.notify();
154 true
155 }
156
157 pub fn scroll_to_item(&mut self, index: usize, cx: &mut Context<Self>) -> bool {
159 if index >= self.list_state.item_count() {
160 return false;
161 }
162
163 self.list_state.scroll_to(ListOffset {
164 item_ix: index,
165 offset_in_item: px(0.),
166 });
167 cx.notify();
168 true
169 }
170
171 pub fn scroll_to_end(&mut self, cx: &mut Context<Self>) {
173 self.list_state.set_follow_mode(FollowMode::Tail);
174 self.list_state.scroll_to_end();
175 cx.notify();
176 }
177
178 fn valid_range(&self, range: &Range<usize>) -> bool {
179 range.start <= range.end && range.end <= self.list_state.item_count()
180 }
181}
182
183#[derive(IntoElement)]
185pub struct MessageScroller {
186 id: ElementId,
187 state: Entity<MessageScrollerState>,
188 renderer: Box<dyn FnMut(usize, &mut Window, &mut App) -> AnyElement + 'static>,
189 style: StyleRefinement,
190 content_style: StyleRefinement,
191 list_style: StyleRefinement,
192 row_style: StyleRefinement,
193 jump_button_style: StyleRefinement,
194 jump_button_renderer: Option<Box<dyn FnOnce(Button) -> Button>>,
195 jump_button_transition: Duration,
196 bottom_fade: Option<Hsla>,
197 scrollbar: bool,
198 jump_button: bool,
199 jump_button_label: SharedString,
200}
201
202impl MessageScroller {
203 pub fn new<E>(
205 id: impl Into<ElementId>,
206 state: Entity<MessageScrollerState>,
207 renderer: impl FnMut(usize, &mut Window, &mut App) -> E + 'static,
208 ) -> Self
209 where
210 E: IntoElement,
211 {
212 let mut renderer = renderer;
213 Self {
214 id: id.into(),
215 state,
216 renderer: Box::new(move |index, window, cx| {
217 renderer(index, window, cx).into_any_element()
218 }),
219 style: StyleRefinement::default(),
220 content_style: StyleRefinement::default(),
221 list_style: StyleRefinement::default(),
222 row_style: StyleRefinement::default(),
223 jump_button_style: StyleRefinement::default(),
224 jump_button_renderer: None,
225 jump_button_transition: JUMP_BUTTON_TRANSITION,
226 bottom_fade: None,
227 scrollbar: true,
228 jump_button: true,
229 jump_button_label: "Jump to latest".into(),
230 }
231 }
232
233 pub fn scrollbar(mut self, scrollbar: bool) -> Self {
235 self.scrollbar = scrollbar;
236 self
237 }
238
239 pub fn jump_button(mut self, jump_button: bool) -> Self {
241 self.jump_button = jump_button;
242 self
243 }
244
245 pub fn with_jump_button_label(mut self, label: impl Into<SharedString>) -> Self {
247 self.jump_button_label = label.into();
248 self
249 }
250
251 pub fn with_content_style(mut self, style: StyleRefinement) -> Self {
253 self.content_style = style;
254 self
255 }
256
257 pub fn with_list_style(mut self, style: StyleRefinement) -> Self {
259 self.list_style = style;
260 self
261 }
262
263 pub fn with_row_style(mut self, style: StyleRefinement) -> Self {
265 self.row_style = style;
266 self
267 }
268
269 pub fn with_jump_button_style(mut self, style: StyleRefinement) -> Self {
271 self.jump_button_style = style;
272 self
273 }
274
275 pub fn with_jump_button_renderer(
280 mut self,
281 renderer: impl FnOnce(Button) -> Button + 'static,
282 ) -> Self {
283 self.jump_button_renderer = Some(Box::new(renderer));
284 self
285 }
286
287 pub fn with_jump_button_transition(mut self, duration: Duration) -> Self {
292 self.jump_button_transition = duration;
293 self
294 }
295
296 pub fn with_bottom_fade(mut self, color: impl Into<Hsla>) -> Self {
304 self.bottom_fade = Some(color.into());
305 self
306 }
307}
308
309impl Styled for MessageScroller {
310 fn style(&mut self) -> &mut StyleRefinement {
311 &mut self.style
312 }
313}
314
315impl RenderOnce for MessageScroller {
316 fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
317 let root_id = self.id.clone();
318 let (list_state, scrolled_up) = {
319 let state = self.state.read(cx);
320 (state.list_state.clone(), state.is_scrolled_up())
321 };
322 let show_jump_button = self.jump_button && scrolled_up;
323 let jump_button_visibility = if self.jump_button {
324 transition(
325 (root_id.clone(), "jump-button-visibility"),
326 if show_jump_button { 1. } else { 0. },
327 Transition::new(self.jump_button_transition),
328 window,
329 cx,
330 )
331 } else {
332 0.
333 };
334 let bottom_fade_visibility = if self.bottom_fade.is_some() {
337 transition(
338 (root_id.clone(), "bottom-fade-visibility"),
339 if scrolled_up { 1. } else { 0. },
340 Transition::new(BOTTOM_FADE_TRANSITION),
341 window,
342 cx,
343 )
344 } else {
345 0.
346 };
347 let tokens = cx.theme().semantic_tokens();
348 let row_style = self.row_style;
349 let jump_button_style = self.jump_button_style;
350 let jump_button_renderer = self.jump_button_renderer;
351 let mut renderer = self.renderer;
352
353 let mut list_style = self.list_style;
365 let row_inset_left = list_style.padding.left.take();
366 let row_inset_right = list_style.padding.right.take();
367 let row_inset_top = list_style.padding.top.take();
368 let row_inset_bottom = list_style.padding.bottom.take();
369
370 let item_count = list_state.item_count();
374 let list = list(list_state.clone(), move |index, window, cx| {
375 div()
376 .w_full()
377 .when(index == 0, |this| match row_inset_top {
378 Some(top) => this.pt(top),
379 None => this.pt_2(),
380 })
381 .when(index + 1 == item_count, |this| match row_inset_bottom {
382 Some(bottom) => this.pb(bottom),
383 None => this.pb_2(),
384 })
385 .child(
386 div()
387 .w_full()
388 .min_w_0()
389 .px_3()
390 .when(index + 1 < item_count, |this| this.pb_8())
391 .when_some(row_inset_left, |this, left| this.pl(left))
392 .when_some(row_inset_right, |this, right| this.pr(right))
393 .refine_style(&row_style)
394 .child(renderer(index, window, cx)),
395 )
396 .into_any_element()
397 })
398 .size_full()
399 .min_h_0()
400 .refine_style(&list_style);
401
402 let viewport = div()
403 .id((root_id.clone(), "viewport"))
404 .role(Role::Log)
407 .size_full()
408 .min_h_0()
409 .min_w_0()
410 .child(
411 gpui_base::ScrollBounce::new((root_id.clone(), "bounce"), &list_state, list)
414 .on_scroll({
415 let state = self.state.downgrade();
416 move |_, cx| {
417 let _ = state.update(cx, |_, cx| cx.notify());
418 }
419 }),
420 )
421 .when_some(
424 self.bottom_fade.filter(|_| bottom_fade_visibility > 0.),
425 |this, color| {
426 this.child(
427 div()
428 .absolute()
429 .left_0()
430 .right_0()
431 .bottom_0()
432 .h(rems(3.))
433 .opacity(bottom_fade_visibility)
434 .bg(linear_gradient(
435 180.,
436 linear_color_stop(color.opacity(0.), 0.),
437 linear_color_stop(color, 1.),
438 )),
439 )
440 },
441 )
442 .when(self.scrollbar, |this| this.vertical_scrollbar(&list_state))
443 .refine_style(&self.content_style);
444
445 div()
446 .id(root_id.clone())
447 .relative()
448 .size_full()
449 .min_h_0()
450 .overflow_hidden()
451 .child(viewport)
452 .when(self.jump_button && jump_button_visibility > 0., |this| {
453 let state = self.state.clone();
454
455 this.child(
456 div()
457 .absolute()
458 .left_0()
459 .right_0()
460 .bottom(rems(0.5 + jump_button_visibility * 0.5))
461 .flex()
462 .justify_center()
463 .opacity(jump_button_visibility)
464 .child(
465 Button::new((root_id, "jump-to-latest"))
470 .secondary()
471 .icon(IconName::ArrowDown)
472 .tooltip(self.jump_button_label)
473 .rounded(cx.theme().radius_full())
474 .border_1()
475 .border_color(tokens.colors.border)
476 .bg(tokens.colors.background)
477 .text_color(tokens.colors.foreground)
478 .refine_style(&jump_button_style)
479 .on_click(move |_, _, cx| {
480 state.update(cx, |state, cx| state.scroll_to_end(cx));
481 })
482 .when_some(jump_button_renderer, |button, renderer| {
483 renderer(button)
484 })
485 .when(!show_jump_button, |button| button.disabled(true)),
486 ),
487 )
488 })
489 .refine_style(&self.style)
490 }
491}
492
493#[cfg(test)]
494mod tests {
495 use super::*;
496 use crate::Sizable as _;
497 use gpui::AppContext as _;
498
499 #[gpui::test]
500 fn test_message_scroller_state_builder(cx: &mut gpui::TestAppContext) {
501 let state = cx.new(|cx| MessageScrollerState::new(3, cx));
502
503 cx.update(|cx| {
504 assert_eq!(state.read(cx).item_count(), 3);
505 assert!(!state.read(cx).is_scrolled_up());
506 assert!(state.read(cx).is_following_tail());
507
508 state.update(cx, |state, cx| {
509 assert!(!state.scroll_to_item(3, cx));
510 assert!(state.append(2, cx));
511 assert_eq!(state.item_count(), 5);
512 assert!(state.prepend(1, cx));
513 assert_eq!(state.item_count(), 6);
514 assert!(!state.splice(5..7, 0, cx));
515 assert!(state.remeasure_items(0..6, cx));
516 assert!(!state.remeasure_items(6..7, cx));
517 assert!(state.scroll_to_item(2, cx));
518 assert!(!state.is_scrolled_up());
519 assert!(!state.is_following_tail());
520 state.scroll_to_end(cx);
521 assert!(state.is_following_tail());
522 state.reset(2, cx);
523 assert_eq!(state.item_count(), 2);
524 assert!(state.is_following_tail());
525 });
526 });
527 }
528
529 struct ScrollerHarness {
530 state: Entity<MessageScrollerState>,
531 }
532
533 impl gpui::Render for ScrollerHarness {
534 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
535 div().size(px(400.)).child(
536 MessageScroller::new("messages", self.state.clone(), |_, _, _| div().h(px(40.)))
537 .scrollbar(false)
538 .jump_button(false),
539 )
540 }
541 }
542
543 #[gpui::test]
544 fn test_logical_scroll_top_reports_the_row_at_the_top(cx: &mut gpui::TestAppContext) {
545 cx.update(crate::init);
546 let state = cx.new(|cx| MessageScrollerState::new(30, cx));
547 let (_, cx) = cx.add_window_view({
548 let state = state.clone();
549 move |_, _| ScrollerHarness { state }
550 });
551 let draw = |cx: &mut gpui::VisualTestContext| {
552 cx.run_until_parked();
553 cx.update(|window, cx| {
554 _ = window.draw(cx);
555 });
556 };
557 let top = |cx: &mut gpui::VisualTestContext| {
558 cx.update(|_, cx| state.read(cx).logical_scroll_top())
559 };
560 draw(cx);
561
562 let tail = top(cx);
564 assert!(
565 tail.item_ix > 0 && tail.item_ix < 30,
566 "the row at the top while following the tail: {tail:?}"
567 );
568
569 cx.update(|_, cx| state.update(cx, |state, cx| state.scroll_to_item(10, cx)));
570 draw(cx);
571 assert_eq!(top(cx).item_ix, 10);
572
573 cx.simulate_event(gpui::ScrollEvent {
575 position: gpui::point(px(200.), px(200.)),
576 delta: gpui::ScrollDelta::Pixels(gpui::point(px(0.), px(-72.))),
577 ..Default::default()
578 });
579 let after_wheel = top(cx);
580 assert_eq!(
581 (after_wheel.item_ix, after_wheel.offset_in_item),
582 (11, px(0.))
583 );
584 }
585
586 #[gpui::test]
591 fn the_scrollbar_drag_range_reaches_the_end_of_the_content(cx: &mut gpui::TestAppContext) {
592 cx.update(crate::init);
593 let state = cx.new(|cx| MessageScrollerState::new(10, cx));
594 let (_, cx) = cx.add_window_view({
595 let state = state.clone();
596 move |_, _| ScrollerHarness { state }
597 });
598 let draw = |cx: &mut gpui::VisualTestContext| {
599 cx.run_until_parked();
600 cx.update(|window, cx| {
601 _ = window.draw(cx);
602 });
603 };
604 draw(cx);
605
606 cx.update(|_, cx| {
609 let list_state = state.read(cx).list_state.clone();
610 list_state.scrollbar_drag_started();
611 let max = list_state.max_offset_for_scrollbar().y;
612 list_state.set_offset_from_scrollbar(gpui::point(px(0.), -max));
613 list_state.scrollbar_drag_ended();
614 });
615
616 cx.update(|_, cx| {
617 let list_state = state.read(cx).list_state.clone();
618 assert_eq!(
619 list_state.is_scrolled_to_end(),
620 Some(true),
621 "the drag must land on the end of the content"
622 );
623 assert!(
624 list_state.is_following_tail(),
625 "the drag to the end must resume the tail"
626 );
627 assert!(
628 !state.read(cx).is_scrolled_up(),
629 "the jump-to-latest button must hide at the end of the content"
630 );
631 });
632 }
633
634 struct PaddedScrollerHarness {
635 state: Entity<MessageScrollerState>,
636 list_style: StyleRefinement,
637 }
638
639 impl gpui::Render for PaddedScrollerHarness {
640 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
641 div().size(px(400.)).child(
642 MessageScroller::new("messages", self.state.clone(), |_, _, _| div().h(px(40.)))
643 .scrollbar(false)
644 .jump_button(false)
645 .with_list_style(self.list_style.clone())
646 .with_row_style(StyleRefinement::default().pt(px(6.)).pb(px(6.))),
647 )
648 }
649 }
650
651 #[gpui::test]
655 fn a_custom_list_padding_rides_on_the_first_and_the_last_row(cx: &mut gpui::TestAppContext) {
656 cx.update(crate::init);
657
658 let first = cx.new(|cx| MessageScrollerState::new(10, cx));
659 let second = cx.new(|cx| MessageScrollerState::new(10, cx));
660 let third = cx.new(|cx| MessageScrollerState::new(10, cx));
661
662 let extent = |cx: &mut gpui::TestAppContext,
663 state: Entity<MessageScrollerState>,
664 list_style: StyleRefinement| {
665 let (_, context) = cx.add_window_view({
666 let state = state.clone();
667 move |_, _| PaddedScrollerHarness { state, list_style }
668 });
669 context.run_until_parked();
670 context.update(|window, cx| {
671 _ = window.draw(cx);
672 state.read(cx).list_state.max_offset_for_scrollbar().y
673 })
674 };
675
676 let first_extent = extent(
677 cx,
678 first,
679 StyleRefinement::default().pt(px(20.)).pb(px(40.)),
680 );
681 let second_extent = extent(
682 cx,
683 second,
684 StyleRefinement::default().pt(px(20.)).pb(px(20.)),
685 );
686 let third_extent = extent(
687 cx,
688 third,
689 StyleRefinement::default().pt(px(40.)).pb(px(20.)),
690 );
691
692 assert_eq!(
695 first_extent - second_extent,
696 px(20.),
697 "the bottom inset must ride on the last row only"
698 );
699 assert_eq!(
701 third_extent - second_extent,
702 px(20.),
703 "the top inset must ride on the first row only"
704 );
705 }
706
707 #[gpui::test]
708 fn test_message_scroller_builder(cx: &mut gpui::TestAppContext) {
709 let state = cx.new(|cx| MessageScrollerState::new(0, cx));
710 let scroller = MessageScroller::new("message-scroller", state, |_, _, _| div())
711 .scrollbar(false)
712 .jump_button(false)
713 .with_jump_button_label("Latest")
714 .with_content_style(StyleRefinement::default())
715 .with_list_style(StyleRefinement::default())
716 .with_row_style(StyleRefinement::default())
717 .with_jump_button_style(StyleRefinement::default())
718 .with_jump_button_renderer(|button| button.large())
719 .with_jump_button_transition(Duration::from_millis(300))
720 .with_bottom_fade(gpui::white());
721
722 assert!(!scroller.scrollbar);
723 assert!(!scroller.jump_button);
724 assert_eq!(scroller.jump_button_label, "Latest");
725 assert!(scroller.jump_button_renderer.is_some());
726 assert_eq!(scroller.jump_button_transition, Duration::from_millis(300));
727 assert_eq!(scroller.bottom_fade, Some(gpui::white()));
728 }
729}