Skip to main content

ratatui_toolkit/clickable_scrollbar/
mod.rs

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