Compare commits

..

4 Commits

Author SHA1 Message Date
3de2c055d5 Add English README and keep Chinese as README_zh
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-06-12 21:31:45 +08:00
b378f14f0a Write detailed README with drone telemetry example and Lua guide
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-06-12 21:29:29 +08:00
227a459712 Add drone telemetry Lua decoder examples and test data generator
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-06-12 21:28:04 +08:00
367800a426 update README.md
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-06-12 21:15:48 +08:00
80 changed files with 3446 additions and 6874 deletions

7
.gitignore vendored
View File

@@ -6,10 +6,3 @@ tools/test_text_c
# Python
__pycache__/
/AGENTS.md
/.agents
/.codex
/.omo
/.opencode
/.trellis

134
AGENTS.md Normal file
View File

@@ -0,0 +1,134 @@
# 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
View File

@@ -189,16 +189,6 @@ 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"
@@ -3272,76 +3262,6 @@ 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"
@@ -4732,16 +4652,6 @@ 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"
@@ -5840,6 +5750,74 @@ 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"

View File

@@ -1,10 +1,10 @@
[workspace]
resolver = "2"
members = [
"crates/pipeview-core",
"crates/pipeview-client",
"crates/pipeview-tui",
"crates/pipeview-gui",
"crates/xserial-core",
"crates/xserial-client",
"crates/xserial-tui",
"crates/xserial-gui",
]
[workspace.dependencies]

392
README.md
View File

