Skip to main content

tui_scrollbar/scrollbar/
interaction.rs

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