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:
2026-06-12 02:42:17 +08:00
parent 9cdbd5a892
commit afb8ca509a
6 changed files with 684 additions and 122 deletions

416
README.md
View File

@@ -1,166 +1,366 @@
# xserial
`xserial` 是一个基于 Rust workspace 的串口 / TCP / UDP 数据观测工具,支持可配置分帧、协议解码、会话管理,以及以文本、十六进制和绘图为核心的桌面 GUI
**跨平台串口 / TCP / UDP 数据观测工具**可配置分帧、协议解码、会话管理,支持文本、十六进制和实时波形绘图。
## 工作区结构
基于 Rust workspaceGUI 使用 [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` (i8f64), `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`

View File

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

View File

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

View File

@@ -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),
}
}

View File

@@ -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),
}
}

View File

@@ -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");
}