use ratatui::prelude::*;
use ratatui::widgets::Paragraph;
use crate::app::App;
use super::{font, gauge, theme};
pub fn render(f: &mut Frame, area: Rect, app: &App) {
let inner = gauge::frame(f, area, "Power");
let Some(m) = app.medium.as_ref() else {
return gauge::unavailable(f, inner);
};
let split = Layout::vertical([Constraint::Min(0), Constraint::Length(1)]).split(inner);
let (body, footer) = (split[0], split[1]);
match &m.power {
Some(p) => {
let hero = font::hero_fits(inner);
let head = if hero { gauge::HERO_ROWS } else { 1 };
let rows = Layout::vertical([
Constraint::Length(head),
Constraint::Length(1), Constraint::Min(2), ])
.split(body);
let total = p.cpu_w + p.gpu_w + p.ane_w;
let text = format!("{total:.1} W");
if hero {
gauge::hero(f, rows[0], &text, &format!("{total:.0} W"), theme::ACCENT);
} else {
gauge::readout(f, rows[0], &text, theme::ACCENT);
}
let legend = format!("cpu {:.1} · gpu {:.1} · ane {:.1}", p.cpu_w, p.gpu_w, p.ane_w);
f.render_widget(
Paragraph::new(legend).style(Style::default().fg(theme::DIM)),
rows[1],
);
render_spark(f, rows[2], app);
}
None => gauge::unavailable(f, body),
}
let battery_line = match &m.battery {
Some(b) => {
let state = if b.charging { "↑" } else { "↓" };
let health = b.health_pct.map_or(String::new(), |h| format!(" · h{h}%"));
format!("{state} {}% · {}cyc{health}", b.percent, b.cycles)
}
None => String::new(), };
f.render_widget(
Paragraph::new(battery_line).style(Style::default().fg(theme::DIM)),
footer,
);
}
fn render_spark(f: &mut Frame, area: Rect, app: &App) {
let data: Vec<u64> = app
.power_hist
.iter()
.map(|(c, g, a)| ((c + g + a) * 10.0).round() as u64)
.collect();
if data.is_empty() {
return;
}
let peak = app.power_hist.iter().map(|(c, g, a)| c + g + a).fold(1.0, f64::max) * 1.2;
let max = (peak * 10.0).round() as u64;
super::spark::render(f, area, &data, max, Style::default().fg(theme::ACCENT));
}
#[cfg(test)]
mod tests {
use ratatui::backend::TestBackend;
use ratatui::buffer::Buffer;
use ratatui::Terminal;
use crate::app::App;
fn draw(w: u16, h: u16) -> String {
let mut t = Terminal::new(TestBackend::new(w, h)).unwrap();
let app = App::demo();
t.draw(|f| super::render(f, f.area(), &app)).unwrap();
t.backend().buffer().content().iter().map(|c| c.symbol()).collect()
}
fn draw_buffer(w: u16, h: u16) -> Buffer {
let mut t = Terminal::new(TestBackend::new(w, h)).unwrap();
let app = App::demo();
t.draw(|f| super::render(f, f.area(), &app)).unwrap();
t.backend().buffer().clone()
}
#[test]
fn hero_when_room_compact_when_small() {
let full = draw(40, 12); let full_buf = draw_buffer(40, 12);
let filled =
full_buf.content().iter().filter(|c| c.style().bg == Some(super::theme::ACCENT)).count();
let expected: usize =
crate::ui::font::big_text("7.9 W").iter().flat_map(|r| r.chars()).filter(|&c| c == '#').count();
assert_eq!(filled, expected, "hero bitmap pixel count mismatch for \"7.9 W\"");
assert!(full.contains("cpu 6.4"), "power legend (cpu/gpu/ane) missing");
let compact = draw(40, 10); assert!(compact.contains("7.9 W"));
let compact_buf = draw_buffer(40, 10);
let compact_filled =
compact_buf.content().iter().any(|c| c.style().bg == Some(super::theme::ACCENT));
assert!(!compact_filled, "compact tier must not paint any hero bitmap pixels");
}
#[test]
fn battery_footer_fits_at_120_width_card() {
let full = draw(30, 12);
assert!(
full.contains("↑ 76% · 120cyc · h97%"),
"battery footer clipped or missing at inner width 28"
);
}
#[test]
fn history_renders_block_sparkline() {
let mut t = Terminal::new(TestBackend::new(4, 1)).unwrap();
let mut app = App::new(false);
for v in [(0.6_f64, 0.3, 0.1), (2.0, 0.8, 0.2), (3.5, 1.2, 0.3), (7.0, 2.5, 0.5)] {
app.power_hist.push(v);
}
t.draw(|f| super::render_spark(f, f.area(), &app)).unwrap();
let s: String = t.backend().buffer().content().iter().map(|c| c.symbol()).collect();
assert_eq!(s, " ▂▃▆", "chart mis-scaled or mis-positioned");
assert_eq!(
s.chars().last().unwrap(),
'▆',
"newest sample (10 W, the tallest) must land at the right edge"
);
}
}