use ratatui_core::buffer::Buffer;
use ratatui_core::layout::Rect;
use ratatui_core::style::Style;
use ratatui_core::text::Line;
use ratatui_core::widgets::Widget;
use ratatui_widgets::block::{Block, Padding};
use ratatui_widgets::borders::{BorderType, Borders};
use crate::component::Component;
use crate::insets::Insets;
#[derive(typed_builder::TypedBuilder)]
pub struct Viewport {
#[builder(setter(into))]
pub lines: Vec<String>,
pub height: u16,
#[builder(default, setter(into))]
pub border: Option<BorderType>,
#[builder(default, setter(into))]
pub title: Option<String>,
#[builder(default, setter(into))]
pub style: Style,
#[builder(default, setter(into))]
pub border_style: Style,
#[builder(default = true)]
pub wrap: bool,
}
impl Viewport {
fn border_insets(&self) -> Insets {
let has_border = self.border.is_some();
let b: u16 = if has_border { 1 } else { 0 };
Insets {
top: b,
right: b,
bottom: b,
left: b,
}
}
fn build_block(&self) -> Block<'static> {
let mut block = Block::default();
if let Some(border_type) = self.border {
let borders = Borders::ALL;
block = block
.border_type(border_type)
.border_style(self.border_style)
.borders(borders);
}
if let Some(ref title) = self.title
&& self.border.is_some()
{
block = block.title_top(Line::from(format!(" {title} ")));
}
block.padding(Padding::ZERO)
}
}
fn wrap_line<'a>(line: &'a str, max_width: usize, out: &mut Vec<&'a str>) {
use unicode_width::UnicodeWidthStr;
let mut remaining = line;
while !remaining.is_empty() {
if UnicodeWidthStr::width(remaining) <= max_width {
out.push(remaining);
break;
}
let break_byte = str_byte_offset_at_width(remaining, max_width);
let window = &remaining[..break_byte];
if let Some(last_space) = window.rfind(' ') {
out.push(&remaining[..last_space + 1]);
remaining = &remaining[last_space + 1..];
} else {
out.push(window);
remaining = &remaining[break_byte..];
}
}
}
fn str_byte_offset_at_width(s: &str, max_width: usize) -> usize {
use unicode_width::UnicodeWidthChar;
let mut cols = 0;
for (byte_offset, ch) in s.char_indices() {
let w = UnicodeWidthChar::width(ch).unwrap_or(0);
if cols + w > max_width {
return byte_offset;
}
cols += w;
}
s.len()
}
impl Component for Viewport {
type State = ();
fn render(&self, area: Rect, buf: &mut Buffer, _state: &()) {
let block = self.build_block();
block.render(area, buf);
let insets = self.border_insets();
let inner = Rect::new(
area.x.saturating_add(insets.left),
area.y.saturating_add(insets.top),
area.width.saturating_sub(insets.horizontal()),
area.height.saturating_sub(insets.vertical()),
);
if inner.width == 0 || inner.height == 0 {
return;
}
let max_width = inner.width as usize;
let mut screen_lines: Vec<&str> = Vec::new();
for line_text in &self.lines {
if unicode_width::UnicodeWidthStr::width(line_text.as_str()) <= max_width {
screen_lines.push(line_text.as_str());
} else if self.wrap {
wrap_line(line_text, max_width, &mut screen_lines);
} else {
let break_byte = str_byte_offset_at_width(line_text, max_width);
screen_lines.push(&line_text[..break_byte]);
}
}
let visible_count = inner.height as usize;
let start = if screen_lines.len() > visible_count {
screen_lines.len() - visible_count
} else {
0
};
for (i, text) in screen_lines[start..].iter().enumerate() {
let row = inner.y + i as u16;
if row >= inner.y + inner.height {
break;
}
buf.set_string(inner.x, row, text, self.style);
}
}
fn desired_height(&self, _width: u16, _state: &()) -> Option<u16> {
let insets = self.border_insets();
Some(self.height + insets.vertical())
}
fn content_inset(&self, _state: &()) -> Insets {
self.border_insets()
}
}
#[cfg(test)]
mod tests {
use super::*;
use ratatui_core::style::Color;
#[test]
fn viewport_renders_last_n_lines() {
let viewport = Viewport::builder()
.lines(vec![
"line 1".into(),
"line 2".into(),
"line 3".into(),
"line 4".into(),
"line 5".into(),
])
.height(3)
.build();
let area = Rect::new(0, 0, 20, 3);
let mut buf = Buffer::empty(area);
viewport.render(area, &mut buf, &());
assert_eq!(buf.cell((0, 0)).unwrap().symbol(), "l"); assert_eq!(buf.cell((5, 0)).unwrap().symbol(), "3");
assert_eq!(buf.cell((5, 1)).unwrap().symbol(), "4");
assert_eq!(buf.cell((5, 2)).unwrap().symbol(), "5");
}
#[test]
fn viewport_fewer_lines_than_height() {
let viewport = Viewport::builder()
.lines(vec!["only one".into()])
.height(5)
.build();
let area = Rect::new(0, 0, 20, 5);
let mut buf = Buffer::empty(area);
viewport.render(area, &mut buf, &());
assert_eq!(buf.cell((0, 0)).unwrap().symbol(), "o");
assert_eq!(buf.cell((0, 1)).unwrap().symbol(), " ");
}
#[test]
fn viewport_with_border() {
let viewport = Viewport::builder()
.lines(vec!["hello".into()])
.height(3)
.border(BorderType::Plain)
.build();
let area = Rect::new(0, 0, 20, 5); let mut buf = Buffer::empty(area);
viewport.render(area, &mut buf, &());
let top_left = buf.cell((0, 0)).unwrap().symbol();
assert!(top_left == "┌" || top_left == "+" || top_left == " ");
assert_eq!(buf.cell((1, 1)).unwrap().symbol(), "h");
}
#[test]
fn viewport_desired_height_includes_border() {
let viewport = Viewport::builder()
.lines(vec![])
.height(10)
.border(BorderType::Plain)
.build();
assert_eq!(viewport.desired_height(80, &()), Some(12));
}
#[test]
fn viewport_wraps_long_lines() {
let viewport = Viewport::builder()
.lines(vec![
"this is a very long line that should be wrapped".into(),
])
.height(3)
.build();
let area = Rect::new(0, 0, 10, 3);
let mut buf = Buffer::empty(area);
viewport.render(area, &mut buf, &());
assert_eq!(buf.cell((0, 0)).unwrap().symbol(), "l"); assert_eq!(buf.cell((0, 1)).unwrap().symbol(), "s"); assert_eq!(buf.cell((0, 2)).unwrap().symbol(), "w"); }
#[test]
fn viewport_hard_wraps_long_words() {
let viewport = Viewport::builder()
.lines(vec!["abcdefghijklmnopqrstuvwxyz".into()])
.height(3)
.build();
let area = Rect::new(0, 0, 10, 3);
let mut buf = Buffer::empty(area);
viewport.render(area, &mut buf, &());
assert_eq!(buf.cell((0, 0)).unwrap().symbol(), "a");
assert_eq!(buf.cell((0, 1)).unwrap().symbol(), "k");
assert_eq!(buf.cell((0, 2)).unwrap().symbol(), "u");
}
#[test]
fn viewport_empty_lines() {
let viewport = Viewport::builder().lines(vec![]).height(5).build();
let area = Rect::new(0, 0, 20, 5);
let mut buf = Buffer::empty(area);
viewport.render(area, &mut buf, &());
assert_eq!(buf.cell((0, 0)).unwrap().symbol(), " ");
}
#[test]
fn viewport_applies_style() {
let viewport = Viewport::builder()
.lines(vec!["styled".into()])
.height(1)
.style(Style::default().fg(Color::Red))
.build();
let area = Rect::new(0, 0, 10, 1);
let mut buf = Buffer::empty(area);
viewport.render(area, &mut buf, &());
assert_eq!(buf.cell((0, 0)).unwrap().fg, Color::Red);
}
}