use ratatui::{
buffer::Buffer,
layout::Rect,
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::Widget,
};
use unicode_width::UnicodeWidthStr;
use crate::session::SessionStatus;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum StatusIndicator {
Starting,
Running,
Terminated,
}
impl From<&SessionStatus> for StatusIndicator {
fn from(status: &SessionStatus) -> Self {
match status {
SessionStatus::Starting => Self::Starting,
SessionStatus::Running => Self::Running,
SessionStatus::Terminated { .. } => Self::Terminated,
}
}
}
impl StatusIndicator {
#[must_use]
pub const fn color(self) -> Color {
match self {
Self::Starting => Color::Yellow,
Self::Running => Color::Green,
Self::Terminated => Color::Red,
}
}
#[must_use]
pub const fn symbol(self) -> &'static str {
match self {
Self::Starting => "○",
Self::Running => "●",
Self::Terminated => "×",
}
}
}
#[derive(Debug, Clone)]
pub struct TabItem {
pub title: String,
pub status: StatusIndicator,
}
impl TabItem {
#[must_use]
pub fn new(title: impl Into<String>, status: StatusIndicator) -> Self {
Self {
title: title.into(),
status,
}
}
}
const INDICATOR_WIDTH: u16 = 2; const SEPARATOR_WIDTH: u16 = 3;
#[must_use]
pub fn tab_display_width(tab: &TabItem) -> u16 {
#[allow(clippy::cast_possible_truncation)]
let title_width = tab.title.width() as u16;
INDICATOR_WIDTH + title_width + SEPARATOR_WIDTH
}
#[must_use]
pub fn find_tab_at_column(tabs: &[TabItem], column: u16) -> Option<usize> {
let mut x = 0u16;
for (i, tab) in tabs.iter().enumerate() {
let width = tab_display_width(tab);
if column < x + width {
return Some(i);
}
x += width;
}
None
}
#[derive(Debug, Clone)]
pub struct TabBar<'a> {
tabs: &'a [TabItem],
active_idx: usize,
}
impl<'a> TabBar<'a> {
#[must_use]
pub fn new(tabs: &'a [TabItem], active_idx: usize) -> Self {
Self { tabs, active_idx }
}
}
impl Widget for TabBar<'_> {
fn render(self, area: Rect, buf: &mut Buffer) {
if area.height == 0 || self.tabs.is_empty() {
return;
}
let mut x = area.x;
for (i, tab) in self.tabs.iter().enumerate() {
let is_active = i == self.active_idx;
let indicator = Span::styled(
format!("{} ", tab.status.symbol()),
Style::default().fg(tab.status.color()),
);
let title_style = if is_active {
Style::default()
.fg(Color::White)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::Gray)
};
let title = Span::styled(&tab.title, title_style);
let sep = Span::styled(" | ", Style::default().fg(Color::DarkGray));
let spans = vec![indicator, title, sep];
let line = Line::from(spans);
#[allow(clippy::cast_possible_truncation)]
let width = line.width() as u16;
if x + width <= area.x + area.width {
buf.set_line(x, area.y, &line, width);
x += width;
} else {
break;
}
}
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
use crate::tui::test_utils::buffer_to_text;
use rstest::rstest;
mod tab_display_width_tests {
use super::*;
#[test]
fn ascii_title() {
let tab = TabItem::new("session-1", StatusIndicator::Running);
assert_eq!(tab_display_width(&tab), 14);
}
#[test]
fn cjk_title() {
let tab = TabItem::new("日本語", StatusIndicator::Running);
assert_eq!(tab_display_width(&tab), 11);
}
#[test]
fn emoji_title() {
let tab = TabItem::new("🎉test", StatusIndicator::Running);
assert_eq!(tab_display_width(&tab), 11);
}
#[test]
fn empty_title() {
let tab = TabItem::new("", StatusIndicator::Running);
assert_eq!(tab_display_width(&tab), 5);
}
}
mod find_tab_at_column_tests {
use super::*;
#[test]
fn single_tab_in_bounds() {
let tabs = vec![TabItem::new("test", StatusIndicator::Running)];
assert_eq!(find_tab_at_column(&tabs, 0), Some(0));
assert_eq!(find_tab_at_column(&tabs, 8), Some(0));
}
#[test]
fn single_tab_out_of_bounds() {
let tabs = vec![TabItem::new("test", StatusIndicator::Running)];
assert_eq!(find_tab_at_column(&tabs, 9), None);
assert_eq!(find_tab_at_column(&tabs, 100), None);
}
#[test]
fn multiple_tabs_variable_width() {
let tabs = vec![
TabItem::new("a", StatusIndicator::Running), TabItem::new("日本語", StatusIndicator::Running), TabItem::new("test", StatusIndicator::Running), ];
assert_eq!(find_tab_at_column(&tabs, 0), Some(0));
assert_eq!(find_tab_at_column(&tabs, 5), Some(0));
assert_eq!(find_tab_at_column(&tabs, 6), Some(1));
assert_eq!(find_tab_at_column(&tabs, 16), Some(1));
assert_eq!(find_tab_at_column(&tabs, 17), Some(2));
assert_eq!(find_tab_at_column(&tabs, 25), Some(2));
assert_eq!(find_tab_at_column(&tabs, 26), None);
}
#[test]
fn empty_tabs() {
let tabs: Vec<TabItem> = vec![];
assert_eq!(find_tab_at_column(&tabs, 0), None);
}
#[test]
fn boundary_between_tabs() {
let tabs = vec![
TabItem::new("ab", StatusIndicator::Running), TabItem::new("cd", StatusIndicator::Running), ];
assert_eq!(find_tab_at_column(&tabs, 6), Some(0));
assert_eq!(find_tab_at_column(&tabs, 7), Some(1));
}
}
#[test]
fn test_tab_bar_empty() {
let tabs: Vec<TabItem> = vec![];
let tab_bar = TabBar::new(&tabs, 0);
let area = Rect::new(0, 0, 40, 1);
let mut buf = Buffer::empty(area);
tab_bar.render(area, &mut buf);
}
#[test]
fn test_tab_bar_renders_tabs() {
let tabs = vec![
TabItem::new("session-1", StatusIndicator::Running),
TabItem::new("session-2", StatusIndicator::Starting),
];
let tab_bar = TabBar::new(&tabs, 0);
let area = Rect::new(0, 0, 60, 1);
let mut buf = Buffer::empty(area);
tab_bar.render(area, &mut buf);
let output = buffer_to_text(&buf);
assert!(output.contains("session-1"));
assert!(output.contains("session-2"));
}
#[test]
fn test_tab_bar_highlights_active() {
let tabs = vec![
TabItem::new("tab-a", StatusIndicator::Running),
TabItem::new("tab-b", StatusIndicator::Running),
];
let tab_bar = TabBar::new(&tabs, 1);
let area = Rect::new(0, 0, 60, 1);
let mut buf = Buffer::empty(area);
tab_bar.render(area, &mut buf);
let mut found_active = false;
for x in 0..buf.area.width {
let cell = &buf[(x, 0)];
if cell.symbol() == "t" {
let mut s = String::new();
for dx in 0..5 {
if x + dx < buf.area.width {
s.push_str(buf[(x + dx, 0)].symbol());
}
}
if s == "tab-b" && cell.modifier.contains(Modifier::BOLD) {
found_active = true;
break;
}
}
}
assert!(found_active, "Active tab should have BOLD modifier");
}
#[test]
fn test_status_indicator_from_session_status() {
assert_eq!(
StatusIndicator::from(&SessionStatus::Starting),
StatusIndicator::Starting
);
assert_eq!(
StatusIndicator::from(&SessionStatus::Running),
StatusIndicator::Running
);
assert_eq!(
StatusIndicator::from(&SessionStatus::Terminated { exit_code: Some(0) }),
StatusIndicator::Terminated
);
}
#[rstest]
#[case(StatusIndicator::Starting, Color::Yellow, "○")]
#[case(StatusIndicator::Running, Color::Green, "●")]
#[case(StatusIndicator::Terminated, Color::Red, "×")]
fn status_indicator_variants(
#[case] indicator: StatusIndicator,
#[case] expected_color: Color,
#[case] expected_symbol: &str,
) {
assert_eq!(indicator.color(), expected_color);
assert_eq!(indicator.symbol(), expected_symbol);
}
mod snapshots {
use super::*;
use crate::tui::test_utils::render_to_snapshot;
use insta::assert_snapshot;
#[test]
fn single_tab_running() {
let tabs = vec![TabItem::new("session-1", StatusIndicator::Running)];
let tab_bar = TabBar::new(&tabs, 0);
assert_snapshot!(render_to_snapshot(tab_bar, 30, 1));
}
#[test]
fn multi_tab_active_highlight() {
let tabs = vec![
TabItem::new("tab-a", StatusIndicator::Running),
TabItem::new("tab-b", StatusIndicator::Running),
];
let tab_bar = TabBar::new(&tabs, 1); assert_snapshot!(render_to_snapshot(tab_bar, 40, 1));
}
#[test]
fn mixed_status_indicators() {
let tabs = vec![
TabItem::new("running", StatusIndicator::Running),
TabItem::new("starting", StatusIndicator::Starting),
TabItem::new("terminated", StatusIndicator::Terminated),
];
let tab_bar = TabBar::new(&tabs, 0);
assert_snapshot!(render_to_snapshot(tab_bar, 60, 1));
}
#[test]
fn truncation_at_boundary() {
let tabs = vec![
TabItem::new("very-long-session-name", StatusIndicator::Running),
TabItem::new("another-session", StatusIndicator::Starting),
];
let tab_bar = TabBar::new(&tabs, 0);
assert_snapshot!(render_to_snapshot(tab_bar, 30, 1));
}
}
}