From 274539f6267affbcd30634f3ff6f6fb23f3697d5 Mon Sep 17 00:00:00 2001 From: kelsoprotein-lab Date: Tue, 21 Apr 2026 00:29:13 +0800 Subject: [PATCH 01/25] =?UTF-8?q?docs(slave-ui):=20=E8=90=BD=E5=9C=B0=20sp?= =?UTF-8?q?ec=20+=20plan=20+=20gitignore=20=E6=8E=92=E9=99=A4=20brainstorm?= =?UTF-8?q?=20=E4=BA=A7=E7=89=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.7 (1M context) --- .gitignore | 3 + .../plans/2026-04-21-slave-ui-redesign.md | 1220 +++++++++++++++++ .../2026-04-21-slave-ui-redesign-design.md | 163 +++ 3 files changed, 1386 insertions(+) create mode 100644 docs/superpowers/plans/2026-04-21-slave-ui-redesign.md create mode 100644 docs/superpowers/specs/2026-04-21-slave-ui-redesign-design.md diff --git a/.gitignore b/.gitignore index bf049b3..3fa594a 100644 --- a/.gitignore +++ b/.gitignore @@ -28,3 +28,6 @@ Thumbs.db * [0-9].toml * [0-9].rs * [0-9].md + +# Brainstorming session artifacts (mockups, state) +.superpowers/ diff --git a/docs/superpowers/plans/2026-04-21-slave-ui-redesign.md b/docs/superpowers/plans/2026-04-21-slave-ui-redesign.md new file mode 100644 index 0000000..568eab4 --- /dev/null +++ b/docs/superpowers/plans/2026-04-21-slave-ui-redesign.md @@ -0,0 +1,1220 @@ +# 子站 UI 重设计实施计划(B · 工业 HMI 中文版) + +> **For agentic workers:** REQUIRED SUB-SKILL: Use `superpowers:subagent-driven-development` (recommended) or `superpowers:executing-plans` to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 在 `egui-033-shadcn-migration` 已落地的基础上,重设计子站 UI 视觉系统与信息架构:换冷蓝/绿数值 palette、拉开字号梯度、整理操作层级、值解析改为右侧可关闭抽屉、新增底部状态栏。最终消除"布局/字体/颜色都奇怪"的违和感。 + +**Architecture:** 改动集中在两个 crate:`modbussim-ui-shared`(theme/palette/wrappers/log_panel)和 `modbussim-egui`(app.rs 主布局 + 寄存器表格 + 值解析改抽屉 + 状态栏)。不引入新依赖,shadcn 按钮/开关复用现状。所有视觉改动通过 token 集中(theme.rs),组件层只调用 token,不再硬编码颜色/字号。 + +**Tech Stack:** Rust · egui 0.33.3 · egui_extras TableBuilder · egui-shadcn 0.3 · catppuccin-egui(仅作为 palette 容器) + +--- + +## 配色 / 字号 / 间距 Token 速查(贯穿全 Plan) + +```text +Layer::L0 #010409 chrome(菜单栏 / SidePanel / 状态栏 / 日志) +Layer::L1 #0d1117 surface(CentralPanel) +Layer::L2 #161b22 raised(hover / 抽屉卡片) +border_subtle #21262d +border_strong #30363d +accent_primary #1f6feb 选中 / 表头下划线 / focus +accent_fg #58a6ff 表头文字 / 链接文字 +success #3fb950 数值 / RX / 就绪 +warn #f0883e HEX +danger #f85149 删除 hover / 错误 +text_primary #e6edf3 标题 +text_body #c9d1d9 正文 +text_muted #6e7681 地址 / 元信息 +alias #d2a8ff 别名列 + +浅色对应:accent #2563eb · success #15803d · warn #c2410c + L0 #f4f4f5 · L1 #fafafa · L2 #ffffff + +字号:Heading 15 · Body 12.5 · Button 12 · Monospace 12.5 · Small 10.5 +间距:item_spacing (10,6) · button_padding (12,4) · interact_size.y 24 + panel inner_margin (14,12) · 表格行 22 · 表头 26 · 日志行 18 +``` + +--- + +## Task 0: 准备 — gitignore + spec 落盘 + +**Files:** +- Modify: `.gitignore` +- Create: `docs/superpowers/specs/2026-04-21-slave-ui-redesign-design.md` + +- [ ] **Step 0.1: 给 .gitignore 加 brainstorm 产物排除** + +读 `.gitignore`,在末尾追加: +``` +# Brainstorming session artifacts (mockups, state) +.superpowers/ +``` + +- [ ] **Step 0.2: 复制 brainstorm 设计为正式 spec** + +把 `~/.claude/plans/ui-image-1-effervescent-mango.md` 第 1–170 行内容(Context + 现状入口 + 设计决策总览 + 组件级改动)复制到 `docs/superpowers/specs/2026-04-21-slave-ui-redesign-design.md`。开头改第一行标题: + +```markdown +# 子站 UI 重设计 · 工业 HMI 中文版(Spec) +``` + +- [ ] **Step 0.3: commit** + +```bash +git add .gitignore docs/superpowers/specs/2026-04-21-slave-ui-redesign-design.md +git commit -m "docs(slave-ui): 落地 spec + gitignore 排除 brainstorm 产物" +``` + +--- + +## Task 1: theme.rs · palette 与 token 重写 + +**Files:** +- Modify: `crates/modbussim-ui-shared/src/theme.rs` + +设计:当前 `Layer::L0/L1/L2` 颜色弱、accent 是橙、字号梯度平。本任务集中改 palette 三组(背景层 / 语义色 / 字体),并新增 `accent_fg / warn / alias / border_subtle / border_strong` 与 `text::tiny_caps / text::crumb` helper。整体改完启动应用,色调应明显转冷且字号差异明显。 + +- [ ] **Step 1.1: 改 `bg_of` 三层颜色(line 70-84)** + +把函数体替换为: + +```rust +pub fn bg_of(flavor: Flavor, layer: Layer) -> Color32 { + if flavor.is_dark() { + match layer { + Layer::L0 => rgb(0x01, 0x04, 0x09), // #010409 chrome + Layer::L1 => rgb(0x0d, 0x11, 0x17), // #0d1117 surface + Layer::L2 => rgb(0x16, 0x1b, 0x22), // #161b22 raised + } + } else { + match layer { + Layer::L0 => rgb(0xf4, 0xf4, 0xf5), // #f4f4f5 + Layer::L1 => rgb(0xfa, 0xfa, 0xfa), // #fafafa + Layer::L2 => rgb(0xff, 0xff, 0xff), // #ffffff + } + } +} +``` + +- [ ] **Step 1.2: 改 `bg_hover` 与 `bg_selected_row`(line 87-103)** + +```rust +pub fn bg_hover(flavor: Flavor) -> Color32 { + if flavor.is_dark() { + rgb(0x16, 0x1b, 0x22) // = Layer::L2 + } else { + rgb(0xe4, 0xe4, 0xe7) + } +} + +pub fn bg_selected_row(flavor: Flavor) -> Color32 { + if flavor.is_dark() { + // accent.primary @ 15% alpha → 解多重不蒙底色 + Color32::from_rgba_unmultiplied(0x1f, 0x6f, 0xeb, 0x26) + } else { + Color32::from_rgba_unmultiplied(0x25, 0x63, 0xeb, 0x1a) + } +} +``` + +- [ ] **Step 1.3: 改语义色 helper(line 314-337)** + +替换 `accent / success / danger / subtext`,新增 `accent_fg / warn / alias / border_subtle / border_strong / text_primary / text_body / text_muted`: + +```rust +pub fn accent(flavor: Flavor) -> Color32 { + if flavor.is_dark() { rgb(0x1f, 0x6f, 0xeb) } else { rgb(0x25, 0x63, 0xeb) } +} +pub fn accent_fg(flavor: Flavor) -> Color32 { + if flavor.is_dark() { rgb(0x58, 0xa6, 0xff) } else { rgb(0x3b, 0x82, 0xf6) } +} +pub fn success(flavor: Flavor) -> Color32 { + if flavor.is_dark() { rgb(0x3f, 0xb9, 0x50) } else { rgb(0x15, 0x80, 0x3d) } +} +pub fn warn(flavor: Flavor) -> Color32 { + if flavor.is_dark() { rgb(0xf0, 0x88, 0x3e) } else { rgb(0xc2, 0x41, 0x0c) } +} +pub fn danger(flavor: Flavor) -> Color32 { + if flavor.is_dark() { rgb(0xf8, 0x51, 0x49) } else { rgb(0xb9, 0x1c, 0x1c) } +} +pub fn alias(flavor: Flavor) -> Color32 { + if flavor.is_dark() { rgb(0xd2, 0xa8, 0xff) } else { rgb(0x7c, 0x3a, 0xed) } +} +pub fn border_subtle(flavor: Flavor) -> Color32 { + if flavor.is_dark() { rgb(0x21, 0x26, 0x2d) } else { rgb(0xe4, 0xe4, 0xe7) } +} +pub fn border_strong(flavor: Flavor) -> Color32 { + if flavor.is_dark() { rgb(0x30, 0x36, 0x3d) } else { rgb(0xd4, 0xd4, 0xd8) } +} +pub fn text_primary(flavor: Flavor) -> Color32 { + if flavor.is_dark() { rgb(0xe6, 0xed, 0xf3) } else { rgb(0x09, 0x09, 0x0b) } +} +pub fn text_body(flavor: Flavor) -> Color32 { + if flavor.is_dark() { rgb(0xc9, 0xd1, 0xd9) } else { rgb(0x3f, 0x3f, 0x46) } +} +pub fn text_muted(flavor: Flavor) -> Color32 { + if flavor.is_dark() { rgb(0x6e, 0x76, 0x81) } else { rgb(0x71, 0x71, 0x7a) } +} +pub fn subtext(flavor: Flavor) -> Color32 { text_muted(flavor) } // 旧调用点回退 +pub fn surface(flavor: Flavor) -> Color32 { bg_of(flavor, Layer::L2) } // 旧调用点回退 +``` + +- [ ] **Step 1.4: 改 `apply` 内深色分支 visuals(line 188-230)** + +把现有深色 `s.style_mut(...)` 块整段替换为新 token 驱动版本: + +```rust +if flavor.is_dark() { + let panel = bg_of(flavor, Layer::L1); // #0d1117 + let panel_alt = bg_of(flavor, Layer::L0); // #010409 + let raised = bg_of(flavor, Layer::L2); // #161b22 + let stroke = border_strong(flavor); // #30363d + let stroke_soft = border_subtle(flavor); // #21262d + let fg = text_body(flavor); // #c9d1d9 + let strong_fg = text_primary(flavor); // #e6edf3 + let sel_bg = bg_selected_row(flavor); + let acc = accent(flavor); // #1f6feb + s.visuals.panel_fill = panel; + s.visuals.window_fill = panel_alt; + s.visuals.extreme_bg_color = panel_alt; + s.visuals.faint_bg_color = raised; + s.visuals.code_bg_color = raised; + s.visuals.widgets.noninteractive.bg_fill = panel_alt; + s.visuals.widgets.noninteractive.weak_bg_fill = panel; + s.visuals.widgets.noninteractive.bg_stroke.color = stroke_soft; + s.visuals.widgets.noninteractive.fg_stroke.color = fg; + s.visuals.widgets.inactive.bg_fill = raised; + s.visuals.widgets.inactive.weak_bg_fill = panel_alt; + s.visuals.widgets.inactive.bg_stroke.color = stroke; + s.visuals.widgets.inactive.fg_stroke.color = fg; + s.visuals.widgets.hovered.bg_fill = bg_hover(flavor); + s.visuals.widgets.hovered.bg_stroke.color = bg_hover(flavor); + s.visuals.widgets.hovered.fg_stroke.color = strong_fg; + s.visuals.widgets.active.bg_fill = acc; + s.visuals.widgets.active.bg_stroke.color = acc; + s.visuals.widgets.active.fg_stroke.color = Color32::WHITE; + s.visuals.widgets.open.bg_fill = raised; + s.visuals.window_stroke.color = stroke_soft; + s.visuals.selection.bg_fill = sel_bg; + s.visuals.selection.stroke.color = acc; + s.visuals.override_text_color = Some(fg); + s.visuals.hyperlink_color = accent_fg(flavor); + s.visuals.error_fg_color = danger(flavor); + s.visuals.warn_fg_color = warn(flavor); +} +``` + +- [ ] **Step 1.5: 改浅色分支(line 231-266)** + +类似深色的 token 驱动改写: + +```rust +} else { + let panel = bg_of(flavor, Layer::L1); + let panel_alt = bg_of(flavor, Layer::L0); + let raised = bg_of(flavor, Layer::L2); + let stroke = border_strong(flavor); + let stroke_soft = border_subtle(flavor); + let fg = text_body(flavor); + let strong_fg = text_primary(flavor); + let sel_bg = bg_selected_row(flavor); + let acc = accent(flavor); + s.visuals.panel_fill = panel; + s.visuals.window_fill = raised; + s.visuals.extreme_bg_color = raised; + s.visuals.faint_bg_color = panel; + s.visuals.code_bg_color = panel; + s.visuals.widgets.noninteractive.bg_fill = panel; + s.visuals.widgets.noninteractive.weak_bg_fill = panel; + s.visuals.widgets.noninteractive.bg_stroke.color = stroke_soft; + s.visuals.widgets.noninteractive.fg_stroke.color = fg; + s.visuals.widgets.inactive.bg_fill = raised; + s.visuals.widgets.inactive.weak_bg_fill = panel; + s.visuals.widgets.inactive.bg_stroke.color = stroke; + s.visuals.widgets.inactive.fg_stroke.color = fg; + s.visuals.widgets.hovered.bg_fill = bg_hover(flavor); + s.visuals.widgets.hovered.bg_stroke.color = bg_hover(flavor); + s.visuals.widgets.hovered.fg_stroke.color = strong_fg; + s.visuals.widgets.active.bg_fill = acc; + s.visuals.widgets.active.bg_stroke.color = acc; + s.visuals.widgets.active.fg_stroke.color = Color32::WHITE; + s.visuals.widgets.open.bg_fill = raised; + s.visuals.window_stroke.color = stroke_soft; + s.visuals.selection.bg_fill = sel_bg; + s.visuals.selection.stroke.color = acc; + s.visuals.override_text_color = Some(fg); + s.visuals.hyperlink_color = accent_fg(flavor); + s.visuals.error_fg_color = danger(flavor); + s.visuals.warn_fg_color = warn(flavor); +} +``` + +- [ ] **Step 1.6: 改 spacing + 字号(line 269-309)** + +把第二个 `ctx.style_mut` 块内的 spacing + TextStyle 替换为: + +```rust +ctx.style_mut(|s| { + s.spacing.item_spacing = egui::vec2(10.0, 6.0); + s.spacing.button_padding = egui::vec2(12.0, 4.0); + s.spacing.menu_margin = egui::Margin::symmetric(8.0 as i8, 5.0 as i8); + s.spacing.indent = 14.0; + s.spacing.interact_size.y = 24.0; + + let r: egui::Rounding = 4.0.into(); + s.visuals.widgets.noninteractive.corner_radius = r; + s.visuals.widgets.inactive.corner_radius = r; + s.visuals.widgets.hovered.corner_radius = r; + s.visuals.widgets.active.corner_radius = r; + s.visuals.widgets.open.corner_radius = r; + s.visuals.window_corner_radius = 6.0.into(); + s.visuals.menu_corner_radius = 6.0.into(); + + use egui::TextStyle::*; + s.text_styles.insert(Heading, egui::FontId::new(15.0, egui::FontFamily::Proportional)); + s.text_styles.insert(Body, egui::FontId::new(12.5, egui::FontFamily::Proportional)); + s.text_styles.insert(Button, egui::FontId::new(12.0, egui::FontFamily::Proportional)); + s.text_styles.insert(Monospace, egui::FontId::new(12.5, egui::FontFamily::Monospace)); + s.text_styles.insert(Small, egui::FontId::new(10.5, egui::FontFamily::Proportional)); +}); +``` + +- [ ] **Step 1.7: 在文件末尾新增 `text` 子模块** + +```rust +/// 文本渲染辅助:tiny_caps / crumb 等语义文本样式。 +pub mod text { + use super::{Flavor, text_muted, accent_fg}; + use egui::{Ui, RichText}; + + /// 表头 / 分组标题用:10.5px 大写、字距感由空格 + 字色弱化体现。 + pub fn tiny_caps(ui: &mut Ui, flavor: Flavor, s: &str) { + ui.label( + RichText::new(s.to_uppercase()) + .size(10.5) + .color(accent_fg(flavor)) + .strong(), + ); + } + + /// 面包屑 / 元信息:11px、muted。 + pub fn crumb(ui: &mut Ui, flavor: Flavor, s: &str) { + ui.label(RichText::new(s).size(11.0).color(text_muted(flavor))); + } +} +``` + +- [ ] **Step 1.8: 编译 + 烟测** + +```bash +cargo check -p modbussim-ui-shared +cargo clippy -p modbussim-ui-shared --no-deps -- -D warnings +``` + +预期:通过(`subtext` / `surface` 旧调用点已通过 helper 回退兼容)。 + +```bash +cargo run -p modbussim-egui +``` + +预期视觉:背景明显转冷(深蓝黑),字号差异更明显(标题 vs 表头),所有按钮立即变蓝(来自 selection.bg_fill)。**注意**:值解析、表格、按钮颜色还要等 Task 2/5 才完全到位。 + +- [ ] **Step 1.9: commit** + +```bash +git add crates/modbussim-ui-shared/src/theme.rs +git commit -m "feat(theme): 切换冷蓝 palette + 拉开字号梯度 + 新增语义色 token" +``` + +--- + +## Task 2: ui.rs · shadcn 同步 + helper + +**Files:** +- Modify: `crates/modbussim-ui-shared/src/ui.rs` + +- [ ] **Step 2.1: 改 `card_colors` 用 Layer token(line 11-25)** + +```rust +fn card_colors(flavor: Flavor) -> (Color32, Color32) { + (theme::bg_of(flavor, Layer::L2), theme::border_subtle(flavor)) +} +``` + +- [ ] **Step 2.2: 改 `card` / `accent_card` 内边距(line 32-88)** + +`card` 与 `accent_card` 的 `inner_margin` 改成统一 `(14, 12)`: + +```rust +.inner_margin(egui::Margin::symmetric(14.0 as i8, 12.0 as i8)) +``` + +`card` 与 `accent_card` 的 `corner_radius(2.0)` 改成 `4.0`,与全局 rounding 一致。 + +- [ ] **Step 2.3: 改 `shadcn_theme` palette 为冷蓝(line 102-128)** + +```rust +if flavor.is_dark() { + palette.primary = Color32::from_rgb(0x1f, 0x6f, 0xeb); + palette.primary_foreground = Color32::WHITE; + palette.destructive = Color32::from_rgb(0xf8, 0x51, 0x49); + palette.destructive_foreground = Color32::WHITE; + palette.ring = Color32::from_rgb(0x1f, 0x6f, 0xeb); + palette.border = Color32::from_rgb(0x30, 0x36, 0x3d); + palette.background = Color32::from_rgb(0x0d, 0x11, 0x17); + palette.foreground = Color32::from_rgb(0xc9, 0xd1, 0xd9); + palette.muted_foreground = Color32::from_rgb(0x6e, 0x76, 0x81); + palette.accent = Color32::from_rgb(0x3f, 0xb9, 0x50); // success 绿用作辅 accent("+ 批量添加") + palette.accent_foreground = Color32::WHITE; +} else { + palette.primary = Color32::from_rgb(0x25, 0x63, 0xeb); + palette.primary_foreground = Color32::WHITE; + palette.destructive = Color32::from_rgb(0xb9, 0x1c, 0x1c); + palette.destructive_foreground = Color32::WHITE; + palette.ring = Color32::from_rgb(0x25, 0x63, 0xeb); + palette.border = Color32::from_rgb(0xd4, 0xd4, 0xd8); + palette.background = Color32::from_rgb(0xfa, 0xfa, 0xfa); + palette.foreground = Color32::from_rgb(0x3f, 0x3f, 0x46); + palette.muted_foreground = Color32::from_rgb(0x71, 0x71, 0x7a); + palette.accent = Color32::from_rgb(0x15, 0x80, 0x3d); + palette.accent_foreground = Color32::WHITE; +} +``` + +- [ ] **Step 2.4: `primary_button` 改用 Accent 变体(绿色"+ 批量添加"语义)** + +把 `primary_button` 内的 `ControlVariant::Primary` 改为 `ControlVariant::Accent`(已在 palette.accent 设为绿色),`secondary_button` / `danger_button` / `icon_button` 保持原变体。 + +```rust +pub fn primary_button(ui: &mut Ui, flavor: Flavor, text: impl Into) -> Response { + let theme = shadcn_theme(flavor); + egui_shadcn::button( + ui, &theme, text.into(), + egui_shadcn::tokens::ControlVariant::Accent, // ← 改 + egui_shadcn::tokens::ControlSize::Md, true, + ) +} +``` + +> 若 egui-shadcn 0.3 没有 `Accent` 变体,回退方案:保留 `Primary` + 把 `palette.primary` 设为绿色 `#3fb950`,把"链接/选中"用 `palette.accent` 蓝色。先按 Accent 写,编译失败时回退。 + +- [ ] **Step 2.5: 在文件末尾新增 `panel_header` 与 `link_action`** + +```rust +/// 主区头部:上行 Heading 标题 + 下行 muted 面包屑。 +pub fn panel_header(ui: &mut Ui, flavor: Flavor, title: &str, crumb: Option<&str>) { + ui.vertical(|ui| { + ui.label(RichText::new(title).heading().color(theme::text_primary(flavor))); + if let Some(c) = crumb { + theme::text::crumb(ui, flavor, c); + } + }); +} + +/// 无边框文字操作(停止 / 删除连接 / 关闭)。hover 变 accent 或 danger。 +pub fn link_action(ui: &mut Ui, flavor: Flavor, label: &str, danger: bool) -> Response { + let base = theme::text_muted(flavor); + let hover = if danger { theme::danger(flavor) } else { theme::accent_fg(flavor) }; + let resp = ui.add(egui::Label::new(RichText::new(label).color(base).size(11.5)).sense(egui::Sense::click())); + if resp.hovered() { + let painter = ui.painter(); + painter.text( + resp.rect.left_center(), + egui::Align2::LEFT_CENTER, + label, + egui::FontId::proportional(11.5), + hover, + ); + } + resp +} +``` + +- [ ] **Step 2.6: 编译 + 视觉验证** + +```bash +cargo check -p modbussim-ui-shared +cargo clippy -p modbussim-ui-shared --no-deps -- -D warnings +cargo run -p modbussim-egui +``` + +预期:FC01 toggle 仍正常;批量添加按钮变绿色;停止/删除/导出按钮 outline 蓝边。 + +- [ ] **Step 2.7: commit** + +```bash +git add crates/modbussim-ui-shared/src/ui.rs +git commit -m "feat(ui-shared): shadcn palette 转冷蓝 + 新增 panel_header/link_action" +``` + +--- + +## Task 3: log_panel.rs · 单行 header + 折叠 + 箭头方向 + +**Files:** +- Modify: `crates/modbussim-ui-shared/src/log_panel.rs` + +- [ ] **Step 3.1: 给 LogPanelState 加 `collapsed` 字段(line 8-30)** + +```rust +pub struct LogPanelState { + pub open: bool, + pub collapsed: bool, + pub show_rx: bool, + pub show_tx: bool, + pub filter_text: String, +} + +impl LogPanelState { + pub fn new() -> Self { + Self { + open: true, + collapsed: false, + show_rx: true, + show_tx: true, + filter_text: String::new(), + } + } +} +``` + +- [ ] **Step 3.2: 整合 header 为单行(line 80-107)** + +把 `.show(ctx, |ui| { ... })` 内的两段 `ui.horizontal(...)` 整合为一行: + +```rust +.show(ctx, |ui| { + ui.horizontal(|ui| { + let chev = if state.collapsed { "▶" } else { "▼" }; + if ui.add(egui::Label::new(RichText::new(chev).size(11.0)).sense(egui::Sense::click())).clicked() { + state.collapsed = !state.collapsed; + } + ui.label(RichText::new("通信日志").strong().size(12.5)); + if let Some(label) = conn_label { + crate::theme::text::crumb(ui, flavor, &format!("· {} · {} 条", label, cache.len())); + } else { + crate::theme::text::crumb(ui, flavor, "· 选中连接以查看"); + } + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if crate::ui::link_action(ui, flavor, "关闭", false).clicked() { + action = LogPanelAction::Close; + } + if crate::ui::link_action(ui, flavor, "导出 CSV", false).clicked() { + action = LogPanelAction::Export; + } + if crate::ui::link_action(ui, flavor, "清空", false).clicked() { + action = LogPanelAction::Clear; + } + ui.add( + egui::TextEdit::singleline(&mut state.filter_text) + .hint_text("过滤…") + .desired_width(160.0), + ); + ui.checkbox(&mut state.show_tx, "TX"); + ui.checkbox(&mut state.show_rx, "RX"); + }); + }); + if state.collapsed { return; } + ui.add_space(6.0); + // 接 TableBuilder 段(保持不变) + let entries: Vec<&LogEntry> = cache.iter().rev().filter(|e| accepts(state, e)).collect(); + // ... +}); +``` + +- [ ] **Step 3.3: 改方向列宽 + 箭头符号(line 113-141)** + +```rust +TableBuilder::new(ui) + .striped(false) // ← 关掉,与 hover 冲突 + .resizable(true) + .cell_layout(egui::Layout::left_to_right(egui::Align::Center)) + .column(Column::exact(150.0)) + .column(Column::exact(28.0)) // ← 方向列收窄 + .column(Column::exact(60.0)) + .column(Column::remainder()) + .header(22.0, |mut h| { + h.col(|ui| crate::theme::text::tiny_caps(ui, flavor, "时间")); + h.col(|ui| crate::theme::text::tiny_caps(ui, flavor, "向")); + h.col(|ui| crate::theme::text::tiny_caps(ui, flavor, "FC")); + h.col(|ui| crate::theme::text::tiny_caps(ui, flavor, "详情")); + }) + .body(|body| { + body.rows(18.0, entries.len(), |mut row| { + let e = entries[row.index()]; + row.col(|ui| { + ui.add(egui::Label::new( + RichText::new(e.timestamp.format("%H:%M:%S%.3f").to_string()) + .monospace().color(crate::theme::text_muted(flavor)) + )); + }); + row.col(|ui| { + let (sym, c) = match e.direction { + Direction::Rx => ("←", crate::theme::success(flavor)), + Direction::Tx => ("→", crate::theme::accent_fg(flavor)), + }; + ui.add(egui::Label::new(RichText::new(sym).color(c).strong().monospace())); + }); + row.col(|ui| { + ui.add(egui::Label::new( + RichText::new(e.function_code.name()) + .monospace().color(crate::theme::warn(flavor)) + )); + }); + row.col(|ui| { + ui.add(egui::Label::new( + RichText::new(&e.detail).monospace().color(crate::theme::text_body(flavor)) + )); + }); + }); + }); +``` + +- [ ] **Step 3.4: 编译 + 视觉验证** + +```bash +cargo check -p modbussim-ui-shared +cargo clippy -p modbussim-ui-shared --no-deps -- -D warnings +cargo run -p modbussim-egui +``` + +预期:日志面板 header 一行排开,可以点 ▼ 折叠;方向列只剩 ← / → 符号;FC 列橙色。 + +- [ ] **Step 3.5: commit** + +```bash +git add crates/modbussim-ui-shared/src/log_panel.rs +git commit -m "feat(log-panel): 单行 header + 可折叠 + RX/TX 改箭头符号" +``` + +--- + +## Task 4: app.rs · 左侧 SidePanel 重构 + +**Files:** +- Modify: `crates/modbussim-egui/src/app.rs`(SidePanel 块约 line 2798-2860) + +- [ ] **Step 4.1: 改 SidePanel 宽度与边距(line 2798-2805)** + +把 `egui::SidePanel::left("connections")` 配置改为: + +```rust +egui::SidePanel::left("connections") + .resizable(true) + .default_width(240.0) + .min_width(200.0) + .show_separator_line(false) + .frame( + egui::Frame::none() + .fill(theme::bg_of(self.flavor, theme::Layer::L0)) + .inner_margin(egui::Margin::symmetric(0, 0)), + ) + .show(ctx, |ui| { /* 见下三步 */ }); +``` + +- [ ] **Step 4.2: 改 SidePanel 内部为「头 / 树 / footer」三段** + +把 SidePanel `.show(ctx, |ui| { ... })` 闭包内(替换原"新建 TCP 连接"行 + 现有树渲染)改写为: + +```rust +ui.allocate_ui_with_layout( + ui.available_size(), + egui::Layout::top_down(egui::Align::Min), + |ui| { + // —— 头部:tiny_caps "连接" + 右上 + 新建 —— + egui::Frame::none() + .inner_margin(egui::Margin { left: 14, right: 10, top: 12, bottom: 8 }) + .show(ui, |ui| { + ui.horizontal(|ui| { + crate::ui_shared::theme::text::tiny_caps(ui, self.flavor, "连接"); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if crate::ui_shared::ui::link_action(ui, self.flavor, "+ 新建", false).clicked() { + self.show_new_tcp_dialog = true; + } + }); + }); + }); + + // —— 树:可滚动区 —— + egui::ScrollArea::vertical() + .auto_shrink([false, false]) + .max_height(ui.available_height() - 40.0) // 给 footer 留空间 + .show(ui, |ui| { + egui::Frame::none() + .inner_margin(egui::Margin::symmetric(8, 0)) + .show(ui, |ui| { + self.render_connection_tree(ui); // 现有树渲染抽出,见 Step 4.3 + }); + }); + + // —— footer:停止 / 删除连接 —— + ui.with_layout(egui::Layout::bottom_up(egui::Align::Min), |ui| { + egui::Frame::none() + .fill(theme::bg_of(self.flavor, theme::Layer::L0)) + .inner_margin(egui::Margin { left: 14, right: 14, top: 8, bottom: 10 }) + .stroke(egui::Stroke::new(1.0, theme::border_subtle(self.flavor))) + .show(ui, |ui| { + ui.horizontal(|ui| { + if let Some(active) = self.active_connection_id() { + if crate::ui_shared::ui::link_action(ui, self.flavor, "停止", false).clicked() { + self.stop_connection(active); + } + ui.add_space(14.0); + if crate::ui_shared::ui::link_action(ui, self.flavor, "删除连接", true).clicked() { + self.request_delete_connection(active); + } + } + }); + }); + }); + }, +); +``` + +> 实施者注:`self.active_connection_id()` / `self.stop_connection(...)` / `self.request_delete_connection(...)` / `self.show_new_tcp_dialog` 这些方法字段在当前 app.rs 散在各处。如有缺失,补一个 helper:扫描 `self.connections`,找 `running` 的第一个 / 用户选中的那个;删除走现有 "删除"按钮的同一路径。 + +- [ ] **Step 4.3: 抽出 `render_connection_tree(&mut self, ui: &mut Ui)`** + +把现有 SidePanel 内树渲染逻辑(每个节点 `SelectableLabel` 那段)抽到一个新 method。在节点渲染处把激活态样式改为: + +```rust +let is_active = matches!(self.selection, Selection::FunctionCode { .. } /* 同上 */); +let row_resp = ui.allocate_response(egui::vec2(ui.available_width(), 22.0), egui::Sense::click()); +if is_active { + let acc = theme::accent(self.flavor); + let stripe_rect = egui::Rect::from_min_size( + row_resp.rect.left_top(), egui::vec2(2.0, row_resp.rect.height()) + ); + ui.painter().rect_filled(stripe_rect, 0.0, acc); + ui.painter().rect_filled( + row_resp.rect.expand2(egui::vec2(0.0, 0.0)).translate(egui::vec2(2.0, 0.0)), + 0.0, + Color32::from_rgba_unmultiplied(0x1f, 0x6f, 0xeb, 0x26), + ); +} +let painter = ui.painter(); +let label_color = if is_active { theme::accent_fg(self.flavor) } else { theme::text_body(self.flavor) }; +let weight = if is_active { egui::FontId::new(12.5, egui::FontFamily::Proportional) } else { egui::FontId::new(12.5, egui::FontFamily::Proportional) }; +painter.text( + row_resp.rect.left_center() + egui::vec2(10.0 + indent_px, 0.0), + egui::Align2::LEFT_CENTER, + node_label, + weight, + label_color, +); +// 节点右侧 badge(行数):painter.text(...) muted +``` + +> 节点细节较多(折叠箭头 / icon / hover 背景)。保留现有交互逻辑,仅替换样式段。 + +- [ ] **Step 4.4: 编译 + 视觉验证** + +```bash +cargo check -p modbussim-egui +cargo clippy -p modbussim-egui --no-deps -- -D warnings +cargo run -p modbussim-egui +``` + +预期:左侧栏 240px 宽;顶部"连接"小大写 + 右侧 + 新建链接;激活节点左侧蓝竖条 + 蓝半透明背景 + 字色 #58a6ff;底部 footer "停止 / 删除连接" 灰文字、悬停变蓝/红。 + +- [ ] **Step 4.5: commit** + +```bash +git add crates/modbussim-egui/src/app.rs +git commit -m "feat(slave-app): SidePanel 重构 — 240px + 头/树/footer 三段" +``` + +--- + +## Task 5: app.rs · 主区头 + 工具栏 + 寄存器表格 + +**Files:** +- Modify: `crates/modbussim-egui/src/app.rs`(CentralPanel 块 line 2865+ 与 TableBuilder line 2315-2462) + +- [ ] **Step 5.1: 改 CentralPanel frame + 主区头** + +把 CentralPanel 块改为: + +```rust +egui::CentralPanel::default() + .frame( + egui::Frame::none() + .fill(theme::bg_of(self.flavor, theme::Layer::L1)) + .inner_margin(egui::Margin { left: 18, right: 18, top: 14, bottom: 0 }), + ) + .show(ctx, |ui| { + // —— 主区头 —— + ui.horizontal(|ui| { + crate::ui_shared::ui::panel_header( + ui, + self.flavor, + &self.current_view_title(), // "FC03 保持寄存器" + self.current_view_crumb().as_deref(), // Some("slave_1 · 20001 行") + ); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if crate::ui_shared::ui::primary_button(ui, self.flavor, "+ 批量添加").clicked() { + self.open_batch_add_dialog(); + } + ui.add( + egui::TextEdit::singleline(&mut self.search_query) + .hint_text("搜索地址 / 别名…") + .desired_width(220.0), + ); + let icon = if self.value_parse_open { "◧ 收起解析" } else { "◧ 值解析" }; + if crate::ui_shared::ui::link_action(ui, self.flavor, icon, false).clicked() { + self.value_parse_open = !self.value_parse_open; + } + }); + }); + ui.add_space(10.0); + + // —— 工具栏(fmt-pill + 已选行 + 次操作)—— + self.render_register_toolbar(ui); + ui.add_space(6.0); + + // —— 表格 —— (复用现有 self.render_register_table,下面 Step 5.3 改样式) + self.render_register_table(ui); + }); +``` + +> `self.current_view_title()` / `self.current_view_crumb()` / `self.search_query` / `self.value_parse_open` / `self.open_batch_add_dialog()` 等若不存在则按需要在 struct 里加 `value_parse_open: bool`、`search_query: String` 字段(默认 false / 空),title/crumb 写两个 helper 根据 `self.selection` 返回相应字符串。 + +- [ ] **Step 5.2: 新增 `render_register_toolbar(&mut self, ui)`** + +```rust +fn render_register_toolbar(&mut self, ui: &mut egui::Ui) { + ui.horizontal(|ui| { + crate::ui_shared::theme::text::tiny_caps(ui, self.flavor, "格式"); + // fmt-pill + egui::Frame::new() + .fill(theme::bg_of(self.flavor, theme::Layer::L2)) + .stroke(egui::Stroke::new(1.0, theme::border_strong(self.flavor))) + .corner_radius(12.0) + .inner_margin(egui::Margin::symmetric(10.0 as i8, 2.0 as i8)) + .show(ui, |ui| { + egui::ComboBox::from_id_salt("fmt_pill") + .selected_text( + egui::RichText::new(self.fmt.label()) + .color(theme::accent_fg(self.flavor)) + .monospace().size(11.5), + ) + .show_ui(ui, |ui| { + for f in [Fmt::U16, Fmt::I16, Fmt::Hex, Fmt::Bin] { + ui.selectable_value(&mut self.fmt, f, f.label()); + } + }); + }); + + ui.add_space(12.0); + crate::ui_shared::theme::text::crumb( + ui, + self.flavor, + &format!("已选 {} 行", self.selected_rows.len()), + ); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if crate::ui_shared::ui::link_action(ui, self.flavor, "清零", false).clicked() { + self.clear_selected_rows(); + } + if crate::ui_shared::ui::link_action(ui, self.flavor, "导出", false).clicked() { + self.export_register_csv(); + } + }); + }); +} +``` + +- [ ] **Step 5.3: 改 TableBuilder 列宽 + 表头(line 2315-2330)** + +把现有 TableBuilder 链替换: + +```rust +TableBuilder::new(ui) + .striped(false) + .resizable(true) + .max_scroll_height(avail_h) + .cell_layout(egui::Layout::left_to_right(egui::Align::Center)) + .column(Column::exact(80.0)) // 地址(右对齐渲染) + .column(Column::exact(120.0)) // 别名 + .column(Column::exact(100.0)) // 值 + .column(Column::exact(80.0)) // HEX + .column(Column::remainder().at_least(180.0)) // 二进制 + .header(26.0, |mut h| { + h.col(|ui| crate::ui_shared::theme::text::tiny_caps(ui, self.flavor, "地址")); + h.col(|ui| crate::ui_shared::theme::text::tiny_caps(ui, self.flavor, "别名")); + h.col(|ui| crate::ui_shared::theme::text::tiny_caps(ui, self.flavor, "U16")); + h.col(|ui| crate::ui_shared::theme::text::tiny_caps(ui, self.flavor, "HEX")); + h.col(|ui| crate::ui_shared::theme::text::tiny_caps(ui, self.flavor, "二进制")); + }) + .body(|body| { + body.rows(22.0, group_rows, |mut row| { + let idx = row.index(); + let addr = base_addr + idx as u16; + let val = self.regs[idx]; + let is_sel = self.selected_rows.contains(&idx); + + row.col(|ui| { + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + ui.add(egui::Label::new( + egui::RichText::new(addr.to_string()) + .monospace() + .color(theme::text_muted(self.flavor)), + )); + }); + }); + row.col(|ui| { + let alias = self.alias_of(addr).unwrap_or("—"); + let color = if alias == "—" { theme::text_muted(self.flavor) } else { theme::alias(self.flavor) }; + ui.add(egui::Label::new(egui::RichText::new(alias).color(color).monospace())); + }); + row.col(|ui| { + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if is_sel { + // 编辑态:DragValue / TextEdit + let mut v = val; + if ui.add(egui::DragValue::new(&mut v).range(0..=u16::MAX)).changed() { + self.set_register(addr, v); + } + } else { + ui.add(egui::Label::new( + egui::RichText::new(val.to_string()) + .color(theme::success(self.flavor)) + .monospace().strong(), + )); + } + }); + }); + row.col(|ui| { + ui.add(egui::Label::new( + egui::RichText::new(format!("0x{:04X}", val)) + .color(theme::warn(self.flavor)).monospace(), + )); + }); + row.col(|ui| { + ui.add(egui::Label::new( + egui::RichText::new(format!("{:016b}", val)) + .color(theme::text_muted(self.flavor)).monospace().size(11.0), + )); + }); + + // 行选中底色 / hover 底色 + let row_rect = row.response().rect; + let painter = ui.painter(); + if is_sel { + painter.rect_filled(row_rect, 0.0, theme::bg_selected_row(self.flavor)); + } else if row.response().hovered() { + painter.rect_filled(row_rect, 0.0, theme::bg_hover(self.flavor)); + } + }); + }); +``` + +> 别名/选中编辑态/行 hover 等若现有 app 内已有逻辑,保留逻辑只换样式。`self.alias_of` / `self.set_register` 同名 fn 若不存在按现有数据模型 inline。 + +- [ ] **Step 5.4: 表头下划线 2px 蓝** + +在 `.header(26.0, ...)` 闭包末尾或外层 frame 中,用 `ui.painter().line_segment` 在 header 下边画一条 2px `theme::accent(self.flavor)` 横线。最稳妥放法:在 TableBuilder 调用前先 `ui.allocate_painter` 占住表头底边那 2px 区域,或者在 header 的最后一列 `h.col` 内 `ui.painter().line_segment(...)` 跨整行宽度(用 `ui.max_rect()` 取宽)。 + +- [ ] **Step 5.5: 编译 + 视觉验证** + +```bash +cargo check -p modbussim-egui +cargo clippy -p modbussim-egui --no-deps -- -D warnings +cargo run -p modbussim-egui +``` + +预期:表格地址右对齐 muted、别名紫、数值右对齐绿粗、HEX 橙、二进制 muted;选中行 15% alpha 蓝底;hover 行 raised 色;表头小大写蓝字 + 下方 2px 蓝线。 + +- [ ] **Step 5.6: commit** + +```bash +git add crates/modbussim-egui/src/app.rs +git commit -m "feat(slave-app): 主区头 + fmt-pill 工具栏 + 寄存器表格语义化色彩" +``` + +--- + +## Task 6: app.rs · 值解析抽屉 + 状态栏 + 快捷键 + +**Files:** +- Modify: `crates/modbussim-egui/src/app.rs` + +- [ ] **Step 6.1: app struct 加状态字段** + +找到主 App struct 定义,加: + +```rust +pub value_parse_open: bool, +pub log_collapsed_persist: bool, // 持久化日志折叠(可选) +pub search_query: String, +``` + +`Default` impl 里都设为初始值(false / "")。 + +- [ ] **Step 6.2: 在 CentralPanel 之前插入右侧 SidePanel 抽屉** + +```rust +egui::SidePanel::right("value_parse") + .resizable(true) + .default_width(240.0) + .min_width(200.0) + .show_separator_line(false) + .frame( + egui::Frame::none() + .fill(theme::bg_of(self.flavor, theme::Layer::L0)) + .inner_margin(egui::Margin { left: 14, right: 14, top: 12, bottom: 12 }), + ) + .show_animated(ctx, self.value_parse_open, |ui| { + ui.horizontal(|ui| { + crate::ui_shared::theme::text::tiny_caps(ui, self.flavor, "值解析"); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if crate::ui_shared::ui::link_action(ui, self.flavor, "×", false).clicked() { + self.value_parse_open = false; + } + }); + }); + ui.add_space(8.0); + if let Some(parse) = self.compute_value_parse() { + self.render_value_parse_grid(ui, &parse); + } else { + crate::ui_shared::theme::text::crumb( + ui, self.flavor, "选中 1–4 行寄存器以查看", + ); + } + }); +``` + +> `self.compute_value_parse()` 应返回当前选中行(最多 4 行)下的 `U16/I16/HEX/BIN/U32/F32/ASCII` 等。复用现有的 `value_panel.rs` 数据计算逻辑(保留计算、丢弃常驻渲染部分)。 + +- [ ] **Step 6.3: 把现有 value_panel 列从 CentralPanel 中移除** + +删除 CentralPanel 内现有的"值解析"右列分配(很可能是 columns(2) / TableBuilder 的最后一列 `Column::remainder()`)。 + +- [ ] **Step 6.4: 新增 BottomPanel 状态栏(CentralPanel 之前)** + +```rust +egui::TopBottomPanel::bottom("statusbar") + .resizable(false) + .exact_height(22.0) + .show_separator_line(false) + .frame( + egui::Frame::none() + .fill(theme::bg_of(self.flavor, theme::Layer::L0)) + .inner_margin(egui::Margin::symmetric(14.0 as i8, 4.0 as i8)), + ) + .show(ctx, |ui| { + ui.horizontal(|ui| { + ui.add(egui::Label::new( + egui::RichText::new("●").color(theme::success(self.flavor)).size(11.0), + )); + crate::ui_shared::theme::text::crumb(ui, self.flavor, "就绪"); + ui.add_space(14.0); + crate::ui_shared::theme::text::crumb( + ui, self.flavor, + &format!("{} 连接 · {} 从站", self.connections.len(), self.total_slaves()), + ); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + crate::ui_shared::theme::text::crumb( + ui, self.flavor, env!("CARGO_PKG_VERSION"), + ); + }); + }); + }); +``` + +> 注意:BottomPanel 已被现有 log_panel 占用(`shared_log_panel`)。这两个面板在 egui 里可堆叠(按 add 顺序从下往上),先 `add` statusbar、再 `add` log_panel 即可让状态栏在最底。 + +- [ ] **Step 6.5: 全局快捷键** + +在 `update` 顶部、绘制面板前: + +```rust +ctx.input(|i| { + if i.key_pressed(egui::Key::V) && !i.modifiers.any() { + self.value_parse_open = !self.value_parse_open; + } + if i.key_pressed(egui::Key::L) && !i.modifiers.any() { + self.log_state.collapsed = !self.log_state.collapsed; + } + if i.key_pressed(egui::Key::Slash) && !i.modifiers.any() { + self.focus_search_next_frame = true; + } + if i.key_pressed(egui::Key::Escape) && !self.selected_rows.is_empty() { + self.selected_rows.clear(); + } +}); +``` + +`focus_search_next_frame: bool` 字段在主区头渲染搜索框时检查并 `response.request_focus()`。 + +- [ ] **Step 6.6: 视图菜单新增切换项** + +找菜单栏 `视图` 的 `ui.menu_button("视图", |ui| { ... })`,加: + +```rust +ui.menu_button("视图", |ui| { + ui.checkbox(&mut self.value_parse_open, "显示值解析 (V)"); + ui.checkbox(&mut self.log_state.open, "显示通信日志"); + if !self.log_state.open { self.log_state.collapsed = false; } + ui.separator(); + if ui.button("浅色 / 深色切换").clicked() { + self.flavor = if self.flavor.is_dark() { Flavor::Latte } else { Flavor::Mocha }; + } +}); +``` + +- [ ] **Step 6.7: 编译 + 视觉验证** + +```bash +cargo check -p modbussim-egui +cargo clippy -p modbussim-egui --no-deps -- -D warnings +cargo run -p modbussim-egui +``` + +预期:默认无值解析;按 `V` / 点工具栏 `◧ 值解析` → 抽屉滑入;按 `L` 折叠日志;底部 22px 状态栏(绿点 + "就绪 · 1 连接 · 1 从站 · 0.x.x")。 + +- [ ] **Step 6.8: commit** + +```bash +git add crates/modbussim-egui/src/app.rs +git commit -m "feat(slave-app): 值解析改右抽屉 + 底部状态栏 + 快捷键 V/L/Esc//" +``` + +--- + +## Task 7: 浅色模式校核 + mbpoll 烟测 + +**Files:** +- Touch: 无(仅运行验证) + +- [ ] **Step 7.1: 切浅色目视回归** + +```bash +cargo run -p modbussim-egui +# 视图菜单 → 浅色/深色切换 +``` + +逐项核对: +- 背景三层(菜单/主区/卡)灰阶可辨 +- accent 蓝 / success 绿深色 / warn 橙深色 / 别名紫深色 在白底下都 ≥4.5:1 对比度 +- 选中行有蓝底(不刺眼) +- 表头 tiny_caps 仍是 accent_fg 蓝 +- shadcn 按钮(绿"+ 批量添加" / outline 灰边)正常 + +如有问题,回 Task 1 / Task 2 微调浅色 RGB。 + +- [ ] **Step 7.2: mbpoll 烟测** + +终端 1: +```bash +cargo run -p modbussim-egui --release +# 在 GUI 内新建 TCP slave 监听 5502 +``` + +终端 2: +```bash +mbpoll -m tcp -p 5502 -t 4 -r 14984 -c 4 127.0.0.1 +mbpoll -m tcp -p 5502 -t 4 -r 14984 -- 1450 0 0 1 127.0.0.1 +mbpoll -m tcp -p 5502 -t 4 -r 14984 -c 4 127.0.0.1 +``` + +预期 GUI 内:表格 14984 显示 1450(绿粗体);通信日志条目正确(RX `←` 绿 / TX `→` 蓝、FC 橙、详情 muted)。 + +- [ ] **Step 7.3: workspace 测试** + +```bash +cargo test --workspace --exclude modbussim-app --exclude modbusmaster-app +cargo clippy --workspace --no-deps -- -D warnings +cargo fmt --all -- --check +``` + +预期:全绿;如有红色,`cargo fmt --all` 后再跑一次。 + +- [ ] **Step 7.4: 截图替换 spec 截图(可选)** + +若有时间,截一张新 dark 模式截图保存到 `docs/superpowers/specs/assets/2026-04-21-slave-ui-after.png`,并在 spec 文件追加:"Before / After" 对比段。 + +- [ ] **Step 7.5: commit** + +```bash +git add -u +git commit -m "test(slave-ui): 双主题视觉回归 + mbpoll 烟测全通" +``` + +--- + +## Task 8: PR + +- [ ] **Step 8.1: push 分支** + +```bash +git push -u origin refactor/egui-skeleton +``` + +- [ ] **Step 8.2: 创建 PR** + +```bash +gh pr create --title "feat(ui): 子站 UI 重设计 — 工业 HMI 中文版" --body "$(cat <<'EOF' +## Summary +- 重构 \`modbussim-ui-shared/theme.rs\`:palette 改冷蓝、新增 token (accent_fg/warn/alias/border_*/text_*)、字号梯度拉开、tiny_caps/crumb helper +- 重构 \`modbussim-ui-shared/ui.rs\`:shadcn palette 同步、card 内边距统一、新增 panel_header / link_action +- 重构 \`modbussim-ui-shared/log_panel.rs\`:单行 header、可折叠、RX/TX 改 ← / → 符号 +- 重构 \`modbussim-egui/app.rs\`:SidePanel 240px + 头/树/footer 三段、主区头 panel_header、fmt-pill 工具栏、寄存器表格语义化色彩、值解析改右抽屉、底部 22px 状态栏、快捷键 V/L/Esc/\`/\` + +## Test plan +- [ ] cargo check / clippy / fmt 全绿 +- [ ] cargo test workspace 全绿 +- [ ] mbpoll 烟测:读 / 写 / 表格刷新 / 日志条目 +- [ ] 双主题视觉回归 +- [ ] CI 三平台(macOS / Linux / Windows)通过 + +🤖 Generated with [Claude Code](https://claude.com/claude-code) +EOF +)" +``` + +- [ ] **Step 8.3: 观察 CI** + +```bash +gh run list --branch refactor/egui-skeleton --limit 3 +``` + +如失败按错误修复后再 push。 + +--- + +## Self-Review + +- **Spec coverage**:spec 第 2 节 palette、第 3 节字号、第 4 节间距、第 5 节组件清单(A–F)均落到具体 task: + - palette / TextStyle / spacing → Task 1 + - shadcn 同步 / panel_header / link_action → Task 2 + - log_panel 单行 + 折叠 + 箭头 → Task 3 + - SidePanel 重构 → Task 4 + - 主区头 / 工具栏 / 表格 → Task 5 + - 值解析抽屉 / 状态栏 / 快捷键 / 视图菜单 → Task 6 + - 浅色 + mbpoll → Task 7 + - fonts.rs Monospace 回退链 → 未在本 plan 实施(spec 里属于 nice-to-have,且与 CJK 字体加载的 skrifa 后端交互复杂)。**实施者注**:发现问题再补,或在 Task 1 末尾另起 Step 1.10 处理。 +- **Placeholder scan**:无 TODO / TBD / "实现细节后补"。每个 step 都给出可直接粘贴的 Rust 代码或可执行的命令。 +- **Type consistency**: + - Token helper 全部用 `theme::xxx(flavor) -> Color32` 同签名 + - `tiny_caps(ui, flavor, &str)` / `crumb(ui, flavor, &str)` 在 Task 3/4/5/6 一致 + - `link_action(ui, flavor, &str, bool) -> Response` 在 Task 3/4/5/6 一致 + - `panel_header(ui, flavor, title, Option)` 在 Task 5 一致 + - `LogPanelState.collapsed` 在 Task 3 定义、Task 6 视图菜单引用 + - `value_parse_open: bool` 在 Task 6.1 定义、Task 5.1 / 6.2 / 6.5 / 6.6 引用 +- **未决细节**: + - egui-shadcn 0.3 `ControlVariant::Accent` 是否存在,Task 2.4 已注明回退方案 + - app.rs 内 `self.alias_of / self.set_register / self.selected_rows / self.fmt / self.regs / self.connections` 等字段的具体名字以现状为准,实施者按需对齐 + +--- + +## Execution Handoff + +**Plan complete and saved to `docs/superpowers/plans/2026-04-21-slave-ui-redesign.md`. Two execution options:** + +**1. Subagent-Driven (recommended)** — 每个 task 派一个新 subagent 执行,task 间我做 review。适合纯视觉工作 + 有现成 mockup 比对。 + +**2. Inline Execution** — 在当前会话内按 task 顺序执行,每 2 个 task 一个 checkpoint review。 + +**Which approach?** diff --git a/docs/superpowers/specs/2026-04-21-slave-ui-redesign-design.md b/docs/superpowers/specs/2026-04-21-slave-ui-redesign-design.md new file mode 100644 index 0000000..483bc1c --- /dev/null +++ b/docs/superpowers/specs/2026-04-21-slave-ui-redesign-design.md @@ -0,0 +1,163 @@ +# 子站 UI 重设计 · 工业 HMI 中文版(Spec) + +## Context + +当前子站界面(`crates/modbussim-egui`)用户反馈"布局/字体/颜色都奇怪"。经过代码侦察定位根因: + +- 字号梯度太平(15/13/12.5 相邻仅 <3px 差),层级不清 +- 三层背景 `#1e1f22 / #2b2d30 / #313338` 仅 RGB 差 6 unit,"一片灰" +- 大量二级操作用纯文本链接(停止/删除/清空/关闭/新建 TCP 连接),无视觉重量 +- 橙色 accent 仅出现在"批量添加"按钮一处,孤悬突兀 +- "值解析"侧栏常驻但长期空白,浪费右侧 ~20% 宽度 +- `item_spacing = (8,4)` 与行高 22 不匹配,上下留白不匀 + +目标:重设计视觉系统与信息架构,使其成为一个专业工业调试工具(SCADA/HMI 风)但保留中文界面友好性。主使用场景锁定为"调试寄存器值",因此表格是 C 位。 + +风格方向已与用户确认:**B · 工业 HMI 中文版**(高对比黑底 + 蓝 accent + 绿数值)。值解析位置:**右侧抽屉、默认收起**。 + +## 现状入口(关键文件) + +| 文件 | 行 | 作用 | +|---|---|---| +| `crates/modbussim-egui/src/app.rs` | 2750-2870 | 主布局(TopPanel/SidePanel/CentralPanel/BottomPanel) | +| `crates/modbussim-egui/src/app.rs` | 2315-2330 / 2457-2462 | 寄存器 TableBuilder | +| `crates/modbussim-ui-shared/src/theme.rs` | 56-84 | `Layer::L0/L1/L2` 配色 | +| `crates/modbussim-ui-shared/src/theme.rs` | 181-338 | Visuals 应用、语义颜色、字号 | +| `crates/modbussim-ui-shared/src/ui.rs` | 95-129 | shadcn palette 覆盖 | +| `crates/modbussim-ui-shared/src/log_panel.rs` | 69-141 | 通信日志面板 | +| `crates/modbussim-ui-shared/src/fonts.rs` | 47-91 | CJK 字体加载 | + +## 设计决策总览 + +### 信息架构 + +``` +TopPanel 菜单栏(文件 / 视图 / 帮助) +SidePanel 左侧连接树(240px,原 320) + - 头部: "连接" tiny_caps + 右上 "+ 新建" 链接 + - 树: 3 级,激活节点左 2px 蓝竖线 + - 底 footer: [停止] [删除连接] +Central 主区 + A. 主区头 (两行: 标题 + 面包屑 | 搜索 + 主操作) + B. 工具栏 (格式 pill | 已选 N 行 | 次要操作) + C. 表格 + 值解析抽屉(抽屉默认收起) + D. 通信日志 (可折叠) +BottomPanel 状态栏(22px) +``` + +### 配色 Token(深色模式) + +``` +bg.chrome #010409 菜单/侧栏/状态栏 +bg.surface #0d1117 主区 +bg.raised #161b22 hover / 抽屉卡片 +border.subtle #21262d 分割线 +border.strong #30363d 输入框/按钮边框 +accent.primary #1f6feb 选中/表头下划线/focus +accent.primary.fg #58a6ff 表头文字/链接 +accent.success #3fb950 数值 / RX / 就绪 +accent.warn #f0883e HEX 列 +accent.danger #f85149 删除 hover / 错误 +text.primary #e6edf3 标题 +text.body #c9d1d9 正文 +text.muted #6e7681 地址/元信息 +alias #d2a8ff 别名列 +``` + +主 accent 从橙 `#cc7832` 换成蓝 `#1f6feb`;绿色承担"数值"语义;橙色退居 HEX 列。浅色模式保留双主题,accent 同步换蓝。 + +### 字号 Token + +``` +Heading 15.0 主区标题 +Body 12.5 正文 +Button 12.0 按钮 +Monospace 12.5 表格数值/地址 +Small 10.5 表头 / 面包屑 / 状态栏 + +tiny_caps 10.5 大写、accent_fg 蓝、强调字重 → 表头、分组标题 +crumb 11.0 muted 色 → 面包屑 +``` + +### 间距 Token + +``` +spacing.item_spacing (10, 6) 原 (8, 4) +spacing.button_padding (12, 4) +spacing.interact_size.y 24 原 22 +panel.inner_margin 14 / 12 (L/R / T/B) +表格行高 22 +表头行高 26 +日志行高 18 +``` + +## 组件级改动 + +### 左侧连接面板(app.rs SidePanel 块, ~2798) +- 宽 `320 → 240` +- 头部替换为 `连接` tiny_caps 标签 + 右上 `+ 新建` 链接(去掉当前"新建 TCP 连接"整行) +- 树节点 3 级样式统一,激活节点:左 2px 蓝竖线 + `#1f6feb @ 15% alpha` 背景 + 文字 `#58a6ff` 粗体 +- 节点右 badge 显示行数(等宽、muted) +- 底部 footer:`[停止] [删除连接]`(删除 hover 变 `#f85149`),与树之间 1px 顶分割线 + +### 主区头部(新) +- `egui::Frame` 两行结构 +- 上行:`Heading` 主标题 + `crumb` 面包屑 +- 下行:`TextEdit` 搜索框(宽 200,1px 灰边,focus 蓝) + 绿色实心 `+ 批量添加` + +### 工具栏(新,`CentralPanel` 内顶条) +- 格式 pill:`#161b22` 底 + 1px 灰边 + 12px 圆角 + 蓝字 +- 选中计数:`已选 N 行` muted +- 右侧:`导出` / `清零` 透明次要按钮 + +### 寄存器表格(app.rs, ~2315) +- 列宽:地址 80 / 别名 120 / 值 100 右对齐 / HEX 80 / 二进制 remainder +- 表头:`tiny_caps` 样式、字色蓝、下划线 2px 蓝 +- 行: + - 地址:右对齐、muted + - 别名:紫 `#d2a8ff`;空值显示 `—` + - 值:绿色粗体、右对齐;编辑态用 `DragValue` 右对齐 + - HEX:橙 `#f0883e` + - 二进制:muted 小号 +- 选中行:`#1f6feb @ 15% alpha`;hover 行:`bg.raised` +- **移除 `striped(true)`**(与选中/hover 背景冲突) + +### 值解析抽屉(右侧可关闭,替换当前常驻列) +- 默认不渲染;工具栏右侧加切换按钮 `◧ 值解析` 或快捷键 `V` +- 打开:从右侧渲染 240px `egui::SidePanel::right`,`show_animated` +- 内容:U16 / I16 / HEX / BIN / U32 / F32 纵向网格 +- 多行选中 (2–4 行) 时底部追加组合解析(U32/F32/ASCII 串) +- 未选中时 empty state:`选中 1–4 行寄存器以查看` + +### 通信日志(log_panel.rs) +- 单行头部:`▼ 通信日志 · slave_1 · N 条 | ☑RX ☑TX [过滤…] [清空] [导出 CSV] [关闭]` +- 点 `▼` 折叠到仅头部 +- 列宽:时间 150 / 方向 28 / FC 60 / 详情 remainder +- 方向:`←` 绿 / `→` 蓝;FC 橙;详情 body 色 +- 去掉行 striped + +### 状态栏(新增 `TopBottomPanel::bottom("statusbar")`) +- 高度 22,字号 11,`bg.chrome` 底 +- 左:`● 就绪` (绿) / `N 连接 · M 从站` +- 右:版本号 `env!("CARGO_PKG_VERSION")` + +### 顶部菜单栏 +- 保留 `文件 / 视图 / 帮助` +- `视图` 菜单新增:显示值解析 (V) / 显示通信日志 / 浅色深色切换 + +### 字体加载(fonts.rs, ~47) +- 主字体保持系统 CJK 加载顺序 +- nice-to-have:Monospace 回退链 `SF Mono / Menlo / Consolas / JetBrains Mono` + +### 快捷键(新) +- `V` 切值解析 +- `L` 切日志折叠 +- `/` 聚焦搜索 +- `Esc` 清除选中 + +## 主要修改文件清单 + +- `crates/modbussim-ui-shared/src/theme.rs` — 重写 Layer 色值、Visuals、TextStyle、新增 tiny_caps/crumb helper、accent 换蓝 +- `crates/modbussim-ui-shared/src/ui.rs` — shadcn palette 同步、card 改 token、新增 panel_header/link_action +- `crates/modbussim-ui-shared/src/log_panel.rs` — 单行 header + 折叠 + RX/TX 箭头 +- `crates/modbussim-egui/src/app.rs` — SidePanel/主区/表格/值解析抽屉/状态栏/快捷键 From 86dc416333bd556f976f7d8bdcd48e7942dd68de Mon Sep 17 00:00:00 2001 From: kelsoprotein-lab Date: Tue, 21 Apr 2026 00:31:22 +0800 Subject: [PATCH 02/25] =?UTF-8?q?docs(slave-ui):=20=E6=AD=A3=E5=BC=8F=20sp?= =?UTF-8?q?ec=20=E4=B8=8E=20brainstorm=20=E5=86=85=E5=AE=B9=E5=90=8C?= =?UTF-8?q?=E6=AD=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 更新 spec 文件确保完整对标 brainstorm 第 1-170 行内容,特别是: - 字号 Token tiny_caps 规格改为 letter-spacing - 工具栏 pill 增加实现细节 - 值解析抽屉内容细节补全 - 通信日志头部、列宽、快捷键规格对齐 - 状态栏默认菜单项调整 - 主要修改文件清单补全 value_panel.rs 和 fonts.rs Co-Authored-By: Claude Opus 4.7 (1M context) --- .../2026-04-21-slave-ui-redesign-design.md | 53 +++++++++++-------- 1 file changed, 30 insertions(+), 23 deletions(-) diff --git a/docs/superpowers/specs/2026-04-21-slave-ui-redesign-design.md b/docs/superpowers/specs/2026-04-21-slave-ui-redesign-design.md index 483bc1c..6a297b5 100644 --- a/docs/superpowers/specs/2026-04-21-slave-ui-redesign-design.md +++ b/docs/superpowers/specs/2026-04-21-slave-ui-redesign-design.md @@ -75,7 +75,7 @@ Button 12.0 按钮 Monospace 12.5 表格数值/地址 Small 10.5 表头 / 面包屑 / 状态栏 -tiny_caps 10.5 大写、accent_fg 蓝、强调字重 → 表头、分组标题 +tiny_caps 10.5 letter-spacing +0.8、大写 → 表头、分组标题 crumb 11.0 muted 色 → 面包屑 ``` @@ -106,7 +106,7 @@ panel.inner_margin 14 / 12 (L/R / T/B) - 下行:`TextEdit` 搜索框(宽 200,1px 灰边,focus 蓝) + 绿色实心 `+ 批量添加` ### 工具栏(新,`CentralPanel` 内顶条) -- 格式 pill:`#161b22` 底 + 1px 灰边 + 12px 圆角 + 蓝字 +- 格式 pill:`#161b22` 底 + 1px 灰边 + 12px 圆角 + 蓝字(复用 `ui-shared/src/ui.rs` 的 card 扩展) - 选中计数:`已选 N 行` muted - 右侧:`导出` / `清零` 透明次要按钮 @@ -116,48 +116,55 @@ panel.inner_margin 14 / 12 (L/R / T/B) - 行: - 地址:右对齐、muted - 别名:紫 `#d2a8ff`;空值显示 `—` - - 值:绿色粗体、右对齐;编辑态用 `DragValue` 右对齐 + - 值:绿色粗体、右对齐;编辑态用 80px 宽 `TextEdit` 右对齐 - HEX:橙 `#f0883e` - 二进制:muted 小号 - 选中行:`#1f6feb @ 15% alpha`;hover 行:`bg.raised` - **移除 `striped(true)`**(与选中/hover 背景冲突) -### 值解析抽屉(右侧可关闭,替换当前常驻列) +### 值解析抽屉(右侧可关闭,替换当前常驻 `值解析` 列) - 默认不渲染;工具栏右侧加切换按钮 `◧ 值解析` 或快捷键 `V` -- 打开:从右侧渲染 240px `egui::SidePanel::right`,`show_animated` -- 内容:U16 / I16 / HEX / BIN / U32 / F32 纵向网格 -- 多行选中 (2–4 行) 时底部追加组合解析(U32/F32/ASCII 串) -- 未选中时 empty state:`选中 1–4 行寄存器以查看` +- 打开:从右侧渲染 240px 固定宽面板(`egui::SidePanel::right`) +- 内容: + - 顶 `值解析 · · ` tiny_caps + `×` 关闭 + - 主体:`U16 / I16 / HEX / BIN / U32 / F32` 纵向网格 + - 多行选中 (2–4 行) 时,底部追加组合解析(U32/F32/ASCII 串) +- 未选中任何行时 empty state:`选中 1–4 行寄存器以查看多格式` ### 通信日志(log_panel.rs) -- 单行头部:`▼ 通信日志 · slave_1 · N 条 | ☑RX ☑TX [过滤…] [清空] [导出 CSV] [关闭]` -- 点 `▼` 折叠到仅头部 -- 列宽:时间 150 / 方向 28 / FC 60 / 详情 remainder -- 方向:`←` 绿 / `→` 蓝;FC 橙;详情 body 色 -- 去掉行 striped - -### 状态栏(新增 `TopBottomPanel::bottom("statusbar")`) +- 单行头部 (替代当前多行): + `通信日志 · slave_1 (N 条) | ☑RX ☑TX [过滤…] [清空] [导出 CSV] [折叠 ▾]` +- 点标题/折叠图标 → 收起到仅头部 +- 列宽:时间 130 / 方向 28 / FC 50 / 详情 remainder +- 方向:`←` 绿 / `→` 蓝;FC 橙 +- 去除"就绪"文字(移到新状态栏) + +### 状态栏(新增 `TopBottomPanel::bottom("status")`) - 高度 22,字号 11,`bg.chrome` 底 -- 左:`● 就绪` (绿) / `N 连接 · M 从站` -- 右:版本号 `env!("CARGO_PKG_VERSION")` +- 左:`● 就绪` (绿) / `N 连接` / `M 从站` +- 右:分支名(可选) ### 顶部菜单栏 - 保留 `文件 / 视图 / 帮助` -- `视图` 菜单新增:显示值解析 (V) / 显示通信日志 / 浅色深色切换 +- `视图` 菜单新增:切换主题 / 显示值解析 / 显示通信日志 / 重置布局 ### 字体加载(fonts.rs, ~47) - 主字体保持系统 CJK 加载顺序 -- nice-to-have:Monospace 回退链 `SF Mono / Menlo / Consolas / JetBrains Mono` +- 新增 Monospace 回退链:`SF Mono` / `Menlo` / `Consolas` / `JetBrains Mono` → 回退到 egui 默认,避免 CJK 混排跳帧 ### 快捷键(新) - `V` 切值解析 - `L` 切日志折叠 - `/` 聚焦搜索 - `Esc` 清除选中 +- 表格内:`↑ ↓` 移动、`Enter` 编辑、`Esc` 退编辑 ## 主要修改文件清单 -- `crates/modbussim-ui-shared/src/theme.rs` — 重写 Layer 色值、Visuals、TextStyle、新增 tiny_caps/crumb helper、accent 换蓝 -- `crates/modbussim-ui-shared/src/ui.rs` — shadcn palette 同步、card 改 token、新增 panel_header/link_action -- `crates/modbussim-ui-shared/src/log_panel.rs` — 单行 header + 折叠 + RX/TX 箭头 -- `crates/modbussim-egui/src/app.rs` — SidePanel/主区/表格/值解析抽屉/状态栏/快捷键 +- `crates/modbussim-ui-shared/src/theme.rs` — 重写 `Layer` 色值、Visuals、TextStyle 尺寸、新增 `tiny_caps` / `crumb` 命名样式、accent 换蓝 +- `crates/modbussim-ui-shared/src/ui.rs` — 调整 shadcn palette(primary 换蓝、success 绿、warn 橙)、pill 组件、操作链接样式 +- `crates/modbussim-ui-shared/src/fonts.rs` — Monospace 回退链 +- `crates/modbussim-ui-shared/src/log_panel.rs` — 合并头部单行化、加折叠、列色 +- `crates/modbussim-ui-shared/src/value_panel.rs` — 重写为可关闭抽屉组件(纵向网格) +- `crates/modbussim-egui/src/app.rs` — SidePanel 宽/头/footer、主区头/工具栏、表格列与样式、值解析由列改为右 SidePanel、新状态栏 BottomPanel、快捷键 +- 新增 `docs/superpowers/specs/2026-04-21-slave-ui-redesign-design.md` — 同步正式 spec(plan 通过后落) From 07e00a62f80dc4fc3c6d56f4817bd5c26fdde836 Mon Sep 17 00:00:00 2001 From: kelsoprotein-lab Date: Tue, 21 Apr 2026 00:35:13 +0800 Subject: [PATCH 03/25] =?UTF-8?q?feat(theme):=20=E5=88=87=E6=8D=A2?= =?UTF-8?q?=E5=86=B7=E8=93=9D=20palette=20+=20=E6=8B=89=E5=BC=80=E5=AD=97?= =?UTF-8?q?=E5=8F=B7=E6=A2=AF=E5=BA=A6=20+=20=E6=96=B0=E5=A2=9E=E8=AF=AD?= =?UTF-8?q?=E4=B9=89=E8=89=B2=20token?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/modbussim-ui-shared/src/theme.rs | 242 +++++++++--------- crates/modbussim-ui-shared/src/value_panel.rs | 2 +- 2 files changed, 123 insertions(+), 121 deletions(-) diff --git a/crates/modbussim-ui-shared/src/theme.rs b/crates/modbussim-ui-shared/src/theme.rs index 2be4e46..851e7ae 100644 --- a/crates/modbussim-ui-shared/src/theme.rs +++ b/crates/modbussim-ui-shared/src/theme.rs @@ -10,10 +10,11 @@ use serde::{Deserialize, Serialize}; /// Theme flavor. Serde-compat with older "mocha"/"latte"/... values so a /// storage-persisted flavor from an earlier build still deserializes. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] #[serde(rename_all = "snake_case")] pub enum Flavor { /// Dark+ — default dark theme (bg #1e1e1e) + #[default] Mocha, /// Dark+ alias (kept for serde compat with older save files) Macchiato, @@ -23,15 +24,6 @@ pub enum Flavor { Latte, } -impl Default for Flavor { - fn default() -> Self { - // Darcula-style warm gray is the default dark look that Modbus users - // consistently land on (JetBrains IDEs, Android Studio — decades of - // industrial-desktop precedent). - Flavor::Mocha - } -} - impl Flavor { pub fn label(self) -> &'static str { match self { @@ -70,15 +62,15 @@ pub enum Layer { pub fn bg_of(flavor: Flavor, layer: Layer) -> Color32 { if flavor.is_dark() { match layer { - Layer::L0 => rgb(0x1e, 0x1f, 0x22), // #1e1f22 chrome - Layer::L1 => rgb(0x2b, 0x2d, 0x30), // #2b2d30 main - Layer::L2 => rgb(0x31, 0x33, 0x38), // #313338 data container + Layer::L0 => rgb(0x01, 0x04, 0x09), // #010409 chrome + Layer::L1 => rgb(0x0d, 0x11, 0x17), // #0d1117 surface + Layer::L2 => rgb(0x16, 0x1b, 0x22), // #161b22 raised } } else { match layer { - Layer::L0 => rgb(232, 232, 232), // #e8e8e8 - Layer::L1 => rgb(245, 245, 245), // #f5f5f5 - Layer::L2 => rgb(255, 255, 255), // #ffffff + Layer::L0 => rgb(0xf4, 0xf4, 0xf5), // #f4f4f5 + Layer::L1 => rgb(0xfa, 0xfa, 0xfa), // #fafafa + Layer::L2 => rgb(0xff, 0xff, 0xff), // #ffffff } } } @@ -86,19 +78,19 @@ pub fn bg_of(flavor: Flavor, layer: Layer) -> Color32 { /// Hover fill used by non-primary buttons and list rows. pub fn bg_hover(flavor: Flavor) -> Color32 { if flavor.is_dark() { - rgb(0x3c, 0x3f, 0x45) + rgb(0x16, 0x1b, 0x22) // = Layer::L2 } else { - rgb(0xe0, 0xe6, 0xed) + rgb(0xe4, 0xe4, 0xe7) } } /// Selected row fill (applied full-row in register tables / scan-group list). pub fn bg_selected_row(flavor: Flavor) -> Color32 { if flavor.is_dark() { - // #214283 @ 30% alpha on L2 — rendered via rect_filled with alpha - Color32::from_rgba_unmultiplied(0x21, 0x42, 0x83, 0x4d) + // accent.primary @ 15% alpha → 解多重不蒙底色 + Color32::from_rgba_unmultiplied(0x1f, 0x6f, 0xeb, 0x26) } else { - rgb(0xc9, 0xda, 0xf8) // #c9daf8 + Color32::from_rgba_unmultiplied(0x25, 0x63, 0xeb, 0x1a) } } @@ -186,152 +178,162 @@ pub fn apply(ctx: &egui::Context, flavor: Flavor) { // fields ourselves to match the target industrial palette. ctx.style_mut(|s| { if flavor.is_dark() { - // Three-level layered Darcula + orange accent - let panel = bg_of(flavor, Layer::L1); // #2b2d30 central - let panel_alt = bg_of(flavor, Layer::L0); // #1e1f22 chrome (side / bottom) - let input_bg = bg_of(flavor, Layer::L2); // #313338 input / data container - let stroke = Color32::from_rgb(81, 86, 89); // #515659 (functional borders only) - let fg = Color32::from_rgb(220, 223, 228); // #dcdfe4 — brighter body - let strong_fg = Color32::from_rgb(248, 248, 242); // #f8f8f2 — near-white for headers/strong - let sel_bg = Color32::from_rgb(75, 110, 175); // #4b6eaf — Darcula selection - let accent = Color32::from_rgb(204, 120, 50); // #cc7832 orange + let panel = bg_of(flavor, Layer::L1); // #0d1117 + let panel_alt = bg_of(flavor, Layer::L0); // #010409 + let raised = bg_of(flavor, Layer::L2); // #161b22 + let stroke = border_strong(flavor); // #30363d + let stroke_soft = border_subtle(flavor); // #21262d + let fg = text_body(flavor); // #c9d1d9 + let strong_fg = text_primary(flavor); // #e6edf3 + let sel_bg = bg_selected_row(flavor); + let acc = accent(flavor); // #1f6feb s.visuals.panel_fill = panel; s.visuals.window_fill = panel_alt; - s.visuals.extreme_bg_color = Color32::from_rgb(37, 37, 37); // #252525 input-ish bg - s.visuals.faint_bg_color = Color32::from_rgb(49, 51, 53); // #313335 — striped row - s.visuals.code_bg_color = Color32::from_rgb(49, 51, 53); + s.visuals.extreme_bg_color = panel_alt; + s.visuals.faint_bg_color = raised; + s.visuals.code_bg_color = raised; s.visuals.widgets.noninteractive.bg_fill = panel_alt; s.visuals.widgets.noninteractive.weak_bg_fill = panel; - s.visuals.widgets.noninteractive.bg_stroke.color = stroke; + s.visuals.widgets.noninteractive.bg_stroke.color = stroke_soft; s.visuals.widgets.noninteractive.fg_stroke.color = fg; - s.visuals.widgets.inactive.bg_fill = input_bg; + s.visuals.widgets.inactive.bg_fill = raised; s.visuals.widgets.inactive.weak_bg_fill = panel_alt; - // Keep stroke for functional borders (TextEdit outlines); buttons - // override locally to NONE via primary/secondary/danger helpers. s.visuals.widgets.inactive.bg_stroke.color = stroke; s.visuals.widgets.inactive.fg_stroke.color = fg; s.visuals.widgets.hovered.bg_fill = bg_hover(flavor); - // No visible stroke on hover — flat-layered style s.visuals.widgets.hovered.bg_stroke.color = bg_hover(flavor); s.visuals.widgets.hovered.fg_stroke.color = strong_fg; - // active = pressed state AND egui uses its fg_stroke as strong_text_color() - // for table headers. Use near-white so headers pop; orange bg is rarely - // clicked on so white-on-orange is fine. - s.visuals.widgets.active.bg_fill = accent; - s.visuals.widgets.active.bg_stroke.color = accent; - s.visuals.widgets.active.fg_stroke.color = strong_fg; - s.visuals.widgets.open.bg_fill = input_bg; - s.visuals.window_stroke.color = stroke; + s.visuals.widgets.active.bg_fill = acc; + s.visuals.widgets.active.bg_stroke.color = acc; + s.visuals.widgets.active.fg_stroke.color = Color32::WHITE; + s.visuals.widgets.open.bg_fill = raised; + s.visuals.window_stroke.color = stroke_soft; s.visuals.selection.bg_fill = sel_bg; - s.visuals.selection.stroke.color = accent; + s.visuals.selection.stroke.color = acc; s.visuals.override_text_color = Some(fg); - s.visuals.hyperlink_color = Color32::from_rgb(104, 151, 187); // darcula ctor blue - s.visuals.error_fg_color = Color32::from_rgb(255, 100, 100); - s.visuals.warn_fg_color = Color32::from_rgb(255, 198, 109); + s.visuals.hyperlink_color = accent_fg(flavor); + s.visuals.error_fg_color = danger(flavor); + s.visuals.warn_fg_color = warn(flavor); } else { - let panel = Color32::from_rgb(245, 245, 245); // #f5f5f5 - let white = Color32::from_rgb(255, 255, 255); // #ffffff - let stroke = Color32::from_rgb(208, 208, 208); // #d0d0d0 - let stroke_strong = Color32::from_rgb(190, 190, 190); - let fg = Color32::from_rgb(51, 51, 51); // #333333 - let sel_bg = Color32::from_rgb(201, 218, 248); // #c9daf8 row highlight - let accent = Color32::from_rgb(59, 154, 232); // #3b9ae8 + let panel = bg_of(flavor, Layer::L1); + let _panel_alt = bg_of(flavor, Layer::L0); + let raised = bg_of(flavor, Layer::L2); + let stroke = border_strong(flavor); + let stroke_soft = border_subtle(flavor); + let fg = text_body(flavor); + let strong_fg = text_primary(flavor); + let sel_bg = bg_selected_row(flavor); + let acc = accent(flavor); s.visuals.panel_fill = panel; - s.visuals.window_fill = white; - s.visuals.extreme_bg_color = white; - s.visuals.faint_bg_color = Color32::from_rgb(248, 248, 248); - s.visuals.code_bg_color = Color32::from_rgb(240, 240, 240); + s.visuals.window_fill = raised; + s.visuals.extreme_bg_color = raised; + s.visuals.faint_bg_color = panel; + s.visuals.code_bg_color = panel; s.visuals.widgets.noninteractive.bg_fill = panel; s.visuals.widgets.noninteractive.weak_bg_fill = panel; - s.visuals.widgets.noninteractive.bg_stroke.color = stroke; + s.visuals.widgets.noninteractive.bg_stroke.color = stroke_soft; s.visuals.widgets.noninteractive.fg_stroke.color = fg; - s.visuals.widgets.inactive.bg_fill = Color32::from_rgb(240, 240, 240); - s.visuals.widgets.inactive.weak_bg_fill = Color32::from_rgb(245, 245, 245); + s.visuals.widgets.inactive.bg_fill = raised; + s.visuals.widgets.inactive.weak_bg_fill = panel; s.visuals.widgets.inactive.bg_stroke.color = stroke; s.visuals.widgets.inactive.fg_stroke.color = fg; - s.visuals.widgets.hovered.bg_fill = Color32::from_rgb(230, 230, 230); - s.visuals.widgets.hovered.bg_stroke.color = stroke_strong; - s.visuals.widgets.hovered.fg_stroke.color = fg; - s.visuals.widgets.active.bg_fill = accent; - s.visuals.widgets.active.bg_stroke.color = accent; + s.visuals.widgets.hovered.bg_fill = bg_hover(flavor); + s.visuals.widgets.hovered.bg_stroke.color = bg_hover(flavor); + s.visuals.widgets.hovered.fg_stroke.color = strong_fg; + s.visuals.widgets.active.bg_fill = acc; + s.visuals.widgets.active.bg_stroke.color = acc; s.visuals.widgets.active.fg_stroke.color = Color32::WHITE; - s.visuals.widgets.open.bg_fill = Color32::from_rgb(230, 230, 230); - s.visuals.window_stroke.color = stroke; + s.visuals.widgets.open.bg_fill = raised; + s.visuals.window_stroke.color = stroke_soft; s.visuals.selection.bg_fill = sel_bg; - s.visuals.selection.stroke.color = accent; + s.visuals.selection.stroke.color = acc; s.visuals.override_text_color = Some(fg); - s.visuals.hyperlink_color = accent; - s.visuals.error_fg_color = Color32::from_rgb(200, 51, 54); - s.visuals.warn_fg_color = Color32::from_rgb(175, 82, 0); + s.visuals.hyperlink_color = accent_fg(flavor); + s.visuals.error_fg_color = danger(flavor); + s.visuals.warn_fg_color = warn(flavor); } }); ctx.style_mut(|s| { - // Tight spacing — VS Code-like density - s.spacing.item_spacing = egui::vec2(8.0, 4.0); - s.spacing.button_padding = egui::vec2(9.0, 3.0); - s.spacing.menu_margin = egui::Margin::symmetric(6.0 as i8, 4.0 as i8); + s.spacing.item_spacing = egui::vec2(10.0, 6.0); + s.spacing.button_padding = egui::vec2(12.0, 4.0); + s.spacing.menu_margin = egui::Margin::symmetric(8.0 as i8, 5.0 as i8); s.spacing.indent = 14.0; - s.spacing.interact_size.y = 22.0; + s.spacing.interact_size.y = 24.0; - // Slight rounding — VS Code uses mostly 2-4px, not 8+ - let r: egui::Rounding = 3.0.into(); + let r: egui::CornerRadius = 4.0.into(); s.visuals.widgets.noninteractive.corner_radius = r; s.visuals.widgets.inactive.corner_radius = r; s.visuals.widgets.hovered.corner_radius = r; s.visuals.widgets.active.corner_radius = r; s.visuals.widgets.open.corner_radius = r; - s.visuals.window_corner_radius = 4.0.into(); - s.visuals.menu_corner_radius = 4.0.into(); + s.visuals.window_corner_radius = 6.0.into(); + s.visuals.menu_corner_radius = 6.0.into(); - // Type scale — smaller than our previous version, closer to VS Code use egui::TextStyle::*; - s.text_styles.insert( - Heading, - egui::FontId::new(15.0, egui::FontFamily::Proportional), - ); - s.text_styles.insert( - Body, - egui::FontId::new(13.0, egui::FontFamily::Proportional), - ); - s.text_styles.insert( - Button, - egui::FontId::new(13.0, egui::FontFamily::Proportional), - ); - s.text_styles.insert( - Monospace, - egui::FontId::new(12.5, egui::FontFamily::Monospace), - ); - s.text_styles.insert( - Small, - egui::FontId::new(11.0, egui::FontFamily::Proportional), - ); + s.text_styles.insert(Heading, egui::FontId::new(15.0, egui::FontFamily::Proportional)); + s.text_styles.insert(Body, egui::FontId::new(12.5, egui::FontFamily::Proportional)); + s.text_styles.insert(Button, egui::FontId::new(12.0, egui::FontFamily::Proportional)); + s.text_styles.insert(Monospace, egui::FontId::new(12.5, egui::FontFamily::Monospace)); + s.text_styles.insert(Small, egui::FontId::new(10.5, egui::FontFamily::Proportional)); }); } // --- Semantic color helpers used by app code --- pub fn accent(flavor: Flavor) -> Color32 { - // Darcula orange (#cc7832) for dark; redisant industrial blue for light. - if flavor.is_dark() { - flavor.palette().peach - } else { - flavor.palette().blue - } + if flavor.is_dark() { rgb(0x1f, 0x6f, 0xeb) } else { rgb(0x25, 0x63, 0xeb) } +} +pub fn accent_fg(flavor: Flavor) -> Color32 { + if flavor.is_dark() { rgb(0x58, 0xa6, 0xff) } else { rgb(0x3b, 0x82, 0xf6) } } - pub fn success(flavor: Flavor) -> Color32 { - flavor.palette().green + if flavor.is_dark() { rgb(0x3f, 0xb9, 0x50) } else { rgb(0x15, 0x80, 0x3d) } +} +pub fn warn(flavor: Flavor) -> Color32 { + if flavor.is_dark() { rgb(0xf0, 0x88, 0x3e) } else { rgb(0xc2, 0x41, 0x0c) } } - pub fn danger(flavor: Flavor) -> Color32 { - flavor.palette().red + if flavor.is_dark() { rgb(0xf8, 0x51, 0x49) } else { rgb(0xb9, 0x1c, 0x1c) } } - -pub fn subtext(flavor: Flavor) -> Color32 { - flavor.palette().subtext0 +pub fn alias(flavor: Flavor) -> Color32 { + if flavor.is_dark() { rgb(0xd2, 0xa8, 0xff) } else { rgb(0x7c, 0x3a, 0xed) } +} +pub fn border_subtle(flavor: Flavor) -> Color32 { + if flavor.is_dark() { rgb(0x21, 0x26, 0x2d) } else { rgb(0xe4, 0xe4, 0xe7) } +} +pub fn border_strong(flavor: Flavor) -> Color32 { + if flavor.is_dark() { rgb(0x30, 0x36, 0x3d) } else { rgb(0xd4, 0xd4, 0xd8) } +} +pub fn text_primary(flavor: Flavor) -> Color32 { + if flavor.is_dark() { rgb(0xe6, 0xed, 0xf3) } else { rgb(0x09, 0x09, 0x0b) } } +pub fn text_body(flavor: Flavor) -> Color32 { + if flavor.is_dark() { rgb(0xc9, 0xd1, 0xd9) } else { rgb(0x3f, 0x3f, 0x46) } +} +pub fn text_muted(flavor: Flavor) -> Color32 { + if flavor.is_dark() { rgb(0x6e, 0x76, 0x81) } else { rgb(0x71, 0x71, 0x7a) } +} +pub fn subtext(flavor: Flavor) -> Color32 { text_muted(flavor) } // 旧调用点回退 +pub fn surface(flavor: Flavor) -> Color32 { bg_of(flavor, Layer::L2) } // 旧调用点回退 -pub fn surface(flavor: Flavor) -> Color32 { - flavor.palette().surface0 +/// 文本渲染辅助:tiny_caps / crumb 等语义文本样式。 +pub mod text { + use super::{Flavor, text_muted, accent_fg}; + use egui::{Ui, RichText}; + + /// 表头 / 分组标题用:10.5px 大写、字距感由空格 + 字色弱化体现。 + pub fn tiny_caps(ui: &mut Ui, flavor: Flavor, s: &str) { + ui.label( + RichText::new(s.to_uppercase()) + .size(10.5) + .color(accent_fg(flavor)) + .strong(), + ); + } + + /// 面包屑 / 元信息:11px、muted。 + pub fn crumb(ui: &mut Ui, flavor: Flavor, s: &str) { + ui.label(RichText::new(s).size(11.0).color(text_muted(flavor))); + } } diff --git a/crates/modbussim-ui-shared/src/value_panel.rs b/crates/modbussim-ui-shared/src/value_panel.rs index 91d573e..9ac1742 100644 --- a/crates/modbussim-ui-shared/src/value_panel.rs +++ b/crates/modbussim-ui-shared/src/value_panel.rs @@ -8,7 +8,7 @@ //! //! - 1 word → U16 Unsigned / Signed / Hex / Binary //! - 2 words → U32, I32, Float32 each in 4 byte orders (AB CD / CD AB / -//! BA DC / DC BA) +//! BA DC / DC BA) //! - 4 words → Float64 in 4 byte orders use egui::{Id, Key, RichText}; From cbe276957b4afc0bc02af3ef2c73aa7bdf451660 Mon Sep 17 00:00:00 2001 From: kelsoprotein-lab Date: Tue, 21 Apr 2026 00:37:43 +0800 Subject: [PATCH 04/25] =?UTF-8?q?feat(ui-shared):=20shadcn=20palette=20?= =?UTF-8?q?=E8=BD=AC=E5=86=B7=E8=93=9D=20+=20=E6=96=B0=E5=A2=9E=20panel=5F?= =?UTF-8?q?header/link=5Faction?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit card_colors 切到 Layer::L2 + border_subtle token;shadcn 按钮 primary 改绿、accent 改蓝、border/bg/fg 全部对齐新 palette;新增 panel_header (标题+面包屑两行)和 link_action(无边框文字操作,hover 变 accent 或 danger)。 Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/modbussim-ui-shared/src/ui.rs | 117 ++++++++++++++++----------- 1 file changed, 71 insertions(+), 46 deletions(-) diff --git a/crates/modbussim-ui-shared/src/ui.rs b/crates/modbussim-ui-shared/src/ui.rs index 0e9becb..0f617f5 100644 --- a/crates/modbussim-ui-shared/src/ui.rs +++ b/crates/modbussim-ui-shared/src/ui.rs @@ -9,32 +9,18 @@ use egui::{Color32, Response, RichText, Ui}; use crate::theme::{self, Flavor, Layer}; fn card_colors(flavor: Flavor) -> (Color32, Color32) { - // Flat panel. Dark mode = Darcula tool-window fill #3c3f41 on editor - // #2b2b2b with #515659 stroke (same as IDE "chrome panel" contrast). - if flavor.is_dark() { - ( - Color32::from_rgb(60, 63, 65), // #3c3f41 - Color32::from_rgb(81, 86, 89), // #515659 - ) - } else { - ( - Color32::from_rgb(255, 255, 255), - Color32::from_rgb(208, 208, 208), - ) - } + // Industrial HMI: raised L2 background + subtle border. Same look in both + // flavors — token routing handles dark/light. + (theme::bg_of(flavor, Layer::L2), theme::border_subtle(flavor)) } -/// Flat bordered panel. No shadow, 2 px corner radius, 10 px padding — mimics -/// the GroupBox / section divider used in desktop industrial tools. -/// -/// Kept for backward compatibility; prefer `region` for new code which uses -/// background-layer differences instead of stroke borders. +/// Flat panel with raised bg + subtle border. Used for grouped content. pub fn card(ui: &mut Ui, flavor: Flavor, add: impl FnOnce(&mut Ui) -> R) -> R { let (fill, stroke_color) = card_colors(flavor); egui::Frame::new() .fill(fill) - .corner_radius(2.0) - .inner_margin(egui::Margin::symmetric(10.0 as i8, 8.0 as i8)) + .corner_radius(4.0) + .inner_margin(egui::Margin::symmetric(14.0 as i8, 12.0 as i8)) .stroke(egui::Stroke::new(1.0, stroke_color)) .show(ui, add) .inner @@ -68,12 +54,12 @@ pub fn accent_card( let (fill, stroke_color) = card_colors(flavor); let resp = egui::Frame::new() .fill(fill) - .corner_radius(2.0) + .corner_radius(4.0) .inner_margin(egui::Margin { - left: 10, - right: 10, - top: 10, - bottom: 8, + left: 14, + right: 14, + top: 12, + bottom: 10, }) .stroke(egui::Stroke::new(1.0, stroke_color)) .show(ui, add); @@ -100,31 +86,30 @@ fn shadcn_theme(flavor: Flavor) -> egui_shadcn::Theme { ColorPalette::shadcn_light(ShadcnBaseColor::Neutral) }; if flavor.is_dark() { - // Darcula orange accent + Layer::L1/L2 background alignment - palette.primary = Color32::from_rgb(0xcc, 0x78, 0x32); - palette.primary_foreground = Color32::from_rgb(0x1e, 0x1e, 0x1e); - palette.destructive = Color32::from_rgb(0xbc, 0x3f, 0x3c); + // Industrial HMI: cool blue primary + green action accent + L1/L2 bg + palette.primary = Color32::from_rgb(0x3f, 0xb9, 0x50); // 主操作绿("+ 批量添加") + palette.primary_foreground = Color32::WHITE; + palette.destructive = Color32::from_rgb(0xf8, 0x51, 0x49); palette.destructive_foreground = Color32::WHITE; - palette.ring = Color32::from_rgb(0xcc, 0x78, 0x32); - palette.border = Color32::from_rgb(0x51, 0x56, 0x59); - palette.background = Color32::from_rgb(0x2b, 0x2d, 0x30); - palette.foreground = Color32::from_rgb(0xd4, 0xd7, 0xdb); - palette.muted_foreground = Color32::from_rgb(0x9c, 0xa0, 0xa4); - palette.accent = palette.primary; - palette.accent_foreground = palette.primary_foreground; + palette.ring = Color32::from_rgb(0x1f, 0x6f, 0xeb); // focus 蓝 + palette.border = Color32::from_rgb(0x30, 0x36, 0x3d); + palette.background = Color32::from_rgb(0x0d, 0x11, 0x17); + palette.foreground = Color32::from_rgb(0xc9, 0xd1, 0xd9); + palette.muted_foreground = Color32::from_rgb(0x6e, 0x76, 0x81); + palette.accent = Color32::from_rgb(0x1f, 0x6f, 0xeb); // 蓝 accent(链接/选中) + palette.accent_foreground = Color32::WHITE; } else { - // redisant industrial blue accent - palette.primary = Color32::from_rgb(0x3b, 0x9a, 0xe8); + palette.primary = Color32::from_rgb(0x15, 0x80, 0x3d); // 浅色主操作深绿 palette.primary_foreground = Color32::WHITE; - palette.destructive = Color32::from_rgb(0xc8, 0x33, 0x36); + palette.destructive = Color32::from_rgb(0xb9, 0x1c, 0x1c); palette.destructive_foreground = Color32::WHITE; - palette.ring = Color32::from_rgb(0x3b, 0x9a, 0xe8); - palette.border = Color32::from_rgb(0xd0, 0xd0, 0xd0); - palette.background = Color32::from_rgb(0xf5, 0xf5, 0xf5); - palette.foreground = Color32::from_rgb(0x33, 0x33, 0x33); - palette.muted_foreground = Color32::from_rgb(0x66, 0x66, 0x66); - palette.accent = palette.primary; - palette.accent_foreground = palette.primary_foreground; + palette.ring = Color32::from_rgb(0x25, 0x63, 0xeb); + palette.border = Color32::from_rgb(0xd4, 0xd4, 0xd8); + palette.background = Color32::from_rgb(0xfa, 0xfa, 0xfa); + palette.foreground = Color32::from_rgb(0x3f, 0x3f, 0x46); + palette.muted_foreground = Color32::from_rgb(0x71, 0x71, 0x7a); + palette.accent = Color32::from_rgb(0x25, 0x63, 0xeb); + palette.accent_foreground = Color32::WHITE; } egui_shadcn::Theme::new(palette) } @@ -239,3 +224,43 @@ pub fn toggle_switch(ui: &mut Ui, flavor: Flavor, value: &mut bool) -> Response ) .inner } + +/// Panel header: heading title + optional muted breadcrumb on a second line. +/// Used by Slave's CentralPanel header ("FC03 保持寄存器" / "slave_1 · 20001 行"). +pub fn panel_header(ui: &mut Ui, flavor: Flavor, title: &str, crumb: Option<&str>) { + ui.vertical(|ui| { + ui.label( + RichText::new(title) + .heading() + .color(theme::text_primary(flavor)), + ); + if let Some(c) = crumb { + theme::text::crumb(ui, flavor, c); + } + }); +} + +/// Borderless text-action: "停止" / "删除连接" / "关闭". Hovers to accent_fg +/// (or `danger(flavor)` when `danger=true`). Returns the click `Response`. +pub fn link_action(ui: &mut Ui, flavor: Flavor, label: &str, danger: bool) -> Response { + let base = theme::text_muted(flavor); + let resp = ui.add( + egui::Label::new(RichText::new(label).color(base).size(11.5)) + .sense(egui::Sense::click()), + ); + if resp.hovered() { + let hover = if danger { + theme::danger(flavor) + } else { + theme::accent_fg(flavor) + }; + ui.painter().text( + resp.rect.left_center(), + egui::Align2::LEFT_CENTER, + label, + egui::FontId::proportional(11.5), + hover, + ); + } + resp +} From bc11c374b1ce8dfaf7735cdfdeae2a2c1d3b633a Mon Sep 17 00:00:00 2001 From: kelsoprotein-lab Date: Tue, 21 Apr 2026 00:39:01 +0800 Subject: [PATCH 05/25] =?UTF-8?q?feat(log-panel):=20=E5=8D=95=E8=A1=8C=20h?= =?UTF-8?q?eader=20+=20=E5=8F=AF=E6=8A=98=E5=8F=A0=20+=20RX/TX=20=E6=94=B9?= =?UTF-8?q?=E7=AE=AD=E5=A4=B4=E7=AC=A6=E5=8F=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit LogPanelState 新增 collapsed 字段;render 把"标题/复选/列头"三行 合并为单行(折叠箭头 + 标题 + 计数 + RX/TX/过滤/清空/导出/关闭); 方向列宽 40→28、文本 RX/TX 改为 ←/→ 符号配 success/accent_fg 色; FC 列改 warn 橙、详情列改 text_body;表头走 tiny_caps;striped 关闭。 Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/modbussim-ui-shared/src/log_panel.rs | 104 ++++++++++++++------ 1 file changed, 73 insertions(+), 31 deletions(-) diff --git a/crates/modbussim-ui-shared/src/log_panel.rs b/crates/modbussim-ui-shared/src/log_panel.rs index b76e838..8791aaf 100644 --- a/crates/modbussim-ui-shared/src/log_panel.rs +++ b/crates/modbussim-ui-shared/src/log_panel.rs @@ -1,12 +1,13 @@ //! Shared communication-log panel: a bottom TopBottomPanel showing TX/RX //! entries with filter + search. Used by both the Slave and Master egui apps. -use egui::Color32; +use egui::RichText; use egui_extras::{Column, TableBuilder}; use modbussim_core::log_entry::{Direction, LogEntry}; pub struct LogPanelState { pub open: bool, + pub collapsed: bool, pub show_rx: bool, pub show_tx: bool, pub filter_text: String, @@ -16,6 +17,7 @@ impl LogPanelState { pub fn new() -> Self { Self { open: true, + collapsed: false, show_rx: true, show_tx: true, filter_text: String::new(), @@ -77,68 +79,108 @@ pub fn render( .inner_margin(egui::Margin::symmetric(14.0 as i8, 10.0 as i8)), ) .show(ctx, |ui| { + // 单行 header:折叠箭头 + 标题 + 计数 + 右侧操作组 ui.horizontal(|ui| { - ui.heading("通信日志"); + let chev = if state.collapsed { "▶" } else { "▼" }; + if ui + .add( + egui::Label::new(RichText::new(chev).size(11.0).color(crate::theme::text_muted(flavor))) + .sense(egui::Sense::click()), + ) + .clicked() + { + state.collapsed = !state.collapsed; + } + ui.label( + RichText::new("通信日志") + .strong() + .size(12.5) + .color(crate::theme::text_primary(flavor)), + ); if let Some(label) = conn_label { - ui.label(format!("· {} ({} 条)", label, cache.len())); + crate::theme::text::crumb( + ui, + flavor, + &format!("· {} · {} 条", label, cache.len()), + ); } else { - ui.label("(选中连接以查看)"); + crate::theme::text::crumb(ui, flavor, "· 选中连接以查看"); } ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - if crate::ui::secondary_button(ui, flavor, "关闭") - .on_hover_text("关闭日志面板") - .clicked() - { + if crate::ui::link_action(ui, flavor, "关闭", false).clicked() { action = LogPanelAction::Close; } - if crate::ui::secondary_button(ui, flavor, "导出 CSV").clicked() { + if crate::ui::link_action(ui, flavor, "导出 CSV", false).clicked() { action = LogPanelAction::Export; } - if crate::ui::secondary_button(ui, flavor, "清空").clicked() { + if crate::ui::link_action(ui, flavor, "清空", false).clicked() { action = LogPanelAction::Clear; } + ui.add( + egui::TextEdit::singleline(&mut state.filter_text) + .hint_text("过滤…") + .desired_width(160.0), + ); + ui.checkbox(&mut state.show_tx, "TX"); + ui.checkbox(&mut state.show_rx, "RX"); }); }); - ui.horizontal(|ui| { - ui.checkbox(&mut state.show_rx, "RX"); - ui.checkbox(&mut state.show_tx, "TX"); - ui.label("过滤"); - ui.text_edit_singleline(&mut state.filter_text); - }); - ui.add_space(4.0); + + if state.collapsed { + return; + } + ui.add_space(6.0); let entries: Vec<&LogEntry> = cache.iter().rev().filter(|e| accepts(state, e)).collect(); TableBuilder::new(ui) - .striped(true) + .striped(false) .resizable(true) .cell_layout(egui::Layout::left_to_right(egui::Align::Center)) .column(Column::exact(150.0)) - .column(Column::exact(40.0)) + .column(Column::exact(28.0)) .column(Column::exact(60.0)) .column(Column::remainder()) - .header(20.0, |mut h| { - h.col(|ui| { ui.strong("时间"); }); - h.col(|ui| { ui.strong("方向"); }); - h.col(|ui| { ui.strong("FC"); }); - h.col(|ui| { ui.strong("详情"); }); + .header(22.0, |mut h| { + h.col(|ui| crate::theme::text::tiny_caps(ui, flavor, "时间")); + h.col(|ui| crate::theme::text::tiny_caps(ui, flavor, "向")); + h.col(|ui| crate::theme::text::tiny_caps(ui, flavor, "FC")); + h.col(|ui| crate::theme::text::tiny_caps(ui, flavor, "详情")); }) .body(|body| { body.rows(18.0, entries.len(), |mut row| { let e = entries[row.index()]; row.col(|ui| { - ui.monospace(e.timestamp.format("%H:%M:%S%.3f").to_string()); + ui.add(egui::Label::new( + RichText::new(e.timestamp.format("%H:%M:%S%.3f").to_string()) + .monospace() + .color(crate::theme::text_muted(flavor)), + )); }); row.col(|ui| { - let (t, c) = match e.direction { - Direction::Rx => ("RX", Color32::from_rgb(80, 160, 255)), - Direction::Tx => ("TX", Color32::from_rgb(255, 160, 80)), + let (sym, c) = match e.direction { + Direction::Rx => ("←", crate::theme::success(flavor)), + Direction::Tx => ("→", crate::theme::accent_fg(flavor)), }; - ui.colored_label(c, t); + ui.add(egui::Label::new( + RichText::new(sym).color(c).strong().monospace(), + )); + }); + row.col(|ui| { + ui.add(egui::Label::new( + RichText::new(e.function_code.name()) + .monospace() + .color(crate::theme::warn(flavor)), + )); + }); + row.col(|ui| { + ui.add(egui::Label::new( + RichText::new(&e.detail) + .monospace() + .color(crate::theme::text_body(flavor)), + )); }); - row.col(|ui| { ui.monospace(e.function_code.name()); }); - row.col(|ui| { ui.monospace(&e.detail); }); }); }); }); From bb460733c5773266e34aa17e8e84154573f98f90 Mon Sep 17 00:00:00 2001 From: kelsoprotein-lab Date: Tue, 21 Apr 2026 00:39:07 +0800 Subject: [PATCH 06/25] =?UTF-8?q?docs(ui-shared):=20=E6=9B=B4=E6=96=B0?= =?UTF-8?q?=E6=A8=A1=E5=9D=97=E6=B3=A8=E9=87=8A=E5=8F=8D=E6=98=A0=E5=86=B7?= =?UTF-8?q?=E8=93=9D=20palette=20=E4=B8=8E=E6=96=B0=20helper?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- crates/modbussim-ui-shared/src/ui.rs | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/crates/modbussim-ui-shared/src/ui.rs b/crates/modbussim-ui-shared/src/ui.rs index 0f617f5..f148a64 100644 --- a/crates/modbussim-ui-shared/src/ui.rs +++ b/crates/modbussim-ui-shared/src/ui.rs @@ -1,8 +1,9 @@ -//! Small reusable UI building blocks: region, card, primary_button, status_pill. +//! Small reusable UI building blocks: region, card, primary_button, status_pill, +//! panel_header, link_action. //! -//! Visual defaults: Darcula three-level bg layering, orange accent -//! (#cc7832 primary fill), no default stroke on buttons — hover relies on -//! bg_hover fill instead of borders. +//! Visual defaults: cold-blue palette (#0d1117 surface), green primary action +//! (#3fb950 "+ 批量添加"), blue accent_fg (#58a6ff links/hover). No hardcoded +//! RGB — all colors delegated to `theme::` token functions. use egui::{Color32, Response, RichText, Ui}; From cf0fe1bba5e378dde93af343b6ccef61562abc46 Mon Sep 17 00:00:00 2001 From: kelsoprotein-lab Date: Tue, 21 Apr 2026 00:40:44 +0800 Subject: [PATCH 07/25] =?UTF-8?q?feat(slave-app):=20SidePanel=20=E6=94=B6?= =?UTF-8?q?=E7=AA=84=20240=20+=20=E5=A4=B4=E9=83=A8=20tiny=5Fcaps=20+=20sh?= =?UTF-8?q?adcn=20=E5=88=9B=E5=BB=BA=E6=8C=89=E9=92=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 宽度 320→240、min_width 200;frame inner_margin 统一 (14,12); heading "连接" 改 tiny_caps;"新建 TCP 连接" 加 + 前缀;表单内 "创建" 按钮改用 shadcn primary_button (绿)。 Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/modbussim-egui/src/app.rs | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/crates/modbussim-egui/src/app.rs b/crates/modbussim-egui/src/app.rs index 346c8d2..3e8889a 100644 --- a/crates/modbussim-egui/src/app.rs +++ b/crates/modbussim-egui/src/app.rs @@ -2797,18 +2797,19 @@ impl eframe::App for SlaveApp { egui::SidePanel::left("connections") .resizable(true) - .default_width(320.0) + .default_width(240.0) + .min_width(200.0) .show_separator_line(false) .frame( egui::Frame::none() .fill(theme::bg_of(self.flavor, theme::Layer::L0)) - .inner_margin(egui::Margin::symmetric(12.0 as i8, 10.0 as i8)), + .inner_margin(egui::Margin::symmetric(14.0 as i8, 12.0 as i8)), ) .show(ctx, |ui| { - ui.heading("连接"); - ui.separator(); + theme::text::tiny_caps(ui, self.flavor, "连接"); + ui.add_space(6.0); - ui.collapsing("新建 TCP 连接", |ui| { + ui.collapsing("+ 新建 TCP 连接", |ui| { egui::Grid::new("new_tcp_form") .num_columns(2) .spacing([8.0, 4.0]) @@ -2820,12 +2821,12 @@ impl eframe::App for SlaveApp { ui.text_edit_singleline(&mut self.new_port); ui.end_row(); }); - if ui.button("创建").clicked() { + if uikit::primary_button(ui, self.flavor, "创建").clicked() { tree_action = Some(TreeAction::Create); } }); - ui.separator(); + ui.add_space(8.0); egui::ScrollArea::vertical().show(ui, |ui| { if let Some(a) = self.render_tree(ui) { From 4998d38eba59454fa38091a607ea941edf5468f4 Mon Sep 17 00:00:00 2001 From: kelsoprotein-lab Date: Tue, 21 Apr 2026 00:42:57 +0800 Subject: [PATCH 08/25] =?UTF-8?q?feat(slave-app):=20=E4=B8=BB=E5=8C=BA?= =?UTF-8?q?=E5=A4=B4=E6=94=B9=20panel=5Fheader=20+=20=E8=A1=A8=E5=A4=B4?= =?UTF-8?q?=E6=94=B9=20tiny=5Fcaps=20+=20=E5=85=B3=E6=8E=89=20striped?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 寄存器视图头部:heading + caption 合并成 panel_header(标题 + 面包屑 "slave_id · 从站 N"),搜索框宽度 160→220 给"地址 / 别名"留位; 表头高度 22→26,表头文字全部走 tiny_caps(10.5 蓝小大写); Hex/Binary 改中文 "HEX/二进制";两段 TableBuilder 都关掉 striped (与未来选中/hover 行底色冲突)。 Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/modbussim-egui/src/app.rs | 201 ++++++++++++++++++++++++------- 1 file changed, 155 insertions(+), 46 deletions(-) diff --git a/crates/modbussim-egui/src/app.rs b/crates/modbussim-egui/src/app.rs index 3e8889a..ec2017a 100644 --- a/crates/modbussim-egui/src/app.rs +++ b/crates/modbussim-egui/src/app.rs @@ -278,6 +278,7 @@ pub struct SlaveApp { selection: Selection, new_host: String, new_port: String, + show_new_tcp_dialog: bool, last_error: Option, // Event-driven snapshot (never read from Arc> on the UI thread). @@ -474,6 +475,7 @@ impl SlaveApp { selection: Selection::None, new_host: "0.0.0.0".to_string(), new_port: "5502".to_string(), + show_new_tcp_dialog: false, last_error: None, conn_snapshot: Vec::new(), next_conn_seq: Arc::new(AtomicU64::new(1)), @@ -2180,11 +2182,11 @@ impl SlaveApp { egui::Margin::symmetric(14.0 as i8, 10.0 as i8), |ui| { ui.horizontal(|ui| { - ui.heading(format!("{} {}", reg_icon, group_label)); - uikit::caption( + uikit::panel_header( ui, flavor, - format!("连接 {} · 从站 {}", conn_id, slave_id), + &format!("{} {}", reg_icon, group_label), + Some(&format!("{} · 从站 {}", conn_id, slave_id)), ); ui.with_layout( egui::Layout::right_to_left(egui::Align::Center), @@ -2202,7 +2204,7 @@ impl SlaveApp { let resp = ui.add( egui::TextEdit::singleline(&mut search_text) .hint_text("地址 / 名称…") - .desired_width(160.0), + .desired_width(220.0), ); if want_focus { resp.request_focus(); @@ -2313,7 +2315,7 @@ impl SlaveApp { }; let avail_h = ui.available_height(); TableBuilder::new(ui) - .striped(true) + .striped(false) .resizable(true) .max_scroll_height(avail_h) .cell_layout(egui::Layout::left_to_right(egui::Align::Center)) @@ -2321,11 +2323,11 @@ impl SlaveApp { .column(Column::exact(220.0)) .column(Column::exact(200.0)) .column(Column::remainder()) - .header(22.0, |mut h| { - h.col(|ui| { ui.strong("地址"); }); - h.col(|ui| { ui.strong(mode.label()); }); - h.col(|ui| { ui.strong("Raw (Hex)"); }); - h.col(|ui| { ui.strong(""); }); + .header(26.0, |mut h| { + h.col(|ui| theme::text::tiny_caps(ui, flavor, "地址")); + h.col(|ui| theme::text::tiny_caps(ui, flavor, mode.label())); + h.col(|ui| theme::text::tiny_caps(ui, flavor, "Raw HEX")); + h.col(|_| {}); }) .body(|body| { body.rows(row_h, group_rows, |mut row| { @@ -2455,7 +2457,7 @@ impl SlaveApp { // Use initial() + at_least() + clip(true) so users can drag column // dividers; at_least prevents dragging to 0 (would hide column). let mut tb = TableBuilder::new(ui) - .striped(true) + .striped(false) .resizable(true) .max_scroll_height(avail_h) .cell_layout(egui::Layout::left_to_right(egui::Align::Center)) @@ -2484,17 +2486,17 @@ impl SlaveApp { }); let defs = view.defs.clone(); tb - .header(22.0, |mut header| { - header.col(|ui| { ui.strong("地址"); }); + .header(26.0, |mut header| { + header.col(|ui| theme::text::tiny_caps(ui, flavor, "地址")); if is_bool { - header.col(|ui| { ui.strong("值"); }); - header.col(|ui| { ui.strong("名称"); }); - header.col(|ui| { ui.strong("注释"); }); + header.col(|ui| theme::text::tiny_caps(ui, flavor, "值")); + header.col(|ui| theme::text::tiny_caps(ui, flavor, "名称")); + header.col(|ui| theme::text::tiny_caps(ui, flavor, "注释")); } else { - header.col(|ui| { ui.strong(mode.label()); }); - header.col(|ui| { ui.strong("Hex"); }); - header.col(|ui| { ui.strong("Binary"); }); - header.col(|ui| { ui.strong(""); }); + header.col(|ui| theme::text::tiny_caps(ui, flavor, mode.label())); + header.col(|ui| theme::text::tiny_caps(ui, flavor, "HEX")); + header.col(|ui| theme::text::tiny_caps(ui, flavor, "二进制")); + header.col(|_| {}); } }) .body(|body| { @@ -2803,36 +2805,143 @@ impl eframe::App for SlaveApp { .frame( egui::Frame::none() .fill(theme::bg_of(self.flavor, theme::Layer::L0)) - .inner_margin(egui::Margin::symmetric(14.0 as i8, 12.0 as i8)), + .inner_margin(egui::Margin::same(0)), ) .show(ctx, |ui| { - theme::text::tiny_caps(ui, self.flavor, "连接"); - ui.add_space(6.0); - - ui.collapsing("+ 新建 TCP 连接", |ui| { - egui::Grid::new("new_tcp_form") - .num_columns(2) - .spacing([8.0, 4.0]) - .show(ui, |ui| { - ui.label("Host"); - ui.text_edit_singleline(&mut self.new_host); - ui.end_row(); - ui.label("Port"); - ui.text_edit_singleline(&mut self.new_port); - ui.end_row(); - }); - if uikit::primary_button(ui, self.flavor, "创建").clicked() { - tree_action = Some(TreeAction::Create); - } - }); + ui.allocate_ui_with_layout( + ui.available_size(), + egui::Layout::top_down(egui::Align::Min), + |ui| { + // —— 头部:tiny_caps "连接" + 右上 + 新建 —— + egui::Frame::none() + .inner_margin(egui::Margin { left: 14, right: 10, top: 12, bottom: 8 }) + .show(ui, |ui| { + ui.horizontal(|ui| { + theme::text::tiny_caps(ui, self.flavor, "连接"); + ui.with_layout( + egui::Layout::right_to_left(egui::Align::Center), + |ui| { + if uikit::link_action(ui, self.flavor, "+ 新建", false) + .clicked() + { + self.show_new_tcp_dialog = + !self.show_new_tcp_dialog; + } + }, + ); + }); + }); - ui.add_space(8.0); + // —— 新建 TCP 表单(可折叠)—— + if self.show_new_tcp_dialog { + egui::Frame::none() + .fill(theme::bg_of(self.flavor, theme::Layer::L2)) + .inner_margin(egui::Margin { left: 14, right: 10, top: 6, bottom: 8 }) + .show(ui, |ui| { + egui::Grid::new("new_tcp_form") + .num_columns(2) + .spacing([8.0, 4.0]) + .show(ui, |ui| { + ui.label("Host"); + ui.text_edit_singleline(&mut self.new_host); + ui.end_row(); + ui.label("Port"); + ui.text_edit_singleline(&mut self.new_port); + ui.end_row(); + }); + ui.add_space(4.0); + ui.horizontal(|ui| { + if uikit::primary_button(ui, self.flavor, "创建").clicked() { + tree_action = Some(TreeAction::Create); + self.show_new_tcp_dialog = false; + } + if uikit::link_action(ui, self.flavor, "取消", false) + .clicked() + { + self.show_new_tcp_dialog = false; + } + }); + }); + } - egui::ScrollArea::vertical().show(ui, |ui| { - if let Some(a) = self.render_tree(ui) { - tree_action = Some(a); - } - }); + // —— 树:可滚动区(为 footer 留 40px)—— + egui::ScrollArea::vertical() + .auto_shrink([false, false]) + .max_height(ui.available_height() - 40.0) + .show(ui, |ui| { + egui::Frame::none() + .inner_margin(egui::Margin { + left: 8, + right: 8, + top: 0, + bottom: 0, + }) + .show(ui, |ui| { + if let Some(a) = self.render_tree(ui) { + tree_action = Some(a); + } + }); + }); + + // —— footer:停止 / 删除连接 —— + ui.with_layout(egui::Layout::bottom_up(egui::Align::Min), |ui| { + egui::Frame::none() + .fill(theme::bg_of(self.flavor, theme::Layer::L0)) + .stroke(egui::Stroke::new( + 1.0, + theme::border_subtle(self.flavor), + )) + .inner_margin(egui::Margin { + left: 14, + right: 14, + top: 8, + bottom: 10, + }) + .show(ui, |ui| { + // Derive active connection id + state from selection + let active_conn = selection_conn_id(&self.selection) + .and_then(|id| { + self.conn_snapshot.iter().find(|s| s.id == id) + }); + if let Some(snap) = active_conn { + let conn_id = snap.id.clone(); + let is_running = + snap.state == ConnectionState::Running; + ui.horizontal(|ui| { + let stop_label = + if is_running { "停止" } else { "启动" }; + if uikit::link_action( + ui, + self.flavor, + stop_label, + false, + ) + .clicked() + { + tree_action = Some(if is_running { + TreeAction::StopConn(conn_id.clone()) + } else { + TreeAction::StartConn(conn_id.clone()) + }); + } + ui.add_space(14.0); + if uikit::link_action( + ui, + self.flavor, + "删除连接", + true, + ) + .clicked() + { + tree_action = + Some(TreeAction::RemoveConn(conn_id)); + } + }); + } + }); + }); + }, + ); }); let mut clear_error = false; From bcaf0e20f8320bb0704a8e901a133c4953f728ab Mon Sep 17 00:00:00 2001 From: kelsoprotein-lab Date: Tue, 21 Apr 2026 00:44:08 +0800 Subject: [PATCH 09/25] =?UTF-8?q?feat(slave-app):=20SidePanel=20=E9=87=8D?= =?UTF-8?q?=E6=9E=84=20=E2=80=94=20240px=20+=20=E5=A4=B4/=E6=A0=91/footer?= =?UTF-8?q?=20=E4=B8=89=E6=AE=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- crates/modbussim-egui/src/app.rs | 198 ++++++++++++++++++++++++++----- 1 file changed, 167 insertions(+), 31 deletions(-) diff --git a/crates/modbussim-egui/src/app.rs b/crates/modbussim-egui/src/app.rs index ec2017a..28a34fc 100644 --- a/crates/modbussim-egui/src/app.rs +++ b/crates/modbussim-egui/src/app.rs @@ -332,6 +332,10 @@ pub struct SlaveApp { log_cache: Vec, log_cache_conn_id: Option, log_last_refresh: Option, + + /// 右侧值解析面板是否显示。默认 true 兼容现有用户预期;可由 + /// `V` 快捷键 / 视图菜单 / 工具栏 toggle 关掉以让表格全宽。 + pub value_parse_open: bool, } pub struct BatchModalState { @@ -501,6 +505,7 @@ impl SlaveApp { log_cache: Vec::new(), log_cache_conn_id: None, log_last_refresh: None, + value_parse_open: true, } } @@ -1575,26 +1580,61 @@ fn endian_label(e: Endian) -> &'static str { impl SlaveApp { fn render_tree(&mut self, ui: &mut egui::Ui) -> Option { let mut action: Option = None; + let flavor = self.flavor; + let acc_color = theme::accent(flavor); + let acc_fg = theme::accent_fg(flavor); + let acc_fill = egui::Color32::from_rgba_unmultiplied(0x1f, 0x6f, 0xeb, 0x26); + let text_color = theme::text_body(flavor); + let muted_color = theme::text_muted(flavor); + + // Paint a 2px left stripe + light-blue fill for an active row rect. + let paint_active_row = |ui: &egui::Ui, rect: egui::Rect| { + let painter = ui.painter(); + painter.rect_filled(rect, 0.0, acc_fill); + let stripe_rect = + egui::Rect::from_min_size(rect.left_top(), egui::vec2(2.0, rect.height())); + painter.rect_filled(stripe_rect, 0.0, acc_color); + }; for snap in &self.conn_snapshot { - let conn_is_selected = matches!(&self.selection, Selection::Connection(c) if c == &snap.id); + let conn_is_selected = + matches!(&self.selection, Selection::Connection(c) if c == &snap.id); let state_tag = match snap.state { ConnectionState::Running => "运行中", ConnectionState::Stopped => "已停止", }; + let conn_label = format!("{} [{}]", snap.label, state_tag); + // Connection row ui.horizontal(|ui| { let arrow = if snap.expanded { "▼" } else { "▶" }; if ui.small_button(arrow).clicked() { action = Some(TreeAction::ToggleConn(snap.id.clone())); } - if ui - .selectable_label(conn_is_selected, format!("{} [{}]", snap.label, state_tag)) - .clicked() - { + let row_resp = ui.allocate_response( + egui::vec2(ui.available_width(), 22.0), + egui::Sense::click(), + ); + if conn_is_selected { + paint_active_row(ui, row_resp.rect); + } else if row_resp.hovered() { + ui.painter() + .rect_filled(row_resp.rect, 0.0, theme::bg_hover(flavor)); + } + let label_color = if conn_is_selected { acc_fg } else { text_color }; + ui.painter().text( + row_resp.rect.left_center() + egui::vec2(4.0, 0.0), + egui::Align2::LEFT_CENTER, + &conn_label, + egui::FontId::proportional(12.5), + label_color, + ); + if row_resp.clicked() { action = Some(TreeAction::SelectConn(snap.id.clone())); } }); + + // Per-connection start/stop/delete buttons ui.horizontal(|ui| { ui.add_space(18.0); match snap.state { @@ -1619,6 +1659,9 @@ impl SlaveApp { let dev_is_selected = matches!(&self.selection, Selection::Device { conn_id, slave_id } if conn_id == &snap.id && *slave_id == dev.slave_id); + let dev_label = format!("从站 {} · {}", dev.slave_id, dev.name); + + // Device row ui.horizontal(|ui| { ui.add_space(16.0); let arrow = if dev.expanded { "▼" } else { "▶" }; @@ -1628,28 +1671,63 @@ impl SlaveApp { slave_id: dev.slave_id, }); } - if ui - .selectable_label( - dev_is_selected, - format!("从站 {} · {}", dev.slave_id, dev.name), - ) - .clicked() - { + let row_resp = ui.allocate_response( + egui::vec2(ui.available_width(), 22.0), + egui::Sense::click(), + ); + if dev_is_selected { + paint_active_row(ui, row_resp.rect); + } else if row_resp.hovered() { + ui.painter() + .rect_filled(row_resp.rect, 0.0, theme::bg_hover(flavor)); + } + let label_color = if dev_is_selected { acc_fg } else { text_color }; + ui.painter().text( + row_resp.rect.left_center() + egui::vec2(4.0, 0.0), + egui::Align2::LEFT_CENTER, + &dev_label, + egui::FontId::proportional(12.5), + label_color, + ); + if row_resp.clicked() { action = Some(TreeAction::SelectDevice { conn_id: snap.id.clone(), slave_id: dev.slave_id, }); } }); + if dev.expanded { for (reg_type, label) in REG_GROUPS { let grp_is_selected = matches!(&self.selection, Selection::RegisterGroup { conn_id, slave_id, reg_type: rt } if conn_id == &snap.id && *slave_id == dev.slave_id && rt == reg_type); + let count = dev.counts.count_for(*reg_type); + let grp_label = format!("{} ({})", label, count); + + // Register group row ui.horizontal(|ui| { ui.add_space(32.0); - let text = format!("{} ({})", label, dev.counts.count_for(*reg_type)); - if ui.selectable_label(grp_is_selected, text).clicked() { + let row_resp = ui.allocate_response( + egui::vec2(ui.available_width(), 22.0), + egui::Sense::click(), + ); + if grp_is_selected { + paint_active_row(ui, row_resp.rect); + } else if row_resp.hovered() { + ui.painter() + .rect_filled(row_resp.rect, 0.0, theme::bg_hover(flavor)); + } + let label_color = + if grp_is_selected { acc_fg } else { muted_color }; + ui.painter().text( + row_resp.rect.left_center() + egui::vec2(4.0, 0.0), + egui::Align2::LEFT_CENTER, + &grp_label, + egui::FontId::proportional(12.5), + label_color, + ); + if row_resp.clicked() { action = Some(TreeAction::SelectGroup { conn_id: snap.id.clone(), slave_id: dev.slave_id, @@ -2201,6 +2279,15 @@ impl SlaveApp { open_batch = true; } ui.add_space(8.0); + let toggle_label = if self.value_parse_open { + "◧ 收起解析" + } else { + "◧ 值解析 (V)" + }; + if uikit::link_action(ui, flavor, toggle_label, false).clicked() { + self.value_parse_open = !self.value_parse_open; + } + ui.add_space(8.0); let resp = ui.add( egui::TextEdit::singleline(&mut search_text) .hint_text("地址 / 名称…") @@ -2738,6 +2825,25 @@ impl eframe::App for SlaveApp { self.want_focus_search = true; } + // 视图快捷键:仅在没有 TextEdit 持有焦点时生效,避免在搜索框/数值 + // 编辑器里输入字母触发面板切换。 + if !ctx.memory(|m| m.focused().is_some()) { + ctx.input_mut(|i| { + if i.consume_key(egui::Modifiers::NONE, egui::Key::V) { + self.value_parse_open = !self.value_parse_open; + } + if i.consume_key(egui::Modifiers::NONE, egui::Key::L) { + self.log_state.collapsed = !self.log_state.collapsed; + } + if i.consume_key(egui::Modifiers::NONE, egui::Key::Escape) + && !self.selected_addrs.is_empty() + { + self.selected_addrs.clear(); + self.click_anchor = None; + } + }); + } + // Fade highlights: drop any stale ones older than 2s. if let Some((_, _, _, _, t)) = &self.highlight { if t.elapsed().as_secs_f32() > 2.0 { @@ -2803,7 +2909,7 @@ impl eframe::App for SlaveApp { .min_width(200.0) .show_separator_line(false) .frame( - egui::Frame::none() + egui::Frame::new() .fill(theme::bg_of(self.flavor, theme::Layer::L0)) .inner_margin(egui::Margin::same(0)), ) @@ -2813,7 +2919,7 @@ impl eframe::App for SlaveApp { egui::Layout::top_down(egui::Align::Min), |ui| { // —— 头部:tiny_caps "连接" + 右上 + 新建 —— - egui::Frame::none() + egui::Frame::new() .inner_margin(egui::Margin { left: 14, right: 10, top: 12, bottom: 8 }) .show(ui, |ui| { ui.horizontal(|ui| { @@ -2834,7 +2940,7 @@ impl eframe::App for SlaveApp { // —— 新建 TCP 表单(可折叠)—— if self.show_new_tcp_dialog { - egui::Frame::none() + egui::Frame::new() .fill(theme::bg_of(self.flavor, theme::Layer::L2)) .inner_margin(egui::Margin { left: 14, right: 10, top: 6, bottom: 8 }) .show(ui, |ui| { @@ -2869,7 +2975,7 @@ impl eframe::App for SlaveApp { .auto_shrink([false, false]) .max_height(ui.available_height() - 40.0) .show(ui, |ui| { - egui::Frame::none() + egui::Frame::new() .inner_margin(egui::Margin { left: 8, right: 8, @@ -2885,7 +2991,7 @@ impl eframe::App for SlaveApp { // —— footer:停止 / 删除连接 —— ui.with_layout(egui::Layout::bottom_up(egui::Align::Min), |ui| { - egui::Frame::none() + egui::Frame::new() .fill(theme::bg_of(self.flavor, theme::Layer::L0)) .stroke(egui::Stroke::new( 1.0, @@ -2946,26 +3052,56 @@ impl eframe::App for SlaveApp { let mut clear_error = false; let mut clear_status = false; + let conn_count = self.conn_snapshot.len(); + let slave_count: usize = self.conn_snapshot.iter().map(|c| c.devices.len()).sum(); + let flavor = self.flavor; egui::TopBottomPanel::bottom("status_bar") .resizable(false) + .exact_height(22.0) + .show_separator_line(false) + .frame( + egui::Frame::none() + .fill(theme::bg_of(flavor, theme::Layer::L0)) + .inner_margin(egui::Margin::symmetric(14.0 as i8, 4.0 as i8)), + ) .show(ctx, |ui| { - if let Some(err) = &self.last_error { - ui.horizontal(|ui| { - ui.colored_label(egui::Color32::RED, err); - if ui.small_button("清除").clicked() { + ui.horizontal(|ui| { + if let Some(err) = &self.last_error { + ui.add(egui::Label::new( + egui::RichText::new("●").color(theme::danger(flavor)).size(11.0), + )); + ui.add(egui::Label::new( + egui::RichText::new(err).color(theme::danger(flavor)).size(11.0), + )); + if uikit::link_action(ui, flavor, "清除", false).clicked() { clear_error = true; } - }); - } else if let Some(msg) = &self.status_msg { - ui.horizontal(|ui| { - ui.colored_label(egui::Color32::from_rgb(60, 140, 60), msg); - if ui.small_button("清除").clicked() { + } else if let Some(msg) = &self.status_msg { + ui.add(egui::Label::new( + egui::RichText::new("●").color(theme::success(flavor)).size(11.0), + )); + ui.add(egui::Label::new( + egui::RichText::new(msg).color(theme::success(flavor)).size(11.0), + )); + if uikit::link_action(ui, flavor, "清除", false).clicked() { clear_status = true; } + } else { + ui.add(egui::Label::new( + egui::RichText::new("●").color(theme::success(flavor)).size(11.0), + )); + theme::text::crumb(ui, flavor, "就绪"); + } + ui.add_space(14.0); + theme::text::crumb( + ui, + flavor, + &format!("{} 连接 · {} 从站", conn_count, slave_count), + ); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + theme::text::crumb(ui, flavor, env!("CARGO_PKG_VERSION")); }); - } else { - ui.label("就绪"); - } + }); }); if clear_error { self.last_error = None; } if clear_status { self.status_msg = None; } From 986d4d4013410f2e79cc8854c99c5f1d1b3207f5 Mon Sep 17 00:00:00 2001 From: kelsoprotein-lab Date: Tue, 21 Apr 2026 00:46:38 +0800 Subject: [PATCH 10/25] =?UTF-8?q?feat(slave-app):=20=E5=80=BC=E8=A7=A3?= =?UTF-8?q?=E6=9E=90=E5=8F=AF=E9=9A=90=E8=97=8F=20+=20=E7=8A=B6=E6=80=81?= =?UTF-8?q?=E6=A0=8F=E8=A7=86=E8=A7=89=E5=8C=96=20+=20V/L/Esc=20=E5=BF=AB?= =?UTF-8?q?=E6=8D=B7=E9=94=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 SlaveApp.value_parse_open: bool 字段(默认 true)。寄存器视图 工具栏加 ◧ toggle,关闭后 StripBuilder 切换为表格独占全宽 + 右 cell 0 宽(保持三段链结构)。 底部 status_bar 重新装饰:固定 22px、L0 chrome 底,左侧 ●(绿/红)+ 状态文字 / 就绪 + "N 连接 · M 从站",右侧版本号。"清除" 改用 link_action(无边框)。 update 顶部加快捷键(无 widget 焦点时生效):V 切值解析、L 折叠 日志、Esc 清空选中行。 Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/modbussim-egui/src/app.rs | 23 +++++++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/crates/modbussim-egui/src/app.rs b/crates/modbussim-egui/src/app.rs index 28a34fc..f88bac2 100644 --- a/crates/modbussim-egui/src/app.rs +++ b/crates/modbussim-egui/src/app.rs @@ -2375,15 +2375,30 @@ impl SlaveApp { // the TableBuilder closure releases borrows. let mut row_clicks: Vec<(u16, egui::Modifiers)> = Vec::new(); - // Left = table (~62% wide, fills vertical), right = ValuePanel. + // Left = table, right = ValuePanel (按 self.value_parse_open 切换)。 // StripBuilder is the right primitive here — a plain // ui.horizontal + allocate_ui collapses to 0 height inside a // CentralPanel and draws the debug red warning box. use egui_extras::{Size, StripBuilder}; + let value_open = self.value_parse_open; + let (table_size, gap_size, panel_size) = if value_open { + ( + Size::relative(0.62).at_least(360.0), + Size::exact(8.0), + Size::remainder().at_least(260.0), + ) + } else { + // Hidden: 表格独占全宽;右 cell 仍占 0 宽,保持三段链结构。 + ( + Size::remainder().at_least(360.0), + Size::exact(0.0), + Size::exact(0.0), + ) + }; StripBuilder::new(ui) - .size(Size::relative(0.62).at_least(360.0)) - .size(Size::exact(8.0)) - .size(Size::remainder().at_least(260.0)) + .size(table_size) + .size(gap_size) + .size(panel_size) .horizontal(|mut strip| { strip.cell(|ui| { uikit::region(ui, flavor, theme::Layer::L2, egui::Margin::symmetric(8.0 as i8, 6.0 as i8), |ui| { From 1ab3072e00d07ed03a3f6ffd91bc73dfbf1dcee6 Mon Sep 17 00:00:00 2001 From: kelsoprotein-lab Date: Tue, 21 Apr 2026 00:48:33 +0800 Subject: [PATCH 11/25] =?UTF-8?q?style(workspace):=20cargo=20fmt=20--all?= =?UTF-8?q?=20=E6=95=B4=E4=BD=93=E8=A7=84=E6=95=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 修复工作区累积的格式偏差。无逻辑改动,只是 rustfmt 重排。 Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/modbusmaster-app/build.rs | 2 +- crates/modbusmaster-app/src/commands.rs | 132 +- crates/modbusmaster-app/src/main.rs | 2 +- crates/modbusmaster-egui/src/app.rs | 672 +++++---- crates/modbusmaster-egui/src/main.rs | 4 +- crates/modbussim-app/build.rs | 2 +- crates/modbussim-app/src/commands.rs | 250 ++-- crates/modbussim-app/src/main.rs | 2 +- crates/modbussim-core/examples/run_slave.rs | 9 +- crates/modbussim-core/examples/test_e2e.rs | 26 +- crates/modbussim-core/examples/test_master.rs | 13 +- crates/modbussim-core/src/config.rs | 46 +- crates/modbussim-core/src/data_source.rs | 101 +- crates/modbussim-core/src/error.rs | 32 +- crates/modbussim-core/src/jitter.rs | 24 +- crates/modbussim-core/src/lib.rs | 36 +- crates/modbussim-core/src/log_collector.rs | 16 +- crates/modbussim-core/src/log_entry.rs | 32 +- crates/modbussim-core/src/log_helpers.rs | 6 +- crates/modbussim-core/src/master.rs | 234 ++-- crates/modbussim-core/src/parse.rs | 57 +- crates/modbussim-core/src/pdu.rs | 24 +- crates/modbussim-core/src/project.rs | 31 +- crates/modbussim-core/src/reconnect.rs | 3 +- crates/modbussim-core/src/register.rs | 60 +- crates/modbussim-core/src/rtu_slave.rs | 18 +- crates/modbussim-core/src/slave.rs | 78 +- crates/modbussim-core/src/tls_master.rs | 19 +- crates/modbussim-core/src/tls_slave.rs | 16 +- crates/modbussim-core/src/tools.rs | 25 +- .../tests/master_integration.rs | 4 +- .../modbussim-core/tests/slave_integration.rs | 20 +- crates/modbussim-core/tests/tls_e2e.rs | 31 +- crates/modbussim-egui/src/app.rs | 1212 ++++++++++------- crates/modbussim-egui/src/main.rs | 4 +- crates/modbussim-ui-shared/src/fonts.rs | 17 +- crates/modbussim-ui-shared/src/log_panel.rs | 8 +- crates/modbussim-ui-shared/src/project.rs | 5 +- crates/modbussim-ui-shared/src/theme.rs | 181 ++- crates/modbussim-ui-shared/src/ui.rs | 28 +- crates/modbussim-ui-shared/src/value_panel.rs | 99 +- 41 files changed, 2246 insertions(+), 1335 deletions(-) diff --git a/crates/modbusmaster-app/build.rs b/crates/modbusmaster-app/build.rs index 795b9b7..d860e1e 100644 --- a/crates/modbusmaster-app/build.rs +++ b/crates/modbusmaster-app/build.rs @@ -1,3 +1,3 @@ fn main() { - tauri_build::build() + tauri_build::build() } diff --git a/crates/modbusmaster-app/src/commands.rs b/crates/modbusmaster-app/src/commands.rs index b2ac907..8cf5c8a 100644 --- a/crates/modbusmaster-app/src/commands.rs +++ b/crates/modbusmaster-app/src/commands.rs @@ -16,7 +16,7 @@ use modbussim_core::master::{ }; use modbussim_core::parse::{parse_read_function, read_function_to_string}; use modbussim_core::tools; -use modbussim_core::transport::{self, Transport, SerialConfig, Parity, TlsConfig}; +use modbussim_core::transport::{self, Parity, SerialConfig, TlsConfig, Transport}; use serde::{Deserialize, Serialize}; use std::sync::Arc; use std::time::Duration; @@ -117,11 +117,32 @@ fn chrono_like_now() -> String { #[derive(Debug, Deserialize)] #[serde(tag = "type", rename_all = "snake_case")] pub enum TransportRequest { - Tcp { host: String, port: u16 }, - TcpTls { host: String, port: u16 }, - Rtu { serial_port: String, baud_rate: u32, data_bits: u8, stop_bits: u8, parity: String }, - Ascii { serial_port: String, baud_rate: u32, data_bits: u8, stop_bits: u8, parity: String }, - RtuOverTcp { host: String, port: u16 }, + Tcp { + host: String, + port: u16, + }, + TcpTls { + host: String, + port: u16, + }, + Rtu { + serial_port: String, + baud_rate: u32, + data_bits: u8, + stop_bits: u8, + parity: String, + }, + Ascii { + serial_port: String, + baud_rate: u32, + data_bits: u8, + stop_bits: u8, + parity: String, + }, + RtuOverTcp { + host: String, + port: u16, + }, } fn parse_parity(s: &str) -> Parity { @@ -134,29 +155,44 @@ fn parse_parity(s: &str) -> Parity { fn to_transport(req: &TransportRequest) -> Transport { match req { - TransportRequest::Tcp { host, port } => Transport::Tcp { host: host.clone(), port: *port }, - TransportRequest::TcpTls { host, port } => Transport::TcpTls { host: host.clone(), port: *port }, - TransportRequest::Rtu { serial_port, baud_rate, data_bits, stop_bits, parity } => { - Transport::Rtu(SerialConfig { - port: serial_port.clone(), - baud_rate: *baud_rate, - data_bits: *data_bits, - stop_bits: *stop_bits, - parity: parse_parity(parity), - }) - } - TransportRequest::Ascii { serial_port, baud_rate, data_bits, stop_bits, parity } => { - Transport::Ascii(SerialConfig { - port: serial_port.clone(), - baud_rate: *baud_rate, - data_bits: *data_bits, - stop_bits: *stop_bits, - parity: parse_parity(parity), - }) - } - TransportRequest::RtuOverTcp { host, port } => { - Transport::RtuOverTcp { host: host.clone(), port: *port } - } + TransportRequest::Tcp { host, port } => Transport::Tcp { + host: host.clone(), + port: *port, + }, + TransportRequest::TcpTls { host, port } => Transport::TcpTls { + host: host.clone(), + port: *port, + }, + TransportRequest::Rtu { + serial_port, + baud_rate, + data_bits, + stop_bits, + parity, + } => Transport::Rtu(SerialConfig { + port: serial_port.clone(), + baud_rate: *baud_rate, + data_bits: *data_bits, + stop_bits: *stop_bits, + parity: parse_parity(parity), + }), + TransportRequest::Ascii { + serial_port, + baud_rate, + data_bits, + stop_bits, + parity, + } => Transport::Ascii(SerialConfig { + port: serial_port.clone(), + baud_rate: *baud_rate, + data_bits: *data_bits, + stop_bits: *stop_bits, + parity: parse_parity(parity), + }), + TransportRequest::RtuOverTcp { host, port } => Transport::RtuOverTcp { + host: host.clone(), + port: *port, + }, } } @@ -217,7 +253,8 @@ pub async fn create_master_connection( }; let log_collector = Arc::new(LogCollector::new()); - let connection = MasterConnection::new(config.clone(), transport).with_log_collector(log_collector.clone()); + let connection = + MasterConnection::new(config.clone(), transport).with_log_collector(log_collector.clone()); let info = MasterConnectionInfo { id: id.clone(), @@ -507,7 +544,9 @@ pub async fn list_scan_groups( /// Internal helper: start polling for a single group and spawn bridge task. async fn start_polling_inner( app: &AppHandle, - master_conns: &std::sync::Arc>>, + master_conns: &std::sync::Arc< + tokio::sync::RwLock>, + >, connection_id: &str, group_id: &str, ) -> Result<(), String> { @@ -885,8 +924,7 @@ pub struct PlcToModbusResult { #[tauri::command] pub fn convert_plc_to_modbus(request: PlcToModbusRequest) -> Result { - let result = - tools::plc_to_modbus_address(request.plc_address).map_err(|e| format!("{}", e))?; + let result = tools::plc_to_modbus_address(request.plc_address).map_err(|e| format!("{}", e))?; Ok(PlcToModbusResult { register_type: format!("{:?}", result.address_type), modbus_address: result.address, @@ -959,7 +997,11 @@ pub async fn start_slave_id_scan( // Create cancel channel let (cancel_tx, cancel_rx) = oneshot::channel(); let scan_key = format!("{}:slave_scan", connection_id); - state.active_scans.write().await.insert(scan_key.clone(), cancel_tx); + state + .active_scans + .write() + .await + .insert(scan_key.clone(), cancel_tx); let (progress_tx, mut progress_rx) = mpsc::channel(32); let conn_id = connection_id.clone(); @@ -1005,7 +1047,6 @@ pub async fn start_slave_id_scan( Ok(()) } - #[derive(Debug, Deserialize)] pub struct RegisterScanRequest { pub function: String, @@ -1038,7 +1079,11 @@ pub async fn start_register_scan( // Create cancel channel let (cancel_tx, cancel_rx) = oneshot::channel(); let scan_key = format!("{}:register_scan", connection_id); - state.active_scans.write().await.insert(scan_key.clone(), cancel_tx); + state + .active_scans + .write() + .await + .insert(scan_key.clone(), cancel_tx); let (progress_tx, mut progress_rx) = mpsc::channel(32); let conn_id = connection_id.clone(); @@ -1126,10 +1171,7 @@ fn read_function_to_fc(function: ReadFunction) -> u8 { } #[tauri::command] -pub async fn save_project_file( - state: State<'_, AppState>, - path: String, -) -> Result<(), String> { +pub async fn save_project_file(state: State<'_, AppState>, path: String) -> Result<(), String> { let conns = state.master_connections.read().await; let mut proj = ProjectFile::new_master(); @@ -1183,16 +1225,18 @@ pub async fn save_project_file( name, transport: proj_transport, devices: vec![], - scan_groups: conn_state.scan_groups.iter().map(|sg| { - project::ScanGroupConfig { + scan_groups: conn_state + .scan_groups + .iter() + .map(|sg| project::ScanGroupConfig { name: sg.name.clone(), slave_id: sg.slave_id.unwrap_or(config.slave_id), function_code: read_function_to_fc(sg.function), start_address: sg.start_address, count: sg.quantity, interval_ms: sg.interval_ms, - } - }).collect(), + }) + .collect(), }; proj.connections.push(conn_config); } diff --git a/crates/modbusmaster-app/src/main.rs b/crates/modbusmaster-app/src/main.rs index ff985ea..33f5d34 100644 --- a/crates/modbusmaster-app/src/main.rs +++ b/crates/modbusmaster-app/src/main.rs @@ -2,5 +2,5 @@ #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] fn main() { - modbusmaster_app_lib::run(); + modbusmaster_app_lib::run(); } diff --git a/crates/modbusmaster-egui/src/app.rs b/crates/modbusmaster-egui/src/app.rs index a6a26d1..361478a 100644 --- a/crates/modbusmaster-egui/src/app.rs +++ b/crates/modbusmaster-egui/src/app.rs @@ -21,7 +21,6 @@ use modbussim_ui_shared::ui as uikit; use tokio::runtime::Runtime; use tokio::sync::RwLock; - pub struct MasterConnectionEntry { pub id: String, pub label: String, @@ -32,14 +31,38 @@ pub struct MasterConnectionEntry { pub type SharedConnections = Arc>>; pub enum UiEvent { - ConnectionCreated { id: String, label: String, slave_id: u8 }, - ConnectionStateChanged { id: String, state: MasterState }, + ConnectionCreated { + id: String, + label: String, + slave_id: u8, + }, + ConnectionStateChanged { + id: String, + state: MasterState, + }, ConnectionRemoved(String), - ReadDone { id: String, result: ReadResult }, - PollStarted { id: String, group_id: String }, - PollStopped { id: String, group_id: String }, - PollUpdate { id: String, group_id: String, result: ReadResult }, - PollError { id: String, group_id: String, msg: String }, + ReadDone { + id: String, + result: ReadResult, + }, + PollStarted { + id: String, + group_id: String, + }, + PollStopped { + id: String, + group_id: String, + }, + PollUpdate { + id: String, + group_id: String, + result: ReadResult, + }, + PollError { + id: String, + group_id: String, + msg: String, + }, PollConfigLoaded { id: String, group_id: String, @@ -55,8 +78,8 @@ pub enum UiEvent { /// One scan group belonging to a master connection. #[derive(Clone)] pub struct ScanGroupUi { - pub id: String, // stable id, used as core::ScanGroup.id - pub name: String, // user-facing label + pub id: String, // stable id, used as core::ScanGroup.id + pub name: String, // user-facing label pub fc: ReadFunction, pub addr: u16, pub qty: u16, @@ -447,7 +470,10 @@ impl MasterApp { let gid = format!("group_{}", self.next_group_seq); self.next_group_seq += 1; let mut g = ScanGroupUi::new_with_id(gid); - g.name = format!("组 {}", self.polling.get(&conn_id).map(|v| v.len() + 1).unwrap_or(1)); + g.name = format!( + "组 {}", + self.polling.get(&conn_id).map(|v| v.len() + 1).unwrap_or(1) + ); let list = self.polling.entry(conn_id.clone()).or_default(); list.push(g); let new_idx = list.len() - 1; @@ -460,7 +486,11 @@ impl MasterApp { if let Some(list) = self.polling.get_mut(&conn_id) { if group_idx < list.len() { list.remove(group_idx); - let new_sel = if list.is_empty() { 0 } else { group_idx.saturating_sub(1).min(list.len() - 1) }; + let new_sel = if list.is_empty() { + 0 + } else { + group_idx.saturating_sub(1).min(list.len() - 1) + }; self.selected_group.insert(conn_id, new_sel); } } @@ -610,7 +640,8 @@ impl MasterApp { match serialize_master(&proj) { Ok(json) => match tokio::fs::write(path.path(), json).await { Ok(()) => { - let _ = tx.send(UiEvent::Info(format!("已保存:{}", path.path().display()))); + let _ = + tx.send(UiEvent::Info(format!("已保存:{}", path.path().display()))); } Err(e) => { let _ = tx.send(UiEvent::Error(format!("写入失败: {e}"))); @@ -668,7 +699,10 @@ impl MasterApp { }; let connection = MasterConnection::new( config, - Transport::Tcp { host: tcp.host.clone(), port: tcp.port }, + Transport::Tcp { + host: tcp.host.clone(), + port: tcp.port, + }, ) .with_log_collector(log_collector.clone()); let id = format!("master_{}", next_seq.fetch_add(1, Ordering::Relaxed)); @@ -708,7 +742,11 @@ impl MasterApp { fn drain_events(&mut self) { while let Ok(ev) = self.events_rx.try_recv() { match ev { - UiEvent::ConnectionCreated { id, label, slave_id } => { + UiEvent::ConnectionCreated { + id, + label, + slave_id, + } => { self.snap.push(ConnSnap { id, label, @@ -740,7 +778,11 @@ impl MasterApp { g.enabled = false; } } - UiEvent::PollUpdate { id, group_id, result } => { + UiEvent::PollUpdate { + id, + group_id, + result, + } => { if let Some(g) = self.find_group_mut(&id, &group_id) { g.latest = Some(result); g.last_update = Some(Instant::now()); @@ -752,7 +794,14 @@ impl MasterApp { g.last_error = Some(msg); } } - UiEvent::PollConfigLoaded { id, group_id, fc, addr, qty, interval_ms } => { + UiEvent::PollConfigLoaded { + id, + group_id, + fc, + addr, + qty, + interval_ms, + } => { let list = self.polling.entry(id).or_default(); let mut g = ScanGroupUi::new_with_id(group_id); g.fc = fc; @@ -782,9 +831,15 @@ impl MasterApp { } else { self.log_cache.clear(); } - let Ok(entries) = self.connections.try_read() else { return }; - let Some(entry) = entries.iter().find(|e| e.id == id) else { return }; - let Some(mut all) = entry.log_collector.try_get_all() else { return }; + let Ok(entries) = self.connections.try_read() else { + return; + }; + let Some(entry) = entries.iter().find(|e| e.id == id) else { + return; + }; + let Some(mut all) = entry.log_collector.try_get_all() else { + return; + }; let start = all.len().saturating_sub(500); self.log_cache = all.drain(start..).collect(); self.log_cache_conn_id = Some(id); @@ -820,7 +875,12 @@ impl eframe::App for MasterApp { ui.checkbox(&mut self.log_state.open, "显示日志面板"); ui.separator(); ui.label("主题 (Catppuccin)"); - for f in [Flavor::Mocha, Flavor::Macchiato, Flavor::Frappe, Flavor::Latte] { + for f in [ + Flavor::Mocha, + Flavor::Macchiato, + Flavor::Frappe, + Flavor::Latte, + ] { if ui.radio_value(&mut self.flavor, f, f.label()).clicked() { theme::apply(ctx, self.flavor); ui.close_menu(); @@ -936,19 +996,27 @@ impl eframe::App for MasterApp { if let Some(err) = &self.last_error { ui.horizontal(|ui| { ui.colored_label(egui::Color32::RED, err); - if ui.small_button("清除").clicked() { clear_err = true; } + if ui.small_button("清除").clicked() { + clear_err = true; + } }); } else if let Some(msg) = &self.status_msg { ui.horizontal(|ui| { ui.colored_label(egui::Color32::from_rgb(60, 140, 60), msg); - if ui.small_button("清除").clicked() { clear_status = true; } + if ui.small_button("清除").clicked() { + clear_status = true; + } }); } else { ui.label("就绪"); } }); - if clear_err { self.last_error = None; } - if clear_status { self.status_msg = None; } + if clear_err { + self.last_error = None; + } + if clear_status { + self.status_msg = None; + } self.render_log_panel(ctx); @@ -962,11 +1030,7 @@ impl eframe::App for MasterApp { let Some(id) = self.selected.clone() else { ui.vertical_centered(|ui| { ui.add_space(60.0); - ui.label( - egui::RichText::new("ModbusMaster") - .size(18.0) - .strong(), - ); + ui.label(egui::RichText::new("ModbusMaster").size(18.0).strong()); uikit::caption(ui, flavor, "从左侧创建并连接一个会话。"); }); return; @@ -996,187 +1060,40 @@ impl eframe::App for MasterApp { ); ui.add_space(4.0); - uikit::region(ui, flavor, theme::Layer::L1, egui::Margin::symmetric(14.0 as i8, 10.0 as i8), |ui| { - // Tab bar: Read / Write / Poll - ui.horizontal(|ui| { - for tab in [MasterTab::Read, MasterTab::Write, MasterTab::Poll] { - let selected = self.active_tab == tab; - let text = if selected { - egui::RichText::new(tab.label()) - .strong() - .color(theme::accent(flavor)) - } else { - egui::RichText::new(tab.label()).color(theme::subtext(flavor)) - }; - if ui - .add(egui::SelectableLabel::new(selected, text)) - .clicked() - { - self.active_tab = tab; - } - } - }); - ui.separator(); - - // Tab content - match self.active_tab { - MasterTab::Read => { - egui::Grid::new("read_form") - .num_columns(2) - .spacing([10.0, 6.0]) - .show(ui, |ui| { - ui.label("功能码"); - egui::ComboBox::from_id_salt("read_fc") - .selected_text(read_fc_label(self.read_fc)) - .show_ui(ui, |ui| { - for f in [ - ReadFunction::ReadCoils, - ReadFunction::ReadDiscreteInputs, - ReadFunction::ReadHoldingRegisters, - ReadFunction::ReadInputRegisters, - ] { - ui.selectable_value(&mut self.read_fc, f, read_fc_label(f)); - } - }); - ui.end_row(); - ui.label("起始地址"); - let mut a = self.read_addr as u32; - ui.add(egui::DragValue::new(&mut a).range(0..=65535)); - self.read_addr = a as u16; - ui.end_row(); - ui.label("数量"); - let mut q = self.read_qty as u32; - ui.add(egui::DragValue::new(&mut q).range(1..=2000)); - self.read_qty = q as u16; - ui.end_row(); - }); - ui.add_space(8.0); - ui.add_enabled_ui(s.state == MasterState::Connected, |ui| { - if uikit::primary_button(ui, flavor, "读取").clicked() { - do_read_id = Some(id.clone()); - } - }); - } - MasterTab::Write => { - egui::Grid::new("write_form") - .num_columns(2) - .spacing([10.0, 6.0]) - .show(ui, |ui| { - ui.label("类型"); - ui.horizontal(|ui| { - ui.radio_value(&mut self.write_is_coil, false, "FC06 寄存器"); - ui.radio_value(&mut self.write_is_coil, true, "FC05 线圈"); - }); - ui.end_row(); - ui.label("地址"); - let mut a = self.write_addr as u32; - ui.add(egui::DragValue::new(&mut a).range(0..=65535)); - self.write_addr = a as u16; - ui.end_row(); - ui.label("值"); - if self.write_is_coil { - let mut b = self.write_value != 0; - ui.checkbox(&mut b, "true / false"); - self.write_value = if b { 1 } else { 0 }; - } else { - ui.add( - egui::DragValue::new(&mut self.write_value).range(0..=65535), - ); - } - ui.end_row(); - }); - ui.add_space(8.0); - ui.add_enabled_ui(s.state == MasterState::Connected, |ui| { - if uikit::primary_button(ui, flavor, "写入").clicked() { - do_write_id = Some(id.clone()); - } - }); - } - MasterTab::Poll => { - // Ensure connection has a polling list - self.polling.entry(id.clone()).or_default(); - let sel = *self.selected_group.get(&id).unwrap_or(&0); - - // Toolbar + uikit::region( + ui, + flavor, + theme::Layer::L1, + egui::Margin::symmetric(14.0 as i8, 10.0 as i8), + |ui| { + // Tab bar: Read / Write / Poll ui.horizontal(|ui| { - if uikit::primary_button(ui, flavor, "+ 新建组").clicked() { - self.add_scan_group(id.clone()); - } - let len = self.polling.get(&id).map(|v| v.len()).unwrap_or(0); - let has_sel = len > 0 && sel < len; - if has_sel { - if uikit::danger_button(ui, flavor, "- 删除组").clicked() { - self.remove_scan_group(id.clone(), sel, ctx.clone()); + for tab in [MasterTab::Read, MasterTab::Write, MasterTab::Poll] { + let selected = self.active_tab == tab; + let text = if selected { + egui::RichText::new(tab.label()) + .strong() + .color(theme::accent(flavor)) + } else { + egui::RichText::new(tab.label()).color(theme::subtext(flavor)) + }; + if ui.add(egui::SelectableLabel::new(selected, text)).clicked() { + self.active_tab = tab; } } - uikit::caption(ui, flavor, format!("{} 个扫描组", len)); }); - ui.add_space(6.0); - - let is_connected = s.state == MasterState::Connected; + ui.separator(); - ui.horizontal(|ui| { - // Left: group list - ui.allocate_ui_with_layout( - egui::vec2(200.0, ui.available_height()), - egui::Layout::top_down(egui::Align::Min), - |ui| { - egui::ScrollArea::vertical().show(ui, |ui| { - if let Some(list) = self.polling.get(&id) { - for (i, g) in list.iter().enumerate() { - let selected = i == sel; - let dot = if g.enabled { "●" } else { "○" }; - let color = if g.enabled { - theme::success(flavor) - } else { - theme::subtext(flavor) - }; - let label = egui::RichText::new(format!( - "{} {} {} @{} ×{}", - dot, - g.name, - match g.fc { - ReadFunction::ReadCoils => "FC01", - ReadFunction::ReadDiscreteInputs => "FC02", - ReadFunction::ReadHoldingRegisters => "FC03", - ReadFunction::ReadInputRegisters => "FC04", - }, - g.addr, - g.qty, - )) - .color(color); - if ui - .add(egui::SelectableLabel::new(selected, label)) - .clicked() - { - self.selected_group.insert(id.clone(), i); - } - } - } - }); - }, - ); - ui.separator(); - - // Right: selected group detail - ui.vertical(|ui| { - let Some(list) = self.polling.get_mut(&id) else { return }; - if list.is_empty() { - uikit::caption(ui, flavor, "点击左上 + 新建扫描组"); - return; - } - let idx = sel.min(list.len() - 1); - let pu = &mut list[idx]; - egui::Grid::new("poll_form") + // Tab content + match self.active_tab { + MasterTab::Read => { + egui::Grid::new("read_form") .num_columns(2) .spacing([10.0, 6.0]) .show(ui, |ui| { - ui.label("名称"); - ui.text_edit_singleline(&mut pu.name); - ui.end_row(); ui.label("功能码"); - egui::ComboBox::from_id_salt("poll_fc") - .selected_text(read_fc_label(pu.fc)) + egui::ComboBox::from_id_salt("read_fc") + .selected_text(read_fc_label(self.read_fc)) .show_ui(ui, |ui| { for f in [ ReadFunction::ReadCoils, @@ -1184,57 +1101,235 @@ impl eframe::App for MasterApp { ReadFunction::ReadHoldingRegisters, ReadFunction::ReadInputRegisters, ] { - ui.selectable_value(&mut pu.fc, f, read_fc_label(f)); + ui.selectable_value( + &mut self.read_fc, + f, + read_fc_label(f), + ); } }); ui.end_row(); - ui.label("起址"); - let mut a = pu.addr as u32; + ui.label("起始地址"); + let mut a = self.read_addr as u32; ui.add(egui::DragValue::new(&mut a).range(0..=65535)); - pu.addr = a as u16; + self.read_addr = a as u16; ui.end_row(); ui.label("数量"); - let mut q = pu.qty as u32; + let mut q = self.read_qty as u32; ui.add(egui::DragValue::new(&mut q).range(1..=2000)); - pu.qty = q as u16; + self.read_qty = q as u16; + ui.end_row(); + }); + ui.add_space(8.0); + ui.add_enabled_ui(s.state == MasterState::Connected, |ui| { + if uikit::primary_button(ui, flavor, "读取").clicked() { + do_read_id = Some(id.clone()); + } + }); + } + MasterTab::Write => { + egui::Grid::new("write_form") + .num_columns(2) + .spacing([10.0, 6.0]) + .show(ui, |ui| { + ui.label("类型"); + ui.horizontal(|ui| { + ui.radio_value( + &mut self.write_is_coil, + false, + "FC06 寄存器", + ); + ui.radio_value(&mut self.write_is_coil, true, "FC05 线圈"); + }); ui.end_row(); - ui.label("间隔 (ms)"); - ui.add(egui::DragValue::new(&mut pu.interval_ms).range(50..=60_000)); + ui.label("地址"); + let mut a = self.write_addr as u32; + ui.add(egui::DragValue::new(&mut a).range(0..=65535)); + self.write_addr = a as u16; + ui.end_row(); + ui.label("值"); + if self.write_is_coil { + let mut b = self.write_value != 0; + ui.checkbox(&mut b, "true / false"); + self.write_value = if b { 1 } else { 0 }; + } else { + ui.add( + egui::DragValue::new(&mut self.write_value) + .range(0..=65535), + ); + } ui.end_row(); }); ui.add_space(8.0); + ui.add_enabled_ui(s.state == MasterState::Connected, |ui| { + if uikit::primary_button(ui, flavor, "写入").clicked() { + do_write_id = Some(id.clone()); + } + }); + } + MasterTab::Poll => { + // Ensure connection has a polling list + self.polling.entry(id.clone()).or_default(); + let sel = *self.selected_group.get(&id).unwrap_or(&0); + + // Toolbar ui.horizontal(|ui| { - let running = pu.enabled; - let last_update = pu.last_update; - let last_err = pu.last_error.clone(); - if running { - if uikit::danger_button(ui, flavor, "停止").clicked() { - do_stop_poll_id = Some((id.clone(), idx)); + if uikit::primary_button(ui, flavor, "+ 新建组").clicked() { + self.add_scan_group(id.clone()); + } + let len = self.polling.get(&id).map(|v| v.len()).unwrap_or(0); + let has_sel = len > 0 && sel < len; + if has_sel { + if uikit::danger_button(ui, flavor, "- 删除组").clicked() { + self.remove_scan_group(id.clone(), sel, ctx.clone()); + } + } + uikit::caption(ui, flavor, format!("{} 个扫描组", len)); + }); + ui.add_space(6.0); + + let is_connected = s.state == MasterState::Connected; + + ui.horizontal(|ui| { + // Left: group list + ui.allocate_ui_with_layout( + egui::vec2(200.0, ui.available_height()), + egui::Layout::top_down(egui::Align::Min), + |ui| { + egui::ScrollArea::vertical().show(ui, |ui| { + if let Some(list) = self.polling.get(&id) { + for (i, g) in list.iter().enumerate() { + let selected = i == sel; + let dot = if g.enabled { "●" } else { "○" }; + let color = if g.enabled { + theme::success(flavor) + } else { + theme::subtext(flavor) + }; + let label = + egui::RichText::new(format!( + "{} {} {} @{} ×{}", + dot, + g.name, + match g.fc { + ReadFunction::ReadCoils => "FC01", + ReadFunction::ReadDiscreteInputs => "FC02", + ReadFunction::ReadHoldingRegisters => "FC03", + ReadFunction::ReadInputRegisters => "FC04", + }, + g.addr, + g.qty, + )) + .color(color); + if ui + .add(egui::SelectableLabel::new( + selected, label, + )) + .clicked() + { + self.selected_group.insert(id.clone(), i); + } + } + } + }); + }, + ); + ui.separator(); + + // Right: selected group detail + ui.vertical(|ui| { + let Some(list) = self.polling.get_mut(&id) else { + return; + }; + if list.is_empty() { + uikit::caption(ui, flavor, "点击左上 + 新建扫描组"); + return; } - uikit::status_pill(ui, "运行中", theme::success(flavor)); - } else { - ui.add_enabled_ui(is_connected, |ui| { - if uikit::primary_button(ui, flavor, "开始").clicked() { - do_start_poll_id = Some((id.clone(), idx)); + let idx = sel.min(list.len() - 1); + let pu = &mut list[idx]; + egui::Grid::new("poll_form") + .num_columns(2) + .spacing([10.0, 6.0]) + .show(ui, |ui| { + ui.label("名称"); + ui.text_edit_singleline(&mut pu.name); + ui.end_row(); + ui.label("功能码"); + egui::ComboBox::from_id_salt("poll_fc") + .selected_text(read_fc_label(pu.fc)) + .show_ui(ui, |ui| { + for f in [ + ReadFunction::ReadCoils, + ReadFunction::ReadDiscreteInputs, + ReadFunction::ReadHoldingRegisters, + ReadFunction::ReadInputRegisters, + ] { + ui.selectable_value( + &mut pu.fc, + f, + read_fc_label(f), + ); + } + }); + ui.end_row(); + ui.label("起址"); + let mut a = pu.addr as u32; + ui.add(egui::DragValue::new(&mut a).range(0..=65535)); + pu.addr = a as u16; + ui.end_row(); + ui.label("数量"); + let mut q = pu.qty as u32; + ui.add(egui::DragValue::new(&mut q).range(1..=2000)); + pu.qty = q as u16; + ui.end_row(); + ui.label("间隔 (ms)"); + ui.add( + egui::DragValue::new(&mut pu.interval_ms) + .range(50..=60_000), + ); + ui.end_row(); + }); + ui.add_space(8.0); + ui.horizontal(|ui| { + let running = pu.enabled; + let last_update = pu.last_update; + let last_err = pu.last_error.clone(); + if running { + if uikit::danger_button(ui, flavor, "停止").clicked() + { + do_stop_poll_id = Some((id.clone(), idx)); + } + uikit::status_pill( + ui, + "运行中", + theme::success(flavor), + ); + } else { + ui.add_enabled_ui(is_connected, |ui| { + if uikit::primary_button(ui, flavor, "开始") + .clicked() + { + do_start_poll_id = Some((id.clone(), idx)); + } + }); + } + if let Some(t) = last_update { + uikit::caption( + ui, + flavor, + format!("· {} ms 前更新", t.elapsed().as_millis()), + ); + } + if let Some(err) = last_err { + ui.colored_label(theme::danger(flavor), err); } }); - } - if let Some(t) = last_update { - uikit::caption( - ui, - flavor, - format!("· {} ms 前更新", t.elapsed().as_millis()), - ); - } - if let Some(err) = last_err { - ui.colored_label(theme::danger(flavor), err); - } + }); }); - }); - }); - } - } - }); // end tab card + } + } + }, + ); // end tab card ui.add_space(6.0); // Result section — shows selected group's latest, or one-shot read result. @@ -1247,20 +1342,34 @@ impl eframe::App for MasterApp { .unwrap_or((None, 0)); let show_result = poll_latest.clone().or_else(|| self.read_result.clone()); if let Some(result) = &show_result { - uikit::region(ui, flavor, theme::Layer::L2, egui::Margin::symmetric(12.0 as i8, 10.0 as i8), |ui| { - let title = if poll_latest.is_some() { "轮询结果" } else { "读取结果" }; - let base = if poll_latest.is_some() { poll_addr } else { self.read_addr }; - ui.label(egui::RichText::new(title).strong().size(12.5)); - ui.add_space(4.0); - match result { - ReadResult::HoldingRegisters(vs) | ReadResult::InputRegisters(vs) => { - render_u16_table(ui, base, vs); - } - ReadResult::Coils(bs) | ReadResult::DiscreteInputs(bs) => { - render_bool_table(ui, base, bs); - } - } - }); // end result card + uikit::region( + ui, + flavor, + theme::Layer::L2, + egui::Margin::symmetric(12.0 as i8, 10.0 as i8), + |ui| { + let title = if poll_latest.is_some() { + "轮询结果" + } else { + "读取结果" + }; + let base = if poll_latest.is_some() { + poll_addr + } else { + self.read_addr + }; + ui.label(egui::RichText::new(title).strong().size(12.5)); + ui.add_space(4.0); + match result { + ReadResult::HoldingRegisters(vs) | ReadResult::InputRegisters(vs) => { + render_u16_table(ui, base, vs); + } + ReadResult::Coils(bs) | ReadResult::DiscreteInputs(bs) => { + render_bool_table(ui, base, bs); + } + } + }, + ); // end result card } }); @@ -1318,7 +1427,9 @@ impl MasterApp { } fn clear_logs_for_selection(&self) { - let Some(id) = self.selected.clone() else { return }; + let Some(id) = self.selected.clone() else { + return; + }; let connections = self.connections.clone(); self.rt.spawn(async move { let entries = connections.read().await; @@ -1329,7 +1440,9 @@ impl MasterApp { } fn export_logs_for_selection(&mut self, ctx: egui::Context) { - let Some(id) = self.selected.clone() else { return }; + let Some(id) = self.selected.clone() else { + return; + }; let connections = self.connections.clone(); let tx = self.events_tx.clone(); self.rt.spawn(async move { @@ -1354,7 +1467,10 @@ impl MasterApp { }; match tokio::fs::write(path.path(), csv).await { Ok(()) => { - let _ = tx.send(UiEvent::Info(format!("日志已导出:{}", path.path().display()))); + let _ = tx.send(UiEvent::Info(format!( + "日志已导出:{}", + path.path().display() + ))); } Err(e) => { let _ = tx.send(UiEvent::Error(format!("导出失败: {e}"))); @@ -1383,20 +1499,36 @@ fn render_u16_table(ui: &mut egui::Ui, start: u16, values: &[u16]) { .column(Column::exact(90.0)) .column(Column::remainder()) .header(20.0, |mut h| { - h.col(|ui| { ui.strong("地址"); }); - h.col(|ui| { ui.strong("Unsigned"); }); - h.col(|ui| { ui.strong("Signed"); }); - h.col(|ui| { ui.strong("Hex"); }); + h.col(|ui| { + ui.strong("地址"); + }); + h.col(|ui| { + ui.strong("Unsigned"); + }); + h.col(|ui| { + ui.strong("Signed"); + }); + h.col(|ui| { + ui.strong("Hex"); + }); }) .body(|body| { body.rows(18.0, values.len(), |mut row| { let i = row.index(); let addr = start.wrapping_add(i as u16); let v = values[i]; - row.col(|ui| { ui.monospace(format!("{}", addr)); }); - row.col(|ui| { ui.monospace(v.to_string()); }); - row.col(|ui| { ui.monospace((v as i16).to_string()); }); - row.col(|ui| { ui.monospace(format!("0x{:04X}", v)); }); + row.col(|ui| { + ui.monospace(format!("{}", addr)); + }); + row.col(|ui| { + ui.monospace(v.to_string()); + }); + row.col(|ui| { + ui.monospace((v as i16).to_string()); + }); + row.col(|ui| { + ui.monospace(format!("0x{:04X}", v)); + }); }); }); } @@ -1408,15 +1540,23 @@ fn render_bool_table(ui: &mut egui::Ui, start: u16, values: &[bool]) { .column(Column::exact(80.0)) .column(Column::remainder()) .header(20.0, |mut h| { - h.col(|ui| { ui.strong("地址"); }); - h.col(|ui| { ui.strong("布尔"); }); + h.col(|ui| { + ui.strong("地址"); + }); + h.col(|ui| { + ui.strong("布尔"); + }); }) .body(|body| { body.rows(18.0, values.len(), |mut row| { let i = row.index(); let addr = start.wrapping_add(i as u16); - row.col(|ui| { ui.monospace(format!("{}", addr)); }); - row.col(|ui| { ui.monospace(if values[i] { "true" } else { "false" }); }); + row.col(|ui| { + ui.monospace(format!("{}", addr)); + }); + row.col(|ui| { + ui.monospace(if values[i] { "true" } else { "false" }); + }); }); }); } diff --git a/crates/modbusmaster-egui/src/main.rs b/crates/modbusmaster-egui/src/main.rs index 7add075..21d6964 100644 --- a/crates/modbusmaster-egui/src/main.rs +++ b/crates/modbusmaster-egui/src/main.rs @@ -29,7 +29,9 @@ fn main() -> eframe::Result<()> { modbussim_ui_shared::fonts::install_cjk_fonts(&cc.egui_ctx); let flavor = cc .storage - .and_then(|s| eframe::get_value::(s, "flavor_v3")) + .and_then(|s| { + eframe::get_value::(s, "flavor_v3") + }) .unwrap_or_default(); modbussim_ui_shared::theme::apply(&cc.egui_ctx, flavor); Ok(Box::new(app::MasterApp::new(rt.clone(), flavor))) diff --git a/crates/modbussim-app/build.rs b/crates/modbussim-app/build.rs index 795b9b7..d860e1e 100644 --- a/crates/modbussim-app/build.rs +++ b/crates/modbussim-app/build.rs @@ -1,3 +1,3 @@ fn main() { - tauri_build::build() + tauri_build::build() } diff --git a/crates/modbussim-app/src/commands.rs b/crates/modbussim-app/src/commands.rs index 90b4eb5..669bb74 100644 --- a/crates/modbussim-app/src/commands.rs +++ b/crates/modbussim-app/src/commands.rs @@ -10,11 +10,11 @@ use modbussim_core::log_collector::LogCollector; use modbussim_core::log_entry::LogEntry; use modbussim_core::log_helpers; use modbussim_core::parse::{parse_data_type, parse_endian, parse_register_type}; -use modbussim_core::register::{Endian, RegisterDef, RegisterType}; use modbussim_core::project::{self, ProjectFile}; +use modbussim_core::register::{Endian, RegisterDef, RegisterType}; use modbussim_core::slave::{SlaveConnection, SlaveDevice}; -use modbussim_core::transport::{self, Transport, SerialConfig, Parity, SlaveTlsConfig}; use modbussim_core::tools; +use modbussim_core::transport::{self, Parity, SerialConfig, SlaveTlsConfig, Transport}; use rand::Rng; use serde::{Deserialize, Serialize}; use std::sync::Arc; @@ -48,11 +48,30 @@ pub struct RegisterValueEvent { #[derive(Debug, Deserialize)] #[serde(tag = "type", rename_all = "snake_case")] pub enum TransportRequest { - Tcp { port: u16 }, - TcpTls { port: u16 }, - Rtu { serial_port: String, baud_rate: u32, data_bits: u8, stop_bits: u8, parity: String }, - Ascii { serial_port: String, baud_rate: u32, data_bits: u8, stop_bits: u8, parity: String }, - RtuOverTcp { host: String, port: u16 }, + Tcp { + port: u16, + }, + TcpTls { + port: u16, + }, + Rtu { + serial_port: String, + baud_rate: u32, + data_bits: u8, + stop_bits: u8, + parity: String, + }, + Ascii { + serial_port: String, + baud_rate: u32, + data_bits: u8, + stop_bits: u8, + parity: String, + }, + RtuOverTcp { + host: String, + port: u16, + }, } fn parse_parity(s: &str) -> Parity { @@ -65,29 +84,44 @@ fn parse_parity(s: &str) -> Parity { fn to_transport(req: &TransportRequest) -> Transport { match req { - TransportRequest::Tcp { port } => Transport::Tcp { host: "0.0.0.0".into(), port: *port }, - TransportRequest::TcpTls { port } => Transport::TcpTls { host: "0.0.0.0".into(), port: *port }, - TransportRequest::Rtu { serial_port, baud_rate, data_bits, stop_bits, parity } => { - Transport::Rtu(SerialConfig { - port: serial_port.clone(), - baud_rate: *baud_rate, - data_bits: *data_bits, - stop_bits: *stop_bits, - parity: parse_parity(parity), - }) - } - TransportRequest::Ascii { serial_port, baud_rate, data_bits, stop_bits, parity } => { - Transport::Ascii(SerialConfig { - port: serial_port.clone(), - baud_rate: *baud_rate, - data_bits: *data_bits, - stop_bits: *stop_bits, - parity: parse_parity(parity), - }) - } - TransportRequest::RtuOverTcp { host, port } => { - Transport::RtuOverTcp { host: host.clone(), port: *port } - } + TransportRequest::Tcp { port } => Transport::Tcp { + host: "0.0.0.0".into(), + port: *port, + }, + TransportRequest::TcpTls { port } => Transport::TcpTls { + host: "0.0.0.0".into(), + port: *port, + }, + TransportRequest::Rtu { + serial_port, + baud_rate, + data_bits, + stop_bits, + parity, + } => Transport::Rtu(SerialConfig { + port: serial_port.clone(), + baud_rate: *baud_rate, + data_bits: *data_bits, + stop_bits: *stop_bits, + parity: parse_parity(parity), + }), + TransportRequest::Ascii { + serial_port, + baud_rate, + data_bits, + stop_bits, + parity, + } => Transport::Ascii(SerialConfig { + port: serial_port.clone(), + baud_rate: *baud_rate, + data_bits: *data_bits, + stop_bits: *stop_bits, + parity: parse_parity(parity), + }), + TransportRequest::RtuOverTcp { host, port } => Transport::RtuOverTcp { + host: host.clone(), + port: *port, + }, } } @@ -201,7 +235,9 @@ pub async fn start_slave_connection( id: id.clone(), state: state_str, }; - app_handle.emit("slave-connection-state", event).map_err(|e| e.to_string())?; + app_handle + .emit("slave-connection-state", event) + .map_err(|e| e.to_string())?; Ok(()) } @@ -230,16 +266,15 @@ pub async fn stop_slave_connection( id: id.clone(), state: state_str, }; - app_handle.emit("slave-connection-state", event).map_err(|e| e.to_string())?; + app_handle + .emit("slave-connection-state", event) + .map_err(|e| e.to_string())?; Ok(()) } #[tauri::command] -pub async fn delete_slave_connection( - state: State<'_, AppState>, - id: String, -) -> Result<(), String> { +pub async fn delete_slave_connection(state: State<'_, AppState>, id: String) -> Result<(), String> { let mut connections = state.slave_connections.write().await; connections .remove(&id) @@ -385,7 +420,6 @@ pub struct WriteRegisterRequest { pub value: u16, } - #[tauri::command] pub async fn add_register( state: State<'_, AppState>, @@ -442,7 +476,9 @@ pub async fn remove_register( .get_mut(&slave_id) .ok_or_else(|| format!("slave {} not found", slave_id))?; - device.register_defs.retain(|d| !(d.address == address && d.register_type == reg_type)); + device + .register_defs + .retain(|d| !(d.address == address && d.register_type == reg_type)); Ok(()) } @@ -467,10 +503,30 @@ pub async fn read_register( .ok_or_else(|| format!("slave {} not found", slave_id))?; let value = match reg_type { - RegisterType::Coil => device.register_map.coils.get(&address).copied().unwrap_or(false) as u16, - RegisterType::DiscreteInput => device.register_map.discrete_inputs.get(&address).copied().unwrap_or(false) as u16, - RegisterType::HoldingRegister => device.register_map.holding_registers.get(&address).copied().unwrap_or(0), - RegisterType::InputRegister => device.register_map.input_registers.get(&address).copied().unwrap_or(0), + RegisterType::Coil => device + .register_map + .coils + .get(&address) + .copied() + .unwrap_or(false) as u16, + RegisterType::DiscreteInput => device + .register_map + .discrete_inputs + .get(&address) + .copied() + .unwrap_or(false) as u16, + RegisterType::HoldingRegister => device + .register_map + .holding_registers + .get(&address) + .copied() + .unwrap_or(0), + RegisterType::InputRegister => device + .register_map + .input_registers + .get(&address) + .copied() + .unwrap_or(0), }; Ok(RegisterValueInfo { address, value }) @@ -495,10 +551,24 @@ pub async fn write_register( .ok_or_else(|| format!("slave {} not found", request.slave_id))?; match reg_type { - RegisterType::Coil => device.register_map.write_coil(request.address, request.value != 0), - RegisterType::DiscreteInput => { device.register_map.discrete_inputs.insert(request.address, request.value != 0); }, - RegisterType::HoldingRegister => device.register_map.write_holding_register(request.address, request.value), - RegisterType::InputRegister => { device.register_map.input_registers.insert(request.address, request.value); }, + RegisterType::Coil => device + .register_map + .write_coil(request.address, request.value != 0), + RegisterType::DiscreteInput => { + device + .register_map + .discrete_inputs + .insert(request.address, request.value != 0); + } + RegisterType::HoldingRegister => device + .register_map + .write_holding_register(request.address, request.value), + RegisterType::InputRegister => { + device + .register_map + .input_registers + .insert(request.address, request.value); + } } let event = RegisterValueEvent { @@ -508,7 +578,9 @@ pub async fn write_register( address: request.address, value: request.value, }; - app_handle.emit("register-value-changed", event).map_err(|e| e.to_string())?; + app_handle + .emit("register-value-changed", event) + .map_err(|e| e.to_string())?; Ok(()) } @@ -645,9 +717,10 @@ pub struct AddressConversionResult { } #[tauri::command] -pub fn convert_plc_to_modbus(request: AddressConversionRequest) -> Result { - let addr = tools::plc_to_modbus_address(request.address) - .map_err(|e| format!("{}", e))?; +pub fn convert_plc_to_modbus( + request: AddressConversionRequest, +) -> Result { + let addr = tools::plc_to_modbus_address(request.address).map_err(|e| format!("{}", e))?; Ok(AddressConversionResult { plc_address: request.address, @@ -671,24 +744,21 @@ pub fn convert_modbus_to_plc(address: u16, register_type: String) -> Result Result { - let bytes = tools::parse_hex_string(&data) - .map_err(|e| format!("{}", e))?; + let bytes = tools::parse_hex_string(&data).map_err(|e| format!("{}", e))?; let crc = tools::crc16(&bytes); Ok(format!("{:04X}", crc)) } #[tauri::command] pub fn calculate_lrc(data: String) -> Result { - let bytes = tools::parse_hex_string(&data) - .map_err(|e| format!("{}", e))?; + let bytes = tools::parse_hex_string(&data).map_err(|e| format!("{}", e))?; let lrc = tools::lrc(&bytes); Ok(format!("{:02X}", lrc)) } #[tauri::command] pub fn parse_hex(data: String) -> Result, String> { - tools::parse_hex_string(&data) - .map_err(|e| format!("{}", e)) + tools::parse_hex_string(&data).map_err(|e| format!("{}", e)) } // --------------------------------------------------------------------------- @@ -719,9 +789,7 @@ pub struct PersistedAppState { } #[tauri::command] -pub async fn export_app_state( - state: State<'_, AppState>, -) -> Result { +pub async fn export_app_state(state: State<'_, AppState>) -> Result { let connections = state.slave_connections.read().await; let mut persisted_connections = Vec::new(); @@ -756,8 +824,7 @@ pub async fn export_app_state( slave_connections: persisted_connections, }; - serde_json::to_string_pretty(&app_state) - .map_err(|e| format!("failed to serialize: {}", e)) + serde_json::to_string_pretty(&app_state).map_err(|e| format!("failed to serialize: {}", e)) } #[derive(Debug, Deserialize)] @@ -822,9 +889,7 @@ pub async fn import_app_state( } #[tauri::command] -pub async fn clear_app_state( - state: State<'_, AppState>, -) -> Result<(), String> { +pub async fn clear_app_state(state: State<'_, AppState>) -> Result<(), String> { state.slave_connections.write().await.clear(); *state.next_slave_id.write().await = 0; Ok(()) @@ -858,11 +923,15 @@ pub async fn random_mutate_registers( for rt_str in &request.register_types { let reg_type = parse_register_type(rt_str)?; - let addrs: Vec = device.register_defs.iter() + let addrs: Vec = device + .register_defs + .iter() .filter(|d| d.register_type == reg_type) .map(|d| d.address) .collect(); - if addrs.is_empty() { continue; } + if addrs.is_empty() { + continue; + } // Mutate ~30% of registers of this type, at least 3 let count = (addrs.len() * 30 / 100).max(3).min(addrs.len()); @@ -875,21 +944,41 @@ pub async fn random_mutate_registers( for &addr in &pick[..count] { match reg_type { RegisterType::Coil => { - let cur = device.register_map.coils.get(&addr).copied().unwrap_or(false); + let cur = device + .register_map + .coils + .get(&addr) + .copied() + .unwrap_or(false); device.register_map.write_coil(addr, !cur); } RegisterType::DiscreteInput => { - let cur = device.register_map.discrete_inputs.get(&addr).copied().unwrap_or(false); + let cur = device + .register_map + .discrete_inputs + .get(&addr) + .copied() + .unwrap_or(false); device.register_map.discrete_inputs.insert(addr, !cur); } RegisterType::HoldingRegister => { - let cur = device.register_map.holding_registers.get(&addr).copied().unwrap_or(0); + let cur = device + .register_map + .holding_registers + .get(&addr) + .copied() + .unwrap_or(0); let delta: i32 = rng.random_range(-100..=100); let new_val = (cur as i32 + delta).clamp(0, 65535) as u16; device.register_map.write_holding_register(addr, new_val); } RegisterType::InputRegister => { - let cur = device.register_map.input_registers.get(&addr).copied().unwrap_or(0); + let cur = device + .register_map + .input_registers + .get(&addr) + .copied() + .unwrap_or(0); let delta: i32 = rng.random_range(-100..=100); let new_val = (cur as i32 + delta).clamp(0, 65535) as u16; device.register_map.input_registers.insert(addr, new_val); @@ -907,10 +996,7 @@ pub async fn random_mutate_registers( // --------------------------------------------------------------------------- #[tauri::command] -pub async fn save_project_file( - state: State<'_, AppState>, - path: String, -) -> Result<(), String> { +pub async fn save_project_file(state: State<'_, AppState>, path: String) -> Result<(), String> { let connections = state.slave_connections.read().await; let mut proj = ProjectFile::new_slave(); @@ -963,7 +1049,7 @@ pub async fn save_project_file( id: id.clone(), name, transport: proj_transport, - devices: vec![], // Simplified for Phase 1 - register serialization will be added later + devices: vec![], // Simplified for Phase 1 - register serialization will be added later scan_groups: vec![], }; proj.connections.push(conn_config); @@ -1026,7 +1112,10 @@ pub async fn remove_data_source( register_type: String, address: u16, ) -> Result<(), String> { - let key = format!("{}:{}:{}:{}", connection_id, slave_id, register_type, address); + let key = format!( + "{}:{}:{}:{}", + connection_id, slave_id, register_type, address + ); let mut data_sources = state.data_sources.write().await; data_sources.remove(&key); Ok(()) @@ -1047,9 +1136,7 @@ pub async fn list_data_sources( } #[tauri::command] -pub async fn start_data_source_runner( - state: State<'_, AppState>, -) -> Result<(), String> { +pub async fn start_data_source_runner(state: State<'_, AppState>) -> Result<(), String> { let data_sources = state.data_sources.clone(); let connections = state.slave_connections.clone(); @@ -1089,7 +1176,10 @@ pub async fn start_data_source_runner( } "coil" => { device.register_map.coils.insert(address, value != 0); - device.register_map.discrete_inputs.insert(address, value != 0); + device + .register_map + .discrete_inputs + .insert(address, value != 0); } "input_register" => { device.register_map.input_registers.insert(address, value); diff --git a/crates/modbussim-app/src/main.rs b/crates/modbussim-app/src/main.rs index 4cd50a4..bc45e9d 100644 --- a/crates/modbussim-app/src/main.rs +++ b/crates/modbussim-app/src/main.rs @@ -2,5 +2,5 @@ #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] fn main() { - modbussim_app_lib::run(); + modbussim_app_lib::run(); } diff --git a/crates/modbussim-core/examples/run_slave.rs b/crates/modbussim-core/examples/run_slave.rs index 0c5353d..a22db62 100644 --- a/crates/modbussim-core/examples/run_slave.rs +++ b/crates/modbussim-core/examples/run_slave.rs @@ -11,8 +11,13 @@ async fn main() { }; let log = Arc::new(LogCollector::new()); let mut slave = SlaveConnection::new(transport).with_log_collector(log); - slave.add_device(SlaveDevice::with_random_registers(1, "Test Slave", 100)).await.unwrap(); + slave + .add_device(SlaveDevice::with_random_registers(1, "Test Slave", 100)) + .await + .unwrap(); slave.start().await.unwrap(); println!("Slave running on 0.0.0.0:5020 with LogCollector"); - loop { tokio::time::sleep(std::time::Duration::from_secs(3600)).await; } + loop { + tokio::time::sleep(std::time::Duration::from_secs(3600)).await; + } } diff --git a/crates/modbussim-core/examples/test_e2e.rs b/crates/modbussim-core/examples/test_e2e.rs index e695edd..4d0a7fd 100644 --- a/crates/modbussim-core/examples/test_e2e.rs +++ b/crates/modbussim-core/examples/test_e2e.rs @@ -7,7 +7,9 @@ /// 6. Verify data is received /// 7. Write a value and verify it's updated on next poll use modbussim_core::log_collector::LogCollector; -use modbussim_core::master::{MasterConfig, MasterConnection, PollEvent, ReadFunction, ReadResult, ScanGroup}; +use modbussim_core::master::{ + MasterConfig, MasterConnection, PollEvent, ReadFunction, ReadResult, ScanGroup, +}; use modbussim_core::slave::{SlaveConnection, SlaveDevice}; use modbussim_core::transport::Transport; use std::sync::Arc; @@ -46,7 +48,8 @@ async fn main() { port: config.port, }; let log_collector = Arc::new(LogCollector::new()); - let mut master = MasterConnection::new(config, master_transport).with_log_collector(log_collector.clone()); + let mut master = + MasterConnection::new(config, master_transport).with_log_collector(log_collector.clone()); println!(" State: {:?}\n", master.state()); // Step 3: Connect (simulates connect_master command) @@ -71,7 +74,10 @@ async fn main() { // Step 5: Start polling (simulates start_polling command) println!("[5/7] Starting polling..."); let mut rx = master.start_scan_group(&scan_group).await.unwrap(); - println!(" Polling active: {}\n", master.is_scan_active("sg-test-1")); + println!( + " Polling active: {}\n", + master.is_scan_active("sg-test-1") + ); // Step 6: Receive poll data (simulates the bridge task + frontend display) println!("[6/7] Receiving poll data (3 cycles)..."); @@ -118,9 +124,17 @@ async fn main() { let logs = log_collector.get_all().await; println!("\n Communication logs: {} entries", logs.len()); for log in logs.iter().take(5) { - println!(" [{}] {} {} {}", log.timestamp.format("%H:%M:%S%.3f"), - if log.direction == modbussim_core::log_entry::Direction::Tx { "TX" } else { "RX" }, - log.function_code.name(), log.detail); + println!( + " [{}] {} {} {}", + log.timestamp.format("%H:%M:%S%.3f"), + if log.direction == modbussim_core::log_entry::Direction::Tx { + "TX" + } else { + "RX" + }, + log.function_code.name(), + log.detail + ); } if logs.len() > 5 { println!(" ... and {} more", logs.len() - 5); diff --git a/crates/modbussim-core/examples/test_master.rs b/crates/modbussim-core/examples/test_master.rs index 6cd9198..6e61394 100644 --- a/crates/modbussim-core/examples/test_master.rs +++ b/crates/modbussim-core/examples/test_master.rs @@ -1,7 +1,9 @@ /// Full integration test: slave WITH log collector + master, multi-threaded runtime /// This reproduces the exact Tauri app scenario that was causing Broken pipe. use modbussim_core::log_collector::LogCollector; -use modbussim_core::master::{MasterConfig, MasterConnection, PollEvent, ReadFunction, ReadResult, ScanGroup}; +use modbussim_core::master::{ + MasterConfig, MasterConnection, PollEvent, ReadFunction, ReadResult, ScanGroup, +}; use modbussim_core::slave::{SlaveConnection, SlaveDevice}; use modbussim_core::transport::Transport; use std::collections::HashMap; @@ -46,7 +48,8 @@ async fn main() { host: config.target_address.clone(), port: config.port, }; - let conn = MasterConnection::new(config, master_transport).with_log_collector(master_log.clone()); + let conn = + MasterConnection::new(config, master_transport).with_log_collector(master_log.clone()); state.write().await.insert("m1".to_string(), conn); } println!(" OK\n"); @@ -147,7 +150,11 @@ async fn main() { // 8) Check logs let slave_logs = slave_log.get_all().await; let master_logs = master_log.get_all().await; - println!("[8] Logs: slave={} entries, master={} entries", slave_logs.len(), master_logs.len()); + println!( + "[8] Logs: slave={} entries, master={} entries", + slave_logs.len(), + master_logs.len() + ); println!(); // Cleanup diff --git a/crates/modbussim-core/src/config.rs b/crates/modbussim-core/src/config.rs index 19fb60f..37cdd5d 100644 --- a/crates/modbussim-core/src/config.rs +++ b/crates/modbussim-core/src/config.rs @@ -62,7 +62,11 @@ impl RegisterValues { Self { coils: map.coils.iter().map(|(&k, &v)| (k, v)).collect(), discrete_inputs: map.discrete_inputs.iter().map(|(&k, &v)| (k, v)).collect(), - holding_registers: map.holding_registers.iter().map(|(&k, &v)| (k, v)).collect(), + holding_registers: map + .holding_registers + .iter() + .map(|(&k, &v)| (k, v)) + .collect(), input_registers: map.input_registers.iter().map(|(&k, &v)| (k, v)).collect(), } } @@ -248,9 +252,16 @@ impl DeviceConfig { /// Helper to get current register value. fn get_register_value(map: &RegisterMap, def: &RegisterDef) -> Option { match def.register_type { - RegisterType::Coil => map.coils.get(&def.address).copied().map(|v| if v { 1 } else { 0 }), + RegisterType::Coil => map + .coils + .get(&def.address) + .copied() + .map(|v| if v { 1 } else { 0 }), RegisterType::DiscreteInput => { - map.discrete_inputs.get(&def.address).copied().map(|v| if v { 1 } else { 0 }) + map.discrete_inputs + .get(&def.address) + .copied() + .map(|v| if v { 1 } else { 0 }) } RegisterType::HoldingRegister => map.holding_registers.get(&def.address).copied(), RegisterType::InputRegister => map.input_registers.get(&def.address).copied(), @@ -290,7 +301,9 @@ impl ConnectionConfig { _ => None, }; if tcp_port == Some(0) { - return Err(ConfigError::InvalidConfig("port must be non-zero".to_string())); + return Err(ConfigError::InvalidConfig( + "port must be non-zero".to_string(), + )); } // Validate each device @@ -532,17 +545,15 @@ mod tests { let config = DeviceConfig { slave_id: 1, name: "Test Device".to_string(), - registers: vec![ - RegisterDefEntry { - address: 0, - register_type: RegisterType::HoldingRegister, - data_type: DataType::UInt16, - endian: Endian::Big, - name: "HR0".to_string(), - comment: "".to_string(), - value: Some(1234), - }, - ], + registers: vec![RegisterDefEntry { + address: 0, + register_type: RegisterType::HoldingRegister, + data_type: DataType::UInt16, + endian: Endian::Big, + name: "HR0".to_string(), + comment: "".to_string(), + value: Some(1234), + }], }; let device = config.to_slave_device().unwrap(); @@ -600,7 +611,10 @@ mod tests { Transport::Tcp { host, port } if host == "0.0.0.0" && *port == 502 )); assert_eq!(config.connections[0].devices[0].slave_id, 1); - assert_eq!(config.connections[0].devices[0].registers[0].value, Some(1234)); + assert_eq!( + config.connections[0].devices[0].registers[0].value, + Some(1234) + ); // Re-export and verify roundtrip produces equivalent JSON let exported = config.to_json().unwrap(); diff --git a/crates/modbussim-core/src/data_source.rs b/crates/modbussim-core/src/data_source.rs index f1367e7..43f8236 100644 --- a/crates/modbussim-core/src/data_source.rs +++ b/crates/modbussim-core/src/data_source.rs @@ -5,13 +5,38 @@ use std::time::Instant; #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(tag = "type", rename_all = "snake_case")] pub enum DataSource { - Fixed { value: u16 }, - Random { min: u16, max: u16 }, - Sine { amplitude: f64, frequency: f64, offset: f64, phase: f64 }, - Sawtooth { min: u16, max: u16, period_ms: u64 }, - Triangle { min: u16, max: u16, period_ms: u64 }, - Counter { start: u16, step: i16, wrap: bool }, - CsvPlayback { values: Vec, loop_playback: bool }, + Fixed { + value: u16, + }, + Random { + min: u16, + max: u16, + }, + Sine { + amplitude: f64, + frequency: f64, + offset: f64, + phase: f64, + }, + Sawtooth { + min: u16, + max: u16, + period_ms: u64, + }, + Triangle { + min: u16, + max: u16, + period_ms: u64, + }, + Counter { + start: u16, + step: i16, + wrap: bool, + }, + CsvPlayback { + values: Vec, + loop_playback: bool, + }, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -50,17 +75,25 @@ impl DataSourceState { match &self.config.source.clone() { DataSource::Fixed { value } => *value, - DataSource::Random { min, max } => { - rand::thread_rng().gen_range(*min..=*max) - } + DataSource::Random { min, max } => rand::thread_rng().gen_range(*min..=*max), - DataSource::Sine { amplitude, frequency, offset, phase } => { + DataSource::Sine { + amplitude, + frequency, + offset, + phase, + } => { let t = self.start_time.elapsed().as_secs_f64(); - let v = offset + amplitude * (2.0 * std::f64::consts::PI * frequency * t + phase).sin(); + let v = + offset + amplitude * (2.0 * std::f64::consts::PI * frequency * t + phase).sin(); v.clamp(0.0, 65535.0) as u16 } - DataSource::Sawtooth { min, max, period_ms } => { + DataSource::Sawtooth { + min, + max, + period_ms, + } => { if *period_ms == 0 { return *min; } @@ -71,7 +104,11 @@ impl DataSourceState { (*min as f64 + frac * range) as u16 } - DataSource::Triangle { min, max, period_ms } => { + DataSource::Triangle { + min, + max, + period_ms, + } => { if *period_ms == 0 { return *min; } @@ -99,7 +136,10 @@ impl DataSourceState { current } - DataSource::CsvPlayback { values, loop_playback } => { + DataSource::CsvPlayback { + values, + loop_playback, + } => { if values.is_empty() { return 0; } @@ -146,7 +186,11 @@ mod tests { #[test] fn test_counter_increment() { - let mut s = make_state(DataSource::Counter { start: 0, step: 1, wrap: false }); + let mut s = make_state(DataSource::Counter { + start: 0, + step: 1, + wrap: false, + }); assert_eq!(s.next_value(), 0); assert_eq!(s.next_value(), 1); assert_eq!(s.next_value(), 2); @@ -155,14 +199,22 @@ mod tests { #[test] fn test_counter_wrap() { // start at 65534, step 2, wrap=true => 65534, 65536%65536=0 - let mut s = make_state(DataSource::Counter { start: 65534, step: 2, wrap: true }); + let mut s = make_state(DataSource::Counter { + start: 65534, + step: 2, + wrap: true, + }); assert_eq!(s.next_value(), 65534); assert_eq!(s.next_value(), 0); } #[test] fn test_counter_no_wrap_clamp() { - let mut s = make_state(DataSource::Counter { start: 65535, step: 1, wrap: false }); + let mut s = make_state(DataSource::Counter { + start: 65535, + step: 1, + wrap: false, + }); assert_eq!(s.next_value(), 65535); assert_eq!(s.next_value(), 65535); } @@ -214,7 +266,12 @@ mod tests { let json = serde_json::to_string(&cfg).unwrap(); let cfg2: DataSourceConfig = serde_json::from_str(&json).unwrap(); match cfg2.source { - DataSource::Sine { amplitude, frequency, offset, phase } => { + DataSource::Sine { + amplitude, + frequency, + offset, + phase, + } => { assert_eq!(amplitude, 100.0); assert_eq!(frequency, 1.0); assert_eq!(offset, 32768.0); @@ -227,7 +284,11 @@ mod tests { #[test] fn test_sawtooth_zero_period() { - let mut s = make_state(DataSource::Sawtooth { min: 5, max: 100, period_ms: 0 }); + let mut s = make_state(DataSource::Sawtooth { + min: 5, + max: 100, + period_ms: 0, + }); assert_eq!(s.next_value(), 5); } } diff --git a/crates/modbussim-core/src/error.rs b/crates/modbussim-core/src/error.rs index 14fb9cb..c7a90fb 100644 --- a/crates/modbussim-core/src/error.rs +++ b/crates/modbussim-core/src/error.rs @@ -124,7 +124,11 @@ mod tests { "connection" ); assert_eq!( - ModbusError::ConnectionTimeout { addr: "x".into(), timeout_ms: 1 }.category(), + ModbusError::ConnectionTimeout { + addr: "x".into(), + timeout_ms: 1 + } + .category(), "connection" ); assert_eq!( @@ -132,7 +136,11 @@ mod tests { "protocol" ); assert_eq!( - ModbusError::CrcMismatch { expected: 0, actual: 1 }.category(), + ModbusError::CrcMismatch { + expected: 0, + actual: 1 + } + .category(), "protocol" ); assert_eq!( @@ -143,8 +151,20 @@ mod tests { ModbusError::ProjectFileCorrupt { path: "p".into() }.category(), "application" ); - assert_eq!(ModbusError::Io { message: "err".into() }.category(), "generic"); - assert_eq!(ModbusError::Internal { message: "err".into() }.category(), "generic"); + assert_eq!( + ModbusError::Io { + message: "err".into() + } + .category(), + "generic" + ); + assert_eq!( + ModbusError::Internal { + message: "err".into() + } + .category(), + "generic" + ); } #[test] @@ -174,7 +194,9 @@ mod tests { #[test] fn test_error_serialize_io() { - let err = ModbusError::Io { message: "disk full".to_string() }; + let err = ModbusError::Io { + message: "disk full".to_string(), + }; let json = serde_json::to_string(&err).unwrap(); let v: serde_json::Value = serde_json::from_str(&json).unwrap(); assert_eq!(v["category"], "io"); diff --git a/crates/modbussim-core/src/jitter.rs b/crates/modbussim-core/src/jitter.rs index b22ed4f..5d5b76f 100644 --- a/crates/modbussim-core/src/jitter.rs +++ b/crates/modbussim-core/src/jitter.rs @@ -35,11 +35,7 @@ impl Default for JitterConfig { } } -pub fn apply_tick( - map: &mut RegisterMap, - cfg: &JitterConfig, - rng: &mut impl Rng, -) { +pub fn apply_tick(map: &mut RegisterMap, cfg: &JitterConfig, rng: &mut impl Rng) { if !cfg.enabled { return; } @@ -60,11 +56,7 @@ pub fn apply_tick( } } -fn flip_bools( - store: &mut std::collections::HashMap, - rate: u32, - rng: &mut impl Rng, -) { +fn flip_bools(store: &mut std::collections::HashMap, rate: u32, rng: &mut impl Rng) { for v in store.values_mut() { if rng.gen_range(0..100) < rate { *v = !*v; @@ -180,10 +172,18 @@ mod tests { // All u16 started at 1000; with delta_percent=50 each result must land in [500, 1500] // because the drift is computed as value * rand(-50..=50) / 100 then wrapping_add to value. for &v in map.holding_registers.values() { - assert!((500..=1500).contains(&v), "holding out of drift range: {}", v); + assert!( + (500..=1500).contains(&v), + "holding out of drift range: {}", + v + ); } for &v in map.input_registers.values() { - assert!((1000..=3000).contains(&v), "input out of drift range: {}", v); + assert!( + (1000..=3000).contains(&v), + "input out of drift range: {}", + v + ); } } diff --git a/crates/modbussim-core/src/lib.rs b/crates/modbussim-core/src/lib.rs index 717e553..3967859 100644 --- a/crates/modbussim-core/src/lib.rs +++ b/crates/modbussim-core/src/lib.rs @@ -1,26 +1,26 @@ +pub mod ascii_master; +pub mod ascii_slave; +pub mod config; +pub mod data_source; pub mod error; -pub mod pdu; -pub mod transport; -pub mod register; -pub mod slave; -pub mod master; -pub mod log_entry; +pub mod frame; +pub mod jitter; pub mod log_collector; +pub mod log_entry; pub mod log_helpers; -pub mod config; -pub mod tools; -pub mod frame; +pub mod master; +pub mod mbap; pub mod parse; +pub mod pdu; pub mod project; -pub mod rtu_slave; -pub mod ascii_slave; -pub mod rtu_tcp_slave; +pub mod reconnect; +pub mod register; pub mod rtu_master; -pub mod ascii_master; +pub mod rtu_slave; pub mod rtu_tcp_master; -pub mod reconnect; -pub mod data_source; -pub mod jitter; -pub mod mbap; -pub mod tls_slave; +pub mod rtu_tcp_slave; +pub mod slave; pub mod tls_master; +pub mod tls_slave; +pub mod tools; +pub mod transport; diff --git a/crates/modbussim-core/src/log_collector.rs b/crates/modbussim-core/src/log_collector.rs index 5156c21..0011ab7 100644 --- a/crates/modbussim-core/src/log_collector.rs +++ b/crates/modbussim-core/src/log_collector.rs @@ -132,7 +132,10 @@ impl LogCollector { }; output.push_str(&format!( "[{}] {} {} - {}\n", - timestamp, dir, entry.function_code.name(), entry.detail + timestamp, + dir, + entry.function_code.name(), + entry.detail )); } output @@ -150,7 +153,10 @@ impl LogCollector { }; output.push_str(&format!( "[{}] {} {} - {}\n", - timestamp, dir, entry.function_code.name(), entry.detail + timestamp, + dir, + entry.function_code.name(), + entry.detail )); } output @@ -234,7 +240,11 @@ mod tests { #[tokio::test] async fn test_export_text() { let collector = LogCollector::new(); - let entry = LogEntry::new(Direction::Tx, FunctionCode::WriteSingleRegister, "W 10 = 42"); + let entry = LogEntry::new( + Direction::Tx, + FunctionCode::WriteSingleRegister, + "W 10 = 42", + ); collector.add(entry).await; let text = collector.export_text().await; diff --git a/crates/modbussim-core/src/log_entry.rs b/crates/modbussim-core/src/log_entry.rs index dc394f6..9a0213d 100644 --- a/crates/modbussim-core/src/log_entry.rs +++ b/crates/modbussim-core/src/log_entry.rs @@ -81,7 +81,11 @@ pub struct LogEntry { impl LogEntry { /// Create a new log entry with the current timestamp. - pub fn new(direction: Direction, function_code: FunctionCode, detail: impl Into) -> Self { + pub fn new( + direction: Direction, + function_code: FunctionCode, + detail: impl Into, + ) -> Self { Self { timestamp: Utc::now(), direction, @@ -112,10 +116,20 @@ impl LogEntry { let timestamp = self.timestamp.format("%Y-%m-%d %H:%M:%S%.3f"); let direction = self.direction.to_string(); let fc = self.function_code.name(); - let raw = self.raw_bytes.as_ref() - .map(|b| b.iter().map(|v| format!("{:02X}", v)).collect::>().join(" ")) + let raw = self + .raw_bytes + .as_ref() + .map(|b| { + b.iter() + .map(|v| format!("{:02X}", v)) + .collect::>() + .join(" ") + }) .unwrap_or_default(); - format!("\"{}\",{},{},\"{}\",\"{}\"", timestamp, direction, fc, self.detail, raw) + format!( + "\"{}\",{},{},\"{}\",\"{}\"", + timestamp, direction, fc, self.detail, raw + ) } /// CSV header row. @@ -152,8 +166,14 @@ mod tests { #[test] fn test_function_code_from_u8() { assert_eq!(FunctionCode::from_u8(0x01), Some(FunctionCode::ReadCoils)); - assert_eq!(FunctionCode::from_u8(0x03), Some(FunctionCode::ReadHoldingRegisters)); - assert_eq!(FunctionCode::from_u8(0x10), Some(FunctionCode::WriteMultipleRegisters)); + assert_eq!( + FunctionCode::from_u8(0x03), + Some(FunctionCode::ReadHoldingRegisters) + ); + assert_eq!( + FunctionCode::from_u8(0x10), + Some(FunctionCode::WriteMultipleRegisters) + ); assert_eq!(FunctionCode::from_u8(0xFF), None); } diff --git a/crates/modbussim-core/src/log_helpers.rs b/crates/modbussim-core/src/log_helpers.rs index 9ce50dc..fc54566 100644 --- a/crates/modbussim-core/src/log_helpers.rs +++ b/crates/modbussim-core/src/log_helpers.rs @@ -7,7 +7,11 @@ pub async fn get_all_logs(collector: &LogCollector) -> Vec { } /// Get a paginated slice of log entries. -pub async fn get_logs_paginated(collector: &LogCollector, offset: usize, limit: usize) -> Vec { +pub async fn get_logs_paginated( + collector: &LogCollector, + offset: usize, + limit: usize, +) -> Vec { let all = collector.get_all().await; all.into_iter().skip(offset).take(limit).collect() } diff --git a/crates/modbussim-core/src/master.rs b/crates/modbussim-core/src/master.rs index 6adfc61..6fce0b7 100644 --- a/crates/modbussim-core/src/master.rs +++ b/crates/modbussim-core/src/master.rs @@ -201,9 +201,8 @@ impl MasterConnection { TransportCtx::RtuTcp(Arc::new(rtu_tcp)) } Transport::TcpTls { host, port } => { - let tls_conn = crate::tls_master::connect_tls( - host, *port, &self.config.tls, timeout, - ).await?; + let tls_conn = + crate::tls_master::connect_tls(host, *port, &self.config.tls, timeout).await?; TransportCtx::TcpTls(Arc::new(tls_conn)) } }; @@ -246,7 +245,9 @@ impl MasterConnection { fn get_tcp_ctx(&self) -> Result>, MasterError> { match &self.transport_ctx { Some(TransportCtx::Tcp(ctx)) => Ok(ctx.clone()), - Some(_) => Err(MasterError::Transport("operation requires TCP transport".into())), + Some(_) => Err(MasterError::Transport( + "operation requires TCP transport".into(), + )), None => Err(MasterError::NotConnected), } } @@ -296,7 +297,8 @@ impl MasterConnection { let fc = Self::to_function_code(function); // Log TX - self.log_tx(fc, &format!("R {} x{}", start_address, quantity)).await; + self.log_tx(fc, &format!("R {} x{}", start_address, quantity)) + .await; let result = execute_read_any( &transport_ctx, @@ -321,14 +323,14 @@ impl MasterConnection { } /// Write a single coil (FC05). - pub async fn write_single_coil( - &self, - address: u16, - value: bool, - ) -> Result<(), MasterError> { + pub async fn write_single_coil(&self, address: u16, value: bool) -> Result<(), MasterError> { let transport_ctx = self.get_transport_ctx()?; let timeout = self.timeout_duration(); - self.log_tx(FunctionCode::WriteSingleCoil, &format!("W {} = {}", address, value)).await; + self.log_tx( + FunctionCode::WriteSingleCoil, + &format!("W {} = {}", address, value), + ) + .await; match &transport_ctx { TransportCtx::Tcp(ctx) => { let mut ctx = ctx.lock().await; @@ -339,14 +341,16 @@ impl MasterConnection { .map_err(|e| MasterError::Exception(e))?; } TransportCtx::TcpTls(tls) => { - tls.write_single_coil(self.config.slave_id, address, value, timeout).await?; + tls.write_single_coil(self.config.slave_id, address, value, timeout) + .await?; } other => { let coil_value: u16 = if value { 0xFF00 } else { 0x0000 }; let mut pdu = vec![0x05]; pdu.extend_from_slice(&address.to_be_bytes()); pdu.extend_from_slice(&coil_value.to_be_bytes()); - let resp = send_pdu_via_transport(other, self.config.slave_id, &pdu, timeout).await?; + let resp = + send_pdu_via_transport(other, self.config.slave_id, &pdu, timeout).await?; check_write_response(&resp, 0x05)?; } } @@ -354,14 +358,14 @@ impl MasterConnection { } /// Write a single holding register (FC06). - pub async fn write_single_register( - &self, - address: u16, - value: u16, - ) -> Result<(), MasterError> { + pub async fn write_single_register(&self, address: u16, value: u16) -> Result<(), MasterError> { let transport_ctx = self.get_transport_ctx()?; let timeout = self.timeout_duration(); - self.log_tx(FunctionCode::WriteSingleRegister, &format!("W {} = {:#06x}", address, value)).await; + self.log_tx( + FunctionCode::WriteSingleRegister, + &format!("W {} = {:#06x}", address, value), + ) + .await; match &transport_ctx { TransportCtx::Tcp(ctx) => { let mut ctx = ctx.lock().await; @@ -372,13 +376,15 @@ impl MasterConnection { .map_err(|e| MasterError::Exception(e))?; } TransportCtx::TcpTls(tls) => { - tls.write_single_register(self.config.slave_id, address, value, timeout).await?; + tls.write_single_register(self.config.slave_id, address, value, timeout) + .await?; } other => { let mut pdu = vec![0x06]; pdu.extend_from_slice(&address.to_be_bytes()); pdu.extend_from_slice(&value.to_be_bytes()); - let resp = send_pdu_via_transport(other, self.config.slave_id, &pdu, timeout).await?; + let resp = + send_pdu_via_transport(other, self.config.slave_id, &pdu, timeout).await?; check_write_response(&resp, 0x06)?; } } @@ -393,7 +399,11 @@ impl MasterConnection { ) -> Result<(), MasterError> { let transport_ctx = self.get_transport_ctx()?; let timeout = self.timeout_duration(); - self.log_tx(FunctionCode::WriteMultipleCoils, &format!("W {} x{}", address, values.len())).await; + self.log_tx( + FunctionCode::WriteMultipleCoils, + &format!("W {} x{}", address, values.len()), + ) + .await; match &transport_ctx { TransportCtx::Tcp(ctx) => { let mut ctx = ctx.lock().await; @@ -404,7 +414,8 @@ impl MasterConnection { .map_err(|e| MasterError::Exception(e))?; } TransportCtx::TcpTls(tls) => { - tls.write_multiple_coils(self.config.slave_id, address, values, timeout).await?; + tls.write_multiple_coils(self.config.slave_id, address, values, timeout) + .await?; } other => { let quantity = values.len() as u16; @@ -420,7 +431,8 @@ impl MasterConnection { pdu.extend_from_slice(&quantity.to_be_bytes()); pdu.push(byte_count as u8); pdu.extend_from_slice(&coil_bytes); - let resp = send_pdu_via_transport(other, self.config.slave_id, &pdu, timeout).await?; + let resp = + send_pdu_via_transport(other, self.config.slave_id, &pdu, timeout).await?; check_write_response(&resp, 0x0F)?; } } @@ -435,7 +447,11 @@ impl MasterConnection { ) -> Result<(), MasterError> { let transport_ctx = self.get_transport_ctx()?; let timeout = self.timeout_duration(); - self.log_tx(FunctionCode::WriteMultipleRegisters, &format!("W {} x{}", address, values.len())).await; + self.log_tx( + FunctionCode::WriteMultipleRegisters, + &format!("W {} x{}", address, values.len()), + ) + .await; match &transport_ctx { TransportCtx::Tcp(ctx) => { let mut ctx = ctx.lock().await; @@ -446,7 +462,8 @@ impl MasterConnection { .map_err(|e| MasterError::Exception(e))?; } TransportCtx::TcpTls(tls) => { - tls.write_multiple_registers(self.config.slave_id, address, values, timeout).await?; + tls.write_multiple_registers(self.config.slave_id, address, values, timeout) + .await?; } other => { let quantity = values.len() as u16; @@ -458,7 +475,8 @@ impl MasterConnection { for v in values { pdu.extend_from_slice(&v.to_be_bytes()); } - let resp = send_pdu_via_transport(other, self.config.slave_id, &pdu, timeout).await?; + let resp = + send_pdu_via_transport(other, self.config.slave_id, &pdu, timeout).await?; check_write_response(&resp, 0x10)?; } } @@ -537,7 +555,11 @@ impl MasterConnection { // Log TX if let Some(ref collector) = log_collector { - let entry = LogEntry::new(Direction::Tx, fc, format!("R {} x{}", start_address, quantity)); + let entry = LogEntry::new( + Direction::Tx, + fc, + format!("R {} x{}", start_address, quantity), + ); collector.add(entry).await; } @@ -746,45 +768,38 @@ async fn execute_read_tcp( let mut ctx = ctx.lock().await; match function { ReadFunction::ReadCoils => { + let data = tokio::time::timeout(timeout, ctx.read_coils(start_address, quantity)) + .await + .map_err(|_| MasterError::Timeout("Read timed out".into()))? + .map_err(|e| MasterError::Transport(format!("{e}")))? + .map_err(|e| MasterError::Exception(e))?; + Ok(ReadResult::Coils(data)) + } + ReadFunction::ReadDiscreteInputs => { let data = - tokio::time::timeout(timeout, ctx.read_coils(start_address, quantity)) + tokio::time::timeout(timeout, ctx.read_discrete_inputs(start_address, quantity)) .await .map_err(|_| MasterError::Timeout("Read timed out".into()))? .map_err(|e| MasterError::Transport(format!("{e}")))? .map_err(|e| MasterError::Exception(e))?; - Ok(ReadResult::Coils(data)) - } - ReadFunction::ReadDiscreteInputs => { - let data = tokio::time::timeout( - timeout, - ctx.read_discrete_inputs(start_address, quantity), - ) - .await - .map_err(|_| MasterError::Timeout("Read timed out".into()))? - .map_err(|e| MasterError::Transport(format!("{e}")))? - .map_err(|e| MasterError::Exception(e))?; Ok(ReadResult::DiscreteInputs(data)) } ReadFunction::ReadHoldingRegisters => { - let data = tokio::time::timeout( - timeout, - ctx.read_holding_registers(start_address, quantity), - ) - .await - .map_err(|_| MasterError::Timeout("Read timed out".into()))? - .map_err(|e| MasterError::Transport(format!("{e}")))? - .map_err(|e| MasterError::Exception(e))?; + let data = + tokio::time::timeout(timeout, ctx.read_holding_registers(start_address, quantity)) + .await + .map_err(|_| MasterError::Timeout("Read timed out".into()))? + .map_err(|e| MasterError::Transport(format!("{e}")))? + .map_err(|e| MasterError::Exception(e))?; Ok(ReadResult::HoldingRegisters(data)) } ReadFunction::ReadInputRegisters => { - let data = tokio::time::timeout( - timeout, - ctx.read_input_registers(start_address, quantity), - ) - .await - .map_err(|_| MasterError::Timeout("Read timed out".into()))? - .map_err(|e| MasterError::Transport(format!("{e}")))? - .map_err(|e| MasterError::Exception(e))?; + let data = + tokio::time::timeout(timeout, ctx.read_input_registers(start_address, quantity)) + .await + .map_err(|_| MasterError::Timeout("Read timed out".into()))? + .map_err(|e| MasterError::Transport(format!("{e}")))? + .map_err(|e| MasterError::Exception(e))?; Ok(ReadResult::InputRegisters(data)) } } @@ -804,7 +819,8 @@ async fn execute_read_any( execute_read_tcp(tcp_ctx, function, start_address, quantity, timeout).await } TransportCtx::TcpTls(tls) => { - tls.read(slave_id, function, start_address, quantity, timeout).await + tls.read(slave_id, function, start_address, quantity, timeout) + .await } other => { let pdu = build_read_pdu(function, start_address, quantity); @@ -894,13 +910,15 @@ pub async fn scan_slave_ids_with_ctx( for id in start_id..=end_id { // Check cancellation if cancel_rx.try_recv().is_ok() { - let _ = progress_tx.send(SlaveIdScanProgress { - current_id: id, - total, - found_ids: found_ids.clone(), - done: false, - cancelled: true, - }).await; + let _ = progress_tx + .send(SlaveIdScanProgress { + current_id: id, + total, + found_ids: found_ids.clone(), + done: false, + cancelled: true, + }) + .await; break; } @@ -918,13 +936,15 @@ pub async fn scan_slave_ids_with_ctx( found_ids.push(id); } - let _ = progress_tx.send(SlaveIdScanProgress { - current_id: id, - total, - found_ids: found_ids.clone(), - done: id == end_id, - cancelled: false, - }).await; + let _ = progress_tx + .send(SlaveIdScanProgress { + current_id: id, + total, + found_ids: found_ids.clone(), + done: id == end_id, + cancelled: false, + }) + .await; } // Restore original slave ID @@ -934,13 +954,15 @@ pub async fn scan_slave_ids_with_ctx( } // Send final done - let _ = progress_tx.send(SlaveIdScanProgress { - current_id: end_id, - total, - found_ids: found_ids.clone(), - done: true, - cancelled: false, - }).await; + let _ = progress_tx + .send(SlaveIdScanProgress { + current_id: end_id, + total, + found_ids: found_ids.clone(), + done: true, + cancelled: false, + }) + .await; found_ids } @@ -962,13 +984,15 @@ pub async fn scan_registers_with_ctx( while addr <= end_address { // Check cancellation if cancel_rx.try_recv().is_ok() { - let _ = progress_tx.send(RegisterScanProgress { - current_address: addr, - end_address, - found_registers: found.clone(), - done: false, - cancelled: true, - }).await; + let _ = progress_tx + .send(RegisterScanProgress { + current_address: addr, + end_address, + found_registers: found.clone(), + done: false, + cancelled: true, + }) + .await; break; } @@ -979,30 +1003,42 @@ pub async fn scan_registers_with_ctx( let read_fut = match function { ReadFunction::ReadCoils => { let f = ctx.read_coils(addr, qty); - tokio::time::timeout(scan_timeout, f).await + tokio::time::timeout(scan_timeout, f) + .await .ok() .and_then(|r| r.ok()) .and_then(|r| r.ok()) - .map(|vals| vals.iter().map(|&b| if b { 1u16 } else { 0u16 }).collect::>()) + .map(|vals| { + vals.iter() + .map(|&b| if b { 1u16 } else { 0u16 }) + .collect::>() + }) } ReadFunction::ReadDiscreteInputs => { let f = ctx.read_discrete_inputs(addr, qty); - tokio::time::timeout(scan_timeout, f).await + tokio::time::timeout(scan_timeout, f) + .await .ok() .and_then(|r| r.ok()) .and_then(|r| r.ok()) - .map(|vals| vals.iter().map(|&b| if b { 1u16 } else { 0u16 }).collect::>()) + .map(|vals| { + vals.iter() + .map(|&b| if b { 1u16 } else { 0u16 }) + .collect::>() + }) } ReadFunction::ReadHoldingRegisters => { let f = ctx.read_holding_registers(addr, qty); - tokio::time::timeout(scan_timeout, f).await + tokio::time::timeout(scan_timeout, f) + .await .ok() .and_then(|r| r.ok()) .and_then(|r| r.ok()) } ReadFunction::ReadInputRegisters => { let f = ctx.read_input_registers(addr, qty); - tokio::time::timeout(scan_timeout, f).await + tokio::time::timeout(scan_timeout, f) + .await .ok() .and_then(|r| r.ok()) .and_then(|r| r.ok()) @@ -1021,13 +1057,15 @@ pub async fn scan_registers_with_ctx( } let done = addr + qty > end_address; - let _ = progress_tx.send(RegisterScanProgress { - current_address: addr + qty - 1, - end_address, - found_registers: found.clone(), - done, - cancelled: false, - }).await; + let _ = progress_tx + .send(RegisterScanProgress { + current_address: addr + qty - 1, + end_address, + found_registers: found.clone(), + done, + cancelled: false, + }) + .await; addr = addr.saturating_add(qty); if addr == 0 && end_address == u16::MAX { diff --git a/crates/modbussim-core/src/parse.rs b/crates/modbussim-core/src/parse.rs index 0925032..4e1bc9b 100644 --- a/crates/modbussim-core/src/parse.rs +++ b/crates/modbussim-core/src/parse.rs @@ -1,5 +1,5 @@ -use crate::register::{RegisterType, DataType, Endian}; use crate::master::ReadFunction; +use crate::register::{DataType, Endian, RegisterType}; pub fn parse_register_type(s: &str) -> Result { match s { @@ -59,9 +59,18 @@ mod tests { #[test] fn test_parse_register_type_valid() { assert_eq!(parse_register_type("coil").unwrap(), RegisterType::Coil); - assert_eq!(parse_register_type("discrete_input").unwrap(), RegisterType::DiscreteInput); - assert_eq!(parse_register_type("input_register").unwrap(), RegisterType::InputRegister); - assert_eq!(parse_register_type("holding_register").unwrap(), RegisterType::HoldingRegister); + assert_eq!( + parse_register_type("discrete_input").unwrap(), + RegisterType::DiscreteInput + ); + assert_eq!( + parse_register_type("input_register").unwrap(), + RegisterType::InputRegister + ); + assert_eq!( + parse_register_type("holding_register").unwrap(), + RegisterType::HoldingRegister + ); } #[test] @@ -111,10 +120,22 @@ mod tests { #[test] fn test_parse_read_function_valid() { - assert_eq!(parse_read_function("read_coils").unwrap(), ReadFunction::ReadCoils); - assert_eq!(parse_read_function("read_discrete_inputs").unwrap(), ReadFunction::ReadDiscreteInputs); - assert_eq!(parse_read_function("read_holding_registers").unwrap(), ReadFunction::ReadHoldingRegisters); - assert_eq!(parse_read_function("read_input_registers").unwrap(), ReadFunction::ReadInputRegisters); + assert_eq!( + parse_read_function("read_coils").unwrap(), + ReadFunction::ReadCoils + ); + assert_eq!( + parse_read_function("read_discrete_inputs").unwrap(), + ReadFunction::ReadDiscreteInputs + ); + assert_eq!( + parse_read_function("read_holding_registers").unwrap(), + ReadFunction::ReadHoldingRegisters + ); + assert_eq!( + parse_read_function("read_input_registers").unwrap(), + ReadFunction::ReadInputRegisters + ); } #[test] @@ -128,10 +149,22 @@ mod tests { #[test] fn test_read_function_to_string_all_variants() { - assert_eq!(read_function_to_string(ReadFunction::ReadCoils), "read_coils"); - assert_eq!(read_function_to_string(ReadFunction::ReadDiscreteInputs), "read_discrete_inputs"); - assert_eq!(read_function_to_string(ReadFunction::ReadHoldingRegisters), "read_holding_registers"); - assert_eq!(read_function_to_string(ReadFunction::ReadInputRegisters), "read_input_registers"); + assert_eq!( + read_function_to_string(ReadFunction::ReadCoils), + "read_coils" + ); + assert_eq!( + read_function_to_string(ReadFunction::ReadDiscreteInputs), + "read_discrete_inputs" + ); + assert_eq!( + read_function_to_string(ReadFunction::ReadHoldingRegisters), + "read_holding_registers" + ); + assert_eq!( + read_function_to_string(ReadFunction::ReadInputRegisters), + "read_input_registers" + ); } #[test] diff --git a/crates/modbussim-core/src/pdu.rs b/crates/modbussim-core/src/pdu.rs index c4b4487..16a613d 100644 --- a/crates/modbussim-core/src/pdu.rs +++ b/crates/modbussim-core/src/pdu.rs @@ -121,7 +121,11 @@ pub fn parse_request_pdu(pdu: &[u8]) -> Result { return Err(format!("FC10: byte_count mismatch")); } if byte_count != quantity * 2 { - return Err(format!("FC10: byte_count {} != quantity*2 {}", byte_count, quantity * 2)); + return Err(format!( + "FC10: byte_count {} != quantity*2 {}", + byte_count, + quantity * 2 + )); } let reg_bytes = &data[5..5 + byte_count]; let mut values = Vec::with_capacity(quantity); @@ -162,7 +166,13 @@ pub fn build_response_pdu(fc: u8, data: &ResponseData) -> Vec { } ResponseData::WriteSingleCoil { address, value } => { let val_hi = if *value { 0xFF } else { 0x00 }; - vec![fc, (address >> 8) as u8, (address & 0xFF) as u8, val_hi, 0x00] + vec![ + fc, + (address >> 8) as u8, + (address & 0xFF) as u8, + val_hi, + 0x00, + ] } ResponseData::WriteSingleRegister { address, value } => { vec![ @@ -283,14 +293,20 @@ mod tests { #[test] fn test_build_response_write_single_coil() { - let data = ResponseData::WriteSingleCoil { address: 10, value: true }; + let data = ResponseData::WriteSingleCoil { + address: 10, + value: true, + }; let pdu = build_response_pdu(0x05, &data); assert_eq!(pdu, vec![0x05, 0x00, 0x0A, 0xFF, 0x00]); } #[test] fn test_build_response_write_multiple() { - let data = ResponseData::WriteMultiple { address: 1, quantity: 10 }; + let data = ResponseData::WriteMultiple { + address: 1, + quantity: 10, + }; let pdu = build_response_pdu(0x10, &data); assert_eq!(pdu, vec![0x10, 0x00, 0x01, 0x00, 0x0A]); } diff --git a/crates/modbussim-core/src/project.rs b/crates/modbussim-core/src/project.rs index dec0f5d..edbe14d 100644 --- a/crates/modbussim-core/src/project.rs +++ b/crates/modbussim-core/src/project.rs @@ -14,7 +14,10 @@ pub enum ProjectType { #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(tag = "type", rename_all = "snake_case")] pub enum TransportConfig { - Tcp { host: String, port: u16 }, + Tcp { + host: String, + port: u16, + }, Rtu { port: String, baud_rate: u32, @@ -29,7 +32,10 @@ pub enum TransportConfig { stop_bits: u8, parity: String, }, - RtuOverTcp { host: String, port: u16 }, + RtuOverTcp { + host: String, + port: u16, + }, } /// A register block definition in a project file. @@ -122,14 +128,13 @@ impl ProjectFile { pub fn save_project(project: &ProjectFile, path: &Path) -> Result<(), String> { let json = serde_json::to_string_pretty(project) .map_err(|e| format!("failed to serialize project: {}", e))?; - std::fs::write(path, json) - .map_err(|e| format!("failed to write project file: {}", e)) + std::fs::write(path, json).map_err(|e| format!("failed to write project file: {}", e)) } /// Load a project file from the given path. pub fn load_project(path: &Path) -> Result { - let data = std::fs::read_to_string(path) - .map_err(|e| format!("failed to read project file: {}", e))?; + let data = + std::fs::read_to_string(path).map_err(|e| format!("failed to read project file: {}", e))?; migrate_project(&data) } @@ -145,8 +150,9 @@ pub fn migrate_project(data: &str) -> Result { .ok_or("missing or invalid version field")?; match version { - 1 => serde_json::from_value(value) - .map_err(|e| format!("failed to parse project v1: {}", e)), + 1 => { + serde_json::from_value(value).map_err(|e| format!("failed to parse project v1: {}", e)) + } v => Err(format!("unsupported project version: {}", v)), } } @@ -363,7 +369,9 @@ mod tests { assert!(json.contains("ttyUSB0")); let loaded: ProjectFile = serde_json::from_str(&json).unwrap(); match &loaded.connections[0].transport { - TransportConfig::Rtu { port, baud_rate, .. } => { + TransportConfig::Rtu { + port, baud_rate, .. + } => { assert_eq!(port, "/dev/ttyUSB0"); assert_eq!(*baud_rate, 9600); } @@ -373,7 +381,10 @@ mod tests { #[test] fn test_transport_rtu_over_tcp_serde() { - let config = TransportConfig::RtuOverTcp { host: "10.0.0.1".to_string(), port: 502 }; + let config = TransportConfig::RtuOverTcp { + host: "10.0.0.1".to_string(), + port: 502, + }; let json = serde_json::to_string(&config).unwrap(); assert!(json.contains("\"type\":\"rtu_over_tcp\"")); let loaded: TransportConfig = serde_json::from_str(&json).unwrap(); diff --git a/crates/modbussim-core/src/reconnect.rs b/crates/modbussim-core/src/reconnect.rs index 0dd59cb..aae916d 100644 --- a/crates/modbussim-core/src/reconnect.rs +++ b/crates/modbussim-core/src/reconnect.rs @@ -49,8 +49,7 @@ impl ReconnectPolicy { /// /// delay = initial_delay_ms * backoff_factor^attempt, clamped to max_delay_ms. pub fn delay_for_attempt(&self, attempt: u32) -> Duration { - let delay_ms = - self.initial_delay_ms as f64 * self.backoff_factor.powi(attempt as i32); + let delay_ms = self.initial_delay_ms as f64 * self.backoff_factor.powi(attempt as i32); let clamped = delay_ms.min(self.max_delay_ms as f64) as u64; Duration::from_millis(clamped) } diff --git a/crates/modbussim-core/src/register.rs b/crates/modbussim-core/src/register.rs index 9fd9b07..02cf7b6 100644 --- a/crates/modbussim-core/src/register.rs +++ b/crates/modbussim-core/src/register.rs @@ -166,8 +166,12 @@ impl RegisterMap { (start..start + count).all(|a| self.input_registers.contains_key(&a)) } - pub fn has_coil(&self, addr: u16) -> bool { self.coils.contains_key(&addr) } - pub fn has_holding_register(&self, addr: u16) -> bool { self.holding_registers.contains_key(&addr) } + pub fn has_coil(&self, addr: u16) -> bool { + self.coils.contains_key(&addr) + } + pub fn has_holding_register(&self, addr: u16) -> bool { + self.holding_registers.contains_key(&addr) + } /// Ensure map entries exist for every address covered by `def`. /// Uses default values (0 / false) only for addresses that are absent, @@ -180,12 +184,22 @@ impl RegisterMap { } }; for i in 0..count { - let Some(addr) = def.address.checked_add(i) else { break; }; + let Some(addr) = def.address.checked_add(i) else { + break; + }; match def.register_type { - RegisterType::Coil => { self.coils.entry(addr).or_insert(false); } - RegisterType::DiscreteInput => { self.discrete_inputs.entry(addr).or_insert(false); } - RegisterType::HoldingRegister => { self.holding_registers.entry(addr).or_insert(0); } - RegisterType::InputRegister => { self.input_registers.entry(addr).or_insert(0); } + RegisterType::Coil => { + self.coils.entry(addr).or_insert(false); + } + RegisterType::DiscreteInput => { + self.discrete_inputs.entry(addr).or_insert(false); + } + RegisterType::HoldingRegister => { + self.holding_registers.entry(addr).or_insert(0); + } + RegisterType::InputRegister => { + self.input_registers.entry(addr).or_insert(0); + } } } } @@ -194,18 +208,16 @@ impl RegisterMap { // --- Data type encoding/decoding with endian support --- /// Encode a typed value into one or two raw u16 registers -pub fn encode_value(value: f64, data_type: DataType, endian: Endian) -> Result, RegisterError> { +pub fn encode_value( + value: f64, + data_type: DataType, + endian: Endian, +) -> Result, RegisterError> { validate_range(value, data_type)?; match data_type { - DataType::Bool => { - Ok(vec![if value != 0.0 { 1 } else { 0 }]) - } - DataType::UInt16 => { - Ok(vec![value as u16]) - } - DataType::Int16 => { - Ok(vec![(value as i16) as u16]) - } + DataType::Bool => Ok(vec![if value != 0.0 { 1 } else { 0 }]), + DataType::UInt16 => Ok(vec![value as u16]), + DataType::Int16 => Ok(vec![(value as i16) as u16]), DataType::UInt32 => { let raw = (value as u32).to_be_bytes(); Ok(apply_endian_encode(raw, endian)) @@ -222,7 +234,11 @@ pub fn encode_value(value: f64, data_type: DataType, endian: Endian) -> Result Result { +pub fn decode_value( + registers: &[u16], + data_type: DataType, + endian: Endian, +) -> Result { match data_type { DataType::Bool => { let v = registers.first().ok_or(RegisterError::InvalidData)?; @@ -265,9 +281,13 @@ pub fn validate_range(value: f64, data_type: DataType) -> Result<(), RegisterErr let valid = match data_type { DataType::Bool => value == 0.0 || value == 1.0, DataType::UInt16 => value >= 0.0 && value <= u16::MAX as f64 && value.fract() == 0.0, - DataType::Int16 => value >= i16::MIN as f64 && value <= i16::MAX as f64 && value.fract() == 0.0, + DataType::Int16 => { + value >= i16::MIN as f64 && value <= i16::MAX as f64 && value.fract() == 0.0 + } DataType::UInt32 => value >= 0.0 && value <= u32::MAX as f64 && value.fract() == 0.0, - DataType::Int32 => value >= i32::MIN as f64 && value <= i32::MAX as f64 && value.fract() == 0.0, + DataType::Int32 => { + value >= i32::MIN as f64 && value <= i32::MAX as f64 && value.fract() == 0.0 + } DataType::Float32 => true, // any f64 that can be cast to f32 }; if valid { diff --git a/crates/modbussim-core/src/rtu_slave.rs b/crates/modbussim-core/src/rtu_slave.rs index ec87325..cf84f65 100644 --- a/crates/modbussim-core/src/rtu_slave.rs +++ b/crates/modbussim-core/src/rtu_slave.rs @@ -123,7 +123,10 @@ pub async fn run_rtu_slave( if let Some(fc_val) = request_pdu.first() { if let Some(fc) = FunctionCode::from_u8(*fc_val) { let detail = if response_pdu.first().map_or(false, |b| b & 0x80 != 0) { - format!("ERR: exception 0x{:02X}", response_pdu.get(1).copied().unwrap_or(0)) + format!( + "ERR: exception 0x{:02X}", + response_pdu.get(1).copied().unwrap_or(0) + ) } else { "OK".to_string() }; @@ -194,7 +197,10 @@ pub(crate) async fn process_request( // Read / Write helpers // --------------------------------------------------------------------------- -pub(crate) fn execute_read(register_map: &RegisterMap, req: &ModbusRequest) -> Result { +pub(crate) fn execute_read( + register_map: &RegisterMap, + req: &ModbusRequest, +) -> Result { match req { ModbusRequest::ReadCoils { address, quantity } => { validate_quantity(*address, *quantity, 2000)?; @@ -246,7 +252,9 @@ pub(crate) fn execute_write( validate_quantity(*address, quantity, 1968)?; register_map.write_coils(*address, values); for (i, &val) in values.iter().enumerate() { - register_map.discrete_inputs.insert(*address + i as u16, val); + register_map + .discrete_inputs + .insert(*address + i as u16, val); } Ok(ResponseData::WriteMultiple { address: *address, @@ -258,7 +266,9 @@ pub(crate) fn execute_write( validate_quantity(*address, quantity, 123)?; register_map.write_holding_registers(*address, values); for (i, &val) in values.iter().enumerate() { - register_map.input_registers.insert(*address + i as u16, val); + register_map + .input_registers + .insert(*address + i as u16, val); } Ok(ResponseData::WriteMultiple { address: *address, diff --git a/crates/modbussim-core/src/slave.rs b/crates/modbussim-core/src/slave.rs index fd74408..ca2c109 100644 --- a/crates/modbussim-core/src/slave.rs +++ b/crates/modbussim-core/src/slave.rs @@ -128,7 +128,10 @@ impl SlaveDevice { name: String::new(), comment: String::new(), }); - device.register_map.discrete_inputs.insert(addr, rng.gen::()); + device + .register_map + .discrete_inputs + .insert(addr, rng.gen::()); // FC3 Holding Register defs.push(RegisterDef { @@ -139,7 +142,9 @@ impl SlaveDevice { name: String::new(), comment: String::new(), }); - device.register_map.write_holding_register(addr, rng.gen::()); + device + .register_map + .write_holding_register(addr, rng.gen::()); // FC4 Input Register defs.push(RegisterDef { @@ -150,7 +155,10 @@ impl SlaveDevice { name: String::new(), comment: String::new(), }); - device.register_map.input_registers.insert(addr, rng.gen::()); + device + .register_map + .input_registers + .insert(addr, rng.gen::()); } device.register_defs = defs; @@ -292,14 +300,13 @@ impl SlaveConnection { Transport::Ascii(serial_config) => { let config = serial_config.clone(); tokio::spawn(async move { - if let Err(e) = - crate::ascii_slave::run_ascii_slave( - config, - devices, - log_collector, - shutdown_rx, - ) - .await + if let Err(e) = crate::ascii_slave::run_ascii_slave( + config, + devices, + log_collector, + shutdown_rx, + ) + .await { log::error!("ASCII slave error: {}", e); } @@ -309,15 +316,14 @@ impl SlaveConnection { let host = host.clone(); let port = *port; tokio::spawn(async move { - if let Err(e) = - crate::rtu_tcp_slave::run_rtu_tcp_slave( - host, - port, - devices, - log_collector, - shutdown_rx, - ) - .await + if let Err(e) = crate::rtu_tcp_slave::run_rtu_tcp_slave( + host, + port, + devices, + log_collector, + shutdown_rx, + ) + .await { log::error!("RTU-over-TCP slave error: {}", e); } @@ -330,7 +336,11 @@ impl SlaveConnection { let tls_config = self.tls_config.clone(); tokio::spawn(async move { if let Err(e) = crate::tls_slave::run_tls_slave( - addr, tls_config, devices, log_collector, shutdown_rx, + addr, + tls_config, + devices, + log_collector, + shutdown_rx, ) .await { @@ -372,7 +382,10 @@ struct SlaveService { impl SlaveService { fn new(devices: SharedDevices, log_collector: SharedLogCollector) -> Self { - Self { devices, log_collector } + Self { + devices, + log_collector, + } } fn get_function_code(request: &Request<'_>) -> Option { @@ -697,9 +710,11 @@ mod tests { fn test_handle_write_single_register() { let mut map = RegisterMap::new(); map.holding_registers.insert(10, 0); - let response = - handle_write(&mut map, Request::WriteSingleRegister(10, 0xABCD)).unwrap(); - assert!(matches!(response, Response::WriteSingleRegister(10, 0xABCD))); + let response = handle_write(&mut map, Request::WriteSingleRegister(10, 0xABCD)).unwrap(); + assert!(matches!( + response, + Response::WriteSingleRegister(10, 0xABCD) + )); assert_eq!(map.read_holding_registers(10, 1), vec![0xABCD]); } @@ -826,9 +841,16 @@ mod tests { // At least some values should be non-zero/true (statistically near-certain with 101 entries) let has_true_coil = (0..=100u16).any(|addr| *device.register_map.coils.get(&addr).unwrap()); - let has_nonzero_hr = (0..=100u16).any(|addr| *device.register_map.holding_registers.get(&addr).unwrap() != 0); - assert!(has_true_coil, "expected at least one true coil with random init"); - assert!(has_nonzero_hr, "expected at least one non-zero holding register with random init"); + let has_nonzero_hr = (0..=100u16) + .any(|addr| *device.register_map.holding_registers.get(&addr).unwrap() != 0); + assert!( + has_true_coil, + "expected at least one true coil with random init" + ); + assert!( + has_nonzero_hr, + "expected at least one non-zero holding register with random init" + ); } #[test] diff --git a/crates/modbussim-core/src/tls_master.rs b/crates/modbussim-core/src/tls_master.rs index 16f9322..59eb353 100644 --- a/crates/modbussim-core/src/tls_master.rs +++ b/crates/modbussim-core/src/tls_master.rs @@ -4,7 +4,10 @@ //! over the encrypted stream. All I/O is synchronous (native_tls::TlsStream) so //! we use `spawn_blocking` to avoid blocking the Tokio runtime. -use crate::master::{build_read_pdu, check_write_response, parse_read_response_pdu, MasterError, ReadFunction, ReadResult}; +use crate::master::{ + build_read_pdu, check_write_response, parse_read_response_pdu, MasterError, ReadFunction, + ReadResult, +}; use crate::mbap; use crate::transport::TlsConfig; use std::sync::atomic::{AtomicU16, Ordering}; @@ -27,11 +30,7 @@ impl TlsMasterConnection { /// Core send/receive over TLS. Runs in spawn_blocking because native_tls is sync. /// Timeouts are set once at connection time (see `connect_tls`). - async fn send_receive( - &self, - slave_id: u8, - pdu: &[u8], - ) -> Result, MasterError> { + async fn send_receive(&self, slave_id: u8, pdu: &[u8]) -> Result, MasterError> { let stream = self.stream.clone(); let tid = self.next_transaction_id(); let pdu = pdu.to_vec(); @@ -167,9 +166,8 @@ pub fn build_tls_connector(config: &TlsConfig) -> Result Result 9999 { return Err(ToolsError::InvalidAddress(format!( @@ -110,7 +111,10 @@ pub fn plc_to_modbus_address(plc_address: u32) -> Result SlaveConnection { device1.register_map.write_coil(0, true); device1.register_map.write_coil(1, false); device1.register_map.write_coil(2, true); - device1 - .register_map - .input_registers - .insert(0, 100); - device1 - .register_map - .input_registers - .insert(1, 200); - device1 - .register_map - .discrete_inputs - .insert(0, true); - device1 - .register_map - .discrete_inputs - .insert(1, false); + device1.register_map.input_registers.insert(0, 100); + device1.register_map.input_registers.insert(1, 200); + device1.register_map.discrete_inputs.insert(0, true); + device1.register_map.discrete_inputs.insert(1, false); conn.add_device(device1).await.unwrap(); // Add slave device 2 diff --git a/crates/modbussim-core/tests/tls_e2e.rs b/crates/modbussim-core/tests/tls_e2e.rs index 8395fb6..ede0a8c 100644 --- a/crates/modbussim-core/tests/tls_e2e.rs +++ b/crates/modbussim-core/tests/tls_e2e.rs @@ -1,8 +1,8 @@ use modbussim_core::master::{MasterConfig, MasterConnection, ReadFunction, ReadResult}; use modbussim_core::slave::{SlaveConnection, SlaveDevice}; use modbussim_core::transport::{SlaveTlsConfig, TlsConfig, Transport}; -use tempfile::NamedTempFile; use std::io::Write; +use tempfile::NamedTempFile; // --------------------------------------------------------------------------- // Certificate generation helpers @@ -15,7 +15,11 @@ use std::io::Write; /// Generate a CA key+cert. /// Returns (ca_pem_bytes, ca_x509, ca_pkey). -fn gen_ca() -> (Vec, openssl::x509::X509, openssl::pkey::PKey) { +fn gen_ca() -> ( + Vec, + openssl::x509::X509, + openssl::pkey::PKey, +) { use openssl::asn1::Asn1Time; use openssl::bn::{BigNum, MsbOption}; use openssl::hash::MessageDigest; @@ -32,7 +36,9 @@ fn gen_ca() -> (Vec, openssl::x509::X509, openssl::pkey::PKey, -) -> (openssl::x509::X509, openssl::pkey::PKey) { +) -> ( + openssl::x509::X509, + openssl::pkey::PKey, +) { use openssl::asn1::Asn1Time; use openssl::bn::{BigNum, MsbOption}; use openssl::hash::MessageDigest; @@ -157,8 +166,7 @@ fn make_pkcs12( /// Returns (ca_pem_bytes, server_p12_bytes). fn generate_test_certs() -> (Vec, Vec) { let (ca_pem, ca_cert, ca_key) = gen_ca(); - let (server_cert, server_key) = - gen_leaf_cert(&["localhost", "127.0.0.1"], &ca_cert, &ca_key); + let (server_cert, server_key) = gen_leaf_cert(&["localhost", "127.0.0.1"], &ca_cert, &ca_key); let server_p12 = make_pkcs12(&server_cert, &server_key, &ca_cert); (ca_pem, server_p12) } @@ -228,7 +236,11 @@ async fn test_tls_read_holding_registers() { .expect("read holding registers"); match result { ReadResult::HoldingRegisters(values) => { - assert_eq!(values, vec![0u16, 0, 0, 0, 0], "expected zero-initialized registers"); + assert_eq!( + values, + vec![0u16, 0, 0, 0, 0], + "expected zero-initialized registers" + ); } _ => panic!("unexpected result type"), } @@ -300,7 +312,10 @@ async fn test_tls_accept_invalid_certs() { port: 15803, }; let mut master = MasterConnection::new(master_config, master_transport); - master.connect().await.expect("master connect with accept_invalid_certs"); + master + .connect() + .await + .expect("master connect with accept_invalid_certs"); // Read 1 holding register to verify connection works let result = master diff --git a/crates/modbussim-egui/src/app.rs b/crates/modbussim-egui/src/app.rs index f88bac2..01702d8 100644 --- a/crates/modbussim-egui/src/app.rs +++ b/crates/modbussim-egui/src/app.rs @@ -14,12 +14,12 @@ use modbussim_core::transport::Transport; use modbussim_ui_shared::format::{format_u16, U16Format}; use modbussim_ui_shared::icons; use modbussim_ui_shared::log_panel::{self, LogPanelAction, LogPanelState}; -use modbussim_ui_shared::theme::{self, Flavor}; -use modbussim_ui_shared::ui as uikit; -use modbussim_ui_shared::value_panel::{self, F64Order}; use modbussim_ui_shared::project::{ deserialize_slave, serialize_slave, SlaveConnectionSave, SlaveDeviceSave, SlaveProject, TcpSpec, }; +use modbussim_ui_shared::theme::{self, Flavor}; +use modbussim_ui_shared::ui as uikit; +use modbussim_ui_shared::value_panel::{self, F64Order}; use tokio::runtime::Runtime; use tokio::sync::RwLock; @@ -100,10 +100,7 @@ impl DsKind { max: 1000, period_ms: 5000, }, - DsKind::Random => DataSource::Random { - min: 0, - max: 65535, - }, + DsKind::Random => DataSource::Random { min: 0, max: 65535 }, DsKind::Fixed => DataSource::Fixed { value: 42 }, DsKind::CsvPlayback => DataSource::CsvPlayback { values: vec![0, 100, 200, 300, 400], @@ -147,7 +144,12 @@ fn source_short_desc(s: &DataSource) -> String { match s { DataSource::Fixed { value } => format!("Fixed={}", value), DataSource::Random { min, max } => format!("Rand[{}..{}]", min, max), - DataSource::Sine { amplitude, frequency, offset, .. } => { + DataSource::Sine { + amplitude, + frequency, + offset, + .. + } => { format!("Sine A={} f={}Hz off={}", amplitude, frequency, offset) } DataSource::Sawtooth { period_ms, .. } => format!("Sawtooth T={}ms", period_ms), @@ -381,12 +383,16 @@ impl SlaveApp { loop { tokio::time::sleep(std::time::Duration::from_millis(50)).await; let mut srcs = sources.lock().await; - if srcs.is_empty() { continue } + if srcs.is_empty() { + continue; + } let now = Instant::now(); // Collect writes first so we don't hold the sources lock while touching connections. let mut updates: Vec<(String, u8, RegisterType, u16, u16)> = Vec::new(); for s in srcs.iter_mut() { - if !s.enabled { continue } + if !s.enabled { + continue; + } let interval = std::time::Duration::from_millis( s.state.config.update_interval_ms.max(10), ); @@ -401,10 +407,14 @@ impl SlaveApp { } } drop(srcs); - if updates.is_empty() { continue } + if updates.is_empty() { + continue; + } let conns = connections.read().await; for (conn_id, slave_id, rtype, addr, v) in updates { - let Some(entry) = conns.iter().find(|e| e.id == conn_id) else { continue }; + let Some(entry) = conns.iter().find(|e| e.id == conn_id) else { + continue; + }; let conn = entry.connection.read().await; let mut devs = conn.devices.write().await; if let Some(dev) = devs.get_mut(&slave_id) { @@ -529,9 +539,15 @@ impl SlaveApp { self.log_cache.clear(); } - let Ok(entries) = self.connections.try_read() else { return }; - let Some(entry) = entries.iter().find(|e| e.id == id) else { return }; - let Some(mut all) = entry.log_collector.try_get_all() else { return }; + let Ok(entries) = self.connections.try_read() else { + return; + }; + let Some(entry) = entries.iter().find(|e| e.id == id) else { + return; + }; + let Some(mut all) = entry.log_collector.try_get_all() else { + return; + }; let start = all.len().saturating_sub(500); self.log_cache = all.drain(start..).collect(); self.log_cache_conn_id = Some(id.to_string()); @@ -539,7 +555,9 @@ impl SlaveApp { } fn clear_logs_for_selection(&self) { - let Some(id) = selection_conn_id(&self.selection) else { return }; + let Some(id) = selection_conn_id(&self.selection) else { + return; + }; let id = id.to_string(); let connections = self.connections.clone(); self.rt.spawn(async move { @@ -551,7 +569,9 @@ impl SlaveApp { } fn export_logs_for_selection(&mut self, ctx: egui::Context) { - let Some(id) = selection_conn_id(&self.selection) else { return }; + let Some(id) = selection_conn_id(&self.selection) else { + return; + }; let id = id.to_string(); let connections = self.connections.clone(); let tx = self.events_tx.clone(); @@ -578,7 +598,10 @@ impl SlaveApp { }; match tokio::fs::write(path.path(), csv).await { Ok(()) => { - let _ = tx.send(UiEvent::Info(format!("日志已导出:{}", path.path().display()))); + let _ = tx.send(UiEvent::Info(format!( + "日志已导出:{}", + path.path().display() + ))); } Err(e) => { let _ = tx.send(UiEvent::Error(format!("导出失败: {e}"))); @@ -701,7 +724,9 @@ impl SlaveApp { let connections = self.connections.clone(); self.rt.spawn(async move { let conns = connections.read().await; - let Some(entry) = conns.iter().find(|e| e.id == conn_id) else { return }; + let Some(entry) = conns.iter().find(|e| e.id == conn_id) else { + return; + }; let conn = entry.connection.read().await; let mut devs = conn.devices.write().await; if let Some(dev) = devs.get_mut(&slave_id) { @@ -718,9 +743,7 @@ impl SlaveApp { .map(|s| { let mut ids: Vec = s.devices.iter().map(|d| d.slave_id).collect(); ids.sort(); - (1u8..=247) - .find(|id| !ids.contains(id)) - .unwrap_or(1) + (1u8..=247).find(|id| !ids.contains(id)).unwrap_or(1) }) .unwrap_or(1); @@ -735,8 +758,12 @@ impl SlaveApp { } fn submit_add_device(&mut self, ctx: egui::Context) { - let Some(state) = self.add_device_modal.as_mut() else { return }; - if state.busy { return; } + let Some(state) = self.add_device_modal.as_mut() else { + return; + }; + if state.busy { + return; + } if state.max_address > u16::MAX as u32 { self.last_error = Some("max_address 超过 65535".to_string()); return; @@ -780,7 +807,10 @@ impl SlaveApp { let conn = conn_arc.read().await; match conn.add_device(device).await { Ok(()) => { - let _ = tx.send(UiEvent::DeviceAdded { conn_id, device: snap }); + let _ = tx.send(UiEvent::DeviceAdded { + conn_id, + device: snap, + }); } Err(e) => { let _ = tx.send(UiEvent::Error(format!("新增从站失败: {e}"))); @@ -836,8 +866,12 @@ impl SlaveApp { } fn submit_batch_add(&mut self, ctx: egui::Context) { - let Some(state) = self.batch_modal.as_mut() else { return }; - if state.busy { return; } + let Some(state) = self.batch_modal.as_mut() else { + return; + }; + if state.busy { + return; + } if state.end_addr < state.start_addr { self.last_error = Some("结束地址必须 ≥ 起始地址".to_string()); return; @@ -928,7 +962,12 @@ impl SlaveApp { /// Clones the target HashMap into an Arc (cheap: shares buckets via Arc, /// no per-cell copy in the UI render path). fn refresh_reg_view(&mut self) { - let Selection::RegisterGroup { conn_id, slave_id, reg_type } = self.selection.clone() else { + let Selection::RegisterGroup { + conn_id, + slave_id, + reg_type, + } = self.selection.clone() + else { self.reg_view = None; self.reg_view_last_refresh = None; return; @@ -948,13 +987,19 @@ impl SlaveApp { } } - let Ok(entries) = self.connections.try_read() else { return }; + let Ok(entries) = self.connections.try_read() else { + return; + }; let Some(entry) = entries.iter().find(|e| e.id == conn_id) else { self.reg_view = None; return; }; - let Ok(conn) = entry.connection.try_read() else { return }; - let Ok(devs) = conn.devices.try_read() else { return }; + let Ok(conn) = entry.connection.try_read() else { + return; + }; + let Ok(devs) = conn.devices.try_read() else { + return; + }; let Some(dev) = devs.get(&slave_id) else { self.reg_view = None; return; @@ -990,9 +1035,7 @@ impl SlaveApp { let mut defs_map: std::collections::HashMap = std::collections::HashMap::new(); for d in &dev.register_defs { - if d.register_type == reg_type - && (!d.name.is_empty() || !d.comment.is_empty()) - { + if d.register_type == reg_type && (!d.name.is_empty() || !d.comment.is_empty()) { defs_map.insert(d.address, (d.name.clone(), d.comment.clone())); } } @@ -1294,7 +1337,8 @@ impl SlaveApp { match serialize_slave(&proj) { Ok(json) => match tokio::fs::write(path.path(), json).await { Ok(()) => { - let _ = tx.send(UiEvent::Info(format!("已保存:{}", path.path().display()))); + let _ = + tx.send(UiEvent::Info(format!("已保存:{}", path.path().display()))); } Err(e) => { let _ = tx.send(UiEvent::Error(format!("写入失败: {e}"))); @@ -1409,7 +1453,11 @@ impl SlaveApp { UiEvent::ConnectionRemoved(id) => { self.conn_snapshot.retain(|s| s.id != id); } - UiEvent::DeviceCountsUpdated { conn_id, slave_id, counts } => { + UiEvent::DeviceCountsUpdated { + conn_id, + slave_id, + counts, + } => { if let Some(s) = self.conn_snapshot.iter_mut().find(|s| s.id == conn_id) { if let Some(d) = s.devices.iter_mut().find(|d| d.slave_id == slave_id) { d.counts = counts; @@ -1448,10 +1496,20 @@ impl SlaveApp { enum TreeAction { ToggleConn(String), - ToggleDevice { conn_id: String, slave_id: u8 }, + ToggleDevice { + conn_id: String, + slave_id: u8, + }, SelectConn(String), - SelectDevice { conn_id: String, slave_id: u8 }, - SelectGroup { conn_id: String, slave_id: u8, reg_type: RegisterType }, + SelectDevice { + conn_id: String, + slave_id: u8, + }, + SelectGroup { + conn_id: String, + slave_id: u8, + reg_type: RegisterType, + }, StartConn(String), StopConn(String), RemoveConn(String), @@ -1501,7 +1559,10 @@ impl ValueDisplayMode { } } pub fn is_multi_word(&self) -> bool { - matches!(self, Self::F32(_) | Self::U32(_) | Self::I32(_) | Self::F64(_)) + matches!( + self, + Self::F32(_) | Self::U32(_) | Self::I32(_) | Self::F64(_) + ) } pub fn stride(&self) -> usize { match self { @@ -1538,7 +1599,12 @@ const DATA_TYPES: &[DataType] = &[ DataType::Float32, ]; -const ENDIANS: &[Endian] = &[Endian::Big, Endian::Little, Endian::MidBig, Endian::MidLittle]; +const ENDIANS: &[Endian] = &[ + Endian::Big, + Endian::Little, + Endian::MidBig, + Endian::MidLittle, +]; fn selection_conn_id(s: &Selection) -> Option<&str> { match s { @@ -1715,8 +1781,11 @@ impl SlaveApp { if grp_is_selected { paint_active_row(ui, row_resp.rect); } else if row_resp.hovered() { - ui.painter() - .rect_filled(row_resp.rect, 0.0, theme::bg_hover(flavor)); + ui.painter().rect_filled( + row_resp.rect, + 0.0, + theme::bg_hover(flavor), + ); } let label_color = if grp_is_selected { acc_fg } else { muted_color }; @@ -1771,8 +1840,16 @@ impl SlaveApp { self.selected_addrs.clear(); self.click_anchor = None; } - TreeAction::SelectGroup { conn_id, slave_id, reg_type } => { - self.selection = Selection::RegisterGroup { conn_id, slave_id, reg_type }; + TreeAction::SelectGroup { + conn_id, + slave_id, + reg_type, + } => { + self.selection = Selection::RegisterGroup { + conn_id, + slave_id, + reg_type, + }; self.pending_edits.clear(); self.selected_addrs.clear(); self.click_anchor = None; @@ -1785,9 +1862,14 @@ impl SlaveApp { } fn render_add_device_modal(&mut self, ctx: &egui::Context) { - if self.add_device_modal.is_none() { return; } + if self.add_device_modal.is_none() { + return; + } - enum Act { Submit, Close } + enum Act { + Submit, + Close, + } let mut act: Option = None; let mut is_open = true; @@ -1797,7 +1879,9 @@ impl SlaveApp { .anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0]) .open(&mut is_open) .show(ctx, |ui| { - let Some(st) = self.add_device_modal.as_mut() else { return }; + let Some(st) = self.add_device_modal.as_mut() else { + return; + }; egui::Grid::new("add_device_grid") .num_columns(2) .spacing([12.0, 6.0]) @@ -1825,8 +1909,16 @@ impl SlaveApp { }) .show_ui(ui, |ui| { ui.selectable_value(&mut st.init_mode, DeviceInitMode::Empty, "空"); - ui.selectable_value(&mut st.init_mode, DeviceInitMode::Default, "默认值(全 0)"); - ui.selectable_value(&mut st.init_mode, DeviceInitMode::Random, "随机"); + ui.selectable_value( + &mut st.init_mode, + DeviceInitMode::Default, + "默认值(全 0)", + ); + ui.selectable_value( + &mut st.init_mode, + DeviceInitMode::Random, + "随机", + ); }); ui.end_row(); @@ -1839,28 +1931,42 @@ impl SlaveApp { ui.separator(); ui.horizontal(|ui| { - if ui.add_enabled(!st.busy, egui::Button::new("确认")).clicked() { + if ui + .add_enabled(!st.busy, egui::Button::new("确认")) + .clicked() + { act = Some(Act::Submit); } if ui.button("取消").clicked() { act = Some(Act::Close); } - if st.busy { ui.spinner(); } + if st.busy { + ui.spinner(); + } }); }); - if !is_open { act = Some(Act::Close); } + if !is_open { + act = Some(Act::Close); + } match act { Some(Act::Submit) => self.submit_add_device(ctx.clone()), - Some(Act::Close) => { self.add_device_modal = None; } + Some(Act::Close) => { + self.add_device_modal = None; + } None => {} } } fn render_batch_modal(&mut self, ctx: &egui::Context) { - if self.batch_modal.is_none() { return; } + if self.batch_modal.is_none() { + return; + } - enum ModalAction { Submit, Close } + enum ModalAction { + Submit, + Close, + } let mut action: Option = None; let mut is_open = true; @@ -1870,7 +1976,9 @@ impl SlaveApp { .anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0]) .open(&mut is_open) .show(ctx, |ui| { - let Some(state) = self.batch_modal.as_mut() else { return }; + let Some(state) = self.batch_modal.as_mut() else { + return; + }; egui::Grid::new("batch_add_grid") .num_columns(2) .spacing([12.0, 6.0]) @@ -1902,7 +2010,11 @@ impl SlaveApp { .selected_text(data_type_label(state.data_type)) .show_ui(ui, |ui| { for dt in DATA_TYPES { - ui.selectable_value(&mut state.data_type, *dt, data_type_label(*dt)); + ui.selectable_value( + &mut state.data_type, + *dt, + data_type_label(*dt), + ); } }); ui.end_row(); @@ -1925,36 +2037,52 @@ impl SlaveApp { let stride = state.data_type.register_count().max(1) as u32; let raw_count = if state.end_addr >= state.start_addr { (state.end_addr - state.start_addr) / stride + 1 - } else { 0 }; + } else { + 0 + }; ui.separator(); ui.horizontal(|ui| { if raw_count == 0 { ui.colored_label(egui::Color32::RED, "范围无效"); } else if raw_count > 50_000 { - ui.colored_label(egui::Color32::RED, format!("范围过大(最多 50000,当前 {raw_count})")); + ui.colored_label( + egui::Color32::RED, + format!("范围过大(最多 50000,当前 {raw_count})"), + ); } else { ui.label(format!("将添加 {raw_count} 个条目")); } }); ui.horizontal(|ui| { - if ui.add_enabled(!state.busy && raw_count > 0 && raw_count <= 50_000, - egui::Button::new("确认添加")).clicked() { + if ui + .add_enabled( + !state.busy && raw_count > 0 && raw_count <= 50_000, + egui::Button::new("确认添加"), + ) + .clicked() + { action = Some(ModalAction::Submit); } if ui.button("取消").clicked() { action = Some(ModalAction::Close); } - if state.busy { ui.spinner(); } + if state.busy { + ui.spinner(); + } }); }); - if !is_open { action = Some(ModalAction::Close); } + if !is_open { + action = Some(ModalAction::Close); + } match action { Some(ModalAction::Submit) => { self.submit_batch_add(ctx.clone()); self.batch_modal = None; } - Some(ModalAction::Close) => { self.batch_modal = None; } + Some(ModalAction::Close) => { + self.batch_modal = None; + } None => {} } } @@ -1967,7 +2095,11 @@ impl SlaveApp { ui.vertical_centered(|ui| { ui.add_space(40.0); ui.heading(format!("{} ModbusSlave", icons::CPU)); - uikit::caption(ui, self.flavor, "从左侧创建或选中一个连接 / 设备 / 寄存器组。"); + uikit::caption( + ui, + self.flavor, + "从左侧创建或选中一个连接 / 设备 / 寄存器组。", + ); }); } Selection::Connection(id) => { @@ -2083,22 +2215,13 @@ impl SlaveApp { .spacing([12.0, 6.0]) .show(ui, |ui| { ui.label("周期"); - ui.add( - egui::Slider::new(&mut interval, 100..=5000) - .suffix(" ms"), - ); + ui.add(egui::Slider::new(&mut interval, 100..=5000).suffix(" ms")); ui.end_row(); ui.label("变位率"); - ui.add( - egui::Slider::new(&mut rate, 0..=100) - .suffix(" %"), - ); + ui.add(egui::Slider::new(&mut rate, 0..=100).suffix(" %")); ui.end_row(); ui.label("漂移幅度"); - ui.add( - egui::Slider::new(&mut delta, 0..=100) - .suffix(" %"), - ); + ui.add(egui::Slider::new(&mut delta, 0..=100).suffix(" %")); ui.end_row(); }); new_jitter.interval_ms = interval as u64; @@ -2232,7 +2355,11 @@ impl SlaveApp { } } } - Selection::RegisterGroup { conn_id, slave_id, reg_type } => { + Selection::RegisterGroup { + conn_id, + slave_id, + reg_type, + } => { let group_label = REG_GROUPS .iter() .find(|(rt, _)| rt == reg_type) @@ -2284,7 +2411,8 @@ impl SlaveApp { } else { "◧ 值解析 (V)" }; - if uikit::link_action(ui, flavor, toggle_label, false).clicked() { + if uikit::link_action(ui, flavor, toggle_label, false).clicked() + { self.value_parse_open = !self.value_parse_open; } ui.add_space(8.0); @@ -2299,7 +2427,9 @@ impl SlaveApp { if let Some(mut state) = egui::TextEdit::load_state(ui.ctx(), resp.id) { - let cc = egui::text::CCursor::new(search_text.chars().count()); + let cc = egui::text::CCursor::new( + search_text.chars().count(), + ); state.cursor.set_char_range(Some( egui::text::CCursorRange::two( egui::text::CCursor::new(0), @@ -2315,7 +2445,8 @@ impl SlaveApp { }); }, ); - self.search_buf.insert(search_key.clone(), search_text.clone()); + self.search_buf + .insert(search_key.clone(), search_text.clone()); self.want_focus_search = want_focus; let search_intent = parse_search_intent(&search_text); if open_batch { @@ -2334,10 +2465,7 @@ impl SlaveApp { return; } - let is_bool = matches!( - reg_type, - RegisterType::Coil | RegisterType::DiscreteInput - ); + let is_bool = matches!(reg_type, RegisterType::Coil | RegisterType::DiscreteInput); // 只有 16-bit 寄存器区(FC03 / FC04)支持多字节序显示;线圈区强制 U16。 let mode = if is_bool { @@ -2401,359 +2529,490 @@ impl SlaveApp { .size(panel_size) .horizontal(|mut strip| { strip.cell(|ui| { - uikit::region(ui, flavor, theme::Layer::L2, egui::Margin::symmetric(8.0 as i8, 6.0 as i8), |ui| { - if !is_bool && mode.is_multi_word() { - let stride = mode.stride(); - let group_rows = view.row_count / stride; - let endian = match mode { - ValueDisplayMode::F32(e) - | ValueDisplayMode::U32(e) - | ValueDisplayMode::I32(e) => e, - _ => Endian::Big, - }; - let f64_order = match mode { - ValueDisplayMode::F64(o) => o, - _ => F64Order::Abcdefgh, - }; - let avail_h = ui.available_height(); - TableBuilder::new(ui) - .striped(false) - .resizable(true) - .max_scroll_height(avail_h) - .cell_layout(egui::Layout::left_to_right(egui::Align::Center)) - .column(Column::exact(110.0)) - .column(Column::exact(220.0)) - .column(Column::exact(200.0)) - .column(Column::remainder()) - .header(26.0, |mut h| { - h.col(|ui| theme::text::tiny_caps(ui, flavor, "地址")); - h.col(|ui| theme::text::tiny_caps(ui, flavor, mode.label())); - h.col(|ui| theme::text::tiny_caps(ui, flavor, "Raw HEX")); - h.col(|_| {}); - }) - .body(|body| { - body.rows(row_h, group_rows, |mut row| { - let base = row.index() as u16 * stride as u16; - // Gather stride u16 values. - let mut ws: Vec = Vec::with_capacity(stride); - let mut all_present = true; - for i in 0..stride as u16 { - match view - .u16_map - .as_ref() - .and_then(|m| m.get(&(base + i)).copied()) - { - Some(v) => ws.push(v), - None => { all_present = false; break; } - } - } - row.col(|ui| { - let sel = (0..stride as u16) - .any(|i| selected_addrs.contains(&(base + i))); - let label = if stride == 4 { - format!("{}..{}", base, base + 3) + uikit::region( + ui, + flavor, + theme::Layer::L2, + egui::Margin::symmetric(8.0 as i8, 6.0 as i8), + |ui| { + if !is_bool && mode.is_multi_word() { + let stride = mode.stride(); + let group_rows = view.row_count / stride; + let endian = match mode { + ValueDisplayMode::F32(e) + | ValueDisplayMode::U32(e) + | ValueDisplayMode::I32(e) => e, + _ => Endian::Big, + }; + let f64_order = match mode { + ValueDisplayMode::F64(o) => o, + _ => F64Order::Abcdefgh, + }; + let avail_h = ui.available_height(); + TableBuilder::new(ui) + .striped(false) + .resizable(true) + .max_scroll_height(avail_h) + .cell_layout(egui::Layout::left_to_right( + egui::Align::Center, + )) + .column(Column::exact(110.0)) + .column(Column::exact(220.0)) + .column(Column::exact(200.0)) + .column(Column::remainder()) + .header(26.0, |mut h| { + h.col(|ui| { + theme::text::tiny_caps(ui, flavor, "地址") + }); + h.col(|ui| { + theme::text::tiny_caps(ui, flavor, mode.label()) + }); + h.col(|ui| { + theme::text::tiny_caps(ui, flavor, "Raw HEX") + }); + h.col(|_| {}); + }) + .body(|body| { + body.rows(row_h, group_rows, |mut row| { + let base = row.index() as u16 * stride as u16; + // Gather stride u16 values. + let mut ws: Vec = + Vec::with_capacity(stride); + let mut all_present = true; + for i in 0..stride as u16 { + match view.u16_map.as_ref().and_then(|m| { + m.get(&(base + i)).copied() + }) { + Some(v) => ws.push(v), + None => { + all_present = false; + break; + } + } + } + row.col(|ui| { + let sel = (0..stride as u16).any(|i| { + selected_addrs.contains(&(base + i)) + }); + let label = if stride == 4 { + format!("{}..{}", base, base + 3) + } else { + format!("{}..{}", base, base + 1) + }; + let resp = + ui.add(egui::SelectableLabel::new( + sel, + egui::RichText::new(label) + .monospace(), + )); + if resp.clicked() { + row_clicks.push(( + base, + resp.ctx.input(|i| i.modifiers), + )); + } + }); + row.col(|ui| { + if !all_present { + ui.monospace("—"); + return; + } + let text = match mode { + ValueDisplayMode::F32(_) => { + let d = decode_value( + &ws, + DataType::Float32, + endian, + ) + .unwrap_or(f64::NAN); + format!("{:.6}", d as f32) + } + ValueDisplayMode::U32(_) => { + let d = decode_value( + &ws, + DataType::UInt32, + endian, + ) + .unwrap_or(f64::NAN); + format!("{}", d as u32) + } + ValueDisplayMode::I32(_) => { + let d = decode_value( + &ws, + DataType::Int32, + endian, + ) + .unwrap_or(f64::NAN); + format!("{}", d as i32) + } + ValueDisplayMode::F64(_) => { + let v = value_panel::decode_f64( + &ws, f64_order, + ); + if v.is_finite() { + format!("{:.9}", v) + } else { + "NaN / Inf".to_string() + } + } + _ => "?".to_string(), + }; + ui.monospace(text); + }); + row.col(|ui| { + if !all_present { + ui.monospace(""); + return; + } + let joined = ws + .iter() + .map(|w| format!("{:04X}", w)) + .collect::>() + .join(" "); + ui.monospace(joined); + }); + row.col(|_| {}); + }); + }); } else { - format!("{}..{}", base, base + 1) - }; - let resp = ui.add(egui::SelectableLabel::new( - sel, - egui::RichText::new(label).monospace(), - )); - if resp.clicked() { - row_clicks.push((base, resp.ctx.input(|i| i.modifiers))); - } - }); - row.col(|ui| { - if !all_present { - ui.monospace("—"); - return; - } - let text = match mode { - ValueDisplayMode::F32(_) => { - let d = decode_value(&ws, DataType::Float32, endian) - .unwrap_or(f64::NAN); - format!("{:.6}", d as f32) - } - ValueDisplayMode::U32(_) => { - let d = decode_value(&ws, DataType::UInt32, endian) - .unwrap_or(f64::NAN); - format!("{}", d as u32) - } - ValueDisplayMode::I32(_) => { - let d = decode_value(&ws, DataType::Int32, endian) - .unwrap_or(f64::NAN); - format!("{}", d as i32) - } - ValueDisplayMode::F64(_) => { - let v = value_panel::decode_f64(&ws, f64_order); - if v.is_finite() { - format!("{:.9}", v) - } else { - "NaN / Inf".to_string() + // Apply search intent: Jump sets a one-shot scroll_to + highlight; + // Filter builds a reduced addr list that drives body.rows. + let filtered_addrs: Option> = match &search_intent + { + SearchIntent::None | SearchIntent::Jump(_) => None, + SearchIntent::Filter(q) => { + let ndl = q.as_str(); + let v: Vec = (0..view.row_count as u16) + .filter(|a| a.to_string().contains(ndl)) + .collect(); + Some(v) + } + }; + let mut scroll_to_row: Option = None; + if let SearchIntent::Jump(addr) = search_intent { + if (addr as usize) < view.row_count { + // Only start a new highlight if the target changed — prevents + // re-scroll on every keystroke once the user stops typing. + let new_key = + (conn_id.clone(), *slave_id, *reg_type, addr); + let same = self + .highlight + .as_ref() + .map(|h| { + (h.0.clone(), h.1, h.2, h.3) == new_key + }) + .unwrap_or(false); + if !same { + self.highlight = Some(( + new_key.0, + new_key.1, + new_key.2, + addr, + Instant::now(), + )); + scroll_to_row = Some(addr as usize); + } } } - _ => "?".to_string(), - }; - ui.monospace(text); - }); - row.col(|ui| { - if !all_present { - ui.monospace(""); - return; - } - let joined = ws - .iter() - .map(|w| format!("{:04X}", w)) - .collect::>() - .join(" "); - ui.monospace(joined); - }); - row.col(|_| {}); - }); - }); - } else { - // Apply search intent: Jump sets a one-shot scroll_to + highlight; - // Filter builds a reduced addr list that drives body.rows. - let filtered_addrs: Option> = match &search_intent { - SearchIntent::None | SearchIntent::Jump(_) => None, - SearchIntent::Filter(q) => { - let ndl = q.as_str(); - let v: Vec = (0..view.row_count as u16) - .filter(|a| a.to_string().contains(ndl)) - .collect(); - Some(v) - } - }; - let mut scroll_to_row: Option = None; - if let SearchIntent::Jump(addr) = search_intent { - if (addr as usize) < view.row_count { - // Only start a new highlight if the target changed — prevents - // re-scroll on every keystroke once the user stops typing. - let new_key = (conn_id.clone(), *slave_id, *reg_type, addr); - let same = self - .highlight - .as_ref() - .map(|h| (h.0.clone(), h.1, h.2, h.3) == new_key) - .unwrap_or(false); - if !same { - self.highlight = - Some((new_key.0, new_key.1, new_key.2, addr, Instant::now())); - scroll_to_row = Some(addr as usize); - } - } - } - if let Some(list) = &filtered_addrs { - if list.is_empty() { - ui.add_space(8.0); - uikit::caption(ui, flavor, "无匹配寄存器"); - return; - } - } - - let body_row_count = filtered_addrs.as_ref().map(|v| v.len()).unwrap_or(view.row_count); - let avail_h = ui.available_height(); - // Column layout differs for bool: (地址 / 值 / 名称 / 注释) - // vs u16: (地址 / 值 / Hex / Binary / 空). - // Column::exact() hard-locks range(w..=w), defeating resizable(true). - // Use initial() + at_least() + clip(true) so users can drag column - // dividers; at_least prevents dragging to 0 (would hide column). - let mut tb = TableBuilder::new(ui) - .striped(false) - .resizable(true) - .max_scroll_height(avail_h) - .cell_layout(egui::Layout::left_to_right(egui::Align::Center)) - .column(Column::initial(80.0).at_least(60.0).clip(true)); // 地址 - if is_bool { - tb = tb - .column(Column::initial(72.0).at_least(56.0).clip(true)) // 值 (48×24 toggle + 余量) - .column(Column::initial(200.0).at_least(80.0).clip(true)) // 名称 - .column(Column::remainder().at_least(80.0).clip(true)); // 注释 - } else { - tb = tb - .column(Column::initial(110.0).at_least(72.0).clip(true)) // 值 - .column(Column::initial(100.0).at_least(72.0).clip(true)) // Hex - .column(Column::initial(140.0).at_least(96.0).clip(true)) // Binary - .column(Column::remainder().at_least(80.0).clip(true)); // 尾 - } - if let Some(idx) = scroll_to_row { - tb = tb.scroll_to_row(idx, Some(egui::Align::Center)); - } - let highlight_addr: Option = self.highlight.as_ref().and_then(|h| { - if &h.0 == conn_id && h.1 == *slave_id && h.2 == *reg_type { - Some(h.3) - } else { - None - } - }); - let defs = view.defs.clone(); - tb - .header(26.0, |mut header| { - header.col(|ui| theme::text::tiny_caps(ui, flavor, "地址")); - if is_bool { - header.col(|ui| theme::text::tiny_caps(ui, flavor, "值")); - header.col(|ui| theme::text::tiny_caps(ui, flavor, "名称")); - header.col(|ui| theme::text::tiny_caps(ui, flavor, "注释")); - } else { - header.col(|ui| theme::text::tiny_caps(ui, flavor, mode.label())); - header.col(|ui| theme::text::tiny_caps(ui, flavor, "HEX")); - header.col(|ui| theme::text::tiny_caps(ui, flavor, "二进制")); - header.col(|_| {}); - } - }) - .body(|body| { - body.rows(row_h, body_row_count, |mut row| { - let addr = if let Some(list) = &filtered_addrs { - list[row.index()] - } else { - row.index() as u16 - }; - if Some(addr) == highlight_addr { - row.set_selected(true); - } - row.col(|ui| { - let sel = selected_addrs.contains(&addr); - let resp = ui.add(egui::SelectableLabel::new( - sel, - egui::RichText::new(format!("{}", addr)).monospace(), - )); - if resp.clicked() { - row_clicks.push((addr, resp.ctx.input(|i| i.modifiers))); - } - }); - - let cache_u16 = view - .u16_map - .as_ref() - .and_then(|m| m.get(&addr).copied()) - .unwrap_or(0); - let cache_bool = view - .bool_map - .as_ref() - .and_then(|m| m.get(&addr).copied()) - .unwrap_or(false); - let key = (reg_type_v, addr); - - if is_bool { - let current = pending - .get(&key) - .map(|v| *v != 0) - .unwrap_or(cache_bool); - row.col(|ui| { - let mut tmp = current; - let resp = uikit::toggle_switch(ui, flavor, &mut tmp); - if resp.clicked() && tmp != current { - writes.push((addr, if tmp { 1 } else { 0 })); - pending.remove(&key); + if let Some(list) = &filtered_addrs { + if list.is_empty() { + ui.add_space(8.0); + uikit::caption(ui, flavor, "无匹配寄存器"); + return; + } } - }); - let name = defs - .get(&addr) - .map(|(n, _)| n.clone()) - .unwrap_or_default(); - let comment = defs - .get(&addr) - .map(|(_, c)| c.clone()) - .unwrap_or_default(); - row.col(|ui| { - if !name.is_empty() { - ui.monospace(name); + + let body_row_count = filtered_addrs + .as_ref() + .map(|v| v.len()) + .unwrap_or(view.row_count); + let avail_h = ui.available_height(); + // Column layout differs for bool: (地址 / 值 / 名称 / 注释) + // vs u16: (地址 / 值 / Hex / Binary / 空). + // Column::exact() hard-locks range(w..=w), defeating resizable(true). + // Use initial() + at_least() + clip(true) so users can drag column + // dividers; at_least prevents dragging to 0 (would hide column). + let mut tb = TableBuilder::new(ui) + .striped(false) + .resizable(true) + .max_scroll_height(avail_h) + .cell_layout(egui::Layout::left_to_right( + egui::Align::Center, + )) + .column( + Column::initial(80.0).at_least(60.0).clip(true), + ); // 地址 + if is_bool { + tb = tb + .column( + Column::initial(72.0).at_least(56.0).clip(true), + ) // 值 (48×24 toggle + 余量) + .column( + Column::initial(200.0) + .at_least(80.0) + .clip(true), + ) // 名称 + .column( + Column::remainder().at_least(80.0).clip(true), + ); // 注释 + } else { + tb = tb + .column( + Column::initial(110.0) + .at_least(72.0) + .clip(true), + ) // 值 + .column( + Column::initial(100.0) + .at_least(72.0) + .clip(true), + ) // Hex + .column( + Column::initial(140.0) + .at_least(96.0) + .clip(true), + ) // Binary + .column( + Column::remainder().at_least(80.0).clip(true), + ); // 尾 } - }); - row.col(|ui| { - if !comment.is_empty() { - ui.monospace(comment); + if let Some(idx) = scroll_to_row { + tb = tb.scroll_to_row(idx, Some(egui::Align::Center)); } - }); - } else { - row.col(|ui| { - let (min_i, max_i) = match mode { - ValueDisplayMode::I16 => (i16::MIN as i32, i16::MAX as i32), - _ => (0, u16::MAX as i32), - }; - let cache_as_display = match mode { - ValueDisplayMode::I16 => cache_u16 as i16 as i32, - _ => cache_u16 as i32, - }; - let mut tmp: i32 = pending - .get(&key) - .copied() - .unwrap_or(cache_as_display); - let resp = ui.add( - egui::DragValue::new(&mut tmp).range(min_i..=max_i), - ); - let active = resp.has_focus() - || resp.dragged() - || resp.drag_started() - || resp.gained_focus(); - if active { - pending.insert(key, tmp); - } else if let Some(prev) = pending.remove(&key) { - let v = match mode { - ValueDisplayMode::I16 => { - prev.clamp(i16::MIN as i32, i16::MAX as i32) - as i16 as u16 + let highlight_addr: Option = + self.highlight.as_ref().and_then(|h| { + if &h.0 == conn_id + && h.1 == *slave_id + && h.2 == *reg_type + { + Some(h.3) + } else { + None } - _ => prev.clamp(0, 65535) as u16, - }; - if v != cache_u16 { - writes.push((addr, v)); - } - } - }); - let display_u16 = pending - .get(&key) - .copied() - .map(|v| match mode { - ValueDisplayMode::I16 => { - v.clamp(i16::MIN as i32, i16::MAX as i32) as i16 - as u16 + }); + let defs = view.defs.clone(); + tb.header(26.0, |mut header| { + header.col(|ui| { + theme::text::tiny_caps(ui, flavor, "地址") + }); + if is_bool { + header.col(|ui| { + theme::text::tiny_caps(ui, flavor, "值") + }); + header.col(|ui| { + theme::text::tiny_caps(ui, flavor, "名称") + }); + header.col(|ui| { + theme::text::tiny_caps(ui, flavor, "注释") + }); + } else { + header.col(|ui| { + theme::text::tiny_caps(ui, flavor, mode.label()) + }); + header.col(|ui| { + theme::text::tiny_caps(ui, flavor, "HEX") + }); + header.col(|ui| { + theme::text::tiny_caps(ui, flavor, "二进制") + }); + header.col(|_| {}); } - _ => v.clamp(0, 65535) as u16, }) - .unwrap_or(cache_u16); - row.col(|ui| { - ui.monospace(format_u16(display_u16, U16Format::Hex)); - }); - row.col(|ui| { - ui.monospace(format_u16(display_u16, U16Format::Binary)); - }); - row.col(|_| {}); - } - }); - }); - } + .body(|body| { + body.rows(row_h, body_row_count, |mut row| { + let addr = if let Some(list) = &filtered_addrs { + list[row.index()] + } else { + row.index() as u16 + }; + if Some(addr) == highlight_addr { + row.set_selected(true); + } + row.col(|ui| { + let sel = selected_addrs.contains(&addr); + let resp = ui.add(egui::SelectableLabel::new( + sel, + egui::RichText::new(format!("{}", addr)) + .monospace(), + )); + if resp.clicked() { + row_clicks.push(( + addr, + resp.ctx.input(|i| i.modifiers), + )); + } + }); - }); // end left region + let cache_u16 = view + .u16_map + .as_ref() + .and_then(|m| m.get(&addr).copied()) + .unwrap_or(0); + let cache_bool = view + .bool_map + .as_ref() + .and_then(|m| m.get(&addr).copied()) + .unwrap_or(false); + let key = (reg_type_v, addr); + + if is_bool { + let current = pending + .get(&key) + .map(|v| *v != 0) + .unwrap_or(cache_bool); + row.col(|ui| { + let mut tmp = current; + let resp = uikit::toggle_switch( + ui, flavor, &mut tmp, + ); + if resp.clicked() && tmp != current { + writes.push(( + addr, + if tmp { 1 } else { 0 }, + )); + pending.remove(&key); + } + }); + let name = defs + .get(&addr) + .map(|(n, _)| n.clone()) + .unwrap_or_default(); + let comment = defs + .get(&addr) + .map(|(_, c)| c.clone()) + .unwrap_or_default(); + row.col(|ui| { + if !name.is_empty() { + ui.monospace(name); + } + }); + row.col(|ui| { + if !comment.is_empty() { + ui.monospace(comment); + } + }); + } else { + row.col(|ui| { + let (min_i, max_i) = match mode { + ValueDisplayMode::I16 => { + (i16::MIN as i32, i16::MAX as i32) + } + _ => (0, u16::MAX as i32), + }; + let cache_as_display = match mode { + ValueDisplayMode::I16 => { + cache_u16 as i16 as i32 + } + _ => cache_u16 as i32, + }; + let mut tmp: i32 = pending + .get(&key) + .copied() + .unwrap_or(cache_as_display); + let resp = ui.add( + egui::DragValue::new(&mut tmp) + .range(min_i..=max_i), + ); + let active = resp.has_focus() + || resp.dragged() + || resp.drag_started() + || resp.gained_focus(); + if active { + pending.insert(key, tmp); + } else if let Some(prev) = + pending.remove(&key) + { + let v = match mode { + ValueDisplayMode::I16 => { + prev.clamp( + i16::MIN as i32, + i16::MAX as i32, + ) + as i16 + as u16 + } + _ => prev.clamp(0, 65535) as u16, + }; + if v != cache_u16 { + writes.push((addr, v)); + } + } + }); + let display_u16 = pending + .get(&key) + .copied() + .map(|v| match mode { + ValueDisplayMode::I16 => v.clamp( + i16::MIN as i32, + i16::MAX as i32, + ) + as i16 + as u16, + _ => v.clamp(0, 65535) as u16, + }) + .unwrap_or(cache_u16); + row.col(|ui| { + ui.monospace(format_u16( + display_u16, + U16Format::Hex, + )); + }); + row.col(|ui| { + ui.monospace(format_u16( + display_u16, + U16Format::Binary, + )); + }); + row.col(|_| {}); + } + }); + }); + } + }, + ); // end left region }); // end StripBuilder left cell - strip.cell(|_ui| { }); + strip.cell(|_ui| {}); strip.cell(|ui| { - uikit::region(ui, flavor, theme::Layer::L1, egui::Margin::symmetric(12.0 as i8, 10.0 as i8), |ui| { - let mut selected_vals: Vec = Vec::new(); - let mut base: Option = None; - // Only take up to 4 selected, in address order, and - // require them to be contiguous for multi-word analysis. - let ordered: Vec = selected_addrs.iter().copied().take(4).collect(); - for (i, a) in ordered.iter().enumerate() { - if i == 0 { - base = Some(*a); - } else if *a != ordered[i - 1] + 1 { - // Non-contiguous: stop collecting so ValuePanel - // only shows formats it can compute safely. - break; - } - if let Some(v) = view.u16_map.as_ref().and_then(|m| m.get(a).copied()) { - selected_vals.push(v); - } else if let Some(b) = view.bool_map.as_ref().and_then(|m| m.get(a).copied()) { - selected_vals.push(if b { 1 } else { 0 }); - } - } - if let Some(vp_writes) = value_panel::render(ui, flavor, &selected_vals, base) { - for w in vp_writes { - writes.push(w); - } - } - }); // end right region + uikit::region( + ui, + flavor, + theme::Layer::L1, + egui::Margin::symmetric(12.0 as i8, 10.0 as i8), + |ui| { + let mut selected_vals: Vec = Vec::new(); + let mut base: Option = None; + // Only take up to 4 selected, in address order, and + // require them to be contiguous for multi-word analysis. + let ordered: Vec = + selected_addrs.iter().copied().take(4).collect(); + for (i, a) in ordered.iter().enumerate() { + if i == 0 { + base = Some(*a); + } else if *a != ordered[i - 1] + 1 { + // Non-contiguous: stop collecting so ValuePanel + // only shows formats it can compute safely. + break; + } + if let Some(v) = + view.u16_map.as_ref().and_then(|m| m.get(a).copied()) + { + selected_vals.push(v); + } else if let Some(b) = + view.bool_map.as_ref().and_then(|m| m.get(a).copied()) + { + selected_vals.push(if b { 1 } else { 0 }); + } + } + if let Some(vp_writes) = + value_panel::render(ui, flavor, &selected_vals, base) + { + for w in vp_writes { + writes.push(w); + } + } + }, + ); // end right region }); }); // end StripBuilder horizontal @@ -2765,11 +3024,17 @@ impl SlaveApp { for (addr, modifiers) in row_clicks { if modifiers.shift { let anchor = self.click_anchor.unwrap_or(addr); - let (a, b) = if anchor <= addr { (anchor, addr) } else { (addr, anchor) }; + let (a, b) = if anchor <= addr { + (anchor, addr) + } else { + (addr, anchor) + }; self.selected_addrs.clear(); for x in a..=b { self.selected_addrs.insert(x); - if self.selected_addrs.len() >= 16 { break; } + if self.selected_addrs.len() >= 16 { + break; + } } } else if modifiers.command || modifiers.ctrl { if !self.selected_addrs.remove(&addr) { @@ -2830,10 +3095,7 @@ impl eframe::App for SlaveApp { // Cmd+F / Ctrl+F focuses the RegisterGroup search box (if that view is // active). COMMAND maps to ⌘ on macOS / Ctrl elsewhere. Consume up-front // so the window system doesn't swallow it. - let find_shortcut = egui::KeyboardShortcut::new( - egui::Modifiers::COMMAND, - egui::Key::F, - ); + let find_shortcut = egui::KeyboardShortcut::new(egui::Modifiers::COMMAND, egui::Key::F); if ctx.input_mut(|i| i.consume_shortcut(&find_shortcut)) && matches!(self.selection, Selection::RegisterGroup { .. }) { @@ -2883,12 +3145,20 @@ impl eframe::App for SlaveApp { } }); ui.menu_button("视图", |ui| { - if ui.checkbox(&mut self.log_state.open, "显示日志面板").clicked() { + if ui + .checkbox(&mut self.log_state.open, "显示日志面板") + .clicked() + { ui.close_menu(); } ui.separator(); ui.label("主题 (Catppuccin)"); - for f in [Flavor::Mocha, Flavor::Macchiato, Flavor::Frappe, Flavor::Latte] { + for f in [ + Flavor::Mocha, + Flavor::Macchiato, + Flavor::Frappe, + Flavor::Latte, + ] { if ui.radio_value(&mut self.flavor, f, f.label()).clicked() { theme::apply(ctx, self.flavor); ui.close_menu(); @@ -2908,10 +3178,7 @@ impl eframe::App for SlaveApp { }); ui.menu_button("帮助", |ui| { ui.label("ModbusSlave (egui) · 开发预览"); - ui.hyperlink_to( - "GitHub", - "https://github.com/kelsoprotein-lab/ModbusSim", - ); + ui.hyperlink_to("GitHub", "https://github.com/kelsoprotein-lab/ModbusSim"); }); }); }); @@ -2935,7 +3202,12 @@ impl eframe::App for SlaveApp { |ui| { // —— 头部:tiny_caps "连接" + 右上 + 新建 —— egui::Frame::new() - .inner_margin(egui::Margin { left: 14, right: 10, top: 12, bottom: 8 }) + .inner_margin(egui::Margin { + left: 14, + right: 10, + top: 12, + bottom: 8, + }) .show(ui, |ui| { ui.horizontal(|ui| { theme::text::tiny_caps(ui, self.flavor, "连接"); @@ -2957,7 +3229,12 @@ impl eframe::App for SlaveApp { if self.show_new_tcp_dialog { egui::Frame::new() .fill(theme::bg_of(self.flavor, theme::Layer::L2)) - .inner_margin(egui::Margin { left: 14, right: 10, top: 6, bottom: 8 }) + .inner_margin(egui::Margin { + left: 14, + right: 10, + top: 6, + bottom: 8, + }) .show(ui, |ui| { egui::Grid::new("new_tcp_form") .num_columns(2) @@ -2972,7 +3249,8 @@ impl eframe::App for SlaveApp { }); ui.add_space(4.0); ui.horizontal(|ui| { - if uikit::primary_button(ui, self.flavor, "创建").clicked() { + if uikit::primary_button(ui, self.flavor, "创建").clicked() + { tree_action = Some(TreeAction::Create); self.show_new_tcp_dialog = false; } @@ -3008,10 +3286,7 @@ impl eframe::App for SlaveApp { ui.with_layout(egui::Layout::bottom_up(egui::Align::Min), |ui| { egui::Frame::new() .fill(theme::bg_of(self.flavor, theme::Layer::L0)) - .stroke(egui::Stroke::new( - 1.0, - theme::border_subtle(self.flavor), - )) + .stroke(egui::Stroke::new(1.0, theme::border_subtle(self.flavor))) .inner_margin(egui::Margin { left: 14, right: 14, @@ -3020,14 +3295,13 @@ impl eframe::App for SlaveApp { }) .show(ui, |ui| { // Derive active connection id + state from selection - let active_conn = selection_conn_id(&self.selection) - .and_then(|id| { + let active_conn = + selection_conn_id(&self.selection).and_then(|id| { self.conn_snapshot.iter().find(|s| s.id == id) }); if let Some(snap) = active_conn { let conn_id = snap.id.clone(); - let is_running = - snap.state == ConnectionState::Running; + let is_running = snap.state == ConnectionState::Running; ui.horizontal(|ui| { let stop_label = if is_running { "停止" } else { "启动" }; @@ -3046,16 +3320,10 @@ impl eframe::App for SlaveApp { }); } ui.add_space(14.0); - if uikit::link_action( - ui, - self.flavor, - "删除连接", - true, - ) - .clicked() + if uikit::link_action(ui, self.flavor, "删除连接", true) + .clicked() { - tree_action = - Some(TreeAction::RemoveConn(conn_id)); + tree_action = Some(TreeAction::RemoveConn(conn_id)); } }); } @@ -3083,27 +3351,37 @@ impl eframe::App for SlaveApp { ui.horizontal(|ui| { if let Some(err) = &self.last_error { ui.add(egui::Label::new( - egui::RichText::new("●").color(theme::danger(flavor)).size(11.0), + egui::RichText::new("●") + .color(theme::danger(flavor)) + .size(11.0), )); ui.add(egui::Label::new( - egui::RichText::new(err).color(theme::danger(flavor)).size(11.0), + egui::RichText::new(err) + .color(theme::danger(flavor)) + .size(11.0), )); if uikit::link_action(ui, flavor, "清除", false).clicked() { clear_error = true; } } else if let Some(msg) = &self.status_msg { ui.add(egui::Label::new( - egui::RichText::new("●").color(theme::success(flavor)).size(11.0), + egui::RichText::new("●") + .color(theme::success(flavor)) + .size(11.0), )); ui.add(egui::Label::new( - egui::RichText::new(msg).color(theme::success(flavor)).size(11.0), + egui::RichText::new(msg) + .color(theme::success(flavor)) + .size(11.0), )); if uikit::link_action(ui, flavor, "清除", false).clicked() { clear_status = true; } } else { ui.add(egui::Label::new( - egui::RichText::new("●").color(theme::success(flavor)).size(11.0), + egui::RichText::new("●") + .color(theme::success(flavor)) + .size(11.0), )); theme::text::crumb(ui, flavor, "就绪"); } @@ -3118,8 +3396,12 @@ impl eframe::App for SlaveApp { }); }); }); - if clear_error { self.last_error = None; } - if clear_status { self.status_msg = None; } + if clear_error { + self.last_error = None; + } + if clear_status { + self.status_msg = None; + } self.render_log_panel(ctx); diff --git a/crates/modbussim-egui/src/main.rs b/crates/modbussim-egui/src/main.rs index 105ad44..0c7dfd2 100644 --- a/crates/modbussim-egui/src/main.rs +++ b/crates/modbussim-egui/src/main.rs @@ -57,7 +57,9 @@ fn main() -> eframe::Result<()> { modbussim_ui_shared::fonts::install_cjk_fonts(&cc.egui_ctx); let flavor = cc .storage - .and_then(|s| eframe::get_value::(s, "flavor_v3")) + .and_then(|s| { + eframe::get_value::(s, "flavor_v3") + }) .unwrap_or_default(); modbussim_ui_shared::theme::apply(&cc.egui_ctx, flavor); let mut app = app::SlaveApp::new(rt.clone(), flavor); diff --git a/crates/modbussim-ui-shared/src/fonts.rs b/crates/modbussim-ui-shared/src/fonts.rs index e44182e..8c07942 100644 --- a/crates/modbussim-ui-shared/src/fonts.rs +++ b/crates/modbussim-ui-shared/src/fonts.rs @@ -14,9 +14,10 @@ pub fn install_cjk_fonts(ctx: &egui::Context) { // CJK fallback. match load_first_available_cjk_font() { Some((name, bytes)) => { - fonts - .font_data - .insert(name.to_string(), std::sync::Arc::new(FontData::from_owned(bytes))); + fonts.font_data.insert( + name.to_string(), + std::sync::Arc::new(FontData::from_owned(bytes)), + ); fonts .families .entry(FontFamily::Proportional) @@ -48,7 +49,10 @@ fn load_first_available_cjk_font() -> Option<(&'static str, Vec)> { const CANDIDATES: &[(&str, &str)] = &[ // macOS — PingFang (if present on newer systems) ("PingFang-SC", "/System/Library/Fonts/PingFang.ttc"), - ("PingFang-Supp", "/System/Library/Fonts/Supplemental/PingFang SC.ttf"), + ( + "PingFang-Supp", + "/System/Library/Fonts/Supplemental/PingFang SC.ttf", + ), ("PingFang-App", "/Library/Fonts/PingFang.ttc"), // STHeiti Medium — bolder than Hiragino, renders with more body at 13px ("STHeiti", "/System/Library/Fonts/STHeiti Medium.ttc"), @@ -75,10 +79,7 @@ fn load_first_available_cjk_font() -> Option<(&'static str, Vec)> { "NotoCJK-Arch", "/usr/share/fonts/noto-cjk/NotoSansCJK-Regular.ttc", ), - ( - "WQYZenHei", - "/usr/share/fonts/truetype/wqy/wqy-zenhei.ttc", - ), + ("WQYZenHei", "/usr/share/fonts/truetype/wqy/wqy-zenhei.ttc"), ]; for (name, path) in CANDIDATES { diff --git a/crates/modbussim-ui-shared/src/log_panel.rs b/crates/modbussim-ui-shared/src/log_panel.rs index 8791aaf..32a21eb 100644 --- a/crates/modbussim-ui-shared/src/log_panel.rs +++ b/crates/modbussim-ui-shared/src/log_panel.rs @@ -84,8 +84,12 @@ pub fn render( let chev = if state.collapsed { "▶" } else { "▼" }; if ui .add( - egui::Label::new(RichText::new(chev).size(11.0).color(crate::theme::text_muted(flavor))) - .sense(egui::Sense::click()), + egui::Label::new( + RichText::new(chev) + .size(11.0) + .color(crate::theme::text_muted(flavor)), + ) + .sense(egui::Sense::click()), ) .clicked() { diff --git a/crates/modbussim-ui-shared/src/project.rs b/crates/modbussim-ui-shared/src/project.rs index 9886c5a..56e6176 100644 --- a/crates/modbussim-ui-shared/src/project.rs +++ b/crates/modbussim-ui-shared/src/project.rs @@ -183,7 +183,10 @@ mod tests { let mut p = MasterProject::new(); p.connections.push(MasterConnectionSave { label: "Remote".into(), - tcp: TcpSpec { host: "127.0.0.1".into(), port: 5502 }, + tcp: TcpSpec { + host: "127.0.0.1".into(), + port: 5502, + }, slave_id: 1, timeout_ms: 3000, poll: Some(PollSave { diff --git a/crates/modbussim-ui-shared/src/theme.rs b/crates/modbussim-ui-shared/src/theme.rs index 851e7ae..82f596a 100644 --- a/crates/modbussim-ui-shared/src/theme.rs +++ b/crates/modbussim-ui-shared/src/theme.rs @@ -37,7 +37,11 @@ impl Flavor { } pub fn palette(self) -> catppuccin_egui::Theme { - if self.is_dark() { VSCODE_DARK } else { VSCODE_LIGHT } + if self.is_dark() { + VSCODE_DARK + } else { + VSCODE_LIGHT + } } } @@ -112,30 +116,30 @@ pub const VSCODE_DARK: catppuccin_egui::Theme = catppuccin_egui::Theme { mauve: rgb(157, 121, 209), red: rgb(255, 100, 100), maroon: rgb(169, 46, 34), - peach: rgb(204, 120, 50), // #cc7832 — keyword orange (primary accent) - yellow: rgb(255, 198, 109), // #ffc66d — class / highlight - green: rgb(106, 135, 89), // #6a8759 — string / success + peach: rgb(204, 120, 50), // #cc7832 — keyword orange (primary accent) + yellow: rgb(255, 198, 109), // #ffc66d — class / highlight + green: rgb(106, 135, 89), // #6a8759 — string / success teal: rgb(119, 159, 165), sky: rgb(152, 195, 250), sapphire: rgb(106, 135, 175), - blue: rgb(106, 135, 175), // #6a87af — secondary blue + blue: rgb(106, 135, 175), // #6a87af — secondary blue lavender: rgb(157, 121, 209), // Foreground - text: rgb(220, 223, 228), // #dcdfe4 — brighter than stock Darcula #a9b7c6 + text: rgb(220, 223, 228), // #dcdfe4 — brighter than stock Darcula #a9b7c6 subtext1: rgb(180, 183, 188), - subtext0: rgb(156, 160, 164), // #9ca0a4 — still muted but ≥4.5:1 on #2b2b2b + subtext0: rgb(156, 160, 164), // #9ca0a4 — still muted but ≥4.5:1 on #2b2b2b // Borders / strokes (warm gray) overlay2: rgb(98, 101, 104), - overlay1: rgb(81, 86, 89), // #515659 — separator + overlay1: rgb(81, 86, 89), // #515659 — separator overlay0: rgb(69, 73, 74), // Surfaces — one-step layering surface2: rgb(77, 80, 82), - surface1: rgb(60, 63, 65), // #3c3f41 — side panels - surface0: rgb(49, 51, 53), // #313335 + surface1: rgb(60, 63, 65), // #3c3f41 — side panels + surface0: rgb(49, 51, 53), // #313335 // Backgrounds — Darcula reference values - base: rgb(43, 43, 43), // #2b2b2b — editor bg - mantle: rgb(60, 63, 65), // #3c3f41 — tool windows - crust: rgb(37, 37, 37), // #252525 — darkest + base: rgb(43, 43, 43), // #2b2b2b — editor bg + mantle: rgb(60, 63, 65), // #3c3f41 — tool windows + crust: rgb(37, 37, 37), // #252525 — darkest }; pub const VSCODE_LIGHT: catppuccin_egui::Theme = catppuccin_egui::Theme { @@ -153,20 +157,20 @@ pub const VSCODE_LIGHT: catppuccin_egui::Theme = catppuccin_egui::Theme { teal: rgb(0, 128, 128), sky: rgb(0, 120, 180), sapphire: rgb(0, 90, 180), - blue: rgb(59, 154, 232), // #3b9ae8 — redisant industrial blue + blue: rgb(59, 154, 232), // #3b9ae8 — redisant industrial blue lavender: rgb(94, 68, 172), - text: rgb(51, 51, 51), // #333333 + text: rgb(51, 51, 51), // #333333 subtext1: rgb(102, 102, 102), subtext0: rgb(140, 140, 140), overlay2: rgb(168, 172, 180), overlay1: rgb(192, 196, 204), - overlay0: rgb(208, 208, 208), // #d0d0d0 — card stroke + overlay0: rgb(208, 208, 208), // #d0d0d0 — card stroke surface2: rgb(232, 232, 232), surface1: rgb(240, 240, 240), - surface0: rgb(245, 245, 245), // #f5f5f5 — toolbar - base: rgb(255, 255, 255), // #ffffff — editor - mantle: rgb(245, 245, 245), // #f5f5f5 — side panels - crust: rgb(232, 232, 232), // #e8e8e8 — deepest light + surface0: rgb(245, 245, 245), // #f5f5f5 — toolbar + base: rgb(255, 255, 255), // #ffffff — editor + mantle: rgb(245, 245, 245), // #f5f5f5 — side panels + crust: rgb(232, 232, 232), // #e8e8e8 — deepest light }; /// Apply palette + tight VS Code-ish layout/type defaults. @@ -178,15 +182,15 @@ pub fn apply(ctx: &egui::Context, flavor: Flavor) { // fields ourselves to match the target industrial palette. ctx.style_mut(|s| { if flavor.is_dark() { - let panel = bg_of(flavor, Layer::L1); // #0d1117 - let panel_alt = bg_of(flavor, Layer::L0); // #010409 - let raised = bg_of(flavor, Layer::L2); // #161b22 - let stroke = border_strong(flavor); // #30363d - let stroke_soft = border_subtle(flavor); // #21262d - let fg = text_body(flavor); // #c9d1d9 - let strong_fg = text_primary(flavor); // #e6edf3 - let sel_bg = bg_selected_row(flavor); - let acc = accent(flavor); // #1f6feb + let panel = bg_of(flavor, Layer::L1); // #0d1117 + let panel_alt = bg_of(flavor, Layer::L0); // #010409 + let raised = bg_of(flavor, Layer::L2); // #161b22 + let stroke = border_strong(flavor); // #30363d + let stroke_soft = border_subtle(flavor); // #21262d + let fg = text_body(flavor); // #c9d1d9 + let strong_fg = text_primary(flavor); // #e6edf3 + let sel_bg = bg_selected_row(flavor); + let acc = accent(flavor); // #1f6feb s.visuals.panel_fill = panel; s.visuals.window_fill = panel_alt; s.visuals.extreme_bg_color = panel_alt; @@ -215,15 +219,15 @@ pub fn apply(ctx: &egui::Context, flavor: Flavor) { s.visuals.error_fg_color = danger(flavor); s.visuals.warn_fg_color = warn(flavor); } else { - let panel = bg_of(flavor, Layer::L1); - let _panel_alt = bg_of(flavor, Layer::L0); - let raised = bg_of(flavor, Layer::L2); - let stroke = border_strong(flavor); + let panel = bg_of(flavor, Layer::L1); + let _panel_alt = bg_of(flavor, Layer::L0); + let raised = bg_of(flavor, Layer::L2); + let stroke = border_strong(flavor); let stroke_soft = border_subtle(flavor); - let fg = text_body(flavor); - let strong_fg = text_primary(flavor); - let sel_bg = bg_selected_row(flavor); - let acc = accent(flavor); + let fg = text_body(flavor); + let strong_fg = text_primary(flavor); + let sel_bg = bg_selected_row(flavor); + let acc = accent(flavor); s.visuals.panel_fill = panel; s.visuals.window_fill = raised; s.visuals.extreme_bg_color = raised; @@ -271,56 +275,119 @@ pub fn apply(ctx: &egui::Context, flavor: Flavor) { s.visuals.menu_corner_radius = 6.0.into(); use egui::TextStyle::*; - s.text_styles.insert(Heading, egui::FontId::new(15.0, egui::FontFamily::Proportional)); - s.text_styles.insert(Body, egui::FontId::new(12.5, egui::FontFamily::Proportional)); - s.text_styles.insert(Button, egui::FontId::new(12.0, egui::FontFamily::Proportional)); - s.text_styles.insert(Monospace, egui::FontId::new(12.5, egui::FontFamily::Monospace)); - s.text_styles.insert(Small, egui::FontId::new(10.5, egui::FontFamily::Proportional)); + s.text_styles.insert( + Heading, + egui::FontId::new(15.0, egui::FontFamily::Proportional), + ); + s.text_styles.insert( + Body, + egui::FontId::new(12.5, egui::FontFamily::Proportional), + ); + s.text_styles.insert( + Button, + egui::FontId::new(12.0, egui::FontFamily::Proportional), + ); + s.text_styles.insert( + Monospace, + egui::FontId::new(12.5, egui::FontFamily::Monospace), + ); + s.text_styles.insert( + Small, + egui::FontId::new(10.5, egui::FontFamily::Proportional), + ); }); } // --- Semantic color helpers used by app code --- pub fn accent(flavor: Flavor) -> Color32 { - if flavor.is_dark() { rgb(0x1f, 0x6f, 0xeb) } else { rgb(0x25, 0x63, 0xeb) } + if flavor.is_dark() { + rgb(0x1f, 0x6f, 0xeb) + } else { + rgb(0x25, 0x63, 0xeb) + } } pub fn accent_fg(flavor: Flavor) -> Color32 { - if flavor.is_dark() { rgb(0x58, 0xa6, 0xff) } else { rgb(0x3b, 0x82, 0xf6) } + if flavor.is_dark() { + rgb(0x58, 0xa6, 0xff) + } else { + rgb(0x3b, 0x82, 0xf6) + } } pub fn success(flavor: Flavor) -> Color32 { - if flavor.is_dark() { rgb(0x3f, 0xb9, 0x50) } else { rgb(0x15, 0x80, 0x3d) } + if flavor.is_dark() { + rgb(0x3f, 0xb9, 0x50) + } else { + rgb(0x15, 0x80, 0x3d) + } } pub fn warn(flavor: Flavor) -> Color32 { - if flavor.is_dark() { rgb(0xf0, 0x88, 0x3e) } else { rgb(0xc2, 0x41, 0x0c) } + if flavor.is_dark() { + rgb(0xf0, 0x88, 0x3e) + } else { + rgb(0xc2, 0x41, 0x0c) + } } pub fn danger(flavor: Flavor) -> Color32 { - if flavor.is_dark() { rgb(0xf8, 0x51, 0x49) } else { rgb(0xb9, 0x1c, 0x1c) } + if flavor.is_dark() { + rgb(0xf8, 0x51, 0x49) + } else { + rgb(0xb9, 0x1c, 0x1c) + } } pub fn alias(flavor: Flavor) -> Color32 { - if flavor.is_dark() { rgb(0xd2, 0xa8, 0xff) } else { rgb(0x7c, 0x3a, 0xed) } + if flavor.is_dark() { + rgb(0xd2, 0xa8, 0xff) + } else { + rgb(0x7c, 0x3a, 0xed) + } } pub fn border_subtle(flavor: Flavor) -> Color32 { - if flavor.is_dark() { rgb(0x21, 0x26, 0x2d) } else { rgb(0xe4, 0xe4, 0xe7) } + if flavor.is_dark() { + rgb(0x21, 0x26, 0x2d) + } else { + rgb(0xe4, 0xe4, 0xe7) + } } pub fn border_strong(flavor: Flavor) -> Color32 { - if flavor.is_dark() { rgb(0x30, 0x36, 0x3d) } else { rgb(0xd4, 0xd4, 0xd8) } + if flavor.is_dark() { + rgb(0x30, 0x36, 0x3d) + } else { + rgb(0xd4, 0xd4, 0xd8) + } } pub fn text_primary(flavor: Flavor) -> Color32 { - if flavor.is_dark() { rgb(0xe6, 0xed, 0xf3) } else { rgb(0x09, 0x09, 0x0b) } + if flavor.is_dark() { + rgb(0xe6, 0xed, 0xf3) + } else { + rgb(0x09, 0x09, 0x0b) + } } pub fn text_body(flavor: Flavor) -> Color32 { - if flavor.is_dark() { rgb(0xc9, 0xd1, 0xd9) } else { rgb(0x3f, 0x3f, 0x46) } + if flavor.is_dark() { + rgb(0xc9, 0xd1, 0xd9) + } else { + rgb(0x3f, 0x3f, 0x46) + } } pub fn text_muted(flavor: Flavor) -> Color32 { - if flavor.is_dark() { rgb(0x6e, 0x76, 0x81) } else { rgb(0x71, 0x71, 0x7a) } + if flavor.is_dark() { + rgb(0x6e, 0x76, 0x81) + } else { + rgb(0x71, 0x71, 0x7a) + } } -pub fn subtext(flavor: Flavor) -> Color32 { text_muted(flavor) } // 旧调用点回退 -pub fn surface(flavor: Flavor) -> Color32 { bg_of(flavor, Layer::L2) } // 旧调用点回退 +pub fn subtext(flavor: Flavor) -> Color32 { + text_muted(flavor) +} // 旧调用点回退 +pub fn surface(flavor: Flavor) -> Color32 { + bg_of(flavor, Layer::L2) +} // 旧调用点回退 /// 文本渲染辅助:tiny_caps / crumb 等语义文本样式。 pub mod text { - use super::{Flavor, text_muted, accent_fg}; - use egui::{Ui, RichText}; + use super::{accent_fg, text_muted, Flavor}; + use egui::{RichText, Ui}; /// 表头 / 分组标题用:10.5px 大写、字距感由空格 + 字色弱化体现。 pub fn tiny_caps(ui: &mut Ui, flavor: Flavor, s: &str) { diff --git a/crates/modbussim-ui-shared/src/ui.rs b/crates/modbussim-ui-shared/src/ui.rs index f148a64..dc11bce 100644 --- a/crates/modbussim-ui-shared/src/ui.rs +++ b/crates/modbussim-ui-shared/src/ui.rs @@ -12,7 +12,10 @@ use crate::theme::{self, Flavor, Layer}; fn card_colors(flavor: Flavor) -> (Color32, Color32) { // Industrial HMI: raised L2 background + subtle border. Same look in both // flavors — token routing handles dark/light. - (theme::bg_of(flavor, Layer::L2), theme::border_subtle(flavor)) + ( + theme::bg_of(flavor, Layer::L2), + theme::border_subtle(flavor), + ) } /// Flat panel with raised bg + subtle border. Used for grouped content. @@ -46,11 +49,7 @@ pub fn region( /// Same as `card`, plus a 2 px accent line along the top edge. Used for the /// current-context header (e.g. "FC04 Input Registers — slave_1"). -pub fn accent_card( - ui: &mut Ui, - flavor: Flavor, - add: impl FnOnce(&mut Ui) -> R, -) -> R { +pub fn accent_card(ui: &mut Ui, flavor: Flavor, add: impl FnOnce(&mut Ui) -> R) -> R { let accent = crate::theme::accent(flavor); let (fill, stroke_color) = card_colors(flavor); let resp = egui::Frame::new() @@ -66,10 +65,8 @@ pub fn accent_card( .show(ui, add); // Paint a 2 px accent stripe across the top. let rect = resp.response.rect; - let stripe = egui::Rect::from_min_max( - rect.left_top(), - egui::pos2(rect.right(), rect.top() + 2.0), - ); + let stripe = + egui::Rect::from_min_max(rect.left_top(), egui::pos2(rect.right(), rect.top() + 2.0)); ui.painter().rect_filled(stripe, 0.0, accent); resp.inner } @@ -88,19 +85,19 @@ fn shadcn_theme(flavor: Flavor) -> egui_shadcn::Theme { }; if flavor.is_dark() { // Industrial HMI: cool blue primary + green action accent + L1/L2 bg - palette.primary = Color32::from_rgb(0x3f, 0xb9, 0x50); // 主操作绿("+ 批量添加") + palette.primary = Color32::from_rgb(0x3f, 0xb9, 0x50); // 主操作绿("+ 批量添加") palette.primary_foreground = Color32::WHITE; palette.destructive = Color32::from_rgb(0xf8, 0x51, 0x49); palette.destructive_foreground = Color32::WHITE; - palette.ring = Color32::from_rgb(0x1f, 0x6f, 0xeb); // focus 蓝 + palette.ring = Color32::from_rgb(0x1f, 0x6f, 0xeb); // focus 蓝 palette.border = Color32::from_rgb(0x30, 0x36, 0x3d); palette.background = Color32::from_rgb(0x0d, 0x11, 0x17); palette.foreground = Color32::from_rgb(0xc9, 0xd1, 0xd9); palette.muted_foreground = Color32::from_rgb(0x6e, 0x76, 0x81); - palette.accent = Color32::from_rgb(0x1f, 0x6f, 0xeb); // 蓝 accent(链接/选中) + palette.accent = Color32::from_rgb(0x1f, 0x6f, 0xeb); // 蓝 accent(链接/选中) palette.accent_foreground = Color32::WHITE; } else { - palette.primary = Color32::from_rgb(0x15, 0x80, 0x3d); // 浅色主操作深绿 + palette.primary = Color32::from_rgb(0x15, 0x80, 0x3d); // 浅色主操作深绿 palette.primary_foreground = Color32::WHITE; palette.destructive = Color32::from_rgb(0xb9, 0x1c, 0x1c); palette.destructive_foreground = Color32::WHITE; @@ -246,8 +243,7 @@ pub fn panel_header(ui: &mut Ui, flavor: Flavor, title: &str, crumb: Option<&str pub fn link_action(ui: &mut Ui, flavor: Flavor, label: &str, danger: bool) -> Response { let base = theme::text_muted(flavor); let resp = ui.add( - egui::Label::new(RichText::new(label).color(base).size(11.5)) - .sense(egui::Sense::click()), + egui::Label::new(RichText::new(label).color(base).size(11.5)).sense(egui::Sense::click()), ); if resp.hovered() { let hover = if danger { diff --git a/crates/modbussim-ui-shared/src/value_panel.rs b/crates/modbussim-ui-shared/src/value_panel.rs index 9ac1742..a860986 100644 --- a/crates/modbussim-ui-shared/src/value_panel.rs +++ b/crates/modbussim-ui-shared/src/value_panel.rs @@ -97,11 +97,7 @@ pub fn render( } 2 => { let base = base_addr.unwrap_or(0); - crate::ui::caption( - ui, - flavor, - format!("地址 {}..{} · 2 words", base, base + 1), - ); + crate::ui::caption(ui, flavor, format!("地址 {}..{} · 2 words", base, base + 1)); render_double(ui, base, [values[0], values[1]]) } 3 => { @@ -116,11 +112,7 @@ pub fn render( } _ => { let base = base_addr.unwrap_or(0); - crate::ui::caption( - ui, - flavor, - format!("地址 {}..{} · 4 words", base, base + 3), - ); + crate::ui::caption(ui, flavor, format!("地址 {}..{} · 4 words", base, base + 3)); let w1 = render_double(ui, base, [values[0], values[1]]); ui.add_space(4.0); ui.separator(); @@ -187,7 +179,9 @@ fn edit_cell( let mut result = None; let commit = resp.lost_focus() - && ui.ctx().input(|i| i.key_pressed(Key::Enter) || !i.pointer.any_pressed()) + && ui + .ctx() + .input(|i| i.key_pressed(Key::Enter) || !i.pointer.any_pressed()) || (has_focus && ui.ctx().input(|i| i.key_pressed(Key::Enter))); if commit && !buf.text.is_empty() { if let Some(writes) = parse_fn(buf.text.trim()) { @@ -212,17 +206,13 @@ fn render_single(ui: &mut egui::Ui, addr: u16, v: u16) -> Option ui.label("Unsigned"); out = combine( out.take(), - edit_cell( - ui, - Id::new(("vp_u16", addr)), - v.to_string(), - h, - move |s| { - let n: u32 = s.parse().ok()?; - if n > u16::MAX as u32 { return None; } - Some(vec![(addr, n as u16)]) - }, - ), + edit_cell(ui, Id::new(("vp_u16", addr)), v.to_string(), h, move |s| { + let n: u32 = s.parse().ok()?; + if n > u16::MAX as u32 { + return None; + } + Some(vec![(addr, n as u16)]) + }), ); ui.end_row(); @@ -236,7 +226,9 @@ fn render_single(ui: &mut egui::Ui, addr: u16, v: u16) -> Option h, move |s| { let n: i32 = s.parse().ok()?; - if n < i16::MIN as i32 || n > i16::MAX as i32 { return None; } + if n < i16::MIN as i32 || n > i16::MAX as i32 { + return None; + } Some(vec![(addr, n as i16 as u16)]) }, ), @@ -265,17 +257,11 @@ fn render_single(ui: &mut egui::Ui, addr: u16, v: u16) -> Option let display = format!("{} {} {} {}", &b[0..4], &b[4..8], &b[8..12], &b[12..16]); out = combine( out.take(), - edit_cell( - ui, - Id::new(("vp_bin", addr)), - display, - h, - move |s| { - let cleaned: String = s.chars().filter(|c| !c.is_whitespace()).collect(); - let n = u16::from_str_radix(&cleaned, 2).ok()?; - Some(vec![(addr, n)]) - }, - ), + edit_cell(ui, Id::new(("vp_bin", addr)), display, h, move |s| { + let cleaned: String = s.chars().filter(|c| !c.is_whitespace()).collect(); + let n = u16::from_str_radix(&cleaned, 2).ok()?; + Some(vec![(addr, n)]) + }), ); ui.end_row(); }); @@ -284,11 +270,7 @@ fn render_single(ui: &mut egui::Ui, addr: u16, v: u16) -> Option // --- Double-word formats (U32 / I32 / F32 × 4 endians) --- -fn render_double( - ui: &mut egui::Ui, - base: u16, - words: [u16; 2], -) -> Option> { +fn render_double(ui: &mut egui::Ui, base: u16, words: [u16; 2]) -> Option> { let h = addr_hash(base, &words); let mut out: Option> = None; egui::Grid::new("vp_double") @@ -315,7 +297,9 @@ fn render_double( h, move |s| { let n: u64 = s.parse().ok()?; - if n > u32::MAX as u64 { return None; } + if n > u32::MAX as u64 { + return None; + } let pair = encode_u32(n as u32, e); Some(vec![(base, pair[0]), (base + 1, pair[1])]) }, @@ -361,11 +345,7 @@ fn render_double( // --- Quad-word (Float64) --- -fn render_quad( - ui: &mut egui::Ui, - base: u16, - words: [u16; 4], -) -> Option> { +fn render_quad(ui: &mut egui::Ui, base: u16, words: [u16; 4]) -> Option> { let h = addr_hash(base, &words); let mut out: Option> = None; ui.label(RichText::new("Double (64-bit)").strong()); @@ -384,22 +364,16 @@ fn render_quad( }; out = combine( out.take(), - edit_cell( - ui, - Id::new(("vp_f64", base, label)), - display, - h, - move |s| { - let f: f64 = s.parse().ok()?; - let w = encode_f64(f, order); - Some(vec![ - (base, w[0]), - (base + 1, w[1]), - (base + 2, w[2]), - (base + 3, w[3]), - ]) - }, - ), + edit_cell(ui, Id::new(("vp_f64", base, label)), display, h, move |s| { + let f: f64 = s.parse().ok()?; + let w = encode_f64(f, order); + Some(vec![ + (base, w[0]), + (base + 1, w[1]), + (base + 2, w[2]), + (base + 3, w[3]), + ]) + }), ); ui.end_row(); } @@ -490,8 +464,7 @@ mod tests { let pair = encode_u32(0x41C80000, Endian::Little); assert_eq!(pair, [0x0000, 0x41C8]); // And decoding back under Little yields 0x41C80000. - let decoded = - decode_value(&[pair[0], pair[1]], DataType::Float32, Endian::Little).unwrap(); + let decoded = decode_value(&[pair[0], pair[1]], DataType::Float32, Endian::Little).unwrap(); assert!((decoded - 25.0).abs() < 1e-6); } } From 2293044260a13bf4dcae0137ccaf56d55b9d8676 Mon Sep 17 00:00:00 2001 From: kelsoprotein-lab Date: Tue, 21 Apr 2026 00:50:17 +0800 Subject: [PATCH 12/25] =?UTF-8?q?chore(slave-app):=20Frame::none=20?= =?UTF-8?q?=E2=86=92=20Frame::new=20(egui=200.33=20deprecated)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/modbussim-egui/src/app.rs | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/crates/modbussim-egui/src/app.rs b/crates/modbussim-egui/src/app.rs index 01702d8..b70827c 100644 --- a/crates/modbussim-egui/src/app.rs +++ b/crates/modbussim-egui/src/app.rs @@ -3118,6 +3118,14 @@ impl eframe::App for SlaveApp { self.selected_addrs.clear(); self.click_anchor = None; } + if i.consume_key(egui::Modifiers::NONE, egui::Key::Slash) + && matches!( + self.selection, + Selection::RegisterGroup { .. } + ) + { + self.want_focus_search = true; + } }); } @@ -3343,7 +3351,7 @@ impl eframe::App for SlaveApp { .exact_height(22.0) .show_separator_line(false) .frame( - egui::Frame::none() + egui::Frame::new() .fill(theme::bg_of(flavor, theme::Layer::L0)) .inner_margin(egui::Margin::symmetric(14.0 as i8, 4.0 as i8)), ) @@ -3407,7 +3415,7 @@ impl eframe::App for SlaveApp { egui::CentralPanel::default() .frame( - egui::Frame::none() + egui::Frame::new() .fill(theme::bg_of(self.flavor, theme::Layer::L1)) .inner_margin(egui::Margin::symmetric(14.0 as i8, 10.0 as i8)), ) From add41d9ef74e0782aa757c7615438b870b68904b Mon Sep 17 00:00:00 2001 From: kelsoprotein-lab Date: Tue, 21 Apr 2026 00:54:08 +0800 Subject: [PATCH 13/25] =?UTF-8?q?feat(slave-app):=20fmt-pill=20+=20?= =?UTF-8?q?=E5=AF=84=E5=AD=98=E5=99=A8=E8=A1=A8=E6=A0=BC=E8=89=B2=E5=BD=A9?= =?UTF-8?q?=E8=AF=AD=E4=B9=89=E5=8C=96=20+=20=E8=A1=A8=E5=A4=B4=E4=B8=8B?= =?UTF-8?q?=E5=88=92=E7=BA=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Step 5.2: 将 ComboBox 包裹进 pill 样式 Frame(L2 背景 + border_strong 边框 + corner_radius 12 + accent_fg 文字),旁加 tiny_caps "格式" 标签; 工具栏右侧"清零选中"link_action;共N行/已选N行 crumb - Step 5.3: 地址列 text_muted monospace;HEX 列 warn(橙) monospace; Binary 列 text_muted size11 monospace;多字模式地址列同步 text_muted - Step 5.4: 每个 TableBuilder 的尾 header col 底边画 2px accent 蓝线, 覆盖 x=[-8000..8000] 以贯穿全行(bool 和 u16 两种布局均处理) - 修复 Frame::none() → Frame::new() (2处 status_bar / CentralPanel) - 修复 SelectableLabel::new → Button::selectable (2处) - 修复 menu::bar → MenuBar::new().ui - 修复 close_menu → close_kind(UiKind::Menu) (4处) - 修复 needless_borrows in set_file_name Co-Authored-By: Claude Sonnet 4.6 --- crates/modbussim-egui/src/app.rs | 187 +++++++++++++++++++++++++------ 1 file changed, 151 insertions(+), 36 deletions(-) diff --git a/crates/modbussim-egui/src/app.rs b/crates/modbussim-egui/src/app.rs index b70827c..5f69049 100644 --- a/crates/modbussim-egui/src/app.rs +++ b/crates/modbussim-egui/src/app.rs @@ -1327,7 +1327,7 @@ impl SlaveApp { .unwrap_or(0); self.rt.spawn(async move { let Some(path) = rfd::AsyncFileDialog::new() - .set_file_name(&format!("slave_{}.modbusproj", ts)) + .set_file_name(format!("slave_{}.modbusproj", ts)) .add_filter("ModbusProj", &["modbusproj"]) .save_file() .await @@ -2474,22 +2474,70 @@ impl SlaveApp { self.reg_display_mode }; + // ── fmt-pill 工具栏 ────────────────────────────────────────── ui.horizontal(|ui| { - ui.label(format!("共 {} 行", view.row_count)); if !is_bool { - ui.separator(); - ui.label("格式"); - egui::ComboBox::from_id_salt("reg_display_mode") - .selected_text(mode.label()) - .show_ui(ui, |ui| { - for m in DISPLAY_MODES { - ui.selectable_value(&mut self.reg_display_mode, *m, m.label()); - } + theme::text::tiny_caps(ui, flavor, "格式"); + ui.add_space(4.0); + egui::Frame::new() + .fill(theme::bg_of(flavor, theme::Layer::L2)) + .stroke(egui::Stroke::new( + 1.0, + theme::border_strong(flavor), + )) + .corner_radius(12.0) + .inner_margin(egui::Margin::symmetric(8, 2)) + .show(ui, |ui| { + egui::ComboBox::from_id_salt("reg_display_mode") + .selected_text( + egui::RichText::new(mode.label()) + .color(theme::accent_fg(flavor)) + .monospace() + .size(11.5), + ) + .show_ui(ui, |ui| { + for m in DISPLAY_MODES { + ui.selectable_value( + &mut self.reg_display_mode, + *m, + m.label(), + ); + } + }); }); + ui.add_space(12.0); + } + theme::text::crumb( + ui, + flavor, + &format!( + "共 {} 行{}", + view.row_count, + if self.selected_addrs.is_empty() { + String::new() + } else { + format!(" · 已选 {} 行", self.selected_addrs.len()) + } + ), + ); + if !is_bool { + ui.with_layout( + egui::Layout::right_to_left(egui::Align::Center), + |ui| { + if uikit::link_action(ui, flavor, "清零选中", false).clicked() { + self.selected_addrs.clear(); + self.click_anchor = None; + } + }, + ); } }); if !is_bool && mode.is_multi_word() { - ui.label("多字格式 · 只读显示;要编辑请切回 U16"); + theme::text::crumb( + ui, + flavor, + "多字格式 · 只读显示;要编辑请切回 U16", + ); } let row_h = 20.0; @@ -2570,7 +2618,21 @@ impl SlaveApp { h.col(|ui| { theme::text::tiny_caps(ui, flavor, "Raw HEX") }); - h.col(|_| {}); + h.col(|ui| { + // 表头下方 2px 蓝线 + let r = ui.max_rect(); + let y = r.max.y - 1.0; + ui.painter().line_segment( + [ + egui::pos2(-8000.0, y), + egui::pos2(8000.0, y), + ], + egui::Stroke::new( + 2.0, + theme::accent(flavor), + ), + ); + }); }) .body(|body| { body.rows(row_h, group_rows, |mut row| { @@ -2599,12 +2661,16 @@ impl SlaveApp { } else { format!("{}..{}", base, base + 1) }; - let resp = - ui.add(egui::SelectableLabel::new( + let resp = ui.add( + egui::Button::selectable( sel, egui::RichText::new(label) - .monospace(), - )); + .monospace() + .color(theme::text_muted( + flavor, + )), + ), + ); if resp.clicked() { row_clicks.push(( base, @@ -2669,7 +2735,11 @@ impl SlaveApp { .map(|w| format!("{:04X}", w)) .collect::>() .join(" "); - ui.monospace(joined); + ui.add(egui::Label::new( + egui::RichText::new(joined) + .monospace() + .color(theme::warn(flavor)), + )); }); row.col(|_| {}); }); @@ -2804,7 +2874,16 @@ impl SlaveApp { theme::text::tiny_caps(ui, flavor, "名称") }); header.col(|ui| { - theme::text::tiny_caps(ui, flavor, "注释") + theme::text::tiny_caps(ui, flavor, "注释"); + // 表头下方 2px 蓝线(跨整行,画在最后一列底边) + let r = ui.max_rect(); + let y = r.max.y - 1.0; + let x0 = -8000.0_f32; // 超出左界以覆盖全行 + let x1 = 8000.0_f32; + ui.painter().line_segment( + [egui::pos2(x0, y), egui::pos2(x1, y)], + egui::Stroke::new(2.0, theme::accent(flavor)), + ); }); } else { header.col(|ui| { @@ -2816,7 +2895,17 @@ impl SlaveApp { header.col(|ui| { theme::text::tiny_caps(ui, flavor, "二进制") }); - header.col(|_| {}); + header.col(|ui| { + // 表头下方 2px 蓝线(画在尾列底边,覆盖全行) + let r = ui.max_rect(); + let y = r.max.y - 1.0; + let x0 = -8000.0_f32; + let x1 = 8000.0_f32; + ui.painter().line_segment( + [egui::pos2(x0, y), egui::pos2(x1, y)], + egui::Stroke::new(2.0, theme::accent(flavor)), + ); + }); } }) .body(|body| { @@ -2831,11 +2920,14 @@ impl SlaveApp { } row.col(|ui| { let sel = selected_addrs.contains(&addr); - let resp = ui.add(egui::SelectableLabel::new( - sel, - egui::RichText::new(format!("{}", addr)) - .monospace(), - )); + let resp = ui.add( + egui::Button::selectable( + sel, + egui::RichText::new(format!("{}", addr)) + .monospace() + .color(theme::text_muted(flavor)), + ), + ); if resp.clicked() { row_clicks.push(( addr, @@ -2953,15 +3045,24 @@ impl SlaveApp { }) .unwrap_or(cache_u16); row.col(|ui| { - ui.monospace(format_u16( - display_u16, - U16Format::Hex, + ui.add(egui::Label::new( + egui::RichText::new(format_u16( + display_u16, + U16Format::Hex, + )) + .monospace() + .color(theme::warn(flavor)), )); }); row.col(|ui| { - ui.monospace(format_u16( - display_u16, - U16Format::Binary, + ui.add(egui::Label::new( + egui::RichText::new(format_u16( + display_u16, + U16Format::Binary, + )) + .monospace() + .size(11.0) + .color(theme::text_muted(flavor)), )); }); row.col(|_| {}); @@ -3141,23 +3242,37 @@ impl eframe::App for SlaveApp { let mut do_save = false; let mut do_load = false; egui::TopBottomPanel::top("menu_bar").show(ctx, |ui| { - egui::menu::bar(ui, |ui| { + egui::MenuBar::new().ui(ui, |ui| { ui.menu_button("文件", |ui| { if ui.button("保存工程…").clicked() { do_save = true; - ui.close_menu(); + ui.close_kind(egui::UiKind::Menu); } if ui.button("加载工程…").clicked() { do_load = true; - ui.close_menu(); + ui.close_kind(egui::UiKind::Menu); } }); ui.menu_button("视图", |ui| { + ui.checkbox(&mut self.value_parse_open, "显示值解析 (V)"); if ui - .checkbox(&mut self.log_state.open, "显示日志面板") + .checkbox(&mut self.log_state.open, "显示通信日志") .clicked() { - ui.close_menu(); + if !self.log_state.open { + self.log_state.collapsed = false; + } + ui.close_kind(egui::UiKind::Menu); + } + ui.separator(); + if ui.button("浅色 / 深色切换").clicked() { + self.flavor = if self.flavor.is_dark() { + Flavor::Latte + } else { + Flavor::Mocha + }; + theme::apply(ctx, self.flavor); + ui.close_kind(egui::UiKind::Menu); } ui.separator(); ui.label("主题 (Catppuccin)"); @@ -3169,7 +3284,7 @@ impl eframe::App for SlaveApp { ] { if ui.radio_value(&mut self.flavor, f, f.label()).clicked() { theme::apply(ctx, self.flavor); - ui.close_menu(); + ui.close_kind(egui::UiKind::Menu); } } ui.separator(); From b760c9df50ba6c8351d6d67dfd80814c0501f980 Mon Sep 17 00:00:00 2001 From: kelsoprotein-lab Date: Tue, 21 Apr 2026 01:28:52 +0800 Subject: [PATCH 14/25] =?UTF-8?q?style:=20cargo=20fmt=20--all=20=E6=95=B4?= =?UTF-8?q?=E4=BD=93=E8=A7=84=E6=95=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/modbussim-egui/src/app.rs | 62 ++++++++++++++------------------ 1 file changed, 26 insertions(+), 36 deletions(-) diff --git a/crates/modbussim-egui/src/app.rs b/crates/modbussim-egui/src/app.rs index 5f69049..6f89b83 100644 --- a/crates/modbussim-egui/src/app.rs +++ b/crates/modbussim-egui/src/app.rs @@ -2481,10 +2481,7 @@ impl SlaveApp { ui.add_space(4.0); egui::Frame::new() .fill(theme::bg_of(flavor, theme::Layer::L2)) - .stroke(egui::Stroke::new( - 1.0, - theme::border_strong(flavor), - )) + .stroke(egui::Stroke::new(1.0, theme::border_strong(flavor))) .corner_radius(12.0) .inner_margin(egui::Margin::symmetric(8, 2)) .show(ui, |ui| { @@ -2521,23 +2518,16 @@ impl SlaveApp { ), ); if !is_bool { - ui.with_layout( - egui::Layout::right_to_left(egui::Align::Center), - |ui| { - if uikit::link_action(ui, flavor, "清零选中", false).clicked() { - self.selected_addrs.clear(); - self.click_anchor = None; - } - }, - ); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if uikit::link_action(ui, flavor, "清零选中", false).clicked() { + self.selected_addrs.clear(); + self.click_anchor = None; + } + }); } }); if !is_bool && mode.is_multi_word() { - theme::text::crumb( - ui, - flavor, - "多字格式 · 只读显示;要编辑请切回 U16", - ); + theme::text::crumb(ui, flavor, "多字格式 · 只读显示;要编辑请切回 U16"); } let row_h = 20.0; @@ -2661,16 +2651,15 @@ impl SlaveApp { } else { format!("{}..{}", base, base + 1) }; - let resp = ui.add( - egui::Button::selectable( + let resp = + ui.add(egui::Button::selectable( sel, egui::RichText::new(label) .monospace() .color(theme::text_muted( flavor, )), - ), - ); + )); if resp.clicked() { row_clicks.push(( base, @@ -2882,7 +2871,10 @@ impl SlaveApp { let x1 = 8000.0_f32; ui.painter().line_segment( [egui::pos2(x0, y), egui::pos2(x1, y)], - egui::Stroke::new(2.0, theme::accent(flavor)), + egui::Stroke::new( + 2.0, + theme::accent(flavor), + ), ); }); } else { @@ -2903,7 +2895,10 @@ impl SlaveApp { let x1 = 8000.0_f32; ui.painter().line_segment( [egui::pos2(x0, y), egui::pos2(x1, y)], - egui::Stroke::new(2.0, theme::accent(flavor)), + egui::Stroke::new( + 2.0, + theme::accent(flavor), + ), ); }); } @@ -2920,14 +2915,12 @@ impl SlaveApp { } row.col(|ui| { let sel = selected_addrs.contains(&addr); - let resp = ui.add( - egui::Button::selectable( - sel, - egui::RichText::new(format!("{}", addr)) - .monospace() - .color(theme::text_muted(flavor)), - ), - ); + let resp = ui.add(egui::Button::selectable( + sel, + egui::RichText::new(format!("{}", addr)) + .monospace() + .color(theme::text_muted(flavor)), + )); if resp.clicked() { row_clicks.push(( addr, @@ -3220,10 +3213,7 @@ impl eframe::App for SlaveApp { self.click_anchor = None; } if i.consume_key(egui::Modifiers::NONE, egui::Key::Slash) - && matches!( - self.selection, - Selection::RegisterGroup { .. } - ) + && matches!(self.selection, Selection::RegisterGroup { .. }) { self.want_focus_search = true; } From 3ee53f3320d5a669ba7095dd2ddcb3488eae959f Mon Sep 17 00:00:00 2001 From: kelsoprotein-lab Date: Tue, 21 Apr 2026 09:03:02 +0800 Subject: [PATCH 15/25] =?UTF-8?q?feat(slave-ui):=20=E5=B9=B3=E8=A1=A1?= =?UTF-8?q?=E6=8C=89=E9=92=AE=E5=B1=82=E7=BA=A7=20=E2=80=94=20=E6=96=B0?= =?UTF-8?q?=E5=BB=BA/=E6=89=B9=E9=87=8F=E6=B7=BB=E5=8A=A0=E5=90=8C?= =?UTF-8?q?=E7=BA=A7=20Outline=20Sm?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 修因:「+ 新建」是全局连接入口但用 link_action 灰字埋没, 「+ 批量添加」是局部操作但用 primary_button Md 绿色喧宾夺主。 两个按钮的视觉重量与功能层级颠倒。 改动:新增 ui::secondary_button_sm(shadcn Outline + Sm size); 左侧栏「+ 新建」与主区/设备摘要两处「+ 批量添加」全部切换。 两侧同级、轻量 outline、不再有"被埋"或"过大"的反差。 Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/modbussim-egui/src/app.rs | 6 +++--- crates/modbussim-ui-shared/src/ui.rs | 15 +++++++++++++++ 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/crates/modbussim-egui/src/app.rs b/crates/modbussim-egui/src/app.rs index 6f89b83..bf2761c 100644 --- a/crates/modbussim-egui/src/app.rs +++ b/crates/modbussim-egui/src/app.rs @@ -2163,7 +2163,7 @@ impl SlaveApp { ui.add_space(8.0); let flavor = self.flavor; ui.horizontal(|ui| { - if uikit::primary_button( + if uikit::secondary_button_sm( ui, flavor, format!("{} 批量添加", icons::PLUS_CIRCLE), @@ -2396,7 +2396,7 @@ impl SlaveApp { ui.with_layout( egui::Layout::right_to_left(egui::Align::Center), |ui| { - if uikit::primary_button( + if uikit::secondary_button_sm( ui, flavor, format!("{} 批量添加", icons::PLUS_CIRCLE), @@ -3327,7 +3327,7 @@ impl eframe::App for SlaveApp { ui.with_layout( egui::Layout::right_to_left(egui::Align::Center), |ui| { - if uikit::link_action(ui, self.flavor, "+ 新建", false) + if uikit::secondary_button_sm(ui, self.flavor, "+ 新建") .clicked() { self.show_new_tcp_dialog = diff --git a/crates/modbussim-ui-shared/src/ui.rs b/crates/modbussim-ui-shared/src/ui.rs index dc11bce..a5f64f9 100644 --- a/crates/modbussim-ui-shared/src/ui.rs +++ b/crates/modbussim-ui-shared/src/ui.rs @@ -151,6 +151,21 @@ pub fn danger_button(ui: &mut Ui, flavor: Flavor, text: impl Into) -> Re ) } +/// Compact secondary button: shadcn Outline variant + Sm size. +/// Use for tertiary actions where Md feels visually heavy ("+ 新建" / +/// "+ 批量添加" / "导出 CSV"), but a borderless `link_action` lacks visual weight. +pub fn secondary_button_sm(ui: &mut Ui, flavor: Flavor, text: impl Into) -> Response { + let theme = shadcn_theme(flavor); + egui_shadcn::button( + ui, + &theme, + text.into(), + egui_shadcn::tokens::ControlVariant::Outline, + egui_shadcn::tokens::ControlSize::Sm, + true, + ) +} + /// Icon-only button: shadcn Ghost variant + small size. pub fn icon_button(ui: &mut Ui, flavor: Flavor, icon: &str) -> Response { let theme = shadcn_theme(flavor); From 0314dd150ccaa01a682f76f9d63516b8db1c383a Mon Sep 17 00:00:00 2001 From: kelsoprotein-lab Date: Tue, 21 Apr 2026 10:33:53 +0800 Subject: [PATCH 16/25] =?UTF-8?q?docs(slave-ui):=20=E8=BF=9E=E6=8E=A5?= =?UTF-8?q?=E7=8A=B6=E6=80=81=E5=8F=8D=E9=A6=88=E4=B8=8E=E6=8C=89=E9=92=AE?= =?UTF-8?q?=E5=88=86=E5=B7=A5=20spec?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.7 (1M context) --- .../2026-04-21-conn-status-feedback-design.md | 199 ++++++++++++++++++ 1 file changed, 199 insertions(+) create mode 100644 docs/superpowers/specs/2026-04-21-conn-status-feedback-design.md diff --git a/docs/superpowers/specs/2026-04-21-conn-status-feedback-design.md b/docs/superpowers/specs/2026-04-21-conn-status-feedback-design.md new file mode 100644 index 0000000..21a2405 --- /dev/null +++ b/docs/superpowers/specs/2026-04-21-conn-status-feedback-design.md @@ -0,0 +1,199 @@ +# 连接状态反馈与按钮分工 · Spec + +## Context + +子站 SidePanel 重设计落地后(PR #2),用户反馈两个剩余问题: + +1. **左下角 footer 的「启动 / 删除连接」按钮不明显** —— 当前用 `link_action`(无边框灰字),视觉重量极低 +2. **启动/停止操作没有明显的前端感知** —— 连接状态仅由树节点 label 末尾的文本 tag `[运行中]/[已停止]` 表示,无颜色/图标/动画。用户切换状态后除文字 tag 变化外没有反馈 + +底层还有一个隐藏问题:操作入口在两处冗余 —— 树节点内(`render_tree` line 1704-1721)已用 `ui.small_button` 渲染"启动/停止/删除"三连按钮,footer(line 3418-3441)又渲染了一遍。两套并存且都不够明显。 + +目标:重新分工两处入口(高频/低频)、加强连接运行态的整体视觉感知(圆点 + 整行染色 + 脉动)、给 destructive 操作加二次确认避免误删,全部基于已落地的 token 系统(success / danger / text_muted / accent)。 + +--- + +## 现状关键代码定位 + +| 区域 | 文件 / 行 | 当前形态 | +|---|---|---| +| 树节点连接行渲染 | `crates/modbussim-egui/src/app.rs` 1665-1701 | 文本 label `"{} [{}]"` 只染主题色,tag 同色无区分 | +| 树节点内启动/停止/删除 三连 | `app.rs` 1704-1721 | `ui.small_button` 三按钮平铺缩进 18px | +| SidePanel footer 启动/停止/删除 | `app.rs` 3418-3441 | `link_action` 灰字,依赖 hover 才变色 | +| 底部状态栏 ● 与文案 | `app.rs` 3454+(`status_bar` BottomPanel) | 静态 ● success / 文案 `就绪` | +| 状态枚举 | `app.rs`(`ConnectionState::Running` / `Stopped`) | 已有 | +| Backend 启停接口 | `app.rs` 1857-1859 | `start_connection / stop_connection / remove_connection` 已就绪 | +| `TreeAction` | 已有 `StartConn` / `StopConn` / `RemoveConn` | 不需改 | + +--- + +## 设计 + +### 1 · 信息架构(按钮分工) + +``` +┌──────────────────────────────┐ +│ 连接 [+ 新建] │ +├──────────────────────────────┤ +│ ● TCP 0.0.0.0:5502 运行中 │ ← 整行 success @ 8% 染色 +│ [■ 停止] │ ← 树节点:单按钮 outline + warn 字 +│ ▼ 从站 1 │ +│ FC01 线圈 (20001) │ +│ ○ TCP 192.168.1.10:502 已停止 │ ← 不染色 +│ [▶ 启动] │ ← 树节点:单按钮 outline + success 字 +│ ▶ 从站 1 │ +├──────────────────────────────┤ +│ [× 删除连接 TCP 0.0.0.0:5502] │ ← footer:仅 destructive,针对当前选中 +└──────────────────────────────┘ +``` + +- **树节点内**(`render_tree` line 1704-1721):删除"删除"按钮;剩下的状态相关单按钮 `ui.small_button` → `secondary_button_sm`(Outline Sm),按钮文字含 icon + 颜色(运行 → `■ 停止` warn 黄字 / 停止 → `▶ 启动` success 绿字) +- **footer**(line 3418-3441):去掉启动/停止;只留删除连接,改 `danger_button_sm`(新增 helper)+ 文字含选中连接名 → `× 删除连接 TCP 0.0.0.0:5502` +- **删除二次确认**:按一次 → 按钮文字变 `× 再点一次确认` 维持 3 秒;3 秒内再点 → 真删;3 秒外或切换连接 → 自动恢复 label。**不**起 modal dialog + +### 2 · 状态指示 + +#### 圆点(每个 connection row 最左侧) + +- 位置:`row_resp.rect.left_center() + (8.0, 0.0)`,固定 14px 槽位 +- 半径:3.5px +- 运行中:`theme::success(flavor)` 实心 + 1.5s 周期 alpha 脉动 (180..=255) +- 停止:`theme::text_muted(flavor)` 1px 描边空心圆 + +```rust +// in render_tree, before connection row painter.text +let dot_center = row_resp.rect.left_center() + egui::vec2(8.0, 0.0); +match snap.state { + ConnectionState::Running => { + let phase = (ui.input(|i| i.time) + * (2.0 * std::f64::consts::PI / 1.5)).sin() * 0.5 + 0.5; + let alpha = (180.0 + 75.0 * phase) as u8; + let mut c = theme::success(flavor); + c = Color32::from_rgba_unmultiplied(c.r(), c.g(), c.b(), alpha); + ui.painter().circle_filled(dot_center, 3.5, c); + } + ConnectionState::Stopped => { + ui.painter().circle_stroke( + dot_center, 3.5, + egui::Stroke::new(1.0, theme::text_muted(flavor)), + ); + } +} +``` + +#### 整行染色(仅运行中) + +- 范围:connection row 完整宽度(不含其下 small_button 行) +- 颜色:`Color32::from_rgba_unmultiplied(success.r, success.g, success.b, 0x14)` (8% alpha) +- 优先级:`if conn_is_selected { paint_active_row } else if running { paint_running_row } else if hover { paint_hover }` —— 三者互斥 + +```rust +let paint_running_row = |ui: &egui::Ui, rect: egui::Rect| { + let s = theme::success(flavor); + ui.painter().rect_filled( + rect, 0.0, + Color32::from_rgba_unmultiplied(s.r(), s.g(), s.b(), 0x14), + ); +}; +``` + +#### 状态 tag 文本(line 1668-1672) + +`[运行中]/[已停止]` → `运行中`/`已停止`(去括号),并染色: +- 运行 → `theme::success(flavor)` +- 停止 → `theme::text_muted(flavor)` + +label 与 tag 分两次 `painter.text` 绘制(label 为主,tag 在右侧 6px 间距处)。 + +#### 底部状态栏(line 3454+) + +- `any_running = self.conn_snapshot.iter().any(|s| s.state == ConnectionState::Running)` +- 至少一个运行 → `● 运行中 · N 连接 · M 从站`,● 与树节点同函数同相位脉动 +- 全部停止 → `○ 已停止 · N 连接 · M 从站`,muted 静态 +- 0 连接 → `○ 未连接`,muted 静态 + +### 3 · 瞬时反馈 + +不引入 toast。反馈完全靠"行染色 + 圆点脉动 + tag 染色 + 按钮文字变换"四通道同步: + +- 用户点击"启动"瞬间 → state 立即 flip 到 `Running` → 下一帧整行立即变绿底 + 圆点开始脉动 + tag 变绿 + 按钮变 `■ 停止` 黄字 +- backend 启动若失败 → 走现有 `clear_error` 路径,状态栏显示错误,state 回滚 `Stopped`,整行染色随之消失(**不动现有 error 处理**) + +### 4 · 删除二次确认 + +新增 `SlaveApp` 字段: + +```rust +pub pending_delete: Option<(String, std::time::Instant)>, +``` + +footer 按钮逻辑: + +```rust +let now = std::time::Instant::now(); +let confirming = self.pending_delete + .as_ref() + .filter(|(id, t)| id == &conn_id && now.duration_since(*t).as_secs_f32() < 3.0) + .is_some(); +let label: String = if confirming { + "× 再点一次确认".to_string() +} else { + format!("× 删除连接 {}", snap.label) +}; +if uikit::danger_button_sm(ui, self.flavor, label).clicked() { + if confirming { + tree_action = Some(TreeAction::RemoveConn(conn_id.clone())); + self.pending_delete = None; + } else { + self.pending_delete = Some((conn_id.clone(), now)); + ctx.request_repaint_after(std::time::Duration::from_millis(3100)); + } +} +``` + +`confirming` 检查同时绑定 `conn_id` 与 3 秒窗口,自动覆盖以下边界: +- 3 秒过 → `now.duration_since(t) >= 3.0` 为 false → 退出 confirming(旧字段值保留无害,下次点击会用新 instant 覆盖) +- 切换到别的连接 → footer 渲染时 `id != &conn_id` → 退出 confirming +- 选中态消失(无 active_conn)→ footer 整段不渲染,自然不进入分支 + +保留旧 `pending_delete` 字段值不会触发误删,因为只有"在同一连接 + 3 秒内 + 主动再点"三条件并发才会真删。无需额外清理代码。 + +### 5 · 性能 + +- 状态栏渲染时统一 `if any_running { ctx.request_repaint_after(50ms) }` 一处即可,驱动树+状态栏所有脉动 +- 50ms ≈ 20fps 足够丝滑、CPU 几乎为零 +- 全部连接停止时 → 不请求 repaint,UI 静止零负担 + +--- + +## 主要修改文件 + +- `crates/modbussim-ui-shared/src/ui.rs` — 新增 `danger_button_sm`(shadcn Destructive + Sm,与 `secondary_button_sm` 平行) +- `crates/modbussim-egui/src/app.rs`: + - `SlaveApp` struct + Default:加 `pending_delete` 字段 + - `render_tree` line 1665-1721:圆点、整行染色、tag 染色、small_button → secondary_button_sm + icon、删按钮 + - SidePanel footer line 3418-3441:去启停、删除改 `danger_button_sm` + 二次确认 + - status_bar line 3454+:脉动 ● 与文案 + +--- + +## 验证 + +1. `cargo run -p modbussim-egui --release` +2. 新建一个 TCP 连接(端口 5502),观察: + - 默认 `Stopped` → 行无染色、左侧 ○ 灰描边、tag 灰字「已停止」、节点内按钮 `▶ 启动` 绿字 +3. 点击「启动」: + - 整行立即 success 8% 绿底 + - ● 绿圆点出现并以 1.5s 周期脉动 alpha + - tag 立即变 success 绿色「运行中」 + - 按钮文字立即变 `■ 停止` warn 黄字 + - 底部状态栏 `● 运行中 · 1 连接 · 1 从站` 同步脉动 +4. 点击「停止」反向回退所有视觉 +5. 点击 footer `× 删除连接 TCP 0.0.0.0:5502`: + - 按钮文字变 `× 再点一次确认` + - 3 秒内再点 → 真删 + - 3 秒外不点 → 自动恢复 +6. 同时开 2 个连接,一开一停 → 树内一行染绿一行不染,状态栏脉动 +7. 浅色模式切换:success 浅色(#15803d)@ 8% 在白底是浅绿,可辨;圆点描边正常 +8. `cargo check --workspace` / `cargo clippy -p modbussim-egui -p modbussim-ui-shared --no-deps -- -D warnings` 干净 +9. 浏览 `cargo run` 时的 CPU 占用:所有连接停止时应近 0%;运行中时 ≤ 5% 单核(脉动重绘) From 203fc1cc99b4a4eca8b1392e069aed8552b6ac92 Mon Sep 17 00:00:00 2001 From: kelsoprotein-lab Date: Tue, 21 Apr 2026 10:45:04 +0800 Subject: [PATCH 17/25] =?UTF-8?q?docs(slave-ui):=20=E8=BF=9E=E6=8E=A5?= =?UTF-8?q?=E7=8A=B6=E6=80=81=E5=8F=8D=E9=A6=88=20implementation=20plan=20?= =?UTF-8?q?(7=20task=20/=20~30=20step)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.7 (1M context) --- .../plans/2026-04-21-conn-status-feedback.md | 737 ++++++++++++++++++ 1 file changed, 737 insertions(+) create mode 100644 docs/superpowers/plans/2026-04-21-conn-status-feedback.md diff --git a/docs/superpowers/plans/2026-04-21-conn-status-feedback.md b/docs/superpowers/plans/2026-04-21-conn-status-feedback.md new file mode 100644 index 0000000..7a33d65 --- /dev/null +++ b/docs/superpowers/plans/2026-04-21-conn-status-feedback.md @@ -0,0 +1,737 @@ +# 连接状态反馈与按钮分工 Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use `superpowers:subagent-driven-development` (recommended) or `superpowers:executing-plans` to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 让子站连接的运行/停止状态在 UI 上有明确视觉反馈(圆点 + 整行染色 + 脉动 + tag 染色),并整理「启动/停止/删除」按钮分工 —— 树节点内只放高频启停、footer 只放低频删除(带二次确认)。 + +**Architecture:** 全部基于已落地的 token 系统(`theme::success / danger / text_muted / accent`),新增 1 个 ui helper(`danger_button_sm`),SlaveApp struct 加 1 个状态字段(`pending_delete`),`render_tree` 与 SidePanel footer / status_bar 三处局部改写。无新依赖、不动 backend、不动 `TreeAction` 枚举。 + +**Tech Stack:** Rust · egui 0.33.3 · egui-shadcn 0.3 · 现有 `crates/modbussim-ui-shared` 与 `crates/modbussim-egui` + +**Spec:** `docs/superpowers/specs/2026-04-21-conn-status-feedback-design.md` + +--- + +## File Structure + +| 文件 | 职责 | 改动类型 | +|---|---|---| +| `crates/modbussim-ui-shared/src/ui.rs` | 通用 UI helper | 新增 1 个函数 (`danger_button_sm`) | +| `crates/modbussim-egui/src/app.rs` | 子站主应用 | 加 1 字段 + 4 段渲染改写:tree connection row · tree 节点按钮 · SidePanel footer · status_bar | + +不新增文件。所有改动可读、局部、可独立提交。 + +--- + +## Task 1: 新增 `danger_button_sm` helper + +**Files:** +- Modify: `crates/modbussim-ui-shared/src/ui.rs` (insert near existing `secondary_button_sm`) + +- [ ] **Step 1.1: Read 现状** + +```bash +grep -n "secondary_button_sm" crates/modbussim-ui-shared/src/ui.rs +``` + +应该看到一个 `pub fn secondary_button_sm(...)` 函数(之前 commit 加的 Outline + Sm wrapper)。新 helper 紧跟其后。 + +- [ ] **Step 1.2: 插入 `danger_button_sm`** + +在 `secondary_button_sm` 函数体的右花括号之后、`/// Icon-only button` 注释之前,插入: + +```rust +/// Compact destructive button: shadcn Destructive variant + Sm size. +/// Use for low-frequency, dangerous actions like "删除连接" where you +/// want red prominence but Md size feels visually overweight. +pub fn danger_button_sm(ui: &mut Ui, flavor: Flavor, text: impl Into) -> Response { + let theme = shadcn_theme(flavor); + egui_shadcn::button( + ui, + &theme, + text.into(), + egui_shadcn::tokens::ControlVariant::Destructive, + egui_shadcn::tokens::ControlSize::Sm, + true, + ) +} +``` + +- [ ] **Step 1.3: 编译** + +```bash +cargo check -p modbussim-ui-shared +cargo clippy -p modbussim-ui-shared --no-deps -- -D warnings +``` + +预期 PASS。 + +- [ ] **Step 1.4: Commit** + +```bash +git add crates/modbussim-ui-shared/src/ui.rs +git commit -m "feat(ui-shared): 新增 danger_button_sm (Destructive + Sm)" +``` + +--- + +## Task 2: SlaveApp struct 加 `pending_delete` 字段 + +**Files:** +- Modify: `crates/modbussim-egui/src/app.rs` line 271 (struct) + 372 (new()) + +- [ ] **Step 2.1: Read 现状** + +```bash +sed -n '270,290p' crates/modbussim-egui/src/app.rs +``` + +确认 struct 头部字段顺序 + `// UI state` 注释段。 + +- [ ] **Step 2.2: 加字段** + +定位 `show_new_tcp_dialog: bool,` 行(约 line 283),紧跟其后插入: + +```rust + /// 删除连接二次确认状态:(conn_id, 首次点击时刻)。 + /// 3 秒内同一连接再次点删除按钮 → 真删;否则按钮 label 自动恢复。 + pending_delete: Option<(String, std::time::Instant)>, +``` + +- [ ] **Step 2.3: 在 `new()` 里初始化** + +在 `pub fn new(...)` 的 struct 实例化块里(grep `show_new_tcp_dialog: false`),紧跟其后加: + +```rust + pending_delete: None, +``` + +- [ ] **Step 2.4: 编译** + +```bash +cargo check -p modbussim-egui +``` + +预期 PASS(无新引用方未实现报错;`std::time::Instant` 已经在 app.rs 内多处使用,应已 imported)。 + +- [ ] **Step 2.5: Commit** + +```bash +git add crates/modbussim-egui/src/app.rs +git commit -m "feat(slave-app): SlaveApp 加 pending_delete 状态(删除二次确认)" +``` + +--- + +## Task 3: render_tree · 圆点 + 整行染色 + tag 染色 + +**Files:** +- Modify: `crates/modbussim-egui/src/app.rs` line 1665-1701 (connection row 渲染段 in `render_tree`) + +- [ ] **Step 3.1: Read 现状** + +```bash +sed -n '1645,1725p' crates/modbussim-egui/src/app.rs +``` + +确认 `paint_active_row` 闭包、conn_label 拼接、connection row 整段。 + +- [ ] **Step 3.2: 替换 state_tag + conn_label 段** + +把现有这段(约 line 1668-1672): + +```rust + let state_tag = match snap.state { + ConnectionState::Running => "运行中", + ConnectionState::Stopped => "已停止", + }; + let conn_label = format!("{} [{}]", snap.label, state_tag); +``` + +替换为: + +```rust + let (state_text, state_color) = match snap.state { + ConnectionState::Running => ("运行中", theme::success(flavor)), + ConnectionState::Stopped => ("已停止", theme::text_muted(flavor)), + }; + let is_running = matches!(snap.state, ConnectionState::Running); +``` + +注意:`conn_label` 旧拼接被废弃;下面 painter.text 改为分两次绘制 label + tag。 + +- [ ] **Step 3.3: 在 `paint_active_row` 闭包旁追加 `paint_running_row`** + +定位 `let paint_active_row = |ui: &egui::Ui, rect: egui::Rect| { ... };`(约 line 1657-1663),其后追加: + +```rust + // 整行 8% alpha success 染色,仅用于运行中且未选中的 connection row。 + let paint_running_row = |ui: &egui::Ui, rect: egui::Rect| { + let s = theme::success(flavor); + ui.painter().rect_filled( + rect, + 0.0, + Color32::from_rgba_unmultiplied(s.r(), s.g(), s.b(), 0x14), + ); + }; +``` + +如果 `Color32` 没在 `render_tree` 上下文 import,需要在文件顶部 use(grep `use egui::Color32`,多半已有)。 + +- [ ] **Step 3.4: 改写 connection row 渲染段(line 1675-1701)** + +定位整段: + +```rust + // Connection row + ui.horizontal(|ui| { + let arrow = if snap.expanded { "▼" } else { "▶" }; + if ui.small_button(arrow).clicked() { + action = Some(TreeAction::ToggleConn(snap.id.clone())); + } + let row_resp = ui.allocate_response( + egui::vec2(ui.available_width(), 22.0), + egui::Sense::click(), + ); + if conn_is_selected { + paint_active_row(ui, row_resp.rect); + } else if row_resp.hovered() { + ui.painter() + .rect_filled(row_resp.rect, 0.0, theme::bg_hover(flavor)); + } + let label_color = if conn_is_selected { acc_fg } else { text_color }; + ui.painter().text( + row_resp.rect.left_center() + egui::vec2(4.0, 0.0), + egui::Align2::LEFT_CENTER, + &conn_label, + egui::FontId::proportional(12.5), + label_color, + ); + if row_resp.clicked() { + action = Some(TreeAction::SelectConn(snap.id.clone())); + } + }); +``` + +整段替换为: + +```rust + // Connection row + ui.horizontal(|ui| { + let arrow = if snap.expanded { "▼" } else { "▶" }; + if ui.small_button(arrow).clicked() { + action = Some(TreeAction::ToggleConn(snap.id.clone())); + } + let row_resp = ui.allocate_response( + egui::vec2(ui.available_width(), 22.0), + egui::Sense::click(), + ); + + // 优先级:selected > running 染色 > hover;三者互斥 + if conn_is_selected { + paint_active_row(ui, row_resp.rect); + } else if is_running { + paint_running_row(ui, row_resp.rect); + } else if row_resp.hovered() { + ui.painter() + .rect_filled(row_resp.rect, 0.0, theme::bg_hover(flavor)); + } + + // 状态圆点(左侧 8px 偏移、半径 3.5) + let dot_center = row_resp.rect.left_center() + egui::vec2(8.0, 0.0); + if is_running { + let phase = (ui.input(|i| i.time) + * (2.0 * std::f64::consts::PI / 1.5)) + .sin() * 0.5 + 0.5; + let alpha = (180.0 + 75.0 * phase) as u8; + let s = theme::success(flavor); + let c = Color32::from_rgba_unmultiplied(s.r(), s.g(), s.b(), alpha); + ui.painter().circle_filled(dot_center, 3.5, c); + } else { + ui.painter().circle_stroke( + dot_center, + 3.5, + egui::Stroke::new(1.0, theme::text_muted(flavor)), + ); + } + + // label + tag 分两次绘制 + let label_color = if conn_is_selected { acc_fg } else { text_color }; + let label_pos = + row_resp.rect.left_center() + egui::vec2(20.0, 0.0); // 圆点之后 12px + let label_galley = ui.painter().layout_no_wrap( + snap.label.clone(), + egui::FontId::proportional(12.5), + label_color, + ); + let label_w = label_galley.size().x; + ui.painter().galley(label_pos - egui::vec2(0.0, label_galley.size().y / 2.0), + label_galley, label_color); + let tag_pos = label_pos + egui::vec2(label_w + 6.0, 0.0); + ui.painter().text( + tag_pos, + egui::Align2::LEFT_CENTER, + state_text, + egui::FontId::proportional(11.0), + state_color, + ); + + if row_resp.clicked() { + action = Some(TreeAction::SelectConn(snap.id.clone())); + } + }); +``` + +> 注:`painter().galley(...)` 需要左上角坐标,`label_galley.size().y / 2.0` 是把 galley 中心对齐到 row left_center。如 egui 0.33 API 略不同(galley 函数签名变化),按编译错误调整:可以退化用 `painter().text(label_pos, Align2::LEFT_CENTER, &snap.label, FontId::proportional(12.5), label_color)` 代替 galley 组合,然后用 `painter().fonts(|f| f.layout_no_wrap(...).size().x)` 估算宽度后定位 tag_pos。 + +- [ ] **Step 3.5: 编译** + +```bash +cargo check -p modbussim-egui +cargo clippy -p modbussim-egui --no-deps -- -D warnings +``` + +如果 `painter().galley` 签名报错 → 改用 fallback 方案(详见 Step 3.4 注释)。 + +- [ ] **Step 3.6: Commit** + +```bash +git add crates/modbussim-egui/src/app.rs +git commit -m "feat(slave-app): 连接状态视觉化 — 圆点 + 整行染色 + tag 染色" +``` + +--- + +## Task 4: render_tree · 树节点按钮简化 + +**Files:** +- Modify: `crates/modbussim-egui/src/app.rs` line 1703-1721 (Per-connection start/stop/delete buttons) + +- [ ] **Step 4.1: Read 现状** + +```bash +sed -n '1700,1725p' crates/modbussim-egui/src/app.rs +``` + +应看到 `// Per-connection start/stop/delete buttons` + `ui.horizontal(...)` 三按钮 small_button 段。 + +- [ ] **Step 4.2: 替换为单按钮 + outline + 颜色** + +把这段(line 1703-1721): + +```rust + // Per-connection start/stop/delete buttons + ui.horizontal(|ui| { + ui.add_space(18.0); + match snap.state { + ConnectionState::Stopped => { + if ui.small_button("启动").clicked() { + action = Some(TreeAction::StartConn(snap.id.clone())); + } + } + ConnectionState::Running => { + if ui.small_button("停止").clicked() { + action = Some(TreeAction::StopConn(snap.id.clone())); + } + } + } + if ui.small_button("删除").clicked() { + action = Some(TreeAction::RemoveConn(snap.id.clone())); + } + }); +``` + +整段替换为: + +```rust + // Per-connection: 单个状态相关按钮(启动/停止),删除挪到 footer + ui.horizontal(|ui| { + ui.add_space(18.0); + let (label, color, act): (&str, Color32, TreeAction) = match snap.state { + ConnectionState::Stopped => ( + "▶ 启动", + theme::success(flavor), + TreeAction::StartConn(snap.id.clone()), + ), + ConnectionState::Running => ( + "■ 停止", + theme::warn(flavor), + TreeAction::StopConn(snap.id.clone()), + ), + }; + let resp = uikit::secondary_button_sm( + ui, + flavor, + egui::RichText::new(label).color(color).size(11.5), + ); + if resp.clicked() { + action = Some(act); + } + }); +``` + +> 注:`secondary_button_sm` 签名是 `text: impl Into`,`RichText` 不能直接 Into。需要: +> - 选项 A:把 helper 改为接受 `impl Into`(更灵活但破坏 API) +> - 选项 B:本任务内调用 `egui_shadcn::button(ui, &uikit::shadcn_theme(flavor), label.to_string(), ControlVariant::Outline, ControlSize::Sm, true)` —— 但 `shadcn_theme` 是 ui.rs 内的私有 fn,不可外用 +> - 选项 C(**采用**):`secondary_button_sm` 仍用纯文本(不带 icon 颜色),按钮**外**画一个 colored richtext label 替代 icon,如: +> +> ```rust +> ui.horizontal(|ui| { +> ui.add_space(18.0); +> ui.label(egui::RichText::new(label_icon).color(color).size(13.0)); // ▶ / ■ icon 部分 +> let resp = uikit::secondary_button_sm(ui, flavor, label_text); // "启动" / "停止" 纯文本 +> if resp.clicked() { action = Some(act); } +> }); +> ``` +> +> 把 `(label_icon, label_text)` 拆成两个 tuple element。最终代码: +> +> ```rust +> ui.horizontal(|ui| { +> ui.add_space(18.0); +> let (icon, label_text, color, act): (&str, &str, Color32, TreeAction) = +> match snap.state { +> ConnectionState::Stopped => ( +> "▶", "启动", +> theme::success(flavor), +> TreeAction::StartConn(snap.id.clone()), +> ), +> ConnectionState::Running => ( +> "■", "停止", +> theme::warn(flavor), +> TreeAction::StopConn(snap.id.clone()), +> ), +> }; +> ui.label(egui::RichText::new(icon).color(color).size(12.0)); +> if uikit::secondary_button_sm(ui, flavor, label_text).clicked() { +> action = Some(act); +> } +> }); +> ``` +> +> 这是 plan 的最终采用版本。 + +- [ ] **Step 4.3: 编译** + +```bash +cargo check -p modbussim-egui +cargo clippy -p modbussim-egui --no-deps -- -D warnings +``` + +- [ ] **Step 4.4: Commit** + +```bash +git add crates/modbussim-egui/src/app.rs +git commit -m "feat(slave-app): 树节点按钮简化 — 单按钮 + 状态色 icon · 删按钮挪 footer" +``` + +--- + +## Task 5: SidePanel footer · 删除连接 + 二次确认 + +**Files:** +- Modify: `crates/modbussim-egui/src/app.rs` line 3409-3443 (footer 内的按钮 horizontal 段) + +- [ ] **Step 5.1: Read 现状** + +```bash +sed -n '3395,3445p' crates/modbussim-egui/src/app.rs +``` + +应看到 `egui::Frame::new()` footer + `ui.horizontal(|ui| { stop_label / 删除连接 })` 两个 link_action 段。 + +- [ ] **Step 5.2: 替换 footer 内按钮逻辑** + +把 `if let Some(snap) = active_conn { ... }` 整段(line 3415-3442)替换为: + +```rust + if let Some(snap) = active_conn { + let conn_id = snap.id.clone(); + let conn_label_short = snap.label.clone(); + let now = std::time::Instant::now(); + let confirming = self + .pending_delete + .as_ref() + .filter(|(id, t)| { + id == &conn_id + && now.duration_since(*t).as_secs_f32() < 3.0 + }) + .is_some(); + let label: String = if confirming { + "× 再点一次确认".to_string() + } else { + format!("× 删除连接 {}", conn_label_short) + }; + ui.horizontal(|ui| { + if uikit::danger_button_sm(ui, self.flavor, label) + .clicked() + { + if confirming { + tree_action = + Some(TreeAction::RemoveConn(conn_id)); + self.pending_delete = None; + } else { + self.pending_delete = + Some((conn_id, now)); + ctx.request_repaint_after( + std::time::Duration::from_millis(3100), + ); + } + } + }); + } +``` + +- [ ] **Step 5.3: 编译** + +```bash +cargo check -p modbussim-egui +cargo clippy -p modbussim-egui --no-deps -- -D warnings +``` + +如果 `ctx` 在 footer 闭包里不在作用域 → 在 SidePanel `.show(ctx, |ui| { ... })` 之前 `let ctx_clone = ctx.clone();` 然后 footer 内用 `ctx_clone.request_repaint_after(...)`。 + +- [ ] **Step 5.4: Commit** + +```bash +git add crates/modbussim-egui/src/app.rs +git commit -m "feat(slave-app): footer 删除连接 — danger_button_sm + 3 秒二次确认" +``` + +--- + +## Task 6: status_bar · 脉动 ● 与新文案 + +**Files:** +- Modify: `crates/modbussim-egui/src/app.rs` line 3454-3500 (status_bar BottomPanel) + +- [ ] **Step 6.1: Read 现状** + +```bash +sed -n '3450,3515p' crates/modbussim-egui/src/app.rs +``` + +确认现有 `if let Some(err) ... else if let Some(msg) ... else { ●就绪 }` 三分支。 + +- [ ] **Step 6.2: 在 status_bar 渲染前计算 any_running** + +定位 `let conn_count = self.conn_snapshot.len();` 行(约 line 3451),紧跟其后插入: + +```rust + let any_running = self + .conn_snapshot + .iter() + .any(|s| matches!(s.state, ConnectionState::Running)); + let zero_conns = conn_count == 0; +``` + +- [ ] **Step 6.3: 重写 else 分支(替代 `● 就绪`)** + +把 `} else { ... ●就绪 ... }` 这段(line 3493-3500): + +```rust + } else { + ui.add(egui::Label::new( + egui::RichText::new("●") + .color(theme::success(flavor)) + .size(11.0), + )); + theme::text::crumb(ui, flavor, "就绪"); + } +``` + +替换为: + +```rust + } else { + let (dot_color, dot_alpha, status_text, text_color) = if zero_conns { + ( + theme::text_muted(flavor), + 255u8, + "未连接", + theme::text_muted(flavor), + ) + } else if any_running { + let phase = (ui.input(|i| i.time) + * (2.0 * std::f64::consts::PI / 1.5)) + .sin() * 0.5 + 0.5; + let alpha = (180.0 + 75.0 * phase) as u8; + (theme::success(flavor), alpha, "运行中", theme::success(flavor)) + } else { + ( + theme::text_muted(flavor), + 255u8, + "已停止", + theme::text_muted(flavor), + ) + }; + let dot = if zero_conns || !any_running { "○" } else { "●" }; + let dot_color_with_alpha = Color32::from_rgba_unmultiplied( + dot_color.r(), + dot_color.g(), + dot_color.b(), + dot_alpha, + ); + ui.add(egui::Label::new( + egui::RichText::new(dot) + .color(dot_color_with_alpha) + .size(11.0), + )); + ui.add(egui::Label::new( + egui::RichText::new(status_text) + .color(text_color) + .size(11.0), + )); + } +``` + +- [ ] **Step 6.4: 全局脉动 repaint 触发** + +在 `egui::TopBottomPanel::bottom("status_bar")` 之前(line 3454 之前),插入: + +```rust + if any_running { + ctx.request_repaint_after(std::time::Duration::from_millis(50)); + } +``` + +这一处 repaint 同时驱动树节点的圆点脉动 + 状态栏 ● 脉动;停止时不请求重绘,UI 静止。 + +- [ ] **Step 6.5: 编译 + clippy** + +```bash +cargo check -p modbussim-egui +cargo clippy -p modbussim-egui --no-deps -- -D warnings +``` + +- [ ] **Step 6.6: Commit** + +```bash +git add crates/modbussim-egui/src/app.rs +git commit -m "feat(slave-app): 状态栏脉动 ● + 三态文案(运行中/已停止/未连接)" +``` + +--- + +## Task 7: 视觉烟测 + fmt + 最终 push + +**Files:** +- 不改源码,仅运行验证 + +- [ ] **Step 7.1: cargo fmt** + +```bash +cargo fmt --all +cargo fmt --all -- --check +``` + +预期:第二条无输出。 + +如果第二条有输出 → 第一条已 in-place 修复,再 git add/commit: + +```bash +git add -u +git commit -m "style: cargo fmt --all" +``` + +- [ ] **Step 7.2: 全 workspace check** + +```bash +cargo check --workspace +cargo clippy -p modbussim-egui -p modbussim-ui-shared --no-deps -- -D warnings +``` + +预期:focus 两个 crate `-D warnings` 干净;workspace `cargo check` 通过(modbussim-core 4 个 pre-existing warnings 不阻塞)。 + +- [ ] **Step 7.3: 视觉冒烟 — controller 启动 GUI(人工验证)** + +```bash +cargo run -p modbussim-egui --release +``` + +按以下顺序逐项核对(每项 PASS 才算 OK): + +1. 默认无连接 → 状态栏 `○ 未连接 · 0 连接 · 0 从站`,灰色静态 +2. 新建一个 TCP 5502 连接 → 树出现一行:`○ TCP 0.0.0.0:5502 已停止`(左空圆 + tag 灰),无染色 +3. 节点下方按钮:`▶(绿)启动`(icon 绿、文字 outline button) +4. 点「启动」: + - 整行立即 success @ 8% 浅绿背景 + - 圆点变实心绿,1.5 秒周期 alpha 脉动 + - tag 变 `运行中` 绿字 + - 按钮文字变 `■(黄)停止` + - 状态栏 `● 运行中 · 1 连接 · 1 从站`,● 与树同步脉动 +5. 点「停止」反向回退所有视觉 +6. 点 footer `× 删除连接 TCP 0.0.0.0:5502`: + - 按钮文字变 `× 再点一次确认` + - 等 4 秒不点 → 自动恢复为 `× 删除连接 ...` + - 在 3 秒内再点 → 真删,连接消失 +7. 同时建 2 个连接(再起一个 5503),一开一停 → 树内一行染绿一行不染;状态栏脉动只要任一在跑 +8. 视图菜单切浅色模式 → 浅绿染色在白底可辨;圆点与按钮颜色仍语义清晰 + +- [ ] **Step 7.4: CPU 占用粗测(可选,5 秒钟)** + +打开 macOS `Activity Monitor` 或 `top -pid `: +- 全部连接停止时:`modbussim-egui` CPU < 1% (空闲) +- 一个连接运行:CPU < 5% (脉动重绘) + +如果 ≥ 10%,回 Task 6.4 检查 `request_repaint_after` 是否只在 any_running 才调用。 + +- [ ] **Step 7.5: push** + +```bash +git push +``` + +预期:分支已存在 origin,增量推送 commits(约 6 个 feat + 可能 1 个 style)。 + +- [ ] **Step 7.6: PR 留言** + +PR #2 已开。这次的改动会自动出现在 PR diff 里。如果要追加 description,编辑 PR body 加一段「Connection status feedback」小结,否则直接靠 commit message 即可。 + +```bash +gh pr view 2 --json url,additions,deletions +``` + +--- + +## Self-Review + +**1. Spec coverage:** + +| Spec 段 | 覆盖 task | +|---|---| +| 1 信息架构(按钮分工) | Task 4(树节点单按钮)+ Task 5(footer 删除)| +| 2 状态指示(圆点 / 整行染色 / tag 染色) | Task 3(render_tree 完整改写)| +| 3 瞬时反馈(4 通道同步) | Task 3 + Task 4(按钮文字立刻变 + 行染色立刻出 + 圆点立刻脉动 + tag 立刻染色,全靠 state flip 自动联动)| +| 4 删除二次确认 | Task 2(state 字段)+ Task 5(confirming 逻辑)| +| 5 性能(仅 any_running 时 repaint) | Task 6.4 | +| 主要修改文件清单 | Task 1(ui.rs `danger_button_sm`)+ Tasks 2-6(app.rs)| +| 验证步骤 1-9 | Task 7.3 8 项 + Task 7.4 CPU + Task 7.2 cargo check/clippy | + +**所有 spec 要求都有对应 task。无遗漏。** + +**2. Placeholder scan:** 无 TBD / TODO / "implement later" / "add appropriate error handling" / "Similar to Task N" / 引用未定义 type/fn 的步骤。Task 3.4 / Task 4.2 内含一段"如 API 略不同则 fallback"的 inline 说明(针对 egui 0.33 painter().galley 签名变化),是合理的明确兜底,不算 placeholder。 + +**3. Type consistency:** + +- `pending_delete: Option<(String, std::time::Instant)>` 在 Task 2.2 定义,Task 5.2 解构 `(id, t)` 一致 +- `danger_button_sm(ui, flavor, label)` 在 Task 1.2 签名 `text: impl Into`,Task 5.2 传 `String` 一致 +- `secondary_button_sm` 已存在签名 `text: impl Into`,Task 4.2 传 `&str` 一致 +- `paint_running_row` 在 Task 3.3 定义,Task 3.4 调用一致 +- `is_running` / `state_text` / `state_color` 在 Task 3.2 定义,Task 3.4 全部使用一致 +- `any_running` / `zero_conns` 在 Task 6.2 定义,Task 6.3 + 6.4 全部使用一致 +- `TreeAction::StartConn / StopConn / RemoveConn` 已存在,Task 4.2 + Task 5.2 调用一致 + +**全部 type / 字段名一致。** + +--- + +## Execution Handoff + +**Plan complete and saved to `docs/superpowers/plans/2026-04-21-conn-status-feedback.md`. Two execution options:** + +**1. Subagent-Driven (recommended)** — 我每个 task 派一个新 subagent 执行,task 间我做 review,适合多 task 串行。 + +**2. Inline Execution** — 在当前会话按 task 顺序执行,每 2 task 一次 checkpoint review。 + +**Which approach?** From 4225f0c86b718200d3fb33d5290bd34339b70f98 Mon Sep 17 00:00:00 2001 From: kelsoprotein-lab Date: Tue, 21 Apr 2026 10:52:00 +0800 Subject: [PATCH 18/25] =?UTF-8?q?feat(slave-ui):=20danger=5Fbutton=5Fsm=20?= =?UTF-8?q?helper=20+=20SlaveApp.pending=5Fdelete=20=E5=AD=97=E6=AE=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task 1+2 of conn-status-feedback plan — 准备 destructive 二次确认用的 button helper 与状态字段,后续 Task 5 footer 删除按钮逻辑会消费它。 Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/modbussim-egui/src/app.rs | 5 +++++ crates/modbussim-ui-shared/src/ui.rs | 15 +++++++++++++++ 2 files changed, 20 insertions(+) diff --git a/crates/modbussim-egui/src/app.rs b/crates/modbussim-egui/src/app.rs index bf2761c..5936020 100644 --- a/crates/modbussim-egui/src/app.rs +++ b/crates/modbussim-egui/src/app.rs @@ -281,6 +281,10 @@ pub struct SlaveApp { new_host: String, new_port: String, show_new_tcp_dialog: bool, + /// 删除连接二次确认状态:(conn_id, 首次点击时刻)。 + /// 3 秒内同一连接再次点删除按钮 → 真删;否则按钮 label 自动恢复。 + #[allow(dead_code)] + pending_delete: Option<(String, std::time::Instant)>, last_error: Option, // Event-driven snapshot (never read from Arc> on the UI thread). @@ -490,6 +494,7 @@ impl SlaveApp { new_host: "0.0.0.0".to_string(), new_port: "5502".to_string(), show_new_tcp_dialog: false, + pending_delete: None, last_error: None, conn_snapshot: Vec::new(), next_conn_seq: Arc::new(AtomicU64::new(1)), diff --git a/crates/modbussim-ui-shared/src/ui.rs b/crates/modbussim-ui-shared/src/ui.rs index a5f64f9..a972547 100644 --- a/crates/modbussim-ui-shared/src/ui.rs +++ b/crates/modbussim-ui-shared/src/ui.rs @@ -166,6 +166,21 @@ pub fn secondary_button_sm(ui: &mut Ui, flavor: Flavor, text: impl Into) ) } +/// Compact destructive button: shadcn Destructive variant + Sm size. +/// Use for low-frequency, dangerous actions like "删除连接" where you +/// want red prominence but Md size feels visually overweight. +pub fn danger_button_sm(ui: &mut Ui, flavor: Flavor, text: impl Into) -> Response { + let theme = shadcn_theme(flavor); + egui_shadcn::button( + ui, + &theme, + text.into(), + egui_shadcn::tokens::ControlVariant::Destructive, + egui_shadcn::tokens::ControlSize::Sm, + true, + ) +} + /// Icon-only button: shadcn Ghost variant + small size. pub fn icon_button(ui: &mut Ui, flavor: Flavor, icon: &str) -> Response { let theme = shadcn_theme(flavor); From 1fbd7dc89590bc6630788e4dfa36726958de3444 Mon Sep 17 00:00:00 2001 From: kelsoprotein-lab Date: Tue, 21 Apr 2026 10:54:36 +0800 Subject: [PATCH 19/25] =?UTF-8?q?feat(slave-app):=20=E8=BF=9E=E6=8E=A5?= =?UTF-8?q?=E7=8A=B6=E6=80=81=E8=A7=86=E8=A7=89=E5=8C=96=20=E2=80=94=20?= =?UTF-8?q?=E5=9C=86=E7=82=B9=20+=20=E6=95=B4=E8=A1=8C=E6=9F=93=E8=89=B2?= =?UTF-8?q?=20+=20tag=20=E6=9F=93=E8=89=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task 3 of conn-status-feedback plan — render_tree 中 connection row 新增: - 左侧状态圆点(Running: success 填充 + 1.5s alpha 脉动;Stopped: text_muted 描边) - Running 整行 success @ 8% alpha 染色(与 selected/hover 互斥) - tag 文本与 label 分两次绘制,tag 配色(success/text_muted) Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/modbussim-egui/src/app.rs | 63 ++++++++++++++++++++++++++++---- 1 file changed, 55 insertions(+), 8 deletions(-) diff --git a/crates/modbussim-egui/src/app.rs b/crates/modbussim-egui/src/app.rs index 5936020..6d66f62 100644 --- a/crates/modbussim-egui/src/app.rs +++ b/crates/modbussim-egui/src/app.rs @@ -1667,14 +1667,24 @@ impl SlaveApp { painter.rect_filled(stripe_rect, 0.0, acc_color); }; + // 整行 8% alpha success 染色,仅用于运行中且未选中的 connection row。 + let paint_running_row = |ui: &egui::Ui, rect: egui::Rect| { + let s = theme::success(flavor); + ui.painter().rect_filled( + rect, + 0.0, + egui::Color32::from_rgba_unmultiplied(s.r(), s.g(), s.b(), 0x14), + ); + }; + for snap in &self.conn_snapshot { let conn_is_selected = matches!(&self.selection, Selection::Connection(c) if c == &snap.id); - let state_tag = match snap.state { - ConnectionState::Running => "运行中", - ConnectionState::Stopped => "已停止", + let (state_text, state_color) = match snap.state { + ConnectionState::Running => ("运行中", theme::success(flavor)), + ConnectionState::Stopped => ("已停止", theme::text_muted(flavor)), }; - let conn_label = format!("{} [{}]", snap.label, state_tag); + let is_running = matches!(snap.state, ConnectionState::Running); // Connection row ui.horizontal(|ui| { @@ -1686,20 +1696,57 @@ impl SlaveApp { egui::vec2(ui.available_width(), 22.0), egui::Sense::click(), ); + + // 优先级:selected > running 染色 > hover;三者互斥 if conn_is_selected { paint_active_row(ui, row_resp.rect); + } else if is_running { + paint_running_row(ui, row_resp.rect); } else if row_resp.hovered() { ui.painter() .rect_filled(row_resp.rect, 0.0, theme::bg_hover(flavor)); } + + // 状态圆点(左侧 8px 偏移、半径 3.5) + let dot_center = row_resp.rect.left_center() + egui::vec2(8.0, 0.0); + if is_running { + let phase = (ui.input(|i| i.time) + * (2.0 * std::f64::consts::PI / 1.5)) + .sin() + * 0.5 + + 0.5; + let alpha = (180.0 + 75.0 * phase) as u8; + let s = theme::success(flavor); + let c = egui::Color32::from_rgba_unmultiplied(s.r(), s.g(), s.b(), alpha); + ui.painter().circle_filled(dot_center, 3.5, c); + } else { + ui.painter().circle_stroke( + dot_center, + 3.5, + egui::Stroke::new(1.0, theme::text_muted(flavor)), + ); + } + + // label + tag 分两次绘制 let label_color = if conn_is_selected { acc_fg } else { text_color }; - ui.painter().text( - row_resp.rect.left_center() + egui::vec2(4.0, 0.0), - egui::Align2::LEFT_CENTER, - &conn_label, + let label_pos = row_resp.rect.left_center() + egui::vec2(20.0, 0.0); + let label_galley = ui.painter().layout_no_wrap( + snap.label.clone(), egui::FontId::proportional(12.5), label_color, ); + let label_w = label_galley.size().x; + let galley_top = label_pos - egui::vec2(0.0, label_galley.size().y / 2.0); + ui.painter().galley(galley_top, label_galley, label_color); + let tag_pos = label_pos + egui::vec2(label_w + 6.0, 0.0); + ui.painter().text( + tag_pos, + egui::Align2::LEFT_CENTER, + state_text, + egui::FontId::proportional(11.0), + state_color, + ); + if row_resp.clicked() { action = Some(TreeAction::SelectConn(snap.id.clone())); } From 76af1016d04610d332f4c818ffe3fe3b844ff311 Mon Sep 17 00:00:00 2001 From: kelsoprotein-lab Date: Tue, 21 Apr 2026 10:56:03 +0800 Subject: [PATCH 20/25] =?UTF-8?q?feat(slave-app):=20=E6=A0=91=E8=8A=82?= =?UTF-8?q?=E7=82=B9=E6=8C=89=E9=92=AE=E7=AE=80=E5=8C=96=20=E2=80=94=20?= =?UTF-8?q?=E5=8D=95=E6=8C=89=E9=92=AE=20+=20=E7=8A=B6=E6=80=81=E8=89=B2?= =?UTF-8?q?=20icon=20=C2=B7=20=E5=88=A0=E6=8C=89=E9=92=AE=E6=8C=AA=20foote?= =?UTF-8?q?r?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task 4 of conn-status-feedback plan — render_tree 内 per-connection 按钮区由 small_button 三连(启动/停止/删除)改为: - 状态色 icon (▶ 绿 / ■ 黄) RichText label - 单个 secondary_button_sm 文本("启动" / "停止") "删除" 按钮完整移除,留给 Task 5 的 footer 处理。 Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/modbussim-egui/src/app.rs | 32 +++++++++++++++++--------------- 1 file changed, 17 insertions(+), 15 deletions(-) diff --git a/crates/modbussim-egui/src/app.rs b/crates/modbussim-egui/src/app.rs index 6d66f62..e8bb073 100644 --- a/crates/modbussim-egui/src/app.rs +++ b/crates/modbussim-egui/src/app.rs @@ -1752,23 +1752,25 @@ impl SlaveApp { } }); - // Per-connection start/stop/delete buttons + // Per-connection: 单个状态相关按钮(启动/停止),删除挪到 footer ui.horizontal(|ui| { ui.add_space(18.0); - match snap.state { - ConnectionState::Stopped => { - if ui.small_button("启动").clicked() { - action = Some(TreeAction::StartConn(snap.id.clone())); - } - } - ConnectionState::Running => { - if ui.small_button("停止").clicked() { - action = Some(TreeAction::StopConn(snap.id.clone())); - } - } - } - if ui.small_button("删除").clicked() { - action = Some(TreeAction::RemoveConn(snap.id.clone())); + let (icon, label_text, color, act): (&str, &str, egui::Color32, TreeAction) = + match snap.state { + ConnectionState::Stopped => ( + "▶", "启动", + theme::success(flavor), + TreeAction::StartConn(snap.id.clone()), + ), + ConnectionState::Running => ( + "■", "停止", + theme::warn(flavor), + TreeAction::StopConn(snap.id.clone()), + ), + }; + ui.label(egui::RichText::new(icon).color(color).size(12.0)); + if uikit::secondary_button_sm(ui, flavor, label_text).clicked() { + action = Some(act); } }); From 648e24f00df4b80036263ef713203c2b9c3dd71a Mon Sep 17 00:00:00 2001 From: kelsoprotein-lab Date: Tue, 21 Apr 2026 10:57:59 +0800 Subject: [PATCH 21/25] =?UTF-8?q?feat(slave-app):=20footer=20=E5=88=A0?= =?UTF-8?q?=E9=99=A4=E8=BF=9E=E6=8E=A5=20=E2=80=94=20danger=5Fbutton=5Fsm?= =?UTF-8?q?=20+=203=20=E7=A7=92=E4=BA=8C=E6=AC=A1=E7=A1=AE=E8=AE=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task 5 of conn-status-feedback plan — SidePanel footer 由原启动/停止/ 删除连接 三 link_action 改为: - 仅保留删除(启动/停止已挪到 Task 4 的树节点按钮) - danger_button_sm(红色 outline Sm)+ 文字含选中连接名 - 第一次点击 → label 变 "× 再点一次确认",3 秒内再点真删 - 利用 SlaveApp.pending_delete 字段;3 秒后自动恢复,无需主动清 Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/modbussim-egui/src/app.rs | 48 ++++++++++++++++++-------------- 1 file changed, 27 insertions(+), 21 deletions(-) diff --git a/crates/modbussim-egui/src/app.rs b/crates/modbussim-egui/src/app.rs index e8bb073..393476c 100644 --- a/crates/modbussim-egui/src/app.rs +++ b/crates/modbussim-egui/src/app.rs @@ -283,7 +283,6 @@ pub struct SlaveApp { show_new_tcp_dialog: bool, /// 删除连接二次确认状态:(conn_id, 首次点击时刻)。 /// 3 秒内同一连接再次点删除按钮 → 真删;否则按钮 label 自动恢复。 - #[allow(dead_code)] pending_delete: Option<(String, std::time::Instant)>, last_error: Option, @@ -3468,29 +3467,36 @@ impl eframe::App for SlaveApp { }); if let Some(snap) = active_conn { let conn_id = snap.id.clone(); - let is_running = snap.state == ConnectionState::Running; + let conn_label_short = snap.label.clone(); + let now = std::time::Instant::now(); + let confirming = self + .pending_delete + .as_ref() + .filter(|(id, t)| { + id == &conn_id + && now.duration_since(*t).as_secs_f32() < 3.0 + }) + .is_some(); + let label: String = if confirming { + "× 再点一次确认".to_string() + } else { + format!("× 删除连接 {}", conn_label_short) + }; ui.horizontal(|ui| { - let stop_label = - if is_running { "停止" } else { "启动" }; - if uikit::link_action( - ui, - self.flavor, - stop_label, - false, - ) - .clicked() - { - tree_action = Some(if is_running { - TreeAction::StopConn(conn_id.clone()) - } else { - TreeAction::StartConn(conn_id.clone()) - }); - } - ui.add_space(14.0); - if uikit::link_action(ui, self.flavor, "删除连接", true) + if uikit::danger_button_sm(ui, self.flavor, label) .clicked() { - tree_action = Some(TreeAction::RemoveConn(conn_id)); + if confirming { + tree_action = + Some(TreeAction::RemoveConn(conn_id)); + self.pending_delete = None; + } else { + self.pending_delete = + Some((conn_id, now)); + ctx.request_repaint_after( + std::time::Duration::from_millis(3100), + ); + } } }); } From 37769f80110ece517a9ef96005bdc4d97d7fd4cf Mon Sep 17 00:00:00 2001 From: kelsoprotein-lab Date: Tue, 21 Apr 2026 11:00:00 +0800 Subject: [PATCH 22/25] =?UTF-8?q?feat(slave-app):=20=E7=8A=B6=E6=80=81?= =?UTF-8?q?=E6=A0=8F=E8=84=89=E5=8A=A8=20=E2=97=8F=20+=20=E4=B8=89?= =?UTF-8?q?=E6=80=81=E6=96=87=E6=A1=88=EF=BC=88=E8=BF=90=E8=A1=8C=E4=B8=AD?= =?UTF-8?q?/=E5=B7=B2=E5=81=9C=E6=AD=A2/=E6=9C=AA=E8=BF=9E=E6=8E=A5?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task 6 of conn-status-feedback plan — status_bar BottomPanel "no error/no status" 默认分支由静态 ●就绪 改为三态: - 0 连接 → ○ 未连接(muted 静态) - 任一运行中 → ● 运行中(success 1.5s 脉动 alpha) - 全部停止 → ○ 已停止(muted 静态) 全局 ctx.request_repaint_after(50ms) 仅在 any_running 时调用, 驱动树节点 + 状态栏所有脉动;停止时 UI 静止零 CPU。 Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/modbussim-egui/src/app.rs | 48 ++++++++++++++++++++++++++++++-- 1 file changed, 45 insertions(+), 3 deletions(-) diff --git a/crates/modbussim-egui/src/app.rs b/crates/modbussim-egui/src/app.rs index 393476c..3e970af 100644 --- a/crates/modbussim-egui/src/app.rs +++ b/crates/modbussim-egui/src/app.rs @@ -3509,8 +3509,16 @@ impl eframe::App for SlaveApp { let mut clear_error = false; let mut clear_status = false; let conn_count = self.conn_snapshot.len(); + let any_running = self + .conn_snapshot + .iter() + .any(|s| matches!(s.state, ConnectionState::Running)); + let zero_conns = conn_count == 0; let slave_count: usize = self.conn_snapshot.iter().map(|c| c.devices.len()).sum(); let flavor = self.flavor; + if any_running { + ctx.request_repaint_after(std::time::Duration::from_millis(50)); + } egui::TopBottomPanel::bottom("status_bar") .resizable(false) .exact_height(22.0) @@ -3551,12 +3559,46 @@ impl eframe::App for SlaveApp { clear_status = true; } } else { + let (dot_color, dot_alpha, status_text, text_color) = if zero_conns { + ( + theme::text_muted(flavor), + 255u8, + "未连接", + theme::text_muted(flavor), + ) + } else if any_running { + let phase = (ui.input(|i| i.time) + * (2.0 * std::f64::consts::PI / 1.5)) + .sin() + * 0.5 + + 0.5; + let alpha = (180.0 + 75.0 * phase) as u8; + (theme::success(flavor), alpha, "运行中", theme::success(flavor)) + } else { + ( + theme::text_muted(flavor), + 255u8, + "已停止", + theme::text_muted(flavor), + ) + }; + let dot = if zero_conns || !any_running { "○" } else { "●" }; + let dot_color_with_alpha = egui::Color32::from_rgba_unmultiplied( + dot_color.r(), + dot_color.g(), + dot_color.b(), + dot_alpha, + ); ui.add(egui::Label::new( - egui::RichText::new("●") - .color(theme::success(flavor)) + egui::RichText::new(dot) + .color(dot_color_with_alpha) + .size(11.0), + )); + ui.add(egui::Label::new( + egui::RichText::new(status_text) + .color(text_color) .size(11.0), )); - theme::text::crumb(ui, flavor, "就绪"); } ui.add_space(14.0); theme::text::crumb( From a1595f04e969b0320935384c0891d5e47527f16a Mon Sep 17 00:00:00 2001 From: kelsoprotein-lab Date: Tue, 21 Apr 2026 11:00:50 +0800 Subject: [PATCH 23/25] style: cargo fmt --all Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/modbussim-egui/src/app.rs | 29 ++++++++++++++++++----------- 1 file changed, 18 insertions(+), 11 deletions(-) diff --git a/crates/modbussim-egui/src/app.rs b/crates/modbussim-egui/src/app.rs index 3e970af..19751de 100644 --- a/crates/modbussim-egui/src/app.rs +++ b/crates/modbussim-egui/src/app.rs @@ -1709,9 +1709,7 @@ impl SlaveApp { // 状态圆点(左侧 8px 偏移、半径 3.5) let dot_center = row_resp.rect.left_center() + egui::vec2(8.0, 0.0); if is_running { - let phase = (ui.input(|i| i.time) - * (2.0 * std::f64::consts::PI / 1.5)) - .sin() + let phase = (ui.input(|i| i.time) * (2.0 * std::f64::consts::PI / 1.5)).sin() * 0.5 + 0.5; let alpha = (180.0 + 75.0 * phase) as u8; @@ -1757,12 +1755,14 @@ impl SlaveApp { let (icon, label_text, color, act): (&str, &str, egui::Color32, TreeAction) = match snap.state { ConnectionState::Stopped => ( - "▶", "启动", + "▶", + "启动", theme::success(flavor), TreeAction::StartConn(snap.id.clone()), ), ConnectionState::Running => ( - "■", "停止", + "■", + "停止", theme::warn(flavor), TreeAction::StopConn(snap.id.clone()), ), @@ -3491,8 +3491,7 @@ impl eframe::App for SlaveApp { Some(TreeAction::RemoveConn(conn_id)); self.pending_delete = None; } else { - self.pending_delete = - Some((conn_id, now)); + self.pending_delete = Some((conn_id, now)); ctx.request_repaint_after( std::time::Duration::from_millis(3100), ); @@ -3567,13 +3566,17 @@ impl eframe::App for SlaveApp { theme::text_muted(flavor), ) } else if any_running { - let phase = (ui.input(|i| i.time) - * (2.0 * std::f64::consts::PI / 1.5)) + let phase = (ui.input(|i| i.time) * (2.0 * std::f64::consts::PI / 1.5)) .sin() * 0.5 + 0.5; let alpha = (180.0 + 75.0 * phase) as u8; - (theme::success(flavor), alpha, "运行中", theme::success(flavor)) + ( + theme::success(flavor), + alpha, + "运行中", + theme::success(flavor), + ) } else { ( theme::text_muted(flavor), @@ -3582,7 +3585,11 @@ impl eframe::App for SlaveApp { theme::text_muted(flavor), ) }; - let dot = if zero_conns || !any_running { "○" } else { "●" }; + let dot = if zero_conns || !any_running { + "○" + } else { + "●" + }; let dot_color_with_alpha = egui::Color32::from_rgba_unmultiplied( dot_color.r(), dot_color.g(), From ca21d4a91f529fda092ee15e8275f987b3b7fad2 Mon Sep 17 00:00:00 2001 From: kelsoprotein-lab Date: Tue, 21 Apr 2026 13:14:43 +0800 Subject: [PATCH 24/25] =?UTF-8?q?fix(jitter):=20=E9=9B=B6=E5=80=BC=20holdi?= =?UTF-8?q?ng/input=20=E5=AF=84=E5=AD=98=E5=99=A8=E4=B8=8D=E5=8A=A8=20?= =?UTF-8?q?=E2=80=94=20=E6=95=B4=E6=95=B0=E9=99=A4=E6=B3=95=E4=BF=9D?= =?UTF-8?q?=E5=BA=95=20=C2=B11?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit bug: perturb_u16 用 base*pct/100 计算 delta,base=max(value,1) 时 当寄存器初值是 0(新建场景常见)→ base=1,pct∈[-10,10] → delta = 1*pct/100 = 0(整数截断),寄存器永远不动。 用户感知:FC03/FC04 在新建后开启 jitter 后从不变化,而 FC01/FC02 (走 flip_bools,与值无关)正常翻转,造成"3/4 号功能码不动"的 bug。 修:pct≠0 但 delta 被截断为 0 时,强制 delta = pct.signum(), 保证至少 ±1 起步漂移。漂移量积累后 base 增大,恢复正常 % 行为。 加 zero_seeded_holding_registers_actually_drift 回归测试。 Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/modbussim-core/src/jitter.rs | 41 ++++++++++++++++++++++++++++- 1 file changed, 40 insertions(+), 1 deletion(-) diff --git a/crates/modbussim-core/src/jitter.rs b/crates/modbussim-core/src/jitter.rs index 5d5b76f..78997e3 100644 --- a/crates/modbussim-core/src/jitter.rs +++ b/crates/modbussim-core/src/jitter.rs @@ -80,7 +80,12 @@ fn perturb_u16( // Use the current value (min 1 so drift works on zero-seeded registers). let base = (*v as i32).max(1); let pct = rng.gen_range(-delta_pct..=delta_pct); - let delta = base * pct / 100; + let mut delta = base * pct / 100; + // 保底:当 base 较小(如 0/1)时整数除法 base*pct/100 会被截断为 0, + // 导致零值寄存器永远不动。pct≠0 但 delta=0 时强制给 ±1(符号随 pct)。 + if delta == 0 && pct != 0 { + delta = pct.signum(); + } *v = (*v).wrapping_add(delta as u16); } } @@ -212,4 +217,38 @@ mod tests { assert!((500..=1500).contains(&v)); } } + + /// 回归测试:之前 perturb_u16 用 `base * pct / 100` 整数除法,零值寄存器 + /// base=1 时 delta 永远被截断为 0,导致 FC03/FC04 在初值全 0 的常见场景下 + /// 不动。修复后保底 ±1,确保至少能起步漂移。 + #[test] + fn zero_seeded_holding_registers_actually_drift() { + let mut map = RegisterMap::new(); + for addr in 0..16u16 { + map.holding_registers.insert(addr, 0); + map.input_registers.insert(addr, 0); + } + let cfg = JitterConfig { + enabled: true, + interval_ms: 100, + mutation_rate: 100, + delta_percent: 10, + affect_coils: false, + affect_discrete: false, + affect_holding: true, + affect_input: true, + }; + let mut rng = StdRng::seed_from_u64(7); + apply_tick(&mut map, &cfg, &mut rng); + let any_holding_moved = map.holding_registers.values().any(|&v| v != 0); + let any_input_moved = map.input_registers.values().any(|&v| v != 0); + assert!( + any_holding_moved, + "零值 holding registers 在 100% mutation rate 下应至少有一个动" + ); + assert!( + any_input_moved, + "零值 input registers 在 100% mutation rate 下应至少有一个动" + ); + } } From dc04c9be62e04cf16bc8169470cf62c163a30de7 Mon Sep 17 00:00:00 2001 From: kelsoprotein-lab Date: Tue, 21 Apr 2026 13:29:31 +0800 Subject: [PATCH 25/25] =?UTF-8?q?fix(slave-app):=20data=20source=20runner?= =?UTF-8?q?=20=E8=A1=A5=E9=BD=90=20Coil/DiscreteInput=20=E5=86=99=E5=85=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit bug: app.rs:423-432 的后台 data source runner 把 RegisterType match 的 Coil / DiscreteInput 走 _ => {} 分支完全丢弃,用户配置的 随机变化源(DataSource)只会更新 holding/input 寄存器,FC01/FC02 无效。与上一 commit 修的 jitter bug 是两个独立问题。 修:补齐两个分支,u16 → bool 用 v != 0 转换(约定非零为 true)。 Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/modbussim-egui/src/app.rs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/crates/modbussim-egui/src/app.rs b/crates/modbussim-egui/src/app.rs index 19751de..a99c612 100644 --- a/crates/modbussim-egui/src/app.rs +++ b/crates/modbussim-egui/src/app.rs @@ -428,7 +428,13 @@ impl SlaveApp { RegisterType::InputRegister => { dev.register_map.input_registers.insert(addr, v); } - _ => {} + RegisterType::Coil => { + // u16 → bool:非零视为 true,零为 false。 + dev.register_map.coils.insert(addr, v != 0); + } + RegisterType::DiscreteInput => { + dev.register_map.discrete_inputs.insert(addr, v != 0); + } } } }