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
21pub struct MessageScrollerState {
27 list_state: ListState,
28}
29
30impl MessageScrollerState {
31 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 pub fn item_count(&self) -> usize {
54 self.list_state.item_count()
55 }
56
57 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 pub fn is_following_tail(&self) -> bool {
66 self.list_state.is_following_tail()
67 }
68
69 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 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 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 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 pub fn prepend(&mut self, count: usize, cx: &mut Context<Self>) -> bool {
116 self.splice(0..0, count, cx)
117 }
118
119 pub fn remeasure(&mut self, cx: &mut Context<Self>) {
121 self.list_state.remeasure();
122 cx.notify();
123 }
124
125 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 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 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#[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 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 pub fn scrollbar(mut self, scrollbar: bool) -> Self {
216 self.scrollbar = scrollbar;
217 self
218 }
219
220 pub fn jump_button(mut self, jump_button: bool) -> Self {
222 self.jump_button = jump_button;
223 self
224 }
225
226 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 pub fn with_content_style(mut self, style: StyleRefinement) -> Self {
234 self.content_style = style;
235 self
236 }
237
238 pub fn with_list_style(mut self, style: StyleRefinement) -> Self {
240 self.list_style = style;
241 self
242 }
243
244 pub fn with_row_style(mut self, style: StyleRefinement) -> Self {
246 self.row_style = style;
247 self
248 }
249
250 pub fn with_jump_button_style(mut self, style: StyleRefinement) -> Self {
252 self.jump_button_style = style;
253 self
254 }
255
256 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 pub fn with_jump_button_transition(mut self, duration: Duration) -> Self {
273 self.jump_button_transition = duration;
274 self
275 }
276
277 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 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 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 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 .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 .role(Role::Log)
369 .size_full()
370 .min_h_0()
371 .min_w_0()
372 .child(list)
373 .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 .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 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}