use std::path::Path;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::{Modifier, Style};
use ratatui::widgets::Widget;
use crate::app::App;
use crate::data::format_rate_limit_window;
pub fn draw(f: &mut ratatui::Frame<'_>, area: Rect, app: &App) {
StatusBar { app }.render(area, f.buffer_mut());
}
pub struct StatusBar<'a> {
pub app: &'a App,
}
impl Widget for StatusBar<'_> {
fn render(self, area: Rect, buf: &mut Buffer) {
if area.width == 0 || area.height == 0 {
return;
}
let muted = self.app.capabilities.muted();
let metrics = system_metrics_string(&self.app.sysinfo);
let area_w = area.width as usize;
let metrics_w = metrics.chars().count();
let path_str = display_path(&self.app.team.root);
let gutter = 1usize;
let metrics_will_render = area_w >= path_min_width() + metrics_w + gutter;
let path_budget = if metrics_will_render {
area_w.saturating_sub(metrics_w + gutter)
} else {
area_w
};
let path_rendered = truncate_path_middle(&path_str, path_budget);
buf.set_string(area.x, area.y, &path_rendered, Style::default().fg(muted));
let center_text: Option<String> = if self.app.rate_limit_indicator_enabled {
self.app
.selected_agent
.and_then(|i| self.app.team.agents.get(i))
.and_then(|a| {
let now_unix = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs_f64())
.unwrap_or(0.0);
format_rate_limit_window(a.rate_limit_resets_at, now_unix)
})
.map(|w| format!("limit {w}"))
} else {
None
};
let metrics_x_or_end = if metrics_will_render {
area_w.saturating_sub(metrics_w)
} else {
area_w
};
if let Some(text) = center_text {
let text_w = text.chars().count();
let path_actual_w = path_rendered.chars().count();
let center_left_bound = path_actual_w + gutter;
let center_right_bound = metrics_x_or_end.saturating_sub(gutter);
if center_right_bound > center_left_bound
&& text_w <= center_right_bound - center_left_bound
{
let avail = center_right_bound - center_left_bound;
let pad = (avail - text_w) / 2;
let center_x = area.x + (center_left_bound + pad) as u16;
buf.set_string(center_x, area.y, &text, Style::default().fg(muted));
}
}
if metrics_will_render {
let metrics_x = area.x + (area_w as u16 - metrics_w as u16);
buf.set_string(
metrics_x,
area.y,
&metrics,
Style::default().fg(muted).add_modifier(Modifier::DIM),
);
}
}
}
fn path_min_width() -> usize {
12
}
fn display_path(path: &Path) -> String {
if let Some(home) = dirs::home_dir() {
if let Ok(rest) = path.strip_prefix(&home) {
if rest.as_os_str().is_empty() {
return "~".to_string();
}
return format!("~/{}", rest.display());
}
}
path.display().to_string()
}
fn truncate_path_middle(path: &str, max_width: usize) -> String {
let total = path.chars().count();
if total <= max_width {
return path.to_string();
}
if max_width <= 1 {
return "…".to_string();
}
let budget = max_width - 1;
let basename_len = path
.rsplit('/')
.next()
.map(|s| s.chars().count())
.unwrap_or(0);
if basename_len + 4 < budget {
let head_len = budget - basename_len;
let head: String = path.chars().take(head_len).collect();
let tail: String = path
.chars()
.rev()
.take(basename_len)
.collect::<Vec<_>>()
.into_iter()
.rev()
.collect();
return format!("{head}…{tail}");
}
let tail: String = path
.chars()
.rev()
.take(budget)
.collect::<Vec<_>>()
.into_iter()
.rev()
.collect();
format!("…{tail}")
}
fn system_metrics_string(sys: &sysinfo::System) -> String {
let cpu = global_cpu_percent(sys);
let (used_gb, total_gb) = ram_used_total_gb(sys);
format!("CPU {cpu}% · RAM {used_gb:.1}/{total_gb:.0} GB")
}
fn global_cpu_percent(sys: &sysinfo::System) -> u8 {
sys.global_cpu_usage().round().clamp(0.0, 100.0) as u8
}
fn ram_used_total_gb(sys: &sysinfo::System) -> (f32, f32) {
const GB: f32 = 1_000_000_000.0;
let used = sys.used_memory() as f32 / GB;
let total = sys.total_memory() as f32 / GB;
(used, total)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn truncate_path_middle_returns_path_unchanged_when_it_fits() {
assert_eq!(
truncate_path_middle("/home/user/proj", 32),
"/home/user/proj"
);
}
#[test]
fn truncate_path_middle_preserves_basename_with_head_ellipsis() {
let truncated = truncate_path_middle("/home/alireza/dev/projects/teamctl", 20);
assert!(
truncated.ends_with("teamctl"),
"basename lost: {truncated:?}"
);
assert!(truncated.contains('…'), "no ellipsis: {truncated:?}");
assert!(truncated.chars().count() <= 20);
}
#[test]
fn truncate_path_middle_hard_clips_when_basename_overflows() {
let truncated =
truncate_path_middle("/home/user/extremely-long-project-directory-name", 12);
assert!(
truncated.starts_with('…'),
"no leading ellipsis: {truncated:?}"
);
assert!(truncated.chars().count() <= 12);
}
#[test]
fn truncate_path_middle_degenerate_widths() {
assert_eq!(truncate_path_middle("/long/path", 0), "…");
assert_eq!(truncate_path_middle("/long/path", 1), "…");
}
#[test]
fn truncate_path_middle_counts_chars_not_bytes() {
let truncated = truncate_path_middle("/home/usér/projet", 13);
assert!(truncated.chars().count() <= 13);
}
#[test]
fn display_path_collapses_home_prefix() {
if let Some(home) = dirs::home_dir() {
let under_home = home.join("dev/projects/teamctl/.team");
let rendered = display_path(&under_home);
assert!(
rendered.starts_with("~/"),
"expected ~-prefix: {rendered:?}"
);
assert!(rendered.ends_with("teamctl/.team"));
}
}
#[test]
fn display_path_returns_full_path_outside_home() {
let outside = Path::new("/tmp/teamctl-fixture/.team");
let rendered = display_path(outside);
assert_eq!(rendered, "/tmp/teamctl-fixture/.team");
}
#[test]
fn display_path_handles_path_equal_to_home() {
if let Some(home) = dirs::home_dir() {
assert_eq!(display_path(&home), "~");
}
}
#[test]
fn metrics_string_is_compact_and_well_formed() {
let mut sys = sysinfo::System::new();
sys.refresh_memory();
sys.refresh_cpu_usage();
let s = system_metrics_string(&sys);
assert!(s.starts_with("CPU "), "metrics shape changed: {s:?}");
assert!(s.contains(" · RAM "), "separator missing: {s:?}");
assert!(s.ends_with(" GB"), "trailing unit missing: {s:?}");
assert!(s.chars().count() < 30, "metrics too wide: {s:?}");
}
#[test]
fn path_min_width_is_reasonable() {
assert!(path_min_width() >= 10);
assert!(path_min_width() <= 16);
}
}