use ratatui::Frame;
use ratatui::layout::Rect;
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::Paragraph;
use super::super::app::{App, Tab, ToastKind};
use super::super::theme;
const SEP: &str = " ";
const SEP_WIDTH: usize = 3;
const PREV_MARK: &str = "‹ ";
const NEXT_MARK: &str = " ›";
pub(super) fn draw(frame: &mut Frame<'_>, area: Rect, app: &App) {
let avail = area.width as usize;
let spans = if fits_normal(avail) {
build_normal(app, avail)
} else {
build_overflow(app)
};
let para = Paragraph::new(Line::from(spans)).style(theme::base());
frame.render_widget(para, area);
}
fn full_strip_width() -> usize {
Tab::ALL
.iter()
.enumerate()
.map(|(i, t)| {
let sep = if i > 0 { SEP_WIDTH } else { 0 };
sep + t.title().len()
})
.sum()
}
fn fits_normal(avail: usize) -> bool {
full_strip_width() <= avail
}
fn build_normal(app: &App, _avail: usize) -> Vec<Span<'static>> {
let mut spans: Vec<Span<'static>> = Vec::new();
for (i, tab) in Tab::ALL.iter().enumerate() {
if i > 0 {
spans.push(Span::styled(SEP, theme::dim()));
}
let label = tab.title().to_lowercase();
if *tab == app.tab {
spans.push(Span::styled(
label,
Style::default()
.fg(theme::accent_color())
.add_modifier(Modifier::BOLD | Modifier::UNDERLINED),
));
} else {
let color = activity_color(app.tab_activity[tab.index()]);
spans.push(Span::styled(label, Style::default().fg(color)));
}
}
spans
}
fn build_overflow(app: &App) -> Vec<Span<'static>> {
let active_idx = app.tab.index();
let last_idx = Tab::ALL.len().saturating_sub(1);
let mut spans: Vec<Span<'static>> = Vec::new();
if active_idx > 0 {
spans.push(Span::styled(PREV_MARK, theme::faint()));
}
let label = app.tab.title().to_lowercase();
spans.push(Span::styled(
label,
Style::default()
.fg(theme::accent_color())
.add_modifier(Modifier::BOLD | Modifier::UNDERLINED),
));
if active_idx < last_idx {
spans.push(Span::styled(NEXT_MARK, theme::faint()));
}
spans
}
fn activity_color(activity: Option<ToastKind>) -> Color {
match activity {
None => theme::text_dim_color(),
Some(ToastKind::Success) => theme::success_color(),
Some(ToastKind::Danger) => theme::danger_color(),
Some(ToastKind::Warning) => theme::warning_color(),
Some(ToastKind::Info) => theme::text_color(),
}
}
#[cfg(test)]
mod tab_overflow_tests {
use super::*;
use crate::profile::{AppConfig, AppState};
use crate::tui::app::App;
use ratatui::Terminal;
use ratatui::backend::TestBackend;
fn truncate_label(label: String, max_chars: usize) -> String {
let char_count = label.chars().count();
if char_count <= max_chars {
return label;
}
if max_chars == 0 {
return String::new();
}
if let Some(suffix_start) = label.rfind('(') {
let suffix = &label[suffix_start..];
let body = &label[..suffix_start];
let suffix_chars = suffix.chars().count();
let body_budget = max_chars.saturating_sub(suffix_chars + 1); if body_budget > 0 {
let trimmed_body: String = body.chars().take(body_budget).collect();
return format!("{trimmed_body}…{suffix}");
}
}
let trimmed: String = label.chars().take(max_chars.saturating_sub(1)).collect();
format!("{trimmed}…")
}
fn empty_app(active: Tab) -> App {
let mut app = App::new(AppConfig {
state: AppState::default(),
profiles: Vec::new(),
});
app.tab = active;
app
}
fn render_tabs(app: &App, width: u16) -> String {
let mut term = Terminal::new(TestBackend::new(width, 1)).unwrap();
term.draw(|f| {
let area = f.area();
super::draw(f, area, app);
})
.unwrap();
let buf = term.backend().buffer().clone();
(0..width)
.map(|x| buf.content[x as usize].symbol().to_owned())
.collect()
}
#[test]
fn normal_form_shows_all_tabs() {
let app = empty_app(Tab::Overview);
let s = render_tabs(&app, 70);
assert!(s.contains("overview"), "active tab missing");
assert!(s.contains("usage"), "inactive tab missing");
assert!(s.contains("setup"), "inactive tab missing");
assert!(s.contains("fallback"), "inactive tab missing");
assert!(s.contains("config"), "inactive tab missing");
assert!(s.contains("status"), "inactive tab missing");
}
#[test]
fn normal_form_labels_untruncated_at_tight_boundary() {
let app = empty_app(Tab::Overview);
let s = render_tabs(&app, 53);
assert!(
s.contains("overview"),
"overview must not be truncated at tight boundary"
);
assert!(
s.contains("status"),
"status must not be truncated at tight boundary"
);
}
#[test]
fn overflow_form_shows_only_active() {
let app = empty_app(Tab::Usage);
let s = render_tabs(&app, 10);
assert!(
s.contains("usage"),
"active label must appear in overflow form"
);
assert!(
!s.contains("overview"),
"non-active tab must not appear in overflow"
);
assert!(
!s.contains("config"),
"non-active tab must not appear in overflow"
);
}
#[test]
fn overflow_chevrons_at_middle_tab() {
let app = empty_app(Tab::Usage); let s = render_tabs(&app, 15);
assert!(
s.contains('‹'),
"left chevron must appear for non-first tab"
);
assert!(
s.contains('›'),
"right chevron must appear for non-last tab"
);
}
#[test]
fn overflow_no_left_chevron_at_first_tab() {
let app = empty_app(Tab::Overview); let s = render_tabs(&app, 12);
assert!(!s.contains('‹'), "no left chevron at first tab");
assert!(
s.contains('›'),
"right chevron must appear when more tabs follow"
);
}
#[test]
fn overflow_no_right_chevron_at_last_tab() {
let app = empty_app(Tab::Status); let s = render_tabs(&app, 12);
assert!(
s.contains('‹'),
"left chevron must appear for non-first tab"
);
assert!(!s.contains('›'), "no right chevron at last tab");
}
#[test]
fn truncate_label_plain() {
assert_eq!(truncate_label("overview".into(), 5), "over…");
assert_eq!(truncate_label("overview".into(), 8), "overview");
assert_eq!(truncate_label("overview".into(), 0), "");
}
#[test]
fn truncate_label_preserves_suffix() {
assert_eq!(truncate_label("my-long-name(45%)".into(), 10), "my-l…(45%)");
assert_eq!(truncate_label("done(✓)".into(), 6), "do…(✓)");
}
}