1use 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#[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 #[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 #[must_use]
71 pub const fn offset(mut self, offset: usize) -> Self {
72 self.offset = offset;
73 self
74 }
75
76 #[must_use]
78 pub const fn track_style(mut self, style: Style) -> Self {
79 self.track_style = style;
80 self
81 }
82
83 #[must_use]
85 pub const fn thumb_style(mut self, style: Style) -> Self {
86 self.thumb_style = style;
87 self
88 }
89
90 #[must_use]
98 pub fn theme(self, theme: Theme) -> Self {
99 self.theme_on(theme, theme.panel_bg)
100 }
101
102 #[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 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 #[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 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 if response.pressed() {
176 if let Some(pos) = response.pointer_pos() {
179 if let Some(target) = offset_for_pos(area, self.total_len, self.visible_len, pos) {
180 #[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 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)] 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)] mod 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); 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 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); 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); 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 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 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); 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); 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}