use std::time::{Duration, Instant};
use ratatui::Frame;
use ratatui::layout::Rect;
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::Paragraph;
use crate::chart::meter_line;
use crate::config::BatteryConfig;
use crate::frame::Binding;
use crate::glyphs;
use crate::panel::{Panel, RenderContext};
const MAX_INTERIOR_ROWS: u16 = 3;
const COLUMNS_PER_ROW: u16 = 6;
const MIN_BODY: u16 = 4;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum Flow {
Discharging,
Charging,
Full,
Holding,
Unknown,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub(crate) struct Reading {
pub charge_pct: u16,
pub flow: Flow,
pub remaining: Option<Duration>,
pub health_pct: Option<u16>,
pub cycles: Option<u32>,
pub watts: f32,
}
pub struct BatteryPanel {
config: BatteryConfig,
manager: Option<starship_battery::Manager>,
reading: Option<Reading>,
error: Option<String>,
last_sample: Option<Instant>,
}
impl std::fmt::Debug for BatteryPanel {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("BatteryPanel")
.field("reading", &self.reading)
.field("error", &self.error)
.finish_non_exhaustive()
}
}
impl BatteryPanel {
pub fn new(config: BatteryConfig) -> Self {
let (manager, error) = match starship_battery::Manager::new() {
Ok(manager) => (Some(manager), None),
Err(e) => (None, Some(e.to_string())),
};
let mut panel = Self {
config,
manager,
reading: None,
error,
last_sample: None,
};
panel.sample();
panel
}
#[cfg(test)]
pub(crate) fn with_reading(config: BatteryConfig, reading: Option<Reading>) -> Self {
Self {
config,
manager: None,
reading,
error: None,
last_sample: Some(Instant::now()),
}
}
#[cfg(test)]
pub(crate) fn canned(config: BatteryConfig) -> Self {
Self::with_reading(
config,
Some(Reading {
charge_pct: 80,
flow: Flow::Discharging,
remaining: Some(Duration::from_mins(192)),
health_pct: Some(100),
cycles: Some(3),
watts: 12.4,
}),
)
}
fn sample(&mut self) -> bool {
let interval = Duration::from_secs(self.config.sample_secs.max(1));
if self.last_sample.is_some_and(|at| at.elapsed() < interval) {
return false;
}
self.last_sample = Some(Instant::now());
let Some(manager) = &self.manager else {
return false;
};
let before = self.reading;
match manager.batteries() {
Err(e) => {
self.error = Some(e.to_string());
self.reading = None;
}
Ok(batteries) => {
self.error = None;
self.reading = batteries.flatten().next().map(|b| read(&b));
}
}
before != self.reading
}
fn colour(&self, reading: Reading, theme: &crate::theme::Theme) -> Color {
match reading.flow {
Flow::Charging | Flow::Full => theme.success,
Flow::Discharging if reading.charge_pct < self.config.alert_below_pct => theme.error,
Flow::Discharging if reading.charge_pct < self.config.warn_below_pct => theme.warning,
_ => theme.accent,
}
}
}
fn read(b: &starship_battery::Battery) -> Reading {
use starship_battery::State;
use starship_battery::units::power::watt;
use starship_battery::units::ratio::percent;
use starship_battery::units::time::second;
let pct = |ratio: starship_battery::units::Ratio| -> u16 {
ratio.get::<percent>().round().clamp(0.0, 100.0) as u16
};
let secs =
|t: starship_battery::units::Time| Duration::from_secs(t.get::<second>().max(0.0) as u64);
let watts = b.energy_rate().get::<watt>();
let flow = match b.state() {
State::Charging => Flow::Charging,
State::Discharging | State::Empty => Flow::Discharging,
State::Full => Flow::Full,
State::Paused => Flow::Holding,
State::Unknown if watts.abs() < 0.05 => Flow::Holding,
State::Unknown => Flow::Unknown,
};
let remaining = match flow {
Flow::Discharging => b.time_to_empty().map(secs),
Flow::Charging => b.time_to_full().map(secs),
_ => None,
};
Reading {
charge_pct: pct(b.state_of_charge()),
flow,
remaining,
health_pct: Some(pct(b.state_of_health())),
cycles: b.cycle_count(),
watts,
}
}
pub(crate) fn describe_remaining(left: Duration) -> String {
let minutes = left.as_secs() / 60;
match (minutes / 60, minutes % 60) {
(0, 0) => "under a minute".to_string(),
(0, m) => format!("{m}m"),
(h, 0) => format!("{h}h"),
(h, m) => format!("{h}h {m}m"),
}
}
fn label_for(flow: Flow) -> &'static str {
match flow {
Flow::Discharging => "on battery",
Flow::Charging => "charging",
Flow::Full => "full",
Flow::Holding => "plugged in",
Flow::Unknown => "battery",
}
}
const BINDINGS: &[Binding] = &[];
impl Panel for BatteryPanel {
fn title(&self) -> String {
"Battery".to_string()
}
fn counter(&self) -> Option<String> {
let reading = self.reading?;
match (reading.flow, reading.remaining) {
(Flow::Discharging, Some(left)) => Some(format!("{} left", describe_remaining(left))),
(Flow::Charging, Some(left)) => Some(format!("{} to full", describe_remaining(left))),
_ => None,
}
}
fn bindings(&self) -> &'static [Binding] {
BINDINGS
}
fn refresh_interval(&self) -> Duration {
Duration::from_secs(5)
}
fn tick(&mut self) -> bool {
self.sample()
}
fn alert(&self) -> Option<crate::panel::Alert> {
let reading = self.reading?;
if reading.flow != Flow::Discharging || reading.charge_pct >= self.config.alert_below_pct {
return None;
}
let left = reading
.remaining
.map(|left| format!(" — {} left", describe_remaining(left)))
.unwrap_or_default();
Some(crate::panel::Alert::soon(format!(
"Battery at {}%{left}",
reading.charge_pct
)))
}
fn render(&mut self, frame: &mut Frame, area: Rect, ctx: RenderContext<'_>) {
let theme = ctx.theme;
if area.width == 0 || area.height == 0 {
return;
}
let Some(reading) = self.reading else {
self.draw_empty(frame, area, theme);
return;
};
let colour = self.colour(reading, theme);
let muted = Style::default().fg(theme.muted);
let bottom = area.y + area.height;
let figure = format!("{}%", reading.charge_pct);
let face = Face::fit(area.width, area.height, &figure);
let mut cursor = area.y + area.height.saturating_sub(face.rows()) / 2;
if face.label && cursor < bottom {
let label = glyphs::utility(label_for(reading.flow));
draw_centred(frame, area, cursor, &label, colour);
cursor += 1;
}
cursor += draw_cell(
frame,
area,
cursor,
face.cell_rows,
reading.charge_pct,
&figure,
colour,
theme,
);
if face.detail && cursor < bottom {
let parts = detail_parts(reading, muted);
if !parts.is_empty() {
frame.render_widget(
Paragraph::new(crate::grid::assemble(parts, area.width)).centered(),
Rect::new(area.x, cursor, area.width, 1),
);
}
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct Face {
label: bool,
cell_rows: u16,
detail: bool,
}
impl Face {
fn fit(width: u16, height: u16, figure: &str) -> Self {
let outlined = width >= chrome_width(figure) + MIN_BODY;
let (label, cell_rows, detail) = match height {
0 | 1 => (false, 1, false),
2 => (true, 1, false),
3 if outlined => (false, 3, false),
4 if outlined => (true, 3, false),
_ if outlined => (true, (height - 2).min(MAX_INTERIOR_ROWS + 2), true),
_ => (true, 1, true),
};
Self {
label,
cell_rows,
detail,
}
}
fn rows(self) -> u16 {
u16::from(self.label) + self.cell_rows + u16::from(self.detail)
}
}
#[allow(clippy::too_many_arguments)] fn draw_cell(
frame: &mut Frame,
area: Rect,
top: u16,
rows: u16,
charge_pct: u16,
figure: &str,
colour: Color,
theme: &crate::theme::Theme,
) -> u16 {
let bottom = area.y + area.height;
if top >= bottom || area.width == 0 {
return 0;
}
let figure_w = u16::try_from(crate::grid::display_width(figure)).unwrap_or(u16::MAX);
let chrome = chrome_width(figure);
let outline = Style::default().fg(theme.muted);
let fill = Style::default().fg(colour);
let track = Style::default().fg(theme.track);
let figure_spans = |gap: &'static str| {
let (digits, unit) = figure.split_at(figure.len().saturating_sub(1));
vec![
Span::raw(gap),
Span::styled(digits.to_string(), fill.add_modifier(Modifier::BOLD)),
Span::styled(unit.to_string(), outline),
]
};
let interior_rows = rows.saturating_sub(2);
if rows >= 3 && area.width >= chrome + MIN_BODY {
let body = (area.width - chrome).min(COLUMNS_PER_ROW * rows - 2);
let filled = filled_cells(charge_pct, body);
let x = area.x + area.width.saturating_sub(body + chrome) / 2;
let cap = |left: &str, right: &str| {
Line::from(vec![
Span::styled(left.to_string(), outline),
Span::styled("─".repeat(usize::from(body)), outline),
Span::styled(right.to_string(), outline),
])
};
let mut lines = vec![cap("╭", "╮")];
let nub_rows: std::ops::RangeInclusive<u16> = match interior_rows {
2 => 0..=1,
n => (n - 1) / 2..=(n - 1) / 2,
};
for row in 0..interior_rows {
let mut spans = vec![
Span::styled("│", outline),
Span::styled("█".repeat(usize::from(filled)), fill),
Span::styled("░".repeat(usize::from(body - filled)), track),
Span::styled("│", outline),
];
if nub_rows.contains(&row) {
spans.push(Span::styled("▌", outline));
}
if row == (interior_rows - 1) / 2 {
if !nub_rows.contains(&row) {
spans.push(Span::raw(" "));
}
spans.extend(figure_spans(" "));
}
lines.push(Line::from(spans));
}
lines.push(cap("╰", "╯"));
for (i, line) in lines.into_iter().enumerate() {
let y = top + u16::try_from(i).unwrap_or(u16::MAX);
if y >= bottom {
break;
}
frame.render_widget(
Paragraph::new(line),
Rect::new(x, y, (body + chrome).min(area.width), 1),
);
}
return rows;
}
let line = if area.width >= figure_w + 3 {
let meter = area.width - figure_w - 1;
let mut spans = meter_line(charge_pct, meter, colour, theme.track);
spans.extend(figure_spans(" "));
Line::from(spans)
} else if area.width >= figure_w {
Line::from(figure_spans("")).centered()
} else {
let cut = crate::grid::truncate(figure, usize::from(area.width));
Line::from(Span::styled(cut, fill.add_modifier(Modifier::BOLD))).centered()
};
frame.render_widget(Paragraph::new(line), Rect::new(area.x, top, area.width, 1));
1
}
fn chrome_width(figure: &str) -> u16 {
4 + u16::try_from(crate::grid::display_width(figure)).unwrap_or(u16::MAX)
}
fn filled_cells(charge_pct: u16, body: u16) -> u16 {
let filled = charge_pct.min(100) * body / 100;
if charge_pct > 0 {
filled.max(1)
} else {
filled
}
}
impl BatteryPanel {
fn draw_empty(&self, frame: &mut Frame, area: Rect, theme: &crate::theme::Theme) {
let muted = theme.muted;
let (first, second) = match &self.error {
Some(why) => ("No battery readable", why.as_str()),
None => ("No battery", "Mains powered"),
};
let top = area.y + area.height.saturating_sub(2) / 2;
for (i, text) in [first, second].into_iter().enumerate() {
let y = top + u16::try_from(i).unwrap_or(0);
if y < area.y + area.height {
let text = crate::grid::truncate(text, usize::from(area.width));
frame.render_widget(
Paragraph::new(Span::styled(text, Style::default().fg(muted))).centered(),
Rect::new(area.x, y, area.width, 1),
);
}
}
}
}
fn draw_centred(frame: &mut Frame, area: Rect, y: u16, text: &str, colour: Color) {
frame.render_widget(
Paragraph::new(Span::styled(
crate::grid::truncate(text, usize::from(area.width)),
Style::default().fg(colour).add_modifier(Modifier::BOLD),
))
.centered(),
Rect::new(area.x, y, area.width, 1),
);
}
fn detail_parts(reading: Reading, muted: Style) -> Vec<Vec<Span<'static>>> {
let mut texts: Vec<String> = Vec::new();
if let Some(health) = reading.health_pct {
texts.push(format!("health {health}%"));
}
if let Some(cycles) = reading.cycles {
texts.push(format!("{cycles} cycles"));
}
if reading.watts.abs() >= 0.05 {
texts.push(format!("{:.1} W", reading.watts.abs()));
}
texts
.into_iter()
.enumerate()
.map(|(i, text)| {
let gap = if i == 0 { "" } else { " " };
vec![Span::styled(format!("{gap}{text}"), muted)]
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
fn reading(charge_pct: u16, flow: Flow, remaining: Option<u64>) -> Reading {
Reading {
charge_pct,
flow,
remaining: remaining.map(Duration::from_secs),
health_pct: Some(100),
cycles: Some(3),
watts: 12.4,
}
}
fn panel(reading: Option<Reading>) -> BatteryPanel {
BatteryPanel::with_reading(BatteryConfig::default(), reading)
}
fn screen(panel: &mut BatteryPanel, width: u16, height: u16) -> Vec<String> {
use ratatui::Terminal;
use ratatui::backend::TestBackend;
let config = crate::config::Config::default();
let gradients = config.theme.gradients();
let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap();
terminal
.draw(|frame| {
panel.render(
frame,
frame.area(),
RenderContext {
theme: &config.theme,
gradients: &gradients,
focused: true,
watch: &crate::watch::WatchLog::default(),
},
);
})
.unwrap();
let buffer = terminal.backend().buffer().clone();
(0..height)
.map(|y| {
(0..width)
.map(|x| buffer[(x, y)].symbol())
.collect::<String>()
.trim_end()
.to_string()
})
.collect()
}
#[test]
fn remaining_time_reads_the_way_someone_says_it() {
assert_eq!(describe_remaining(Duration::from_secs(0)), "under a minute");
assert_eq!(
describe_remaining(Duration::from_secs(59)),
"under a minute"
);
assert_eq!(describe_remaining(Duration::from_mins(42)), "42m");
assert_eq!(describe_remaining(Duration::from_hours(3)), "3h");
assert_eq!(
describe_remaining(Duration::from_mins(3 * 60 + 12)),
"3h 12m"
);
}
#[test]
fn the_border_says_how_long_and_nothing_the_panel_already_says() {
assert_eq!(
panel(Some(reading(80, Flow::Discharging, Some(11520))))
.counter()
.as_deref(),
Some("3h 12m left")
);
assert_eq!(
panel(Some(reading(40, Flow::Charging, Some(2520))))
.counter()
.as_deref(),
Some("42m to full")
);
assert_eq!(
panel(Some(reading(80, Flow::Holding, None))).counter(),
None
);
assert_eq!(panel(Some(reading(100, Flow::Full, None))).counter(), None);
assert_eq!(
panel(Some(reading(40, Flow::Charging, None))).counter(),
None
);
assert_eq!(
panel(Some(reading(63, Flow::Discharging, None))).counter(),
None,
"no estimate, nothing to say — the charge is the figure"
);
assert_eq!(panel(None).counter(), None, "no battery, no counter");
}
#[test]
fn the_colour_is_calm_unless_the_battery_is_low_and_in_use() {
let theme = crate::theme::Theme::default();
let p = panel(None);
assert_eq!(
p.colour(reading(80, Flow::Discharging, None), &theme),
theme.accent
);
assert_eq!(
p.colour(reading(15, Flow::Discharging, None), &theme),
theme.warning
);
assert_eq!(
p.colour(reading(7, Flow::Discharging, None), &theme),
theme.error
);
assert_eq!(
p.colour(reading(7, Flow::Charging, None), &theme),
theme.success,
"low but charging is fine"
);
assert_eq!(
p.colour(reading(7, Flow::Holding, None), &theme),
theme.accent,
"low on mains is nothing to shout about"
);
assert_eq!(
p.colour(reading(100, Flow::Full, None), &theme),
theme.success
);
}
#[test]
fn the_alert_fires_only_when_running_down_and_nearly_gone() {
assert_eq!(
panel(Some(reading(8, Flow::Discharging, Some(14 * 60))))
.alert()
.map(|a| a.text),
Some("Battery at 8% — 14m left".to_string())
);
assert_eq!(
panel(Some(reading(8, Flow::Discharging, None)))
.alert()
.map(|a| a.text),
Some("Battery at 8%".to_string())
);
assert!(
panel(Some(reading(8, Flow::Charging, None)))
.alert()
.is_none()
);
assert!(
panel(Some(reading(10, Flow::Discharging, None)))
.alert()
.is_none(),
"the threshold is exclusive"
);
assert!(panel(None).alert().is_none());
}
#[test]
fn the_face_reads_label_figure_meter_detail_and_never_a_fragment() {
let mut p = panel(Some(reading(80, Flow::Discharging, Some(11520))));
let rows = screen(&mut p, 34, 9);
let text: Vec<&str> = rows
.iter()
.map(|r| r.trim())
.filter(|r| !r.is_empty())
.collect();
assert_eq!(text[0], "ON BATTERY", "{rows:?}");
assert_eq!(
text[1], "╭───────────────────────────╮",
"a rounded cell, three columns to a row: {rows:?}"
);
assert_eq!(text[2], "│█████████████████████░░░░░░│", "{rows:?}");
assert_eq!(
text[3], "│█████████████████████░░░░░░│▌ 80%",
"the terminal on the middle row, and the figure beside it: {rows:?}"
);
assert_eq!(text[4], "│█████████████████████░░░░░░│", "{rows:?}");
assert_eq!(text[5], "╰───────────────────────────╯", "{rows:?}");
assert_eq!(
text[6], "health 100% 3 cycles 12.4 W",
"then the detail — and not the time, which the border has: {rows:?}"
);
assert_eq!(text.len(), 7, "{rows:?}");
assert!(
!rows.iter().any(|r| r.contains("left")),
"the time is said once, by the border: {rows:?}"
);
for width in 1..=40u16 {
for height in 1..=12u16 {
let rows = screen(&mut p, width, height);
for row in &rows {
let t = row.trim();
for bad in ["healt", "cycle", "12.", "80"] {
assert!(!t.ends_with(bad), "fragment {t:?} at {width}x{height}");
}
assert!(
!t.contains("80") || t.contains("80%"),
"a figure without its unit: {t:?} at {width}x{height}"
);
assert!(
!t.contains('▌') || t.contains("│▌"),
"a terminal off its cell: {t:?} at {width}x{height}"
);
}
}
}
}
#[test]
fn the_cell_fills_in_proportion_to_the_charge() {
let middle = |charge: u16| -> (usize, usize) {
let mut p = panel(Some(reading(charge, Flow::Discharging, None)));
let rows = screen(&mut p, 34, 3);
let row = rows.iter().find(|r| r.contains('▌')).expect("the cell");
(row.matches('█').count(), row.matches('░').count())
};
assert_eq!(middle(100), (16, 0), "full");
assert_eq!(middle(99), (15, 1), "nearly full is not full");
assert_eq!(middle(50), (8, 8), "half");
assert_eq!(middle(7), (1, 15), "low");
assert_eq!(middle(1), (1, 15), "any charge at all draws a cell");
assert_eq!(middle(0), (0, 16), "empty");
assert_eq!(filled_cells(1, 40), 1);
assert_eq!(filled_cells(0, 40), 0);
assert_eq!(filled_cells(100, 40), 40);
assert_eq!(
filled_cells(200, 40),
40,
"a reading over 100 is still full"
);
}
#[test]
fn the_face_gives_up_its_rows_from_the_outside_in() {
let mut p = panel(Some(reading(80, Flow::Discharging, None)));
let nonblank = |rows: Vec<String>| -> Vec<String> {
rows.into_iter()
.map(|r| r.trim().to_string())
.filter(|r| !r.is_empty())
.collect()
};
let mut at = |w, h| nonblank(screen(&mut p, w, h));
assert_eq!(at(34, 1), ["■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■ 80%"]);
assert_eq!(
at(34, 2),
["ON BATTERY", "■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■ 80%"]
);
assert_eq!(
at(34, 3),
[
"╭────────────────╮",
"│████████████░░░░│▌ 80%",
"╰────────────────╯"
],
"three rows is the outline alone"
);
assert_eq!(
at(34, 4),
[
"ON BATTERY",
"╭────────────────╮",
"│████████████░░░░│▌ 80%",
"╰────────────────╯"
]
);
assert_eq!(
at(34, 5).last().map(String::as_str),
Some("health 100% 3 cycles 12.4 W"),
"five rows brings the detail back"
);
assert_eq!(
at(9, 7),
["ON BATTE…", "■■■■■ 80%", "health 1…"],
"too narrow for an outline: a bare meter, at any height"
);
let mut q = panel(Some(reading(80, Flow::Discharging, None)));
assert_eq!(
screen(&mut q, 9, 7)[3],
"■■■■■ 80%",
"and the three rows are centred, not pinned to the top by the \
rows an outline would have taken"
);
assert_eq!(at(3, 1), ["80%"], "the figure alone");
assert_eq!(at(2, 1), ["8…"], "and the figure says when it is cut");
}
#[test]
fn the_cell_grows_with_the_panel_and_stops_looking_like_a_box() {
let mut p = panel(Some(reading(80, Flow::Discharging, None)));
let mut cell = |h: u16| -> (usize, usize) {
let rows = screen(&mut p, 60, h);
let top = rows.iter().find(|r| r.contains('╭')).expect("an outline");
let interior = rows.iter().filter(|r| r.contains('│')).count();
(interior, top.trim().chars().count())
};
assert_eq!(cell(5), (1, 18));
assert_eq!(cell(6), (2, 24));
assert_eq!(cell(7), (3, 30));
assert_eq!(cell(12), (3, 30), "capped");
assert_eq!(cell(40), (3, 30), "still capped");
}
#[test]
fn a_machine_without_a_battery_says_so_rather_than_drawing_an_empty_meter() {
let mut p = panel(None);
let rows = screen(&mut p, 24, 5);
let text = rows.join("\n");
assert!(text.contains("No battery"), "{rows:?}");
assert!(text.contains("Mains powered"), "{rows:?}");
assert!(!text.contains('■'), "no meter for nothing: {rows:?}");
}
}