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