use ratatui_core::buffer::{Buffer, Cell};
use ratatui_core::layout::Rect;
use ratatui_core::style::Style;
use crate::geometry::Size;
use crate::surface::Surface;
use crate::view::{Element, RenderCtx, View};
use crate::width::str_cols;
use super::ScrollState;
pub struct Viewport {
child: Element,
content: Size,
offset: usize,
x_offset: usize,
vertical_scrollbar: bool,
horizontal_scrollbar: bool,
}
impl Viewport {
pub fn new(child: Element, content: Size, state: &ScrollState) -> Self {
Self {
child,
content,
offset: state.offset(),
x_offset: state.x_offset(),
vertical_scrollbar: true,
horizontal_scrollbar: false,
}
}
pub fn vertical_scrollbar(mut self, show: bool) -> Self {
self.vertical_scrollbar = show;
self
}
pub fn horizontal_scrollbar(mut self, show: bool) -> Self {
self.horizontal_scrollbar = show;
self
}
pub fn content_size(&self) -> Size {
self.content
}
fn viewport_size(&self, area: Rect) -> Size {
let mut show_vertical =
self.vertical_scrollbar && self.content.height as usize > area.height as usize;
let mut show_horizontal =
self.horizontal_scrollbar && self.content.width as usize > area.width as usize;
for _ in 0..2 {
let width = area.width.saturating_sub(u16::from(show_vertical));
let height = area.height.saturating_sub(u16::from(show_horizontal));
show_vertical =
self.vertical_scrollbar && self.content.height as usize > height as usize;
show_horizontal =
self.horizontal_scrollbar && self.content.width as usize > width as usize;
}
Size::new(
area.width.saturating_sub(u16::from(show_vertical)),
area.height.saturating_sub(u16::from(show_horizontal)),
)
}
pub fn clamped_offsets(&self, area: Rect) -> (usize, usize) {
let viewport = self.viewport_size(area);
(
self.offset.min(ScrollState::max_offset(
self.content.height as usize,
viewport.height as usize,
)),
self.x_offset.min(ScrollState::max_x_offset(
self.content.width as usize,
viewport.width as usize,
)),
)
}
}
impl View for Viewport {
fn measure(&self, available: Size) -> Size {
Size::new(
self.content.width.min(available.width),
self.content.height.min(available.height),
)
}
fn render(&self, area: Rect, surface: &mut Surface, ctx: &RenderCtx) {
if area.is_empty() || self.content.width == 0 || self.content.height == 0 {
return;
}
let viewport = self.viewport_size(area);
let (offset, x_offset) = self.clamped_offsets(area);
if viewport.width == 0 || viewport.height == 0 {
return;
}
let source_area = Rect::new(
x_offset as u16,
offset as u16,
viewport
.width
.min(self.content.width.saturating_sub(x_offset as u16)),
viewport
.height
.min(self.content.height.saturating_sub(offset as u16)),
);
let mut scratch = Buffer::empty(source_area);
{
let content_area = Rect::new(0, 0, self.content.width, self.content.height);
let mut content_surface = Surface::new(&mut scratch, source_area);
self.child.render(content_area, &mut content_surface, ctx);
}
let destination =
Rect::new(area.x, area.y, viewport.width, viewport.height).intersection(surface.area());
let buffer = surface.buffer_mut();
for y in destination.y..destination.bottom() {
let source_y = offset + usize::from(y - area.y);
if source_y >= self.content.height as usize {
break;
}
for x in destination.x..destination.right() {
let source_x = x_offset + usize::from(x - area.x);
if source_x >= self.content.width as usize {
break;
}
let source = &scratch[(source_x as u16, source_y as u16)];
let remaining = destination.right().saturating_sub(x);
if str_cols(source.symbol()) > remaining {
buffer[(x, y)] = Cell::default();
} else {
buffer[(x, y)] = source.clone();
}
}
}
let vertical_overflow = self.content.height as usize > viewport.height as usize;
if vertical_overflow && self.vertical_scrollbar && viewport.width < area.width {
draw_scrollbar(
surface,
area.right() - 1,
area.y,
viewport.height,
self.content.height as usize,
offset,
false,
ctx,
);
}
let horizontal_overflow = self.content.width as usize > viewport.width as usize;
if horizontal_overflow && self.horizontal_scrollbar && viewport.height < area.height {
draw_scrollbar(
surface,
area.y + viewport.height,
area.x,
viewport.width,
self.content.width as usize,
x_offset,
true,
ctx,
);
}
}
}
#[allow(clippy::too_many_arguments)]
fn draw_scrollbar(
surface: &mut Surface,
fixed: u16,
start: u16,
length: u16,
content: usize,
offset: usize,
horizontal: bool,
ctx: &RenderCtx,
) {
if length == 0 {
return;
}
let length_u = length as usize;
let thumb = ((length_u * length_u) / content.max(1))
.max(1)
.min(length_u) as u16;
let travel = length.saturating_sub(thumb);
let max_offset = content.saturating_sub(length_u).max(1);
let thumb_start = start + ((offset * travel as usize) / max_offset) as u16;
for index in 0..length {
let position = start + index;
let within = position >= thumb_start && position < thumb_start.saturating_add(thumb);
let (glyph, style) = if within {
(
if horizontal { '━' } else { '█' },
Style::default().fg(ctx.theme.muted),
)
} else {
(
if horizontal { '─' } else { '│' },
Style::default().fg(ctx.theme.dim),
)
};
let (x, y) = if horizontal {
(position, fixed)
} else {
(fixed, position)
};
surface.set(x, y, glyph, style);
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::components::Text;
use crate::testing::{grid, render};
use crate::{Theme, element};
use ratatui_core::text::Line;
#[test]
fn viewport_clamps_offsets_and_crops_arbitrary_child() {
let mut state = ScrollState::new();
state.set_offset(99);
state.set_x_offset(99);
let child = Text::new(vec![
Line::from("abcdef"),
Line::from("ghijkl"),
Line::from("mnopqr"),
]);
let view = Viewport::new(element(child), Size::new(6, 3), &state).vertical_scrollbar(false);
assert_eq!(view.clamped_offsets(Rect::new(0, 0, 3, 2)), (1, 3));
assert_eq!(grid(&render(&view, 3, 2, &Theme::default())), "jkl\npqr");
}
#[test]
fn viewport_does_not_split_wide_cell_at_right_edge() {
let state = ScrollState::default();
let view = Viewport::new(element(Text::raw("a界")), Size::new(3, 1), &state)
.vertical_scrollbar(false);
assert_eq!(grid(&render(&view, 2, 1, &Theme::default())), "a ");
}
#[test]
fn viewport_handles_all_tiny_sizes() {
let state = ScrollState::default();
for width in 0..=2 {
for height in 0..=2 {
let view = Viewport::new(element(Text::raw("content")), Size::new(7, 1), &state)
.horizontal_scrollbar(true);
let _ = render(&view, width, height, &Theme::default());
}
}
}
#[test]
fn viewport_reclamps_after_resize() {
let mut state = ScrollState::default();
state.set_offset(8);
state.set_x_offset(8);
let view = Viewport::new(element(Text::raw("0123456789")), Size::new(10, 10), &state)
.vertical_scrollbar(false);
assert_eq!(view.clamped_offsets(Rect::new(0, 0, 2, 2)), (8, 8));
assert_eq!(view.clamped_offsets(Rect::new(0, 0, 8, 8)), (2, 2));
}
#[test]
fn one_scrollbar_can_trigger_the_other_axis() {
let state = ScrollState::default();
let view = Viewport::new(element(Text::raw("12345")), Size::new(5, 2), &state)
.horizontal_scrollbar(true);
assert_eq!(view.viewport_size(Rect::new(0, 0, 4, 2)), Size::new(3, 1));
}
}