Skip to main content

scrollbar/
scrollbar.rs

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