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