Skip to main content

retroglyph_widgets/widget/
scrollbar.rs

1//! [`Scrollbar`]: a vertical track+thumb indicator.
2use retroglyph_core::{Backend, Color, Rect, Style, Terminal};
3
4use super::Widget;
5use crate::Theme;
6use crate::draw::thumb_geometry;
7
8/// A vertical scrollbar (typically one cell wide) covering `total_len`
9/// items in a `visible_len`-row viewport.
10///
11/// `offset` defaults to `0`; `track_style`/`thumb_style` default to
12/// [`Style::new()`]. Set whichever a caller needs via
13/// [`Scrollbar::offset`]/[`Scrollbar::track_style`]/[`Scrollbar::thumb_style`].
14///
15/// `track_style` fills the whole strip, then [`crate::draw::thumb_geometry`]'s
16/// span (if any) is redrawn with `thumb_style` on top. Draws just the plain
17/// track, with no thumb, if there's nothing to scroll -- see
18/// [`crate::draw::thumb_geometry`].
19///
20/// Deliberately independent of [`crate::interact`] -- see
21/// [`crate::draw::thumb_geometry`] and
22/// [`crate::draw::offset_for_pos`]'s own doc comments for how to make this
23/// draggable using [`Interaction`](crate::Interaction) instead.
24///
25/// # Examples
26///
27/// ```
28/// use retroglyph_core::{Headless, Rect, Terminal};
29/// use retroglyph_widgets::{Scrollbar, Widget};
30///
31/// let mut term = Terminal::new(Headless::new(1, 10));
32/// Scrollbar::new(100, 10).offset(20).render(Rect::new(0, 0, 1, 10), &mut term);
33/// ```
34#[derive(Clone, Copy, Debug)]
35pub struct Scrollbar {
36    total_len: usize,
37    visible_len: usize,
38    offset: usize,
39    track_style: Style,
40    thumb_style: Style,
41}
42
43impl Scrollbar {
44    /// A scrollbar covering `total_len` items in a `visible_len`-row
45    /// viewport, starting at offset `0` in the default style.
46    #[must_use]
47    pub fn new(total_len: usize, visible_len: usize) -> Self {
48        Self {
49            total_len,
50            visible_len,
51            offset: 0,
52            track_style: Style::new(),
53            thumb_style: Style::new(),
54        }
55    }
56
57    /// Set the scroll offset the thumb is drawn at.
58    #[must_use]
59    pub const fn offset(mut self, offset: usize) -> Self {
60        self.offset = offset;
61        self
62    }
63
64    /// Set the track's style.
65    #[must_use]
66    pub const fn track_style(mut self, style: Style) -> Self {
67        self.track_style = style;
68        self
69    }
70
71    /// Set the thumb's style.
72    #[must_use]
73    pub const fn thumb_style(mut self, style: Style) -> Self {
74        self.thumb_style = style;
75        self
76    }
77
78    /// Applies `theme`'s named roles to this scrollbar: `track_style` becomes `theme.panel_bg`
79    /// (the same surface the scrolled content sits on), and `thumb_style` becomes `theme.border`
80    /// -- a subtle divider-like color rather than `theme.accent`, so a themed scrollbar doesn't
81    /// compete with an actually-selected/focused control for attention.
82    ///
83    /// Call before any manual [`Scrollbar::track_style`]/[`Scrollbar::thumb_style`] override you
84    /// want to keep.
85    #[must_use]
86    pub fn theme(self, theme: Theme) -> Self {
87        self.theme_on(theme, theme.panel_bg)
88    }
89
90    /// Same as [`Scrollbar::theme`], but `track_style` is drawn on `bg` instead of
91    /// `theme.panel_bg` -- for a scrollbar drawn directly on a backdrop other than a themed
92    /// [`super::Panel`]/[`super::Modal`]'s fill. [`Scrollbar::theme`] is exactly
93    /// `theme_on(theme, theme.panel_bg)`.
94    #[must_use]
95    pub fn theme_on(mut self, theme: Theme, bg: Color) -> Self {
96        self.track_style = Style::new().bg(bg);
97        self.thumb_style = Style::new().bg(theme.border);
98        self
99    }
100}
101
102impl<B: Backend> Widget<B> for Scrollbar {
103    fn render(self, area: Rect, term: &mut Terminal<B>) {
104        if area.width() == 0 || area.height() == 0 {
105            return;
106        }
107
108        for y in area.top()..area.bottom() {
109            for x in area.left()..area.right() {
110                term.put_styled(x, y, ' ', self.track_style);
111            }
112        }
113
114        let Some((start, len)) =
115            thumb_geometry(area, self.total_len, self.visible_len, self.offset)
116        else {
117            return;
118        };
119        for y in (area.top() + start)..(area.top() + start + len) {
120            for x in area.left()..area.right() {
121                term.put_styled(x, y, ' ', self.thumb_style);
122            }
123        }
124    }
125}
126
127#[cfg(test)]
128mod tests {
129    use retroglyph_core::{Color, Headless};
130
131    use super::*;
132
133    #[test]
134    fn draws_a_plain_track_with_no_thumb_when_nothing_to_scroll() {
135        let area = Rect::new(0, 0, 1, 5);
136        let mut term = Terminal::new(Headless::new(1, 5));
137        let track = Style::new().bg(Color::Rgb { r: 1, g: 1, b: 1 });
138        let thumb = Style::new().bg(Color::Rgb { r: 2, g: 2, b: 2 });
139        Scrollbar::new(3, 5)
140            .track_style(track)
141            .thumb_style(thumb)
142            .render(area, &mut term);
143        for y in 0..5 {
144            assert_eq!(
145                term.grid().get(0, y).style().background(),
146                track.background()
147            );
148        }
149    }
150
151    #[test]
152    fn draws_the_thumb_over_the_track() {
153        let area = Rect::new(0, 0, 1, 10);
154        let mut term = Terminal::new(Headless::new(1, 10));
155        let track = Style::new().bg(Color::Rgb { r: 1, g: 1, b: 1 });
156        let thumb = Style::new().bg(Color::Rgb { r: 2, g: 2, b: 2 });
157        Scrollbar::new(20, 5)
158            .offset(0)
159            .track_style(track)
160            .thumb_style(thumb)
161            .render(area, &mut term);
162
163        let (start, len) = thumb_geometry(area, 20, 5, 0).unwrap();
164        for y in 0..10 {
165            let bg = term.grid().get(0, y).style().background();
166            if y >= start && y < start + len {
167                assert_eq!(bg, thumb.background());
168            } else {
169                assert_eq!(bg, track.background());
170            }
171        }
172    }
173
174    #[test]
175    fn theme_maps_named_roles_onto_track_and_thumb() {
176        let area = Rect::new(0, 0, 1, 10);
177        let mut term = Terminal::new(Headless::new(1, 10));
178        Scrollbar::new(20, 5)
179            .theme(Theme::DARK)
180            .render(area, &mut term);
181
182        let (start, len) = thumb_geometry(area, 20, 5, 0).unwrap();
183        for y in 0..10 {
184            let bg = term.grid().get(0, y).style().background();
185            if y >= start && y < start + len {
186                assert_eq!(bg, Theme::DARK.border);
187            } else {
188                assert_eq!(bg, Theme::DARK.panel_bg);
189            }
190        }
191    }
192
193    #[test]
194    fn theme_on_uses_the_given_backdrop_instead_of_panel_bg() {
195        let area = Rect::new(0, 0, 1, 10);
196        let mut term = Terminal::new(Headless::new(1, 10));
197        Scrollbar::new(20, 5)
198            .theme_on(Theme::DARK, Color::Default)
199            .render(area, &mut term);
200
201        let (start, len) = thumb_geometry(area, 20, 5, 0).unwrap();
202        for y in 0..10 {
203            let bg = term.grid().get(0, y).style().background();
204            if y >= start && y < start + len {
205                assert_eq!(bg, Theme::DARK.border);
206            } else {
207                assert_eq!(bg, Color::Default);
208            }
209        }
210    }
211
212    #[test]
213    fn offset_defaults_to_zero() {
214        let area = Rect::new(0, 0, 1, 10);
215        let mut term = Terminal::new(Headless::new(1, 10));
216        let track = Style::new().bg(Color::Rgb { r: 1, g: 1, b: 1 });
217        let thumb = Style::new().bg(Color::Rgb { r: 2, g: 2, b: 2 });
218        Scrollbar::new(20, 5)
219            .track_style(track)
220            .thumb_style(thumb)
221            .render(area, &mut term);
222
223        let (start, _) = thumb_geometry(area, 20, 5, 0).unwrap();
224        assert_eq!(start, 0);
225    }
226}