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}