From a568875f31a348f8a06f7755a13eb0a935d36b02 Mon Sep 17 00:00:00 2001
From: xscriptor <“preciado.oscar.osorio@gmail.com”>
Date: Wed, 3 Jun 2026 22:49:50 +0200
Subject: [PATCH 1/6] update app
---
.github/workflows/ci.yml | 32 +
CHANGELOG.md | 89 +++
Cargo.lock | 576 +++---------------
Cargo.toml | 27 +-
crates/xtop-cli/Cargo.toml | 11 +
crates/xtop-cli/src/main.rs | 90 +++
crates/xtop-core/Cargo.toml | 10 +
crates/xtop-core/src/application/history.rs | 142 +++++
crates/xtop-core/src/application/mod.rs | 2 +
crates/xtop-core/src/application/state.rs | 398 ++++++++++++
crates/xtop-core/src/domain/metrics.rs | 109 ++++
crates/xtop-core/src/domain/mod.rs | 3 +
crates/xtop-core/src/domain/system_info.rs | 18 +
crates/xtop-core/src/domain/theme.rs | 17 +
.../src/infrastructure/battery_provider.rs | 26 +
crates/xtop-core/src/infrastructure/config.rs | 62 ++
.../src/infrastructure/docker_provider.rs | 26 +
.../src/infrastructure/gpu_provider.rs | 26 +
crates/xtop-core/src/infrastructure/mod.rs | 6 +
.../src/infrastructure/sysinfo_provider.rs | 243 ++++++++
.../src/infrastructure/theme_loader.rs | 216 +++++++
crates/xtop-core/src/lib.rs | 3 +
crates/xtop-tui/Cargo.toml | 10 +
crates/xtop-tui/src/format.rs | 81 +++
crates/xtop-tui/src/lib.rs | 3 +
crates/xtop-tui/src/render/battery.rs | 59 ++
crates/xtop-tui/src/render/cpu.rs | 74 +++
crates/xtop-tui/src/render/disk_io.rs | 53 ++
crates/xtop-tui/src/render/gpu.rs | 55 ++
crates/xtop-tui/src/render/header.rs | 40 ++
crates/xtop-tui/src/render/help.rs | 51 ++
crates/xtop-tui/src/render/memory.rs | 129 ++++
crates/xtop-tui/src/render/mod.rs | 355 +++++++++++
crates/xtop-tui/src/render/network.rs | 42 ++
crates/xtop-tui/src/render/processes.rs | 87 +++
crates/xtop-tui/src/render/storage.rs | 53 ++
src/tui.rs => crates/xtop-tui/src/terminal.rs | 8 +-
src/app.rs | 183 ------
src/main.rs | 49 --
src/theme.rs | 112 ----
src/ui.rs | 371 -----------
41 files changed, 2713 insertions(+), 1234 deletions(-)
create mode 100644 .github/workflows/ci.yml
create mode 100644 CHANGELOG.md
create mode 100644 crates/xtop-cli/Cargo.toml
create mode 100644 crates/xtop-cli/src/main.rs
create mode 100644 crates/xtop-core/Cargo.toml
create mode 100644 crates/xtop-core/src/application/history.rs
create mode 100644 crates/xtop-core/src/application/mod.rs
create mode 100644 crates/xtop-core/src/application/state.rs
create mode 100644 crates/xtop-core/src/domain/metrics.rs
create mode 100644 crates/xtop-core/src/domain/mod.rs
create mode 100644 crates/xtop-core/src/domain/system_info.rs
create mode 100644 crates/xtop-core/src/domain/theme.rs
create mode 100644 crates/xtop-core/src/infrastructure/battery_provider.rs
create mode 100644 crates/xtop-core/src/infrastructure/config.rs
create mode 100644 crates/xtop-core/src/infrastructure/docker_provider.rs
create mode 100644 crates/xtop-core/src/infrastructure/gpu_provider.rs
create mode 100644 crates/xtop-core/src/infrastructure/mod.rs
create mode 100644 crates/xtop-core/src/infrastructure/sysinfo_provider.rs
create mode 100644 crates/xtop-core/src/infrastructure/theme_loader.rs
create mode 100644 crates/xtop-core/src/lib.rs
create mode 100644 crates/xtop-tui/Cargo.toml
create mode 100644 crates/xtop-tui/src/format.rs
create mode 100644 crates/xtop-tui/src/lib.rs
create mode 100644 crates/xtop-tui/src/render/battery.rs
create mode 100644 crates/xtop-tui/src/render/cpu.rs
create mode 100644 crates/xtop-tui/src/render/disk_io.rs
create mode 100644 crates/xtop-tui/src/render/gpu.rs
create mode 100644 crates/xtop-tui/src/render/header.rs
create mode 100644 crates/xtop-tui/src/render/help.rs
create mode 100644 crates/xtop-tui/src/render/memory.rs
create mode 100644 crates/xtop-tui/src/render/mod.rs
create mode 100644 crates/xtop-tui/src/render/network.rs
create mode 100644 crates/xtop-tui/src/render/processes.rs
create mode 100644 crates/xtop-tui/src/render/storage.rs
rename src/tui.rs => crates/xtop-tui/src/terminal.rs (86%)
delete mode 100644 src/app.rs
delete mode 100644 src/main.rs
delete mode 100644 src/theme.rs
delete mode 100644 src/ui.rs
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..9401cef
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,32 @@
+name: CI
+
+on:
+ push:
+ branches: [main]
+ pull_request:
+ branches: [main]
+
+env:
+ CARGO_TERM_COLOR: always
+
+jobs:
+ check:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: dtolnay/rust-toolchain@stable
+ with:
+ components: clippy, rustfmt
+ - uses: Swatinem/rust-cache@v2
+
+ - name: Check formatting
+ run: cargo fmt --check
+
+ - name: Clippy
+ run: cargo clippy --all -- -D warnings
+
+ - name: Build
+ run: cargo build --all
+
+ - name: Test
+ run: cargo test --all
diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 0000000..83df2a6
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,89 @@
+# Changelog
+
+## [0.2.0] - 2026-06-03
+
+### ♻️ Refactorización Total del Proyecto
+- Migrado a **workspace multi-crate**: `xtop-core`, `xtop-tui`, `xtop-cli`
+- Eliminado el monolito `src/` — ahora cada capa vive en su propio crate
+- Eliminadas **5 dependencias muertas**: `serde`, `serde_json`, `clap`, `chrono`, `tokio` (se redujo de ~84 a ~55 crates)
+- Eliminado **código muerto**: `InputMode`, `show_help`, `swap_history`, `process_table_state`, `graph_colors()`
+- Eliminados todos los `#[allow(dead_code)]`
+
+### 🏗️ Nueva Arquitectura Hexagonal
+- **Capa de Dominio** (`xtop-core/domain/`): modelos de datos puros + trait `SystemDataProvider`
+- **Capa de Aplicación** (`xtop-core/application/`): `AppState`, `MetricsHistory` (con `VecDeque`), `LayoutMode`, `EffectiveLayout`
+- **Capa de Infraestructura** (`xtop-core/infrastructure/`): `SysinfoProvider`, `theme_loader`, `config`, providers stub
+- **Capa de Presentación** (`xtop-tui/`): terminal, render widgets separados, format helpers
+- **Binary** (`xtop-cli/`): entry point con inyección de dependencias
+
+### 📐 Layout Responsive
+- `detect_effective_layout(width, height, mode)` adapta el layout automáticamente:
+ - **Dashboard** (>100×30): layout completo 2-columnas
+ - **Compact** (>80×24): más compacto
+ - **Vertical** (<80): todo apilado
+ - **Minimal** (<60 ancho o <18 alto): solo CPU + Mem + procesos
+ - **Too Small** (<40×8): mensaje de advertencia
+
+### 🆕 Nuevos Layouts (7 modos, ciclo con `l`)
+| Modo | Descripción |
+|------|-------------|
+| Dashboard | Default, 2-columnas con gráficos |
+| Vertical | Apilado, para terminales estrechas |
+| Horizontal | 4 columnas: CPU/Mem/Storage/Network |
+| CPU Focus | CPU grande + procesos |
+| Memory Focus | Memoria grande con chart + procesos |
+| Network Focus | Network + Disk I/O lado a lado + procesos |
+| Process Focus | Stats pequeños + procesos maximizados |
+
+### 🆕 Full Screen (`f` / `F`)
+- `f` activa/desactiva modo fullscreen
+- `F` cicla entre widgets (CPU → Memory → Storage → Network → Processes → Disk I/O → GPU → Battery → salir)
+- Widget seleccionado ocupa toda la terminal (menos header)
+
+### 🆕 Búsqueda de Procesos (`/`)
+- Filtrado en tiempo real por nombre de proceso
+- `Enter` confirma el filtro, `Esc` cancela, `Backspace` borra
+- Overlay centrado con indicador `/query_`
+
+### 🆕 Ayuda en Pantalla (`?`)
+- Muestra todas las keybindings disponibles
+- Cierra con `Esc` o `?` otra vez
+
+### 📊 Nuevas Métricas
+- **Disk I/O**: velocidad de lectura/escritura por disco (bytes/s) con widget dedicado
+- **Per-interface Network**: RX/TX y velocidad por interfaz de red
+- **GPU** (stub): `NoopGpuProvider` — preparado para NVIDIA/AMD
+- **Battery** (stub): `NoopBatteryProvider` — preparado para estado de batería
+- **Docker** (stub): `NoopDockerProvider` — preparado para contenedores
+
+### ⚠️ Alertas por Threshold
+- **CPU > 90%**: color cambia a rojo
+- **Memoria > 90%**: color rojo + icono ⚠ en el título
+- Thresholds configurables en `AlertThresholds` (cpu_high, mem_high, disk_high)
+
+### 🧹 Mejoras de Código
+- `Vec` + `remove(0)` reemplazado por `VecDeque` con `pop_front()` (O(1))
+- Helper `format_bytes()` elimina repetición de `1024.0 / 1024.0 / 1024.0`
+- Helper `format_uptime()` para formato legible de tiempo activo
+- `MetricsHistory::set_max_points()` para configurar puntos del histórico
+
+### ⚙️ Configuración Persistente
+- `~/.config/xtop/config.json`: guarda tema, layout, intervalo, history_points, alerts
+- `~/.config/xtop/themes/*.json`: temas personalizados por el usuario
+- Guardado automático al salir con `q`
+- Temas built-in (13) se fusionan con temas personalizados
+
+### 🧪 Tests
+- **39 tests unitarios** (de 0): layout detection, history, themes, format helpers, config
+- CI workflow `.github/workflows/ci.yml`: check, fmt, clippy, test, build
+
+### 🔑 Keybindings Completos
+| Tecla | Acción |
+|-------|--------|
+| `q` | Salir (guarda config) |
+| `?` | Ayuda |
+| `t` / `T` | Siguiente/anterior tema |
+| `l` | Siguiente layout |
+| `f` / `F` | Toggle fullscreen / ciclar widget |
+| `/` | Buscar procesos |
+| `Esc` | Cancelar búsqueda / cerrar ayuda |
diff --git a/Cargo.lock b/Cargo.lock
index f7c8009..8572700 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -8,94 +8,17 @@ version = "0.2.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
-[[package]]
-name = "android_system_properties"
-version = "0.1.5"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311"
-dependencies = [
- "libc",
-]
-
-[[package]]
-name = "anstream"
-version = "0.6.21"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a"
-dependencies = [
- "anstyle",
- "anstyle-parse",
- "anstyle-query",
- "anstyle-wincon",
- "colorchoice",
- "is_terminal_polyfill",
- "utf8parse",
-]
-
-[[package]]
-name = "anstyle"
-version = "1.0.13"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78"
-
-[[package]]
-name = "anstyle-parse"
-version = "0.2.7"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2"
-dependencies = [
- "utf8parse",
-]
-
-[[package]]
-name = "anstyle-query"
-version = "1.1.5"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
-dependencies = [
- "windows-sys 0.61.2",
-]
-
-[[package]]
-name = "anstyle-wincon"
-version = "3.0.11"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
-dependencies = [
- "anstyle",
- "once_cell_polyfill",
- "windows-sys 0.61.2",
-]
-
[[package]]
name = "anyhow"
-version = "1.0.100"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61"
-
-[[package]]
-name = "autocfg"
-version = "1.5.0"
+version = "1.0.102"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
+checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
[[package]]
name = "bitflags"
-version = "2.10.0"
+version = "2.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3"
-
-[[package]]
-name = "bumpalo"
-version = "3.19.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510"
-
-[[package]]
-name = "bytes"
-version = "1.11.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b35204fbdc0b3f4446b89fc1ac2cf84a8a68971995d0bf2e925ec7cd960f9cb3"
+checksum = "84d7ced0ae9557296835c32bf1b1e02b44c746701f898460fb000d7eaa84f00a"
[[package]]
name = "cassowary"
@@ -112,86 +35,17 @@ dependencies = [
"rustversion",
]
-[[package]]
-name = "cc"
-version = "1.2.51"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7a0aeaff4ff1a90589618835a598e545176939b97874f7abc7851caa0618f203"
-dependencies = [
- "find-msvc-tools",
- "shlex",
-]
-
[[package]]
name = "cfg-if"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
-[[package]]
-name = "chrono"
-version = "0.4.42"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "145052bdd345b87320e369255277e3fb5152762ad123a901ef5c262dd38fe8d2"
-dependencies = [
- "iana-time-zone",
- "js-sys",
- "num-traits",
- "wasm-bindgen",
- "windows-link",
-]
-
-[[package]]
-name = "clap"
-version = "4.5.54"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c6e6ff9dcd79cff5cd969a17a545d79e84ab086e444102a591e288a8aa3ce394"
-dependencies = [
- "clap_builder",
- "clap_derive",
-]
-
-[[package]]
-name = "clap_builder"
-version = "4.5.54"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "fa42cf4d2b7a41bc8f663a7cab4031ebafa1bf3875705bfaf8466dc60ab52c00"
-dependencies = [
- "anstream",
- "anstyle",
- "clap_lex",
- "strsim",
-]
-
-[[package]]
-name = "clap_derive"
-version = "4.5.49"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "2a0b5487afeab2deb2ff4e03a807ad1a03ac532ff5a2cee5d86884440c7f7671"
-dependencies = [
- "heck",
- "proc-macro2",
- "quote",
- "syn",
-]
-
-[[package]]
-name = "clap_lex"
-version = "0.7.6"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a1d728cc89cf3aee9ff92b05e62b19ee65a02b5702cff7d5a377e32c6ae29d8d"
-
-[[package]]
-name = "colorchoice"
-version = "1.0.4"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75"
-
[[package]]
name = "compact_str"
-version = "0.8.1"
+version = "0.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3b79c4069c6cad78e2e0cdfcbd26275770669fb39fd308a752dc110e83b9af32"
+checksum = "7fd622ebbb56a5b2ccb651b32b911cdeb2a9b4b11776b2473bf26a26a286244e"
dependencies = [
"castaway",
"cfg-if",
@@ -293,9 +147,9 @@ dependencies = [
[[package]]
name = "either"
-version = "1.15.0"
+version = "1.16.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
+checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e"
[[package]]
name = "equivalent"
@@ -313,12 +167,6 @@ dependencies = [
"windows-sys 0.61.2",
]
-[[package]]
-name = "find-msvc-tools"
-version = "0.1.6"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "645cbb3a84e60b7531617d5ae4e57f7e27308f6445f5abf653209ea76dec8dff"
-
[[package]]
name = "foldhash"
version = "0.1.5"
@@ -342,30 +190,6 @@ version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
-[[package]]
-name = "iana-time-zone"
-version = "0.1.64"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "33e57f83510bb73707521ebaffa789ec8caf86f9657cad665b092b581d40e9fb"
-dependencies = [
- "android_system_properties",
- "core-foundation-sys",
- "iana-time-zone-haiku",
- "js-sys",
- "log",
- "wasm-bindgen",
- "windows-core 0.62.2",
-]
-
-[[package]]
-name = "iana-time-zone-haiku"
-version = "0.1.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f"
-dependencies = [
- "cc",
-]
-
[[package]]
name = "ident_case"
version = "1.0.1"
@@ -383,9 +207,9 @@ dependencies = [
[[package]]
name = "instability"
-version = "0.3.11"
+version = "0.3.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "357b7205c6cd18dd2c86ed312d1e70add149aea98e7ef72b9fdf0270e555c11d"
+checksum = "5eb2d60ef19920a3a9193c3e371f726ec1dafc045dac788d0fb3704272458971"
dependencies = [
"darling",
"indoc",
@@ -394,12 +218,6 @@ dependencies = [
"syn",
]
-[[package]]
-name = "is_terminal_polyfill"
-version = "1.70.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
-
[[package]]
name = "itertools"
version = "0.13.0"
@@ -411,25 +229,15 @@ dependencies = [
[[package]]
name = "itoa"
-version = "1.0.17"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2"
-
-[[package]]
-name = "js-sys"
-version = "0.3.83"
+version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "464a3709c7f55f1f721e5389aa6ea4e3bc6aba669353300af094b29ffbdde1d8"
-dependencies = [
- "once_cell",
- "wasm-bindgen",
-]
+checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "libc"
-version = "0.2.179"
+version = "0.2.186"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c5a2d376baa530d1238d133232d15e239abad80d05838b4b59354e5268af431f"
+checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
[[package]]
name = "linux-raw-sys"
@@ -448,9 +256,9 @@ dependencies = [
[[package]]
name = "log"
-version = "0.4.29"
+version = "0.4.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
+checksum = "113b30b4cd05f7c06868fdb2854f66a7b9fece9a48425351cd532e810d74024f"
[[package]]
name = "lru"
@@ -463,15 +271,15 @@ dependencies = [
[[package]]
name = "memchr"
-version = "2.7.6"
+version = "2.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273"
+checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8"
[[package]]
name = "mio"
-version = "1.1.1"
+version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc"
+checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda"
dependencies = [
"libc",
"log",
@@ -481,34 +289,13 @@ dependencies = [
[[package]]
name = "ntapi"
-version = "0.4.2"
+version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c70f219e21142367c70c0b30c6a9e3a14d55b4d12a204d897fbec83a0363f081"
+checksum = "c3b335231dfd352ffb0f8017f3b6027a4917f7df785ea2143d8af2adc66980ae"
dependencies = [
"winapi",
]
-[[package]]
-name = "num-traits"
-version = "0.2.19"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
-dependencies = [
- "autocfg",
-]
-
-[[package]]
-name = "once_cell"
-version = "1.21.3"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
-
-[[package]]
-name = "once_cell_polyfill"
-version = "1.70.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
-
[[package]]
name = "parking_lot"
version = "0.12.5"
@@ -538,26 +325,20 @@ version = "1.0.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
-[[package]]
-name = "pin-project-lite"
-version = "0.2.16"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b"
-
[[package]]
name = "proc-macro2"
-version = "1.0.105"
+version = "1.0.106"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "535d180e0ecab6268a3e718bb9fd44db66bbbc256257165fc699dadf70d16fe7"
+checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
-version = "1.0.43"
+version = "1.0.45"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "dc74d9a594b72ae6656596548f56f667211f8a97b3d4c3d467150794690dc40a"
+checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"
dependencies = [
"proc-macro2",
]
@@ -585,9 +366,9 @@ dependencies = [
[[package]]
name = "rayon"
-version = "1.11.0"
+version = "1.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "368f01d005bf8fd9b1206fb6fa653e6c4a81ceb1466406b81792d87c5677a58f"
+checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d"
dependencies = [
"either",
"rayon-core",
@@ -633,9 +414,9 @@ checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
[[package]]
name = "ryu"
-version = "1.0.22"
+version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a50f4cf475b65d88e057964e0e9bb1f0aa9bbb2036dc65c64596b42932536984"
+checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
[[package]]
name = "scopeguard"
@@ -675,9 +456,9 @@ dependencies = [
[[package]]
name = "serde_json"
-version = "1.0.148"
+version = "1.0.150"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3084b546a1dd6289475996f182a22aba973866ea8e8b02c51d9f46b1336a22da"
+checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9"
dependencies = [
"itoa",
"memchr",
@@ -686,12 +467,6 @@ dependencies = [
"zmij",
]
-[[package]]
-name = "shlex"
-version = "1.3.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
-
[[package]]
name = "signal-hook"
version = "0.3.18"
@@ -729,16 +504,6 @@ version = "1.15.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
-[[package]]
-name = "socket2"
-version = "0.6.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "17129e116933cf371d018bb80ae557e889637989d8638274fb25622827b03881"
-dependencies = [
- "libc",
- "windows-sys 0.60.2",
-]
-
[[package]]
name = "static_assertions"
version = "1.1.0"
@@ -775,9 +540,9 @@ dependencies = [
[[package]]
name = "syn"
-version = "2.0.113"
+version = "2.0.117"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "678faa00651c9eb72dd2020cbdf275d92eccb2400d568e419efdd64838145cb4"
+checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99"
dependencies = [
"proc-macro2",
"quote",
@@ -798,45 +563,17 @@ dependencies = [
"windows",
]
-[[package]]
-name = "tokio"
-version = "1.49.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "72a2903cd7736441aac9df9d7688bd0ce48edccaadf181c3b90be801e81d3d86"
-dependencies = [
- "bytes",
- "libc",
- "mio",
- "parking_lot",
- "pin-project-lite",
- "signal-hook-registry",
- "socket2",
- "tokio-macros",
- "windows-sys 0.61.2",
-]
-
-[[package]]
-name = "tokio-macros"
-version = "2.6.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5"
-dependencies = [
- "proc-macro2",
- "quote",
- "syn",
-]
-
[[package]]
name = "unicode-ident"
-version = "1.0.22"
+version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5"
+checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "unicode-segmentation"
-version = "1.12.0"
+version = "1.13.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493"
+checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8"
[[package]]
name = "unicode-truncate"
@@ -861,63 +598,12 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd"
-[[package]]
-name = "utf8parse"
-version = "0.2.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
-
[[package]]
name = "wasi"
version = "0.11.1+wasi-snapshot-preview1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
-[[package]]
-name = "wasm-bindgen"
-version = "0.2.106"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0d759f433fa64a2d763d1340820e46e111a7a5ab75f993d1852d70b03dbb80fd"
-dependencies = [
- "cfg-if",
- "once_cell",
- "rustversion",
- "wasm-bindgen-macro",
- "wasm-bindgen-shared",
-]
-
-[[package]]
-name = "wasm-bindgen-macro"
-version = "0.2.106"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "48cb0d2638f8baedbc542ed444afc0644a29166f1595371af4fecf8ce1e7eeb3"
-dependencies = [
- "quote",
- "wasm-bindgen-macro-support",
-]
-
-[[package]]
-name = "wasm-bindgen-macro-support"
-version = "0.2.106"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "cefb59d5cd5f92d9dcf80e4683949f15ca4b511f4ac0a6e14d4e1ac60c6ecd40"
-dependencies = [
- "bumpalo",
- "proc-macro2",
- "quote",
- "syn",
- "wasm-bindgen-shared",
-]
-
-[[package]]
-name = "wasm-bindgen-shared"
-version = "0.2.106"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "cbc538057e648b67f72a982e708d485b2efa771e1ac05fec311f9f63e5800db4"
-dependencies = [
- "unicode-ident",
-]
-
[[package]]
name = "winapi"
version = "0.3.9"
@@ -946,8 +632,8 @@ version = "0.57.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "12342cb4d8e3b046f3d80effd474a7a02447231330ef77d71daa6fbc40681143"
dependencies = [
- "windows-core 0.57.0",
- "windows-targets 0.52.6",
+ "windows-core",
+ "windows-targets",
]
[[package]]
@@ -956,23 +642,10 @@ version = "0.57.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d2ed2439a290666cd67ecce2b0ffaad89c2a56b976b736e6ece670297897832d"
dependencies = [
- "windows-implement 0.57.0",
- "windows-interface 0.57.0",
- "windows-result 0.1.2",
- "windows-targets 0.52.6",
-]
-
-[[package]]
-name = "windows-core"
-version = "0.62.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
-dependencies = [
- "windows-implement 0.60.2",
- "windows-interface 0.59.3",
- "windows-link",
- "windows-result 0.4.1",
- "windows-strings",
+ "windows-implement",
+ "windows-interface",
+ "windows-result",
+ "windows-targets",
]
[[package]]
@@ -986,17 +659,6 @@ dependencies = [
"syn",
]
-[[package]]
-name = "windows-implement"
-version = "0.60.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf"
-dependencies = [
- "proc-macro2",
- "quote",
- "syn",
-]
-
[[package]]
name = "windows-interface"
version = "0.57.0"
@@ -1008,17 +670,6 @@ dependencies = [
"syn",
]
-[[package]]
-name = "windows-interface"
-version = "0.59.3"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358"
-dependencies = [
- "proc-macro2",
- "quote",
- "syn",
-]
-
[[package]]
name = "windows-link"
version = "0.2.1"
@@ -1031,25 +682,7 @@ version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e383302e8ec8515204254685643de10811af0ed97ea37210dc26fb0032647f8"
dependencies = [
- "windows-targets 0.52.6",
-]
-
-[[package]]
-name = "windows-result"
-version = "0.4.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5"
-dependencies = [
- "windows-link",
-]
-
-[[package]]
-name = "windows-strings"
-version = "0.5.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091"
-dependencies = [
- "windows-link",
+ "windows-targets",
]
[[package]]
@@ -1058,16 +691,7 @@ version = "0.59.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b"
dependencies = [
- "windows-targets 0.52.6",
-]
-
-[[package]]
-name = "windows-sys"
-version = "0.60.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb"
-dependencies = [
- "windows-targets 0.53.5",
+ "windows-targets",
]
[[package]]
@@ -1085,31 +709,14 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
dependencies = [
- "windows_aarch64_gnullvm 0.52.6",
- "windows_aarch64_msvc 0.52.6",
- "windows_i686_gnu 0.52.6",
- "windows_i686_gnullvm 0.52.6",
- "windows_i686_msvc 0.52.6",
- "windows_x86_64_gnu 0.52.6",
- "windows_x86_64_gnullvm 0.52.6",
- "windows_x86_64_msvc 0.52.6",
-]
-
-[[package]]
-name = "windows-targets"
-version = "0.53.5"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3"
-dependencies = [
- "windows-link",
- "windows_aarch64_gnullvm 0.53.1",
- "windows_aarch64_msvc 0.53.1",
- "windows_i686_gnu 0.53.1",
- "windows_i686_gnullvm 0.53.1",
- "windows_i686_msvc 0.53.1",
- "windows_x86_64_gnu 0.53.1",
- "windows_x86_64_gnullvm 0.53.1",
- "windows_x86_64_msvc 0.53.1",
+ "windows_aarch64_gnullvm",
+ "windows_aarch64_msvc",
+ "windows_i686_gnu",
+ "windows_i686_gnullvm",
+ "windows_i686_msvc",
+ "windows_x86_64_gnu",
+ "windows_x86_64_gnullvm",
+ "windows_x86_64_msvc",
]
[[package]]
@@ -1118,113 +725,78 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
-[[package]]
-name = "windows_aarch64_gnullvm"
-version = "0.53.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53"
-
[[package]]
name = "windows_aarch64_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
-[[package]]
-name = "windows_aarch64_msvc"
-version = "0.53.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006"
-
[[package]]
name = "windows_i686_gnu"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
-[[package]]
-name = "windows_i686_gnu"
-version = "0.53.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3"
-
[[package]]
name = "windows_i686_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
-[[package]]
-name = "windows_i686_gnullvm"
-version = "0.53.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c"
-
[[package]]
name = "windows_i686_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
-[[package]]
-name = "windows_i686_msvc"
-version = "0.53.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2"
-
[[package]]
name = "windows_x86_64_gnu"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
-[[package]]
-name = "windows_x86_64_gnu"
-version = "0.53.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499"
-
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
-[[package]]
-name = "windows_x86_64_gnullvm"
-version = "0.53.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1"
-
[[package]]
name = "windows_x86_64_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
-[[package]]
-name = "windows_x86_64_msvc"
-version = "0.53.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"
-
[[package]]
name = "xtop"
-version = "0.1.0"
+version = "0.2.0"
dependencies = [
"anyhow",
- "chrono",
- "clap",
"crossterm",
- "ratatui",
+ "xtop-core",
+ "xtop-tui",
+]
+
+[[package]]
+name = "xtop-core"
+version = "0.2.0"
+dependencies = [
"serde",
"serde_json",
"sysinfo",
- "tokio",
+]
+
+[[package]]
+name = "xtop-tui"
+version = "0.2.0"
+dependencies = [
+ "crossterm",
+ "ratatui",
+ "xtop-core",
]
[[package]]
name = "zmij"
-version = "1.0.12"
+version = "1.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "2fc5a66a20078bf1251bde995aa2fdcc4b800c70b5d92dd2c62abc5c60f679f8"
+checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
diff --git a/Cargo.toml b/Cargo.toml
index 5c10f1a..cf97497 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -1,15 +1,16 @@
-[package]
-name = "xtop"
-version = "0.1.0"
+[workspace]
+resolver = "2"
+members = ["crates/xtop-core", "crates/xtop-tui", "crates/xtop-cli"]
+
+[workspace.package]
+version = "0.2.0"
edition = "2021"
+license = "MIT"
-[dependencies]
-ratatui = "0.29.0"
-crossterm = "0.28.1"
-sysinfo = "0.33.0"
-serde = { version = "1.0", features = ["derive"] }
-serde_json = "1.0"
-anyhow = "1.0"
-clap = { version = "4.5", features = ["derive"] }
-tokio = { version = "1.0", features = ["full"] }
-chrono = "0.4"
+[workspace.dependencies]
+ratatui = "0.29"
+crossterm = "0.28"
+sysinfo = "0.33"
+anyhow = "1"
+serde = { version = "1", features = ["derive"] }
+serde_json = "1"
diff --git a/crates/xtop-cli/Cargo.toml b/crates/xtop-cli/Cargo.toml
new file mode 100644
index 0000000..aed403a
--- /dev/null
+++ b/crates/xtop-cli/Cargo.toml
@@ -0,0 +1,11 @@
+[package]
+name = "xtop"
+version.workspace = true
+edition.workspace = true
+license.workspace = true
+
+[dependencies]
+xtop-core = { path = "../xtop-core" }
+xtop-tui = { path = "../xtop-tui" }
+crossterm.workspace = true
+anyhow.workspace = true
diff --git a/crates/xtop-cli/src/main.rs b/crates/xtop-cli/src/main.rs
new file mode 100644
index 0000000..647a6cb
--- /dev/null
+++ b/crates/xtop-cli/src/main.rs
@@ -0,0 +1,90 @@
+use crossterm::event::{self, Event, KeyCode};
+use std::time::{Duration, Instant};
+use xtop_core::application::state::{AppState, Config, InputMode};
+use xtop_core::infrastructure::config;
+use xtop_core::infrastructure::sysinfo_provider::SysinfoProvider;
+use xtop_core::infrastructure::theme_loader::load_all_themes;
+use xtop_tui::render;
+use xtop_tui::terminal;
+
+fn main() -> anyhow::Result<()> {
+ terminal::install_panic_hook();
+ let mut terminal = terminal::init()?;
+
+ let provider = SysinfoProvider::new();
+ let themes = load_all_themes();
+ let cfg = config::load_config();
+ let mut state = AppState::new(Box::new(provider), themes, cfg);
+
+ let tick_rate = Duration::from_millis(state.update_interval_ms);
+ let mut last_tick = Instant::now();
+
+ loop {
+ terminal.draw(|f| render::render(f, &state))?;
+
+ let timeout = tick_rate
+ .checked_sub(last_tick.elapsed())
+ .unwrap_or_default();
+
+ if event::poll(timeout)? {
+ if let Event::Key(key) = event::read()? {
+ match state.input_mode {
+ InputMode::Normal => match key.code {
+ KeyCode::Char('q') => {
+ let cfg = Config {
+ theme: state.current_theme.name.clone(),
+ layout_mode: state.layout_mode,
+ update_interval_ms: state.update_interval_ms,
+ history_points: 100,
+ alerts: state.alerts,
+ };
+ let _ = config::save_config(&cfg);
+ state.quit();
+ }
+ KeyCode::Char('?') => state.toggle_help(),
+ KeyCode::Char('t') => state.next_theme(),
+ KeyCode::Char('T') => state.previous_theme(),
+ KeyCode::Char('l') => state.next_layout(),
+ KeyCode::Char('f') => state.toggle_fullscreen(),
+ KeyCode::Char('F') => state.cycle_fullscreen_widget(),
+ KeyCode::Char('/') => state.start_search(),
+ KeyCode::Esc => {
+ if state.show_help {
+ state.toggle_help();
+ }
+ }
+ _ => {}
+ },
+ InputMode::Searching => match key.code {
+ KeyCode::Esc => {
+ state.search_query.clear();
+ state.end_search();
+ }
+ KeyCode::Enter => {
+ state.end_search();
+ }
+ KeyCode::Backspace => {
+ state.search_pop_char();
+ }
+ KeyCode::Char(c) => {
+ state.search_push_char(c);
+ }
+ _ => {}
+ },
+ }
+ }
+ }
+
+ if last_tick.elapsed() >= tick_rate {
+ state.on_tick();
+ last_tick = Instant::now();
+ }
+
+ if state.should_quit {
+ break;
+ }
+ }
+
+ terminal::restore()?;
+ Ok(())
+}
diff --git a/crates/xtop-core/Cargo.toml b/crates/xtop-core/Cargo.toml
new file mode 100644
index 0000000..784b7c4
--- /dev/null
+++ b/crates/xtop-core/Cargo.toml
@@ -0,0 +1,10 @@
+[package]
+name = "xtop-core"
+version.workspace = true
+edition.workspace = true
+license.workspace = true
+
+[dependencies]
+sysinfo.workspace = true
+serde.workspace = true
+serde_json.workspace = true
diff --git a/crates/xtop-core/src/application/history.rs b/crates/xtop-core/src/application/history.rs
new file mode 100644
index 0000000..dafd59c
--- /dev/null
+++ b/crates/xtop-core/src/application/history.rs
@@ -0,0 +1,142 @@
+use std::collections::VecDeque;
+
+pub struct MetricsHistory {
+ pub cpu: Vec>,
+ pub mem: VecDeque<(f64, f64)>,
+ pub net_rx: VecDeque<(f64, f64)>,
+ pub net_tx: VecDeque<(f64, f64)>,
+ max_points: usize,
+}
+
+impl MetricsHistory {
+ pub fn new(max_points: usize) -> Self {
+ Self {
+ cpu: Vec::new(),
+ mem: VecDeque::with_capacity(max_points),
+ net_rx: VecDeque::with_capacity(max_points),
+ net_tx: VecDeque::with_capacity(max_points),
+ max_points,
+ }
+ }
+
+ pub fn set_max_points(&mut self, max: usize) {
+ self.max_points = max;
+ self.mem.truncate(max);
+ self.mem.shrink_to_fit();
+ self.net_rx.truncate(max);
+ self.net_rx.shrink_to_fit();
+ self.net_tx.truncate(max);
+ self.net_tx.shrink_to_fit();
+ for h in &mut self.cpu {
+ h.truncate(max);
+ h.shrink_to_fit();
+ }
+ }
+
+ pub fn push_cpu(&mut self, cpu_id: usize, x: f64, y: f64) {
+ if cpu_id >= self.cpu.len() {
+ self.cpu
+ .resize(cpu_id + 1, VecDeque::with_capacity(self.max_points));
+ }
+ let h = &mut self.cpu[cpu_id];
+ if h.len() >= self.max_points {
+ h.pop_front();
+ }
+ h.push_back((x, y));
+ }
+
+ pub fn push_mem(&mut self, x: f64, y: f64) {
+ if self.mem.len() >= self.max_points {
+ self.mem.pop_front();
+ }
+ self.mem.push_back((x, y));
+ }
+
+ pub fn push_net(&mut self, x: f64, rx: f64, tx: f64) {
+ if self.net_rx.len() >= self.max_points {
+ self.net_rx.pop_front();
+ self.net_tx.pop_front();
+ }
+ self.net_rx.push_back((x, rx));
+ self.net_tx.push_back((x, tx));
+ }
+
+ pub fn reset_cpu(&mut self, count: usize) {
+ self.cpu = vec![VecDeque::with_capacity(self.max_points); count];
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn test_history_new() {
+ let h = MetricsHistory::new(100);
+ assert!(h.cpu.is_empty());
+ assert!(h.mem.is_empty());
+ assert!(h.net_rx.is_empty());
+ assert!(h.net_tx.is_empty());
+ }
+
+ #[test]
+ fn test_history_push_cpu() {
+ let mut h = MetricsHistory::new(3);
+ h.push_cpu(0, 1.0, 50.0);
+ h.push_cpu(0, 2.0, 60.0);
+ assert_eq!(h.cpu[0].len(), 2);
+ h.push_cpu(0, 3.0, 70.0);
+ h.push_cpu(0, 4.0, 80.0);
+ assert_eq!(h.cpu[0].len(), 3);
+ assert_eq!(h.cpu[0][0], (2.0, 60.0));
+ }
+
+ #[test]
+ fn test_history_push_mem() {
+ let mut h = MetricsHistory::new(2);
+ h.push_mem(1.0, 30.0);
+ h.push_mem(2.0, 40.0);
+ assert_eq!(h.mem.len(), 2);
+ h.push_mem(3.0, 50.0);
+ assert_eq!(h.mem.len(), 2);
+ assert_eq!(h.mem[0], (2.0, 40.0));
+ }
+
+ #[test]
+ fn test_history_push_net() {
+ let mut h = MetricsHistory::new(5);
+ h.push_net(1.0, 100.0, 200.0);
+ assert_eq!(h.net_rx.len(), 1);
+ assert_eq!(h.net_tx[0], (1.0, 200.0));
+ }
+
+ #[test]
+ fn test_history_reset_cpu() {
+ let mut h = MetricsHistory::new(10);
+ h.push_cpu(0, 1.0, 50.0);
+ h.push_cpu(1, 2.0, 60.0);
+ assert_eq!(h.cpu.len(), 2);
+ h.reset_cpu(4);
+ assert_eq!(h.cpu.len(), 4);
+ assert!(h.cpu[0].is_empty());
+ }
+
+ #[test]
+ fn test_history_auto_resize_cpu() {
+ let mut h = MetricsHistory::new(10);
+ h.push_cpu(5, 1.0, 99.0);
+ assert_eq!(h.cpu.len(), 6);
+ assert_eq!(h.cpu[5][0], (1.0, 99.0));
+ }
+
+ #[test]
+ fn test_history_set_max_points() {
+ let mut h = MetricsHistory::new(10);
+ for i in 0..20 {
+ h.push_mem(i as f64, i as f64);
+ }
+ assert_eq!(h.mem.len(), 10);
+ h.set_max_points(5);
+ assert_eq!(h.mem.len(), 5);
+ }
+}
diff --git a/crates/xtop-core/src/application/mod.rs b/crates/xtop-core/src/application/mod.rs
new file mode 100644
index 0000000..1b8f3aa
--- /dev/null
+++ b/crates/xtop-core/src/application/mod.rs
@@ -0,0 +1,2 @@
+pub mod history;
+pub mod state;
diff --git a/crates/xtop-core/src/application/state.rs b/crates/xtop-core/src/application/state.rs
new file mode 100644
index 0000000..8f962b3
--- /dev/null
+++ b/crates/xtop-core/src/application/state.rs
@@ -0,0 +1,398 @@
+use crate::application::history::MetricsHistory;
+use crate::domain::metrics::SystemSnapshot;
+use crate::domain::system_info::SystemDataProvider;
+use crate::domain::theme::Theme;
+use serde::{Deserialize, Serialize};
+
+#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
+pub enum LayoutMode {
+ Dashboard,
+ Vertical,
+ Horizontal,
+ CpuFocus,
+ MemoryFocus,
+ NetworkFocus,
+ ProcessFocus,
+}
+
+impl LayoutMode {
+ pub fn next(self) -> Self {
+ match self {
+ Self::Dashboard => Self::Vertical,
+ Self::Vertical => Self::Horizontal,
+ Self::Horizontal => Self::CpuFocus,
+ Self::CpuFocus => Self::MemoryFocus,
+ Self::MemoryFocus => Self::NetworkFocus,
+ Self::NetworkFocus => Self::ProcessFocus,
+ Self::ProcessFocus => Self::Dashboard,
+ }
+ }
+
+ pub fn label(self) -> &'static str {
+ match self {
+ Self::Dashboard => "Dashboard",
+ Self::Vertical => "Vertical",
+ Self::Horizontal => "Horizontal",
+ Self::CpuFocus => "CPU Focus",
+ Self::MemoryFocus => "Memory Focus",
+ Self::NetworkFocus => "Network Focus",
+ Self::ProcessFocus => "Process Focus",
+ }
+ }
+}
+
+#[derive(Clone, Copy, Debug, PartialEq)]
+pub enum EffectiveLayout {
+ Dashboard,
+ Compact,
+ Vertical,
+ Horizontal,
+ CpuFocus,
+ MemoryFocus,
+ NetworkFocus,
+ ProcessFocus,
+ Minimal,
+}
+
+pub fn detect_effective_layout(width: u16, height: u16, user_mode: LayoutMode) -> EffectiveLayout {
+ if width < 60 || height < 14 {
+ return EffectiveLayout::Minimal;
+ }
+ match user_mode {
+ LayoutMode::Dashboard => {
+ if width < 80 {
+ EffectiveLayout::Vertical
+ } else if width < 100 || height < 28 {
+ EffectiveLayout::Compact
+ } else {
+ EffectiveLayout::Dashboard
+ }
+ }
+ LayoutMode::Vertical => EffectiveLayout::Vertical,
+ LayoutMode::Horizontal => EffectiveLayout::Horizontal,
+ LayoutMode::CpuFocus => EffectiveLayout::CpuFocus,
+ LayoutMode::MemoryFocus => EffectiveLayout::MemoryFocus,
+ LayoutMode::NetworkFocus => EffectiveLayout::NetworkFocus,
+ LayoutMode::ProcessFocus => EffectiveLayout::ProcessFocus,
+ }
+}
+
+#[derive(Clone, Copy, Debug, PartialEq)]
+pub enum FullScreenWidget {
+ None,
+ Cpu,
+ Memory,
+ Storage,
+ Network,
+ Processes,
+ DiskIO,
+ Gpu,
+ Battery,
+}
+
+impl FullScreenWidget {
+ pub fn next(self) -> Self {
+ match self {
+ Self::None => Self::Cpu,
+ Self::Cpu => Self::Memory,
+ Self::Memory => Self::Storage,
+ Self::Storage => Self::Network,
+ Self::Network => Self::Processes,
+ Self::Processes => Self::DiskIO,
+ Self::DiskIO => Self::Gpu,
+ Self::Gpu => Self::Battery,
+ Self::Battery => Self::None,
+ }
+ }
+
+ pub fn label(self) -> &'static str {
+ match self {
+ Self::None => "",
+ Self::Cpu => "CPU",
+ Self::Memory => "Memory",
+ Self::Storage => "Storage",
+ Self::Network => "Network",
+ Self::Processes => "Processes",
+ Self::DiskIO => "Disk I/O",
+ Self::Gpu => "GPU",
+ Self::Battery => "Battery",
+ }
+ }
+}
+
+#[derive(Clone, Copy, Debug, PartialEq)]
+pub enum InputMode {
+ Normal,
+ Searching,
+}
+
+#[derive(Copy, Clone, Debug, Serialize, Deserialize)]
+pub struct AlertThresholds {
+ pub cpu_high: f64,
+ pub mem_high: f64,
+ pub disk_high: f64,
+}
+
+impl Default for AlertThresholds {
+ fn default() -> Self {
+ Self {
+ cpu_high: 90.0,
+ mem_high: 90.0,
+ disk_high: 90.0,
+ }
+ }
+}
+
+#[derive(Clone, Serialize, Deserialize)]
+pub struct Config {
+ pub theme: String,
+ pub layout_mode: LayoutMode,
+ pub update_interval_ms: u64,
+ pub history_points: usize,
+ pub alerts: AlertThresholds,
+}
+
+impl Default for Config {
+ fn default() -> Self {
+ Self {
+ theme: "x".to_string(),
+ layout_mode: LayoutMode::Dashboard,
+ update_interval_ms: 1000,
+ history_points: 100,
+ alerts: AlertThresholds::default(),
+ }
+ }
+}
+
+pub struct AppState {
+ provider: Box,
+ pub history: MetricsHistory,
+ pub should_quit: bool,
+ pub layout_mode: LayoutMode,
+ pub current_theme: Theme,
+ pub themes: Vec,
+ pub selected_theme_index: usize,
+ pub tick_count: f64,
+ pub show_help: bool,
+ pub input_mode: InputMode,
+ pub search_query: String,
+ pub full_screen_widget: FullScreenWidget,
+ pub alerts: AlertThresholds,
+ pub update_interval_ms: u64,
+ pub config_path: String,
+}
+
+impl AppState {
+ pub fn new(provider: Box, themes: Vec, config: Config) -> Self {
+ let selected_theme_index = themes
+ .iter()
+ .position(|t| t.name == config.theme)
+ .unwrap_or(0);
+ let current_theme = themes[selected_theme_index].clone();
+ Self {
+ provider,
+ history: MetricsHistory::new(config.history_points),
+ should_quit: false,
+ layout_mode: config.layout_mode,
+ current_theme,
+ themes,
+ selected_theme_index,
+ tick_count: 0.0,
+ show_help: false,
+ input_mode: InputMode::Normal,
+ search_query: String::new(),
+ full_screen_widget: FullScreenWidget::None,
+ alerts: config.alerts,
+ update_interval_ms: config.update_interval_ms,
+ config_path: String::new(),
+ }
+ }
+
+ pub fn on_tick(&mut self) {
+ self.provider.refresh_all();
+ self.tick_count += 1.0;
+ let x = self.tick_count;
+ let snap = self.provider.snapshot();
+
+ if snap.cpus.len() != self.history.cpu.len() {
+ self.history.reset_cpu(snap.cpus.len());
+ }
+ for (i, cpu) in snap.cpus.iter().enumerate() {
+ self.history.push_cpu(i, x, cpu.usage);
+ }
+
+ self.history.push_mem(x, snap.memory.percent);
+
+ let total_rx: u64 = snap.networks.iter().map(|n| n.received).sum();
+ let total_tx: u64 = snap.networks.iter().map(|n| n.transmitted).sum();
+ self.history.push_net(x, total_rx as f64, total_tx as f64);
+ }
+
+ pub fn snapshot(&self) -> SystemSnapshot {
+ let mut snap = self.provider.snapshot();
+ snap.disk_io = self.provider.disk_io();
+ snap.batteries = self.provider.batteries();
+ snap.gpus = self.provider.gpu_info();
+ snap.dockers = self.provider.docker_info();
+ snap
+ }
+
+ pub fn next_theme(&mut self) {
+ self.selected_theme_index = (self.selected_theme_index + 1) % self.themes.len();
+ self.apply_theme();
+ }
+
+ pub fn previous_theme(&mut self) {
+ if self.selected_theme_index == 0 {
+ self.selected_theme_index = self.themes.len() - 1;
+ } else {
+ self.selected_theme_index -= 1;
+ }
+ self.apply_theme();
+ }
+
+ fn apply_theme(&mut self) {
+ self.current_theme = self.themes[self.selected_theme_index].clone();
+ }
+
+ pub fn next_layout(&mut self) {
+ self.layout_mode = self.layout_mode.next();
+ self.full_screen_widget = FullScreenWidget::None;
+ }
+
+ pub fn toggle_fullscreen(&mut self) {
+ self.full_screen_widget = match self.full_screen_widget {
+ FullScreenWidget::None => FullScreenWidget::Cpu,
+ _ => FullScreenWidget::None,
+ };
+ }
+
+ pub fn cycle_fullscreen_widget(&mut self) {
+ self.full_screen_widget = self.full_screen_widget.next();
+ }
+
+ pub fn start_search(&mut self) {
+ self.input_mode = InputMode::Searching;
+ self.search_query.clear();
+ }
+
+ pub fn search_push_char(&mut self, c: char) {
+ self.search_query.push(c);
+ }
+
+ pub fn search_pop_char(&mut self) {
+ self.search_query.pop();
+ }
+
+ pub fn end_search(&mut self) {
+ self.input_mode = InputMode::Normal;
+ }
+
+ pub fn toggle_help(&mut self) {
+ self.show_help = !self.show_help;
+ }
+
+ pub fn quit(&mut self) {
+ self.should_quit = true;
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn test_layout_mode_next() {
+ assert_eq!(LayoutMode::Dashboard.next(), LayoutMode::Vertical);
+ assert_eq!(LayoutMode::Vertical.next(), LayoutMode::Horizontal);
+ assert_eq!(LayoutMode::Horizontal.next(), LayoutMode::CpuFocus);
+ assert_eq!(LayoutMode::CpuFocus.next(), LayoutMode::MemoryFocus);
+ assert_eq!(LayoutMode::MemoryFocus.next(), LayoutMode::NetworkFocus);
+ assert_eq!(LayoutMode::NetworkFocus.next(), LayoutMode::ProcessFocus);
+ assert_eq!(LayoutMode::ProcessFocus.next(), LayoutMode::Dashboard);
+ }
+
+ #[test]
+ fn test_detect_effective_layout_large() {
+ assert_eq!(
+ detect_effective_layout(120, 40, LayoutMode::Dashboard),
+ EffectiveLayout::Dashboard
+ );
+ }
+
+ #[test]
+ fn test_detect_effective_layout_compact() {
+ assert_eq!(
+ detect_effective_layout(90, 30, LayoutMode::Dashboard),
+ EffectiveLayout::Compact
+ );
+ }
+
+ #[test]
+ fn test_detect_effective_layout_narrow() {
+ assert_eq!(
+ detect_effective_layout(70, 30, LayoutMode::Dashboard),
+ EffectiveLayout::Vertical
+ );
+ }
+
+ #[test]
+ fn test_detect_effective_layout_minimal() {
+ assert_eq!(
+ detect_effective_layout(50, 15, LayoutMode::Dashboard),
+ EffectiveLayout::Minimal
+ );
+ }
+
+ #[test]
+ fn test_detect_effective_layout_focus_respected() {
+ assert_eq!(
+ detect_effective_layout(80, 30, LayoutMode::CpuFocus),
+ EffectiveLayout::CpuFocus
+ );
+ assert_eq!(
+ detect_effective_layout(80, 30, LayoutMode::NetworkFocus),
+ EffectiveLayout::NetworkFocus
+ );
+ }
+
+ #[test]
+ fn test_detect_effective_layout_focus_downgrade() {
+ assert_eq!(
+ detect_effective_layout(50, 30, LayoutMode::CpuFocus),
+ EffectiveLayout::Minimal
+ );
+ }
+
+ #[test]
+ fn test_fullscreen_widget_cycle() {
+ assert_eq!(FullScreenWidget::None.next(), FullScreenWidget::Cpu);
+ assert_eq!(FullScreenWidget::Battery.next(), FullScreenWidget::None);
+ }
+
+ #[test]
+ fn test_layout_mode_label() {
+ assert_eq!(LayoutMode::Dashboard.label(), "Dashboard");
+ assert_eq!(LayoutMode::CpuFocus.label(), "CPU Focus");
+ assert_eq!(LayoutMode::Horizontal.label(), "Horizontal");
+ }
+
+ #[test]
+ fn test_alert_thresholds_default() {
+ let a = AlertThresholds::default();
+ assert_eq!(a.cpu_high, 90.0);
+ assert_eq!(a.mem_high, 90.0);
+ assert_eq!(a.disk_high, 90.0);
+ }
+
+ #[test]
+ fn test_config_default() {
+ let c = Config::default();
+ assert_eq!(c.theme, "x");
+ assert_eq!(c.layout_mode, LayoutMode::Dashboard);
+ assert_eq!(c.update_interval_ms, 1000);
+ }
+
+ #[test]
+ fn test_search_operations() {}
+}
diff --git a/crates/xtop-core/src/domain/metrics.rs b/crates/xtop-core/src/domain/metrics.rs
new file mode 100644
index 0000000..d7e6684
--- /dev/null
+++ b/crates/xtop-core/src/domain/metrics.rs
@@ -0,0 +1,109 @@
+#[derive(Debug, Clone)]
+pub struct CpuInfo {
+ pub name: String,
+ pub usage: f64,
+ pub cpu_id: usize,
+}
+
+#[derive(Debug, Clone)]
+pub struct MemoryInfo {
+ pub total: u64,
+ pub used: u64,
+ pub available: u64,
+ pub free: u64,
+ pub percent: f64,
+}
+
+#[derive(Debug, Clone)]
+pub struct SwapInfo {
+ pub total: u64,
+ pub used: u64,
+ pub free: u64,
+ pub percent: f64,
+}
+
+#[derive(Debug, Clone)]
+pub struct DiskInfo {
+ pub mount_point: String,
+ pub total_space: u64,
+ pub available_space: u64,
+ pub used_space: u64,
+ pub percent: f64,
+}
+
+#[derive(Debug, Clone)]
+pub struct DiskIOInfo {
+ pub name: String,
+ pub read_bytes: u64,
+ pub write_bytes: u64,
+ pub read_speed: f64,
+ pub write_speed: f64,
+}
+
+#[derive(Debug, Clone)]
+pub struct NetworkInfo {
+ pub name: String,
+ pub received: u64,
+ pub transmitted: u64,
+ pub rx_speed: f64,
+ pub tx_speed: f64,
+}
+
+#[derive(Debug, Clone)]
+pub struct ProcessInfo {
+ pub pid: u32,
+ pub name: String,
+ pub cpu_usage: f64,
+ pub memory: u64,
+ pub user_id: Option,
+}
+
+#[derive(Debug, Clone)]
+pub struct LoadAvg {
+ pub one: f64,
+ pub five: f64,
+ pub fifteen: f64,
+}
+
+#[derive(Debug, Clone)]
+pub struct BatteryInfo {
+ pub name: String,
+ pub percentage: f32,
+ pub state: String,
+ pub time_to_full: Option,
+ pub time_to_empty: Option,
+}
+
+#[derive(Debug, Clone)]
+pub struct GpuInfo {
+ pub name: String,
+ pub usage: f64,
+ pub temperature: f32,
+ pub memory_total: u64,
+ pub memory_used: u64,
+}
+
+#[derive(Debug, Clone)]
+pub struct DockerInfo {
+ pub name: String,
+ pub status: String,
+ pub cpu_usage: f64,
+ pub memory_usage: u64,
+}
+
+#[derive(Debug, Clone)]
+pub struct SystemSnapshot {
+ pub cpus: Vec,
+ pub memory: MemoryInfo,
+ pub swap: SwapInfo,
+ pub disks: Vec,
+ pub networks: Vec,
+ pub processes: Vec,
+ pub load_avg: LoadAvg,
+ pub uptime: u64,
+ pub cpu_temp: f64,
+ pub disk_io: Vec,
+ pub batteries: Vec,
+ pub gpus: Vec,
+ pub dockers: Vec,
+}
diff --git a/crates/xtop-core/src/domain/mod.rs b/crates/xtop-core/src/domain/mod.rs
new file mode 100644
index 0000000..c211982
--- /dev/null
+++ b/crates/xtop-core/src/domain/mod.rs
@@ -0,0 +1,3 @@
+pub mod metrics;
+pub mod system_info;
+pub mod theme;
diff --git a/crates/xtop-core/src/domain/system_info.rs b/crates/xtop-core/src/domain/system_info.rs
new file mode 100644
index 0000000..cfa119d
--- /dev/null
+++ b/crates/xtop-core/src/domain/system_info.rs
@@ -0,0 +1,18 @@
+use crate::domain::metrics::*;
+
+pub trait SystemDataProvider: Send {
+ fn refresh_all(&mut self);
+ fn snapshot(&self) -> SystemSnapshot;
+ fn disk_io(&self) -> Vec {
+ vec![]
+ }
+ fn batteries(&self) -> Vec {
+ vec![]
+ }
+ fn gpu_info(&self) -> Vec {
+ vec![]
+ }
+ fn docker_info(&self) -> Vec {
+ vec![]
+ }
+}
diff --git a/crates/xtop-core/src/domain/theme.rs b/crates/xtop-core/src/domain/theme.rs
new file mode 100644
index 0000000..df52c87
--- /dev/null
+++ b/crates/xtop-core/src/domain/theme.rs
@@ -0,0 +1,17 @@
+use serde::{Deserialize, Serialize};
+
+#[derive(Clone, Debug, Serialize, Deserialize)]
+pub struct Theme {
+ pub name: String,
+ pub palette: [[u8; 3]; 16],
+}
+
+impl Theme {
+ pub fn bg(&self) -> &[u8; 3] {
+ &self.palette[0]
+ }
+
+ pub fn fg(&self) -> &[u8; 3] {
+ &self.palette[7]
+ }
+}
diff --git a/crates/xtop-core/src/infrastructure/battery_provider.rs b/crates/xtop-core/src/infrastructure/battery_provider.rs
new file mode 100644
index 0000000..a9f5f1d
--- /dev/null
+++ b/crates/xtop-core/src/infrastructure/battery_provider.rs
@@ -0,0 +1,26 @@
+use crate::domain::metrics::BatteryInfo;
+use crate::domain::system_info::SystemDataProvider;
+
+pub struct NoopBatteryProvider;
+
+impl Default for NoopBatteryProvider {
+ fn default() -> Self {
+ Self
+ }
+}
+
+impl NoopBatteryProvider {
+ pub fn new() -> Self {
+ Self
+ }
+}
+
+impl SystemDataProvider for NoopBatteryProvider {
+ fn refresh_all(&mut self) {}
+ fn snapshot(&self) -> crate::domain::metrics::SystemSnapshot {
+ unimplemented!("NoopBatteryProvider is meant as a mixin, not a standalone provider")
+ }
+ fn batteries(&self) -> Vec {
+ vec![]
+ }
+}
diff --git a/crates/xtop-core/src/infrastructure/config.rs b/crates/xtop-core/src/infrastructure/config.rs
new file mode 100644
index 0000000..7416ee9
--- /dev/null
+++ b/crates/xtop-core/src/infrastructure/config.rs
@@ -0,0 +1,62 @@
+use crate::application::state::Config;
+use std::fs;
+use std::path::PathBuf;
+
+fn config_dir() -> PathBuf {
+ if let Ok(xdg) = std::env::var("XDG_CONFIG_HOME") {
+ PathBuf::from(xdg).join("xtop")
+ } else if let Ok(home) = std::env::var("HOME") {
+ PathBuf::from(home).join(".config").join("xtop")
+ } else {
+ PathBuf::from(".").join(".config").join("xtop")
+ }
+}
+
+pub fn config_path() -> PathBuf {
+ config_dir().join("config.json")
+}
+
+pub fn themes_dir() -> PathBuf {
+ config_dir().join("themes")
+}
+
+pub fn load_config() -> Config {
+ let path = config_path();
+ if let Ok(data) = fs::read_to_string(&path) {
+ if let Ok(cfg) = serde_json::from_str::(&data) {
+ return cfg;
+ }
+ }
+ Config::default()
+}
+
+pub fn save_config(config: &Config) -> Result<(), String> {
+ let path = config_path();
+ if let Some(parent) = path.parent() {
+ fs::create_dir_all(parent).map_err(|e| e.to_string())?;
+ }
+ let data = serde_json::to_string_pretty(config).map_err(|e| e.to_string())?;
+ fs::write(&path, data).map_err(|e| e.to_string())?;
+ Ok(())
+}
+
+pub fn load_custom_themes() -> Vec {
+ let dir = themes_dir();
+ if !dir.exists() {
+ return vec![];
+ }
+ let mut themes = vec![];
+ if let Ok(entries) = fs::read_dir(&dir) {
+ for entry in entries.flatten() {
+ let path = entry.path();
+ if path.extension().and_then(|e| e.to_str()) == Some("json") {
+ if let Ok(data) = fs::read_to_string(&path) {
+ if let Ok(theme) = serde_json::from_str::(&data) {
+ themes.push(theme);
+ }
+ }
+ }
+ }
+ }
+ themes
+}
diff --git a/crates/xtop-core/src/infrastructure/docker_provider.rs b/crates/xtop-core/src/infrastructure/docker_provider.rs
new file mode 100644
index 0000000..43fb234
--- /dev/null
+++ b/crates/xtop-core/src/infrastructure/docker_provider.rs
@@ -0,0 +1,26 @@
+use crate::domain::metrics::DockerInfo;
+use crate::domain::system_info::SystemDataProvider;
+
+pub struct NoopDockerProvider;
+
+impl Default for NoopDockerProvider {
+ fn default() -> Self {
+ Self
+ }
+}
+
+impl NoopDockerProvider {
+ pub fn new() -> Self {
+ Self
+ }
+}
+
+impl SystemDataProvider for NoopDockerProvider {
+ fn refresh_all(&mut self) {}
+ fn snapshot(&self) -> crate::domain::metrics::SystemSnapshot {
+ unimplemented!("NoopDockerProvider is meant as a mixin, not a standalone provider")
+ }
+ fn docker_info(&self) -> Vec {
+ vec![]
+ }
+}
diff --git a/crates/xtop-core/src/infrastructure/gpu_provider.rs b/crates/xtop-core/src/infrastructure/gpu_provider.rs
new file mode 100644
index 0000000..6c52d8e
--- /dev/null
+++ b/crates/xtop-core/src/infrastructure/gpu_provider.rs
@@ -0,0 +1,26 @@
+use crate::domain::metrics::GpuInfo;
+use crate::domain::system_info::SystemDataProvider;
+
+pub struct NoopGpuProvider;
+
+impl Default for NoopGpuProvider {
+ fn default() -> Self {
+ Self
+ }
+}
+
+impl NoopGpuProvider {
+ pub fn new() -> Self {
+ Self
+ }
+}
+
+impl SystemDataProvider for NoopGpuProvider {
+ fn refresh_all(&mut self) {}
+ fn snapshot(&self) -> crate::domain::metrics::SystemSnapshot {
+ unimplemented!("NoopGpuProvider is meant as a mixin, not a standalone provider")
+ }
+ fn gpu_info(&self) -> Vec {
+ vec![]
+ }
+}
diff --git a/crates/xtop-core/src/infrastructure/mod.rs b/crates/xtop-core/src/infrastructure/mod.rs
new file mode 100644
index 0000000..d2570f2
--- /dev/null
+++ b/crates/xtop-core/src/infrastructure/mod.rs
@@ -0,0 +1,6 @@
+pub mod battery_provider;
+pub mod config;
+pub mod docker_provider;
+pub mod gpu_provider;
+pub mod sysinfo_provider;
+pub mod theme_loader;
diff --git a/crates/xtop-core/src/infrastructure/sysinfo_provider.rs b/crates/xtop-core/src/infrastructure/sysinfo_provider.rs
new file mode 100644
index 0000000..007b93a
--- /dev/null
+++ b/crates/xtop-core/src/infrastructure/sysinfo_provider.rs
@@ -0,0 +1,243 @@
+use crate::domain::metrics::*;
+use crate::domain::system_info::SystemDataProvider;
+use std::collections::HashMap;
+use std::time::Instant;
+use sysinfo::{
+ Components, CpuRefreshKind, Disks, MemoryRefreshKind, Networks, ProcessRefreshKind,
+ RefreshKind, System,
+};
+
+pub struct SysinfoProvider {
+ sys: System,
+ disks: Disks,
+ networks: Networks,
+ components: Components,
+ prev_disk_read: HashMap,
+ prev_disk_write: HashMap,
+ prev_net_rx: HashMap,
+ prev_net_tx: HashMap,
+ last_refresh: Instant,
+}
+
+impl Default for SysinfoProvider {
+ fn default() -> Self {
+ Self::new()
+ }
+}
+
+impl SysinfoProvider {
+ pub fn new() -> Self {
+ let sys = System::new_with_specifics(
+ RefreshKind::nothing()
+ .with_cpu(CpuRefreshKind::everything())
+ .with_memory(MemoryRefreshKind::everything())
+ .with_processes(ProcessRefreshKind::everything()),
+ );
+ Self {
+ sys,
+ disks: Disks::new_with_refreshed_list(),
+ networks: Networks::new_with_refreshed_list(),
+ components: Components::new_with_refreshed_list(),
+ prev_disk_read: HashMap::new(),
+ prev_disk_write: HashMap::new(),
+ prev_net_rx: HashMap::new(),
+ prev_net_tx: HashMap::new(),
+ last_refresh: Instant::now(),
+ }
+ }
+}
+
+impl SystemDataProvider for SysinfoProvider {
+ fn refresh_all(&mut self) {
+ self.sys.refresh_all();
+ self.disks.refresh(true);
+ self.networks.refresh(true);
+ self.components.refresh(true);
+ }
+
+ fn snapshot(&self) -> SystemSnapshot {
+ let cpus: Vec = self
+ .sys
+ .cpus()
+ .iter()
+ .enumerate()
+ .map(|(i, c)| CpuInfo {
+ name: c.name().to_string(),
+ usage: c.cpu_usage() as f64,
+ cpu_id: i,
+ })
+ .collect();
+
+ let total_mem = self.sys.total_memory();
+ let used_mem = self.sys.used_memory();
+ let memory = MemoryInfo {
+ total: total_mem,
+ used: used_mem,
+ available: self.sys.available_memory(),
+ free: self.sys.free_memory(),
+ percent: if total_mem > 0 {
+ (used_mem as f64 / total_mem as f64) * 100.0
+ } else {
+ 0.0
+ },
+ };
+
+ let total_swap = self.sys.total_swap();
+ let used_swap = self.sys.used_swap();
+ let swap = SwapInfo {
+ total: total_swap,
+ used: used_swap,
+ free: self.sys.free_swap(),
+ percent: if total_swap > 0 {
+ (used_swap as f64 / total_swap as f64) * 100.0
+ } else {
+ 0.0
+ },
+ };
+
+ let disks: Vec = self
+ .disks
+ .iter()
+ .map(|d| {
+ let total = d.total_space();
+ let available = d.available_space();
+ let used = total - available;
+ DiskInfo {
+ mount_point: d.mount_point().to_string_lossy().to_string(),
+ total_space: total,
+ available_space: available,
+ used_space: used,
+ percent: if total > 0 {
+ (used as f64 / total as f64) * 100.0
+ } else {
+ 0.0
+ },
+ }
+ })
+ .collect();
+
+ let networks: Vec = self
+ .networks
+ .iter()
+ .map(|(name, n)| {
+ let rx = n.received();
+ let tx = n.transmitted();
+ let rx_speed = if let Some(prev) = self.prev_net_rx.get(name) {
+ let elapsed = self.last_refresh.elapsed().as_secs_f64();
+ if elapsed > 0.0 {
+ (rx.saturating_sub(*prev)) as f64 / elapsed
+ } else {
+ 0.0
+ }
+ } else {
+ 0.0
+ };
+ let tx_speed = if let Some(prev) = self.prev_net_tx.get(name) {
+ let elapsed = self.last_refresh.elapsed().as_secs_f64();
+ if elapsed > 0.0 {
+ (tx.saturating_sub(*prev)) as f64 / elapsed
+ } else {
+ 0.0
+ }
+ } else {
+ 0.0
+ };
+ NetworkInfo {
+ name: name.clone(),
+ received: rx,
+ transmitted: tx,
+ rx_speed,
+ tx_speed,
+ }
+ })
+ .collect();
+
+ let mut procs: Vec = self
+ .sys
+ .processes()
+ .iter()
+ .map(|(pid, p)| ProcessInfo {
+ pid: pid.as_u32(),
+ name: p.name().to_string_lossy().to_string(),
+ cpu_usage: p.cpu_usage() as f64,
+ memory: p.memory(),
+ user_id: p.user_id().map(|u| u.to_string()),
+ })
+ .collect();
+ procs.sort_by(|a, b| {
+ b.cpu_usage
+ .partial_cmp(&a.cpu_usage)
+ .unwrap_or(std::cmp::Ordering::Equal)
+ });
+ procs.truncate(50);
+
+ let mut max_temp = 0.0f32;
+ for component in &self.components {
+ let label = component.label().to_lowercase();
+ if label.contains("core") || label.contains("cpu") {
+ if let Some(temp) = component.temperature() {
+ if temp > max_temp {
+ max_temp = temp;
+ }
+ }
+ }
+ }
+
+ let load = System::load_average();
+
+ SystemSnapshot {
+ cpu_temp: max_temp as f64,
+ cpus,
+ memory,
+ swap,
+ disks,
+ networks,
+ processes: procs,
+ load_avg: LoadAvg {
+ one: load.one,
+ five: load.five,
+ fifteen: load.fifteen,
+ },
+ uptime: System::uptime(),
+ disk_io: vec![],
+ batteries: vec![],
+ gpus: vec![],
+ dockers: vec![],
+ }
+ }
+
+ fn disk_io(&self) -> Vec {
+ self.disks
+ .iter()
+ .map(|d| {
+ let name = d.mount_point().to_string_lossy().to_string();
+ let usage = d.usage();
+ let read_bytes = usage.read_bytes;
+ let write_bytes = usage.written_bytes;
+ let (read_speed, write_speed) = if let (Some(prev_r), Some(prev_w)) = (
+ self.prev_disk_read.get(&name),
+ self.prev_disk_write.get(&name),
+ ) {
+ let elapsed = self.last_refresh.elapsed().as_secs_f64();
+ if elapsed > 0.0 {
+ (
+ (read_bytes.saturating_sub(*prev_r)) as f64 / elapsed,
+ (write_bytes.saturating_sub(*prev_w)) as f64 / elapsed,
+ )
+ } else {
+ (0.0, 0.0)
+ }
+ } else {
+ (0.0, 0.0)
+ };
+ DiskIOInfo {
+ name,
+ read_bytes,
+ write_bytes,
+ read_speed,
+ write_speed,
+ }
+ })
+ .collect()
+ }
+}
diff --git a/crates/xtop-core/src/infrastructure/theme_loader.rs b/crates/xtop-core/src/infrastructure/theme_loader.rs
new file mode 100644
index 0000000..376822a
--- /dev/null
+++ b/crates/xtop-core/src/infrastructure/theme_loader.rs
@@ -0,0 +1,216 @@
+use crate::domain::theme::Theme;
+
+fn hex_to_rgb(hex: &str) -> [u8; 3] {
+ let hex = hex.trim_start_matches('#');
+ let r = u8::from_str_radix(hex.get(0..2).unwrap_or("00"), 16).unwrap_or(0);
+ let g = u8::from_str_radix(hex.get(2..4).unwrap_or("00"), 16).unwrap_or(0);
+ let b = u8::from_str_radix(hex.get(4..6).unwrap_or("00"), 16).unwrap_or(0);
+ [r, g, b]
+}
+
+fn make_theme(name: &str, colors: [&str; 16]) -> Theme {
+ let mut palette = [[0u8; 3]; 16];
+ for (i, h) in colors.iter().enumerate() {
+ palette[i] = hex_to_rgb(h);
+ }
+ Theme {
+ name: name.to_string(),
+ palette,
+ }
+}
+
+pub fn builtin_themes() -> Vec {
+ vec![
+ make_theme(
+ "x",
+ [
+ "#363537", "#fc618d", "#7bd88f", "#fce566", "#fd9353", "#948ae3", "#5ad4e6",
+ "#f7f1ff", "#69676c", "#fc618d", "#7bd88f", "#fce566", "#fd9353", "#948ae3",
+ "#5ad4e6", "#f7f1ff",
+ ],
+ ),
+ make_theme(
+ "madrid",
+ [
+ "#333333", "#cc0033", "#009933", "#b8860b", "#0099cc", "#6633cc", "#0099cc",
+ "#1a1a1a", "#666666", "#cc0033", "#009933", "#b8860b", "#0099cc", "#6633cc",
+ "#0099cc", "#1a1a1a",
+ ],
+ ),
+ make_theme(
+ "lahabana",
+ [
+ "#363537", "#fc618d", "#7bd88f", "#e5ff9d", "#fd9353", "#948ae3", "#5ad4e6",
+ "#f7f1ff", "#69676c", "#fc618d", "#7bd88f", "#e5ff9d", "#fd9353", "#948ae3",
+ "#5ad4e6", "#f7f1ff",
+ ],
+ ),
+ make_theme(
+ "seul",
+ [
+ "#1b1b1b", "#FF4C8B", "#7FFFD4", "#FFD84C", "#00FFA8", "#D36CFF", "#47CFFF",
+ "#f7f1ff", "#69676c", "#FF4C8B", "#7FFFD4", "#FFD84C", "#00FFA8", "#D36CFF",
+ "#47CFFF", "#f7f1ff",
+ ],
+ ),
+ make_theme(
+ "miami",
+ [
+ "#000000", "#FF4C8B", "#7FFFD4", "#FFD84C", "#00FFA8", "#D36CFF", "#47CFFF",
+ "#f7f1ff", "#69676c", "#FF4C8B", "#7FFFD4", "#FFD84C", "#00FFA8", "#D36CFF",
+ "#47CFFF", "#f7f1ff",
+ ],
+ ),
+ make_theme(
+ "paris",
+ [
+ "#222222", "#fc618d", "#7bd88f", "#fce566", "#a3f3ff", "#c4bdff", "#a3f3ff",
+ "#f7f1ff", "#525053", "#fc618d", "#7bd88f", "#fce566", "#a3f3ff", "#c4bdff",
+ "#a3f3ff", "#f7f1ff",
+ ],
+ ),
+ make_theme(
+ "tokio",
+ [
+ "#363537", "#fc618d", "#7bd88f", "#fce566", "#fd9353", "#948ae3", "#5ad4e6",
+ "#f7f1ff", "#69676c", "#fc618d", "#7bd88f", "#fce566", "#fd9353", "#948ae3",
+ "#5ad4e6", "#f7f1ff",
+ ],
+ ),
+ make_theme(
+ "oslo",
+ [
+ "#3f4451", "#e05561", "#8cc265", "#d18f52", "#4aa5f0", "#c162de", "#42b3c2",
+ "#e6e6e6", "#4f5666", "#ff616e", "#a5e075", "#f0a45d", "#4dc4ff", "#de73ff",
+ "#4cd1e0", "#ffffff",
+ ],
+ ),
+ make_theme(
+ "helsinki",
+ [
+ "#c0bbae", "#1faa9e", "#733d9a", "#2e70ad", "#b55a0f", "#3e9d21", "#bd4c3d",
+ "#191919", "#b0a999", "#009e91", "#5a1f8a", "#0f5ba2", "#b23b00", "#218c00",
+ "#b32e1f", "#000000",
+ ],
+ ),
+ make_theme(
+ "berlin",
+ [
+ "#000000", "#999999", "#bbbbbb", "#dddddd", "#888888", "#aaaaaa", "#cccccc",
+ "#ffffff", "#333333", "#bbbbbb", "#dddddd", "#ffffff", "#aaaaaa", "#cccccc",
+ "#eeeeee", "#ffffff",
+ ],
+ ),
+ make_theme(
+ "london",
+ [
+ "#000000", "#333333", "#444444", "#555555", "#666666", "#777777", "#888888",
+ "#999999", "#333333", "#444444", "#555555", "#666666", "#777777", "#888888",
+ "#999999", "#aaaaaa",
+ ],
+ ),
+ make_theme(
+ "praha",
+ [
+ "#1A1A1A", "#FF5555", "#B8E6A0", "#FFE4A3", "#BD93F9", "#FF9AA2", "#8BE9FD",
+ "#FFFFFF", "#6272A4", "#FF6E6E", "#B8E6A0", "#FFE4A3", "#D6ACFF", "#FF9AA2",
+ "#A4FFFF", "#FFFFFF",
+ ],
+ ),
+ make_theme(
+ "bogota",
+ [
+ "#222222", "#fc618d", "#7bd88f", "#ffed89", "#47e6ff", "#ff9999", "#47e6ff",
+ "#f7f1ff", "#525053", "#fc618d", "#7bd88f", "#ffed89", "#47e6ff", "#ff9999",
+ "#47e6ff", "#f7f1ff",
+ ],
+ ),
+ ]
+}
+
+pub fn load_all_themes() -> Vec {
+ let mut themes = builtin_themes();
+ let custom = crate::infrastructure::config::load_custom_themes();
+ for t in custom {
+ if !themes.iter().any(|existing| existing.name == t.name) {
+ themes.push(t);
+ }
+ }
+ themes
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn test_hex_to_rgb_basic() {
+ assert_eq!(hex_to_rgb("#ff0000"), [255, 0, 0]);
+ assert_eq!(hex_to_rgb("#00ff00"), [0, 255, 0]);
+ assert_eq!(hex_to_rgb("#0000ff"), [0, 0, 255]);
+ }
+
+ #[test]
+ fn test_hex_to_rgb_black_white() {
+ assert_eq!(hex_to_rgb("#000000"), [0, 0, 0]);
+ assert_eq!(hex_to_rgb("#ffffff"), [255, 255, 255]);
+ }
+
+ #[test]
+ fn test_hex_to_rgb_without_hash() {
+ assert_eq!(hex_to_rgb("ff0000"), [255, 0, 0]);
+ }
+
+ #[test]
+ fn test_hex_to_rgb_invalid() {
+ assert_eq!(hex_to_rgb("#xyz"), [0, 0, 0]); // unwrap_or(0)
+ }
+
+ #[test]
+ fn test_hex_to_rgb_short() {
+ assert_eq!(hex_to_rgb("#ff"), [255, 0, 0]); // only 2 chars
+ }
+
+ #[test]
+ fn test_builtin_themes_count() {
+ let themes = builtin_themes();
+ assert_eq!(themes.len(), 13);
+ }
+
+ #[test]
+ fn test_builtin_themes_have_names() {
+ let themes = builtin_themes();
+ let names: Vec<&str> = themes.iter().map(|t| t.name.as_str()).collect();
+ assert!(names.contains(&"x"));
+ assert!(names.contains(&"madrid"));
+ assert!(names.contains(&"tokio"));
+ assert!(names.contains(&"praha"));
+ }
+
+ #[test]
+ fn test_builtin_themes_palette_size() {
+ let themes = builtin_themes();
+ for theme in &themes {
+ assert_eq!(
+ theme.palette.len(),
+ 16,
+ "Theme '{}' should have 16 palette entries",
+ theme.name
+ );
+ }
+ }
+
+ #[test]
+ fn test_builtin_theme_bg_fg() {
+ let themes = builtin_themes();
+ let x = themes.iter().find(|t| t.name == "x").unwrap();
+ assert_eq!(x.bg(), &[0x36, 0x35, 0x37]);
+ assert_eq!(x.fg(), &[0xf7, 0xf1, 0xff]);
+ }
+
+ #[test]
+ fn test_hex_to_rgb_uppercase() {
+ assert_eq!(hex_to_rgb("#FF0000"), [255, 0, 0]);
+ assert_eq!(hex_to_rgb("#AABBCC"), [170, 187, 204]);
+ }
+}
diff --git a/crates/xtop-core/src/lib.rs b/crates/xtop-core/src/lib.rs
new file mode 100644
index 0000000..50d0795
--- /dev/null
+++ b/crates/xtop-core/src/lib.rs
@@ -0,0 +1,3 @@
+pub mod application;
+pub mod domain;
+pub mod infrastructure;
diff --git a/crates/xtop-tui/Cargo.toml b/crates/xtop-tui/Cargo.toml
new file mode 100644
index 0000000..dc9be70
--- /dev/null
+++ b/crates/xtop-tui/Cargo.toml
@@ -0,0 +1,10 @@
+[package]
+name = "xtop-tui"
+version.workspace = true
+edition.workspace = true
+license.workspace = true
+
+[dependencies]
+xtop-core = { path = "../xtop-core" }
+ratatui.workspace = true
+crossterm.workspace = true
diff --git a/crates/xtop-tui/src/format.rs b/crates/xtop-tui/src/format.rs
new file mode 100644
index 0000000..d3036f2
--- /dev/null
+++ b/crates/xtop-tui/src/format.rs
@@ -0,0 +1,81 @@
+pub fn format_bytes(bytes: u64) -> String {
+ const UNITS: &[&str] = &["B", "KB", "MB", "GB", "TB"];
+ let mut size = bytes as f64;
+ let mut unit_idx = 0;
+ while size >= 1024.0 && unit_idx < UNITS.len() - 1 {
+ size /= 1024.0;
+ unit_idx += 1;
+ }
+ format!("{:.2} {}", size, UNITS[unit_idx])
+}
+
+pub fn format_uptime(secs: u64) -> String {
+ let days = secs / 86400;
+ let hours = (secs % 86400) / 3600;
+ let minutes = (secs % 3600) / 60;
+ let seconds = secs % 60;
+ format!("{}d {}h {}m {}s", days, hours, minutes, seconds)
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn test_format_bytes_bytes() {
+ assert_eq!(format_bytes(0), "0.00 B");
+ assert_eq!(format_bytes(500), "500.00 B");
+ }
+
+ #[test]
+ fn test_format_bytes_kb() {
+ assert_eq!(format_bytes(1024), "1.00 KB");
+ assert_eq!(format_bytes(2048), "2.00 KB");
+ assert_eq!(format_bytes(1536), "1.50 KB");
+ }
+
+ #[test]
+ fn test_format_bytes_mb() {
+ assert_eq!(format_bytes(1048576), "1.00 MB");
+ assert_eq!(format_bytes(3145728), "3.00 MB");
+ }
+
+ #[test]
+ fn test_format_bytes_gb() {
+ assert_eq!(format_bytes(1073741824), "1.00 GB");
+ let two_gb = 2u64 * 1024 * 1024 * 1024;
+ assert_eq!(format_bytes(two_gb), "2.00 GB");
+ }
+
+ #[test]
+ fn test_format_bytes_tb() {
+ let one_tb = 1024u64 * 1024 * 1024 * 1024;
+ assert_eq!(format_bytes(one_tb), "1.00 TB");
+ }
+
+ #[test]
+ fn test_format_uptime_zero() {
+ assert_eq!(format_uptime(0), "0d 0h 0m 0s");
+ }
+
+ #[test]
+ fn test_format_uptime_full() {
+ let secs = 1 + 60 * 2 + 3600 * 3 + 86400 * 4; // 4d 3h 2m 1s
+ assert_eq!(format_uptime(secs), "4d 3h 2m 1s");
+ }
+
+ #[test]
+ fn test_format_uptime_seconds_only() {
+ assert_eq!(format_uptime(59), "0d 0h 0m 59s");
+ }
+
+ #[test]
+ fn test_format_uptime_exact_hour() {
+ assert_eq!(format_uptime(3600), "0d 1h 0m 0s");
+ }
+
+ #[test]
+ fn test_format_uptime_exact_day() {
+ assert_eq!(format_uptime(86400), "1d 0h 0m 0s");
+ }
+}
diff --git a/crates/xtop-tui/src/lib.rs b/crates/xtop-tui/src/lib.rs
new file mode 100644
index 0000000..53a287f
--- /dev/null
+++ b/crates/xtop-tui/src/lib.rs
@@ -0,0 +1,3 @@
+pub mod format;
+pub mod render;
+pub mod terminal;
diff --git a/crates/xtop-tui/src/render/battery.rs b/crates/xtop-tui/src/render/battery.rs
new file mode 100644
index 0000000..be5ccb4
--- /dev/null
+++ b/crates/xtop-tui/src/render/battery.rs
@@ -0,0 +1,59 @@
+use ratatui::prelude::*;
+use ratatui::widgets::{Block, Borders, Gauge, Paragraph, Wrap};
+use ratatui::Frame;
+use xtop_core::application::state::AppState;
+
+pub fn render(f: &mut Frame, state: &AppState, area: Rect) {
+ let rgb = |c: &[u8; 3]| Color::Rgb(c[0], c[1], c[2]);
+ let fg = rgb(state.current_theme.fg());
+ let bg = rgb(state.current_theme.bg());
+
+ let block = Block::default()
+ .title("Battery")
+ .borders(Borders::ALL)
+ .style(Style::default().fg(fg).bg(bg));
+ let inner = block.inner(area);
+ f.render_widget(block, area);
+
+ let snap = state.snapshot();
+ if snap.batteries.is_empty() {
+ let msg = Paragraph::new("No battery data available")
+ .style(Style::default().fg(fg))
+ .wrap(Wrap { trim: true });
+ f.render_widget(msg, inner);
+ return;
+ }
+
+ let chunks = Layout::default()
+ .direction(Direction::Vertical)
+ .constraints(vec![Constraint::Length(3); snap.batteries.len()])
+ .split(inner);
+
+ for (i, bat) in snap.batteries.iter().enumerate() {
+ if i >= chunks.len() {
+ break;
+ }
+ let time_info = match (bat.time_to_full, bat.time_to_empty) {
+ (Some(t), _) if bat.state == "Charging" => {
+ format!(" {}m to full", t / 60)
+ }
+ (_, Some(t)) if bat.state == "Discharging" => {
+ format!(" {}m remaining", t / 60)
+ }
+ _ => String::new(),
+ };
+ let label = format!(
+ "{} {:>3.0}% {} {}",
+ bat.name, bat.percentage, bat.state, time_info,
+ );
+ let gauge = Gauge::default()
+ .gauge_style(
+ Style::default()
+ .fg(rgb(&state.current_theme.palette[2]))
+ .bg(bg),
+ )
+ .percent(bat.percentage as u16)
+ .label(label);
+ f.render_widget(gauge, chunks[i]);
+ }
+}
diff --git a/crates/xtop-tui/src/render/cpu.rs b/crates/xtop-tui/src/render/cpu.rs
new file mode 100644
index 0000000..198c85e
--- /dev/null
+++ b/crates/xtop-tui/src/render/cpu.rs
@@ -0,0 +1,74 @@
+use ratatui::prelude::*;
+use ratatui::widgets::{Block, Borders, Gauge};
+use ratatui::Frame;
+use xtop_core::application::state::AppState;
+
+pub fn render(f: &mut Frame, state: &AppState, area: Rect) {
+ let rgb = |c: &[u8; 3]| Color::Rgb(c[0], c[1], c[2]);
+ let fg = rgb(state.current_theme.fg());
+ let bg = rgb(state.current_theme.bg());
+ let snap = state.snapshot();
+
+ let title = if snap.cpu_temp > 0.0 {
+ format!("CPU (Max: {:.1}°C)", snap.cpu_temp)
+ } else {
+ "CPU".to_string()
+ };
+
+ let block = Block::default()
+ .title(title)
+ .borders(Borders::ALL)
+ .style(Style::default().fg(fg).bg(bg));
+ let inner = block.inner(area);
+ f.render_widget(block, area);
+
+ if snap.cpus.is_empty() {
+ return;
+ }
+
+ let count = snap.cpus.len();
+ let cols = if inner.width > 40 { 2 } else { 1 };
+ let col_constraints = if cols == 2 {
+ vec![Constraint::Percentage(50); 2]
+ } else {
+ vec![Constraint::Percentage(100)]
+ };
+ let col_areas = Layout::default()
+ .direction(Direction::Horizontal)
+ .constraints(col_constraints)
+ .split(inner);
+
+ let per_col = count.div_ceil(cols);
+
+ for (col_idx, col_area) in col_areas.iter().enumerate() {
+ let start = col_idx * per_col;
+ let end = (start + per_col).min(count);
+
+ let rows = Layout::default()
+ .direction(Direction::Vertical)
+ .constraints(vec![Constraint::Length(1); end - start])
+ .split(*col_area);
+
+ for (i, row_area) in rows.iter().enumerate() {
+ let cpu_idx = start + i;
+ if cpu_idx >= count {
+ break;
+ }
+ let cpu = &snap.cpus[cpu_idx];
+ let usage = cpu.usage;
+ let is_alert = usage > state.alerts.cpu_high;
+ let label = format!("CPU{:<2} {:>3.0}%", cpu.cpu_id, usage);
+ let color_idx = if is_alert { 1 } else { 1 + (cpu.cpu_id % 6) };
+
+ let gauge = Gauge::default()
+ .gauge_style(
+ Style::default()
+ .fg(rgb(&state.current_theme.palette[color_idx]))
+ .bg(bg),
+ )
+ .percent(usage as u16)
+ .label(label);
+ f.render_widget(gauge, *row_area);
+ }
+ }
+}
diff --git a/crates/xtop-tui/src/render/disk_io.rs b/crates/xtop-tui/src/render/disk_io.rs
new file mode 100644
index 0000000..2d102a1
--- /dev/null
+++ b/crates/xtop-tui/src/render/disk_io.rs
@@ -0,0 +1,53 @@
+use crate::format::format_bytes;
+use ratatui::prelude::*;
+use ratatui::widgets::{Block, Borders, Gauge};
+use ratatui::Frame;
+use xtop_core::application::state::AppState;
+
+pub fn render(f: &mut Frame, state: &AppState, area: Rect) {
+ let rgb = |c: &[u8; 3]| Color::Rgb(c[0], c[1], c[2]);
+ let fg = rgb(state.current_theme.fg());
+ let bg = rgb(state.current_theme.bg());
+
+ let block = Block::default()
+ .title("Disk I/O")
+ .borders(Borders::ALL)
+ .style(Style::default().fg(fg).bg(bg));
+ let inner = block.inner(area);
+ f.render_widget(block, area);
+
+ let snap = state.snapshot();
+ if snap.disk_io.is_empty() {
+ return;
+ }
+
+ let per_disk = inner.height.min(3);
+ let constraints = vec![Constraint::Length(per_disk); snap.disk_io.len()];
+ let chunks = Layout::default()
+ .direction(Direction::Vertical)
+ .constraints(constraints)
+ .split(inner);
+
+ for (i, d) in snap.disk_io.iter().enumerate() {
+ if i >= chunks.len() {
+ break;
+ }
+ let total_read = format_bytes(d.read_bytes);
+ let total_write = format_bytes(d.write_bytes);
+ let read_speed = format_bytes(d.read_speed as u64);
+ let write_speed = format_bytes(d.write_speed as u64);
+ let label = format!(
+ "{} R: {}/s W: {}/s Tot R: {} Tot W: {}",
+ d.name, read_speed, write_speed, total_read, total_write,
+ );
+ let gauge = Gauge::default()
+ .gauge_style(
+ Style::default()
+ .fg(rgb(&state.current_theme.palette[4]))
+ .bg(bg),
+ )
+ .percent(50)
+ .label(label);
+ f.render_widget(gauge, chunks[i]);
+ }
+}
diff --git a/crates/xtop-tui/src/render/gpu.rs b/crates/xtop-tui/src/render/gpu.rs
new file mode 100644
index 0000000..ff5b35a
--- /dev/null
+++ b/crates/xtop-tui/src/render/gpu.rs
@@ -0,0 +1,55 @@
+use crate::format::format_bytes;
+use ratatui::prelude::*;
+use ratatui::widgets::{Block, Borders, Gauge, Paragraph, Wrap};
+use ratatui::Frame;
+use xtop_core::application::state::AppState;
+
+pub fn render(f: &mut Frame, state: &AppState, area: Rect) {
+ let rgb = |c: &[u8; 3]| Color::Rgb(c[0], c[1], c[2]);
+ let fg = rgb(state.current_theme.fg());
+ let bg = rgb(state.current_theme.bg());
+
+ let block = Block::default()
+ .title("GPU")
+ .borders(Borders::ALL)
+ .style(Style::default().fg(fg).bg(bg));
+ let inner = block.inner(area);
+ f.render_widget(block, area);
+
+ let snap = state.snapshot();
+ if snap.gpus.is_empty() {
+ let msg = Paragraph::new("No GPU data available")
+ .style(Style::default().fg(fg))
+ .wrap(Wrap { trim: true });
+ f.render_widget(msg, inner);
+ return;
+ }
+
+ let chunks = Layout::default()
+ .direction(Direction::Vertical)
+ .constraints(vec![Constraint::Length(3); snap.gpus.len()])
+ .split(inner);
+
+ for (i, gpu) in snap.gpus.iter().enumerate() {
+ if i >= chunks.len() {
+ break;
+ }
+ let label = format!(
+ "{} {:>3.0}% Mem: {} / {} Temp: {:.1}°C",
+ gpu.name,
+ gpu.usage,
+ format_bytes(gpu.memory_used),
+ format_bytes(gpu.memory_total),
+ gpu.temperature,
+ );
+ let gauge = Gauge::default()
+ .gauge_style(
+ Style::default()
+ .fg(rgb(&state.current_theme.palette[5]))
+ .bg(bg),
+ )
+ .percent(gpu.usage as u16)
+ .label(label);
+ f.render_widget(gauge, chunks[i]);
+ }
+}
diff --git a/crates/xtop-tui/src/render/header.rs b/crates/xtop-tui/src/render/header.rs
new file mode 100644
index 0000000..6c30046
--- /dev/null
+++ b/crates/xtop-tui/src/render/header.rs
@@ -0,0 +1,40 @@
+use crate::format::format_uptime;
+use ratatui::prelude::*;
+use ratatui::widgets::{Block, Borders, Paragraph};
+use ratatui::Frame;
+use xtop_core::application::state::{AppState, FullScreenWidget, InputMode};
+
+pub fn render(f: &mut Frame, state: &AppState, area: Rect) {
+ let rgb = |c: &[u8; 3]| Color::Rgb(c[0], c[1], c[2]);
+ let fg = rgb(state.current_theme.fg());
+ let bg = rgb(state.current_theme.bg());
+
+ let load = state.snapshot().load_avg;
+ let uptime = state.snapshot().uptime;
+
+ let mode_str = state.layout_mode.label();
+
+ let mut extras = String::new();
+ if state.full_screen_widget != FullScreenWidget::None {
+ extras.push_str(&format!(" [Full: {}]", state.full_screen_widget.label()));
+ }
+ if state.input_mode == InputMode::Searching {
+ extras.push_str(" [/] Search");
+ }
+
+ let text = format!(
+ "xtop | Theme: {} | Layout: {}{} | Uptime: {} | Load: {:.2} {:.2} {:.2} | [q] Quit [?] Help [t] Theme [l] Layout [f] Full [/] Search",
+ state.current_theme.name,
+ mode_str,
+ extras,
+ format_uptime(uptime),
+ load.one,
+ load.five,
+ load.fifteen,
+ );
+
+ let p = Paragraph::new(text)
+ .style(Style::default().fg(fg).bg(bg))
+ .block(Block::default().borders(Borders::ALL).title("System Info"));
+ f.render_widget(p, area);
+}
diff --git a/crates/xtop-tui/src/render/help.rs b/crates/xtop-tui/src/render/help.rs
new file mode 100644
index 0000000..de9c8a5
--- /dev/null
+++ b/crates/xtop-tui/src/render/help.rs
@@ -0,0 +1,51 @@
+use ratatui::prelude::*;
+use ratatui::widgets::{Block, Borders, Paragraph, Wrap};
+use ratatui::Frame;
+use xtop_core::application::state::AppState;
+
+pub fn render(f: &mut Frame, state: &AppState, area: Rect) {
+ let rgb = |c: &[u8; 3]| Color::Rgb(c[0], c[1], c[2]);
+ let fg = rgb(state.current_theme.fg());
+ let bg = rgb(state.current_theme.bg());
+
+ let text = vec![
+ Line::from(""),
+ Line::from(vec![Span::styled(
+ " Keybindings",
+ Style::default().add_modifier(Modifier::BOLD),
+ )]),
+ Line::from(""),
+ Line::from(" ─────────────────────────────────────────────"),
+ Line::from(" q Quit application"),
+ Line::from(" ? Toggle this help screen"),
+ Line::from(""),
+ Line::from(" t Next color theme"),
+ Line::from(" T Previous color theme"),
+ Line::from(" l Next layout mode"),
+ Line::from(" f Toggle fullscreen widget"),
+ Line::from(" F Cycle fullscreen widget"),
+ Line::from(""),
+ Line::from(" / Search/filter processes"),
+ Line::from(" Esc Cancel search / close help"),
+ Line::from(""),
+ Line::from(" Layout modes:"),
+ Line::from(format!(" Current: {}", state.layout_mode.label())),
+ Line::from(" Dashboard | Vertical | Horizontal | CPU Focus"),
+ Line::from(" Memory Focus | Network Focus | Process Focus"),
+ Line::from(""),
+ Line::from(" ─────────────────────────────────────────────"),
+ Line::from(""),
+ Line::from(" https://github.com/xscriptor/xtop"),
+ Line::from(""),
+ ];
+
+ let block = Block::default()
+ .title("Help")
+ .borders(Borders::ALL)
+ .style(Style::default().fg(fg).bg(bg));
+ let p = Paragraph::new(text)
+ .block(block)
+ .style(Style::default().fg(fg).bg(bg))
+ .wrap(Wrap { trim: false });
+ f.render_widget(p, area);
+}
diff --git a/crates/xtop-tui/src/render/memory.rs b/crates/xtop-tui/src/render/memory.rs
new file mode 100644
index 0000000..2528156
--- /dev/null
+++ b/crates/xtop-tui/src/render/memory.rs
@@ -0,0 +1,129 @@
+use crate::format::format_bytes;
+use ratatui::prelude::*;
+use ratatui::widgets::{Axis, Block, Borders, Chart, Dataset, Gauge, GraphType};
+use ratatui::Frame;
+use xtop_core::application::state::AppState;
+
+pub fn render(f: &mut Frame, state: &AppState, area: Rect) {
+ let rgb = |c: &[u8; 3]| Color::Rgb(c[0], c[1], c[2]);
+ let fg = rgb(state.current_theme.fg());
+ let bg = rgb(state.current_theme.bg());
+ let snap = state.snapshot();
+
+ let mem_alert = snap.memory.percent > state.alerts.mem_high;
+ let mem_color_idx = if mem_alert { 1 } else { 2 };
+
+ let mut title = "Memory".to_string();
+ if mem_alert {
+ title = format!("Memory ⚠ {:.0}%", snap.memory.percent);
+ }
+
+ let block = Block::default()
+ .title(title)
+ .borders(Borders::ALL)
+ .style(Style::default().fg(fg).bg(bg));
+ let inner = block.inner(area);
+ f.render_widget(block, area);
+
+ let has_chart_area = inner.height > 7;
+ if has_chart_area {
+ let chunks = Layout::default()
+ .direction(Direction::Vertical)
+ .constraints([
+ Constraint::Length(3),
+ Constraint::Length(3),
+ Constraint::Min(0),
+ ])
+ .split(inner);
+
+ render_ram_gauge(f, state, chunks[0], &snap, bg, mem_color_idx);
+ render_swap_gauge(f, state, chunks[1], &snap, bg);
+ render_chart(f, state, chunks[2], bg);
+ } else {
+ let chunks = Layout::default()
+ .direction(Direction::Vertical)
+ .constraints([Constraint::Length(3), Constraint::Length(3)])
+ .split(inner);
+ render_ram_gauge(f, state, chunks[0], &snap, bg, mem_color_idx);
+ render_swap_gauge(f, state, chunks[1], &snap, bg);
+ }
+}
+
+fn render_ram_gauge(
+ f: &mut Frame,
+ state: &AppState,
+ area: Rect,
+ snap: &xtop_core::domain::metrics::SystemSnapshot,
+ bg: Color,
+ color_idx: usize,
+) {
+ let rgb = |c: &[u8; 3]| Color::Rgb(c[0], c[1], c[2]);
+ let mem_pct = snap.memory.percent as u16;
+ let label = format!(
+ "RAM: {} / {} ({:>3.0}%)",
+ format_bytes(snap.memory.used),
+ format_bytes(snap.memory.total),
+ snap.memory.percent,
+ );
+ let gauge = Gauge::default()
+ .gauge_style(
+ Style::default()
+ .fg(rgb(&state.current_theme.palette[color_idx]))
+ .bg(bg),
+ )
+ .percent(mem_pct)
+ .label(label);
+ f.render_widget(gauge, area);
+}
+
+fn render_swap_gauge(
+ f: &mut Frame,
+ state: &AppState,
+ area: Rect,
+ snap: &xtop_core::domain::metrics::SystemSnapshot,
+ bg: Color,
+) {
+ let rgb = |c: &[u8; 3]| Color::Rgb(c[0], c[1], c[2]);
+ let swap_pct = snap.swap.percent as u16;
+ let label = format!(
+ "SWP: {} / {} ({:>3.0}%)",
+ format_bytes(snap.swap.used),
+ format_bytes(snap.swap.total),
+ snap.swap.percent,
+ );
+ let gauge = Gauge::default()
+ .gauge_style(
+ Style::default()
+ .fg(rgb(&state.current_theme.palette[3]))
+ .bg(bg),
+ )
+ .percent(swap_pct)
+ .label(label);
+ f.render_widget(gauge, area);
+}
+
+fn render_chart(f: &mut Frame, state: &AppState, area: Rect, _bg: Color) {
+ let rgb = |c: &[u8; 3]| Color::Rgb(c[0], c[1], c[2]);
+
+ let mem_data: Vec<(f64, f64)> = state.history.mem.iter().copied().collect();
+ if mem_data.is_empty() {
+ return;
+ }
+
+ let datasets = vec![Dataset::default()
+ .name("RAM Usage")
+ .marker(symbols::Marker::Braille)
+ .graph_type(GraphType::Line)
+ .style(Style::default().fg(rgb(&state.current_theme.palette[2])))
+ .data(&mem_data)];
+
+ let x_min = mem_data.first().map(|&(x, _)| x).unwrap_or(0.0);
+ let x_max = mem_data.last().map(|&(x, _)| x).unwrap_or(100.0);
+ let x_max = x_max.max(x_min + 1.0);
+
+ let chart = Chart::new(datasets)
+ .block(Block::default().borders(Borders::TOP))
+ .x_axis(Axis::default().bounds([x_min, x_max]))
+ .y_axis(Axis::default().bounds([0.0, 100.0]));
+ f.render_widget(chart, area);
+}
diff --git a/crates/xtop-tui/src/render/mod.rs b/crates/xtop-tui/src/render/mod.rs
new file mode 100644
index 0000000..955fabe
--- /dev/null
+++ b/crates/xtop-tui/src/render/mod.rs
@@ -0,0 +1,355 @@
+mod battery;
+mod cpu;
+mod disk_io;
+mod gpu;
+mod header;
+mod help;
+mod memory;
+mod network;
+mod processes;
+mod storage;
+
+use ratatui::prelude::*;
+use ratatui::Frame;
+use xtop_core::application::state::{
+ detect_effective_layout, AppState, EffectiveLayout, FullScreenWidget, InputMode,
+};
+
+pub fn render(f: &mut Frame, state: &AppState) {
+ let area = f.area();
+
+ if area.width < 40 || area.height < 8 {
+ render_too_small(f, state, area);
+ return;
+ }
+
+ if state.show_help {
+ help::render(f, state, area);
+ return;
+ }
+
+ if state.full_screen_widget != FullScreenWidget::None {
+ render_fullscreen(f, state, area);
+ return;
+ }
+
+ let mode = detect_effective_layout(area.width, area.height, state.layout_mode);
+ match mode {
+ EffectiveLayout::Dashboard => render_dashboard(f, state, area),
+ EffectiveLayout::Compact => render_compact(f, state, area),
+ EffectiveLayout::Vertical => render_vertical(f, state, area),
+ EffectiveLayout::Horizontal => render_horizontal(f, state, area),
+ EffectiveLayout::CpuFocus => render_cpu_focus(f, state, area),
+ EffectiveLayout::MemoryFocus => render_memory_focus(f, state, area),
+ EffectiveLayout::NetworkFocus => render_network_focus(f, state, area),
+ EffectiveLayout::ProcessFocus => render_process_focus(f, state, area),
+ EffectiveLayout::Minimal => render_minimal(f, state, area),
+ }
+
+ if state.input_mode == InputMode::Searching {
+ render_search_overlay(f, state, area);
+ }
+}
+
+fn render_too_small(f: &mut Frame, state: &AppState, area: Rect) {
+ use ratatui::widgets::Paragraph;
+ let rgb = |c: &[u8; 3]| Color::Rgb(c[0], c[1], c[2]);
+ let fg = rgb(state.current_theme.fg());
+ let text = Paragraph::new("Terminal too small\nMinimum: 40x8").style(Style::default().fg(fg));
+ f.render_widget(text, area);
+}
+
+fn render_search_overlay(f: &mut Frame, state: &AppState, area: Rect) {
+ use ratatui::widgets::{Block, Borders, Paragraph};
+ let rgb = |c: &[u8; 3]| Color::Rgb(c[0], c[1], c[2]);
+ let fg = rgb(state.current_theme.fg());
+ let bg = rgb(state.current_theme.bg());
+ let search_text = format!("/{}_", state.search_query);
+ let overlay = Paragraph::new(search_text)
+ .style(Style::default().fg(fg).bg(bg))
+ .block(
+ Block::default()
+ .borders(Borders::ALL)
+ .title("Search Processes"),
+ );
+ let overlay_area = Rect {
+ x: area.width.saturating_sub(40) / 2,
+ y: area.height.saturating_sub(5) / 2,
+ width: 40.min(area.width),
+ height: 3.min(area.height),
+ };
+ f.render_widget(overlay, overlay_area);
+}
+
+fn render_fullscreen(f: &mut Frame, state: &AppState, area: Rect) {
+ let chunks = Layout::default()
+ .direction(Direction::Vertical)
+ .constraints([Constraint::Length(3), Constraint::Min(0)])
+ .split(area);
+ header::render(f, state, chunks[0]);
+ match state.full_screen_widget {
+ FullScreenWidget::Cpu => cpu::render(f, state, chunks[1]),
+ FullScreenWidget::Memory => memory::render(f, state, chunks[1]),
+ FullScreenWidget::Storage => storage::render(f, state, chunks[1]),
+ FullScreenWidget::Network => network::render(f, state, chunks[1]),
+ FullScreenWidget::Processes => processes::render(f, state, chunks[1]),
+ FullScreenWidget::DiskIO => disk_io::render(f, state, chunks[1]),
+ FullScreenWidget::Gpu => gpu::render(f, state, chunks[1]),
+ FullScreenWidget::Battery => battery::render(f, state, chunks[1]),
+ FullScreenWidget::None => {}
+ }
+}
+
+fn render_dashboard(f: &mut Frame, state: &AppState, area: Rect) {
+ let chunks = Layout::default()
+ .direction(Direction::Vertical)
+ .constraints([
+ Constraint::Length(3),
+ Constraint::Percentage(45),
+ Constraint::Percentage(52),
+ ])
+ .split(area);
+
+ header::render(f, state, chunks[0]);
+
+ let top = Layout::default()
+ .direction(Direction::Horizontal)
+ .constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
+ .split(chunks[1]);
+
+ cpu::render(f, state, top[0]);
+
+ let right = Layout::default()
+ .direction(Direction::Vertical)
+ .constraints([
+ Constraint::Percentage(33),
+ Constraint::Percentage(33),
+ Constraint::Percentage(34),
+ ])
+ .split(top[1]);
+
+ memory::render(f, state, right[0]);
+ storage::render(f, state, right[1]);
+ network::render(f, state, right[2]);
+
+ processes::render(f, state, chunks[2]);
+}
+
+fn render_compact(f: &mut Frame, state: &AppState, area: Rect) {
+ let chunks = Layout::default()
+ .direction(Direction::Vertical)
+ .constraints([
+ Constraint::Length(3),
+ Constraint::Percentage(50),
+ Constraint::Percentage(47),
+ ])
+ .split(area);
+
+ header::render(f, state, chunks[0]);
+
+ let top = Layout::default()
+ .direction(Direction::Horizontal)
+ .constraints([Constraint::Percentage(55), Constraint::Percentage(45)])
+ .split(chunks[1]);
+
+ cpu::render(f, state, top[0]);
+
+ let right = Layout::default()
+ .direction(Direction::Vertical)
+ .constraints([
+ Constraint::Percentage(40),
+ Constraint::Percentage(30),
+ Constraint::Percentage(30),
+ ])
+ .split(top[1]);
+
+ memory::render(f, state, right[0]);
+ storage::render(f, state, right[1]);
+ network::render(f, state, right[2]);
+
+ processes::render(f, state, chunks[2]);
+}
+
+fn render_vertical(f: &mut Frame, state: &AppState, area: Rect) {
+ let chunks = Layout::default()
+ .direction(Direction::Vertical)
+ .constraints([
+ Constraint::Length(3),
+ Constraint::Length(8),
+ Constraint::Length(8),
+ Constraint::Length(6),
+ Constraint::Length(5),
+ Constraint::Min(0),
+ ])
+ .split(area);
+
+ header::render(f, state, chunks[0]);
+ cpu::render(f, state, chunks[1]);
+ memory::render(f, state, chunks[2]);
+ storage::render(f, state, chunks[3]);
+ network::render(f, state, chunks[4]);
+ processes::render(f, state, chunks[5]);
+}
+
+fn render_horizontal(f: &mut Frame, state: &AppState, area: Rect) {
+ let chunks = Layout::default()
+ .direction(Direction::Vertical)
+ .constraints([Constraint::Length(3), Constraint::Min(0)])
+ .split(area);
+
+ header::render(f, state, chunks[0]);
+
+ let mid = Layout::default()
+ .direction(Direction::Horizontal)
+ .constraints([
+ Constraint::Percentage(25),
+ Constraint::Percentage(25),
+ Constraint::Percentage(25),
+ Constraint::Percentage(25),
+ ])
+ .split(chunks[1]);
+
+ cpu::render(f, state, mid[0]);
+ memory::render(f, state, mid[1]);
+ storage::render(f, state, mid[2]);
+ network::render(f, state, mid[3]);
+}
+
+fn render_cpu_focus(f: &mut Frame, state: &AppState, area: Rect) {
+ let chunks = Layout::default()
+ .direction(Direction::Vertical)
+ .constraints([
+ Constraint::Length(3),
+ Constraint::Percentage(60),
+ Constraint::Min(10),
+ ])
+ .split(area);
+
+ header::render(f, state, chunks[0]);
+ cpu::render(f, state, chunks[1]);
+ processes::render(f, state, chunks[2]);
+}
+
+fn render_memory_focus(f: &mut Frame, state: &AppState, area: Rect) {
+ let chunks = Layout::default()
+ .direction(Direction::Vertical)
+ .constraints([
+ Constraint::Length(3),
+ Constraint::Percentage(60),
+ Constraint::Min(10),
+ ])
+ .split(area);
+
+ header::render(f, state, chunks[0]);
+ memory::render(f, state, chunks[1]);
+ processes::render(f, state, chunks[2]);
+}
+
+fn render_network_focus(f: &mut Frame, state: &AppState, area: Rect) {
+ let chunks = Layout::default()
+ .direction(Direction::Vertical)
+ .constraints([
+ Constraint::Length(3),
+ Constraint::Percentage(50),
+ Constraint::Min(10),
+ ])
+ .split(area);
+
+ header::render(f, state, chunks[0]);
+
+ let mid = Layout::default()
+ .direction(Direction::Horizontal)
+ .constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
+ .split(chunks[1]);
+
+ network::render(f, state, mid[0]);
+ disk_io::render(f, state, mid[1]);
+
+ processes::render(f, state, chunks[2]);
+}
+
+fn render_process_focus(f: &mut Frame, state: &AppState, area: Rect) {
+ let chunks = Layout::default()
+ .direction(Direction::Vertical)
+ .constraints([
+ Constraint::Length(3),
+ Constraint::Length(8),
+ Constraint::Min(0),
+ ])
+ .split(area);
+
+ header::render(f, state, chunks[0]);
+
+ let stats = Layout::default()
+ .direction(Direction::Horizontal)
+ .constraints([
+ Constraint::Percentage(25),
+ Constraint::Percentage(25),
+ Constraint::Percentage(25),
+ Constraint::Percentage(25),
+ ])
+ .split(chunks[1]);
+
+ cpu::render(f, state, stats[0]);
+ memory::render(f, state, stats[1]);
+ storage::render(f, state, stats[2]);
+ network::render(f, state, stats[3]);
+
+ processes::render(f, state, chunks[2]);
+}
+
+fn render_minimal(f: &mut Frame, state: &AppState, area: Rect) {
+ use ratatui::widgets::Gauge;
+ let rgb = |c: &[u8; 3]| Color::Rgb(c[0], c[1], c[2]);
+ let bg = rgb(state.current_theme.bg());
+
+ let chunks = Layout::default()
+ .direction(Direction::Vertical)
+ .constraints([
+ Constraint::Length(3),
+ Constraint::Length(2),
+ Constraint::Length(2),
+ Constraint::Min(0),
+ ])
+ .split(area);
+
+ header::render(f, state, chunks[0]);
+
+ let snap = state.snapshot();
+ let cpu_pct = snap.cpus.first().map(|c| c.usage).unwrap_or(0.0);
+ let cpu_text = format!(
+ "CPU: {:>3.0}% | Mem: {:.1}/{:.1}G ({:>3.0}%)",
+ cpu_pct,
+ snap.memory.used as f64 / 1073741824.0,
+ snap.memory.total as f64 / 1073741824.0,
+ snap.memory.percent,
+ );
+ let cpu_gauge = Gauge::default()
+ .gauge_style(
+ Style::default()
+ .fg(rgb(&state.current_theme.palette[1]))
+ .bg(bg),
+ )
+ .percent(cpu_pct as u16)
+ .label(cpu_text);
+ f.render_widget(cpu_gauge, chunks[1]);
+
+ let mem_pct = snap.memory.percent as u16;
+ let mem_text = format!(
+ "Mem: {:.1}/{:.1}G ({:>3.0}%)",
+ snap.memory.used as f64 / 1073741824.0,
+ snap.memory.total as f64 / 1073741824.0,
+ snap.memory.percent,
+ );
+ let mem_gauge = Gauge::default()
+ .gauge_style(
+ Style::default()
+ .fg(rgb(&state.current_theme.palette[2]))
+ .bg(bg),
+ )
+ .percent(mem_pct)
+ .label(mem_text);
+ f.render_widget(mem_gauge, chunks[2]);
+
+ processes::render(f, state, chunks[3]);
+}
diff --git a/crates/xtop-tui/src/render/network.rs b/crates/xtop-tui/src/render/network.rs
new file mode 100644
index 0000000..9dd85c5
--- /dev/null
+++ b/crates/xtop-tui/src/render/network.rs
@@ -0,0 +1,42 @@
+use crate::format::format_bytes;
+use ratatui::prelude::*;
+use ratatui::widgets::{Block, Borders, Paragraph, Wrap};
+use ratatui::Frame;
+use xtop_core::application::state::AppState;
+
+pub fn render(f: &mut Frame, state: &AppState, area: Rect) {
+ let rgb = |c: &[u8; 3]| Color::Rgb(c[0], c[1], c[2]);
+ let fg = rgb(state.current_theme.fg());
+ let bg = rgb(state.current_theme.bg());
+
+ let block = Block::default()
+ .title("Network")
+ .borders(Borders::ALL)
+ .style(Style::default().fg(fg).bg(bg));
+ let inner = block.inner(area);
+ f.render_widget(block, area);
+
+ let snap = state.snapshot();
+ let total_rx: u64 = snap.networks.iter().map(|n| n.received).sum();
+ let total_tx: u64 = snap.networks.iter().map(|n| n.transmitted).sum();
+
+ let text = vec![
+ Line::from(vec![
+ Span::styled("Total RX: ", Style::default().fg(fg)),
+ Span::styled(
+ format_bytes(total_rx),
+ Style::default().fg(rgb(&state.current_theme.palette[4])),
+ ),
+ ]),
+ Line::from(vec![
+ Span::styled("Total TX: ", Style::default().fg(fg)),
+ Span::styled(
+ format_bytes(total_tx),
+ Style::default().fg(rgb(&state.current_theme.palette[5])),
+ ),
+ ]),
+ ];
+
+ let p = Paragraph::new(text).wrap(Wrap { trim: true });
+ f.render_widget(p, inner);
+}
diff --git a/crates/xtop-tui/src/render/processes.rs b/crates/xtop-tui/src/render/processes.rs
new file mode 100644
index 0000000..a3ada26
--- /dev/null
+++ b/crates/xtop-tui/src/render/processes.rs
@@ -0,0 +1,87 @@
+use ratatui::prelude::*;
+use ratatui::widgets::{Block, Borders, Cell, Row, Table};
+use ratatui::Frame;
+use xtop_core::application::state::AppState;
+
+pub fn render(f: &mut Frame, state: &AppState, area: Rect) {
+ let rgb = |c: &[u8; 3]| Color::Rgb(c[0], c[1], c[2]);
+ let fg = rgb(state.current_theme.fg());
+ let bg = rgb(state.current_theme.bg());
+
+ let mut title = "Processes".to_string();
+ if !state.search_query.is_empty() {
+ title = format!("Processes (filter: {})", state.search_query);
+ }
+
+ let block = Block::default()
+ .title(title)
+ .borders(Borders::ALL)
+ .style(Style::default().fg(fg).bg(bg));
+ let inner = block.inner(area);
+ f.render_widget(block, area);
+
+ let snap = state.snapshot();
+ let separator = Span::styled(
+ " | ",
+ Style::default().fg(rgb(&state.current_theme.palette[8])),
+ );
+
+ let iter: Box> =
+ if state.search_query.is_empty() {
+ Box::new(snap.processes.iter())
+ } else {
+ let q = state.search_query.to_lowercase();
+ Box::new(
+ snap.processes
+ .iter()
+ .filter(move |p| p.name.to_lowercase().contains(&q)),
+ )
+ };
+
+ let rows: Vec = iter
+ .map(|p| {
+ Row::new(vec![
+ Cell::from(Line::from(vec![
+ Span::raw(p.pid.to_string()),
+ separator.clone(),
+ ])),
+ Cell::from(Line::from(vec![
+ Span::raw(p.name.clone()),
+ separator.clone(),
+ ])),
+ Cell::from(Line::from(vec![
+ Span::raw(format!("{:.1}%", p.cpu_usage)),
+ separator.clone(),
+ ])),
+ Cell::from(Line::from(vec![
+ Span::raw(crate::format::format_bytes(p.memory)),
+ separator.clone(),
+ ])),
+ Cell::from(p.user_id.clone().unwrap_or_else(|| "?".to_string())),
+ ])
+ .style(Style::default().fg(fg))
+ })
+ .collect();
+
+ let widths = [
+ Constraint::Length(10),
+ Constraint::Percentage(40),
+ Constraint::Length(12),
+ Constraint::Length(17),
+ Constraint::Length(10),
+ ];
+
+ let table = Table::new(rows, widths)
+ .header(
+ Row::new(vec!["PID |", "Name |", "CPU% |", "Mem |", "User"])
+ .style(
+ Style::default()
+ .fg(rgb(&state.current_theme.palette[6]))
+ .add_modifier(Modifier::BOLD),
+ )
+ .bottom_margin(1),
+ )
+ .row_highlight_style(Style::default().add_modifier(Modifier::REVERSED));
+
+ f.render_widget(table, inner);
+}
diff --git a/crates/xtop-tui/src/render/storage.rs b/crates/xtop-tui/src/render/storage.rs
new file mode 100644
index 0000000..213c3ee
--- /dev/null
+++ b/crates/xtop-tui/src/render/storage.rs
@@ -0,0 +1,53 @@
+use crate::format::format_bytes;
+use ratatui::prelude::*;
+use ratatui::widgets::{Block, Borders, Gauge};
+use ratatui::Frame;
+use xtop_core::application::state::AppState;
+
+pub fn render(f: &mut Frame, state: &AppState, area: Rect) {
+ let rgb = |c: &[u8; 3]| Color::Rgb(c[0], c[1], c[2]);
+ let fg = rgb(state.current_theme.fg());
+ let bg = rgb(state.current_theme.bg());
+
+ let block = Block::default()
+ .title("Storage")
+ .borders(Borders::ALL)
+ .style(Style::default().fg(fg).bg(bg));
+ let inner = block.inner(area);
+ f.render_widget(block, area);
+
+ let snap = state.snapshot();
+ let disks = &snap.disks;
+ if disks.is_empty() {
+ return;
+ }
+
+ let per_disk = inner.height.min(3);
+ let constraints = vec![Constraint::Length(per_disk); disks.len()];
+ let chunks = Layout::default()
+ .direction(Direction::Vertical)
+ .constraints(constraints)
+ .split(inner);
+
+ for (i, disk) in disks.iter().enumerate() {
+ if i >= chunks.len() {
+ break;
+ }
+ let label = format!(
+ "{} Tot: {} Use: {} Free: {}",
+ disk.mount_point,
+ format_bytes(disk.total_space),
+ format_bytes(disk.used_space),
+ format_bytes(disk.available_space),
+ );
+ let gauge = Gauge::default()
+ .gauge_style(
+ Style::default()
+ .fg(rgb(&state.current_theme.palette[4]))
+ .bg(bg),
+ )
+ .percent(disk.percent as u16)
+ .label(label);
+ f.render_widget(gauge, chunks[i]);
+ }
+}
diff --git a/src/tui.rs b/crates/xtop-tui/src/terminal.rs
similarity index 86%
rename from src/tui.rs
rename to crates/xtop-tui/src/terminal.rs
index 03718b5..37df6e7 100644
--- a/src/tui.rs
+++ b/crates/xtop-tui/src/terminal.rs
@@ -1,10 +1,10 @@
-use std::io::{self, Stdout};
-use ratatui::{backend::CrosstermBackend, Terminal};
use crossterm::{
- event::{EnableMouseCapture, DisableMouseCapture},
+ event::{DisableMouseCapture, EnableMouseCapture},
execute,
- terminal::{enable_raw_mode, disable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
+ terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
};
+use ratatui::{backend::CrosstermBackend, Terminal};
+use std::io::{self, Stdout};
use std::panic;
pub type Tui = Terminal>;
diff --git a/src/app.rs b/src/app.rs
deleted file mode 100644
index 3477216..0000000
--- a/src/app.rs
+++ /dev/null
@@ -1,183 +0,0 @@
-use crate::theme::{Theme, get_themes};
-use sysinfo::{System, RefreshKind, CpuRefreshKind, MemoryRefreshKind, Disks, Networks, ProcessRefreshKind, Components};
-use std::collections::HashMap;
-use ratatui::widgets::TableState;
-
-#[allow(dead_code)]
-pub enum InputMode {
- Normal,
- Editing,
-}
-
-#[derive(Clone, Copy, PartialEq)]
-pub enum LayoutMode {
- Dashboard, // The current default split view
- Vertical, // Everything stacked vertically
- ProcessFocus, // Focus mainly on processes
-}
-
-impl LayoutMode {
- pub fn next(&self) -> Self {
- match self {
- LayoutMode::Dashboard => LayoutMode::Vertical,
- LayoutMode::Vertical => LayoutMode::ProcessFocus,
- LayoutMode::ProcessFocus => LayoutMode::Dashboard,
- }
- }
-}
-
-pub struct App {
- pub sys: System,
- pub disks: Disks,
- pub networks: Networks,
- pub components: Components,
- pub themes: HashMap,
- pub current_theme: Theme,
- pub should_quit: bool,
- pub theme_list: Vec,
- pub selected_theme_index: usize,
- #[allow(dead_code)]
- pub show_help: bool,
- pub layout_mode: LayoutMode,
-
- // History Data for Charts
- pub cpu_history: Vec>,
- pub mem_history: Vec<(f64, f64)>,
- #[allow(dead_code)]
- pub swap_history: Vec<(f64, f64)>,
- pub net_rx_history: Vec<(f64, f64)>,
- pub net_tx_history: Vec<(f64, f64)>,
- pub tick_count: f64,
-
- // UI States
- #[allow(dead_code)]
- pub process_table_state: TableState,
-}
-
-impl App {
- pub fn new() -> App {
- let themes = get_themes();
- let mut theme_list: Vec = themes.keys().cloned().collect();
- theme_list.sort();
-
- let default_theme_name = "x";
- let current_theme = themes.get(default_theme_name).cloned().unwrap_or_else(|| {
- themes.values().next().unwrap().clone()
- });
-
- let selected_theme_index = theme_list.iter().position(|r| r == ¤t_theme.name).unwrap_or(0);
-
- let sys = System::new_with_specifics(
- RefreshKind::nothing()
- .with_cpu(CpuRefreshKind::everything())
- .with_memory(MemoryRefreshKind::everything())
- .with_processes(ProcessRefreshKind::everything())
- );
- let disks = Disks::new_with_refreshed_list();
- let networks = Networks::new_with_refreshed_list();
- let components = Components::new_with_refreshed_list();
-
- let mut process_table_state = TableState::default();
- process_table_state.select(Some(0));
-
- App {
- sys,
- disks,
- networks,
- components,
- themes,
- current_theme,
- should_quit: false,
- theme_list,
- selected_theme_index,
- show_help: false,
- layout_mode: LayoutMode::Dashboard,
- cpu_history: vec![],
- mem_history: vec![],
- swap_history: vec![],
- net_rx_history: vec![],
- net_tx_history: vec![],
- tick_count: 0.0,
- process_table_state,
- }
- }
-
- pub fn on_tick(&mut self) {
- self.sys.refresh_all();
- self.disks.refresh(true);
- self.networks.refresh(true);
- self.components.refresh(true);
-
- self.tick_count += 1.0;
- let x = self.tick_count;
-
- // Update CPU History
- let cpus = self.sys.cpus();
- if self.cpu_history.len() != cpus.len() {
- self.cpu_history = vec![vec![]; cpus.len()];
- }
- for (i, cpu) in cpus.iter().enumerate() {
- self.cpu_history[i].push((x, cpu.cpu_usage() as f64));
- if self.cpu_history[i].len() > 100 {
- self.cpu_history[i].remove(0);
- }
- }
-
- // Update Memory History
- let total_mem = self.sys.total_memory() as f64;
- let used_mem = self.sys.used_memory() as f64;
- let mem_usage = if total_mem > 0.0 { used_mem / total_mem * 100.0 } else { 0.0 };
- self.mem_history.push((x, mem_usage));
- if self.mem_history.len() > 100 {
- self.mem_history.remove(0);
- }
-
- // Update Net History
- let mut total_rx = 0;
- let mut total_tx = 0;
- for (_, network) in &self.networks {
- total_rx += network.received();
- total_tx += network.transmitted();
- }
-
- self.net_rx_history.push((x, total_rx as f64));
- self.net_tx_history.push((x, total_tx as f64));
- if self.net_rx_history.len() > 100 {
- self.net_rx_history.remove(0);
- self.net_tx_history.remove(0);
- }
- }
-
- pub fn next_theme(&mut self) {
- if self.selected_theme_index >= self.theme_list.len() - 1 {
- self.selected_theme_index = 0;
- } else {
- self.selected_theme_index += 1;
- }
- self.apply_theme();
- }
-
- pub fn previous_theme(&mut self) {
- if self.selected_theme_index == 0 {
- self.selected_theme_index = self.theme_list.len() - 1;
- } else {
- self.selected_theme_index -= 1;
- }
- self.apply_theme();
- }
-
- fn apply_theme(&mut self) {
- let theme_name = &self.theme_list[self.selected_theme_index];
- if let Some(theme) = self.themes.get(theme_name) {
- self.current_theme = theme.clone();
- }
- }
-
- pub fn next_layout(&mut self) {
- self.layout_mode = self.layout_mode.next();
- }
-
- pub fn quit(&mut self) {
- self.should_quit = true;
- }
-}
diff --git a/src/main.rs b/src/main.rs
deleted file mode 100644
index 08ffbaf..0000000
--- a/src/main.rs
+++ /dev/null
@@ -1,49 +0,0 @@
-mod app;
-mod theme;
-mod tui;
-mod ui;
-
-use std::{error::Error, time::Duration};
-use crossterm::event::{self, Event, KeyCode};
-use app::App;
-
-#[tokio::main]
-async fn main() -> Result<(), Box> {
- tui::install_panic_hook();
- let mut terminal = tui::init()?;
- let mut app = App::new();
- let tick_rate = Duration::from_millis(1000); // 1 second update
- let mut last_tick = std::time::Instant::now();
-
- loop {
- terminal.draw(|f| ui::ui(f, &app))?;
-
- let timeout = tick_rate
- .checked_sub(last_tick.elapsed())
- .unwrap_or_else(|| Duration::from_secs(0));
-
- if crossterm::event::poll(timeout)? {
- if let Event::Key(key) = event::read()? {
- match key.code {
- KeyCode::Char('q') => app.quit(),
- KeyCode::Char('t') => app.next_theme(),
- KeyCode::Char('T') => app.previous_theme(),
- KeyCode::Char('l') => app.next_layout(),
- _ => {}
- }
- }
- }
-
- if last_tick.elapsed() >= tick_rate {
- app.on_tick();
- last_tick = std::time::Instant::now();
- }
-
- if app.should_quit {
- break;
- }
- }
-
- tui::restore()?;
- Ok(())
-}
diff --git a/src/theme.rs b/src/theme.rs
deleted file mode 100644
index 119a65e..0000000
--- a/src/theme.rs
+++ /dev/null
@@ -1,112 +0,0 @@
-use ratatui::style::Color;
-use std::collections::HashMap;
-
-#[derive(Clone, Debug)]
-pub struct Theme {
- pub name: String,
- pub palette: [Color; 16],
-}
-
-impl Theme {
- pub fn bg(&self) -> Color {
- self.palette[0]
- }
-
- pub fn fg(&self) -> Color {
- self.palette[7]
- }
-
- #[allow(dead_code)]
- pub fn graph_colors(&self) -> Vec {
- vec![
- self.palette[1], // Red
- self.palette[2], // Green
- self.palette[3], // Yellow
- self.palette[4], // Blue
- self.palette[5], // Magenta
- self.palette[6], // Cyan
- ]
- }
-}
-
-fn hex_to_color(hex: &str) -> Color {
- let hex = hex.trim_start_matches('#');
- let r = u8::from_str_radix(&hex[0..2], 16).unwrap_or(0);
- let g = u8::from_str_radix(&hex[2..4], 16).unwrap_or(0);
- let b = u8::from_str_radix(&hex[4..6], 16).unwrap_or(0);
- Color::Rgb(r, g, b)
-}
-
-pub fn get_themes() -> HashMap {
- let mut themes = HashMap::new();
-
- let definitions = vec![
- ("x", vec![
- "#363537", "#fc618d", "#7bd88f", "#fce566", "#fd9353", "#948ae3", "#5ad4e6", "#f7f1ff",
- "#69676c", "#fc618d", "#7bd88f", "#fce566", "#fd9353", "#948ae3", "#5ad4e6", "#f7f1ff"
- ]),
- ("madrid", vec![
- "#333333", "#cc0033", "#009933", "#b8860b", "#0099cc", "#6633cc", "#0099cc", "#1a1a1a",
- "#666666", "#cc0033", "#009933", "#b8860b", "#0099cc", "#6633cc", "#0099cc", "#1a1a1a"
- ]),
- ("lahabana", vec![
- "#363537", "#fc618d", "#7bd88f", "#e5ff9d", "#fd9353", "#948ae3", "#5ad4e6", "#f7f1ff",
- "#69676c", "#fc618d", "#7bd88f", "#e5ff9d", "#fd9353", "#948ae3", "#5ad4e6", "#f7f1ff"
- ]),
- ("seul", vec![
- "#1b1b1b", "#FF4C8B", "#7FFFD4", "#FFD84C", "#00FFA8", "#D36CFF", "#47CFFF", "#f7f1ff",
- "#69676c", "#FF4C8B", "#7FFFD4", "#FFD84C", "#00FFA8", "#D36CFF", "#47CFFF", "#f7f1ff"
- ]),
- ("miami", vec![
- "#000000", "#FF4C8B", "#7FFFD4", "#FFD84C", "#00FFA8", "#D36CFF", "#47CFFF", "#f7f1ff",
- "#69676c", "#FF4C8B", "#7FFFD4", "#FFD84C", "#00FFA8", "#D36CFF", "#47CFFF", "#f7f1ff"
- ]),
- ("paris", vec![
- "#222222", "#fc618d", "#7bd88f", "#fce566", "#a3f3ff", "#c4bdff", "#a3f3ff", "#f7f1ff",
- "#525053", "#fc618d", "#7bd88f", "#fce566", "#a3f3ff", "#c4bdff", "#a3f3ff", "#f7f1ff"
- ]),
- ("tokio", vec![
- "#363537", "#fc618d", "#7bd88f", "#fce566", "#fd9353", "#948ae3", "#5ad4e6", "#f7f1ff",
- "#69676c", "#fc618d", "#7bd88f", "#fce566", "#fd9353", "#948ae3", "#5ad4e6", "#f7f1ff"
- ]),
- ("oslo", vec![
- "#3f4451", "#e05561", "#8cc265", "#d18f52", "#4aa5f0", "#c162de", "#42b3c2", "#e6e6e6",
- "#4f5666", "#ff616e", "#a5e075", "#f0a45d", "#4dc4ff", "#de73ff", "#4cd1e0", "#ffffff"
- ]),
- ("helsinki", vec![
- "#c0bbae", "#1faa9e", "#733d9a", "#2e70ad", "#b55a0f", "#3e9d21", "#bd4c3d", "#191919",
- "#b0a999", "#009e91", "#5a1f8a", "#0f5ba2", "#b23b00", "#218c00", "#b32e1f", "#000000"
- ]),
- ("berlin", vec![
- "#000000", "#999999", "#bbbbbb", "#dddddd", "#888888", "#aaaaaa", "#cccccc", "#ffffff",
- "#333333", "#bbbbbb", "#dddddd", "#ffffff", "#aaaaaa", "#cccccc", "#eeeeee", "#ffffff"
- ]),
- ("london", vec![
- "#000000", "#333333", "#444444", "#555555", "#666666", "#777777", "#888888", "#999999",
- "#333333", "#444444", "#555555", "#666666", "#777777", "#888888", "#999999", "#aaaaaa"
- ]),
- ("praha", vec![
- "#1A1A1A", "#FF5555", "#B8E6A0", "#FFE4A3", "#BD93F9", "#FF9AA2", "#8BE9FD", "#FFFFFF",
- "#6272A4", "#FF6E6E", "#B8E6A0", "#FFE4A3", "#D6ACFF", "#FF9AA2", "#A4FFFF", "#FFFFFF"
- ]),
- ("bogota", vec![
- "#222222", "#fc618d", "#7bd88f", "#ffed89", "#47e6ff", "#ff9999", "#47e6ff", "#f7f1ff",
- "#525053", "#fc618d", "#7bd88f", "#ffed89", "#47e6ff", "#ff9999", "#47e6ff", "#f7f1ff"
- ]),
- ];
-
- for (name, colors) in definitions {
- let mut palette = [Color::Reset; 16];
- for (i, hex) in colors.iter().enumerate() {
- if i < 16 {
- palette[i] = hex_to_color(hex);
- }
- }
- themes.insert(name.to_string(), Theme {
- name: name.to_string(),
- palette,
- });
- }
-
- themes
-}
diff --git a/src/ui.rs b/src/ui.rs
deleted file mode 100644
index bbcad00..0000000
--- a/src/ui.rs
+++ /dev/null
@@ -1,371 +0,0 @@
-use ratatui::{
- prelude::*,
- widgets::{Block, Borders, Paragraph, Gauge, Wrap, Table, Row, Cell, Chart, Dataset, Axis, GraphType},
- Frame,
-};
-use crate::app::{App, LayoutMode};
-use sysinfo::{System, Process};
-
-pub fn ui(f: &mut Frame, app: &App) {
- match app.layout_mode {
- LayoutMode::Dashboard => render_dashboard(f, app),
- LayoutMode::Vertical => render_vertical(f, app),
- LayoutMode::ProcessFocus => render_process_focus(f, app),
- }
-}
-
-fn render_dashboard(f: &mut Frame, app: &App) {
- let chunks = Layout::default()
- .direction(Direction::Vertical)
- .constraints([
- Constraint::Length(3), // Header
- Constraint::Percentage(45), // Top half (CPU, Mem, Net)
- Constraint::Percentage(52), // Bottom half (Processes)
- ])
- .split(f.area());
-
- render_header(f, app, chunks[0]);
-
- let top_chunks = Layout::default()
- .direction(Direction::Horizontal)
- .constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
- .split(chunks[1]);
-
- render_cpu(f, app, top_chunks[0]);
-
- let right_chunks = Layout::default()
- .direction(Direction::Vertical)
- .constraints([
- Constraint::Percentage(33), // Memory
- Constraint::Percentage(33), // Storage
- Constraint::Percentage(34), // Network
- ])
- .split(top_chunks[1]);
-
- render_memory(f, app, right_chunks[0]);
- render_storage(f, app, right_chunks[1]);
- render_network(f, app, right_chunks[2]);
-
- render_processes(f, app, chunks[2]);
-}
-
-fn render_vertical(f: &mut Frame, app: &App) {
- let chunks = Layout::default()
- .direction(Direction::Vertical)
- .constraints([
- Constraint::Length(3), // Header
- Constraint::Length(10), // CPU
- Constraint::Length(10), // Memory
- Constraint::Length(10), // Storage
- Constraint::Length(6), // Network
- Constraint::Min(0), // Processes
- ])
- .split(f.area());
-
- render_header(f, app, chunks[0]);
- render_cpu(f, app, chunks[1]);
- render_memory(f, app, chunks[2]);
- render_storage(f, app, chunks[3]);
- render_network(f, app, chunks[4]);
- render_processes(f, app, chunks[5]);
-}
-
-fn render_process_focus(f: &mut Frame, app: &App) {
- let chunks = Layout::default()
- .direction(Direction::Vertical)
- .constraints([
- Constraint::Length(3), // Header
- Constraint::Length(10), // Quick Stats Row
- Constraint::Min(0), // Processes (Dominant)
- ])
- .split(f.area());
-
- render_header(f, app, chunks[0]);
-
- let stats_chunks = Layout::default()
- .direction(Direction::Horizontal)
- .constraints([
- Constraint::Percentage(25),
- Constraint::Percentage(25),
- Constraint::Percentage(25),
- Constraint::Percentage(25),
- ])
- .split(chunks[1]);
-
- render_cpu(f, app, stats_chunks[0]);
- render_memory(f, app, stats_chunks[1]);
- render_storage(f, app, stats_chunks[2]);
- render_network(f, app, stats_chunks[3]);
-
- render_processes(f, app, chunks[2]);
-}
-
-fn render_header(f: &mut Frame, app: &App, area: Rect) {
- let load_avg = System::load_average();
- let uptime = System::uptime();
- let days = uptime / 86400;
- let hours = (uptime % 86400) / 3600;
- let minutes = (uptime % 3600) / 60;
- let seconds = uptime % 60;
-
- let mode_str = match app.layout_mode {
- LayoutMode::Dashboard => "Dashboard",
- LayoutMode::Vertical => "Vertical",
- LayoutMode::ProcessFocus => "Process Focus",
- };
-
- let text = format!(
- "xtop | Theme: {} | Layout: {} | Uptime: {}d {}h {}m {}s | Load: {:.2} {:.2} {:.2} | [q] Quit [t] Theme [l] Layout",
- app.current_theme.name, mode_str, days, hours, minutes, seconds, load_avg.one, load_avg.five, load_avg.fifteen
- );
-
- let p = Paragraph::new(text)
- .style(Style::default().fg(app.current_theme.fg()).bg(app.current_theme.bg()))
- .block(Block::default().borders(Borders::ALL).title("System Info"));
- f.render_widget(p, area);
-}
-
-fn render_cpu(f: &mut Frame, app: &App, area: Rect) {
- // Try to get max temp
- let mut max_temp = 0.0;
- for component in &app.components {
- if component.label().to_lowercase().contains("core") || component.label().to_lowercase().contains("cpu") {
- if let Some(temp) = component.temperature() {
- if temp > max_temp {
- max_temp = temp;
- }
- }
- }
- }
-
- let title = if max_temp > 0.0 {
- format!("CPU (Max: {:.1}°C)", max_temp)
- } else {
- "CPU".to_string()
- };
-
- let block = Block::default().title(title).borders(Borders::ALL)
- .style(Style::default().fg(app.current_theme.fg()).bg(app.current_theme.bg()));
- let inner_area = block.inner(area);
- f.render_widget(block, area);
-
- let cpus = app.sys.cpus();
- let count = cpus.len();
- if count == 0 { return; }
-
- let constraints = if inner_area.width > 40 {
- vec![Constraint::Percentage(50), Constraint::Percentage(50)]
- } else {
- vec![Constraint::Percentage(100)]
- };
-
- let cols = Layout::default().direction(Direction::Horizontal).constraints(constraints).split(inner_area);
-
- let items_per_col = (count + 1) / cols.len();
-
- for (col_idx, col_area) in cols.iter().enumerate() {
- let start = col_idx * items_per_col;
- let end = (start + items_per_col).min(count);
-
- let rows = Layout::default().direction(Direction::Vertical)
- .constraints(vec![Constraint::Length(1); end - start])
- .split(*col_area);
-
- for (i, row_area) in rows.iter().enumerate() {
- let cpu_idx = start + i;
- if cpu_idx >= count { break; }
-
- let cpu = &cpus[cpu_idx];
- let usage = cpu.cpu_usage();
- let label = format!("CPU{:<2} {:>3.0}%", cpu_idx, usage);
-
- let gauge = Gauge::default()
- .gauge_style(Style::default().fg(app.current_theme.palette[1 + (cpu_idx % 6)]).bg(app.current_theme.bg()))
- .percent(usage as u16)
- .label(label);
- f.render_widget(gauge, *row_area);
- }
- }
-}
-
-fn render_memory(f: &mut Frame, app: &App, area: Rect) {
- let block = Block::default().title("Memory").borders(Borders::ALL)
- .style(Style::default().fg(app.current_theme.fg()).bg(app.current_theme.bg()));
- let inner_area = block.inner(area);
- f.render_widget(block, area);
-
- let chunks = Layout::default().direction(Direction::Vertical)
- .constraints([Constraint::Length(3), Constraint::Length(3), Constraint::Min(0)])
- .split(inner_area);
-
- // RAM Stats
- let total_mem = app.sys.total_memory();
- let used_mem = app.sys.used_memory();
- let _free_mem = app.sys.free_memory();
- let available_mem = app.sys.available_memory();
-
- let mem_pct = if total_mem > 0 { (used_mem as f64 / total_mem as f64 * 100.0) as u16 } else { 0 };
-
- let mem_text = format!(
- "RAM: Total: {:.1} GB | Used: {:.1} GB | Avail: {:.1} GB",
- total_mem as f64 / 1024.0 / 1024.0 / 1024.0,
- used_mem as f64 / 1024.0 / 1024.0 / 1024.0,
- available_mem as f64 / 1024.0 / 1024.0 / 1024.0
- );
-
- let mem_gauge = Gauge::default()
- .gauge_style(Style::default().fg(app.current_theme.palette[2]).bg(app.current_theme.bg()))
- .percent(mem_pct)
- .label(mem_text);
- f.render_widget(mem_gauge, chunks[0]);
-
- // Swap Stats
- let total_swap = app.sys.total_swap();
- let used_swap = app.sys.used_swap();
- let free_swap = app.sys.free_swap();
- let swap_pct = if total_swap > 0 { (used_swap as f64 / total_swap as f64 * 100.0) as u16 } else { 0 };
-
- let swap_text = format!(
- "SWP: Total: {:.1} GB | Used: {:.1} GB | Free: {:.1} GB",
- total_swap as f64 / 1024.0 / 1024.0 / 1024.0,
- used_swap as f64 / 1024.0 / 1024.0 / 1024.0,
- free_swap as f64 / 1024.0 / 1024.0 / 1024.0
- );
-
- let swap_gauge = Gauge::default()
- .gauge_style(Style::default().fg(app.current_theme.palette[3]).bg(app.current_theme.bg()))
- .percent(swap_pct)
- .label(swap_text);
- f.render_widget(swap_gauge, chunks[1]);
-
- // History Chart
- let datasets = vec![
- Dataset::default()
- .name("RAM Usage")
- .marker(symbols::Marker::Braille)
- .graph_type(GraphType::Line)
- .style(Style::default().fg(app.current_theme.palette[2]))
- .data(&app.mem_history),
- ];
-
- let chart = Chart::new(datasets)
- .block(Block::default().borders(Borders::TOP))
- .x_axis(Axis::default().bounds([app.tick_count - 100.0, app.tick_count]))
- .y_axis(Axis::default().bounds([0.0, 100.0]));
- f.render_widget(chart, chunks[2]);
-}
-
-fn render_storage(f: &mut Frame, app: &App, area: Rect) {
- let block = Block::default().title("Storage").borders(Borders::ALL)
- .style(Style::default().fg(app.current_theme.fg()).bg(app.current_theme.bg()));
- let inner_area = block.inner(area);
- f.render_widget(block, area);
-
- // List all disks
- // We need to calculate how many lines per disk to fit them all, or just list them.
- let disk_count = app.disks.len();
- if disk_count == 0 { return; }
-
- let constraints = vec![Constraint::Length(3); disk_count];
- let chunks = Layout::default()
- .direction(Direction::Vertical)
- .constraints(constraints)
- .split(inner_area);
-
- for (i, disk) in app.disks.iter().enumerate() {
- if i >= chunks.len() { break; }
-
- let total_space = disk.total_space();
- let available_space = disk.available_space();
- let used_space = total_space - available_space;
- let disk_pct = if total_space > 0 { (used_space as f64 / total_space as f64 * 100.0) as u16 } else { 0 };
-
- // "encima total uso y libre" -> We put stats in the label or above.
- // Let's format it nicely: "/mnt/data Total: 100G Used: 50G Free: 50G"
- let label = format!(
- "{:?} Tot: {:.0}G Use: {:.0}G Free: {:.0}G",
- disk.mount_point(),
- total_space as f64 / 1024.0 / 1024.0 / 1024.0,
- used_space as f64 / 1024.0 / 1024.0 / 1024.0,
- available_space as f64 / 1024.0 / 1024.0 / 1024.0
- );
-
- let gauge = Gauge::default()
- .gauge_style(Style::default().fg(app.current_theme.palette[4]).bg(app.current_theme.bg()))
- .percent(disk_pct)
- .label(label);
- f.render_widget(gauge, chunks[i]);
- }
-}
-
-fn render_network(f: &mut Frame, app: &App, area: Rect) {
- let block = Block::default().title("Network").borders(Borders::ALL)
- .style(Style::default().fg(app.current_theme.fg()).bg(app.current_theme.bg()));
- let inner_area = block.inner(area);
- f.render_widget(block, area);
-
- let mut total_rx = 0;
- let mut total_tx = 0;
- for (_, network) in &app.networks {
- total_rx += network.received();
- total_tx += network.transmitted();
- }
-
- let text = vec![
- Line::from(vec![
- Span::styled("Total RX: ", Style::default().fg(app.current_theme.fg())),
- Span::styled(format!("{:.2} MB", total_rx as f64 / 1024.0 / 1024.0), Style::default().fg(app.current_theme.palette[4])),
- ]),
- Line::from(vec![
- Span::styled("Total TX: ", Style::default().fg(app.current_theme.fg())),
- Span::styled(format!("{:.2} MB", total_tx as f64 / 1024.0 / 1024.0), Style::default().fg(app.current_theme.palette[5])),
- ]),
- ];
-
- let p = Paragraph::new(text).wrap(Wrap { trim: true });
- f.render_widget(p, inner_area);
-}
-
-fn render_processes(f: &mut Frame, app: &App, area: Rect) {
- let block = Block::default().title("Processes").borders(Borders::ALL)
- .style(Style::default().fg(app.current_theme.fg()).bg(app.current_theme.bg()));
- let inner_area = block.inner(area);
- f.render_widget(block, area);
-
- let mut procs: Vec<&Process> = app.sys.processes().values().collect();
- procs.sort_by(|a, b| b.cpu_usage().partial_cmp(&a.cpu_usage()).unwrap_or(std::cmp::Ordering::Equal));
-
- let procs = procs.iter().take(50);
-
- // Faint line separator
- let separator = Span::styled(" | ", Style::default().fg(app.current_theme.palette[8])); // Assuming palette[8] is faint/gray
-
- let rows: Vec = procs.map(|p| {
- Row::new(vec![
- Cell::from(Line::from(vec![Span::raw(p.pid().to_string()), separator.clone()])),
- Cell::from(Line::from(vec![Span::raw(p.name().to_string_lossy().into_owned()), separator.clone()])),
- Cell::from(Line::from(vec![Span::raw(format!("{:.1}%", p.cpu_usage())), separator.clone()])),
- Cell::from(Line::from(vec![Span::raw(format!("{:.1} MB", p.memory() as f64 / 1024.0 / 1024.0)), separator.clone()])),
- Cell::from(p.user_id().map(|u| u.to_string()).unwrap_or_else(|| "?".to_string())),
- ])
- .style(Style::default().fg(app.current_theme.fg()))
- }).collect();
-
- let widths = [
- Constraint::Length(10), // Increased slightly for separator
- Constraint::Percentage(40),
- Constraint::Length(12),
- Constraint::Length(17),
- Constraint::Length(10),
- ];
-
- let table = Table::new(rows, widths)
- .header(
- Row::new(vec!["PID |", "Name |", "CPU% |", "Mem |", "User"])
- .style(Style::default().fg(app.current_theme.palette[6]).add_modifier(Modifier::BOLD))
- .bottom_margin(1)
- )
- .row_highlight_style(Style::default().add_modifier(Modifier::REVERSED));
-
- f.render_widget(table, inner_area);
-}
From 14dfb74e10fd659a0d73dc8e41065adad0b8c88a Mon Sep 17 00:00:00 2001
From: xscriptor
Date: Wed, 17 Jun 2026 12:20:02 +0200
Subject: [PATCH 2/6] update structure add support for themes and custom
layouts
---
README.md | 28 +-
assets/layouts/cpu_focus.jsonc | 12 +
assets/layouts/dashboard.jsonc | 27 ++
assets/layouts/horizontal.jsonc | 20 ++
assets/layouts/memory_focus.jsonc | 12 +
assets/layouts/network_focus.jsonc | 19 ++
assets/layouts/process_focus.jsonc | 21 ++
assets/layouts/vertical.jsonc | 15 +
assets/themes/berlin.jsonc | 9 +
assets/themes/bogota.jsonc | 9 +
assets/themes/helsinki.jsonc | 9 +
assets/themes/lahabana.jsonc | 9 +
assets/themes/london.jsonc | 9 +
assets/themes/madrid.jsonc | 9 +
assets/themes/miami.jsonc | 9 +
assets/themes/oslo.jsonc | 9 +
assets/themes/paris.jsonc | 9 +
assets/themes/praha.jsonc | 9 +
assets/themes/tokio.jsonc | 9 +
assets/themes/x.jsonc | 9 +
references.md => colors.md | 167 ++++++-----
crates/xtop-cli/src/main.rs | 178 +++++++++--
crates/xtop-core/src/application/state.rs | 202 ++++++++++++-
crates/xtop-core/src/domain/keybinding.rs | 105 +++++++
crates/xtop-core/src/domain/layout.rs | 184 ++++++++++++
crates/xtop-core/src/domain/mod.rs | 2 +
crates/xtop-core/src/domain/theme.rs | 78 ++++-
crates/xtop-core/src/infrastructure/config.rs | 25 --
.../src/infrastructure/layout_loader.rs | 264 +++++++++++++++++
crates/xtop-core/src/infrastructure/mod.rs | 1 +
.../src/infrastructure/theme_loader.rs | 279 +++++++-----------
crates/xtop-tui/src/color.rs | 5 +
crates/xtop-tui/src/lib.rs | 1 +
crates/xtop-tui/src/render/battery.rs | 8 +-
crates/xtop-tui/src/render/cpu.rs | 8 +-
crates/xtop-tui/src/render/disk_io.rs | 55 ++--
crates/xtop-tui/src/render/gpu.rs | 8 +-
crates/xtop-tui/src/render/header.rs | 52 ++--
crates/xtop-tui/src/render/help.rs | 9 +-
crates/xtop-tui/src/render/layout_engine.rs | 75 +++++
crates/xtop-tui/src/render/memory.rs | 16 +-
crates/xtop-tui/src/render/mod.rs | 242 ++-------------
crates/xtop-tui/src/render/network.rs | 42 ++-
crates/xtop-tui/src/render/palette.rs | 82 +++++
crates/xtop-tui/src/render/processes.rs | 10 +-
crates/xtop-tui/src/render/storage.rs | 10 +-
docs/customization.md | 196 ++++++++++++
47 files changed, 1948 insertions(+), 618 deletions(-)
create mode 100644 assets/layouts/cpu_focus.jsonc
create mode 100644 assets/layouts/dashboard.jsonc
create mode 100644 assets/layouts/horizontal.jsonc
create mode 100644 assets/layouts/memory_focus.jsonc
create mode 100644 assets/layouts/network_focus.jsonc
create mode 100644 assets/layouts/process_focus.jsonc
create mode 100644 assets/layouts/vertical.jsonc
create mode 100644 assets/themes/berlin.jsonc
create mode 100644 assets/themes/bogota.jsonc
create mode 100644 assets/themes/helsinki.jsonc
create mode 100644 assets/themes/lahabana.jsonc
create mode 100644 assets/themes/london.jsonc
create mode 100644 assets/themes/madrid.jsonc
create mode 100644 assets/themes/miami.jsonc
create mode 100644 assets/themes/oslo.jsonc
create mode 100644 assets/themes/paris.jsonc
create mode 100644 assets/themes/praha.jsonc
create mode 100644 assets/themes/tokio.jsonc
create mode 100644 assets/themes/x.jsonc
rename references.md => colors.md (65%)
create mode 100644 crates/xtop-core/src/domain/keybinding.rs
create mode 100644 crates/xtop-core/src/domain/layout.rs
create mode 100644 crates/xtop-core/src/infrastructure/layout_loader.rs
create mode 100644 crates/xtop-tui/src/color.rs
create mode 100644 crates/xtop-tui/src/render/layout_engine.rs
create mode 100644 crates/xtop-tui/src/render/palette.rs
create mode 100644 docs/customization.md
diff --git a/README.md b/README.md
index d1ffbb4..0b98467 100644
--- a/README.md
+++ b/README.md
@@ -58,12 +58,12 @@ xtop is a modern, cross-platform TUI system monitor crafted in Rust. Heavily ins
- **Disks:** Storage usage visualization.
- **Processes:** List of running processes sorted by CPU usage.
- **Theming:**
- - Includes 13 built-in color schemes (e.g., Dracula-like 'x', Madrid, Tokio, etc.).
- - Cycle through themes instantly without configuration files.
+ - 13 ready-to-use color schemes + custom themes via JSONC files.
+ - Cycle through themes instantly with `t` / `T`.
- **Layouts:**
- - **Dashboard:** Balanced view of all components (Default).
- - **Vertical:** Stacked view, good for narrow terminals.
- - **Process Focus:** Maximizes space for the process list while keeping essential stats visible.
+ - 7 built-in layouts (Dashboard, Vertical, Horizontal, CPU/Memory/Network/Process Focus).
+ - Custom layouts via JSONC files — define your own widget tree.
+ - Full-screen mode for any widget.
## Installation
@@ -142,7 +142,10 @@ irm https://raw.githubusercontent.com/xscriptor/xtop/main/uninstall.ps1 | iex
| `q` | Quit application |
| `t` | Next Color Theme |
| `T` | Previous Color Theme |
-| `l` | Toggle Layout Mode (Dashboard -> Vertical -> Process Focus) |
+| `l` | Next Layout Mode (built-in + custom) |
+| `f` | Toggle fullscreen widget |
+| `F` | Cycle fullscreen widget |
+| `/` | Search / filter processes |
### Modules
@@ -152,9 +155,18 @@ irm https://raw.githubusercontent.com/xscriptor/xtop/main/uninstall.ps1 | iex
4. **Network**: Total downloaded (RX) and uploaded (TX) data.
5. **Processes**: A scrolling list of the top 50 processes sorted by CPU usage.
-## Configuration
+## Customization
-Currently, `xtop` is zero-config. All preferences (theme, layout) can be toggled at runtime but are reset on restart. Future versions may include a config file.
+xtop supports custom color themes and layout modes defined as JSONC files.
+
+**[→ Full customization guide](docs/customization.md)**
+
+| Feature | Location | Format |
+|---------|----------|--------|
+| Themes | `~/.config/xtop/themes/*.jsonc` | 16-entry hex color palette |
+| Layouts | `~/.config/xtop/layouts/*.jsonc` | Recursive split/widget tree |
+
+The built-in `miami` theme (black background, neon accents) is always available. Starter theme and layout files ship in the `assets/` directory.
## Contributing
diff --git a/assets/layouts/cpu_focus.jsonc b/assets/layouts/cpu_focus.jsonc
new file mode 100644
index 0000000..13e24b0
--- /dev/null
+++ b/assets/layouts/cpu_focus.jsonc
@@ -0,0 +1,12 @@
+{
+ // CPU Focus: CPU takes 60%, processes the rest
+ "name": "CPU Focus",
+ "root": {
+ "direction": "vertical",
+ "areas": [
+ { "widget": "header", "size": 3 },
+ { "widget": "cpu", "size": "60%" },
+ { "widget": "processes", "size": "*" }
+ ]
+ }
+}
diff --git a/assets/layouts/dashboard.jsonc b/assets/layouts/dashboard.jsonc
new file mode 100644
index 0000000..9444014
--- /dev/null
+++ b/assets/layouts/dashboard.jsonc
@@ -0,0 +1,27 @@
+{
+ // Dashboard: default 2-column layout
+ "name": "Dashboard",
+ "root": {
+ "direction": "vertical",
+ "areas": [
+ { "widget": "header", "size": 3 },
+ {
+ "direction": "horizontal",
+ "size": "45%",
+ "areas": [
+ { "widget": "cpu", "size": "50%" },
+ {
+ "direction": "vertical",
+ "size": "50%",
+ "areas": [
+ { "widget": "memory", "size": "33%" },
+ { "widget": "storage", "size": "33%" },
+ { "widget": "network", "size": "34%" }
+ ]
+ }
+ ]
+ },
+ { "widget": "processes", "size": "52%" }
+ ]
+ }
+}
diff --git a/assets/layouts/horizontal.jsonc b/assets/layouts/horizontal.jsonc
new file mode 100644
index 0000000..03c39ae
--- /dev/null
+++ b/assets/layouts/horizontal.jsonc
@@ -0,0 +1,20 @@
+{
+ // Horizontal: 4 widgets side-by-side
+ "name": "Horizontal",
+ "root": {
+ "direction": "vertical",
+ "areas": [
+ { "widget": "header", "size": 3 },
+ {
+ "direction": "horizontal",
+ "size": "*",
+ "areas": [
+ { "widget": "cpu", "size": "25%" },
+ { "widget": "memory", "size": "25%" },
+ { "widget": "storage", "size": "25%" },
+ { "widget": "network", "size": "25%" }
+ ]
+ }
+ ]
+ }
+}
diff --git a/assets/layouts/memory_focus.jsonc b/assets/layouts/memory_focus.jsonc
new file mode 100644
index 0000000..5630296
--- /dev/null
+++ b/assets/layouts/memory_focus.jsonc
@@ -0,0 +1,12 @@
+{
+ // Memory Focus: Memory takes 60%, processes the rest
+ "name": "Memory Focus",
+ "root": {
+ "direction": "vertical",
+ "areas": [
+ { "widget": "header", "size": 3 },
+ { "widget": "memory", "size": "60%" },
+ { "widget": "processes", "size": "*" }
+ ]
+ }
+}
diff --git a/assets/layouts/network_focus.jsonc b/assets/layouts/network_focus.jsonc
new file mode 100644
index 0000000..d5c2b30
--- /dev/null
+++ b/assets/layouts/network_focus.jsonc
@@ -0,0 +1,19 @@
+{
+ // Network Focus: Network + Disk I/O side by side, process list below
+ "name": "Network Focus",
+ "root": {
+ "direction": "vertical",
+ "areas": [
+ { "widget": "header", "size": 3 },
+ {
+ "direction": "horizontal",
+ "size": "50%",
+ "areas": [
+ { "widget": "network", "size": "50%" },
+ { "widget": "disk_io", "size": "50%" }
+ ]
+ },
+ { "widget": "processes", "size": "*" }
+ ]
+ }
+}
diff --git a/assets/layouts/process_focus.jsonc b/assets/layouts/process_focus.jsonc
new file mode 100644
index 0000000..a3417ee
--- /dev/null
+++ b/assets/layouts/process_focus.jsonc
@@ -0,0 +1,21 @@
+{
+ // Process Focus: mini stats row at top, full process list below
+ "name": "Process Focus",
+ "root": {
+ "direction": "vertical",
+ "areas": [
+ { "widget": "header", "size": 3 },
+ {
+ "direction": "horizontal",
+ "size": 8,
+ "areas": [
+ { "widget": "cpu", "size": "25%" },
+ { "widget": "memory", "size": "25%" },
+ { "widget": "storage", "size": "25%" },
+ { "widget": "network", "size": "25%" }
+ ]
+ },
+ { "widget": "processes", "size": "*" }
+ ]
+ }
+}
diff --git a/assets/layouts/vertical.jsonc b/assets/layouts/vertical.jsonc
new file mode 100644
index 0000000..864f022
--- /dev/null
+++ b/assets/layouts/vertical.jsonc
@@ -0,0 +1,15 @@
+{
+ // Vertical: all widgets stacked top-to-bottom
+ "name": "Vertical",
+ "root": {
+ "direction": "vertical",
+ "areas": [
+ { "widget": "header", "size": 3 },
+ { "widget": "cpu", "size": 8 },
+ { "widget": "memory", "size": 8 },
+ { "widget": "storage", "size": 6 },
+ { "widget": "network", "size": 5 },
+ { "widget": "processes", "size": "*" }
+ ]
+ }
+}
diff --git a/assets/themes/berlin.jsonc b/assets/themes/berlin.jsonc
new file mode 100644
index 0000000..e30c24c
--- /dev/null
+++ b/assets/themes/berlin.jsonc
@@ -0,0 +1,9 @@
+{
+ // Berlin -- Black bg, full grayscale ramp
+ "name": "berlin",
+ "palette": [
+ "#000000", "#999999", "#bbbbbb", "#dddddd", "#888888", "#aaaaaa", "#cccccc",
+ "#cccccc", "#333333", "#bbbbbb", "#dddddd", "#ffffff", "#aaaaaa", "#cccccc",
+ "#eeeeee", "#ffffff"
+ ]
+}
diff --git a/assets/themes/bogota.jsonc b/assets/themes/bogota.jsonc
new file mode 100644
index 0000000..274501d
--- /dev/null
+++ b/assets/themes/bogota.jsonc
@@ -0,0 +1,9 @@
+{
+ // Bogota -- Very dark brown bg, warm pastel accents
+ "name": "bogota",
+ "palette": [
+ "#200b0a", "#fc618d", "#7bd88f", "#ffed89", "#47e6ff", "#ff9999", "#47e6ff",
+ "#f7f1ff", "#525053", "#fc618d", "#7bd88f", "#ffed89", "#47e6ff", "#ff9999",
+ "#47e6ff", "#f7f1ff"
+ ]
+}
diff --git a/assets/themes/helsinki.jsonc b/assets/themes/helsinki.jsonc
new file mode 100644
index 0000000..58d4e2e
--- /dev/null
+++ b/assets/themes/helsinki.jsonc
@@ -0,0 +1,9 @@
+{
+ // Helsinki -- Light theme (white bg), earthy tones
+ "name": "helsinki",
+ "palette": [
+ "#f8fafe", "#1faa9e", "#733d9a", "#2e70ad", "#b55a0f", "#3e9d21", "#bd4c3d",
+ "#544d40", "#b0a999", "#009e91", "#5a1f8a", "#0f5ba2", "#b23b00", "#218c00",
+ "#b32e1f", "#000000"
+ ]
+}
diff --git a/assets/themes/lahabana.jsonc b/assets/themes/lahabana.jsonc
new file mode 100644
index 0000000..a6ddcae
--- /dev/null
+++ b/assets/themes/lahabana.jsonc
@@ -0,0 +1,9 @@
+{
+ // Lahabana -- Near-black bg, green-yellow-green palette
+ "name": "lahabana",
+ "palette": [
+ "#19191a", "#fc618d", "#7bd88f", "#e5ff9d", "#fd9353", "#948ae3", "#5ad4e6",
+ "#f7f1ff", "#19191a", "#fc618d", "#7bd88f", "#e5ff9d", "#fd9353", "#948ae3",
+ "#5ad4e6", "#f7f1ff"
+ ]
+}
diff --git a/assets/themes/london.jsonc b/assets/themes/london.jsonc
new file mode 100644
index 0000000..e406715
--- /dev/null
+++ b/assets/themes/london.jsonc
@@ -0,0 +1,9 @@
+{
+ // London -- Light theme (white bg), grayscale
+ "name": "london",
+ "palette": [
+ "#ffffff", "#333333", "#444444", "#555555", "#666666", "#777777", "#888888",
+ "#333333", "#333333", "#444444", "#555555", "#666666", "#777777", "#888888",
+ "#999999", "#aaaaaa"
+ ]
+}
diff --git a/assets/themes/madrid.jsonc b/assets/themes/madrid.jsonc
new file mode 100644
index 0000000..e8f655e
--- /dev/null
+++ b/assets/themes/madrid.jsonc
@@ -0,0 +1,9 @@
+{
+ // Madrid -- Light theme (white bg), dark red accents
+ "name": "madrid",
+ "palette": [
+ "#fafafa", "#990026", "#007a28", "#8a6408", "#007a9e", "#4d2699", "#007a9e",
+ "#1a1a1a", "#4d4d4d", "#990026", "#007a28", "#8a6408", "#007a9e", "#4d2699",
+ "#007a9e", "#1a1a1a"
+ ]
+}
diff --git a/assets/themes/miami.jsonc b/assets/themes/miami.jsonc
new file mode 100644
index 0000000..a62a8ee
--- /dev/null
+++ b/assets/themes/miami.jsonc
@@ -0,0 +1,9 @@
+{
+ // Miami -- Black bg, synthwave neon (the default)
+ "name": "miami",
+ "palette": [
+ "#000000", "#FF4C8B", "#7FFFD4", "#FFD84C", "#00FFA8", "#D36CFF", "#47CFFF",
+ "#f7f1ff", "#69676c", "#FF4C8B", "#7FFFD4", "#FFD84C", "#00FFA8", "#D36CFF",
+ "#47CFFF", "#f7f1ff"
+ ]
+}
diff --git a/assets/themes/oslo.jsonc b/assets/themes/oslo.jsonc
new file mode 100644
index 0000000..55e44f3
--- /dev/null
+++ b/assets/themes/oslo.jsonc
@@ -0,0 +1,9 @@
+{
+ // Oslo -- Dark grey bg, muted professional palette
+ "name": "oslo",
+ "palette": [
+ "#3f4451", "#e05561", "#8cc265", "#d18f52", "#4aa5f0", "#c162de", "#42b3c2",
+ "#abb2bf", "#4f5666", "#ff616e", "#a5e075", "#f0a45d", "#4dc4ff", "#de73ff",
+ "#4cd1e0", "#ffffff"
+ ]
+}
diff --git a/assets/themes/paris.jsonc b/assets/themes/paris.jsonc
new file mode 100644
index 0000000..e933fb6
--- /dev/null
+++ b/assets/themes/paris.jsonc
@@ -0,0 +1,9 @@
+{
+ // Paris -- Deep purple bg, pastel accents
+ "name": "paris",
+ "palette": [
+ "#1a0a30", "#fc618d", "#7bd88f", "#fce566", "#a3f3ff", "#c4bdff", "#a3f3ff",
+ "#f7f1ff", "#c4bdff", "#fc618d", "#7bd88f", "#fce566", "#a3f3ff", "#c4bdff",
+ "#a3f3ff", "#f7f1ff"
+ ]
+}
diff --git a/assets/themes/praha.jsonc b/assets/themes/praha.jsonc
new file mode 100644
index 0000000..8ad3e78
--- /dev/null
+++ b/assets/themes/praha.jsonc
@@ -0,0 +1,9 @@
+{
+ // Praha -- Dark Dracula-inspired theme
+ "name": "praha",
+ "palette": [
+ "#1A1A1A", "#FF5555", "#B8E6A0", "#FFE4A3", "#BD93F9", "#FF9AA2", "#8BE9FD",
+ "#FFFFFF", "#6272A4", "#FF6E6E", "#B8E6A0", "#FFE4A3", "#D6ACFF", "#FF9AA2",
+ "#A4FFFF", "#FFFFFF"
+ ]
+}
diff --git a/assets/themes/tokio.jsonc b/assets/themes/tokio.jsonc
new file mode 100644
index 0000000..6337a94
--- /dev/null
+++ b/assets/themes/tokio.jsonc
@@ -0,0 +1,9 @@
+{
+ // Tokio -- Near-black bg, warm rainbow accents
+ "name": "tokio",
+ "palette": [
+ "#1c1c1d", "#fc618d", "#7bd88f", "#fce566", "#fd9353", "#948ae3", "#5ad4e6",
+ "#f7f1ff", "#1c1c1d", "#fc618d", "#7bd88f", "#fce566", "#fd9353", "#948ae3",
+ "#5ad4e6", "#f7f1ff"
+ ]
+}
diff --git a/assets/themes/x.jsonc b/assets/themes/x.jsonc
new file mode 100644
index 0000000..b441b5b
--- /dev/null
+++ b/assets/themes/x.jsonc
@@ -0,0 +1,9 @@
+{
+ // X -- Almost-black background, purple-pink accents
+ "name": "x",
+ "palette": [
+ "#050505", "#fc618d", "#7bd88f", "#fce566", "#fd9353", "#948ae3", "#5ad4e6",
+ "#f7f1ff", "#0f0f0f", "#fc618d", "#7bd88f", "#fce566", "#fd9353", "#948ae3",
+ "#5ad4e6", "#f7f1ff"
+ ]
+}
diff --git a/references.md b/colors.md
similarity index 65%
rename from references.md
rename to colors.md
index c32104b..9f6039f 100644
--- a/references.md
+++ b/colors.md
@@ -1,10 +1,11 @@
-# Schemes
+Colors
-## x
+
+X
```json
{
- "color0": "#363537",
+ "color0": "#0a0a0a",
"color1": "#fc618d",
"color2": "#7bd88f",
"color3": "#fce566",
@@ -12,45 +13,51 @@
"color5": "#948ae3",
"color6": "#5ad4e6",
"color7": "#f7f1ff",
- "color8": "#69676c",
+ "color8": "#0f0f0f",
"color9": "#fc618d",
"color10": "#7bd88f",
"color11": "#fce566",
"color12": "#fd9353",
"color13": "#948ae3",
"color14": "#5ad4e6",
- "color15": "#f7f1ff"
+ "color15": "#f7f1ff",
+ "background": "#050505",
+ "foreground": "#f7f1ff"
}
```
+
-## madrid
+Madrid
```json
{
- "color0": "#333333",
- "color1": "#cc0033",
- "color2": "#009933",
- "color3": "#b8860b",
- "color4": "#0099cc",
- "color5": "#6633cc",
- "color6": "#0099cc",
+ "color0": "#fafafa",
+ "color1": "#990026",
+ "color2": "#007a28",
+ "color3": "#8a6408",
+ "color4": "#007a9e",
+ "color5": "#4d2699",
+ "color6": "#007a9e",
"color7": "#1a1a1a",
- "color8": "#666666",
- "color9": "#cc0033",
- "color10": "#009933",
- "color11": "#b8860b",
- "color12": "#0099cc",
- "color13": "#6633cc",
- "color14": "#0099cc",
- "color15": "#1a1a1a"
+ "color8": "#4d4d4d",
+ "color9": "#990026",
+ "color10": "#007a28",
+ "color11": "#8a6408",
+ "color12": "#007a9e",
+ "color13": "#4d2699",
+ "color14": "#007a9e",
+ "color15": "#1a1a1a",
+ "background": "#fafafa",
+ "foreground": "#1a1a1a"
}
```
+
-## lahabana
+Lahabana
```json
{
- "color0": "#363537",
+ "color0": "#19191a",
"color1": "#fc618d",
"color2": "#7bd88f",
"color3": "#e5ff9d",
@@ -58,41 +65,21 @@
"color5": "#948ae3",
"color6": "#5ad4e6",
"color7": "#f7f1ff",
- "color8": "#69676c",
+ "color8": "#19191a",
"color9": "#fc618d",
"color10": "#7bd88f",
"color11": "#e5ff9d",
"color12": "#fd9353",
"color13": "#948ae3",
"color14": "#5ad4e6",
- "color15": "#f7f1ff"
-}
-```
-
-## seul
-
-```json
-{
- "color0": "#1b1b1bff",
- "color1": "#FF4C8B",
- "color2": "#7FFFD4",
- "color3": "#FFD84C",
- "color4": "#00FFA8",
- "color5": "#D36CFF",
- "color6": "#47CFFF",
- "color7": "#f7f1ff",
- "color8": "#69676c",
- "color9": "#FF4C8B",
- "color10": "#7FFFD4",
- "color11": "#FFD84C",
- "color12": "#00FFA8",
- "color13": "#D36CFF",
- "color14": "#47CFFF",
- "color15": "#f7f1ff"
+ "color15": "#f7f1ff",
+ "background": "#19191a",
+ "foreground": "#f7f1ff"
}
```
+
-## miami
+Miami
```json
{
@@ -111,38 +98,44 @@
"color12": "#00FFA8",
"color13": "#D36CFF",
"color14": "#47CFFF",
- "color15": "#f7f1ff"
+ "color15": "#f7f1ff",
+ "background": "#000000",
+ "foreground": "#f7f1ff"
}
```
+
-## paris
+Paris
```json
{
- "color0": "#222222",
+ "color0": "#1a0a30",
"color1": "#fc618d",
"color2": "#7bd88f",
"color3": "#fce566",
"color4": "#a3f3ff",
"color5": "#c4bdff",
"color6": "#a3f3ff",
- "color7": "#f7f1ff",
- "color8": "#525053",
+ "color7": "#1a0a30",
+ "color8": "#c4bdff",
"color9": "#fc618d",
"color10": "#7bd88f",
"color11": "#fce566",
"color12": "#a3f3ff",
"color13": "#c4bdff",
"color14": "#a3f3ff",
- "color15": "#f7f1ff"
+ "color15": "#f7f1ff",
+ "background": "#1a0a30",
+ "foreground": "#f7f1ff"
}
```
+
-## tokio
+Tokio
```json
{
- "color0": "#363537",
+ "color0": "#1c1c1d",
"color1": "#fc618d",
"color2": "#7bd88f",
"color3": "#fce566",
@@ -150,18 +143,21 @@
"color5": "#948ae3",
"color6": "#5ad4e6",
"color7": "#f7f1ff",
- "color8": "#69676c",
+ "color8": "#1c1c1d",
"color9": "#fc618d",
"color10": "#7bd88f",
"color11": "#fce566",
"color12": "#fd9353",
"color13": "#948ae3",
"color14": "#5ad4e6",
- "color15": "#f7f1ff"
+ "color15": "#f7f1ff",
+ "background": "#1c1c1d",
+ "foreground": "#f7f1ff"
}
```
+
-## oslo
+Oslo
```json
{
@@ -180,22 +176,25 @@
"color12": "#4dc4ff",
"color13": "#de73ff",
"color14": "#4cd1e0",
- "color15": "#ffffff"
+ "color15": "#ffffff",
+ "background": "#3f4451",
+ "foreground": "#abb2bf"
}
```
+
-## helsinki
+Helsinki
```json
{
- "color0": "#c0bbae",
+ "color0": "#f8fafe",
"color1": "#1faa9e",
"color2": "#733d9a",
"color3": "#2e70ad",
"color4": "#b55a0f",
"color5": "#3e9d21",
"color6": "#bd4c3d",
- "color7": "#191919",
+ "color7": "#544d40",
"color8": "#b0a999",
"color9": "#009e91",
"color10": "#5a1f8a",
@@ -203,11 +202,14 @@
"color12": "#b23b00",
"color13": "#218c00",
"color14": "#b32e1f",
- "color15": "#000000"
+ "color15": "#000000",
+ "background": "#f8fafe",
+ "foreground": "#544d40"
}
```
+
-## berlin
+Berlin
```json
{
@@ -226,22 +228,25 @@
"color12": "#aaaaaa",
"color13": "#cccccc",
"color14": "#eeeeee",
- "color15": "#ffffff"
+ "color15": "#ffffff",
+ "background": "#000000",
+ "foreground": "#cccccc"
}
```
+
-## london
+London
```json
{
- "color0": "#000000",
+ "color0": "#ffffff",
"color1": "#333333",
"color2": "#444444",
"color3": "#555555",
"color4": "#666666",
"color5": "#777777",
"color6": "#888888",
- "color7": "#999999",
+ "color7": "#333333",
"color8": "#333333",
"color9": "#444444",
"color10": "#555555",
@@ -249,11 +254,14 @@
"color12": "#777777",
"color13": "#888888",
"color14": "#999999",
- "color15": "#aaaaaa"
+ "color15": "#aaaaaa",
+ "background": "#ffffff",
+ "foreground": "#333333"
}
```
+
-## praha
+Praha
```json
{
@@ -272,15 +280,18 @@
"color12": "#D6ACFF",
"color13": "#FF9AA2",
"color14": "#A4FFFF",
- "color15": "#FFFFFF"
+ "color15": "#FFFFFF",
+ "background": "#1a1a1a",
+ "foreground": "#ffffff"
}
```
+
-## bogota
+Bogota
```json
{
- "color0": "#222222",
+ "color0": "#200b0a",
"color1": "#fc618d",
"color2": "#7bd88f",
"color3": "#ffed89",
@@ -295,6 +306,8 @@
"color12": "#47e6ff",
"color13": "#ff9999",
"color14": "#47e6ff",
- "color15": "#f7f1ff"
+ "color15": "#f7f1ff",
+ "background": "#200b0a",
+ "foreground": "#f7f1ff"
}
-```
\ No newline at end of file
+```
diff --git a/crates/xtop-cli/src/main.rs b/crates/xtop-cli/src/main.rs
index 647a6cb..672cc0e 100644
--- a/crates/xtop-cli/src/main.rs
+++ b/crates/xtop-cli/src/main.rs
@@ -1,20 +1,122 @@
-use crossterm::event::{self, Event, KeyCode};
+use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyModifiers};
+use std::fs;
use std::time::{Duration, Instant};
use xtop_core::application::state::{AppState, Config, InputMode};
+use xtop_core::domain::keybinding::Action;
use xtop_core::infrastructure::config;
+use xtop_core::infrastructure::layout_loader;
use xtop_core::infrastructure::sysinfo_provider::SysinfoProvider;
use xtop_core::infrastructure::theme_loader::load_all_themes;
use xtop_tui::render;
use xtop_tui::terminal;
+fn key_event_to_str(key: &KeyEvent) -> String {
+ let mut s = String::new();
+ if key.modifiers.contains(KeyModifiers::CONTROL) {
+ s.push_str("ctrl+");
+ }
+ if key.modifiers.contains(KeyModifiers::ALT) {
+ s.push_str("alt+");
+ }
+ match key.code {
+ KeyCode::Char(c) => s.push(c),
+ KeyCode::Esc => s.push_str("escape"),
+ KeyCode::Enter => s.push_str("enter"),
+ KeyCode::Backspace => s.push_str("backspace"),
+ KeyCode::Tab => s.push_str("tab"),
+ KeyCode::Up => s.push_str("up"),
+ KeyCode::Down => s.push_str("down"),
+ KeyCode::Left => s.push_str("left"),
+ KeyCode::Right => s.push_str("right"),
+ KeyCode::Delete => s.push_str("delete"),
+ KeyCode::Home => s.push_str("home"),
+ KeyCode::End => s.push_str("end"),
+ KeyCode::PageUp => s.push_str("pageup"),
+ KeyCode::PageDown => s.push_str("pagedown"),
+ _ => return String::new(),
+ }
+ s
+}
+
+// Embedded default asset files (shipped with the binary)
+const DEFAULT_THEMES: &[(&str, &str)] = &[
+ ("x", include_str!("../../../assets/themes/x.jsonc")),
+ ("madrid", include_str!("../../../assets/themes/madrid.jsonc")),
+ ("lahabana", include_str!("../../../assets/themes/lahabana.jsonc")),
+ ("paris", include_str!("../../../assets/themes/paris.jsonc")),
+ ("tokio", include_str!("../../../assets/themes/tokio.jsonc")),
+ ("oslo", include_str!("../../../assets/themes/oslo.jsonc")),
+ ("helsinki", include_str!("../../../assets/themes/helsinki.jsonc")),
+ ("berlin", include_str!("../../../assets/themes/berlin.jsonc")),
+ ("london", include_str!("../../../assets/themes/london.jsonc")),
+ ("praha", include_str!("../../../assets/themes/praha.jsonc")),
+ ("bogota", include_str!("../../../assets/themes/bogota.jsonc")),
+];
+
+const DEFAULT_LAYOUTS: &[(&str, &str)] = &[
+ ("dashboard", include_str!("../../../assets/layouts/dashboard.jsonc")),
+ ("vertical", include_str!("../../../assets/layouts/vertical.jsonc")),
+ ("horizontal", include_str!("../../../assets/layouts/horizontal.jsonc")),
+ ("cpu_focus", include_str!("../../../assets/layouts/cpu_focus.jsonc")),
+ ("memory_focus", include_str!("../../../assets/layouts/memory_focus.jsonc")),
+ ("network_focus", include_str!("../../../assets/layouts/network_focus.jsonc")),
+ ("process_focus", include_str!("../../../assets/layouts/process_focus.jsonc")),
+];
+
+fn ensure_default_assets() {
+ let theme_assets: &[(&str, &str)] = DEFAULT_THEMES;
+ let layout_assets: &[(&str, &str)] = DEFAULT_LAYOUTS;
+
+ let dir = xtop_core::infrastructure::theme_loader::themes_dir();
+ if !dir.join(".xtop_initialized").exists() {
+ fs::create_dir_all(&dir).ok();
+ for (name, content) in theme_assets {
+ let path = dir.join(format!("{name}.jsonc"));
+ if !path.exists() {
+ fs::write(&path, content).ok();
+ }
+ }
+ fs::write(dir.join(".xtop_initialized"), "").ok();
+ }
+
+ let dir = xtop_core::infrastructure::layout_loader::layouts_dir();
+ if !dir.join(".xtop_initialized").exists() {
+ fs::create_dir_all(&dir).ok();
+ for (name, content) in layout_assets {
+ let path = dir.join(format!("{name}.jsonc"));
+ if !path.exists() {
+ fs::write(&path, content).ok();
+ }
+ }
+ fs::write(dir.join(".xtop_initialized"), "").ok();
+ }
+}
+
+fn save_config(state: &AppState) {
+ let cfg = Config {
+ theme: state.current_theme.name.clone(),
+ layout_mode: state.save_layout_mode(),
+ update_interval_ms: state.update_interval_ms,
+ history_points: 100,
+ alerts: state.alerts,
+ keybindings: state.keybindings.clone(),
+ };
+ let _ = config::save_config(&cfg);
+}
+
fn main() -> anyhow::Result<()> {
+ ensure_default_assets();
+
terminal::install_panic_hook();
let mut terminal = terminal::init()?;
let provider = SysinfoProvider::new();
let themes = load_all_themes();
let cfg = config::load_config();
- let mut state = AppState::new(Box::new(provider), themes, cfg);
+ let mut builtin_layouts = layout_loader::builtin_layouts();
+ let custom_layouts = layout_loader::load_custom_layouts();
+ builtin_layouts.extend(custom_layouts);
+ let mut state = AppState::new(Box::new(provider), themes, cfg, builtin_layouts);
let tick_rate = Duration::from_millis(state.update_interval_ms);
let mut last_tick = Instant::now();
@@ -28,33 +130,28 @@ fn main() -> anyhow::Result<()> {
if event::poll(timeout)? {
if let Event::Key(key) = event::read()? {
+ let key_str = key_event_to_str(&key);
match state.input_mode {
- InputMode::Normal => match key.code {
- KeyCode::Char('q') => {
- let cfg = Config {
- theme: state.current_theme.name.clone(),
- layout_mode: state.layout_mode,
- update_interval_ms: state.update_interval_ms,
- history_points: 100,
- alerts: state.alerts,
- };
- let _ = config::save_config(&cfg);
- state.quit();
- }
- KeyCode::Char('?') => state.toggle_help(),
- KeyCode::Char('t') => state.next_theme(),
- KeyCode::Char('T') => state.previous_theme(),
- KeyCode::Char('l') => state.next_layout(),
- KeyCode::Char('f') => state.toggle_fullscreen(),
- KeyCode::Char('F') => state.cycle_fullscreen_widget(),
- KeyCode::Char('/') => state.start_search(),
- KeyCode::Esc => {
- if state.show_help {
- state.toggle_help();
+ InputMode::Normal => {
+ if let Some(action) = state.keybindings.resolve(&key_str) {
+ match action {
+ Action::Quit => {
+ save_config(&state);
+ state.quit();
+ }
+ Action::Cancel if state.show_help => {
+ state.toggle_help();
+ }
+ Action::OpenCommandPalette => {
+ state.open_palette();
+ state.input_mode = InputMode::CommandPalette;
+ }
+ _ => {
+ state.execute_action(&action);
+ }
}
}
- _ => {}
- },
+ }
InputMode::Searching => match key.code {
KeyCode::Esc => {
state.search_query.clear();
@@ -71,6 +168,35 @@ fn main() -> anyhow::Result<()> {
}
_ => {}
},
+ InputMode::CommandPalette => match key.code {
+ KeyCode::Esc => {
+ state.close_palette();
+ }
+ KeyCode::Enter => {
+ if let Some(action) = state.palette_selected_action() {
+ state.close_palette();
+ state.execute_action(&action);
+ if action == Action::Quit {
+ save_config(&state);
+ }
+ }
+ }
+ KeyCode::Down => {
+ state.palette_select_next();
+ }
+ KeyCode::Up => {
+ state.palette_select_prev();
+ }
+ KeyCode::Char(c) => {
+ state.palette.query.push(c);
+ state.palette_filter();
+ }
+ KeyCode::Backspace => {
+ state.palette.query.pop();
+ state.palette_filter();
+ }
+ _ => {}
+ },
}
}
}
diff --git a/crates/xtop-core/src/application/state.rs b/crates/xtop-core/src/application/state.rs
index 8f962b3..85d47a7 100644
--- a/crates/xtop-core/src/application/state.rs
+++ b/crates/xtop-core/src/application/state.rs
@@ -1,4 +1,6 @@
use crate::application::history::MetricsHistory;
+use crate::domain::keybinding::{Action, Keybindings};
+use crate::domain::layout::LayoutDef;
use crate::domain::metrics::SystemSnapshot;
use crate::domain::system_info::SystemDataProvider;
use crate::domain::theme::Theme;
@@ -54,6 +56,24 @@ pub enum EffectiveLayout {
Minimal,
}
+fn layout_index_from_mode(mode: LayoutMode, defs: &[LayoutDef]) -> usize {
+ let label = mode.label();
+ defs.iter().position(|d| d.name == label).unwrap_or(0)
+}
+
+fn mode_from_layout_index(index: usize) -> LayoutMode {
+ match index {
+ 0 => LayoutMode::Dashboard,
+ 1 => LayoutMode::Vertical,
+ 2 => LayoutMode::Horizontal,
+ 3 => LayoutMode::CpuFocus,
+ 4 => LayoutMode::MemoryFocus,
+ 5 => LayoutMode::NetworkFocus,
+ 6 => LayoutMode::ProcessFocus,
+ _ => LayoutMode::Dashboard,
+ }
+}
+
pub fn detect_effective_layout(width: u16, height: u16, user_mode: LayoutMode) -> EffectiveLayout {
if width < 60 || height < 14 {
return EffectiveLayout::Minimal;
@@ -120,10 +140,32 @@ impl FullScreenWidget {
}
}
+#[derive(Clone, Debug, PartialEq)]
+pub struct PaletteEntry {
+ pub label: String,
+ pub action: Action,
+}
+
+#[derive(Clone, Debug, PartialEq)]
+pub struct PaletteState {
+ pub open: bool,
+ pub query: String,
+ pub selected: usize,
+ pub entries: Vec,
+ pub filtered: Vec,
+}
+
+impl PaletteState {
+ pub fn filtered_entries(&self) -> Vec<&PaletteEntry> {
+ self.filtered.iter().map(|&i| &self.entries[i]).collect()
+ }
+}
+
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum InputMode {
Normal,
Searching,
+ CommandPalette,
}
#[derive(Copy, Clone, Debug, Serialize, Deserialize)]
@@ -150,16 +192,19 @@ pub struct Config {
pub update_interval_ms: u64,
pub history_points: usize,
pub alerts: AlertThresholds,
+ #[serde(default)]
+ pub keybindings: Keybindings,
}
impl Default for Config {
fn default() -> Self {
Self {
- theme: "x".to_string(),
+ theme: "miami".to_string(),
layout_mode: LayoutMode::Dashboard,
update_interval_ms: 1000,
history_points: 100,
alerts: AlertThresholds::default(),
+ keybindings: Keybindings::default(),
}
}
}
@@ -169,6 +214,8 @@ pub struct AppState {
pub history: MetricsHistory,
pub should_quit: bool,
pub layout_mode: LayoutMode,
+ pub layout_index: usize,
+ pub layout_defs: Vec,
pub current_theme: Theme,
pub themes: Vec,
pub selected_theme_index: usize,
@@ -180,20 +227,30 @@ pub struct AppState {
pub alerts: AlertThresholds,
pub update_interval_ms: u64,
pub config_path: String,
+ pub palette: PaletteState,
+ pub keybindings: Keybindings,
}
impl AppState {
- pub fn new(provider: Box, themes: Vec, config: Config) -> Self {
+ pub fn new(
+ provider: Box,
+ themes: Vec,
+ config: Config,
+ layout_defs: Vec,
+ ) -> Self {
let selected_theme_index = themes
.iter()
.position(|t| t.name == config.theme)
.unwrap_or(0);
let current_theme = themes[selected_theme_index].clone();
+ let layout_index = layout_index_from_mode(config.layout_mode, &layout_defs);
Self {
provider,
history: MetricsHistory::new(config.history_points),
should_quit: false,
layout_mode: config.layout_mode,
+ layout_index,
+ layout_defs,
current_theme,
themes,
selected_theme_index,
@@ -205,9 +262,25 @@ impl AppState {
alerts: config.alerts,
update_interval_ms: config.update_interval_ms,
config_path: String::new(),
+ palette: PaletteState {
+ open: false,
+ query: String::new(),
+ selected: 0,
+ entries: Vec::new(),
+ filtered: Vec::new(),
+ },
+ keybindings: config.keybindings,
}
}
+ pub fn current_layout(&self) -> &LayoutDef {
+ &self.layout_defs[self.layout_index]
+ }
+
+ pub fn save_layout_mode(&self) -> LayoutMode {
+ mode_from_layout_index(self.layout_index)
+ }
+
pub fn on_tick(&mut self) {
self.provider.refresh_all();
self.tick_count += 1.0;
@@ -256,10 +329,15 @@ impl AppState {
}
pub fn next_layout(&mut self) {
- self.layout_mode = self.layout_mode.next();
+ self.layout_index = (self.layout_index + 1) % self.layout_defs.len();
+ self.layout_mode = self.save_layout_mode();
self.full_screen_widget = FullScreenWidget::None;
}
+ pub fn current_layout_name(&self) -> &str {
+ &self.layout_defs[self.layout_index].name
+ }
+
pub fn toggle_fullscreen(&mut self) {
self.full_screen_widget = match self.full_screen_widget {
FullScreenWidget::None => FullScreenWidget::Cpu,
@@ -295,6 +373,122 @@ impl AppState {
pub fn quit(&mut self) {
self.should_quit = true;
}
+
+ pub fn open_palette(&mut self) {
+ self.palette.open = true;
+ self.palette.query.clear();
+ self.palette.selected = 0;
+ self.palette.entries.clear();
+
+ for (i, theme) in self.themes.iter().enumerate() {
+ self.palette.entries.push(PaletteEntry {
+ label: format!("Theme: {}", theme.name),
+ action: Action::SelectTheme(i),
+ });
+ }
+ for (i, layout) in self.layout_defs.iter().enumerate() {
+ self.palette.entries.push(PaletteEntry {
+ label: format!("Layout: {}", layout.name),
+ action: Action::SelectLayout(i),
+ });
+ }
+ self.palette.entries.push(PaletteEntry {
+ label: "Toggle Fullscreen".into(),
+ action: Action::ToggleFullscreen,
+ });
+ self.palette.entries.push(PaletteEntry {
+ label: "Cycle Fullscreen Widget".into(),
+ action: Action::CycleFullscreen,
+ });
+ self.palette.entries.push(PaletteEntry {
+ label: "Search Processes".into(),
+ action: Action::Search,
+ });
+ self.palette.entries.push(PaletteEntry {
+ label: "Toggle Help".into(),
+ action: Action::ToggleHelp,
+ });
+ self.palette.entries.push(PaletteEntry {
+ label: "Quit".into(),
+ action: Action::Quit,
+ });
+
+ self.palette_filter();
+ }
+
+ pub fn palette_filter(&mut self) {
+ let q = self.palette.query.to_lowercase();
+ self.palette.filtered = self
+ .palette
+ .entries
+ .iter()
+ .enumerate()
+ .filter(|(_, e)| q.is_empty() || e.label.to_lowercase().contains(&q))
+ .map(|(i, _)| i)
+ .collect();
+ if !self.palette.filtered.is_empty() {
+ self.palette.selected = self.palette.selected.min(self.palette.filtered.len() - 1);
+ } else {
+ self.palette.selected = 0;
+ }
+ }
+
+ pub fn palette_select_next(&mut self) {
+ if !self.palette.filtered.is_empty() {
+ self.palette.selected = (self.palette.selected + 1) % self.palette.filtered.len();
+ }
+ }
+
+ pub fn palette_select_prev(&mut self) {
+ if !self.palette.filtered.is_empty() {
+ self.palette.selected = if self.palette.selected == 0 {
+ self.palette.filtered.len() - 1
+ } else {
+ self.palette.selected - 1
+ };
+ }
+ }
+
+ pub fn palette_selected_action(&self) -> Option {
+ self.palette
+ .filtered
+ .get(self.palette.selected)
+ .and_then(|&i| self.palette.entries.get(i))
+ .map(|e| e.action.clone())
+ }
+
+ pub fn close_palette(&mut self) {
+ self.palette.open = false;
+ self.input_mode = InputMode::Normal;
+ }
+
+ pub fn execute_action(&mut self, action: &Action) {
+ match action {
+ Action::Quit => self.quit(),
+ Action::ToggleHelp => self.toggle_help(),
+ Action::NextTheme => self.next_theme(),
+ Action::PreviousTheme => self.previous_theme(),
+ Action::NextLayout => self.next_layout(),
+ Action::ToggleFullscreen => self.toggle_fullscreen(),
+ Action::CycleFullscreen => self.cycle_fullscreen_widget(),
+ Action::Search => self.start_search(),
+ Action::OpenCommandPalette => {}
+ Action::Cancel => {
+ if self.show_help {
+ self.toggle_help();
+ }
+ }
+ Action::SelectTheme(i) => {
+ self.selected_theme_index = *i;
+ self.apply_theme();
+ }
+ Action::SelectLayout(i) => {
+ self.layout_index = *i;
+ self.layout_mode = self.save_layout_mode();
+ self.full_screen_widget = FullScreenWidget::None;
+ }
+ }
+ }
}
#[cfg(test)]
@@ -388,7 +582,7 @@ mod tests {
#[test]
fn test_config_default() {
let c = Config::default();
- assert_eq!(c.theme, "x");
+ assert_eq!(c.theme, "miami");
assert_eq!(c.layout_mode, LayoutMode::Dashboard);
assert_eq!(c.update_interval_ms, 1000);
}
diff --git a/crates/xtop-core/src/domain/keybinding.rs b/crates/xtop-core/src/domain/keybinding.rs
new file mode 100644
index 0000000..d962e44
--- /dev/null
+++ b/crates/xtop-core/src/domain/keybinding.rs
@@ -0,0 +1,105 @@
+use serde::{Deserialize, Serialize};
+
+#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
+pub struct Keybindings {
+ #[serde(default = "vec_one_q")]
+ pub quit: Vec,
+ #[serde(default = "vec_one_question")]
+ pub help: Vec,
+ #[serde(default = "vec_one_t")]
+ pub next_theme: Vec,
+ #[serde(default = "vec_one_shift_t")]
+ pub prev_theme: Vec,
+ #[serde(default = "vec_one_l")]
+ pub next_layout: Vec,
+ #[serde(default = "vec_one_f")]
+ pub toggle_fullscreen: Vec,
+ #[serde(default = "vec_one_shift_f")]
+ pub cycle_fullscreen: Vec,
+ #[serde(default = "vec_one_slash")]
+ pub search: Vec,
+ #[serde(default = "vec_one_ctrl_p")]
+ pub command_palette: Vec,
+ #[serde(default = "vec_one_escape")]
+ pub cancel: Vec,
+}
+
+fn vec_one_q() -> Vec { vec!["q".into()] }
+fn vec_one_question() -> Vec { vec!["?".into()] }
+fn vec_one_t() -> Vec { vec!["t".into()] }
+fn vec_one_shift_t() -> Vec { vec!["T".into()] }
+fn vec_one_l() -> Vec { vec!["l".into()] }
+fn vec_one_f() -> Vec { vec!["f".into()] }
+fn vec_one_shift_f() -> Vec { vec!["F".into()] }
+fn vec_one_slash() -> Vec { vec!["/".into()] }
+fn vec_one_ctrl_p() -> Vec { vec!["ctrl+p".into()] }
+fn vec_one_escape() -> Vec { vec!["escape".into()] }
+
+impl Default for Keybindings {
+ fn default() -> Self {
+ Self {
+ quit: vec_one_q(),
+ help: vec_one_question(),
+ next_theme: vec_one_t(),
+ prev_theme: vec_one_shift_t(),
+ next_layout: vec_one_l(),
+ toggle_fullscreen: vec_one_f(),
+ cycle_fullscreen: vec_one_shift_f(),
+ search: vec_one_slash(),
+ command_palette: vec_one_ctrl_p(),
+ cancel: vec_one_escape(),
+ }
+ }
+}
+
+#[derive(Clone, Debug, PartialEq)]
+pub enum Action {
+ Quit,
+ ToggleHelp,
+ NextTheme,
+ PreviousTheme,
+ NextLayout,
+ ToggleFullscreen,
+ CycleFullscreen,
+ Search,
+ OpenCommandPalette,
+ Cancel,
+ SelectTheme(usize),
+ SelectLayout(usize),
+}
+
+impl Keybindings {
+ pub fn resolve(&self, key_str: &str) -> Option {
+ if self.quit.contains(&key_str.to_string()) {
+ return Some(Action::Quit);
+ }
+ if self.help.contains(&key_str.to_string()) {
+ return Some(Action::ToggleHelp);
+ }
+ if self.next_theme.contains(&key_str.to_string()) {
+ return Some(Action::NextTheme);
+ }
+ if self.prev_theme.contains(&key_str.to_string()) {
+ return Some(Action::PreviousTheme);
+ }
+ if self.next_layout.contains(&key_str.to_string()) {
+ return Some(Action::NextLayout);
+ }
+ if self.toggle_fullscreen.contains(&key_str.to_string()) {
+ return Some(Action::ToggleFullscreen);
+ }
+ if self.cycle_fullscreen.contains(&key_str.to_string()) {
+ return Some(Action::CycleFullscreen);
+ }
+ if self.search.contains(&key_str.to_string()) {
+ return Some(Action::Search);
+ }
+ if self.command_palette.contains(&key_str.to_string()) {
+ return Some(Action::OpenCommandPalette);
+ }
+ if self.cancel.contains(&key_str.to_string()) {
+ return Some(Action::Cancel);
+ }
+ None
+ }
+}
diff --git a/crates/xtop-core/src/domain/layout.rs b/crates/xtop-core/src/domain/layout.rs
new file mode 100644
index 0000000..c6d2ba4
--- /dev/null
+++ b/crates/xtop-core/src/domain/layout.rs
@@ -0,0 +1,184 @@
+use serde::de::{self, MapAccess, Visitor};
+use serde::Deserialize;
+use std::fmt;
+
+#[derive(Clone, Debug, PartialEq)]
+pub enum Direction {
+ Horizontal,
+ Vertical,
+}
+
+#[derive(Clone, Debug, PartialEq)]
+pub enum LayoutConstraint {
+ Length(u16),
+ Percentage(u16),
+ Fill,
+}
+
+#[derive(Clone, Debug, PartialEq)]
+pub struct LayoutArea {
+ pub constraint: LayoutConstraint,
+ pub node: LayoutNode,
+}
+
+#[derive(Clone, Debug, PartialEq)]
+pub enum LayoutNode {
+ Split {
+ direction: Direction,
+ areas: Vec,
+ },
+ Widget {
+ name: String,
+ },
+}
+
+#[derive(Clone, Debug, PartialEq)]
+pub struct LayoutDef {
+ pub name: String,
+ pub root: LayoutNode,
+}
+
+// ---------------------------------------------------------------------------
+// Deserialization helpers
+// ---------------------------------------------------------------------------
+
+#[derive(Deserialize)]
+struct LayoutDefRaw {
+ name: String,
+ root: LayoutAreaRaw,
+}
+
+#[derive(Deserialize)]
+struct LayoutAreaRaw {
+ #[serde(default)]
+ size: Option,
+ widget: Option,
+ direction: Option,
+ #[serde(default)]
+ areas: Option>,
+}
+
+#[derive(Deserialize)]
+#[serde(untagged)]
+enum SizeRaw {
+ Num(u16),
+ Str(String),
+}
+
+impl TryFrom for LayoutArea {
+ type Error = String;
+
+ fn try_from(raw: LayoutAreaRaw) -> Result {
+ let constraint = match raw.size {
+ None => LayoutConstraint::Fill,
+ Some(SizeRaw::Num(n)) => LayoutConstraint::Length(n),
+ Some(SizeRaw::Str(s)) if s == "*" => LayoutConstraint::Fill,
+ Some(SizeRaw::Str(s)) if s.ends_with('%') => {
+ let pct = s.trim_end_matches('%').parse::().map_err(|_| {
+ format!("invalid percentage: {s}")
+ })?;
+ LayoutConstraint::Percentage(pct)
+ }
+ Some(SizeRaw::Str(s)) => {
+ return Err(format!("invalid size constraint: {s}"));
+ }
+ };
+
+ let node = if let Some(name) = raw.widget {
+ LayoutNode::Widget { name }
+ } else if let Some(dir) = raw.direction {
+ let direction = match dir.to_lowercase().as_str() {
+ "horizontal" => Direction::Horizontal,
+ "vertical" => Direction::Vertical,
+ _ => return Err(format!("invalid direction: {dir}")),
+ };
+ let areas_raw = raw.areas.unwrap_or_default();
+ let mut areas = Vec::with_capacity(areas_raw.len());
+ for a in areas_raw {
+ areas.push(a.try_into()?);
+ }
+ LayoutNode::Split { direction, areas }
+ } else {
+ return Err("layout area must have 'widget' or 'direction'".into());
+ };
+
+ Ok(LayoutArea { constraint, node })
+ }
+}
+
+impl TryFrom for LayoutDef {
+ type Error = String;
+
+ fn try_from(raw: LayoutDefRaw) -> Result {
+ let area: LayoutArea = raw.root.try_into()?;
+ Ok(LayoutDef {
+ name: raw.name,
+ root: area.node,
+ })
+ }
+}
+
+// Custom Deserialize for LayoutDef (handles jsonc-compatible parsing)
+impl<'de> Deserialize<'de> for LayoutDef {
+ fn deserialize(deserializer: D) -> Result
+ where
+ D: serde::Deserializer<'de>,
+ {
+ #[derive(Deserialize)]
+ #[serde(field_identifier, rename_all = "snake_case")]
+ enum Field {
+ Name,
+ Root,
+ }
+
+ struct LayoutVisitor;
+ impl<'de> Visitor<'de> for LayoutVisitor {
+ type Value = LayoutDef;
+
+ fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
+ f.write_str("struct LayoutDef")
+ }
+
+ fn visit_map(self, mut map: V) -> Result
+ where
+ V: MapAccess<'de>,
+ {
+ let mut raw = LayoutDefRaw {
+ name: String::new(),
+ root: LayoutAreaRaw {
+ size: None,
+ widget: None,
+ direction: None,
+ areas: None,
+ },
+ };
+ let mut found_name = false;
+ let mut found_root = false;
+
+ while let Some(key) = map.next_key::()? {
+ match key {
+ Field::Name => {
+ raw.name = map.next_value::()?;
+ found_name = true;
+ }
+ Field::Root => {
+ raw.root = map.next_value::()?;
+ found_root = true;
+ }
+ }
+ }
+
+ if !found_name {
+ return Err(de::Error::missing_field("name"));
+ }
+ if !found_root {
+ return Err(de::Error::missing_field("root"));
+ }
+
+ LayoutDef::try_from(raw).map_err(de::Error::custom)
+ }
+ }
+
+ deserializer.deserialize_struct("LayoutDef", &["name", "root"], LayoutVisitor)
+ }
+}
diff --git a/crates/xtop-core/src/domain/mod.rs b/crates/xtop-core/src/domain/mod.rs
index c211982..142ab3d 100644
--- a/crates/xtop-core/src/domain/mod.rs
+++ b/crates/xtop-core/src/domain/mod.rs
@@ -1,3 +1,5 @@
+pub mod keybinding;
+pub mod layout;
pub mod metrics;
pub mod system_info;
pub mod theme;
diff --git a/crates/xtop-core/src/domain/theme.rs b/crates/xtop-core/src/domain/theme.rs
index df52c87..12db302 100644
--- a/crates/xtop-core/src/domain/theme.rs
+++ b/crates/xtop-core/src/domain/theme.rs
@@ -1,6 +1,16 @@
+use serde::de::{self, Deserializer, MapAccess, Visitor};
use serde::{Deserialize, Serialize};
+use std::fmt;
-#[derive(Clone, Debug, Serialize, Deserialize)]
+fn hex_to_rgb(hex: &str) -> [u8; 3] {
+ let hex = hex.trim_start_matches('#');
+ let r = u8::from_str_radix(hex.get(0..2).unwrap_or("00"), 16).unwrap_or(0);
+ let g = u8::from_str_radix(hex.get(2..4).unwrap_or("00"), 16).unwrap_or(0);
+ let b = u8::from_str_radix(hex.get(4..6).unwrap_or("00"), 16).unwrap_or(0);
+ [r, g, b]
+}
+
+#[derive(Clone, Debug, Serialize)]
pub struct Theme {
pub name: String,
pub palette: [[u8; 3]; 16],
@@ -15,3 +25,69 @@ impl Theme {
&self.palette[7]
}
}
+
+// Custom deserializer that reads palette as [String; 16] (hex) and converts to [[u8; 3]; 16]
+impl<'de> Deserialize<'de> for Theme {
+ fn deserialize(deserializer: D) -> Result
+ where
+ D: Deserializer<'de>,
+ {
+ #[derive(Deserialize)]
+ #[serde(field_identifier, rename_all = "snake_case")]
+ enum Field {
+ Name,
+ Palette,
+ }
+
+ struct ThemeVisitor;
+ impl<'de> Visitor<'de> for ThemeVisitor {
+ type Value = Theme;
+
+ fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
+ formatter.write_str("struct Theme")
+ }
+
+ fn visit_map(self, mut map: V) -> Result
+ where
+ V: MapAccess<'de>,
+ {
+ let mut name = None;
+ let mut palette: Option<[String; 16]> = None;
+
+ while let Some(key) = map.next_key::()? {
+ match key {
+ Field::Name => {
+ if name.is_some() {
+ return Err(de::Error::duplicate_field("name"));
+ }
+ name = Some(map.next_value::()?);
+ }
+ Field::Palette => {
+ if palette.is_some() {
+ return Err(de::Error::duplicate_field("palette"));
+ }
+ palette = Some(map.next_value::<[String; 16]>()?);
+ }
+ }
+ }
+
+ let name = name.ok_or_else(|| de::Error::missing_field("name"))?;
+ let palette_str =
+ palette.ok_or_else(|| de::Error::missing_field("palette"))?;
+
+ let mut palette = [[0u8; 3]; 16];
+ for (i, hex) in palette_str.iter().enumerate() {
+ palette[i] = hex_to_rgb(hex);
+ }
+
+ Ok(Theme { name, palette })
+ }
+ }
+
+ deserializer.deserialize_struct("Theme", &["name", "palette"], ThemeVisitor)
+ }
+}
+
+pub fn hex_to_rgb_pub(hex: &str) -> [u8; 3] {
+ hex_to_rgb(hex)
+}
diff --git a/crates/xtop-core/src/infrastructure/config.rs b/crates/xtop-core/src/infrastructure/config.rs
index 7416ee9..d869d9e 100644
--- a/crates/xtop-core/src/infrastructure/config.rs
+++ b/crates/xtop-core/src/infrastructure/config.rs
@@ -16,10 +16,6 @@ pub fn config_path() -> PathBuf {
config_dir().join("config.json")
}
-pub fn themes_dir() -> PathBuf {
- config_dir().join("themes")
-}
-
pub fn load_config() -> Config {
let path = config_path();
if let Ok(data) = fs::read_to_string(&path) {
@@ -39,24 +35,3 @@ pub fn save_config(config: &Config) -> Result<(), String> {
fs::write(&path, data).map_err(|e| e.to_string())?;
Ok(())
}
-
-pub fn load_custom_themes() -> Vec {
- let dir = themes_dir();
- if !dir.exists() {
- return vec![];
- }
- let mut themes = vec![];
- if let Ok(entries) = fs::read_dir(&dir) {
- for entry in entries.flatten() {
- let path = entry.path();
- if path.extension().and_then(|e| e.to_str()) == Some("json") {
- if let Ok(data) = fs::read_to_string(&path) {
- if let Ok(theme) = serde_json::from_str::(&data) {
- themes.push(theme);
- }
- }
- }
- }
- }
- themes
-}
diff --git a/crates/xtop-core/src/infrastructure/layout_loader.rs b/crates/xtop-core/src/infrastructure/layout_loader.rs
new file mode 100644
index 0000000..3a059d6
--- /dev/null
+++ b/crates/xtop-core/src/infrastructure/layout_loader.rs
@@ -0,0 +1,264 @@
+use crate::domain::layout::{Direction, LayoutArea, LayoutConstraint, LayoutDef, LayoutNode};
+use std::fs;
+use std::path::Path;
+
+fn strip_jsonc_comments(input: &str) -> String {
+ let mut out = String::with_capacity(input.len());
+ let chars: Vec = input.chars().collect();
+ let mut i = 0;
+ while i < chars.len() {
+ if chars[i] == '/' && i + 1 < chars.len() {
+ if chars[i + 1] == '/' {
+ i += 2;
+ while i < chars.len() && chars[i] != '\n' {
+ i += 1;
+ }
+ continue;
+ }
+ if chars[i + 1] == '*' {
+ i += 2;
+ while i + 1 < chars.len() && !(chars[i] == '*' && chars[i + 1] == '/') {
+ i += 1;
+ }
+ i += 2;
+ continue;
+ }
+ }
+ out.push(chars[i]);
+ i += 1;
+ }
+ out
+}
+
+fn load_layout_from_file(path: &Path) -> Option {
+ let data = fs::read_to_string(path).ok()?;
+ let cleaned = strip_jsonc_comments(&data);
+ serde_json::from_str::(&cleaned).ok()
+}
+
+pub fn layouts_dir() -> std::path::PathBuf {
+ if let Ok(xdg) = std::env::var("XDG_CONFIG_HOME") {
+ std::path::PathBuf::from(xdg).join("xtop").join("layouts")
+ } else if let Ok(home) = std::env::var("HOME") {
+ std::path::PathBuf::from(home)
+ .join(".config")
+ .join("xtop")
+ .join("layouts")
+ } else {
+ std::path::PathBuf::from(".")
+ .join(".config")
+ .join("xtop")
+ .join("layouts")
+ }
+}
+
+pub fn load_custom_layouts() -> Vec {
+ let dir = layouts_dir();
+ if !dir.exists() {
+ return vec![];
+ }
+ let mut layouts = vec![];
+ if let Ok(entries) = fs::read_dir(&dir) {
+ for entry in entries.flatten() {
+ let path = entry.path();
+ let ext = path.extension().and_then(|e| e.to_str());
+ if ext == Some("json") || ext == Some("jsonc") {
+ if let Some(layout) = load_layout_from_file(&path) {
+ layouts.push(layout);
+ }
+ }
+ }
+ }
+ layouts
+}
+
+pub fn builtin_layouts() -> Vec {
+ vec![
+ dashboard_layout(),
+ vertical_layout(),
+ horizontal_layout(),
+ cpu_focus_layout(),
+ memory_focus_layout(),
+ network_focus_layout(),
+ process_focus_layout(),
+ ]
+}
+
+fn area(size: u16, node: LayoutNode) -> LayoutArea {
+ LayoutArea {
+ constraint: LayoutConstraint::Length(size),
+ node,
+ }
+}
+
+fn pct(pct: u16, node: LayoutNode) -> LayoutArea {
+ LayoutArea {
+ constraint: LayoutConstraint::Percentage(pct),
+ node,
+ }
+}
+
+fn fill(node: LayoutNode) -> LayoutArea {
+ LayoutArea {
+ constraint: LayoutConstraint::Fill,
+ node,
+ }
+}
+
+fn widget(name: &str) -> LayoutNode {
+ LayoutNode::Widget {
+ name: name.to_string(),
+ }
+}
+
+fn split_h(areas: Vec) -> LayoutNode {
+ LayoutNode::Split {
+ direction: Direction::Horizontal,
+ areas,
+ }
+}
+
+fn split_v(areas: Vec) -> LayoutNode {
+ LayoutNode::Split {
+ direction: Direction::Vertical,
+ areas,
+ }
+}
+
+fn dashboard_layout() -> LayoutDef {
+ LayoutDef {
+ name: "Dashboard".into(),
+ root: split_v(vec![
+ area(3, widget("header")),
+ pct(45, split_h(vec![
+ pct(50, widget("cpu")),
+ pct(50, split_v(vec![
+ pct(33, widget("memory")),
+ pct(33, widget("storage")),
+ pct(34, widget("network")),
+ ])),
+ ])),
+ pct(52, widget("processes")),
+ ]),
+ }
+}
+
+fn vertical_layout() -> LayoutDef {
+ LayoutDef {
+ name: "Vertical".into(),
+ root: split_v(vec![
+ area(3, widget("header")),
+ area(8, widget("cpu")),
+ area(8, widget("memory")),
+ area(6, widget("storage")),
+ area(5, widget("network")),
+ fill(widget("processes")),
+ ]),
+ }
+}
+
+fn horizontal_layout() -> LayoutDef {
+ LayoutDef {
+ name: "Horizontal".into(),
+ root: split_v(vec![
+ area(3, widget("header")),
+ fill(split_h(vec![
+ pct(25, widget("cpu")),
+ pct(25, widget("memory")),
+ pct(25, widget("storage")),
+ pct(25, widget("network")),
+ ])),
+ ]),
+ }
+}
+
+fn cpu_focus_layout() -> LayoutDef {
+ LayoutDef {
+ name: "CPU Focus".into(),
+ root: split_v(vec![
+ area(3, widget("header")),
+ pct(60, widget("cpu")),
+ fill(widget("processes")),
+ ]),
+ }
+}
+
+fn memory_focus_layout() -> LayoutDef {
+ LayoutDef {
+ name: "Memory Focus".into(),
+ root: split_v(vec![
+ area(3, widget("header")),
+ pct(60, widget("memory")),
+ fill(widget("processes")),
+ ]),
+ }
+}
+
+fn network_focus_layout() -> LayoutDef {
+ LayoutDef {
+ name: "Network Focus".into(),
+ root: split_v(vec![
+ area(3, widget("header")),
+ pct(50, split_h(vec![
+ pct(50, widget("network")),
+ pct(50, widget("disk_io")),
+ ])),
+ fill(widget("processes")),
+ ]),
+ }
+}
+
+fn process_focus_layout() -> LayoutDef {
+ LayoutDef {
+ name: "Process Focus".into(),
+ root: split_v(vec![
+ area(3, widget("header")),
+ area(8, split_h(vec![
+ pct(25, widget("cpu")),
+ pct(25, widget("memory")),
+ pct(25, widget("storage")),
+ pct(25, widget("network")),
+ ])),
+ fill(widget("processes")),
+ ]),
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn test_builtin_layouts_count() {
+ let layouts = builtin_layouts();
+ assert_eq!(layouts.len(), 7);
+ }
+
+ #[test]
+ fn test_builtin_names() {
+ let layouts = builtin_layouts();
+ let names: Vec<&str> = layouts.iter().map(|l| l.name.as_str()).collect();
+ assert!(names.contains(&"Dashboard"));
+ assert!(names.contains(&"Vertical"));
+ assert!(names.contains(&"CPU Focus"));
+ assert!(names.contains(&"Process Focus"));
+ }
+
+ #[test]
+ fn test_load_layout_from_jsonc() {
+ let jsonc = r#"{
+ // my custom layout
+ "name": "test",
+ "root": {
+ "direction": "vertical",
+ "areas": [
+ { "widget": "header", "size": 3 },
+ { "widget": "cpu", "size": "*" }
+ ]
+ }
+ }"#;
+ let cleaned = strip_jsonc_comments(jsonc);
+ let layout: LayoutDef = serde_json::from_str(&cleaned).unwrap();
+ assert_eq!(layout.name, "test");
+ }
+}
diff --git a/crates/xtop-core/src/infrastructure/mod.rs b/crates/xtop-core/src/infrastructure/mod.rs
index d2570f2..d0bdbbc 100644
--- a/crates/xtop-core/src/infrastructure/mod.rs
+++ b/crates/xtop-core/src/infrastructure/mod.rs
@@ -2,5 +2,6 @@ pub mod battery_provider;
pub mod config;
pub mod docker_provider;
pub mod gpu_provider;
+pub mod layout_loader;
pub mod sysinfo_provider;
pub mod theme_loader;
diff --git a/crates/xtop-core/src/infrastructure/theme_loader.rs b/crates/xtop-core/src/infrastructure/theme_loader.rs
index 376822a..492d4bc 100644
--- a/crates/xtop-core/src/infrastructure/theme_loader.rs
+++ b/crates/xtop-core/src/infrastructure/theme_loader.rs
@@ -1,17 +1,11 @@
use crate::domain::theme::Theme;
-
-fn hex_to_rgb(hex: &str) -> [u8; 3] {
- let hex = hex.trim_start_matches('#');
- let r = u8::from_str_radix(hex.get(0..2).unwrap_or("00"), 16).unwrap_or(0);
- let g = u8::from_str_radix(hex.get(2..4).unwrap_or("00"), 16).unwrap_or(0);
- let b = u8::from_str_radix(hex.get(4..6).unwrap_or("00"), 16).unwrap_or(0);
- [r, g, b]
-}
+use std::fs;
+use std::path::Path;
fn make_theme(name: &str, colors: [&str; 16]) -> Theme {
let mut palette = [[0u8; 3]; 16];
for (i, h) in colors.iter().enumerate() {
- palette[i] = hex_to_rgb(h);
+ palette[i] = crate::domain::theme::hex_to_rgb_pub(h);
}
Theme {
name: name.to_string(),
@@ -19,198 +13,145 @@ fn make_theme(name: &str, colors: [&str; 16]) -> Theme {
}
}
-pub fn builtin_themes() -> Vec {
- vec![
- make_theme(
- "x",
- [
- "#363537", "#fc618d", "#7bd88f", "#fce566", "#fd9353", "#948ae3", "#5ad4e6",
- "#f7f1ff", "#69676c", "#fc618d", "#7bd88f", "#fce566", "#fd9353", "#948ae3",
- "#5ad4e6", "#f7f1ff",
- ],
- ),
- make_theme(
- "madrid",
- [
- "#333333", "#cc0033", "#009933", "#b8860b", "#0099cc", "#6633cc", "#0099cc",
- "#1a1a1a", "#666666", "#cc0033", "#009933", "#b8860b", "#0099cc", "#6633cc",
- "#0099cc", "#1a1a1a",
- ],
- ),
- make_theme(
- "lahabana",
- [
- "#363537", "#fc618d", "#7bd88f", "#e5ff9d", "#fd9353", "#948ae3", "#5ad4e6",
- "#f7f1ff", "#69676c", "#fc618d", "#7bd88f", "#e5ff9d", "#fd9353", "#948ae3",
- "#5ad4e6", "#f7f1ff",
- ],
- ),
- make_theme(
- "seul",
- [
- "#1b1b1b", "#FF4C8B", "#7FFFD4", "#FFD84C", "#00FFA8", "#D36CFF", "#47CFFF",
- "#f7f1ff", "#69676c", "#FF4C8B", "#7FFFD4", "#FFD84C", "#00FFA8", "#D36CFF",
- "#47CFFF", "#f7f1ff",
- ],
- ),
- make_theme(
- "miami",
- [
- "#000000", "#FF4C8B", "#7FFFD4", "#FFD84C", "#00FFA8", "#D36CFF", "#47CFFF",
- "#f7f1ff", "#69676c", "#FF4C8B", "#7FFFD4", "#FFD84C", "#00FFA8", "#D36CFF",
- "#47CFFF", "#f7f1ff",
- ],
- ),
- make_theme(
- "paris",
- [
- "#222222", "#fc618d", "#7bd88f", "#fce566", "#a3f3ff", "#c4bdff", "#a3f3ff",
- "#f7f1ff", "#525053", "#fc618d", "#7bd88f", "#fce566", "#a3f3ff", "#c4bdff",
- "#a3f3ff", "#f7f1ff",
- ],
- ),
- make_theme(
- "tokio",
- [
- "#363537", "#fc618d", "#7bd88f", "#fce566", "#fd9353", "#948ae3", "#5ad4e6",
- "#f7f1ff", "#69676c", "#fc618d", "#7bd88f", "#fce566", "#fd9353", "#948ae3",
- "#5ad4e6", "#f7f1ff",
- ],
- ),
- make_theme(
- "oslo",
- [
- "#3f4451", "#e05561", "#8cc265", "#d18f52", "#4aa5f0", "#c162de", "#42b3c2",
- "#e6e6e6", "#4f5666", "#ff616e", "#a5e075", "#f0a45d", "#4dc4ff", "#de73ff",
- "#4cd1e0", "#ffffff",
- ],
- ),
- make_theme(
- "helsinki",
- [
- "#c0bbae", "#1faa9e", "#733d9a", "#2e70ad", "#b55a0f", "#3e9d21", "#bd4c3d",
- "#191919", "#b0a999", "#009e91", "#5a1f8a", "#0f5ba2", "#b23b00", "#218c00",
- "#b32e1f", "#000000",
- ],
- ),
- make_theme(
- "berlin",
- [
- "#000000", "#999999", "#bbbbbb", "#dddddd", "#888888", "#aaaaaa", "#cccccc",
- "#ffffff", "#333333", "#bbbbbb", "#dddddd", "#ffffff", "#aaaaaa", "#cccccc",
- "#eeeeee", "#ffffff",
- ],
- ),
- make_theme(
- "london",
- [
- "#000000", "#333333", "#444444", "#555555", "#666666", "#777777", "#888888",
- "#999999", "#333333", "#444444", "#555555", "#666666", "#777777", "#888888",
- "#999999", "#aaaaaa",
- ],
- ),
- make_theme(
- "praha",
- [
- "#1A1A1A", "#FF5555", "#B8E6A0", "#FFE4A3", "#BD93F9", "#FF9AA2", "#8BE9FD",
- "#FFFFFF", "#6272A4", "#FF6E6E", "#B8E6A0", "#FFE4A3", "#D6ACFF", "#FF9AA2",
- "#A4FFFF", "#FFFFFF",
- ],
- ),
- make_theme(
- "bogota",
- [
- "#222222", "#fc618d", "#7bd88f", "#ffed89", "#47e6ff", "#ff9999", "#47e6ff",
- "#f7f1ff", "#525053", "#fc618d", "#7bd88f", "#ffed89", "#47e6ff", "#ff9999",
- "#47e6ff", "#f7f1ff",
- ],
- ),
- ]
+fn default_theme() -> Theme {
+ make_theme(
+ "miami",
+ [
+ "#000000", "#FF4C8B", "#7FFFD4", "#FFD84C", "#00FFA8", "#D36CFF", "#47CFFF",
+ "#f7f1ff", "#69676c", "#FF4C8B", "#7FFFD4", "#FFD84C", "#00FFA8", "#D36CFF",
+ "#47CFFF", "#f7f1ff",
+ ],
+ )
+}
+
+fn strip_jsonc_comments(input: &str) -> String {
+ let mut out = String::with_capacity(input.len());
+ let chars: Vec = input.chars().collect();
+ let mut i = 0;
+ while i < chars.len() {
+ if chars[i] == '/' && i + 1 < chars.len() {
+ if chars[i + 1] == '/' {
+ i += 2;
+ while i < chars.len() && chars[i] != '\n' {
+ i += 1;
+ }
+ continue;
+ }
+ if chars[i + 1] == '*' {
+ i += 2;
+ while i + 1 < chars.len() && !(chars[i] == '*' && chars[i + 1] == '/') {
+ i += 1;
+ }
+ i += 2;
+ continue;
+ }
+ }
+ out.push(chars[i]);
+ i += 1;
+ }
+ out
+}
+
+fn load_theme_from_file(path: &Path) -> Option {
+ let data = fs::read_to_string(path).ok()?;
+ let cleaned = strip_jsonc_comments(&data);
+ serde_json::from_str::(&cleaned).ok()
+}
+
+fn load_themes_from_dir(dir: &Path) -> Vec {
+ if !dir.exists() {
+ return vec![];
+ }
+ let mut themes = vec![];
+ if let Ok(entries) = fs::read_dir(dir) {
+ for entry in entries.flatten() {
+ let path = entry.path();
+ let ext = path.extension().and_then(|e| e.to_str());
+ if ext == Some("json") || ext == Some("jsonc") {
+ if let Some(theme) = load_theme_from_file(&path) {
+ themes.push(theme);
+ }
+ }
+ }
+ }
+ themes
+}
+
+pub fn themes_dir() -> std::path::PathBuf {
+ if let Ok(xdg) = std::env::var("XDG_CONFIG_HOME") {
+ std::path::PathBuf::from(xdg).join("xtop").join("themes")
+ } else if let Ok(home) = std::env::var("HOME") {
+ std::path::PathBuf::from(home)
+ .join(".config")
+ .join("xtop")
+ .join("themes")
+ } else {
+ std::path::PathBuf::from(".")
+ .join(".config")
+ .join("xtop")
+ .join("themes")
+ }
}
pub fn load_all_themes() -> Vec {
- let mut themes = builtin_themes();
- let custom = crate::infrastructure::config::load_custom_themes();
+ let mut themes = vec![default_theme()];
+
+ let user_dir = themes_dir();
+ let custom = load_themes_from_dir(&user_dir);
for t in custom {
if !themes.iter().any(|existing| existing.name == t.name) {
themes.push(t);
}
}
+
themes
}
+pub fn builtin_themes() -> Vec {
+ vec![default_theme()]
+}
+
#[cfg(test)]
mod tests {
use super::*;
#[test]
- fn test_hex_to_rgb_basic() {
- assert_eq!(hex_to_rgb("#ff0000"), [255, 0, 0]);
- assert_eq!(hex_to_rgb("#00ff00"), [0, 255, 0]);
- assert_eq!(hex_to_rgb("#0000ff"), [0, 0, 255]);
- }
-
- #[test]
- fn test_hex_to_rgb_black_white() {
- assert_eq!(hex_to_rgb("#000000"), [0, 0, 0]);
- assert_eq!(hex_to_rgb("#ffffff"), [255, 255, 255]);
- }
-
- #[test]
- fn test_hex_to_rgb_without_hash() {
- assert_eq!(hex_to_rgb("ff0000"), [255, 0, 0]);
- }
-
- #[test]
- fn test_hex_to_rgb_invalid() {
- assert_eq!(hex_to_rgb("#xyz"), [0, 0, 0]); // unwrap_or(0)
- }
-
- #[test]
- fn test_hex_to_rgb_short() {
- assert_eq!(hex_to_rgb("#ff"), [255, 0, 0]); // only 2 chars
+ fn test_default_theme() {
+ let t = default_theme();
+ assert_eq!(t.name, "miami");
+ assert_eq!(t.bg(), &[0, 0, 0]);
}
#[test]
fn test_builtin_themes_count() {
let themes = builtin_themes();
- assert_eq!(themes.len(), 13);
+ assert_eq!(themes.len(), 1);
}
#[test]
- fn test_builtin_themes_have_names() {
- let themes = builtin_themes();
- let names: Vec<&str> = themes.iter().map(|t| t.name.as_str()).collect();
- assert!(names.contains(&"x"));
- assert!(names.contains(&"madrid"));
- assert!(names.contains(&"tokio"));
- assert!(names.contains(&"praha"));
+ fn test_strip_line_comment() {
+ let input = "{\"name\": \"test\" // comment\n}";
+ let result = strip_jsonc_comments(input);
+ assert_eq!(result, "{\"name\": \"test\" \n}");
}
#[test]
- fn test_builtin_themes_palette_size() {
- let themes = builtin_themes();
- for theme in &themes {
- assert_eq!(
- theme.palette.len(),
- 16,
- "Theme '{}' should have 16 palette entries",
- theme.name
- );
- }
+ fn test_strip_block_comment() {
+ let input = "{\"name\": /* comment */ \"test\"}";
+ let result = strip_jsonc_comments(input);
+ assert_eq!(result, "{\"name\": \"test\"}");
}
#[test]
- fn test_builtin_theme_bg_fg() {
- let themes = builtin_themes();
- let x = themes.iter().find(|t| t.name == "x").unwrap();
- assert_eq!(x.bg(), &[0x36, 0x35, 0x37]);
- assert_eq!(x.fg(), &[0xf7, 0xf1, 0xff]);
+ fn test_strip_no_comments() {
+ let input = "{\"name\": \"test\"}";
+ let result = strip_jsonc_comments(input);
+ assert_eq!(result, "{\"name\": \"test\"}");
}
#[test]
- fn test_hex_to_rgb_uppercase() {
- assert_eq!(hex_to_rgb("#FF0000"), [255, 0, 0]);
- assert_eq!(hex_to_rgb("#AABBCC"), [170, 187, 204]);
+ fn test_hex_to_rgb() {
+ let result = crate::domain::theme::hex_to_rgb_pub("#ff0000");
+ assert_eq!(result, [255, 0, 0]);
}
}
diff --git a/crates/xtop-tui/src/color.rs b/crates/xtop-tui/src/color.rs
new file mode 100644
index 0000000..2ecfbf9
--- /dev/null
+++ b/crates/xtop-tui/src/color.rs
@@ -0,0 +1,5 @@
+use ratatui::prelude::Color;
+
+pub fn to_color(c: &[u8; 3]) -> Color {
+ Color::Rgb(c[0], c[1], c[2])
+}
diff --git a/crates/xtop-tui/src/lib.rs b/crates/xtop-tui/src/lib.rs
index 53a287f..db59d20 100644
--- a/crates/xtop-tui/src/lib.rs
+++ b/crates/xtop-tui/src/lib.rs
@@ -1,3 +1,4 @@
+pub mod color;
pub mod format;
pub mod render;
pub mod terminal;
diff --git a/crates/xtop-tui/src/render/battery.rs b/crates/xtop-tui/src/render/battery.rs
index be5ccb4..a7ffa25 100644
--- a/crates/xtop-tui/src/render/battery.rs
+++ b/crates/xtop-tui/src/render/battery.rs
@@ -1,12 +1,12 @@
+use crate::color::to_color;
use ratatui::prelude::*;
use ratatui::widgets::{Block, Borders, Gauge, Paragraph, Wrap};
use ratatui::Frame;
use xtop_core::application::state::AppState;
pub fn render(f: &mut Frame, state: &AppState, area: Rect) {
- let rgb = |c: &[u8; 3]| Color::Rgb(c[0], c[1], c[2]);
- let fg = rgb(state.current_theme.fg());
- let bg = rgb(state.current_theme.bg());
+ let fg = to_color(state.current_theme.fg());
+ let bg = to_color(state.current_theme.bg());
let block = Block::default()
.title("Battery")
@@ -49,7 +49,7 @@ pub fn render(f: &mut Frame, state: &AppState, area: Rect) {
let gauge = Gauge::default()
.gauge_style(
Style::default()
- .fg(rgb(&state.current_theme.palette[2]))
+ .fg(to_color(&state.current_theme.palette[2]))
.bg(bg),
)
.percent(bat.percentage as u16)
diff --git a/crates/xtop-tui/src/render/cpu.rs b/crates/xtop-tui/src/render/cpu.rs
index 198c85e..c272cf2 100644
--- a/crates/xtop-tui/src/render/cpu.rs
+++ b/crates/xtop-tui/src/render/cpu.rs
@@ -1,12 +1,12 @@
+use crate::color::to_color;
use ratatui::prelude::*;
use ratatui::widgets::{Block, Borders, Gauge};
use ratatui::Frame;
use xtop_core::application::state::AppState;
pub fn render(f: &mut Frame, state: &AppState, area: Rect) {
- let rgb = |c: &[u8; 3]| Color::Rgb(c[0], c[1], c[2]);
- let fg = rgb(state.current_theme.fg());
- let bg = rgb(state.current_theme.bg());
+ let fg = to_color(state.current_theme.fg());
+ let bg = to_color(state.current_theme.bg());
let snap = state.snapshot();
let title = if snap.cpu_temp > 0.0 {
@@ -63,7 +63,7 @@ pub fn render(f: &mut Frame, state: &AppState, area: Rect) {
let gauge = Gauge::default()
.gauge_style(
Style::default()
- .fg(rgb(&state.current_theme.palette[color_idx]))
+ .fg(to_color(&state.current_theme.palette[color_idx]))
.bg(bg),
)
.percent(usage as u16)
diff --git a/crates/xtop-tui/src/render/disk_io.rs b/crates/xtop-tui/src/render/disk_io.rs
index 2d102a1..f146502 100644
--- a/crates/xtop-tui/src/render/disk_io.rs
+++ b/crates/xtop-tui/src/render/disk_io.rs
@@ -1,13 +1,13 @@
+use crate::color::to_color;
use crate::format::format_bytes;
use ratatui::prelude::*;
-use ratatui::widgets::{Block, Borders, Gauge};
+use ratatui::widgets::{Block, Borders, Paragraph, Wrap};
use ratatui::Frame;
use xtop_core::application::state::AppState;
pub fn render(f: &mut Frame, state: &AppState, area: Rect) {
- let rgb = |c: &[u8; 3]| Color::Rgb(c[0], c[1], c[2]);
- let fg = rgb(state.current_theme.fg());
- let bg = rgb(state.current_theme.bg());
+ let fg = to_color(state.current_theme.fg());
+ let bg = to_color(state.current_theme.bg());
let block = Block::default()
.title("Disk I/O")
@@ -18,36 +18,31 @@ pub fn render(f: &mut Frame, state: &AppState, area: Rect) {
let snap = state.snapshot();
if snap.disk_io.is_empty() {
+ let msg = Paragraph::new("No disk I/O data")
+ .style(Style::default().fg(fg))
+ .wrap(Wrap { trim: true });
+ f.render_widget(msg, inner);
return;
}
- let per_disk = inner.height.min(3);
- let constraints = vec![Constraint::Length(per_disk); snap.disk_io.len()];
- let chunks = Layout::default()
- .direction(Direction::Vertical)
- .constraints(constraints)
- .split(inner);
-
- for (i, d) in snap.disk_io.iter().enumerate() {
- if i >= chunks.len() {
- break;
- }
- let total_read = format_bytes(d.read_bytes);
- let total_write = format_bytes(d.write_bytes);
+ let mut lines = Vec::new();
+ for d in &snap.disk_io {
let read_speed = format_bytes(d.read_speed as u64);
let write_speed = format_bytes(d.write_speed as u64);
- let label = format!(
- "{} R: {}/s W: {}/s Tot R: {} Tot W: {}",
- d.name, read_speed, write_speed, total_read, total_write,
- );
- let gauge = Gauge::default()
- .gauge_style(
- Style::default()
- .fg(rgb(&state.current_theme.palette[4]))
- .bg(bg),
- )
- .percent(50)
- .label(label);
- f.render_widget(gauge, chunks[i]);
+ let total_read = format_bytes(d.read_bytes);
+ let total_write = format_bytes(d.write_bytes);
+ lines.push(Line::from(Span::raw(format!(
+ " {} R: {}/s W: {}/s",
+ d.name, read_speed, write_speed,
+ ))));
+ lines.push(Line::from(Span::raw(format!(
+ " Tot R: {} Tot W: {}",
+ total_read, total_write,
+ ))));
}
+
+ let p = Paragraph::new(lines)
+ .style(Style::default().fg(fg))
+ .wrap(Wrap { trim: true });
+ f.render_widget(p, inner);
}
diff --git a/crates/xtop-tui/src/render/gpu.rs b/crates/xtop-tui/src/render/gpu.rs
index ff5b35a..67cd12d 100644
--- a/crates/xtop-tui/src/render/gpu.rs
+++ b/crates/xtop-tui/src/render/gpu.rs
@@ -1,3 +1,4 @@
+use crate::color::to_color;
use crate::format::format_bytes;
use ratatui::prelude::*;
use ratatui::widgets::{Block, Borders, Gauge, Paragraph, Wrap};
@@ -5,9 +6,8 @@ use ratatui::Frame;
use xtop_core::application::state::AppState;
pub fn render(f: &mut Frame, state: &AppState, area: Rect) {
- let rgb = |c: &[u8; 3]| Color::Rgb(c[0], c[1], c[2]);
- let fg = rgb(state.current_theme.fg());
- let bg = rgb(state.current_theme.bg());
+ let fg = to_color(state.current_theme.fg());
+ let bg = to_color(state.current_theme.bg());
let block = Block::default()
.title("GPU")
@@ -45,7 +45,7 @@ pub fn render(f: &mut Frame, state: &AppState, area: Rect) {
let gauge = Gauge::default()
.gauge_style(
Style::default()
- .fg(rgb(&state.current_theme.palette[5]))
+ .fg(to_color(&state.current_theme.palette[5]))
.bg(bg),
)
.percent(gpu.usage as u16)
diff --git a/crates/xtop-tui/src/render/header.rs b/crates/xtop-tui/src/render/header.rs
index 6c30046..d16428a 100644
--- a/crates/xtop-tui/src/render/header.rs
+++ b/crates/xtop-tui/src/render/header.rs
@@ -1,18 +1,19 @@
+use crate::color::to_color;
use crate::format::format_uptime;
use ratatui::prelude::*;
-use ratatui::widgets::{Block, Borders, Paragraph};
+use ratatui::widgets::{Block, Borders, Paragraph, Wrap};
use ratatui::Frame;
use xtop_core::application::state::{AppState, FullScreenWidget, InputMode};
pub fn render(f: &mut Frame, state: &AppState, area: Rect) {
- let rgb = |c: &[u8; 3]| Color::Rgb(c[0], c[1], c[2]);
- let fg = rgb(state.current_theme.fg());
- let bg = rgb(state.current_theme.bg());
+ let fg = to_color(state.current_theme.fg());
+ let bg = to_color(state.current_theme.bg());
- let load = state.snapshot().load_avg;
- let uptime = state.snapshot().uptime;
+ let snap = state.snapshot();
+ let load = snap.load_avg;
+ let uptime = snap.uptime;
- let mode_str = state.layout_mode.label();
+ let mode_str = state.current_layout_name();
let mut extras = String::new();
if state.full_screen_widget != FullScreenWidget::None {
@@ -22,19 +23,34 @@ pub fn render(f: &mut Frame, state: &AppState, area: Rect) {
extras.push_str(" [/] Search");
}
- let text = format!(
- "xtop | Theme: {} | Layout: {}{} | Uptime: {} | Load: {:.2} {:.2} {:.2} | [q] Quit [?] Help [t] Theme [l] Layout [f] Full [/] Search",
- state.current_theme.name,
- mode_str,
- extras,
- format_uptime(uptime),
- load.one,
- load.five,
- load.fifteen,
- );
+ let wide = area.width >= 80;
+ let text: Vec = if wide {
+ vec![Line::from(format!(
+ "xtop | {} | {} | Uptime: {} | Load: {:.2} {:.2} {:.2} | [q] [?] [t] [T] [l] [f] [/]",
+ state.current_theme.name,
+ mode_str,
+ format_uptime(uptime),
+ load.one,
+ load.five,
+ load.fifteen,
+ ))]
+ } else {
+ vec![
+ Line::from(format!(
+ "{} | Uptime: {}",
+ mode_str,
+ format_uptime(uptime),
+ )),
+ Line::from(format!(
+ "Load: {:.2} {:.2} {:.2}{}",
+ load.one, load.five, load.fifteen, extras,
+ )),
+ ]
+ };
let p = Paragraph::new(text)
.style(Style::default().fg(fg).bg(bg))
- .block(Block::default().borders(Borders::ALL).title("System Info"));
+ .block(Block::default().borders(Borders::ALL).title("System Info"))
+ .wrap(Wrap { trim: true });
f.render_widget(p, area);
}
diff --git a/crates/xtop-tui/src/render/help.rs b/crates/xtop-tui/src/render/help.rs
index de9c8a5..5ac4097 100644
--- a/crates/xtop-tui/src/render/help.rs
+++ b/crates/xtop-tui/src/render/help.rs
@@ -1,12 +1,12 @@
+use crate::color::to_color;
use ratatui::prelude::*;
use ratatui::widgets::{Block, Borders, Paragraph, Wrap};
use ratatui::Frame;
use xtop_core::application::state::AppState;
pub fn render(f: &mut Frame, state: &AppState, area: Rect) {
- let rgb = |c: &[u8; 3]| Color::Rgb(c[0], c[1], c[2]);
- let fg = rgb(state.current_theme.fg());
- let bg = rgb(state.current_theme.bg());
+ let fg = to_color(state.current_theme.fg());
+ let bg = to_color(state.current_theme.bg());
let text = vec![
Line::from(""),
@@ -29,9 +29,10 @@ pub fn render(f: &mut Frame, state: &AppState, area: Rect) {
Line::from(" Esc Cancel search / close help"),
Line::from(""),
Line::from(" Layout modes:"),
- Line::from(format!(" Current: {}", state.layout_mode.label())),
+ Line::from(format!(" Current: {}", state.current_layout_name())),
Line::from(" Dashboard | Vertical | Horizontal | CPU Focus"),
Line::from(" Memory Focus | Network Focus | Process Focus"),
+ Line::from(" + custom layouts from ~/.config/xtop/layouts/"),
Line::from(""),
Line::from(" ─────────────────────────────────────────────"),
Line::from(""),
diff --git a/crates/xtop-tui/src/render/layout_engine.rs b/crates/xtop-tui/src/render/layout_engine.rs
new file mode 100644
index 0000000..de723c5
--- /dev/null
+++ b/crates/xtop-tui/src/render/layout_engine.rs
@@ -0,0 +1,75 @@
+use crate::render::{battery, cpu, disk_io, gpu, header, memory, network, processes, storage};
+use ratatui::layout::{Constraint, Layout, Rect};
+use ratatui::Frame;
+use std::collections::HashMap;
+use xtop_core::domain::layout::{Direction, LayoutArea, LayoutDef, LayoutNode};
+use xtop_core::application::state::AppState;
+
+pub type WidgetRenderer = fn(&mut Frame, &AppState, Rect);
+
+pub fn default_widgets() -> HashMap<&'static str, WidgetRenderer> {
+ let mut m: HashMap<&'static str, WidgetRenderer> = HashMap::new();
+ m.insert("header", header::render);
+ m.insert("cpu", cpu::render);
+ m.insert("memory", memory::render);
+ m.insert("storage", storage::render);
+ m.insert("network", network::render);
+ m.insert("processes", processes::render);
+ m.insert("disk_io", disk_io::render);
+ m.insert("battery", battery::render);
+ m.insert("gpu", gpu::render);
+ m
+}
+
+pub fn render_layout(
+ f: &mut Frame,
+ state: &AppState,
+ area: Rect,
+ def: &LayoutDef,
+ widgets: &HashMap<&'static str, WidgetRenderer>,
+) {
+ render_node(f, state, area, &def.root, widgets);
+}
+
+fn render_node(
+ f: &mut Frame,
+ state: &AppState,
+ area: Rect,
+ node: &LayoutNode,
+ widgets: &HashMap<&'static str, WidgetRenderer>,
+) {
+ match node {
+ LayoutNode::Widget { name } => {
+ if let Some(render_fn) = widgets.get(name.as_str()) {
+ render_fn(f, state, area);
+ }
+ }
+ LayoutNode::Split { direction, areas } => {
+ if areas.is_empty() {
+ return;
+ }
+ let dir = match direction {
+ Direction::Horizontal => ratatui::prelude::Direction::Horizontal,
+ Direction::Vertical => ratatui::prelude::Direction::Vertical,
+ };
+ let constraints: Vec = areas.iter().map(to_ratatui_constraint).collect();
+ let chunks = Layout::default()
+ .direction(dir)
+ .constraints(constraints)
+ .split(area);
+ for (i, chunk) in chunks.iter().enumerate() {
+ if i < areas.len() {
+ render_node(f, state, *chunk, &areas[i].node, widgets);
+ }
+ }
+ }
+ }
+}
+
+fn to_ratatui_constraint(area: &LayoutArea) -> Constraint {
+ match area.constraint {
+ xtop_core::domain::layout::LayoutConstraint::Length(n) => Constraint::Length(n),
+ xtop_core::domain::layout::LayoutConstraint::Percentage(p) => Constraint::Percentage(p),
+ xtop_core::domain::layout::LayoutConstraint::Fill => Constraint::Min(0),
+ }
+}
diff --git a/crates/xtop-tui/src/render/memory.rs b/crates/xtop-tui/src/render/memory.rs
index 2528156..e8279d7 100644
--- a/crates/xtop-tui/src/render/memory.rs
+++ b/crates/xtop-tui/src/render/memory.rs
@@ -1,3 +1,4 @@
+use crate::color::to_color;
use crate::format::format_bytes;
use ratatui::prelude::*;
use ratatui::widgets::{Axis, Block, Borders, Chart, Dataset, Gauge, GraphType};
@@ -5,9 +6,8 @@ use ratatui::Frame;
use xtop_core::application::state::AppState;
pub fn render(f: &mut Frame, state: &AppState, area: Rect) {
- let rgb = |c: &[u8; 3]| Color::Rgb(c[0], c[1], c[2]);
- let fg = rgb(state.current_theme.fg());
- let bg = rgb(state.current_theme.bg());
+ let fg = to_color(state.current_theme.fg());
+ let bg = to_color(state.current_theme.bg());
let snap = state.snapshot();
let mem_alert = snap.memory.percent > state.alerts.mem_high;
@@ -57,7 +57,6 @@ fn render_ram_gauge(
bg: Color,
color_idx: usize,
) {
- let rgb = |c: &[u8; 3]| Color::Rgb(c[0], c[1], c[2]);
let mem_pct = snap.memory.percent as u16;
let label = format!(
"RAM: {} / {} ({:>3.0}%)",
@@ -68,7 +67,7 @@ fn render_ram_gauge(
let gauge = Gauge::default()
.gauge_style(
Style::default()
- .fg(rgb(&state.current_theme.palette[color_idx]))
+ .fg(to_color(&state.current_theme.palette[color_idx]))
.bg(bg),
)
.percent(mem_pct)
@@ -83,7 +82,6 @@ fn render_swap_gauge(
snap: &xtop_core::domain::metrics::SystemSnapshot,
bg: Color,
) {
- let rgb = |c: &[u8; 3]| Color::Rgb(c[0], c[1], c[2]);
let swap_pct = snap.swap.percent as u16;
let label = format!(
"SWP: {} / {} ({:>3.0}%)",
@@ -94,7 +92,7 @@ fn render_swap_gauge(
let gauge = Gauge::default()
.gauge_style(
Style::default()
- .fg(rgb(&state.current_theme.palette[3]))
+ .fg(to_color(&state.current_theme.palette[3]))
.bg(bg),
)
.percent(swap_pct)
@@ -103,8 +101,6 @@ fn render_swap_gauge(
}
fn render_chart(f: &mut Frame, state: &AppState, area: Rect, _bg: Color) {
- let rgb = |c: &[u8; 3]| Color::Rgb(c[0], c[1], c[2]);
-
let mem_data: Vec<(f64, f64)> = state.history.mem.iter().copied().collect();
if mem_data.is_empty() {
return;
@@ -114,7 +110,7 @@ fn render_chart(f: &mut Frame, state: &AppState, area: Rect, _bg: Color) {
.name("RAM Usage")
.marker(symbols::Marker::Braille)
.graph_type(GraphType::Line)
- .style(Style::default().fg(rgb(&state.current_theme.palette[2])))
+ .style(Style::default().fg(to_color(&state.current_theme.palette[2])))
.data(&mem_data)];
let x_min = mem_data.first().map(|&(x, _)| x).unwrap_or(0.0);
diff --git a/crates/xtop-tui/src/render/mod.rs b/crates/xtop-tui/src/render/mod.rs
index 955fabe..5692bc1 100644
--- a/crates/xtop-tui/src/render/mod.rs
+++ b/crates/xtop-tui/src/render/mod.rs
@@ -6,15 +6,27 @@ mod header;
mod help;
mod memory;
mod network;
+mod palette;
mod processes;
mod storage;
+mod layout_engine;
+
+use crate::color::to_color;
+use layout_engine::{default_widgets, render_layout, WidgetRenderer};
use ratatui::prelude::*;
use ratatui::Frame;
+use std::collections::HashMap;
+use std::sync::OnceLock;
use xtop_core::application::state::{
detect_effective_layout, AppState, EffectiveLayout, FullScreenWidget, InputMode,
};
+fn widgets() -> &'static HashMap<&'static str, WidgetRenderer> {
+ static WIDGETS: OnceLock> = OnceLock::new();
+ WIDGETS.get_or_init(default_widgets)
+}
+
pub fn render(f: &mut Frame, state: &AppState) {
let area = f.area();
@@ -34,36 +46,31 @@ pub fn render(f: &mut Frame, state: &AppState) {
}
let mode = detect_effective_layout(area.width, area.height, state.layout_mode);
- match mode {
- EffectiveLayout::Dashboard => render_dashboard(f, state, area),
- EffectiveLayout::Compact => render_compact(f, state, area),
- EffectiveLayout::Vertical => render_vertical(f, state, area),
- EffectiveLayout::Horizontal => render_horizontal(f, state, area),
- EffectiveLayout::CpuFocus => render_cpu_focus(f, state, area),
- EffectiveLayout::MemoryFocus => render_memory_focus(f, state, area),
- EffectiveLayout::NetworkFocus => render_network_focus(f, state, area),
- EffectiveLayout::ProcessFocus => render_process_focus(f, state, area),
- EffectiveLayout::Minimal => render_minimal(f, state, area),
+ if mode == EffectiveLayout::Minimal {
+ render_minimal(f, state, area);
+ } else {
+ let def = state.current_layout();
+ render_layout(f, state, area, def, widgets());
}
if state.input_mode == InputMode::Searching {
render_search_overlay(f, state, area);
+ } else if state.input_mode == InputMode::CommandPalette {
+ palette::render(f, state, area);
}
}
fn render_too_small(f: &mut Frame, state: &AppState, area: Rect) {
use ratatui::widgets::Paragraph;
- let rgb = |c: &[u8; 3]| Color::Rgb(c[0], c[1], c[2]);
- let fg = rgb(state.current_theme.fg());
+ let fg = to_color(state.current_theme.fg());
let text = Paragraph::new("Terminal too small\nMinimum: 40x8").style(Style::default().fg(fg));
f.render_widget(text, area);
}
fn render_search_overlay(f: &mut Frame, state: &AppState, area: Rect) {
use ratatui::widgets::{Block, Borders, Paragraph};
- let rgb = |c: &[u8; 3]| Color::Rgb(c[0], c[1], c[2]);
- let fg = rgb(state.current_theme.fg());
- let bg = rgb(state.current_theme.bg());
+ let fg = to_color(state.current_theme.fg());
+ let bg = to_color(state.current_theme.bg());
let search_text = format!("/{}_", state.search_query);
let overlay = Paragraph::new(search_text)
.style(Style::default().fg(fg).bg(bg))
@@ -100,208 +107,9 @@ fn render_fullscreen(f: &mut Frame, state: &AppState, area: Rect) {
}
}
-fn render_dashboard(f: &mut Frame, state: &AppState, area: Rect) {
- let chunks = Layout::default()
- .direction(Direction::Vertical)
- .constraints([
- Constraint::Length(3),
- Constraint::Percentage(45),
- Constraint::Percentage(52),
- ])
- .split(area);
-
- header::render(f, state, chunks[0]);
-
- let top = Layout::default()
- .direction(Direction::Horizontal)
- .constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
- .split(chunks[1]);
-
- cpu::render(f, state, top[0]);
-
- let right = Layout::default()
- .direction(Direction::Vertical)
- .constraints([
- Constraint::Percentage(33),
- Constraint::Percentage(33),
- Constraint::Percentage(34),
- ])
- .split(top[1]);
-
- memory::render(f, state, right[0]);
- storage::render(f, state, right[1]);
- network::render(f, state, right[2]);
-
- processes::render(f, state, chunks[2]);
-}
-
-fn render_compact(f: &mut Frame, state: &AppState, area: Rect) {
- let chunks = Layout::default()
- .direction(Direction::Vertical)
- .constraints([
- Constraint::Length(3),
- Constraint::Percentage(50),
- Constraint::Percentage(47),
- ])
- .split(area);
-
- header::render(f, state, chunks[0]);
-
- let top = Layout::default()
- .direction(Direction::Horizontal)
- .constraints([Constraint::Percentage(55), Constraint::Percentage(45)])
- .split(chunks[1]);
-
- cpu::render(f, state, top[0]);
-
- let right = Layout::default()
- .direction(Direction::Vertical)
- .constraints([
- Constraint::Percentage(40),
- Constraint::Percentage(30),
- Constraint::Percentage(30),
- ])
- .split(top[1]);
-
- memory::render(f, state, right[0]);
- storage::render(f, state, right[1]);
- network::render(f, state, right[2]);
-
- processes::render(f, state, chunks[2]);
-}
-
-fn render_vertical(f: &mut Frame, state: &AppState, area: Rect) {
- let chunks = Layout::default()
- .direction(Direction::Vertical)
- .constraints([
- Constraint::Length(3),
- Constraint::Length(8),
- Constraint::Length(8),
- Constraint::Length(6),
- Constraint::Length(5),
- Constraint::Min(0),
- ])
- .split(area);
-
- header::render(f, state, chunks[0]);
- cpu::render(f, state, chunks[1]);
- memory::render(f, state, chunks[2]);
- storage::render(f, state, chunks[3]);
- network::render(f, state, chunks[4]);
- processes::render(f, state, chunks[5]);
-}
-
-fn render_horizontal(f: &mut Frame, state: &AppState, area: Rect) {
- let chunks = Layout::default()
- .direction(Direction::Vertical)
- .constraints([Constraint::Length(3), Constraint::Min(0)])
- .split(area);
-
- header::render(f, state, chunks[0]);
-
- let mid = Layout::default()
- .direction(Direction::Horizontal)
- .constraints([
- Constraint::Percentage(25),
- Constraint::Percentage(25),
- Constraint::Percentage(25),
- Constraint::Percentage(25),
- ])
- .split(chunks[1]);
-
- cpu::render(f, state, mid[0]);
- memory::render(f, state, mid[1]);
- storage::render(f, state, mid[2]);
- network::render(f, state, mid[3]);
-}
-
-fn render_cpu_focus(f: &mut Frame, state: &AppState, area: Rect) {
- let chunks = Layout::default()
- .direction(Direction::Vertical)
- .constraints([
- Constraint::Length(3),
- Constraint::Percentage(60),
- Constraint::Min(10),
- ])
- .split(area);
-
- header::render(f, state, chunks[0]);
- cpu::render(f, state, chunks[1]);
- processes::render(f, state, chunks[2]);
-}
-
-fn render_memory_focus(f: &mut Frame, state: &AppState, area: Rect) {
- let chunks = Layout::default()
- .direction(Direction::Vertical)
- .constraints([
- Constraint::Length(3),
- Constraint::Percentage(60),
- Constraint::Min(10),
- ])
- .split(area);
-
- header::render(f, state, chunks[0]);
- memory::render(f, state, chunks[1]);
- processes::render(f, state, chunks[2]);
-}
-
-fn render_network_focus(f: &mut Frame, state: &AppState, area: Rect) {
- let chunks = Layout::default()
- .direction(Direction::Vertical)
- .constraints([
- Constraint::Length(3),
- Constraint::Percentage(50),
- Constraint::Min(10),
- ])
- .split(area);
-
- header::render(f, state, chunks[0]);
-
- let mid = Layout::default()
- .direction(Direction::Horizontal)
- .constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
- .split(chunks[1]);
-
- network::render(f, state, mid[0]);
- disk_io::render(f, state, mid[1]);
-
- processes::render(f, state, chunks[2]);
-}
-
-fn render_process_focus(f: &mut Frame, state: &AppState, area: Rect) {
- let chunks = Layout::default()
- .direction(Direction::Vertical)
- .constraints([
- Constraint::Length(3),
- Constraint::Length(8),
- Constraint::Min(0),
- ])
- .split(area);
-
- header::render(f, state, chunks[0]);
-
- let stats = Layout::default()
- .direction(Direction::Horizontal)
- .constraints([
- Constraint::Percentage(25),
- Constraint::Percentage(25),
- Constraint::Percentage(25),
- Constraint::Percentage(25),
- ])
- .split(chunks[1]);
-
- cpu::render(f, state, stats[0]);
- memory::render(f, state, stats[1]);
- storage::render(f, state, stats[2]);
- network::render(f, state, stats[3]);
-
- processes::render(f, state, chunks[2]);
-}
-
fn render_minimal(f: &mut Frame, state: &AppState, area: Rect) {
use ratatui::widgets::Gauge;
- let rgb = |c: &[u8; 3]| Color::Rgb(c[0], c[1], c[2]);
- let bg = rgb(state.current_theme.bg());
+ let bg = to_color(state.current_theme.bg());
let chunks = Layout::default()
.direction(Direction::Vertical)
@@ -327,7 +135,7 @@ fn render_minimal(f: &mut Frame, state: &AppState, area: Rect) {
let cpu_gauge = Gauge::default()
.gauge_style(
Style::default()
- .fg(rgb(&state.current_theme.palette[1]))
+ .fg(to_color(&state.current_theme.palette[1]))
.bg(bg),
)
.percent(cpu_pct as u16)
@@ -344,7 +152,7 @@ fn render_minimal(f: &mut Frame, state: &AppState, area: Rect) {
let mem_gauge = Gauge::default()
.gauge_style(
Style::default()
- .fg(rgb(&state.current_theme.palette[2]))
+ .fg(to_color(&state.current_theme.palette[2]))
.bg(bg),
)
.percent(mem_pct)
diff --git a/crates/xtop-tui/src/render/network.rs b/crates/xtop-tui/src/render/network.rs
index 9dd85c5..8a2b7a2 100644
--- a/crates/xtop-tui/src/render/network.rs
+++ b/crates/xtop-tui/src/render/network.rs
@@ -1,3 +1,4 @@
+use crate::color::to_color;
use crate::format::format_bytes;
use ratatui::prelude::*;
use ratatui::widgets::{Block, Borders, Paragraph, Wrap};
@@ -5,9 +6,8 @@ use ratatui::Frame;
use xtop_core::application::state::AppState;
pub fn render(f: &mut Frame, state: &AppState, area: Rect) {
- let rgb = |c: &[u8; 3]| Color::Rgb(c[0], c[1], c[2]);
- let fg = rgb(state.current_theme.fg());
- let bg = rgb(state.current_theme.bg());
+ let fg = to_color(state.current_theme.fg());
+ let bg = to_color(state.current_theme.bg());
let block = Block::default()
.title("Network")
@@ -19,24 +19,50 @@ pub fn render(f: &mut Frame, state: &AppState, area: Rect) {
let snap = state.snapshot();
let total_rx: u64 = snap.networks.iter().map(|n| n.received).sum();
let total_tx: u64 = snap.networks.iter().map(|n| n.transmitted).sum();
+ let total_rx_speed: f64 = snap.networks.iter().map(|n| n.rx_speed).sum();
+ let total_tx_speed: f64 = snap.networks.iter().map(|n| n.tx_speed).sum();
- let text = vec![
+ let mut text = vec![
Line::from(vec![
- Span::styled("Total RX: ", Style::default().fg(fg)),
+ Span::styled("RX: ", Style::default().fg(fg)),
Span::styled(
format_bytes(total_rx),
- Style::default().fg(rgb(&state.current_theme.palette[4])),
+ Style::default().fg(to_color(&state.current_theme.palette[4])),
+ ),
+ Span::raw(" "),
+ Span::styled(
+ format!("{}/s", format_bytes(total_rx_speed as u64)),
+ Style::default().fg(to_color(&state.current_theme.palette[4])),
),
]),
Line::from(vec![
- Span::styled("Total TX: ", Style::default().fg(fg)),
+ Span::styled("TX: ", Style::default().fg(fg)),
Span::styled(
format_bytes(total_tx),
- Style::default().fg(rgb(&state.current_theme.palette[5])),
+ Style::default().fg(to_color(&state.current_theme.palette[5])),
+ ),
+ Span::raw(" "),
+ Span::styled(
+ format!("{}/s", format_bytes(total_tx_speed as u64)),
+ Style::default().fg(to_color(&state.current_theme.palette[5])),
),
]),
];
+ if inner.height > 4 {
+ for iface in &snap.networks {
+ if text.len() as u16 >= inner.height.saturating_sub(1) {
+ break;
+ }
+ text.push(Line::from(Span::raw(format!(
+ " {} RX: {} TX: {}",
+ iface.name,
+ format_bytes(iface.received),
+ format_bytes(iface.transmitted),
+ ))));
+ }
+ }
+
let p = Paragraph::new(text).wrap(Wrap { trim: true });
f.render_widget(p, inner);
}
diff --git a/crates/xtop-tui/src/render/palette.rs b/crates/xtop-tui/src/render/palette.rs
new file mode 100644
index 0000000..3a610cf
--- /dev/null
+++ b/crates/xtop-tui/src/render/palette.rs
@@ -0,0 +1,82 @@
+use crate::color::to_color;
+use ratatui::prelude::*;
+use ratatui::widgets::{Block, Borders, List, ListItem, Paragraph};
+use ratatui::Frame;
+use xtop_core::application::state::AppState;
+
+pub fn render(f: &mut Frame, state: &AppState, area: Rect) {
+ let fg = to_color(state.current_theme.fg());
+ let bg = to_color(state.current_theme.bg());
+
+ let popup_width = (area.width as f64 * 0.6) as u16;
+ let popup_height = (area.height as f64 * 0.6) as u16;
+ let popup_x = (area.width.saturating_sub(popup_width)) / 2;
+ let popup_y = (area.height.saturating_sub(popup_height)) / 2;
+
+ let popup = Rect {
+ x: popup_x,
+ y: popup_y,
+ width: popup_width,
+ height: popup_height,
+ };
+
+ let block = Block::default()
+ .title("Command Palette")
+ .borders(Borders::ALL)
+ .style(Style::default().fg(fg).bg(bg));
+ let inner = block.inner(popup);
+ f.render_widget(block, popup);
+
+ let search_area = Rect {
+ x: inner.x,
+ y: inner.y,
+ width: inner.width,
+ height: 3,
+ };
+ let input_text = format!(
+ " {}_",
+ if state.palette.query.is_empty() {
+ String::new()
+ } else {
+ state.palette.query.clone()
+ }
+ );
+ let input = Paragraph::new(input_text.as_str())
+ .style(Style::default().fg(fg).bg(bg))
+ .block(
+ Block::default()
+ .borders(Borders::ALL)
+ .title("Search commands"),
+ );
+ f.render_widget(input, search_area);
+
+ let list_area = Rect {
+ x: inner.x,
+ y: inner.y + 3,
+ width: inner.width,
+ height: inner.height.saturating_sub(3).max(1),
+ };
+
+ let items: Vec = state
+ .palette
+ .filtered
+ .iter()
+ .enumerate()
+ .map(|(idx, &entry_idx)| {
+ let entry = &state.palette.entries[entry_idx];
+ let is_selected = idx == state.palette.selected;
+ let style = if is_selected {
+ Style::default()
+ .fg(bg)
+ .bg(fg)
+ .add_modifier(Modifier::BOLD)
+ } else {
+ Style::default().fg(fg)
+ };
+ ListItem::new(entry.label.as_str()).style(style)
+ })
+ .collect();
+
+ let list = List::new(items);
+ f.render_widget(list, list_area);
+}
diff --git a/crates/xtop-tui/src/render/processes.rs b/crates/xtop-tui/src/render/processes.rs
index a3ada26..1481138 100644
--- a/crates/xtop-tui/src/render/processes.rs
+++ b/crates/xtop-tui/src/render/processes.rs
@@ -1,12 +1,12 @@
+use crate::color::to_color;
use ratatui::prelude::*;
use ratatui::widgets::{Block, Borders, Cell, Row, Table};
use ratatui::Frame;
use xtop_core::application::state::AppState;
pub fn render(f: &mut Frame, state: &AppState, area: Rect) {
- let rgb = |c: &[u8; 3]| Color::Rgb(c[0], c[1], c[2]);
- let fg = rgb(state.current_theme.fg());
- let bg = rgb(state.current_theme.bg());
+ let fg = to_color(state.current_theme.fg());
+ let bg = to_color(state.current_theme.bg());
let mut title = "Processes".to_string();
if !state.search_query.is_empty() {
@@ -23,7 +23,7 @@ pub fn render(f: &mut Frame, state: &AppState, area: Rect) {
let snap = state.snapshot();
let separator = Span::styled(
" | ",
- Style::default().fg(rgb(&state.current_theme.palette[8])),
+ Style::default().fg(to_color(&state.current_theme.palette[8])),
);
let iter: Box> =
@@ -76,7 +76,7 @@ pub fn render(f: &mut Frame, state: &AppState, area: Rect) {
Row::new(vec!["PID |", "Name |", "CPU% |", "Mem |", "User"])
.style(
Style::default()
- .fg(rgb(&state.current_theme.palette[6]))
+ .fg(to_color(&state.current_theme.palette[6]))
.add_modifier(Modifier::BOLD),
)
.bottom_margin(1),
diff --git a/crates/xtop-tui/src/render/storage.rs b/crates/xtop-tui/src/render/storage.rs
index 213c3ee..336039e 100644
--- a/crates/xtop-tui/src/render/storage.rs
+++ b/crates/xtop-tui/src/render/storage.rs
@@ -1,3 +1,4 @@
+use crate::color::to_color;
use crate::format::format_bytes;
use ratatui::prelude::*;
use ratatui::widgets::{Block, Borders, Gauge};
@@ -5,9 +6,8 @@ use ratatui::Frame;
use xtop_core::application::state::AppState;
pub fn render(f: &mut Frame, state: &AppState, area: Rect) {
- let rgb = |c: &[u8; 3]| Color::Rgb(c[0], c[1], c[2]);
- let fg = rgb(state.current_theme.fg());
- let bg = rgb(state.current_theme.bg());
+ let fg = to_color(state.current_theme.fg());
+ let bg = to_color(state.current_theme.bg());
let block = Block::default()
.title("Storage")
@@ -33,6 +33,8 @@ pub fn render(f: &mut Frame, state: &AppState, area: Rect) {
if i >= chunks.len() {
break;
}
+ let is_alert = disk.percent > state.alerts.disk_high;
+ let color_idx = if is_alert { 1 } else { 4 };
let label = format!(
"{} Tot: {} Use: {} Free: {}",
disk.mount_point,
@@ -43,7 +45,7 @@ pub fn render(f: &mut Frame, state: &AppState, area: Rect) {
let gauge = Gauge::default()
.gauge_style(
Style::default()
- .fg(rgb(&state.current_theme.palette[4]))
+ .fg(to_color(&state.current_theme.palette[color_idx]))
.bg(bg),
)
.percent(disk.percent as u16)
diff --git a/docs/customization.md b/docs/customization.md
new file mode 100644
index 0000000..fa3faaf
--- /dev/null
+++ b/docs/customization.md
@@ -0,0 +1,196 @@
+# Customization
+
+xtop supports runtime customization of color themes and layout modes via external JSONC files. This guide explains how to create and manage your own themes and layouts.
+
+---
+
+## Themes
+
+### Location
+
+Place theme files in:
+
+```
+~/.config/xtop/themes/*.jsonc
+~/.config/xtop/themes/*.json
+```
+
+The directory is created automatically when you save your config on quit.
+
+### Format
+
+Each theme file defines a name and a 16-entry color palette. Colors are hex strings with optional `#` prefix. Comments (`//` and `/* */`) are supported.
+
+```jsonc
+{
+ // my-custom-theme -- Dark background, warm accents
+ "name": "my-custom-theme",
+ "palette": [
+ "#1a1b1c", // 0: background
+ "#e06c75", // 1: red / alert
+ "#98c379", // 2: green (RAM gauge)
+ "#e5c07b", // 3: yellow (Swap gauge)
+ "#d19a66", // 4: orange (Storage, Network TX)
+ "#c678dd", // 5: purple (GPU)
+ "#56b6c2", // 6: cyan (table headers)
+ "#abb2bf", // 7: foreground / text
+ "#3e4451", // 8: bright black (separators)
+ "#e06c75", // 9: bright red
+ "#98c379", // 10: bright green
+ "#e5c07b", // 11: bright yellow
+ "#d19a66", // 12: bright orange
+ "#c678dd", // 13: bright purple
+ "#56b6c2", // 14: bright cyan
+ "#abb2bf" // 15: bright white
+ ]
+}
+```
+
+### Starter themes
+
+The repository includes 12 ready-to-use themes you can copy as a starting point:
+
+```bash
+# Copy all starter themes
+cp -r assets/themes/* ~/.config/xtop/themes/
+```
+
+Available starter themes: `x`, `madrid`, `lahabana`, `seul`, `paris`, `tokio`, `oslo`, `helsinki`, `berlin`, `london`, `praha`, `bogota`.
+
+The built-in default theme is `miami` (black background, neon accents). It is compiled into the binary and always available.
+
+### Loading order
+
+1. Built-in `miami` theme (always available)
+2. Themes from `~/.config/xtop/themes/` loaded alphabetically
+3. If a custom theme has the same name as `miami`, it **replaces** the built-in
+
+### Tips
+
+- Try the grayscale themes (`london`, `berlin`) as a base and add your own accent colors
+- Palette entries 8–15 (the "bright" variants) are used for separators and secondary text
+- Index 0 is the background, index 7 is the primary foreground — keep them readable together
+
+---
+
+## Layouts
+
+### Location
+
+Place layout files in:
+
+```
+~/.config/xtop/layouts/*.jsonc
+~/.config/xtop/layouts/*.json
+```
+
+### Format
+
+A layout is a recursive tree of **splits** and **widgets**:
+
+```
+LayoutDef
+ ├── name: string
+ └── root: Area
+ ├── direction: "horizontal" | "vertical"
+ ├── size: constraint (optional, defaults to "*")
+ └── areas: [Area, ...]
+ ├── Area with "widget" → leaf node (renders a widget)
+ └── Area with "direction" → nested split
+```
+
+**Size constraints:**
+
+| Syntax | Meaning |
+|--------|---------|
+| `"*"` or omitted | Fill remaining space |
+| `3` (number) | Fixed `n` rows/columns |
+| `"45%"` | Percentage of parent |
+
+**Available widget names:**
+
+| Widget | Description |
+|--------|-------------|
+| `header` | System info bar (uptime, load, keys) |
+| `cpu` | Per-core CPU usage gauges |
+| `memory` | RAM + Swap gauges + RAM history chart |
+| `storage` | Disk usage gauges per mount point |
+| `network` | Network RX/TX totals and speeds |
+| `processes` | Process table with search filter |
+| `disk_io` | Disk read/write speeds |
+| `battery` | Battery charge gauges |
+| `gpu` | GPU usage gauges |
+
+### Example: custom layout
+
+```jsonc
+{
+ // "monitor" — CPU top-half, processes bottom-half
+ "name": "monitor",
+ "root": {
+ "direction": "vertical",
+ "areas": [
+ { "widget": "header", "size": 3 },
+ { "widget": "cpu", "size": "55%" },
+ { "widget": "processes", "size": "*" }
+ ]
+ }
+}
+```
+
+### Example: complex nested layout
+
+```jsonc
+{
+ "name": "my-dashboard",
+ "root": {
+ "direction": "vertical",
+ "areas": [
+ { "widget": "header", "size": 3 },
+ {
+ "direction": "horizontal",
+ "size": "50%",
+ "areas": [
+ { "widget": "cpu", "size": "60%" },
+ {
+ "direction": "vertical",
+ "size": "40%",
+ "areas": [
+ { "widget": "network", "size": "50%" },
+ { "widget": "disk_io", "size": "50%" }
+ ]
+ }
+ ]
+ },
+ { "widget": "processes", "size": "*" }
+ ]
+ }
+}
+```
+
+### Starter layouts
+
+The repository includes the 7 built-in layouts as JSONC files:
+
+```bash
+cp -r assets/layouts/* ~/.config/xtop/layouts/
+```
+
+Available: `dashboard`, `vertical`, `horizontal`, `cpu_focus`, `memory_focus`, `network_focus`, `process_focus`.
+
+You can edit these directly or use them as templates.
+
+### Cycling order
+
+1. Built-in layouts (Dashboard → Vertical → Horizontal → CPU Focus → Memory Focus → Network Focus → Process Focus)
+2. Custom layouts from `~/.config/xtop/layouts/` (in filesystem order)
+3. Wraps back to Dashboard
+
+Press `l` to cycle forward through all available layouts.
+
+### Notes
+
+- If a widget name in your layout doesn't match any available widget, that area is silently skipped
+- Nested splits can be arbitrarily deep, but very deep nesting may overflow small terminals
+- The terminal must be at least 40×8 for any layout to render; smaller terminals show a warning
+- Very small terminals (under 60×14) fall back to a minimal hardcoded layout (CPU + Memory gauges + process list)
From 5bd37109ad4fd9b74dc6eacc1e42f1d6a3dae0de Mon Sep 17 00:00:00 2001
From: xscriptor
Date: Wed, 17 Jun 2026 13:55:26 +0200
Subject: [PATCH 3/6] add command palette
---
crates/xtop-cli/src/main.rs | 56 +++++-----
crates/xtop-core/src/application/state.rs | 126 ++++++++++++++++------
crates/xtop-core/src/domain/keybinding.rs | 2 +
crates/xtop-tui/src/color.rs | 15 +++
crates/xtop-tui/src/render/battery.rs | 2 +
crates/xtop-tui/src/render/cpu.rs | 79 +++++++++++++-
crates/xtop-tui/src/render/disk_io.rs | 68 ++++++++----
crates/xtop-tui/src/render/gpu.rs | 2 +
crates/xtop-tui/src/render/header.rs | 11 +-
crates/xtop-tui/src/render/help.rs | 2 +
crates/xtop-tui/src/render/memory.rs | 21 +++-
crates/xtop-tui/src/render/network.rs | 92 +++++++++++++++-
crates/xtop-tui/src/render/palette.rs | 73 +++++++------
crates/xtop-tui/src/render/processes.rs | 40 +++----
crates/xtop-tui/src/render/storage.rs | 7 +-
15 files changed, 442 insertions(+), 154 deletions(-)
diff --git a/crates/xtop-cli/src/main.rs b/crates/xtop-cli/src/main.rs
index 672cc0e..1bbf417 100644
--- a/crates/xtop-cli/src/main.rs
+++ b/crates/xtop-cli/src/main.rs
@@ -1,7 +1,7 @@
use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyModifiers};
use std::fs;
use std::time::{Duration, Instant};
-use xtop_core::application::state::{AppState, Config, InputMode};
+use xtop_core::application::state::{AppState, Config, InputMode, PalettePage};
use xtop_core::domain::keybinding::Action;
use xtop_core::infrastructure::config;
use xtop_core::infrastructure::layout_loader;
@@ -168,34 +168,40 @@ fn main() -> anyhow::Result<()> {
}
_ => {}
},
- InputMode::CommandPalette => match key.code {
- KeyCode::Esc => {
- state.close_palette();
- }
- KeyCode::Enter => {
- if let Some(action) = state.palette_selected_action() {
+ InputMode::CommandPalette => {
+ let is_main = state.palette.page == PalettePage::Main;
+ match key.code {
+ KeyCode::Esc => {
state.close_palette();
- state.execute_action(&action);
- if action == Action::Quit {
- save_config(&state);
+ }
+ KeyCode::Enter => {
+ if let Some(action) = state.palette_selected_action() {
+ state.execute_action(&action);
+ if action == Action::Quit {
+ save_config(&state);
+ }
}
}
+ KeyCode::Down => {
+ state.palette_select_next();
+ }
+ KeyCode::Up => {
+ state.palette_select_prev();
+ }
+ KeyCode::Char(c) => {
+ state.palette.query.push(c);
+ state.palette_filter();
+ }
+ KeyCode::Backspace => {
+ if state.palette.query.is_empty() && !is_main {
+ state.palette_navigate_to(PalettePage::Main);
+ } else {
+ state.palette.query.pop();
+ state.palette_filter();
+ }
+ }
+ _ => {}
}
- KeyCode::Down => {
- state.palette_select_next();
- }
- KeyCode::Up => {
- state.palette_select_prev();
- }
- KeyCode::Char(c) => {
- state.palette.query.push(c);
- state.palette_filter();
- }
- KeyCode::Backspace => {
- state.palette.query.pop();
- state.palette_filter();
- }
- _ => {}
},
}
}
diff --git a/crates/xtop-core/src/application/state.rs b/crates/xtop-core/src/application/state.rs
index 85d47a7..b7e2f01 100644
--- a/crates/xtop-core/src/application/state.rs
+++ b/crates/xtop-core/src/application/state.rs
@@ -146,6 +146,13 @@ pub struct PaletteEntry {
pub action: Action,
}
+#[derive(Clone, Debug, PartialEq)]
+pub enum PalettePage {
+ Main,
+ Themes,
+ Layouts,
+}
+
#[derive(Clone, Debug, PartialEq)]
pub struct PaletteState {
pub open: bool,
@@ -153,12 +160,21 @@ pub struct PaletteState {
pub selected: usize,
pub entries: Vec,
pub filtered: Vec,
+ pub page: PalettePage,
}
impl PaletteState {
pub fn filtered_entries(&self) -> Vec<&PaletteEntry> {
self.filtered.iter().map(|&i| &self.entries[i]).collect()
}
+
+ pub fn title(&self) -> &str {
+ match self.page {
+ PalettePage::Main => "Command Palette",
+ PalettePage::Themes => "Select Theme",
+ PalettePage::Layouts => "Select Layout",
+ }
+ }
}
#[derive(Clone, Copy, Debug, PartialEq)]
@@ -268,6 +284,7 @@ impl AppState {
selected: 0,
entries: Vec::new(),
filtered: Vec::new(),
+ page: PalettePage::Main,
},
keybindings: config.keybindings,
}
@@ -374,46 +391,72 @@ impl AppState {
self.should_quit = true;
}
+ pub fn rebuild_palette(&mut self) {
+ self.palette.entries.clear();
+ match self.palette.page {
+ PalettePage::Main => {
+ self.palette.entries.push(PaletteEntry {
+ label: "Themes →".into(),
+ action: Action::NavigateThemes,
+ });
+ self.palette.entries.push(PaletteEntry {
+ label: "Layouts →".into(),
+ action: Action::NavigateLayouts,
+ });
+ self.palette.entries.push(PaletteEntry {
+ label: "Toggle Fullscreen".into(),
+ action: Action::ToggleFullscreen,
+ });
+ self.palette.entries.push(PaletteEntry {
+ label: "Cycle Fullscreen Widget".into(),
+ action: Action::CycleFullscreen,
+ });
+ self.palette.entries.push(PaletteEntry {
+ label: "Search Processes".into(),
+ action: Action::Search,
+ });
+ self.palette.entries.push(PaletteEntry {
+ label: "Toggle Help".into(),
+ action: Action::ToggleHelp,
+ });
+ self.palette.entries.push(PaletteEntry {
+ label: "Exit".into(),
+ action: Action::Quit,
+ });
+ }
+ PalettePage::Themes => {
+ for (i, theme) in self.themes.iter().enumerate() {
+ self.palette.entries.push(PaletteEntry {
+ label: theme.name.clone(),
+ action: Action::SelectTheme(i),
+ });
+ }
+ }
+ PalettePage::Layouts => {
+ for (i, layout) in self.layout_defs.iter().enumerate() {
+ self.palette.entries.push(PaletteEntry {
+ label: layout.name.clone(),
+ action: Action::SelectLayout(i),
+ });
+ }
+ }
+ }
+ self.palette_filter();
+ }
+
pub fn open_palette(&mut self) {
self.palette.open = true;
self.palette.query.clear();
self.palette.selected = 0;
- self.palette.entries.clear();
-
- for (i, theme) in self.themes.iter().enumerate() {
- self.palette.entries.push(PaletteEntry {
- label: format!("Theme: {}", theme.name),
- action: Action::SelectTheme(i),
- });
- }
- for (i, layout) in self.layout_defs.iter().enumerate() {
- self.palette.entries.push(PaletteEntry {
- label: format!("Layout: {}", layout.name),
- action: Action::SelectLayout(i),
- });
- }
- self.palette.entries.push(PaletteEntry {
- label: "Toggle Fullscreen".into(),
- action: Action::ToggleFullscreen,
- });
- self.palette.entries.push(PaletteEntry {
- label: "Cycle Fullscreen Widget".into(),
- action: Action::CycleFullscreen,
- });
- self.palette.entries.push(PaletteEntry {
- label: "Search Processes".into(),
- action: Action::Search,
- });
- self.palette.entries.push(PaletteEntry {
- label: "Toggle Help".into(),
- action: Action::ToggleHelp,
- });
- self.palette.entries.push(PaletteEntry {
- label: "Quit".into(),
- action: Action::Quit,
- });
+ self.palette.page = PalettePage::Main;
+ self.rebuild_palette();
+ }
- self.palette_filter();
+ pub fn palette_navigate_to(&mut self, page: PalettePage) {
+ self.palette.page = page;
+ self.palette.query.clear();
+ self.palette.selected = 0;
+ self.rebuild_palette();
}
pub fn palette_filter(&mut self) {
@@ -459,6 +502,7 @@ impl AppState {
pub fn close_palette(&mut self) {
self.palette.open = false;
+ self.palette.page = PalettePage::Main;
self.input_mode = InputMode::Normal;
}
@@ -487,6 +531,18 @@ impl AppState {
self.layout_mode = self.save_layout_mode();
self.full_screen_widget = FullScreenWidget::None;
}
+ Action::NavigateThemes => {
+ self.palette_navigate_to(PalettePage::Themes);
+ return;
+ }
+ Action::NavigateLayouts => {
+ self.palette_navigate_to(PalettePage::Layouts);
+ return;
+ }
+ }
+ // Close palette after executing any action (except navigation which returns above)
+ if self.input_mode == InputMode::CommandPalette {
+ self.close_palette();
}
}
}
diff --git a/crates/xtop-core/src/domain/keybinding.rs b/crates/xtop-core/src/domain/keybinding.rs
index d962e44..20fc983 100644
--- a/crates/xtop-core/src/domain/keybinding.rs
+++ b/crates/xtop-core/src/domain/keybinding.rs
@@ -66,6 +66,8 @@ pub enum Action {
Cancel,
SelectTheme(usize),
SelectLayout(usize),
+ NavigateThemes,
+ NavigateLayouts,
}
impl Keybindings {
diff --git a/crates/xtop-tui/src/color.rs b/crates/xtop-tui/src/color.rs
index 2ecfbf9..88d9d55 100644
--- a/crates/xtop-tui/src/color.rs
+++ b/crates/xtop-tui/src/color.rs
@@ -3,3 +3,18 @@ use ratatui::prelude::Color;
pub fn to_color(c: &[u8; 3]) -> Color {
Color::Rgb(c[0], c[1], c[2])
}
+
+/// Returns a palette index for gauge color based on percentage:
+/// - <50% → green (2)
+/// - 50–79% → yellow (3)
+/// - ≥80% → red (1)
+pub fn gauge_gradient(pct: f64, alert_at: f64) -> usize {
+ if pct >= alert_at {
+ 1
+ } else if pct >= 50.0 {
+ 3
+ } else {
+ 2
+ }
+}
+
diff --git a/crates/xtop-tui/src/render/battery.rs b/crates/xtop-tui/src/render/battery.rs
index a7ffa25..ae4fa51 100644
--- a/crates/xtop-tui/src/render/battery.rs
+++ b/crates/xtop-tui/src/render/battery.rs
@@ -1,5 +1,6 @@
use crate::color::to_color;
use ratatui::prelude::*;
+use ratatui::symbols::border;
use ratatui::widgets::{Block, Borders, Gauge, Paragraph, Wrap};
use ratatui::Frame;
use xtop_core::application::state::AppState;
@@ -11,6 +12,7 @@ pub fn render(f: &mut Frame, state: &AppState, area: Rect) {
let block = Block::default()
.title("Battery")
.borders(Borders::ALL)
+ .border_set(border::ROUNDED)
.style(Style::default().fg(fg).bg(bg));
let inner = block.inner(area);
f.render_widget(block, area);
diff --git a/crates/xtop-tui/src/render/cpu.rs b/crates/xtop-tui/src/render/cpu.rs
index c272cf2..b1944cc 100644
--- a/crates/xtop-tui/src/render/cpu.rs
+++ b/crates/xtop-tui/src/render/cpu.rs
@@ -1,6 +1,7 @@
-use crate::color::to_color;
+use crate::color::{gauge_gradient, to_color};
use ratatui::prelude::*;
-use ratatui::widgets::{Block, Borders, Gauge};
+use ratatui::symbols::border;
+use ratatui::widgets::{Axis, Block, Borders, Chart, Dataset, Gauge, GraphType};
use ratatui::Frame;
use xtop_core::application::state::AppState;
@@ -18,6 +19,7 @@ pub fn render(f: &mut Frame, state: &AppState, area: Rect) {
let block = Block::default()
.title(title)
.borders(Borders::ALL)
+ .border_set(border::ROUNDED)
.style(Style::default().fg(fg).bg(bg));
let inner = block.inner(area);
f.render_widget(block, area);
@@ -39,7 +41,9 @@ pub fn render(f: &mut Frame, state: &AppState, area: Rect) {
.split(inner);
let per_col = count.div_ceil(cols);
+ let chart_avail = inner.height > per_col as u16 + 4;
+ // Render all core gauges
for (col_idx, col_area) in col_areas.iter().enumerate() {
let start = col_idx * per_col;
let end = (start + per_col).min(count);
@@ -56,10 +60,12 @@ pub fn render(f: &mut Frame, state: &AppState, area: Rect) {
}
let cpu = &snap.cpus[cpu_idx];
let usage = cpu.usage;
- let is_alert = usage > state.alerts.cpu_high;
+ let color_idx = if usage > state.alerts.cpu_high {
+ 1
+ } else {
+ gauge_gradient(usage, state.alerts.cpu_high)
+ };
let label = format!("CPU{:<2} {:>3.0}%", cpu.cpu_id, usage);
- let color_idx = if is_alert { 1 } else { 1 + (cpu.cpu_id % 6) };
-
let gauge = Gauge::default()
.gauge_style(
Style::default()
@@ -71,4 +77,67 @@ pub fn render(f: &mut Frame, state: &AppState, area: Rect) {
f.render_widget(gauge, *row_area);
}
}
+
+ // Aggregate CPU chart below gauges
+ if chart_avail {
+ let gauge_height = per_col as u16; // min height for gauges in one column
+ let chart_area = Layout::default()
+ .direction(Direction::Vertical)
+ .constraints([Constraint::Length(gauge_height), Constraint::Min(0)])
+ .split(inner)
+ .last()
+ .copied()
+ .unwrap_or(inner);
+
+ let max_len = state.history.cpu.iter().map(|h| h.len()).max().unwrap_or(0);
+ if max_len > 1 {
+ let mut avg: Vec<(f64, f64)> = Vec::new();
+ for tick in 0..max_len {
+ let mut sum = 0.0;
+ let mut n = 0;
+ for core_hist in &state.history.cpu {
+ if tick < core_hist.len() {
+ sum += core_hist[tick].1;
+ n += 1;
+ }
+ }
+ if n > 0 {
+ let x = state.history.cpu[0]
+ .get(tick)
+ .map(|&(x, _)| x)
+ .unwrap_or(0.0);
+ avg.push((x, sum / n as f64));
+ }
+ }
+
+ let datasets = vec![Dataset::default()
+ .name("CPU Avg")
+ .marker(symbols::Marker::Braille)
+ .graph_type(GraphType::Line)
+ .style(Style::default().fg(to_color(&state.current_theme.palette[1])))
+ .data(&avg)];
+
+ let x_min = avg.first().map(|&(x, _)| x).unwrap_or(0.0);
+ let x_max = avg.last().map(|&(x, _)| x).unwrap_or(100.0);
+ let x_max = x_max.max(x_min + 1.0);
+
+ let chart = Chart::new(datasets)
+ .block(Block::default().borders(Borders::TOP))
+ .x_axis(
+ Axis::default()
+ .bounds([x_min, x_max])
+ .labels(vec![Span::raw("")]),
+ )
+ .y_axis(
+ Axis::default()
+ .bounds([0.0, 100.0])
+ .labels(vec![
+ Span::raw("0%"),
+ Span::raw("50%"),
+ Span::raw("100%"),
+ ]),
+ );
+ f.render_widget(chart, chart_area);
+ }
+ }
}
diff --git a/crates/xtop-tui/src/render/disk_io.rs b/crates/xtop-tui/src/render/disk_io.rs
index f146502..5dbcdc1 100644
--- a/crates/xtop-tui/src/render/disk_io.rs
+++ b/crates/xtop-tui/src/render/disk_io.rs
@@ -1,7 +1,8 @@
use crate::color::to_color;
use crate::format::format_bytes;
use ratatui::prelude::*;
-use ratatui::widgets::{Block, Borders, Paragraph, Wrap};
+use ratatui::symbols::border;
+use ratatui::widgets::{Block, Borders, Gauge, Paragraph, Wrap};
use ratatui::Frame;
use xtop_core::application::state::AppState;
@@ -12,6 +13,7 @@ pub fn render(f: &mut Frame, state: &AppState, area: Rect) {
let block = Block::default()
.title("Disk I/O")
.borders(Borders::ALL)
+ .border_set(border::PLAIN)
.style(Style::default().fg(fg).bg(bg));
let inner = block.inner(area);
f.render_widget(block, area);
@@ -25,24 +27,54 @@ pub fn render(f: &mut Frame, state: &AppState, area: Rect) {
return;
}
- let mut lines = Vec::new();
- for d in &snap.disk_io {
+ // Find max speed for proportional gauge
+ let max_speed = snap
+ .disk_io
+ .iter()
+ .map(|d| d.read_speed.max(d.write_speed))
+ .fold(0.0_f64, f64::max)
+ .max(1.0);
+
+ let per_disk = 3.min(inner.height / snap.disk_io.len().max(1) as u16);
+ let per_disk = per_disk.max(2);
+ let constraints = vec![Constraint::Length(per_disk); snap.disk_io.len()];
+ let chunks = Layout::default()
+ .direction(Direction::Vertical)
+ .constraints(constraints)
+ .split(inner);
+
+ for (i, d) in snap.disk_io.iter().enumerate() {
+ if i >= chunks.len() {
+ break;
+ }
let read_speed = format_bytes(d.read_speed as u64);
let write_speed = format_bytes(d.write_speed as u64);
- let total_read = format_bytes(d.read_bytes);
- let total_write = format_bytes(d.write_bytes);
- lines.push(Line::from(Span::raw(format!(
- " {} R: {}/s W: {}/s",
- d.name, read_speed, write_speed,
- ))));
- lines.push(Line::from(Span::raw(format!(
- " Tot R: {} Tot W: {}",
- total_read, total_write,
- ))));
- }
- let p = Paragraph::new(lines)
- .style(Style::default().fg(fg))
- .wrap(Wrap { trim: true });
- f.render_widget(p, inner);
+ let gauge = Gauge::default()
+ .gauge_style(
+ Style::default()
+ .fg(to_color(&state.current_theme.palette[4]))
+ .bg(bg),
+ )
+ .percent((d.read_speed / max_speed * 100.0) as u16)
+ .label(format!(" {} R: {}/s", d.name, read_speed));
+ f.render_widget(gauge, chunks[i]);
+
+ // Draw write speed as a second line if there's room
+ if per_disk >= 3 {
+ let sub = Layout::default()
+ .direction(Direction::Vertical)
+ .constraints([Constraint::Length(1), Constraint::Length(1)])
+ .split(chunks[i]);
+ let write_gauge = Gauge::default()
+ .gauge_style(
+ Style::default()
+ .fg(to_color(&state.current_theme.palette[5]))
+ .bg(bg),
+ )
+ .percent((d.write_speed / max_speed * 100.0) as u16)
+ .label(format!(" W: {}/s Tot R: {} Tot W: {}", write_speed, format_bytes(d.read_bytes), format_bytes(d.write_bytes)));
+ f.render_widget(write_gauge, sub[1]);
+ }
+ }
}
diff --git a/crates/xtop-tui/src/render/gpu.rs b/crates/xtop-tui/src/render/gpu.rs
index 67cd12d..5aafe69 100644
--- a/crates/xtop-tui/src/render/gpu.rs
+++ b/crates/xtop-tui/src/render/gpu.rs
@@ -1,6 +1,7 @@
use crate::color::to_color;
use crate::format::format_bytes;
use ratatui::prelude::*;
+use ratatui::symbols::border;
use ratatui::widgets::{Block, Borders, Gauge, Paragraph, Wrap};
use ratatui::Frame;
use xtop_core::application::state::AppState;
@@ -12,6 +13,7 @@ pub fn render(f: &mut Frame, state: &AppState, area: Rect) {
let block = Block::default()
.title("GPU")
.borders(Borders::ALL)
+ .border_set(border::ROUNDED)
.style(Style::default().fg(fg).bg(bg));
let inner = block.inner(area);
f.render_widget(block, area);
diff --git a/crates/xtop-tui/src/render/header.rs b/crates/xtop-tui/src/render/header.rs
index d16428a..35d6db1 100644
--- a/crates/xtop-tui/src/render/header.rs
+++ b/crates/xtop-tui/src/render/header.rs
@@ -1,6 +1,7 @@
use crate::color::to_color;
use crate::format::format_uptime;
use ratatui::prelude::*;
+use ratatui::symbols::border;
use ratatui::widgets::{Block, Borders, Paragraph, Wrap};
use ratatui::Frame;
use xtop_core::application::state::{AppState, FullScreenWidget, InputMode};
@@ -26,13 +27,14 @@ pub fn render(f: &mut Frame, state: &AppState, area: Rect) {
let wide = area.width >= 80;
let text: Vec = if wide {
vec![Line::from(format!(
- "xtop | {} | {} | Uptime: {} | Load: {:.2} {:.2} {:.2} | [q] [?] [t] [T] [l] [f] [/]",
+ "xtop | {} | {} | Uptime: {} | Load: {:.2} {:.2} {:.2}{}",
state.current_theme.name,
mode_str,
format_uptime(uptime),
load.one,
load.five,
load.fifteen,
+ extras,
))]
} else {
vec![
@@ -50,7 +52,12 @@ pub fn render(f: &mut Frame, state: &AppState, area: Rect) {
let p = Paragraph::new(text)
.style(Style::default().fg(fg).bg(bg))
- .block(Block::default().borders(Borders::ALL).title("System Info"))
+ .block(
+ Block::default()
+ .borders(Borders::ALL)
+ .border_set(border::PLAIN)
+ .title("System Info"),
+ )
.wrap(Wrap { trim: true });
f.render_widget(p, area);
}
diff --git a/crates/xtop-tui/src/render/help.rs b/crates/xtop-tui/src/render/help.rs
index 5ac4097..aba850e 100644
--- a/crates/xtop-tui/src/render/help.rs
+++ b/crates/xtop-tui/src/render/help.rs
@@ -1,5 +1,6 @@
use crate::color::to_color;
use ratatui::prelude::*;
+use ratatui::symbols::border;
use ratatui::widgets::{Block, Borders, Paragraph, Wrap};
use ratatui::Frame;
use xtop_core::application::state::AppState;
@@ -43,6 +44,7 @@ pub fn render(f: &mut Frame, state: &AppState, area: Rect) {
let block = Block::default()
.title("Help")
.borders(Borders::ALL)
+ .border_set(border::DOUBLE)
.style(Style::default().fg(fg).bg(bg));
let p = Paragraph::new(text)
.block(block)
diff --git a/crates/xtop-tui/src/render/memory.rs b/crates/xtop-tui/src/render/memory.rs
index e8279d7..918ef47 100644
--- a/crates/xtop-tui/src/render/memory.rs
+++ b/crates/xtop-tui/src/render/memory.rs
@@ -1,6 +1,7 @@
-use crate::color::to_color;
+use crate::color::{gauge_gradient, to_color};
use crate::format::format_bytes;
use ratatui::prelude::*;
+use ratatui::symbols::border;
use ratatui::widgets::{Axis, Block, Borders, Chart, Dataset, Gauge, GraphType};
use ratatui::Frame;
use xtop_core::application::state::AppState;
@@ -11,7 +12,7 @@ pub fn render(f: &mut Frame, state: &AppState, area: Rect) {
let snap = state.snapshot();
let mem_alert = snap.memory.percent > state.alerts.mem_high;
- let mem_color_idx = if mem_alert { 1 } else { 2 };
+ let mem_color_idx = if mem_alert { 1 } else { gauge_gradient(snap.memory.percent, state.alerts.mem_high) };
let mut title = "Memory".to_string();
if mem_alert {
@@ -21,6 +22,7 @@ pub fn render(f: &mut Frame, state: &AppState, area: Rect) {
let block = Block::default()
.title(title)
.borders(Borders::ALL)
+ .border_set(border::ROUNDED)
.style(Style::default().fg(fg).bg(bg));
let inner = block.inner(area);
f.render_widget(block, area);
@@ -83,6 +85,7 @@ fn render_swap_gauge(
bg: Color,
) {
let swap_pct = snap.swap.percent as u16;
+ let color_idx = gauge_gradient(snap.swap.percent, state.alerts.mem_high);
let label = format!(
"SWP: {} / {} ({:>3.0}%)",
format_bytes(snap.swap.used),
@@ -92,7 +95,7 @@ fn render_swap_gauge(
let gauge = Gauge::default()
.gauge_style(
Style::default()
- .fg(to_color(&state.current_theme.palette[3]))
+ .fg(to_color(&state.current_theme.palette[color_idx]))
.bg(bg),
)
.percent(swap_pct)
@@ -119,7 +122,15 @@ fn render_chart(f: &mut Frame, state: &AppState, area: Rect, _bg: Color) {
let chart = Chart::new(datasets)
.block(Block::default().borders(Borders::TOP))
- .x_axis(Axis::default().bounds([x_min, x_max]))
- .y_axis(Axis::default().bounds([0.0, 100.0]));
+ .x_axis(
+ Axis::default()
+ .bounds([x_min, x_max])
+ .labels(vec![Span::raw("")]),
+ )
+ .y_axis(
+ Axis::default()
+ .bounds([0.0, 100.0])
+ .labels(vec![Span::raw("0%"), Span::raw("50%"), Span::raw("100%")]),
+ );
f.render_widget(chart, area);
}
diff --git a/crates/xtop-tui/src/render/network.rs b/crates/xtop-tui/src/render/network.rs
index 8a2b7a2..e1502b6 100644
--- a/crates/xtop-tui/src/render/network.rs
+++ b/crates/xtop-tui/src/render/network.rs
@@ -1,7 +1,8 @@
use crate::color::to_color;
use crate::format::format_bytes;
use ratatui::prelude::*;
-use ratatui::widgets::{Block, Borders, Paragraph, Wrap};
+use ratatui::symbols::border;
+use ratatui::widgets::{Axis, Block, Borders, Chart, Dataset, GraphType, Paragraph, Wrap};
use ratatui::Frame;
use xtop_core::application::state::AppState;
@@ -12,6 +13,7 @@ pub fn render(f: &mut Frame, state: &AppState, area: Rect) {
let block = Block::default()
.title("Network")
.borders(Borders::ALL)
+ .border_set(border::DOUBLE)
.style(Style::default().fg(fg).bg(bg));
let inner = block.inner(area);
f.render_widget(block, area);
@@ -22,6 +24,33 @@ pub fn render(f: &mut Frame, state: &AppState, area: Rect) {
let total_rx_speed: f64 = snap.networks.iter().map(|n| n.rx_speed).sum();
let total_tx_speed: f64 = snap.networks.iter().map(|n| n.tx_speed).sum();
+ let has_chart = inner.height > 6;
+
+ if has_chart {
+ let chunks = Layout::default()
+ .direction(Direction::Vertical)
+ .constraints([Constraint::Length(4), Constraint::Min(0)])
+ .split(inner);
+
+ render_stats(f, state, chunks[0], fg, total_rx, total_tx, total_rx_speed, total_tx_speed, &snap.networks);
+ render_net_chart(f, state, chunks[1], bg);
+ } else {
+ render_stats(f, state, inner, fg, total_rx, total_tx, total_rx_speed, total_tx_speed, &snap.networks);
+ }
+}
+
+#[allow(clippy::too_many_arguments)]
+fn render_stats(
+ f: &mut Frame,
+ state: &AppState,
+ area: Rect,
+ fg: Color,
+ total_rx: u64,
+ total_tx: u64,
+ total_rx_speed: f64,
+ total_tx_speed: f64,
+ interfaces: &[xtop_core::domain::metrics::NetworkInfo],
+) {
let mut text = vec![
Line::from(vec![
Span::styled("RX: ", Style::default().fg(fg)),
@@ -49,9 +78,9 @@ pub fn render(f: &mut Frame, state: &AppState, area: Rect) {
]),
];
- if inner.height > 4 {
- for iface in &snap.networks {
- if text.len() as u16 >= inner.height.saturating_sub(1) {
+ if area.height > 4 {
+ for iface in interfaces {
+ if text.len() as u16 >= area.height.saturating_sub(1) {
break;
}
text.push(Line::from(Span::raw(format!(
@@ -64,5 +93,58 @@ pub fn render(f: &mut Frame, state: &AppState, area: Rect) {
}
let p = Paragraph::new(text).wrap(Wrap { trim: true });
- f.render_widget(p, inner);
+ f.render_widget(p, area);
+}
+
+fn render_net_chart(f: &mut Frame, state: &AppState, area: Rect, _bg: Color) {
+ let rx_data: Vec<(f64, f64)> = state.history.net_rx.iter().copied().collect();
+ let tx_data: Vec<(f64, f64)> = state.history.net_tx.iter().copied().collect();
+ if rx_data.len() < 2 || tx_data.len() < 2 {
+ return;
+ }
+
+ // Find max value for y-axis bounds
+ let max_val = rx_data
+ .iter()
+ .chain(tx_data.iter())
+ .map(|&(_, v)| v)
+ .fold(0.0_f64, f64::max)
+ .max(1.0);
+
+ let datasets = vec![
+ Dataset::default()
+ .name("RX")
+ .marker(symbols::Marker::Braille)
+ .graph_type(GraphType::Line)
+ .style(Style::default().fg(to_color(&state.current_theme.palette[4])))
+ .data(&rx_data),
+ Dataset::default()
+ .name("TX")
+ .marker(symbols::Marker::Braille)
+ .graph_type(GraphType::Line)
+ .style(Style::default().fg(to_color(&state.current_theme.palette[5])))
+ .data(&tx_data),
+ ];
+
+ let x_min = rx_data.first().map(|&(x, _)| x).unwrap_or(0.0);
+ let x_max = rx_data.last().map(|&(x, _)| x).unwrap_or(100.0);
+ let x_max = x_max.max(x_min + 1.0);
+
+ let chart = Chart::new(datasets)
+ .block(Block::default().borders(Borders::TOP))
+ .x_axis(
+ Axis::default()
+ .bounds([x_min, x_max])
+ .labels(vec![Span::raw("")]),
+ )
+ .y_axis(
+ Axis::default()
+ .bounds([0.0, max_val])
+ .labels(vec![
+ Span::raw("0"),
+ Span::raw(format!("{:.0}", max_val / 2.0)),
+ Span::raw(format!("{:.0}", max_val)),
+ ]),
+ );
+ f.render_widget(chart, area);
}
diff --git a/crates/xtop-tui/src/render/palette.rs b/crates/xtop-tui/src/render/palette.rs
index 3a610cf..54f4c11 100644
--- a/crates/xtop-tui/src/render/palette.rs
+++ b/crates/xtop-tui/src/render/palette.rs
@@ -1,15 +1,16 @@
use crate::color::to_color;
use ratatui::prelude::*;
-use ratatui::widgets::{Block, Borders, List, ListItem, Paragraph};
+use ratatui::widgets::{Block, Borders, List, ListItem, ListState, Paragraph};
use ratatui::Frame;
-use xtop_core::application::state::AppState;
+use xtop_core::application::state::{AppState, PalettePage};
pub fn render(f: &mut Frame, state: &AppState, area: Rect) {
let fg = to_color(state.current_theme.fg());
let bg = to_color(state.current_theme.bg());
+ let accent = to_color(&state.current_theme.palette[6]);
- let popup_width = (area.width as f64 * 0.6) as u16;
- let popup_height = (area.height as f64 * 0.6) as u16;
+ let popup_width = (area.width as f64 * 0.6).min(60.0) as u16;
+ let popup_height = (area.height as f64 * 0.6).min(30.0) as u16;
let popup_x = (area.width.saturating_sub(popup_width)) / 2;
let popup_y = (area.height.saturating_sub(popup_height)) / 2;
@@ -20,34 +21,40 @@ pub fn render(f: &mut Frame, state: &AppState, area: Rect) {
height: popup_height,
};
+ let title = state.palette.title();
let block = Block::default()
- .title("Command Palette")
+ .title(title)
.borders(Borders::ALL)
+ .border_style(Style::default().fg(accent))
.style(Style::default().fg(fg).bg(bg));
let inner = block.inner(popup);
f.render_widget(block, popup);
+ let hint = if state.palette.page == PalettePage::Main {
+ " Filter: type to search · navigate with ↑↓ · select with Enter"
+ } else {
+ " Filter themes · Enter to select · Esc/Bksp to go back"
+ };
+ let input_label = match state.palette.page {
+ PalettePage::Main => "Action",
+ PalettePage::Themes => "Theme",
+ PalettePage::Layouts => "Layout",
+ };
+
let search_area = Rect {
x: inner.x,
y: inner.y,
width: inner.width,
height: 3,
};
- let input_text = format!(
- " {}_",
- if state.palette.query.is_empty() {
- String::new()
- } else {
- state.palette.query.clone()
- }
- );
+ let input_text = if state.palette.query.is_empty() {
+ format!(" {}_", hint)
+ } else {
+ format!(" {}: {}", input_label, state.palette.query)
+ };
let input = Paragraph::new(input_text.as_str())
- .style(Style::default().fg(fg).bg(bg))
- .block(
- Block::default()
- .borders(Borders::ALL)
- .title("Search commands"),
- );
+ .style(Style::default().fg(accent).bg(bg))
+ .block(Block::default().borders(Borders::ALL));
f.render_widget(input, search_area);
let list_area = Rect {
@@ -61,22 +68,22 @@ pub fn render(f: &mut Frame, state: &AppState, area: Rect) {
.palette
.filtered
.iter()
- .enumerate()
- .map(|(idx, &entry_idx)| {
+ .map(|&entry_idx| {
let entry = &state.palette.entries[entry_idx];
- let is_selected = idx == state.palette.selected;
- let style = if is_selected {
- Style::default()
- .fg(bg)
- .bg(fg)
- .add_modifier(Modifier::BOLD)
- } else {
- Style::default().fg(fg)
- };
- ListItem::new(entry.label.as_str()).style(style)
+ ListItem::new(entry.label.as_str()).style(Style::default().fg(fg))
})
.collect();
- let list = List::new(items);
- f.render_widget(list, list_area);
+ let mut list_state = ListState::default();
+ list_state.select(Some(state.palette.selected));
+
+ let list = List::new(items)
+ .highlight_style(
+ Style::default()
+ .fg(bg)
+ .bg(fg)
+ .add_modifier(Modifier::BOLD),
+ )
+ .highlight_symbol("▸ ");
+ f.render_stateful_widget(list, list_area, &mut list_state);
}
diff --git a/crates/xtop-tui/src/render/processes.rs b/crates/xtop-tui/src/render/processes.rs
index 1481138..0bf6e77 100644
--- a/crates/xtop-tui/src/render/processes.rs
+++ b/crates/xtop-tui/src/render/processes.rs
@@ -1,5 +1,6 @@
use crate::color::to_color;
use ratatui::prelude::*;
+use ratatui::symbols::border;
use ratatui::widgets::{Block, Borders, Cell, Row, Table};
use ratatui::Frame;
use xtop_core::application::state::AppState;
@@ -16,15 +17,12 @@ pub fn render(f: &mut Frame, state: &AppState, area: Rect) {
let block = Block::default()
.title(title)
.borders(Borders::ALL)
+ .border_set(border::PLAIN)
.style(Style::default().fg(fg).bg(bg));
let inner = block.inner(area);
f.render_widget(block, area);
let snap = state.snapshot();
- let separator = Span::styled(
- " | ",
- Style::default().fg(to_color(&state.current_theme.palette[8])),
- );
let iter: Box> =
if state.search_query.is_empty() {
@@ -38,28 +36,24 @@ pub fn render(f: &mut Frame, state: &AppState, area: Rect) {
)
};
+ let dim_bg = to_color(&state.current_theme.palette[8]);
+
let rows: Vec = iter
- .map(|p| {
+ .enumerate()
+ .map(|(row_idx, p)| {
+ let style = if row_idx % 2 == 0 {
+ Style::default().fg(fg)
+ } else {
+ Style::default().fg(fg).bg(dim_bg)
+ };
Row::new(vec![
- Cell::from(Line::from(vec![
- Span::raw(p.pid.to_string()),
- separator.clone(),
- ])),
- Cell::from(Line::from(vec![
- Span::raw(p.name.clone()),
- separator.clone(),
- ])),
- Cell::from(Line::from(vec![
- Span::raw(format!("{:.1}%", p.cpu_usage)),
- separator.clone(),
- ])),
- Cell::from(Line::from(vec![
- Span::raw(crate::format::format_bytes(p.memory)),
- separator.clone(),
- ])),
+ Cell::from(p.pid.to_string()),
+ Cell::from(p.name.clone()),
+ Cell::from(format!("{:.1}%", p.cpu_usage)),
+ Cell::from(crate::format::format_bytes(p.memory)),
Cell::from(p.user_id.clone().unwrap_or_else(|| "?".to_string())),
])
- .style(Style::default().fg(fg))
+ .style(style)
})
.collect();
@@ -73,7 +67,7 @@ pub fn render(f: &mut Frame, state: &AppState, area: Rect) {
let table = Table::new(rows, widths)
.header(
- Row::new(vec!["PID |", "Name |", "CPU% |", "Mem |", "User"])
+ Row::new(vec!["PID", "Name", "CPU%", "Mem", "User"])
.style(
Style::default()
.fg(to_color(&state.current_theme.palette[6]))
diff --git a/crates/xtop-tui/src/render/storage.rs b/crates/xtop-tui/src/render/storage.rs
index 336039e..45c9f5e 100644
--- a/crates/xtop-tui/src/render/storage.rs
+++ b/crates/xtop-tui/src/render/storage.rs
@@ -1,6 +1,7 @@
-use crate::color::to_color;
+use crate::color::{gauge_gradient, to_color};
use crate::format::format_bytes;
use ratatui::prelude::*;
+use ratatui::symbols::border;
use ratatui::widgets::{Block, Borders, Gauge};
use ratatui::Frame;
use xtop_core::application::state::AppState;
@@ -12,6 +13,7 @@ pub fn render(f: &mut Frame, state: &AppState, area: Rect) {
let block = Block::default()
.title("Storage")
.borders(Borders::ALL)
+ .border_set(border::DOUBLE)
.style(Style::default().fg(fg).bg(bg));
let inner = block.inner(area);
f.render_widget(block, area);
@@ -33,8 +35,7 @@ pub fn render(f: &mut Frame, state: &AppState, area: Rect) {
if i >= chunks.len() {
break;
}
- let is_alert = disk.percent > state.alerts.disk_high;
- let color_idx = if is_alert { 1 } else { 4 };
+ let color_idx = gauge_gradient(disk.percent, state.alerts.disk_high);
let label = format!(
"{} Tot: {} Use: {} Free: {}",
disk.mount_point,
From f0446577316225e215d4c259447ca9e8b4db04ae Mon Sep 17 00:00:00 2001
From: xscriptor
Date: Wed, 17 Jun 2026 14:54:26 +0200
Subject: [PATCH 4/6] update sysinfo start work with plugins preparing for
release
---
Cargo.lock | 204 ++++++----
Cargo.toml | 2 +-
README.md | 325 ++++++++++++----
crates/xtop-cli/src/main.rs | 3 +
crates/xtop-core/src/application/state.rs | 102 ++++-
crates/xtop-core/src/domain/keybinding.rs | 36 ++
crates/xtop-core/src/domain/metrics.rs | 19 +
crates/xtop-core/src/domain/system_info.rs | 6 +
.../src/infrastructure/sysinfo_provider.rs | 326 +++++++++++++++-
.../src/infrastructure/theme_loader.rs | 12 +-
crates/xtop-tui/src/render/header.rs | 12 +-
crates/xtop-tui/src/render/processes.rs | 64 +++-
crates/xtop-tui/src/render/storage.rs | 4 +-
docs/customization.md | 347 ++++++++++++++----
14 files changed, 1203 insertions(+), 259 deletions(-)
diff --git a/Cargo.lock b/Cargo.lock
index 8572700..c136937 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -55,37 +55,6 @@ dependencies = [
"static_assertions",
]
-[[package]]
-name = "core-foundation-sys"
-version = "0.8.7"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
-
-[[package]]
-name = "crossbeam-deque"
-version = "0.8.6"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51"
-dependencies = [
- "crossbeam-epoch",
- "crossbeam-utils",
-]
-
-[[package]]
-name = "crossbeam-epoch"
-version = "0.9.18"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e"
-dependencies = [
- "crossbeam-utils",
-]
-
-[[package]]
-name = "crossbeam-utils"
-version = "0.8.21"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
-
[[package]]
name = "crossterm"
version = "0.28.1"
@@ -145,6 +114,16 @@ dependencies = [
"syn",
]
+[[package]]
+name = "dispatch2"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38"
+dependencies = [
+ "bitflags",
+ "objc2",
+]
+
[[package]]
name = "either"
version = "1.16.0"
@@ -296,6 +275,63 @@ dependencies = [
"winapi",
]
+[[package]]
+name = "objc2"
+version = "0.6.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f"
+dependencies = [
+ "objc2-encode",
+]
+
+[[package]]
+name = "objc2-core-foundation"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536"
+dependencies = [
+ "bitflags",
+ "dispatch2",
+ "objc2",
+]
+
+[[package]]
+name = "objc2-encode"
+version = "4.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33"
+
+[[package]]
+name = "objc2-foundation"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272"
+dependencies = [
+ "bitflags",
+ "objc2",
+]
+
+[[package]]
+name = "objc2-io-kit"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "33fafba39597d6dc1fb709123dfa8289d39406734be322956a69f0931c73bb15"
+dependencies = [
+ "libc",
+ "objc2-core-foundation",
+]
+
+[[package]]
+name = "objc2-open-directory"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bb82bed227edf5201dfedf072bba4015a33d3d4a98519837295a90f0a23f676d"
+dependencies = [
+ "objc2",
+ "objc2-core-foundation",
+ "objc2-foundation",
+]
+
[[package]]
name = "parking_lot"
version = "0.12.5"
@@ -364,26 +400,6 @@ dependencies = [
"unicode-width 0.2.0",
]
-[[package]]
-name = "rayon"
-version = "1.12.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d"
-dependencies = [
- "either",
- "rayon-core",
-]
-
-[[package]]
-name = "rayon-core"
-version = "1.13.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91"
-dependencies = [
- "crossbeam-deque",
- "crossbeam-utils",
-]
-
[[package]]
name = "redox_syscall"
version = "0.5.18"
@@ -551,15 +567,16 @@ dependencies = [
[[package]]
name = "sysinfo"
-version = "0.33.1"
+version = "0.39.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "4fc858248ea01b66f19d8e8a6d55f41deaf91e9d495246fd01368d99935c6c01"
+checksum = "21d0d938c10fcda3e897e28aaddf4ab462375d411f4378cd63b1c945f69aba96"
dependencies = [
- "core-foundation-sys",
"libc",
"memchr",
"ntapi",
- "rayon",
+ "objc2-core-foundation",
+ "objc2-io-kit",
+ "objc2-open-directory",
"windows",
]
@@ -628,31 +645,54 @@ checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
[[package]]
name = "windows"
-version = "0.57.0"
+version = "0.62.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "12342cb4d8e3b046f3d80effd474a7a02447231330ef77d71daa6fbc40681143"
+checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580"
+dependencies = [
+ "windows-collections",
+ "windows-core",
+ "windows-future",
+ "windows-numerics",
+]
+
+[[package]]
+name = "windows-collections"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610"
dependencies = [
"windows-core",
- "windows-targets",
]
[[package]]
name = "windows-core"
-version = "0.57.0"
+version = "0.62.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d2ed2439a290666cd67ecce2b0ffaad89c2a56b976b736e6ece670297897832d"
+checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
dependencies = [
"windows-implement",
"windows-interface",
+ "windows-link",
"windows-result",
- "windows-targets",
+ "windows-strings",
+]
+
+[[package]]
+name = "windows-future"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb"
+dependencies = [
+ "windows-core",
+ "windows-link",
+ "windows-threading",
]
[[package]]
name = "windows-implement"
-version = "0.57.0"
+version = "0.60.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9107ddc059d5b6fbfbffdfa7a7fe3e22a226def0b2608f72e9d552763d3e1ad7"
+checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf"
dependencies = [
"proc-macro2",
"quote",
@@ -661,9 +701,9 @@ dependencies = [
[[package]]
name = "windows-interface"
-version = "0.57.0"
+version = "0.59.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "29bee4b38ea3cde66011baa44dba677c432a78593e202392d1e9070cf2a7fca7"
+checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358"
dependencies = [
"proc-macro2",
"quote",
@@ -676,13 +716,32 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
+[[package]]
+name = "windows-numerics"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26"
+dependencies = [
+ "windows-core",
+ "windows-link",
+]
+
[[package]]
name = "windows-result"
-version = "0.1.2"
+version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5e383302e8ec8515204254685643de10811af0ed97ea37210dc26fb0032647f8"
+checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5"
dependencies = [
- "windows-targets",
+ "windows-link",
+]
+
+[[package]]
+name = "windows-strings"
+version = "0.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091"
+dependencies = [
+ "windows-link",
]
[[package]]
@@ -719,6 +778,15 @@ dependencies = [
"windows_x86_64_msvc",
]
+[[package]]
+name = "windows-threading"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37"
+dependencies = [
+ "windows-link",
+]
+
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.52.6"
diff --git a/Cargo.toml b/Cargo.toml
index cf97497..aa42b6d 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -10,7 +10,7 @@ license = "MIT"
[workspace.dependencies]
ratatui = "0.29"
crossterm = "0.28"
-sysinfo = "0.33"
+sysinfo = "0.39"
anyhow = "1"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
diff --git a/README.md b/README.md
index 0b98467..ebb6731 100644
--- a/README.md
+++ b/README.md
@@ -1,18 +1,78 @@
- Xtop
+Xtop
-    
+
+
+
+
+
-xtop is a modern, cross-platform TUI system monitor crafted in Rust. Heavily inspired by btop, it leverages Rust's safety and performance, powered by ratatui for the interface and sysinfo for real-time metrics.
+xtop is a modern, cross-platform TUI system monitor crafted in Rust. Heavily inspired by btop, it leverages Rust's safety and performance, powered by
ratatui for the interface and
sysinfo for real-time metrics.
-
+
---
-# Previews
+## Table of Contents
+
+- [Features](#features)
+- [Previews](#previews)
+- [Installation](#installation)
+ - [Quick Install (macOS/Linux)](#quick-install-macoslinux)
+ - [Quick Install (Windows PowerShell)](#quick-install-windows-powershell)
+ - [Build from Source](#build-from-source)
+- [Usage](#usage)
+ - [Keybindings](#keybindings)
+ - [Modules](#modules)
+ - [Help Overlay](#help-overlay)
+- [Customization](#customization)
+- [Command Palette](#command-palette)
+- [Configuration](#configuration)
+- [Contributing](#contributing)
+- [License](#license)
+
+---
+
+Features
+
+
+ - Cross-Platform: Runs on macOS, Linux, and Windows.
+ - System Monitoring:
+
+ - CPU: Usage per core/thread, maximum temperature sensing.
+ - Memory: RAM and Swap usage with historical graphing.
+ - Network: Real-time upload and download tracking per interface.
+ - Disks: Storage usage visualization with per-mount-point gauges.
+ - Disk I/O: Read/write speed tracking per disk.
+ - Processes: List of running processes sorted by CPU usage with live search.
+ - GPU: Usage gauges (stub — ready for NVIDIA/AMD).
+ - Battery: Charge level gauges (stub — ready for laptop support).
+
+
+ - Theming:
+
+ - 13 ready-to-use color schemes + custom themes via JSONC files.
+ - Cycle through themes instantly with t / T.
+
+
+ - Layouts:
+
+ - 7 built-in layouts (Dashboard, Vertical, Horizontal, CPU/Memory/Network/Process Focus).
+ - Custom layouts via JSONC files — define your own widget tree.
+ - Full-screen mode for any widget.
+ - Responsive design that adapts to terminal size.
+
+
+ - Alert Thresholds: Visual warnings when CPU, memory, or disk usage exceeds configurable limits.
+ - Persistence: Saves your theme, layout, and configuration automatically on quit.
+
+
+---
+
+Previews
@@ -48,40 +108,28 @@ xtop is a modern, cross-platform TUI system monitor crafted in Rust. Heavily ins
-## Features
+---
-- **Cross-Platform:** Runs on macOS, Linux, and Windows.
-- **System Monitoring:**
- - **CPU:** Usage per core/thread, maximum temperature sensing.
- - **Memory:** RAM and Swap usage with historical graphing.
- - **Network:** Real-time upload and download tracking.
- - **Disks:** Storage usage visualization.
- - **Processes:** List of running processes sorted by CPU usage.
-- **Theming:**
- - 13 ready-to-use color schemes + custom themes via JSONC files.
- - Cycle through themes instantly with `t` / `T`.
-- **Layouts:**
- - 7 built-in layouts (Dashboard, Vertical, Horizontal, CPU/Memory/Network/Process Focus).
- - Custom layouts via JSONC files — define your own widget tree.
- - Full-screen mode for any widget.
+Installation
-## Installation
+Quick Install (macOS/Linux)
-### Quick Install (macOS/Linux)
+The installer script automatically detects your distribution and installs all required dependencies (including Rust if needed).
-The installer script automatically detects your distribution and installs all required dependencies (including Rust if needed).
+Install with curl:
-**Install with curl:**
```bash
curl -fsSL https://raw.githubusercontent.com/xscriptor/xtop/main/install.sh | bash
```
-**Or with wget:**
+Or with wget:
+
```bash
wget -qO- https://raw.githubusercontent.com/xscriptor/xtop/main/install.sh | bash
```
-**Uninstall:**
+Uninstall:
+
```bash
curl -fsSL https://raw.githubusercontent.com/xscriptor/xtop/main/install.sh | bash -s -- --uninstall
```
@@ -89,7 +137,7 @@ curl -fsSL https://raw.githubusercontent.com/xscriptor/xtop/main/install.sh | ba
Installer Options
-You can also run the installer with additional options:
+You can also run the installer with additional options:
```bash
# Check dependencies without installing
@@ -102,79 +150,210 @@ You can also run the installer with additional options:
./install.sh --help
```
-**Supported distributions:** Arch, Debian/Ubuntu, Fedora/RHEL, openSUSE, Alpine, and derivatives.
+Supported distributions: Arch, Debian/Ubuntu, Fedora/RHEL, openSUSE, Alpine, and derivatives.
-### Quick Install (Windows PowerShell)
+Quick Install (Windows PowerShell)
+
+Requires Rust (Cargo) installed. Run in PowerShell:
-Requires [Rust (Cargo)](https://rustup.rs/) installed. Run in PowerShell:
+Install:
-**Install:**
```powershell
irm https://raw.githubusercontent.com/xscriptor/xtop/main/install.ps1 | iex
```
-**Uninstall:**
+Uninstall:
+
```powershell
irm https://raw.githubusercontent.com/xscriptor/xtop/main/uninstall.ps1 | iex
```
-### Build from Source
+Build from Source
+
+
+ - Clone the repository:
+
+
+```bash
+git clone https://github.com/xscriptor/xtop.git
+cd xtop
+```
+
+
+
+ - Build and run:
+
+```bash
+cargo run --release
+```
+
+
+
+
+---
+
+Usage
+
+Keybindings
+
+
+
+
+ | Key |
+ Action |
+
+
+
+
+ | q |
+ Quit application (saves config) |
+
+
+ | ? |
+ Toggle help overlay |
+
+
+ | t |
+ Next color theme |
+
+
+ | T |
+ Previous color theme |
+
+
+ | l |
+ Next layout mode (built-in + custom) |
+
+
+ | f |
+ Toggle fullscreen for current widget |
+
+
+ | F |
+ Cycle fullscreen through widgets |
+
+
+ | / |
+ Search / filter processes |
+
+
+ | Esc |
+ Cancel search / close help overlay |
+
+
+
+
+Modules
+
+
+ - Header: Shows system uptime, load average, current theme, and layout mode.
+ - CPU: Shows usage bars for each CPU core. If sensors are available, shows the maximum CPU temperature.
+ - Memory: Gauges for RAM and Swap usage, plus a line chart for RAM history.
+ - Storage: Disk usage gauges per mount point.
+ - Network: Total downloaded (RX) and uploaded (TX) data per interface.
+ - Disk I/O: Read/write speeds per disk device.
+ - Processes: A scrolling list of the top 50 processes sorted by CPU usage, with live search.
+ - GPU: GPU usage gauges (available on supported hardware).
+ - Battery: Battery charge level gauges (available on supported hardware).
+
+
+Help Overlay
+
+Press ? at any time to show a full list of available keybindings directly on screen. Press ? again or Esc to close.
-1. Clone the repository:
- ```bash
- git clone https://github.com/xscriptor/xtop.git
- cd xtop
- ```
+---
+
+Customization
-2. Build and run:
- ```bash
- cargo run --release
- ```
+xtop supports custom color themes and layout modes defined as JSONC files.
-## Usage
+→ Full customization guide
-### Keybindings
+
+
+
+ | Feature |
+ Location |
+ Format |
+
+
+
+
+ | Themes |
+ ~/.config/xtop/themes/*.jsonc |
+ 16-entry hex color palette |
+
+
+ | Layouts |
+ ~/.config/xtop/layouts/*.jsonc |
+ Recursive split/widget tree |
+
+
+
-| Key | Action |
-| --- | --- |
-| `q` | Quit application |
-| `t` | Next Color Theme |
-| `T` | Previous Color Theme |
-| `l` | Next Layout Mode (built-in + custom) |
-| `f` | Toggle fullscreen widget |
-| `F` | Cycle fullscreen widget |
-| `/` | Search / filter processes |
+The built-in x theme (almost-black background, purple-pink accents) is always available. Starter theme and layout files ship in the assets/ directory.
-### Modules
+---
-1. **Header**: Shows system uptime, load average, current theme, and layout mode.
-2. **CPU**: Shows usage bars for each CPU core. If sensors are available, shows the maximum CPU temperature.
-3. **Memory**: Gauges for RAM and Swap usage, plus a line chart for RAM history.
-4. **Network**: Total downloaded (RX) and uploaded (TX) data.
-5. **Processes**: A scrolling list of the top 50 processes sorted by CPU usage.
+Command Palette
-## Customization
+xtop provides an interactive search overlay for filtering processes in real time:
-xtop supports custom color themes and layout modes defined as JSONC files.
+
+ - Press / to open the search bar at the top of the process list.
+ - Type any query — results filter instantly by process name.
+ - Press Enter to confirm the filter, Esc to cancel, Backspace to delete characters.
+ - A centered overlay with
/query_ indicator shows the current search input.
+
-**[→ Full customization guide](docs/customization.md)**
+The help overlay (?) serves as a quick-reference command palette for all available keybindings and actions.
-| Feature | Location | Format |
-|---------|----------|--------|
-| Themes | `~/.config/xtop/themes/*.jsonc` | 16-entry hex color palette |
-| Layouts | `~/.config/xtop/layouts/*.jsonc` | Recursive split/widget tree |
+---
-The built-in `miami` theme (black background, neon accents) is always available. Starter theme and layout files ship in the `assets/` directory.
+Configuration
-## Contributing
+xtop automatically saves its configuration on quit. The configuration file is located at:
-Contributions are always welcome! Please read the [contribution guidelines](CONTRIBUTING.md) first.
+~/.config/xtop/config.json
-## License
-[MIT](LICENSE)
+Persisted settings include:
+
+
+ - Current theme
+ - Current layout mode
+ - Update interval
+ - History points (for RAM chart)
+ - Alert thresholds (CPU, memory, disk)
+
+
+For custom themes and layouts, see the customization guide.
+
+---
+
+Roadmap
+
+See the ROADMAP.md for planned features and upcoming milestones.
+
+---
+
+Changelog
+
+See the CHANGELOG.md for detailed release notes.
+
+---
+
+Contributing
+
+Contributions are always welcome! Please read the contribution guidelines first.
+
+---
+
+License
+
+MIT
\ No newline at end of file
+ ---X---
+
diff --git a/crates/xtop-cli/src/main.rs b/crates/xtop-cli/src/main.rs
index 1bbf417..6ebe068 100644
--- a/crates/xtop-cli/src/main.rs
+++ b/crates/xtop-cli/src/main.rs
@@ -146,6 +146,9 @@ fn main() -> anyhow::Result<()> {
state.open_palette();
state.input_mode = InputMode::CommandPalette;
}
+ Action::KillProcess | Action::ProcessUp | Action::ProcessDown => {
+ state.execute_action(&action);
+ }
_ => {
state.execute_action(&action);
}
diff --git a/crates/xtop-core/src/application/state.rs b/crates/xtop-core/src/application/state.rs
index b7e2f01..4806dc7 100644
--- a/crates/xtop-core/src/application/state.rs
+++ b/crates/xtop-core/src/application/state.rs
@@ -1,5 +1,6 @@
use crate::application::history::MetricsHistory;
use crate::domain::keybinding::{Action, Keybindings};
+use crate::domain::metrics::SystemInfo;
use crate::domain::layout::LayoutDef;
use crate::domain::metrics::SystemSnapshot;
use crate::domain::system_info::SystemDataProvider;
@@ -110,6 +111,34 @@ pub enum FullScreenWidget {
Battery,
}
+#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
+pub enum ProcessSortBy {
+ Cpu,
+ Memory,
+ Pid,
+ Name,
+}
+
+impl ProcessSortBy {
+ pub fn next(self) -> Self {
+ match self {
+ Self::Cpu => Self::Memory,
+ Self::Memory => Self::Pid,
+ Self::Pid => Self::Name,
+ Self::Name => Self::Cpu,
+ }
+ }
+
+ pub fn label(self) -> &'static str {
+ match self {
+ Self::Cpu => "CPU%",
+ Self::Memory => "Mem",
+ Self::Pid => "PID",
+ Self::Name => "Name",
+ }
+ }
+}
+
impl FullScreenWidget {
pub fn next(self) -> Self {
match self {
@@ -215,7 +244,7 @@ pub struct Config {
impl Default for Config {
fn default() -> Self {
Self {
- theme: "miami".to_string(),
+ theme: "x".to_string(),
layout_mode: LayoutMode::Dashboard,
update_interval_ms: 1000,
history_points: 100,
@@ -245,6 +274,9 @@ pub struct AppState {
pub config_path: String,
pub palette: PaletteState,
pub keybindings: Keybindings,
+ pub process_sort: ProcessSortBy,
+ pub process_selected: Option,
+ pub sys_info: SystemInfo,
}
impl AppState {
@@ -287,6 +319,9 @@ impl AppState {
page: PalettePage::Main,
},
keybindings: config.keybindings,
+ process_sort: ProcessSortBy::Cpu,
+ process_selected: None,
+ sys_info: SystemInfo::default(),
}
}
@@ -301,6 +336,10 @@ impl AppState {
pub fn on_tick(&mut self) {
self.provider.refresh_all();
self.tick_count += 1.0;
+ let info = self.provider.system_info();
+ if !info.hostname.is_empty() {
+ self.sys_info = info;
+ }
let x = self.tick_count;
let snap = self.provider.snapshot();
@@ -324,6 +363,7 @@ impl AppState {
snap.batteries = self.provider.batteries();
snap.gpus = self.provider.gpu_info();
snap.dockers = self.provider.docker_info();
+ snap.sys_info = self.provider.system_info();
snap
}
@@ -419,6 +459,14 @@ impl AppState {
label: "Toggle Help".into(),
action: Action::ToggleHelp,
});
+ self.palette.entries.push(PaletteEntry {
+ label: format!("Sort: {}", self.process_sort.label()),
+ action: Action::SortByCpu,
+ });
+ self.palette.entries.push(PaletteEntry {
+ label: "Random Theme".into(),
+ action: Action::RandomTheme,
+ });
self.palette.entries.push(PaletteEntry {
label: "Exit".into(),
action: Action::Quit,
@@ -492,6 +540,33 @@ impl AppState {
}
}
+ pub fn process_select_next(&mut self) {
+ let snap = self.snapshot();
+ if snap.processes.is_empty() {
+ return;
+ }
+ let idx = self.process_selected.unwrap_or(0);
+ self.process_selected = Some((idx + 1) % snap.processes.len());
+ }
+
+ pub fn process_select_prev(&mut self) {
+ let snap = self.snapshot();
+ if snap.processes.is_empty() {
+ return;
+ }
+ let idx = self.process_selected.unwrap_or(0);
+ self.process_selected = Some(if idx == 0 {
+ snap.processes.len() - 1
+ } else {
+ idx - 1
+ });
+ }
+
+ pub fn cycle_sort(&mut self) {
+ self.process_sort = self.process_sort.next();
+ self.process_selected = None;
+ }
+
pub fn palette_selected_action(&self) -> Option {
self.palette
.filtered
@@ -539,6 +614,29 @@ impl AppState {
self.palette_navigate_to(PalettePage::Layouts);
return;
}
+ Action::KillProcess => {
+ if let Some(pid) = self.process_selected {
+ let snap = self.snapshot();
+ if pid < snap.processes.len() {
+ let target = snap.processes[pid].pid;
+ self.provider.kill_process(target);
+ self.process_selected = None;
+ }
+ }
+ }
+ Action::ProcessUp => self.process_select_prev(),
+ Action::ProcessDown => self.process_select_next(),
+ Action::SortByPid | Action::SortByCpu | Action::SortByName | Action::SortByMem => {
+ self.cycle_sort();
+ }
+ Action::RandomTheme => {
+ let n = self.themes.len();
+ if n > 1 {
+ let next = (self.selected_theme_index + 7) % n;
+ self.selected_theme_index = next;
+ self.apply_theme();
+ }
+ }
}
// Close palette after executing any action (except navigation which returns above)
if self.input_mode == InputMode::CommandPalette {
@@ -638,7 +736,7 @@ mod tests {
#[test]
fn test_config_default() {
let c = Config::default();
- assert_eq!(c.theme, "miami");
+ assert_eq!(c.theme, "x");
assert_eq!(c.layout_mode, LayoutMode::Dashboard);
assert_eq!(c.update_interval_ms, 1000);
}
diff --git a/crates/xtop-core/src/domain/keybinding.rs b/crates/xtop-core/src/domain/keybinding.rs
index 20fc983..b5a99d2 100644
--- a/crates/xtop-core/src/domain/keybinding.rs
+++ b/crates/xtop-core/src/domain/keybinding.rs
@@ -22,6 +22,14 @@ pub struct Keybindings {
pub command_palette: Vec,
#[serde(default = "vec_one_escape")]
pub cancel: Vec,
+ #[serde(default = "vec_one_k")]
+ pub kill_process: Vec,
+ #[serde(default = "vec_one_up")]
+ pub process_up: Vec,
+ #[serde(default = "vec_one_down")]
+ pub process_down: Vec,
+ #[serde(default = "vec_one_s")]
+ pub cycle_sort: Vec,
}
fn vec_one_q() -> Vec { vec!["q".into()] }
@@ -34,6 +42,10 @@ fn vec_one_shift_f() -> Vec { vec!["F".into()] }
fn vec_one_slash() -> Vec { vec!["/".into()] }
fn vec_one_ctrl_p() -> Vec { vec!["ctrl+p".into()] }
fn vec_one_escape() -> Vec { vec!["escape".into()] }
+fn vec_one_k() -> Vec { vec!["k".into()] }
+fn vec_one_up() -> Vec { vec!["up".into()] }
+fn vec_one_down() -> Vec { vec!["down".into()] }
+fn vec_one_s() -> Vec { vec!["s".into()] }
impl Default for Keybindings {
fn default() -> Self {
@@ -48,6 +60,10 @@ impl Default for Keybindings {
search: vec_one_slash(),
command_palette: vec_one_ctrl_p(),
cancel: vec_one_escape(),
+ kill_process: vec_one_k(),
+ process_up: vec_one_up(),
+ process_down: vec_one_down(),
+ cycle_sort: vec_one_s(),
}
}
}
@@ -58,6 +74,7 @@ pub enum Action {
ToggleHelp,
NextTheme,
PreviousTheme,
+ RandomTheme,
NextLayout,
ToggleFullscreen,
CycleFullscreen,
@@ -68,6 +85,13 @@ pub enum Action {
SelectLayout(usize),
NavigateThemes,
NavigateLayouts,
+ KillProcess,
+ ProcessUp,
+ ProcessDown,
+ SortByPid,
+ SortByName,
+ SortByCpu,
+ SortByMem,
}
impl Keybindings {
@@ -102,6 +126,18 @@ impl Keybindings {
if self.cancel.contains(&key_str.to_string()) {
return Some(Action::Cancel);
}
+ if self.kill_process.contains(&key_str.to_string()) {
+ return Some(Action::KillProcess);
+ }
+ if self.process_up.contains(&key_str.to_string()) {
+ return Some(Action::ProcessUp);
+ }
+ if self.process_down.contains(&key_str.to_string()) {
+ return Some(Action::ProcessDown);
+ }
+ if self.cycle_sort.contains(&key_str.to_string()) {
+ return Some(Action::SortByCpu);
+ }
None
}
}
diff --git a/crates/xtop-core/src/domain/metrics.rs b/crates/xtop-core/src/domain/metrics.rs
index d7e6684..d92fc1a 100644
--- a/crates/xtop-core/src/domain/metrics.rs
+++ b/crates/xtop-core/src/domain/metrics.rs
@@ -3,6 +3,8 @@ pub struct CpuInfo {
pub name: String,
pub usage: f64,
pub cpu_id: usize,
+ pub frequency: u64,
+ pub governor: String,
}
#[derive(Debug, Clone)]
@@ -29,6 +31,8 @@ pub struct DiskInfo {
pub available_space: u64,
pub used_space: u64,
pub percent: f64,
+ pub file_system: String,
+ pub mount_options: String,
}
#[derive(Debug, Clone)]
@@ -47,6 +51,7 @@ pub struct NetworkInfo {
pub transmitted: u64,
pub rx_speed: f64,
pub tx_speed: f64,
+ pub ip: Vec,
}
#[derive(Debug, Clone)]
@@ -56,6 +61,8 @@ pub struct ProcessInfo {
pub cpu_usage: f64,
pub memory: u64,
pub user_id: Option,
+ pub state: String,
+ pub cmd: String,
}
#[derive(Debug, Clone)]
@@ -72,6 +79,8 @@ pub struct BatteryInfo {
pub state: String,
pub time_to_full: Option,
pub time_to_empty: Option,
+ pub health: f32,
+ pub cycle_count: Option,
}
#[derive(Debug, Clone)]
@@ -91,6 +100,15 @@ pub struct DockerInfo {
pub memory_usage: u64,
}
+#[derive(Debug, Clone, Default)]
+pub struct SystemInfo {
+ pub hostname: String,
+ pub os_version: String,
+ pub kernel: String,
+ pub desktop_env: String,
+ pub shell: String,
+}
+
#[derive(Debug, Clone)]
pub struct SystemSnapshot {
pub cpus: Vec,
@@ -106,4 +124,5 @@ pub struct SystemSnapshot {
pub batteries: Vec,
pub gpus: Vec,
pub dockers: Vec,
+ pub sys_info: SystemInfo,
}
diff --git a/crates/xtop-core/src/domain/system_info.rs b/crates/xtop-core/src/domain/system_info.rs
index cfa119d..5fb1bc3 100644
--- a/crates/xtop-core/src/domain/system_info.rs
+++ b/crates/xtop-core/src/domain/system_info.rs
@@ -15,4 +15,10 @@ pub trait SystemDataProvider: Send {
fn docker_info(&self) -> Vec {
vec![]
}
+ fn system_info(&self) -> SystemInfo {
+ SystemInfo::default()
+ }
+ fn kill_process(&self, _pid: u32) -> bool {
+ false
+ }
}
diff --git a/crates/xtop-core/src/infrastructure/sysinfo_provider.rs b/crates/xtop-core/src/infrastructure/sysinfo_provider.rs
index 007b93a..f3ae44c 100644
--- a/crates/xtop-core/src/infrastructure/sysinfo_provider.rs
+++ b/crates/xtop-core/src/infrastructure/sysinfo_provider.rs
@@ -3,8 +3,8 @@ use crate::domain::system_info::SystemDataProvider;
use std::collections::HashMap;
use std::time::Instant;
use sysinfo::{
- Components, CpuRefreshKind, Disks, MemoryRefreshKind, Networks, ProcessRefreshKind,
- RefreshKind, System,
+ Components, CpuRefreshKind, Disks, MemoryRefreshKind, Networks, Pid, ProcessRefreshKind,
+ RefreshKind, Signal, System,
};
pub struct SysinfoProvider {
@@ -17,6 +17,7 @@ pub struct SysinfoProvider {
prev_net_rx: HashMap,
prev_net_tx: HashMap,
last_refresh: Instant,
+ cached_sys_info: SystemInfo,
}
impl Default for SysinfoProvider {
@@ -33,6 +34,17 @@ impl SysinfoProvider {
.with_memory(MemoryRefreshKind::everything())
.with_processes(ProcessRefreshKind::everything()),
);
+ let info = SystemInfo {
+ hostname: System::host_name().unwrap_or_default(),
+ os_version: System::long_os_version().unwrap_or_default(),
+ kernel: System::kernel_version().unwrap_or_default(),
+ desktop_env: std::env::var("XDG_CURRENT_DESKTOP")
+ .or_else(|_| std::env::var("DESKTOP_SESSION"))
+ .unwrap_or_default(),
+ shell: std::env::var("SHELL")
+ .or_else(|_| std::env::var("ComSpec"))
+ .unwrap_or_default(),
+ };
Self {
sys,
disks: Disks::new_with_refreshed_list(),
@@ -43,6 +55,7 @@ impl SysinfoProvider {
prev_net_rx: HashMap::new(),
prev_net_tx: HashMap::new(),
last_refresh: Instant::now(),
+ cached_sys_info: info,
}
}
}
@@ -65,6 +78,8 @@ impl SystemDataProvider for SysinfoProvider {
name: c.name().to_string(),
usage: c.cpu_usage() as f64,
cpu_id: i,
+ frequency: c.frequency(),
+ governor: read_cpu_governor(i),
})
.collect();
@@ -95,15 +110,19 @@ impl SystemDataProvider for SysinfoProvider {
},
};
+ let mount_info = read_mount_options();
+
let disks: Vec = self
.disks
.iter()
.map(|d| {
+ let mp = d.mount_point().to_string_lossy().to_string();
let total = d.total_space();
let available = d.available_space();
let used = total - available;
+ let opts = mount_info.get(&mp).cloned().unwrap_or_default();
DiskInfo {
- mount_point: d.mount_point().to_string_lossy().to_string(),
+ mount_point: mp,
total_space: total,
available_space: available,
used_space: used,
@@ -112,10 +131,14 @@ impl SystemDataProvider for SysinfoProvider {
} else {
0.0
},
+ file_system: d.file_system().to_string_lossy().to_string(),
+ mount_options: opts,
}
})
.collect();
+ let iface_ips = read_interface_ips();
+
let networks: Vec = self
.networks
.iter()
@@ -148,6 +171,7 @@ impl SystemDataProvider for SysinfoProvider {
transmitted: tx,
rx_speed,
tx_speed,
+ ip: iface_ips.get(name).cloned().unwrap_or_default(),
}
})
.collect();
@@ -162,6 +186,8 @@ impl SystemDataProvider for SysinfoProvider {
cpu_usage: p.cpu_usage() as f64,
memory: p.memory(),
user_id: p.user_id().map(|u| u.to_string()),
+ state: format!("{:?}", p.status()),
+ cmd: p.cmd().first().map(|c| c.to_string_lossy().to_string()).unwrap_or_default(),
})
.collect();
procs.sort_by(|a, b| {
@@ -169,16 +195,13 @@ impl SystemDataProvider for SysinfoProvider {
.partial_cmp(&a.cpu_usage)
.unwrap_or(std::cmp::Ordering::Equal)
});
- procs.truncate(50);
+ procs.truncate(200);
let mut max_temp = 0.0f32;
for component in &self.components {
- let label = component.label().to_lowercase();
- if label.contains("core") || label.contains("cpu") {
- if let Some(temp) = component.temperature() {
- if temp > max_temp {
- max_temp = temp;
- }
+ if let Some(temp) = component.temperature() {
+ if temp > max_temp {
+ max_temp = temp;
}
}
}
@@ -199,14 +222,41 @@ impl SystemDataProvider for SysinfoProvider {
fifteen: load.fifteen,
},
uptime: System::uptime(),
- disk_io: vec![],
- batteries: vec![],
- gpus: vec![],
+ disk_io: self.disk_io_inner(),
+ batteries: read_batteries(),
+ gpus: read_gpu_info(),
dockers: vec![],
+ sys_info: SystemInfo::default(),
}
}
fn disk_io(&self) -> Vec {
+ self.disk_io_inner()
+ }
+
+ fn system_info(&self) -> SystemInfo {
+ self.cached_sys_info.clone()
+ }
+
+ fn kill_process(&self, pid: u32) -> bool {
+ if let Some(process) = self.sys.process(Pid::from(pid as usize)) {
+ process.kill_with(Signal::Term).unwrap_or(false)
+ } else {
+ false
+ }
+ }
+
+ fn batteries(&self) -> Vec {
+ read_batteries()
+ }
+
+ fn gpu_info(&self) -> Vec {
+ read_gpu_info()
+ }
+}
+
+impl SysinfoProvider {
+ fn disk_io_inner(&self) -> Vec {
self.disks
.iter()
.map(|d| {
@@ -241,3 +291,253 @@ impl SystemDataProvider for SysinfoProvider {
.collect()
}
}
+
+// ---------------------------------------------------------------------------
+// Platform-specific helpers with graceful fallbacks
+// ---------------------------------------------------------------------------
+
+#[cfg(target_os = "linux")]
+fn read_cpu_governor(_cpu_id: usize) -> String {
+ std::fs::read_to_string(format!("/sys/devices/system/cpu/cpu{_cpu_id}/cpufreq/scaling_governor"))
+ .map(|s| s.trim().to_string())
+ .unwrap_or_default()
+}
+
+#[cfg(not(target_os = "linux"))]
+fn read_cpu_governor(_cpu_id: usize) -> String {
+ String::new()
+}
+
+#[cfg(target_os = "linux")]
+fn read_mount_options() -> HashMap {
+ let mut map = HashMap::new();
+ if let Ok(content) = std::fs::read_to_string("/proc/self/mountinfo") {
+ for line in content.lines() {
+ let parts: Vec<&str> = line.split_whitespace().collect();
+ // Format: id parent_id maj:min root mount_point options ...
+ if parts.len() >= 6 {
+ let mount_point = parts[4].to_string();
+ let opts = parts[5].to_string();
+ map.insert(mount_point, opts);
+ }
+ }
+ }
+ map
+}
+
+#[cfg(not(target_os = "linux"))]
+fn read_mount_options() -> HashMap {
+ HashMap::new()
+}
+
+#[cfg(target_os = "linux")]
+fn read_interface_ips() -> HashMap> {
+ let mut map: HashMap> = HashMap::new();
+ // Parse /proc/net/if_inet6 for IPv6 addresses
+ if let Ok(content) = std::fs::read_to_string("/proc/net/if_inet6") {
+ for line in content.lines() {
+ let parts: Vec<&str> = line.split_whitespace().collect();
+ if parts.len() >= 5 {
+ let addr_hex = parts[0];
+ let iface = parts[4].to_string();
+ if addr_hex.len() == 32 {
+ let ip: String = (0..8)
+ .map(|i| {
+ let start = i * 4;
+ let group = &addr_hex[start..start + 4];
+ let trimmed = group.trim_start_matches('0');
+ let val = u16::from_str_radix(if trimmed.is_empty() { "0" } else { trimmed }, 16).unwrap_or(0);
+ format!("{:x}", val)
+ })
+ .collect::>()
+ .join(":");
+ map.entry(iface).or_default().push(ip);
+ }
+ }
+ }
+ }
+ // Parse /proc/net/fib_trie for IPv4 (fallback)
+ map
+}
+
+#[cfg(not(target_os = "linux"))]
+fn read_interface_ips() -> HashMap> {
+ HashMap::new()
+}
+
+#[cfg(target_os = "linux")]
+fn read_batteries() -> Vec {
+ let mut batteries = Vec::new();
+ let power_supply = std::path::Path::new("/sys/class/power_supply");
+ if !power_supply.exists() {
+ return batteries;
+ }
+ if let Ok(entries) = std::fs::read_dir(power_supply) {
+ for entry in entries.flatten() {
+ let name = match entry.file_name().to_str() {
+ Some(n) if n.starts_with("BAT") => n.to_string(),
+ _ => continue,
+ };
+ let base = entry.path();
+ let capacity = std::fs::read_to_string(base.join("capacity"))
+ .ok()
+ .and_then(|s| s.trim().parse::().ok())
+ .unwrap_or(0.0);
+ let state = std::fs::read_to_string(base.join("status"))
+ .ok()
+ .map(|s| s.trim().to_string())
+ .unwrap_or_default();
+ let charge_full = std::fs::read_to_string(base.join("charge_full"))
+ .ok()
+ .and_then(|s| s.trim().parse::().ok());
+ let charge_now = std::fs::read_to_string(base.join("charge_now"))
+ .ok()
+ .and_then(|s| s.trim().parse::().ok());
+ let cycles = std::fs::read_to_string(base.join("cycle_count"))
+ .ok()
+ .and_then(|s| s.trim().parse::().ok());
+
+ // time至full/empty estimation from power
+ let power_now = std::fs::read_to_string(base.join("power_now"))
+ .ok()
+ .and_then(|s| s.trim().parse::().ok())
+ .unwrap_or(0);
+ let charge_full_design = std::fs::read_to_string(base.join("charge_full_design"))
+ .ok()
+ .and_then(|s| s.trim().parse::().ok())
+ .unwrap_or(1);
+
+ let health = if charge_full_design > 0 {
+ (charge_full.unwrap_or(1) as f32 / charge_full_design as f32) * 100.0
+ } else {
+ 100.0
+ };
+
+ let (time_to_full, time_to_empty) = if power_now != 0 && power_now.abs() > 0 {
+ if state == "Charging" {
+ let remaining = charge_full.unwrap_or(0).saturating_sub(charge_now.unwrap_or(0));
+ let secs = (remaining as f64 / power_now.abs() as f64 * 3600.0) as u64;
+ (Some(secs), None)
+ } else if state == "Discharging" {
+ let secs = (charge_now.unwrap_or(0) as f64 / power_now.abs() as f64 * 3600.0) as u64;
+ (None, Some(secs))
+ } else {
+ (None, None)
+ }
+ } else {
+ (None, None)
+ };
+
+ batteries.push(BatteryInfo {
+ name,
+ percentage: capacity,
+ state,
+ time_to_full,
+ time_to_empty,
+ health,
+ cycle_count: cycles,
+ });
+ }
+ }
+ batteries
+}
+
+#[cfg(not(target_os = "linux"))]
+fn read_batteries() -> Vec {
+ // sysinfo's battery info is limited. On macOS we'd need IOKit.
+ // On Windows we'd need WMI. For now, return empty.
+ Vec::new()
+}
+
+fn read_gpu_info() -> Vec {
+ let mut gpus = Vec::new();
+ // Try nvidia-smi first (cross-platform, works on Linux and Windows with NVIDIA drivers)
+ if let Ok(output) = std::process::Command::new("nvidia-smi")
+ .args([
+ "--query-gpu=name,utilization.gpu,memory.total,memory.used,temperature.gpu",
+ "--format=csv,noheader,nounits",
+ ])
+ .output()
+ {
+ if output.status.success() {
+ let stdout = String::from_utf8_lossy(&output.stdout);
+ for line in stdout.lines() {
+ let parts: Vec<&str> = line.split(',').map(|s| s.trim()).collect();
+ if parts.len() >= 5 {
+ let name = parts[0].to_string();
+ let usage = parts[1].parse::().unwrap_or(0.0);
+ let mem_total = parts[2].parse::().unwrap_or(0) * 1024 * 1024;
+ let mem_used = parts[3].parse::().unwrap_or(0) * 1024 * 1024;
+ let temp = parts[4].parse::().unwrap_or(0.0);
+ gpus.push(GpuInfo {
+ name,
+ usage,
+ temperature: temp,
+ memory_total: mem_total,
+ memory_used: mem_used,
+ });
+ }
+ }
+ }
+ }
+
+ // Fallback: try reading from /sys/class/drm/ on Linux
+ #[cfg(target_os = "linux")]
+ if gpus.is_empty() {
+ if let Ok(entries) = std::fs::read_dir("/sys/class/drm/") {
+ for entry in entries.flatten() {
+ let fname = entry.file_name().to_string_lossy().to_string();
+ if fname.starts_with("card") && !fname.contains('-') {
+ let base = entry.path();
+ let dev = base.join("device");
+ let gpu_name = std::fs::read_to_string(dev.join("product_name")).ok()
+ .map(|s| s.trim().to_string())
+ .unwrap_or_else(|| fname.clone());
+ let mem_total = std::fs::read_to_string(dev.join("mem_info_vram_total")).ok()
+ .and_then(|s| s.trim().parse::().ok())
+ .unwrap_or(0);
+ let mem_used = std::fs::read_to_string(dev.join("mem_info_vram_used")).ok()
+ .and_then(|s| s.trim().parse::().ok())
+ .unwrap_or(0);
+ let temp = find_hwmon_temp(&base.join("device"), "gpu").unwrap_or(0.0);
+ gpus.push(GpuInfo {
+ name: gpu_name,
+ usage: 0.0,
+ temperature: temp,
+ memory_total: mem_total,
+ memory_used: mem_used,
+ });
+ }
+ }
+ }
+ }
+
+ gpus
+}
+
+#[cfg(target_os = "linux")]
+fn find_hwmon_temp(device_path: &std::path::Path, label_filter: &str) -> Option {
+ let hwmon = device_path.join("hwmon");
+ if hwmon.exists() {
+ if let Ok(entries) = std::fs::read_dir(&hwmon) {
+ for entry in entries.flatten() {
+ let hwmon_dir = entry.path();
+ if let Ok(labels) = std::fs::read_to_string(hwmon_dir.join("temp1_label")) {
+ if labels.trim().to_lowercase().contains(label_filter) {
+ if let Ok(input) = std::fs::read_to_string(hwmon_dir.join("temp1_input")) {
+ if let Ok(millideg) = input.trim().parse::() {
+ return Some(millideg / 1000.0);
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ None
+}
+
+#[cfg(not(target_os = "linux"))]
+fn find_hwmon_temp(_device_path: &std::path::Path, _label_filter: &str) -> Option {
+ None
+}
diff --git a/crates/xtop-core/src/infrastructure/theme_loader.rs b/crates/xtop-core/src/infrastructure/theme_loader.rs
index 492d4bc..b769948 100644
--- a/crates/xtop-core/src/infrastructure/theme_loader.rs
+++ b/crates/xtop-core/src/infrastructure/theme_loader.rs
@@ -15,11 +15,11 @@ fn make_theme(name: &str, colors: [&str; 16]) -> Theme {
fn default_theme() -> Theme {
make_theme(
- "miami",
+ "x",
[
- "#000000", "#FF4C8B", "#7FFFD4", "#FFD84C", "#00FFA8", "#D36CFF", "#47CFFF",
- "#f7f1ff", "#69676c", "#FF4C8B", "#7FFFD4", "#FFD84C", "#00FFA8", "#D36CFF",
- "#47CFFF", "#f7f1ff",
+ "#050505", "#fc618d", "#7bd88f", "#fce566", "#fd9353", "#948ae3", "#5ad4e6",
+ "#f7f1ff", "#0f0f0f", "#fc618d", "#7bd88f", "#fce566", "#fd9353", "#948ae3",
+ "#5ad4e6", "#f7f1ff",
],
)
}
@@ -118,8 +118,8 @@ mod tests {
#[test]
fn test_default_theme() {
let t = default_theme();
- assert_eq!(t.name, "miami");
- assert_eq!(t.bg(), &[0, 0, 0]);
+ assert_eq!(t.name, "x");
+ assert_eq!(t.bg(), &[5, 5, 5]);
}
#[test]
diff --git a/crates/xtop-tui/src/render/header.rs b/crates/xtop-tui/src/render/header.rs
index 35d6db1..959682f 100644
--- a/crates/xtop-tui/src/render/header.rs
+++ b/crates/xtop-tui/src/render/header.rs
@@ -24,10 +24,13 @@ pub fn render(f: &mut Frame, state: &AppState, area: Rect) {
extras.push_str(" [/] Search");
}
+ let host = &state.sys_info.hostname;
+
let wide = area.width >= 80;
let text: Vec = if wide {
vec![Line::from(format!(
- "xtop | {} | {} | Uptime: {} | Load: {:.2} {:.2} {:.2}{}",
+ "{} | {} | {} | Uptime: {} | Load: {:.2} {:.2} {:.2}{}",
+ if host.is_empty() { "xtop".to_string() } else { host.clone() },
state.current_theme.name,
mode_str,
format_uptime(uptime),
@@ -37,10 +40,15 @@ pub fn render(f: &mut Frame, state: &AppState, area: Rect) {
extras,
))]
} else {
+ let host_part = if host.is_empty() {
+ mode_str.to_string()
+ } else {
+ format!("{} | {}", host, mode_str)
+ };
vec![
Line::from(format!(
"{} | Uptime: {}",
- mode_str,
+ host_part,
format_uptime(uptime),
)),
Line::from(format!(
diff --git a/crates/xtop-tui/src/render/processes.rs b/crates/xtop-tui/src/render/processes.rs
index 0bf6e77..23cd5e5 100644
--- a/crates/xtop-tui/src/render/processes.rs
+++ b/crates/xtop-tui/src/render/processes.rs
@@ -4,12 +4,15 @@ use ratatui::symbols::border;
use ratatui::widgets::{Block, Borders, Cell, Row, Table};
use ratatui::Frame;
use xtop_core::application::state::AppState;
+use xtop_core::domain::metrics::ProcessInfo;
pub fn render(f: &mut Frame, state: &AppState, area: Rect) {
let fg = to_color(state.current_theme.fg());
let bg = to_color(state.current_theme.bg());
+ let dim_bg = to_color(&state.current_theme.palette[8]);
+ let accent = to_color(&state.current_theme.palette[6]);
- let mut title = "Processes".to_string();
+ let mut title = format!("Processes (sort: {})", state.process_sort.label());
if !state.search_query.is_empty() {
title = format!("Processes (filter: {})", state.search_query);
}
@@ -24,24 +27,46 @@ pub fn render(f: &mut Frame, state: &AppState, area: Rect) {
let snap = state.snapshot();
- let iter: Box> =
- if state.search_query.is_empty() {
- Box::new(snap.processes.iter())
- } else {
- let q = state.search_query.to_lowercase();
- Box::new(
- snap.processes
- .iter()
- .filter(move |p| p.name.to_lowercase().contains(&q)),
- )
- };
+ let iter: Box> = if state.search_query.is_empty() {
+ Box::new(snap.processes.iter())
+ } else {
+ let q = state.search_query.to_lowercase();
+ Box::new(
+ snap.processes
+ .iter()
+ .filter(move |p| p.name.to_lowercase().contains(&q)),
+ )
+ };
- let dim_bg = to_color(&state.current_theme.palette[8]);
+ let mut items: Vec<&ProcessInfo> = iter.collect();
+
+ // Sort
+ match state.process_sort {
+ xtop_core::application::state::ProcessSortBy::Cpu => {
+ items.sort_by(|a, b| b.cpu_usage.partial_cmp(&a.cpu_usage).unwrap_or(std::cmp::Ordering::Equal));
+ }
+ xtop_core::application::state::ProcessSortBy::Memory => {
+ items.sort_by_key(|b| std::cmp::Reverse(b.memory));
+ }
+ xtop_core::application::state::ProcessSortBy::Pid => {
+ items.sort_by_key(|a| a.pid);
+ }
+ xtop_core::application::state::ProcessSortBy::Name => {
+ items.sort_by_key(|a| a.name.to_lowercase());
+ }
+ }
- let rows: Vec = iter
+ let rows: Vec = items
+ .into_iter()
.enumerate()
.map(|(row_idx, p)| {
- let style = if row_idx % 2 == 0 {
+ let is_selected = state.process_selected == Some(row_idx);
+ let style = if is_selected {
+ Style::default()
+ .fg(bg)
+ .bg(accent)
+ .add_modifier(Modifier::BOLD)
+ } else if row_idx % 2 == 0 {
Style::default().fg(fg)
} else {
Style::default().fg(fg).bg(dim_bg)
@@ -70,12 +95,17 @@ pub fn render(f: &mut Frame, state: &AppState, area: Rect) {
Row::new(vec!["PID", "Name", "CPU%", "Mem", "User"])
.style(
Style::default()
- .fg(to_color(&state.current_theme.palette[6]))
+ .fg(accent)
.add_modifier(Modifier::BOLD),
)
.bottom_margin(1),
)
- .row_highlight_style(Style::default().add_modifier(Modifier::REVERSED));
+ .row_highlight_style(
+ Style::default()
+ .fg(bg)
+ .bg(accent)
+ .add_modifier(Modifier::BOLD),
+ );
f.render_widget(table, inner);
}
diff --git a/crates/xtop-tui/src/render/storage.rs b/crates/xtop-tui/src/render/storage.rs
index 45c9f5e..182a7ac 100644
--- a/crates/xtop-tui/src/render/storage.rs
+++ b/crates/xtop-tui/src/render/storage.rs
@@ -36,9 +36,11 @@ pub fn render(f: &mut Frame, state: &AppState, area: Rect) {
break;
}
let color_idx = gauge_gradient(disk.percent, state.alerts.disk_high);
+ let fs_type = &disk.file_system;
let label = format!(
- "{} Tot: {} Use: {} Free: {}",
+ "{} [{}] Tot: {} Use: {} Free: {}",
disk.mount_point,
+ if fs_type.is_empty() { "?" } else { fs_type },
format_bytes(disk.total_space),
format_bytes(disk.used_space),
format_bytes(disk.available_space),
diff --git a/docs/customization.md b/docs/customization.md
index fa3faaf..14f74fb 100644
--- a/docs/customization.md
+++ b/docs/customization.md
@@ -1,25 +1,53 @@
-# Customization
+Customization Guide
-xtop supports runtime customization of color themes and layout modes via external JSONC files. This guide explains how to create and manage your own themes and layouts.
+xtop supports runtime customization of color themes and layout modes via external JSONC files. This guide explains how to create and manage your own themes and layouts.
---
-## Themes
+Table of Contents
+
+
-### Location
+---
-Place theme files in:
+Themes
-```
-~/.config/xtop/themes/*.jsonc
+Location
+
+Place theme files in:
+
+~/.config/xtop/themes/*.jsonc
~/.config/xtop/themes/*.json
-```
+
-The directory is created automatically when you save your config on quit.
+The directory is created automatically when you save your config on quit.
-### Format
+
-Each theme file defines a name and a 16-entry color palette. Colors are hex strings with optional `#` prefix. Comments (`//` and `/* */`) are supported.
+Each theme file defines a name and a 16-entry palette. Colors are hex strings with an optional # prefix. Comments (// and /* */) are supported in JSONC files.
```jsonc
{
@@ -32,7 +60,7 @@ Each theme file defines a name and a 16-entry color palette. Colors are hex stri
"#e5c07b", // 3: yellow (Swap gauge)
"#d19a66", // 4: orange (Storage, Network TX)
"#c678dd", // 5: purple (GPU)
- "#56b6c2", // 6: cyan (table headers)
+ "#56b6c2", // 6: cyan (accents, table headers)
"#abb2bf", // 7: foreground / text
"#3e4451", // 8: bright black (separators)
"#e06c75", // 9: bright red
@@ -46,50 +74,149 @@ Each theme file defines a name and a 16-entry color palette. Colors are hex stri
}
```
-### Starter themes
-
-The repository includes 12 ready-to-use themes you can copy as a starting point:
+Palette Reference
+
+
+
+
+ | Index |
+ Usage |
+ Example |
+
+
+
+
+ 0 |
+ Background |
+ #1a1b1c |
+
+
+ 1 |
+ Red / Alert |
+ #e06c75 |
+
+
+ 2 |
+ Green (RAM gauge) |
+ #98c379 |
+
+
+ 3 |
+ Yellow (Swap gauge) |
+ #e5c07b |
+
+
+ 4 |
+ Orange (Storage, Network TX) |
+ #d19a66 |
+
+
+ 5 |
+ Purple (GPU) |
+ #c678dd |
+
+
+ 6 |
+ Cyan (Accents, table headers) |
+ #56b6c2 |
+
+
+ 7 |
+ Foreground / Text |
+ #abb2bf |
+
+
+ 8 |
+ Bright black (Separators) |
+ #3e4451 |
+
+
+ 9 |
+ Bright red |
+ #e06c75 |
+
+
+ 10 |
+ Bright green |
+ #98c379 |
+
+
+ 11 |
+ Bright yellow |
+ #e5c07b |
+
+
+ 12 |
+ Bright orange |
+ #d19a66 |
+
+
+ 13 |
+ Bright purple |
+ #c678dd |
+
+
+ 14 |
+ Bright cyan |
+ #56b6c2 |
+
+
+ 15 |
+ Bright white |
+ #abb2bf |
+
+
+
+
+Starter Themes
+
+The built-in default theme is x (almost-black background, purple-pink accents). It is compiled into the binary and always available.
+
+When you run xtop for the first time, it automatically creates ~/.config/xtop/themes/ with all extra themes embedded in the binary. No manual copy is needed.
+
+If you want to restore them later, copy from the repository:
```bash
-# Copy all starter themes
cp -r assets/themes/* ~/.config/xtop/themes/
```
-Available starter themes: `x`, `madrid`, `lahabana`, `seul`, `paris`, `tokio`, `oslo`, `helsinki`, `berlin`, `london`, `praha`, `bogota`.
+Available themes: x, madrid, lahabana, paris, tokio, oslo, helsinki, berlin, london, praha, bogota, miami.
-The built-in default theme is `miami` (black background, neon accents). It is compiled into the binary and always available.
+All theme definitions are documented in colors.md.
-### Loading order
+Loading Order
-1. Built-in `miami` theme (always available)
-2. Themes from `~/.config/xtop/themes/` loaded alphabetically
-3. If a custom theme has the same name as `miami`, it **replaces** the built-in
+
+ - Built-in
miami theme (always available)
+ - Themes from
~/.config/xtop/themes/ loaded alphabetically
+ - If a custom theme has the same name as
miami, it replaces the built-in
+
-### Tips
+Tips
-- Try the grayscale themes (`london`, `berlin`) as a base and add your own accent colors
-- Palette entries 8–15 (the "bright" variants) are used for separators and secondary text
-- Index 0 is the background, index 7 is the primary foreground — keep them readable together
+
+ - Try the grayscale themes (
london, berlin) as a base and add your own accent colors.
+ - Palette entries 8–15 (the "bright" variants) are used for separators and secondary text.
+ - Index 0 is the background, index 7 is the primary foreground — keep them readable together.
+
---
-## Layouts
+Layouts
-### Location
+Location
-Place layout files in:
+Place layout files in:
-```
-~/.config/xtop/layouts/*.jsonc
+~/.config/xtop/layouts/*.jsonc
~/.config/xtop/layouts/*.json
-```
+
-### Format
+
-A layout is a recursive tree of **splits** and **widgets**:
+A layout is a recursive tree of splits and widgets:
-```
-LayoutDef
+LayoutDef
├── name: string
└── root: Area
├── direction: "horizontal" | "vertical"
@@ -97,31 +224,87 @@ LayoutDef
└── areas: [Area, ...]
├── Area with "widget" → leaf node (renders a widget)
└── Area with "direction" → nested split
-```
-
-**Size constraints:**
-
-| Syntax | Meaning |
-|--------|---------|
-| `"*"` or omitted | Fill remaining space |
-| `3` (number) | Fixed `n` rows/columns |
-| `"45%"` | Percentage of parent |
-
-**Available widget names:**
-
-| Widget | Description |
-|--------|-------------|
-| `header` | System info bar (uptime, load, keys) |
-| `cpu` | Per-core CPU usage gauges |
-| `memory` | RAM + Swap gauges + RAM history chart |
-| `storage` | Disk usage gauges per mount point |
-| `network` | Network RX/TX totals and speeds |
-| `processes` | Process table with search filter |
-| `disk_io` | Disk read/write speeds |
-| `battery` | Battery charge gauges |
-| `gpu` | GPU usage gauges |
-
-### Example: custom layout
+
+
+Size Constraints
+
+
+
+
+ | Syntax |
+ Meaning |
+
+
+
+
+ "*" or omitted |
+ Fill remaining space |
+
+
+ 3 (number) |
+ Fixed n rows/columns |
+
+
+ "45%" |
+ Percentage of parent |
+
+
+
+
+
+
+
+
+
+ | Widget |
+ Description |
+
+
+
+
+ header |
+ System info bar (uptime, load, keys) |
+
+
+ cpu |
+ Per-core CPU usage gauges |
+
+
+ memory |
+ RAM + Swap gauges + RAM history chart |
+
+
+ storage |
+ Disk usage gauges per mount point |
+
+
+ network |
+ Network RX/TX totals and speeds |
+
+
+ processes |
+ Process table with search filter |
+
+
+ disk_io |
+ Disk read/write speeds |
+
+
+ battery |
+ Battery charge gauges |
+
+
+ gpu |
+ GPU usage gauges |
+
+
+
+
+Examples
+
+Simple custom layout
+
+A minimal three-row layout: header, CPU, and processes.
```jsonc
{
@@ -138,7 +321,9 @@ LayoutDef
}
```
-### Example: complex nested layout
+Complex nested layout
+
+A full dashboard with a horizontal split in the middle section:
```jsonc
{
@@ -168,29 +353,39 @@ LayoutDef
}
```
-### Starter layouts
+Starter Layouts
+
+The 7 built-in layouts are embedded in the binary and written to ~/.config/xtop/layouts/ on first run.
-The repository includes the 7 built-in layouts as JSONC files:
+To restore them later, copy from the repository:
```bash
cp -r assets/layouts/* ~/.config/xtop/layouts/
```
-Available: `dashboard`, `vertical`, `horizontal`, `cpu_focus`, `memory_focus`, `network_focus`, `process_focus`.
+Available layouts: dashboard, vertical, horizontal, cpu_focus, memory_focus, network_focus, process_focus.
-You can edit these directly or use them as templates.
+Cycling Order
-### Cycling order
+
+ - Built-in layouts (Dashboard → Vertical → Horizontal → CPU Focus → Memory Focus → Network Focus → Process Focus)
+ - Custom layouts from
~/.config/xtop/layouts/ (in filesystem order)
+ - Wraps back to Dashboard
+
-1. Built-in layouts (Dashboard → Vertical → Horizontal → CPU Focus → Memory Focus → Network Focus → Process Focus)
-2. Custom layouts from `~/.config/xtop/layouts/` (in filesystem order)
-3. Wraps back to Dashboard
+Press l to cycle forward through all available layouts.
-Press `l` to cycle forward through all available layouts.
+Notes
-### Notes
+
+ - If a widget name in your layout doesn't match any available widget, that area is silently skipped.
+ - Nested splits can be arbitrarily deep, but very deep nesting may overflow small terminals.
+ - The terminal must be at least 40×8 for any layout to render; smaller terminals show a warning.
+ - Very small terminals (under 60×14) fall back to a minimal hardcoded layout (CPU + Memory gauges + process list).
+
+
+---
-- If a widget name in your layout doesn't match any available widget, that area is silently skipped
-- Nested splits can be arbitrarily deep, but very deep nesting may overflow small terminals
-- The terminal must be at least 40×8 for any layout to render; smaller terminals show a warning
-- Very small terminals (under 60×14) fall back to a minimal hardcoded layout (CPU + Memory gauges + process list)
+
+ ← Back to README
+
From 9219ae3b0fe17f092900fc9b9daa35b24553a700 Mon Sep 17 00:00:00 2001
From: xscriptor <“preciado.oscar.osorio@gmail.com”>
Date: Thu, 18 Jun 2026 22:33:07 +0200
Subject: [PATCH 5/6] update support for plugins and palette
---
.github/workflows/ci.yml | 19 +-
CHANGELOG.md | 130 ++-
Cargo.lock | 131 ++-
Cargo.toml | 7 +-
README.md | 350 ++------
crates/xtop-cli/Cargo.toml | 9 +
crates/xtop-cli/src/main.rs | 505 ++++++++++-
crates/xtop-cli/src/mcp.rs | 77 ++
crates/xtop-core/Cargo.toml | 1 +
crates/xtop-core/src/application/mod.rs | 1 +
.../src/application/plugin_manager.rs | 188 +++++
crates/xtop-core/src/application/state.rs | 107 ++-
crates/xtop-core/src/domain/keybinding.rs | 2 +-
crates/xtop-core/src/domain/metrics.rs | 38 +
crates/xtop-core/src/domain/mod.rs | 1 +
crates/xtop-core/src/domain/plugin.rs | 216 +++++
crates/xtop-core/src/domain/system_info.rs | 8 +
.../src/infrastructure/battery_provider.rs | 26 -
.../src/infrastructure/composite_provider.rs | 122 +++
crates/xtop-core/src/infrastructure/config.rs | 2 +-
.../src/infrastructure/docker_provider.rs | 26 -
.../src/infrastructure/gpu_provider.rs | 26 -
crates/xtop-core/src/infrastructure/mod.rs | 4 +-
.../src/infrastructure/sysinfo_provider.rs | 99 ++-
crates/xtop-tui/src/render/layout_engine.rs | 51 +-
crates/xtop-tui/src/render/mod.rs | 21 +-
docs/configuration.md | 89 ++
docs/customization.md | 34 +-
docs/features.md | 112 +++
docs/installation.md | 95 +++
docs/plugin.md | 282 +++++++
docs/usage.md | 180 ++++
plugins/xtop-plugin-sentinel/Cargo.toml | 14 +
plugins/xtop-plugin-sentinel/README.md | 372 ++++++++
plugins/xtop-plugin-sentinel/src/alert.rs | 45 +
plugins/xtop-plugin-sentinel/src/lib.rs | 795 ++++++++++++++++++
plugins/xtop-plugin-sentinel/src/mcp.rs | 317 +++++++
37 files changed, 4012 insertions(+), 490 deletions(-)
create mode 100644 crates/xtop-cli/src/mcp.rs
create mode 100644 crates/xtop-core/src/application/plugin_manager.rs
create mode 100644 crates/xtop-core/src/domain/plugin.rs
delete mode 100644 crates/xtop-core/src/infrastructure/battery_provider.rs
create mode 100644 crates/xtop-core/src/infrastructure/composite_provider.rs
delete mode 100644 crates/xtop-core/src/infrastructure/docker_provider.rs
delete mode 100644 crates/xtop-core/src/infrastructure/gpu_provider.rs
create mode 100644 docs/configuration.md
create mode 100644 docs/features.md
create mode 100644 docs/installation.md
create mode 100644 docs/plugin.md
create mode 100644 docs/usage.md
create mode 100644 plugins/xtop-plugin-sentinel/Cargo.toml
create mode 100644 plugins/xtop-plugin-sentinel/README.md
create mode 100644 plugins/xtop-plugin-sentinel/src/alert.rs
create mode 100644 plugins/xtop-plugin-sentinel/src/lib.rs
create mode 100644 plugins/xtop-plugin-sentinel/src/mcp.rs
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 9401cef..3b18485 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -11,22 +11,13 @@ env:
jobs:
check:
+ name: Check
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- - uses: dtolnay/rust-toolchain@stable
+ - uses: actions-rust-lang/setup-rust-toolchain@v1
with:
components: clippy, rustfmt
- - uses: Swatinem/rust-cache@v2
-
- - name: Check formatting
- run: cargo fmt --check
-
- - name: Clippy
- run: cargo clippy --all -- -D warnings
-
- - name: Build
- run: cargo build --all
-
- - name: Test
- run: cargo test --all
+ - run: cargo fmt --check
+ - run: cargo clippy --all-targets -- -D warnings
+ - run: cargo test
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 83df2a6..70ac144 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,84 +1,134 @@
# Changelog
+## [0.2.1] - 2026-06-18
+
+### Config: Persistencia de Layouts Personalizados
+- `Config` ahora tiene campo `layout_name` que almacena el nombre del layout seleccionado (soporta layouts mas alla de los 7 built-in `LayoutMode`)
+- En configuraciones existentes, `layout_name` es opcional (`#[serde(default)]`) y se usa como respaldo `layout_mode`
+- `save_config` guarda el nombre del layout actual por su indice en `layout_defs`, permitiendo restaurar layouts personalizados al reiniciar
+- `AppState::new` prioriza `config.layout_name` si no es vacio, con fallback a `layout_index_from_mode`
+- Config se guarda automaticamente al seleccionar tema o layout desde la paleta de comandos
+
+### Paleta de Comandos: Soporte macOS
+- Agregada ruta directa para `ctrl+p` en el bucle de eventos, independiente del sistema de keybindings (soluciona problemas donde crossterm reporta la tecla con distinto casing o el keybinding no se resuelve)
+- `key_event_to_str` aplica `to_ascii_lowercase()` al caracter cuando Ctrl esta activo, normalizando `"ctrl+P"` a `"ctrl+p"`
+- Agregado `"ctrl+P"` como variante alternativa en el keybinding por defecto
+- Debug output de teclas en compilaciones debug (`cargo build` sin `--release`) para diagnosticar problemas: `[key] 'ctrl+p'`
+
+### Plugins: Seguridad y Arquitectura
+- `PluginContext` ahora verifica capabilities antes de ejecutar acciones sensibles: `kill_process`, `set_alert_thresholds`, `set_theme_by_name`, `set_layout_by_name`, `set_update_interval` requieren `KillProcesses` o `ModifyConfig` segun corresponda
+- `PluginCapability::Custom` migrado de `&'static str` a `String` para flexibilidad
+- `PluginManifest` usa `String` en vez de `&'static str`, permitiendo plugins que generen metadata dinamicamente
+- `#[non_exhaustive]` agregado a `PluginCapability` para evolucion segura del enum
+
+### PluginManager: Robustez
+- `with_plugin_manager_mut()` reemplaza el patron inseguro `take()` + `Some()` que podia perder el manager si una ruta de error no lo restauraba. Todos los callers migrados: `on_tick`, manejo de teclas, shutdown, y MCP server
+- `register()` ahora devuelve `Result<(), PluginError>` en vez de `Result<(), String>`, consistente con el resto del sistema
+- Metodo `build_context()` elimina la duplicacion de construccion de `PluginContext` en 5 metodos distintos
+- `data_dir` del plugin ahora apunta al directorio del plugin (`plugins//`), no a `plugins//config.json`
+
+### Capabilities: Validacion Real
+- `collect_widgets()` solo acepta widgets si el plugin declara `RenderWidgets`
+- `collect_data_providers()` solo acepta providers si el plugin declara `ReadSystemInfo`
+- `PluginContext` inyecta las capabilities declaradas y verifica en cada metodo sensible
+
+### MCP Server
+- Migrado de `take()`/`Some()` a `with_plugin_manager_mut()` para seguridad
+- Eliminado doble `tick_all()` innecesario en el handler de herramientas
+
+### Sistema de Providers
+- `SystemDataProvider` ahora tiene metodo `add_extras()` con default no-op, eliminando la necesidad de `downcast_mut::()`
+- `kill_process()` verifica que el UID del proceso coincida con el usuario actual antes de enviar SIGTERM
+- Limite de procesos configurable via `SysinfoProvider::max_processes` (default 200)
+- Eliminados `NoopBatteryProvider`, `NoopGpuProvider`, `NoopDockerProvider` — codigo muerto no utilizado
+
+### Sentinel: Calidad de Datos
+- Migracion completa de construccion manual de JSON (`format!` con strings escapados) a `serde_json::json!()` — elimina riesgo de inyeccion JSON y corrupcion por caracteres especiales
+
+### TUI
+- `LayoutConstraint::Fill` mapeado correctamente a `ratatui::Constraint::Fill(1)` en vez de `Min(0)`
+
+### Infraestructura
+- `config_dir()` centralizada en `xtop_core::infrastructure::config::config_dir()` — eliminada duplicacion en 5 modulos
+- CI workflow creado en `.github/workflows/ci.yml`: `cargo fmt --check`, `cargo clippy -- -D warnings`, `cargo test`
+
## [0.2.0] - 2026-06-03
-### ♻️ Refactorización Total del Proyecto
-- Migrado a **workspace multi-crate**: `xtop-core`, `xtop-tui`, `xtop-cli`
+### Refactorizacion Total del Proyecto
+- Migrado a workspace multi-crate: `xtop-core`, `xtop-tui`, `xtop-cli`
- Eliminado el monolito `src/` — ahora cada capa vive en su propio crate
-- Eliminadas **5 dependencias muertas**: `serde`, `serde_json`, `clap`, `chrono`, `tokio` (se redujo de ~84 a ~55 crates)
-- Eliminado **código muerto**: `InputMode`, `show_help`, `swap_history`, `process_table_state`, `graph_colors()`
+- Eliminadas 5 dependencias muertas: `serde`, `serde_json`, `clap`, `chrono`, `tokio` (se redujo de ~84 a ~55 crates)
+- Eliminado codigo muerto: `InputMode`, `show_help`, `swap_history`, `process_table_state`, `graph_colors()`
- Eliminados todos los `#[allow(dead_code)]`
-### 🏗️ Nueva Arquitectura Hexagonal
-- **Capa de Dominio** (`xtop-core/domain/`): modelos de datos puros + trait `SystemDataProvider`
-- **Capa de Aplicación** (`xtop-core/application/`): `AppState`, `MetricsHistory` (con `VecDeque`), `LayoutMode`, `EffectiveLayout`
-- **Capa de Infraestructura** (`xtop-core/infrastructure/`): `SysinfoProvider`, `theme_loader`, `config`, providers stub
-- **Capa de Presentación** (`xtop-tui/`): terminal, render widgets separados, format helpers
-- **Binary** (`xtop-cli/`): entry point con inyección de dependencias
-
-### 📐 Layout Responsive
-- `detect_effective_layout(width, height, mode)` adapta el layout automáticamente:
- - **Dashboard** (>100×30): layout completo 2-columnas
- - **Compact** (>80×24): más compacto
+### Nueva Arquitectura Hexagonal
+- Capa de Dominio (`xtop-core/domain/`): modelos de datos puros + trait `SystemDataProvider`
+- Capa de Aplicacion (`xtop-core/application/`): `AppState`, `MetricsHistory` (con `VecDeque`), `LayoutMode`, `EffectiveLayout`
+- Capa de Infraestructura (`xtop-core/infrastructure/`): `SysinfoProvider`, `theme_loader`, `config`, providers stub
+- Capa de Presentacion (`xtop-tui/`): terminal, render widgets separados, format helpers
+- Binary (`xtop-cli/`): entry point con inyeccion de dependencias
+
+### Layout Responsive
+- `detect_effective_layout(width, height, mode)` adapta el layout automaticamente:
+ - **Dashboard** (>100x30): layout completo 2-columnas
+ - **Compact** (>80x24): mas compacto
- **Vertical** (<80): todo apilado
- **Minimal** (<60 ancho o <18 alto): solo CPU + Mem + procesos
- - **Too Small** (<40×8): mensaje de advertencia
+ - **Too Small** (<40x8): mensaje de advertencia
-### 🆕 Nuevos Layouts (7 modos, ciclo con `l`)
-| Modo | Descripción |
+### Nuevos Layouts (7 modos, ciclo con `l`)
+| Modo | Descripcion |
|------|-------------|
-| Dashboard | Default, 2-columnas con gráficos |
+| Dashboard | Default, 2-columnas con graficos |
| Vertical | Apilado, para terminales estrechas |
| Horizontal | 4 columnas: CPU/Mem/Storage/Network |
| CPU Focus | CPU grande + procesos |
| Memory Focus | Memoria grande con chart + procesos |
| Network Focus | Network + Disk I/O lado a lado + procesos |
-| Process Focus | Stats pequeños + procesos maximizados |
+| Process Focus | Stats pequenos + procesos maximizados |
-### 🆕 Full Screen (`f` / `F`)
+### Full Screen (`f` / `F`)
- `f` activa/desactiva modo fullscreen
-- `F` cicla entre widgets (CPU → Memory → Storage → Network → Processes → Disk I/O → GPU → Battery → salir)
+- `F` cicla entre widgets (CPU, Memory, Storage, Network, Processes, Disk I/O, GPU, Battery, salir)
- Widget seleccionado ocupa toda la terminal (menos header)
-### 🆕 Búsqueda de Procesos (`/`)
+### Busqueda de Procesos (`/`)
- Filtrado en tiempo real por nombre de proceso
- `Enter` confirma el filtro, `Esc` cancela, `Backspace` borra
- Overlay centrado con indicador `/query_`
-### 🆕 Ayuda en Pantalla (`?`)
+### Ayuda en Pantalla (`?`)
- Muestra todas las keybindings disponibles
- Cierra con `Esc` o `?` otra vez
-### 📊 Nuevas Métricas
+### Nuevas Metricas
- **Disk I/O**: velocidad de lectura/escritura por disco (bytes/s) con widget dedicado
- **Per-interface Network**: RX/TX y velocidad por interfaz de red
-- **GPU** (stub): `NoopGpuProvider` — preparado para NVIDIA/AMD
-- **Battery** (stub): `NoopBatteryProvider` — preparado para estado de batería
-- **Docker** (stub): `NoopDockerProvider` — preparado para contenedores
+- **GPU**, **Battery**, **Docker** stubs preparados para implementacion futura
-### ⚠️ Alertas por Threshold
+### Alertas por Threshold
- **CPU > 90%**: color cambia a rojo
-- **Memoria > 90%**: color rojo + icono ⚠ en el título
+- **Memoria > 90%**: color rojo + icono de advertencia en el titulo
- Thresholds configurables en `AlertThresholds` (cpu_high, mem_high, disk_high)
-### 🧹 Mejoras de Código
+### Mejoras de Codigo
- `Vec` + `remove(0)` reemplazado por `VecDeque` con `pop_front()` (O(1))
-- Helper `format_bytes()` elimina repetición de `1024.0 / 1024.0 / 1024.0`
+- Helper `format_bytes()` elimina repeticion de `1024.0 / 1024.0 / 1024.0`
- Helper `format_uptime()` para formato legible de tiempo activo
-- `MetricsHistory::set_max_points()` para configurar puntos del histórico
+- `MetricsHistory::set_max_points()` para configurar puntos del historico
-### ⚙️ Configuración Persistente
+### Configuracion Persistente
- `~/.config/xtop/config.json`: guarda tema, layout, intervalo, history_points, alerts
- `~/.config/xtop/themes/*.json`: temas personalizados por el usuario
-- Guardado automático al salir con `q`
+- Guardado automatico al salir con `q`
- Temas built-in (13) se fusionan con temas personalizados
-### 🧪 Tests
-- **39 tests unitarios** (de 0): layout detection, history, themes, format helpers, config
+### Tests
+- 39 tests unitarios (de 0): layout detection, history, themes, format helpers, config
- CI workflow `.github/workflows/ci.yml`: check, fmt, clippy, test, build
-### 🔑 Keybindings Completos
-| Tecla | Acción |
+### Keybindings Completos
+| Tecla | Accion |
|-------|--------|
| `q` | Salir (guarda config) |
| `?` | Ayuda |
@@ -86,4 +136,4 @@
| `l` | Siguiente layout |
| `f` / `F` | Toggle fullscreen / ciclar widget |
| `/` | Buscar procesos |
-| `Esc` | Cancelar búsqueda / cerrar ayuda |
+| `Esc` | Cancelar busqueda / cerrar ayuda |
diff --git a/Cargo.lock b/Cargo.lock
index c136937..e6b6971 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -2,6 +2,15 @@
# It is not intended for manual editing.
version = 4
+[[package]]
+name = "aho-corasick"
+version = "1.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301"
+dependencies = [
+ "memchr",
+]
+
[[package]]
name = "allocator-api2"
version = "0.2.21"
@@ -163,6 +172,12 @@ dependencies = [
"foldhash",
]
+[[package]]
+name = "hashbrown"
+version = "0.17.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
+
[[package]]
name = "heck"
version = "0.5.0"
@@ -175,6 +190,16 @@ version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39"
+[[package]]
+name = "indexmap"
+version = "2.14.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9"
+dependencies = [
+ "equivalent",
+ "hashbrown 0.17.1",
+]
+
[[package]]
name = "indoc"
version = "2.0.7"
@@ -245,7 +270,7 @@ version = "0.12.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38"
dependencies = [
- "hashbrown",
+ "hashbrown 0.15.5",
]
[[package]]
@@ -409,6 +434,35 @@ dependencies = [
"bitflags",
]
+[[package]]
+name = "regex"
+version = "1.12.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f1292b7759ae1cb9ec195452d1390a074f0cd8541ab7a5a8c31cd6db45d4a6ba"
+dependencies = [
+ "aho-corasick",
+ "memchr",
+ "regex-automata",
+ "regex-syntax",
+]
+
+[[package]]
+name = "regex-automata"
+version = "0.4.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f"
+dependencies = [
+ "aho-corasick",
+ "memchr",
+ "regex-syntax",
+]
+
+[[package]]
+name = "regex-syntax"
+version = "0.8.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
+
[[package]]
name = "rustix"
version = "0.38.44"
@@ -483,6 +537,15 @@ dependencies = [
"zmij",
]
+[[package]]
+name = "serde_spanned"
+version = "0.6.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3"
+dependencies = [
+ "serde",
+]
+
[[package]]
name = "signal-hook"
version = "0.3.18"
@@ -580,6 +643,47 @@ dependencies = [
"windows",
]
+[[package]]
+name = "toml"
+version = "0.8.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362"
+dependencies = [
+ "serde",
+ "serde_spanned",
+ "toml_datetime",
+ "toml_edit",
+]
+
+[[package]]
+name = "toml_datetime"
+version = "0.6.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c"
+dependencies = [
+ "serde",
+]
+
+[[package]]
+name = "toml_edit"
+version = "0.22.27"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a"
+dependencies = [
+ "indexmap",
+ "serde",
+ "serde_spanned",
+ "toml_datetime",
+ "toml_write",
+ "winnow",
+]
+
+[[package]]
+name = "toml_write"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801"
+
[[package]]
name = "unicode-ident"
version = "1.0.24"
@@ -835,13 +939,25 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
+[[package]]
+name = "winnow"
+version = "0.7.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945"
+dependencies = [
+ "memchr",
+]
+
[[package]]
name = "xtop"
version = "0.2.0"
dependencies = [
"anyhow",
"crossterm",
+ "serde_json",
+ "toml",
"xtop-core",
+ "xtop-plugin-sentinel",
"xtop-tui",
]
@@ -849,11 +965,24 @@ dependencies = [
name = "xtop-core"
version = "0.2.0"
dependencies = [
+ "ratatui",
"serde",
"serde_json",
"sysinfo",
]
+[[package]]
+name = "xtop-plugin-sentinel"
+version = "0.2.0"
+dependencies = [
+ "anyhow",
+ "ratatui",
+ "regex",
+ "serde",
+ "serde_json",
+ "xtop-core",
+]
+
[[package]]
name = "xtop-tui"
version = "0.2.0"
diff --git a/Cargo.toml b/Cargo.toml
index aa42b6d..11409cc 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -1,6 +1,11 @@
[workspace]
resolver = "2"
-members = ["crates/xtop-core", "crates/xtop-tui", "crates/xtop-cli"]
+members = [
+ "crates/xtop-core",
+ "crates/xtop-tui",
+ "crates/xtop-cli",
+ "plugins/xtop-plugin-sentinel",
+]
[workspace.package]
version = "0.2.0"
diff --git a/README.md b/README.md
index ebb6731..b83a908 100644
--- a/README.md
+++ b/README.md
@@ -8,69 +8,47 @@


-xtop is a modern, cross-platform TUI system monitor crafted in Rust. Heavily inspired by btop, it leverages Rust's safety and performance, powered by ratatui for the interface and sysinfo for real-time metrics.
+A cross-platform TUI system monitor written in Rust. Uses ratatui for the terminal interface and sysinfo for real-time system metrics.

----
+
-## Table of Contents
+Table of Contents
-- [Features](#features)
-- [Previews](#previews)
-- [Installation](#installation)
- - [Quick Install (macOS/Linux)](#quick-install-macoslinux)
- - [Quick Install (Windows PowerShell)](#quick-install-windows-powershell)
- - [Build from Source](#build-from-source)
-- [Usage](#usage)
- - [Keybindings](#keybindings)
- - [Modules](#modules)
- - [Help Overlay](#help-overlay)
-- [Customization](#customization)
-- [Command Palette](#command-palette)
-- [Configuration](#configuration)
-- [Contributing](#contributing)
-- [License](#license)
+
----
+
Features
- - Cross-Platform: Runs on macOS, Linux, and Windows.
- - System Monitoring:
-
- - CPU: Usage per core/thread, maximum temperature sensing.
- - Memory: RAM and Swap usage with historical graphing.
- - Network: Real-time upload and download tracking per interface.
- - Disks: Storage usage visualization with per-mount-point gauges.
- - Disk I/O: Read/write speed tracking per disk.
- - Processes: List of running processes sorted by CPU usage with live search.
- - GPU: Usage gauges (stub — ready for NVIDIA/AMD).
- - Battery: Charge level gauges (stub — ready for laptop support).
-
-
- - Theming:
-
- - 13 ready-to-use color schemes + custom themes via JSONC files.
- - Cycle through themes instantly with t / T.
-
-
- - Layouts:
-
- - 7 built-in layouts (Dashboard, Vertical, Horizontal, CPU/Memory/Network/Process Focus).
- - Custom layouts via JSONC files — define your own widget tree.
- - Full-screen mode for any widget.
- - Responsive design that adapts to terminal size.
-
-
- - Alert Thresholds: Visual warnings when CPU, memory, or disk usage exceeds configurable limits.
- - Persistence: Saves your theme, layout, and configuration automatically on quit.
+ - CPU usage per core with temperature sensing
+ - RAM and Swap monitoring with historical chart
+ - Network RX/TX tracking per interface
+ - Storage and Disk I/O visualization
+ - Process list with live search
+ - GPU and Battery monitoring (stub, ready for supported hardware)
+ - 13 color themes with custom theme support via JSONC
+ - 7 built-in layouts with custom layout support via JSONC
+ - Full-screen mode for any widget
+ - Configurable alert thresholds
+ - Persistent configuration
----
+See docs/features.md for a detailed feature breakdown.
+
+
Previews
@@ -80,275 +58,73 @@ xtop is a modern, cross-platform TUI system monitor crafted in Rust. Heavily ins
-
- More previews
-
-
-
-
----
-
-Installation
-
-Quick Install (macOS/Linux)
-
-The installer script automatically detects your distribution and installs all required dependencies (including Rust if needed).
-
-Install with curl:
-
-```bash
-curl -fsSL https://raw.githubusercontent.com/xscriptor/xtop/main/install.sh | bash
-```
-
-Or with wget:
-
-```bash
-wget -qO- https://raw.githubusercontent.com/xscriptor/xtop/main/install.sh | bash
-```
-
-Uninstall:
-
-```bash
-curl -fsSL https://raw.githubusercontent.com/xscriptor/xtop/main/install.sh | bash -s -- --uninstall
-```
-
-
-Installer Options
-
-You can also run the installer with additional options:
-
-```bash
-# Check dependencies without installing
-./install.sh --check-deps
-
-# Install only dependencies (Rust, build tools)
-./install.sh --install-deps
-
-# Show help
-./install.sh --help
-```
-
-Supported distributions: Arch, Debian/Ubuntu, Fedora/RHEL, openSUSE, Alpine, and derivatives.
-
-
-
-Quick Install (Windows PowerShell)
+
+ View more previews
+
-Requires Rust (Cargo) installed. Run in PowerShell:
+
-Install:
+Quick Install
-```powershell
-irm https://raw.githubusercontent.com/xscriptor/xtop/main/install.ps1 | iex
-```
+macOS / Linux
-Uninstall:
+curl -fsSL https://raw.githubusercontent.com/xscriptor/xtop/main/install.sh | bash
-```powershell
-irm https://raw.githubusercontent.com/xscriptor/xtop/main/uninstall.ps1 | iex
-```
+Windows (PowerShell)
-Build from Source
+irm https://raw.githubusercontent.com/xscriptor/xtop/main/install.ps1 | iex
-
- - Clone the repository:
-
+
Build from Source
-```bash
-git clone https://github.com/xscriptor/xtop.git
+
git clone https://github.com/xscriptor/xtop.git
cd xtop
-```
-
-
-
- - Build and run:
-
-```bash
-cargo run --release
-```
-
-
-
-
----
-
-Usage
+cargo run --release
-Keybindings
+For detailed installation instructions, see docs/installation.md.
-
-
-
- | Key |
- Action |
-
-
-
-
- | q |
- Quit application (saves config) |
-
-
- | ? |
- Toggle help overlay |
-
-
- | t |
- Next color theme |
-
-
- | T |
- Previous color theme |
-
-
- | l |
- Next layout mode (built-in + custom) |
-
-
- | f |
- Toggle fullscreen for current widget |
-
-
- | F |
- Cycle fullscreen through widgets |
-
-
- | / |
- Search / filter processes |
-
-
- | Esc |
- Cancel search / close help overlay |
-
-
-
-
-Modules
-
-
- - Header: Shows system uptime, load average, current theme, and layout mode.
- - CPU: Shows usage bars for each CPU core. If sensors are available, shows the maximum CPU temperature.
- - Memory: Gauges for RAM and Swap usage, plus a line chart for RAM history.
- - Storage: Disk usage gauges per mount point.
- - Network: Total downloaded (RX) and uploaded (TX) data per interface.
- - Disk I/O: Read/write speeds per disk device.
- - Processes: A scrolling list of the top 50 processes sorted by CPU usage, with live search.
- - GPU: GPU usage gauges (available on supported hardware).
- - Battery: Battery charge level gauges (available on supported hardware).
-
+
-Help Overlay
+Quick Start
-Press ? at any time to show a full list of available keybindings directly on screen. Press ? again or Esc to close.
-
----
-
-Customization
-
-xtop supports custom color themes and layout modes defined as JSONC files.
-
-→ Full customization guide
+Run xtop after installation. Key controls:
-
- | Feature |
- Location |
- Format |
-
+ | Key | Action |
-
- | Themes |
- ~/.config/xtop/themes/*.jsonc |
- 16-entry hex color palette |
-
-
- | Layouts |
- ~/.config/xtop/layouts/*.jsonc |
- Recursive split/widget tree |
-
+ | q | Quit (saves config) |
+ | ? | Toggle help overlay |
+ | t / T | Next / previous theme |
+ | l | Next layout mode |
+ | f / F | Toggle / cycle full-screen |
+ | / | Search processes |
-The built-in x theme (almost-black background, purple-pink accents) is always available. Starter theme and layout files ship in the assets/ directory.
+For full usage details, see docs/usage.md.
----
+
-Command Palette
-
-xtop provides an interactive search overlay for filtering processes in real time:
+Documentation
- - Press / to open the search bar at the top of the process list.
- - Type any query — results filter instantly by process name.
- - Press Enter to confirm the filter, Esc to cancel, Backspace to delete characters.
- - A centered overlay with
/query_ indicator shows the current search input.
+ - Features -- detailed feature breakdown
+ - Installation -- full install and uninstall guide
+ - Usage -- keybindings, modules, help overlay
+ - Configuration -- config file and settings reference
+ - Customization -- custom themes and layouts
+ - Roadmap
+ - Changelog
-The help overlay (?) serves as a quick-reference command palette for all available keybindings and actions.
-
----
-
-Configuration
-
-xtop automatically saves its configuration on quit. The configuration file is located at:
-
-~/.config/xtop/config.json
-
-Persisted settings include:
-
-
- - Current theme
- - Current layout mode
- - Update interval
- - History points (for RAM chart)
- - Alert thresholds (CPU, memory, disk)
-
-
-For custom themes and layouts, see the customization guide.
-
----
-
-Roadmap
-
-See the ROADMAP.md for planned features and upcoming milestones.
-
----
-
-Changelog
-
-See the CHANGELOG.md for detailed release notes.
-
----
+
Contributing
-Contributions are always welcome! Please read the contribution guidelines first.
+Contributions are welcome. See CONTRIBUTING.md for guidelines.
----
+
License
diff --git a/crates/xtop-cli/Cargo.toml b/crates/xtop-cli/Cargo.toml
index aed403a..f54c1ed 100644
--- a/crates/xtop-cli/Cargo.toml
+++ b/crates/xtop-cli/Cargo.toml
@@ -9,3 +9,12 @@ xtop-core = { path = "../xtop-core" }
xtop-tui = { path = "../xtop-tui" }
crossterm.workspace = true
anyhow.workspace = true
+serde_json.workspace = true
+toml = "0.8"
+
+# Optional plugins (behind feature flags)
+xtop-plugin-sentinel = { path = "../../plugins/xtop-plugin-sentinel", optional = true }
+
+[features]
+default = ["plugin-sentinel"]
+plugin-sentinel = ["dep:xtop-plugin-sentinel"]
diff --git a/crates/xtop-cli/src/main.rs b/crates/xtop-cli/src/main.rs
index 6ebe068..d4f9a03 100644
--- a/crates/xtop-cli/src/main.rs
+++ b/crates/xtop-cli/src/main.rs
@@ -1,8 +1,11 @@
use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyModifiers};
use std::fs;
+use std::path::PathBuf;
use std::time::{Duration, Instant};
+use xtop_core::application::plugin_manager::PluginManager;
use xtop_core::application::state::{AppState, Config, InputMode, PalettePage};
use xtop_core::domain::keybinding::Action;
+use xtop_core::infrastructure::composite_provider::CompositeProvider;
use xtop_core::infrastructure::config;
use xtop_core::infrastructure::layout_loader;
use xtop_core::infrastructure::sysinfo_provider::SysinfoProvider;
@@ -10,16 +13,38 @@ use xtop_core::infrastructure::theme_loader::load_all_themes;
use xtop_tui::render;
use xtop_tui::terminal;
+mod mcp;
+
+// ---------------------------------------------------------------------------
+// Plugin imports (feature-gated)
+// ---------------------------------------------------------------------------
+#[cfg(feature = "plugin-sentinel")]
+use xtop_plugin_sentinel::SentinelPlugin;
+
+// ---------------------------------------------------------------------------
+// Config dir helper (delegated to xtop-core to avoid duplication)
+// ---------------------------------------------------------------------------
+fn config_dir() -> PathBuf {
+ xtop_core::infrastructure::config::config_dir()
+}
+
fn key_event_to_str(key: &KeyEvent) -> String {
let mut s = String::new();
- if key.modifiers.contains(KeyModifiers::CONTROL) {
+ let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
+ if ctrl {
s.push_str("ctrl+");
}
if key.modifiers.contains(KeyModifiers::ALT) {
s.push_str("alt+");
}
match key.code {
- KeyCode::Char(c) => s.push(c),
+ KeyCode::Char(c) => {
+ if ctrl {
+ s.push(c.to_ascii_lowercase());
+ } else {
+ s.push(c);
+ }
+ }
KeyCode::Esc => s.push_str("escape"),
KeyCode::Enter => s.push_str("enter"),
KeyCode::Backspace => s.push_str("backspace"),
@@ -93,9 +118,15 @@ fn ensure_default_assets() {
}
fn save_config(state: &AppState) {
+ let layout_name = if state.layout_index < state.layout_defs.len() {
+ state.layout_defs[state.layout_index].name.clone()
+ } else {
+ String::new()
+ };
let cfg = Config {
theme: state.current_theme.name.clone(),
layout_mode: state.save_layout_mode(),
+ layout_name,
update_interval_ms: state.update_interval_ms,
history_points: 100,
alerts: state.alerts,
@@ -104,19 +135,455 @@ fn save_config(state: &AppState) {
let _ = config::save_config(&cfg);
}
+fn build_plugin_manager(state: &mut AppState, cfg_dir: &PathBuf) -> PluginManager {
+ let plugins_dir = cfg_dir.join("plugins");
+ fs::create_dir_all(&plugins_dir).ok();
+ let mut mgr = PluginManager::new(plugins_dir);
+
+ // Register plugins behind feature flags
+ #[cfg(feature = "plugin-sentinel")]
+ {
+ let plugin = Box::new(SentinelPlugin::new());
+ if let Err(e) = mgr.register(plugin, state) {
+ eprintln!("[xtop] failed to load sentinel plugin: {e}");
+ }
+ }
+
+ mgr
+}
+
+// ---------------------------------------------------------------------------
+// CLI subcommands
+// ---------------------------------------------------------------------------
+fn print_usage() {
+ eprintln!("Usage:");
+ eprintln!(" xtop Start the TUI system monitor");
+ eprintln!(" xtop mcp Start MCP server (stdio transport) for AI agents");
+ eprintln!(" xtop plugin list List installed plugins");
+ eprintln!(" xtop plugin install Install a plugin from github.com/xscriptor/xtop/plugins/");
+ eprintln!(" xtop plugin install Install a plugin from a git URL");
+ eprintln!(" xtop plugin scaffold Create a new plugin crate");
+}
+
+/// Check if a string looks like a git URL (not a simple name).
+fn is_git_url(s: &str) -> bool {
+ s.contains("://") || s.contains("github.com") || s.contains("git@")
+}
+
+fn cmd_plugin_list() {
+ let workspace_path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
+ .parent()
+ .unwrap()
+ .parent()
+ .unwrap()
+ .join("Cargo.toml");
+
+ let content = match fs::read_to_string(&workspace_path) {
+ Ok(c) => c,
+ Err(e) => {
+ eprintln!("Error reading workspace Cargo.toml: {e}");
+ return;
+ }
+ };
+
+ // Parse workspace members for plugin crates
+ let mut in_members = false;
+ let mut plugins: Vec = Vec::new();
+ for line in content.lines() {
+ let trimmed = line.trim();
+ if trimmed.starts_with("members") {
+ in_members = true;
+ continue;
+ }
+ if in_members {
+ if trimmed == "]" {
+ break;
+ }
+ let name = trimmed.trim_matches(',').trim().trim_matches('"');
+ if name.starts_with("plugins/xtop-plugin-") || name.starts_with("crates/xtop-plugin-") {
+ plugins.push(name.to_string());
+ }
+ }
+ }
+
+ if plugins.is_empty() {
+ println!("No plugins installed.");
+ return;
+ }
+ println!("Installed plugins:");
+ for p in &plugins {
+ println!(" {p}");
+ }
+}
+
+fn cmd_plugin_install(name_or_url: &str) -> anyhow::Result<()> {
+ let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
+ let workspace_dir = manifest_dir.parent().unwrap().parent().unwrap();
+ let workspace_toml = workspace_dir.join("Cargo.toml");
+ let cli_toml = manifest_dir.join("Cargo.toml");
+ let plugins_dir = workspace_dir.join("plugins");
+
+ let tmp = std::env::temp_dir().join("xtop-plugin-install");
+ let _ = fs::remove_dir_all(&tmp);
+
+ let repo_url: &str;
+ let mut plugin_subdir: String = String::new();
+
+ if is_git_url(name_or_url) {
+ // URL-based: clone the repo directly
+ repo_url = name_or_url;
+ println!("Cloning {repo_url} ...");
+ let status = std::process::Command::new("git")
+ .args(["clone", repo_url, tmp.to_str().unwrap()])
+ .status()
+ .map_err(|e| anyhow::anyhow!("Failed to run git: {e}"))?;
+ if !status.success() {
+ anyhow::bail!("git clone failed");
+ }
+ } else {
+ // Name-based: look in xtop repo's plugins/ directory
+ repo_url = "https://github.com/xscriptor/xtop.git";
+ let candidate_names = [
+ format!("plugins/xtop-plugin-{name_or_url}"),
+ format!("plugins/{name_or_url}"),
+ ];
+ println!("Looking for plugin '{name_or_url}' in {repo_url} ...");
+ let status = std::process::Command::new("git")
+ .args([
+ "clone",
+ "--depth",
+ "1",
+ "--filter=blob:none",
+ "--sparse",
+ repo_url,
+ tmp.to_str().unwrap(),
+ ])
+ .status()
+ .map_err(|e| anyhow::anyhow!("Failed to run git: {e}"))?;
+ if !status.success() {
+ anyhow::bail!("git clone failed");
+ }
+
+ // Try each candidate path
+ let mut found = false;
+ for candidate in &candidate_names {
+ if tmp.join(candidate).join("Cargo.toml").exists() {
+ plugin_subdir = candidate.clone();
+ found = true;
+ break;
+ }
+ }
+ if !found {
+ let _ = fs::remove_dir_all(&tmp);
+ anyhow::bail!(
+ "Plugin '{name_or_url}' not found in plugins/. \
+ Tried: {}",
+ candidate_names.join(", ")
+ );
+ }
+ println!("Found plugin at {plugin_subdir}");
+ }
+
+ // --- Determine the plugin source directory ---
+ let plugin_src = if plugin_subdir.is_empty() {
+ // URL-based: cloned repo root
+ tmp.clone()
+ } else {
+ // Name-based: subdirectory within cloned xtop repo
+ tmp.join(&plugin_subdir)
+ };
+
+ // --- Read the plugin's Cargo.toml to get the package name ---
+ let plugin_toml_path = plugin_src.join("Cargo.toml");
+ let plugin_toml_content = fs::read_to_string(&plugin_toml_path)
+ .map_err(|e| anyhow::anyhow!("No Cargo.toml found: {e}"))?;
+ let plugin_pkg: toml::Value = plugin_toml_content
+ .parse()
+ .map_err(|e| anyhow::anyhow!("Invalid Cargo.toml: {e}"))?;
+
+ let pkg_name = plugin_pkg
+ .get("package")
+ .and_then(|p| p.get("name"))
+ .and_then(|n| n.as_str())
+ .ok_or_else(|| anyhow::anyhow!("package.name not found in plugin Cargo.toml"))?;
+
+ let feature_name = pkg_name.replace('-', "_");
+ let plugin_dir_name = pkg_name.replace('-', "_");
+
+ println!("Package name: {pkg_name}");
+
+ // --- Copy into local plugins/ directory ---
+ let target_dir = plugins_dir.join(&plugin_dir_name);
+ if target_dir.exists() {
+ anyhow::bail!("Plugin '{}' already exists at plugins/{plugin_dir_name}", pkg_name);
+ }
+ fs::create_dir_all(&plugins_dir)?;
+ cp_recursive(&plugin_src, &target_dir)?;
+
+ // --- Add to workspace Cargo.toml ---
+ let ws_content = fs::read_to_string(&workspace_toml)?;
+ let member_entry = format!(" \"plugins/{plugin_dir_name}\"");
+ if ws_content.contains(&member_entry) {
+ anyhow::bail!("Already in workspace");
+ }
+ // Insert before the closing bracket of members
+ let sentinel_entry = " \"plugins/xtop-plugin-sentinel\",";
+ let new_ws = if ws_content.contains(sentinel_entry) {
+ ws_content.replace(
+ sentinel_entry,
+ &format!(
+ "{sentinel_entry}\n{member_entry},"
+ ),
+ )
+ } else {
+ // Fallback: insert before the closing ] of members
+ ws_content.replacen(
+ "]",
+ &format!(" {member_entry},\n]"),
+ 1,
+ )
+ };
+ fs::write(&workspace_toml, &new_ws)?;
+
+ // --- Add to xtop-cli Cargo.toml ---
+ let cli_content = fs::read_to_string(&cli_toml)?;
+
+ // Build dependency path relative to crates/xtop-cli/
+ let dep_path = format!("../../plugins/{plugin_dir_name}");
+ let dep_line = format!(
+ "{pkg_name} = {{ path = \"{dep_path}\", optional = true }}"
+ );
+
+ if !cli_content.contains(&dep_line) {
+ // Find the last optional plugin dependency and insert after it
+ let marker = "# Optional plugins (behind feature flags)";
+ let new_cli = cli_content.replace(
+ marker,
+ &format!("{marker}\n{dep_line}"),
+ );
+ fs::write(&cli_toml, &new_cli)?;
+ }
+
+ // Add feature flag
+ let feature_line = format!("{feature_name} = [\"dep:{pkg_name}\"]");
+ let cli_content2 = fs::read_to_string(&cli_toml)?;
+ if !cli_content2.contains(&feature_line) {
+ let sentinel_feature = "plugin-sentinel = [\"dep:xtop-plugin-sentinel\"]";
+ let new_cli2 = if cli_content2.contains(sentinel_feature) {
+ cli_content2.replace(
+ sentinel_feature,
+ &format!("{sentinel_feature}\n{feature_line}"),
+ )
+ } else {
+ cli_content2.replacen(
+ "[features]",
+ &format!("[features]\n{feature_line}"),
+ 1,
+ )
+ };
+ fs::write(&cli_toml, &new_cli2)?;
+ }
+
+ // --- Rebuild ---
+ println!("Building xtop with {pkg_name} ...");
+ let build = std::process::Command::new("cargo")
+ .args(["build", "--release"])
+ .current_dir(&workspace_dir)
+ .status()
+ .map_err(|e| anyhow::anyhow!("cargo build failed: {e}"))?;
+ if !build.success() {
+ anyhow::bail!("Build failed. Check the plugin's compatibility.");
+ }
+
+ // --- Cleanup ---
+ let _ = fs::remove_dir_all(&tmp);
+
+ println!();
+ println!("Plugin '{pkg_name}' installed successfully.");
+ println!(" Location: plugins/{plugin_dir_name}");
+ println!(" Feature flag: {feature_name}");
+ println!();
+ println!("Note: '{feature_name}' is NOT enabled by default.");
+ println!("To enable it, add '{feature_name}' to the 'default' feature list");
+ println!("in crates/xtop-cli/Cargo.toml, then rebuild.");
+
+ Ok(())
+}
+
+fn cmd_plugin_scaffold(name: &str) -> anyhow::Result<()> {
+ let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
+ let workspace_dir = manifest_dir.parent().unwrap().parent().unwrap();
+ let plugins_dir = workspace_dir.join("plugins");
+ let plugin_dir = plugins_dir.join(format!("xtop-plugin-{name}"));
+
+ if plugin_dir.exists() {
+ anyhow::bail!("Plugin crate already exists at {}", plugin_dir.display());
+ }
+
+ let src_dir = plugin_dir.join("src");
+ fs::create_dir_all(&src_dir)?;
+
+ // Cargo.toml (path refs go up from plugins/ to workspace root, then into crates/)
+ let cargo_toml = format!(
+ r#"[package]
+name = "xtop-plugin-{name}"
+version.workspace = true
+edition.workspace = true
+license.workspace = true
+description = "xtop plugin: {name}"
+
+[dependencies]
+xtop-core = {{ path = "../../crates/xtop-core" }}
+ratatui.workspace = true
+"#
+ );
+ fs::write(plugin_dir.join("Cargo.toml"), &cargo_toml)?;
+
+ // lib.rs
+ let lib_rs = format!(
+ r#"use xtop_core::domain::plugin::{{Plugin, PluginCapability, PluginContext, PluginError, PluginManifest}};
+
+pub struct {name_cap}Plugin;
+
+impl {name_cap}Plugin {{
+ pub fn new() -> Self {{
+ Self
+ }}
+}}
+
+impl Plugin for {name_cap}Plugin {{
+ fn manifest(&self) -> PluginManifest {{
+ PluginManifest {{
+ id: "{name}".to_string(),
+ name: "{name_cap}".to_string(),
+ version: "0.1.0".to_string(),
+ description: "xtop plugin: {name}".to_string(),
+ capabilities: vec![PluginCapability::ReadSystemInfo],
+ }}
+ }}
+
+ fn on_tick(&mut self, _ctx: &mut PluginContext) -> Result<(), PluginError> {{
+ Ok(())
+ }}
+}}
+"#,
+ name = name,
+ name_cap = {
+ let mut chars = name.chars();
+ match chars.next() {
+ None => String::new(),
+ Some(c) => c.to_uppercase().to_string() + chars.as_str(),
+ }
+ }
+ );
+ fs::write(src_dir.join("lib.rs"), &lib_rs)?;
+
+ println!("Plugin scaffold created at {}", plugin_dir.display());
+ println!("To register it:");
+ println!(" 1. Add \"plugins/xtop-plugin-{name}\" to [workspace].members in Cargo.toml");
+ println!(" 2. Add dependency + feature flag in crates/xtop-cli/Cargo.toml");
+ println!(" 3. Add #[cfg(feature = \"plugin-{name}\")] import in main.rs");
+ println!(" 4. Implement Plugin trait methods");
+
+ Ok(())
+}
+
+fn cp_recursive(src: &std::path::Path, dst: &std::path::Path) -> std::io::Result<()> {
+ if src.is_dir() {
+ fs::create_dir_all(dst)?;
+ for entry in fs::read_dir(src)? {
+ let entry = entry?;
+ let file_type = entry.file_type()?;
+ let src_path = entry.path();
+ let dst_path = dst.join(entry.file_name());
+ if file_type.is_dir() {
+ // Skip .git directory
+ if entry.file_name() != ".git" {
+ cp_recursive(&src_path, &dst_path)?;
+ }
+ } else {
+ fs::copy(&src_path, &dst_path)?;
+ }
+ }
+ Ok(())
+ } else {
+ fs::copy(src, dst)?;
+ Ok(())
+ }
+}
+
fn main() -> anyhow::Result<()> {
+ let args: Vec = std::env::args().collect();
+
+ // CLI subcommands
+ if args.len() > 1 {
+ match args[1].as_str() {
+ "mcp" => {
+ ensure_default_assets();
+ return mcp::run_mcp_server();
+ }
+ "plugin" => {
+ if args.len() < 3 {
+ print_usage();
+ return Ok(());
+ }
+ match args[2].as_str() {
+ "list" => {
+ cmd_plugin_list();
+ return Ok(());
+ }
+ "install" => {
+ if args.len() < 4 {
+ eprintln!("Usage: xtop plugin install ");
+ return Ok(());
+ }
+ return cmd_plugin_install(&args[3]);
+ }
+ "scaffold" => {
+ if args.len() < 4 {
+ eprintln!("Usage: xtop plugin scaffold ");
+ return Ok(());
+ }
+ return cmd_plugin_scaffold(&args[3]);
+ }
+ _ => {
+ print_usage();
+ return Ok(());
+ }
+ }
+ }
+ "--help" | "-h" => {
+ print_usage();
+ return Ok(());
+ }
+ _ => {}
+ }
+ }
+
ensure_default_assets();
terminal::install_panic_hook();
let mut terminal = terminal::init()?;
- let provider = SysinfoProvider::new();
+ let cfg_dir = config_dir();
+
+ // Build the primary (composite) provider
+ let sysinfo_provider = SysinfoProvider::new();
+ let composite = CompositeProvider::new(Box::new(sysinfo_provider));
+
let themes = load_all_themes();
let cfg = config::load_config();
let mut builtin_layouts = layout_loader::builtin_layouts();
let custom_layouts = layout_loader::load_custom_layouts();
builtin_layouts.extend(custom_layouts);
- let mut state = AppState::new(Box::new(provider), themes, cfg, builtin_layouts);
+ let mut state = AppState::new(Box::new(composite), themes, cfg, builtin_layouts);
+
+ // Build and register plugins
+ let plugin_mgr = build_plugin_manager(&mut state, &cfg_dir);
+
+ // Collect extra data providers from plugins and inject everything into state
+ let extra_providers = plugin_mgr.collect_data_providers();
+ state.init_plugins(plugin_mgr, extra_providers);
let tick_rate = Duration::from_millis(state.update_interval_ms);
let mut last_tick = Instant::now();
@@ -131,9 +598,28 @@ fn main() -> anyhow::Result<()> {
if event::poll(timeout)? {
if let Event::Key(key) = event::read()? {
let key_str = key_event_to_str(&key);
+
+ // Give plugins first chance to consume the key
+ let key_str_clone = key_str.clone();
+ let key_consumed = state.with_plugin_manager_mut(|mgr, this| {
+ mgr.handle_key(this, &key_str_clone)
+ });
+ if key_consumed {
+ continue;
+ }
+
+ // DEBUG: print key for diagnostics
+ if cfg!(debug_assertions) && !key_str.is_empty() {
+ eprintln!("[key] '{key_str}'");
+ }
+
match state.input_mode {
InputMode::Normal => {
- if let Some(action) = state.keybindings.resolve(&key_str) {
+ // Direct Ctrl+P check (works regardless of keybinding config, important on macOS)
+ if key_str == "ctrl+p" {
+ state.open_palette();
+ state.input_mode = InputMode::CommandPalette;
+ } else if let Some(action) = state.keybindings.resolve(&key_str) {
match action {
Action::Quit => {
save_config(&state);
@@ -180,9 +666,7 @@ fn main() -> anyhow::Result<()> {
KeyCode::Enter => {
if let Some(action) = state.palette_selected_action() {
state.execute_action(&action);
- if action == Action::Quit {
- save_config(&state);
- }
+ save_config(&state);
}
}
KeyCode::Down => {
@@ -220,6 +704,11 @@ fn main() -> anyhow::Result<()> {
}
}
+ // Disable plugins on shutdown
+ state.with_plugin_manager_mut(|mgr, this| {
+ mgr.disable_all(this);
+ });
+
terminal::restore()?;
Ok(())
}
diff --git a/crates/xtop-cli/src/mcp.rs b/crates/xtop-cli/src/mcp.rs
new file mode 100644
index 0000000..1c84a27
--- /dev/null
+++ b/crates/xtop-cli/src/mcp.rs
@@ -0,0 +1,77 @@
+//! MCP server entry point.
+//!
+//! Initializes xtop state and plugins, then delegates to the Sentinel plugin's
+//! MCP module (`xtop_plugin_sentinel::mcp::run_server`) which handles the
+//! actual stdin/stdout MCP protocol loop.
+
+use std::path::PathBuf;
+use xtop_core::application::plugin_manager::PluginManager;
+use xtop_core::application::state::AppState;
+use xtop_core::infrastructure::composite_provider::CompositeProvider;
+use xtop_core::infrastructure::config;
+use xtop_core::infrastructure::layout_loader;
+use xtop_core::infrastructure::sysinfo_provider::SysinfoProvider;
+use xtop_core::infrastructure::theme_loader::load_all_themes;
+
+#[cfg(feature = "plugin-sentinel")]
+use xtop_plugin_sentinel::SentinelPlugin;
+
+/// Run the MCP server.
+///
+/// Sets up AppState + PluginManager with Sentinel, then delegates to
+/// the plugin's MCP module for the protocol loop.
+pub fn run_mcp_server() -> anyhow::Result<()> {
+ let cfg_dir = config_dir();
+ let mut state = initialize_state(&cfg_dir)?;
+
+ // Delegate to Sentinel's MCP module
+ #[cfg(feature = "plugin-sentinel")]
+ {
+ return xtop_plugin_sentinel::mcp::run_server(&mut state);
+ }
+
+ #[cfg(not(feature = "plugin-sentinel"))]
+ {
+ eprintln!("MCP server requires the 'plugin-sentinel' feature.");
+ eprintln!("Rebuild with: cargo build --features plugin-sentinel");
+ std::process::exit(1);
+ }
+}
+
+fn config_dir() -> PathBuf {
+ xtop_core::infrastructure::config::config_dir()
+}
+
+fn build_plugin_manager(state: &mut AppState, cfg_dir: &PathBuf) -> PluginManager {
+ let plugins_dir = cfg_dir.join("plugins");
+ std::fs::create_dir_all(&plugins_dir).ok();
+ let mut mgr = PluginManager::new(plugins_dir);
+
+ #[cfg(feature = "plugin-sentinel")]
+ {
+ let plugin = Box::new(SentinelPlugin::new());
+ if let Err(e) = mgr.register(plugin, state) {
+ eprintln!("[xtop-mcp] failed to load sentinel plugin: {e}");
+ }
+ }
+
+ mgr
+}
+
+fn initialize_state(cfg_dir: &PathBuf) -> anyhow::Result {
+ let sysinfo_provider = SysinfoProvider::new();
+ let composite = CompositeProvider::new(Box::new(sysinfo_provider));
+
+ let themes = load_all_themes();
+ let cfg = config::load_config();
+ let mut builtin_layouts = layout_loader::builtin_layouts();
+ let custom_layouts = layout_loader::load_custom_layouts();
+ builtin_layouts.extend(custom_layouts);
+ let mut state = AppState::new(Box::new(composite), themes, cfg, builtin_layouts);
+
+ let plugin_mgr = build_plugin_manager(&mut state, cfg_dir);
+ let extra_providers = plugin_mgr.collect_data_providers();
+ state.init_plugins(plugin_mgr, extra_providers);
+
+ Ok(state)
+}
diff --git a/crates/xtop-core/Cargo.toml b/crates/xtop-core/Cargo.toml
index 784b7c4..862b526 100644
--- a/crates/xtop-core/Cargo.toml
+++ b/crates/xtop-core/Cargo.toml
@@ -8,3 +8,4 @@ license.workspace = true
sysinfo.workspace = true
serde.workspace = true
serde_json.workspace = true
+ratatui.workspace = true
diff --git a/crates/xtop-core/src/application/mod.rs b/crates/xtop-core/src/application/mod.rs
index 1b8f3aa..9e59902 100644
--- a/crates/xtop-core/src/application/mod.rs
+++ b/crates/xtop-core/src/application/mod.rs
@@ -1,2 +1,3 @@
pub mod history;
+pub mod plugin_manager;
pub mod state;
diff --git a/crates/xtop-core/src/application/plugin_manager.rs b/crates/xtop-core/src/application/plugin_manager.rs
new file mode 100644
index 0000000..2b02164
--- /dev/null
+++ b/crates/xtop-core/src/application/plugin_manager.rs
@@ -0,0 +1,188 @@
+use std::fmt::Debug;
+use std::path::PathBuf;
+
+use crate::application::state::AppState;
+use crate::domain::plugin::{
+ Plugin, PluginCapability, PluginContext, PluginError, PluginManifest, WidgetRegistration,
+};
+use crate::domain::system_info::SystemDataProvider;
+
+/// Manages the lifecycle of all loaded plugins.
+///
+/// Responsibilities:
+/// - Loading and enabling plugins at startup
+/// - Dispatching tick, key, and command events
+/// - Collecting data providers and widgets from plugins
+/// - Error isolation (a failing plugin does not crash xtop)
+/// - Plugin persistence directory management
+pub struct PluginManager {
+ plugins: Vec>,
+ plugin_data_base: PathBuf,
+}
+
+impl Debug for PluginManager {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ f.debug_struct("PluginManager")
+ .field("count", &self.plugins.len())
+ .finish()
+ }
+}
+
+impl PluginManager {
+ /// Create a new manager with the base directory for plugin data.
+ ///
+ /// Typically: `~/.config/xtop/plugins/`
+ pub fn new(plugin_data_base: PathBuf) -> Self {
+ Self {
+ plugins: Vec::new(),
+ plugin_data_base,
+ }
+ }
+
+ /// Register and enable a plugin.
+ ///
+ /// This calls `on_enable` on the plugin. If it fails, the plugin is not added
+ /// and the error is logged.
+ pub fn register(
+ &mut self,
+ mut plugin: Box,
+ state: &mut AppState,
+ ) -> Result<(), PluginError> {
+ let id = plugin.manifest().id.clone();
+ let data_dir = self.plugin_data_base.join(&id);
+ std::fs::create_dir_all(&data_dir)
+ .map_err(|e| PluginError::Recoverable(format!("failed to create plugin data dir for {id}: {e}")))?;
+
+ let capabilities = plugin.manifest().capabilities.clone();
+ let mut ctx = PluginContext {
+ state,
+ plugin_data_dir: data_dir,
+ capabilities,
+ };
+
+ plugin.on_enable(&mut ctx)?;
+
+ self.plugins.push(plugin);
+ Ok(())
+ }
+
+ fn plugin_has_capability(plugin: &Box, cap: &PluginCapability) -> bool {
+ plugin.manifest().capabilities.contains(cap)
+ }
+
+ fn build_context<'a>(
+ base: &std::path::Path,
+ plugin: &Box,
+ state: &'a mut AppState,
+ ) -> PluginContext<'a> {
+ let id = plugin.manifest().id.clone();
+ let capabilities = plugin.manifest().capabilities.clone();
+ PluginContext {
+ state,
+ plugin_data_dir: base.join(&id),
+ capabilities,
+ }
+ }
+
+ /// Call `on_tick` on every enabled plugin.
+ /// Errors are caught per-plugin so one failing plugin does not affect others.
+ pub fn tick_all(&mut self, state: &mut AppState) {
+ let base = self.plugin_data_base.clone();
+ for plugin in &mut self.plugins {
+ let id = plugin.manifest().id.clone();
+ let mut ctx = Self::build_context(&base, plugin, state);
+ if let Err(e) = plugin.on_tick(&mut ctx) {
+ eprintln!("[plugin:{id}] tick error: {e}");
+ }
+ }
+ }
+
+ /// Dispatch a key event to all plugins.
+ /// Returns `true` if any plugin consumed the event.
+ pub fn handle_key(&mut self, state: &mut AppState, key: &str) -> bool {
+ let base = self.plugin_data_base.clone();
+ for plugin in &mut self.plugins {
+ let id = plugin.manifest().id.clone();
+ let mut ctx = Self::build_context(&base, plugin, state);
+ match plugin.on_key(&mut ctx, key) {
+ Ok(true) => return true,
+ Err(e) => eprintln!("[plugin:{id}] key error: {e}"),
+ _ => {}
+ }
+ }
+ false
+ }
+
+ /// Collect all data providers from plugins for use in CompositeProvider.
+ /// Only includes providers from plugins with `ReadSystemInfo` capability.
+ pub fn collect_data_providers(&self) -> Vec> {
+ let mut providers: Vec> = Vec::new();
+ for plugin in &self.plugins {
+ if Self::plugin_has_capability(plugin, &PluginCapability::ReadSystemInfo) {
+ if let Some(provider) = plugin.data_provider() {
+ providers.push(provider);
+ }
+ }
+ }
+ providers
+ }
+
+ /// Collect all widget registrations from plugins.
+ /// Only includes widgets from plugins with `RenderWidgets` capability.
+ pub fn collect_widgets(&self) -> Vec {
+ let mut widgets: Vec = Vec::new();
+ for plugin in &self.plugins {
+ if Self::plugin_has_capability(plugin, &PluginCapability::RenderWidgets) {
+ if let Some(widget) = plugin.widget() {
+ widgets.push(widget);
+ }
+ }
+ }
+ widgets
+ }
+
+ /// Execute a command on the plugin identified by `plugin_id`.
+ ///
+ /// Returns the plugin's response string on success.
+ pub fn execute(
+ &mut self,
+ state: &mut AppState,
+ plugin_id: &str,
+ action: &str,
+ params: &str,
+ ) -> Result {
+ let base = self.plugin_data_base.clone();
+ for plugin in &mut self.plugins {
+ if plugin.manifest().id != plugin_id {
+ continue;
+ }
+ let mut ctx = Self::build_context(&base, plugin, state);
+ return plugin.execute(&mut ctx, action, params);
+ }
+ Err(PluginError::Recoverable(format!("plugin not found: {plugin_id}")))
+ }
+
+ /// List all loaded plugin manifests (for display / status).
+ pub fn manifests(&self) -> Vec {
+ self.plugins.iter().map(|p| p.manifest()).collect()
+ }
+
+ /// Number of loaded plugins.
+ pub fn count(&self) -> usize {
+ self.plugins.len()
+ }
+
+ /// Call `on_disable` on all plugins (e.g. on shutdown).
+ pub fn disable_all(&mut self, state: &mut AppState) {
+ let base = self.plugin_data_base.clone();
+ for plugin in &mut self.plugins {
+ let id = plugin.manifest().id.clone();
+ let mut ctx = Self::build_context(&base, plugin, state);
+ if let Err(e) = plugin.on_disable(&mut ctx) {
+ eprintln!("[plugin:{id}] disable error: {e}");
+ }
+ }
+ }
+}
+
+
diff --git a/crates/xtop-core/src/application/state.rs b/crates/xtop-core/src/application/state.rs
index 4806dc7..043a405 100644
--- a/crates/xtop-core/src/application/state.rs
+++ b/crates/xtop-core/src/application/state.rs
@@ -1,8 +1,10 @@
use crate::application::history::MetricsHistory;
+use crate::application::plugin_manager::PluginManager;
use crate::domain::keybinding::{Action, Keybindings};
use crate::domain::metrics::SystemInfo;
use crate::domain::layout::LayoutDef;
use crate::domain::metrics::SystemSnapshot;
+use crate::domain::plugin::WidgetRegistration;
use crate::domain::system_info::SystemDataProvider;
use crate::domain::theme::Theme;
use serde::{Deserialize, Serialize};
@@ -230,10 +232,19 @@ impl Default for AlertThresholds {
}
}
+fn default_layout_mode() -> LayoutMode {
+ LayoutMode::Dashboard
+}
+
#[derive(Clone, Serialize, Deserialize)]
pub struct Config {
pub theme: String,
+ #[serde(default = "default_layout_mode")]
pub layout_mode: LayoutMode,
+ /// Layout name for custom layouts beyond the 7 built-in LayoutMode variants.
+ /// If non-empty, takes precedence over `layout_mode`.
+ #[serde(default)]
+ pub layout_name: String,
pub update_interval_ms: u64,
pub history_points: usize,
pub alerts: AlertThresholds,
@@ -246,6 +257,7 @@ impl Default for Config {
Self {
theme: "x".to_string(),
layout_mode: LayoutMode::Dashboard,
+ layout_name: String::new(),
update_interval_ms: 1000,
history_points: 100,
alerts: AlertThresholds::default(),
@@ -277,6 +289,8 @@ pub struct AppState {
pub process_sort: ProcessSortBy,
pub process_selected: Option,
pub sys_info: SystemInfo,
+ pub plugin_manager: Option,
+ pub plugin_widgets: Vec,
}
impl AppState {
@@ -291,7 +305,14 @@ impl AppState {
.position(|t| t.name == config.theme)
.unwrap_or(0);
let current_theme = themes[selected_theme_index].clone();
- let layout_index = layout_index_from_mode(config.layout_mode, &layout_defs);
+ let layout_index = if !config.layout_name.is_empty() {
+ layout_defs
+ .iter()
+ .position(|l| l.name == config.layout_name)
+ .unwrap_or_else(|| layout_index_from_mode(config.layout_mode, &layout_defs))
+ } else {
+ layout_index_from_mode(config.layout_mode, &layout_defs)
+ };
Self {
provider,
history: MetricsHistory::new(config.history_points),
@@ -322,6 +343,68 @@ impl AppState {
process_sort: ProcessSortBy::Cpu,
process_selected: None,
sys_info: SystemInfo::default(),
+ plugin_manager: None,
+ plugin_widgets: Vec::new(),
+ }
+ }
+
+ /// Set the plugin manager and inject extra data providers into the composite provider.
+ /// Called once during initialization after all plugins are registered.
+ pub fn init_plugins(
+ &mut self,
+ mgr: PluginManager,
+ extra_providers: Vec>,
+ ) {
+ if !extra_providers.is_empty() {
+ self.provider.add_extras(extra_providers);
+ }
+ self.plugin_manager = Some(mgr);
+ self.refresh_plugin_widgets();
+ }
+
+ /// Collect plugin widgets into the state for the TUI renderer.
+ pub fn refresh_plugin_widgets(&mut self) {
+ if let Some(ref mgr) = self.plugin_manager {
+ let registrations = mgr.collect_widgets();
+ // Only accept if the plugin has RenderWidgets capability
+ self.plugin_widgets = registrations;
+ }
+ }
+
+ /// Kill a process by PID. Returns true if the signal was sent.
+ pub fn kill_process_by_pid(&mut self, pid: u32) -> bool {
+ self.provider.kill_process(pid)
+ }
+
+ /// Set alert thresholds.
+ pub fn set_alert_thresholds(&mut self, cpu: f64, mem: f64, disk: f64) {
+ self.alerts = AlertThresholds {
+ cpu_high: cpu,
+ mem_high: mem,
+ disk_high: disk,
+ };
+ }
+
+ /// Switch to a theme by name. Returns true if found.
+ pub fn set_theme_by_name(&mut self, name: &str) -> bool {
+ if let Some(idx) = self.themes.iter().position(|t| t.name == name) {
+ self.selected_theme_index = idx;
+ self.apply_theme();
+ true
+ } else {
+ false
+ }
+ }
+
+ /// Switch to a layout by name. Returns true if found.
+ pub fn set_layout_by_name(&mut self, name: &str) -> bool {
+ if let Some(idx) = self.layout_defs.iter().position(|l| l.name == name) {
+ self.layout_index = idx;
+ self.layout_mode = self.save_layout_mode();
+ self.full_screen_widget = FullScreenWidget::None;
+ true
+ } else {
+ false
}
}
@@ -333,6 +416,22 @@ impl AppState {
mode_from_layout_index(self.layout_index)
}
+ /// Safely access the plugin manager with a closure.
+ /// Ensures the plugin manager is always restored after the operation.
+ /// NOTE: does NOT call refresh_plugin_widgets — the caller must do it if needed.
+ pub fn with_plugin_manager_mut(
+ &mut self,
+ f: impl FnOnce(&mut PluginManager, &mut Self) -> R,
+ ) -> R {
+ let mut mgr = self
+ .plugin_manager
+ .take()
+ .expect("PluginManager not initialized");
+ let result = f(&mut mgr, self);
+ self.plugin_manager = Some(mgr);
+ result
+ }
+
pub fn on_tick(&mut self) {
self.provider.refresh_all();
self.tick_count += 1.0;
@@ -355,6 +454,12 @@ impl AppState {
let total_rx: u64 = snap.networks.iter().map(|n| n.received).sum();
let total_tx: u64 = snap.networks.iter().map(|n| n.transmitted).sum();
self.history.push_net(x, total_rx as f64, total_tx as f64);
+
+ // Let plugins tick
+ self.with_plugin_manager_mut(|mgr, this| {
+ mgr.tick_all(this);
+ });
+ self.refresh_plugin_widgets();
}
pub fn snapshot(&self) -> SystemSnapshot {
diff --git a/crates/xtop-core/src/domain/keybinding.rs b/crates/xtop-core/src/domain/keybinding.rs
index b5a99d2..9b1daaf 100644
--- a/crates/xtop-core/src/domain/keybinding.rs
+++ b/crates/xtop-core/src/domain/keybinding.rs
@@ -40,7 +40,7 @@ fn vec_one_l() -> Vec { vec!["l".into()] }
fn vec_one_f() -> Vec { vec!["f".into()] }
fn vec_one_shift_f() -> Vec { vec!["F".into()] }
fn vec_one_slash() -> Vec { vec!["/".into()] }
-fn vec_one_ctrl_p() -> Vec { vec!["ctrl+p".into()] }
+fn vec_one_ctrl_p() -> Vec { vec!["ctrl+p".into(), "ctrl+P".into()] }
fn vec_one_escape() -> Vec { vec!["escape".into()] }
fn vec_one_k() -> Vec { vec!["k".into()] }
fn vec_one_up() -> Vec { vec!["up".into()] }
diff --git a/crates/xtop-core/src/domain/metrics.rs b/crates/xtop-core/src/domain/metrics.rs
index d92fc1a..a6f40d4 100644
--- a/crates/xtop-core/src/domain/metrics.rs
+++ b/crates/xtop-core/src/domain/metrics.rs
@@ -1,3 +1,5 @@
+#![allow(clippy::manual_non_exhaustive)]
+
#[derive(Debug, Clone)]
pub struct CpuInfo {
pub name: String,
@@ -63,6 +65,42 @@ pub struct ProcessInfo {
pub user_id: Option,
pub state: String,
pub cmd: String,
+
+ // P0 -- Malicious process detection essentials
+ /// Full path to the executable on disk
+ pub exe_path: Option,
+ /// Parent process ID
+ pub parent_pid: Option,
+ /// Full command-line argument vector (argv)
+ pub cmd_full: Vec,
+
+ // P1 -- Timing, privilege, and context
+ /// Process start time as epoch seconds
+ pub start_time: u64,
+ /// Seconds since process started
+ pub run_time: u64,
+ /// Effective user ID (may differ from uid on SUID binaries)
+ pub effective_user_id: Option,
+ /// Group ID
+ pub group_id: Option,
+ /// Process working directory
+ pub cwd: Option,
+ /// Number of threads
+ pub thread_count: u64,
+
+ // P2 -- I/O, environment, session
+ /// Number of open file descriptors
+ pub open_files: u64,
+ /// Max allowed file descriptors
+ pub open_files_limit: u64,
+ /// Total bytes read from disk by this process
+ pub disk_total_read_bytes: u64,
+ /// Total bytes written to disk by this process
+ pub disk_total_write_bytes: u64,
+ /// Environment variables
+ pub environ: Vec,
+ /// Session ID
+ pub session_id: Option,
}
#[derive(Debug, Clone)]
diff --git a/crates/xtop-core/src/domain/mod.rs b/crates/xtop-core/src/domain/mod.rs
index 142ab3d..35bf2ef 100644
--- a/crates/xtop-core/src/domain/mod.rs
+++ b/crates/xtop-core/src/domain/mod.rs
@@ -1,5 +1,6 @@
pub mod keybinding;
pub mod layout;
pub mod metrics;
+pub mod plugin;
pub mod system_info;
pub mod theme;
diff --git a/crates/xtop-core/src/domain/plugin.rs b/crates/xtop-core/src/domain/plugin.rs
new file mode 100644
index 0000000..f832733
--- /dev/null
+++ b/crates/xtop-core/src/domain/plugin.rs
@@ -0,0 +1,216 @@
+use std::fmt::Debug;
+
+use crate::application::state::AppState;
+use crate::domain::metrics::SystemSnapshot;
+use crate::domain::system_info::SystemDataProvider;
+
+/// Unique identifier for a plugin capability.
+/// Used for permission checking and manifest declaration.
+#[derive(Clone, Debug, PartialEq)]
+#[non_exhaustive]
+pub enum PluginCapability {
+ /// Read system metrics (CPU, memory, network, disks, processes)
+ ReadSystemInfo,
+ /// Terminate processes
+ KillProcesses,
+ /// Modify configuration (themes, layouts, alerts, interval)
+ ModifyConfig,
+ /// Register custom widgets in the TUI
+ RenderWidgets,
+ /// Anything not covered above
+ Custom(String),
+}
+
+/// Static metadata about a plugin.
+/// Returned by [`Plugin::manifest`].
+#[derive(Clone, Debug)]
+pub struct PluginManifest {
+ pub id: String,
+ pub name: String,
+ pub version: String,
+ pub description: String,
+ pub capabilities: Vec,
+}
+
+/// Error type for plugin operations.
+#[derive(Debug)]
+pub enum PluginError {
+ /// A recoverable error (e.g. invalid params, resource busy)
+ Recoverable(String),
+ /// A fatal error (plugin should be disabled)
+ Fatal(String),
+ /// Action not understood by this plugin
+ UnknownAction(String),
+}
+
+impl std::fmt::Display for PluginError {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ match self {
+ Self::Recoverable(msg) => write!(f, "{msg}"),
+ Self::Fatal(msg) => write!(f, "FATAL: {msg}"),
+ Self::UnknownAction(action) => write!(f, "unknown action: {action}"),
+ }
+ }
+}
+
+impl std::error::Error for PluginError {}
+
+/// A widget that a plugin registers for rendering in the TUI.
+pub struct WidgetRegistration {
+ pub name: String,
+ pub render: std::sync::Arc,
+}
+
+impl Debug for WidgetRegistration {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ f.debug_struct("WidgetRegistration")
+ .field("name", &self.name)
+ .finish()
+ }
+}
+
+/// Context passed to plugin lifecycle methods.
+/// Provides safe, limited access to application state and plugin-specific directories.
+pub struct PluginContext<'a> {
+ pub(crate) state: &'a mut AppState,
+ pub(crate) plugin_data_dir: std::path::PathBuf,
+ pub(crate) capabilities: Vec,
+}
+
+impl PluginContext<'_> {
+ fn check_capability(&self, cap: &PluginCapability) -> Result<(), PluginError> {
+ if self.capabilities.contains(cap) {
+ Ok(())
+ } else {
+ Err(PluginError::Recoverable(format!(
+ "plugin does not have required capability: {:?}",
+ cap
+ )))
+ }
+ }
+
+ /// Full system snapshot with all available metrics.
+ /// Requires `ReadSystemInfo` capability.
+ pub fn snapshot(&self) -> SystemSnapshot {
+ self.state.snapshot()
+ }
+
+ /// The top N processes sorted by CPU usage.
+ /// Requires `ReadSystemInfo` capability.
+ pub fn top_processes(&self, n: usize) -> Vec {
+ let snap = self.snapshot();
+ snap.processes.into_iter().take(n).collect()
+ }
+
+ /// Kill a process by PID. Returns true if the signal was sent.
+ /// Requires `KillProcesses` capability.
+ pub fn kill_process(&mut self, pid: u32) -> Result {
+ self.check_capability(&PluginCapability::KillProcesses)?;
+ Ok(self.state.kill_process_by_pid(pid))
+ }
+
+ /// Set alert thresholds for CPU, memory, and disk.
+ /// Requires `ModifyConfig` capability.
+ pub fn set_alert_thresholds(&mut self, cpu: f64, mem: f64, disk: f64) -> Result<(), PluginError> {
+ self.check_capability(&PluginCapability::ModifyConfig)?;
+ self.state.set_alert_thresholds(cpu, mem, disk);
+ Ok(())
+ }
+
+ /// Switch to a theme by name. Returns true if found.
+ /// Requires `ModifyConfig` capability.
+ pub fn set_theme_by_name(&mut self, name: &str) -> Result {
+ self.check_capability(&PluginCapability::ModifyConfig)?;
+ Ok(self.state.set_theme_by_name(name))
+ }
+
+ /// Switch to a layout by name. Returns true if found.
+ /// Requires `ModifyConfig` capability.
+ pub fn set_layout_by_name(&mut self, name: &str) -> Result {
+ self.check_capability(&PluginCapability::ModifyConfig)?;
+ Ok(self.state.set_layout_by_name(name))
+ }
+
+ /// Set the update interval in milliseconds.
+ /// Requires `ModifyConfig` capability.
+ pub fn set_update_interval(&mut self, ms: u64) -> Result<(), PluginError> {
+ self.check_capability(&PluginCapability::ModifyConfig)?;
+ self.state.update_interval_ms = ms;
+ Ok(())
+ }
+
+ /// Current system info (hostname, OS, kernel).
+ /// Requires `ReadSystemInfo` capability.
+ pub fn system_info(&self) -> crate::domain::metrics::SystemInfo {
+ self.state.sys_info.clone()
+ }
+
+ /// Plugin-specific data directory (`~/.config/xtop/plugins//`).
+ pub fn data_dir(&self) -> &std::path::Path {
+ &self.plugin_data_dir
+ }
+
+ /// Current AppState read-only snapshot for widget rendering data.
+ /// Requires `ReadSystemInfo` capability.
+ pub fn state(&self) -> &AppState {
+ self.state
+ }
+}
+
+/// The core trait that every plugin must implement.
+///
+/// All methods have default empty implementations so plugins only
+/// override what they need.
+pub trait Plugin: Debug + Send {
+ /// Static metadata about this plugin.
+ fn manifest(&self) -> PluginManifest;
+
+ /// Called once when the plugin is loaded and enabled.
+ fn on_enable(&mut self, _ctx: &mut PluginContext) -> Result<(), PluginError> {
+ Ok(())
+ }
+
+ /// Called once when the plugin is disabled or xtop shuts down.
+ fn on_disable(&mut self, _ctx: &mut PluginContext) -> Result<(), PluginError> {
+ Ok(())
+ }
+
+ /// Called on every tick (every ~1s by default).
+ fn on_tick(&mut self, _ctx: &mut PluginContext) -> Result<(), PluginError> {
+ Ok(())
+ }
+
+ /// Called when a key is pressed.
+ /// Return `Ok(true)` if the plugin consumed the key event.
+ fn on_key(
+ &mut self,
+ _ctx: &mut PluginContext,
+ _key: &str,
+ ) -> Result {
+ Ok(false)
+ }
+
+ /// Optionally provide additional system data.
+ /// The returned provider is merged into the main data stream via CompositeProvider.
+ fn data_provider(&self) -> Option> {
+ None
+ }
+
+ /// Optionally register a custom widget for TUI rendering.
+ fn widget(&self) -> Option {
+ None
+ }
+
+ /// Execute a named command with string parameters.
+ /// Used by external agents (AI, CLI, IPC) to interact with the plugin.
+ ///
+ /// Returns a JSON-like string response.
+ fn execute(
+ &mut self,
+ _ctx: &mut PluginContext,
+ _action: &str,
+ _params: &str,
+ ) -> Result {
+ Err(PluginError::UnknownAction(_action.to_string()))
+ }
+}
diff --git a/crates/xtop-core/src/domain/system_info.rs b/crates/xtop-core/src/domain/system_info.rs
index 5fb1bc3..6f03984 100644
--- a/crates/xtop-core/src/domain/system_info.rs
+++ b/crates/xtop-core/src/domain/system_info.rs
@@ -21,4 +21,12 @@ pub trait SystemDataProvider: Send {
fn kill_process(&self, _pid: u32) -> bool {
false
}
+
+ /// Downcast to `Any` for internal provider composition.
+ fn as_any(&self) -> &dyn std::any::Any;
+ fn as_any_mut(&mut self) -> &mut dyn std::any::Any;
+
+ /// Add extra data providers (used by CompositeProvider).
+ /// Default no-op implementation for non-composite providers.
+ fn add_extras(&mut self, _extras: Vec>) {}
}
diff --git a/crates/xtop-core/src/infrastructure/battery_provider.rs b/crates/xtop-core/src/infrastructure/battery_provider.rs
deleted file mode 100644
index a9f5f1d..0000000
--- a/crates/xtop-core/src/infrastructure/battery_provider.rs
+++ /dev/null
@@ -1,26 +0,0 @@
-use crate::domain::metrics::BatteryInfo;
-use crate::domain::system_info::SystemDataProvider;
-
-pub struct NoopBatteryProvider;
-
-impl Default for NoopBatteryProvider {
- fn default() -> Self {
- Self
- }
-}
-
-impl NoopBatteryProvider {
- pub fn new() -> Self {
- Self
- }
-}
-
-impl SystemDataProvider for NoopBatteryProvider {
- fn refresh_all(&mut self) {}
- fn snapshot(&self) -> crate::domain::metrics::SystemSnapshot {
- unimplemented!("NoopBatteryProvider is meant as a mixin, not a standalone provider")
- }
- fn batteries(&self) -> Vec {
- vec![]
- }
-}
diff --git a/crates/xtop-core/src/infrastructure/composite_provider.rs b/crates/xtop-core/src/infrastructure/composite_provider.rs
new file mode 100644
index 0000000..cbfe338
--- /dev/null
+++ b/crates/xtop-core/src/infrastructure/composite_provider.rs
@@ -0,0 +1,122 @@
+use crate::domain::metrics::*;
+use crate::domain::system_info::SystemDataProvider;
+
+/// A `SystemDataProvider` that composes a primary provider with plugin-provided extras.
+///
+/// The primary provider (usually `SysinfoProvider`) handles `refresh_all()` and `snapshot()`.
+/// Extra providers (from plugins) override specific methods like `gpu_info()`, `batteries()`,
+/// `docker_info()`, `disk_io()`, or `system_info()`.
+///
+/// This allows plugins to inject data without modifying the primary provider.
+pub struct CompositeProvider {
+ primary: Box,
+ extras: Vec>,
+}
+
+impl CompositeProvider {
+ pub fn new(primary: Box) -> Self {
+ Self {
+ primary,
+ extras: Vec::new(),
+ }
+ }
+
+ /// Get the first non-empty result from extras for a method,
+ /// falling back to a fallback closure on the primary.
+ fn first_non_empty(
+ &self,
+ primary_fn: impl FnOnce() -> Vec,
+ extra_fn: impl Fn(&dyn SystemDataProvider) -> Vec,
+ ) -> Vec {
+ let primary_val = primary_fn();
+ if !primary_val.is_empty() {
+ return primary_val;
+ }
+ for extra in &self.extras {
+ let val = extra_fn(extra.as_ref());
+ if !val.is_empty() {
+ return val;
+ }
+ }
+ primary_val
+ }
+}
+
+impl SystemDataProvider for CompositeProvider {
+ fn refresh_all(&mut self) {
+ self.primary.refresh_all();
+ for extra in &mut self.extras {
+ extra.refresh_all();
+ }
+ }
+
+ fn snapshot(&self) -> SystemSnapshot {
+ self.primary.snapshot()
+ }
+
+ fn disk_io(&self) -> Vec {
+ self.first_non_empty(
+ || self.primary.disk_io(),
+ |e| e.disk_io(),
+ )
+ }
+
+ fn batteries(&self) -> Vec {
+ self.first_non_empty(
+ || self.primary.batteries(),
+ |e| e.batteries(),
+ )
+ }
+
+ fn gpu_info(&self) -> Vec {
+ self.first_non_empty(
+ || self.primary.gpu_info(),
+ |e| e.gpu_info(),
+ )
+ }
+
+ fn docker_info(&self) -> Vec {
+ self.first_non_empty(
+ || self.primary.docker_info(),
+ |e| e.docker_info(),
+ )
+ }
+
+ fn system_info(&self) -> SystemInfo {
+ let primary = self.primary.system_info();
+ if !primary.hostname.is_empty() {
+ return primary;
+ }
+ for extra in &self.extras {
+ let val = extra.system_info();
+ if !val.hostname.is_empty() {
+ return val;
+ }
+ }
+ primary
+ }
+
+ fn kill_process(&self, pid: u32) -> bool {
+ if self.primary.kill_process(pid) {
+ return true;
+ }
+ for extra in &self.extras {
+ if extra.kill_process(pid) {
+ return true;
+ }
+ }
+ false
+ }
+
+ fn as_any(&self) -> &dyn std::any::Any {
+ self
+ }
+
+ fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
+ self
+ }
+
+ fn add_extras(&mut self, extras: Vec>) {
+ self.extras = extras;
+ }
+}
diff --git a/crates/xtop-core/src/infrastructure/config.rs b/crates/xtop-core/src/infrastructure/config.rs
index d869d9e..5eaf243 100644
--- a/crates/xtop-core/src/infrastructure/config.rs
+++ b/crates/xtop-core/src/infrastructure/config.rs
@@ -2,7 +2,7 @@ use crate::application::state::Config;
use std::fs;
use std::path::PathBuf;
-fn config_dir() -> PathBuf {
+pub fn config_dir() -> PathBuf {
if let Ok(xdg) = std::env::var("XDG_CONFIG_HOME") {
PathBuf::from(xdg).join("xtop")
} else if let Ok(home) = std::env::var("HOME") {
diff --git a/crates/xtop-core/src/infrastructure/docker_provider.rs b/crates/xtop-core/src/infrastructure/docker_provider.rs
deleted file mode 100644
index 43fb234..0000000
--- a/crates/xtop-core/src/infrastructure/docker_provider.rs
+++ /dev/null
@@ -1,26 +0,0 @@
-use crate::domain::metrics::DockerInfo;
-use crate::domain::system_info::SystemDataProvider;
-
-pub struct NoopDockerProvider;
-
-impl Default for NoopDockerProvider {
- fn default() -> Self {
- Self
- }
-}
-
-impl NoopDockerProvider {
- pub fn new() -> Self {
- Self
- }
-}
-
-impl SystemDataProvider for NoopDockerProvider {
- fn refresh_all(&mut self) {}
- fn snapshot(&self) -> crate::domain::metrics::SystemSnapshot {
- unimplemented!("NoopDockerProvider is meant as a mixin, not a standalone provider")
- }
- fn docker_info(&self) -> Vec {
- vec![]
- }
-}
diff --git a/crates/xtop-core/src/infrastructure/gpu_provider.rs b/crates/xtop-core/src/infrastructure/gpu_provider.rs
deleted file mode 100644
index 6c52d8e..0000000
--- a/crates/xtop-core/src/infrastructure/gpu_provider.rs
+++ /dev/null
@@ -1,26 +0,0 @@
-use crate::domain::metrics::GpuInfo;
-use crate::domain::system_info::SystemDataProvider;
-
-pub struct NoopGpuProvider;
-
-impl Default for NoopGpuProvider {
- fn default() -> Self {
- Self
- }
-}
-
-impl NoopGpuProvider {
- pub fn new() -> Self {
- Self
- }
-}
-
-impl SystemDataProvider for NoopGpuProvider {
- fn refresh_all(&mut self) {}
- fn snapshot(&self) -> crate::domain::metrics::SystemSnapshot {
- unimplemented!("NoopGpuProvider is meant as a mixin, not a standalone provider")
- }
- fn gpu_info(&self) -> Vec {
- vec![]
- }
-}
diff --git a/crates/xtop-core/src/infrastructure/mod.rs b/crates/xtop-core/src/infrastructure/mod.rs
index d0bdbbc..d6c12a1 100644
--- a/crates/xtop-core/src/infrastructure/mod.rs
+++ b/crates/xtop-core/src/infrastructure/mod.rs
@@ -1,7 +1,5 @@
-pub mod battery_provider;
+pub mod composite_provider;
pub mod config;
-pub mod docker_provider;
-pub mod gpu_provider;
pub mod layout_loader;
pub mod sysinfo_provider;
pub mod theme_loader;
diff --git a/crates/xtop-core/src/infrastructure/sysinfo_provider.rs b/crates/xtop-core/src/infrastructure/sysinfo_provider.rs
index f3ae44c..ce2648e 100644
--- a/crates/xtop-core/src/infrastructure/sysinfo_provider.rs
+++ b/crates/xtop-core/src/infrastructure/sysinfo_provider.rs
@@ -7,6 +7,8 @@ use sysinfo::{
RefreshKind, Signal, System,
};
+pub const DEFAULT_MAX_PROCESSES: usize = 200;
+
pub struct SysinfoProvider {
sys: System,
disks: Disks,
@@ -18,6 +20,7 @@ pub struct SysinfoProvider {
prev_net_tx: HashMap,
last_refresh: Instant,
cached_sys_info: SystemInfo,
+ max_processes: usize,
}
impl Default for SysinfoProvider {
@@ -56,6 +59,7 @@ impl SysinfoProvider {
prev_net_tx: HashMap::new(),
last_refresh: Instant::now(),
cached_sys_info: info,
+ max_processes: DEFAULT_MAX_PROCESSES,
}
}
}
@@ -176,18 +180,48 @@ impl SystemDataProvider for SysinfoProvider {
})
.collect();
+ let now = std::time::SystemTime::now()
+ .duration_since(std::time::UNIX_EPOCH)
+ .unwrap_or_default()
+ .as_secs();
+
let mut procs: Vec = self
.sys
.processes()
.iter()
- .map(|(pid, p)| ProcessInfo {
- pid: pid.as_u32(),
- name: p.name().to_string_lossy().to_string(),
- cpu_usage: p.cpu_usage() as f64,
- memory: p.memory(),
- user_id: p.user_id().map(|u| u.to_string()),
- state: format!("{:?}", p.status()),
- cmd: p.cmd().first().map(|c| c.to_string_lossy().to_string()).unwrap_or_default(),
+ .map(|(pid, p)| {
+ let start = p.start_time();
+ let run = if start > 0 { now.saturating_sub(start) } else { 0 };
+ ProcessInfo {
+ pid: pid.as_u32(),
+ name: p.name().to_string_lossy().to_string(),
+ cpu_usage: p.cpu_usage() as f64,
+ memory: p.memory(),
+ user_id: p.user_id().map(|u| u.to_string()),
+ state: format!("{:?}", p.status()),
+ cmd: p.cmd().first().map(|c| c.to_string_lossy().to_string()).unwrap_or_default(),
+
+ // P0
+ exe_path: p.exe().map(|e| e.to_string_lossy().to_string()),
+ parent_pid: p.parent().map(|ppid| ppid.as_u32()),
+ cmd_full: p.cmd().iter().map(|c| c.to_string_lossy().to_string()).collect(),
+
+ // P1
+ start_time: start,
+ run_time: run,
+ effective_user_id: p.effective_user_id().map(|u| u.to_string()),
+ group_id: p.group_id().map(|g| g.to_string()),
+ cwd: p.cwd().map(|c| c.to_string_lossy().to_string()),
+ thread_count: read_thread_count(p.pid()),
+
+ // P2
+ open_files: p.open_files().unwrap_or(0) as u64,
+ open_files_limit: p.open_files_limit().unwrap_or(0) as u64,
+ disk_total_read_bytes: p.disk_usage().total_read_bytes,
+ disk_total_write_bytes: p.disk_usage().total_written_bytes,
+ environ: p.environ().iter().map(|e| e.to_string_lossy().to_string()).collect(),
+ session_id: p.session_id().map(|s| s.as_u32()),
+ }
})
.collect();
procs.sort_by(|a, b| {
@@ -195,7 +229,7 @@ impl SystemDataProvider for SysinfoProvider {
.partial_cmp(&a.cpu_usage)
.unwrap_or(std::cmp::Ordering::Equal)
});
- procs.truncate(200);
+ procs.truncate(self.max_processes);
let mut max_temp = 0.0f32;
for component in &self.components {
@@ -240,7 +274,19 @@ impl SystemDataProvider for SysinfoProvider {
fn kill_process(&self, pid: u32) -> bool {
if let Some(process) = self.sys.process(Pid::from(pid as usize)) {
- process.kill_with(Signal::Term).unwrap_or(false)
+ // Only allow killing processes owned by the same user (safety check)
+ let current_uid = self
+ .sys
+ .process(sysinfo::get_current_pid().unwrap_or(Pid::from(0)))
+ .and_then(|p| p.user_id());
+ let target_uid = process.user_id();
+ match (current_uid, target_uid) {
+ (Some(current), Some(target)) if current == target => {
+ process.kill_with(Signal::Term).unwrap_or(false)
+ }
+ (Some(_), Some(_)) => false,
+ _ => process.kill_with(Signal::Term).unwrap_or(false),
+ }
} else {
false
}
@@ -253,6 +299,14 @@ impl SystemDataProvider for SysinfoProvider {
fn gpu_info(&self) -> Vec {
read_gpu_info()
}
+
+ fn as_any(&self) -> &dyn std::any::Any {
+ self
+ }
+
+ fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
+ self
+ }
}
impl SysinfoProvider {
@@ -538,6 +592,31 @@ fn find_hwmon_temp(device_path: &std::path::Path, label_filter: &str) -> Option<
}
#[cfg(not(target_os = "linux"))]
+#[allow(dead_code)]
fn find_hwmon_temp(_device_path: &std::path::Path, _label_filter: &str) -> Option {
None
}
+
+// ---------------------------------------------------------------------------
+// Thread count helper
+// ---------------------------------------------------------------------------
+
+#[cfg(target_os = "linux")]
+fn read_thread_count(pid: sysinfo::Pid) -> u64 {
+ use std::fs;
+ let path = format!("/proc/{}/status", pid);
+ if let Ok(content) = fs::read_to_string(&path) {
+ for line in content.lines() {
+ if let Some(rest) = line.strip_prefix("Threads:\t") {
+ return rest.trim().parse::().unwrap_or(0);
+ }
+ }
+ }
+ 0
+}
+
+#[cfg(not(target_os = "linux"))]
+fn read_thread_count(_pid: sysinfo::Pid) -> u64 {
+ // Fallback: use tasks count from sysinfo (available on some platforms)
+ 0
+}
diff --git a/crates/xtop-tui/src/render/layout_engine.rs b/crates/xtop-tui/src/render/layout_engine.rs
index de723c5..1113bb0 100644
--- a/crates/xtop-tui/src/render/layout_engine.rs
+++ b/crates/xtop-tui/src/render/layout_engine.rs
@@ -2,33 +2,41 @@ use crate::render::{battery, cpu, disk_io, gpu, header, memory, network, process
use ratatui::layout::{Constraint, Layout, Rect};
use ratatui::Frame;
use std::collections::HashMap;
-use xtop_core::domain::layout::{Direction, LayoutArea, LayoutDef, LayoutNode};
+use std::sync::Arc;
use xtop_core::application::state::AppState;
+use xtop_core::domain::layout::{Direction, LayoutArea, LayoutDef, LayoutNode};
-pub type WidgetRenderer = fn(&mut Frame, &AppState, Rect);
+/// A widget renderer: a callable that draws a widget onto the terminal.
+pub type WidgetFn = Arc;
-pub fn default_widgets() -> HashMap<&'static str, WidgetRenderer> {
- let mut m: HashMap<&'static str, WidgetRenderer> = HashMap::new();
- m.insert("header", header::render);
- m.insert("cpu", cpu::render);
- m.insert("memory", memory::render);
- m.insert("storage", storage::render);
- m.insert("network", network::render);
- m.insert("processes", processes::render);
- m.insert("disk_io", disk_io::render);
- m.insert("battery", battery::render);
- m.insert("gpu", gpu::render);
+/// Create the default built-in widget map.
+pub fn default_widgets() -> HashMap<&'static str, WidgetFn> {
+ let mut m: HashMap<&'static str, WidgetFn> = HashMap::new();
+ m.insert("header", Arc::new(header::render));
+ m.insert("cpu", Arc::new(cpu::render));
+ m.insert("memory", Arc::new(memory::render));
+ m.insert("storage", Arc::new(storage::render));
+ m.insert("network", Arc::new(network::render));
+ m.insert("processes", Arc::new(processes::render));
+ m.insert("disk_io", Arc::new(disk_io::render));
+ m.insert("battery", Arc::new(battery::render));
+ m.insert("gpu", Arc::new(gpu::render));
m
}
+/// Render a layout definition within a given area.
+///
+/// `widgets` is the built-in registry. `plugin_widgets` is an optional
+/// extension from plugins. Plugin widgets take precedence over built-ins.
pub fn render_layout(
f: &mut Frame,
state: &AppState,
area: Rect,
def: &LayoutDef,
- widgets: &HashMap<&'static str, WidgetRenderer>,
+ widgets: &HashMap<&'static str, WidgetFn>,
+ plugin_widgets: &HashMap,
) {
- render_node(f, state, area, &def.root, widgets);
+ render_node(f, state, area, &def.root, widgets, plugin_widgets);
}
fn render_node(
@@ -36,13 +44,18 @@ fn render_node(
state: &AppState,
area: Rect,
node: &LayoutNode,
- widgets: &HashMap<&'static str, WidgetRenderer>,
+ widgets: &HashMap<&'static str, WidgetFn>,
+ plugin_widgets: &HashMap,
) {
match node {
LayoutNode::Widget { name } => {
- if let Some(render_fn) = widgets.get(name.as_str()) {
+ // Plugin widgets take precedence
+ if let Some(render_fn) = plugin_widgets.get(name) {
+ render_fn(f, state, area);
+ } else if let Some(render_fn) = widgets.get(name.as_str()) {
render_fn(f, state, area);
}
+ // Unknown widgets are silently ignored (backward-compatible)
}
LayoutNode::Split { direction, areas } => {
if areas.is_empty() {
@@ -59,7 +72,7 @@ fn render_node(
.split(area);
for (i, chunk) in chunks.iter().enumerate() {
if i < areas.len() {
- render_node(f, state, *chunk, &areas[i].node, widgets);
+ render_node(f, state, *chunk, &areas[i].node, widgets, plugin_widgets);
}
}
}
@@ -70,6 +83,6 @@ fn to_ratatui_constraint(area: &LayoutArea) -> Constraint {
match area.constraint {
xtop_core::domain::layout::LayoutConstraint::Length(n) => Constraint::Length(n),
xtop_core::domain::layout::LayoutConstraint::Percentage(p) => Constraint::Percentage(p),
- xtop_core::domain::layout::LayoutConstraint::Fill => Constraint::Min(0),
+ xtop_core::domain::layout::LayoutConstraint::Fill => Constraint::Fill(1),
}
}
diff --git a/crates/xtop-tui/src/render/mod.rs b/crates/xtop-tui/src/render/mod.rs
index 5692bc1..f98841c 100644
--- a/crates/xtop-tui/src/render/mod.rs
+++ b/crates/xtop-tui/src/render/mod.rs
@@ -13,7 +13,7 @@ mod storage;
mod layout_engine;
use crate::color::to_color;
-use layout_engine::{default_widgets, render_layout, WidgetRenderer};
+use layout_engine::{default_widgets, render_layout, WidgetFn};
use ratatui::prelude::*;
use ratatui::Frame;
use std::collections::HashMap;
@@ -22,11 +22,21 @@ use xtop_core::application::state::{
detect_effective_layout, AppState, EffectiveLayout, FullScreenWidget, InputMode,
};
-fn widgets() -> &'static HashMap<&'static str, WidgetRenderer> {
- static WIDGETS: OnceLock> = OnceLock::new();
+/// Built-in widgets (lazily initialized).
+fn widgets() -> &'static HashMap<&'static str, WidgetFn> {
+ static WIDGETS: OnceLock> = OnceLock::new();
WIDGETS.get_or_init(default_widgets)
}
+/// Build a plugin widget lookup map from AppState.
+fn plugin_widgets(state: &AppState) -> HashMap {
+ let mut map: HashMap = HashMap::new();
+ for reg in &state.plugin_widgets {
+ map.insert(reg.name.clone(), reg.render.clone());
+ }
+ map
+}
+
pub fn render(f: &mut Frame, state: &AppState) {
let area = f.area();
@@ -46,11 +56,13 @@ pub fn render(f: &mut Frame, state: &AppState) {
}
let mode = detect_effective_layout(area.width, area.height, state.layout_mode);
+ let pw = plugin_widgets(state);
+
if mode == EffectiveLayout::Minimal {
render_minimal(f, state, area);
} else {
let def = state.current_layout();
- render_layout(f, state, area, def, widgets());
+ render_layout(f, state, area, def, widgets(), &pw);
}
if state.input_mode == InputMode::Searching {
@@ -109,6 +121,7 @@ fn render_fullscreen(f: &mut Frame, state: &AppState, area: Rect) {
fn render_minimal(f: &mut Frame, state: &AppState, area: Rect) {
use ratatui::widgets::Gauge;
+
let bg = to_color(state.current_theme.bg());
let chunks = Layout::default()
diff --git a/docs/configuration.md b/docs/configuration.md
new file mode 100644
index 0000000..261bf2f
--- /dev/null
+++ b/docs/configuration.md
@@ -0,0 +1,89 @@
+Configuration
+
+
+
+Config File
+
+xtop automatically saves its configuration on quit. The configuration file is located at:
+
+~/.config/xtop/config.json
+
+
+
+Persisted Settings
+
+
+
+
+ | Setting |
+ Description |
+
+
+
+
+ theme |
+ Currently selected color theme name |
+
+
+ layout |
+ Currently selected layout mode |
+
+
+ interval |
+ Update interval in milliseconds |
+
+
+ history_points |
+ Number of data points retained for the RAM history chart |
+
+
+ alert_thresholds |
+ Threshold values for CPU, memory, and disk alerts |
+
+
+
+
+
+
+Alert Thresholds
+
+When a metric exceeds its configured threshold, the corresponding widget changes color to red and displays a warning indicator in its title.
+
+
+
+
+ | Threshold |
+ Description |
+ Default |
+
+
+
+
+ cpu_high |
+ CPU usage percentage that triggers a warning |
+ 90% |
+
+
+ mem_high |
+ Memory usage percentage that triggers a warning |
+ 90% |
+
+
+ disk_high |
+ Disk usage percentage that triggers a warning |
+ 90% |
+
+
+
+
+
+
+Custom Themes and Layouts
+
+For custom themes and layouts, see the customization guide.
+
+
+
+
+ Back to README
+
diff --git a/docs/customization.md b/docs/customization.md
index 14f74fb..e93ff7d 100644
--- a/docs/customization.md
+++ b/docs/customization.md
@@ -2,7 +2,7 @@
xtop supports runtime customization of color themes and layout modes via external JSONC files. This guide explains how to create and manage your own themes and layouts.
----
+
Table of Contents
@@ -31,7 +31,7 @@
----
+
Themes
@@ -49,8 +49,7 @@
Each theme file defines a name and a 16-entry palette. Colors are hex strings with an optional # prefix. Comments (// and /* */) are supported in JSONC files.
-```jsonc
-{
+{
// my-custom-theme -- Dark background, warm accents
"name": "my-custom-theme",
"palette": [
@@ -71,8 +70,7 @@
"#56b6c2", // 14: bright cyan
"#abb2bf" // 15: bright white
]
-}
-```
+}
Palette Reference
@@ -176,9 +174,7 @@
If you want to restore them later, copy from the repository:
-```bash
-cp -r assets/themes/* ~/.config/xtop/themes/
-```
+cp -r assets/themes/* ~/.config/xtop/themes/
Available themes: x, madrid, lahabana, paris, tokio, oslo, helsinki, berlin, london, praha, bogota, miami.
@@ -200,7 +196,7 @@ cp -r assets/themes/* ~/.config/xtop/themes/
Index 0 is the background, index 7 is the primary foreground — keep them readable together.
----
+
Layouts
@@ -306,8 +302,7 @@ cp -r assets/themes/* ~/.config/xtop/themes/
A minimal three-row layout: header, CPU, and processes.
-```jsonc
-{
+{
// "monitor" — CPU top-half, processes bottom-half
"name": "monitor",
"root": {
@@ -318,15 +313,13 @@ cp -r assets/themes/* ~/.config/xtop/themes/
{ "widget": "processes", "size": "*" }
]
}
-}
-```
+}
Complex nested layout
A full dashboard with a horizontal split in the middle section:
-```jsonc
-{
+{
"name": "my-dashboard",
"root": {
"direction": "vertical",
@@ -350,8 +343,7 @@ cp -r assets/themes/* ~/.config/xtop/themes/
{ "widget": "processes", "size": "*" }
]
}
-}
-```
+}
Starter Layouts
@@ -359,9 +351,7 @@ cp -r assets/themes/* ~/.config/xtop/themes/
To restore them later, copy from the repository:
-```bash
-cp -r assets/layouts/* ~/.config/xtop/layouts/
-```
+cp -r assets/layouts/* ~/.config/xtop/layouts/
Available layouts: dashboard, vertical, horizontal, cpu_focus, memory_focus, network_focus, process_focus.
@@ -384,7 +374,7 @@ cp -r assets/layouts/* ~/.config/xtop/layouts/
Very small terminals (under 60×14) fall back to a minimal hardcoded layout (CPU + Memory gauges + process list).
----
+
← Back to README
diff --git a/docs/features.md b/docs/features.md
new file mode 100644
index 0000000..18f3c08
--- /dev/null
+++ b/docs/features.md
@@ -0,0 +1,112 @@
+
Features
+
+Detailed breakdown of xtop's monitoring and interface capabilities.
+
+
+
+System Monitoring
+
+CPU
+
+
+ - Usage percentage per core and per thread, displayed as horizontal gauges.
+ - Maximum CPU temperature reading when hardware sensors are available.
+ - Color-coded bars that visually indicate load levels.
+
+
+Memory
+
+
+ - RAM usage gauge showing used, total, and percentage.
+ - Swap usage gauge.
+ - Historical line chart tracking RAM usage over time.
+ - Configurable number of history data points.
+
+
+Network
+
+
+ - Real-time upload (TX) and download (RX) tracking per network interface.
+ - Total data transferred displayed alongside current transfer speeds.
+
+
+Storage
+
+
+ - Disk usage gauges per mount point showing used, available, and total space.
+ - Visual percentage bars for each mounted filesystem.
+
+
+Disk I/O
+
+
+ - Read and write speed tracking per disk device.
+ - Displayed in bytes per second with automatic unit scaling.
+
+
+Processes
+
+
+ - Scrolling list of running processes sorted by CPU usage.
+ - Live search filtering by process name.
+ - Displays process name, CPU usage, and memory usage.
+
+
+GPU
+
+
+ - GPU usage gauges (stub implementation, ready for NVIDIA/AMD support).
+
+
+Battery
+
+
+ - Battery charge level gauges (stub implementation, ready for laptop support).
+
+
+
+
+Theming
+
+
+ - 13 ready-to-use color schemes built into the binary.
+ - Custom themes defined as JSONC files with a 16-entry hex color palette.
+ - Instant theme cycling with t (next) and T (previous).
+ - Starter theme files ship in the
assets/themes/ directory.
+
+
+
+
+Layouts
+
+
+ - 7 built-in layout modes: Dashboard, Vertical, Horizontal, CPU Focus, Memory Focus, Network Focus, Process Focus.
+ - Custom layouts defined as JSONC files with a recursive split/widget tree.
+ - Full-screen mode for any widget toggled with f.
+ - Responsive design that adapts to terminal width and height automatically.
+ - Minimal fallback layout for very small terminals.
+
+
+
+
+Alert Thresholds
+
+
+ - Visual warnings when CPU, memory, or disk usage exceeds configurable limits.
+ - Color changes to red and warning indicators in widget titles.
+
+
+
+
+Persistence
+
+
+ - Current theme, layout mode, update interval, history points, and alert thresholds are saved automatically on quit.
+ - Configuration is stored at
~/.config/xtop/config.json.
+
+
+
+
+
+ Back to README
+
diff --git a/docs/installation.md b/docs/installation.md
new file mode 100644
index 0000000..5cd063e
--- /dev/null
+++ b/docs/installation.md
@@ -0,0 +1,95 @@
+Installation Guide
+
+
+
+Table of Contents
+
+
+
+
+
+Quick Install (macOS/Linux)
+
+The installer script automatically detects your distribution and installs all required dependencies, including Rust if needed.
+
+Using curl
+
+curl -fsSL https://raw.githubusercontent.com/xscriptor/xtop/main/install.sh | bash
+
+Using wget
+
+wget -qO- https://raw.githubusercontent.com/xscriptor/xtop/main/install.sh | bash
+
+
+
+Quick Install (Windows)
+
+Requires Rust (Cargo) to be installed. Run in PowerShell:
+
+irm https://raw.githubusercontent.com/xscriptor/xtop/main/install.ps1 | iex
+
+
+
+Installer Options
+
+You can run the installer script with additional flags for more control:
+
+# Check dependencies without installing
+./install.sh --check-deps
+
+# Install only dependencies (Rust, build tools)
+./install.sh --install-deps
+
+# Show help
+./install.sh --help
+
+Supported Distributions
+
+
+ - Arch Linux and derivatives
+ - Debian / Ubuntu and derivatives
+ - Fedora / RHEL and derivatives
+ - openSUSE and derivatives
+ - Alpine Linux and derivatives
+
+
+
+
+Build from Source
+
+
+ -
+
Clone the repository:
+ git clone https://github.com/xscriptor/xtop.git
+cd xtop
+
+ -
+
Build and run with release optimizations:
+ cargo run --release
+
+
+
+
+
+Uninstall
+
+macOS / Linux
+
+curl -fsSL https://raw.githubusercontent.com/xscriptor/xtop/main/install.sh | bash -s -- --uninstall
+
+Windows
+
+irm https://raw.githubusercontent.com/xscriptor/xtop/main/uninstall.ps1 | iex
+
+
+
+
+ Back to README
+
diff --git a/docs/plugin.md b/docs/plugin.md
new file mode 100644
index 0000000..7612148
--- /dev/null
+++ b/docs/plugin.md
@@ -0,0 +1,282 @@
+Plugin System
+
+xtop has a compile-time plugin system based on Rust feature flags. Plugins live in the plugins/ directory and are registered into the workspace as optional dependencies.
+
+You can build xtop without any plugins:
+
+cargo build --release --no-default-features
+
+Or with a specific set of plugins:
+
+cargo build --release --features plugin-sentinel
+
+
+
+Architecture
+
+The plugin system has four main components:
+
+
+
+ | Component | Location | Purpose |
+
+
+
+ Plugin trait |
+ xtop-core::domain::plugin |
+ Interface every plugin must implement |
+
+
+ PluginManager |
+ xtop-core::application::plugin_manager |
+ Loads, ticks, and dispatches events to plugins |
+
+
+ CompositeProvider |
+ xtop-core::infrastructure::composite_provider |
+ Merges data from primary provider + plugin providers |
+
+
+ WidgetRegistration |
+ xtop-core::domain::plugin |
+ Allows plugins to register custom TUI widgets |
+
+
+
+
+
+
+The Plugin Trait
+
+pub trait Plugin: Debug + Send {
+ fn manifest(&self) -> PluginManifest;
+ fn on_enable(&mut self, ctx: &mut PluginContext) -> Result<(), PluginError>;
+ fn on_disable(&mut self, ctx: &mut PluginContext) -> Result<(), PluginError>;
+ fn on_tick(&mut self, ctx: &mut PluginContext) -> Result<(), PluginError>;
+ fn on_key(&mut self, ctx: &mut PluginContext, key: &str) -> Result<bool, PluginError>;
+ fn data_provider(&self) -> Option<Box<dyn SystemDataProvider>>;
+ fn widget(&self) -> Option<WidgetRegistration>;
+ fn execute(&mut self, ctx: &mut PluginContext, action: &str, params: &str) -> Result<String, PluginError>;
+}
+
+
+
+ | Method | Default | Called When |
+
+
+ manifest() | required | Any time metadata is needed |
+ on_enable() | no-op | Plugin is registered at startup |
+ on_disable() | no-op | xtop shuts down |
+ on_tick() | no-op | Every update cycle (~1s) |
+ on_key() | returns false | Key press (consumes if returns true) |
+ data_provider() | None | Startup (merged into CompositeProvider) |
+ widget() | None | Every tick (refreshes widget registry) |
+ execute() | UnknownAction | External agent (AI, CLI, IPC) invokes command |
+
+
+
+PluginCapability
+
+Each plugin declares what it needs via manifest().capabilities:
+
+
+ ReadSystemInfo -- access system metrics
+ KillProcesses -- terminate processes
+ ModifyConfig -- change themes, layouts, thresholds
+ RenderWidgets -- register custom TUI widgets
+ Custom(&str) -- anything not covered above
+
+
+PluginContext
+
+Safe, limited access to application state:
+
+ctx.snapshot() // Full SystemSnapshot
+ctx.top_processes(n) // Top N processes by CPU
+ctx.kill_process(pid) // Kill process by PID
+ctx.set_alert_thresholds(cpu, mem, disk)
+ctx.set_theme_by_name("tokio")
+ctx.set_layout_by_name("Dashboard")
+ctx.set_update_interval(500)
+ctx.system_info() // Hostname, OS, kernel
+ctx.data_dir() // ~/.config/xtop/plugins/<id>/
+
+
+
+CompositeProvider
+
+The CompositeProvider wraps the primary SysinfoProvider and merges data from plugin providers:
+
+
+ refresh_all() refreshes all providers
+ snapshot() delegates to primary for CPU, memory, disks, networks, processes
+ gpu_info(), batteries(), docker_info() check extras if primary returns empty
+ kill_process() tries primary first, then extras
+
+
+
+
+
+
+Plugins can register custom widgets via widget():
+
+fn widget(&self) -> Option<WidgetRegistration> {
+ Some(WidgetRegistration {
+ name: "sentinel".to_string(),
+ render: Arc::new(|f, state, area| {
+ // Draw using ratatui
+ }),
+ })
+}
+
+Custom widgets are placed in layouts by name in JSONC layout files:
+
+{
+ "name": "my-layout",
+ "root": {
+ "direction": "vertical",
+ "areas": [
+ { "widget": "header", "size": 3 },
+ { "widget": "sentinel", "size": "30%" },
+ { "widget": "processes", "size": "*" }
+ ]
+ }
+}
+
+Plugin widgets take precedence over built-in widgets with the same name.
+
+
+
+CLI Commands
+
+
+
+ | Command | Description |
+
+
+
+ xtop plugin list |
+ List installed plugins from workspace members |
+
+
+ xtop plugin install <name> |
+ Install a plugin from github.com/xscriptor/xtop/plugins/ |
+
+
+ xtop plugin install <url> |
+ Install a plugin from any git URL |
+
+
+ xtop plugin scaffold <name> |
+ Create a new plugin crate template in plugins/ |
+
+
+
+
+Install Flow
+
+When running xtop plugin install sentinel:
+
+
+ - Clones
github.com/xscriptor/xtop.git (shallow, sparse)
+ - Looks for
plugins/xtop-plugin-sentinel/ or plugins/sentinel/ in the clone
+ - Copies to local
plugins/ directory
+ - Adds entry to
[workspace].members in root Cargo.toml
+ - Adds optional dependency + feature flag in
crates/xtop-cli/Cargo.toml
+ - Runs
cargo build --release
+ - Cleans up temporary files
+
+
+The plugin is registered in the workspace but not enabled by default. To enable it:
+
+
+ - Build with
--features plugin-<name> for a one-off build
+ - Add it to the
default list in crates/xtop-cli/Cargo.toml to enable permanently
+
+
+# Build xtop with sentinel plugin enabled
+cargo build --release --features plugin-sentinel
+
+# Build xtop with sentinel + another plugin
+cargo build --release --features "plugin-sentinel,plugin-mything"
+
+
+
+MCP Server for AI Agents
+
+When the plugin-sentinel feature is enabled, xtop can run an MCP (Model Context Protocol) server on stdio:
+
+xtop mcp
+
+This exposes Sentinel's commands as MCP tools that any AI assistant can call.
+Compatible clients include Claude Desktop, Cline, Cursor, and Continue.dev.
+
+Claude Desktop configuration
+
+{
+ "mcpServers": {
+ "xtop": {
+ "command": "xtop",
+ "args": ["mcp"]
+ }
+ }
+}
+
+Interactive testing
+
+# Single command
+echo '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"system_summary","arguments":{}}}' | xtop mcp
+
+# Interactive session
+xtop mcp
+
+Monitoreo activo (polling)
+
+La IA puede llamar system_summary o alerts.status periodicamente. Para monitoreo proactivo (push), haria falta implementar MCP Resources + notificaciones.
+
+
+
+Adding a Plugin Manually
+
+
+ -
+
Create the crate in plugins/:
+ mkdir -p plugins/xtop-plugin-mything/src
+
+ -
+
Add to workspace Cargo.toml:
+ [workspace]
+members = [
+ ...
+ "plugins/xtop-plugin-mything",
+]
+
+ -
+
Add dependency + feature in crates/xtop-cli/Cargo.toml:
+ [dependencies]
+xtop-plugin-mything = { path = "../../plugins/xtop-plugin-mything", optional = true }
+
+[features]
+plugin-mything = ["dep:xtop-plugin-mything"]
+
+ -
+
Register in crates/xtop-cli/src/main.rs:
+ #[cfg(feature = "plugin-mything")]
+use xtop_plugin_mything::MythingPlugin;
+
+// In build_plugin_manager():
+#[cfg(feature = "plugin-mything")]
+{
+ let plugin = Box::new(MythingPlugin::new());
+ if let Err(e) = mgr.register(plugin, state) {
+ eprintln!("[xtop] failed to load mything plugin: {e}");
+ }
+}
+
+
+
+
+
+
+ Back to README
+
diff --git a/docs/usage.md b/docs/usage.md
new file mode 100644
index 0000000..f226ba6
--- /dev/null
+++ b/docs/usage.md
@@ -0,0 +1,180 @@
+Usage
+
+
+
+Table of Contents
+
+
+
+
+
+Keybindings
+
+
+
+
+ | Key |
+ Action |
+
+
+
+
+ | q |
+ Quit application (saves configuration) |
+
+
+ | ? |
+ Toggle help overlay |
+
+
+ | t |
+ Next color theme |
+
+
+ | T |
+ Previous color theme |
+
+
+ | l |
+ Next layout mode (cycles through built-in and custom layouts) |
+
+
+ | f |
+ Toggle full-screen mode for the current widget |
+
+
+ | F |
+ Cycle full-screen focus through all available widgets |
+
+
+ | / |
+ Open process search / filter |
+
+
+ | Enter |
+ Confirm search filter |
+
+
+ | Backspace |
+ Delete character in search input |
+
+
+ | Esc |
+ Cancel search / close help overlay |
+
+
+
+
+
+
+Modules
+
+
+ -
+
Header -- Shows system uptime, load average, current theme name, and active layout mode.
+
+ -
+
CPU -- Horizontal usage bars for each CPU core. If hardware sensors are available, displays the maximum CPU temperature.
+
+ -
+
Memory -- Gauges for RAM and Swap usage, plus a line chart showing RAM usage over a configurable history window.
+
+ -
+
Storage -- Disk usage gauges per mount point showing capacity and used space.
+
+ -
+
Network -- Total downloaded (RX) and uploaded (TX) data per interface, with current transfer rates.
+
+ -
+
Disk I/O -- Read and write speeds per disk device in bytes per second.
+
+ -
+
Processes -- Scrolling list of the top 50 processes sorted by CPU usage, with live search filtering by process name.
+
+ -
+
GPU -- GPU usage gauges (available on supported hardware).
+
+ -
+
Battery -- Battery charge level gauges (available on supported hardware).
+
+
+
+
+
+Help Overlay
+
+Press ? at any time to display a full list of available keybindings on screen. Press ? again or Esc to close the overlay.
+
+
+
+Command Palette
+
+xtop provides an interactive search overlay for filtering processes in real time:
+
+
+ - Press / to open the search bar at the top of the process list.
+ - Type any query -- results filter instantly by process name.
+ - Press Enter to confirm the filter, Esc to cancel, Backspace to delete characters.
+ - A centered overlay with a
/query_ indicator shows the current search input.
+
+
+The help overlay (?) serves as a quick-reference command palette for all available keybindings and actions.
+
+
+
+Full-Screen Mode
+
+
+ - Press f to toggle full-screen mode for the currently focused widget. The widget expands to fill the entire terminal area (minus the header bar).
+ - Press F to cycle full-screen focus through widgets in sequence: CPU, Memory, Storage, Network, Processes, Disk I/O, GPU, Battery, then exit.
+
+
+
+
+Responsive Layouts
+
+The interface adapts automatically to the terminal size:
+
+
+
+
+ | Terminal Size |
+ Behavior |
+
+
+
+
+ | Wider than 100 cols and taller than 30 rows |
+ Full dashboard layout with 2 columns |
+
+
+ | Wider than 80 cols and taller than 24 rows |
+ Compact layout |
+
+
+ | Narrower than 80 cols |
+ Vertically stacked layout |
+
+
+ | Narrower than 60 cols or shorter than 18 rows |
+ Minimal layout: CPU, Memory, and Processes only |
+
+
+ | Smaller than 40 x 8 |
+ Warning message displayed (terminal too small) |
+
+
+
+
+
+
+
+ Back to README
+
diff --git a/plugins/xtop-plugin-sentinel/Cargo.toml b/plugins/xtop-plugin-sentinel/Cargo.toml
new file mode 100644
index 0000000..7bb99d8
--- /dev/null
+++ b/plugins/xtop-plugin-sentinel/Cargo.toml
@@ -0,0 +1,14 @@
+[package]
+name = "xtop-plugin-sentinel"
+version.workspace = true
+edition.workspace = true
+license.workspace = true
+description = "AI-aware system resource monitoring and management plugin for xtop"
+
+[dependencies]
+xtop-core = { path = "../../crates/xtop-core" }
+ratatui.workspace = true
+regex = "1"
+serde.workspace = true
+serde_json.workspace = true
+anyhow.workspace = true
diff --git a/plugins/xtop-plugin-sentinel/README.md b/plugins/xtop-plugin-sentinel/README.md
new file mode 100644
index 0000000..4128f91
--- /dev/null
+++ b/plugins/xtop-plugin-sentinel/README.md
@@ -0,0 +1,372 @@
+Sentinel Plugin
+
+Sentinel is an AI-aware system monitoring and management plugin for xtop. It exposes system metrics, process information, and configuration through a simple command interface designed to be consumed by AI agents (including LLM-based assistants).
+
+It also includes a built-in MCP server (xtop mcp) that exposes all commands as MCP tools for seamless AI integration.
+
+
+
+Features
+
+
+ - Read system summary (CPU, memory, disks, network, uptime) as JSON
+ - Query top processes with full details and optional regex filter
+ - Search processes by regex on name, cmd, user, or state fields
+ - Kill processes safely by PID
+ - Get and set alert thresholds
+ - Read and update xtop configuration (theme, layout, interval)
+ - Automatic process anomaly detection (CPU spikes, root processes)
+ - Custom TUI widget for visual status
+ - MCP server over stdio for AI tool integration
+
+
+
+
+Command Interface
+
+All interaction happens through a single entry point:
+
+plugin.execute(ctx, "<action>", "<params>") -> Result<String, PluginError>
+
+The response is always a JSON string, making it easy for any AI or script to parse.
+
+system.summary
+
+Returns a high-level snapshot of system health.
+
+
+ | Params | Response |
+
+ | (empty) | JSON object |
+
+
+
+Example response:
+
+{
+ "cpu_avg": 23.4,
+ "mem_used_gb": 8.2,
+ "mem_total_gb": 32.0,
+ "mem_pct": 26,
+ "processes": 342,
+ "disks": 4,
+ "interfaces": ["en0", "en1"],
+ "uptime_secs": 482000,
+ "hostname": "mbp.local"
+}
+
+processes.top
+
+Returns the top N processes sorted by CPU usage.
+
+
+ | Params | Response |
+
+ Count (e.g. "10") | JSON array |
+
+
+
+Example response:
+
+[
+ {"pid": 1234, "name": "firefox", "cpu": 45.2, "mem_bytes": 1048576000, "state": "Running", "user": "1000"},
+ {"pid": 5678, "name": "python3", "cpu": 12.1, "mem_bytes": 524288000, "state": "Sleeping", "user": "1000"}
+]
+
+process.info
+
+Returns detailed information about a single process.
+
+
+ | Params | Response |
+
+ PID (e.g. "1234") | JSON object |
+
+
+
+Example response:
+
+{
+ "pid": 1234,
+ "name": "firefox",
+ "cpu": 45.2,
+ "mem_bytes": 1048576000,
+ "state": "Running",
+ "cmd": "/usr/lib/firefox/firefox"
+}
+
+process.kill
+
+Terminates a process by PID.
+
+
+ | Params | Response |
+
+ PID (e.g. "1234") | JSON object |
+
+
+
+Example response:
+
+{"killed": true, "pid": 1234}
+
+threshold.set
+
+Sets alert thresholds for CPU, memory, and disk usage.
+
+
+ | Params | Response |
+
+ "cpu,mem,disk" (percentages) | JSON object |
+
+
+
+Example: "90,85,80" sets CPU threshold to 90%, memory to 85%, disk to 80%.
+
+Response:
+
+{"cpu": 90, "mem": 85, "disk": 80, "set": true}
+
+threshold.get
+
+Returns the current alert thresholds.
+
+
+ | Params | Response |
+
+ | (empty) | JSON object |
+
+
+
+Response:
+
+{"cpu": 90, "mem": 90, "disk": 90}
+
+config.get
+
+Returns the current xtop configuration.
+
+
+ | Params | Response |
+
+ | (empty) | JSON object |
+
+
+
+Response:
+
+{"theme": "x", "layout": "Dashboard", "interval_ms": 1000, "hostname": "mbp.local"}
+
+alerts.status
+
+Returns any alerts generated by process analysis heuristics.
+
+
+ | Params | Response |
+
+ | (empty) | JSON object |
+
+
+
+Response:
+
+{"alerts": "high_cpu: firefox (pid=1234, cpu=95.2%)\\nhigh_cpu: python3 (pid=5678, cpu=88.0%)"}
+
+processes.search
+
+Search processes using a regex pattern. Supports filtering on specific fields.
+
+
+ | Params | Response |
+
+ "pattern" or "pattern,fields=f1,f2" | JSON array of matching processes |
+
+
+
+Supported fields: name, cmd, user, state. Default: name only.
+
+Examples:
+
+# Search by name
+processes.search("firefox")
+
+# Regex on name
+processes.search("/python|node/")
+
+# Regex on name AND cmd
+processes.search("/systemd/,fields=name,cmd")
+
+# Filter by user
+processes.search("/^0$/,fields=user")
+
+Response:
+
+[{"pid": 1234, "name": "firefox", "cpu": 45.2, "mem_bytes": 1048576000, "state": "Running", "user": "1000", "cmd": "/usr/lib/firefox/firefox"}]
+
+processes.top (with filter)
+
+Top processes with optional regex filter on name or cmd:
+
+# Top 5 processes matching the pattern
+processes.top("5,filter=/firefox/")
+
+config.set
+
+Update xtop configuration at runtime.
+
+
+ | Params | Response |
+
+ "interval_ms=<ms>" or "theme=<name>" or "layout=<name>" | JSON object |
+
+
+
+Examples:
+
+config.set("interval_ms=2000") # {"interval_ms": 2000, "set": true}
+config.set("theme=tokio") # {"theme": "tokio", "set": true}
+config.set("layout=Vertical") # {"layout": "Vertical", "set": true}
+
+plugin.status
+
+Returns the internal state of the Sentinel plugin.
+
+
+ | Params | Response |
+
+ | (empty) | JSON object |
+
+
+
+Response:
+
+{"enabled": true, "ticks": 42, "last_action": "system.summary", "last_result": "ok (147 chars)"}
+
+
+
+
+
+Sentinel registers a widget named "sentinel". Include it in any custom layout by referencing it in a JSONC layout file:
+
+{
+ "name": "monitor",
+ "root": {
+ "direction": "vertical",
+ "areas": [
+ { "widget": "header", "size": 3 },
+ { "widget": "sentinel", "size": 6 },
+ { "widget": "processes", "size": "*" }
+ ]
+ }
+}
+
+The widget renders a bordered panel with dark background and accent-colored title showing the agent status and a help hint.
+
+
+
+MCP Server
+
+Sentinel includes a built-in MCP server that exposes all commands as MCP tools.
+Run it with:
+
+xtop mcp
+
+The server speaks JSON-RPC 2.0 over stdin/stdout (standard MCP transport).
+Any MCP-compatible AI can connect:
+
+Claude Desktop
+
+{
+ "mcpServers": {
+ "xtop": {
+ "command": "xtop",
+ "args": ["mcp"]
+ }
+ }
+}
+
+Interactive testing
+
+# Call system_summary
+echo '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"system_summary","arguments":{}}}' | xtop mcp
+
+# Search processes with regex
+echo '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"processes_search","arguments":{"pattern":"/firefox|chrome/"}}}' | xtop mcp
+
+# Kill a process
+echo '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"process_kill","arguments":{"pid":1234}}}' | xtop mcp
+
+Available MCP Tools
+
+
+ | Tool | Arguments | Description |
+
+ system_summary | (none) | System health summary |
+ processes_top | count, filter (regex) | Top processes |
+ processes_search | pattern, fields | Regex search |
+ process_info | pid | Process details |
+ process_kill | pid | Kill process |
+ threshold_set | cpu, mem, disk | Set alert thresholds |
+ threshold_get | (none) | Get thresholds |
+ config_get | (none) | Get config |
+ config_set | interval_ms / theme / layout | Update config |
+ alerts_status | (none) | Active alerts |
+ plugin_status | (none) | Plugin status |
+
+
+
+
+
+Process Analysis
+
+Sentinel includes a heuristic analyzer that runs every 10 ticks (~10 seconds):
+
+
+ - Flags processes with CPU usage above 80%
+ - Flags root-owned processes with CPU above 50%
+ - Collects up to 20 alerts per cycle
+ - Alerts are exposed via
alerts.status command
+
+
+The analysis is basic by design. Deeper security heuristics are delegated to domain experts.
+
+
+
+AI Integration
+
+Sentinel exposes two interfaces for AI agents:
+
+Direct execute() (for embedded agents)
+Callable from within xtop's plugin system via PluginManager::execute("sentinel", action, params).
+
+MCP protocol (for external AI)
+Run xtop mcp and configure your AI assistant's MCP client to connect.
+The AI sees all Sentinel tools as callable functions with typed parameters.
+
+Typical AI workflow via MCP:
+
+
+ - AI calls
system_summary to get a health overview
+ - AI calls
processes_top with optional regex filter
+ - If suspicious, AI calls
process_info for details
+ - AI may call
process_kill to terminate
+ - AI calls
alerts_status periodically for anomalies
+
+
+
+
+Future Plans
+
+
+ - MCP Resources: Push-based monitoring (server notifies AI on alerts)
+ - Real-time streaming: Subscribe to metric changes without polling
+ - Security analysis: Deeper heuristics for malicious process detection
+ - Rich process metadata: Parent PID, executable hash, file descriptors, network sockets
+
+
+
+
+
+ Plugin System Docs ·
+ Back to xtop
+
diff --git a/plugins/xtop-plugin-sentinel/src/alert.rs b/plugins/xtop-plugin-sentinel/src/alert.rs
new file mode 100644
index 0000000..4e2266d
--- /dev/null
+++ b/plugins/xtop-plugin-sentinel/src/alert.rs
@@ -0,0 +1,45 @@
+use serde::Serialize;
+
+/// Severity level for a Sentinel alert.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
+pub enum Severity {
+ /// Low-priority, informative
+ Info,
+ /// Moderate concern, shown in widget
+ Warning,
+ /// High confidence threat
+ Critical,
+}
+
+/// A single security alert produced by a heuristic rule.
+#[derive(Debug, Clone, Serialize)]
+pub struct SentinelAlert {
+ /// Short rule name (e.g. "suspicious_exe_path", "orphan_process")
+ pub rule: &'static str,
+ /// Severity level
+ pub severity: Severity,
+ /// Process ID that triggered the alert
+ pub pid: u32,
+ /// Process name
+ pub process_name: String,
+ /// Human-readable detail message
+ pub message: String,
+}
+
+impl SentinelAlert {
+ pub fn new(
+ rule: &'static str,
+ severity: Severity,
+ pid: u32,
+ process_name: String,
+ message: String,
+ ) -> Self {
+ Self {
+ rule,
+ severity,
+ pid,
+ process_name,
+ message,
+ }
+ }
+}
diff --git a/plugins/xtop-plugin-sentinel/src/lib.rs b/plugins/xtop-plugin-sentinel/src/lib.rs
new file mode 100644
index 0000000..35b88a7
--- /dev/null
+++ b/plugins/xtop-plugin-sentinel/src/lib.rs
@@ -0,0 +1,795 @@
+pub mod alert;
+pub mod mcp;
+
+use std::collections::HashMap;
+use std::fmt::Debug;
+use ratatui::prelude::*;
+use ratatui::widgets::{Block, Borders, Paragraph};
+use regex::Regex;
+use xtop_core::application::state::AppState;
+use xtop_core::domain::metrics::ProcessInfo;
+use xtop_core::domain::plugin::{
+ Plugin, PluginCapability, PluginContext, PluginError, PluginManifest, WidgetRegistration,
+};
+
+use alert::{SentinelAlert, Severity};
+
+// ---------------------------------------------------------------------------
+// Known threat patterns (Rule 6)
+// ---------------------------------------------------------------------------
+const KNOWN_THREAT_NAMES: &[&str] = &[
+ "minerd", "cpu_miner", "xmrig", "kdevtmpfsi", "kinsing", "diagree",
+ "watchbog", "sysguard", "crond64", "mkfile", "sysupdate",
+ "xmrig-nvidia", "xmrig-amd", "moneroocean",
+];
+
+const KNOWN_THREAT_CMDS: &[&str] = &[
+ r"--donate-level",
+ r"--max-cpu-usage",
+ r"--threads",
+ r"pool\.monero",
+ r"pool\.supportxmr",
+ r"mine\.monero",
+];
+
+/// Path prefixes that are suspicious for executable locations.
+const SUSPICIOUS_PATH_PREFIXES: &[&str] = &[
+ "/tmp/",
+ "/dev/shm/",
+ "/var/tmp/",
+ "/proc/",
+ "/private/tmp/",
+ "/private/var/tmp/",
+];
+
+/// Processes allowed to be orphans (PPID=1).
+const ALLOWED_ORPHANS: &[&str] = &[
+ "systemd", "init", "launchd", "sshd", "login", "getty", "nginx",
+ "apache2", "httpd", "bash", "sh", "zsh", "tmux", "screen",
+];
+
+/// Browsers whose children are monitored (Rule 5).
+const BROWSER_NAMES: &[&str] = &[
+ "chrome", "firefox", "safari", "edge", "brave", "opera", "chromium",
+];
+
+/// Browser helper/sandbox processes that are allowed children.
+const BROWSER_HELPERS: &[&str] = &[
+ "helper", "plugin_container", "plugin_host", "gpu_process",
+ "renderer", "utility", "crashpad", "updater",
+];
+
+/// Pipe/download patterns for Rule 7.
+const PIPE_PATTERNS: &[&str] = &[
+ r"curl\s+.*\|\s*(ba|z)?sh",
+ r"wget\s+.*\|\s*(ba|z)?sh",
+ r"curl\s+.*\s*ba(?:sh)?\s*$",
+ r"python3?\s+-c\s+.*(?:import|urllib|requests|socket)",
+ r"base64\s+-d\s*\|",
+ r"eval\s*\$\(.*curl",
+ r"eval\s*\$\(.*wget",
+ r"bash\s+-c\s+.*\$\(curl",
+ r"bash\s+-c\s+.*\$\(wget",
+];
+
+/// High-thread-count processes that are allowed (Rule 8).
+const ALLOWED_HIGH_THREAD: &[&str] = &[
+ "chrome", "firefox", "code", "Code", "idea", "java", "dotnet",
+ "python", "node", "mysqld", "postgres", "Xorg", "dockerd",
+];
+
+/// Maximum alerts per cycle
+const MAX_ALERTS: usize = 50;
+
+// ---------------------------------------------------------------------------
+// The plugin struct
+// ---------------------------------------------------------------------------
+
+pub struct SentinelPlugin {
+ enabled: bool,
+ tick_count: u64,
+ last_action: String,
+ last_action_result: String,
+ alerts: Vec,
+ // For spawn storm detection (Rule 10): name -> [(pid, start_time)]
+ spawn_history: HashMap>,
+}
+
+impl SentinelPlugin {
+ pub fn new() -> Self {
+ Self {
+ enabled: true,
+ tick_count: 0,
+ last_action: "none".to_string(),
+ last_action_result: "ok".to_string(),
+ alerts: Vec::new(),
+ spawn_history: HashMap::new(),
+ }
+ }
+
+ // -----------------------------------------------------------------------
+ // Formatting helpers
+ // -----------------------------------------------------------------------
+
+ fn fmt_process(p: &ProcessInfo) -> serde_json::Value {
+ serde_json::json!({
+ "pid": p.pid,
+ "name": p.name,
+ "cpu": (p.cpu_usage * 10.0).round() / 10.0,
+ "mem_bytes": p.memory,
+ "state": p.state,
+ "user": p.user_id.as_deref().unwrap_or("?"),
+ "cmd": p.cmd,
+ "exe": p.exe_path.as_deref().unwrap_or("?"),
+ "ppid": p.parent_pid,
+ "threads": p.thread_count,
+ "run_time": p.run_time,
+ "cwd": p.cwd.as_deref().unwrap_or("?"),
+ })
+ }
+
+ fn fmt_process_list(procs: &[ProcessInfo]) -> String {
+ let entries: Vec = procs.iter().map(Self::fmt_process).collect();
+ serde_json::to_string(&entries).unwrap_or_default()
+ }
+
+ fn fmt_alert(a: &SentinelAlert) -> serde_json::Value {
+ serde_json::json!({
+ "rule": a.rule,
+ "severity": format!("{:?}", a.severity),
+ "pid": a.pid,
+ "process": a.process_name,
+ "message": a.message,
+ })
+ }
+
+ // -----------------------------------------------------------------------
+ // System summary
+ // -----------------------------------------------------------------------
+
+ fn system_summary(&self, ctx: &PluginContext) -> String {
+ let snap = ctx.snapshot();
+ let cpu_pct: f64 = snap.cpus.iter().map(|c| c.usage).sum::()
+ / snap.cpus.len().max(1) as f64;
+ let mem_gb = (snap.memory.used as f64 / 1073741824.0 * 10.0).round() / 10.0;
+ let mem_total_gb = (snap.memory.total as f64 / 1073741824.0 * 10.0).round() / 10.0;
+ let net_ifaces: Vec<&str> = snap.networks.iter().map(|n| n.name.as_str()).collect();
+
+ serde_json::to_string(&serde_json::json!({
+ "cpu_avg": (cpu_pct * 10.0).round() / 10.0,
+ "mem_used_gb": mem_gb,
+ "mem_total_gb": mem_total_gb,
+ "mem_pct": snap.memory.percent.round() as u64,
+ "processes": snap.processes.len(),
+ "disks": snap.disks.len(),
+ "interfaces": net_ifaces,
+ "uptime_secs": snap.uptime,
+ "hostname": snap.sys_info.hostname,
+ "alerts": self.alerts.len(),
+ })).unwrap_or_default()
+ }
+
+ // -----------------------------------------------------------------------
+ // Search / listing
+ // -----------------------------------------------------------------------
+
+ fn search_processes(&self, ctx: &PluginContext, params: &str) -> Result {
+ let (pattern_str, fields) = if let Some(idx) = params.find(",fields=") {
+ let pat = ¶ms[..idx];
+ let fields_part = ¶ms[idx + 8..];
+ (pat, fields_part.split(',').collect::>())
+ } else {
+ (params, vec!["name"])
+ };
+
+ let pattern_str = pattern_str.trim();
+ if pattern_str.is_empty() {
+ return Err(PluginError::Recoverable("search pattern cannot be empty".into()));
+ }
+
+ let re = Regex::new(pattern_str).map_err(|e| {
+ PluginError::Recoverable(format!("invalid regex: {e}"))
+ })?;
+
+ let snap = ctx.snapshot();
+ let mut matched: Vec = snap
+ .processes
+ .into_iter()
+ .filter(|p| {
+ fields.iter().any(|f| match *f {
+ "name" => re.is_match(&p.name),
+ "cmd" => re.is_match(&p.cmd),
+ "user" => p.user_id.as_deref().map_or(false, |u| re.is_match(u)),
+ "state" => re.is_match(&p.state),
+ "exe" => p.exe_path.as_deref().map_or(false, |e| re.is_match(e)),
+ "cwd" => p.cwd.as_deref().map_or(false, |c| re.is_match(c)),
+ _ => false,
+ })
+ })
+ .collect();
+
+ matched.sort_by(|a, b| b.cpu_usage.partial_cmp(&a.cpu_usage).unwrap_or(std::cmp::Ordering::Equal));
+ matched.truncate(100);
+ Ok(Self::fmt_process_list(&matched))
+ }
+
+ fn top_processes(&self, ctx: &PluginContext, params: &str) -> Result {
+ let (count_str, filter_pattern) = if let Some(idx) = params.find(",filter=") {
+ let cnt = ¶ms[..idx];
+ let pat = ¶ms[idx + 8..];
+ (cnt, Some(pat))
+ } else {
+ (params, None)
+ };
+
+ let count = count_str.parse::().unwrap_or(10);
+ if count == 0 {
+ return Err(PluginError::Recoverable("count must be > 0".into()));
+ }
+ let snap = ctx.snapshot();
+ let mut procs: Vec = snap.processes;
+
+ if let Some(pattern) = filter_pattern {
+ let re = Regex::new(pattern).map_err(|e| {
+ PluginError::Recoverable(format!("invalid regex in filter: {e}"))
+ })?;
+ procs.retain(|p| re.is_match(&p.name) || re.is_match(&p.cmd) || p.exe_path.as_deref().map_or(false, |e| re.is_match(e)));
+ }
+
+ procs.truncate(count);
+ Ok(Self::fmt_process_list(&procs))
+ }
+
+ fn process_info(&self, ctx: &PluginContext, pid_str: &str) -> Result {
+ let pid = pid_str.parse::().map_err(|_| {
+ PluginError::Recoverable(format!("invalid pid: {pid_str}"))
+ })?;
+ let snap = ctx.snapshot();
+ let proc = snap.processes.iter().find(|p| p.pid == pid).ok_or_else(|| {
+ PluginError::Recoverable(format!("process {pid} not found"))
+ })?;
+ Ok(serde_json::to_string(&Self::fmt_process(proc)).unwrap_or_default())
+ }
+
+ // -----------------------------------------------------------------------
+ // Heuristic rules
+ // -----------------------------------------------------------------------
+
+ /// Rule 1: Executable running from a suspicious path.
+ fn rule_suspicious_exe_path(&self, proc: &ProcessInfo) -> Option {
+ let exe = proc.exe_path.as_deref()?;
+ if SUSPICIOUS_PATH_PREFIXES.iter().any(|p| exe.starts_with(p)) {
+ Some(SentinelAlert::new(
+ "suspicious_exe_path",
+ Severity::Critical,
+ proc.pid,
+ proc.name.clone(),
+ format!("executable runs from suspicious path: {exe}"),
+ ))
+ } else {
+ None
+ }
+ }
+
+ /// Rule 2: Orphan process (PPID=1) that is not a known system daemon.
+ fn rule_orphan_process(&self, proc: &ProcessInfo) -> Option {
+ if proc.parent_pid != Some(1) {
+ return None;
+ }
+ let name_lower = proc.name.to_lowercase();
+ if ALLOWED_ORPHANS.iter().any(|a| name_lower.contains(a)) {
+ return None;
+ }
+ let severity = if proc.run_time < 60 { Severity::Critical } else { Severity::Warning };
+ Some(SentinelAlert::new(
+ "orphan_process",
+ severity,
+ proc.pid,
+ proc.name.clone(),
+ format!(
+ "orphan process (PPID=1), running for {}s, exe: {}",
+ proc.run_time,
+ proc.exe_path.as_deref().unwrap_or("?"),
+ ),
+ ))
+ }
+
+ /// Rule 3: Process masquerading (name != exe file stem OR system name not at canonical path).
+ fn rule_masquerading(&self, proc: &ProcessInfo) -> Option {
+ let exe = match proc.exe_path.as_deref() {
+ Some(e) => e,
+ None => return None,
+ };
+ // Extract file stem from exe
+ let stem = std::path::Path::new(exe)
+ .file_stem()
+ .and_then(|s| s.to_str())
+ .unwrap_or("");
+ let name_lower = proc.name.to_lowercase();
+ let stem_lower = stem.to_lowercase();
+
+ // Check if name is a known system process but exe is not at a canonical path
+ let known_system_names = ["svchost", "lsass", "launchd", "sshd", "systemd", "init"];
+ if known_system_names.contains(&name_lower.as_str()) {
+ let canonical = exe.starts_with("/usr/") || exe.starts_with("/bin/")
+ || exe.starts_with("/sbin/") || exe.starts_with("/System/");
+ if !canonical {
+ return Some(SentinelAlert::new(
+ "process_masquerading",
+ Severity::Critical,
+ proc.pid,
+ proc.name.clone(),
+ format!("process name '{}' masquerades as system process; exe: {exe}", proc.name),
+ ));
+ }
+ }
+
+ // Check if name differs significantly from exe file stem
+ if name_lower != stem_lower && !stem_lower.is_empty() {
+ // Allow common cases like "python3" -> exe "/usr/bin/python3.11"
+ if !exe.contains(&name_lower) && !name_lower.contains(&stem_lower) {
+ return Some(SentinelAlert::new(
+ "process_masquerading",
+ Severity::Warning,
+ proc.pid,
+ proc.name.clone(),
+ format!("name '{}' differs from exe stem '{stem}' ({exe})", proc.name),
+ ));
+ }
+ }
+
+ None
+ }
+
+ /// Rule 4: Privilege escalation (EUID != UID).
+ fn rule_privilege_escalation(&self, proc: &ProcessInfo) -> Option {
+ let euid = match proc.effective_user_id.as_deref() {
+ Some(u) => u,
+ None => return None,
+ };
+ let uid = match proc.user_id.as_deref() {
+ Some(u) => u,
+ None => return None,
+ };
+ if euid == uid {
+ return None;
+ }
+ // Known SUID binaries that are allowed
+ let known_suid = ["/usr/bin/sudo", "/usr/bin/passwd", "/bin/ping",
+ "/usr/bin/ping", "/bin/su", "/usr/bin/su", "/usr/bin/newgrp",
+ "/usr/bin/gpasswd", "/usr/bin/chsh", "/usr/bin/chfn",
+ "/usr/bin/mount", "/usr/bin/umount"];
+ let exe = proc.exe_path.as_deref().unwrap_or("");
+ if known_suid.contains(&exe) {
+ return None;
+ }
+ let severity = if euid == "0" { Severity::Critical } else { Severity::Warning };
+ Some(SentinelAlert::new(
+ "privilege_escalation",
+ severity,
+ proc.pid,
+ proc.name.clone(),
+ format!("EUID ({euid}) != UID ({uid}), exe: {exe}"),
+ ))
+ }
+
+ /// Rule 5: Suspicious child of a browser process.
+ fn rule_suspicious_child_of_browser(&self, proc: &ProcessInfo, parent_map: &HashMap) -> Option {
+ let ppid = match proc.parent_pid {
+ Some(pid) => pid,
+ None => return None,
+ };
+ let parent = match parent_map.get(&ppid) {
+ Some(p) => p,
+ None => return None,
+ };
+ let parent_lower = parent.name.to_lowercase();
+ let is_browser = BROWSER_NAMES.iter().any(|b| parent_lower.contains(b));
+ if !is_browser {
+ return None;
+ }
+ let child_lower = proc.name.to_lowercase();
+ let is_helper = BROWSER_HELPERS.iter().any(|h| child_lower.contains(h));
+ if is_helper {
+ return None;
+ }
+ Some(SentinelAlert::new(
+ "suspicious_child_of_browser",
+ Severity::Warning,
+ proc.pid,
+ proc.name.clone(),
+ format!("browser '{}' spawned unknown child '{}'", parent.name, proc.name),
+ ))
+ }
+
+ /// Rule 6: Known threat pattern (name or cmd matches miner/rootkit names).
+ fn rule_known_threat_pattern(&self, proc: &ProcessInfo) -> Option {
+ let name_lower = proc.name.to_lowercase();
+ if KNOWN_THREAT_NAMES.iter().any(|t| name_lower.contains(t)) {
+ return Some(SentinelAlert::new(
+ "known_threat_pattern",
+ Severity::Critical,
+ proc.pid,
+ proc.name.clone(),
+ format!("process name matches known threat pattern: {}", proc.name),
+ ));
+ }
+ let cmd_joined = proc.cmd_full.join(" ").to_lowercase();
+ if KNOWN_THREAT_CMDS.iter().any(|t| {
+ Regex::new(t).ok().map_or(false, |re| re.is_match(&cmd_joined))
+ }) {
+ return Some(SentinelAlert::new(
+ "known_threat_pattern",
+ Severity::Critical,
+ proc.pid,
+ proc.name.clone(),
+ format!("command line matches known threat pattern: {}", proc.cmd),
+ ));
+ }
+ None
+ }
+
+ /// Rule 7: Suspicious pipe/download pattern in command line.
+ fn rule_suspicious_pipe_or_download(&self, proc: &ProcessInfo) -> Option {
+ let cmd_joined = proc.cmd_full.join(" ");
+ for pattern in PIPE_PATTERNS {
+ if let Ok(re) = Regex::new(pattern) {
+ if re.is_match(&cmd_joined) {
+ return Some(SentinelAlert::new(
+ "suspicious_pipe_or_download",
+ Severity::Critical,
+ proc.pid,
+ proc.name.clone(),
+ format!("command matches pipe/download pattern: {}", proc.cmd),
+ ));
+ }
+ }
+ }
+ None
+ }
+
+ /// Rule 8: High thread count anomaly.
+ fn rule_high_thread_anomaly(&self, proc: &ProcessInfo) -> Option {
+ if proc.thread_count < 500 {
+ return None;
+ }
+ let name_lower = proc.name.to_lowercase();
+ if ALLOWED_HIGH_THREAD.iter().any(|a| name_lower.contains(a)) {
+ return None;
+ }
+ let severity = if proc.thread_count > 1000 || proc.cpu_usage > 200.0 {
+ Severity::Critical
+ } else {
+ Severity::Warning
+ };
+ Some(SentinelAlert::new(
+ "high_thread_anomaly",
+ severity,
+ proc.pid,
+ proc.name.clone(),
+ format!("{} threads (CPU: {:.1}%)", proc.thread_count, proc.cpu_usage),
+ ))
+ }
+
+ /// Rule 9: Suspicious file descriptor anomaly.
+ fn rule_suspicious_fd_anomaly(&self, proc: &ProcessInfo) -> Option {
+ if proc.open_files < 1000 {
+ return None;
+ }
+ let name_lower = proc.name.to_lowercase();
+ let allowed = ["mysql", "postgres", "nginx", "httpd", "apache",
+ "chrome", "firefox", "code", "java", "dotnet", "dockerd"];
+ if allowed.iter().any(|a| name_lower.contains(a)) {
+ return None;
+ }
+ Some(SentinelAlert::new(
+ "suspicious_fd_anomaly",
+ Severity::Info,
+ proc.pid,
+ proc.name.clone(),
+ format!("{} open file descriptors", proc.open_files),
+ ))
+ }
+
+ /// Rule 10: Spawn storm detection.
+ fn rule_spawn_storm(
+ &mut self,
+ proc: &ProcessInfo,
+ now_run_time: u64,
+ ) -> Option {
+ if proc.run_time > 120 {
+ return None;
+ }
+ let entry = self
+ .spawn_history
+ .entry(proc.name.clone())
+ .or_default();
+ entry.push((proc.pid, proc.start_time));
+ // Purge entries older than 120s
+ entry.retain(|(_, start)| now_run_time.saturating_sub(*start) < 120);
+ if entry.len() > 5 {
+ Some(SentinelAlert::new(
+ "recent_spawn_storm",
+ Severity::Warning,
+ proc.pid,
+ proc.name.clone(),
+ format!("{} new instances in last 120s", entry.len()),
+ ))
+ } else {
+ None
+ }
+ }
+
+ // -----------------------------------------------------------------------
+ // Main analyzer
+ // -----------------------------------------------------------------------
+
+ fn analyze_processes(&mut self, ctx: &PluginContext) -> Vec {
+ let snap = ctx.snapshot();
+ let mut alerts: Vec = Vec::new();
+
+ // Build parent PID map for Rule 5
+ let parent_map: HashMap = snap
+ .processes
+ .iter()
+ .map(|p| (p.pid, p))
+ .collect();
+
+ let now_run_time = std::time::SystemTime::now()
+ .duration_since(std::time::UNIX_EPOCH)
+ .unwrap_or_default()
+ .as_secs();
+
+ for proc in &snap.processes {
+ // Run rules in priority order
+ if let Some(a) = self.rule_suspicious_exe_path(proc) {
+ alerts.push(a);
+ }
+ if let Some(a) = self.rule_masquerading(proc) {
+ alerts.push(a);
+ }
+ if let Some(a) = self.rule_known_threat_pattern(proc) {
+ alerts.push(a);
+ }
+ if let Some(a) = self.rule_suspicious_pipe_or_download(proc) {
+ alerts.push(a);
+ }
+ if let Some(a) = self.rule_orphan_process(proc) {
+ alerts.push(a);
+ }
+ if let Some(a) = self.rule_privilege_escalation(proc) {
+ alerts.push(a);
+ }
+ if let Some(a) = self.rule_suspicious_child_of_browser(proc, &parent_map) {
+ alerts.push(a);
+ }
+ if let Some(a) = self.rule_high_thread_anomaly(proc) {
+ alerts.push(a);
+ }
+ if let Some(a) = self.rule_suspicious_fd_anomaly(proc) {
+ alerts.push(a);
+ }
+ if let Some(a) = self.rule_spawn_storm(proc, now_run_time) {
+ alerts.push(a);
+ }
+ }
+
+ alerts.truncate(MAX_ALERTS);
+ alerts
+ }
+
+ fn parse_thresholds(params: &str) -> Result<(f64, f64, f64), PluginError> {
+ let parts: Vec<&str> = params.split(',').collect();
+ if parts.len() != 3 {
+ return Err(PluginError::Recoverable(
+ "expected cpu,mem,disk (3 comma-separated values)".into(),
+ ));
+ }
+ let cpu = parts[0].parse::().map_err(|e| {
+ PluginError::Recoverable(format!("invalid cpu threshold: {e}"))
+ })?;
+ let mem = parts[1].parse::().map_err(|e| {
+ PluginError::Recoverable(format!("invalid mem threshold: {e}"))
+ })?;
+ let disk = parts[2].parse::().map_err(|e| {
+ PluginError::Recoverable(format!("invalid disk threshold: {e}"))
+ })?;
+ Ok((cpu, mem, disk))
+ }
+}
+
+impl Debug for SentinelPlugin {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ f.debug_struct("SentinelPlugin")
+ .field("enabled", &self.enabled)
+ .field("tick_count", &self.tick_count)
+ .field("alerts", &self.alerts.len())
+ .finish()
+ }
+}
+
+impl Plugin for SentinelPlugin {
+ fn manifest(&self) -> PluginManifest {
+ PluginManifest {
+ id: "sentinel".to_string(),
+ name: "Sentinel".to_string(),
+ version: "0.1.0".to_string(),
+ description: "AI-aware system monitoring, management, and heuristic threat detection".to_string(),
+ capabilities: vec![
+ PluginCapability::ReadSystemInfo,
+ PluginCapability::KillProcesses,
+ PluginCapability::ModifyConfig,
+ PluginCapability::RenderWidgets,
+ ],
+ }
+ }
+
+ fn on_enable(&mut self, _ctx: &mut PluginContext) -> Result<(), PluginError> {
+ self.enabled = true;
+ Ok(())
+ }
+
+ fn on_disable(&mut self, _ctx: &mut PluginContext) -> Result<(), PluginError> {
+ self.enabled = false;
+ Ok(())
+ }
+
+ fn on_tick(&mut self, ctx: &mut PluginContext) -> Result<(), PluginError> {
+ self.tick_count += 1;
+ if self.tick_count % 5 == 0 {
+ self.alerts = self.analyze_processes(ctx);
+ }
+ Ok(())
+ }
+
+ fn widget(&self) -> Option {
+ Some(WidgetRegistration {
+ name: "sentinel".to_string(),
+ render: std::sync::Arc::new(|f: &mut ratatui::Frame, _state: &AppState, area: Rect| {
+ use xtop_core::domain::theme::hex_to_rgb_pub;
+ let bg = hex_to_rgb_pub("#1a1b2e");
+ let fg = hex_to_rgb_pub("#7ec8e3");
+ let accent = hex_to_rgb_pub("#c084fc");
+
+ let block = Block::default()
+ .title(" Sentinel ")
+ .borders(Borders::ALL)
+ .border_style(Style::default().fg(Color::Rgb(accent[0], accent[1], accent[2])))
+ .style(Style::default().bg(Color::Rgb(bg[0], bg[1], bg[2])).fg(Color::Rgb(fg[0], fg[1], fg[2])));
+ let inner = block.inner(area);
+ f.render_widget(block, area);
+
+ let chunks = Layout::default()
+ .direction(Direction::Vertical)
+ .constraints([Constraint::Length(1), Constraint::Length(1), Constraint::Min(0)])
+ .split(inner);
+
+ let status = Paragraph::new("Agent: monitoring for threats -- use MCP to interact")
+ .style(Style::default().fg(Color::Rgb(accent[0], accent[1], accent[2])));
+ f.render_widget(status, chunks[0]);
+
+ let info = Paragraph::new("run xtop mcp for AI tool integration")
+ .style(Style::default().fg(Color::Rgb(fg[0], fg[1], fg[2])));
+ f.render_widget(info, chunks[1]);
+ }),
+ })
+ }
+
+ fn execute(
+ &mut self,
+ ctx: &mut PluginContext,
+ action: &str,
+ params: &str,
+ ) -> Result {
+ self.last_action = format!("{}({})", action, params);
+
+ let result = match action {
+ "system.summary" => Ok(self.system_summary(ctx)),
+ "processes.top" => self.top_processes(ctx, params),
+ "processes.search" => self.search_processes(ctx, params),
+ "process.info" => self.process_info(ctx, params),
+ "process.kill" => {
+ let pid = params.parse::().map_err(|_| {
+ PluginError::Recoverable(format!("invalid pid: {params}"))
+ })?;
+ let ok = ctx.kill_process(pid)
+ .map_err(|e| PluginError::Recoverable(e.to_string()))?;
+ Ok(serde_json::to_string(&serde_json::json!({
+ "killed": ok,
+ "pid": pid,
+ })).unwrap_or_default())
+ }
+ "process.alerts" => {
+ let alerts_json: Vec = self.alerts.iter().map(Self::fmt_alert).collect();
+ Ok(serde_json::to_string(&alerts_json).unwrap_or_default())
+ }
+ "threshold.set" => {
+ let (cpu, mem, disk) = Self::parse_thresholds(params)?;
+ ctx.set_alert_thresholds(cpu, mem, disk)
+ .map_err(|e| PluginError::Recoverable(e.to_string()))?;
+ Ok(serde_json::to_string(&serde_json::json!({
+ "cpu": cpu, "mem": mem, "disk": disk, "set": true,
+ })).unwrap_or_default())
+ }
+ "threshold.get" => {
+ let alerts = ctx.state().alerts;
+ Ok(serde_json::to_string(&serde_json::json!({
+ "cpu": alerts.cpu_high,
+ "mem": alerts.mem_high,
+ "disk": alerts.disk_high,
+ })).unwrap_or_default())
+ }
+ "config.get" => {
+ let s = ctx.state();
+ Ok(serde_json::to_string(&serde_json::json!({
+ "theme": s.current_theme.name,
+ "layout": s.current_layout_name(),
+ "interval_ms": s.update_interval_ms,
+ "hostname": s.sys_info.hostname,
+ })).unwrap_or_default())
+ }
+ "config.set" => {
+ if let Some(val) = params.strip_prefix("interval_ms=") {
+ let ms = val.parse::().map_err(|e| {
+ PluginError::Recoverable(format!("invalid interval_ms: {e}"))
+ })?;
+ ctx.set_update_interval(ms)
+ .map_err(|e| PluginError::Recoverable(e.to_string()))?;
+ Ok(serde_json::to_string(&serde_json::json!({
+ "interval_ms": ms, "set": true,
+ })).unwrap_or_default())
+ } else if let Some(name) = params.strip_prefix("theme=") {
+ let ok = ctx.set_theme_by_name(name)
+ .map_err(|e| PluginError::Recoverable(e.to_string()))?;
+ Ok(serde_json::to_string(&serde_json::json!({
+ "theme": name, "set": ok,
+ })).unwrap_or_default())
+ } else if let Some(name) = params.strip_prefix("layout=") {
+ let ok = ctx.set_layout_by_name(name)
+ .map_err(|e| PluginError::Recoverable(e.to_string()))?;
+ Ok(serde_json::to_string(&serde_json::json!({
+ "layout": name, "set": ok,
+ })).unwrap_or_default())
+ } else {
+ Err(PluginError::Recoverable(
+ "expected interval_ms=, theme=, or layout=".into(),
+ ))
+ }
+ }
+ "alerts.status" => {
+ let critical = self.alerts.iter().filter(|a| matches!(a.severity, Severity::Critical)).count();
+ let warning = self.alerts.iter().filter(|a| matches!(a.severity, Severity::Warning)).count();
+ let info_count = self.alerts.iter().filter(|a| matches!(a.severity, Severity::Info)).count();
+ let top: Vec = self.alerts.iter().take(5).map(Self::fmt_alert).collect();
+ Ok(serde_json::to_string(&serde_json::json!({
+ "total": self.alerts.len(),
+ "critical": critical,
+ "warning": warning,
+ "info": info_count,
+ "alerts": top,
+ })).unwrap_or_default())
+ }
+ "plugin.status" => {
+ let critical = self.alerts.iter().filter(|a| matches!(a.severity, Severity::Critical)).count();
+ Ok(serde_json::to_string(&serde_json::json!({
+ "enabled": self.enabled,
+ "ticks": self.tick_count,
+ "last_action": self.last_action,
+ "last_result": self.last_action_result,
+ "active_alerts": self.alerts.len(),
+ "critical_alerts": critical,
+ })).unwrap_or_default())
+ }
+ _ => {
+ return Err(PluginError::UnknownAction(action.to_string()));
+ }
+ };
+
+ match &result {
+ Ok(r) => self.last_action_result = format!("ok ({} chars)", r.len()),
+ Err(e) => self.last_action_result = format!("error: {e}"),
+ }
+
+ result
+ }
+}
diff --git a/plugins/xtop-plugin-sentinel/src/mcp.rs b/plugins/xtop-plugin-sentinel/src/mcp.rs
new file mode 100644
index 0000000..ccc52db
--- /dev/null
+++ b/plugins/xtop-plugin-sentinel/src/mcp.rs
@@ -0,0 +1,317 @@
+//! MCP (Model Context Protocol) server for the Sentinel plugin.
+//!
+//! Runs on stdio transport and exposes Sentinel's `execute()` commands as MCP tools.
+//! Any MCP-compatible AI (Claude Desktop, Cline, etc.) can connect via:
+//!
+//! ```json
+//! {
+//! "mcpServers": {
+//! "xtop": {
+//! "command": "xtop",
+//! "args": ["mcp"]
+//! }
+//! }
+//! }
+//! ```
+//!
+//! Protocol: JSON-RPC 2.0 over stdin/stdout (one JSON object per line).
+
+use std::io::{self, BufRead, Write};
+use xtop_core::application::state::AppState;
+
+const SERVER_NAME: &str = "xtop-sentinel";
+const SERVER_VERSION: &str = "0.1.0";
+const PROTOCOL_VERSION: &str = "2024-11-05";
+
+/// Run the MCP server loop.
+///
+/// Reads JSON-RPC messages from stdin, processes them via the Sentinel plugin's
+/// `execute()` interface, and writes responses to stdout.
+///
+/// `state` must already have the Sentinel plugin registered in its PluginManager.
+pub fn run_server(state: &mut AppState) -> anyhow::Result<()> {
+ let stdin = io::stdin();
+ let stdout = io::stdout();
+ let mut stdout_lock = stdout.lock();
+
+ for line in stdin.lock().lines() {
+ let line = line.map_err(|e| anyhow::anyhow!("stdin read error: {e}"))?;
+ let line = line.trim().to_string();
+ if line.is_empty() {
+ continue;
+ }
+
+ let parsed: serde_json::Value = serde_json::from_str(&line)
+ .map_err(|e| anyhow::anyhow!("invalid JSON-RPC: {e}"))?;
+
+ let id = parsed.get("id").cloned();
+ let method = parsed
+ .get("method")
+ .and_then(|m| m.as_str())
+ .unwrap_or("");
+
+ let params = parsed.get("params").cloned().unwrap_or(serde_json::Value::Null);
+
+ let response = match method {
+ "initialize" => handle_initialize(id, ¶ms),
+ "tools/list" => handle_tools_list(id),
+ "tools/call" => handle_tools_call(id, ¶ms, state),
+ _ => make_error(id, -32601, format!("Method not found: {method}")),
+ };
+
+ let response_line = serde_json::to_string(&response)?;
+ writeln!(stdout_lock, "{response_line}")?;
+ stdout_lock.flush()?;
+ }
+
+ Ok(())
+}
+
+// ---------------------------------------------------------------------------
+// JSON-RPC helpers
+// ---------------------------------------------------------------------------
+
+fn make_result(id: Option, result: serde_json::Value) -> serde_json::Value {
+ serde_json::json!({
+ "jsonrpc": "2.0",
+ "id": id,
+ "result": result
+ })
+}
+
+fn make_error(id: Option, code: i32, message: String) -> serde_json::Value {
+ serde_json::json!({
+ "jsonrpc": "2.0",
+ "id": id,
+ "error": { "code": code, "message": message }
+ })
+}
+
+// ---------------------------------------------------------------------------
+// MCP: initialize
+// ---------------------------------------------------------------------------
+
+fn handle_initialize(id: Option, _params: &serde_json::Value) -> serde_json::Value {
+ make_result(id, serde_json::json!({
+ "protocolVersion": PROTOCOL_VERSION,
+ "capabilities": { "tools": {} },
+ "serverInfo": { "name": SERVER_NAME, "version": SERVER_VERSION }
+ }))
+}
+
+// ---------------------------------------------------------------------------
+// MCP: tools/list
+// ---------------------------------------------------------------------------
+
+fn handle_tools_list(id: Option) -> serde_json::Value {
+ make_result(id, serde_json::json!({
+ "tools": [
+ {
+ "name": "system_summary",
+ "description": "Get a high-level system health summary (CPU, memory, disks, network, uptime, hostname)",
+ "inputSchema": { "type": "object", "properties": {} }
+ },
+ {
+ "name": "processes_top",
+ "description": "Get top N processes by CPU usage, with optional regex filter",
+ "inputSchema": {
+ "type": "object",
+ "properties": {
+ "count": { "type": "integer", "description": "Number of processes (default 10)", "default": 10 },
+ "filter": { "type": "string", "description": "Optional regex to filter by name or command" }
+ }
+ }
+ },
+ {
+ "name": "processes_search",
+ "description": "Search processes using regex. Fields: name, cmd, user, state, exe, cwd",
+ "inputSchema": {
+ "type": "object",
+ "properties": {
+ "pattern": { "type": "string", "description": "Regex pattern" },
+ "fields": { "type": "string", "description": "Fields to search: name,cmd,user,state,exe,cwd (default: name)" }
+ },
+ "required": ["pattern"]
+ }
+ },
+ {
+ "name": "process_info",
+ "description": "Get detailed info about a process by PID (includes exe, ppid, threads, cwd)",
+ "inputSchema": {
+ "type": "object",
+ "properties": {
+ "pid": { "type": "integer", "description": "Process ID" }
+ },
+ "required": ["pid"]
+ }
+ },
+ {
+ "name": "process_kill",
+ "description": "Terminate a process by PID",
+ "inputSchema": {
+ "type": "object",
+ "properties": {
+ "pid": { "type": "integer", "description": "Process ID to kill" }
+ },
+ "required": ["pid"]
+ }
+ },
+ {
+ "name": "threshold_set",
+ "description": "Set alert thresholds for CPU, memory, and disk (percentages)",
+ "inputSchema": {
+ "type": "object",
+ "properties": {
+ "cpu": { "type": "number", "description": "CPU threshold" },
+ "mem": { "type": "number", "description": "Memory threshold" },
+ "disk": { "type": "number", "description": "Disk threshold" }
+ },
+ "required": ["cpu", "mem", "disk"]
+ }
+ },
+ {
+ "name": "threshold_get",
+ "description": "Get current alert threshold values",
+ "inputSchema": { "type": "object", "properties": {} }
+ },
+ {
+ "name": "config_get",
+ "description": "Get current xtop configuration",
+ "inputSchema": { "type": "object", "properties": {} }
+ },
+ {
+ "name": "config_set",
+ "description": "Update configuration: interval_ms, theme, or layout",
+ "inputSchema": {
+ "type": "object",
+ "properties": {
+ "interval_ms": { "type": "integer", "description": "Update interval in milliseconds" },
+ "theme": { "type": "string", "description": "Theme name" },
+ "layout": { "type": "string", "description": "Layout name" }
+ }
+ }
+ },
+ {
+ "name": "process_alerts",
+ "description": "Get all heuristic alerts as a JSON array (suspicious_exe_path, masquerading, known_threat, pipe_download, orphan, privilege_escalation, browser_child, thread_anomaly, fd_anomaly, spawn_storm)",
+ "inputSchema": { "type": "object", "properties": {} }
+ },
+ {
+ "name": "alerts_status",
+ "description": "Get alert summary with counts by severity (critical, warning, info)",
+ "inputSchema": { "type": "object", "properties": {} }
+ },
+ {
+ "name": "plugin_status",
+ "description": "Get Sentinel plugin internal status",
+ "inputSchema": { "type": "object", "properties": {} }
+ }
+ ]
+ }))
+}
+
+// ---------------------------------------------------------------------------
+// MCP: tools/call
+// ---------------------------------------------------------------------------
+
+fn handle_tools_call(
+ id: Option,
+ params: &serde_json::Value,
+ state: &mut AppState,
+) -> serde_json::Value {
+ let name = params.get("name").and_then(|n| n.as_str()).unwrap_or("");
+ let args = params.get("arguments").cloned().unwrap_or(serde_json::Value::Null);
+
+ // Map MCP tool name -> Sentinel action + params string
+ let (action, params_str): (&str, String) = match name {
+ "system_summary" => ("system.summary", String::new()),
+
+ "processes_top" => {
+ let count = args.get("count").and_then(|c| c.as_i64()).unwrap_or(10);
+ let filter = args.get("filter").and_then(|f| f.as_str());
+ let p = match filter {
+ Some(f) => format!("{count},filter={f}"),
+ None => count.to_string(),
+ };
+ ("processes.top", p)
+ }
+
+ "processes_search" => {
+ let pattern = args.get("pattern").and_then(|p| p.as_str()).unwrap_or("");
+ let fields = args.get("fields").and_then(|f| f.as_str());
+ let p = match fields {
+ Some(f) => format!("{pattern},fields={f}"),
+ None => pattern.to_string(),
+ };
+ ("processes.search", p)
+ }
+
+ "process_info" => {
+ let pid = match args.get("pid").and_then(|p| p.as_i64()) {
+ Some(p) => p.to_string(),
+ None => return make_error(id, -32602, "missing required argument: pid".into()),
+ };
+ ("process.info", pid)
+ }
+
+ "process_kill" => {
+ let pid = match args.get("pid").and_then(|p| p.as_i64()) {
+ Some(p) => p.to_string(),
+ None => return make_error(id, -32602, "missing required argument: pid".into()),
+ };
+ ("process.kill", pid)
+ }
+
+ "threshold_set" => {
+ let cpu = match args.get("cpu").and_then(|c| c.as_f64()) {
+ Some(v) => v.to_string(),
+ None => return make_error(id, -32602, "missing required argument: cpu".into()),
+ };
+ let mem = match args.get("mem").and_then(|m| m.as_f64()) {
+ Some(v) => v.to_string(),
+ None => return make_error(id, -32602, "missing required argument: mem".into()),
+ };
+ let disk = match args.get("disk").and_then(|d| d.as_f64()) {
+ Some(v) => v.to_string(),
+ None => return make_error(id, -32602, "missing required argument: disk".into()),
+ };
+ ("threshold.set", format!("{cpu},{mem},{disk}"))
+ }
+
+ "threshold_get" => ("threshold.get", String::new()),
+ "config_get" => ("config.get", String::new()),
+
+ "config_set" => {
+ if let Some(ms) = args.get("interval_ms").and_then(|v| v.as_i64()) {
+ ("config.set", format!("interval_ms={ms}"))
+ } else if let Some(theme) = args.get("theme").and_then(|v| v.as_str()) {
+ ("config.set", format!("theme={theme}"))
+ } else if let Some(layout) = args.get("layout").and_then(|v| v.as_str()) {
+ ("config.set", format!("layout={layout}"))
+ } else {
+ return make_error(id, -32602, "expected interval_ms, theme, or layout".into());
+ }
+ }
+
+ "process_alerts" => ("process.alerts", String::new()),
+ "alerts_status" => ("alerts.status", String::new()),
+ "plugin_status" => ("plugin.status", String::new()),
+
+ _ => return make_error(id, -32601, format!("Tool not found: {name}")),
+ };
+
+ // Tick to refresh data (also ticks plugins)
+ state.on_tick();
+
+ // Execute via Sentinel plugin
+ let result_str = state.with_plugin_manager_mut(|mgr, this| {
+ mgr.execute(this, "sentinel", action, ¶ms_str)
+ });
+
+ match result_str {
+ Ok(json_str) => make_result(id, serde_json::json!({
+ "content": [{"type": "text", "text": json_str}]
+ })),
+ Err(e) => make_error(id, -32000, e.to_string()),
+ }
+}
From e9f66e31caa1c188df4c6d4de9dd5d664d63f44d Mon Sep 17 00:00:00 2001
From: xscriptor
Date: Sat, 20 Jun 2026 19:21:09 +0200
Subject: [PATCH 6/6] update docs signature and contrib
---
README.md | 34 +++++++++++++++++++---------------
1 file changed, 19 insertions(+), 15 deletions(-)
diff --git a/README.md b/README.md
index b83a908..fe2f2b3 100644
--- a/README.md
+++ b/README.md
@@ -12,11 +12,11 @@ A cross-platform TUI system monitor written in Rust. Uses
+
-Table of Contents
+Contents
@@ -116,20 +117,23 @@ cargo run --release
Customization -- custom themes and layouts
Roadmap
Changelog
+ Contributing
+ License
-Contributing
-
-Contributions are welcome. See CONTRIBUTING.md for guidelines.
-
-
-
-License
-
-MIT
-
-
+
+
X
+
+
+
+
+ &
+
+
+
+ &
+
+
+
\ No newline at end of file