Skip to main content

gpui_component/
message_scroller.rs

1use std::{ops::Range, time::Duration};
2
3use gpui::{
4    AnyElement, App, Axis, 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::{
13    button::ButtonVariants as _,
14    scroll::{ScrollableElement as _, ScrollableMask},
15};
16
17const LIST_OVERDRAW: gpui::Pixels = px(400.);
18const JUMP_BUTTON_TRANSITION: Duration = Duration::from_millis(200);
19const BOTTOM_FADE_TRANSITION: Duration = Duration::from_millis(200);
20
21/// The entity-owned scrolling state for a [`MessageScroller`].
22///
23/// The state owns only GPUI's virtual-list bookkeeping. Message data remains
24/// with the caller and is read by the row renderer passed to
25/// [`MessageScroller::new`].
26pub struct MessageScrollerState {
27    list_state: ListState,
28}
29
30impl MessageScrollerState {
31    /// Create a state for `item_count` rows and enable tail following.
32    ///
33    /// The constructor receives the entity context so the list's scroll
34    /// handler can safely defer its entity update until GPUI has released the
35    /// list's internal borrow.
36    pub fn new(item_count: usize, cx: &mut Context<Self>) -> Self {
37        let list_state = ListState::new(item_count, ListAlignment::Top, LIST_OVERDRAW);
38        list_state.set_follow_mode(FollowMode::Tail);
39
40        let weak_state = cx.weak_entity();
41        list_state.set_scroll_handler(move |_, _, cx| {
42            let weak_state = weak_state.clone();
43
44            cx.defer(move |cx| {
45                let _ = weak_state.update(cx, |_, cx| cx.notify());
46            });
47        });
48
49        Self { list_state }
50    }
51
52    /// Return the current number of rows known by the virtual list.
53    pub fn item_count(&self) -> usize {
54        self.list_state.item_count()
55    }
56
57    /// Return whether the user has scrolled away from the latest content.
58    pub fn is_scrolled_up(&self) -> bool {
59        self.list_state.max_offset_for_scrollbar().y > px(0.)
60            && !self.list_state.is_following_tail()
61            && !self.list_state.is_scrolled_to_end().unwrap_or(false)
62    }
63
64    /// Return whether the list is actively following its tail.
65    pub fn is_following_tail(&self) -> bool {
66        self.list_state.is_following_tail()
67    }
68
69    /// Reset the list to `item_count` rows.
70    pub fn reset(&mut self, item_count: usize, cx: &mut Context<Self>) {
71        self.list_state.reset(item_count);
72        self.list_state.set_follow_mode(FollowMode::Tail);
73        cx.notify();
74    }
75
76    /// Replace `old_range` with `count` new rows.
77    ///
78    /// Returns `false` when the range is outside the current list and leaves
79    /// the state unchanged.
80    pub fn splice(
81        &mut self,
82        old_range: Range<usize>,
83        count: usize,
84        cx: &mut Context<Self>,
85    ) -> bool {
86        if !self.valid_range(&old_range) {
87            return false;
88        }
89
90        let neighbor = old_range.start.checked_sub(1);
91        self.list_state.splice(old_range, count);
92
93        // The default row wrapper pads every row except the last, so a row
94        // whose "last" status may have flipped carries a stale measured
95        // height. Remeasure the new last row and the survivor next to the
96        // splice.
97        if let Some(last) = self.list_state.item_count().checked_sub(1) {
98            self.list_state.remeasure_items(last..last + 1);
99            if let Some(neighbor) = neighbor.filter(|neighbor| *neighbor != last) {
100                self.list_state.remeasure_items(neighbor..neighbor + 1);
101            }
102        }
103
104        cx.notify();
105        true
106    }
107
108    /// Append `count` rows to the end of the list.
109    pub fn append(&mut self, count: usize, cx: &mut Context<Self>) -> bool {
110        let item_count = self.list_state.item_count();
111        self.splice(item_count..item_count, count, cx)
112    }
113
114    /// Prepend `count` rows while preserving the current scroll anchor.
115    pub fn prepend(&mut self, count: usize, cx: &mut Context<Self>) -> bool {
116        self.splice(0..0, count, cx)
117    }
118
119    /// Mark all rows for remeasurement while preserving a proportional anchor.
120    pub fn remeasure(&mut self, cx: &mut Context<Self>) {
121        self.list_state.remeasure();
122        cx.notify();
123    }
124
125    /// Mark rows in `range` for remeasurement while preserving an item anchor.
126    ///
127    /// Returns `false` when the range is outside the current list.
128    pub fn remeasure_items(&mut self, range: Range<usize>, cx: &mut Context<Self>) -> bool {
129        if !self.valid_range(&range) {
130            return false;
131        }
132
133        self.list_state.remeasure_items(range);
134        cx.notify();
135        true
136    }
137
138    /// Scroll to the row at `index`, if it exists.
139    pub fn scroll_to_item(&mut self, index: usize, cx: &mut Context<Self>) -> bool {
140        if index >= self.list_state.item_count() {
141            return false;
142        }
143
144        self.list_state.scroll_to(ListOffset {
145            item_ix: index,
146            offset_in_item: px(0.),
147        });
148        cx.notify();
149        true
150    }
151
152    /// Resume tail following and scroll to the latest row.
153    pub fn scroll_to_end(&mut self, cx: &mut Context<Self>) {
154        self.list_state.set_follow_mode(FollowMode::Tail);
155        self.list_state.scroll_to_end();
156        cx.notify();
157    }
158
159    fn valid_range(&self, range: &Range<usize>) -> bool {
160        range.start <= range.end && range.end <= self.list_state.item_count()
161    }
162}
163
164/// A virtualized message list with optional scrollbar and jump-to-latest UI.
165#[derive(IntoElement)]
166pub struct MessageScroller {
167    id: ElementId,
168    state: Entity<MessageScrollerState>,
169    renderer: Box<dyn FnMut(usize, &mut Window, &mut App) -> AnyElement + 'static>,
170    style: StyleRefinement,
171    content_style: StyleRefinement,
172    list_style: StyleRefinement,
173    row_style: StyleRefinement,
174    jump_button_style: StyleRefinement,
175    jump_button_renderer: Option<Box<dyn FnOnce(Button) -> Button>>,
176    jump_button_transition: Duration,
177    bottom_fade: Option<Hsla>,
178    scrollbar: bool,
179    jump_button: bool,
180    jump_button_label: SharedString,
181}
182
183impl MessageScroller {
184    /// Create a message scroller with a renderer for each row.
185    pub fn new<E>(
186        id: impl Into<ElementId>,
187        state: Entity<MessageScrollerState>,
188        renderer: impl FnMut(usize, &mut Window, &mut App) -> E + 'static,
189    ) -> Self
190    where
191        E: IntoElement,
192    {
193        let mut renderer = renderer;
194        Self {
195            id: id.into(),
196            state,
197            renderer: Box::new(move |index, window, cx| {
198                renderer(index, window, cx).into_any_element()
199            }),
200            style: StyleRefinement::default(),
201            content_style: StyleRefinement::default(),
202            list_style: StyleRefinement::default(),
203            row_style: StyleRefinement::default(),
204            jump_button_style: StyleRefinement::default(),
205            jump_button_renderer: None,
206            jump_button_transition: JUMP_BUTTON_TRANSITION,
207            bottom_fade: None,
208            scrollbar: true,
209            jump_button: true,
210            jump_button_label: "Jump to latest".into(),
211        }
212    }
213
214    /// Enable or disable the virtual-list scrollbar.
215    pub fn scrollbar(mut self, scrollbar: bool) -> Self {
216        self.scrollbar = scrollbar;
217        self
218    }
219
220    /// Enable or disable the built-in jump-to-latest button.
221    pub fn jump_button(mut self, jump_button: bool) -> Self {
222        self.jump_button = jump_button;
223        self
224    }
225
226    /// Set the label used by the built-in jump-to-latest button.
227    pub fn with_jump_button_label(mut self, label: impl Into<SharedString>) -> Self {
228        self.jump_button_label = label.into();
229        self
230    }
231
232    /// Refine the viewport that contains the list and scrollbar.
233    pub fn with_content_style(mut self, style: StyleRefinement) -> Self {
234        self.content_style = style;
235        self
236    }
237
238    /// Refine the GPUI list element used to render rows.
239    pub fn with_list_style(mut self, style: StyleRefinement) -> Self {
240        self.list_style = style;
241        self
242    }
243
244    /// Refine the full-width wrapper around every rendered row.
245    pub fn with_row_style(mut self, style: StyleRefinement) -> Self {
246        self.row_style = style;
247        self
248    }
249
250    /// Refine the built-in jump-to-latest button after its defaults.
251    pub fn with_jump_button_style(mut self, style: StyleRefinement) -> Self {
252        self.jump_button_style = style;
253        self
254    }
255
256    /// Customize the built-in jump button without replacing its scroll action.
257    ///
258    /// The callback receives the fully configured Button, so its variant,
259    /// semantic size, icon, tooltip, or instance styling may be adjusted.
260    pub fn with_jump_button_renderer(
261        mut self,
262        renderer: impl FnOnce(Button) -> Button + 'static,
263    ) -> Self {
264        self.jump_button_renderer = Some(Box::new(renderer));
265        self
266    }
267
268    /// Set how long the built-in jump button takes to enter or leave.
269    ///
270    /// A zero duration disables its transition. Reduced-motion preferences
271    /// always adopt the final state immediately.
272    pub fn with_jump_button_transition(mut self, duration: Duration) -> Self {
273        self.jump_button_transition = duration;
274        self
275    }
276
277    /// Fade the transcript's bottom edge into `color`.
278    ///
279    /// A partially visible row melts into the surface behind the scroller
280    /// instead of clipping mid-line. The fade shows only while the reader is
281    /// away from the live edge — at the bottom nothing is clipped. Pass the
282    /// color of that surface; the fade is off by default and sits under the
283    /// jump button.
284    pub fn with_bottom_fade(mut self, color: impl Into<Hsla>) -> Self {
285        self.bottom_fade = Some(color.into());
286        self
287    }
288}
289
290impl Styled for MessageScroller {
291    fn style(&mut self) -> &mut StyleRefinement {
292        &mut self.style
293    }
294}
295
296impl RenderOnce for MessageScroller {
297    fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
298        let root_id = self.id.clone();
299        let (list_state, scrolled_up) = {
300            let state = self.state.read(cx);
301            (state.list_state.clone(), state.is_scrolled_up())
302        };
303        let show_jump_button = self.jump_button && scrolled_up;
304        let jump_button_visibility = if self.jump_button {
305            transition(
306                (root_id.clone(), "jump-button-visibility"),
307                if show_jump_button { 1. } else { 0. },
308                Transition::new(self.jump_button_transition),
309                window,
310                cx,
311            )
312        } else {
313            0.
314        };
315        // At the live edge nothing is clipped below, so a visible fade would
316        // suggest more content than there is.
317        let bottom_fade_visibility = if self.bottom_fade.is_some() {
318            transition(
319                (root_id.clone(), "bottom-fade-visibility"),
320                if scrolled_up { 1. } else { 0. },
321                Transition::new(BOTTOM_FADE_TRANSITION),
322                window,
323                cx,
324            )
325        } else {
326            0.
327        };
328        let tokens = cx.theme().semantic_tokens();
329        let row_style = self.row_style;
330        let jump_button_style = self.jump_button_style;
331        let jump_button_renderer = self.jump_button_renderer;
332        let mut renderer = self.renderer;
333
334        // GPUI's `list` lays rows out at the full list width and offsets them
335        // only by vertical padding, so the horizontal component of the list
336        // style must be carried by every row wrapper instead.
337        let mut list_style = self.list_style;
338        let row_inset_left = list_style.padding.left.take();
339        let row_inset_right = list_style.padding.right.take();
340
341        // Read the count outside the row closure: the list holds a mutable
342        // borrow of its state while rendering rows, so the closure must not
343        // borrow it again. The count is stable within one render pass.
344        let item_count = list_state.item_count();
345        let list = list(list_state.clone(), move |index, window, cx| {
346            div()
347                .w_full()
348                .min_w_0()
349                .px_3()
350                // Spacing between rows only, like a CSS gap: the list's own
351                // bottom padding owns the gap after the last row.
352                .when(index + 1 < item_count, |this| this.pb_8())
353                .when_some(row_inset_left, |this, left| this.pl(left))
354                .when_some(row_inset_right, |this, right| this.pr(right))
355                .refine_style(&row_style)
356                .child(renderer(index, window, cx))
357                .into_any_element()
358        })
359        .size_full()
360        .min_h_0()
361        .py_2()
362        .refine_style(&list_style);
363
364        let viewport = div()
365            .id((root_id.clone(), "viewport"))
366            // Announce appended rows as a log region, like shadcn's
367            // `role="log"` transcript content.
368            .role(Role::Log)
369            .size_full()
370            .min_h_0()
371            .min_w_0()
372            .child(list)
373            // The fade sits above the rows but below the scrollbar and the
374            // jump button, so neither control is washed out by it.
375            .when_some(
376                self.bottom_fade.filter(|_| bottom_fade_visibility > 0.),
377                |this, color| {
378                    this.child(
379                        div()
380                            .absolute()
381                            .left_0()
382                            .right_0()
383                            .bottom_0()
384                            .h(rems(3.))
385                            .opacity(bottom_fade_visibility)
386                            .bg(linear_gradient(
387                                180.,
388                                linear_color_stop(color.opacity(0.), 0.),
389                                linear_color_stop(color, 1.),
390                            )),
391                    )
392                },
393            )
394            .when(self.scrollbar, |this| this.vertical_scrollbar(&list_state))
395            .refine_style(&self.content_style);
396
397        div()
398            .id(root_id.clone())
399            .relative()
400            .size_full()
401            .min_h_0()
402            .overflow_hidden()
403            .child(viewport)
404            // Keep vertical wheel scrolling from leaking into an ancestor
405            // scroller (like in Table): the mask consumes vertical-dominant
406            // wheel events while the list can move and chains to the ancestor
407            // only at the edges.
408            .child(ScrollableMask::new(Axis::Vertical, &list_state).id(root_id.clone()))
409            .when(self.jump_button && jump_button_visibility > 0., |this| {
410                let state = self.state.clone();
411
412                this.child(
413                    div()
414                        .absolute()
415                        .left_0()
416                        .right_0()
417                        .bottom(rems(0.5 + jump_button_visibility * 0.5))
418                        .flex()
419                        .justify_center()
420                        .opacity(jump_button_visibility)
421                        .child(
422                            // No explicit width or height: Button sizes an
423                            // icon-only button as a square on its own, and a
424                            // renderer that adds a label or another semantic
425                            // size must be able to change the layout.
426                            Button::new((root_id, "jump-to-latest"))
427                                .secondary()
428                                .icon(IconName::ArrowDown)
429                                .tooltip(self.jump_button_label)
430                                .rounded(cx.theme().radius_full())
431                                .border_1()
432                                .border_color(tokens.colors.border)
433                                .bg(tokens.colors.background)
434                                .text_color(tokens.colors.foreground)
435                                .refine_style(&jump_button_style)
436                                .on_click(move |_, _, cx| {
437                                    state.update(cx, |state, cx| state.scroll_to_end(cx));
438                                })
439                                .when_some(jump_button_renderer, |button, renderer| {
440                                    renderer(button)
441                                })
442                                .when(!show_jump_button, |button| button.disabled(true)),
443                        ),
444                )
445            })
446            .refine_style(&self.style)
447    }
448}
449
450#[cfg(test)]
451mod tests {
452    use super::*;
453    use crate::Sizable as _;
454    use gpui::AppContext as _;
455
456    #[gpui::test]
457    fn test_message_scroller_state_builder(cx: &mut gpui::TestAppContext) {
458        let state = cx.new(|cx| MessageScrollerState::new(3, cx));
459
460        cx.update(|cx| {
461            assert_eq!(state.read(cx).item_count(), 3);
462            assert!(!state.read(cx).is_scrolled_up());
463            assert!(state.read(cx).is_following_tail());
464
465            state.update(cx, |state, cx| {
466                assert!(!state.scroll_to_item(3, cx));
467                assert!(state.append(2, cx));
468                assert_eq!(state.item_count(), 5);
469                assert!(state.prepend(1, cx));
470                assert_eq!(state.item_count(), 6);
471                assert!(!state.splice(5..7, 0, cx));
472                assert!(state.remeasure_items(0..6, cx));
473                assert!(!state.remeasure_items(6..7, cx));
474                assert!(state.scroll_to_item(2, cx));
475                assert!(!state.is_scrolled_up());
476                assert!(!state.is_following_tail());
477                state.scroll_to_end(cx);
478                assert!(state.is_following_tail());
479                state.reset(2, cx);
480                assert_eq!(state.item_count(), 2);
481                assert!(state.is_following_tail());
482            });
483        });
484    }
485
486    #[gpui::test]
487    fn test_message_scroller_builder(cx: &mut gpui::TestAppContext) {
488        let state = cx.new(|cx| MessageScrollerState::new(0, cx));
489        let scroller = MessageScroller::new("message-scroller", state, |_, _, _| div())
490            .scrollbar(false)
491            .jump_button(false)
492            .with_jump_button_label("Latest")
493            .with_content_style(StyleRefinement::default())
494            .with_list_style(StyleRefinement::default())
495            .with_row_style(StyleRefinement::default())
496            .with_jump_button_style(StyleRefinement::default())
497            .with_jump_button_renderer(|button| button.large())
498            .with_jump_button_transition(Duration::from_millis(300))
499            .with_bottom_fade(gpui::white());
500
501        assert!(!scroller.scrollbar);
502        assert!(!scroller.jump_button);
503        assert_eq!(scroller.jump_button_label, "Latest");
504        assert!(scroller.jump_button_renderer.is_some());
505        assert_eq!(scroller.jump_button_transition, Duration::from_millis(300));
506        assert_eq!(scroller.bottom_fade, Some(gpui::white()));
507    }
508}