Compare commits
19 Commits
d489bc0831
...
tui
| Author | SHA1 | Date | |
|---|---|---|---|
| 5035e73d7e | |||
| ee65e284c4 | |||
| 4e4c9cee65 | |||
| e600418da8 | |||
| e939b16d2d | |||
| a2c7c0fa71 | |||
| ab1c5882d1 | |||
| 2efbc9c284 | |||
| b3acb02d52 | |||
| afb8ca509a | |||
| 9cdbd5a892 | |||
| 8207b5e883 | |||
| 202b8261c2 | |||
| dc8f2eb8a4 | |||
| edec8113fb | |||
| 1fec5f8941 | |||
| 09bee86a08 | |||
| 18bdd2652a | |||
| 9b826d79da |
7
.gitignore
vendored
7
.gitignore
vendored
@@ -1 +1,8 @@
|
|||||||
/target
|
/target
|
||||||
|
|
||||||
|
# Compiled binaries in tools/
|
||||||
|
tools/test_plot_c
|
||||||
|
tools/test_text_c
|
||||||
|
|
||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
|||||||
28
AGENTS.md
28
AGENTS.md
@@ -1,28 +0,0 @@
|
|||||||
# Repository Guidelines
|
|
||||||
|
|
||||||
## Project Structure & Module Organization
|
|
||||||
`xserial` is a Rust workspace. The root [Cargo.toml](/D:/Dev/xserial/Cargo.toml) defines four crates in `crates/`:
|
|
||||||
- `xserial-core`: transport, framing, protocol, and pipeline primitives in `src/`, with integration tests in `tests/pipeline.rs`.
|
|
||||||
- `xserial-client`: session management, history, commands, and Lua bindings, with integration tests under `tests/`.
|
|
||||||
- `xserial-tui`: terminal app entrypoint in `src/main.rs`.
|
|
||||||
- `xserial-gui`: egui/eframe app, with UI panels in `src/panels/` and font helpers in `src/ui_fonts.rs`.
|
|
||||||
|
|
||||||
Use `tools/test_plot.py` as a local waveform source for GUI plot testing. Treat `target/` as generated output.
|
|
||||||
|
|
||||||
## Build, Test, and Development Commands
|
|
||||||
- `cargo check --workspace`: fast compile check for all crates.
|
|
||||||
- `cargo test --workspace`: run the workspace test suite, including `crates/xserial-core/tests` and `crates/xserial-client/tests`.
|
|
||||||
- `cargo run -p xserial-gui`: launch the desktop GUI.
|
|
||||||
- `cargo run -p xserial-tui`: launch the terminal UI.
|
|
||||||
- `cargo fmt --all`: apply standard Rust formatting.
|
|
||||||
- `cargo clippy --workspace --all-targets -- -D warnings`: catch lint issues before review.
|
|
||||||
- `python tools/test_plot.py --help`: inspect options for the TCP plot-frame generator.
|
|
||||||
|
|
||||||
## Coding Style & Naming Conventions
|
|
||||||
Follow standard Rust formatting: 4-space indentation, trailing commas in multiline literals, and `snake_case` for modules, files, functions, and tests. Use `PascalCase` for structs/enums and `SCREAMING_SNAKE_CASE` for constants. Keep crate boundaries clean: protocol/transport code belongs in `xserial-core`; session and Lua integration belong in `xserial-client`; UI state stays in `xserial-gui` or `xserial-tui`.
|
|
||||||
|
|
||||||
## Testing Guidelines
|
|
||||||
Prefer focused unit tests next to implementation and integration tests in `crates/<crate>/tests/*.rs`. Name tests after behavior, for example `session_lifecycle` or `pipeline`. Run `cargo test --workspace` before opening a PR; add targeted regression tests for transport, framing, parsing, and session-state fixes.
|
|
||||||
|
|
||||||
## Commit & Pull Request Guidelines
|
|
||||||
Recent history uses short, imperative subjects such as `Persist GUI font settings` and `Improve session controls and GUI views`, with occasional `feat:` prefixes for major additions. Keep subjects concise and action-oriented. PRs should state which crate(s) changed, list validation commands, and include screenshots or short recordings for GUI/TUI changes. Call out protocol, transport, or Lua API compatibility impacts explicitly.
|
|
||||||
155
Cargo.lock
generated
155
Cargo.lock
generated
@@ -189,6 +189,16 @@ dependencies = [
|
|||||||
"libc",
|
"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]]
|
[[package]]
|
||||||
name = "anstream"
|
name = "anstream"
|
||||||
version = "1.0.0"
|
version = "1.0.0"
|
||||||
@@ -3262,6 +3272,76 @@ dependencies = [
|
|||||||
"futures-io",
|
"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]]
|
[[package]]
|
||||||
name = "pkg-config"
|
name = "pkg-config"
|
||||||
version = "0.3.33"
|
version = "0.3.33"
|
||||||
@@ -4652,6 +4732,16 @@ version = "0.9.5"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
|
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]]
|
[[package]]
|
||||||
name = "vtparse"
|
name = "vtparse"
|
||||||
version = "0.6.2"
|
version = "0.6.2"
|
||||||
@@ -5750,71 +5840,6 @@ version = "0.8.28"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "3ae8337f8a065cfc972643663ea4279e04e7256de865aa66fe25cec5fb912d3f"
|
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",
|
|
||||||
"ratatui",
|
|
||||||
"tokio",
|
|
||||||
"tracing",
|
|
||||||
"tracing-appender",
|
|
||||||
"tracing-subscriber",
|
|
||||||
"xserial-client",
|
|
||||||
"xserial-core",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "yoke"
|
name = "yoke"
|
||||||
version = "0.8.2"
|
version = "0.8.2"
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
[workspace]
|
[workspace]
|
||||||
resolver = "2"
|
resolver = "2"
|
||||||
members = [
|
members = [
|
||||||
"crates/xserial-core",
|
"crates/pipeview-core",
|
||||||
"crates/xserial-client",
|
"crates/pipeview-client",
|
||||||
"crates/xserial-tui",
|
"crates/pipeview-tui",
|
||||||
"crates/xserial-gui",
|
"crates/pipeview-gui",
|
||||||
]
|
]
|
||||||
|
|
||||||
[workspace.dependencies]
|
[workspace.dependencies]
|
||||||
|
|||||||
394
README.md
Normal file
394
README.md
Normal file
@@ -0,0 +1,394 @@
|
|||||||
|
# pipeview
|
||||||
|
|
||||||
|
**跨平台串口 / TCP / UDP 数据观测工具** — 可配置分帧、协议解码、多会话管理、实时波形绘图,单二进制零依赖。
|
||||||
|
|
||||||
|
基于 Rust + [egui](https://github.com/emilk/egui) 构建,支持 Lua 脚本扩展。
|
||||||
|
|
||||||
|
[English](README_en.md)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 目录
|
||||||
|
|
||||||
|
- [功能特性](#功能特性)
|
||||||
|
- [快速开始](#快速开始)
|
||||||
|
- [完整示例:无人机遥测解析](#完整示例无人机遥测解析)
|
||||||
|
- [Lua 脚本开发指南](#lua-脚本开发指南)
|
||||||
|
- [架构](#架构)
|
||||||
|
- [配置与持久化](#配置与持久化)
|
||||||
|
- [开发工具](#开发工具)
|
||||||
|
- [技术栈](#技术栈)
|
||||||
|
- [License](#license)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 功能特性
|
||||||
|
|
||||||
|
### 传输层
|
||||||
|
|
||||||
|
| 类型 | 说明 | 配置项 |
|
||||||
|
|------|------|--------|
|
||||||
|
| **Serial** | 串口通信 | 端口名、波特率 (300–12M)、数据位 (5/6/7/8)、校验位 (None/Odd/Even)、停止位 (1/2)、流控 (None/Software/Hardware)、DTR/RTS 控制 |
|
||||||
|
| **TCP** | TCP 客户端 | 目标地址 `host:port` |
|
||||||
|
| **UDP** | UDP 通信 | 绑定地址 `host:port`,可选远端地址 |
|
||||||
|
|
||||||
|
### 分帧器(Framer)
|
||||||
|
|
||||||
|
将原始字节流切分为独立帧。每个 Session 可配置**多条独立管线**,同一字节流并行送入多个 framer→decoder 链路。
|
||||||
|
|
||||||
|
| 分帧器 | 说明 | 配置参数 |
|
||||||
|
|--------|------|----------|
|
||||||
|
| **Line** | 按 `\n` 分割文本行 | `strip_cr`(去除 `\r`)、`max_line_len`(最大行长度) |
|
||||||
|
| **Fixed** | 固定字节数为一帧 | `frame_len`(帧长度) |
|
||||||
|
| **Length** | 长度前缀协议 | `len_bytes` (1/2/4/8)、`endian` (大小端)、`length_includes_self`、`max_payload` |
|
||||||
|
| **COBS** | [Consistent Overhead Byte Stuffing](https://en.wikipedia.org/wiki/Consistent_Overhead_Byte_Stuffing) 编码 | `max_frame`(最大帧长) |
|
||||||
|
| **MixedTextPlot** | 单连接混合文本行 + COBS 编码的 plot 帧 | `strip_cr`、`max_line_len`、`max_plot_frame` |
|
||||||
|
| **Lua** | 用户自定义分帧脚本 | `script_path`(Lua 脚本路径) |
|
||||||
|
|
||||||
|
### 解码器(Decoder)
|
||||||
|
|
||||||
|
将帧数据解析为可展示的内容。
|
||||||
|
|
||||||
|
| 解码器 | 输出类型 | 配置参数 |
|
||||||
|
|--------|----------|----------|
|
||||||
|
| **Text** | 文本 | `encoding`(UTF-8 / Latin1 / ASCII) |
|
||||||
|
| **Hex** | 十六进制 | `uppercase`、`separator`、`bytes_per_group`、`endian` |
|
||||||
|
| **Plot** | 波形数据 | `sample_type` (i8–f64)、`endian`、`channels` (1–64)、`format` (Interleaved / Block / XY) |
|
||||||
|
| **MixedTextPlot** | 混合文本 + 波形 | `encoding` |
|
||||||
|
| **Lua** | 自定义 | `script_path`(Lua 脚本路径) |
|
||||||
|
|
||||||
|
**Plot 采样格式:**
|
||||||
|
|
||||||
|
| 格式 | 说明 | 字节排列 |
|
||||||
|
|------|------|----------|
|
||||||
|
| **Interleaved** | 多通道交叉排列 | `[ch0_s0, ch1_s0, ch2_s0, ch0_s1, ch1_s1, …]` |
|
||||||
|
| **Block** | 按通道分块排列 | `[ch0_s0, ch0_s1, …, ch1_s0, ch1_s1, …]` |
|
||||||
|
| **XY** | 2 通道交替 x/y | `[x0, y0, x1, y1, …]` |
|
||||||
|
|
||||||
|
支持的采样类型:`i8`、`u8`、`i16`、`u16`、`i32`、`u32`、`i64`、`u64`、`f32`、`f64`
|
||||||
|
|
||||||
|
### 视图
|
||||||
|
|
||||||
|
- **文本视图** — 带时间戳、方向标记 (`[IN]`/`[OUT]`)、管线标签的格式化文本流。支持搜索(`Ctrl+F`)、大小写匹配、匹配计数和高亮。
|
||||||
|
- **十六进制视图** — hex dump 与 ASCII 侧栏并排展示,可按分组和大小端解析多字节数值。
|
||||||
|
- **波形视图** — 基于 [egui_plot](https://github.com/emilk/egui/tree/master/crates/egui_plot) 的实时波形。支持自动缩放、框选缩放、坐标轴锁定、跟随最新数据、浮动窗口、通道图例。
|
||||||
|
|
||||||
|
### 会话管理
|
||||||
|
|
||||||
|
- **多会话并发** — 标签页切换,每个 Session 独立配置
|
||||||
|
- **连接控制** — Connect / Disconnect / Reconnect,支持断线自动重连
|
||||||
|
- **运行时重配置** — 修改分帧器/解码器参数无需断开连接
|
||||||
|
- **数据发送** — Text 模式(UTF-8,可选换行符)和 Hex 模式(十六进制字节)。支持 `None` / `LF` / `CR` / `CRLF` 四种行尾
|
||||||
|
- **环形缓冲** — 默认 10,000 条历史记录,可配置 100–1,000,000
|
||||||
|
- **日志到文件** — 每行数据实时写入,1KB 缓冲 + 后台线程,不阻塞 UI
|
||||||
|
- **搜索** — `Ctrl+F` 呼出搜索栏,大小写敏感切换,F3/Shift+F3 前后跳转
|
||||||
|
|
||||||
|
### Lua 脚本扩展
|
||||||
|
|
||||||
|
内置 LuaJIT 运行时:
|
||||||
|
|
||||||
|
- **自定义分帧器** — 实现 `feed(bytes)`、`flush()`、`reset()`、`pending_len()` 四个函数
|
||||||
|
- **自定义解码器** — 实现 `decode(frame)` 函数,返回 text/hex/plot/binary 四种类型
|
||||||
|
- **会话 API** — `pipeview.open()` 创建 Session、`session:on_data()` 事件回调、`session:send()` 发送数据
|
||||||
|
- **工具函数** — `pipeview.list_ports()`、`pipeview.sleep(ms)`、`pipeview.poll(limit)`、`pipeview.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/pipeview.git
|
||||||
|
cd pipeview
|
||||||
|
|
||||||
|
# 编译运行
|
||||||
|
cargo run -p pipeview-gui
|
||||||
|
|
||||||
|
# 运行测试
|
||||||
|
cargo test --workspace # ~308 个测试
|
||||||
|
cargo clippy --workspace --all-targets -- -D warnings
|
||||||
|
```
|
||||||
|
|
||||||
|
### 发布构建
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo build -p pipeview-gui --release
|
||||||
|
# 二进制位于 target/release/pipeview-gui (Linux/macOS)
|
||||||
|
# 或 target/release/pipeview-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
|
||||||
|
|
||||||
|
在 pipeview-gui 中创建 Session:
|
||||||
|
|
||||||
|
| 配置项 | 值 |
|
||||||
|
|--------|-----|
|
||||||
|
| Transport | `TCP 127.0.0.1:8091` |
|
||||||
|
| Pipeline 1 | Framer: `Line` → Decoder: `Lua` → 选择 `examples/drone_text.lua` |
|
||||||
|
| Pipeline 2 | Framer: `Line` → Decoder: `Lua` → 选择 `examples/drone_plot.lua` |
|
||||||
|
|
||||||
|
### 步骤 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/` 目录。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 架构
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────────────────────────────────────┐
|
||||||
|
│ pipeview-gui (egui) pipeview-tui (ratatui) │
|
||||||
|
├──────────────────────────────────────────────────┤
|
||||||
|
│ pipeview-client │
|
||||||
|
│ SessionManager · Session · Config · History │
|
||||||
|
│ Lua Runtime (mlua / LuaJIT) │
|
||||||
|
├──────────────────────────────────────────────────┤
|
||||||
|
│ pipeview-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,仍在开发中) |
|
||||||
|
|
||||||
|
**依赖方向:** `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/pipeview/gui-state.json` 或 `~/.config/pipeview/gui-state.json` |
|
||||||
|
| macOS | `~/Library/Application Support/pipeview/gui-state.json` |
|
||||||
|
| Windows | `%APPDATA%\pipeview\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=pipeview_gui::perf=info XSERIAL_GUI_PROFILE=1 cargo run -p pipeview-gui
|
||||||
|
XSERIAL_GUI_PROFILE_INTERVAL_MS=500 cargo run -p pipeview-gui
|
||||||
|
```
|
||||||
|
|
||||||
|
输出每帧的耗时、事件 drain 耗时、text/hex/plot 渲染耗时、plot 点数统计。
|
||||||
|
|
||||||
|
### 日志
|
||||||
|
|
||||||
|
```bash
|
||||||
|
RUST_LOG=info cargo run -p pipeview-gui # 应用日志
|
||||||
|
RUST_LOG=pipeview_gui=debug cargo run -p pipeview-gui # 详细日志
|
||||||
|
```
|
||||||
|
|
||||||
|
使用 `tracing-subscriber` + `RUST_LOG` 环境变量控制。
|
||||||
|
|
||||||
|
### 项目结构
|
||||||
|
|
||||||
|
```
|
||||||
|
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 协议参考头文件
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 技术栈
|
||||||
|
|
||||||
|
- **运行时**: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
|
||||||
378
README_en.md
Normal file
378
README_en.md
Normal file
@@ -0,0 +1,378 @@
|
|||||||
|
# pipeview
|
||||||
|
|
||||||
|
**Cross-platform serial / TCP / UDP data inspection tool** with configurable framing, protocol decoding, multi-session management, and real-time waveform plotting — all in a single binary.
|
||||||
|
|
||||||
|
Built with Rust + [egui](https://github.com/emilk/egui), scriptable with Lua.
|
||||||
|
|
||||||
|
[中文文档](README.md)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Table of Contents
|
||||||
|
|
||||||
|
- [Features](#features)
|
||||||
|
- [Quick Start](#quick-start)
|
||||||
|
- [Walkthrough: Drone Telemetry](#walkthrough-drone-telemetry)
|
||||||
|
- [Lua Scripting Guide](#lua-scripting-guide)
|
||||||
|
- [Architecture](#architecture)
|
||||||
|
- [Configuration & Persistence](#configuration--persistence)
|
||||||
|
- [Development Tools](#development-tools)
|
||||||
|
- [Tech Stack](#tech-stack)
|
||||||
|
- [License](#license)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
### Transport
|
||||||
|
|
||||||
|
| Type | Description | Options |
|
||||||
|
|------|-------------|---------|
|
||||||
|
| **Serial** | Serial port communication | Port name, baud rate (300–12M), data bits (5/6/7/8), parity (None/Odd/Even), stop bits (1/2), flow control (None/Software/Hardware), DTR/RTS control |
|
||||||
|
| **TCP** | TCP client | Target address `host:port` |
|
||||||
|
| **UDP** | UDP communication | Bind address `host:port`, optional remote address |
|
||||||
|
|
||||||
|
### Framers
|
||||||
|
|
||||||
|
Framers split raw byte streams into discrete frames. Each session supports **multiple independent pipelines** — the same byte stream feeds multiple framer→decoder chains in parallel.
|
||||||
|
|
||||||
|
| Framer | Description | Parameters |
|
||||||
|
|--------|-------------|------------|
|
||||||
|
| **Line** | Split by `\n` | `strip_cr`, `max_line_len` |
|
||||||
|
| **Fixed** | Fixed-length frames | `frame_len` |
|
||||||
|
| **Length** | Length-prefixed protocol | `len_bytes` (1/2/4/8), `endian`, `length_includes_self`, `max_payload` |
|
||||||
|
| **COBS** | [Consistent Overhead Byte Stuffing](https://en.wikipedia.org/wiki/Consistent_Overhead_Byte_Stuffing) | `max_frame` |
|
||||||
|
| **MixedTextPlot** | Hybrid text lines + COBS-encoded plot frames on one stream | `strip_cr`, `max_line_len`, `max_plot_frame` |
|
||||||
|
| **Lua** | User-defined framer script | `script_path` |
|
||||||
|
|
||||||
|
### Decoders
|
||||||
|
|
||||||
|
Decoders parse framed data into displayable content.
|
||||||
|
|
||||||
|
| Decoder | Output | Parameters |
|
||||||
|
|---------|--------|------------|
|
||||||
|
| **Text** | Text | `encoding` (UTF-8 / Latin1 / ASCII) |
|
||||||
|
| **Hex** | Hexadecimal | `uppercase`, `separator`, `bytes_per_group`, `endian` |
|
||||||
|
| **Plot** | Waveform | `sample_type` (i8–f64), `endian`, `channels` (1–64), `format` (Interleaved / Block / XY) |
|
||||||
|
| **MixedTextPlot** | Hybrid text + waveform | `encoding` |
|
||||||
|
| **Lua** | Custom | `script_path` |
|
||||||
|
|
||||||
|
**Plot sample formats:**
|
||||||
|
|
||||||
|
| Format | Description | Byte layout |
|
||||||
|
|--------|-------------|-------------|
|
||||||
|
| **Interleaved** | Channels interleaved | `[ch0_s0, ch1_s0, ch2_s0, ch0_s1, ch1_s1, …]` |
|
||||||
|
| **Block** | Channel-contiguous blocks | `[ch0_s0, ch0_s1, …, ch1_s0, ch1_s1, …]` |
|
||||||
|
| **XY** | 2-channel x/y pairs | `[x0, y0, x1, y1, …]` |
|
||||||
|
|
||||||
|
Supported sample types: `i8`, `u8`, `i16`, `u16`, `i32`, `u32`, `i64`, `u64`, `f32`, `f64`
|
||||||
|
|
||||||
|
### Views
|
||||||
|
|
||||||
|
- **Text View** — formatted text with timestamps, direction markers (`[IN]`/`[OUT]`), and pipeline labels. Built-in search (`Ctrl+F`) with case-sensitive toggle, match counter, and highlighting.
|
||||||
|
- **Hex View** — hex dump alongside ASCII sidebar. Configurable byte grouping and endianness.
|
||||||
|
- **Plot View** — real-time waveforms via [egui_plot](https://github.com/emilk/egui/tree/master/crates/egui_plot). Auto-fit, box zoom, axis locking, follow-latest, detached window, channel legend.
|
||||||
|
|
||||||
|
### Sessions
|
||||||
|
|
||||||
|
- **Multi-session** — tabbed interface, each session independently configured
|
||||||
|
- **Connection control** — Connect / Disconnect / Reconnect with auto-reconnect on drop
|
||||||
|
- **Live reconfiguration** — change framer/decoder settings without restarting
|
||||||
|
- **Data sending** — Text mode (UTF-8, configurable line endings) and Hex mode (raw bytes). Four line ending options: None / LF / CR / CRLF
|
||||||
|
- **Ring buffer** — default 10,000 entries, configurable 100–1,000,000
|
||||||
|
- **Log to file** — background writer thread with 1 KB buffer, non-blocking
|
||||||
|
- **Search** — `Ctrl+F` search bar, case-sensitive toggle, F3/Shift+F3 navigation
|
||||||
|
|
||||||
|
### Lua Scripting
|
||||||
|
|
||||||
|
Built-in LuaJIT runtime:
|
||||||
|
|
||||||
|
- **Custom framers** — implement `feed(bytes)`, `flush()`, `reset()`, `pending_len()`
|
||||||
|
- **Custom decoders** — implement `decode(frame)`, return text/hex/plot/binary
|
||||||
|
- **Session API** — `pipeview.open()` to create sessions, `session:on_data()` for event callbacks, `session:send()` to transmit data
|
||||||
|
- **Utilities** — `pipeview.list_ports()`, `pipeview.sleep(ms)`, `pipeview.poll(limit)`, `pipeview.log(msg)`
|
||||||
|
|
||||||
|
### Keyboard Shortcuts
|
||||||
|
|
||||||
|
| Shortcut | Action |
|
||||||
|
|----------|--------|
|
||||||
|
| `Ctrl+N` | New session |
|
||||||
|
| `Ctrl+E` | Edit current session |
|
||||||
|
| `Ctrl+W` | Delete current session |
|
||||||
|
| `Ctrl+F5` | Toggle connection |
|
||||||
|
| `Ctrl+T` / `Ctrl+H` / `Ctrl+P` | Switch to Text / Hex / Plot view |
|
||||||
|
| `Ctrl+Tab` / `Ctrl+Shift+Tab` | Next / Previous tab |
|
||||||
|
| `Ctrl+L` | Clear output |
|
||||||
|
| `Ctrl+F` | Search |
|
||||||
|
| `F3` / `Shift+F3` | Next / Previous match |
|
||||||
|
| `Ctrl+,` | UI settings |
|
||||||
|
| `Esc` | Close overlay |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
|
||||||
|
| Platform | Dependencies |
|
||||||
|
|----------|-------------|
|
||||||
|
| **Linux** | `libudev-dev` (`apt install libudev-dev`) |
|
||||||
|
| **macOS** | None |
|
||||||
|
| **Windows** | None |
|
||||||
|
| **All** | Rust ≥ 1.85, C compiler (GCC / Clang / MSVC) |
|
||||||
|
|
||||||
|
### Build & Run
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/your-org/pipeview.git
|
||||||
|
cd pipeview
|
||||||
|
|
||||||
|
cargo run -p pipeview-gui
|
||||||
|
|
||||||
|
# Run tests
|
||||||
|
cargo test --workspace # ~308 tests
|
||||||
|
cargo clippy --workspace --all-targets -- -D warnings
|
||||||
|
```
|
||||||
|
|
||||||
|
### Release Build
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo build -p pipeview-gui --release
|
||||||
|
# Binary: target/release/pipeview-gui (Linux/macOS)
|
||||||
|
# target/release/pipeview-gui.exe (Windows)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Walkthrough: Drone Telemetry
|
||||||
|
|
||||||
|
Parse Betaflight/INAV flight controller telemetry in the format:
|
||||||
|
|
||||||
|
```
|
||||||
|
AHRS q:1.0000,0.0998,0.0499,0.0200|YPR:8.87,4.44,2.96|Gyro:14.78,8.87,5.92|RC:1559,1544,1500,1519|M:1612,1588,1603,1597|L:0 F:1 C:0
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 1: Start the data source
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python tools/test_drone.py --rate 10 --port 8091
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 2: Configure the session
|
||||||
|
|
||||||
|
In pipeview-gui, create a session with two pipelines:
|
||||||
|
|
||||||
|
| Setting | Value |
|
||||||
|
|---------|-------|
|
||||||
|
| Transport | `TCP 127.0.0.1:8091` |
|
||||||
|
| Pipeline 1 | Framer: `Line` → Decoder: `Lua` → `examples/drone_text.lua` |
|
||||||
|
| Pipeline 2 | Framer: `Line` → Decoder: `Lua` → `examples/drone_plot.lua` |
|
||||||
|
|
||||||
|
### Step 3: View the results
|
||||||
|
|
||||||
|
- **Text View** — formatted sensor readouts
|
||||||
|
- **Plot View** — real-time gyroscope curves (gz/gy/gx)
|
||||||
|
|
||||||
|
The decoder in `examples/drone_plot.lua`:
|
||||||
|
|
||||||
|
```lua
|
||||||
|
return {
|
||||||
|
decode = function(frame)
|
||||||
|
local gz, gy, gx = frame:match("Gyro:([%d%.%-]+),([%d%.%-]+),([%d%.%-]+)")
|
||||||
|
return {
|
||||||
|
kind = "plot",
|
||||||
|
channels = { { tonumber(gz) }, { tonumber(gy) }, { tonumber(gx) } },
|
||||||
|
sample_type = "F64",
|
||||||
|
format = "Block",
|
||||||
|
}
|
||||||
|
end,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Lua Scripting Guide
|
||||||
|
|
||||||
|
### Framer API
|
||||||
|
|
||||||
|
A Lua framer splits raw bytes into frames. The script must return a table with these functions:
|
||||||
|
|
||||||
|
```lua
|
||||||
|
return {
|
||||||
|
feed = function(bytes)
|
||||||
|
-- bytes: Lua string (raw bytes)
|
||||||
|
-- Returns: { frame1, frame2, ... } or nil
|
||||||
|
end,
|
||||||
|
|
||||||
|
flush = function()
|
||||||
|
-- Returns: last buffered frame (string) or nil
|
||||||
|
end,
|
||||||
|
|
||||||
|
reset = function()
|
||||||
|
end,
|
||||||
|
|
||||||
|
pending_len = function()
|
||||||
|
-- Returns: number of bytes pending in buffer
|
||||||
|
end,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Reference: `tests/lua_line_framer.lua` (line-based framer splitting on `\n`).
|
||||||
|
|
||||||
|
### Decoder API
|
||||||
|
|
||||||
|
A Lua decoder parses a frame into structured data. The script must return a table with a `decode` function:
|
||||||
|
|
||||||
|
```lua
|
||||||
|
return {
|
||||||
|
decode = function(frame)
|
||||||
|
-- frame: Lua string (one frame from the framer)
|
||||||
|
-- Returns nil → skip this frame
|
||||||
|
-- Returns string → treated as Text
|
||||||
|
-- Returns table → must contain "kind" field
|
||||||
|
end,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Return value formats:**
|
||||||
|
|
||||||
|
| `kind` | Required | Optional | Purpose |
|
||||||
|
|--------|----------|----------|---------|
|
||||||
|
| `"text"` | `data: string` | — | Text view |
|
||||||
|
| `"hex"` | `data: string` | — | Hex view |
|
||||||
|
| `"binary"` | `data: string` | — | Raw bytes |
|
||||||
|
| `"plot"` | `channels: {{number,…},…}` | `sample_type`, `format` | Plot view |
|
||||||
|
|
||||||
|
**Plot return value example:**
|
||||||
|
|
||||||
|
```lua
|
||||||
|
return {
|
||||||
|
kind = "plot",
|
||||||
|
channels = {
|
||||||
|
{ 1.0, 2.0, 3.0 }, -- channel 0
|
||||||
|
{ 4.0, 5.0, 6.0 }, -- channel 1
|
||||||
|
},
|
||||||
|
sample_type = "F64", -- default F64; also I8/U8/I16/U32/U64/F32
|
||||||
|
format = "Block", -- default Interleaved; also Block/XY
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
More examples in `examples/`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────────────────────────────────────┐
|
||||||
|
│ pipeview-gui (egui) pipeview-tui (ratatui) │
|
||||||
|
├──────────────────────────────────────────────────┤
|
||||||
|
│ pipeview-client │
|
||||||
|
│ SessionManager · Session · Config · History │
|
||||||
|
│ Lua Runtime (mlua / LuaJIT) │
|
||||||
|
├──────────────────────────────────────────────────┤
|
||||||
|
│ pipeview-core │
|
||||||
|
│ Transport ──▶ Frame ──▶ Protocol ──▶ Pipeline │
|
||||||
|
└──────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
| Crate | Type | Purpose |
|
||||||
|
|-------|------|---------|
|
||||||
|
| `pipeview-core` | library | Transport (Serial/TCP/UDP), framers (Line/Fixed/Length/COBS/Mixed/Lua), decoders (Text/Hex/Plot), MultiPipeline |
|
||||||
|
| `pipeview-client` | library | Session lifecycle, SessionManager, event broadcast (tokio broadcast), RingBuffer history, Lua runtime & session API |
|
||||||
|
| `pipeview-gui` | binary | egui desktop app: sidebar, config, console, text/hex/plot panels, keyboard shortcuts, font management, profiling |
|
||||||
|
| `pipeview-tui` | binary | ratatui terminal app (feature-incomplete, under development) |
|
||||||
|
|
||||||
|
**Dependency flow:** `core ← client ← {gui, tui}`
|
||||||
|
|
||||||
|
**Data flow:**
|
||||||
|
|
||||||
|
```
|
||||||
|
[Transport] → read bytes → [MultiPipeline]
|
||||||
|
→ Pipeline 1: Framer → Decoder → DecodedEntry → broadcast → GUI buffers
|
||||||
|
→ Pipeline 2: Framer → Decoder → DecodedEntry → broadcast → GUI buffers
|
||||||
|
→ ...
|
||||||
|
```
|
||||||
|
|
||||||
|
Each pipeline frames and decodes independently. Only successful decodes produce output.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Configuration & Persistence
|
||||||
|
|
||||||
|
GUI state is saved automatically to platform-standard locations:
|
||||||
|
|
||||||
|
| Platform | Path |
|
||||||
|
|----------|------|
|
||||||
|
| Linux | `$XDG_CONFIG_HOME/pipeview/gui-state.json` or `~/.config/pipeview/gui-state.json` |
|
||||||
|
| macOS | `~/Library/Application Support/pipeview/gui-state.json` |
|
||||||
|
| Windows | `%APPDATA%\pipeview\gui-state.json` |
|
||||||
|
|
||||||
|
Persisted data includes: session configurations (transport, pipelines), log settings, active tab, and display options.
|
||||||
|
|
||||||
|
Log files are written to `logs/` under the config directory, named `session_{id}_{timestamp}.log`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Development Tools
|
||||||
|
|
||||||
|
### Test Data Generators
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Plot waveform test data
|
||||||
|
python tools/test_plot.py --wire-format mixed --host 127.0.0.1 --port 8091
|
||||||
|
python tools/test_plot.py --wire-format mixed --format xy --channels 2
|
||||||
|
python tools/test_plot.py --wire-format raw --channels 2 --framelen 256
|
||||||
|
|
||||||
|
# Drone telemetry test data
|
||||||
|
python tools/test_drone.py --rate 10 --port 8092
|
||||||
|
```
|
||||||
|
|
||||||
|
### Profiling
|
||||||
|
|
||||||
|
```bash
|
||||||
|
RUST_LOG=pipeview_gui::perf=info XSERIAL_GUI_PROFILE=1 cargo run -p pipeview-gui
|
||||||
|
XSERIAL_GUI_PROFILE_INTERVAL_MS=500 cargo run -p pipeview-gui
|
||||||
|
```
|
||||||
|
|
||||||
|
### Tracing
|
||||||
|
|
||||||
|
```bash
|
||||||
|
RUST_LOG=info cargo run -p pipeview-gui
|
||||||
|
RUST_LOG=pipeview_gui=debug cargo run -p pipeview-gui
|
||||||
|
```
|
||||||
|
|
||||||
|
### Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
crates/
|
||||||
|
pipeview-core/ # Transport, framing, protocol
|
||||||
|
pipeview-client/ # Session management, Lua runtime
|
||||||
|
pipeview-gui/ # egui desktop application
|
||||||
|
pipeview-tui/ # ratatui terminal application
|
||||||
|
examples/ # Lua script examples
|
||||||
|
drone_plot.lua # Drone telemetry plot decoder
|
||||||
|
drone_text.lua # Drone telemetry text decoder
|
||||||
|
tests/ # Lua test fixtures
|
||||||
|
tools/ # Development utilities
|
||||||
|
test_plot.py # Waveform test data generator
|
||||||
|
test_drone.py # Drone test data generator
|
||||||
|
test_plot_serial.c # C serial plot test client
|
||||||
|
test_text_serial.c # C serial text test client
|
||||||
|
xs_mixed_plot.h # MixedTextPlot protocol reference
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tech Stack
|
||||||
|
|
||||||
|
- **Runtime**: tokio (multi-threaded)
|
||||||
|
- **Serial**: `tokio-serial` + `serialport`
|
||||||
|
- **GUI**: egui + egui_plot
|
||||||
|
- **Lua**: mlua 0.11, LuaJIT (vendored), async/serde/send
|
||||||
|
- **Events**: `tokio::sync::broadcast` (multi-subscriber)
|
||||||
|
- **Zero feature flags**, zero build scripts, zero conditional compilation
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "xserial-client"
|
name = "pipeview-client"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
xserial-core = { path = "../xserial-core" }
|
pipeview-core = { path = "../pipeview-core" }
|
||||||
tokio = { workspace = true, features = ["sync", "time"] }
|
tokio = { workspace = true, features = ["sync", "time"] }
|
||||||
tracing = { workspace = true }
|
tracing = { workspace = true }
|
||||||
serde = { workspace = true }
|
serde = { workspace = true }
|
||||||
@@ -8,4 +8,6 @@ pub enum SessionCmd {
|
|||||||
SetAutoReconnect(bool),
|
SetAutoReconnect(bool),
|
||||||
Close,
|
Close,
|
||||||
Reconfigure(SessionConfig),
|
Reconfigure(SessionConfig),
|
||||||
|
SetDtr(bool),
|
||||||
|
SetRts(bool),
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
use serde::{Deserialize, Serialize};
|
use pipeview_core::frame::{
|
||||||
use xserial_core::frame::{
|
|
||||||
Endian, Framer,
|
Endian, Framer,
|
||||||
cobs::CobsFramer,
|
cobs::CobsFramer,
|
||||||
fixed::FixedLengthFramer,
|
fixed::FixedLengthFramer,
|
||||||
@@ -7,14 +6,18 @@ use xserial_core::frame::{
|
|||||||
line::{LineConfig, LineFramer},
|
line::{LineConfig, LineFramer},
|
||||||
mixed::{MixedTextPlotConfig as MixedFramerConfig, MixedTextPlotFramer},
|
mixed::{MixedTextPlotConfig as MixedFramerConfig, MixedTextPlotFramer},
|
||||||
};
|
};
|
||||||
use xserial_core::protocol::{
|
use pipeview_core::protocol::{
|
||||||
ProtocolDecoder,
|
ProtocolDecoder,
|
||||||
hex::{HexConfig, HexDecoder},
|
hex::{HexConfig, HexDecoder},
|
||||||
mixed::{MixedTextPlotConfig as MixedDecoderConfig, MixedTextPlotDecoder},
|
mixed::{MixedTextPlotConfig as MixedDecoderConfig, MixedTextPlotDecoder},
|
||||||
plot::{PlotConfig, PlotDecoder, PlotFormat, SampleType},
|
plot::{PlotConfig, PlotDecoder, PlotFormat, SampleType},
|
||||||
text::{TextDecoder, TextEncoding},
|
text::{TextDecoder, TextEncoding},
|
||||||
};
|
};
|
||||||
use xserial_core::transport::TransportConfig;
|
use pipeview_core::transport::TransportConfig;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::error::Result;
|
||||||
|
use crate::lua::codec::{LuaDecoder, LuaFramer};
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub enum FramerConfig {
|
pub enum FramerConfig {
|
||||||
@@ -49,6 +52,9 @@ pub enum FramerConfig {
|
|||||||
#[serde(default = "default_max_frame")]
|
#[serde(default = "default_max_frame")]
|
||||||
max_plot_frame: usize,
|
max_plot_frame: usize,
|
||||||
},
|
},
|
||||||
|
Lua {
|
||||||
|
script_path: String,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
fn default_true() -> bool {
|
fn default_true() -> bool {
|
||||||
@@ -68,15 +74,15 @@ fn default_max_frame() -> usize {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl FramerConfig {
|
impl FramerConfig {
|
||||||
pub fn build(self) -> Box<dyn Framer> {
|
pub fn build(self) -> Result<Box<dyn Framer>> {
|
||||||
match self {
|
Ok(match self {
|
||||||
FramerConfig::Line {
|
FramerConfig::Line {
|
||||||
strip_cr,
|
strip_cr,
|
||||||
max_line_len,
|
max_line_len,
|
||||||
} => Box::new(LineFramer::new(LineConfig {
|
} => Box::new(LineFramer::new(LineConfig {
|
||||||
strip_cr,
|
strip_cr,
|
||||||
max_line_len,
|
max_line_len,
|
||||||
})),
|
})) as Box<dyn Framer>,
|
||||||
FramerConfig::Fixed { frame_len } => Box::new(FixedLengthFramer::new(frame_len)),
|
FramerConfig::Fixed { frame_len } => Box::new(FixedLengthFramer::new(frame_len)),
|
||||||
FramerConfig::Length {
|
FramerConfig::Length {
|
||||||
len_bytes,
|
len_bytes,
|
||||||
@@ -99,7 +105,10 @@ impl FramerConfig {
|
|||||||
max_line_len,
|
max_line_len,
|
||||||
max_plot_frame,
|
max_plot_frame,
|
||||||
})),
|
})),
|
||||||
|
FramerConfig::Lua { script_path } => {
|
||||||
|
Box::new(LuaFramer::from_script_path(script_path)?)
|
||||||
}
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -132,6 +141,9 @@ pub enum DecoderConfig {
|
|||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
encoding: TextEncoding,
|
encoding: TextEncoding,
|
||||||
},
|
},
|
||||||
|
Lua {
|
||||||
|
script_path: String,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
fn default_sep() -> String {
|
fn default_sep() -> String {
|
||||||
@@ -142,9 +154,11 @@ fn default_one() -> usize {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl DecoderConfig {
|
impl DecoderConfig {
|
||||||
pub fn build(self) -> Box<dyn ProtocolDecoder> {
|
pub fn build(self) -> Result<Box<dyn ProtocolDecoder>> {
|
||||||
match self {
|
Ok(match self {
|
||||||
DecoderConfig::Text { encoding } => Box::new(TextDecoder::new(encoding)),
|
DecoderConfig::Text { encoding } => {
|
||||||
|
Box::new(TextDecoder::new(encoding)) as Box<dyn ProtocolDecoder>
|
||||||
|
}
|
||||||
DecoderConfig::Hex {
|
DecoderConfig::Hex {
|
||||||
uppercase,
|
uppercase,
|
||||||
separator,
|
separator,
|
||||||
@@ -170,7 +184,10 @@ impl DecoderConfig {
|
|||||||
DecoderConfig::MixedTextPlot { encoding } => {
|
DecoderConfig::MixedTextPlot { encoding } => {
|
||||||
Box::new(MixedTextPlotDecoder::new(MixedDecoderConfig { encoding }))
|
Box::new(MixedTextPlotDecoder::new(MixedDecoderConfig { encoding }))
|
||||||
}
|
}
|
||||||
|
DecoderConfig::Lua { script_path } => {
|
||||||
|
Box::new(LuaDecoder::from_script_path(script_path)?)
|
||||||
}
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -212,10 +229,10 @@ impl Default for SessionConfig {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use xserial_core::protocol::Endian;
|
use pipeview_core::protocol::Endian;
|
||||||
use xserial_core::protocol::plot::{PlotFormat, SampleType};
|
use pipeview_core::protocol::plot::{PlotFormat, SampleType};
|
||||||
use xserial_core::protocol::text::TextEncoding;
|
use pipeview_core::protocol::text::TextEncoding;
|
||||||
use xserial_core::transport::TransportConfig;
|
use pipeview_core::transport::TransportConfig;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn framer_line_serde_roundtrip() {
|
fn framer_line_serde_roundtrip() {
|
||||||
@@ -285,6 +302,16 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn framer_lua_serde() {
|
||||||
|
let cfg: FramerConfig =
|
||||||
|
serde_json::from_str(r#"{"Lua":{"script_path":"/tmp/framer.lua"}}"#).unwrap();
|
||||||
|
match cfg {
|
||||||
|
FramerConfig::Lua { script_path } => assert_eq!(script_path, "/tmp/framer.lua"),
|
||||||
|
_ => panic!("expected Lua"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn decoder_text_serde() {
|
fn decoder_text_serde() {
|
||||||
let cfg: DecoderConfig = serde_json::from_str(r#"{"Text":{"encoding":"Latin1"}}"#).unwrap();
|
let cfg: DecoderConfig = serde_json::from_str(r#"{"Text":{"encoding":"Latin1"}}"#).unwrap();
|
||||||
@@ -345,6 +372,16 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn decoder_lua_serde() {
|
||||||
|
let cfg: DecoderConfig =
|
||||||
|
serde_json::from_str(r#"{"Lua":{"script_path":"/tmp/decoder.lua"}}"#).unwrap();
|
||||||
|
match cfg {
|
||||||
|
DecoderConfig::Lua { script_path } => assert_eq!(script_path, "/tmp/decoder.lua"),
|
||||||
|
_ => panic!("expected Lua"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn session_config_full_roundtrip() {
|
fn session_config_full_roundtrip() {
|
||||||
let json = r#"{
|
let json = r#"{
|
||||||
@@ -388,7 +425,7 @@ mod tests {
|
|||||||
max_plot_frame: 1024,
|
max_plot_frame: 1024,
|
||||||
},
|
},
|
||||||
] {
|
] {
|
||||||
let f = cfg.build();
|
let f = cfg.build().unwrap();
|
||||||
assert_eq!(f.pending_len(), 0);
|
assert_eq!(f.pending_len(), 0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -400,6 +437,7 @@ mod tests {
|
|||||||
encoding: TextEncoding::Utf8
|
encoding: TextEncoding::Utf8
|
||||||
}
|
}
|
||||||
.build()
|
.build()
|
||||||
|
.unwrap()
|
||||||
.name(),
|
.name(),
|
||||||
"Text"
|
"Text"
|
||||||
);
|
);
|
||||||
@@ -411,6 +449,7 @@ mod tests {
|
|||||||
endian: Endian::Big
|
endian: Endian::Big
|
||||||
}
|
}
|
||||||
.build()
|
.build()
|
||||||
|
.unwrap()
|
||||||
.name(),
|
.name(),
|
||||||
"Hex"
|
"Hex"
|
||||||
);
|
);
|
||||||
@@ -422,6 +461,7 @@ mod tests {
|
|||||||
format: PlotFormat::XY
|
format: PlotFormat::XY
|
||||||
}
|
}
|
||||||
.build()
|
.build()
|
||||||
|
.unwrap()
|
||||||
.name(),
|
.name(),
|
||||||
"Plot"
|
"Plot"
|
||||||
);
|
);
|
||||||
@@ -430,6 +470,7 @@ mod tests {
|
|||||||
encoding: TextEncoding::Utf8
|
encoding: TextEncoding::Utf8
|
||||||
}
|
}
|
||||||
.build()
|
.build()
|
||||||
|
.unwrap()
|
||||||
.name(),
|
.name(),
|
||||||
"MixedTextPlot"
|
"MixedTextPlot"
|
||||||
);
|
);
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
use xserial_core::protocol::DecodedData;
|
use pipeview_core::protocol::DecodedData;
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct DecodedEntry {
|
pub struct DecodedEntry {
|
||||||
@@ -28,6 +28,9 @@ impl<T> RingBuffer<T> {
|
|||||||
pub fn iter(&self) -> impl Iterator<Item = &T> {
|
pub fn iter(&self) -> impl Iterator<Item = &T> {
|
||||||
self.buf.iter()
|
self.buf.iter()
|
||||||
}
|
}
|
||||||
|
pub fn get(&self, index: usize) -> Option<&T> {
|
||||||
|
self.buf.get(index)
|
||||||
|
}
|
||||||
pub fn clear(&mut self) {
|
pub fn clear(&mut self) {
|
||||||
self.buf.clear();
|
self.buf.clear();
|
||||||
}
|
}
|
||||||
404
crates/pipeview-client/src/lua/codec.rs
Normal file
404
crates/pipeview-client/src/lua/codec.rs
Normal file
@@ -0,0 +1,404 @@
|
|||||||
|
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 crate::error::{Error, Result};
|
||||||
|
|
||||||
|
pub struct LuaFramer {
|
||||||
|
lua: Lua,
|
||||||
|
feed: RegistryKey,
|
||||||
|
flush: RegistryKey,
|
||||||
|
reset: RegistryKey,
|
||||||
|
pending_len: RegistryKey,
|
||||||
|
script_path: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LuaFramer {
|
||||||
|
pub fn from_script_path(script_path: impl Into<String>) -> Result<Self> {
|
||||||
|
let script_path = script_path.into();
|
||||||
|
let lua = Lua::new();
|
||||||
|
let table = load_script_table(&lua, &script_path)?;
|
||||||
|
let feed = registry_function(&lua, &table, "feed", &script_path)?;
|
||||||
|
let flush = registry_function(&lua, &table, "flush", &script_path)?;
|
||||||
|
let reset = registry_function(&lua, &table, "reset", &script_path)?;
|
||||||
|
let pending_len = registry_function(&lua, &table, "pending_len", &script_path)?;
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
lua,
|
||||||
|
feed,
|
||||||
|
flush,
|
||||||
|
reset,
|
||||||
|
pending_len,
|
||||||
|
script_path,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Framer for LuaFramer {
|
||||||
|
fn feed(&mut self, data: &[u8]) -> Vec<Vec<u8>> {
|
||||||
|
let result = (|| -> mlua::Result<Vec<Vec<u8>>> {
|
||||||
|
let function: Function = self.lua.registry_value(&self.feed)?;
|
||||||
|
let bytes = self.lua.create_string(data)?;
|
||||||
|
frames_from_value(function.call::<Value>(bytes)?)
|
||||||
|
})();
|
||||||
|
|
||||||
|
match result {
|
||||||
|
Ok(frames) => frames,
|
||||||
|
Err(err) => {
|
||||||
|
warn!(script = %self.script_path, error = %err, "lua framer feed failed");
|
||||||
|
Vec::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn flush(&mut self) -> Option<Vec<u8>> {
|
||||||
|
let result = (|| -> mlua::Result<Option<Vec<u8>>> {
|
||||||
|
let function: Function = self.lua.registry_value(&self.flush)?;
|
||||||
|
optional_bytes_from_value(function.call::<Value>(())?)
|
||||||
|
})();
|
||||||
|
|
||||||
|
match result {
|
||||||
|
Ok(frame) => frame,
|
||||||
|
Err(err) => {
|
||||||
|
warn!(script = %self.script_path, error = %err, "lua framer flush failed");
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn reset(&mut self) {
|
||||||
|
let result = (|| -> mlua::Result<()> {
|
||||||
|
let function: Function = self.lua.registry_value(&self.reset)?;
|
||||||
|
function.call::<()>(())
|
||||||
|
})();
|
||||||
|
|
||||||
|
if let Err(err) = result {
|
||||||
|
warn!(script = %self.script_path, error = %err, "lua framer reset failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn pending_len(&self) -> usize {
|
||||||
|
let result = (|| -> mlua::Result<usize> {
|
||||||
|
let function: Function = self.lua.registry_value(&self.pending_len)?;
|
||||||
|
function.call::<usize>(())
|
||||||
|
})();
|
||||||
|
|
||||||
|
match result {
|
||||||
|
Ok(len) => len,
|
||||||
|
Err(err) => {
|
||||||
|
warn!(script = %self.script_path, error = %err, "lua framer pending_len failed");
|
||||||
|
0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct LuaDecoder {
|
||||||
|
lua: Lua,
|
||||||
|
decode: RegistryKey,
|
||||||
|
script_path: String,
|
||||||
|
call_lock: Mutex<()>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LuaDecoder {
|
||||||
|
pub fn from_script_path(script_path: impl Into<String>) -> Result<Self> {
|
||||||
|
let script_path = script_path.into();
|
||||||
|
let lua = Lua::new();
|
||||||
|
let table = load_script_table(&lua, &script_path)?;
|
||||||
|
let decode = registry_function(&lua, &table, "decode", &script_path)?;
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
lua,
|
||||||
|
decode,
|
||||||
|
script_path,
|
||||||
|
call_lock: Mutex::new(()),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ProtocolDecoder for LuaDecoder {
|
||||||
|
fn name(&self) -> &str {
|
||||||
|
"Lua"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn decode(&self, frame: &[u8]) -> Option<DecodedData> {
|
||||||
|
let _guard = self
|
||||||
|
.call_lock
|
||||||
|
.lock()
|
||||||
|
.expect("lua decoder call mutex poisoned");
|
||||||
|
let result = (|| -> mlua::Result<Option<DecodedData>> {
|
||||||
|
let function: Function = self.lua.registry_value(&self.decode)?;
|
||||||
|
let bytes = self.lua.create_string(frame)?;
|
||||||
|
decoded_from_value(function.call::<Value>(bytes)?, frame)
|
||||||
|
})();
|
||||||
|
|
||||||
|
match result {
|
||||||
|
Ok(decoded) => decoded,
|
||||||
|
Err(err) => {
|
||||||
|
warn!(script = %self.script_path, error = %err, "lua decoder decode failed");
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn load_script_table(lua: &Lua, script_path: &str) -> Result<Table> {
|
||||||
|
let script = fs::read_to_string(script_path)?;
|
||||||
|
match lua.load(&script).eval::<Value>()? {
|
||||||
|
Value::Table(table) => Ok(table),
|
||||||
|
other => Err(Error::Other(format!(
|
||||||
|
"lua script '{script_path}' must return a table, got {}",
|
||||||
|
other.type_name()
|
||||||
|
))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn registry_function(
|
||||||
|
lua: &Lua,
|
||||||
|
table: &Table,
|
||||||
|
name: &str,
|
||||||
|
script_path: &str,
|
||||||
|
) -> Result<RegistryKey> {
|
||||||
|
let value: Value = table.get(name)?;
|
||||||
|
match value {
|
||||||
|
Value::Function(function) => Ok(lua.create_registry_value(function)?),
|
||||||
|
other => Err(Error::Other(format!(
|
||||||
|
"lua script '{script_path}' field '{name}' must be a function, got {}",
|
||||||
|
other.type_name()
|
||||||
|
))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn frames_from_value(value: Value) -> mlua::Result<Vec<Vec<u8>>> {
|
||||||
|
match value {
|
||||||
|
Value::Nil => Ok(Vec::new()),
|
||||||
|
Value::String(bytes) => Ok(vec![bytes.as_bytes().to_vec()]),
|
||||||
|
Value::Table(table) => {
|
||||||
|
let mut frames = Vec::new();
|
||||||
|
for value in table.sequence_values::<mlua::String>() {
|
||||||
|
frames.push(value?.as_bytes().to_vec());
|
||||||
|
}
|
||||||
|
Ok(frames)
|
||||||
|
}
|
||||||
|
other => Err(mlua::Error::RuntimeError(format!(
|
||||||
|
"framer feed must return nil, string, or table of strings, got {}",
|
||||||
|
other.type_name()
|
||||||
|
))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn optional_bytes_from_value(value: Value) -> mlua::Result<Option<Vec<u8>>> {
|
||||||
|
match value {
|
||||||
|
Value::Nil => Ok(None),
|
||||||
|
Value::String(bytes) => Ok(Some(bytes.as_bytes().to_vec())),
|
||||||
|
other => Err(mlua::Error::RuntimeError(format!(
|
||||||
|
"framer flush must return nil or string, got {}",
|
||||||
|
other.type_name()
|
||||||
|
))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn decoded_from_value(value: Value, raw: &[u8]) -> mlua::Result<Option<DecodedData>> {
|
||||||
|
match value {
|
||||||
|
Value::Nil => Ok(None),
|
||||||
|
Value::String(text) => Ok(Some(DecodedData::Text(text.to_str()?.to_string()))),
|
||||||
|
Value::Table(table) => decoded_from_table(table, raw).map(Some),
|
||||||
|
other => Err(mlua::Error::RuntimeError(format!(
|
||||||
|
"decoder decode must return nil, string, or table, got {}",
|
||||||
|
other.type_name()
|
||||||
|
))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn decoded_from_table(table: Table, raw: &[u8]) -> mlua::Result<DecodedData> {
|
||||||
|
let kind: String = table.get("kind")?;
|
||||||
|
match kind.as_str() {
|
||||||
|
"text" => Ok(DecodedData::Text(table.get("data")?)),
|
||||||
|
"hex" => Ok(DecodedData::Hex(table.get("data")?)),
|
||||||
|
"binary" => {
|
||||||
|
let bytes: mlua::String = table.get("data")?;
|
||||||
|
Ok(DecodedData::Binary(bytes.as_bytes().to_vec()))
|
||||||
|
}
|
||||||
|
"plot" => Ok(DecodedData::Plot(plot_from_table(table, raw)?)),
|
||||||
|
other => Err(mlua::Error::RuntimeError(format!(
|
||||||
|
"unknown lua decoder kind '{other}'"
|
||||||
|
))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn plot_from_table(table: Table, raw: &[u8]) -> mlua::Result<PlotFrame> {
|
||||||
|
let channels_table: Table = table.get("channels")?;
|
||||||
|
let mut channels = Vec::new();
|
||||||
|
for channel in channels_table.sequence_values::<Table>() {
|
||||||
|
let channel = channel?;
|
||||||
|
let mut samples = Vec::new();
|
||||||
|
for sample in channel.sequence_values::<f64>() {
|
||||||
|
samples.push(sample?);
|
||||||
|
}
|
||||||
|
channels.push(samples);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(PlotFrame {
|
||||||
|
channels,
|
||||||
|
raw: raw.to_vec(),
|
||||||
|
sample_type: match table.get::<Option<String>>("sample_type")?.as_deref() {
|
||||||
|
Some(value) => parse_sample_type(value)?,
|
||||||
|
None => SampleType::F64,
|
||||||
|
},
|
||||||
|
format: match table.get::<Option<String>>("format")?.as_deref() {
|
||||||
|
Some(value) => parse_plot_format(value)?,
|
||||||
|
None => PlotFormat::Interleaved,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_sample_type(value: &str) -> mlua::Result<SampleType> {
|
||||||
|
match value {
|
||||||
|
"i8" | "I8" => Ok(SampleType::I8),
|
||||||
|
"u8" | "U8" => Ok(SampleType::U8),
|
||||||
|
"i16" | "I16" => Ok(SampleType::I16),
|
||||||
|
"u16" | "U16" => Ok(SampleType::U16),
|
||||||
|
"i32" | "I32" => Ok(SampleType::I32),
|
||||||
|
"u32" | "U32" => Ok(SampleType::U32),
|
||||||
|
"i64" | "I64" => Ok(SampleType::I64),
|
||||||
|
"u64" | "U64" => Ok(SampleType::U64),
|
||||||
|
"f32" | "F32" => Ok(SampleType::F32),
|
||||||
|
"f64" | "F64" => Ok(SampleType::F64),
|
||||||
|
other => Err(mlua::Error::RuntimeError(format!(
|
||||||
|
"unknown plot sample_type '{other}'"
|
||||||
|
))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_plot_format(value: &str) -> mlua::Result<PlotFormat> {
|
||||||
|
match value {
|
||||||
|
"Interleaved" | "interleaved" => Ok(PlotFormat::Interleaved),
|
||||||
|
"Block" | "block" => Ok(PlotFormat::Block),
|
||||||
|
"XY" | "xy" => Ok(PlotFormat::XY),
|
||||||
|
other => Err(mlua::Error::RuntimeError(format!(
|
||||||
|
"unknown plot format '{other}'"
|
||||||
|
))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
|
fn write_script(name: &str, script: &str) -> PathBuf {
|
||||||
|
let nonce = SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.unwrap()
|
||||||
|
.as_nanos();
|
||||||
|
let path = std::env::temp_dir().join(format!("pipeview_{name}_{nonce}.lua"));
|
||||||
|
fs::write(&path, script).unwrap();
|
||||||
|
path
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn lua_framer_splits_lines_across_chunks() {
|
||||||
|
let path = write_script(
|
||||||
|
"framer",
|
||||||
|
r#"
|
||||||
|
local buffer = ""
|
||||||
|
return {
|
||||||
|
feed = function(bytes)
|
||||||
|
buffer = buffer .. bytes
|
||||||
|
local frames = {}
|
||||||
|
while true do
|
||||||
|
local i = buffer:find("\n", 1, true)
|
||||||
|
if not i then break end
|
||||||
|
frames[#frames + 1] = buffer:sub(1, i - 1)
|
||||||
|
buffer = buffer:sub(i + 1)
|
||||||
|
end
|
||||||
|
return frames
|
||||||
|
end,
|
||||||
|
flush = function()
|
||||||
|
if #buffer == 0 then return nil end
|
||||||
|
local frame = buffer
|
||||||
|
buffer = ""
|
||||||
|
return frame
|
||||||
|
end,
|
||||||
|
reset = function()
|
||||||
|
buffer = ""
|
||||||
|
end,
|
||||||
|
pending_len = function()
|
||||||
|
return #buffer
|
||||||
|
end,
|
||||||
|
}
|
||||||
|
"#,
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut framer = LuaFramer::from_script_path(path.to_string_lossy()).unwrap();
|
||||||
|
assert_eq!(framer.feed(b"one\nt"), vec![b"one".to_vec()]);
|
||||||
|
assert_eq!(framer.pending_len(), 1);
|
||||||
|
assert_eq!(framer.feed(b"wo\nthree"), vec![b"two".to_vec()]);
|
||||||
|
assert_eq!(framer.flush(), Some(b"three".to_vec()));
|
||||||
|
assert_eq!(framer.pending_len(), 0);
|
||||||
|
|
||||||
|
fs::remove_file(path).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn lua_decoder_maps_text_binary_and_plot() {
|
||||||
|
let path = write_script(
|
||||||
|
"decoder",
|
||||||
|
r#"
|
||||||
|
return {
|
||||||
|
decode = function(frame)
|
||||||
|
if frame == "skip" then
|
||||||
|
return nil
|
||||||
|
elseif frame == "bin" then
|
||||||
|
return { kind = "binary", data = "\1\2" }
|
||||||
|
elseif frame == "plot" then
|
||||||
|
return {
|
||||||
|
kind = "plot",
|
||||||
|
channels = { { 1.0, 2.0 }, { 3.5, 4.5 } },
|
||||||
|
sample_type = "F64",
|
||||||
|
format = "Block",
|
||||||
|
}
|
||||||
|
end
|
||||||
|
return { kind = "text", data = "decoded:" .. frame }
|
||||||
|
end,
|
||||||
|
}
|
||||||
|
"#,
|
||||||
|
);
|
||||||
|
|
||||||
|
let decoder = LuaDecoder::from_script_path(path.to_string_lossy()).unwrap();
|
||||||
|
assert!(decoder.decode(b"skip").is_none());
|
||||||
|
assert!(matches!(
|
||||||
|
decoder.decode(b"abc"),
|
||||||
|
Some(DecodedData::Text(text)) if text == "decoded:abc"
|
||||||
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
decoder.decode(b"bin"),
|
||||||
|
Some(DecodedData::Binary(bytes)) if bytes == vec![1, 2]
|
||||||
|
));
|
||||||
|
match decoder.decode(b"plot").unwrap() {
|
||||||
|
DecodedData::Plot(frame) => {
|
||||||
|
assert_eq!(frame.channels, vec![vec![1.0, 2.0], vec![3.5, 4.5]]);
|
||||||
|
assert_eq!(frame.sample_type, SampleType::F64);
|
||||||
|
assert_eq!(frame.format, PlotFormat::Block);
|
||||||
|
assert_eq!(frame.raw, b"plot");
|
||||||
|
}
|
||||||
|
other => panic!("expected plot, got {other:?}"),
|
||||||
|
}
|
||||||
|
|
||||||
|
fs::remove_file(path).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn lua_framer_requires_expected_functions() {
|
||||||
|
let path = write_script("bad_framer", "return { feed = function() return {} end }");
|
||||||
|
let result = LuaFramer::from_script_path(path.to_string_lossy());
|
||||||
|
assert!(result.is_err());
|
||||||
|
fs::remove_file(path).unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ use mlua::{Lua, LuaSerdeExt, Result as LuaResult, Table, Value};
|
|||||||
use crate::config::SessionConfig;
|
use crate::config::SessionConfig;
|
||||||
use crate::manager::SessionManager;
|
use crate::manager::SessionManager;
|
||||||
|
|
||||||
|
pub(crate) mod codec;
|
||||||
mod session_api;
|
mod session_api;
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
@@ -80,9 +81,9 @@ pub fn register(lua: &Lua) -> LuaResult<()> {
|
|||||||
|
|
||||||
pub fn register_with_manager(lua: &Lua, manager: SessionManager) -> LuaResult<()> {
|
pub fn register_with_manager(lua: &Lua, manager: SessionManager) -> LuaResult<()> {
|
||||||
let runtime = LuaRuntime::new(manager);
|
let runtime = LuaRuntime::new(manager);
|
||||||
let xserial = lua.create_table()?;
|
let pipeview = lua.create_table()?;
|
||||||
|
|
||||||
xserial.set("open", {
|
pipeview.set("open", {
|
||||||
let runtime = runtime.clone();
|
let runtime = runtime.clone();
|
||||||
lua.create_async_function(move |lua, config: Table| {
|
lua.create_async_function(move |lua, config: Table| {
|
||||||
let runtime = runtime.clone();
|
let runtime = runtime.clone();
|
||||||
@@ -95,15 +96,15 @@ pub fn register_with_manager(lua: &Lua, manager: SessionManager) -> LuaResult<()
|
|||||||
})?
|
})?
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
xserial.set(
|
pipeview.set(
|
||||||
"list_ports",
|
"list_ports",
|
||||||
lua.create_function(|_, ()| {
|
lua.create_function(|_, ()| {
|
||||||
let ports = xserial_core::transport::serial::SerialTransport::list_ports();
|
let ports = pipeview_core::transport::serial::SerialTransport::list_ports();
|
||||||
Ok(ports.into_iter().map(|p| p.port_name).collect::<Vec<_>>())
|
Ok(ports.into_iter().map(|p| p.port_name).collect::<Vec<_>>())
|
||||||
})?,
|
})?,
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
xserial.set("sleep", {
|
pipeview.set("sleep", {
|
||||||
let runtime = runtime.clone();
|
let runtime = runtime.clone();
|
||||||
lua.create_async_function(move |lua, ms: u64| {
|
lua.create_async_function(move |lua, ms: u64| {
|
||||||
let runtime = runtime.clone();
|
let runtime = runtime.clone();
|
||||||
@@ -115,7 +116,7 @@ pub fn register_with_manager(lua: &Lua, manager: SessionManager) -> LuaResult<()
|
|||||||
})?
|
})?
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
xserial.set("poll", {
|
pipeview.set("poll", {
|
||||||
let runtime = runtime.clone();
|
let runtime = runtime.clone();
|
||||||
lua.create_async_function(move |lua, limit_per_session: Option<usize>| {
|
lua.create_async_function(move |lua, limit_per_session: Option<usize>| {
|
||||||
let runtime = runtime.clone();
|
let runtime = runtime.clone();
|
||||||
@@ -123,7 +124,7 @@ pub fn register_with_manager(lua: &Lua, manager: SessionManager) -> LuaResult<()
|
|||||||
})?
|
})?
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
xserial.set(
|
pipeview.set(
|
||||||
"log",
|
"log",
|
||||||
lua.create_function(|_, msg: String| {
|
lua.create_function(|_, msg: String| {
|
||||||
tracing::info!("[lua] {}", msg);
|
tracing::info!("[lua] {}", msg);
|
||||||
@@ -131,7 +132,7 @@ pub fn register_with_manager(lua: &Lua, manager: SessionManager) -> LuaResult<()
|
|||||||
})?,
|
})?,
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
lua.globals().set("xserial", xserial)?;
|
lua.globals().set("pipeview", pipeview)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -145,9 +146,9 @@ mod tests {
|
|||||||
let lua = Lua::new();
|
let lua = Lua::new();
|
||||||
register(&lua).unwrap();
|
register(&lua).unwrap();
|
||||||
|
|
||||||
let xserial: Table = lua.globals().get("xserial").unwrap();
|
let pipeview: Table = lua.globals().get("pipeview").unwrap();
|
||||||
for name in ["open", "list_ports", "sleep", "poll", "log"] {
|
for name in ["open", "list_ports", "sleep", "poll", "log"] {
|
||||||
let value: Value = xserial.get(name).unwrap();
|
let value: Value = pipeview.get(name).unwrap();
|
||||||
assert!(
|
assert!(
|
||||||
matches!(value, Value::Function(_)),
|
matches!(value, Value::Function(_)),
|
||||||
"{name} should be a function"
|
"{name} should be a function"
|
||||||
@@ -161,8 +162,8 @@ mod tests {
|
|||||||
let manager = SessionManager::new();
|
let manager = SessionManager::new();
|
||||||
register_with_manager(&lua, manager.clone()).unwrap();
|
register_with_manager(&lua, manager.clone()).unwrap();
|
||||||
|
|
||||||
let xserial: Table = lua.globals().get("xserial").unwrap();
|
let pipeview: Table = lua.globals().get("pipeview").unwrap();
|
||||||
let open: Value = xserial.get("open").unwrap();
|
let open: Value = pipeview.get("open").unwrap();
|
||||||
assert!(matches!(open, Value::Function(_)));
|
assert!(matches!(open, Value::Function(_)));
|
||||||
assert_eq!(manager.count(), 0);
|
assert_eq!(manager.count(), 0);
|
||||||
}
|
}
|
||||||
@@ -6,9 +6,9 @@ use std::sync::{
|
|||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use mlua::{Function, Lua, LuaSerdeExt, RegistryKey, UserData, UserDataMethods, Value, Variadic};
|
use mlua::{Function, Lua, LuaSerdeExt, RegistryKey, UserData, UserDataMethods, Value, Variadic};
|
||||||
|
use pipeview_core::protocol::DecodedData;
|
||||||
use tokio::sync::mpsc;
|
use tokio::sync::mpsc;
|
||||||
use tokio::task::JoinHandle;
|
use tokio::task::JoinHandle;
|
||||||
use xserial_core::protocol::DecodedData;
|
|
||||||
|
|
||||||
use crate::config::SessionConfig;
|
use crate::config::SessionConfig;
|
||||||
use crate::lua::LuaRuntime;
|
use crate::lua::LuaRuntime;
|
||||||
@@ -224,12 +224,12 @@ impl CallbackState {
|
|||||||
|
|
||||||
impl Drop for CallbackState {
|
impl Drop for CallbackState {
|
||||||
fn drop(&mut self) {
|
fn drop(&mut self) {
|
||||||
if let Ok(mut relay_task) = self.relay_task.lock() {
|
if let Ok(mut relay_task) = self.relay_task.lock()
|
||||||
if let Some(task) = relay_task.take() {
|
&& let Some(task) = relay_task.take()
|
||||||
|
{
|
||||||
task.abort();
|
task.abort();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl UserData for LuaSessionHandle {
|
impl UserData for LuaSessionHandle {
|
||||||
@@ -11,8 +11,8 @@ use tokio::sync::{Mutex, broadcast, mpsc};
|
|||||||
use tokio::task::JoinHandle;
|
use tokio::task::JoinHandle;
|
||||||
use tracing::{debug, error, info, warn};
|
use tracing::{debug, error, info, warn};
|
||||||
|
|
||||||
use xserial_core::pipeline::{MultiPipeline, Pipeline};
|
use pipeview_core::pipeline::{MultiPipeline, Pipeline};
|
||||||
use xserial_core::transport::Connection;
|
use pipeview_core::transport::Connection;
|
||||||
|
|
||||||
use crate::cmd::SessionCmd;
|
use crate::cmd::SessionCmd;
|
||||||
use crate::config::SessionConfig;
|
use crate::config::SessionConfig;
|
||||||
@@ -138,6 +138,22 @@ impl SessionHandle {
|
|||||||
.map_err(|_| String::from("session closed"))
|
.map_err(|_| String::from("session closed"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn set_dtr(&self, state: bool) -> Result<(), String> {
|
||||||
|
self.inner
|
||||||
|
.cmd_tx
|
||||||
|
.send(SessionCmd::SetDtr(state))
|
||||||
|
.await
|
||||||
|
.map_err(|_| String::from("session closed"))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn set_rts(&self, state: bool) -> Result<(), String> {
|
||||||
|
self.inner
|
||||||
|
.cmd_tx
|
||||||
|
.send(SessionCmd::SetRts(state))
|
||||||
|
.await
|
||||||
|
.map_err(|_| String::from("session closed"))
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn read(&self, timeout_ms: u64) -> Option<DecodedEntry> {
|
pub async fn read(&self, timeout_ms: u64) -> Option<DecodedEntry> {
|
||||||
let mut rx = self.inner.event_tx.subscribe();
|
let mut rx = self.inner.event_tx.subscribe();
|
||||||
let deadline = Duration::from_millis(timeout_ms);
|
let deadline = Duration::from_millis(timeout_ms);
|
||||||
@@ -176,13 +192,13 @@ impl Drop for SessionHandle {
|
|||||||
fn drop(&mut self) {
|
fn drop(&mut self) {
|
||||||
if Arc::strong_count(&self.inner) == 1 {
|
if Arc::strong_count(&self.inner) == 1 {
|
||||||
self.inner.request_close_nonblocking();
|
self.inner.request_close_nonblocking();
|
||||||
if let Ok(mut task) = self.inner.task.try_lock() {
|
if let Ok(mut task) = self.inner.task.try_lock()
|
||||||
if let Some(task) = task.take() {
|
&& let Some(task) = task.take()
|
||||||
|
{
|
||||||
task.abort();
|
task.abort();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct Session {
|
pub struct Session {
|
||||||
@@ -190,6 +206,7 @@ pub struct Session {
|
|||||||
config: SessionConfig,
|
config: SessionConfig,
|
||||||
conn: Option<Connection>,
|
conn: Option<Connection>,
|
||||||
pipeline: MultiPipeline,
|
pipeline: MultiPipeline,
|
||||||
|
pipeline_build_error: Option<String>,
|
||||||
history: RingBuffer<DecodedEntry>,
|
history: RingBuffer<DecodedEntry>,
|
||||||
cmd_rx: mpsc::Receiver<SessionCmd>,
|
cmd_rx: mpsc::Receiver<SessionCmd>,
|
||||||
event_tx: broadcast::Sender<SessionEvent>,
|
event_tx: broadcast::Sender<SessionEvent>,
|
||||||
@@ -201,9 +218,17 @@ impl Session {
|
|||||||
pub fn spawn(id: SessionId, config: SessionConfig) -> SessionHandle {
|
pub fn spawn(id: SessionId, config: SessionConfig) -> SessionHandle {
|
||||||
let (cmd_tx, cmd_rx) = mpsc::channel(32);
|
let (cmd_tx, cmd_rx) = mpsc::channel(32);
|
||||||
let (event_tx, _) = broadcast::channel(256);
|
let (event_tx, _) = broadcast::channel(256);
|
||||||
|
let (pipeline, pipeline_build_error) = match Self::build_pipeline(&config) {
|
||||||
|
Ok(pipeline) => (pipeline, None),
|
||||||
|
Err(err) => (
|
||||||
|
MultiPipeline::new(),
|
||||||
|
Some(format!("pipeline build failed: {err}")),
|
||||||
|
),
|
||||||
|
};
|
||||||
let mut session = Self {
|
let mut session = Self {
|
||||||
id,
|
id,
|
||||||
pipeline: Self::build_pipeline(&config),
|
pipeline,
|
||||||
|
pipeline_build_error,
|
||||||
history: RingBuffer::new(config.history_limit),
|
history: RingBuffer::new(config.history_limit),
|
||||||
config,
|
config,
|
||||||
conn: None,
|
conn: None,
|
||||||
@@ -217,22 +242,29 @@ impl Session {
|
|||||||
SessionHandle::new(inner)
|
SessionHandle::new(inner)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn build_pipeline(config: &SessionConfig) -> MultiPipeline {
|
fn build_pipeline(config: &SessionConfig) -> crate::Result<MultiPipeline> {
|
||||||
let mut pipelines = MultiPipeline::new();
|
let mut pipelines = MultiPipeline::new();
|
||||||
for pipeline in &config.pipelines {
|
for pipeline in &config.pipelines {
|
||||||
pipelines.add(Pipeline::new(
|
pipelines.add(Pipeline::new(
|
||||||
pipeline.name.clone(),
|
pipeline.name.clone(),
|
||||||
pipeline.framer.clone().build(),
|
pipeline.framer.clone().build()?,
|
||||||
pipeline.decoder.clone().build(),
|
pipeline.decoder.clone().build()?,
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
pipelines
|
Ok(pipelines)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn run(&mut self) {
|
async fn run(&mut self) {
|
||||||
info!(session_id = self.id, "session started");
|
info!(session_id = self.id, "session started");
|
||||||
|
|
||||||
if let Err(err) = self.connect().await {
|
if let Some(message) = self.pipeline_build_error.take() {
|
||||||
|
self.emit_error(message);
|
||||||
|
self.desired_connected = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.desired_connected
|
||||||
|
&& let Err(err) = self.connect().await
|
||||||
|
{
|
||||||
warn!(session_id = self.id, error = %err, "initial connect failed");
|
warn!(session_id = self.id, error = %err, "initial connect failed");
|
||||||
self.emit_error(format!("connect failed: {err}"));
|
self.emit_error(format!("connect failed: {err}"));
|
||||||
}
|
}
|
||||||
@@ -272,11 +304,11 @@ impl Session {
|
|||||||
}
|
}
|
||||||
Some(SessionCmd::Connect) => {
|
Some(SessionCmd::Connect) => {
|
||||||
self.desired_connected = true;
|
self.desired_connected = true;
|
||||||
if self.conn.is_none() {
|
if self.conn.is_none()
|
||||||
if let Err(err) = self.connect().await {
|
&& let Err(err) = self.connect().await
|
||||||
|
{
|
||||||
self.emit_error(format!("connect failed: {err}"));
|
self.emit_error(format!("connect failed: {err}"));
|
||||||
}
|
}
|
||||||
}
|
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
Some(SessionCmd::Disconnect) => {
|
Some(SessionCmd::Disconnect) => {
|
||||||
@@ -304,6 +336,14 @@ impl Session {
|
|||||||
self.handle_reconfigure(config).await;
|
self.handle_reconfigure(config).await;
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
Some(SessionCmd::SetDtr(state)) => {
|
||||||
|
self.handle_set_dtr(state);
|
||||||
|
true
|
||||||
|
}
|
||||||
|
Some(SessionCmd::SetRts(state)) => {
|
||||||
|
self.handle_set_rts(state);
|
||||||
|
true
|
||||||
|
}
|
||||||
None => false,
|
None => false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -322,9 +362,16 @@ impl Session {
|
|||||||
|
|
||||||
async fn handle_reconfigure(&mut self, config: SessionConfig) {
|
async fn handle_reconfigure(&mut self, config: SessionConfig) {
|
||||||
info!(session_id = self.id, "reconfiguring session");
|
info!(session_id = self.id, "reconfiguring session");
|
||||||
|
let pipeline = match Self::build_pipeline(&config) {
|
||||||
|
Ok(pipeline) => pipeline,
|
||||||
|
Err(err) => {
|
||||||
|
self.emit_error(format!("pipeline build failed: {err}"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
self.disconnect(true).await;
|
self.disconnect(true).await;
|
||||||
self.config = config;
|
self.config = config;
|
||||||
self.pipeline = Self::build_pipeline(&self.config);
|
self.pipeline = pipeline;
|
||||||
self.history = RingBuffer::new(self.config.history_limit);
|
self.history = RingBuffer::new(self.config.history_limit);
|
||||||
|
|
||||||
if let Err(err) = self.connect().await {
|
if let Err(err) = self.connect().await {
|
||||||
@@ -332,6 +379,28 @@ impl Session {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn handle_set_dtr(&mut self, state: bool) {
|
||||||
|
match self.conn.as_mut() {
|
||||||
|
Some(conn) => {
|
||||||
|
if let Err(err) = conn.set_dtr(state) {
|
||||||
|
self.emit_error(format!("set_dtr: {err}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => self.emit_error(String::from("set_dtr: session not connected")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_set_rts(&mut self, state: bool) {
|
||||||
|
match self.conn.as_mut() {
|
||||||
|
Some(conn) => {
|
||||||
|
if let Err(err) = conn.set_rts(state) {
|
||||||
|
self.emit_error(format!("set_rts: {err}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => self.emit_error(String::from("set_rts: session not connected")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async fn handle_read_result(
|
async fn handle_read_result(
|
||||||
&mut self,
|
&mut self,
|
||||||
result: Result<Vec<DecodedEntry>, std::io::Error>,
|
result: Result<Vec<DecodedEntry>, std::io::Error>,
|
||||||
@@ -430,8 +499,8 @@ async fn try_read(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn to_io_error(err: xserial_core::error::Error) -> std::io::Error {
|
fn to_io_error(err: pipeview_core::error::Error) -> std::io::Error {
|
||||||
io::Error::new(io::ErrorKind::Other, err.to_string())
|
io::Error::other(err.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -441,7 +510,7 @@ mod tests {
|
|||||||
|
|
||||||
fn tcp_config() -> SessionConfig {
|
fn tcp_config() -> SessionConfig {
|
||||||
SessionConfig {
|
SessionConfig {
|
||||||
transport: xserial_core::transport::TransportConfig::Tcp {
|
transport: pipeview_core::transport::TransportConfig::Tcp {
|
||||||
addr: "127.0.0.1:1".into(),
|
addr: "127.0.0.1:1".into(),
|
||||||
},
|
},
|
||||||
pipelines: vec![PipelineConfig {
|
pipelines: vec![PipelineConfig {
|
||||||
@@ -451,7 +520,7 @@ mod tests {
|
|||||||
max_line_len: 65536,
|
max_line_len: 65536,
|
||||||
},
|
},
|
||||||
decoder: DecoderConfig::Text {
|
decoder: DecoderConfig::Text {
|
||||||
encoding: xserial_core::protocol::text::TextEncoding::Utf8,
|
encoding: pipeview_core::protocol::text::TextEncoding::Utf8,
|
||||||
},
|
},
|
||||||
}],
|
}],
|
||||||
..Default::default()
|
..Default::default()
|
||||||
@@ -507,6 +576,7 @@ mod tests {
|
|||||||
let handle = Session::spawn(2, tcp_config());
|
let handle = Session::spawn(2, tcp_config());
|
||||||
tokio::time::sleep(Duration::from_millis(200)).await;
|
tokio::time::sleep(Duration::from_millis(200)).await;
|
||||||
let _ = handle.send(b"data".to_vec()).await;
|
let _ = handle.send(b"data".to_vec()).await;
|
||||||
|
let _ = handle.close().await;
|
||||||
handle.join().await;
|
handle.join().await;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
use std::sync::Once;
|
use std::sync::Once;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
use std::{fs, path::PathBuf};
|
||||||
|
|
||||||
use mlua::Lua;
|
use mlua::Lua;
|
||||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||||
@@ -10,12 +11,22 @@ static INIT: Once = Once::new();
|
|||||||
fn init_tracing() {
|
fn init_tracing() {
|
||||||
INIT.call_once(|| {
|
INIT.call_once(|| {
|
||||||
tracing_subscriber::fmt()
|
tracing_subscriber::fmt()
|
||||||
.with_env_filter("xserial=trace")
|
.with_env_filter("pipeview=trace")
|
||||||
.try_init()
|
.try_init()
|
||||||
.ok();
|
.ok();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn write_temp_lua(name: &str, script: &str) -> PathBuf {
|
||||||
|
let nonce = std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.unwrap()
|
||||||
|
.as_nanos();
|
||||||
|
let path = std::env::temp_dir().join(format!("pipeview_{name}_{nonce}.lua"));
|
||||||
|
fs::write(&path, script).unwrap();
|
||||||
|
path
|
||||||
|
}
|
||||||
|
|
||||||
fn text_pipeline_lua() -> &'static str {
|
fn text_pipeline_lua() -> &'static str {
|
||||||
r#"
|
r#"
|
||||||
{
|
{
|
||||||
@@ -36,18 +47,18 @@ fn hex_pipeline_lua() -> &'static str {
|
|||||||
"#
|
"#
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── xserial.sleep / xserial.log ──────────────────────────────────
|
// ── pipeview.sleep / pipeview.log ──────────────────────────────────
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn lua_sleep_and_log() {
|
async fn lua_sleep_and_log() {
|
||||||
let lua = Lua::new();
|
let lua = Lua::new();
|
||||||
xserial_client::lua::register(&lua).unwrap();
|
pipeview_client::lua::register(&lua).unwrap();
|
||||||
|
|
||||||
lua.load(
|
lua.load(
|
||||||
r#"
|
r#"
|
||||||
xserial.log("test start")
|
pipeview.log("test start")
|
||||||
xserial.sleep(50)
|
pipeview.sleep(50)
|
||||||
xserial.log("test end")
|
pipeview.log("test end")
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
.exec_async()
|
.exec_async()
|
||||||
@@ -55,15 +66,15 @@ async fn lua_sleep_and_log() {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── xserial.list_ports ───────────────────────────────────────────
|
// ── pipeview.list_ports ───────────────────────────────────────────
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn lua_list_ports() {
|
async fn lua_list_ports() {
|
||||||
let lua = Lua::new();
|
let lua = Lua::new();
|
||||||
xserial_client::lua::register(&lua).unwrap();
|
pipeview_client::lua::register(&lua).unwrap();
|
||||||
|
|
||||||
let result: Vec<String> = lua
|
let result: Vec<String> = lua
|
||||||
.load(r#"return xserial.list_ports()"#)
|
.load(r#"return pipeview.list_ports()"#)
|
||||||
.eval_async()
|
.eval_async()
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -73,7 +84,7 @@ async fn lua_list_ports() {
|
|||||||
let _ = result;
|
let _ = result;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── xserial.open + session:send / session:read / session:close ────
|
// ── pipeview.open + session:send / session:read / session:close ────
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn lua_session_open_send_read_close() {
|
async fn lua_session_open_send_read_close() {
|
||||||
@@ -91,11 +102,11 @@ async fn lua_session_open_send_read_close() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
let lua = Lua::new();
|
let lua = Lua::new();
|
||||||
xserial_client::lua::register(&lua).unwrap();
|
pipeview_client::lua::register(&lua).unwrap();
|
||||||
|
|
||||||
let script = format!(
|
let script = format!(
|
||||||
r#"
|
r#"
|
||||||
local sess = xserial.open({{
|
local sess = pipeview.open({{
|
||||||
transport = {{ Tcp = {{ addr = "{}" }} }},
|
transport = {{ Tcp = {{ addr = "{}" }} }},
|
||||||
pipelines = {{{}}}
|
pipelines = {{{}}}
|
||||||
}})
|
}})
|
||||||
@@ -130,11 +141,11 @@ async fn lua_session_read_timeout() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
let lua = Lua::new();
|
let lua = Lua::new();
|
||||||
xserial_client::lua::register(&lua).unwrap();
|
pipeview_client::lua::register(&lua).unwrap();
|
||||||
|
|
||||||
let script = format!(
|
let script = format!(
|
||||||
r#"
|
r#"
|
||||||
local sess = xserial.open({{
|
local sess = pipeview.open({{
|
||||||
transport = {{ Tcp = {{ addr = "{}" }} }},
|
transport = {{ Tcp = {{ addr = "{}" }} }},
|
||||||
pipelines = {{{}}}
|
pipelines = {{{}}}
|
||||||
}})
|
}})
|
||||||
@@ -151,7 +162,7 @@ async fn lua_session_read_timeout() {
|
|||||||
lua.load(&script).exec_async().await.unwrap();
|
lua.load(&script).exec_async().await.unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── xserial.open with hex decoder ────────────────────────────────
|
// ── pipeview.open with hex decoder ────────────────────────────────
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn lua_session_hex_decoder() {
|
async fn lua_session_hex_decoder() {
|
||||||
@@ -165,11 +176,11 @@ async fn lua_session_hex_decoder() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
let lua = Lua::new();
|
let lua = Lua::new();
|
||||||
xserial_client::lua::register(&lua).unwrap();
|
pipeview_client::lua::register(&lua).unwrap();
|
||||||
|
|
||||||
let script = format!(
|
let script = format!(
|
||||||
r#"
|
r#"
|
||||||
local sess = xserial.open({{
|
local sess = pipeview.open({{
|
||||||
transport = {{ Tcp = {{ addr = "{}" }} }},
|
transport = {{ Tcp = {{ addr = "{}" }} }},
|
||||||
pipelines = {{{}}}
|
pipelines = {{{}}}
|
||||||
}})
|
}})
|
||||||
@@ -190,6 +201,102 @@ async fn lua_session_hex_decoder() {
|
|||||||
server.await.unwrap();
|
server.await.unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── pipeview.open with Lua framer + Lua decoder ───────────────────
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn lua_session_custom_lua_pipeline() {
|
||||||
|
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||||
|
let addr = listener.local_addr().unwrap().to_string();
|
||||||
|
|
||||||
|
let framer_path = write_temp_lua(
|
||||||
|
"custom_framer",
|
||||||
|
r#"
|
||||||
|
local buffer = ""
|
||||||
|
return {
|
||||||
|
feed = function(bytes)
|
||||||
|
buffer = buffer .. bytes
|
||||||
|
local frames = {}
|
||||||
|
while true do
|
||||||
|
local i = buffer:find("|", 1, true)
|
||||||
|
if not i then break end
|
||||||
|
frames[#frames + 1] = buffer:sub(1, i - 1)
|
||||||
|
buffer = buffer:sub(i + 1)
|
||||||
|
end
|
||||||
|
return frames
|
||||||
|
end,
|
||||||
|
flush = function()
|
||||||
|
if #buffer == 0 then return nil end
|
||||||
|
local frame = buffer
|
||||||
|
buffer = ""
|
||||||
|
return frame
|
||||||
|
end,
|
||||||
|
reset = function()
|
||||||
|
buffer = ""
|
||||||
|
end,
|
||||||
|
pending_len = function()
|
||||||
|
return #buffer
|
||||||
|
end,
|
||||||
|
}
|
||||||
|
"#,
|
||||||
|
);
|
||||||
|
let decoder_path = write_temp_lua(
|
||||||
|
"custom_decoder",
|
||||||
|
r#"
|
||||||
|
return {
|
||||||
|
decode = function(frame)
|
||||||
|
return { kind = "text", data = string.upper(frame) }
|
||||||
|
end,
|
||||||
|
}
|
||||||
|
"#,
|
||||||
|
);
|
||||||
|
|
||||||
|
let server = tokio::spawn(async move {
|
||||||
|
let (mut stream, _) = listener.accept().await.unwrap();
|
||||||
|
stream.write_all(b"alpha|").await.unwrap();
|
||||||
|
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||||
|
stream.write_all(b"beta|").await.unwrap();
|
||||||
|
tokio::time::sleep(Duration::from_millis(200)).await;
|
||||||
|
});
|
||||||
|
|
||||||
|
let lua = Lua::new();
|
||||||
|
pipeview_client::lua::register(&lua).unwrap();
|
||||||
|
|
||||||
|
let script = format!(
|
||||||
|
r#"
|
||||||
|
local sess = pipeview.open({{
|
||||||
|
transport = {{ Tcp = {{ addr = "{}" }} }},
|
||||||
|
pipelines = {{
|
||||||
|
{{
|
||||||
|
name = "custom",
|
||||||
|
framer = {{ Lua = {{ script_path = [[{}]] }} }},
|
||||||
|
decoder = {{ Lua = {{ script_path = [[{}]] }} }}
|
||||||
|
}}
|
||||||
|
}}
|
||||||
|
}})
|
||||||
|
|
||||||
|
local r1 = sess:read(5000)
|
||||||
|
assert(r1 ~= nil, "expected first custom frame")
|
||||||
|
assert(r1.pipeline == "custom", "expected custom pipeline, got " .. tostring(r1.pipeline))
|
||||||
|
assert(r1.kind == "text", "expected text kind, got " .. tostring(r1.kind))
|
||||||
|
assert(r1.data == "ALPHA", "expected ALPHA, got " .. tostring(r1.data))
|
||||||
|
|
||||||
|
local r2 = sess:read(5000)
|
||||||
|
assert(r2 ~= nil, "expected second custom frame")
|
||||||
|
assert(r2.data == "BETA", "expected BETA, got " .. tostring(r2.data))
|
||||||
|
|
||||||
|
sess:close()
|
||||||
|
"#,
|
||||||
|
addr,
|
||||||
|
framer_path.display(),
|
||||||
|
decoder_path.display()
|
||||||
|
);
|
||||||
|
|
||||||
|
lua.load(&script).exec_async().await.unwrap();
|
||||||
|
server.await.unwrap();
|
||||||
|
fs::remove_file(framer_path).unwrap();
|
||||||
|
fs::remove_file(decoder_path).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
// ── session:on_data callback ─────────────────────────────────────
|
// ── session:on_data callback ─────────────────────────────────────
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
@@ -204,12 +311,12 @@ async fn lua_session_on_data_callback() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
let lua = Lua::new();
|
let lua = Lua::new();
|
||||||
xserial_client::lua::register(&lua).unwrap();
|
pipeview_client::lua::register(&lua).unwrap();
|
||||||
|
|
||||||
let script = format!(
|
let script = format!(
|
||||||
r#"
|
r#"
|
||||||
local received = {{}}
|
local received = {{}}
|
||||||
local sess = xserial.open({{
|
local sess = pipeview.open({{
|
||||||
transport = {{ Tcp = {{ addr = "{}" }} }},
|
transport = {{ Tcp = {{ addr = "{}" }} }},
|
||||||
pipelines = {{{}}}
|
pipelines = {{{}}}
|
||||||
}})
|
}})
|
||||||
@@ -220,8 +327,8 @@ async fn lua_session_on_data_callback() {
|
|||||||
|
|
||||||
for _ = 1, 50 do
|
for _ = 1, 50 do
|
||||||
if #received < 2 then
|
if #received < 2 then
|
||||||
xserial.poll(1)
|
pipeview.poll(1)
|
||||||
xserial.sleep(10)
|
pipeview.sleep(10)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -264,11 +371,11 @@ async fn lua_session_reconfigure() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
let lua = Lua::new();
|
let lua = Lua::new();
|
||||||
xserial_client::lua::register(&lua).unwrap();
|
pipeview_client::lua::register(&lua).unwrap();
|
||||||
|
|
||||||
let script = format!(
|
let script = format!(
|
||||||
r#"
|
r#"
|
||||||
local sess = xserial.open({{
|
local sess = pipeview.open({{
|
||||||
transport = {{ Tcp = {{ addr = "{}" }} }},
|
transport = {{ Tcp = {{ addr = "{}" }} }},
|
||||||
pipelines = {{{}}}
|
pipelines = {{{}}}
|
||||||
}})
|
}})
|
||||||
@@ -313,11 +420,11 @@ async fn lua_session_next_event_stream() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
let lua = Lua::new();
|
let lua = Lua::new();
|
||||||
xserial_client::lua::register(&lua).unwrap();
|
pipeview_client::lua::register(&lua).unwrap();
|
||||||
|
|
||||||
let script = format!(
|
let script = format!(
|
||||||
r#"
|
r#"
|
||||||
local sess = xserial.open({{
|
local sess = pipeview.open({{
|
||||||
transport = {{ Tcp = {{ addr = "{}" }} }},
|
transport = {{ Tcp = {{ addr = "{}" }} }},
|
||||||
pipelines = {{{}}}
|
pipelines = {{{}}}
|
||||||
}})
|
}})
|
||||||
@@ -343,7 +450,7 @@ async fn lua_session_next_event_stream() {
|
|||||||
server.await.unwrap();
|
server.await.unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── session:off + xserial.poll ───────────────────────────────────
|
// ── session:off + pipeview.poll ───────────────────────────────────
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn lua_session_off_stops_future_callbacks() {
|
async fn lua_session_off_stops_future_callbacks() {
|
||||||
@@ -359,12 +466,12 @@ async fn lua_session_off_stops_future_callbacks() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
let lua = Lua::new();
|
let lua = Lua::new();
|
||||||
xserial_client::lua::register(&lua).unwrap();
|
pipeview_client::lua::register(&lua).unwrap();
|
||||||
|
|
||||||
let script = format!(
|
let script = format!(
|
||||||
r#"
|
r#"
|
||||||
local received = {{}}
|
local received = {{}}
|
||||||
local sess = xserial.open({{
|
local sess = pipeview.open({{
|
||||||
transport = {{ Tcp = {{ addr = "{}" }} }},
|
transport = {{ Tcp = {{ addr = "{}" }} }},
|
||||||
pipelines = {{{}}}
|
pipelines = {{{}}}
|
||||||
}})
|
}})
|
||||||
@@ -375,8 +482,8 @@ async fn lua_session_off_stops_future_callbacks() {
|
|||||||
|
|
||||||
for _ = 1, 50 do
|
for _ = 1, 50 do
|
||||||
if #received == 0 then
|
if #received == 0 then
|
||||||
xserial.poll(1)
|
pipeview.poll(1)
|
||||||
xserial.sleep(10)
|
pipeview.sleep(10)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -384,8 +491,8 @@ async fn lua_session_off_stops_future_callbacks() {
|
|||||||
assert(received[1] == "text:first")
|
assert(received[1] == "text:first")
|
||||||
assert(sess:off(token) == true)
|
assert(sess:off(token) == true)
|
||||||
|
|
||||||
xserial.sleep(250)
|
pipeview.sleep(250)
|
||||||
xserial.poll(10)
|
pipeview.poll(10)
|
||||||
|
|
||||||
assert(#received == 1, "callback should not fire after off")
|
assert(#received == 1, "callback should not fire after off")
|
||||||
assert(sess:off(token) == false)
|
assert(sess:off(token) == false)
|
||||||
@@ -3,11 +3,11 @@ use std::time::Duration;
|
|||||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||||
use tokio::net::TcpListener;
|
use tokio::net::TcpListener;
|
||||||
|
|
||||||
use xserial_client::SessionManager;
|
use pipeview_client::SessionManager;
|
||||||
use xserial_client::config::{DecoderConfig, FramerConfig, PipelineConfig, SessionConfig};
|
use pipeview_client::config::{DecoderConfig, FramerConfig, PipelineConfig, SessionConfig};
|
||||||
use xserial_client::session::{Session, SessionEvent};
|
use pipeview_client::session::{Session, SessionEvent};
|
||||||
use xserial_core::protocol::DecodedData;
|
use pipeview_core::protocol::DecodedData;
|
||||||
use xserial_core::transport::TransportConfig;
|
use pipeview_core::transport::TransportConfig;
|
||||||
|
|
||||||
fn tcp_config(addr: String) -> SessionConfig {
|
fn tcp_config(addr: String) -> SessionConfig {
|
||||||
SessionConfig {
|
SessionConfig {
|
||||||
@@ -19,7 +19,7 @@ fn tcp_config(addr: String) -> SessionConfig {
|
|||||||
max_line_len: 1024 * 1024,
|
max_line_len: 1024 * 1024,
|
||||||
},
|
},
|
||||||
decoder: DecoderConfig::Text {
|
decoder: DecoderConfig::Text {
|
||||||
encoding: xserial_core::protocol::text::TextEncoding::Utf8,
|
encoding: pipeview_core::protocol::text::TextEncoding::Utf8,
|
||||||
},
|
},
|
||||||
}],
|
}],
|
||||||
history_limit: 100,
|
history_limit: 100,
|
||||||
@@ -72,18 +72,15 @@ async fn session_multiple_reads() {
|
|||||||
let mut texts = Vec::new();
|
let mut texts = Vec::new();
|
||||||
tokio::time::timeout(Duration::from_secs(5), async {
|
tokio::time::timeout(Duration::from_secs(5), async {
|
||||||
loop {
|
loop {
|
||||||
match rx.recv().await.unwrap() {
|
if let SessionEvent::Data(_, entry) = rx.recv().await.unwrap()
|
||||||
SessionEvent::Data(_, entry) => {
|
&& let DecodedData::Text(s) = entry.data
|
||||||
if let DecodedData::Text(s) = entry.data {
|
{
|
||||||
texts.push(s);
|
texts.push(s);
|
||||||
if texts.len() == 3 {
|
if texts.len() == 3 {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -116,7 +113,7 @@ async fn session_multi_pipeline_text_and_hex() {
|
|||||||
uppercase: false,
|
uppercase: false,
|
||||||
separator: " ".into(),
|
separator: " ".into(),
|
||||||
bytes_per_group: 1,
|
bytes_per_group: 1,
|
||||||
endian: xserial_core::protocol::Endian::Big,
|
endian: pipeview_core::protocol::Endian::Big,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -127,15 +124,12 @@ async fn session_multi_pipeline_text_and_hex() {
|
|||||||
let mut results = Vec::new();
|
let mut results = Vec::new();
|
||||||
tokio::time::timeout(Duration::from_secs(5), async {
|
tokio::time::timeout(Duration::from_secs(5), async {
|
||||||
loop {
|
loop {
|
||||||
match rx.recv().await.unwrap() {
|
if let SessionEvent::Data(_, entry) = rx.recv().await.unwrap() {
|
||||||
SessionEvent::Data(_, entry) => {
|
|
||||||
results.push((entry.pipeline_name.clone(), entry.data.clone()));
|
results.push((entry.pipeline_name.clone(), entry.data.clone()));
|
||||||
if results.len() == 2 {
|
if results.len() == 2 {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
@@ -291,7 +285,7 @@ async fn session_reconfigure_changes_pipeline() {
|
|||||||
uppercase: true,
|
uppercase: true,
|
||||||
separator: " ".into(),
|
separator: " ".into(),
|
||||||
bytes_per_group: 1,
|
bytes_per_group: 1,
|
||||||
endian: xserial_core::protocol::Endian::Big,
|
endian: pipeview_core::protocol::Endian::Big,
|
||||||
};
|
};
|
||||||
|
|
||||||
handle.reconfigure(new_cfg).await.unwrap();
|
handle.reconfigure(new_cfg).await.unwrap();
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "xserial-core"
|
name = "pipeview-core"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
@@ -324,7 +324,7 @@ mod tests {
|
|||||||
let mut f = LengthPrefixedFramer::new(cfg);
|
let mut f = LengthPrefixedFramer::new(cfg);
|
||||||
// Corrupt frame: claims 200 bytes, actual payload is 0xFF bytes
|
// Corrupt frame: claims 200 bytes, actual payload is 0xFF bytes
|
||||||
let mut data = be2(200);
|
let mut data = be2(200);
|
||||||
data.extend_from_slice(&vec![0xFFu8; 200]);
|
data.extend_from_slice(&[0xFFu8; 200]);
|
||||||
// Followed by valid frame
|
// Followed by valid frame
|
||||||
data.extend(&be2(3));
|
data.extend(&be2(3));
|
||||||
data.extend_from_slice(b"foo");
|
data.extend_from_slice(b"foo");
|
||||||
@@ -72,11 +72,11 @@ impl MixedTextPlotFramer {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(decoded) = cobs_decode(&self.plot_buf) {
|
if let Some(decoded) = cobs_decode(&self.plot_buf)
|
||||||
if decoded.len() <= self.config.max_plot_frame {
|
&& decoded.len() <= self.config.max_plot_frame
|
||||||
|
{
|
||||||
frames.push(tag_plot_frame(decoded));
|
frames.push(tag_plot_frame(decoded));
|
||||||
}
|
}
|
||||||
}
|
|
||||||
self.plot_buf.clear();
|
self.plot_buf.clear();
|
||||||
self.plot_overflow = false;
|
self.plot_overflow = false;
|
||||||
}
|
}
|
||||||
@@ -61,11 +61,11 @@ pub struct PipelineResult {
|
|||||||
/// Manages multiple [`Pipeline`]s, feeding the same byte stream to all of them.
|
/// Manages multiple [`Pipeline`]s, feeding the same byte stream to all of them.
|
||||||
///
|
///
|
||||||
/// ```
|
/// ```
|
||||||
/// use xserial_core::pipeline::{MultiPipeline, Pipeline};
|
/// use pipeview_core::pipeline::{MultiPipeline, Pipeline};
|
||||||
/// use xserial_core::frame::line::{LineFramer, LineConfig};
|
/// use pipeview_core::frame::line::{LineFramer, LineConfig};
|
||||||
/// use xserial_core::frame::fixed::FixedLengthFramer;
|
/// use pipeview_core::frame::fixed::FixedLengthFramer;
|
||||||
/// use xserial_core::protocol::text::{TextDecoder, TextEncoding};
|
/// use pipeview_core::protocol::text::{TextDecoder, TextEncoding};
|
||||||
/// use xserial_core::protocol::hex::{HexDecoder, HexConfig};
|
/// use pipeview_core::protocol::hex::{HexDecoder, HexConfig};
|
||||||
///
|
///
|
||||||
/// let mut mp = MultiPipeline::new();
|
/// let mut mp = MultiPipeline::new();
|
||||||
/// mp.add(
|
/// mp.add(
|
||||||
@@ -52,7 +52,8 @@ impl MixedTextPlotDecoder {
|
|||||||
Endian::Big => 1,
|
Endian::Big => 1,
|
||||||
});
|
});
|
||||||
packet.push(channels.min(u8::MAX as usize) as u8);
|
packet.push(channels.min(u8::MAX as usize) as u8);
|
||||||
packet.extend_from_slice(&(samples_per_channel.min(u16::MAX as usize) as u16).to_le_bytes());
|
packet
|
||||||
|
.extend_from_slice(&(samples_per_channel.min(u16::MAX as usize) as u16).to_le_bytes());
|
||||||
packet.extend_from_slice(&(payload.len().min(u32::MAX as usize) as u32).to_le_bytes());
|
packet.extend_from_slice(&(payload.len().min(u32::MAX as usize) as u32).to_le_bytes());
|
||||||
packet.extend_from_slice(payload);
|
packet.extend_from_slice(payload);
|
||||||
packet
|
packet
|
||||||
@@ -469,7 +469,8 @@ mod tests {
|
|||||||
};
|
};
|
||||||
let d = PlotDecoder::new(cfg);
|
let d = PlotDecoder::new(cfg);
|
||||||
// 7 bytes: 1 full u32 (4 bytes) + 3 trailing bytes
|
// 7 bytes: 1 full u32 (4 bytes) + 3 trailing bytes
|
||||||
let data = vec![1u32.to_le_bytes().to_vec(), vec![0xff; 3]].concat();
|
let mut data = 1u32.to_le_bytes().to_vec();
|
||||||
|
data.extend_from_slice(&[0xff; 3]);
|
||||||
let result = d.decode(&data).unwrap();
|
let result = d.decode(&data).unwrap();
|
||||||
match result {
|
match result {
|
||||||
DecodedData::Plot(frame) => {
|
DecodedData::Plot(frame) => {
|
||||||
@@ -30,6 +30,14 @@ fn default_serial_flow_control() -> SerialFlowControl {
|
|||||||
SerialFlowControl::None
|
SerialFlowControl::None
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn default_serial_dtr() -> bool {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_serial_rts() -> bool {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
pub enum TransportType {
|
pub enum TransportType {
|
||||||
Serial,
|
Serial,
|
||||||
@@ -60,6 +68,10 @@ pub enum TransportConfig {
|
|||||||
stop_bits: SerialStopBits,
|
stop_bits: SerialStopBits,
|
||||||
#[serde(default = "default_serial_flow_control")]
|
#[serde(default = "default_serial_flow_control")]
|
||||||
flow_control: SerialFlowControl,
|
flow_control: SerialFlowControl,
|
||||||
|
#[serde(default = "default_serial_dtr")]
|
||||||
|
dtr: bool,
|
||||||
|
#[serde(default = "default_serial_rts")]
|
||||||
|
rts: bool,
|
||||||
},
|
},
|
||||||
Tcp {
|
Tcp {
|
||||||
addr: String,
|
addr: String,
|
||||||
@@ -96,6 +108,8 @@ impl Connection {
|
|||||||
parity,
|
parity,
|
||||||
stop_bits,
|
stop_bits,
|
||||||
flow_control,
|
flow_control,
|
||||||
|
dtr,
|
||||||
|
rts,
|
||||||
} => Connection::Serial(SerialTransport::new(
|
} => Connection::Serial(SerialTransport::new(
|
||||||
port,
|
port,
|
||||||
baud_rate,
|
baud_rate,
|
||||||
@@ -103,6 +117,8 @@ impl Connection {
|
|||||||
parity,
|
parity,
|
||||||
stop_bits,
|
stop_bits,
|
||||||
flow_control,
|
flow_control,
|
||||||
|
dtr,
|
||||||
|
rts,
|
||||||
)),
|
)),
|
||||||
TransportConfig::Tcp { addr } => Connection::Tcp(TcpTransport::new(addr)),
|
TransportConfig::Tcp { addr } => Connection::Tcp(TcpTransport::new(addr)),
|
||||||
TransportConfig::Udp {
|
TransportConfig::Udp {
|
||||||
@@ -153,6 +169,24 @@ impl Connection {
|
|||||||
Connection::Udp(t) => t.disconnect().await,
|
Connection::Udp(t) => t.disconnect().await,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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(),
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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(),
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AsyncRead for Connection {
|
impl AsyncRead for Connection {
|
||||||
@@ -220,6 +254,8 @@ mod tests {
|
|||||||
parity: SerialParity::None,
|
parity: SerialParity::None,
|
||||||
stop_bits: SerialStopBits::One,
|
stop_bits: SerialStopBits::One,
|
||||||
flow_control: SerialFlowControl::None,
|
flow_control: SerialFlowControl::None,
|
||||||
|
dtr: false,
|
||||||
|
rts: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -254,11 +290,11 @@ mod tests {
|
|||||||
assert_eq!(original, cloned);
|
assert_eq!(original, cloned);
|
||||||
|
|
||||||
let original = TransportType::Tcp;
|
let original = TransportType::Tcp;
|
||||||
let cloned = original.clone();
|
let cloned = original;
|
||||||
assert_eq!(original, cloned);
|
assert_eq!(original, cloned);
|
||||||
|
|
||||||
let original = TransportType::Udp;
|
let original = TransportType::Udp;
|
||||||
let cloned = original.clone();
|
let cloned = original;
|
||||||
assert_eq!(original, cloned);
|
assert_eq!(original, cloned);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -312,6 +348,8 @@ mod tests {
|
|||||||
parity: SerialParity::None,
|
parity: SerialParity::None,
|
||||||
stop_bits: SerialStopBits::One,
|
stop_bits: SerialStopBits::One,
|
||||||
flow_control: SerialFlowControl::None,
|
flow_control: SerialFlowControl::None,
|
||||||
|
dtr: false,
|
||||||
|
rts: false,
|
||||||
};
|
};
|
||||||
let _ = format!("{:?}", cfg);
|
let _ = format!("{:?}", cfg);
|
||||||
|
|
||||||
@@ -371,6 +409,8 @@ mod tests {
|
|||||||
parity,
|
parity,
|
||||||
stop_bits,
|
stop_bits,
|
||||||
flow_control,
|
flow_control,
|
||||||
|
dtr,
|
||||||
|
rts,
|
||||||
} => {
|
} => {
|
||||||
assert_eq!(port, "COM1");
|
assert_eq!(port, "COM1");
|
||||||
assert_eq!(baud_rate, 9600);
|
assert_eq!(baud_rate, 9600);
|
||||||
@@ -378,6 +418,8 @@ mod tests {
|
|||||||
assert_eq!(parity, SerialParity::None);
|
assert_eq!(parity, SerialParity::None);
|
||||||
assert_eq!(stop_bits, SerialStopBits::One);
|
assert_eq!(stop_bits, SerialStopBits::One);
|
||||||
assert_eq!(flow_control, SerialFlowControl::None);
|
assert_eq!(flow_control, SerialFlowControl::None);
|
||||||
|
assert!(!dtr);
|
||||||
|
assert!(!rts);
|
||||||
}
|
}
|
||||||
_ => panic!("expected Serial variant"),
|
_ => panic!("expected Serial variant"),
|
||||||
}
|
}
|
||||||
@@ -395,6 +437,8 @@ mod tests {
|
|||||||
parity,
|
parity,
|
||||||
stop_bits,
|
stop_bits,
|
||||||
flow_control,
|
flow_control,
|
||||||
|
dtr,
|
||||||
|
rts,
|
||||||
} => {
|
} => {
|
||||||
assert_eq!(port, "COM2");
|
assert_eq!(port, "COM2");
|
||||||
assert_eq!(baud_rate, 57600);
|
assert_eq!(baud_rate, 57600);
|
||||||
@@ -402,6 +446,8 @@ mod tests {
|
|||||||
assert_eq!(parity, SerialParity::Even);
|
assert_eq!(parity, SerialParity::Even);
|
||||||
assert_eq!(stop_bits, SerialStopBits::Two);
|
assert_eq!(stop_bits, SerialStopBits::Two);
|
||||||
assert_eq!(flow_control, SerialFlowControl::Hardware);
|
assert_eq!(flow_control, SerialFlowControl::Hardware);
|
||||||
|
assert!(!dtr);
|
||||||
|
assert!(!rts);
|
||||||
}
|
}
|
||||||
_ => panic!("expected Serial variant"),
|
_ => panic!("expected Serial variant"),
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
|
use serialport::SerialPort;
|
||||||
use tokio::io::{AsyncRead, AsyncWrite, ReadBuf};
|
use tokio::io::{AsyncRead, AsyncWrite, ReadBuf};
|
||||||
use tokio_serial::{DataBits, FlowControl, Parity, SerialPortBuilderExt, SerialStream, StopBits};
|
use tokio_serial::{DataBits, FlowControl, Parity, SerialPortBuilderExt, SerialStream, StopBits};
|
||||||
use tracing::{debug, info, warn};
|
use tracing::{debug, info, warn};
|
||||||
@@ -83,9 +84,12 @@ pub struct SerialTransport {
|
|||||||
parity: SerialParity,
|
parity: SerialParity,
|
||||||
stop_bits: SerialStopBits,
|
stop_bits: SerialStopBits,
|
||||||
flow_control: SerialFlowControl,
|
flow_control: SerialFlowControl,
|
||||||
|
dtr: bool,
|
||||||
|
rts: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl SerialTransport {
|
impl SerialTransport {
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
pub fn new(
|
pub fn new(
|
||||||
port_name: String,
|
port_name: String,
|
||||||
baud_rate: u32,
|
baud_rate: u32,
|
||||||
@@ -93,6 +97,8 @@ impl SerialTransport {
|
|||||||
parity: SerialParity,
|
parity: SerialParity,
|
||||||
stop_bits: SerialStopBits,
|
stop_bits: SerialStopBits,
|
||||||
flow_control: SerialFlowControl,
|
flow_control: SerialFlowControl,
|
||||||
|
dtr: bool,
|
||||||
|
rts: bool,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
Self {
|
Self {
|
||||||
port: None,
|
port: None,
|
||||||
@@ -102,6 +108,8 @@ impl SerialTransport {
|
|||||||
parity,
|
parity,
|
||||||
stop_bits,
|
stop_bits,
|
||||||
flow_control,
|
flow_control,
|
||||||
|
dtr,
|
||||||
|
rts,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -138,6 +146,36 @@ impl SerialTransport {
|
|||||||
pub fn flow_control(&self) -> SerialFlowControl {
|
pub fn flow_control(&self) -> SerialFlowControl {
|
||||||
self.flow_control
|
self.flow_control
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn set_dtr(&mut self, state: bool) -> Result<()> {
|
||||||
|
match &mut self.port {
|
||||||
|
Some(port) => {
|
||||||
|
port.write_data_terminal_ready(state)?;
|
||||||
|
self.dtr = state;
|
||||||
|
debug!("DTR set to {} on {}", state, self.port_name);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
None => Err(crate::error::Error::ConnectionFailed(format!(
|
||||||
|
"port {} not open",
|
||||||
|
self.port_name
|
||||||
|
))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_rts(&mut self, state: bool) -> Result<()> {
|
||||||
|
match &mut self.port {
|
||||||
|
Some(port) => {
|
||||||
|
port.write_request_to_send(state)?;
|
||||||
|
self.rts = state;
|
||||||
|
debug!("RTS set to {} on {}", state, self.port_name);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
None => Err(crate::error::Error::ConnectionFailed(format!(
|
||||||
|
"port {} not open",
|
||||||
|
self.port_name
|
||||||
|
))),
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AsyncRead for SerialTransport {
|
impl AsyncRead for SerialTransport {
|
||||||
@@ -221,7 +259,7 @@ impl Transport for SerialTransport {
|
|||||||
self.flow_control
|
self.flow_control
|
||||||
);
|
);
|
||||||
|
|
||||||
let port = tokio_serial::new(&self.port_name, self.baud_rate)
|
let mut port = tokio_serial::new(&self.port_name, self.baud_rate)
|
||||||
.data_bits(self.data_bits.into())
|
.data_bits(self.data_bits.into())
|
||||||
.parity(self.parity.into())
|
.parity(self.parity.into())
|
||||||
.stop_bits(self.stop_bits.into())
|
.stop_bits(self.stop_bits.into())
|
||||||
@@ -234,6 +272,23 @@ impl Transport for SerialTransport {
|
|||||||
))
|
))
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
|
// Linux kernel toggles DTR/RTS on every open() — restore configured
|
||||||
|
// state immediately afterward. Many wireless serial modules (HC-12,
|
||||||
|
// 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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if let Err(e) = port.write_request_to_send(self.rts) {
|
||||||
|
warn!(
|
||||||
|
"Failed to set RTS({}) on {}: {}",
|
||||||
|
self.rts, self.port_name, e
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
debug!("Serial port {} opened successfully", self.port_name);
|
debug!("Serial port {} opened successfully", self.port_name);
|
||||||
self.port = Some(port);
|
self.port = Some(port);
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -282,6 +337,8 @@ mod tests {
|
|||||||
SerialParity::None,
|
SerialParity::None,
|
||||||
SerialStopBits::One,
|
SerialStopBits::One,
|
||||||
SerialFlowControl::None,
|
SerialFlowControl::None,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
);
|
);
|
||||||
assert_eq!(transport.port_name(), "COM1");
|
assert_eq!(transport.port_name(), "COM1");
|
||||||
assert_eq!(transport.baud_rate(), 115200);
|
assert_eq!(transport.baud_rate(), 115200);
|
||||||
@@ -296,6 +353,8 @@ mod tests {
|
|||||||
SerialParity::Even,
|
SerialParity::Even,
|
||||||
SerialStopBits::Two,
|
SerialStopBits::Two,
|
||||||
SerialFlowControl::Hardware,
|
SerialFlowControl::Hardware,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
);
|
);
|
||||||
assert_eq!(transport.port_name(), "COM3");
|
assert_eq!(transport.port_name(), "COM3");
|
||||||
assert_eq!(transport.baud_rate(), 9600);
|
assert_eq!(transport.baud_rate(), 9600);
|
||||||
@@ -314,6 +373,8 @@ mod tests {
|
|||||||
SerialParity::None,
|
SerialParity::None,
|
||||||
SerialStopBits::One,
|
SerialStopBits::One,
|
||||||
SerialFlowControl::None,
|
SerialFlowControl::None,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
);
|
);
|
||||||
assert_eq!(transport.name(), "COM1");
|
assert_eq!(transport.name(), "COM1");
|
||||||
}
|
}
|
||||||
@@ -327,6 +388,8 @@ mod tests {
|
|||||||
SerialParity::None,
|
SerialParity::None,
|
||||||
SerialStopBits::One,
|
SerialStopBits::One,
|
||||||
SerialFlowControl::None,
|
SerialFlowControl::None,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
);
|
);
|
||||||
assert_eq!(transport.transport_type(), TransportType::Serial);
|
assert_eq!(transport.transport_type(), TransportType::Serial);
|
||||||
}
|
}
|
||||||
@@ -340,6 +403,8 @@ mod tests {
|
|||||||
SerialParity::None,
|
SerialParity::None,
|
||||||
SerialStopBits::One,
|
SerialStopBits::One,
|
||||||
SerialFlowControl::None,
|
SerialFlowControl::None,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
);
|
);
|
||||||
assert!(!transport.is_connected());
|
assert!(!transport.is_connected());
|
||||||
}
|
}
|
||||||
@@ -368,6 +433,8 @@ mod tests {
|
|||||||
SerialParity::None,
|
SerialParity::None,
|
||||||
SerialStopBits::One,
|
SerialStopBits::One,
|
||||||
SerialFlowControl::None,
|
SerialFlowControl::None,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
);
|
);
|
||||||
let pinned = Pin::new(&mut transport);
|
let pinned = Pin::new(&mut transport);
|
||||||
let mut buf_data = [0u8; 16];
|
let mut buf_data = [0u8; 16];
|
||||||
@@ -391,6 +458,8 @@ mod tests {
|
|||||||
SerialParity::None,
|
SerialParity::None,
|
||||||
SerialStopBits::One,
|
SerialStopBits::One,
|
||||||
SerialFlowControl::None,
|
SerialFlowControl::None,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
);
|
);
|
||||||
let pinned = Pin::new(&mut transport);
|
let pinned = Pin::new(&mut transport);
|
||||||
let data = b"hello";
|
let data = b"hello";
|
||||||
@@ -411,6 +480,8 @@ mod tests {
|
|||||||
SerialParity::None,
|
SerialParity::None,
|
||||||
SerialStopBits::One,
|
SerialStopBits::One,
|
||||||
SerialFlowControl::None,
|
SerialFlowControl::None,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
);
|
);
|
||||||
let pinned = Pin::new(&mut transport);
|
let pinned = Pin::new(&mut transport);
|
||||||
let waker = noop_waker();
|
let waker = noop_waker();
|
||||||
@@ -430,6 +501,8 @@ mod tests {
|
|||||||
SerialParity::None,
|
SerialParity::None,
|
||||||
SerialStopBits::One,
|
SerialStopBits::One,
|
||||||
SerialFlowControl::None,
|
SerialFlowControl::None,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
);
|
);
|
||||||
let pinned = Pin::new(&mut transport);
|
let pinned = Pin::new(&mut transport);
|
||||||
let waker = noop_waker();
|
let waker = noop_waker();
|
||||||
@@ -451,6 +524,8 @@ mod tests {
|
|||||||
SerialParity::None,
|
SerialParity::None,
|
||||||
SerialStopBits::One,
|
SerialStopBits::One,
|
||||||
SerialFlowControl::None,
|
SerialFlowControl::None,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
);
|
);
|
||||||
assert_eq!(transport.name(), "COM1");
|
assert_eq!(transport.name(), "COM1");
|
||||||
}
|
}
|
||||||
@@ -464,6 +539,8 @@ mod tests {
|
|||||||
SerialParity::None,
|
SerialParity::None,
|
||||||
SerialStopBits::One,
|
SerialStopBits::One,
|
||||||
SerialFlowControl::None,
|
SerialFlowControl::None,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
);
|
);
|
||||||
assert_eq!(transport.transport_type(), TransportType::Serial);
|
assert_eq!(transport.transport_type(), TransportType::Serial);
|
||||||
}
|
}
|
||||||
@@ -477,6 +554,8 @@ mod tests {
|
|||||||
SerialParity::None,
|
SerialParity::None,
|
||||||
SerialStopBits::One,
|
SerialStopBits::One,
|
||||||
SerialFlowControl::None,
|
SerialFlowControl::None,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
);
|
);
|
||||||
assert!(!transport.is_connected());
|
assert!(!transport.is_connected());
|
||||||
}
|
}
|
||||||
@@ -4,26 +4,26 @@ use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
|||||||
use tokio::net::{TcpListener, UdpSocket};
|
use tokio::net::{TcpListener, UdpSocket};
|
||||||
use tokio::time::timeout;
|
use tokio::time::timeout;
|
||||||
|
|
||||||
use xserial_core::frame::{
|
use pipeview_core::frame::{
|
||||||
Endian, Framer,
|
Endian, Framer,
|
||||||
cobs::cobs_encode as raw_cobs_encode,
|
|
||||||
cobs::CobsFramer,
|
cobs::CobsFramer,
|
||||||
|
cobs::cobs_encode as raw_cobs_encode,
|
||||||
fixed::FixedLengthFramer,
|
fixed::FixedLengthFramer,
|
||||||
length::{LengthConfig, LengthPrefixedFramer},
|
length::{LengthConfig, LengthPrefixedFramer},
|
||||||
line::{LineConfig, LineFramer},
|
line::{LineConfig, LineFramer},
|
||||||
mixed::{MixedTextPlotConfig as MixedFramerConfig, MixedTextPlotFramer},
|
mixed::{MixedTextPlotConfig as MixedFramerConfig, MixedTextPlotFramer},
|
||||||
};
|
};
|
||||||
use xserial_core::protocol::{
|
use pipeview_core::protocol::{
|
||||||
DecodedData, ProtocolDecoder,
|
DecodedData, ProtocolDecoder,
|
||||||
hex::{HexConfig, HexDecoder},
|
hex::{HexConfig, HexDecoder},
|
||||||
mixed::{MIXED_PLOT_ESCAPE, MIXED_PLOT_MARKER, MixedTextPlotConfig, MixedTextPlotDecoder},
|
mixed::{MIXED_PLOT_ESCAPE, MIXED_PLOT_MARKER, MixedTextPlotConfig, MixedTextPlotDecoder},
|
||||||
plot::{PlotConfig, PlotDecoder, PlotFormat, SampleType},
|
plot::{PlotConfig, PlotDecoder, PlotFormat, SampleType},
|
||||||
text::{TextDecoder, TextEncoding},
|
text::{TextDecoder, TextEncoding},
|
||||||
};
|
};
|
||||||
use xserial_core::transport::serial::{
|
use pipeview_core::transport::serial::{
|
||||||
SerialDataBits, SerialFlowControl, SerialParity, SerialStopBits,
|
SerialDataBits, SerialFlowControl, SerialParity, SerialStopBits,
|
||||||
};
|
};
|
||||||
use xserial_core::transport::{Connection, TransportConfig, TransportType};
|
use pipeview_core::transport::{Connection, TransportConfig, TransportType};
|
||||||
|
|
||||||
const TEST_TIMEOUT: Duration = Duration::from_secs(5);
|
const TEST_TIMEOUT: Duration = Duration::from_secs(5);
|
||||||
|
|
||||||
@@ -225,7 +225,7 @@ async fn tcp_fixed_plot_f32() {
|
|||||||
let server = tokio::spawn(async move {
|
let server = tokio::spawn(async move {
|
||||||
let (mut stream, _) = listener.accept().await.unwrap();
|
let (mut stream, _) = listener.accept().await.unwrap();
|
||||||
// 3 samples × 4 bytes each = 12 bytes per frame
|
// 3 samples × 4 bytes each = 12 bytes per frame
|
||||||
let samples: [f32; 3] = [1.0, -2.5, 3.14];
|
let samples: [f32; 3] = [1.0, -2.5, 0.0];
|
||||||
let mut data = Vec::new();
|
let mut data = Vec::new();
|
||||||
for s in &samples {
|
for s in &samples {
|
||||||
data.extend_from_slice(&s.to_le_bytes());
|
data.extend_from_slice(&s.to_le_bytes());
|
||||||
@@ -255,7 +255,7 @@ async fn tcp_fixed_plot_f32() {
|
|||||||
assert_eq!(frame.channels[0].len(), 3);
|
assert_eq!(frame.channels[0].len(), 3);
|
||||||
assert!((frame.channels[0][0] - 1.0).abs() < 1e-5);
|
assert!((frame.channels[0][0] - 1.0).abs() < 1e-5);
|
||||||
assert!((frame.channels[0][1] - (-2.5)).abs() < 1e-5);
|
assert!((frame.channels[0][1] - (-2.5)).abs() < 1e-5);
|
||||||
assert!((frame.channels[0][2] - 3.14).abs() < 1e-5);
|
assert!((frame.channels[0][2] - 0.0).abs() < 1e-5);
|
||||||
}
|
}
|
||||||
other => panic!("expected Plot, got {:?}", other),
|
other => panic!("expected Plot, got {:?}", other),
|
||||||
}
|
}
|
||||||
@@ -298,10 +298,7 @@ async fn tcp_mixed_text_and_plot_single_stream() {
|
|||||||
conn.disconnect().await.unwrap();
|
conn.disconnect().await.unwrap();
|
||||||
|
|
||||||
let decoder = MixedTextPlotDecoder::new(MixedTextPlotConfig::default());
|
let decoder = MixedTextPlotDecoder::new(MixedTextPlotConfig::default());
|
||||||
let results: Vec<DecodedData> = frames
|
let results: Vec<DecodedData> = frames.iter().filter_map(|f| decoder.decode(f)).collect();
|
||||||
.iter()
|
|
||||||
.filter_map(|f| decoder.decode(f))
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
assert_eq!(results.len(), 3);
|
assert_eq!(results.len(), 3);
|
||||||
assert!(matches!(&results[0], DecodedData::Text(s) if s == "status ok"));
|
assert!(matches!(&results[0], DecodedData::Text(s) if s == "status ok"));
|
||||||
@@ -691,6 +688,8 @@ async fn connection_transport_type_dispatch() {
|
|||||||
parity: SerialParity::None,
|
parity: SerialParity::None,
|
||||||
stop_bits: SerialStopBits::One,
|
stop_bits: SerialStopBits::One,
|
||||||
flow_control: SerialFlowControl::None,
|
flow_control: SerialFlowControl::None,
|
||||||
|
dtr: false,
|
||||||
|
rts: false,
|
||||||
});
|
});
|
||||||
let tcp = Connection::new(TransportConfig::Tcp {
|
let tcp = Connection::new(TransportConfig::Tcp {
|
||||||
addr: "127.0.0.1:8080".into(),
|
addr: "127.0.0.1:8080".into(),
|
||||||
@@ -1,15 +1,15 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "xserial-gui"
|
name = "pipeview-gui"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[[bin]]
|
[[bin]]
|
||||||
name = "xserial-gui"
|
name = "pipeview-gui"
|
||||||
path = "src/main.rs"
|
path = "src/main.rs"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
xserial-core = { path = "../xserial-core" }
|
pipeview-core = { path = "../pipeview-core" }
|
||||||
xserial-client = { path = "../xserial-client" }
|
pipeview-client = { path = "../pipeview-client" }
|
||||||
tokio = { workspace = true }
|
tokio = { workspace = true }
|
||||||
egui = { workspace = true }
|
egui = { workspace = true }
|
||||||
eframe = { workspace = true }
|
eframe = { workspace = true }
|
||||||
@@ -19,3 +19,4 @@ tracing-subscriber = { workspace = true }
|
|||||||
serde = { workspace = true }
|
serde = { workspace = true }
|
||||||
serde_json = { workspace = true }
|
serde_json = { workspace = true }
|
||||||
hex = "0.4"
|
hex = "0.4"
|
||||||
|
ansitok = "0.3"
|
||||||
451
crates/pipeview-gui/src/ansi_render.rs
Normal file
451
crates/pipeview-gui/src/ansi_render.rs
Normal file
@@ -0,0 +1,451 @@
|
|||||||
|
use ansitok::{AnsiColor, ElementKind, VisualAttribute, parse_ansi, parse_ansi_sgr};
|
||||||
|
use egui::Color32;
|
||||||
|
use egui::text::{LayoutJob, TextFormat};
|
||||||
|
use std::ops::Range;
|
||||||
|
|
||||||
|
pub fn ansi_to_layout_job(text: &str, base_format: TextFormat) -> LayoutJob {
|
||||||
|
let mut job = LayoutJob::default();
|
||||||
|
for segment in ansi_segments(text, &base_format) {
|
||||||
|
job.append(segment.text, 0.0, segment.format);
|
||||||
|
}
|
||||||
|
|
||||||
|
job
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn ansi_to_layout_job_highlighted(
|
||||||
|
text: &str,
|
||||||
|
base_format: TextFormat,
|
||||||
|
query: &str,
|
||||||
|
case_sensitive: bool,
|
||||||
|
highlight_format: TextFormat,
|
||||||
|
) -> LayoutJob {
|
||||||
|
if query.is_empty() {
|
||||||
|
return ansi_to_layout_job(text, base_format);
|
||||||
|
}
|
||||||
|
|
||||||
|
let segments = ansi_segments(text, &base_format);
|
||||||
|
let mut visible_text = String::with_capacity(segments.iter().map(|s| s.text.len()).sum());
|
||||||
|
for segment in &segments {
|
||||||
|
visible_text.push_str(segment.text);
|
||||||
|
}
|
||||||
|
|
||||||
|
let ranges = search_ranges(&visible_text, query, case_sensitive);
|
||||||
|
if ranges.is_empty() {
|
||||||
|
let mut job = LayoutJob::default();
|
||||||
|
for segment in segments {
|
||||||
|
job.append(segment.text, 0.0, segment.format);
|
||||||
|
}
|
||||||
|
return job;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut job = LayoutJob::default();
|
||||||
|
let mut offset = 0;
|
||||||
|
let mut range_idx = 0;
|
||||||
|
for segment in segments {
|
||||||
|
let len = segment.text.len();
|
||||||
|
append_segment_with_highlights(
|
||||||
|
&mut job,
|
||||||
|
segment.text,
|
||||||
|
segment.format,
|
||||||
|
&highlight_format,
|
||||||
|
offset,
|
||||||
|
&ranges,
|
||||||
|
&mut range_idx,
|
||||||
|
);
|
||||||
|
offset += len;
|
||||||
|
}
|
||||||
|
job
|
||||||
|
}
|
||||||
|
|
||||||
|
struct StyledSegment<'a> {
|
||||||
|
text: &'a str,
|
||||||
|
format: TextFormat,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
struct AnsiStyle {
|
||||||
|
fg: Option<Color32>,
|
||||||
|
bg: Option<Color32>,
|
||||||
|
bold: bool,
|
||||||
|
italic: bool,
|
||||||
|
underline: bool,
|
||||||
|
strikethrough: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AnsiStyle {
|
||||||
|
fn apply_sgr(&mut self, attr: VisualAttribute) {
|
||||||
|
match attr {
|
||||||
|
VisualAttribute::Reset(0) => self.reset_all(),
|
||||||
|
VisualAttribute::Reset(22) => self.bold = false,
|
||||||
|
VisualAttribute::Reset(23) => self.italic = false,
|
||||||
|
VisualAttribute::Reset(24) => self.underline = false,
|
||||||
|
VisualAttribute::Reset(29) => self.strikethrough = false,
|
||||||
|
VisualAttribute::Reset(39) => self.fg = None,
|
||||||
|
VisualAttribute::Reset(49) => self.bg = None,
|
||||||
|
VisualAttribute::Reset(_) => {}
|
||||||
|
VisualAttribute::Bold => self.bold = true,
|
||||||
|
VisualAttribute::Faint => self.bold = false,
|
||||||
|
VisualAttribute::Italic => self.italic = true,
|
||||||
|
VisualAttribute::Underline => self.underline = true,
|
||||||
|
VisualAttribute::Crossedout => self.strikethrough = true,
|
||||||
|
VisualAttribute::FgColor(c) => self.fg = Some(ansi_color_to_egui(c)),
|
||||||
|
VisualAttribute::BgColor(c) => self.bg = Some(ansi_color_to_egui(c)),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn reset_all(&mut self) {
|
||||||
|
*self = Self::default();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn format(&self, base_format: &TextFormat) -> TextFormat {
|
||||||
|
let mut fmt = base_format.clone();
|
||||||
|
|
||||||
|
if let Some(c) = self.fg {
|
||||||
|
if self.bold {
|
||||||
|
fmt.color = brighten(c);
|
||||||
|
} else {
|
||||||
|
fmt.color = c;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(c) = self.bg {
|
||||||
|
fmt.background = c;
|
||||||
|
}
|
||||||
|
if self.italic {
|
||||||
|
fmt.italics = true;
|
||||||
|
}
|
||||||
|
if self.underline {
|
||||||
|
fmt.underline = egui::Stroke::new(1.0, fmt.color);
|
||||||
|
}
|
||||||
|
if self.strikethrough {
|
||||||
|
fmt.strikethrough = egui::Stroke::new(1.0, fmt.color);
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ansi_segments<'a>(text: &'a str, base_format: &TextFormat) -> Vec<StyledSegment<'a>> {
|
||||||
|
let mut segments = Vec::new();
|
||||||
|
let mut style = AnsiStyle::default();
|
||||||
|
|
||||||
|
for element in parse_ansi(text) {
|
||||||
|
match element.kind() {
|
||||||
|
ElementKind::Text => {
|
||||||
|
let slice = &text[element.start()..element.end()];
|
||||||
|
if !slice.is_empty() {
|
||||||
|
segments.push(StyledSegment {
|
||||||
|
text: slice,
|
||||||
|
format: style.format(base_format),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ElementKind::Sgr => {
|
||||||
|
let sgr = &text[element.start()..element.end()];
|
||||||
|
apply_sgr_sequence(&mut style, sgr);
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
segments
|
||||||
|
}
|
||||||
|
|
||||||
|
fn apply_sgr_sequence(style: &mut AnsiStyle, sgr: &str) {
|
||||||
|
let params = sgr
|
||||||
|
.strip_prefix("\x1b[")
|
||||||
|
.and_then(|s| s.strip_suffix('m'))
|
||||||
|
.unwrap_or(sgr);
|
||||||
|
|
||||||
|
if params.is_empty() {
|
||||||
|
style.reset_all();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for output in parse_ansi_sgr(sgr) {
|
||||||
|
if let Some(attr) = output.as_escape() {
|
||||||
|
style.apply_sgr(attr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn search_ranges(text: &str, query: &str, case_sensitive: bool) -> Vec<Range<usize>> {
|
||||||
|
if query.is_empty() {
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
|
||||||
|
if case_sensitive {
|
||||||
|
return text
|
||||||
|
.match_indices(query)
|
||||||
|
.map(|(start, matched)| start..start + matched.len())
|
||||||
|
.collect();
|
||||||
|
}
|
||||||
|
|
||||||
|
let (search_text, byte_to_original) = lowercase_with_byte_map(text);
|
||||||
|
let query = query.to_lowercase();
|
||||||
|
let mut ranges = Vec::new();
|
||||||
|
let mut idx = 0;
|
||||||
|
while let Some(pos) = search_text[idx..].find(&query) {
|
||||||
|
let start = idx + pos;
|
||||||
|
let end = start + query.len();
|
||||||
|
let original_start = byte_to_original.get(start).copied().unwrap_or(text.len());
|
||||||
|
let original_end = byte_to_original.get(end).copied().unwrap_or(text.len());
|
||||||
|
if original_start < original_end {
|
||||||
|
ranges.push(original_start..original_end);
|
||||||
|
}
|
||||||
|
idx = end;
|
||||||
|
}
|
||||||
|
ranges
|
||||||
|
}
|
||||||
|
|
||||||
|
fn lowercase_with_byte_map(text: &str) -> (String, Vec<usize>) {
|
||||||
|
let mut lowered = String::new();
|
||||||
|
let mut byte_to_original = Vec::new();
|
||||||
|
|
||||||
|
for (original_idx, ch) in text.char_indices() {
|
||||||
|
for lower_ch in ch.to_lowercase() {
|
||||||
|
lowered.push(lower_ch);
|
||||||
|
for _ in 0..lower_ch.len_utf8() {
|
||||||
|
byte_to_original.push(original_idx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
byte_to_original.push(text.len());
|
||||||
|
(lowered, byte_to_original)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn append_segment_with_highlights(
|
||||||
|
job: &mut LayoutJob,
|
||||||
|
text: &str,
|
||||||
|
format: TextFormat,
|
||||||
|
highlight_format: &TextFormat,
|
||||||
|
segment_start: usize,
|
||||||
|
ranges: &[Range<usize>],
|
||||||
|
range_idx: &mut usize,
|
||||||
|
) {
|
||||||
|
let segment_end = segment_start + text.len();
|
||||||
|
while *range_idx < ranges.len() && ranges[*range_idx].end <= segment_start {
|
||||||
|
*range_idx += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut idx = *range_idx;
|
||||||
|
let mut last = 0;
|
||||||
|
while idx < ranges.len() && ranges[idx].start < segment_end {
|
||||||
|
let range = &ranges[idx];
|
||||||
|
let start = range.start.max(segment_start) - segment_start;
|
||||||
|
let end = range.end.min(segment_end) - segment_start;
|
||||||
|
|
||||||
|
if last < start {
|
||||||
|
job.append(&text[last..start], 0.0, format.clone());
|
||||||
|
}
|
||||||
|
if start < end {
|
||||||
|
job.append(&text[start..end], 0.0, highlight_format.clone());
|
||||||
|
}
|
||||||
|
last = end;
|
||||||
|
|
||||||
|
if range.end <= segment_end {
|
||||||
|
idx += 1;
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
*range_idx = idx;
|
||||||
|
if last < text.len() {
|
||||||
|
job.append(&text[last..], 0.0, format);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ansi_color_to_egui(color: AnsiColor) -> Color32 {
|
||||||
|
match color {
|
||||||
|
AnsiColor::Bit4(c) => ansi_4bit(c),
|
||||||
|
AnsiColor::Bit8(c) => ansi_256(c),
|
||||||
|
AnsiColor::Bit24 { r, g, b } => Color32::from_rgb(r, g, b),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ansi_4bit(code: u8) -> Color32 {
|
||||||
|
match code {
|
||||||
|
30 => Color32::BLACK,
|
||||||
|
31 => Color32::from_rgb(194, 54, 33),
|
||||||
|
32 => Color32::from_rgb(37, 188, 36),
|
||||||
|
33 => Color32::from_rgb(173, 173, 39),
|
||||||
|
34 => Color32::from_rgb(73, 46, 225),
|
||||||
|
35 => Color32::from_rgb(211, 56, 211),
|
||||||
|
36 => Color32::from_rgb(51, 187, 200),
|
||||||
|
37 => Color32::from_rgb(203, 204, 205),
|
||||||
|
|
||||||
|
90 => Color32::from_rgb(129, 131, 131),
|
||||||
|
91 => Color32::from_rgb(252, 57, 31),
|
||||||
|
92 => Color32::from_rgb(49, 231, 34),
|
||||||
|
93 => Color32::from_rgb(234, 236, 35),
|
||||||
|
94 => Color32::from_rgb(88, 51, 255),
|
||||||
|
95 => Color32::from_rgb(249, 53, 248),
|
||||||
|
96 => Color32::from_rgb(20, 240, 240),
|
||||||
|
97 => Color32::from_rgb(233, 235, 235),
|
||||||
|
|
||||||
|
40 => Color32::BLACK,
|
||||||
|
41 => Color32::from_rgb(194, 54, 33),
|
||||||
|
42 => Color32::from_rgb(37, 188, 36),
|
||||||
|
43 => Color32::from_rgb(173, 173, 39),
|
||||||
|
44 => Color32::from_rgb(73, 46, 225),
|
||||||
|
45 => Color32::from_rgb(211, 56, 211),
|
||||||
|
46 => Color32::from_rgb(51, 187, 200),
|
||||||
|
47 => Color32::from_rgb(203, 204, 205),
|
||||||
|
|
||||||
|
100 => Color32::from_rgb(129, 131, 131),
|
||||||
|
101 => Color32::from_rgb(252, 57, 31),
|
||||||
|
102 => Color32::from_rgb(49, 231, 34),
|
||||||
|
103 => Color32::from_rgb(234, 236, 35),
|
||||||
|
104 => Color32::from_rgb(88, 51, 255),
|
||||||
|
105 => Color32::from_rgb(249, 53, 248),
|
||||||
|
106 => Color32::from_rgb(20, 240, 240),
|
||||||
|
107 => Color32::from_rgb(233, 235, 235),
|
||||||
|
|
||||||
|
_ => Color32::WHITE,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ansi_256(code: u8) -> Color32 {
|
||||||
|
match code {
|
||||||
|
0..=15 => ansi_4bit(if code < 8 { code + 30 } else { code + 82 }),
|
||||||
|
16..=231 => {
|
||||||
|
let idx = code - 16;
|
||||||
|
let cube = [0, 95, 135, 175, 215, 255];
|
||||||
|
let r = cube[(idx / 36) as usize];
|
||||||
|
let g = cube[((idx / 6) % 6) as usize];
|
||||||
|
let b = cube[(idx % 6) as usize];
|
||||||
|
Color32::from_rgb(r, g, b)
|
||||||
|
}
|
||||||
|
232..=255 => {
|
||||||
|
let v = (code - 232) * 10 + 8;
|
||||||
|
Color32::from_rgb(v, v, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn brighten(color: Color32) -> Color32 {
|
||||||
|
Color32::from_rgb(
|
||||||
|
(color.r() as u32 * 13 / 10).min(255) as u8,
|
||||||
|
(color.g() as u32 * 13 / 10).min(255) as u8,
|
||||||
|
(color.b() as u32 * 13 / 10).min(255) as u8,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn visible_text(job: &LayoutJob) -> String {
|
||||||
|
job.sections
|
||||||
|
.iter()
|
||||||
|
.map(|s| &job.text[s.byte_range.clone()])
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn plain_text_no_ansi() {
|
||||||
|
let fmt = TextFormat::default();
|
||||||
|
let job = ansi_to_layout_job("hello", fmt);
|
||||||
|
assert_eq!(job.sections.len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn fg_red_reset() {
|
||||||
|
let fmt = TextFormat::default();
|
||||||
|
let job = ansi_to_layout_job("\x1b[31mred\x1b[0m plain", fmt);
|
||||||
|
assert!(job.sections.len() >= 2);
|
||||||
|
assert!(job.sections[0].format.color != job.sections[1].format.color);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn empty_sgr_resets_all_styles() {
|
||||||
|
let fmt = TextFormat {
|
||||||
|
color: Color32::from_rgb(1, 2, 3),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let job = ansi_to_layout_job("\x1b[31mred\x1b[m plain", fmt.clone());
|
||||||
|
|
||||||
|
assert_eq!(visible_text(&job), "red plain");
|
||||||
|
assert!(job.sections.len() >= 2);
|
||||||
|
assert_ne!(job.sections[0].format.color, job.sections[1].format.color);
|
||||||
|
assert_eq!(job.sections[1].format.color, fmt.color);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn selective_foreground_reset_keeps_background() {
|
||||||
|
let fmt = TextFormat {
|
||||||
|
color: Color32::from_rgb(1, 2, 3),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let job = ansi_to_layout_job("\x1b[41;37mtext\x1b[39mmore", fmt.clone());
|
||||||
|
|
||||||
|
assert_eq!(visible_text(&job), "textmore");
|
||||||
|
assert!(job.sections.len() >= 2);
|
||||||
|
assert_ne!(job.sections[0].format.color, job.sections[1].format.color);
|
||||||
|
assert_eq!(job.sections[1].format.color, fmt.color);
|
||||||
|
assert_ne!(job.sections[0].format.background, fmt.background);
|
||||||
|
assert_eq!(
|
||||||
|
job.sections[0].format.background,
|
||||||
|
job.sections[1].format.background
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn bold_brightens_color() {
|
||||||
|
let fmt = TextFormat::default();
|
||||||
|
let job = ansi_to_layout_job("\x1b[1;31mbold red\x1b[0m", fmt);
|
||||||
|
assert_eq!(job.sections.len(), 1);
|
||||||
|
let c = job.sections[0].format.color;
|
||||||
|
assert!(c.r() > 200 || c.g() > 50 || c.b() > 30);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn strip_ansi_codes_from_output() {
|
||||||
|
let fmt = TextFormat::default();
|
||||||
|
let job = ansi_to_layout_job("\x1b[32mgreen\x1b[0m", fmt);
|
||||||
|
let text: String = job
|
||||||
|
.sections
|
||||||
|
.iter()
|
||||||
|
.map(|s| &job.text[s.byte_range.clone()])
|
||||||
|
.collect();
|
||||||
|
assert_eq!(text, "green");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn highlighted_search_uses_visible_ansi_text() {
|
||||||
|
let default = TextFormat {
|
||||||
|
color: Color32::WHITE,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let highlight = TextFormat {
|
||||||
|
color: Color32::BLACK,
|
||||||
|
background: Color32::from_rgb(255, 255, 0),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let job = ansi_to_layout_job_highlighted(
|
||||||
|
"\x1b[31mred\x1b[0m plain",
|
||||||
|
default,
|
||||||
|
"red plain",
|
||||||
|
true,
|
||||||
|
highlight.clone(),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(visible_text(&job), "red plain");
|
||||||
|
assert!(job.sections.len() >= 2);
|
||||||
|
assert!(
|
||||||
|
job.sections
|
||||||
|
.iter()
|
||||||
|
.all(|section| section.format.background == highlight.background)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn xterm_256_color_cube_values() {
|
||||||
|
assert_eq!(ansi_256(16), Color32::from_rgb(0, 0, 0));
|
||||||
|
assert_eq!(ansi_256(21), Color32::from_rgb(0, 0, 255));
|
||||||
|
assert_eq!(ansi_256(52), Color32::from_rgb(95, 0, 0));
|
||||||
|
assert_eq!(ansi_256(67), Color32::from_rgb(95, 135, 175));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,16 +1,22 @@
|
|||||||
use crate::app_state::{self, PersistedGuiState};
|
use crate::app_state::{self, PersistedGuiState, SessionLogConfig};
|
||||||
|
use std::path::PathBuf;
|
||||||
use std::sync::mpsc;
|
use std::sync::mpsc;
|
||||||
use std::time::Duration;
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
use crate::buffers::{HexBuffer, PlotBuffer, TextBuffer};
|
use crate::buffers::{HexBuffer, PlotBuffer, TextBuffer};
|
||||||
|
use crate::logging::{self, LogWriter};
|
||||||
use crate::panels::{config, console, hex_view, plot_view, 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, FontChoice, UiFontSettings};
|
use crate::ui_fonts::{self, FontCandidate, FontChoice, UiFontSettings};
|
||||||
use egui::{Color32, Layout, Panel, Pos2, Rect, TextEdit, UiBuilder};
|
use egui::{Color32, Layout, Panel, Pos2, Rect, TextEdit, UiBuilder};
|
||||||
use xserial_client::SessionManager;
|
use pipeview_client::SessionManager;
|
||||||
use xserial_client::config::SessionConfig;
|
use pipeview_client::config::SessionConfig;
|
||||||
use xserial_client::session::SessionEvent;
|
use pipeview_client::session::SessionEvent;
|
||||||
use xserial_core::protocol::DecodedData;
|
use pipeview_core::protocol::DecodedData;
|
||||||
use xserial_core::transport::TransportConfig;
|
use pipeview_core::transport::TransportConfig;
|
||||||
|
|
||||||
|
const DATA_REPAINT_INTERVAL: Duration = Duration::from_millis(33);
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub enum ConnectionStatus {
|
pub enum ConnectionStatus {
|
||||||
@@ -44,6 +50,26 @@ pub enum SendMode {
|
|||||||
Hex,
|
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)]
|
#[derive(Clone, Copy)]
|
||||||
pub struct DisplayOptions {
|
pub struct DisplayOptions {
|
||||||
pub show_timestamp: bool,
|
pub show_timestamp: bool,
|
||||||
@@ -51,6 +77,58 @@ pub struct DisplayOptions {
|
|||||||
pub show_pipeline: 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 struct SessionTab {
|
||||||
pub id: u64,
|
pub id: u64,
|
||||||
pub session_config: SessionConfig,
|
pub session_config: SessionConfig,
|
||||||
@@ -61,10 +139,16 @@ pub struct SessionTab {
|
|||||||
pub plot_view: plot_view::PlotViewState,
|
pub plot_view: plot_view::PlotViewState,
|
||||||
pub view: View,
|
pub view: View,
|
||||||
pub auto_reconnect: bool,
|
pub auto_reconnect: bool,
|
||||||
|
pub dtr: bool,
|
||||||
|
pub rts: bool,
|
||||||
pub send_input: String,
|
pub send_input: String,
|
||||||
pub send_mode: SendMode,
|
pub send_mode: SendMode,
|
||||||
pub append_newline: bool,
|
pub line_ending: LineEnding,
|
||||||
pub send_status: Option<String>,
|
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 struct XserialApp {
|
||||||
@@ -86,6 +170,8 @@ pub struct XserialApp {
|
|||||||
config_form: config::ConfigForm,
|
config_form: config::ConfigForm,
|
||||||
event_rx: mpsc::Receiver<SessionEvent>,
|
event_rx: mpsc::Receiver<SessionEvent>,
|
||||||
pending: Vec<SessionEvent>,
|
pending: Vec<SessionEvent>,
|
||||||
|
profiler: GuiProfiler,
|
||||||
|
shortcut_bindings: Vec<(shortcuts::Action, egui::KeyboardShortcut)>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl XserialApp {
|
impl XserialApp {
|
||||||
@@ -94,16 +180,32 @@ impl XserialApp {
|
|||||||
mut rx: tokio::sync::broadcast::Receiver<SessionEvent>,
|
mut rx: tokio::sync::broadcast::Receiver<SessionEvent>,
|
||||||
ctx: egui::Context,
|
ctx: egui::Context,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
|
let profiler = GuiProfiler::from_env();
|
||||||
|
let repaint_counter = profiler.repaint_counter();
|
||||||
let (event_tx, event_rx) = mpsc::channel();
|
let (event_tx, event_rx) = mpsc::channel();
|
||||||
let repaint_ctx = ctx.clone();
|
let repaint_ctx = ctx.clone();
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
|
let mut last_repaint = Instant::now()
|
||||||
|
.checked_sub(DATA_REPAINT_INTERVAL)
|
||||||
|
.unwrap_or_else(Instant::now);
|
||||||
|
let mut delayed_repaint_pending = false;
|
||||||
loop {
|
loop {
|
||||||
match rx.recv().await {
|
match rx.recv().await {
|
||||||
Ok(event) => {
|
Ok(event) => {
|
||||||
if event_tx.send(event).is_err() {
|
if event_tx.send(event).is_err() {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
let elapsed = last_repaint.elapsed();
|
||||||
|
if elapsed >= DATA_REPAINT_INTERVAL {
|
||||||
|
repaint_counter.increment();
|
||||||
repaint_ctx.request_repaint();
|
repaint_ctx.request_repaint();
|
||||||
|
last_repaint = Instant::now();
|
||||||
|
delayed_repaint_pending = false;
|
||||||
|
} else if !delayed_repaint_pending {
|
||||||
|
repaint_counter.increment();
|
||||||
|
repaint_ctx.request_repaint_after(DATA_REPAINT_INTERVAL - elapsed);
|
||||||
|
delayed_repaint_pending = true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue,
|
Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue,
|
||||||
Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
|
Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
|
||||||
@@ -139,11 +241,126 @@ impl XserialApp {
|
|||||||
config_form: config::ConfigForm::default(),
|
config_form: config::ConfigForm::default(),
|
||||||
event_rx,
|
event_rx,
|
||||||
pending: Vec::new(),
|
pending: Vec::new(),
|
||||||
|
profiler,
|
||||||
|
shortcut_bindings: default_bindings(),
|
||||||
};
|
};
|
||||||
app.restore_saved_sessions(saved_state);
|
app.restore_saved_sessions(saved_state);
|
||||||
app
|
app
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn execute(&mut self, action: shortcuts::Action) {
|
||||||
|
use shortcuts::Action::*;
|
||||||
|
|
||||||
|
match action {
|
||||||
|
NextTab => {
|
||||||
|
if self.tabs.len() > 1 {
|
||||||
|
self.active = (self.active + 1) % self.tabs.len();
|
||||||
|
self.persist_gui_state();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
PrevTab => {
|
||||||
|
if self.tabs.len() > 1 {
|
||||||
|
self.active = if self.active == 0 {
|
||||||
|
self.tabs.len() - 1
|
||||||
|
} else {
|
||||||
|
self.active - 1
|
||||||
|
};
|
||||||
|
self.persist_gui_state();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ViewText => {
|
||||||
|
if let Some(tab) = self.tabs.get_mut(self.active) {
|
||||||
|
tab.view = View::Text;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ViewHex => {
|
||||||
|
if let Some(tab) = self.tabs.get_mut(self.active) {
|
||||||
|
tab.view = View::Hex;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ViewPlot => {
|
||||||
|
if let Some(tab) = self.tabs.get_mut(self.active) {
|
||||||
|
tab.view = View::Plot;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
NewSession => self.open_create_config(),
|
||||||
|
EditSession => {
|
||||||
|
if let Some(tab) = self.tabs.get(self.active) {
|
||||||
|
self.open_edit_config(tab.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
DeleteSession => {
|
||||||
|
if !self.tabs.is_empty() {
|
||||||
|
let id = self.tabs[self.active].id;
|
||||||
|
self.manager.remove(id);
|
||||||
|
self.tabs.remove(self.active);
|
||||||
|
if self.active >= self.tabs.len() && !self.tabs.is_empty() {
|
||||||
|
self.active = self.tabs.len() - 1;
|
||||||
|
}
|
||||||
|
self.persist_gui_state();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ToggleConnect => {
|
||||||
|
if let Some(tab) = self.tabs.get_mut(self.active) {
|
||||||
|
let connected = matches!(
|
||||||
|
tab.status,
|
||||||
|
ConnectionStatus::Connected | ConnectionStatus::Connecting
|
||||||
|
);
|
||||||
|
if let Some(handle) = self.manager.get(tab.id) {
|
||||||
|
if connected {
|
||||||
|
tab.status = ConnectionStatus::Disconnected;
|
||||||
|
let handle = handle.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let _ = handle.disconnect().await;
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
tab.status = ConnectionStatus::Connecting;
|
||||||
|
let handle = handle.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let _ = handle.connect().await;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
tab.status = ConnectionStatus::Error(String::from("session not found"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Clear => {
|
||||||
|
if let Some(tab) = self.tabs.get_mut(self.active) {
|
||||||
|
tab.console.clear();
|
||||||
|
tab.hex.clear();
|
||||||
|
tab.plot.clear();
|
||||||
|
tab.send_status = Some(String::from("Cleared"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
UiSettings => self.font_settings_open = true,
|
||||||
|
CloseOverlay => {
|
||||||
|
self.config_open = false;
|
||||||
|
self.font_settings_open = false;
|
||||||
|
}
|
||||||
|
Search => {
|
||||||
|
if let Some(tab) = self.tabs.get_mut(self.active) {
|
||||||
|
tab.search.active = true;
|
||||||
|
tab.search.just_opened = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
SearchNext => {
|
||||||
|
if let Some(tab) = self.tabs.get_mut(self.active)
|
||||||
|
&& tab.search.active
|
||||||
|
{
|
||||||
|
tab.search.next();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
SearchPrev => {
|
||||||
|
if let Some(tab) = self.tabs.get_mut(self.active)
|
||||||
|
&& tab.search.active
|
||||||
|
{
|
||||||
|
tab.search.prev();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn open_create_config(&mut self) {
|
fn open_create_config(&mut self) {
|
||||||
self.config_target = None;
|
self.config_target = None;
|
||||||
self.config_form = config::ConfigForm::default();
|
self.config_form = config::ConfigForm::default();
|
||||||
@@ -151,8 +368,17 @@ impl XserialApp {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn restore_saved_sessions(&mut self, saved_state: PersistedGuiState) {
|
fn restore_saved_sessions(&mut self, saved_state: PersistedGuiState) {
|
||||||
for session_config in saved_state.sessions {
|
for (i, session_config) in saved_state.sessions.into_iter().enumerate() {
|
||||||
self.add_session_tab(session_config);
|
self.add_session_tab(session_config);
|
||||||
|
if let Some(tab) = self.tabs.last_mut()
|
||||||
|
&& let Some(log_cfg) = saved_state.sessions_log.get(i)
|
||||||
|
{
|
||||||
|
tab.log_enabled = log_cfg.enabled;
|
||||||
|
tab.log_path = log_cfg.file_path.clone();
|
||||||
|
if log_cfg.enabled && !log_cfg.file_path.is_empty() {
|
||||||
|
tab.log_writer = LogWriter::open(&log_cfg.file_path).ok();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if !self.tabs.is_empty() {
|
if !self.tabs.is_empty() {
|
||||||
self.active = saved_state.active.min(self.tabs.len() - 1);
|
self.active = saved_state.active.min(self.tabs.len() - 1);
|
||||||
@@ -162,6 +388,10 @@ impl XserialApp {
|
|||||||
fn add_session_tab(&mut self, session_config: SessionConfig) {
|
fn add_session_tab(&mut self, session_config: SessionConfig) {
|
||||||
let history_limit = session_config.history_limit;
|
let history_limit = session_config.history_limit;
|
||||||
let auto_reconnect = session_config.auto_reconnect;
|
let auto_reconnect = session_config.auto_reconnect;
|
||||||
|
let (dtr, rts) = match &session_config.transport {
|
||||||
|
TransportConfig::Serial { dtr, rts, .. } => (*dtr, *rts),
|
||||||
|
_ => (false, false),
|
||||||
|
};
|
||||||
let handle = self.manager.create(session_config.clone());
|
let handle = self.manager.create(session_config.clone());
|
||||||
self.tabs.push(SessionTab {
|
self.tabs.push(SessionTab {
|
||||||
id: handle.id(),
|
id: handle.id(),
|
||||||
@@ -173,10 +403,16 @@ impl XserialApp {
|
|||||||
plot_view: plot_view::PlotViewState::default(),
|
plot_view: plot_view::PlotViewState::default(),
|
||||||
view: View::Text,
|
view: View::Text,
|
||||||
auto_reconnect,
|
auto_reconnect,
|
||||||
|
dtr,
|
||||||
|
rts,
|
||||||
send_input: String::new(),
|
send_input: String::new(),
|
||||||
send_mode: SendMode::Text,
|
send_mode: SendMode::Text,
|
||||||
append_newline: true,
|
line_ending: LineEnding::None,
|
||||||
send_status: None,
|
send_status: None,
|
||||||
|
search: SearchState::default(),
|
||||||
|
log_enabled: false,
|
||||||
|
log_path: String::new(),
|
||||||
|
log_writer: None,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -187,6 +423,14 @@ impl XserialApp {
|
|||||||
.iter()
|
.iter()
|
||||||
.map(|tab| tab.session_config.clone())
|
.map(|tab| tab.session_config.clone())
|
||||||
.collect(),
|
.collect(),
|
||||||
|
sessions_log: self
|
||||||
|
.tabs
|
||||||
|
.iter()
|
||||||
|
.map(|tab| SessionLogConfig {
|
||||||
|
enabled: tab.log_enabled,
|
||||||
|
file_path: tab.log_path.clone(),
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
active: if self.tabs.is_empty() {
|
active: if self.tabs.is_empty() {
|
||||||
0
|
0
|
||||||
} else {
|
} else {
|
||||||
@@ -230,11 +474,22 @@ impl XserialApp {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn drain_events(&mut self) {
|
fn drain_events(&mut self) {
|
||||||
|
let started = Instant::now();
|
||||||
|
let mut stats = DrainStats {
|
||||||
|
drained_events: 0,
|
||||||
|
drained_data_events: 0,
|
||||||
|
drained_text_events: 0,
|
||||||
|
drained_hex_events: 0,
|
||||||
|
drained_plot_events: 0,
|
||||||
|
pending_events: 0,
|
||||||
|
};
|
||||||
while let Ok(event) = self.event_rx.try_recv() {
|
while let Ok(event) = self.event_rx.try_recv() {
|
||||||
self.pending.push(event);
|
self.pending.push(event);
|
||||||
}
|
}
|
||||||
|
stats.pending_events = self.pending.len();
|
||||||
|
|
||||||
for event in self.pending.drain(..) {
|
for event in self.pending.drain(..) {
|
||||||
|
stats.drained_events += 1;
|
||||||
match event {
|
match event {
|
||||||
SessionEvent::Connected(id) => {
|
SessionEvent::Connected(id) => {
|
||||||
if let Some(tab) = self.tabs.iter_mut().find(|tab| tab.id == id) {
|
if let Some(tab) = self.tabs.iter_mut().find(|tab| tab.id == id) {
|
||||||
@@ -252,26 +507,73 @@ impl XserialApp {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
SessionEvent::Data(id, entry) => {
|
SessionEvent::Data(id, entry) => {
|
||||||
|
stats.drained_data_events += 1;
|
||||||
if let Some(tab) = self.tabs.iter_mut().find(|tab| tab.id == id) {
|
if let Some(tab) = self.tabs.iter_mut().find(|tab| tab.id == id) {
|
||||||
|
if let Some(ref writer) = tab.log_writer
|
||||||
|
&& let Some(line) = logging::format_data_log(
|
||||||
|
&entry,
|
||||||
|
"IN",
|
||||||
|
self.display.show_timestamp,
|
||||||
|
self.display.show_direction,
|
||||||
|
self.display.show_pipeline,
|
||||||
|
)
|
||||||
|
{
|
||||||
|
writer.write_line(&line);
|
||||||
|
}
|
||||||
match &entry.data {
|
match &entry.data {
|
||||||
DecodedData::Text(_) => tab.console.push(&entry),
|
DecodedData::Text(_) => {
|
||||||
DecodedData::Hex(_) => tab.hex.push(&entry),
|
stats.drained_text_events += 1;
|
||||||
DecodedData::Plot(_) => tab.plot.push(&entry),
|
tab.console.push(&entry);
|
||||||
|
}
|
||||||
|
DecodedData::Hex(_) => {
|
||||||
|
stats.drained_hex_events += 1;
|
||||||
|
tab.hex.push(&entry);
|
||||||
|
}
|
||||||
|
DecodedData::Plot(_) => {
|
||||||
|
stats.drained_plot_events += 1;
|
||||||
|
tab.plot.push(&entry);
|
||||||
|
}
|
||||||
DecodedData::Binary(_) => {}
|
DecodedData::Binary(_) => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
self.profiler.record_drain(started.elapsed(), stats);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn wants_live_plot_repaint(&self) -> bool {
|
fn wants_live_plot_repaint(&self) -> bool {
|
||||||
self.tabs
|
self.tabs.iter().enumerate().any(|(index, tab)| {
|
||||||
.get(self.active)
|
matches!(tab.status, ConnectionStatus::Connected)
|
||||||
.map(|tab| {
|
&& (tab.plot_view.detached
|
||||||
matches!(tab.view, View::Plot) && matches!(tab.status, ConnectionStatus::Connected)
|
|| (index == self.active && matches!(tab.view, View::Plot)))
|
||||||
})
|
})
|
||||||
.unwrap_or(false)
|
}
|
||||||
|
|
||||||
|
fn profiler_snapshot(&self) -> GuiSnapshot {
|
||||||
|
if let Some(tab) = self.tabs.get(self.active) {
|
||||||
|
GuiSnapshot {
|
||||||
|
tabs: self.tabs.len(),
|
||||||
|
active_view: match tab.view {
|
||||||
|
View::Text => "text",
|
||||||
|
View::Hex => "hex",
|
||||||
|
View::Plot => "plot",
|
||||||
|
},
|
||||||
|
active_text_lines: tab.console.len(),
|
||||||
|
active_hex_lines: tab.hex.len(),
|
||||||
|
active_plot_series: tab.plot.series_len(),
|
||||||
|
active_plot_points: tab.plot.total_points(),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
GuiSnapshot {
|
||||||
|
tabs: self.tabs.len(),
|
||||||
|
active_view: "none",
|
||||||
|
active_text_lines: 0,
|
||||||
|
active_hex_lines: 0,
|
||||||
|
active_plot_series: 0,
|
||||||
|
active_plot_points: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn render_config_window(&mut self, ctx: &egui::Context) {
|
fn render_config_window(&mut self, ctx: &egui::Context) {
|
||||||
@@ -313,6 +615,7 @@ impl XserialApp {
|
|||||||
|
|
||||||
fn render_sidebar(&mut self, ui: &mut egui::Ui) {
|
fn render_sidebar(&mut self, ui: &mut egui::Ui) {
|
||||||
let mut on_new = false;
|
let mut on_new = false;
|
||||||
|
let mut on_edit = None;
|
||||||
let mut on_delete = None;
|
let mut on_delete = None;
|
||||||
let previous_active = self.active;
|
let previous_active = self.active;
|
||||||
|
|
||||||
@@ -328,13 +631,26 @@ impl XserialApp {
|
|||||||
transport_summary: transport_summary(&tab.session_config.transport),
|
transport_summary: transport_summary(&tab.session_config.transport),
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
sidebar::render(ui, &sessions, &mut self.active, &mut on_new, &mut on_delete);
|
sidebar::render(
|
||||||
|
ui,
|
||||||
|
&sessions,
|
||||||
|
&mut self.active,
|
||||||
|
&mut on_new,
|
||||||
|
&mut on_edit,
|
||||||
|
&mut on_delete,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
if on_new {
|
if on_new {
|
||||||
self.open_create_config();
|
self.open_create_config();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if let Some(index) = on_edit
|
||||||
|
&& let Some(tab) = self.tabs.get(index)
|
||||||
|
{
|
||||||
|
self.open_edit_config(tab.id);
|
||||||
|
}
|
||||||
|
|
||||||
if let Some(index) = on_delete {
|
if let Some(index) = on_delete {
|
||||||
if let Some(tab) = self.tabs.get(index) {
|
if let Some(tab) = self.tabs.get(index) {
|
||||||
self.manager.remove(tab.id);
|
self.manager.remove(tab.id);
|
||||||
@@ -362,8 +678,10 @@ impl XserialApp {
|
|||||||
|
|
||||||
let manager = self.manager.clone();
|
let manager = self.manager.clone();
|
||||||
let display = &mut self.display;
|
let display = &mut self.display;
|
||||||
let mut edit_session = None;
|
|
||||||
let mut persist_state = false;
|
let mut persist_state = false;
|
||||||
|
let mut text_render = None;
|
||||||
|
let mut hex_render = None;
|
||||||
|
let mut plot_render = None;
|
||||||
|
|
||||||
{
|
{
|
||||||
let tab = &mut self.tabs[self.active];
|
let tab = &mut self.tabs[self.active];
|
||||||
@@ -392,11 +710,7 @@ impl XserialApp {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
let (configure_clicked, auto_reconnect_changed) =
|
let auto_reconnect_changed = render_session_controls(ui, &manager, tab);
|
||||||
render_session_controls(ui, &manager, tab);
|
|
||||||
if configure_clicked {
|
|
||||||
edit_session = Some(tab.id);
|
|
||||||
}
|
|
||||||
persist_state |= auto_reconnect_changed;
|
persist_state |= auto_reconnect_changed;
|
||||||
|
|
||||||
let mut display_changed = false;
|
let mut display_changed = false;
|
||||||
@@ -412,6 +726,8 @@ impl XserialApp {
|
|||||||
});
|
});
|
||||||
persist_state |= display_changed;
|
persist_state |= display_changed;
|
||||||
|
|
||||||
|
render_search_bar(ui, tab);
|
||||||
|
|
||||||
if let ConnectionStatus::Error(message) = &tab.status {
|
if let ConnectionStatus::Error(message) = &tab.status {
|
||||||
ui.label(egui::RichText::new(message).color(Color32::RED));
|
ui.label(egui::RichText::new(message).color(Color32::RED));
|
||||||
}
|
}
|
||||||
@@ -431,10 +747,41 @@ impl XserialApp {
|
|||||||
UiBuilder::new()
|
UiBuilder::new()
|
||||||
.max_rect(receive_rect)
|
.max_rect(receive_rect)
|
||||||
.layout(Layout::top_down(egui::Align::Min).with_cross_justify(true)),
|
.layout(Layout::top_down(egui::Align::Min).with_cross_justify(true)),
|
||||||
|ui| match tab.view {
|
|ui| {
|
||||||
View::Text => console::render(ui, &tab.console, *display),
|
let started = Instant::now();
|
||||||
View::Hex => hex_view::render(ui, &tab.hex, *display),
|
match tab.view {
|
||||||
View::Plot => plot_view::render(ui, &tab.plot, tab.id, &mut tab.plot_view),
|
View::Text => {
|
||||||
|
let line_count = console::render(ui, &tab.console, *display, tab.search.active.then_some(&tab.search));
|
||||||
|
text_render = Some((started.elapsed(), line_count));
|
||||||
|
}
|
||||||
|
View::Hex => {
|
||||||
|
let line_count = hex_view::render(ui, &tab.hex, *display, tab.search.active.then_some(&tab.search));
|
||||||
|
hex_render = Some((started.elapsed(), line_count));
|
||||||
|
}
|
||||||
|
View::Plot => {
|
||||||
|
if tab.plot_view.detached {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let output = plot_view::render(
|
||||||
|
ui,
|
||||||
|
&mut tab.plot,
|
||||||
|
tab.id,
|
||||||
|
&mut tab.plot_view,
|
||||||
|
);
|
||||||
|
if output.toggle_detached {
|
||||||
|
tab.plot_view.detached = true;
|
||||||
|
}
|
||||||
|
plot_render = Some((started.elapsed(), output.stats));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -446,8 +793,14 @@ impl XserialApp {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(id) = edit_session {
|
if let Some((elapsed, line_count)) = text_render {
|
||||||
self.open_edit_config(id);
|
self.profiler.record_text_render(elapsed, line_count);
|
||||||
|
}
|
||||||
|
if let Some((elapsed, line_count)) = hex_render {
|
||||||
|
self.profiler.record_hex_render(elapsed, line_count);
|
||||||
|
}
|
||||||
|
if let Some((elapsed, stats)) = plot_render {
|
||||||
|
self.profiler.record_plot_render(elapsed, stats);
|
||||||
}
|
}
|
||||||
if persist_state {
|
if persist_state {
|
||||||
self.persist_gui_state();
|
self.persist_gui_state();
|
||||||
@@ -455,10 +808,50 @@ impl XserialApp {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn render_detached_plot_windows(&mut self, ctx: &egui::Context) {
|
||||||
|
let mut plot_renders = Vec::new();
|
||||||
|
|
||||||
|
for tab in &mut self.tabs {
|
||||||
|
if !tab.plot_view.detached {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut open = true;
|
||||||
|
let mut output = None;
|
||||||
|
egui::Window::new(format!("Plot - Session {}", tab.id))
|
||||||
|
.open(&mut open)
|
||||||
|
.default_width(720.0)
|
||||||
|
.default_height(480.0)
|
||||||
|
.resizable(true)
|
||||||
|
.vscroll(false)
|
||||||
|
.show(ctx, |ui| {
|
||||||
|
let started = Instant::now();
|
||||||
|
let render_output =
|
||||||
|
plot_view::render(ui, &mut tab.plot, tab.id, &mut tab.plot_view);
|
||||||
|
output = Some((started.elapsed(), render_output));
|
||||||
|
});
|
||||||
|
|
||||||
|
if let Some((elapsed, render_output)) = output {
|
||||||
|
if render_output.toggle_detached {
|
||||||
|
tab.plot_view.detached = false;
|
||||||
|
}
|
||||||
|
plot_renders.push((elapsed, render_output.stats));
|
||||||
|
}
|
||||||
|
|
||||||
|
if !open {
|
||||||
|
tab.plot_view.detached = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (elapsed, stats) in plot_renders {
|
||||||
|
self.profiler.record_plot_render(elapsed, stats);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn render_top_bar(&mut self, ui: &mut egui::Ui) {
|
fn render_top_bar(&mut self, ui: &mut egui::Ui) {
|
||||||
Panel::top("top_bar").show_inside(ui, |ui| {
|
Panel::top("top_bar").show_inside(ui, |ui| {
|
||||||
ui.horizontal_wrapped(|ui| {
|
ui.horizontal_wrapped(|ui| {
|
||||||
ui.heading("xserial");
|
ui.heading("pipeview");
|
||||||
ui.separator();
|
ui.separator();
|
||||||
// ui.label(format!(
|
// ui.label(format!(
|
||||||
// "Fonts: {} + {} {:.1} pt",
|
// "Fonts: {} + {} {:.1} pt",
|
||||||
@@ -601,6 +994,7 @@ fn transport_summary(transport: &TransportConfig) -> String {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
fn render_font_selector(
|
fn render_font_selector(
|
||||||
ui: &mut egui::Ui,
|
ui: &mut egui::Ui,
|
||||||
title: &str,
|
title: &str,
|
||||||
@@ -686,6 +1080,16 @@ impl eframe::App for XserialApp {
|
|||||||
fn update(&mut self, _ctx: &egui::Context, _frame: &mut eframe::Frame) {}
|
fn update(&mut self, _ctx: &egui::Context, _frame: &mut eframe::Frame) {}
|
||||||
|
|
||||||
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
|
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
|
||||||
|
{
|
||||||
|
let supress = ui.ctx().egui_wants_keyboard_input();
|
||||||
|
let actions = shortcuts::process(ui.ctx(), supress, &self.shortcut_bindings);
|
||||||
|
|
||||||
|
for action in actions {
|
||||||
|
self.execute(action);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let frame_started = Instant::now();
|
||||||
self.drain_events();
|
self.drain_events();
|
||||||
if self.wants_live_plot_repaint() {
|
if self.wants_live_plot_repaint() {
|
||||||
ui.ctx().request_repaint_after(Duration::from_millis(16));
|
ui.ctx().request_repaint_after(Duration::from_millis(16));
|
||||||
@@ -695,53 +1099,108 @@ impl eframe::App for XserialApp {
|
|||||||
self.render_config_window(ui.ctx());
|
self.render_config_window(ui.ctx());
|
||||||
self.render_sidebar(ui);
|
self.render_sidebar(ui);
|
||||||
self.render_main_panel(ui);
|
self.render_main_panel(ui);
|
||||||
|
self.render_detached_plot_windows(ui.ctx());
|
||||||
|
self.profiler
|
||||||
|
.record_frame(frame_started.elapsed(), self.profiler_snapshot());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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(
|
fn render_session_controls(
|
||||||
ui: &mut egui::Ui,
|
ui: &mut egui::Ui,
|
||||||
manager: &SessionManager,
|
manager: &SessionManager,
|
||||||
tab: &mut SessionTab,
|
tab: &mut SessionTab,
|
||||||
) -> (bool, bool) {
|
) -> bool {
|
||||||
let mut configure_clicked = false;
|
|
||||||
let mut auto_reconnect_changed = false;
|
let mut auto_reconnect_changed = false;
|
||||||
ui.horizontal_wrapped(|ui| {
|
ui.horizontal_wrapped(|ui| {
|
||||||
if ui.button("Connect").clicked() {
|
let connected = matches!(
|
||||||
if let Some(handle) = manager.get(tab.id) {
|
tab.status,
|
||||||
tab.status = ConnectionStatus::Connecting;
|
ConnectionStatus::Connected | ConnectionStatus::Connecting
|
||||||
tokio::spawn(async move {
|
);
|
||||||
let _ = handle.connect().await;
|
let connect_label = if connected { "Disconnect" } else { "Connect" };
|
||||||
});
|
if ui.button(connect_label).clicked() {
|
||||||
} else {
|
|
||||||
tab.status = ConnectionStatus::Error(String::from("session not found"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if ui.button("Disconnect").clicked() {
|
|
||||||
if let Some(handle) = manager.get(tab.id) {
|
if let Some(handle) = manager.get(tab.id) {
|
||||||
|
if connected {
|
||||||
tab.status = ConnectionStatus::Disconnected;
|
tab.status = ConnectionStatus::Disconnected;
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
let _ = handle.disconnect().await;
|
let _ = handle.disconnect().await;
|
||||||
});
|
});
|
||||||
} else {
|
} 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;
|
tab.status = ConnectionStatus::Connecting;
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
let _ = handle.reconnect().await;
|
let _ = handle.connect().await;
|
||||||
});
|
});
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
tab.status = ConnectionStatus::Error(String::from("session not found"));
|
tab.status = ConnectionStatus::Error(String::from("session not found"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if ui.button("Configure").clicked() {
|
// if ui.button("Reconnect").clicked() {
|
||||||
configure_clicked = true;
|
// 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() {
|
if ui.button("Clear").clicked() {
|
||||||
tab.console.clear();
|
tab.console.clear();
|
||||||
@@ -763,8 +1222,30 @@ fn render_session_controls(
|
|||||||
tab.status = ConnectionStatus::Error(String::from("session not found"));
|
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;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
(configure_clicked, auto_reconnect_changed)
|
} 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) {
|
fn render_send_panel(ui: &mut egui::Ui, manager: &SessionManager, tab: &mut SessionTab) {
|
||||||
@@ -774,24 +1255,77 @@ fn render_send_panel(ui: &mut egui::Ui, manager: &SessionManager, tab: &mut Sess
|
|||||||
ui.selectable_value(&mut tab.send_mode, SendMode::Text, "Text");
|
ui.selectable_value(&mut tab.send_mode, SendMode::Text, "Text");
|
||||||
ui.selectable_value(&mut tab.send_mode, SendMode::Hex, "Hex");
|
ui.selectable_value(&mut tab.send_mode, SendMode::Hex, "Hex");
|
||||||
if tab.send_mode == SendMode::Text {
|
if tab.send_mode == SendMode::Text {
|
||||||
ui.checkbox(&mut tab.append_newline, "Append newline");
|
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);
|
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 {
|
let hint = match tab.send_mode {
|
||||||
SendMode::Text => "Enter text to send",
|
SendMode::Text => "Enter to send, Ctrl+Enter for newline",
|
||||||
SendMode::Hex => "Enter hex bytes, e.g. 48 65 6C 6C 6F",
|
SendMode::Hex => "Enter hex bytes, e.g. 48 65 6C 6C 6F",
|
||||||
};
|
};
|
||||||
let response = ui.add(
|
let response = ui.add(
|
||||||
TextEdit::multiline(&mut tab.send_input)
|
TextEdit::multiline(&mut tab.send_input)
|
||||||
.desired_rows(6)
|
.desired_rows(6)
|
||||||
.desired_width(f32::INFINITY)
|
.desired_width(f32::INFINITY)
|
||||||
|
.return_key(egui::KeyboardShortcut::new(
|
||||||
|
egui::Modifiers::COMMAND,
|
||||||
|
egui::Key::Enter,
|
||||||
|
))
|
||||||
.hint_text(hint),
|
.hint_text(hint),
|
||||||
);
|
);
|
||||||
|
|
||||||
let wants_submit = response.has_focus()
|
let wants_submit = response.has_focus()
|
||||||
&& ui.input(|input| input.key_pressed(egui::Key::Enter) && input.modifiers.command_only());
|
&& ui.input(|input| input.key_pressed(egui::Key::Enter) && input.modifiers.is_none());
|
||||||
|
|
||||||
let mut send_clicked = false;
|
let mut send_clicked = false;
|
||||||
ui.horizontal(|ui| {
|
ui.horizontal(|ui| {
|
||||||
@@ -816,13 +1350,18 @@ fn render_send_panel(ui: &mut egui::Ui, manager: &SessionManager, tab: &mut Sess
|
|||||||
if let Some(handle) = manager.get(tab.id) {
|
if let Some(handle) = manager.get(tab.id) {
|
||||||
match tab.send_mode {
|
match tab.send_mode {
|
||||||
SendMode::Text => {
|
SendMode::Text => {
|
||||||
let text = if tab.append_newline {
|
let mut text = tab.send_input.trim_end_matches('\n').to_string();
|
||||||
tab.send_input.trim_end_matches('\n').to_string()
|
|
||||||
} else {
|
|
||||||
tab.send_input.clone()
|
|
||||||
};
|
|
||||||
if !text.is_empty() {
|
if !text.is_empty() {
|
||||||
tab.console.push_outbound(text);
|
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 => {
|
SendMode::Hex => {
|
||||||
@@ -832,7 +1371,10 @@ fn render_send_panel(ui: &mut egui::Ui, manager: &SessionManager, tab: &mut Sess
|
|||||||
.collect::<Vec<_>>()
|
.collect::<Vec<_>>()
|
||||||
.join(" ");
|
.join(" ");
|
||||||
if !hex.is_empty() {
|
if !hex.is_empty() {
|
||||||
tab.hex.push_outbound(hex);
|
tab.hex.push_outbound(hex.clone());
|
||||||
|
if let Some(ref writer) = tab.log_writer {
|
||||||
|
writer.write_line(&logging::format_sent_log(&hex));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -854,6 +1396,16 @@ fn render_send_panel(ui: &mut egui::Ui, manager: &SessionManager, tab: &mut Sess
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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> {
|
fn build_payload(tab: &SessionTab) -> Result<Option<Vec<u8>>, String> {
|
||||||
let trimmed = tab.send_input.trim();
|
let trimmed = tab.send_input.trim();
|
||||||
if trimmed.is_empty() {
|
if trimmed.is_empty() {
|
||||||
@@ -862,9 +1414,12 @@ fn build_payload(tab: &SessionTab) -> Result<Option<Vec<u8>>, String> {
|
|||||||
|
|
||||||
match tab.send_mode {
|
match tab.send_mode {
|
||||||
SendMode::Text => {
|
SendMode::Text => {
|
||||||
let mut text = tab.send_input.clone();
|
let mut text = tab.send_input.trim_end_matches('\n').to_string();
|
||||||
if tab.append_newline && !text.ends_with('\n') {
|
match tab.line_ending {
|
||||||
text.push('\n');
|
LineEnding::None => {}
|
||||||
|
LineEnding::LF => text.push('\n'),
|
||||||
|
LineEnding::CR => text.push('\r'),
|
||||||
|
LineEnding::CRLF => text.push_str("\r\n"),
|
||||||
}
|
}
|
||||||
Ok(Some(text.into_bytes()))
|
Ok(Some(text.into_bytes()))
|
||||||
}
|
}
|
||||||
@@ -883,8 +1438,8 @@ fn build_payload(tab: &SessionTab) -> Result<Option<Vec<u8>>, String> {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::transport_summary;
|
use super::transport_summary;
|
||||||
use xserial_core::transport::TransportConfig;
|
use pipeview_core::transport::TransportConfig;
|
||||||
use xserial_core::transport::serial::{
|
use pipeview_core::transport::serial::{
|
||||||
SerialDataBits, SerialFlowControl, SerialParity, SerialStopBits,
|
SerialDataBits, SerialFlowControl, SerialParity, SerialStopBits,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -905,6 +1460,8 @@ mod tests {
|
|||||||
parity: SerialParity::None,
|
parity: SerialParity::None,
|
||||||
stop_bits: SerialStopBits::One,
|
stop_bits: SerialStopBits::One,
|
||||||
flow_control: SerialFlowControl::None,
|
flow_control: SerialFlowControl::None,
|
||||||
|
dtr: false,
|
||||||
|
rts: false,
|
||||||
}),
|
}),
|
||||||
"Serial COM7"
|
"Serial COM7"
|
||||||
);
|
);
|
||||||
@@ -3,17 +3,27 @@ use std::fs;
|
|||||||
use std::io;
|
use std::io;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use pipeview_client::config::SessionConfig;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use tracing::warn;
|
use tracing::warn;
|
||||||
use xserial_client::config::SessionConfig;
|
|
||||||
|
|
||||||
const GUI_STATE_FILE_NAME: &str = "gui-state.json";
|
const GUI_STATE_FILE_NAME: &str = "gui-state.json";
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
|
pub struct SessionLogConfig {
|
||||||
|
#[serde(default)]
|
||||||
|
pub enabled: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
pub file_path: String,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
pub struct PersistedGuiState {
|
pub struct PersistedGuiState {
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub sessions: Vec<SessionConfig>,
|
pub sessions: Vec<SessionConfig>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
|
pub sessions_log: Vec<SessionLogConfig>,
|
||||||
|
#[serde(default)]
|
||||||
pub active: usize,
|
pub active: usize,
|
||||||
#[serde(default = "default_true")]
|
#[serde(default = "default_true")]
|
||||||
pub show_timestamp: bool,
|
pub show_timestamp: bool,
|
||||||
@@ -44,17 +54,25 @@ fn gui_state_path() -> PathBuf {
|
|||||||
gui_state_path_for_os_and_env(env::consts::OS, |key| env::var(key).ok())
|
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).
|
||||||
|
pub fn config_dir() -> PathBuf {
|
||||||
|
gui_state_path()
|
||||||
|
.parent()
|
||||||
|
.map(Path::to_path_buf)
|
||||||
|
.unwrap_or_else(|| PathBuf::from("."))
|
||||||
|
}
|
||||||
|
|
||||||
fn gui_state_path_for_os_and_env(os: &str, get_env: impl Fn(&str) -> Option<String>) -> PathBuf {
|
fn gui_state_path_for_os_and_env(os: &str, get_env: impl Fn(&str) -> Option<String>) -> PathBuf {
|
||||||
match os {
|
match os {
|
||||||
"windows" => {
|
"windows" => {
|
||||||
if let Some(path) = get_env("APPDATA").filter(|path| !path.trim().is_empty()) {
|
if let Some(path) = get_env("APPDATA").filter(|path| !path.trim().is_empty()) {
|
||||||
return PathBuf::from(path)
|
return PathBuf::from(path)
|
||||||
.join("xserial")
|
.join("pipeview")
|
||||||
.join(GUI_STATE_FILE_NAME);
|
.join(GUI_STATE_FILE_NAME);
|
||||||
}
|
}
|
||||||
if let Some(path) = get_env("LOCALAPPDATA").filter(|path| !path.trim().is_empty()) {
|
if let Some(path) = get_env("LOCALAPPDATA").filter(|path| !path.trim().is_empty()) {
|
||||||
return PathBuf::from(path)
|
return PathBuf::from(path)
|
||||||
.join("xserial")
|
.join("pipeview")
|
||||||
.join(GUI_STATE_FILE_NAME);
|
.join(GUI_STATE_FILE_NAME);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -63,20 +81,20 @@ fn gui_state_path_for_os_and_env(os: &str, get_env: impl Fn(&str) -> Option<Stri
|
|||||||
return PathBuf::from(home)
|
return PathBuf::from(home)
|
||||||
.join("Library")
|
.join("Library")
|
||||||
.join("Application Support")
|
.join("Application Support")
|
||||||
.join("xserial")
|
.join("pipeview")
|
||||||
.join(GUI_STATE_FILE_NAME);
|
.join(GUI_STATE_FILE_NAME);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
_ => {
|
_ => {
|
||||||
if let Some(path) = get_env("XDG_CONFIG_HOME").filter(|path| !path.trim().is_empty()) {
|
if let Some(path) = get_env("XDG_CONFIG_HOME").filter(|path| !path.trim().is_empty()) {
|
||||||
return PathBuf::from(path)
|
return PathBuf::from(path)
|
||||||
.join("xserial")
|
.join("pipeview")
|
||||||
.join(GUI_STATE_FILE_NAME);
|
.join(GUI_STATE_FILE_NAME);
|
||||||
}
|
}
|
||||||
if let Some(home) = get_env("HOME").filter(|path| !path.trim().is_empty()) {
|
if let Some(home) = get_env("HOME").filter(|path| !path.trim().is_empty()) {
|
||||||
return PathBuf::from(home)
|
return PathBuf::from(home)
|
||||||
.join(".config")
|
.join(".config")
|
||||||
.join("xserial")
|
.join("pipeview")
|
||||||
.join(GUI_STATE_FILE_NAME);
|
.join(GUI_STATE_FILE_NAME);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -105,8 +123,8 @@ const fn default_true() -> bool {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
use pipeview_core::transport::TransportConfig;
|
||||||
use std::time::{SystemTime, UNIX_EPOCH};
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
use xserial_core::transport::TransportConfig;
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn windows_gui_state_path_uses_appdata() {
|
fn windows_gui_state_path_uses_appdata() {
|
||||||
@@ -117,7 +135,7 @@ mod tests {
|
|||||||
assert_eq!(
|
assert_eq!(
|
||||||
path,
|
path,
|
||||||
PathBuf::from(r"C:\Users\Test\AppData\Roaming")
|
PathBuf::from(r"C:\Users\Test\AppData\Roaming")
|
||||||
.join("xserial")
|
.join("pipeview")
|
||||||
.join(GUI_STATE_FILE_NAME)
|
.join(GUI_STATE_FILE_NAME)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -128,7 +146,7 @@ mod tests {
|
|||||||
.duration_since(UNIX_EPOCH)
|
.duration_since(UNIX_EPOCH)
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.as_nanos();
|
.as_nanos();
|
||||||
let dir = env::temp_dir().join(format!("xserial-gui-state-{unique}"));
|
let dir = env::temp_dir().join(format!("pipeview-gui-state-{unique}"));
|
||||||
let path = dir.join(GUI_STATE_FILE_NAME);
|
let path = dir.join(GUI_STATE_FILE_NAME);
|
||||||
let state = PersistedGuiState {
|
let state = PersistedGuiState {
|
||||||
sessions: vec![SessionConfig {
|
sessions: vec![SessionConfig {
|
||||||
@@ -137,6 +155,7 @@ mod tests {
|
|||||||
},
|
},
|
||||||
..SessionConfig::default()
|
..SessionConfig::default()
|
||||||
}],
|
}],
|
||||||
|
sessions_log: vec![SessionLogConfig::default()],
|
||||||
active: 0,
|
active: 0,
|
||||||
show_timestamp: false,
|
show_timestamp: false,
|
||||||
show_direction: true,
|
show_direction: true,
|
||||||
@@ -1,9 +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::collections::VecDeque;
|
||||||
use std::time::{Duration, Instant};
|
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)]
|
#[derive(Clone)]
|
||||||
pub enum LineDirection {
|
pub enum LineDirection {
|
||||||
@@ -51,8 +52,12 @@ impl TextBuffer {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn iter(&self) -> impl Iterator<Item = &ConsoleLine> {
|
pub fn get(&self, index: usize) -> Option<&ConsoleLine> {
|
||||||
self.lines.iter()
|
self.lines.get(index)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn len(&self) -> usize {
|
||||||
|
self.lines.len()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn clear(&mut self) {
|
pub fn clear(&mut self) {
|
||||||
@@ -62,6 +67,32 @@ impl TextBuffer {
|
|||||||
pub fn set_limit(&mut self, limit: usize) {
|
pub fn set_limit(&mut self, limit: usize) {
|
||||||
self.lines.set_limit(limit);
|
self.lines.set_limit(limit);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// pub fn iter(&self) -> impl Iterator<Item = &ConsoleLine> {
|
||||||
|
// (0..self.lines.len()).filter_map(|i| self.lines.get(i))
|
||||||
|
// }
|
||||||
|
|
||||||
|
pub fn search(&self, query: &str, case_sensitive: bool) -> Vec<usize> {
|
||||||
|
if query.is_empty() {
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
let query_lower = if case_sensitive {
|
||||||
|
String::new()
|
||||||
|
} else {
|
||||||
|
query.to_lowercase()
|
||||||
|
};
|
||||||
|
(0..self.lines.len())
|
||||||
|
.filter(|&i| {
|
||||||
|
self.lines.get(i).is_some_and(|line| {
|
||||||
|
if case_sensitive {
|
||||||
|
line.text.contains(query)
|
||||||
|
} else {
|
||||||
|
line.text.to_lowercase().contains(&query_lower)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
@@ -135,8 +166,12 @@ impl HexBuffer {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn iter(&self) -> impl Iterator<Item = &HexLine> {
|
pub fn get(&self, index: usize) -> Option<&HexLine> {
|
||||||
self.lines.iter()
|
self.lines.get(index)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn len(&self) -> usize {
|
||||||
|
self.lines.len()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn clear(&mut self) {
|
pub fn clear(&mut self) {
|
||||||
@@ -146,6 +181,33 @@ impl HexBuffer {
|
|||||||
pub fn set_limit(&mut self, limit: usize) {
|
pub fn set_limit(&mut self, limit: usize) {
|
||||||
self.lines.set_limit(limit);
|
self.lines.set_limit(limit);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// pub fn iter(&self) -> impl Iterator<Item = &HexLine> {
|
||||||
|
// (0..self.lines.len()).filter_map(|i| self.lines.get(i))
|
||||||
|
// }
|
||||||
|
|
||||||
|
pub fn search(&self, query: &str, case_sensitive: bool) -> Vec<usize> {
|
||||||
|
if query.is_empty() {
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
let query_lower = if case_sensitive {
|
||||||
|
String::new()
|
||||||
|
} else {
|
||||||
|
query.to_lowercase()
|
||||||
|
};
|
||||||
|
(0..self.lines.len())
|
||||||
|
.filter(|&i| {
|
||||||
|
self.lines.get(i).is_some_and(|line| {
|
||||||
|
if case_sensitive {
|
||||||
|
line.hex.contains(query) || line.ascii.contains(query)
|
||||||
|
} else {
|
||||||
|
line.hex.to_lowercase().contains(&query_lower)
|
||||||
|
|| line.ascii.to_lowercase().contains(&query_lower)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct PlotSeries {
|
pub struct PlotSeries {
|
||||||
@@ -168,11 +230,17 @@ impl PlotSeries {
|
|||||||
if !sample.is_finite() {
|
if !sample.is_finite() {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
self.points.push_back([self.next_x, *sample]);
|
self.push_point([self.next_x, *sample], limit);
|
||||||
self.next_x += 1.0;
|
self.next_x += 1.0;
|
||||||
}
|
}
|
||||||
while self.points.len() > limit {
|
}
|
||||||
self.points.pop_front();
|
|
||||||
|
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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -180,26 +248,56 @@ impl PlotSeries {
|
|||||||
self.points.iter().copied()
|
self.points.iter().copied()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn render_points_time_series(&self, x_min: f64, x_max: f64, max_points: usize) -> Vec<[f64; 2]> {
|
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 {
|
if self.points.is_empty() || max_points == 0 {
|
||||||
return Vec::new();
|
return Vec::new();
|
||||||
}
|
}
|
||||||
|
|
||||||
let visible: Vec<[f64; 2]> = self
|
let mut exact_visible = Vec::with_capacity(max_points.min(self.points.len()));
|
||||||
.points
|
for point in self.points.iter().copied() {
|
||||||
.iter()
|
if point[0] < x_min {
|
||||||
.copied()
|
continue;
|
||||||
.filter(|point| point[0] >= x_min && point[0] <= x_max)
|
}
|
||||||
.collect();
|
if point[0] > x_max {
|
||||||
if visible.len() <= max_points {
|
break;
|
||||||
return visible;
|
}
|
||||||
|
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 bucket_count = (max_points / 2).max(1);
|
||||||
let width = (x_max - x_min).max(1.0);
|
let width = (x_max - x_min).max(1.0);
|
||||||
let bucket_width = width / bucket_count as f64;
|
let bucket_width = width / bucket_count as f64;
|
||||||
let mut rendered = Vec::with_capacity(bucket_count * 2);
|
let mut rendered = Vec::with_capacity(bucket_count * 2);
|
||||||
let mut start = 0usize;
|
let mut points = self
|
||||||
|
.points
|
||||||
|
.iter()
|
||||||
|
.copied()
|
||||||
|
.skip_while(|point| point[0] < x_min)
|
||||||
|
.peekable();
|
||||||
|
|
||||||
for bucket_index in 0..bucket_count {
|
for bucket_index in 0..bucket_count {
|
||||||
let bucket_start = x_min + bucket_width * bucket_index as f64;
|
let bucket_start = x_min + bucket_width * bucket_index as f64;
|
||||||
@@ -212,8 +310,7 @@ impl PlotSeries {
|
|||||||
let mut min_point: Option<[f64; 2]> = None;
|
let mut min_point: Option<[f64; 2]> = None;
|
||||||
let mut max_point: Option<[f64; 2]> = None;
|
let mut max_point: Option<[f64; 2]> = None;
|
||||||
|
|
||||||
while start < visible.len() {
|
while let Some(point) = points.peek().copied() {
|
||||||
let point = visible[start];
|
|
||||||
if point[0] > bucket_end {
|
if point[0] > bucket_end {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -227,7 +324,7 @@ impl PlotSeries {
|
|||||||
_ => max_point = Some(point),
|
_ => max_point = Some(point),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
start += 1;
|
points.next();
|
||||||
}
|
}
|
||||||
|
|
||||||
match (min_point, max_point) {
|
match (min_point, max_point) {
|
||||||
@@ -274,10 +371,69 @@ pub enum PlotSeriesKind {
|
|||||||
XY,
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
fn into_plot_bounds(self) -> PlotBounds {
|
||||||
|
let mut bounds =
|
||||||
|
PlotBounds::from_min_max([self.min_x, self.min_y], [self.max_x, self.max_y]);
|
||||||
|
|
||||||
|
if bounds.width() <= 0.0 {
|
||||||
|
bounds.set_x_center_width(bounds.center().x, 1.0);
|
||||||
|
} else {
|
||||||
|
bounds.expand_x(bounds.width() * 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
if bounds.height() <= 0.0 {
|
||||||
|
bounds.set_y_center_height(bounds.center().y, 1.0);
|
||||||
|
} else {
|
||||||
|
bounds.expand_y(bounds.height() * 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
bounds
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub struct PlotBuffer {
|
pub struct PlotBuffer {
|
||||||
limit: usize,
|
limit: usize,
|
||||||
kind: PlotSeriesKind,
|
kind: PlotSeriesKind,
|
||||||
series: Vec<PlotSeries>,
|
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 {
|
impl PlotBuffer {
|
||||||
@@ -286,6 +442,10 @@ impl PlotBuffer {
|
|||||||
limit,
|
limit,
|
||||||
kind: PlotSeriesKind::TimeSeries,
|
kind: PlotSeriesKind::TimeSeries,
|
||||||
series: Vec::new(),
|
series: Vec::new(),
|
||||||
|
time_series_y_bounds: None,
|
||||||
|
time_series_y_dirty: false,
|
||||||
|
xy_bounds: None,
|
||||||
|
xy_bounds_dirty: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -296,10 +456,18 @@ impl PlotBuffer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn push_frame(&mut self, pipeline_name: &str, frame: &PlotFrame) {
|
fn push_frame(&mut self, pipeline_name: &str, frame: &PlotFrame) {
|
||||||
self.kind = match frame.format {
|
let next_kind = match frame.format {
|
||||||
PlotFormat::XY => PlotSeriesKind::XY,
|
PlotFormat::XY => PlotSeriesKind::XY,
|
||||||
PlotFormat::Interleaved | PlotFormat::Block => PlotSeriesKind::TimeSeries,
|
PlotFormat::Interleaved | PlotFormat::Block => PlotSeriesKind::TimeSeries,
|
||||||
};
|
};
|
||||||
|
if !matches!(
|
||||||
|
(&self.kind, &next_kind),
|
||||||
|
(PlotSeriesKind::TimeSeries, PlotSeriesKind::TimeSeries)
|
||||||
|
| (PlotSeriesKind::XY, PlotSeriesKind::XY)
|
||||||
|
) {
|
||||||
|
self.invalidate_bounds();
|
||||||
|
}
|
||||||
|
self.kind = next_kind;
|
||||||
|
|
||||||
if matches!(frame.format, PlotFormat::XY) {
|
if matches!(frame.format, PlotFormat::XY) {
|
||||||
self.push_xy_frame(pipeline_name, frame);
|
self.push_xy_frame(pipeline_name, frame);
|
||||||
@@ -313,15 +481,33 @@ impl PlotBuffer {
|
|||||||
format!("{pipeline_name}:ch{}", index + 1)
|
format!("{pipeline_name}:ch{}", index + 1)
|
||||||
};
|
};
|
||||||
|
|
||||||
if let Some(series) = self
|
if let Some(series_index) = self
|
||||||
.series
|
.series
|
||||||
.iter_mut()
|
.iter()
|
||||||
.find(|series| series.name == series_name)
|
.position(|series| series.name == series_name)
|
||||||
{
|
{
|
||||||
series.push_samples(channel, self.limit);
|
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 {
|
} else {
|
||||||
let mut series = PlotSeries::new(series_name);
|
let mut series = PlotSeries::new(series_name);
|
||||||
series.push_samples(channel, self.limit);
|
series.push_samples(channel, self.limit);
|
||||||
|
for point in series.points() {
|
||||||
|
self.note_time_series_point(point);
|
||||||
|
}
|
||||||
self.series.push(series);
|
self.series.push(series);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -337,30 +523,36 @@ impl PlotBuffer {
|
|||||||
let len = x.len().min(y.len());
|
let len = x.len().min(y.len());
|
||||||
let series_name = format!("{pipeline_name}:xy");
|
let series_name = format!("{pipeline_name}:xy");
|
||||||
|
|
||||||
let series = if let Some(series) = self
|
let series_index = if let Some(index) = self
|
||||||
.series
|
.series
|
||||||
.iter_mut()
|
.iter()
|
||||||
.find(|series| series.name == series_name)
|
.position(|series| series.name == series_name)
|
||||||
{
|
{
|
||||||
series
|
index
|
||||||
} else {
|
} else {
|
||||||
self.series.push(PlotSeries::new(series_name));
|
self.series.push(PlotSeries::new(series_name));
|
||||||
self.series.last_mut().expect("just pushed")
|
self.series.len() - 1
|
||||||
};
|
};
|
||||||
|
|
||||||
for index in 0..len {
|
for index in 0..len {
|
||||||
if !x[index].is_finite() || !y[index].is_finite() {
|
if !x[index].is_finite() || !y[index].is_finite() {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
series.points.push_back([x[index], y[index]]);
|
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);
|
||||||
}
|
}
|
||||||
while series.points.len() > self.limit {
|
self.note_xy_point(point);
|
||||||
series.points.pop_front();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn clear(&mut self) {
|
pub fn clear(&mut self) {
|
||||||
self.series.clear();
|
self.series.clear();
|
||||||
|
self.invalidate_bounds();
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn set_limit(&mut self, limit: usize) {
|
pub fn set_limit(&mut self, limit: usize) {
|
||||||
@@ -370,6 +562,7 @@ impl PlotBuffer {
|
|||||||
series.points.pop_front();
|
series.points.pop_front();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
self.invalidate_bounds();
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn is_empty(&self) -> bool {
|
pub fn is_empty(&self) -> bool {
|
||||||
@@ -383,14 +576,149 @@ impl PlotBuffer {
|
|||||||
pub fn iter(&self) -> impl Iterator<Item = &PlotSeries> {
|
pub fn iter(&self) -> impl Iterator<Item = &PlotSeries> {
|
||||||
self.series.iter()
|
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 plot_bounds(&mut self) -> Option<PlotBounds> {
|
||||||
|
match self.kind {
|
||||||
|
PlotSeriesKind::TimeSeries => self.time_series_plot_bounds(),
|
||||||
|
PlotSeriesKind::XY => self.xy_plot_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_plot_bounds(&mut self) -> Option<PlotBounds> {
|
||||||
|
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 (min_y, max_y) = self.time_series_y_bounds?;
|
||||||
|
|
||||||
|
Some(
|
||||||
|
BoundsRect {
|
||||||
|
min_x,
|
||||||
|
max_x,
|
||||||
|
min_y,
|
||||||
|
max_y,
|
||||||
|
}
|
||||||
|
.into_plot_bounds(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn xy_plot_bounds(&mut self) -> Option<PlotBounds> {
|
||||||
|
if self.xy_bounds_dirty {
|
||||||
|
self.recompute_xy_bounds();
|
||||||
|
}
|
||||||
|
self.xy_bounds.map(BoundsRect::into_plot_bounds)
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use xserial_client::event::DecodedEntry;
|
use pipeview_client::event::DecodedEntry;
|
||||||
use xserial_core::protocol::DecodedData;
|
use pipeview_core::protocol::DecodedData;
|
||||||
use xserial_core::protocol::plot::{PlotFrame, SampleType};
|
use pipeview_core::protocol::plot::{PlotFrame, SampleType};
|
||||||
|
|
||||||
fn plot_entry() -> DecodedEntry {
|
fn plot_entry() -> DecodedEntry {
|
||||||
DecodedEntry {
|
DecodedEntry {
|
||||||
@@ -459,7 +787,11 @@ mod tests {
|
|||||||
let rendered = series.render_points_time_series(0.0, 99.0, 16);
|
let rendered = series.render_points_time_series(0.0, 99.0, 16);
|
||||||
assert!(!rendered.is_empty());
|
assert!(!rendered.is_empty());
|
||||||
assert!(rendered.len() <= 16);
|
assert!(rendered.len() <= 16);
|
||||||
assert!(rendered.iter().all(|point| point[0] >= 0.0 && point[0] <= 99.0));
|
assert!(
|
||||||
|
rendered
|
||||||
|
.iter()
|
||||||
|
.all(|point| point[0] >= 0.0 && point[0] <= 99.0)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
109
crates/pipeview-gui/src/logging.rs
Normal file
109
crates/pipeview-gui/src/logging.rs
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
use std::fs::{self, OpenOptions};
|
||||||
|
use std::io::{BufWriter, Write};
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::sync::mpsc;
|
||||||
|
use std::thread;
|
||||||
|
|
||||||
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
|
use pipeview_client::event::DecodedEntry;
|
||||||
|
use pipeview_core::protocol::DecodedData;
|
||||||
|
|
||||||
|
/// Manages background file logging via a dedicated writer thread.
|
||||||
|
///
|
||||||
|
/// Lines are sent through an mpsc channel to a background thread that
|
||||||
|
/// appends to the log file using buffered I/O, keeping the GUI thread
|
||||||
|
/// non-blocking.
|
||||||
|
pub struct LogWriter {
|
||||||
|
sender: mpsc::Sender<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LogWriter {
|
||||||
|
/// Spawns a background thread that opens `path` in append mode and
|
||||||
|
/// writes every line it receives through the returned `LogWriter`.
|
||||||
|
pub fn open(path: impl Into<PathBuf>) -> std::io::Result<Self> {
|
||||||
|
let path: PathBuf = path.into();
|
||||||
|
if let Some(parent) = path.parent() {
|
||||||
|
fs::create_dir_all(parent)?;
|
||||||
|
}
|
||||||
|
let file = OpenOptions::new().create(true).append(true).open(&path)?;
|
||||||
|
let mut writer = BufWriter::with_capacity(1024, file);
|
||||||
|
let (sender, receiver) = mpsc::channel::<String>();
|
||||||
|
|
||||||
|
let thread_name = format!("pipeview-log-{}", path.display());
|
||||||
|
thread::Builder::new().name(thread_name).spawn(move || {
|
||||||
|
while let Ok(line) = receiver.recv() {
|
||||||
|
if writeln!(writer, "{line}").is_err() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let _ = writer.flush();
|
||||||
|
})?;
|
||||||
|
|
||||||
|
Ok(Self { sender })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Send a line to the background writer. Non-blocking — drops the line
|
||||||
|
/// silently if the writer thread has stopped.
|
||||||
|
pub fn write_line(&self, line: &str) {
|
||||||
|
let _ = self.sender.send(line.to_owned());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for LogWriter {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
// Sender is dropped here, which causes the background thread's
|
||||||
|
// receiver.recv() to return Err, triggering flush + exit.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Format a [`DecodedEntry`] into a log line.
|
||||||
|
///
|
||||||
|
/// Format: `[HH:MM:SS.mmm] [IN] [pipeline] payload`
|
||||||
|
///
|
||||||
|
/// Returns `None` for Binary and Plot entries (not meaningful as text logs).
|
||||||
|
pub fn format_data_log(
|
||||||
|
entry: &DecodedEntry,
|
||||||
|
direction: &str,
|
||||||
|
show_timestamp: bool,
|
||||||
|
show_direction: bool,
|
||||||
|
show_pipeline: bool,
|
||||||
|
) -> Option<String> {
|
||||||
|
let data = match &entry.data {
|
||||||
|
DecodedData::Text(text) => text.clone(),
|
||||||
|
DecodedData::Hex(hex) => hex.clone(),
|
||||||
|
DecodedData::Binary(_) | DecodedData::Plot(_) => return None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut parts = Vec::with_capacity(4);
|
||||||
|
if show_timestamp {
|
||||||
|
parts.push(utc_timestamp());
|
||||||
|
}
|
||||||
|
if show_direction {
|
||||||
|
parts.push(format!("[{direction}]"));
|
||||||
|
}
|
||||||
|
if show_pipeline {
|
||||||
|
parts.push(format!("[{}]", entry.pipeline_name));
|
||||||
|
}
|
||||||
|
parts.push(data);
|
||||||
|
|
||||||
|
Some(parts.join(" "))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Format a sent text string into a log line (no `DecodedEntry` available
|
||||||
|
/// for outbound data).
|
||||||
|
pub fn format_sent_log(text: &str) -> String {
|
||||||
|
format!("{} [OUT] {text}", utc_timestamp())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn utc_timestamp() -> String {
|
||||||
|
let since_epoch = SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.unwrap_or_default();
|
||||||
|
let secs = since_epoch.as_secs();
|
||||||
|
let ms = since_epoch.subsec_millis();
|
||||||
|
let h = (secs / 3600) % 24;
|
||||||
|
let m = (secs / 60) % 60;
|
||||||
|
let s = secs % 60;
|
||||||
|
format!("{h:02}:{m:02}:{s:02}.{ms:03}")
|
||||||
|
}
|
||||||
65
crates/pipeview-gui/src/main.rs
Normal file
65
crates/pipeview-gui/src/main.rs
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
#![cfg_attr(windows, windows_subsystem = "windows")]
|
||||||
|
|
||||||
|
mod ansi_render;
|
||||||
|
mod app;
|
||||||
|
mod app_state;
|
||||||
|
mod buffers;
|
||||||
|
mod logging;
|
||||||
|
mod panels;
|
||||||
|
mod perf;
|
||||||
|
mod shortcuts;
|
||||||
|
mod ui_fonts;
|
||||||
|
|
||||||
|
use pipeview_client::SessionManager;
|
||||||
|
|
||||||
|
/// On Linux, when launched from a file manager, a terminal emulator may be spawned
|
||||||
|
/// to run this binary. We re-spawn ourselves detached so the terminal can close
|
||||||
|
/// while the GUI window persists.
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
fn ensure_detached() {
|
||||||
|
use std::os::unix::process::CommandExt;
|
||||||
|
use std::process;
|
||||||
|
|
||||||
|
if std::env::var("PIPEVIEW_DETACHED").is_ok() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let exe = match std::env::current_exe() {
|
||||||
|
Ok(p) => p,
|
||||||
|
Err(_) => return,
|
||||||
|
};
|
||||||
|
|
||||||
|
let args: Vec<String> = std::env::args().skip(1).collect();
|
||||||
|
|
||||||
|
let _ = process::Command::new(&exe)
|
||||||
|
.args(&args)
|
||||||
|
.env("PIPEVIEW_DETACHED", "1")
|
||||||
|
.stdin(process::Stdio::null())
|
||||||
|
.stdout(process::Stdio::null())
|
||||||
|
.stderr(process::Stdio::null())
|
||||||
|
.process_group(0)
|
||||||
|
.spawn();
|
||||||
|
|
||||||
|
process::exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
ensure_detached();
|
||||||
|
|
||||||
|
tracing_subscriber::fmt()
|
||||||
|
.with_env_filter(tracing_subscriber::EnvFilter::from_default_env())
|
||||||
|
.init();
|
||||||
|
|
||||||
|
let rt = tokio::runtime::Runtime::new().expect("tokio runtime");
|
||||||
|
let _guard = rt.enter();
|
||||||
|
|
||||||
|
let mgr = SessionManager::new();
|
||||||
|
let rx = mgr.subscribe();
|
||||||
|
|
||||||
|
let _ = eframe::run_native(
|
||||||
|
"pipeview",
|
||||||
|
eframe::NativeOptions::default(),
|
||||||
|
Box::new(|cc| Ok(Box::new(app::XserialApp::new(mgr, rx, cc.egui_ctx.clone())))),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
use egui::{ComboBox, DragValue, ScrollArea, TextEdit, Ui};
|
use egui::{ComboBox, DragValue, ScrollArea, TextEdit, Ui};
|
||||||
use xserial_client::config::{DecoderConfig, FramerConfig, PipelineConfig, SessionConfig};
|
use pipeview_client::config::{DecoderConfig, FramerConfig, PipelineConfig, SessionConfig};
|
||||||
use xserial_core::protocol::Endian;
|
use pipeview_core::protocol::Endian;
|
||||||
use xserial_core::protocol::plot::{PlotFormat, SampleType};
|
use pipeview_core::protocol::plot::{PlotFormat, SampleType};
|
||||||
use xserial_core::protocol::text::TextEncoding;
|
use pipeview_core::protocol::text::TextEncoding;
|
||||||
use xserial_core::transport::TransportConfig;
|
use pipeview_core::transport::TransportConfig;
|
||||||
use xserial_core::transport::serial::{
|
use pipeview_core::transport::serial::{
|
||||||
SerialDataBits, SerialFlowControl, SerialParity, SerialStopBits,
|
SerialDataBits, SerialFlowControl, SerialParity, SerialStopBits, SerialTransport,
|
||||||
};
|
};
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
@@ -17,6 +17,8 @@ pub struct ConfigForm {
|
|||||||
pub serial_parity: SerialParity,
|
pub serial_parity: SerialParity,
|
||||||
pub serial_stop_bits: SerialStopBits,
|
pub serial_stop_bits: SerialStopBits,
|
||||||
pub serial_flow_control: SerialFlowControl,
|
pub serial_flow_control: SerialFlowControl,
|
||||||
|
pub serial_dtr: bool,
|
||||||
|
pub serial_rts: bool,
|
||||||
pub addr: String,
|
pub addr: String,
|
||||||
pub udp_bind: String,
|
pub udp_bind: String,
|
||||||
pub udp_remote: String,
|
pub udp_remote: String,
|
||||||
@@ -42,6 +44,7 @@ pub struct PipelineForm {
|
|||||||
pub mixed_strip_cr: bool,
|
pub mixed_strip_cr: bool,
|
||||||
pub mixed_max_line_len: usize,
|
pub mixed_max_line_len: usize,
|
||||||
pub mixed_max_plot_frame: usize,
|
pub mixed_max_plot_frame: usize,
|
||||||
|
pub lua_framer_script_path: String,
|
||||||
pub hex_uppercase: bool,
|
pub hex_uppercase: bool,
|
||||||
pub hex_separator: String,
|
pub hex_separator: String,
|
||||||
pub hex_bytes_per_group: usize,
|
pub hex_bytes_per_group: usize,
|
||||||
@@ -50,6 +53,7 @@ pub struct PipelineForm {
|
|||||||
pub plot_channels: usize,
|
pub plot_channels: usize,
|
||||||
pub plot_endian: Endian,
|
pub plot_endian: Endian,
|
||||||
pub plot_format: PlotFormat,
|
pub plot_format: PlotFormat,
|
||||||
|
pub lua_decoder_script_path: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||||
@@ -66,6 +70,7 @@ pub enum FramerChoice {
|
|||||||
Length,
|
Length,
|
||||||
Cobs,
|
Cobs,
|
||||||
MixedTextPlot,
|
MixedTextPlot,
|
||||||
|
Lua,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||||
@@ -74,6 +79,7 @@ pub enum DecoderChoice {
|
|||||||
Hex,
|
Hex,
|
||||||
Plot,
|
Plot,
|
||||||
MixedTextPlot,
|
MixedTextPlot,
|
||||||
|
Lua,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl PipelineForm {
|
impl PipelineForm {
|
||||||
@@ -94,6 +100,7 @@ impl PipelineForm {
|
|||||||
mixed_strip_cr: true,
|
mixed_strip_cr: true,
|
||||||
mixed_max_line_len: 1_048_576,
|
mixed_max_line_len: 1_048_576,
|
||||||
mixed_max_plot_frame: 1_048_576,
|
mixed_max_plot_frame: 1_048_576,
|
||||||
|
lua_framer_script_path: String::new(),
|
||||||
hex_uppercase: false,
|
hex_uppercase: false,
|
||||||
hex_separator: String::from(" "),
|
hex_separator: String::from(" "),
|
||||||
hex_bytes_per_group: 1,
|
hex_bytes_per_group: 1,
|
||||||
@@ -102,6 +109,7 @@ impl PipelineForm {
|
|||||||
plot_channels: 1,
|
plot_channels: 1,
|
||||||
plot_endian: Endian::Little,
|
plot_endian: Endian::Little,
|
||||||
plot_format: PlotFormat::Interleaved,
|
plot_format: PlotFormat::Interleaved,
|
||||||
|
lua_decoder_script_path: String::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -116,6 +124,8 @@ impl Default for ConfigForm {
|
|||||||
serial_parity: SerialParity::None,
|
serial_parity: SerialParity::None,
|
||||||
serial_stop_bits: SerialStopBits::One,
|
serial_stop_bits: SerialStopBits::One,
|
||||||
serial_flow_control: SerialFlowControl::None,
|
serial_flow_control: SerialFlowControl::None,
|
||||||
|
serial_dtr: false,
|
||||||
|
serial_rts: false,
|
||||||
addr: String::from("127.0.0.1:8080"),
|
addr: String::from("127.0.0.1:8080"),
|
||||||
udp_bind: String::from("0.0.0.0:9000"),
|
udp_bind: String::from("0.0.0.0:9000"),
|
||||||
udp_remote: String::new(),
|
udp_remote: String::new(),
|
||||||
@@ -158,6 +168,14 @@ impl ConfigForm {
|
|||||||
TransportConfig::Serial { flow_control, .. } => *flow_control,
|
TransportConfig::Serial { flow_control, .. } => *flow_control,
|
||||||
_ => SerialFlowControl::None,
|
_ => SerialFlowControl::None,
|
||||||
},
|
},
|
||||||
|
serial_dtr: match &config.transport {
|
||||||
|
TransportConfig::Serial { dtr, .. } => *dtr,
|
||||||
|
_ => false,
|
||||||
|
},
|
||||||
|
serial_rts: match &config.transport {
|
||||||
|
TransportConfig::Serial { rts, .. } => *rts,
|
||||||
|
_ => false,
|
||||||
|
},
|
||||||
addr: match &config.transport {
|
addr: match &config.transport {
|
||||||
TransportConfig::Tcp { addr } => addr.clone(),
|
TransportConfig::Tcp { addr } => addr.clone(),
|
||||||
_ => String::from("127.0.0.1:8080"),
|
_ => String::from("127.0.0.1:8080"),
|
||||||
@@ -190,6 +208,8 @@ impl ConfigForm {
|
|||||||
parity: self.serial_parity,
|
parity: self.serial_parity,
|
||||||
stop_bits: self.serial_stop_bits,
|
stop_bits: self.serial_stop_bits,
|
||||||
flow_control: self.serial_flow_control,
|
flow_control: self.serial_flow_control,
|
||||||
|
dtr: self.serial_dtr,
|
||||||
|
rts: self.serial_rts,
|
||||||
},
|
},
|
||||||
TransportChoice::Tcp => TransportConfig::Tcp {
|
TransportChoice::Tcp => TransportConfig::Tcp {
|
||||||
addr: self.addr.clone(),
|
addr: self.addr.clone(),
|
||||||
@@ -257,6 +277,10 @@ impl PipelineForm {
|
|||||||
form.mixed_max_line_len = *max_line_len;
|
form.mixed_max_line_len = *max_line_len;
|
||||||
form.mixed_max_plot_frame = *max_plot_frame;
|
form.mixed_max_plot_frame = *max_plot_frame;
|
||||||
}
|
}
|
||||||
|
FramerConfig::Lua { script_path } => {
|
||||||
|
form.framer = FramerChoice::Lua;
|
||||||
|
form.lua_framer_script_path = script_path.clone();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
match &config.decoder {
|
match &config.decoder {
|
||||||
@@ -292,6 +316,10 @@ impl PipelineForm {
|
|||||||
form.decoder = DecoderChoice::MixedTextPlot;
|
form.decoder = DecoderChoice::MixedTextPlot;
|
||||||
form.text_encoding = *encoding;
|
form.text_encoding = *encoding;
|
||||||
}
|
}
|
||||||
|
DecoderConfig::Lua { script_path } => {
|
||||||
|
form.decoder = DecoderChoice::Lua;
|
||||||
|
form.lua_decoder_script_path = script_path.clone();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
form
|
form
|
||||||
@@ -322,6 +350,9 @@ impl PipelineForm {
|
|||||||
max_line_len: self.mixed_max_line_len,
|
max_line_len: self.mixed_max_line_len,
|
||||||
max_plot_frame: self.mixed_max_plot_frame,
|
max_plot_frame: self.mixed_max_plot_frame,
|
||||||
},
|
},
|
||||||
|
FramerChoice::Lua => FramerConfig::Lua {
|
||||||
|
script_path: self.lua_framer_script_path.clone(),
|
||||||
|
},
|
||||||
},
|
},
|
||||||
decoder: match self.decoder {
|
decoder: match self.decoder {
|
||||||
DecoderChoice::Text => DecoderConfig::Text {
|
DecoderChoice::Text => DecoderConfig::Text {
|
||||||
@@ -342,6 +373,9 @@ impl PipelineForm {
|
|||||||
DecoderChoice::MixedTextPlot => DecoderConfig::MixedTextPlot {
|
DecoderChoice::MixedTextPlot => DecoderConfig::MixedTextPlot {
|
||||||
encoding: self.text_encoding,
|
encoding: self.text_encoding,
|
||||||
},
|
},
|
||||||
|
DecoderChoice::Lua => DecoderConfig::Lua {
|
||||||
|
script_path: self.lua_decoder_script_path.clone(),
|
||||||
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -411,10 +445,7 @@ fn render_transport_section(ui: &mut Ui, form: &mut ConfigForm) {
|
|||||||
|
|
||||||
match form.transport {
|
match form.transport {
|
||||||
TransportChoice::Serial => {
|
TransportChoice::Serial => {
|
||||||
ui.horizontal(|ui| {
|
render_serial_port_combo(ui, form);
|
||||||
ui.label("Port:");
|
|
||||||
ui.text_edit_singleline(&mut form.port);
|
|
||||||
});
|
|
||||||
ui.horizontal(|ui| {
|
ui.horizontal(|ui| {
|
||||||
ui.label("Baud:");
|
ui.label("Baud:");
|
||||||
ui.add(DragValue::new(&mut form.baud).range(300..=12_000_000));
|
ui.add(DragValue::new(&mut form.baud).range(300..=12_000_000));
|
||||||
@@ -487,6 +518,8 @@ fn render_transport_section(ui: &mut Ui, form: &mut ConfigForm) {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
ui.checkbox(&mut form.serial_dtr, "DTR");
|
||||||
|
ui.checkbox(&mut form.serial_rts, "RTS");
|
||||||
}
|
}
|
||||||
TransportChoice::Tcp => {
|
TransportChoice::Tcp => {
|
||||||
ui.horizontal(|ui| {
|
ui.horizontal(|ui| {
|
||||||
@@ -507,6 +540,62 @@ fn render_transport_section(ui: &mut Ui, form: &mut ConfigForm) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn render_serial_port_combo(ui: &mut Ui, form: &mut ConfigForm) {
|
||||||
|
let available_ports = available_serial_ports();
|
||||||
|
let selected_text = selected_serial_port_text(&form.port, &available_ports);
|
||||||
|
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
ui.label("Port:");
|
||||||
|
ComboBox::from_id_salt("serial_port")
|
||||||
|
.width(180.0)
|
||||||
|
.selected_text(selected_text)
|
||||||
|
.show_ui(ui, |ui| {
|
||||||
|
if available_ports.is_empty() {
|
||||||
|
ui.add_enabled(false, egui::Label::new("No serial ports found"));
|
||||||
|
} else {
|
||||||
|
for port in &available_ports {
|
||||||
|
ui.selectable_value(&mut form.port, port.clone(), port);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let current_port = form.port.clone();
|
||||||
|
if !current_port.trim().is_empty()
|
||||||
|
&& !available_ports.iter().any(|port| port == ¤t_port)
|
||||||
|
{
|
||||||
|
ui.separator();
|
||||||
|
ui.selectable_value(
|
||||||
|
&mut form.port,
|
||||||
|
current_port.clone(),
|
||||||
|
format!("{current_port} (current)"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
ui.add(TextEdit::singleline(&mut form.port).desired_width(260.0));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn available_serial_ports() -> Vec<String> {
|
||||||
|
let mut ports: Vec<_> = SerialTransport::list_ports()
|
||||||
|
.into_iter()
|
||||||
|
.map(|port| port.port_name)
|
||||||
|
.collect();
|
||||||
|
ports.sort();
|
||||||
|
ports.dedup();
|
||||||
|
ports
|
||||||
|
}
|
||||||
|
|
||||||
|
fn selected_serial_port_text(current_port: &str, available_ports: &[String]) -> String {
|
||||||
|
if current_port.trim().is_empty() {
|
||||||
|
if available_ports.is_empty() {
|
||||||
|
String::from("No serial ports found")
|
||||||
|
} else {
|
||||||
|
String::from("Select port")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
current_port.to_owned()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn render_pipeline_header(
|
fn render_pipeline_header(
|
||||||
ui: &mut Ui,
|
ui: &mut Ui,
|
||||||
pipeline: &mut PipelineForm,
|
pipeline: &mut PipelineForm,
|
||||||
@@ -524,12 +613,14 @@ fn render_pipeline_header(
|
|||||||
FramerChoice::Length => "Length",
|
FramerChoice::Length => "Length",
|
||||||
FramerChoice::Cobs => "Cobs",
|
FramerChoice::Cobs => "Cobs",
|
||||||
FramerChoice::MixedTextPlot => "MixedTextPlot",
|
FramerChoice::MixedTextPlot => "MixedTextPlot",
|
||||||
|
FramerChoice::Lua => "Lua",
|
||||||
})
|
})
|
||||||
.show_ui(ui, |ui| {
|
.show_ui(ui, |ui| {
|
||||||
ui.selectable_value(&mut pipeline.framer, FramerChoice::Line, "Line");
|
ui.selectable_value(&mut pipeline.framer, FramerChoice::Line, "Line");
|
||||||
ui.selectable_value(&mut pipeline.framer, FramerChoice::Fixed, "Fixed");
|
ui.selectable_value(&mut pipeline.framer, FramerChoice::Fixed, "Fixed");
|
||||||
ui.selectable_value(&mut pipeline.framer, FramerChoice::Length, "Length");
|
ui.selectable_value(&mut pipeline.framer, FramerChoice::Length, "Length");
|
||||||
ui.selectable_value(&mut pipeline.framer, FramerChoice::Cobs, "Cobs");
|
ui.selectable_value(&mut pipeline.framer, FramerChoice::Cobs, "Cobs");
|
||||||
|
ui.selectable_value(&mut pipeline.framer, FramerChoice::Lua, "Lua");
|
||||||
if ui
|
if ui
|
||||||
.selectable_label(
|
.selectable_label(
|
||||||
matches!(pipeline.framer, FramerChoice::MixedTextPlot),
|
matches!(pipeline.framer, FramerChoice::MixedTextPlot),
|
||||||
@@ -548,10 +639,12 @@ fn render_pipeline_header(
|
|||||||
DecoderChoice::Hex => "Hex",
|
DecoderChoice::Hex => "Hex",
|
||||||
DecoderChoice::Plot => "Plot",
|
DecoderChoice::Plot => "Plot",
|
||||||
DecoderChoice::MixedTextPlot => "MixedTextPlot",
|
DecoderChoice::MixedTextPlot => "MixedTextPlot",
|
||||||
|
DecoderChoice::Lua => "Lua",
|
||||||
})
|
})
|
||||||
.show_ui(ui, |ui| {
|
.show_ui(ui, |ui| {
|
||||||
ui.selectable_value(&mut pipeline.decoder, DecoderChoice::Text, "Text");
|
ui.selectable_value(&mut pipeline.decoder, DecoderChoice::Text, "Text");
|
||||||
ui.selectable_value(&mut pipeline.decoder, DecoderChoice::Hex, "Hex");
|
ui.selectable_value(&mut pipeline.decoder, DecoderChoice::Hex, "Hex");
|
||||||
|
ui.selectable_value(&mut pipeline.decoder, DecoderChoice::Lua, "Lua");
|
||||||
if ui
|
if ui
|
||||||
.selectable_label(matches!(pipeline.decoder, DecoderChoice::Plot), "Plot")
|
.selectable_label(matches!(pipeline.decoder, DecoderChoice::Plot), "Plot")
|
||||||
.clicked()
|
.clicked()
|
||||||
@@ -642,6 +735,14 @@ fn render_framer_settings(ui: &mut Ui, pipeline: &mut PipelineForm, index: usize
|
|||||||
ui.add(DragValue::new(&mut pipeline.mixed_max_plot_frame).range(1..=16_777_216));
|
ui.add(DragValue::new(&mut pipeline.mixed_max_plot_frame).range(1..=16_777_216));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
FramerChoice::Lua => {
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
ui.label("Script:");
|
||||||
|
ui.add(
|
||||||
|
TextEdit::singleline(&mut pipeline.lua_framer_script_path).desired_width(260.0),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -721,6 +822,15 @@ fn render_decoder_settings(ui: &mut Ui, pipeline: &mut PipelineForm, index: usiz
|
|||||||
"Plot sample type, endian, format, and channels come from the mixed packet header.",
|
"Plot sample type, endian, format, and channels come from the mixed packet header.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
DecoderChoice::Lua => {
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
ui.label("Script:");
|
||||||
|
ui.add(
|
||||||
|
TextEdit::singleline(&mut pipeline.lua_decoder_script_path)
|
||||||
|
.desired_width(260.0),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -768,7 +878,7 @@ fn validate_pipeline(ui: &mut Ui, pipeline: &PipelineForm, errors: &mut Vec<Stri
|
|||||||
};
|
};
|
||||||
let sample_bytes = pipeline.plot_sample_type.byte_size();
|
let sample_bytes = pipeline.plot_sample_type.byte_size();
|
||||||
let frame_unit = channel_count.saturating_mul(sample_bytes);
|
let frame_unit = channel_count.saturating_mul(sample_bytes);
|
||||||
if frame_unit == 0 || pipeline.fixed_frame_len % frame_unit != 0 {
|
if frame_unit == 0 || !pipeline.fixed_frame_len.is_multiple_of(frame_unit) {
|
||||||
push_error(format!(
|
push_error(format!(
|
||||||
"Fixed frame length must be a multiple of {} bytes for the current plot layout",
|
"Fixed frame length must be a multiple of {} bytes for the current plot layout",
|
||||||
frame_unit
|
frame_unit
|
||||||
@@ -782,6 +892,18 @@ fn validate_pipeline(ui: &mut Ui, pipeline: &PipelineForm, errors: &mut Vec<Stri
|
|||||||
{
|
{
|
||||||
push_error(String::from("Length framer len_bytes must be 1, 2, or 4"));
|
push_error(String::from("Length framer len_bytes must be 1, 2, or 4"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if matches!(pipeline.framer, FramerChoice::Lua)
|
||||||
|
&& pipeline.lua_framer_script_path.trim().is_empty()
|
||||||
|
{
|
||||||
|
push_error(String::from("Lua framer script path is required"));
|
||||||
|
}
|
||||||
|
|
||||||
|
if matches!(pipeline.decoder, DecoderChoice::Lua)
|
||||||
|
&& pipeline.lua_decoder_script_path.trim().is_empty()
|
||||||
|
{
|
||||||
|
push_error(String::from("Lua decoder script path is required"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn render_text_encoding_combo(ui: &mut Ui, encoding: &mut TextEncoding, id: impl std::hash::Hash) {
|
fn render_text_encoding_combo(ui: &mut Ui, encoding: &mut TextEncoding, id: impl std::hash::Hash) {
|
||||||
@@ -881,6 +1003,15 @@ mod tests {
|
|||||||
format: PlotFormat::Block,
|
format: PlotFormat::Block,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
PipelineConfig {
|
||||||
|
name: String::from("lua"),
|
||||||
|
framer: FramerConfig::Lua {
|
||||||
|
script_path: String::from("/tmp/framer.lua"),
|
||||||
|
},
|
||||||
|
decoder: DecoderConfig::Lua {
|
||||||
|
script_path: String::from("/tmp/decoder.lua"),
|
||||||
|
},
|
||||||
|
},
|
||||||
],
|
],
|
||||||
history_limit: 4096,
|
history_limit: 4096,
|
||||||
auto_reconnect: true,
|
auto_reconnect: true,
|
||||||
@@ -894,4 +1025,22 @@ mod tests {
|
|||||||
serde_json::to_string(&session).unwrap()
|
serde_json::to_string(&session).unwrap()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn selected_serial_port_text_prefers_current_value() {
|
||||||
|
let ports = vec![String::from("/dev/ttyUSB0")];
|
||||||
|
assert_eq!(
|
||||||
|
selected_serial_port_text("/dev/ttyUSB1", &ports),
|
||||||
|
"/dev/ttyUSB1"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn selected_serial_port_text_uses_placeholder_when_empty() {
|
||||||
|
assert_eq!(selected_serial_port_text("", &[]), "No serial ports found");
|
||||||
|
assert_eq!(
|
||||||
|
selected_serial_port_text("", &[String::from("/dev/ttyUSB0")]),
|
||||||
|
"Select port"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
98
crates/pipeview-gui/src/panels/console.rs
Normal file
98
crates/pipeview-gui/src/panels/console.rs
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
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},
|
||||||
|
};
|
||||||
|
|
||||||
|
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 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),
|
||||||
|
}
|
||||||
|
}
|
||||||
137
crates/pipeview-gui/src/panels/hex_view.rs
Normal file
137
crates/pipeview-gui/src/panels/hex_view.rs
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
use crate::app::{DisplayOptions, SearchState};
|
||||||
|
use crate::buffers::{HexBuffer, HexLine, LineDirection};
|
||||||
|
use egui::{
|
||||||
|
Color32, Label, ScrollArea, TextStyle, TextWrapMode, Ui,
|
||||||
|
text::{LayoutJob, TextFormat},
|
||||||
|
};
|
||||||
|
|
||||||
|
pub fn render(
|
||||||
|
ui: &mut Ui,
|
||||||
|
buf: &HexBuffer,
|
||||||
|
display: DisplayOptions,
|
||||||
|
search: Option<&SearchState>,
|
||||||
|
) -> usize {
|
||||||
|
let line_count = buf.len();
|
||||||
|
if line_count == 0 {
|
||||||
|
ui.label("no hex data");
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
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_hex_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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn format_hex_line(
|
||||||
|
line: &HexLine,
|
||||||
|
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.hex, line.ascii);
|
||||||
|
|
||||||
|
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),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,31 +1,61 @@
|
|||||||
use crate::buffers::{PlotBuffer, PlotSeriesKind};
|
use crate::buffers::{PlotBuffer, PlotSeriesKind};
|
||||||
|
use crate::perf::PlotRenderStats;
|
||||||
use egui::{Button, Ui, vec2};
|
use egui::{Button, Ui, vec2};
|
||||||
use egui_plot::{Legend, Line, Plot, PlotBounds, PlotPoints};
|
use egui_plot::{Legend, Line, Plot, PlotBounds, PlotPoints};
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum PlotDisplayMode {
|
||||||
|
Embedded,
|
||||||
|
Detached,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
pub struct PlotViewState {
|
pub struct PlotViewState {
|
||||||
pub lock_x: bool,
|
pub lock_x: bool,
|
||||||
pub lock_y: bool,
|
pub lock_y: bool,
|
||||||
pub box_zoom: bool,
|
pub box_zoom: bool,
|
||||||
pub follow_latest: bool,
|
pub follow_latest: bool,
|
||||||
pub last_bounds: Option<PlotBounds>,
|
pub last_bounds: Option<PlotBounds>,
|
||||||
|
pub detached: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for PlotViewState {
|
pub struct PlotRenderOutput {
|
||||||
fn default() -> Self {
|
pub stats: PlotRenderStats,
|
||||||
Self {
|
pub toggle_detached: bool,
|
||||||
lock_x: false,
|
|
||||||
lock_y: false,
|
|
||||||
box_zoom: false,
|
|
||||||
follow_latest: false,
|
|
||||||
last_bounds: None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn render(ui: &mut Ui, buf: &PlotBuffer, session_id: u64, state: &mut PlotViewState) {
|
pub fn render(
|
||||||
|
ui: &mut Ui,
|
||||||
|
buf: &mut PlotBuffer,
|
||||||
|
session_id: u64,
|
||||||
|
state: &mut PlotViewState,
|
||||||
|
) -> PlotRenderOutput {
|
||||||
|
let mode = if state.detached {
|
||||||
|
PlotDisplayMode::Detached
|
||||||
|
} else {
|
||||||
|
PlotDisplayMode::Embedded
|
||||||
|
};
|
||||||
|
let mut toggle_detached = false;
|
||||||
|
|
||||||
if buf.is_empty() {
|
if buf.is_empty() {
|
||||||
|
ui.horizontal_wrapped(|ui| {
|
||||||
|
let toggle_label = match mode {
|
||||||
|
PlotDisplayMode::Embedded => "Pop Out",
|
||||||
|
PlotDisplayMode::Detached => "Dock Back",
|
||||||
|
};
|
||||||
|
if ui.button(toggle_label).clicked() {
|
||||||
|
toggle_detached = true;
|
||||||
|
}
|
||||||
ui.label("no plot data");
|
ui.label("no plot data");
|
||||||
return;
|
});
|
||||||
|
return PlotRenderOutput {
|
||||||
|
stats: PlotRenderStats {
|
||||||
|
series_count: 0,
|
||||||
|
stored_points: 0,
|
||||||
|
rendered_points: 0,
|
||||||
|
},
|
||||||
|
toggle_detached,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut auto_fit = false;
|
let mut auto_fit = false;
|
||||||
@@ -40,6 +70,13 @@ pub fn render(ui: &mut Ui, buf: &PlotBuffer, session_id: u64, state: &mut PlotVi
|
|||||||
if ui.button("Reset View").clicked() {
|
if ui.button("Reset View").clicked() {
|
||||||
reset_view = true;
|
reset_view = true;
|
||||||
}
|
}
|
||||||
|
let toggle_label = match mode {
|
||||||
|
PlotDisplayMode::Embedded => "Pop Out",
|
||||||
|
PlotDisplayMode::Detached => "Dock Back",
|
||||||
|
};
|
||||||
|
if ui.button(toggle_label).clicked() {
|
||||||
|
toggle_detached = true;
|
||||||
|
}
|
||||||
ui.separator();
|
ui.separator();
|
||||||
ui.checkbox(&mut state.lock_x, "Lock X");
|
ui.checkbox(&mut state.lock_x, "Lock X");
|
||||||
ui.checkbox(&mut state.lock_y, "Lock Y");
|
ui.checkbox(&mut state.lock_y, "Lock Y");
|
||||||
@@ -92,7 +129,7 @@ pub fn render(ui: &mut Ui, buf: &PlotBuffer, session_id: u64, state: &mut PlotVi
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
let data_bounds = plot_bounds(buf);
|
let data_bounds = buf.plot_bounds();
|
||||||
let mut plot = Plot::new(format!("plot_view_{session_id}"))
|
let mut plot = Plot::new(format!("plot_view_{session_id}"))
|
||||||
.legend(Legend::default())
|
.legend(Legend::default())
|
||||||
.allow_boxed_zoom(state.box_zoom)
|
.allow_boxed_zoom(state.box_zoom)
|
||||||
@@ -107,6 +144,7 @@ pub fn render(ui: &mut Ui, buf: &PlotBuffer, session_id: u64, state: &mut PlotVi
|
|||||||
plot = plot.reset();
|
plot = plot.reset();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let mut rendered_points = 0usize;
|
||||||
plot.show(ui, |plot_ui| {
|
plot.show(ui, |plot_ui| {
|
||||||
let current_bounds = plot_ui.plot_bounds();
|
let current_bounds = plot_ui.plot_bounds();
|
||||||
state.last_bounds = current_bounds.is_finite().then_some(current_bounds);
|
state.last_bounds = current_bounds.is_finite().then_some(current_bounds);
|
||||||
@@ -123,7 +161,10 @@ pub fn render(ui: &mut Ui, buf: &PlotBuffer, session_id: u64, state: &mut PlotVi
|
|||||||
if let Some(bounds) = data_bounds {
|
if let Some(bounds) = data_bounds {
|
||||||
let current_width = current_bounds.width();
|
let current_width = current_bounds.width();
|
||||||
let data_width = bounds.width();
|
let data_width = bounds.width();
|
||||||
let x_range = if current_width.is_finite() && current_width > 0.0 && data_width > current_width {
|
let x_range = if current_width.is_finite()
|
||||||
|
&& current_width > 0.0
|
||||||
|
&& data_width > current_width
|
||||||
|
{
|
||||||
(bounds.max()[0] - current_width)..=bounds.max()[0]
|
(bounds.max()[0] - current_width)..=bounds.max()[0]
|
||||||
} else {
|
} else {
|
||||||
bounds.min()[0]..=bounds.max()[0]
|
bounds.min()[0]..=bounds.max()[0]
|
||||||
@@ -132,7 +173,10 @@ pub fn render(ui: &mut Ui, buf: &PlotBuffer, session_id: u64, state: &mut PlotVi
|
|||||||
plot_ui.set_plot_bounds_x(x_range.clone());
|
plot_ui.set_plot_bounds_x(x_range.clone());
|
||||||
|
|
||||||
let mut followed = current_bounds;
|
let mut followed = current_bounds;
|
||||||
followed.set_x(&PlotBounds::from_min_max([*x_range.start(), current_bounds.min()[1]], [*x_range.end(), current_bounds.max()[1]]));
|
followed.set_x(&PlotBounds::from_min_max(
|
||||||
|
[*x_range.start(), current_bounds.min()[1]],
|
||||||
|
[*x_range.end(), current_bounds.max()[1]],
|
||||||
|
));
|
||||||
state.last_bounds = Some(followed);
|
state.last_bounds = Some(followed);
|
||||||
}
|
}
|
||||||
} else if zoom_x != 1.0 || zoom_y != 1.0 {
|
} else if zoom_x != 1.0 || zoom_y != 1.0 {
|
||||||
@@ -151,49 +195,25 @@ pub fn render(ui: &mut Ui, buf: &PlotBuffer, session_id: u64, state: &mut PlotVi
|
|||||||
|
|
||||||
for series in buf.iter() {
|
for series in buf.iter() {
|
||||||
let sampled_points = match buf.kind() {
|
let sampled_points = match buf.kind() {
|
||||||
PlotSeriesKind::TimeSeries => {
|
PlotSeriesKind::TimeSeries => series.render_points_time_series(
|
||||||
series.render_points_time_series(
|
|
||||||
current_bounds.min()[0],
|
current_bounds.min()[0],
|
||||||
current_bounds.max()[0],
|
current_bounds.max()[0],
|
||||||
max_render_points,
|
max_render_points,
|
||||||
)
|
),
|
||||||
}
|
|
||||||
PlotSeriesKind::XY => series.render_points_xy(max_render_points),
|
PlotSeriesKind::XY => series.render_points_xy(max_render_points),
|
||||||
};
|
};
|
||||||
|
rendered_points += sampled_points.len();
|
||||||
let points = PlotPoints::from_iter(sampled_points);
|
let points = PlotPoints::from_iter(sampled_points);
|
||||||
plot_ui.line(Line::new(series.name.clone(), points));
|
plot_ui.line(Line::new(series.name.clone(), points));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
|
||||||
|
PlotRenderOutput {
|
||||||
fn plot_bounds(buf: &PlotBuffer) -> Option<PlotBounds> {
|
stats: PlotRenderStats {
|
||||||
let mut bounds = PlotBounds::NOTHING;
|
series_count: buf.series_len(),
|
||||||
|
stored_points: buf.total_points(),
|
||||||
for series in buf.iter() {
|
rendered_points,
|
||||||
for [x, y] in series.points() {
|
},
|
||||||
if x.is_finite() && y.is_finite() {
|
toggle_detached,
|
||||||
bounds.extend_with(&[x, y].into());
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if !bounds.is_finite() {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
|
|
||||||
if bounds.width() <= 0.0 {
|
|
||||||
let center_x = bounds.center().x;
|
|
||||||
bounds.set_x_center_width(center_x, 1.0);
|
|
||||||
} else {
|
|
||||||
bounds.expand_x(bounds.width() * 0.05);
|
|
||||||
}
|
|
||||||
|
|
||||||
if bounds.height() <= 0.0 {
|
|
||||||
let center_y = bounds.center().y;
|
|
||||||
bounds.set_y_center_height(center_y, 1.0);
|
|
||||||
} else {
|
|
||||||
bounds.expand_y(bounds.height() * 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
Some(bounds)
|
|
||||||
}
|
}
|
||||||
@@ -12,6 +12,7 @@ pub fn render(
|
|||||||
sessions: &[SessionListItem],
|
sessions: &[SessionListItem],
|
||||||
active: &mut usize,
|
active: &mut usize,
|
||||||
on_new: &mut bool,
|
on_new: &mut bool,
|
||||||
|
on_edit: &mut Option<usize>,
|
||||||
on_delete: &mut Option<usize>,
|
on_delete: &mut Option<usize>,
|
||||||
) {
|
) {
|
||||||
ui.heading("Sessions");
|
ui.heading("Sessions");
|
||||||
@@ -37,12 +38,23 @@ pub fn render(
|
|||||||
"{} Session {}\n{}",
|
"{} Session {}\n{}",
|
||||||
status_tag, session.id, session.transport_summary
|
status_tag, session.id, session.transport_summary
|
||||||
);
|
);
|
||||||
|
ui.horizontal(|ui| {
|
||||||
if ui
|
if ui
|
||||||
.selectable_label(*active == i, RichText::new(label).color(color))
|
.selectable_label(*active == i, RichText::new(label).color(color))
|
||||||
.clicked()
|
.clicked()
|
||||||
{
|
{
|
||||||
*active = i;
|
*active = i;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ui
|
||||||
|
.small_button("⚙")
|
||||||
|
.on_hover_text("Edit session config")
|
||||||
|
.clicked()
|
||||||
|
{
|
||||||
|
*active = i;
|
||||||
|
*on_edit = Some(i);
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
ui.separator();
|
ui.separator();
|
||||||
257
crates/pipeview-gui/src/perf.rs
Normal file
257
crates/pipeview-gui/src/perf.rs
Normal file
@@ -0,0 +1,257 @@
|
|||||||
|
use std::env;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::sync::atomic::{AtomicU64, Ordering};
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
use tracing::info;
|
||||||
|
|
||||||
|
#[derive(Clone, Default)]
|
||||||
|
pub struct RepaintCounter {
|
||||||
|
inner: Arc<AtomicU64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RepaintCounter {
|
||||||
|
pub fn increment(&self) {
|
||||||
|
self.inner.fetch_add(1, Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn take(&self) -> u64 {
|
||||||
|
self.inner.swap(0, Ordering::Relaxed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
struct DurationStats {
|
||||||
|
count: u64,
|
||||||
|
total: Duration,
|
||||||
|
max: Duration,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DurationStats {
|
||||||
|
fn record(&mut self, elapsed: Duration) {
|
||||||
|
self.count += 1;
|
||||||
|
self.total += elapsed;
|
||||||
|
self.max = self.max.max(elapsed);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn avg_ms(&self) -> f64 {
|
||||||
|
if self.count == 0 {
|
||||||
|
0.0
|
||||||
|
} else {
|
||||||
|
(self.total.as_secs_f64() * 1000.0) / self.count as f64
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn max_ms(&self) -> f64 {
|
||||||
|
self.max.as_secs_f64() * 1000.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
struct IntervalStats {
|
||||||
|
frames: DurationStats,
|
||||||
|
drains: DurationStats,
|
||||||
|
text_renders: DurationStats,
|
||||||
|
hex_renders: DurationStats,
|
||||||
|
plot_renders: DurationStats,
|
||||||
|
drained_events: u64,
|
||||||
|
drained_data_events: u64,
|
||||||
|
drained_text_events: u64,
|
||||||
|
drained_hex_events: u64,
|
||||||
|
drained_plot_events: u64,
|
||||||
|
max_pending_events: usize,
|
||||||
|
max_text_lines: usize,
|
||||||
|
max_hex_lines: usize,
|
||||||
|
max_plot_series: usize,
|
||||||
|
max_plot_stored_points: usize,
|
||||||
|
max_plot_rendered_points: usize,
|
||||||
|
tabs: usize,
|
||||||
|
active_view: &'static str,
|
||||||
|
active_text_lines: usize,
|
||||||
|
active_hex_lines: usize,
|
||||||
|
active_plot_series: usize,
|
||||||
|
active_plot_points: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct DrainStats {
|
||||||
|
pub drained_events: u64,
|
||||||
|
pub drained_data_events: u64,
|
||||||
|
pub drained_text_events: u64,
|
||||||
|
pub drained_hex_events: u64,
|
||||||
|
pub drained_plot_events: u64,
|
||||||
|
pub pending_events: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy)]
|
||||||
|
pub struct GuiSnapshot {
|
||||||
|
pub tabs: usize,
|
||||||
|
pub active_view: &'static str,
|
||||||
|
pub active_text_lines: usize,
|
||||||
|
pub active_hex_lines: usize,
|
||||||
|
pub active_plot_series: usize,
|
||||||
|
pub active_plot_points: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct PlotRenderStats {
|
||||||
|
pub series_count: usize,
|
||||||
|
pub stored_points: usize,
|
||||||
|
pub rendered_points: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct GuiProfiler {
|
||||||
|
enabled: bool,
|
||||||
|
interval: Duration,
|
||||||
|
last_report_at: Instant,
|
||||||
|
repaint_counter: RepaintCounter,
|
||||||
|
interval_stats: IntervalStats,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl GuiProfiler {
|
||||||
|
pub fn from_env() -> Self {
|
||||||
|
let enabled = env_flag("XSERIAL_GUI_PROFILE");
|
||||||
|
let interval_ms = env::var("XSERIAL_GUI_PROFILE_INTERVAL_MS")
|
||||||
|
.ok()
|
||||||
|
.and_then(|value| value.parse::<u64>().ok())
|
||||||
|
.filter(|value| *value > 0)
|
||||||
|
.unwrap_or(1_000);
|
||||||
|
Self {
|
||||||
|
enabled,
|
||||||
|
interval: Duration::from_millis(interval_ms),
|
||||||
|
last_report_at: Instant::now(),
|
||||||
|
repaint_counter: RepaintCounter::default(),
|
||||||
|
interval_stats: IntervalStats {
|
||||||
|
active_view: "none",
|
||||||
|
..IntervalStats::default()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn repaint_counter(&self) -> RepaintCounter {
|
||||||
|
self.repaint_counter.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn record_drain(&mut self, elapsed: Duration, stats: DrainStats) {
|
||||||
|
if !self.enabled {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
self.interval_stats.drains.record(elapsed);
|
||||||
|
self.interval_stats.drained_events += stats.drained_events;
|
||||||
|
self.interval_stats.drained_data_events += stats.drained_data_events;
|
||||||
|
self.interval_stats.drained_text_events += stats.drained_text_events;
|
||||||
|
self.interval_stats.drained_hex_events += stats.drained_hex_events;
|
||||||
|
self.interval_stats.drained_plot_events += stats.drained_plot_events;
|
||||||
|
self.interval_stats.max_pending_events = self
|
||||||
|
.interval_stats
|
||||||
|
.max_pending_events
|
||||||
|
.max(stats.pending_events);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn record_text_render(&mut self, elapsed: Duration, line_count: usize) {
|
||||||
|
if !self.enabled {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
self.interval_stats.text_renders.record(elapsed);
|
||||||
|
self.interval_stats.max_text_lines = self.interval_stats.max_text_lines.max(line_count);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn record_hex_render(&mut self, elapsed: Duration, line_count: usize) {
|
||||||
|
if !self.enabled {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
self.interval_stats.hex_renders.record(elapsed);
|
||||||
|
self.interval_stats.max_hex_lines = self.interval_stats.max_hex_lines.max(line_count);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn record_plot_render(&mut self, elapsed: Duration, stats: PlotRenderStats) {
|
||||||
|
if !self.enabled {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
self.interval_stats.plot_renders.record(elapsed);
|
||||||
|
self.interval_stats.max_plot_series =
|
||||||
|
self.interval_stats.max_plot_series.max(stats.series_count);
|
||||||
|
self.interval_stats.max_plot_stored_points = self
|
||||||
|
.interval_stats
|
||||||
|
.max_plot_stored_points
|
||||||
|
.max(stats.stored_points);
|
||||||
|
self.interval_stats.max_plot_rendered_points = self
|
||||||
|
.interval_stats
|
||||||
|
.max_plot_rendered_points
|
||||||
|
.max(stats.rendered_points);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn record_frame(&mut self, elapsed: Duration, snapshot: GuiSnapshot) {
|
||||||
|
if !self.enabled {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
self.interval_stats.frames.record(elapsed);
|
||||||
|
self.interval_stats.tabs = snapshot.tabs;
|
||||||
|
self.interval_stats.active_view = snapshot.active_view;
|
||||||
|
self.interval_stats.active_text_lines = snapshot.active_text_lines;
|
||||||
|
self.interval_stats.active_hex_lines = snapshot.active_hex_lines;
|
||||||
|
self.interval_stats.active_plot_series = snapshot.active_plot_series;
|
||||||
|
self.interval_stats.active_plot_points = snapshot.active_plot_points;
|
||||||
|
|
||||||
|
if self.last_report_at.elapsed() < self.interval {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let repaints = self.repaint_counter.take();
|
||||||
|
info!(
|
||||||
|
target: "pipeview_gui::perf",
|
||||||
|
frames = self.interval_stats.frames.count,
|
||||||
|
frame_avg_ms = self.interval_stats.frames.avg_ms(),
|
||||||
|
frame_max_ms = self.interval_stats.frames.max_ms(),
|
||||||
|
drains = self.interval_stats.drains.count,
|
||||||
|
drain_avg_ms = self.interval_stats.drains.avg_ms(),
|
||||||
|
drain_max_ms = self.interval_stats.drains.max_ms(),
|
||||||
|
drained_events = self.interval_stats.drained_events,
|
||||||
|
drained_data_events = self.interval_stats.drained_data_events,
|
||||||
|
drained_text_events = self.interval_stats.drained_text_events,
|
||||||
|
drained_hex_events = self.interval_stats.drained_hex_events,
|
||||||
|
drained_plot_events = self.interval_stats.drained_plot_events,
|
||||||
|
repaint_requests = repaints,
|
||||||
|
max_pending_events = self.interval_stats.max_pending_events,
|
||||||
|
text_renders = self.interval_stats.text_renders.count,
|
||||||
|
text_avg_ms = self.interval_stats.text_renders.avg_ms(),
|
||||||
|
text_max_ms = self.interval_stats.text_renders.max_ms(),
|
||||||
|
max_text_lines = self.interval_stats.max_text_lines,
|
||||||
|
hex_renders = self.interval_stats.hex_renders.count,
|
||||||
|
hex_avg_ms = self.interval_stats.hex_renders.avg_ms(),
|
||||||
|
hex_max_ms = self.interval_stats.hex_renders.max_ms(),
|
||||||
|
max_hex_lines = self.interval_stats.max_hex_lines,
|
||||||
|
plot_renders = self.interval_stats.plot_renders.count,
|
||||||
|
plot_avg_ms = self.interval_stats.plot_renders.avg_ms(),
|
||||||
|
plot_max_ms = self.interval_stats.plot_renders.max_ms(),
|
||||||
|
max_plot_series = self.interval_stats.max_plot_series,
|
||||||
|
max_plot_stored_points = self.interval_stats.max_plot_stored_points,
|
||||||
|
max_plot_rendered_points = self.interval_stats.max_plot_rendered_points,
|
||||||
|
tabs = self.interval_stats.tabs,
|
||||||
|
active_view = self.interval_stats.active_view,
|
||||||
|
active_text_lines = self.interval_stats.active_text_lines,
|
||||||
|
active_hex_lines = self.interval_stats.active_hex_lines,
|
||||||
|
active_plot_series = self.interval_stats.active_plot_series,
|
||||||
|
active_plot_points = self.interval_stats.active_plot_points,
|
||||||
|
"gui perf"
|
||||||
|
);
|
||||||
|
|
||||||
|
self.last_report_at = Instant::now();
|
||||||
|
self.interval_stats = IntervalStats {
|
||||||
|
active_view: "none",
|
||||||
|
..IntervalStats::default()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn env_flag(name: &str) -> bool {
|
||||||
|
env::var(name)
|
||||||
|
.map(|value| {
|
||||||
|
let value = value.trim().to_ascii_lowercase();
|
||||||
|
!value.is_empty() && value != "0" && value != "false" && value != "off"
|
||||||
|
})
|
||||||
|
.unwrap_or(false)
|
||||||
|
}
|
||||||
139
crates/pipeview-gui/src/shortcuts.rs
Normal file
139
crates/pipeview-gui/src/shortcuts.rs
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
use egui::{Context, Key, KeyboardShortcut, Modifiers};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum Action {
|
||||||
|
NextTab,
|
||||||
|
PrevTab,
|
||||||
|
|
||||||
|
ViewText,
|
||||||
|
ViewHex,
|
||||||
|
ViewPlot,
|
||||||
|
|
||||||
|
NewSession,
|
||||||
|
EditSession,
|
||||||
|
DeleteSession,
|
||||||
|
ToggleConnect,
|
||||||
|
Clear,
|
||||||
|
|
||||||
|
UiSettings,
|
||||||
|
CloseOverlay,
|
||||||
|
|
||||||
|
Search,
|
||||||
|
SearchNext,
|
||||||
|
SearchPrev,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn default_bindings() -> Vec<(Action, KeyboardShortcut)> {
|
||||||
|
use Action::*;
|
||||||
|
|
||||||
|
vec![
|
||||||
|
(NextTab, KeyboardShortcut::new(Modifiers::CTRL, Key::Tab)),
|
||||||
|
(
|
||||||
|
PrevTab,
|
||||||
|
KeyboardShortcut::new(Modifiers::CTRL | Modifiers::SHIFT, Key::Tab),
|
||||||
|
),
|
||||||
|
(ViewText, KeyboardShortcut::new(Modifiers::CTRL, Key::T)),
|
||||||
|
(ViewHex, KeyboardShortcut::new(Modifiers::CTRL, Key::H)),
|
||||||
|
(ViewPlot, KeyboardShortcut::new(Modifiers::CTRL, Key::P)),
|
||||||
|
(NewSession, KeyboardShortcut::new(Modifiers::CTRL, Key::N)),
|
||||||
|
(EditSession, KeyboardShortcut::new(Modifiers::CTRL, Key::E)),
|
||||||
|
(
|
||||||
|
DeleteSession,
|
||||||
|
KeyboardShortcut::new(Modifiers::CTRL, Key::W),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
ToggleConnect,
|
||||||
|
KeyboardShortcut::new(Modifiers::CTRL, Key::F5),
|
||||||
|
),
|
||||||
|
(Clear, KeyboardShortcut::new(Modifiers::CTRL, Key::L)),
|
||||||
|
(
|
||||||
|
UiSettings,
|
||||||
|
KeyboardShortcut::new(Modifiers::CTRL, Key::Comma),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
CloseOverlay,
|
||||||
|
KeyboardShortcut::new(Modifiers::NONE, Key::Escape),
|
||||||
|
),
|
||||||
|
(Search, KeyboardShortcut::new(Modifiers::CTRL, Key::F)),
|
||||||
|
(SearchNext, KeyboardShortcut::new(Modifiers::NONE, Key::F3)),
|
||||||
|
(SearchPrev, KeyboardShortcut::new(Modifiers::SHIFT, Key::F3)),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn process(
|
||||||
|
ctx: &Context,
|
||||||
|
suppress_char_shortcuts: bool,
|
||||||
|
bindings: &[(Action, KeyboardShortcut)],
|
||||||
|
) -> Vec<Action> {
|
||||||
|
let mut actions = Vec::new();
|
||||||
|
|
||||||
|
ctx.input_mut(|input| {
|
||||||
|
for (action, shortcut) in bindings {
|
||||||
|
if !input.consume_shortcut(shortcut) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if suppress_char_shortcuts && is_char_based(*action) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
actions.push(*action);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
actions
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_char_based(action: Action) -> bool {
|
||||||
|
use Action::*;
|
||||||
|
!matches!(action, CloseOverlay)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn bindings_contain_no_duplicate_shortcuts() {
|
||||||
|
let bindings = default_bindings();
|
||||||
|
for (i, (_, a)) in bindings.iter().enumerate() {
|
||||||
|
for (j, (_, b)) in bindings.iter().enumerate() {
|
||||||
|
if i != j {
|
||||||
|
assert_ne!(a, b, "Duplicate shortcut at indices {i} and {j}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn bindings_non_empty() {
|
||||||
|
assert!(!default_bindings().is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn close_overlay_not_char_based() {
|
||||||
|
assert!(!is_char_based(Action::CloseOverlay));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn all_other_actions_are_char_based() {
|
||||||
|
for action in [
|
||||||
|
Action::NextTab,
|
||||||
|
Action::PrevTab,
|
||||||
|
Action::ViewText,
|
||||||
|
Action::ViewHex,
|
||||||
|
Action::ViewPlot,
|
||||||
|
Action::NewSession,
|
||||||
|
Action::EditSession,
|
||||||
|
Action::DeleteSession,
|
||||||
|
Action::ToggleConnect,
|
||||||
|
Action::Clear,
|
||||||
|
Action::UiSettings,
|
||||||
|
Action::Search,
|
||||||
|
Action::SearchNext,
|
||||||
|
Action::SearchPrev,
|
||||||
|
] {
|
||||||
|
assert!(is_char_based(action), "{action:?} should be char-based");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,19 +10,14 @@ use tracing::{info, warn};
|
|||||||
|
|
||||||
const FONT_SETTINGS_FILE_NAME: &str = "gui-fonts.json";
|
const FONT_SETTINGS_FILE_NAME: &str = "gui-fonts.json";
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||||
pub enum FontChoice {
|
pub enum FontChoice {
|
||||||
Auto,
|
Auto,
|
||||||
|
#[default]
|
||||||
Default,
|
Default,
|
||||||
System(String),
|
System(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for FontChoice {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self::Default
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
pub struct FontCandidate {
|
pub struct FontCandidate {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
@@ -363,12 +358,12 @@ fn font_settings_path_for_os_and_env(
|
|||||||
"windows" => {
|
"windows" => {
|
||||||
if let Some(path) = get_env("APPDATA").filter(|path| !path.trim().is_empty()) {
|
if let Some(path) = get_env("APPDATA").filter(|path| !path.trim().is_empty()) {
|
||||||
return PathBuf::from(path)
|
return PathBuf::from(path)
|
||||||
.join("xserial")
|
.join("pipeview")
|
||||||
.join(FONT_SETTINGS_FILE_NAME);
|
.join(FONT_SETTINGS_FILE_NAME);
|
||||||
}
|
}
|
||||||
if let Some(path) = get_env("LOCALAPPDATA").filter(|path| !path.trim().is_empty()) {
|
if let Some(path) = get_env("LOCALAPPDATA").filter(|path| !path.trim().is_empty()) {
|
||||||
return PathBuf::from(path)
|
return PathBuf::from(path)
|
||||||
.join("xserial")
|
.join("pipeview")
|
||||||
.join(FONT_SETTINGS_FILE_NAME);
|
.join(FONT_SETTINGS_FILE_NAME);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -377,20 +372,20 @@ fn font_settings_path_for_os_and_env(
|
|||||||
return PathBuf::from(home)
|
return PathBuf::from(home)
|
||||||
.join("Library")
|
.join("Library")
|
||||||
.join("Application Support")
|
.join("Application Support")
|
||||||
.join("xserial")
|
.join("pipeview")
|
||||||
.join(FONT_SETTINGS_FILE_NAME);
|
.join(FONT_SETTINGS_FILE_NAME);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
_ => {
|
_ => {
|
||||||
if let Some(path) = get_env("XDG_CONFIG_HOME").filter(|path| !path.trim().is_empty()) {
|
if let Some(path) = get_env("XDG_CONFIG_HOME").filter(|path| !path.trim().is_empty()) {
|
||||||
return PathBuf::from(path)
|
return PathBuf::from(path)
|
||||||
.join("xserial")
|
.join("pipeview")
|
||||||
.join(FONT_SETTINGS_FILE_NAME);
|
.join(FONT_SETTINGS_FILE_NAME);
|
||||||
}
|
}
|
||||||
if let Some(home) = get_env("HOME").filter(|path| !path.trim().is_empty()) {
|
if let Some(home) = get_env("HOME").filter(|path| !path.trim().is_empty()) {
|
||||||
return PathBuf::from(home)
|
return PathBuf::from(home)
|
||||||
.join(".config")
|
.join(".config")
|
||||||
.join("xserial")
|
.join("pipeview")
|
||||||
.join(FONT_SETTINGS_FILE_NAME);
|
.join(FONT_SETTINGS_FILE_NAME);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -596,7 +591,7 @@ mod tests {
|
|||||||
.duration_since(UNIX_EPOCH)
|
.duration_since(UNIX_EPOCH)
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.as_nanos();
|
.as_nanos();
|
||||||
let dir = env::temp_dir().join(format!("xserial-gui-font-settings-{unique}"));
|
let dir = env::temp_dir().join(format!("pipeview-gui-font-settings-{unique}"));
|
||||||
let path = dir.join("gui-fonts.json");
|
let path = dir.join("gui-fonts.json");
|
||||||
let settings = UiFontSettings {
|
let settings = UiFontSettings {
|
||||||
primary_choice: FontChoice::System(String::from("primary")),
|
primary_choice: FontChoice::System(String::from("primary")),
|
||||||
@@ -627,7 +622,7 @@ mod tests {
|
|||||||
assert_eq!(
|
assert_eq!(
|
||||||
path,
|
path,
|
||||||
PathBuf::from("/tmp/xdg-config")
|
PathBuf::from("/tmp/xdg-config")
|
||||||
.join("xserial")
|
.join("pipeview")
|
||||||
.join(FONT_SETTINGS_FILE_NAME)
|
.join(FONT_SETTINGS_FILE_NAME)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -641,7 +636,7 @@ mod tests {
|
|||||||
assert_eq!(
|
assert_eq!(
|
||||||
path,
|
path,
|
||||||
PathBuf::from(r"C:\Users\Test\AppData\Roaming")
|
PathBuf::from(r"C:\Users\Test\AppData\Roaming")
|
||||||
.join("xserial")
|
.join("pipeview")
|
||||||
.join(FONT_SETTINGS_FILE_NAME)
|
.join(FONT_SETTINGS_FILE_NAME)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -657,7 +652,7 @@ mod tests {
|
|||||||
PathBuf::from("/Users/tester")
|
PathBuf::from("/Users/tester")
|
||||||
.join("Library")
|
.join("Library")
|
||||||
.join("Application Support")
|
.join("Application Support")
|
||||||
.join("xserial")
|
.join("pipeview")
|
||||||
.join(FONT_SETTINGS_FILE_NAME)
|
.join(FONT_SETTINGS_FILE_NAME)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,15 +1,15 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "xserial-tui"
|
name = "pipeview-tui"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[[bin]]
|
[[bin]]
|
||||||
name = "xserial-tui"
|
name = "pipeview-tui"
|
||||||
path = "src/main.rs"
|
path = "src/main.rs"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
xserial-core = { path = "../xserial-core" }
|
pipeview-core = { path = "../pipeview-core" }
|
||||||
xserial-client = { path = "../xserial-client" }
|
pipeview-client = { path = "../pipeview-client" }
|
||||||
tokio = { workspace = true }
|
tokio = { workspace = true }
|
||||||
ratatui = { workspace = true }
|
ratatui = { workspace = true }
|
||||||
crossterm = { workspace = true }
|
crossterm = { workspace = true }
|
||||||
@@ -19,3 +19,7 @@ tracing-appender = { workspace = true }
|
|||||||
clap = { workspace = true }
|
clap = { workspace = true }
|
||||||
hex = { workspace = true }
|
hex = { workspace = true }
|
||||||
image = { workspace = true }
|
image = { workspace = true }
|
||||||
|
mlua = { workspace = true }
|
||||||
|
serde = { workspace = true }
|
||||||
|
serde_json = { workspace = true }
|
||||||
|
ansitok = "0.3"
|
||||||
295
crates/pipeview-tui/src/ansi.rs
Normal file
295
crates/pipeview-tui/src/ansi.rs
Normal file
@@ -0,0 +1,295 @@
|
|||||||
|
use ansitok::{AnsiColor, ElementKind, VisualAttribute, parse_ansi, parse_ansi_sgr};
|
||||||
|
use ratatui::style::{Color, Modifier, Style};
|
||||||
|
use ratatui::text::Span;
|
||||||
|
|
||||||
|
/// Parse ANSI-encoded text and return a Vec of styled Spans.
|
||||||
|
///
|
||||||
|
/// ANSI SGR sequences (colors, bold, italic, underline, strikethrough)
|
||||||
|
/// are converted to ratatui `Style` applied on top of `base_style`.
|
||||||
|
/// Non-SGR sequences (cursor movement, etc.) are silently ignored.
|
||||||
|
pub fn ansi_to_spans(text: &str, base_style: Style) -> Vec<Span<'static>> {
|
||||||
|
let mut spans = Vec::new();
|
||||||
|
let mut style = AnsiStyleStack::default();
|
||||||
|
|
||||||
|
for element in parse_ansi(text) {
|
||||||
|
match element.kind() {
|
||||||
|
ElementKind::Text => {
|
||||||
|
let slice = &text[element.start()..element.end()];
|
||||||
|
if !slice.is_empty() {
|
||||||
|
spans.push(Span::styled(
|
||||||
|
slice.to_string(),
|
||||||
|
style.merge(base_style),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ElementKind::Sgr => {
|
||||||
|
let sgr = &text[element.start()..element.end()];
|
||||||
|
apply_sgr_sequence(&mut style, sgr);
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
spans
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extract visible text from ANSI-encoded string (strips escape sequences).
|
||||||
|
///
|
||||||
|
/// This is the text that a user would actually see on a terminal —
|
||||||
|
/// useful for search, copy, and line counting.
|
||||||
|
pub fn ansi_visible_text(text: &str) -> String {
|
||||||
|
let mut visible = String::new();
|
||||||
|
for element in parse_ansi(text) {
|
||||||
|
if element.kind() == ElementKind::Text {
|
||||||
|
visible.push_str(&text[element.start()..element.end()]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
visible
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── internal style stack ──
|
||||||
|
|
||||||
|
#[derive(Default, Clone)]
|
||||||
|
struct AnsiStyleStack {
|
||||||
|
fg: Option<Color>,
|
||||||
|
bg: Option<Color>,
|
||||||
|
bold: bool,
|
||||||
|
italic: bool,
|
||||||
|
underline: bool,
|
||||||
|
strikethrough: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AnsiStyleStack {
|
||||||
|
fn apply_sgr_attr(&mut self, attr: VisualAttribute) {
|
||||||
|
match attr {
|
||||||
|
VisualAttribute::Reset(0) => *self = Self::default(),
|
||||||
|
VisualAttribute::Reset(22) => self.bold = false,
|
||||||
|
VisualAttribute::Reset(23) => self.italic = false,
|
||||||
|
VisualAttribute::Reset(24) => self.underline = false,
|
||||||
|
VisualAttribute::Reset(29) => self.strikethrough = false,
|
||||||
|
VisualAttribute::Reset(39) => self.fg = None,
|
||||||
|
VisualAttribute::Reset(49) => self.bg = None,
|
||||||
|
VisualAttribute::Reset(_) => {}
|
||||||
|
VisualAttribute::Bold => self.bold = true,
|
||||||
|
VisualAttribute::Faint => self.bold = false,
|
||||||
|
VisualAttribute::Italic => self.italic = true,
|
||||||
|
VisualAttribute::Underline => self.underline = true,
|
||||||
|
VisualAttribute::Crossedout => self.strikethrough = true,
|
||||||
|
VisualAttribute::FgColor(c) => self.fg = Some(ansi_color_to_ratatui(c)),
|
||||||
|
VisualAttribute::BgColor(c) => self.bg = Some(ansi_color_to_ratatui(c)),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn apply_sgr(&mut self, sgr: &str) {
|
||||||
|
// Strip ESC[ ... m wrapper and delegate to ansitok's SGR parser
|
||||||
|
let params = sgr
|
||||||
|
.strip_prefix("\x1b[")
|
||||||
|
.and_then(|s| s.strip_suffix('m'))
|
||||||
|
.unwrap_or(sgr);
|
||||||
|
|
||||||
|
if params.is_empty() {
|
||||||
|
*self = Self::default();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for output in parse_ansi_sgr(sgr) {
|
||||||
|
if let Some(attr) = output.as_escape() {
|
||||||
|
self.apply_sgr_attr(attr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn merge(&self, base: Style) -> Style {
|
||||||
|
let mut style = base;
|
||||||
|
|
||||||
|
if let Some(fg) = self.fg {
|
||||||
|
if self.bold {
|
||||||
|
style = style.fg(brighten(fg));
|
||||||
|
} else {
|
||||||
|
style = style.fg(fg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(bg) = self.bg {
|
||||||
|
style = style.bg(bg);
|
||||||
|
}
|
||||||
|
if self.italic {
|
||||||
|
style = style.add_modifier(Modifier::ITALIC);
|
||||||
|
}
|
||||||
|
if self.underline {
|
||||||
|
style = style.add_modifier(Modifier::UNDERLINED);
|
||||||
|
}
|
||||||
|
if self.strikethrough {
|
||||||
|
style = style.add_modifier(Modifier::CROSSED_OUT);
|
||||||
|
}
|
||||||
|
if self.bold {
|
||||||
|
// Bold applied only when there's no explicit fg (brighten handles it otherwise)
|
||||||
|
style = style.add_modifier(Modifier::BOLD);
|
||||||
|
}
|
||||||
|
|
||||||
|
style
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn apply_sgr_sequence(style: &mut AnsiStyleStack, sgr: &str) {
|
||||||
|
style.apply_sgr(sgr);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── color conversion ──
|
||||||
|
|
||||||
|
fn ansi_color_to_ratatui(color: AnsiColor) -> Color {
|
||||||
|
match color {
|
||||||
|
AnsiColor::Bit4(c) => ansi_4bit(c),
|
||||||
|
AnsiColor::Bit8(c) => ansi_256(c),
|
||||||
|
AnsiColor::Bit24 { r, g, b } => Color::Rgb(r, g, b),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ansi_4bit(code: u8) -> Color {
|
||||||
|
match code {
|
||||||
|
30 => Color::Rgb(0, 0, 0),
|
||||||
|
31 => Color::Rgb(194, 54, 33),
|
||||||
|
32 => Color::Rgb(37, 188, 36),
|
||||||
|
33 => Color::Rgb(173, 173, 39),
|
||||||
|
34 => Color::Rgb(73, 46, 225),
|
||||||
|
35 => Color::Rgb(211, 56, 211),
|
||||||
|
36 => Color::Rgb(51, 187, 200),
|
||||||
|
37 => Color::Rgb(203, 204, 205),
|
||||||
|
|
||||||
|
90 => Color::Rgb(129, 131, 131),
|
||||||
|
91 => Color::Rgb(252, 57, 31),
|
||||||
|
92 => Color::Rgb(49, 231, 34),
|
||||||
|
93 => Color::Rgb(234, 236, 35),
|
||||||
|
94 => Color::Rgb(88, 51, 255),
|
||||||
|
95 => Color::Rgb(249, 53, 248),
|
||||||
|
96 => Color::Rgb(20, 240, 240),
|
||||||
|
97 => Color::Rgb(233, 235, 235),
|
||||||
|
|
||||||
|
// Background colors (same values as foreground but offset by 10)
|
||||||
|
40 => Color::Rgb(0, 0, 0),
|
||||||
|
41 => Color::Rgb(194, 54, 33),
|
||||||
|
42 => Color::Rgb(37, 188, 36),
|
||||||
|
43 => Color::Rgb(173, 173, 39),
|
||||||
|
44 => Color::Rgb(73, 46, 225),
|
||||||
|
45 => Color::Rgb(211, 56, 211),
|
||||||
|
46 => Color::Rgb(51, 187, 200),
|
||||||
|
47 => Color::Rgb(203, 204, 205),
|
||||||
|
|
||||||
|
100 => Color::Rgb(129, 131, 131),
|
||||||
|
101 => Color::Rgb(252, 57, 31),
|
||||||
|
102 => Color::Rgb(49, 231, 34),
|
||||||
|
103 => Color::Rgb(234, 236, 35),
|
||||||
|
104 => Color::Rgb(88, 51, 255),
|
||||||
|
105 => Color::Rgb(249, 53, 248),
|
||||||
|
106 => Color::Rgb(20, 240, 240),
|
||||||
|
107 => Color::Rgb(233, 235, 235),
|
||||||
|
|
||||||
|
_ => Color::Rgb(255, 255, 255),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ansi_256(code: u8) -> Color {
|
||||||
|
match code {
|
||||||
|
0..=15 => ansi_4bit(if code < 8 { code + 30 } else { code + 82 }),
|
||||||
|
16..=231 => {
|
||||||
|
let idx = code - 16;
|
||||||
|
let cube = [0, 95, 135, 175, 215, 255];
|
||||||
|
let r = cube[(idx / 36) as usize];
|
||||||
|
let g = cube[((idx / 6) % 6) as usize];
|
||||||
|
let b = cube[(idx % 6) as usize];
|
||||||
|
Color::Rgb(r, g, b)
|
||||||
|
}
|
||||||
|
232..=255 => {
|
||||||
|
let v = (code - 232) * 10 + 8;
|
||||||
|
Color::Rgb(v, v, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn brighten(color: Color) -> Color {
|
||||||
|
match color {
|
||||||
|
Color::Rgb(r, g, b) => Color::Rgb(
|
||||||
|
((r as u32) * 13 / 10).min(255) as u8,
|
||||||
|
((g as u32) * 13 / 10).min(255) as u8,
|
||||||
|
((b as u32) * 13 / 10).min(255) as u8,
|
||||||
|
),
|
||||||
|
_ => color,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use ratatui::style::Style;
|
||||||
|
|
||||||
|
fn visible<'a>(spans: &[Span<'a>]) -> String {
|
||||||
|
spans.iter().map(|s| s.content.as_ref()).collect::<String>()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn plain_text_no_ansi() {
|
||||||
|
let spans = ansi_to_spans("hello", Style::default());
|
||||||
|
assert_eq!(spans.len(), 1);
|
||||||
|
assert_eq!(spans[0].content, "hello");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn fg_red_reset() {
|
||||||
|
let spans = ansi_to_spans("\x1b[31mred\x1b[0m plain", Style::default());
|
||||||
|
assert!(spans.len() >= 2);
|
||||||
|
assert_eq!(visible(&spans), "red plain");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn empty_sgr_resets_all() {
|
||||||
|
let base = Style::default().fg(Color::Rgb(1, 2, 3));
|
||||||
|
let spans = ansi_to_spans("\x1b[31mred\x1b[m plain", base);
|
||||||
|
assert_eq!(visible(&spans), "red plain");
|
||||||
|
assert!(spans.len() >= 2);
|
||||||
|
// First span should have red foreground
|
||||||
|
assert_ne!(spans[0].style.fg, Some(Color::Rgb(1, 2, 3)));
|
||||||
|
// Last span should have base foreground
|
||||||
|
assert_eq!(spans.last().unwrap().style.fg, Some(Color::Rgb(1, 2, 3)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn bold_brightens_color() {
|
||||||
|
let spans = ansi_to_spans("\x1b[1;31mbold red\x1b[0m", Style::default());
|
||||||
|
assert_eq!(spans.len(), 1);
|
||||||
|
assert_eq!(visible(&spans), "bold red");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn strip_ansi_codes_from_output() {
|
||||||
|
let spans = ansi_to_spans("\x1b[32mgreen\x1b[0m", Style::default());
|
||||||
|
assert_eq!(visible(&spans), "green");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn visible_text_strips_ansi() {
|
||||||
|
assert_eq!(ansi_visible_text("plain"), "plain");
|
||||||
|
assert_eq!(ansi_visible_text("\x1b[31mred\x1b[0m"), "red");
|
||||||
|
assert_eq!(
|
||||||
|
ansi_visible_text("\x1b[1;32mbold green\x1b[0m plain"),
|
||||||
|
"bold green plain"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn xterm_256_color_cube() {
|
||||||
|
assert_eq!(ansi_256(16), Color::Rgb(0, 0, 0));
|
||||||
|
assert_eq!(ansi_256(21), Color::Rgb(0, 0, 255));
|
||||||
|
assert_eq!(ansi_256(52), Color::Rgb(95, 0, 0));
|
||||||
|
assert_eq!(ansi_256(67), Color::Rgb(95, 135, 175));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn true_color_24bit() {
|
||||||
|
let spans = ansi_to_spans(
|
||||||
|
"\x1b[38;2;100;200;50mtruecolor\x1b[0m",
|
||||||
|
Style::default(),
|
||||||
|
);
|
||||||
|
assert_eq!(visible(&spans), "truecolor");
|
||||||
|
assert_eq!(spans[0].style.fg, Some(Color::Rgb(100, 200, 50)));
|
||||||
|
}
|
||||||
|
}
|
||||||
1782
crates/pipeview-tui/src/app.rs
Normal file
1782
crates/pipeview-tui/src/app.rs
Normal file
File diff suppressed because it is too large
Load Diff
169
crates/pipeview-tui/src/app_state.rs
Normal file
169
crates/pipeview-tui/src/app_state.rs
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
use std::env;
|
||||||
|
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};
|
||||||
|
|
||||||
|
const TUI_STATE_FILE_NAME: &str = "tui-state.json";
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct PersistedTuiState {
|
||||||
|
#[serde(default = "default_session")]
|
||||||
|
pub session: SessionConfig,
|
||||||
|
#[serde(default)]
|
||||||
|
pub active_view: PersistedView,
|
||||||
|
#[serde(default = "default_true")]
|
||||||
|
pub show_timestamp: bool,
|
||||||
|
#[serde(default = "default_true")]
|
||||||
|
pub show_direction: bool,
|
||||||
|
#[serde(default = "default_true")]
|
||||||
|
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
|
||||||
|
}
|
||||||
|
Err(err) if err.kind() == io::ErrorKind::NotFound => PersistedTuiState::default(),
|
||||||
|
Err(err) => {
|
||||||
|
warn!(error = %err, "Failed to load persisted TUI state");
|
||||||
|
PersistedTuiState::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn save_tui_state(session: &SessionConfig, view: View, display: DisplayOptions) {
|
||||||
|
let state = PersistedTuiState {
|
||||||
|
session: session.clone(),
|
||||||
|
active_view: view.into(),
|
||||||
|
show_timestamp: display.show_timestamp,
|
||||||
|
show_direction: display.show_direction,
|
||||||
|
show_pipeline: display.show_pipeline,
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Err(err) = save_tui_state_to_path(&state, &tui_state_path()) {
|
||||||
|
warn!(error = %err, "Failed to persist TUI state");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_session() -> SessionConfig {
|
||||||
|
default_session_config()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn tui_state_path() -> PathBuf {
|
||||||
|
state_path_for_os_and_env(env::consts::OS, |key| env::var(key).ok())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn config_dir() -> PathBuf {
|
||||||
|
tui_state_path()
|
||||||
|
.parent()
|
||||||
|
.map(Path::to_path_buf)
|
||||||
|
.unwrap_or_else(|| PathBuf::from("."))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn state_path_for_os_and_env(os: &str, get_env: impl Fn(&str) -> Option<String>) -> PathBuf {
|
||||||
|
match os {
|
||||||
|
"windows" => {
|
||||||
|
if let Some(path) = get_env("APPDATA").filter(|path| !path.trim().is_empty()) {
|
||||||
|
return PathBuf::from(path)
|
||||||
|
.join("pipeview")
|
||||||
|
.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(TUI_STATE_FILE_NAME);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"macos" => {
|
||||||
|
if let Some(home) = get_env("HOME").filter(|path| !path.trim().is_empty()) {
|
||||||
|
return PathBuf::from(home)
|
||||||
|
.join("Library")
|
||||||
|
.join("Application Support")
|
||||||
|
.join("pipeview")
|
||||||
|
.join(TUI_STATE_FILE_NAME);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
if let Some(path) = get_env("XDG_CONFIG_HOME").filter(|path| !path.trim().is_empty()) {
|
||||||
|
return PathBuf::from(path)
|
||||||
|
.join("pipeview")
|
||||||
|
.join(TUI_STATE_FILE_NAME);
|
||||||
|
}
|
||||||
|
if let Some(home) = get_env("HOME").filter(|path| !path.trim().is_empty()) {
|
||||||
|
return PathBuf::from(home)
|
||||||
|
.join(".config")
|
||||||
|
.join("pipeview")
|
||||||
|
.join(TUI_STATE_FILE_NAME);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
PathBuf::from(TUI_STATE_FILE_NAME)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn load_tui_state_from_path(path: &Path) -> io::Result<PersistedTuiState> {
|
||||||
|
let text = fs::read_to_string(path)?;
|
||||||
|
serde_json::from_str(&text).map_err(io::Error::other)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn save_tui_state_to_path(state: &PersistedTuiState, path: &Path) -> io::Result<()> {
|
||||||
|
if let Some(parent) = path.parent() {
|
||||||
|
fs::create_dir_all(parent)?;
|
||||||
|
}
|
||||||
|
let json = serde_json::to_string_pretty(state).map_err(io::Error::other)?;
|
||||||
|
fs::write(path, json)
|
||||||
|
}
|
||||||
|
|
||||||
|
const fn default_true() -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
637
crates/pipeview-tui/src/buffers.rs
Normal file
637
crates/pipeview-tui/src/buffers.rs
Normal file
@@ -0,0 +1,637 @@
|
|||||||
|
use std::collections::VecDeque;
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
use pipeview_client::{DecodedEntry, RingBuffer};
|
||||||
|
use pipeview_core::protocol::DecodedData;
|
||||||
|
use pipeview_core::protocol::plot::{PlotFormat, PlotFrame};
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum LineDirection {
|
||||||
|
In,
|
||||||
|
Out,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct ConsoleLine {
|
||||||
|
pub elapsed: Duration,
|
||||||
|
pub pipeline: String,
|
||||||
|
pub text: String,
|
||||||
|
pub direction: LineDirection,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct TextBuffer {
|
||||||
|
started_at: Instant,
|
||||||
|
lines: RingBuffer<ConsoleLine>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TextBuffer {
|
||||||
|
pub fn new(limit: usize) -> Self {
|
||||||
|
Self {
|
||||||
|
started_at: Instant::now(),
|
||||||
|
lines: RingBuffer::new(limit),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn push(&mut self, entry: &DecodedEntry) {
|
||||||
|
if let DecodedData::Text(text) = &entry.data {
|
||||||
|
self.lines.push(ConsoleLine {
|
||||||
|
elapsed: self.started_at.elapsed(),
|
||||||
|
pipeline: entry.pipeline_name.clone(),
|
||||||
|
text: text.clone(),
|
||||||
|
direction: LineDirection::In,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn push_outbound(&mut self, text: String) {
|
||||||
|
self.lines.push(ConsoleLine {
|
||||||
|
elapsed: self.started_at.elapsed(),
|
||||||
|
pipeline: String::from("OUT"),
|
||||||
|
text,
|
||||||
|
direction: LineDirection::Out,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get(&self, index: usize) -> Option<&ConsoleLine> {
|
||||||
|
self.lines.get(index)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn len(&self) -> usize {
|
||||||
|
self.lines.len()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn clear(&mut self) {
|
||||||
|
self.lines.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_limit(&mut self, limit: usize) {
|
||||||
|
self.lines.set_limit(limit);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct HexLine {
|
||||||
|
pub elapsed: Duration,
|
||||||
|
pub pipeline: String,
|
||||||
|
pub hex: String,
|
||||||
|
pub ascii: String,
|
||||||
|
pub direction: LineDirection,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct HexBuffer {
|
||||||
|
started_at: Instant,
|
||||||
|
lines: RingBuffer<HexLine>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl HexBuffer {
|
||||||
|
pub fn new(limit: usize) -> Self {
|
||||||
|
Self {
|
||||||
|
started_at: Instant::now(),
|
||||||
|
lines: RingBuffer::new(limit),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn push(&mut self, entry: &DecodedEntry) {
|
||||||
|
if let DecodedData::Hex(hex) = &entry.data {
|
||||||
|
self.lines.push(HexLine {
|
||||||
|
elapsed: self.started_at.elapsed(),
|
||||||
|
pipeline: entry.pipeline_name.clone(),
|
||||||
|
ascii: decode_ascii(hex),
|
||||||
|
hex: hex.clone(),
|
||||||
|
direction: LineDirection::In,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn push_outbound(&mut self, hex: String) {
|
||||||
|
self.lines.push(HexLine {
|
||||||
|
elapsed: self.started_at.elapsed(),
|
||||||
|
pipeline: String::from("OUT"),
|
||||||
|
ascii: decode_ascii(&hex),
|
||||||
|
hex,
|
||||||
|
direction: LineDirection::Out,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get(&self, index: usize) -> Option<&HexLine> {
|
||||||
|
self.lines.get(index)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn len(&self) -> usize {
|
||||||
|
self.lines.len()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn clear(&mut self) {
|
||||||
|
self.lines.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_limit(&mut self, limit: usize) {
|
||||||
|
self.lines.set_limit(limit);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct PlotSeries {
|
||||||
|
pub name: String,
|
||||||
|
points: VecDeque<[f64; 2]>,
|
||||||
|
next_x: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PlotSeries {
|
||||||
|
fn new(name: String) -> Self {
|
||||||
|
Self {
|
||||||
|
name,
|
||||||
|
points: VecDeque::new(),
|
||||||
|
next_x: 0.0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn push_samples(&mut self, samples: &[f64], limit: usize) {
|
||||||
|
for sample in samples {
|
||||||
|
if !sample.is_finite() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
self.push_point([self.next_x, *sample], limit);
|
||||||
|
self.next_x += 1.0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn push_point(&mut self, point: [f64; 2], limit: usize) -> Option<[f64; 2]> {
|
||||||
|
self.points.push_back(point);
|
||||||
|
if self.points.len() > limit {
|
||||||
|
self.points.pop_front()
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn points(&self) -> impl Iterator<Item = [f64; 2]> + '_ {
|
||||||
|
self.points.iter().copied()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn len(&self) -> usize {
|
||||||
|
self.points.len()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn first_point(&self) -> Option<[f64; 2]> {
|
||||||
|
self.points.front().copied()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn last_point(&self) -> Option<[f64; 2]> {
|
||||||
|
self.points.back().copied()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn render_points_time_series(
|
||||||
|
&self,
|
||||||
|
x_min: f64,
|
||||||
|
x_max: f64,
|
||||||
|
max_points: usize,
|
||||||
|
) -> Vec<[f64; 2]> {
|
||||||
|
if self.points.is_empty() || max_points == 0 {
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut exact_visible = Vec::with_capacity(max_points.min(self.points.len()));
|
||||||
|
for point in self.points.iter().copied() {
|
||||||
|
if point[0] < x_min {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if point[0] > x_max {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
exact_visible.push(point);
|
||||||
|
if exact_visible.len() > max_points {
|
||||||
|
exact_visible.clear();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !exact_visible.is_empty() {
|
||||||
|
return exact_visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
let bucket_count = (max_points / 2).max(1);
|
||||||
|
let width = (x_max - x_min).max(1.0);
|
||||||
|
let bucket_width = width / bucket_count as f64;
|
||||||
|
let mut rendered = Vec::with_capacity(bucket_count * 2);
|
||||||
|
let mut points = self
|
||||||
|
.points
|
||||||
|
.iter()
|
||||||
|
.copied()
|
||||||
|
.skip_while(|point| point[0] < x_min)
|
||||||
|
.peekable();
|
||||||
|
|
||||||
|
for bucket_index in 0..bucket_count {
|
||||||
|
let bucket_start = x_min + bucket_width * bucket_index as f64;
|
||||||
|
let bucket_end = if bucket_index + 1 == bucket_count {
|
||||||
|
x_max
|
||||||
|
} else {
|
||||||
|
bucket_start + bucket_width
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut min_point: Option<[f64; 2]> = None;
|
||||||
|
let mut max_point: Option<[f64; 2]> = None;
|
||||||
|
|
||||||
|
while let Some(point) = points.peek().copied() {
|
||||||
|
if point[0] > bucket_end {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if point[0] >= bucket_start {
|
||||||
|
match min_point {
|
||||||
|
Some(current) if current[1] <= point[1] => {}
|
||||||
|
_ => min_point = Some(point),
|
||||||
|
}
|
||||||
|
match max_point {
|
||||||
|
Some(current) if current[1] >= point[1] => {}
|
||||||
|
_ => max_point = Some(point),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
points.next();
|
||||||
|
}
|
||||||
|
|
||||||
|
match (min_point, max_point) {
|
||||||
|
(Some(a), Some(b)) if a[0] <= b[0] => {
|
||||||
|
rendered.push(a);
|
||||||
|
if a != b {
|
||||||
|
rendered.push(b);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(Some(a), Some(b)) => {
|
||||||
|
rendered.push(b);
|
||||||
|
if a != b {
|
||||||
|
rendered.push(a);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(Some(a), None) | (None, Some(a)) => rendered.push(a),
|
||||||
|
(None, None) => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if rendered.len() > max_points {
|
||||||
|
let stride = rendered.len().div_ceil(max_points);
|
||||||
|
rendered.into_iter().step_by(stride).collect()
|
||||||
|
} else {
|
||||||
|
rendered
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn render_points_xy(&self, max_points: usize) -> Vec<[f64; 2]> {
|
||||||
|
if self.points.is_empty() || max_points == 0 {
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
if self.points.len() <= max_points {
|
||||||
|
return self.points.iter().copied().collect();
|
||||||
|
}
|
||||||
|
|
||||||
|
let stride = self.points.len().div_ceil(max_points);
|
||||||
|
self.points.iter().copied().step_by(stride).collect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum PlotSeriesKind {
|
||||||
|
TimeSeries,
|
||||||
|
XY,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy)]
|
||||||
|
struct BoundsRect {
|
||||||
|
min_x: f64,
|
||||||
|
max_x: f64,
|
||||||
|
min_y: f64,
|
||||||
|
max_y: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl BoundsRect {
|
||||||
|
fn from_point([x, y]: [f64; 2]) -> Option<Self> {
|
||||||
|
if !(x.is_finite() && y.is_finite()) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(Self {
|
||||||
|
min_x: x,
|
||||||
|
max_x: x,
|
||||||
|
min_y: y,
|
||||||
|
max_y: y,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extend_with_point(&mut self, [x, y]: [f64; 2]) {
|
||||||
|
if !(x.is_finite() && y.is_finite()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self.min_x = self.min_x.min(x);
|
||||||
|
self.max_x = self.max_x.max(x);
|
||||||
|
self.min_y = self.min_y.min(y);
|
||||||
|
self.max_y = self.max_y.max(y);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn touches(&self, [x, y]: [f64; 2]) -> bool {
|
||||||
|
x == self.min_x || x == self.max_x || y == self.min_y || y == self.max_y
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct PlotBuffer {
|
||||||
|
limit: usize,
|
||||||
|
kind: PlotSeriesKind,
|
||||||
|
series: Vec<PlotSeries>,
|
||||||
|
time_series_y_bounds: Option<(f64, f64)>,
|
||||||
|
time_series_y_dirty: bool,
|
||||||
|
xy_bounds: Option<BoundsRect>,
|
||||||
|
xy_bounds_dirty: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PlotBuffer {
|
||||||
|
pub fn new(limit: usize) -> Self {
|
||||||
|
Self {
|
||||||
|
limit,
|
||||||
|
kind: PlotSeriesKind::TimeSeries,
|
||||||
|
series: Vec::new(),
|
||||||
|
time_series_y_bounds: None,
|
||||||
|
time_series_y_dirty: false,
|
||||||
|
xy_bounds: None,
|
||||||
|
xy_bounds_dirty: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn push(&mut self, entry: &DecodedEntry) {
|
||||||
|
if let DecodedData::Plot(frame) = &entry.data {
|
||||||
|
self.push_frame(&entry.pipeline_name, frame);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn push_frame(&mut self, pipeline_name: &str, frame: &PlotFrame) {
|
||||||
|
let next_kind = match frame.format {
|
||||||
|
PlotFormat::XY => PlotSeriesKind::XY,
|
||||||
|
PlotFormat::Interleaved | PlotFormat::Block => PlotSeriesKind::TimeSeries,
|
||||||
|
};
|
||||||
|
if self.kind != next_kind {
|
||||||
|
self.invalidate_bounds();
|
||||||
|
}
|
||||||
|
self.kind = next_kind;
|
||||||
|
|
||||||
|
if matches!(frame.format, PlotFormat::XY) {
|
||||||
|
self.push_xy_frame(pipeline_name, frame);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (index, channel) in frame.channels.iter().enumerate() {
|
||||||
|
let series_name = if frame.channels.len() == 1 {
|
||||||
|
pipeline_name.to_owned()
|
||||||
|
} else {
|
||||||
|
format!("{pipeline_name}:ch{}", index + 1)
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(series_index) = self
|
||||||
|
.series
|
||||||
|
.iter()
|
||||||
|
.position(|series| series.name == series_name)
|
||||||
|
{
|
||||||
|
for sample in channel {
|
||||||
|
if !sample.is_finite() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let (point, removed) = {
|
||||||
|
let series = &mut self.series[series_index];
|
||||||
|
let point = [series.next_x, *sample];
|
||||||
|
let removed = series.push_point(point, self.limit);
|
||||||
|
series.next_x += 1.0;
|
||||||
|
(point, removed)
|
||||||
|
};
|
||||||
|
if let Some(removed) = removed {
|
||||||
|
self.note_time_series_removed(removed);
|
||||||
|
}
|
||||||
|
self.note_time_series_point(point);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let mut series = PlotSeries::new(series_name);
|
||||||
|
series.push_samples(channel, self.limit);
|
||||||
|
for point in series.points() {
|
||||||
|
self.note_time_series_point(point);
|
||||||
|
}
|
||||||
|
self.series.push(series);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn push_xy_frame(&mut self, pipeline_name: &str, frame: &PlotFrame) {
|
||||||
|
if frame.channels.len() < 2 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let x = &frame.channels[0];
|
||||||
|
let y = &frame.channels[1];
|
||||||
|
let len = x.len().min(y.len());
|
||||||
|
let series_name = format!("{pipeline_name}:xy");
|
||||||
|
|
||||||
|
let series_index = if let Some(index) = self
|
||||||
|
.series
|
||||||
|
.iter()
|
||||||
|
.position(|series| series.name == series_name)
|
||||||
|
{
|
||||||
|
index
|
||||||
|
} else {
|
||||||
|
self.series.push(PlotSeries::new(series_name));
|
||||||
|
self.series.len() - 1
|
||||||
|
};
|
||||||
|
|
||||||
|
for index in 0..len {
|
||||||
|
if !x[index].is_finite() || !y[index].is_finite() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let point = [x[index], y[index]];
|
||||||
|
let removed = {
|
||||||
|
let series = &mut self.series[series_index];
|
||||||
|
series.push_point(point, self.limit)
|
||||||
|
};
|
||||||
|
if let Some(removed) = removed {
|
||||||
|
self.note_xy_removed(removed);
|
||||||
|
}
|
||||||
|
self.note_xy_point(point);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn clear(&mut self) {
|
||||||
|
self.series.clear();
|
||||||
|
self.invalidate_bounds();
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_limit(&mut self, limit: usize) {
|
||||||
|
self.limit = limit;
|
||||||
|
for series in &mut self.series {
|
||||||
|
while series.points.len() > self.limit {
|
||||||
|
series.points.pop_front();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.invalidate_bounds();
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_empty(&self) -> bool {
|
||||||
|
self.series.is_empty()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn kind(&self) -> PlotSeriesKind {
|
||||||
|
self.kind
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn iter(&self) -> impl Iterator<Item = &PlotSeries> {
|
||||||
|
self.series.iter()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn series_len(&self) -> usize {
|
||||||
|
self.series.len()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn total_points(&self) -> usize {
|
||||||
|
self.series.iter().map(PlotSeries::len).sum()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn bounds(&mut self) -> Option<([f64; 2], [f64; 2])> {
|
||||||
|
match self.kind {
|
||||||
|
PlotSeriesKind::TimeSeries => self.time_series_bounds(),
|
||||||
|
PlotSeriesKind::XY => self.xy_bounds(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn invalidate_bounds(&mut self) {
|
||||||
|
self.time_series_y_bounds = None;
|
||||||
|
self.time_series_y_dirty = false;
|
||||||
|
self.xy_bounds = None;
|
||||||
|
self.xy_bounds_dirty = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn note_time_series_point(&mut self, point: [f64; 2]) {
|
||||||
|
if !point[1].is_finite() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
match &mut self.time_series_y_bounds {
|
||||||
|
Some((min_y, max_y)) => {
|
||||||
|
*min_y = min_y.min(point[1]);
|
||||||
|
*max_y = max_y.max(point[1]);
|
||||||
|
}
|
||||||
|
None => self.time_series_y_bounds = Some((point[1], point[1])),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn note_time_series_removed(&mut self, removed: [f64; 2]) {
|
||||||
|
if let Some((min_y, max_y)) = self.time_series_y_bounds
|
||||||
|
&& (removed[1] == min_y || removed[1] == max_y)
|
||||||
|
{
|
||||||
|
self.time_series_y_dirty = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn note_xy_point(&mut self, point: [f64; 2]) {
|
||||||
|
match &mut self.xy_bounds {
|
||||||
|
Some(bounds) => bounds.extend_with_point(point),
|
||||||
|
None => self.xy_bounds = BoundsRect::from_point(point),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn note_xy_removed(&mut self, removed: [f64; 2]) {
|
||||||
|
if let Some(bounds) = self.xy_bounds
|
||||||
|
&& bounds.touches(removed)
|
||||||
|
{
|
||||||
|
self.xy_bounds_dirty = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn time_series_bounds(&mut self) -> Option<([f64; 2], [f64; 2])> {
|
||||||
|
let mut min_x = f64::INFINITY;
|
||||||
|
let mut max_x = f64::NEG_INFINITY;
|
||||||
|
|
||||||
|
for series in &self.series {
|
||||||
|
if let Some([x, _]) = series.first_point() {
|
||||||
|
min_x = min_x.min(x);
|
||||||
|
}
|
||||||
|
if let Some([x, _]) = series.last_point() {
|
||||||
|
max_x = max_x.max(x);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !(min_x.is_finite() && max_x.is_finite()) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.time_series_y_dirty {
|
||||||
|
self.recompute_time_series_y_bounds();
|
||||||
|
}
|
||||||
|
let (mut min_y, mut max_y) = self.time_series_y_bounds?;
|
||||||
|
if min_y == max_y {
|
||||||
|
min_y -= 1.0;
|
||||||
|
max_y += 1.0;
|
||||||
|
}
|
||||||
|
Some(([min_x, min_y], [max_x, max_y]))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn xy_bounds(&mut self) -> Option<([f64; 2], [f64; 2])> {
|
||||||
|
if self.xy_bounds_dirty {
|
||||||
|
self.recompute_xy_bounds();
|
||||||
|
}
|
||||||
|
let bounds = self.xy_bounds?;
|
||||||
|
let mut min_x = bounds.min_x;
|
||||||
|
let mut max_x = bounds.max_x;
|
||||||
|
let mut min_y = bounds.min_y;
|
||||||
|
let mut max_y = bounds.max_y;
|
||||||
|
if min_x == max_x {
|
||||||
|
min_x -= 1.0;
|
||||||
|
max_x += 1.0;
|
||||||
|
}
|
||||||
|
if min_y == max_y {
|
||||||
|
min_y -= 1.0;
|
||||||
|
max_y += 1.0;
|
||||||
|
}
|
||||||
|
Some(([min_x, min_y], [max_x, max_y]))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn recompute_time_series_y_bounds(&mut self) {
|
||||||
|
let mut min_y = f64::INFINITY;
|
||||||
|
let mut max_y = f64::NEG_INFINITY;
|
||||||
|
|
||||||
|
for series in &self.series {
|
||||||
|
for [_, y] in series.points() {
|
||||||
|
if y.is_finite() {
|
||||||
|
min_y = min_y.min(y);
|
||||||
|
max_y = max_y.max(y);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
self.time_series_y_bounds = if min_y.is_finite() && max_y.is_finite() {
|
||||||
|
Some((min_y, max_y))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
self.time_series_y_dirty = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn recompute_xy_bounds(&mut self) {
|
||||||
|
let mut bounds: Option<BoundsRect> = None;
|
||||||
|
|
||||||
|
for series in &self.series {
|
||||||
|
for point in series.points() {
|
||||||
|
match &mut bounds {
|
||||||
|
Some(existing) => existing.extend_with_point(point),
|
||||||
|
None => bounds = BoundsRect::from_point(point),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
self.xy_bounds = bounds;
|
||||||
|
self.xy_bounds_dirty = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn decode_ascii(hex: &str) -> String {
|
||||||
|
hex::decode(hex.replace(' ', ""))
|
||||||
|
.map(|bytes| {
|
||||||
|
bytes
|
||||||
|
.into_iter()
|
||||||
|
.map(|byte| {
|
||||||
|
if byte.is_ascii_graphic() || byte == b' ' {
|
||||||
|
byte as char
|
||||||
|
} else {
|
||||||
|
'.'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
|
.unwrap_or_else(|_| String::from("[invalid hex]"))
|
||||||
|
}
|
||||||
65
crates/pipeview-tui/src/main.rs
Normal file
65
crates/pipeview-tui/src/main.rs
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
mod ansi;
|
||||||
|
mod app;
|
||||||
|
mod app_state;
|
||||||
|
mod buffers;
|
||||||
|
mod ui;
|
||||||
|
|
||||||
|
use std::io;
|
||||||
|
|
||||||
|
use crossterm::{
|
||||||
|
event::{DisableMouseCapture, EnableMouseCapture},
|
||||||
|
execute,
|
||||||
|
terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode},
|
||||||
|
};
|
||||||
|
use ratatui::{Terminal, backend::CrosstermBackend};
|
||||||
|
use tracing_appender::non_blocking::WorkerGuard;
|
||||||
|
use tracing_subscriber::{EnvFilter, fmt};
|
||||||
|
|
||||||
|
use crate::app::App;
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() -> io::Result<()> {
|
||||||
|
let _log_guard = init_tracing();
|
||||||
|
|
||||||
|
enable_raw_mode()?;
|
||||||
|
let mut stdout = io::stdout();
|
||||||
|
execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
|
||||||
|
let backend = CrosstermBackend::new(stdout);
|
||||||
|
let mut terminal = Terminal::new(backend)?;
|
||||||
|
|
||||||
|
let result = async {
|
||||||
|
let mut app = App::new();
|
||||||
|
app.run(&mut terminal).await?;
|
||||||
|
app.shutdown().await;
|
||||||
|
io::Result::Ok(())
|
||||||
|
}
|
||||||
|
.await;
|
||||||
|
|
||||||
|
disable_raw_mode()?;
|
||||||
|
execute!(
|
||||||
|
terminal.backend_mut(),
|
||||||
|
LeaveAlternateScreen,
|
||||||
|
DisableMouseCapture
|
||||||
|
)?;
|
||||||
|
terminal.show_cursor()?;
|
||||||
|
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
fn init_tracing() -> Option<WorkerGuard> {
|
||||||
|
let filter = EnvFilter::try_from_default_env()
|
||||||
|
.unwrap_or_else(|_| EnvFilter::new("pipeview_tui=info,pipeview_client=info"));
|
||||||
|
let log_dir = app_state::config_dir();
|
||||||
|
let _ = std::fs::create_dir_all(&log_dir);
|
||||||
|
let file_appender = tracing_appender::rolling::never(log_dir, "tui.log");
|
||||||
|
let (writer, guard) = tracing_appender::non_blocking(file_appender);
|
||||||
|
|
||||||
|
fmt()
|
||||||
|
.with_env_filter(filter)
|
||||||
|
.with_target(false)
|
||||||
|
.with_ansi(false)
|
||||||
|
.with_writer(writer)
|
||||||
|
.try_init()
|
||||||
|
.ok()
|
||||||
|
.map(|_| guard)
|
||||||
|
}
|
||||||
989
crates/pipeview-tui/src/ui.rs
Normal file
989
crates/pipeview-tui/src/ui.rs
Normal file
@@ -0,0 +1,989 @@
|
|||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use ratatui::{
|
||||||
|
Frame,
|
||||||
|
layout::{Alignment, Constraint, Direction, Layout, Rect},
|
||||||
|
style::{Color, Modifier, Style},
|
||||||
|
symbols::Marker,
|
||||||
|
text::{Line, Span},
|
||||||
|
widgets::{
|
||||||
|
Axis, Block, Borders, Chart, Clear, Dataset, GraphType, List, ListItem, Paragraph, Wrap,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::ansi::ansi_to_spans;
|
||||||
|
use crate::app::{
|
||||||
|
App, AppMode, ConfigField, ConnectionStatus, DisplayOptions, FocusPane, HotAction, LineEnding,
|
||||||
|
SendMode, View,
|
||||||
|
};
|
||||||
|
use crate::buffers::{ConsoleLine, HexLine, LineDirection, PlotSeriesKind};
|
||||||
|
|
||||||
|
const BG: Color = Color::Rgb(8, 12, 18);
|
||||||
|
const PANEL: Color = Color::Rgb(12, 20, 28);
|
||||||
|
const PANEL_ALT: Color = Color::Rgb(17, 27, 38);
|
||||||
|
const CYAN: Color = Color::Rgb(55, 214, 230);
|
||||||
|
const GREEN: Color = Color::Rgb(72, 211, 137);
|
||||||
|
const AMBER: Color = Color::Rgb(244, 184, 74);
|
||||||
|
const MAGENTA: Color = Color::Rgb(219, 111, 220);
|
||||||
|
const RED: Color = Color::Rgb(238, 92, 107);
|
||||||
|
const BLUE: Color = Color::Rgb(96, 165, 250);
|
||||||
|
const MUTED: Color = Color::Rgb(120, 134, 153);
|
||||||
|
const TEXT: Color = Color::Rgb(224, 234, 244);
|
||||||
|
|
||||||
|
pub fn render(frame: &mut Frame, app: &mut App) {
|
||||||
|
app.reset_hot_zones();
|
||||||
|
let area = frame.area();
|
||||||
|
frame.render_widget(Block::default().style(Style::default().bg(BG)), area);
|
||||||
|
|
||||||
|
let root = Layout::default()
|
||||||
|
.direction(Direction::Vertical)
|
||||||
|
.constraints([
|
||||||
|
Constraint::Length(4),
|
||||||
|
Constraint::Min(10),
|
||||||
|
Constraint::Length(6),
|
||||||
|
Constraint::Length(2),
|
||||||
|
])
|
||||||
|
.split(area);
|
||||||
|
|
||||||
|
render_header(frame, app, root[0]);
|
||||||
|
|
||||||
|
let body = Layout::default()
|
||||||
|
.direction(Direction::Horizontal)
|
||||||
|
.constraints([Constraint::Length(34), Constraint::Min(30)])
|
||||||
|
.split(root[1]);
|
||||||
|
render_controls(frame, app, body[0]);
|
||||||
|
render_main(frame, app, body[1]);
|
||||||
|
render_composer(frame, app, root[2]);
|
||||||
|
render_footer(frame, app, root[3]);
|
||||||
|
|
||||||
|
match app.mode {
|
||||||
|
AppMode::Search => render_search_modal(frame, app),
|
||||||
|
AppMode::Config => render_config_modal(frame, app),
|
||||||
|
AppMode::Help => render_help_modal(frame, app),
|
||||||
|
AppMode::Normal | AppMode::EditingSend => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_header(frame: &mut Frame, app: &App, area: Rect) {
|
||||||
|
let status = &app.session.status;
|
||||||
|
let status_color = status_color(status);
|
||||||
|
let error = match status {
|
||||||
|
ConnectionStatus::Error(message) => format!(" {message}"),
|
||||||
|
_ => String::new(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let header = vec![
|
||||||
|
Line::from(vec![
|
||||||
|
Span::styled(
|
||||||
|
" PIPEVIEW ",
|
||||||
|
Style::default()
|
||||||
|
.fg(Color::Black)
|
||||||
|
.bg(CYAN)
|
||||||
|
.add_modifier(Modifier::BOLD),
|
||||||
|
),
|
||||||
|
Span::raw(" "),
|
||||||
|
Span::styled(
|
||||||
|
"single session telemetry console",
|
||||||
|
Style::default().fg(TEXT).add_modifier(Modifier::BOLD),
|
||||||
|
),
|
||||||
|
Span::raw(" "),
|
||||||
|
Span::styled(
|
||||||
|
status.label(),
|
||||||
|
Style::default()
|
||||||
|
.fg(Color::Black)
|
||||||
|
.bg(status_color)
|
||||||
|
.add_modifier(Modifier::BOLD),
|
||||||
|
),
|
||||||
|
Span::styled(error, Style::default().fg(RED)),
|
||||||
|
]),
|
||||||
|
Line::from(vec![
|
||||||
|
Span::styled(app.transport_summary(), Style::default().fg(AMBER)),
|
||||||
|
Span::raw(" | pipelines: "),
|
||||||
|
Span::styled(app.pipeline_summary(), Style::default().fg(MAGENTA)),
|
||||||
|
Span::raw(" | view: "),
|
||||||
|
Span::styled(
|
||||||
|
app.session.view.label(),
|
||||||
|
Style::default().fg(view_color(app.session.view)),
|
||||||
|
),
|
||||||
|
Span::raw(" | rx/tx: "),
|
||||||
|
Span::styled(
|
||||||
|
format!(
|
||||||
|
"{}/{}",
|
||||||
|
app.session.received_messages, app.session.sent_messages
|
||||||
|
),
|
||||||
|
Style::default().fg(GREEN),
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
];
|
||||||
|
|
||||||
|
frame.render_widget(
|
||||||
|
Paragraph::new(header)
|
||||||
|
.block(panel_block("Status", false, CYAN))
|
||||||
|
.wrap(Wrap { trim: false }),
|
||||||
|
area,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_controls(frame: &mut Frame, app: &mut App, area: Rect) {
|
||||||
|
app.add_hot_zone(area, HotAction::Focus(FocusPane::Controls));
|
||||||
|
let focused = app.focus == FocusPane::Controls;
|
||||||
|
let block = panel_block("Control Deck", focused, AMBER);
|
||||||
|
let inner = block.inner(area);
|
||||||
|
frame.render_widget(block, area);
|
||||||
|
|
||||||
|
let connect_label = if app.session.status.is_connectedish() {
|
||||||
|
"[ Disconnect ]"
|
||||||
|
} else {
|
||||||
|
"[ Connect ]"
|
||||||
|
};
|
||||||
|
let ending = next_line_ending(app.session.line_ending);
|
||||||
|
let rows: Vec<(Line<'static>, Option<HotAction>)> = vec![
|
||||||
|
section_line("SESSION"),
|
||||||
|
button_line(
|
||||||
|
connect_label,
|
||||||
|
status_color(&app.session.status),
|
||||||
|
Some(HotAction::ToggleConnection),
|
||||||
|
),
|
||||||
|
button_line("[ Reconnect ]", BLUE, Some(HotAction::Reconnect)),
|
||||||
|
button_line("[ Clear Buffers ]", RED, Some(HotAction::Clear)),
|
||||||
|
button_line("[ Configure ]", MAGENTA, Some(HotAction::OpenConfig)),
|
||||||
|
spacer_line(),
|
||||||
|
section_line("LINES"),
|
||||||
|
toggle_line(
|
||||||
|
"Auto reconnect",
|
||||||
|
app.session.auto_reconnect,
|
||||||
|
Some(HotAction::ToggleAutoReconnect),
|
||||||
|
),
|
||||||
|
toggle_line("DTR", app.session.dtr, Some(HotAction::ToggleDtr)),
|
||||||
|
toggle_line("RTS", app.session.rts, Some(HotAction::ToggleRts)),
|
||||||
|
spacer_line(),
|
||||||
|
section_line("DISPLAY"),
|
||||||
|
toggle_line(
|
||||||
|
"Timestamp",
|
||||||
|
app.display.show_timestamp,
|
||||||
|
Some(HotAction::ToggleTimestamp),
|
||||||
|
),
|
||||||
|
toggle_line(
|
||||||
|
"Direction",
|
||||||
|
app.display.show_direction,
|
||||||
|
Some(HotAction::ToggleDirection),
|
||||||
|
),
|
||||||
|
toggle_line(
|
||||||
|
"Pipeline",
|
||||||
|
app.display.show_pipeline,
|
||||||
|
Some(HotAction::TogglePipeline),
|
||||||
|
),
|
||||||
|
spacer_line(),
|
||||||
|
section_line("SEND"),
|
||||||
|
value_line(
|
||||||
|
"Mode",
|
||||||
|
app.session.send_mode.label(),
|
||||||
|
Some(HotAction::SendMode(match app.session.send_mode {
|
||||||
|
SendMode::Text => SendMode::Hex,
|
||||||
|
SendMode::Hex => SendMode::Text,
|
||||||
|
})),
|
||||||
|
),
|
||||||
|
value_line(
|
||||||
|
"Ending",
|
||||||
|
app.session.line_ending.label(),
|
||||||
|
Some(HotAction::LineEnding(ending)),
|
||||||
|
),
|
||||||
|
button_line("[ Search ]", CYAN, Some(HotAction::OpenSearch)),
|
||||||
|
button_line("[ Help ]", MUTED, Some(HotAction::OpenHelp)),
|
||||||
|
];
|
||||||
|
|
||||||
|
for (index, (_, action)) in rows.iter().enumerate() {
|
||||||
|
if let Some(action) = action {
|
||||||
|
let y = inner.y.saturating_add(index as u16);
|
||||||
|
if y < inner.y.saturating_add(inner.height) {
|
||||||
|
app.add_hot_zone(
|
||||||
|
Rect {
|
||||||
|
x: inner.x,
|
||||||
|
y,
|
||||||
|
width: inner.width,
|
||||||
|
height: 1,
|
||||||
|
},
|
||||||
|
*action,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
frame.render_widget(
|
||||||
|
Paragraph::new(rows.into_iter().map(|(line, _)| line).collect::<Vec<_>>())
|
||||||
|
.style(Style::default().fg(TEXT).bg(PANEL)),
|
||||||
|
inner,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_main(frame: &mut Frame, app: &mut App, area: Rect) {
|
||||||
|
app.add_hot_zone(area, HotAction::Focus(FocusPane::Main));
|
||||||
|
let layout = Layout::default()
|
||||||
|
.direction(Direction::Vertical)
|
||||||
|
.constraints([Constraint::Length(3), Constraint::Min(5)])
|
||||||
|
.split(area);
|
||||||
|
|
||||||
|
render_tabs(frame, app, layout[0]);
|
||||||
|
match app.session.view {
|
||||||
|
View::Text => render_text_view(frame, app, layout[1]),
|
||||||
|
View::Hex => render_hex_view(frame, app, layout[1]),
|
||||||
|
View::Plot => render_plot_view(frame, app, layout[1]),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_tabs(frame: &mut Frame, app: &mut App, area: Rect) {
|
||||||
|
let chunks = Layout::default()
|
||||||
|
.direction(Direction::Horizontal)
|
||||||
|
.constraints([
|
||||||
|
Constraint::Percentage(34),
|
||||||
|
Constraint::Percentage(33),
|
||||||
|
Constraint::Percentage(33),
|
||||||
|
])
|
||||||
|
.split(area);
|
||||||
|
|
||||||
|
for (idx, view) in View::ALL.into_iter().enumerate() {
|
||||||
|
let selected = app.session.view == view;
|
||||||
|
app.add_hot_zone(chunks[idx], HotAction::View(view));
|
||||||
|
let color = view_color(view);
|
||||||
|
let block = panel_block(view.label(), selected, color);
|
||||||
|
frame.render_widget(
|
||||||
|
Paragraph::new(Line::from(vec![
|
||||||
|
Span::styled(
|
||||||
|
view.label(),
|
||||||
|
Style::default()
|
||||||
|
.fg(if selected { Color::Black } else { color })
|
||||||
|
.bg(if selected { color } else { PANEL })
|
||||||
|
.add_modifier(Modifier::BOLD),
|
||||||
|
),
|
||||||
|
Span::styled(
|
||||||
|
match view {
|
||||||
|
View::Text => format!(" {} lines", app.session.console.len()),
|
||||||
|
View::Hex => format!(" {} lines", app.session.hex.len()),
|
||||||
|
View::Plot => format!(
|
||||||
|
" {} series / {} pts",
|
||||||
|
app.session.plot.series_len(),
|
||||||
|
app.session.plot.total_points()
|
||||||
|
),
|
||||||
|
},
|
||||||
|
Style::default().fg(MUTED),
|
||||||
|
),
|
||||||
|
]))
|
||||||
|
.block(block)
|
||||||
|
.alignment(Alignment::Center),
|
||||||
|
chunks[idx],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_text_view(frame: &mut Frame, app: &mut App, area: Rect) {
|
||||||
|
let focused = app.focus == FocusPane::Main;
|
||||||
|
let block = panel_block("Text Stream", focused, CYAN);
|
||||||
|
let inner = block.inner(area);
|
||||||
|
let height = inner.height as usize;
|
||||||
|
let total = app.session.console.len();
|
||||||
|
let start = app.visible_start(total, height);
|
||||||
|
let end = start.saturating_add(height).min(total);
|
||||||
|
|
||||||
|
let lines = if total == 0 {
|
||||||
|
vec![Line::styled("no text data", Style::default().fg(MUTED))]
|
||||||
|
} else {
|
||||||
|
(start..end)
|
||||||
|
.filter_map(|index| {
|
||||||
|
app.session
|
||||||
|
.console
|
||||||
|
.get(index)
|
||||||
|
.map(|line| format_console_line(index, line, app.display, app))
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
};
|
||||||
|
|
||||||
|
frame.render_widget(
|
||||||
|
Paragraph::new(lines)
|
||||||
|
.block(block)
|
||||||
|
.style(Style::default().bg(PANEL_ALT))
|
||||||
|
.wrap(Wrap { trim: false }),
|
||||||
|
area,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_hex_view(frame: &mut Frame, app: &mut App, area: Rect) {
|
||||||
|
let focused = app.focus == FocusPane::Main;
|
||||||
|
let block = panel_block("Hex Stream", focused, AMBER);
|
||||||
|
let inner = block.inner(area);
|
||||||
|
let height = inner.height as usize;
|
||||||
|
let total = app.session.hex.len();
|
||||||
|
let start = app.visible_start(total, height);
|
||||||
|
let end = start.saturating_add(height).min(total);
|
||||||
|
|
||||||
|
let lines = if total == 0 {
|
||||||
|
vec![Line::styled("no hex data", Style::default().fg(MUTED))]
|
||||||
|
} else {
|
||||||
|
(start..end)
|
||||||
|
.filter_map(|index| {
|
||||||
|
app.session
|
||||||
|
.hex
|
||||||
|
.get(index)
|
||||||
|
.map(|line| format_hex_line(index, line, app.display, app))
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
};
|
||||||
|
|
||||||
|
frame.render_widget(
|
||||||
|
Paragraph::new(lines)
|
||||||
|
.block(block)
|
||||||
|
.style(Style::default().bg(PANEL_ALT))
|
||||||
|
.wrap(Wrap { trim: false }),
|
||||||
|
area,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_plot_view(frame: &mut Frame, app: &mut App, area: Rect) {
|
||||||
|
let focused = app.focus == FocusPane::Main;
|
||||||
|
let block = panel_block("Plot View", focused, GREEN);
|
||||||
|
let inner = block.inner(area);
|
||||||
|
app.add_hot_zone(inner, HotAction::Focus(FocusPane::Main));
|
||||||
|
|
||||||
|
if app.session.plot.is_empty() {
|
||||||
|
frame.render_widget(
|
||||||
|
Paragraph::new(vec![
|
||||||
|
Line::styled("no plot data", Style::default().fg(MUTED)),
|
||||||
|
Line::from(vec![
|
||||||
|
Span::raw("pipeline: "),
|
||||||
|
Span::styled(app.pipeline_summary(), Style::default().fg(MAGENTA)),
|
||||||
|
]),
|
||||||
|
])
|
||||||
|
.block(block)
|
||||||
|
.style(Style::default().bg(PANEL_ALT))
|
||||||
|
.alignment(Alignment::Center),
|
||||||
|
area,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some((min, max)) = app.plot_bounds() else {
|
||||||
|
frame.render_widget(
|
||||||
|
Paragraph::new("plot bounds unavailable")
|
||||||
|
.block(block)
|
||||||
|
.style(Style::default().fg(MUTED).bg(PANEL_ALT)),
|
||||||
|
area,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let max_points = (inner.width as usize).saturating_mul(2).max(32);
|
||||||
|
let kind = app.session.plot.kind();
|
||||||
|
let series_points: Vec<(String, Vec<(f64, f64)>)> = app
|
||||||
|
.session
|
||||||
|
.plot
|
||||||
|
.iter()
|
||||||
|
.map(|series| {
|
||||||
|
let points = match kind {
|
||||||
|
PlotSeriesKind::TimeSeries => {
|
||||||
|
series.render_points_time_series(min[0], max[0], max_points)
|
||||||
|
}
|
||||||
|
PlotSeriesKind::XY => series.render_points_xy(max_points),
|
||||||
|
};
|
||||||
|
(
|
||||||
|
series.name.clone(),
|
||||||
|
points.into_iter().map(|[x, y]| (x, y)).collect(),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let palette = [GREEN, CYAN, AMBER, MAGENTA, BLUE, RED];
|
||||||
|
let datasets = series_points
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(index, (name, points))| {
|
||||||
|
Dataset::default()
|
||||||
|
.name(name.as_str())
|
||||||
|
.marker(Marker::Braille)
|
||||||
|
.graph_type(GraphType::Line)
|
||||||
|
.style(Style::default().fg(palette[index % palette.len()]))
|
||||||
|
.data(points)
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
|
let chart = Chart::new(datasets)
|
||||||
|
.block(block)
|
||||||
|
.style(Style::default().bg(PANEL_ALT))
|
||||||
|
.x_axis(axis("X", min[0], max[0], CYAN))
|
||||||
|
.y_axis(axis("Y", min[1], max[1], GREEN));
|
||||||
|
|
||||||
|
frame.render_widget(chart, area);
|
||||||
|
|
||||||
|
let overlay = Rect {
|
||||||
|
x: inner.x.saturating_add(1),
|
||||||
|
y: inner.y,
|
||||||
|
width: inner.width.saturating_sub(2).min(72),
|
||||||
|
height: 1,
|
||||||
|
};
|
||||||
|
let controls = Layout::default()
|
||||||
|
.direction(Direction::Horizontal)
|
||||||
|
.constraints([
|
||||||
|
Constraint::Length(16),
|
||||||
|
Constraint::Length(10),
|
||||||
|
Constraint::Length(10),
|
||||||
|
Constraint::Min(1),
|
||||||
|
])
|
||||||
|
.split(overlay);
|
||||||
|
app.add_hot_zone(controls[0], HotAction::PlotFollow);
|
||||||
|
app.add_hot_zone(controls[1], HotAction::PlotZoomIn);
|
||||||
|
app.add_hot_zone(controls[2], HotAction::PlotZoomOut);
|
||||||
|
let mode = match kind {
|
||||||
|
PlotSeriesKind::TimeSeries => "time",
|
||||||
|
PlotSeriesKind::XY => "xy",
|
||||||
|
};
|
||||||
|
frame.render_widget(
|
||||||
|
Paragraph::new(Line::from(vec![
|
||||||
|
Span::styled(
|
||||||
|
format!(
|
||||||
|
"[follow {}] ",
|
||||||
|
if app.plot.follow_latest { "on" } else { "off" }
|
||||||
|
),
|
||||||
|
Style::default()
|
||||||
|
.fg(Color::Black)
|
||||||
|
.bg(if app.plot.follow_latest { GREEN } else { MUTED })
|
||||||
|
.add_modifier(Modifier::BOLD),
|
||||||
|
),
|
||||||
|
Span::styled("[zoom +] ", Style::default().fg(Color::Black).bg(CYAN)),
|
||||||
|
Span::styled("[zoom -] ", Style::default().fg(Color::Black).bg(AMBER)),
|
||||||
|
Span::styled(format!("{mode} "), Style::default().fg(MUTED)),
|
||||||
|
Span::styled(
|
||||||
|
format!(
|
||||||
|
"{} series / {} pts",
|
||||||
|
app.session.plot.series_len(),
|
||||||
|
app.session.plot.total_points()
|
||||||
|
),
|
||||||
|
Style::default().fg(TEXT),
|
||||||
|
),
|
||||||
|
]))
|
||||||
|
.style(Style::default().bg(PANEL_ALT)),
|
||||||
|
overlay,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_composer(frame: &mut Frame, app: &mut App, area: Rect) {
|
||||||
|
app.add_hot_zone(area, HotAction::Focus(FocusPane::Composer));
|
||||||
|
let focused = app.focus == FocusPane::Composer || matches!(app.mode, AppMode::EditingSend);
|
||||||
|
let block = panel_block("Composer", focused, MAGENTA);
|
||||||
|
let inner = block.inner(area);
|
||||||
|
frame.render_widget(block, area);
|
||||||
|
|
||||||
|
let chunks = Layout::default()
|
||||||
|
.direction(Direction::Horizontal)
|
||||||
|
.constraints([Constraint::Min(20), Constraint::Length(28)])
|
||||||
|
.split(inner);
|
||||||
|
|
||||||
|
let input_block = Block::default()
|
||||||
|
.borders(Borders::ALL)
|
||||||
|
.border_style(Style::default().fg(if focused { MAGENTA } else { MUTED }))
|
||||||
|
.style(Style::default().bg(PANEL_ALT));
|
||||||
|
|
||||||
|
frame.render_widget(
|
||||||
|
Paragraph::new(input_line(app))
|
||||||
|
.block(input_block)
|
||||||
|
.wrap(Wrap { trim: false }),
|
||||||
|
chunks[0],
|
||||||
|
);
|
||||||
|
|
||||||
|
let side_rows = vec![
|
||||||
|
value_line(
|
||||||
|
"Mode",
|
||||||
|
app.session.send_mode.label(),
|
||||||
|
Some(HotAction::SendMode(match app.session.send_mode {
|
||||||
|
SendMode::Text => SendMode::Hex,
|
||||||
|
SendMode::Hex => SendMode::Text,
|
||||||
|
})),
|
||||||
|
),
|
||||||
|
value_line(
|
||||||
|
"Ending",
|
||||||
|
app.session.line_ending.label(),
|
||||||
|
Some(HotAction::LineEnding(next_line_ending(
|
||||||
|
app.session.line_ending,
|
||||||
|
))),
|
||||||
|
),
|
||||||
|
button_line("[ Send ]", GREEN, Some(HotAction::Send)),
|
||||||
|
(
|
||||||
|
Line::from(vec![
|
||||||
|
Span::styled("Status ", Style::default().fg(MUTED)),
|
||||||
|
Span::styled(app.session.send_status.clone(), Style::default().fg(TEXT)),
|
||||||
|
]),
|
||||||
|
None,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
for (index, (_, action)) in side_rows.iter().enumerate() {
|
||||||
|
if let Some(action) = action {
|
||||||
|
app.add_hot_zone(
|
||||||
|
Rect {
|
||||||
|
x: chunks[1].x,
|
||||||
|
y: chunks[1].y.saturating_add(index as u16),
|
||||||
|
width: chunks[1].width,
|
||||||
|
height: 1,
|
||||||
|
},
|
||||||
|
*action,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
frame.render_widget(
|
||||||
|
Paragraph::new(
|
||||||
|
side_rows
|
||||||
|
.into_iter()
|
||||||
|
.map(|(line, _)| line)
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
)
|
||||||
|
.style(Style::default().bg(PANEL)),
|
||||||
|
chunks[1],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_footer(frame: &mut Frame, app: &App, area: Rect) {
|
||||||
|
let search = if app.search.active && !app.search.query.is_empty() {
|
||||||
|
format!(
|
||||||
|
" search {}/{} '{}'",
|
||||||
|
app.search.display_index(),
|
||||||
|
app.search.count(),
|
||||||
|
app.search.query
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
String::new()
|
||||||
|
};
|
||||||
|
let shortcuts = "1/2/3 view c connect r reconnect e config / search Ctrl-S send q quit";
|
||||||
|
frame.render_widget(
|
||||||
|
Paragraph::new(Line::from(vec![
|
||||||
|
Span::styled(
|
||||||
|
format!(" {} ", app.notice),
|
||||||
|
Style::default().fg(TEXT).bg(PANEL_ALT),
|
||||||
|
),
|
||||||
|
Span::styled(search, Style::default().fg(AMBER).bg(PANEL_ALT)),
|
||||||
|
Span::styled(" ", Style::default().bg(PANEL_ALT)),
|
||||||
|
Span::styled(shortcuts, Style::default().fg(MUTED).bg(PANEL_ALT)),
|
||||||
|
])),
|
||||||
|
area,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_search_modal(frame: &mut Frame, app: &mut App) {
|
||||||
|
let area = centered_rect(70, 7, frame.area());
|
||||||
|
app.add_hot_zone(area, HotAction::CloseModal);
|
||||||
|
frame.render_widget(Clear, area);
|
||||||
|
let block = panel_block("Search", true, CYAN);
|
||||||
|
let inner = block.inner(area);
|
||||||
|
frame.render_widget(block, area);
|
||||||
|
|
||||||
|
let body = vec![
|
||||||
|
Line::from(vec![
|
||||||
|
Span::styled("Query: ", Style::default().fg(MUTED)),
|
||||||
|
Span::styled(app.search.query.clone(), Style::default().fg(TEXT)),
|
||||||
|
Span::styled(
|
||||||
|
"_",
|
||||||
|
Style::default().fg(CYAN).add_modifier(Modifier::SLOW_BLINK),
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
Line::from(vec![
|
||||||
|
Span::styled("Matches: ", Style::default().fg(MUTED)),
|
||||||
|
Span::styled(
|
||||||
|
format!("{}/{}", app.search.display_index(), app.search.count()),
|
||||||
|
Style::default().fg(AMBER),
|
||||||
|
),
|
||||||
|
Span::raw(" "),
|
||||||
|
Span::styled(
|
||||||
|
if app.search.case_sensitive {
|
||||||
|
"case sensitive"
|
||||||
|
} else {
|
||||||
|
"case insensitive"
|
||||||
|
},
|
||||||
|
Style::default().fg(MAGENTA),
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
Line::styled(
|
||||||
|
"Enter close Up/Down navigate Ctrl-C case",
|
||||||
|
Style::default().fg(MUTED),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
frame.render_widget(
|
||||||
|
Paragraph::new(body)
|
||||||
|
.style(Style::default().bg(PANEL))
|
||||||
|
.wrap(Wrap { trim: false }),
|
||||||
|
inner,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_config_modal(frame: &mut Frame, app: &mut App) {
|
||||||
|
let rows = app.config_form.rows();
|
||||||
|
let height = (rows.len() as u16 + 6).min(frame.area().height.saturating_sub(2));
|
||||||
|
let area = centered_rect(88, height, frame.area());
|
||||||
|
frame.render_widget(Clear, area);
|
||||||
|
let block = panel_block("Session Config", true, MAGENTA);
|
||||||
|
let inner = block.inner(area);
|
||||||
|
frame.render_widget(block, area);
|
||||||
|
|
||||||
|
let footer_height = 3;
|
||||||
|
let layout = Layout::default()
|
||||||
|
.direction(Direction::Vertical)
|
||||||
|
.constraints([Constraint::Min(3), Constraint::Length(footer_height)])
|
||||||
|
.split(inner);
|
||||||
|
|
||||||
|
let items = rows
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(index, row)| {
|
||||||
|
let focused = index == app.config_form.focused;
|
||||||
|
let value_style = if row.editable {
|
||||||
|
Style::default().fg(CYAN)
|
||||||
|
} else {
|
||||||
|
Style::default().fg(AMBER)
|
||||||
|
};
|
||||||
|
ListItem::new(Line::from(vec![
|
||||||
|
Span::styled(
|
||||||
|
format!("{:<18}", row.label),
|
||||||
|
Style::default().fg(if focused { Color::Black } else { MUTED }),
|
||||||
|
),
|
||||||
|
Span::styled(
|
||||||
|
row.value.clone(),
|
||||||
|
if focused {
|
||||||
|
value_style.bg(MAGENTA).fg(Color::Black)
|
||||||
|
} else {
|
||||||
|
value_style
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]))
|
||||||
|
.style(if focused {
|
||||||
|
Style::default().bg(MAGENTA)
|
||||||
|
} else {
|
||||||
|
Style::default().bg(PANEL)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
|
for (index, row) in rows.iter().enumerate() {
|
||||||
|
let y = layout[0].y.saturating_add(index as u16);
|
||||||
|
if y >= layout[0].y.saturating_add(layout[0].height) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
app.add_hot_zone(
|
||||||
|
Rect {
|
||||||
|
x: layout[0].x,
|
||||||
|
y,
|
||||||
|
width: layout[0].width,
|
||||||
|
height: 1,
|
||||||
|
},
|
||||||
|
if row.field == ConfigField::Apply {
|
||||||
|
HotAction::ConfigSubmit
|
||||||
|
} else {
|
||||||
|
HotAction::ConfigFocus(row.field)
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
frame.render_widget(
|
||||||
|
List::new(items).style(Style::default().bg(PANEL)),
|
||||||
|
layout[0],
|
||||||
|
);
|
||||||
|
app.add_hot_zone(layout[1], HotAction::ConfigCancel);
|
||||||
|
frame.render_widget(
|
||||||
|
Paragraph::new(vec![
|
||||||
|
Line::styled(
|
||||||
|
"Tab move Left/Right change options Enter apply Esc cancel",
|
||||||
|
Style::default().fg(MUTED),
|
||||||
|
),
|
||||||
|
Line::styled(serial_ports_hint(app), Style::default().fg(BLUE)),
|
||||||
|
])
|
||||||
|
.style(Style::default().bg(PANEL)),
|
||||||
|
layout[1],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_help_modal(frame: &mut Frame, app: &mut App) {
|
||||||
|
let area = centered_rect(74, 12, frame.area());
|
||||||
|
app.add_hot_zone(area, HotAction::CloseModal);
|
||||||
|
frame.render_widget(Clear, area);
|
||||||
|
let block = panel_block("Help", true, BLUE);
|
||||||
|
let inner = block.inner(area);
|
||||||
|
frame.render_widget(block, area);
|
||||||
|
frame.render_widget(
|
||||||
|
Paragraph::new(vec![
|
||||||
|
Line::styled(
|
||||||
|
"Keyboard",
|
||||||
|
Style::default().fg(CYAN).add_modifier(Modifier::BOLD),
|
||||||
|
),
|
||||||
|
Line::raw("1/2/3 switch views, Tab shift focus, q quit"),
|
||||||
|
Line::raw("c connect, r reconnect, e config, / search, x clear"),
|
||||||
|
Line::raw("Ctrl-S send, Enter edit/send composer, Esc close modal"),
|
||||||
|
Line::styled(
|
||||||
|
"Mouse",
|
||||||
|
Style::default().fg(AMBER).add_modifier(Modifier::BOLD),
|
||||||
|
),
|
||||||
|
Line::raw("Click tabs, buttons, toggles, config rows, and composer."),
|
||||||
|
Line::raw("Wheel scrolls Text/Hex and zooms Plot."),
|
||||||
|
])
|
||||||
|
.style(Style::default().fg(TEXT).bg(PANEL))
|
||||||
|
.wrap(Wrap { trim: false }),
|
||||||
|
inner,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn format_console_line(
|
||||||
|
index: usize,
|
||||||
|
line: &ConsoleLine,
|
||||||
|
display: DisplayOptions,
|
||||||
|
app: &App,
|
||||||
|
) -> Line<'static> {
|
||||||
|
let mut spans = prefix_spans(line.elapsed, line.direction, &line.pipeline, display);
|
||||||
|
let base_style = Style::default().fg(TEXT);
|
||||||
|
spans.extend(ansi_to_spans(&line.text, base_style));
|
||||||
|
apply_search_style(index, Line::from(spans), app)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn format_hex_line(
|
||||||
|
index: usize,
|
||||||
|
line: &HexLine,
|
||||||
|
display: DisplayOptions,
|
||||||
|
app: &App,
|
||||||
|
) -> Line<'static> {
|
||||||
|
let mut spans = prefix_spans(line.elapsed, line.direction, &line.pipeline, display);
|
||||||
|
spans.push(Span::styled(line.hex.clone(), Style::default().fg(AMBER)));
|
||||||
|
spans.push(Span::styled(" |", Style::default().fg(MUTED)));
|
||||||
|
spans.push(Span::styled(line.ascii.clone(), Style::default().fg(TEXT)));
|
||||||
|
spans.push(Span::styled("|", Style::default().fg(MUTED)));
|
||||||
|
apply_search_style(index, Line::from(spans), app)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn prefix_spans(
|
||||||
|
elapsed: Duration,
|
||||||
|
direction: LineDirection,
|
||||||
|
pipeline: &str,
|
||||||
|
display: DisplayOptions,
|
||||||
|
) -> Vec<Span<'static>> {
|
||||||
|
let mut spans = Vec::new();
|
||||||
|
if display.show_timestamp {
|
||||||
|
spans.push(Span::styled(
|
||||||
|
format!("[{}] ", format_elapsed(elapsed)),
|
||||||
|
Style::default().fg(MUTED),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if display.show_direction {
|
||||||
|
let (label, color) = match direction {
|
||||||
|
LineDirection::In => ("IN", CYAN),
|
||||||
|
LineDirection::Out => ("OUT", AMBER),
|
||||||
|
};
|
||||||
|
spans.push(Span::styled(
|
||||||
|
format!("[{label}] "),
|
||||||
|
Style::default().fg(color).add_modifier(Modifier::BOLD),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if display.show_pipeline {
|
||||||
|
spans.push(Span::styled(
|
||||||
|
format!("[{pipeline}] "),
|
||||||
|
Style::default().fg(MAGENTA),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
spans
|
||||||
|
}
|
||||||
|
|
||||||
|
fn apply_search_style(index: usize, line: Line<'static>, app: &App) -> Line<'static> {
|
||||||
|
if !app.search.active || app.search.query.is_empty() {
|
||||||
|
return line;
|
||||||
|
}
|
||||||
|
|
||||||
|
if app.search.current_line() == Some(index) {
|
||||||
|
line.style(Style::default().bg(AMBER))
|
||||||
|
} else if app.search.matches.contains(&index) {
|
||||||
|
line.style(Style::default().bg(Color::Rgb(47, 61, 91)))
|
||||||
|
} else {
|
||||||
|
line
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn input_line(app: &App) -> Line<'static> {
|
||||||
|
if app.session.send_input.is_empty() {
|
||||||
|
return Line::styled(
|
||||||
|
match app.session.send_mode {
|
||||||
|
SendMode::Text => "type text payload",
|
||||||
|
SendMode::Hex => "hex bytes, e.g. 48 65 6c 6c 6f",
|
||||||
|
},
|
||||||
|
Style::default().fg(MUTED),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if !matches!(app.mode, AppMode::EditingSend) {
|
||||||
|
return Line::styled(app.session.send_input.clone(), Style::default().fg(TEXT));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut spans = Vec::new();
|
||||||
|
let cursor = app.session.input_cursor;
|
||||||
|
for (index, ch) in app.session.send_input.chars().enumerate() {
|
||||||
|
if index == cursor {
|
||||||
|
spans.push(Span::styled(
|
||||||
|
ch.to_string(),
|
||||||
|
Style::default().fg(Color::Black).bg(MAGENTA),
|
||||||
|
));
|
||||||
|
} else {
|
||||||
|
spans.push(Span::styled(ch.to_string(), Style::default().fg(TEXT)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if cursor >= app.session.send_input.chars().count() {
|
||||||
|
spans.push(Span::styled(" ", Style::default().bg(MAGENTA)));
|
||||||
|
}
|
||||||
|
Line::from(spans)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn section_line(label: &'static str) -> (Line<'static>, Option<HotAction>) {
|
||||||
|
(
|
||||||
|
Line::styled(
|
||||||
|
format!("-- {label} "),
|
||||||
|
Style::default().fg(MUTED).add_modifier(Modifier::BOLD),
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn spacer_line() -> (Line<'static>, Option<HotAction>) {
|
||||||
|
(Line::raw(""), None)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn button_line(
|
||||||
|
label: &'static str,
|
||||||
|
color: Color,
|
||||||
|
action: Option<HotAction>,
|
||||||
|
) -> (Line<'static>, Option<HotAction>) {
|
||||||
|
(
|
||||||
|
Line::from(vec![Span::styled(
|
||||||
|
label,
|
||||||
|
Style::default()
|
||||||
|
.fg(Color::Black)
|
||||||
|
.bg(color)
|
||||||
|
.add_modifier(Modifier::BOLD),
|
||||||
|
)]),
|
||||||
|
action,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn toggle_line(
|
||||||
|
label: &'static str,
|
||||||
|
enabled: bool,
|
||||||
|
action: Option<HotAction>,
|
||||||
|
) -> (Line<'static>, Option<HotAction>) {
|
||||||
|
(
|
||||||
|
Line::from(vec![
|
||||||
|
Span::styled(
|
||||||
|
if enabled { "[x] " } else { "[ ] " },
|
||||||
|
Style::default().fg(if enabled { GREEN } else { MUTED }),
|
||||||
|
),
|
||||||
|
Span::styled(label, Style::default().fg(TEXT)),
|
||||||
|
]),
|
||||||
|
action,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn value_line(
|
||||||
|
label: &'static str,
|
||||||
|
value: &'static str,
|
||||||
|
action: Option<HotAction>,
|
||||||
|
) -> (Line<'static>, Option<HotAction>) {
|
||||||
|
(
|
||||||
|
Line::from(vec![
|
||||||
|
Span::styled(format!("{label:<8}"), Style::default().fg(MUTED)),
|
||||||
|
Span::styled(value.to_string(), Style::default().fg(CYAN)),
|
||||||
|
]),
|
||||||
|
action,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn panel_block(title: &str, focused: bool, color: Color) -> Block<'_> {
|
||||||
|
Block::default()
|
||||||
|
.borders(Borders::ALL)
|
||||||
|
.title(Span::styled(
|
||||||
|
format!(" {title} "),
|
||||||
|
Style::default()
|
||||||
|
.fg(if focused { Color::Black } else { color })
|
||||||
|
.bg(if focused { color } else { PANEL })
|
||||||
|
.add_modifier(Modifier::BOLD),
|
||||||
|
))
|
||||||
|
.border_style(Style::default().fg(if focused {
|
||||||
|
color
|
||||||
|
} else {
|
||||||
|
Color::Rgb(45, 58, 74)
|
||||||
|
}))
|
||||||
|
.style(Style::default().bg(PANEL))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn axis(title: &'static str, min: f64, max: f64, color: Color) -> Axis<'static> {
|
||||||
|
Axis::default()
|
||||||
|
.title(title)
|
||||||
|
.style(Style::default().fg(color))
|
||||||
|
.bounds([min, max])
|
||||||
|
.labels(vec![
|
||||||
|
Span::styled(format!("{min:.2}"), Style::default().fg(MUTED)),
|
||||||
|
Span::styled(format!("{max:.2}"), Style::default().fg(MUTED)),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
fn centered_rect(width: u16, height: u16, area: Rect) -> Rect {
|
||||||
|
let vertical = Layout::default()
|
||||||
|
.direction(Direction::Vertical)
|
||||||
|
.constraints([
|
||||||
|
Constraint::Fill(1),
|
||||||
|
Constraint::Length(height.min(area.height)),
|
||||||
|
Constraint::Fill(1),
|
||||||
|
])
|
||||||
|
.split(area);
|
||||||
|
|
||||||
|
Layout::default()
|
||||||
|
.direction(Direction::Horizontal)
|
||||||
|
.constraints([
|
||||||
|
Constraint::Fill(1),
|
||||||
|
Constraint::Length(width.min(area.width)),
|
||||||
|
Constraint::Fill(1),
|
||||||
|
])
|
||||||
|
.split(vertical[1])[1]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn status_color(status: &ConnectionStatus) -> Color {
|
||||||
|
match status {
|
||||||
|
ConnectionStatus::Connected => GREEN,
|
||||||
|
ConnectionStatus::Disconnected => MUTED,
|
||||||
|
ConnectionStatus::Connecting => AMBER,
|
||||||
|
ConnectionStatus::Error(_) => RED,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn view_color(view: View) -> Color {
|
||||||
|
match view {
|
||||||
|
View::Text => CYAN,
|
||||||
|
View::Hex => AMBER,
|
||||||
|
View::Plot => GREEN,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn format_elapsed(elapsed: Duration) -> String {
|
||||||
|
let secs = elapsed.as_secs_f64();
|
||||||
|
if secs < 60.0 {
|
||||||
|
format!("{secs:05.2}")
|
||||||
|
} else {
|
||||||
|
format!("{:02}:{:02}", (secs / 60.0) as u64, (secs % 60.0) as u64)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn next_line_ending(current: LineEnding) -> LineEnding {
|
||||||
|
let endings = LineEnding::ALL;
|
||||||
|
let index = endings
|
||||||
|
.iter()
|
||||||
|
.position(|ending| *ending == current)
|
||||||
|
.unwrap_or(0);
|
||||||
|
endings[(index + 1) % endings.len()]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn serial_ports_hint(app: &App) -> String {
|
||||||
|
if app.config_form.available_serial_ports.is_empty() {
|
||||||
|
String::from("Serial ports: none detected")
|
||||||
|
} else {
|
||||||
|
format!(
|
||||||
|
"Serial ports: {}",
|
||||||
|
app.config_form.available_serial_ports.join(", ")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
mod app;
|
|
||||||
mod app_state;
|
|
||||||
mod buffers;
|
|
||||||
mod panels;
|
|
||||||
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())))),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
use crate::app::DisplayOptions;
|
|
||||||
use crate::buffers::{LineDirection, TextBuffer};
|
|
||||||
use egui::{ScrollArea, Ui};
|
|
||||||
|
|
||||||
pub fn render(ui: &mut Ui, buf: &TextBuffer, display: DisplayOptions) {
|
|
||||||
ScrollArea::vertical().stick_to_bottom(true).show(ui, |ui| {
|
|
||||||
for line in buf.iter() {
|
|
||||||
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(" "))
|
|
||||||
};
|
|
||||||
ui.monospace(format!("{prefix}{}", line.text));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
use crate::app::DisplayOptions;
|
|
||||||
use crate::buffers::{HexBuffer, LineDirection};
|
|
||||||
use egui::{ScrollArea, Ui};
|
|
||||||
|
|
||||||
pub fn render(ui: &mut Ui, buf: &HexBuffer, display: DisplayOptions) {
|
|
||||||
let mut lines = buf.iter().peekable();
|
|
||||||
if lines.peek().is_none() {
|
|
||||||
ui.label("no hex data");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
ScrollArea::vertical().stick_to_bottom(true).show(ui, |ui| {
|
|
||||||
for line in lines {
|
|
||||||
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(" "))
|
|
||||||
};
|
|
||||||
ui.monospace(format!("{prefix}{} |{}|", line.hex, line.ascii));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
fn main() {}
|
|
||||||
16
examples/drone_plot.lua
Normal file
16
examples/drone_plot.lua
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
return {
|
||||||
|
decode = function(frame)
|
||||||
|
if #frame == 0 then return nil end
|
||||||
|
|
||||||
|
local gyro_str = frame:match("Gyro:([%d%.%-]+),([%d%.%-]+),([%d%.%-]+)")
|
||||||
|
if not gyro_str then return nil end
|
||||||
|
|
||||||
|
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,
|
||||||
|
}
|
||||||
33
examples/drone_text.lua
Normal file
33
examples/drone_text.lua
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
local LABELS = {
|
||||||
|
AHRS = "Quaternion",
|
||||||
|
YPR = "Yaw/Pitch/Roll",
|
||||||
|
Gyro = "Gyro (deg/s)",
|
||||||
|
RC = "RC Channels",
|
||||||
|
M = "Motors",
|
||||||
|
L = "L",
|
||||||
|
F = "F",
|
||||||
|
C = "C",
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
decode = function(frame)
|
||||||
|
if #frame == 0 then return nil end
|
||||||
|
|
||||||
|
local lines = {}
|
||||||
|
for segment in frame:gmatch("[^|]+") do
|
||||||
|
local colon = segment:find(":")
|
||||||
|
if colon then
|
||||||
|
local key = segment:sub(1, colon - 1)
|
||||||
|
local val = segment:sub(colon + 1)
|
||||||
|
local label = LABELS[key] or key
|
||||||
|
lines[#lines + 1] = string.format("%-18s %s", label .. ":", val)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
lines[#lines + 1] = string.rep("-", 40)
|
||||||
|
|
||||||
|
return {
|
||||||
|
kind = "text",
|
||||||
|
data = table.concat(lines, "\n"),
|
||||||
|
}
|
||||||
|
end,
|
||||||
|
}
|
||||||
43
tests/lua_line_framer.lua
Normal file
43
tests/lua_line_framer.lua
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
local buffer = ""
|
||||||
|
|
||||||
|
return {
|
||||||
|
feed = function(bytes)
|
||||||
|
buffer = buffer .. bytes
|
||||||
|
local frames = {}
|
||||||
|
|
||||||
|
while true do
|
||||||
|
local i = buffer:find("\n", 1, true)
|
||||||
|
if not i then
|
||||||
|
break
|
||||||
|
end
|
||||||
|
|
||||||
|
local frame = buffer:sub(1, i - 1)
|
||||||
|
if frame:sub(-1) == "\r" then
|
||||||
|
frame = frame:sub(1, -2)
|
||||||
|
end
|
||||||
|
|
||||||
|
frames[#frames + 1] = frame
|
||||||
|
buffer = buffer:sub(i + 1)
|
||||||
|
end
|
||||||
|
|
||||||
|
return frames
|
||||||
|
end,
|
||||||
|
|
||||||
|
flush = function()
|
||||||
|
if #buffer == 0 then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
local frame = buffer
|
||||||
|
buffer = ""
|
||||||
|
return frame
|
||||||
|
end,
|
||||||
|
|
||||||
|
reset = function()
|
||||||
|
buffer = ""
|
||||||
|
end,
|
||||||
|
|
||||||
|
pending_len = function()
|
||||||
|
return #buffer
|
||||||
|
end,
|
||||||
|
}
|
||||||
12
tests/lua_text_decoder.lua
Normal file
12
tests/lua_text_decoder.lua
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
return {
|
||||||
|
decode = function(frame)
|
||||||
|
if #frame == 0 then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
return {
|
||||||
|
kind = "text",
|
||||||
|
data = frame,
|
||||||
|
}
|
||||||
|
end,
|
||||||
|
}
|
||||||
317
tools/test_ansi.py
Executable file
317
tools/test_ansi.py
Executable file
@@ -0,0 +1,317 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Generate ANSI-colored terminal output for testing pipeview ANSI rendering."""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import math
|
||||||
|
import random
|
||||||
|
import socket
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import itertools
|
||||||
|
|
||||||
|
# ── helpers ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
RESET = "\x1b[0m"
|
||||||
|
|
||||||
|
# Standard 3/4-bit foreground
|
||||||
|
FG = {
|
||||||
|
"black": "\x1b[30m", "red": "\x1b[31m", "green": "\x1b[32m",
|
||||||
|
"yellow": "\x1b[33m", "blue": "\x1b[34m", "magenta":"\x1b[35m",
|
||||||
|
"cyan": "\x1b[36m", "white": "\x1b[37m",
|
||||||
|
}
|
||||||
|
FG_BRIGHT = {
|
||||||
|
"black": "\x1b[90m", "red": "\x1b[91m", "green": "\x1b[92m",
|
||||||
|
"yellow": "\x1b[93m", "blue": "\x1b[94m", "magenta":"\x1b[95m",
|
||||||
|
"cyan": "\x1b[96m", "white": "\x1b[97m",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Standard 3/4-bit background
|
||||||
|
BG = {
|
||||||
|
"black": "\x1b[40m", "red": "\x1b[41m", "green": "\x1b[42m",
|
||||||
|
"yellow": "\x1b[43m", "blue": "\x1b[44m", "magenta":"\x1b[45m",
|
||||||
|
"cyan": "\x1b[46m", "white": "\x1b[47m",
|
||||||
|
}
|
||||||
|
BG_BRIGHT = {
|
||||||
|
"black": "\x1b[100m", "red": "\x1b[101m", "green": "\x1b[102m",
|
||||||
|
"yellow": "\x1b[103m", "blue": "\x1b[104m", "magenta":"\x1b[105m",
|
||||||
|
"cyan": "\x1b[106m", "white": "\x1b[107m",
|
||||||
|
}
|
||||||
|
|
||||||
|
BOLD = "\x1b[1m"
|
||||||
|
FAINT = "\x1b[2m"
|
||||||
|
ITALIC = "\x1b[3m"
|
||||||
|
UNDERLINE = "\x1b[4m"
|
||||||
|
STRIKE = "\x1b[9m"
|
||||||
|
|
||||||
|
|
||||||
|
def fg256(n: int) -> str:
|
||||||
|
return f"\x1b[38;5;{n}m"
|
||||||
|
|
||||||
|
def bg256(n: int) -> str:
|
||||||
|
return f"\x1b[48;5;{n}m"
|
||||||
|
|
||||||
|
def fg_rgb(r: int, g: int, b: int) -> str:
|
||||||
|
return f"\x1b[38;2;{r};{g};{b}m"
|
||||||
|
|
||||||
|
def bg_rgb(r: int, g: int, b: int) -> str:
|
||||||
|
return f"\x1b[48;2;{r};{g};{b}m"
|
||||||
|
|
||||||
|
|
||||||
|
# ── test scenes ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def scene_16color(delay: float):
|
||||||
|
"""Show all 8 standard + 8 bright foreground colors."""
|
||||||
|
lines = []
|
||||||
|
lines.append(f"{BOLD}─── Standard 16 Foreground Colors ───{RESET}")
|
||||||
|
for name in FG:
|
||||||
|
lines.append(f" {FG[name]}■ {name:>8}{RESET} "
|
||||||
|
f"{FG_BRIGHT[name]}■ bright {name}{RESET}")
|
||||||
|
for line in lines:
|
||||||
|
yield line, delay
|
||||||
|
|
||||||
|
|
||||||
|
def scene_bg_colors(delay: float):
|
||||||
|
"""Show foreground colors on colored backgrounds."""
|
||||||
|
lines = []
|
||||||
|
lines.append(f"{BOLD}─── Background Colors ───{RESET}")
|
||||||
|
for bg_name in BG:
|
||||||
|
line = ""
|
||||||
|
for fg_name in ["white", "black"]:
|
||||||
|
line += f"{BG[bg_name]}{FG[fg_name]} {fg_name} on {bg_name} {RESET} "
|
||||||
|
lines.append(line)
|
||||||
|
for line in lines:
|
||||||
|
yield line, delay
|
||||||
|
|
||||||
|
|
||||||
|
def scene_styles(delay: float):
|
||||||
|
"""Show bold, italic, underline, strikethrough."""
|
||||||
|
lines = []
|
||||||
|
lines.append(f"{BOLD}─── Text Styles ───{RESET}")
|
||||||
|
lines.append(f" {BOLD}Bold text{RESET}")
|
||||||
|
lines.append(f" {ITALIC}Italic text{RESET}")
|
||||||
|
lines.append(f" {UNDERLINE}Underlined text{RESET}")
|
||||||
|
lines.append(f" {STRIKE}Strikethrough text{RESET}")
|
||||||
|
lines.append(f" {BOLD}{ITALIC}Bold + Italic{RESET}")
|
||||||
|
lines.append(f" {BOLD}{FG['red']}Bold Red{RESET} vs {FG['red']}Normal Red{RESET}")
|
||||||
|
for line in lines:
|
||||||
|
yield line, delay
|
||||||
|
|
||||||
|
|
||||||
|
def scene_256_ramp(delay: float):
|
||||||
|
"""Show a 256-color ramp."""
|
||||||
|
lines = []
|
||||||
|
lines.append(f"{BOLD}─── 256-Color Ramp ───{RESET}")
|
||||||
|
# 16 basic colors
|
||||||
|
line = "Basic: "
|
||||||
|
for n in range(16):
|
||||||
|
line += f"{fg256(n)}██{RESET}"
|
||||||
|
lines.append(line)
|
||||||
|
for row in range(3):
|
||||||
|
line = f"Cube {row+1}: "
|
||||||
|
for col in range(6):
|
||||||
|
n = 16 + row * 36 + col * 6
|
||||||
|
line += f"{fg256(n)}██{RESET}"
|
||||||
|
lines.append(line)
|
||||||
|
line = "Gray: "
|
||||||
|
for n in range(232, 256):
|
||||||
|
line += f"{fg256(n)}█{RESET}"
|
||||||
|
lines.append(line)
|
||||||
|
for line in lines:
|
||||||
|
yield line, delay
|
||||||
|
|
||||||
|
|
||||||
|
def scene_truecolor(delay: float):
|
||||||
|
"""Show 24-bit truecolor gradient."""
|
||||||
|
lines = []
|
||||||
|
lines.append(f"{BOLD}─── Truecolor (24-bit) ───{RESET}")
|
||||||
|
line = "R→G: "
|
||||||
|
for i in range(32):
|
||||||
|
r = 255 - i * 8
|
||||||
|
g = i * 8
|
||||||
|
line += f"{fg_rgb(r, g, 0)}█{RESET}"
|
||||||
|
lines.append(line)
|
||||||
|
line = "B→Y: "
|
||||||
|
for i in range(32):
|
||||||
|
b = 255 - i * 8
|
||||||
|
r = g = i * 8
|
||||||
|
line += f"{fg_rgb(r, g, b)}█{RESET}"
|
||||||
|
lines.append(line)
|
||||||
|
line = "Rainbow: "
|
||||||
|
for i in range(48):
|
||||||
|
hue = i / 48.0 * 6.0
|
||||||
|
r, g, b = _hsv_to_rgb(hue, 1.0, 1.0)
|
||||||
|
line += f"{fg_rgb(r, g, b)}━{RESET}"
|
||||||
|
lines.append(line)
|
||||||
|
for line in lines:
|
||||||
|
yield line, delay
|
||||||
|
|
||||||
|
|
||||||
|
def scene_status_log(delay: float):
|
||||||
|
"""Simulate systemd-style status output."""
|
||||||
|
lines = [
|
||||||
|
f"{FG_BRIGHT['green']}[ OK ]{RESET} Started {BOLD}System Logging Service{RESET}.",
|
||||||
|
f"{FG_BRIGHT['green']}[ OK ]{RESET} Started {BOLD}Network Manager{RESET}.",
|
||||||
|
f"{FG_BRIGHT['green']}[ OK ]{RESET} Reached target {ITALIC}multi-user.target{RESET}.",
|
||||||
|
f"{FG_BRIGHT['yellow']}[ WARN ]{RESET} Failed to load kernel module {UNDERLINE}nvidia{RESET}.",
|
||||||
|
f"{FG_BRIGHT['red']}[ FAIL ]{RESET} {FG['red']}{BOLD}ssh.service{RESET}{FG['red']} failed to start.{RESET}",
|
||||||
|
f"{FG_BRIGHT['cyan']}[ INFO ]{RESET} Listening on {fg256(33)}0.0.0.0:8080{RESET}.",
|
||||||
|
f" {ITALIC}─ subject=CN=example.com{RESET}",
|
||||||
|
f" {ITALIC}─ fingerprint={FG['yellow']}SHA256:abcd1234{RESET}",
|
||||||
|
f"{FG_BRIGHT['green']}[ OK ]{RESET} Mounted {BG['blue']}{FG['white']} /var {RESET} filesystem.",
|
||||||
|
f"{FG_BRIGHT['magenta']}[STATUS]{RESET} CPU: {fg256(46)}32%{RESET} "
|
||||||
|
f"Mem: {fg256(220)}1.2G{RESET}/{fg256(33)}4.0G{RESET} "
|
||||||
|
f"Temp: {_temp_color(58)}58°C{RESET}",
|
||||||
|
]
|
||||||
|
for line in lines:
|
||||||
|
yield line, delay
|
||||||
|
|
||||||
|
|
||||||
|
def scene_colored_log_stream(delay: float):
|
||||||
|
"""Continuously generate log lines cycling through themes."""
|
||||||
|
themes = [
|
||||||
|
('green', 'INFO ', "Connection accepted from 192.168.1.100"),
|
||||||
|
('cyan', 'DEBUG', "Processing frame #{}"),
|
||||||
|
('yellow', 'WARN ', "Buffer usage at {:.0f}%"),
|
||||||
|
('red', 'ERROR', "CRC mismatch on packet {}"),
|
||||||
|
('white', 'TRACE', "Entering function handle_request()"),
|
||||||
|
('magenta','AUDIT', "User admin performed action {}"),
|
||||||
|
]
|
||||||
|
counter = itertools.count(1)
|
||||||
|
while True:
|
||||||
|
color_name, level, template = random.choice(themes)
|
||||||
|
n = next(counter)
|
||||||
|
msg = template.format(n, random.uniform(60, 95), n)
|
||||||
|
ts = time.strftime("%H:%M:%S")
|
||||||
|
line = (f"{ITALIC}{ts}{RESET} "
|
||||||
|
f"{FG_BRIGHT[color_name]}{BOLD}[{level}]{RESET} "
|
||||||
|
f"{msg}")
|
||||||
|
yield line, delay
|
||||||
|
|
||||||
|
|
||||||
|
def scene_rainbow_wave(delay: float):
|
||||||
|
"""Animated rainbow wave (for live testing)."""
|
||||||
|
t0 = time.time()
|
||||||
|
while True:
|
||||||
|
t = time.time() - t0
|
||||||
|
line = ""
|
||||||
|
for x in range(60):
|
||||||
|
hue = (x / 60.0 + t * 0.3) % 1.0
|
||||||
|
r, g, b = _hsv_to_rgb(hue * 6.0, 1.0, 0.8 + 0.2 * math.sin(t * 2 + x * 0.3))
|
||||||
|
line += f"{fg_rgb(r, g, b)}━{RESET}"
|
||||||
|
yield line, delay
|
||||||
|
|
||||||
|
|
||||||
|
def scene_system_boot(delay: float):
|
||||||
|
"""Simulate a system boot sequence with progress."""
|
||||||
|
services = [
|
||||||
|
("udev", "Kernel Device Manager", 0.6),
|
||||||
|
("systemd-journald","Journal Service", 0.8),
|
||||||
|
("NetworkManager", "Network Manager", 0.7),
|
||||||
|
("sshd", "OpenSSH Daemon", 0.5),
|
||||||
|
("nginx", "HTTP Server", 0.9),
|
||||||
|
("postgresql", "PostgreSQL 16", 1.2),
|
||||||
|
("docker", "Docker Engine", 1.5),
|
||||||
|
("pipeview", "Pipe Data Monitor", 0.3),
|
||||||
|
]
|
||||||
|
t0 = time.time()
|
||||||
|
for name, desc, startup_time in services:
|
||||||
|
yield (f"{FG_BRIGHT['cyan']}[ .... ]{RESET} Starting {BOLD}{name}{RESET} - {desc}...",
|
||||||
|
startup_time * 0.3)
|
||||||
|
yield (f"{FG_BRIGHT['green']}[ OK ]{RESET} Started {BOLD}{name}{RESET} - {desc}.",
|
||||||
|
delay)
|
||||||
|
yield (f"\n{FG_BRIGHT['green']}{BOLD}Boot complete.{RESET} "
|
||||||
|
f"({time.time() - t0:.1f}s)", delay)
|
||||||
|
|
||||||
|
|
||||||
|
# ── helpers ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _hsv_to_rgb(h: float, s: float, v: float) -> tuple[int, int, int]:
|
||||||
|
"""HSV → RGB, h in [0, 6), s,v in [0, 1]."""
|
||||||
|
c = v * s
|
||||||
|
x = c * (1 - abs((h % 2) - 1))
|
||||||
|
m = v - c
|
||||||
|
r, g, b = {
|
||||||
|
0: (c, x, 0), 1: (x, c, 0), 2: (0, c, x),
|
||||||
|
3: (0, x, c), 4: (x, 0, c), 5: (c, 0, x),
|
||||||
|
}[int(h) % 6]
|
||||||
|
return int((r + m) * 255), int((g + m) * 255), int((b + m) * 255)
|
||||||
|
|
||||||
|
|
||||||
|
def _temp_color(temp: float) -> str:
|
||||||
|
if temp < 50: return fg256(46)
|
||||||
|
elif temp < 70: return fg256(220)
|
||||||
|
else: return fg256(196)
|
||||||
|
|
||||||
|
|
||||||
|
# ── scenes registry ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
STATIC_SCENES = [
|
||||||
|
("16color", scene_16color, "Standard 16 foreground colors"),
|
||||||
|
("bg", scene_bg_colors, "Background colors"),
|
||||||
|
("styles", scene_styles, "Bold, italic, underline, strikethrough"),
|
||||||
|
("256ramp", scene_256_ramp, "256-color ramp"),
|
||||||
|
("truecolor", scene_truecolor, "24-bit truecolor gradients"),
|
||||||
|
("status", scene_status_log, "systemd-style status log"),
|
||||||
|
("boot", scene_system_boot, "System boot sequence"),
|
||||||
|
]
|
||||||
|
|
||||||
|
LIVE_SCENES = [
|
||||||
|
("logstream", scene_colored_log_stream, "Continuous colored log stream"),
|
||||||
|
("rainbow", scene_rainbow_wave, "Animated rainbow wave"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# ── main ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="ANSI color test data generator for pipeview",
|
||||||
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||||
|
epilog="Scenes:\n" + "\n".join(
|
||||||
|
f" {n:<14} {d}" for n, _, d in STATIC_SCENES + LIVE_SCENES
|
||||||
|
),
|
||||||
|
)
|
||||||
|
parser.add_argument("--host", default="127.0.0.1", help="TCP bind host")
|
||||||
|
parser.add_argument("--port", type=int, default=8099, help="TCP port")
|
||||||
|
parser.add_argument("--scene", choices=[n for n, _, _ in STATIC_SCENES + LIVE_SCENES] + ["all"],
|
||||||
|
default="all", help="Scene to play")
|
||||||
|
parser.add_argument("--delay", type=float, default=0.15, help="Inter-line delay (seconds)")
|
||||||
|
parser.add_argument("--loop", action="store_true", help="Repeat the scene forever")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
scenes = STATIC_SCENES + LIVE_SCENES
|
||||||
|
if args.scene != "all":
|
||||||
|
scenes = [(n, f, d) for n, f, d in scenes if n == args.scene]
|
||||||
|
|
||||||
|
print(f"ANSI color test → {args.host}:{args.port}")
|
||||||
|
print(f"Scene: {args.scene}, delay: {args.delay}s, loop: {args.loop}")
|
||||||
|
|
||||||
|
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as server:
|
||||||
|
server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||||
|
server.bind((args.host, args.port))
|
||||||
|
server.listen(1)
|
||||||
|
print(f"Listening on {args.host}:{args.port}, waiting for connections...")
|
||||||
|
|
||||||
|
while True:
|
||||||
|
sock, addr = server.accept()
|
||||||
|
print(f"Client connected from {addr}")
|
||||||
|
try:
|
||||||
|
_play_scenes(sock, scenes, args.delay, args.loop)
|
||||||
|
except (BrokenPipeError, ConnectionResetError):
|
||||||
|
pass
|
||||||
|
print("Client disconnected. Waiting for new connection...")
|
||||||
|
|
||||||
|
|
||||||
|
def _play_scenes(sock: socket.socket, scenes, delay: float, loop_scenes: bool):
|
||||||
|
first = True
|
||||||
|
while first or loop_scenes:
|
||||||
|
first = False
|
||||||
|
for name, scene_fn, _desc in scenes:
|
||||||
|
print(f" [{name}]")
|
||||||
|
for line, line_delay in scene_fn(delay):
|
||||||
|
sock.sendall((line + "\n").encode())
|
||||||
|
time.sleep(line_delay)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
102
tools/test_drone.py
Normal file
102
tools/test_drone.py
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Generate fake drone telemetry data for testing pipeview Lua decoder."""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import math
|
||||||
|
import random
|
||||||
|
import socket
|
||||||
|
import time
|
||||||
|
|
||||||
|
|
||||||
|
def generate_line(t: float) -> str:
|
||||||
|
"""Generate one telemetry line with simulated sensor values."""
|
||||||
|
# Quaternion (w, x, y, z) — gently rotating
|
||||||
|
angle = t * 0.5
|
||||||
|
qw = math.cos(angle)
|
||||||
|
qx = math.sin(angle) * 0.1
|
||||||
|
qy = math.sin(angle) * 0.05
|
||||||
|
qz = math.sin(angle) * 0.02
|
||||||
|
|
||||||
|
# Yaw/Pitch/Roll — sine wave simulation
|
||||||
|
yaw = math.degrees(math.sin(t * 0.3)) * 30
|
||||||
|
pitch = math.degrees(math.sin(t * 0.5)) * 15
|
||||||
|
roll = math.degrees(math.sin(t * 0.7)) * 10
|
||||||
|
|
||||||
|
# Gyro (deg/s)
|
||||||
|
gz = math.sin(t * 0.3) * 50 + random.uniform(-2, 2)
|
||||||
|
gy = math.sin(t * 0.5) * 30 + random.uniform(-2, 2)
|
||||||
|
gx = math.sin(t * 0.7) * 20 + random.uniform(-2, 2)
|
||||||
|
|
||||||
|
# RC channels (1000-2000 us)
|
||||||
|
rc_r = int(1500 + math.sin(t * 0.3) * 200)
|
||||||
|
rc_p = int(1500 + math.sin(t * 0.5) * 150)
|
||||||
|
rc_t = int(1000 + (math.sin(t * 0.1) + 1) * 500) # throttle 1000-2000
|
||||||
|
rc_y = int(1500 + math.sin(t * 0.3) * 100)
|
||||||
|
|
||||||
|
# Motors (1000-2000)
|
||||||
|
base = 1200 + int((math.sin(t * 0.1) + 1) * 400)
|
||||||
|
m1 = base + random.randint(-20, 20)
|
||||||
|
m2 = base + random.randint(-20, 20)
|
||||||
|
m3 = base + random.randint(-20, 20)
|
||||||
|
m4 = base + random.randint(-20, 20)
|
||||||
|
|
||||||
|
return (
|
||||||
|
f"AHRS q:{qw:.4f},{qx:.4f},{qy:.4f},{qz:.4f}|"
|
||||||
|
f"YPR:{yaw:.2f},{pitch:.2f},{roll:.2f}|"
|
||||||
|
f"Gyro:{gz:.2f},{gy:.2f},{gx:.2f}|"
|
||||||
|
f"RC:{rc_r},{rc_p},{rc_t},{rc_y}|"
|
||||||
|
f"M:{m1},{m2},{m3},{m4}|"
|
||||||
|
f"L:0 F:1 C:0\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(description="Drone telemetry test data generator")
|
||||||
|
parser.add_argument("--host", default="127.0.0.1", help="TCP host")
|
||||||
|
parser.add_argument("--port", type=int, default=8092, help="TCP port")
|
||||||
|
parser.add_argument("--rate", type=float, default=10, help="Lines per second")
|
||||||
|
parser.add_argument("--duration", type=float, default=0, help="Seconds to run (0 = forever)")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
interval = 1.0 / args.rate
|
||||||
|
print(f"Sending drone telemetry to {args.host}:{args.port} at {args.rate} Hz")
|
||||||
|
|
||||||
|
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}")
|
||||||
|
|
||||||
|
t0 = time.time()
|
||||||
|
seq = 0
|
||||||
|
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
t = time.time() - t0
|
||||||
|
line = generate_line(t)
|
||||||
|
try:
|
||||||
|
sock.sendall(line.encode())
|
||||||
|
except (BrokenPipeError, ConnectionResetError):
|
||||||
|
break
|
||||||
|
seq += 1
|
||||||
|
|
||||||
|
if args.duration > 0 and t >= args.duration:
|
||||||
|
break
|
||||||
|
|
||||||
|
elapsed = time.time() - t0
|
||||||
|
target = (seq + 1) * interval
|
||||||
|
sleep_time = target - elapsed
|
||||||
|
if sleep_time > 0:
|
||||||
|
time.sleep(sleep_time)
|
||||||
|
except (BrokenPipeError, ConnectionResetError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
print(f"Client disconnected. Sent {seq} lines. Waiting for new connection...")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""TCP server that sends plot waveform frames for the current xserial GUI."""
|
"""TCP server that sends plot waveform frames or text lines for pipeview GUI."""
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
import math
|
import math
|
||||||
@@ -116,9 +116,9 @@ def is_disconnect_error(err: OSError) -> bool:
|
|||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
def main() -> None:
|
||||||
parser = argparse.ArgumentParser(description="xserial plot test server")
|
parser = argparse.ArgumentParser(description="pipeview plot test server")
|
||||||
parser.add_argument("--host", default="127.0.0.1")
|
parser.add_argument("--host", default="127.0.0.1")
|
||||||
parser.add_argument("--port", type=int, default=8091)
|
parser.add_argument("--port", type=int, default=8080)
|
||||||
parser.add_argument("--channels", type=int, default=1)
|
parser.add_argument("--channels", type=int, default=1)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--format",
|
"--format",
|
||||||
@@ -148,9 +148,9 @@ def main() -> None:
|
|||||||
)
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--wire-format",
|
"--wire-format",
|
||||||
choices=("mixed", "raw"),
|
choices=("mixed", "raw", "text"),
|
||||||
default="mixed",
|
default="mixed",
|
||||||
help="mixed sends text+plot on one connection; raw sends plot only",
|
help="mixed sends text+plot; raw sends plot only; text sends text only",
|
||||||
)
|
)
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
@@ -193,17 +193,36 @@ def main() -> None:
|
|||||||
values.append(args.amp * math.sin(2 * math.pi * args.freq * t + phase))
|
values.append(args.amp * math.sin(2 * math.pi * args.freq * t + phase))
|
||||||
return values
|
return values
|
||||||
|
|
||||||
|
def build_text_line(started_at: float, now: float, sample_index: int) -> str:
|
||||||
|
values = sample_values(sample_index)
|
||||||
|
joined = ", ".join(
|
||||||
|
f"ch{index + 1}={value:8.3f}" for index, value in enumerate(values)
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
f"测试 time={now - started_at:7.3f}s "
|
||||||
|
f"sample={sample_index:7d} {joined}\n"
|
||||||
|
)
|
||||||
|
|
||||||
print(f"Listening {args.host}:{args.port} - Ctrl+C to stop")
|
print(f"Listening {args.host}:{args.port} - Ctrl+C to stop")
|
||||||
print("GUI:")
|
print("GUI:")
|
||||||
print(f" transport = TCP {args.host}:{args.port}")
|
print(f" transport = TCP {args.host}:{args.port}")
|
||||||
if args.wire_format == "mixed":
|
if args.wire_format == "mixed":
|
||||||
print(" framer = MixedTextPlot")
|
print(" framer = MixedTextPlot")
|
||||||
print(" decoder = MixedTextPlot\n")
|
print(" decoder = MixedTextPlot\n")
|
||||||
else:
|
elif args.wire_format == "raw":
|
||||||
print(f" framer = Fixed({frame_bytes})")
|
print(f" framer = Fixed({frame_bytes})")
|
||||||
print(
|
print(
|
||||||
f" decoder = Plot(f32, little-endian, {args.channels} channel, {args.format})\n"
|
f" decoder = Plot(f32, little-endian, {args.channels} channel, {args.format})\n"
|
||||||
)
|
)
|
||||||
|
else:
|
||||||
|
print(" framer = Line")
|
||||||
|
print(" decoder = Text")
|
||||||
|
print(" lua test = Lua framer tests/lua_line_framer.lua")
|
||||||
|
print(" Lua decoder tests/lua_text_decoder.lua\n")
|
||||||
|
if args.wire_format == "text":
|
||||||
|
print(f"sending text only at {args.text_interval:.3f}s intervals")
|
||||||
|
print(f"sample clock: {args.rate:.1f} samples/sec\n")
|
||||||
|
else:
|
||||||
print(f"sending {samples_per_channel} samples/frame at {args.rate:.1f} samples/sec")
|
print(f"sending {samples_per_channel} samples/frame at {args.rate:.1f} samples/sec")
|
||||||
print(f"effective frame rate: {frame_rate:.2f} fps\n")
|
print(f"effective frame rate: {frame_rate:.2f} fps\n")
|
||||||
|
|
||||||
@@ -230,6 +249,25 @@ def main() -> None:
|
|||||||
next_text_at = started_at
|
next_text_at = started_at
|
||||||
try:
|
try:
|
||||||
while not stop_event.is_set():
|
while not stop_event.is_set():
|
||||||
|
if args.wire_format == "text":
|
||||||
|
now = time.perf_counter()
|
||||||
|
wait = next_text_at - now
|
||||||
|
if wait > 0:
|
||||||
|
time.sleep(min(wait, 0.1))
|
||||||
|
continue
|
||||||
|
|
||||||
|
sample_index = int((now - started_at) * args.rate)
|
||||||
|
conn.sendall(
|
||||||
|
build_text_line(
|
||||||
|
started_at,
|
||||||
|
now,
|
||||||
|
sample_index,
|
||||||
|
).encode("utf-8")
|
||||||
|
)
|
||||||
|
text_count += 1
|
||||||
|
next_text_at += args.text_interval
|
||||||
|
continue
|
||||||
|
|
||||||
plot_payload = build_frame(
|
plot_payload = build_frame(
|
||||||
sample_index=sample_index,
|
sample_index=sample_index,
|
||||||
channels=args.channels,
|
channels=args.channels,
|
||||||
@@ -252,16 +290,13 @@ def main() -> None:
|
|||||||
|
|
||||||
now = time.perf_counter()
|
now = time.perf_counter()
|
||||||
if args.wire_format == "mixed" and now >= next_text_at:
|
if args.wire_format == "mixed" and now >= next_text_at:
|
||||||
values = sample_values(sample_index)
|
conn.sendall(
|
||||||
joined = ", ".join(
|
build_text_line(
|
||||||
f"ch{index + 1}={value:8.3f}"
|
started_at,
|
||||||
for index, value in enumerate(values)
|
now,
|
||||||
|
sample_index,
|
||||||
|
).encode("utf-8")
|
||||||
)
|
)
|
||||||
line = (
|
|
||||||
f"time={now - started_at:7.3f}s "
|
|
||||||
f"sample={sample_index:7d} {joined}\n"
|
|
||||||
)
|
|
||||||
conn.sendall(line.encode("utf-8"))
|
|
||||||
text_count += 1
|
text_count += 1
|
||||||
next_text_at += args.text_interval
|
next_text_at += args.text_interval
|
||||||
|
|
||||||
@@ -275,6 +310,9 @@ def main() -> None:
|
|||||||
except OSError as err:
|
except OSError as err:
|
||||||
if not is_disconnect_error(err):
|
if not is_disconnect_error(err):
|
||||||
raise
|
raise
|
||||||
|
if args.wire_format == "text":
|
||||||
|
print(f"[conn -] {addr} ({text_count} text lines)")
|
||||||
|
else:
|
||||||
print(
|
print(
|
||||||
f"[conn -] {addr} "
|
f"[conn -] {addr} "
|
||||||
f"({frame_count} plot frames, {text_count} text lines)"
|
f"({frame_count} plot frames, {text_count} text lines)"
|
||||||
|
|||||||
540
tools/test_plot_serial.c
Normal file
540
tools/test_plot_serial.c
Normal file
@@ -0,0 +1,540 @@
|
|||||||
|
/*
|
||||||
|
* test_plot_serial.c — 持续向串口发送 MixedTextPlot 正弦波 + 文本流
|
||||||
|
*
|
||||||
|
* 编译 (Linux):
|
||||||
|
* gcc -std=c11 -Wall -Wextra -O2 -o test_plot_serial tools/test_plot_serial.c -lm
|
||||||
|
*
|
||||||
|
* 用法:
|
||||||
|
* ./test_plot_serial /dev/ttyUSB0
|
||||||
|
* ./test_plot_serial /dev/ttyUSB0 --baud 115200 --channels 2 --format xy --rate 1024 --freq 2.0 --amp 150
|
||||||
|
* ./test_plot_serial --help
|
||||||
|
*
|
||||||
|
* 依赖: tools/xs_mixed_plot.h (同目录, 定义 COBS 和 MixedTextPlot 帧格式)
|
||||||
|
*
|
||||||
|
* 协议说明:
|
||||||
|
* Plot 帧: 0x1E 'P' + COBS(packet) + 0x00
|
||||||
|
* 文本帧: UTF-8 文本行 + '\n' (直接写入, 由 MixedTextPlot framer 按行分割)
|
||||||
|
*
|
||||||
|
* 串口参数: 8N1, 无流控, 默认 115200 baud
|
||||||
|
*/
|
||||||
|
|
||||||
|
#define _POSIX_C_SOURCE 200809L
|
||||||
|
#include "xs_mixed_plot.h"
|
||||||
|
|
||||||
|
#include <errno.h>
|
||||||
|
#include <fcntl.h>
|
||||||
|
#include <getopt.h>
|
||||||
|
#include <math.h>
|
||||||
|
#include <signal.h>
|
||||||
|
#include <stdbool.h>
|
||||||
|
#include <stdio.h>
|
||||||
|
#include <stdlib.h>
|
||||||
|
#include <string.h>
|
||||||
|
#include <termios.h>
|
||||||
|
#include <time.h>
|
||||||
|
#include <unistd.h>
|
||||||
|
|
||||||
|
#ifndef M_PI
|
||||||
|
#define M_PI 3.14159265358979323846
|
||||||
|
#endif
|
||||||
|
|
||||||
|
/* ── 配置默认值 ───────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
#define DEFAULT_BAUDRATE 115200
|
||||||
|
#define DEFAULT_RATE 1024.0 /* samples/sec per channel */
|
||||||
|
#define DEFAULT_FREQ 1.0 /* sine Hz */
|
||||||
|
#define DEFAULT_AMP 100.0
|
||||||
|
#define DEFAULT_TEXT_INTERVAL 1.0 /* seconds between text lines */
|
||||||
|
|
||||||
|
/* ── 全局状态 (用于信号处理) ──────────────────────────────────────── */
|
||||||
|
|
||||||
|
static volatile sig_atomic_t g_running = 1;
|
||||||
|
|
||||||
|
static void sigint_handler(int sig) {
|
||||||
|
(void)sig;
|
||||||
|
g_running = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── 缓冲大小 ─────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
#define MAX_CHANNELS 8
|
||||||
|
#define MAX_SPC 4096 /* samples_per_channel */
|
||||||
|
#define MAX_VALUES (MAX_CHANNELS * MAX_SPC) /* float values per frame */
|
||||||
|
#define MAX_PAYLOAD (MAX_VALUES * 4)
|
||||||
|
#define MAX_PACKET (13 + MAX_PAYLOAD)
|
||||||
|
#define MAX_FRAME (2 + (MAX_PACKET + MAX_PACKET / 254 + 1) + 1)
|
||||||
|
#define TEXT_BUF_SIZE 512
|
||||||
|
|
||||||
|
/* ── 运行时参数 ───────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
typedef struct {
|
||||||
|
const char *port;
|
||||||
|
int baudrate;
|
||||||
|
uint8_t channels;
|
||||||
|
uint16_t samples_per_channel;
|
||||||
|
uint8_t plot_format; /* XS_PLOT_FORMAT_INTERLEAVED / XY */
|
||||||
|
double rate; /* samples/sec */
|
||||||
|
double freq; /* sine Hz */
|
||||||
|
double amp;
|
||||||
|
double text_interval; /* seconds */
|
||||||
|
} args_t;
|
||||||
|
|
||||||
|
/* ── 串口操作 ─────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
static int serial_open(const char *path, int baudrate) {
|
||||||
|
int fd = open(path, O_RDWR | O_NOCTTY | O_NONBLOCK);
|
||||||
|
if (fd < 0) {
|
||||||
|
perror("open");
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 恢复阻塞模式, 但配合超时使用 select */
|
||||||
|
int flags = fcntl(fd, F_GETFL, 0);
|
||||||
|
if (flags < 0) { perror("fcntl"); close(fd); return -1; }
|
||||||
|
flags &= ~O_NONBLOCK;
|
||||||
|
if (fcntl(fd, F_SETFL, flags) < 0) { perror("fcntl"); close(fd); return -1; }
|
||||||
|
|
||||||
|
struct termios tty;
|
||||||
|
memset(&tty, 0, sizeof(tty));
|
||||||
|
if (tcgetattr(fd, &tty) < 0) { perror("tcgetattr"); close(fd); return -1; }
|
||||||
|
|
||||||
|
/* 输入: 忽略 BREAK, CR→NL 不转换, 奇偶校验不检查 */
|
||||||
|
tty.c_iflag = IGNBRK;
|
||||||
|
|
||||||
|
/* 输出: 原始模式, 不做任何转换 */
|
||||||
|
tty.c_oflag = 0;
|
||||||
|
|
||||||
|
/* 控制: 8 位, 无奇偶校验, 启用接收器 */
|
||||||
|
tty.c_cflag = CS8 | CREAD | CLOCAL;
|
||||||
|
|
||||||
|
/* 本地: 关闭标准行处理/回显/信号字符 */
|
||||||
|
tty.c_lflag = 0;
|
||||||
|
|
||||||
|
/* 特殊字符: 无超时 (VMIN=1, VTIME=0 → 阻塞读直到至少1字节) */
|
||||||
|
tty.c_cc[VMIN] = 1;
|
||||||
|
tty.c_cc[VTIME] = 0;
|
||||||
|
|
||||||
|
/* 设置波特率 */
|
||||||
|
speed_t speed = B115200;
|
||||||
|
switch (baudrate) {
|
||||||
|
case 9600: speed = B9600; break;
|
||||||
|
case 19200: speed = B19200; break;
|
||||||
|
case 38400: speed = B38400; break;
|
||||||
|
case 57600: speed = B57600; break;
|
||||||
|
case 115200: speed = B115200; break;
|
||||||
|
case 230400: speed = B230400; break;
|
||||||
|
case 460800: speed = B460800; break;
|
||||||
|
case 921600: speed = B921600; break;
|
||||||
|
default:
|
||||||
|
fprintf(stderr, "不支持的波特率: %d (支持: 9600-921600)\n", baudrate);
|
||||||
|
close(fd);
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
cfsetispeed(&tty, speed);
|
||||||
|
cfsetospeed(&tty, speed);
|
||||||
|
|
||||||
|
#ifdef CRTSCTS
|
||||||
|
tty.c_cflag &= ~CRTSCTS;
|
||||||
|
#endif
|
||||||
|
/* 关闭软件流控 */
|
||||||
|
tty.c_iflag &= ~(IXON | IXOFF | IXANY);
|
||||||
|
|
||||||
|
if (tcsetattr(fd, TCSANOW, &tty) < 0) {
|
||||||
|
perror("tcsetattr");
|
||||||
|
close(fd);
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 排空缓冲区 */
|
||||||
|
tcflush(fd, TCIOFLUSH);
|
||||||
|
|
||||||
|
return fd;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── 时间工具 ─────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
static double now_sec(void) {
|
||||||
|
struct timespec ts;
|
||||||
|
clock_gettime(CLOCK_MONOTONIC, &ts);
|
||||||
|
return (double)ts.tv_sec + (double)ts.tv_nsec * 1e-9;
|
||||||
|
}
|
||||||
|
|
||||||
|
static void sleep_sec(double seconds) {
|
||||||
|
if (seconds <= 0.0) return;
|
||||||
|
struct timespec ts;
|
||||||
|
ts.tv_sec = (time_t)seconds;
|
||||||
|
ts.tv_nsec = (long)((seconds - (time_t)seconds) * 1e9);
|
||||||
|
clock_nanosleep(CLOCK_MONOTONIC, 0, &ts, NULL);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── 信号生成 ─────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
static void generate_samples(
|
||||||
|
float *samples,
|
||||||
|
uint32_t sample_index,
|
||||||
|
uint8_t channels,
|
||||||
|
uint16_t samples_per_channel,
|
||||||
|
uint8_t plot_format,
|
||||||
|
double rate,
|
||||||
|
double freq,
|
||||||
|
double amp)
|
||||||
|
{
|
||||||
|
if (plot_format == XS_PLOT_FORMAT_XY) {
|
||||||
|
/* XY 模式: 每对 (cos, sin) 画一个圆 */
|
||||||
|
for (uint16_t i = 0; i < samples_per_channel; ++i) {
|
||||||
|
double t = (double)(sample_index + i) / rate;
|
||||||
|
samples[2 * i] = (float)(amp * cos(2.0 * M_PI * freq * t));
|
||||||
|
samples[2 * i + 1] = (float)(amp * sin(2.0 * M_PI * freq * t));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
/* interleaved 模式: 每个通道相位偏移 */
|
||||||
|
for (uint16_t i = 0; i < samples_per_channel; ++i) {
|
||||||
|
double t = (double)(sample_index + i) / rate;
|
||||||
|
for (uint8_t ch = 0; ch < channels; ++ch) {
|
||||||
|
double phase = 2.0 * M_PI * (double)ch / (double)channels;
|
||||||
|
samples[i * channels + ch] = (float)(amp * sin(2.0 * M_PI * freq * t + phase));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── 用法 ─────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
static void print_usage(const char *prog) {
|
||||||
|
printf("用法: %s <串口> [选项]\n", prog);
|
||||||
|
printf("\n选项:\n");
|
||||||
|
printf(" --baud <n> 波特率 (默认 %d)\n", DEFAULT_BAUDRATE);
|
||||||
|
printf(" --channels <n> 通道数 (默认 1, --format xy 时强制 2)\n");
|
||||||
|
printf(" --format <fmt> 格式: interleaved | xy (默认 interleaved)\n");
|
||||||
|
printf(" --rate <f> 采样率, samples/sec (默认 %.0f)\n", DEFAULT_RATE);
|
||||||
|
printf(" --freq <f> 正弦频率 Hz (默认 %.1f)\n", DEFAULT_FREQ);
|
||||||
|
printf(" --amp <f> 振幅 (默认 %.0f)\n", DEFAULT_AMP);
|
||||||
|
printf(" --text-interval <f> 文本输出间隔秒 (默认 %.1f)\n", DEFAULT_TEXT_INTERVAL);
|
||||||
|
printf(" --spc <n> 每帧每通道采样数 (默认 1)\n");
|
||||||
|
printf(" -h, --help 显示帮助\n");
|
||||||
|
printf("\n支持的波特率: 9600, 19200, 38400, 57600, 115200, 230400, 460800, 921600\n");
|
||||||
|
printf("\n示例:\n");
|
||||||
|
printf(" %s /dev/ttyUSB0\n", prog);
|
||||||
|
printf(" %s /dev/ttyUSB0 --baud 921600 --channels 2 --rate 2048 --freq 5\n", prog);
|
||||||
|
printf(" %s /dev/ttyUSB0 --format xy --amp 200 --text-interval 2.0\n", prog);
|
||||||
|
printf(" %s /dev/ttyUSB0 --channels 4 --spc 128 --rate 1024\n", prog);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── 参数解析 ─────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
static int parse_args(int argc, char **argv, args_t *args) {
|
||||||
|
memset(args, 0, sizeof(*args));
|
||||||
|
args->baudrate = DEFAULT_BAUDRATE;
|
||||||
|
args->channels = 1;
|
||||||
|
args->samples_per_channel = 1;
|
||||||
|
args->plot_format = XS_PLOT_FORMAT_INTERLEAVED;
|
||||||
|
args->rate = DEFAULT_RATE;
|
||||||
|
args->freq = DEFAULT_FREQ;
|
||||||
|
args->amp = DEFAULT_AMP;
|
||||||
|
args->text_interval = DEFAULT_TEXT_INTERVAL;
|
||||||
|
|
||||||
|
enum { OPT_BAUD = 256, OPT_CHANNELS, OPT_FORMAT, OPT_RATE, OPT_FREQ,
|
||||||
|
OPT_AMP, OPT_TEXT_INTERVAL, OPT_SPC };
|
||||||
|
|
||||||
|
static struct option long_opts[] = {
|
||||||
|
{"baud", required_argument, 0, OPT_BAUD},
|
||||||
|
{"channels", required_argument, 0, OPT_CHANNELS},
|
||||||
|
{"format", required_argument, 0, OPT_FORMAT},
|
||||||
|
{"rate", required_argument, 0, OPT_RATE},
|
||||||
|
{"freq", required_argument, 0, OPT_FREQ},
|
||||||
|
{"amp", required_argument, 0, OPT_AMP},
|
||||||
|
{"text-interval", required_argument, 0, OPT_TEXT_INTERVAL},
|
||||||
|
{"spc", required_argument, 0, OPT_SPC},
|
||||||
|
{"help", no_argument, 0, 'h'},
|
||||||
|
{0, 0, 0, 0}
|
||||||
|
};
|
||||||
|
|
||||||
|
int opt;
|
||||||
|
while ((opt = getopt_long(argc, argv, "h", long_opts, NULL)) != -1) {
|
||||||
|
switch (opt) {
|
||||||
|
case OPT_BAUD:
|
||||||
|
args->baudrate = atoi(optarg);
|
||||||
|
break;
|
||||||
|
case OPT_CHANNELS: {
|
||||||
|
int v = atoi(optarg);
|
||||||
|
if (v < 1 || v > MAX_CHANNELS) {
|
||||||
|
fprintf(stderr, "通道数必须在 1-%d 之间\n", MAX_CHANNELS);
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
args->channels = (uint8_t)v;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case OPT_FORMAT:
|
||||||
|
if (strcmp(optarg, "xy") == 0) {
|
||||||
|
args->plot_format = XS_PLOT_FORMAT_XY;
|
||||||
|
} else if (strcmp(optarg, "interleaved") == 0) {
|
||||||
|
args->plot_format = XS_PLOT_FORMAT_INTERLEAVED;
|
||||||
|
} else {
|
||||||
|
fprintf(stderr, "未知格式: %s (支持: interleaved, xy)\n", optarg);
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case OPT_RATE:
|
||||||
|
args->rate = atof(optarg);
|
||||||
|
break;
|
||||||
|
case OPT_FREQ:
|
||||||
|
args->freq = atof(optarg);
|
||||||
|
break;
|
||||||
|
case OPT_AMP:
|
||||||
|
args->amp = atof(optarg);
|
||||||
|
break;
|
||||||
|
case OPT_TEXT_INTERVAL:
|
||||||
|
args->text_interval = atof(optarg);
|
||||||
|
break;
|
||||||
|
case OPT_SPC: {
|
||||||
|
int v = atoi(optarg);
|
||||||
|
if (v < 1 || v > MAX_SPC) {
|
||||||
|
fprintf(stderr, "spc 必须在 1-%d 之间\n", MAX_SPC);
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
args->samples_per_channel = (uint16_t)v;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'h':
|
||||||
|
print_usage(argv[0]);
|
||||||
|
exit(0);
|
||||||
|
default:
|
||||||
|
print_usage(argv[0]);
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (optind >= argc) {
|
||||||
|
fprintf(stderr, "错误: 缺少串口路径\n");
|
||||||
|
print_usage(argv[0]);
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
args->port = argv[optind];
|
||||||
|
|
||||||
|
/* 校验 */
|
||||||
|
if (args->plot_format == XS_PLOT_FORMAT_XY) {
|
||||||
|
if (args->channels != 2) {
|
||||||
|
fprintf(stderr, "错误: --format xy 要求 --channels 2\n");
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (args->channels == 0) {
|
||||||
|
fprintf(stderr, "错误: --channels 必须 >= 1\n");
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
if (args->rate <= 0.0) {
|
||||||
|
fprintf(stderr, "错误: --rate 必须为正数\n");
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
if (args->text_interval <= 0.0) {
|
||||||
|
fprintf(stderr, "错误: --text-interval 必须为正数\n");
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── 发送 plot 帧 ─────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
static int send_plot_frame(
|
||||||
|
int fd,
|
||||||
|
const float *samples,
|
||||||
|
uint8_t channels,
|
||||||
|
uint16_t samples_per_channel,
|
||||||
|
uint8_t plot_format,
|
||||||
|
uint8_t *packet_buf,
|
||||||
|
size_t packet_cap,
|
||||||
|
uint8_t *frame_buf,
|
||||||
|
size_t frame_cap)
|
||||||
|
{
|
||||||
|
size_t frame_len = 0;
|
||||||
|
xs_status_t rc = xs_mixed_build_plot_frame_f32(
|
||||||
|
samples, channels, samples_per_channel, plot_format,
|
||||||
|
packet_buf, packet_cap,
|
||||||
|
frame_buf, frame_cap,
|
||||||
|
&frame_len);
|
||||||
|
if (rc != XS_OK) {
|
||||||
|
fprintf(stderr, "构建 plot 帧失败: %d\n", rc);
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
size_t written = 0;
|
||||||
|
while (written < frame_len) {
|
||||||
|
ssize_t n = write(fd, frame_buf + written, frame_len - written);
|
||||||
|
if (n < 0) {
|
||||||
|
perror("write");
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
written += (size_t)n;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── 发送文本行 ───────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
static int send_text_line(
|
||||||
|
int fd,
|
||||||
|
uint32_t sample_index,
|
||||||
|
const float *current_samples,
|
||||||
|
uint8_t channels,
|
||||||
|
uint16_t samples_per_channel,
|
||||||
|
const char *format_name)
|
||||||
|
{
|
||||||
|
char buf[TEXT_BUF_SIZE];
|
||||||
|
int pos = 0;
|
||||||
|
|
||||||
|
pos += snprintf(buf + pos, sizeof(buf) - pos,
|
||||||
|
"sample=%-7u", sample_index);
|
||||||
|
|
||||||
|
if (channels > 1) {
|
||||||
|
for (uint8_t ch = 0; ch < channels; ++ch) {
|
||||||
|
pos += snprintf(buf + pos, sizeof(buf) - pos,
|
||||||
|
" ch%d=%8.3f", ch + 1, (double)current_samples[ch]);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
pos += snprintf(buf + pos, sizeof(buf) - pos,
|
||||||
|
" value=%8.3f", (double)current_samples[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
pos += snprintf(buf + pos, sizeof(buf) - pos,
|
||||||
|
" [fmt=%s, ch=%u, spc=%u]\n",
|
||||||
|
format_name, channels, samples_per_channel);
|
||||||
|
|
||||||
|
/* 确保不超过缓冲 */
|
||||||
|
if (pos >= (int)sizeof(buf)) pos = (int)sizeof(buf) - 1;
|
||||||
|
|
||||||
|
size_t len = (size_t)pos;
|
||||||
|
size_t written = 0;
|
||||||
|
while (written < len) {
|
||||||
|
ssize_t n = write(fd, buf + written, len - written);
|
||||||
|
if (n < 0) {
|
||||||
|
perror("write");
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
written += (size_t)n;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── 主函数 ───────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
int main(int argc, char **argv) {
|
||||||
|
args_t args;
|
||||||
|
if (parse_args(argc, argv, &args) < 0) {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 打印配置 */
|
||||||
|
const char *format_name = (args.plot_format == XS_PLOT_FORMAT_XY) ? "xy" : "interleaved";
|
||||||
|
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("串口: %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);
|
||||||
|
printf("采样率: %.0f samples/sec spc: %u\n", args.rate, args.samples_per_channel);
|
||||||
|
printf("帧间隔: %.3f ms (%.2f fps)\n", frame_interval * 1000.0, frame_rate);
|
||||||
|
printf("文本间隔: %.1f s\n", args.text_interval);
|
||||||
|
printf("═══════════════════════════════════════\n");
|
||||||
|
|
||||||
|
/* 分配缓冲 */
|
||||||
|
uint32_t value_count = (uint32_t)args.channels * (uint32_t)args.samples_per_channel;
|
||||||
|
uint32_t payload_len = value_count * 4;
|
||||||
|
size_t packet_cap = (size_t)XS_PLOT_HEADER_SIZE + (size_t)payload_len;
|
||||||
|
size_t frame_cap = 2u + xs_cobs_max_encoded_size(packet_cap) + 1u;
|
||||||
|
|
||||||
|
uint8_t *packet_buf = malloc(packet_cap);
|
||||||
|
uint8_t *frame_buf = malloc(frame_cap);
|
||||||
|
float *samples = malloc((size_t)value_count * sizeof(float));
|
||||||
|
|
||||||
|
if (!packet_buf || !frame_buf || !samples) {
|
||||||
|
fprintf(stderr, "内存分配失败\n");
|
||||||
|
free(packet_buf); free(frame_buf); free(samples);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 打开串口 */
|
||||||
|
int fd = serial_open(args.port, args.baudrate);
|
||||||
|
if (fd < 0) {
|
||||||
|
fprintf(stderr, "无法打开串口 %s\n", args.port);
|
||||||
|
free(packet_buf); free(frame_buf); free(samples);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
printf("已打开 %s\n\n", args.port);
|
||||||
|
|
||||||
|
/* 设置信号处理 */
|
||||||
|
struct sigaction sa;
|
||||||
|
memset(&sa, 0, sizeof(sa));
|
||||||
|
sa.sa_handler = sigint_handler;
|
||||||
|
sigaction(SIGINT, &sa, NULL);
|
||||||
|
sigaction(SIGTERM, &sa, NULL);
|
||||||
|
|
||||||
|
/* 主循环 */
|
||||||
|
uint32_t sample_index = 0;
|
||||||
|
uint64_t frame_count = 0;
|
||||||
|
uint64_t text_count = 0;
|
||||||
|
double started_at = now_sec();
|
||||||
|
double next_text_at = started_at + args.text_interval;
|
||||||
|
|
||||||
|
printf("开始发送 (Ctrl+C 停止) ...\n");
|
||||||
|
|
||||||
|
while (g_running) {
|
||||||
|
/* 生成采样数据 */
|
||||||
|
generate_samples(samples, sample_index,
|
||||||
|
args.channels, args.samples_per_channel,
|
||||||
|
args.plot_format, args.rate, args.freq, args.amp);
|
||||||
|
|
||||||
|
/* 发送 plot 帧 */
|
||||||
|
if (send_plot_frame(fd, samples,
|
||||||
|
args.channels, args.samples_per_channel,
|
||||||
|
args.plot_format,
|
||||||
|
packet_buf, packet_cap,
|
||||||
|
frame_buf, frame_cap) < 0) {
|
||||||
|
fprintf(stderr, "发送失败, 退出\n");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
frame_count++;
|
||||||
|
|
||||||
|
/* 检查是否需要发送文本行 */
|
||||||
|
double now = now_sec();
|
||||||
|
if (now >= next_text_at) {
|
||||||
|
if (send_text_line(fd, sample_index, samples,
|
||||||
|
args.channels, args.samples_per_channel,
|
||||||
|
format_name) < 0) {
|
||||||
|
fprintf(stderr, "发送文本失败, 退出\n");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
text_count++;
|
||||||
|
next_text_at += args.text_interval;
|
||||||
|
}
|
||||||
|
|
||||||
|
sample_index += args.samples_per_channel;
|
||||||
|
|
||||||
|
/* 帧间隔时序控制 */
|
||||||
|
double elapsed = now_sec() - started_at;
|
||||||
|
double target = (double)frame_count * frame_interval;
|
||||||
|
double wait = target - elapsed;
|
||||||
|
if (wait > 0.0) {
|
||||||
|
sleep_sec(wait);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
double total_time = now_sec() - started_at;
|
||||||
|
printf("\n");
|
||||||
|
printf("═══ 已停止 ═══\n");
|
||||||
|
printf("运行时间: %.1f 秒\n", total_time);
|
||||||
|
printf("Plot 帧: %lu (%.1f fps)\n",
|
||||||
|
(unsigned long)frame_count,
|
||||||
|
total_time > 0.0 ? (double)frame_count / total_time : 0.0);
|
||||||
|
printf("文本行: %lu\n", (unsigned long)text_count);
|
||||||
|
|
||||||
|
/* 清理 */
|
||||||
|
tcflush(fd, TCIOFLUSH);
|
||||||
|
close(fd);
|
||||||
|
free(packet_buf);
|
||||||
|
free(frame_buf);
|
||||||
|
free(samples);
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
270
tools/test_text_serial.c
Normal file
270
tools/test_text_serial.c
Normal file
@@ -0,0 +1,270 @@
|
|||||||
|
/*
|
||||||
|
* test_text_serial.c — 持续向串口发送纯文本正弦波数据流
|
||||||
|
*
|
||||||
|
* 编译 (Linux):
|
||||||
|
* gcc -std=c11 -Wall -Wextra -O2 -o test_text_serial tools/test_text_serial.c -lm
|
||||||
|
*
|
||||||
|
* 用法:
|
||||||
|
* ./test_text_serial /dev/ttyUSB0
|
||||||
|
* ./test_text_serial /dev/ttyUSB0 --baud 115200 --channels 2 --rate 100 --freq 2.0 --amp 150
|
||||||
|
* ./test_text_serial --help
|
||||||
|
*
|
||||||
|
* 输出格式 (每行):
|
||||||
|
* sample=123 ch1=100.000 ch2=0.000
|
||||||
|
*
|
||||||
|
* 串口参数: 8N1, 无流控, 默认 115200 baud
|
||||||
|
*/
|
||||||
|
|
||||||
|
#define _POSIX_C_SOURCE 200809L
|
||||||
|
|
||||||
|
#include <errno.h>
|
||||||
|
#include <fcntl.h>
|
||||||
|
#include <getopt.h>
|
||||||
|
#include <math.h>
|
||||||
|
#include <signal.h>
|
||||||
|
#include <stdbool.h>
|
||||||
|
#include <stddef.h>
|
||||||
|
#include <stdint.h>
|
||||||
|
#include <stdio.h>
|
||||||
|
#include <stdlib.h>
|
||||||
|
#include <string.h>
|
||||||
|
#include <termios.h>
|
||||||
|
#include <time.h>
|
||||||
|
#include <unistd.h>
|
||||||
|
|
||||||
|
#ifndef M_PI
|
||||||
|
#define M_PI 3.14159265358979323846
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#define DEFAULT_BAUDRATE 115200
|
||||||
|
#define DEFAULT_RATE 100.0 /* lines/sec */
|
||||||
|
#define DEFAULT_FREQ 1.0
|
||||||
|
#define DEFAULT_AMP 100.0
|
||||||
|
#define MAX_CHANNELS 8
|
||||||
|
#define LINE_BUF_SIZE 512
|
||||||
|
|
||||||
|
static volatile sig_atomic_t g_running = 1;
|
||||||
|
|
||||||
|
static void sigint_handler(int sig) {
|
||||||
|
(void)sig;
|
||||||
|
g_running = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
typedef struct {
|
||||||
|
const char *port;
|
||||||
|
int baudrate;
|
||||||
|
uint8_t channels;
|
||||||
|
double rate;
|
||||||
|
double freq;
|
||||||
|
double amp;
|
||||||
|
} args_t;
|
||||||
|
|
||||||
|
static int serial_open(const char *path, int baudrate) {
|
||||||
|
int fd = open(path, O_RDWR | O_NOCTTY | O_NONBLOCK);
|
||||||
|
if (fd < 0) { perror("open"); return -1; }
|
||||||
|
|
||||||
|
int flags = fcntl(fd, F_GETFL, 0);
|
||||||
|
if (flags < 0) { perror("fcntl"); close(fd); return -1; }
|
||||||
|
flags &= ~O_NONBLOCK;
|
||||||
|
if (fcntl(fd, F_SETFL, flags) < 0) { perror("fcntl"); close(fd); return -1; }
|
||||||
|
|
||||||
|
struct termios tty;
|
||||||
|
memset(&tty, 0, sizeof(tty));
|
||||||
|
if (tcgetattr(fd, &tty) < 0) { perror("tcgetattr"); close(fd); return -1; }
|
||||||
|
|
||||||
|
tty.c_iflag = IGNBRK;
|
||||||
|
tty.c_oflag = 0;
|
||||||
|
tty.c_cflag = CS8 | CREAD | CLOCAL;
|
||||||
|
tty.c_lflag = 0;
|
||||||
|
tty.c_cc[VMIN] = 1;
|
||||||
|
tty.c_cc[VTIME] = 0;
|
||||||
|
|
||||||
|
speed_t speed = B115200;
|
||||||
|
switch (baudrate) {
|
||||||
|
case 9600: speed = B9600; break;
|
||||||
|
case 19200: speed = B19200; break;
|
||||||
|
case 38400: speed = B38400; break;
|
||||||
|
case 57600: speed = B57600; break;
|
||||||
|
case 115200: speed = B115200; break;
|
||||||
|
case 230400: speed = B230400; break;
|
||||||
|
case 460800: speed = B460800; break;
|
||||||
|
case 921600: speed = B921600; break;
|
||||||
|
default:
|
||||||
|
fprintf(stderr, "不支持的波特率: %d\n", baudrate);
|
||||||
|
close(fd);
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
cfsetispeed(&tty, speed);
|
||||||
|
cfsetospeed(&tty, speed);
|
||||||
|
|
||||||
|
#ifdef CRTSCTS
|
||||||
|
tty.c_cflag &= ~CRTSCTS;
|
||||||
|
#endif
|
||||||
|
tty.c_iflag &= ~(IXON | IXOFF | IXANY);
|
||||||
|
|
||||||
|
if (tcsetattr(fd, TCSANOW, &tty) < 0) {
|
||||||
|
perror("tcsetattr");
|
||||||
|
close(fd);
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
tcflush(fd, TCIOFLUSH);
|
||||||
|
return fd;
|
||||||
|
}
|
||||||
|
|
||||||
|
static double now_sec(void) {
|
||||||
|
struct timespec ts;
|
||||||
|
clock_gettime(CLOCK_MONOTONIC, &ts);
|
||||||
|
return (double)ts.tv_sec + (double)ts.tv_nsec * 1e-9;
|
||||||
|
}
|
||||||
|
|
||||||
|
static void sleep_sec(double seconds) {
|
||||||
|
if (seconds <= 0.0) return;
|
||||||
|
struct timespec ts;
|
||||||
|
ts.tv_sec = (time_t)seconds;
|
||||||
|
ts.tv_nsec = (long)((seconds - (time_t)seconds) * 1e9);
|
||||||
|
clock_nanosleep(CLOCK_MONOTONIC, 0, &ts, NULL);
|
||||||
|
}
|
||||||
|
|
||||||
|
static int format_line(char *buf, size_t cap, uint32_t sample, const float *values, uint8_t channels) {
|
||||||
|
int pos = snprintf(buf, cap, "sample=%-7u", sample);
|
||||||
|
for (uint8_t ch = 0; ch < channels; ++ch) {
|
||||||
|
pos += snprintf(buf + pos, cap - (size_t)pos, " ch%d=%8.3f", ch + 1, (double)values[ch]);
|
||||||
|
}
|
||||||
|
pos += snprintf(buf + pos, cap - (size_t)pos, "\n");
|
||||||
|
return pos >= (int)cap ? (int)cap - 1 : pos;
|
||||||
|
}
|
||||||
|
|
||||||
|
static void print_usage(const char *prog) {
|
||||||
|
printf("用法: %s <串口> [选项]\n", prog);
|
||||||
|
printf("\n选项:\n");
|
||||||
|
printf(" --baud <n> 波特率 (默认 %d)\n", DEFAULT_BAUDRATE);
|
||||||
|
printf(" --channels <n> 通道数 (默认 1)\n");
|
||||||
|
printf(" --rate <f> 行/秒 输出速率 (默认 %.0f)\n", DEFAULT_RATE);
|
||||||
|
printf(" --freq <f> 正弦频率 Hz (默认 %.1f)\n", DEFAULT_FREQ);
|
||||||
|
printf(" --amp <f> 振幅 (默认 %.0f)\n", DEFAULT_AMP);
|
||||||
|
printf(" -h, --help 显示帮助\n");
|
||||||
|
printf("\n示例:\n");
|
||||||
|
printf(" %s /dev/ttyUSB0\n", prog);
|
||||||
|
printf(" %s /dev/ttyUSB0 --channels 2 --rate 50 --freq 3\n", prog);
|
||||||
|
printf(" %s /dev/ttyUSB0 --baud 921600 --rate 1000\n", prog);
|
||||||
|
}
|
||||||
|
|
||||||
|
static int parse_args(int argc, char **argv, args_t *args) {
|
||||||
|
memset(args, 0, sizeof(*args));
|
||||||
|
args->baudrate = DEFAULT_BAUDRATE;
|
||||||
|
args->channels = 1;
|
||||||
|
args->rate = DEFAULT_RATE;
|
||||||
|
args->freq = DEFAULT_FREQ;
|
||||||
|
args->amp = DEFAULT_AMP;
|
||||||
|
|
||||||
|
enum { OPT_BAUD = 256, OPT_CHANNELS, OPT_RATE, OPT_FREQ, OPT_AMP };
|
||||||
|
|
||||||
|
static struct option long_opts[] = {
|
||||||
|
{"baud", required_argument, 0, OPT_BAUD},
|
||||||
|
{"channels", required_argument, 0, OPT_CHANNELS},
|
||||||
|
{"rate", required_argument, 0, OPT_RATE},
|
||||||
|
{"freq", required_argument, 0, OPT_FREQ},
|
||||||
|
{"amp", required_argument, 0, OPT_AMP},
|
||||||
|
{"help", no_argument, 0, 'h'},
|
||||||
|
{0, 0, 0, 0}
|
||||||
|
};
|
||||||
|
|
||||||
|
int opt;
|
||||||
|
while ((opt = getopt_long(argc, argv, "h", long_opts, NULL)) != -1) {
|
||||||
|
switch (opt) {
|
||||||
|
case OPT_BAUD: args->baudrate = atoi(optarg); break;
|
||||||
|
case OPT_CHANNELS: {
|
||||||
|
int v = atoi(optarg);
|
||||||
|
if (v < 1 || v > MAX_CHANNELS) {
|
||||||
|
fprintf(stderr, "通道数必须在 1-%d 之间\n", MAX_CHANNELS);
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
args->channels = (uint8_t)v;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case OPT_RATE: args->rate = atof(optarg); break;
|
||||||
|
case OPT_FREQ: args->freq = atof(optarg); break;
|
||||||
|
case OPT_AMP: args->amp = atof(optarg); break;
|
||||||
|
case 'h': print_usage(argv[0]); exit(0);
|
||||||
|
default: print_usage(argv[0]); return -1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (optind >= argc) {
|
||||||
|
fprintf(stderr, "错误: 缺少串口路径\n");
|
||||||
|
print_usage(argv[0]);
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
args->port = argv[optind];
|
||||||
|
|
||||||
|
if (args->rate <= 0.0) { fprintf(stderr, "错误: --rate 必须为正数\n"); return -1; }
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
int main(int argc, char **argv) {
|
||||||
|
args_t args;
|
||||||
|
if (parse_args(argc, argv, &args) < 0) return 1;
|
||||||
|
|
||||||
|
double interval = 1.0 / args.rate;
|
||||||
|
|
||||||
|
printf("═══ pipeview Plain Text Generator ═══\n");
|
||||||
|
printf("串口: %s @ %d baud\n", args.port, args.baudrate);
|
||||||
|
printf("通道: %u 振幅: %.1f 频率: %.1f Hz\n", args.channels, args.amp, args.freq);
|
||||||
|
printf("速率: %.0f lines/sec (间隔 %.3f ms)\n", args.rate, interval * 1000.0);
|
||||||
|
printf("═════════════════════════════════════\n");
|
||||||
|
|
||||||
|
float values[MAX_CHANNELS];
|
||||||
|
char line[LINE_BUF_SIZE];
|
||||||
|
|
||||||
|
int fd = serial_open(args.port, args.baudrate);
|
||||||
|
if (fd < 0) { fprintf(stderr, "无法打开串口 %s\n", args.port); return 1; }
|
||||||
|
printf("已打开 %s\n\n开始发送 (Ctrl+C 停止) ...\n", args.port);
|
||||||
|
|
||||||
|
struct sigaction sa;
|
||||||
|
memset(&sa, 0, sizeof(sa));
|
||||||
|
sa.sa_handler = sigint_handler;
|
||||||
|
sigaction(SIGINT, &sa, NULL);
|
||||||
|
sigaction(SIGTERM, &sa, NULL);
|
||||||
|
|
||||||
|
uint32_t sample_index = 0;
|
||||||
|
uint64_t line_count = 0;
|
||||||
|
double started_at = now_sec();
|
||||||
|
|
||||||
|
while (g_running) {
|
||||||
|
double t = (double)sample_index / args.rate;
|
||||||
|
for (uint8_t ch = 0; ch < args.channels; ++ch) {
|
||||||
|
double phase = 2.0 * M_PI * (double)ch / (double)args.channels;
|
||||||
|
values[ch] = (float)(args.amp * sin(2.0 * M_PI * args.freq * t + phase));
|
||||||
|
}
|
||||||
|
|
||||||
|
int len = format_line(line, sizeof(line), sample_index, values, args.channels);
|
||||||
|
size_t written = 0;
|
||||||
|
while (written < (size_t)len) {
|
||||||
|
ssize_t n = write(fd, line + written, (size_t)len - written);
|
||||||
|
if (n < 0) { perror("write"); goto cleanup; }
|
||||||
|
written += (size_t)n;
|
||||||
|
}
|
||||||
|
line_count++;
|
||||||
|
|
||||||
|
double elapsed = now_sec() - started_at;
|
||||||
|
double target = (double)line_count * interval;
|
||||||
|
double wait = target - elapsed;
|
||||||
|
if (wait > 0.0) sleep_sec(wait);
|
||||||
|
|
||||||
|
sample_index++;
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanup: {
|
||||||
|
double total_time = now_sec() - started_at;
|
||||||
|
printf("\n═══ 已停止 ═══\n");
|
||||||
|
printf("运行时间: %.1f 秒\n", total_time);
|
||||||
|
printf("行数: %lu (%.1f lines/sec)\n",
|
||||||
|
(unsigned long)line_count,
|
||||||
|
total_time > 0.0 ? (double)line_count / total_time : 0.0);
|
||||||
|
|
||||||
|
tcflush(fd, TCIOFLUSH);
|
||||||
|
close(fd);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user