diff --git a/Cargo.toml b/Cargo.toml index f17dadab..854e1456 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -127,6 +127,11 @@ name = "popup" required-features = ["crossterm"] doc-scrape-examples = true +[[example]] +name = "scrollbar" +required-features = ["crossterm"] +doc-scrape-examples = true + [[example]] name = "sparkline" required-features = ["crossterm"] diff --git a/examples/scrollbar.rs b/examples/scrollbar.rs new file mode 100644 index 00000000..e3c61b24 --- /dev/null +++ b/examples/scrollbar.rs @@ -0,0 +1,273 @@ +use std::{ + error::Error, + io, + time::{Duration, Instant}, +}; + +use crossterm::{ + event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode}, + execute, + terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, +}; +use ratatui::{ + backend::{Backend, CrosstermBackend}, + layout::{Alignment, Constraint, Direction, Layout, Margin}, + style::{Color, Modifier, Style}, + text::{Line, Masked, Span}, + widgets::{ + scrollbar, Block, Borders, Paragraph, Scrollbar, ScrollbarOrientation, ScrollbarState, + }, + Frame, Terminal, +}; + +#[derive(Default)] +struct App { + pub vertical_scroll_state: ScrollbarState, + pub horizontal_scroll_state: ScrollbarState, + pub vertical_scroll: usize, + pub horizontal_scroll: usize, +} + +fn main() -> Result<(), Box> { + // setup terminal + enable_raw_mode()?; + let mut stdout = io::stdout(); + execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?; + let backend = CrosstermBackend::new(stdout); + let mut terminal = Terminal::new(backend)?; + + // create app and run it + let tick_rate = Duration::from_millis(250); + let app = App::default(); + let res = run_app(&mut terminal, app, tick_rate); + + // restore terminal + disable_raw_mode()?; + execute!( + terminal.backend_mut(), + LeaveAlternateScreen, + DisableMouseCapture + )?; + terminal.show_cursor()?; + + if let Err(err) = res { + println!("{err:?}"); + } + + Ok(()) +} + +fn run_app( + terminal: &mut Terminal, + mut app: App, + tick_rate: Duration, +) -> io::Result<()> { + let mut last_tick = Instant::now(); + loop { + terminal.draw(|f| ui(f, &mut app))?; + + let timeout = tick_rate + .checked_sub(last_tick.elapsed()) + .unwrap_or_else(|| Duration::from_secs(0)); + if crossterm::event::poll(timeout)? { + if let Event::Key(key) = event::read()? { + match key.code { + KeyCode::Char('q') => return Ok(()), + KeyCode::Char('j') => { + app.vertical_scroll = app.vertical_scroll.saturating_add(1); + app.vertical_scroll_state = app + .vertical_scroll_state + .position(app.vertical_scroll as u16); + } + KeyCode::Char('k') => { + app.vertical_scroll = app.vertical_scroll.saturating_sub(1); + app.vertical_scroll_state = app + .vertical_scroll_state + .position(app.vertical_scroll as u16); + } + KeyCode::Char('h') => { + app.horizontal_scroll = app.horizontal_scroll.saturating_sub(1); + app.horizontal_scroll_state = app + .horizontal_scroll_state + .position(app.horizontal_scroll as u16); + } + KeyCode::Char('l') => { + app.horizontal_scroll = app.horizontal_scroll.saturating_add(1); + app.horizontal_scroll_state = app + .horizontal_scroll_state + .position(app.horizontal_scroll as u16); + } + _ => {} + } + } + } + if last_tick.elapsed() >= tick_rate { + last_tick = Instant::now(); + } + } +} + +fn ui(f: &mut Frame, app: &mut App) { + let size = f.size(); + + // Words made "loooong" to demonstrate line breaking. + let s = "Veeeeeeeeeeeeeeeery loooooooooooooooooong striiiiiiiiiiiiiiiiiiiiiiiiiing. "; + let mut long_line = s.repeat(usize::from(size.width) / s.len() + 4); + long_line.push('\n'); + + let block = Block::default().style(Style::default().fg(Color::Black)); + f.render_widget(block, size); + + let chunks = Layout::default() + .direction(Direction::Vertical) + .margin(2) + .constraints( + [ + Constraint::Min(1), + Constraint::Percentage(25), + Constraint::Percentage(25), + Constraint::Percentage(25), + Constraint::Percentage(25), + ] + .as_ref(), + ) + .split(size); + + let text = vec![ + Line::from("This is a line "), + Line::from(Span::styled( + "This is a line ", + Style::default().fg(Color::Red), + )), + Line::from(Span::styled( + "This is a line", + Style::default().bg(Color::DarkGray), + )), + Line::from(Span::styled( + "This is a longer line", + Style::default().add_modifier(Modifier::CROSSED_OUT), + )), + Line::from(Span::styled(&long_line, Style::default())), + Line::from(Span::styled("This is a line", Style::default())), + Line::from(vec![ + Span::raw("Masked text: "), + Span::styled( + Masked::new("password", '*'), + Style::default().fg(Color::Red), + ), + ]), + Line::from("This is a line "), + Line::from(Span::styled( + "This is a line ", + Style::default().fg(Color::Red), + )), + Line::from(Span::styled( + "This is a line", + Style::default().bg(Color::DarkGray), + )), + Line::from(Span::styled( + "This is a longer line", + Style::default().add_modifier(Modifier::CROSSED_OUT), + )), + Line::from(Span::styled(&long_line, Style::default())), + Line::from(Span::styled("This is a line", Style::default())), + Line::from(vec![ + Span::raw("Masked text: "), + Span::styled( + Masked::new("password", '*'), + Style::default().fg(Color::Red), + ), + ]), + ]; + app.vertical_scroll_state = app.vertical_scroll_state.content_length(text.len() as u16); + app.horizontal_scroll_state = app + .horizontal_scroll_state + .content_length(long_line.len() as u16); + + let create_block = |title| { + Block::default() + .borders(Borders::ALL) + .style(Style::default().fg(Color::Gray)) + .title(Span::styled( + title, + Style::default().add_modifier(Modifier::BOLD), + )) + }; + + let title = Block::default() + .title("Use h j k l to scroll ◄ ▲ ▼ ►") + .title_alignment(Alignment::Center); + f.render_widget(title, chunks[0]); + + let paragraph = Paragraph::new(text.clone()) + .style(Style::default().fg(Color::Gray)) + .block(create_block("Vertical scrollbar with arrows")) + .scroll((app.vertical_scroll as u16, 0)); + f.render_widget(paragraph, chunks[1]); + f.render_stateful_widget( + Scrollbar::default() + .orientation(ScrollbarOrientation::VerticalRight) + .begin_symbol(Some("↑")) + .end_symbol(Some("↓")), + chunks[1], + &mut app.vertical_scroll_state, + ); + + let paragraph = Paragraph::new(text.clone()) + .style(Style::default().fg(Color::Gray)) + .block(create_block( + "Vertical scrollbar without arrows and mirrored", + )) + .scroll((app.vertical_scroll as u16, 0)); + f.render_widget(paragraph, chunks[2]); + f.render_stateful_widget( + Scrollbar::default() + .orientation(ScrollbarOrientation::VerticalLeft) + .symbols(scrollbar::VERTICAL) + .begin_symbol(None) + .end_symbol(None), + chunks[2].inner(&Margin { + vertical: 1, + horizontal: 0, + }), + &mut app.vertical_scroll_state, + ); + + let paragraph = Paragraph::new(text.clone()) + .style(Style::default().fg(Color::Gray)) + .block(create_block( + "Horizontal scrollbar with only begin arrow & custom thumb symbol", + )) + .scroll((0, app.horizontal_scroll as u16)); + f.render_widget(paragraph, chunks[3]); + f.render_stateful_widget( + Scrollbar::default() + .orientation(ScrollbarOrientation::HorizontalBottom) + .thumb_symbol("🬋") + .end_symbol(None), + chunks[3].inner(&Margin { + vertical: 0, + horizontal: 1, + }), + &mut app.horizontal_scroll_state, + ); + + let paragraph = Paragraph::new(text.clone()) + .style(Style::default().fg(Color::Gray)) + .block(create_block( + "Horizontal scrollbar without arrows & custom thumb and track symbol", + )) + .scroll((0, app.horizontal_scroll as u16)); + f.render_widget(paragraph, chunks[4]); + f.render_stateful_widget( + Scrollbar::default() + .orientation(ScrollbarOrientation::HorizontalBottom) + .thumb_symbol("░") + .track_symbol("─"), + chunks[4].inner(&Margin { + vertical: 0, + horizontal: 1, + }), + &mut app.horizontal_scroll_state, + ); +} diff --git a/src/widgets/mod.rs b/src/widgets/mod.rs index 487edf67..4d0286e9 100644 --- a/src/widgets/mod.rs +++ b/src/widgets/mod.rs @@ -27,6 +27,7 @@ mod gauge; mod list; mod paragraph; mod reflow; +pub mod scrollbar; mod sparkline; mod table; mod tabs; @@ -43,6 +44,7 @@ pub use self::{ gauge::{Gauge, LineGauge}, list::{List, ListItem, ListState}, paragraph::{Paragraph, Wrap}, + scrollbar::{ScrollDirection, Scrollbar, ScrollbarOrientation, ScrollbarState}, sparkline::{RenderDirection, Sparkline}, table::{Cell, Row, Table, TableState}, tabs::Tabs, diff --git a/src/widgets/scrollbar.rs b/src/widgets/scrollbar.rs new file mode 100644 index 00000000..5646b778 --- /dev/null +++ b/src/widgets/scrollbar.rs @@ -0,0 +1,871 @@ +use super::StatefulWidget; +use crate::{ + buffer::Buffer, + layout::Rect, + style::Style, + symbols::{block::FULL, line}, +}; + +/// Scrollbar Set +/// <--▮-------> +/// ^ ^ ^ ^ +/// │ │ │ └ end +/// │ │ └──── track +/// │ └──────── thumb +/// └─────────── begin +#[derive(Debug, Clone)] +pub struct Set { + pub track: &'static str, + pub thumb: &'static str, + pub begin: &'static str, + pub end: &'static str, +} + +pub const DOUBLE_VERTICAL: Set = Set { + track: line::DOUBLE_VERTICAL, + thumb: FULL, + begin: "▲", + end: "▼", +}; + +pub const DOUBLE_HORIZONTAL: Set = Set { + track: line::DOUBLE_HORIZONTAL, + thumb: FULL, + begin: "◄", + end: "►", +}; + +pub const VERTICAL: Set = Set { + track: line::VERTICAL, + thumb: FULL, + begin: "↑", + end: "↓", +}; + +pub const HORIZONTAL: Set = Set { + track: line::HORIZONTAL, + thumb: FULL, + begin: "←", + end: "→", +}; + +/// An enum representing the direction of scrolling in a Scrollbar widget. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub enum ScrollDirection { + /// Forward scroll direction, usually corresponds to scrolling downwards or rightwards. + #[default] + Forward, + /// Backward scroll direction, usually corresponds to scrolling upwards or leftwards. + Backward, +} + +/// A struct representing the state of a Scrollbar widget. +/// +/// For example, in the following list, assume there are 4 bullet points: +/// +/// - the `position` is 0 +/// - the `content_length` is 4 +/// - the `viewport_content_length` is 2 +/// +/// ┌───────────────┐ +/// │1. this is a █ +/// │ single item █ +/// │2. this is a ║ +/// │ second item ║ +/// └───────────────┘ +/// +/// If you don't have multi-line content, you can leave the `viewport_content_length` set to the +/// default of 0 and it'll use the track size as a `viewport_content_length`. +#[derive(Clone, Copy, Debug, Default)] +pub struct ScrollbarState { + // The current position within the scrollable content. + position: u16, + // The total length of the scrollable content. + content_length: u16, + // The length of content in current viewport. + viewport_content_length: u16, +} + +impl ScrollbarState { + /// Sets the scroll position of the scrollbar and returns the modified ScrollbarState. + pub fn position(mut self, position: u16) -> Self { + self.position = position; + self + } + + /// Sets the length of the scrollable content and returns the modified ScrollbarState. + pub fn content_length(mut self, content_length: u16) -> Self { + self.content_length = content_length; + self + } + + /// Sets the length of the viewport content and returns the modified ScrollbarState. + pub fn viewport_content_length(mut self, viewport_content_length: u16) -> Self { + self.viewport_content_length = viewport_content_length; + self + } + + /// Decrements the scroll position by one, ensuring it doesn't go below zero. + pub fn prev(&mut self) { + self.position = self.position.saturating_sub(1); + } + + /// Increments the scroll position by one, ensuring it doesn't exceed the length of the content. + pub fn next(&mut self) { + self.position = self + .position + .saturating_add(1) + .clamp(0, self.content_length.saturating_sub(1)) + } + + /// Sets the scroll position to the start of the scrollable content. + pub fn first(&mut self) { + self.position = 0; + } + + /// Sets the scroll position to the end of the scrollable content. + pub fn last(&mut self) { + self.position = self.content_length.saturating_sub(1) + } + + /// Changes the scroll position based on the provided ScrollDirection. + pub fn scroll(&mut self, direction: ScrollDirection) { + match direction { + ScrollDirection::Forward => { + self.next(); + } + ScrollDirection::Backward => { + self.prev(); + } + } + } +} + +/// Scrollbar Orientation +#[derive(Default, Debug, Clone)] +pub enum ScrollbarOrientation { + #[default] + VerticalRight, + VerticalLeft, + HorizontalBottom, + HorizontalTop, +} + +/// Scrollbar widget for tui-rs library. +/// +/// This widget can be used to display a scrollbar in a terminal user interface. +/// The following components of the scrollbar are customizable in symbol and style. +/// +/// <--▮-------> +/// ^ ^ ^ ^ +/// │ │ │ └ end +/// │ │ └──── track +/// │ └──────── thumb +/// └─────────── begin +#[derive(Debug, Clone)] +pub struct Scrollbar<'a> { + orientation: ScrollbarOrientation, + thumb_style: Style, + thumb_symbol: &'a str, + track_style: Style, + track_symbol: &'a str, + begin_symbol: Option<&'a str>, + begin_style: Style, + end_symbol: Option<&'a str>, + end_style: Style, +} + +impl<'a> Default for Scrollbar<'a> { + fn default() -> Self { + Self { + orientation: ScrollbarOrientation::default(), + thumb_symbol: DOUBLE_VERTICAL.thumb, + thumb_style: Style::default(), + track_symbol: DOUBLE_VERTICAL.track, + track_style: Style::default(), + begin_symbol: Some(DOUBLE_VERTICAL.begin), + begin_style: Style::default(), + end_symbol: Some(DOUBLE_VERTICAL.end), + end_style: Style::default(), + } + } +} + +impl<'a> Scrollbar<'a> { + pub fn new(orientation: ScrollbarOrientation) -> Self { + Self::default().orientation(orientation) + } + + /// Sets the orientation of the scrollbar. + /// Resets the symbols to [`DOUBLE_VERTICAL`] or [`DOUBLE_HORIZONTAL`] based on orientation + pub fn orientation(mut self, orientation: ScrollbarOrientation) -> Self { + self.orientation = orientation; + let set = if self.is_vertical() { + DOUBLE_VERTICAL + } else { + DOUBLE_HORIZONTAL + }; + self.symbols(set) + } + + /// Sets the orientation and symbols for the scrollbar from a [`Set`]. + pub fn orientation_and_symbol(mut self, orientation: ScrollbarOrientation, set: Set) -> Self { + self.orientation = orientation; + self.symbols(set) + } + + /// Sets the symbol that represents the thumb of the scrollbar. + pub fn thumb_symbol(mut self, thumb_symbol: &'a str) -> Self { + self.thumb_symbol = thumb_symbol; + self + } + + /// Sets the style that represents the thumb of the scrollbar. + pub fn thumb_style(mut self, thumb_style: Style) -> Self { + self.thumb_style = thumb_style; + self + } + + /// Sets the symbol that represents the track of the scrollbar. + pub fn track_symbol(mut self, track_symbol: &'a str) -> Self { + self.track_symbol = track_symbol; + self + } + + /// Sets the style that is used for the track of the scrollbar. + pub fn track_style(mut self, track_style: Style) -> Self { + self.track_style = track_style; + self + } + + /// Sets the symbol that represents the beginning of the scrollbar. + pub fn begin_symbol(mut self, begin_symbol: Option<&'a str>) -> Self { + self.begin_symbol = begin_symbol; + self + } + + /// Sets the style that is used for the beginning of the scrollbar. + pub fn begin_style(mut self, begin_style: Style) -> Self { + self.begin_style = begin_style; + self + } + + /// Sets the symbol that represents the end of the scrollbar. + pub fn end_symbol(mut self, end_symbol: Option<&'a str>) -> Self { + self.end_symbol = end_symbol; + self + } + + /// Sets the style that is used for the end of the scrollbar. + pub fn end_style(mut self, end_style: Style) -> Self { + self.end_style = end_style; + self + } + + /// Sets the symbols used for the various parts of the scrollbar from a [`Set`]. + /// + /// <--▮-------> + /// ^ ^ ^ ^ + /// │ │ │ └ end + /// │ │ └──── track + /// │ └──────── thumb + /// └─────────── begin + /// + /// Only sets begin_symbol and end_symbol if they already contain a value. + /// If begin_symbol and/or end_symbol were set to `None` explicitly, this function will respect + /// that choice. + pub fn symbols(mut self, symbol: Set) -> Self { + self.track_symbol = symbol.track; + self.thumb_symbol = symbol.thumb; + if self.begin_symbol.is_some() { + self.begin_symbol = Some(symbol.begin); + } + if self.end_symbol.is_some() { + self.end_symbol = Some(symbol.end); + } + self + } + + /// Sets the style used for the various parts of the scrollbar from a [`Style`]. + /// <--▮-------> + /// ^ ^ ^ ^ + /// │ │ │ └ end + /// │ │ └──── track + /// │ └──────── thumb + /// └─────────── begin + pub fn style(mut self, style: Style) -> Self { + self.track_style = style; + self.thumb_style = style; + self.begin_style = style; + self.end_style = style; + self + } + + fn is_vertical(&self) -> bool { + match self.orientation { + ScrollbarOrientation::VerticalRight | ScrollbarOrientation::VerticalLeft => true, + ScrollbarOrientation::HorizontalBottom | ScrollbarOrientation::HorizontalTop => false, + } + } + + fn get_track_area(&self, area: Rect) -> Rect { + // Decrease track area if a begin arrow is present + let area = if self.begin_symbol.is_some() { + if self.is_vertical() { + // For vertical scrollbar, reduce the height by one + Rect::new( + area.x, + area.y + 1, + area.width, + area.height.saturating_sub(1), + ) + } else { + // For horizontal scrollbar, reduce the width by one + Rect::new( + area.x + 1, + area.y, + area.width.saturating_sub(1), + area.height, + ) + } + } else { + area + }; + // Further decrease scrollbar area if an end arrow is present + if self.end_symbol.is_some() { + if self.is_vertical() { + // For vertical scrollbar, reduce the height by one + Rect::new(area.x, area.y, area.width, area.height.saturating_sub(1)) + } else { + // For horizontal scrollbar, reduce the width by one + Rect::new(area.x, area.y, area.width.saturating_sub(1), area.height) + } + } else { + area + } + } + + fn should_not_render(&self, track_start: u16, track_end: u16, content_length: u16) -> bool { + if track_end - track_start == 0 || content_length == 0 { + return true; + } + false + } + + fn get_track_start_end(&self, area: Rect) -> (u16, u16, u16) { + match self.orientation { + ScrollbarOrientation::VerticalRight => { + (area.top(), area.bottom(), area.right().saturating_sub(1)) + } + ScrollbarOrientation::VerticalLeft => (area.top(), area.bottom(), area.left()), + ScrollbarOrientation::HorizontalBottom => { + (area.left(), area.right(), area.bottom().saturating_sub(1)) + } + ScrollbarOrientation::HorizontalTop => (area.left(), area.right(), area.top()), + } + } + + /// Calculate the starting and ending position of a scrollbar thumb. + /// + /// The scrollbar thumb's position and size are determined based on the current state of the + /// scrollbar, and the dimensions of the scrollbar track. + /// + /// This function returns a tuple `(thumb_start, thumb_end)` where `thumb_start` is the position + /// at which the scrollbar thumb begins, and `thumb_end` is the position at which the + /// scrollbar thumb ends. + /// + /// The size of the thumb (i.e., `thumb_end - thumb_start`) is proportional to the ratio of the + /// viewport content length to the total content length. + /// + /// The position of the thumb (i.e., `thumb_start`) is proportional to the ratio of the current + /// scroll position to the total content length. + fn get_thumb_start_end( + &self, + state: &ScrollbarState, + track_start_end: (u16, u16), + ) -> (u16, u16) { + // let (track_start, track_end) = track_start_end; + // let track_size = track_end - track_start; + // let thumb_size = + // ((state.viewport_content_length / state.content_length) * track_size).max(1); + // let thumb_start = (state.position / state.content_length) * + // state.viewport_content_length; + // let thumb_end = thumb_size + thumb_start; + // (thumb_start, thumb_end) + + let (track_start, track_end) = track_start_end; + + let viewport_content_length = if state.viewport_content_length == 0 { + track_end - track_start + } else { + state.viewport_content_length + }; + + let scroll_position_ratio = (state.position as f64 / state.content_length as f64).min(1.0); + + let thumb_size = (((viewport_content_length as f64 / state.content_length as f64) + * (track_end - track_start) as f64) + .round() as u16) + .max(1); + + let track_size = (track_end - track_start).saturating_sub(thumb_size); + + let thumb_start = track_start + (scroll_position_ratio * track_size as f64).round() as u16; + + let thumb_end = thumb_start + thumb_size; + + (thumb_start, thumb_end) + } +} + +impl<'a> StatefulWidget for Scrollbar<'a> { + type State = ScrollbarState; + + fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) { + // + // For ScrollbarOrientation::VerticalRight + // + // ┌───────── track_axis (x) + // v + // ┌───────────────┐ + // │ ║<──────── track_start (y1) + // │ █ + // │ █ + // │ ║ + // │ ║<──────── track_end (y2) + // └───────────────┘ + // + // For ScrollbarOrientation::HorizontalBottom + // + // ┌───────────────┐ + // │ │ + // │ │ + // │ │ + // └═══███═════════┘<──────── track_axis (y) + // ^ ^ + // │ └────────── track_end (x2) + // │ + // └──────────────────────── track_start (x1) + // + + // Find track_start, track_end, and track_axis + let area = self.get_track_area(area); + let (track_start, track_end, track_axis) = self.get_track_start_end(area); + + if self.should_not_render(track_start, track_end, state.content_length) { + return; + } + + let (thumb_start, thumb_end) = self.get_thumb_start_end(state, (track_start, track_end)); + + for i in track_start..track_end { + let (style, symbol) = if i >= thumb_start && i < thumb_end { + (self.thumb_style, self.thumb_symbol) + } else { + (self.track_style, self.track_symbol) + }; + + if self.is_vertical() { + buf.set_string(track_axis, i, symbol, style); + } else { + buf.set_string(i, track_axis, symbol, style); + } + } + + if let Some(s) = self.begin_symbol { + if self.is_vertical() { + buf.set_string(track_axis, track_start - 1, s, self.begin_style); + } else { + buf.set_string(track_start - 1, track_axis, s, self.begin_style); + } + }; + if let Some(s) = self.end_symbol { + if self.is_vertical() { + buf.set_string(track_axis, track_end, s, self.end_style); + } else { + buf.set_string(track_end, track_axis, s, self.end_style); + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::assert_buffer_eq; + + #[test] + fn test_no_render_when_area_zero() { + let mut buffer = Buffer::empty(Rect::new(0, 0, 0, 0)); + let mut state = ScrollbarState::default().position(0).content_length(1); + Scrollbar::default().render(buffer.area, &mut buffer, &mut state); + assert_buffer_eq!(buffer, Buffer::empty(buffer.area)); + } + + #[test] + fn test_no_render_when_height_zero_with_without_arrows() { + let mut buffer = Buffer::empty(Rect::new(0, 0, 3, 0)); + let mut state = ScrollbarState::default().position(0).content_length(1); + Scrollbar::default().render(buffer.area, &mut buffer, &mut state); + assert_buffer_eq!(buffer, Buffer::empty(buffer.area)); + + let mut buffer = Buffer::empty(Rect::new(0, 0, 3, 0)); + let mut state = ScrollbarState::default().position(0).content_length(1); + Scrollbar::default() + .begin_symbol(None) + .end_symbol(None) + .render(buffer.area, &mut buffer, &mut state); + assert_buffer_eq!(buffer, Buffer::empty(buffer.area)); + } + + #[test] + fn test_no_render_when_height_too_small_for_arrows() { + let mut buffer = Buffer::empty(Rect::new(0, 0, 4, 2)); + let mut state = ScrollbarState::default().position(0).content_length(1); + Scrollbar::default().render(buffer.area, &mut buffer, &mut state); + assert_buffer_eq!(buffer, Buffer::with_lines(vec![" ", " "])); + } + + #[test] + fn test_renders_all_thumbs_at_minimum_height_without_arrows() { + let mut buffer = Buffer::empty(Rect::new(0, 0, 4, 2)); + let mut state = ScrollbarState::default().position(0).content_length(1); + Scrollbar::default() + .begin_symbol(None) + .end_symbol(None) + .render(buffer.area, &mut buffer, &mut state); + assert_buffer_eq!(buffer, Buffer::with_lines(vec![" █", " █"])); + } + + #[test] + fn test_renders_all_thumbs_at_minimum_height_and_minimum_width_without_arrows() { + let mut buffer = Buffer::empty(Rect::new(0, 0, 1, 2)); + let mut state = ScrollbarState::default().position(0).content_length(1); + Scrollbar::default() + .begin_symbol(None) + .end_symbol(None) + .render(buffer.area, &mut buffer, &mut state); + assert_buffer_eq!(buffer, Buffer::with_lines(vec!["█", "█"])); + } + + #[test] + fn test_renders_two_arrows_one_thumb_at_minimum_height_with_arrows() { + let mut buffer = Buffer::empty(Rect::new(0, 0, 4, 3)); + let mut state = ScrollbarState::default().position(0).content_length(1); + Scrollbar::default().render(buffer.area, &mut buffer, &mut state); + assert_buffer_eq!(buffer, Buffer::with_lines(vec![" ▲", " █", " ▼"])); + } + + #[test] + fn test_no_render_when_content_length_zero() { + let mut buffer = Buffer::empty(Rect::new(0, 0, 2, 2)); + let mut state = ScrollbarState::default().position(0).content_length(0); + Scrollbar::default().render(buffer.area, &mut buffer, &mut state); + assert_buffer_eq!(buffer, Buffer::with_lines(vec![" ", " "])); + } + + #[test] + fn test_renders_all_thumbs_when_height_equals_content_length() { + let mut buffer = Buffer::empty(Rect::new(0, 0, 2, 2)); + let mut state = ScrollbarState::default().position(0).content_length(2); + Scrollbar::default() + .begin_symbol(None) + .end_symbol(None) + .render(buffer.area, &mut buffer, &mut state); + assert_buffer_eq!(buffer, Buffer::with_lines(vec![" █", " █"])); + + let mut buffer = Buffer::empty(Rect::new(0, 0, 2, 8)); + let mut state = ScrollbarState::default().position(0).content_length(8); + Scrollbar::default() + .begin_symbol(None) + .end_symbol(None) + .render(buffer.area, &mut buffer, &mut state); + assert_buffer_eq!( + buffer, + Buffer::with_lines(vec![" █", " █", " █", " █", " █", " █", " █", " █"]) + ); + } + + #[test] + fn test_renders_single_vertical_thumb_when_content_length_square_of_height() { + for i in 0..=17 { + let mut buffer = Buffer::empty(Rect::new(0, 0, 2, 4)); + let mut state = ScrollbarState::default().position(i).content_length(16); + Scrollbar::default() + .begin_symbol(None) + .end_symbol(None) + .render(buffer.area, &mut buffer, &mut state); + let expected = if i <= 2 { + vec![" █", " ║", " ║", " ║"] + } else if i <= 7 { + vec![" ║", " █", " ║", " ║"] + } else if i <= 13 { + vec![" ║", " ║", " █", " ║"] + } else { + vec![" ║", " ║", " ║", " █"] + }; + assert_buffer_eq!(buffer, Buffer::with_lines(expected.clone())); + } + } + + #[test] + fn test_renders_single_horizontal_thumb_when_content_length_square_of_width() { + for i in 0..=17 { + let mut buffer = Buffer::empty(Rect::new(0, 0, 4, 2)); + let mut state = ScrollbarState::default().position(i).content_length(16); + Scrollbar::default() + .begin_symbol(None) + .end_symbol(None) + .orientation(ScrollbarOrientation::HorizontalBottom) + .render(buffer.area, &mut buffer, &mut state); + let expected = if i <= 2 { + vec![" ", "█═══"] + } else if i <= 7 { + vec![" ", "═█══"] + } else if i <= 13 { + vec![" ", "══█═"] + } else { + vec![" ", "═══█"] + }; + assert_buffer_eq!(buffer, Buffer::with_lines(expected.clone())); + } + } + + #[test] + fn test_renders_one_thumb_for_large_content_relative_to_height() { + let mut buffer = Buffer::empty(Rect::new(0, 0, 4, 2)); + let mut state = ScrollbarState::default().position(0).content_length(1600); + Scrollbar::default() + .begin_symbol(None) + .end_symbol(None) + .orientation(ScrollbarOrientation::HorizontalBottom) + .render(buffer.area, &mut buffer, &mut state); + let expected = vec![" ", "█═══"]; + assert_buffer_eq!(buffer, Buffer::with_lines(expected.clone())); + + let mut buffer = Buffer::empty(Rect::new(0, 0, 4, 2)); + let mut state = ScrollbarState::default().position(800).content_length(1600); + Scrollbar::default() + .begin_symbol(None) + .end_symbol(None) + .orientation(ScrollbarOrientation::HorizontalBottom) + .render(buffer.area, &mut buffer, &mut state); + let expected = vec![" ", "══█═"]; + assert_buffer_eq!(buffer, Buffer::with_lines(expected.clone())); + } + + #[test] + fn test_renders_two_thumb_default_symbols_for_content_double_height() { + for i in 0..=7 { + let mut buffer = Buffer::empty(Rect::new(0, 0, 2, 4)); + let mut state = ScrollbarState::default().position(i).content_length(8); + Scrollbar::default() + .begin_symbol(None) + .end_symbol(None) + .render(buffer.area, &mut buffer, &mut state); + let expected = if i <= 1 { + vec![" █", " █", " ║", " ║"] + } else if i <= 5 { + vec![" ║", " █", " █", " ║"] + } else { + vec![" ║", " ║", " █", " █"] + }; + assert_buffer_eq!(buffer, Buffer::with_lines(expected.clone())); + } + } + + #[test] + fn test_renders_two_thumb_custom_symbols_for_content_double_height() { + for i in 0..=7 { + let mut buffer = Buffer::empty(Rect::new(0, 0, 2, 4)); + let mut state = ScrollbarState::default().position(i).content_length(8); + Scrollbar::default() + .symbols(VERTICAL) + .begin_symbol(None) + .end_symbol(None) + .render(buffer.area, &mut buffer, &mut state); + let expected = if i <= 1 { + vec![" █", " █", " │", " │"] + } else if i <= 5 { + vec![" │", " █", " █", " │"] + } else { + vec![" │", " │", " █", " █"] + }; + assert_buffer_eq!(buffer, Buffer::with_lines(expected.clone())); + } + } + + #[test] + fn test_renders_two_thumb_default_symbols_for_content_double_width() { + for i in 0..=7 { + let mut buffer = Buffer::empty(Rect::new(0, 0, 4, 2)); + let mut state = ScrollbarState::default().position(i).content_length(8); + Scrollbar::default() + .orientation(ScrollbarOrientation::HorizontalBottom) + .begin_symbol(None) + .end_symbol(None) + .render(buffer.area, &mut buffer, &mut state); + let expected = if i <= 1 { + vec![" ", "██══"] + } else if i <= 5 { + vec![" ", "═██═"] + } else { + vec![" ", "══██"] + }; + assert_buffer_eq!(buffer, Buffer::with_lines(expected.clone())); + } + } + + #[test] + fn test_renders_two_thumb_custom_symbols_for_content_double_width() { + for i in 0..=7 { + let mut buffer = Buffer::empty(Rect::new(0, 0, 4, 2)); + let mut state = ScrollbarState::default().position(i).content_length(8); + Scrollbar::default() + .orientation(ScrollbarOrientation::HorizontalBottom) + .symbols(HORIZONTAL) + .begin_symbol(None) + .end_symbol(None) + .render(buffer.area, &mut buffer, &mut state); + let expected = if i <= 1 { + vec![" ", "██──"] + } else if i <= 5 { + vec![" ", "─██─"] + } else { + vec![" ", "──██"] + }; + assert_buffer_eq!(buffer, Buffer::with_lines(expected.clone())); + } + } + + #[test] + fn test_rendering_viewport_content_length() { + for i in 0..=16 { + let mut buffer = Buffer::empty(Rect::new(0, 0, 8, 2)); + let mut state = ScrollbarState::default() + .position(i) + .content_length(16) + .viewport_content_length(4); + Scrollbar::default() + .orientation(ScrollbarOrientation::HorizontalBottom) + .begin_symbol(Some(DOUBLE_HORIZONTAL.begin)) + .end_symbol(Some(DOUBLE_HORIZONTAL.end)) + .render(buffer.area, &mut buffer, &mut state); + let expected = if i <= 1 { + vec![" ", "◄██════►"] + } else if i <= 5 { + vec![" ", "◄═██═══►"] + } else if i <= 9 { + vec![" ", "◄══██══►"] + } else if i <= 13 { + vec![" ", "◄═══██═►"] + } else { + vec![" ", "◄════██►"] + }; + assert_buffer_eq!(buffer, Buffer::with_lines(expected.clone())); + } + + for i in 0..=16 { + let mut buffer = Buffer::empty(Rect::new(0, 0, 8, 2)); + let mut state = ScrollbarState::default() + .position(i) + .content_length(16) + .viewport_content_length(1); + Scrollbar::default() + .orientation(ScrollbarOrientation::HorizontalBottom) + .begin_symbol(Some(DOUBLE_HORIZONTAL.begin)) + .end_symbol(Some(DOUBLE_HORIZONTAL.end)) + .render(buffer.area, &mut buffer, &mut state); + dbg!(i); + let expected = if i <= 1 { + vec![" ", "◄█═════►"] + } else if i <= 4 { + vec![" ", "◄═█════►"] + } else if i <= 7 { + vec![" ", "◄══█═══►"] + } else if i <= 11 { + vec![" ", "◄═══█══►"] + } else if i <= 14 { + vec![" ", "◄════█═►"] + } else { + vec![" ", "◄═════█►"] + }; + assert_buffer_eq!(buffer, Buffer::with_lines(expected.clone())); + } + } + + #[test] + fn test_rendering_begin_end_arrows_horizontal_bottom() { + for i in 0..=16 { + let mut buffer = Buffer::empty(Rect::new(0, 0, 8, 2)); + let mut state = ScrollbarState::default().position(i).content_length(16); + Scrollbar::default() + .orientation(ScrollbarOrientation::HorizontalBottom) + .begin_symbol(Some(DOUBLE_HORIZONTAL.begin)) + .end_symbol(Some(DOUBLE_HORIZONTAL.end)) + .render(buffer.area, &mut buffer, &mut state); + let expected = if i <= 1 { + vec![" ", "◄██════►"] + } else if i <= 5 { + vec![" ", "◄═██═══►"] + } else if i <= 9 { + vec![" ", "◄══██══►"] + } else if i <= 13 { + vec![" ", "◄═══██═►"] + } else { + vec![" ", "◄════██►"] + }; + assert_buffer_eq!(buffer, Buffer::with_lines(expected.clone())); + } + } + + #[test] + fn test_rendering_begin_end_arrows_horizontal_top() { + for i in 0..=16 { + let mut buffer = Buffer::empty(Rect::new(0, 0, 8, 2)); + let mut state = ScrollbarState::default().position(i).content_length(16); + Scrollbar::default() + .orientation(ScrollbarOrientation::HorizontalTop) + .begin_symbol(Some(DOUBLE_HORIZONTAL.begin)) + .end_symbol(Some(DOUBLE_HORIZONTAL.end)) + .render(buffer.area, &mut buffer, &mut state); + let expected = if i <= 1 { + vec!["◄██════►", " "] + } else if i <= 5 { + vec!["◄═██═══►", " "] + } else if i <= 9 { + vec!["◄══██══►", " "] + } else if i <= 13 { + vec!["◄═══██═►", " "] + } else { + vec!["◄════██►", " "] + }; + assert_buffer_eq!(buffer, Buffer::with_lines(expected.clone())); + } + } + + #[test] + fn test_rendering_only_begin_arrow_horizontal_bottom() { + for i in 0..=16 { + let mut buffer = Buffer::empty(Rect::new(0, 0, 8, 2)); + let mut state = ScrollbarState::default().position(i).content_length(16); + Scrollbar::default() + .orientation(ScrollbarOrientation::HorizontalBottom) + .begin_symbol(Some(DOUBLE_HORIZONTAL.begin)) + .end_symbol(None) + .render(buffer.area, &mut buffer, &mut state); + let expected = if i <= 1 { + vec![" ", "◄███════"] + } else if i <= 5 { + vec![" ", "◄═███═══"] + } else if i <= 9 { + vec![" ", "◄══███══"] + } else if i <= 13 { + vec![" ", "◄═══███═"] + } else { + vec![" ", "◄════███"] + }; + assert_buffer_eq!(buffer, Buffer::with_lines(expected.clone())); + } + } +}