1use 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#[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 #[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 #[must_use]
74 pub const fn offset(mut self, offset: usize) -> Self {
75 self.offset = offset;
76 self
77 }
78
79 #[must_use]
81 pub const fn track_style(mut self, style: Style) -> Self {
82 self.track_style = style;
83 self
84 }
85
86 #[must_use]
88 pub const fn thumb_style(mut self, style: Style) -> Self {
89 self.thumb_style = style;
90 self
91 }
92
93 #[must_use]
101 pub fn theme(self, theme: Theme) -> Self {
102 self.theme_on(theme, theme.panel_bg)
103 }
104
105 #[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 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 #[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 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 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 if response.pressed() {
190 if let Some(pos) = response.pointer_pos() {
193 if let Some(target) = offset_for_pos(area, self.total_len, self.visible_len, pos) {
194 #[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 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)] 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)] mod 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); 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 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); 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); 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 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 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); 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); 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 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 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}