Skip to main content

gpui_kit_grid/state/
pinned.rs

1//! Pinned rows on screen: where the bands sit in the body, what the last
2//! frame painted in them, and their hit tests.
3//!
4//! The rows live in the store (`GridWriter::set_pinned_rows`), one band per
5//! edge, each in a key space of its own. The body splits top to bottom into
6//! the top band, the data rows and the bottom band. The top band takes
7//! `min(lines × row height, body)`, the bottom band `min(lines × row height,
8//! body − top band)`, and the data rows the rest, possibly nothing: the top
9//! band wins when the body is too short (one place to change). Bands do not
10//! scroll vertically; their columns scroll sideways with the data rows', in
11//! the same column zones.
12//!
13//! A band's lines are its application rows and, in the band of
14//! `LiveGridState::with_totals`, the totals row as its outermost line: first
15//! in the top band, last in the bottom one. Each band is laid out from the
16//! body's edge, so a band cut short loses the lines next to the data rows
17//! and keeps the totals at the edge.
18
19use std::{hash::Hash, sync::Arc};
20
21use gpui_kit::{Bounds, Pixels, Point, point, px, size};
22
23use super::{KeyOf, LiveGridDelegate, LiveGridState, key_hash};
24use crate::{model::GridRow, store::PinnedEdge};
25
26/// Both edges, top first: the order bands are planned in.
27pub(super) const EDGES: [PinnedEdge; 2] = [PinnedEdge::Top, PinnedEdge::Bottom];
28
29/// The slot of an edge in per-band arrays.
30pub(super) const fn band_slot(edge: PinnedEdge) -> usize {
31    match edge {
32        PinnedEdge::Top => 0,
33        PinnedEdge::Bottom => 1,
34    }
35}
36
37/// The name of a band in element ids; the band's accessibility rows use
38/// the same name.
39pub(crate) const fn band_name(edge: PinnedEdge) -> &'static str {
40    match edge {
41        PinnedEdge::Top => "live-grid-pinned-top",
42        PinnedEdge::Bottom => "live-grid-pinned-bottom",
43    }
44}
45
46/// The hash of a pinned row's key for element ids, such as the tooltip div
47/// of a hovered pinned cell. A band's keys are its own, so the band's name
48/// is hashed with the key: a pinned row never gets the id of a data row or
49/// of the other band's row with the same key. (Not `(edge, key)`:
50/// `FxHasher` turns the zero discriminant of `PinnedEdge::Top` into
51/// nothing, so the top band would hash as the data rows.)
52pub(crate) fn pinned_key_hash<K: Hash + ?Sized>(edge: PinnedEdge, key: &K) -> u64 {
53    key_hash(&(band_name(edge), key))
54}
55
56/// The body split into the top band, the data rows and the bottom band, in
57/// window coordinates, and the lines of each band.
58#[derive(Clone, Copy, Debug, Default, PartialEq)]
59pub(crate) struct BodyBands {
60    top: Bounds<Pixels>,
61    rows: Bounds<Pixels>,
62    bottom: Bounds<Pixels>,
63    row_height: Pixels,
64    /// The application rows of each band, by band slot.
65    pinned: [usize; 2],
66    /// The band with the totals row.
67    totals: Option<PinnedEdge>,
68}
69
70impl BodyBands {
71    /// Splits `body` for `top` and `bottom` pinned rows of `row_height`,
72    /// and the totals row in the band at `totals`.
73    pub(super) fn new(
74        body: Bounds<Pixels>,
75        row_height: Pixels,
76        top: usize,
77        bottom: usize,
78        totals: Option<PinnedEdge>,
79    ) -> Self {
80        let mut bands = Self {
81            row_height: row_height.max(px(0.)),
82            pinned: [top, bottom],
83            totals,
84            ..Self::default()
85        };
86        let height = body.size.height.max(px(0.));
87        let line_height = bands.row_height;
88        let top_height = (line_height * bands.lines(PinnedEdge::Top) as f32).min(height);
89        let bottom_height =
90            (line_height * bands.lines(PinnedEdge::Bottom) as f32).min(height - top_height);
91        let (x, y, width) = (body.origin.x, body.origin.y, body.size.width);
92        bands.top = Bounds::new(point(x, y), size(width, top_height));
93        bands.rows = Bounds::new(
94            point(x, y + top_height),
95            size(width, height - top_height - bottom_height),
96        );
97        bands.bottom = Bounds::new(
98            point(x, y + height - bottom_height),
99            size(width, bottom_height),
100        );
101        bands
102    }
103
104    /// The data rows' area: the body without the bands.
105    pub(crate) fn rows(&self) -> Bounds<Pixels> {
106        self.rows
107    }
108
109    /// The area of the band at `edge` (zero high without lines).
110    pub(crate) fn band(&self, edge: PinnedEdge) -> Bounds<Pixels> {
111        match edge {
112            PinnedEdge::Top => self.top,
113            PinnedEdge::Bottom => self.bottom,
114        }
115    }
116
117    /// The lines of the band at `edge`: its application rows, and the
118    /// totals row if it is in this band.
119    pub(super) fn lines(&self, edge: PinnedEdge) -> usize {
120        self.pinned[band_slot(edge)] + usize::from(self.totals == Some(edge))
121    }
122
123    /// What line `line` of the band at `edge` holds: the totals row as the
124    /// outermost line, or an application row by its index in the band.
125    pub(super) fn band_line(&self, edge: PinnedEdge, line: usize) -> BodyLine {
126        match (self.totals == Some(edge), edge) {
127            (false, _) => BodyLine::Pinned(edge, line),
128            (true, PinnedEdge::Top) => match line.checked_sub(1) {
129                Some(ix) => BodyLine::Pinned(edge, ix),
130                None => BodyLine::Totals(edge),
131            },
132            (true, PinnedEdge::Bottom) if line + 1 == self.lines(edge) => BodyLine::Totals(edge),
133            (true, PinnedEdge::Bottom) => BodyLine::Pinned(edge, line),
134        }
135    }
136
137    /// The top edge of line `line` of the band at `edge` in window
138    /// coordinates, if any of it shows: the top band is laid out from its
139    /// top edge, the bottom band from its bottom edge.
140    pub(super) fn line_y(&self, edge: PinnedEdge, line: usize) -> Option<Pixels> {
141        let band = self.band(edge);
142        let y = match edge {
143            PinnedEdge::Top => band.origin.y + self.row_height * line as f32,
144            PinnedEdge::Bottom => {
145                band.bottom() - self.row_height * (self.lines(edge).checked_sub(line)?) as f32
146            }
147        };
148        (y < band.bottom() && y + self.row_height > band.origin.y).then_some(y)
149    }
150
151    /// The lines of the band at `edge` that show, top to bottom, with their
152    /// top edges.
153    pub(super) fn visible_lines(
154        &self,
155        edge: PinnedEdge,
156    ) -> impl Iterator<Item = (BodyLine, Pixels)> + '_ {
157        (0..self.lines(edge))
158            .filter_map(move |line| Some((self.band_line(edge, line), self.line_y(edge, line)?)))
159    }
160
161    /// The line of the band at `edge` under window y `y`, if `y` is in the
162    /// band.
163    fn line_at(&self, edge: PinnedEdge, y: Pixels) -> Option<BodyLine> {
164        let band = self.band(edge);
165        if y < band.origin.y || y >= band.bottom() || self.row_height <= px(0.) {
166            return None;
167        }
168        let line = match edge {
169            PinnedEdge::Top => ((y - band.origin.y) / self.row_height).floor() as usize,
170            PinnedEdge::Bottom => {
171                let from_edge = ((band.bottom() - y) / self.row_height).ceil() as usize;
172                self.lines(edge).checked_sub(from_edge.max(1))?
173            }
174        };
175        (line < self.lines(edge)).then(|| self.band_line(edge, line))
176    }
177}
178
179/// A line of the body: a data row's screen line from the top of the data
180/// rows, a pinned row's index in its band, or a band's totals row.
181#[derive(Clone, Copy, Debug, PartialEq, Eq)]
182pub(super) enum BodyLine {
183    Data(usize),
184    Pinned(PinnedEdge, usize),
185    /// The totals row.
186    Totals(PinnedEdge),
187}
188
189/// The pinned rows the last frame painted, band by band, top to bottom. Hit
190/// tests and [`LiveGridState::pinned_keys`] read what is on screen, as they
191/// read `painted` for the data rows. Cleared and refilled every frame; the
192/// vectors keep their capacity, so a frame does not allocate for them.
193pub(super) struct PaintedBands<R> {
194    rows: [Vec<Arc<R>>; 2],
195    /// The band index of each band's first painted row: a bottom band cut
196    /// short paints its last rows.
197    first: [usize; 2],
198}
199
200impl<R> Default for PaintedBands<R> {
201    fn default() -> Self {
202        Self {
203            rows: [Vec::new(), Vec::new()],
204            first: [0; 2],
205        }
206    }
207}
208
209impl<R> PaintedBands<R> {
210    /// The rows painted at `edge`.
211    pub(super) fn rows(&self, edge: PinnedEdge) -> &[Arc<R>] {
212        &self.rows[band_slot(edge)]
213    }
214
215    /// The painted row of index `ix` in the band at `edge`.
216    fn row(&self, edge: PinnedEdge, ix: usize) -> Option<&Arc<R>> {
217        let slot = band_slot(edge);
218        self.rows[slot].get(ix.checked_sub(self.first[slot])?)
219    }
220
221    /// Empties the band at `edge` for a new frame.
222    pub(super) fn begin(&mut self, edge: PinnedEdge) {
223        let slot = band_slot(edge);
224        self.rows[slot].clear();
225        self.first[slot] = 0;
226    }
227
228    /// The frame painted the row of index `ix` in the band at `edge`; rows
229    /// come top to bottom, without gaps.
230    pub(super) fn push(&mut self, edge: PinnedEdge, ix: usize, row: Arc<R>) {
231        let slot = band_slot(edge);
232        if self.rows[slot].is_empty() {
233            self.first[slot] = ix;
234        }
235        debug_assert_eq!(self.first[slot] + self.rows[slot].len(), ix, "no gaps");
236        self.rows[slot].push(row);
237    }
238}
239
240impl<D: LiveGridDelegate> LiveGridState<D> {
241    /// Keys of the rows pinned at `edge` that the last frame painted, top
242    /// to bottom. A band taller than the body is cut next to the data rows,
243    /// so the last rows of the top band, or the first rows of the bottom
244    /// band, may be missing.
245    pub fn pinned_keys(&self, edge: PinnedEdge) -> Vec<KeyOf<D>> {
246        self.painted_pinned
247            .rows(edge)
248            .iter()
249            .map(|row| row.key().clone())
250            .collect()
251    }
252
253    /// The body split into bands for the bands of the current snapshot and
254    /// the totals row. O(1).
255    pub(crate) fn body_bands(&self) -> BodyBands {
256        BodyBands::new(
257            self.body_bounds,
258            self.row_height,
259            self.snapshot.pinned_rows(PinnedEdge::Top).len(),
260            self.snapshot.pinned_rows(PinnedEdge::Bottom).len(),
261            self.totals,
262        )
263    }
264
265    /// The data rows' area of the body, in window coordinates.
266    pub(super) fn rows_bounds(&self) -> Bounds<Pixels> {
267        self.body_bands().rows()
268    }
269
270    /// The line of the body under a window position: a band's line first,
271    /// then a data row's screen line.
272    pub(super) fn body_line_at(&self, position: Point<Pixels>) -> Option<BodyLine> {
273        let bands = self.body_bands();
274        for edge in EDGES {
275            if bands.band(edge).contains(&position) {
276                return bands.line_at(edge, position.y);
277            }
278        }
279        self.line_at(position).map(BodyLine::Data)
280    }
281
282    /// The painted pinned row under a window position: its band and key.
283    pub(super) fn pinned_row_at(&self, position: Point<Pixels>) -> Option<(PinnedEdge, KeyOf<D>)> {
284        let Some(BodyLine::Pinned(edge, ix)) = self.body_line_at(position) else {
285            return None;
286        };
287        let row = self.painted_pinned.row(edge, ix)?;
288        Some((edge, row.key().clone()))
289    }
290
291    /// Returns true if the band at `edge` of the current snapshot holds
292    /// `key`: a menu on a pinned row closes when its row leaves the band.
293    pub(super) fn is_pinned_key(&self, edge: PinnedEdge, key: &KeyOf<D>) -> bool {
294        self.snapshot
295            .pinned_rows(edge)
296            .iter()
297            .any(|row| row.key() == key)
298    }
299}
300
301#[cfg(test)]
302mod tests {
303    use gpui_kit::{Bounds, point, px, size};
304
305    use super::{BodyBands, BodyLine, band_slot, pinned_key_hash};
306    use crate::{state::key_hash, store::PinnedEdge};
307
308    #[test]
309    fn test_pinned_key_hash() {
310        for key in [0u32, 1, 42] {
311            let top = pinned_key_hash(PinnedEdge::Top, &key);
312            let bottom = pinned_key_hash(PinnedEdge::Bottom, &key);
313            assert_ne!(top, key_hash(&key), "{key}");
314            assert_ne!(bottom, key_hash(&key), "{key}");
315            assert_ne!(top, bottom, "{key}");
316        }
317        assert_eq!(
318            pinned_key_hash(PinnedEdge::Top, "total"),
319            pinned_key_hash(PinnedEdge::Top, &String::from("total"))
320        );
321        // What the band name avoids: `FxHasher` hashes the top edge to
322        // nothing.
323        assert_eq!(key_hash(&(PinnedEdge::Top, 7u32)), key_hash(&7u32));
324    }
325
326    #[test]
327    fn test_body_bands_overflow_rule() {
328        let body = Bounds::new(point(px(10.), px(40.)), size(px(300.), px(260.)));
329        let row = px(26.);
330
331        // No pinned rows: the data rows take the whole body.
332        let bands = BodyBands::new(body, row, 0, 0, None);
333        assert_eq!(bands.rows(), body);
334        assert_eq!(bands.band(PinnedEdge::Top).size.height, px(0.));
335        assert_eq!(bands.band(PinnedEdge::Bottom).size.height, px(0.));
336        assert_eq!(bands.band(PinnedEdge::Bottom).origin.y, px(300.));
337
338        // One row each: the bands at the edges, the data rows between.
339        let bands = BodyBands::new(body, row, 1, 1, None);
340        assert_eq!(
341            bands.band(PinnedEdge::Top),
342            Bounds::new(point(px(10.), px(40.)), size(px(300.), px(26.)))
343        );
344        assert_eq!(
345            bands.rows(),
346            Bounds::new(point(px(10.), px(66.)), size(px(300.), px(208.)))
347        );
348        assert_eq!(
349            bands.band(PinnedEdge::Bottom),
350            Bounds::new(point(px(10.), px(274.)), size(px(300.), px(26.)))
351        );
352
353        // Too many rows: the top band first, then the bottom band, then
354        // the data rows get what is left, possibly nothing.
355        let bands = BodyBands::new(body, row, 6, 6, None);
356        assert_eq!(bands.band(PinnedEdge::Top).size.height, px(156.));
357        assert_eq!(bands.band(PinnedEdge::Bottom).size.height, px(104.));
358        assert_eq!(bands.rows().size.height, px(0.));
359        let bands = BodyBands::new(body, row, 20, 1, None);
360        assert_eq!(bands.band(PinnedEdge::Top).size.height, px(260.));
361        assert_eq!(bands.band(PinnedEdge::Bottom).size.height, px(0.));
362        assert_eq!(bands.rows().size.height, px(0.));
363
364        // Before the first frame the body is empty.
365        let bands = BodyBands::new(Bounds::default(), row, 2, 1, None);
366        assert_eq!(bands.rows().size.height, px(0.));
367        assert_eq!(bands.band(PinnedEdge::Top).size.height, px(0.));
368
369        assert_eq!(band_slot(PinnedEdge::Top), 0);
370        assert_eq!(band_slot(PinnedEdge::Bottom), 1);
371    }
372
373    #[test]
374    fn test_body_bands_with_totals() {
375        let body = Bounds::new(point(px(10.), px(40.)), size(px(300.), px(260.)));
376        let row = px(26.);
377        let lines = |bands: &BodyBands, edge| -> Vec<(BodyLine, f32)> {
378            bands
379                .visible_lines(edge)
380                .map(|(line, y)| (line, y.as_f32()))
381                .collect()
382        };
383        use BodyLine::{Pinned, Totals};
384        use PinnedEdge::{Bottom, Top};
385
386        // Totals at the bottom: the band is one line higher, the totals
387        // last, at the body's bottom edge.
388        let bands = BodyBands::new(body, row, 1, 1, Some(Bottom));
389        assert_eq!(bands.band(Bottom).size.height, px(52.));
390        assert_eq!(bands.rows().size.height, px(182.));
391        assert_eq!(
392            lines(&bands, Bottom),
393            [(Pinned(Bottom, 0), 248.), (Totals(Bottom), 274.)]
394        );
395        assert_eq!(lines(&bands, Top), [(Pinned(Top, 0), 40.)]);
396        // Without application rows the band holds the totals alone.
397        let bands = BodyBands::new(body, row, 0, 0, Some(Bottom));
398        assert_eq!(lines(&bands, Bottom), [(Totals(Bottom), 274.)]);
399
400        // Totals at the top: the first line, under the header.
401        let bands = BodyBands::new(body, row, 2, 0, Some(Top));
402        assert_eq!(bands.band(Top).size.height, px(78.));
403        assert_eq!(
404            lines(&bands, Top),
405            [
406                (Totals(Top), 40.),
407                (Pinned(Top, 0), 66.),
408                (Pinned(Top, 1), 92.)
409            ]
410        );
411        assert_eq!(bands.line_at(Top, px(40.)), Some(Totals(Top)));
412        assert_eq!(bands.line_at(Top, px(91.)), Some(Pinned(Top, 0)));
413        assert_eq!(bands.line_at(Top, px(118.)), None);
414
415        // A bottom band cut short loses the lines next to the data rows:
416        // 8 lines on top leave 52 px, two lines laid out from the body's
417        // bottom edge.
418        let bands = BodyBands::new(body, row, 8, 3, Some(Bottom));
419        assert_eq!(bands.band(Bottom).size.height, px(52.));
420        assert_eq!(
421            lines(&bands, Bottom),
422            [(Pinned(Bottom, 2), 248.), (Totals(Bottom), 274.)]
423        );
424        assert_eq!(bands.line_at(Bottom, px(299.)), Some(Totals(Bottom)));
425        assert_eq!(bands.line_at(Bottom, px(274.)), Some(Totals(Bottom)));
426        assert_eq!(bands.line_at(Bottom, px(273.)), Some(Pinned(Bottom, 2)));
427        assert_eq!(bands.line_at(Bottom, px(300.)), None);
428        // A line partly shown: half of row 2 above the totals; rows 0 and 1
429        // are cut.
430        let short = Bounds::new(body.origin, size(px(300.), px(26. * 3. + 13.)));
431        let bands = BodyBands::new(short, row, 2, 3, Some(Bottom));
432        assert_eq!(bands.band(Top).size.height, px(52.));
433        assert_eq!(bands.band(Bottom).size.height, px(39.));
434        assert_eq!(
435            lines(&bands, Bottom),
436            [(Pinned(Bottom, 2), 79.), (Totals(Bottom), 105.)]
437        );
438        assert_eq!(bands.line_at(Bottom, px(92.)), Some(Pinned(Bottom, 2)));
439        // A top band cut short loses its last lines, as without totals; the
440        // totals stay under the header.
441        let bands = BodyBands::new(short, row, 5, 0, Some(Top));
442        assert_eq!(bands.rows().size.height, px(0.));
443        assert_eq!(
444            lines(&bands, Top),
445            [
446                (Totals(Top), 40.),
447                (Pinned(Top, 0), 66.),
448                (Pinned(Top, 1), 92.),
449                (Pinned(Top, 2), 118.)
450            ]
451        );
452    }
453}