whirr 0.3.0

A whirring macOS system dashboard for your terminal
pub mod burst;
pub mod cpu;
pub mod font;
pub mod gauge;
pub mod header;
pub mod memory;
pub mod network;
pub mod ports;
pub mod power;
pub mod processes;
pub mod screen;
pub mod scroll;
pub mod sessions;
pub mod spark;
pub mod temp;
pub mod text;
pub mod theme;

use ratatui::prelude::*;
use ratatui::widgets::{Block, Paragraph};

use crate::app::{App, Focus, MAX_VISIBLE_PROCS};
use screen::{Body, Gauge, Screen, Tier};

/// Draw one frame.
///
/// All of the "does this fit" reasoning lives in `screen::Screen::resolve`,
/// which turns the terminal size into a value; this function only places what
/// that value says to place. The vertical split is the same at every tier —
/// header, gauges, body, and a bare footer row taken off the bottom first (it
/// has no block, so it can't shift where the header and gauges land).
pub fn draw(f: &mut Frame, app: &App) {
    let area = f.area();
    // Paint the whole frame with the near-black base first, before anything
    // else renders, so every widget composites on top of it. A borderless
    // Block fills its full area's background via Buffer::set_style, which
    // only patches the bg channel — later fg-only styles (most widgets here)
    // leave this bg untouched.
    f.render_widget(Block::default().style(Style::default().bg(theme::BASE)), area);

    let screen = Screen::resolve(area);
    let split = Layout::vertical([Constraint::Min(0), Constraint::Length(1)]).split(area);
    render_footer(f, split[1], app);

    let chunks = Layout::vertical([
        Constraint::Length(screen.tier.header_rows()),
        Constraint::Length(screen.tier.gauge_rows()),
        Constraint::Min(6),
    ])
    .split(split[0]);

    header::render(f, chunks[0], app);
    render_gauges(f, chunks[1], app, &screen);
    render_body(f, chunks[2], app, &screen);
}

/// The gauge band. `Tier::Grid` stacks the four cards 2x2 — `Screen` only
/// produces that tier when all four are present, so `zip` can never leave a
/// cell unpainted.
fn render_gauges(f: &mut Frame, area: Rect, app: &App, screen: &Screen) {
    let half = |r: Rect| {
        Layout::horizontal([Constraint::Ratio(1, 2), Constraint::Ratio(1, 2)]).split(r).to_vec()
    };
    let cells: Vec<Rect> = match screen.tier {
        Tier::Grid => {
            let bands =
                Layout::vertical([Constraint::Ratio(1, 2), Constraint::Ratio(1, 2)]).split(area);
            [half(bands[0]), half(bands[1])].concat()
        }
        _ => {
            let n = screen.gauges.len() as u32;
            Layout::horizontal(vec![Constraint::Ratio(1, n); n as usize]).split(area).to_vec()
        }
    };
    for (gauge, cell) in screen.gauges.iter().zip(cells) {
        match gauge {
            Gauge::Cpu => cpu::render(f, cell, app),
            Gauge::Temp => temp::render(f, cell, app),
            Gauge::Power => power::render(f, cell, app),
            Gauge::Memory => memory::render(f, cell, app),
        }
    }
}

/// Everything below the gauge band. The network card takes a column beside the
/// left content in every shape that has it, so that split happens once here
/// rather than inside each arm.
fn render_body(f: &mut Frame, area: Rect, app: &App, screen: &Screen) {
    match screen.body {
        Body::ThreeCards => {
            // Row A holds processes (and network); row B the three cards. Row B
            // gets up to 10 rows (2 borders + 8 content), with processes floored
            // at 3 — at 120x30 that floor leaves processes a single content row,
            // prioritising card height.
            let rows = Layout::vertical([Constraint::Min(3), Constraint::Max(10)]).split(area);
            let procs = beside_network(f, rows[0], app, screen);
            processes::render(f, procs, app);
            let cards = Layout::horizontal([Constraint::Ratio(1, 3); 3]).split(rows[1]);
            ports::render(f, cards[0], app, ports::Card::Localhost);
            sessions::render(f, cards[1], app);
            ports::render(f, cards[2], app, ports::Card::Others);
        }
        Body::ProcessesOverPorts => {
            let left = beside_network(f, area, app, screen);
            // The process table claims up to its cap (10 rows + footer +
            // borders) and ports gets the rest, but never below its floor: at
            // tight heights the table shrinks while ports keeps its minimum,
            // and past both the table takes its full cap and ports grows into
            // the slack. This can only ever move space between these two — the
            // header and gauge bands were resolved by `draw`'s split above.
            let rows = Layout::vertical([
                Constraint::Max(MAX_VISIBLE_PROCS as u16 + 3),
                Constraint::Min(4),
            ])
            .split(left);
            processes::render(f, rows[0], app);
            ports::render(f, rows[1], app, ports::Card::Combined);
        }
        Body::ProcessesOnly => {
            let procs = beside_network(f, area, app, screen);
            processes::render(f, procs, app);
        }
    }
}

/// Give the network card the right-hand ~2/5 of `area` when it fits, and
/// return what's left for the caller's own content.
fn beside_network(f: &mut Frame, area: Rect, app: &App, screen: &Screen) -> Rect {
    if !screen.network {
        return area;
    }
    let cols =
        Layout::horizontal([Constraint::Ratio(3, 5), Constraint::Ratio(2, 5)]).split(area);
    network::render(f, cols[1], app);
    cols[0]
}

/// Global keybind footer: one bare row, left-aligned, no border. Only shows
/// keys that actually do something for the current focus — `c/m sort` is a
/// process-table concept, `k kill` only applies to the two killable panels
/// (Processes, Localhost) — everything else is always available. Items sit
/// in fixed positions (select, sort?, kill?, tab, quit) so the line reads as
/// entries appearing/disappearing as focus changes, not reshuffling.
fn render_footer(f: &mut Frame, area: Rect, app: &App) {
    let show_sort = matches!(app.focus, Focus::Processes);
    let show_kill = matches!(app.focus, Focus::Processes | Focus::Localhost);
    let items: [Option<&str>; 5] = [
        Some("↑↓ select"),
        show_sort.then_some("c/m sort"),
        show_kill.then_some("k kill"),
        Some("tab focus"),
        Some("q quit"),
    ];
    let text = items.into_iter().flatten().collect::<Vec<_>>().join(" · ");
    f.render_widget(Paragraph::new(text).style(Style::default().fg(theme::DIM)), area);
}