#![allow(
clippy::cast_possible_truncation,
clippy::cast_possible_wrap,
clippy::cast_lossless
)]
use ratatui::layout::Rect;
use ratatui::style::Style;
use crate::widget::{RenderCtx, Renderable, hash_combine, hash_str};
const SPINNER_FRAMES: &[&str] = &["|", "/", "-", "\\"];
#[derive(Debug, Clone)]
pub struct Footer {
model: String,
tokens_in: u64,
tokens_out: u64,
cost: f64,
spinner_phase: usize,
}
impl Footer {
#[must_use]
pub fn new() -> Self {
Self::default()
}
pub fn set_model(&mut self, model: impl Into<String>) {
self.model = model.into();
}
pub const fn set_tokens(&mut self, tin: u64, tout: u64) {
self.tokens_in = tin;
self.tokens_out = tout;
}
pub const fn set_cost(&mut self, cost: f64) {
self.cost = cost;
}
pub fn advance_spinner(&mut self) {
self.spinner_phase = self.spinner_phase.wrapping_add(1) % SPINNER_FRAMES.len();
}
fn segments(&self) -> (String, String, String) {
let model = if self.model.is_empty() {
"—".to_string()
} else {
self.model.clone()
};
let tokens = format!("{} in / {} out", self.tokens_in, self.tokens_out);
let cost = format_cost(self.cost);
let spinner = SPINNER_FRAMES[self.spinner_phase % SPINNER_FRAMES.len()];
let right = format!("{cost} {spinner}");
(model, tokens, right)
}
}
impl Default for Footer {
fn default() -> Self {
Self {
model: String::new(),
tokens_in: 0,
tokens_out: 0,
cost: 0.0,
spinner_phase: 0,
}
}
}
impl Renderable for Footer {
fn content_hash(&self) -> u64 {
hash_combine(
hash_combine(
hash_str(&self.model),
hash_combine(self.tokens_in, self.tokens_out),
),
hash_combine(self.cost.to_bits(), self.spinner_phase as u64),
)
}
fn height_for(&self, _width: u16, _ctx: &RenderCtx) -> u16 {
1
}
fn render(&mut self, area: Rect, ctx: &mut RenderCtx) {
if area.width == 0 || area.height == 0 {
return;
}
let styles = ctx.theme().styles;
let bg_style = styles.surface_bg;
let fg_model = bg_style.patch(styles.accent);
let fg_tokens = bg_style.patch(styles.muted);
let fg_right = bg_style.patch(styles.normal);
let (model, tokens, right) = self.segments();
let buf = ctx.buffer_mut();
for dx in 0..area.width {
let x = area.x + dx;
let cell = &mut buf[(x, area.y)];
cell.set_symbol(" ");
cell.set_style(bg_style);
}
let width = area.width as usize;
let model_len = model.chars().count();
let tokens_len = tokens.chars().count();
let right_len = right.chars().count();
let total = model_len + tokens_len + right_len + 3;
if total > width {
paint_left_to_right(
buf,
area,
width,
&[
(model.as_str(), fg_model),
(tokens.as_str(), fg_tokens),
(right.as_str(), fg_right),
],
);
return;
}
let slack = width - (model_len + tokens_len + right_len + 3);
let left_gap = slack / 2;
let mut col = area.x as usize;
col = paint_text_clipped(buf, area.y, col, " ", bg_style, width, 1);
col = paint_text_clipped(buf, area.y, col, &model, fg_model, width, model_len);
col = paint_text_clipped(buf, area.y, col, " ", bg_style, width, 1 + left_gap);
col = paint_text_clipped(buf, area.y, col, &tokens, fg_tokens, width, tokens_len);
let trailing_pad = 1 + (slack - left_gap);
col = paint_text_clipped(buf, area.y, col, " ", bg_style, width, trailing_pad);
let remaining = width.saturating_sub(col - area.x as usize);
let _ = paint_text_clipped(buf, area.y, col, &right, fg_right, width, remaining);
}
}
fn format_cost(cost: f64) -> String {
if cost < 0.0 {
format!("-${:.2}", cost.abs())
} else {
format!("${cost:.2}")
}
}
fn paint_text_clipped(
buf: &mut ratatui::buffer::Buffer,
y: u16,
col: usize,
text: &str,
style: Style,
row_width: usize,
count: usize,
) -> usize {
let mut col = col;
let max = col.saturating_add(count).min(row_width);
let mut emitted = 0usize;
for ch in text.chars() {
if col >= max || emitted >= count {
break;
}
let cell = &mut buf[(col as u16, y)];
let mut s = String::with_capacity(ch.len_utf8());
s.push(ch);
cell.set_symbol(&s);
cell.set_style(style);
col = col.saturating_add(1);
emitted = emitted.saturating_add(1);
}
if text == " " && emitted < count {
while col < max && emitted < count {
let cell = &mut buf[(col as u16, y)];
cell.set_symbol(" ");
cell.set_style(style);
col = col.saturating_add(1);
emitted = emitted.saturating_add(1);
}
}
col
}
fn paint_left_to_right(
buf: &mut ratatui::buffer::Buffer,
area: Rect,
row_width: usize,
segments: &[(&str, Style); 3],
) {
let mut col = area.x as usize;
for (text, style) in segments {
let remaining = row_width.saturating_sub(col - area.x as usize);
if remaining == 0 {
break;
}
col = paint_text_clipped(buf, area.y, col, text, *style, row_width, remaining);
}
while col < row_width {
let cell = &mut buf[(col as u16, area.y)];
cell.set_symbol(" ");
cell.set_style(segments[2].1);
col = col.saturating_add(1);
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::theme::{TerminalCaps, Theme};
use ratatui::Terminal;
use ratatui::backend::TestBackend;
use ratatui::buffer::Buffer;
fn render_footer(footer: &mut Footer, width: u16, height: u16) -> Buffer {
let backend = TestBackend::new(width, height);
let mut term = Terminal::new(backend).unwrap();
term.draw(|frame| {
let theme = Theme::dark();
let caps = TerminalCaps::default();
let mut ctx = RenderCtx::new(frame, &theme, &caps);
let area = Rect {
x: 0,
y: 0,
width,
height,
};
footer.render(area, &mut ctx);
})
.unwrap();
term.backend().buffer().clone()
}
#[test]
fn new_equals_default() {
let a = Footer::new();
let b = Footer::default();
assert_eq!(a.content_hash(), b.content_hash());
}
#[test]
fn footer_renders_model_and_tokens() {
let mut footer = Footer::new();
footer.set_model("claude-sonnet-4.5");
footer.set_tokens(123, 456);
footer.set_cost(0.42);
let buf = render_footer(&mut footer, 80, 1);
let row: String = (0..80u16)
.map(|x| buf[(x, 0)].symbol().to_string())
.collect();
assert!(
row.contains("claude-sonnet-4.5"),
"row should contain model name, got: {row:?}",
);
assert!(row.contains("123"), "row should contain input tokens");
assert!(row.contains("456"), "row should contain output tokens");
assert!(row.contains('$'), "row should contain cost");
assert!(row.contains("in"), "row should contain `in` label");
assert!(row.contains("out"), "row should contain `out` label");
}
#[test]
fn footer_spinner_advances_hash() {
let mut footer = Footer::new();
let h0 = footer.content_hash();
footer.advance_spinner();
let h1 = footer.content_hash();
footer.advance_spinner();
let h2 = footer.content_hash();
assert_ne!(h0, h1, "first advance should change hash");
assert_ne!(h1, h2, "second advance should change hash");
assert_ne!(h0, h2, "hash should differ across phases");
}
#[test]
fn spinner_phase_wraps_modulo_frame_count() {
let mut footer = Footer::new();
let h_at_start = footer.content_hash();
for _ in 0..(SPINNER_FRAMES.len() * 3) {
footer.advance_spinner();
}
assert_eq!(
footer.content_hash(),
h_at_start,
"spinner phase should wrap after SPINNER_FRAMES.len() advances",
);
}
#[test]
fn footer_hash_changes_on_data_mutation() {
let mut footer = Footer::new();
let h0 = footer.content_hash();
footer.set_model("gpt-4");
assert_ne!(footer.content_hash(), h0);
footer.set_tokens(10, 20);
let h_after_tokens = footer.content_hash();
footer.set_cost(0.5);
assert_ne!(footer.content_hash(), h_after_tokens);
let _ = h_after_tokens;
}
#[test]
fn footer_hash_stable_on_same_state() {
let mut a = Footer::new();
a.set_model("same");
a.set_tokens(1, 2);
a.set_cost(0.01);
let mut b = Footer::new();
b.set_model("same");
b.set_tokens(1, 2);
b.set_cost(0.01);
assert_eq!(a.content_hash(), b.content_hash());
}
#[test]
fn empty_render_paints_no_cells() {
let mut footer = Footer::new();
let buf = render_footer(&mut footer, 0, 0);
assert_eq!(*buf.area(), Rect::new(0, 0, 0, 0));
}
#[test]
fn footer_fits_narrow_terminal() {
let mut footer = Footer::new();
footer.set_model("claude");
footer.set_tokens(1, 2);
footer.set_cost(0.1);
let buf = render_footer(&mut footer, 10, 1);
let joined: String = (0..10u16)
.map(|x| buf[(x, 0)].symbol().to_string())
.collect();
assert_eq!(joined.chars().count(), 10);
}
}