josie / alder-tools

//! htop-style TUI front-end. Consumes `sensors::` snapshots on a ~250 ms
//! tick; never touches sysfs itself. Style per design.md: terminal-default
//! background, one-line htop meters, ANSI color classes only.

use std::time::{Duration, Instant};

use crossterm::event::{self, Event, KeyCode};
use ratatui::{
    Frame,
    style::{Color, Modifier, Style},
    text::{Line, Span},
};

use crate::sensors;

const TICK_MS: u64 = 250;
const PEAK_WINDOW: Duration = Duration::from_secs(5 * 60);
/// vCore shown Yellow when within this margin of the limit, Red over it.
const VCORE_MARGIN: f64 = 0.05;
/// Safety limit, volts. User-tunable config value. Intel max Vcore
/// spec default for the 12600KF.
const VCORE_LIMIT: f64 = 1.403;
const VCORE_BAR_MAX: f64 = 1.50;
const TEMP_WARN: f64 = 80.0;
const TEMP_CRIT: f64 = 95.0;
/// Package power bar scale, watts (dGPU PPT ceiling as a sane display max).
const POWER_BAR_MAX: f64 = 200.0;
/// Temp bar scale, °C.
const TEMP_BAR_MAX: f64 = 100.0;

pub struct App {
    vcore: Option<f64>,
    vcore_peak: Option<f64>,
    /// (timestamp, volts) per-tick maxima inside the 5-min peak window
    peak_ring: Vec<(Instant, f64)>,
    package_watts: Option<f64>,
    package_temp: Option<f64>,
    /// Per physical core: label (coretemp "Core N"), freq kHz, bar-max kHz
    /// (that core's cpuinfo_max_freq), temperature °C.
    cores: Vec<CoreRow>,
    last_pkg_energy: Option<(u64, Instant)>,
}

/// One physical core's row in the per-core section.
struct CoreRow {
    label: String,
    e_core: bool,
    freq_khz: Option<u64>,
    freq_min_khz: u64,
    freq_max_khz: u64,
    temp: Option<f64>,
}

impl App {
    pub fn new() -> App {
        App {
            vcore: None,
            vcore_peak: None,
            peak_ring: Vec::new(),
            package_watts: None,
            package_temp: None,
            cores: Vec::new(),
            last_pkg_energy: None,
        }
    }

    fn poll(&mut self) {
        self.vcore = sensors::sio_vcore();
        if let Some(v) = self.vcore {
            let now = Instant::now();
            self.peak_ring.push((now, v));
            self.peak_ring
                .retain(|(t, _)| now.duration_since(*t) <= PEAK_WINDOW);
            self.vcore_peak = Some(
                self.peak_ring
                    .iter()
                    .map(|(_, v)| *v)
                    .fold(f64::MIN, f64::max),
            );
        }

        if let Some(chip) = sensors::discover_chips()
            .into_iter()
            .find(|c| c.name == "coretemp")
        {
            let temps = sensors::temperatures(&chip);
            self.package_temp = temps
                .iter()
                .find(|(l, _)| l.contains("Package"))
                .map(|(_, v)| *v);

            // Per physical core: coretemp label "Core N" N == topology
            // core_id; cpufreq is per logical cpu, so a core's speed is the
            // max across its HT siblings. Verified mapping for the 12600KF:
            // P core_ids 0/4/8/12/16/20 → cpus (0,1)..(10,11), E 28..31 →
            // cpu12..15 (no HT).
            let freqs = sensors::cpu_frequencies();
            self.cores = temps
                .iter()
                .filter(|(l, _)| l.starts_with("Core "))
                .map(|(label, t)| {
                    let core_id: u64 = label
                        .strip_prefix("Core ")
                        .and_then(|d| d.parse().ok())
                        .unwrap_or(u64::MAX);
                    let cpus = core_to_cpus(core_id);
                    let e_core = cpus.first().is_some_and(|c| *c >= 12);
                    let freq_khz = cpus
                        .iter()
                        .filter_map(|c| {
                            freqs.iter().find(|(cpu, _)| cpu == c).map(|(_, k)| *k)
                        })
                        .max();
                    let freq_max_khz = cpus
                        .iter()
                        .filter_map(|c| sensors::cpu_max_freq(*c))
                        .max()
                        .unwrap_or(0);
                    let freq_min_khz = cpus
                        .iter()
                        .filter_map(|c| sensors::cpu_min_freq(*c))
                        .max()
                        .unwrap_or(0);
                    CoreRow {
                        label: label.clone(),
                        e_core,
                        freq_khz,
                        freq_min_khz,
                        freq_max_khz,
                        temp: Some(*t),
                    }
                })
                .collect();
            self.cores
                .sort_by_key(|c| (c.e_core, c.label.clone()));
        }

        // Watts = energy delta over the tick (poll cadence IS the sample
        // interval; no extra sleep like the dump's fixed 500 ms window).
        if let Some(d) = sensors::rapl_domains()
            .iter()
            .find(|d| d.name.starts_with("package"))
        {
            if let Some(e) = sensors::rapl_energy_uj(&d.id) {
                self.last_pkg_energy = match self.last_pkg_energy {
                    Some((prev, t0)) => {
                        let dt = t0.elapsed().as_secs_f64();
                        if dt > 0.0 && e >= prev {
                            self.package_watts =
                                Some((e - prev) as f64 / 1_000_000.0 / dt);
                        }
                        Some((e, Instant::now()))
                    }
                    None => Some((e, Instant::now())),
                };
            }
        }
    }
}

