feat: add search for received data in Text and Hex views
- Add search() method to TextBuffer and HexBuffer - Add SearchState with match tracking, prev/next navigation - Highlight matched text with yellow background via LayoutJob - Search bar UI with case-sensitive toggle and match counter - Keyboard shortcuts: Ctrl+F (open), F3 (next), Shift+F3 (prev) - Auto-focus input on Ctrl+F - Update README with comprehensive project documentation
This commit is contained in:
416
README.md
416
README.md
@@ -1,166 +1,366 @@
|
||||
# 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。
|
||||
- 支持 `Serial`、`TCP`、`UDP` 三种传输方式。
|
||||
- 支持以下 framer:
|
||||
`Line`、`Fixed`、`Length`、`Cobs`、`MixedTextPlot`。
|
||||
- 支持以下 decoder:
|
||||
`Text`、`Hex`、`Plot`、`MixedTextPlot`。
|
||||
- 每个 session 可在 `Text`、`Hex`、`Plot` 三种视图之间切换。
|
||||
- 支持单连接 mixed text + plot 数据流。
|
||||
- 支持 `Interleaved`、`Block`、`XY` 三种 plot 格式。
|
||||
- 可在 sidebar 中直接编辑 session 配置。
|
||||
- plot 视图支持嵌入主界面,也支持在 GUI 内部弹出为浮动窗口。
|
||||
| Crate | 类型 | 职责 |
|
||||
|-------|------|------|
|
||||
| `xserial-core` | library | 传输层、分帧器、协议解码器、Pipeline |
|
||||
| `xserial-client` | library | 会话管理、配置持久化、事件分发、历史缓冲、Lua 运行时 |
|
||||
| `xserial-gui` | binary | egui 桌面应用(主要入口) |
|
||||
| `xserial-tui` | binary | ratatui 终端应用(占位,未达功能对等) |
|
||||
|
||||
依赖方向: `core ← client ← {gui, tui}`
|
||||
|
||||
---
|
||||
|
||||
## 构建
|
||||
|
||||
**前置条件**:
|
||||
|
||||
- **Rust ≥ 1.85**(workspace 使用 `edition = "2024"`)
|
||||
- **Linux**: `libudev-dev`(`serialport` 依赖)
|
||||
- **所有平台**: C 编译器(`mlua` 使用 `vendored` 特性从源码编译 LuaJIT)
|
||||
|
||||
```bash
|
||||
# 编译检查
|
||||
cargo check --workspace
|
||||
|
||||
# 运行测试(~266 个)
|
||||
cargo test --workspace
|
||||
|
||||
# 格式化 + Clippy
|
||||
cargo fmt --all
|
||||
cargo clippy --workspace --all-targets -- -D warnings
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 运行
|
||||
|
||||
启动 GUI:
|
||||
### GUI(主要入口)
|
||||
|
||||
```bash
|
||||
cargo run -p xserial-gui
|
||||
```
|
||||
|
||||
当前 `xserial-tui` 还不是完整实现:
|
||||
### TUI(占位实现)
|
||||
|
||||
```bash
|
||||
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
|
||||
cargo fmt --all
|
||||
cargo clippy --workspace --all-targets -- -D warnings
|
||||
```
|
||||
|
||||
## GUI 快速开始
|
||||
|
||||
1. 启动 `xserial-gui`。
|
||||
2. 在 sidebar 中创建一个 session。
|
||||
3. 选择传输方式:`Serial`、`TCP` 或 `UDP`。
|
||||
4. 添加一个或多个 pipeline,并选择匹配的 framer / decoder。
|
||||
5. 连接 session。
|
||||
6. 在 `Text`、`Hex`、`Plot` 视图之间切换。
|
||||
|
||||
补充说明:
|
||||
|
||||
- session 配置入口在 sidebar 的齿轮按钮。
|
||||
- 连接控制是单个切换按钮,不再区分独立的 `Connect` / `Disconnect`。
|
||||
- plot 视图支持内嵌和浮动窗口两种显示方式。
|
||||
|
||||
## Plot 测试服务器
|
||||
|
||||
仓库自带一个本地波形测试工具:
|
||||
|
||||
```bash
|
||||
python tools/test_plot.py --help
|
||||
```
|
||||
|
||||
示例:通过 TCP 发送 mixed text + plot 数据流:
|
||||
---
|
||||
|
||||
## GUI
|
||||
|
||||
### 面板
|
||||
|
||||
| 面板 | 功能 |
|
||||
|------|------|
|
||||
| **Sidebar** | Session 列表、创建/删除/编辑 session、连接切换 |
|
||||
| **Config** | 传输参数、Pipeline 配置(分帧器 + 解码器类型及参数) |
|
||||
| **Console** | 数据发送输入框,发送按钮 |
|
||||
| **Text View** | 解码后的文本流,支持时间戳和方向标记 |
|
||||
| **Hex View** | 十六进制字节展示,可配置分组和分隔符 |
|
||||
| **Plot View** | 实时波形图,支持内嵌和浮动窗口两种模式 |
|
||||
|
||||
### 快捷键
|
||||
|
||||
| 快捷键 | 操作 |
|
||||
|--------|------|
|
||||
| `Ctrl+Tab` | 下一个 Tab |
|
||||
| `Ctrl+Shift+Tab` | 上一个 Tab |
|
||||
| `Ctrl+T` | 切换到 Text 视图 |
|
||||
| `Ctrl+H` | 切换到 Hex 视图 |
|
||||
| `Ctrl+P` | 切换到 Plot 视图 |
|
||||
| `Ctrl+N` | 新建 Session |
|
||||
| `Ctrl+E` | 编辑当前 Session |
|
||||
| `Ctrl+W` | 删除当前 Session |
|
||||
| `Ctrl+F5` | 切换连接 |
|
||||
| `Ctrl+L` | 清空输出 |
|
||||
| `Ctrl+,` | UI 设置 |
|
||||
| `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
|
||||
# 启动 TCP 波形数据源
|
||||
python tools/test_plot.py --wire-format mixed --host 127.0.0.1 --port 8091
|
||||
```
|
||||
|
||||
GUI 中对应配置为:
|
||||
|
||||
- Transport:`TCP 127.0.0.1:8091`
|
||||
- Framer:`MixedTextPlot`
|
||||
- Decoder:`MixedTextPlot`
|
||||
|
||||
示例:发送 `XY` plot 数据:
|
||||
|
||||
```bash
|
||||
# XY 格式,2 通道
|
||||
python tools/test_plot.py --wire-format mixed --format xy --channels 2
|
||||
```
|
||||
|
||||
示例:发送固定长度 raw plot 帧:
|
||||
|
||||
```bash
|
||||
# 固定长度 raw plot 帧
|
||||
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
|
||||
# 开启性能日志
|
||||
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
|
||||
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/
|
||||
xserial-core/
|
||||
xserial-client/
|
||||
xserial-gui/
|
||||
xserial-tui/
|
||||
tools/
|
||||
test_plot.py
|
||||
xserial-core/ # 传输、分帧、协议解码、Pipeline
|
||||
src/
|
||||
transport/ # Serial, TCP, UDP
|
||||
frame/ # Line, Fixed, Length, Cobs, MixedTextPlot
|
||||
protocol/ # Text, Hex, Plot, MixedTextPlot
|
||||
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`。
|
||||
- 会话管理和状态编排优先放在 `xserial-client`。
|
||||
- UI 相关状态放在 `xserial-gui` 或 `xserial-tui`。
|
||||
- 集成测试位于 `crates/*/tests`。
|
||||
## MixedTextPlot 协议
|
||||
|
||||
## 当前状态
|
||||
单连接上混合传输文本行和波形数据的线格式。
|
||||
|
||||
目前项目中最完整的是 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`
|
||||
|
||||
@@ -55,6 +55,71 @@ pub struct DisplayOptions {
|
||||
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 id: u64,
|
||||
pub session_config: SessionConfig,
|
||||
@@ -71,6 +136,7 @@ pub struct SessionTab {
|
||||
pub send_mode: SendMode,
|
||||
pub append_newline: bool,
|
||||
pub send_status: Option<String>,
|
||||
pub search: SearchState,
|
||||
}
|
||||
|
||||
pub struct XserialApp {
|
||||
@@ -260,6 +326,26 @@ impl XserialApp {
|
||||
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) {
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -302,6 +388,7 @@ impl XserialApp {
|
||||
send_mode: SendMode::Text,
|
||||
append_newline: true,
|
||||
send_status: None,
|
||||
search: SearchState::default(),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -596,6 +683,8 @@ impl XserialApp {
|
||||
});
|
||||
persist_state |= display_changed;
|
||||
|
||||
render_search_bar(ui, tab);
|
||||
|
||||
if let ConnectionStatus::Error(message) = &tab.status {
|
||||
ui.label(egui::RichText::new(message).color(Color32::RED));
|
||||
}
|
||||
@@ -619,11 +708,11 @@ impl XserialApp {
|
||||
let started = Instant::now();
|
||||
match tab.view {
|
||||
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));
|
||||
}
|
||||
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));
|
||||
}
|
||||
View::Plot => {
|
||||
@@ -972,6 +1061,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(
|
||||
ui: &mut egui::Ui,
|
||||
manager: &SessionManager,
|
||||
|
||||
@@ -67,6 +67,32 @@ impl TextBuffer {
|
||||
pub fn set_limit(&mut self, limit: usize) {
|
||||
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)]
|
||||
@@ -155,6 +181,33 @@ impl HexBuffer {
|
||||
pub fn set_limit(&mut self, limit: usize) {
|
||||
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 {
|
||||
|
||||
@@ -1,8 +1,16 @@
|
||||
use crate::app::DisplayOptions;
|
||||
use crate::app::{DisplayOptions, SearchState};
|
||||
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 row_height = ui.text_style_height(&TextStyle::Monospace);
|
||||
ScrollArea::vertical().stick_to_bottom(true).show_rows(
|
||||
@@ -13,7 +21,7 @@ pub fn render(ui: &mut Ui, buf: &TextBuffer, display: DisplayOptions) -> usize {
|
||||
for row in row_range {
|
||||
if let Some(line) = buf.get(row) {
|
||||
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),
|
||||
);
|
||||
}
|
||||
@@ -23,7 +31,59 @@ pub fn render(ui: &mut Ui, buf: &TextBuffer, display: DisplayOptions) -> usize {
|
||||
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(),
|
||||
..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();
|
||||
@@ -49,5 +109,20 @@ fn format_console_line(line: &ConsoleLine, display: DisplayOptions) -> String {
|
||||
} else {
|
||||
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,8 +1,16 @@
|
||||
use crate::app::DisplayOptions;
|
||||
use crate::app::{DisplayOptions, SearchState};
|
||||
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();
|
||||
if line_count == 0 {
|
||||
ui.label("no hex data");
|
||||
@@ -17,7 +25,7 @@ pub fn render(ui: &mut Ui, buf: &HexBuffer, display: DisplayOptions) -> usize {
|
||||
for row in row_range {
|
||||
if let Some(line) = buf.get(row) {
|
||||
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),
|
||||
);
|
||||
}
|
||||
@@ -27,7 +35,59 @@ pub fn render(ui: &mut Ui, buf: &HexBuffer, display: DisplayOptions) -> usize {
|
||||
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(),
|
||||
..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();
|
||||
@@ -53,5 +113,21 @@ fn format_hex_line(line: &HexLine, display: DisplayOptions) -> String {
|
||||
} else {
|
||||
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,
|
||||
CloseOverlay,
|
||||
|
||||
Search,
|
||||
SearchNext,
|
||||
SearchPrev,
|
||||
}
|
||||
|
||||
pub fn default_bindings() -> Vec<(Action, KeyboardShortcut)> {
|
||||
@@ -50,6 +54,12 @@ pub fn default_bindings() -> Vec<(Action, KeyboardShortcut)> {
|
||||
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),
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
@@ -122,6 +132,9 @@ mod tests {
|
||||
Action::ToggleConnect,
|
||||
Action::Clear,
|
||||
Action::UiSettings,
|
||||
Action::Search,
|
||||
Action::SearchNext,
|
||||
Action::SearchPrev,
|
||||
] {
|
||||
assert!(is_char_based(action), "{action:?} should be char-based");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user