@@ -1,187 +1,179 @@
# pipeview
# xserial
**跨平台串口 / TCP / UDP 数据观测工具** — 可配置分帧、协议解码、多会话管理、实时波形绘图,单二进制零依赖。
**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.
基于 Rust + [egui](https://github.com/emilk/egui) 构建,支持 Lua 脚本扩展。
Built with Rust + [egui](https://github.com/emilk/egui), scriptable with Lua.
[English](README_en.md)
[中文文档](README_zh.md)
---
## 目录
## Table of Contents
- [功能特性](#功能特性)
- [快速开始](#快速开始)
- [完整示例:无人机遥测解析](#完整示例无人机遥测解析)
- [Lua 脚本开发指南](#lua-脚本开发指南)
- [架构](#架构)
- [配置与持久化](#配置与持久化)
- [开发工具](#开发工具)
- [技术栈](#技术栈)
- [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
| 类型 | 说明 | 配置项 |
|------|------|--------|
| **Serial** | 串口通信 | 端口名、波特率 (30012M)、数据位 (5/6/7/8)、校验位 (None/Odd/Even)、停止位 (1/2)、流控 (None/Software/Hardware)DTR/RTS 控制 |
| **TCP** | TCP 客户端 | 目标地址 `host:port` |
| **UDP** | UDP 通信 | 绑定地址 `host:port`,可选远端地址 |
| Type | Description | Options |
|------|-------------|---------|
| **Serial** | Serial port communication | Port name, baud rate (30012M), 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 |
### 分帧器(Framer
### Framers
将原始字节流切分为独立帧。每个 Session 可配置**多条独立管线**,同一字节流并行送入多个 framer→decoder 链路。
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.
| 分帧器 | 说明 | 配置参数 |
|--------|------|----------|
| **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 脚本路径) |
| 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` |
### 解码器(Decoder
### Decoders
将帧数据解析为可展示的内容。
Decoders parse framed data into displayable content.
| 解码器 | 输出类型 | 配置参数 |
|--------|----------|----------|
| **Text** | 文本 | `encoding`UTF-8 / Latin1 / ASCII |
| **Hex** | 十六进制 | `uppercase``separator``bytes_per_group``endian` |
| **Plot** | 波形数据 | `sample_type` (i8f64)`endian``channels` (164)`format` (Interleaved / Block / XY) |
| **MixedTextPlot** | 混合文本 + 波形 | `encoding` |
| **Lua** | 自定义 | `script_path`Lua 脚本路径) |
| Decoder | Output | Parameters |
|---------|--------|------------|
| **Text** | Text | `encoding` (UTF-8 / Latin1 / ASCII) |
| **Hex** | Hexadecimal | `uppercase`, `separator`, `bytes_per_group`, `endian` |
| **Plot** | Waveform | `sample_type` (i8f64), `endian`, `channels` (164), `format` (Interleaved / Block / XY) |
| **MixedTextPlot** | Hybrid text + waveform | `encoding` |
| **Lua** | Custom | `script_path` |
**Plot 采样格式:**
**Plot sample formats:**
| 格式 | 说明 | 字节排列 |
|------|------|----------|
| **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, …]` |
| 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, …]` |
支持的采样类型:`i8``u8``i16``u16``i32``u32``i64``u64``f32``f64`
Supported sample types: `i8`, `u8`, `i16`, `u16`, `i32`, `u32`, `i64`, `u64`, `f32`, `f64`
### 视图
### Views
- **文本视图** — 带时间戳、方向标记 (`[IN]`/`[OUT]`)、管线标签的格式化文本流。支持搜索(`Ctrl+F`)、大小写匹配、匹配计数和高亮。
- **十六进制视图** — hex dump 与 ASCII 侧栏并排展示,可按分组和大小端解析多字节数值。
- **波形视图** — 基于 [egui_plot](https://github.com/emilk/egui/tree/master/crates/egui_plot) 的实时波形。支持自动缩放、框选缩放、坐标轴锁定、跟随最新数据、浮动窗口、通道图例。
- **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
- **多会话并发** — 标签页切换,每个 Session 独立配置
- **连接控制** — Connect / Disconnect / Reconnect,支持断线自动重连
- **运行时重配置** — 修改分帧器/解码器参数无需断开连接
- **数据发送** — Text 模式UTF-8可选换行符和 Hex 模式(十六进制字节)。支持 `None` / `LF` / `CR` / `CRLF` 四种行尾
- **环形缓冲** — 默认 10,000 条历史记录,可配置 1001,000,000
- **日志到文件** — 每行数据实时写入1KB 缓冲 + 后台线程,不阻塞 UI
- **搜索** — `Ctrl+F` 呼出搜索栏,大小写敏感切换,F3/Shift+F3 前后跳转
- **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 1001,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 脚本扩展
### Lua Scripting
内置 LuaJIT 运行时:
Built-in LuaJIT runtime:
- **自定义分帧器** — 实现 `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)`
- **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)`
### 快捷键
### Keyboard Shortcuts
| 快捷键 | 操作 |
|--------|------|
| `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` | 关闭浮层 |
| 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
| 平台 | 依赖 |
|------|------|
| **Linux** | `libudev-dev``apt install libudev-dev` |
| **macOS** | 无需额外依赖 |
| **Windows** | 无需额外依赖 |
| **所有平台** | Rust ≥ 1.85、C 编译器(GCC / Clang / MSVC |
| 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
git clone https://github.com/your-org/xserial.git
cd xserial
# 编译运行
cargo run -p pipeview-gui
cargo run -p xserial-gui
# 运行测试
cargo test --workspace # ~308 个测试
# Run tests
cargo test --workspace # ~308 tests
cargo clippy --workspace --all-targets -- -D warnings
```
### 发布构建
### Release Build
```bash
cargo build -p pipeview-gui --release
# 二进制位于 target/release/pipeview-gui (Linux/macOS)
# target/release/pipeview-gui.exe (Windows)
cargo build -p xserial-gui --release
# Binary: target/release/xserial-gui (Linux/macOS)
# target/release/xserial-gui.exe (Windows)
```
---
## 完整示例:无人机遥测解析
## Walkthrough: Drone Telemetry
Betaflight/INAV 飞控遥测为例,数据格式为:
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
```
### 步骤 1启动测试数据源
### Step 1: Start the data source
```bash
python tools/test_drone.py --rate 10 --port 8091
```
输出:
### Step 2: Configure the session
```
Listening on 127.0.0.1:8091, waiting for connections...
```
In xserial-gui, create a session with two pipelines:
### 步骤 2配置 Session
在 pipeview-gui 中创建 Session
| 配置项 | 值 |
|--------|-----|
| 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` |
| Pipeline 1 | Framer: `Line` → Decoder: `Lua``examples/drone_text.lua` |
| Pipeline 2 | Framer: `Line` → Decoder: `Lua``examples/drone_plot.lua` |
### 步骤 3查看结果
### Step 3: View the results
- **文本视图** — 显示格式化的传感器数据
- **波形视图** — 显示 Gyro 三轴实时曲线 (gz/gy/gx)
- **Text View** — formatted sensor readouts
- **Plot View** — real-time gyroscope curves (gz/gy/gx)
`examples/drone_plot.lua` 的核心逻辑:
The decoder in `examples/drone_plot.lua`:
```lua
return {
@@ -199,63 +191,59 @@ return {
---
## Lua 脚本开发指南
## Lua Scripting Guide
### 分帧器 API
### Framer API
分帧器将原始字节流切分为帧Lua 脚本必须返回包含以下函数的 table
A Lua framer splits raw bytes into frames. The script must return a table with these functions:
```lua
return {
-- 输入新到达的字节返回帧数组Lua strings
feed = function(bytes)
-- bytes: Lua string(原始字节)
-- 返回: { frame1, frame2, ... } nil
-- bytes: Lua string (raw bytes)
-- Returns: { frame1, frame2, ... } or nil
end,
-- 刷新缓冲区中残留的数据
flush = function()
-- 返回: 最后一帧Lua string)或 nil
-- Returns: last buffered frame (string) or nil
end,
-- 重置内部状态
reset = function()
end,
-- 返回缓冲区中待处理的字节数
pending_len = function()
-- 返回: number
-- Returns: number of bytes pending in buffer
end,
}
```
参考实现:`tests/lua_line_framer.lua`(按 `\n` 分割的行分帧器)
Reference: `tests/lua_line_framer.lua` (line-based framer splitting on `\n`).
### 解码器 API
### Decoder API
解码器将帧解析为结构化数据Lua 脚本必须返回包含 `decode` 函数的 table
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(来自分帧器的一帧)
-- 返回 nil → 跳过此帧
-- 返回 string → 自动视为 Text
-- 返回 table → 必须包含 kind 字段
-- 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` | 必需字段 | 可选字段 | 用途 |
|--------|----------|----------|------|
| `"text"` | `data: string` | — | 文本视图 |
| `"hex"` | `data: string` | — | 十六进制视图 |
| `"binary"` | `data: string` | — | 原始二进制 |
| `"plot"` | `channels: {{number,…},…}` | `sample_type``format` | 波形视图 |
| `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 返回值示例:**
**Plot return value example:**
```lua
return {
@@ -264,40 +252,40 @@ return {
{ 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
sample_type = "F64", -- default F64; also I8/U8/I16/U32/U64/F32
format = "Block", -- default Interleaved; also Block/XY
}
```
更多示例见 `examples/` 目录。
More examples in `examples/`.
---
## 架构
## Architecture
```
┌──────────────────────────────────────────────────┐
pipeview-gui (egui) pipeview-tui (ratatui) │
xserial-gui (egui) xserial-tui (ratatui) │
├──────────────────────────────────────────────────┤
pipeview-client │
xserial-client │
│ SessionManager · Session · Config · History │
│ Lua Runtime (mlua / LuaJIT) │
├──────────────────────────────────────────────────┤
pipeview-core │
xserial-core │
│ Transport ──▶ Frame ──▶ Protocol ──▶ Pipeline │
└──────────────────────────────────────────────────┘
```
| 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仍在开发中 |
| 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) |
**依赖方向:** `core ← client ← {gui, tui}`
**Dependency flow:** `core ← client ← {gui, tui}`
**数据流:**
**Data flow:**
```
[Transport] → read bytes → [MultiPipeline]
@@ -306,88 +294,84 @@ return {
→ ...
```
每条管线独立分帧、解码,互不干扰。只有成功解码的管线产生输出。
Each pipeline frames and decodes independently. Only successful decodes produce output.
---
## 配置与持久化
## Configuration & Persistence
GUI 状态自动保存,路径遵循各平台规范:
GUI state is saved automatically to platform-standard locations:
| 平台 | 路径 |
|------|------|
| 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` |
| 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` |
持久化的内容包括Session 配置(传输参数、管线设置)、日志开关及路径、活动标签页、显示选项。
Persisted data includes: session configurations (transport, pipelines), log settings, active tab, and display options.
日志文件默认保存在配置目录的 `logs/` 子目录下,文件命名格式为 `session_{id}_{timestamp}.log`
Log files are written to `logs/` under the config directory, named `session_{id}_{timestamp}.log`.
---
## 开发工具
## Development Tools
### 测试数据生成器
### Test Data Generators
```bash
# Plot 波形测试数据
# 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
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 点数统计。
### 日志
### Tracing
```bash
RUST_LOG=info cargo run -p pipeview-gui # 应用日志
RUST_LOG=pipeview_gui=debug cargo run -p pipeview-gui # 详细日志
RUST_LOG=info cargo run -p xserial-gui
RUST_LOG=xserial_gui=debug cargo run -p xserial-gui
```
使用 `tracing-subscriber` + `RUST_LOG` 环境变量控制。
### 项目结构
### Project Structure
```
crates/
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 协议参考头文件
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
```
---
## 技术栈
## Tech Stack
- **运行时**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、零条件编译
- **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

View File

@@ -1,378 +0,0 @@
# 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 (30012M), 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` (i8f64), `endian`, `channels` (164), `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 1001,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 Normal file
View File

@@ -0,0 +1,394 @@
# xserial
**跨平台串口 / TCP / UDP 数据观测工具** — 可配置分帧、协议解码、多会话管理、实时波形绘图,单二进制零依赖。
基于 Rust + [egui](https://github.com/emilk/egui) 构建,支持 Lua 脚本扩展。
[English](README.md)
---
## 目录
- [功能特性](#功能特性)
- [快速开始](#快速开始)
- [完整示例:无人机遥测解析](#完整示例无人机遥测解析)
- [Lua 脚本开发指南](#lua-脚本开发指南)
- [架构](#架构)
- [配置与持久化](#配置与持久化)
- [开发工具](#开发工具)
- [技术栈](#技术栈)
- [License](#license)
---
## 功能特性
### 传输层
| 类型 | 说明 | 配置项 |
|------|------|--------|
| **Serial** | 串口通信 | 端口名、波特率 (30012M)、数据位 (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` (i8f64)、`endian``channels` (164)、`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 条历史记录,可配置 1001,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

View File

@@ -1,451 +0,0 @@
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));
}
}

View File

@@ -1,66 +0,0 @@
#![cfg_attr(windows, windows_subsystem = "windows")]
mod ansi_render;
mod app;
mod app_state;
mod buffers;
mod logging;
mod models;
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())))),
);
}

View File

@@ -1,134 +0,0 @@
use crate::buffers::{HexBuffer, PlotBuffer, TextBuffer};
use crate::logging::LogWriter;
use crate::panels::plot_view;
use pipeview_client::config::SessionConfig;
#[derive(Clone)]
pub enum ConnectionStatus {
Connected,
Disconnected,
Connecting,
Error(String),
}
impl ConnectionStatus {
pub fn badge(&self) -> (&'static str, &'static str) {
match self {
Self::Connected => ("[connected]", "Connected"),
Self::Disconnected => ("[disconnected]", "Disconnected"),
Self::Connecting => ("[connecting]", "Connecting"),
Self::Error(_) => ("[error]", "Error"),
}
}
}
#[derive(Clone, Copy, PartialEq)]
pub enum View {
Text,
Hex,
Plot,
}
#[derive(Clone, Copy, PartialEq)]
pub enum SendMode {
Text,
Hex,
}
#[derive(Clone, Copy, PartialEq)]
#[allow(clippy::upper_case_acronyms)]
pub enum LineEnding {
None,
LF,
CR,
CRLF,
}
impl std::fmt::Display for LineEnding {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::None => write!(f, "None"),
Self::LF => write!(f, "LF (\\n)"),
Self::CR => write!(f, "CR (\\r)"),
Self::CRLF => write!(f, "CRLF (\\r\\n)"),
}
}
}
#[derive(Clone, Copy)]
pub struct DisplayOptions {
pub show_timestamp: bool,
pub show_direction: bool,
pub show_pipeline: bool,
}
#[derive(Clone, Default)]
pub struct SearchState {
pub query: String,
pub matches: Vec<usize>,
pub current_match: usize,
pub case_sensitive: bool,
pub active: bool,
pub just_opened: bool,
}
impl SearchState {
pub fn clear(&mut self) {
self.query.clear();
self.matches.clear();
self.current_match = 0;
self.active = false;
self.just_opened = false;
}
pub fn next(&mut self) {
if !self.matches.is_empty() {
self.current_match = (self.current_match + 1) % self.matches.len();
}
}
pub fn prev(&mut self) {
if !self.matches.is_empty() {
self.current_match = if self.current_match == 0 {
self.matches.len() - 1
} else {
self.current_match - 1
};
}
}
pub fn match_count(&self) -> usize {
self.matches.len()
}
pub fn current_display(&self) -> usize {
if self.matches.is_empty() {
0
} else {
self.current_match + 1
}
}
}
pub struct SessionTab {
pub id: u64,
pub session_config: SessionConfig,
pub status: ConnectionStatus,
pub console: TextBuffer,
pub hex: HexBuffer,
pub plot: PlotBuffer,
pub plot_view: plot_view::PlotViewState,
pub view: View,
pub auto_reconnect: bool,
pub dtr: bool,
pub rts: bool,
pub send_input: String,
pub send_mode: SendMode,
pub line_ending: LineEnding,
pub send_status: Option<String>,
pub search: SearchState,
pub log_enabled: bool,
pub log_path: String,
pub show_sent: bool,
pub log_writer: Option<LogWriter>,
}

View File

@@ -1,133 +0,0 @@
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::{
Color32, Label, ScrollArea, TextStyle, TextWrapMode, Ui,
text::{LayoutJob, TextFormat},
};
/// Nudge amount (pixels/frame) for auto-scroll during text-selection drag
const EDGE_SCROLL_NUDGE: f32 = 4.0;
/// Distance from the bottom edge (pixels) that triggers auto-scroll
const EDGE_SCROLL_ZONE: f32 = 30.0;
pub fn render(
ui: &mut Ui,
buf: &TextBuffer,
display: DisplayOptions,
search: Option<&SearchState>,
) -> usize {
let line_count = buf.len();
let row_height = ui.text_style_height(&TextStyle::Monospace);
let mut near_bottom_edge = false;
let scroll_area = ScrollArea::both()
.id_salt("console_text_area")
.stick_to_bottom(true);
let output = scroll_area.show_viewport(ui, |ui, viewport| {
let start_row = (viewport.min.y / row_height).floor().max(0.0) as usize;
let end_row = ((viewport.max.y / row_height).ceil() as usize).min(line_count);
// Check if user is drag-selecting near the bottom edge of the scroll area.
// We compare pointer position against the screen-space bottom of the
// allocated scroll area (ui.max_rect) rather than the content viewport,
// so that any overflow content area counts.
near_bottom_edge = ui.ctx().input(|input| {
input.pointer.button_down(egui::PointerButton::Primary)
&& input.pointer.hover_pos().is_some_and(|pos| {
let area = ui.max_rect();
pos.y > area.bottom() - EDGE_SCROLL_ZONE
&& pos.y < area.bottom() + 20.0
})
});
for row in start_row..end_row {
if let Some(line) = buf.get(row) {
ui.add(
Label::new(format_console_line(line, display, search, ui.style()))
.wrap_mode(TextWrapMode::Extend),
);
}
}
});
// If the user is dragging a selection near the bottom edge, nudge the scroll
// offset for the next frame and request a repaint for smooth continuous scrolling.
if near_bottom_edge && line_count > 0 {
let mut state = output.state;
let max_offset = (line_count as f32 * row_height)
- output.inner_rect.height()
+ EDGE_SCROLL_ZONE;
state.offset.y = (state.offset.y + EDGE_SCROLL_NUDGE).min(max_offset.max(0.0));
state.store(ui.ctx(), output.id);
ui.ctx().request_repaint();
}
line_count
}
fn monospace_format(style: &egui::Style) -> TextFormat {
TextFormat {
font_id: style
.text_styles
.get(&TextStyle::Monospace)
.cloned()
.unwrap_or_default(),
color: Color32::WHITE,
..Default::default()
}
}
pub fn format_console_line(
line: &ConsoleLine,
display: DisplayOptions,
search: Option<&SearchState>,
style: &egui::Style,
) -> LayoutJob {
let mut prefix = Vec::new();
if display.show_timestamp {
let ts = line.elapsed.as_secs_f64();
let time = if ts < 60.0 {
format!("{:05.2}", ts)
} else {
format!("{:02}:{:02}", (ts / 60.0) as u64, (ts % 60.0) as u64)
};
prefix.push(format!("[{time}]"));
}
if display.show_direction {
let dir = match line.direction {
LineDirection::In => "IN",
LineDirection::Out => "OUT",
};
prefix.push(format!("[{dir}]"));
}
if display.show_pipeline {
prefix.push(format!("[{}]", line.pipeline));
}
let prefix = if prefix.is_empty() {
String::new()
} else {
format!("{} ", prefix.join(" "))
};
let full_text = format!("{prefix}{}", line.text);
let default = monospace_format(style);
let highlight = TextFormat {
font_id: default.font_id.clone(),
color: Color32::BLACK,
background: Color32::from_rgb(255, 255, 0),
..Default::default()
};
match search {
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),
}
}

View File

@@ -1,184 +0,0 @@
use crate::app::XserialApp;
use crate::ui_fonts::{self, FontCandidate, FontChoice};
pub fn render_font_settings_window(app: &mut XserialApp, ctx: &egui::Context) {
if !app.font_settings_open {
return;
}
let mut open = app.font_settings_open;
let mut changed = false;
egui::Window::new("UI Settings")
.open(&mut open)
.default_width(520.0)
.resizable(true)
.show(ctx, |ui| {
ui.heading("Fonts");
ui.horizontal(|ui| {
ui.label("Primary:");
ui.monospace(ui_fonts::font_choice_label(
&app.font_settings.primary_choice,
&app.font_candidates,
));
ui.label("Fallback:");
ui.monospace(ui_fonts::font_choice_label(
&app.font_settings.fallback_choice,
&app.font_candidates,
));
if ui.button("Refresh").clicked() {
app.font_candidates = ui_fonts::discover_font_candidates();
app.invalidate_font_filters();
}
});
ui.small("Primary font is tried first. Fallback font is used when the primary font lacks a glyph.");
ui.add_space(6.0);
render_font_selector(
ui,
"Primary font",
"primary_font_choice",
&mut app.font_settings.primary_choice,
&mut app.primary_font_search,
&app.font_candidates,
&mut app.primary_filtered_fonts,
&mut app.primary_filter_cache_key,
true,
true,
180.0,
&mut changed,
);
ui.separator();
render_font_selector(
ui,
"Fallback font",
"fallback_font_choice",
&mut app.font_settings.fallback_choice,
&mut app.fallback_font_search,
&app.font_candidates,
&mut app.fallback_filtered_fonts,
&mut app.fallback_filter_cache_key,
true,
true,
140.0,
&mut changed,
);
ui.separator();
ui.heading("Sizes");
ui.label("UI font size");
changed |= ui
.add(
egui::Slider::new(&mut app.font_settings.ui_font_size, 10.0..=28.0)
.suffix(" pt"),
)
.changed();
ui.label("Monospace font size");
changed |= ui
.add(
egui::Slider::new(
&mut app.font_settings.monospace_font_size,
10.0..=28.0,
)
.suffix(" pt"),
)
.changed();
ui.label("Heading size");
changed |= ui
.add(
egui::Slider::new(&mut app.font_settings.heading_font_size, 14.0..=40.0)
.suffix(" pt"),
)
.changed();
ui.separator();
ui.heading("Preview");
ui.label("The quick brown fox jumps over the lazy dog.");
ui.label("中文预览:串口、网络、绘图、十六进制、会话管理。");
ui.monospace("Monospace preview: 0123456789 ABCDEF deadbeef");
});
if changed {
ui_fonts::apply_font_settings(ctx, &app.font_settings, &app.font_candidates);
ui_fonts::save_font_settings(&app.font_settings);
}
app.font_settings_open = open;
}
#[allow(clippy::too_many_arguments)]
pub fn render_font_selector(
ui: &mut egui::Ui,
title: &str,
id_prefix: &str,
choice: &mut FontChoice,
search: &mut String,
candidates: &[FontCandidate],
filtered: &mut Vec<usize>,
cache_key: &mut String,
allow_auto: bool,
allow_default: bool,
max_height: f32,
changed: &mut bool,
) {
ui.label(title);
ui.horizontal(|ui| {
ui.label("Search:");
ui.text_edit_singleline(search);
});
ui.horizontal_wrapped(|ui| {
if allow_auto {
*changed |= ui
.selectable_value(choice, FontChoice::Auto, "Auto")
.changed();
}
if allow_default {
*changed |= ui
.selectable_value(choice, FontChoice::Default, "Default")
.changed();
}
});
ui.add_space(4.0);
refresh_font_filter(search, candidates, filtered, cache_key);
ui.small(format!("{} fonts", filtered.len()));
let row_height = ui.spacing().interact_size.y;
egui::ScrollArea::vertical()
.id_salt(format!("{id_prefix}_scroll"))
.max_height(max_height)
.auto_shrink([false, false])
.show_rows(ui, row_height, filtered.len(), |ui, row_range| {
for row in row_range {
if let Some(candidate) = filtered.get(row).and_then(|index| candidates.get(*index))
{
let response = ui.selectable_value(
choice,
FontChoice::System(candidate.id.clone()),
candidate.display_label.as_str(),
);
*changed |= response.changed();
response.on_hover_text(&candidate.path);
}
}
});
}
pub fn refresh_font_filter(
search: &str,
candidates: &[FontCandidate],
filtered: &mut Vec<usize>,
cache_key: &mut String,
) {
let needle = search.trim().to_lowercase();
if *cache_key == needle {
return;
}
filtered.clear();
if needle.is_empty() {
filtered.extend(0..candidates.len());
} else {
filtered.extend(
candidates
.iter()
.enumerate()
.filter(|(_, candidate)| candidate.search_key.contains(&needle))
.map(|(index, _)| index),
);
}
*cache_key = needle;
}

View File

@@ -1,207 +0,0 @@
use std::path::PathBuf;
use crate::app_state;
use crate::logging::{self, LogWriter};
use crate::models::{LineEnding, SendMode, SessionTab};
use egui::{Color32, Label, TextEdit};
use pipeview_client::SessionManager;
pub fn render_send_panel(ui: &mut egui::Ui, manager: &SessionManager, tab: &mut SessionTab) {
ui.set_width(ui.available_width());
ui.add(Label::new(egui::RichText::new("Send").heading()).selectable(false));
ui.horizontal(|ui| {
ui.selectable_value(&mut tab.send_mode, SendMode::Text, "Text");
ui.selectable_value(&mut tab.send_mode, SendMode::Hex, "Hex");
if tab.send_mode == SendMode::Text {
ui.add_space(6.0);
egui::ComboBox::from_label("Line ending")
.selected_text(tab.line_ending.to_string())
.show_ui(ui, |ui| {
ui.selectable_value(
&mut tab.line_ending,
LineEnding::None,
LineEnding::None.to_string(),
);
ui.selectable_value(
&mut tab.line_ending,
LineEnding::LF,
LineEnding::LF.to_string(),
);
ui.selectable_value(
&mut tab.line_ending,
LineEnding::CR,
LineEnding::CR.to_string(),
);
ui.selectable_value(
&mut tab.line_ending,
LineEnding::CRLF,
LineEnding::CRLF.to_string(),
);
});
}
});
ui.add_space(6.0);
let log_toggled = ui
.horizontal(|ui| {
let toggled = ui
.checkbox(&mut tab.log_enabled, "Log to file")
.changed();
ui.checkbox(&mut tab.show_sent, "Show sent data");
toggled
})
.inner;
if log_toggled {
if tab.log_enabled {
tab.log_path = default_log_path(tab.id)
.to_string_lossy()
.to_string();
tab.log_writer = LogWriter::open(&tab.log_path).ok();
} else {
tab.log_writer = None;
}
}
if tab.log_enabled {
ui.horizontal(|ui| {
let changed = ui
.text_edit_singleline(&mut tab.log_path)
.lost_focus();
if changed && !tab.log_path.is_empty() {
tab.log_writer = LogWriter::open(&tab.log_path).ok();
}
});
}
ui.add_space(3.0);
let hint = match tab.send_mode {
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.is_none());
let mut send_clicked = false;
ui.horizontal(|ui| {
send_clicked = ui.button("Send").clicked();
if let Some(status) = &tab.send_status {
ui.add(
Label::new(
egui::RichText::new(status).color(if status.starts_with("Send failed") {
Color32::RED
} else {
Color32::GRAY
}),
)
.selectable(false),
);
}
});
if !(send_clicked || wants_submit) {
return;
}
match build_payload(tab) {
Ok(Some(payload)) => {
if let Some(handle) = manager.get(tab.id) {
match tab.send_mode {
SendMode::Text => {
let mut text = tab.send_input.trim_end_matches('\n').to_string();
if !text.is_empty() {
match tab.line_ending {
LineEnding::None => {}
LineEnding::LF => text.push('\n'),
LineEnding::CR => text.push('\r'),
LineEnding::CRLF => text.push_str("\r\n"),
}
if tab.show_sent {
tab.console.push_outbound(text.clone());
}
if let Some(ref writer) = tab.log_writer {
writer.write_line(&logging::format_sent_log(&text));
}
}
}
SendMode::Hex => {
let hex = tab
.send_input
.split_whitespace()
.collect::<Vec<_>>()
.join(" ");
if !hex.is_empty() {
if tab.show_sent {
tab.hex.push_outbound(hex.clone());
}
if let Some(ref writer) = tab.log_writer {
writer.write_line(&logging::format_sent_log(&hex));
}
}
}
}
tokio::spawn(async move {
let _ = handle.send(payload).await;
});
tab.send_input.clear();
tab.send_status = Some(String::from("Sent"));
} else {
tab.send_status = Some(String::from("Send failed: session not found"));
}
}
Ok(None) => {
tab.send_status = Some(String::from("Nothing to send"));
}
Err(message) => {
tab.send_status = Some(format!("Send failed: {message}"));
}
}
}
pub fn default_log_path(session_id: u64) -> PathBuf {
use std::time::{SystemTime, UNIX_EPOCH};
let dir = app_state::config_dir().join("logs");
let ts = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
dir.join(format!("session_{session_id}_{ts}.log"))
}
pub fn build_payload(tab: &SessionTab) -> Result<Option<Vec<u8>>, String> {
let trimmed = tab.send_input.trim();
if trimmed.is_empty() {
return Ok(None);
}
match tab.send_mode {
SendMode::Text => {
let mut text = tab.send_input.trim_end_matches('\n').to_string();
match tab.line_ending {
LineEnding::None => {}
LineEnding::LF => text.push('\n'),
LineEnding::CR => text.push('\r'),
LineEnding::CRLF => text.push_str("\r\n"),
}
Ok(Some(text.into_bytes()))
}
SendMode::Hex => {
let compact: String = trimmed
.chars()
.filter(|ch| !ch.is_ascii_whitespace())
.collect();
hex::decode(compact)
.map(Some)
.map_err(|err| format!("invalid hex input ({err})"))
}
}
}

View File

@@ -1,150 +0,0 @@
use crate::models::{ConnectionStatus, SessionTab, View};
use egui::{Label, TextEdit};
use pipeview_client::SessionManager;
use pipeview_core::transport::TransportConfig;
pub fn render_search_bar(ui: &mut egui::Ui, tab: &mut SessionTab) {
if !tab.search.active {
return;
}
ui.horizontal(|ui| {
let response = ui.add(
TextEdit::singleline(&mut tab.search.query)
.hint_text("Search...")
.desired_width(200.0),
);
if tab.search.just_opened {
response.request_focus();
tab.search.just_opened = false;
}
if response.changed() {
let matches = match tab.view {
View::Text => tab.console.search(&tab.search.query, tab.search.case_sensitive),
View::Hex => tab.hex.search(&tab.search.query, tab.search.case_sensitive),
View::Plot => Vec::new(),
};
tab.search.matches = matches;
tab.search.current_match = 0;
}
if response.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) {
tab.search.next();
}
ui.add(
Label::new(format!(
"{}/{}",
tab.search.current_display(),
tab.search.match_count()
))
.selectable(false),
);
if ui.button("").clicked() {
tab.search.prev();
}
if ui.button("").clicked() {
tab.search.next();
}
if ui.checkbox(&mut tab.search.case_sensitive, "Aa").changed() {
let matches = match tab.view {
View::Text => tab.console.search(&tab.search.query, tab.search.case_sensitive),
View::Hex => tab.hex.search(&tab.search.query, tab.search.case_sensitive),
View::Plot => Vec::new(),
};
tab.search.matches = matches;
tab.search.current_match = 0;
}
if ui.button("").clicked() {
tab.search.clear();
}
});
}
pub fn render_session_controls(
ui: &mut egui::Ui,
manager: &SessionManager,
tab: &mut SessionTab,
) -> bool {
let mut auto_reconnect_changed = false;
ui.horizontal_wrapped(|ui| {
let connected = matches!(
tab.status,
ConnectionStatus::Connected | ConnectionStatus::Connecting
);
let connect_label = if connected { "Disconnect" } else { "Connect" };
if ui.button(connect_label).clicked() {
if let Some(handle) = manager.get(tab.id) {
if connected {
tab.status = ConnectionStatus::Disconnected;
tokio::spawn(async move {
let _ = handle.disconnect().await;
});
} else {
tab.status = ConnectionStatus::Connecting;
tokio::spawn(async move {
let _ = handle.connect().await;
});
}
} else {
tab.status = ConnectionStatus::Error(String::from("session not found"));
}
}
// if ui.button("Reconnect").clicked() {
// if let Some(handle) = manager.get(tab.id) {
// tab.status = ConnectionStatus::Connecting;
// tokio::spawn(async move {
// let _ = handle.reconnect().await;
// });
// } else {
// tab.status = ConnectionStatus::Error(String::from("session not found"));
// }
// }
if ui.button("Clear").clicked() {
tab.console.clear();
tab.hex.clear();
tab.plot.clear();
tab.send_status = Some(String::from("Cleared"));
}
let response = ui.checkbox(&mut tab.auto_reconnect, "Auto reconnect");
if response.changed() {
tab.session_config.auto_reconnect = tab.auto_reconnect;
auto_reconnect_changed = true;
if let Some(handle) = manager.get(tab.id) {
let enabled = tab.auto_reconnect;
tokio::spawn(async move {
let _ = handle.set_auto_reconnect(enabled).await;
});
} else {
tab.status = ConnectionStatus::Error(String::from("session not found"));
}
}
if matches!(tab.status, ConnectionStatus::Connected)
&& matches!(tab.session_config.transport, TransportConfig::Serial { .. })
{
let dtr_changed = ui.checkbox(&mut tab.dtr, "DTR").changed();
let rts_changed = ui.checkbox(&mut tab.rts, "RTS").changed();
if dtr_changed || rts_changed {
if let Some(handle) = manager.get(tab.id) {
let dtr = tab.dtr;
let rts = tab.rts;
tokio::spawn(async move {
if dtr_changed {
let _ = handle.set_dtr(dtr).await;
}
if rts_changed {
let _ = handle.set_rts(rts).await;
}
});
} else {
tab.status = ConnectionStatus::Error(String::from("session not found"));
}
}
}
});
auto_reconnect_changed
}

View File

@@ -1,295 +0,0 @@
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)));
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,637 +0,0 @@
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]"))
}

View File

@@ -1,65 +0,0 @@
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)
}

View File

@@ -1,989 +0,0 @@
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(", ")
)
}
}

View File

@@ -1,10 +1,10 @@
[package]
name = "pipeview-client"
name = "xserial-client"
version = "0.1.0"
edition = "2024"
[dependencies]
pipeview-core = { path = "../pipeview-core" }
xserial-core = { path = "../xserial-core" }
tokio = { workspace = true, features = ["sync", "time"] }
tracing = { workspace = true }
serde = { workspace = true }

View File

@@ -1,4 +1,5 @@
use pipeview_core::frame::{
use serde::{Deserialize, Serialize};
use xserial_core::frame::{
Endian, Framer,
cobs::CobsFramer,
fixed::FixedLengthFramer,
@@ -6,15 +7,14 @@ use pipeview_core::frame::{
line::{LineConfig, LineFramer},
mixed::{MixedTextPlotConfig as MixedFramerConfig, MixedTextPlotFramer},
};
use pipeview_core::protocol::{
use xserial_core::protocol::{
ProtocolDecoder,
hex::{HexConfig, HexDecoder},
mixed::{MixedTextPlotConfig as MixedDecoderConfig, MixedTextPlotDecoder},
plot::{PlotConfig, PlotDecoder, PlotFormat, SampleType},
text::{TextDecoder, TextEncoding},
};
use pipeview_core::transport::TransportConfig;
use serde::{Deserialize, Serialize};
use xserial_core::transport::TransportConfig;
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 pipeview_core::protocol::Endian;
use pipeview_core::protocol::plot::{PlotFormat, SampleType};
use pipeview_core::protocol::text::TextEncoding;
use pipeview_core::transport::TransportConfig;
use xserial_core::protocol::Endian;
use xserial_core::protocol::plot::{PlotFormat, SampleType};
use xserial_core::protocol::text::TextEncoding;
use xserial_core::transport::TransportConfig;
#[test]
fn framer_line_serde_roundtrip() {

View File

@@ -1,4 +1,4 @@
use pipeview_core::protocol::DecodedData;
use xserial_core::protocol::DecodedData;
#[derive(Debug, Clone)]
pub struct DecodedEntry {

View File

@@ -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!("pipeview_{name}_{nonce}.lua"));
let path = std::env::temp_dir().join(format!("xserial_{name}_{nonce}.lua"));
fs::write(&path, script).unwrap();
path
}

View File

@@ -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 pipeview = lua.create_table()?;
let xserial = lua.create_table()?;
pipeview.set("open", {
xserial.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<()
})?
})?;
pipeview.set(
xserial.set(
"list_ports",
lua.create_function(|_, ()| {
let ports = pipeview_core::transport::serial::SerialTransport::list_ports();
let ports = xserial_core::transport::serial::SerialTransport::list_ports();
Ok(ports.into_iter().map(|p| p.port_name).collect::<Vec<_>>())
})?,
)?;
pipeview.set("sleep", {
xserial.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<()
})?
})?;
pipeview.set("poll", {
xserial.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<()
})?
})?;
pipeview.set(
xserial.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("pipeview", pipeview)?;
lua.globals().set("xserial", xserial)?;
Ok(())
}
@@ -146,9 +146,9 @@ mod tests {
let lua = Lua::new();
register(&lua).unwrap();
let pipeview: Table = lua.globals().get("pipeview").unwrap();
let xserial: Table = lua.globals().get("xserial").unwrap();
for name in ["open", "list_ports", "sleep", "poll", "log"] {
let value: Value = pipeview.get(name).unwrap();
let value: Value = xserial.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 pipeview: Table = lua.globals().get("pipeview").unwrap();
let open: Value = pipeview.get("open").unwrap();
let xserial: Table = lua.globals().get("xserial").unwrap();
let open: Value = xserial.get("open").unwrap();
assert!(matches!(open, Value::Function(_)));
assert_eq!(manager.count(), 0);
}

View File

@@ -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;

View File

@@ -11,8 +11,8 @@ use tokio::sync::{Mutex, broadcast, mpsc};
use tokio::task::JoinHandle;
use tracing::{debug, error, info, warn};
use pipeview_core::pipeline::{MultiPipeline, Pipeline};
use pipeview_core::transport::Connection;
use xserial_core::pipeline::{MultiPipeline, Pipeline};
use xserial_core::transport::Connection;
use crate::cmd::SessionCmd;
use crate::config::SessionConfig;
@@ -499,7 +499,7 @@ async fn try_read(
}
}
fn to_io_error(err: pipeview_core::error::Error) -> std::io::Error {
fn to_io_error(err: xserial_core::error::Error) -> std::io::Error {
io::Error::other(err.to_string())
}
@@ -510,7 +510,7 @@ mod tests {
fn tcp_config() -> SessionConfig {
SessionConfig {
transport: pipeview_core::transport::TransportConfig::Tcp {
transport: xserial_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: pipeview_core::protocol::text::TextEncoding::Utf8,
encoding: xserial_core::protocol::text::TextEncoding::Utf8,
},
}],
..Default::default()

View File

@@ -11,7 +11,7 @@ static INIT: Once = Once::new();
fn init_tracing() {
INIT.call_once(|| {
tracing_subscriber::fmt()
.with_env_filter("pipeview=trace")
.with_env_filter("xserial=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!("pipeview_{name}_{nonce}.lua"));
let path = std::env::temp_dir().join(format!("xserial_{name}_{nonce}.lua"));
fs::write(&path, script).unwrap();
path
}
@@ -47,18 +47,18 @@ fn hex_pipeline_lua() -> &'static str {
"#
}
// ── pipeview.sleep / pipeview.log ──────────────────────────────────
// ── xserial.sleep / xserial.log ──────────────────────────────────
#[tokio::test]
async fn lua_sleep_and_log() {
let lua = Lua::new();
pipeview_client::lua::register(&lua).unwrap();
xserial_client::lua::register(&lua).unwrap();
lua.load(
r#"
pipeview.log("test start")
pipeview.sleep(50)
pipeview.log("test end")
xserial.log("test start")
xserial.sleep(50)
xserial.log("test end")
"#,
)
.exec_async()
@@ -66,15 +66,15 @@ async fn lua_sleep_and_log() {
.unwrap();
}
// ── pipeview.list_ports ───────────────────────────────────────────
// ── xserial.list_ports ───────────────────────────────────────────
#[tokio::test]
async fn lua_list_ports() {
let lua = Lua::new();
pipeview_client::lua::register(&lua).unwrap();
xserial_client::lua::register(&lua).unwrap();
let result: Vec<String> = lua
.load(r#"return pipeview.list_ports()"#)
.load(r#"return xserial.list_ports()"#)
.eval_async()
.await
.unwrap();
@@ -84,7 +84,7 @@ async fn lua_list_ports() {
let _ = result;
}
// ── pipeview.open + session:send / session:read / session:close ────
// ── xserial.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();
pipeview_client::lua::register(&lua).unwrap();
xserial_client::lua::register(&lua).unwrap();
let script = format!(
r#"
local sess = pipeview.open({{
local sess = xserial.open({{
transport = {{ Tcp = {{ addr = "{}" }} }},
pipelines = {{{}}}
}})
@@ -141,11 +141,11 @@ async fn lua_session_read_timeout() {
});
let lua = Lua::new();
pipeview_client::lua::register(&lua).unwrap();
xserial_client::lua::register(&lua).unwrap();
let script = format!(
r#"
local sess = pipeview.open({{
local sess = xserial.open({{
transport = {{ Tcp = {{ addr = "{}" }} }},
pipelines = {{{}}}
}})
@@ -162,7 +162,7 @@ async fn lua_session_read_timeout() {
lua.load(&script).exec_async().await.unwrap();
}
// ── pipeview.open with hex decoder ────────────────────────────────
// ── xserial.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();
pipeview_client::lua::register(&lua).unwrap();
xserial_client::lua::register(&lua).unwrap();
let script = format!(
r#"
local sess = pipeview.open({{
local sess = xserial.open({{
transport = {{ Tcp = {{ addr = "{}" }} }},
pipelines = {{{}}}
}})
@@ -201,7 +201,7 @@ async fn lua_session_hex_decoder() {
server.await.unwrap();
}
// ── pipeview.open with Lua framer + Lua decoder ───────────────────
// ── xserial.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();
pipeview_client::lua::register(&lua).unwrap();
xserial_client::lua::register(&lua).unwrap();
let script = format!(
r#"
local sess = pipeview.open({{
local sess = xserial.open({{
transport = {{ Tcp = {{ addr = "{}" }} }},
pipelines = {{
{{
@@ -311,12 +311,12 @@ async fn lua_session_on_data_callback() {
});
let lua = Lua::new();
pipeview_client::lua::register(&lua).unwrap();
xserial_client::lua::register(&lua).unwrap();
let script = format!(
r#"
local received = {{}}
local sess = pipeview.open({{
local sess = xserial.open({{
transport = {{ Tcp = {{ addr = "{}" }} }},
pipelines = {{{}}}
}})
@@ -327,8 +327,8 @@ async fn lua_session_on_data_callback() {
for _ = 1, 50 do
if #received < 2 then
pipeview.poll(1)
pipeview.sleep(10)
xserial.poll(1)
xserial.sleep(10)
end
end
@@ -371,11 +371,11 @@ async fn lua_session_reconfigure() {
});
let lua = Lua::new();
pipeview_client::lua::register(&lua).unwrap();
xserial_client::lua::register(&lua).unwrap();
let script = format!(
r#"
local sess = pipeview.open({{
local sess = xserial.open({{
transport = {{ Tcp = {{ addr = "{}" }} }},
pipelines = {{{}}}
}})
@@ -420,11 +420,11 @@ async fn lua_session_next_event_stream() {
});
let lua = Lua::new();
pipeview_client::lua::register(&lua).unwrap();
xserial_client::lua::register(&lua).unwrap();
let script = format!(
r#"
local sess = pipeview.open({{
local sess = xserial.open({{
transport = {{ Tcp = {{ addr = "{}" }} }},
pipelines = {{{}}}
}})
@@ -450,7 +450,7 @@ async fn lua_session_next_event_stream() {
server.await.unwrap();
}
// ── session:off + pipeview.poll ───────────────────────────────────
// ── session:off + xserial.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();
pipeview_client::lua::register(&lua).unwrap();
xserial_client::lua::register(&lua).unwrap();
let script = format!(
r#"
local received = {{}}
local sess = pipeview.open({{
local sess = xserial.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
pipeview.poll(1)
pipeview.sleep(10)
xserial.poll(1)
xserial.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)
pipeview.sleep(250)
pipeview.poll(10)
xserial.sleep(250)
xserial.poll(10)
assert(#received == 1, "callback should not fire after off")
assert(sess:off(token) == false)

View File

@@ -3,11 +3,11 @@ use std::time::Duration;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpListener;
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;
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;
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: pipeview_core::protocol::text::TextEncoding::Utf8,
encoding: xserial_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: pipeview_core::protocol::Endian::Big,
endian: xserial_core::protocol::Endian::Big,
},
});
@@ -285,7 +285,7 @@ async fn session_reconfigure_changes_pipeline() {
uppercase: true,
separator: " ".into(),
bytes_per_group: 1,
endian: pipeview_core::protocol::Endian::Big,
endian: xserial_core::protocol::Endian::Big,
};
handle.reconfigure(new_cfg).await.unwrap();

View File

@@ -1,5 +1,5 @@
[package]
name = "pipeview-core"
name = "xserial-core"
version = "0.1.0"
edition = "2024"

View File

@@ -61,11 +61,11 @@ pub struct PipelineResult {
/// Manages multiple [`Pipeline`]s, feeding the same byte stream to all of them.
///
/// ```
/// 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};
/// 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};
///
/// let mut mp = MultiPipeline::new();
/// mp.add(

View File

@@ -173,18 +173,22 @@ 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(
"DTR only supported on Serial connections".into(),
)),
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(
"RTS only supported on Serial connections".into(),
)),
Connection::Tcp(_) | Connection::Udp(_) => {
Err(crate::error::Error::ConnectionFailed(
"RTS only supported on Serial connections".into(),
))
}
}
}
}

View File

@@ -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,16 +277,10 @@ 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);
@@ -337,8 +331,8 @@ mod tests {
SerialParity::None,
SerialStopBits::One,
SerialFlowControl::None,
true,
true,
true,
true,
);
assert_eq!(transport.port_name(), "COM1");
assert_eq!(transport.baud_rate(), 115200);
@@ -353,8 +347,8 @@ mod tests {
SerialParity::Even,
SerialStopBits::Two,
SerialFlowControl::Hardware,
true,
true,
true,
true,
);
assert_eq!(transport.port_name(), "COM3");
assert_eq!(transport.baud_rate(), 9600);
@@ -373,8 +367,8 @@ mod tests {
SerialParity::None,
SerialStopBits::One,
SerialFlowControl::None,
true,
true,
true,
true,
);
assert_eq!(transport.name(), "COM1");
}
@@ -388,8 +382,8 @@ mod tests {
SerialParity::None,
SerialStopBits::One,
SerialFlowControl::None,
true,
true,
true,
true,
);
assert_eq!(transport.transport_type(), TransportType::Serial);
}
@@ -403,8 +397,8 @@ mod tests {
SerialParity::None,
SerialStopBits::One,
SerialFlowControl::None,
true,
true,
true,
true,
);
assert!(!transport.is_connected());
}
@@ -433,8 +427,8 @@ mod tests {
SerialParity::None,
SerialStopBits::One,
SerialFlowControl::None,
true,
true,
true,
true,
);
let pinned = Pin::new(&mut transport);
let mut buf_data = [0u8; 16];
@@ -458,8 +452,8 @@ mod tests {
SerialParity::None,
SerialStopBits::One,
SerialFlowControl::None,
true,
true,
true,
true,
);
let pinned = Pin::new(&mut transport);
let data = b"hello";
@@ -480,8 +474,8 @@ mod tests {
SerialParity::None,
SerialStopBits::One,
SerialFlowControl::None,
true,
true,
true,
true,
);
let pinned = Pin::new(&mut transport);
let waker = noop_waker();
@@ -501,8 +495,8 @@ mod tests {
SerialParity::None,
SerialStopBits::One,
SerialFlowControl::None,
true,
true,
true,
true,
);
let pinned = Pin::new(&mut transport);
let waker = noop_waker();
@@ -524,8 +518,8 @@ mod tests {
SerialParity::None,
SerialStopBits::One,
SerialFlowControl::None,
true,
true,
true,
true,
);
assert_eq!(transport.name(), "COM1");
}
@@ -539,8 +533,8 @@ mod tests {
SerialParity::None,
SerialStopBits::One,
SerialFlowControl::None,
true,
true,
true,
true,
);
assert_eq!(transport.transport_type(), TransportType::Serial);
}
@@ -554,8 +548,8 @@ mod tests {
SerialParity::None,
SerialStopBits::One,
SerialFlowControl::None,
true,
true,
true,
true,
);
assert!(!transport.is_connected());
}

