Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
# CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

## Project

`ztop` — a cross-platform (Linux + Windows) terminal system monitor written in Rust 2024. CPU, memory, disk, network, GPU, and processes in a single ratatui screen.

## Commands

```
cargo build --release # default: nvidia feature on
cargo build --release --no-default-features # drop nvml-wrapper (NVIDIA live stats)
cargo run # dev run
cargo test # tests
cargo fmt --all -- --check # CI fmt gate
cargo clippy --all-targets -- -D warnings # CI clippy gate (warnings = error)
```

Linux build needs system deps (for `wgpu` adapter enumeration): `pkg-config libxcb-render0-dev libxcb-shape0-dev libxcb-xfixes0-dev libxkbcommon-dev libudev-dev`. The CI workflow in `.github/workflows/linux.yml` is the canonical list.

There are no separate per-test commands beyond `cargo test`; the codebase has no integration test files and very few unit tests.

## Architecture

**Single-threaded tick loop, one mutable `App`.** `main.rs` sets up the terminal (alternate screen + raw mode + mouse capture) and runs a 1 s poll loop: read events → maybe exit, otherwise on tick boundary call `app.tick(delta)` then `ui::draw`. There is no async runtime.

**State lives in `App` (`src/app.rs`).** It owns `sysinfo::System`, `Users`, `Networks`, `DiskState`, the `GpuShared` (`Arc<Mutex<Vec<GpuRow>>>`), input mode, sort/filter/category, and the *derived* `visible: Vec<(depth, index)>` that the process table renders. Any input that changes filtering, sorting, category, or the tree toggle must call `rebuild_visible()` — the table never re-sorts during draw.

**Data sources fan out under `src/system/`** — one module per concern (`process`, `disk`, `network`, `gpu`, `boot`). They expose plain row structs (`ProcRow`, `DiskRow`, `IfaceStats`, `GpuRow`); the UI layer never touches `sysinfo` directly.

**GPU is the one async-ish piece.** `gpu::init()` enumerates adapters once via `wgpu::Instance::enumerate_adapters` (works on Vulkan/DX12/Metal — no vendor SDK needed), then, when the `nvidia` feature is on, spawns a background thread that polls NVML and writes into the shared `Arc<Mutex<Vec<GpuRow>>>`. The UI reads under that mutex each draw. Anything we can't measure stays `None` and renders as `N/A`.

**`ProcessRefreshKind` is deliberately narrow** (`with_cpu`, `with_memory`, `with_user(OnlyIfNotSet)`). Don't add cmdline/env/disk-IO refreshes without measuring — opening process handles is the dominant per-tick cost on Windows. CPU usage needs two samples to be meaningful; `App::new` does a 200 ms warm-up sleep before the first frame for that reason.

**Input is modal (`src/event.rs`).** `App::mode` is one of `Normal | Search | Filter | ConfirmKill | SignalPrompt | RenicePrompt`. `handle_prompt` routes keystrokes to whichever prompt is active; the filter prompt updates `App.filter` (and rebuilds visible) live on every keystroke, while search only commits on `Enter`. Process actions (`kill_pid` / `signal_pid` / signals 1/2/3/6/9/14/15/17/19) go through `sysinfo::Process::kill` / `kill_with`; renice is currently unimplemented and intentionally flashes an N/A message.

**UI layout is height-adaptive (`src/ui/mod.rs::plan_layout`).** Header + process + footer are always rendered. Overview is added when terminal height ≥ 18; disk+gpu row when ≥ 30. Sub-panel heights also scale with available height. When changing layouts, update `plan_layout` rather than per-panel constraints.

**Mouse-row mapping uses `App.proc_rect`** — the process panel caches its last `Rect` during draw so `event::handle_mouse` can translate a click row back into a visible index. Don't remove that cache without rewiring the click path.

## Conventions

