Skip to main content

ratatui_toolkit/
clickable_scrollbar.rs

1//! Clickable scrollbar with mouse support adapted from rat-salsa's rat-scrolled
2//!
3//! This is a simplified version that provides:
4//! - Click-to-jump functionality
5//! - Drag scrollbar thumb
6//! - Mouse wheel support
7//!
8//! Unlike ratatui's basic Scrollbar, this one handles mouse events.
9
10use crossterm::event::{MouseButton, MouseEvent, MouseEventKind};
11use ratatui::buffer::Buffer;
12use ratatui::layout::Rect;
13use ratatui::style::Style;
14use ratatui::symbols;
15use ratatui::widgets::{Scrollbar, ScrollbarOrientation, ScrollbarState, StatefulWidget};
16
17/// Clickable scrollbar widget.
18///
19/// This wraps ratatui's Scrollbar but adds mouse interaction support.
20#[derive(Debug, Default, Clone)]
21pub struct ClickableScrollbar<'a> {
22    orientation: ScrollbarOrientation,
23    scrollbar: Scrollbar<'a>,
24}
25
26/// State for the clickable scrollbar.
27///
28/// This manages the scrolling position and handles mouse interactions.
29#[derive(Debug, Clone)]
30pub struct ClickableScrollbarState {
31    /// Area where the scrollbar is rendered.
32    /// Updated automatically during rendering.
33    pub area: Rect,
34
35    /// Orientation of the scrollbar.
36    pub orientation: ScrollbarOrientation,
37
38    /// Current scroll offset (position in content).
39    pub offset: usize,
40
41    /// Length of visible content area.
42    pub page_len: usize,
43
44    /// Maximum scroll offset (content_length - page_len).
45    pub max_offset: usize,
46
47    /// How many lines/columns to scroll per wheel event.
48    /// Defaults to 1/10 of page_len.
49    pub scroll_by: Option<usize>,
50
51    /// Track if mouse drag is active.
52    drag_active: bool,
53}
54
55/// Result of handling mouse events on the scrollbar.
56#[derive(Debug, Clone, Copy, PartialEq, Eq)]
57pub enum ScrollbarEvent {
58    /// No event or event not handled.
59    None,
60    /// Scroll up by N units.
61    Up(usize),
62    /// Scroll down by N units.
63    Down(usize),
64    /// Jump to absolute position.
65    Position(usize),
66}
67
68impl<'a> ClickableScrollbar<'a> {
69    pub fn new(orientation: ScrollbarOrientation) -> Self {
70        Self {
71            orientation: orientation.clone(),
72            scrollbar: Scrollbar::new(orientation),
73        }
74    }
75
76    /// Create a vertical scrollbar on the right side.
77    pub fn vertical() -> Self {
78        Self::new(ScrollbarOrientation::VerticalRight)
79    }
80
81    /// Create a horizontal scrollbar on the bottom.
82    pub fn horizontal() -> Self {
83        Self::new(ScrollbarOrientation::HorizontalBottom)
84    }
85
86    /// Set the scrollbar style.
87    pub fn style(mut self, style: Style) -> Self {
88        self.scrollbar = self.scrollbar.style(style);
89        self
90    }
91
92    /// Set the thumb symbol.
93    pub fn thumb_symbol(mut self, symbol: &'a str) -> Self {
94        self.scrollbar = self.scrollbar.thumb_symbol(symbol);
95        self
96    }
97
98    /// Set the thumb style.
99    pub fn thumb_style(mut self, style: Style) -> Self {
100        self.scrollbar = self.scrollbar.thumb_style(style);
101        self
102    }
103
104    /// Set the track symbol.
105    pub fn track_symbol(mut self, symbol: Option<&'a str>) -> Self {
106        self.scrollbar = self.scrollbar.track_symbol(symbol);
107        self
108    }
109
110    /// Set the track style.
111    pub fn track_style(mut self, style: Style) -> Self {
112        self.scrollbar = self.scrollbar.track_style(style);
113        self
114    }
115
116    /// Set the begin symbol.
117    pub fn begin_symbol(mut self, symbol: Option<&'a str>) -> Self {
118        self.scrollbar = self.scrollbar.begin_symbol(symbol);
119        self
120    }
121
122    /// Set the begin style.
123    pub fn begin_style(mut self, style: Style) -> Self {
124        self.scrollbar = self.scrollbar.begin_style(style);
125        self
126    }
127
128    /// Set the end symbol.
129    pub fn end_symbol(mut self, symbol: Option<&'a str>) -> Self {
130        self.scrollbar = self.scrollbar.end_symbol(symbol);
131        self
132    }
133
134    /// Set the end style.
135    pub fn end_style(mut self, style: Style) -> Self {
136        self.scrollbar = self.scrollbar.end_style(style);
137        self
138    }
139
140    /// Set all symbols at once.
141    pub fn symbols(mut self, symbols: symbols::scrollbar::Set) -> Self {
142        self.scrollbar = self.scrollbar.symbols(symbols);
143        self
144    }
145}
146
147impl<'a> StatefulWidget for ClickableScrollbar<'a> {
148    type State = ClickableScrollbarState;
149
150    fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
151        state.area = area;
152        state.orientation = self.orientation;
153
154        if area.is_empty() {
155            return;
156        }
157
158        // Create ratatui ScrollbarState for rendering
159        let mut scrollbar_state = ScrollbarState::new(state.max_offset)
160            .position(state.offset)
161            .viewport_content_length(state.page_len);
162
163        // Render using ratatui's scrollbar
164        self.scrollbar.render(area, buf, &mut scrollbar_state);
165    }
166}
167
168impl Default for ClickableScrollbarState {
169    fn default() -> Self {
170        Self::new()
171    }
172}
173
174impl ClickableScrollbarState {
175    /// Create a new scrollbar state.
176    pub fn new() -> Self {
177        Self {
178            area: Rect::default(),
179            orientation: ScrollbarOrientation::VerticalRight,
180            offset: 0,
181            page_len: 0,
182            max_offset: 0,
183            scroll_by: None,
184            drag_active: false,
185        }
186    }
187
188    /// Set the content length and page length.
189    /// This will calculate max_offset automatically.
190    pub fn set_content(mut self, content_len: usize, page_len: usize) -> Self {
191        self.page_len = page_len;
192        self.max_offset = content_len.saturating_sub(page_len);
193        self
194    }
195
196    /// Set the position.
197    pub fn position(mut self, offset: usize) -> Self {
198        self.offset = offset.min(self.max_offset);
199        self
200    }
201
202    /// Get the current offset.
203    pub fn offset(&self) -> usize {
204        self.offset
205    }
206
207    /// Set the offset.
208    pub fn set_offset(&mut self, offset: usize) -> bool {
209        let old = self.offset;
210        self.offset = offset.min(self.max_offset);
211        old != self.offset
212    }
213
214    /// Scroll up by N units.
215    pub fn scroll_up(&mut self, n: usize) -> bool {
216        let old = self.offset;
217        self.offset = self.offset.saturating_sub(n);
218        old != self.offset
219    }
220
221    /// Scroll down by N units.
222    pub fn scroll_down(&mut self, n: usize) -> bool {
223        let old = self.offset;
224        self.offset = (self.offset + n).min(self.max_offset);
225        old != self.offset
226    }
227
228    /// Get the scroll increment for wheel events.
229    /// Defaults to 1/10 of page_len.
230    pub fn scroll_increment(&self) -> usize {
231        self.scroll_by
232            .unwrap_or_else(|| (self.page_len / 10).max(1))
233    }
234
235    /// Handle a mouse event.
236    /// Returns Some(ScrollbarEvent) if the event was handled.
237    pub fn handle_mouse_event(&mut self, event: &MouseEvent) -> ScrollbarEvent {
238        let (col, row) = (event.column, event.row);
239
240        // Check if event is within scrollbar area
241        if !self.area.contains((col, row).into()) {
242            // Release drag if we're outside the area
243            if self.drag_active {
244                self.drag_active = false;
245            }
246            return ScrollbarEvent::None;
247        }
248
249        match event.kind {
250            // Mouse wheel scrolling
251            MouseEventKind::ScrollDown => {
252                if self.is_vertical() {
253                    ScrollbarEvent::Down(self.scroll_increment())
254                } else {
255                    ScrollbarEvent::None
256                }
257            }
258            MouseEventKind::ScrollUp => {
259                if self.is_vertical() {
260                    ScrollbarEvent::Up(self.scroll_increment())
261                } else {
262                    ScrollbarEvent::None
263                }
264            }
265
266            // Click to jump to position
267            MouseEventKind::Down(MouseButton::Left) => {
268                self.drag_active = true;
269                let pos = self.map_position_to_offset(col, row);
270                ScrollbarEvent::Position(pos)
271            }
272
273            // Drag scrollbar thumb
274            MouseEventKind::Drag(MouseButton::Left) if self.drag_active => {
275                let pos = self.map_position_to_offset(col, row);
276                ScrollbarEvent::Position(pos)
277            }
278
279            // Release drag
280            MouseEventKind::Up(MouseButton::Left) => {
281                self.drag_active = false;
282                ScrollbarEvent::None
283            }
284
285            _ => ScrollbarEvent::None,
286        }
287    }
288
289    /// Map a mouse position to a scroll offset.
290    fn map_position_to_offset(&self, col: u16, row: u16) -> usize {
291        if self.is_vertical() {
292            // Vertical scrollbar
293            let pos = row.saturating_sub(self.area.y).saturating_sub(1) as usize;
294            let span = self.area.height.saturating_sub(2) as usize;
295
296            if span > 0 {
297                (self.max_offset * pos) / span
298            } else {
299                0
300            }
301        } else {
302            // Horizontal scrollbar
303            let pos = col.saturating_sub(self.area.x).saturating_sub(1) as usize;
304            let span = self.area.width.saturating_sub(2) as usize;
305
306            if span > 0 {
307                (self.max_offset * pos) / span
308            } else {
309                0
310            }
311        }
312    }
313
314    /// Is this a vertical scrollbar?
315    fn is_vertical(&self) -> bool {
316        matches!(
317            self.orientation,
318            ScrollbarOrientation::VerticalRight | ScrollbarOrientation::VerticalLeft
319        )
320    }
321}