use ratatui::{buffer::Buffer, layout::Rect, style::Style, widgets::Widget};
use std::fmt;
use crate::tui::tokens::compat;
pub struct ScrollIndicator {
offset: usize,
total: usize,
visible: usize,
track_style: Style,
thumb_style: Style,
show_arrows: bool,
}
impl Default for ScrollIndicator {
fn default() -> Self {
Self {
offset: 0,
total: 0,
visible: 0,
track_style: Style::default().fg(compat::SLATE_600),
thumb_style: Style::default().fg(compat::CYAN_500),
show_arrows: true,
}
}
}
impl ScrollIndicator {
pub fn new() -> Self {
Self::default()
}
pub fn position(mut self, offset: usize, total: usize, visible: usize) -> Self {
self.offset = offset;
self.total = total;
self.visible = visible;
self
}
pub fn track_style(mut self, style: Style) -> Self {
self.track_style = style;
self
}
pub fn thumb_style(mut self, style: Style) -> Self {
self.thumb_style = style;
self
}
pub fn show_arrows(mut self, show: bool) -> Self {
self.show_arrows = show;
self
}
pub fn is_scrollable(&self) -> bool {
self.total > self.visible
}
pub fn can_scroll_up(&self) -> bool {
self.offset > 0
}
pub fn can_scroll_down(&self) -> bool {
self.offset + self.visible < self.total
}
fn calculate_thumb(&self, track_height: usize) -> (usize, usize) {
if !self.is_scrollable() || track_height == 0 {
return (0, track_height);
}
let thumb_size = ((self.visible as f64 / self.total as f64) * track_height as f64)
.max(1.0)
.min(track_height as f64) as usize;
let max_offset = self.total.saturating_sub(self.visible);
let scrollable_track = track_height.saturating_sub(thumb_size);
let thumb_pos = if max_offset > 0 {
((self.offset as f64 / max_offset as f64) * scrollable_track as f64) as usize
} else {
0
};
(thumb_pos, thumb_size)
}
}
impl Widget for ScrollIndicator {
fn render(self, area: Rect, buf: &mut Buffer) {
if area.width == 0 || area.height == 0 {
return;
}
let height = area.height as usize;
if !self.is_scrollable() {
for y in 0..height {
let pos_y = area.y + y as u16;
if let Some(cell) = buf.cell_mut((area.x, pos_y)) {
cell.set_char('│');
cell.set_style(self.track_style);
}
}
return;
}
let (track_start, track_height) = if self.show_arrows && height >= 3 {
(1, height - 2)
} else {
(0, height)
};
if self.show_arrows && height >= 3 {
if let Some(up_cell) = buf.cell_mut((area.x, area.y)) {
if self.can_scroll_up() {
up_cell.set_char('▲');
up_cell.set_style(self.thumb_style);
} else {
up_cell.set_char('△');
up_cell.set_style(self.track_style);
}
}
if let Some(down_cell) = buf.cell_mut((area.x, area.y + area.height - 1)) {
if self.can_scroll_down() {
down_cell.set_char('▼');
down_cell.set_style(self.thumb_style);
} else {
down_cell.set_char('▽');
down_cell.set_style(self.track_style);
}
}
}
let (thumb_pos, thumb_size) = self.calculate_thumb(track_height);
for i in 0..track_height {
let y = area.y + (track_start + i) as u16;
if let Some(cell) = buf.cell_mut((area.x, y)) {
if i >= thumb_pos && i < thumb_pos + thumb_size {
cell.set_char('█');
cell.set_style(self.thumb_style);
} else {
cell.set_char('░');
cell.set_style(self.track_style);
}
}
}
}
}
pub struct ScrollHint {
above: usize,
below: usize,
style: Style,
}
impl ScrollHint {
pub fn new(above: usize, below: usize) -> Self {
Self {
above,
below,
style: Style::default().fg(compat::SLATE_600),
}
}
pub fn style(mut self, style: Style) -> Self {
self.style = style;
self
}
}
impl fmt::Display for ScrollHint {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match (self.above > 0, self.below > 0) {
(true, true) => write!(f, "↑{}↓{}", self.above, self.below),
(true, false) => write!(f, "↑{}", self.above),
(false, true) => write!(f, "↓{}", self.below),
(false, false) => Ok(()),
}
}
}
impl Widget for ScrollHint {
fn render(self, area: Rect, buf: &mut Buffer) {
if area.width == 0 {
return;
}
let text = self.to_string();
if text.is_empty() {
return;
}
let text_len = text.chars().count() as u16;
let start_x = if area.width >= text_len {
area.x + area.width - text_len
} else {
area.x
};
for (i, ch) in text.chars().enumerate() {
let x = start_x + i as u16;
if x < area.x + area.width {
if let Some(cell) = buf.cell_mut((x, area.y)) {
cell.set_char(ch);
cell.set_style(self.style);
}
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_scroll_indicator_default() {
let indicator = ScrollIndicator::new();
assert!(!indicator.is_scrollable());
assert!(!indicator.can_scroll_up());
assert!(!indicator.can_scroll_down());
}
#[test]
fn test_scroll_indicator_scrollable() {
let indicator = ScrollIndicator::new().position(0, 100, 20);
assert!(indicator.is_scrollable());
assert!(!indicator.can_scroll_up());
assert!(indicator.can_scroll_down());
}
#[test]
fn test_scroll_indicator_middle() {
let indicator = ScrollIndicator::new().position(40, 100, 20);
assert!(indicator.is_scrollable());
assert!(indicator.can_scroll_up());
assert!(indicator.can_scroll_down());
}
#[test]
fn test_scroll_indicator_at_bottom() {
let indicator = ScrollIndicator::new().position(80, 100, 20);
assert!(indicator.is_scrollable());
assert!(indicator.can_scroll_up());
assert!(!indicator.can_scroll_down());
}
#[test]
fn test_thumb_calculation() {
let indicator = ScrollIndicator::new().position(0, 100, 20);
let (pos, size) = indicator.calculate_thumb(20);
assert_eq!(pos, 0);
assert!(size > 0 && size < 20);
}
#[test]
fn test_scroll_hint_formatting() {
assert_eq!(ScrollHint::new(3, 7).to_string(), "↑3↓7");
assert_eq!(ScrollHint::new(5, 0).to_string(), "↑5");
assert_eq!(ScrollHint::new(0, 10).to_string(), "↓10");
assert_eq!(ScrollHint::new(0, 0).to_string(), "");
}
#[test]
fn test_not_scrollable_when_all_visible() {
let indicator = ScrollIndicator::new().position(0, 10, 20);
assert!(!indicator.is_scrollable());
}
}