tui_scrollbar/scrollbar/
mod.rs

1//! Rendering and interaction for proportional scrollbars.
2//!
3//! This module provides the widget, glyph selection, and interaction helpers. The pure math lives
4//! in [`crate::metrics`].
5//!
6//! # How the parts interact
7//!
8//! 1. Your app owns `content_len`, `viewport_len`, and `offset`.
9//! 2. [`ScrollMetrics`] converts them into thumb geometry.
10//! 3. [`ScrollBar`] renders using the selected [`GlyphSet`].
11//! 4. Input events update `offset` via [`ScrollCommand`].
12//!
13//! The scrollbar renders only a single row or column. If you provide a larger [`Rect`], it will
14//! still render into the first row/column of that area.
15//!
16//! ## Layout choices
17//!
18//! The widget treats the provided area as the track container. When arrows are enabled, one cell
19//! at each end is reserved for the endcaps and the remaining inner area is used for the thumb.
20//!
21//! Arrow endcaps are optional. When enabled, they consume one cell at the start/end of the track,
22//! and the thumb renders inside the remaining inner area.
23//!
24//! ## Interaction choices
25//!
26//! - The widget is stateless: it renders from inputs and returns commands instead of mutating
27//!   scroll offsets. This keeps control with the application.
28//! - Dragging stores a grab offset in subcells so the thumb does not jump under the pointer.
29//! - Arrow endcaps consume track space; the inner track is used for metrics and hit testing so
30//!   thumb math stays consistent regardless of arrows.
31//!
32//! Partial glyph selection uses [`CellFill::Partial`]: `start == 0` means the partial fill begins
33//! at the leading edge (top/left), so the upper/left glyphs are chosen. Non-zero `start` uses the
34//! lower/right glyphs to indicate a trailing-edge fill.
35//!
36//! Drag operations store a "grab offset" in subcells (1/8 of a cell; see [`crate::SUBCELL`]) so the
37//! thumb does not jump when the pointer starts dragging; subsequent drag events subtract that
38//! offset to keep the grab point stable.
39//!
40//! Wheel events are ignored unless their axis matches the scrollbar orientation. Positive deltas
41//! scroll down/right.
42//!
43//! The example below renders a vertical scrollbar into a buffer. It demonstrates how the widget
44//! uses `content_len`, `viewport_len`, and `offset` to decide the thumb size and position.
45//!
46//! ```rust
47//! use ratatui_core::buffer::Buffer;
48//! use ratatui_core::layout::Rect;
49//! use ratatui_core::widgets::Widget;
50//! use tui_scrollbar::{ScrollBar, ScrollLengths};
51//!
52//! let area = Rect::new(0, 0, 1, 4);
53//! let lengths = ScrollLengths {
54//!     content_len: 120,
55//!     viewport_len: 40,
56//! };
57//! let scrollbar = ScrollBar::vertical(lengths).offset(20);
58//!
59//! let mut buffer = Buffer::empty(area);
60//! scrollbar.render(area, &mut buffer);
61//! ```
62//!
63//! [`Rect`]: ratatui_core::layout::Rect
64
65use ratatui_core::layout::Rect;
66use ratatui_core::style::{Color, Style};
67
68use crate::glyphs::GlyphSet;
69
70mod interaction;
71mod render;
72
73/// Axis the scrollbar is laid out on.
74///
75/// Orientation determines whether the track length is derived from height or width.
76#[derive(Debug, Clone, Copy, PartialEq, Eq)]
77pub enum ScrollBarOrientation {
78    /// A vertical scrollbar that fills a single column.
79    Vertical,
80    /// A horizontal scrollbar that fills a single row.
81    Horizontal,
82}
83
84/// Behavior when the user clicks on the track outside the thumb.
85///
86/// Page clicks move by `viewport_len`. Jump-to-click centers the thumb near the click.
87#[derive(Debug, Clone, Copy, PartialEq, Eq)]
88pub enum TrackClickBehavior {
89    /// Move by one viewport length toward the click position.
90    Page,
91    /// Jump the thumb toward the click position.
92    JumpToClick,
93}
94
95/// Which arrow endcaps to render on the track.
96#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
97pub enum ScrollBarArrows {
98    /// Do not render arrow endcaps.
99    None,
100    /// Render the arrow at the start of the track (top/left).
101    Start,
102    /// Render the arrow at the end of the track (bottom/right).
103    End,
104    /// Render arrows at both ends of the track.
105    #[default]
106    Both,
107}
108
109impl ScrollBarArrows {
110    const fn has_start(self) -> bool {
111        matches!(self, Self::Start | Self::Both)
112    }
113
114    const fn has_end(self) -> bool {
115        matches!(self, Self::End | Self::Both)
116    }
117}
118
119#[derive(Debug, Clone, Copy, PartialEq, Eq)]
120enum ArrowHit {
121    Start,
122    End,
123}
124
125#[derive(Debug, Clone, Copy)]
126struct ArrowLayout {
127    track_area: Rect,
128    start: Option<(u16, u16)>,
129    end: Option<(u16, u16)>,
130}
131
132/// A proportional scrollbar widget with fractional thumb rendering.
133///
134/// # Key methods
135///
136/// - [`Self::new`]
137/// - [`Self::orientation`]
138/// - [`Self::arrows`]
139/// - [`Self::content_len`]
140/// - [`Self::viewport_len`]
141/// - [`Self::offset`]
142///
143/// # Important
144///
145/// - `content_len` and `viewport_len` are in logical units.
146/// - Zero values are treated as 1.
147/// - The scrollbar renders into a single row or column.
148///
149/// # Behavior
150///
151/// The thumb length is proportional to `viewport_len / content_len` and clamped to at least one
152/// full cell for usability. When `content_len <= viewport_len`, the thumb fills the track. Areas
153/// with zero width or height render nothing.
154///
155/// Arrow endcaps, when enabled, consume one cell at the start/end of the track. The thumb and
156/// track render in the remaining inner area. Clicking an arrow steps the offset by `scroll_step`.
157///
158/// # Styling
159///
160/// Track glyphs use `track_style`. Thumb glyphs use `thumb_style`. Arrow endcaps use
161/// `arrow_style`, which defaults to white on dark gray.
162///
163/// # State
164///
165/// This widget is stateless. Pointer drag state lives in [`ScrollBarInteraction`].
166///
167/// # Examples
168///
169/// ```rust
170/// use ratatui_core::buffer::Buffer;
171/// use ratatui_core::layout::Rect;
172/// use ratatui_core::widgets::Widget;
173/// use tui_scrollbar::{ScrollBar, ScrollLengths};
174///
175/// let area = Rect::new(0, 0, 1, 5);
176/// let lengths = ScrollLengths {
177///     content_len: 200,
178///     viewport_len: 40,
179/// };
180/// let scrollbar = ScrollBar::vertical(lengths).offset(60);
181///
182/// let mut buffer = Buffer::empty(area);
183/// scrollbar.render(area, &mut buffer);
184/// ```
185///
186/// ## Updating offsets on input
187///
188/// This is the typical pattern for pointer handling: feed events to the scrollbar and apply the
189/// returned command to your stored offset.
190///
191/// ```rust,no_run
192/// use ratatui_core::layout::Rect;
193/// use tui_scrollbar::{
194///     PointerButton, PointerEvent, PointerEventKind, ScrollBar, ScrollBarInteraction,
195///     ScrollCommand, ScrollEvent, ScrollLengths,
196/// };
197///
198/// let area = Rect::new(0, 0, 1, 10);
199/// let lengths = ScrollLengths {
200///     content_len: 400,
201///     viewport_len: 80,
202/// };
203/// let scrollbar = ScrollBar::vertical(lengths).offset(0);
204/// let mut interaction = ScrollBarInteraction::new();
205/// let mut offset = 0;
206///
207/// let event = ScrollEvent::Pointer(PointerEvent {
208///     column: 0,
209///     row: 3,
210///     kind: PointerEventKind::Down,
211///     button: PointerButton::Primary,
212/// });
213///
214/// if let Some(ScrollCommand::SetOffset(next)) =
215///     scrollbar.handle_event(area, event, &mut interaction)
216/// {
217///     offset = next;
218/// }
219/// # let _ = offset;
220/// ```
221///
222/// ## Track click behavior
223///
224/// Choose between classic page jumps or jump-to-click behavior.
225///
226/// ```rust
227/// use tui_scrollbar::{ScrollBar, ScrollLengths, TrackClickBehavior};
228///
229/// let lengths = ScrollLengths {
230///     content_len: 10,
231///     viewport_len: 5,
232/// };
233/// let scrollbar =
234///     ScrollBar::vertical(lengths).track_click_behavior(TrackClickBehavior::JumpToClick);
235/// ```
236///
237/// ## Arrow endcaps
238///
239/// Arrow endcaps are optional. When enabled, they reserve one cell at each end of the track.
240///
241/// ```rust
242/// use tui_scrollbar::{ScrollBar, ScrollBarArrows, ScrollLengths};
243///
244/// let lengths = ScrollLengths {
245///     content_len: 120,
246///     viewport_len: 24,
247/// };
248/// let scrollbar = ScrollBar::vertical(lengths).arrows(ScrollBarArrows::Both);
249/// ```
250#[derive(Debug, Clone, PartialEq, Eq)]
251pub struct ScrollBar {
252    orientation: ScrollBarOrientation,
253    content_len: usize,
254    viewport_len: usize,
255    offset: usize,
256    track_style: Style,
257    thumb_style: Style,
258    arrow_style: Option<Style>,
259    glyph_set: GlyphSet,
260    arrows: ScrollBarArrows,
261    track_click_behavior: TrackClickBehavior,
262    scroll_step: usize,
263}
264
265impl ScrollBar {
266    /// Creates a scrollbar with the given orientation and lengths.
267    ///
268    /// Zero lengths are treated as 1.
269    ///
270    /// ```rust
271    /// use tui_scrollbar::{ScrollBar, ScrollBarOrientation, ScrollLengths};
272    ///
273    /// let lengths = ScrollLengths {
274    ///     content_len: 120,
275    ///     viewport_len: 40,
276    /// };
277    /// let scrollbar = ScrollBar::new(ScrollBarOrientation::Vertical, lengths);
278    /// ```
279    pub fn new(orientation: ScrollBarOrientation, lengths: crate::ScrollLengths) -> Self {
280        Self {
281            orientation,
282            content_len: lengths.content_len,
283            viewport_len: lengths.viewport_len,
284            offset: 0,
285            track_style: Style::new().bg(Color::DarkGray),
286            thumb_style: Style::new().fg(Color::White).bg(Color::DarkGray),
287            arrow_style: Some(Style::new().fg(Color::White).bg(Color::DarkGray)),
288            glyph_set: GlyphSet::default(),
289            arrows: ScrollBarArrows::default(),
290            track_click_behavior: TrackClickBehavior::Page,
291            scroll_step: 1,
292        }
293    }
294
295    /// Creates a vertical scrollbar with the given content and viewport lengths.
296    pub fn vertical(lengths: crate::ScrollLengths) -> Self {
297        Self::new(ScrollBarOrientation::Vertical, lengths)
298    }
299
300    /// Creates a horizontal scrollbar with the given content and viewport lengths.
301    pub fn horizontal(lengths: crate::ScrollLengths) -> Self {
302        Self::new(ScrollBarOrientation::Horizontal, lengths)
303    }
304
305    /// Sets the scrollbar orientation.
306    pub const fn orientation(mut self, orientation: ScrollBarOrientation) -> Self {
307        self.orientation = orientation;
308        self
309    }
310
311    /// Sets the total scrollable content length in logical units.
312    ///
313    /// Larger values shrink the thumb, while smaller values enlarge it.
314    ///
315    /// Zero values are treated as 1.
316    pub const fn content_len(mut self, content_len: usize) -> Self {
317        self.content_len = content_len;
318        self
319    }
320
321    /// Sets the visible viewport length in logical units.
322    ///
323    /// When `viewport_len >= content_len`, the thumb fills the track.
324    ///
325    /// Zero values are treated as 1.
326    pub const fn viewport_len(mut self, viewport_len: usize) -> Self {
327        self.viewport_len = viewport_len;
328        self
329    }
330
331    /// Sets the current scroll offset in logical units.
332    ///
333    /// Offsets are clamped to `content_len - viewport_len` during rendering.
334    pub const fn offset(mut self, offset: usize) -> Self {
335        self.offset = offset;
336        self
337    }
338
339    /// Sets the style applied to track glyphs.
340    ///
341    /// Track styling applies only where the thumb is not rendered.
342    pub const fn track_style(mut self, style: Style) -> Self {
343        self.track_style = style;
344        self
345    }
346
347    /// Sets the style applied to thumb glyphs.
348    ///
349    /// Thumb styling overrides track styling for covered cells.
350    pub const fn thumb_style(mut self, style: Style) -> Self {
351        self.thumb_style = style;
352        self
353    }
354
355    /// Sets the style applied to arrow glyphs.
356    ///
357    /// Defaults to white on dark gray.
358    pub const fn arrow_style(mut self, style: Style) -> Self {
359        self.arrow_style = Some(style);
360        self
361    }
362
363    /// Selects the glyph set used to render the track and thumb.
364    ///
365    /// [`GlyphSet::symbols_for_legacy_computing`] uses additional symbols for 1/8th upper/right
366    /// fills. Use [`GlyphSet::unicode`] if you want to avoid the legacy supplement.
367    pub const fn glyph_set(mut self, glyph_set: GlyphSet) -> Self {
368        self.glyph_set = glyph_set;
369        self
370    }
371
372    /// Sets which arrow endcaps are rendered.
373    pub const fn arrows(mut self, arrows: ScrollBarArrows) -> Self {
374        self.arrows = arrows;
375        self
376    }
377
378    /// Sets behavior for clicks on the track outside the thumb.
379    ///
380    /// Use [`TrackClickBehavior::Page`] for classic page-up/down behavior, or
381    /// [`TrackClickBehavior::JumpToClick`] to move the thumb toward the click.
382    pub const fn track_click_behavior(mut self, behavior: TrackClickBehavior) -> Self {
383        self.track_click_behavior = behavior;
384        self
385    }
386
387    /// Sets the scroll step used for wheel events.
388    ///
389    /// The wheel delta is multiplied by this value (in your logical units) and then clamped.
390    pub fn scroll_step(mut self, step: usize) -> Self {
391        self.scroll_step = step.max(1);
392        self
393    }
394
395    /// Computes the inner track area and arrow cell positions for this orientation.
396    fn arrow_layout(&self, area: Rect) -> ArrowLayout {
397        let mut track_area = area;
398        let (start, end) = match self.orientation {
399            ScrollBarOrientation::Vertical => {
400                let start_enabled = self.arrows.has_start() && area.height > 0;
401                let end_enabled = self.arrows.has_end() && area.height > start_enabled as u16;
402                let start = start_enabled.then_some((area.x, area.y));
403                let end = end_enabled
404                    .then_some((area.x, area.y.saturating_add(area.height).saturating_sub(1)));
405                if start_enabled {
406                    track_area.y = track_area.y.saturating_add(1);
407                    track_area.height = track_area.height.saturating_sub(1);
408                }
409                if end_enabled {
410                    track_area.height = track_area.height.saturating_sub(1);
411                }
412                (start, end)
413            }
414            ScrollBarOrientation::Horizontal => {
415                let start_enabled = self.arrows.has_start() && area.width > 0;
416                let end_enabled = self.arrows.has_end() && area.width > start_enabled as u16;
417                let start = start_enabled.then_some((area.x, area.y));
418                let end = end_enabled
419                    .then_some((area.x.saturating_add(area.width).saturating_sub(1), area.y));
420                if start_enabled {
421                    track_area.x = track_area.x.saturating_add(1);
422                    track_area.width = track_area.width.saturating_sub(1);
423                }
424                if end_enabled {
425                    track_area.width = track_area.width.saturating_sub(1);
426                }
427                (start, end)
428            }
429        };
430
431        ArrowLayout {
432            track_area,
433            start,
434            end,
435        }
436    }
437}
438
439#[cfg(test)]
440mod tests {
441    use ratatui_core::style::{Color, Style};
442
443    use super::*;
444    use crate::glyphs::GlyphSet;
445    use crate::ScrollLengths;
446
447    #[test]
448    fn builder_methods_update_fields() {
449        let lengths = ScrollLengths {
450            content_len: 10,
451            viewport_len: 4,
452        };
453        let track_style = Style::new().fg(Color::Red);
454        let thumb_style = Style::new().bg(Color::Blue);
455        let arrow_style = Style::new().fg(Color::Green);
456        let glyphs = GlyphSet::unicode();
457
458        let scrollbar = ScrollBar::new(ScrollBarOrientation::Vertical, lengths)
459            .orientation(ScrollBarOrientation::Horizontal)
460            .content_len(20)
461            .viewport_len(5)
462            .offset(3)
463            .track_style(track_style)
464            .thumb_style(thumb_style)
465            .arrow_style(arrow_style)
466            .glyph_set(glyphs.clone())
467            .arrows(ScrollBarArrows::End)
468            .track_click_behavior(TrackClickBehavior::JumpToClick)
469            .scroll_step(0);
470
471        assert_eq!(scrollbar.orientation, ScrollBarOrientation::Horizontal);
472        assert_eq!(scrollbar.content_len, 20);
473        assert_eq!(scrollbar.viewport_len, 5);
474        assert_eq!(scrollbar.offset, 3);
475        assert_eq!(scrollbar.track_style, track_style);
476        assert_eq!(scrollbar.thumb_style, thumb_style);
477        assert_eq!(scrollbar.arrow_style, Some(arrow_style));
478        assert_eq!(scrollbar.glyph_set, glyphs);
479        assert_eq!(scrollbar.arrows, ScrollBarArrows::End);
480        assert_eq!(
481            scrollbar.track_click_behavior,
482            TrackClickBehavior::JumpToClick
483        );
484        assert_eq!(scrollbar.scroll_step, 1);
485    }
486
487    #[test]
488    fn constructors_set_orientation() {
489        let lengths = ScrollLengths {
490            content_len: 10,
491            viewport_len: 4,
492        };
493        let vertical = ScrollBar::vertical(lengths);
494        let horizontal = ScrollBar::horizontal(lengths);
495
496        assert_eq!(vertical.orientation, ScrollBarOrientation::Vertical);
497        assert_eq!(horizontal.orientation, ScrollBarOrientation::Horizontal);
498    }
499
500    #[test]
501    fn reserves_track_cells_for_arrows() {
502        let lengths = ScrollLengths {
503            content_len: 10,
504            viewport_len: 4,
505        };
506        let scrollbar = ScrollBar::vertical(lengths).arrows(ScrollBarArrows::Both);
507        let area = Rect::new(0, 0, 1, 5);
508        let layout = scrollbar.arrow_layout(area);
509
510        assert_eq!(layout.track_area.height, 3);
511        assert_eq!(layout.start, Some((area.x, area.y)));
512        assert_eq!(
513            layout.end,
514            Some((area.x, area.y.saturating_add(area.height).saturating_sub(1)))
515        );
516    }
517
518    #[test]
519    fn reserves_track_cells_for_horizontal_arrows() {
520        let lengths = ScrollLengths {
521            content_len: 10,
522            viewport_len: 4,
523        };
524        let scrollbar = ScrollBar::horizontal(lengths).arrows(ScrollBarArrows::Both);
525        let area = Rect::new(0, 0, 5, 1);
526        let layout = scrollbar.arrow_layout(area);
527
528        assert_eq!(layout.track_area.width, 3);
529        assert_eq!(layout.start, Some((area.x, area.y)));
530        assert_eq!(
531            layout.end,
532            Some((area.x.saturating_add(area.width).saturating_sub(1), area.y))
533        );
534    }
535}