- `#[cfg(feature = "nvidia")]` gates all NVML use. Anything outside that cfg must compile and run with `--no-default-features`.
- `#[cfg(windows)]` / `#[cfg(not(windows))]` for platform branches. `system/process.rs` has Windows-specific service account names; keep new Windows-specific names there.
- CI clippy is `-D warnings`. Fix lints; don't `#[allow]` to suppress them unless there's a real reason.
- Process CPU% follows htop convention (100% = one core), not Task Manager's normalized %. Don't "fix" this — it's intentional and documented in README.
89 changes: 65 additions & 24 deletions src/ui/overview.rs
Original file line number Diff line number Diff line change
Expand Up @@ -40,37 +40,78 @@ pub fn draw(f: &mut Frame, app: &App, area: Rect) {

fn draw_cpu(f: &mut Frame, app: &App, area: Rect) {
let cpus = app.sys.cpus();
let label_w: u16 = 8; // " all " / " core 12"
// 2 border + label + 1 space + 2 brackets + 1 space + 7 percent
let bar_w = area
.width
.saturating_sub(2 + label_w + 1 + 2 + 1 + 7)
.max(4);

// How many cores can we fit? Use the remaining vertical space.
let n_meters = cpus.len() + 1; // +1 for the aggregate "all" bar
let max_rows = area.height.saturating_sub(2) as usize;
let inner_w = area.width.saturating_sub(2);

// Choose column count htop-style: only multi-column when (a) the panel
// is too short to fit every core vertically AND (b) the panel is wide
// enough that each column can still render a useful bar.
const MIN_COL_W: u16 = 17; // 1 + 4 (label) + 1 + 2 (brackets) + 2 (bar) + 1 + 6 (percent)
let max_cols_by_width = (inner_w / MIN_COL_W).max(1) as usize;
let needed_cols = if max_rows == 0 {
1
} else {
n_meters.div_ceil(max_rows)
};
let cols = needed_cols.min(max_cols_by_width).max(1);

let per_col_w = inner_w / cols as u16;
// Long label "core 12" when there's room; compact "c12" otherwise.
let label_w: u16 = if per_col_w >= 23 { 8 } else { 4 };
let bar_w = per_col_w.saturating_sub(1 + label_w + 1 + 2 + 1 + 6).max(2);

let global = app.sys.global_cpu_info().cpu_usage();

let mut lines: Vec<Line> = Vec::with_capacity(max_rows);
lines.push(meter("all", global, bar_w, true));
// Distribute meters column-major: fill column 0 top-to-bottom first,
// then column 1, etc. Matches the reading order people expect from htop.
let rows_per_col = n_meters.div_ceil(cols);
let displayable_rows = rows_per_col.min(max_rows);

for (i, c) in cpus.iter().enumerate() {
if lines.len() >= max_rows {
break;
let mut grid: Vec<Vec<Line<'static>>> = vec![Vec::with_capacity(displayable_rows); cols];
let mut hidden = 0usize;
for i in 0..n_meters {
let col = i / rows_per_col;
let row = i % rows_per_col;
if row >= displayable_rows {
hidden += 1;
continue;
}
let label = format!("core {:>2}", i);
lines.push(meter(&label, c.cpu_usage(), bar_w, false));
let (text, pct, bold) = if i == 0 {
("all".to_string(), global, true)
} else {
let n = i - 1;
let text = if label_w == 8 {
format!("core {:>2}", n)
} else {
format!("c{:>2}", n)
};
(text, cpus[n].cpu_usage(), false)
};
let padded = format!("{:<w$}", text, w = label_w as usize);
grid[col].push(meter(&padded, pct, bar_w, bold));
}

if cpus.len() + 1 > max_rows && !lines.is_empty() {
// Note that some cores are hidden when the panel is short.
let hidden = cpus.len() + 1 - max_rows;
if let Some(last) = lines.last_mut() {
last.spans.push(Span::styled(
format!(" (+{} hidden)", hidden),
Style::default().fg(Color::DarkGray),
));
// Stitch columns row-by-row. The leading space inside each meter doubles
// as the gutter between columns, so no extra spacing is needed here.
let mut lines: Vec<Line<'static>> = Vec::with_capacity(displayable_rows);
for r in 0..displayable_rows {
let mut spans: Vec<Span<'static>> = Vec::new();
for col_lines in &grid {
if let Some(l) = col_lines.get(r) {
spans.extend(l.spans.iter().cloned());
}
}
lines.push(Line::from(spans));
}

if hidden > 0
&& let Some(last) = lines.last_mut()
{
last.spans.push(Span::styled(
format!(" (+{} hidden)", hidden),
Style::default().fg(Color::DarkGray),
));
}

let p = Paragraph::new(lines).block(
Expand All @@ -94,7 +135,7 @@ fn meter(label: &str, pct: f32, width: u16, bold_label: bool) -> Line<'static> {
};
Line::from(vec![
Span::raw(" "),
Span::styled(format!("{:<7}", label), label_style),
Span::styled(label.to_string(), label_style),
Span::raw(" "),
Span::styled("▕", Style::default().fg(Color::DarkGray)),
Span::styled(fill, Style::default().fg(fill_color)),
Expand Down
Loading