Skip to main content

retroglyph_widgets/widget/
scrollbar.rs

1//! [`Scrollbar`]: a vertical track+thumb indicator.
2use retroglyph_core::{Backend, 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(mut self, theme: Theme) -> Self {
77        self.track_style = Style::new().bg(theme.panel_bg);
78        self.thumb_style = Style::new().bg(theme.border);
79        self
80    }
81}
82
83impl<B: Backend> Widget<B> for Scrollbar {
84    fn render(self, area: Rect, term: &mut Terminal<B>) {
85        if area.width() == 0 || area.height() == 0 {
86            return;
87        }
88
89        for y in area.top()..area.bottom() {
90            for x in area.left()..area.right() {
91                term.put_styled(x, y, ' ', self.track_style);
92            }
93        }
94
95        let Some((start, len)) =
96            thumb_geometry(area, self.total_len, self.visible_len, self.offset)
97        else {
98            return;
99        };
100        for y in (area.top() + start)..(area.top() + start + len) {
101            for x in area.left()..area.right() {
102                term.put_styled(x, y, ' ', self.thumb_style);
103            }
104        }
105    }
106}
107
108#[cfg(test)]
109mod tests {
110    use retroglyph_core::{Color, Headless};
111
112    use super::*;
113
114    #[test]
115    fn draws_a_plain_track_with_no_thumb_when_nothing_to_scroll() {
116        let area = Rect::new(0, 0, 1, 5);
117        let mut term = Terminal::new(Headless::new(1, 5));
118        let track = Style::new().bg(Color::Rgb { r: 1, g: 1, b: 1 });
119        let thumb = Style::new().bg(Color::Rgb { r: 2, g: 2, b: 2 });
120        Scrollbar::new(3, 5)
121            .track_style(track)
122            .thumb_style(thumb)
123            .render(area, &mut term);
124        for y in 0..5 {
125            assert_eq!(
126                term.grid().get(0, y).style().background(),
127                track.background()
128            );
129        }
130    }
131
132    #[test]
133    fn draws_the_thumb_over_the_track() {
134        let area = Rect::new(0, 0, 1, 10);
135        let mut term = Terminal::new(Headless::new(1, 10));
136        let track = Style::new().bg(Color::Rgb { r: 1, g: 1, b: 1 });
137        let thumb = Style::new().bg(Color::Rgb { r: 2, g: 2, b: 2 });
138        Scrollbar::new(20, 5)
139            .offset(0)
140            .track_style(track)
141            .thumb_style(thumb)
142            .render(area, &mut term);
143
144        let (start, len) = thumb_geometry(area, 20, 5, 0).unwrap();
145        for y in 0..10 {
146            let bg = term.grid().get(0, y).style().background();
147            if y >= start && y < start + len {
148                assert_eq!(bg, thumb.background());
149            } else {
150                assert_eq!(bg, track.background());
151            }
152        }
153    }
154
155    #[test]
156    fn theme_maps_named_roles_onto_track_and_thumb() {
157        let area = Rect::new(0, 0, 1, 10);
158        let mut term = Terminal::new(Headless::new(1, 10));
159        Scrollbar::new(20, 5)
160            .theme(Theme::DARK)
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, Theme::DARK.border);
168            } else {
169                assert_eq!(bg, Theme::DARK.panel_bg);
170            }
171        }
172    }
173
174    #[test]
175    fn offset_defaults_to_zero() {
176        let area = Rect::new(0, 0, 1, 10);
177        let mut term = Terminal::new(Headless::new(1, 10));
178        let track = Style::new().bg(Color::Rgb { r: 1, g: 1, b: 1 });
179        let thumb = Style::new().bg(Color::Rgb { r: 2, g: 2, b: 2 });
180        Scrollbar::new(20, 5)
181            .track_style(track)
182            .thumb_style(thumb)
183            .render(area, &mut term);
184
185        let (start, _) = thumb_geometry(area, 20, 5, 0).unwrap();
186        assert_eq!(start, 0);
187    }
188}