Skip to main content

gpui_component/
message_scroller.rs

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
18/// The entity-owned scrolling state for a [`MessageScroller`].
19///
20/// The state owns only GPUI's virtual-list bookkeeping. Message data remains
21/// with the caller and is read by the row renderer passed to
22/// [`MessageScroller::new`].
23pub struct MessageScrollerState {
24    list_state: ListState,
25}
26
27impl MessageScrollerState {
28    /// Create a state for `item_count` rows and enable tail following.
29    ///
30    /// The constructor receives the entity context so the list's scroll
31    /// handler can safely defer its entity update until GPUI has released the
32    /// list's internal borrow.
33    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    /// Return the current number of rows known by the virtual list.
50    pub fn item_count(&self) -> usize {
51        self.list_state.item_count()
52    }
53
54    /// Return whether the user has scrolled away from the latest content.
55    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    /// Return whether the list is actively following its tail.
62    pub fn is_following_tail(&self) -> bool {
63        self.list_state.is_following_tail()
64    }
65
66    /// Return the row at the top of the viewport and how far into it the list
67    /// is scrolled.
68    ///
69    /// Wheel, trackpad, and scrollbar scrolls and [`Self::scroll_to_item`]
70    /// update it at once. [`Self::scroll_to_end`] and tail following settle it
71    /// when the list next lays out; right after `scroll_to_end`, `item_ix` is
72    /// [`Self::item_count`] until then.
73    pub fn logical_scroll_top(&self) -> ListOffset {
74        self.list_state.logical_scroll_top()
75    }
76
77    /// Reset the list to `item_count` rows.
78    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    /// Replace `old_range` with `count` new rows.
85    ///
86    /// Returns `false` when the range is outside the current list and leaves
87    /// the state unchanged.
88    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        // The default row wrapper pads every row except the last, so a row
102        // whose "last" status may have flipped carries a stale measured
103        // height. Remeasure the new last row and the survivor next to the
104        // splice.
105        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    /// Append `count` rows to the end of the list.
117    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    /// Prepend `count` rows while preserving the current scroll anchor.
123    pub fn prepend(&mut self, count: usize, cx: &mut Context<Self>) -> bool {
124        self.splice(0..0, count, cx)
125    }
126
127    /// Mark all rows for remeasurement while preserving a proportional anchor.
128    pub fn remeasure(&mut self, cx: &mut Context<Self>) {
129        self.list_state.remeasure();
130        cx.notify();
131    }
132
133    /// Mark rows in `range` for remeasurement while preserving an item anchor.
134    ///
135    /// Returns `false` when the range is outside the current list.
136    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    /// Scroll to the row at `index`, if it exists.
147    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    /// Resume tail following and scroll to the latest row.
161    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/// A virtualized message list with optional scrollbar and jump-to-latest UI.
173#[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    /// Create a message scroller with a renderer for each row.
193    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    /// Enable or disable the virtual-list scrollbar.
223    pub fn scrollbar(mut self, scrollbar: bool) -> Self {
224        self.scrollbar = scrollbar;
225        self
226    }
227
228    /// Enable or disable the built-in jump-to-latest button.
229    pub fn jump_button(mut self, jump_button: bool) -> Self {
230        self.jump_button = jump_button;
231        self
232    }
233
234    /// Set the label used by the built-in jump-to-latest button.
235    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    /// Refine the viewport that contains the list and scrollbar.
241    pub fn with_content_style(mut self, style: StyleRefinement) -> Self {
242        self.content_style = style;
243        self
244    }
245
246    /// Refine the GPUI list element used to render rows.
247    pub fn with_list_style(mut self, style: StyleRefinement) -> Self {
248        self.list_style = style;
249        self
250    }
251
252    /// Refine the full-width wrapper around every rendered row.
253    pub fn with_row_style(mut self, style: StyleRefinement) -> Self {
254        self.row_style = style;
255        self
256    }
257
258    /// Refine the built-in jump-to-latest button after its defaults.
259    pub fn with_jump_button_style(mut self, style: StyleRefinement) -> Self {
260        self.jump_button_style = style;
261        self
262    }
263
264    /// Customize the built-in jump button without replacing its scroll action.
265    ///
266    /// The callback receives the fully configured Button, so its variant,
267    /// semantic size, icon, tooltip, or instance styling may be adjusted.
268    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    /// Set how long the built-in jump button takes to enter or leave.
277    ///
278    /// A zero duration disables its transition. Reduced-motion preferences
279    /// always adopt the final state immediately.
280    pub fn with_jump_button_transition(mut self, duration: Duration) -> Self {
281        self.jump_button_transition = duration;
282        self
283    }
284
285    /// Fade the transcript's bottom edge into `color`.
286    ///
287    /// A partially visible row melts into the surface behind the scroller
288    /// instead of clipping mid-line. The fade shows only while the reader is
289    /// away from the live edge — at the bottom nothing is clipped. Pass the
290    /// color of that surface; the fade is off by default and sits under the
291    /// jump button.
292    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        // At the live edge nothing is clipped below, so a visible fade would
324        // suggest more content than there is.
325        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        // GPUI's `list` lays rows out at the full list width and offsets them
343        // only by vertical padding, so the horizontal component of the list
344        // style must be carried by every row wrapper instead.
345        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        // Read the count outside the row closure: the list holds a mutable
350        // borrow of its state while rendering rows, so the closure must not
351        // borrow it again. The count is stable within one render pass.
352        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                // Spacing between rows only, like a CSS gap: the list's own
359                // bottom padding owns the gap after the last row.
360                .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            // Announce appended rows as a log region, like shadcn's
375            // `role="log"` transcript content.
376            .role(Role::Log)
377            .size_full()
378            .min_h_0()
379            .min_w_0()
380            .child(
381                // The bounce scrolls the list itself, past the list's own
382                // scroll handler; tell the state, as that handler would.
383                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            // The fade sits above the rows but below the scrollbar and the
392            // jump button, so neither control is washed out by it.
393            .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                            // No explicit width or height: Button sizes an
436                            // icon-only button as a square on its own, and a
437                            // renderer that adds a label or another semantic
438                            // size must be able to change the layout.
439                            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        // Following the tail, layout settles on the row the tail starts on.
533        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        // A wheel scroll by one row: its 40px and the 32px gap after it.
544        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}