/// Verified topology mapping for the 12600KF: physical core N with HT has
/// logical cpus (2k, 2k+1); P core_ids 0/4/8/12/16/20 map to pairs starting
/// at core_id/2·2. E-cores 28..31 map 1:1 to cpu12..15.
fn core_to_cpus(core_id: u64) -> Vec<usize> {
    match core_id {
        0 => vec![0, 1],
        4 => vec![2, 3],
        8 => vec![4, 5],
        12 => vec![6, 7],
        16 => vec![8, 9],
        20 => vec![10, 11],
        28..=31 => vec![core_id as usize - 16],
        _ => Vec::new(),
    }
}

fn temp_color(t: f64) -> Color {
    if t >= TEMP_CRIT {
        Color::Red
    } else if t >= TEMP_WARN {
        Color::Yellow
    } else {
        Color::Green
    }
}

fn vcore_color(v: f64) -> Color {
    if v >= VCORE_LIMIT {
        Color::Red
    } else if v >= VCORE_LIMIT - VCORE_MARGIN {
        Color::Yellow
    } else {
        Color::Green
    }
}

/// One htop-style meter line: `Label [|||||  1.152 V  ]`.
/// `ratio` is fill fraction (None → empty, sensor missing). `marker_at`
/// (fraction) draws a bold `!` at that bar position — the vCore limit.
fn meter_line(
    label: &str,
    value_text: &str,
    ratio: Option<f64>,
    color: Color,
    width: u16,
    marker_at: Option<f64>,
) -> Line<'static> {
    let cap_w = 12usize;
    let inner = width.saturating_sub(cap_w as u16 + 2) as usize; // room for [ ]
    let mut spans = vec![Span::styled(
        format!("{:<w$}", label, w = cap_w),
        Style::default().fg(Color::Cyan),
    )];

    let ratio = ratio.unwrap_or(0.0).clamp(0.0, 1.0);
    let fill = (ratio * inner as f64).round() as usize;
    let marker_pos = marker_at
        .map(|m| ((m.clamp(0.0, 1.0)) * inner as f64).round() as usize)
        .map(|p| p.min(inner.saturating_sub(1)));

    // Value text right-aligned inside the bar (htop pattern); bold default fg.
    let text: Vec<char> = if value_text.len() >= inner {
        value_text.chars().rev().take(inner).collect::<Vec<_>>()
            .into_iter().rev().collect()
    } else {
        let pad = inner - value_text.len();
        std::iter::repeat_n(' ', pad)
            .chain(value_text.chars())
            .collect()
    };

    spans.push(Span::raw("["));
    for (i, &tc) in text.iter().enumerate().take(inner) {
        // marker needs a blank cell underneath; never eat the value text
        let is_marker = Some(i) == marker_pos && tc == ' ';
        let bg_ch = if i < fill { '|' } else { ' ' };
        let (ch, style) = if is_marker {
            (
                '!',
                Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
            )
        } else if tc != ' ' {
            (tc, Style::default().add_modifier(Modifier::BOLD))
        } else if i < fill {
            (bg_ch, Style::default().fg(color))
        } else {
            (' ', Style::default())
        };
        spans.push(Span::styled(ch.to_string(), style));
    }
    spans.push(Span::raw("]"));
    Line::from(spans)
}

