use crate::panel;
use wattop::telemetry::Sample;
use ratatui::{
Frame,
buffer::Buffer,
layout::Rect,
style::{Color, Style, Stylize},
text::{Line, Span},
widgets::{Block, BorderType, Paragraph},
};
pub const EPS_W: f64 = 0.05;
const ORANGE: Color = Color::Rgb(255, 140, 0);
const NODE_H: i32 = 5;
const MID: i32 = NODE_H / 2;
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct Flows {
pub charger_to_system: bool,
pub charger_to_battery: bool,
pub battery_to_system: bool,
}
pub fn flows(s: &Sample) -> Flows {
Flows {
charger_to_system: s.plugged_in,
charger_to_battery: s.battery_w > EPS_W,
battery_to_system: s.battery_w < -EPS_W,
}
}
pub fn signed_w(v: f64) -> String {
format!("{v:+.1} W").replace('-', "−")
}
pub fn draw(f: &mut Frame, area: Rect, s: &Sample, frame: u64) {
let block = panel("energy flow");
let inner = block.inner(area);
f.render_widget(block, area);
let live = flows(s);
let (w, h) = (inner.width as i32, inner.height as i32);
let nw = (w / 3).clamp(14, 28);
let sx = w - nw; let bx = (w - nw) / 2; let by = h - NODE_H; let cx = nw / 2; let sxc = sx + nw / 2; let bottom = by + MID;
let top_line = [(nw, MID), (sx - 1, MID)];
let charge_line = [(cx, NODE_H), (cx, bottom), (bx - 1, bottom)];
let drain_line = [(bx + nw, bottom), (sxc, bottom), (sxc, NODE_H)];
let buf = f.buffer_mut();
let on = |on: bool, c: Color| on.then_some(c);
route(buf, inner, &top_line, on(live.charger_to_system, Color::Green), frame);
route(buf, inner, &charge_line, on(live.charger_to_battery, Color::Green), frame);
route(buf, inner, &drain_line, on(live.battery_to_system, ORANGE), frame);
let (charger_border, charger_lines) = if s.plugged_in {
let ports = if s.power_ports.is_empty() { "no port".to_string() } else { s.power_ports.join(" + ") };
let source = if s.power_sources > 1 {
format!("{} sources", s.power_sources)
} else {
s.charger_name.clone().unwrap_or_else(|| "unknown adapter".into())
};
(
Color::Green,
vec![
Line::from(ports.green().bold()),
Line::from(source),
Line::from(format!("{:.1} V × {:.1} A", s.charger_volts, s.charger_amps).dark_gray()),
],
)
} else {
(Color::DarkGray, vec![Line::from("UNPLUGGED".dark_gray().dim())])
};
node(f, inner, (0, 0), nw, charger_border, "⚡ CHARGER", charger_lines);
let system = vec![Line::from(format!("load {:.1} W", s.load_w).yellow().bold())];
node(f, inner, (sx, 0), nw, Color::Cyan, "◆ SYSTEM", system);
let (batt_border, batt_val) = if s.battery_w > EPS_W {
(Color::Green, signed_w(s.battery_w).green().bold())
} else if s.battery_w < -EPS_W {
(ORANGE, signed_w(s.battery_w).fg(ORANGE).bold())
} else {
(Color::DarkGray, "0.0 W".dark_gray())
};
node(f, inner, (bx, by), nw, batt_border, "▮ BATTERY", vec![Line::from(batt_val)]);
}
fn node(f: &mut Frame, inner: Rect, at: (i32, i32), nw: i32, border: Color, title: &str, lines: Vec<Line>) {
let rect = clip(inner, at.0, at.1, nw, NODE_H);
let block = Block::bordered()
.border_type(BorderType::Rounded)
.border_style(Style::new().fg(border))
.title(Span::styled(format!(" {title} "), Style::new().fg(border).bold()));
f.render_widget(Paragraph::new(lines).centered().block(block), rect);
}
fn clip(inner: Rect, x: i32, y: i32, w: i32, h: i32) -> Rect {
let x = x.clamp(0, inner.width as i32);
let y = y.clamp(0, inner.height as i32);
let w = w.min(inner.width as i32 - x).max(0);
let h = h.min(inner.height as i32 - y).max(0);
Rect::new(inner.x + x as u16, inner.y + y as u16, w as u16, h as u16)
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum Side {
L,
R,
U,
D,
}
impl Side {
fn opposite(self) -> Self {
match self {
Side::L => Side::R,
Side::R => Side::L,
Side::U => Side::D,
Side::D => Side::U,
}
}
}
fn glyph(a: Side, b: Side) -> &'static str {
use Side::*;
match (a, b) {
(L, R) | (R, L) => "─",
(U, D) | (D, U) => "│",
(R, D) | (D, R) => "┌",
(L, D) | (D, L) => "┐",
(R, U) | (U, R) => "└",
_ => "┘",
}
}
fn arrow(d: Side) -> &'static str {
match d {
Side::R => "▸",
Side::L => "◂",
Side::D => "▾",
Side::U => "▴",
}
}
fn step(a: (i32, i32), b: (i32, i32)) -> Side {
if b.0 > a.0 {
Side::R
} else if b.0 < a.0 {
Side::L
} else if b.1 > a.1 {
Side::D
} else {
Side::U
}
}
fn walk(points: &[(i32, i32)]) -> Vec<(i32, i32)> {
let mut out = vec![points[0]];
for pair in points.windows(2) {
let (mut x, mut y) = pair[0];
let (tx, ty) = pair[1];
while (x, y) != (tx, ty) {
x += (tx - x).signum();
y += (ty - y).signum();
out.push((x, y));
}
}
out
}
fn route(buf: &mut Buffer, inner: Rect, points: &[(i32, i32)], live: Option<Color>, frame: u64) {
let cells = walk(points);
let n = cells.len();
if n < 2 {
return;
}
for i in 0..n {
let d_in = if i == 0 { step(cells[0], cells[1]) } else { step(cells[i - 1], cells[i]) };
let d_out = if i + 1 < n { step(cells[i], cells[i + 1]) } else { d_in };
let (x, y) = cells[i];
let straight = d_in == d_out;
match live {
Some(c) if straight && i > 0 && i + 1 < n && (i as u64 % 4) == frame % 4 => {
put(buf, inner, x, y, arrow(d_out), Style::new().fg(c).bold());
}
Some(c) => put(buf, inner, x, y, glyph(d_in.opposite(), d_out), Style::new().fg(c)),
None => put(buf, inner, x, y, glyph(d_in.opposite(), d_out), Style::new().fg(Color::DarkGray).dim()),
}
}
}
pub fn put(buf: &mut Buffer, inner: Rect, x: i32, y: i32, sym: &str, style: Style) {
if x < 0 || y < 0 || x >= inner.width as i32 || y >= inner.height as i32 {
return;
}
if let Some(cell) = buf.cell_mut((inner.x + x as u16, inner.y + y as u16)) {
cell.set_symbol(sym).set_style(style);
}
}
pub fn put_str(buf: &mut Buffer, inner: Rect, x: i32, y: i32, text: &str, style: Style) {
for (i, ch) in text.chars().enumerate() {
put(buf, inner, x + i as i32, y, &ch.to_string(), style);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn flows_follow_power_direction() {
let charging = Sample { plugged_in: true, battery_w: 3.0, ..Default::default() };
let draining = Sample { plugged_in: true, battery_w: -1.5, ..Default::default() };
let unplugged = Sample { battery_w: -4.0, ..Default::default() };
let idle = Sample::default();
assert_eq!(flows(&charging), Flows { charger_to_system: true, charger_to_battery: true, battery_to_system: false });
assert_eq!(flows(&draining), Flows { charger_to_system: true, charger_to_battery: false, battery_to_system: true });
assert_eq!(flows(&unplugged), Flows { charger_to_system: false, charger_to_battery: false, battery_to_system: true });
assert_eq!(flows(&idle), Flows::default());
}
}