use crate::key::{Binding, matches};
use bubbletea::{Cmd, KeyMsg, Message, Model, MouseMsg};
use lipgloss::Style;
use unicode_width::UnicodeWidthChar;
#[derive(Debug, Clone)]
pub struct KeyMap {
pub page_down: Binding,
pub page_up: Binding,
pub half_page_up: Binding,
pub half_page_down: Binding,
pub down: Binding,
pub up: Binding,
pub left: Binding,
pub right: Binding,
}
impl Default for KeyMap {
fn default() -> Self {
Self {
page_down: Binding::new()
.keys(&["pgdown", " ", "f"])
.help("f/pgdn", "page down"),
page_up: Binding::new()
.keys(&["pgup", "b"])
.help("b/pgup", "page up"),
half_page_up: Binding::new().keys(&["u", "ctrl+u"]).help("u", "½ page up"),
half_page_down: Binding::new()
.keys(&["d", "ctrl+d"])
.help("d", "½ page down"),
up: Binding::new().keys(&["up", "k"]).help("↑/k", "up"),
down: Binding::new().keys(&["down", "j"]).help("↓/j", "down"),
left: Binding::new().keys(&["left", "h"]).help("←/h", "move left"),
right: Binding::new()
.keys(&["right", "l"])
.help("→/l", "move right"),
}
}
}
#[derive(Debug, Clone)]
pub struct Viewport {
pub width: usize,
pub height: usize,
pub key_map: KeyMap,
pub mouse_wheel_enabled: bool,
pub mouse_wheel_delta: usize,
y_offset: usize,
x_offset: usize,
horizontal_step: usize,
pub style: Style,
lines: Vec<String>,
longest_line_width: usize,
}
impl Viewport {
#[must_use]
pub fn new(width: usize, height: usize) -> Self {
Self {
width,
height,
key_map: KeyMap::default(),
mouse_wheel_enabled: true,
mouse_wheel_delta: 3,
y_offset: 0,
x_offset: 0,
horizontal_step: 0,
style: Style::new(),
lines: Vec::new(),
longest_line_width: 0,
}
}
pub fn set_content(&mut self, content: &str) {
let normalized = content.replace("\r\n", "\n");
self.lines = normalized.split('\n').map(String::from).collect();
self.longest_line_width = self
.lines
.iter()
.map(|l| visible_width(l))
.max()
.unwrap_or(0);
if self.y_offset > self.lines.len().saturating_sub(1) {
self.goto_bottom();
}
}
#[must_use]
pub fn y_offset(&self) -> usize {
self.y_offset
}
pub fn set_y_offset(&mut self, n: usize) {
self.y_offset = n.min(self.max_y_offset());
}
#[must_use]
pub fn x_offset(&self) -> usize {
self.x_offset
}
pub fn set_x_offset(&mut self, n: usize) {
self.x_offset = n.min(self.longest_line_width.saturating_sub(self.width));
}
pub fn set_horizontal_step(&mut self, n: usize) {
self.horizontal_step = n;
}
#[must_use]
pub fn at_top(&self) -> bool {
self.y_offset == 0
}
#[must_use]
pub fn at_bottom(&self) -> bool {
self.y_offset >= self.max_y_offset()
}
#[must_use]
pub fn past_bottom(&self) -> bool {
self.y_offset > self.max_y_offset()
}
#[must_use]
pub fn scroll_percent(&self) -> f64 {
if self.height >= self.lines.len() {
return 1.0;
}
let y = self.y_offset as f64;
let h = self.height as f64;
let t = self.lines.len() as f64;
let v = y / (t - h);
v.clamp(0.0, 1.0)
}
#[must_use]
pub fn horizontal_scroll_percent(&self) -> f64 {
if self.longest_line_width <= self.width {
return 1.0;
}
let x = self.x_offset as f64;
let scrollable = (self.longest_line_width - self.width) as f64;
let v = x / scrollable;
v.clamp(0.0, 1.0)
}
#[must_use]
pub fn total_line_count(&self) -> usize {
self.lines.len()
}
#[must_use]
pub fn visible_line_count(&self) -> usize {
self.visible_lines().len()
}
fn max_y_offset(&self) -> usize {
self.lines.len().saturating_sub(self.content_height())
}
fn visible_lines(&self) -> Vec<String> {
if self.lines.is_empty() {
return Vec::new();
}
let content_height = self.content_height();
if content_height == 0 {
return Vec::new();
}
let top = self.y_offset.min(self.lines.len());
let bottom = top.saturating_add(content_height).min(self.lines.len());
let visible = &self.lines[top..bottom];
let content_width = self.content_width();
if (self.x_offset == 0 && self.longest_line_width <= content_width) || content_width == 0 {
return visible.to_vec();
}
visible
.iter()
.map(|line| cut_line(line, self.x_offset, content_width))
.collect()
}
pub fn scroll_down(&mut self, n: usize) {
if self.at_bottom() || n == 0 || self.lines.is_empty() {
return;
}
self.set_y_offset(self.y_offset + n);
}
pub fn scroll_up(&mut self, n: usize) {
if self.at_top() || n == 0 || self.lines.is_empty() {
return;
}
self.set_y_offset(self.y_offset.saturating_sub(n));
}
pub fn scroll_left(&mut self, n: usize) {
self.set_x_offset(self.x_offset.saturating_sub(n));
}
pub fn scroll_right(&mut self, n: usize) {
self.set_x_offset(self.x_offset + n);
}
pub fn page_down(&mut self) {
if !self.at_bottom() {
self.scroll_down(self.height);
}
}
pub fn page_up(&mut self) {
if !self.at_top() {
self.scroll_up(self.height);
}
}
pub fn half_page_down(&mut self) {
if !self.at_bottom() {
self.scroll_down(self.height / 2);
}
}
pub fn half_page_up(&mut self) {
if !self.at_top() {
self.scroll_up(self.height / 2);
}
}
pub fn goto_top(&mut self) {
self.set_y_offset(0);
}
pub fn goto_bottom(&mut self) {
self.set_y_offset(self.max_y_offset());
}
pub fn update(&mut self, msg: &Message) {
if let Some(key) = msg.downcast_ref::<KeyMsg>() {
let key_str = key.to_string();
if matches(&key_str, &[&self.key_map.page_down]) {
self.page_down();
} else if matches(&key_str, &[&self.key_map.page_up]) {
self.page_up();
} else if matches(&key_str, &[&self.key_map.half_page_down]) {
self.half_page_down();
} else if matches(&key_str, &[&self.key_map.half_page_up]) {
self.half_page_up();
} else if matches(&key_str, &[&self.key_map.down]) {
self.scroll_down(1);
} else if matches(&key_str, &[&self.key_map.up]) {
self.scroll_up(1);
} else if matches(&key_str, &[&self.key_map.left]) {
self.scroll_left(self.horizontal_step);
} else if matches(&key_str, &[&self.key_map.right]) {
self.scroll_right(self.horizontal_step);
}
return;
}
if let Some(mouse) = msg.downcast_ref::<MouseMsg>() {
if !self.mouse_wheel_enabled || mouse.action != bubbletea::MouseAction::Press {
return;
}
match mouse.button {
bubbletea::MouseButton::WheelUp => {
if mouse.shift {
self.scroll_left(self.horizontal_step);
} else {
self.scroll_up(self.mouse_wheel_delta);
}
}
bubbletea::MouseButton::WheelDown => {
if mouse.shift {
self.scroll_right(self.horizontal_step);
} else {
self.scroll_down(self.mouse_wheel_delta);
}
}
bubbletea::MouseButton::WheelLeft => self.scroll_left(self.horizontal_step),
bubbletea::MouseButton::WheelRight => self.scroll_right(self.horizontal_step),
_ => {}
}
}
}
#[must_use]
pub fn view(&self) -> String {
let mut width = self.width;
if let Some(style_width) = self.style.get_width()
&& style_width > 0
{
width = width.min(style_width as usize);
}
let mut height = self.height;
if let Some(style_height) = self.style.get_height()
&& style_height > 0
{
height = height.min(style_height as usize);
}
let frame_width = self.style.get_horizontal_frame_size();
let frame_height = self.style.get_vertical_frame_size();
let content_width = width.saturating_sub(frame_width);
let content_height = height.saturating_sub(frame_height);
let lines = self.visible_lines();
let contents = if content_width == 0 || content_height == 0 {
String::new()
} else {
let content_style = Style::new()
.width(as_u16(content_width))
.height(as_u16(content_height))
.max_width(as_u16(content_width))
.max_height(as_u16(content_height));
content_style.render(&lines.join("\n"))
};
self.style.render(&contents)
}
fn content_width(&self) -> usize {
self.width
.saturating_sub(self.style.get_horizontal_frame_size())
}
fn content_height(&self) -> usize {
self.height
.saturating_sub(self.style.get_vertical_frame_size())
}
}
impl Model for Viewport {
fn init(&self) -> Option<Cmd> {
None
}
fn update(&mut self, msg: Message) -> Option<Cmd> {
Viewport::update(self, &msg);
None
}
fn view(&self) -> String {
Viewport::view(self)
}
}
fn as_u16(value: usize) -> u16 {
value.min(u16::MAX as usize) as u16
}
fn visible_width(s: &str) -> usize {
let mut width = 0;
let mut in_escape = false;
let mut in_csi = false;
for c in s.chars() {
if c == '\x1b' {
in_escape = true;
continue;
}
if in_escape {
in_escape = false;
if c == '[' {
in_csi = true;
}
continue;
}
if in_csi {
if ('@'..='~').contains(&c) {
in_csi = false;
}
continue;
}
width += UnicodeWidthChar::width(c).unwrap_or(0);
}
width
}
fn cut_line(line: &str, start: usize, width: usize) -> String {
if width == 0 {
return String::new();
}
let end = start.saturating_add(width);
let mut result = String::new();
let mut in_escape = false;
let mut in_csi = false;
let mut visible = 0;
for c in line.chars() {
if c == '\x1b' {
in_escape = true;
result.push(c);
continue;
}
if in_escape {
in_escape = false;
result.push(c);
if c == '[' {
in_csi = true;
}
continue;
}
if in_csi {
result.push(c);
if ('@'..='~').contains(&c) {
in_csi = false;
}
continue;
}
let cw = UnicodeWidthChar::width(c).unwrap_or(0);
if visible + cw <= start {
visible += cw;
continue;
}
if visible >= end {
break;
}
result.push(c);
visible += cw;
}
result
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_viewport_new() {
let v = Viewport::new(80, 24);
assert_eq!(v.width, 80);
assert_eq!(v.height, 24);
assert!(v.mouse_wheel_enabled);
}
#[test]
fn test_viewport_set_content() {
let mut v = Viewport::new(80, 5);
v.set_content("Line 1\nLine 2\nLine 3\nLine 4\nLine 5\nLine 6\nLine 7");
assert_eq!(v.total_line_count(), 7);
}
#[test]
fn test_viewport_at_top_bottom() {
let mut v = Viewport::new(80, 3);
v.set_content("1\n2\n3\n4\n5");
assert!(v.at_top());
assert!(!v.at_bottom());
v.goto_bottom();
assert!(!v.at_top());
assert!(v.at_bottom());
}
#[test]
fn test_viewport_scroll() {
let mut v = Viewport::new(80, 3);
v.set_content("1\n2\n3\n4\n5\n6\n7\n8\n9\n10");
assert_eq!(v.y_offset(), 0);
v.scroll_down(2);
assert_eq!(v.y_offset(), 2);
v.scroll_up(1);
assert_eq!(v.y_offset(), 1);
}
#[test]
fn test_viewport_page_navigation() {
let mut v = Viewport::new(80, 3);
v.set_content("1\n2\n3\n4\n5\n6\n7\n8\n9\n10");
v.page_down();
assert_eq!(v.y_offset(), 3);
v.page_up();
assert_eq!(v.y_offset(), 0);
}
#[test]
fn test_viewport_scroll_percent() {
let mut v = Viewport::new(80, 5);
v.set_content("1\n2\n3\n4\n5\n6\n7\n8\n9\n10");
assert!((v.scroll_percent() - 0.0).abs() < 0.01);
v.goto_bottom();
assert!((v.scroll_percent() - 1.0).abs() < 0.01);
}
#[test]
fn test_viewport_view() {
let mut v = Viewport::new(80, 3);
v.set_content("Line 1\nLine 2\nLine 3\nLine 4");
let view = v.view();
assert!(view.contains("Line 1"));
assert!(view.contains("Line 2"));
assert!(view.contains("Line 3"));
assert!(!view.contains("Line 4"));
}
#[test]
fn test_viewport_view_pads_to_dimensions() {
let mut v = Viewport::new(4, 2);
v.set_content("a");
assert_eq!(v.view(), "a \n ");
}
#[test]
fn test_viewport_frame_affects_visible_height() {
let mut v = Viewport::new(10, 5);
v.style = Style::new().padding(1);
v.set_content("1\n2\n3\n4\n5\n6");
assert_eq!(v.visible_line_count(), 3);
v.goto_bottom();
assert_eq!(v.y_offset(), 3);
}
#[test]
fn test_viewport_horizontal_scroll() {
let mut v = Viewport::new(10, 5);
v.set_horizontal_step(5);
v.set_content("This is a very long line that exceeds the width");
assert_eq!(v.x_offset(), 0);
v.scroll_right(5);
assert_eq!(v.x_offset(), 5);
v.scroll_left(3);
assert_eq!(v.x_offset(), 2);
}
#[test]
fn test_viewport_horizontal_scroll_uses_display_width() {
let mut v = Viewport::new(4, 1);
v.set_content("日本語abc");
v.set_x_offset(2);
assert_eq!(v.view(), "本語");
}
#[test]
fn test_viewport_mouse_wheel_shift_scrolls_horizontal() {
let mut v = Viewport::new(10, 2);
v.set_content("This is a very long line that exceeds the width");
v.set_horizontal_step(2);
let down_shift = MouseMsg {
button: bubbletea::MouseButton::WheelDown,
shift: true,
..MouseMsg::default()
};
v.update(&Message::new(down_shift));
assert_eq!(v.x_offset(), 2);
let up_shift = MouseMsg {
button: bubbletea::MouseButton::WheelUp,
shift: true,
..MouseMsg::default()
};
v.update(&Message::new(up_shift));
assert_eq!(v.x_offset(), 0);
}
#[test]
fn test_viewport_mouse_wheel_ignores_release() {
let mut v = Viewport::new(10, 2);
v.set_content("1\n2\n3\n4");
let release = MouseMsg {
button: bubbletea::MouseButton::WheelDown,
action: bubbletea::MouseAction::Release,
..MouseMsg::default()
};
v.update(&Message::new(release));
assert_eq!(v.y_offset(), 0);
}
#[test]
fn test_viewport_empty_content() {
let v = Viewport::new(80, 24);
assert_eq!(v.total_line_count(), 0);
assert!(v.at_top());
assert!(v.at_bottom());
}
#[test]
fn test_viewport_model_init_returns_none() {
let v = Viewport::new(80, 24);
assert!(Model::init(&v).is_none());
}
#[test]
fn test_viewport_model_update_scrolls() {
let mut v = Viewport::new(10, 2);
v.set_content("1\n2\n3\n4");
assert_eq!(v.y_offset(), 0);
let down_msg = Message::new(KeyMsg::from_char('j'));
let result = Model::update(&mut v, down_msg);
assert!(result.is_none());
assert_eq!(v.y_offset(), 1);
}
#[test]
fn test_viewport_model_view_matches_view() {
let mut v = Viewport::new(10, 2);
v.set_content("Line 1\nLine 2\nLine 3");
assert_eq!(Model::view(&v), v.view());
}
#[test]
fn test_visible_width_with_non_sgr_csi_sequences() {
assert_eq!(visible_width("\x1b[2JHello"), 5);
assert_eq!(visible_width("\x1b[HWorld"), 5);
assert_eq!(visible_width("\x1b[31m\x1b[2KRed"), 3);
assert_eq!(visible_width("Start\x1b[K"), 5);
}
#[test]
fn test_visible_width_with_simple_escapes() {
assert_eq!(visible_width("\x1b7Text\x1b8"), 4);
}
#[test]
fn test_cut_line_with_non_sgr_csi_sequences() {
let line = "\x1b[2JHello World";
assert_eq!(cut_line(line, 0, 5), "\x1b[2JHello");
assert_eq!(cut_line(line, 6, 5), "\x1b[2JWorld");
}
}