1use 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 state: AppState,
38}
39
40#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
41enum AppState {
42 #[default]
43 Running,
45 Quit,
47}
48
49impl App {
50 const fn new() -> Self {
52 Self {
53 state: AppState::Running,
54 }
55 }
56
57 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 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
79fn 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 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 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 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
126fn 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
152fn 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
178fn 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
186fn 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
204fn 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}