use std::sync::{Arc, RwLock};
use std::{io::stdout, time::Instant};
use std::{sync::mpsc, time::Duration};
use ratatui::crossterm::{
ExecutableCommand,
event::{self, KeyCode, KeyModifiers},
terminal,
};
use ratatui::{prelude::*, widgets::*};
use crate::config::{Config, TUI_MAX_MS, TUI_MIN_MS, ViewType};
use crate::shared::zero_div;
use crate::sources::{SocInfo, get_soc_info};
use macmon::{FanMetric, MemMetrics, Metrics, Sampler};
type WithError<T> = Result<T, Box<dyn std::error::Error>>;
const GB: u64 = 1024 * 1024 * 1024;
const MAX_SPARKLINE: usize = 128;
const MAX_TEMPS: usize = 8;
fn enter_term() -> Terminal<impl Backend> {
std::panic::set_hook(Box::new(|info| {
leave_term();
eprintln!("{}", info);
}));
terminal::enable_raw_mode().unwrap();
stdout().execute(terminal::EnterAlternateScreen).unwrap();
let term = CrosstermBackend::new(std::io::stdout());
Terminal::new(term).unwrap()
}
fn leave_term() {
terminal::disable_raw_mode().unwrap();
stdout().execute(terminal::LeaveAlternateScreen).unwrap();
}
#[derive(Debug, Default, Clone)]
struct FreqStore {
items: Vec<u64>, top_value: u64,
usage: f64, }
impl FreqStore {
fn push(&mut self, value: u64, usage: f64) {
self.items.insert(0, (usage * 100.0) as u64);
self.items.truncate(MAX_SPARKLINE);
self.top_value = value;
self.usage = usage;
}
}
#[derive(Debug, Default)]
struct PowerStore {
items: Vec<u64>,
top_value: f64,
max_value: f64,
avg_value: f64,
}
impl PowerStore {
fn push(&mut self, value: f64) {
let was_top = if !self.items.is_empty() { self.items[0] as f64 / 1000.0 } else { 0.0 };
self.items.insert(0, (value * 1000.0) as u64);
self.items.truncate(MAX_SPARKLINE);
self.top_value = avg2(was_top, value);
self.avg_value = self.items.iter().sum::<u64>() as f64 / self.items.len() as f64 / 1000.0;
self.max_value = self.items.iter().max().map_or(0, |v| *v) as f64 / 1000.0;
}
}
#[derive(Debug, Default)]
struct MemoryStore {
items: Vec<u64>,
swap_items: Vec<u64>,
ram_usage: u64,
ram_total: u64,
swap_usage: u64,
swap_total: u64,
max_ram: u64,
max_swap: u64,
}
impl MemoryStore {
fn push(&mut self, value: MemMetrics) {
self.items.insert(0, value.ram_usage);
self.items.truncate(MAX_SPARKLINE);
self.swap_items.insert(0, value.swap_usage);
self.swap_items.truncate(MAX_SPARKLINE);
self.ram_usage = value.ram_usage;
self.ram_total = value.ram_total;
self.swap_usage = value.swap_usage;
self.swap_total = value.swap_total;
self.max_ram = self.items.iter().max().map_or(0, |v| *v);
self.max_swap = self.swap_items.iter().max().map_or(0, |v| *v);
}
}
#[derive(Debug, Default)]
struct TempStore {
items: Vec<f32>,
}
impl TempStore {
fn last(&self) -> f32 {
*self.items.first().unwrap_or(&0.0)
}
fn push(&mut self, value: f32) {
let value = if value == 0.0 { self.trend_ema(0.8) } else { value };
if value == 0.0 {
return; }
self.items.insert(0, value);
self.items.truncate(MAX_TEMPS);
}
fn trend_ema(&self, alpha: f32) -> f32 {
if self.items.len() < 2 {
return 0.0;
}
let mut iter = self.items.iter().rev();
let mut ema = *iter.next().unwrap_or(&0.0);
for &item in iter {
ema = alpha * item + (1.0 - alpha) * ema;
}
ema
}
}
#[derive(Debug, Default)]
struct FanStore {
items: Vec<FanMetric>,
}
impl FanStore {
fn push(&mut self, value: Vec<FanMetric>) {
self.items = value;
}
fn label(&self) -> String {
match self.items.as_slice() {
[] => "".to_string(),
[fan] => format!("Fan {} RPM", fan.rpm),
fans => {
let values = fans.iter().map(|fan| fan.rpm.to_string()).collect::<Vec<_>>().join("/");
format!("Fans {values} RPM")
}
}
}
}
fn bar_set() -> symbols::bar::Set<'static> {
match std::env::var("TERM_PROGRAM").as_deref() {
Ok("Apple_Terminal") => symbols::bar::THREE_LEVELS,
_ => symbols::bar::NINE_LEVELS,
}
}
fn compute_aggregate_freq(cores: &[FreqStore]) -> FreqStore {
if cores.is_empty() {
return FreqStore::default();
}
let avg_usage = cores.iter().map(|c| c.usage).sum::<f64>() / cores.len() as f64;
let avg_freq = cores.iter().map(|c| c.top_value).sum::<u64>() / cores.len() as u64;
let max_len = cores.iter().map(|c| c.items.len()).max().unwrap_or(0);
let mut aggregated_items = Vec::with_capacity(max_len);
for i in 0..max_len {
let mut sum = 0u64;
let mut count = 0;
for core in cores {
if let Some(&val) = core.items.get(i) {
sum += val;
count += 1;
}
}
if count > 0 {
aggregated_items.push(sum / count as u64);
}
}
FreqStore { items: aggregated_items, top_value: avg_freq, usage: avg_usage }
}
fn h_stack(area: Rect) -> (Rect, Rect) {
let ha = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Fill(1), Constraint::Fill(1)].as_ref())
.split(area);
(ha[0], ha[1])
}
enum Event {
Update(Box<Metrics>),
ChangeColor,
ChangeView,
TogglePerCore,
IncInterval,
DecInterval,
Tick,
Quit,
}
fn handle_key_event(key: &event::KeyEvent, tx: &mpsc::Sender<Event>) -> WithError<()> {
match key.code {
KeyCode::Char('q') => Ok(tx.send(Event::Quit)?),
KeyCode::Char('c') if key.modifiers == KeyModifiers::CONTROL => Ok(tx.send(Event::Quit)?),
KeyCode::Char('c') => Ok(tx.send(Event::ChangeColor)?),
KeyCode::Char('v') => Ok(tx.send(Event::ChangeView)?),
KeyCode::Char('d') => Ok(tx.send(Event::TogglePerCore)?),
KeyCode::Char('+') => Ok(tx.send(Event::IncInterval)?),
KeyCode::Char('=') => Ok(tx.send(Event::IncInterval)?), KeyCode::Char('-') => Ok(tx.send(Event::DecInterval)?),
_ => Ok(()),
}
}
fn run_inputs_thread(tx: mpsc::Sender<Event>, tick: u64) {
let tick_rate = Duration::from_millis(tick);
std::thread::spawn(move || {
let mut last_tick = Instant::now();
loop {
if event::poll(Duration::from_millis(tick)).unwrap() {
match event::read().unwrap() {
event::Event::Key(key) => handle_key_event(&key, &tx).unwrap(),
_ => {}
};
}
if last_tick.elapsed() >= tick_rate {
tx.send(Event::Tick).unwrap();
last_tick = Instant::now();
}
}
});
}
fn run_sampler_thread(tx: mpsc::Sender<Event>, msec: Arc<RwLock<u32>>) {
std::thread::spawn(move || {
let mut sampler = Sampler::new().unwrap();
tx.send(Event::Update(Box::new(sampler.get_metrics(100).unwrap()))).unwrap();
loop {
let msec = (*msec.read().unwrap()).max(TUI_MIN_MS);
tx.send(Event::Update(Box::new(sampler.get_metrics(msec).unwrap()))).unwrap();
}
});
}
fn avg2<T: num_traits::Float>(a: T, b: T) -> T {
if a == T::zero() { b } else { (a + b) / T::from(2.0).unwrap() }
}
#[derive(Debug, Default)]
pub struct App {
cfg: Config,
soc: SocInfo,
mem: MemoryStore,
cpu_power: PowerStore,
gpu_power: PowerStore,
ane_power: PowerStore,
all_power: PowerStore,
sys_power: PowerStore,
cpu_temp: TempStore,
gpu_temp: TempStore,
fans: FanStore,
ecpu_freq: Vec<FreqStore>,
pcpu_freq: Vec<FreqStore>,
igpu_freq: FreqStore,
}
impl App {
pub fn new() -> WithError<Self> {
let soc = get_soc_info()?;
let cfg = Config::load();
let ecpu_freq = vec![FreqStore::default(); soc.ecpu_cores as usize];
let pcpu_freq = vec![FreqStore::default(); soc.pcpu_cores as usize];
Ok(Self { cfg, soc, ecpu_freq, pcpu_freq, ..Default::default() })
}
fn update_metrics(&mut self, data: Metrics) {
self.cpu_power.push(data.cpu_power as f64);
self.gpu_power.push(data.gpu_power as f64);
self.ane_power.push(data.ane_power as f64);
self.all_power.push(data.all_power as f64);
self.sys_power.push(data.sys_power as f64);
for (i, core) in data.ecpu_cores.iter().enumerate() {
if i < self.ecpu_freq.len() {
self.ecpu_freq[i].push(core.freq_mhz as u64, core.usage_ratio as f64);
}
}
for (i, core) in data.pcpu_cores.iter().enumerate() {
if i < self.pcpu_freq.len() {
self.pcpu_freq[i].push(core.freq_mhz as u64, core.usage_ratio as f64);
}
}
self.igpu_freq.push(data.gpu_freq_mhz as u64, data.gpu_usage_ratio as f64);
self.cpu_temp.push(data.temp.cpu_temp_avg);
self.gpu_temp.push(data.temp.gpu_temp_avg);
self.fans.push(data.fans);
self.mem.push(data.memory);
}
fn title_block<'a>(&self, label_l: &str, label_r: &str) -> Block<'a> {
let mut block = Block::new()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(self.cfg.color)
.padding(Padding::ZERO);
if !label_l.is_empty() {
block = block.title_top(Line::from(format!(" {label_l} ")));
}
if !label_r.is_empty() {
block = block.title_top(Line::from(format!(" {label_r} ")).alignment(Alignment::Right));
}
block
}
fn get_power_block<'a>(&self, label: &str, val: &'a PowerStore, temp: f32) -> Sparkline<'a> {
let label_l = format!(
"{} {:.2}W ({:.2}, {:.2})",
label,
val.top_value,
val.avg_value,
val.max_value
);
let label_r = if temp > 0.0 { format!("{:.1}°C", temp) } else { "".to_string() };
Sparkline::default()
.block(self.title_block(label_l.as_str(), label_r.as_str()))
.direction(RenderDirection::RightToLeft)
.data(&val.items)
.style(self.cfg.color)
.bar_set(bar_set())
}
fn render_freq_block(&self, f: &mut Frame, r: Rect, label: &str, val: &FreqStore) {
let label = format!("{} {:3.0}% @ {:4.0} MHz", label, val.usage * 100.0, val.top_value);
let block = self.title_block(label.as_str(), "");
match self.cfg.view_type {
ViewType::Sparkline => {
let w = Sparkline::default()
.block(block)
.direction(RenderDirection::RightToLeft)
.data(&val.items)
.max(100)
.style(self.cfg.color)
.bar_set(bar_set());
f.render_widget(w, r);
}
ViewType::Gauge => {
let w = Gauge::default()
.block(block)
.gauge_style(self.cfg.color)
.style(self.cfg.color)
.label("")
.ratio(val.usage);
f.render_widget(w, r);
}
}
}
fn render_multi_core_block(&self, f: &mut Frame, r: Rect, label: &str, cores: &[FreqStore]) {
if cores.is_empty() {
return;
}
let avg_usage = cores.iter().map(|c| c.usage).sum::<f64>() / cores.len() as f64;
let avg_freq = cores.iter().map(|c| c.top_value).sum::<u64>() / cores.len() as u64;
let title = format!(
"{} {:3.0}% @ {:4.0} MHz ({} cores)",
label,
avg_usage * 100.0,
avg_freq,
cores.len()
);
let block = self.title_block(title.as_str(), "");
let inner = block.inner(r);
f.render_widget(block, r);
let constraints: Vec<Constraint> = (0..cores.len()).map(|_| Constraint::Fill(1)).collect();
let core_areas =
Layout::default().direction(Direction::Vertical).constraints(constraints).split(inner);
for (i, core) in cores.iter().enumerate() {
if i >= core_areas.len() {
break;
}
let core_label = format!("Core {} {:3.0}%", i, core.usage * 100.0);
match self.cfg.view_type {
ViewType::Sparkline => {
let w = Sparkline::default()
.direction(RenderDirection::RightToLeft)
.data(&core.items)
.max(100)
.style(self.cfg.color)
.bar_set(bar_set());
let label_len = core_label.len();
let label_span = Span::styled(core_label, Style::default().fg(self.cfg.color));
let mut area = core_areas[i];
if area.width > label_len as u16 {
let label_area = Rect { x: area.x, y: area.y, width: label_len as u16 + 1, height: 1 };
f.render_widget(Paragraph::new(label_span), label_area);
area.x += label_len as u16 + 1;
area.width = area.width.saturating_sub(label_len as u16 + 1);
}
f.render_widget(w, area);
}
ViewType::Gauge => {
let w = Gauge::default()
.gauge_style(self.cfg.color)
.style(self.cfg.color)
.label(core_label)
.ratio(core.usage);
f.render_widget(w, core_areas[i]);
}
}
}
}
fn render_mem_block(&self, f: &mut Frame, r: Rect, val: &MemoryStore) {
let ram_usage_gb = val.ram_usage as f64 / GB as f64;
let ram_total_gb = val.ram_total as f64 / GB as f64;
let swap_usage_gb = val.swap_usage as f64 / GB as f64;
let swap_total_gb = val.swap_total as f64 / GB as f64;
let ram_pct = zero_div(ram_usage_gb, ram_total_gb) * 100.0;
let label_l = format!("RAM {:4.2} / {:4.1} GB ({:.1}%)", ram_usage_gb, ram_total_gb, ram_pct);
let label_r = if val.swap_total > 0 {
format!("SWAP {:.2} / {:.1} GB", swap_usage_gb, swap_total_gb)
} else {
String::new()
};
let block = self.title_block(label_l.as_str(), label_r.as_str());
match self.cfg.view_type {
ViewType::Sparkline => {
let w = Sparkline::default()
.block(block)
.direction(RenderDirection::RightToLeft)
.data(&val.items)
.max(val.ram_total)
.style(self.cfg.color)
.bar_set(bar_set());
f.render_widget(w, r);
}
ViewType::Gauge => {
let w = Gauge::default()
.block(block)
.gauge_style(self.cfg.color)
.style(self.cfg.color)
.label("")
.ratio(zero_div(ram_usage_gb, ram_total_gb));
f.render_widget(w, r);
}
}
}
fn gauge_label(&self, label: String, ratio: f64) -> Span<'static> {
let fg = if ratio > 0.5 { Color::Black } else { self.cfg.color };
Span::styled(label, Style::default().fg(fg))
}
fn render_split_mem_block(&self, f: &mut Frame, r: Rect, val: &MemoryStore) {
let ram_usage_gb = val.ram_usage as f64 / GB as f64;
let ram_total_gb = val.ram_total as f64 / GB as f64;
let swap_usage_gb = val.swap_usage as f64 / GB as f64;
let swap_total_gb = val.swap_total as f64 / GB as f64;
let title = "Memory";
let block = self.title_block(title, "");
let inner = block.inner(r);
f.render_widget(block, r);
let constraints = if val.swap_total > 0 {
vec![Constraint::Fill(1), Constraint::Fill(1)]
} else {
vec![Constraint::Fill(1)]
};
let sections =
Layout::default().direction(Direction::Vertical).constraints(constraints).split(inner);
let ram_label = format!("RAM {:4.2}/{:4.1} GB", ram_usage_gb, ram_total_gb);
match self.cfg.view_type {
ViewType::Sparkline => {
let w = Sparkline::default()
.direction(RenderDirection::RightToLeft)
.data(&val.items)
.max(val.ram_total)
.style(self.cfg.color)
.bar_set(bar_set());
let label_len = ram_label.len();
let label_span = Span::styled(ram_label, Style::default().fg(self.cfg.color));
let mut area = sections[0];
if area.width > label_len as u16 {
let label_area = Rect { x: area.x, y: area.y, width: label_len as u16 + 1, height: 1 };
f.render_widget(Paragraph::new(label_span), label_area);
area.x += label_len as u16 + 1;
area.width = area.width.saturating_sub(label_len as u16 + 1);
}
f.render_widget(w, area);
}
ViewType::Gauge => {
let ratio = zero_div(ram_usage_gb, ram_total_gb);
let w = Gauge::default()
.gauge_style(self.cfg.color)
.style(self.cfg.color)
.label(self.gauge_label(ram_label, ratio))
.ratio(ratio);
f.render_widget(w, sections[0]);
}
}
if val.swap_total == 0 {
return;
}
let swap_label = format!("SWAP {:4.2}/{:4.1} GB", swap_usage_gb, swap_total_gb);
match self.cfg.view_type {
ViewType::Sparkline => {
let w = Sparkline::default()
.direction(RenderDirection::RightToLeft)
.data(&val.swap_items)
.max(val.swap_total.max(1)) .style(self.cfg.color)
.bar_set(bar_set());
let label_len = swap_label.len();
let label_span = Span::styled(swap_label, Style::default().fg(self.cfg.color));
let mut area = sections[1];
if area.width > label_len as u16 {
let label_area = Rect { x: area.x, y: area.y, width: label_len as u16 + 1, height: 1 };
f.render_widget(Paragraph::new(label_span), label_area);
area.x += label_len as u16 + 1;
area.width = area.width.saturating_sub(label_len as u16 + 1);
}
f.render_widget(w, area);
}
ViewType::Gauge => {
let ratio = zero_div(swap_usage_gb, swap_total_gb);
let w = Gauge::default()
.gauge_style(self.cfg.color)
.style(self.cfg.color)
.label(self.gauge_label(swap_label, ratio))
.ratio(ratio);
f.render_widget(w, sections[1]);
}
}
}
fn render(&mut self, f: &mut Frame) {
let label_l = format!(
"{} ({}{}+{}{}+{}GPU {}GB)",
self.soc.chip_name,
self.soc.ecpu_cores,
self.soc.ecpu_label,
self.soc.pcpu_cores,
self.soc.pcpu_label,
self.soc.gpu_cores,
self.soc.memory_gb,
);
let rows = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Fill(2), Constraint::Fill(1)].as_ref())
.split(f.area());
let brand = format!("{} v{}", env!("CARGO_PKG_NAME"), env!("CARGO_PKG_VERSION"));
let block = self.title_block(&label_l, &brand);
let iarea = block.inner(rows[0]);
f.render_widget(block, rows[0]);
let iarea = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Fill(1), Constraint::Fill(1)].as_ref())
.split(iarea);
let (c1, c2) = h_stack(iarea[0]);
let ecpu_block_label = format!("{}-CPU", self.soc.ecpu_label);
let pcpu_block_label = format!("{}-CPU", self.soc.pcpu_label);
if self.cfg.per_core_view {
self.render_multi_core_block(f, c1, &ecpu_block_label, &self.ecpu_freq);
self.render_multi_core_block(f, c2, &pcpu_block_label, &self.pcpu_freq);
} else {
let ecpu_agg = compute_aggregate_freq(&self.ecpu_freq);
let pcpu_agg = compute_aggregate_freq(&self.pcpu_freq);
self.render_freq_block(f, c1, &ecpu_block_label, &ecpu_agg);
self.render_freq_block(f, c2, &pcpu_block_label, &pcpu_agg);
}
let (c1, c2) = h_stack(iarea[1]);
if self.cfg.per_core_view {
self.render_split_mem_block(f, c1, &self.mem);
} else {
self.render_mem_block(f, c1, &self.mem);
}
self.render_freq_block(f, c2, "GPU", &self.igpu_freq);
let label_l = format!(
"Power: {:.2}W (avg {:.2}W, max {:.2}W)",
self.all_power.top_value, self.all_power.avg_value, self.all_power.max_value,
);
let fan_label = self.fans.label();
let sys_label = if self.sys_power.top_value > 0.0 {
Some(format!(
"Total {:.2}W ({:.2}, {:.2})",
self.sys_power.top_value, self.sys_power.avg_value, self.sys_power.max_value
))
} else {
None
};
let label_r = match (!fan_label.is_empty(), sys_label) {
(true, Some(sys_label)) => format!("{fan_label} | {sys_label}"),
(true, None) => fan_label,
(false, Some(sys_label)) => sys_label,
(false, None) => "".to_string(),
};
let block = self.title_block(&label_l, &label_r);
let usage = format!(" q quit | c color | v chart | d detail | -/+ {}ms ", self.cfg.interval);
let block = block.title_bottom(Line::from(usage).right_aligned());
let iarea = block.inner(rows[1]);
f.render_widget(block, rows[1]);
let ha = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Fill(1), Constraint::Fill(1), Constraint::Fill(1)].as_ref())
.split(iarea);
f.render_widget(self.get_power_block("CPU", &self.cpu_power, self.cpu_temp.last()), ha[0]);
f.render_widget(self.get_power_block("GPU", &self.gpu_power, self.gpu_temp.last()), ha[1]);
f.render_widget(self.get_power_block("ANE", &self.ane_power, 0.0), ha[2]);
}
pub fn run_loop(&mut self, interval: Option<u32>) -> WithError<()> {
self.cfg.interval = interval.unwrap_or(self.cfg.interval).clamp(TUI_MIN_MS, TUI_MAX_MS);
let msec = Arc::new(RwLock::new(self.cfg.interval));
let (tx, rx) = mpsc::channel::<Event>();
run_inputs_thread(tx.clone(), 250);
run_sampler_thread(tx.clone(), msec.clone());
let mut term = enter_term();
loop {
term.draw(|f| self.render(f)).unwrap();
match rx.recv()? {
Event::Quit => break,
Event::Update(data) => self.update_metrics(*data),
Event::ChangeColor => self.cfg.next_color(),
Event::ChangeView => self.cfg.next_view_type(),
Event::TogglePerCore => self.cfg.toggle_per_core_view(),
Event::IncInterval => {
self.cfg.inc_interval();
*msec.write().unwrap() = self.cfg.interval;
}
Event::DecInterval => {
self.cfg.dec_interval();
*msec.write().unwrap() = self.cfg.interval;
}
_ => {}
}
}
leave_term();
Ok(())
}
}