feat(ui): TUI restyle, theme presets, and config file (#563)

* feat(ui): theme presets, config file, and TUI restyle
* fix(ui): flat status bar alerts and sandbox-aware copy hints
* feat(ui): rename --theme classic to vivid
* feat(ui): darken gray text tiers on light terminal backgrounds
This commit is contained in:
Marco Cadetg
2026-08-20 20:46:54 +02:00
committed by GitHub
parent bb4e358593
commit 7f41fbc476
68 changed files with 5148 additions and 1217 deletions
+71
View File
@@ -45,6 +45,28 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
for non-unicast endpoints
### Added
- **Light Background Detection**: at startup rustnet asks the terminal for its
background color (OSC 11, Unix only) and, on a light background, darkens the
ANSI Gray muted/label text tiers to DarkGray, which were nearly unreadable on
white; the per-process identity tints darken likewise. Terminals that stay
silent past a 150 ms timeout keep the theme unchanged, and explicit
`[theme.overrides]` values are never touched (#563)
- **Theme Contrast Warning**: config file color overrides that leave a
foreground/background pair below 3:1 contrast now print a startup warning.
Only pairs the override touches are judged, so the built-in palettes are
never second-guessed, and the colors are never altered. Body text is the
terminal's own foreground, so pairs involving it cannot be measured (#563)
- **Truecolor Detection on Direct-Color Terminals**: `TERM=*-direct` entries
advertise 24-bit color without setting `COLORTERM`, and are no longer
downgraded to ANSI-16 (#563)
- **Config File Under sudo**: `sudo rustnet` now reads the invoking user's
config rather than root's, resolving their home from the passwd database
the same way the privilege drop resolves `SUDO_UID`. A config not owned by
that user is refused, since the read happens with root privileges (#563)
- **New Theme Presets**: `--theme` gains `catppuccin-mocha`, `tokyo-night`,
`gruvbox`, and `nord` truecolor themes with ANSI fallback (#563)
- **Config File**: optional `~/.config/rustnet/config.toml` sets the theme and
per-color overrides; `--theme` takes precedence (#563)
- **Passive DNS Attribution**: connections without an SNI or HTTP Host header
(encrypted QUIC, plain TCP/UDP) are now tagged with a hostname inferred from
DNS responses observed on the wire within the last 10 seconds, shown as a
@@ -193,6 +215,55 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
each socket-table refresh (#513)
### Changed
- **Scrollbar**: the thumb is now a thin accent-colored bar on the outer edge
of its column instead of a full block in the terminal foreground, and the
track rule is gone, so the bar reads as a cue rather than a second vertical
line beside the data (#563)
- **`--theme classic` Renamed to `vivid`**: the name now describes the colors
rather than the provenance. It is the same ANSI-16 palette as `muted` with
the chrome colored: yellow headings and keys, magenta borders. `classic` is
no longer accepted (#563)
- **Status Bar Rebuilt Around Priority**: the footer now shows the active
tab's context actions on the left and a fixed `h help q quit` cluster
pinned right. The cluster is reserved before any context action is placed,
so quit never falls off the edge; when the terminal is too narrow to spell
the actions out, the labels go first and the keys stand alone. Tab
navigation hints are gone, since the numbered tab bar already advertises
them, and the exhaustive keymap lives on the Help tab. Trailing actions
are dropped one at a time to keep the remaining labels readable, and only
a very narrow terminal falls back to bare keys. Keys are bright text on
the terminal background rather than a reverse-video band, and Details
only offers `ctrl-d/u` when the record outgrows its pane (#563)
- **Status Bar Alerts Match the Chrome**: the quit prompt, copy feedback,
and capture errors now carry their meaning in a bold signal color instead
of filling the row with a solid yellow, green, or red band. `NO_COLOR`
keeps the reverse-video band, where it is the only cue available (#563)
- **Copy Hints Follow the Sandbox**: on Linux under the default sandbox the
clipboard cannot be reached, so the `c copy` hints and the Details
"click a field to copy" affordance are hidden rather than offering a key
that can only report an error. `--no-sandbox` restores them (#563)
- **Filtering Is a Mode, Not Four Notices**: the filter input row now shows
only while a query is being typed. Once confirmed it collapses, leaving the
query chip in the Connections title and the activity dot on the Overview
tab, and the status bar returns to actions with `esc clear filter` first.
While typing, the footer offers only what the filter editor handles, since
every other key types a character into the query (#563)
- **Connection Table Cues**: the selected row now leads with an accent bar,
and process names get a stable per-name tint (#563)
- **Tab Bar Status Cues**: the tab row right-aligns the capture interface and
its link type, with a dot that turns red while capture is failing, and marks
Overview with a `•` while a filter is active. The active filter query also
shows next to the connection count in the Connections title. The capture
cluster drops its link type, then itself, when the tab row gets tight (#563)
- **Details Header Badges**: the Details header shows the connection state as a
colored pill plus chips for the current rates and RTT, dropping chips
right to left as the terminal narrows. Scrolled Details and Help panes dim the
line where the content continues, and long process paths now truncate from the
left so the binary name stays visible (#563)
- **Loading Shimmer**: the loading screen text shimmers across the accent color
on truecolor terminals and stays static everywhere else (#563)
- **TUI Restyle**: keycap-style status bar, realigned Help tab, and refined
bar, scrollbar, and selection styling (#563)
- **Details Tab Application Card Alignment**: every protocol's Application
card now renders a fixed row set with `-` placeholders instead of rows that
appear and disappear with data availability; HTTPS shows its four rows even
Generated
+61
View File
@@ -2712,6 +2712,7 @@ dependencies = [
"serde_json",
"sha2 0.11.0",
"simplelog",
"toml",
"windows 0.62.2",
"zip",
]
@@ -2840,6 +2841,15 @@ dependencies = [
"zmij",
]
[[package]]
name = "serde_spanned"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26"
dependencies = [
"serde_core",
]
[[package]]
name = "sha1"
version = "0.11.0"
@@ -3244,6 +3254,45 @@ dependencies = [
"serde_json",
]
[[package]]
name = "toml"
version = "0.9.12+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863"
dependencies = [
"indexmap",
"serde_core",
"serde_spanned",
"toml_datetime",
"toml_parser",
"toml_writer",
"winnow 0.7.15",
]
[[package]]
name = "toml_datetime"
version = "0.7.5+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347"
dependencies = [
"serde_core",
]
[[package]]
name = "toml_parser"
version = "1.1.3+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56"
dependencies = [
"winnow 1.0.4",
]
[[package]]
name = "toml_writer"
version = "1.1.2+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2"
[[package]]
name = "tracing"
version = "0.1.44"
@@ -4080,6 +4129,18 @@ version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"
[[package]]
name = "winnow"
version = "0.7.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945"
[[package]]
name = "winnow"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81"
[[package]]
name = "wit-bindgen"
version = "0.51.0"
+1
View File
@@ -85,6 +85,7 @@ chrono = "0.4"
ratatui = { version = "0.30", features = ["all-widgets"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
toml = "0.9"
regex-lite = "0.1"
# Note: dns-lookup, ring, aes, flate2, and maxminddb moved to rustnet-core,
# which is the only place they are used. They are re-exported transitively
+3
View File
@@ -93,9 +93,12 @@ rustnet -i any # Linux ですべてのインターフ
rustnet --show-localhost # localhost 接続を表示
rustnet --no-resolve-dns # 逆引き DNS を無効化
rustnet --no-dpi # 深層パケット解析を無効化
rustnet --theme tokyo-night # カラーテーマ(muted[既定]、vivid、catppuccin-mocha、tokyo-night、gruvbox、nord
rustnet --pcapng-export capture.pcapng # 注釈付き PCAPNG を出力
```
テーマと各色の上書きは `~/.config/rustnet/config.toml` でも設定できます(`--theme` が優先)。詳細は [USAGE.md](USAGE.md#--theme-preset) を参照してください。
## 基本操作
| キー | 操作 |
+3 -1
View File
@@ -188,10 +188,12 @@ rustnet -i eth0 # Specify network interface
rustnet --show-localhost # Show localhost connections
rustnet --no-resolve-dns # Disable reverse DNS lookups (enabled by default)
rustnet -r 500 # Set refresh interval (ms)
rustnet --theme classic # Original full-color palette (default: muted)
rustnet --theme tokyo-night # Theme: muted (default), vivid, catppuccin-mocha, tokyo-night, gruvbox, nord
rustnet --pcapng-export capture.pcapng # Annotated PCAPNG for Wireshark
```
The theme and per-color overrides can also be set in `~/.config/rustnet/config.toml`; `--theme` takes precedence. See [USAGE.md](USAGE.md#--theme-preset) for the schema.
See [INSTALL.md](INSTALL.md) for detailed permission setup and [USAGE.md](USAGE.md) for complete options.
> If you set capabilities but the TUI still shows `eBPF unavailable`, see
+3 -1
View File
@@ -188,10 +188,12 @@ rustnet -i eth0 # 指定网络接口
rustnet --show-localhost # 显示 localhost 上的连接
rustnet --no-resolve-dns # 关闭反向 DNS 解析(默认开启)
rustnet -r 500 # 设置刷新间隔(毫秒)
rustnet --theme classic # 原始全彩调色板(默认:muted)
rustnet --theme tokyo-night # 主题:muted(默认)、vivid、catppuccin-mocha、tokyo-night、gruvbox、nord
rustnet --pcapng-export capture.pcapng # 导出带注释的 PCAPNG
```
主题及各颜色的覆盖也可在 `~/.config/rustnet/config.toml` 中设置;`--theme` 优先。配置格式见 [USAGE.zh-CN.md](USAGE.zh-CN.md#--theme-preset)。
权限配置详情见 [INSTALL.zh-CN.md](INSTALL.zh-CN.md),完整参数说明见 [USAGE.zh-CN.md](USAGE.zh-CN.md)。
> 如果已经设置了 Linux capabilities,但 TUI 仍然提示 `eBPF unavailable`,请参阅 [INSTALL.zh-CN.md 的排障章节](INSTALL.zh-CN.md#ebpf-unavailable-despite-capabilities-being-set)。
+52 -9
View File
@@ -63,8 +63,8 @@ rustnet --refresh-interval 2000
# Disable deep packet inspection
rustnet --no-dpi
# Restore the original full-color palette
rustnet --theme classic
# Pick a color theme (muted, vivid, catppuccin-mocha, tokyo-night, gruvbox, nord)
rustnet --theme tokyo-night
# Disable reverse DNS lookups (enabled by default)
rustnet --no-resolve-dns
@@ -95,8 +95,9 @@ Options:
--pcap-export <FILE> Export captured packets to PCAP file for Wireshark analysis
--pcapng-export <FILE> Export captured packets to annotated PCAPNG file for Wireshark analysis
--no-color Disable all colors in the UI (also respects NO_COLOR env var)
--theme <PRESET> Color theme preset: "muted" (single accent, color reserved
for signals, default) or "classic" (original full-color palette)
--theme <PRESET> Color theme: muted (default), vivid, catppuccin-mocha,
tokyo-night, gruvbox, nord. Overrides the theme set in the
config file (~/.config/rustnet/config.toml)
--geoip-country <PATH> Path to GeoLite2-Country.mmdb (auto-discovered if not specified)
--geoip-asn <PATH> Path to GeoLite2-ASN.mmdb (auto-discovered if not specified)
--geoip-city <PATH> Path to GeoLite2-City.mmdb (auto-discovered if not specified)
@@ -210,16 +211,58 @@ Select the color theme preset:
- **`muted`** (default): A restrained palette with one cyan accent. Addresses
keep calm colors (remote = blue, local = cyan); everything else uses color
only for *signals* transitional connection states, staleness (yellow/red
only for *signals*: transitional connection states, staleness (yellow/red
rows), and live bandwidth.
- **`classic`**: The original full-color palette from earlier releases, with a
distinct color per column.
- **`vivid`**: The same ANSI-16 palette as `muted`, but the chrome itself takes
color: yellow headings and keys, magenta borders, and a distinct color per
column.
- **`catppuccin-mocha`**, **`tokyo-night`**, **`gruvbox`**, **`nord`**:
Truecolor renditions of the popular palettes. On terminals without truecolor
support they fall back to the nearest ANSI-16 colors.
```bash
# Bring back the original full-color look
rustnet --theme classic
# Color the chrome too, with a distinct color per column
rustnet --theme vivid
# Use a truecolor theme
rustnet --theme tokyo-night
```
The theme can also be set in an optional config file at
`~/.config/rustnet/config.toml` (`$XDG_CONFIG_HOME/rustnet/config.toml` when
set; `%APPDATA%\rustnet\config.toml` on Windows), so the flag is not needed on
every run. The `[theme.overrides]` table optionally replaces individual colors:
```toml
[theme]
name = "tokyo-night"
[theme.overrides]
accent = "#ff9e64"
border = "darkgray"
```
Override values are ANSI color names (`red`, `lightblue`, `darkgray`, ...) or
`#rrggbb` hex. Valid keys: `accent`, `ok`, `warn`, `err`, `info`, `special`,
`muted`, `faint`, `text`, `heading`, `label`, `key`, `border`, `rx`, `tx`,
`rx_wave`, `tx_wave`, `selection_bg`, `selection_fg`, `status_bg`.
Precedence: `--theme` on the command line overrides the config file, which
overrides the `muted` default. A missing config file is fine; an unreadable or
invalid file, or a bad override value, prints a warning at startup and falls
back to the defaults. Overrides that leave a foreground/background pair below
3:1 contrast also print a startup warning, though the colors are used as
given. Under `sudo rustnet`, the config of the user who ran sudo is read
rather than root's, and the file must be owned by that user.
On light terminal backgrounds, ANSI Gray (the muted/label text tier of the
`muted` and `vivid` presets) is nearly unreadable, so at startup rustnet asks
the terminal for its background color (an OSC 11 query, Unix only) and darkens
those gray tiers to ANSI DarkGray when the background reports as light; the
per-process name tints darken likewise. Terminals that do not answer the query
keep the theme as-is, and explicit `[theme.overrides]` values are never
touched.
Related: `--no-color` disables all colors entirely (also honors the `NO_COLOR`
environment variable).
+47 -8
View File
@@ -63,8 +63,8 @@ rustnet --refresh-interval 2000
# 禁用深度包检测
rustnet --no-dpi
# 恢复原始全彩调色板
rustnet --theme classic
# 选择颜色主题(muted、vivid、catppuccin-mocha、tokyo-night、gruvbox、nord
rustnet --theme tokyo-night
# 禁用反向 DNS 查找(默认启用)
rustnet --no-resolve-dns
@@ -95,8 +95,9 @@ Options:
--pcap-export <FILE> 将捕获的数据包导出到 PCAP 文件供 Wireshark 分析
--pcapng-export <FILE> 将捕获的数据包导出为带注释的 PCAPNG 文件供 Wireshark 分析
--no-color 禁用 UI 中的所有颜色(同时尊重 NO_COLOR 环境变量)
--theme <PRESET> 颜色主题预设:"muted"(单一强调色,颜色仅用于信号,默认)
或 "classic"(原始全彩调色板)
--theme <PRESET> 颜色主题muted(默认)、vivid、catppuccin-mocha、
tokyo-night、gruvbox、nord。优先于配置文件
~/.config/rustnet/config.toml)中设置的主题
--geoip-country <PATH> GeoLite2-Country.mmdb 的路径(未指定时自动发现)
--geoip-asn <PATH> GeoLite2-ASN.mmdb 的路径(未指定时自动发现)
--geoip-city <PATH> GeoLite2-City.mmdb 的路径(未指定时自动发现)
@@ -209,15 +210,53 @@ RustNet 自动检测 TUN/TAP 接口并相应调整数据包解析。接口类型
选择颜色主题预设:
- **`muted`**(默认):克制的调色板,只有一个青色强调色。地址保留柔和的颜色
(远程 = 蓝色,本地 = 青色);其他颜色仅用于*信号* —— 连接状态变化、
(远程 = 蓝色,本地 = 青色);其他颜色仅用于*信号*连接状态变化、
过期状态(黄色/红色行)以及实时带宽。
- **`classic`**:早期版本的原始全彩调色板,每列一种颜色
- **`vivid`**:与 `muted` 相同的 ANSI-16 调色板,但界面框架本身也带颜色
黄色标题与按键、洋红色边框,并且每列一种颜色。
- **`catppuccin-mocha`**、**`tokyo-night`**、**`gruvbox`**、**`nord`**
流行配色的真彩色(truecolor)版本。在不支持真彩色的终端上回退到最接近的
ANSI-16 颜色。
```bash
# 恢复原始全彩外观
rustnet --theme classic
# 让界面框架也带颜色,每列一种颜色
rustnet --theme vivid
# 使用真彩色主题
rustnet --theme tokyo-night
```
主题也可以在可选的配置文件中设置,路径为 `~/.config/rustnet/config.toml`
(设置了 `$XDG_CONFIG_HOME` 时为 `$XDG_CONFIG_HOME/rustnet/config.toml`
Windows 上为 `%APPDATA%\rustnet\config.toml`),这样无需每次运行都加此参数。
可选的 `[theme.overrides]` 表可替换单个颜色:
```toml
[theme]
name = "tokyo-night"
[theme.overrides]
accent = "#ff9e64"
border = "darkgray"
```
覆盖值为 ANSI 颜色名(`red``lightblue``darkgray` 等)或 `#rrggbb`
十六进制值。有效的键:`accent``ok``warn``err``info``special`
`muted``faint``text``heading``label``key``border``rx``tx`
`rx_wave``tx_wave``selection_bg``selection_fg``status_bg`
优先级:命令行的 `--theme` 优先于配置文件,配置文件优先于默认的 `muted`
配置文件缺失没有影响;文件不可读、无效或覆盖值错误时,启动时打印一条警告并
回退到默认值。覆盖使某个前景/背景组合的对比度低于 3:1 时,启动时也会打印一条
警告,但颜色仍按原样使用。通过 `sudo rustnet` 运行时,读取的是执行 sudo 的
用户的配置而非 root 的,且该文件必须归该用户所有。
在浅色终端背景上,ANSI Gray`muted``vivid` 预设的 muted/label 文字层级)
几乎不可读,因此 rustnet 会在启动时向终端查询背景色(OSC 11 查询,仅限
Unix),并在背景报告为浅色时将这些灰色层级加深为 ANSI DarkGray,各进程名的
辨识色也会相应加深。不回应查询的终端保持主题原样,显式的 `[theme.overrides]`
值也绝不会被改动。
相关:`--no-color` 完全禁用所有颜色(同时尊重 `NO_COLOR` 环境变量)。
#### `-f, --bpf-filter <FILTER>`<a id="-f---bpf-filter-filter"></a>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 MiB

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 388 KiB

After

Width:  |  Height:  |  Size: 338 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 MiB

After

Width:  |  Height:  |  Size: 989 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 605 KiB

After

Width:  |  Height:  |  Size: 356 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

After

Width:  |  Height:  |  Size: 406 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 784 KiB

After

Width:  |  Height:  |  Size: 451 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 933 KiB

After

Width:  |  Height:  |  Size: 893 KiB

+10
View File
@@ -36,10 +36,20 @@
# sidebar (GitHub scales the GIF down to the README width; wide captures
# stay sharp on click-through/retina). Narrower canvases push the table
# to its degraded column floor, which hides most of the data.
#
# The font is pinned deliberately. The traffic graphs are drawn with Unicode
# Braille (U+2800..U+28FF), and VHS's default family is plain "JetBrains Mono",
# which is often not installed. Fontconfig then substitutes silently and every
# graph cell renders as fallback tofu, turning the waves into a solid block.
# The Nerd Font Mono variant has Braille coverage and keeps every glyph one
# cell wide, so the table columns stay aligned.
# Arch: sudo pacman -S ttf-jetbrains-mono-nerd
# Verify: fc-list ':charset=2840' family
Output assets/rustnet.gif
Set Theme "Catppuccin Mocha"
Set FontFamily "JetBrainsMono Nerd Font Mono"
Set FontSize 16
Set Width 1600
Set Height 900
+10
View File
@@ -20,10 +20,20 @@
# (shows the continuity strip + "grouped by process" badge)
# 5. Graph (4) -> assets/screenshots/graph.png
# 6. Activity (3) -> assets/screenshots/interfaces.png
#
# The font is pinned deliberately. The traffic graphs are drawn with Unicode
# Braille (U+2800..U+28FF), and VHS's default family is plain "JetBrains Mono",
# which is often not installed. Fontconfig then substitutes silently and every
# graph cell renders as fallback tofu, turning the waves into a solid block.
# The Nerd Font Mono variant has Braille coverage and keeps every glyph one
# cell wide, so the table columns stay aligned.
# Arch: sudo pacman -S ttf-jetbrains-mono-nerd
# Verify: fc-list ':charset=2840' family
Output assets/screenshots/_throwaway.gif
Set Theme "Catppuccin Mocha"
Set FontFamily "JetBrainsMono Nerd Font Mono"
Set FontSize 16
Set Width 1600
Set Height 1000
+10
View File
@@ -6,9 +6,19 @@ set -euo pipefail
#
# Prerequisites (Linux):
# vhs # required (https://github.com/charmbracelet/vhs)
# a Braille-capable font # required, see below
# gifsicle # optional, for GIF size optimization
# dig # optional, no DNS rows in the capture without it
# cargo build --release # auto-run if target/release/rustnet missing
#
# The traffic graphs are drawn with Unicode Braille, so the recording font
# needs those glyphs or every graph cell renders as fallback tofu and the
# waves come out as a solid block. Both tapes pin "JetBrainsMono Nerd Font
# Mono" for that reason.
# Arch: sudo pacman -S ttf-jetbrains-mono-nerd gifsicle bind
# Debian: sudo apt install fonts-jetbrains-mono gifsicle dnsutils
# Verify: fc-list ':charset=2840' family
#
# Usage:
# scripts/record-rustnet-demo.sh
#
+40 -3
View File
@@ -1,5 +1,18 @@
use clap::{Arg, Command};
/// Built-in theme preset names. Spelled out as literals because build.rs
/// `include!`s this file and cannot reach `crate::ui`; a unit test in
/// `ui::theme::definitions` asserts the list stays in sync with
/// `ThemePreset::ALL`.
pub const THEME_PRESETS: [&str; 6] = [
"muted",
"vivid",
"catppuccin-mocha",
"tokyo-night",
"gruvbox",
"nord",
];
#[cfg(target_os = "linux")]
const INTERFACE_HELP: &str = "Network interface to monitor (use \"any\" to capture all interfaces)";
@@ -113,9 +126,8 @@ pub fn build_cli() -> Command {
Arg::new("theme")
.long("theme")
.value_name("PRESET")
.help("Color theme preset: \"muted\" (single accent, color reserved for signals) or \"classic\" (original full-color palette)")
.value_parser(["muted", "classic"])
.default_value("muted")
.help("Color theme: muted (default), vivid, catppuccin-mocha, tokyo-night, gruvbox, nord. Overrides the theme set in the config file (~/.config/rustnet/config.toml)")
.value_parser(THEME_PRESETS)
.required(false),
)
.arg(
@@ -223,4 +235,29 @@ mod tests {
assert_eq!(matches.get_one::<u64>("refresh-interval"), Some(&500));
}
#[test]
fn theme_has_no_default_and_accepts_all_presets() {
// No default: an absent --theme must defer to the config file.
let matches = build_cli()
.try_get_matches_from(["rustnet"])
.expect("no --theme should parse");
assert_eq!(matches.get_one::<String>("theme"), None);
for name in THEME_PRESETS {
let matches = build_cli()
.try_get_matches_from(["rustnet", "--theme", name])
.unwrap_or_else(|e| panic!("--theme {name} should parse: {e}"));
assert_eq!(
matches.get_one::<String>("theme").map(String::as_str),
Some(name)
);
}
assert!(
build_cli()
.try_get_matches_from(["rustnet", "--theme", "bogus"])
.is_err()
);
}
}
+294
View File
@@ -0,0 +1,294 @@
//! User configuration file loading.
//!
//! rustnet reads an optional TOML file for settings that do not fit the
//! command line, currently the color theme:
//!
//! ```toml
//! # ~/.config/rustnet/config.toml
//! [theme]
//! name = "tokyo-night"
//!
//! [theme.overrides]
//! accent = "#ff9e64"
//! border = "darkgray"
//! ```
//!
//! Loading never fails: a missing file yields the defaults silently, and an
//! unreadable or invalid file yields the defaults after a single stderr
//! warning (emitted before the terminal enters raw mode). Overrides that
//! leave text unreadable on its background are reported the same way by the
//! contrast guard in `ui::theme`, which warns but never changes a color.
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use serde::Deserialize;
/// Raw on-disk schema. Unknown top-level and `[theme]` keys are ignored for
/// forward compatibility.
#[derive(Debug, Default, Deserialize)]
#[serde(default)]
struct RawConfig {
theme: RawTheme,
}
#[derive(Debug, Default, Deserialize)]
#[serde(default)]
struct RawTheme {
name: Option<String>,
/// BTreeMap so warnings about bad overrides come out in a stable order.
overrides: BTreeMap<String, String>,
}
/// Parsed user configuration. Override values are validated later by
/// `ThemeSpec::set_token`, not here.
#[derive(Debug, Default, PartialEq)]
pub struct UserConfig {
pub theme: Option<String>,
pub overrides: Vec<(String, String)>,
}
/// Load the user configuration. Never panics, never fails: missing file is
/// a silent default; an unreadable file or parse error prints one stderr
/// warning naming the path and falls back to the default.
pub fn load() -> UserConfig {
let Some(path) = config_path() else {
return UserConfig::default();
};
let contents = match read_config(&path) {
Ok(contents) => contents,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return UserConfig::default(),
Err(e) => {
eprintln!("rustnet: cannot read {}: {e}", path.display());
return UserConfig::default();
}
};
match parse(&contents) {
Ok(config) => config,
Err(e) => {
eprintln!("rustnet: invalid config {}: {e}", path.display());
UserConfig::default()
}
}
}
/// Read the config file. Under sudo the read happens with root privileges
/// (before the privilege drop) at a path inside the invoking user's home,
/// so a config not owned by that user is refused: following a planted
/// symlink there would otherwise turn rustnet into a root file oracle.
/// The owner comes from fstat on the opened file, leaving no window
/// between check and read.
fn read_config(path: &Path) -> std::io::Result<String> {
#[cfg(not(windows))]
{
use std::io::Read as _;
use std::os::unix::fs::MetadataExt as _;
let mut file = std::fs::File::open(path)?;
if let Some(uid) = sudo_uid() {
let owner = file.metadata()?.uid();
if owner != uid {
return Err(std::io::Error::other(format!(
"owned by uid {owner}, not the invoking user (uid {uid})"
)));
}
}
let mut contents = String::new();
file.read_to_string(&mut contents)?;
Ok(contents)
}
#[cfg(windows)]
{
std::fs::read_to_string(path)
}
}
/// Pure parsing core, unit-testable without the filesystem.
fn parse(contents: &str) -> Result<UserConfig, String> {
// Not `e.to_string()`: the toml Display echoes the offending source
// line, and the file may have been read with root privileges, so its
// contents must never be reflected back to the caller's terminal.
let raw: RawConfig = toml::from_str(contents).map_err(|e| match e.span() {
Some(span) => {
let clamped = span.start.min(contents.len());
let line = contents[..clamped].bytes().filter(|&b| b == b'\n').count() + 1;
format!("{} (line {line})", e.message())
}
None => e.message().to_string(),
})?;
Ok(UserConfig {
theme: raw.theme.name,
overrides: raw.theme.overrides.into_iter().collect(),
})
}
/// Platform config file location, from environment variables only:
/// `%APPDATA%\rustnet\config.toml` on Windows, otherwise
/// `$XDG_CONFIG_HOME/rustnet/config.toml` (when set and non-empty) or
/// `$HOME/.config/rustnet/config.toml`. `None` when the relevant
/// environment variables are unset.
/// Home directory of the user who invoked `sudo`, when running under it.
/// `None` when not under sudo, when the invoking user is root anyway (their
/// own HOME is then correct), or when the passwd lookup yields nothing.
#[cfg(not(windows))]
fn invoking_user_home() -> Option<PathBuf> {
sudo_uid()?;
let user = std::env::var("SUDO_USER").ok().filter(|u| !u.is_empty())?;
// Read the home field straight out of the passwd database rather than
// assuming /home/<user>: it is wrong for root, for macOS (/Users), and
// for anyone with a relocated home.
let passwd = std::fs::read_to_string("/etc/passwd").ok()?;
passwd_home(&passwd, &user)
}
/// The uid that invoked `sudo`, when running under it. `None` when not
/// under sudo or when the invoker was root anyway.
#[cfg(not(windows))]
fn sudo_uid() -> Option<u32> {
let uid: u32 = std::env::var("SUDO_UID").ok()?.parse().ok()?;
(uid != 0).then_some(uid)
}
/// Home directory field for `user` in the contents of a passwd file.
#[cfg(not(windows))]
fn passwd_home(passwd: &str, user: &str) -> Option<PathBuf> {
passwd.lines().find_map(|line| {
let mut fields = line.split(':');
if fields.next()? != user {
return None;
}
// name:passwd:uid:gid:gecos:home:shell, so home is four past passwd.
let home = fields.nth(4)?;
(!home.is_empty()).then(|| PathBuf::from(home))
})
}
fn config_path() -> Option<PathBuf> {
#[cfg(windows)]
{
std::env::var_os("APPDATA")
.filter(|v| !v.is_empty())
.map(|appdata| PathBuf::from(appdata).join("rustnet").join("config.toml"))
}
#[cfg(not(windows))]
{
// Under `sudo rustnet`, which the docs recommend, sudo's env_reset
// clears XDG_CONFIG_HOME and many distros point HOME at /root, so
// both would resolve to root's config rather than the config of the
// person who ran the command. rustnet already drops back to
// SUDO_UID/SUDO_GID after initialization, so it knows who that is:
// resolve their home the same way and read their config.
if let Some(home) = invoking_user_home() {
return Some(home.join(".config").join("rustnet").join("config.toml"));
}
if let Some(xdg) = std::env::var_os("XDG_CONFIG_HOME").filter(|v| !v.is_empty()) {
return Some(PathBuf::from(xdg).join("rustnet").join("config.toml"));
}
std::env::var_os("HOME")
.filter(|v| !v.is_empty())
.map(|home| {
PathBuf::from(home)
.join(".config")
.join("rustnet")
.join("config.toml")
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
#[cfg(not(windows))]
fn passwd_home_reads_the_home_field_not_a_guessed_path() {
let passwd = "root:x:0:0:root:/root:/bin/bash\n\
marco:x:1000:1000:Marco:/home/marco:/usr/bin/fish\n\
relocated:x:1001:1001::/srv/people/relocated:/bin/sh\n\
noshell:x:1002:1002:::\n";
assert_eq!(
passwd_home(passwd, "marco"),
Some(PathBuf::from("/home/marco"))
);
// A home outside /home is exactly why the field is read, not guessed.
assert_eq!(
passwd_home(passwd, "relocated"),
Some(PathBuf::from("/srv/people/relocated"))
);
assert_eq!(passwd_home(passwd, "root"), Some(PathBuf::from("/root")));
// An empty home field is not a usable answer.
assert_eq!(passwd_home(passwd, "noshell"), None);
assert_eq!(passwd_home(passwd, "absent"), None);
}
#[test]
fn parses_theme_name_and_overrides() {
let config = parse(
r##"
[theme]
name = "tokyo-night"
[theme.overrides]
accent = "#ff9e64"
border = "darkgray"
selection_bg = "#3b4261"
"##,
)
.unwrap();
assert_eq!(config.theme.as_deref(), Some("tokyo-night"));
assert_eq!(
config.overrides,
vec![
("accent".to_string(), "#ff9e64".to_string()),
("border".to_string(), "darkgray".to_string()),
("selection_bg".to_string(), "#3b4261".to_string()),
]
);
}
#[test]
fn empty_input_yields_default() {
assert_eq!(parse("").unwrap(), UserConfig::default());
}
#[test]
fn theme_name_without_overrides() {
let config = parse("[theme]\nname = \"nord\"\n").unwrap();
assert_eq!(config.theme.as_deref(), Some("nord"));
assert!(config.overrides.is_empty());
}
#[test]
fn invalid_toml_is_an_error() {
let err = parse("not [valid toml").unwrap_err();
assert!(!err.is_empty());
}
#[test]
fn parse_error_never_echoes_file_contents() {
// A parse error on a file rustnet was never meant to read (here a
// shadow-style line) must not quote the offending source back.
let err = parse("root:$6$hunter2$abcdef:19000:0:99999:7:::").unwrap_err();
assert!(!err.contains("hunter2"), "{err}");
// The line number alone is fine and expected.
let err = parse("[theme]\nname = not-quoted\n").unwrap_err();
assert!(err.contains("(line 2)"), "{err}");
assert!(!err.contains("not-quoted"), "{err}");
}
#[test]
fn unknown_keys_are_ignored() {
let config = parse(
r#"
[capture]
foo = 1
[theme]
name = "gruvbox"
unknown = "x"
"#,
)
.unwrap();
assert_eq!(config.theme.as_deref(), Some("gruvbox"));
assert!(config.overrides.is_empty());
}
}
+1
View File
@@ -57,6 +57,7 @@
pub mod app;
pub mod cli;
pub mod config;
pub(crate) mod export;
pub(crate) mod filter;
pub mod network;
+36 -15
View File
@@ -1,7 +1,7 @@
use anyhow::Result;
use log::{LevelFilter, error, info, warn};
use ratatui::prelude::CrosstermBackend;
use rustnet_monitor::{app, cli, network, ui};
use rustnet_monitor::{app, cli, config, network, ui};
use simplelog::{ConfigBuilder, WriteLogger};
use std::fs;
use std::io;
@@ -96,13 +96,35 @@ fn main() -> Result<()> {
ui::set_no_color(true);
}
// Color theme preset
let theme_preset = match matches.get_one::<String>("theme").map(String::as_str) {
Some("classic") => ui::ThemePreset::Classic,
_ => ui::ThemePreset::Muted,
};
info!("Using {theme_preset:?} color theme");
ui::set_theme_preset(theme_preset);
// Color theme: CLI --theme > config file > muted default. Warnings go to
// stderr here, before the terminal enters raw mode.
let user_config = config::load();
let theme_name = matches
.get_one::<String>("theme")
.map(String::as_str)
.or(user_config.theme.as_deref())
.unwrap_or("muted");
let preset = ui::ThemePreset::from_name(theme_name).unwrap_or_else(|| {
// Only reachable via the config file; clap validates the CLI value.
eprintln!("rustnet: unknown theme {theme_name:?} in config, using \"muted\"");
ui::ThemePreset::Muted
});
let mut spec = ui::ThemeSpec::builtin(preset);
for (token, value) in &user_config.overrides {
if let Err(e) = spec.set_token(token, value) {
eprintln!("rustnet: ignoring theme override {token:?}: {e}");
}
}
info!("Using {preset:?} color theme");
// ANSI Gray (the muted/label text tier) is nearly unreadable on light
// backgrounds, so ask the terminal for its background (OSC 11) and
// darken those tiers when it reports a light one. Skipped under
// NO_COLOR, where no colors are emitted at all.
if !no_color && ui::detect_light_background() == Some(true) {
info!("Light terminal background detected; darkening gray text tiers");
spec.adapt_to_light_background();
}
ui::set_theme(ui::Theme::resolve(&spec, ui::detect_truecolor()));
// GeoIP configuration
if matches.get_flag("no-geoip") {
@@ -765,14 +787,13 @@ where
// Update visible rows for page navigation based on terminal height.
// Chrome rows: tab bar (2) + section title (1) + table header incl.
// margin (2) + status bar (1) = 6, plus the filter line (1) when a
// filter is being edited or active.
// margin (2) + status bar (1) = 6, plus the filter line (1) while a
// filter is being typed. This must track the layout in `ui::draw`
// exactly: a confirmed filter keeps no row of its own, so counting
// one here would scroll the selection a row early and hand the
// scrollbar a viewport shorter than what is drawn.
if let Ok(size) = terminal.size() {
let chrome = if ui_state.filter_mode || ui_state.has_active_filter() {
7
} else {
6
};
let chrome = if ui_state.filter_row_visible() { 7 } else { 6 };
ui_state.visible_rows = (size.height as usize).saturating_sub(chrome);
}
+18 -1
View File
@@ -16,6 +16,23 @@ use crate::ui::UIState;
/// "Copied: …" banner in the status bar; on failure, sets an error
/// banner instead. `display_msg` is what's shown to the user
/// (typically "label: value"), while `text` is the literal payload.
/// Whether the system clipboard can be reached at all. Landlock's
/// filesystem and IPC-scope restrictions both sever the path to the display
/// server's clipboard, so under the default Linux sandbox no copy can
/// succeed and the UI stops offering one.
pub fn clipboard_available(app: &App) -> bool {
#[cfg(target_os = "linux")]
{
let sandbox = app.get_sandbox_info();
!(sandbox.fs_restricted || sandbox.scope_restricted)
}
#[cfg(not(target_os = "linux"))]
{
let _ = app;
true
}
}
pub fn copy_to_clipboard(text: &str, display_msg: &str, ui_state: &mut UIState, app: &App) {
// Used conditionally on Linux/FreeBSD for sandbox-aware error messages
let _ = app;
@@ -47,7 +64,7 @@ pub fn copy_to_clipboard(text: &str, display_msg: &str, ui_state: &mut UIState,
}
Err(e) => {
#[cfg(target_os = "linux")]
let msg = if app.get_sandbox_info().fs_restricted {
let msg = if !clipboard_available(app) {
"Clipboard unavailable (sandbox active). Use --no-sandbox to enable.".to_string()
} else {
format!("Clipboard error: {}", e)
+92 -29
View File
@@ -50,12 +50,18 @@ const BANDWIDTH_WIDTH: u16 = 11;
/// Floor for the Remote column; bare "ip:port" for IPv4 fits in 21.
const REMOTE_MIN_WIDTH: u16 = 21;
/// Selection bar drawn in front of the highlighted row. It is the row
/// highlight symbol, so it costs no column width, and it stays the
/// selection cue when colors are off.
pub(in crate::ui) const SELECTION_BAR: &str = "";
/// One of the connection-table columns. Headers use short labels and
/// single-cell glyphs (↓ ↑ ·) only — multi-width emoji are deliberately
/// avoided because double-width glyphs break ratatui column alignment
/// in many terminals.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(in crate::ui) enum ColumnId {
/// Narrow gutter holding the connection's state dot.
Process,
Remote,
Local,
@@ -96,11 +102,14 @@ impl Column {
}
}
/// Fixed chrome the table adds around the column widths: the row
/// highlight symbol "> " (2) plus the inter-column spacing.
/// Fixed chrome the table adds around the column widths: the 1-cell
/// selection bar drawn as the row highlight symbol, plus the
/// inter-column spacing. Every caller of this grid (the Overview lists
/// and the Details continuity strip) draws the same bar, so the
/// reserve is exact and the last column lands flush right.
fn table_chrome(column_count: usize) -> u16 {
let spacing = column_count.saturating_sub(1) as u16; // default column_spacing(1)
2 + spacing
1 + spacing
}
/// Pick the visible column set for `available_width` (the table area's
@@ -109,8 +118,9 @@ fn table_chrome(column_count: usize) -> u16 {
///
/// Too narrow: whole columns are hidden in a fixed degradation order
/// (Location → Service → Local → RTT → Application shrinks to compact →
/// State) rather than truncating cells. The floor is Process · Remote ·
/// App · Bandwidth; below that ratatui clips columns from the right.
/// State) rather than truncating cells. The floor is Process ·
/// Remote · App · Bandwidth; below that ratatui clips columns from the
/// right.
///
/// Width to spare: the surplus is distributed to the flexible columns
/// proportionally to their weight (Remote 4 · App 3 · Process 2 ·
@@ -399,10 +409,18 @@ pub(in crate::ui) fn build_header<'a>(columns: &[Column], ui_state: &UIState) ->
/// Row-level staleness styling shared by every connection row. Fresh rows
/// keep per-cell colors. Historic rows turn gray, while expiring rows stay
/// yellow through the warning window and intensify toward red near removal.
fn staleness_style(conn: &Connection) -> (Option<Style>, bool) {
///
/// A selected historic row on a theme with a selection tint keeps its
/// per-cell colors instead: the faint whole-row fg is unreadable against
/// the selection band, and the "closed" state still marks the row.
fn staleness_style(conn: &Connection, selected: bool) -> (Option<Style>, bool) {
let staleness = conn.staleness_ratio();
if conn.is_historic {
(Some(theme::historic_row()), false)
if selected && theme::selection_has_bg() {
(None, true)
} else {
(Some(theme::historic_row()), false)
}
} else if let Some(intensity) = theme::expiry_glow_intensity(staleness) {
let color = theme::expiry_glow(intensity);
let style = if intensity >= 0.6 {
@@ -420,15 +438,17 @@ fn staleness_style(conn: &Connection) -> (Option<Style>, bool) {
///
/// `process_override` replaces the Process cell content (the grouped
/// view passes the tree connector + PID since the group header above
/// already names the process).
/// already names the process). `selected` marks the table's highlighted
/// row so historic rows can stay readable on the selection band.
pub(in crate::ui) fn connection_row<'a>(
conn: &'a Connection,
columns: &[Column],
ui_state: &UIState,
dns_resolver: Option<&DnsResolver>,
process_override: Option<Line<'a>>,
selected: bool,
) -> Row<'a> {
let (row_override, color_cells) = staleness_style(conn);
let (row_override, color_cells) = staleness_style(conn, selected);
let style_if_colored = |c: Color| {
if color_cells {
theme::fg(c)
@@ -447,7 +467,7 @@ pub(in crate::ui) fn connection_row<'a>(
}
let full = process_text(conn);
Cell::from(truncate_with_ellipsis(&full, col.width as usize))
.style(style_if_colored(theme::field_process()))
.style(process_style(conn, color_cells))
}
ColumnId::Remote => {
let (display, attributed) =
@@ -515,6 +535,21 @@ pub(in crate::ui) fn connection_row<'a>(
}
}
/// Style for the Process cell: the identity tint keyed on the process
/// name, falling back to the shared process color when the theme has no
/// identity hues (NO_COLOR, no truecolor, vivid preset). Rows painted
/// whole by the staleness pass keep that paint instead.
fn process_style(conn: &Connection, color_cells: bool) -> Style {
if !color_cells {
return Style::default();
}
let base = theme::fg(theme::field_process());
match conn.process_name.as_deref() {
Some(name) => theme::identity_color(name).map(theme::fg).unwrap_or(base),
None => base,
}
}
/// Merged protocol + application cell: "TCP·HTTPS (sni)" at full width,
/// "TCP·HTTPS" compact, bare "TCP" without DPI info. The protocol half
/// is muted so the detected application reads as the content.
@@ -591,7 +626,7 @@ pub(in crate::ui) fn bandwidth_cell<'a>(rx_bps: f64, tx_bps: f64, color_cells: b
let line = if !color_cells {
Line::from(format!("{rx}/{tx}"))
} else if !active && !theme::is_classic() {
} else if !active && !theme::is_vivid() {
Line::from(Span::styled(
format!("{rx}/{tx}"),
theme::fg(theme::muted()),
@@ -654,7 +689,7 @@ pub(in crate::ui) fn render_row_table(
let connections_table = Table::new(rows, widths)
.header(header)
.row_highlight_style(theme::row_highlight())
.highlight_symbol("> ");
.highlight_symbol(Line::styled(SELECTION_BAR, theme::fg(theme::accent())));
let table_area = Rect::new(area.x, area.y, area.width.saturating_sub(2), area.height);
f.render_stateful_widget(connections_table, table_area, &mut state);
@@ -713,14 +748,15 @@ mod tests {
assert!((midpoint - 0.5).abs() < 0.000_001);
assert_eq!(theme::expiry_glow_intensity(1.0), Some(1.0));
assert_eq!(theme::expiry_glow_intensity(1.5), Some(1.0));
assert_eq!(theme::expiry_glow(0.0), Color::Rgb(0xFA, 0xCC, 0x15));
assert_eq!(theme::expiry_glow(0.5), Color::Rgb(0xFB, 0x92, 0x3C));
assert_eq!(theme::expiry_glow(1.0), Color::Rgb(0xFF, 0x2D, 0x55));
// Endpoints of the muted theme's derived warn-to-err expiry ramp.
assert_eq!(theme::expiry_glow(0.0), Color::Rgb(250, 164, 65));
assert_eq!(theme::expiry_glow(0.5), Color::Rgb(247, 108, 59));
assert_eq!(theme::expiry_glow(1.0), Color::Rgb(244, 52, 52));
}
// Width math for the full set with Location at floor widths:
// 22+21+18+4+10+24+12+7+11 = 129 content + chrome(9 cols) = 10 -> 139.
const FULL_WIDTH: u16 = 139;
// 22+21+18+4+10+24+12+7+11 = 129 content + chrome(9 cols) = 9 -> 138.
const FULL_WIDTH: u16 = 138;
#[test]
fn select_columns_shows_everything_when_wide() {
@@ -749,31 +785,31 @@ mod tests {
assert!(!ids(&cols).contains(&ColumnId::Location));
assert!(ids(&cols).contains(&ColumnId::Service));
// 22+21+18+10+24+12+7+11 = 125 + chrome(8) = 134 -> below that Service goes.
let cols = select_columns(133, true);
// 22+21+18+10+24+12+7+11 = 125 + chrome(8) = 133 -> below that Service goes.
let cols = select_columns(132, true);
assert!(!ids(&cols).contains(&ColumnId::Service));
assert!(ids(&cols).contains(&ColumnId::Local));
// 22+21+18+24+12+7+11 = 115 + chrome(7) = 123 -> below that Local goes.
let cols = select_columns(123, true);
// 22+21+18+24+12+7+11 = 115 + chrome(7) = 122 -> below that Local goes.
let cols = select_columns(122, true);
assert!(ids(&cols).contains(&ColumnId::Local));
assert_eq!(width_of(&cols, ColumnId::Application), APP_WIDTH_FULL);
let cols = select_columns(122, true);
let cols = select_columns(121, true);
assert!(!ids(&cols).contains(&ColumnId::Local));
// 22+21+24+12+7+11 = 97 + chrome(6) = 104 -> below that RTT goes.
let cols = select_columns(104, true);
assert!(ids(&cols).contains(&ColumnId::Rtt));
// 22+21+24+12+7+11 = 97 + chrome(6) = 103 -> below that RTT goes.
let cols = select_columns(103, true);
assert!(ids(&cols).contains(&ColumnId::Rtt));
let cols = select_columns(102, true);
assert!(!ids(&cols).contains(&ColumnId::Rtt));
// 22+21+24+12+11 = 90 + chrome(5) = 96 -> below that App compacts.
let cols = select_columns(95, true);
// 22+21+24+12+11 = 90 + chrome(5) = 95 -> below that App compacts.
let cols = select_columns(94, true);
assert_eq!(width_of(&cols, ColumnId::Application), APP_WIDTH_COMPACT);
assert!(ids(&cols).contains(&ColumnId::State));
// 22+21+14+12+11 = 80 + chrome(5) = 86 -> below that State goes.
let cols = select_columns(85, true);
// 22+21+14+12+11 = 80 + chrome(5) = 85 -> below that State goes.
let cols = select_columns(84, true);
assert_eq!(
ids(&cols),
vec![
@@ -1026,4 +1062,31 @@ mod tests {
conn.pid = Some(42);
assert_eq!(process_text(&conn), format!("{NONE_PLACEHOLDER} (42)"));
}
fn tcp_conn(state: TcpState) -> Connection {
Connection::new(
Protocol::Tcp,
"192.168.1.10:51234".parse().unwrap(),
"140.82.121.4:443".parse().unwrap(),
ProtocolState::Tcp(state),
)
}
#[test]
fn process_style_falls_back_without_identity_hues() {
let mut conn = tcp_conn(TcpState::Established);
conn.process_name = Some("firefox".to_string());
// Whole-row paint wins over any per-cell color.
assert_eq!(process_style(&conn, false), Style::default());
// The default test theme resolves without truecolor, so there are
// no identity hues and the shared process color stands.
let base = theme::fg(theme::field_process());
assert_eq!(process_style(&conn, true), base);
// Unnamed processes never hash the placeholder.
conn.process_name = None;
assert_eq!(process_style(&conn, true), base);
}
}
+55
View File
@@ -87,3 +87,58 @@ pub(super) fn truncate_with_ellipsis(s: &str, width: usize) -> String {
out.push('…');
out
}
/// Truncation to `max` chars that keeps the *end* of the string,
/// prefixing "…" when cut. Like [`truncate_with_ellipsis`] it counts
/// chars, not display cells, so a run of wide characters can still
/// overflow a fixed-width column by a few cells.
///
/// The tail is the informative half of a filesystem path (the basename
/// says what the binary is, the leading directories only say where it
/// lives), so a path that has to lose characters loses them from the
/// front.
pub(super) fn ellipsize_left(s: &str, max: usize) -> String {
let len = s.chars().count();
if len <= max {
return s.to_string();
}
if max <= 1 {
return "".to_string();
}
let tail: String = s.chars().skip(len - (max - 1)).collect();
format!("{tail}")
}
#[cfg(test)]
mod tests {
use super::ellipsize_left;
#[test]
fn fitting_strings_are_returned_unchanged() {
assert_eq!(ellipsize_left("/usr/bin/curl", 13), "/usr/bin/curl");
assert_eq!(ellipsize_left("/usr/bin/curl", 40), "/usr/bin/curl");
assert_eq!(ellipsize_left("", 0), "");
}
#[test]
fn truncation_keeps_the_tail_and_fits_the_budget() {
let cut = ellipsize_left("/usr/libexec/ApplicationFirmwareUpdater", 10);
assert_eq!(cut, "…reUpdater");
assert_eq!(cut.chars().count(), 10);
}
#[test]
fn hopeless_budgets_collapse_to_the_ellipsis() {
assert_eq!(ellipsize_left("/usr/bin/curl", 1), "");
assert_eq!(ellipsize_left("/usr/bin/curl", 0), "");
}
#[test]
fn multibyte_input_is_cut_on_char_boundaries() {
// Counting bytes here would slice mid-codepoint and panic. The
// budget is in chars, so the wide-character result is 6 chars
// wide, not 6 cells.
assert_eq!(ellipsize_left("/日本語/データ/ファイル", 6), "…/ファイル");
assert_eq!(ellipsize_left("ααββγγ", 3), "…γγ");
}
}
+171 -36
View File
@@ -23,8 +23,10 @@ pub use terminal::{Terminal, restore_terminal, setup_terminal};
mod widgets;
use widgets::{
filter_input::draw_filter_input, loading::draw_loading_screen, status_bar::draw_status_bar,
tabs_bar::draw_tabs,
filter_input::draw_filter_input,
loading::draw_loading_screen,
status_bar::draw_status_bar,
tabs_bar::{CaptureCluster, draw_tabs},
};
mod tabs;
@@ -105,7 +107,7 @@ mod sorting;
pub use sorting::sort_connections;
mod clipboard;
pub use clipboard::copy_to_clipboard;
pub use clipboard::{clipboard_available, copy_to_clipboard};
mod actions;
pub use actions::clear_all_with_confirmation;
@@ -120,7 +122,9 @@ mod effects;
pub use effects::apply_effects;
mod theme;
pub use theme::{ThemePreset, set_preset as set_theme_preset};
pub use theme::{
Theme, ThemePreset, ThemeSpec, TokenColor, detect_light_background, detect_truecolor, set_theme,
};
/// Standard panel chrome: rounded border + title. Kept for the few
/// views that still frame themselves (Help reference card, loading
@@ -165,6 +169,18 @@ pub(crate) fn section_header<'a, T: Into<Line<'a>>>(
)
}
/// Fade one line toward the faint tier: the scroll-boundary cue shared
/// by every scrolling pane. Spans carry their own styles, so the fade is
/// applied span by span, with the line style following for the cells a
/// short line leaves empty. A no-op under NO_COLOR, where
/// [`theme::edge_fade`] returns the style untouched.
pub(crate) fn fade_line(line: &mut Line<'_>) {
line.style = theme::edge_fade(line.style);
for span in &mut line.spans {
span.style = theme::edge_fade(span.style);
}
}
/// Resolve the cell color for a connection's State column.
/// Maps TCP states to the existing `tcp_*` aliases; falls back to
/// `field_state()` for non-TCP protocols.
@@ -182,12 +198,12 @@ pub(crate) fn state_color(conn: &Connection) -> Color {
}
/// Resolve the cell color for a DPI Application protocol.
/// Classic preset mirrors the palette used in `draw_app_distribution`;
/// Vivid preset mirrors the palette used in `draw_app_distribution`;
/// the muted preset renders detected applications as plain content so
/// the `proto_*` palette stays a chart-only encoding.
pub(crate) fn dpi_color(app: &crate::network::types::ApplicationProtocol) -> Color {
use crate::network::types::ApplicationProtocol as AP;
if !theme::is_classic() {
if !theme::is_vivid() {
return Color::Reset;
}
match app {
@@ -202,11 +218,11 @@ pub(crate) fn dpi_color(app: &crate::network::types::ApplicationProtocol) -> Col
/// Color for the Details Application heading of the non-DPI protocol classes
/// (ARP, ICMP, IGMP), which have no `ApplicationProtocol` value to feed
/// [`dpi_color`]. Mirrors its theme fallback: the classic preset colors the
/// [`dpi_color`]. Mirrors its theme fallback: the vivid preset colors the
/// heading like any other detected application, the muted preset renders it
/// as plain content.
pub(crate) fn non_dpi_app_color() -> Color {
if theme::is_classic() {
if theme::is_vivid() {
theme::field_application()
} else {
Color::Reset
@@ -249,7 +265,7 @@ pub fn draw(
let capture_error = app.get_capture_error();
let status_height = status_bar_height(capture_error.as_deref(), f.area().width);
let chunks = if ui_state.filter_mode || ui_state.has_active_filter() {
let chunks = if ui_state.filter_row_visible() {
Layout::default()
.direction(Direction::Vertical)
.constraints([
@@ -270,10 +286,21 @@ pub fn draw(
.split(f.area())
};
draw_tabs(f, ui_state, chunks[0], click_regions);
// Capture cluster: same sources the status bar and the Overview
// sidebar read. `get_link_layer_info` reports "Unknown" until the
// capture thread has a linktype, which is not worth a suffix.
let capture_interface = app.get_current_interface();
let (link_type, _is_tunnel) = app.get_link_layer_info();
let capture = CaptureCluster {
interface: capture_interface.as_deref(),
link_type: Some(link_type.as_str()).filter(|link| *link != "Unknown"),
failed: capture_error.is_some(),
};
draw_tabs(f, ui_state, &capture, chunks[0], click_regions);
let content_area = chunks[1];
let (filter_area, status_area) = if ui_state.filter_mode || ui_state.has_active_filter() {
let (filter_area, status_area) = if ui_state.filter_row_visible() {
(Some(chunks[2]), chunks[3])
} else {
(None, chunks[2])
@@ -302,7 +329,7 @@ pub fn draw(
draw_status_bar(
f,
ui_state,
connections.len(),
clipboard_available(app),
capture_error.as_deref(),
status_area,
);
@@ -663,7 +690,15 @@ mod snapshot_tests {
..Default::default()
};
let mut regions = ClickableRegions::default();
let output = render(80, 2, |f| draw_tabs(f, &ui_state, f.area(), &mut regions));
let output = render(80, 2, |f| {
draw_tabs(
f,
&ui_state,
&CaptureCluster::default(),
f.area(),
&mut regions,
)
});
insta::assert_snapshot!(output);
}
@@ -674,7 +709,15 @@ mod snapshot_tests {
..Default::default()
};
let mut regions = ClickableRegions::default();
let output = render(80, 2, |f| draw_tabs(f, &ui_state, f.area(), &mut regions));
let output = render(80, 2, |f| {
draw_tabs(
f,
&ui_state,
&CaptureCluster::default(),
f.area(),
&mut regions,
)
});
insta::assert_snapshot!(output);
}
@@ -685,10 +728,110 @@ mod snapshot_tests {
..Default::default()
};
let mut regions = ClickableRegions::default();
let output = render(80, 2, |f| draw_tabs(f, &ui_state, f.area(), &mut regions));
let output = render(80, 2, |f| {
draw_tabs(
f,
&ui_state,
&CaptureCluster::default(),
f.area(),
&mut regions,
)
});
insta::assert_snapshot!(output);
}
/// The capture cluster is right-aligned on the title row and
/// carries the interface plus its link layer.
#[test]
fn tabs_bar_capture_cluster_is_right_aligned() {
let ui_state = UIState::default();
let mut regions = ClickableRegions::default();
let capture = CaptureCluster {
interface: Some("eth0"),
link_type: Some("Ethernet"),
failed: false,
};
let output = render(100, 2, |f| {
draw_tabs(f, &ui_state, &capture, f.area(), &mut regions)
});
let title_row = output.lines().next().expect("title row");
assert!(
title_row.trim_end().ends_with("● eth0 · Ethernet"),
"cluster should sit at the right edge, got:\n{output}"
);
}
/// The cluster degrades in two steps: link layer first, then the
/// whole cluster once the tab titles would collide with it.
#[test]
fn tabs_bar_capture_cluster_drops_on_narrow_terminals() {
let ui_state = UIState::default();
let capture = CaptureCluster {
interface: Some("eth0"),
link_type: Some("Ethernet"),
failed: false,
};
let mut regions = ClickableRegions::default();
let medium = render(76, 2, |f| {
draw_tabs(f, &ui_state, &capture, f.area(), &mut regions)
});
assert!(
medium.contains("● eth0") && !medium.contains("Ethernet"),
"link layer should be dropped first, got:\n{medium}"
);
let mut regions = ClickableRegions::default();
let narrow = render(70, 2, |f| {
draw_tabs(f, &ui_state, &capture, f.area(), &mut regions)
});
assert!(
!narrow.contains(""),
"cluster should disappear rather than collide, got:\n{narrow}"
);
}
/// An active filter marks the Overview title, and the underline
/// grows with the wider label so the rule keeps tracking it.
#[test]
fn tabs_bar_marks_an_active_filter_on_overview() {
let ui_state = UIState {
selected_tab: 0,
filter_query: "port:443".to_string(),
..Default::default()
};
let mut regions = ClickableRegions::default();
let capture = CaptureCluster::default();
let output = render(80, 2, |f| {
draw_tabs(f, &ui_state, &capture, f.area(), &mut regions)
});
let mut rows = output.lines();
let title_row = rows.next().expect("title row");
let underline_row = rows.next().expect("underline row");
assert!(
title_row.contains("1 Overview •"),
"filtered Overview should carry the activity dot, got:\n{output}"
);
let dot_column = title_row
.chars()
.position(|c| c == '•')
.expect("dot column");
assert_eq!(
underline_row.chars().nth(dot_column),
Some('━'),
"the active underline must extend under the dot, got:\n{output}"
);
// No filter, no dot.
let mut regions = ClickableRegions::default();
let plain = render(80, 2, |f| {
draw_tabs(f, &UIState::default(), &capture, f.area(), &mut regions)
});
assert!(!plain.contains('•'), "unfiltered Overview stays plain");
}
#[test]
fn filter_input_mode_active_empty() {
let ui_state = UIState {
@@ -713,23 +856,11 @@ mod snapshot_tests {
insta::assert_snapshot!(output);
}
#[test]
fn filter_input_persisted() {
let ui_state = UIState {
filter_mode: false,
filter_query: "tcp port:443".to_string(),
filter_cursor_position: 0,
..Default::default()
};
let output = render(80, 1, |f| draw_filter_input(f, &ui_state, f.area()));
insta::assert_snapshot!(output);
}
#[test]
fn status_bar_overview_default() {
let ui_state = UIState::default();
let output = render(120, 1, |f| {
draw_status_bar(f, &ui_state, 42, None, f.area())
draw_status_bar(f, &ui_state, true, None, f.area())
});
insta::assert_snapshot!(output);
}
@@ -741,7 +872,7 @@ mod snapshot_tests {
..Default::default()
};
let output = render(120, 1, |f| {
draw_status_bar(f, &ui_state, 42, None, f.area())
draw_status_bar(f, &ui_state, true, None, f.area())
});
insta::assert_snapshot!(output);
}
@@ -753,7 +884,7 @@ mod snapshot_tests {
..Default::default()
};
let output = render(120, 1, |f| {
draw_status_bar(f, &ui_state, 42, None, f.area())
draw_status_bar(f, &ui_state, true, None, f.area())
});
insta::assert_snapshot!(output);
}
@@ -764,7 +895,9 @@ mod snapshot_tests {
selected_tab: 4,
..Default::default()
};
let output = render(120, 1, |f| draw_status_bar(f, &ui_state, 0, None, f.area()));
let output = render(120, 1, |f| {
draw_status_bar(f, &ui_state, true, None, f.area())
});
insta::assert_snapshot!(output);
}
@@ -774,7 +907,9 @@ mod snapshot_tests {
filter_query: "port:443".to_string(),
..Default::default()
};
let output = render(120, 1, |f| draw_status_bar(f, &ui_state, 7, None, f.area()));
let output = render(120, 1, |f| {
draw_status_bar(f, &ui_state, true, None, f.area())
});
insta::assert_snapshot!(output);
}
@@ -785,7 +920,7 @@ mod snapshot_tests {
..Default::default()
};
let output = render(120, 1, |f| {
draw_status_bar(f, &ui_state, 42, None, f.area())
draw_status_bar(f, &ui_state, true, None, f.area())
});
insta::assert_snapshot!(output);
}
@@ -797,7 +932,7 @@ mod snapshot_tests {
..Default::default()
};
let output = render(120, 1, |f| {
draw_status_bar(f, &ui_state, 42, None, f.area())
draw_status_bar(f, &ui_state, true, None, f.area())
});
insta::assert_snapshot!(output);
}
@@ -809,7 +944,7 @@ mod snapshot_tests {
draw_status_bar(
f,
&ui_state,
0,
true,
Some("Capture stopped: The interface disappeared."),
f.area(),
)
@@ -826,7 +961,7 @@ mod snapshot_tests {
draw_status_bar(
f,
&ui_state,
0,
true,
Some(
"Capture failed to start: eth0: You don't have permission to capture on that device (socket: Operation not permitted).",
),
@@ -2,7 +2,7 @@
source: src/ui/mod.rs
expression: output
---
rustnet 1 Overview 2 Details 3 Activity 4 Graph 5 Help
rustnet 1 Overview 2 Details 3 Activity 4 Graph 5 Help ● eth0
─────────────────────────────────────━━━━━━━━━━─────────────────────────────────────────────────────────────────────────────────────────────
▎ Interface Statistics
Interface RX Rate TX Rate RX Packets TX Packets RX Err TX Err RX Drop TX Drop Collisions
@@ -31,4 +31,4 @@ eth0 512.00 KB/s 128.00 KB/s 1200000 800000 0
'h' help | 1-5 jump | Tab/[/] cycle | j/k scroll | 'i' process activity | Esc back
j/k scroll i process activity esc back h help q quit
@@ -1,15 +1,14 @@
---
source: src/ui/mod.rs
assertion_line: 1056
expression: "render_activity(&app, ActivityDirection::Egress)"
---
rustnet 1 Overview 2 Details 3 Activity 4 Graph 5 Help
rustnet 1 Overview 2 Details 3 Activity 4 Graph 5 Help ● eth0
─────────────────────────────────────━━━━━━━━━━───────────────────────────────────────────────────────────────────────────────────────────────────────
▎ Traffic Pulse
TX now 2.88 MB/s 60s captured 5.85 MB / eth0 8.00 MB 73.1% observed
TX 60s coverage ███████████████████████████████████████████████████████████████████████████████████████████████░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░
TX 60s coverage ███████████████████████████████████████████████████████████████████████████████████████████████···································
RX now 1.70 MB/s 60s captured 3.67 MB / eth0 4.00 MB 91.8% observed
RX 60s coverage ███████████████████████████████████████████████████████████████████████████████████████████████████████████████████████░░░░░░░░░░░
RX 60s coverage ███████████████████████████████████████████████████████████████████████████████████████████████████████████████████████▍··········
retained TX 5.85 MB RX 3.67 MB
process attribution TX 100.0% unknown 0 B RX 100.0% unknown 0 B
60s coverage = captured ÷ interface | Retained = active + recent closed | Attribution = mapped to PID/name
@@ -17,10 +16,10 @@ process attribution TX 100.0% unknown 0 B RX 100.0% unknown 0 B
▎ Top Processes: Egress (TX) Retained TX ↓
Process Pulse TX now Peak TX 60s % Iface 60s TX 60s Retained Conns Remote Top remote peer
firefox (2001) ███████████░░░ 2.38 MB/s 2.38 MB/s 81.7% 59.8% 4.78 MB 4.78 MB 1/1 1 140.82.121.4:443
sshd (1500) ██░░░░░░░░░░░░ 488.28 KB/s 488.28 KB/s 17.7% 13.0% 1.04 MB 1.04 MB 1/1 1 10.0.0.5:51022
systemd-resolved (820) ░░░░░░░░░░░░░░ 15.62 KB/s 15.62 KB/s 0.5% 0.4% 32.42 KB 32.42 KB 1/1 1 1.1.1.1:53
curl (9876) ░░░░░░░░░░░░░░ - - 0.0% 0.0% 0 B 0 B 1/1 1 151.101.1.195:443
firefox (2001) ███████████▌·· 2.38 MB/s 2.38 MB/s 81.7% 59.8% 4.78 MB 4.78 MB 1/1 1 140.82.121.4:443
sshd (1500) ██▌··········· 488.28 KB/s 488.28 KB/s 17.7% 13.0% 1.04 MB 1.04 MB 1/1 1 10.0.0.5:51022
systemd-resolved (820) ▏············· 15.62 KB/s 15.62 KB/s 0.5% 0.4% 32.42 KB 32.42 KB 1/1 1 1.1.1.1:53
curl (9876) ·············· - - 0.0% 0.0% 0 B 0 B 1/1 1 151.101.1.195:443
@@ -34,12 +33,12 @@ curl (9876) ░░░░░░░░░░░░░░ -
▎ Egress (TX) Share (60s) ▎ Interface Pulse: TX
firefox (2001) ███████████████████████████████████████████████░░░░░░░░░░░ 81.7% eth0 ████████████████████████████████████ ↑4.0M ↓2.0M
sshd (1500) ██████████░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ 17.7%
systemd-resolved … ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ 0.5%
curl (9876) ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ 0.0%
firefox (2001) ███████████████████████████████████████████████▍·········· 81.7% eth0 ████████████████████████████████████ ↑4.0M ↓2.0M
sshd (1500) ██████████▎··············································· 17.7%
systemd-resolved … ▍························································· 0.5%
curl (9876) ·························································· 0.0%
'h' help | 1-5 jump | Tab/[/] cycle | 'd' TX/RX | 's' sort | 'S' order | 'i' interfaces | Esc back
d tx/rx s sort S order i interfaces esc back h help q quit
@@ -1,15 +1,14 @@
---
source: src/ui/mod.rs
assertion_line: 1062
expression: "render_activity(&app, ActivityDirection::Ingress)"
---
rustnet 1 Overview 2 Details 3 Activity 4 Graph 5 Help
rustnet 1 Overview 2 Details 3 Activity 4 Graph 5 Help ● eth0
─────────────────────────────────────━━━━━━━━━━───────────────────────────────────────────────────────────────────────────────────────────────────────
▎ Traffic Pulse
TX now 2.88 MB/s 60s captured 5.85 MB / eth0 8.00 MB 73.1% observed
TX 60s coverage ███████████████████████████████████████████████████████████████████████████████████████████████░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░
TX 60s coverage ███████████████████████████████████████████████████████████████████████████████████████████████···································
RX now 1.70 MB/s 60s captured 3.67 MB / eth0 4.00 MB 91.8% observed
RX 60s coverage ███████████████████████████████████████████████████████████████████████████████████████████████████████████████████████░░░░░░░░░░░
RX 60s coverage ███████████████████████████████████████████████████████████████████████████████████████████████████████████████████████▍··········
retained TX 5.85 MB RX 3.67 MB
process attribution TX 100.0% unknown 0 B RX 100.0% unknown 0 B
60s coverage = captured ÷ interface | Retained = active + recent closed | Attribution = mapped to PID/name
@@ -17,10 +16,10 @@ process attribution TX 100.0% unknown 0 B RX 100.0% unknown 0 B
▎ Top Processes: Ingress (RX) Retained RX ↓
Process Pulse RX now Peak RX 60s % Iface 60s RX 60s Retained Conns Remote Top remote peer
firefox (2001) ████████████░░ 1.43 MB/s 1.43 MB/s 84.1% 77.2% 3.09 MB 3.09 MB 1/1 1 140.82.121.4:443
sshd (1500) ██░░░░░░░░░░░░ 244.14 KB/s 244.14 KB/s 14.1% 12.9% 529.30 KB 529.30 KB 1/1 1 10.0.0.5:51022
systemd-resolved (820) ░░░░░░░░░░░░░░ 31.25 KB/s 31.25 KB/s 1.8% 1.6% 65.82 KB 65.82 KB 1/1 1 1.1.1.1:53
curl (9876) ░░░░░░░░░░░░░░ - - 0.0% 0.0% 1.50 KB 1.50 KB 1/1 1 151.101.1.195:443
firefox (2001) ███████████▊·· 1.43 MB/s 1.43 MB/s 84.1% 77.2% 3.09 MB 3.09 MB 1/1 1 140.82.121.4:443
sshd (1500) ██············ 244.14 KB/s 244.14 KB/s 14.1% 12.9% 529.30 KB 529.30 KB 1/1 1 10.0.0.5:51022
systemd-resolved (820) ▎············· 31.25 KB/s 31.25 KB/s 1.8% 1.6% 65.82 KB 65.82 KB 1/1 1 1.1.1.1:53
curl (9876) ·············· - - 0.0% 0.0% 1.50 KB 1.50 KB 1/1 1 151.101.1.195:443
@@ -34,12 +33,12 @@ curl (9876) ░░░░░░░░░░░░░░ -
▎ Ingress (RX) Share (60s) ▎ Interface Pulse: RX
firefox (2001) █████████████████████████████████████████████████░░░░░░░░░ 84.1% eth0 ████████████████████████████████████ ↑4.0M ↓2.0M
sshd (1500) ████████░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ 14.1%
systemd-resolved … █░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ 1.8%
curl (9876) ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ 0.0%
firefox (2001) ████████████████████████████████████████████████▊········· 84.1% eth0 ████████████████████████████████████ ↑4.0M ↓2.0M
sshd (1500) ████████▏················································· 14.1%
systemd-resolved … █························································· 1.8%
curl (9876) ·························································· 0.0%
'h' help | 1-5 jump | Tab/[/] cycle | 'd' TX/RX | 's' sort | 'S' order | 'i' interfaces | Esc back
d tx/rx s sort S order i interfaces esc back h help q quit
@@ -2,37 +2,37 @@
source: src/ui/mod.rs
expression: output
---
rustnet 1 Overview 2 Details 3 Activity 4 Graph 5 Help
rustnet 1 Overview 2 Details 3 Activity 4 Graph 5 Help ● eth0
─────────────────────────━━━━━━━━━──────────────────────────────────────────────────────────────────────────────────────────────────────────
Process Remote Local Service App State RTT Rx↓/Tx↑
Process Remote Local Service App State RTT Rx↓/Tx↑
> - 192.168.1.1:0 192.168.1.10:0 - ARP ARP_WHO_HAS… - -/-
- 192.168.1.1:0 192.168.1.10:0 - ARP ARP_WHO_HAS… - -/-
▎ ? → 192.168.1.1:0 · click a field to copy
Connection Application: ARP
Protocol ARP Operation Request
Status Active (last seen <T> ago) Sender MAC 68:5e:dd:09:15:5e
Local Address 192.168.1.10:0 Sender Vendor Apple, Inc.
Remote Address 192.168.1.1:0 Sender IP 192.168.1.10
Scope PRIVATE Target MAC 00:00:00:00:00:00
State ARP_WHO_HAS 192.168.1.1 (Apple, Inc.) Target Vendor ASUSTek COMPUTER INC.
Process - Target IP 192.168.1.1
PID -
Service -
Network Context Transport Health
Local Hostname - No transport metrics for this protocol
Local MAC -
Remote Hostname -
Attributed Name -
Attributed Via -
Remote MAC -
Country -
City -
ASN -
▎ ? → 192.168.1.1:0 [ARP_WHO_HAS 192.168.1.1 (Apple, Inc.)] · click a field to copy
Connection Application: ARP
Protocol ARP Operation Request
Status Active (last seen <T> ago) Sender MAC 68:5e:dd:09:15:5e
Local Address 192.168.1.10:0 Sender Vendor Apple, Inc.
Remote Address 192.168.1.1:0 Sender IP 192.168.1.10
Scope PRIVATE Target MAC 00:00:00:00:00:00
State ARP_WHO_HAS 192.168.1.1 (Apple, Inc.) Target Vendor ASUSTek COMPUTER INC.
Process - Target IP 192.168.1.1
PID -
Service -
Network Context Transport Health
Local Hostname - No transport metrics for this protocol
Local MAC -
Remote Hostname -
Attributed Name -
Attributed Via -
Remote MAC -
Country -
City -
ASN -
▎ Traffic Statistics
↓ RX - → peak - ↑ TX - → peak -
@@ -41,4 +41,4 @@ Total 0 B · 0 packets Total 0 B
'h' help | 1-5 jump | Tab/[/] cycle | j/k prev/next | Ctrl-d/u scroll | 'c' copy remote addr | Esc back
j/k prev/next ctrl-d/u scroll c copy remote addr esc back h help q quit
@@ -2,37 +2,37 @@
source: src/ui/mod.rs
expression: output
---
rustnet 1 Overview 2 Details 3 Activity 4 Graph 5 Help
rustnet 1 Overview 2 Details 3 Activity 4 Graph 5 Help ● eth0
─────────────────────────━━━━━━━━━──────────────────────────────────────────────────────────────────────────────────────────────────────────
Process Remote Local Service App State RTT Rx↓/Tx↑
Process Remote Local Service App State RTT Rx↓/Tx↑
> proc (4242) 203.0.113.7:4433 192.168.1.10:50000 - UDP·DNS (example.com) DNS_RESPONSE - -/-
proc (4242) 203.0.113.7:4433 192.168.1.10:50000 - UDP·DNS (example.com) DNS_RESPONSE - -/-
▎ proc → 203.0.113.7:4433 · click a field to copy
Connection Application: DNS
Protocol UDP DNS Query example.com
Status Active (last seen <T> ago) DNS Type A
Local Address 192.168.1.10:50000 DNS Response IPs 93.184.216.34, 93.184.216.35
Remote Address 203.0.113.7:4433 DNS Answer -
Scope DOCUMENTATION Transaction ID 0x1234
State DNS_RESPONSE
Process proc
PID 4242
Service -
Network Context Transport Health
Local Hostname - DNS Response Time -
Local MAC - Last Response Code NOERROR
Remote Hostname -
Attributed Name - Timed by pairing query and response IDs
Attributed Via -
Remote MAC -
Country -
City -
ASN -
▎ proc → 203.0.113.7:4433 [DNS_RESPONSE] · click a field to copy
Connection Application: DNS
Protocol UDP DNS Query example.com
Status Active (last seen <T> ago) DNS Type A
Local Address 192.168.1.10:50000 DNS Response IPs 93.184.216.34, 93.184.216.35
Remote Address 203.0.113.7:4433 DNS Answer -
Scope DOCUMENTATION Transaction ID 0x1234
State DNS_RESPONSE
Process proc
PID 4242
Service -
Network Context Transport Health
Local Hostname - DNS Response Time -
Local MAC - Last Response Code NOERROR
Remote Hostname -
Attributed Name - Timed by pairing query and response IDs
Attributed Via -
Remote MAC -
Country -
City -
ASN -
▎ Traffic Statistics
↓ RX - → peak - ↑ TX - → peak -
@@ -41,4 +41,4 @@ Total 0 B · 0 packets Total 0 B
'h' help | 1-5 jump | Tab/[/] cycle | j/k prev/next | Ctrl-d/u scroll | 'c' copy remote addr | Esc back
j/k prev/next ctrl-d/u scroll c copy remote addr esc back h help q quit
@@ -2,37 +2,37 @@
source: src/ui/mod.rs
expression: output
---
rustnet 1 Overview 2 Details 3 Activity 4 Graph 5 Help
rustnet 1 Overview 2 Details 3 Activity 4 Graph 5 Help ● eth0
─────────────────────────━━━━━━━━━──────────────────────────────────────────────────────────────────────────────────────────────────────────
Process Remote Local Service App State RTT Rx↓/Tx↑
Process Remote Local Service App State RTT Rx↓/Tx↑
> proc (4242) 203.0.113.7:4433 192.168.1.10:50000 - TCP·HTTP (example.com) ESTABLISHED - -/-
proc (4242) 203.0.113.7:4433 192.168.1.10:50000 - TCP·HTTP (example.com) ESTABLISHED - -/-
▎ proc → 203.0.113.7:4433 · click a field to copy
Connection Application: HTTP
Protocol TCP HTTP Version HTTP/1.1
Status Active (last seen <T> ago) HTTP Method GET
Local Address 192.168.1.10:50000 HTTP Host example.com
Remote Address 203.0.113.7:4433 HTTP Path /index.html
Scope DOCUMENTATION HTTP Status 200
State ESTABLISHED User-Agent curl/8.9.0
Process proc
PID 4242
Service -
Network Context Transport Health
Local Hostname - Initial RTT -
Local MAC - Live RTT -
Remote Hostname - TCP Retransmits 0
Attributed Name - Out-of-Order Packets 0
Attributed Via - Duplicate ACKs 0
Remote MAC - Fast Retransmits 0
Country - Window Size 0
City -
ASN -
▎ proc → 203.0.113.7:4433 [ESTABLISHED] · click a field to copy
Connection Application: HTTP
Protocol TCP HTTP Version HTTP/1.1
Status Active (last seen <T> ago) HTTP Method GET
Local Address 192.168.1.10:50000 HTTP Host example.com
Remote Address 203.0.113.7:4433 HTTP Path /index.html
Scope DOCUMENTATION HTTP Status 200
State ESTABLISHED User-Agent curl/8.9.0
Process proc
PID 4242
Service -
Network Context Transport Health
Local Hostname - Initial RTT -
Local MAC - Live RTT -
Remote Hostname - TCP Retransmits 0
Attributed Name - Out-of-Order Packets 0
Attributed Via - Duplicate ACKs 0
Remote MAC - Fast Retransmits 0
Country - Window Size 0
City -
ASN -
▎ Traffic Statistics
↓ RX - → peak - ↑ TX - → peak -
@@ -41,4 +41,4 @@ Total 0 B · 0 packets Total 0 B
'h' help | 1-5 jump | Tab/[/] cycle | j/k prev/next | Ctrl-d/u scroll | 'c' copy remote addr | Esc back
j/k prev/next ctrl-d/u scroll c copy remote addr esc back h help q quit
@@ -2,37 +2,37 @@
source: src/ui/mod.rs
expression: output
---
rustnet 1 Overview 2 Details 3 Activity 4 Graph 5 Help
rustnet 1 Overview 2 Details 3 Activity 4 Graph 5 Help ● eth0
─────────────────────────━━━━━━━━━──────────────────────────────────────────────────────────────────────────────────────────────────────────
Process Remote Local Service App State RTT Rx↓/Tx↑
Process Remote Local Service App State RTT Rx↓/Tx↑
> proc (4242) 203.0.113.7:4433 192.168.1.10:50000 - TCP·HTTPS ESTABLISHED - -/-
proc (4242) 203.0.113.7:4433 192.168.1.10:50000 - TCP·HTTPS ESTABLISHED - -/-
▎ proc → 203.0.113.7:4433 · click a field to copy
Connection Application: HTTPS
Protocol TCP SNI -
Status Active (last seen <T> ago) ALPN -
Local Address 192.168.1.10:50000 TLS Version -
Remote Address 203.0.113.7:4433 Cipher Suite -
Scope DOCUMENTATION
State ESTABLISHED
Process proc
PID 4242
Service -
Network Context Transport Health
Local Hostname - Initial RTT -
Local MAC - Live RTT -
Remote Hostname - TCP Retransmits 0
Attributed Name - Out-of-Order Packets 0
Attributed Via - Duplicate ACKs 0
Remote MAC - Fast Retransmits 0
Country - Window Size 0
City -
ASN -
▎ proc → 203.0.113.7:4433 [ESTABLISHED] · click a field to copy
Connection Application: HTTPS
Protocol TCP SNI -
Status Active (last seen <T> ago) ALPN -
Local Address 192.168.1.10:50000 TLS Version -
Remote Address 203.0.113.7:4433 Cipher Suite -
Scope DOCUMENTATION
State ESTABLISHED
Process proc
PID 4242
Service -
Network Context Transport Health
Local Hostname - Initial RTT -
Local MAC - Live RTT -
Remote Hostname - TCP Retransmits 0
Attributed Name - Out-of-Order Packets 0
Attributed Via - Duplicate ACKs 0
Remote MAC - Fast Retransmits 0
Country - Window Size 0
City -
ASN -
▎ Traffic Statistics
↓ RX - → peak - ↑ TX - → peak -
@@ -41,4 +41,4 @@ Total 0 B · 0 packets Total 0 B
'h' help | 1-5 jump | Tab/[/] cycle | j/k prev/next | Ctrl-d/u scroll | 'c' copy remote addr | Esc back
j/k prev/next ctrl-d/u scroll c copy remote addr esc back h help q quit
@@ -2,37 +2,37 @@
source: src/ui/mod.rs
expression: output
---
rustnet 1 Overview 2 Details 3 Activity 4 Graph 5 Help
rustnet 1 Overview 2 Details 3 Activity 4 Graph 5 Help ● eth0
─────────────────────────━━━━━━━━━──────────────────────────────────────────────────────────────────────────────────────────────────────────
Process Remote Local Service App State RTT Rx↓/Tx↑
Process Remote Local Service App State RTT Rx↓/Tx↑
> ping 8.8.8.8:0 192.168.1.10:0 - ICMP ECHO_REQ(46… 8.7ms -/-
ping 8.8.8.8:0 192.168.1.10:0 - ICMP ECHO_REQ(46… 8.7ms -/-
▎ ping → 8.8.8.8:0 · click a field to copy
Connection Application: ICMP
Protocol ICMP Message Echo Request
Status Active (last seen <T> ago) Echo ID 4660
Local Address 192.168.1.10:0 Sequence 42
Remote Address 8.8.8.8:0 NDP Neighbor -
Scope PUBLIC
State ECHO_REQ(4660)
Process ping
PID -
Service -
Network Context Transport Health
Local Hostname - Ping RTT 8.7ms
Local MAC - Last Sequence 42
Remote Hostname -
Attributed Name - Paired by echo ID and sequence
Attributed Via -
Remote MAC -
Country -
City -
ASN -
▎ ping → 8.8.8.8:0 [ECHO_REQ(4660)] [rtt 8.7ms] · click a field to copy
Connection Application: ICMP
Protocol ICMP Message Echo Request
Status Active (last seen <T> ago) Echo ID 4660
Local Address 192.168.1.10:0 Sequence 42
Remote Address 8.8.8.8:0 NDP Neighbor -
Scope PUBLIC
State ECHO_REQ(4660)
Process ping
PID -
Service -
Network Context Transport Health
Local Hostname - Ping RTT 8.7ms
Local MAC - Last Sequence 42
Remote Hostname -
Attributed Name - Paired by echo ID and sequence
Attributed Via -
Remote MAC -
Country -
City -
ASN -
▎ Traffic Statistics
↓ RX - → peak - ↑ TX - → peak -
@@ -41,4 +41,4 @@ Total 0 B · 0 packets Total 0 B
'h' help | 1-5 jump | Tab/[/] cycle | j/k prev/next | Ctrl-d/u scroll | 'c' copy remote addr | Esc back
j/k prev/next ctrl-d/u scroll c copy remote addr esc back h help q quit
@@ -2,37 +2,37 @@
source: src/ui/mod.rs
expression: output
---
rustnet 1 Overview 2 Details 3 Activity 4 Graph 5 Help
rustnet 1 Overview 2 Details 3 Activity 4 Graph 5 Help ● eth0
─────────────────────────━━━━━━━━━──────────────────────────────────────────────────────────────────────────────────────────────────────────
Process Remote Local Service App State RTT Rx↓/Tx↑
Process Remote Local Service App State RTT Rx↓/Tx↑
> - [fe80::2]:0 [fe80::1]:0 - ICMP ICMP_OTHER - -/-
- [fe80::2]:0 [fe80::1]:0 - ICMP ICMP_OTHER - -/-
▎ ? → [fe80::2]:0 · click a field to copy
Connection Application: ICMPv6
Protocol ICMP Message Neighbor Advertisement
Status Active (last seen <T> ago) Echo ID -
Local Address [fe80::1]:0 Sequence -
Remote Address [fe80::2]:0 NDP Neighbor fe80::2 at b8:27:eb:12:34:56 (Raspberry Pi Fou
Scope LINK-LOCAL
State ICMP_OTHER
Process -
PID -
Service -
Network Context Transport Health
Local Hostname - No transport metrics for this protocol
Local MAC -
Remote Hostname -
Attributed Name -
Attributed Via -
Remote MAC -
Country -
City -
ASN -
▎ ? → [fe80::2]:0 [ICMP_OTHER] · click a field to copy
Connection Application: ICMPv6
Protocol ICMP Message Neighbor Advertisement
Status Active (last seen <T> ago) Echo ID -
Local Address [fe80::1]:0 Sequence -
Remote Address [fe80::2]:0 NDP Neighbor fe80::2 at b8:27:eb:12:34:56 (Raspberry Pi Fou
Scope LINK-LOCAL
State ICMP_OTHER
Process -
PID -
Service -
Network Context Transport Health
Local Hostname - No transport metrics for this protocol
Local MAC -
Remote Hostname -
Attributed Name -
Attributed Via -
Remote MAC -
Country -
City -
ASN -
▎ Traffic Statistics
↓ RX - → peak - ↑ TX - → peak -
@@ -41,4 +41,4 @@ Total 0 B · 0 packets Total 0 B
'h' help | 1-5 jump | Tab/[/] cycle | j/k prev/next | Ctrl-d/u scroll | 'c' copy remote addr | Esc back
j/k prev/next ctrl-d/u scroll c copy remote addr esc back h help q quit
@@ -2,37 +2,37 @@
source: src/ui/mod.rs
expression: output
---
rustnet 1 Overview 2 Details 3 Activity 4 Graph 5 Help
rustnet 1 Overview 2 Details 3 Activity 4 Graph 5 Help ● eth0
─────────────────────────━━━━━━━━━──────────────────────────────────────────────────────────────────────────────────────────────────────────
Process Remote Local Service App State RTT Rx↓/Tx↑
Process Remote Local Service App State RTT Rx↓/Tx↑
> - 224.0.0.251:0 192.168.1.10:0 - IGMP REPORT_V2(2… - -/-
- 224.0.0.251:0 192.168.1.10:0 - IGMP REPORT_V2(2… - -/-
▎ ? → 224.0.0.251:0 · click a field to copy
Connection Application: IGMP
Protocol IGMP Message Membership Report v2
Status Active (last seen <T> ago) Group Address 224.0.0.251
Local Address 192.168.1.10:0
Remote Address 224.0.0.251:0
Scope MULTICAST
State REPORT_V2(224.0.0.251)
Process -
PID -
Service -
Network Context Transport Health
Local Hostname - No transport metrics for this protocol
Local MAC -
Remote Hostname -
Attributed Name -
Attributed Via -
Remote MAC -
Country -
City -
ASN -
▎ ? → 224.0.0.251:0 [REPORT_V2(224.0.0.251)] · click a field to copy
Connection Application: IGMP
Protocol IGMP Message Membership Report v2
Status Active (last seen <T> ago) Group Address 224.0.0.251
Local Address 192.168.1.10:0
Remote Address 224.0.0.251:0
Scope MULTICAST
State REPORT_V2(224.0.0.251)
Process -
PID -
Service -
Network Context Transport Health
Local Hostname - No transport metrics for this protocol
Local MAC -
Remote Hostname -
Attributed Name -
Attributed Via -
Remote MAC -
Country -
City -
ASN -
▎ Traffic Statistics
↓ RX - → peak - ↑ TX - → peak -
@@ -41,4 +41,4 @@ Total 0 B · 0 packets Total 0 B
'h' help | 1-5 jump | Tab/[/] cycle | j/k prev/next | Ctrl-d/u scroll | 'c' copy remote addr | Esc back
j/k prev/next ctrl-d/u scroll c copy remote addr esc back h help q quit
@@ -2,37 +2,37 @@
source: src/ui/mod.rs
expression: output
---
rustnet 1 Overview 2 Details 3 Activity 4 Graph 5 Help
rustnet 1 Overview 2 Details 3 Activity 4 Graph 5 Help ● eth0
─────────────────────────━━━━━━━━━──────────────────────────────────────────────────────────────────────────────────────────────────────────
Process Remote Local Service App State RTT Rx↓/Tx↑
Process Remote Local Service App State RTT Rx↓/Tx↑
> proc (4242) 203.0.113.7:4433 192.168.1.10:50000 - UDP·NTP (v4 Server) NTP - -/-
proc (4242) 203.0.113.7:4433 192.168.1.10:50000 - UDP·NTP (v4 Server) NTP - -/-
▎ proc → 203.0.113.7:4433 · click a field to copy
Connection Application: NTP
Protocol UDP NTP Version 4
Status Active (last seen <T> ago) NTP Mode Server
Local Address 192.168.1.10:50000 Stratum 2
Remote Address 203.0.113.7:4433
Scope DOCUMENTATION
State NTP
Process proc
PID 4242
Service -
Network Context Transport Health
Local Hostname - NTP RTT -
Local MAC -
Remote Hostname - Paired by originate timestamp echo
Attributed Name -
Attributed Via -
Remote MAC -
Country -
City -
ASN -
▎ proc → 203.0.113.7:4433 [NTP] · click a field to copy
Connection Application: NTP
Protocol UDP NTP Version 4
Status Active (last seen <T> ago) NTP Mode Server
Local Address 192.168.1.10:50000 Stratum 2
Remote Address 203.0.113.7:4433
Scope DOCUMENTATION
State NTP
Process proc
PID 4242
Service -
Network Context Transport Health
Local Hostname - NTP RTT -
Local MAC -
Remote Hostname - Paired by originate timestamp echo
Attributed Name -
Attributed Via -
Remote MAC -
Country -
City -
ASN -
▎ Traffic Statistics
↓ RX - → peak - ↑ TX - → peak -
@@ -41,4 +41,4 @@ Total 0 B · 0 packets Total 0 B
'h' help | 1-5 jump | Tab/[/] cycle | j/k prev/next | Ctrl-d/u scroll | 'c' copy remote addr | Esc back
j/k prev/next ctrl-d/u scroll c copy remote addr esc back h help q quit
@@ -2,37 +2,37 @@
source: src/ui/mod.rs
expression: output
---
rustnet 1 Overview 2 Details 3 Activity 4 Graph 5 Help
rustnet 1 Overview 2 Details 3 Activity 4 Graph 5 Help ● eth0
─────────────────────────━━━━━━━━━──────────────────────────────────────────────────────────────────────────────────────────────────────────
Process Remote Local Service App State RTT Rx↓/Tx↑
Process Remote Local Service App State RTT Rx↓/Tx↑
> proc (4242) 203.0.113.7:4433 192.168.1.10:50000 - TCP·SSH (OpenSSH) ESTABLISHED - -/-
proc (4242) 203.0.113.7:4433 192.168.1.10:50000 - TCP·SSH (OpenSSH) ESTABLISHED - -/-
▎ proc → 203.0.113.7:4433 · click a field to copy
Connection Application: SSH
Protocol TCP SSH Version SSH-2
Status Active (last seen <T> ago) Connection State Established
Local Address 192.168.1.10:50000 Server Software OpenSSH_9.6p1
Remote Address 203.0.113.7:4433 Client Software OpenSSH_9.8
Scope DOCUMENTATION Algorithms curve25519-sha256, ssh-ed25519
State ESTABLISHED Auth Method publickey
Process proc
PID 4242
Service -
Network Context Transport Health
Local Hostname - Initial RTT -
Local MAC - Live RTT -
Remote Hostname - TCP Retransmits 0
Attributed Name - Out-of-Order Packets 0
Attributed Via - Duplicate ACKs 0
Remote MAC - Fast Retransmits 0
Country - Window Size 0
City -
ASN -
▎ proc → 203.0.113.7:4433 [ESTABLISHED] · click a field to copy
Connection Application: SSH
Protocol TCP SSH Version SSH-2
Status Active (last seen <T> ago) Connection State Established
Local Address 192.168.1.10:50000 Server Software OpenSSH_9.6p1
Remote Address 203.0.113.7:4433 Client Software OpenSSH_9.8
Scope DOCUMENTATION Algorithms curve25519-sha256, ssh-ed25519
State ESTABLISHED Auth Method publickey
Process proc
PID 4242
Service -
Network Context Transport Health
Local Hostname - Initial RTT -
Local MAC - Live RTT -
Remote Hostname - TCP Retransmits 0
Attributed Name - Out-of-Order Packets 0
Attributed Via - Duplicate ACKs 0
Remote MAC - Fast Retransmits 0
Country - Window Size 0
City -
ASN -
▎ Traffic Statistics
↓ RX - → peak - ↑ TX - → peak -
@@ -41,4 +41,4 @@ Total 0 B · 0 packets Total 0 B
'h' help | 1-5 jump | Tab/[/] cycle | j/k prev/next | Ctrl-d/u scroll | 'c' copy remote addr | Esc back
j/k prev/next ctrl-d/u scroll c copy remote addr esc back h help q quit
@@ -2,37 +2,37 @@
source: src/ui/mod.rs
expression: output
---
rustnet 1 Overview 2 Details 3 Activity 4 Graph 5 Help
rustnet 1 Overview 2 Details 3 Activity 4 Graph 5 Help ● eth0
─────────────────────────━━━━━━━━━──────────────────────────────────────────────────────────────────────────────────────────────────────────
Process Remote Local Service App State RTT Rx↓/Tx↑
Process Remote Local Service App State RTT Rx↓/Tx↑
> firefox (2001) 140.82.121.4:443 192.168.1.10:51234 https TCP ESTABLISHED - -/-
systemd-resolved (820) 1.1.1.1:53 192.168.1.10:53 dns UDP UDP_ACTIVE - -/-
sshd (1500) 10.0.0.5:51022 192.168.1.10:22 ssh TCP ESTABLISHED - -/-
firefox (2001) 140.82.121.4:443 192.168.1.10:51234 https TCP ESTABLISHED - -/-
systemd-resolved (820) 1.1.1.1:53 192.168.1.10:53 dns UDP UDP_ACTIVE - -/-
sshd (1500) 10.0.0.5:51022 192.168.1.10:22 ssh TCP ESTABLISHED - -/-
▎ firefox → 140.82.121.4:443 [ESTABLISHED] · click a field to copy
Connection Application ▐
Protocol TCP Detected - ▐
Status Active (last seen <T> ago) ▐
Local Address 192.168.1.10:51234 ▐
Remote Address 140.82.121.4:443 ▐
Scope PUBLIC ▐
State ESTABLISHED ▐
Process firefox ▐
PID 2001 ▐
Service https ▐
Network Context Transport Health ▐
Local Hostname - Initial RTT - ▐
Local MAC 68:5e:dd:09:15:5e (Apple, Inc.) Live RTT - ▐
Remote Hostname - TCP Retransmits 0 ▐
Attributed Name - Out-of-Order Packets 0 ▐
Attributed Via - Duplicate ACKs 0 ▐
Remote MAC - Fast Retransmits 0
Country - Window Size 0
City -
ASN -
▎ firefox → 140.82.121.4:443 · click a field to copy
Connection Application █
Protocol TCP Detected - █
Status Active (last seen <T> ago) █
Local Address 192.168.1.10:51234 █
Remote Address 140.82.121.4:443 █
Scope PUBLIC █
State ESTABLISHED █
Process firefox █
PID 2001 █
Service https █
Network Context Transport Health █
Local Hostname - Initial RTT - █
Local MAC 68:5e:dd:09:15:5e (Apple, Inc.) Live RTT - █
Remote Hostname - TCP Retransmits 0 █
Attributed Name - Out-of-Order Packets 0 █
Attributed Via - Duplicate ACKs 0 █
Remote MAC - Fast Retransmits 0 ║
Country - Window Size 0 ║
City - ║
ASN - ║
▎ Traffic Statistics
↓ RX - → peak - ↑ TX - → peak -
@@ -41,4 +41,4 @@ Total 234.38 KB · 234 packets Total 12.
'h' help | 1-5 jump | Tab/[/] cycle | j/k prev/next | Ctrl-d/u scroll | 'c' copy remote addr | Esc back
j/k prev/next ctrl-d/u scroll c copy remote addr esc back h help q quit
@@ -1,6 +0,0 @@
---
source: src/ui/mod.rs
assertion_line: 676
expression: output
---
/ tcp port:443 filter active · Esc clears
@@ -2,10 +2,10 @@
source: src/ui/mod.rs
expression: output
---
rustnet 1 Overview 2 Details 3 Activity 4 Graph 5 Help
rustnet 1 Overview 2 Details 3 Activity 4 Graph 5 Help ● eth0
──────────────────────────────────────────────────━━━━━━━───────────────────────────────────────────────────────────────────────────────────
▎ Traffic Over Time (60s) ▎ Connection Lifecycle
Collecting data... Collecting...
Waiting for traffic data... Waiting for connection data...
@@ -19,7 +19,7 @@ Collecting data...
▎ Network Health ▎ TCP Counters ▎ TCP States
Collecting data... Retransmits 0 ESTAB █████████████████████████ 2
Waiting for health data... Retransmits 0 ESTAB █████████████████████████ 2
Out of Order 0 TIME_WAIT ████████████ 1
Fast Retrans 0
@@ -41,4 +41,4 @@ Other ████████████████████████
'h' help | 1-5 jump | Tab/[/] cycle | Esc back to Overview
esc back h help q quit
@@ -2,53 +2,53 @@
source: src/ui/mod.rs
expression: output
---
╭Help · ↑/↓ scroll─────────────────────────────────────────────────────────────────────────────────╮
│RustNet Monitor - Network Connection Monitor █│
│ █│
│q Quit application (press twice to confirm) █│
Ctrl+C Quit immediately █│
│x Clear all connections (press twice to confirm) █│
Tab, ] Next tab █│
Shift+Tab, [ Previous tab █│
1-5 Jump directly to a tab (1=Overview, 2=Details, 3=Activity, 4=Graph, 5=Help) █│
│↑/k, ↓/j Navigate connections (wraps around) █│
│g, G Jump to first/last connection (vim-style) █│
│Page Up/Down, Ctrl+B/F Navigate connections by page █│
│Ctrl+D/U Scroll the Details info panes █│
│c Copy remote address to clipboard █│
│p Toggle between service names and port numbers █│
│d Toggle hostnames/IPs on Overview or Egress (TX)/Ingress (RX) on Activity █│
│s Cycle through sort columns (Bandwidth, Process, etc.) █│
│S Toggle sort direction (ascending/descending) █│
│a Toggle process grouping (aggregate by process) █│
│Space Expand/collapse group (when grouping enabled) █│
│←/→ or h/l Collapse/expand group █│
│t Toggle display of historic (closed) connections █│
│i Toggle System info on Overview or interface details on Activity █│
│r Reset view (grouping, sort, filter) █│
│Enter View connection details █│
│Esc Return to overview █│
│h Toggle this help screen █│
│/ Enter filter mode on Overview (use ↑/↓ to navigate while typing) █│
│ █│
│Tabs: █│
│Overview Connection list with mini traffic graph █│
│Details Full details for selected connection █│
│Activity Process egress/ingress, bandwidth shares, connections, and interface pulse █│
│Graph Traffic charts and protocol distribution ║│
│Help This help screen ║│
│ ║│
│Activity concepts: ║│
│Egress (TX) / Ingress (RX) Traffic sent from or received by the local process ║│
│60s coverage Captured connection traffic divided by interface traffic ║│
│Retained Active traffic plus up to 5,000 recently closed connections ║│
│Process attribution Traffic mapped to a PID or process name; unresolved bytes are Unknown ║│
│Top remote peer Highest-volume remote endpoint for the selected direction ║│
│ ║│
│Mouse Controls: ║│
│Click tab Switch between tabs ║│
│Click row Select connection ║│
│Scroll wheel Navigate connection list / scroll Details, Activity interfaces, Help ║│
│Double-click row Open connection details ║│
│Double-click group Expand/collapse process group ║│
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
RustNet Monitor - Network Connection Monitor · ↑/↓ scroll ▐
▎ Key Bindings ▐
q Quit application (press twice to confirm)
Ctrl+C Quit immediately
x Clear all connections (press twice to confirm)
Tab, ] Next tab
Shift+Tab, [ Previous tab
1-5 Jump directly to a tab (1=Overview, 2=Details, 3=Activity, 4=Graph,
5=Help)
↑/k, ↓/j Navigate connections (wraps around)
g, G Jump to first/last connection (vim-style) ▐
Page Up/Down, Ctrl+B/F Navigate connections by page ▐
Ctrl+D/U Scroll the Details info panes ▐
c Copy remote address to clipboard ▐
p Toggle between service names and port numbers ▐
d Toggle hostnames/IPs on Overview or Egress (TX)/Ingress (RX) on Activity ▐
s Cycle through sort columns (Bandwidth, Process, etc.) ▐
S Toggle sort direction (ascending/descending) ▐
a Toggle process grouping (aggregate by process) ▐
Space Expand/collapse group (when grouping enabled) ▐
←/→ or h/l Collapse/expand group ▐
t Toggle display of historic (closed) connections ▐
i Toggle System info on Overview or interface details on Activity ▐
r Reset view (grouping, sort, filter) ▐
Enter View connection details ▐
Esc Return to overview ▐
h Toggle this help screen
/ Enter filter mode on Overview (use ↑/↓ to navigate while typing) ▐
▎ Tabs ▐
Overview Connection list with mini traffic graph ▐
Details Full details for selected connection ▐
Activity Process egress/ingress, bandwidth shares, connections, and interface pulse ▐
Graph Traffic charts and protocol distribution ▐
Help This help screen
▎ Activity Concepts
Egress (TX) / Ingress (RX) Traffic sent from or received by the local process
60s coverage Captured connection traffic divided by interface traffic
Retained Active traffic plus up to 5,000 recently closed connections
Process attribution Traffic mapped to a PID or process name; unresolved bytes are
Unknown
Top remote peer Highest-volume remote endpoint for the selected direction
▎ Mouse Controls
Click tab Switch between tabs
Click row Select connection
Scroll wheel Navigate connection list / scroll Details, Activity interfaces, Help
Double-click row Open connection details
@@ -2,15 +2,15 @@
source: src/ui/mod.rs
expression: render_overview(false)
---
rustnet 1 Overview 2 Details 3 Activity 4 Graph 5 Help
rustnet 1 Overview 2 Details 3 Activity 4 Graph 5 Help ● eth0
────────────━━━━━━━━━━──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
▎ Live Connections
Process Remote Local Service App State RTT Rx↓/Tx↑
Process Remote Local Service App State RTT Rx↓/Tx↑
> firefox (2001) 140.82.121.4:443 192.168.1.10:51234 https TCP ESTABLISHED 23ms 3.1M/928K
systemd-resolved (820) 1.1.1.1:53 192.168.1.10:53 dns UDP UDP_ACTIVE - 47K/82K
sshd (1500) 10.0.0.5:51022 192.168.1.10:22 ssh TCP ESTABLISHED 184ms 410K/1.7M
curl (9876) 151.101.1.195:443 192.168.1.10:60123 https TCP TIME_WAIT - -/-
firefox (2001) 140.82.121.4:443 192.168.1.10:51234 https TCP ESTABLISHED 23ms 3.1M/928K
systemd-resolved (820) 1.1.1.1:53 192.168.1.10:53 dns UDP UDP_ACTIVE - 47K/82K
sshd (1500) 10.0.0.5:51022 192.168.1.10:22 ssh TCP ESTABLISHED 184ms 410K/1.7M
curl (9876) 151.101.1.195:443 192.168.1.10:60123 https TCP TIME_WAIT - -/-
@@ -27,4 +27,4 @@ expression: render_overview(false)
'h' help | 1-5 jump | Tab/[/] cycle | '/' filter | 'a' group | 't' history | 'i' info | 'c' copy
↑↓ select / filter a group t history i info c copy h help q quit
@@ -2,15 +2,15 @@
source: src/ui/mod.rs
expression: render_overview(true)
---
rustnet 1 Overview 2 Details 3 Activity 4 Graph 5 Help
rustnet 1 Overview 2 Details 3 Activity 4 Graph 5 Help ● eth0
────────────━━━━━━━━━━──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
▎ Process Aggregate
Process Remote Local Service App State RTT Rx↓/Tx↑
Process Remote Local Service App State RTT Rx↓/Tx↑
> ▸ curl (1) TCP:1 UDP:0 -/-
▸ firefox (1) TCP:1 UDP:0 3.1M/928K
▸ sshd (1) TCP:1 UDP:0 410K/1.7M
▸ systemd-resolved (1) TCP:0 UDP:1 47K/82K
▸ curl (1) TCP:1 UDP:0 -/-
▸ firefox (1) TCP:1 UDP:0 3.1M/928K
▸ sshd (1) TCP:1 UDP:0 410K/1.7M
▸ systemd-resolved (1) TCP:0 UDP:1 47K/82K
@@ -27,4 +27,4 @@ expression: render_overview(true)
'h' help | 1-5 jump | Tab/[/] cycle | '/' filter | 'a' group | 't' history | 'i' info | 'c' copy
↑↓ select / filter a group t history i info c copy h help q quit
@@ -2,4 +2,4 @@
source: src/ui/mod.rs
expression: output
---
'h' help | 1-5 jump | Tab/[/] cycle | 'd' TX/RX | 's' sort | 'S' order | 'i' interfaces | Esc back
d tx/rx s sort S order i interfaces esc back h help q quit
@@ -1,6 +1,5 @@
---
source: src/ui/mod.rs
assertion_line: 697
expression: output
---
'h' help | 1-5 jump | Tab/[/] cycle | j/k prev/next | Ctrl-d/u scroll | 'c' copy remote addr | Esc back
j/k prev/next c copy remote addr esc back h help q quit
@@ -1,6 +1,5 @@
---
source: src/ui/mod.rs
assertion_line: 703
expression: output
---
'h' help | 1-5 jump | Tab/[/] cycle | Showing 7 filtered connections (Esc to clear)
esc clear filter ↑↓ select / filter a group t history i info c copy h help q quit
@@ -1,6 +1,5 @@
---
source: src/ui/mod.rs
assertion_line: 707
expression: output
---
'h' help | 1-5 jump | Tab/[/] cycle | j/k scroll | Esc back to Overview
j/k scroll esc back h help q quit
@@ -1,6 +1,5 @@
---
source: src/ui/mod.rs
assertion_line: 648
expression: output
---
'h' help | 1-5 jump | Tab/[/] cycle | '/' filter | 'a' group | 't' history | 'i' info | 'c' copy
↑↓ select / filter a group t history i info c copy h help q quit
+52
View File
@@ -123,6 +123,16 @@ impl PaneScroll {
pub fn reset(&mut self) {
self.offset = 0;
// Forget the last render's extent too, so the hint gating does not
// keep advertising scroll for content that is gone; the next render
// reports the real extent again.
self.max.set(0);
}
/// Whether the last render had content beyond the viewport. Drives
/// hints that would otherwise advertise a key that does nothing.
pub fn can_scroll(&self) -> bool {
self.max.get() > 0
}
/// Record this render's maximum scroll offset and return the
@@ -426,6 +436,22 @@ impl UIState {
!self.filter_query.trim().is_empty()
}
/// Whether the connection list is being narrowed right now: a
/// persisted query, or one currently being typed. Drives the
/// Overview tab's activity dot in the tab bar.
pub fn is_filtering(&self) -> bool {
self.filter_mode || self.has_active_filter()
}
/// Whether the filter input row is on screen, claiming a terminal row.
/// The row is an editing surface only: once a query is confirmed it is
/// gone and the title chip carries the state. The layout in `ui::draw`
/// and the page-navigation math in `main` both read this, so the two
/// cannot disagree about how many rows the chrome occupies.
pub fn filter_row_visible(&self) -> bool {
self.filter_mode
}
/// Set the selected connection key, resetting the Details pane
/// scroll when the selection actually changes so a newly selected
/// record always starts at the top.
@@ -1069,6 +1095,32 @@ mod tests {
assert_eq!(ui.filter_cursor_position, 0);
}
#[test]
fn is_filtering_covers_both_typing_and_a_persisted_query() {
assert!(!UIState::default().is_filtering());
// Filter mode with an empty query still counts: the user is typing.
let typing = UIState {
filter_mode: true,
..UIState::default()
};
assert!(typing.is_filtering());
// A persisted query counts after filter mode is left.
let persisted = UIState {
filter_query: "port:443".to_string(),
..UIState::default()
};
assert!(persisted.is_filtering());
// Whitespace alone narrows nothing.
let blank = UIState {
filter_query: " ".to_string(),
..UIState::default()
};
assert!(!blank.is_filtering());
}
#[test]
fn jump_to_tab_sets_selected_and_help_flag() {
// Each in-range index switches to the matching tab; `show_help` must
+43 -6
View File
@@ -12,7 +12,7 @@ use ratatui::{
};
use crate::app::App;
use crate::network::process_activity::{ProcessActivity, ProcessActivitySnapshot};
use crate::network::process_activity::{ProcessActivity, ProcessActivitySnapshot, ProcessIdentity};
use crate::ui::{
ActivityDirection, ActivitySort, ClickableRegions, Component, ComponentContext, Effect,
HandlerContext, UIState,
@@ -511,7 +511,7 @@ fn draw_process_table(
if snapshot.processes.is_empty() {
f.render_widget(
Paragraph::new("Listening for process traffic...").style(theme::fg(theme::muted())),
Paragraph::new("Waiting for process traffic...").style(theme::fg(theme::muted())),
inner,
);
return;
@@ -530,6 +530,7 @@ fn draw_process_table(
let wide = inner.width >= 132;
let medium = inner.width >= 90;
let pulse_width = if wide { 14 } else { 10 };
let name_width = if wide { 24 } else { 20 };
let rows: Vec<Row> = processes
.into_iter()
@@ -540,7 +541,12 @@ fn draw_process_table(
} else {
retained_share(&process, traffic_direction) / 100.0
};
let mut cells = vec![Cell::from(process.identity.display_name())];
let name = truncate_with_ellipsis(&process.identity.display_name(), name_width);
let name_cell = match process_tint(&process.identity) {
Some(style) => Cell::from(name).style(style),
None => Cell::from(name),
};
let mut cells = vec![name_cell];
if medium {
cells.push(Cell::from(Line::from(glow_bar::spans(
pulse_fraction,
@@ -618,7 +624,7 @@ fn draw_process_table(
.collect();
let mut headers = vec![Cell::from("Process")];
let mut constraints = vec![Constraint::Length(if wide { 24 } else { 20 })];
let mut constraints = vec![Constraint::Length(name_width as u16)];
if medium {
headers.push(Cell::from("Pulse"));
constraints.push(Constraint::Length(pulse_width as u16));
@@ -666,6 +672,19 @@ fn right_cell(value: String) -> Cell<'static> {
Cell::from(Line::from(value).right_aligned())
}
/// Stable per-process tint for a process name, so the same process keeps
/// the same hue wherever it appears. `None` keeps the caller's own style:
/// the theme withholds a tint on NO_COLOR, non-truecolor terminals, and
/// the vivid preset, and unattributed rows keep their warning color so
/// the "could not be mapped" cue is never painted over.
fn process_tint(identity: &ProcessIdentity) -> Option<Style> {
identity
.attributed
.then(|| theme::identity_color(&identity.name))
.flatten()
.map(theme::fg)
}
fn draw_traffic_share(
f: &mut Frame,
snapshot: &ProcessActivitySnapshot,
@@ -703,7 +722,8 @@ fn draw_traffic_share(
let name = truncate_with_ellipsis(&process.identity.display_name(), name_width);
let mut spans = vec![Span::styled(
format!("{name:<name_width$} "),
theme::fg(theme::field_process()),
process_tint(&process.identity)
.unwrap_or_else(|| theme::fg(theme::field_process())),
)];
spans.extend(glow_bar::spans(
window_share(&process, direction) / 100.0,
@@ -733,7 +753,7 @@ fn draw_interface_pulse(f: &mut Frame, app: &App, direction: ActivityDirection,
let rates = app.get_interface_rates();
if rates.is_empty() {
f.render_widget(
Paragraph::new("Collecting interface counters...").style(theme::fg(theme::muted())),
Paragraph::new("Waiting for interface counters...").style(theme::fg(theme::muted())),
inner,
);
return;
@@ -886,6 +906,23 @@ mod tests {
assert_eq!(truncate_with_ellipsis("short", 6), "short");
}
#[test]
fn unattributed_processes_keep_their_warning_style() {
let mut identity = ProcessIdentity {
pid: Some(42),
name: "firefox".to_string(),
attributed: false,
};
assert_eq!(process_tint(&identity), None);
// Attributed names only get a tint where the theme offers one, so
// the tint must equal the theme's answer for the same name.
identity.attributed = true;
assert_eq!(
process_tint(&identity),
theme::identity_color("firefox").map(theme::fg)
);
}
#[test]
fn direction_bars_match_graph_wave_colors() {
assert_eq!(
+202 -19
View File
@@ -8,7 +8,7 @@ use anyhow::Result;
use ratatui::{
Frame,
layout::{Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style},
style::{Modifier, Style},
text::{Line, Span},
widgets::{Paragraph, Wrap},
};
@@ -30,11 +30,14 @@ use crate::network::types::{
use crate::ui::{
ClickAction, ClickableRegions, Component, ComponentContext, Effect, GroupedRow, HandlerContext,
NONE_PLACEHOLDER,
connection_table::{build_header, column_constraints, connection_row, select_columns},
dpi_color,
format::{format_bytes, format_rate},
connection_table::{
SELECTION_BAR, build_header, column_constraints, connection_row, select_columns,
},
dpi_color, fade_line,
format::{ellipsize_left, format_bytes, format_rate, format_rtt_compact},
non_dpi_app_color, section_header, state_color, theme, try_handle_connection_nav,
try_handle_pane_wheel,
widgets::badge::{chip, pill},
widgets::braille_graph,
widgets::scrollbar::draw_scrollbar,
};
@@ -471,6 +474,54 @@ fn process_tree_value(lineage: &ProcessLineage, owner_name: &str, max_width: usi
format!("{prefix}")
}
/// Rendered width of a span run, in characters.
fn span_cells(spans: &[Span<'_>]) -> usize {
spans.iter().map(|s| s.content.chars().count()).sum()
}
/// Drop header-band badges right to left until they fit `room` cells,
/// counting the separating space each one is rendered with. The state
/// pill is the last to go, but it goes too: `room` already reserves the
/// trailing hints, so a pill kept past the edge would push them out of
/// the band instead of being dropped itself.
fn fit_badges(mut badges: Vec<Vec<Span<'static>>>, room: usize) -> Vec<Vec<Span<'static>>> {
let row_cells = |badges: &[Vec<Span<'static>>]| {
badges
.iter()
.map(|badge| span_cells(badge) + 1)
.sum::<usize>()
};
while !badges.is_empty() && row_cells(&badges) > room {
badges.pop();
}
badges
}
/// Dim the boundary rows of a scrolled pane: the first visible row when
/// content continues above it, the last when content continues below.
/// Nothing is inserted or removed, so the panes' fixed row positions and
/// the click-to-copy registry stay in step with the rendered lines.
fn fade_scroll_edges(lines: &mut [Line<'_>], scroll: u16, height: u16) {
if height == 0 {
return;
}
let (top, height) = (scroll as usize, height as usize);
let bottom = top + height - 1;
if top > 0 {
fade_at(lines, top);
}
if bottom + 1 < lines.len() {
fade_at(lines, bottom);
}
}
/// Apply the shared edge fade to the line at `index`, if it exists.
fn fade_at(lines: &mut [Line<'_>], index: usize) {
if let Some(line) = lines.get_mut(index) {
fade_line(line);
}
}
/// Component-aware middle ellipsis: `/nix/store/…/bin/hello`.
fn fit_path_middle(display: &str, max_width: usize) -> String {
let width = |s: &str| s.chars().count();
@@ -498,10 +549,7 @@ fn fit_path_middle(display: &str, max_width: usize) -> String {
if components.is_empty() || width(&candidate(0, 1)) > max_width {
// Not even `…/basename` fits; keep the end of the string, which at
// least ends in the basename.
let keep = max_width.saturating_sub(1);
let skip = width(display).saturating_sub(keep);
let tail: String = display.chars().skip(skip).collect();
return format!("{tail}");
return ellipsize_left(display, max_width);
}
// Grow greedily from both ends, leading components first: where the
@@ -771,7 +819,17 @@ fn draw_connection_strip(
let rows: Vec<ratatui::widgets::Row> = window
.iter()
.map(|conn| connection_row(conn, &columns, ui_state, dns_resolver, None))
.enumerate()
.map(|(i, conn)| {
connection_row(
conn,
&columns,
ui_state,
dns_resolver,
None,
start + i == selected,
)
})
.collect();
let mut state = ratatui::widgets::TableState::default();
@@ -780,7 +838,7 @@ fn draw_connection_strip(
let table = ratatui::widgets::Table::new(rows, &widths)
.header(header)
.row_highlight_style(theme::row_highlight())
.highlight_symbol("> ");
.highlight_symbol(Line::styled(SELECTION_BAR, theme::fg(theme::accent())));
f.render_stateful_widget(table, area, &mut state);
let header_height = 2_u16; // column header (1) + bottom margin (1)
@@ -809,6 +867,9 @@ pub(in crate::ui) fn draw_connection_details(
let (has_country_db, _has_asn_db, _has_city_db) = ctx.app.get_geoip_status();
if connections.is_empty() {
// Nothing rendered: clear the recorded scroll extent so the status
// bar stops offering ctrl-d/u for a record that is no longer there.
ui_state.details_scroll.clamp_for_render(0);
return Ok(());
}
@@ -1726,30 +1787,71 @@ pub(in crate::ui) fn draw_connection_details(
};
let staleness = conn.staleness_ratio();
let title_style = if conn.is_historic {
Style::default()
.fg(Color::DarkGray)
.add_modifier(Modifier::DIM | Modifier::BOLD)
theme::fg(theme::faint()).add_modifier(Modifier::DIM | Modifier::BOLD)
} else if let Some(intensity) = theme::expiry_glow_intensity(staleness) {
theme::bold_fg(theme::expiry_glow(intensity))
} else {
Style::default().add_modifier(Modifier::BOLD)
};
// Badges after the title: the connection state as a solid pill in the
// state's own color, then quiet chips for the live rates and the
// measured RTT. They repeat values the cards below carry, so the band
// answers "how is this flow doing" before anything is scrolled.
// Historic and idle records have no live rates and, once historic, no
// state color left: they keep the pill on the muted tier and drop the
// rate chip rather than show a chip full of placeholders.
let state_bg = if conn.is_historic {
theme::muted()
} else {
state_color(conn)
};
let mut badges: Vec<Vec<Span<'static>>> = vec![pill(&conn.state(), state_bg)];
let moving = conn.current_incoming_rate_bps > 0.0 || conn.current_outgoing_rate_bps > 0.0;
if !conn.is_historic && moving {
badges.push(chip(&format!(
"{} in · {} out",
format_rate(conn.current_incoming_rate_bps),
format_rate(conn.current_outgoing_rate_bps),
)));
}
if let Some(rtt) = conn.current_rtt() {
badges.push(chip(&format!("rtt {}", format_rtt_compact(rtt))));
}
// One header band across the whole info area; the panes below it
// are borderless. When grouping is on, say so — the strip above and
// the j/k navigation follow the grouped view's order, mirroring the
// "Grouped by Process" suffix in the Overview title.
let mut band = vec![Span::styled(detail_title, title_style)];
let mut suffix: Vec<Span<'static>> = Vec::new();
if ui_state.grouping_enabled {
band.push(Span::styled(
suffix.push(Span::styled(
" · grouped by process",
theme::fg(theme::muted()),
));
}
band.push(Span::styled(
" · click a field to copy",
theme::fg(theme::muted()),
));
// Same rule as the footer's copy hint: no point pointing at fields whose
// only outcome would be a clipboard error.
if crate::ui::clipboard_available(ctx.app) {
suffix.push(Span::styled(
" · click a field to copy",
theme::fg(theme::muted()),
));
}
// The band is a single row, so the badges get whatever the title and
// the muted hints leave. One cell goes to the "▎" tick that
// section_header prefixes.
let room = (body.width as usize)
.saturating_sub(1 + detail_title.chars().count() + span_cells(&suffix));
let badges = fit_badges(badges, room);
let mut band = vec![Span::styled(detail_title, title_style)];
for badge in badges {
band.push(Span::raw(" "));
band.extend(badge);
}
band.extend(suffix);
let info_area = section_header(f, body, Line::from(band));
let info_area = Rect {
width: info_area.width.min(DETAILS_MAX_CONTENT_WIDTH),
@@ -1827,6 +1929,12 @@ pub(in crate::ui) fn draw_connection_details(
let max_scroll = (content_rows as u16).saturating_sub(info_h);
let scroll = ui_state.details_scroll.clamp_for_render(max_scroll);
// Fade the rows the scroll window cuts through, so a clipped card
// reads as "there is more" rather than as a hard edge. Styling only:
// the row count and every anchor below it stay put.
fade_scroll_edges(&mut details_text, scroll, info_h);
fade_scroll_edges(&mut right_text, scroll, info_h);
// Card rows must stay one terminal row tall. Long hostnames, SNI values,
// and identifiers are clipped at the pane edge instead of wrapping and
// displacing every anchor below them. The complete value remains available
@@ -2058,6 +2166,81 @@ pub(in crate::ui) fn draw_connection_details(
Ok(())
}
#[cfg(test)]
mod header_band_tests {
use super::{fade_scroll_edges, fit_badges, span_cells};
use crate::ui::theme;
use crate::ui::widgets::badge::{chip, pill};
use ratatui::style::Color;
use ratatui::text::{Line, Span};
fn badges() -> Vec<Vec<Span<'static>>> {
vec![
pill("ESTABLISHED", Color::Rgb(0x4c, 0xaf, 0x50)),
chip("1.20 KB/s in · 340 B/s out"),
chip("rtt 34ms"),
]
}
#[test]
fn a_wide_band_keeps_every_badge() {
let all = badges();
let room = all.iter().map(|badge| span_cells(badge) + 1).sum();
assert_eq!(fit_badges(badges(), room).len(), 3);
}
#[test]
fn a_narrow_band_drops_badges_right_to_left() {
let all = badges();
let rtt_cells = span_cells(&all[2]) + 1;
let room: usize = all.iter().map(|badge| span_cells(badge) + 1).sum();
assert_eq!(fit_badges(badges(), room - rtt_cells).len(), 2);
assert_eq!(fit_badges(badges(), 0).len(), 0);
}
#[test]
fn the_state_pill_is_the_last_badge_to_go() {
let pill_cells = span_cells(&badges()[0]) + 1;
let fitted = fit_badges(badges(), pill_cells);
assert_eq!(fitted.len(), 1);
assert_eq!(span_cells(&fitted[0]), span_cells(&badges()[0]));
// One cell short of the pill: the band keeps its hints instead.
assert!(fit_badges(badges(), pill_cells - 1).is_empty());
}
#[test]
fn only_the_cut_rows_of_a_scrolled_pane_fade() {
let plain = theme::fg(theme::text());
let mut lines: Vec<Line<'static>> = (0..6)
.map(|i| Line::from(Span::styled(format!("row {i}"), plain)))
.collect();
// Rows 1..=3 visible: content continues above and below.
fade_scroll_edges(&mut lines, 1, 3);
let faded = theme::edge_fade(plain);
assert_eq!(lines[1].spans[0].style, faded, "top boundary must fade");
assert_eq!(lines[3].spans[0].style, faded, "bottom boundary must fade");
for index in [0, 2, 4, 5] {
assert_eq!(
lines[index].spans[0].style, plain,
"row {index} is not a boundary and must keep its style"
);
}
}
#[test]
fn a_pane_that_does_not_scroll_keeps_every_row() {
let plain = theme::fg(theme::text());
let mut lines: Vec<Line<'static>> = (0..3)
.map(|i| Line::from(Span::styled(format!("row {i}"), plain)))
.collect();
fade_scroll_edges(&mut lines, 0, 3);
assert!(lines.iter().all(|line| line.spans[0].style == plain));
// A zero-height pane has no boundary rows to fade.
fade_scroll_edges(&mut lines, 0, 0);
assert!(lines.iter().all(|line| line.spans[0].style == plain));
}
}
#[cfg(test)]
mod path_shortening_tests {
use super::{
+6 -3
View File
@@ -173,7 +173,8 @@ fn draw_traffic_chart(f: &mut Frame, history: &TrafficHistory, area: Rect) {
let inner = section_header(f, area, graph_title(" Traffic Over Time (60s)"));
if !history.has_enough_data() {
let placeholder = Paragraph::new("Collecting data...").style(theme::fg(theme::muted()));
let placeholder =
Paragraph::new("Waiting for traffic data...").style(theme::fg(theme::muted()));
f.render_widget(placeholder, inner);
return;
}
@@ -215,7 +216,8 @@ fn draw_connection_lifecycle(f: &mut Frame, history: &TrafficHistory, area: Rect
let inner = section_header(f, area, graph_title(" Connection Lifecycle"));
if !history.has_enough_data() {
let placeholder = Paragraph::new("Collecting...").style(theme::fg(theme::muted()));
let placeholder =
Paragraph::new("Waiting for connection data...").style(theme::fg(theme::muted()));
f.render_widget(placeholder, inner);
return;
}
@@ -469,7 +471,8 @@ fn draw_health_chart(f: &mut Frame, history: &TrafficHistory, area: Rect) {
let inner = section_header(f, area, graph_title(" Network Health"));
if !history.has_enough_data() {
let placeholder = Paragraph::new("Collecting data...").style(theme::fg(theme::muted()));
let placeholder =
Paragraph::new("Waiting for health data...").style(theme::fg(theme::muted()));
f.render_widget(placeholder, inner);
return;
}
+234 -159
View File
@@ -1,23 +1,24 @@
//! Help/legend tab a scrollable paragraph of keybinds, mouse
//! controls, colors, and filter examples. Scroll position lives in
//! `UIState::help_scroll`.
//! Help/legend tab: a scrollable document of keybinds, mouse
//! controls, colors, and filter examples, laid out as tick-marked
//! sections of aligned key/description columns. Scroll position lives
//! in `UIState::help_scroll`.
use anyhow::Result;
use crossterm::event::{KeyEvent, MouseEvent, MouseEventKind};
use ratatui::{
Frame,
layout::Rect,
style::Style,
style::{Modifier, Style},
text::{Line, Span},
widgets::{Padding, Paragraph, Wrap},
widgets::{Block, Padding, Paragraph, Wrap},
};
use crate::ui::{
ClickableRegions, Component, ComponentContext, Effect, HandlerContext, UIState, panel_block,
ClickableRegions, Component, ComponentContext, Effect, HandlerContext, UIState, fade_line,
theme, try_handle_pane_scroll, widgets::scrollbar::draw_scrollbar,
};
/// Help tab. Zero-sized the scroll offset it responds to lives in
/// Help tab. Zero-sized: the scroll offset it responds to lives in
/// `UIState`, not here.
pub(in crate::ui) struct HelpTab;
@@ -59,208 +60,253 @@ impl Component for HelpTab {
}
}
/// Key/description rows for the keybind list. The key half keeps its
/// trailing space so the description starts one cell after it.
/// Key/description rows for the keybind list. Keys carry no padding;
/// each section pads its key column to the widest key at render time.
const KEY_BINDINGS: &[(&str, &str)] = &[
("q ", "Quit application (press twice to confirm)"),
("Ctrl+C ", "Quit immediately"),
("x ", "Clear all connections (press twice to confirm)"),
("Tab, ] ", "Next tab"),
("Shift+Tab, [ ", "Previous tab"),
("q", "Quit application (press twice to confirm)"),
("Ctrl+C", "Quit immediately"),
("x", "Clear all connections (press twice to confirm)"),
("Tab, ]", "Next tab"),
("Shift+Tab, [", "Previous tab"),
(
"1-5 ",
"1-5",
"Jump directly to a tab (1=Overview, 2=Details, 3=Activity, 4=Graph, 5=Help)",
),
("↑/k, ↓/j ", "Navigate connections (wraps around)"),
("g, G ", "Jump to first/last connection (vim-style)"),
("Page Up/Down, Ctrl+B/F ", "Navigate connections by page"),
("Ctrl+D/U ", "Scroll the Details info panes"),
("c ", "Copy remote address to clipboard"),
("p ", "Toggle between service names and port numbers"),
("↑/k, ↓/j", "Navigate connections (wraps around)"),
("g, G", "Jump to first/last connection (vim-style)"),
("Page Up/Down, Ctrl+B/F", "Navigate connections by page"),
("Ctrl+D/U", "Scroll the Details info panes"),
("c", "Copy remote address to clipboard"),
("p", "Toggle between service names and port numbers"),
(
"d ",
"d",
"Toggle hostnames/IPs on Overview or Egress (TX)/Ingress (RX) on Activity",
),
("s", "Cycle through sort columns (Bandwidth, Process, etc.)"),
("S", "Toggle sort direction (ascending/descending)"),
("a", "Toggle process grouping (aggregate by process)"),
("Space", "Expand/collapse group (when grouping enabled)"),
("←/→ or h/l", "Collapse/expand group"),
("t", "Toggle display of historic (closed) connections"),
(
"s ",
"Cycle through sort columns (Bandwidth, Process, etc.)",
),
("S ", "Toggle sort direction (ascending/descending)"),
("a ", "Toggle process grouping (aggregate by process)"),
("Space ", "Expand/collapse group (when grouping enabled)"),
("←/→ or h/l ", "Collapse/expand group"),
("t ", "Toggle display of historic (closed) connections"),
(
"i ",
"i",
"Toggle System info on Overview or interface details on Activity",
),
("r ", "Reset view (grouping, sort, filter)"),
("Enter ", "View connection details"),
("Esc ", "Return to overview"),
("h ", "Toggle this help screen"),
("r", "Reset view (grouping, sort, filter)"),
("Enter", "View connection details"),
("Esc", "Return to overview"),
("h", "Toggle this help screen"),
(
"/ ",
"/",
"Enter filter mode on Overview (use \u{2191}/\u{2193} to navigate while typing)",
),
];
const TAB_SUMMARIES: &[(&str, &str)] = &[
(" Overview ", "Connection list with mini traffic graph"),
(" Details ", "Full details for selected connection"),
("Overview", "Connection list with mini traffic graph"),
("Details", "Full details for selected connection"),
(
" Activity ",
"Activity",
"Process egress/ingress, bandwidth shares, connections, and interface pulse",
),
(" Graph ", "Traffic charts and protocol distribution"),
(" Help ", "This help screen"),
("Graph", "Traffic charts and protocol distribution"),
("Help", "This help screen"),
];
const ACTIVITY_CONCEPTS: &[(&str, &str)] = &[
(
" Egress (TX) / Ingress (RX) ",
"Egress (TX) / Ingress (RX)",
"Traffic sent from or received by the local process",
),
(
" 60s coverage ",
"60s coverage",
"Captured connection traffic divided by interface traffic",
),
(
" Retained ",
"Retained",
"Active traffic plus up to 5,000 recently closed connections",
),
(
" Process attribution ",
"Process attribution",
"Traffic mapped to a PID or process name; unresolved bytes are Unknown",
),
(
" Top remote peer ",
"Top remote peer",
"Highest-volume remote endpoint for the selected direction",
),
];
const MOUSE_CONTROLS: &[(&str, &str)] = &[
(" Click tab ", "Switch between tabs"),
(" Click row ", "Select connection"),
("Click tab", "Switch between tabs"),
("Click row", "Select connection"),
(
" Scroll wheel ",
"Scroll wheel",
"Navigate connection list / scroll Details, Activity interfaces, Help",
),
(" Double-click row ", "Open connection details"),
(" Double-click group ", "Expand/collapse process group"),
(" Click field (Details) ", "Copy field value to clipboard"),
("Double-click row", "Open connection details"),
("Double-click group", "Expand/collapse process group"),
("Click field (Details)", "Copy field value to clipboard"),
];
const FILTER_EXAMPLES: &[(&str, &str)] = &[
(" /google ", "Search for 'google' in all fields"),
("/google", "Search for 'google' in all fields"),
(
" /port:22 ",
"/port:22",
"Exact port match (only port 22, not 2223 or 5522)",
),
(" /port:/22/ ", "Regex port match (22, 220, 5522, etc.)"),
(" /src:192.168 ", "Filter by source IP prefix"),
(" /dst:github.com ", "Filter by destination"),
("/port:/22/", "Regex port match (22, 220, 5522, etc.)"),
("/src:192.168", "Filter by source IP prefix"),
("/dst:github.com", "Filter by destination"),
(
" /sni:/.*github.*/ ",
"/sni:/.*github.*/",
"Regex SNI match (wrap value in /…/ for regex)",
),
(" /process:firefox ", "Filter by process name"),
("/process:firefox", "Filter by process name"),
];
/// A key/description help row: the key span styled, the description raw.
fn kv_line(key: &'static str, description: &'static str, key_style: Style) -> Line<'static> {
Line::from(vec![Span::styled(key, key_style), Span::raw(description)])
/// Left indent for rows under a section tick line.
const ROW_INDENT: &str = " ";
/// Gap between the padded key column and the description column.
const COLUMN_GAP: &str = " ";
/// Widest key of a section in character cells (every glyph used in the
/// help keys is single width, so `chars().count()` is the cell width).
fn key_column_width(rows: &[(&str, &str)]) -> usize {
rows.iter()
.map(|(key, _)| key.chars().count())
.max()
.unwrap_or(0)
}
/// A bold accent section title ("Tabs:", "Mouse Controls:", ...).
fn section_title(title: &'static str) -> Line<'static> {
Line::from(vec![Span::styled(title, theme::bold_fg(theme::accent()))])
/// Section title line matching the `section_header` chrome used by the
/// other tabs: accent `▎` tick plus a bold title. Built as a paragraph
/// line instead of calling `section_header` because the whole Help page
/// scrolls as one paragraph, so the headers must scroll with it.
fn tick_line(title: &'static str) -> Line<'static> {
Line::from(vec![
Span::styled("", theme::fg(theme::accent())),
Span::styled(
format!(" {title}"),
Style::default().add_modifier(Modifier::BOLD),
),
])
}
/// One aligned two-column row: the key padded to the section's key
/// column width in the given style, the description in the hint label
/// style.
fn column_row(
key: &str,
key_style: Style,
description: &'static str,
width: usize,
) -> Line<'static> {
Line::from(vec![
Span::raw(ROW_INDENT),
Span::styled(format!("{key:<width$}"), key_style),
Span::raw(COLUMN_GAP),
Span::styled(description, theme::key_hint_label()),
])
}
/// A whole key/description section: blank separator, tick title, then
/// one aligned row per entry with the keys in the keycap style.
fn push_section(
out: &mut Vec<Line<'static>>,
title: &'static str,
rows: &'static [(&'static str, &'static str)],
) {
out.push(Line::from(""));
out.push(tick_line(title));
let width = key_column_width(rows);
out.extend(
rows.iter()
.map(|&(key, desc)| column_row(key, theme::key_hint(), desc, width)),
);
}
/// Visible-window line indices whose style marks a scroll boundary: the
/// top line when the page is scrolled past its start, the bottom line
/// when content continues below. Both are `None` on a page that fits.
fn fade_targets(scroll: u16, height: u16, max_scroll: u16) -> [Option<usize>; 2] {
let top = (scroll > 0).then_some(scroll as usize);
let bottom = (scroll < max_scroll && height > 0).then(|| scroll as usize + height as usize - 1);
[top, bottom]
}
pub(in crate::ui) fn draw_help(f: &mut Frame, ui_state: &UIState, area: Rect) -> Result<()> {
let key_style = theme::fg(theme::key());
let example_style = theme::fg(theme::ok());
let mut help_text: Vec<Line> = vec![Line::from(vec![
Span::styled("RustNet Monitor ", theme::bold_fg(theme::ok())),
Span::raw("- Network Connection Monitor"),
])];
let mut help_text: Vec<Line> = vec![
Line::from(vec![
Span::styled("RustNet Monitor ", theme::bold_fg(theme::ok())),
Span::raw("- Network Connection Monitor"),
]),
Line::from(""),
];
help_text.extend(
KEY_BINDINGS
.iter()
.map(|&(key, desc)| kv_line(key, desc, key_style)),
);
push_section(&mut help_text, "Key Bindings", KEY_BINDINGS);
push_section(&mut help_text, "Tabs", TAB_SUMMARIES);
push_section(&mut help_text, "Activity Concepts", ACTIVITY_CONCEPTS);
push_section(&mut help_text, "Mouse Controls", MOUSE_CONTROLS);
// Connection colors: the keys are color swatches, so each keeps its
// demo style instead of the keycap style, aligned to the same
// two-column grid as every other section.
help_text.push(Line::from(""));
help_text.push(section_title("Tabs:"));
help_text.extend(
TAB_SUMMARIES
.iter()
.map(|&(key, desc)| kv_line(key, desc, example_style)),
);
help_text.push(Line::from(""));
help_text.push(section_title("Activity concepts:"));
help_text.extend(
ACTIVITY_CONCEPTS
.iter()
.map(|&(key, desc)| kv_line(key, desc, key_style)),
);
help_text.push(Line::from(""));
help_text.push(section_title("Mouse Controls:"));
help_text.extend(
MOUSE_CONTROLS
.iter()
.map(|&(key, desc)| kv_line(key, desc, key_style)),
);
help_text.push(Line::from(""));
help_text.push(section_title("Connection Colors:"));
help_text.push(kv_line(
" White ",
"Active connection (< 75% of timeout)",
help_text.push(tick_line("Connection Colors"));
let gradient_key = "Yellow → Orange → Red";
let color_width = ["White", gradient_key, "Gray"]
.iter()
.map(|key| key.chars().count())
.max()
.unwrap_or(0);
help_text.push(column_row(
"White",
Style::default(),
"Active connection (< 75% of timeout)",
color_width,
));
// The expiry gradient names three ramp stops, so it stays a literal.
// The expiry gradient names three ramp stops, so its key column is
// assembled span by span and padded by hand.
let gradient_pad = color_width.saturating_sub(gradient_key.chars().count());
help_text.push(Line::from(vec![
Span::styled(" Yellow", theme::fg(theme::expiry_glow(0.0))),
Span::raw(ROW_INDENT),
Span::styled("Yellow", theme::fg(theme::expiry_glow(0.0))),
Span::styled("", theme::fg(theme::muted())),
Span::styled("Orange", theme::fg(theme::expiry_glow(0.5))),
Span::styled("", theme::fg(theme::muted())),
Span::styled("Red ", theme::fg(theme::expiry_glow(1.0))),
Span::raw("Connection nearing timeout (75-100%; holds yellow to 90%, then intensifies)"),
Span::styled("Red", theme::fg(theme::expiry_glow(1.0))),
Span::raw(format!("{}{}", " ".repeat(gradient_pad), COLUMN_GAP)),
Span::styled(
"Connection nearing timeout (75-100%; holds yellow to 90%, then intensifies)",
theme::key_hint_label(),
),
]));
help_text.push(kv_line(
" Gray ",
"Historic (closed) connection",
help_text.push(column_row(
"Gray",
theme::historic_row(),
"Historic (closed) connection",
color_width,
));
help_text.push(Line::from(""));
help_text.push(section_title("Hostname Display:"));
help_text.push(Line::from(
help_text.push(tick_line("Hostname Display"));
help_text.push(Line::from(Span::styled(
" Names in the Remote column come from a recently observed DNS",
));
help_text.push(Line::from(
theme::key_hint_label(),
)));
help_text.push(Line::from(Span::styled(
" resolution (shown as ~name, dimmed) or reverse DNS; SNI and",
theme::key_hint_label(),
)));
help_text.push(Line::from(Span::styled(
" HTTP Host appear in the App column.",
theme::key_hint_label(),
)));
help_text.push(column_row(
"~name",
theme::fg(theme::field_attributed_hostname()),
"Hostname inferred from a DNS response, not extracted from the connection itself",
"~name".chars().count(),
));
help_text.push(Line::from(" HTTP Host appear in the App column."));
help_text.push(Line::from(vec![
Span::styled(" ~name ", theme::fg(theme::field_attributed_hostname())),
Span::raw("Hostname inferred from a DNS response, not extracted"),
]));
help_text.push(Line::from(" from the connection itself"));
help_text.push(Line::from(""));
help_text.push(section_title("Filter Examples:"));
help_text.extend(
FILTER_EXAMPLES
.iter()
.map(|&(key, desc)| kv_line(key, desc, example_style)),
);
push_section(&mut help_text, "Filter Examples", FILTER_EXAMPLES);
help_text.push(Line::from(""));
// Scroll against the unwrapped line count. A handful of lines can
@@ -268,43 +314,72 @@ pub(in crate::ui) fn draw_help(f: &mut Frame, ui_state: &UIState, area: Rect) ->
// larger, but staying off the unstable rendered-line-info APIs is
// worth the last row or two of scroll range.
let total_lines = help_text.len();
let inner_height = area.height.saturating_sub(2); // panel borders
let inner_height = area.height;
let max_scroll = (total_lines as u16).saturating_sub(inner_height);
let scroll = ui_state.help_scroll.clamp_for_render(max_scroll);
let title = if max_scroll > 0 {
"Help · ↑/↓ scroll"
} else {
"Help"
};
// Right padding keeps the text clear of the two rightmost inner
// columns: a blank gap and the scrollbar, same arrangement as the
// Overview table.
// The old panel border carried the scroll hint in its title; with
// the border gone it rides the intro line instead.
if max_scroll > 0 {
help_text[0]
.spans
.push(Span::styled(" · ↑/↓ scroll", theme::fg(theme::muted())));
}
// Mark the cut edges of the visible window. Wrapping shifts the true
// window on narrow terminals, the same approximation the scroll range
// accepts above, which is good enough for a color-only cue.
for index in fade_targets(scroll, inner_height, max_scroll)
.into_iter()
.flatten()
{
if let Some(line) = help_text.get_mut(index) {
fade_line(line);
}
}
// Right padding keeps the text clear of the two rightmost columns:
// a blank gap and the scrollbar, same arrangement as the Overview
// table. `trim: false` preserves the row indent and the padded key
// columns that align the descriptions.
let help = Paragraph::new(help_text)
.block(panel_block(title).padding(Padding::right(2)))
.block(Block::default().padding(Padding::right(2)))
.style(Style::default())
.wrap(Wrap { trim: true })
.wrap(Wrap { trim: false })
.scroll((scroll, 0))
.alignment(ratatui::layout::Alignment::Left);
f.render_widget(help, area);
// Scrollbar one column inside the panel border so the border line
// stays intact, inset one row top and bottom to clear the title
// row and rounded corners.
let track = Rect::new(
area.x,
area.y + 1,
area.width.saturating_sub(1),
area.height.saturating_sub(2),
);
draw_scrollbar(
f,
track,
total_lines,
scroll as usize,
inner_height as usize,
);
draw_scrollbar(f, area, total_lines, scroll as usize, inner_height as usize);
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_page_that_fits_has_no_faded_edges() {
assert_eq!(fade_targets(0, 40, 0), [None, None]);
}
#[test]
fn scrolling_fades_the_edges_that_hide_content() {
// At the top only the bottom edge continues.
assert_eq!(fade_targets(0, 10, 5), [None, Some(9)]);
// Mid-page both edges do.
assert_eq!(fade_targets(3, 10, 5), [Some(3), Some(12)]);
// At the end only the top edge does.
assert_eq!(fade_targets(5, 10, 5), [Some(5), None]);
}
#[test]
fn faded_lines_restyle_every_span() {
let mut line = Line::from(vec![Span::raw("a"), Span::raw("b")]);
fade_line(&mut line);
let expected = theme::edge_fade(Style::default());
assert!(line.spans.iter().all(|span| span.style == expected));
}
}
+42 -15
View File
@@ -25,11 +25,11 @@ use crate::ui::{
Column, ColumnId, RowWindow, bandwidth_cell, build_header, column_constraints,
connection_row, render_row_table, select_columns, visible_window,
},
format::format_bytes,
format::{format_bytes, truncate_with_ellipsis},
section_header,
state::ProcessGroupStats,
theme, try_handle_connection_nav,
widgets::braille_graph,
widgets::{badge, braille_graph},
};
/// Overview tab — connection list + stats sidebar. Reads every
@@ -464,9 +464,14 @@ fn draw_connections_list(
let widths = column_constraints(&columns);
let header = build_header(&columns, ui_state);
let selected = ui_state.get_selected_index(connections);
let rows: Vec<Row> = visible_connections
.iter()
.map(|conn| connection_row(conn, &columns, ui_state, dns_resolver, None))
.enumerate()
.map(|(i, conn)| {
let is_selected = selected == Some(scroll_offset + i);
connection_row(conn, &columns, ui_state, dns_resolver, None, is_selected)
})
.collect();
render_row_table(
@@ -476,7 +481,7 @@ fn draw_connections_list(
rows,
&widths,
RowWindow {
selected: ui_state.get_selected_index(connections),
selected,
scroll_offset,
total_rows: connections.len(),
visible_rows,
@@ -485,6 +490,10 @@ fn draw_connections_list(
);
}
/// Longest filter query shown in the title chip; longer queries are cut
/// with an ellipsis so the chip cannot crowd out the title itself.
const FILTER_CHIP_MAX: usize = 20;
/// Shared section title for the flat and grouped connection tables. The
/// visual grammar stays consistent while aggregate mode names its view.
fn connections_title<'a>(
@@ -508,6 +517,13 @@ fn connections_title<'a>(
format!(" · {shown} {counter}"),
theme::fg(theme::muted()),
));
// The query itself rides along as a chip, so what is being
// filtered on stays visible without reopening filter mode.
let query = ui_state.filter_query.trim();
if !query.is_empty() {
spans.push(Span::raw(" "));
spans.extend(badge::chip(&truncate_with_ellipsis(query, FILTER_CHIP_MAX)));
}
}
if ui_state.sort_column != SortColumn::CreatedAt {
@@ -560,9 +576,11 @@ fn draw_grouped_connections_list(
let widths = column_constraints(&columns);
let header = build_header(&columns, ui_state);
let selected = ui_state.get_selected_grouped_index(grouped_rows);
let rows: Vec<Row> = visible_grouped
.iter()
.map(|row| match row {
.enumerate()
.map(|(i, row)| match row {
GroupedRow::Group {
process_name,
stats,
@@ -594,6 +612,7 @@ fn draw_grouped_connections_list(
ui_state,
dns_resolver,
Some(process_cell),
selected == Some(scroll_offset + i),
)
}
})
@@ -606,7 +625,7 @@ fn draw_grouped_connections_list(
rows,
&widths,
RowWindow {
selected: ui_state.get_selected_grouped_index(grouped_rows),
selected,
scroll_offset,
total_rows: grouped_rows.len(),
visible_rows,
@@ -641,9 +660,7 @@ fn group_header_row<'a>(
),
Span::styled(
stats.historic_count.to_string(),
Style::default()
.fg(Color::DarkGray)
.add_modifier(Modifier::DIM | Modifier::BOLD),
theme::fg(theme::faint()).add_modifier(Modifier::DIM | Modifier::BOLD),
),
Span::styled(")".to_string(), group_style),
])
@@ -675,15 +692,14 @@ fn group_header_row<'a>(
}
/// Draw stats panel
/// Render a single-row horizontal rule between sections. Uses the default
/// terminal foreground so it matches the surrounding `Block` borders rather
/// than rendering muted gray.
/// Render a single-row horizontal rule between sections, styled with the
/// theme border color so it matches every other rule in the chrome.
fn render_section_separator(f: &mut Frame, area: Rect) {
if area.width == 0 || area.height == 0 {
return;
}
let rule: String = "".repeat(area.width as usize);
let para = Paragraph::new(Line::from(rule));
let para = Paragraph::new(Line::from(rule)).style(theme::fg(theme::border()));
f.render_widget(para, area);
}
@@ -1561,13 +1577,24 @@ mod tests {
filter_query: "port:443".to_string(),
..Default::default()
};
// The default (muted) theme has no selection tint, so the chip
// renders in its bracket form.
assert_eq!(
title_text(connections_title(&filtered, false, Some(7))),
" Live Connections · 7 shown"
" Live Connections · 7 shown [port:443]"
);
assert_eq!(
title_text(connections_title(&filtered, true, Some(3))),
" Process Aggregate · 3 processes"
" Process Aggregate · 3 processes [port:443]"
);
let long = UIState {
filter_query: "process:some-very-long-daemon-name".to_string(),
..Default::default()
};
assert_eq!(
title_text(connections_title(&long, false, Some(1))),
" Live Connections · 1 shown [process:some-very-l…]"
);
let whitespace = UIState {
-419
View File
@@ -1,419 +0,0 @@
//! Centralized color palette for cross-terminal consistency.
//! All semantic colors derive from these 7 base constants.
//!
//! Two presets share this module: the default `Muted` preset keeps one
//! accent color (cyan) and reserves the rest of the palette for semantic
//! signals (state health, staleness, traffic activity), while `Classic`
//! restores the original per-field rainbow. Every alias below branches on
//! the active preset so call sites stay preset-agnostic.
use std::sync::atomic::{AtomicBool, Ordering};
use ratatui::style::{Color, Modifier, Style};
// --- 7-slot base palette ---
const OK: Color = Color::Green; // Healthy/success
const WARN: Color = Color::Yellow; // Caution/attention
const ERR: Color = Color::Red; // Error/critical
const ACCENT: Color = Color::Cyan; // Informational highlight
const MUTED: Color = Color::Gray; // Secondary/inactive
const INFO: Color = Color::Blue; // Neutral info
const SPECIAL: Color = Color::Magenta; // Distinct/special
// --- Theme presets ---
/// Selectable palette presets (`--theme` CLI flag).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ThemePreset {
/// Restrained default: one cyan accent, color only for semantic signals.
Muted,
/// The original full-color palette with per-field colors.
Classic,
}
/// Stored as a bool ("is classic") so reads stay a single relaxed atomic
/// load, mirroring the NO_COLOR flag in `ui::mod`.
static CLASSIC: AtomicBool = AtomicBool::new(false);
/// Select the active palette preset. Called once at startup.
pub fn set_preset(preset: ThemePreset) {
CLASSIC.store(preset == ThemePreset::Classic, Ordering::Relaxed);
}
/// Whether the Classic (full-color) preset is active.
pub(super) fn is_classic() -> bool {
CLASSIC.load(Ordering::Relaxed)
}
// --- Base color accessors ---
pub(super) fn ok() -> Color {
OK
}
pub(super) fn warn() -> Color {
WARN
}
pub(super) fn err() -> Color {
ERR
}
pub(super) fn accent() -> Color {
ACCENT
}
pub(super) fn muted() -> Color {
MUTED
}
pub(super) fn info() -> Color {
INFO
}
pub(super) fn special() -> Color {
SPECIAL
}
// --- UI element aliases ---
//
// Three-tier hierarchy so the showcase can pick out a clear winner:
// * primary() — what the user is acting on right now (active tab,
// selected row's focus column, sorted column header)
// * heading() — structural anchors (table column headers, section titles)
// * label() — supporting context (field labels, units, separators)
//
// `primary()` returns a full Style because it always pairs with BOLD;
// the others return raw Colors so callers can compose with `fg()` /
// `bold_fg()` as needed.
pub(super) fn primary() -> Style {
bold_fg(accent())
}
pub(super) fn label() -> Color {
muted()
}
pub(super) fn heading() -> Color {
if is_classic() { warn() } else { muted() }
}
pub(super) fn key() -> Color {
if is_classic() { warn() } else { accent() }
}
// --- Network aliases ---
pub(super) fn rx() -> Color {
ok()
}
pub(super) fn tx() -> Color {
info()
}
// --- Traffic wave gradients (Graph tab) ---
//
// Truecolor 5-stop ramps for the braille traffic waves: saturated color at
// the base, brighter neighboring hues through the body, and a white-hot crest
// for the terminal-glow effect used by github.com/programmersd21/flow. RX uses
// Flow's cool download ramp and TX uses its green upload ramp. Callers must
// wrap the result in `fg()` so NO_COLOR still strips these.
const RX_WAVE_STOPS: [(u8, u8, u8); 5] = [
(0x3B, 0x82, 0xF6), // vibrant blue
(0x63, 0x66, 0xF1),
(0x06, 0xB6, 0xD4),
(0x00, 0xF5, 0xD4),
(0xFF, 0xFF, 0xFF), // white-hot crest
];
const TX_WAVE_STOPS: [(u8, u8, u8); 5] = [
(0x10, 0xB9, 0x81), // emerald green
(0x22, 0xC5, 0x5E),
(0x84, 0xCC, 0x16),
(0xA3, 0xE6, 0x35),
(0xFF, 0xFF, 0xFF), // white-hot crest
];
const ACCENT_WAVE_STOPS: [(u8, u8, u8); 5] = [
(0x08, 0x91, 0xB2), // saturated cyan
(0x06, 0xB6, 0xD4),
(0x22, 0xD3, 0xEE),
(0x67, 0xE8, 0xF9),
(0xFF, 0xFF, 0xFF), // white-hot crest
];
const WARN_WAVE_STOPS: [(u8, u8, u8); 5] = [
(0x92, 0x40, 0x0E), // deep amber
(0xB4, 0x53, 0x09),
(0xD9, 0x77, 0x06),
(0xF5, 0x9E, 0x0B),
(0xFB, 0xBF, 0x24), // bright amber
];
const ERR_WAVE_STOPS: [(u8, u8, u8); 5] = [
(0x99, 0x1B, 0x1B), // deep red
(0xB9, 0x1C, 0x1C),
(0xDC, 0x26, 0x26),
(0xEF, 0x44, 0x44),
(0xF8, 0x71, 0x71), // bright red
];
const SPECIAL_WAVE_STOPS: [(u8, u8, u8); 5] = [
(0x86, 0x19, 0x8F), // deep fuchsia
(0xA2, 0x1C, 0xAF),
(0xC0, 0x26, 0xD3),
(0xD9, 0x46, 0xEF),
(0xE8, 0x79, 0xF9), // bright fuchsia
];
const MUTED_WAVE_STOPS: [(u8, u8, u8); 5] = [
(0x37, 0x41, 0x51), // deep gray
(0x4B, 0x55, 0x63),
(0x6B, 0x72, 0x80),
(0x84, 0x8D, 0x9C),
(0x9C, 0xA3, 0xAF), // light gray
];
const EXPIRY_GLOW_STOPS: [(u8, u8, u8); 5] = [
(0xFA, 0xCC, 0x15), // bright yellow warning
(0xFB, 0xBF, 0x24),
(0xFB, 0x92, 0x3C),
(0xF8, 0x71, 0x71),
(0xFF, 0x2D, 0x55), // vivid red at removal
];
const EXPIRY_WARNING_START: f32 = 0.75;
const EXPIRY_CRITICAL_START: f32 = 0.90;
fn lerp_channel(a: u8, b: u8, t: f64) -> u8 {
(a as f64 + (b as f64 - a as f64) * t).round() as u8
}
/// Walk a 5-stop color ramp at `t` ∈ [0, 1] (4 linear segments).
fn five_stop(stops: &[(u8, u8, u8); 5], t: f64) -> Color {
let seg = t.clamp(0.0, 1.0) * 4.0;
let i = (seg as usize).min(3);
let local = seg - i as f64;
let (a, b) = (stops[i], stops[i + 1]);
Color::Rgb(
lerp_channel(a.0, b.0, local),
lerp_channel(a.1, b.1, local),
lerp_channel(a.2, b.2, local),
)
}
/// RX wave gradient color at intensity `t` (0 = dim base, 1 = crest).
pub(super) fn rx_wave(t: f64) -> Color {
five_stop(&RX_WAVE_STOPS, t)
}
/// TX wave gradient color at intensity `t` (0 = dim base, 1 = crest).
pub(super) fn tx_wave(t: f64) -> Color {
five_stop(&TX_WAVE_STOPS, t)
}
/// Accent (cyan) wave gradient for non-directional graphs like the
/// connection count, at intensity `t` (0 = dim base, 1 = crest).
pub(super) fn accent_wave(t: f64) -> Color {
five_stop(&ACCENT_WAVE_STOPS, t)
}
/// Green gradient for healthy/success bars (same ramp as RX).
pub(super) fn ok_wave(t: f64) -> Color {
five_stop(&RX_WAVE_STOPS, t)
}
/// Amber gradient for caution bars.
pub(super) fn warn_wave(t: f64) -> Color {
five_stop(&WARN_WAVE_STOPS, t)
}
/// Red gradient for critical bars.
pub(super) fn err_wave(t: f64) -> Color {
five_stop(&ERR_WAVE_STOPS, t)
}
/// Fuchsia gradient for special/distinct bars (DNS).
pub(super) fn special_wave(t: f64) -> Color {
five_stop(&SPECIAL_WAVE_STOPS, t)
}
/// Gray gradient for secondary/inactive bars.
pub(super) fn muted_wave(t: f64) -> Color {
five_stop(&MUTED_WAVE_STOPS, t)
}
/// Yellow-to-red glow for connections nearing their removal timeout.
pub(super) fn expiry_glow(t: f64) -> Color {
five_stop(&EXPIRY_GLOW_STOPS, t)
}
/// Map connection staleness to the expiry glow. The row turns yellow at 75%
/// of its timeout, stays yellow through the warning window, then intensifies
/// toward red during the final 10% before removal.
pub(super) fn expiry_glow_intensity(staleness: f32) -> Option<f64> {
(staleness >= EXPIRY_WARNING_START).then(|| {
f64::from(
((staleness - EXPIRY_CRITICAL_START) / (1.0 - EXPIRY_CRITICAL_START)).clamp(0.0, 1.0),
)
})
}
// --- Protocol aliases ---
pub(super) fn proto_https() -> Color {
ok()
}
pub(super) fn proto_quic() -> Color {
accent()
}
pub(super) fn proto_http() -> Color {
warn()
}
pub(super) fn proto_dns() -> Color {
special()
}
pub(super) fn proto_ssh() -> Color {
info()
}
pub(super) fn proto_other() -> Color {
muted()
}
// --- TCP state aliases ---
// Muted preset: ESTABLISHED is the common case and reads as plain text;
// only transitional states (a genuine signal) keep an attention color.
pub(super) fn tcp_established() -> Color {
if is_classic() { ok() } else { Color::Reset }
}
pub(super) fn tcp_opening() -> Color {
warn()
}
pub(super) fn tcp_closing() -> Color {
if is_classic() { accent() } else { muted() }
}
pub(super) fn tcp_waiting() -> Color {
if is_classic() { special() } else { muted() }
}
pub(super) fn tcp_closed() -> Color {
muted()
}
// --- Field-level aliases (same color used everywhere a field appears) ---
// Muted preset: addresses keep a calm color (they're the data being
// monitored), the other identifying fields render in the terminal's
// default foreground (`Color::Reset`), supporting context fades to
// gray. Same address colors in both presets.
pub(super) fn field_local_addr() -> Color {
accent()
}
pub(super) fn field_remote_addr() -> Color {
info()
}
pub(super) fn field_state() -> Color {
if is_classic() { ok() } else { Color::Reset }
}
pub(super) fn field_service() -> Color {
if is_classic() { warn() } else { muted() }
}
pub(super) fn field_location() -> Color {
if is_classic() { special() } else { muted() }
}
pub(super) fn field_process() -> Color {
if is_classic() { ok() } else { Color::Reset }
}
pub(super) fn field_application() -> Color {
if is_classic() { warn() } else { muted() }
}
/// Color for hostnames inferred from a recently observed DNS resolution
/// (shown with a `~` prefix). Dimmer than `field_remote_addr` so the
/// inference is visually distinct from authoritative SNI / Host data.
pub(super) fn field_attributed_hostname() -> Color {
muted()
}
// --- Historic (closed) connection rows ---
// Whole-row override; per-cell colors are dropped so the uniform gray
// carries the signal. DarkGray reads as muted-but-present on both
// light and dark backgrounds. DIM is deliberately NOT used when colors
// are available: terminals disagree wildly on it (invisible on light
// themes, barely-there in WezTerm dark). Under NO_COLOR it returns as
// the only row-level cue, alongside the "closed" state text.
pub(super) fn historic_row() -> Style {
if super::NO_COLOR.load(super::Ordering::Relaxed) {
Style::default().add_modifier(Modifier::DIM)
} else {
Style::default().fg(Color::DarkGray)
}
}
// --- Panel border ---
pub(super) fn border() -> Color {
if is_classic() {
special()
} else {
Color::DarkGray
}
}
// --- Status bar styles ---
// Uses REVERSED modifier instead of fg(Black).bg(Color) which breaks on dark terminals
pub(super) fn status_bar_confirm() -> Style {
if super::NO_COLOR.load(super::Ordering::Relaxed) {
return Style::default().add_modifier(Modifier::REVERSED);
}
Style::default()
.fg(warn())
.add_modifier(Modifier::BOLD | Modifier::REVERSED)
}
pub(super) fn status_bar_success() -> Style {
if super::NO_COLOR.load(super::Ordering::Relaxed) {
return Style::default().add_modifier(Modifier::REVERSED);
}
Style::default()
.fg(ok())
.add_modifier(Modifier::BOLD | Modifier::REVERSED)
}
pub(super) fn status_bar_error() -> Style {
if super::NO_COLOR.load(super::Ordering::Relaxed) {
return Style::default().add_modifier(Modifier::BOLD | Modifier::REVERSED);
}
Style::default()
.fg(err())
.add_modifier(Modifier::BOLD | Modifier::REVERSED)
}
pub(super) fn status_bar_default() -> Style {
if super::NO_COLOR.load(super::Ordering::Relaxed) || !is_classic() {
return Style::default().add_modifier(Modifier::REVERSED);
}
Style::default().fg(info()).add_modifier(Modifier::REVERSED)
}
pub(super) fn row_highlight() -> Style {
// No fg override: the highlight inherits the row's existing fg, so
// when REVERSED swaps fg ↔ bg, a red staleness row gets a red
// selection bar, a yellow row gets a yellow bar, and a default row
// gets a default-fg bar. The staleness signal survives the
// selection highlight.
Style::default().add_modifier(Modifier::BOLD | Modifier::REVERSED)
}
// --- Style builders (NO_COLOR-aware) ---
/// Apply a foreground color, respecting NO_COLOR.
pub(super) fn fg(color: Color) -> Style {
if super::NO_COLOR.load(super::Ordering::Relaxed) {
Style::default()
} else {
Style::default().fg(color)
}
}
/// Apply a foreground color with BOLD, respecting NO_COLOR.
pub(super) fn bold_fg(color: Color) -> Style {
if super::NO_COLOR.load(super::Ordering::Relaxed) {
Style::default().add_modifier(Modifier::BOLD)
} else {
Style::default().fg(color).add_modifier(Modifier::BOLD)
}
}
/// Apply a foreground color with BOLD + UNDERLINED, respecting NO_COLOR.
pub(super) fn bold_underline_fg(color: Color) -> Style {
if super::NO_COLOR.load(super::Ordering::Relaxed) {
Style::default().add_modifier(Modifier::BOLD | Modifier::UNDERLINED)
} else {
Style::default()
.fg(color)
.add_modifier(Modifier::BOLD | Modifier::UNDERLINED)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn primary_wave_ramps_match_flow_direction_colors() {
let white = Color::Rgb(0xFF, 0xFF, 0xFF);
assert_eq!(rx_wave(0.0), Color::Rgb(0x3B, 0x82, 0xF6));
assert_eq!(tx_wave(0.0), Color::Rgb(0x10, 0xB9, 0x81));
assert_eq!(rx_wave(1.0), white);
assert_eq!(tx_wave(1.0), white);
assert_eq!(accent_wave(1.0), white);
}
}
+220
View File
@@ -0,0 +1,220 @@
//! Terminal background lightness detection (OSC 11).
//!
//! Asks the terminal for its background color with the xterm `OSC 11 ; ?`
//! query before the TUI takes over, so ANSI presets can darken their gray
//! text tiers on light backgrounds (ANSI Gray, color 7, is nearly
//! invisible on white). Best effort by design: no controlling tty, a
//! terminal that stays silent past the timeout, or a non-Unix platform all
//! yield `None` and the theme stays exactly as authored. Deliberately
//! hand-rolled for the simple case only; multiplexer passthrough and other
//! quirks are out of scope.
#[cfg(unix)]
mod unix {
use std::fs::File;
use std::io::{Read, Write};
use std::os::unix::io::AsRawFd;
use std::time::{Duration, Instant};
/// OSC 11 query, ST-terminated. Terminals reply with the same framing:
/// `ESC ] 11 ; rgb:RRRR/GGGG/BBBB` plus BEL or ST.
const QUERY: &[u8] = b"\x1b]11;?\x1b\\";
/// Total budget for the reply. Supporting terminals answer within a few
/// milliseconds; this bounds the startup cost on ones that never do.
const TIMEOUT: Duration = Duration::from_millis(150);
/// Whether the terminal reports a light background, `None` when it
/// cannot be determined (no tty, no reply, unparseable reply).
pub fn detect_light_background() -> Option<bool> {
let mut tty = File::options()
.read(true)
.write(true)
.open("/dev/tty")
.ok()?;
let fd = tty.as_raw_fd();
let saved = enter_quiet_read(fd)?;
let rgb = query_background(&mut tty, fd);
// Discard whatever is still queued on the tty: a reply landing
// after the deadline, or the trailing `\` of an ST terminator
// read in a later chunk, would otherwise reach the TUI as
// phantom keystrokes. Best effort like the rest; a reply still
// in flight can arrive after the flush.
unsafe { libc::tcflush(fd, libc::TCIFLUSH) };
// Best effort: the settings were valid a moment ago.
unsafe { libc::tcsetattr(fd, libc::TCSANOW, &saved) };
rgb.map(super::is_light)
}
/// Disable canonical mode and echo on `fd` so the reply can be read
/// byte-by-byte without echoing to the screen. Returns the original
/// settings for the caller to restore.
fn enter_quiet_read(fd: i32) -> Option<libc::termios> {
// SAFETY: termios is plain old data and tcgetattr fills it in
// fully on success.
let mut termios = unsafe { std::mem::zeroed::<libc::termios>() };
if unsafe { libc::tcgetattr(fd, &mut termios) } != 0 {
return None;
}
let saved = termios;
termios.c_lflag &= !(libc::ICANON | libc::ECHO);
termios.c_cc[libc::VMIN] = 0;
termios.c_cc[libc::VTIME] = 0;
if unsafe { libc::tcsetattr(fd, libc::TCSANOW, &termios) } != 0 {
return None;
}
Some(saved)
}
/// Write the query and wait for the reply until [`TIMEOUT`], tolerating
/// unrelated bytes (early keypresses) around the OSC response.
/// `select` rather than `poll`: macOS `poll` reports POLLNVAL for the
/// `/dev/tty` character device.
fn query_background(tty: &mut File, fd: i32) -> Option<(u8, u8, u8)> {
tty.write_all(QUERY).ok()?;
let deadline = Instant::now() + TIMEOUT;
let mut buf = Vec::new();
loop {
let remaining = deadline.checked_duration_since(Instant::now())?;
// SAFETY: fd_set is plain old data; FD_ZERO/FD_SET initialize
// it, and fd is a live descriptor below FD_SETSIZE.
let ready = unsafe {
let mut readfds = std::mem::zeroed::<libc::fd_set>();
libc::FD_ZERO(&mut readfds);
libc::FD_SET(fd, &mut readfds);
let mut timeout = libc::timeval {
tv_sec: remaining.as_secs() as _,
tv_usec: remaining.subsec_micros() as _,
};
libc::select(
fd + 1,
&mut readfds,
std::ptr::null_mut(),
std::ptr::null_mut(),
&mut timeout,
)
};
if ready <= 0 {
return None; // timeout or select error
}
let mut chunk = [0u8; 256];
let read = tty.read(&mut chunk).ok()?;
if read == 0 {
return None;
}
buf.extend_from_slice(&chunk[..read]);
if let Some(rgb) = super::parse_osc11_reply(&buf) {
return Some(rgb);
}
}
}
}
#[cfg(unix)]
pub use unix::detect_light_background;
/// Non-Unix stub: reading the reply needs termios-style tty control, which
/// the Windows console does not offer through this path.
#[cfg(not(unix))]
pub fn detect_light_background() -> Option<bool> {
None
}
/// Whether `rgb` reads as a light background: WCAG relative luminance
/// above 0.5, the midpoint between black (0.0) and white (1.0).
#[cfg_attr(not(unix), allow(dead_code))]
fn is_light((r, g, b): (u8, u8, u8)) -> bool {
super::derive::relative_luminance(r, g, b) > 0.5
}
/// Extract the background RGB from an OSC 11 reply anywhere in `buf`:
/// `ESC ] 11 ; <color>` terminated by BEL or ESC (the start of ST).
/// `None` while the reply is absent or still incomplete.
#[cfg_attr(not(unix), allow(dead_code))]
fn parse_osc11_reply(buf: &[u8]) -> Option<(u8, u8, u8)> {
const PREFIX: &[u8] = b"\x1b]11;";
let start = buf.windows(PREFIX.len()).position(|w| w == PREFIX)? + PREFIX.len();
let payload = &buf[start..];
let end = payload.iter().position(|&b| b == 0x07 || b == 0x1B)?;
parse_x11_color(std::str::from_utf8(&payload[..end]).ok()?)
}
/// Parse an X11 `rgb:` color spec (xterm's reply format), scaling each
/// 1-4 hex digit channel to 8 bits. `rgba:` (KDE Konsole) is accepted
/// with its alpha channel ignored.
#[cfg_attr(not(unix), allow(dead_code))]
fn parse_x11_color(spec: &str) -> Option<(u8, u8, u8)> {
let channels = spec
.strip_prefix("rgb:")
.or_else(|| spec.strip_prefix("rgba:"))?;
let mut parts = channels.split('/');
let mut channel = || {
let digits = parts.next()?;
if digits.is_empty() || digits.len() > 4 {
return None;
}
let value = u32::from_str_radix(digits, 16).ok()?;
let max = (1u32 << (4 * digits.len() as u32)) - 1;
Some(((value * 255 + max / 2) / max) as u8)
};
Some((channel()?, channel()?, channel()?))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn x11_color_scales_any_digit_width_to_8_bits() {
assert_eq!(parse_x11_color("rgb:ffff/ffff/ffff"), Some((255, 255, 255)));
assert_eq!(parse_x11_color("rgb:0000/0000/0000"), Some((0, 0, 0)));
assert_eq!(parse_x11_color("rgb:8000/8000/8000"), Some((128, 128, 128)));
assert_eq!(parse_x11_color("rgb:1a/1b/26"), Some((0x1A, 0x1B, 0x26)));
assert_eq!(parse_x11_color("rgb:f/f/f"), Some((255, 255, 255)));
// Konsole replies rgba:; alpha is ignored.
assert_eq!(
parse_x11_color("rgba:ffff/0000/0000/ffff"),
Some((255, 0, 0))
);
}
#[test]
fn x11_color_rejects_malformed_specs() {
for spec in [
"",
"rgb:",
"rgb:ff/ff",
"rgb:ff//ff",
"rgb:12345/0/0",
"#ffffff",
] {
assert_eq!(parse_x11_color(spec), None, "{spec:?}");
}
}
#[test]
fn osc11_reply_is_found_amid_other_bytes_and_either_terminator() {
// BEL-terminated, with a stray keypress before the reply.
assert_eq!(
parse_osc11_reply(b"q\x1b]11;rgb:fdf6/f6e3/e3d0\x07"),
Some((0xFD, 0xF6, 0xE3))
);
// ST-terminated.
assert_eq!(
parse_osc11_reply(b"\x1b]11;rgb:0000/0000/0000\x1b\\"),
Some((0, 0, 0))
);
// Incomplete reply: keep waiting.
assert_eq!(parse_osc11_reply(b"\x1b]11;rgb:ffff/ff"), None);
assert_eq!(parse_osc11_reply(b"nothing here"), None);
}
#[test]
fn lightness_splits_common_backgrounds() {
assert!(is_light((0xFF, 0xFF, 0xFF)));
assert!(is_light((0xFD, 0xF6, 0xE3))); // solarized light
assert!(!is_light((0x00, 0x00, 0x00)));
assert!(!is_light((0x1A, 0x1B, 0x26))); // tokyo night
assert!(!is_light((0x28, 0x28, 0x28))); // gruvbox dark
}
}
+713
View File
@@ -0,0 +1,713 @@
//! Theme presets and the overridable token layer.
//!
//! A [`ThemeSpec`] holds one [`TokenColor`] per semantic token. Config file
//! overrides mutate the spec via [`ThemeSpec::set_token`]; the spec is then
//! resolved into a final [`super::Theme`] (colors plus gradient ramps) by
//! [`super::Theme::resolve`]. Field, TCP-state, and protocol role colors are
//! mapped from these core tokens during resolve, so they are deliberately
//! not part of the spec: overriding `accent` propagates everywhere.
use ratatui::style::Color;
/// One themable color: an RGB value for truecolor terminals plus an
/// ANSI-16 fallback. `rgb: None` means "always emit `ansi`" (used for
/// `Color::Reset` tokens).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct TokenColor {
pub rgb: Option<(u8, u8, u8)>,
pub ansi: Color,
/// True for user config overrides: resolve honors the value even on
/// presets whose own tokens stay ANSI (muted, vivid), so an explicit
/// override never silently degrades or vanishes.
pub exact: bool,
}
/// Selectable built-in themes (`--theme` CLI flag, `[theme] name` config key).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ThemePreset {
/// Restrained default: one cyan accent, color only for semantic signals.
Muted,
/// Colored chrome on the ANSI-16 palette: yellow headings and keys,
/// magenta borders, where Muted leaves all three gray.
Vivid,
/// Catppuccin Mocha (truecolor).
CatppuccinMocha,
/// Tokyo Night (truecolor).
TokyoNight,
/// Gruvbox dark, bright variants (truecolor).
Gruvbox,
/// Nord (truecolor).
Nord,
}
impl ThemePreset {
pub const ALL: [ThemePreset; 6] = [
ThemePreset::Muted,
ThemePreset::Vivid,
ThemePreset::CatppuccinMocha,
ThemePreset::TokyoNight,
ThemePreset::Gruvbox,
ThemePreset::Nord,
];
/// Exact CLI/config names, no aliases.
pub fn from_name(name: &str) -> Option<ThemePreset> {
Self::ALL.into_iter().find(|p| p.name() == name)
}
pub fn name(self) -> &'static str {
match self {
ThemePreset::Muted => "muted",
ThemePreset::Vivid => "vivid",
ThemePreset::CatppuccinMocha => "catppuccin-mocha",
ThemePreset::TokyoNight => "tokyo-night",
ThemePreset::Gruvbox => "gruvbox",
ThemePreset::Nord => "nord",
}
}
}
/// Overridable token layer of a theme. See the module docs: role colors are
/// derived from these during resolve and are not part of the spec.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ThemeSpec {
/// Whether the chrome itself takes color: yellow headings and keys,
/// magenta borders, plus the per-field palette that goes with them.
pub vivid: bool,
/// When false, built-in tokens always emit their `ansi` color (muted,
/// vivid); `exact` user overrides are still honored.
pub truecolor_tokens: bool,
pub accent: TokenColor,
pub ok: TokenColor,
pub warn: TokenColor,
pub err: TokenColor,
pub info: TokenColor,
pub special: TokenColor,
pub muted: TokenColor,
pub faint: TokenColor,
pub text: TokenColor,
pub heading: TokenColor,
pub label: TokenColor,
pub key: TokenColor,
pub border: TokenColor,
pub rx: TokenColor,
pub tx: TokenColor,
/// Ramp seed override for the RX wave; `None` = use `rx`.
pub rx_wave: Option<TokenColor>,
/// Ramp seed override for the TX wave; `None` = use `tx`.
pub tx_wave: Option<TokenColor>,
/// Selection band background; `None` = REVERSED fallback.
pub selection_bg: Option<TokenColor>,
/// Selection band foreground; `None` = keep the row's own fg.
pub selection_fg: Option<TokenColor>,
/// Status bar background tint; `None` = REVERSED fallback.
pub status_bg: Option<TokenColor>,
/// Hue wheel (degrees) for per-identity tints, indexed by a stable hash
/// of the identity's name (see `super::identity_color`). Not
/// overridable from the config file.
pub identity_hues: &'static [u16],
/// Whether the palette has been adapted to a light terminal background
/// (see [`Self::adapt_to_light_background`]).
pub light_background: bool,
}
/// Default identity hue wheel: 16 well-separated hues walked in a
/// scattered order, so adjacent hash buckets stay visually distinct. Every
/// built-in shares this list; presets may curate their own later.
pub(super) const IDENTITY_HUES: &[u16] = &[
220, 280, 170, 30, 330, 140, 200, 260, 50, 0, 185, 20, 235, 295, 155, 340,
];
/// Valid `set_token` keys, matching the `ThemeSpec` token field names.
const TOKEN_NAMES: [&str; 20] = [
"accent",
"ok",
"warn",
"err",
"info",
"special",
"muted",
"faint",
"text",
"heading",
"label",
"key",
"border",
"rx",
"tx",
"rx_wave",
"tx_wave",
"selection_bg",
"selection_fg",
"status_bg",
];
impl ThemeSpec {
pub fn builtin(preset: ThemePreset) -> ThemeSpec {
match preset {
ThemePreset::Muted => muted_or_vivid(false),
ThemePreset::Vivid => muted_or_vivid(true),
ThemePreset::CatppuccinMocha => catppuccin_mocha(),
ThemePreset::TokyoNight => tokyo_night(),
ThemePreset::Gruvbox => gruvbox(),
ThemePreset::Nord => nord(),
}
}
/// Whether any token carries a user config override (see
/// [`TokenColor::exact`]). Gates the contrast guard, which only has
/// something to say about hand-picked colors.
pub(super) fn has_overrides(&self) -> bool {
let required = [
self.accent,
self.ok,
self.warn,
self.err,
self.info,
self.special,
self.muted,
self.faint,
self.text,
self.heading,
self.label,
self.key,
self.border,
self.rx,
self.tx,
];
let optional = [
self.rx_wave,
self.tx_wave,
self.selection_bg,
self.selection_fg,
self.status_bg,
];
required.iter().any(|t| t.exact) || optional.iter().flatten().any(|t| t.exact)
}
/// Adapt the ANSI palette to a light terminal background: ANSI Gray
/// (color 7) is nearly invisible on white, so every built-in token
/// that would emit it emits DarkGray instead. RGB values are left
/// alone (the truecolor palettes are their authors' choices, and the
/// ramp seeds are already mid-tone), as are `exact` user overrides.
/// The synthesized identity tints do darken: `Theme::resolve` reads
/// the flag this sets and lowers their lightness to clear the same
/// contrast floor on white.
pub fn adapt_to_light_background(&mut self) {
self.light_background = true;
// The optional bg/wave slots are skipped: no built-in puts Gray
// there, and they are not text tiers.
let tokens = [
&mut self.accent,
&mut self.ok,
&mut self.warn,
&mut self.err,
&mut self.info,
&mut self.special,
&mut self.muted,
&mut self.faint,
&mut self.text,
&mut self.heading,
&mut self.label,
&mut self.key,
&mut self.border,
&mut self.rx,
&mut self.tx,
];
for token in tokens {
if !token.exact && token.ansi == Color::Gray {
token.ansi = Color::DarkGray;
}
}
}
/// Apply one config override. `token` is a snake_case key (the field
/// names above); `value` is an ANSI color name or `#rrggbb`. The `Err`
/// string is a human-readable message intended for stderr.
pub fn set_token(&mut self, token: &str, value: &str) -> Result<(), String> {
if !TOKEN_NAMES.contains(&token) {
return Err(format!(
"unknown theme token {token:?}: expected one of {}",
TOKEN_NAMES.join(", ")
));
}
let color = parse_color(value)?;
if color.ansi == Color::Reset
&& matches!(token, "selection_bg" | "selection_fg" | "status_bg")
{
return Err(format!(
"\"reset\" is not a valid value for {token}: use an ANSI color name or #rrggbb"
));
}
match token {
"accent" => self.accent = color,
"ok" => self.ok = color,
"warn" => self.warn = color,
"err" => self.err = color,
"info" => self.info = color,
"special" => self.special = color,
"muted" => self.muted = color,
"faint" => self.faint = color,
"text" => self.text = color,
"heading" => self.heading = color,
"label" => self.label = color,
"key" => self.key = color,
"border" => self.border = color,
"rx" => self.rx = color,
"tx" => self.tx = color,
"rx_wave" => self.rx_wave = Some(color),
"tx_wave" => self.tx_wave = Some(color),
"selection_bg" => self.selection_bg = Some(color),
"selection_fg" => self.selection_fg = Some(color),
"status_bg" => self.status_bg = Some(color),
_ => unreachable!("token validated above"),
}
Ok(())
}
}
/// Whether the terminal advertises truecolor support: COLORTERM contains
/// "truecolor" or "24bit" (case-insensitive).
pub fn detect_truecolor() -> bool {
truecolor_from(
std::env::var("COLORTERM").ok().as_deref(),
std::env::var("TERM").ok().as_deref(),
)
}
/// The decision behind [`detect_truecolor`], separated from the environment
/// so it can be tested without mutating process-wide state.
fn truecolor_from(colorterm: Option<&str>, term: Option<&str>) -> bool {
if colorterm.is_some_and(|v| {
let v = v.to_ascii_lowercase();
v.contains("truecolor") || v.contains("24bit")
}) {
return true;
}
// Direct-color terminfo entries (xterm-direct, tmux-direct, and the rest
// of the `-direct` family) advertise 24-bit color through their own
// capabilities and frequently ship no COLORTERM at all. Without this they
// would be downgraded to ANSI-16 and lose the truecolor presets.
term.is_some_and(|v| v.to_ascii_lowercase().contains("direct"))
}
/// Reference RGB values for the 16 ANSI colors (VGA palette). Used both for
/// name-to-ramp-seed mapping and for nearest-color fallback matching.
const ANSI16: [(Color, (u8, u8, u8)); 16] = [
(Color::Black, (0x00, 0x00, 0x00)),
(Color::Red, (0x80, 0x00, 0x00)),
(Color::Green, (0x00, 0x80, 0x00)),
(Color::Yellow, (0x80, 0x80, 0x00)),
(Color::Blue, (0x00, 0x00, 0x80)),
(Color::Magenta, (0x80, 0x00, 0x80)),
(Color::Cyan, (0x00, 0x80, 0x80)),
(Color::Gray, (0xC0, 0xC0, 0xC0)),
(Color::DarkGray, (0x80, 0x80, 0x80)),
(Color::LightRed, (0xFF, 0x00, 0x00)),
(Color::LightGreen, (0x00, 0xFF, 0x00)),
(Color::LightYellow, (0xFF, 0xFF, 0x00)),
(Color::LightBlue, (0x00, 0x00, 0xFF)),
(Color::LightMagenta, (0xFF, 0x00, 0xFF)),
(Color::LightCyan, (0x00, 0xFF, 0xFF)),
(Color::White, (0xFF, 0xFF, 0xFF)),
];
/// Reference RGB for an ANSI color, `None` for `Color::Reset` (and any
/// non-ANSI variant, which token colors never hold).
pub(super) fn ansi_seed(ansi: Color) -> Option<(u8, u8, u8)> {
ANSI16.iter().find(|(c, _)| *c == ansi).map(|(_, rgb)| *rgb)
}
/// The ANSI-16 color with the minimum squared Euclidean RGB distance to
/// `rgb`; first match wins on ties (in `ANSI16` table order).
pub(super) fn nearest_ansi(rgb: (u8, u8, u8)) -> Color {
fn dist(a: (u8, u8, u8), b: (u8, u8, u8)) -> u32 {
let d = |x: u8, y: u8| {
let d = i32::from(x) - i32::from(y);
(d * d) as u32
};
d(a.0, b.0) + d(a.1, b.1) + d(a.2, b.2)
}
ANSI16
.iter()
.min_by_key(|(_, reference)| dist(rgb, *reference))
.map(|(c, _)| *c)
.expect("ANSI16 is non-empty")
}
/// Parse a config color value: an ANSI color name or `#rrggbb`. The result
/// is `exact`: hex values emit their RGB on truecolor terminals whatever
/// the preset, ANSI names always emit the terminal's palette color (their
/// reference RGB is derived from `ansi` when a ramp seed is needed).
pub(super) fn parse_color(value: &str) -> Result<TokenColor, String> {
if let Some(hex) = value.strip_prefix('#') {
if hex.len() == 6 && hex.chars().all(|c| c.is_ascii_hexdigit()) {
let n = u32::from_str_radix(hex, 16).expect("validated hex digits");
let rgb = ((n >> 16) as u8, (n >> 8) as u8, n as u8);
return Ok(TokenColor {
rgb: Some(rgb),
ansi: nearest_ansi(rgb),
exact: true,
});
}
return Err(format!(
"invalid color {value:?}: expected an ANSI color name or #rrggbb"
));
}
// ANSI names only; deliberately not ratatui's FromStr, which also
// accepts indexed forms we do not want to support in config.
let ansi = match value.to_ascii_lowercase().as_str() {
"black" => Color::Black,
"red" => Color::Red,
"green" => Color::Green,
"yellow" => Color::Yellow,
"blue" => Color::Blue,
"magenta" => Color::Magenta,
"cyan" => Color::Cyan,
"gray" | "grey" => Color::Gray,
"darkgray" | "darkgrey" => Color::DarkGray,
"lightred" => Color::LightRed,
"lightgreen" => Color::LightGreen,
"lightyellow" => Color::LightYellow,
"lightblue" => Color::LightBlue,
"lightmagenta" => Color::LightMagenta,
"lightcyan" => Color::LightCyan,
"white" => Color::White,
"reset" => Color::Reset,
_ => {
return Err(format!(
"invalid color {value:?}: expected an ANSI color name or #rrggbb"
));
}
};
Ok(TokenColor {
rgb: None,
ansi,
exact: true,
})
}
// --- Built-in specs ---
/// Token with an RGB ramp seed and an ANSI-16 color. On `truecolor_tokens`
/// themes the RGB is emitted (ANSI is the fallback); on muted/vivid the
/// ANSI name is always emitted and the RGB only seeds gradient ramps.
const fn rgb(hex: u32, ansi: Color) -> TokenColor {
TokenColor {
rgb: Some(((hex >> 16) as u8, (hex >> 8) as u8, hex as u8)),
ansi,
exact: false,
}
}
/// Token that always emits its ANSI color and has no RGB ramp seed.
const fn ansi_only(ansi: Color) -> TokenColor {
TokenColor {
rgb: None,
ansi,
exact: false,
}
}
/// Muted (default) and Vivid share every token except the three Vivid
/// colors the chrome: heading and key go yellow, border goes magenta, where
/// Muted keeps them the terminal foreground, cyan, and dark gray.
/// `truecolor_tokens` is false for both, so neither emits RGB outside the
/// gradient ramps and both follow the terminal's own ANSI-16 palette.
fn muted_or_vivid(vivid: bool) -> ThemeSpec {
ThemeSpec {
vivid,
truecolor_tokens: false,
accent: rgb(0x0891B2, Color::Cyan),
ok: rgb(0x10B981, Color::Green),
warn: rgb(0xD97706, Color::Yellow),
err: rgb(0xDC2626, Color::Red),
info: rgb(0x3B82F6, Color::Blue),
special: rgb(0xC026D3, Color::Magenta),
muted: rgb(0x6B7280, Color::Gray),
faint: rgb(0x4B5563, Color::DarkGray),
text: ansi_only(Color::Reset),
heading: if vivid {
ansi_only(Color::Yellow)
} else {
ansi_only(Color::Reset)
},
label: ansi_only(Color::Gray),
key: if vivid {
ansi_only(Color::Yellow)
} else {
ansi_only(Color::Cyan)
},
border: if vivid {
ansi_only(Color::Magenta)
} else {
ansi_only(Color::DarkGray)
},
rx: rgb(0x10B981, Color::Green),
tx: rgb(0x3B82F6, Color::Blue),
// Keep today's wave directions: blue RX wave, green TX wave, while
// the rx/tx table text colors stay Green/Blue respectively.
rx_wave: Some(rgb(0x3B82F6, Color::Blue)),
tx_wave: Some(rgb(0x10B981, Color::Green)),
selection_bg: None,
selection_fg: None,
status_bg: None,
identity_hues: IDENTITY_HUES,
light_background: false,
}
}
fn catppuccin_mocha() -> ThemeSpec {
ThemeSpec {
vivid: false,
truecolor_tokens: true,
accent: rgb(0xCBA6F7, Color::LightMagenta),
ok: rgb(0xA6E3A1, Color::LightGreen),
warn: rgb(0xF9E2AF, Color::LightYellow),
err: rgb(0xF38BA8, Color::LightRed),
info: rgb(0x89B4FA, Color::LightBlue),
special: rgb(0xF5C2E7, Color::LightMagenta),
muted: rgb(0x7F849C, Color::DarkGray),
faint: rgb(0x585B70, Color::DarkGray),
text: ansi_only(Color::Reset),
heading: rgb(0xBAC2DE, Color::Gray),
label: rgb(0x7F849C, Color::DarkGray),
key: rgb(0xCBA6F7, Color::LightMagenta),
border: rgb(0x585B70, Color::DarkGray),
rx: rgb(0x74C7EC, Color::LightCyan),
tx: rgb(0xA6E3A1, Color::LightGreen),
rx_wave: None,
tx_wave: None,
selection_bg: Some(rgb(0x45475A, Color::DarkGray)),
selection_fg: None,
status_bg: Some(rgb(0x313244, Color::DarkGray)),
identity_hues: IDENTITY_HUES,
light_background: false,
}
}
fn tokyo_night() -> ThemeSpec {
ThemeSpec {
vivid: false,
truecolor_tokens: true,
accent: rgb(0x7AA2F7, Color::LightBlue),
ok: rgb(0x9ECE6A, Color::LightGreen),
warn: rgb(0xE0AF68, Color::LightYellow),
err: rgb(0xF7768E, Color::LightRed),
info: rgb(0x7DCFFF, Color::LightCyan),
special: rgb(0xBB9AF7, Color::LightMagenta),
muted: rgb(0x565F89, Color::DarkGray),
faint: rgb(0x3B4261, Color::DarkGray),
text: ansi_only(Color::Reset),
heading: rgb(0xA9B1D6, Color::Gray),
label: rgb(0x565F89, Color::DarkGray),
key: rgb(0x7AA2F7, Color::LightBlue),
border: rgb(0x3B4261, Color::DarkGray),
rx: rgb(0x7DCFFF, Color::LightCyan),
tx: rgb(0x9ECE6A, Color::LightGreen),
rx_wave: None,
tx_wave: None,
selection_bg: Some(rgb(0x33467C, Color::DarkGray)),
selection_fg: None,
status_bg: Some(rgb(0x292E42, Color::DarkGray)),
identity_hues: IDENTITY_HUES,
light_background: false,
}
}
// Some ANSI fallbacks below intentionally deviate from pure nearest-distance
// matching to preserve semantic distinctness (gruvbox ok stays Green so it
// never collides with warn; nord info stays LightBlue).
fn gruvbox() -> ThemeSpec {
ThemeSpec {
vivid: false,
truecolor_tokens: true,
accent: rgb(0xFE8019, Color::LightRed),
ok: rgb(0xB8BB26, Color::Green),
warn: rgb(0xFABD2F, Color::LightYellow),
err: rgb(0xFB4934, Color::Red),
info: rgb(0x83A598, Color::Cyan),
special: rgb(0xD3869B, Color::LightMagenta),
muted: rgb(0x928374, Color::DarkGray),
faint: rgb(0x665C54, Color::DarkGray),
text: ansi_only(Color::Reset),
heading: rgb(0xD5C4A1, Color::Gray),
label: rgb(0x928374, Color::DarkGray),
key: rgb(0xFE8019, Color::LightRed),
border: rgb(0x504945, Color::DarkGray),
rx: rgb(0x83A598, Color::Cyan),
tx: rgb(0xB8BB26, Color::Green),
rx_wave: None,
tx_wave: None,
selection_bg: Some(rgb(0x504945, Color::DarkGray)),
selection_fg: None,
status_bg: Some(rgb(0x3C3836, Color::DarkGray)),
identity_hues: IDENTITY_HUES,
light_background: false,
}
}
fn nord() -> ThemeSpec {
ThemeSpec {
vivid: false,
truecolor_tokens: true,
accent: rgb(0x88C0D0, Color::LightCyan),
ok: rgb(0xA3BE8C, Color::LightGreen),
warn: rgb(0xEBCB8B, Color::LightYellow),
err: rgb(0xBF616A, Color::Red),
info: rgb(0x81A1C1, Color::LightBlue),
special: rgb(0xB48EAD, Color::LightMagenta),
muted: rgb(0x616E88, Color::DarkGray),
faint: rgb(0x4C566A, Color::DarkGray),
text: ansi_only(Color::Reset),
heading: rgb(0xD8DEE9, Color::Gray),
label: rgb(0x616E88, Color::DarkGray),
key: rgb(0x88C0D0, Color::LightCyan),
border: rgb(0x3B4252, Color::DarkGray),
rx: rgb(0x88C0D0, Color::LightCyan),
tx: rgb(0xA3BE8C, Color::LightGreen),
rx_wave: None,
tx_wave: None,
selection_bg: Some(rgb(0x434C5E, Color::DarkGray)),
selection_fg: None,
status_bg: Some(rgb(0x3B4252, Color::DarkGray)),
identity_hues: IDENTITY_HUES,
light_background: false,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn truecolor_is_detected_from_colorterm_or_a_direct_term() {
assert!(truecolor_from(Some("truecolor"), Some("xterm-256color")));
assert!(truecolor_from(Some("24bit"), None));
// A -direct terminfo entry carries no COLORTERM of its own.
assert!(truecolor_from(None, Some("xterm-direct")));
assert!(truecolor_from(None, Some("tmux-direct")));
assert!(!truecolor_from(None, Some("xterm-256color")));
assert!(!truecolor_from(Some("8bit"), Some("screen")));
assert!(!truecolor_from(None, None));
}
#[test]
fn preset_names_round_trip() {
for preset in ThemePreset::ALL {
assert_eq!(ThemePreset::from_name(preset.name()), Some(preset));
}
assert_eq!(ThemePreset::from_name("bogus"), None);
}
#[test]
fn cli_preset_list_matches_theme_presets() {
// The clap value list lives in cli.rs as literals (build.rs
// include!s that file); this pins it to the source of truth.
assert_eq!(
crate::cli::THEME_PRESETS,
ThemePreset::ALL.map(|p| p.name())
);
}
#[test]
fn set_token_hex_sets_rgb_and_fallback() {
let mut spec = ThemeSpec::builtin(ThemePreset::Muted);
spec.set_token("accent", "#7aa2f7").unwrap();
assert_eq!(spec.accent.rgb, Some((0x7A, 0xA2, 0xF7)));
assert_eq!(spec.accent.ansi, nearest_ansi((0x7A, 0xA2, 0xF7)));
}
#[test]
fn set_token_ansi_name_keeps_terminal_palette_color() {
let mut spec = ThemeSpec::builtin(ThemePreset::Muted);
spec.set_token("border", "darkgray").unwrap();
assert_eq!(spec.border.ansi, Color::DarkGray);
// No RGB: named overrides emit the terminal's own palette color;
// ramp seeds fall back to the ANSI reference RGB.
assert_eq!(spec.border.rgb, None);
assert!(spec.border.exact);
}
#[test]
fn set_token_rejects_bad_input() {
let mut spec = ThemeSpec::builtin(ThemePreset::Muted);
assert!(spec.set_token("nope", "red").is_err());
assert!(spec.set_token("accent", "#12345").is_err());
assert!(spec.set_token("accent", "notacolor").is_err());
}
#[test]
fn set_token_rejects_reset_for_background_slots() {
let mut spec = ThemeSpec::builtin(ThemePreset::TokyoNight);
for token in ["selection_bg", "selection_fg", "status_bg"] {
assert!(spec.set_token(token, "reset").is_err(), "{token}");
}
// "reset" stays valid for foreground tokens.
assert!(spec.set_token("text", "reset").is_ok());
}
#[test]
fn every_token_name_is_seen_by_has_overrides() {
// The contrast guard only runs on overridden specs, so a token
// that set_token accepts but has_overrides cannot see would
// silently opt out of it.
for token in TOKEN_NAMES {
let mut spec = ThemeSpec::builtin(ThemePreset::Muted);
assert!(!spec.has_overrides(), "{token}");
spec.set_token(token, "#3b4261").unwrap();
assert!(spec.has_overrides(), "{token}");
}
}
#[test]
fn set_token_covers_optional_slots() {
let mut spec = ThemeSpec::builtin(ThemePreset::Muted);
spec.set_token("selection_bg", "#3b4261").unwrap();
assert_eq!(
spec.selection_bg.map(|t| t.rgb),
Some(Some((0x3B, 0x42, 0x61)))
);
spec.set_token("rx_wave", "lightcyan").unwrap();
assert_eq!(spec.rx_wave.map(|t| t.ansi), Some(Color::LightCyan));
}
#[test]
fn light_background_darkens_gray_tiers_but_not_overrides() {
let mut spec = ThemeSpec::builtin(ThemePreset::Muted);
spec.adapt_to_light_background();
assert_eq!(spec.muted.ansi, Color::DarkGray);
assert_eq!(spec.label.ansi, Color::DarkGray);
// Ramp seeds and the non-gray tokens are untouched.
assert_eq!(spec.muted.rgb, Some((0x6B, 0x72, 0x80)));
assert_eq!(spec.accent.ansi, Color::Cyan);
assert_eq!(spec.faint.ansi, Color::DarkGray);
// An explicit user choice of Gray is kept.
let mut spec = ThemeSpec::builtin(ThemePreset::Muted);
spec.set_token("label", "gray").unwrap();
spec.adapt_to_light_background();
assert_eq!(spec.label.ansi, Color::Gray);
assert_eq!(spec.muted.ansi, Color::DarkGray);
}
#[test]
fn nearest_ansi_minimizes_euclidean_distance() {
assert_eq!(nearest_ansi((0x00, 0x00, 0x00)), Color::Black);
assert_eq!(nearest_ansi((0xFF, 0xFF, 0xFF)), Color::White);
// #fe8019 (gruvbox orange) sits closest to VGA Yellow (808000) by
// squared distance; the gruvbox built-in pins LightRed by hand.
assert_eq!(nearest_ansi((0xFE, 0x80, 0x19)), Color::Yellow);
}
#[test]
fn parse_color_accepts_grey_spellings_and_reset() {
assert_eq!(parse_color("grey").unwrap().ansi, Color::Gray);
assert_eq!(parse_color("DarkGrey").unwrap().ansi, Color::DarkGray);
let reset = parse_color("reset").unwrap();
assert_eq!(reset.ansi, Color::Reset);
assert_eq!(reset.rgb, None);
}
}
+820
View File
@@ -0,0 +1,820 @@
//! Theme resolution: spec tokens into final colors, roles, and gradient
//! ramps.
//!
//! The 5-stop gradient ramps used by the braille traffic waves and glow
//! bars are derived here from each token's RGB seed via a small HSL walk,
//! so an override of a core token automatically re-tints its gradients.
use ratatui::style::Color;
use super::definitions::{ThemeSpec, TokenColor, ansi_seed};
/// Fully resolved theme: final `Color` per token and role, plus
/// precomputed gradient ramps. Built once at startup by [`Theme::resolve`]
/// and stored in the module-level `ACTIVE` static.
#[derive(Debug, Clone)]
pub struct Theme {
pub(super) vivid: bool,
// Core tokens.
pub(super) accent: Color,
pub(super) ok: Color,
pub(super) warn: Color,
pub(super) err: Color,
pub(super) info: Color,
pub(super) muted: Color,
pub(super) faint: Color,
pub(super) text: Color,
pub(super) heading: Color,
pub(super) label: Color,
pub(super) key: Color,
pub(super) border: Color,
pub(super) rx: Color,
pub(super) tx: Color,
// Background tints; `None` unless the theme is truecolor or the user
// explicitly overrode the slot.
pub(super) selection_bg: Option<Color>,
pub(super) selection_fg: Option<Color>,
pub(super) status_bg: Option<Color>,
// Role colors mapped from the core tokens at resolve time.
pub(super) field_local_addr: Color,
pub(super) field_remote_addr: Color,
pub(super) field_state: Color,
pub(super) field_service: Color,
pub(super) field_location: Color,
pub(super) field_process: Color,
pub(super) field_application: Color,
pub(super) field_attributed_hostname: Color,
pub(super) tcp_established: Color,
pub(super) tcp_opening: Color,
pub(super) tcp_closing: Color,
pub(super) tcp_waiting: Color,
pub(super) tcp_closed: Color,
pub(super) proto_https: Color,
pub(super) proto_quic: Color,
pub(super) proto_http: Color,
pub(super) proto_dns: Color,
pub(super) proto_ssh: Color,
pub(super) proto_other: Color,
// Gradient ramps. These always emit `Color::Rgb` regardless of the
// terminal's truecolor support (terminals approximate; NO_COLOR still
// strips them via `fg()`).
pub(super) rx_ramp: [(u8, u8, u8); 5],
pub(super) tx_ramp: [(u8, u8, u8); 5],
pub(super) accent_ramp: [(u8, u8, u8); 5],
pub(super) ok_ramp: [(u8, u8, u8); 5],
pub(super) warn_ramp: [(u8, u8, u8); 5],
pub(super) err_ramp: [(u8, u8, u8); 5],
pub(super) special_ramp: [(u8, u8, u8); 5],
pub(super) muted_ramp: [(u8, u8, u8); 5],
pub(super) expiry_ramp: [(u8, u8, u8); 5],
/// 3-stop accent shimmer for the loading screen; `None` (static accent)
/// unless the preset and the terminal both do truecolor.
pub(super) shimmer_ramp: Option<[(u8, u8, u8); 3]>,
/// Hue wheel for per-identity tints; `None` disables them (ANSI
/// terminal, or the vivid preset, whose per-field palette is fixed).
pub(super) identity_hues: Option<&'static [u16]>,
/// Lightness the identity tints are synthesized at: pastel on dark
/// backgrounds, darkened on light ones so they stay readable.
pub(super) identity_lightness: f64,
}
impl Theme {
/// Resolve a spec into final colors. `terminal_truecolor` should be
/// [`super::detect_truecolor`]'s result; it is ignored when
/// `spec.truecolor_tokens` is false.
pub fn resolve(spec: &ThemeSpec, terminal_truecolor: bool) -> Theme {
let truecolor = spec.truecolor_tokens && terminal_truecolor;
// `exact` (user-overridden) tokens are honored on every preset:
// their RGB is emitted whenever the terminal supports truecolor,
// and their bg slots are never dropped.
let color = |t: TokenColor| match t.rgb {
Some((r, g, b)) if truecolor || (t.exact && terminal_truecolor) => Color::Rgb(r, g, b),
_ => t.ansi,
};
let bg = |t: Option<TokenColor>| t.filter(|t| truecolor || t.exact).map(color);
// Ramp seed: the token's RGB if set, else the ANSI reference RGB,
// else (Reset) fall back to Gray. Built-ins never hit the last arm;
// it is defensive only.
let seed = |t: TokenColor| {
t.rgb
.or_else(|| ansi_seed(t.ansi))
.unwrap_or((0xC0, 0xC0, 0xC0))
};
let accent = color(spec.accent);
let ok = color(spec.ok);
let warn = color(spec.warn);
let err = color(spec.err);
let info = color(spec.info);
let special = color(spec.special);
let muted = color(spec.muted);
let text = color(spec.text);
let vivid = spec.vivid;
let theme = Theme {
vivid,
accent,
ok,
warn,
err,
info,
muted,
faint: color(spec.faint),
text,
heading: color(spec.heading),
label: color(spec.label),
key: color(spec.key),
border: color(spec.border),
rx: color(spec.rx),
tx: color(spec.tx),
selection_bg: bg(spec.selection_bg),
selection_fg: bg(spec.selection_fg),
status_bg: bg(spec.status_bg),
field_local_addr: accent,
field_remote_addr: info,
field_state: if vivid { ok } else { text },
field_service: if vivid { warn } else { muted },
field_location: if vivid { special } else { muted },
field_process: if vivid { ok } else { text },
field_application: if vivid { warn } else { muted },
field_attributed_hostname: muted,
tcp_established: if vivid { ok } else { text },
tcp_opening: warn,
tcp_closing: if vivid { accent } else { muted },
tcp_waiting: if vivid { special } else { muted },
tcp_closed: muted,
proto_https: ok,
proto_quic: accent,
proto_http: warn,
proto_dns: special,
proto_ssh: info,
proto_other: muted,
rx_ramp: glow_ramp(seed(spec.rx_wave.unwrap_or(spec.rx))),
tx_ramp: glow_ramp(seed(spec.tx_wave.unwrap_or(spec.tx))),
accent_ramp: glow_ramp(seed(spec.accent)),
ok_ramp: glow_ramp(seed(spec.ok)),
warn_ramp: signal_ramp(seed(spec.warn)),
err_ramp: signal_ramp(seed(spec.err)),
special_ramp: signal_ramp(seed(spec.special)),
muted_ramp: signal_ramp(seed(spec.muted)),
expiry_ramp: expiry_ramp(seed(spec.warn), seed(spec.err)),
shimmer_ramp: truecolor.then(|| shimmer_ramp(seed(spec.accent))),
// Identity tints synthesize RGB directly, so they need a
// truecolor terminal but not a truecolor preset. Vivid opts
// out: its per-field palette is fixed by design.
identity_hues: (terminal_truecolor && !vivid && !spec.identity_hues.is_empty())
.then_some(spec.identity_hues),
identity_lightness: if spec.light_background {
IDENTITY_LIGHTNESS_ON_LIGHT
} else {
IDENTITY_LIGHTNESS
},
};
// NO_COLOR strips every color before it reaches the terminal, so
// there is nothing left to be unreadable: stay quiet there.
let no_color = crate::ui::NO_COLOR.load(crate::ui::Ordering::Relaxed);
if spec.has_overrides() && !no_color {
for warning in contrast_warnings(spec, &theme) {
eprintln!("rustnet: {warning}");
}
}
theme
}
}
// --- HSL helpers (h in degrees [0, 360), s and l in [0, 1]) ---
pub(super) fn rgb_to_hsl(rgb: (u8, u8, u8)) -> (f64, f64, f64) {
let r = f64::from(rgb.0) / 255.0;
let g = f64::from(rgb.1) / 255.0;
let b = f64::from(rgb.2) / 255.0;
let max = r.max(g).max(b);
let min = r.min(g).min(b);
let l = (max + min) / 2.0;
if max == min {
return (0.0, 0.0, l);
}
let d = max - min;
let s = if l > 0.5 {
d / (2.0 - max - min)
} else {
d / (max + min)
};
let h = if max == r {
60.0 * ((g - b) / d)
} else if max == g {
60.0 * ((b - r) / d + 2.0)
} else {
60.0 * ((r - g) / d + 4.0)
};
(h.rem_euclid(360.0), s, l)
}
pub(super) fn hsl_to_rgb(h: f64, s: f64, l: f64) -> (u8, u8, u8) {
let c = (1.0 - (2.0 * l - 1.0).abs()) * s;
let hp = h.rem_euclid(360.0) / 60.0;
let x = c * (1.0 - (hp % 2.0 - 1.0).abs());
let (r1, g1, b1) = match hp as u32 {
0 => (c, x, 0.0),
1 => (x, c, 0.0),
2 => (0.0, c, x),
3 => (0.0, x, c),
4 => (x, 0.0, c),
_ => (c, 0.0, x),
};
let m = l - c / 2.0;
let to_u8 = |v: f64| ((v + m) * 255.0).round().clamp(0.0, 255.0) as u8;
(to_u8(r1), to_u8(g1), to_u8(b1))
}
/// Glow ramp (rx, tx, accent, ok waves): the saturated seed walking
/// brighter, capped, then a white-hot crest. Stop 0 is the seed exactly.
pub(super) fn glow_ramp(seed: (u8, u8, u8)) -> [(u8, u8, u8); 5] {
let (h, s, l) = rgb_to_hsl(seed);
// Never below the seed's own lightness: a pale seed (l > 0.85) holds
// steady instead of dipping darker before the white crest.
let cap = (l + 0.24).min(0.85).max(l);
let mut stops = [(0u8, 0u8, 0u8); 5];
stops[0] = seed; // guard against float drift at i = 0
for (i, stop) in stops.iter_mut().enumerate().take(4).skip(1) {
*stop = hsl_to_rgb(h, s, l + (cap - l) * i as f64 / 3.0);
}
stops[4] = (0xFF, 0xFF, 0xFF);
stops
}
/// Signal ramp (warn, err, special, muted waves): darker start below the
/// token, brighter finish, no white crest.
pub(super) fn signal_ramp(seed: (u8, u8, u8)) -> [(u8, u8, u8); 5] {
let (h, s, l) = rgb_to_hsl(seed);
let lo = (l - 0.18).clamp(0.20, 0.80);
let hi = (l + 0.18).clamp(lo, 0.80);
let mut stops = [(0u8, 0u8, 0u8); 5];
for (i, stop) in stops.iter_mut().enumerate() {
*stop = hsl_to_rgb(h, s, lo + (hi - lo) * i as f64 / 4.0);
}
stops
}
/// Expiry glow: brightest warn blending into a vivid red endpoint derived
/// from the err token's hue.
pub(super) fn expiry_ramp(warn_seed: (u8, u8, u8), err_seed: (u8, u8, u8)) -> [(u8, u8, u8); 5] {
let a = signal_ramp(warn_seed)[4];
let (eh, es, _) = rgb_to_hsl(err_seed);
let b = hsl_to_rgb(eh, es.max(0.90), 0.58);
let mut stops = [(0u8, 0u8, 0u8); 5];
for (i, stop) in stops.iter_mut().enumerate() {
let t = i as f64 / 4.0;
*stop = (
lerp_channel(a.0, b.0, t),
lerp_channel(a.1, b.1, t),
lerp_channel(a.2, b.2, t),
);
}
stops
}
/// Midpoint between two colors, one channel at a time. Used by the edge
/// fade to pull a foreground halfway toward the faint tier.
pub(super) fn blend_half(a: (u8, u8, u8), b: (u8, u8, u8)) -> (u8, u8, u8) {
(
lerp_channel(a.0, b.0, 0.5),
lerp_channel(a.1, b.1, 0.5),
lerp_channel(a.2, b.2, 0.5),
)
}
pub(super) fn lerp_channel(a: u8, b: u8, t: f64) -> u8 {
(a as f64 + (b as f64 - a as f64) * t).round() as u8
}
/// Walk a 5-stop color ramp at `t` ∈ [0, 1] (4 linear segments).
pub(super) fn five_stop(stops: &[(u8, u8, u8); 5], t: f64) -> Color {
let seg = t.clamp(0.0, 1.0) * 4.0;
let i = (seg as usize).min(3);
let local = seg - i as f64;
let (a, b) = (stops[i], stops[i + 1]);
Color::Rgb(
lerp_channel(a.0, b.0, local),
lerp_channel(a.1, b.1, local),
lerp_channel(a.2, b.2, local),
)
}
// --- Contrast (WCAG) ---
/// Relative luminance of an sRGB color, per the WCAG 2.x definition.
pub(super) fn relative_luminance(r: u8, g: u8, b: u8) -> f64 {
fn linear(channel: u8) -> f64 {
let c = f64::from(channel) / 255.0;
if c <= 0.03928 {
c / 12.92
} else {
((c + 0.055) / 1.055).powf(2.4)
}
}
0.2126 * linear(r) + 0.7152 * linear(g) + 0.0722 * linear(b)
}
/// WCAG contrast ratio between two colors, in `[1.0, 21.0]`. Order does
/// not matter.
pub(super) fn contrast_ratio(a: (u8, u8, u8), b: (u8, u8, u8)) -> f64 {
let la = relative_luminance(a.0, a.1, a.2);
let lb = relative_luminance(b.0, b.1, b.2);
let (hi, lo) = if la >= lb { (la, lb) } else { (lb, la) };
(hi + 0.05) / (lo + 0.05)
}
/// Foreground candidates for [`super::on_color`]: near-black on light
/// backgrounds, near-white on dark ones (pure black/white read as harsh
/// next to the rest of the chrome).
pub(super) const ON_COLOR_DARK: (u8, u8, u8) = (26, 26, 26);
pub(super) const ON_COLOR_LIGHT: (u8, u8, u8) = (240, 240, 240);
/// Minimum fg/bg contrast the guard accepts: WCAG AA for large or bold
/// text, which is what every checked pair renders.
const MIN_CONTRAST: f64 = 3.0;
/// Reference RGB for a resolved color: itself when truecolor, the ANSI
/// reference otherwise, `None` for `Color::Reset` (the terminal's own
/// foreground, which we cannot know).
fn reference_rgb(color: Color) -> Option<(u8, u8, u8)> {
match color {
Color::Rgb(r, g, b) => Some((r, g, b)),
other => ansi_seed(other),
}
}
/// Warnings for fg/bg pairs a user override pushed below [`MIN_CONTRAST`],
/// one line per failing pair, ready for stderr. Nothing is altered: the
/// user's colors win, they just get told.
///
/// Only pairs whose text must stay readable are checked. The `label` tier
/// is deliberately dim (several built-ins sit below 3:1 on the status
/// band by design) and badge foregrounds are auto-picked by
/// [`super::on_color`], so neither needs a guard. ANSI colors are compared
/// via their reference palette RGB, which is an approximation of what the
/// terminal actually paints.
pub(super) fn contrast_warnings(spec: &ThemeSpec, theme: &Theme) -> Vec<String> {
let mut warnings = Vec::new();
// Only pairs the user actually touched are judged. The built-in palettes
// are their authors' deliberate choices (no Nord-family red clears 3:1 on
// a Nord background, and the recessed tiers are dim on purpose), so
// flagging them would be second-guessing the theme, not the config.
let mut check = |fg_name: &str,
fg: Option<Color>,
fg_exact: bool,
bg_name: &str,
bg: Option<Color>,
bg_exact: bool| {
if !(fg_exact || bg_exact) {
return;
}
let (Some(fg), Some(bg)) = (fg, bg) else {
return;
};
// Body text is the terminal's own foreground on every preset, so it
// resolves to `Reset` and has no RGB to measure. Unknowable, skipped.
let (Some(fg_rgb), Some(bg_rgb)) = (reference_rgb(fg), reference_rgb(bg)) else {
return;
};
let ratio = contrast_ratio(fg_rgb, bg_rgb);
if ratio < MIN_CONTRAST {
warnings.push(format!(
"theme contrast: {fg_name} on {bg_name} is {ratio:.1}:1, below {MIN_CONTRAST:.1}:1; text may be hard to read"
));
}
};
// The selection band, which the filter chip also renders on. Every
// built-in leaves `selection_fg` unset, and tinting the band without
// setting the text color is the likeliest override of all, so the
// recessed row tier is measured against it too.
let sel_bg_exact = spec.selection_bg.is_some_and(|t| t.exact);
check(
"selection_fg",
theme.selection_fg,
spec.selection_fg.is_some_and(|t| t.exact),
"selection_bg",
theme.selection_bg,
sel_bg_exact,
);
// Only when the band sets no text color of its own: with `selection_fg`
// set, every cell on the band takes it and the row's own tiers never
// render there.
if theme.selection_fg.is_none() {
check(
"muted",
Some(theme.muted),
spec.muted.exact,
"selection_bg",
theme.selection_bg,
sel_bg_exact,
);
}
// The status bar: keycaps carry the hints, labels sit beside them, and
// the alert states paint the same row in a signal color.
let status_exact = spec.status_bg.is_some_and(|t| t.exact);
for (name, color, exact) in [
("key", theme.key, spec.key.exact),
("label", theme.label, spec.label.exact),
("warn", theme.warn, spec.warn.exact),
("ok", theme.ok, spec.ok.exact),
("err", theme.err, spec.err.exact),
] {
check(
name,
Some(color),
exact,
"status_bg",
theme.status_bg,
status_exact,
);
}
warnings
}
// --- Identity tints ---
const IDENTITY_SATURATION: f64 = 0.45;
const IDENTITY_LIGHTNESS: f64 = 0.68;
/// On a light terminal background the pastel tints fall to roughly 2:1
/// against white, so identities darken instead; 0.34 keeps the worst hue
/// (yellow) above the same 3:1 floor the contrast guard enforces.
const IDENTITY_LIGHTNESS_ON_LIGHT: f64 = 0.34;
/// FNV-1a over the name's bytes: a stable, dependency-free hash, so the
/// same process name keeps the same tint across runs and machines.
fn fnv1a(name: &str) -> u64 {
let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
for byte in name.as_bytes() {
hash ^= u64::from(*byte);
hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
}
hash
}
/// Tint for `name`: its hash picks a hue from `hues`, which is then
/// synthesized at a fixed saturation and `lightness` so every identity
/// color carries the same weight. `hues` must be non-empty.
pub(super) fn identity_rgb(hues: &[u16], name: &str, lightness: f64) -> (u8, u8, u8) {
debug_assert!(!hues.is_empty(), "identity hue list must be non-empty");
let hue = hues[(fnv1a(name) % hues.len() as u64) as usize];
hsl_to_rgb(f64::from(hue), IDENTITY_SATURATION, lightness)
}
/// Shimmer ramp: the accent walking up to 24% lighter in three steps.
/// Stop 0 is the seed exactly, so a shimmer at rest is the accent color.
pub(super) fn shimmer_ramp(seed: (u8, u8, u8)) -> [(u8, u8, u8); 3] {
let (h, s, l) = rgb_to_hsl(seed);
[
seed,
hsl_to_rgb(h, s, (l + 0.12).clamp(0.0, 0.95)),
hsl_to_rgb(h, s, (l + 0.24).clamp(0.0, 0.95)),
]
}
/// Walk a 3-stop color ramp at `t` ∈ [0, 1] (2 linear segments).
pub(super) fn three_stop(stops: &[(u8, u8, u8); 3], t: f64) -> Color {
let seg = t.clamp(0.0, 1.0) * 2.0;
let i = (seg as usize).min(1);
let local = seg - i as f64;
let (a, b) = (stops[i], stops[i + 1]);
Color::Rgb(
lerp_channel(a.0, b.0, local),
lerp_channel(a.1, b.1, local),
lerp_channel(a.2, b.2, local),
)
}
#[cfg(test)]
mod tests {
use super::super::definitions::{ThemePreset, ThemeSpec};
use super::*;
#[test]
fn glow_ramp_keeps_seed_and_white_crest() {
let seed = (0x3B, 0x82, 0xF6); // muted rx wave seed
let ramp = glow_ramp(seed);
assert_eq!(ramp[0], seed);
assert_eq!(ramp[4], (0xFF, 0xFF, 0xFF));
}
#[test]
fn glow_ramp_is_monotonic_for_pale_seeds() {
// A seed lighter than the 0.85 cap must not dip darker before the
// white crest.
for seed in [(0xFF, 0xE9, 0xC2), (0xFF, 0xFF, 0xFF)] {
let ramp = glow_ramp(seed);
for pair in ramp.windows(2) {
let (_, _, l0) = rgb_to_hsl(pair[0]);
let (_, _, l1) = rgb_to_hsl(pair[1]);
assert!(l1 >= l0 - 1e-9, "lightness decreased in {ramp:?}");
}
}
}
#[test]
fn signal_ramp_lightness_is_non_decreasing() {
for seed in [(0xD9, 0x77, 0x06), (0xDC, 0x26, 0x26), (0x6B, 0x72, 0x80)] {
let ramp = signal_ramp(seed);
for pair in ramp.windows(2) {
let (_, _, l0) = rgb_to_hsl(pair[0]);
let (_, _, l1) = rgb_to_hsl(pair[1]);
assert!(l1 >= l0 - 1e-9, "lightness decreased in {ramp:?}");
}
}
}
#[test]
fn hsl_round_trips_within_one_per_channel() {
for rgb in [
(0x3B, 0x82, 0xF6),
(0xD9, 0x77, 0x06),
(0x10, 0xB9, 0x81),
(0x6B, 0x72, 0x80),
(0x00, 0x00, 0x00),
(0xFF, 0xFF, 0xFF),
] {
let (h, s, l) = rgb_to_hsl(rgb);
let back = hsl_to_rgb(h, s, l);
let close = |a: u8, b: u8| (i16::from(a) - i16::from(b)).abs() <= 1;
assert!(
close(rgb.0, back.0) && close(rgb.1, back.1) && close(rgb.2, back.2),
"{rgb:?} round-tripped to {back:?}"
);
}
}
#[test]
fn muted_tokens_stay_ansi_even_on_truecolor_terminals() {
let theme = Theme::resolve(&ThemeSpec::builtin(ThemePreset::Muted), true);
assert_eq!(theme.accent, Color::Cyan);
assert_eq!(theme.selection_bg, None);
}
#[test]
fn overrides_are_honored_on_ansi_presets() {
let mut spec = ThemeSpec::builtin(ThemePreset::Muted);
spec.set_token("accent", "#ff9e64").unwrap();
spec.set_token("selection_bg", "#3b4261").unwrap();
// Truecolor terminal: the exact values are emitted even though the
// muted preset itself stays ANSI.
let theme = Theme::resolve(&spec, true);
assert_eq!(theme.accent, Color::Rgb(0xFF, 0x9E, 0x64));
assert_eq!(theme.selection_bg, Some(Color::Rgb(0x3B, 0x42, 0x61)));
// Non-truecolor terminal: hex degrades to its nearest-ANSI
// fallback, and the overridden bg slot is still honored.
let theme = Theme::resolve(&spec, false);
assert_eq!(theme.accent, spec.accent.ansi);
assert_eq!(theme.selection_bg, Some(spec.selection_bg.unwrap().ansi));
}
#[test]
fn truecolor_theme_falls_back_to_ansi_without_truecolor() {
let theme = Theme::resolve(&ThemeSpec::builtin(ThemePreset::TokyoNight), false);
assert_eq!(theme.accent, Color::LightBlue);
assert_eq!(theme.selection_bg, None);
assert_eq!(theme.status_bg, None);
}
#[test]
fn truecolor_theme_emits_rgb_on_truecolor_terminals() {
let theme = Theme::resolve(&ThemeSpec::builtin(ThemePreset::TokyoNight), true);
assert_eq!(theme.accent, Color::Rgb(0x7A, 0xA2, 0xF7));
assert_eq!(theme.selection_bg, Some(Color::Rgb(0x33, 0x46, 0x7C)));
}
#[test]
fn contrast_ratio_matches_wcag_extremes() {
let black = (0x00, 0x00, 0x00);
let white = (0xFF, 0xFF, 0xFF);
assert!((contrast_ratio(black, white) - 21.0).abs() < 1e-6);
assert!((contrast_ratio(white, black) - 21.0).abs() < 1e-6);
assert!((contrast_ratio(white, white) - 1.0).abs() < 1e-6);
// WCAG reference luminances.
assert!(relative_luminance(0, 0, 0).abs() < 1e-9);
assert!((relative_luminance(255, 255, 255) - 1.0).abs() < 1e-9);
assert!(relative_luminance(0, 255, 0) > relative_luminance(0, 0, 255));
}
#[test]
fn blend_half_is_the_channel_midpoint() {
assert_eq!(
blend_half((0x00, 0x10, 0xFF), (0x40, 0x20, 0xFF)),
(0x20, 0x18, 0xFF)
);
}
#[test]
fn shimmer_ramp_starts_at_the_seed_and_brightens() {
let seed = (0x08, 0x91, 0xB2);
let ramp = shimmer_ramp(seed);
assert_eq!(ramp[0], seed);
let lightness = ramp.map(|stop| rgb_to_hsl(stop).2);
assert!(lightness[1] > lightness[0], "{ramp:?}");
assert!(lightness[2] > lightness[1], "{ramp:?}");
// Endpoints land exactly on their stops, midpoint interpolates.
assert_eq!(three_stop(&ramp, 0.0), Color::Rgb(seed.0, seed.1, seed.2));
assert_eq!(
three_stop(&ramp, 1.0),
Color::Rgb(ramp[2].0, ramp[2].1, ramp[2].2)
);
assert_eq!(
three_stop(&ramp, 0.5),
Color::Rgb(ramp[1].0, ramp[1].1, ramp[1].2)
);
}
#[test]
fn shimmer_ramp_is_absent_without_truecolor() {
assert!(
Theme::resolve(&ThemeSpec::builtin(ThemePreset::TokyoNight), true)
.shimmer_ramp
.is_some()
);
assert!(
Theme::resolve(&ThemeSpec::builtin(ThemePreset::TokyoNight), false)
.shimmer_ramp
.is_none()
);
// Muted is an ANSI preset: static accent even on a truecolor term.
assert!(
Theme::resolve(&ThemeSpec::builtin(ThemePreset::Muted), true)
.shimmer_ramp
.is_none()
);
}
#[test]
fn identity_rgb_is_stable_per_name_and_spreads_across_hues() {
let hues = super::super::definitions::IDENTITY_HUES;
let l = IDENTITY_LIGHTNESS;
assert_eq!(
identity_rgb(hues, "firefox", l),
identity_rgb(hues, "firefox", l)
);
assert_ne!(
identity_rgb(hues, "firefox", l),
identity_rgb(hues, "curl", l)
);
let names = [
"firefox", "chrome", "curl", "ssh", "sshd", "systemd", "dockerd", "postgres", "redis",
"nginx", "code", "slack", "spotify", "zoom",
];
let distinct: std::collections::BTreeSet<_> =
names.iter().map(|n| identity_rgb(hues, n, l)).collect();
assert!(
distinct.len() >= names.len() * 2 / 3,
"identity hues clustered: {distinct:?}"
);
}
#[test]
fn identity_tints_on_light_background_clear_the_contrast_floor() {
// Every hue on the wheel must stay readable as fg text on a white
// terminal background, the same 3:1 floor the contrast guard uses.
for &hue in super::super::definitions::IDENTITY_HUES {
let rgb = hsl_to_rgb(
f64::from(hue),
IDENTITY_SATURATION,
IDENTITY_LIGHTNESS_ON_LIGHT,
);
let ratio = contrast_ratio(rgb, (0xFF, 0xFF, 0xFF));
assert!(ratio >= 3.0, "hue {hue}: {ratio:.2}:1 against white");
}
}
#[test]
fn light_background_spec_darkens_identity_tints() {
let mut spec = ThemeSpec::builtin(ThemePreset::Muted);
let pastel = Theme::resolve(&spec, true).identity_lightness;
spec.adapt_to_light_background();
let darkened = Theme::resolve(&spec, true).identity_lightness;
assert!(darkened < pastel);
}
#[test]
fn identity_hues_are_gated_by_terminal_and_preset() {
// Truecolor terminal: available on every preset but vivid, the
// ANSI muted preset included.
for preset in [ThemePreset::Muted, ThemePreset::Nord] {
assert!(
Theme::resolve(&ThemeSpec::builtin(preset), true)
.identity_hues
.is_some(),
"{preset:?}"
);
}
assert!(
Theme::resolve(&ThemeSpec::builtin(ThemePreset::Vivid), true)
.identity_hues
.is_none()
);
// No truecolor: no synthesized hues anywhere.
for preset in ThemePreset::ALL {
assert!(
Theme::resolve(&ThemeSpec::builtin(preset), false)
.identity_hues
.is_none(),
"{preset:?}"
);
}
}
#[test]
fn built_in_presets_are_never_second_guessed() {
// A preset the user has not touched is its author's choice, however
// dim: the guard judges overrides only.
for preset in ThemePreset::ALL {
for truecolor in [false, true] {
let spec = ThemeSpec::builtin(preset);
let theme = Theme::resolve(&spec, truecolor);
assert!(
contrast_warnings(&spec, &theme).is_empty(),
"{preset:?} (truecolor={truecolor}): {:?}",
contrast_warnings(&spec, &theme)
);
}
}
}
#[test]
fn tinting_the_band_alone_is_still_judged() {
// The likeliest override of all: a new selection_bg with no
// selection_fg. Nothing pairs with it explicitly, so the recessed row
// tier is what gets measured against it.
let mut spec = ThemeSpec::builtin(ThemePreset::TokyoNight);
spec.set_token("selection_bg", "#828bb8").unwrap();
let warnings = contrast_warnings(&spec, &Theme::resolve(&spec, true));
assert!(
warnings.iter().any(|w| w.contains("muted on selection_bg")),
"a background-only override went unjudged: {warnings:?}"
);
}
#[test]
fn low_contrast_overrides_are_reported() {
let mut spec = ThemeSpec::builtin(ThemePreset::TokyoNight);
spec.set_token("selection_bg", "#33467c").unwrap();
spec.set_token("selection_fg", "#3b4261").unwrap();
spec.set_token("status_bg", "#292e42").unwrap();
spec.set_token("key", "#2b3350").unwrap();
assert!(spec.has_overrides());
let warnings = contrast_warnings(&spec, &Theme::resolve(&spec, true));
// An overridden background is judged against every foreground that
// lands on it, not just the one explicitly paired with it. `muted` is
// absent here on purpose: this spec sets `selection_fg`, so the band
// paints all of its text with that instead.
for pair in [
"selection_fg on selection_bg",
"key on status_bg",
"label on status_bg",
] {
assert!(
warnings.iter().any(|w| w.contains(pair)),
"{pair} went unreported: {warnings:?}"
);
}
// A readable override pair says nothing, and the colors are never
// altered either way.
let mut spec = ThemeSpec::builtin(ThemePreset::TokyoNight);
spec.set_token("selection_bg", "#1a1b26").unwrap();
spec.set_token("selection_fg", "#c0caf5").unwrap();
let theme = Theme::resolve(&spec, true);
assert!(contrast_warnings(&spec, &theme).is_empty());
assert_eq!(theme.selection_fg, Some(Color::Rgb(0xC0, 0xCA, 0xF5)));
}
#[test]
fn built_in_presets_have_no_overrides() {
for preset in ThemePreset::ALL {
assert!(!ThemeSpec::builtin(preset).has_overrides(), "{preset:?}");
}
let mut spec = ThemeSpec::builtin(ThemePreset::Muted);
spec.set_token("faint", "darkgray").unwrap();
assert!(spec.has_overrides());
}
#[test]
fn accent_override_propagates_to_roles_and_ramp() {
let mut spec = ThemeSpec::builtin(ThemePreset::TokyoNight);
spec.set_token("accent", "#ff9e64").unwrap();
let theme = Theme::resolve(&spec, true);
assert_eq!(theme.field_local_addr, Color::Rgb(0xFF, 0x9E, 0x64));
assert_eq!(theme.proto_quic, Color::Rgb(0xFF, 0x9E, 0x64));
assert_eq!(theme.accent_ramp[0], (0xFF, 0x9E, 0x64));
}
}
+589
View File
@@ -0,0 +1,589 @@
//! Centralized color palette for cross-terminal consistency.
//!
//! Every color the UI emits routes through this module. A [`ThemeSpec`]
//! (per-token definitions, see `definitions`) resolves into a [`Theme`]
//! (final `Color` per role plus precomputed gradient ramps, see `derive`)
//! which is stored once at startup in the `ACTIVE` static. Helper fns read
//! the active theme, so call sites stay theme-agnostic, and every style
//! builder respects the `ui::NO_COLOR` flag.
use std::sync::OnceLock;
use ratatui::style::{Color, Modifier, Style};
mod background;
mod definitions;
mod derive;
pub use background::detect_light_background;
pub use definitions::{ThemePreset, ThemeSpec, TokenColor, detect_truecolor};
pub use derive::Theme;
use derive::{ON_COLOR_DARK, ON_COLOR_LIGHT, five_stop, three_stop};
/// The resolved theme in effect. Set once at startup; reads are lock-free
/// after first use and default to the muted preset (snapshot tests never
/// set a theme, so they keep rendering the muted default).
static ACTIVE: OnceLock<Theme> = OnceLock::new();
fn active() -> &'static Theme {
ACTIVE.get_or_init(|| Theme::resolve(&ThemeSpec::builtin(ThemePreset::Muted), false))
}
/// Install the resolved theme. Called once at startup; a repeat call is
/// ignored with a warning.
pub fn set_theme(theme: Theme) {
if ACTIVE.set(theme).is_err() {
log::warn!("set_theme called more than once; keeping the active theme");
}
}
/// Whether the Vivid (full-color) preset mapping is active.
pub(super) fn is_vivid() -> bool {
active().vivid
}
// --- Base color accessors ---
pub(super) fn ok() -> Color {
active().ok
}
pub(super) fn warn() -> Color {
active().warn
}
pub(super) fn err() -> Color {
active().err
}
pub(super) fn accent() -> Color {
active().accent
}
pub(super) fn muted() -> Color {
active().muted
}
pub(super) fn info() -> Color {
active().info
}
/// Dimmest tier: historic rows, disabled chrome.
pub(super) fn faint() -> Color {
active().faint
}
/// Default body text (the terminal's own foreground on every built-in).
pub(super) fn text() -> Color {
active().text
}
// --- UI element aliases ---
//
// Three-tier hierarchy so the showcase can pick out a clear winner:
// * primary() - what the user is acting on right now (active tab,
// selected row's focus column, sorted column header)
// * heading() - structural anchors (table column headers, section titles)
// * label() - supporting context (field labels, units, separators)
//
// `primary()` returns a full Style because it always pairs with BOLD;
// the others return raw Colors so callers can compose with `fg()` /
// `bold_fg()` as needed.
pub(super) fn primary() -> Style {
bold_fg(accent())
}
pub(super) fn label() -> Color {
active().label
}
pub(super) fn heading() -> Color {
active().heading
}
// --- Network aliases ---
pub(super) fn rx() -> Color {
active().rx
}
pub(super) fn tx() -> Color {
active().tx
}
// --- Traffic wave gradients (Graph tab) ---
//
// Truecolor 5-stop ramps for the braille traffic waves, derived from the
// theme's token seeds at resolve time: saturated color at the base and a
// white-hot crest for the glow ramps, a dark-to-bright walk for the signal
// ramps. Callers must wrap the result in `fg()` so NO_COLOR still strips
// these.
const EXPIRY_WARNING_START: f32 = 0.75;
const EXPIRY_CRITICAL_START: f32 = 0.90;
/// RX wave gradient color at intensity `t` (0 = dim base, 1 = crest).
pub(super) fn rx_wave(t: f64) -> Color {
five_stop(&active().rx_ramp, t)
}
/// TX wave gradient color at intensity `t` (0 = dim base, 1 = crest).
pub(super) fn tx_wave(t: f64) -> Color {
five_stop(&active().tx_ramp, t)
}
/// Accent wave gradient for non-directional graphs like the connection
/// count, at intensity `t` (0 = dim base, 1 = crest).
pub(super) fn accent_wave(t: f64) -> Color {
five_stop(&active().accent_ramp, t)
}
/// Green gradient for healthy/success bars (derived from the ok token).
pub(super) fn ok_wave(t: f64) -> Color {
five_stop(&active().ok_ramp, t)
}
/// Amber gradient for caution bars.
pub(super) fn warn_wave(t: f64) -> Color {
five_stop(&active().warn_ramp, t)
}
/// Red gradient for critical bars.
pub(super) fn err_wave(t: f64) -> Color {
five_stop(&active().err_ramp, t)
}
/// Fuchsia gradient for special/distinct bars (DNS).
pub(super) fn special_wave(t: f64) -> Color {
five_stop(&active().special_ramp, t)
}
/// Gray gradient for secondary/inactive bars.
pub(super) fn muted_wave(t: f64) -> Color {
five_stop(&active().muted_ramp, t)
}
/// Warn-to-err glow for connections nearing their removal timeout.
pub(super) fn expiry_glow(t: f64) -> Color {
five_stop(&active().expiry_ramp, t)
}
/// Accent shimmer at phase `t` (0 = the accent itself, 1 = its lightest
/// step): a 3-stop lightness walk for animated text. Themes and terminals
/// without truecolor get the plain accent color, so the animation
/// gracefully becomes a static one.
pub(super) fn shimmer_wave(t: f64) -> Color {
match &active().shimmer_ramp {
Some(ramp) => three_stop(ramp, t),
None => accent(),
}
}
/// Map connection staleness to the expiry glow. The row turns yellow at 75%
/// of its timeout, stays yellow through the warning window, then intensifies
/// toward red during the final 10% before removal.
pub(super) fn expiry_glow_intensity(staleness: f32) -> Option<f64> {
(staleness >= EXPIRY_WARNING_START).then(|| {
f64::from(
((staleness - EXPIRY_CRITICAL_START) / (1.0 - EXPIRY_CRITICAL_START)).clamp(0.0, 1.0),
)
})
}
// --- Protocol aliases ---
pub(super) fn proto_https() -> Color {
active().proto_https
}
pub(super) fn proto_quic() -> Color {
active().proto_quic
}
pub(super) fn proto_http() -> Color {
active().proto_http
}
pub(super) fn proto_dns() -> Color {
active().proto_dns
}
pub(super) fn proto_ssh() -> Color {
active().proto_ssh
}
pub(super) fn proto_other() -> Color {
active().proto_other
}
// --- TCP state aliases ---
// Non-vivid themes: ESTABLISHED is the common case and reads as plain
// text; only transitional states (a genuine signal) keep an attention color.
pub(super) fn tcp_established() -> Color {
active().tcp_established
}
pub(super) fn tcp_opening() -> Color {
active().tcp_opening
}
pub(super) fn tcp_closing() -> Color {
active().tcp_closing
}
pub(super) fn tcp_waiting() -> Color {
active().tcp_waiting
}
pub(super) fn tcp_closed() -> Color {
active().tcp_closed
}
// --- Field-level aliases (same color used everywhere a field appears) ---
// Non-vivid themes: addresses keep a calm color (they're the data being
// monitored), the other identifying fields render as body text, supporting
// context fades to the muted tier. Same address roles in every theme.
pub(super) fn field_local_addr() -> Color {
active().field_local_addr
}
pub(super) fn field_remote_addr() -> Color {
active().field_remote_addr
}
pub(super) fn field_state() -> Color {
active().field_state
}
pub(super) fn field_service() -> Color {
active().field_service
}
pub(super) fn field_location() -> Color {
active().field_location
}
pub(super) fn field_process() -> Color {
active().field_process
}
pub(super) fn field_application() -> Color {
active().field_application
}
/// Color for hostnames inferred from a recently observed DNS resolution
/// (shown with a `~` prefix). Dimmer than `field_remote_addr` so the
/// inference is visually distinct from authoritative SNI / Host data.
pub(super) fn field_attributed_hostname() -> Color {
active().field_attributed_hostname
}
// --- Historic (closed) connection rows ---
// Whole-row override; per-cell colors are dropped so the uniform faint
// tier carries the signal. DIM is deliberately NOT used when colors are
// available: terminals disagree wildly on it (invisible on light themes,
// barely-there in WezTerm dark). Under NO_COLOR it returns as the only
// row-level cue, alongside the "closed" state text.
pub(super) fn historic_row() -> Style {
if super::NO_COLOR.load(super::Ordering::Relaxed) {
Style::default().add_modifier(Modifier::DIM)
} else {
Style::default().fg(faint())
}
}
// --- Panel border ---
pub(super) fn border() -> Color {
active().border
}
// --- Status bar styles ---
// Every state rides `status_bar_hints()`: the theme's status_bg tint when it
// has one, otherwise the terminal background. The alert states carry their
// meaning in a bold signal color rather than a filled band, the way color is
// used everywhere else in the chrome. `fg(Black).bg(Color)` is deliberately
// avoided (it breaks on dark terminals), and REVERSED is reserved for
// NO_COLOR, where a band is the only cue the row is a status bar.
/// Base style for the status bar. Reverse video under NO_COLOR, the theme's
/// own band when it has one, and otherwise nothing: the spans carry the
/// contrast, and a REVERSED band would turn each one into a solid block of
/// its own foreground color.
pub(super) fn status_bar_hints() -> Style {
if super::NO_COLOR.load(super::Ordering::Relaxed) {
return Style::default().add_modifier(Modifier::REVERSED);
}
match active().status_bg {
Some(bg) => Style::default().bg(bg),
None => Style::default(),
}
}
pub(super) fn status_bar_confirm() -> Style {
if super::NO_COLOR.load(super::Ordering::Relaxed) {
return status_bar_hints();
}
status_bar_hints().fg(warn()).add_modifier(Modifier::BOLD)
}
pub(super) fn status_bar_success() -> Style {
if super::NO_COLOR.load(super::Ordering::Relaxed) {
return status_bar_hints();
}
status_bar_hints().fg(ok()).add_modifier(Modifier::BOLD)
}
pub(super) fn status_bar_error() -> Style {
if super::NO_COLOR.load(super::Ordering::Relaxed) {
return status_bar_hints().add_modifier(Modifier::BOLD);
}
status_bar_hints().fg(err()).add_modifier(Modifier::BOLD)
}
pub(super) fn status_bar_default() -> Style {
if super::NO_COLOR.load(super::Ordering::Relaxed) || !is_vivid() {
return status_bar_hints();
}
status_bar_hints().fg(info())
}
// --- Selection and key hint styles ---
/// Accent-tinted selection band for table rows. Falls back to
/// BOLD | REVERSED (with no fg override, so when REVERSED swaps fg and bg
/// a red staleness row gets a red selection bar and the signal survives)
/// whenever the theme has no truecolor selection tint. Built-ins leave
/// `selection_fg` unset for the same reason: the row's own fg, including
/// the expiry glow, survives selection.
pub(super) fn selection_row() -> Style {
if super::NO_COLOR.load(super::Ordering::Relaxed) {
return Style::default().add_modifier(Modifier::BOLD | Modifier::REVERSED);
}
match active().selection_bg {
Some(bg) => {
let mut style = Style::default().bg(bg).add_modifier(Modifier::BOLD);
if let Some(fg) = active().selection_fg {
style = style.fg(fg);
}
style
}
None => Style::default().add_modifier(Modifier::BOLD | Modifier::REVERSED),
}
}
/// The table highlight routes through `selection_row()` so a theme's
/// selection tint reaches every existing call site.
pub(super) fn row_highlight() -> Style {
selection_row()
}
/// Whether the selection band is a real background tint (rather than the
/// BOLD | REVERSED fallback). Rows whose fg would be unreadable on the
/// tint (the faint historic tier) use this to restyle themselves when
/// selected.
pub(super) fn selection_has_bg() -> bool {
!super::NO_COLOR.load(super::Ordering::Relaxed) && active().selection_bg.is_some()
}
/// Keycap style for status bar and help key hints.
pub(super) fn key_hint() -> Style {
if super::NO_COLOR.load(super::Ordering::Relaxed) {
return Style::default().add_modifier(Modifier::BOLD);
}
Style::default()
.fg(active().key)
.add_modifier(Modifier::BOLD)
}
/// Label text following a keycap.
pub(super) fn key_hint_label() -> Style {
if super::NO_COLOR.load(super::Ordering::Relaxed) {
return Style::default();
}
Style::default().fg(active().label)
}
// --- Style builders (NO_COLOR-aware) ---
/// Apply a foreground color, respecting NO_COLOR.
pub(super) fn fg(color: Color) -> Style {
if super::NO_COLOR.load(super::Ordering::Relaxed) {
Style::default()
} else {
Style::default().fg(color)
}
}
/// Apply a foreground color with BOLD, respecting NO_COLOR.
pub(super) fn bold_fg(color: Color) -> Style {
if super::NO_COLOR.load(super::Ordering::Relaxed) {
Style::default().add_modifier(Modifier::BOLD)
} else {
Style::default().fg(color).add_modifier(Modifier::BOLD)
}
}
/// Readable foreground for text drawn on `bg`, picking the near-black or
/// near-white candidate with the better contrast. Non-RGB (ANSI)
/// backgrounds return `Color::Black`: callers that cannot guarantee a
/// readable pair on an ANSI terminal render their bracket fallback instead
/// (see the badge widget). NO_COLOR returns `Color::Reset`.
pub(super) fn on_color(bg: Color) -> Color {
if super::NO_COLOR.load(super::Ordering::Relaxed) {
return Color::Reset;
}
match bg {
Color::Rgb(r, g, b) => {
let bg = (r, g, b);
let (r, g, b) = if derive::contrast_ratio(bg, ON_COLOR_DARK)
>= derive::contrast_ratio(bg, ON_COLOR_LIGHT)
{
ON_COLOR_DARK
} else {
ON_COLOR_LIGHT
};
Color::Rgb(r, g, b)
}
_ => Color::Black,
}
}
/// Stable per-identity tint for a name (a process or application), so the
/// same name keeps the same hue everywhere it appears. `None` means "no
/// tint available": NO_COLOR, a terminal without truecolor, or the vivid
/// preset, whose palette stays as it always was. Callers keep their own
/// style in that case.
pub(super) fn identity_color(name: &str) -> Option<Color> {
if super::NO_COLOR.load(super::Ordering::Relaxed) {
return None;
}
let theme = active();
let hues = theme.identity_hues?;
let (r, g, b) = derive::identity_rgb(hues, name, theme.identity_lightness);
Some(Color::Rgb(r, g, b))
}
/// Fade a style toward the faint tier, marking a scroll boundary where
/// content continues past the visible edge. Truecolor foregrounds blend
/// halfway to the faint token; anything else (including a style with no
/// foreground) is substituted with it outright. NO_COLOR returns the style
/// untouched, since the fade is a color-only cue.
pub(super) fn edge_fade(style: Style) -> Style {
if super::NO_COLOR.load(super::Ordering::Relaxed) {
return style;
}
match (style.fg, faint()) {
(Some(Color::Rgb(r, g, b)), Color::Rgb(fr, fg, fb)) => {
let (r, g, b) = derive::blend_half((r, g, b), (fr, fg, fb));
style.fg(Color::Rgb(r, g, b))
}
_ => style.fg(faint()),
}
}
/// Apply a foreground color with BOLD + UNDERLINED, respecting NO_COLOR.
pub(super) fn bold_underline_fg(color: Color) -> Style {
if super::NO_COLOR.load(super::Ordering::Relaxed) {
Style::default().add_modifier(Modifier::BOLD | Modifier::UNDERLINED)
} else {
Style::default()
.fg(color)
.add_modifier(Modifier::BOLD | Modifier::UNDERLINED)
}
}
#[cfg(test)]
mod tests {
use super::*;
// These tests read the module-level default (muted) theme; none of
// them may call set_theme, since ACTIVE is process-wide.
#[test]
fn primary_wave_ramps_match_flow_direction_colors() {
let white = Color::Rgb(0xFF, 0xFF, 0xFF);
assert_eq!(rx_wave(0.0), Color::Rgb(0x3B, 0x82, 0xF6));
assert_eq!(tx_wave(0.0), Color::Rgb(0x10, 0xB9, 0x81));
assert_eq!(rx_wave(1.0), white);
assert_eq!(tx_wave(1.0), white);
assert_eq!(accent_wave(1.0), white);
assert_eq!(ok_wave(1.0), white);
}
#[test]
fn heading_outranks_label_in_default_theme() {
// The hierarchy fix: heading (Reset, terminal default fg) must not
// collapse into the label tier (Gray).
assert_ne!(heading(), label());
assert_eq!(heading(), Color::Reset);
assert_eq!(label(), Color::Gray);
}
#[test]
fn default_theme_uses_reversed_fallbacks_for_bg_styles() {
let reversed_bold = Style::default().add_modifier(Modifier::BOLD | Modifier::REVERSED);
assert_eq!(selection_row(), reversed_bold);
assert_eq!(row_highlight(), reversed_bold);
}
#[test]
fn status_bar_alerts_are_colored_text_rather_than_a_filled_band() {
// No status_bg on the default theme, so the row rides the terminal
// background and the signal color lands on the text. A REVERSED band
// here would paint the whole row in the alert color instead.
assert_eq!(status_bar_hints(), Style::default());
assert_eq!(
status_bar_confirm(),
Style::default().fg(warn()).add_modifier(Modifier::BOLD)
);
assert_eq!(
status_bar_error(),
Style::default().fg(err()).add_modifier(Modifier::BOLD)
);
}
#[test]
fn key_hints_use_key_and_label_tokens() {
assert_eq!(
key_hint(),
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD)
);
assert_eq!(key_hint_label(), Style::default().fg(Color::Gray));
}
#[test]
fn on_color_picks_the_readable_foreground() {
// Dark badge background: near-white text; light one: near-black.
assert_eq!(
on_color(Color::Rgb(0x33, 0x46, 0x7C)),
Color::Rgb(240, 240, 240)
);
assert_eq!(
on_color(Color::Rgb(0x00, 0x00, 0x00)),
Color::Rgb(240, 240, 240)
);
assert_eq!(
on_color(Color::Rgb(0xF9, 0xE2, 0xAF)),
Color::Rgb(26, 26, 26)
);
assert_eq!(
on_color(Color::Rgb(0xFF, 0xFF, 0xFF)),
Color::Rgb(26, 26, 26)
);
// ANSI backgrounds cannot be measured; callers render their
// bracket fallback instead of a filled badge.
assert_eq!(on_color(Color::Green), Color::Black);
assert_eq!(on_color(Color::Reset), Color::Black);
}
#[test]
fn shimmer_wave_is_static_accent_without_truecolor() {
// The default (muted, ANSI) theme has no shimmer ramp.
for t in [0.0, 0.25, 0.5, 1.0] {
assert_eq!(shimmer_wave(t), accent());
}
}
#[test]
fn identity_color_is_absent_without_truecolor() {
// The default theme resolves against a non-truecolor terminal.
assert_eq!(identity_color("firefox"), None);
}
#[test]
fn edge_fade_substitutes_faint_for_ansi_foregrounds() {
let style = Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD);
let faded = edge_fade(style);
assert_eq!(faded.fg, Some(faint()));
assert_eq!(faded.add_modifier, Modifier::BOLD);
// A style with no foreground still picks up the faint tier.
assert_eq!(edge_fade(Style::default()).fg, Some(faint()));
// Backgrounds are untouched: only the text fades.
let banded = edge_fade(Style::default().bg(Color::Blue));
assert_eq!(banded.bg, Some(Color::Blue));
}
#[test]
fn default_theme_matches_historical_muted_palette() {
assert_eq!(accent(), Color::Cyan);
assert_eq!(ok(), Color::Green);
assert_eq!(warn(), Color::Yellow);
assert_eq!(err(), Color::Red);
assert_eq!(info(), Color::Blue);
// The special token has no accessor of its own; DNS is its role.
assert_eq!(proto_dns(), Color::Magenta);
assert_eq!(muted(), Color::Gray);
assert_eq!(faint(), Color::DarkGray);
assert_eq!(text(), Color::Reset);
assert_eq!(border(), Color::DarkGray);
assert_eq!(rx(), Color::Green);
assert_eq!(tx(), Color::Blue);
assert!(!is_vivid());
}
}
+135
View File
@@ -0,0 +1,135 @@
//! Inline badge primitives: a solid `pill` for state-like values and a
//! quieter `chip` for metadata. Both return spans, so callers splice them
//! into a `Line` they are already building.
//!
//! A painted band needs a fg/bg pair that is readable on the terminal at
//! hand. Truecolor gives us that (the foreground is picked by luminance,
//! see `theme::on_color`); ANSI-16 palettes do not, since the terminal
//! remaps them freely. Both helpers therefore degrade to a bracketed
//! `[text]` form whenever no tint is available, which occupies exactly
//! the same width as the padded ` text ` band so layouts do not shift.
//! Under NO_COLOR the brackets carry the badge on their own.
use ratatui::{
style::{Color, Modifier, Style},
text::Span,
};
use crate::ui::{NO_COLOR, Ordering, theme};
/// Solid badge: ` text ` painted on `bg`, bold, with the readable
/// foreground [`theme::on_color`] picks for that background. `bg` is
/// meant to be a theme color (a connection state color, for instance).
///
/// Non-RGB backgrounds (every color on an ANSI-16 terminal) render as
/// `[text]` in `bg`'s own color with no band; NO_COLOR renders `[text]`
/// unstyled.
pub(in crate::ui) fn pill(text: &str, bg: Color) -> Vec<Span<'static>> {
pill_spans(text, bg, NO_COLOR.load(Ordering::Relaxed))
}
/// Quiet badge: ` text ` on the theme's selection band, the one fg/bg
/// pair every preset already guarantees (and the contrast guard checks).
///
/// Themes without a truecolor selection tint, and NO_COLOR, render
/// `[text]` in the muted tier instead.
pub(in crate::ui) fn chip(text: &str) -> Vec<Span<'static>> {
chip_spans(text, theme::selection_has_bg())
}
/// [`pill`] with the NO_COLOR flag passed in, so tests can exercise both
/// paths without touching the process-wide flag.
fn pill_spans(text: &str, bg: Color, no_color: bool) -> Vec<Span<'static>> {
if no_color {
return vec![Span::raw(bracketed(text))];
}
match bg {
Color::Rgb(..) => vec![Span::styled(
padded(text),
Style::default()
.fg(theme::on_color(bg))
.bg(bg)
.add_modifier(Modifier::BOLD),
)],
_ => vec![Span::styled(bracketed(text), theme::fg(bg))],
}
}
/// [`chip`] with the selection-tint decision passed in, so tests can
/// exercise both paths whatever theme is active.
fn chip_spans(text: &str, banded: bool) -> Vec<Span<'static>> {
if banded {
// selection_row() is the band itself: tint, plus the theme's
// selection foreground when it sets one.
vec![Span::styled(padded(text), theme::selection_row())]
} else {
// Muted under a color theme, plain under NO_COLOR (theme::fg
// strips the color there).
vec![Span::styled(bracketed(text), theme::fg(theme::muted()))]
}
}
/// Band form: one padding cell on each side.
fn padded(text: &str) -> String {
format!(" {text} ")
}
/// Bracket fallback, the same width as [`padded`].
fn bracketed(text: &str) -> String {
format!("[{text}]")
}
#[cfg(test)]
mod tests {
use super::*;
fn text_of(spans: &[Span<'_>]) -> String {
spans.iter().map(|s| s.content.as_ref()).collect()
}
#[test]
fn pill_paints_a_band_on_rgb_backgrounds() {
let bg = Color::Rgb(20, 120, 60);
let spans = pill_spans("ESTABLISHED", bg, false);
assert_eq!(text_of(&spans), " ESTABLISHED ");
let style = spans[0].style;
assert_eq!(style.bg, Some(bg));
assert_eq!(style.fg, Some(theme::on_color(bg)));
assert!(style.add_modifier.contains(Modifier::BOLD));
}
#[test]
fn pill_brackets_non_rgb_backgrounds() {
let spans = pill_spans("CLOSED", Color::Red, false);
assert_eq!(text_of(&spans), "[CLOSED]");
assert_eq!(spans[0].style.bg, None);
assert_eq!(spans[0].style.fg, Some(Color::Red));
}
#[test]
fn pill_is_plain_under_no_color() {
let spans = pill_spans("ESTABLISHED", Color::Rgb(20, 120, 60), true);
assert_eq!(text_of(&spans), "[ESTABLISHED]");
assert_eq!(spans[0].style, Style::default());
}
#[test]
fn chip_paints_the_selection_band_when_tinted() {
let spans = chip_spans("rtt 34 ms", true);
assert_eq!(text_of(&spans), " rtt 34 ms ");
assert_eq!(spans[0].style, theme::selection_row());
}
#[test]
fn chip_brackets_without_a_selection_tint() {
let spans = chip_spans("rtt 34 ms", false);
assert_eq!(text_of(&spans), "[rtt 34 ms]");
assert_eq!(spans[0].style.bg, None);
}
#[test]
fn both_forms_have_the_same_width() {
assert_eq!(padded("port:443").chars().count(), 10);
assert_eq!(bracketed("port:443").chars().count(), 10);
}
}
+12 -20
View File
@@ -1,7 +1,10 @@
//! Filter input line shown above the status bar whenever the user
//! has either entered filter mode or has a persistent filter active.
//! A single borderless row: accent " / " prompt, the query, and a
//! muted right-side hint with the relevant keys.
//! Filter input line shown above the status bar while the user is typing
//! a filter. A single borderless row: accent " / " prompt, the query with
//! its cursor, and a muted right-side hint.
//!
//! It is an editing surface, not a status readout: once a query is
//! confirmed the row is gone, and the Connections title chip plus the tab
//! bar's activity dot carry the filter state instead.
use ratatui::{
Frame,
@@ -16,22 +19,11 @@ use crate::ui::{UIState, theme};
pub(crate) const FILTER_INPUT_HEIGHT: u16 = 1;
pub(in crate::ui) fn draw_filter_input(f: &mut Frame, ui_state: &UIState, area: Rect) {
let query = if ui_state.filter_mode {
// Show cursor when in filter mode
let mut display_query = ui_state.filter_query.clone();
if ui_state.filter_cursor_position <= display_query.len() {
display_query.insert(ui_state.filter_cursor_position, '|');
}
display_query
} else {
ui_state.filter_query.clone()
};
let hint = if ui_state.filter_mode {
"↑↓ navigate · Enter confirm · Esc cancel "
} else {
"filter active · Esc clears "
};
let mut query = ui_state.filter_query.clone();
if ui_state.filter_cursor_position <= query.len() {
query.insert(ui_state.filter_cursor_position, '|');
}
let hint = "↑↓ navigate · Enter confirm · Esc cancel ";
let line = Line::from(vec![
Span::styled(" / ", theme::bold_fg(theme::accent())),
+129 -17
View File
@@ -1,17 +1,38 @@
//! Truecolor horizontal bars using the same dark-to-bright ramps as the
//! braille traffic graphs.
//! braille traffic graphs. Bars have sub-cell precision: the tip renders
//! the fractional cell with an eighth-block glyph, and the unfilled
//! remainder is a quiet dotted track. Under NO_COLOR the block-vs-dot
//! glyph contrast keeps the bar legible on its own.
use ratatui::{style::Color, text::Span};
use crate::ui::theme;
/// Partial-cell tip glyphs; index i renders (i + 1)/8 of a cell.
const EIGHTHS: [&str; 7] = [
"\u{258F}", "\u{258E}", "\u{258D}", "\u{258C}", "\u{258B}", "\u{258A}", "\u{2589}",
];
/// Quiet track glyph (middle dot) for the unfilled remainder.
const TRACK: &str = "\u{00B7}";
pub(in crate::ui) fn spans(
fraction: f64,
width: usize,
ramp: fn(f64) -> Color,
) -> Vec<Span<'static>> {
let filled = (fraction.clamp(0.0, 1.0) * width as f64).round() as usize;
from_filled(filled, width, ramp)
let cells = fraction.clamp(0.0, 1.0) * width as f64;
let mut whole = cells.floor() as usize;
let mut tip_eighths = ((cells - whole as f64) * 8.0).round() as usize;
if tip_eighths == 8 {
whole += 1;
tip_eighths = 0;
}
if whole >= width {
whole = width;
tip_eighths = 0;
}
render(whole, tip_eighths, width, ramp)
}
pub(in crate::ui) fn from_filled(
@@ -19,22 +40,113 @@ pub(in crate::ui) fn from_filled(
width: usize,
ramp: fn(f64) -> Color,
) -> Vec<Span<'static>> {
let filled = filled.min(width);
let mut spans: Vec<Span> = (0..filled)
.map(|i| {
let t = if filled > 1 {
i as f64 / (filled - 1) as f64
} else {
1.0
};
Span::styled("", theme::fg(ramp(0.15 + 0.85 * t)))
})
.collect();
if width > filled {
render(filled.min(width), 0, width, ramp)
}
/// `whole` full cells, then an optional eighth-block tip (`tip_eighths` in
/// 1..=7), then the track. The gradient walks every lit cell including the
/// tip, so the crest always sits at the bar's leading edge.
fn render(
whole: usize,
tip_eighths: usize,
width: usize,
ramp: fn(f64) -> Color,
) -> Vec<Span<'static>> {
let lit = whole + usize::from(tip_eighths > 0);
let color_at = |i: usize| {
let t = if lit > 1 {
i as f64 / (lit - 1) as f64
} else {
1.0
};
ramp(0.15 + 0.85 * t)
};
let mut spans: Vec<Span> = Vec::with_capacity(lit + 1);
for i in 0..whole {
spans.push(Span::styled("", theme::fg(color_at(i))));
}
if tip_eighths > 0 {
spans.push(Span::styled(
"".repeat(width - filled),
theme::fg(theme::muted()),
EIGHTHS[tip_eighths - 1],
theme::fg(color_at(whole)),
));
}
if width > lit {
// faint(): a neutral dim tier on every preset. border() would leak
// chrome colors into data bars (Vivid's border is Magenta).
spans.push(Span::styled(
TRACK.repeat(width - lit),
theme::fg(theme::faint()),
));
}
spans
}
#[cfg(test)]
mod tests {
use super::*;
use ratatui::style::Color;
fn flat_ramp(_t: f64) -> Color {
Color::Green
}
fn rendered_width(spans: &[Span<'_>]) -> usize {
spans.iter().map(|s| s.content.chars().count()).sum()
}
fn text(spans: &[Span<'_>]) -> String {
spans.iter().map(|s| s.content.as_ref()).collect()
}
#[test]
fn empty_bar_is_all_track() {
let s = spans(0.0, 4, flat_ramp);
assert_eq!(text(&s), "····");
assert_eq!(rendered_width(&s), 4);
}
#[test]
fn full_bar_has_no_track() {
let s = spans(1.0, 4, flat_ramp);
assert_eq!(text(&s), "████");
assert_eq!(rendered_width(&s), 4);
}
#[test]
fn fractional_tip_uses_eighth_blocks() {
// 2.5 cells over width 4: two full blocks, a half-block tip, one track cell.
let s = spans(0.625, 4, flat_ramp);
assert_eq!(text(&s), "██▌·");
assert_eq!(rendered_width(&s), 4);
}
#[test]
fn tip_rounds_up_to_full_cell() {
// 1.99 cells rounds to 2 full blocks, never a stray tip glyph.
let s = spans(0.995, 2, flat_ramp);
assert_eq!(text(&s), "██");
}
#[test]
fn tiny_fraction_shows_one_eighth() {
let s = spans(0.01, 10, flat_ramp);
assert_eq!(text(&s), "▏·········");
}
#[test]
fn from_filled_stays_whole_cell() {
let s = from_filled(2, 5, flat_ramp);
assert_eq!(text(&s), "██···");
let overshoot = from_filled(9, 5, flat_ramp);
assert_eq!(text(&overshoot), "█████");
}
#[test]
fn width_is_preserved_across_fractions() {
for i in 0..=100 {
let s = spans(i as f64 / 100.0, 7, flat_ramp);
assert_eq!(rendered_width(&s), 7, "fraction {i}%");
}
}
}
+68 -7
View File
@@ -1,13 +1,13 @@
//! Startup splash shown while packet capture initializes: a breathing
//! accent glow, a spinning braille spinner, and an animated wave in
//! the same gradient family as the traffic graphs.
//! accent glow, a spinning braille spinner, a shimmer running through
//! the headline, and an animated wave in the same gradient family as
//! the traffic graphs.
use std::time::Duration;
use ratatui::{
Frame,
layout::{Constraint, Direction, Layout, Rect},
style::Style,
text::{Line, Span},
widgets::Paragraph,
};
@@ -22,6 +22,12 @@ const SPINNER: [char; 8] = ['⣾', '⣽', '⣻', '⢿', '⡿', '⣟', '⣯', '
/// byte-identical (and the first frame is deterministic for tests).
pub(in crate::ui) const FRAME_MS: u64 = 120;
/// Headline shimmer: cycles per second of the travelling highlight.
const SHIMMER_SPEED: f64 = 0.7;
/// Phase offset between neighboring characters, in cycles. Small enough
/// that the crest reads as one band sliding along the text.
const SHIMMER_STEP: f64 = 0.045;
pub(in crate::ui) fn draw_loading_screen(f: &mut Frame, elapsed: Duration) {
let chunks = Layout::default()
.direction(Direction::Vertical)
@@ -40,12 +46,12 @@ pub(in crate::ui) fn draw_loading_screen(f: &mut Frame, elapsed: Duration) {
let glow = theme::accent_wave(0.35 + 0.55 * breath);
let spinner = SPINNER[(elapsed.as_millis() as u64 / FRAME_MS) as usize % SPINNER.len()];
let mut headline = vec![Span::styled(format!("{spinner} "), theme::bold_fg(glow))];
headline.extend(shimmer_spans("Loading network connections...", secs));
let loading_text = vec![
Line::from(""),
Line::from(vec![
Span::styled(format!("{spinner} "), theme::bold_fg(glow)),
Span::styled("Loading network connections...", Style::default()),
]),
Line::from(headline),
Line::from(""),
Line::from(vec![Span::styled(
"Preparing capture and process attribution",
@@ -92,3 +98,58 @@ pub(in crate::ui) fn draw_loading_screen(f: &mut Frame, elapsed: Duration) {
f.render_widget(Paragraph::new(lines), wave_area);
}
}
/// One span per character of `text`, each sampling the accent shimmer at
/// its own phase so a highlight travels along the line. `secs` is the
/// splash clock; the caller quantizes it, so a redraw inside one
/// animation frame repeats byte for byte.
///
/// On themes and terminals without truecolor `shimmer_wave` returns the
/// plain accent for every phase, which makes the headline a static
/// accent line; under NO_COLOR `theme::fg` strips it back to plain text.
fn shimmer_spans(text: &str, secs: f64) -> Vec<Span<'static>> {
text.chars()
.enumerate()
.map(|(i, ch)| {
let phase = secs * SHIMMER_SPEED - i as f64 * SHIMMER_STEP;
let t = 0.5 + 0.5 * (phase * std::f64::consts::TAU).sin();
Span::styled(ch.to_string(), theme::fg(theme::shimmer_wave(t)))
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn shimmer_preserves_the_text() {
let spans = shimmer_spans("Loading network connections...", 0.0);
assert_eq!(
spans.len(),
"Loading network connections...".chars().count()
);
let text: String = spans.iter().map(|s| s.content.as_ref()).collect();
assert_eq!(text, "Loading network connections...");
}
#[test]
fn shimmer_is_char_safe() {
let spans = shimmer_spans("héllo…", 1.5);
assert_eq!(spans.len(), 6);
let text: String = spans.iter().map(|s| s.content.as_ref()).collect();
assert_eq!(text, "héllo…");
}
#[test]
fn shimmer_is_static_without_truecolor() {
// The default (muted) theme is ANSI, so every phase resolves to
// the same accent color and the splash simply stops animating.
let spans = shimmer_spans("abc", 0.0);
let later = shimmer_spans("abc", 3.0);
for (a, b) in spans.iter().zip(later.iter()) {
assert_eq!(a.style, b.style);
assert_eq!(a.style, theme::fg(theme::accent()));
}
}
}
+3 -1
View File
@@ -1,7 +1,9 @@
//! Reusable chrome widgets that wrap the main content area:
//! tabs bar at the top, filter input + status bar at the bottom,
//! and the loading screen shown during startup.
//! and the loading screen shown during startup, plus the small
//! inline primitives (badges, bars, sparklines) the tabs compose with.
pub(super) mod badge;
pub(super) mod braille_graph;
pub(super) mod filter_input;
pub(super) mod glow_bar;
+46 -15
View File
@@ -5,12 +5,15 @@
use ratatui::{
Frame,
layout::Rect,
style::{Color, Style},
widgets::{Scrollbar, ScrollbarOrientation, ScrollbarState},
};
use crate::ui::theme;
/// Scrollbar thumb: a right half block, so the bar reads as a thin rule on
/// the outer edge of its column instead of a full-width slab.
const THUMB: &str = "\u{2590}";
/// Render a vertical scrollbar on the right edge of `area` when the
/// content overflows the viewport. `position` is the scroll offset of
/// the topmost visible row; `viewport` is the number of rows currently
@@ -39,27 +42,31 @@ pub(in crate::ui) fn draw_scrollbar(
let mut scrollbar_state = ScrollbarState::new(scroll_positions)
.position(position)
.viewport_content_length(viewport);
// Thumb in the terminal's default foreground so it matches the
// content; only the track recedes into the chrome gray. The fg
// must be set explicitly (`Color::Reset`), not left empty: ratatui
// styles are patches, and an empty patch lets the thumb inherit
// whatever color the underlying cells already have (on the Help
// tab the scrollbar rides the panel border, which is gray, and the
// thumb would blend into the track).
// A half-block thumb in the accent color, riding an unpainted track.
// The thumb alone carries both position and proportion, so the track
// rule only adds a second vertical line beside the pane chrome that
// already has one. The half block hugs the outer edge of its column,
// keeping the bar clear of the right-aligned data beside it.
//
// The thumb fg must be set explicitly, not left empty: ratatui styles
// are patches, and an empty patch lets the thumb inherit whatever color
// the underlying cells already have (on the Help tab the scrollbar
// rides the panel border, which is gray, and the thumb would vanish
// into it). Under NO_COLOR the glyph keeps it legible on its own.
let scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight)
.begin_symbol(None)
.end_symbol(None)
.track_style(theme::fg(theme::border()))
.thumb_style(Style::default().fg(Color::Reset));
.track_symbol(None)
.thumb_symbol(THUMB)
.thumb_style(theme::fg(theme::accent()));
f.render_stateful_widget(scrollbar, area, &mut scrollbar_state);
}
#[cfg(test)]
mod tests {
/// Render `draw_scrollbar` into a test buffer and report whether
/// any non-space glyph landed in the rightmost column (the scrollbar
/// track/thumb sits on the right border).
fn scrollbar_renders(total_rows: usize, position: usize, viewport: usize) -> bool {
/// Glyphs `draw_scrollbar` paints down the rightmost column (the
/// scrollbar track/thumb sits on the right border).
fn scrollbar_glyphs(total_rows: usize, position: usize, viewport: usize) -> Vec<String> {
use ratatui::Terminal;
use ratatui::backend::TestBackend;
use ratatui::layout::Rect;
@@ -73,7 +80,31 @@ mod tests {
.expect("draw scrollbar");
let buffer = terminal.backend().buffer();
let right_x = 19;
(0..12).any(|y| buffer[(right_x, y)].symbol() != " ")
(0..12)
.map(|y| buffer[(right_x, y)].symbol().to_string())
.collect()
}
/// Whether the scrollbar painted anything at all.
fn scrollbar_renders(total_rows: usize, position: usize, viewport: usize) -> bool {
scrollbar_glyphs(total_rows, position, viewport)
.iter()
.any(|glyph| glyph != " ")
}
#[test]
fn thumb_is_a_thin_bar_rather_than_a_full_block() {
let glyphs = scrollbar_glyphs(100, 0, 10);
assert!(
glyphs.iter().any(|glyph| glyph == super::THUMB),
"no thumb painted: {glyphs:?}"
);
assert!(
!glyphs
.iter()
.any(|glyph| glyph == ratatui::symbols::block::FULL),
"thumb still renders as a full block: {glyphs:?}"
);
}
#[test]
+348 -45
View File
@@ -1,38 +1,196 @@
//! Bottom status line: shows tab-specific keybinds by default, or
//! transient confirmation prompts ("press q again to quit"),
//! filtered-count messages, clipboard feedback, and capture failures
//! (which claim a second row when they do not fit on one).
//! Bottom status line: the active tab's context actions on the left and a
//! fixed global cluster (help, quit) pinned right, or transient
//! confirmation prompts ("press q again to quit"), clipboard feedback, and
//! capture failures (which claim a second row when they do not fit on one).
//!
//! Hints follow a keycap grammar: the key in `theme::key_hint()`, its label
//! in `theme::key_hint_label()`, two spaces between hints. The global
//! cluster is reserved before any context action is placed, so `q quit`
//! never falls off the right edge. When the terminal is too narrow to spell
//! every action out, the labels go first and the keys stand alone; only
//! then are context actions dropped from the end. The exhaustive keymap
//! lives on the Help tab.
use ratatui::{Frame, layout::Rect, widgets::Paragraph};
use ratatui::{
Frame,
layout::Rect,
text::{Line, Span},
widgets::Paragraph,
};
use crate::ui::{UIState, theme};
/// Status bar text per tab. Only Overview exposes connection-list shortcuts
/// (/, a, t, c); other tabs show just what actually works there.
fn default_status_line(ui_state: &UIState) -> &'static str {
/// One keycap hint: the key as typed and the action it triggers.
type Hint = (&'static str, &'static str);
/// Pinned to the right edge, and the last thing dropped: the two keys worth
/// knowing when nothing else makes sense.
const GLOBAL_HINTS: [Hint; 2] = [("h", "help"), ("q", "quit")];
/// Right-edge cluster while a filter is being typed. `h` and `q` would type
/// a character into the query rather than help or quit, so the two keys that
/// end the mode take their place.
const FILTER_HINTS: [Hint; 2] = [("enter", "apply"), ("esc", "cancel")];
/// Cells between two hints inside a group.
const HINT_GAP: usize = 2;
/// Minimum blank cells between the context actions and the global cluster,
/// so the two groups never read as one run of hints.
const CLUSTER_GAP: usize = 3;
/// How many context actions must still fit with their labels spelled out
/// before the bar gives up on labels entirely. Below this it is showing so
/// few actions that a full row of bare keys carries more.
const MIN_LABELED: usize = 3;
/// Context actions for the active tab, most useful first. Tab navigation is
/// deliberately absent: the numbered titles in the tab bar already advertise
/// it, and the footer's room is better spent on actions that appear nowhere
/// else on screen. Copy drops out entirely when the clipboard is out of
/// reach, rather than advertising a key that can only fail.
fn context_hints(ui_state: &UIState, clipboard: bool) -> Vec<Hint> {
// While a filter is being typed the key handler routes every character
// into the query, so the tab's own actions are unreachable: advertising
// them would name keys that type a letter instead. Only what the filter
// editor actually handles is offered.
if ui_state.filter_mode {
return vec![("\u{2191}\u{2193}", "select")];
}
match ui_state.selected_tab {
// Overview
0 => {
" 'h' help | 1-5 jump | Tab/[/] cycle | '/' filter | 'a' group | 't' history | 'i' info | 'c' copy"
let mut hints = Vec::new();
// Clearing outranks everything else while a filter is on.
if ui_state.has_active_filter() {
hints.push(("esc", "clear filter"));
}
hints.extend([
("\u{2191}\u{2193}", "select"),
("/", "filter"),
("a", "group"),
("t", "history"),
("i", "info"),
]);
if clipboard {
hints.push(("c", "copy"));
}
hints
}
// Details
1 => {
" 'h' help | 1-5 jump | Tab/[/] cycle | j/k prev/next | Ctrl-d/u scroll | 'c' copy remote addr | Esc back"
let mut hints = vec![("j/k", "prev/next")];
// Ctrl+D/U only moves when the record outgrows its pane, so on a
// tall terminal the hint would advertise a no-op.
if ui_state.details_scroll.can_scroll() {
hints.push(("ctrl-d/u", "scroll"));
}
if clipboard {
hints.push(("c", "copy remote addr"));
}
hints.push(("esc", "back"));
hints
}
// Activity, interface list
2 if ui_state.activity_show_interfaces => vec![
("j/k", "scroll"),
("i", "process activity"),
("esc", "back"),
],
// Activity
2 if ui_state.activity_show_interfaces => {
" 'h' help | 1-5 jump | Tab/[/] cycle | j/k scroll | 'i' process activity | Esc back"
}
2 => {
" 'h' help | 1-5 jump | Tab/[/] cycle | 'd' TX/RX | 's' sort | 'S' order | 'i' interfaces | Esc back"
}
2 => vec![
("d", "tx/rx"),
("s", "sort"),
("S", "order"),
("i", "interfaces"),
("esc", "back"),
],
// Help
4 => " 'h' help | 1-5 jump | Tab/[/] cycle | j/k scroll | Esc back to Overview",
4 => vec![("j/k", "scroll"), ("esc", "back")],
// Graph
_ => " 'h' help | 1-5 jump | Tab/[/] cycle | Esc back to Overview",
_ => vec![("esc", "back")],
}
}
/// Spans for one hint. Without `labels` the key stands alone, the fallback
/// for a terminal too narrow to spell the action out.
fn hint_spans(hint: Hint, labels: bool) -> Vec<Span<'static>> {
let (key, label) = hint;
let mut spans = vec![Span::styled(key, theme::key_hint())];
if labels {
spans.push(Span::raw(" "));
spans.push(Span::styled(label, theme::key_hint_label()));
}
spans
}
fn hint_width(hint: Hint, labels: bool) -> usize {
let (key, label) = hint;
let mut width = key.chars().count();
if labels {
width += 1 + label.chars().count();
}
width
}
/// Cells a run of hints occupies, gaps included.
fn group_width(hints: &[Hint], labels: bool) -> usize {
hints
.iter()
.enumerate()
.map(|(i, hint)| {
let gap = if i == 0 { 0 } else { HINT_GAP };
gap + hint_width(*hint, labels)
})
.sum()
}
fn push_group(spans: &mut Vec<Span<'static>>, hints: &[Hint], labels: bool) {
for (i, hint) in hints.iter().enumerate() {
if i > 0 {
spans.push(Span::raw(" ".repeat(HINT_GAP)));
}
spans.extend(hint_spans(*hint, labels));
}
}
/// Lay the bar out: context actions from the left, `cluster` flush right.
/// Tried once with every label spelled out, then with keys alone, dropping
/// context actions from the end only when even that overflows.
fn hint_line(context: &[Hint], cluster: &[Hint], width: u16) -> Line<'static> {
let width = width as usize;
for labels in [true, false] {
let global = group_width(cluster, labels);
// One pad cell at each edge, plus the reserved global cluster.
let Some(room) = width.checked_sub(global + 2) else {
continue;
};
let mut kept: Vec<Hint> = Vec::new();
let mut used = 0usize;
for hint in context {
let gap = if kept.is_empty() { 0 } else { HINT_GAP };
let needed = gap + hint_width(*hint, labels);
if used + needed + CLUSTER_GAP > room {
break;
}
used += needed;
kept.push(*hint);
}
// A labeled action says what it does, which is the whole point of the
// footer, so trailing actions are dropped to keep the labels. Only
// once too few survive is the row better off as bare keys.
if labels && kept.len() < context.len().min(MIN_LABELED) {
continue;
}
let mut spans = vec![Span::raw(" ")];
push_group(&mut spans, &kept, labels);
spans.push(Span::raw(" ".repeat(room - used)));
push_group(&mut spans, cluster, labels);
spans.push(Span::raw(" "));
return Line::from(spans);
}
// Narrower than the cluster itself: show the one key that ends the mode.
Line::from(hint_spans(cluster[cluster.len() - 1], false))
}
/// Actionable half of a capture-failure line.
const CAPTURE_RECOVERY_HINT: &str = "Restart rustnet to resume. Press 'q' to quit.";
@@ -87,7 +245,7 @@ fn capture_error_text(cause: &str, width: u16, height: u16) -> String {
pub(in crate::ui) fn draw_status_bar(
f: &mut Frame,
ui_state: &UIState,
connection_count: usize,
clipboard: bool,
capture_error: Option<&str>,
area: Rect,
) {
@@ -97,36 +255,181 @@ pub(in crate::ui) fn draw_status_bar(
.filter(|(_, time)| time.elapsed().as_secs() < 3)
.map(|(message, _)| message);
let status = if ui_state.quit_confirmation {
" Press 'q' again to quit or any other key to cancel ".to_string()
let status_bar = if ui_state.quit_confirmation {
Paragraph::new(" Press 'q' again to quit or any other key to cancel ")
.style(theme::status_bar_confirm())
} else if ui_state.clear_confirmation {
" Press 'x' again to clear all connections or any other key to cancel ".to_string()
Paragraph::new(" Press 'x' again to clear all connections or any other key to cancel ")
.style(theme::status_bar_confirm())
} else if let Some(message) = clipboard_message {
format!(" {message} ")
Paragraph::new(format!(" {message} ")).style(theme::status_bar_success())
} else if let Some(error) = capture_error {
capture_error_text(error, area.width, area.height)
} else if ui_state.has_active_filter() {
format!(
" 'h' help | 1-5 jump | Tab/[/] cycle | Showing {} filtered connections (Esc to clear) ",
connection_count
)
Paragraph::new(capture_error_text(error, area.width, area.height))
.style(theme::status_bar_error())
} else {
default_status_line(ui_state).to_string()
let cluster: &[Hint] = if ui_state.filter_mode {
&FILTER_HINTS
} else {
&GLOBAL_HINTS
};
Paragraph::new(hint_line(
&context_hints(ui_state, clipboard),
cluster,
area.width,
))
.style(theme::status_bar_default())
};
let style = if ui_state.quit_confirmation || ui_state.clear_confirmation {
theme::status_bar_confirm()
} else if clipboard_message.is_some() {
theme::status_bar_success()
} else if capture_error.is_some() {
theme::status_bar_error()
} else {
theme::status_bar_default()
};
let status_bar = Paragraph::new(status)
.style(style)
.alignment(ratatui::layout::Alignment::Left);
f.render_widget(status_bar, area);
f.render_widget(status_bar.alignment(ratatui::layout::Alignment::Left), area);
}
#[cfg(test)]
mod tests {
use super::*;
fn advertises(hints: &[Hint], key: &str) -> bool {
hints.iter().any(|(hint_key, _)| *hint_key == key)
}
fn rendered(line: &Line<'_>) -> String {
line.spans
.iter()
.map(|span| span.content.as_ref())
.collect()
}
#[test]
fn details_advertises_pane_scrolling_only_once_the_pane_scrolls() {
let ui_state = UIState {
selected_tab: 1,
..Default::default()
};
assert!(!advertises(&context_hints(&ui_state, true), "ctrl-d/u"));
// A render that reports headroom turns the hint on.
ui_state.details_scroll.clamp_for_render(12);
assert!(advertises(&context_hints(&ui_state, true), "ctrl-d/u"));
}
#[test]
fn overview_spends_its_room_on_actions_visible_nowhere_else() {
let hints = context_hints(&UIState::default(), true);
assert_eq!(hints.first().map(|(key, _)| *key), Some("\u{2191}\u{2193}"));
assert!(advertises(&hints, "/"));
// Tab navigation is advertised by the numbered tab bar itself.
assert!(!advertises(&hints, "1-5"));
assert!(!advertises(&hints, "tab"));
}
#[test]
fn clearing_outranks_every_other_overview_action() {
let ui_state = UIState {
filter_query: "port:443".to_string(),
..Default::default()
};
assert_eq!(
context_hints(&ui_state, true).first(),
Some(&("esc", "clear filter"))
);
}
#[test]
fn the_global_cluster_survives_every_width() {
let context = context_hints(&UIState::default(), true);
for width in [200u16, 120, 80, 60, 40, 24, 12] {
let line = rendered(&hint_line(&context, &GLOBAL_HINTS, width));
assert!(line.contains('q'), "quit dropped at {width}: {line:?}");
assert!(
line.chars().count() <= width as usize,
"overflowed {width}: {line:?}"
);
}
}
#[test]
fn labels_are_dropped_before_context_actions_are() {
let context = context_hints(&UIState::default(), true);
// Wide enough to spell every action out.
let wide = rendered(&hint_line(&context, &GLOBAL_HINTS, 120));
assert!(wide.contains("filter"), "{wide:?}");
assert!(wide.contains("quit"), "{wide:?}");
// Too narrow for labels, yet every key is still there.
let narrow = rendered(&hint_line(&context, &GLOBAL_HINTS, 40));
assert!(!narrow.contains("filter"), "{narrow:?}");
for (key, _) in &context {
assert!(narrow.contains(key), "{key} dropped: {narrow:?}");
}
}
#[test]
fn labels_survive_a_standard_terminal_by_dropping_trailing_actions() {
// 80 columns with a filter on is the tightest realistic case: the
// labels have to survive it, even if the last action does not.
let ui_state = UIState {
filter_query: "port:443".to_string(),
..Default::default()
};
let context = context_hints(&ui_state, true);
let line = rendered(&hint_line(&context, &GLOBAL_HINTS, 80));
assert!(line.contains("clear filter"), "{line:?}");
assert!(line.contains("select"), "{line:?}");
assert!(line.ends_with("q quit "), "{line:?}");
assert!(
!line.contains("copy"),
"the last action should have gone before the labels: {line:?}"
);
}
#[test]
fn copy_is_not_offered_when_the_clipboard_is_out_of_reach() {
for tab in [0, 1] {
let ui_state = UIState {
selected_tab: tab,
..Default::default()
};
assert!(
advertises(&context_hints(&ui_state, true), "c"),
"tab {tab} should offer copy when the clipboard works"
);
assert!(
!advertises(&context_hints(&ui_state, false), "c"),
"tab {tab} still offers a copy that can only fail"
);
}
// Everything else on the tab survives losing copy.
let sandboxed = context_hints(&UIState::default(), false);
assert!(advertises(&sandboxed, "/"));
assert!(advertises(&sandboxed, "i"));
}
#[test]
fn filter_editing_only_offers_keys_the_editor_handles() {
let editing = UIState {
filter_mode: true,
filter_query: "port:44".to_string(),
..Default::default()
};
let hints = context_hints(&editing, true);
// Every Char key goes into the query while typing, so none of the
// tab's own actions may be named here.
for key in ["/", "a", "t", "i", "c"] {
assert!(
!advertises(&hints, key),
"{key} types a character while filtering, but is advertised"
);
}
// The keys that end the mode move to the right-edge cluster.
assert_eq!(FILTER_HINTS.map(|(key, _)| key), ["enter", "esc"]);
}
#[test]
fn the_global_cluster_sits_flush_right() {
let line = rendered(&hint_line(
&context_hints(&UIState::default(), true),
&GLOBAL_HINTS,
120,
));
assert!(line.ends_with("q quit "), "{line:?}");
}
}
+137 -5
View File
@@ -1,8 +1,13 @@
//! Top tab bar a borderless two-row strip: the brand + numbered tab
//! Top tab bar: a borderless two-row strip: the brand + numbered tab
//! titles on the first row, and an underline rule on the second with a
//! heavy accent segment under the active tab. The heavy ━ vs light ─
//! glyph difference keeps the active tab readable under NO_COLOR.
//! Click regions cover both rows so a click on the underline works too.
//!
//! The title row also carries two status cues: a `•` after a tab title
//! whose tab has something running (Overview while a filter narrows the
//! list), and a right-aligned capture cluster (`● <iface> · <link>`)
//! whose dot turns red when packet capture has failed.
use ratatui::{
Frame,
@@ -19,6 +24,8 @@ pub(crate) const TAB_COUNT: usize = TAB_TITLES.len();
/// Index of the Help tab. Lets `UIState::jump_to_tab` keep `show_help` in
/// sync without re-checking `TAB_TITLES` at the call site.
pub(crate) const HELP_TAB_INDEX: usize = TAB_COUNT - 1;
/// Index of the Overview tab, the only tab with an activity dot so far.
const OVERVIEW_TAB_INDEX: usize = 0;
/// Height of the tab bar in rows (titles + underline).
pub(crate) const TABS_BAR_HEIGHT: u16 = 2;
@@ -26,10 +33,67 @@ pub(crate) const TABS_BAR_HEIGHT: u16 = 2;
const BRAND: &str = " rustnet ";
/// Gap between tab titles, in cells.
const TAB_GAP: u16 = 3;
/// Marks a tab that is doing something the user set up (Overview with an
/// active filter). Rendered right after the title.
const ACTIVITY_DOT: &str = "";
/// Blank cells kept between the last tab title and the capture cluster.
const CLUSTER_GAP: usize = 2;
/// What the right-aligned capture cluster reports: which interface is
/// being captured, its link layer, and whether capture is still running.
/// Built by `crate::ui::draw` from the same `App` accessors the status
/// bar and the Overview sidebar already read.
#[derive(Debug, Default, Clone, Copy)]
pub(in crate::ui) struct CaptureCluster<'a> {
/// Interface name, `None` until the capture thread reports one.
pub interface: Option<&'a str>,
/// Link layer of that interface ("Ethernet"), appended when it fits.
pub link_type: Option<&'a str>,
/// Set while the app has a current capture failure.
pub failed: bool,
}
/// Spans for the capture cluster, or `None` when there is no interface to
/// show or `room` cells cannot hold it. Degrades in two steps: the link
/// layer suffix is dropped first, then the cluster as a whole, so tab
/// titles never collide with it.
fn capture_cluster_spans(capture: &CaptureCluster<'_>, room: usize) -> Option<Vec<Span<'static>>> {
let interface = capture.interface?;
let dot_style = if capture.failed {
theme::fg(theme::err())
} else {
theme::fg(theme::ok())
};
let mut spans = vec![
Span::styled("", dot_style),
Span::styled(interface.to_string(), theme::fg(theme::text())),
];
// Measured the same way the caller pads the row: display width, so a
// wide glyph in an interface name cannot overrun the reserved room.
let base = cluster_width(&spans);
if base > room {
return None;
}
if let Some(link_type) = capture.link_type {
let suffix = Span::styled(format!(" · {link_type}"), theme::fg(theme::muted()));
if base + suffix.width() <= room {
spans.push(suffix);
}
}
Some(spans)
}
/// Rendered width of the capture cluster, in cells.
fn cluster_width(spans: &[Span<'static>]) -> usize {
spans.iter().map(Span::width).sum()
}
pub(in crate::ui) fn draw_tabs(
f: &mut Frame,
ui_state: &UIState,
capture: &CaptureCluster<'_>,
area: Rect,
click_regions: &mut ClickableRegions,
) {
@@ -44,8 +108,16 @@ pub(in crate::ui) fn draw_tabs(
for (i, title) in TAB_TITLES.iter().enumerate() {
// Numbered titles: the 1-5 jump shortcut becomes discoverable.
let label = format!("{} {}", i + 1, title);
let label_width = label.chars().count() as u16;
let active = i == ui_state.selected_tab;
let dotted = i == OVERVIEW_TAB_INDEX && ui_state.is_filtering();
// The dot is part of the label: the underline and the click
// region have to grow with it.
let dot_width = if dotted {
ACTIVITY_DOT.chars().count()
} else {
0
};
let label_width = (label.chars().count() + dot_width) as u16;
title_spans.push(Span::raw(gap.clone()));
if active {
@@ -57,6 +129,9 @@ pub(in crate::ui) fn draw_tabs(
} else {
title_spans.push(Span::styled(label.clone(), theme::fg(theme::muted())));
}
if dotted {
title_spans.push(Span::styled(ACTIVITY_DOT, theme::fg(theme::accent())));
}
underline_spans.push(Span::styled(
"".repeat(TAB_GAP as usize),
@@ -79,11 +154,21 @@ pub(in crate::ui) fn draw_tabs(
x_offset += TAB_GAP + label_width;
}
let used = x_offset.saturating_sub(area.x) as usize;
// Right-align the capture cluster on the title row, keeping at least
// CLUSTER_GAP cells clear of the last title.
let room = (area.width as usize).saturating_sub(used + CLUSTER_GAP);
if let Some(cluster) = capture_cluster_spans(capture, room) {
let pad = (area.width as usize).saturating_sub(used + cluster_width(&cluster));
title_spans.push(Span::raw(" ".repeat(pad)));
title_spans.extend(cluster);
}
// Extend the rule to the right edge of the bar.
let used: u16 = x_offset.saturating_sub(area.x);
if area.width > used {
if area.width as usize > used {
underline_spans.push(Span::styled(
"".repeat((area.width - used) as usize),
"".repeat(area.width as usize - used),
theme::fg(theme::border()),
));
}
@@ -96,3 +181,50 @@ pub(in crate::ui) fn draw_tabs(
f.render_widget(underline, Rect::new(area.x, area.y + 1, area.width, 1));
}
}
#[cfg(test)]
mod tests {
use super::*;
fn text_of(spans: &[Span<'_>]) -> String {
spans.iter().map(|span| span.content.as_ref()).collect()
}
#[test]
fn cluster_is_empty_without_an_interface() {
let capture = CaptureCluster::default();
assert!(capture_cluster_spans(&capture, 80).is_none());
}
#[test]
fn cluster_shows_interface_and_link_type() {
let capture = CaptureCluster {
interface: Some("eth0"),
link_type: Some("Ethernet"),
failed: false,
};
let spans = capture_cluster_spans(&capture, 40).expect("cluster fits");
assert_eq!(text_of(&spans), "● eth0 · Ethernet");
}
#[test]
fn cluster_drops_the_link_type_before_the_interface() {
let capture = CaptureCluster {
interface: Some("eth0"),
link_type: Some("Ethernet"),
failed: false,
};
let spans = capture_cluster_spans(&capture, 6).expect("interface alone fits");
assert_eq!(text_of(&spans), "● eth0");
}
#[test]
fn cluster_disappears_when_it_cannot_fit() {
let capture = CaptureCluster {
interface: Some("eth0"),
link_type: None,
failed: false,
};
assert!(capture_cluster_spans(&capture, 5).is_none());
}
}