use macpow::sma::TimeSma;
use macpow::types::*;
use ratatui::buffer::Buffer;
use ratatui::layout::{Constraint, Direction, Layout, Rect};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Paragraph};
use ratatui::Frame;
use std::collections::{BTreeMap, VecDeque};
use std::time::Instant;
use unicode_width::UnicodeWidthStr;
const BOLD: Style = Style::new().add_modifier(Modifier::BOLD);
const DIM: Style = Style::new().fg(Color::DarkGray);
const DATA_STYLE: Style = Style::new().fg(Color::Rgb(80, 140, 255));
const PENDING: Style = Style::new().fg(Color::Magenta);
#[allow(dead_code)]
const CURSOR_BG: Style = Style::new().bg(Color::Rgb(50, 50, 60));
const TREE_STYLE: Style = Style::new().fg(Color::Reset);
const PIN_MARKER: &str = "▸ ";
const HISTORY_LEN: usize = 240;
const CHART_HEIGHT: u16 = 7;
const COL_FREQ: u16 = 10;
const COL_TEMP: u16 = 16;
const COL_CUR: u16 = 14;
const COL_TOT: u16 = 14;
const SPARK_CHARS: [char; 8] = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '▇'];
const BAR_EIGHTHS: [char; 9] = [' ', '▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'];
fn power_color(w: f32) -> Color {
match w {
w if w < 1.0 => Color::Rgb(46, 139, 87),
w if w < 5.0 => Color::Rgb(255, 255, 50), w if w < 10.0 => Color::Rgb(255, 140, 0), _ => Color::Rgb(255, 50, 50), }
}
fn fmt_wh(wh: f64) -> String {
let mwh = wh * 1000.0;
if mwh.abs() >= 1000.0 {
format!("{:>10.3} Wh", wh)
} else {
format!("{:>10.2} mWh", mwh)
}
}
#[allow(dead_code)]
fn fmt_mwh(mwh: f64) -> String {
if mwh.abs() >= 1000.0 {
format!("{:>10.3} Wh", mwh / 1000.0)
} else {
format!("{:>10.2} mWh", mwh)
}
}
fn human_rate(bps: f64) -> String {
match bps {
b if b < 1024.0 => format!("{:>7.0} B/s", b),
b if b < 1024.0 * 1024.0 => format!("{:>7.1} KB/s", b / 1024.0),
b => format!("{:>7.1} MB/s", b / (1024.0 * 1024.0)),
}
}
fn human_bytes(b: f64) -> String {
match b {
b if b < 1024.0 => format!("{:.0} B", b),
b if b < 1024.0 * 1024.0 => format!("{:.1} KB", b / 1024.0),
b if b < 1024.0 * 1024.0 * 1024.0 => format!("{:.1} MB", b / (1024.0 * 1024.0)),
b => format!("{:.2} GB", b / (1024.0 * 1024.0 * 1024.0)),
}
}
fn fmt_freq(mhz: f32) -> String {
if mhz > 0.0 {
format!("{:.0} MHz", mhz)
} else {
String::new()
}
}
struct TreeRow {
prefix: String,
label: String,
freq: String,
temp: String,
current: String,
total: String,
label_style: Style,
current_style: Style,
key: Option<&'static str>,
parent: Option<&'static str>,
pinned: bool,
#[allow(dead_code)]
is_header: bool,
}
impl TreeRow {
#[allow(clippy::too_many_arguments)]
fn pw(
key: &'static str,
parent: Option<&'static str>,
prefix: &str,
label: &str,
watts: f32,
wh: f64,
style: Style,
pinned: bool,
) -> Self {
let w = watts + 0.0; Self {
prefix: prefix.to_string(),
label: label.to_string(),
freq: String::new(),
temp: String::new(),
current: format!("{:>7.3} W", w),
total: fmt_wh(wh),
label_style: style.fg(style.fg.unwrap_or(power_color(w.abs()))),
current_style: Style::default().fg(power_color(w.abs())),
key: Some(key),
parent,
is_header: false,
pinned,
}
}
#[allow(clippy::too_many_arguments)]
fn pw_full(
key: &'static str,
parent: Option<&'static str>,
prefix: &str,
label: &str,
watts: f32,
wh: f64,
freq: &str,
temp: &str,
style: Style,
pinned: bool,
) -> Self {
let w = watts + 0.0;
Self {
prefix: prefix.to_string(),
label: label.to_string(),
freq: freq.to_string(),
temp: temp.to_string(),
current: format!("{:>7.3} W", w),
total: fmt_wh(wh),
label_style: style.fg(style.fg.unwrap_or(power_color(w.abs()))),
current_style: Style::default().fg(power_color(w.abs())),
key: Some(key),
parent,
is_header: false,
pinned,
}
}
fn info(
parent: Option<&'static str>,
prefix: &str,
label: &str,
current: &str,
total: &str,
style: Style,
) -> Self {
Self {
prefix: prefix.to_string(),
label: label.to_string(),
freq: String::new(),
temp: String::new(),
current: current.to_string(),
total: total.to_string(),
label_style: style,
current_style: style,
key: None,
parent,
is_header: false,
pinned: false,
}
}
fn header(
parent: Option<&'static str>,
prefix: &str,
label: &str,
col3: &str,
col4: &str,
) -> Self {
Self {
prefix: prefix.to_string(),
label: label.to_string(),
freq: String::new(),
temp: String::new(),
current: col3.to_string(),
total: col4.to_string(),
label_style: BOLD,
current_style: BOLD,
key: None,
parent,
is_header: true,
pinned: false,
}
}
fn has_children_in(&self, rows: &[TreeRow]) -> bool {
self.key
.map(|k| rows.iter().any(|r| r.parent == Some(k)))
.unwrap_or(false)
}
fn separator() -> Self {
Self {
prefix: String::new(),
label: "\x00sep".into(), freq: String::new(),
temp: String::new(),
current: String::new(),
total: String::new(),
label_style: DIM,
current_style: DIM,
key: None,
parent: None,
pinned: false,
is_header: false,
}
}
}
#[derive(Default)]
struct Wh {
ecpu: f64,
pcpu: f64,
cpu: f64,
gpu: f64,
ane: f64,
dram: f64,
gpu_sram: f64,
isp: f64,
display_soc: f64,
display_ext: f64,
pcie: f64,
media: f64,
fabric: f64,
ssd: f64,
display: f64,
keyboard: f64,
audio: f64,
fans: f64,
wifi: f64,
bluetooth: f64,
sys: f64,
battery: f64,
net_down_bytes: f64,
net_up_bytes: f64,
disk_read_bytes: f64,
disk_write_bytes: f64,
}
macro_rules! sma_fields {
($($f:ident),*) => {
struct MetricsSma { $( $f: TimeSma, )* }
impl MetricsSma {
fn new(w: f64) -> Self { Self { $( $f: TimeSma::new(w), )* } }
fn set_all_windows(&mut self, s: f64) { $( self.$f.set_window(s); )* }
fn clear_all(&mut self) { $( self.$f.clear(); )* }
}
}
}
sma_fields!(
soc_total,
cpu,
ecpu,
pcpu,
gpu,
ane,
dram,
gpu_sram,
isp,
display_soc,
display_ext,
pcie,
media,
fabric,
ssd,
display,
keyboard,
audio,
fan_total,
wifi,
bluetooth,
sys,
battery,
net_down,
net_up,
ecpu_freq,
pcpu_freq,
gpu_freq
);
impl MetricsSma {
fn push_metrics(&mut self, m: &Metrics) {
self.soc_total.push(m.soc.total_w);
self.cpu.push(m.soc.cpu_w);
self.ecpu.push(m.soc.ecpu_total_w());
self.pcpu.push(m.soc.pcpu_total_w());
self.gpu.push(m.soc.gpu_w);
self.ane.push(m.soc.ane_w);
self.dram.push(m.soc.dram_w);
self.gpu_sram.push(m.soc.gpu_sram_w);
self.isp.push(m.soc.isp_w);
self.display_soc
.push(m.soc.display_soc_w + m.soc.display_ext_w);
self.display_ext.push(m.soc.display_ext_w);
self.pcie.push(m.soc.pcie_w);
self.media.push(m.soc.media_w);
self.fabric.push(m.soc.fabric_w);
self.ssd.push(m.ssd_power_w);
self.display.push(m.display.estimated_power_w);
self.keyboard.push(m.keyboard.estimated_power_w);
self.audio.push(m.audio.estimated_power_w);
self.fan_total
.push(m.fans.iter().map(|f| f.estimated_power_w).sum());
self.wifi.push(m.wifi.estimated_power_w);
self.bluetooth.push(m.bluetooth_power_w);
self.sys.push(m.sys_power_w);
self.battery.push(m.battery.drain_w as f32);
self.net_down.push(m.network.bytes_in_per_sec as f32);
self.net_up.push(m.network.bytes_out_per_sec as f32);
self.ecpu_freq.push(m.soc.ecpu_freq_mhz as f32);
self.pcpu_freq.push(m.soc.pcpu_freq_mhz as f32);
self.gpu_freq.push(m.soc.gpu_freq_mhz as f32);
}
}
pub struct App {
pub metrics: Metrics,
pub cursor: usize,
last_tick: Option<Instant>,
wh: Wh,
sma: MetricsSma,
pub sma_window: u32,
pub latency_ms: u64,
temp_min: BTreeMap<String, f32>,
temp_max: BTreeMap<String, f32>,
temp_sum: BTreeMap<String, f64>,
temp_count: BTreeMap<String, u64>,
history: BTreeMap<&'static str, VecDeque<f64>>,
pinned: Vec<&'static str>,
collapsed: std::collections::HashSet<&'static str>,
total_rows: usize,
row_keys_cache: Vec<Option<&'static str>>,
row_parents_cache: Vec<Option<&'static str>>,
row_is_sep: Vec<bool>,
labels: BTreeMap<&'static str, String>,
proc_baseline: std::collections::HashMap<i32, f64>,
proc_keys: std::collections::HashMap<i32, &'static str>,
fan_wh: Vec<f64>,
usb_wh: Vec<f64>,
usb_prev_bytes: Vec<(u64, u64)>,
usb_rates: Vec<(f64, f64)>,
machine_name: String,
tree_data_y: u16,
tree_scroll: usize,
tree_vis_h: usize,
term_height: u16,
}
impl App {
pub fn new() -> Self {
let machine_name = read_machine_name();
Self {
metrics: Metrics::default(),
cursor: 2, last_tick: None,
wh: Wh::default(),
sma: MetricsSma::new(0.0),
sma_window: 0,
latency_ms: 250,
temp_min: BTreeMap::new(),
temp_max: BTreeMap::new(),
temp_sum: BTreeMap::new(),
temp_count: BTreeMap::new(),
history: BTreeMap::new(),
pinned: Vec::new(),
collapsed: [
"wifi",
"bluetooth",
"ssd",
"ecpu",
"pcpu",
"gpu",
"ane",
"display",
"fabric",
"usb0",
"usb1",
"usb2",
"usb3",
"usb4",
"usb5",
"usb6",
"usb7",
]
.into_iter()
.collect(),
total_rows: 0,
row_keys_cache: Vec::new(),
row_parents_cache: Vec::new(),
row_is_sep: Vec::new(),
labels: BTreeMap::new(),
proc_baseline: std::collections::HashMap::new(),
proc_keys: std::collections::HashMap::new(),
fan_wh: Vec::new(),
usb_wh: Vec::new(),
usb_prev_bytes: Vec::new(),
usb_rates: Vec::new(),
machine_name,
tree_data_y: 0,
tree_scroll: 0,
tree_vis_h: 0,
term_height: 40,
}
}
fn push_history(&mut self, key: &'static str, val: f64) {
let buf = self
.history
.entry(key)
.or_insert_with(|| VecDeque::with_capacity(HISTORY_LEN + 1));
buf.push_back(val);
if buf.len() > HISTORY_LEN {
buf.pop_front();
}
}
pub fn update(&mut self, m: Metrics) {
self.sma.push_metrics(&m);
if let Some(prev) = self.last_tick {
let dt_h = prev.elapsed().as_secs_f64() / 3600.0;
let dt_s = prev.elapsed().as_secs_f64();
self.wh.ecpu += m.soc.ecpu_total_w() as f64 * dt_h;
self.wh.pcpu += m.soc.pcpu_total_w() as f64 * dt_h;
self.wh.cpu += m.soc.cpu_w as f64 * dt_h;
self.wh.gpu += m.soc.gpu_w as f64 * dt_h;
self.wh.ane += m.soc.ane_w as f64 * dt_h;
self.wh.dram += m.soc.dram_w as f64 * dt_h;
self.wh.gpu_sram += m.soc.gpu_sram_w as f64 * dt_h;
self.wh.isp += m.soc.isp_w as f64 * dt_h;
self.wh.display_soc += m.soc.display_soc_w as f64 * dt_h;
self.wh.display_ext += m.soc.display_ext_w as f64 * dt_h;
self.wh.pcie += m.soc.pcie_w as f64 * dt_h;
self.wh.media += m.soc.media_w as f64 * dt_h;
self.wh.fabric += m.soc.fabric_w as f64 * dt_h;
self.wh.ssd += m.ssd_power_w as f64 * dt_h;
self.wh.display += m.display.estimated_power_w as f64 * dt_h;
self.wh.keyboard += m.keyboard.estimated_power_w as f64 * dt_h;
self.wh.audio += m.audio.estimated_power_w as f64 * dt_h;
self.wh.fans += m
.fans
.iter()
.map(|f| f.estimated_power_w as f64)
.sum::<f64>()
* dt_h;
self.fan_wh.resize(m.fans.len(), 0.0);
for (i, fan) in m.fans.iter().enumerate() {
self.fan_wh[i] += fan.estimated_power_w as f64 * dt_h;
}
self.usb_wh.resize(m.usb_devices.len(), 0.0);
self.usb_rates.resize(m.usb_devices.len(), (0.0, 0.0));
self.usb_prev_bytes.resize(m.usb_devices.len(), (0, 0));
for (i, d) in m.usb_devices.iter().enumerate() {
let watts = d.power_ma.unwrap_or(0) as f64 * 5.0 / 1000.0;
self.usb_wh[i] += watts * dt_h;
let (prev_r, prev_w) = self.usb_prev_bytes[i];
if prev_r > 0 || prev_w > 0 {
self.usb_rates[i] = (
d.bytes_read.saturating_sub(prev_r) as f64 / dt_s,
d.bytes_written.saturating_sub(prev_w) as f64 / dt_s,
);
}
self.usb_prev_bytes[i] = (d.bytes_read, d.bytes_written);
}
self.wh.wifi += m.wifi.estimated_power_w as f64 * dt_h;
self.wh.bluetooth += m.bluetooth_power_w as f64 * dt_h;
self.wh.sys += m.sys_power_w as f64 * dt_h;
self.wh.battery += m.battery.drain_w * dt_h;
self.wh.net_down_bytes += m.network.bytes_in_per_sec * dt_s;
self.wh.net_up_bytes += m.network.bytes_out_per_sec * dt_s;
self.wh.disk_read_bytes += m.disk.read_bytes_per_sec * dt_s;
self.wh.disk_write_bytes += m.disk.write_bytes_per_sec * dt_s;
}
self.last_tick = Some(Instant::now());
self.push_history("soc", m.soc.total_w as f64);
self.push_history("cpu", m.soc.cpu_w as f64);
self.push_history("ecpu", m.soc.ecpu_total_w() as f64);
for (ci, core) in m
.soc
.ecpu_clusters
.iter()
.flat_map(|cl| cl.cores.iter())
.enumerate()
{
let key = proc_key(&mut self.proc_keys, -(ci as i32 + 1000));
self.push_history(key, core.watts as f64);
}
self.push_history("pcpu", m.soc.pcpu_total_w() as f64);
for (ci, core) in m.soc.pcpu_cluster.cores.iter().enumerate() {
let key = proc_key(&mut self.proc_keys, -(ci as i32 + 2000));
self.push_history(key, core.watts as f64);
}
self.push_history("gpu", m.soc.gpu_w as f64);
self.push_history("ane", m.soc.ane_w as f64);
self.push_history("dram", m.soc.dram_w as f64);
self.push_history("gpu_sram", m.soc.gpu_sram_w as f64);
self.push_history("isp", m.soc.isp_w as f64);
self.push_history(
"display_soc",
(m.soc.display_soc_w + m.soc.display_ext_w) as f64,
);
self.push_history("display_ext", m.soc.display_ext_w as f64);
self.push_history("pcie", m.soc.pcie_w as f64);
self.push_history("media", m.soc.media_w as f64);
self.push_history("fabric", m.soc.fabric_w as f64);
self.push_history("ssd", m.ssd_power_w as f64);
self.push_history("display", m.display.estimated_power_w as f64);
self.push_history("keyboard", m.keyboard.estimated_power_w as f64);
self.push_history("audio", m.audio.estimated_power_w as f64);
self.push_history(
"fans",
m.fans.iter().map(|f| f.estimated_power_w as f64).sum(),
);
for (i, fan) in m.fans.iter().enumerate() {
self.push_history(fan_key(i), fan.estimated_power_w as f64);
}
for (i, d) in m.usb_devices.iter().enumerate() {
self.push_history(usb_key(i), d.power_ma.unwrap_or(0) as f64 * 5.0 / 1000.0);
}
self.push_history("wifi", m.wifi.estimated_power_w as f64);
self.push_history("bluetooth", m.bluetooth_power_w as f64);
self.push_history("net_down", m.network.bytes_in_per_sec);
self.push_history("net_up", m.network.bytes_out_per_sec);
self.push_history("disk_read", m.disk.read_bytes_per_sec);
self.push_history("disk_write", m.disk.write_bytes_per_sec);
self.push_history(
"peripherals",
(m.wifi.estimated_power_w + m.bluetooth_power_w) as f64,
);
self.push_history("system", m.sys_power_w as f64);
self.push_history("battery", m.battery.drain_w.abs());
self.push_history("software", m.all_procs_power_w as f64);
for p in &m.top_processes {
let key = proc_key(&mut self.proc_keys, p.pid);
self.push_history(key, p.power_w as f64);
}
let cat_now = temps_by_category(&m.temperatures);
for (cat, vals) in &cat_now {
let (avg, min, max) = stats(vals);
let e_min = self.temp_min.entry(cat.clone()).or_insert(f32::INFINITY);
*e_min = e_min.min(min);
let e_max = self
.temp_max
.entry(cat.clone())
.or_insert(f32::NEG_INFINITY);
*e_max = e_max.max(max);
*self.temp_sum.entry(cat.clone()).or_insert(0.0) += avg as f64;
*self.temp_count.entry(cat.clone()).or_insert(0) += 1;
}
self.metrics = m;
}
pub fn handle_key(&mut self, key: crossterm::event::KeyEvent) -> bool {
use crossterm::event::KeyCode;
match key.code {
KeyCode::Char('q') | KeyCode::Esc => return true,
KeyCode::Char('c')
if key
.modifiers
.contains(crossterm::event::KeyModifiers::CONTROL) =>
{
return true
}
KeyCode::Char('k') | KeyCode::Up => self.move_cursor(-1),
KeyCode::Char('j') | KeyCode::Down => self.move_cursor(1),
KeyCode::Char('h') | KeyCode::Left => self.collapse_or_parent(),
KeyCode::Right => self.expand_or_child(),
KeyCode::Char('r') => self.reset(),
KeyCode::Char('a') => self.cycle_sma(),
KeyCode::Char('l') => self.cycle_latency(),
KeyCode::Home => self.cursor = 0,
KeyCode::PageUp => self.move_cursor(-10),
KeyCode::PageDown => self.move_cursor(10),
KeyCode::Char(' ') => self.toggle_pin(),
KeyCode::Char('-') => self.collapse_all(),
KeyCode::Char('+') | KeyCode::Char('=') => self.expand_all(),
_ => {}
}
false
}
fn move_cursor(&mut self, delta: i32) {
let max = self.total_rows.saturating_sub(1) as i32;
let mut pos = (self.cursor as i32 + delta).clamp(0, max);
let dir = if delta >= 0 { 1 } else { -1 };
while pos >= 0 && pos <= max && self.row_is_sep.get(pos as usize).copied().unwrap_or(false)
{
pos += dir;
}
self.cursor = pos.clamp(0, max) as usize;
}
pub fn handle_mouse(&mut self, mouse: crossterm::event::MouseEvent) {
use crossterm::event::{MouseButton, MouseEventKind};
if let MouseEventKind::Down(MouseButton::Left) = mouse.kind {
let y = mouse.row;
if y >= self.tree_data_y {
let vi = (y - self.tree_data_y) as usize;
if vi < self.tree_vis_h {
let target = self.tree_scroll + vi;
if target < self.total_rows
&& !self.row_is_sep.get(target).copied().unwrap_or(false)
{
if target == self.cursor {
if let Some(Some(key)) = self.row_keys_cache.get(self.cursor) {
if self.collapsed.contains(key) {
self.collapsed.remove(key);
} else if self.row_parents_cache.contains(&Some(*key)) {
self.collapsed.insert(*key);
}
}
} else {
self.cursor = target;
}
}
}
}
}
}
fn toggle_pin(&mut self) {
if let Some(Some(key)) = self.row_keys_cache.get(self.cursor) {
if let Some(pos) = self.pinned.iter().position(|&k| k == *key) {
self.pinned.remove(pos);
} else {
self.pinned.push(*key);
}
}
}
fn collapse_or_parent(&mut self) {
if let Some(Some(key)) = self.row_keys_cache.get(self.cursor) {
if !self.collapsed.contains(key) {
if self.row_parents_cache.contains(&Some(*key)) {
self.collapsed.insert(*key);
return;
}
}
}
if let Some(Some(parent)) = self.row_parents_cache.get(self.cursor) {
if let Some(pos) = self.row_keys_cache.iter().position(|k| *k == Some(*parent)) {
self.cursor = pos;
}
}
}
fn expand_or_child(&mut self) {
if let Some(Some(key)) = self.row_keys_cache.get(self.cursor) {
if self.collapsed.remove(key) {
return;
}
if let Some(pos) = self
.row_parents_cache
.iter()
.enumerate()
.skip(self.cursor + 1)
.find(|(_, p)| **p == Some(*key))
.map(|(i, _)| i)
{
self.cursor = pos;
}
}
}
fn collapse_all(&mut self) {
for k in self.row_keys_cache.iter().flatten() {
if self.row_parents_cache.contains(&Some(*k)) {
self.collapsed.insert(*k);
}
}
}
fn expand_all(&mut self) {
self.collapsed.clear();
}
fn reset(&mut self) {
self.wh = Wh::default();
self.sma.clear_all();
self.temp_min.clear();
self.temp_max.clear();
self.temp_sum.clear();
self.temp_count.clear();
self.history.clear();
self.fan_wh.iter_mut().for_each(|v| *v = 0.0);
self.usb_wh.iter_mut().for_each(|v| *v = 0.0);
self.proc_baseline = self
.metrics
.top_processes
.iter()
.map(|p| (p.pid, p.energy_mj))
.collect();
}
fn cycle_sma(&mut self) {
self.sma_window = match self.sma_window {
0 => 5,
5 => 10,
_ => 0,
};
self.sma.set_all_windows(self.sma_window as f64);
}
fn cycle_latency(&mut self) {
self.latency_ms = match self.latency_ms {
250 => 1000,
1000 => 2000,
_ => 250,
};
}
pub fn poll_interval_ms(&self) -> u64 {
self.latency_ms
}
pub fn draw(&mut self, f: &mut Frame) {
self.term_height = f.area().height;
let all_rows = self.build_rows();
let rows: Vec<&TreeRow> = all_rows
.iter()
.filter(|r| !self.is_hidden(r, &all_rows))
.collect();
self.total_rows = rows.len();
let prev_key = self.row_keys_cache.get(self.cursor).copied().flatten();
self.row_keys_cache = rows.iter().map(|r| r.key).collect();
self.row_parents_cache = rows.iter().map(|r| r.parent).collect();
self.row_is_sep = rows.iter().map(|r| r.label == "\x00sep").collect();
if let Some(pk) = prev_key {
if let Some(pos) = self.row_keys_cache.iter().position(|k| *k == Some(pk)) {
self.cursor = pos;
}
}
self.cursor = self.cursor.min(self.total_rows.saturating_sub(1));
for r in &rows {
if let Some(key) = r.key {
if !r.label.is_empty() {
self.labels.insert(key, r.label.clone());
}
}
}
let cursor_key = self
.row_keys_cache
.get(self.cursor)
.copied()
.flatten()
.or_else(|| {
self.row_parents_cache.get(self.cursor).copied().flatten()
});
let chart_keys = self.chart_keys(cursor_key);
let min_charts = if self.pinned.is_empty() {
1
} else {
self.pinned.len()
};
let chart_count = chart_keys.len().max(min_charts);
let chart_h = chart_count as u16 * CHART_HEIGHT;
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Min(0),
Constraint::Length(chart_h),
Constraint::Length(1),
])
.split(f.area());
self.draw_tree_buf(f, chunks[0], &rows, &all_rows);
if !chart_keys.is_empty() {
self.draw_charts(f, chunks[1], &chart_keys);
}
self.draw_footer(f, chunks[2]);
}
fn is_hidden(&self, row: &TreeRow, all: &[TreeRow]) -> bool {
let mut parent = row.parent;
while let Some(p) = parent {
if self.collapsed.contains(p) {
return true;
}
parent = all.iter().find(|r| r.key == Some(p)).and_then(|r| r.parent);
}
false
}
fn chart_keys(&self, cursor_key: Option<&'static str>) -> Vec<&'static str> {
let mut entries: Vec<(usize, &'static str)> = Vec::new();
if let Some(ck) = cursor_key {
if !self.pinned.contains(&ck) {
let pos = self
.row_keys_cache
.iter()
.position(|k| *k == Some(ck))
.unwrap_or(0);
entries.push((pos, ck));
}
}
for &pk in &self.pinned {
let pos = self
.row_keys_cache
.iter()
.position(|k| *k == Some(pk))
.unwrap_or(usize::MAX);
entries.push((pos, pk));
}
entries.sort_by_key(|(pos, _)| *pos);
entries.into_iter().map(|(_, k)| k).collect()
}
fn build_rows(&mut self) -> Vec<TreeRow> {
let m = &self.metrics;
let w = &self.wh;
let s = &self.sma;
let pin = |key: &str| -> bool { self.pinned.contains(&key) };
let e_count: usize = m.soc.ecpu_clusters.iter().map(|c| c.cores.len()).sum();
let p_count = m.soc.pcpu_cluster.cores.len();
let (e_temps, p_temps) = selected_cpu_core_temps(&m.temperatures, e_count, p_count);
let mut temp_groups = temps_by_category(&m.temperatures);
let cpu_display_temps: Vec<f32> = e_temps.iter().chain(p_temps.iter()).copied().collect();
if !cpu_display_temps.is_empty() {
temp_groups.insert("CPU".to_string(), cpu_display_temps);
}
let temps_pending = m.temperatures.is_empty();
let temp_info = |cat: &str| -> String {
temp_groups
.get(cat)
.map(|v| {
let avg = v.iter().sum::<f32>() / v.len() as f32;
let cur_min = v.iter().copied().fold(f32::INFINITY, f32::min);
let cur_max = v.iter().copied().fold(f32::NEG_INFINITY, f32::max);
format!("{:.0}°C ({:.0}–{:.0})", avg, cur_min, cur_max)
})
.unwrap_or_else(|| {
if temps_pending {
"pending…".into()
} else {
String::new()
}
})
};
let mut rows: Vec<TreeRow> = Vec::new();
let inline_cats_list = ["CPU", "GPU", "Memory", "SSD", "Battery", "ANE"];
let has_remaining_temps = temp_groups
.keys()
.any(|k| !inline_cats_list.contains(&k.as_str()));
let last_section = if has_remaining_temps {
"temps"
} else {
"peripherals"
};
let t = |section: &str| -> String {
if section == last_section {
"└─ ".into()
} else {
"├─ ".into()
}
};
let c = |section: &str| -> String {
if section == last_section {
" ".into()
} else {
"│ ".into()
}
};
rows.push(TreeRow::separator());
if m.battery.present {
let batt_w = s.battery.get();
let t = m.battery.time_remaining_min;
let has_time = t > 0;
let effectively_charging = m.battery.external_connected && m.battery.drain_w < 0.0;
let (display_w, charge_status, batt_style) = if m.battery.external_connected {
let status = if effectively_charging && has_time {
format!("full in {}h {:02}m", t / 60, t % 60)
} else if effectively_charging {
"charging…".into()
} else {
"on power".into()
};
(
batt_w.abs(),
status,
Style::default().fg(Color::Rgb(46, 139, 87)),
)
} else {
(
-batt_w.abs(),
if has_time {
format!("{}h {:02}m remaining", t / 60, t % 60)
} else {
"estimating…".into()
},
Style::default().fg(power_color(batt_w.abs())),
)
};
let batt_label = format!("Battery {:.0}% ({})", m.battery.percent, charge_status);
rows.push(TreeRow::pw_full(
"battery",
None,
"",
&batt_label,
display_w,
w.battery,
"",
&temp_info("Battery"),
batt_style,
pin("battery"),
));
}
rows.push(TreeRow::separator());
let display_w = s.display.get() + s.display_soc.get() + s.display_ext.get();
let display_wh = w.display + w.display_soc + w.display_ext;
let sys_w = s.soc_total.get()
+ s.ssd.get()
+ display_w
+ s.keyboard.get()
+ s.audio.get()
+ s.fan_total.get()
+ s.wifi.get()
+ s.bluetooth.get()
+ s.pcie.get();
let sys_wh = w.cpu
+ w.gpu
+ w.ane
+ w.dram
+ w.gpu_sram
+ w.isp
+ w.pcie
+ w.media
+ w.fabric
+ w.ssd
+ display_wh
+ w.keyboard
+ w.audio
+ w.fans
+ w.wifi
+ w.bluetooth;
rows.push(TreeRow::pw(
"system",
None,
"",
&self.machine_name,
sys_w,
sys_wh,
BOLD,
pin("system"),
));
let soc_pending = m.soc.total_w == 0.0 && m.soc.ecpu_clusters.is_empty();
if soc_pending {
rows.push(TreeRow::pw(
"soc",
Some("system"),
&t("soc"),
"SoC (pending…)",
0.0,
0.0,
PENDING,
pin("soc"),
));
} else {
let soc_wh = w.cpu + w.gpu + w.ane + w.dram + w.gpu_sram + w.isp + w.media + w.fabric;
let cp = c("soc");
rows.push(TreeRow::pw(
"soc",
Some("system"),
&t("soc"),
"SoC",
s.soc_total.get(),
soc_wh,
BOLD,
pin("soc"),
));
let cpu_usage = &m.cpu_usage_pct;
let p_usage: Vec<f32> = cpu_usage.iter().take(p_count).copied().collect();
let e_usage: Vec<f32> = cpu_usage
.iter()
.skip(p_count)
.take(e_count)
.copied()
.collect();
let e_avg_usage = if e_usage.is_empty() {
0.0
} else {
e_usage.iter().sum::<f32>() / e_usage.len() as f32
};
let p_avg_usage = if p_usage.is_empty() {
0.0
} else {
p_usage.iter().sum::<f32>() / p_usage.len() as f32
};
let cpu_avg_usage = if cpu_usage.is_empty() {
0.0
} else {
cpu_usage.iter().take(e_count + p_count).sum::<f32>() / (e_count + p_count) as f32
};
rows.push(TreeRow::pw_full(
"cpu",
Some("soc"),
&format!("{}├─ ", cp),
&format!("CPU ({} cores, {:.0}%)", e_count + p_count, cpu_avg_usage),
s.cpu.get(),
w.cpu,
"",
&temp_info("CPU"),
Style::default(),
pin("cpu"),
));
let e_avg_temp = if e_temps.is_empty() {
String::new()
} else {
let avg = e_temps.iter().sum::<f32>() / e_temps.len() as f32;
let min = e_temps.iter().copied().fold(f32::INFINITY, f32::min);
let max = e_temps.iter().copied().fold(f32::NEG_INFINITY, f32::max);
format!("{:.0}°C ({:.0}–{:.0})", avg, min, max)
};
let p_avg_temp = if p_temps.is_empty() {
String::new()
} else {
let avg = p_temps.iter().sum::<f32>() / p_temps.len() as f32;
let min = p_temps.iter().copied().fold(f32::INFINITY, f32::min);
let max = p_temps.iter().copied().fold(f32::NEG_INFINITY, f32::max);
format!("{:.0}°C ({:.0}–{:.0})", avg, min, max)
};
rows.push(TreeRow::pw_full(
"ecpu",
Some("cpu"),
&format!("{}│ ├─ ", cp),
&format!("E-Cores ({}, {:.0}%)", e_count, e_avg_usage),
s.ecpu.get(),
w.ecpu,
&fmt_freq(s.ecpu_freq.get()),
&e_avg_temp,
Style::default(),
pin("ecpu"),
));
{
let ecpu_cont = format!("{}│ │ ", cp);
let all_ecores: Vec<_> = m
.soc
.ecpu_clusters
.iter()
.flat_map(|cl| cl.cores.iter())
.collect();
for (ci, core) in all_ecores.iter().enumerate() {
let pfx = if ci == all_ecores.len() - 1 {
format!("{}└─ ", ecpu_cont)
} else {
format!("{}├─ ", ecpu_cont)
};
let key = proc_key(&mut self.proc_keys, -(ci as i32 + 1000));
let temp = e_temps
.get(ci)
.map(|t| format!("{:.0}°C", t))
.unwrap_or_default();
let usage = e_usage
.get(ci)
.map(|u| format!(" ({:>3.0}%) {}", u, usage_bar(*u)))
.unwrap_or_default();
rows.push(TreeRow::pw_full(
key,
Some("ecpu"),
&pfx,
&format!("{:<10}{}", core.name, usage),
core.watts,
0.0,
"",
&temp,
Style::default(),
pin(key),
));
}
}
rows.push(TreeRow::pw_full(
"pcpu",
Some("cpu"),
&format!("{}│ └─ ", cp),
&format!("P-Cores ({}, {:.0}%)", p_count, p_avg_usage),
s.pcpu.get(),
w.pcpu,
&fmt_freq(s.pcpu_freq.get()),
&p_avg_temp,
Style::default(),
pin("pcpu"),
));
{
let pcpu_cont = format!("{}│ ", cp);
for (ci, core) in m.soc.pcpu_cluster.cores.iter().enumerate() {
let pfx = if ci == m.soc.pcpu_cluster.cores.len() - 1 {
format!("{}└─ ", pcpu_cont)
} else {
format!("{}├─ ", pcpu_cont)
};
let key = proc_key(&mut self.proc_keys, -(ci as i32 + 2000));
let temp = p_temps
.get(ci)
.map(|t| format!("{:.0}°C", t))
.unwrap_or_default();
let usage = p_usage
.get(ci)
.map(|u| format!(" ({:>3.0}%) {}", u, usage_bar(*u)))
.unwrap_or_default();
rows.push(TreeRow::pw_full(
key,
Some("pcpu"),
&pfx,
&format!("{:<10}{}", core.name, usage),
core.watts,
0.0,
"",
&temp,
Style::default(),
pin(key),
));
}
}
let gpu_util = m.soc.gpu_util_device;
let gpu_name = if m.gpu_cores > 0 {
format!("GPU ({} cores, {}%)", m.gpu_cores, gpu_util)
} else {
format!("GPU ({}%)", gpu_util)
};
rows.push(TreeRow::pw_full(
"gpu",
Some("soc"),
&format!("{}├─ ", cp),
&gpu_name,
s.gpu.get(),
w.gpu,
&fmt_freq(s.gpu_freq.get()),
&temp_info("GPU"),
Style::default(),
pin("gpu"),
));
{
let gpu_cont = format!("{}│ ", cp);
rows.push(TreeRow::info(
Some("gpu"),
&format!("{}├─ ", gpu_cont),
&format!(
"Renderer ({:>3}%) {}",
m.soc.gpu_util_renderer,
usage_bar(m.soc.gpu_util_renderer as f32)
),
"",
"",
Style::default(),
));
rows.push(TreeRow::info(
Some("gpu"),
&format!("{}└─ ", gpu_cont),
&format!(
"Tiler ({:>3}%) {}",
m.soc.gpu_util_tiler,
usage_bar(m.soc.gpu_util_tiler as f32)
),
"",
"",
Style::default(),
));
}
rows.push(TreeRow::pw_full(
"ane",
Some("soc"),
&format!("{}├─ ", cp),
"ANE",
s.ane.get(),
w.ane,
"",
&temp_info("ANE"),
Style::default(),
pin("ane"),
));
if m.soc.ane_parts.len() > 1 {
let ane_cont = format!("{}│ ", cp);
for (ai, (name, watts)) in m.soc.ane_parts.iter().enumerate() {
let pfx = if ai == m.soc.ane_parts.len() - 1 {
format!("{}└─ ", ane_cont)
} else {
format!("{}├─ ", ane_cont)
};
rows.push(TreeRow::pw(
proc_key(&mut self.proc_keys, -(ai as i32 + 3000)),
Some("ane"),
&pfx,
name,
*watts,
0.0,
Style::default(),
false,
));
}
}
let dram_name = if m.dram_gb > 0 {
format!("DRAM ({:.1}/{} GB)", m.mem_used_gb, m.dram_gb)
} else {
"DRAM".into()
};
rows.push(TreeRow::pw_full(
"dram",
Some("soc"),
&format!("{}├─ ", cp),
&dram_name,
s.dram.get(),
w.dram,
"",
&temp_info("Memory"),
Style::default(),
pin("dram"),
));
rows.push(TreeRow::pw(
"gpu_sram",
Some("soc"),
&format!("{}├─ ", cp),
"GPU SRAM (SLC)",
s.gpu_sram.get(),
w.gpu_sram,
Style::default(),
pin("gpu_sram"),
));
rows.push(TreeRow::pw(
"media",
Some("soc"),
&format!("{}├─ ", cp),
"Media Engine",
s.media.get(),
w.media,
Style::default(),
pin("media"),
));
rows.push(TreeRow::pw(
"isp",
Some("soc"),
&format!("{}├─ ", cp),
"Camera (ISP)",
s.isp.get(),
w.isp,
Style::default(),
pin("isp"),
));
rows.push(TreeRow::pw(
"fabric",
Some("soc"),
&format!("{}└─ ", cp),
"Fabric",
s.fabric.get(),
w.fabric,
Style::default(),
pin("fabric"),
));
}
rows.push(TreeRow::pw_full(
"ssd",
Some("system"),
&t("ssd"),
&if m.ssd_model.is_empty() {
"SSD".into()
} else {
format!("SSD ({})", m.ssd_model)
},
s.ssd.get(),
w.ssd,
"",
&temp_info("SSD"),
BOLD,
pin("ssd"),
));
{
let ssd_cont = c("ssd");
let mut r = TreeRow::info(
Some("ssd"),
&format!("{}├─ ", ssd_cont),
"Read",
&human_rate(m.disk.read_bytes_per_sec),
&human_bytes(self.wh.disk_read_bytes),
DATA_STYLE,
);
r.key = Some("disk_read");
rows.push(r);
let mut r = TreeRow::info(
Some("ssd"),
&format!("{}└─ ", ssd_cont),
"Write",
&human_rate(m.disk.write_bytes_per_sec),
&human_bytes(self.wh.disk_write_bytes),
DATA_STYLE,
);
r.key = Some("disk_write");
rows.push(r);
}
{
let disp_w = s.display.get() + s.display_soc.get() + s.display_ext.get();
let disp_wh = w.display + w.display_soc + w.display_ext;
let name = if m.display.available {
if m.display.nits > 0.0 {
format!(
"Display ({:.0}% brightness, {:.0} nits)",
m.display.brightness_pct, m.display.nits
)
} else if m.display.brightness_pct > 0.0 {
format!("Display ({:.0}% brightness)", m.display.brightness_pct)
} else {
"Display (0% brightness)".into()
}
} else {
"Display (off)".into()
};
let style = if m.display.available { BOLD } else { DIM };
rows.push(TreeRow::pw(
"display",
Some("system"),
&t("display"),
&name,
disp_w,
disp_wh,
style,
pin("display"),
));
if m.soc.display_ext_w > 0.0 || w.display_ext > 0.0 {
let dc = c("display");
rows.push(TreeRow::pw(
"display_ext",
Some("display"),
&format!("{}└─ ", dc),
"External Display",
s.display_ext.get(),
w.display_ext,
Style::default(),
pin("display_ext"),
));
}
}
rows.push(TreeRow::pw(
"keyboard",
Some("system"),
&t("keyboard"),
&format!("Keyboard ({:.0}% brightness)", m.keyboard.brightness_pct),
s.keyboard.get(),
w.keyboard,
BOLD,
pin("keyboard"),
));
let audio_status = match (m.audio.device_active, m.audio.playing, m.audio.volume_pct) {
(false, _, _) => "off".into(),
(_, true, Some(v)) => format!(
"{:.0}% volume{}",
v,
if m.audio.muted { " muted" } else { ", playing" }
),
(_, true, None) => "playing".into(),
(_, false, _) => "idle".into(),
};
rows.push(TreeRow::pw(
"audio",
Some("system"),
&t("audio"),
&format!("Audio ({})", audio_status),
s.audio.get(),
w.audio,
BOLD,
pin("audio"),
));
let fc = c("fans");
if m.fans.is_empty() {
rows.push(TreeRow::pw(
"fans",
Some("system"),
&t("fans"),
"Fans (pending…)",
0.0,
0.0,
PENDING,
pin("fans"),
));
} else {
rows.push(TreeRow::pw(
"fans",
Some("system"),
&t("fans"),
"Fans",
s.fan_total.get(),
w.fans,
BOLD,
pin("fans"),
));
rows.extend(m.fans.iter().enumerate().map(|(i, fan)| {
let pfx = if i == m.fans.len() - 1 {
format!("{}└─ ", fc)
} else {
format!("{}├─ ", fc)
};
let fan_wh_val = self.fan_wh.get(i).copied().unwrap_or(0.0);
TreeRow::pw(
fan_key(i),
Some("fans"),
&pfx,
&format!(
"{} ({:.0}/{:.0} RPM)",
fan.name, fan.actual_rpm, fan.max_rpm
),
fan.estimated_power_w,
fan_wh_val,
Style::default(),
pin(fan_key(i)),
)
}));
}
let pc = c("peripherals");
let usb_total_w: f32 = m
.usb_devices
.iter()
.map(|d| d.power_ma.unwrap_or(0) as f32 * 5.0 / 1000.0)
.sum();
let usb_total_wh: f64 = self.usb_wh.iter().sum();
rows.push(TreeRow::pw(
"peripherals",
Some("system"),
&t("peripherals"),
"Peripherals",
s.wifi.get() + s.bluetooth.get() + s.pcie.get() + usb_total_w,
w.wifi + w.bluetooth + w.pcie + usb_total_wh,
BOLD,
pin("peripherals"),
));
rows.push(TreeRow::pw(
"pcie",
Some("peripherals"),
&format!("{}├─ ", pc),
"Thunderbolt/PCIe",
s.pcie.get(),
w.pcie,
Style::default(),
pin("pcie"),
));
let (wifi_name, wifi_style) = match (m.wifi.connected, m.wifi.phy_mode.is_empty()) {
(true, _) => {
let ch = if m.wifi.channel.is_empty() {
String::new()
} else {
format!(", ch{}", m.wifi.channel)
};
(
format!("WiFi ({} dBm, {}{})", m.wifi.rssi_dbm, m.wifi.phy_mode, ch),
Style::default(),
)
}
(false, true) => ("WiFi (scanning…)".into(), PENDING),
(false, false) => ("WiFi (off)".into(), Style::default()),
};
rows.push(TreeRow::pw(
"wifi",
Some("peripherals"),
&format!("{}├─ ", pc),
&wifi_name,
s.wifi.get(),
w.wifi,
wifi_style,
pin("wifi"),
));
let has_traffic =
s.net_down.get() > 0.0 || s.net_up.get() > 0.0 || self.wh.net_down_bytes > 0.0;
if m.wifi.connected || has_traffic {
let mut r = TreeRow::info(
Some("wifi"),
&format!("{}│ ├─ ", pc),
"↓ Download",
&human_rate(s.net_down.get() as f64),
&human_bytes(self.wh.net_down_bytes),
DATA_STYLE,
);
r.key = Some("net_down");
rows.push(r);
let mut r = TreeRow::info(
Some("wifi"),
&format!("{}│ └─ ", pc),
"↑ Upload",
&human_rate(s.net_up.get() as f64),
&human_bytes(self.wh.net_up_bytes),
DATA_STYLE,
);
r.key = Some("net_up");
rows.push(r);
}
let bt_name = if !m.bluetooth_devices.is_empty() {
format!("Bluetooth ({} devices)", m.bluetooth_devices.len())
} else {
"Bluetooth".into()
};
rows.push(TreeRow::pw(
"bluetooth",
Some("peripherals"),
&format!("{}├─ ", pc),
&bt_name,
s.bluetooth.get(),
w.bluetooth,
Style::default(),
pin("bluetooth"),
));
rows.extend(m.bluetooth_devices.iter().enumerate().map(|(i, d)| {
let pfx = if i == m.bluetooth_devices.len() - 1 {
format!("{}│ └─ ", pc)
} else {
format!("{}│ ├─ ", pc)
};
let bat = d
.batteries
.iter()
.map(|(l, p)| {
if l.is_empty() {
p.clone()
} else {
format!("{}: {}", l, p)
}
})
.collect::<Vec<_>>()
.join(", ");
let bat_str = if !bat.is_empty() {
format!(" [{}]", bat)
} else {
String::new()
};
TreeRow::info(
Some("bluetooth"),
&pfx,
&format!("{} {}{}", d.name, d.minor_type, bat_str),
"",
"",
DIM,
)
}));
if m.usb_devices.is_empty() {
rows.push(TreeRow::info(
Some("peripherals"),
&format!("{}└─ ", pc),
"USB (no devices)",
"",
"",
DIM,
));
} else {
rows.push(TreeRow::pw(
"usb",
Some("peripherals"),
&format!("{}└─ ", pc),
&format!("USB ({} devices)", m.usb_devices.len()),
usb_total_w,
usb_total_wh,
Style::default(),
pin("usb"),
));
for (i, d) in m.usb_devices.iter().enumerate() {
let pfx = if i == m.usb_devices.len() - 1 {
format!("{} └─ ", pc)
} else {
format!("{} ├─ ", pc)
};
let cont = if i == m.usb_devices.len() - 1 {
format!("{} ", pc)
} else {
format!("{} │ ", pc)
};
let watts = d.power_ma.unwrap_or(0) as f32 * 5.0 / 1000.0;
let usb_wh_val = self.usb_wh.get(i).copied().unwrap_or(0.0);
let key = usb_key(i);
let speed_str = match d.speed {
0 => "1.5Mbps",
1 => "12Mbps",
2 => "480Mbps",
3 => "5Gbps",
4 => "10Gbps",
5 => "20Gbps",
_ => "?",
};
let pwr_str = d.power_ma.map(|p| format!(", {}mA", p)).unwrap_or_default();
rows.push(TreeRow::pw(
key,
Some("usb"),
&pfx,
&format!("{} ({}{})", d.name.trim(), speed_str, pwr_str),
watts,
usb_wh_val,
Style::default(),
pin(key),
));
if d.bytes_read > 0 || d.bytes_written > 0 {
let (rate_r, rate_w) = self.usb_rates.get(i).copied().unwrap_or((0.0, 0.0));
rows.push(TreeRow::info(
Some(key),
&format!("{}├─ ", cont),
"Read",
&human_rate(rate_r),
&human_bytes(d.bytes_read as f64),
DATA_STYLE,
));
rows.push(TreeRow::info(
Some(key),
&format!("{}└─ ", cont),
"Write",
&human_rate(rate_w),
&human_bytes(d.bytes_written as f64),
DATA_STYLE,
));
}
}
}
let inline_cats = ["CPU", "GPU", "Memory", "SSD", "Battery", "ANE"];
let remaining: Vec<_> = temp_groups
.iter()
.filter(|(k, _)| !inline_cats.contains(&k.as_str()))
.collect();
if !remaining.is_empty() {
let tc = c("temps");
rows.push(TreeRow::header(
Some("system"),
&t("temps"),
"Temperatures",
"",
"Now (min–max)",
));
rows.extend(remaining.iter().enumerate().map(|(i, (cat, vals))| {
let pfx = if i == remaining.len() - 1 {
format!("{}└─ ", tc)
} else {
format!("{}├─ ", tc)
};
let avg = vals.iter().sum::<f32>() / vals.len() as f32;
let hmin = self.temp_min.get(*cat).copied().unwrap_or(avg);
let hmax = self.temp_max.get(*cat).copied().unwrap_or(avg);
let color = match avg {
a if a > 90.0 => Color::Red,
a if a > 70.0 => Color::Yellow,
_ => Color::Reset,
};
let mut r = TreeRow::info(
Some("temps"),
&pfx,
&format!("{} ({})", cat, vals.len()),
"",
&format!("{:.0}°C ({:.0}–{:.0})", avg, hmin, hmax),
Style::default().fg(color),
);
r.current_style = Style::default().fg(color);
r
}));
}
rows.push(TreeRow::separator());
let all_sw_energy =
(m.all_procs_energy_mj - self.proc_baseline.values().sum::<f64>()).max(0.0);
let visible_tree_rows = rows.iter().filter(|r| !self.is_hidden(r, &rows)).count();
let chart_slots = if self.pinned.is_empty() {
1
} else {
self.pinned.len() + 1
};
let reserved = visible_tree_rows + 5 + chart_slots * CHART_HEIGHT as usize;
let proc_limit = ((self.term_height as usize).saturating_sub(reserved)).max(10);
{
let mut sw_row = TreeRow::pw(
"software",
None,
"",
&format!("Software (filter: top {} by total)", proc_limit),
m.all_procs_power_w + 0.0,
all_sw_energy / 3600.0 / 1000.0,
BOLD,
pin("software"),
);
sw_row.label_style = BOLD;
rows.push(sw_row);
}
if m.top_processes.is_empty() {
rows.push(TreeRow::info(
Some("software"),
"",
"(collecting…)",
"",
"",
PENDING,
));
}
let self_pid = std::process::id() as i32;
let baseline = &self.proc_baseline;
let mut display_procs: Vec<_> = m
.top_processes
.iter()
.map(|p| {
let base = baseline.get(&p.pid).copied().unwrap_or(0.0);
(p, (p.energy_mj - base).max(0.0))
})
.filter(|(_, adj)| *adj > 0.0)
.collect();
display_procs.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
display_procs.truncate(proc_limit);
let proc_row_keys: Vec<&'static str> = display_procs
.iter()
.map(|(p, _)| proc_key(&mut self.proc_keys, p.pid))
.collect();
rows.extend(display_procs.iter().enumerate().map(|(i, (p, adj_mj))| {
let pfx = if i == display_procs.len() - 1 {
"└─ "
} else {
"├─ "
};
let dead = !p.alive;
let color = if dead {
Color::DarkGray
} else if p.pid == self_pid {
Color::Blue
} else {
power_color(p.power_w)
};
let key = proc_row_keys[i];
let label = if dead {
format!("{} ({}) [dead]", p.name, p.pid)
} else {
format!("{} ({})", p.name, p.pid)
};
let mut r = TreeRow::pw(
key,
Some("software"),
pfx,
&label,
p.power_w,
*adj_mj / 3600.0 / 1000.0,
Style::default().fg(color),
self.pinned.contains(&key),
);
r.current_style = Style::default().fg(color);
r
}));
rows
}
fn draw_tree_buf(
&mut self,
f: &mut Frame,
area: Rect,
rows: &[&TreeRow],
all_rows: &[TreeRow],
) {
let block = Block::default().borders(Borders::ALL).title(format!(
" Power Tree ({}/{}) ",
self.cursor + 1,
rows.len()
));
let inner = block.inner(area);
f.render_widget(block, area);
if inner.width < 20 || inner.height < 3 {
return;
}
let buf = f.buffer_mut();
let hdr_y = inner.y;
let right = inner.right();
let spark_gap: u16 = if inner.width > 90 { 1 } else { 0 };
let spark_w = if inner.width > 90 {
(inner.width - 90 - 1).min(60)
} else {
0
};
let tot_x = right
.saturating_sub(COL_TOT)
.saturating_sub(spark_w)
.saturating_sub(spark_gap);
let cur_x = tot_x.saturating_sub(COL_CUR);
let tmp_x = cur_x.saturating_sub(COL_TEMP);
let frq_x = tmp_x.saturating_sub(COL_FREQ);
let spark_x = right.saturating_sub(spark_w);
buf.set_string(inner.x + 2, hdr_y, "Component", BOLD);
right_str(buf, frq_x, hdr_y, COL_FREQ, "Freq", BOLD);
right_str(buf, tmp_x, hdr_y, COL_TEMP, "Temp", BOLD);
right_str(buf, cur_x, hdr_y, COL_CUR, "Current", BOLD);
right_str(buf, tot_x, hdr_y, COL_TOT, "Total", BOLD);
if spark_w > 0 {
right_str(buf, spark_x, hdr_y, spark_w, "History", BOLD);
}
let data_y = hdr_y + 1;
let vis_h = inner.height.saturating_sub(1) as usize;
let total = rows.len();
let scroll = self.scroll_offset(vis_h, total);
self.tree_data_y = data_y;
self.tree_scroll = scroll;
self.tree_vis_h = vis_h;
let pin_w: u16 = 2;
let tree_x = inner.x + pin_w;
for (vi, row) in rows.iter().skip(scroll).take(vis_h).enumerate() {
let y = data_y + vi as u16;
let abs_idx = scroll + vi;
if row.label == "\x00sep" {
let line = "─".repeat(inner.width as usize);
buf.set_string(inner.x, y, &line, TREE_STYLE);
continue;
}
if row.pinned {
buf.set_string(inner.x, y, PIN_MARKER, Style::default().fg(Color::Cyan));
}
buf.set_string(tree_x, y, &row.prefix, TREE_STYLE);
let label_x = tree_x + row.prefix.width() as u16;
let is_parent = row.has_children_in(all_rows);
let is_collapsed = row.key.map(|k| self.collapsed.contains(k)).unwrap_or(false);
let indicator = if is_parent {
if is_collapsed {
"▸ "
} else {
"▾ "
}
} else {
""
};
let max_label_w = cur_x.saturating_sub(label_x) as usize;
if is_parent {
buf.set_string(label_x, y, indicator, TREE_STYLE);
let lbl_start = label_x + indicator.width() as u16;
let lbl_text =
truncate_str(&row.label, max_label_w.saturating_sub(indicator.width()));
buf.set_string(lbl_start, y, &lbl_text, row.label_style);
} else {
let full_label = format!("{}{}", indicator, row.label);
let truncated_label = truncate_str(&full_label, max_label_w);
buf.set_string(label_x, y, &truncated_label, row.label_style);
}
if !row.freq.is_empty() {
buf.set_string(frq_x, y, " ".repeat(COL_FREQ as usize), Style::default());
right_str(buf, frq_x, y, COL_FREQ, &row.freq, DIM);
}
if !row.temp.is_empty() {
buf.set_string(tmp_x, y, " ".repeat(COL_TEMP as usize), Style::default());
right_str(buf, tmp_x, y, COL_TEMP, &row.temp, DIM);
}
if !row.current.is_empty() {
right_str(buf, cur_x, y, COL_CUR, &row.current, row.current_style);
}
if !row.total.is_empty() {
right_str(buf, tot_x, y, COL_TOT, &row.total, DIM);
}
if spark_w > 0 {
if let Some(key) = row.key {
if let Some(hist) = self.history.get(key) {
let w = spark_w as usize;
let skip = hist.len().saturating_sub(w);
let visible: Vec<f64> = hist.iter().skip(skip).copied().collect();
let vis_max = visible.iter().copied().fold(0.0f64, f64::max).max(0.001);
let is_data_key =
matches!(key, "net_down" | "net_up" | "disk_read" | "disk_write");
for (ci, &val) in visible.iter().enumerate() {
let x = spark_x + (w - visible.len() + ci) as u16;
let level = (val / vis_max * 7.0).round() as usize;
let ch = SPARK_CHARS[level.min(7)];
let color = if is_data_key {
Color::Rgb(80, 140, 255)
} else {
power_color(val as f32)
};
buf.set_string(x, y, ch.to_string(), Style::default().fg(color));
}
}
}
}
if abs_idx == self.cursor {
for cx in inner.x..inner.right() {
if let Some(cell) = buf.cell_mut(ratatui::layout::Position::new(cx, y)) {
cell.set_bg(Color::Rgb(50, 50, 60));
}
}
}
}
}
fn scroll_offset(&self, vis_h: usize, total: usize) -> usize {
if self.cursor < vis_h / 3 {
0
} else if self.cursor > total.saturating_sub(vis_h * 2 / 3) {
total.saturating_sub(vis_h)
} else {
self.cursor.saturating_sub(vis_h / 3)
}
}
#[allow(dead_code)]
fn draw_header(&self, f: &mut Frame, area: Rect) {
let block = Block::default()
.borders(Borders::ALL)
.title(" macpow — Apple Silicon Power Monitor ");
f.render_widget(block, area);
}
fn draw_charts(&self, f: &mut Frame, area: Rect, keys: &[&'static str]) {
if keys.is_empty() || area.height == 0 {
return;
}
let constraints: Vec<Constraint> = keys
.iter()
.map(|_| Constraint::Length(CHART_HEIGHT))
.collect();
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints(constraints)
.split(area);
for (i, &key) in keys.iter().enumerate() {
if i >= chunks.len() {
break;
}
let data = self.history.get(key);
let current = data.and_then(|b| b.back().copied()).unwrap_or(0.0);
let is_pinned = self.pinned.contains(&key);
let title_style = if is_pinned {
Style::default().fg(Color::Cyan)
} else {
Style::default().fg(Color::Reset)
};
let pin_icon = if is_pinned { " [pinned]" } else { "" };
let chart_area = chunks[i];
let inner = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Length(6), Constraint::Min(0)])
.split(chart_area);
let vis_w = inner[1].width.saturating_sub(2) as usize;
let visible_data: Vec<f64> = data
.map(|b| {
let skip = b.len().saturating_sub(vis_w);
b.iter().skip(skip).copied().collect()
})
.unwrap_or_default();
let vis_max = visible_data.iter().copied().fold(0.0f64, f64::max);
let scale_max = nice_scale(vis_max);
let is_data = matches!(key, "net_down" | "net_up" | "disk_read" | "disk_write");
let scale_h = inner[0].height;
let fmt_axis = |v: f64| -> String {
if is_data {
human_rate(v)
} else {
format!("{:>5.1}", v)
}
};
let scale_lines: Vec<Line> = (0..scale_h)
.map(|row| {
if row == 0 {
Line::from(Span::styled(fmt_axis(scale_max), DIM))
} else if row == scale_h / 2 {
Line::from(Span::styled(fmt_axis(scale_max / 2.0), DIM))
} else if row == scale_h - 1 {
Line::from(Span::styled(fmt_axis(0.0), DIM))
} else {
Line::from("")
}
})
.collect();
f.render_widget(Paragraph::new(scale_lines), inner[0]);
let title_value = if is_data {
human_rate(current)
} else {
format!("{:.3} W", current)
};
let block = Block::default()
.borders(Borders::ALL)
.border_style(DIM)
.title(Span::styled(
format!(
" {} — {}{}",
self.labels.get(key).map(|s| s.as_str()).unwrap_or(key),
title_value,
pin_icon
),
title_style,
));
let chart_inner = block.inner(inner[1]);
f.render_widget(block, inner[1]);
if chart_inner.width == 0 || chart_inner.height == 0 || scale_max <= 0.0 {
continue;
}
let buf = f.buffer_mut();
let inner_h = chart_inner.height as usize;
let max_eighths = inner_h * 8;
let bottom_y = chart_inner.y + chart_inner.height - 1;
for (ci, &val) in visible_data.iter().enumerate() {
let x = chart_inner.x + (vis_w.saturating_sub(visible_data.len()) + ci) as u16;
if x >= chart_inner.right() {
continue;
}
let color = if is_data {
Color::Rgb(80, 140, 255)
} else {
power_color(val as f32)
};
let bar_eighths =
((val / scale_max * max_eighths as f64).round() as usize).min(max_eighths);
let full_rows = bar_eighths / 8;
let remainder = bar_eighths % 8;
let style = Style::default().fg(color);
for row in 0..full_rows {
let y = bottom_y.saturating_sub(row as u16);
if y >= chart_inner.y {
buf.set_string(x, y, "█", style);
}
}
if remainder > 0 {
let y = bottom_y.saturating_sub(full_rows as u16);
if y >= chart_inner.y {
buf.set_string(
x,
y,
BAR_EIGHTHS[remainder].to_string(),
style,
);
}
}
}
}
}
fn draw_footer(&self, f: &mut Frame, area: Rect) {
let footer = Paragraph::new(Line::from(vec![
Span::styled(" q", Style::default().fg(Color::Yellow)),
Span::raw(" quit "),
Span::styled("r", Style::default().fg(Color::Yellow)),
Span::raw(" reset "),
Span::styled("a", Style::default().fg(Color::Yellow)),
Span::raw(format!(" avg:{}s ", self.sma_window)),
Span::styled("l", Style::default().fg(Color::Yellow)),
Span::raw(format!(" {}ms ", self.latency_ms)),
Span::styled("↑/↓", Style::default().fg(Color::Yellow)),
Span::raw(" select "),
Span::styled("←/→", Style::default().fg(Color::Yellow)),
Span::raw(" fold "),
Span::styled("+/-", Style::default().fg(Color::Yellow)),
Span::raw(" all "),
Span::styled("space", Style::default().fg(Color::Yellow)),
Span::raw(" pin "),
Span::styled("■", Style::default().fg(Color::Rgb(46, 139, 87))),
Span::raw("<1W "),
Span::styled("■", Style::default().fg(Color::Rgb(255, 255, 50))),
Span::raw("<5W "),
Span::styled("■", Style::default().fg(Color::Rgb(255, 140, 0))),
Span::raw("<10W "),
Span::styled("■", Style::default().fg(Color::Rgb(255, 50, 50))),
Span::raw("≥10W "),
Span::styled("■", PENDING),
Span::raw("pending"),
]));
f.render_widget(footer, area);
}
}
fn right_str(buf: &mut Buffer, x: u16, y: u16, width: u16, text: &str, style: Style) {
let tw = text.width() as u16;
let start = if tw >= width { x } else { x + width - tw };
buf.set_string(start, y, text, style);
}
fn truncate_str(s: &str, max_w: usize) -> String {
if s.width() <= max_w {
return s.to_string();
}
let mut w = 0;
let mut end = 0;
for (i, ch) in s.char_indices() {
let cw = unicode_width::UnicodeWidthChar::width(ch).unwrap_or(0);
if w + cw > max_w.saturating_sub(1) {
break;
}
w += cw;
end = i + ch.len_utf8();
}
format!("{}…", &s[..end])
}
fn temps_by_category(temps: &[TempSensor]) -> BTreeMap<String, Vec<f32>> {
temps
.iter()
.filter(|t| t.category != "Other")
.fold(BTreeMap::new(), |mut m, t| {
m.entry(t.category.clone())
.or_default()
.push(t.value_celsius);
m
})
}
fn selected_cpu_core_temps(
temps: &[TempSensor],
e_count: usize,
p_count: usize,
) -> (Vec<f32>, Vec<f32>) {
let mut e_temps = Vec::new();
let mut p_temps = Vec::new();
let e_candidates = sorted_temp_values(temps, |key| key.starts_with("Tex"));
if e_count > 0 && e_candidates.len() == e_count {
e_temps = e_candidates;
}
let p_triplets = sorted_temp_values(temps, |key| {
key.starts_with("Tp1") || key.starts_with("Tp2")
});
if p_count > 0 && p_triplets.len() == p_count * 3 {
p_temps = p_triplets
.chunks_exact(3)
.map(|chunk| chunk.iter().copied().fold(f32::NEG_INFINITY, f32::max))
.collect();
}
if e_temps.len() == e_count && p_temps.len() == p_count {
return (e_temps, p_temps);
}
let fallback = sorted_temp_values(temps, |key| key.starts_with("Tp0"));
if fallback.len() >= e_count + p_count
&& !is_placeholder_temp_bank(&fallback, e_count + p_count)
{
if e_temps.len() != e_count {
e_temps = fallback.iter().take(e_count).copied().collect();
}
if p_temps.len() != p_count {
p_temps = fallback
.iter()
.skip(e_count)
.take(p_count)
.copied()
.collect();
}
}
(e_temps, p_temps)
}
fn sorted_temp_values<F>(temps: &[TempSensor], predicate: F) -> Vec<f32>
where
F: Fn(&str) -> bool,
{
let mut vals: Vec<_> = temps
.iter()
.filter(|t| t.category == "CPU" && predicate(&t.key))
.map(|t| (t.key.as_str(), t.value_celsius))
.collect();
vals.sort_by(|a, b| a.0.cmp(b.0));
vals.into_iter().map(|(_, temp)| temp).collect()
}
fn is_placeholder_temp_bank(vals: &[f32], expected_count: usize) -> bool {
if vals.len() <= expected_count.saturating_mul(2) {
return false;
}
let min = vals.iter().copied().fold(f32::INFINITY, f32::min);
let max = vals.iter().copied().fold(f32::NEG_INFINITY, f32::max);
max - min < 0.25
}
fn stats(vals: &[f32]) -> (f32, f32, f32) {
let sum: f32 = vals.iter().sum();
let min = vals.iter().copied().fold(f32::INFINITY, f32::min);
let max = vals.iter().copied().fold(f32::NEG_INFINITY, f32::max);
(sum / vals.len() as f32, min, max)
}
fn nice_scale(max_val: f64) -> f64 {
if max_val <= 0.0 {
return 1.0;
}
let steps = [0.1, 0.2, 0.5, 1.0, 2.0, 5.0, 10.0, 20.0, 50.0, 100.0, 200.0];
steps
.iter()
.copied()
.find(|&s| s >= max_val)
.unwrap_or(max_val.ceil().max(1.0))
}
fn usage_bar(pct: f32) -> String {
let width = 10;
let filled = ((pct / 100.0) * width as f32).round() as usize;
let empty = width - filled.min(width);
format!("{}{}", "▓".repeat(filled), "░".repeat(empty))
}
fn fan_key(index: usize) -> &'static str {
const KEYS: [&str; 8] = [
"fan0", "fan1", "fan2", "fan3", "fan4", "fan5", "fan6", "fan7",
];
KEYS.get(index).copied().unwrap_or("fan0")
}
fn usb_key(index: usize) -> &'static str {
const KEYS: [&str; 16] = [
"usb0", "usb1", "usb2", "usb3", "usb4", "usb5", "usb6", "usb7", "usb8", "usb9", "usb10",
"usb11", "usb12", "usb13", "usb14", "usb15",
];
KEYS.get(index).copied().unwrap_or("usb0")
}
fn proc_key(cache: &mut std::collections::HashMap<i32, &'static str>, pid: i32) -> &'static str {
cache
.entry(pid)
.or_insert_with(|| Box::leak(format!("pid.{}", pid).into_boxed_str()))
}
fn read_machine_name() -> String {
let chip = std::process::Command::new("sysctl")
.args(["-n", "machdep.cpu.brand_string"])
.output()
.ok()
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
.filter(|s| !s.is_empty());
let model = std::process::Command::new("sysctl")
.args(["-n", "hw.model"])
.output()
.ok()
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
.filter(|s| !s.is_empty());
match (chip, model) {
(Some(c), Some(m)) => format!("{} ({})", c, m),
(Some(c), None) => c,
(None, Some(m)) => m,
_ => "Mac".into(),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn cpu_sensor(key: &str, temp: f32) -> TempSensor {
TempSensor {
key: key.to_string(),
category: "CPU".to_string(),
value_celsius: temp,
}
}
#[test]
fn selects_modern_cpu_core_temps() {
let mut temps = vec![
cpu_sensor("Tex2", 42.0),
cpu_sensor("Tex0", 40.0),
cpu_sensor("Tex3", 43.0),
cpu_sensor("Tex1", 41.0),
];
for (idx, base) in [50.0, 55.0, 60.0, 65.0].into_iter().enumerate() {
temps.push(cpu_sensor(&format!("Tp1{}a", idx), base - 10.0));
temps.push(cpu_sensor(&format!("Tp1{}b", idx), base - 5.0));
temps.push(cpu_sensor(&format!("Tp1{}c", idx), base));
}
for (idx, base) in [70.0, 71.0, 72.0, 73.0, 74.0, 75.0].into_iter().enumerate() {
temps.push(cpu_sensor(&format!("Tp2{}a", idx), base - 10.0));
temps.push(cpu_sensor(&format!("Tp2{}b", idx), base - 5.0));
temps.push(cpu_sensor(&format!("Tp2{}c", idx), base));
}
let (e_temps, p_temps) = selected_cpu_core_temps(&temps, 4, 10);
assert_eq!(e_temps, vec![40.0, 41.0, 42.0, 43.0]);
assert_eq!(
p_temps,
vec![50.0, 55.0, 60.0, 65.0, 70.0, 71.0, 72.0, 73.0, 74.0, 75.0]
);
}
#[test]
fn falls_back_to_legacy_tp0_mapping() {
let temps: Vec<_> = (0..14)
.map(|idx| cpu_sensor(&format!("Tp0{idx:02}"), 35.0 + idx as f32))
.collect();
let (e_temps, p_temps) = selected_cpu_core_temps(&temps, 4, 10);
assert_eq!(e_temps, vec![35.0, 36.0, 37.0, 38.0]);
assert_eq!(
p_temps,
vec![39.0, 40.0, 41.0, 42.0, 43.0, 44.0, 45.0, 46.0, 47.0, 48.0]
);
}
#[test]
fn ignores_flat_placeholder_tp0_bank() {
let temps: Vec<_> = (0..40)
.map(|idx| cpu_sensor(&format!("Tp0{idx:02}"), 40.0))
.collect();
let (e_temps, p_temps) = selected_cpu_core_temps(&temps, 4, 10);
assert!(e_temps.is_empty());
assert!(p_temps.is_empty());
}
}