View File

@@ -4,7 +4,7 @@ use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::{TcpListener, UdpSocket};
use tokio::time::timeout;
use pipeview_core::frame::{
use xserial_core::frame::{
Endian, Framer,
cobs::CobsFramer,
cobs::cobs_encode as raw_cobs_encode,
@@ -13,17 +13,17 @@ use pipeview_core::frame::{
line::{LineConfig, LineFramer},
mixed::{MixedTextPlotConfig as MixedFramerConfig, MixedTextPlotFramer},
};
use pipeview_core::protocol::{
use xserial_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 pipeview_core::transport::serial::{
use xserial_core::transport::serial::{
SerialDataBits, SerialFlowControl, SerialParity, SerialStopBits,
};
use pipeview_core::transport::{Connection, TransportConfig, TransportType};
use xserial_core::transport::{Connection, TransportConfig, TransportType};
const TEST_TIMEOUT: Duration = Duration::from_secs(5);
@@ -688,8 +688,8 @@ async fn connection_transport_type_dispatch() {
parity: SerialParity::None,
stop_bits: SerialStopBits::One,
flow_control: SerialFlowControl::None,
dtr: false,
rts: false,
dtr: false,
rts: false,
});
let tcp = Connection::new(TransportConfig::Tcp {
addr: "127.0.0.1:8080".into(),

View File

@@ -1,15 +1,15 @@
[package]
name = "pipeview-gui"
name = "xserial-gui"
version = "0.1.0"
edition = "2024"
[[bin]]
name = "pipeview-gui"
name = "xserial-gui"
path = "src/main.rs"
[dependencies]
pipeview-core = { path = "../pipeview-core" }
pipeview-client = { path = "../pipeview-client" }
xserial-core = { path = "../xserial-core" }
xserial-client = { path = "../xserial-client" }
tokio = { workspace = true }
egui = { workspace = true }
eframe = { workspace = true }
@@ -19,4 +19,3 @@ tracing-subscriber = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
hex = "0.4"
ansitok = "0.3"

View File

@@ -1,45 +1,177 @@
use crate::app_state::{self, PersistedGuiState, SessionLogConfig};
use std::path::PathBuf;
use std::sync::mpsc;
use std::time::{Duration, Instant};
use crate::buffers::{HexBuffer, PlotBuffer, TextBuffer};
use crate::logging::{self, LogWriter};
use crate::panels::{config, console, font_settings, hex_view, plot_view, send_panel, session_controls, sidebar};
use crate::panels::{config, console, hex_view, plot_view, sidebar};
use crate::perf::{DrainStats, GuiProfiler, GuiSnapshot};
use crate::shortcuts::{self, default_bindings};
use crate::ui_fonts::{self, FontCandidate, UiFontSettings};
use egui::{Color32, Label, Layout, Panel, Pos2, Rect, UiBuilder};
use pipeview_client::SessionManager;
use pipeview_client::config::SessionConfig;
use pipeview_client::session::SessionEvent;
use pipeview_core::protocol::DecodedData;
use pipeview_core::transport::TransportConfig;
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;
const DATA_REPAINT_INTERVAL: Duration = Duration::from_millis(33);
pub use crate::models::*;
#[derive(Clone)]
pub enum ConnectionStatus {
Connected,
Disconnected,
Connecting,
Error(String),
}
impl ConnectionStatus {
fn badge(&self) -> (&'static str, &'static str) {
match self {
Self::Connected => ("[connected]", "Connected"),
Self::Disconnected => ("[disconnected]", "Disconnected"),
Self::Connecting => ("[connecting]", "Connecting"),
Self::Error(_) => ("[error]", "Error"),
}
}
}
#[derive(Clone, Copy, PartialEq)]
pub enum View {
Text,
Hex,
Plot,
}
#[derive(Clone, Copy, PartialEq)]
pub enum SendMode {
Text,
Hex,
}
#[derive(Clone, Copy, PartialEq)]
#[allow(clippy::upper_case_acronyms)]
pub enum LineEnding {
None,
LF,
CR,
CRLF,
}
impl std::fmt::Display for LineEnding {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::None => write!(f, "None"),
Self::LF => write!(f, "LF (\\n)"),
Self::CR => write!(f, "CR (\\r)"),
Self::CRLF => write!(f, "CRLF (\\r\\n)"),
}
}
}
#[derive(Clone, Copy)]
pub struct DisplayOptions {
pub show_timestamp: bool,
pub show_direction: bool,
pub show_pipeline: bool,
}
#[derive(Clone, Default)]
pub struct SearchState {
pub query: String,
pub matches: Vec<usize>,
pub current_match: usize,
pub case_sensitive: bool,
pub active: bool,
pub just_opened: bool,
}
impl SearchState {
pub fn clear(&mut self) {
self.query.clear();
self.matches.clear();
self.current_match = 0;
self.active = false;
self.just_opened = false;
}
pub fn next(&mut self) {
if !self.matches.is_empty() {
self.current_match = (self.current_match + 1) % self.matches.len();
}
}
pub fn prev(&mut self) {
if !self.matches.is_empty() {
self.current_match = if self.current_match == 0 {
self.matches.len() - 1
} else {
self.current_match - 1
};
}
}
// pub fn current_line_index(&self) -> Option<usize> {
// self.matches.get(self.current_match).copied()
// }
pub fn match_count(&self) -> usize {
self.matches.len()
}
pub fn current_display(&self) -> usize {
if self.matches.is_empty() {
0
} else {
self.current_match + 1
}
}
}
pub struct SessionTab {
pub id: u64,
pub session_config: SessionConfig,
pub status: ConnectionStatus,
pub console: TextBuffer,
pub hex: HexBuffer,
pub plot: PlotBuffer,
pub plot_view: plot_view::PlotViewState,
pub view: View,
pub auto_reconnect: bool,
pub dtr: bool,
pub rts: bool,
pub send_input: String,
pub send_mode: SendMode,
pub line_ending: LineEnding,
pub send_status: Option<String>,
pub search: SearchState,
pub log_enabled: bool,
pub log_path: String,
pub log_writer: Option<LogWriter>,
}
pub struct XserialApp {
pub(crate) manager: SessionManager,
pub(crate) tabs: Vec<SessionTab>,
pub(crate) active: usize,
pub(crate) display: DisplayOptions,
pub(crate) font_settings_open: bool,
pub(crate) font_candidates: Vec<FontCandidate>,
pub(crate) font_settings: UiFontSettings,
pub(crate) primary_font_search: String,
pub(crate) fallback_font_search: String,
pub(crate) primary_filtered_fonts: Vec<usize>,
pub(crate) fallback_filtered_fonts: Vec<usize>,
pub(crate) primary_filter_cache_key: String,
pub(crate) fallback_filter_cache_key: String,
pub(crate) config_open: bool,
pub(crate) config_target: Option<u64>,
pub(crate) config_form: config::ConfigForm,
pub(crate) event_rx: mpsc::Receiver<SessionEvent>,
pub(crate) pending: Vec<SessionEvent>,
pub(crate) profiler: GuiProfiler,
pub(crate) shortcut_bindings: Vec<(shortcuts::Action, egui::KeyboardShortcut)>,
manager: SessionManager,
tabs: Vec<SessionTab>,
active: usize,
display: DisplayOptions,
font_settings_open: bool,
font_candidates: Vec<FontCandidate>,
font_settings: UiFontSettings,
primary_font_search: String,
fallback_font_search: String,
primary_filtered_fonts: Vec<usize>,
fallback_filtered_fonts: Vec<usize>,
primary_filter_cache_key: String,
fallback_filter_cache_key: String,
config_open: bool,
config_target: Option<u64>,
config_form: config::ConfigForm,
event_rx: mpsc::Receiver<SessionEvent>,
pending: Vec<SessionEvent>,
profiler: GuiProfiler,
shortcut_bindings: Vec<(shortcuts::Action, egui::KeyboardShortcut)>,
}
impl XserialApp {
@@ -280,7 +412,6 @@ impl XserialApp {
search: SearchState::default(),
log_enabled: false,
log_path: String::new(),
show_sent: true,
log_writer: None,
});
}
@@ -537,7 +668,7 @@ impl XserialApp {
fn render_main_panel(&mut self, ui: &mut egui::Ui) {
egui::CentralPanel::default().show_inside(ui, |ui| {
if self.tabs.is_empty() {
ui.add(Label::new(egui::RichText::new("No sessions.").heading()).selectable(false));
ui.heading("No sessions.");
return;
}
@@ -557,10 +688,10 @@ impl XserialApp {
let (badge, status_text) = tab.status.badge();
ui.horizontal(|ui| {
ui.add(Label::new(egui::RichText::new(format!("Session {}", tab.id)).heading()).selectable(false));
ui.add(Label::new(badge).selectable(false));
ui.heading(format!("Session {}", tab.id));
ui.label(badge);
ui.separator();
ui.add(Label::new(status_text).selectable(false));
ui.label(status_text);
ui.separator();
if ui
.selectable_label(tab.view == View::Text, "Text")
@@ -579,12 +710,12 @@ impl XserialApp {
}
});
let auto_reconnect_changed = session_controls::render_session_controls(ui, &manager, tab);
let auto_reconnect_changed = render_session_controls(ui, &manager, tab);
persist_state |= auto_reconnect_changed;
let mut display_changed = false;
ui.horizontal_wrapped(|ui| {
ui.add(Label::new("Show:").selectable(false));
ui.label("Show:");
display_changed |= ui
.checkbox(&mut display.show_timestamp, "Timestamp")
.changed();
@@ -595,10 +726,10 @@ impl XserialApp {
});
persist_state |= display_changed;
session_controls::render_search_bar(ui, tab);
render_search_bar(ui, tab);
if let ConnectionStatus::Error(message) = &tab.status {
ui.add(Label::new(egui::RichText::new(message).color(Color32::RED)).selectable(false));
ui.label(egui::RichText::new(message).color(Color32::RED));
}
let full = ui.available_rect_before_wrap();
@@ -629,22 +760,11 @@ impl XserialApp {
}
View::Plot => {
if tab.plot_view.detached {
ui.add(
Label::new(
egui::RichText::new(format!(
"Plot window detached for Session {}",
tab.id,
))
.heading(),
)
.selectable(false),
);
ui.add(
Label::new(
"The plot is currently shown in a floating window.",
)
.selectable(false),
);
ui.heading(format!(
"Plot window detached for Session {}",
tab.id
));
ui.label("The plot is currently shown in a floating window.");
if ui.button("Dock Plot Back").clicked() {
tab.plot_view.detached = false;
}
@@ -669,7 +789,7 @@ impl XserialApp {
UiBuilder::new()
.max_rect(send_rect)
.layout(Layout::top_down(egui::Align::Min).with_cross_justify(true)),
|ui| send_panel::render_send_panel(ui, &manager, tab),
|ui| render_send_panel(ui, &manager, tab),
);
}
@@ -731,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.add(Label::new(egui::RichText::new("pipeview").heading()).selectable(false));
ui.heading("xserial");
ui.separator();
// ui.label(format!(
// "Fonts: {} + {} {:.1} pt",
@@ -753,10 +873,106 @@ impl XserialApp {
}
fn render_font_settings_window(&mut self, ctx: &egui::Context) {
font_settings::render_font_settings_window(self, ctx);
if !self.font_settings_open {
return;
}
let mut open = self.font_settings_open;
let mut changed = false;
egui::Window::new("UI Settings")
.open(&mut open)
.default_width(520.0)
.resizable(true)
.show(ctx, |ui| {
ui.heading("Fonts");
ui.horizontal(|ui| {
ui.label("Primary:");
ui.monospace(ui_fonts::font_choice_label(
&self.font_settings.primary_choice,
&self.font_candidates,
));
ui.label("Fallback:");
ui.monospace(ui_fonts::font_choice_label(
&self.font_settings.fallback_choice,
&self.font_candidates,
));
if ui.button("Refresh").clicked() {
self.font_candidates = ui_fonts::discover_font_candidates();
self.invalidate_font_filters();
}
});
ui.small("Primary font is tried first. Fallback font is used when the primary font lacks a glyph.");
ui.add_space(6.0);
render_font_selector(
ui,
"Primary font",
"primary_font_choice",
&mut self.font_settings.primary_choice,
&mut self.primary_font_search,
&self.font_candidates,
&mut self.primary_filtered_fonts,
&mut self.primary_filter_cache_key,
true,
true,
180.0,
&mut changed,
);
ui.separator();
render_font_selector(
ui,
"Fallback font",
"fallback_font_choice",
&mut self.font_settings.fallback_choice,
&mut self.fallback_font_search,
&self.font_candidates,
&mut self.fallback_filtered_fonts,
&mut self.fallback_filter_cache_key,
true,
true,
140.0,
&mut changed,
);
ui.separator();
ui.heading("Sizes");
ui.label("UI font size");
changed |= ui
.add(
egui::Slider::new(&mut self.font_settings.ui_font_size, 10.0..=28.0)
.suffix(" pt"),
)
.changed();
ui.label("Monospace font size");
changed |= ui
.add(
egui::Slider::new(
&mut self.font_settings.monospace_font_size,
10.0..=28.0,
)
.suffix(" pt"),
)
.changed();
ui.label("Heading size");
changed |= ui
.add(
egui::Slider::new(&mut self.font_settings.heading_font_size, 14.0..=40.0)
.suffix(" pt"),
)
.changed();
ui.separator();
ui.heading("Preview");
ui.label("The quick brown fox jumps over the lazy dog.");
ui.label("中文预览:串口、网络、绘图、十六进制、会话管理。");
ui.monospace("Monospace preview: 0123456789 ABCDEF deadbeef");
});
if changed {
ui_fonts::apply_font_settings(ctx, &self.font_settings, &self.font_candidates);
ui_fonts::save_font_settings(&self.font_settings);
}
self.font_settings_open = open;
}
pub(crate) fn invalidate_font_filters(&mut self) {
fn invalidate_font_filters(&mut self) {
self.primary_filtered_fonts.clear();
self.fallback_filtered_fonts.clear();
self.primary_filter_cache_key = String::from("\0");
@@ -778,6 +994,88 @@ fn transport_summary(transport: &TransportConfig) -> String {
}
}
#[allow(clippy::too_many_arguments)]
fn render_font_selector(
ui: &mut egui::Ui,
title: &str,
id_prefix: &str,
choice: &mut FontChoice,
search: &mut String,
candidates: &[FontCandidate],
filtered: &mut Vec<usize>,
cache_key: &mut String,
allow_auto: bool,
allow_default: bool,
max_height: f32,
changed: &mut bool,
) {
ui.label(title);
ui.horizontal(|ui| {
ui.label("Search:");
ui.text_edit_singleline(search);
});
ui.horizontal_wrapped(|ui| {
if allow_auto {
*changed |= ui
.selectable_value(choice, FontChoice::Auto, "Auto")
.changed();
}
if allow_default {
*changed |= ui
.selectable_value(choice, FontChoice::Default, "Default")
.changed();
}
});
ui.add_space(4.0);
refresh_font_filter(search, candidates, filtered, cache_key);
ui.small(format!("{} fonts", filtered.len()));
let row_height = ui.spacing().interact_size.y;
egui::ScrollArea::vertical()
.id_salt(format!("{id_prefix}_scroll"))
.max_height(max_height)
.auto_shrink([false, false])
.show_rows(ui, row_height, filtered.len(), |ui, row_range| {
for row in row_range {
if let Some(candidate) = filtered.get(row).and_then(|index| candidates.get(*index))
{
let response = ui.selectable_value(
choice,
FontChoice::System(candidate.id.clone()),
candidate.display_label.as_str(),
);
*changed |= response.changed();
response.on_hover_text(&candidate.path);
}
}
});
}
fn refresh_font_filter(
search: &str,
candidates: &[FontCandidate],
filtered: &mut Vec<usize>,
cache_key: &mut String,
) {
let needle = search.trim().to_lowercase();
if *cache_key == needle {
return;
}
filtered.clear();
if needle.is_empty() {
filtered.extend(0..candidates.len());
} else {
filtered.extend(
candidates
.iter()
.enumerate()
.filter(|(_, candidate)| candidate.search_key.contains(&needle))
.map(|(index, _)| index),
);
}
*cache_key = needle;
}
impl eframe::App for XserialApp {
fn update(&mut self, _ctx: &egui::Context, _frame: &mut eframe::Frame) {}
@@ -807,11 +1105,337 @@ impl eframe::App for XserialApp {
}
}
fn render_search_bar(ui: &mut egui::Ui, tab: &mut SessionTab) {
if !tab.search.active {
return;
}
ui.horizontal(|ui| {
let response = ui.add(
TextEdit::singleline(&mut tab.search.query)
.hint_text("Search...")
.desired_width(200.0),
);
if tab.search.just_opened {
response.request_focus();
tab.search.just_opened = false;
}
if response.changed() {
let matches = match tab.view {
View::Text => tab.console.search(&tab.search.query, tab.search.case_sensitive),
View::Hex => tab.hex.search(&tab.search.query, tab.search.case_sensitive),
View::Plot => Vec::new(),
};
tab.search.matches = matches;
tab.search.current_match = 0;
}
if response.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) {
tab.search.next();
}
ui.label(format!(
"{}/{}",
tab.search.current_display(),
tab.search.match_count()
));
if ui.button("").clicked() {
tab.search.prev();
}
if ui.button("").clicked() {
tab.search.next();
}
if ui.checkbox(&mut tab.search.case_sensitive, "Aa").changed() {
let matches = match tab.view {
View::Text => tab.console.search(&tab.search.query, tab.search.case_sensitive),
View::Hex => tab.hex.search(&tab.search.query, tab.search.case_sensitive),
View::Plot => Vec::new(),
};
tab.search.matches = matches;
tab.search.current_match = 0;
}
if ui.button("").clicked() {
tab.search.clear();
}
});
}
fn render_session_controls(
ui: &mut egui::Ui,
manager: &SessionManager,
tab: &mut SessionTab,
) -> bool {
let mut auto_reconnect_changed = false;
ui.horizontal_wrapped(|ui| {
let connected = matches!(
tab.status,
ConnectionStatus::Connected | ConnectionStatus::Connecting
);
let connect_label = if connected { "Disconnect" } else { "Connect" };
if ui.button(connect_label).clicked() {
if let Some(handle) = manager.get(tab.id) {
if connected {
tab.status = ConnectionStatus::Disconnected;
tokio::spawn(async move {
let _ = handle.disconnect().await;
});
} else {
tab.status = ConnectionStatus::Connecting;
tokio::spawn(async move {
let _ = handle.connect().await;
});
}
} else {
tab.status = ConnectionStatus::Error(String::from("session not found"));
}
}
// if ui.button("Reconnect").clicked() {
// if let Some(handle) = manager.get(tab.id) {
// tab.status = ConnectionStatus::Connecting;
// tokio::spawn(async move {
// let _ = handle.reconnect().await;
// });
// } else {
// tab.status = ConnectionStatus::Error(String::from("session not found"));
// }
// }
if ui.button("Clear").clicked() {
tab.console.clear();
tab.hex.clear();
tab.plot.clear();
tab.send_status = Some(String::from("Cleared"));
}
let response = ui.checkbox(&mut tab.auto_reconnect, "Auto reconnect");
if response.changed() {
tab.session_config.auto_reconnect = tab.auto_reconnect;
auto_reconnect_changed = true;
if let Some(handle) = manager.get(tab.id) {
let enabled = tab.auto_reconnect;
tokio::spawn(async move {
let _ = handle.set_auto_reconnect(enabled).await;
});
} else {
tab.status = ConnectionStatus::Error(String::from("session not found"));
}
}
if matches!(tab.status, ConnectionStatus::Connected)
&& matches!(tab.session_config.transport, TransportConfig::Serial { .. })
{
let dtr_changed = ui.checkbox(&mut tab.dtr, "DTR").changed();
let rts_changed = ui.checkbox(&mut tab.rts, "RTS").changed();
if dtr_changed || rts_changed {
if let Some(handle) = manager.get(tab.id) {
let dtr = tab.dtr;
let rts = tab.rts;
tokio::spawn(async move {
if dtr_changed {
let _ = handle.set_dtr(dtr).await;
}
if rts_changed {
let _ = handle.set_rts(rts).await;
}
});
} else {
tab.status = ConnectionStatus::Error(String::from("session not found"));
}
}
}
});
auto_reconnect_changed
}
fn render_send_panel(ui: &mut egui::Ui, manager: &SessionManager, tab: &mut SessionTab) {
ui.set_width(ui.available_width());
ui.heading("Send");
ui.horizontal(|ui| {
ui.selectable_value(&mut tab.send_mode, SendMode::Text, "Text");
ui.selectable_value(&mut tab.send_mode, SendMode::Hex, "Hex");
if tab.send_mode == SendMode::Text {
ui.add_space(6.0);
egui::ComboBox::from_label("Line ending")
.selected_text(tab.line_ending.to_string())
.show_ui(ui, |ui| {
ui.selectable_value(
&mut tab.line_ending,
LineEnding::None,
LineEnding::None.to_string(),
);
ui.selectable_value(
&mut tab.line_ending,
LineEnding::LF,
LineEnding::LF.to_string(),
);
ui.selectable_value(
&mut tab.line_ending,
LineEnding::CR,
LineEnding::CR.to_string(),
);
ui.selectable_value(
&mut tab.line_ending,
LineEnding::CRLF,
LineEnding::CRLF.to_string(),
);
});
}
});
ui.add_space(6.0);
let log_toggled = ui
.checkbox(&mut tab.log_enabled, "Log to file")
.changed();
if log_toggled {
if tab.log_enabled {
tab.log_path = default_log_path(tab.id)
.to_string_lossy()
.to_string();
tab.log_writer = LogWriter::open(&tab.log_path).ok();
} else {
tab.log_writer = None;
}
}
if tab.log_enabled {
ui.horizontal(|ui| {
let changed = ui
.text_edit_singleline(&mut tab.log_path)
.lost_focus();
if changed && !tab.log_path.is_empty() {
tab.log_writer = LogWriter::open(&tab.log_path).ok();
}
});
}
ui.add_space(3.0);
let hint = match tab.send_mode {
SendMode::Text => "Enter text to send",
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)
.hint_text(hint),
);
let wants_submit = response.has_focus()
&& ui.input(|input| input.key_pressed(egui::Key::Enter) && input.modifiers.command_only());
let mut send_clicked = false;
ui.horizontal(|ui| {
send_clicked = ui.button("Send").clicked();
if let Some(status) = &tab.send_status {
ui.label(
egui::RichText::new(status).color(if status.starts_with("Send failed") {
Color32::RED
} else {
Color32::GRAY
}),
);
}
});
if !(send_clicked || wants_submit) {
return;
}
match build_payload(tab) {
Ok(Some(payload)) => {
if let Some(handle) = manager.get(tab.id) {
match tab.send_mode {
SendMode::Text => {
let mut text = tab.send_input.trim_end_matches('\n').to_string();
if !text.is_empty() {
match tab.line_ending {
LineEnding::None => {}
LineEnding::LF => text.push('\n'),
LineEnding::CR => text.push('\r'),
LineEnding::CRLF => text.push_str("\r\n"),
}
tab.console.push_outbound(text.clone());
if let Some(ref writer) = tab.log_writer {
writer.write_line(&logging::format_sent_log(&text));
}
}
}
SendMode::Hex => {
let hex = tab
.send_input
.split_whitespace()
.collect::<Vec<_>>()
.join(" ");
if !hex.is_empty() {
tab.hex.push_outbound(hex.clone());
if let Some(ref writer) = tab.log_writer {
writer.write_line(&logging::format_sent_log(&hex));
}
}
}
}
tokio::spawn(async move {
let _ = handle.send(payload).await;
});
tab.send_input.clear();
tab.send_status = Some(String::from("Sent"));
} else {
tab.send_status = Some(String::from("Send failed: session not found"));
}
}
Ok(None) => {
tab.send_status = Some(String::from("Nothing to send"));
}
Err(message) => {
tab.send_status = Some(format!("Send failed: {message}"));
}
}
}
fn default_log_path(session_id: u64) -> PathBuf {
use std::time::{SystemTime, UNIX_EPOCH};
let dir = app_state::config_dir().join("logs");
let ts = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
dir.join(format!("session_{session_id}_{ts}.log"))
}
fn build_payload(tab: &SessionTab) -> Result<Option<Vec<u8>>, String> {
let trimmed = tab.send_input.trim();
if trimmed.is_empty() {
return Ok(None);
}
match tab.send_mode {
SendMode::Text => {
let mut text = tab.send_input.trim_end_matches('\n').to_string();
match tab.line_ending {
LineEnding::None => {}
LineEnding::LF => text.push('\n'),
LineEnding::CR => text.push('\r'),
LineEnding::CRLF => text.push_str("\r\n"),
}
Ok(Some(text.into_bytes()))
}
SendMode::Hex => {
let compact: String = trimmed
.chars()
.filter(|ch| !ch.is_ascii_whitespace())
.collect();
hex::decode(compact)
.map(Some)
.map_err(|err| format!("invalid hex input ({err})"))
}
}
}
#[cfg(test)]
mod tests {
use super::transport_summary;
use pipeview_core::transport::TransportConfig;
use pipeview_core::transport::serial::{
use xserial_core::transport::TransportConfig;
use xserial_core::transport::serial::{
SerialDataBits, SerialFlowControl, SerialParity, SerialStopBits,
};

View File

@@ -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 pipeview configuration directory (the parent of gui-state.json).
/// Returns the xserial 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("pipeview")
.join("xserial")
.join(GUI_STATE_FILE_NAME);
}
if let Some(path) = get_env("LOCALAPPDATA").filter(|path| !path.trim().is_empty()) {
return PathBuf::from(path)
.join("pipeview")
.join("xserial")
.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("pipeview")
.join("xserial")
.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("pipeview")
.join("xserial")
.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("pipeview")
.join("xserial")
.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("pipeview")
.join("xserial")
.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!("pipeview-gui-state-{unique}"));
let dir = env::temp_dir().join(format!("xserial-gui-state-{unique}"));
let path = dir.join(GUI_STATE_FILE_NAME);
let state = PersistedGuiState {
sessions: vec![SessionConfig {

View File

@@ -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 pipeview_client::event::DecodedEntry;
use pipeview_core::protocol::DecodedData;
use pipeview_core::protocol::plot::{PlotFrame, SampleType};
use xserial_client::event::DecodedEntry;
use xserial_core::protocol::DecodedData;
use xserial_core::protocol::plot::{PlotFrame, SampleType};
fn plot_entry() -> DecodedEntry {
DecodedEntry {

View File

@@ -6,8 +6,8 @@ use std::thread;
use std::time::{SystemTime, UNIX_EPOCH};
use pipeview_client::event::DecodedEntry;
use pipeview_core::protocol::DecodedData;
use xserial_client::event::DecodedEntry;
use xserial_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!("pipeview-log-{}", path.display());
let thread_name = format!("xserial-log-{}", path.display());
thread::Builder::new().name(thread_name).spawn(move || {
while let Ok(line) = receiver.recv() {
if writeln!(writer, "{line}").is_err() {

View File

@@ -0,0 +1,28 @@
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())))),
);
}

View File

@@ -1,10 +1,10 @@
use egui::{ComboBox, DragValue, ScrollArea, TextEdit, Ui};
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::{
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::{
SerialDataBits, SerialFlowControl, SerialParity, SerialStopBits, SerialTransport,
};

View File

@@ -0,0 +1,129 @@
use crate::app::{DisplayOptions, SearchState};
use crate::buffers::{ConsoleLine, LineDirection, TextBuffer};
use egui::{
Color32, Label, ScrollArea, TextStyle, TextWrapMode, Ui,
text::{LayoutJob, TextFormat},
};
pub fn render(
ui: &mut Ui,
buf: &TextBuffer,
display: DisplayOptions,
search: Option<&SearchState>,
) -> usize {
let line_count = buf.len();
let row_height = ui.text_style_height(&TextStyle::Monospace);
ScrollArea::both().stick_to_bottom(true).show_rows(
ui,
row_height,
line_count,
|ui, row_range| {
for row in row_range {
if let Some(line) = buf.get(row) {
ui.add(
Label::new(format_console_line(line, display, search, ui.style()))
.wrap_mode(TextWrapMode::Extend),
);
}
}
},
);
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
.text_styles
.get(&TextStyle::Monospace)
.cloned()
.unwrap_or_default(),
color: Color32::WHITE,
..Default::default()
}
}
pub fn format_console_line(
line: &ConsoleLine,
display: DisplayOptions,
search: Option<&SearchState>,
style: &egui::Style,
) -> LayoutJob {
let mut prefix = Vec::new();
if display.show_timestamp {
let ts = line.elapsed.as_secs_f64();
let time = if ts < 60.0 {
format!("{:05.2}", ts)
} else {
format!("{:02}:{:02}", (ts / 60.0) as u64, (ts % 60.0) as u64)
};
prefix.push(format!("[{time}]"));
}
if display.show_direction {
let dir = match line.direction {
LineDirection::In => "IN",
LineDirection::Out => "OUT",
};
prefix.push(format!("[{dir}]"));
}
if display.show_pipeline {
prefix.push(format!("[{}]", line.pipeline));
}
let prefix = if prefix.is_empty() {
String::new()
} else {
format!("{} ", prefix.join(" "))
};
let full_text = format!("{prefix}{}", line.text);
let default = monospace_format(style);
let highlight = TextFormat {
font_id: default.font_id.clone(),
color: Color32::BLACK,
background: Color32::from_rgb(255, 255, 0),
..Default::default()
};
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),
}
}

View File

@@ -5,11 +5,6 @@ use egui::{
text::{LayoutJob, TextFormat},
};
/// Nudge amount (pixels/frame) for auto-scroll during text-selection drag
const EDGE_SCROLL_NUDGE: f32 = 4.0;
/// Distance from the bottom edge (pixels) that triggers auto-scroll
const EDGE_SCROLL_ZONE: f32 = 30.0;
pub fn render(
ui: &mut Ui,
buf: &HexBuffer,
@@ -18,53 +13,25 @@ pub fn render(
) -> usize {
let line_count = buf.len();
if line_count == 0 {
ui.add(Label::new("no hex data").selectable(false));
ui.label("no hex data");
return 0;
}
let row_height = ui.text_style_height(&TextStyle::Monospace);
let mut near_bottom_edge = false;
let scroll_area = ScrollArea::both()
.id_salt("hex_text_area")
.stick_to_bottom(true);
let output = scroll_area.show_viewport(ui, |ui, viewport| {
let start_row = (viewport.min.y / row_height).floor().max(0.0) as usize;
let end_row = ((viewport.max.y / row_height).ceil() as usize).min(line_count);
// Check if user is drag-selecting near the bottom edge of the scroll area
near_bottom_edge = ui.ctx().input(|input| {
input.pointer.button_down(egui::PointerButton::Primary)
&& input.pointer.hover_pos().is_some_and(|pos| {
let area = ui.max_rect();
pos.y > area.bottom() - EDGE_SCROLL_ZONE
&& pos.y < area.bottom() + 20.0
})
});
for row in start_row..end_row {
if let Some(line) = buf.get(row) {
ui.add(
Label::new(format_hex_line(line, display, search, ui.style()))
.wrap_mode(TextWrapMode::Extend),
);
ScrollArea::both().stick_to_bottom(true).show_rows(
ui,
row_height,
line_count,
|ui, row_range| {
for row in row_range {
if let Some(line) = buf.get(row) {
ui.add(
Label::new(format_hex_line(line, display, search, ui.style()))
.wrap_mode(TextWrapMode::Extend),
);
}
}
}
});
// If the user is dragging a selection near the bottom edge, nudge the scroll
// offset for the next frame and request a repaint for smooth continuous scrolling.
if near_bottom_edge {
let mut state = output.state;
let max_offset = (line_count as f32 * row_height)
- output.inner_rect.height()
+ EDGE_SCROLL_ZONE;
state.offset.y = (state.offset.y + EDGE_SCROLL_NUDGE).min(max_offset.max(0.0));
state.store(ui.ctx(), output.id);
ui.ctx().request_repaint();
}
},
);
line_count
}
@@ -111,7 +78,7 @@ fn monospace_format(style: &egui::Style) -> TextFormat {
.get(&TextStyle::Monospace)
.cloned()
.unwrap_or_default(),
color: Color32::WHITE,
color: Color32::WHITE,
..Default::default()
}
}
@@ -158,13 +125,10 @@ 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),
}
}

View File

@@ -1,8 +1,5 @@
pub mod config;
pub mod console;
pub mod font_settings;
pub mod hex_view;
pub mod plot_view;
pub mod send_panel;
pub mod session_controls;
pub mod sidebar;

View File

@@ -1,6 +1,6 @@
use crate::buffers::{PlotBuffer, PlotSeriesKind};
use crate::perf::PlotRenderStats;
use egui::{Button, Label, Ui, vec2};
use egui::{Button, Ui, vec2};
use egui_plot::{Legend, Line, Plot, PlotBounds, PlotPoints};
#[derive(Clone, Copy, PartialEq, Eq)]
@@ -46,7 +46,7 @@ pub fn render(
if ui.button(toggle_label).clicked() {
toggle_detached = true;
}
ui.add(Label::new("no plot data").selectable(false));
ui.label("no plot data");
});
return PlotRenderOutput {
stats: PlotRenderStats {

View File

@@ -1,5 +1,5 @@
use crate::app::ConnectionStatus;
use egui::{Color32, Label, RichText, Ui};
use egui::{Color32, RichText, Ui};
pub struct SessionListItem {
pub id: u64,
@@ -15,7 +15,7 @@ pub fn render(
on_edit: &mut Option<usize>,
on_delete: &mut Option<usize>,
) {
ui.add(Label::new(egui::RichText::new("Sessions").heading()).selectable(false));
ui.heading("Sessions");
if ui
.button(RichText::new("+ New Session").color(Color32::GREEN))

View File

@@ -202,7 +202,7 @@ impl GuiProfiler {
let repaints = self.repaint_counter.take();
info!(
target: "pipeview_gui::perf",
target: "xserial_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(),

View File

@@ -56,7 +56,10 @@ 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),
),
]
}

View File

@@ -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("pipeview")
.join("xserial")
.join(FONT_SETTINGS_FILE_NAME);
}
if let Some(path) = get_env("LOCALAPPDATA").filter(|path| !path.trim().is_empty()) {
return PathBuf::from(path)
.join("pipeview")
.join("xserial")
.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("pipeview")
.join("xserial")
.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("pipeview")
.join("xserial")
.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("pipeview")
.join("xserial")
.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!("pipeview-gui-font-settings-{unique}"));
let dir = env::temp_dir().join(format!("xserial-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("pipeview")
.join("xserial")
.join(FONT_SETTINGS_FILE_NAME)
);
}
@@ -636,7 +636,7 @@ mod tests {
assert_eq!(
path,
PathBuf::from(r"C:\Users\Test\AppData\Roaming")
.join("pipeview")
.join("xserial")
.join(FONT_SETTINGS_FILE_NAME)
);
}
@@ -652,7 +652,7 @@ mod tests {
PathBuf::from("/Users/tester")
.join("Library")
.join("Application Support")
.join("pipeview")
.join("xserial")
.join(FONT_SETTINGS_FILE_NAME)
);
}

View File

@@ -1,15 +1,15 @@
[package]
name = "pipeview-tui"
name = "xserial-tui"
version = "0.1.0"
edition = "2024"
[[bin]]
name = "pipeview-tui"
name = "xserial-tui"
path = "src/main.rs"
[dependencies]
pipeview-core = { path = "../pipeview-core" }
pipeview-client = { path = "../pipeview-client" }
xserial-core = { path = "../xserial-core" }
xserial-client = { path = "../xserial-client" }
tokio = { workspace = true }
ratatui = { workspace = true }
crossterm = { workspace = true }
@@ -22,4 +22,3 @@ image = { workspace = true }
mlua = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
ansitok = "0.3"

File diff suppressed because it is too large Load Diff

View File

@@ -3,20 +3,18 @@ use std::fs;
use std::io;
use std::path::{Path, PathBuf};
use pipeview_client::SessionConfig;
use serde::{Deserialize, Serialize};
use tracing::warn;
use crate::app::{DisplayOptions, View, default_session_config};
use xserial_client::SessionConfig;
const TUI_STATE_FILE_NAME: &str = "tui-state.json";
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct PersistedTuiState {
#[serde(default = "default_session")]
pub session: SessionConfig,
#[serde(default)]
pub active_view: PersistedView,
pub sessions: Vec<SessionConfig>,
#[serde(default)]
pub active: usize,
#[serde(default = "default_true")]
pub show_timestamp: bool,
#[serde(default = "default_true")]
@@ -25,54 +23,9 @@ 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(mut state) => {
if state.session.pipelines.is_empty() {
state.session = default_session();
}
state
}
Ok(state) => state,
Err(err) if err.kind() == io::ErrorKind::NotFound => PersistedTuiState::default(),
Err(err) => {
warn!(error = %err, "Failed to load persisted TUI state");
@@ -81,46 +34,27 @@ pub fn load_tui_state() -> PersistedTuiState {
}
}
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()) {
pub fn save_tui_state(state: &PersistedTuiState) {
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("pipeview")
.join("xserial")
.join(TUI_STATE_FILE_NAME);
}
if let Some(path) = get_env("LOCALAPPDATA").filter(|path| !path.trim().is_empty()) {
return PathBuf::from(path)
.join("pipeview")
.join("xserial")
.join(TUI_STATE_FILE_NAME);
}
}
@@ -129,20 +63,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("pipeview")
.join("xserial")
.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("pipeview")
.join("xserial")
.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("pipeview")
.join("xserial")
.join(TUI_STATE_FILE_NAME);
}
}

View File

@@ -0,0 +1,146 @@
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]"))
}

View File

@@ -0,0 +1,38 @@
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
}

View File

@@ -0,0 +1,332 @@
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]
}

