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        let touches_start = old_range.start == 0;
100        self.list_state.splice(old_range, count);
101
102        // The default row wrapper pads every row except the last, so a row
103        // whose "last" status may have flipped carries a stale measured
104        // height. Remeasure the new last row and the survivor next to the
105        // splice.
106        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        // A surviving former first row may have lost its list inset, or a
114        // surviving row may have become the first after a removal.
115        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    /// Append `count` rows to the end of the list.
128    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    /// Prepend `count` rows while preserving the current scroll anchor.
134    pub fn prepend(&mut self, count: usize, cx: &mut Context<Self>) -> bool {
135        self.splice(0..0, count, cx)
136    }
137
138    /// Mark all rows for remeasurement while preserving a proportional anchor.
139    pub fn remeasure(&mut self, cx: &mut Context<Self>) {
140        self.list_state.remeasure();
141        cx.notify();
142    }
143
144    /// Mark rows in `range` for remeasurement while preserving an item anchor.
145    ///
146    /// Returns `false` when the range is outside the current list.
147    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    /// Scroll to the row at `index`, if it exists.
158    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    /// Resume tail following and scroll to the latest row.
172    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/// A virtualized message list with optional scrollbar and jump-to-latest UI.
184#[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    /// Create a message scroller with a renderer for each row.
204    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    /// Enable or disable the virtual-list scrollbar.
234    pub fn scrollbar(mut self, scrollbar: bool) -> Self {
235        self.scrollbar = scrollbar;
236        self
237    }
238
239    /// Enable or disable the built-in jump-to-latest button.
240    pub fn jump_button(mut self, jump_button: bool) -> Self {
241        self.jump_button = jump_button;
242        self
243    }
244
245    /// Set the label used by the built-in jump-to-latest button.
246    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    /// Refine the viewport that contains the list and scrollbar.
252    pub fn with_content_style(mut self, style: StyleRefinement) -> Self {
253        self.content_style = style;
254        self
255    }
256
257    /// Refine the GPUI list element used to render rows.
258    pub fn with_list_style(mut self, style: StyleRefinement) -> Self {
259        self.list_style = style;
260        self
261    }
262
263    /// Refine the full-width wrapper around every rendered row.
264    pub fn with_row_style(mut self, style: StyleRefinement) -> Self {
265        self.row_style = style;
266        self
267    }
268
269    /// Refine the built-in jump-to-latest button after its defaults.
270    pub fn with_jump_button_style(mut self, style: StyleRefinement) -> Self {
271        self.jump_button_style = style;
272        self
273    }
274
275    /// Customize the built-in jump button without replacing its scroll action.
276    ///
277    /// The callback receives the fully configured Button, so its variant,
278    /// semantic size, icon, tooltip, or instance styling may be adjusted.
279    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    /// Set how long the built-in jump button takes to enter or leave.
288    ///
289    /// A zero duration disables its transition. Reduced-motion preferences
290    /// always adopt the final state immediately.
291    pub fn with_jump_button_transition(mut self, duration: Duration) -> Self {
292        self.jump_button_transition = duration;
293        self
294    }
295
296    /// Fade the transcript's bottom edge into `color`.
297    ///
298    /// A partially visible row melts into the surface behind the scroller
299    /// instead of clipping mid-line. The fade shows only while the reader is
300    /// away from the live edge — at the bottom nothing is clipped. Pass the
301    /// color of that surface; the fade is off by default and sits under the
302    /// jump button.
303    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        // At the live edge nothing is clipped below, so a visible fade would
335        // suggest more content than there is.
336        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        // GPUI's `list` lays rows out at the full list width and offsets them
354        // only by vertical padding, so the horizontal component of the list
355        // style must be carried by every row wrapper instead.
356        //
357        // The vertical padding rides on the rows as well. A list measures its
358        // scroll extent from its items alone, so a list that carries padding
359        // of its own reports a scroll range that stops short of its end: the
360        // scrollbar thumb can never reach the last row, the tail never
361        // resumes, and the list never reports itself as scrolled to the end.
362        // Padding on the rows is part of the content, so the extent covers
363        // it.
364        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        // Read the count outside the row closure: the list holds a mutable
371        // borrow of its state while rendering rows, so the closure must not
372        // borrow it again. The count is stable within one render pass.
373        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            // Announce appended rows as a log region, like shadcn's
405            // `role="log"` transcript content.
406            .role(Role::Log)
407            .size_full()
408            .min_h_0()
409            .min_w_0()
410            .child(
411                // The bounce scrolls the list itself, past the list's own
412                // scroll handler; tell the state, as that handler would.
413                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            // The fade sits above the rows but below the scrollbar and the
422            // jump button, so neither control is washed out by it.
423            .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                            // No explicit width or height: Button sizes an
466                            // icon-only button as a square on its own, and a
467                            // renderer that adds a label or another semantic
468                            // size must be able to change the layout.
469                            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        // Following the tail, layout settles on the row the tail starts on.
563        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        // A wheel scroll by one row: its 40px and the 32px gap after it.
574        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    /// A scrollbar drag to the bottom of the track must reach the end of the
587    /// content: the thumb's range is the list's scroll extent, and an extent
588    /// that stops short of the end leaves the reader above the last row,
589    /// with the tail-follow off and the jump button showing.
590    #[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        // Drag the thumb to the bottom of the track: the scrollbar maps that
607        // to the largest offset it reports for the list.
608        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    /// A custom list padding must ride on the first and the last row, the
652    /// way the list's own padding does. Carried by every row it would give
653    /// every message the list's padding instead of giving it to the list.
654    #[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        // The bottom inset rides on the last row only, so the content grows
693        // by the difference between the two bottom insets, not once per row.
694        assert_eq!(
695            first_extent - second_extent,
696            px(20.),
697            "the bottom inset must ride on the last row only"
698        );
699        // The top inset rides on the first row only.
700        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}