use ratatui::{
buffer::Buffer,
layout::Rect,
style::{Color, Modifier, Style},
text::Line,
widgets::{Block, Borders, Paragraph, Widget},
};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ScrollableContentAction {
ScrollUp,
ScrollDown,
ScrollToTop,
ScrollToBottom,
PageUp,
PageDown,
ToggleFullscreen,
}
#[derive(Debug, Clone)]
pub struct ScrollableContentState {
lines: Vec<String>,
scroll_offset: usize,
focused: bool,
fullscreen: bool,
title: Option<String>,
}
impl ScrollableContentState {
pub fn new(lines: Vec<String>) -> Self {
Self {
lines,
scroll_offset: 0,
focused: false,
fullscreen: false,
title: None,
}
}
pub fn empty() -> Self {
Self::new(Vec::new())
}
pub fn set_lines(&mut self, lines: Vec<String>) {
self.lines = lines;
if !self.lines.is_empty() {
self.scroll_offset = self.scroll_offset.min(self.lines.len() - 1);
} else {
self.scroll_offset = 0;
}
}
pub fn lines(&self) -> &[String] {
&self.lines
}
pub fn push_line(&mut self, line: impl Into<String>) {
self.lines.push(line.into());
}
pub fn clear(&mut self) {
self.lines.clear();
self.scroll_offset = 0;
}
pub fn line_count(&self) -> usize {
self.lines.len()
}
pub fn scroll_offset(&self) -> usize {
self.scroll_offset
}
pub fn set_scroll_offset(&mut self, offset: usize) {
if !self.lines.is_empty() {
self.scroll_offset = offset.min(self.lines.len() - 1);
} else {
self.scroll_offset = 0;
}
}
pub fn is_focused(&self) -> bool {
self.focused
}
pub fn set_focused(&mut self, focused: bool) {
self.focused = focused;
}
pub fn is_fullscreen(&self) -> bool {
self.fullscreen
}
pub fn set_fullscreen(&mut self, fullscreen: bool) {
self.fullscreen = fullscreen;
}
pub fn toggle_fullscreen(&mut self) -> bool {
self.fullscreen = !self.fullscreen;
self.fullscreen
}
pub fn set_title(&mut self, title: impl Into<String>) {
self.title = Some(title.into());
}
pub fn title(&self) -> Option<&str> {
self.title.as_deref()
}
pub fn scroll_up(&mut self, lines: usize) {
self.scroll_offset = self.scroll_offset.saturating_sub(lines);
}
pub fn scroll_down(&mut self, lines: usize, visible_height: usize) {
if self.lines.is_empty() {
return;
}
let max_offset = self.lines.len().saturating_sub(visible_height);
self.scroll_offset = (self.scroll_offset + lines).min(max_offset);
}
pub fn scroll_to_top(&mut self) {
self.scroll_offset = 0;
}
pub fn scroll_to_bottom(&mut self, visible_height: usize) {
if self.lines.is_empty() {
return;
}
self.scroll_offset = self.lines.len().saturating_sub(visible_height);
}
pub fn page_up(&mut self, visible_height: usize) {
self.scroll_up(visible_height.saturating_sub(1));
}
pub fn page_down(&mut self, visible_height: usize) {
self.scroll_down(visible_height.saturating_sub(1), visible_height);
}
pub fn visible_lines(&self, height: usize) -> &[String] {
if self.lines.is_empty() {
return &[];
}
let start = self.scroll_offset.min(self.lines.len() - 1);
let end = (start + height).min(self.lines.len());
&self.lines[start..end]
}
pub fn is_at_top(&self) -> bool {
self.scroll_offset == 0
}
pub fn is_at_bottom(&self, visible_height: usize) -> bool {
if self.lines.is_empty() {
return true;
}
self.scroll_offset >= self.lines.len().saturating_sub(visible_height)
}
pub fn content_as_string(&self) -> String {
self.lines.join("\n")
}
}
impl Default for ScrollableContentState {
fn default() -> Self {
Self::empty()
}
}
#[derive(Debug, Clone)]
pub struct ScrollableContentStyle {
pub border_style: Style,
pub focused_border_style: Style,
pub text_style: Style,
pub show_borders: bool,
pub show_scroll_indicators: bool,
}
impl Default for ScrollableContentStyle {
fn default() -> Self {
Self {
border_style: Style::default().fg(Color::DarkGray),
focused_border_style: Style::default().fg(Color::Cyan),
text_style: Style::default().fg(Color::White),
show_borders: true,
show_scroll_indicators: true,
}
}
}
impl From<&crate::theme::Theme> for ScrollableContentStyle {
fn from(theme: &crate::theme::Theme) -> Self {
let p = &theme.palette;
Self {
border_style: Style::default().fg(p.border_disabled),
focused_border_style: Style::default().fg(p.border_accent),
text_style: Style::default().fg(p.text),
show_borders: true,
show_scroll_indicators: true,
}
}
}
impl ScrollableContentStyle {
pub fn borderless() -> Self {
Self {
show_borders: false,
..Default::default()
}
}
pub fn with_focus_color(mut self, color: Color) -> Self {
self.focused_border_style = Style::default().fg(color);
self
}
pub fn text_style(mut self, style: Style) -> Self {
self.text_style = style;
self
}
}
pub struct ScrollableContent<'a> {
state: &'a ScrollableContentState,
style: ScrollableContentStyle,
title: Option<&'a str>,
}
impl<'a> ScrollableContent<'a> {
pub fn new(state: &'a ScrollableContentState) -> Self {
Self {
state,
style: ScrollableContentStyle::default(),
title: state.title.as_deref(),
}
}
pub fn style(mut self, style: ScrollableContentStyle) -> Self {
self.style = style;
self
}
pub fn theme(self, theme: &crate::theme::Theme) -> Self {
self.style(ScrollableContentStyle::from(theme))
}
pub fn title(mut self, title: &'a str) -> Self {
self.title = Some(title);
self
}
pub fn inner_area(&self, area: Rect) -> Rect {
if self.style.show_borders {
Rect {
x: area.x + 1,
y: area.y + 1,
width: area.width.saturating_sub(2),
height: area.height.saturating_sub(2),
}
} else {
area
}
}
}
impl Widget for ScrollableContent<'_> {
fn render(self, area: Rect, buf: &mut Buffer) {
if area.width == 0 || area.height == 0 {
return;
}
let border_style = if self.state.focused {
self.style.focused_border_style
} else {
self.style.border_style
};
let mut block = Block::default().border_style(border_style);
if self.style.show_borders {
block = block.borders(Borders::ALL);
}
if let Some(title) = self.title {
let title_style = if self.state.focused {
border_style.add_modifier(Modifier::BOLD)
} else {
border_style
};
block = block.title(format!(" {} ", title)).title_style(title_style);
}
let inner = block.inner(area);
block.render(area, buf);
let visible_height = inner.height as usize;
let visible_lines = self.state.visible_lines(visible_height);
let lines: Vec<Line> = visible_lines
.iter()
.map(|s| Line::from(s.as_str()).style(self.style.text_style))
.collect();
let paragraph = Paragraph::new(lines);
paragraph.render(inner, buf);
if self.style.show_scroll_indicators && self.style.show_borders {
let has_content_above = !self.state.is_at_top();
let has_content_below = !self.state.is_at_bottom(visible_height);
if has_content_above && area.height > 2 {
buf.set_string(
area.x + area.width - 2,
area.y,
"â–²",
Style::default().fg(Color::DarkGray),
);
}
if has_content_below && area.height > 2 {
buf.set_string(
area.x + area.width - 2,
area.y + area.height - 1,
"â–¼",
Style::default().fg(Color::DarkGray),
);
}
}
}
}
pub fn handle_scrollable_content_key(
state: &mut ScrollableContentState,
key: &crossterm::event::KeyEvent,
visible_height: usize,
) -> Option<ScrollableContentAction> {
use crossterm::event::KeyCode;
match key.code {
KeyCode::Up | KeyCode::Char('k') => {
state.scroll_up(1);
Some(ScrollableContentAction::ScrollUp)
}
KeyCode::Down | KeyCode::Char('j') => {
state.scroll_down(1, visible_height);
Some(ScrollableContentAction::ScrollDown)
}
KeyCode::PageUp => {
state.page_up(visible_height);
Some(ScrollableContentAction::PageUp)
}
KeyCode::PageDown => {
state.page_down(visible_height);
Some(ScrollableContentAction::PageDown)
}
KeyCode::Home => {
state.scroll_to_top();
Some(ScrollableContentAction::ScrollToTop)
}
KeyCode::End => {
state.scroll_to_bottom(visible_height);
Some(ScrollableContentAction::ScrollToBottom)
}
KeyCode::F(10) | KeyCode::Enter => {
state.toggle_fullscreen();
Some(ScrollableContentAction::ToggleFullscreen)
}
_ => None,
}
}
pub fn handle_scrollable_content_mouse(
state: &mut ScrollableContentState,
mouse: &crossterm::event::MouseEvent,
content_area: Rect,
visible_height: usize,
) -> Option<ScrollableContentAction> {
use crossterm::event::MouseEventKind;
if mouse.column < content_area.x
|| mouse.column >= content_area.x + content_area.width
|| mouse.row < content_area.y
|| mouse.row >= content_area.y + content_area.height
{
return None;
}
match mouse.kind {
MouseEventKind::ScrollUp => {
state.scroll_up(3);
Some(ScrollableContentAction::ScrollUp)
}
MouseEventKind::ScrollDown => {
state.scroll_down(3, visible_height);
Some(ScrollableContentAction::ScrollDown)
}
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
fn sample_lines() -> Vec<String> {
(1..=100).map(|i| format!("Line {}", i)).collect()
}
#[test]
fn test_state_new() {
let lines = vec!["a".to_string(), "b".to_string()];
let state = ScrollableContentState::new(lines.clone());
assert_eq!(state.lines(), &lines);
assert_eq!(state.scroll_offset(), 0);
assert!(!state.is_focused());
assert!(!state.is_fullscreen());
}
#[test]
fn test_state_empty() {
let state = ScrollableContentState::empty();
assert!(state.lines().is_empty());
assert_eq!(state.line_count(), 0);
}
#[test]
fn test_scroll_up() {
let mut state = ScrollableContentState::new(sample_lines());
state.set_scroll_offset(50);
assert_eq!(state.scroll_offset(), 50);
state.scroll_up(10);
assert_eq!(state.scroll_offset(), 40);
state.scroll_up(100); assert_eq!(state.scroll_offset(), 0);
}
#[test]
fn test_scroll_down() {
let mut state = ScrollableContentState::new(sample_lines());
let visible_height = 20;
state.scroll_down(10, visible_height);
assert_eq!(state.scroll_offset(), 10);
state.scroll_down(1000, visible_height); assert_eq!(state.scroll_offset(), 100 - visible_height);
}
#[test]
fn test_scroll_to_top_bottom() {
let mut state = ScrollableContentState::new(sample_lines());
let visible_height = 20;
state.scroll_to_bottom(visible_height);
assert_eq!(state.scroll_offset(), 80);
assert!(state.is_at_bottom(visible_height));
state.scroll_to_top();
assert_eq!(state.scroll_offset(), 0);
assert!(state.is_at_top());
}
#[test]
fn test_page_up_down() {
let mut state = ScrollableContentState::new(sample_lines());
let visible_height = 20;
state.page_down(visible_height);
assert_eq!(state.scroll_offset(), 19);
state.page_up(visible_height);
assert_eq!(state.scroll_offset(), 0);
}
#[test]
fn test_visible_lines() {
let state = ScrollableContentState::new(sample_lines());
let visible = state.visible_lines(5);
assert_eq!(visible.len(), 5);
assert_eq!(visible[0], "Line 1");
assert_eq!(visible[4], "Line 5");
}
#[test]
fn test_focus_and_fullscreen() {
let mut state = ScrollableContentState::empty();
assert!(!state.is_focused());
state.set_focused(true);
assert!(state.is_focused());
assert!(!state.is_fullscreen());
assert!(state.toggle_fullscreen());
assert!(state.is_fullscreen());
assert!(!state.toggle_fullscreen());
assert!(!state.is_fullscreen());
}
#[test]
fn test_content_as_string() {
let lines = vec!["a".to_string(), "b".to_string(), "c".to_string()];
let state = ScrollableContentState::new(lines);
assert_eq!(state.content_as_string(), "a\nb\nc");
}
#[test]
fn test_set_lines_clamps_scroll() {
let mut state = ScrollableContentState::new(sample_lines());
state.set_scroll_offset(50);
state.set_lines(vec!["a".to_string(), "b".to_string()]);
assert_eq!(state.scroll_offset(), 1); }
#[test]
fn test_style_default() {
let style = ScrollableContentStyle::default();
assert!(style.show_borders);
assert!(style.show_scroll_indicators);
}
#[test]
fn test_style_borderless() {
let style = ScrollableContentStyle::borderless();
assert!(!style.show_borders);
}
#[test]
fn test_handle_key_scroll() {
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
let mut state = ScrollableContentState::new(sample_lines());
let visible_height = 20;
let key = KeyEvent::new(KeyCode::Down, KeyModifiers::NONE);
let action = handle_scrollable_content_key(&mut state, &key, visible_height);
assert_eq!(action, Some(ScrollableContentAction::ScrollDown));
assert_eq!(state.scroll_offset(), 1);
let key = KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE);
handle_scrollable_content_key(&mut state, &key, visible_height);
assert_eq!(state.scroll_offset(), 2);
let key = KeyEvent::new(KeyCode::Up, KeyModifiers::NONE);
let action = handle_scrollable_content_key(&mut state, &key, visible_height);
assert_eq!(action, Some(ScrollableContentAction::ScrollUp));
assert_eq!(state.scroll_offset(), 1);
let key = KeyEvent::new(KeyCode::Char('k'), KeyModifiers::NONE);
handle_scrollable_content_key(&mut state, &key, visible_height);
assert_eq!(state.scroll_offset(), 0);
state.set_scroll_offset(50);
let key = KeyEvent::new(KeyCode::Home, KeyModifiers::NONE);
let action = handle_scrollable_content_key(&mut state, &key, visible_height);
assert_eq!(action, Some(ScrollableContentAction::ScrollToTop));
assert_eq!(state.scroll_offset(), 0);
let key = KeyEvent::new(KeyCode::End, KeyModifiers::NONE);
let action = handle_scrollable_content_key(&mut state, &key, visible_height);
assert_eq!(action, Some(ScrollableContentAction::ScrollToBottom));
assert_eq!(state.scroll_offset(), 80);
}
#[test]
fn test_handle_key_fullscreen() {
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
let mut state = ScrollableContentState::new(sample_lines());
let visible_height = 20;
let key = KeyEvent::new(KeyCode::F(10), KeyModifiers::NONE);
let action = handle_scrollable_content_key(&mut state, &key, visible_height);
assert_eq!(action, Some(ScrollableContentAction::ToggleFullscreen));
assert!(state.is_fullscreen());
let key = KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE);
handle_scrollable_content_key(&mut state, &key, visible_height);
assert!(!state.is_fullscreen());
}
#[test]
fn test_widget_render() {
let state = ScrollableContentState::new(vec![
"Line 1".to_string(),
"Line 2".to_string(),
"Line 3".to_string(),
]);
let widget = ScrollableContent::new(&state).title("Test");
let mut buf = Buffer::empty(Rect::new(0, 0, 20, 10));
widget.render(Rect::new(0, 0, 20, 10), &mut buf);
let content: String = buf.content.iter().map(|c| c.symbol()).collect();
assert!(content.contains("Line 1"));
}
#[test]
fn test_inner_area() {
let state = ScrollableContentState::empty();
let content = ScrollableContent::new(&state);
let area = Rect::new(0, 0, 20, 10);
let inner = content.inner_area(area);
assert_eq!(inner.x, 1);
assert_eq!(inner.y, 1);
assert_eq!(inner.width, 18);
assert_eq!(inner.height, 8);
}
#[test]
fn test_title() {
let mut state = ScrollableContentState::empty();
state.set_title("My Title");
assert_eq!(state.title(), Some("My Title"));
let widget = ScrollableContent::new(&state);
assert_eq!(widget.title, Some("My Title"));
let widget = ScrollableContent::new(&state).title("Override");
assert_eq!(widget.title, Some("Override"));
}
}