Compare commits
3 Commits
9cdbd5a892
...
2efbc9c284
| Author | SHA1 | Date | |
|---|---|---|---|
| 2efbc9c284 | |||
| b3acb02d52 | |||
| afb8ca509a |
416
README.md
416
README.md
@@ -1,166 +1,366 @@
|
|||||||
# xserial
|
# xserial
|
||||||
|
|
||||||
`xserial` 是一个基于 Rust workspace 的串口 / TCP / UDP 数据观测工具,支持可配置分帧、协议解码、会话管理,以及以文本、十六进制和绘图为核心的桌面 GUI。
|
**跨平台串口 / TCP / UDP 数据观测工具** — 可配置分帧、协议解码、多会话管理,支持文本、十六进制和实时波形绘图。
|
||||||
|
|
||||||
## 工作区结构
|
基于 Rust workspace,GUI 使用 [egui](https://github.com/emilk/egui) + [egui_plot](https://github.com/emilk/egui/tree/master/crates/egui_plot),支持 Lua 脚本扩展分帧与解码逻辑。
|
||||||
|
|
||||||
仓库当前包含 4 个 crate:
|
---
|
||||||
|
|
||||||
- `xserial-core`
|
## 架构
|
||||||
负责传输层、分帧器、协议解码器和 pipeline 基础能力。
|
|
||||||
- `xserial-client`
|
|
||||||
负责会话生命周期、配置、事件分发、历史缓冲和 Lua 相关接口。
|
|
||||||
- `xserial-gui`
|
|
||||||
当前主要使用入口,基于 `egui` / `eframe`。
|
|
||||||
- `xserial-tui`
|
|
||||||
终端 UI crate,目前还只是占位实现。
|
|
||||||
|
|
||||||
## 当前功能
|
```
|
||||||
|
┌─────────────────────────────────────────────────────┐
|
||||||
|
│ xserial-gui (egui) xserial-tui (ratatui) │
|
||||||
|
│ 面板: sidebar, console, 占位实现 │
|
||||||
|
│ hex_view, plot_view, │
|
||||||
|
│ config │
|
||||||
|
├─────────────────────────────────────────────────────┤
|
||||||
|
│ xserial-client │
|
||||||
|
│ SessionManager · Session · Config · History │
|
||||||
|
│ Lua Runtime (mlua/LuaJIT) │
|
||||||
|
│ ┌──────────┐ ┌──────────┐ ┌──────────────────┐ │
|
||||||
|
│ │ LuaFramer│ │LuaDecoder│ │ Lua Session API │ │
|
||||||
|
│ └──────────┘ └──────────┘ └──────────────────┘ │
|
||||||
|
├─────────────────────────────────────────────────────┤
|
||||||
|
│ xserial-core │
|
||||||
|
│ Transport ──▶ Frame ──▶ Protocol ──▶ Pipeline │
|
||||||
|
│ (Serial, (Line, (Text, Hex, (MultiPipeline) │
|
||||||
|
│ TCP, UDP) Fixed, Plot, │
|
||||||
|
│ Length, MixedTextPlot) │
|
||||||
|
│ Cobs, │
|
||||||
|
│ MixedTextPlot) │
|
||||||
|
└─────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
- 在一个 GUI 中管理多个 session。
|
| Crate | 类型 | 职责 |
|
||||||
- 支持 `Serial`、`TCP`、`UDP` 三种传输方式。
|
|-------|------|------|
|
||||||
- 支持以下 framer:
|
| `xserial-core` | library | 传输层、分帧器、协议解码器、Pipeline |
|
||||||
`Line`、`Fixed`、`Length`、`Cobs`、`MixedTextPlot`。
|
| `xserial-client` | library | 会话管理、配置持久化、事件分发、历史缓冲、Lua 运行时 |
|
||||||
- 支持以下 decoder:
|
| `xserial-gui` | binary | egui 桌面应用(主要入口) |
|
||||||
`Text`、`Hex`、`Plot`、`MixedTextPlot`。
|
| `xserial-tui` | binary | ratatui 终端应用(占位,未达功能对等) |
|
||||||
- 每个 session 可在 `Text`、`Hex`、`Plot` 三种视图之间切换。
|
|
||||||
- 支持单连接 mixed text + plot 数据流。
|
依赖方向: `core ← client ← {gui, tui}`
|
||||||
- 支持 `Interleaved`、`Block`、`XY` 三种 plot 格式。
|
|
||||||
- 可在 sidebar 中直接编辑 session 配置。
|
---
|
||||||
- plot 视图支持嵌入主界面,也支持在 GUI 内部弹出为浮动窗口。
|
|
||||||
|
|
||||||
## 构建
|
## 构建
|
||||||
|
|
||||||
|
**前置条件**:
|
||||||
|
|
||||||
|
- **Rust ≥ 1.85**(workspace 使用 `edition = "2024"`)
|
||||||
|
- **Linux**: `libudev-dev`(`serialport` 依赖)
|
||||||
|
- **所有平台**: C 编译器(`mlua` 使用 `vendored` 特性从源码编译 LuaJIT)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
# 编译检查
|
||||||
cargo check --workspace
|
cargo check --workspace
|
||||||
|
|
||||||
|
# 运行测试(~266 个)
|
||||||
|
cargo test --workspace
|
||||||
|
|
||||||
|
# 格式化 + Clippy
|
||||||
|
cargo fmt --all
|
||||||
|
cargo clippy --workspace --all-targets -- -D warnings
|
||||||
```
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 运行
|
## 运行
|
||||||
|
|
||||||
启动 GUI:
|
### GUI(主要入口)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cargo run -p xserial-gui
|
cargo run -p xserial-gui
|
||||||
```
|
```
|
||||||
|
|
||||||
当前 `xserial-tui` 还不是完整实现:
|
### TUI(占位实现)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cargo run -p xserial-tui
|
cargo run -p xserial-tui
|
||||||
```
|
```
|
||||||
|
|
||||||
## 测试
|
---
|
||||||
|
|
||||||
运行整个 workspace 的测试:
|
## 功能
|
||||||
|
|
||||||
```bash
|
### 传输层
|
||||||
cargo test --workspace
|
|
||||||
|
| 类型 | 配置项 |
|
||||||
|
|------|--------|
|
||||||
|
| **Serial** | 端口名、波特率、数据位 (5/6/7/8)、校验位 (None/Odd/Even)、停止位 (1/2)、流控 (None/Software/Hardware)、DTR、RTS |
|
||||||
|
| **TCP** | 目标地址 `host:port` |
|
||||||
|
| **UDP** | 绑定地址 `host:port`,可选远端地址 |
|
||||||
|
|
||||||
|
DTR/RTS 控制仅在 Serial 连接时可用。
|
||||||
|
|
||||||
|
### 分帧器
|
||||||
|
|
||||||
|
| 分帧器 | 说明 | 配置 |
|
||||||
|
|--------|------|------|
|
||||||
|
| **Line** | 按换行符 `\n` 分割,可选去除 `\r` | `strip_cr`, `max_line_len` |
|
||||||
|
| **Fixed** | 固定字节数为一帧 | `frame_len` |
|
||||||
|
| **Length** | 长度前缀协议帧 | `len_bytes` (1/2/4/8), `endian`, `length_includes_self`, `max_payload` |
|
||||||
|
| **Cobs** | [COBS](https://en.wikipedia.org/wiki/Consistent_Overhead_Byte_Stuffing) 编码,`0x00` 分隔 | `max_frame` |
|
||||||
|
| **MixedTextPlot** | 单连接混合文本行 + COBS plot 帧 | `strip_cr`, `max_line_len`, `max_plot_frame` |
|
||||||
|
| **Lua** | 用户自定义 Lua 脚本 | `script_path` |
|
||||||
|
|
||||||
|
### 协议解码器
|
||||||
|
|
||||||
|
| 解码器 | 输出 | 配置 |
|
||||||
|
|--------|------|------|
|
||||||
|
| **Text** | UTF-8 / Latin1 文本 | `encoding` |
|
||||||
|
| **Hex** | 十六进制字符串 | `uppercase`, `separator`, `bytes_per_group`, `endian` |
|
||||||
|
| **Plot** | 数值波形数据 | `sample_type` (i8–f64), `endian`, `channels`, `format` (Interleaved / Block / XY) |
|
||||||
|
| **MixedTextPlot** | 混合流(文本 + 波形) | `encoding` |
|
||||||
|
| **Lua** | 用户自定义解码 | `script_path` |
|
||||||
|
|
||||||
|
#### Plot 采样格式
|
||||||
|
|
||||||
|
| 格式 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| **Interleaved** | 多通道交叉排列: `[ch0_s0, ch1_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`
|
||||||
|
|
||||||
|
### Pipeline
|
||||||
|
|
||||||
|
每个 Session 可配置多个 Pipeline。同一个字节流被送入所有 Pipeline,每个 Pipeline 独立分帧、解码,互不干扰。例如:
|
||||||
|
|
||||||
|
- Pipeline "text": LineFramer → TextDecoder → 文本视图
|
||||||
|
- Pipeline "hex": FixedFramer(16) → HexDecoder → 十六进制视图
|
||||||
|
- Pipeline "plot": FixedFramer(256) → PlotDecoder → 波形视图
|
||||||
|
|
||||||
|
当数据到达时,只有成功解码的 Pipeline 产生输出。
|
||||||
|
|
||||||
|
### 会话管理
|
||||||
|
|
||||||
|
- **多会话**: 同时运行多个独立 session
|
||||||
|
- **连接控制**: Connect / Disconnect / Reconnect
|
||||||
|
- **自动重连**: 断线后每秒自动重试
|
||||||
|
- **动态重配置**: 运行时修改分帧器/解码器配置
|
||||||
|
- **数据发送**: 通过 Console 面板发送原始字节到连接
|
||||||
|
- **历史缓冲**: 环形缓冲区(默认 10,000 条),可配置上限
|
||||||
|
- **状态持久化**: Session 配置自动保存到 `gui-state.json`
|
||||||
|
|
||||||
|
### Lua 脚本扩展
|
||||||
|
|
||||||
|
内置 LuaJIT 运行时,支持:
|
||||||
|
|
||||||
|
- **自定义分帧器**: 实现 `feed(bytes)`, `flush()`, `reset()`, `pending_len()` 四个函数
|
||||||
|
- **自定义解码器**: 实现 `decode(frame)` 函数,返回 `{kind="text"|"hex"|"binary"|"plot", data=…}`
|
||||||
|
- **会话 API**: `xserial.open(config)` 创建 session,支持 `on_data(callback)` 事件回调
|
||||||
|
- **辅助函数**: `xserial.list_ports()`, `xserial.sleep(ms)`, `xserial.poll(limit)`, `xserial.log(msg)`
|
||||||
|
|
||||||
|
示例 Lua Line Framer:
|
||||||
|
|
||||||
|
```lua
|
||||||
|
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,
|
||||||
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
常用本地检查命令:
|
---
|
||||||
|
|
||||||
```bash
|
## GUI
|
||||||
cargo fmt --all
|
|
||||||
cargo clippy --workspace --all-targets -- -D warnings
|
### 面板
|
||||||
```
|
|
||||||
|
| 面板 | 功能 |
|
||||||
## GUI 快速开始
|
|------|------|
|
||||||
|
| **Sidebar** | Session 列表、创建/删除/编辑 session、连接切换 |
|
||||||
1. 启动 `xserial-gui`。
|
| **Config** | 传输参数、Pipeline 配置(分帧器 + 解码器类型及参数) |
|
||||||
2. 在 sidebar 中创建一个 session。
|
| **Console** | 数据发送输入框,发送按钮 |
|
||||||
3. 选择传输方式:`Serial`、`TCP` 或 `UDP`。
|
| **Text View** | 解码后的文本流,支持时间戳和方向标记 |
|
||||||
4. 添加一个或多个 pipeline,并选择匹配的 framer / decoder。
|
| **Hex View** | 十六进制字节展示,可配置分组和分隔符 |
|
||||||
5. 连接 session。
|
| **Plot View** | 实时波形图,支持内嵌和浮动窗口两种模式 |
|
||||||
6. 在 `Text`、`Hex`、`Plot` 视图之间切换。
|
|
||||||
|
### 快捷键
|
||||||
补充说明:
|
|
||||||
|
| 快捷键 | 操作 |
|
||||||
- session 配置入口在 sidebar 的齿轮按钮。
|
|--------|------|
|
||||||
- 连接控制是单个切换按钮,不再区分独立的 `Connect` / `Disconnect`。
|
| `Ctrl+Tab` | 下一个 Tab |
|
||||||
- plot 视图支持内嵌和浮动窗口两种显示方式。
|
| `Ctrl+Shift+Tab` | 上一个 Tab |
|
||||||
|
| `Ctrl+T` | 切换到 Text 视图 |
|
||||||
## Plot 测试服务器
|
| `Ctrl+H` | 切换到 Hex 视图 |
|
||||||
|
| `Ctrl+P` | 切换到 Plot 视图 |
|
||||||
仓库自带一个本地波形测试工具:
|
| `Ctrl+N` | 新建 Session |
|
||||||
|
| `Ctrl+E` | 编辑当前 Session |
|
||||||
```bash
|
| `Ctrl+W` | 删除当前 Session |
|
||||||
python tools/test_plot.py --help
|
| `Ctrl+F5` | 切换连接 |
|
||||||
```
|
| `Ctrl+L` | 清空输出 |
|
||||||
|
| `Ctrl+,` | UI 设置 |
|
||||||
示例:通过 TCP 发送 mixed text + plot 数据流:
|
| `Esc` | 关闭浮层 |
|
||||||
|
|
||||||
|
### 状态持久化
|
||||||
|
|
||||||
|
GUI 状态(session 配置、活动 tab、显示选项)自动保存到:
|
||||||
|
|
||||||
|
| 平台 | 路径 |
|
||||||
|
|------|------|
|
||||||
|
| Linux | `$XDG_CONFIG_HOME/xserial/gui-state.json` 或 `~/.config/xserial/gui-state.json` |
|
||||||
|
| macOS | `~/Library/Application Support/xserial/gui-state.json` |
|
||||||
|
| Windows | `%APPDATA%\xserial\gui-state.json` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 开发工具
|
||||||
|
|
||||||
|
### Plot 测试服务器
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
# 启动 TCP 波形数据源
|
||||||
python tools/test_plot.py --wire-format mixed --host 127.0.0.1 --port 8091
|
python tools/test_plot.py --wire-format mixed --host 127.0.0.1 --port 8091
|
||||||
```
|
|
||||||
|
|
||||||
GUI 中对应配置为:
|
# XY 格式,2 通道
|
||||||
|
|
||||||
- Transport:`TCP 127.0.0.1:8091`
|
|
||||||
- Framer:`MixedTextPlot`
|
|
||||||
- Decoder:`MixedTextPlot`
|
|
||||||
|
|
||||||
示例:发送 `XY` plot 数据:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python tools/test_plot.py --wire-format mixed --format xy --channels 2
|
python tools/test_plot.py --wire-format mixed --format xy --channels 2
|
||||||
```
|
|
||||||
|
|
||||||
示例:发送固定长度 raw plot 帧:
|
# 固定长度 raw plot 帧
|
||||||
|
|
||||||
```bash
|
|
||||||
python tools/test_plot.py --wire-format raw --channels 2 --framelen 256
|
python tools/test_plot.py --wire-format raw --channels 2 --framelen 256
|
||||||
```
|
```
|
||||||
|
|
||||||
GUI 中对应配置为:
|
GUI 中配置: Transport `TCP 127.0.0.1:8091`, Framer `MixedTextPlot`, Decoder `MixedTextPlot`
|
||||||
|
|
||||||
- Framer:`Fixed`
|
### 性能分析
|
||||||
- Decoder:`Plot`
|
|
||||||
- Sample type:`F32`
|
|
||||||
- Endian:`Little`
|
|
||||||
- `Channels` / `Format` 与脚本参数保持一致
|
|
||||||
|
|
||||||
## Profiling
|
|
||||||
|
|
||||||
GUI 内置了一套轻量级性能日志,默认关闭。
|
|
||||||
|
|
||||||
开启方式:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
# 开启性能日志
|
||||||
RUST_LOG=xserial_gui::perf=info XSERIAL_GUI_PROFILE=1 cargo run -p xserial-gui
|
RUST_LOG=xserial_gui::perf=info XSERIAL_GUI_PROFILE=1 cargo run -p xserial-gui
|
||||||
|
|
||||||
|
# 自定义统计间隔(默认 1000ms)
|
||||||
|
XSERIAL_GUI_PROFILE_INTERVAL_MS=500 cargo run -p xserial-gui
|
||||||
```
|
```
|
||||||
|
|
||||||
修改统计输出间隔:
|
日志输出: frame 耗时、事件 drain 耗时、text/hex/plot 渲染耗时、plot 点数统计
|
||||||
|
|
||||||
|
### 日志
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
RUST_LOG=xserial_gui::perf=info XSERIAL_GUI_PROFILE=1 XSERIAL_GUI_PROFILE_INTERVAL_MS=500 cargo run -p xserial-gui
|
RUST_LOG=info cargo run -p xserial-gui
|
||||||
|
RUST_LOG=xserial_gui=debug cargo run -p xserial-gui
|
||||||
```
|
```
|
||||||
|
|
||||||
日志会输出:
|
使用 `tracing-subscriber` + `RUST_LOG` 环境变量控制日志级别。
|
||||||
|
|
||||||
- frame 耗时
|
### 测试
|
||||||
- 事件 drain 耗时
|
|
||||||
- text / hex / plot 渲染耗时
|
|
||||||
- plot 点数量统计
|
|
||||||
|
|
||||||
## 仓库目录
|
```bash
|
||||||
|
# 全部测试
|
||||||
|
cargo test --workspace
|
||||||
|
|
||||||
```text
|
# 特定 crate
|
||||||
|
cargo test -p xserial-core
|
||||||
|
cargo test -p xserial-client
|
||||||
|
|
||||||
|
# 集成测试
|
||||||
|
cargo test -p xserial-core --test pipeline
|
||||||
|
cargo test -p xserial-client --test session_lifecycle
|
||||||
|
cargo test -p xserial-client --test lua_tests
|
||||||
|
|
||||||
|
# 带输出
|
||||||
|
cargo test -p xserial-core -- --nocapture
|
||||||
|
```
|
||||||
|
|
||||||
|
所有测试自包含(TCP/UDP 绑定 `127.0.0.1:0`,串口测试使用虚拟端口名),无外部依赖。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 目录结构
|
||||||
|
|
||||||
|
```
|
||||||
crates/
|
crates/
|
||||||
xserial-core/
|
xserial-core/ # 传输、分帧、协议解码、Pipeline
|
||||||
xserial-client/
|
src/
|
||||||
xserial-gui/
|
transport/ # Serial, TCP, UDP
|
||||||
xserial-tui/
|
frame/ # Line, Fixed, Length, Cobs, MixedTextPlot
|
||||||
tools/
|
protocol/ # Text, Hex, Plot, MixedTextPlot
|
||||||
test_plot.py
|
pipeline.rs # MultiPipeline
|
||||||
|
xserial-client/ # 会话管理、Lua 运行时
|
||||||
|
src/
|
||||||
|
session.rs # Session 事件循环
|
||||||
|
manager.rs # SessionManager
|
||||||
|
config.rs # SessionConfig, FramerConfig, DecoderConfig
|
||||||
|
event.rs # DecodedEntry
|
||||||
|
history.rs # RingBuffer
|
||||||
|
cmd.rs # SessionCmd
|
||||||
|
lua/ # Lua 运行时、自定义 codec、会话 API
|
||||||
|
xserial-gui/ # egui 桌面应用
|
||||||
|
src/
|
||||||
|
main.rs
|
||||||
|
app.rs # XserialApp 主控制器
|
||||||
|
panels/ # sidebar, config, console, hex_view, plot_view
|
||||||
|
shortcuts.rs # 键盘快捷键
|
||||||
|
ui_fonts.rs # 字体加载
|
||||||
|
perf.rs # 性能分析
|
||||||
|
app_state.rs # 状态持久化
|
||||||
|
buffers.rs # UI 缓冲管理
|
||||||
|
xserial-tui/ # ratatui 终端应用 (占位)
|
||||||
|
tests/ # Lua 测试 fixture
|
||||||
|
lua_line_framer.lua
|
||||||
|
lua_text_decoder.lua
|
||||||
|
tools/ # 开发辅助工具
|
||||||
|
test_plot.py # TCP 波形测试服务器
|
||||||
|
test_plot_serial.c # C 串口 plot 测试客户端
|
||||||
|
test_text_serial.c # C 串口文本测试客户端
|
||||||
|
xs_mixed_plot.h # MixedTextPlot 线格式 C 参考头文件
|
||||||
```
|
```
|
||||||
|
|
||||||
## 开发约定
|
---
|
||||||
|
|
||||||
- 传输层、协议和 pipeline 逻辑优先放在 `xserial-core`。
|
## MixedTextPlot 协议
|
||||||
- 会话管理和状态编排优先放在 `xserial-client`。
|
|
||||||
- UI 相关状态放在 `xserial-gui` 或 `xserial-tui`。
|
|
||||||
- 集成测试位于 `crates/*/tests`。
|
|
||||||
|
|
||||||
## 当前状态
|
单连接上混合传输文本行和波形数据的线格式。
|
||||||
|
|
||||||
目前项目中最完整的是 GUI 路径。`xserial-tui` 已经在 workspace 中,但还没有达到和 GUI 同等的可用程度。
|
**文本帧**: 普通文本行,以 `\n` 分隔。
|
||||||
|
|
||||||
|
**Plot 帧**:
|
||||||
|
```
|
||||||
|
0x1E 'P' | COBS(plot_packet) | 0x00
|
||||||
|
```
|
||||||
|
|
||||||
|
**Plot Packet** (13 字节头 + payload):
|
||||||
|
```
|
||||||
|
'X' 'P' (magic)
|
||||||
|
version:u8 (固定 1)
|
||||||
|
format:u8 (0=Interleaved, 1=Block, 2=XY)
|
||||||
|
sample_type:u8 (F32=8)
|
||||||
|
endian:u8 (0=Little)
|
||||||
|
channels:u8
|
||||||
|
samples_per_channel:u16le
|
||||||
|
payload_len:u32le
|
||||||
|
payload:bytes (channels × samples_per_channel × sample_byte_size)
|
||||||
|
```
|
||||||
|
|
||||||
|
参考实现: `tools/xs_mixed_plot.h`(C header)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 技术细节
|
||||||
|
|
||||||
|
- **异步运行时**: tokio (multi-thread)
|
||||||
|
- **串口**: `tokio-serial` + `serialport`
|
||||||
|
- **分帧器特征**: `feed()`, `flush()`, `reset()`, `pending_len()` — 有状态字节流分帧
|
||||||
|
- **解码器特征**: `name()`, `decode(&[u8]) -> Option<DecodedData>` — 无状态帧解码
|
||||||
|
- **Session 事件循环**: `tokio::select!` 多路复用命令、读取、重连定时器
|
||||||
|
- **事件分发**: `tokio::sync::broadcast` — 多订阅者
|
||||||
|
- **Lua**: `mlua` 0.11, LuaJIT (vendored 编译), 支持 async/serde/send
|
||||||
|
- **无 feature flags**: 零条件编译,无 build script
|
||||||
|
- **无 CI**: 本地自检 `cargo test --workspace` + `cargo clippy`
|
||||||
|
|||||||
@@ -48,6 +48,25 @@ pub enum SendMode {
|
|||||||
Hex,
|
Hex,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, PartialEq)]
|
||||||
|
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,
|
||||||
@@ -55,6 +74,71 @@ pub struct DisplayOptions {
|
|||||||
pub show_pipeline: bool,
|
pub show_pipeline: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
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 Default for SearchState {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
query: String::new(),
|
||||||
|
matches: Vec::new(),
|
||||||
|
current_match: 0,
|
||||||
|
case_sensitive: false,
|
||||||
|
active: false,
|
||||||
|
just_opened: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
@@ -69,8 +153,9 @@ pub struct SessionTab {
|
|||||||
pub rts: 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 struct XserialApp {
|
pub struct XserialApp {
|
||||||
@@ -260,6 +345,26 @@ impl XserialApp {
|
|||||||
self.config_open = false;
|
self.config_open = false;
|
||||||
self.font_settings_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) {
|
||||||
|
if tab.search.active {
|
||||||
|
tab.search.next();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
SearchPrev => {
|
||||||
|
if let Some(tab) = self.tabs.get_mut(self.active) {
|
||||||
|
if tab.search.active {
|
||||||
|
tab.search.prev();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -300,8 +405,9 @@ impl XserialApp {
|
|||||||
rts,
|
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(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -596,6 +702,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));
|
||||||
}
|
}
|
||||||
@@ -619,11 +727,11 @@ impl XserialApp {
|
|||||||
let started = Instant::now();
|
let started = Instant::now();
|
||||||
match tab.view {
|
match tab.view {
|
||||||
View::Text => {
|
View::Text => {
|
||||||
let line_count = console::render(ui, &tab.console, *display);
|
let line_count = console::render(ui, &tab.console, *display, tab.search.active.then_some(&tab.search));
|
||||||
text_render = Some((started.elapsed(), line_count));
|
text_render = Some((started.elapsed(), line_count));
|
||||||
}
|
}
|
||||||
View::Hex => {
|
View::Hex => {
|
||||||
let line_count = hex_view::render(ui, &tab.hex, *display);
|
let line_count = hex_view::render(ui, &tab.hex, *display, tab.search.active.then_some(&tab.search));
|
||||||
hex_render = Some((started.elapsed(), line_count));
|
hex_render = Some((started.elapsed(), line_count));
|
||||||
}
|
}
|
||||||
View::Plot => {
|
View::Plot => {
|
||||||
@@ -972,6 +1080,62 @@ impl eframe::App for XserialApp {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn render_search_bar(ui: &mut egui::Ui, tab: &mut SessionTab) {
|
||||||
|
if !tab.search.active {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
let response = ui.add(
|
||||||
|
TextEdit::singleline(&mut tab.search.query)
|
||||||
|
.hint_text("Search...")
|
||||||
|
.desired_width(200.0),
|
||||||
|
);
|
||||||
|
if tab.search.just_opened {
|
||||||
|
response.request_focus();
|
||||||
|
tab.search.just_opened = false;
|
||||||
|
}
|
||||||
|
if response.changed() {
|
||||||
|
let matches = match tab.view {
|
||||||
|
View::Text => tab.console.search(&tab.search.query, tab.search.case_sensitive),
|
||||||
|
View::Hex => tab.hex.search(&tab.search.query, tab.search.case_sensitive),
|
||||||
|
View::Plot => Vec::new(),
|
||||||
|
};
|
||||||
|
tab.search.matches = matches;
|
||||||
|
tab.search.current_match = 0;
|
||||||
|
}
|
||||||
|
if response.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) {
|
||||||
|
tab.search.next();
|
||||||
|
}
|
||||||
|
|
||||||
|
ui.label(format!(
|
||||||
|
"{}/{}",
|
||||||
|
tab.search.current_display(),
|
||||||
|
tab.search.match_count()
|
||||||
|
));
|
||||||
|
|
||||||
|
if ui.button("▲").clicked() {
|
||||||
|
tab.search.prev();
|
||||||
|
}
|
||||||
|
if ui.button("▼").clicked() {
|
||||||
|
tab.search.next();
|
||||||
|
}
|
||||||
|
|
||||||
|
if ui.checkbox(&mut tab.search.case_sensitive, "Aa").changed() {
|
||||||
|
let matches = match tab.view {
|
||||||
|
View::Text => tab.console.search(&tab.search.query, tab.search.case_sensitive),
|
||||||
|
View::Hex => tab.hex.search(&tab.search.query, tab.search.case_sensitive),
|
||||||
|
View::Plot => Vec::new(),
|
||||||
|
};
|
||||||
|
tab.search.matches = matches;
|
||||||
|
tab.search.current_match = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ui.button("✕").clicked() {
|
||||||
|
tab.search.clear();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
fn render_session_controls(
|
fn render_session_controls(
|
||||||
ui: &mut egui::Ui,
|
ui: &mut egui::Ui,
|
||||||
manager: &SessionManager,
|
manager: &SessionManager,
|
||||||
@@ -1066,7 +1230,31 @@ 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);
|
||||||
@@ -1108,12 +1296,14 @@ 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() {
|
||||||
|
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);
|
tab.console.push_outbound(text);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1154,9 +1344,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()))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -67,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).map_or(false, |line| {
|
||||||
|
if case_sensitive {
|
||||||
|
line.text.contains(query)
|
||||||
|
} else {
|
||||||
|
line.text.to_lowercase().contains(&query_lower)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
@@ -155,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).map_or(false, |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 {
|
||||||
|
|||||||
@@ -1,11 +1,19 @@
|
|||||||
use crate::app::DisplayOptions;
|
use crate::app::{DisplayOptions, SearchState};
|
||||||
use crate::buffers::{ConsoleLine, LineDirection, TextBuffer};
|
use crate::buffers::{ConsoleLine, LineDirection, TextBuffer};
|
||||||
use egui::{Label, RichText, ScrollArea, TextStyle, TextWrapMode, Ui};
|
use egui::{
|
||||||
|
Color32, Label, ScrollArea, TextStyle, TextWrapMode, Ui,
|
||||||
|
text::{LayoutJob, TextFormat},
|
||||||
|
};
|
||||||
|
|
||||||
pub fn render(ui: &mut Ui, buf: &TextBuffer, display: DisplayOptions) -> usize {
|
pub fn render(
|
||||||
|
ui: &mut Ui,
|
||||||
|
buf: &TextBuffer,
|
||||||
|
display: DisplayOptions,
|
||||||
|
search: Option<&SearchState>,
|
||||||
|
) -> usize {
|
||||||
let line_count = buf.len();
|
let line_count = buf.len();
|
||||||
let row_height = ui.text_style_height(&TextStyle::Monospace);
|
let row_height = ui.text_style_height(&TextStyle::Monospace);
|
||||||
ScrollArea::vertical().stick_to_bottom(true).show_rows(
|
ScrollArea::both().stick_to_bottom(true).show_rows(
|
||||||
ui,
|
ui,
|
||||||
row_height,
|
row_height,
|
||||||
line_count,
|
line_count,
|
||||||
@@ -13,7 +21,7 @@ pub fn render(ui: &mut Ui, buf: &TextBuffer, display: DisplayOptions) -> usize {
|
|||||||
for row in row_range {
|
for row in row_range {
|
||||||
if let Some(line) = buf.get(row) {
|
if let Some(line) = buf.get(row) {
|
||||||
ui.add(
|
ui.add(
|
||||||
Label::new(RichText::new(format_console_line(line, display)).monospace())
|
Label::new(format_console_line(line, display, search, ui.style()))
|
||||||
.wrap_mode(TextWrapMode::Extend),
|
.wrap_mode(TextWrapMode::Extend),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -23,7 +31,60 @@ pub fn render(ui: &mut Ui, buf: &TextBuffer, display: DisplayOptions) -> usize {
|
|||||||
line_count
|
line_count
|
||||||
}
|
}
|
||||||
|
|
||||||
fn format_console_line(line: &ConsoleLine, display: DisplayOptions) -> String {
|
fn highlight_matches(
|
||||||
|
text: &str,
|
||||||
|
query: &str,
|
||||||
|
case_sensitive: bool,
|
||||||
|
default_format: TextFormat,
|
||||||
|
highlight_format: TextFormat,
|
||||||
|
) -> LayoutJob {
|
||||||
|
let search_text = if case_sensitive {
|
||||||
|
text.to_string()
|
||||||
|
} else {
|
||||||
|
text.to_lowercase()
|
||||||
|
};
|
||||||
|
let query = if case_sensitive {
|
||||||
|
query.to_string()
|
||||||
|
} else {
|
||||||
|
query.to_lowercase()
|
||||||
|
};
|
||||||
|
let mut job = LayoutJob::default();
|
||||||
|
let mut last_end = 0;
|
||||||
|
let mut idx = 0;
|
||||||
|
while let Some(pos) = search_text[idx..].find(&query) {
|
||||||
|
let start = idx + pos;
|
||||||
|
let end = start + query.len();
|
||||||
|
if last_end < start {
|
||||||
|
job.append(&text[last_end..start], 0.0, default_format.clone());
|
||||||
|
}
|
||||||
|
job.append(&text[start..end], 0.0, highlight_format.clone());
|
||||||
|
last_end = end;
|
||||||
|
idx = end;
|
||||||
|
}
|
||||||
|
if last_end < text.len() {
|
||||||
|
job.append(&text[last_end..], 0.0, default_format);
|
||||||
|
}
|
||||||
|
job
|
||||||
|
}
|
||||||
|
|
||||||
|
fn monospace_format(style: &egui::Style) -> TextFormat {
|
||||||
|
TextFormat {
|
||||||
|
font_id: style
|
||||||
|
.text_styles
|
||||||
|
.get(&TextStyle::Monospace)
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_default(),
|
||||||
|
color: Color32::WHITE,
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn format_console_line(
|
||||||
|
line: &ConsoleLine,
|
||||||
|
display: DisplayOptions,
|
||||||
|
search: Option<&SearchState>,
|
||||||
|
style: &egui::Style,
|
||||||
|
) -> LayoutJob {
|
||||||
let mut prefix = Vec::new();
|
let mut prefix = Vec::new();
|
||||||
if display.show_timestamp {
|
if display.show_timestamp {
|
||||||
let ts = line.elapsed.as_secs_f64();
|
let ts = line.elapsed.as_secs_f64();
|
||||||
@@ -49,5 +110,20 @@ fn format_console_line(line: &ConsoleLine, display: DisplayOptions) -> String {
|
|||||||
} else {
|
} else {
|
||||||
format!("{} ", prefix.join(" "))
|
format!("{} ", prefix.join(" "))
|
||||||
};
|
};
|
||||||
format!("{prefix}{}", line.text)
|
let full_text = format!("{prefix}{}", line.text);
|
||||||
|
|
||||||
|
let default = monospace_format(style);
|
||||||
|
let highlight = TextFormat {
|
||||||
|
font_id: default.font_id.clone(),
|
||||||
|
color: Color32::BLACK,
|
||||||
|
background: Color32::from_rgb(255, 255, 0),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
match search {
|
||||||
|
Some(state) if state.active && !state.query.is_empty() => {
|
||||||
|
highlight_matches(&full_text, &state.query, state.case_sensitive, default, highlight)
|
||||||
|
}
|
||||||
|
_ => LayoutJob::single_section(full_text, default),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,23 @@
|
|||||||
use crate::app::DisplayOptions;
|
use crate::app::{DisplayOptions, SearchState};
|
||||||
use crate::buffers::{HexBuffer, HexLine, LineDirection};
|
use crate::buffers::{HexBuffer, HexLine, LineDirection};
|
||||||
use egui::{Label, RichText, ScrollArea, TextStyle, TextWrapMode, Ui};
|
use egui::{
|
||||||
|
Color32, Label, ScrollArea, TextStyle, TextWrapMode, Ui,
|
||||||
|
text::{LayoutJob, TextFormat},
|
||||||
|
};
|
||||||
|
|
||||||
pub fn render(ui: &mut Ui, buf: &HexBuffer, display: DisplayOptions) -> usize {
|
pub fn render(
|
||||||
|
ui: &mut Ui,
|
||||||
|
buf: &HexBuffer,
|
||||||
|
display: DisplayOptions,
|
||||||
|
search: Option<&SearchState>,
|
||||||
|
) -> usize {
|
||||||
let line_count = buf.len();
|
let line_count = buf.len();
|
||||||
if line_count == 0 {
|
if line_count == 0 {
|
||||||
ui.label("no hex data");
|
ui.label("no hex data");
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
let row_height = ui.text_style_height(&TextStyle::Monospace);
|
let row_height = ui.text_style_height(&TextStyle::Monospace);
|
||||||
ScrollArea::vertical().stick_to_bottom(true).show_rows(
|
ScrollArea::both().stick_to_bottom(true).show_rows(
|
||||||
ui,
|
ui,
|
||||||
row_height,
|
row_height,
|
||||||
line_count,
|
line_count,
|
||||||
@@ -17,7 +25,7 @@ pub fn render(ui: &mut Ui, buf: &HexBuffer, display: DisplayOptions) -> usize {
|
|||||||
for row in row_range {
|
for row in row_range {
|
||||||
if let Some(line) = buf.get(row) {
|
if let Some(line) = buf.get(row) {
|
||||||
ui.add(
|
ui.add(
|
||||||
Label::new(RichText::new(format_hex_line(line, display)).monospace())
|
Label::new(format_hex_line(line, display, search, ui.style()))
|
||||||
.wrap_mode(TextWrapMode::Extend),
|
.wrap_mode(TextWrapMode::Extend),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -27,7 +35,60 @@ pub fn render(ui: &mut Ui, buf: &HexBuffer, display: DisplayOptions) -> usize {
|
|||||||
line_count
|
line_count
|
||||||
}
|
}
|
||||||
|
|
||||||
fn format_hex_line(line: &HexLine, display: DisplayOptions) -> String {
|
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();
|
let mut prefix = Vec::new();
|
||||||
if display.show_timestamp {
|
if display.show_timestamp {
|
||||||
let ts = line.elapsed.as_secs_f64();
|
let ts = line.elapsed.as_secs_f64();
|
||||||
@@ -53,5 +114,21 @@ fn format_hex_line(line: &HexLine, display: DisplayOptions) -> String {
|
|||||||
} else {
|
} else {
|
||||||
format!("{} ", prefix.join(" "))
|
format!("{} ", prefix.join(" "))
|
||||||
};
|
};
|
||||||
format!("{prefix}{} |{}|", line.hex, line.ascii)
|
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),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,10 @@ pub enum Action {
|
|||||||
|
|
||||||
UiSettings,
|
UiSettings,
|
||||||
CloseOverlay,
|
CloseOverlay,
|
||||||
|
|
||||||
|
Search,
|
||||||
|
SearchNext,
|
||||||
|
SearchPrev,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn default_bindings() -> Vec<(Action, KeyboardShortcut)> {
|
pub fn default_bindings() -> Vec<(Action, KeyboardShortcut)> {
|
||||||
@@ -50,6 +54,12 @@ pub fn default_bindings() -> Vec<(Action, KeyboardShortcut)> {
|
|||||||
CloseOverlay,
|
CloseOverlay,
|
||||||
KeyboardShortcut::new(Modifiers::NONE, Key::Escape),
|
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),
|
||||||
|
),
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -122,6 +132,9 @@ mod tests {
|
|||||||
Action::ToggleConnect,
|
Action::ToggleConnect,
|
||||||
Action::Clear,
|
Action::Clear,
|
||||||
Action::UiSettings,
|
Action::UiSettings,
|
||||||
|
Action::Search,
|
||||||
|
Action::SearchNext,
|
||||||
|
Action::SearchPrev,
|
||||||
] {
|
] {
|
||||||
assert!(is_char_based(action), "{action:?} should be char-based");
|
assert!(is_char_based(action), "{action:?} should be char-based");
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user