Skip to main content

scrollbar/
scrollbar.rs

1//! Fractional scrollbar step showcase.
2//!
3//! Run with `cargo run -p tui-scrollbar --example scrollbar`.
4//!
5//! This example renders every 1/8th thumb step for both horizontal and vertical scrollbars so
6//! you can visually compare partial glyphs and track alignment.
7//!
8//! Press `q` or `Esc` to exit.
9//!
10//! ## Structure
11//!
12//! - Calculation helpers (`build_metrics`, `step_entry`) derive `ScrollMetrics` and offsets so the
13//!   demo can cover all 1/8th positions. These functions are not required for normal usage.
14//! - Rendering helpers (`render_horizontal_steps`, `render_vertical_steps`) instantiate
15//!   [`ScrollBar`] widgets and render them into each cell.
16//!
17//! ## Why this example exists
18//!
19//! Fractional glyphs can be hard to reason about without a full sweep. This example deliberately
20//! draws every 1/8th step from both ends so you can verify the glyph ordering and thumb symmetry.
21
22use color_eyre::Result;
23use ratatui::DefaultTerminal;
24use ratatui::crossterm::event::{self, KeyCode};
25use ratatui::layout::{Constraint, Layout, Rect};
26use ratatui::widgets::Paragraph;
27use tui_scrollbar::{SUBCELL, ScrollBar, ScrollBarArrows, ScrollLengths, ScrollMetrics};
28
29fn main() -> Result<()> {
30    color_eyre::install()?;
31    ratatui::run(|terminal| App::new().run(terminal))
32}
33
34#[derive(Debug, Default)]
35struct App {
36    /// Current run state for the render loop.
37    state: AppState,
38}
39
40#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
41enum AppState {
42    #[default]
43    /// Continue drawing frames and handling input.
44    Running,
45    /// Exit the demo on the next tick.
46    Quit,
47}
48
49impl App {
50    /// Creates the demo app in its initial running state.
51    const fn new() -> Self {
52        Self {
53            state: AppState::Running,
54        }
55    }
56
57    /// Runs the draw loop until a quit key is pressed.
58    fn run(&mut self, terminal: &mut DefaultTerminal) -> Result<()> {
59        while self.state == AppState::Running {
60            terminal.draw(|frame| {
61                render_scrollbars(frame.area(), frame);
62            })?;
63            self.handle_events()?;
64        }
65        Ok(())
66    }
67
68    /// Handles a single input event and updates the run state.
69    fn handle_events(&mut self) -> Result<()> {
70        if let Some(key) = event::read()?.as_key_press_event()
71            && matches!(key.code, KeyCode::Char('q') | KeyCode::Esc)
72        {
73            self.state = AppState::Quit;
74        }
75        Ok(())
76    }
77}
78
79/// Splits the area into horizontal and vertical showcases and renders all 1/8th steps.
80fn render_scrollbars(area: Rect, frame: &mut ratatui::Frame) {
81    if area.height < 8 {
82        return;
83    }
84
85    let title = "Fractional scrollbar steps (q/Esc to quit)";
86    frame.render_widget(Paragraph::new(title), area);
87
88    let content_area = Rect {
89        y: area.y.saturating_add(1),
90        height: area.height.saturating_sub(1),
91        ..area
92    };
93    if content_area.height == 0 {
94        return;
95    }
96
97    // Reserve enough width for 34 vertical bars (2 columns each) on the right.
98    let min_left_width = 12;
99    let max_right_width = 68;
100    let right_width = max_right_width.min(content_area.width.saturating_sub(min_left_width));
101    let [left_column, right_column] = content_area.layout(&Layout::horizontal([
102        Constraint::Fill(1),
103        Constraint::Length(right_width),
104    ]));
105
106    // Stack up to 34 horizontal bars top-to-bottom in the left column.
107    let max_rows = left_column.height as usize;
108    let row_count = 34.min(max_rows);
109    let left_cells =
110        left_column.layout_vec(&Layout::vertical(vec![Constraint::Length(1); row_count]));
111
112    // Arrange up to 34 vertical bars left-to-right in the right column.
113    let bar_width = if right_column.width >= 68 { 2 } else { 1 };
114    let max_cols = (right_column.width / bar_width) as usize;
115    let col_count = 34.min(max_cols);
116    let right_cells =
117        right_column.layout_vec(&Layout::horizontal(vec![
118            Constraint::Length(bar_width);
119            col_count
120        ]));
121
122    render_horizontal_steps(frame, left_cells);
123    render_vertical_steps(frame, right_cells);
124}
125
126/// Draws horizontal scrollbars that sweep every 1/8th thumb position, top to bottom.
127fn render_horizontal_steps(frame: &mut ratatui::Frame, cells: Vec<Rect>) {
128    for (index, area) in cells.iter().enumerate() {
129        let [label_area, bar_area] = area.layout(&Layout::horizontal([
130            Constraint::Length(2),
131            Constraint::Fill(1),
132        ]));
133        if bar_area.width == 0 {
134            continue;
135        }
136        let metrics = build_metrics(bar_area.width as usize, 6);
137        let (label, thumb_start) = step_entry(&metrics, index);
138        let label = (label % 8).to_string();
139        let offset = metrics.offset_for_thumb_start(thumb_start);
140        let lengths = ScrollLengths {
141            content_len: metrics.content_len(),
142            viewport_len: metrics.viewport_len(),
143        };
144        let scrollbar = ScrollBar::horizontal(lengths)
145            .arrows(ScrollBarArrows::Both)
146            .offset(offset);
147        render_label(frame, label_area, &label);
148        frame.render_widget(&scrollbar, bar_area);
149    }
150}
151
152/// Draws vertical scrollbars that sweep every 1/8th thumb position, left to right.
153fn render_vertical_steps(frame: &mut ratatui::Frame, cells: Vec<Rect>) {
154    for (index, area) in cells.iter().enumerate() {
155        let [label_area, bar_area] = area.layout(&Layout::vertical([
156            Constraint::Length(1),
157            Constraint::Fill(1),
158        ]));
159        if bar_area.height == 0 {
160            continue;
161        }
162        let metrics = build_metrics(bar_area.height as usize, 3);
163        let (label, thumb_start) = step_entry(&metrics, index);
164        let label = (label % 8).to_string();
165        let offset = metrics.offset_for_thumb_start(thumb_start);
166        let lengths = ScrollLengths {
167            content_len: metrics.content_len(),
168            viewport_len: metrics.viewport_len(),
169        };
170        let scrollbar = ScrollBar::vertical(lengths)
171            .arrows(ScrollBarArrows::Both)
172            .offset(offset);
173        render_label(frame, label_area, &label);
174        frame.render_widget(&scrollbar, bar_area);
175    }
176}
177
178/// Renders the small modulo-8 label that marks the fractional step.
179fn render_label(frame: &mut ratatui::Frame, area: Rect, label: &str) {
180    if area.width == 0 || area.height == 0 {
181        return;
182    }
183    frame.render_widget(Paragraph::new(label), area);
184}
185
186/// Builds metrics where the thumb occupies a fixed number of cells on the track.
187fn build_metrics(track_cells: usize, desired_thumb_cells: usize) -> ScrollMetrics {
188    let track_len = track_cells.saturating_mul(SUBCELL);
189    let viewport_len = track_len.max(1);
190    let desired_thumb_len = desired_thumb_cells.saturating_mul(SUBCELL).max(1);
191    let content_len =
192        ((track_len as u128) * (viewport_len as u128) / (desired_thumb_len as u128)) as usize;
193    let content_len = content_len.max(viewport_len.saturating_add(1));
194    ScrollMetrics::new(
195        ScrollLengths {
196            content_len,
197            viewport_len,
198        },
199        0,
200        track_cells as u16,
201    )
202}
203
204/// Returns the label index and thumb start for the 0..=16 or trailing sweep.
205fn step_entry(metrics: &ScrollMetrics, index: usize) -> (usize, usize) {
206    let max_start = metrics.thumb_travel();
207    let local = index % 17;
208    if index < 17 {
209        (local, local.min(max_start))
210    } else {
211        let base = max_start.saturating_sub(16);
212        (local, base.saturating_add(local).min(max_start))
213    }
214}