View File

@@ -1,399 +0,0 @@
#!/usr/bin/env python3
"""
pipeview-tauri 极限压力测试脚本
模拟各种高数据量场景,测试 Tauri 前端的渲染性能和稳定性:
- 文本洪水:大文本行 + ANSI 颜色
- 十六进制洪水:大批量 hex dump
- 波形洪水:高频 plot 采样点
- 混合模式:同时发送文本 + 波形MixedTextPlot
- 突发模式:间歇性峰值流量
用法:
python tools/stress_test.py --mode text --rate 5000 --port 8091
python tools/stress_test.py --mode plot --rate 200 --channels 8 --port 8092
python tools/stress_test.py --mode mixed --rate 1000 --port 8093
python tools/stress_test.py --mode burst --rate 10000 --burst-size 500 --port 8094
"""
import argparse
import math
import random
import socket
import struct
import sys
import threading
import time
from collections import defaultdict
PLOT_ESCAPE = 0x1E
PLOT_MARKER = ord("P")
DISCONNECT_WINERRORS = {
10053, # Software caused connection abort
10054, # Connection reset by peer
10058, # Socket shutdown race on Windows
}
# ── ANSI Color Palette ─────────────────────────────────────────────
ANSI_COLORS = [
"\033[31m", "\033[32m", "\033[33m", "\033[34m", "\033[35m", "\033[36m",
"\033[91m", "\033[92m", "\033[93m", "\033[94m", "\033[95m", "\033[96m",
"\033[1;31m", "\033[1;32m", "\033[1;33m", "\033[1;34m",
]
ANSI_RESET = "\033[0m"
# ── Lorem ipsum lines for text stress testing ──────────────────────
LOREM_LINES = [
"[INFO] Sensor data received: temperature={temp:.2f}°C humidity={hum:.1f}% pressure={pres:.1f}hPa",
"[DEBUG] Frame #{n:06d} decoded pipeline={pipe} latency={lat:.3f}ms",
"[WARN] Buffer utilization {pct:.1f}% — threshold approaching",
"[ERROR] Checksum mismatch at offset {off:#x} expected={exp:#x} got={got:#x}",
"[TRACE] I2C transaction: addr={addr:#x} reg={reg:#x} val={val:#x}",
"[METRIC] throughput={tput:.1f} lines/s memory={mem:.1f}MB active_sessions={sess}",
"[EVENT] Session {sid} state changed: {old} -> {new}",
"[DATA] {ts} | CH{ch:02d} | {v0:+08.4f} | {v1:+08.4f} | {v2:+08.4f}",
]
# ── Text stress generator ───────────────────────────────────────────
def generate_text_line(seq: int, ansi: bool = False, payload_size: int = 80) -> bytes:
"""Generate a single text line with optional ANSI colors."""
template = random.choice(LOREM_LINES)
line = template.format(
temp=20.0 + 10 * math.sin(seq * 0.1),
hum=45.0 + 20 * math.cos(seq * 0.07),
pres=1013.0 + random.uniform(-5, 5),
n=seq, pipe=f"pipe_{seq % 4}", lat=random.uniform(0.1, 5.0),
pct=random.uniform(10, 95), off=seq * 16,
exp=random.randint(0, 255), got=random.randint(0, 255),
addr=0x40 + (seq % 8), reg=0x00 + (seq % 16), val=random.randint(0, 65535),
tput=random.uniform(100, 10000), mem=random.uniform(50, 500), sess=random.randint(1, 8),
sid=seq % 8 + 1, old="disconnected", new="connected",
ts=time.strftime("%H:%M:%S", time.localtime()),
ch=seq % 8, v0=random.uniform(-10, 10), v1=random.uniform(-10, 10), v2=random.uniform(-10, 10),
)
# Pad or trim to target payload size
if len(line) < payload_size:
line += " " + "x" * (payload_size - len(line) - 1)
elif len(line) > payload_size:
line = line[:payload_size]
if ansi:
color = random.choice(ANSI_COLORS)
line = f"{color}{line}{ANSI_RESET}"
return (line + "\n").encode("utf-8")
def generate_hex_line(seq: int, bytes_per_line: int = 32) -> bytes:
"""Generate a hex dump line (as text, mimicking hex view data)."""
data = bytes([(seq * bytes_per_line + i) % 256 for i in range(bytes_per_line)])
hex_part = " ".join(f"{b:02X}" for b in data)
ascii_part = "".join(chr(b) if 32 <= b < 127 else "." for b in data)
return f"[{seq:06d}] {hex_part} |{ascii_part}|\n".encode("utf-8")
# ── Plot stress generator ───────────────────────────────────────────
def build_plot_frame(
sample_index: int,
channels: int,
plot_format: str,
samples_per_channel: int,
amplitude: float,
frequency_hz: float,
sample_rate_hz: float,
) -> bytes:
"""Build a raw COBS-encoded plot frame (matching test_plot.py format)."""
values = []
if plot_format == "xy":
for _ in range(samples_per_channel):
t = (sample_index * samples_per_channel + len(values)) / sample_rate_hz
values.append(amplitude * math.sin(2 * math.pi * frequency_hz * t))
values.append(amplitude * math.cos(2 * math.pi * frequency_hz * t))
elif plot_format == "block":
for ch in range(channels):
phase = 2 * math.pi * ch / channels
for _ in range(samples_per_channel):
t = (sample_index * samples_per_channel + len(values) // channels) / sample_rate_hz
values.append(amplitude * math.sin(2 * math.pi * frequency_hz * t + phase))
else: # interleaved
for _ in range(samples_per_channel):
t = (sample_index * samples_per_channel + len(values) // channels) / sample_rate_hz
for ch in range(channels):
phase = 2 * math.pi * ch / channels
values.append(amplitude * math.sin(2 * math.pi * frequency_hz * t + phase))
# Pack as f32 little-endian
payload = struct.pack(f"<{len(values)}f", *values)
return cobs_encode(payload)
def cobs_encode(data: bytes) -> bytes:
"""Consistent Overhead Byte Stuffing encoder."""
result = bytearray()
block_start = 0
while block_start < len(data):
end = min(block_start + 254, len(data))
block = data[block_start:end]
if end < len(data):
result.append(len(block) + 1)
result.extend(block)
else:
result.append(len(block) + 1 if len(block) < 254 else 255)
result.extend(block)
result.append(0)
block_start = end
return bytes(result)
def build_mixed_frame(seq: int, text_rate_per_plot: int, channels: int) -> bytes:
"""Build a MixedTextPlot frame: text lines + one COBS plot frame."""
frames = []
for i in range(text_rate_per_plot):
frames.append(generate_text_line(seq * text_rate_per_plot + i, ansi=True))
# Add one plot frame per text_rate_per_plot text lines
plot_data = build_plot_frame(seq, channels, "interleaved", 32, 100.0, 10.0, 1000.0)
frames.append(bytes([PLOT_ESCAPE, PLOT_MARKER]) + plot_data)
return b"".join(frames)
# ── TCP Server ──────────────────────────────────────────────────────
class StressServer:
def __init__(self, host: str, port: int, mode: str, rate: float,
payload_size: int, channels: int, burst_size: int,
duration: float, ansi: bool):
self.host = host
self.port = port
self.mode = mode
self.rate = rate # lines or frames per second
self.payload_size = payload_size
self.channels = channels
self.burst_size = burst_size
self.duration = duration
self.ansi = ansi
self.stats = defaultdict(int)
self.running = False
self.clients = []
def start(self):
self.running = True
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
sock.bind((self.host, self.port))
sock.listen(5)
sock.settimeout(1.0)
print(f"[STRESS] {self.mode.upper()} mode | {self.rate} lines/s")
print(f"[STRESS] Listening on {self.host}:{self.port}")
if self.duration > 0:
print(f"[STRESS] Duration: {self.duration}s")
print(f"[STRESS] Payload: {self.payload_size}B | Channels: {self.channels}")
if self.mode == "burst":
print(f"[STRESS] Burst size: {self.burst_size} lines")
print()
stats_thread = threading.Thread(target=self._print_stats, daemon=True)
stats_thread.start()
accept_thread = threading.Thread(target=self._accept_loop, args=(sock,), daemon=True)
accept_thread.start()
try:
while self.running:
time.sleep(0.1)
except KeyboardInterrupt:
print("\n[STRESS] Shutting down...")
finally:
self.running = False
sock.close()
def _accept_loop(self, sock):
while self.running:
try:
conn, addr = sock.accept()
print(f"[STRESS] Client connected: {addr[0]}:{addr[1]}")
t = threading.Thread(target=self._client_loop, args=(conn, addr), daemon=True)
t.start()
self.clients.append(t)
except socket.timeout:
continue
except OSError:
break
def _client_loop(self, conn: socket.socket, addr):
seq = 0
start_time = time.time()
last_count_time = start_time
local_count = 0
# Pre-compute interval
if self.mode == "burst":
interval = 0
burst_interval = max(0.016, self.burst_size / max(self.rate, 1))
else:
interval = 1.0 / max(self.rate, 1) if self.rate > 0 else 0.016
try:
conn.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
while self.running:
elapsed = time.time() - start_time
if self.duration > 0 and elapsed >= self.duration:
break
if self.mode == "burst":
self._send_burst(conn, seq)
seq += self.burst_size
local_count += self.burst_size
time.sleep(burst_interval)
else:
data = self._generate(seq)
conn.sendall(data)
seq += 1
local_count += 1
if interval > 0:
# Spin-wait for precise timing at high rates
target = start_time + (seq / self.rate)
sleep_time = target - time.time()
if sleep_time > 0:
time.sleep(min(sleep_time, interval))
# Periodic stats update
if time.time() - last_count_time >= 1.0:
with threading.Lock():
self.stats["lines"] += local_count
self.stats["bytes"] += local_count * self.payload_size # approximate
local_count = 0
last_count_time = time.time()
except OSError as e:
if hasattr(e, "winerror") and e.winerror in DISCONNECT_WINERRORS:
pass
elif not self.running:
pass
else:
self.stats["errors"] += 1
finally:
try:
conn.close()
except OSError:
pass
print(f"[STRESS] Client disconnected: {addr[0]}:{addr[1]}")
def _send_burst(self, conn, start_seq):
"""Send burst_size lines as fast as possible."""
data = b"".join(self._generate(start_seq + i) for i in range(self.burst_size))
conn.sendall(data)
def _generate(self, seq: int) -> bytes:
if self.mode == "text":
return generate_text_line(seq, ansi=self.ansi, payload_size=self.payload_size)
elif self.mode == "hex":
return generate_hex_line(seq, bytes_per_line=max(4, self.payload_size // 3))
elif self.mode == "plot":
return build_plot_frame(
seq, self.channels, "interleaved",
samples_per_channel=32, amplitude=100.0,
frequency_hz=10.0, sample_rate_hz=1000.0,
)
elif self.mode == "mixed":
return build_mixed_frame(seq, text_rate_per_plot=5, channels=self.channels)
else:
return generate_text_line(seq, ansi=True, payload_size=self.payload_size)
def _print_stats(self):
while self.running:
time.sleep(2.0)
with threading.Lock():
lines = self.stats["lines"]
errors = self.stats["errors"]
b = self.stats["bytes"]
if lines > 0:
kb_s = b / 2048 # KB/s over 2 seconds
print(f"[STATS] {lines:>8d} lines | {kb_s:>8.1f} KB/s | {errors:>4d} errors")
self.stats.clear()
# ── CLI ─────────────────────────────────────────────────────────────
def main():
parser = argparse.ArgumentParser(
description="pipeview-tauri 极限压力测试",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
示例:
# 文本洪水5000 行/秒1KB 行宽ANSI 彩色
python tools/stress_test.py --mode text --rate 5000 --payload 1024 --ansi
# 波形洪水200 帧/秒8 通道
python tools/stress_test.py --mode plot --rate 200 --channels 8 --port 8092
# 突发模式:每秒爆发一次 2000 行峰值
python tools/stress_test.py --mode burst --rate 10000 --burst-size 2000
# 混合模式:文本 + 波形MixedTextPlot 帧)
python tools/stress_test.py --mode mixed --rate 1000 --channels 4
# 压测 60 秒后自动停止
python tools/stress_test.py --mode text --rate 10000 --duration 60
""",
)
parser.add_argument("--host", default="127.0.0.1", help="绑定地址 (default: 127.0.0.1)")
parser.add_argument("--port", type=int, default=8091, help="端口 (default: 8091)")
parser.add_argument(
"--mode", choices=["text", "hex", "plot", "mixed", "burst"],
default="text", help="测试模式 (default: text)"
)
parser.add_argument(
"--rate", type=float, default=1000,
help="目标速率 (lines/s 或 frames/s, default: 1000)"
)
parser.add_argument(
"--payload", type=int, default=256,
help="文本行/hex 行目标字节数 (default: 256)"
)
parser.add_argument(
"--channels", type=int, default=4,
help="Plot 通道数 (default: 4)"
)
parser.add_argument(
"--burst-size", type=int, default=500,
help="突发模式每次发送的行数 (default: 500)"
)
parser.add_argument(
"--duration", type=float, default=0,
help="运行时长0 表示无限制 (default: 0)"
)
parser.add_argument(
"--ansi", action="store_true",
help="文本模式启用 ANSI 颜色"
)
args = parser.parse_args()
if args.mode == "plot" and args.rate > 500:
print("[WARN] Plot rate > 500 may overwhelm the renderer. Consider --rate 100-200.")
print()
server = StressServer(
host=args.host,
port=args.port,
mode=args.mode,
rate=args.rate,
payload_size=args.payload,
channels=args.channels,
burst_size=args.burst_size,
duration=args.duration,
ansi=args.ansi,
)
server.start()
if __name__ == "__main__":
main()

View File

@@ -1,317 +0,0 @@
#!/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()

View File

@@ -1,5 +1,5 @@
#!/usr/bin/env python3
"""Generate fake drone telemetry data for testing pipeview Lua decoder."""
"""Generate fake drone telemetry data for testing xserial Lua decoder."""
import argparse
import math

View File

@@ -1,5 +1,5 @@
#!/usr/bin/env python3
"""TCP server that sends plot waveform frames or text lines for pipeview GUI."""
"""TCP server that sends plot waveform frames or text lines for xserial GUI."""
import argparse
import math
@@ -116,7 +116,7 @@ def is_disconnect_error(err: OSError) -> bool:
def main() -> None:
parser = argparse.ArgumentParser(description="pipeview plot test server")
parser = argparse.ArgumentParser(description="xserial 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)

View File

@@ -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("═══ pipeview Serial Plot Generator ═══\n");
printf("═══ xserial 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);

View File

@@ -208,7 +208,7 @@ int main(int argc, char **argv) {
double interval = 1.0 / args.rate;
printf("═══ pipeview Plain Text Generator ═══\n");
printf("═══ xserial 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);