Compare commits
5 Commits
3de2c055d5
...
tui
| Author | SHA1 | Date | |
|---|---|---|---|
| 5035e73d7e | |||
| ee65e284c4 | |||
| 4e4c9cee65 | |||
| e600418da8 | |||
| e939b16d2d |
134
AGENTS.md
134
AGENTS.md
@@ -1,134 +0,0 @@
|
||||
# Repository Guidelines
|
||||
|
||||
## Build Prerequisites
|
||||
|
||||
- **Linux**: `libudev-dev` (for `serialport`).
|
||||
- **All platforms**: a C compiler (`cc`, `gcc`, `clang`, or MSVC) — `mlua` uses `features = ["vendored"]` which compiles LuaJIT from source at build time.
|
||||
- **No pinned toolchain**: all crates use `edition = "2024"` (requires Rust ≥ 1.85). No `rust-toolchain.toml`; builds with whatever `rustc` is on `PATH`.
|
||||
|
||||
## Project Structure
|
||||
|
||||
`xserial` is a Rust workspace. The root `Cargo.toml` defines four crates in `crates/`, with dependencies flowing:
|
||||
|
||||
```
|
||||
xserial-core (no internal deps — transport, framing, protocol, pipeline)
|
||||
↑
|
||||
xserial-client (depends on xserial-core — sessions, history, commands, Lua)
|
||||
↑ ↑
|
||||
xserial-gui xserial-tui
|
||||
(both depend on xserial-core AND xserial-client)
|
||||
```
|
||||
|
||||
| Crate | Type | What goes here |
|
||||
|-------|------|---------------|
|
||||
| `xserial-core` | library (`src/lib.rs`) | `transport/`, `frame/`, `protocol/`, `pipeline.rs` — protocol and I/O. Integration tests: `tests/pipeline.rs`. |
|
||||
| `xserial-client` | library (`src/lib.rs`) | `session.rs`, `manager.rs`, `config.rs`, `history.rs`, `lua/` — state management and Lua bindings. Integration tests: `tests/session_lifecycle.rs`, `tests/lua_tests.rs`. |
|
||||
| `xserial-gui` | binary (`src/main.rs`) | egui/eframe app. Panels in `src/panels/`. Font helpers in `src/ui_fonts.rs`. Profiling in `src/perf.rs`. |
|
||||
| `xserial-tui` | binary (`src/main.rs`) | ratatui/crossterm app. **Still a placeholder** — zero tests, not at feature parity with GUI. |
|
||||
|
||||
**Module boundaries**: transport/protocol → `xserial-core`. Session/Lua → `xserial-client`. UI state → `xserial-gui` or `xserial-tui`. Never put I/O logic in the UI crates, never put UI state in the client crate.
|
||||
|
||||
## Build, Test, and Development Commands
|
||||
|
||||
```bash
|
||||
cargo check --workspace # fast compile check (all crates)
|
||||
cargo test --workspace # full suite (~266 tests)
|
||||
cargo fmt --all # standard Rust formatting
|
||||
cargo clippy --workspace --all-targets -- -D warnings # lint gate
|
||||
```
|
||||
|
||||
**Run specific tests:**
|
||||
|
||||
```bash
|
||||
cargo test -p xserial-core -- tcp_line_text_utf8
|
||||
cargo test -p xserial-client -- session_echo_send_and_read
|
||||
cargo test -p xserial-core -- --nocapture # show test output
|
||||
|
||||
# Integration tests only
|
||||
cargo test -p xserial-core --test pipeline
|
||||
cargo test -p xserial-client --test session_lifecycle
|
||||
cargo test -p xserial-client --test lua_tests
|
||||
```
|
||||
|
||||
**Launch:**
|
||||
|
||||
```bash
|
||||
cargo run -p xserial-gui
|
||||
cargo run -p xserial-tui
|
||||
```
|
||||
|
||||
## Environment & Configuration
|
||||
|
||||
### Tracing (all entrypoints)
|
||||
|
||||
```bash
|
||||
RUST_LOG=info cargo run -p xserial-gui
|
||||
RUST_LOG=xserial_gui::perf=debug cargo run -p xserial-gui
|
||||
```
|
||||
|
||||
`tracing_subscriber::EnvFilter::from_default_env()` reads `RUST_LOG`. The workspace dep `tracing-subscriber` has `features = ["env-filter"]`.
|
||||
|
||||
### GUI Profiling
|
||||
|
||||
```bash
|
||||
RUST_LOG=xserial_gui::perf=info XSERIAL_GUI_PROFILE=1 cargo run -p xserial-gui
|
||||
XSERIAL_GUI_PROFILE_INTERVAL_MS=500 cargo run -p xserial-gui # custom interval (default 1s)
|
||||
```
|
||||
|
||||
Profiling logs go to target `xserial_gui::perf`.
|
||||
|
||||
### State & Config File Paths (runtime, not compile-time)
|
||||
|
||||
Uses `env::consts::OS` + env vars — no `cfg(target_os)` gating for path resolution:
|
||||
|
||||
| Platform | Root config path |
|
||||
|----------|-----------------|
|
||||
| Linux | `$XDG_CONFIG_HOME/xserial/` or `~/.config/xserial/` |
|
||||
| macOS | `~/Library/Application Support/xserial/` |
|
||||
| Windows | `%APPDATA%\xserial\` |
|
||||
|
||||
State files: `gui-state.json`, `tui-state.json`, `font-settings.json`.
|
||||
|
||||
### GUI Plot Testing
|
||||
|
||||
```bash
|
||||
python tools/test_plot.py --wire-format mixed --host 127.0.0.1 --port 8091
|
||||
python tools/test_plot.py --wire-format mixed --format xy --channels 2
|
||||
python tools/test_plot.py --wire-format raw --channels 2 --framelen 256
|
||||
```
|
||||
|
||||
Configure in GUI: Transport `TCP 127.0.0.1:8091`, Framer `MixedTextPlot`, Decoder `MixedTextPlot`.
|
||||
|
||||
The wire format spec lives in `tools/xs_mixed_plot.h` (C header, reference only — not compiled into Rust).
|
||||
|
||||
## Coding Style
|
||||
|
||||
Standard Rust conventions (`cargo fmt`). No crate-level lint attrs, no `[lints]` table in any `Cargo.toml`. Names: `snake_case` for modules/files/functions/tests, `PascalCase` for types, `SCREAMING_SNAKE_CASE` for constants.
|
||||
|
||||
## Testing Guidelines
|
||||
|
||||
- **Inline unit tests** (`#[cfg(test)] mod tests`) next to implementation — 24 source files with `#[test]` or `#[tokio::test]`.
|
||||
- **Integration tests** in `crates/<crate>/tests/*.rs` — 3 files covering pipeline, session lifecycle, and Lua bindings.
|
||||
- **~266 tests total** — all active (zero `#[ignore]`). Single `#[should_panic]` in `frame/fixed.rs`.
|
||||
- **All tests are self-contained**: TCP/UDP bind to `127.0.0.1:0` (OS-assigned port). No hardcoded ports. No external services. Serial tests use dummy port names and never open real hardware.
|
||||
- **Async tests** use `#[tokio::test]` (no `async-std`). **No benchmarks** exist.
|
||||
- Run `cargo test --workspace` before opening PRs. Add targeted regression tests for transport, framing, parsing, and session-state fixes.
|
||||
|
||||
## Known Quirks & Constraints
|
||||
|
||||
- **No CI** — no `.github/workflows` or other CI config. Agents must self-verify with `cargo test --workspace` and `cargo clippy --workspace --all-targets -- -D warnings`.
|
||||
- **No feature flags** — zero `[features]` in any `Cargo.toml`. No `#[cfg(feature)]` in any source.
|
||||
- **No build scripts** — zero `build.rs` files. No codegen. No `include!`/`include_str!`/`include_bytes!`.
|
||||
- **All `unsafe` is test-only** — constructing noop wakers for poll-based unit tests in `transport/`. No non-test `unsafe` anywhere.
|
||||
- **`xserial-tui` is a stub** — no tests, minimal implementation. GUI is the primary target.
|
||||
- **Platform font directories** use `#[cfg(target_os)]` in `ui_fonts.rs` (three blocks: Windows, macOS, Linux/BSD).
|
||||
- **`tokio` features differ by build**: `xserial-client` uses `["sync", "time"]` for production, `["full"]` for dev-dependencies (tests need net/IO).
|
||||
|
||||
## Commit & PR Guidelines
|
||||
|
||||
Short, imperative subjects (`Persist GUI font settings`), occasional `feat:` prefix for major additions. PRs: state which crate(s) changed, list validation commands, include screenshots/recordings for GUI/TUI changes. Explicitly call out protocol, transport, or Lua API compatibility impacts.
|
||||
|
||||
## Reference
|
||||
|
||||
- `tools/test_plot.py --help` — TCP plot-frame generator options.
|
||||
- `tools/xs_mixed_plot.h` — wire format spec for MixedTextPlot (COBS-framed binary plot + text on one stream).
|
||||
158
Cargo.lock
generated
158
Cargo.lock
generated
@@ -189,6 +189,16 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ansitok"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c0a8acea8c2f1c60f0a92a8cd26bf96ca97db56f10bbcab238bbe0cceba659ee"
|
||||
dependencies = [
|
||||
"nom 7.1.3",
|
||||
"vte",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anstream"
|
||||
version = "1.0.0"
|
||||
@@ -3262,6 +3272,76 @@ dependencies = [
|
||||
"futures-io",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pipeview-client"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"mlua",
|
||||
"pipeview-core",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pipeview-core"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"bytes",
|
||||
"hex",
|
||||
"nom 8.0.0",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serialport",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"tokio-serial",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pipeview-gui"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"ansitok",
|
||||
"eframe",
|
||||
"egui",
|
||||
"egui_plot",
|
||||
"hex",
|
||||
"pipeview-client",
|
||||
"pipeview-core",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pipeview-tui"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"ansitok",
|
||||
"clap",
|
||||
"crossterm 0.28.1",
|
||||
"hex",
|
||||
"image",
|
||||
"mlua",
|
||||
"pipeview-client",
|
||||
"pipeview-core",
|
||||
"ratatui",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"tracing-appender",
|
||||
"tracing-subscriber",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pkg-config"
|
||||
version = "0.3.33"
|
||||
@@ -4652,6 +4732,16 @@ version = "0.9.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
|
||||
|
||||
[[package]]
|
||||
name = "vte"
|
||||
version = "0.14.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "231fdcd7ef3037e8330d8e17e61011a2c244126acc0a982f4040ac3f9f0bc077"
|
||||
dependencies = [
|
||||
"arrayvec",
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "vtparse"
|
||||
version = "0.6.2"
|
||||
@@ -5750,74 +5840,6 @@ version = "0.8.28"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3ae8337f8a065cfc972643663ea4279e04e7256de865aa66fe25cec5fb912d3f"
|
||||
|
||||
[[package]]
|
||||
name = "xserial-client"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"mlua",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"xserial-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "xserial-core"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"bytes",
|
||||
"hex",
|
||||
"nom 8.0.0",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serialport",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"tokio-serial",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "xserial-gui"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"eframe",
|
||||
"egui",
|
||||
"egui_plot",
|
||||
"hex",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"xserial-client",
|
||||
"xserial-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "xserial-tui"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"clap",
|
||||
"crossterm 0.28.1",
|
||||
"hex",
|
||||
"image",
|
||||
"mlua",
|
||||
"ratatui",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"tracing-appender",
|
||||
"tracing-subscriber",
|
||||
"xserial-client",
|
||||
"xserial-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "yoke"
|
||||
version = "0.8.2"
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
[workspace]
|
||||
resolver = "2"
|
||||
members = [
|
||||
"crates/xserial-core",
|
||||
"crates/xserial-client",
|
||||
"crates/xserial-tui",
|
||||
"crates/xserial-gui",
|
||||
"crates/pipeview-core",
|
||||
"crates/pipeview-client",
|
||||
"crates/pipeview-tui",
|
||||
"crates/pipeview-gui",
|
||||
]
|
||||
|
||||
[workspace.dependencies]
|
||||
|
||||
392
README.md
392
README.md
@@ -1,179 +1,187 @@
|
||||
# xserial
|
||||
# pipeview
|
||||
|
||||
**Cross-platform serial / TCP / UDP data inspection tool** with configurable framing, protocol decoding, multi-session management, and real-time waveform plotting — all in a single binary.
|
||||
**跨平台串口 / TCP / UDP 数据观测工具** — 可配置分帧、协议解码、多会话管理、实时波形绘图,单二进制零依赖。
|
||||
|
||||
Built with Rust + [egui](https://github.com/emilk/egui), scriptable with Lua.
|
||||
基于 Rust + [egui](https://github.com/emilk/egui) 构建,支持 Lua 脚本扩展。
|
||||
|
||||
[中文文档](README_zh.md)
|
||||
[English](README_en.md)
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
## 目录
|
||||
|
||||
- [Features](#features)
|
||||
- [Quick Start](#quick-start)
|
||||
- [Walkthrough: Drone Telemetry](#walkthrough-drone-telemetry)
|
||||
- [Lua Scripting Guide](#lua-scripting-guide)
|
||||
- [Architecture](#architecture)
|
||||
- [Configuration & Persistence](#configuration--persistence)
|
||||
- [Development Tools](#development-tools)
|
||||
- [Tech Stack](#tech-stack)
|
||||
- [功能特性](#功能特性)
|
||||
- [快速开始](#快速开始)
|
||||
- [完整示例:无人机遥测解析](#完整示例无人机遥测解析)
|
||||
- [Lua 脚本开发指南](#lua-脚本开发指南)
|
||||
- [架构](#架构)
|
||||
- [配置与持久化](#配置与持久化)
|
||||
- [开发工具](#开发工具)
|
||||
- [技术栈](#技术栈)
|
||||
- [License](#license)
|
||||
|
||||
---
|
||||
|
||||
## Features
|
||||
## 功能特性
|
||||
|
||||
### Transport
|
||||
### 传输层
|
||||
|
||||
| Type | Description | Options |
|
||||
|------|-------------|---------|
|
||||
| **Serial** | Serial port communication | Port name, baud rate (300–12M), data bits (5/6/7/8), parity (None/Odd/Even), stop bits (1/2), flow control (None/Software/Hardware), DTR/RTS control |
|
||||
| **TCP** | TCP client | Target address `host:port` |
|
||||
| **UDP** | UDP communication | Bind address `host:port`, optional remote address |
|
||||
| 类型 | 说明 | 配置项 |
|
||||
|------|------|--------|
|
||||
| **Serial** | 串口通信 | 端口名、波特率 (300–12M)、数据位 (5/6/7/8)、校验位 (None/Odd/Even)、停止位 (1/2)、流控 (None/Software/Hardware)、DTR/RTS 控制 |
|
||||
| **TCP** | TCP 客户端 | 目标地址 `host:port` |
|
||||
| **UDP** | UDP 通信 | 绑定地址 `host:port`,可选远端地址 |
|
||||
|
||||
### Framers
|
||||
### 分帧器(Framer)
|
||||
|
||||
Framers split raw byte streams into discrete frames. Each session supports **multiple independent pipelines** — the same byte stream feeds multiple framer→decoder chains in parallel.
|
||||
将原始字节流切分为独立帧。每个 Session 可配置**多条独立管线**,同一字节流并行送入多个 framer→decoder 链路。
|
||||
|
||||
| Framer | Description | Parameters |
|
||||
|--------|-------------|------------|
|
||||
| **Line** | Split by `\n` | `strip_cr`, `max_line_len` |
|
||||
| **Fixed** | Fixed-length frames | `frame_len` |
|
||||
| **Length** | Length-prefixed protocol | `len_bytes` (1/2/4/8), `endian`, `length_includes_self`, `max_payload` |
|
||||
| **COBS** | [Consistent Overhead Byte Stuffing](https://en.wikipedia.org/wiki/Consistent_Overhead_Byte_Stuffing) | `max_frame` |
|
||||
| **MixedTextPlot** | Hybrid text lines + COBS-encoded plot frames on one stream | `strip_cr`, `max_line_len`, `max_plot_frame` |
|
||||
| **Lua** | User-defined framer script | `script_path` |
|
||||
| 分帧器 | 说明 | 配置参数 |
|
||||
|--------|------|----------|
|
||||
| **Line** | 按 `\n` 分割文本行 | `strip_cr`(去除 `\r`)、`max_line_len`(最大行长度) |
|
||||
| **Fixed** | 固定字节数为一帧 | `frame_len`(帧长度) |
|
||||
| **Length** | 长度前缀协议 | `len_bytes` (1/2/4/8)、`endian` (大小端)、`length_includes_self`、`max_payload` |
|
||||
| **COBS** | [Consistent Overhead Byte Stuffing](https://en.wikipedia.org/wiki/Consistent_Overhead_Byte_Stuffing) 编码 | `max_frame`(最大帧长) |
|
||||
| **MixedTextPlot** | 单连接混合文本行 + COBS 编码的 plot 帧 | `strip_cr`、`max_line_len`、`max_plot_frame` |
|
||||
| **Lua** | 用户自定义分帧脚本 | `script_path`(Lua 脚本路径) |
|
||||
|
||||
### Decoders
|
||||
### 解码器(Decoder)
|
||||
|
||||
Decoders parse framed data into displayable content.
|
||||
将帧数据解析为可展示的内容。
|
||||
|
||||
| Decoder | Output | Parameters |
|
||||
|---------|--------|------------|
|
||||
| **Text** | Text | `encoding` (UTF-8 / Latin1 / ASCII) |
|
||||
| **Hex** | Hexadecimal | `uppercase`, `separator`, `bytes_per_group`, `endian` |
|
||||
| **Plot** | Waveform | `sample_type` (i8–f64), `endian`, `channels` (1–64), `format` (Interleaved / Block / XY) |
|
||||
| **MixedTextPlot** | Hybrid text + waveform | `encoding` |
|
||||
| **Lua** | Custom | `script_path` |
|
||||
| 解码器 | 输出类型 | 配置参数 |
|
||||
|--------|----------|----------|
|
||||
| **Text** | 文本 | `encoding`(UTF-8 / Latin1 / ASCII) |
|
||||
| **Hex** | 十六进制 | `uppercase`、`separator`、`bytes_per_group`、`endian` |
|
||||
| **Plot** | 波形数据 | `sample_type` (i8–f64)、`endian`、`channels` (1–64)、`format` (Interleaved / Block / XY) |
|
||||
| **MixedTextPlot** | 混合文本 + 波形 | `encoding` |
|
||||
| **Lua** | 自定义 | `script_path`(Lua 脚本路径) |
|
||||
|
||||
**Plot sample formats:**
|
||||
**Plot 采样格式:**
|
||||
|
||||
| Format | Description | Byte layout |
|
||||
|--------|-------------|-------------|
|
||||
| **Interleaved** | Channels interleaved | `[ch0_s0, ch1_s0, ch2_s0, ch0_s1, ch1_s1, …]` |
|
||||
| **Block** | Channel-contiguous blocks | `[ch0_s0, ch0_s1, …, ch1_s0, ch1_s1, …]` |
|
||||
| **XY** | 2-channel x/y pairs | `[x0, y0, x1, y1, …]` |
|
||||
| 格式 | 说明 | 字节排列 |
|
||||
|------|------|----------|
|
||||
| **Interleaved** | 多通道交叉排列 | `[ch0_s0, ch1_s0, ch2_s0, ch0_s1, ch1_s1, …]` |
|
||||
| **Block** | 按通道分块排列 | `[ch0_s0, ch0_s1, …, ch1_s0, ch1_s1, …]` |
|
||||
| **XY** | 2 通道交替 x/y | `[x0, y0, x1, y1, …]` |
|
||||
|
||||
Supported sample types: `i8`, `u8`, `i16`, `u16`, `i32`, `u32`, `i64`, `u64`, `f32`, `f64`
|
||||
支持的采样类型:`i8`、`u8`、`i16`、`u16`、`i32`、`u32`、`i64`、`u64`、`f32`、`f64`
|
||||
|
||||
### Views
|
||||
### 视图
|
||||
|
||||
- **Text View** — formatted text with timestamps, direction markers (`[IN]`/`[OUT]`), and pipeline labels. Built-in search (`Ctrl+F`) with case-sensitive toggle, match counter, and highlighting.
|
||||
- **Hex View** — hex dump alongside ASCII sidebar. Configurable byte grouping and endianness.
|
||||
- **Plot View** — real-time waveforms via [egui_plot](https://github.com/emilk/egui/tree/master/crates/egui_plot). Auto-fit, box zoom, axis locking, follow-latest, detached window, channel legend.
|
||||
- **文本视图** — 带时间戳、方向标记 (`[IN]`/`[OUT]`)、管线标签的格式化文本流。支持搜索(`Ctrl+F`)、大小写匹配、匹配计数和高亮。
|
||||
- **十六进制视图** — hex dump 与 ASCII 侧栏并排展示,可按分组和大小端解析多字节数值。
|
||||
- **波形视图** — 基于 [egui_plot](https://github.com/emilk/egui/tree/master/crates/egui_plot) 的实时波形。支持自动缩放、框选缩放、坐标轴锁定、跟随最新数据、浮动窗口、通道图例。
|
||||
|
||||
### Sessions
|
||||
### 会话管理
|
||||
|
||||
- **Multi-session** — tabbed interface, each session independently configured
|
||||
- **Connection control** — Connect / Disconnect / Reconnect with auto-reconnect on drop
|
||||
- **Live reconfiguration** — change framer/decoder settings without restarting
|
||||
- **Data sending** — Text mode (UTF-8, configurable line endings) and Hex mode (raw bytes). Four line ending options: None / LF / CR / CRLF
|
||||
- **Ring buffer** — default 10,000 entries, configurable 100–1,000,000
|
||||
- **Log to file** — background writer thread with 1 KB buffer, non-blocking
|
||||
- **Search** — `Ctrl+F` search bar, case-sensitive toggle, F3/Shift+F3 navigation
|
||||
- **多会话并发** — 标签页切换,每个 Session 独立配置
|
||||
- **连接控制** — Connect / Disconnect / Reconnect,支持断线自动重连
|
||||
- **运行时重配置** — 修改分帧器/解码器参数无需断开连接
|
||||
- **数据发送** — Text 模式(UTF-8,可选换行符)和 Hex 模式(十六进制字节)。支持 `None` / `LF` / `CR` / `CRLF` 四种行尾
|
||||
- **环形缓冲** — 默认 10,000 条历史记录,可配置 100–1,000,000
|
||||
- **日志到文件** — 每行数据实时写入,1KB 缓冲 + 后台线程,不阻塞 UI
|
||||
- **搜索** — `Ctrl+F` 呼出搜索栏,大小写敏感切换,F3/Shift+F3 前后跳转
|
||||
|
||||
### Lua Scripting
|
||||
### Lua 脚本扩展
|
||||
|
||||
Built-in LuaJIT runtime:
|
||||
内置 LuaJIT 运行时:
|
||||
|
||||
- **Custom framers** — implement `feed(bytes)`, `flush()`, `reset()`, `pending_len()`
|
||||
- **Custom decoders** — implement `decode(frame)`, return text/hex/plot/binary
|
||||
- **Session API** — `xserial.open()` to create sessions, `session:on_data()` for event callbacks, `session:send()` to transmit data
|
||||
- **Utilities** — `xserial.list_ports()`, `xserial.sleep(ms)`, `xserial.poll(limit)`, `xserial.log(msg)`
|
||||
- **自定义分帧器** — 实现 `feed(bytes)`、`flush()`、`reset()`、`pending_len()` 四个函数
|
||||
- **自定义解码器** — 实现 `decode(frame)` 函数,返回 text/hex/plot/binary 四种类型
|
||||
- **会话 API** — `pipeview.open()` 创建 Session、`session:on_data()` 事件回调、`session:send()` 发送数据
|
||||
- **工具函数** — `pipeview.list_ports()`、`pipeview.sleep(ms)`、`pipeview.poll(limit)`、`pipeview.log(msg)`
|
||||
|
||||
### Keyboard Shortcuts
|
||||
### 快捷键
|
||||
|
||||
| Shortcut | Action |
|
||||
|----------|--------|
|
||||
| `Ctrl+N` | New session |
|
||||
| `Ctrl+E` | Edit current session |
|
||||
| `Ctrl+W` | Delete current session |
|
||||
| `Ctrl+F5` | Toggle connection |
|
||||
| `Ctrl+T` / `Ctrl+H` / `Ctrl+P` | Switch to Text / Hex / Plot view |
|
||||
| `Ctrl+Tab` / `Ctrl+Shift+Tab` | Next / Previous tab |
|
||||
| `Ctrl+L` | Clear output |
|
||||
| `Ctrl+F` | Search |
|
||||
| `F3` / `Shift+F3` | Next / Previous match |
|
||||
| `Ctrl+,` | UI settings |
|
||||
| `Esc` | Close overlay |
|
||||
| 快捷键 | 操作 |
|
||||
|--------|------|
|
||||
| `Ctrl+N` | 新建会话 |
|
||||
| `Ctrl+E` | 编辑当前会话 |
|
||||
| `Ctrl+W` | 删除当前会话 |
|
||||
| `Ctrl+F5` | 切换连接 |
|
||||
| `Ctrl+T` / `Ctrl+H` / `Ctrl+P` | 切换到文本 / 十六进制 / 波形视图 |
|
||||
| `Ctrl+Tab` / `Ctrl+Shift+Tab` | 下一个 / 上一个标签页 |
|
||||
| `Ctrl+L` | 清空输出 |
|
||||
| `Ctrl+F` | 搜索 |
|
||||
| `F3` / `Shift+F3` | 上一个 / 下一个匹配 |
|
||||
| `Ctrl+,` | UI 设置 |
|
||||
| `Esc` | 关闭浮层 |
|
||||
|
||||
---
|
||||
|
||||
## Quick Start
|
||||
## 快速开始
|
||||
|
||||
### Prerequisites
|
||||
### 前置条件
|
||||
|
||||
| Platform | Dependencies |
|
||||
|----------|-------------|
|
||||
| **Linux** | `libudev-dev` (`apt install libudev-dev`) |
|
||||
| **macOS** | None |
|
||||
| **Windows** | None |
|
||||
| **All** | Rust ≥ 1.85, C compiler (GCC / Clang / MSVC) |
|
||||
| 平台 | 依赖 |
|
||||
|------|------|
|
||||
| **Linux** | `libudev-dev`(`apt install libudev-dev`) |
|
||||
| **macOS** | 无需额外依赖 |
|
||||
| **Windows** | 无需额外依赖 |
|
||||
| **所有平台** | Rust ≥ 1.85、C 编译器(GCC / Clang / MSVC) |
|
||||
|
||||
### Build & Run
|
||||
### 构建与运行
|
||||
|
||||
```bash
|
||||
git clone https://github.com/your-org/xserial.git
|
||||
cd xserial
|
||||
# 克隆项目
|
||||
git clone https://github.com/your-org/pipeview.git
|
||||
cd pipeview
|
||||
|
||||
cargo run -p xserial-gui
|
||||
# 编译运行
|
||||
cargo run -p pipeview-gui
|
||||
|
||||
# Run tests
|
||||
cargo test --workspace # ~308 tests
|
||||
# 运行测试
|
||||
cargo test --workspace # ~308 个测试
|
||||
cargo clippy --workspace --all-targets -- -D warnings
|
||||
```
|
||||
|
||||
### Release Build
|
||||
### 发布构建
|
||||
|
||||
```bash
|
||||
cargo build -p xserial-gui --release
|
||||
# Binary: target/release/xserial-gui (Linux/macOS)
|
||||
# target/release/xserial-gui.exe (Windows)
|
||||
cargo build -p pipeview-gui --release
|
||||
# 二进制位于 target/release/pipeview-gui (Linux/macOS)
|
||||
# 或 target/release/pipeview-gui.exe (Windows)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Walkthrough: Drone Telemetry
|
||||
## 完整示例:无人机遥测解析
|
||||
|
||||
Parse Betaflight/INAV flight controller telemetry in the format:
|
||||
以 Betaflight/INAV 飞控遥测为例,数据格式为:
|
||||
|
||||
```
|
||||
AHRS q:1.0000,0.0998,0.0499,0.0200|YPR:8.87,4.44,2.96|Gyro:14.78,8.87,5.92|RC:1559,1544,1500,1519|M:1612,1588,1603,1597|L:0 F:1 C:0
|
||||
```
|
||||
|
||||
### Step 1: Start the data source
|
||||
### 步骤 1:启动测试数据源
|
||||
|
||||
```bash
|
||||
python tools/test_drone.py --rate 10 --port 8091
|
||||
```
|
||||
|
||||
### Step 2: Configure the session
|
||||
输出:
|
||||
|
||||
In xserial-gui, create a session with two pipelines:
|
||||
```
|
||||
Listening on 127.0.0.1:8091, waiting for connections...
|
||||
```
|
||||
|
||||
| Setting | Value |
|
||||
|---------|-------|
|
||||
### 步骤 2:配置 Session
|
||||
|
||||
在 pipeview-gui 中创建 Session:
|
||||
|
||||
| 配置项 | 值 |
|
||||
|--------|-----|
|
||||
| Transport | `TCP 127.0.0.1:8091` |
|
||||
| Pipeline 1 | Framer: `Line` → Decoder: `Lua` → `examples/drone_text.lua` |
|
||||
| Pipeline 2 | Framer: `Line` → Decoder: `Lua` → `examples/drone_plot.lua` |
|
||||
| Pipeline 1 | Framer: `Line` → Decoder: `Lua` → 选择 `examples/drone_text.lua` |
|
||||
| Pipeline 2 | Framer: `Line` → Decoder: `Lua` → 选择 `examples/drone_plot.lua` |
|
||||
|
||||
### Step 3: View the results
|
||||
### 步骤 3:查看结果
|
||||
|
||||
- **Text View** — formatted sensor readouts
|
||||
- **Plot View** — real-time gyroscope curves (gz/gy/gx)
|
||||
- **文本视图** — 显示格式化的传感器数据
|
||||
- **波形视图** — 显示 Gyro 三轴实时曲线 (gz/gy/gx)
|
||||
|
||||
The decoder in `examples/drone_plot.lua`:
|
||||
`examples/drone_plot.lua` 的核心逻辑:
|
||||
|
||||
```lua
|
||||
return {
|
||||
@@ -191,59 +199,63 @@ return {
|
||||
|
||||
---
|
||||
|
||||
## Lua Scripting Guide
|
||||
## Lua 脚本开发指南
|
||||
|
||||
### Framer API
|
||||
### 分帧器 API
|
||||
|
||||
A Lua framer splits raw bytes into frames. The script must return a table with these functions:
|
||||
分帧器将原始字节流切分为帧,Lua 脚本必须返回包含以下函数的 table:
|
||||
|
||||
```lua
|
||||
return {
|
||||
-- 输入新到达的字节,返回帧数组(Lua strings)
|
||||
feed = function(bytes)
|
||||
-- bytes: Lua string (raw bytes)
|
||||
-- Returns: { frame1, frame2, ... } or nil
|
||||
-- bytes: Lua string(原始字节)
|
||||
-- 返回: { frame1, frame2, ... } 或 nil
|
||||
end,
|
||||
|
||||
-- 刷新缓冲区中残留的数据
|
||||
flush = function()
|
||||
-- Returns: last buffered frame (string) or nil
|
||||
-- 返回: 最后一帧(Lua string)或 nil
|
||||
end,
|
||||
|
||||
-- 重置内部状态
|
||||
reset = function()
|
||||
end,
|
||||
|
||||
-- 返回缓冲区中待处理的字节数
|
||||
pending_len = function()
|
||||
-- Returns: number of bytes pending in buffer
|
||||
-- 返回: number
|
||||
end,
|
||||
}
|
||||
```
|
||||
|
||||
Reference: `tests/lua_line_framer.lua` (line-based framer splitting on `\n`).
|
||||
参考实现:`tests/lua_line_framer.lua`(按 `\n` 分割的行分帧器)
|
||||
|
||||
### Decoder API
|
||||
### 解码器 API
|
||||
|
||||
A Lua decoder parses a frame into structured data. The script must return a table with a `decode` function:
|
||||
解码器将帧解析为结构化数据,Lua 脚本必须返回包含 `decode` 函数的 table:
|
||||
|
||||
```lua
|
||||
return {
|
||||
decode = function(frame)
|
||||
-- frame: Lua string (one frame from the framer)
|
||||
-- Returns nil → skip this frame
|
||||
-- Returns string → treated as Text
|
||||
-- Returns table → must contain "kind" field
|
||||
-- frame: Lua string(来自分帧器的一帧)
|
||||
-- 返回 nil → 跳过此帧
|
||||
-- 返回 string → 自动视为 Text
|
||||
-- 返回 table → 必须包含 kind 字段
|
||||
end,
|
||||
}
|
||||
```
|
||||
|
||||
**Return value formats:**
|
||||
**返回值格式:**
|
||||
|
||||
| `kind` | Required | Optional | Purpose |
|
||||
|--------|----------|----------|---------|
|
||||
| `"text"` | `data: string` | — | Text view |
|
||||
| `"hex"` | `data: string` | — | Hex view |
|
||||
| `"binary"` | `data: string` | — | Raw bytes |
|
||||
| `"plot"` | `channels: {{number,…},…}` | `sample_type`, `format` | Plot view |
|
||||
| `kind` | 必需字段 | 可选字段 | 用途 |
|
||||
|--------|----------|----------|------|
|
||||
| `"text"` | `data: string` | — | 文本视图 |
|
||||
| `"hex"` | `data: string` | — | 十六进制视图 |
|
||||
| `"binary"` | `data: string` | — | 原始二进制 |
|
||||
| `"plot"` | `channels: {{number,…},…}` | `sample_type`、`format` | 波形视图 |
|
||||
|
||||
**Plot return value example:**
|
||||
**Plot 返回值示例:**
|
||||
|
||||
```lua
|
||||
return {
|
||||
@@ -252,40 +264,40 @@ return {
|
||||
{ 1.0, 2.0, 3.0 }, -- channel 0
|
||||
{ 4.0, 5.0, 6.0 }, -- channel 1
|
||||
},
|
||||
sample_type = "F64", -- default F64; also I8/U8/I16/U32/U64/F32
|
||||
format = "Block", -- default Interleaved; also Block/XY
|
||||
sample_type = "F64", -- 默认 F64,可选 I8/U8/I16/U16/I32/U32/I64/U64/F32
|
||||
format = "Block", -- 默认 Interleaved,可选 Block/XY
|
||||
}
|
||||
```
|
||||
|
||||
More examples in `examples/`.
|
||||
更多示例见 `examples/` 目录。
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
## 架构
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────┐
|
||||
│ xserial-gui (egui) xserial-tui (ratatui) │
|
||||
│ pipeview-gui (egui) pipeview-tui (ratatui) │
|
||||
├──────────────────────────────────────────────────┤
|
||||
│ xserial-client │
|
||||
│ pipeview-client │
|
||||
│ SessionManager · Session · Config · History │
|
||||
│ Lua Runtime (mlua / LuaJIT) │
|
||||
├──────────────────────────────────────────────────┤
|
||||
│ xserial-core │
|
||||
│ pipeview-core │
|
||||
│ Transport ──▶ Frame ──▶ Protocol ──▶ Pipeline │
|
||||
└──────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
| Crate | Type | Purpose |
|
||||
|-------|------|---------|
|
||||
| `xserial-core` | library | Transport (Serial/TCP/UDP), framers (Line/Fixed/Length/COBS/Mixed/Lua), decoders (Text/Hex/Plot), MultiPipeline |
|
||||
| `xserial-client` | library | Session lifecycle, SessionManager, event broadcast (tokio broadcast), RingBuffer history, Lua runtime & session API |
|
||||
| `xserial-gui` | binary | egui desktop app: sidebar, config, console, text/hex/plot panels, keyboard shortcuts, font management, profiling |
|
||||
| `xserial-tui` | binary | ratatui terminal app (feature-incomplete, under development) |
|
||||
| Crate | 类型 | 职责 |
|
||||
|-------|------|------|
|
||||
| `pipeview-core` | library | 传输层(Serial/TCP/UDP)、分帧器(Line/Fixed/Length/COBS/Mixed/Lua)、协议解码器(Text/Hex/Plot)、MultiPipeline |
|
||||
| `pipeview-client` | library | Session 生命周期管理、SessionManager、事件广播(tokio broadcast)、RingBuffer 历史、Lua 运行时及会话 API |
|
||||
| `pipeview-gui` | binary | egui 桌面应用,包含 sidebar/config/console/text/hex/plot 面板、键盘快捷键、字体管理、性能分析 |
|
||||
| `pipeview-tui` | binary | ratatui 终端应用(功能未对齐 GUI,仍在开发中) |
|
||||
|
||||
**Dependency flow:** `core ← client ← {gui, tui}`
|
||||
**依赖方向:** `core ← client ← {gui, tui}`
|
||||
|
||||
**Data flow:**
|
||||
**数据流:**
|
||||
|
||||
```
|
||||
[Transport] → read bytes → [MultiPipeline]
|
||||
@@ -294,84 +306,88 @@ More examples in `examples/`.
|
||||
→ ...
|
||||
```
|
||||
|
||||
Each pipeline frames and decodes independently. Only successful decodes produce output.
|
||||
每条管线独立分帧、解码,互不干扰。只有成功解码的管线产生输出。
|
||||
|
||||
---
|
||||
|
||||
## Configuration & Persistence
|
||||
## 配置与持久化
|
||||
|
||||
GUI state is saved automatically to platform-standard locations:
|
||||
GUI 状态自动保存,路径遵循各平台规范:
|
||||
|
||||
| Platform | Path |
|
||||
|----------|------|
|
||||
| Linux | `$XDG_CONFIG_HOME/xserial/gui-state.json` or `~/.config/xserial/gui-state.json` |
|
||||
| macOS | `~/Library/Application Support/xserial/gui-state.json` |
|
||||
| Windows | `%APPDATA%\xserial\gui-state.json` |
|
||||
| 平台 | 路径 |
|
||||
|------|------|
|
||||
| Linux | `$XDG_CONFIG_HOME/pipeview/gui-state.json` 或 `~/.config/pipeview/gui-state.json` |
|
||||
| macOS | `~/Library/Application Support/pipeview/gui-state.json` |
|
||||
| Windows | `%APPDATA%\pipeview\gui-state.json` |
|
||||
|
||||
Persisted data includes: session configurations (transport, pipelines), log settings, active tab, and display options.
|
||||
持久化的内容包括:Session 配置(传输参数、管线设置)、日志开关及路径、活动标签页、显示选项。
|
||||
|
||||
Log files are written to `logs/` under the config directory, named `session_{id}_{timestamp}.log`.
|
||||
日志文件默认保存在配置目录的 `logs/` 子目录下,文件命名格式为 `session_{id}_{timestamp}.log`。
|
||||
|
||||
---
|
||||
|
||||
## Development Tools
|
||||
## 开发工具
|
||||
|
||||
### Test Data Generators
|
||||
### 测试数据生成器
|
||||
|
||||
```bash
|
||||
# Plot waveform test data
|
||||
# Plot 波形测试数据
|
||||
python tools/test_plot.py --wire-format mixed --host 127.0.0.1 --port 8091
|
||||
python tools/test_plot.py --wire-format mixed --format xy --channels 2
|
||||
python tools/test_plot.py --wire-format raw --channels 2 --framelen 256
|
||||
|
||||
# Drone telemetry test data
|
||||
# 无人机遥测测试数据
|
||||
python tools/test_drone.py --rate 10 --port 8092
|
||||
```
|
||||
|
||||
### Profiling
|
||||
### 性能分析
|
||||
|
||||
```bash
|
||||
RUST_LOG=xserial_gui::perf=info XSERIAL_GUI_PROFILE=1 cargo run -p xserial-gui
|
||||
XSERIAL_GUI_PROFILE_INTERVAL_MS=500 cargo run -p xserial-gui
|
||||
RUST_LOG=pipeview_gui::perf=info XSERIAL_GUI_PROFILE=1 cargo run -p pipeview-gui
|
||||
XSERIAL_GUI_PROFILE_INTERVAL_MS=500 cargo run -p pipeview-gui
|
||||
```
|
||||
|
||||
### Tracing
|
||||
输出每帧的耗时、事件 drain 耗时、text/hex/plot 渲染耗时、plot 点数统计。
|
||||
|
||||
### 日志
|
||||
|
||||
```bash
|
||||
RUST_LOG=info cargo run -p xserial-gui
|
||||
RUST_LOG=xserial_gui=debug cargo run -p xserial-gui
|
||||
RUST_LOG=info cargo run -p pipeview-gui # 应用日志
|
||||
RUST_LOG=pipeview_gui=debug cargo run -p pipeview-gui # 详细日志
|
||||
```
|
||||
|
||||
### Project Structure
|
||||
使用 `tracing-subscriber` + `RUST_LOG` 环境变量控制。
|
||||
|
||||
### 项目结构
|
||||
|
||||
```
|
||||
crates/
|
||||
xserial-core/ # Transport, framing, protocol
|
||||
xserial-client/ # Session management, Lua runtime
|
||||
xserial-gui/ # egui desktop application
|
||||
xserial-tui/ # ratatui terminal application
|
||||
examples/ # Lua script examples
|
||||
drone_plot.lua # Drone telemetry plot decoder
|
||||
drone_text.lua # Drone telemetry text decoder
|
||||
tests/ # Lua test fixtures
|
||||
tools/ # Development utilities
|
||||
test_plot.py # Waveform test data generator
|
||||
test_drone.py # Drone test data generator
|
||||
test_plot_serial.c # C serial plot test client
|
||||
test_text_serial.c # C serial text test client
|
||||
xs_mixed_plot.h # MixedTextPlot protocol reference
|
||||
pipeview-core/ # 传输、分帧、协议
|
||||
pipeview-client/ # 会话管理、Lua 运行时
|
||||
pipeview-gui/ # egui 桌面应用
|
||||
pipeview-tui/ # ratatui 终端应用
|
||||
examples/ # Lua 脚本示例
|
||||
drone_plot.lua # 飞控遥测波形解码器
|
||||
drone_text.lua # 飞控遥测文本解码器
|
||||
tests/ # Lua 测试 fixture
|
||||
tools/ # 开发辅助工具
|
||||
test_plot.py # 波形测试数据生成器
|
||||
test_drone.py # 飞控测试数据生成器
|
||||
test_plot_serial.c # C 串口波形测试客户端
|
||||
test_text_serial.c # C 串口文本测试客户端
|
||||
xs_mixed_plot.h # MixedTextPlot 协议参考头文件
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Tech Stack
|
||||
## 技术栈
|
||||
|
||||
- **Runtime**: tokio (multi-threaded)
|
||||
- **Serial**: `tokio-serial` + `serialport`
|
||||
- **GUI**: egui + egui_plot
|
||||
- **Lua**: mlua 0.11, LuaJIT (vendored), async/serde/send
|
||||
- **Events**: `tokio::sync::broadcast` (multi-subscriber)
|
||||
- **Zero feature flags**, zero build scripts, zero conditional compilation
|
||||
- **运行时**:tokio (multi-thread)
|
||||
- **串口**:`tokio-serial` + `serialport`
|
||||
- **GUI**:egui + egui_plot
|
||||
- **Lua**:mlua 0.11, LuaJIT (vendored 编译), async/serde/send
|
||||
- **事件分发**:`tokio::sync::broadcast`(多订阅者)
|
||||
- **零 feature flags**、零 build script、零条件编译
|
||||
|
||||
## License
|
||||
|
||||
|
||||
378
README_en.md
Normal file
378
README_en.md
Normal file
@@ -0,0 +1,378 @@
|
||||
# pipeview
|
||||
|
||||
**Cross-platform serial / TCP / UDP data inspection tool** with configurable framing, protocol decoding, multi-session management, and real-time waveform plotting — all in a single binary.
|
||||
|
||||
Built with Rust + [egui](https://github.com/emilk/egui), scriptable with Lua.
|
||||
|
||||
[中文文档](README.md)
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Features](#features)
|
||||
- [Quick Start](#quick-start)
|
||||
- [Walkthrough: Drone Telemetry](#walkthrough-drone-telemetry)
|
||||
- [Lua Scripting Guide](#lua-scripting-guide)
|
||||
- [Architecture](#architecture)
|
||||
- [Configuration & Persistence](#configuration--persistence)
|
||||
- [Development Tools](#development-tools)
|
||||
- [Tech Stack](#tech-stack)
|
||||
- [License](#license)
|
||||
|
||||
---
|
||||
|
||||
## Features
|
||||
|
||||
### Transport
|
||||
|
||||
| Type | Description | Options |
|
||||
|------|-------------|---------|
|
||||
| **Serial** | Serial port communication | Port name, baud rate (300–12M), data bits (5/6/7/8), parity (None/Odd/Even), stop bits (1/2), flow control (None/Software/Hardware), DTR/RTS control |
|
||||
| **TCP** | TCP client | Target address `host:port` |
|
||||
| **UDP** | UDP communication | Bind address `host:port`, optional remote address |
|
||||
|
||||
### Framers
|
||||
|
||||
Framers split raw byte streams into discrete frames. Each session supports **multiple independent pipelines** — the same byte stream feeds multiple framer→decoder chains in parallel.
|
||||
|
||||
| Framer | Description | Parameters |
|
||||
|--------|-------------|------------|
|
||||
| **Line** | Split by `\n` | `strip_cr`, `max_line_len` |
|
||||
| **Fixed** | Fixed-length frames | `frame_len` |
|
||||
| **Length** | Length-prefixed protocol | `len_bytes` (1/2/4/8), `endian`, `length_includes_self`, `max_payload` |
|
||||
| **COBS** | [Consistent Overhead Byte Stuffing](https://en.wikipedia.org/wiki/Consistent_Overhead_Byte_Stuffing) | `max_frame` |
|
||||
| **MixedTextPlot** | Hybrid text lines + COBS-encoded plot frames on one stream | `strip_cr`, `max_line_len`, `max_plot_frame` |
|
||||
| **Lua** | User-defined framer script | `script_path` |
|
||||
|
||||
### Decoders
|
||||
|
||||
Decoders parse framed data into displayable content.
|
||||
|
||||
| Decoder | Output | Parameters |
|
||||
|---------|--------|------------|
|
||||
| **Text** | Text | `encoding` (UTF-8 / Latin1 / ASCII) |
|
||||
| **Hex** | Hexadecimal | `uppercase`, `separator`, `bytes_per_group`, `endian` |
|
||||
| **Plot** | Waveform | `sample_type` (i8–f64), `endian`, `channels` (1–64), `format` (Interleaved / Block / XY) |
|
||||
| **MixedTextPlot** | Hybrid text + waveform | `encoding` |
|
||||
| **Lua** | Custom | `script_path` |
|
||||
|
||||
**Plot sample formats:**
|
||||
|
||||
| Format | Description | Byte layout |
|
||||
|--------|-------------|-------------|
|
||||
| **Interleaved** | Channels interleaved | `[ch0_s0, ch1_s0, ch2_s0, ch0_s1, ch1_s1, …]` |
|
||||
| **Block** | Channel-contiguous blocks | `[ch0_s0, ch0_s1, …, ch1_s0, ch1_s1, …]` |
|
||||
| **XY** | 2-channel x/y pairs | `[x0, y0, x1, y1, …]` |
|
||||
|
||||
Supported sample types: `i8`, `u8`, `i16`, `u16`, `i32`, `u32`, `i64`, `u64`, `f32`, `f64`
|
||||
|
||||
### Views
|
||||
|
||||
- **Text View** — formatted text with timestamps, direction markers (`[IN]`/`[OUT]`), and pipeline labels. Built-in search (`Ctrl+F`) with case-sensitive toggle, match counter, and highlighting.
|
||||
- **Hex View** — hex dump alongside ASCII sidebar. Configurable byte grouping and endianness.
|
||||
- **Plot View** — real-time waveforms via [egui_plot](https://github.com/emilk/egui/tree/master/crates/egui_plot). Auto-fit, box zoom, axis locking, follow-latest, detached window, channel legend.
|
||||
|
||||
### Sessions
|
||||
|
||||
- **Multi-session** — tabbed interface, each session independently configured
|
||||
- **Connection control** — Connect / Disconnect / Reconnect with auto-reconnect on drop
|
||||
- **Live reconfiguration** — change framer/decoder settings without restarting
|
||||
- **Data sending** — Text mode (UTF-8, configurable line endings) and Hex mode (raw bytes). Four line ending options: None / LF / CR / CRLF
|
||||
- **Ring buffer** — default 10,000 entries, configurable 100–1,000,000
|
||||
- **Log to file** — background writer thread with 1 KB buffer, non-blocking
|
||||
- **Search** — `Ctrl+F` search bar, case-sensitive toggle, F3/Shift+F3 navigation
|
||||
|
||||
### Lua Scripting
|
||||
|
||||
Built-in LuaJIT runtime:
|
||||
|
||||
- **Custom framers** — implement `feed(bytes)`, `flush()`, `reset()`, `pending_len()`
|
||||
- **Custom decoders** — implement `decode(frame)`, return text/hex/plot/binary
|
||||
- **Session API** — `pipeview.open()` to create sessions, `session:on_data()` for event callbacks, `session:send()` to transmit data
|
||||
- **Utilities** — `pipeview.list_ports()`, `pipeview.sleep(ms)`, `pipeview.poll(limit)`, `pipeview.log(msg)`
|
||||
|
||||
### Keyboard Shortcuts
|
||||
|
||||
| Shortcut | Action |
|
||||
|----------|--------|
|
||||
| `Ctrl+N` | New session |
|
||||
| `Ctrl+E` | Edit current session |
|
||||
| `Ctrl+W` | Delete current session |
|
||||
| `Ctrl+F5` | Toggle connection |
|
||||
| `Ctrl+T` / `Ctrl+H` / `Ctrl+P` | Switch to Text / Hex / Plot view |
|
||||
| `Ctrl+Tab` / `Ctrl+Shift+Tab` | Next / Previous tab |
|
||||
| `Ctrl+L` | Clear output |
|
||||
| `Ctrl+F` | Search |
|
||||
| `F3` / `Shift+F3` | Next / Previous match |
|
||||
| `Ctrl+,` | UI settings |
|
||||
| `Esc` | Close overlay |
|
||||
|
||||
---
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Prerequisites
|
||||
|
||||
| Platform | Dependencies |
|
||||
|----------|-------------|
|
||||
| **Linux** | `libudev-dev` (`apt install libudev-dev`) |
|
||||
| **macOS** | None |
|
||||
| **Windows** | None |
|
||||
| **All** | Rust ≥ 1.85, C compiler (GCC / Clang / MSVC) |
|
||||
|
||||
### Build & Run
|
||||
|
||||
```bash
|
||||
git clone https://github.com/your-org/pipeview.git
|
||||
cd pipeview
|
||||
|
||||
cargo run -p pipeview-gui
|
||||
|
||||
# Run tests
|
||||
cargo test --workspace # ~308 tests
|
||||
cargo clippy --workspace --all-targets -- -D warnings
|
||||
```
|
||||
|
||||
### Release Build
|
||||
|
||||
```bash
|
||||
cargo build -p pipeview-gui --release
|
||||
# Binary: target/release/pipeview-gui (Linux/macOS)
|
||||
# target/release/pipeview-gui.exe (Windows)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Walkthrough: Drone Telemetry
|
||||
|
||||
Parse Betaflight/INAV flight controller telemetry in the format:
|
||||
|
||||
```
|
||||
AHRS q:1.0000,0.0998,0.0499,0.0200|YPR:8.87,4.44,2.96|Gyro:14.78,8.87,5.92|RC:1559,1544,1500,1519|M:1612,1588,1603,1597|L:0 F:1 C:0
|
||||
```
|
||||
|
||||
### Step 1: Start the data source
|
||||
|
||||
```bash
|
||||
python tools/test_drone.py --rate 10 --port 8091
|
||||
```
|
||||
|
||||
### Step 2: Configure the session
|
||||
|
||||
In pipeview-gui, create a session with two pipelines:
|
||||
|
||||
| Setting | Value |
|
||||
|---------|-------|
|
||||
| Transport | `TCP 127.0.0.1:8091` |
|
||||
| Pipeline 1 | Framer: `Line` → Decoder: `Lua` → `examples/drone_text.lua` |
|
||||
| Pipeline 2 | Framer: `Line` → Decoder: `Lua` → `examples/drone_plot.lua` |
|
||||
|
||||
### Step 3: View the results
|
||||
|
||||
- **Text View** — formatted sensor readouts
|
||||
- **Plot View** — real-time gyroscope curves (gz/gy/gx)
|
||||
|
||||
The decoder in `examples/drone_plot.lua`:
|
||||
|
||||
```lua
|
||||
return {
|
||||
decode = function(frame)
|
||||
local gz, gy, gx = frame:match("Gyro:([%d%.%-]+),([%d%.%-]+),([%d%.%-]+)")
|
||||
return {
|
||||
kind = "plot",
|
||||
channels = { { tonumber(gz) }, { tonumber(gy) }, { tonumber(gx) } },
|
||||
sample_type = "F64",
|
||||
format = "Block",
|
||||
}
|
||||
end,
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Lua Scripting Guide
|
||||
|
||||
### Framer API
|
||||
|
||||
A Lua framer splits raw bytes into frames. The script must return a table with these functions:
|
||||
|
||||
```lua
|
||||
return {
|
||||
feed = function(bytes)
|
||||
-- bytes: Lua string (raw bytes)
|
||||
-- Returns: { frame1, frame2, ... } or nil
|
||||
end,
|
||||
|
||||
flush = function()
|
||||
-- Returns: last buffered frame (string) or nil
|
||||
end,
|
||||
|
||||
reset = function()
|
||||
end,
|
||||
|
||||
pending_len = function()
|
||||
-- Returns: number of bytes pending in buffer
|
||||
end,
|
||||
}
|
||||
```
|
||||
|
||||
Reference: `tests/lua_line_framer.lua` (line-based framer splitting on `\n`).
|
||||
|
||||
### Decoder API
|
||||
|
||||
A Lua decoder parses a frame into structured data. The script must return a table with a `decode` function:
|
||||
|
||||
```lua
|
||||
return {
|
||||
decode = function(frame)
|
||||
-- frame: Lua string (one frame from the framer)
|
||||
-- Returns nil → skip this frame
|
||||
-- Returns string → treated as Text
|
||||
-- Returns table → must contain "kind" field
|
||||
end,
|
||||
}
|
||||
```
|
||||
|
||||
**Return value formats:**
|
||||
|
||||
| `kind` | Required | Optional | Purpose |
|
||||
|--------|----------|----------|---------|
|
||||
| `"text"` | `data: string` | — | Text view |
|
||||
| `"hex"` | `data: string` | — | Hex view |
|
||||
| `"binary"` | `data: string` | — | Raw bytes |
|
||||
| `"plot"` | `channels: {{number,…},…}` | `sample_type`, `format` | Plot view |
|
||||
|
||||
**Plot return value example:**
|
||||
|
||||
```lua
|
||||
return {
|
||||
kind = "plot",
|
||||
channels = {
|
||||
{ 1.0, 2.0, 3.0 }, -- channel 0
|
||||
{ 4.0, 5.0, 6.0 }, -- channel 1
|
||||
},
|
||||
sample_type = "F64", -- default F64; also I8/U8/I16/U32/U64/F32
|
||||
format = "Block", -- default Interleaved; also Block/XY
|
||||
}
|
||||
```
|
||||
|
||||
More examples in `examples/`.
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────┐
|
||||
│ pipeview-gui (egui) pipeview-tui (ratatui) │
|
||||
├──────────────────────────────────────────────────┤
|
||||
│ pipeview-client │
|
||||
│ SessionManager · Session · Config · History │
|
||||
│ Lua Runtime (mlua / LuaJIT) │
|
||||
├──────────────────────────────────────────────────┤
|
||||
│ pipeview-core │
|
||||
│ Transport ──▶ Frame ──▶ Protocol ──▶ Pipeline │
|
||||
└──────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
| Crate | Type | Purpose |
|
||||
|-------|------|---------|
|
||||
| `pipeview-core` | library | Transport (Serial/TCP/UDP), framers (Line/Fixed/Length/COBS/Mixed/Lua), decoders (Text/Hex/Plot), MultiPipeline |
|
||||
| `pipeview-client` | library | Session lifecycle, SessionManager, event broadcast (tokio broadcast), RingBuffer history, Lua runtime & session API |
|
||||
| `pipeview-gui` | binary | egui desktop app: sidebar, config, console, text/hex/plot panels, keyboard shortcuts, font management, profiling |
|
||||
| `pipeview-tui` | binary | ratatui terminal app (feature-incomplete, under development) |
|
||||
|
||||
**Dependency flow:** `core ← client ← {gui, tui}`
|
||||
|
||||
**Data flow:**
|
||||
|
||||
```
|
||||
[Transport] → read bytes → [MultiPipeline]
|
||||
→ Pipeline 1: Framer → Decoder → DecodedEntry → broadcast → GUI buffers
|
||||
→ Pipeline 2: Framer → Decoder → DecodedEntry → broadcast → GUI buffers
|
||||
→ ...
|
||||
```
|
||||
|
||||
Each pipeline frames and decodes independently. Only successful decodes produce output.
|
||||
|
||||
---
|
||||
|
||||
## Configuration & Persistence
|
||||
|
||||
GUI state is saved automatically to platform-standard locations:
|
||||
|
||||
| Platform | Path |
|
||||
|----------|------|
|
||||
| Linux | `$XDG_CONFIG_HOME/pipeview/gui-state.json` or `~/.config/pipeview/gui-state.json` |
|
||||
| macOS | `~/Library/Application Support/pipeview/gui-state.json` |
|
||||
| Windows | `%APPDATA%\pipeview\gui-state.json` |
|
||||
|
||||
Persisted data includes: session configurations (transport, pipelines), log settings, active tab, and display options.
|
||||
|
||||
Log files are written to `logs/` under the config directory, named `session_{id}_{timestamp}.log`.
|
||||
|
||||
---
|
||||
|
||||
## Development Tools
|
||||
|
||||
### Test Data Generators
|
||||
|
||||
```bash
|
||||
# Plot waveform test data
|
||||
python tools/test_plot.py --wire-format mixed --host 127.0.0.1 --port 8091
|
||||
python tools/test_plot.py --wire-format mixed --format xy --channels 2
|
||||
python tools/test_plot.py --wire-format raw --channels 2 --framelen 256
|
||||
|
||||
# Drone telemetry test data
|
||||
python tools/test_drone.py --rate 10 --port 8092
|
||||
```
|
||||
|
||||
### Profiling
|
||||
|
||||
```bash
|
||||
RUST_LOG=pipeview_gui::perf=info XSERIAL_GUI_PROFILE=1 cargo run -p pipeview-gui
|
||||
XSERIAL_GUI_PROFILE_INTERVAL_MS=500 cargo run -p pipeview-gui
|
||||
```
|
||||
|
||||
### Tracing
|
||||
|
||||
```bash
|
||||
RUST_LOG=info cargo run -p pipeview-gui
|
||||
RUST_LOG=pipeview_gui=debug cargo run -p pipeview-gui
|
||||
```
|
||||
|
||||
### Project Structure
|
||||
|
||||
```
|
||||
crates/
|
||||
pipeview-core/ # Transport, framing, protocol
|
||||
pipeview-client/ # Session management, Lua runtime
|
||||
pipeview-gui/ # egui desktop application
|
||||
pipeview-tui/ # ratatui terminal application
|
||||
examples/ # Lua script examples
|
||||
drone_plot.lua # Drone telemetry plot decoder
|
||||
drone_text.lua # Drone telemetry text decoder
|
||||
tests/ # Lua test fixtures
|
||||
tools/ # Development utilities
|
||||
test_plot.py # Waveform test data generator
|
||||
test_drone.py # Drone test data generator
|
||||
test_plot_serial.c # C serial plot test client
|
||||
test_text_serial.c # C serial text test client
|
||||
xs_mixed_plot.h # MixedTextPlot protocol reference
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- **Runtime**: tokio (multi-threaded)
|
||||
- **Serial**: `tokio-serial` + `serialport`
|
||||
- **GUI**: egui + egui_plot
|
||||
- **Lua**: mlua 0.11, LuaJIT (vendored), async/serde/send
|
||||
- **Events**: `tokio::sync::broadcast` (multi-subscriber)
|
||||
- **Zero feature flags**, zero build scripts, zero conditional compilation
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
394
README_zh.md
394
README_zh.md
@@ -1,394 +0,0 @@
|
||||
# xserial
|
||||
|
||||
**跨平台串口 / TCP / UDP 数据观测工具** — 可配置分帧、协议解码、多会话管理、实时波形绘图,单二进制零依赖。
|
||||
|
||||
基于 Rust + [egui](https://github.com/emilk/egui) 构建,支持 Lua 脚本扩展。
|
||||
|
||||
[English](README.md)
|
||||
|
||||
---
|
||||
|
||||
## 目录
|
||||
|
||||
- [功能特性](#功能特性)
|
||||
- [快速开始](#快速开始)
|
||||
- [完整示例:无人机遥测解析](#完整示例无人机遥测解析)
|
||||
- [Lua 脚本开发指南](#lua-脚本开发指南)
|
||||
- [架构](#架构)
|
||||
- [配置与持久化](#配置与持久化)
|
||||
- [开发工具](#开发工具)
|
||||
- [技术栈](#技术栈)
|
||||
- [License](#license)
|
||||
|
||||
---
|
||||
|
||||
## 功能特性
|
||||
|
||||
### 传输层
|
||||
|
||||
| 类型 | 说明 | 配置项 |
|
||||
|------|------|--------|
|
||||
| **Serial** | 串口通信 | 端口名、波特率 (300–12M)、数据位 (5/6/7/8)、校验位 (None/Odd/Even)、停止位 (1/2)、流控 (None/Software/Hardware)、DTR/RTS 控制 |
|
||||
| **TCP** | TCP 客户端 | 目标地址 `host:port` |
|
||||
| **UDP** | UDP 通信 | 绑定地址 `host:port`,可选远端地址 |
|
||||
|
||||
### 分帧器(Framer)
|
||||
|
||||
将原始字节流切分为独立帧。每个 Session 可配置**多条独立管线**,同一字节流并行送入多个 framer→decoder 链路。
|
||||
|
||||
| 分帧器 | 说明 | 配置参数 |
|
||||
|--------|------|----------|
|
||||
| **Line** | 按 `\n` 分割文本行 | `strip_cr`(去除 `\r`)、`max_line_len`(最大行长度) |
|
||||
| **Fixed** | 固定字节数为一帧 | `frame_len`(帧长度) |
|
||||
| **Length** | 长度前缀协议 | `len_bytes` (1/2/4/8)、`endian` (大小端)、`length_includes_self`、`max_payload` |
|
||||
| **COBS** | [Consistent Overhead Byte Stuffing](https://en.wikipedia.org/wiki/Consistent_Overhead_Byte_Stuffing) 编码 | `max_frame`(最大帧长) |
|
||||
| **MixedTextPlot** | 单连接混合文本行 + COBS 编码的 plot 帧 | `strip_cr`、`max_line_len`、`max_plot_frame` |
|
||||
| **Lua** | 用户自定义分帧脚本 | `script_path`(Lua 脚本路径) |
|
||||
|
||||
### 解码器(Decoder)
|
||||
|
||||
将帧数据解析为可展示的内容。
|
||||
|
||||
| 解码器 | 输出类型 | 配置参数 |
|
||||
|--------|----------|----------|
|
||||
| **Text** | 文本 | `encoding`(UTF-8 / Latin1 / ASCII) |
|
||||
| **Hex** | 十六进制 | `uppercase`、`separator`、`bytes_per_group`、`endian` |
|
||||
| **Plot** | 波形数据 | `sample_type` (i8–f64)、`endian`、`channels` (1–64)、`format` (Interleaved / Block / XY) |
|
||||
| **MixedTextPlot** | 混合文本 + 波形 | `encoding` |
|
||||
| **Lua** | 自定义 | `script_path`(Lua 脚本路径) |
|
||||
|
||||
**Plot 采样格式:**
|
||||
|
||||
| 格式 | 说明 | 字节排列 |
|
||||
|------|------|----------|
|
||||
| **Interleaved** | 多通道交叉排列 | `[ch0_s0, ch1_s0, ch2_s0, ch0_s1, ch1_s1, …]` |
|
||||
| **Block** | 按通道分块排列 | `[ch0_s0, ch0_s1, …, ch1_s0, ch1_s1, …]` |
|
||||
| **XY** | 2 通道交替 x/y | `[x0, y0, x1, y1, …]` |
|
||||
|
||||
支持的采样类型:`i8`、`u8`、`i16`、`u16`、`i32`、`u32`、`i64`、`u64`、`f32`、`f64`
|
||||
|
||||
### 视图
|
||||
|
||||
- **文本视图** — 带时间戳、方向标记 (`[IN]`/`[OUT]`)、管线标签的格式化文本流。支持搜索(`Ctrl+F`)、大小写匹配、匹配计数和高亮。
|
||||
- **十六进制视图** — hex dump 与 ASCII 侧栏并排展示,可按分组和大小端解析多字节数值。
|
||||
- **波形视图** — 基于 [egui_plot](https://github.com/emilk/egui/tree/master/crates/egui_plot) 的实时波形。支持自动缩放、框选缩放、坐标轴锁定、跟随最新数据、浮动窗口、通道图例。
|
||||
|
||||
### 会话管理
|
||||
|
||||
- **多会话并发** — 标签页切换,每个 Session 独立配置
|
||||
- **连接控制** — Connect / Disconnect / Reconnect,支持断线自动重连
|
||||
- **运行时重配置** — 修改分帧器/解码器参数无需断开连接
|
||||
- **数据发送** — Text 模式(UTF-8,可选换行符)和 Hex 模式(十六进制字节)。支持 `None` / `LF` / `CR` / `CRLF` 四种行尾
|
||||
- **环形缓冲** — 默认 10,000 条历史记录,可配置 100–1,000,000
|
||||
- **日志到文件** — 每行数据实时写入,1KB 缓冲 + 后台线程,不阻塞 UI
|
||||
- **搜索** — `Ctrl+F` 呼出搜索栏,大小写敏感切换,F3/Shift+F3 前后跳转
|
||||
|
||||
### Lua 脚本扩展
|
||||
|
||||
内置 LuaJIT 运行时:
|
||||
|
||||
- **自定义分帧器** — 实现 `feed(bytes)`、`flush()`、`reset()`、`pending_len()` 四个函数
|
||||
- **自定义解码器** — 实现 `decode(frame)` 函数,返回 text/hex/plot/binary 四种类型
|
||||
- **会话 API** — `xserial.open()` 创建 Session、`session:on_data()` 事件回调、`session:send()` 发送数据
|
||||
- **工具函数** — `xserial.list_ports()`、`xserial.sleep(ms)`、`xserial.poll(limit)`、`xserial.log(msg)`
|
||||
|
||||
### 快捷键
|
||||
|
||||
| 快捷键 | 操作 |
|
||||
|--------|------|
|
||||
| `Ctrl+N` | 新建会话 |
|
||||
| `Ctrl+E` | 编辑当前会话 |
|
||||
| `Ctrl+W` | 删除当前会话 |
|
||||
| `Ctrl+F5` | 切换连接 |
|
||||
| `Ctrl+T` / `Ctrl+H` / `Ctrl+P` | 切换到文本 / 十六进制 / 波形视图 |
|
||||
| `Ctrl+Tab` / `Ctrl+Shift+Tab` | 下一个 / 上一个标签页 |
|
||||
| `Ctrl+L` | 清空输出 |
|
||||
| `Ctrl+F` | 搜索 |
|
||||
| `F3` / `Shift+F3` | 上一个 / 下一个匹配 |
|
||||
| `Ctrl+,` | UI 设置 |
|
||||
| `Esc` | 关闭浮层 |
|
||||
|
||||
---
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 前置条件
|
||||
|
||||
| 平台 | 依赖 |
|
||||
|------|------|
|
||||
| **Linux** | `libudev-dev`(`apt install libudev-dev`) |
|
||||
| **macOS** | 无需额外依赖 |
|
||||
| **Windows** | 无需额外依赖 |
|
||||
| **所有平台** | Rust ≥ 1.85、C 编译器(GCC / Clang / MSVC) |
|
||||
|
||||
### 构建与运行
|
||||
|
||||
```bash
|
||||
# 克隆项目
|
||||
git clone https://github.com/your-org/xserial.git
|
||||
cd xserial
|
||||
|
||||
# 编译运行
|
||||
cargo run -p xserial-gui
|
||||
|
||||
# 运行测试
|
||||
cargo test --workspace # ~308 个测试
|
||||
cargo clippy --workspace --all-targets -- -D warnings
|
||||
```
|
||||
|
||||
### 发布构建
|
||||
|
||||
```bash
|
||||
cargo build -p xserial-gui --release
|
||||
# 二进制位于 target/release/xserial-gui (Linux/macOS)
|
||||
# 或 target/release/xserial-gui.exe (Windows)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 完整示例:无人机遥测解析
|
||||
|
||||
以 Betaflight/INAV 飞控遥测为例,数据格式为:
|
||||
|
||||
```
|
||||
AHRS q:1.0000,0.0998,0.0499,0.0200|YPR:8.87,4.44,2.96|Gyro:14.78,8.87,5.92|RC:1559,1544,1500,1519|M:1612,1588,1603,1597|L:0 F:1 C:0
|
||||
```
|
||||
|
||||
### 步骤 1:启动测试数据源
|
||||
|
||||
```bash
|
||||
python tools/test_drone.py --rate 10 --port 8091
|
||||
```
|
||||
|
||||
输出:
|
||||
|
||||
```
|
||||
Listening on 127.0.0.1:8091, waiting for connections...
|
||||
```
|
||||
|
||||
### 步骤 2:配置 Session
|
||||
|
||||
在 xserial-gui 中创建 Session:
|
||||
|
||||
| 配置项 | 值 |
|
||||
|--------|-----|
|
||||
| Transport | `TCP 127.0.0.1:8091` |
|
||||
| Pipeline 1 | Framer: `Line` → Decoder: `Lua` → 选择 `examples/drone_text.lua` |
|
||||
| Pipeline 2 | Framer: `Line` → Decoder: `Lua` → 选择 `examples/drone_plot.lua` |
|
||||
|
||||
### 步骤 3:查看结果
|
||||
|
||||
- **文本视图** — 显示格式化的传感器数据
|
||||
- **波形视图** — 显示 Gyro 三轴实时曲线 (gz/gy/gx)
|
||||
|
||||
`examples/drone_plot.lua` 的核心逻辑:
|
||||
|
||||
```lua
|
||||
return {
|
||||
decode = function(frame)
|
||||
local gz, gy, gx = frame:match("Gyro:([%d%.%-]+),([%d%.%-]+),([%d%.%-]+)")
|
||||
return {
|
||||
kind = "plot",
|
||||
channels = { { tonumber(gz) }, { tonumber(gy) }, { tonumber(gx) } },
|
||||
sample_type = "F64",
|
||||
format = "Block",
|
||||
}
|
||||
end,
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Lua 脚本开发指南
|
||||
|
||||
### 分帧器 API
|
||||
|
||||
分帧器将原始字节流切分为帧,Lua 脚本必须返回包含以下函数的 table:
|
||||
|
||||
```lua
|
||||
return {
|
||||
-- 输入新到达的字节,返回帧数组(Lua strings)
|
||||
feed = function(bytes)
|
||||
-- bytes: Lua string(原始字节)
|
||||
-- 返回: { frame1, frame2, ... } 或 nil
|
||||
end,
|
||||
|
||||
-- 刷新缓冲区中残留的数据
|
||||
flush = function()
|
||||
-- 返回: 最后一帧(Lua string)或 nil
|
||||
end,
|
||||
|
||||
-- 重置内部状态
|
||||
reset = function()
|
||||
end,
|
||||
|
||||
-- 返回缓冲区中待处理的字节数
|
||||
pending_len = function()
|
||||
-- 返回: number
|
||||
end,
|
||||
}
|
||||
```
|
||||
|
||||
参考实现:`tests/lua_line_framer.lua`(按 `\n` 分割的行分帧器)
|
||||
|
||||
### 解码器 API
|
||||
|
||||
解码器将帧解析为结构化数据,Lua 脚本必须返回包含 `decode` 函数的 table:
|
||||
|
||||
```lua
|
||||
return {
|
||||
decode = function(frame)
|
||||
-- frame: Lua string(来自分帧器的一帧)
|
||||
-- 返回 nil → 跳过此帧
|
||||
-- 返回 string → 自动视为 Text
|
||||
-- 返回 table → 必须包含 kind 字段
|
||||
end,
|
||||
}
|
||||
```
|
||||
|
||||
**返回值格式:**
|
||||
|
||||
| `kind` | 必需字段 | 可选字段 | 用途 |
|
||||
|--------|----------|----------|------|
|
||||
| `"text"` | `data: string` | — | 文本视图 |
|
||||
| `"hex"` | `data: string` | — | 十六进制视图 |
|
||||
| `"binary"` | `data: string` | — | 原始二进制 |
|
||||
| `"plot"` | `channels: {{number,…},…}` | `sample_type`、`format` | 波形视图 |
|
||||
|
||||
**Plot 返回值示例:**
|
||||
|
||||
```lua
|
||||
return {
|
||||
kind = "plot",
|
||||
channels = {
|
||||
{ 1.0, 2.0, 3.0 }, -- channel 0
|
||||
{ 4.0, 5.0, 6.0 }, -- channel 1
|
||||
},
|
||||
sample_type = "F64", -- 默认 F64,可选 I8/U8/I16/U16/I32/U32/I64/U64/F32
|
||||
format = "Block", -- 默认 Interleaved,可选 Block/XY
|
||||
}
|
||||
```
|
||||
|
||||
更多示例见 `examples/` 目录。
|
||||
|
||||
---
|
||||
|
||||
## 架构
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────┐
|
||||
│ xserial-gui (egui) xserial-tui (ratatui) │
|
||||
├──────────────────────────────────────────────────┤
|
||||
│ xserial-client │
|
||||
│ SessionManager · Session · Config · History │
|
||||
│ Lua Runtime (mlua / LuaJIT) │
|
||||
├──────────────────────────────────────────────────┤
|
||||
│ xserial-core │
|
||||
│ Transport ──▶ Frame ──▶ Protocol ──▶ Pipeline │
|
||||
└──────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
| Crate | 类型 | 职责 |
|
||||
|-------|------|------|
|
||||
| `xserial-core` | library | 传输层(Serial/TCP/UDP)、分帧器(Line/Fixed/Length/COBS/Mixed/Lua)、协议解码器(Text/Hex/Plot)、MultiPipeline |
|
||||
| `xserial-client` | library | Session 生命周期管理、SessionManager、事件广播(tokio broadcast)、RingBuffer 历史、Lua 运行时及会话 API |
|
||||
| `xserial-gui` | binary | egui 桌面应用,包含 sidebar/config/console/text/hex/plot 面板、键盘快捷键、字体管理、性能分析 |
|
||||
| `xserial-tui` | binary | ratatui 终端应用(功能未对齐 GUI,仍在开发中) |
|
||||
|
||||
**依赖方向:** `core ← client ← {gui, tui}`
|
||||
|
||||
**数据流:**
|
||||
|
||||
```
|
||||
[Transport] → read bytes → [MultiPipeline]
|
||||
→ Pipeline 1: Framer → Decoder → DecodedEntry → broadcast → GUI buffers
|
||||
→ Pipeline 2: Framer → Decoder → DecodedEntry → broadcast → GUI buffers
|
||||
→ ...
|
||||
```
|
||||
|
||||
每条管线独立分帧、解码,互不干扰。只有成功解码的管线产生输出。
|
||||
|
||||
---
|
||||
|
||||
## 配置与持久化
|
||||
|
||||
GUI 状态自动保存,路径遵循各平台规范:
|
||||
|
||||
| 平台 | 路径 |
|
||||
|------|------|
|
||||
| Linux | `$XDG_CONFIG_HOME/xserial/gui-state.json` 或 `~/.config/xserial/gui-state.json` |
|
||||
| macOS | `~/Library/Application Support/xserial/gui-state.json` |
|
||||
| Windows | `%APPDATA%\xserial\gui-state.json` |
|
||||
|
||||
持久化的内容包括:Session 配置(传输参数、管线设置)、日志开关及路径、活动标签页、显示选项。
|
||||
|
||||
日志文件默认保存在配置目录的 `logs/` 子目录下,文件命名格式为 `session_{id}_{timestamp}.log`。
|
||||
|
||||
---
|
||||
|
||||
## 开发工具
|
||||
|
||||
### 测试数据生成器
|
||||
|
||||
```bash
|
||||
# Plot 波形测试数据
|
||||
python tools/test_plot.py --wire-format mixed --host 127.0.0.1 --port 8091
|
||||
python tools/test_plot.py --wire-format mixed --format xy --channels 2
|
||||
python tools/test_plot.py --wire-format raw --channels 2 --framelen 256
|
||||
|
||||
# 无人机遥测测试数据
|
||||
python tools/test_drone.py --rate 10 --port 8092
|
||||
```
|
||||
|
||||
### 性能分析
|
||||
|
||||
```bash
|
||||
RUST_LOG=xserial_gui::perf=info XSERIAL_GUI_PROFILE=1 cargo run -p xserial-gui
|
||||
XSERIAL_GUI_PROFILE_INTERVAL_MS=500 cargo run -p xserial-gui
|
||||
```
|
||||
|
||||
输出每帧的耗时、事件 drain 耗时、text/hex/plot 渲染耗时、plot 点数统计。
|
||||
|
||||
### 日志
|
||||
|
||||
```bash
|
||||
RUST_LOG=info cargo run -p xserial-gui # 应用日志
|
||||
RUST_LOG=xserial_gui=debug cargo run -p xserial-gui # 详细日志
|
||||
```
|
||||
|
||||
使用 `tracing-subscriber` + `RUST_LOG` 环境变量控制。
|
||||
|
||||
### 项目结构
|
||||
|
||||
```
|
||||
crates/
|
||||
xserial-core/ # 传输、分帧、协议
|
||||
xserial-client/ # 会话管理、Lua 运行时
|
||||
xserial-gui/ # egui 桌面应用
|
||||
xserial-tui/ # ratatui 终端应用
|
||||
examples/ # Lua 脚本示例
|
||||
drone_plot.lua # 飞控遥测波形解码器
|
||||
drone_text.lua # 飞控遥测文本解码器
|
||||
tests/ # Lua 测试 fixture
|
||||
tools/ # 开发辅助工具
|
||||
test_plot.py # 波形测试数据生成器
|
||||
test_drone.py # 飞控测试数据生成器
|
||||
test_plot_serial.c # C 串口波形测试客户端
|
||||
test_text_serial.c # C 串口文本测试客户端
|
||||
xs_mixed_plot.h # MixedTextPlot 协议参考头文件
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 技术栈
|
||||
|
||||
- **运行时**:tokio (multi-thread)
|
||||
- **串口**:`tokio-serial` + `serialport`
|
||||
- **GUI**:egui + egui_plot
|
||||
- **Lua**:mlua 0.11, LuaJIT (vendored 编译), async/serde/send
|
||||
- **事件分发**:`tokio::sync::broadcast`(多订阅者)
|
||||
- **零 feature flags**、零 build script、零条件编译
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
@@ -1,10 +1,10 @@
|
||||
[package]
|
||||
name = "xserial-client"
|
||||
name = "pipeview-client"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
xserial-core = { path = "../xserial-core" }
|
||||
pipeview-core = { path = "../pipeview-core" }
|
||||
tokio = { workspace = true, features = ["sync", "time"] }
|
||||
tracing = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
@@ -1,5 +1,4 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use xserial_core::frame::{
|
||||
use pipeview_core::frame::{
|
||||
Endian, Framer,
|
||||
cobs::CobsFramer,
|
||||
fixed::FixedLengthFramer,
|
||||
@@ -7,14 +6,15 @@ use xserial_core::frame::{
|
||||
line::{LineConfig, LineFramer},
|
||||
mixed::{MixedTextPlotConfig as MixedFramerConfig, MixedTextPlotFramer},
|
||||
};
|
||||
use xserial_core::protocol::{
|
||||
use pipeview_core::protocol::{
|
||||
ProtocolDecoder,
|
||||
hex::{HexConfig, HexDecoder},
|
||||
mixed::{MixedTextPlotConfig as MixedDecoderConfig, MixedTextPlotDecoder},
|
||||
plot::{PlotConfig, PlotDecoder, PlotFormat, SampleType},
|
||||
text::{TextDecoder, TextEncoding},
|
||||
};
|
||||
use xserial_core::transport::TransportConfig;
|
||||
use pipeview_core::transport::TransportConfig;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::error::Result;
|
||||
use crate::lua::codec::{LuaDecoder, LuaFramer};
|
||||
@@ -229,10 +229,10 @@ impl Default for SessionConfig {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use xserial_core::protocol::Endian;
|
||||
use xserial_core::protocol::plot::{PlotFormat, SampleType};
|
||||
use xserial_core::protocol::text::TextEncoding;
|
||||
use xserial_core::transport::TransportConfig;
|
||||
use pipeview_core::protocol::Endian;
|
||||
use pipeview_core::protocol::plot::{PlotFormat, SampleType};
|
||||
use pipeview_core::protocol::text::TextEncoding;
|
||||
use pipeview_core::transport::TransportConfig;
|
||||
|
||||
#[test]
|
||||
fn framer_line_serde_roundtrip() {
|
||||
@@ -1,4 +1,4 @@
|
||||
use xserial_core::protocol::DecodedData;
|
||||
use pipeview_core::protocol::DecodedData;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DecodedEntry {
|
||||
@@ -2,10 +2,10 @@ use std::fs;
|
||||
use std::sync::Mutex;
|
||||
|
||||
use mlua::{Function, Lua, RegistryKey, Table, Value};
|
||||
use pipeview_core::frame::Framer;
|
||||
use pipeview_core::protocol::plot::{PlotFormat, PlotFrame, SampleType};
|
||||
use pipeview_core::protocol::{DecodedData, ProtocolDecoder};
|
||||
use tracing::warn;
|
||||
use xserial_core::frame::Framer;
|
||||
use xserial_core::protocol::plot::{PlotFormat, PlotFrame, SampleType};
|
||||
use xserial_core::protocol::{DecodedData, ProtocolDecoder};
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
|
||||
@@ -297,7 +297,7 @@ mod tests {
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos();
|
||||
let path = std::env::temp_dir().join(format!("xserial_{name}_{nonce}.lua"));
|
||||
let path = std::env::temp_dir().join(format!("pipeview_{name}_{nonce}.lua"));
|
||||
fs::write(&path, script).unwrap();
|
||||
path
|
||||
}
|
||||
@@ -81,9 +81,9 @@ pub fn register(lua: &Lua) -> LuaResult<()> {
|
||||
|
||||
pub fn register_with_manager(lua: &Lua, manager: SessionManager) -> LuaResult<()> {
|
||||
let runtime = LuaRuntime::new(manager);
|
||||
let xserial = lua.create_table()?;
|
||||
let pipeview = lua.create_table()?;
|
||||
|
||||
xserial.set("open", {
|
||||
pipeview.set("open", {
|
||||
let runtime = runtime.clone();
|
||||
lua.create_async_function(move |lua, config: Table| {
|
||||
let runtime = runtime.clone();
|
||||
@@ -96,15 +96,15 @@ pub fn register_with_manager(lua: &Lua, manager: SessionManager) -> LuaResult<()
|
||||
})?
|
||||
})?;
|
||||
|
||||
xserial.set(
|
||||
pipeview.set(
|
||||
"list_ports",
|
||||
lua.create_function(|_, ()| {
|
||||
let ports = xserial_core::transport::serial::SerialTransport::list_ports();
|
||||
let ports = pipeview_core::transport::serial::SerialTransport::list_ports();
|
||||
Ok(ports.into_iter().map(|p| p.port_name).collect::<Vec<_>>())
|
||||
})?,
|
||||
)?;
|
||||
|
||||
xserial.set("sleep", {
|
||||
pipeview.set("sleep", {
|
||||
let runtime = runtime.clone();
|
||||
lua.create_async_function(move |lua, ms: u64| {
|
||||
let runtime = runtime.clone();
|
||||
@@ -116,7 +116,7 @@ pub fn register_with_manager(lua: &Lua, manager: SessionManager) -> LuaResult<()
|
||||
})?
|
||||
})?;
|
||||
|
||||
xserial.set("poll", {
|
||||
pipeview.set("poll", {
|
||||
let runtime = runtime.clone();
|
||||
lua.create_async_function(move |lua, limit_per_session: Option<usize>| {
|
||||
let runtime = runtime.clone();
|
||||
@@ -124,7 +124,7 @@ pub fn register_with_manager(lua: &Lua, manager: SessionManager) -> LuaResult<()
|
||||
})?
|
||||
})?;
|
||||
|
||||
xserial.set(
|
||||
pipeview.set(
|
||||
"log",
|
||||
lua.create_function(|_, msg: String| {
|
||||
tracing::info!("[lua] {}", msg);
|
||||
@@ -132,7 +132,7 @@ pub fn register_with_manager(lua: &Lua, manager: SessionManager) -> LuaResult<()
|
||||
})?,
|
||||
)?;
|
||||
|
||||
lua.globals().set("xserial", xserial)?;
|
||||
lua.globals().set("pipeview", pipeview)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -146,9 +146,9 @@ mod tests {
|
||||
let lua = Lua::new();
|
||||
register(&lua).unwrap();
|
||||
|
||||
let xserial: Table = lua.globals().get("xserial").unwrap();
|
||||
let pipeview: Table = lua.globals().get("pipeview").unwrap();
|
||||
for name in ["open", "list_ports", "sleep", "poll", "log"] {
|
||||
let value: Value = xserial.get(name).unwrap();
|
||||
let value: Value = pipeview.get(name).unwrap();
|
||||
assert!(
|
||||
matches!(value, Value::Function(_)),
|
||||
"{name} should be a function"
|
||||
@@ -162,8 +162,8 @@ mod tests {
|
||||
let manager = SessionManager::new();
|
||||
register_with_manager(&lua, manager.clone()).unwrap();
|
||||
|
||||
let xserial: Table = lua.globals().get("xserial").unwrap();
|
||||
let open: Value = xserial.get("open").unwrap();
|
||||
let pipeview: Table = lua.globals().get("pipeview").unwrap();
|
||||
let open: Value = pipeview.get("open").unwrap();
|
||||
assert!(matches!(open, Value::Function(_)));
|
||||
assert_eq!(manager.count(), 0);
|
||||
}
|
||||
@@ -6,9 +6,9 @@ use std::sync::{
|
||||
use std::time::Duration;
|
||||
|
||||
use mlua::{Function, Lua, LuaSerdeExt, RegistryKey, UserData, UserDataMethods, Value, Variadic};
|
||||
use pipeview_core::protocol::DecodedData;
|
||||
use tokio::sync::mpsc;
|
||||
use tokio::task::JoinHandle;
|
||||
use xserial_core::protocol::DecodedData;
|
||||
|
||||
use crate::config::SessionConfig;
|
||||
use crate::lua::LuaRuntime;
|
||||
@@ -11,8 +11,8 @@ use tokio::sync::{Mutex, broadcast, mpsc};
|
||||
use tokio::task::JoinHandle;
|
||||
use tracing::{debug, error, info, warn};
|
||||
|
||||
use xserial_core::pipeline::{MultiPipeline, Pipeline};
|
||||
use xserial_core::transport::Connection;
|
||||
use pipeview_core::pipeline::{MultiPipeline, Pipeline};
|
||||
use pipeview_core::transport::Connection;
|
||||
|
||||
use crate::cmd::SessionCmd;
|
||||
use crate::config::SessionConfig;
|
||||
@@ -499,7 +499,7 @@ async fn try_read(
|
||||
}
|
||||
}
|
||||
|
||||
fn to_io_error(err: xserial_core::error::Error) -> std::io::Error {
|
||||
fn to_io_error(err: pipeview_core::error::Error) -> std::io::Error {
|
||||
io::Error::other(err.to_string())
|
||||
}
|
||||
|
||||
@@ -510,7 +510,7 @@ mod tests {
|
||||
|
||||
fn tcp_config() -> SessionConfig {
|
||||
SessionConfig {
|
||||
transport: xserial_core::transport::TransportConfig::Tcp {
|
||||
transport: pipeview_core::transport::TransportConfig::Tcp {
|
||||
addr: "127.0.0.1:1".into(),
|
||||
},
|
||||
pipelines: vec![PipelineConfig {
|
||||
@@ -520,7 +520,7 @@ mod tests {
|
||||
max_line_len: 65536,
|
||||
},
|
||||
decoder: DecoderConfig::Text {
|
||||
encoding: xserial_core::protocol::text::TextEncoding::Utf8,
|
||||
encoding: pipeview_core::protocol::text::TextEncoding::Utf8,
|
||||
},
|
||||
}],
|
||||
..Default::default()
|
||||
@@ -11,7 +11,7 @@ static INIT: Once = Once::new();
|
||||
fn init_tracing() {
|
||||
INIT.call_once(|| {
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter("xserial=trace")
|
||||
.with_env_filter("pipeview=trace")
|
||||
.try_init()
|
||||
.ok();
|
||||
});
|
||||
@@ -22,7 +22,7 @@ fn write_temp_lua(name: &str, script: &str) -> PathBuf {
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos();
|
||||
let path = std::env::temp_dir().join(format!("xserial_{name}_{nonce}.lua"));
|
||||
let path = std::env::temp_dir().join(format!("pipeview_{name}_{nonce}.lua"));
|
||||
fs::write(&path, script).unwrap();
|
||||
path
|
||||
}
|
||||
@@ -47,18 +47,18 @@ fn hex_pipeline_lua() -> &'static str {
|
||||
"#
|
||||
}
|
||||
|
||||
// ── xserial.sleep / xserial.log ──────────────────────────────────
|
||||
// ── pipeview.sleep / pipeview.log ──────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn lua_sleep_and_log() {
|
||||
let lua = Lua::new();
|
||||
xserial_client::lua::register(&lua).unwrap();
|
||||
pipeview_client::lua::register(&lua).unwrap();
|
||||
|
||||
lua.load(
|
||||
r#"
|
||||
xserial.log("test start")
|
||||
xserial.sleep(50)
|
||||
xserial.log("test end")
|
||||
pipeview.log("test start")
|
||||
pipeview.sleep(50)
|
||||
pipeview.log("test end")
|
||||
"#,
|
||||
)
|
||||
.exec_async()
|
||||
@@ -66,15 +66,15 @@ async fn lua_sleep_and_log() {
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
// ── xserial.list_ports ───────────────────────────────────────────
|
||||
// ── pipeview.list_ports ───────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn lua_list_ports() {
|
||||
let lua = Lua::new();
|
||||
xserial_client::lua::register(&lua).unwrap();
|
||||
pipeview_client::lua::register(&lua).unwrap();
|
||||
|
||||
let result: Vec<String> = lua
|
||||
.load(r#"return xserial.list_ports()"#)
|
||||
.load(r#"return pipeview.list_ports()"#)
|
||||
.eval_async()
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -84,7 +84,7 @@ async fn lua_list_ports() {
|
||||
let _ = result;
|
||||
}
|
||||
|
||||
// ── xserial.open + session:send / session:read / session:close ────
|
||||
// ── pipeview.open + session:send / session:read / session:close ────
|
||||
|
||||
#[tokio::test]
|
||||
async fn lua_session_open_send_read_close() {
|
||||
@@ -102,11 +102,11 @@ async fn lua_session_open_send_read_close() {
|
||||
});
|
||||
|
||||
let lua = Lua::new();
|
||||
xserial_client::lua::register(&lua).unwrap();
|
||||
pipeview_client::lua::register(&lua).unwrap();
|
||||
|
||||
let script = format!(
|
||||
r#"
|
||||
local sess = xserial.open({{
|
||||
local sess = pipeview.open({{
|
||||
transport = {{ Tcp = {{ addr = "{}" }} }},
|
||||
pipelines = {{{}}}
|
||||
}})
|
||||
@@ -141,11 +141,11 @@ async fn lua_session_read_timeout() {
|
||||
});
|
||||
|
||||
let lua = Lua::new();
|
||||
xserial_client::lua::register(&lua).unwrap();
|
||||
pipeview_client::lua::register(&lua).unwrap();
|
||||
|
||||
let script = format!(
|
||||
r#"
|
||||
local sess = xserial.open({{
|
||||
local sess = pipeview.open({{
|
||||
transport = {{ Tcp = {{ addr = "{}" }} }},
|
||||
pipelines = {{{}}}
|
||||
}})
|
||||
@@ -162,7 +162,7 @@ async fn lua_session_read_timeout() {
|
||||
lua.load(&script).exec_async().await.unwrap();
|
||||
}
|
||||
|
||||
// ── xserial.open with hex decoder ────────────────────────────────
|
||||
// ── pipeview.open with hex decoder ────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn lua_session_hex_decoder() {
|
||||
@@ -176,11 +176,11 @@ async fn lua_session_hex_decoder() {
|
||||
});
|
||||
|
||||
let lua = Lua::new();
|
||||
xserial_client::lua::register(&lua).unwrap();
|
||||
pipeview_client::lua::register(&lua).unwrap();
|
||||
|
||||
let script = format!(
|
||||
r#"
|
||||
local sess = xserial.open({{
|
||||
local sess = pipeview.open({{
|
||||
transport = {{ Tcp = {{ addr = "{}" }} }},
|
||||
pipelines = {{{}}}
|
||||
}})
|
||||
@@ -201,7 +201,7 @@ async fn lua_session_hex_decoder() {
|
||||
server.await.unwrap();
|
||||
}
|
||||
|
||||
// ── xserial.open with Lua framer + Lua decoder ───────────────────
|
||||
// ── pipeview.open with Lua framer + Lua decoder ───────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn lua_session_custom_lua_pipeline() {
|
||||
@@ -259,11 +259,11 @@ async fn lua_session_custom_lua_pipeline() {
|
||||
});
|
||||
|
||||
let lua = Lua::new();
|
||||
xserial_client::lua::register(&lua).unwrap();
|
||||
pipeview_client::lua::register(&lua).unwrap();
|
||||
|
||||
let script = format!(
|
||||
r#"
|
||||
local sess = xserial.open({{
|
||||
local sess = pipeview.open({{
|
||||
transport = {{ Tcp = {{ addr = "{}" }} }},
|
||||
pipelines = {{
|
||||
{{
|
||||
@@ -311,12 +311,12 @@ async fn lua_session_on_data_callback() {
|
||||
});
|
||||
|
||||
let lua = Lua::new();
|
||||
xserial_client::lua::register(&lua).unwrap();
|
||||
pipeview_client::lua::register(&lua).unwrap();
|
||||
|
||||
let script = format!(
|
||||
r#"
|
||||
local received = {{}}
|
||||
local sess = xserial.open({{
|
||||
local sess = pipeview.open({{
|
||||
transport = {{ Tcp = {{ addr = "{}" }} }},
|
||||
pipelines = {{{}}}
|
||||
}})
|
||||
@@ -327,8 +327,8 @@ async fn lua_session_on_data_callback() {
|
||||
|
||||
for _ = 1, 50 do
|
||||
if #received < 2 then
|
||||
xserial.poll(1)
|
||||
xserial.sleep(10)
|
||||
pipeview.poll(1)
|
||||
pipeview.sleep(10)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -371,11 +371,11 @@ async fn lua_session_reconfigure() {
|
||||
});
|
||||
|
||||
let lua = Lua::new();
|
||||
xserial_client::lua::register(&lua).unwrap();
|
||||
pipeview_client::lua::register(&lua).unwrap();
|
||||
|
||||
let script = format!(
|
||||
r#"
|
||||
local sess = xserial.open({{
|
||||
local sess = pipeview.open({{
|
||||
transport = {{ Tcp = {{ addr = "{}" }} }},
|
||||
pipelines = {{{}}}
|
||||
}})
|
||||
@@ -420,11 +420,11 @@ async fn lua_session_next_event_stream() {
|
||||
});
|
||||
|
||||
let lua = Lua::new();
|
||||
xserial_client::lua::register(&lua).unwrap();
|
||||
pipeview_client::lua::register(&lua).unwrap();
|
||||
|
||||
let script = format!(
|
||||
r#"
|
||||
local sess = xserial.open({{
|
||||
local sess = pipeview.open({{
|
||||
transport = {{ Tcp = {{ addr = "{}" }} }},
|
||||
pipelines = {{{}}}
|
||||
}})
|
||||
@@ -450,7 +450,7 @@ async fn lua_session_next_event_stream() {
|
||||
server.await.unwrap();
|
||||
}
|
||||
|
||||
// ── session:off + xserial.poll ───────────────────────────────────
|
||||
// ── session:off + pipeview.poll ───────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn lua_session_off_stops_future_callbacks() {
|
||||
@@ -466,12 +466,12 @@ async fn lua_session_off_stops_future_callbacks() {
|
||||
});
|
||||
|
||||
let lua = Lua::new();
|
||||
xserial_client::lua::register(&lua).unwrap();
|
||||
pipeview_client::lua::register(&lua).unwrap();
|
||||
|
||||
let script = format!(
|
||||
r#"
|
||||
local received = {{}}
|
||||
local sess = xserial.open({{
|
||||
local sess = pipeview.open({{
|
||||
transport = {{ Tcp = {{ addr = "{}" }} }},
|
||||
pipelines = {{{}}}
|
||||
}})
|
||||
@@ -482,8 +482,8 @@ async fn lua_session_off_stops_future_callbacks() {
|
||||
|
||||
for _ = 1, 50 do
|
||||
if #received == 0 then
|
||||
xserial.poll(1)
|
||||
xserial.sleep(10)
|
||||
pipeview.poll(1)
|
||||
pipeview.sleep(10)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -491,8 +491,8 @@ async fn lua_session_off_stops_future_callbacks() {
|
||||
assert(received[1] == "text:first")
|
||||
assert(sess:off(token) == true)
|
||||
|
||||
xserial.sleep(250)
|
||||
xserial.poll(10)
|
||||
pipeview.sleep(250)
|
||||
pipeview.poll(10)
|
||||
|
||||
assert(#received == 1, "callback should not fire after off")
|
||||
assert(sess:off(token) == false)
|
||||
@@ -3,11 +3,11 @@ use std::time::Duration;
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::net::TcpListener;
|
||||
|
||||
use xserial_client::SessionManager;
|
||||
use xserial_client::config::{DecoderConfig, FramerConfig, PipelineConfig, SessionConfig};
|
||||
use xserial_client::session::{Session, SessionEvent};
|
||||
use xserial_core::protocol::DecodedData;
|
||||
use xserial_core::transport::TransportConfig;
|
||||
use pipeview_client::SessionManager;
|
||||
use pipeview_client::config::{DecoderConfig, FramerConfig, PipelineConfig, SessionConfig};
|
||||
use pipeview_client::session::{Session, SessionEvent};
|
||||
use pipeview_core::protocol::DecodedData;
|
||||
use pipeview_core::transport::TransportConfig;
|
||||
|
||||
fn tcp_config(addr: String) -> SessionConfig {
|
||||
SessionConfig {
|
||||
@@ -19,7 +19,7 @@ fn tcp_config(addr: String) -> SessionConfig {
|
||||
max_line_len: 1024 * 1024,
|
||||
},
|
||||
decoder: DecoderConfig::Text {
|
||||
encoding: xserial_core::protocol::text::TextEncoding::Utf8,
|
||||
encoding: pipeview_core::protocol::text::TextEncoding::Utf8,
|
||||
},
|
||||
}],
|
||||
history_limit: 100,
|
||||
@@ -113,7 +113,7 @@ async fn session_multi_pipeline_text_and_hex() {
|
||||
uppercase: false,
|
||||
separator: " ".into(),
|
||||
bytes_per_group: 1,
|
||||
endian: xserial_core::protocol::Endian::Big,
|
||||
endian: pipeview_core::protocol::Endian::Big,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -285,7 +285,7 @@ async fn session_reconfigure_changes_pipeline() {
|
||||
uppercase: true,
|
||||
separator: " ".into(),
|
||||
bytes_per_group: 1,
|
||||
endian: xserial_core::protocol::Endian::Big,
|
||||
endian: pipeview_core::protocol::Endian::Big,
|
||||
};
|
||||
|
||||
handle.reconfigure(new_cfg).await.unwrap();
|
||||
@@ -1,5 +1,5 @@
|
||||
[package]
|
||||
name = "xserial-core"
|
||||
name = "pipeview-core"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
@@ -61,11 +61,11 @@ pub struct PipelineResult {
|
||||
/// Manages multiple [`Pipeline`]s, feeding the same byte stream to all of them.
|
||||
///
|
||||
/// ```
|
||||
/// use xserial_core::pipeline::{MultiPipeline, Pipeline};
|
||||
/// use xserial_core::frame::line::{LineFramer, LineConfig};
|
||||
/// use xserial_core::frame::fixed::FixedLengthFramer;
|
||||
/// use xserial_core::protocol::text::{TextDecoder, TextEncoding};
|
||||
/// use xserial_core::protocol::hex::{HexDecoder, HexConfig};
|
||||
/// use pipeview_core::pipeline::{MultiPipeline, Pipeline};
|
||||
/// use pipeview_core::frame::line::{LineFramer, LineConfig};
|
||||
/// use pipeview_core::frame::fixed::FixedLengthFramer;
|
||||
/// use pipeview_core::protocol::text::{TextDecoder, TextEncoding};
|
||||
/// use pipeview_core::protocol::hex::{HexDecoder, HexConfig};
|
||||
///
|
||||
/// let mut mp = MultiPipeline::new();
|
||||
/// mp.add(
|
||||
@@ -173,22 +173,18 @@ impl Connection {
|
||||
pub fn set_dtr(&mut self, state: bool) -> Result<()> {
|
||||
match self {
|
||||
Connection::Serial(t) => t.set_dtr(state),
|
||||
Connection::Tcp(_) | Connection::Udp(_) => {
|
||||
Err(crate::error::Error::ConnectionFailed(
|
||||
Connection::Tcp(_) | Connection::Udp(_) => Err(crate::error::Error::ConnectionFailed(
|
||||
"DTR only supported on Serial connections".into(),
|
||||
))
|
||||
}
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_rts(&mut self, state: bool) -> Result<()> {
|
||||
match self {
|
||||
Connection::Serial(t) => t.set_rts(state),
|
||||
Connection::Tcp(_) | Connection::Udp(_) => {
|
||||
Err(crate::error::Error::ConnectionFailed(
|
||||
Connection::Tcp(_) | Connection::Udp(_) => Err(crate::error::Error::ConnectionFailed(
|
||||
"RTS only supported on Serial connections".into(),
|
||||
))
|
||||
}
|
||||
)),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
use async_trait::async_trait;
|
||||
use serialport::SerialPort;
|
||||
use tokio::io::{AsyncRead, AsyncWrite, ReadBuf};
|
||||
use tokio_serial::{DataBits, FlowControl, Parity, SerialPortBuilderExt, SerialStream, StopBits};
|
||||
use serialport::SerialPort;
|
||||
use tracing::{debug, info, warn};
|
||||
|
||||
use super::{Transport, TransportType};
|
||||
@@ -277,10 +277,16 @@ impl Transport for SerialTransport {
|
||||
// HC-15, HC-05, Bluetooth/UART bridges) need DTR asserted to stay in
|
||||
// transparent data mode and not fall into AT-command / reset state.
|
||||
if let Err(e) = port.write_data_terminal_ready(self.dtr) {
|
||||
warn!("Failed to set DTR({}) on {}: {}", self.dtr, self.port_name, e);
|
||||
warn!(
|
||||
"Failed to set DTR({}) on {}: {}",
|
||||
self.dtr, self.port_name, e
|
||||
);
|
||||
}
|
||||
if let Err(e) = port.write_request_to_send(self.rts) {
|
||||
warn!("Failed to set RTS({}) on {}: {}", self.rts, self.port_name, e);
|
||||
warn!(
|
||||
"Failed to set RTS({}) on {}: {}",
|
||||
self.rts, self.port_name, e
|
||||
);
|
||||
}
|
||||
|
||||
debug!("Serial port {} opened successfully", self.port_name);
|
||||
@@ -4,7 +4,7 @@ use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::net::{TcpListener, UdpSocket};
|
||||
use tokio::time::timeout;
|
||||
|
||||
use xserial_core::frame::{
|
||||
use pipeview_core::frame::{
|
||||
Endian, Framer,
|
||||
cobs::CobsFramer,
|
||||
cobs::cobs_encode as raw_cobs_encode,
|
||||
@@ -13,17 +13,17 @@ use xserial_core::frame::{
|
||||
line::{LineConfig, LineFramer},
|
||||
mixed::{MixedTextPlotConfig as MixedFramerConfig, MixedTextPlotFramer},
|
||||
};
|
||||
use xserial_core::protocol::{
|
||||
use pipeview_core::protocol::{
|
||||
DecodedData, ProtocolDecoder,
|
||||
hex::{HexConfig, HexDecoder},
|
||||
mixed::{MIXED_PLOT_ESCAPE, MIXED_PLOT_MARKER, MixedTextPlotConfig, MixedTextPlotDecoder},
|
||||
plot::{PlotConfig, PlotDecoder, PlotFormat, SampleType},
|
||||
text::{TextDecoder, TextEncoding},
|
||||
};
|
||||
use xserial_core::transport::serial::{
|
||||
use pipeview_core::transport::serial::{
|
||||
SerialDataBits, SerialFlowControl, SerialParity, SerialStopBits,
|
||||
};
|
||||
use xserial_core::transport::{Connection, TransportConfig, TransportType};
|
||||
use pipeview_core::transport::{Connection, TransportConfig, TransportType};
|
||||
|
||||
const TEST_TIMEOUT: Duration = Duration::from_secs(5);
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
[package]
|
||||
name = "xserial-gui"
|
||||
name = "pipeview-gui"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[[bin]]
|
||||
name = "xserial-gui"
|
||||
name = "pipeview-gui"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
xserial-core = { path = "../xserial-core" }
|
||||
xserial-client = { path = "../xserial-client" }
|
||||
pipeview-core = { path = "../pipeview-core" }
|
||||
pipeview-client = { path = "../pipeview-client" }
|
||||
tokio = { workspace = true }
|
||||
egui = { workspace = true }
|
||||
eframe = { workspace = true }
|
||||
@@ -19,3 +19,4 @@ tracing-subscriber = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
hex = "0.4"
|
||||
ansitok = "0.3"
|
||||
451
crates/pipeview-gui/src/ansi_render.rs
Normal file
451
crates/pipeview-gui/src/ansi_render.rs
Normal file
@@ -0,0 +1,451 @@
|
||||
use ansitok::{AnsiColor, ElementKind, VisualAttribute, parse_ansi, parse_ansi_sgr};
|
||||
use egui::Color32;
|
||||
use egui::text::{LayoutJob, TextFormat};
|
||||
use std::ops::Range;
|
||||
|
||||
pub fn ansi_to_layout_job(text: &str, base_format: TextFormat) -> LayoutJob {
|
||||
let mut job = LayoutJob::default();
|
||||
for segment in ansi_segments(text, &base_format) {
|
||||
job.append(segment.text, 0.0, segment.format);
|
||||
}
|
||||
|
||||
job
|
||||
}
|
||||
|
||||
pub fn ansi_to_layout_job_highlighted(
|
||||
text: &str,
|
||||
base_format: TextFormat,
|
||||
query: &str,
|
||||
case_sensitive: bool,
|
||||
highlight_format: TextFormat,
|
||||
) -> LayoutJob {
|
||||
if query.is_empty() {
|
||||
return ansi_to_layout_job(text, base_format);
|
||||
}
|
||||
|
||||
let segments = ansi_segments(text, &base_format);
|
||||
let mut visible_text = String::with_capacity(segments.iter().map(|s| s.text.len()).sum());
|
||||
for segment in &segments {
|
||||
visible_text.push_str(segment.text);
|
||||
}
|
||||
|
||||
let ranges = search_ranges(&visible_text, query, case_sensitive);
|
||||
if ranges.is_empty() {
|
||||
let mut job = LayoutJob::default();
|
||||
for segment in segments {
|
||||
job.append(segment.text, 0.0, segment.format);
|
||||
}
|
||||
return job;
|
||||
}
|
||||
|
||||
let mut job = LayoutJob::default();
|
||||
let mut offset = 0;
|
||||
let mut range_idx = 0;
|
||||
for segment in segments {
|
||||
let len = segment.text.len();
|
||||
append_segment_with_highlights(
|
||||
&mut job,
|
||||
segment.text,
|
||||
segment.format,
|
||||
&highlight_format,
|
||||
offset,
|
||||
&ranges,
|
||||
&mut range_idx,
|
||||
);
|
||||
offset += len;
|
||||
}
|
||||
job
|
||||
}
|
||||
|
||||
struct StyledSegment<'a> {
|
||||
text: &'a str,
|
||||
format: TextFormat,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct AnsiStyle {
|
||||
fg: Option<Color32>,
|
||||
bg: Option<Color32>,
|
||||
bold: bool,
|
||||
italic: bool,
|
||||
underline: bool,
|
||||
strikethrough: bool,
|
||||
}
|
||||
|
||||
impl AnsiStyle {
|
||||
fn apply_sgr(&mut self, attr: VisualAttribute) {
|
||||
match attr {
|
||||
VisualAttribute::Reset(0) => self.reset_all(),
|
||||
VisualAttribute::Reset(22) => self.bold = false,
|
||||
VisualAttribute::Reset(23) => self.italic = false,
|
||||
VisualAttribute::Reset(24) => self.underline = false,
|
||||
VisualAttribute::Reset(29) => self.strikethrough = false,
|
||||
VisualAttribute::Reset(39) => self.fg = None,
|
||||
VisualAttribute::Reset(49) => self.bg = None,
|
||||
VisualAttribute::Reset(_) => {}
|
||||
VisualAttribute::Bold => self.bold = true,
|
||||
VisualAttribute::Faint => self.bold = false,
|
||||
VisualAttribute::Italic => self.italic = true,
|
||||
VisualAttribute::Underline => self.underline = true,
|
||||
VisualAttribute::Crossedout => self.strikethrough = true,
|
||||
VisualAttribute::FgColor(c) => self.fg = Some(ansi_color_to_egui(c)),
|
||||
VisualAttribute::BgColor(c) => self.bg = Some(ansi_color_to_egui(c)),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn reset_all(&mut self) {
|
||||
*self = Self::default();
|
||||
}
|
||||
|
||||
fn format(&self, base_format: &TextFormat) -> TextFormat {
|
||||
let mut fmt = base_format.clone();
|
||||
|
||||
if let Some(c) = self.fg {
|
||||
if self.bold {
|
||||
fmt.color = brighten(c);
|
||||
} else {
|
||||
fmt.color = c;
|
||||
}
|
||||
}
|
||||
if let Some(c) = self.bg {
|
||||
fmt.background = c;
|
||||
}
|
||||
if self.italic {
|
||||
fmt.italics = true;
|
||||
}
|
||||
if self.underline {
|
||||
fmt.underline = egui::Stroke::new(1.0, fmt.color);
|
||||
}
|
||||
if self.strikethrough {
|
||||
fmt.strikethrough = egui::Stroke::new(1.0, fmt.color);
|
||||
}
|
||||
|
||||
fmt
|
||||
}
|
||||
}
|
||||
|
||||
fn ansi_segments<'a>(text: &'a str, base_format: &TextFormat) -> Vec<StyledSegment<'a>> {
|
||||
let mut segments = Vec::new();
|
||||
let mut style = AnsiStyle::default();
|
||||
|
||||
for element in parse_ansi(text) {
|
||||
match element.kind() {
|
||||
ElementKind::Text => {
|
||||
let slice = &text[element.start()..element.end()];
|
||||
if !slice.is_empty() {
|
||||
segments.push(StyledSegment {
|
||||
text: slice,
|
||||
format: style.format(base_format),
|
||||
});
|
||||
}
|
||||
}
|
||||
ElementKind::Sgr => {
|
||||
let sgr = &text[element.start()..element.end()];
|
||||
apply_sgr_sequence(&mut style, sgr);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
segments
|
||||
}
|
||||
|
||||
fn apply_sgr_sequence(style: &mut AnsiStyle, sgr: &str) {
|
||||
let params = sgr
|
||||
.strip_prefix("\x1b[")
|
||||
.and_then(|s| s.strip_suffix('m'))
|
||||
.unwrap_or(sgr);
|
||||
|
||||
if params.is_empty() {
|
||||
style.reset_all();
|
||||
return;
|
||||
}
|
||||
|
||||
for output in parse_ansi_sgr(sgr) {
|
||||
if let Some(attr) = output.as_escape() {
|
||||
style.apply_sgr(attr);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn search_ranges(text: &str, query: &str, case_sensitive: bool) -> Vec<Range<usize>> {
|
||||
if query.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
if case_sensitive {
|
||||
return text
|
||||
.match_indices(query)
|
||||
.map(|(start, matched)| start..start + matched.len())
|
||||
.collect();
|
||||
}
|
||||
|
||||
let (search_text, byte_to_original) = lowercase_with_byte_map(text);
|
||||
let query = query.to_lowercase();
|
||||
let mut ranges = Vec::new();
|
||||
let mut idx = 0;
|
||||
while let Some(pos) = search_text[idx..].find(&query) {
|
||||
let start = idx + pos;
|
||||
let end = start + query.len();
|
||||
let original_start = byte_to_original.get(start).copied().unwrap_or(text.len());
|
||||
let original_end = byte_to_original.get(end).copied().unwrap_or(text.len());
|
||||
if original_start < original_end {
|
||||
ranges.push(original_start..original_end);
|
||||
}
|
||||
idx = end;
|
||||
}
|
||||
ranges
|
||||
}
|
||||
|
||||
fn lowercase_with_byte_map(text: &str) -> (String, Vec<usize>) {
|
||||
let mut lowered = String::new();
|
||||
let mut byte_to_original = Vec::new();
|
||||
|
||||
for (original_idx, ch) in text.char_indices() {
|
||||
for lower_ch in ch.to_lowercase() {
|
||||
lowered.push(lower_ch);
|
||||
for _ in 0..lower_ch.len_utf8() {
|
||||
byte_to_original.push(original_idx);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
byte_to_original.push(text.len());
|
||||
(lowered, byte_to_original)
|
||||
}
|
||||
|
||||
fn append_segment_with_highlights(
|
||||
job: &mut LayoutJob,
|
||||
text: &str,
|
||||
format: TextFormat,
|
||||
highlight_format: &TextFormat,
|
||||
segment_start: usize,
|
||||
ranges: &[Range<usize>],
|
||||
range_idx: &mut usize,
|
||||
) {
|
||||
let segment_end = segment_start + text.len();
|
||||
while *range_idx < ranges.len() && ranges[*range_idx].end <= segment_start {
|
||||
*range_idx += 1;
|
||||
}
|
||||
|
||||
let mut idx = *range_idx;
|
||||
let mut last = 0;
|
||||
while idx < ranges.len() && ranges[idx].start < segment_end {
|
||||
let range = &ranges[idx];
|
||||
let start = range.start.max(segment_start) - segment_start;
|
||||
let end = range.end.min(segment_end) - segment_start;
|
||||
|
||||
if last < start {
|
||||
job.append(&text[last..start], 0.0, format.clone());
|
||||
}
|
||||
if start < end {
|
||||
job.append(&text[start..end], 0.0, highlight_format.clone());
|
||||
}
|
||||
last = end;
|
||||
|
||||
if range.end <= segment_end {
|
||||
idx += 1;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
*range_idx = idx;
|
||||
if last < text.len() {
|
||||
job.append(&text[last..], 0.0, format);
|
||||
}
|
||||
}
|
||||
|
||||
fn ansi_color_to_egui(color: AnsiColor) -> Color32 {
|
||||
match color {
|
||||
AnsiColor::Bit4(c) => ansi_4bit(c),
|
||||
AnsiColor::Bit8(c) => ansi_256(c),
|
||||
AnsiColor::Bit24 { r, g, b } => Color32::from_rgb(r, g, b),
|
||||
}
|
||||
}
|
||||
|
||||
fn ansi_4bit(code: u8) -> Color32 {
|
||||
match code {
|
||||
30 => Color32::BLACK,
|
||||
31 => Color32::from_rgb(194, 54, 33),
|
||||
32 => Color32::from_rgb(37, 188, 36),
|
||||
33 => Color32::from_rgb(173, 173, 39),
|
||||
34 => Color32::from_rgb(73, 46, 225),
|
||||
35 => Color32::from_rgb(211, 56, 211),
|
||||
36 => Color32::from_rgb(51, 187, 200),
|
||||
37 => Color32::from_rgb(203, 204, 205),
|
||||
|
||||
90 => Color32::from_rgb(129, 131, 131),
|
||||
91 => Color32::from_rgb(252, 57, 31),
|
||||
92 => Color32::from_rgb(49, 231, 34),
|
||||
93 => Color32::from_rgb(234, 236, 35),
|
||||
94 => Color32::from_rgb(88, 51, 255),
|
||||
95 => Color32::from_rgb(249, 53, 248),
|
||||
96 => Color32::from_rgb(20, 240, 240),
|
||||
97 => Color32::from_rgb(233, 235, 235),
|
||||
|
||||
40 => Color32::BLACK,
|
||||
41 => Color32::from_rgb(194, 54, 33),
|
||||
42 => Color32::from_rgb(37, 188, 36),
|
||||
43 => Color32::from_rgb(173, 173, 39),
|
||||
44 => Color32::from_rgb(73, 46, 225),
|
||||
45 => Color32::from_rgb(211, 56, 211),
|
||||
46 => Color32::from_rgb(51, 187, 200),
|
||||
47 => Color32::from_rgb(203, 204, 205),
|
||||
|
||||
100 => Color32::from_rgb(129, 131, 131),
|
||||
101 => Color32::from_rgb(252, 57, 31),
|
||||
102 => Color32::from_rgb(49, 231, 34),
|
||||
103 => Color32::from_rgb(234, 236, 35),
|
||||
104 => Color32::from_rgb(88, 51, 255),
|
||||
105 => Color32::from_rgb(249, 53, 248),
|
||||
106 => Color32::from_rgb(20, 240, 240),
|
||||
107 => Color32::from_rgb(233, 235, 235),
|
||||
|
||||
_ => Color32::WHITE,
|
||||
}
|
||||
}
|
||||
|
||||
fn ansi_256(code: u8) -> Color32 {
|
||||
match code {
|
||||
0..=15 => ansi_4bit(if code < 8 { code + 30 } else { code + 82 }),
|
||||
16..=231 => {
|
||||
let idx = code - 16;
|
||||
let cube = [0, 95, 135, 175, 215, 255];
|
||||
let r = cube[(idx / 36) as usize];
|
||||
let g = cube[((idx / 6) % 6) as usize];
|
||||
let b = cube[(idx % 6) as usize];
|
||||
Color32::from_rgb(r, g, b)
|
||||
}
|
||||
232..=255 => {
|
||||
let v = (code - 232) * 10 + 8;
|
||||
Color32::from_rgb(v, v, v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn brighten(color: Color32) -> Color32 {
|
||||
Color32::from_rgb(
|
||||
(color.r() as u32 * 13 / 10).min(255) as u8,
|
||||
(color.g() as u32 * 13 / 10).min(255) as u8,
|
||||
(color.b() as u32 * 13 / 10).min(255) as u8,
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn visible_text(job: &LayoutJob) -> String {
|
||||
job.sections
|
||||
.iter()
|
||||
.map(|s| &job.text[s.byte_range.clone()])
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plain_text_no_ansi() {
|
||||
let fmt = TextFormat::default();
|
||||
let job = ansi_to_layout_job("hello", fmt);
|
||||
assert_eq!(job.sections.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fg_red_reset() {
|
||||
let fmt = TextFormat::default();
|
||||
let job = ansi_to_layout_job("\x1b[31mred\x1b[0m plain", fmt);
|
||||
assert!(job.sections.len() >= 2);
|
||||
assert!(job.sections[0].format.color != job.sections[1].format.color);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_sgr_resets_all_styles() {
|
||||
let fmt = TextFormat {
|
||||
color: Color32::from_rgb(1, 2, 3),
|
||||
..Default::default()
|
||||
};
|
||||
let job = ansi_to_layout_job("\x1b[31mred\x1b[m plain", fmt.clone());
|
||||
|
||||
assert_eq!(visible_text(&job), "red plain");
|
||||
assert!(job.sections.len() >= 2);
|
||||
assert_ne!(job.sections[0].format.color, job.sections[1].format.color);
|
||||
assert_eq!(job.sections[1].format.color, fmt.color);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selective_foreground_reset_keeps_background() {
|
||||
let fmt = TextFormat {
|
||||
color: Color32::from_rgb(1, 2, 3),
|
||||
..Default::default()
|
||||
};
|
||||
let job = ansi_to_layout_job("\x1b[41;37mtext\x1b[39mmore", fmt.clone());
|
||||
|
||||
assert_eq!(visible_text(&job), "textmore");
|
||||
assert!(job.sections.len() >= 2);
|
||||
assert_ne!(job.sections[0].format.color, job.sections[1].format.color);
|
||||
assert_eq!(job.sections[1].format.color, fmt.color);
|
||||
assert_ne!(job.sections[0].format.background, fmt.background);
|
||||
assert_eq!(
|
||||
job.sections[0].format.background,
|
||||
job.sections[1].format.background
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bold_brightens_color() {
|
||||
let fmt = TextFormat::default();
|
||||
let job = ansi_to_layout_job("\x1b[1;31mbold red\x1b[0m", fmt);
|
||||
assert_eq!(job.sections.len(), 1);
|
||||
let c = job.sections[0].format.color;
|
||||
assert!(c.r() > 200 || c.g() > 50 || c.b() > 30);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strip_ansi_codes_from_output() {
|
||||
let fmt = TextFormat::default();
|
||||
let job = ansi_to_layout_job("\x1b[32mgreen\x1b[0m", fmt);
|
||||
let text: String = job
|
||||
.sections
|
||||
.iter()
|
||||
.map(|s| &job.text[s.byte_range.clone()])
|
||||
.collect();
|
||||
assert_eq!(text, "green");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn highlighted_search_uses_visible_ansi_text() {
|
||||
let default = TextFormat {
|
||||
color: Color32::WHITE,
|
||||
..Default::default()
|
||||
};
|
||||
let highlight = TextFormat {
|
||||
color: Color32::BLACK,
|
||||
background: Color32::from_rgb(255, 255, 0),
|
||||
..Default::default()
|
||||
};
|
||||
let job = ansi_to_layout_job_highlighted(
|
||||
"\x1b[31mred\x1b[0m plain",
|
||||
default,
|
||||
"red plain",
|
||||
true,
|
||||
highlight.clone(),
|
||||
);
|
||||
|
||||
assert_eq!(visible_text(&job), "red plain");
|
||||
assert!(job.sections.len() >= 2);
|
||||
assert!(
|
||||
job.sections
|
||||
.iter()
|
||||
.all(|section| section.format.background == highlight.background)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn xterm_256_color_cube_values() {
|
||||
assert_eq!(ansi_256(16), Color32::from_rgb(0, 0, 0));
|
||||
assert_eq!(ansi_256(21), Color32::from_rgb(0, 0, 255));
|
||||
assert_eq!(ansi_256(52), Color32::from_rgb(95, 0, 0));
|
||||
assert_eq!(ansi_256(67), Color32::from_rgb(95, 135, 175));
|
||||
}
|
||||
}
|
||||
@@ -10,11 +10,11 @@ use crate::perf::{DrainStats, GuiProfiler, GuiSnapshot};
|
||||
use crate::shortcuts::{self, default_bindings};
|
||||
use crate::ui_fonts::{self, FontCandidate, FontChoice, UiFontSettings};
|
||||
use egui::{Color32, Layout, Panel, Pos2, Rect, TextEdit, UiBuilder};
|
||||
use xserial_client::SessionManager;
|
||||
use xserial_client::config::SessionConfig;
|
||||
use xserial_client::session::SessionEvent;
|
||||
use xserial_core::protocol::DecodedData;
|
||||
use xserial_core::transport::TransportConfig;
|
||||
use pipeview_client::SessionManager;
|
||||
use pipeview_client::config::SessionConfig;
|
||||
use pipeview_client::session::SessionEvent;
|
||||
use pipeview_core::protocol::DecodedData;
|
||||
use pipeview_core::transport::TransportConfig;
|
||||
|
||||
const DATA_REPAINT_INTERVAL: Duration = Duration::from_millis(33);
|
||||
|
||||
@@ -851,7 +851,7 @@ impl XserialApp {
|
||||
fn render_top_bar(&mut self, ui: &mut egui::Ui) {
|
||||
Panel::top("top_bar").show_inside(ui, |ui| {
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
ui.heading("xserial");
|
||||
ui.heading("pipeview");
|
||||
ui.separator();
|
||||
// ui.label(format!(
|
||||
// "Fonts: {} + {} {:.1} pt",
|
||||
@@ -1310,18 +1310,22 @@ fn render_send_panel(ui: &mut egui::Ui, manager: &SessionManager, tab: &mut Sess
|
||||
ui.add_space(3.0);
|
||||
|
||||
let hint = match tab.send_mode {
|
||||
SendMode::Text => "Enter text to send",
|
||||
SendMode::Text => "Enter to send, Ctrl+Enter for newline",
|
||||
SendMode::Hex => "Enter hex bytes, e.g. 48 65 6C 6C 6F",
|
||||
};
|
||||
let response = ui.add(
|
||||
TextEdit::multiline(&mut tab.send_input)
|
||||
.desired_rows(6)
|
||||
.desired_width(f32::INFINITY)
|
||||
.return_key(egui::KeyboardShortcut::new(
|
||||
egui::Modifiers::COMMAND,
|
||||
egui::Key::Enter,
|
||||
))
|
||||
.hint_text(hint),
|
||||
);
|
||||
|
||||
let wants_submit = response.has_focus()
|
||||
&& ui.input(|input| input.key_pressed(egui::Key::Enter) && input.modifiers.command_only());
|
||||
&& ui.input(|input| input.key_pressed(egui::Key::Enter) && input.modifiers.is_none());
|
||||
|
||||
let mut send_clicked = false;
|
||||
ui.horizontal(|ui| {
|
||||
@@ -1434,8 +1438,8 @@ fn build_payload(tab: &SessionTab) -> Result<Option<Vec<u8>>, String> {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::transport_summary;
|
||||
use xserial_core::transport::TransportConfig;
|
||||
use xserial_core::transport::serial::{
|
||||
use pipeview_core::transport::TransportConfig;
|
||||
use pipeview_core::transport::serial::{
|
||||
SerialDataBits, SerialFlowControl, SerialParity, SerialStopBits,
|
||||
};
|
||||
|
||||
@@ -3,9 +3,9 @@ use std::fs;
|
||||
use std::io;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use pipeview_client::config::SessionConfig;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tracing::warn;
|
||||
use xserial_client::config::SessionConfig;
|
||||
|
||||
const GUI_STATE_FILE_NAME: &str = "gui-state.json";
|
||||
|
||||
@@ -54,7 +54,7 @@ fn gui_state_path() -> PathBuf {
|
||||
gui_state_path_for_os_and_env(env::consts::OS, |key| env::var(key).ok())
|
||||
}
|
||||
|
||||
/// Returns the xserial configuration directory (the parent of gui-state.json).
|
||||
/// Returns the pipeview configuration directory (the parent of gui-state.json).
|
||||
pub fn config_dir() -> PathBuf {
|
||||
gui_state_path()
|
||||
.parent()
|
||||
@@ -67,12 +67,12 @@ fn gui_state_path_for_os_and_env(os: &str, get_env: impl Fn(&str) -> Option<Stri
|
||||
"windows" => {
|
||||
if let Some(path) = get_env("APPDATA").filter(|path| !path.trim().is_empty()) {
|
||||
return PathBuf::from(path)
|
||||
.join("xserial")
|
||||
.join("pipeview")
|
||||
.join(GUI_STATE_FILE_NAME);
|
||||
}
|
||||
if let Some(path) = get_env("LOCALAPPDATA").filter(|path| !path.trim().is_empty()) {
|
||||
return PathBuf::from(path)
|
||||
.join("xserial")
|
||||
.join("pipeview")
|
||||
.join(GUI_STATE_FILE_NAME);
|
||||
}
|
||||
}
|
||||
@@ -81,20 +81,20 @@ fn gui_state_path_for_os_and_env(os: &str, get_env: impl Fn(&str) -> Option<Stri
|
||||
return PathBuf::from(home)
|
||||
.join("Library")
|
||||
.join("Application Support")
|
||||
.join("xserial")
|
||||
.join("pipeview")
|
||||
.join(GUI_STATE_FILE_NAME);
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
if let Some(path) = get_env("XDG_CONFIG_HOME").filter(|path| !path.trim().is_empty()) {
|
||||
return PathBuf::from(path)
|
||||
.join("xserial")
|
||||
.join("pipeview")
|
||||
.join(GUI_STATE_FILE_NAME);
|
||||
}
|
||||
if let Some(home) = get_env("HOME").filter(|path| !path.trim().is_empty()) {
|
||||
return PathBuf::from(home)
|
||||
.join(".config")
|
||||
.join("xserial")
|
||||
.join("pipeview")
|
||||
.join(GUI_STATE_FILE_NAME);
|
||||
}
|
||||
}
|
||||
@@ -123,8 +123,8 @@ const fn default_true() -> bool {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use pipeview_core::transport::TransportConfig;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
use xserial_core::transport::TransportConfig;
|
||||
|
||||
#[test]
|
||||
fn windows_gui_state_path_uses_appdata() {
|
||||
@@ -135,7 +135,7 @@ mod tests {
|
||||
assert_eq!(
|
||||
path,
|
||||
PathBuf::from(r"C:\Users\Test\AppData\Roaming")
|
||||
.join("xserial")
|
||||
.join("pipeview")
|
||||
.join(GUI_STATE_FILE_NAME)
|
||||
);
|
||||
}
|
||||
@@ -146,7 +146,7 @@ mod tests {
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos();
|
||||
let dir = env::temp_dir().join(format!("xserial-gui-state-{unique}"));
|
||||
let dir = env::temp_dir().join(format!("pipeview-gui-state-{unique}"));
|
||||
let path = dir.join(GUI_STATE_FILE_NAME);
|
||||
let state = PersistedGuiState {
|
||||
sessions: vec![SessionConfig {
|
||||
@@ -1,10 +1,10 @@
|
||||
use egui_plot::PlotBounds;
|
||||
use pipeview_client::RingBuffer;
|
||||
use pipeview_client::event::DecodedEntry;
|
||||
use pipeview_core::protocol::DecodedData;
|
||||
use pipeview_core::protocol::plot::{PlotFormat, PlotFrame};
|
||||
use std::collections::VecDeque;
|
||||
use std::time::{Duration, Instant};
|
||||
use xserial_client::RingBuffer;
|
||||
use xserial_client::event::DecodedEntry;
|
||||
use xserial_core::protocol::DecodedData;
|
||||
use xserial_core::protocol::plot::{PlotFormat, PlotFrame};
|
||||
|
||||
#[derive(Clone)]
|
||||
pub enum LineDirection {
|
||||
@@ -716,9 +716,9 @@ impl PlotBuffer {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use xserial_client::event::DecodedEntry;
|
||||
use xserial_core::protocol::DecodedData;
|
||||
use xserial_core::protocol::plot::{PlotFrame, SampleType};
|
||||
use pipeview_client::event::DecodedEntry;
|
||||
use pipeview_core::protocol::DecodedData;
|
||||
use pipeview_core::protocol::plot::{PlotFrame, SampleType};
|
||||
|
||||
fn plot_entry() -> DecodedEntry {
|
||||
DecodedEntry {
|
||||
@@ -6,8 +6,8 @@ use std::thread;
|
||||
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use xserial_client::event::DecodedEntry;
|
||||
use xserial_core::protocol::DecodedData;
|
||||
use pipeview_client::event::DecodedEntry;
|
||||
use pipeview_core::protocol::DecodedData;
|
||||
|
||||
/// Manages background file logging via a dedicated writer thread.
|
||||
///
|
||||
@@ -30,7 +30,7 @@ impl LogWriter {
|
||||
let mut writer = BufWriter::with_capacity(1024, file);
|
||||
let (sender, receiver) = mpsc::channel::<String>();
|
||||
|
||||
let thread_name = format!("xserial-log-{}", path.display());
|
||||
let thread_name = format!("pipeview-log-{}", path.display());
|
||||
thread::Builder::new().name(thread_name).spawn(move || {
|
||||
while let Ok(line) = receiver.recv() {
|
||||
if writeln!(writer, "{line}").is_err() {
|
||||
65
crates/pipeview-gui/src/main.rs
Normal file
65
crates/pipeview-gui/src/main.rs
Normal file
@@ -0,0 +1,65 @@
|
||||
#![cfg_attr(windows, windows_subsystem = "windows")]
|
||||
|
||||
mod ansi_render;
|
||||
mod app;
|
||||
mod app_state;
|
||||
mod buffers;
|
||||
mod logging;
|
||||
mod panels;
|
||||
mod perf;
|
||||
mod shortcuts;
|
||||
mod ui_fonts;
|
||||
|
||||
use pipeview_client::SessionManager;
|
||||
|
||||
/// On Linux, when launched from a file manager, a terminal emulator may be spawned
|
||||
/// to run this binary. We re-spawn ourselves detached so the terminal can close
|
||||
/// while the GUI window persists.
|
||||
#[cfg(target_os = "linux")]
|
||||
fn ensure_detached() {
|
||||
use std::os::unix::process::CommandExt;
|
||||
use std::process;
|
||||
|
||||
if std::env::var("PIPEVIEW_DETACHED").is_ok() {
|
||||
return;
|
||||
}
|
||||
|
||||
let exe = match std::env::current_exe() {
|
||||
Ok(p) => p,
|
||||
Err(_) => return,
|
||||
};
|
||||
|
||||
let args: Vec<String> = std::env::args().skip(1).collect();
|
||||
|
||||
let _ = process::Command::new(&exe)
|
||||
.args(&args)
|
||||
.env("PIPEVIEW_DETACHED", "1")
|
||||
.stdin(process::Stdio::null())
|
||||
.stdout(process::Stdio::null())
|
||||
.stderr(process::Stdio::null())
|
||||
.process_group(0)
|
||||
.spawn();
|
||||
|
||||
process::exit(0);
|
||||
}
|
||||
|
||||
fn main() {
|
||||
#[cfg(target_os = "linux")]
|
||||
ensure_detached();
|
||||
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(tracing_subscriber::EnvFilter::from_default_env())
|
||||
.init();
|
||||
|
||||
let rt = tokio::runtime::Runtime::new().expect("tokio runtime");
|
||||
let _guard = rt.enter();
|
||||
|
||||
let mgr = SessionManager::new();
|
||||
let rx = mgr.subscribe();
|
||||
|
||||
let _ = eframe::run_native(
|
||||
"pipeview",
|
||||
eframe::NativeOptions::default(),
|
||||
Box::new(|cc| Ok(Box::new(app::XserialApp::new(mgr, rx, cc.egui_ctx.clone())))),
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
use egui::{ComboBox, DragValue, ScrollArea, TextEdit, Ui};
|
||||
use xserial_client::config::{DecoderConfig, FramerConfig, PipelineConfig, SessionConfig};
|
||||
use xserial_core::protocol::Endian;
|
||||
use xserial_core::protocol::plot::{PlotFormat, SampleType};
|
||||
use xserial_core::protocol::text::TextEncoding;
|
||||
use xserial_core::transport::TransportConfig;
|
||||
use xserial_core::transport::serial::{
|
||||
use pipeview_client::config::{DecoderConfig, FramerConfig, PipelineConfig, SessionConfig};
|
||||
use pipeview_core::protocol::Endian;
|
||||
use pipeview_core::protocol::plot::{PlotFormat, SampleType};
|
||||
use pipeview_core::protocol::text::TextEncoding;
|
||||
use pipeview_core::transport::TransportConfig;
|
||||
use pipeview_core::transport::serial::{
|
||||
SerialDataBits, SerialFlowControl, SerialParity, SerialStopBits, SerialTransport,
|
||||
};
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
use crate::ansi_render::{ansi_to_layout_job, ansi_to_layout_job_highlighted};
|
||||
use crate::app::{DisplayOptions, SearchState};
|
||||
use crate::buffers::{ConsoleLine, LineDirection, TextBuffer};
|
||||
use egui::{
|
||||
@@ -31,42 +32,6 @@ pub fn render(
|
||||
line_count
|
||||
}
|
||||
|
||||
fn highlight_matches(
|
||||
text: &str,
|
||||
query: &str,
|
||||
case_sensitive: bool,
|
||||
default_format: TextFormat,
|
||||
highlight_format: TextFormat,
|
||||
) -> LayoutJob {
|
||||
let search_text = if case_sensitive {
|
||||
text.to_string()
|
||||
} else {
|
||||
text.to_lowercase()
|
||||
};
|
||||
let query = if case_sensitive {
|
||||
query.to_string()
|
||||
} else {
|
||||
query.to_lowercase()
|
||||
};
|
||||
let mut job = LayoutJob::default();
|
||||
let mut last_end = 0;
|
||||
let mut idx = 0;
|
||||
while let Some(pos) = search_text[idx..].find(&query) {
|
||||
let start = idx + pos;
|
||||
let end = start + query.len();
|
||||
if last_end < start {
|
||||
job.append(&text[last_end..start], 0.0, default_format.clone());
|
||||
}
|
||||
job.append(&text[start..end], 0.0, highlight_format.clone());
|
||||
last_end = end;
|
||||
idx = end;
|
||||
}
|
||||
if last_end < text.len() {
|
||||
job.append(&text[last_end..], 0.0, default_format);
|
||||
}
|
||||
job
|
||||
}
|
||||
|
||||
fn monospace_format(style: &egui::Style) -> TextFormat {
|
||||
TextFormat {
|
||||
font_id: style
|
||||
@@ -121,9 +86,13 @@ pub fn format_console_line(
|
||||
};
|
||||
|
||||
match search {
|
||||
Some(state) if state.active && !state.query.is_empty() => {
|
||||
highlight_matches(&full_text, &state.query, state.case_sensitive, default, highlight)
|
||||
}
|
||||
_ => LayoutJob::single_section(full_text, default),
|
||||
Some(state) if state.active && !state.query.is_empty() => ansi_to_layout_job_highlighted(
|
||||
&full_text,
|
||||
default,
|
||||
&state.query,
|
||||
state.case_sensitive,
|
||||
highlight,
|
||||
),
|
||||
_ => ansi_to_layout_job(&full_text, default),
|
||||
}
|
||||
}
|
||||
@@ -125,10 +125,13 @@ fn format_hex_line(
|
||||
};
|
||||
|
||||
match search {
|
||||
Some(state) if state.active && !state.query.is_empty() => {
|
||||
highlight_matches(&full_text, &state.query, state.case_sensitive, default, highlight)
|
||||
}
|
||||
Some(state) if state.active && !state.query.is_empty() => highlight_matches(
|
||||
&full_text,
|
||||
&state.query,
|
||||
state.case_sensitive,
|
||||
default,
|
||||
highlight,
|
||||
),
|
||||
_ => LayoutJob::single_section(full_text, default),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -202,7 +202,7 @@ impl GuiProfiler {
|
||||
|
||||
let repaints = self.repaint_counter.take();
|
||||
info!(
|
||||
target: "xserial_gui::perf",
|
||||
target: "pipeview_gui::perf",
|
||||
frames = self.interval_stats.frames.count,
|
||||
frame_avg_ms = self.interval_stats.frames.avg_ms(),
|
||||
frame_max_ms = self.interval_stats.frames.max_ms(),
|
||||
@@ -56,10 +56,7 @@ pub fn default_bindings() -> Vec<(Action, KeyboardShortcut)> {
|
||||
),
|
||||
(Search, KeyboardShortcut::new(Modifiers::CTRL, Key::F)),
|
||||
(SearchNext, KeyboardShortcut::new(Modifiers::NONE, Key::F3)),
|
||||
(
|
||||
SearchPrev,
|
||||
KeyboardShortcut::new(Modifiers::SHIFT, Key::F3),
|
||||
),
|
||||
(SearchPrev, KeyboardShortcut::new(Modifiers::SHIFT, Key::F3)),
|
||||
]
|
||||
}
|
||||
|
||||
@@ -358,12 +358,12 @@ fn font_settings_path_for_os_and_env(
|
||||
"windows" => {
|
||||
if let Some(path) = get_env("APPDATA").filter(|path| !path.trim().is_empty()) {
|
||||
return PathBuf::from(path)
|
||||
.join("xserial")
|
||||
.join("pipeview")
|
||||
.join(FONT_SETTINGS_FILE_NAME);
|
||||
}
|
||||
if let Some(path) = get_env("LOCALAPPDATA").filter(|path| !path.trim().is_empty()) {
|
||||
return PathBuf::from(path)
|
||||
.join("xserial")
|
||||
.join("pipeview")
|
||||
.join(FONT_SETTINGS_FILE_NAME);
|
||||
}
|
||||
}
|
||||
@@ -372,20 +372,20 @@ fn font_settings_path_for_os_and_env(
|
||||
return PathBuf::from(home)
|
||||
.join("Library")
|
||||
.join("Application Support")
|
||||
.join("xserial")
|
||||
.join("pipeview")
|
||||
.join(FONT_SETTINGS_FILE_NAME);
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
if let Some(path) = get_env("XDG_CONFIG_HOME").filter(|path| !path.trim().is_empty()) {
|
||||
return PathBuf::from(path)
|
||||
.join("xserial")
|
||||
.join("pipeview")
|
||||
.join(FONT_SETTINGS_FILE_NAME);
|
||||
}
|
||||
if let Some(home) = get_env("HOME").filter(|path| !path.trim().is_empty()) {
|
||||
return PathBuf::from(home)
|
||||
.join(".config")
|
||||
.join("xserial")
|
||||
.join("pipeview")
|
||||
.join(FONT_SETTINGS_FILE_NAME);
|
||||
}
|
||||
}
|
||||
@@ -591,7 +591,7 @@ mod tests {
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos();
|
||||
let dir = env::temp_dir().join(format!("xserial-gui-font-settings-{unique}"));
|
||||
let dir = env::temp_dir().join(format!("pipeview-gui-font-settings-{unique}"));
|
||||
let path = dir.join("gui-fonts.json");
|
||||
let settings = UiFontSettings {
|
||||
primary_choice: FontChoice::System(String::from("primary")),
|
||||
@@ -622,7 +622,7 @@ mod tests {
|
||||
assert_eq!(
|
||||
path,
|
||||
PathBuf::from("/tmp/xdg-config")
|
||||
.join("xserial")
|
||||
.join("pipeview")
|
||||
.join(FONT_SETTINGS_FILE_NAME)
|
||||
);
|
||||
}
|
||||
@@ -636,7 +636,7 @@ mod tests {
|
||||
assert_eq!(
|
||||
path,
|
||||
PathBuf::from(r"C:\Users\Test\AppData\Roaming")
|
||||
.join("xserial")
|
||||
.join("pipeview")
|
||||
.join(FONT_SETTINGS_FILE_NAME)
|
||||
);
|
||||
}
|
||||
@@ -652,7 +652,7 @@ mod tests {
|
||||
PathBuf::from("/Users/tester")
|
||||
.join("Library")
|
||||
.join("Application Support")
|
||||
.join("xserial")
|
||||
.join("pipeview")
|
||||
.join(FONT_SETTINGS_FILE_NAME)
|
||||
);
|
||||
}
|
||||
@@ -1,15 +1,15 @@
|
||||
[package]
|
||||
name = "xserial-tui"
|
||||
name = "pipeview-tui"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[[bin]]
|
||||
name = "xserial-tui"
|
||||
name = "pipeview-tui"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
xserial-core = { path = "../xserial-core" }
|
||||
xserial-client = { path = "../xserial-client" }
|
||||
pipeview-core = { path = "../pipeview-core" }
|
||||
pipeview-client = { path = "../pipeview-client" }
|
||||
tokio = { workspace = true }
|
||||
ratatui = { workspace = true }
|
||||
crossterm = { workspace = true }
|
||||
@@ -22,3 +22,4 @@ image = { workspace = true }
|
||||
mlua = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
ansitok = "0.3"
|
||||
295
crates/pipeview-tui/src/ansi.rs
Normal file
295
crates/pipeview-tui/src/ansi.rs
Normal file
@@ -0,0 +1,295 @@
|
||||
use ansitok::{AnsiColor, ElementKind, VisualAttribute, parse_ansi, parse_ansi_sgr};
|
||||
use ratatui::style::{Color, Modifier, Style};
|
||||
use ratatui::text::Span;
|
||||
|
||||
/// Parse ANSI-encoded text and return a Vec of styled Spans.
|
||||
///
|
||||
/// ANSI SGR sequences (colors, bold, italic, underline, strikethrough)
|
||||
/// are converted to ratatui `Style` applied on top of `base_style`.
|
||||
/// Non-SGR sequences (cursor movement, etc.) are silently ignored.
|
||||
pub fn ansi_to_spans(text: &str, base_style: Style) -> Vec<Span<'static>> {
|
||||
let mut spans = Vec::new();
|
||||
let mut style = AnsiStyleStack::default();
|
||||
|
||||
for element in parse_ansi(text) {
|
||||
match element.kind() {
|
||||
ElementKind::Text => {
|
||||
let slice = &text[element.start()..element.end()];
|
||||
if !slice.is_empty() {
|
||||
spans.push(Span::styled(
|
||||
slice.to_string(),
|
||||
style.merge(base_style),
|
||||
));
|
||||
}
|
||||
}
|
||||
ElementKind::Sgr => {
|
||||
let sgr = &text[element.start()..element.end()];
|
||||
apply_sgr_sequence(&mut style, sgr);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
spans
|
||||
}
|
||||
|
||||
/// Extract visible text from ANSI-encoded string (strips escape sequences).
|
||||
///
|
||||
/// This is the text that a user would actually see on a terminal —
|
||||
/// useful for search, copy, and line counting.
|
||||
pub fn ansi_visible_text(text: &str) -> String {
|
||||
let mut visible = String::new();
|
||||
for element in parse_ansi(text) {
|
||||
if element.kind() == ElementKind::Text {
|
||||
visible.push_str(&text[element.start()..element.end()]);
|
||||
}
|
||||
}
|
||||
visible
|
||||
}
|
||||
|
||||
// ── internal style stack ──
|
||||
|
||||
#[derive(Default, Clone)]
|
||||
struct AnsiStyleStack {
|
||||
fg: Option<Color>,
|
||||
bg: Option<Color>,
|
||||
bold: bool,
|
||||
italic: bool,
|
||||
underline: bool,
|
||||
strikethrough: bool,
|
||||
}
|
||||
|
||||
impl AnsiStyleStack {
|
||||
fn apply_sgr_attr(&mut self, attr: VisualAttribute) {
|
||||
match attr {
|
||||
VisualAttribute::Reset(0) => *self = Self::default(),
|
||||
VisualAttribute::Reset(22) => self.bold = false,
|
||||
VisualAttribute::Reset(23) => self.italic = false,
|
||||
VisualAttribute::Reset(24) => self.underline = false,
|
||||
VisualAttribute::Reset(29) => self.strikethrough = false,
|
||||
VisualAttribute::Reset(39) => self.fg = None,
|
||||
VisualAttribute::Reset(49) => self.bg = None,
|
||||
VisualAttribute::Reset(_) => {}
|
||||
VisualAttribute::Bold => self.bold = true,
|
||||
VisualAttribute::Faint => self.bold = false,
|
||||
VisualAttribute::Italic => self.italic = true,
|
||||
VisualAttribute::Underline => self.underline = true,
|
||||
VisualAttribute::Crossedout => self.strikethrough = true,
|
||||
VisualAttribute::FgColor(c) => self.fg = Some(ansi_color_to_ratatui(c)),
|
||||
VisualAttribute::BgColor(c) => self.bg = Some(ansi_color_to_ratatui(c)),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_sgr(&mut self, sgr: &str) {
|
||||
// Strip ESC[ ... m wrapper and delegate to ansitok's SGR parser
|
||||
let params = sgr
|
||||
.strip_prefix("\x1b[")
|
||||
.and_then(|s| s.strip_suffix('m'))
|
||||
.unwrap_or(sgr);
|
||||
|
||||
if params.is_empty() {
|
||||
*self = Self::default();
|
||||
return;
|
||||
}
|
||||
|
||||
for output in parse_ansi_sgr(sgr) {
|
||||
if let Some(attr) = output.as_escape() {
|
||||
self.apply_sgr_attr(attr);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn merge(&self, base: Style) -> Style {
|
||||
let mut style = base;
|
||||
|
||||
if let Some(fg) = self.fg {
|
||||
if self.bold {
|
||||
style = style.fg(brighten(fg));
|
||||
} else {
|
||||
style = style.fg(fg);
|
||||
}
|
||||
}
|
||||
if let Some(bg) = self.bg {
|
||||
style = style.bg(bg);
|
||||
}
|
||||
if self.italic {
|
||||
style = style.add_modifier(Modifier::ITALIC);
|
||||
}
|
||||
if self.underline {
|
||||
style = style.add_modifier(Modifier::UNDERLINED);
|
||||
}
|
||||
if self.strikethrough {
|
||||
style = style.add_modifier(Modifier::CROSSED_OUT);
|
||||
}
|
||||
if self.bold {
|
||||
// Bold applied only when there's no explicit fg (brighten handles it otherwise)
|
||||
style = style.add_modifier(Modifier::BOLD);
|
||||
}
|
||||
|
||||
style
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_sgr_sequence(style: &mut AnsiStyleStack, sgr: &str) {
|
||||
style.apply_sgr(sgr);
|
||||
}
|
||||
|
||||
// ── color conversion ──
|
||||
|
||||
fn ansi_color_to_ratatui(color: AnsiColor) -> Color {
|
||||
match color {
|
||||
AnsiColor::Bit4(c) => ansi_4bit(c),
|
||||
AnsiColor::Bit8(c) => ansi_256(c),
|
||||
AnsiColor::Bit24 { r, g, b } => Color::Rgb(r, g, b),
|
||||
}
|
||||
}
|
||||
|
||||
fn ansi_4bit(code: u8) -> Color {
|
||||
match code {
|
||||
30 => Color::Rgb(0, 0, 0),
|
||||
31 => Color::Rgb(194, 54, 33),
|
||||
32 => Color::Rgb(37, 188, 36),
|
||||
33 => Color::Rgb(173, 173, 39),
|
||||
34 => Color::Rgb(73, 46, 225),
|
||||
35 => Color::Rgb(211, 56, 211),
|
||||
36 => Color::Rgb(51, 187, 200),
|
||||
37 => Color::Rgb(203, 204, 205),
|
||||
|
||||
90 => Color::Rgb(129, 131, 131),
|
||||
91 => Color::Rgb(252, 57, 31),
|
||||
92 => Color::Rgb(49, 231, 34),
|
||||
93 => Color::Rgb(234, 236, 35),
|
||||
94 => Color::Rgb(88, 51, 255),
|
||||
95 => Color::Rgb(249, 53, 248),
|
||||
96 => Color::Rgb(20, 240, 240),
|
||||
97 => Color::Rgb(233, 235, 235),
|
||||
|
||||
// Background colors (same values as foreground but offset by 10)
|
||||
40 => Color::Rgb(0, 0, 0),
|
||||
41 => Color::Rgb(194, 54, 33),
|
||||
42 => Color::Rgb(37, 188, 36),
|
||||
43 => Color::Rgb(173, 173, 39),
|
||||
44 => Color::Rgb(73, 46, 225),
|
||||
45 => Color::Rgb(211, 56, 211),
|
||||
46 => Color::Rgb(51, 187, 200),
|
||||
47 => Color::Rgb(203, 204, 205),
|
||||
|
||||
100 => Color::Rgb(129, 131, 131),
|
||||
101 => Color::Rgb(252, 57, 31),
|
||||
102 => Color::Rgb(49, 231, 34),
|
||||
103 => Color::Rgb(234, 236, 35),
|
||||
104 => Color::Rgb(88, 51, 255),
|
||||
105 => Color::Rgb(249, 53, 248),
|
||||
106 => Color::Rgb(20, 240, 240),
|
||||
107 => Color::Rgb(233, 235, 235),
|
||||
|
||||
_ => Color::Rgb(255, 255, 255),
|
||||
}
|
||||
}
|
||||
|
||||
fn ansi_256(code: u8) -> Color {
|
||||
match code {
|
||||
0..=15 => ansi_4bit(if code < 8 { code + 30 } else { code + 82 }),
|
||||
16..=231 => {
|
||||
let idx = code - 16;
|
||||
let cube = [0, 95, 135, 175, 215, 255];
|
||||
let r = cube[(idx / 36) as usize];
|
||||
let g = cube[((idx / 6) % 6) as usize];
|
||||
let b = cube[(idx % 6) as usize];
|
||||
Color::Rgb(r, g, b)
|
||||
}
|
||||
232..=255 => {
|
||||
let v = (code - 232) * 10 + 8;
|
||||
Color::Rgb(v, v, v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn brighten(color: Color) -> Color {
|
||||
match color {
|
||||
Color::Rgb(r, g, b) => Color::Rgb(
|
||||
((r as u32) * 13 / 10).min(255) as u8,
|
||||
((g as u32) * 13 / 10).min(255) as u8,
|
||||
((b as u32) * 13 / 10).min(255) as u8,
|
||||
),
|
||||
_ => color,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use ratatui::style::Style;
|
||||
|
||||
fn visible<'a>(spans: &[Span<'a>]) -> String {
|
||||
spans.iter().map(|s| s.content.as_ref()).collect::<String>()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plain_text_no_ansi() {
|
||||
let spans = ansi_to_spans("hello", Style::default());
|
||||
assert_eq!(spans.len(), 1);
|
||||
assert_eq!(spans[0].content, "hello");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fg_red_reset() {
|
||||
let spans = ansi_to_spans("\x1b[31mred\x1b[0m plain", Style::default());
|
||||
assert!(spans.len() >= 2);
|
||||
assert_eq!(visible(&spans), "red plain");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_sgr_resets_all() {
|
||||
let base = Style::default().fg(Color::Rgb(1, 2, 3));
|
||||
let spans = ansi_to_spans("\x1b[31mred\x1b[m plain", base);
|
||||
assert_eq!(visible(&spans), "red plain");
|
||||
assert!(spans.len() >= 2);
|
||||
// First span should have red foreground
|
||||
assert_ne!(spans[0].style.fg, Some(Color::Rgb(1, 2, 3)));
|
||||
// Last span should have base foreground
|
||||
assert_eq!(spans.last().unwrap().style.fg, Some(Color::Rgb(1, 2, 3)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bold_brightens_color() {
|
||||
let spans = ansi_to_spans("\x1b[1;31mbold red\x1b[0m", Style::default());
|
||||
assert_eq!(spans.len(), 1);
|
||||
assert_eq!(visible(&spans), "bold red");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strip_ansi_codes_from_output() {
|
||||
let spans = ansi_to_spans("\x1b[32mgreen\x1b[0m", Style::default());
|
||||
assert_eq!(visible(&spans), "green");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn visible_text_strips_ansi() {
|
||||
assert_eq!(ansi_visible_text("plain"), "plain");
|
||||
assert_eq!(ansi_visible_text("\x1b[31mred\x1b[0m"), "red");
|
||||
assert_eq!(
|
||||
ansi_visible_text("\x1b[1;32mbold green\x1b[0m plain"),
|
||||
"bold green plain"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn xterm_256_color_cube() {
|
||||
assert_eq!(ansi_256(16), Color::Rgb(0, 0, 0));
|
||||
assert_eq!(ansi_256(21), Color::Rgb(0, 0, 255));
|
||||
assert_eq!(ansi_256(52), Color::Rgb(95, 0, 0));
|
||||
assert_eq!(ansi_256(67), Color::Rgb(95, 135, 175));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn true_color_24bit() {
|
||||
let spans = ansi_to_spans(
|
||||
"\x1b[38;2;100;200;50mtruecolor\x1b[0m",
|
||||
Style::default(),
|
||||
);
|
||||
assert_eq!(visible(&spans), "truecolor");
|
||||
assert_eq!(spans[0].style.fg, Some(Color::Rgb(100, 200, 50)));
|
||||
}
|
||||
}
|
||||
1782
crates/pipeview-tui/src/app.rs
Normal file
1782
crates/pipeview-tui/src/app.rs
Normal file
File diff suppressed because it is too large
Load Diff
@@ -3,18 +3,20 @@ use std::fs;
|
||||
use std::io;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use pipeview_client::SessionConfig;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tracing::warn;
|
||||
use xserial_client::SessionConfig;
|
||||
|
||||
use crate::app::{DisplayOptions, View, default_session_config};
|
||||
|
||||
const TUI_STATE_FILE_NAME: &str = "tui-state.json";
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PersistedTuiState {
|
||||
#[serde(default = "default_session")]
|
||||
pub session: SessionConfig,
|
||||
#[serde(default)]
|
||||
pub sessions: Vec<SessionConfig>,
|
||||
#[serde(default)]
|
||||
pub active: usize,
|
||||
pub active_view: PersistedView,
|
||||
#[serde(default = "default_true")]
|
||||
pub show_timestamp: bool,
|
||||
#[serde(default = "default_true")]
|
||||
@@ -23,9 +25,54 @@ pub struct PersistedTuiState {
|
||||
pub show_pipeline: bool,
|
||||
}
|
||||
|
||||
impl Default for PersistedTuiState {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
session: default_session(),
|
||||
active_view: PersistedView::Text,
|
||||
show_timestamp: true,
|
||||
show_direction: true,
|
||||
show_pipeline: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, Default)]
|
||||
pub enum PersistedView {
|
||||
#[default]
|
||||
Text,
|
||||
Hex,
|
||||
Plot,
|
||||
}
|
||||
|
||||
impl From<View> for PersistedView {
|
||||
fn from(value: View) -> Self {
|
||||
match value {
|
||||
View::Text => Self::Text,
|
||||
View::Hex => Self::Hex,
|
||||
View::Plot => Self::Plot,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<PersistedView> for View {
|
||||
fn from(value: PersistedView) -> Self {
|
||||
match value {
|
||||
PersistedView::Text => View::Text,
|
||||
PersistedView::Hex => View::Hex,
|
||||
PersistedView::Plot => View::Plot,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn load_tui_state() -> PersistedTuiState {
|
||||
match load_tui_state_from_path(&tui_state_path()) {
|
||||
Ok(state) => state,
|
||||
Ok(mut state) => {
|
||||
if state.session.pipelines.is_empty() {
|
||||
state.session = default_session();
|
||||
}
|
||||
state
|
||||
}
|
||||
Err(err) if err.kind() == io::ErrorKind::NotFound => PersistedTuiState::default(),
|
||||
Err(err) => {
|
||||
warn!(error = %err, "Failed to load persisted TUI state");
|
||||
@@ -34,27 +81,46 @@ pub fn load_tui_state() -> PersistedTuiState {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn save_tui_state(state: &PersistedTuiState) {
|
||||
if let Err(err) = save_tui_state_to_path(state, &tui_state_path()) {
|
||||
pub fn save_tui_state(session: &SessionConfig, view: View, display: DisplayOptions) {
|
||||
let state = PersistedTuiState {
|
||||
session: session.clone(),
|
||||
active_view: view.into(),
|
||||
show_timestamp: display.show_timestamp,
|
||||
show_direction: display.show_direction,
|
||||
show_pipeline: display.show_pipeline,
|
||||
};
|
||||
|
||||
if let Err(err) = save_tui_state_to_path(&state, &tui_state_path()) {
|
||||
warn!(error = %err, "Failed to persist TUI state");
|
||||
}
|
||||
}
|
||||
|
||||
fn default_session() -> SessionConfig {
|
||||
default_session_config()
|
||||
}
|
||||
|
||||
fn tui_state_path() -> PathBuf {
|
||||
state_path_for_os_and_env(env::consts::OS, |key| env::var(key).ok())
|
||||
}
|
||||
|
||||
pub fn config_dir() -> PathBuf {
|
||||
tui_state_path()
|
||||
.parent()
|
||||
.map(Path::to_path_buf)
|
||||
.unwrap_or_else(|| PathBuf::from("."))
|
||||
}
|
||||
|
||||
fn state_path_for_os_and_env(os: &str, get_env: impl Fn(&str) -> Option<String>) -> PathBuf {
|
||||
match os {
|
||||
"windows" => {
|
||||
if let Some(path) = get_env("APPDATA").filter(|path| !path.trim().is_empty()) {
|
||||
return PathBuf::from(path)
|
||||
.join("xserial")
|
||||
.join("pipeview")
|
||||
.join(TUI_STATE_FILE_NAME);
|
||||
}
|
||||
if let Some(path) = get_env("LOCALAPPDATA").filter(|path| !path.trim().is_empty()) {
|
||||
return PathBuf::from(path)
|
||||
.join("xserial")
|
||||
.join("pipeview")
|
||||
.join(TUI_STATE_FILE_NAME);
|
||||
}
|
||||
}
|
||||
@@ -63,20 +129,20 @@ fn state_path_for_os_and_env(os: &str, get_env: impl Fn(&str) -> Option<String>)
|
||||
return PathBuf::from(home)
|
||||
.join("Library")
|
||||
.join("Application Support")
|
||||
.join("xserial")
|
||||
.join("pipeview")
|
||||
.join(TUI_STATE_FILE_NAME);
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
if let Some(path) = get_env("XDG_CONFIG_HOME").filter(|path| !path.trim().is_empty()) {
|
||||
return PathBuf::from(path)
|
||||
.join("xserial")
|
||||
.join("pipeview")
|
||||
.join(TUI_STATE_FILE_NAME);
|
||||
}
|
||||
if let Some(home) = get_env("HOME").filter(|path| !path.trim().is_empty()) {
|
||||
return PathBuf::from(home)
|
||||
.join(".config")
|
||||
.join("xserial")
|
||||
.join("pipeview")
|
||||
.join(TUI_STATE_FILE_NAME);
|
||||
}
|
||||
}
|
||||
637
crates/pipeview-tui/src/buffers.rs
Normal file
637
crates/pipeview-tui/src/buffers.rs
Normal file
@@ -0,0 +1,637 @@
|
||||
use std::collections::VecDeque;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use pipeview_client::{DecodedEntry, RingBuffer};
|
||||
use pipeview_core::protocol::DecodedData;
|
||||
use pipeview_core::protocol::plot::{PlotFormat, PlotFrame};
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
pub enum LineDirection {
|
||||
In,
|
||||
Out,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct ConsoleLine {
|
||||
pub elapsed: Duration,
|
||||
pub pipeline: String,
|
||||
pub text: String,
|
||||
pub direction: LineDirection,
|
||||
}
|
||||
|
||||
pub struct TextBuffer {
|
||||
started_at: Instant,
|
||||
lines: RingBuffer<ConsoleLine>,
|
||||
}
|
||||
|
||||
impl TextBuffer {
|
||||
pub fn new(limit: usize) -> Self {
|
||||
Self {
|
||||
started_at: Instant::now(),
|
||||
lines: RingBuffer::new(limit),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn push(&mut self, entry: &DecodedEntry) {
|
||||
if let DecodedData::Text(text) = &entry.data {
|
||||
self.lines.push(ConsoleLine {
|
||||
elapsed: self.started_at.elapsed(),
|
||||
pipeline: entry.pipeline_name.clone(),
|
||||
text: text.clone(),
|
||||
direction: LineDirection::In,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
pub fn push_outbound(&mut self, text: String) {
|
||||
self.lines.push(ConsoleLine {
|
||||
elapsed: self.started_at.elapsed(),
|
||||
pipeline: String::from("OUT"),
|
||||
text,
|
||||
direction: LineDirection::Out,
|
||||
});
|
||||
}
|
||||
|
||||
pub fn get(&self, index: usize) -> Option<&ConsoleLine> {
|
||||
self.lines.get(index)
|
||||
}
|
||||
|
||||
pub fn len(&self) -> usize {
|
||||
self.lines.len()
|
||||
}
|
||||
|
||||
pub fn clear(&mut self) {
|
||||
self.lines.clear();
|
||||
}
|
||||
|
||||
pub fn set_limit(&mut self, limit: usize) {
|
||||
self.lines.set_limit(limit);
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct HexLine {
|
||||
pub elapsed: Duration,
|
||||
pub pipeline: String,
|
||||
pub hex: String,
|
||||
pub ascii: String,
|
||||
pub direction: LineDirection,
|
||||
}
|
||||
|
||||
pub struct HexBuffer {
|
||||
started_at: Instant,
|
||||
lines: RingBuffer<HexLine>,
|
||||
}
|
||||
|
||||
impl HexBuffer {
|
||||
pub fn new(limit: usize) -> Self {
|
||||
Self {
|
||||
started_at: Instant::now(),
|
||||
lines: RingBuffer::new(limit),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn push(&mut self, entry: &DecodedEntry) {
|
||||
if let DecodedData::Hex(hex) = &entry.data {
|
||||
self.lines.push(HexLine {
|
||||
elapsed: self.started_at.elapsed(),
|
||||
pipeline: entry.pipeline_name.clone(),
|
||||
ascii: decode_ascii(hex),
|
||||
hex: hex.clone(),
|
||||
direction: LineDirection::In,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
pub fn push_outbound(&mut self, hex: String) {
|
||||
self.lines.push(HexLine {
|
||||
elapsed: self.started_at.elapsed(),
|
||||
pipeline: String::from("OUT"),
|
||||
ascii: decode_ascii(&hex),
|
||||
hex,
|
||||
direction: LineDirection::Out,
|
||||
});
|
||||
}
|
||||
|
||||
pub fn get(&self, index: usize) -> Option<&HexLine> {
|
||||
self.lines.get(index)
|
||||
}
|
||||
|
||||
pub fn len(&self) -> usize {
|
||||
self.lines.len()
|
||||
}
|
||||
|
||||
pub fn clear(&mut self) {
|
||||
self.lines.clear();
|
||||
}
|
||||
|
||||
pub fn set_limit(&mut self, limit: usize) {
|
||||
self.lines.set_limit(limit);
|
||||
}
|
||||
}
|
||||
|
||||
pub struct PlotSeries {
|
||||
pub name: String,
|
||||
points: VecDeque<[f64; 2]>,
|
||||
next_x: f64,
|
||||
}
|
||||
|
||||
impl PlotSeries {
|
||||
fn new(name: String) -> Self {
|
||||
Self {
|
||||
name,
|
||||
points: VecDeque::new(),
|
||||
next_x: 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
fn push_samples(&mut self, samples: &[f64], limit: usize) {
|
||||
for sample in samples {
|
||||
if !sample.is_finite() {
|
||||
continue;
|
||||
}
|
||||
self.push_point([self.next_x, *sample], limit);
|
||||
self.next_x += 1.0;
|
||||
}
|
||||
}
|
||||
|
||||
fn push_point(&mut self, point: [f64; 2], limit: usize) -> Option<[f64; 2]> {
|
||||
self.points.push_back(point);
|
||||
if self.points.len() > limit {
|
||||
self.points.pop_front()
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
pub fn points(&self) -> impl Iterator<Item = [f64; 2]> + '_ {
|
||||
self.points.iter().copied()
|
||||
}
|
||||
|
||||
pub fn len(&self) -> usize {
|
||||
self.points.len()
|
||||
}
|
||||
|
||||
fn first_point(&self) -> Option<[f64; 2]> {
|
||||
self.points.front().copied()
|
||||
}
|
||||
|
||||
fn last_point(&self) -> Option<[f64; 2]> {
|
||||
self.points.back().copied()
|
||||
}
|
||||
|
||||
pub fn render_points_time_series(
|
||||
&self,
|
||||
x_min: f64,
|
||||
x_max: f64,
|
||||
max_points: usize,
|
||||
) -> Vec<[f64; 2]> {
|
||||
if self.points.is_empty() || max_points == 0 {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let mut exact_visible = Vec::with_capacity(max_points.min(self.points.len()));
|
||||
for point in self.points.iter().copied() {
|
||||
if point[0] < x_min {
|
||||
continue;
|
||||
}
|
||||
if point[0] > x_max {
|
||||
break;
|
||||
}
|
||||
exact_visible.push(point);
|
||||
if exact_visible.len() > max_points {
|
||||
exact_visible.clear();
|
||||
break;
|
||||
}
|
||||
}
|
||||
if !exact_visible.is_empty() {
|
||||
return exact_visible;
|
||||
}
|
||||
|
||||
let bucket_count = (max_points / 2).max(1);
|
||||
let width = (x_max - x_min).max(1.0);
|
||||
let bucket_width = width / bucket_count as f64;
|
||||
let mut rendered = Vec::with_capacity(bucket_count * 2);
|
||||
let mut points = self
|
||||
.points
|
||||
.iter()
|
||||
.copied()
|
||||
.skip_while(|point| point[0] < x_min)
|
||||
.peekable();
|
||||
|
||||
for bucket_index in 0..bucket_count {
|
||||
let bucket_start = x_min + bucket_width * bucket_index as f64;
|
||||
let bucket_end = if bucket_index + 1 == bucket_count {
|
||||
x_max
|
||||
} else {
|
||||
bucket_start + bucket_width
|
||||
};
|
||||
|
||||
let mut min_point: Option<[f64; 2]> = None;
|
||||
let mut max_point: Option<[f64; 2]> = None;
|
||||
|
||||
while let Some(point) = points.peek().copied() {
|
||||
if point[0] > bucket_end {
|
||||
break;
|
||||
}
|
||||
if point[0] >= bucket_start {
|
||||
match min_point {
|
||||
Some(current) if current[1] <= point[1] => {}
|
||||
_ => min_point = Some(point),
|
||||
}
|
||||
match max_point {
|
||||
Some(current) if current[1] >= point[1] => {}
|
||||
_ => max_point = Some(point),
|
||||
}
|
||||
}
|
||||
points.next();
|
||||
}
|
||||
|
||||
match (min_point, max_point) {
|
||||
(Some(a), Some(b)) if a[0] <= b[0] => {
|
||||
rendered.push(a);
|
||||
if a != b {
|
||||
rendered.push(b);
|
||||
}
|
||||
}
|
||||
(Some(a), Some(b)) => {
|
||||
rendered.push(b);
|
||||
if a != b {
|
||||
rendered.push(a);
|
||||
}
|
||||
}
|
||||
(Some(a), None) | (None, Some(a)) => rendered.push(a),
|
||||
(None, None) => {}
|
||||
}
|
||||
}
|
||||
|
||||
if rendered.len() > max_points {
|
||||
let stride = rendered.len().div_ceil(max_points);
|
||||
rendered.into_iter().step_by(stride).collect()
|
||||
} else {
|
||||
rendered
|
||||
}
|
||||
}
|
||||
|
||||
pub fn render_points_xy(&self, max_points: usize) -> Vec<[f64; 2]> {
|
||||
if self.points.is_empty() || max_points == 0 {
|
||||
return Vec::new();
|
||||
}
|
||||
if self.points.len() <= max_points {
|
||||
return self.points.iter().copied().collect();
|
||||
}
|
||||
|
||||
let stride = self.points.len().div_ceil(max_points);
|
||||
self.points.iter().copied().step_by(stride).collect()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
pub enum PlotSeriesKind {
|
||||
TimeSeries,
|
||||
XY,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct BoundsRect {
|
||||
min_x: f64,
|
||||
max_x: f64,
|
||||
min_y: f64,
|
||||
max_y: f64,
|
||||
}
|
||||
|
||||
impl BoundsRect {
|
||||
fn from_point([x, y]: [f64; 2]) -> Option<Self> {
|
||||
if !(x.is_finite() && y.is_finite()) {
|
||||
return None;
|
||||
}
|
||||
Some(Self {
|
||||
min_x: x,
|
||||
max_x: x,
|
||||
min_y: y,
|
||||
max_y: y,
|
||||
})
|
||||
}
|
||||
|
||||
fn extend_with_point(&mut self, [x, y]: [f64; 2]) {
|
||||
if !(x.is_finite() && y.is_finite()) {
|
||||
return;
|
||||
}
|
||||
self.min_x = self.min_x.min(x);
|
||||
self.max_x = self.max_x.max(x);
|
||||
self.min_y = self.min_y.min(y);
|
||||
self.max_y = self.max_y.max(y);
|
||||
}
|
||||
|
||||
fn touches(&self, [x, y]: [f64; 2]) -> bool {
|
||||
x == self.min_x || x == self.max_x || y == self.min_y || y == self.max_y
|
||||
}
|
||||
}
|
||||
|
||||
pub struct PlotBuffer {
|
||||
limit: usize,
|
||||
kind: PlotSeriesKind,
|
||||
series: Vec<PlotSeries>,
|
||||
time_series_y_bounds: Option<(f64, f64)>,
|
||||
time_series_y_dirty: bool,
|
||||
xy_bounds: Option<BoundsRect>,
|
||||
xy_bounds_dirty: bool,
|
||||
}
|
||||
|
||||
impl PlotBuffer {
|
||||
pub fn new(limit: usize) -> Self {
|
||||
Self {
|
||||
limit,
|
||||
kind: PlotSeriesKind::TimeSeries,
|
||||
series: Vec::new(),
|
||||
time_series_y_bounds: None,
|
||||
time_series_y_dirty: false,
|
||||
xy_bounds: None,
|
||||
xy_bounds_dirty: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn push(&mut self, entry: &DecodedEntry) {
|
||||
if let DecodedData::Plot(frame) = &entry.data {
|
||||
self.push_frame(&entry.pipeline_name, frame);
|
||||
}
|
||||
}
|
||||
|
||||
fn push_frame(&mut self, pipeline_name: &str, frame: &PlotFrame) {
|
||||
let next_kind = match frame.format {
|
||||
PlotFormat::XY => PlotSeriesKind::XY,
|
||||
PlotFormat::Interleaved | PlotFormat::Block => PlotSeriesKind::TimeSeries,
|
||||
};
|
||||
if self.kind != next_kind {
|
||||
self.invalidate_bounds();
|
||||
}
|
||||
self.kind = next_kind;
|
||||
|
||||
if matches!(frame.format, PlotFormat::XY) {
|
||||
self.push_xy_frame(pipeline_name, frame);
|
||||
return;
|
||||
}
|
||||
|
||||
for (index, channel) in frame.channels.iter().enumerate() {
|
||||
let series_name = if frame.channels.len() == 1 {
|
||||
pipeline_name.to_owned()
|
||||
} else {
|
||||
format!("{pipeline_name}:ch{}", index + 1)
|
||||
};
|
||||
|
||||
if let Some(series_index) = self
|
||||
.series
|
||||
.iter()
|
||||
.position(|series| series.name == series_name)
|
||||
{
|
||||
for sample in channel {
|
||||
if !sample.is_finite() {
|
||||
continue;
|
||||
}
|
||||
let (point, removed) = {
|
||||
let series = &mut self.series[series_index];
|
||||
let point = [series.next_x, *sample];
|
||||
let removed = series.push_point(point, self.limit);
|
||||
series.next_x += 1.0;
|
||||
(point, removed)
|
||||
};
|
||||
if let Some(removed) = removed {
|
||||
self.note_time_series_removed(removed);
|
||||
}
|
||||
self.note_time_series_point(point);
|
||||
}
|
||||
} else {
|
||||
let mut series = PlotSeries::new(series_name);
|
||||
series.push_samples(channel, self.limit);
|
||||
for point in series.points() {
|
||||
self.note_time_series_point(point);
|
||||
}
|
||||
self.series.push(series);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn push_xy_frame(&mut self, pipeline_name: &str, frame: &PlotFrame) {
|
||||
if frame.channels.len() < 2 {
|
||||
return;
|
||||
}
|
||||
|
||||
let x = &frame.channels[0];
|
||||
let y = &frame.channels[1];
|
||||
let len = x.len().min(y.len());
|
||||
let series_name = format!("{pipeline_name}:xy");
|
||||
|
||||
let series_index = if let Some(index) = self
|
||||
.series
|
||||
.iter()
|
||||
.position(|series| series.name == series_name)
|
||||
{
|
||||
index
|
||||
} else {
|
||||
self.series.push(PlotSeries::new(series_name));
|
||||
self.series.len() - 1
|
||||
};
|
||||
|
||||
for index in 0..len {
|
||||
if !x[index].is_finite() || !y[index].is_finite() {
|
||||
continue;
|
||||
}
|
||||
let point = [x[index], y[index]];
|
||||
let removed = {
|
||||
let series = &mut self.series[series_index];
|
||||
series.push_point(point, self.limit)
|
||||
};
|
||||
if let Some(removed) = removed {
|
||||
self.note_xy_removed(removed);
|
||||
}
|
||||
self.note_xy_point(point);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn clear(&mut self) {
|
||||
self.series.clear();
|
||||
self.invalidate_bounds();
|
||||
}
|
||||
|
||||
pub fn set_limit(&mut self, limit: usize) {
|
||||
self.limit = limit;
|
||||
for series in &mut self.series {
|
||||
while series.points.len() > self.limit {
|
||||
series.points.pop_front();
|
||||
}
|
||||
}
|
||||
self.invalidate_bounds();
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.series.is_empty()
|
||||
}
|
||||
|
||||
pub fn kind(&self) -> PlotSeriesKind {
|
||||
self.kind
|
||||
}
|
||||
|
||||
pub fn iter(&self) -> impl Iterator<Item = &PlotSeries> {
|
||||
self.series.iter()
|
||||
}
|
||||
|
||||
pub fn series_len(&self) -> usize {
|
||||
self.series.len()
|
||||
}
|
||||
|
||||
pub fn total_points(&self) -> usize {
|
||||
self.series.iter().map(PlotSeries::len).sum()
|
||||
}
|
||||
|
||||
pub fn bounds(&mut self) -> Option<([f64; 2], [f64; 2])> {
|
||||
match self.kind {
|
||||
PlotSeriesKind::TimeSeries => self.time_series_bounds(),
|
||||
PlotSeriesKind::XY => self.xy_bounds(),
|
||||
}
|
||||
}
|
||||
|
||||
fn invalidate_bounds(&mut self) {
|
||||
self.time_series_y_bounds = None;
|
||||
self.time_series_y_dirty = false;
|
||||
self.xy_bounds = None;
|
||||
self.xy_bounds_dirty = false;
|
||||
}
|
||||
|
||||
fn note_time_series_point(&mut self, point: [f64; 2]) {
|
||||
if !point[1].is_finite() {
|
||||
return;
|
||||
}
|
||||
match &mut self.time_series_y_bounds {
|
||||
Some((min_y, max_y)) => {
|
||||
*min_y = min_y.min(point[1]);
|
||||
*max_y = max_y.max(point[1]);
|
||||
}
|
||||
None => self.time_series_y_bounds = Some((point[1], point[1])),
|
||||
}
|
||||
}
|
||||
|
||||
fn note_time_series_removed(&mut self, removed: [f64; 2]) {
|
||||
if let Some((min_y, max_y)) = self.time_series_y_bounds
|
||||
&& (removed[1] == min_y || removed[1] == max_y)
|
||||
{
|
||||
self.time_series_y_dirty = true;
|
||||
}
|
||||
}
|
||||
|
||||
fn note_xy_point(&mut self, point: [f64; 2]) {
|
||||
match &mut self.xy_bounds {
|
||||
Some(bounds) => bounds.extend_with_point(point),
|
||||
None => self.xy_bounds = BoundsRect::from_point(point),
|
||||
}
|
||||
}
|
||||
|
||||
fn note_xy_removed(&mut self, removed: [f64; 2]) {
|
||||
if let Some(bounds) = self.xy_bounds
|
||||
&& bounds.touches(removed)
|
||||
{
|
||||
self.xy_bounds_dirty = true;
|
||||
}
|
||||
}
|
||||
|
||||
fn time_series_bounds(&mut self) -> Option<([f64; 2], [f64; 2])> {
|
||||
let mut min_x = f64::INFINITY;
|
||||
let mut max_x = f64::NEG_INFINITY;
|
||||
|
||||
for series in &self.series {
|
||||
if let Some([x, _]) = series.first_point() {
|
||||
min_x = min_x.min(x);
|
||||
}
|
||||
if let Some([x, _]) = series.last_point() {
|
||||
max_x = max_x.max(x);
|
||||
}
|
||||
}
|
||||
|
||||
if !(min_x.is_finite() && max_x.is_finite()) {
|
||||
return None;
|
||||
}
|
||||
|
||||
if self.time_series_y_dirty {
|
||||
self.recompute_time_series_y_bounds();
|
||||
}
|
||||
let (mut min_y, mut max_y) = self.time_series_y_bounds?;
|
||||
if min_y == max_y {
|
||||
min_y -= 1.0;
|
||||
max_y += 1.0;
|
||||
}
|
||||
Some(([min_x, min_y], [max_x, max_y]))
|
||||
}
|
||||
|
||||
fn xy_bounds(&mut self) -> Option<([f64; 2], [f64; 2])> {
|
||||
if self.xy_bounds_dirty {
|
||||
self.recompute_xy_bounds();
|
||||
}
|
||||
let bounds = self.xy_bounds?;
|
||||
let mut min_x = bounds.min_x;
|
||||
let mut max_x = bounds.max_x;
|
||||
let mut min_y = bounds.min_y;
|
||||
let mut max_y = bounds.max_y;
|
||||
if min_x == max_x {
|
||||
min_x -= 1.0;
|
||||
max_x += 1.0;
|
||||
}
|
||||
if min_y == max_y {
|
||||
min_y -= 1.0;
|
||||
max_y += 1.0;
|
||||
}
|
||||
Some(([min_x, min_y], [max_x, max_y]))
|
||||
}
|
||||
|
||||
fn recompute_time_series_y_bounds(&mut self) {
|
||||
let mut min_y = f64::INFINITY;
|
||||
let mut max_y = f64::NEG_INFINITY;
|
||||
|
||||
for series in &self.series {
|
||||
for [_, y] in series.points() {
|
||||
if y.is_finite() {
|
||||
min_y = min_y.min(y);
|
||||
max_y = max_y.max(y);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
self.time_series_y_bounds = if min_y.is_finite() && max_y.is_finite() {
|
||||
Some((min_y, max_y))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
self.time_series_y_dirty = false;
|
||||
}
|
||||
|
||||
fn recompute_xy_bounds(&mut self) {
|
||||
let mut bounds: Option<BoundsRect> = None;
|
||||
|
||||
for series in &self.series {
|
||||
for point in series.points() {
|
||||
match &mut bounds {
|
||||
Some(existing) => existing.extend_with_point(point),
|
||||
None => bounds = BoundsRect::from_point(point),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
self.xy_bounds = bounds;
|
||||
self.xy_bounds_dirty = false;
|
||||
}
|
||||
}
|
||||
|
||||
fn decode_ascii(hex: &str) -> String {
|
||||
hex::decode(hex.replace(' ', ""))
|
||||
.map(|bytes| {
|
||||
bytes
|
||||
.into_iter()
|
||||
.map(|byte| {
|
||||
if byte.is_ascii_graphic() || byte == b' ' {
|
||||
byte as char
|
||||
} else {
|
||||
'.'
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_else(|_| String::from("[invalid hex]"))
|
||||
}
|
||||
65
crates/pipeview-tui/src/main.rs
Normal file
65
crates/pipeview-tui/src/main.rs
Normal file
@@ -0,0 +1,65 @@
|
||||
mod ansi;
|
||||
mod app;
|
||||
mod app_state;
|
||||
mod buffers;
|
||||
mod ui;
|
||||
|
||||
use std::io;
|
||||
|
||||
use crossterm::{
|
||||
event::{DisableMouseCapture, EnableMouseCapture},
|
||||
execute,
|
||||
terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode},
|
||||
};
|
||||
use ratatui::{Terminal, backend::CrosstermBackend};
|
||||
use tracing_appender::non_blocking::WorkerGuard;
|
||||
use tracing_subscriber::{EnvFilter, fmt};
|
||||
|
||||
use crate::app::App;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> io::Result<()> {
|
||||
let _log_guard = init_tracing();
|
||||
|
||||
enable_raw_mode()?;
|
||||
let mut stdout = io::stdout();
|
||||
execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
|
||||
let backend = CrosstermBackend::new(stdout);
|
||||
let mut terminal = Terminal::new(backend)?;
|
||||
|
||||
let result = async {
|
||||
let mut app = App::new();
|
||||
app.run(&mut terminal).await?;
|
||||
app.shutdown().await;
|
||||
io::Result::Ok(())
|
||||
}
|
||||
.await;
|
||||
|
||||
disable_raw_mode()?;
|
||||
execute!(
|
||||
terminal.backend_mut(),
|
||||
LeaveAlternateScreen,
|
||||
DisableMouseCapture
|
||||
)?;
|
||||
terminal.show_cursor()?;
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
fn init_tracing() -> Option<WorkerGuard> {
|
||||
let filter = EnvFilter::try_from_default_env()
|
||||
.unwrap_or_else(|_| EnvFilter::new("pipeview_tui=info,pipeview_client=info"));
|
||||
let log_dir = app_state::config_dir();
|
||||
let _ = std::fs::create_dir_all(&log_dir);
|
||||
let file_appender = tracing_appender::rolling::never(log_dir, "tui.log");
|
||||
let (writer, guard) = tracing_appender::non_blocking(file_appender);
|
||||
|
||||
fmt()
|
||||
.with_env_filter(filter)
|
||||
.with_target(false)
|
||||
.with_ansi(false)
|
||||
.with_writer(writer)
|
||||
.try_init()
|
||||
.ok()
|
||||
.map(|_| guard)
|
||||
}
|
||||
989
crates/pipeview-tui/src/ui.rs
Normal file
989
crates/pipeview-tui/src/ui.rs
Normal file
@@ -0,0 +1,989 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use ratatui::{
|
||||
Frame,
|
||||
layout::{Alignment, Constraint, Direction, Layout, Rect},
|
||||
style::{Color, Modifier, Style},
|
||||
symbols::Marker,
|
||||
text::{Line, Span},
|
||||
widgets::{
|
||||
Axis, Block, Borders, Chart, Clear, Dataset, GraphType, List, ListItem, Paragraph, Wrap,
|
||||
},
|
||||
};
|
||||
|
||||
use crate::ansi::ansi_to_spans;
|
||||
use crate::app::{
|
||||
App, AppMode, ConfigField, ConnectionStatus, DisplayOptions, FocusPane, HotAction, LineEnding,
|
||||
SendMode, View,
|
||||
};
|
||||
use crate::buffers::{ConsoleLine, HexLine, LineDirection, PlotSeriesKind};
|
||||
|
||||
const BG: Color = Color::Rgb(8, 12, 18);
|
||||
const PANEL: Color = Color::Rgb(12, 20, 28);
|
||||
const PANEL_ALT: Color = Color::Rgb(17, 27, 38);
|
||||
const CYAN: Color = Color::Rgb(55, 214, 230);
|
||||
const GREEN: Color = Color::Rgb(72, 211, 137);
|
||||
const AMBER: Color = Color::Rgb(244, 184, 74);
|
||||
const MAGENTA: Color = Color::Rgb(219, 111, 220);
|
||||
const RED: Color = Color::Rgb(238, 92, 107);
|
||||
const BLUE: Color = Color::Rgb(96, 165, 250);
|
||||
const MUTED: Color = Color::Rgb(120, 134, 153);
|
||||
const TEXT: Color = Color::Rgb(224, 234, 244);
|
||||
|
||||
pub fn render(frame: &mut Frame, app: &mut App) {
|
||||
app.reset_hot_zones();
|
||||
let area = frame.area();
|
||||
frame.render_widget(Block::default().style(Style::default().bg(BG)), area);
|
||||
|
||||
let root = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([
|
||||
Constraint::Length(4),
|
||||
Constraint::Min(10),
|
||||
Constraint::Length(6),
|
||||
Constraint::Length(2),
|
||||
])
|
||||
.split(area);
|
||||
|
||||
render_header(frame, app, root[0]);
|
||||
|
||||
let body = Layout::default()
|
||||
.direction(Direction::Horizontal)
|
||||
.constraints([Constraint::Length(34), Constraint::Min(30)])
|
||||
.split(root[1]);
|
||||
render_controls(frame, app, body[0]);
|
||||
render_main(frame, app, body[1]);
|
||||
render_composer(frame, app, root[2]);
|
||||
render_footer(frame, app, root[3]);
|
||||
|
||||
match app.mode {
|
||||
AppMode::Search => render_search_modal(frame, app),
|
||||
AppMode::Config => render_config_modal(frame, app),
|
||||
AppMode::Help => render_help_modal(frame, app),
|
||||
AppMode::Normal | AppMode::EditingSend => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn render_header(frame: &mut Frame, app: &App, area: Rect) {
|
||||
let status = &app.session.status;
|
||||
let status_color = status_color(status);
|
||||
let error = match status {
|
||||
ConnectionStatus::Error(message) => format!(" {message}"),
|
||||
_ => String::new(),
|
||||
};
|
||||
|
||||
let header = vec![
|
||||
Line::from(vec![
|
||||
Span::styled(
|
||||
" PIPEVIEW ",
|
||||
Style::default()
|
||||
.fg(Color::Black)
|
||||
.bg(CYAN)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
),
|
||||
Span::raw(" "),
|
||||
Span::styled(
|
||||
"single session telemetry console",
|
||||
Style::default().fg(TEXT).add_modifier(Modifier::BOLD),
|
||||
),
|
||||
Span::raw(" "),
|
||||
Span::styled(
|
||||
status.label(),
|
||||
Style::default()
|
||||
.fg(Color::Black)
|
||||
.bg(status_color)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
),
|
||||
Span::styled(error, Style::default().fg(RED)),
|
||||
]),
|
||||
Line::from(vec![
|
||||
Span::styled(app.transport_summary(), Style::default().fg(AMBER)),
|
||||
Span::raw(" | pipelines: "),
|
||||
Span::styled(app.pipeline_summary(), Style::default().fg(MAGENTA)),
|
||||
Span::raw(" | view: "),
|
||||
Span::styled(
|
||||
app.session.view.label(),
|
||||
Style::default().fg(view_color(app.session.view)),
|
||||
),
|
||||
Span::raw(" | rx/tx: "),
|
||||
Span::styled(
|
||||
format!(
|
||||
"{}/{}",
|
||||
app.session.received_messages, app.session.sent_messages
|
||||
),
|
||||
Style::default().fg(GREEN),
|
||||
),
|
||||
]),
|
||||
];
|
||||
|
||||
frame.render_widget(
|
||||
Paragraph::new(header)
|
||||
.block(panel_block("Status", false, CYAN))
|
||||
.wrap(Wrap { trim: false }),
|
||||
area,
|
||||
);
|
||||
}
|
||||
|
||||
fn render_controls(frame: &mut Frame, app: &mut App, area: Rect) {
|
||||
app.add_hot_zone(area, HotAction::Focus(FocusPane::Controls));
|
||||
let focused = app.focus == FocusPane::Controls;
|
||||
let block = panel_block("Control Deck", focused, AMBER);
|
||||
let inner = block.inner(area);
|
||||
frame.render_widget(block, area);
|
||||
|
||||
let connect_label = if app.session.status.is_connectedish() {
|
||||
"[ Disconnect ]"
|
||||
} else {
|
||||
"[ Connect ]"
|
||||
};
|
||||
let ending = next_line_ending(app.session.line_ending);
|
||||
let rows: Vec<(Line<'static>, Option<HotAction>)> = vec![
|
||||
section_line("SESSION"),
|
||||
button_line(
|
||||
connect_label,
|
||||
status_color(&app.session.status),
|
||||
Some(HotAction::ToggleConnection),
|
||||
),
|
||||
button_line("[ Reconnect ]", BLUE, Some(HotAction::Reconnect)),
|
||||
button_line("[ Clear Buffers ]", RED, Some(HotAction::Clear)),
|
||||
button_line("[ Configure ]", MAGENTA, Some(HotAction::OpenConfig)),
|
||||
spacer_line(),
|
||||
section_line("LINES"),
|
||||
toggle_line(
|
||||
"Auto reconnect",
|
||||
app.session.auto_reconnect,
|
||||
Some(HotAction::ToggleAutoReconnect),
|
||||
),
|
||||
toggle_line("DTR", app.session.dtr, Some(HotAction::ToggleDtr)),
|
||||
toggle_line("RTS", app.session.rts, Some(HotAction::ToggleRts)),
|
||||
spacer_line(),
|
||||
section_line("DISPLAY"),
|
||||
toggle_line(
|
||||
"Timestamp",
|
||||
app.display.show_timestamp,
|
||||
Some(HotAction::ToggleTimestamp),
|
||||
),
|
||||
toggle_line(
|
||||
"Direction",
|
||||
app.display.show_direction,
|
||||
Some(HotAction::ToggleDirection),
|
||||
),
|
||||
toggle_line(
|
||||
"Pipeline",
|
||||
app.display.show_pipeline,
|
||||
Some(HotAction::TogglePipeline),
|
||||
),
|
||||
spacer_line(),
|
||||
section_line("SEND"),
|
||||
value_line(
|
||||
"Mode",
|
||||
app.session.send_mode.label(),
|
||||
Some(HotAction::SendMode(match app.session.send_mode {
|
||||
SendMode::Text => SendMode::Hex,
|
||||
SendMode::Hex => SendMode::Text,
|
||||
})),
|
||||
),
|
||||
value_line(
|
||||
"Ending",
|
||||
app.session.line_ending.label(),
|
||||
Some(HotAction::LineEnding(ending)),
|
||||
),
|
||||
button_line("[ Search ]", CYAN, Some(HotAction::OpenSearch)),
|
||||
button_line("[ Help ]", MUTED, Some(HotAction::OpenHelp)),
|
||||
];
|
||||
|
||||
for (index, (_, action)) in rows.iter().enumerate() {
|
||||
if let Some(action) = action {
|
||||
let y = inner.y.saturating_add(index as u16);
|
||||
if y < inner.y.saturating_add(inner.height) {
|
||||
app.add_hot_zone(
|
||||
Rect {
|
||||
x: inner.x,
|
||||
y,
|
||||
width: inner.width,
|
||||
height: 1,
|
||||
},
|
||||
*action,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
frame.render_widget(
|
||||
Paragraph::new(rows.into_iter().map(|(line, _)| line).collect::<Vec<_>>())
|
||||
.style(Style::default().fg(TEXT).bg(PANEL)),
|
||||
inner,
|
||||
);
|
||||
}
|
||||
|
||||
fn render_main(frame: &mut Frame, app: &mut App, area: Rect) {
|
||||
app.add_hot_zone(area, HotAction::Focus(FocusPane::Main));
|
||||
let layout = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([Constraint::Length(3), Constraint::Min(5)])
|
||||
.split(area);
|
||||
|
||||
render_tabs(frame, app, layout[0]);
|
||||
match app.session.view {
|
||||
View::Text => render_text_view(frame, app, layout[1]),
|
||||
View::Hex => render_hex_view(frame, app, layout[1]),
|
||||
View::Plot => render_plot_view(frame, app, layout[1]),
|
||||
}
|
||||
}
|
||||
|
||||
fn render_tabs(frame: &mut Frame, app: &mut App, area: Rect) {
|
||||
let chunks = Layout::default()
|
||||
.direction(Direction::Horizontal)
|
||||
.constraints([
|
||||
Constraint::Percentage(34),
|
||||
Constraint::Percentage(33),
|
||||
Constraint::Percentage(33),
|
||||
])
|
||||
.split(area);
|
||||
|
||||
for (idx, view) in View::ALL.into_iter().enumerate() {
|
||||
let selected = app.session.view == view;
|
||||
app.add_hot_zone(chunks[idx], HotAction::View(view));
|
||||
let color = view_color(view);
|
||||
let block = panel_block(view.label(), selected, color);
|
||||
frame.render_widget(
|
||||
Paragraph::new(Line::from(vec![
|
||||
Span::styled(
|
||||
view.label(),
|
||||
Style::default()
|
||||
.fg(if selected { Color::Black } else { color })
|
||||
.bg(if selected { color } else { PANEL })
|
||||
.add_modifier(Modifier::BOLD),
|
||||
),
|
||||
Span::styled(
|
||||
match view {
|
||||
View::Text => format!(" {} lines", app.session.console.len()),
|
||||
View::Hex => format!(" {} lines", app.session.hex.len()),
|
||||
View::Plot => format!(
|
||||
" {} series / {} pts",
|
||||
app.session.plot.series_len(),
|
||||
app.session.plot.total_points()
|
||||
),
|
||||
},
|
||||
Style::default().fg(MUTED),
|
||||
),
|
||||
]))
|
||||
.block(block)
|
||||
.alignment(Alignment::Center),
|
||||
chunks[idx],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn render_text_view(frame: &mut Frame, app: &mut App, area: Rect) {
|
||||
let focused = app.focus == FocusPane::Main;
|
||||
let block = panel_block("Text Stream", focused, CYAN);
|
||||
let inner = block.inner(area);
|
||||
let height = inner.height as usize;
|
||||
let total = app.session.console.len();
|
||||
let start = app.visible_start(total, height);
|
||||
let end = start.saturating_add(height).min(total);
|
||||
|
||||
let lines = if total == 0 {
|
||||
vec![Line::styled("no text data", Style::default().fg(MUTED))]
|
||||
} else {
|
||||
(start..end)
|
||||
.filter_map(|index| {
|
||||
app.session
|
||||
.console
|
||||
.get(index)
|
||||
.map(|line| format_console_line(index, line, app.display, app))
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
|
||||
frame.render_widget(
|
||||
Paragraph::new(lines)
|
||||
.block(block)
|
||||
.style(Style::default().bg(PANEL_ALT))
|
||||
.wrap(Wrap { trim: false }),
|
||||
area,
|
||||
);
|
||||
}
|
||||
|
||||
fn render_hex_view(frame: &mut Frame, app: &mut App, area: Rect) {
|
||||
let focused = app.focus == FocusPane::Main;
|
||||
let block = panel_block("Hex Stream", focused, AMBER);
|
||||
let inner = block.inner(area);
|
||||
let height = inner.height as usize;
|
||||
let total = app.session.hex.len();
|
||||
let start = app.visible_start(total, height);
|
||||
let end = start.saturating_add(height).min(total);
|
||||
|
||||
let lines = if total == 0 {
|
||||
vec![Line::styled("no hex data", Style::default().fg(MUTED))]
|
||||
} else {
|
||||
(start..end)
|
||||
.filter_map(|index| {
|
||||
app.session
|
||||
.hex
|
||||
.get(index)
|
||||
.map(|line| format_hex_line(index, line, app.display, app))
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
|
||||
frame.render_widget(
|
||||
Paragraph::new(lines)
|
||||
.block(block)
|
||||
.style(Style::default().bg(PANEL_ALT))
|
||||
.wrap(Wrap { trim: false }),
|
||||
area,
|
||||
);
|
||||
}
|
||||
|
||||
fn render_plot_view(frame: &mut Frame, app: &mut App, area: Rect) {
|
||||
let focused = app.focus == FocusPane::Main;
|
||||
let block = panel_block("Plot View", focused, GREEN);
|
||||
let inner = block.inner(area);
|
||||
app.add_hot_zone(inner, HotAction::Focus(FocusPane::Main));
|
||||
|
||||
if app.session.plot.is_empty() {
|
||||
frame.render_widget(
|
||||
Paragraph::new(vec![
|
||||
Line::styled("no plot data", Style::default().fg(MUTED)),
|
||||
Line::from(vec![
|
||||
Span::raw("pipeline: "),
|
||||
Span::styled(app.pipeline_summary(), Style::default().fg(MAGENTA)),
|
||||
]),
|
||||
])
|
||||
.block(block)
|
||||
.style(Style::default().bg(PANEL_ALT))
|
||||
.alignment(Alignment::Center),
|
||||
area,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let Some((min, max)) = app.plot_bounds() else {
|
||||
frame.render_widget(
|
||||
Paragraph::new("plot bounds unavailable")
|
||||
.block(block)
|
||||
.style(Style::default().fg(MUTED).bg(PANEL_ALT)),
|
||||
area,
|
||||
);
|
||||
return;
|
||||
};
|
||||
|
||||
let max_points = (inner.width as usize).saturating_mul(2).max(32);
|
||||
let kind = app.session.plot.kind();
|
||||
let series_points: Vec<(String, Vec<(f64, f64)>)> = app
|
||||
.session
|
||||
.plot
|
||||
.iter()
|
||||
.map(|series| {
|
||||
let points = match kind {
|
||||
PlotSeriesKind::TimeSeries => {
|
||||
series.render_points_time_series(min[0], max[0], max_points)
|
||||
}
|
||||
PlotSeriesKind::XY => series.render_points_xy(max_points),
|
||||
};
|
||||
(
|
||||
series.name.clone(),
|
||||
points.into_iter().map(|[x, y]| (x, y)).collect(),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
|
||||
let palette = [GREEN, CYAN, AMBER, MAGENTA, BLUE, RED];
|
||||
let datasets = series_points
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, (name, points))| {
|
||||
Dataset::default()
|
||||
.name(name.as_str())
|
||||
.marker(Marker::Braille)
|
||||
.graph_type(GraphType::Line)
|
||||
.style(Style::default().fg(palette[index % palette.len()]))
|
||||
.data(points)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let chart = Chart::new(datasets)
|
||||
.block(block)
|
||||
.style(Style::default().bg(PANEL_ALT))
|
||||
.x_axis(axis("X", min[0], max[0], CYAN))
|
||||
.y_axis(axis("Y", min[1], max[1], GREEN));
|
||||
|
||||
frame.render_widget(chart, area);
|
||||
|
||||
let overlay = Rect {
|
||||
x: inner.x.saturating_add(1),
|
||||
y: inner.y,
|
||||
width: inner.width.saturating_sub(2).min(72),
|
||||
height: 1,
|
||||
};
|
||||
let controls = Layout::default()
|
||||
.direction(Direction::Horizontal)
|
||||
.constraints([
|
||||
Constraint::Length(16),
|
||||
Constraint::Length(10),
|
||||
Constraint::Length(10),
|
||||
Constraint::Min(1),
|
||||
])
|
||||
.split(overlay);
|
||||
app.add_hot_zone(controls[0], HotAction::PlotFollow);
|
||||
app.add_hot_zone(controls[1], HotAction::PlotZoomIn);
|
||||
app.add_hot_zone(controls[2], HotAction::PlotZoomOut);
|
||||
let mode = match kind {
|
||||
PlotSeriesKind::TimeSeries => "time",
|
||||
PlotSeriesKind::XY => "xy",
|
||||
};
|
||||
frame.render_widget(
|
||||
Paragraph::new(Line::from(vec![
|
||||
Span::styled(
|
||||
format!(
|
||||
"[follow {}] ",
|
||||
if app.plot.follow_latest { "on" } else { "off" }
|
||||
),
|
||||
Style::default()
|
||||
.fg(Color::Black)
|
||||
.bg(if app.plot.follow_latest { GREEN } else { MUTED })
|
||||
.add_modifier(Modifier::BOLD),
|
||||
),
|
||||
Span::styled("[zoom +] ", Style::default().fg(Color::Black).bg(CYAN)),
|
||||
Span::styled("[zoom -] ", Style::default().fg(Color::Black).bg(AMBER)),
|
||||
Span::styled(format!("{mode} "), Style::default().fg(MUTED)),
|
||||
Span::styled(
|
||||
format!(
|
||||
"{} series / {} pts",
|
||||
app.session.plot.series_len(),
|
||||
app.session.plot.total_points()
|
||||
),
|
||||
Style::default().fg(TEXT),
|
||||
),
|
||||
]))
|
||||
.style(Style::default().bg(PANEL_ALT)),
|
||||
overlay,
|
||||
);
|
||||
}
|
||||
|
||||
fn render_composer(frame: &mut Frame, app: &mut App, area: Rect) {
|
||||
app.add_hot_zone(area, HotAction::Focus(FocusPane::Composer));
|
||||
let focused = app.focus == FocusPane::Composer || matches!(app.mode, AppMode::EditingSend);
|
||||
let block = panel_block("Composer", focused, MAGENTA);
|
||||
let inner = block.inner(area);
|
||||
frame.render_widget(block, area);
|
||||
|
||||
let chunks = Layout::default()
|
||||
.direction(Direction::Horizontal)
|
||||
.constraints([Constraint::Min(20), Constraint::Length(28)])
|
||||
.split(inner);
|
||||
|
||||
let input_block = Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.border_style(Style::default().fg(if focused { MAGENTA } else { MUTED }))
|
||||
.style(Style::default().bg(PANEL_ALT));
|
||||
|
||||
frame.render_widget(
|
||||
Paragraph::new(input_line(app))
|
||||
.block(input_block)
|
||||
.wrap(Wrap { trim: false }),
|
||||
chunks[0],
|
||||
);
|
||||
|
||||
let side_rows = vec![
|
||||
value_line(
|
||||
"Mode",
|
||||
app.session.send_mode.label(),
|
||||
Some(HotAction::SendMode(match app.session.send_mode {
|
||||
SendMode::Text => SendMode::Hex,
|
||||
SendMode::Hex => SendMode::Text,
|
||||
})),
|
||||
),
|
||||
value_line(
|
||||
"Ending",
|
||||
app.session.line_ending.label(),
|
||||
Some(HotAction::LineEnding(next_line_ending(
|
||||
app.session.line_ending,
|
||||
))),
|
||||
),
|
||||
button_line("[ Send ]", GREEN, Some(HotAction::Send)),
|
||||
(
|
||||
Line::from(vec![
|
||||
Span::styled("Status ", Style::default().fg(MUTED)),
|
||||
Span::styled(app.session.send_status.clone(), Style::default().fg(TEXT)),
|
||||
]),
|
||||
None,
|
||||
),
|
||||
];
|
||||
|
||||
for (index, (_, action)) in side_rows.iter().enumerate() {
|
||||
if let Some(action) = action {
|
||||
app.add_hot_zone(
|
||||
Rect {
|
||||
x: chunks[1].x,
|
||||
y: chunks[1].y.saturating_add(index as u16),
|
||||
width: chunks[1].width,
|
||||
height: 1,
|
||||
},
|
||||
*action,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
frame.render_widget(
|
||||
Paragraph::new(
|
||||
side_rows
|
||||
.into_iter()
|
||||
.map(|(line, _)| line)
|
||||
.collect::<Vec<_>>(),
|
||||
)
|
||||
.style(Style::default().bg(PANEL)),
|
||||
chunks[1],
|
||||
);
|
||||
}
|
||||
|
||||
fn render_footer(frame: &mut Frame, app: &App, area: Rect) {
|
||||
let search = if app.search.active && !app.search.query.is_empty() {
|
||||
format!(
|
||||
" search {}/{} '{}'",
|
||||
app.search.display_index(),
|
||||
app.search.count(),
|
||||
app.search.query
|
||||
)
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
let shortcuts = "1/2/3 view c connect r reconnect e config / search Ctrl-S send q quit";
|
||||
frame.render_widget(
|
||||
Paragraph::new(Line::from(vec![
|
||||
Span::styled(
|
||||
format!(" {} ", app.notice),
|
||||
Style::default().fg(TEXT).bg(PANEL_ALT),
|
||||
),
|
||||
Span::styled(search, Style::default().fg(AMBER).bg(PANEL_ALT)),
|
||||
Span::styled(" ", Style::default().bg(PANEL_ALT)),
|
||||
Span::styled(shortcuts, Style::default().fg(MUTED).bg(PANEL_ALT)),
|
||||
])),
|
||||
area,
|
||||
);
|
||||
}
|
||||
|
||||
fn render_search_modal(frame: &mut Frame, app: &mut App) {
|
||||
let area = centered_rect(70, 7, frame.area());
|
||||
app.add_hot_zone(area, HotAction::CloseModal);
|
||||
frame.render_widget(Clear, area);
|
||||
let block = panel_block("Search", true, CYAN);
|
||||
let inner = block.inner(area);
|
||||
frame.render_widget(block, area);
|
||||
|
||||
let body = vec![
|
||||
Line::from(vec![
|
||||
Span::styled("Query: ", Style::default().fg(MUTED)),
|
||||
Span::styled(app.search.query.clone(), Style::default().fg(TEXT)),
|
||||
Span::styled(
|
||||
"_",
|
||||
Style::default().fg(CYAN).add_modifier(Modifier::SLOW_BLINK),
|
||||
),
|
||||
]),
|
||||
Line::from(vec![
|
||||
Span::styled("Matches: ", Style::default().fg(MUTED)),
|
||||
Span::styled(
|
||||
format!("{}/{}", app.search.display_index(), app.search.count()),
|
||||
Style::default().fg(AMBER),
|
||||
),
|
||||
Span::raw(" "),
|
||||
Span::styled(
|
||||
if app.search.case_sensitive {
|
||||
"case sensitive"
|
||||
} else {
|
||||
"case insensitive"
|
||||
},
|
||||
Style::default().fg(MAGENTA),
|
||||
),
|
||||
]),
|
||||
Line::styled(
|
||||
"Enter close Up/Down navigate Ctrl-C case",
|
||||
Style::default().fg(MUTED),
|
||||
),
|
||||
];
|
||||
|
||||
frame.render_widget(
|
||||
Paragraph::new(body)
|
||||
.style(Style::default().bg(PANEL))
|
||||
.wrap(Wrap { trim: false }),
|
||||
inner,
|
||||
);
|
||||
}
|
||||
|
||||
fn render_config_modal(frame: &mut Frame, app: &mut App) {
|
||||
let rows = app.config_form.rows();
|
||||
let height = (rows.len() as u16 + 6).min(frame.area().height.saturating_sub(2));
|
||||
let area = centered_rect(88, height, frame.area());
|
||||
frame.render_widget(Clear, area);
|
||||
let block = panel_block("Session Config", true, MAGENTA);
|
||||
let inner = block.inner(area);
|
||||
frame.render_widget(block, area);
|
||||
|
||||
let footer_height = 3;
|
||||
let layout = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([Constraint::Min(3), Constraint::Length(footer_height)])
|
||||
.split(inner);
|
||||
|
||||
let items = rows
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, row)| {
|
||||
let focused = index == app.config_form.focused;
|
||||
let value_style = if row.editable {
|
||||
Style::default().fg(CYAN)
|
||||
} else {
|
||||
Style::default().fg(AMBER)
|
||||
};
|
||||
ListItem::new(Line::from(vec![
|
||||
Span::styled(
|
||||
format!("{:<18}", row.label),
|
||||
Style::default().fg(if focused { Color::Black } else { MUTED }),
|
||||
),
|
||||
Span::styled(
|
||||
row.value.clone(),
|
||||
if focused {
|
||||
value_style.bg(MAGENTA).fg(Color::Black)
|
||||
} else {
|
||||
value_style
|
||||
},
|
||||
),
|
||||
]))
|
||||
.style(if focused {
|
||||
Style::default().bg(MAGENTA)
|
||||
} else {
|
||||
Style::default().bg(PANEL)
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
for (index, row) in rows.iter().enumerate() {
|
||||
let y = layout[0].y.saturating_add(index as u16);
|
||||
if y >= layout[0].y.saturating_add(layout[0].height) {
|
||||
break;
|
||||
}
|
||||
app.add_hot_zone(
|
||||
Rect {
|
||||
x: layout[0].x,
|
||||
y,
|
||||
width: layout[0].width,
|
||||
height: 1,
|
||||
},
|
||||
if row.field == ConfigField::Apply {
|
||||
HotAction::ConfigSubmit
|
||||
} else {
|
||||
HotAction::ConfigFocus(row.field)
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
frame.render_widget(
|
||||
List::new(items).style(Style::default().bg(PANEL)),
|
||||
layout[0],
|
||||
);
|
||||
app.add_hot_zone(layout[1], HotAction::ConfigCancel);
|
||||
frame.render_widget(
|
||||
Paragraph::new(vec![
|
||||
Line::styled(
|
||||
"Tab move Left/Right change options Enter apply Esc cancel",
|
||||
Style::default().fg(MUTED),
|
||||
),
|
||||
Line::styled(serial_ports_hint(app), Style::default().fg(BLUE)),
|
||||
])
|
||||
.style(Style::default().bg(PANEL)),
|
||||
layout[1],
|
||||
);
|
||||
}
|
||||
|
||||
fn render_help_modal(frame: &mut Frame, app: &mut App) {
|
||||
let area = centered_rect(74, 12, frame.area());
|
||||
app.add_hot_zone(area, HotAction::CloseModal);
|
||||
frame.render_widget(Clear, area);
|
||||
let block = panel_block("Help", true, BLUE);
|
||||
let inner = block.inner(area);
|
||||
frame.render_widget(block, area);
|
||||
frame.render_widget(
|
||||
Paragraph::new(vec![
|
||||
Line::styled(
|
||||
"Keyboard",
|
||||
Style::default().fg(CYAN).add_modifier(Modifier::BOLD),
|
||||
),
|
||||
Line::raw("1/2/3 switch views, Tab shift focus, q quit"),
|
||||
Line::raw("c connect, r reconnect, e config, / search, x clear"),
|
||||
Line::raw("Ctrl-S send, Enter edit/send composer, Esc close modal"),
|
||||
Line::styled(
|
||||
"Mouse",
|
||||
Style::default().fg(AMBER).add_modifier(Modifier::BOLD),
|
||||
),
|
||||
Line::raw("Click tabs, buttons, toggles, config rows, and composer."),
|
||||
Line::raw("Wheel scrolls Text/Hex and zooms Plot."),
|
||||
])
|
||||
.style(Style::default().fg(TEXT).bg(PANEL))
|
||||
.wrap(Wrap { trim: false }),
|
||||
inner,
|
||||
);
|
||||
}
|
||||
|
||||
fn format_console_line(
|
||||
index: usize,
|
||||
line: &ConsoleLine,
|
||||
display: DisplayOptions,
|
||||
app: &App,
|
||||
) -> Line<'static> {
|
||||
let mut spans = prefix_spans(line.elapsed, line.direction, &line.pipeline, display);
|
||||
let base_style = Style::default().fg(TEXT);
|
||||
spans.extend(ansi_to_spans(&line.text, base_style));
|
||||
apply_search_style(index, Line::from(spans), app)
|
||||
}
|
||||
|
||||
fn format_hex_line(
|
||||
index: usize,
|
||||
line: &HexLine,
|
||||
display: DisplayOptions,
|
||||
app: &App,
|
||||
) -> Line<'static> {
|
||||
let mut spans = prefix_spans(line.elapsed, line.direction, &line.pipeline, display);
|
||||
spans.push(Span::styled(line.hex.clone(), Style::default().fg(AMBER)));
|
||||
spans.push(Span::styled(" |", Style::default().fg(MUTED)));
|
||||
spans.push(Span::styled(line.ascii.clone(), Style::default().fg(TEXT)));
|
||||
spans.push(Span::styled("|", Style::default().fg(MUTED)));
|
||||
apply_search_style(index, Line::from(spans), app)
|
||||
}
|
||||
|
||||
fn prefix_spans(
|
||||
elapsed: Duration,
|
||||
direction: LineDirection,
|
||||
pipeline: &str,
|
||||
display: DisplayOptions,
|
||||
) -> Vec<Span<'static>> {
|
||||
let mut spans = Vec::new();
|
||||
if display.show_timestamp {
|
||||
spans.push(Span::styled(
|
||||
format!("[{}] ", format_elapsed(elapsed)),
|
||||
Style::default().fg(MUTED),
|
||||
));
|
||||
}
|
||||
if display.show_direction {
|
||||
let (label, color) = match direction {
|
||||
LineDirection::In => ("IN", CYAN),
|
||||
LineDirection::Out => ("OUT", AMBER),
|
||||
};
|
||||
spans.push(Span::styled(
|
||||
format!("[{label}] "),
|
||||
Style::default().fg(color).add_modifier(Modifier::BOLD),
|
||||
));
|
||||
}
|
||||
if display.show_pipeline {
|
||||
spans.push(Span::styled(
|
||||
format!("[{pipeline}] "),
|
||||
Style::default().fg(MAGENTA),
|
||||
));
|
||||
}
|
||||
spans
|
||||
}
|
||||
|
||||
fn apply_search_style(index: usize, line: Line<'static>, app: &App) -> Line<'static> {
|
||||
if !app.search.active || app.search.query.is_empty() {
|
||||
return line;
|
||||
}
|
||||
|
||||
if app.search.current_line() == Some(index) {
|
||||
line.style(Style::default().bg(AMBER))
|
||||
} else if app.search.matches.contains(&index) {
|
||||
line.style(Style::default().bg(Color::Rgb(47, 61, 91)))
|
||||
} else {
|
||||
line
|
||||
}
|
||||
}
|
||||
|
||||
fn input_line(app: &App) -> Line<'static> {
|
||||
if app.session.send_input.is_empty() {
|
||||
return Line::styled(
|
||||
match app.session.send_mode {
|
||||
SendMode::Text => "type text payload",
|
||||
SendMode::Hex => "hex bytes, e.g. 48 65 6c 6c 6f",
|
||||
},
|
||||
Style::default().fg(MUTED),
|
||||
);
|
||||
}
|
||||
|
||||
if !matches!(app.mode, AppMode::EditingSend) {
|
||||
return Line::styled(app.session.send_input.clone(), Style::default().fg(TEXT));
|
||||
}
|
||||
|
||||
let mut spans = Vec::new();
|
||||
let cursor = app.session.input_cursor;
|
||||
for (index, ch) in app.session.send_input.chars().enumerate() {
|
||||
if index == cursor {
|
||||
spans.push(Span::styled(
|
||||
ch.to_string(),
|
||||
Style::default().fg(Color::Black).bg(MAGENTA),
|
||||
));
|
||||
} else {
|
||||
spans.push(Span::styled(ch.to_string(), Style::default().fg(TEXT)));
|
||||
}
|
||||
}
|
||||
if cursor >= app.session.send_input.chars().count() {
|
||||
spans.push(Span::styled(" ", Style::default().bg(MAGENTA)));
|
||||
}
|
||||
Line::from(spans)
|
||||
}
|
||||
|
||||
fn section_line(label: &'static str) -> (Line<'static>, Option<HotAction>) {
|
||||
(
|
||||
Line::styled(
|
||||
format!("-- {label} "),
|
||||
Style::default().fg(MUTED).add_modifier(Modifier::BOLD),
|
||||
),
|
||||
None,
|
||||
)
|
||||
}
|
||||
|
||||
fn spacer_line() -> (Line<'static>, Option<HotAction>) {
|
||||
(Line::raw(""), None)
|
||||
}
|
||||
|
||||
fn button_line(
|
||||
label: &'static str,
|
||||
color: Color,
|
||||
action: Option<HotAction>,
|
||||
) -> (Line<'static>, Option<HotAction>) {
|
||||
(
|
||||
Line::from(vec![Span::styled(
|
||||
label,
|
||||
Style::default()
|
||||
.fg(Color::Black)
|
||||
.bg(color)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
)]),
|
||||
action,
|
||||
)
|
||||
}
|
||||
|
||||
fn toggle_line(
|
||||
label: &'static str,
|
||||
enabled: bool,
|
||||
action: Option<HotAction>,
|
||||
) -> (Line<'static>, Option<HotAction>) {
|
||||
(
|
||||
Line::from(vec![
|
||||
Span::styled(
|
||||
if enabled { "[x] " } else { "[ ] " },
|
||||
Style::default().fg(if enabled { GREEN } else { MUTED }),
|
||||
),
|
||||
Span::styled(label, Style::default().fg(TEXT)),
|
||||
]),
|
||||
action,
|
||||
)
|
||||
}
|
||||
|
||||
fn value_line(
|
||||
label: &'static str,
|
||||
value: &'static str,
|
||||
action: Option<HotAction>,
|
||||
) -> (Line<'static>, Option<HotAction>) {
|
||||
(
|
||||
Line::from(vec![
|
||||
Span::styled(format!("{label:<8}"), Style::default().fg(MUTED)),
|
||||
Span::styled(value.to_string(), Style::default().fg(CYAN)),
|
||||
]),
|
||||
action,
|
||||
)
|
||||
}
|
||||
|
||||
fn panel_block(title: &str, focused: bool, color: Color) -> Block<'_> {
|
||||
Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.title(Span::styled(
|
||||
format!(" {title} "),
|
||||
Style::default()
|
||||
.fg(if focused { Color::Black } else { color })
|
||||
.bg(if focused { color } else { PANEL })
|
||||
.add_modifier(Modifier::BOLD),
|
||||
))
|
||||
.border_style(Style::default().fg(if focused {
|
||||
color
|
||||
} else {
|
||||
Color::Rgb(45, 58, 74)
|
||||
}))
|
||||
.style(Style::default().bg(PANEL))
|
||||
}
|
||||
|
||||
fn axis(title: &'static str, min: f64, max: f64, color: Color) -> Axis<'static> {
|
||||
Axis::default()
|
||||
.title(title)
|
||||
.style(Style::default().fg(color))
|
||||
.bounds([min, max])
|
||||
.labels(vec![
|
||||
Span::styled(format!("{min:.2}"), Style::default().fg(MUTED)),
|
||||
Span::styled(format!("{max:.2}"), Style::default().fg(MUTED)),
|
||||
])
|
||||
}
|
||||
|
||||
fn centered_rect(width: u16, height: u16, area: Rect) -> Rect {
|
||||
let vertical = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([
|
||||
Constraint::Fill(1),
|
||||
Constraint::Length(height.min(area.height)),
|
||||
Constraint::Fill(1),
|
||||
])
|
||||
.split(area);
|
||||
|
||||
Layout::default()
|
||||
.direction(Direction::Horizontal)
|
||||
.constraints([
|
||||
Constraint::Fill(1),
|
||||
Constraint::Length(width.min(area.width)),
|
||||
Constraint::Fill(1),
|
||||
])
|
||||
.split(vertical[1])[1]
|
||||
}
|
||||
|
||||
fn status_color(status: &ConnectionStatus) -> Color {
|
||||
match status {
|
||||
ConnectionStatus::Connected => GREEN,
|
||||
ConnectionStatus::Disconnected => MUTED,
|
||||
ConnectionStatus::Connecting => AMBER,
|
||||
ConnectionStatus::Error(_) => RED,
|
||||
}
|
||||
}
|
||||
|
||||
fn view_color(view: View) -> Color {
|
||||
match view {
|
||||
View::Text => CYAN,
|
||||
View::Hex => AMBER,
|
||||
View::Plot => GREEN,
|
||||
}
|
||||
}
|
||||
|
||||
fn format_elapsed(elapsed: Duration) -> String {
|
||||
let secs = elapsed.as_secs_f64();
|
||||
if secs < 60.0 {
|
||||
format!("{secs:05.2}")
|
||||
} else {
|
||||
format!("{:02}:{:02}", (secs / 60.0) as u64, (secs % 60.0) as u64)
|
||||
}
|
||||
}
|
||||
|
||||
fn next_line_ending(current: LineEnding) -> LineEnding {
|
||||
let endings = LineEnding::ALL;
|
||||
let index = endings
|
||||
.iter()
|
||||
.position(|ending| *ending == current)
|
||||
.unwrap_or(0);
|
||||
endings[(index + 1) % endings.len()]
|
||||
}
|
||||
|
||||
fn serial_ports_hint(app: &App) -> String {
|
||||
if app.config_form.available_serial_ports.is_empty() {
|
||||
String::from("Serial ports: none detected")
|
||||
} else {
|
||||
format!(
|
||||
"Serial ports: {}",
|
||||
app.config_form.available_serial_ports.join(", ")
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
mod app;
|
||||
mod app_state;
|
||||
mod buffers;
|
||||
mod logging;
|
||||
mod panels;
|
||||
mod perf;
|
||||
mod shortcuts;
|
||||
mod ui_fonts;
|
||||
|
||||
use xserial_client::SessionManager;
|
||||
|
||||
fn main() {
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(tracing_subscriber::EnvFilter::from_default_env())
|
||||
.init();
|
||||
|
||||
let rt = tokio::runtime::Runtime::new().expect("tokio runtime");
|
||||
let _guard = rt.enter();
|
||||
|
||||
let mgr = SessionManager::new();
|
||||
let rx = mgr.subscribe();
|
||||
|
||||
let _ = eframe::run_native(
|
||||
"xserial",
|
||||
eframe::NativeOptions::default(),
|
||||
Box::new(|cc| Ok(Box::new(app::XserialApp::new(mgr, rx, cc.egui_ctx.clone())))),
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,146 +0,0 @@
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use xserial_client::{DecodedEntry, RingBuffer};
|
||||
use xserial_core::protocol::DecodedData;
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
pub enum LineDirection {
|
||||
In,
|
||||
Out,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct ConsoleLine {
|
||||
pub elapsed: Duration,
|
||||
pub pipeline: String,
|
||||
pub text: String,
|
||||
pub direction: LineDirection,
|
||||
}
|
||||
|
||||
pub struct TextBuffer {
|
||||
started_at: Instant,
|
||||
lines: RingBuffer<ConsoleLine>,
|
||||
}
|
||||
|
||||
impl TextBuffer {
|
||||
pub fn new(limit: usize) -> Self {
|
||||
Self {
|
||||
started_at: Instant::now(),
|
||||
lines: RingBuffer::new(limit),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn push(&mut self, entry: &DecodedEntry) {
|
||||
if let DecodedData::Text(text) = &entry.data {
|
||||
self.lines.push(ConsoleLine {
|
||||
elapsed: self.started_at.elapsed(),
|
||||
pipeline: entry.pipeline_name.clone(),
|
||||
text: text.clone(),
|
||||
direction: LineDirection::In,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
pub fn push_outbound(&mut self, text: String) {
|
||||
self.lines.push(ConsoleLine {
|
||||
elapsed: self.started_at.elapsed(),
|
||||
pipeline: String::from("OUT"),
|
||||
text,
|
||||
direction: LineDirection::Out,
|
||||
});
|
||||
}
|
||||
|
||||
pub fn len(&self) -> usize {
|
||||
self.lines.len()
|
||||
}
|
||||
|
||||
pub fn clear(&mut self) {
|
||||
self.lines.clear();
|
||||
}
|
||||
|
||||
pub fn set_limit(&mut self, limit: usize) {
|
||||
self.lines.set_limit(limit);
|
||||
}
|
||||
|
||||
pub fn recent(&self, count: usize) -> Vec<ConsoleLine> {
|
||||
self.lines.drain_recent(count)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct HexLine {
|
||||
pub elapsed: Duration,
|
||||
pub pipeline: String,
|
||||
pub hex: String,
|
||||
pub ascii: String,
|
||||
pub direction: LineDirection,
|
||||
}
|
||||
|
||||
pub struct HexBuffer {
|
||||
started_at: Instant,
|
||||
lines: RingBuffer<HexLine>,
|
||||
}
|
||||
|
||||
impl HexBuffer {
|
||||
pub fn new(limit: usize) -> Self {
|
||||
Self {
|
||||
started_at: Instant::now(),
|
||||
lines: RingBuffer::new(limit),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn push(&mut self, entry: &DecodedEntry) {
|
||||
if let DecodedData::Hex(hex) = &entry.data {
|
||||
self.lines.push(HexLine {
|
||||
elapsed: self.started_at.elapsed(),
|
||||
pipeline: entry.pipeline_name.clone(),
|
||||
ascii: decode_ascii(hex),
|
||||
hex: hex.clone(),
|
||||
direction: LineDirection::In,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
pub fn push_outbound(&mut self, hex: String) {
|
||||
self.lines.push(HexLine {
|
||||
elapsed: self.started_at.elapsed(),
|
||||
pipeline: String::from("OUT"),
|
||||
ascii: decode_ascii(&hex),
|
||||
hex,
|
||||
direction: LineDirection::Out,
|
||||
});
|
||||
}
|
||||
|
||||
pub fn len(&self) -> usize {
|
||||
self.lines.len()
|
||||
}
|
||||
|
||||
pub fn clear(&mut self) {
|
||||
self.lines.clear();
|
||||
}
|
||||
|
||||
pub fn set_limit(&mut self, limit: usize) {
|
||||
self.lines.set_limit(limit);
|
||||
}
|
||||
|
||||
pub fn recent(&self, count: usize) -> Vec<HexLine> {
|
||||
self.lines.drain_recent(count)
|
||||
}
|
||||
}
|
||||
|
||||
fn decode_ascii(hex: &str) -> String {
|
||||
hex::decode(hex.replace(' ', ""))
|
||||
.map(|bytes| {
|
||||
bytes
|
||||
.into_iter()
|
||||
.map(|byte| {
|
||||
if byte.is_ascii_graphic() || byte == b' ' {
|
||||
byte as char
|
||||
} else {
|
||||
'.'
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_else(|_| String::from("[invalid hex]"))
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
mod app;
|
||||
mod app_state;
|
||||
mod buffers;
|
||||
mod ui;
|
||||
|
||||
use std::io;
|
||||
|
||||
use crossterm::{
|
||||
execute,
|
||||
terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode},
|
||||
};
|
||||
use ratatui::{Terminal, backend::CrosstermBackend};
|
||||
use xserial_client::SessionManager;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> io::Result<()> {
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(tracing_subscriber::EnvFilter::from_default_env())
|
||||
.init();
|
||||
|
||||
enable_raw_mode()?;
|
||||
let mut stdout = io::stdout();
|
||||
execute!(stdout, EnterAlternateScreen)?;
|
||||
|
||||
let backend = CrosstermBackend::new(stdout);
|
||||
let mut terminal = Terminal::new(backend)?;
|
||||
|
||||
let manager = SessionManager::new();
|
||||
let rx = manager.subscribe();
|
||||
|
||||
let result = app::run(&mut terminal, app::App::new(manager, rx));
|
||||
|
||||
disable_raw_mode()?;
|
||||
execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
|
||||
terminal.show_cursor()?;
|
||||
|
||||
result
|
||||
}
|
||||
@@ -1,332 +0,0 @@
|
||||
use ratatui::{
|
||||
Frame,
|
||||
layout::{Constraint, Direction, Layout, Rect},
|
||||
style::{Modifier, Style},
|
||||
widgets::{Block, Borders, Clear, List, ListItem, Paragraph, Wrap},
|
||||
};
|
||||
|
||||
use crate::app::{App, AppMode, ConnectionStatus, DisplayOptions, SendMode, SessionForm, View};
|
||||
use crate::buffers::{ConsoleLine, HexLine, LineDirection};
|
||||
|
||||
const MAX_RENDER_LINES: usize = 400;
|
||||
|
||||
pub fn render(frame: &mut Frame, app: &App) {
|
||||
let root = Layout::default()
|
||||
.direction(Direction::Horizontal)
|
||||
.constraints([Constraint::Length(34), Constraint::Min(20)])
|
||||
.split(frame.area());
|
||||
|
||||
render_sidebar(frame, app, root[0]);
|
||||
render_main(frame, app, root[1]);
|
||||
|
||||
if let AppMode::SessionForm(form) = app.mode() {
|
||||
render_session_form(frame, form);
|
||||
}
|
||||
}
|
||||
|
||||
fn render_sidebar(frame: &mut Frame, app: &App, area: Rect) {
|
||||
let items: Vec<ListItem> = app
|
||||
.tabs()
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, tab)| {
|
||||
let prefix = if index == app.active_index() {
|
||||
"> "
|
||||
} else {
|
||||
" "
|
||||
};
|
||||
let transport = App::transport_summary(&tab.session_config);
|
||||
ListItem::new(format!(
|
||||
"{prefix}[{}] {}\n {}",
|
||||
tab.id,
|
||||
tab.status.badge(),
|
||||
transport
|
||||
))
|
||||
})
|
||||
.collect();
|
||||
|
||||
let sidebar = if items.is_empty() {
|
||||
Paragraph::new("No sessions.\nPress n to create one.")
|
||||
.block(Block::default().borders(Borders::ALL).title("Sessions"))
|
||||
.wrap(Wrap { trim: false })
|
||||
} else {
|
||||
Paragraph::new("").block(Block::default().borders(Borders::ALL).title("Sessions"))
|
||||
};
|
||||
|
||||
frame.render_widget(sidebar, area);
|
||||
if !items.is_empty() {
|
||||
frame.render_widget(
|
||||
List::new(items).block(Block::default().borders(Borders::ALL).title("Sessions")),
|
||||
area,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn render_main(frame: &mut Frame, app: &App, area: Rect) {
|
||||
let sections = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([
|
||||
Constraint::Length(4),
|
||||
Constraint::Min(8),
|
||||
Constraint::Length(7),
|
||||
Constraint::Length(3),
|
||||
])
|
||||
.split(area);
|
||||
|
||||
if let Some(tab) = app.active_tab() {
|
||||
render_header(frame, app, sections[0]);
|
||||
render_receive(frame, app, sections[1]);
|
||||
render_send(frame, app, sections[2]);
|
||||
let footer = format!(
|
||||
"{}{}",
|
||||
app.help_text(),
|
||||
app.notice()
|
||||
.map(|notice| format!(" | {notice}"))
|
||||
.unwrap_or_default()
|
||||
);
|
||||
frame.render_widget(
|
||||
Paragraph::new(footer)
|
||||
.block(Block::default().borders(Borders::ALL).title("Help"))
|
||||
.wrap(Wrap { trim: false }),
|
||||
sections[3],
|
||||
);
|
||||
|
||||
if let ConnectionStatus::Error(_) = &tab.status {
|
||||
}
|
||||
} else {
|
||||
frame.render_widget(
|
||||
Paragraph::new(format!(
|
||||
"No active session.\n{}\n{}",
|
||||
app.help_text(),
|
||||
app.notice().unwrap_or("")
|
||||
))
|
||||
.block(Block::default().borders(Borders::ALL).title("xserial-tui"))
|
||||
.wrap(Wrap { trim: false }),
|
||||
area,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn render_header(frame: &mut Frame, app: &App, area: Rect) {
|
||||
let tab = app.active_tab().expect("active tab");
|
||||
let display = app.display();
|
||||
let status_line = match &tab.status {
|
||||
ConnectionStatus::Error(message) => {
|
||||
format!("Session {} [{}] {}", tab.id, tab.status.badge(), message)
|
||||
}
|
||||
_ => format!("Session {} [{}]", tab.id, tab.status.badge()),
|
||||
};
|
||||
let line2 = format!(
|
||||
"{} | View: {} | Send: {} | AutoReconnect: {} | AppendNewline: {}",
|
||||
App::transport_summary(&tab.session_config),
|
||||
match tab.view {
|
||||
View::Text => "Text",
|
||||
View::Hex => "Hex",
|
||||
},
|
||||
match tab.send_mode {
|
||||
SendMode::Text => "Text",
|
||||
SendMode::Hex => "Hex",
|
||||
},
|
||||
tab.auto_reconnect,
|
||||
tab.append_newline
|
||||
);
|
||||
let line3 = format!(
|
||||
"Display: ts={} dir={} pipe={} | Text lines={} Hex lines={}",
|
||||
display.show_timestamp,
|
||||
display.show_direction,
|
||||
display.show_pipeline,
|
||||
tab.console.len(),
|
||||
tab.hex.len()
|
||||
);
|
||||
frame.render_widget(
|
||||
Paragraph::new(format!("{status_line}\n{line2}\n{line3}"))
|
||||
.block(Block::default().borders(Borders::ALL).title("Session"))
|
||||
.wrap(Wrap { trim: false }),
|
||||
area,
|
||||
);
|
||||
}
|
||||
|
||||
fn render_receive(frame: &mut Frame, app: &App, area: Rect) {
|
||||
let tab = app.active_tab().expect("active tab");
|
||||
let display = app.display();
|
||||
let title = match tab.view {
|
||||
View::Text => "Receive Text",
|
||||
View::Hex => "Receive Hex",
|
||||
};
|
||||
|
||||
let lines: Vec<String> = match tab.view {
|
||||
View::Text => tab
|
||||
.console
|
||||
.recent(MAX_RENDER_LINES)
|
||||
.into_iter()
|
||||
.map(|line| format_console_line(&line, display))
|
||||
.collect(),
|
||||
View::Hex => tab
|
||||
.hex
|
||||
.recent(MAX_RENDER_LINES)
|
||||
.into_iter()
|
||||
.map(|line| format_hex_line(&line, display))
|
||||
.collect(),
|
||||
};
|
||||
|
||||
let body = if lines.is_empty() {
|
||||
String::from("no data")
|
||||
} else {
|
||||
lines.join("\n")
|
||||
};
|
||||
|
||||
frame.render_widget(
|
||||
Paragraph::new(body)
|
||||
.block(Block::default().borders(Borders::ALL).title(title))
|
||||
.wrap(Wrap { trim: false }),
|
||||
area,
|
||||
);
|
||||
}
|
||||
|
||||
fn render_send(frame: &mut Frame, app: &App, area: Rect) {
|
||||
let tab = app.active_tab().expect("active tab");
|
||||
let editing = matches!(app.mode(), AppMode::SendInput);
|
||||
let title = if editing { "Send [editing]" } else { "Send" };
|
||||
let status = tab.send_status.as_deref().unwrap_or("idle");
|
||||
let hint = match tab.send_mode {
|
||||
SendMode::Text => "Press i to edit input, Enter to send while editing",
|
||||
SendMode::Hex => "Hex bytes, e.g. 48 65 6C 6C 6F",
|
||||
};
|
||||
let body = format!(
|
||||
"Mode: {} | Append newline: {}\nStatus: {}\nHint: {}\n\n{}",
|
||||
match tab.send_mode {
|
||||
SendMode::Text => "Text",
|
||||
SendMode::Hex => "Hex",
|
||||
},
|
||||
tab.append_newline,
|
||||
status,
|
||||
hint,
|
||||
if tab.send_input.is_empty() {
|
||||
String::from("<empty>")
|
||||
} else {
|
||||
tab.send_input.clone()
|
||||
}
|
||||
);
|
||||
|
||||
frame.render_widget(
|
||||
Paragraph::new(body)
|
||||
.block(Block::default().borders(Borders::ALL).title(title))
|
||||
.wrap(Wrap { trim: false }),
|
||||
area,
|
||||
);
|
||||
}
|
||||
|
||||
fn render_session_form(frame: &mut Frame, form: &SessionForm) {
|
||||
let rows = form.rows();
|
||||
let footer_lines = form.footer_lines();
|
||||
let height = (rows.len() + footer_lines.len() + 4) as u16;
|
||||
let area = centered_rect(82, height, frame.area());
|
||||
let inner = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([
|
||||
Constraint::Min(1),
|
||||
Constraint::Length(footer_lines.len() as u16),
|
||||
])
|
||||
.margin(1)
|
||||
.split(area);
|
||||
|
||||
let items: Vec<ListItem> = rows
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(idx, row)| ListItem::new(row).style(focus_style(idx == form.focused_field)))
|
||||
.collect();
|
||||
|
||||
frame.render_widget(Clear, area);
|
||||
frame.render_widget(
|
||||
Block::default().borders(Borders::ALL).title(form.title()),
|
||||
area,
|
||||
);
|
||||
frame.render_widget(List::new(items), inner[0]);
|
||||
frame.render_widget(Paragraph::new(footer_lines.join("\n")), inner[1]);
|
||||
}
|
||||
|
||||
fn format_console_line(line: &ConsoleLine, display: DisplayOptions) -> String {
|
||||
let mut prefix = Vec::new();
|
||||
if display.show_timestamp {
|
||||
prefix.push(format!("[{}]", format_elapsed(line.elapsed)));
|
||||
}
|
||||
if display.show_direction {
|
||||
prefix.push(format!(
|
||||
"[{}]",
|
||||
match line.direction {
|
||||
LineDirection::In => "IN",
|
||||
LineDirection::Out => "OUT",
|
||||
}
|
||||
));
|
||||
}
|
||||
if display.show_pipeline {
|
||||
prefix.push(format!("[{}]", line.pipeline));
|
||||
}
|
||||
if prefix.is_empty() {
|
||||
line.text.clone()
|
||||
} else {
|
||||
format!("{} {}", prefix.join(" "), line.text)
|
||||
}
|
||||
}
|
||||
|
||||
fn format_hex_line(line: &HexLine, display: DisplayOptions) -> String {
|
||||
let mut prefix = Vec::new();
|
||||
if display.show_timestamp {
|
||||
prefix.push(format!("[{}]", format_elapsed(line.elapsed)));
|
||||
}
|
||||
if display.show_direction {
|
||||
prefix.push(format!(
|
||||
"[{}]",
|
||||
match line.direction {
|
||||
LineDirection::In => "IN",
|
||||
LineDirection::Out => "OUT",
|
||||
}
|
||||
));
|
||||
}
|
||||
if display.show_pipeline {
|
||||
prefix.push(format!("[{}]", line.pipeline));
|
||||
}
|
||||
let base = format!("{} |{}|", line.hex, line.ascii);
|
||||
if prefix.is_empty() {
|
||||
base
|
||||
} else {
|
||||
format!("{} {}", prefix.join(" "), base)
|
||||
}
|
||||
}
|
||||
|
||||
fn format_elapsed(elapsed: std::time::Duration) -> String {
|
||||
let secs = elapsed.as_secs_f64();
|
||||
if secs < 60.0 {
|
||||
format!("{secs:05.2}")
|
||||
} else {
|
||||
format!("{:02}:{:02}", (secs / 60.0) as u64, (secs % 60.0) as u64)
|
||||
}
|
||||
}
|
||||
|
||||
fn focus_style(focused: bool) -> Style {
|
||||
if focused {
|
||||
Style::default().add_modifier(Modifier::REVERSED | Modifier::BOLD)
|
||||
} else {
|
||||
Style::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn centered_rect(width: u16, height: u16, area: Rect) -> Rect {
|
||||
let vertical = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([
|
||||
Constraint::Fill(1),
|
||||
Constraint::Length(height.min(area.height)),
|
||||
Constraint::Fill(1),
|
||||
])
|
||||
.split(area);
|
||||
|
||||
Layout::default()
|
||||
.direction(Direction::Horizontal)
|
||||
.constraints([
|
||||
Constraint::Fill(1),
|
||||
Constraint::Length(width.min(area.width)),
|
||||
Constraint::Fill(1),
|
||||
])
|
||||
.split(vertical[1])[1]
|
||||
}
|
||||
317
tools/test_ansi.py
Executable file
317
tools/test_ansi.py
Executable file
@@ -0,0 +1,317 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Generate ANSI-colored terminal output for testing pipeview ANSI rendering."""
|
||||
|
||||
import argparse
|
||||
import math
|
||||
import random
|
||||
import socket
|
||||
import sys
|
||||
import time
|
||||
import itertools
|
||||
|
||||
# ── helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
RESET = "\x1b[0m"
|
||||
|
||||
# Standard 3/4-bit foreground
|
||||
FG = {
|
||||
"black": "\x1b[30m", "red": "\x1b[31m", "green": "\x1b[32m",
|
||||
"yellow": "\x1b[33m", "blue": "\x1b[34m", "magenta":"\x1b[35m",
|
||||
"cyan": "\x1b[36m", "white": "\x1b[37m",
|
||||
}
|
||||
FG_BRIGHT = {
|
||||
"black": "\x1b[90m", "red": "\x1b[91m", "green": "\x1b[92m",
|
||||
"yellow": "\x1b[93m", "blue": "\x1b[94m", "magenta":"\x1b[95m",
|
||||
"cyan": "\x1b[96m", "white": "\x1b[97m",
|
||||
}
|
||||
|
||||
# Standard 3/4-bit background
|
||||
BG = {
|
||||
"black": "\x1b[40m", "red": "\x1b[41m", "green": "\x1b[42m",
|
||||
"yellow": "\x1b[43m", "blue": "\x1b[44m", "magenta":"\x1b[45m",
|
||||
"cyan": "\x1b[46m", "white": "\x1b[47m",
|
||||
}
|
||||
BG_BRIGHT = {
|
||||
"black": "\x1b[100m", "red": "\x1b[101m", "green": "\x1b[102m",
|
||||
"yellow": "\x1b[103m", "blue": "\x1b[104m", "magenta":"\x1b[105m",
|
||||
"cyan": "\x1b[106m", "white": "\x1b[107m",
|
||||
}
|
||||
|
||||
BOLD = "\x1b[1m"
|
||||
FAINT = "\x1b[2m"
|
||||
ITALIC = "\x1b[3m"
|
||||
UNDERLINE = "\x1b[4m"
|
||||
STRIKE = "\x1b[9m"
|
||||
|
||||
|
||||
def fg256(n: int) -> str:
|
||||
return f"\x1b[38;5;{n}m"
|
||||
|
||||
def bg256(n: int) -> str:
|
||||
return f"\x1b[48;5;{n}m"
|
||||
|
||||
def fg_rgb(r: int, g: int, b: int) -> str:
|
||||
return f"\x1b[38;2;{r};{g};{b}m"
|
||||
|
||||
def bg_rgb(r: int, g: int, b: int) -> str:
|
||||
return f"\x1b[48;2;{r};{g};{b}m"
|
||||
|
||||
|
||||
# ── test scenes ──────────────────────────────────────────────────────────────
|
||||
|
||||
def scene_16color(delay: float):
|
||||
"""Show all 8 standard + 8 bright foreground colors."""
|
||||
lines = []
|
||||
lines.append(f"{BOLD}─── Standard 16 Foreground Colors ───{RESET}")
|
||||
for name in FG:
|
||||
lines.append(f" {FG[name]}■ {name:>8}{RESET} "
|
||||
f"{FG_BRIGHT[name]}■ bright {name}{RESET}")
|
||||
for line in lines:
|
||||
yield line, delay
|
||||
|
||||
|
||||
def scene_bg_colors(delay: float):
|
||||
"""Show foreground colors on colored backgrounds."""
|
||||
lines = []
|
||||
lines.append(f"{BOLD}─── Background Colors ───{RESET}")
|
||||
for bg_name in BG:
|
||||
line = ""
|
||||
for fg_name in ["white", "black"]:
|
||||
line += f"{BG[bg_name]}{FG[fg_name]} {fg_name} on {bg_name} {RESET} "
|
||||
lines.append(line)
|
||||
for line in lines:
|
||||
yield line, delay
|
||||
|
||||
|
||||
def scene_styles(delay: float):
|
||||
"""Show bold, italic, underline, strikethrough."""
|
||||
lines = []
|
||||
lines.append(f"{BOLD}─── Text Styles ───{RESET}")
|
||||
lines.append(f" {BOLD}Bold text{RESET}")
|
||||
lines.append(f" {ITALIC}Italic text{RESET}")
|
||||
lines.append(f" {UNDERLINE}Underlined text{RESET}")
|
||||
lines.append(f" {STRIKE}Strikethrough text{RESET}")
|
||||
lines.append(f" {BOLD}{ITALIC}Bold + Italic{RESET}")
|
||||
lines.append(f" {BOLD}{FG['red']}Bold Red{RESET} vs {FG['red']}Normal Red{RESET}")
|
||||
for line in lines:
|
||||
yield line, delay
|
||||
|
||||
|
||||
def scene_256_ramp(delay: float):
|
||||
"""Show a 256-color ramp."""
|
||||
lines = []
|
||||
lines.append(f"{BOLD}─── 256-Color Ramp ───{RESET}")
|
||||
# 16 basic colors
|
||||
line = "Basic: "
|
||||
for n in range(16):
|
||||
line += f"{fg256(n)}██{RESET}"
|
||||
lines.append(line)
|
||||
for row in range(3):
|
||||
line = f"Cube {row+1}: "
|
||||
for col in range(6):
|
||||
n = 16 + row * 36 + col * 6
|
||||
line += f"{fg256(n)}██{RESET}"
|
||||
lines.append(line)
|
||||
line = "Gray: "
|
||||
for n in range(232, 256):
|
||||
line += f"{fg256(n)}█{RESET}"
|
||||
lines.append(line)
|
||||
for line in lines:
|
||||
yield line, delay
|
||||
|
||||
|
||||
def scene_truecolor(delay: float):
|
||||
"""Show 24-bit truecolor gradient."""
|
||||
lines = []
|
||||
lines.append(f"{BOLD}─── Truecolor (24-bit) ───{RESET}")
|
||||
line = "R→G: "
|
||||
for i in range(32):
|
||||
r = 255 - i * 8
|
||||
g = i * 8
|
||||
line += f"{fg_rgb(r, g, 0)}█{RESET}"
|
||||
lines.append(line)
|
||||
line = "B→Y: "
|
||||
for i in range(32):
|
||||
b = 255 - i * 8
|
||||
r = g = i * 8
|
||||
line += f"{fg_rgb(r, g, b)}█{RESET}"
|
||||
lines.append(line)
|
||||
line = "Rainbow: "
|
||||
for i in range(48):
|
||||
hue = i / 48.0 * 6.0
|
||||
r, g, b = _hsv_to_rgb(hue, 1.0, 1.0)
|
||||
line += f"{fg_rgb(r, g, b)}━{RESET}"
|
||||
lines.append(line)
|
||||
for line in lines:
|
||||
yield line, delay
|
||||
|
||||
|
||||
def scene_status_log(delay: float):
|
||||
"""Simulate systemd-style status output."""
|
||||
lines = [
|
||||
f"{FG_BRIGHT['green']}[ OK ]{RESET} Started {BOLD}System Logging Service{RESET}.",
|
||||
f"{FG_BRIGHT['green']}[ OK ]{RESET} Started {BOLD}Network Manager{RESET}.",
|
||||
f"{FG_BRIGHT['green']}[ OK ]{RESET} Reached target {ITALIC}multi-user.target{RESET}.",
|
||||
f"{FG_BRIGHT['yellow']}[ WARN ]{RESET} Failed to load kernel module {UNDERLINE}nvidia{RESET}.",
|
||||
f"{FG_BRIGHT['red']}[ FAIL ]{RESET} {FG['red']}{BOLD}ssh.service{RESET}{FG['red']} failed to start.{RESET}",
|
||||
f"{FG_BRIGHT['cyan']}[ INFO ]{RESET} Listening on {fg256(33)}0.0.0.0:8080{RESET}.",
|
||||
f" {ITALIC}─ subject=CN=example.com{RESET}",
|
||||
f" {ITALIC}─ fingerprint={FG['yellow']}SHA256:abcd1234{RESET}",
|
||||
f"{FG_BRIGHT['green']}[ OK ]{RESET} Mounted {BG['blue']}{FG['white']} /var {RESET} filesystem.",
|
||||
f"{FG_BRIGHT['magenta']}[STATUS]{RESET} CPU: {fg256(46)}32%{RESET} "
|
||||
f"Mem: {fg256(220)}1.2G{RESET}/{fg256(33)}4.0G{RESET} "
|
||||
f"Temp: {_temp_color(58)}58°C{RESET}",
|
||||
]
|
||||
for line in lines:
|
||||
yield line, delay
|
||||
|
||||
|
||||
def scene_colored_log_stream(delay: float):
|
||||
"""Continuously generate log lines cycling through themes."""
|
||||
themes = [
|
||||
('green', 'INFO ', "Connection accepted from 192.168.1.100"),
|
||||
('cyan', 'DEBUG', "Processing frame #{}"),
|
||||
('yellow', 'WARN ', "Buffer usage at {:.0f}%"),
|
||||
('red', 'ERROR', "CRC mismatch on packet {}"),
|
||||
('white', 'TRACE', "Entering function handle_request()"),
|
||||
('magenta','AUDIT', "User admin performed action {}"),
|
||||
]
|
||||
counter = itertools.count(1)
|
||||
while True:
|
||||
color_name, level, template = random.choice(themes)
|
||||
n = next(counter)
|
||||
msg = template.format(n, random.uniform(60, 95), n)
|
||||
ts = time.strftime("%H:%M:%S")
|
||||
line = (f"{ITALIC}{ts}{RESET} "
|
||||
f"{FG_BRIGHT[color_name]}{BOLD}[{level}]{RESET} "
|
||||
f"{msg}")
|
||||
yield line, delay
|
||||
|
||||
|
||||
def scene_rainbow_wave(delay: float):
|
||||
"""Animated rainbow wave (for live testing)."""
|
||||
t0 = time.time()
|
||||
while True:
|
||||
t = time.time() - t0
|
||||
line = ""
|
||||
for x in range(60):
|
||||
hue = (x / 60.0 + t * 0.3) % 1.0
|
||||
r, g, b = _hsv_to_rgb(hue * 6.0, 1.0, 0.8 + 0.2 * math.sin(t * 2 + x * 0.3))
|
||||
line += f"{fg_rgb(r, g, b)}━{RESET}"
|
||||
yield line, delay
|
||||
|
||||
|
||||
def scene_system_boot(delay: float):
|
||||
"""Simulate a system boot sequence with progress."""
|
||||
services = [
|
||||
("udev", "Kernel Device Manager", 0.6),
|
||||
("systemd-journald","Journal Service", 0.8),
|
||||
("NetworkManager", "Network Manager", 0.7),
|
||||
("sshd", "OpenSSH Daemon", 0.5),
|
||||
("nginx", "HTTP Server", 0.9),
|
||||
("postgresql", "PostgreSQL 16", 1.2),
|
||||
("docker", "Docker Engine", 1.5),
|
||||
("pipeview", "Pipe Data Monitor", 0.3),
|
||||
]
|
||||
t0 = time.time()
|
||||
for name, desc, startup_time in services:
|
||||
yield (f"{FG_BRIGHT['cyan']}[ .... ]{RESET} Starting {BOLD}{name}{RESET} - {desc}...",
|
||||
startup_time * 0.3)
|
||||
yield (f"{FG_BRIGHT['green']}[ OK ]{RESET} Started {BOLD}{name}{RESET} - {desc}.",
|
||||
delay)
|
||||
yield (f"\n{FG_BRIGHT['green']}{BOLD}Boot complete.{RESET} "
|
||||
f"({time.time() - t0:.1f}s)", delay)
|
||||
|
||||
|
||||
# ── helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
def _hsv_to_rgb(h: float, s: float, v: float) -> tuple[int, int, int]:
|
||||
"""HSV → RGB, h in [0, 6), s,v in [0, 1]."""
|
||||
c = v * s
|
||||
x = c * (1 - abs((h % 2) - 1))
|
||||
m = v - c
|
||||
r, g, b = {
|
||||
0: (c, x, 0), 1: (x, c, 0), 2: (0, c, x),
|
||||
3: (0, x, c), 4: (x, 0, c), 5: (c, 0, x),
|
||||
}[int(h) % 6]
|
||||
return int((r + m) * 255), int((g + m) * 255), int((b + m) * 255)
|
||||
|
||||
|
||||
def _temp_color(temp: float) -> str:
|
||||
if temp < 50: return fg256(46)
|
||||
elif temp < 70: return fg256(220)
|
||||
else: return fg256(196)
|
||||
|
||||
|
||||
# ── scenes registry ──────────────────────────────────────────────────────────
|
||||
|
||||
STATIC_SCENES = [
|
||||
("16color", scene_16color, "Standard 16 foreground colors"),
|
||||
("bg", scene_bg_colors, "Background colors"),
|
||||
("styles", scene_styles, "Bold, italic, underline, strikethrough"),
|
||||
("256ramp", scene_256_ramp, "256-color ramp"),
|
||||
("truecolor", scene_truecolor, "24-bit truecolor gradients"),
|
||||
("status", scene_status_log, "systemd-style status log"),
|
||||
("boot", scene_system_boot, "System boot sequence"),
|
||||
]
|
||||
|
||||
LIVE_SCENES = [
|
||||
("logstream", scene_colored_log_stream, "Continuous colored log stream"),
|
||||
("rainbow", scene_rainbow_wave, "Animated rainbow wave"),
|
||||
]
|
||||
|
||||
|
||||
# ── main ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="ANSI color test data generator for pipeview",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog="Scenes:\n" + "\n".join(
|
||||
f" {n:<14} {d}" for n, _, d in STATIC_SCENES + LIVE_SCENES
|
||||
),
|
||||
)
|
||||
parser.add_argument("--host", default="127.0.0.1", help="TCP bind host")
|
||||
parser.add_argument("--port", type=int, default=8099, help="TCP port")
|
||||
parser.add_argument("--scene", choices=[n for n, _, _ in STATIC_SCENES + LIVE_SCENES] + ["all"],
|
||||
default="all", help="Scene to play")
|
||||
parser.add_argument("--delay", type=float, default=0.15, help="Inter-line delay (seconds)")
|
||||
parser.add_argument("--loop", action="store_true", help="Repeat the scene forever")
|
||||
args = parser.parse_args()
|
||||
|
||||
scenes = STATIC_SCENES + LIVE_SCENES
|
||||
if args.scene != "all":
|
||||
scenes = [(n, f, d) for n, f, d in scenes if n == args.scene]
|
||||
|
||||
print(f"ANSI color test → {args.host}:{args.port}")
|
||||
print(f"Scene: {args.scene}, delay: {args.delay}s, loop: {args.loop}")
|
||||
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as server:
|
||||
server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
server.bind((args.host, args.port))
|
||||
server.listen(1)
|
||||
print(f"Listening on {args.host}:{args.port}, waiting for connections...")
|
||||
|
||||
while True:
|
||||
sock, addr = server.accept()
|
||||
print(f"Client connected from {addr}")
|
||||
try:
|
||||
_play_scenes(sock, scenes, args.delay, args.loop)
|
||||
except (BrokenPipeError, ConnectionResetError):
|
||||
pass
|
||||
print("Client disconnected. Waiting for new connection...")
|
||||
|
||||
|
||||
def _play_scenes(sock: socket.socket, scenes, delay: float, loop_scenes: bool):
|
||||
first = True
|
||||
while first or loop_scenes:
|
||||
first = False
|
||||
for name, scene_fn, _desc in scenes:
|
||||
print(f" [{name}]")
|
||||
for line, line_delay in scene_fn(delay):
|
||||
sock.sendall((line + "\n").encode())
|
||||
time.sleep(line_delay)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,5 +1,5 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Generate fake drone telemetry data for testing xserial Lua decoder."""
|
||||
"""Generate fake drone telemetry data for testing pipeview Lua decoder."""
|
||||
|
||||
import argparse
|
||||
import math
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
#!/usr/bin/env python3
|
||||
"""TCP server that sends plot waveform frames or text lines for xserial GUI."""
|
||||
"""TCP server that sends plot waveform frames or text lines for pipeview GUI."""
|
||||
|
||||
import argparse
|
||||
import math
|
||||
@@ -116,7 +116,7 @@ def is_disconnect_error(err: OSError) -> bool:
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description="xserial plot test server")
|
||||
parser = argparse.ArgumentParser(description="pipeview plot test server")
|
||||
parser.add_argument("--host", default="127.0.0.1")
|
||||
parser.add_argument("--port", type=int, default=8080)
|
||||
parser.add_argument("--channels", type=int, default=1)
|
||||
|
||||
@@ -429,7 +429,7 @@ int main(int argc, char **argv) {
|
||||
double frame_interval = (double)args.samples_per_channel / args.rate;
|
||||
double frame_rate = args.rate / (double)args.samples_per_channel;
|
||||
|
||||
printf("═══ xserial Serial Plot Generator ═══\n");
|
||||
printf("═══ pipeview Serial Plot Generator ═══\n");
|
||||
printf("串口: %s @ %d baud\n", args.port, args.baudrate);
|
||||
printf("通道: %u 格式: %s\n", args.channels, format_name);
|
||||
printf("频率: %.1f Hz 振幅: %.1f\n", args.freq, args.amp);
|
||||
|
||||
@@ -208,7 +208,7 @@ int main(int argc, char **argv) {
|
||||
|
||||
double interval = 1.0 / args.rate;
|
||||
|
||||
printf("═══ xserial Plain Text Generator ═══\n");
|
||||
printf("═══ pipeview Plain Text Generator ═══\n");
|
||||
printf("串口: %s @ %d baud\n", args.port, args.baudrate);
|
||||
printf("通道: %u 振幅: %.1f 频率: %.1f Hz\n", args.channels, args.amp, args.freq);
|
||||
printf("速率: %.0f lines/sec (间隔 %.3f ms)\n", args.rate, interval * 1000.0);
|
||||
|
||||
Reference in New Issue
Block a user