use retroglyph_core::app::Frame;
use retroglyph_core::color::{Color, Style};
use retroglyph_core::grid::Size;
use super::{AnimatedWidget, InteractiveWidget, MinSize, Widget};
use crate::Surface;
use crate::draw::{offset_for_pos, thumb_geometry};
use crate::interact::Density;
use crate::interact::Response;
use crate::interact::Sense;
use crate::state::ScrollState;
use crate::theme::Theme;
#[derive(Clone, Copy, Debug)]
pub struct Scrollbar {
total_len: usize,
visible_len: usize,
offset: usize,
track_style: Style,
thumb_style: Style,
}
impl Scrollbar {
#[must_use]
pub fn new(total_len: usize, visible_len: usize) -> Self {
Self {
total_len,
visible_len,
offset: 0,
track_style: Style::new(),
thumb_style: Style::new(),
}
.theme(Theme::DARK)
}
#[must_use]
pub const fn offset(mut self, offset: usize) -> Self {
self.offset = offset;
self
}
#[must_use]
pub const fn track_style(mut self, style: Style) -> Self {
self.track_style = style;
self
}
#[must_use]
pub const fn thumb_style(mut self, style: Style) -> Self {
self.thumb_style = style;
self
}
#[must_use]
pub fn theme(self, theme: Theme) -> Self {
self.theme_on(theme, theme.panel_bg)
}
#[must_use]
pub fn theme_on(mut self, theme: Theme, bg: Color) -> Self {
self.track_style = Style::new().bg(bg);
self.thumb_style = Style::new().bg(theme.border);
self
}
}
impl Scrollbar {
fn draw(&self, surface: &mut Surface<'_>, offset: usize) {
let (w, h) = (surface.width(), surface.height());
if w == 0 || h == 0 {
return;
}
for y in 0..h {
for x in 0..w {
surface.put((x, y), ' ', self.track_style);
}
}
let local = surface.area().at_origin();
let Some((start, len)) = thumb_geometry(local, self.total_len, self.visible_len, offset)
else {
return;
};
for y in start..(start + len) {
for x in 0..w {
surface.put((x, y), ' ', self.thumb_style);
}
}
}
#[allow(clippy::cast_precision_loss)]
const fn max_offset(&self) -> f32 {
self.total_len.saturating_sub(self.visible_len) as f32
}
}
impl MinSize for Scrollbar {
fn min_size(&self, density: Density) -> Size {
let track_height = u16::try_from(self.visible_len).unwrap_or(u16::MAX);
Size::new(1, track_height).max(density.min_target_size())
}
}
impl Widget for Scrollbar {
fn render(&self, surface: &mut Surface<'_>) {
self.draw(surface, self.offset);
}
}
impl<Id> InteractiveWidget<Id> for Scrollbar {
type State = ScrollState;
fn sense(&self) -> Sense {
Sense::drag() | Sense::CLICK | Sense::SCROLL
}
fn render(&self, surface: &mut Surface<'_>, state: &mut Self::State, response: Response<Id>) {
let area = surface.area();
let max_offset = self.max_offset();
if response.pressed() {
if let Some(pos) = response.pointer_pos() {
if let Some(target) = offset_for_pos(area, self.total_len, self.visible_len, pos) {
#[allow(clippy::cast_precision_loss)]
state.set_offset(target as f32, max_offset);
}
state.begin_drag(-f32::from(pos.y));
}
}
if response.held()
&& let Some(pos) = response.pointer_pos()
{
state.update_drag(-f32::from(pos.y), max_offset);
}
if response.released() {
state.end_drag();
}
state.apply(&response);
self.draw(surface, state.integer_offset());
}
}
impl AnimatedWidget for Scrollbar {
type State = ScrollState;
fn render(&self, surface: &mut Surface<'_>, state: &mut Self::State, frame: &Frame) {
let max_offset = self.total_len.saturating_sub(self.visible_len);
#[allow(clippy::cast_precision_loss)] state.tick(frame.delta, max_offset as f32);
self.draw(surface, state.integer_offset());
}
}
#[cfg(test)]
#[allow(clippy::float_cmp)] mod tests {
use retroglyph_core::color::Color;
use retroglyph_core::grid::{Grid, HasSize as _, Pos, Rect};
use super::*;
use crate::Surface;
#[test]
fn draws_a_plain_track_with_no_thumb_when_nothing_to_scroll() {
let area = Rect::new(0, 0, 1, 5);
let mut grid = Grid::new(1, 5);
let track = Style::new().bg(Color::rgb(1, 1, 1));
let thumb = Style::new().bg(Color::rgb(2, 2, 2));
let scrollbar = Scrollbar::new(3, 5).track_style(track).thumb_style(thumb);
Widget::render(&scrollbar, &mut Surface::new(&mut grid, area, 0));
for y in 0..5 {
assert_eq!(
grid[Pos::new(0, y)].style().background(),
track.background()
);
}
}
#[test]
fn draws_the_thumb_over_the_track() {
let area = Rect::new(0, 0, 1, 10);
let mut grid = Grid::new(1, 10);
let track = Style::new().bg(Color::rgb(1, 1, 1));
let thumb = Style::new().bg(Color::rgb(2, 2, 2));
let scrollbar = Scrollbar::new(20, 5)
.offset(0)
.track_style(track)
.thumb_style(thumb);
Widget::render(&scrollbar, &mut Surface::new(&mut grid, area, 0));
let (start, len) = thumb_geometry(area, 20, 5, 0).unwrap();
for y in 0..10 {
let bg = grid[Pos::new(0, y)].style().background();
if y >= start && y < start + len {
assert_eq!(bg, thumb.background());
} else {
assert_eq!(bg, track.background());
}
}
}
#[test]
fn theme_maps_named_roles_onto_track_and_thumb() {
let area = Rect::new(0, 0, 1, 10);
let mut grid = Grid::new(1, 10);
let scrollbar = Scrollbar::new(20, 5).theme(Theme::DARK);
Widget::render(&scrollbar, &mut Surface::new(&mut grid, area, 0));
let (start, len) = thumb_geometry(area, 20, 5, 0).unwrap();
for y in 0..10 {
let bg = grid[Pos::new(0, y)].style().background();
if y >= start && y < start + len {
assert_eq!(bg, Theme::DARK.border);
} else {
assert_eq!(bg, Theme::DARK.panel_bg);
}
}
}
#[test]
fn theme_on_uses_the_given_backdrop_instead_of_panel_bg() {
let area = Rect::new(0, 0, 1, 10);
let mut grid = Grid::new(1, 10);
let scrollbar = Scrollbar::new(20, 5).theme_on(Theme::DARK, Color::Default);
Widget::render(&scrollbar, &mut Surface::new(&mut grid, area, 0));
let (start, len) = thumb_geometry(area, 20, 5, 0).unwrap();
for y in 0..10 {
let bg = grid[Pos::new(0, y)].style().background();
if y >= start && y < start + len {
assert_eq!(bg, Theme::DARK.border);
} else {
assert_eq!(bg, Color::Default);
}
}
}
#[test]
fn offset_defaults_to_zero() {
let area = Rect::new(0, 0, 1, 10);
let mut grid = Grid::new(1, 10);
let track = Style::new().bg(Color::rgb(1, 1, 1));
let thumb = Style::new().bg(Color::rgb(2, 2, 2));
let scrollbar = Scrollbar::new(20, 5).track_style(track).thumb_style(thumb);
Widget::render(&scrollbar, &mut Surface::new(&mut grid, area, 0));
let (start, _) = thumb_geometry(area, 20, 5, 0).unwrap();
assert_eq!(start, 0);
}
fn frame(delta_ms: u64) -> Frame {
Frame::new(core::time::Duration::from_millis(delta_ms), 0)
}
#[test]
fn animated_render_ticks_state_before_drawing_the_thumb() {
let area = Rect::new(0, 0, 1, 10);
let mut grid = Grid::new(1, 10);
let mut state = ScrollState::new();
state.scroll_by_wheel(4.0); assert_eq!(state.offset(), 0.0, "no physics has run yet");
AnimatedWidget::render(
&Scrollbar::new(20, 5),
&mut Surface::new(&mut grid, area, 0),
&mut state,
&frame(100),
);
assert!(
state.offset() > 0.0,
"tick should have advanced the offset before drawing"
);
let (start, _) = thumb_geometry(area, 20, 5, state.integer_offset()).unwrap();
let mut expected = Grid::new(1, 10);
let scrollbar = Scrollbar::new(20, 5).offset(state.integer_offset());
Widget::render(&scrollbar, &mut Surface::new(&mut expected, area, 0));
for y in 0..10 {
assert_eq!(
grid[Pos::new(0, y)].style().background(),
expected[Pos::new(0, y)].style().background(),
"row {y}, thumb starting at {start}"
);
}
}
#[test]
fn animated_render_does_not_tick_while_dragging() {
let area = Rect::new(0, 0, 1, 10);
let mut grid = Grid::new(1, 10);
let mut state = ScrollState::new();
state.begin_drag(5.0);
state.scroll_by_wheel(4.0);
AnimatedWidget::render(
&Scrollbar::new(20, 5),
&mut Surface::new(&mut grid, area, 0),
&mut state,
&frame(100),
);
assert_eq!(state.offset(), 0.0, "tick is a no-op while dragging");
}
#[test]
fn drag_moves_scroll_state() {
use retroglyph_core::event::{
Event, KeyModifiers, MouseButton, MouseEvent, MouseEventKind,
};
use crate::interact::Interaction;
#[derive(Clone, Copy, PartialEq, Eq)]
enum Id {
Bar,
}
let area = Rect::new(0, 0, 1, 10); let scrollbar = Scrollbar::new(40, 10);
let mut state = ScrollState::new();
let mut interaction = Interaction::<Id>::new();
let mut grid = Grid::new(1, 10);
interaction.begin_frame();
let response =
interaction.interact(area, Id::Bar, InteractiveWidget::<Id>::sense(&scrollbar));
InteractiveWidget::render(
&scrollbar,
&mut Surface::new(&mut grid, area, 0),
&mut state,
response,
);
interaction.end_frame();
assert!((state.offset() - 0.0).abs() < f32::EPSILON);
let _ = interaction.handle_event(&Event::Mouse(MouseEvent::new(
MouseEventKind::Down(MouseButton::Left),
Pos::new(0, 0),
KeyModifiers::NONE,
)));
interaction.begin_frame();
let response =
interaction.interact(area, Id::Bar, InteractiveWidget::<Id>::sense(&scrollbar));
InteractiveWidget::render(
&scrollbar,
&mut Surface::new(&mut grid, area, 0),
&mut state,
response,
);
interaction.end_frame();
assert!((state.offset() - 0.0).abs() < f32::EPSILON);
let _ = interaction.handle_event(&Event::Mouse(MouseEvent::new(
MouseEventKind::Moved,
Pos::new(0, 9),
KeyModifiers::NONE,
)));
interaction.begin_frame();
let response =
interaction.interact(area, Id::Bar, InteractiveWidget::<Id>::sense(&scrollbar));
InteractiveWidget::render(
&scrollbar,
&mut Surface::new(&mut grid, area, 0),
&mut state,
response,
);
interaction.end_frame();
assert!(state.offset() > 0.0);
let _ = interaction.handle_event(&Event::Mouse(MouseEvent::new(
MouseEventKind::Up(MouseButton::Left),
Pos::new(0, 9),
KeyModifiers::NONE,
)));
interaction.begin_frame();
let response =
interaction.interact(area, Id::Bar, InteractiveWidget::<Id>::sense(&scrollbar));
InteractiveWidget::render(
&scrollbar,
&mut Surface::new(&mut grid, area, 0),
&mut state,
response,
);
interaction.end_frame();
assert!(!state.dragging());
}
#[test]
fn wheel_scroll_applies_velocity() {
let area = Rect::new(0, 0, 1, 10);
let scrollbar = Scrollbar::new(40, 10);
let mut state = ScrollState::new();
let response: Response<()> = Response {
scroll_delta: 2,
..Response::default()
};
let mut grid = Grid::new(1, 10);
InteractiveWidget::render(
&scrollbar,
&mut Surface::new(&mut grid, area, 0),
&mut state,
response,
);
assert!(state.velocity() > 0.0);
}
#[test]
fn min_size_is_one_column_by_visible_len_rows() {
let scrollbar = Scrollbar::new(100, 10);
let size = scrollbar.min_size(Density::Mouse);
assert_eq!(size.width(), 6);
assert_eq!(size.height(), 10);
}
#[test]
fn min_size_floors_a_short_track_at_the_density_minimum() {
let scrollbar = Scrollbar::new(100, 1);
let size = scrollbar.min_size(Density::Touch);
assert_eq!(size, Density::Touch.min_target_size());
}
}