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(any(feature = "crossterm_0_28", feature = "crossterm_0_29"))]
11use crate::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(any(feature = "crossterm_0_28", feature = "crossterm_0_29"))]
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)
447            .arrows(ScrollBarArrows::Both)
448            .offset(10)
449            .scroll_step(5);
450        let area = Rect::new(0, 0, 1, 5);
451        let mut interaction = ScrollBarInteraction::default();
452        let up = ScrollEvent::Pointer(PointerEvent {
453            column: 0,
454            row: 0,
455            kind: PointerEventKind::Down,
456            button: PointerButton::Primary,
457        });
458        assert_eq!(
459            scrollbar.handle_event(area, up, &mut interaction),
460            Some(ScrollCommand::SetOffset(5))
461        );
462
463        let down = ScrollEvent::Pointer(PointerEvent {
464            column: 0,
465            row: 4,
466            kind: PointerEventKind::Down,
467            button: PointerButton::Primary,
468        });
469        assert_eq!(
470            scrollbar.handle_event(area, down, &mut interaction),
471            Some(ScrollCommand::SetOffset(15))
472        );
473    }
474
475    #[test]
476    fn ignores_scroll_wheel_on_other_axis() {
477        let lengths = ScrollLengths {
478            content_len: 100,
479            viewport_len: 20,
480        };
481        let scrollbar = ScrollBar::vertical(lengths);
482        let area = Rect::new(0, 0, 1, 5);
483        let mut interaction = ScrollBarInteraction::default();
484        let event = ScrollEvent::ScrollWheel(ScrollWheel {
485            axis: ScrollAxis::Horizontal,
486            delta: 1,
487            column: 0,
488            row: 2,
489        });
490        assert_eq!(scrollbar.handle_event(area, event, &mut interaction), None);
491    }
492
493    #[test]
494    fn applies_negative_scroll_wheel_delta() {
495        let lengths = ScrollLengths {
496            content_len: 100,
497            viewport_len: 20,
498        };
499        let scrollbar = ScrollBar::vertical(lengths).offset(10).scroll_step(2);
500        let area = Rect::new(0, 0, 1, 5);
501        let event = ScrollEvent::ScrollWheel(ScrollWheel {
502            axis: ScrollAxis::Vertical,
503            delta: -1,
504            column: 0,
505            row: 2,
506        });
507        let mut interaction = ScrollBarInteraction::default();
508        assert_eq!(
509            scrollbar.handle_event(area, event, &mut interaction),
510            Some(ScrollCommand::SetOffset(8))
511        );
512    }
513
514    #[test]
515    fn jumps_toward_track_click() {
516        let lengths = ScrollLengths {
517            content_len: 8,
518            viewport_len: 4,
519        };
520        let scrollbar = ScrollBar::vertical(lengths)
521            .arrows(ScrollBarArrows::None)
522            .track_click_behavior(TrackClickBehavior::JumpToClick);
523        let area = Rect::new(0, 0, 1, 4);
524        let event = ScrollEvent::Pointer(PointerEvent {
525            column: 0,
526            row: 2,
527            kind: PointerEventKind::Down,
528            button: PointerButton::Primary,
529        });
530        let expected = 3;
531        let mut interaction = ScrollBarInteraction::default();
532        assert_eq!(
533            scrollbar.handle_event(area, event, &mut interaction),
534            Some(ScrollCommand::SetOffset(expected))
535        );
536    }
537
538    #[test]
539    fn clears_drag_on_pointer_up() {
540        let lengths = ScrollLengths {
541            content_len: 100,
542            viewport_len: 20,
543        };
544        let scrollbar = ScrollBar::vertical(lengths);
545        let area = Rect::new(0, 0, 1, 5);
546        let mut interaction = ScrollBarInteraction::default();
547        interaction.start_drag(3);
548        let event = ScrollEvent::Pointer(PointerEvent {
549            column: 0,
550            row: 1,
551            kind: PointerEventKind::Up,
552            button: PointerButton::Primary,
553        });
554        assert_eq!(scrollbar.handle_event(area, event, &mut interaction), None);
555        assert_eq!(interaction.drag_state, DragState::Idle);
556    }
557
558    #[test]
559    fn ignores_pointer_events_outside_track() {
560        let lengths = ScrollLengths {
561            content_len: 100,
562            viewport_len: 20,
563        };
564        let scrollbar = ScrollBar::vertical(lengths);
565        let area = Rect::new(0, 0, 1, 5);
566        let event = ScrollEvent::Pointer(PointerEvent {
567            column: 0,
568            row: 6,
569            kind: PointerEventKind::Down,
570            button: PointerButton::Primary,
571        });
572        let mut interaction = ScrollBarInteraction::default();
573        assert_eq!(scrollbar.handle_event(area, event, &mut interaction), None);
574    }
575
576    #[test]
577    fn ignores_arrow_clicks_when_max_offset_zero() {
578        let lengths = ScrollLengths {
579            content_len: 10,
580            viewport_len: 10,
581        };
582        let scrollbar = ScrollBar::vertical(lengths).arrows(ScrollBarArrows::Both);
583        let area = Rect::new(0, 0, 1, 5);
584        let event = ScrollEvent::Pointer(PointerEvent {
585            column: 0,
586            row: 0,
587            kind: PointerEventKind::Down,
588            button: PointerButton::Primary,
589        });
590        let mut interaction = ScrollBarInteraction::default();
591        assert_eq!(scrollbar.handle_event(area, event, &mut interaction), None);
592    }
593
594    #[test]
595    fn stops_drag_on_track_click() {
596        let lengths = ScrollLengths {
597            content_len: 10,
598            viewport_len: 5,
599        };
600        let scrollbar = ScrollBar::vertical(lengths).arrows(ScrollBarArrows::None);
601        let area = Rect::new(0, 0, 1, 4);
602        let mut interaction = ScrollBarInteraction::default();
603        interaction.start_drag(2);
604        let event = ScrollEvent::Pointer(PointerEvent {
605            column: 0,
606            row: 3,
607            kind: PointerEventKind::Down,
608            button: PointerButton::Primary,
609        });
610        assert_eq!(
611            scrollbar.handle_event(area, event, &mut interaction),
612            Some(ScrollCommand::SetOffset(5))
613        );
614        assert_eq!(interaction.drag_state, DragState::Idle);
615    }
616
617    #[test]
618    fn returns_none_when_clicking_inside_thumb_in_page_mode() {
619        let lengths = ScrollLengths {
620            content_len: 100,
621            viewport_len: 20,
622        };
623        let scrollbar = ScrollBar::vertical(lengths).arrows(ScrollBarArrows::None);
624        let area = Rect::new(0, 0, 1, 10);
625        let mut interaction = ScrollBarInteraction::default();
626        let event = ScrollEvent::Pointer(PointerEvent {
627            column: 0,
628            row: 0,
629            kind: PointerEventKind::Down,
630            button: PointerButton::Primary,
631        });
632        assert_eq!(scrollbar.handle_event(area, event, &mut interaction), None);
633    }
634}