Skip to main content

ui/list/
variable.rs

1//! Variable-height virtual lists with stable keys and viewport notifications.
2use gpui::{
3    Animation, AnimationExt, AnyElement, App, ElementId, Empty, FocusHandle, IntoElement,
4    ListAlignment, ListOffset, ListState, MouseButton, Pixels, RenderOnce, SharedString, Window,
5    prelude::*, px,
6};
7use std::{
8    cell::{Cell, RefCell},
9    ops::Range,
10    rc::Rc,
11    sync::atomic::{AtomicUsize, Ordering},
12};
13
14/// A tail-following list that builds visible rows plus 500px of overscan.
15/// Keys must be unique. Call `sync` before rendering and `invalidate` when an
16/// offscreen item's height changes. Use `state.remeasure()` after font changes.
17#[derive(Clone)]
18pub struct VariableList<K> {
19    id: usize,
20    activity: Rc<Cell<ScrollbarActivity>>,
21    pub state: ListState,
22    keys: Rc<RefCell<Vec<K>>>,
23    visible: Rc<RefCell<Range<usize>>>,
24    grab: Rc<Cell<Option<(Pixels, Pixels)>>>,
25    end_inset: Rc<Cell<Pixels>>,
26}
27impl<K: Clone + Eq + 'static> Default for VariableList<K> {
28    fn default() -> Self {
29        let state = ListState::new(0, ListAlignment::Top, px(500.));
30        state.set_follow_mode(gpui::FollowMode::Tail);
31        Self {
32            id: NEXT_ID.fetch_add(1, Ordering::Relaxed),
33            state,
34            activity: Default::default(),
35            keys: Default::default(),
36            visible: Default::default(),
37            grab: Default::default(),
38            end_inset: Default::default(),
39        }
40    }
41}
42impl<K: Clone + Eq + 'static> VariableList<K> {
43    /// Reconcile insertions/removals while retaining the visible item's pixel anchor.
44    pub fn sync(&self, next: Vec<K>) {
45        let mut keys = self.keys.borrow_mut();
46        if *keys == next {
47            return;
48        }
49        let top = self.state.logical_scroll_top();
50        let anchor = keys.get(top.item_ix).cloned();
51        let following = self.state.is_following_tail();
52        let prefix = keys.iter().zip(&next).take_while(|(a, b)| a == b).count();
53        let suffix = keys[prefix..]
54            .iter()
55            .rev()
56            .zip(next[prefix..].iter().rev())
57            .take_while(|(a, b)| a == b)
58            .count();
59        self.state
60            .splice(prefix..keys.len() - suffix, next.len() - prefix - suffix);
61        if !following
62            && let Some(ix) = anchor.and_then(|key| next.iter().position(|item| *item == key))
63        {
64            self.state.scroll_to(ListOffset {
65                item_ix: ix,
66                offset_in_item: top.offset_in_item,
67            });
68        }
69        *keys = next;
70    }
71    pub fn invalidate(&self, key: &K) {
72        if let Some(ix) = self.keys.borrow().iter().position(|item| item == key) {
73            self.state.remeasure_items(ix..ix + 1);
74        }
75    }
76    /// Keep the scrollbar clear of floating controls at the bottom.
77    pub fn set_end_inset(&self, inset: Pixels) {
78        self.end_inset.set(inset);
79    }
80
81    pub fn visible_range(&self) -> Range<usize> {
82        self.visible.borrow().clone()
83    }
84    pub fn scroll_to(&self, index: usize) {
85        self.state.pause_following_tail();
86        self.state.scroll_to(ListOffset {
87            item_ix: index,
88            offset_in_item: px(0.),
89        });
90    }
91    /// Keep keyboard interaction alive for a selected item outside the viewport.
92    pub fn focus_item(&self, index: usize, focus: Option<FocusHandle>) {
93        let top = self.state.logical_scroll_top();
94        let following = self.state.is_following_tail();
95        self.state.splice_focusable(index..index + 1, [focus]);
96        if !following {
97            self.state.scroll_to(top);
98        }
99    }
100    pub fn render(
101        &self,
102        render: impl FnMut(usize, &mut Window, &mut App) -> AnyElement + 'static,
103        on_visible: impl Fn(Range<usize>, &mut Window, &mut App) + 'static,
104    ) -> gpui::Div {
105        let bar = self.scrollbar();
106        let state = self.state.clone();
107        let visible = self.visible.clone();
108        gpui::div()
109            .size_full()
110            .relative()
111            .child(gpui::list(self.state.clone(), render).size_full())
112            .child(
113                gpui::canvas(
114                    move |_, window, cx| {
115                        let start = state.logical_scroll_top().item_ix.min(state.item_count());
116                        let mut end = start;
117                        let viewport = state.viewport_bounds();
118                        while end < state.item_count() {
119                            if let Some(bounds) = state.bounds_for_item(end) {
120                                if bounds.top() >= viewport.bottom() {
121                                    break;
122                                }
123                                end += 1;
124                            } else {
125                                break;
126                            }
127                        }
128                        let range = start..end;
129                        if *visible.borrow() != range {
130                            *visible.borrow_mut() = range.clone();
131                            on_visible(range, window, cx);
132                        }
133                    },
134                    |_, _, _, _| {},
135                )
136                .absolute()
137                .size_full(),
138            )
139            .child(bar)
140    }
141    fn scrollbar(&self) -> Scrollbar {
142        Scrollbar {
143            id: self.id,
144            state: self.state.clone(),
145            grab: self.grab.clone(),
146            activity: self.activity.clone(),
147            end_inset: self.end_inset.get(),
148        }
149    }
150}
151
152/// Element ids must not collide between lists sharing a window.
153static NEXT_ID: AtomicUsize = AtomicUsize::new(0);
154
155#[derive(Clone, Copy, Default)]
156struct ScrollbarActivity {
157    offset: Pixels,
158    max: Pixels,
159    generation: u64,
160    hovered: bool,
161}
162
163/// The list's thumb, shaped like [`crate::scroll::transient`]: the fade *is*
164/// the idle window, and a fresh animation id restarts it, because
165/// `AnimationElement` pins its clock to the id it first laid out with.
166///
167/// Geometry comes from the previous frame — layout hands the list its viewport
168/// after this element is built — so the strip asks for another frame whenever
169/// the two disagree.
170#[derive(IntoElement)]
171struct Scrollbar {
172    id: usize,
173    state: ListState,
174    grab: Rc<Cell<Option<(Pixels, Pixels)>>>,
175    activity: Rc<Cell<ScrollbarActivity>>,
176    end_inset: Pixels,
177}
178
179impl RenderOnce for Scrollbar {
180    fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
181        let mode = crate::scroll::visibility(cx);
182        if mode == crate::scroll::Visibility::Never {
183            return Empty.into_any_element();
184        }
185        let always = mode == crate::scroll::Visibility::Always || cx.reduce_motion();
186        let viewport = self.state.viewport_bounds().size.height;
187        let max = self.state.max_offset_for_scrollbar().y;
188        let offset = self.state.scroll_px_offset_for_scrollbar().y;
189        let track = (viewport - self.end_inset).max(px(0.));
190
191        // Any change in the scroll state is activity. The half-pixel slack
192        // keeps a sub-pixel layout jitter from re-showing a settled bar.
193        let mut held = self.activity.get();
194        if (offset - held.offset).abs() > px(0.5) || (max - held.max).abs() > px(0.5) {
195            held.offset = offset;
196            held.max = max;
197            held.generation += 1;
198            self.activity.set(held);
199        }
200        let generation = held.generation;
201
202        let range = crate::scroll::thumb_in_track(viewport, max, offset, track);
203        let travel = range
204            .as_ref()
205            .map(|range| track - (range.end - range.start))
206            .unwrap_or(px(0.));
207        let thumb = range.map(|range| {
208            let down_state = self.state.clone();
209            let down_grab = self.grab.clone();
210            let thumb = gpui::div()
211                .id(SharedString::from(format!("list-{}-thumb", self.id)))
212                .absolute()
213                .top(range.start)
214                .h(range.end - range.start)
215                .w(px(4.))
216                .rounded(px(2.))
217                .bg(theme::Theme::of(cx).text_muted.opacity(0.4))
218                .on_mouse_down(MouseButton::Left, move |event, window, cx| {
219                    down_state.scrollbar_drag_started();
220                    down_grab.set(Some((
221                        event.position.y,
222                        down_state.scroll_px_offset_for_scrollbar().y,
223                    )));
224                    cx.stop_propagation();
225                    window.refresh();
226                });
227            if always {
228                return thumb.into_any_element();
229            }
230            // The thumb's presence is the animation: progress 0 is fully up,
231            // and progress 1 — a full idle window later — is hidden, so no
232            // frame is requested once the fade completes. A hidden thumb is
233            // also out of the hit test, which is what hovering the strip
234            // raises it for.
235            let anim = self.activity.clone();
236            let anim_grab = self.grab.clone();
237            thumb
238                .with_animation(
239                    ElementId::from(SharedString::from(format!(
240                        "list-{}-fade-{generation}",
241                        self.id
242                    ))),
243                    Animation::new(crate::scroll::TRANSIENT_IDLE),
244                    move |el, progress| {
245                        if anim.get().hovered || anim_grab.get().is_some() {
246                            el
247                        } else if progress < 1.0 {
248                            el.opacity(1.0 - progress)
249                        } else {
250                            el.hidden()
251                        }
252                    },
253                )
254                .into_any_element()
255        });
256
257        let hover = self.activity.clone();
258        let state = self.state.clone();
259        let grab = self.grab.clone();
260        gpui::div()
261            .id(SharedString::from(format!("list-{}-track", self.id)))
262            .absolute()
263            .right_0()
264            .top_0()
265            .bottom(self.end_inset)
266            .w(px(10.))
267            .flex()
268            .justify_center()
269            .on_hover(move |hovered, window, _| {
270                let mut held = hover.get();
271                if held.hovered == *hovered {
272                    return;
273                }
274                held.hovered = *hovered;
275                held.generation += 1;
276                hover.set(held);
277                window.refresh();
278            })
279            .children(thumb)
280            .child(
281                gpui::canvas(
282                    move |bounds, window, _| {
283                        // Laid out against a viewport the geometry above has
284                        // not seen. Self-limiting: once they agree, nothing is
285                        // requested.
286                        if (bounds.size.height - track).abs() > px(0.5) {
287                            window.request_animation_frame();
288                        }
289                    },
290                    move |_, _, window, _| {
291                        // Window-wide, because a drag leaves the strip and a
292                        // release can land anywhere: a grab left set would make
293                        // the next press continue the last gesture.
294                        let moved_state = state.clone();
295                        let moved_grab = grab.clone();
296                        window.on_mouse_event(
297                            move |event: &gpui::MouseMoveEvent, phase, window, _| {
298                                if phase == gpui::DispatchPhase::Bubble
299                                    && let Some((start, offset)) = moved_grab.get()
300                                    && travel > px(0.)
301                                {
302                                    moved_state.set_offset_from_scrollbar(gpui::point(
303                                        px(0.),
304                                        (offset - (event.position.y - start) / travel * max)
305                                            .clamp(-max, px(0.)),
306                                    ));
307                                    window.refresh();
308                                }
309                            },
310                        );
311                        let up_state = state.clone();
312                        let up_grab = grab.clone();
313                        window.on_mouse_event(move |event: &gpui::MouseUpEvent, _, window, _| {
314                            if event.button == MouseButton::Left && up_grab.take().is_some() {
315                                up_state.scrollbar_drag_ended();
316                                window.refresh();
317                            }
318                        });
319                    },
320                )
321                .absolute()
322                .size_full(),
323            )
324            .into_any_element()
325    }
326}