fn fmt_opt_watts(v: Option<f64>) -> String {
    match v {
        Some(w) => format!("{:.1} W", w),
        None => "unreadable (root)".to_string(),
    }
}

fn fmt_opt_temp(v: Option<f64>) -> String {
    match v {
        Some(t) => format!("{:.0} °C", t),
        None => "n/a".to_string(),
    }
}

pub fn draw(f: &mut Frame, app: &App) {
    let w = f.area().width;
    let mut lines: Vec<Line> = Vec::new();

    // ---- vCore hero ----
    let vc_color = match app.vcore {
        Some(v) => vcore_color(v),
        None => Color::DarkGray,
    };
    let hero_text = match (app.vcore, app.vcore_peak) {
        (Some(v), Some(p)) => format!("{:.3} V  peak {:.3}", v, p),
        (Some(v), None) => format!("{:.3} V", v),
        (None, _) => "n/a".to_string(),
    };
    lines.push(Line::from(Span::styled(
        "vCore",
        Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD),
    )));
    lines.push(meter_line(
        "",
        &hero_text,
        app.vcore.map(|v| v / VCORE_BAR_MAX),
        vc_color,
        w,
        Some(VCORE_LIMIT / VCORE_BAR_MAX),
    ));
    lines.push(Line::from(""));

    // ---- package ----
    lines.push(meter_line(
        "Pkg power",
        &fmt_opt_watts(app.package_watts),
        app.package_watts.map(|v| v / POWER_BAR_MAX),
        Color::Blue,
        w,
        None,
    ));
    lines.push(meter_line(
        "Pkg temp",
        &fmt_opt_temp(app.package_temp),
        app.package_temp.map(|t| t / TEMP_BAR_MAX),
        app.package_temp.map_or(Color::DarkGray, temp_color),
        w,
        None,
    ));
    lines.push(Line::from(""));

    // ---- cores: freq bar + temp number ----
    for (section, pred) in [("P-cores", false), ("E-cores", true)] {
        lines.push(Line::from(Span::styled(
            section,
            Style::default().fg(Color::Cyan),
        )));
        for core in app.cores.iter().filter(|c| c.e_core == pred) {
            let text = match (core.freq_khz, core.temp) {
                (Some(f), Some(t)) => format!(
                    "{:.2}GHz  {:.0}°C",
                    f as f64 / 1_000_000.0,
                    t
                ),
                (Some(f), None) => format!("{:.2}GHz", f as f64 / 1_000_000.0),
                (None, Some(t)) => format!("{:.0}°C", t),
                (None, None) => "n/a".to_string(),
            };
            // Bar spans the cpu's own [min_freq, max_freq] window so the
            // 800 MHz idle floor sits at 0% and turbo pegs 100%.
            let ratio = match (core.freq_khz, core.freq_max_khz) {
                (Some(f), max) if max > core.freq_min_khz => Some(
                    ((f as f64 - core.freq_min_khz as f64)
                        / (max - core.freq_min_khz) as f64)
                        .clamp(0.0, 1.0),
                ),
                _ => None,
            };
            let color = match core.temp {
                Some(t) => temp_color(t),
                None => Color::DarkGray,
            };
        lines.push(meter_line(&core.label, &text, ratio, color, w, None));
        }
        lines.push(Line::from(""));
    }

    // ---- footer ----
    lines.push(Line::from(vec![
        Span::styled("Max freq: ", Style::default().fg(Color::Cyan)),
        Span::raw(format!(
            "{:.2} GHz",
            app.cores
                .iter()
                .filter_map(|c| c.freq_khz)
                .max()
                .unwrap_or(0) as f64
                / 1_000_000.0
        )),
        Span::styled("    q: quit", Style::default().fg(Color::DarkGray)),
    ]));

    f.render_widget(ratatui::text::Text::from(lines), f.area());
}

pub fn run_tui() {
    let mut terminal = ratatui::init();
    let mut app = App::new();
    loop {
        app.poll();
        terminal.draw(|f| draw(f, &app)).expect("draw failed");
        if event::poll(Duration::from_millis(TICK_MS))
            .unwrap_or(false)
        {
            if let Ok(Event::Key(key)) = event::read() {
                if key.kind == crossterm::event::KeyEventKind::Press
                    && matches!(key.code, KeyCode::Char('q') | KeyCode::Esc)
                {
                    break;
                }
            }
        }
    }
    ratatui::restore();
}