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 metrics_no_scroll_fills_track() {
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 metrics_clamps_offset() {
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 metrics_cell_fill_partial() {
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 metrics_hit_test_thumb_vs_track() {
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 metrics_are_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}