mod app;
mod backend;
mod cli;
mod config;
mod keys;
mod splash;
mod theme;
mod ui;
use anyhow::Result;
use app::{App, Focus, InputMode};
use clap::Parser;
use cli::Cli;
use config::GpurConfig;
use crossterm::event::KeyCode;
use crossterm::event::{
self, DisableMouseCapture, EnableMouseCapture, Event, KeyEventKind, MouseButton, MouseEventKind,
};
use keys::Action;
use ratatui::layout::Position;
use std::io::stdout;
use std::time::{Duration, Instant};
fn main() -> Result<()> {
let cli = Cli::parse();
let cfg: GpurConfig = match &cli.config {
Some(path) => hjkl_config::load_from(path)?,
None => hjkl_config::load()?.0,
};
let tick_ms = cli.tick_ms.unwrap_or(cfg.tick_ms).max(50);
let theme_path = cli.theme.clone().or(cfg.theme.clone());
let theme = theme::load(theme_path.as_deref())?;
let backend = backend::detect(cli.mock)?;
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
)
})?,
};
let log = match &cli.log {
Some(path) => Some(std::io::BufWriter::new(
std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(path)?,
)),
None => None,
};
let mut app = App::new(
backend,
theme,
tick_ms,
cfg.history_len,
cli.no_splash,
graph_style,
log,
);
app.poll();
if cli.once || cli.json {
return snapshot(&mut app, cli.json, tick_ms);
}
let mut terminal = ratatui::init();
hjkl_kitty::enable(&mut stdout())?;
crossterm::execute!(stdout(), EnableMouseCapture)?;
{
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);
restore_extras();
ratatui::restore();
result
}
fn restore_extras() {
let _ = crossterm::execute!(stdout(), DisableMouseCapture);
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(not(unix))]
fn install_signal_teardown() {
}
fn snapshot(app: &mut App, json: bool, tick_ms: u64) -> Result<()> {
std::thread::sleep(Duration::from_millis(tick_ms.clamp(100, 1000)));
app.poll();
if json {
let out = serde_json::json!({
"backend": app.backend.name(),
"gpus": app.gpus,
"processes": app.procs,
});
println!("{}", serde_json::to_string_pretty(&out)?);
return Ok(());
}
for (i, g) in app.gpus.iter().enumerate() {
let mut line = format!(
"{i} {} util {:>3.0}% vram {}/{}MiB",
g.name,
g.utilization_pct,
g.vram_used_bytes / 1024 / 1024,
g.vram_total_bytes / 1024 / 1024,
);
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}");
}
for p in &app.procs {
println!(
" pid {:>7} gpu {} {:>4} {:>5}MiB {}",
p.pid,
p.gpu_index,
p.gpu_util_pct
.map(|u| format!("{u:.0}%"))
.unwrap_or_else(|| "-".into()),
p.gpu_mem_bytes / 1024 / 1024,
p.command,
);
}
Ok(())
}
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 interval = if app.splash_active() {
Duration::from_millis(60)
} else {
Duration::from_millis(app.tick_ms)
};
let timeout = interval.saturating_sub(last_poll.elapsed()).min(interval);
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 => match key.code {
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 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;
}
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;
if m.row >= first_row_y {
let clicked = app.proc_scroll + (m.row - first_row_y) as usize;
if clicked < app.procs.len() {
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();
}
}
}