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
10#[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    /// Handles a backend-agnostic scrollbar event.
24    ///
25    /// Returns a [`ScrollCommand`] when the event should update the offset.
26    ///
27    /// Pointer events outside the track are ignored. Scroll wheel events are ignored unless the
28    /// axis matches the scrollbar orientation.
29    ///
30    /// ```rust
31    /// use ratatui_core::layout::Rect;
32    /// use tui_scrollbar::{
33    ///     PointerButton, PointerEvent, PointerEventKind, ScrollBar, ScrollBarInteraction,
34    ///     ScrollEvent, ScrollLengths,
35    /// };
36    ///
37    /// let area = Rect::new(0, 0, 1, 6);
38    /// let lengths = ScrollLengths {
39    ///     content_len: 120,
40    ///     viewport_len: 24,
41    /// };
42    /// let scrollbar = ScrollBar::vertical(lengths).offset(0);
43    /// let mut interaction = ScrollBarInteraction::new();
44    /// let event = ScrollEvent::Pointer(PointerEvent {
45    ///     column: 0,
46    ///     row: 2,
47    ///     kind: PointerEventKind::Down,
48    ///     button: PointerButton::Primary,
49    /// });
50    ///
51    /// let _ = scrollbar.handle_event(area, event, &mut interaction);
52    /// ```
53    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    /// Handles crossterm mouse events for this scrollbar.
89    ///
90    /// This helper converts crossterm events into [`ScrollEvent`] values before delegating to
91    /// [`Self::handle_event`].
92    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    /// Handles pointer down/drag/up events and converts them to scroll commands.
148    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    /// Converts a click on the track into a page or jump action.
202    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    /// Handles scroll wheel input, respecting axis and clamping to bounds.
233    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    /// Handles arrow clicks by stepping the offset in the requested direction.
258    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    /// Returns which arrow (if any) a pointer event hit.
287    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
302/// Returns the cell index along the scroll axis for a pointer location.
303fn 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
327/// Returns a clamped cell index along the scroll axis for drag updates.
328fn 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}