use crate::element::{BoxElement, Element, FlexDirection, TextElement};
use crate::style::{pad_visible, Color, Style};
pub struct Scrollbar {
total: usize,
visible: usize,
offset: usize,
track_char: char,
thumb_char: char,
track_color: Color,
thumb_color: Color,
hide_when_not_overflowing: bool,
}
impl Scrollbar {
pub fn new(total: usize, visible: usize, offset: usize) -> Self {
Self {
total,
visible,
offset,
track_char: '│',
thumb_char: '█',
track_color: Color::BrightBlack,
thumb_color: Color::White,
hide_when_not_overflowing: false,
}
}
pub fn track_char(mut self, ch: char) -> Self {
self.track_char = ch;
self
}
pub fn thumb_char(mut self, ch: char) -> Self {
self.thumb_char = ch;
self
}
pub fn track_color(mut self, color: Color) -> Self {
self.track_color = color;
self
}
pub fn thumb_color(mut self, color: Color) -> Self {
self.thumb_color = color;
self
}
pub fn hide_when_not_overflowing(mut self, enabled: bool) -> Self {
self.hide_when_not_overflowing = enabled;
self
}
pub fn has_overflow(&self) -> bool {
self.total > self.visible && self.visible > 0
}
fn thumb_range(&self, height: usize) -> (usize, usize) {
if self.total <= self.visible || height == 0 {
return (0, height);
}
let thumb_size = ((self.visible as f64 / self.total as f64) * height as f64)
.ceil()
.max(1.0) as usize;
let max_offset = self.total.saturating_sub(self.visible);
let thumb_pos = if max_offset == 0 {
0
} else {
((self.offset as f64 / max_offset as f64) * (height - thumb_size) as f64).round()
as usize
};
(thumb_pos, thumb_size)
}
pub fn element<Msg>(&self, height: usize) -> Element<Msg> {
let (thumb_pos, thumb_size) = self.thumb_range(height);
let children: Vec<Element<Msg>> = (0..height)
.map(|i| {
if i >= thumb_pos && i < thumb_pos + thumb_size {
Element::Text(
TextElement::new(self.thumb_char.to_string()).fg(self.thumb_color),
)
} else {
Element::Text(
TextElement::new(self.track_char.to_string()).fg(self.track_color),
)
}
})
.collect();
Element::Box(
BoxElement::new()
.direction(FlexDirection::Column)
.children(children),
)
}
pub fn view(&self, height: usize) -> String {
if self.hide_when_not_overflowing && !self.has_overflow() {
return " ".repeat(height);
}
let (thumb_pos, thumb_size) = self.thumb_range(height);
(0..height)
.map(|i| {
if i >= thumb_pos && i < thumb_pos + thumb_size {
self.thumb_char
} else {
self.track_char
}
})
.collect()
}
pub fn styled_view(&self, height: usize) -> String {
if self.hide_when_not_overflowing && !self.has_overflow() {
return (0..height)
.map(|_| " ".to_string())
.collect::<Vec<_>>()
.join("\n");
}
let (thumb_pos, thumb_size) = self.thumb_range(height);
(0..height)
.map(|i| {
if i >= thumb_pos && i < thumb_pos + thumb_size {
Style::new()
.fg(self.thumb_color)
.render(&self.thumb_char.to_string())
} else {
Style::new()
.fg(self.track_color)
.render(&self.track_char.to_string())
}
})
.collect::<Vec<_>>()
.join("\n")
}
pub fn append_to_view(&self, view: &str, inner_width: usize) -> String {
let rows = view.split('\n').collect::<Vec<_>>();
let bar = self.styled_view(rows.len());
rows.into_iter()
.zip(bar.lines())
.map(|(row, bar)| format!("{}{}", pad_visible(row, inner_width), bar))
.collect::<Vec<_>>()
.join("\n")
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn full_content_visible_fills_track() {
let sb = Scrollbar::new(10, 10, 0);
let (pos, size) = sb.thumb_range(10);
assert_eq!(pos, 0);
assert_eq!(size, 10);
}
#[test]
fn half_content_visible() {
let sb = Scrollbar::new(20, 10, 0);
let (pos, size) = sb.thumb_range(10);
assert_eq!(pos, 0);
assert_eq!(size, 5);
}
#[test]
fn scrolled_to_bottom() {
let sb = Scrollbar::new(20, 10, 10);
let (pos, size) = sb.thumb_range(10);
assert_eq!(size, 5);
assert_eq!(pos, 5);
}
#[test]
fn scrolled_to_middle() {
let sb = Scrollbar::new(20, 10, 5);
let (pos, size) = sb.thumb_range(10);
assert_eq!(size, 5);
assert!(pos > 0 && pos < 5);
}
#[test]
fn minimum_thumb_size_is_one() {
let sb = Scrollbar::new(1000, 1, 0);
let (_, size) = sb.thumb_range(10);
assert_eq!(size, 1);
}
#[test]
fn view_renders_correct_length() {
let sb = Scrollbar::new(20, 10, 0);
let view = sb.view(10);
assert_eq!(view.chars().count(), 10);
}
#[test]
fn hide_when_not_overflowing_renders_blank_gutter() {
let sb = Scrollbar::new(3, 5, 0).hide_when_not_overflowing(true);
assert_eq!(sb.view(5), " ");
assert_eq!(sb.styled_view(3), " \n \n ");
}
#[test]
fn styled_view_contains_ansi_for_track_and_thumb() {
let sb = Scrollbar::new(20, 5, 10)
.track_color(Color::BrightBlack)
.thumb_color(Color::Cyan);
let rendered = sb.styled_view(5);
assert!(rendered.contains("\x1b["));
assert_eq!(rendered.lines().count(), 5);
}
#[test]
fn append_to_view_pads_rows_and_adds_gutter() {
let view = "short\n中文";
let rendered = Scrollbar::new(20, 2, 3)
.thumb_color(Color::Cyan)
.append_to_view(view, 8);
let plain = crate::style::strip_ansi(&rendered);
let rows = plain.lines().collect::<Vec<_>>();
assert_eq!(rows.len(), 2);
assert!(rows[0].starts_with("short "));
assert!(rows[1].starts_with("中文 "));
assert!(rendered.contains("\x1b["));
}
}