Skip to main content

retroglyph_ui/draw/
scrollbar.rs

1//! [`thumb_geometry`]/[`offset_for_pos`]: pure scrollbar geometry, kept as
2//! functions (not [`widget::Scrollbar`](crate::widget::Scrollbar) methods)
3//! because they have legitimate standalone callers that never draw anything:
4//! e.g. hit-testing a click/drag against the thumb via
5//! [`Interaction::interact`](crate::Interaction::interact) with
6//! [`Sense::DRAG`](crate::Sense::DRAG), independently of (and possibly
7//! before) ever rendering a [`widget::Scrollbar`](crate::widget::Scrollbar).
8//! This module has no dependency on (or awareness of) [`crate::interact`],
9//! and stays that way on purpose.
10
11use retroglyph_core::grid::{Pos, Rect};
12
13/// The thumb's length, in rows, for a `track`-row-tall vertical scrollbar covering `total_len`
14/// items in a `visible_len`-row viewport: proportional to `visible_len / total_len`, clamped to
15/// `1..=track` so it's never invisible (and never taller than the track itself).
16///
17/// Shared by [`thumb_geometry`] and [`offset_for_pos`] so both agree on how much of the track the
18/// thumb occupies, and therefore on [`TrackMap::max_start`], the last row the thumb can start on.
19fn thumb_len(track: u16, total_len: usize, visible_len: usize) -> u16 {
20    let track_f = f32::from(track);
21    // `visible_len`/`total_len` are item counts feeding a display ratio; losing mantissa bits
22    // above f32's 2^23 threshold has no visible effect on scrollbar geometry.
23    #[allow(clippy::cast_precision_loss)]
24    let ratio = visible_len as f32 / total_len as f32;
25    // Explicitly clamped to `1.0..=track_f`, itself derived from `area`'s `u16` height, so the
26    // result always narrows back exactly and is never negative.
27    #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
28    let len = retroglyph_core::math::round(track_f * ratio).clamp(1.0, track_f) as u16;
29
30    len
31}
32
33/// A linear map between a scrollbar's `offset` domain (`0..=max_offset`, item units) and its
34/// thumb's row domain (`0..=max_start`, the rows the thumb's *top* can start on within the
35/// track): the single shared implementation behind both [`thumb_geometry`]'s `offset -> row` and
36/// [`offset_for_pos`]'s `row -> offset`, so the two agree on both the denominator and the
37/// rounding, instead of independently re-deriving (and disagreeing on) the same conversion.
38///
39/// `row_for`/`offset_for` are each other's nearest-integer inverse, computed from the same two
40/// fields with the same rounding, rather than (as before this type existed) two independently
41/// re-derived formulas that disagreed on both. That makes `row -> offset -> row` an *exact* round
42/// trip whenever `max_start <= max_offset` (the usual case: a track has far fewer rows than a
43/// scrollable list has items, so `max_start`, itself capped by the track's row count, is the
44/// smaller of the two): `row_for(offset_for(row)) == row` for every `row` in `0..=max_start`. The
45/// reverse, `offset -> row -> offset`, can't be exact in that same common case, since several
46/// offsets necessarily share a single row; `offset_for(row_for(offset))` is only guaranteed to
47/// land within one row's worth of `offset`, not always exactly `offset` (this is what the old
48/// mismatched formulas got wrong: they could drift by several rows' worth, not just one).
49struct TrackMap {
50    max_offset: usize,
51    max_start: u16,
52}
53
54impl TrackMap {
55    /// `offset` (clamped to `0..=max_offset`) -> the thumb's top row, in `0..=max_start`.
56    fn row_for(&self, offset: usize) -> u16 {
57        if self.max_offset == 0 {
58            return 0;
59        }
60        let offset = offset.min(self.max_offset);
61        // `offset <= max_offset`, so the ratio is in `0.0..=1.0`; the result is clamped below to
62        // `max_start` (itself a `u16`) and is never negative. `offset`/`max_offset` are item
63        // counts (same precision-loss rationale as `thumb_len`'s ratio).
64        #[allow(
65            clippy::cast_precision_loss,
66            clippy::cast_possible_truncation,
67            clippy::cast_sign_loss
68        )]
69        let row = retroglyph_core::math::round(
70            (offset as f32 / self.max_offset as f32) * f32::from(self.max_start),
71        ) as u16;
72        row.min(self.max_start)
73    }
74
75    /// `row` (clamped to `0..=max_start`) -> an offset in `0..=max_offset`.
76    fn offset_for(&self, row: u16) -> usize {
77        if self.max_start == 0 {
78            return self.max_offset;
79        }
80        let row = row.min(self.max_start);
81        // Mirrors `row_for`'s cast rationale, in the opposite direction.
82        #[allow(
83            clippy::cast_precision_loss,
84            clippy::cast_possible_truncation,
85            clippy::cast_sign_loss
86        )]
87        let offset = retroglyph_core::math::round(
88            (f32::from(row) / f32::from(self.max_start)) * self.max_offset as f32,
89        ) as usize;
90        offset.min(self.max_offset)
91    }
92}
93
94/// The thumb's row span within `area` (`(start, len)`, both relative to
95/// `area.top()`) for a vertical scrollbar covering `total_len` items in a
96/// `visible_len`-row viewport currently starting at `offset`.
97///
98/// `None` if there's nothing to scroll (`area` has no rows, `visible_len`
99/// is zero, or `total_len <= visible_len`, the whole track already fits
100/// in the viewport). [`widget::Scrollbar`](crate::widget::Scrollbar) falls
101/// back to drawing a plain, thumb-less track in that case.
102///
103/// The thumb is sized proportionally to `visible_len / total_len` (clamped
104/// to at least one row so it's never invisible) and positioned
105/// proportionally to `offset` within the remaining scrollable range.
106#[must_use]
107pub fn thumb_geometry(
108    area: Rect,
109    total_len: usize,
110    visible_len: usize,
111    offset: usize,
112) -> Option<(u16, u16)> {
113    let track = area.height();
114    if track == 0 || visible_len == 0 || total_len <= visible_len {
115        return None;
116    }
117
118    let len = thumb_len(track, total_len, visible_len);
119    let max_offset = total_len - visible_len; // > 0, since total_len > visible_len here
120    let max_start = track.saturating_sub(len); // last row the thumb can start on
121    let map = TrackMap {
122        max_offset,
123        max_start,
124    };
125
126    Some((map.row_for(offset), len))
127}
128
129/// The offset a vertical scrollbar should jump to for a click/drag at `pos`.
130///
131/// Covers `total_len` items in a `visible_len`-row `area`; useful for
132/// click-to-jump or drag-to-scroll interactions built on top of
133/// [`thumb_geometry`], whose exact inverse it is (both share the same private row/offset
134/// conversion internally). `None` if `pos` falls outside `area`, or (mirroring
135/// [`thumb_geometry`]) there's nothing to scroll.
136#[must_use]
137pub fn offset_for_pos(area: Rect, total_len: usize, visible_len: usize, pos: Pos) -> Option<usize> {
138    let track = area.height();
139    if !area.contains_pos(pos) || track == 0 || visible_len == 0 || total_len <= visible_len {
140        return None;
141    }
142
143    // `area.contains_pos(pos)` above already guarantees `pos.y >= area.top()`; `checked_sub`
144    // (rather than `saturating_sub`) makes that the one place the absolute-`Pos`-to-local-row
145    // conversion happens, instead of leaving it implicit.
146    let row = pos.y.checked_sub(area.top())?;
147
148    let len = thumb_len(track, total_len, visible_len);
149    let max_offset = total_len - visible_len;
150    let max_start = track.saturating_sub(len);
151    let map = TrackMap {
152        max_offset,
153        max_start,
154    };
155
156    Some(map.offset_for(row))
157}
158
159#[cfg(test)]
160mod tests {
161    use super::*;
162
163    #[test]
164    fn nothing_to_scroll_when_everything_fits() {
165        assert_eq!(thumb_geometry(Rect::new(0, 0, 1, 10), 5, 10, 0), None);
166        assert_eq!(thumb_geometry(Rect::new(0, 0, 1, 10), 10, 10, 0), None);
167    }
168
169    #[test]
170    fn thumb_shrinks_with_the_visible_fraction() {
171        // Half the content visible -> roughly half the track.
172        let (_, len) = thumb_geometry(Rect::new(0, 0, 1, 20), 20, 10, 0).unwrap();
173        assert_eq!(len, 10);
174
175        // A tiny fraction still gets at least one row, never zero.
176        let (_, len) = thumb_geometry(Rect::new(0, 0, 1, 20), 2000, 1, 0).unwrap();
177        assert_eq!(len, 1);
178    }
179
180    #[test]
181    fn thumb_moves_from_top_to_bottom_as_offset_increases() {
182        let area = Rect::new(0, 0, 1, 20);
183        let (start_at_zero, len) = thumb_geometry(area, 40, 10, 0).unwrap();
184        assert_eq!(start_at_zero, 0);
185
186        let (start_at_max, _) = thumb_geometry(area, 40, 10, 30).unwrap(); // max_offset = 30
187        assert_eq!(start_at_max, area.height() - len); // flush with the bottom
188
189        let (start_at_mid, _) = thumb_geometry(area, 40, 10, 15).unwrap();
190        assert!(start_at_mid > start_at_zero && start_at_mid < start_at_max);
191    }
192
193    #[test]
194    fn offset_for_pos_round_trips_thumb_geometry_endpoints() {
195        let area = Rect::new(0, 0, 1, 20);
196        assert_eq!(
197            offset_for_pos(area, 40, 10, Pos::new(0, area.top())),
198            Some(0)
199        );
200        assert_eq!(
201            offset_for_pos(area, 40, 10, Pos::new(0, area.bottom() - 1)),
202            Some(30) // max_offset
203        );
204    }
205
206    #[test]
207    fn offset_for_pos_outside_the_area_is_none() {
208        let area = Rect::new(5, 5, 1, 10);
209        assert_eq!(offset_for_pos(area, 40, 10, Pos::new(0, 0)), None);
210    }
211
212    #[test]
213    fn offset_for_pos_mirrors_thumb_geometry_for_a_zero_height_viewport() {
214        let area = Rect::new(0, 0, 1, 10);
215        assert_eq!(thumb_geometry(area, 40, 0, 0), None); // visible_len == 0: nothing to scroll
216        assert_eq!(offset_for_pos(area, 40, 0, Pos::new(0, 5)), None);
217    }
218
219    /// Any row at or below the thumb's own start (`max_start`, here `15`: `track=20` minus
220    /// `len=5`) now maps to `max_offset`, not just the very bottom row: `offset_for_pos` clamps
221    /// to the same `max_start` [`thumb_geometry`] uses, so anywhere the thumb's body can visibly
222    /// sit already reads as "scrolled all the way". Before [`TrackMap`], `offset_for_pos` instead
223    /// divided by `track - 1` on its own, so a click a few rows above the very bottom (still
224    /// within the thumb, once scrolled all the way) landed short of `max_offset`, a mismatch a
225    /// user could see the thumb visibly slide to correct on release.
226    #[test]
227    fn offset_for_pos_clamps_to_max_offset_for_any_row_the_thumb_can_start_on() {
228        let area = Rect::new(0, 0, 1, 20);
229        let (max_start, len) = thumb_geometry(area, 40, 10, 30).unwrap(); // max_offset = 30
230        assert_eq!((max_start, len), (15, 5));
231
232        for row in max_start..area.height() {
233            assert_eq!(
234                offset_for_pos(area, 40, 10, Pos::new(0, area.top() + row)),
235                Some(30),
236                "row {row}"
237            );
238        }
239    }
240
241    /// The bug this module was named after (retroglyph#761): `thumb_geometry` and
242    /// `offset_for_pos` independently rounded through different denominators (`track - len` vs.
243    /// `track - 1`), so dragging the thumb to a spot and re-deriving an offset from where it
244    /// visually landed could jump the content by several rows' worth at once (the issue's repro,
245    /// these same `area`/`total_len`/`visible_len`, jumped by 6). With both functions now sharing
246    /// one [`TrackMap`], the round trip can still be off (rounding a wide `offset` range down into
247    /// a narrow `row` range is inherently lossy, see [`TrackMap`]'s docs), but never by more than
248    /// one row's worth of offset.
249    #[test]
250    fn offset_for_pos_round_trips_thumb_geometry_within_one_row() {
251        let area = Rect::new(0, 0, 1, 20);
252        let (total, visible) = (40, 10);
253        let max_offset = total - visible;
254
255        for offset in 0..=max_offset {
256            let (start, _len) = thumb_geometry(area, total, visible, offset).unwrap();
257            let back =
258                offset_for_pos(area, total, visible, Pos::new(0, area.top() + start)).unwrap();
259            let diff = back.abs_diff(offset);
260            assert!(
261                diff <= 1,
262                "offset {offset} -> row {start} -> {back} (diff {diff})"
263            );
264        }
265    }
266
267    /// [`TrackMap`]'s exact-inverse guarantee: `row -> offset -> row` recovers the original row
268    /// whenever there are at least as many offsets as rows (`max_start <= max_offset`), which is
269    /// the case for any scrollbar with more scrollable items than screen rows. Swept across a
270    /// spread of track heights and total/visible lengths, not just one `area`, since the claim is
271    /// about the map's construction, not one particular size.
272    #[test]
273    fn track_map_row_then_offset_round_trips_exactly() {
274        for height in [1u16, 2, 3, 5, 7, 10, 20, 50] {
275            for total_len in [2usize, 5, 10, 40, 100, 1000] {
276                for visible_len in [1usize, 2, 5, 10, 50] {
277                    if total_len <= visible_len {
278                        continue;
279                    }
280                    let area = Rect::new(0, 0, 1, height);
281                    let Some((_, len)) = thumb_geometry(area, total_len, visible_len, 0) else {
282                        continue;
283                    };
284                    let max_start = height.saturating_sub(len);
285                    let max_offset = total_len - visible_len;
286                    if usize::from(max_start) > max_offset {
287                        continue; // the lossy direction here instead; see the type's docs.
288                    }
289
290                    for row in 0..=max_start {
291                        let offset = offset_for_pos(
292                            area,
293                            total_len,
294                            visible_len,
295                            Pos::new(0, area.top() + row),
296                        )
297                        .unwrap();
298                        let (back_row, _) =
299                            thumb_geometry(area, total_len, visible_len, offset).unwrap();
300                        assert_eq!(
301                            back_row, row,
302                            "height={height} total_len={total_len} visible_len={visible_len} row={row}"
303                        );
304                    }
305                }
306            }
307        }
308    }
309}