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