use ratatui::{
buffer::Buffer,
layout::Rect,
style::{Color, Style},
widgets::{Block, Scrollbar, ScrollbarOrientation, ScrollbarState, StatefulWidget, Widget},
};
use crate::Component;
use crate::hooks::{use_keyboard, use_state};
use crossterm::event::KeyCode;
#[derive(Clone, Debug)]
pub struct ScrollIndicator {
pub show_scrollbar: bool,
pub show_more_above: bool,
pub show_more_below: bool,
pub track_color: Color,
pub thumb_color: Color,
}
impl Default for ScrollIndicator {
fn default() -> Self {
Self {
show_scrollbar: true,
show_more_above: true,
show_more_below: true,
track_color: Color::DarkGray,
thumb_color: Color::Gray,
}
}
}
pub struct VirtualBuffer {
buffer: Buffer,
pub area: Rect,
}
impl VirtualBuffer {
pub fn new(width: u16, height: u16) -> Self {
let area = Rect::new(0, 0, width, height);
Self {
buffer: Buffer::empty(area),
area,
}
}
pub fn buffer_mut(&mut self) -> &mut Buffer {
&mut self.buffer
}
pub fn buffer(&self) -> &Buffer {
&self.buffer
}
pub fn copy_to(&self, target: &mut Buffer, target_area: Rect, scroll_offset: u16) {
let src_height = self.area.height;
let dst_height = target_area.height;
for dy in 0..dst_height {
let src_y = scroll_offset + dy;
if src_y >= src_height {
break;
}
for dx in 0..target_area.width.min(self.area.width) {
let src_cell = self.buffer.cell((dx, src_y));
if let Some(cell) = src_cell {
if let Some(dst_cell) =
target.cell_mut((target_area.x + dx, target_area.y + dy))
{
*dst_cell = cell.clone();
}
}
}
}
}
}
pub struct ScrollViewProps<F>
where
F: Fn(Rect, &mut Buffer),
{
pub content_height: u16,
pub render_content: F,
pub block: Option<Block<'static>>,
pub indicators: ScrollIndicator,
pub keyboard_nav: bool,
pub scroll_step: u16,
pub initial_offset: u16,
}
impl<F> ScrollViewProps<F>
where
F: Fn(Rect, &mut Buffer),
{
pub fn new(content_height: u16, render_content: F) -> Self {
Self {
content_height,
render_content,
block: None,
indicators: ScrollIndicator::default(),
keyboard_nav: true,
scroll_step: 1,
initial_offset: 0,
}
}
pub fn block(mut self, block: Block<'static>) -> Self {
self.block = Some(block);
self
}
pub fn indicators(mut self, indicators: ScrollIndicator) -> Self {
self.indicators = indicators;
self
}
pub fn keyboard_nav(mut self, enabled: bool) -> Self {
self.keyboard_nav = enabled;
self
}
pub fn scroll_step(mut self, step: u16) -> Self {
self.scroll_step = step;
self
}
pub fn initial_offset(mut self, offset: u16) -> Self {
self.initial_offset = offset;
self
}
}
pub struct ScrollView<F>
where
F: Fn(Rect, &mut Buffer),
{
props: ScrollViewProps<F>,
}
impl<F> ScrollView<F>
where
F: Fn(Rect, &mut Buffer),
{
pub fn new(props: ScrollViewProps<F>) -> Self {
Self { props }
}
}
impl<F> Component for ScrollView<F>
where
F: Fn(Rect, &mut Buffer) + 'static,
{
fn render(&self, area: Rect, buffer: &mut Buffer) {
let inner_area = if let Some(ref block) = self.props.block {
block.clone().render(area, buffer);
block.inner(area)
} else {
area
};
let content_area = if self.props.indicators.show_scrollbar {
Rect {
width: inner_area.width.saturating_sub(1),
..inner_area
}
} else {
inner_area
};
let viewport_height = content_area.height;
let content_height = self.props.content_height;
let (offset, set_offset) = use_state(|| self.props.initial_offset as usize);
let max_offset = (content_height as usize).saturating_sub(viewport_height as usize);
let clamped_offset = offset.min(max_offset);
if clamped_offset != offset {
set_offset.set(clamped_offset);
}
if self.props.keyboard_nav {
let step = self.props.scroll_step as usize;
use_keyboard(move |key| {
let current = offset;
let new_offset = match key.code {
KeyCode::Char('j') | KeyCode::Down => (current + step).min(max_offset),
KeyCode::Char('k') | KeyCode::Up => current.saturating_sub(step),
KeyCode::Char('g') | KeyCode::Home => 0,
KeyCode::Char('G') | KeyCode::End => max_offset,
KeyCode::PageDown => {
(current + (viewport_height as usize).saturating_sub(1)).min(max_offset)
}
KeyCode::PageUp => {
current.saturating_sub((viewport_height as usize).saturating_sub(1))
}
KeyCode::Char('d')
if key
.modifiers
.contains(crossterm::event::KeyModifiers::CONTROL) =>
{
(current + viewport_height as usize / 2).min(max_offset)
}
KeyCode::Char('u')
if key
.modifiers
.contains(crossterm::event::KeyModifiers::CONTROL) =>
{
current.saturating_sub(viewport_height as usize / 2)
}
_ => return,
};
set_offset.set(new_offset);
});
}
let mut virtual_buf = VirtualBuffer::new(content_area.width, content_height);
let virtual_content_area = Rect::new(0, 0, content_area.width, content_height);
(self.props.render_content)(virtual_content_area, virtual_buf.buffer_mut());
virtual_buf.copy_to(buffer, content_area, clamped_offset as u16);
if self.props.indicators.show_scrollbar && content_height > viewport_height {
let scrollbar_area = Rect {
x: inner_area.x + inner_area.width.saturating_sub(1),
y: inner_area.y,
width: 1,
height: inner_area.height,
};
let mut scrollbar_state =
ScrollbarState::new(max_offset.max(1)).position(clamped_offset);
let scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight)
.track_style(Style::default().fg(self.props.indicators.track_color))
.thumb_style(Style::default().fg(self.props.indicators.thumb_color));
StatefulWidget::render(scrollbar, scrollbar_area, buffer, &mut scrollbar_state);
}
if self.props.indicators.show_more_above && clamped_offset > 0 {
let indicator = "▲";
let x = content_area.x + content_area.width.saturating_sub(2);
buffer.set_string(
x,
content_area.y,
indicator,
Style::default().fg(Color::Yellow),
);
}
if self.props.indicators.show_more_below && clamped_offset < max_offset {
let indicator = "▼";
let y = content_area.y + content_area.height.saturating_sub(1);
let x = content_area.x + content_area.width.saturating_sub(2);
buffer.set_string(x, y, indicator, Style::default().fg(Color::Yellow));
}
}
}
pub struct ScrollViewItemProps<F>
where
F: Fn(Rect, &mut Buffer, usize, usize),
{
pub item_count: usize,
pub render_items: F,
pub block: Option<Block<'static>>,
pub indicators: ScrollIndicator,
pub keyboard_nav: bool,
pub scroll_step: usize,
}
impl<F> ScrollViewItemProps<F>
where
F: Fn(Rect, &mut Buffer, usize, usize),
{
pub fn new(item_count: usize, render_items: F) -> Self {
Self {
item_count,
render_items,
block: None,
indicators: ScrollIndicator::default(),
keyboard_nav: true,
scroll_step: 1,
}
}
pub fn block(mut self, block: Block<'static>) -> Self {
self.block = Some(block);
self
}
pub fn indicators(mut self, indicators: ScrollIndicator) -> Self {
self.indicators = indicators;
self
}
pub fn keyboard_nav(mut self, enabled: bool) -> Self {
self.keyboard_nav = enabled;
self
}
pub fn scroll_step(mut self, step: usize) -> Self {
self.scroll_step = step;
self
}
}
pub struct ScrollViewItems<F>
where
F: Fn(Rect, &mut Buffer, usize, usize),
{
props: ScrollViewItemProps<F>,
}
impl<F> ScrollViewItems<F>
where
F: Fn(Rect, &mut Buffer, usize, usize),
{
pub fn new(props: ScrollViewItemProps<F>) -> Self {
Self { props }
}
}
impl<F> Component for ScrollViewItems<F>
where
F: Fn(Rect, &mut Buffer, usize, usize) + 'static,
{
fn render(&self, area: Rect, buffer: &mut Buffer) {
let inner_area = if let Some(ref block) = self.props.block {
block.clone().render(area, buffer);
block.inner(area)
} else {
area
};
let content_area = if self.props.indicators.show_scrollbar {
Rect {
width: inner_area.width.saturating_sub(1),
..inner_area
}
} else {
inner_area
};
let viewport_height = content_area.height as usize;
let content_height = self.props.item_count;
let (offset, set_offset) = use_state(|| 0usize);
let max_offset = content_height.saturating_sub(viewport_height);
let clamped_offset = offset.min(max_offset);
if clamped_offset != offset {
set_offset.set(clamped_offset);
}
if self.props.keyboard_nav {
let step = self.props.scroll_step;
use_keyboard(move |key| {
let current = offset;
let new_offset = match key.code {
KeyCode::Char('j') | KeyCode::Down => (current + step).min(max_offset),
KeyCode::Char('k') | KeyCode::Up => current.saturating_sub(step),
KeyCode::Char('g') | KeyCode::Home => 0,
KeyCode::Char('G') | KeyCode::End => max_offset,
KeyCode::PageDown => {
(current + viewport_height.saturating_sub(1)).min(max_offset)
}
KeyCode::PageUp => current.saturating_sub(viewport_height.saturating_sub(1)),
_ => return,
};
set_offset.set(new_offset);
});
}
let visible_count = viewport_height.min(content_height.saturating_sub(clamped_offset));
(self.props.render_items)(content_area, buffer, clamped_offset, visible_count);
if self.props.indicators.show_scrollbar && content_height > viewport_height {
let scrollbar_area = Rect {
x: inner_area.x + inner_area.width.saturating_sub(1),
y: inner_area.y,
width: 1,
height: inner_area.height,
};
let mut scrollbar_state =
ScrollbarState::new(max_offset.max(1)).position(clamped_offset);
let scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight)
.track_style(Style::default().fg(self.props.indicators.track_color))
.thumb_style(Style::default().fg(self.props.indicators.thumb_color));
StatefulWidget::render(scrollbar, scrollbar_area, buffer, &mut scrollbar_state);
}
if self.props.indicators.show_more_above && clamped_offset > 0 {
buffer.set_string(
content_area.x + content_area.width.saturating_sub(2),
content_area.y,
"▲",
Style::default().fg(Color::Yellow),
);
}
if self.props.indicators.show_more_below && clamped_offset < max_offset {
buffer.set_string(
content_area.x + content_area.width.saturating_sub(2),
content_area.y + content_area.height.saturating_sub(1),
"▼",
Style::default().fg(Color::Yellow),
);
}
}
}