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

-![rust](https://xscriptor.github.io/badges/languages/rust.svg) ![mit](https://xscriptor.github.io/badges/licenses/mit.svg) ![shell](https://xscriptor.github.io/badges/languages/shell.svg) ![powershell](https://xscriptor.github.io/badges/languages/powershell.svg) ![xtop](https://xscriptor.github.io/badges/software/xtop.svg) +![Rust](https://img.shields.io/badge/Rust-1.80%2B-orange) +![License](https://img.shields.io/badge/license-MIT-blue) +![CI](https://img.shields.io/github/actions/workflow/status/xscriptor/xtop/ci.yml?branch=main) +![Platform](https://img.shields.io/badge/platform-linux%20%7C%20macos%20%7C%20windows-lightgrey) +![ratatui](https://img.shields.io/badge/built%20with-ratatui-red) -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.
-

Xscriptor logo

+

Xtop icon

--- -# 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

+ +
    +
  1. Clone the repository: +
    + +```bash +git clone https://github.com/xscriptor/xtop.git +cd xtop +``` + +
    +
  2. +
  3. Build and run: + +```bash +cargo run --release +``` + +
  4. +
+ +--- + +

Usage

+ +

Keybindings

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
KeyAction
qQuit application (saves config)
?Toggle help overlay
tNext color theme
TPrevious color theme
lNext layout mode (built-in + custom)
fToggle fullscreen for current widget
FCycle fullscreen through widgets
/Search / filter processes
EscCancel search / close help overlay
+ +

Modules

+ +
    +
  1. Header: Shows system uptime, load average, current theme, and layout mode.
  2. +
  3. CPU: Shows usage bars for each CPU core. If sensors are available, shows the maximum CPU temperature.
  4. +
  5. Memory: Gauges for RAM and Swap usage, plus a line chart for RAM history.
  6. +
  7. Storage: Disk usage gauges per mount point.
  8. +
  9. Network: Total downloaded (RX) and uploaded (TX) data per interface.
  10. +
  11. Disk I/O: Read/write speeds per disk device.
  12. +
  13. Processes: A scrolling list of the top 50 processes sorted by CPU usage, with live search.
  14. +
  15. GPU: GPU usage gauges (available on supported hardware).
  16. +
  17. Battery: Battery charge level gauges (available on supported hardware).
  18. +
+ +

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 + + + + + + + + + + + + + + + + + + + + +
FeatureLocationFormat
Themes~/.config/xtop/themes/*.jsonc16-entry hex color palette
Layouts~/.config/xtop/layouts/*.jsoncRecursive 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 +

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

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
IndexUsageExample
0Background#1a1b1c
1Red / Alert#e06c75
2Green (RAM gauge)#98c379
3Yellow (Swap gauge)#e5c07b
4Orange (Storage, Network TX)#d19a66
5Purple (GPU)#c678dd
6Cyan (Accents, table headers)#56b6c2
7Foreground / Text#abb2bf
8Bright black (Separators)#3e4451
9Bright red#e06c75
10Bright green#98c379
11Bright yellow#e5c07b
12Bright orange#d19a66
13Bright purple#c678dd
14Bright cyan#56b6c2
15Bright 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 +
    +
  1. Built-in miami theme (always available)
  2. +
  3. Themes from ~/.config/xtop/themes/ loaded alphabetically
  4. +
  5. If a custom theme has the same name as miami, it replaces the built-in
  6. +
-### 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 +

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

+ + + + + + + + + + + + + + + + + + + + + + +
SyntaxMeaning
"*" or omittedFill remaining space
3 (number)Fixed n rows/columns
"45%"Percentage of parent
+ +

Available Widgets

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
WidgetDescription
headerSystem info bar (uptime, load, keys)
cpuPer-core CPU usage gauges
memoryRAM + Swap gauges + RAM history chart
storageDisk usage gauges per mount point
networkNetwork RX/TX totals and speeds
processesProcess table with search filter
disk_ioDisk read/write speeds
batteryBattery charge gauges
gpuGPU 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 +
    +
  1. Built-in layouts (Dashboard → Vertical → Horizontal → CPU Focus → Memory Focus → Network Focus → Process Focus)
  2. +
  3. Custom layouts from ~/.config/xtop/layouts/ (in filesystem order)
  4. +
  5. Wraps back to Dashboard
  6. +
-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 @@ ![Platform](https://img.shields.io/badge/platform-linux%20%7C%20macos%20%7C%20windows-lightgrey) ![ratatui](https://img.shields.io/badge/built%20with-ratatui-red) -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.

Xtop icon

---- +
-## 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 - - - - - - - - - - -
- - Preview 2 - - - - Preview 3 - -
- - Preview 4 - - -
-
- ---- - -

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
-
    -
  1. 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
    -```
    -
    -    
    -
  2. -
  3. Build and run: - -```bash -cargo run --release -``` - -
  4. -
- ---- - -

Usage

+cargo run --release
-

Keybindings

+

For detailed installation instructions, see docs/installation.md.

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
KeyAction
qQuit application (saves config)
?Toggle help overlay
tNext color theme
TPrevious color theme
lNext layout mode (built-in + custom)
fToggle fullscreen for current widget
FCycle fullscreen through widgets
/Search / filter processes
EscCancel search / close help overlay
- -

Modules

- -
    -
  1. Header: Shows system uptime, load average, current theme, and layout mode.
  2. -
  3. CPU: Shows usage bars for each CPU core. If sensors are available, shows the maximum CPU temperature.
  4. -
  5. Memory: Gauges for RAM and Swap usage, plus a line chart for RAM history.
  6. -
  7. Storage: Disk usage gauges per mount point.
  8. -
  9. Network: Total downloaded (RX) and uploaded (TX) data per interface.
  10. -
  11. Disk I/O: Read/write speeds per disk device.
  12. -
  13. Processes: A scrolling list of the top 50 processes sorted by CPU usage, with live search.
  14. -
  15. GPU: GPU usage gauges (available on supported hardware).
  16. -
  17. Battery: Battery charge level gauges (available on supported hardware).
  18. -
+
-

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:

- - - - - + - - - - - - - - - - + + + + + +
FeatureLocationFormat
KeyAction
Themes~/.config/xtop/themes/*.jsonc16-entry hex color palette
Layouts~/.config/xtop/layouts/*.jsoncRecursive split/widget tree
qQuit (saves config)
?Toggle help overlay
t / TNext / previous theme
lNext layout mode
f / FToggle / 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

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
SettingDescription
themeCurrently selected color theme name
layoutCurrently selected layout mode
intervalUpdate interval in milliseconds
history_pointsNumber of data points retained for the RAM history chart
alert_thresholdsThreshold 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.

+ + + + + + + + + + + + + + + + + + + + + + + + + + +
ThresholdDescriptionDefault
cpu_highCPU usage percentage that triggers a warning90%
mem_highMemory usage percentage that triggers a warning90%
disk_highDisk usage percentage that triggers a warning90%
+ +
+ +

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

    + +
      +
    1. +

      Clone the repository:

      +
      git clone https://github.com/xscriptor/xtop.git
      +cd xtop
      +
    2. +
    3. +

      Build and run with release optimizations:

      +
      cargo run --release
      +
    4. +
    + +
    + +

    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:

    + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    ComponentLocationPurpose
    Plugin traitxtop-core::domain::pluginInterface every plugin must implement
    PluginManagerxtop-core::application::plugin_managerLoads, ticks, and dispatches events to plugins
    CompositeProviderxtop-core::infrastructure::composite_providerMerges data from primary provider + plugin providers
    WidgetRegistrationxtop-core::domain::pluginAllows 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>;
    +}
    + + + + + + + + + + + + + + + +
    MethodDefaultCalled When
    manifest()requiredAny time metadata is needed
    on_enable()no-opPlugin is registered at startup
    on_disable()no-opxtop shuts down
    on_tick()no-opEvery update cycle (~1s)
    on_key()returns falseKey press (consumes if returns true)
    data_provider()NoneStartup (merged into CompositeProvider)
    widget()NoneEvery tick (refreshes widget registry)
    execute()UnknownActionExternal 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
    • +
    + +
    + +

    Widget Registration

    + +

    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

    + + + + + + + + + + + + + + + + + + + + + + + +
    CommandDescription
    xtop plugin listList 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:

    + +
      +
    1. Clones github.com/xscriptor/xtop.git (shallow, sparse)
    2. +
    3. Looks for plugins/xtop-plugin-sentinel/ or plugins/sentinel/ in the clone
    4. +
    5. Copies to local plugins/ directory
    6. +
    7. Adds entry to [workspace].members in root Cargo.toml
    8. +
    9. Adds optional dependency + feature flag in crates/xtop-cli/Cargo.toml
    10. +
    11. Runs cargo build --release
    12. +
    13. Cleans up temporary files
    14. +
    + +

    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

    + +
      +
    1. +

      Create the crate in plugins/:

      +
      mkdir -p plugins/xtop-plugin-mything/src
      +
    2. +
    3. +

      Add to workspace Cargo.toml:

      +
      [workspace]
      +members = [
      +    ...
      +    "plugins/xtop-plugin-mything",
      +]
      +
    4. +
    5. +

      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"]
      +
    6. +
    7. +

      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}");
      +    }
      +}
      +
    8. +
    + +
    + +

    + 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

    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    KeyAction
    qQuit application (saves configuration)
    ?Toggle help overlay
    tNext color theme
    TPrevious color theme
    lNext layout mode (cycles through built-in and custom layouts)
    fToggle full-screen mode for the current widget
    FCycle full-screen focus through all available widgets
    /Open process search / filter
    EnterConfirm search filter
    BackspaceDelete character in search input
    EscCancel search / close help overlay
    + +
    + +

    Modules

    + +
      +
    1. +

      Header -- Shows system uptime, load average, current theme name, and active layout mode.

      +
    2. +
    3. +

      CPU -- Horizontal usage bars for each CPU core. If hardware sensors are available, displays the maximum CPU temperature.

      +
    4. +
    5. +

      Memory -- Gauges for RAM and Swap usage, plus a line chart showing RAM usage over a configurable history window.

      +
    6. +
    7. +

      Storage -- Disk usage gauges per mount point showing capacity and used space.

      +
    8. +
    9. +

      Network -- Total downloaded (RX) and uploaded (TX) data per interface, with current transfer rates.

      +
    10. +
    11. +

      Disk I/O -- Read and write speeds per disk device in bytes per second.

      +
    12. +
    13. +

      Processes -- Scrolling list of the top 50 processes sorted by CPU usage, with live search filtering by process name.

      +
    14. +
    15. +

      GPU -- GPU usage gauges (available on supported hardware).

      +
    16. +
    17. +

      Battery -- Battery charge level gauges (available on supported hardware).

      +
    18. +
    + +
    + +

    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 SizeBehavior
    Wider than 100 cols and taller than 30 rowsFull dashboard layout with 2 columns
    Wider than 80 cols and taller than 24 rowsCompact layout
    Narrower than 80 colsVertically stacked layout
    Narrower than 60 cols or shorter than 18 rowsMinimal layout: CPU, Memory, and Processes only
    Smaller than 40 x 8Warning 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.

    + + + + + + +
    ParamsResponse
    (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.

    + + + + + + +
    ParamsResponse
    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.

    + + + + + + +
    ParamsResponse
    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.

    + + + + + + +
    ParamsResponse
    PID (e.g. "1234")JSON object
    + +

    Example response:

    + +
    {"killed": true, "pid": 1234}
    + +

    threshold.set

    + +

    Sets alert thresholds for CPU, memory, and disk usage.

    + + + + + + +
    ParamsResponse
    "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.

    + + + + + + +
    ParamsResponse
    (empty)JSON object
    + +

    Response:

    + +
    {"cpu": 90, "mem": 90, "disk": 90}
    + +

    config.get

    + +

    Returns the current xtop configuration.

    + + + + + + +
    ParamsResponse
    (empty)JSON object
    + +

    Response:

    + +
    {"theme": "x", "layout": "Dashboard", "interval_ms": 1000, "hostname": "mbp.local"}
    + +

    alerts.status

    + +

    Returns any alerts generated by process analysis heuristics.

    + + + + + + +
    ParamsResponse
    (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.

    + + + + + + +
    ParamsResponse
    "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.

    + + + + + + +
    ParamsResponse
    "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.

    + + + + + + +
    ParamsResponse
    (empty)JSON object
    + +

    Response:

    + +
    {"enabled": true, "ticks": 42, "last_action": "system.summary", "last_result": "ok (147 chars)"}
    + +
    + +

    TUI Widget

    + +

    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

    + + + + + + + + + + + + + + + + +
    ToolArgumentsDescription
    system_summary(none)System health summary
    processes_topcount, filter (regex)Top processes
    processes_searchpattern, fieldsRegex search
    process_infopidProcess details
    process_killpidKill process
    threshold_setcpu, mem, diskSet alert thresholds
    threshold_get(none)Get thresholds
    config_get(none)Get config
    config_setinterval_ms / theme / layoutUpdate 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:

    + +
      +
    1. AI calls system_summary to get a health overview
    2. +
    3. AI calls processes_top with optional regex filter
    4. +
    5. If suspicious, AI calls process_info for details
    6. +
    7. AI may call process_kill to terminate
    8. +
    9. AI calls alerts_status periodically for anomalies
    10. +
    + +
    + +

    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 Xtop icon

    +

    XTop logo


    -

    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--- -
    +
    +

    X

    + + + X Web + + & + + X Github Profile + + & + + Xscriptor web + \ No newline at end of file