use ratatui_core::buffer::Buffer;
use ratatui_core::layout::{Rect, Size};
use ratatui_core::widgets::{StatefulWidget, Widget};
use ratatui_widgets::scrollbar::{Scrollbar, ScrollbarOrientation, ScrollbarState};
use crate::ScrollViewState;
#[derive(Debug, Default, Clone, Eq, PartialEq, Hash)]
pub struct ScrollView {
buf: Buffer,
size: Size,
vertical_scrollbar_visibility: ScrollbarVisibility,
horizontal_scrollbar_visibility: ScrollbarVisibility,
}
#[derive(Debug, Default, Clone, Copy, Eq, PartialEq, Hash)]
pub enum ScrollbarVisibility {
#[default]
Automatic,
Always,
Never,
}
impl ScrollView {
pub fn new(size: Size) -> Self {
let area = Rect::new(0, 0, size.width, size.height);
Self {
buf: Buffer::empty(area),
size,
horizontal_scrollbar_visibility: ScrollbarVisibility::default(),
vertical_scrollbar_visibility: ScrollbarVisibility::default(),
}
}
pub const fn size(&self) -> Size {
self.size
}
pub const fn area(&self) -> Rect {
self.buf.area
}
pub const fn buf(&self) -> &Buffer {
&self.buf
}
pub const fn buf_mut(&mut self) -> &mut Buffer {
&mut self.buf
}
pub const fn vertical_scrollbar_visibility(mut self, visibility: ScrollbarVisibility) -> Self {
self.vertical_scrollbar_visibility = visibility;
self
}
pub const fn horizontal_scrollbar_visibility(
mut self,
visibility: ScrollbarVisibility,
) -> Self {
self.horizontal_scrollbar_visibility = visibility;
self
}
pub const fn scrollbars_visibility(mut self, visibility: ScrollbarVisibility) -> Self {
self.vertical_scrollbar_visibility = visibility;
self.horizontal_scrollbar_visibility = visibility;
self
}
pub fn render_widget<W: Widget>(&mut self, widget: W, area: Rect) {
widget.render(area, &mut self.buf);
}
pub fn render_stateful_widget<W: StatefulWidget>(
&mut self,
widget: W,
area: Rect,
state: &mut W::State,
) {
widget.render(area, &mut self.buf, state);
}
}
impl StatefulWidget for ScrollView {
type State = ScrollViewState;
fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
let (mut x, mut y) = state.offset.into();
let max_x_offset = self
.buf
.area
.width
.saturating_sub(area.width.saturating_sub(1));
let max_y_offset = self
.buf
.area
.height
.saturating_sub(area.height.saturating_sub(1));
x = x.min(max_x_offset);
y = y.min(max_y_offset);
state.offset = (x, y).into();
state.size = Some(self.size);
state.page_size = Some(area.into());
let visible_area = self
.render_scrollbars(area, buf, state)
.intersection(self.buf.area);
self.render_visible_area(area, buf, visible_area);
}
}
impl ScrollView {
fn render_scrollbars(&self, area: Rect, buf: &mut Buffer, state: &mut ScrollViewState) -> Rect {
let horizontal_space = area.width as i32 - self.size.width as i32;
let vertical_space = area.height as i32 - self.size.height as i32;
if horizontal_space > 0 {
state.offset.x = 0;
}
if vertical_space > 0 {
state.offset.y = 0;
}
let (show_horizontal, show_vertical) =
self.visible_scrollbars(horizontal_space, vertical_space);
let new_height = if show_horizontal {
let width = area.width.saturating_sub(show_vertical as u16);
let render_area = Rect { width, ..area };
self.render_horizontal_scrollbar(render_area, buf, state);
area.height.saturating_sub(1)
} else {
area.height
};
let new_width = if show_vertical {
let height = area.height.saturating_sub(show_horizontal as u16);
let render_area = Rect { height, ..area };
self.render_vertical_scrollbar(render_area, buf, state);
area.width.saturating_sub(1)
} else {
area.width
};
Rect::new(state.offset.x, state.offset.y, new_width, new_height)
}
const fn visible_scrollbars(&self, horizontal_space: i32, vertical_space: i32) -> (bool, bool) {
type V = crate::scroll_view::ScrollbarVisibility;
match (
self.horizontal_scrollbar_visibility,
self.vertical_scrollbar_visibility,
) {
(V::Always, V::Always) => (true, true),
(V::Never, V::Never) => (false, false),
(V::Always, V::Never) => (true, false),
(V::Never, V::Always) => (false, true),
(V::Automatic, V::Never) => (horizontal_space < 0, false),
(V::Never, V::Automatic) => (false, vertical_space < 0),
(V::Always, V::Automatic) => (true, vertical_space <= 0),
(V::Automatic, V::Always) => (horizontal_space <= 0, true),
(V::Automatic, V::Automatic) => {
if horizontal_space >= 0 && vertical_space >= 0 {
(false, false)
} else if horizontal_space < 0 && vertical_space < 0 {
(true, true)
} else if horizontal_space > 0 && vertical_space < 0 {
(false, true)
} else if horizontal_space < 0 && vertical_space > 0 {
(true, false)
} else {
(true, true)
}
}
}
}
fn render_vertical_scrollbar(&self, area: Rect, buf: &mut Buffer, state: &ScrollViewState) {
let scrollbar_height = self.size.height.saturating_sub(area.height);
let mut scrollbar_state =
ScrollbarState::new(scrollbar_height as usize).position(state.offset.y as usize);
let scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight);
scrollbar.render(area, buf, &mut scrollbar_state);
}
fn render_horizontal_scrollbar(&self, area: Rect, buf: &mut Buffer, state: &ScrollViewState) {
let scrollbar_width = self.size.width.saturating_sub(area.width);
let mut scrollbar_state =
ScrollbarState::new(scrollbar_width as usize).position(state.offset.x as usize);
let scrollbar = Scrollbar::new(ScrollbarOrientation::HorizontalBottom);
scrollbar.render(area, buf, &mut scrollbar_state);
}
fn render_visible_area(&self, area: Rect, buf: &mut Buffer, visible_area: Rect) {
for (src_row, dst_row) in visible_area.rows().zip(area.rows()) {
for (src_col, dst_col) in src_row.columns().zip(dst_row.columns()) {
buf[dst_col] = self.buf[src_col].clone();
}
}
}
}
#[cfg(test)]
mod tests {
use ratatui_core::text::Span;
use rstest::{fixture, rstest};
use super::*;
#[fixture]
fn scroll_view() -> ScrollView {
let mut scroll_view = ScrollView::new(Size::new(10, 10));
for y in 0..10 {
for x in 0..10 {
let c = char::from_u32((x + y * 10) % 26 + 65).unwrap();
let widget = Span::raw(format!("{c}"));
let area = Rect::new(x as u16, y as u16, 1, 1);
scroll_view.render_widget(widget, area);
}
}
scroll_view
}
#[rstest]
fn zero_offset(scroll_view: ScrollView) {
let mut buf = Buffer::empty(Rect::new(0, 0, 6, 6));
let mut state = ScrollViewState::default();
scroll_view.render(buf.area, &mut buf, &mut state);
assert_eq!(
buf,
Buffer::with_lines(vec![
"ABCDE▲",
"KLMNO█",
"UVWXY█",
"EFGHI║",
"OPQRS▼",
"◄██═► ",
])
)
}
#[rstest]
fn move_right(scroll_view: ScrollView) {
let mut buf = Buffer::empty(Rect::new(0, 0, 6, 6));
let mut state = ScrollViewState::with_offset((3, 0).into());
scroll_view.render(buf.area, &mut buf, &mut state);
assert_eq!(
buf,
Buffer::with_lines(vec![
"DEFGH▲",
"NOPQR█",
"XYZAB█",
"HIJKL║",
"RSTUV▼",
"◄═██► ",
])
)
}
#[rstest]
fn move_down(scroll_view: ScrollView) {
let mut buf = Buffer::empty(Rect::new(0, 0, 6, 6));
let mut state = ScrollViewState::with_offset((0, 3).into());
scroll_view.render(buf.area, &mut buf, &mut state);
assert_eq!(
buf,
Buffer::with_lines(vec![
"EFGHI▲",
"OPQRS║",
"YZABC█",
"IJKLM█",
"STUVW▼",
"◄██═► ",
])
)
}
#[rstest]
fn hides_both_scrollbars(scroll_view: ScrollView) {
let mut buf = Buffer::empty(Rect::new(0, 0, 10, 10));
let mut state = ScrollViewState::new();
scroll_view.render(buf.area, &mut buf, &mut state);
assert_eq!(
buf,
Buffer::with_lines(vec![
"ABCDEFGHIJ",
"KLMNOPQRST",
"UVWXYZABCD",
"EFGHIJKLMN",
"OPQRSTUVWX",
"YZABCDEFGH",
"IJKLMNOPQR",
"STUVWXYZAB",
"CDEFGHIJKL",
"MNOPQRSTUV",
])
)
}
#[rstest]
fn hides_horizontal_scrollbar(scroll_view: ScrollView) {
let mut buf = Buffer::empty(Rect::new(0, 0, 11, 9));
let mut state = ScrollViewState::new();
scroll_view.render(buf.area, &mut buf, &mut state);
assert_eq!(
buf,
Buffer::with_lines(vec![
"ABCDEFGHIJ▲",
"KLMNOPQRST█",
"UVWXYZABCD█",
"EFGHIJKLMN█",
"OPQRSTUVWX█",
"YZABCDEFGH█",
"IJKLMNOPQR█",
"STUVWXYZAB█",
"CDEFGHIJKL▼",
])
)
}
#[rstest]
fn hides_vertical_scrollbar(scroll_view: ScrollView) {
let mut buf = Buffer::empty(Rect::new(0, 0, 9, 11));
let mut state = ScrollViewState::new();
scroll_view.render(buf.area, &mut buf, &mut state);
assert_eq!(
buf,
Buffer::with_lines(vec![
"ABCDEFGHI",
"KLMNOPQRS",
"UVWXYZABC",
"EFGHIJKLM",
"OPQRSTUVW",
"YZABCDEFG",
"IJKLMNOPQ",
"STUVWXYZA",
"CDEFGHIJK",
"MNOPQRSTU",
"◄███████►",
])
)
}
#[rstest]
fn does_not_hide_horizontal_scrollbar(scroll_view: ScrollView) {
let mut buf = Buffer::empty(Rect::new(0, 0, 10, 9));
let mut state = ScrollViewState::new();
scroll_view.render(buf.area, &mut buf, &mut state);
assert_eq!(
buf,
Buffer::with_lines(vec![
"ABCDEFGHI▲",
"KLMNOPQRS█",
"UVWXYZABC█",
"EFGHIJKLM█",
"OPQRSTUVW█",
"YZABCDEFG█",
"IJKLMNOPQ║",
"STUVWXYZA▼",
"◄███████► ",
])
)
}
#[rstest]
fn does_not_hide_vertical_scrollbar(scroll_view: ScrollView) {
let mut buf = Buffer::empty(Rect::new(0, 0, 9, 10));
let mut state = ScrollViewState::new();
scroll_view.render(buf.area, &mut buf, &mut state);
assert_eq!(
buf,
Buffer::with_lines(vec![
"ABCDEFGH▲",
"KLMNOPQR█",
"UVWXYZAB█",
"EFGHIJKL█",
"OPQRSTUV█",
"YZABCDEF█",
"IJKLMNOP█",
"STUVWXYZ█",
"CDEFGHIJ▼",
"◄█████═► ",
])
)
}
#[rstest]
fn ensure_buffer_offset_is_correct(scroll_view: ScrollView) {
let mut buf = Buffer::empty(Rect::new(0, 0, 20, 20));
let mut state = ScrollViewState::with_offset((2, 3).into());
scroll_view.render(Rect::new(5, 6, 7, 8), &mut buf, &mut state);
assert_eq!(
buf,
Buffer::with_lines(vec![
" ",
" ",
" ",
" ",
" ",
" ",
" GHIJKL▲ ",
" QRSTUV║ ",
" ABCDEF█ ",
" KLMNOP█ ",
" UVWXYZ█ ",
" EFGHIJ█ ",
" OPQRST▼ ",
" ◄═███► ",
" ",
" ",
" ",
" ",
" ",
" ",
])
)
}
#[rstest]
fn ensure_buffer_last_elements(scroll_view: ScrollView) {
let mut buf = Buffer::empty(Rect::new(0, 0, 6, 6));
let mut state = ScrollViewState::with_offset((5, 5).into());
scroll_view.render(buf.area, &mut buf, &mut state);
assert_eq!(
buf,
Buffer::with_lines(vec![
"DEFGH▲",
"NOPQR║",
"XYZAB█",
"HIJKL█",
"RSTUV▼",
"◄═██► ",
])
)
}
#[rstest]
fn zero_width(scroll_view: ScrollView) {
let mut buf = Buffer::empty(Rect::new(0, 0, 0, 10));
let mut state = ScrollViewState::new();
scroll_view.render(buf.area, &mut buf, &mut state);
assert_eq!(buf, Buffer::empty(Rect::new(0, 0, 0, 10)));
}
#[rstest]
fn zero_height(scroll_view: ScrollView) {
let mut buf = Buffer::empty(Rect::new(0, 0, 10, 0));
let mut state = ScrollViewState::new();
scroll_view.render(buf.area, &mut buf, &mut state);
assert_eq!(buf, Buffer::empty(Rect::new(0, 0, 10, 0)));
}
#[rstest]
fn never_vertical_scrollbar(mut scroll_view: ScrollView) {
scroll_view = scroll_view.vertical_scrollbar_visibility(ScrollbarVisibility::Never);
let mut buf = Buffer::empty(Rect::new(0, 0, 11, 9));
let mut state = ScrollViewState::new();
scroll_view.render(buf.area, &mut buf, &mut state);
assert_eq!(
buf,
Buffer::with_lines(vec![
"ABCDEFGHIJ ",
"KLMNOPQRST ",
"UVWXYZABCD ",
"EFGHIJKLMN ",
"OPQRSTUVWX ",
"YZABCDEFGH ",
"IJKLMNOPQR ",
"STUVWXYZAB ",
"CDEFGHIJKL ",
])
)
}
#[rstest]
fn never_horizontal_scrollbar(mut scroll_view: ScrollView) {
scroll_view = scroll_view.horizontal_scrollbar_visibility(ScrollbarVisibility::Never);
let mut buf = Buffer::empty(Rect::new(0, 0, 9, 11));
let mut state = ScrollViewState::new();
scroll_view.render(buf.area, &mut buf, &mut state);
assert_eq!(
buf,
Buffer::with_lines(vec![
"ABCDEFGHI",
"KLMNOPQRS",
"UVWXYZABC",
"EFGHIJKLM",
"OPQRSTUVW",
"YZABCDEFG",
"IJKLMNOPQ",
"STUVWXYZA",
"CDEFGHIJK",
"MNOPQRSTU",
" ",
])
)
}
#[rstest]
fn does_not_trigger_horizontal_scrollbar(mut scroll_view: ScrollView) {
scroll_view = scroll_view.vertical_scrollbar_visibility(ScrollbarVisibility::Never);
let mut buf = Buffer::empty(Rect::new(0, 0, 10, 9));
let mut state = ScrollViewState::new();
scroll_view.render(buf.area, &mut buf, &mut state);
assert_eq!(
buf,
Buffer::with_lines(vec![
"ABCDEFGHIJ",
"KLMNOPQRST",
"UVWXYZABCD",
"EFGHIJKLMN",
"OPQRSTUVWX",
"YZABCDEFGH",
"IJKLMNOPQR",
"STUVWXYZAB",
"CDEFGHIJKL",
])
)
}
#[rstest]
fn does_not_trigger_vertical_scrollbar(mut scroll_view: ScrollView) {
scroll_view = scroll_view.horizontal_scrollbar_visibility(ScrollbarVisibility::Never);
let mut buf = Buffer::empty(Rect::new(0, 0, 9, 10));
let mut state = ScrollViewState::new();
scroll_view.render(buf.area, &mut buf, &mut state);
assert_eq!(
buf,
Buffer::with_lines(vec![
"ABCDEFGHI",
"KLMNOPQRS",
"UVWXYZABC",
"EFGHIJKLM",
"OPQRSTUVW",
"YZABCDEFG",
"IJKLMNOPQ",
"STUVWXYZA",
"CDEFGHIJK",
"MNOPQRSTU",
])
)
}
#[rstest]
fn does_not_render_vertical_scrollbar(mut scroll_view: ScrollView) {
scroll_view = scroll_view.vertical_scrollbar_visibility(ScrollbarVisibility::Never);
let mut buf = Buffer::empty(Rect::new(0, 0, 6, 6));
let mut state = ScrollViewState::default();
scroll_view.render(buf.area, &mut buf, &mut state);
assert_eq!(
buf,
Buffer::with_lines(vec![
"ABCDEF",
"KLMNOP",
"UVWXYZ",
"EFGHIJ",
"OPQRST",
"◄███═►",
])
)
}
#[rstest]
fn does_not_render_horizontal_scrollbar(mut scroll_view: ScrollView) {
scroll_view = scroll_view.horizontal_scrollbar_visibility(ScrollbarVisibility::Never);
let mut buf = Buffer::empty(Rect::new(0, 0, 7, 6));
let mut state = ScrollViewState::default();
scroll_view.render(buf.area, &mut buf, &mut state);
assert_eq!(
buf,
Buffer::with_lines(vec![
"ABCDEF▲",
"KLMNOP█",
"UVWXYZ█",
"EFGHIJ█",
"OPQRST║",
"YZABCD▼",
])
)
}
#[rstest]
#[rustfmt::skip]
fn does_not_render_both_scrollbars(mut scroll_view: ScrollView) {
scroll_view = scroll_view.scrollbars_visibility(ScrollbarVisibility::Never);
let mut buf = Buffer::empty(Rect::new(0, 0, 6, 6));
let mut state = ScrollViewState::default();
scroll_view.render(buf.area, &mut buf, &mut state);
assert_eq!(
buf,
Buffer::with_lines(vec![
"ABCDEF",
"KLMNOP",
"UVWXYZ",
"EFGHIJ",
"OPQRST",
"YZABCD",
])
)
}
#[rstest]
#[rustfmt::skip]
fn render_stateful_widget(mut scroll_view: ScrollView) {
use ratatui_widgets::list::{List, ListState};
scroll_view = scroll_view.horizontal_scrollbar_visibility(ScrollbarVisibility::Never);
let mut buf = Buffer::empty(Rect::new(0, 0, 7, 5));
let mut state = ScrollViewState::default();
let mut list_state = ListState::default();
let items: Vec<String> = (1..=10).map(|i| format!("Item {i}")).collect();
let list = List::new(items);
scroll_view.render_stateful_widget(list, scroll_view.area(), &mut list_state);
scroll_view.render(buf.area, &mut buf, &mut state);
assert_eq!(
buf,
Buffer::with_lines(vec![
"Item 1▲",
"Item 2█",
"Item 3█",
"Item 4║",
"Item 5▼",
])
)
}
}