tui_scrollbar/
metrics.rs

1//! Pure scrollbar geometry and hit testing.
2//!
3//! This module contains the math behind thumb sizing and positioning. It is backend-agnostic and
4//! does not touch terminal rendering, making it suitable for unit tests and hit testing.
5//!
6//! Use [`ScrollMetrics`] when you need the thumb geometry without rendering a widget. This is
7//! especially useful for input handling, layout tests, or validating edge cases such as
8//! `content_len <= viewport_len`.
9//!
10//! ## Subcells
11//!
12//! A subcell is one eighth of a terminal cell. This module measures content, viewport, and offsets
13//! in logical units so fractional thumb sizes can be represented precisely. These lengths are
14//! logical values (not pixels); you decide how they map to your data. The track length is still
15//! expressed in full cells, then multiplied by [`SUBCELL`] to compute subcell positions.
16//!
17//! The example below shows a common pattern: convert a track measured in cells into subcell units,
18//! then compute a proportional thumb size and position.
19//!
20//! ```rust
21//! use tui_scrollbar::{ScrollLengths, ScrollMetrics, SUBCELL};
22//!
23//! let track_cells = 8;
24//! let viewport_len = track_cells * SUBCELL;
25//! let content_len = viewport_len * 4;
26//! let lengths = ScrollLengths {
27//!     content_len,
28//!     viewport_len,
29//! };
30//! let metrics = ScrollMetrics::new(lengths, 0, track_cells as u16);
31//!
32//! assert!(metrics.thumb_len() >= SUBCELL);
33//! ```
34
35use std::ops::Range;
36
37/// Number of subcells in a single terminal cell.
38pub const SUBCELL: usize = 8;
39
40#[derive(Debug, Clone, Copy, PartialEq, Eq)]
41/// Describes how much of a single cell is covered by the thumb.
42///
43/// Use this to select track vs thumb glyphs. Partial fills are measured from the start of the
44/// cell (top for vertical, left for horizontal).
45pub enum CellFill {
46    /// No coverage; the track should render.
47    Empty,
48    /// Entire cell is covered by the thumb.
49    Full,
50    /// A fractional range inside the cell is covered by the thumb.
51    Partial {
52        /// Subcell offset within the cell where coverage starts.
53        start: u8,
54        /// Number of subcells covered within the cell.
55        len: u8,
56    },
57}
58
59#[derive(Debug, Clone, Copy, PartialEq, Eq)]
60/// Whether a position lands on the thumb or track.
61///
62/// Positions are measured in subcells along the track.
63pub enum HitTest {
64    /// The position is inside the thumb.
65    Thumb,
66    /// The position is outside the thumb.
67    Track,
68}
69
70#[derive(Debug, Clone, Copy, PartialEq, Eq)]
71/// Precomputed values for proportional scrollbars.
72///
73/// All positions are tracked in subcell units (1/8 of a terminal cell). Use this type to compute
74/// thumb length, travel, and hit testing without rendering anything. The inputs are:
75///
76/// - `content_len` and `viewport_len` in logical units (zero treated as 1)
77/// - `track_cells` in terminal cells
78pub struct ScrollMetrics {
79    content_len: usize,
80    viewport_len: usize,
81    offset: usize,
82    track_cells: usize,
83    track_len: usize,
84    thumb_len: usize,
85    thumb_start: usize,
86}
87
88impl ScrollMetrics {
89    /// Build metrics using a [`crate::ScrollLengths`] helper.
90    pub fn from_lengths(lengths: crate::ScrollLengths, offset: usize, track_cells: u16) -> Self {
91        Self::new(lengths, offset, track_cells)
92    }
93
94    /// Build metrics for the given content and viewport lengths.
95    ///
96    /// The `track_cells` parameter is the number of terminal cells available for the track
97    /// (height for vertical scrollbars, width for horizontal). The lengths are logical units.
98    /// When `content_len` is smaller than `viewport_len`, the thumb fills the track to indicate no
99    /// scrolling. Zero lengths are treated as 1.
100    pub fn new(lengths: crate::ScrollLengths, offset: usize, track_cells: u16) -> Self {
101        let track_cells = track_cells as usize;
102        let track_len = track_cells.saturating_mul(SUBCELL);
103
104        if track_len == 0 {
105            return Self {
106                content_len: lengths.content_len,
107                viewport_len: lengths.viewport_len,
108                offset,
109                track_cells,
110                track_len,
111                thumb_len: 0,
112                thumb_start: 0,
113            };
114        }
115
116        let content_len = lengths.content_len.max(1);
117        let viewport_len = lengths.viewport_len.min(content_len).max(1);
118        let max_offset = content_len.saturating_sub(viewport_len);
119        let offset = offset.min(max_offset);
120
121        let (thumb_len, thumb_start) = if max_offset == 0 {
122            (track_len, 0)
123        } else {
124            let thumb_len = (track_len.saturating_mul(viewport_len) / content_len)
125                .max(SUBCELL)
126                .min(track_len);
127            let thumb_travel = track_len.saturating_sub(thumb_len);
128            let thumb_start = thumb_travel.saturating_mul(offset) / max_offset;
129            (thumb_len, thumb_start)
130        };
131
132        Self {
133            content_len,
134            viewport_len,
135            offset,
136            track_cells,
137            track_len,
138            thumb_len,
139            thumb_start,
140        }
141    }
142
143    /// Returns the current content length in logical units.
144    pub const fn content_len(&self) -> usize {
145        self.content_len
146    }
147
148    /// Returns the current viewport length in logical units.
149    pub const fn viewport_len(&self) -> usize {
150        self.viewport_len
151    }
152
153    /// Returns the current content offset in logical units.
154    pub const fn offset(&self) -> usize {
155        self.offset
156    }
157
158    /// Returns the track length in terminal cells.
159    pub const fn track_cells(&self) -> usize {
160        self.track_cells
161    }
162
163    /// Returns the track length in subcells.
164    pub const fn track_len(&self) -> usize {
165        self.track_len
166    }
167
168    /// Returns the thumb length in subcells.
169    pub const fn thumb_len(&self) -> usize {
170        self.thumb_len
171    }
172
173    /// Returns the thumb start position in subcells.
174    pub const fn thumb_start(&self) -> usize {
175        self.thumb_start
176    }
177
178    /// Returns the maximum scrollable offset in subcells.
179    pub const fn max_offset(&self) -> usize {
180        self.content_len.saturating_sub(self.viewport_len)
181    }
182
183    /// Returns the maximum thumb travel in subcells.
184    pub const fn thumb_travel(&self) -> usize {
185        self.track_len.saturating_sub(self.thumb_len)
186    }
187
188    /// Returns the thumb range in subcell coordinates.
189    pub const fn thumb_range(&self) -> Range<usize> {
190        self.thumb_start..self.thumb_start.saturating_add(self.thumb_len)
191    }
192
193    /// Returns whether a subcell position hits the thumb or the track.
194    pub const fn hit_test(&self, position: usize) -> HitTest {
195        if position >= self.thumb_start
196            && position < self.thumb_start.saturating_add(self.thumb_len)
197        {
198            HitTest::Thumb
199        } else {
200            HitTest::Track
201        }
202    }
203
204    /// Converts an offset (in subcells) to a thumb start position (in subcells).
205    ///
206    /// Larger offsets move the thumb toward the end of the track, clamped to the maximum travel.
207    pub fn thumb_start_for_offset(&self, offset: usize) -> usize {
208        let max_offset = self.max_offset();
209        if max_offset == 0 {
210            return 0;
211        }
212        let offset = offset.min(max_offset);
213        self.thumb_travel().saturating_mul(offset) / max_offset
214    }
215
216    /// Converts a thumb start position (in subcells) to an offset (in subcells).
217    ///
218    /// Thumb positions beyond the end of travel are clamped to the maximum offset.
219    pub fn offset_for_thumb_start(&self, thumb_start: usize) -> usize {
220        let max_offset = self.max_offset();
221        if max_offset == 0 {
222            return 0;
223        }
224        let thumb_start = thumb_start.min(self.thumb_travel());
225        max_offset.saturating_mul(thumb_start) / self.thumb_travel()
226    }
227
228    /// Returns how much of a cell is filled by the thumb.
229    ///
230    /// The `cell_index` is in terminal cells, not subcells. Use this to select the correct glyph
231    /// for the track or thumb.
232    pub fn cell_fill(&self, cell_index: usize) -> CellFill {
233        if self.thumb_len == 0 {
234            return CellFill::Empty;
235        }
236
237        let cell_start = cell_index.saturating_mul(SUBCELL);
238        let cell_end = cell_start.saturating_add(SUBCELL);
239
240        let thumb_end = self.thumb_start.saturating_add(self.thumb_len);
241        let start = self.thumb_start.max(cell_start);
242        let end = thumb_end.min(cell_end);
243
244        if end <= start {
245            return CellFill::Empty;
246        }
247
248        let len = end.saturating_sub(start).min(SUBCELL) as u8;
249        let start = start.saturating_sub(cell_start).min(SUBCELL) as u8;
250
251        if len as usize >= SUBCELL {
252            CellFill::Full
253        } else {
254            CellFill::Partial { start, len }
255        }
256    }
257}
258
259#[cfg(test)]
260mod tests {
261    use super::*;
262    #[test]
263    fn fills_track_when_no_scroll() {
264        let metrics = ScrollMetrics::new(
265            crate::ScrollLengths {
266                content_len: 10,
267                viewport_len: 10,
268            },
269            0,
270            4,
271        );
272        assert_eq!(metrics.thumb_len(), 32);
273        assert_eq!(metrics.thumb_start(), 0);
274    }
275
276    #[test]
277    fn clamps_offset_to_max() {
278        let metrics = ScrollMetrics::new(
279            crate::ScrollLengths {
280                content_len: 100,
281                viewport_len: 10,
282            },
283            200,
284            4,
285        );
286        assert_eq!(metrics.offset(), 90);
287        assert_eq!(metrics.thumb_start(), metrics.thumb_travel());
288    }
289
290    #[test]
291    fn reports_partial_cell_fills() {
292        let metrics = ScrollMetrics::new(
293            crate::ScrollLengths {
294                content_len: 10,
295                viewport_len: 3,
296            },
297            1,
298            4,
299        );
300        assert_eq!(metrics.cell_fill(0), CellFill::Partial { start: 3, len: 5 });
301        assert_eq!(metrics.cell_fill(1), CellFill::Partial { start: 0, len: 4 });
302        assert_eq!(metrics.cell_fill(2), CellFill::Empty);
303    }
304
305    #[test]
306    fn distinguishes_thumb_vs_track_hits() {
307        let metrics = ScrollMetrics::new(
308            crate::ScrollLengths {
309                content_len: 10,
310                viewport_len: 3,
311            },
312            1,
313            4,
314        );
315        assert_eq!(metrics.hit_test(0), HitTest::Track);
316        assert_eq!(metrics.hit_test(4), HitTest::Thumb);
317        assert_eq!(metrics.hit_test(12), HitTest::Track);
318    }
319
320    #[test]
321    fn stays_scale_invariant_for_logical_units() {
322        let track_cells = 10;
323        let base = ScrollMetrics::new(
324            crate::ScrollLengths {
325                content_len: 200,
326                viewport_len: 20,
327            },
328            10,
329            track_cells,
330        );
331        let scaled = ScrollMetrics::new(
332            crate::ScrollLengths {
333                content_len: 200 * SUBCELL,
334                viewport_len: 20 * SUBCELL,
335            },
336            10 * SUBCELL,
337            track_cells,
338        );
339        assert_eq!(base.thumb_len(), scaled.thumb_len());
340        assert_eq!(base.thumb_start(), scaled.thumb_start());
341    }
342
343    #[test]
344    fn yields_empty_thumb_when_track_len_zero() {
345        let lengths = crate::ScrollLengths {
346            content_len: 10,
347            viewport_len: 4,
348        };
349        let metrics = ScrollMetrics::new(lengths, 0, 0);
350        assert_eq!(metrics.track_len(), 0);
351        assert_eq!(metrics.thumb_len(), 0);
352        assert_eq!(metrics.cell_fill(0), CellFill::Empty);
353    }
354
355    #[test]
356    fn reports_full_cell_when_thumb_covers_track() {
357        let lengths = crate::ScrollLengths {
358            content_len: 8,
359            viewport_len: 8,
360        };
361        let metrics = ScrollMetrics::new(lengths, 0, 1);
362        assert_eq!(metrics.thumb_len(), SUBCELL);
363        assert_eq!(metrics.cell_fill(0), CellFill::Full);
364    }
365
366    #[test]
367    fn treats_zero_lengths_as_one() {
368        let lengths = crate::ScrollLengths {
369            content_len: 0,
370            viewport_len: 0,
371        };
372        let metrics = ScrollMetrics::new(lengths, 0, 1);
373        assert_eq!(metrics.content_len(), 1);
374        assert_eq!(metrics.viewport_len(), 1);
375        assert_eq!(metrics.thumb_len(), SUBCELL);
376    }
377
378    #[test]
379    fn thumb_start_for_offset_returns_zero_when_no_scroll() {
380        let lengths = crate::ScrollLengths {
381            content_len: 10,
382            viewport_len: 10,
383        };
384        let metrics = ScrollMetrics::new(lengths, 0, 4);
385        assert_eq!(metrics.thumb_start_for_offset(5), 0);
386    }
387
388    #[test]
389    fn offset_for_thumb_start_returns_zero_when_no_scroll() {
390        let lengths = crate::ScrollLengths {
391            content_len: 10,
392            viewport_len: 10,
393        };
394        let metrics = ScrollMetrics::new(lengths, 0, 4);
395        assert_eq!(metrics.offset_for_thumb_start(5), 0);
396    }
397
398    #[test]
399    fn hit_test_returns_track_before_thumb_start() {
400        let lengths = crate::ScrollLengths {
401            content_len: 10,
402            viewport_len: 3,
403        };
404        let metrics = ScrollMetrics::new(lengths, 1, 4);
405        assert_eq!(
406            metrics.hit_test(metrics.thumb_start().saturating_sub(1)),
407            HitTest::Track
408        );
409    }
410
411    #[test]
412    fn hit_test_returns_track_at_thumb_end() {
413        let lengths = crate::ScrollLengths {
414            content_len: 10,
415            viewport_len: 3,
416        };
417        let metrics = ScrollMetrics::new(lengths, 1, 4);
418        let thumb_end = metrics.thumb_start().saturating_add(metrics.thumb_len());
419        assert_eq!(metrics.hit_test(thumb_end), HitTest::Track);
420    }
421
422    #[test]
423    fn reports_empty_cell_fill_when_thumb_len_zero() {
424        let lengths = crate::ScrollLengths {
425            content_len: 10,
426            viewport_len: 4,
427        };
428        let metrics = ScrollMetrics::new(lengths, 0, 0);
429        assert_eq!(metrics.cell_fill(0), CellFill::Empty);
430    }
431
432    #[test]
433    fn thumb_range_matches_start_and_len() {
434        let lengths = crate::ScrollLengths {
435            content_len: 10,
436            viewport_len: 3,
437        };
438        let metrics = ScrollMetrics::new(lengths, 1, 4);
439        assert_eq!(
440            metrics.thumb_range(),
441            metrics.thumb_start()..metrics.thumb_start().saturating_add(metrics.thumb_len())
442        );
443    }
444
445    #[test]
446    fn clamps_thumb_start_for_offset() {
447        let lengths = crate::ScrollLengths {
448            content_len: 100,
449            viewport_len: 10,
450        };
451        let metrics = ScrollMetrics::new(lengths, 0, 4);
452        let max_offset = metrics.max_offset();
453        assert_eq!(
454            metrics.thumb_start_for_offset(max_offset.saturating_add(10)),
455            metrics.thumb_travel()
456        );
457    }
458
459    #[test]
460    fn clamps_offset_for_thumb_start() {
461        let lengths = crate::ScrollLengths {
462            content_len: 100,
463            viewport_len: 10,
464        };
465        let metrics = ScrollMetrics::new(lengths, 0, 4);
466        let max_offset = metrics.max_offset();
467        assert_eq!(
468            metrics.offset_for_thumb_start(metrics.thumb_travel().saturating_add(10)),
469            max_offset
470        );
471    }
472}