ratatui_toolkit/
clickable_scrollbar.rs1use 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#[derive(Debug, Default, Clone)]
21pub struct ClickableScrollbar<'a> {
22 orientation: ScrollbarOrientation,
23 scrollbar: Scrollbar<'a>,
24}
25
26#[derive(Debug, Clone)]
30pub struct ClickableScrollbarState {
31 pub area: Rect,
34
35 pub orientation: ScrollbarOrientation,
37
38 pub offset: usize,
40
41 pub page_len: usize,
43
44 pub max_offset: usize,
46
47 pub scroll_by: Option<usize>,
50
51 drag_active: bool,
53}
54
55#[derive(Debug, Clone, Copy, PartialEq, Eq)]
57pub enum ScrollbarEvent {
58 None,
60 Up(usize),
62 Down(usize),
64 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 pub fn vertical() -> Self {
78 Self::new(ScrollbarOrientation::VerticalRight)
79 }
80
81 pub fn horizontal() -> Self {
83 Self::new(ScrollbarOrientation::HorizontalBottom)
84 }
85
86 pub fn style(mut self, style: Style) -> Self {
88 self.scrollbar = self.scrollbar.style(style);
89 self
90 }
91
92 pub fn thumb_symbol(mut self, symbol: &'a str) -> Self {
94 self.scrollbar = self.scrollbar.thumb_symbol(symbol);
95 self
96 }
97
98 pub fn thumb_style(mut self, style: Style) -> Self {
100 self.scrollbar = self.scrollbar.thumb_style(style);
101 self
102 }
103
104 pub fn track_symbol(mut self, symbol: Option<&'a str>) -> Self {
106 self.scrollbar = self.scrollbar.track_symbol(symbol);
107 self
108 }
109
110 pub fn track_style(mut self, style: Style) -> Self {
112 self.scrollbar = self.scrollbar.track_style(style);
113 self
114 }
115
116 pub fn begin_symbol(mut self, symbol: Option<&'a str>) -> Self {
118 self.scrollbar = self.scrollbar.begin_symbol(symbol);
119 self
120 }
121
122 pub fn begin_style(mut self, style: Style) -> Self {
124 self.scrollbar = self.scrollbar.begin_style(style);
125 self
126 }
127
128 pub fn end_symbol(mut self, symbol: Option<&'a str>) -> Self {
130 self.scrollbar = self.scrollbar.end_symbol(symbol);
131 self
132 }
133
134 pub fn end_style(mut self, style: Style) -> Self {
136 self.scrollbar = self.scrollbar.end_style(style);
137 self
138 }
139
140 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 let mut scrollbar_state = ScrollbarState::new(state.max_offset)
160 .position(state.offset)
161 .viewport_content_length(state.page_len);
162
163 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 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 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 pub fn position(mut self, offset: usize) -> Self {
198 self.offset = offset.min(self.max_offset);
199 self
200 }
201
202 pub fn offset(&self) -> usize {
204 self.offset
205 }
206
207 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 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 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 pub fn scroll_increment(&self) -> usize {
231 self.scroll_by
232 .unwrap_or_else(|| (self.page_len / 10).max(1))
233 }
234
235 pub fn handle_mouse_event(&mut self, event: &MouseEvent) -> ScrollbarEvent {
238 let (col, row) = (event.column, event.row);
239
240 if !self.area.contains((col, row).into()) {
242 if self.drag_active {
244 self.drag_active = false;
245 }
246 return ScrollbarEvent::None;
247 }
248
249 match event.kind {
250 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 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 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 MouseEventKind::Up(MouseButton::Left) => {
281 self.drag_active = false;
282 ScrollbarEvent::None
283 }
284
285 _ => ScrollbarEvent::None,
286 }
287 }
288
289 fn map_position_to_offset(&self, col: u16, row: u16) -> usize {
291 if self.is_vertical() {
292 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 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 fn is_vertical(&self) -> bool {
316 matches!(
317 self.orientation,
318 ScrollbarOrientation::VerticalRight | ScrollbarOrientation::VerticalLeft
319 )
320 }
321}