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
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) を参照してください。
|
||||
|
||||
## 基本操作
|
||||
|
||||
| キー | 操作 |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)。
|
||||
|
||||
@@ -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).
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
Before Width: | Height: | Size: 3.8 MiB After Width: | Height: | Size: 2.1 MiB |
|
Before Width: | Height: | Size: 388 KiB After Width: | Height: | Size: 338 KiB |
|
Before Width: | Height: | Size: 1.3 MiB After Width: | Height: | Size: 989 KiB |
|
Before Width: | Height: | Size: 605 KiB After Width: | Height: | Size: 356 KiB |
|
Before Width: | Height: | Size: 1.1 MiB After Width: | Height: | Size: 406 KiB |
|
Before Width: | Height: | Size: 784 KiB After Width: | Height: | Size: 451 KiB |
|
Before Width: | Height: | Size: 933 KiB After Width: | Height: | Size: 893 KiB |
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
#
|
||||
|
||||
@@ -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()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -57,6 +57,7 @@
|
||||
|
||||
pub mod app;
|
||||
pub mod cli;
|
||||
pub mod config;
|
||||
pub(crate) mod export;
|
||||
pub(crate) mod filter;
|
||||
pub mod network;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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), "…γγ");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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!(
|
||||
|
||||
@@ -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::{
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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())),
|
||||
|
||||
@@ -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}%");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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:?}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||