Skip to main content

scrollbar_mouse/
scrollbar_mouse.rs

1//! Mouse + keyboard-driven scrollbar demo with smooth subcell movement.
2//!
3//! If you are new to this crate, this is the fastest way to see the full interaction model:
4//! a horizontal scrollbar on the bottom edge and a vertical scrollbar on the right edge, both
5//! wired to the same input flow your app would use. The example keeps the scroll offsets in app
6//! state, renders the scrollbars each frame, and applies the commands returned by
7//! [`ScrollBar::handle_mouse_event`].
8//!
9//! ## Why this example exists
10//!
11//! The demo uses subcell units so the thumb glides smoothly even when you step with the keyboard.
12//! It also shows how to keep scroll offsets clamped when the terminal resizes.
13//!
14//! ## Implementation choices
15//!
16//! - The app owns the offsets and clamps them after each resize or input event.
17//! - `ScrollMetrics` is used to derive the `ScrollLengths` and max offsets for each axis.
18//! - `handle_mouse_event` stays thin by building scrollbars from the latest metrics, mirroring how
19//!   an app would typically handle input per frame.
20//!
21//! ## Controls
22//!
23//! - Arrow keys: move the scrollbars in subcell steps.
24//! - Mouse wheel: scroll the matching axis.
25//! - Click + drag: grab the thumb and drag.
26//! - `q` or `Esc`: exit the demo.
27
28use std::io;
29
30use color_eyre::Result;
31use ratatui::crossterm::event::{self, Event, KeyCode, KeyEventKind};
32use ratatui::crossterm::execute;
33use ratatui::layout::{Constraint, Layout, Rect};
34use ratatui::style::{Color, Style, Stylize};
35use ratatui::text::Line;
36use ratatui::widgets::{Block, Borders, Paragraph};
37use ratatui::DefaultTerminal;
38use tui_scrollbar::{
39    ScrollBar, ScrollBarArrows, ScrollBarInteraction, ScrollCommand, ScrollLengths, ScrollMetrics,
40    SUBCELL,
41};
42
43const KEY_STEP: usize = 1;
44const TITLE_FG: Color = Color::Rgb(196, 206, 224);
45const TITLE_BG: Color = Color::Rgb(32, 43, 64);
46const BLOCK_FG: Color = Color::Rgb(196, 206, 224);
47const BLOCK_BG: Color = Color::Rgb(13, 23, 38);
48const SCROLLBAR_TRACK_BG: Color = Color::Rgb(40, 40, 40);
49const SCROLLBAR_THUMB_BG: Color = SCROLLBAR_TRACK_BG;
50const SCROLLBAR_THUMB_FG: Color = Color::Rgb(224, 224, 224);
51const SCROLLBAR_ARROW_FG: Color = Color::Rgb(224, 224, 224);
52
53fn main() -> Result<()> {
54    color_eyre::install()?;
55    let mut terminal = ratatui::init();
56    execute!(io::stdout(), event::EnableMouseCapture)?;
57    let result = App::new().run(&mut terminal);
58    execute!(io::stdout(), event::DisableMouseCapture)?;
59    ratatui::restore();
60    result
61}
62
63#[derive(Debug, Default)]
64struct App {
65    /// Current run state, toggled to `Quit` when the user exits.
66    state: AppState,
67    /// Latest layout rectangles used to place the scrollbars.
68    layout: Option<LayoutState>,
69    /// Vertical scroll offset in logical units (subcells for this demo).
70    vertical_offset: usize,
71    /// Horizontal scroll offset in logical units (subcells for this demo).
72    horizontal_offset: usize,
73    /// Drag state for the vertical scrollbar.
74    vertical_interaction: ScrollBarInteraction,
75    /// Drag state for the horizontal scrollbar.
76    horizontal_interaction: ScrollBarInteraction,
77}
78
79#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
80enum AppState {
81    #[default]
82    /// Keep running the event loop and rendering frames.
83    Running,
84    /// Exit the application on the next loop iteration.
85    Quit,
86}
87
88#[derive(Debug, Clone, Copy)]
89struct LayoutState {
90    /// The content area used to compute scroll metrics.
91    content: Rect,
92    /// Rect for the vertical scrollbar (right edge).
93    vertical_bar: Rect,
94    /// Rect for the horizontal scrollbar (bottom edge).
95    horizontal_bar: Rect,
96}
97
98impl App {
99    /// Builds a fresh app state with zero offsets.
100    fn new() -> Self {
101        Self {
102            state: AppState::Running,
103            layout: None,
104            vertical_offset: 0,
105            horizontal_offset: 0,
106            vertical_interaction: ScrollBarInteraction::new(),
107            horizontal_interaction: ScrollBarInteraction::new(),
108        }
109    }
110
111    /// Runs the event loop until the user exits.
112    fn run(&mut self, terminal: &mut DefaultTerminal) -> Result<()> {
113        while self.state == AppState::Running {
114            terminal.draw(|frame| self.render(frame))?;
115            self.handle_events()?;
116        }
117        Ok(())
118    }
119
120    /// Renders the title block and the two scrollbars.
121    fn render(&mut self, frame: &mut ratatui::Frame) {
122        let area = frame.area();
123        if area.width < 2 || area.height < 2 {
124            return;
125        }
126
127        let title = "tui-scrollbar - mouse scroll demo";
128        let block = Block::new()
129            .borders(Borders::TOP)
130            .border_style(Style::new().fg(TITLE_FG).bg(TITLE_BG))
131            .style(Style::new().fg(BLOCK_FG).bg(BLOCK_BG))
132            .title(
133                Line::from(title)
134                    .centered()
135                    .fg(TITLE_FG)
136                    .bg(TITLE_BG)
137                    .bold(),
138            );
139        frame.render_widget(&block, area);
140
141        let content_area = Rect {
142            y: area.y.saturating_add(1),
143            height: area.height.saturating_sub(1),
144            ..area
145        };
146        let help = "Arrows: move | Wheel: scroll | Drag: thumb | q/Esc: quit";
147        let help_area = Rect {
148            x: content_area.x.saturating_add(1),
149            y: content_area.y,
150            width: content_area.width.saturating_sub(1),
151            height: 1,
152        };
153        if help_area.width > 0 {
154            frame.render_widget(
155                Paragraph::new(help).style(Style::new().fg(TITLE_FG)),
156                help_area,
157            );
158        }
159        let content_area = Rect {
160            y: content_area.y.saturating_add(1),
161            height: content_area.height.saturating_sub(1),
162            ..content_area
163        };
164
165        // Split out the bottom row and right column for the scrollbars.
166        let [content_row, bar_row] = content_area.layout(&Layout::vertical([
167            Constraint::Fill(1),
168            Constraint::Length(1),
169        ]));
170        let [content, vertical_bar] = content_row.layout(&Layout::horizontal([
171            Constraint::Fill(1),
172            Constraint::Length(1),
173        ]));
174        let [horizontal_bar, _corner] = bar_row.layout(&Layout::horizontal([
175            Constraint::Fill(1),
176            Constraint::Length(1),
177        ]));
178
179        self.layout = Some(LayoutState {
180            content,
181            vertical_bar,
182            horizontal_bar,
183        });
184
185        // Keep offsets valid when the terminal is resized.
186        let (h_metrics, v_metrics) = self.metrics_for_layout(content);
187        self.horizontal_offset = self.horizontal_offset.min(h_metrics.max_offset());
188        self.vertical_offset = self.vertical_offset.min(v_metrics.max_offset());
189
190        let horizontal_lengths = ScrollLengths {
191            content_len: h_metrics.content_len(),
192            viewport_len: h_metrics.viewport_len(),
193        };
194        let track_style = Style::new().bg(SCROLLBAR_TRACK_BG);
195        let thumb_style = Style::new().fg(SCROLLBAR_THUMB_FG).bg(SCROLLBAR_THUMB_BG);
196        let arrow_style = Style::new().fg(SCROLLBAR_ARROW_FG).bg(SCROLLBAR_TRACK_BG);
197        let horizontal = ScrollBar::horizontal(horizontal_lengths)
198            .arrows(ScrollBarArrows::Both)
199            .offset(self.horizontal_offset)
200            .scroll_step(SUBCELL)
201            .track_style(track_style)
202            .thumb_style(thumb_style)
203            .arrow_style(arrow_style);
204        let vertical_lengths = ScrollLengths {
205            content_len: v_metrics.content_len(),
206            viewport_len: v_metrics.viewport_len(),
207        };
208        let vertical = ScrollBar::vertical(vertical_lengths)
209            .arrows(ScrollBarArrows::Both)
210            .offset(self.vertical_offset)
211            .scroll_step(SUBCELL)
212            .track_style(track_style)
213            .thumb_style(thumb_style)
214            .arrow_style(arrow_style);
215
216        frame.render_widget(&horizontal, horizontal_bar);
217        frame.render_widget(&vertical, vertical_bar);
218    }
219
220    /// Handles keyboard and mouse events, updating offsets as needed.
221    fn handle_events(&mut self) -> Result<()> {
222        match event::read()? {
223            Event::Key(key) => {
224                if key.kind == KeyEventKind::Press {
225                    match key.code {
226                        KeyCode::Char('q') | KeyCode::Esc => self.state = AppState::Quit,
227                        KeyCode::Up => self.handle_key_scroll(0, -(KEY_STEP as isize)),
228                        KeyCode::Down => self.handle_key_scroll(0, KEY_STEP as isize),
229                        KeyCode::Left => self.handle_key_scroll(-(KEY_STEP as isize), 0),
230                        KeyCode::Right => self.handle_key_scroll(KEY_STEP as isize, 0),
231                        _ => {}
232                    }
233                }
234            }
235            Event::Mouse(event) => {
236                self.handle_mouse_event(event);
237            }
238            _ => {}
239        }
240        Ok(())
241    }
242
243    /// Applies a keyboard delta to the scrollbar offsets.
244    fn handle_key_scroll(&mut self, dx: isize, dy: isize) {
245        let Some(layout) = self.layout else {
246            return;
247        };
248        let (h_metrics, v_metrics) = self.metrics_for_layout(layout.content);
249        self.horizontal_offset =
250            Self::apply_delta(self.horizontal_offset, dx, h_metrics.max_offset());
251        self.vertical_offset = Self::apply_delta(self.vertical_offset, dy, v_metrics.max_offset());
252    }
253
254    /// Handles crossterm mouse events using the scrollbar helpers.
255    fn handle_mouse_event(&mut self, event: event::MouseEvent) {
256        let Some(layout) = self.layout else {
257            return;
258        };
259        let (h_metrics, v_metrics) = self.metrics_for_layout(layout.content);
260        let horizontal = self.horizontal_scrollbar(h_metrics);
261        let vertical = self.vertical_scrollbar(v_metrics);
262
263        if let Some(command) = horizontal.handle_mouse_event(
264            layout.horizontal_bar,
265            event,
266            &mut self.horizontal_interaction,
267        ) {
268            self.apply_command(command, true);
269        }
270        if let Some(command) =
271            vertical.handle_mouse_event(layout.vertical_bar, event, &mut self.vertical_interaction)
272        {
273            self.apply_command(command, false);
274        }
275    }
276
277    /// Applies a scroll command to the current axis offset.
278    fn apply_command(&mut self, command: ScrollCommand, is_horizontal: bool) {
279        let ScrollCommand::SetOffset(offset) = command;
280        if is_horizontal {
281            self.horizontal_offset = offset;
282        } else {
283            self.vertical_offset = offset;
284        }
285    }
286
287    /// Builds a horizontal scrollbar from the current metrics.
288    fn horizontal_scrollbar(&self, metrics: ScrollMetrics) -> ScrollBar {
289        let lengths = ScrollLengths {
290            content_len: metrics.content_len(),
291            viewport_len: metrics.viewport_len(),
292        };
293        ScrollBar::horizontal(lengths)
294            .arrows(ScrollBarArrows::Both)
295            .offset(self.horizontal_offset)
296            .scroll_step(SUBCELL)
297    }
298
299    /// Builds a vertical scrollbar from the current metrics.
300    fn vertical_scrollbar(&self, metrics: ScrollMetrics) -> ScrollBar {
301        let lengths = ScrollLengths {
302            content_len: metrics.content_len(),
303            viewport_len: metrics.viewport_len(),
304        };
305        ScrollBar::vertical(lengths)
306            .arrows(ScrollBarArrows::Both)
307            .offset(self.vertical_offset)
308            .scroll_step(SUBCELL)
309    }
310
311    /// Derives metrics based on the current layout and desired scroll range.
312    fn metrics_for_layout(&self, content: Rect) -> (ScrollMetrics, ScrollMetrics) {
313        // Use subcell units so wheel/drag updates line up with the fractional renderer.
314        let h_cells = content.width.max(1) as usize;
315        let v_cells = content.height.max(1) as usize;
316        let h_content = h_cells.saturating_mul(SUBCELL).max(1);
317        let v_content = v_cells.saturating_mul(SUBCELL).max(1);
318        let h_viewport = h_content.saturating_sub(100).max(1);
319        let v_viewport = v_content.saturating_sub(100).max(1);
320        (
321            ScrollMetrics::new(
322                ScrollLengths {
323                    content_len: h_content,
324                    viewport_len: h_viewport,
325                },
326                self.horizontal_offset,
327                content.width,
328            ),
329            ScrollMetrics::new(
330                ScrollLengths {
331                    content_len: v_content,
332                    viewport_len: v_viewport,
333                },
334                self.vertical_offset,
335                content.height,
336            ),
337        )
338    }
339
340    /// Adds a signed delta while clamping to the provided maximum.
341    fn apply_delta(current: usize, delta: isize, max: usize) -> usize {
342        if delta < 0 {
343            current.saturating_sub(delta.unsigned_abs())
344        } else {
345            current.saturating_add(delta as usize).min(max)
346        }
347    }
348}