Skip to main content

retroglyph_ui/widget/
scrollbar.rs

1//! [`Scrollbar`]: a vertical track+thumb indicator.
2use retroglyph_core::app::Frame;
3use retroglyph_core::color::{Color, Style};
4use retroglyph_core::grid::Size;
5
6use super::{AnimatedWidget, InteractiveWidget, MinSize, Widget};
7use crate::Surface;
8use crate::draw::{offset_for_pos, thumb_geometry};
9use crate::interact::Density;
10use crate::interact::Response;
11use crate::interact::Sense;
12use crate::state::ScrollState;
13use crate::theme::Theme;
14
15/// A vertical scrollbar (typically one cell wide) covering `total_len`
16/// items in a `visible_len`-row viewport.
17///
18/// `offset` defaults to `0`; `track_style`/`thumb_style` default to [`Theme::DARK`] (as if
19/// [`Scrollbar::theme`] had been called). Set whichever a caller needs via
20/// [`Scrollbar::offset`]/[`Scrollbar::track_style`]/[`Scrollbar::thumb_style`].
21///
22/// `track_style` fills the whole strip, then [`crate::draw::thumb_geometry`]'s
23/// span (if any) is redrawn with `thumb_style` on top. Draws just the plain
24/// track, with no thumb, if there's nothing to scroll: see
25/// [`crate::draw::thumb_geometry`].
26///
27/// As a plain [`Widget`], `Scrollbar` is purely a display: `offset` is whatever the caller last
28/// set, unaffected by the pointer. [`InteractiveWidget`]'s `type State = ScrollState` makes it
29/// draggable and wheel-scrollable instead: `sense()` is
30/// <code>[Sense::drag]() | [Sense::CLICK](crate::interact::Sense::CLICK) |
31/// [Sense::SCROLL](crate::interact::Sense::SCROLL)</code>, and its `render` reads the thumb's position from
32/// `state.integer_offset()`, drives a drag via [`offset_for_pos`]/[`ScrollState::update_drag`]/
33/// [`ScrollState::end_drag`] using [`Response::pointer_pos`], and applies wheel input via
34/// [`ScrollState::apply`].
35///
36/// # Examples
37///
38/// ```
39/// use retroglyph_core::grid::{Grid, Rect};
40/// use retroglyph_ui::widget::{Scrollbar, Widget};
41/// use retroglyph_ui::Surface;
42///
43/// let area = Rect::new(0, 0, 1, 10);
44/// let mut grid = Grid::new(1, 10);
45/// let scrollbar = Scrollbar::new(100, 10).offset(20);
46/// Widget::render(&scrollbar, &mut Surface::new(&mut grid, area, 0));
47/// ```
48#[derive(Clone, Copy, Debug)]
49pub struct Scrollbar {
50    total_len: usize,
51    visible_len: usize,
52    offset: usize,
53    track_style: Style,
54    thumb_style: Style,
55}
56
57impl Scrollbar {
58    /// A scrollbar covering `total_len` items in a `visible_len`-row viewport, starting at offset
59    /// `0`, styled from [`Theme::DARK`] (as if [`Scrollbar::theme`] had been called).
60    #[must_use]
61    pub fn new(total_len: usize, visible_len: usize) -> Self {
62        Self {
63            total_len,
64            visible_len,
65            offset: 0,
66            track_style: Style::new(),
67            thumb_style: Style::new(),
68        }
69        .theme(Theme::DARK)
70    }
71
72    /// Set the scroll offset the thumb is drawn at.
73    #[must_use]
74    pub const fn offset(mut self, offset: usize) -> Self {
75        self.offset = offset;
76        self
77    }
78
79    /// Set the track's style.
80    #[must_use]
81    pub const fn track_style(mut self, style: Style) -> Self {
82        self.track_style = style;
83        self
84    }
85
86    /// Set the thumb's style.
87    #[must_use]
88    pub const fn thumb_style(mut self, style: Style) -> Self {
89        self.thumb_style = style;
90        self
91    }
92
93    /// Applies `theme`'s named roles to this scrollbar: `track_style` becomes `theme.panel_bg`
94    /// (the same surface the scrolled content sits on), and `thumb_style` becomes `theme.border`:
95    /// a subtle divider-like color rather than `theme.accent`, so a themed scrollbar doesn't
96    /// compete with an actually-selected/focused control for attention.
97    ///
98    /// Call before any manual [`Scrollbar::track_style`]/[`Scrollbar::thumb_style`] override you
99    /// want to keep.
100    #[must_use]
101    pub fn theme(self, theme: Theme) -> Self {
102        self.theme_on(theme, theme.panel_bg)
103    }
104
105    /// Same as [`Scrollbar::theme`], but `track_style` is drawn on `bg` instead of
106    /// `theme.panel_bg`: for a scrollbar drawn directly on a backdrop other than a themed
107    /// [`super::Panel`]/[`super::Modal`]'s fill. [`Scrollbar::theme`] is exactly
108    /// `theme_on(theme, theme.panel_bg)`.
109    #[must_use]
110    pub fn theme_on(mut self, theme: Theme, bg: Color) -> Self {
111        self.track_style = Style::new().bg(bg);
112        self.thumb_style = Style::new().bg(theme.border);
113        self
114    }
115}
116
117impl Scrollbar {
118    /// The shared drawing routine both [`Widget::render`] and [`InteractiveWidget::render`] use,
119    /// parameterized on the offset to draw the thumb at: the display-only [`Widget`] impl passes
120    /// `self.offset`, the interactive one passes `state.integer_offset()`.
121    fn draw(&self, surface: &mut Surface<'_>, offset: usize) {
122        let (w, h) = (surface.width(), surface.height());
123        if w == 0 || h == 0 {
124            return;
125        }
126
127        for y in 0..h {
128            for x in 0..w {
129                surface.put((x, y), ' ', self.track_style);
130            }
131        }
132
133        let local = surface.area().at_origin();
134        let Some((start, len)) = thumb_geometry(local, self.total_len, self.visible_len, offset)
135        else {
136            return;
137        };
138        for y in start..(start + len) {
139            for x in 0..w {
140                surface.put((x, y), ' ', self.thumb_style);
141            }
142        }
143    }
144
145    /// This scrollbar's maximum offset: `0` if everything already fits (`total_len <=
146    /// visible_len`), otherwise `total_len - visible_len`, widened to `f32` for
147    /// [`ScrollState`]'s fractional-offset math.
148    #[allow(clippy::cast_precision_loss)]
149    const fn max_offset(&self) -> f32 {
150        self.total_len.saturating_sub(self.visible_len) as f32
151    }
152}
153
154impl MinSize for Scrollbar {
155    /// One column wide, `visible_len` rows tall (the natural track height, saturating at
156    /// [`u16::MAX`] for an implausibly large `visible_len`), floored at `density`'s
157    /// [`min_target_size`](Density::min_target_size) so a touch-sized thumb still has room to
158    /// grab.
159    fn min_size(&self, density: Density) -> Size {
160        let track_height = u16::try_from(self.visible_len).unwrap_or(u16::MAX);
161        Size::new(1, track_height).max(density.min_target_size())
162    }
163}
164
165impl Widget for Scrollbar {
166    fn render(&self, surface: &mut Surface<'_>) {
167        self.draw(surface, self.offset);
168    }
169}
170
171impl<Id> InteractiveWidget<Id> for Scrollbar {
172    type State = ScrollState;
173
174    /// Draggable (thumb or track, click-to-jump then continues as a smooth drag) and
175    /// wheel-scrollable.
176    fn sense(&self) -> Sense {
177        Sense::drag() | Sense::CLICK | Sense::SCROLL
178    }
179
180    fn render(&self, surface: &mut Surface<'_>, state: &mut Self::State, response: Response<Id>) {
181        let area = surface.area();
182        let max_offset = self.max_offset();
183
184        // `ScrollState::update_drag` follows a content-drag convention (dragging up, i.e.
185        // decreasing `y`, increases the offset, matching a touch-scroll gesture). A scrollbar
186        // thumb needs the opposite: dragging *down* increases the offset, so `y` is negated
187        // before it's fed in, flipping that convention to match `offset_for_pos`'s own
188        // top-to-bottom-is-increasing mapping.
189        if response.pressed() {
190            // Click-to-jump: land the thumb under the pointer immediately, then let the drag
191            // below continue smoothly from there.
192            if let Some(pos) = response.pointer_pos() {
193                if let Some(target) = offset_for_pos(area, self.total_len, self.visible_len, pos) {
194                    // `target` is bounded to `total_len - visible_len` by `offset_for_pos`, the
195                    // same quantity `max_offset` widens to `f32` above.
196                    #[allow(clippy::cast_precision_loss)]
197                    state.set_offset(target as f32, max_offset);
198                }
199                state.begin_drag(-f32::from(pos.y));
200            }
201        }
202        if response.held()
203            && let Some(pos) = response.pointer_pos()
204        {
205            state.update_drag(-f32::from(pos.y), max_offset);
206        }
207        if response.released() {
208            state.end_drag();
209        }
210        state.apply(&response);
211
212        self.draw(surface, state.integer_offset());
213    }
214}
215
216impl AnimatedWidget for Scrollbar {
217    type State = ScrollState;
218
219    /// Ticks `state`'s momentum/rubber-band physics forward by `frame.delta` (a no-op while
220    /// [`ScrollState::dragging`](crate::state::ScrollState::dragging) is `true`, per [`ScrollState::tick`]'s
221    /// own docs), then draws the thumb at the resulting
222    /// [`integer_offset`](crate::state::ScrollState::integer_offset): the [`Widget::render`] this type
223    /// already has, just with `self.offset` replaced by `state`'s current one. `max_offset` is
224    /// `total_len - visible_len` (floored at `0`), matching [`thumb_geometry`]'s own definition, so
225    /// the physics and the drawn thumb position are always in terms of the same bound.
226    fn render(&self, surface: &mut Surface<'_>, state: &mut Self::State, frame: &Frame) {
227        let max_offset = self.total_len.saturating_sub(self.visible_len);
228        #[allow(clippy::cast_precision_loss)] // scroll extents stay well under 2^24 items
229        state.tick(frame.delta, max_offset as f32);
230
231        self.draw(surface, state.integer_offset());
232    }
233}
234
235#[cfg(test)]
236#[allow(clippy::float_cmp)] // ScrollState offsets under test here are exact 0.0 sentinels, not
237// accumulated float results, so exact equality is the correct check, not a bug.
238mod tests {
239    use retroglyph_core::color::Color;
240    use retroglyph_core::grid::{Grid, HasSize as _, Pos, Rect};
241
242    use super::*;
243    use crate::Surface;
244
245    #[test]
246    fn draws_a_plain_track_with_no_thumb_when_nothing_to_scroll() {
247        let area = Rect::new(0, 0, 1, 5);
248        let mut grid = Grid::new(1, 5);
249        let track = Style::new().bg(Color::rgb(1, 1, 1));
250        let thumb = Style::new().bg(Color::rgb(2, 2, 2));
251        let scrollbar = Scrollbar::new(3, 5).track_style(track).thumb_style(thumb);
252        Widget::render(&scrollbar, &mut Surface::new(&mut grid, area, 0));
253        for y in 0..5 {
254            assert_eq!(
255                grid[Pos::new(0, y)].style().background(),
256                track.background()
257            );
258        }
259    }
260
261    #[test]
262    fn draws_the_thumb_over_the_track() {
263        let area = Rect::new(0, 0, 1, 10);
264        let mut grid = Grid::new(1, 10);
265        let track = Style::new().bg(Color::rgb(1, 1, 1));
266        let thumb = Style::new().bg(Color::rgb(2, 2, 2));
267        let scrollbar = Scrollbar::new(20, 5)
268            .offset(0)
269            .track_style(track)
270            .thumb_style(thumb);
271        Widget::render(&scrollbar, &mut Surface::new(&mut grid, area, 0));
272
273        let (start, len) = thumb_geometry(area, 20, 5, 0).unwrap();
274        for y in 0..10 {
275            let bg = grid[Pos::new(0, y)].style().background();
276            if y >= start && y < start + len {
277                assert_eq!(bg, thumb.background());
278            } else {
279                assert_eq!(bg, track.background());
280            }
281        }
282    }
283
284    #[test]
285    fn theme_maps_named_roles_onto_track_and_thumb() {
286        let area = Rect::new(0, 0, 1, 10);
287        let mut grid = Grid::new(1, 10);
288        let scrollbar = Scrollbar::new(20, 5).theme(Theme::DARK);
289        Widget::render(&scrollbar, &mut Surface::new(&mut grid, area, 0));
290
291        let (start, len) = thumb_geometry(area, 20, 5, 0).unwrap();
292        for y in 0..10 {
293            let bg = grid[Pos::new(0, y)].style().background();
294            if y >= start && y < start + len {
295                assert_eq!(bg, Theme::DARK.border);
296            } else {
297                assert_eq!(bg, Theme::DARK.panel_bg);
298            }
299        }
300    }
301
302    #[test]
303    fn theme_on_uses_the_given_backdrop_instead_of_panel_bg() {
304        let area = Rect::new(0, 0, 1, 10);
305        let mut grid = Grid::new(1, 10);
306        let scrollbar = Scrollbar::new(20, 5).theme_on(Theme::DARK, Color::Default);
307        Widget::render(&scrollbar, &mut Surface::new(&mut grid, area, 0));
308
309        let (start, len) = thumb_geometry(area, 20, 5, 0).unwrap();
310        for y in 0..10 {
311            let bg = grid[Pos::new(0, y)].style().background();
312            if y >= start && y < start + len {
313                assert_eq!(bg, Theme::DARK.border);
314            } else {
315                assert_eq!(bg, Color::Default);
316            }
317        }
318    }
319
320    #[test]
321    fn offset_defaults_to_zero() {
322        let area = Rect::new(0, 0, 1, 10);
323        let mut grid = Grid::new(1, 10);
324        let track = Style::new().bg(Color::rgb(1, 1, 1));
325        let thumb = Style::new().bg(Color::rgb(2, 2, 2));
326        let scrollbar = Scrollbar::new(20, 5).track_style(track).thumb_style(thumb);
327        Widget::render(&scrollbar, &mut Surface::new(&mut grid, area, 0));
328
329        let (start, _) = thumb_geometry(area, 20, 5, 0).unwrap();
330        assert_eq!(start, 0);
331    }
332
333    fn frame(delta_ms: u64) -> Frame {
334        Frame::new(core::time::Duration::from_millis(delta_ms), 0)
335    }
336
337    #[test]
338    fn animated_render_ticks_state_before_drawing_the_thumb() {
339        let area = Rect::new(0, 0, 1, 10);
340        let mut grid = Grid::new(1, 10);
341        let mut state = ScrollState::new();
342        state.scroll_by_wheel(4.0); // gives the state some velocity to integrate
343        assert_eq!(state.offset(), 0.0, "no physics has run yet");
344
345        AnimatedWidget::render(
346            &Scrollbar::new(20, 5),
347            &mut Surface::new(&mut grid, area, 0),
348            &mut state,
349            &frame(100),
350        );
351
352        assert!(
353            state.offset() > 0.0,
354            "tick should have advanced the offset before drawing"
355        );
356
357        // The thumb was drawn at the *post-tick* offset, not the stale offset from before the
358        // call: proves render() and tick() ran in the order the trait promises, in one call.
359        let (start, _) = thumb_geometry(area, 20, 5, state.integer_offset()).unwrap();
360        let mut expected = Grid::new(1, 10);
361        let scrollbar = Scrollbar::new(20, 5).offset(state.integer_offset());
362        Widget::render(&scrollbar, &mut Surface::new(&mut expected, area, 0));
363        for y in 0..10 {
364            assert_eq!(
365                grid[Pos::new(0, y)].style().background(),
366                expected[Pos::new(0, y)].style().background(),
367                "row {y}, thumb starting at {start}"
368            );
369        }
370    }
371
372    #[test]
373    fn animated_render_does_not_tick_while_dragging() {
374        let area = Rect::new(0, 0, 1, 10);
375        let mut grid = Grid::new(1, 10);
376        let mut state = ScrollState::new();
377        state.begin_drag(5.0);
378        state.scroll_by_wheel(4.0); // ignored while dragging, per ScrollState::scroll_by_wheel
379
380        AnimatedWidget::render(
381            &Scrollbar::new(20, 5),
382            &mut Surface::new(&mut grid, area, 0),
383            &mut state,
384            &frame(100),
385        );
386
387        assert_eq!(state.offset(), 0.0, "tick is a no-op while dragging");
388    }
389
390    #[test]
391    fn drag_moves_scroll_state() {
392        use retroglyph_core::event::{
393            Event, KeyModifiers, MouseButton, MouseEvent, MouseEventKind,
394        };
395
396        use crate::interact::Interaction;
397
398        #[derive(Clone, Copy, PartialEq, Eq)]
399        enum Id {
400            Bar,
401        }
402
403        let area = Rect::new(0, 0, 1, 10); // total=40, visible=10 -> max_offset = 30
404        let scrollbar = Scrollbar::new(40, 10);
405        let mut state = ScrollState::new();
406        let mut interaction = Interaction::<Id>::new();
407        let mut grid = Grid::new(1, 10);
408
409        // Frame 1: register the rect for next frame's hit-test.
410        interaction.begin_frame();
411        let response =
412            interaction.interact(area, Id::Bar, InteractiveWidget::<Id>::sense(&scrollbar));
413        InteractiveWidget::render(
414            &scrollbar,
415            &mut Surface::new(&mut grid, area, 0),
416            &mut state,
417            response,
418        );
419        interaction.end_frame();
420        assert!((state.offset() - 0.0).abs() < f32::EPSILON);
421
422        // Press at the top of the track: resolves against frame 1's registration.
423        let _ = interaction.handle_event(&Event::Mouse(MouseEvent::new(
424            MouseEventKind::Down(MouseButton::Left),
425            Pos::new(0, 0),
426            KeyModifiers::NONE,
427        )));
428        interaction.begin_frame();
429        let response =
430            interaction.interact(area, Id::Bar, InteractiveWidget::<Id>::sense(&scrollbar));
431        InteractiveWidget::render(
432            &scrollbar,
433            &mut Surface::new(&mut grid, area, 0),
434            &mut state,
435            response,
436        );
437        interaction.end_frame();
438        assert!((state.offset() - 0.0).abs() < f32::EPSILON); // clicked at the top: no jump needed
439
440        // Drag down to the bottom of the track while still held.
441        let _ = interaction.handle_event(&Event::Mouse(MouseEvent::new(
442            MouseEventKind::Moved,
443            Pos::new(0, 9),
444            KeyModifiers::NONE,
445        )));
446        interaction.begin_frame();
447        let response =
448            interaction.interact(area, Id::Bar, InteractiveWidget::<Id>::sense(&scrollbar));
449        InteractiveWidget::render(
450            &scrollbar,
451            &mut Surface::new(&mut grid, area, 0),
452            &mut state,
453            response,
454        );
455        interaction.end_frame();
456        assert!(state.offset() > 0.0); // dragged toward the bottom: offset increased
457
458        let _ = interaction.handle_event(&Event::Mouse(MouseEvent::new(
459            MouseEventKind::Up(MouseButton::Left),
460            Pos::new(0, 9),
461            KeyModifiers::NONE,
462        )));
463        interaction.begin_frame();
464        let response =
465            interaction.interact(area, Id::Bar, InteractiveWidget::<Id>::sense(&scrollbar));
466        InteractiveWidget::render(
467            &scrollbar,
468            &mut Surface::new(&mut grid, area, 0),
469            &mut state,
470            response,
471        );
472        interaction.end_frame();
473        assert!(!state.dragging());
474    }
475
476    #[test]
477    fn wheel_scroll_applies_velocity() {
478        let area = Rect::new(0, 0, 1, 10);
479        let scrollbar = Scrollbar::new(40, 10);
480        let mut state = ScrollState::new();
481        let response: Response<()> = Response {
482            scroll_delta: 2,
483            ..Response::default()
484        };
485        let mut grid = Grid::new(1, 10);
486        InteractiveWidget::render(
487            &scrollbar,
488            &mut Surface::new(&mut grid, area, 0),
489            &mut state,
490            response,
491        );
492        assert!(state.velocity() > 0.0);
493    }
494
495    #[test]
496    fn min_size_is_one_column_by_visible_len_rows() {
497        // 10 rows is well past either density's height floor (1 for Mouse, 3 for Touch), and
498        // the natural 1-column width is below the 6-cell floor, so the floor wins on width only.
499        let scrollbar = Scrollbar::new(100, 10);
500        let size = scrollbar.min_size(Density::Mouse);
501        assert_eq!(size.width(), 6);
502        assert_eq!(size.height(), 10);
503    }
504
505    #[test]
506    fn min_size_floors_a_short_track_at_the_density_minimum() {
507        // A 1-row-tall track is nowhere near Touch's 3-row height floor.
508        let scrollbar = Scrollbar::new(100, 1);
509        let size = scrollbar.min_size(Density::Touch);
510        assert_eq!(size, Density::Touch.min_target_size());
511    }
512}