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};
13use crate::ScrollLengths;
14#[cfg(any(feature = "crossterm_0_28", feature = "crossterm_0_29"))]
15use crate::crossterm::event::{MouseButton, MouseEvent, MouseEventKind};
16use crate::input::{
17    DragState, PointerButton, PointerEvent, PointerEventKind, ScrollAxis, ScrollBarInteraction,
18    ScrollCommand, ScrollEvent, ScrollWheel,
19};
20use crate::metrics::{HitTest, SUBCELL, ScrollMetrics};
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            && event.column == x
292            && event.row == y
293        {
294            return Some(ArrowHit::Start);
295        }
296        if let Some((x, y)) = layout.end
297            && event.column == x
298            && event.row == y
299        {
300            return Some(ArrowHit::End);
301        }
302        None
303    }
304}
305
306/// Returns the cell index along the scroll axis for a pointer location.
307fn axis_cell_index(
308    area: Rect,
309    column: u16,
310    row: u16,
311    orientation: ScrollBarOrientation,
312) -> Option<usize> {
313    match orientation {
314        ScrollBarOrientation::Vertical => {
315            if row < area.y || row >= area.y.saturating_add(area.height) {
316                None
317            } else {
318                Some(row.saturating_sub(area.y) as usize)
319            }
320        }
321        ScrollBarOrientation::Horizontal => {
322            if column < area.x || column >= area.x.saturating_add(area.width) {
323                None
324            } else {
325                Some(column.saturating_sub(area.x) as usize)
326            }
327        }
328    }
329}
330
331/// Returns a clamped cell index along the scroll axis for drag updates.
332fn axis_cell_index_clamped(
333    area: Rect,
334    column: u16,
335    row: u16,
336    orientation: ScrollBarOrientation,
337) -> Option<usize> {
338    match orientation {
339        ScrollBarOrientation::Vertical => {
340            if area.height == 0 {
341                return None;
342            }
343            let end = area.y.saturating_add(area.height).saturating_sub(1);
344            let row = row.clamp(area.y, end);
345            Some(row.saturating_sub(area.y) as usize)
346        }
347        ScrollBarOrientation::Horizontal => {
348            if area.width == 0 {
349                return None;
350            }
351            let end = area.x.saturating_add(area.width).saturating_sub(1);
352            let column = column.clamp(area.x, end);
353            Some(column.saturating_sub(area.x) as usize)
354        }
355    }
356}
357
358#[cfg(test)]
359mod tests {
360    use ratatui_core::layout::Rect;
361
362    use super::*;
363    use crate::{ScrollBarArrows, ScrollLengths};
364
365    #[test]
366    fn pages_when_clicking_track() {
367        let lengths = ScrollLengths {
368            content_len: 100,
369            viewport_len: 20,
370        };
371        let scrollbar = ScrollBar::vertical(lengths)
372            .arrows(ScrollBarArrows::None)
373            .offset(40);
374        let area = Rect::new(0, 0, 1, 10);
375        let event = ScrollEvent::Pointer(PointerEvent {
376            column: 0,
377            row: 0,
378            kind: PointerEventKind::Down,
379            button: PointerButton::Primary,
380        });
381        let expected = 20;
382        let mut interaction = ScrollBarInteraction::default();
383        assert_eq!(
384            scrollbar.handle_event(area, event, &mut interaction),
385            Some(ScrollCommand::SetOffset(expected))
386        );
387    }
388
389    #[test]
390    fn updates_offset_while_dragging() {
391        let lengths = ScrollLengths {
392            content_len: 16,
393            viewport_len: 8,
394        };
395        let scrollbar = ScrollBar::vertical(lengths)
396            .arrows(ScrollBarArrows::None)
397            .offset(0);
398        let area = Rect::new(0, 0, 1, 4);
399        let mut interaction = ScrollBarInteraction::default();
400        let down = ScrollEvent::Pointer(PointerEvent {
401            column: 0,
402            row: 0,
403            kind: PointerEventKind::Down,
404            button: PointerButton::Primary,
405        });
406        assert_eq!(scrollbar.handle_event(area, down, &mut interaction), None);
407
408        let drag = ScrollEvent::Pointer(PointerEvent {
409            column: 0,
410            row: 1,
411            kind: PointerEventKind::Drag,
412            button: PointerButton::Primary,
413        });
414        assert_eq!(
415            scrollbar.handle_event(area, drag, &mut interaction),
416            Some(ScrollCommand::SetOffset(4))
417        );
418    }
419
420    #[test]
421    fn applies_scroll_step_to_wheel() {
422        let lengths = ScrollLengths {
423            content_len: 100,
424            viewport_len: 20,
425        };
426        let scrollbar = ScrollBar::vertical(lengths)
427            .arrows(ScrollBarArrows::None)
428            .offset(40)
429            .scroll_step(3);
430        let area = Rect::new(0, 0, 1, 10);
431        let mut interaction = ScrollBarInteraction::default();
432        let event = ScrollEvent::ScrollWheel(ScrollWheel {
433            axis: ScrollAxis::Vertical,
434            delta: 1,
435            column: 0,
436            row: 0,
437        });
438        assert_eq!(
439            scrollbar.handle_event(area, event, &mut interaction),
440            Some(ScrollCommand::SetOffset(43))
441        );
442    }
443
444    #[test]
445    fn steps_offset_when_clicking_arrows() {
446        let lengths = ScrollLengths {
447            content_len: 100,
448            viewport_len: 20,
449        };
450        let scrollbar = ScrollBar::vertical(lengths)
451            .arrows(ScrollBarArrows::Both)
452            .offset(10)
453            .scroll_step(5);
454        let area = Rect::new(0, 0, 1, 5);
455        let mut interaction = ScrollBarInteraction::default();
456        let up = ScrollEvent::Pointer(PointerEvent {
457            column: 0,
458            row: 0,
459            kind: PointerEventKind::Down,
460            button: PointerButton::Primary,
461        });
462        assert_eq!(
463            scrollbar.handle_event(area, up, &mut interaction),
464            Some(ScrollCommand::SetOffset(5))
465        );
466
467        let down = ScrollEvent::Pointer(PointerEvent {
468            column: 0,
469            row: 4,
470            kind: PointerEventKind::Down,
471            button: PointerButton::Primary,
472        });
473        assert_eq!(
474            scrollbar.handle_event(area, down, &mut interaction),
475            Some(ScrollCommand::SetOffset(15))
476        );
477    }
478
479    #[test]
480    fn ignores_scroll_wheel_on_other_axis() {
481        let lengths = ScrollLengths {
482            content_len: 100,
483            viewport_len: 20,
484        };
485        let scrollbar = ScrollBar::vertical(lengths);
486        let area = Rect::new(0, 0, 1, 5);
487        let mut interaction = ScrollBarInteraction::default();
488        let event = ScrollEvent::ScrollWheel(ScrollWheel {
489            axis: ScrollAxis::Horizontal,
490            delta: 1,
491            column: 0,
492            row: 2,
493        });
494        assert_eq!(scrollbar.handle_event(area, event, &mut interaction), None);
495    }
496
497    #[test]
498    fn applies_negative_scroll_wheel_delta() {
499        let lengths = ScrollLengths {
500            content_len: 100,
501            viewport_len: 20,
502        };
503        let scrollbar = ScrollBar::vertical(lengths).offset(10).scroll_step(2);
504        let area = Rect::new(0, 0, 1, 5);
505        let event = ScrollEvent::ScrollWheel(ScrollWheel {
506            axis: ScrollAxis::Vertical,
507            delta: -1,
508            column: 0,
509            row: 2,
510        });
511        let mut interaction = ScrollBarInteraction::default();
512        assert_eq!(
513            scrollbar.handle_event(area, event, &mut interaction),
514            Some(ScrollCommand::SetOffset(8))
515        );
516    }
517
518    #[test]
519    fn jumps_toward_track_click() {
520        let lengths = ScrollLengths {
521            content_len: 8,
522            viewport_len: 4,
523        };
524        let scrollbar = ScrollBar::vertical(lengths)
525            .arrows(ScrollBarArrows::None)
526            .track_click_behavior(TrackClickBehavior::JumpToClick);
527        let area = Rect::new(0, 0, 1, 4);
528        let event = ScrollEvent::Pointer(PointerEvent {
529            column: 0,
530            row: 2,
531            kind: PointerEventKind::Down,
532            button: PointerButton::Primary,
533        });
534        let expected = 3;
535        let mut interaction = ScrollBarInteraction::default();
536        assert_eq!(
537            scrollbar.handle_event(area, event, &mut interaction),
538            Some(ScrollCommand::SetOffset(expected))
539        );
540    }
541
542    #[test]
543    fn clears_drag_on_pointer_up() {
544        let lengths = ScrollLengths {
545            content_len: 100,
546            viewport_len: 20,
547        };
548        let scrollbar = ScrollBar::vertical(lengths);
549        let area = Rect::new(0, 0, 1, 5);
550        let mut interaction = ScrollBarInteraction::default();
551        interaction.start_drag(3);
552        let event = ScrollEvent::Pointer(PointerEvent {
553            column: 0,
554            row: 1,
555            kind: PointerEventKind::Up,
556            button: PointerButton::Primary,
557        });
558        assert_eq!(scrollbar.handle_event(area, event, &mut interaction), None);
559        assert_eq!(interaction.drag_state, DragState::Idle);
560    }
561
562    #[test]
563    fn ignores_pointer_events_outside_track() {
564        let lengths = ScrollLengths {
565            content_len: 100,
566            viewport_len: 20,
567        };
568        let scrollbar = ScrollBar::vertical(lengths);
569        let area = Rect::new(0, 0, 1, 5);
570        let event = ScrollEvent::Pointer(PointerEvent {
571            column: 0,
572            row: 6,
573            kind: PointerEventKind::Down,
574            button: PointerButton::Primary,
575        });
576        let mut interaction = ScrollBarInteraction::default();
577        assert_eq!(scrollbar.handle_event(area, event, &mut interaction), None);
578    }
579
580    #[test]
581    fn ignores_arrow_clicks_when_max_offset_zero() {
582        let lengths = ScrollLengths {
583            content_len: 10,
584            viewport_len: 10,
585        };
586        let scrollbar = ScrollBar::vertical(lengths).arrows(ScrollBarArrows::Both);
587        let area = Rect::new(0, 0, 1, 5);
588        let event = ScrollEvent::Pointer(PointerEvent {
589            column: 0,
590            row: 0,
591            kind: PointerEventKind::Down,
592            button: PointerButton::Primary,
593        });
594        let mut interaction = ScrollBarInteraction::default();
595        assert_eq!(scrollbar.handle_event(area, event, &mut interaction), None);
596    }
597
598    #[test]
599    fn stops_drag_on_track_click() {
600        let lengths = ScrollLengths {
601            content_len: 10,
602            viewport_len: 5,
603        };
604        let scrollbar = ScrollBar::vertical(lengths).arrows(ScrollBarArrows::None);
605        let area = Rect::new(0, 0, 1, 4);
606        let mut interaction = ScrollBarInteraction::default();
607        interaction.start_drag(2);
608        let event = ScrollEvent::Pointer(PointerEvent {
609            column: 0,
610            row: 3,
611            kind: PointerEventKind::Down,
612            button: PointerButton::Primary,
613        });
614        assert_eq!(
615            scrollbar.handle_event(area, event, &mut interaction),
616            Some(ScrollCommand::SetOffset(5))
617        );
618        assert_eq!(interaction.drag_state, DragState::Idle);
619    }
620
621    #[test]
622    fn returns_none_when_clicking_inside_thumb_in_page_mode() {
623        let lengths = ScrollLengths {
624            content_len: 100,
625            viewport_len: 20,
626        };
627        let scrollbar = ScrollBar::vertical(lengths).arrows(ScrollBarArrows::None);
628        let area = Rect::new(0, 0, 1, 10);
629        let mut interaction = ScrollBarInteraction::default();
630        let event = ScrollEvent::Pointer(PointerEvent {
631            column: 0,
632            row: 0,
633            kind: PointerEventKind::Down,
634            button: PointerButton::Primary,
635        });
636        assert_eq!(scrollbar.handle_event(area, event, &mut interaction), None);
637    }
638}