use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::{Color, Style};
use ratatui::text::{Line, Span};
use unicode_width::UnicodeWidthStr;
#[derive(Debug, Clone)]
pub struct ScrollMetrics {
pub content_length: usize,
pub viewport_length: usize,
pub position: usize,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ThumbPosition {
pub start_cell: u16,
pub start_eighth: u8,
pub end_cell: u16,
pub end_eighth: u8,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct OverflowCounts {
pub above: usize,
pub below: usize,
}
#[must_use]
#[allow(
clippy::cast_possible_truncation,
reason = "track coordinates are always small"
)]
pub fn compute_thumb(metrics: &ScrollMetrics, track_height: u16) -> Option<ThumbPosition> {
if metrics.content_length <= metrics.viewport_length || track_height == 0 {
return None;
}
let track_eighths = u64::from(track_height) * 8;
let thumb_eighths = ((metrics.viewport_length as u64) * track_eighths
/ (metrics.content_length as u64))
.max(8) .min(track_eighths);
let scrollable = metrics.content_length - metrics.viewport_length;
let available = track_eighths - thumb_eighths;
let start_eighth_abs = if scrollable == 0 {
0
} else {
(metrics.position as u64) * available / (scrollable as u64)
};
let end_eighth_abs = start_eighth_abs + thumb_eighths - 1;
Some(ThumbPosition {
start_cell: (start_eighth_abs / 8) as u16,
start_eighth: (start_eighth_abs % 8) as u8,
end_cell: (end_eighth_abs / 8) as u16,
end_eighth: (end_eighth_abs % 8) as u8,
})
}
#[must_use]
pub const fn fractional_block_lower(eighths: u8) -> char {
match eighths {
1 => '▁',
2 => '▂',
3 => '▃',
4 => '▄',
5 => '▅',
6 => '▆',
7 => '▇',
8 => '█',
_ => ' ',
}
}
#[must_use]
pub const fn fractional_block_upper(eighths: u8) -> (char, bool) {
if eighths >= 8 {
('█', false)
} else if eighths == 0 {
(' ', false)
} else {
(fractional_block_lower(8 - eighths), true)
}
}
#[must_use]
pub const fn compute_overflow(metrics: &ScrollMetrics) -> OverflowCounts {
OverflowCounts {
above: metrics.position,
below: metrics
.content_length
.saturating_sub(metrics.position + metrics.viewport_length),
}
}
#[must_use]
#[allow(
clippy::cast_possible_truncation,
reason = "track coordinates are always small"
)]
pub fn scroll_position_from_click(y: u16, track_area: Rect, metrics: &ScrollMetrics) -> usize {
if metrics.content_length <= metrics.viewport_length {
return 0;
}
let track_height = track_area.height;
if track_height == 0 {
return 0;
}
let click_offset = y
.saturating_sub(track_area.y)
.min(track_height.saturating_sub(1));
let scrollable = metrics.content_length - metrics.viewport_length;
let position = u64::from(click_offset) * (scrollable as u64)
/ u64::from(track_height.saturating_sub(1).max(1));
(position as usize).min(scrollable)
}
#[allow(
clippy::cast_possible_truncation,
reason = "terminal coordinates are always small"
)]
pub fn render_scrollbar(
metrics: &ScrollMetrics,
track_area: Rect,
buf: &mut Buffer,
thumb_color: Color,
track_color: Color,
) {
let Some(thumb) = compute_thumb(metrics, track_area.height) else {
return;
};
for row in 0..track_area.height {
let x = track_area.x;
let y = track_area.y + row;
if y >= buf.area.y + buf.area.height || x >= buf.area.x + buf.area.width {
continue;
}
let cell = &mut buf[(x, y)];
if row < thumb.start_cell || row > thumb.end_cell {
cell.set_char(' ');
cell.set_style(Style::default().bg(track_color));
} else if row == thumb.start_cell && row == thumb.end_cell {
let top_skip = thumb.start_eighth;
let bottom_fill = thumb.end_eighth + 1;
let fill = bottom_fill.saturating_sub(top_skip);
if top_skip == 0 {
cell.set_char(fractional_block_lower(fill));
cell.set_style(Style::default().fg(thumb_color).bg(track_color));
} else {
let (ch, swap) = fractional_block_upper(fill);
cell.set_char(ch);
cell.set_style(if swap {
Style::default().fg(track_color).bg(thumb_color)
} else {
Style::default().fg(thumb_color).bg(track_color)
});
}
} else if row == thumb.start_cell {
let fill = 8 - thumb.start_eighth;
cell.set_char(fractional_block_lower(fill));
cell.set_style(Style::default().fg(thumb_color).bg(track_color));
} else if row == thumb.end_cell {
let fill = thumb.end_eighth + 1;
let (ch, swap) = fractional_block_upper(fill);
cell.set_char(ch);
cell.set_style(if swap {
Style::default().fg(track_color).bg(thumb_color)
} else {
Style::default().fg(thumb_color).bg(track_color)
});
} else {
cell.set_char('█');
cell.set_style(Style::default().fg(thumb_color).bg(track_color));
}
}
}
#[allow(
clippy::cast_possible_truncation,
reason = "terminal coordinates are always small"
)]
pub fn render_overflow_counts(
counts: &OverflowCounts,
content_area: Rect,
buf: &mut Buffer,
style: Style,
) {
if content_area.width == 0 || content_area.height == 0 {
return;
}
if counts.above > 0 {
let text = format!(" {}▲", counts.above);
let text_width = UnicodeWidthStr::width(text.as_str()) as u16;
if text_width <= content_area.width {
let x = content_area.x + content_area.width - text_width;
let y = content_area.y;
let line = Line::from(Span::styled(text, style));
buf.set_line(x, y, &line, text_width);
}
}
if counts.below > 0 {
let text = format!(" {}▼", counts.below);
let text_width = UnicodeWidthStr::width(text.as_str()) as u16;
if text_width <= content_area.width {
let x = content_area.x + content_area.width - text_width;
let y = content_area.y + content_area.height - 1;
let line = Line::from(Span::styled(text, style));
buf.set_line(x, y, &line, text_width);
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OverflowHit {
Top,
Bottom,
}
#[must_use]
#[allow(
clippy::cast_possible_truncation,
reason = "terminal coordinates are always small"
)]
pub fn overflow_hit_test(
x: u16,
y: u16,
content_area: Rect,
counts: &OverflowCounts,
) -> Option<OverflowHit> {
if content_area.width == 0 || content_area.height == 0 {
return None;
}
let right = content_area.x + content_area.width;
if counts.above > 0 && y == content_area.y {
let label = format!("{}▲", counts.above);
let label_width = UnicodeWidthStr::width(label.as_str()) as u16;
if label_width <= content_area.width && x >= right - label_width && x < right {
return Some(OverflowHit::Top);
}
}
let bottom_y = content_area.y + content_area.height - 1;
if counts.below > 0 && y == bottom_y {
let label = format!("{}▼", counts.below);
let label_width = UnicodeWidthStr::width(label.as_str()) as u16;
if label_width <= content_area.width && x >= right - label_width && x < right {
return Some(OverflowHit::Bottom);
}
}
None
}
#[cfg(test)]
#[allow(
clippy::expect_used,
reason = "tests use expect for readable assertions"
)]
mod tests {
use super::*;
use ratatui::Terminal;
use ratatui::backend::TestBackend;
#[test]
fn test_no_scrollbar_when_fits() {
let metrics = ScrollMetrics {
content_length: 10,
viewport_length: 20,
position: 0,
};
assert!(
compute_thumb(&metrics, 20).is_none(),
"should return None when content fits in viewport"
);
}
#[test]
fn test_thumb_proportional_size() {
let metrics = ScrollMetrics {
content_length: 100,
viewport_length: 20,
position: 0,
};
let thumb = compute_thumb(&metrics, 20).expect("should produce a thumb");
let start_abs = u64::from(thumb.start_cell) * 8 + u64::from(thumb.start_eighth);
let end_abs = u64::from(thumb.end_cell) * 8 + u64::from(thumb.end_eighth);
let size = end_abs - start_abs + 1;
assert_eq!(size, 32, "thumb should be 32 eighths (4 cells)");
}
#[test]
fn test_thumb_minimum_size() {
let metrics = ScrollMetrics {
content_length: 10000,
viewport_length: 1,
position: 0,
};
let thumb = compute_thumb(&metrics, 20).expect("should produce a thumb");
let start_abs = u64::from(thumb.start_cell) * 8 + u64::from(thumb.start_eighth);
let end_abs = u64::from(thumb.end_cell) * 8 + u64::from(thumb.end_eighth);
let size = end_abs - start_abs + 1;
assert_eq!(size, 8, "thumb should be clamped to minimum 8 eighths");
}
#[test]
fn test_thumb_position_top() {
let metrics = ScrollMetrics {
content_length: 100,
viewport_length: 20,
position: 0,
};
let thumb = compute_thumb(&metrics, 20).expect("should produce a thumb");
assert_eq!(thumb.start_cell, 0, "thumb should start at cell 0");
assert_eq!(thumb.start_eighth, 0, "thumb should start at eighth 0");
}
#[test]
fn test_thumb_position_bottom() {
let metrics = ScrollMetrics {
content_length: 100,
viewport_length: 20,
position: 80, };
let thumb = compute_thumb(&metrics, 20).expect("should produce a thumb");
assert_eq!(
thumb.end_cell, 19,
"thumb should end at last cell (track_height - 1)"
);
assert_eq!(thumb.end_eighth, 7, "thumb should end at eighth 7");
}
#[test]
fn test_thumb_position_middle() {
let metrics = ScrollMetrics {
content_length: 100,
viewport_length: 20,
position: 40, };
let thumb = compute_thumb(&metrics, 20).expect("should produce a thumb");
let start_abs = u64::from(thumb.start_cell) * 8 + u64::from(thumb.start_eighth);
let end_abs = u64::from(thumb.end_cell) * 8 + u64::from(thumb.end_eighth);
let center = u64::midpoint(start_abs, end_abs);
assert!(
(70..=90).contains(¢er),
"thumb center {center} should be roughly in the middle of the track"
);
}
#[test]
fn test_subchar_precision_160_positions() {
let content = 1000;
let viewport = 20;
let track_height = 20u16;
let max_pos = content - viewport;
let mut prev_abs = 0u64;
let mut positions = Vec::new();
for pos in 0..=max_pos {
let metrics = ScrollMetrics {
content_length: content,
viewport_length: viewport,
position: pos,
};
let thumb = compute_thumb(&metrics, track_height).expect("should produce a thumb");
let abs = u64::from(thumb.start_cell) * 8 + u64::from(thumb.start_eighth);
positions.push(abs);
if pos > 0 {
assert!(
abs >= prev_abs,
"position should be monotonically non-decreasing: pos={pos}, abs={abs}, prev={prev_abs}"
);
}
prev_abs = abs;
}
assert_eq!(positions[0], 0, "first position should start at 0");
let last = *positions.last().expect("non-empty");
let track_eighths = u64::from(track_height) * 8;
let thumb_eighths = ((viewport as u64) * track_eighths / (content as u64)).max(8);
let expected_last = track_eighths - thumb_eighths;
assert_eq!(
last, expected_last,
"last position should cover end of track"
);
}
#[test]
fn test_fractional_block_lower() {
assert_eq!(fractional_block_lower(0), ' ');
assert_eq!(fractional_block_lower(1), '▁');
assert_eq!(fractional_block_lower(4), '▄');
assert_eq!(fractional_block_lower(8), '█');
}
#[test]
fn test_overflow_counts_none() {
let metrics = ScrollMetrics {
content_length: 10,
viewport_length: 20,
position: 0,
};
let counts = compute_overflow(&metrics);
assert_eq!(counts.above, 0);
assert_eq!(counts.below, 0);
}
#[test]
fn test_overflow_counts_scrolled_middle() {
let metrics = ScrollMetrics {
content_length: 100,
viewport_length: 20,
position: 40,
};
let counts = compute_overflow(&metrics);
assert_eq!(counts.above, 40);
assert_eq!(counts.below, 40);
}
#[test]
fn test_overflow_counts_at_bottom() {
let metrics = ScrollMetrics {
content_length: 100,
viewport_length: 20,
position: 80,
};
let counts = compute_overflow(&metrics);
assert_eq!(counts.above, 80);
assert_eq!(counts.below, 0);
}
#[test]
fn test_render_scrollbar_basic() {
let metrics = ScrollMetrics {
content_length: 100,
viewport_length: 10,
position: 0,
};
let track_area = Rect::new(0, 0, 1, 10);
let backend = TestBackend::new(1, 10);
let mut terminal = Terminal::new(backend).expect("terminal creation");
terminal
.draw(|f| {
render_scrollbar(
&metrics,
track_area,
f.buffer_mut(),
Color::White,
Color::DarkGray,
);
})
.expect("draw");
let buf = terminal.backend().buffer().clone();
assert_eq!(buf[(0, 0)].symbol(), "█", "first cell should be thumb");
assert_eq!(
buf[(0, 5)].symbol(),
" ",
"cell below thumb should be empty track"
);
}
#[test]
fn test_render_overflow_counts() {
let counts = OverflowCounts {
above: 15,
below: 30,
};
let content_area = Rect::new(0, 0, 40, 10);
let style = Style::default().fg(Color::DarkGray);
let backend = TestBackend::new(40, 10);
let mut terminal = Terminal::new(backend).expect("terminal creation");
terminal
.draw(|f| {
render_overflow_counts(&counts, content_area, f.buffer_mut(), style);
})
.expect("draw");
let buf = terminal.backend().buffer().clone();
let mut top_right = String::new();
for x in 36..40 {
top_right.push_str(buf[(x, 0)].symbol());
}
assert!(
top_right.contains("15▲"),
"expected 15▲ in top-right, got: {top_right:?}"
);
let mut bottom_right = String::new();
for x in 36..40 {
bottom_right.push_str(buf[(x, 9)].symbol());
}
assert!(
bottom_right.contains("30▼"),
"expected 30▼ in bottom-right, got: {bottom_right:?}"
);
}
#[test]
fn test_scroll_position_from_click() {
let track_area = Rect::new(0, 0, 1, 20);
let metrics = ScrollMetrics {
content_length: 200,
viewport_length: 20,
position: 0,
};
let pos = scroll_position_from_click(10, track_area, &metrics);
assert!(
(85..=100).contains(&pos),
"expected position roughly 90, got {pos}"
);
let pos_top = scroll_position_from_click(0, track_area, &metrics);
assert_eq!(pos_top, 0, "click at top should give position 0");
let pos_bottom = scroll_position_from_click(19, track_area, &metrics);
assert_eq!(pos_bottom, 180, "click at bottom should give max position");
}
#[test]
fn test_overflow_hit_test_top() {
let content_area = Rect::new(0, 0, 40, 10);
let counts = OverflowCounts {
above: 15,
below: 5,
};
assert_eq!(
overflow_hit_test(37, 0, content_area, &counts),
Some(OverflowHit::Top)
);
assert_eq!(
overflow_hit_test(39, 0, content_area, &counts),
Some(OverflowHit::Top)
);
assert_eq!(overflow_hit_test(36, 0, content_area, &counts), None);
assert_eq!(overflow_hit_test(38, 1, content_area, &counts), None);
}
#[test]
fn test_overflow_hit_test_bottom() {
let content_area = Rect::new(0, 0, 40, 10);
let counts = OverflowCounts { above: 0, below: 5 };
assert_eq!(
overflow_hit_test(38, 9, content_area, &counts),
Some(OverflowHit::Bottom)
);
assert_eq!(
overflow_hit_test(39, 9, content_area, &counts),
Some(OverflowHit::Bottom)
);
assert_eq!(overflow_hit_test(37, 9, content_area, &counts), None);
}
#[test]
fn test_overflow_hit_test_no_overflow() {
let content_area = Rect::new(0, 0, 40, 10);
let counts = OverflowCounts { above: 0, below: 0 };
assert_eq!(overflow_hit_test(39, 0, content_area, &counts), None);
assert_eq!(overflow_hit_test(39, 9, content_area, &counts), None);
}
}