1#[cfg(feature = "crossterm")]
11use crossterm::event::{MouseButton, MouseEvent, MouseEventKind};
12use ratatui_core::layout::Rect;
13
14use super::{ArrowHit, ArrowLayout, ScrollBar, ScrollBarOrientation, TrackClickBehavior};
15use crate::input::{
16 DragState, PointerButton, PointerEvent, PointerEventKind, ScrollAxis, ScrollBarInteraction,
17 ScrollCommand, ScrollEvent, ScrollWheel,
18};
19use crate::metrics::{HitTest, ScrollMetrics, SUBCELL};
20use crate::ScrollLengths;
21
22impl ScrollBar {
23 pub fn handle_event(
54 &self,
55 area: Rect,
56 event: ScrollEvent,
57 interaction: &mut ScrollBarInteraction,
58 ) -> Option<ScrollCommand> {
59 if area.width == 0 || area.height == 0 {
60 return None;
61 }
62
63 let layout = self.arrow_layout(area);
64 let lengths = ScrollLengths {
65 content_len: self.content_len,
66 viewport_len: self.viewport_len,
67 };
68 let track_cells = match self.orientation {
69 ScrollBarOrientation::Vertical => layout.track_area.height,
70 ScrollBarOrientation::Horizontal => layout.track_area.width,
71 };
72 let metrics = ScrollMetrics::new(lengths, self.offset, track_cells);
73
74 match event {
75 ScrollEvent::Pointer(event) => {
76 if let Some(command) =
77 self.handle_arrow_pointer(&layout, metrics, event, interaction)
78 {
79 return Some(command);
80 }
81 self.handle_pointer_event(layout.track_area, metrics, event, interaction)
82 }
83 ScrollEvent::ScrollWheel(event) => self.handle_scroll_wheel(area, metrics, event),
84 }
85 }
86
87 #[cfg(feature = "crossterm")]
88 pub fn handle_mouse_event(
93 &self,
94 area: Rect,
95 event: MouseEvent,
96 interaction: &mut ScrollBarInteraction,
97 ) -> Option<ScrollCommand> {
98 let event = match event.kind {
99 MouseEventKind::Down(MouseButton::Left) => Some(ScrollEvent::Pointer(PointerEvent {
100 column: event.column,
101 row: event.row,
102 kind: PointerEventKind::Down,
103 button: PointerButton::Primary,
104 })),
105 MouseEventKind::Up(MouseButton::Left) => Some(ScrollEvent::Pointer(PointerEvent {
106 column: event.column,
107 row: event.row,
108 kind: PointerEventKind::Up,
109 button: PointerButton::Primary,
110 })),
111 MouseEventKind::Drag(MouseButton::Left) => Some(ScrollEvent::Pointer(PointerEvent {
112 column: event.column,
113 row: event.row,
114 kind: PointerEventKind::Drag,
115 button: PointerButton::Primary,
116 })),
117 MouseEventKind::ScrollUp => Some(ScrollEvent::ScrollWheel(ScrollWheel {
118 axis: ScrollAxis::Vertical,
119 delta: -1,
120 column: event.column,
121 row: event.row,
122 })),
123 MouseEventKind::ScrollDown => Some(ScrollEvent::ScrollWheel(ScrollWheel {
124 axis: ScrollAxis::Vertical,
125 delta: 1,
126 column: event.column,
127 row: event.row,
128 })),
129 MouseEventKind::ScrollLeft => Some(ScrollEvent::ScrollWheel(ScrollWheel {
130 axis: ScrollAxis::Horizontal,
131 delta: -1,
132 column: event.column,
133 row: event.row,
134 })),
135 MouseEventKind::ScrollRight => Some(ScrollEvent::ScrollWheel(ScrollWheel {
136 axis: ScrollAxis::Horizontal,
137 delta: 1,
138 column: event.column,
139 row: event.row,
140 })),
141 _ => None,
142 };
143
144 event.and_then(|event| self.handle_event(area, event, interaction))
145 }
146
147 fn handle_pointer_event(
149 &self,
150 area: Rect,
151 metrics: ScrollMetrics,
152 event: PointerEvent,
153 interaction: &mut ScrollBarInteraction,
154 ) -> Option<ScrollCommand> {
155 if event.button != PointerButton::Primary {
156 return None;
157 }
158
159 match event.kind {
160 PointerEventKind::Down => {
161 let cell_index = axis_cell_index(area, event.column, event.row, self.orientation)?;
162 let position = cell_index
163 .saturating_mul(SUBCELL)
164 .saturating_add(SUBCELL / 2);
165 if metrics.thumb_len() == 0 {
166 return None;
167 }
168 match metrics.hit_test(position) {
169 HitTest::Thumb => {
170 let grab_offset = position.saturating_sub(metrics.thumb_start());
171 interaction.start_drag(grab_offset);
172 None
173 }
174 HitTest::Track => {
175 interaction.stop_drag();
176 self.handle_track_click(metrics, position)
177 }
178 }
179 }
180 PointerEventKind::Drag => match interaction.drag_state {
181 DragState::Idle => None,
182 DragState::Dragging { grab_offset } => {
183 let cell_index =
184 axis_cell_index_clamped(area, event.column, event.row, self.orientation)?;
185 let position = cell_index
186 .saturating_mul(SUBCELL)
187 .saturating_add(SUBCELL / 2);
188 let thumb_start = position.saturating_sub(grab_offset);
189 Some(ScrollCommand::SetOffset(
190 metrics.offset_for_thumb_start(thumb_start),
191 ))
192 }
193 },
194 PointerEventKind::Up => {
195 interaction.stop_drag();
196 None
197 }
198 }
199 }
200
201 fn handle_track_click(&self, metrics: ScrollMetrics, position: usize) -> Option<ScrollCommand> {
203 if metrics.max_offset() == 0 {
204 return None;
205 }
206
207 match self.track_click_behavior {
208 TrackClickBehavior::Page => {
209 let thumb_end = metrics.thumb_start().saturating_add(metrics.thumb_len());
210 if position < metrics.thumb_start() {
211 Some(ScrollCommand::SetOffset(
212 metrics.offset().saturating_sub(metrics.viewport_len()),
213 ))
214 } else if position >= thumb_end {
215 Some(ScrollCommand::SetOffset(
216 (metrics.offset() + metrics.viewport_len()).min(metrics.max_offset()),
217 ))
218 } else {
219 None
220 }
221 }
222 TrackClickBehavior::JumpToClick => {
223 let half_thumb = metrics.thumb_len() / 2;
224 let thumb_start = position.saturating_sub(half_thumb);
225 Some(ScrollCommand::SetOffset(
226 metrics.offset_for_thumb_start(thumb_start),
227 ))
228 }
229 }
230 }
231
232 fn handle_scroll_wheel(
234 &self,
235 _area: Rect,
236 metrics: ScrollMetrics,
237 event: ScrollWheel,
238 ) -> Option<ScrollCommand> {
239 let matches_axis = matches!(
240 (self.orientation, event.axis),
241 (ScrollBarOrientation::Vertical, ScrollAxis::Vertical)
242 | (ScrollBarOrientation::Horizontal, ScrollAxis::Horizontal)
243 );
244
245 if !matches_axis {
246 return None;
247 }
248
249 let step = self.scroll_step.max(1) as isize;
250 let delta = event.delta.saturating_mul(step);
251 let max_offset = metrics.max_offset() as isize;
252 let next = (metrics.offset() as isize).saturating_add(delta);
253 let next = next.clamp(0, max_offset);
254 Some(ScrollCommand::SetOffset(next as usize))
255 }
256
257 fn handle_arrow_pointer(
259 &self,
260 layout: &ArrowLayout,
261 metrics: ScrollMetrics,
262 event: PointerEvent,
263 interaction: &mut ScrollBarInteraction,
264 ) -> Option<ScrollCommand> {
265 if event.button != PointerButton::Primary || event.kind != PointerEventKind::Down {
266 return None;
267 }
268
269 let hit = self.arrow_hit(layout, event)?;
270 if metrics.max_offset() == 0 {
271 return None;
272 }
273
274 interaction.stop_drag();
275 let step = self.scroll_step.max(1) as isize;
276 let delta = match hit {
277 ArrowHit::Start => -step,
278 ArrowHit::End => step,
279 };
280 let max_offset = metrics.max_offset() as isize;
281 let next = (metrics.offset() as isize).saturating_add(delta);
282 let next = next.clamp(0, max_offset);
283 Some(ScrollCommand::SetOffset(next as usize))
284 }
285
286 fn arrow_hit(&self, layout: &ArrowLayout, event: PointerEvent) -> Option<ArrowHit> {
288 if let Some((x, y)) = layout.start {
289 if event.column == x && event.row == y {
290 return Some(ArrowHit::Start);
291 }
292 }
293 if let Some((x, y)) = layout.end {
294 if event.column == x && event.row == y {
295 return Some(ArrowHit::End);
296 }
297 }
298 None
299 }
300}
301
302fn axis_cell_index(
304 area: Rect,
305 column: u16,
306 row: u16,
307 orientation: ScrollBarOrientation,
308) -> Option<usize> {
309 match orientation {
310 ScrollBarOrientation::Vertical => {
311 if row < area.y || row >= area.y.saturating_add(area.height) {
312 None
313 } else {
314 Some(row.saturating_sub(area.y) as usize)
315 }
316 }
317 ScrollBarOrientation::Horizontal => {
318 if column < area.x || column >= area.x.saturating_add(area.width) {
319 None
320 } else {
321 Some(column.saturating_sub(area.x) as usize)
322 }
323 }
324 }
325}
326
327fn axis_cell_index_clamped(
329 area: Rect,
330 column: u16,
331 row: u16,
332 orientation: ScrollBarOrientation,
333) -> Option<usize> {
334 match orientation {
335 ScrollBarOrientation::Vertical => {
336 if area.height == 0 {
337 return None;
338 }
339 let end = area.y.saturating_add(area.height).saturating_sub(1);
340 let row = row.clamp(area.y, end);
341 Some(row.saturating_sub(area.y) as usize)
342 }
343 ScrollBarOrientation::Horizontal => {
344 if area.width == 0 {
345 return None;
346 }
347 let end = area.x.saturating_add(area.width).saturating_sub(1);
348 let column = column.clamp(area.x, end);
349 Some(column.saturating_sub(area.x) as usize)
350 }
351 }
352}
353
354#[cfg(test)]
355mod tests {
356 use ratatui_core::layout::Rect;
357
358 use super::*;
359 use crate::{ScrollBarArrows, ScrollLengths};
360
361 #[test]
362 fn pages_when_clicking_track() {
363 let lengths = ScrollLengths {
364 content_len: 100,
365 viewport_len: 20,
366 };
367 let scrollbar = ScrollBar::vertical(lengths)
368 .arrows(ScrollBarArrows::None)
369 .offset(40);
370 let area = Rect::new(0, 0, 1, 10);
371 let event = ScrollEvent::Pointer(PointerEvent {
372 column: 0,
373 row: 0,
374 kind: PointerEventKind::Down,
375 button: PointerButton::Primary,
376 });
377 let expected = 20;
378 let mut interaction = ScrollBarInteraction::default();
379 assert_eq!(
380 scrollbar.handle_event(area, event, &mut interaction),
381 Some(ScrollCommand::SetOffset(expected))
382 );
383 }
384
385 #[test]
386 fn updates_offset_while_dragging() {
387 let lengths = ScrollLengths {
388 content_len: 16,
389 viewport_len: 8,
390 };
391 let scrollbar = ScrollBar::vertical(lengths)
392 .arrows(ScrollBarArrows::None)
393 .offset(0);
394 let area = Rect::new(0, 0, 1, 4);
395 let mut interaction = ScrollBarInteraction::default();
396 let down = ScrollEvent::Pointer(PointerEvent {
397 column: 0,
398 row: 0,
399 kind: PointerEventKind::Down,
400 button: PointerButton::Primary,
401 });
402 assert_eq!(scrollbar.handle_event(area, down, &mut interaction), None);
403
404 let drag = ScrollEvent::Pointer(PointerEvent {
405 column: 0,
406 row: 1,
407 kind: PointerEventKind::Drag,
408 button: PointerButton::Primary,
409 });
410 assert_eq!(
411 scrollbar.handle_event(area, drag, &mut interaction),
412 Some(ScrollCommand::SetOffset(4))
413 );
414 }
415
416 #[test]
417 fn applies_scroll_step_to_wheel() {
418 let lengths = ScrollLengths {
419 content_len: 100,
420 viewport_len: 20,
421 };
422 let scrollbar = ScrollBar::vertical(lengths)
423 .arrows(ScrollBarArrows::None)
424 .offset(40)
425 .scroll_step(3);
426 let area = Rect::new(0, 0, 1, 10);
427 let mut interaction = ScrollBarInteraction::default();
428 let event = ScrollEvent::ScrollWheel(ScrollWheel {
429 axis: ScrollAxis::Vertical,
430 delta: 1,
431 column: 0,
432 row: 0,
433 });
434 assert_eq!(
435 scrollbar.handle_event(area, event, &mut interaction),
436 Some(ScrollCommand::SetOffset(43))
437 );
438 }
439
440 #[test]
441 fn steps_offset_when_clicking_arrows() {
442 let lengths = ScrollLengths {
443 content_len: 100,
444 viewport_len: 20,
445 };
446 let scrollbar = ScrollBar::vertical(lengths).offset(10).scroll_step(5);
447 let area = Rect::new(0, 0, 1, 5);
448 let mut interaction = ScrollBarInteraction::default();
449 let up = ScrollEvent::Pointer(PointerEvent {
450 column: 0,
451 row: 0,
452 kind: PointerEventKind::Down,
453 button: PointerButton::Primary,
454 });
455 assert_eq!(
456 scrollbar.handle_event(area, up, &mut interaction),
457 Some(ScrollCommand::SetOffset(5))
458 );
459
460 let down = ScrollEvent::Pointer(PointerEvent {
461 column: 0,
462 row: 4,
463 kind: PointerEventKind::Down,
464 button: PointerButton::Primary,
465 });
466 assert_eq!(
467 scrollbar.handle_event(area, down, &mut interaction),
468 Some(ScrollCommand::SetOffset(15))
469 );
470 }
471
472 #[test]
473 fn ignores_scroll_wheel_on_other_axis() {
474 let lengths = ScrollLengths {
475 content_len: 100,
476 viewport_len: 20,
477 };
478 let scrollbar = ScrollBar::vertical(lengths);
479 let area = Rect::new(0, 0, 1, 5);
480 let mut interaction = ScrollBarInteraction::default();
481 let event = ScrollEvent::ScrollWheel(ScrollWheel {
482 axis: ScrollAxis::Horizontal,
483 delta: 1,
484 column: 0,
485 row: 2,
486 });
487 assert_eq!(scrollbar.handle_event(area, event, &mut interaction), None);
488 }
489
490 #[test]
491 fn applies_negative_scroll_wheel_delta() {
492 let lengths = ScrollLengths {
493 content_len: 100,
494 viewport_len: 20,
495 };
496 let scrollbar = ScrollBar::vertical(lengths).offset(10).scroll_step(2);
497 let area = Rect::new(0, 0, 1, 5);
498 let event = ScrollEvent::ScrollWheel(ScrollWheel {
499 axis: ScrollAxis::Vertical,
500 delta: -1,
501 column: 0,
502 row: 2,
503 });
504 let mut interaction = ScrollBarInteraction::default();
505 assert_eq!(
506 scrollbar.handle_event(area, event, &mut interaction),
507 Some(ScrollCommand::SetOffset(8))
508 );
509 }
510
511 #[test]
512 fn jumps_toward_track_click() {
513 let lengths = ScrollLengths {
514 content_len: 8,
515 viewport_len: 4,
516 };
517 let scrollbar = ScrollBar::vertical(lengths)
518 .arrows(ScrollBarArrows::None)
519 .track_click_behavior(TrackClickBehavior::JumpToClick);
520 let area = Rect::new(0, 0, 1, 4);
521 let event = ScrollEvent::Pointer(PointerEvent {
522 column: 0,
523 row: 2,
524 kind: PointerEventKind::Down,
525 button: PointerButton::Primary,
526 });
527 let expected = 3;
528 let mut interaction = ScrollBarInteraction::default();
529 assert_eq!(
530 scrollbar.handle_event(area, event, &mut interaction),
531 Some(ScrollCommand::SetOffset(expected))
532 );
533 }
534
535 #[test]
536 fn clears_drag_on_pointer_up() {
537 let lengths = ScrollLengths {
538 content_len: 100,
539 viewport_len: 20,
540 };
541 let scrollbar = ScrollBar::vertical(lengths);
542 let area = Rect::new(0, 0, 1, 5);
543 let mut interaction = ScrollBarInteraction::default();
544 interaction.start_drag(3);
545 let event = ScrollEvent::Pointer(PointerEvent {
546 column: 0,
547 row: 1,
548 kind: PointerEventKind::Up,
549 button: PointerButton::Primary,
550 });
551 assert_eq!(scrollbar.handle_event(area, event, &mut interaction), None);
552 assert_eq!(interaction.drag_state, DragState::Idle);
553 }
554
555 #[test]
556 fn ignores_pointer_events_outside_track() {
557 let lengths = ScrollLengths {
558 content_len: 100,
559 viewport_len: 20,
560 };
561 let scrollbar = ScrollBar::vertical(lengths);
562 let area = Rect::new(0, 0, 1, 5);
563 let event = ScrollEvent::Pointer(PointerEvent {
564 column: 0,
565 row: 6,
566 kind: PointerEventKind::Down,
567 button: PointerButton::Primary,
568 });
569 let mut interaction = ScrollBarInteraction::default();
570 assert_eq!(scrollbar.handle_event(area, event, &mut interaction), None);
571 }
572
573 #[test]
574 fn ignores_arrow_clicks_when_max_offset_zero() {
575 let lengths = ScrollLengths {
576 content_len: 10,
577 viewport_len: 10,
578 };
579 let scrollbar = ScrollBar::vertical(lengths).arrows(ScrollBarArrows::Both);
580 let area = Rect::new(0, 0, 1, 5);
581 let event = ScrollEvent::Pointer(PointerEvent {
582 column: 0,
583 row: 0,
584 kind: PointerEventKind::Down,
585 button: PointerButton::Primary,
586 });
587 let mut interaction = ScrollBarInteraction::default();
588 assert_eq!(scrollbar.handle_event(area, event, &mut interaction), None);
589 }
590
591 #[test]
592 fn stops_drag_on_track_click() {
593 let lengths = ScrollLengths {
594 content_len: 10,
595 viewport_len: 5,
596 };
597 let scrollbar = ScrollBar::vertical(lengths).arrows(ScrollBarArrows::None);
598 let area = Rect::new(0, 0, 1, 4);
599 let mut interaction = ScrollBarInteraction::default();
600 interaction.start_drag(2);
601 let event = ScrollEvent::Pointer(PointerEvent {
602 column: 0,
603 row: 3,
604 kind: PointerEventKind::Down,
605 button: PointerButton::Primary,
606 });
607 assert_eq!(
608 scrollbar.handle_event(area, event, &mut interaction),
609 Some(ScrollCommand::SetOffset(5))
610 );
611 assert_eq!(interaction.drag_state, DragState::Idle);
612 }
613
614 #[test]
615 fn returns_none_when_clicking_inside_thumb_in_page_mode() {
616 let lengths = ScrollLengths {
617 content_len: 100,
618 viewport_len: 20,
619 };
620 let scrollbar = ScrollBar::vertical(lengths).arrows(ScrollBarArrows::None);
621 let area = Rect::new(0, 0, 1, 10);
622 let mut interaction = ScrollBarInteraction::default();
623 let event = ScrollEvent::Pointer(PointerEvent {
624 column: 0,
625 row: 0,
626 kind: PointerEventKind::Down,
627 button: PointerButton::Primary,
628 });
629 assert_eq!(scrollbar.handle_event(area, event, &mut interaction), None);
630 }
631}