Skip to main content

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::{SUBCELL, ScrollLengths, ScrollMetrics};
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 = (track_len.saturating_mul(viewport_len) / content_len)
122            .max(SUBCELL)
123            .min(track_len);
124        let thumb_travel = track_len.saturating_sub(thumb_len);
125        let thumb_start = thumb_travel
126            .saturating_mul(offset)
127            .checked_div(max_offset)
128            .unwrap_or(0);
129
130        Self {
131            content_len,
132            viewport_len,
133            offset,
134            track_cells,
135            track_len,
136            thumb_len,
137            thumb_start,
138        }
139    }
140
141    /// Returns the current content length in logical units.
142    pub const fn content_len(&self) -> usize {
143        self.content_len
144    }
145
146    /// Returns the current viewport length in logical units.
147    pub const fn viewport_len(&self) -> usize {
148        self.viewport_len
149    }
150
151    /// Returns the current content offset in logical units.
152    pub const fn offset(&self) -> usize {
153        self.offset
154    }
155
156    /// Returns the track length in terminal cells.
157    pub const fn track_cells(&self) -> usize {
158        self.track_cells
159    }
160
161    /// Returns the track length in subcells.
162    pub const fn track_len(&self) -> usize {
163        self.track_len
164    }
165
166    /// Returns the thumb length in subcells.
167    pub const fn thumb_len(&self) -> usize {
168        self.thumb_len
169    }
170
171    /// Returns the thumb start position in subcells.
172    pub const fn thumb_start(&self) -> usize {
173        self.thumb_start
174    }
175
176    /// Returns the maximum scrollable offset in subcells.
177    pub const fn max_offset(&self) -> usize {
178        self.content_len.saturating_sub(self.viewport_len)
179    }
180
181    /// Returns the maximum thumb travel in subcells.
182    pub const fn thumb_travel(&self) -> usize {
183        self.track_len.saturating_sub(self.thumb_len)
184    }
185
186    /// Returns the thumb range in subcell coordinates.
187    pub const fn thumb_range(&self) -> Range<usize> {
188        self.thumb_start..self.thumb_start.saturating_add(self.thumb_len)
189    }
190
191    /// Returns whether a subcell position hits the thumb or the track.
192    pub const fn hit_test(&self, position: usize) -> HitTest {
193        if position >= self.thumb_start
194            && position < self.thumb_start.saturating_add(self.thumb_len)
195        {
196            HitTest::Thumb
197        } else {
198            HitTest::Track
199        }
200    }
201
202    /// Converts an offset (in subcells) to a thumb start position (in subcells).
203    ///
204    /// Larger offsets move the thumb toward the end of the track, clamped to the maximum travel.
205    pub fn thumb_start_for_offset(&self, offset: usize) -> usize {
206        let max_offset = self.max_offset();
207        let offset = offset.min(max_offset);
208        self.thumb_travel()
209            .saturating_mul(offset)
210            .checked_div(max_offset)
211            .unwrap_or(0)
212    }
213
214    /// Converts a thumb start position (in subcells) to an offset (in subcells).
215    ///
216    /// Thumb positions beyond the end of travel are clamped to the maximum offset.
217    pub fn offset_for_thumb_start(&self, thumb_start: usize) -> usize {
218        let max_offset = self.max_offset();
219        let thumb_start = thumb_start.min(self.thumb_travel());
220        max_offset
221            .saturating_mul(thumb_start)
222            .checked_div(self.thumb_travel())
223            .unwrap_or(0)
224    }
225
226    /// Returns how much of a cell is filled by the thumb.
227    ///
228    /// The `cell_index` is in terminal cells, not subcells. Use this to select the correct glyph
229    /// for the track or thumb.
230    pub fn cell_fill(&self, cell_index: usize) -> CellFill {
231        if self.thumb_len == 0 {
232            return CellFill::Empty;
233        }
234
235        let cell_start = cell_index.saturating_mul(SUBCELL);
236        let cell_end = cell_start.saturating_add(SUBCELL);
237
238        let thumb_end = self.thumb_start.saturating_add(self.thumb_len);
239        let start = self.thumb_start.max(cell_start);
240        let end = thumb_end.min(cell_end);
241
242        if end <= start {
243            return CellFill::Empty;
244        }
245
246        let len = end.saturating_sub(start).min(SUBCELL) as u8;
247        let start = start.saturating_sub(cell_start).min(SUBCELL) as u8;
248
249        if len as usize >= SUBCELL {
250            CellFill::Full
251        } else {
252            CellFill::Partial { start, len }
253        }
254    }
255}
256
257#[cfg(test)]
258mod tests {
259    use super::*;
260    #[test]
261    fn fills_track_when_no_scroll() {
262        let metrics = ScrollMetrics::new(
263            crate::ScrollLengths {
264                content_len: 10,
265                viewport_len: 10,
266            },
267            0,
268            4,
269        );
270        assert_eq!(metrics.thumb_len(), 32);
271        assert_eq!(metrics.thumb_start(), 0);
272    }
273
274    #[test]
275    fn clamps_offset_to_max() {
276        let metrics = ScrollMetrics::new(
277            crate::ScrollLengths {
278                content_len: 100,
279                viewport_len: 10,
280            },
281            200,
282            4,
283        );
284        assert_eq!(metrics.offset(), 90);
285        assert_eq!(metrics.thumb_start(), metrics.thumb_travel());
286    }
287
288    #[test]
289    fn reports_partial_cell_fills() {
290        let metrics = ScrollMetrics::new(
291            crate::ScrollLengths {
292                content_len: 10,
293                viewport_len: 3,
294            },
295            1,
296            4,
297        );
298        assert_eq!(metrics.cell_fill(0), CellFill::Partial { start: 3, len: 5 });
299        assert_eq!(metrics.cell_fill(1), CellFill::Partial { start: 0, len: 4 });
300        assert_eq!(metrics.cell_fill(2), CellFill::Empty);
301    }
302
303    #[test]
304    fn distinguishes_thumb_vs_track_hits() {
305        let metrics = ScrollMetrics::new(
306            crate::ScrollLengths {
307                content_len: 10,
308                viewport_len: 3,
309            },
310            1,
311            4,
312        );
313        assert_eq!(metrics.hit_test(0), HitTest::Track);
314        assert_eq!(metrics.hit_test(4), HitTest::Thumb);
315        assert_eq!(metrics.hit_test(12), HitTest::Track);
316    }
317
318    #[test]
319    fn stays_scale_invariant_for_logical_units() {
320        let track_cells = 10;
321        let base = ScrollMetrics::new(
322            crate::ScrollLengths {
323                content_len: 200,
324                viewport_len: 20,
325            },
326            10,
327            track_cells,
328        );
329        let scaled = ScrollMetrics::new(
330            crate::ScrollLengths {
331                content_len: 200 * SUBCELL,
332                viewport_len: 20 * SUBCELL,
333            },
334            10 * SUBCELL,
335            track_cells,
336        );
337        assert_eq!(base.thumb_len(), scaled.thumb_len());
338        assert_eq!(base.thumb_start(), scaled.thumb_start());
339    }
340
341    #[test]
342    fn yields_empty_thumb_when_track_len_zero() {
343        let lengths = crate::ScrollLengths {
344            content_len: 10,
345            viewport_len: 4,
346        };
347        let metrics = ScrollMetrics::new(lengths, 0, 0);
348        assert_eq!(metrics.track_len(), 0);
349        assert_eq!(metrics.thumb_len(), 0);
350        assert_eq!(metrics.cell_fill(0), CellFill::Empty);
351    }
352
353    #[test]
354    fn reports_full_cell_when_thumb_covers_track() {
355        let lengths = crate::ScrollLengths {
356            content_len: 8,
357            viewport_len: 8,
358        };
359        let metrics = ScrollMetrics::new(lengths, 0, 1);
360        assert_eq!(metrics.thumb_len(), SUBCELL);
361        assert_eq!(metrics.cell_fill(0), CellFill::Full);
362    }
363
364    #[test]
365    fn treats_zero_lengths_as_one() {
366        let lengths = crate::ScrollLengths {
367            content_len: 0,
368            viewport_len: 0,
369        };
370        let metrics = ScrollMetrics::new(lengths, 0, 1);
371        assert_eq!(metrics.content_len(), 1);
372        assert_eq!(metrics.viewport_len(), 1);
373        assert_eq!(metrics.thumb_len(), SUBCELL);
374    }
375
376    #[test]
377    fn thumb_start_for_offset_returns_zero_when_no_scroll() {
378        let lengths = crate::ScrollLengths {
379            content_len: 10,
380            viewport_len: 10,
381        };
382        let metrics = ScrollMetrics::new(lengths, 0, 4);
383        assert_eq!(metrics.thumb_start_for_offset(5), 0);
384    }
385
386    #[test]
387    fn offset_for_thumb_start_returns_zero_when_no_scroll() {
388        let lengths = crate::ScrollLengths {
389            content_len: 10,
390            viewport_len: 10,
391        };
392        let metrics = ScrollMetrics::new(lengths, 0, 4);
393        assert_eq!(metrics.offset_for_thumb_start(5), 0);
394    }
395
396    #[test]
397    fn conversions_return_zero_when_thumb_cannot_travel() {
398        let lengths = crate::ScrollLengths {
399            content_len: 10,
400            viewport_len: 3,
401        };
402        let metrics = ScrollMetrics::new(lengths, 0, 1);
403        assert_eq!(metrics.thumb_travel(), 0);
404        assert_eq!(metrics.thumb_start_for_offset(5), 0);
405        assert_eq!(metrics.offset_for_thumb_start(5), 0);
406    }
407
408    #[test]
409    fn hit_test_returns_track_before_thumb_start() {
410        let lengths = crate::ScrollLengths {
411            content_len: 10,
412            viewport_len: 3,
413        };
414        let metrics = ScrollMetrics::new(lengths, 1, 4);
415        assert_eq!(
416            metrics.hit_test(metrics.thumb_start().saturating_sub(1)),
417            HitTest::Track
418        );
419    }
420
421    #[test]
422    fn hit_test_returns_track_at_thumb_end() {
423        let lengths = crate::ScrollLengths {
424            content_len: 10,
425            viewport_len: 3,
426        };
427        let metrics = ScrollMetrics::new(lengths, 1, 4);
428        let thumb_end = metrics.thumb_start().saturating_add(metrics.thumb_len());
429        assert_eq!(metrics.hit_test(thumb_end), HitTest::Track);
430    }
431
432    #[test]
433    fn reports_empty_cell_fill_when_thumb_len_zero() {
434        let lengths = crate::ScrollLengths {
435            content_len: 10,
436            viewport_len: 4,
437        };
438        let metrics = ScrollMetrics::new(lengths, 0, 0);
439        assert_eq!(metrics.cell_fill(0), CellFill::Empty);
440    }
441
442    #[test]
443    fn thumb_range_matches_start_and_len() {
444        let lengths = crate::ScrollLengths {
445            content_len: 10,
446            viewport_len: 3,
447        };
448        let metrics = ScrollMetrics::new(lengths, 1, 4);
449        assert_eq!(
450            metrics.thumb_range(),
451            metrics.thumb_start()..metrics.thumb_start().saturating_add(metrics.thumb_len())
452        );
453    }
454
455    #[test]
456    fn clamps_thumb_start_for_offset() {
457        let lengths = crate::ScrollLengths {
458            content_len: 100,
459            viewport_len: 10,
460        };
461        let metrics = ScrollMetrics::new(lengths, 0, 4);
462        let max_offset = metrics.max_offset();
463        assert_eq!(
464            metrics.thumb_start_for_offset(max_offset.saturating_add(10)),
465            metrics.thumb_travel()
466        );
467    }
468
469    #[test]
470    fn clamps_offset_for_thumb_start() {
471        let lengths = crate::ScrollLengths {
472            content_len: 100,
473            viewport_len: 10,
474        };
475        let metrics = ScrollMetrics::new(lengths, 0, 4);
476        let max_offset = metrics.max_offset();
477        assert_eq!(
478            metrics.offset_for_thumb_start(metrics.thumb_travel().saturating_add(10)),
479            max_offset
480        );
481    }
482}