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}