diff --git a/README.md b/README.md index 755fcf0..b26bc6a 100644 --- a/README.md +++ b/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` — 无状态帧解码 +- **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` diff --git a/crates/xserial-gui/src/app.rs b/crates/xserial-gui/src/app.rs index 020f5d7..ff5b53e 100644 --- a/crates/xserial-gui/src/app.rs +++ b/crates/xserial-gui/src/app.rs @@ -55,6 +55,71 @@ pub struct DisplayOptions { pub show_pipeline: bool, } +#[derive(Clone)] +pub struct SearchState { + pub query: String, + pub matches: Vec, + 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 { + // 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, + 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, diff --git a/crates/xserial-gui/src/buffers.rs b/crates/xserial-gui/src/buffers.rs index bf2d23f..691c5f0 100644 --- a/crates/xserial-gui/src/buffers.rs +++ b/crates/xserial-gui/src/buffers.rs @@ -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 { + // (0..self.lines.len()).filter_map(|i| self.lines.get(i)) + // } + + pub fn search(&self, query: &str, case_sensitive: bool) -> Vec { + 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 { + // (0..self.lines.len()).filter_map(|i| self.lines.get(i)) + // } + + pub fn search(&self, query: &str, case_sensitive: bool) -> Vec { + 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 { diff --git a/crates/xserial-gui/src/panels/console.rs b/crates/xserial-gui/src/panels/console.rs index 9e09d60..2bc0750 100644 --- a/crates/xserial-gui/src/panels/console.rs +++ b/crates/xserial-gui/src/panels/console.rs @@ -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), + } } diff --git a/crates/xserial-gui/src/panels/hex_view.rs b/crates/xserial-gui/src/panels/hex_view.rs index 286e207..5909c27 100644 --- a/crates/xserial-gui/src/panels/hex_view.rs +++ b/crates/xserial-gui/src/panels/hex_view.rs @@ -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), + } } + diff --git a/crates/xserial-gui/src/shortcuts.rs b/crates/xserial-gui/src/shortcuts.rs index aaa1b20..1f27a5c 100644 --- a/crates/xserial-gui/src/shortcuts.rs +++ b/crates/xserial-gui/src/shortcuts.rs @@ -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"); }