use ratatui::Frame;
use ratatui::layout::Rect;
use ratatui::style::Style;
use ratatui::widgets::{Block, Borders, Tabs};
#[derive(Debug, Clone, Default)]
pub struct BoardTabs {
pub names: Vec<String>,
pub selected: usize,
pub hovered: Option<usize>,
}
impl BoardTabs {
pub fn new(names: Vec<String>) -> Self {
Self {
names,
selected: 0,
hovered: None,
}
}
pub fn len(&self) -> usize {
self.names.len()
}
pub fn is_empty(&self) -> bool {
self.names.is_empty()
}
pub fn needs_bar(&self) -> bool {
self.names.len() > 1
}
pub fn next(&mut self) {
if !self.names.is_empty() {
self.selected = (self.selected + 1) % self.names.len();
}
}
pub fn previous(&mut self) {
if self.names.is_empty() {
return;
}
self.selected = if self.selected == 0 {
self.names.len() - 1
} else {
self.selected - 1
};
}
pub fn render(&self, f: &mut Frame, area: Rect) {
if area.width == 0 || area.height == 0 {
return;
}
let titles: Vec<String> = self
.names
.iter()
.enumerate()
.map(|(i, _)| self.title(i).unwrap_or_default())
.collect();
let tabs = Tabs::new(titles)
.block(Block::default().borders(Borders::BOTTOM))
.highlight_style(Style::default().bold())
.select(self.selected);
f.render_widget(tabs, area);
}
pub fn title(&self, index: usize) -> Option<String> {
self.names.get(index).map(|name| {
if index == self.selected {
format!("[{name}]")
} else if self.hovered == Some(index) {
format!(">{name}<")
} else {
name.clone()
}
})
}
pub fn tab_rect(&self, index: usize, area: Rect) -> Option<Rect> {
let width = self.title(index)?.chars().count() as u16;
let offset: u16 = (0..index)
.map(|i| {
self.title(i)
.map(|t| t.chars().count() as u16 + 1)
.unwrap_or(1)
})
.sum();
Some(Rect::new(area.x + offset, area.y, width, area.height))
}
}
#[cfg(test)]
mod tests {
use super::*;
use ratatui::Terminal;
use ratatui::backend::TestBackend;
#[test]
fn first_tab_is_default() {
let tabs = BoardTabs::new(vec!["default".into(), "release".into(), "personal".into()]);
assert_eq!(tabs.selected, 0);
}
#[test]
fn next_wraps_forward() {
let mut tabs = BoardTabs::new(vec!["a".into(), "b".into(), "c".into()]);
tabs.next();
tabs.next();
assert_eq!(tabs.selected, 2);
tabs.next();
assert_eq!(tabs.selected, 0, "wraps to first tab");
}
#[test]
fn previous_wraps_backward() {
let mut tabs = BoardTabs::new(vec!["a".into(), "b".into(), "c".into()]);
tabs.previous();
assert_eq!(tabs.selected, 2, "wraps to last tab");
tabs.previous();
assert_eq!(tabs.selected, 1);
}
#[test]
fn single_tab_stays_put() {
let mut tabs = BoardTabs::new(vec!["only".into()]);
tabs.next();
assert_eq!(tabs.selected, 0);
tabs.previous();
assert_eq!(tabs.selected, 0);
}
#[test]
fn empty_tabs_next_previous_are_noops() {
let mut tabs = BoardTabs::new(vec![]);
tabs.next();
tabs.previous();
assert_eq!(tabs.selected, 0);
}
#[test]
fn single_board_needs_no_bar() {
assert!(!BoardTabs::new(vec!["default".into()]).needs_bar());
assert!(!BoardTabs::new(vec![]).needs_bar());
}
#[test]
fn multiple_boards_need_a_bar() {
assert!(BoardTabs::new(vec!["default".into(), "release".into()]).needs_bar());
}
#[test]
fn render_80x24_shows_all_three_boards() {
let backend = TestBackend::new(80, 24);
let mut terminal = Terminal::new(backend).unwrap();
let tabs = BoardTabs::new(vec!["default".into(), "release".into(), "personal".into()]);
terminal.draw(|f| tabs.render(f, f.area())).unwrap();
let buffer = terminal.backend().buffer();
let content: String = buffer.content.iter().map(|c| c.symbol()).collect();
assert!(content.contains("default"));
assert!(content.contains("release"));
assert!(content.contains("personal"));
}
#[test]
fn render_selected_highlight_marks_active_bracket() {
let backend = TestBackend::new(80, 24);
let mut terminal = Terminal::new(backend).unwrap();
let mut tabs = BoardTabs::new(vec!["default".into(), "release".into()]);
tabs.next();
terminal.draw(|f| tabs.render(f, f.area())).unwrap();
let buffer = terminal.backend().buffer();
let content: String = buffer.content.iter().map(|c| c.symbol()).collect();
assert!(content.contains("[release]"), "active tab bracketed");
assert!(!content.contains("[default]"), "inactive tab not bracketed");
}
#[test]
fn render_hovered_tab_uses_hover_style() {
let backend = TestBackend::new(80, 24);
let mut terminal = Terminal::new(backend).unwrap();
let mut tabs = BoardTabs::new(vec!["default".into(), "release".into()]);
tabs.hovered = Some(1);
terminal.draw(|f| tabs.render(f, f.area())).unwrap();
let buffer = terminal.backend().buffer();
let content: String = buffer.content.iter().map(|c| c.symbol()).collect();
assert!(
content.contains(">release<"),
"hovered tab should be marked: {content:?}"
);
}
}