use ratatui::{
layout::{Constraint, Direction, Layout, Rect},
style::{Modifier, Style},
text::{Line, Span},
widgets::Paragraph,
Frame,
};
use crate::app::{App, Snapshot};
use crate::collect::{BatteryTick, PowerSource, PowerTick};
use crate::ui::{
graph::GraphStyle,
palette as p,
widgets::{block_bar_styled, panel},
};
pub fn draw(f: &mut Frame, area: Rect, app: &App, snap: &Snapshot) {
let v = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(8), Constraint::Length(7), Constraint::Min(0), ])
.split(area);
draw_battery(f, v[0], &snap.power, app.graph_style);
draw_status(f, v[1], &snap.power);
draw_thermal(f, v[2], &snap.power, app.graph_style);
}
fn draw_battery(f: &mut Frame, area: Rect, pwr: &PowerTick, style: GraphStyle) {
let block = panel("BATTERY");
let inner = block.inner(area);
f.render_widget(block, area);
let Some(bat) = &pwr.battery else {
f.render_widget(
Paragraph::new(Line::from(vec![Span::styled(
"No battery detected (desktop or VM).",
Style::default().fg(p::text_muted()),
)]))
.style(Style::default().bg(p::bg())),
inner,
);
return;
};
let cols = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(60), Constraint::Percentage(40)])
.split(inner);
let pct = (bat.charge_pct / 100.0).clamp(0.0, 1.0);
let color = charge_color(bat.charge_pct, bat.is_charging);
let header_text = state_text(bat);
let bar_lines = vec![
Line::from(vec![Span::styled(
format!("{:>5.0}%", bat.charge_pct),
Style::default().fg(color).add_modifier(Modifier::BOLD),
)]),
Line::from(""),
block_bar_styled(pct, cols[0].width.saturating_sub(2), color, style),
Line::from(""),
Line::from(vec![Span::styled(
header_text,
Style::default().fg(p::text_muted()),
)]),
];
f.render_widget(
Paragraph::new(bar_lines).style(Style::default().bg(p::bg())),
cols[0],
);
let mut lines: Vec<Line> = Vec::new();
if let Some(t) = bat.time_remaining_min {
let h = t / 60;
let m = t % 60;
let label = if bat.is_charging {
"to full"
} else {
"remaining"
};
lines.push(kv(label, format!("{}:{:02}", h, m), p::text_primary()));
} else {
lines.push(kv("remaining", "calculating…".into(), p::text_muted()));
}
if let Some(c) = bat.cycle_count {
lines.push(kv("cycles", c.to_string(), p::text_primary()));
}
if let Some(h) = bat.health_pct {
let color = if h >= 80.0 {
p::status_good()
} else if h >= 60.0 {
p::status_warn()
} else {
p::status_error()
};
lines.push(kv("health", format!("{:.0}%", h), color));
}
if let Some(t) = bat.temp_c {
let color = if t >= 40.0 {
p::status_error()
} else if t >= 35.0 {
p::status_warn()
} else {
p::status_good()
};
lines.push(kv("temp", format!("{:.1}°C", t), color));
}
if let Some(v) = bat.voltage_v {
lines.push(kv("voltage", format!("{:.2} V", v), p::text_muted()));
}
f.render_widget(
Paragraph::new(lines).style(Style::default().bg(p::bg())),
cols[1],
);
}
fn draw_status(f: &mut Frame, area: Rect, pwr: &PowerTick) {
let block = panel("POWER STATUS");
let inner = block.inner(area);
f.render_widget(block, area);
let cols = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Percentage(33),
Constraint::Percentage(33),
Constraint::Percentage(34),
])
.split(inner);
let (src_color, src_glyph) = match pwr.source {
PowerSource::Ac => (p::status_good(), "\u{26A1}"),
PowerSource::Battery => (p::status_warn(), "\u{1F50B}"),
PowerSource::Unknown => (p::text_muted(), "?"),
};
f.render_widget(
Paragraph::new(vec![
Line::from(vec![Span::styled(
"source",
Style::default().fg(p::text_muted()),
)]),
Line::from(""),
Line::from(vec![
Span::styled(format!("{} ", src_glyph), Style::default().fg(src_color)),
Span::styled(
pwr.source.label(),
Style::default().fg(src_color).add_modifier(Modifier::BOLD),
),
]),
])
.style(Style::default().bg(p::bg())),
cols[0],
);
let (throttle_color, throttle_text, throttle_detail): (ratatui::style::Color, String, String) =
match pwr.thermal_throttle_pct {
Some(100) => (
p::status_good(),
"no throttle".into(),
"CPU at 100% nominal speed".into(),
),
Some(n) if n >= 80 => (
p::status_warn(),
format!("{}%", n),
"thermal throttling — mild".into(),
),
Some(n) => (
p::status_error(),
format!("{}%", n),
"thermal throttling — significant".into(),
),
None => (
p::text_muted(),
"—".into(),
"platform doesn't expose throttle state".into(),
),
};
f.render_widget(
Paragraph::new(vec![
Line::from(vec![Span::styled(
"thermal",
Style::default().fg(p::text_muted()),
)]),
Line::from(""),
Line::from(vec![Span::styled(
throttle_text,
Style::default()
.fg(throttle_color)
.add_modifier(Modifier::BOLD),
)]),
Line::from(vec![Span::styled(
throttle_detail,
Style::default().fg(p::text_muted()),
)]),
])
.style(Style::default().bg(p::bg())),
cols[1],
);
let (draw_color, draw_text, draw_detail): (ratatui::style::Color, String, String) =
match pwr.system_power_w {
Some(w) => (
if w >= 30.0 {
p::status_error()
} else if w >= 15.0 {
p::status_warn()
} else {
p::status_good()
},
format!("{:.1} W", w),
"system draw at the battery".into(),
),
None => (
p::text_muted(),
"—".into(),
"needs sudo powermetrics on macOS".into(),
),
};
f.render_widget(
Paragraph::new(vec![
Line::from(vec![Span::styled(
"draw",
Style::default().fg(p::text_muted()),
)]),
Line::from(""),
Line::from(vec![Span::styled(
draw_text,
Style::default().fg(draw_color).add_modifier(Modifier::BOLD),
)]),
Line::from(vec![Span::styled(
draw_detail,
Style::default().fg(p::text_muted()),
)]),
])
.style(Style::default().bg(p::bg())),
cols[2],
);
}
fn draw_thermal(f: &mut Frame, area: Rect, pwr: &PowerTick, style: GraphStyle) {
let cols = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(60), Constraint::Percentage(40)])
.split(area);
let zones_block = panel(format!("THERMAL ZONES {}", pwr.thermal_zones.len()));
let zones_inner = zones_block.inner(cols[0]);
f.render_widget(zones_block, cols[0]);
if pwr.thermal_zones.is_empty() {
let hint = pwr
.live_data_hint
.clone()
.unwrap_or_else(|| "platform doesn't expose thermal zones without sudo.".into());
f.render_widget(
Paragraph::new(vec![
Line::from(vec![Span::styled(
"no zones reported",
Style::default().fg(p::text_muted()),
)]),
Line::from(""),
Line::from(vec![Span::styled(hint, Style::default().fg(p::border()))]),
])
.style(Style::default().bg(p::bg())),
zones_inner,
);
} else {
let mut lines = Vec::new();
for z in pwr.thermal_zones.iter() {
let color = if z.temp_c >= 80.0 {
p::status_error()
} else if z.temp_c >= 60.0 {
p::status_warn()
} else {
p::status_good()
};
let bar = block_bar_styled(
(z.temp_c / 100.0).clamp(0.0, 1.0),
zones_inner.width.saturating_sub(28),
color,
style,
);
let mut spans = vec![
Span::styled(
format!("{:<18.18} ", z.name),
Style::default().fg(p::text_primary()),
),
Span::styled(format!("{:>5.1}°C ", z.temp_c), Style::default().fg(color)),
];
spans.extend(bar.spans);
lines.push(Line::from(spans));
}
f.render_widget(
Paragraph::new(lines).style(Style::default().bg(p::bg())),
zones_inner,
);
}
let fans_block = panel(format!("FANS {}", pwr.fans.len()));
let fans_inner = fans_block.inner(cols[1]);
f.render_widget(fans_block, cols[1]);
if pwr.fans.is_empty() {
let hint = pwr.live_data_hint.clone().unwrap_or_else(|| {
"no fans reported (passive cooling or platform doesn't expose them).".into()
});
f.render_widget(
Paragraph::new(vec![
Line::from(vec![Span::styled(
"no fan data",
Style::default().fg(p::text_muted()),
)]),
Line::from(""),
Line::from(vec![Span::styled(hint, Style::default().fg(p::border()))]),
])
.style(Style::default().bg(p::bg())),
fans_inner,
);
} else {
let lines: Vec<Line> = pwr
.fans
.iter()
.map(|fan| {
Line::from(vec![
Span::styled(
format!("{:<10.10} ", fan.name),
Style::default().fg(p::text_primary()),
),
Span::styled(
format!("{:>5} RPM", fan.rpm),
Style::default().fg(p::brand()),
),
Span::styled(
match fan.target_rpm {
Some(t) => format!(" → {} target", t),
None => String::new(),
},
Style::default().fg(p::text_muted()),
),
])
})
.collect();
f.render_widget(
Paragraph::new(lines).style(Style::default().bg(p::bg())),
fans_inner,
);
}
}
fn state_text(bat: &BatteryTick) -> String {
if bat.fully_charged {
"fully charged".into()
} else if bat.is_charging {
format!(
"charging{}",
bat.amperage_ma
.map(|a| format!(" @ {:.1} A", a as f32 / 1000.0))
.unwrap_or_default()
)
} else {
format!(
"discharging{}",
bat.amperage_ma
.map(|a| format!(" @ {:.1} A", a.unsigned_abs() as f32 / 1000.0))
.unwrap_or_default()
)
}
}
fn charge_color(pct: f32, is_charging: bool) -> ratatui::style::Color {
if is_charging {
p::brand()
} else if pct <= 15.0 {
p::status_error()
} else if pct <= 30.0 {
p::status_warn()
} else {
p::status_good()
}
}
fn kv(k: &str, v: String, val_color: ratatui::style::Color) -> Line<'static> {
Line::from(vec![
Span::styled(format!("{:<10} ", k), Style::default().fg(p::text_muted())),
Span::styled(v, Style::default().fg(val_color)),
])
}