Skip to main content

retroglyph_widgets/widget/
scrollbar.rs

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