use ratatui::{
buffer::Buffer,
layout::Rect,
style::{Color, Style},
text::{Line, Span},
widgets::Widget,
};
#[derive(Debug, Clone)]
pub struct StatusBar<'a> {
pub session_count: usize,
pub active_session: Option<&'a str>,
pub active_branch: Option<&'a str>,
pub notifications: usize,
pub today_cost: Option<f64>,
}
impl<'a> StatusBar<'a> {
#[must_use]
pub fn new(session_count: usize) -> Self {
Self {
session_count,
active_session: None,
active_branch: None,
notifications: 0,
today_cost: None,
}
}
#[must_use]
pub fn active_session(mut self, name: &'a str) -> Self {
self.active_session = Some(name);
self
}
#[must_use]
pub fn active_branch(mut self, branch: &'a str) -> Self {
self.active_branch = Some(branch);
self
}
#[must_use]
pub fn notifications(mut self, count: usize) -> Self {
self.notifications = count;
self
}
#[must_use]
pub fn today_cost(mut self, cost: f64) -> Self {
self.today_cost = Some(cost);
self
}
fn build_left_spans(&self) -> Vec<Span<'_>> {
let mut spans = vec![
Span::styled(" [", Style::default().fg(Color::Gray)),
Span::styled(
format!("{}", self.session_count),
Style::default().fg(Color::Cyan),
),
Span::styled("] ", Style::default().fg(Color::Gray)),
];
if let Some(name) = self.active_session {
spans.push(Span::styled(name, Style::default().fg(Color::Green)));
if let Some(branch) = self.active_branch {
spans.push(Span::styled(
format!(" ({branch})"),
Style::default().fg(Color::Gray),
));
}
}
spans
}
fn build_right_spans(&self) -> Vec<Span<'static>> {
let mut spans = Vec::new();
if let Some(cost) = self.today_cost {
spans.push(Span::styled(
format!("${cost:.2}"),
Style::default().fg(Color::Cyan),
));
spans.push(Span::styled(" today", Style::default().fg(Color::Gray)));
}
if self.notifications > 0 {
if !spans.is_empty() {
spans.push(Span::styled(" ", Style::default()));
}
spans.push(Span::styled(
format!("{} pending ", self.notifications),
Style::default().fg(Color::Yellow),
));
}
spans
}
}
impl Widget for StatusBar<'_> {
fn render(self, area: Rect, buf: &mut Buffer) {
if area.height == 0 || area.width == 0 {
return;
}
let left_spans = self.build_left_spans();
let right_spans = self.build_right_spans();
let left_width: usize = left_spans.iter().map(|s| s.content.len()).sum();
let right_width: usize = right_spans.iter().map(|s| s.content.len()).sum();
let total_width = area.width as usize;
let mut all_spans = left_spans;
if total_width > left_width + right_width {
let gap = total_width.saturating_sub(left_width + right_width);
all_spans.push(Span::raw(" ".repeat(gap)));
}
all_spans.extend(right_spans);
let line = Line::from(all_spans);
buf.set_line(area.x, area.y, &line, area.width);
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
use crate::tui::test_utils::buffer_to_text;
#[test]
fn test_status_bar_renders_count() {
let status = StatusBar::new(3);
let area = Rect::new(0, 0, 40, 1);
let mut buf = Buffer::empty(area);
status.render(area, &mut buf);
let output = buffer_to_text(&buf);
assert!(output.contains("[3]"));
}
#[test]
fn test_status_bar_renders_active_name() {
let status = StatusBar::new(2).active_session("feature-auth");
let area = Rect::new(0, 0, 60, 1);
let mut buf = Buffer::empty(area);
status.render(area, &mut buf);
let output = buffer_to_text(&buf);
assert!(output.contains("feature-auth"));
}
#[test]
fn test_status_bar_renders_active_branch() {
let status = StatusBar::new(2)
.active_session("my-session")
.active_branch("main");
let area = Rect::new(0, 0, 80, 1);
let mut buf = Buffer::empty(area);
status.render(area, &mut buf);
let output = buffer_to_text(&buf);
assert!(output.contains("my-session"));
assert!(output.contains("(main)"));
}
#[test]
fn test_status_bar_renders_notifications() {
let status = StatusBar::new(1).notifications(5);
let area = Rect::new(0, 0, 50, 1);
let mut buf = Buffer::empty(area);
status.render(area, &mut buf);
let output = buffer_to_text(&buf);
assert!(output.contains("5 pending"));
}
#[test]
fn test_status_bar_renders_today_cost() {
let status = StatusBar::new(1).today_cost(7.50);
let area = Rect::new(0, 0, 50, 1);
let mut buf = Buffer::empty(area);
status.render(area, &mut buf);
let output = buffer_to_text(&buf);
assert!(output.contains("$7.50"));
assert!(output.contains("today"));
}
#[test]
fn test_status_bar_empty_area() {
let status = StatusBar::new(1);
let area = Rect::new(0, 0, 40, 0);
let mut buf = Buffer::empty(area);
status.render(area, &mut buf);
}
mod snapshots {
use super::*;
use crate::tui::test_utils::render_to_snapshot;
use insta::assert_snapshot;
#[test]
fn minimal_count_only() {
let status = StatusBar::new(3);
assert_snapshot!(render_to_snapshot(status, 30, 1));
}
#[test]
fn with_active_session() {
let status = StatusBar::new(2).active_session("feature-auth");
assert_snapshot!(render_to_snapshot(status, 50, 1));
}
#[test]
fn with_active_branch() {
let status = StatusBar::new(2)
.active_session("my-session")
.active_branch("main");
assert_snapshot!(render_to_snapshot(status, 60, 1));
}
#[test]
fn with_notifications() {
let status = StatusBar::new(1).notifications(5);
assert_snapshot!(render_to_snapshot(status, 50, 1));
}
#[test]
fn with_today_cost() {
let status = StatusBar::new(1).today_cost(7.50);
assert_snapshot!(render_to_snapshot(status, 50, 1));
}
#[test]
fn full_combination() {
let status = StatusBar::new(5)
.active_session("dev-session")
.active_branch("feature/login")
.notifications(3)
.today_cost(12.34);
assert_snapshot!(render_to_snapshot(status, 80, 1));
}
}
}