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