cranpose_ui/scrollbar.rs
1//! Where a scrollbar's thumb sits and how long it is.
2//!
3//! Pure arithmetic over three lengths — how much content there is, how much of
4//! it is on screen, how far it has travelled — shared by every indicator that
5//! reports a scroll position: the rectangular [`Scrollbar`](crate::widgets::Scrollbar)
6//! and the curved indicator a round watch draws.
7//!
8//! What differs between them is only how short and how long the thumb is
9//! allowed to get, which is why [`ThumbBounds`] is a parameter rather than a
10//! constant here.
11
12/// Thumb length and position, both as fractions of the track.
13#[derive(Clone, Copy, Debug, PartialEq)]
14pub struct ThumbGeometry {
15 /// The thumb's share of the track, in `0..=1`.
16 pub length: f32,
17 /// The thumb's leading edge: `0.0` at the start of the track, and
18 /// `1.0 - length` once the content is scrolled to the end.
19 pub offset: f32,
20}
21
22impl ThumbGeometry {
23 /// The thumb's trailing edge, as a fraction of the track.
24 pub fn end(self) -> f32 {
25 self.offset + self.length
26 }
27
28 /// How far through its travel the thumb is, in `0..=1`. `0.0` when the
29 /// thumb fills the track and cannot travel at all.
30 pub fn progress(self) -> f32 {
31 let travel = 1.0 - self.length;
32 if travel <= 0.0 {
33 0.0
34 } else {
35 (self.offset / travel).clamp(0.0, 1.0)
36 }
37 }
38}
39
40/// How short and how long a thumb may get, as fractions of the track.
41///
42/// A very long list would otherwise produce a thumb too small to see or to
43/// grab; a platform states its own floor rather than inheriting someone else's.
44#[derive(Clone, Copy, Debug, PartialEq)]
45pub struct ThumbBounds {
46 minimum: f32,
47 maximum: f32,
48}
49
50impl ThumbBounds {
51 /// A thumb free to be any length, which is what a caller that enforces a
52 /// minimum in pixels rather than fractions wants.
53 pub const FULL: Self = Self {
54 minimum: 0.0,
55 maximum: 1.0,
56 };
57
58 /// Bounds clamped into `0..=1` and ordered, so a caller cannot describe an
59 /// empty range.
60 pub fn new(minimum: f32, maximum: f32) -> Self {
61 let minimum = if minimum.is_finite() {
62 minimum.clamp(0.0, 1.0)
63 } else {
64 0.0
65 };
66 let maximum = if maximum.is_finite() {
67 maximum.clamp(0.0, 1.0)
68 } else {
69 1.0
70 };
71 Self {
72 minimum: minimum.min(maximum),
73 maximum: maximum.max(minimum),
74 }
75 }
76
77 /// The bounds that keep a thumb at least `extent` long on a track of
78 /// `track` — the pixel-shaped way to say it.
79 pub fn at_least(extent: f32, track: f32) -> Self {
80 if !(extent.is_finite() && track.is_finite()) || track <= 0.0 || extent <= 0.0 {
81 return Self::FULL;
82 }
83 Self::new(extent / track, 1.0)
84 }
85
86 /// Shortest permitted thumb, as a fraction of the track.
87 pub fn minimum(self) -> f32 {
88 self.minimum
89 }
90
91 /// Longest permitted thumb, as a fraction of the track.
92 pub fn maximum(self) -> f32 {
93 self.maximum
94 }
95}
96
97impl Default for ThumbBounds {
98 fn default() -> Self {
99 Self::FULL
100 }
101}
102
103/// The thumb for a scroll position, or `None` when the content fits and there
104/// is nothing to indicate.
105///
106/// `content` and `viewport` are lengths in one unit and `scrolled` is how far
107/// the content has travelled in that same unit. Non-finite inputs and a
108/// viewport that has not been measured yet both answer `None` rather than a
109/// thumb drawn from a guess.
110pub fn thumb_geometry(
111 content: f32,
112 viewport: f32,
113 scrolled: f32,
114 bounds: ThumbBounds,
115) -> Option<ThumbGeometry> {
116 if !(content.is_finite() && viewport.is_finite() && scrolled.is_finite()) {
117 return None;
118 }
119 if viewport <= 0.0 || content <= viewport {
120 return None;
121 }
122 let length = (viewport / content).clamp(bounds.minimum(), bounds.maximum());
123 let travel = content - viewport;
124 let progress = (scrolled / travel).clamp(0.0, 1.0);
125 Some(ThumbGeometry {
126 length,
127 offset: progress * (1.0 - length),
128 })
129}
130
131/// How far the content moves when the thumb is dragged `delta` along a track of
132/// `track`, given the thumb it currently shows.
133///
134/// A thumb that fills its track cannot be dragged, and a scroll with no travel
135/// cannot follow one, so both answer zero rather than dividing by zero.
136pub fn content_delta_for_thumb_drag(
137 delta: f32,
138 track: f32,
139 geometry: ThumbGeometry,
140 max_offset: f32,
141) -> f32 {
142 if !(delta.is_finite() && track.is_finite() && max_offset.is_finite()) {
143 return 0.0;
144 }
145 let travel = track * (1.0 - geometry.length);
146 if travel <= 0.0 || max_offset <= 0.0 {
147 return 0.0;
148 }
149 delta / travel * max_offset
150}
151
152#[cfg(test)]
153#[path = "tests/scrollbar_tests.rs"]
154mod tests;