mod app;
mod backend;
mod cli;
mod config;
mod keys;
mod splash;
mod theme;
mod ui;
use anyhow::{Context, Result};
use app::{App, Focus, InputMode};
use clap::Parser;
use cli::Cli;
use config::GpurConfig;
use crossterm::event::{
self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEventKind, KeyModifiers,
MouseButton, MouseEventKind,
};
use keys::Action;
use ratatui::layout::Position;
use std::io::{IsTerminal, stdout};
use std::time::{Duration, Instant};
fn main() -> Result<()> {
let cli = Cli::parse();
if let Some(shell) = cli.completions {
use clap::CommandFactory;
shell.generate(&mut Cli::command());
return Ok(());
}
if cli.man {
use clap::CommandFactory;
clap_mangen::Man::new(Cli::command()).render(&mut stdout())?;
return Ok(());
}
let cfg: GpurConfig = match &cli.config {
Some(path) => hjkl_config::load_from(path)?,
None => hjkl_config::load()?.0,
};
let headless = cli.once || cli.json;
if headless {
backend::sweep_on_poll_thread();
}
let state = app::load_state().filter(|_| !headless);
let saved_tick = state.as_ref().and_then(app::UiState::sticky_tick_ms);
let tick_ms = app::clamp_tick_ms(cli.tick_ms.or(saved_tick).unwrap_or(cfg.tick_ms));
let theme_path = cli.theme.clone().or(cfg.theme.clone());
let mut theme = theme::load(theme_path.as_deref(), theme::detect_color_mode())?;
if cfg.transparent {
theme.bg = ratatui::style::Color::Reset;
}
let source = backend::BackendSource::from_cli(cli.mock, cli.replay.clone());
let backend = source.detect()?;
let graph_style = match cli.graphs {
Some(s) => s,
None => app::GraphStyle::from_config(&cfg.graphs).ok_or_else(|| {
anyhow::anyhow!(
"unknown graphs value {:?} in config (expected braille, block, or ascii)",
cfg.graphs
)
})?,
};
if !headless && !stdout().is_terminal() {
anyhow::bail!("stdout is not a terminal — use --once or --json for non-interactive output");
}
let log = match &cli.log {
Some(path) => Some(std::io::BufWriter::new(open_log(path)?)),
None => None,
};
let mut app = App::new(
backend,
theme,
app::AppOptions {
tick_ms,
tick_explicit: cli.tick_ms.is_none()
&& state.as_ref().is_some_and(app::UiState::tick_chosen_by_key),
history_len: cfg.history_len,
no_splash: cli.no_splash,
graph_style,
source,
log,
},
);
if headless {
return snapshot(&mut app, cli.json, tick_ms);
}
if let Some(s) = &state {
app.restore_state(s);
}
app.poll();
let mut terminal = ratatui::try_init().context("initializing the terminal")?;
#[cfg(not(windows))]
if let Err(e) = hjkl_kitty::enable(&mut stdout()) {
return Err(fail_setup(e, "enabling kitty keyboard protocol"));
}
if let Err(e) = crossterm::execute!(stdout(), EnableMouseCapture) {
return Err(fail_setup(e, "enabling mouse capture"));
}
{
let prev = std::panic::take_hook();
std::panic::set_hook(Box::new(move |info| {
restore_extras();
prev(info);
}));
}
install_signal_teardown();
let result = run(&mut terminal, &mut app);
app.save_state();
restore_extras();
ratatui::restore();
result
}
fn fail_setup(e: impl Into<anyhow::Error>, what: &'static str) -> anyhow::Error {
restore_extras();
ratatui::restore();
e.into().context(what)
}
fn open_log(path: &std::path::Path) -> std::io::Result<std::fs::File> {
let mut opts = std::fs::OpenOptions::new();
opts.create(true).append(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
opts.mode(0o600);
}
opts.open(path)
}
fn restore_extras() {
let _ = crossterm::execute!(stdout(), DisableMouseCapture);
#[cfg(not(windows))]
let _ = hjkl_kitty::disable(&mut stdout());
}
#[cfg(unix)]
fn install_signal_teardown() {
use signal_hook::consts::signal::{SIGHUP, SIGINT, SIGTERM};
use signal_hook::iterator::Signals;
let Ok(mut signals) = Signals::new([SIGTERM, SIGHUP, SIGINT]) else {
return;
};
std::thread::spawn(move || {
if let Some(sig) = signals.forever().next() {
restore_extras();
ratatui::restore();
std::process::exit(128 + sig);
}
});
}
#[cfg(windows)]
fn install_signal_teardown() {
use windows::Win32::System::Console::SetConsoleCtrlHandler;
unsafe extern "system" fn handler(_ctrl_type: u32) -> windows::core::BOOL {
restore_extras();
ratatui::restore();
std::process::exit(130);
}
unsafe {
let _ = SetConsoleCtrlHandler(Some(handler), true);
}
}
#[cfg(not(any(unix, windows)))]
fn install_signal_teardown() {}
fn strip_controls(s: &str) -> String {
s.chars().filter(|c| !c.is_control()).collect()
}
fn snapshot(app: &mut App, json: bool, tick_ms: u64) -> Result<()> {
app.poll_priming();
std::thread::sleep(Duration::from_millis(tick_ms.clamp(100, 1000)));
app.poll();
if let Some(e) = &app.poll_error {
anyhow::bail!("{e}");
}
if json {
println!("{}", serde_json::to_string_pretty(&app.record())?);
return Ok(());
}
let mib = |b: Option<u64>| b.map(ui::mib).unwrap_or_else(|| "n/a".to_string());
for (i, g) in app.gpus.iter().enumerate() {
let mem = g.mem_primary();
let mut line = format!(
"{i} {} util {} mem {}/{}{}",
strip_controls(&g.name),
g.utilization_pct
.map(|u| format!("{u:>3.0}%"))
.unwrap_or_else(|| "n/a".to_string()),
mib(mem.used),
mib(mem.total),
if mem.shared { " shared" } else { "" },
);
if let Some(second) = g.mem_secondary() {
let label = if second.shared { "shared" } else { "gtt" };
line.push_str(&format!(
" {label} {}/{}",
mib(second.used),
mib(second.total)
));
}
if let Some(t) = g.temperature_c {
line.push_str(&format!(" {t:.0}°C"));
}
if let Some(w) = g.power_w {
line.push_str(&format!(" {w:.0}W"));
}
println!("{line}");
}
let proc_mib = |b: Option<u64>| b.map(ui::mib).unwrap_or_else(|| "-".to_string());
for p in &app.all_procs {
println!(
" pid {:>7} gpu {} {:>4} {:>8} {}",
p.pid,
p.gpu_index,
p.gpu_util_pct
.map(|u| format!("{u:.0}%"))
.unwrap_or_else(|| "-".into()),
proc_mib(p.gpu_mem_bytes),
strip_controls(&p.command),
);
}
Ok(())
}
const SPLASH_FRAME_MS: u64 = 60;
fn event_timeout(
splash: bool,
tick_ms: u64,
since_draw: Duration,
since_poll: Duration,
) -> Duration {
let frame_interval = if splash {
Duration::from_millis(SPLASH_FRAME_MS)
} else {
Duration::from_millis(tick_ms)
};
let tick = Duration::from_millis(tick_ms);
frame_interval
.saturating_sub(since_draw)
.min(tick.saturating_sub(since_poll))
}
fn run(terminal: &mut ratatui::DefaultTerminal, app: &mut App) -> Result<()> {
let mut keymap = keys::default_keymap();
let mut last_poll = Instant::now();
loop {
terminal.draw(|frame| ui::draw(frame, app))?;
let last_draw = Instant::now();
let timeout = event_timeout(
app.splash_active(),
app.tick_ms,
last_draw.elapsed(),
last_poll.elapsed(),
);
if event::poll(timeout)? {
match event::read()? {
Event::Key(key) if key.kind != KeyEventKind::Release => {
if app.splash_active() {
app.splash_skipped = true;
continue;
}
match app.input_mode {
InputMode::Filter => {
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
match key.code {
KeyCode::Char('c') if ctrl => return Ok(()),
KeyCode::Char('u') if ctrl => app.filter_input.clear(),
KeyCode::Char('w') if ctrl => app.filter_delete_word(),
KeyCode::Char(_) if ctrl => {}
KeyCode::Enter => app.commit_filter(),
KeyCode::Esc => app.input_mode = InputMode::Normal,
KeyCode::Backspace => {
app.filter_input.pop();
}
KeyCode::Char(c) => app.filter_input.push(c),
_ => {}
}
}
InputMode::Confirm => {
if matches!(key.code, KeyCode::Char('y') | KeyCode::Char('Y')) {
app.confirm_kill();
} else {
app.pending_kill = None;
app.input_mode = InputMode::Normal;
}
}
InputMode::Normal => {
if app.show_help {
app.show_help = false;
continue;
}
if let Some(action) = keys::resolve(&mut keymap, key)
&& app.apply(action)
{
return Ok(());
}
}
}
}
Event::Mouse(m) => {
if app.splash_active() {
app.splash_skipped = true;
continue;
}
if app.show_help || app.input_mode != InputMode::Normal {
continue;
}
let pos = Position::new(m.column, m.row);
let in_procs = app.proc_rect.contains(pos);
let in_gpus = app.gpus_rect.contains(pos);
let action = match m.kind {
MouseEventKind::ScrollDown if in_procs => {
app.focus = Focus::Procs;
Some(Action::ProcScrollDown)
}
MouseEventKind::ScrollUp if in_procs => {
app.focus = Focus::Procs;
Some(Action::ProcScrollUp)
}
MouseEventKind::ScrollDown if in_gpus => {
app.focus = Focus::Gpus;
Some(Action::NextGpu)
}
MouseEventKind::ScrollUp if in_gpus => {
app.focus = Focus::Gpus;
Some(Action::PrevGpu)
}
MouseEventKind::Down(MouseButton::Left) if in_procs => {
app.focus = Focus::Procs;
let first_row_y = app.proc_rect.y + 2;
let border_y = app.proc_rect.bottom().saturating_sub(1);
if m.row >= first_row_y && m.row < border_y {
let clicked = app.proc_scroll + (m.row - first_row_y) as usize;
if clicked < app.proc_scroll + app.proc_visible {
app.proc_sel = clicked;
}
}
None
}
MouseEventKind::Down(MouseButton::Left) if in_gpus => {
app.focus = Focus::Gpus;
if let Some(&(_, i)) =
app.card_rects.iter().find(|(rect, _)| rect.contains(pos))
{
app.selected = i;
}
None
}
_ => None,
};
if let Some(action) = action {
app.apply(action);
}
}
_ => {}
}
}
if last_poll.elapsed() >= Duration::from_millis(app.tick_ms) {
app.poll();
last_poll = Instant::now();
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn splash_frames_wait_a_frame_not_zero() {
let t = event_timeout(
true,
1000,
Duration::from_millis(40),
Duration::from_millis(100),
);
assert_eq!(t, Duration::from_millis(SPLASH_FRAME_MS - 40));
}
#[test]
fn a_due_poll_wins_over_a_later_frame() {
let t = event_timeout(true, 1000, Duration::ZERO, Duration::from_millis(990));
assert_eq!(t, Duration::from_millis(10));
}
#[test]
fn steady_state_paces_on_the_tick() {
assert_eq!(
event_timeout(false, 500, Duration::ZERO, Duration::ZERO),
Duration::from_millis(500)
);
assert_eq!(
event_timeout(false, 500, Duration::ZERO, Duration::from_millis(700)),
Duration::ZERO
);
}
}