use ratatui::Frame;
use ratatui::layout::{Alignment, Constraint, Direction, Layout, Rect};
use ratatui::style::Style;
use ratatui::text::{Line, Span};
use ratatui::widgets::Paragraph;
use super::super::app::{App, Tab};
use super::super::theme;
use super::format::{bar_string_with_cells, fixed_split, name_style};
const VERSION: &str = env!("CARGO_PKG_VERSION");
const GAUGE_NAME_GAP: usize = 2;
const GAUGE_BAR_FULL: usize = 10;
const GAUGE_BAR_MIN: usize = 3;
const GAUGE_NAME_MAX: usize = 16;
const GAUGE_NAME_MIN: usize = 3;
const GAUGE_PCT_W: usize = 4;
const GAUGE_DASH_W: usize = 1;
const PULSE_SWEEP_MS: u64 = 900;
const PULSE_REST_MS: u64 = 1700;
const PULSE_DEPTH: f32 = 0.4;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct GaugeFit {
name_w: usize,
bar_cells: usize,
visible: bool,
}
impl GaugeFit {
const HIDDEN: GaugeFit = GaugeFit {
name_w: 0,
bar_cells: 0,
visible: false,
};
}
struct ActiveGauge {
name: String,
pct: Option<f64>,
style: Style,
}
fn active_gauge(app: &App) -> Option<ActiveGauge> {
let cfg = app.config();
let name = cfg.state.active_profile.as_deref()?;
let profile = cfg.find(name)?;
let pct = if profile.is_oauth() {
profile
.usage
.as_ref()
.and_then(|u| u.five_hour.as_ref())
.map(|w| w.utilization.clamp(0.0, 100.0))
} else {
None
};
Some(ActiveGauge {
name: name.to_string(),
pct,
style: name_style(profile),
})
}
fn gauge_tail_w(has_pct: bool) -> usize {
if has_pct { GAUGE_PCT_W } else { GAUGE_DASH_W }
}
fn gauge_total_w(name_w: usize, bar_cells: usize, has_pct: bool) -> usize {
let mut w = gauge_tail_w(has_pct);
if bar_cells > 0 {
w += bar_cells + 2 + 1;
}
if name_w > 0 {
w += name_w + GAUGE_NAME_GAP;
}
w
}
fn gauge_fit(avail: usize, name_len: usize, has_pct: bool) -> GaugeFit {
if avail < gauge_tail_w(has_pct) {
return GaugeFit::HIDDEN;
}
let mut name_w = name_len.min(GAUGE_NAME_MAX);
let mut bar_cells = if has_pct { GAUGE_BAR_FULL } else { 0 };
let name_floor = GAUGE_NAME_MIN.min(name_len);
while gauge_total_w(name_w, bar_cells, has_pct) > avail && bar_cells > GAUGE_BAR_MIN {
bar_cells -= 1;
}
if bar_cells > 0 && gauge_total_w(name_w, bar_cells, has_pct) > avail {
bar_cells = 0;
}
while gauge_total_w(name_w, bar_cells, has_pct) > avail && name_w > name_floor {
name_w -= 1;
}
if name_w > 0 && gauge_total_w(name_w, bar_cells, has_pct) > avail {
name_w = 0;
}
if gauge_total_w(name_w, bar_cells, has_pct) > avail {
return GaugeFit::HIDDEN;
}
GaugeFit {
name_w,
bar_cells,
visible: true,
}
}
fn gauge_spans(
fit: GaugeFit,
name: &str,
style: Style,
pct: Option<f64>,
elapsed_ms: u64,
) -> Vec<Span<'static>> {
let mut spans = Vec::new();
if fit.name_w > 0 {
let (nt, _pad) = fixed_split(name, fit.name_w);
spans.extend(pulse_name_spans(&nt, style, elapsed_ms));
spans.push(Span::raw(" "));
}
match pct {
Some(pct) if fit.bar_cells > 0 => {
let style = Style::default().fg(theme::util_color(pct));
spans.push(Span::styled("[", theme::dim()));
spans.push(Span::styled(
bar_string_with_cells(pct, fit.bar_cells),
style,
));
spans.push(Span::styled("]", theme::dim()));
spans.push(Span::styled(format!(" {pct:.0}%"), style));
}
Some(pct) => {
spans.push(Span::styled(
format!("{pct:.0}%"),
Style::default().fg(theme::util_color(pct)),
));
}
None => spans.push(Span::styled("—", theme::faint())),
}
spans
}
fn pulse_name_spans(name: &str, style: Style, elapsed_ms: u64) -> Vec<Span<'static>> {
use std::f32::consts::{PI, TAU};
let plain = || vec![Span::styled(name.to_string(), style)];
if theme::tier() != theme::Tier::Full {
return plain();
}
let t = (elapsed_ms % (PULSE_SWEEP_MS + PULSE_REST_MS)) as f32;
let sweep = PULSE_SWEEP_MS as f32;
if t >= sweep {
return plain();
}
let progress = t / sweep;
let envelope = (PI * progress).sin();
let head = progress * TAU;
let len = name.chars().count().max(1) as f32;
let base = style.fg.unwrap_or_else(theme::text_color);
name.chars()
.enumerate()
.map(|(i, ch)| {
let col = (i as f32 / len) * TAU;
let crest = ((col - head).cos() * 0.5 + 0.5).powi(2);
let lean = f64::from(crest * envelope * PULSE_DEPTH);
let fg = theme::blend_over(base, theme::accent_2_color(), lean);
Span::styled(ch.to_string(), style.fg(fg))
})
.collect()
}
pub(super) fn header_height(_app: &App) -> u16 {
3
}
pub(super) fn draw(frame: &mut Frame<'_>, area: Rect, app: &App) {
let cols = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Length(10), Constraint::Min(20)])
.split(area);
draw_logo(frame, cols[0], app);
let rows = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(1); 3])
.split(cols[1]);
let n = app.config().profiles.len();
let info_width = rows[0].width as usize;
let gauge = if app.tab == Tab::Overview || app.compact {
None
} else {
active_gauge(app)
};
let brand = "clauth";
let ver = format!("v{VERSION}");
let gap = info_width.saturating_sub(brand.len() + ver.len());
let title = Line::from(vec![
Span::styled(brand, Style::default().fg(theme::text_color()).bold()),
Span::styled(" ".repeat(gap), theme::base()),
Span::styled(ver, theme::dim()),
]);
frame.render_widget(Paragraph::new(title).style(theme::base()), rows[0]);
let row1_width = rows[1].width as usize;
let prefix = if gauge.is_some() {
format!("{n} account{} · ", plural(n))
} else {
format!("{n} account{}", plural(n))
};
let feed = "status.claude.ai";
let status_head = "● ";
let status_w = status_head.chars().count() + feed.chars().count();
let reserve: usize = 1;
let mut left_spans: Vec<Span<'static>> = vec![Span::styled(prefix, theme::faint())];
if let Some(ref g) = gauge {
let gauge_budget = row1_width.saturating_sub(
left_spans
.iter()
.map(|s| s.content.chars().count())
.sum::<usize>()
+ status_w
+ reserve,
);
let fit = gauge_fit(gauge_budget, g.name.chars().count(), g.pct.is_some());
if fit.visible {
let elapsed = app.started_at.elapsed().as_millis() as u64;
left_spans.extend(gauge_spans(fit, &g.name, g.style, g.pct, elapsed));
}
}
let left_w: usize = left_spans.iter().map(|s| s.content.chars().count()).sum();
let mut row1_spans = left_spans;
if row1_width >= left_w + status_w + reserve {
let gap = row1_width - left_w - status_w;
row1_spans.push(Span::raw(" ".repeat(gap)));
}
row1_spans.push(Span::styled(
status_head,
Style::default().fg(status_dot_color(app)),
));
row1_spans.push(Span::styled(feed, theme::dim()));
frame.render_widget(
Paragraph::new(Line::from(row1_spans)).style(theme::base()),
rows[1],
);
super::tabs::draw(frame, rows[2], app);
}
fn plural(n: usize) -> &'static str {
if n == 1 { "" } else { "s" }
}
fn status_dot_color(app: &App) -> ratatui::style::Color {
use crate::status::Impact;
match app.status.worst_active_impact() {
Impact::Critical | Impact::Major => theme::danger_color(),
Impact::Minor => theme::warning_color(),
Impact::Maintenance => theme::text_dim_color(),
Impact::None | Impact::Other(_) => theme::success_color(),
}
}
fn draw_logo(frame: &mut Frame<'_>, area: Rect, app: &App) {
let elapsed = app.started_at.elapsed().as_millis() as u64;
let blink = (elapsed % 6000) < 200;
let style = Style::default().fg(theme::accent_2_color());
let logo_top = if blink {
" ▐█████▌ "
} else {
" ▐▛███▜▌ "
};
let logo_mid = "▝▜█████▛▘";
let logo_eyes = " ▘▘ ▝▝ ";
let lines = vec![
Line::from(Span::styled(logo_top, style)).alignment(Alignment::Left),
Line::from(Span::styled(logo_mid, style)).alignment(Alignment::Left),
Line::from(Span::styled(logo_eyes, style)).alignment(Alignment::Left),
];
let para = Paragraph::new(lines).style(theme::base());
frame.render_widget(para, area);
}
#[cfg(test)]
#[path = "../../../tests/inline/tui_render_header.rs"]
mod gauge_tests;