use crate::key::{Binding, matches};
use crate::viewport::Viewport;
use bubbletea::{Cmd, KeyMsg, Message, Model, MouseAction, MouseButton, MouseMsg};
use lipgloss::{Color, Style};
#[derive(Debug, Clone)]
pub struct Column {
pub title: String,
pub width: usize,
}
impl Column {
#[must_use]
pub fn new(title: impl Into<String>, width: usize) -> Self {
Self {
title: title.into(),
width,
}
}
}
pub type Row = Vec<String>;
#[derive(Debug, Clone)]
pub struct KeyMap {
pub line_up: Binding,
pub line_down: Binding,
pub page_up: Binding,
pub page_down: Binding,
pub half_page_up: Binding,
pub half_page_down: Binding,
pub goto_top: Binding,
pub goto_bottom: Binding,
}
impl Default for KeyMap {
fn default() -> Self {
Self {
line_up: Binding::new().keys(&["up", "k"]).help("↑/k", "up"),
line_down: Binding::new().keys(&["down", "j"]).help("↓/j", "down"),
page_up: Binding::new()
.keys(&["b", "pgup"])
.help("b/pgup", "page up"),
page_down: Binding::new()
.keys(&["f", "pgdown", " "])
.help("f/pgdn", "page down"),
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"),
goto_top: Binding::new()
.keys(&["home", "g"])
.help("g/home", "go to start"),
goto_bottom: Binding::new()
.keys(&["end", "G"])
.help("G/end", "go to end"),
}
}
}
#[derive(Debug, Clone)]
pub struct Styles {
pub header: Style,
pub cell: Style,
pub selected: Style,
}
impl Default for Styles {
fn default() -> Self {
Self {
header: Style::new().bold().padding_left(1).padding_right(1),
cell: Style::new().padding_left(1).padding_right(1),
selected: Style::new().bold().foreground_color(Color::from("212")),
}
}
}
#[derive(Debug, Clone)]
pub struct Table {
pub key_map: KeyMap,
pub styles: Styles,
pub mouse_wheel_enabled: bool,
pub mouse_wheel_delta: usize,
pub mouse_click_enabled: bool,
columns: Vec<Column>,
rows: Vec<Row>,
cursor: usize,
focus: bool,
viewport: Viewport,
start: usize,
end: usize,
}
impl Default for Table {
fn default() -> Self {
Self::new()
}
}
impl Table {
#[must_use]
pub fn new() -> Self {
Self {
key_map: KeyMap::default(),
styles: Styles::default(),
mouse_wheel_enabled: true,
mouse_wheel_delta: 3,
mouse_click_enabled: true,
columns: Vec::new(),
rows: Vec::new(),
cursor: 0,
focus: false,
viewport: Viewport::new(0, 20),
start: 0,
end: 0,
}
}
#[must_use]
pub fn columns(mut self, columns: Vec<Column>) -> Self {
self.columns = columns;
self.update_viewport();
self
}
#[must_use]
pub fn rows(mut self, rows: Vec<Row>) -> Self {
self.rows = rows;
self.update_viewport();
self
}
#[must_use]
pub fn height(mut self, h: usize) -> Self {
let header_height = 1; self.viewport.height = h.saturating_sub(header_height);
self.update_viewport();
self
}
#[must_use]
pub fn width(mut self, w: usize) -> Self {
self.viewport.width = w;
self.update_viewport();
self
}
#[must_use]
pub fn focused(mut self, f: bool) -> Self {
self.focus = f;
self.update_viewport();
self
}
#[must_use]
pub fn with_styles(mut self, styles: Styles) -> Self {
self.styles = styles;
self.update_viewport();
self
}
#[must_use]
pub fn with_key_map(mut self, key_map: KeyMap) -> Self {
self.key_map = key_map;
self
}
#[must_use]
pub fn mouse_wheel(mut self, enabled: bool) -> Self {
self.mouse_wheel_enabled = enabled;
self
}
#[must_use]
pub fn mouse_wheel_delta(mut self, delta: usize) -> Self {
self.mouse_wheel_delta = delta;
self
}
#[must_use]
pub fn mouse_click(mut self, enabled: bool) -> Self {
self.mouse_click_enabled = enabled;
self
}
#[must_use]
pub fn is_focused(&self) -> bool {
self.focus
}
pub fn focus(&mut self) {
self.focus = true;
self.update_viewport();
}
pub fn blur(&mut self) {
self.focus = false;
self.update_viewport();
}
#[must_use]
pub fn get_columns(&self) -> &[Column] {
&self.columns
}
#[must_use]
pub fn get_rows(&self) -> &[Row] {
&self.rows
}
pub fn set_columns(&mut self, columns: Vec<Column>) {
self.columns = columns;
self.update_viewport();
}
pub fn set_rows(&mut self, rows: Vec<Row>) {
self.rows = rows;
if self.cursor > self.rows.len().saturating_sub(1) {
self.cursor = self.rows.len().saturating_sub(1);
}
self.update_viewport();
}
pub fn set_width(&mut self, w: usize) {
self.viewport.width = w;
self.update_viewport();
}
pub fn set_height(&mut self, h: usize) {
let header_height = 1;
self.viewport.height = h.saturating_sub(header_height);
self.update_viewport();
}
#[must_use]
pub fn get_height(&self) -> usize {
self.viewport.height
}
#[must_use]
pub fn get_width(&self) -> usize {
self.viewport.width
}
#[must_use]
pub fn selected_row(&self) -> Option<&Row> {
self.rows.get(self.cursor)
}
#[must_use]
pub fn cursor(&self) -> usize {
self.cursor
}
pub fn set_cursor(&mut self, n: usize) {
self.cursor = n.min(self.rows.len().saturating_sub(1));
self.update_viewport();
}
pub fn move_up(&mut self, n: usize) {
if self.rows.is_empty() {
return;
}
self.cursor = self.cursor.saturating_sub(n);
self.update_viewport();
}
pub fn move_down(&mut self, n: usize) {
if self.rows.is_empty() {
return;
}
self.cursor = (self.cursor + n).min(self.rows.len().saturating_sub(1));
self.update_viewport();
}
pub fn goto_top(&mut self) {
self.cursor = 0;
self.update_viewport();
}
pub fn goto_bottom(&mut self) {
if !self.rows.is_empty() {
self.cursor = self.rows.len() - 1;
}
self.update_viewport();
}
pub fn from_values(&mut self, value: &str, separator: &str) {
let rows: Vec<Row> = value
.lines()
.map(|line| line.split(separator).map(String::from).collect())
.collect();
self.set_rows(rows);
}
fn update_viewport(&mut self) {
if self.rows.is_empty() {
self.start = 0;
self.end = 0;
self.viewport.set_content("");
return;
}
let height = self.viewport.height;
if height == 0 {
self.start = 0;
self.end = 0;
self.viewport.set_content("");
return;
}
if self.cursor < self.start {
self.start = self.cursor;
} else if self.cursor >= self.start + height {
self.start = self.cursor - height + 1;
}
self.end = (self.start + height).min(self.rows.len());
if self.end - self.start < height && self.start > 0 {
self.start = self.end.saturating_sub(height);
}
let rendered: Vec<String> = (self.start..self.end).map(|i| self.render_row(i)).collect();
self.viewport.set_content(&rendered.join("\n"));
}
fn headers_view(&self) -> String {
let cells: Vec<String> = self
.columns
.iter()
.filter(|col| col.width > 0)
.map(|col| {
let truncated = truncate_string(&col.title, col.width);
let padded = pad_string(&truncated, col.width);
self.styles.header.render(&padded)
})
.collect();
cells.join("")
}
fn render_row(&self, row_idx: usize) -> String {
let row = &self.rows[row_idx];
let cells: Vec<String> = self
.columns
.iter()
.enumerate()
.filter(|(_, col)| col.width > 0)
.map(|(i, col)| {
let value = row.get(i).map(String::as_str).unwrap_or("");
let truncated = truncate_string(value, col.width);
let padded = pad_string(&truncated, col.width);
self.styles.cell.render(&padded)
})
.collect();
let row_str = cells.join("");
if row_idx == self.cursor {
self.styles.selected.render(&row_str)
} else {
row_str
}
}
pub fn update(&mut self, msg: &Message) {
if !self.focus {
return;
}
if let Some(key) = msg.downcast_ref::<KeyMsg>() {
let key_str = key.to_string();
if matches(&key_str, &[&self.key_map.line_up]) {
self.move_up(1);
} else if matches(&key_str, &[&self.key_map.line_down]) {
self.move_down(1);
} else if matches(&key_str, &[&self.key_map.page_up]) {
self.move_up(self.viewport.height);
} else if matches(&key_str, &[&self.key_map.page_down]) {
self.move_down(self.viewport.height);
} else if matches(&key_str, &[&self.key_map.half_page_up]) {
self.move_up(self.viewport.height / 2);
} else if matches(&key_str, &[&self.key_map.half_page_down]) {
self.move_down(self.viewport.height / 2);
} else if matches(&key_str, &[&self.key_map.goto_top]) {
self.goto_top();
} else if matches(&key_str, &[&self.key_map.goto_bottom]) {
self.goto_bottom();
}
}
if let Some(mouse) = msg.downcast_ref::<MouseMsg>() {
if mouse.action != MouseAction::Press {
return;
}
match mouse.button {
MouseButton::WheelUp if self.mouse_wheel_enabled => {
self.move_up(self.mouse_wheel_delta);
}
MouseButton::WheelDown if self.mouse_wheel_enabled => {
self.move_down(self.mouse_wheel_delta);
}
MouseButton::Left if self.mouse_click_enabled => {
let header_height = 1usize;
let click_y = mouse.y as usize;
if click_y >= header_height {
let visible_row = click_y - header_height;
let row_index = self.start + visible_row;
if row_index < self.rows.len() {
self.cursor = row_index;
self.update_viewport();
}
}
}
_ => {}
}
}
}
#[must_use]
pub fn view(&self) -> String {
format!("{}\n{}", self.headers_view(), self.viewport.view())
}
}
fn pad_string(s: &str, width: usize) -> String {
use unicode_width::UnicodeWidthStr;
let current_width = UnicodeWidthStr::width(s);
if current_width >= width {
s.to_string()
} else {
let padding = width - current_width;
format!("{}{}", s, " ".repeat(padding))
}
}
fn truncate_string(s: &str, width: usize) -> String {
use unicode_width::UnicodeWidthStr;
if UnicodeWidthStr::width(s) <= width {
return s.to_string();
}
if width == 0 {
return String::new();
}
let target_width = width.saturating_sub(1);
let mut current_width = 0;
let mut result = String::new();
for c in s.chars() {
let w = unicode_width::UnicodeWidthChar::width(c).unwrap_or(0);
if current_width + w > target_width {
break;
}
result.push(c);
current_width += w;
}
format!("{}…", result)
}
impl Model for Table {
fn init(&self) -> Option<Cmd> {
None
}
fn update(&mut self, msg: Message) -> Option<Cmd> {
self.update(&msg);
None
}
fn view(&self) -> String {
Table::view(self)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_column_new() {
let col = Column::new("Name", 20);
assert_eq!(col.title, "Name");
assert_eq!(col.width, 20);
}
#[test]
fn test_table_new() {
let table = Table::new();
assert!(table.get_columns().is_empty());
assert!(table.get_rows().is_empty());
assert!(!table.is_focused());
}
#[test]
fn test_table_builder() {
let columns = vec![Column::new("ID", 10), Column::new("Name", 20)];
let rows = vec![
vec!["1".into(), "Alice".into()],
vec!["2".into(), "Bob".into()],
];
let table = Table::new()
.columns(columns)
.rows(rows)
.height(10)
.focused(true);
assert_eq!(table.get_columns().len(), 2);
assert_eq!(table.get_rows().len(), 2);
assert!(table.is_focused());
}
#[test]
fn test_table_navigation() {
let rows = vec![
vec!["1".into()],
vec!["2".into()],
vec!["3".into()],
vec!["4".into()],
vec!["5".into()],
];
let mut table = Table::new().rows(rows).height(10);
assert_eq!(table.cursor(), 0);
table.move_down(1);
assert_eq!(table.cursor(), 1);
table.move_down(2);
assert_eq!(table.cursor(), 3);
table.move_up(1);
assert_eq!(table.cursor(), 2);
table.goto_bottom();
assert_eq!(table.cursor(), 4);
table.goto_top();
assert_eq!(table.cursor(), 0);
}
#[test]
fn test_table_selected_row() {
let rows = vec![
vec!["1".into(), "Alice".into()],
vec!["2".into(), "Bob".into()],
];
let mut table = Table::new().rows(rows);
assert_eq!(
table.selected_row(),
Some(&vec!["1".into(), "Alice".into()])
);
table.move_down(1);
assert_eq!(table.selected_row(), Some(&vec!["2".into(), "Bob".into()]));
}
#[test]
fn test_table_focus_blur() {
let mut table = Table::new();
assert!(!table.is_focused());
table.focus();
assert!(table.is_focused());
table.blur();
assert!(!table.is_focused());
}
#[test]
fn test_table_set_cursor() {
let rows = vec![vec!["1".into()], vec!["2".into()], vec!["3".into()]];
let mut table = Table::new().rows(rows);
table.set_cursor(2);
assert_eq!(table.cursor(), 2);
table.set_cursor(100);
assert_eq!(table.cursor(), 2);
}
#[test]
fn test_table_from_values() {
let mut table = Table::new();
table.from_values("a,b,c\n1,2,3\nx,y,z", ",");
assert_eq!(table.get_rows().len(), 3);
assert_eq!(table.get_rows()[0], vec!["a", "b", "c"]);
assert_eq!(table.get_rows()[1], vec!["1", "2", "3"]);
}
#[test]
fn test_table_view() {
let columns = vec![Column::new("ID", 5), Column::new("Name", 10)];
let rows = vec![
vec!["1".into(), "Alice".into()],
vec!["2".into(), "Bob".into()],
];
let table = Table::new().columns(columns).rows(rows).height(5);
let view = table.view();
assert!(view.contains("ID"));
assert!(view.contains("Name"));
}
#[test]
fn test_truncate_string() {
assert_eq!(truncate_string("Hello", 10), "Hello");
assert_eq!(truncate_string("Hello World", 5), "Hell…");
assert_eq!(truncate_string("Hi", 2), "Hi");
assert_eq!(truncate_string("", 5), "");
}
#[test]
fn test_table_empty() {
let table = Table::new();
assert!(table.selected_row().is_none());
assert_eq!(table.cursor(), 0);
}
#[test]
fn test_keymap_default() {
let km = KeyMap::default();
assert!(!km.line_up.get_keys().is_empty());
assert!(!km.goto_bottom.get_keys().is_empty());
}
#[test]
fn test_model_init() {
let table = Table::new();
let cmd = Model::init(&table);
assert!(cmd.is_none());
}
#[test]
fn test_model_view() {
let columns = vec![Column::new("ID", 5)];
let rows = vec![vec!["1".into()]];
let table = Table::new().columns(columns).rows(rows);
let model_view = Model::view(&table);
let table_view = Table::view(&table);
assert_eq!(model_view, table_view);
}
#[test]
fn test_model_update_handles_navigation() {
use bubbletea::{KeyMsg, KeyType, Message};
let columns = vec![Column::new("ID", 5), Column::new("Name", 20)];
let rows = vec![
vec!["1".into(), "First".into()],
vec!["2".into(), "Second".into()],
vec!["3".into(), "Third".into()],
];
let mut table = Table::new().columns(columns).rows(rows).focused(true);
assert_eq!(table.cursor(), 0);
let down_msg = Message::new(KeyMsg::from_type(KeyType::Down));
let _ = Model::update(&mut table, down_msg);
assert_eq!(table.cursor(), 1, "Table should navigate down on Down key");
}
#[test]
fn test_model_update_unfocused_ignores_input() {
use bubbletea::{KeyMsg, KeyType, Message};
let columns = vec![Column::new("ID", 5)];
let rows = vec![vec!["1".into()], vec!["2".into()]];
let mut table = Table::new().columns(columns).rows(rows);
assert!(!table.focus);
assert_eq!(table.cursor(), 0);
let down_msg = Message::new(KeyMsg::from_type(KeyType::Down));
let _ = Model::update(&mut table, down_msg);
assert_eq!(table.cursor(), 0, "Unfocused table should ignore key input");
}
#[test]
fn test_model_update_goto_bottom() {
use bubbletea::{KeyMsg, KeyType, Message};
let columns = vec![Column::new("ID", 5)];
let rows = vec![vec!["1".into()], vec!["2".into()], vec!["3".into()]];
let mut table = Table::new().columns(columns).rows(rows).focused(true);
assert_eq!(table.cursor(), 0);
let end_msg = Message::new(KeyMsg::from_type(KeyType::End));
let _ = Model::update(&mut table, end_msg);
assert_eq!(table.cursor(), 2, "Table should go to bottom on End key");
}
#[test]
fn test_table_satisfies_model_bounds() {
fn requires_model<T: Model + Send + 'static>() {}
requires_model::<Table>();
}
#[test]
fn test_model_update_page_down() {
use bubbletea::{KeyMsg, KeyType, Message};
let columns = vec![Column::new("ID", 5)];
let rows: Vec<Row> = (1..=20).map(|i| vec![i.to_string()]).collect();
let mut table = Table::new()
.columns(columns)
.rows(rows)
.focused(true)
.height(5);
assert_eq!(table.cursor(), 0);
let msg = Message::new(KeyMsg::from_type(KeyType::PgDown));
let _ = Model::update(&mut table, msg);
assert!(
table.cursor() > 0,
"Table should navigate down on PageDown key"
);
}
#[test]
fn test_model_update_page_up() {
use bubbletea::{KeyMsg, KeyType, Message};
let columns = vec![Column::new("ID", 5)];
let rows: Vec<Row> = (1..=20).map(|i| vec![i.to_string()]).collect();
let mut table = Table::new()
.columns(columns)
.rows(rows)
.focused(true)
.height(5);
table.set_cursor(10);
assert_eq!(table.cursor(), 10);
let msg = Message::new(KeyMsg::from_type(KeyType::PgUp));
let _ = Model::update(&mut table, msg);
assert!(
table.cursor() < 10,
"Table should navigate up on PageUp key"
);
}
#[test]
fn test_model_update_goto_top() {
use bubbletea::{KeyMsg, KeyType, Message};
let columns = vec![Column::new("ID", 5)];
let rows = vec![vec!["1".into()], vec!["2".into()], vec!["3".into()]];
let mut table = Table::new().columns(columns).rows(rows).focused(true);
table.set_cursor(2);
assert_eq!(table.cursor(), 2);
let msg = Message::new(KeyMsg::from_type(KeyType::Home));
let _ = Model::update(&mut table, msg);
assert_eq!(table.cursor(), 0, "Table should go to top on Home key");
}
#[test]
fn test_table_set_rows_replaces_data() {
let columns = vec![Column::new("Name", 10)];
let initial_rows = vec![vec!["Alice".into()], vec!["Bob".into()]];
let mut table = Table::new().columns(columns).rows(initial_rows);
assert_eq!(table.rows.len(), 2);
assert_eq!(table.rows[0][0], "Alice");
let new_rows = vec![
vec!["Charlie".into()],
vec!["Diana".into()],
vec!["Eve".into()],
];
table.set_rows(new_rows);
assert_eq!(table.rows.len(), 3);
assert_eq!(table.rows[0][0], "Charlie");
assert_eq!(table.rows[1][0], "Diana");
assert_eq!(table.rows[2][0], "Eve");
}
#[test]
fn test_table_set_columns_updates_headers() {
let initial_cols = vec![Column::new("A", 5), Column::new("B", 5)];
let rows = vec![vec!["1".into(), "2".into()]];
let mut table = Table::new().columns(initial_cols).rows(rows);
assert_eq!(table.columns.len(), 2);
assert_eq!(table.columns[0].title, "A");
let new_cols = vec![
Column::new("X", 10),
Column::new("Y", 10),
Column::new("Z", 10),
];
table.set_columns(new_cols);
assert_eq!(table.columns.len(), 3);
assert_eq!(table.columns[0].title, "X");
assert_eq!(table.columns[1].title, "Y");
assert_eq!(table.columns[2].title, "Z");
}
#[test]
fn test_table_single_row() {
use bubbletea::{KeyMsg, KeyType, Message};
let columns = vec![Column::new("Item", 10)];
let rows = vec![vec!["Only One".into()]];
let mut table = Table::new().columns(columns).rows(rows).focused(true);
assert_eq!(table.cursor(), 0);
assert_eq!(table.rows.len(), 1);
let down_msg = Message::new(KeyMsg::from_type(KeyType::Down));
let _ = Model::update(&mut table, down_msg);
assert_eq!(table.cursor(), 0, "Single row table should not move down");
let up_msg = Message::new(KeyMsg::from_type(KeyType::Up));
let _ = Model::update(&mut table, up_msg);
assert_eq!(table.cursor(), 0, "Single row table should not move up");
assert!(table.selected_row().is_some());
assert_eq!(table.selected_row().unwrap()[0], "Only One");
}
#[test]
fn test_table_single_column() {
let columns = vec![Column::new("Solo", 15)];
let rows = vec![
vec!["Row 1".into()],
vec!["Row 2".into()],
vec!["Row 3".into()],
];
let table = Table::new().columns(columns).rows(rows);
assert_eq!(table.columns.len(), 1);
assert_eq!(table.columns[0].title, "Solo");
assert_eq!(table.columns[0].width, 15);
let view = table.view();
assert!(!view.is_empty());
assert!(
view.contains("Solo") || view.contains("Row"),
"Single column table should render"
);
}
#[test]
fn test_table_empty_navigation() {
use bubbletea::{KeyMsg, KeyType, Message};
let mut table = Table::new().focused(true);
assert!(table.rows.is_empty());
assert_eq!(table.cursor(), 0);
let down_msg = Message::new(KeyMsg::from_type(KeyType::Down));
let _ = Model::update(&mut table, down_msg);
assert_eq!(table.cursor(), 0, "Empty table cursor should stay at 0");
let up_msg = Message::new(KeyMsg::from_type(KeyType::Up));
let _ = Model::update(&mut table, up_msg);
assert_eq!(table.cursor(), 0, "Empty table cursor should stay at 0");
let end_msg = Message::new(KeyMsg::from_type(KeyType::End));
let _ = Model::update(&mut table, end_msg);
assert_eq!(
table.cursor(),
0,
"Empty table goto_bottom should stay at 0"
);
let home_msg = Message::new(KeyMsg::from_type(KeyType::Home));
let _ = Model::update(&mut table, home_msg);
assert_eq!(table.cursor(), 0, "Empty table goto_top should stay at 0");
}
#[test]
fn test_table_view_empty() {
let table = Table::new();
let view = table.view();
let _ = view;
}
#[test]
fn test_table_view_renders_column_widths() {
let columns = vec![Column::new("Short", 5), Column::new("LongerColumn", 15)];
let rows = vec![vec!["A".into(), "B".into()]];
let table = Table::new().columns(columns).rows(rows);
let view = table.view();
assert!(!view.is_empty());
assert!(view.contains("Short") || view.contains("Longer"));
}
#[test]
fn test_model_update_navigate_up() {
use bubbletea::{KeyMsg, KeyType, Message};
let columns = vec![Column::new("ID", 5)];
let rows = vec![vec!["1".into()], vec!["2".into()], vec!["3".into()]];
let mut table = Table::new().columns(columns).rows(rows).focused(true);
table.set_cursor(2);
assert_eq!(table.cursor(), 2);
let up_msg = Message::new(KeyMsg::from_type(KeyType::Up));
let _ = Model::update(&mut table, up_msg);
assert_eq!(table.cursor(), 1, "Table should navigate up on Up key");
}
#[test]
fn test_table_view_with_long_content() {
let columns = vec![Column::new("Name", 5)];
let rows = vec![vec!["VeryLongNameThatExceedsColumnWidth".into()]];
let table = Table::new().columns(columns).rows(rows);
let view = table.view();
assert!(!view.is_empty());
}
#[test]
fn test_table_cursor_boundary_top() {
use bubbletea::{KeyMsg, KeyType, Message};
let columns = vec![Column::new("ID", 5)];
let rows = vec![vec!["1".into()], vec!["2".into()]];
let mut table = Table::new().columns(columns).rows(rows).focused(true);
assert_eq!(table.cursor(), 0);
let up_msg = Message::new(KeyMsg::from_type(KeyType::Up));
let _ = Model::update(&mut table, up_msg);
assert_eq!(table.cursor(), 0, "Cursor should not go below 0");
}
#[test]
fn test_table_cursor_boundary_bottom() {
use bubbletea::{KeyMsg, KeyType, Message};
let columns = vec![Column::new("ID", 5)];
let rows = vec![vec!["1".into()], vec!["2".into()]];
let mut table = Table::new().columns(columns).rows(rows).focused(true);
table.set_cursor(1);
assert_eq!(table.cursor(), 1);
let down_msg = Message::new(KeyMsg::from_type(KeyType::Down));
let _ = Model::update(&mut table, down_msg);
assert_eq!(table.cursor(), 1, "Cursor should not exceed row count");
}
#[test]
fn test_table_update_with_j_k_keys() {
use bubbletea::{KeyMsg, Message};
let columns = vec![Column::new("ID", 5)];
let rows = vec![vec!["1".into()], vec!["2".into()], vec!["3".into()]];
let mut table = Table::new().columns(columns).rows(rows).focused(true);
assert_eq!(table.cursor(), 0);
let j_msg = Message::new(KeyMsg::from_char('j'));
let _ = Model::update(&mut table, j_msg);
assert_eq!(table.cursor(), 1, "'j' should move cursor down");
let k_msg = Message::new(KeyMsg::from_char('k'));
let _ = Model::update(&mut table, k_msg);
assert_eq!(table.cursor(), 0, "'k' should move cursor up");
}
#[test]
fn test_table_update_with_g_and_shift_g_keys() {
use bubbletea::{KeyMsg, Message};
let columns = vec![Column::new("ID", 5)];
let rows = vec![vec!["1".into()], vec!["2".into()], vec!["3".into()]];
let mut table = Table::new().columns(columns).rows(rows).focused(true);
assert_eq!(table.cursor(), 0);
let g_upper_msg = Message::new(KeyMsg::from_char('G'));
let _ = Model::update(&mut table, g_upper_msg);
assert_eq!(table.cursor(), 2, "'G' should go to bottom");
let g_msg = Message::new(KeyMsg::from_char('g'));
let _ = Model::update(&mut table, g_msg);
assert_eq!(table.cursor(), 0, "'g' should go to top");
}
#[test]
fn test_table_height_affects_pagination() {
use bubbletea::{KeyMsg, KeyType, Message};
let columns = vec![Column::new("ID", 5)];
let rows: Vec<Row> = (1..=20).map(|i| vec![i.to_string()]).collect();
let mut table = Table::new()
.columns(columns)
.rows(rows)
.focused(true)
.height(3);
assert_eq!(table.cursor(), 0);
let pgdown_msg = Message::new(KeyMsg::from_type(KeyType::PgDown));
let _ = Model::update(&mut table, pgdown_msg);
assert!(table.cursor() > 0, "PageDown should move cursor down");
}
#[test]
fn test_table_selected_row_after_navigation() {
use bubbletea::{KeyMsg, KeyType, Message};
let columns = vec![Column::new("Name", 10)];
let rows = vec![
vec!["Alice".into()],
vec!["Bob".into()],
vec!["Carol".into()],
];
let mut table = Table::new().columns(columns).rows(rows).focused(true);
assert_eq!(table.selected_row().unwrap()[0], "Alice");
let down_msg = Message::new(KeyMsg::from_type(KeyType::Down));
let _ = Model::update(&mut table, down_msg);
assert_eq!(table.selected_row().unwrap()[0], "Bob");
let _ = Model::update(&mut table, Message::new(KeyMsg::from_type(KeyType::Down)));
assert_eq!(table.selected_row().unwrap()[0], "Carol");
}
mod mouse_tests {
use super::*;
use bubbletea::Message;
fn wheel_up_msg() -> Message {
Message::new(MouseMsg {
x: 0,
y: 0,
shift: false,
alt: false,
ctrl: false,
action: MouseAction::Press,
button: MouseButton::WheelUp,
})
}
fn wheel_down_msg() -> Message {
Message::new(MouseMsg {
x: 0,
y: 0,
shift: false,
alt: false,
ctrl: false,
action: MouseAction::Press,
button: MouseButton::WheelDown,
})
}
fn click_msg(x: u16, y: u16) -> Message {
Message::new(MouseMsg {
x,
y,
shift: false,
alt: false,
ctrl: false,
action: MouseAction::Press,
button: MouseButton::Left,
})
}
#[test]
fn test_table_mouse_wheel_scroll_down() {
let rows = vec![
vec!["1".into()],
vec!["2".into()],
vec!["3".into()],
vec!["4".into()],
vec!["5".into()],
];
let mut table = Table::new().rows(rows).focused(true);
assert_eq!(table.cursor(), 0);
table.update(&wheel_down_msg());
assert_eq!(table.cursor(), 3);
}
#[test]
fn test_table_mouse_wheel_scroll_up() {
let rows = vec![
vec!["1".into()],
vec!["2".into()],
vec!["3".into()],
vec!["4".into()],
vec!["5".into()],
];
let mut table = Table::new().rows(rows).focused(true);
table.goto_bottom();
assert_eq!(table.cursor(), 4);
table.update(&wheel_up_msg());
assert_eq!(table.cursor(), 1);
}
#[test]
fn test_table_mouse_click_select_row() {
let rows = vec![vec!["1".into()], vec!["2".into()], vec!["3".into()]];
let mut table = Table::new().rows(rows).focused(true);
assert_eq!(table.cursor(), 0);
table.update(&click_msg(5, 2));
assert_eq!(table.cursor(), 1);
table.update(&click_msg(5, 3));
assert_eq!(table.cursor(), 2);
}
#[test]
fn test_table_mouse_click_header_does_nothing() {
let rows = vec![vec!["1".into()], vec!["2".into()]];
let mut table = Table::new().rows(rows).focused(true);
assert_eq!(table.cursor(), 0);
table.update(&click_msg(5, 0));
assert_eq!(table.cursor(), 0);
}
#[test]
fn test_table_mouse_click_out_of_bounds() {
let rows = vec![vec!["1".into()], vec!["2".into()]];
let mut table = Table::new().rows(rows).focused(true);
assert_eq!(table.cursor(), 0);
table.update(&click_msg(5, 100));
assert_eq!(table.cursor(), 0);
}
#[test]
fn test_table_mouse_disabled() {
let rows = vec![vec!["1".into()], vec!["2".into()], vec!["3".into()]];
let mut table = Table::new()
.rows(rows)
.focused(true)
.mouse_wheel(false)
.mouse_click(false);
table.update(&wheel_down_msg());
assert_eq!(table.cursor(), 0);
table.update(&click_msg(5, 2));
assert_eq!(table.cursor(), 0);
}
#[test]
fn test_table_mouse_not_focused() {
let rows = vec![vec!["1".into()], vec!["2".into()]];
let mut table = Table::new().rows(rows).focused(false);
table.update(&wheel_down_msg());
assert_eq!(table.cursor(), 0);
table.update(&click_msg(5, 2));
assert_eq!(table.cursor(), 0);
}
#[test]
fn test_table_mouse_wheel_delta_builder() {
let rows = vec![
vec!["1".into()],
vec!["2".into()],
vec!["3".into()],
vec!["4".into()],
vec!["5".into()],
];
let mut table = Table::new().rows(rows).focused(true).mouse_wheel_delta(1);
table.update(&wheel_down_msg());
assert_eq!(table.cursor(), 1); }
#[test]
fn test_table_mouse_release_ignored() {
let rows = vec![vec!["1".into()], vec!["2".into()]];
let mut table = Table::new().rows(rows).focused(true);
let release_msg = Message::new(MouseMsg {
x: 5,
y: 2,
shift: false,
alt: false,
ctrl: false,
action: MouseAction::Release,
button: MouseButton::Left,
});
table.update(&release_msg);
assert_eq!(table.cursor(), 0);
}
}
}