Skip to main content

ui/list/
variable.rs

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