Skip to main content

gpui_kit_grid/state/
scroll.rs

1//! Vertical and horizontal scrolling: the position as "first row plus the
2//! offset inside it", scrolling into view, pages, the scroll anchor kept
3//! when a new display order is shown, and the handle the scrollbars use.
4//!
5//! Only the data rows scroll vertically: their area is the body without the
6//! pinned bands (`state/pinned.rs`), and pages, scrolling into view and the
7//! end of the scroll range are measured in it.
8
9use std::{cell::RefCell, ops::Range, rc::Rc};
10
11use gpui_kit::base::ScrollbarHandle;
12use gpui_kit::{Bounds, Context, Pixels, Point, ScrollDelta, Size, point, px, size};
13
14use super::{
15    ColumnZone, KeyOf, LiveGridDelegate, LiveGridState, frame::PaintedLine, selection::successor,
16};
17use crate::{
18    engine::{GroupPath, Projection},
19    model::{ColumnIx, ColumnSet},
20    store::RowId,
21};
22
23/// The rows a page key moves by: the rows that fit in the row area, at
24/// least one (also before the first frame, when the area is empty).
25pub(super) fn page_size(rows_height: Pixels, row_height: Pixels) -> usize {
26    if row_height <= px(0.) {
27        return 1;
28    }
29    ((rows_height / row_height).floor().max(0.) as usize).max(1)
30}
31
32/// A scroll position as GPUI's pixel offset of the content: negative, from
33/// the first row and the offset inside it.
34fn offset_from_position(row: usize, offset: Pixels, row_height: Pixels) -> Pixels {
35    -(row_height * row as f32 + offset)
36}
37
38/// A pixel offset of the content (negative, as GPUI's) back as "first row
39/// plus the offset inside it", held at `max_row`. `f32` pixels are exact
40/// enough for a scrollbar thumb: about half a pixel at 100k rows of 40 px.
41fn position_from_offset(offset: Pixels, row_height: Pixels, max_row: usize) -> (usize, Pixels) {
42    let y = (-offset).max(px(0.));
43    if row_height <= px(0.) {
44        return (0, px(0.));
45    }
46    let row = (y / row_height).floor() as usize;
47    if row >= max_row {
48        return (max_row, px(0.));
49    }
50    (row, (y - row_height * row as f32).max(px(0.)))
51}
52
53#[derive(Default)]
54struct ScrollHandleState {
55    /// The content offset of the last frame (negative).
56    offset: Point<Pixels>,
57    content: Size<Pixels>,
58    viewport: Bounds<Pixels>,
59    /// An offset a scrollbar thumb asked for, applied by the next render.
60    requested: Option<Point<Pixels>>,
61}
62
63/// The grid's scroll position for `gpui_kit::base::Scrollbar`. The
64/// scrollbar sets an offset with `&self` and no context, so the request
65/// waits here until the next render applies it to the grid's own model;
66/// the scrollbar repaints the grid view itself.
67#[derive(Clone, Default)]
68pub(super) struct GridScrollHandle(Rc<RefCell<ScrollHandleState>>);
69
70impl GridScrollHandle {
71    /// What the frame shows, for the scrollbars painted after the body.
72    fn publish(&self, offset: Point<Pixels>, content: Size<Pixels>, viewport: Bounds<Pixels>) {
73        let mut state = self.0.borrow_mut();
74        state.offset = offset;
75        state.content = content;
76        state.viewport = viewport;
77    }
78
79    fn take_request(&self) -> Option<Point<Pixels>> {
80        self.0.borrow_mut().requested.take()
81    }
82}
83
84impl ScrollbarHandle for GridScrollHandle {
85    fn viewport_bounds(&self) -> Bounds<Pixels> {
86        self.0.borrow().viewport
87    }
88
89    fn offset(&self) -> Point<Pixels> {
90        let state = self.0.borrow();
91        state.requested.unwrap_or(state.offset)
92    }
93
94    fn set_offset(&self, offset: Point<Pixels>) {
95        self.0.borrow_mut().requested = Some(offset);
96    }
97
98    fn content_size(&self) -> Size<Pixels> {
99        self.0.borrow().content
100    }
101}
102
103/// How the next display order of a new view takes the scroll position.
104#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord)]
105pub(super) enum ScrollReset {
106    /// The same view with new data: the anchor line keeps its screen line.
107    #[default]
108    Keep,
109    /// A new sort or filter: from the top, unless a painted focused line
110    /// stays on its screen line.
111    NewView,
112    /// A new grouping or tree: from the top, so the first caption shows,
113    /// if the body was at the top; else a painted focused line by key.
114    NewStructure,
115}
116
117impl ScrollReset {
118    /// Returns true for a new view or structure.
119    pub(super) fn is_reset(self) -> bool {
120        self != Self::Keep
121    }
122}
123
124/// A shown line by identity: a data row, or a group caption by path.
125#[derive(Clone, Debug, PartialEq, Eq)]
126pub(super) enum AnchorLine {
127    Row(RowId),
128    Group(GroupPath),
129}
130
131/// The line that keeps its screen line when a new display order is shown:
132/// a toggled caption or tree row while its lines come and go, else the
133/// focused line if the last frame painted it, else the first painted line.
134pub(super) struct LineAnchor {
135    line: AnchorLine,
136    screen: usize,
137    focused: bool,
138}
139
140impl LineAnchor {
141    /// `line` on screen line `screen`; a `focused` anchor keeps its line at
142    /// the very top of the body too.
143    pub(super) fn new(line: AnchorLine, screen: usize, focused: bool) -> Self {
144        Self {
145            line,
146            screen,
147            focused,
148        }
149    }
150}
151
152/// The visible scrolling columns from the first one on screen, with their
153/// left edges relative to the scroll position: a layout change keeps the
154/// first of them that is still there at its place. A zone
155/// scrolled to its start stays at its start, so a column shown or moved
156/// before the first one is in view, as new rows on top are.
157pub(super) struct ScrollAnchor {
158    columns: Vec<(ColumnIx, Pixels)>,
159    at_start: bool,
160}
161
162impl<D: LiveGridDelegate> LiveGridState<D> {
163    /// The display rows currently in the viewport: the data rows' area,
164    /// between the pinned bands.
165    pub fn visible_rows(&self) -> Range<usize> {
166        let count =
167            ((self.rows_area_height() + self.scroll_offset) / self.row_height).ceil() as usize;
168        self.scroll_row..(self.scroll_row + count).min(self.projection.len())
169    }
170
171    /// Scrolls the row with `key` into view, between the pinned bands. O(1):
172    /// key index of the snapshot, then the display index of the projection.
173    /// Returns false if the row is not displayed (unknown key, filtered out,
174    /// or not yet projected). Pinned rows are always in view.
175    pub fn scroll_to_key(&mut self, key: &KeyOf<D>, cx: &mut Context<Self>) -> bool {
176        let Some(display_ix) = self
177            .snapshot
178            .row_id(key)
179            .and_then(|id| self.projection.display_ix(id))
180        else {
181            return false;
182        };
183        self.reveal_row(display_ix);
184        cx.notify();
185        true
186    }
187
188    /// Scrolls a display row into view by the nearest edge.
189    pub(super) fn reveal_row(&mut self, display_ix: usize) {
190        let viewport_rows = page_size(self.rows_area_height(), self.row_height);
191        if display_ix < self.scroll_row
192            || (display_ix == self.scroll_row && self.scroll_offset > px(0.))
193        {
194            self.scroll_row = display_ix;
195            self.scroll_offset = px(0.);
196        } else if display_ix >= self.scroll_row + viewport_rows {
197            self.scroll_row = display_ix + 1 - viewport_rows;
198            self.scroll_offset = px(0.);
199        }
200        self.clamp_scroll();
201    }
202
203    /// The height of the data rows' area: the body below the header without
204    /// the pinned bands. Page keys move by the rows that fit in it.
205    pub(super) fn rows_area_height(&self) -> Pixels {
206        self.rows_bounds().size.height
207    }
208
209    /// Scrolls so `display_ix` is the first visible row.
210    pub fn scroll_to_row(&mut self, display_ix: usize, cx: &mut Context<Self>) {
211        self.scroll_row = display_ix;
212        self.scroll_offset = px(0.);
213        self.clamp_scroll();
214        cx.notify();
215    }
216
217    /// Scrolls by a pixel delta (positive moves content up and left).
218    pub fn scroll_by(&mut self, delta: Point<Pixels>, cx: &mut Context<Self>) {
219        self.scroll_x = (self.scroll_x + delta.x).max(px(0.));
220        self.scroll_rows_by(delta.y);
221        cx.notify();
222    }
223
224    /// Scrolls by a wheel or trackpad delta. A wheel line is one row at
225    /// every row height, so a line wheel (X11, Windows, a mouse on macOS)
226    /// stops on a whole row; a pixel delta scrolls by its pixels.
227    pub(crate) fn on_scroll_wheel(&mut self, delta: ScrollDelta, cx: &mut Context<Self>) {
228        let delta = delta.pixel_delta(self.row_height);
229        self.scroll_by(point(-delta.x, -delta.y), cx);
230    }
231
232    /// Scrolls the rows by a pixel delta (positive moves content up).
233    pub(super) fn scroll_rows_by(&mut self, delta: Pixels) {
234        // Keep the position as "first row + offset inside it": exact at any
235        // depth, where one f32 of total pixels would lose sub-pixel precision.
236        let mut offset = self.scroll_offset + delta;
237        while offset < px(0.) && self.scroll_row > 0 {
238            self.scroll_row -= 1;
239            offset += self.row_height;
240        }
241        let rows = (offset / self.row_height).floor() as usize;
242        self.scroll_row += rows;
243        self.scroll_offset = (offset - self.row_height * rows as f32).max(px(0.));
244        self.clamp_scroll();
245    }
246
247    /// Applies an offset a scrollbar thumb asked for since the last render.
248    pub(super) fn apply_scroll_request(&mut self) {
249        let Some(offset) = self.scroll_handle.take_request() else {
250            return;
251        };
252        let (row, row_offset) =
253            position_from_offset(offset.y, self.row_height, self.max_scroll_row());
254        self.scroll_row = row;
255        self.scroll_offset = row_offset;
256        self.scroll_x = (-offset.x).max(px(0.));
257        self.clamp_scroll();
258    }
259
260    /// Tells the scrollbars what this frame shows: the offset, the rows'
261    /// height and the scrolling columns' width, and the data rows' area.
262    pub(super) fn publish_scroll(&self) {
263        let content = size(
264            self.columns
265                .zone_columns(ColumnZone::Scrolling)
266                .content_width(),
267            self.row_height * self.projection.len() as f32,
268        );
269        let offset = point(
270            -self.scroll_x,
271            offset_from_position(self.scroll_row, self.scroll_offset, self.row_height),
272        );
273        self.scroll_handle
274            .publish(offset, content, self.rows_bounds());
275    }
276
277    /// The first row of the body when it is scrolled to the end.
278    fn max_scroll_row(&self) -> usize {
279        let viewport_rows = (self.rows_area_height() / self.row_height).floor() as usize;
280        self.projection.len().saturating_sub(viewport_rows.max(1))
281    }
282
283    /// The line that keeps its screen line through a projection swap: the
284    /// painted focused line (a row or a caption), else the first painted
285    /// row or caption. A group footer is never the anchor: it has no
286    /// identity of its own across expansions. A fading row is never the
287    /// anchor either: it is leaving.
288    pub(super) fn line_anchor(&self) -> Option<LineAnchor> {
289        let anchor = |screen: usize, focused: bool| {
290            let line = match &self.painted[screen] {
291                PaintedLine::Row(id) => AnchorLine::Row(*id),
292                PaintedLine::Caption(path) => AnchorLine::Group(path.clone()),
293                PaintedLine::Footer(_) => return None,
294                // A fading row is no anchor.
295                PaintedLine::Ghost(_) => return None,
296            };
297            Some(LineAnchor::new(line, screen, focused))
298        };
299        let focused = self.selection.focus().and_then(|focus| {
300            let screen = match (focus.row(), focus.group()) {
301                (Some(key), _) => {
302                    let id = self.snapshot.row_id(key)?;
303                    self.painted
304                        .iter()
305                        .position(|painted| painted.row() == Some(id))?
306                }
307                (None, Some(path)) => self
308                    .painted
309                    .iter()
310                    .position(|painted| painted.group() == Some(path))?,
311                (None, None) => return None,
312            };
313            anchor(screen, true)
314        });
315        focused.or_else(|| (0..self.painted.len()).find_map(|screen| anchor(screen, false)))
316    }
317
318    /// Puts the anchor line back on its screen line after `previous` was
319    /// replaced. A body at the very top stays there unless the
320    /// anchor is the focused line, so new rows on top show. A gone anchor
321    /// row is replaced by its successor in the `previous` order. After a
322    /// new view (`ScrollReset::NewView`) only a painted focused line keeps
323    /// its screen line; otherwise the body shows the new order from the
324    /// top. After a new grouping or tree (`ScrollReset::NewStructure`) a
325    /// body at the top shows the new order from the top, its first caption
326    /// first, even with a focused line on screen; a scrolled body keeps a
327    /// painted focused line by key if the new order shows it, and shows
328    /// the new order from the top otherwise.
329    pub(super) fn restore_line_anchor(
330        &mut self,
331        anchor: Option<LineAnchor>,
332        previous: &Projection,
333        scroll_reset: ScrollReset,
334    ) {
335        let shown = |line: &AnchorLine| match line {
336            AnchorLine::Row(id) => self.projection.display_ix(*id),
337            AnchorLine::Group(path) => {
338                let group = self.projection.groups().find(path)?;
339                self.projection.group_line(group.id())
340            }
341        };
342        let at_top = self.scroll_row == 0 && self.scroll_offset == px(0.);
343        if scroll_reset == ScrollReset::NewStructure {
344            // The lines around the anchor are not the ones it had: at the
345            // top the first caption shows; scrolled, a painted focused line
346            // stays on its screen line by key, as after a new view.
347            match anchor
348                .filter(|anchor| anchor.focused && !at_top)
349                .and_then(|anchor| {
350                    shown(&anchor.line).map(|display_ix| (display_ix, anchor.screen))
351                }) {
352                Some((display_ix, screen)) => self.place_row(display_ix, screen),
353                None => self.scroll_to_top(),
354            }
355            return;
356        }
357        if scroll_reset.is_reset() {
358            match anchor.filter(|anchor| anchor.focused).and_then(|anchor| {
359                shown(&anchor.line).map(|display_ix| (display_ix, anchor.screen))
360            }) {
361                Some((display_ix, screen)) => self.place_row(display_ix, screen),
362                None => self.scroll_to_top(),
363            }
364            return;
365        }
366        let Some(anchor) = anchor else {
367            return;
368        };
369        if at_top && !anchor.focused {
370            return;
371        }
372        let display_ix = shown(&anchor.line).or_else(|| {
373            let AnchorLine::Row(id) = anchor.line else {
374                return None;
375            };
376            let snapshot = &self.snapshot;
377            successor(previous, Some(id), |id| {
378                snapshot
379                    .row(id)
380                    .and_then(|_| self.projection.display_ix(id))
381            })
382        });
383        if let Some(display_ix) = display_ix {
384            self.place_row(display_ix, anchor.screen);
385        }
386    }
387
388    /// Scrolls so the display row is on screen line `line`, with the offset
389    /// inside the first row unchanged.
390    fn place_row(&mut self, display_ix: usize, line: usize) {
391        if display_ix >= line {
392            self.scroll_row = display_ix - line;
393        } else {
394            self.scroll_row = 0;
395            self.scroll_offset = px(0.);
396        }
397    }
398
399    /// Shows the first row at the top. A new view (sort, filter, quick
400    /// filter) has no relation to the old position, so its order is shown
401    /// from the top, unless a painted focused row stays.
402    pub(super) fn scroll_to_top(&mut self) {
403        self.scroll_row = 0;
404        self.scroll_offset = px(0.);
405    }
406
407    /// The horizontal scroll offset of the scrolling zone: 0 at its
408    /// start.
409    pub fn scroll_x(&self) -> Pixels {
410        self.scroll_x
411    }
412
413    /// Sets the horizontal scroll offset.
414    pub fn set_scroll_x(&mut self, scroll_x: Pixels, cx: &mut Context<Self>) {
415        self.scroll_x = scroll_x.max(px(0.));
416        self.clamp_scroll();
417        cx.notify();
418    }
419
420    pub(super) fn clamp_scroll(&mut self) {
421        let max_row = self.max_scroll_row();
422        if self.scroll_row >= max_row {
423            self.scroll_row = max_row;
424            self.scroll_offset = px(0.);
425        }
426        // Only the scrolling zone scrolls: its content past its own width.
427        let content = self
428            .columns
429            .zone_columns(ColumnZone::Scrolling)
430            .content_width();
431        let max_x = (content - self.scrolling_zone_width()).max(px(0.));
432        self.scroll_x = self.scroll_x.min(max_x).max(px(0.));
433    }
434
435    /// The width of the scrolling zone in the body.
436    pub(super) fn scrolling_zone_width(&self) -> Pixels {
437        self.columns
438            .geometry(self.body_bounds.size.width, self.scroll_x)
439            .zone(ColumnZone::Scrolling)
440            .width
441    }
442
443    pub(super) fn scroll_anchor(&self) -> ScrollAnchor {
444        let zone = self.columns.zone_columns(ColumnZone::Scrolling);
445        let first = zone.offsets[1..].partition_point(|&end| end <= self.scroll_x);
446        ScrollAnchor {
447            columns: (first..zone.len())
448                .map(|i| (zone.columns[i], zone.offsets[i] - self.scroll_x))
449                .collect(),
450            at_start: self.scroll_x <= px(0.),
451        }
452    }
453
454    /// Scrolls so the anchor column (or, if it left the zone, the next one
455    /// still there) is where it was on screen; a zone at its start stays
456    /// there.
457    pub(super) fn restore_scroll_anchor(&mut self, anchor: ScrollAnchor) {
458        if anchor.at_start {
459            self.scroll_x = px(0.);
460            return;
461        }
462        let zone = self.columns.zone_columns(ColumnZone::Scrolling);
463        for (column, screen_x) in anchor.columns {
464            if let Some((ColumnZone::Scrolling, i)) = self.columns.zone_position(column) {
465                self.scroll_x = (zone.offsets[i] - screen_x).max(px(0.));
466                return;
467            }
468        }
469    }
470
471    /// Scrolls the scrolling-zone columns of `columns` into view (their left
472    /// edge first if they do not fit). Moved columns would otherwise land
473    /// off screen when dropped left of the anchor column.
474    pub(super) fn reveal_columns(&mut self, columns: ColumnSet) {
475        let width = self.scrolling_zone_width();
476        if width <= px(0.) {
477            return;
478        }
479        let zone = self.columns.zone_columns(ColumnZone::Scrolling);
480        let mut span: Option<(Pixels, Pixels)> = None;
481        for (i, column) in zone.columns.iter().enumerate() {
482            if columns.contains(*column) {
483                let (left, right) = (zone.offsets[i], zone.offsets[i + 1]);
484                span = Some(span.map_or((left, right), |(l, r)| (l.min(left), r.max(right))));
485            }
486        }
487        let Some((left, right)) = span else {
488            return;
489        };
490        if left < self.scroll_x {
491            self.scroll_x = left;
492        } else if right > self.scroll_x + width {
493            self.scroll_x = (right - width).min(left);
494        }
495    }
496
497    /// Keeps the right edge of a scrolling column in view while it is
498    /// resized with the mouse.
499    pub(super) fn reveal_right_edge(&mut self, column: ColumnIx) {
500        let width = self.scrolling_zone_width();
501        let Some((ColumnZone::Scrolling, i)) = self.columns.zone_position(column) else {
502            return;
503        };
504        let right = self.columns.zone_columns(ColumnZone::Scrolling).offsets[i + 1];
505        if width > px(0.) && right > self.scroll_x + width {
506            self.scroll_x = right - width;
507        }
508    }
509}
510
511#[cfg(test)]
512mod tests {
513    use gpui_kit::base::ScrollbarHandle as _;
514    use gpui_kit::{point, px};
515
516    use super::{GridScrollHandle, offset_from_position, page_size, position_from_offset};
517
518    #[test]
519    fn test_page_size() {
520        // The `xsmall`, `small`, `medium` and `large` rows in a 260 px area.
521        for (row_height, page) in [(26., 10), (30., 8), (32., 8), (40., 6)] {
522            assert_eq!(page_size(px(260.), px(row_height)), page, "{row_height} px");
523        }
524        // A partly visible row is not part of the page.
525        assert_eq!(page_size(px(259.), px(26.)), 9);
526        // Before the first frame the area is empty: a page is one row.
527        assert_eq!(page_size(px(0.), px(26.)), 1);
528        assert_eq!(page_size(px(10.), px(26.)), 1);
529        assert_eq!(page_size(px(260.), px(0.)), 1);
530    }
531
532    #[test]
533    fn test_grid_scroll_handle() {
534        let row = px(26.);
535        // GPUI's convention: the content moves up, so the offset is negative.
536        assert_eq!(offset_from_position(0, px(0.), row), px(0.));
537        assert_eq!(offset_from_position(3, px(5.), row), px(-83.));
538        // There and back, at the top and deep into 100k rows.
539        for (first, inside) in [(0, 0.), (3, 5.), (12_345, 13.5), (99_990, 0.)] {
540            let offset = offset_from_position(first, px(inside), row);
541            let (back, back_inside) = position_from_offset(offset, row, 99_990);
542            assert_eq!(back, first);
543            assert!(
544                (back_inside - px(inside)).abs() < px(0.5),
545                "{first}: {back_inside:?}"
546            );
547        }
548        // Held at the last first row; never above the top.
549        assert_eq!(
550            position_from_offset(px(-26. * 200.), row, 100),
551            (100, px(0.))
552        );
553        assert_eq!(position_from_offset(px(40.), row, 100), (0, px(0.)));
554
555        // A thumb drag waits in the handle until the next render takes it.
556        let handle = GridScrollHandle::default();
557        handle.publish(
558            point(px(0.), px(-52.)),
559            gpui_kit::size(px(500.), px(2_600.)),
560            Default::default(),
561        );
562        assert_eq!(handle.offset(), point(px(0.), px(-52.)));
563        assert_eq!(handle.content_size().height, px(2_600.));
564        handle.set_offset(point(px(-10.), px(-104.)));
565        assert_eq!(handle.offset(), point(px(-10.), px(-104.)));
566        assert_eq!(handle.take_request(), Some(point(px(-10.), px(-104.))));
567        assert_eq!(handle.take_request(), None);
568        assert_eq!(handle.offset(), point(px(0.), px(-52.)));
569    }
570}