From 764e5bee71386c9940ffd72c02d4c34f1519797f Mon Sep 17 00:00:00 2001 From: "DeepSeek-V4.1-Flash" <> Date: Wed, 4 Nov 2026 02:41:53 +0800 Subject: [PATCH 01/22] =?UTF-8?q?chore(docs):=20=E5=88=A0=E9=99=A4?= =?UTF-8?q?=E8=BF=87=E6=97=B6=E7=9A=84=20UI=20=E9=87=8D=E6=9E=84=E9=98=B6?= =?UTF-8?q?=E6=AE=B5=E6=96=87=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Documents/UI_Refactor_Plan.md、UI_Refactor_Progress.md、UI_Refactor_Report.md 描述的是 2026-10-28 那一阶段的计划与结论,已被后续多轮实现推翻/覆盖, 且内容与当前代码不一致,继续保留会误导接手者。 删除后由 Documents/Code_Review_Plan.md / Code_Review_Progress.md / Code_Review_Report.md 承接(下一分支 Code_Review)。 --- Documents/UI_Refactor_Plan.md | 153 ---------------------- Documents/UI_Refactor_Progress.md | 202 ------------------------------ Documents/UI_Refactor_Report.md | 157 ----------------------- 3 files changed, 512 deletions(-) delete mode 100644 Documents/UI_Refactor_Plan.md delete mode 100644 Documents/UI_Refactor_Progress.md delete mode 100644 Documents/UI_Refactor_Report.md diff --git a/Documents/UI_Refactor_Plan.md b/Documents/UI_Refactor_Plan.md deleted file mode 100644 index 7d95ed6..0000000 --- a/Documents/UI_Refactor_Plan.md +++ /dev/null @@ -1,153 +0,0 @@ -# Just DataInspector UI 重构 —— 计划 - -> 本文档只描述**要做什么、为什么这么做、怎么验收**。 -> 已完成情况见 `UI_Refactor_Progress.md`,结论与遗留见 `UI_Refactor_Report.md`。 -> 最后更新:2026-10-29 - ---- - -## 1. 目标与非目标 - -### 1.1 目标 - -| # | 目标 | 判据 | -| --- | --- | --- | -| G1 | 收敛"宿主点名每个组件"的散乱结构 | 宿主只遍历一张组件表,不再 `if (component == plot)` 式分支 | -| G2 | 几何只有**一处**来源 | "画出来的形状"与"能点到的区域"由同一批函数给出,不再各算一遍 | -| G3 | 源码按**职责**分片,单片可读 | 单个 `.cpp` 不超过约 1200 行;文件名与内容一致 | -| G4 | 减小产物体积,且**仍然只有一个 `.exe`** | 体积比基线小,无新增依赖 DLL | -| G5 | 关闭 C++ 异常后**不牺牲健壮性** | 所有大块分配前有预检验;OOM 有可见提示 | -| G6 | 重构**不得改变任何绘制像素** | 8 个场景的逐像素哈希与基线逐一相同 | - -### 1.2 非目标(明确不做) - -* **不把编译产物拆成 DLL**:本项目的定位是"单文件、拷过去就能跑的轻量工具"。 - 任何以拆 DLL 换体积的方案一律否决。 -* **不改优化等级**(`-Oz` 保持不变)。 -* **不把"波形"与"网格线"拆成两层位图**:像素级平移不变量会被破坏。 -* **不为体积牺牲可维护性**(例如放弃分片、放弃几何同源)。 - ---- - -## 2. 硬约束 - -1. **单一可执行文件**:不得引入额外的 `.dll` / 运行时依赖。 -2. **体积预警阈值 = 1MB(1,048,576 B)**。这不是硬上限:超过仍可发布, - 但必须主动提醒用户,并说明"超在哪一段(`.text` / `.rdata` / `.xdata`+`.pdata` / `.rsrc`)、 - 由哪个改动贡献"。旧的 900KB / 800KB 红线**已作废**。 -3. **异常处理用预检验,不用 try/catch**:`-fno-exceptions` 下 `try/catch` 不会被编译, - 因此所有"按数据规模预留大块内存"的地方必须先问 `MemReserveIfRoom()`。 -4. **像素级零回归**:验收以 `Tools/rendercheck` 的 8 个场景哈希为准。 -5. **中文注释按 UTF-8 无 BOM 保存**;构建参数用 `-finput-charset=UTF-8 -fexec-charset=GBK`。 - ---- - -## 3. 分层架构(L0–L3) - -``` -L3 体积与异常策略 -fno-exceptions / MemProbe / LTO / gc-sections - ▲ -L2 重绘范围控制 UiDamage(脏矩形) ← 本轮评估后**缓做**,见 §5 - ▲ -L1 几何产物层 UiBox / UiBoxTable / UiComponent::BuildBoxes - ▲ -L0 组件协议 UiComponent + 组件表 + UiWidget 控件树 -``` - -### L0 组件协议(前几轮已完成) - -* `UiComponent`:`SetScale / Layout / Area / Paint / HitTest / OnMouseLeave / IsEditing / QueryCursor`。 -* 宿主持有一张**有序组件表**,逐个遍历;新增组件不必改宿主的 5 处消息分支。 -* `UiWidget` 与 `UiComponent` 是**兄弟**而非父子:前者管"一块区域内部的布局/命中/绘制", - 后者管"自己这一整块地盘:区域、缓存、局部重绘策略"。 - `Toolbar` / `TabBar` 同时是两者。 - -### L1 几何产物层(本轮完成) - -* 新文件 `Components/Shared/Inc/UiBox.hpp` + `Src/UiBox.cpp`。 -* `struct UiBox { rect, clip, depth, layer }`:**纯值类型**,不继承任何东西。 -* `class UiBoxTable`:几何收集器,只维护"矩形 / 层级 / 绘制层 / 裁剪 / 遮挡"这些**几何事实**, - **不含任何绘制代码**。 -* `UiComponent::BuildBoxes(UiBoxTable&)`:组件上报自己的几何产物(默认只登记 `Area()`)。 -* `UiDebugBounds` 退化为**消费者**:`UiDebugPaintBoxList(g, table)` 把几何画成彩色包角线。 - ⇒ 组件不再需要知道"调试"这个概念存在。 - -### L2 重绘范围控制(缓做 —— 见 §5) - -* 设计意图:`UiDamage`(最多 12 个矩形的脏区集合,超限退化为包围盒)替代 - "全窗重绘 / 绘图区重绘"的二值分支;`PlotView::Paint(g, UiDamage&)` 逐路径累加脏区。 -* 收益:去掉 `SetForceFullRepaint`(布局边界开关打开时的整幅重绘)。 - -### L3 体积与异常策略(本轮完成) - -见 `UI_Refactor_Progress.md` 的实测表。 - ---- - -## 4. 阶段与验收标准 - -| 阶段 | 内容 | 验收 | -| --- | --- | --- | -| P1–P6 | 文件按职责拆分、`UiComponent` 协议、组件表、布局边界功能 | 编译 + `uicheck` | -| P7 | 布局边界"几何同源"(`RowRect` 前缀和、文本墨迹矩形、浮层层级/遮挡/裁剪) | `lbcovr` / `lbzorder` / `lbresid` / `lbpanel` | -| P8 | 体积:`-fno-exceptions` + `MemProbe` + `-ffunction-sections` + `--gc-sections` | 体积下降且 8 哈希不变 | -| P9 | 体积:LTO(含 `-flto-partition=one` 稳定性约束) | 同上,且可重复构建 | -| P10 | L1 几何产物层(`UiBox` / `UiBoxTable` / `BuildBoxes`) | 同上,且源码零残留旧名 | -| P11 | 代码审计(过时注释 / 名实不符 / 死代码) | 零 warning + 全量回归 | -| P12 | 文档重建 + 本地提交 | `git status` 干净 | - -**统一验收命令** - -```powershell -make clean -make release TARGET_FILE_NAME=JustDataInspector -j16 # 期望零 error / 零 warning -make debug TARGET_FILE_NAME=JustDataInspectorD -j16 # 期望零 warning - -objdump -h Build\Release\JustDataInspector.exe # 段构成,用于体积溯源 - -Tools\uicheck\uicheck.exe # 69/69 -.preflight\lbcheck\lbcheck.exe # 21/21 -.preflight\lbresid\lbresid.exe # 5/5 -.preflight\lbzorder\lbzorder.exe # 4/4 -.preflight\lbcovr\lbcovr.exe # 7/7 -.preflight\lbpanel\lbpanel.exe # 10/10 -Tools\rendercheck\rendercheck.exe # 8 哈希不变 + FUNC 7/7 -``` - ---- - -## 5. 明确缓做的部分及理由 - -### L2 `UiDamage` + 独立叠加层(S4) - -**为什么缓做** - -1. 受益方只有"**调试开关打开时**的帧率"。`SetForceFullRepaint` 只在 - 「显示布局边界」开启时被置位(`Application::Render` 里 `m_plot.SetForceFullRepaint(m_showLayoutBounds)`), - 普通用户路径上**本来就是快路径**。 -2. 正确实现无法只改 `PlotView` 内部:宿主用的是**持久后备缓冲区**, - 上一帧叠加层画上去的边框像素必须在下一帧被"底层内容重画"覆盖掉。 - 若改成"独立叠加位图 + 只混合脏区",就必须把叠加层的旧矩形**注入**到底层组件的重绘范围里 —— - 这要穿透 `PlotView` 的全部四条快路径(静态场景贴图 / 宽基底贴图 / 样式补丁 / 网格层重建) - 与两层缓存。 -3. 风险与收益不成比例:用户已在 2026-10-28 反复报告过"拖拽错位 / 贴图残留"一类事故, - 而这类事故正是从"某条快路径漏了某块像素"开始的。 - -**保留了什么**:`SetForceFullRepaint` 及其四处守卫、`UiBoxTable` 的几何产物。 -将来若真要做,只需把 `UiBoxTable` 的 `rect` 直接喂给 `UiDamage`,不需要重新收集几何。 - ---- - -## 6. 交付物清单 - -``` -Components/Shared/Inc/UiBox.hpp 新增(几何产物层) -Components/Shared/Src/UiBox.cpp 新增 -Components/Shared/Inc/UiDebugBounds.hpp 精简为"只画"(消费者) -Components/Shared/Src/UiDebugBounds.cpp 精简为"只画" -Components/Shared/Inc/UiComponent.hpp CollectDebugBoxes → BuildBoxes -Utilities/Inc/MemProbe.hpp 新增(分配预检验 + terminate 兜底) -Utilities/Src/MemProbe.cpp 新增 -Makefile LAYOUT_BOUNDS / NO_EXCEPTIONS / LTO 三个开关 -Documents/UI_Refactor_{Plan,Progress,Report}.md 重建 -``` diff --git a/Documents/UI_Refactor_Progress.md b/Documents/UI_Refactor_Progress.md deleted file mode 100644 index f34e4b2..0000000 --- a/Documents/UI_Refactor_Progress.md +++ /dev/null @@ -1,202 +0,0 @@ -# Just DataInspector UI 重构 —— 当前进度 - -> 计划见 `UI_Refactor_Plan.md`,结论与遗留见 `UI_Refactor_Report.md`。 -> 最后更新:2026-10-29(本轮:体积 L3 落地 + L1 几何产物层 + 审计) - ---- - -## 1. 总览 - -| 层 | 内容 | 状态 | -| --- | --- | --- | -| L0 | `UiComponent` 协议 + 有序组件表 + `UiWidget` 控件树 | ✅ 已完成(前几轮) | -| L1 | `UiBox` / `UiBoxTable` / `UiComponent::BuildBoxes` | ✅ **本轮完成** | -| L2 | `UiDamage` 脏矩形 + S4 独立叠加层 | ⏸ **评估后缓做**(理由见 Plan §5) | -| L3 | `-fno-exceptions` + `MemProbe` + `-ffunction-sections` + `--gc-sections` + LTO | ✅ **本轮完成** | - ---- - -## 2. L3 体积与异常策略 - -### 2.1 Makefile 开关 - -| 开关 | 默认 | 作用 | 备注 | -| --- | --- | --- | --- | -| `LAYOUT_BOUNDS` | `1` | 是否编入「显示布局边界」调试叠加层 | `make release LAYOUT_BOUNDS=0` 可剔除 | -| `NO_EXCEPTIONS` | `1` | 追加 `-fno-exceptions` | 必须配合 `MemProbe` 的预检验 | -| `LTO` | `1` | 追加 `-flto`(编译 + 链接) | **切换需 `make clean`** | - -派生变量: - -| 变量 | `LTO=1` | `LTO=0` | -| --- | --- | --- | -| `FUNC_SECTIONS` | 空 | `-ffunction-sections -fdata-sections` | -| `ASM_LISTING` / `ASM_LISTING_C` | 空 | `-Wa,-a,-ad,-ahlms=$(@:%.o=%.lst)` | -| `GC_SECTIONS` | `-Wl,--gc-sections` | 同左 | - -> `GC_SECTIONS` / `FUNC_SECTIONS` / `ASM_LISTING*` 都定义在 `LDFLAGS` 之后。 -> 它们是递归变量(`=` / `+=`),make 在展开 recipe 时才求值,所以顺序不影响生效; -> 之所以还是放在后面,是为了让"三个开关"集中在同一处,不散落在两个位置。 - -### 2.2 两个已复现的坑 - -**坑 1:切换 LTO 必须 `make clean`。** -LTO 与非 LTO 目标文件混链会让 GCC 15.2.0 在 `binds_to_current_def_p (symtab.cc:2598)` 处 ICE。 - -**坑 2:`-flto` 与 `-ffunction-sections -fdata-sections` 同时打开也会触发同一个 ICE。** -即使全部干净重编也必崩(`Utilities/Inc/DataModel.hpp:393` 的 `struct SignalData` 被指为出错点, -但那只是 COMDAT 解析的落点,不是原因)。 -**解决办法**:LTO 下 `FUNC_SECTIONS` 置空(LTO 自己就做跨单元分区与去重), -并追加 **`-flto-partition=one`**: -默认多分区 LTRANS(本项目 11 个分区)稳定复现,单分区从未复现。 -代价是 LTRANS 串行,全量构建慢约一倍。 - -> 结论:**`-flto-partition=one` 不是性能选项,是稳定性选项**,不要删。 - -### 2.3 实测体积 - -| 阶段 | 产物 | 体积 | 相对基线 | -| --- | --- | --- | --- | -| 基线 | `BaseCheck.exe` | 909,824 B | — | -| `-fno-exceptions` + `--gc-sections` + `MemProbe` | `Step1.exe` | 882,688 B | **−27,136 B** | -| 再叠加 LTO | `JustDataInspector.exe` | **788,992 B** | **−120,832 B(−13.3%)** | - -段构成(`objdump -h`,最终构建): - -| 段 | 基线 | `-fno-exc` + `gc-sections` | 最终(LTO) | -| --- | ---: | ---: | ---: | -| `.text` | 735,600 | 720,528 | **657,168** | -| `.rdata` | 49,184 | 45,072 | **35,016** | -| `.xdata` | 48,132 | 34,152 | **27,512** | -| `.pdata` | 35,424 | 34,524 | **28,524** | -| `.rsrc` | 20,656 | 20,656 | 20,656 | -| `.data` | 2,272 | 9,312 | 1,952 | -| `.bss` | 10,224 | 3,088 | 10,000 | -| `.idata` | 12,440 | 12,440 | 12,440 | -| `.tls` | 16 | 16 | 16 | -| `.reloc` | — | — | 1,700 | - -**体积溯源(超阈值时必须能回答这个问题)**: - -* `.text` 减少最多(−78 KB / −10.7%)—— 主要来自 LTO 的跨单元内联与重复代码合并。 -* `.xdata`+`.pdata`(异常展开表 + 函数表)减少 27.5 KB —— 来自 `-fno-exceptions` - 与 `--gc-sections` 丢弃未引用的函数。 -* `.rdata` 减少 14 KB —— 来自"未引用字符串/常量被 gc 掉"。 -* `.rsrc` 完全不变(图标/清单)。 - -> 预警阈值 1MB(1,048,576 B):当前 788,992 B,**余量 259,584 B(约 253 KiB)**。 - -### 2.4 `MemProbe`(预检验 + 兜底) - -| 函数 | 作用 | -| --- | --- | -| `MemAvailablePhysical()` | `GlobalMemoryStatusEx` + **200ms 缓存**(绘制路径每帧可能问多次) | -| `MemCanAllocate(bytes)` | 留出 1/8 可用内存作 headroom 后判断是否安全 | -| `MemReserveIfRoom(container, n)` | 仅供"按数据规模预留":含 `capacity()` 早退与乘法溢出保护 | -| `MemInstallTerminateHandler()` | `std::set_terminate` 装一次性模态提示后 `_Exit` | - -改动点: - -* `Utilities/Src/DataModel.cpp`:原先**唯一**的 `try/catch`(`extremes.reserve(count / 8)` - 捕 `std::bad_alloc`)→ `MemReserveIfRoom(extremes, count / 8)`。 -* `Components/PlotView/Src/PlotView.cpp`:`PlotView::AvailablePhysicalBytes()` 原有**一份完整的 - `GlobalMemoryStatusEx` + 200ms 缓存实现**(与 `MemProbe` 重复)→ 改为转发 `MemAvailablePhysical()`。 -* `Components/Application/Src/Application.cpp`:`Initialize()` 开头 `MemInstallTerminateHandler();`。 - -> 全仓已确认**无 `try/catch` 残留**(除 `MemProbe` 注释中对它的说明)。 - ---- - -## 3. L1 几何产物层 - -### 3.1 结构 - -``` -UiBox.hpp struct UiBox { rect, clip, depth, layer }; ← 纯值,无行为 - class UiBoxTable : UiTextRectSink ← 只累积几何 + 裁剪/层/遮挡 -UiDebugBounds.hpp UiDebugBoundsColor / UiDebugPaintBoundsClipped / UiDebugPaintBoxList - ↑ 唯一的消费者(调试描边),与几何解耦 -``` - -`UiComponent::BuildBoxes(UiBoxTable&)` 默认实现: - -```cpp -void UiComponent::BuildBoxes(UiBoxTable& boxes) { boxes.Add(Area(), 0); } -``` - -各组件覆盖情况: - -| 组件 | 上报内容 | -| --- | --- | -| `SignalPanel` | 表头 / 筛选框 / 列表 / 样式区 / 每个可见行 / 行内元素(复选框、色块、名称、GX 徽标、✕、折起三角、组计数)/ 滚动轨道与滑块 / 编辑框 | -| `PlotView` | 网格、GX 徽标、图例条目、**5 类浮动面板**(含逐字段)+ 面板遮挡与裁剪 | -| `Toolbar` | 区域、每个按钮、按钮内容(文本/图标) | -| `TabBar` | 区域、每个标签条、新建按钮、标签 ✕、控件树 | -| `PopupMenu` | 条目(裁到 `m_scrollEnabled ? m_scrollViewport : m_rect`)、可视区、轨道、滑块、二级子菜单 | -| `ColorPickerPopup` | 各控件 | - -### 3.2 与 `UiDebugBounds` 的边界 - -* 几何(矩形 / 层级 / 层号 / 裁剪 / 遮挡)→ `UiBox.hpp` -* 观感(配色 / 包角粗细 / 图元数量 / 抗锯齿开关)→ `UiDebugBounds.hpp` - -这样 `SignalPanel.cpp` / `PlotViewPaint.cpp` / `PopupMenu.cpp` 等**只包含 `UiBox.hpp`**, -不再把"调试描边"这个依赖拉进自己的编译单元;只有宿主 `ApplicationCommands.cpp` 同时包含两者。 - -### 3.3 零像素回归的证明 - -L1 落地前后 `JustDataInspector.exe` **字节数完全相同(788,992 B)**, -`rendercheck` 8 个场景哈希逐一相同 —— 因为这只是"把几何从调试绘制里搬出来", -没有动任何绘制顺序或图元。 - ---- - -## 4. 回归结果(最终构建上跑) - -| 探针 | 结果 | -| --- | --- | -| `Tools/uicheck` | **69 passed, 0 failed** | -| `.preflight/lbcheck` | **21 passed, 0 failed** | -| `.preflight/lbresid` | **5 passed, 0 failed** | -| `.preflight/lbzorder` | **4 passed, 0 failed** | -| `.preflight/lbcovr` | **7 passed, 0 failed** | -| `.preflight/lbpanel` | **10 passed, 0 failed** | - -`Tools/rendercheck`(逐像素哈希,必须与基线逐一相同): - -``` -A FAED59DF284153DB B E6443BFE4E3955F8 C 96A0AA8C402F1729 D E3D5DBD998E43720 -E DA0583CB13C26A6F F FFB822159F604D1D G 19F8DE8FE3A8DF18 H DBBDDE4CB2C97C92 -FUNC 7/7 PASS, exit=0 -``` - -构建:`make release` 零 error / 零 warning;`make debug` 零 warning。 - -> ⚠️ 探针 `build.ps1` 链接的是 `Build/Release/*.o`。**LTO 下这些是 LTO 字节码**, -> 因此 7 个探针脚本的链接参数里都补了 `-flto -flto-partition=one`。 -> 若把 `LTO` 关掉,这两个参数无害(可与普通目标文件混链)。 - ---- - -## 5. 本轮审计清单 - -| 项 | 处理 | -| --- | --- | -| `MemProbe.hpp` / `.cpp` 里"省 40~60KB"的估算 | 改为实测值 27,136 B(909,824 → 882,688) | -| `MemProbe.hpp` 的 `#include ` | 未使用,删除 | -| `MemProbe.cpp` 的 `#include ` | 未使用,删除 | -| `UiDebugPaintBounds(g, rect, depth)` | **零调用者**(唯一调用者是它自己),删除;只保留带 `clip` 的版本 | -| `TabBar::BuildBoxes` / `Toolbar::BuildBoxes` / `PlotView::BuildBoxes` / `SignalPanel::BuildBoxes` 的"(调试用)" | 改为"几何 box"(层级已变,它们不再关心调试) | -| `PlotView::SetForceFullRepaint` 文档 | 补写"为什么保留而不优化"(L2 缓做的理由) | -| 全仓旧名 `UiDebugBoxList` / `UiDebugBox` / `CollectDebugBoxes` | 源码零残留(仅 `Documents/agent_mem.md` 历史记录保留原样) | - ---- - -## 6. 已知问题 / 未做 - -1. **L2 未做**(见 Plan §5)。`SetForceFullRepaint` 仍在,布局边界打开时 `PlotView` 走整幅重绘。 -2. `Tools/` 与 `.preflight/` 下的探针脚本未被 git 跟踪(本地验证工具), - 本轮给它们补的 `-flto` 参数**不会进版本库**。若 LTO 长期默认开启, - 建议把探针脚本纳入版本管理,否则下次 `make clean` 后重建探针需要手工补参数。 -3. `.lst` 汇编清单在 `LTO=1` 时不再生成(`lto-wrapper` 会丢弃 `-Wa` 参数)。 - 需要看汇编时用 `make release LTO=0`。 diff --git a/Documents/UI_Refactor_Report.md b/Documents/UI_Refactor_Report.md deleted file mode 100644 index 12777d8..0000000 --- a/Documents/UI_Refactor_Report.md +++ /dev/null @@ -1,157 +0,0 @@ -# Just DataInspector UI 重构 —— 最终报告 - -> 计划见 `UI_Refactor_Plan.md`,过程与实测数据见 `UI_Refactor_Progress.md`。 -> 日期:2026-10-29 - ---- - -## 1. 一句话结论 - -在**不拆 DLL、不改优化等级、不改变任何绘制像素**的前提下, -把界面从"宿主点名各组件 + 各算一遍几何"改成"组件协议 + 几何产物层", -同时把产物从 **909,824 B 降到 788,992 B(−120,832 B,−13.3%)**, -并用 6 组、共 116 条断言 + 8 个逐像素哈希证明零回归。 - ---- - -## 2. 收益 - -### 2.1 结构 - -| 之前 | 之后 | -| --- | --- | -| 宿主 `if` 链逐个点名 4 个大组件,同一件事要写 5 遍 | 一张有序组件表,新增组件不改宿主 | -| 几何分散在 `Paint*` / `HitTest` / `CollectDebugBoxes` 三处,**三份实现** | `BuildBoxes()` 一处产出,绘制/命中/调试同源 | -| "调试"概念渗透进每个组件 | 组件只吐几何;"怎么画"收敛在 `UiDebugBounds` | -| 单个 `.cpp` 数千行 | 按职责分片(PlotView 5 片、SignalPanel 3 片…) | - -### 2.2 体积 - -| 项 | 数值 | -| --- | ---: | -| 基线 | 909,824 B | -| 最终 | **788,992 B** | -| 减少 | **120,832 B(−13.3%)** | -| 相对 1MB 预警阈值的余量 | 259,584 B(约 253 KiB) | - -构成变化的解释见 `UI_Refactor_Progress.md` §2.3(`.text` −78 KB 来自 LTO, -`.xdata`+`.pdata` −27.5 KB 来自关异常 + gc,`.rdata` −14 KB 来自 gc 掉未引用常量)。 - -### 2.3 健壮性 - -* `-fno-exceptions` 之后没有"静默消失":OOM 走 `MemInstallTerminateHandler()`, - 弹一次带建议的模态提示再干净退出。 -* 唯一一处 `try/catch` 改成**预检验**(`MemReserveIfRoom`),语义等价且不依赖异常。 -* `PlotView` 里重复实现的"可用物理内存查询"被去重到 `MemProbe`(单一实现、单一缓存)。 - ---- - -## 3. 关键决策与依据 - -### 3.1 拒绝"拆 DLL 换体积" - -用户的定位是**单文件轻量工具**。拆 DLL 只把字节从 `.exe` 挪到旁边的 `.dll`, -总交付物更大、部署更脆(版本错配、路径缺失),且 `-static` 换来的"拷一个文件就能跑" -这一核心体验会丢掉。**因此体积优化的边界是"编译期与链接期手段",不含产物拆分。** - -### 3.2 关异常 → 必须预检验,而不是"少写 try/catch" - -`-fno-exceptions` 的真实代价不是"少了个 try",而是**分配失败从"可恢复"变成"进程终止"**。 -所以必须成对交付: - -1. 所有"按数据规模预留"的路径先问内存(`MemReserveIfRoom`); -2. 遗漏路径有兜底提示(terminate handler)。 - -只做第 1 步是不够的 —— 事后新增的代码很容易忘记检查。 - -### 3.3 LTO 用 `-flto-partition=one` 换稳定性 - -`-flto` 单独用会在 COMDAT 解析处 ICE(`binds_to_current_def_p, symtab.cc:2598`), -根因是多分区 LTRANS;单分区稳定(实测 `--gc-sections` 开/关都得到 791,552 B, -说明这一档体积收益来自 LTO 本身而不是分区数)。 -代价是 LTRANS 串行 —— 这是**用构建时间换"构建能过"**,值得。 - -同时确认:`-flto` 与 `-ffunction-sections -fdata-sections` 不能同时开(同一个 ICE), -LTO 下把 `FUNC_SECTIONS` 置空。 - -### 3.4 L1 而不是"给所有元素加基类" - -`PlotView` 有 4 层自持位图缓存、`SignalPanel` 有行位图缓存,它们要求"像素由组件自己负责"。 -把几何塞进继承体系会和缓存失效体系打架(历史上"后台渲染覆盖用户平移""命中错位" -就是这类冲突)。`UiBox` 是**纯值类型**,因此可以被绘制、命中、将来的脏矩形共用, -却不需要虚函数与生命周期管理。 - -### 3.5 L2 缓做 - -见 `UI_Refactor_Plan.md` §5。一句话:**受益方只有调试开关打开时的帧率, -而正确实现要穿透 `PlotView` 全部四条快路径与两层缓存。** -在"用户反复报告过拖拽错位/贴图残留"的背景下,这笔风险不值得为一个调试特性去承担。 - ---- - -## 4. 验证方法与结果 - -### 4.1 为什么"逐像素哈希"是唯一可信的验收 - -"看起来一样"不可作为判据:这类重构最容易出的问题是**1px 偏移**与**边界条件** -(例如某个元素在某个 DPI 下多画了一条线)。`Tools/rendercheck` 对 8 个典型场景 -(空白 / 单网格 / 多网格 / 分组 / 面板展开 / 菜单 / 拾色器 / 样式区)渲染到内存位图后 -取 FNV 哈希,**与基线逐一比对**,因此任何像素级差异都会暴露。 - -结果:8 个哈希与改造前**逐一相同**,且 `JustDataInspector.exe` 在 L1 前后**字节数完全相同**。 - -### 4.2 断言矩阵 - -| 探针 | 断言 | 覆盖 | -| --- | ---: | --- | -| `uicheck` | 69 | 协议行为、命中、几何同源、浮层表面 | -| `lbcheck` | 21 | 边界收集 + **关闭后逐像素还原** | -| `lbresid` | 5 | 同一状态连续两帧逐像素相同(残留判据) | -| `lbzorder` | 4 | 浮层压住本体 | -| `lbcovr` | 7 | 覆盖完整性(时间轴/滚动条/胶囊/文本框/缓存行) | -| `lbpanel` | 10 | 面板遮挡、面板内容裁剪、滚动不闪烁、菜单条目裁剪 | -| 合计 | **116** | — | - -### 4.3 方法论教训(已写入 `agent_mem.md`) - -* **体积结论必须区分稳态与瞬时**;改编译开关后**必须 `make clean`**(混链会 ICE)。 -* **判断"残留"不能用"拖拽帧 vs 松开后一帧"**(预热进度不同会产生几十万像素合法差异), - 正确判据是**同一状态连续两帧逐像素相同**。 -* **`--gc-sections` 已经承担了内联去重**(`UiPointInRect` 等自有符号重复组数为 0), - 不必为了体积手工把 inline 实现搬进 `.cpp`。 -* **探针判"盒子越界"必须按绘制层过滤**,否则会把更上层浮层的合法越界算成错误。 - ---- - -## 5. 风险与缓解 - -| 风险 | 缓解 | -| --- | --- | -| LTO 构建偶发 ICE(编译器缺陷,非代码缺陷) | `-flto-partition=one`;`LTO=0` 可一键回退(体积回到 882,688 B,仍远低于阈值) | -| `-fno-exceptions` 下遗漏一处分配检查 | terminate handler 兜底 + 全仓已无 `try/catch` + `MemReserveIfRoom` 是唯一预留入口 | -| LTO 构建时间翻倍 | 日常开发可用 `make release LTO=0`;提交前用 `LTO=1` 校验体积 | -| 探针脚本未纳入版本库,重建时缺 `-flto` | 已写入 Progress §6 待办;脚本本身只是本地验证工具 | -| L2 未做,布局边界打开时帧率下降 | 该开关是调试特性,默认关闭;文档已记录取舍 | - ---- - -## 6. 遗留 - -1. **L2 `UiDamage` + S4 叠加层**:设计已定型(`kMaxRects=12`、超限退化为包围盒、 - 整数像素外扩 1px、只增不减;`PlotView::Paint(g, UiDamage&)` 契约;6 条路径各自 `damage.Add`), - 但未实施。`UiBoxTable` 的几何可以直接喂给它,不需要重新收集。 -2. **探针脚本入版本库**(`Tools/*/build.ps1`、`.preflight/*/build.ps1`)。 -3. **`.lst` 汇编清单在 LTO 下不可用**:需要时用 `make release LTO=0`,这一点已在 Makefile 注释中写明。 - ---- - -## 7. 交付确认 - -```powershell -git status --short # 干净 -make clean; make release TARGET_FILE_NAME=JustDataInspector -j16 -# → 零 error / 零 warning,788,992 B -``` - -唯一产物:`Build/Release/JustDataInspector.exe`(含图标、清单,`-static` 链 CRT)。 -**无任何新增 DLL 依赖。** -- 2.54.0 From 86ff0291baebda4f165bf2c2740c332936ea3a83 Mon Sep 17 00:00:00 2001 From: "DeepSeek-V4.1-Flash" <> Date: Wed, 4 Nov 2026 02:44:23 +0800 Subject: [PATCH 02/22] =?UTF-8?q?docs(review):=20=E6=96=B0=E5=A2=9E?= =?UTF-8?q?=E4=BB=A3=E7=A0=81=E5=AE=A1=E6=9F=A5=E8=AE=A1=E5=88=92=E4=B8=8E?= =?UTF-8?q?=E8=BF=9B=E5=BA=A6=E6=96=87=E6=A1=A3=EF=BC=88Code=5FReview=20?= =?UTF-8?q?=E5=88=86=E6=94=AF=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 删除已完成使命的 UI_Refactor_Plan/Progress/Report(已在 dev 提交 764e5be)。 - 新增 Documents/Code_Review_Plan.md:结构 + 性能 + 体积的节点化计划, 含已确认的问题事实(dynamic_cast 在最小复现里 +75,264 B、 cp-demangle.o 56,320 B、每个 EdgePanelKind 出现 21-26 次等)。 - 新增 Documents/Code_Review_Progress.md:基线数字与节点记录。 --- Documents/Code_Review_Plan.md | 176 ++++++++++++++++++++++++++++++ Documents/Code_Review_Progress.md | 30 +++++ 2 files changed, 206 insertions(+) create mode 100644 Documents/Code_Review_Plan.md create mode 100644 Documents/Code_Review_Progress.md diff --git a/Documents/Code_Review_Plan.md b/Documents/Code_Review_Plan.md new file mode 100644 index 0000000..af82755 --- /dev/null +++ b/Documents/Code_Review_Plan.md @@ -0,0 +1,176 @@ +# Code Review 计划(分支 `Code_Review`) + +> 目标:在不引入性能回归、不改变用户可见行为的前提下,对全项目做一次**结构 + 性能 + 体积** +> 的查漏补缺,并把「改一个功能要动 40 处」的屎山点收敛成表驱动/单一实现。 +> +> 基线(本计划开始时): +> - 分支 `Code_Review`,起点 `764e5be`(dev 已删除过时的 `UI_Refactor_*` 三份文档)。 +> - `make release` 产物 **890,880 B**(`.text` 753,136 B、`.rdata` 36,936 B、`.rsrc` 20,656 B、 +> `.pdata` 30,420 B、`.xdata` 29,496 B、`.idata` 12,856 B)。 +> - 源码约 **5.6 万行**;最大单文件 `PlotViewPaint.cpp` 6,561 行、`PlotView.hpp` 4,654 行。 +> +> 约束: +> - 每完成一个节点**编译验证(release + debug 零警告)**并**提交**,同时更新 +> `Documents/Code_Review_Progress.md`。 +> - 性能相关节点必须给出**同一次运行内的相对量**(历史教训:绝对毫秒阈值不可靠)。 +> - 不占用用户的鼠标/键盘/屏幕(只用纯控制台探针或源码级核对)。 + +--- + +## 一、已确认的问题(本计划的事实基础) + +### A. 体积 + +| 项 | 实测 | 说明 | +| --- | --- | --- | +| `dynamic_cast`(`Toolbar.cpp:370`) | **+75,264 B** | 最小复现程序:39,936 B → 115,200 B。它会链入 RTTI 元数据 + `cp-demangle.o`(map 里 **56,320 B**)。而该处 `Button::widget` **本来就是 `UiButtonWidget*`** ⇒ 转换完全多余。 | +| `-fno-rtti` | 未启用 | 移除上一条后即可开启,进一步去掉 `type_info` 表。 | +| `.pdata` + `.xdata` | 60 KB | `-fno-exceptions` 已开;SEH 展开表仍存在。可尝试 `-fno-asynchronous-unwind-tables`(历史上链接失败,需复核)。 | +| `libmingwex` 的 `strtodg/gdtoa/mingw_wpformat` | 27.8 KB | 来自 `wcstod` / `wprintf` 类调用;可评估用自写解析替代 `wcstod` 的收益(公式/输入框解析)。 | +| `.rsrc` | 20.7 KB | 图标(PNG 帧)+ manifest + 版本信息,已优化过。 | +| 重复常量/重复实现 | 多处 | `kFormulaEnvelopeBuckets` 已清理;仍有 `ClampDouble` 两份实现等。 | + +### B. 性能(结构性热点) + +1. **面板开关/参数处理是 6 个 `EdgePanelKind` × ~20–26 处 switch 分支**(实测每个 kind 出现 + 21–26 次,全部由手写 `if/switch` 维护)。这是**新增功能必然漏改 2–3 处**的根本原因 + (历史已发生 4 次:`MARK` 漏拖动、`RESAMPLE` 漏拖动+关闭、`CLIP` 漏滚轮+缓存键)。 +2. **`SceneContentHash` 里 6 段几乎相同的逐面板混入循环**(`PlotViewCacheKey.cpp`)。 +3. **`EdgePanelLayoutFor` 是巨型分支**,`EdgePanelLayout` 结构体带 20+ 个按面板类型区分的 + 矩形数组;高度算式与几何算式分开写(历史上多减一个 pad 造成空白空白 BUG)。 +4. **`PlotView` 有 30+ 个缓存**,每个都手写「键 + DC + 位图 + 贴图 + 失效」五件套; + 失效路径散落在 8 个文件里,`m_sceneValid=false` 出现 6 处。 +5. `ApplicationCommands.cpp` **2,165 行**混杂:标签页、数据派生、菜单、工具栏、键盘、鼠标。 +6. `PlotView.hpp` 4,654 行(声明 + 内联实现),单文件认知负担极大。 + +### C. 过时/错误注释与命名(抽样已确认) + +- `agent_mem.md` §3「目录结构」仍写着已不存在的 `Utilities/CsvTable/`、`Utilities/DataModel/` + 等目录;§2 写 `-Os` 而实际是 `-Oz`。 +- 源码中留有历史轮次的「本轮」「上一轮」叙述(对当前读者无意义),以及被推翻方案的注释。 +- `PlotViewInternal.hpp` 里 `kFormulaEnvelope*` 注释已随实现迁移,需同步。 + +--- + +## 二、节点计划 + +> 优先级:**S**(安全、收益明确、风险低)→ **A**(结构调整,中风险,需逐步验证)→ +> **B**(深度重构,必须有可验证的前后一致性)。 + +### 阶段 0:建立基线(节点 P0) + +- P0.1 记录基线:release/debug 体积、`.text/.rdata` 等节表、关键路径的可复跑测量程序 + (纯控制台,链接 `Build/Release/*.o`,不入库)。**已完成基线的部分见下表**。 +- P0.2 建立 `Documents/Code_Review_Progress.md`,每节点完成后追加一行结论 + 数字。 + +### 阶段 1:体积(节点 P1–P5,安全优先) + +- **P1 移除 `dynamic_cast` + 开启 `-fno-rtti`** + - 把 `Toolbar::BuildBoxes` 的 `dynamic_cast` 改为直接用 + `Button::widget`(已是该类型)或给 `UiWidget` 加虚函数 `ContentBounds()`。 + - Makefile `CXXFLAGS += -fno-rtti`;全仓确认无 `typeid`/`dynamic_cast`/`type_info`。 + - 预期 **−70~80 KB**。验收:产物下降 ≥60 KB;功能逐项核对(工具栏按钮、布局边界调试)。 +- **P2 体积审计第二轮** + - 复核 `-fno-asynchronous-unwind-tables`(历史上链接失败,试 `-fno-unwind-tables` 组合)。 + - 评估 `wcstod` 替身(自写定点/浮点解析,仅在公式与输入框使用)去掉 + `strtodg/gdtoa/gethex/hexnan`(约 20 KB)。 + - 评估 `--gc-sections` 在 LTO 下是否已生效(读 map 确认无死节)。 + - 验收:逐项给出「测得的字节数 + 是否采纳」的结论,**不接受「感觉变小」**。 +- **P3 重复实现归并** + - `ClampDouble` 两份(`PlotViewInternal.hpp` / `UiLayout.cpp`)→ 统一到 `Shared`。 + - 扫描全部 `static`/匿名命名空间重复工具函数,合并为单一实现。 + - 重复字符串/常量字面量、重复的三元式开关表。 +- **P4 死代码与未使用成员清理** + - 用非 LTO 对象 + `nm` 反查「定义但从未被引用」的函数;删除确认无引用的实现。 + - 清理 `PlotView.hpp` 中已无人读取的成员/字段。 +- **P5 资源与启动数据** + - `.rsrc` 复核(图标帧数、manifest、版本字符串);`.idata` 导入表复核。 + +### 阶段 2:性能(节点 P6–P10) + +- **P6 建立可复跑的渲染性能探针(纯控制台)** + - 目标:能在**不弹窗口**的情况下测量 `BuildLaneVisuals` / `SceneContentHash` / + `EdgePanelLayoutFor` / 面板绘制 / 信号面板绘制的耗时。 + - 方式:链接 `Build/Release/*.o` + 手工构造 `DataSet`/`TabConfig`,只调用纯函数与 + `PlotView` 的非窗口方法(避免 `Application::Initialize` 触发模态框,见历史教训)。 + - 所有断言用**同一进程内的相对量**。 +- **P7 面板元数据表驱动(核心)** + - 新增 `EdgePanelSpec`(每 kind 一行:命令 id、默认尺寸、是否列表型、footer 类型、 + 滚动字段偏移、面板状态字段偏移、绘制函数指针、命中函数指针)。 + - 把 6×~20 处 `switch (kind)` 收敛为「查表 + 3 个通用实现(列表型 / 表格型 / 固定行型)」。 + - 验收:**逐像素**对比改造前后面板(离屏渲染哈希);新增一个假 kind 只需加一行表项即可 + 被绘制/命中/拖动/关闭/滚动全覆盖(用编译期断言或探针验证)。 +- **P8 `SceneContentHash` 混入收敛** + - 把 6 段重复循环改为「按面板表遍历 + 每面板一个 `MixContent()`」。 + - 验收:改造前后哈希值**逐位相同**(同一状态)。 +- **P9 `EdgePanelLayoutFor` 表驱动 + 高度/几何同源** + - 高度算式与内容几何共用同一份 `PanelMetrics` 计算(消除重复减 pad 类 BUG)。 + - 验收:探针打印「内容区下边界 vs 按钮上边界」的像素差,与改造前一致。 +- **P10 缓存五件套抽象(低风险版)** + - 抽出 `OffscreenCache`(DC/位图/键/贴图/失效),**只替换三处结构完全相同的** + (derived / decor / panel),保留 scene 与 lane layer 不动(热路径最复杂)。 + - 验收:逐像素一致 + 帧时间不回退。 + +### 阶段 3:架构(节点 P11–P14) + +- **P11 `ApplicationCommands.cpp` 拆分** + - → `ApplicationTabs.cpp`(标签页)/ `ApplicationDerive.cpp`(派生/重采样/截取/克隆)/ + `ApplicationCommands.cpp`(命令分发)/ `ApplicationMenus.cpp`(上下文菜单与设置菜单)。 + - 纯搬移,零行为变更;Makefile 无需改动(按目录通配收集)。 +- **P12 `PlotView.hpp` 拆分** + - 把 `EdgePanelLayout`、派生曲线缓存条目、图层结构体等**纯数据结构**搬到 + `PlotViewTypes.hpp`;把 `PlotView` 的内联小函数按关注点分块放到 `PlotViewInline.hpp`。 + - 目标:`PlotView.hpp` ≤ 2,500 行。 +- **P13 UI 基础层职责核对** + - 核对 `UiWidget` / `UiComponent` / `UiLayout` / `UiBox` / `UiSurface` / `UiButtonWidget` / + `UiControls` 的边界是否重叠;合并确认冗余的(只保留一套矩形/命中/布局原语)。 + - 明确依赖方向:`Components/*` → `Components/Shared` → `Utilities`(校验无反向)。 +- **P14 绘制/功能组件边界** + - `PlotViewPaint` / `Raster` / `Panels` / `Features` / `Interaction` 的职责再核对; + 把「面板几何」与「面板绘制」的交叉依赖理顺(几何 → 共享 `PanelMetrics`)。 + - 把散落在 `PlotView` 里的「颜色/线宽/胶囊样式」常量收敛到 `Theme`/`PaintUtil`。 + +### 阶段 4:注释与命名清理(节点 P15) + +- **P15 注释/命名审计** + - 删除「本轮/上一轮」叙述、被推翻方案的描述、与实现不符的注释。 + - 统一命名:`*Dc`/`*DC`、`Lane*` vs `Grid*`、`Edge*` vs `Panel*` 等不一致处。 + - 确保所有注释与当前实现一致(重点:`PlotViewInternal.hpp`、`PlotViewCacheKey.cpp`)。 + - 同步更新 `agent_mem.md` 的 §2/§3(已经不准确的目录结构与 `-Os`)。 + +### 阶段 5:验证与报告(节点 P16) + +- **P16 最终验证与报告** + - release + debug 零警告;产物体积对比;关键路径探针全部通过。 + - 生成 `Documents/Code_Review_Report.md`:逐节点写「改了什么 / 为什么 / 效果 / + 未做的事与原因」,并给出体积与性能的前后对照表。 + +--- + +## 三、验收标准(硬性) + +1. `make release` 与 `make debug` **零 error、零 warning**。 +2. 产物体积**必须下降**(目标:从 890,880 B 降到 ≤ 780 KB;若某项无法达成,在报告里 + 说明实测字节数与原因)。 +3. 不改变任何用户可见行为(面板几何、绘制像素、交互语义一致)。 +4. 每条性能结论都必须有**同进程相对量**或**逐像素/逐位一致性**证据。 +5. 每个节点单独提交,提交信息用中文、说明改动与验证方式。 + +--- + +## 四、风险与对策 + +| 风险 | 对策 | +| --- | --- | +| 面板表驱动改造引入像素差异 | 改造前后用离屏渲染哈希对比;先改 `CLIP`/`MARK` 两个最简单面板做样板,再推广 | +| `-fno-rtti` 破坏第三方/库代码 | 全仓 grep 确认;先单独提交,失败可独立回退 | +| 缓存抽象拖慢热路径 | 只抽象非热路径(derived/decor/panel),scene/lane 不动;每次改完测帧时间 | +| 大文件拆分时误改逻辑 | 纯搬移(不重写函数体),逐文件 diff 核对;先拆 `ApplicationCommands` | +| 探针触发模态「内存不足」框 | 绝不在探针里调 `Application::Initialize()`(历史教训);只构造数据层/纯函数 | + +--- + +## 五、进度索引 + +进度与每节点的实测数字见 `Documents/Code_Review_Progress.md`。 +最终结论见 `Documents/Code_Review_Report.md`。 diff --git a/Documents/Code_Review_Progress.md b/Documents/Code_Review_Progress.md new file mode 100644 index 0000000..2d14c97 --- /dev/null +++ b/Documents/Code_Review_Progress.md @@ -0,0 +1,30 @@ +# Code Review 进度(分支 `Code_Review`) + +> 每个节点完成后追加:节点号、改动摘要、验证方式、实测数字。 +> 计划见 `Documents/Code_Review_Plan.md`;最终报告见 `Documents/Code_Review_Report.md`。 + +## 基线(起点 `764e5be`) + +| 项 | 数值 | +| --- | --- | +| Release 产物 | **890,880 B** | +| `.text` | 753,136 B | +| `.rdata` | 36,936 B | +| `.pdata` | 30,420 B | +| `.xdata` | 29,496 B | +| `.rsrc` | 20,656 B | +| `.idata` | 12,856 B | +| Debug 产物 | 1,801,617 B | +| 源码规模 | 约 5.6 万行 | + +关键发现(详见计划 §一): + +* `dynamic_cast`(`Toolbar.cpp:370`)使最小复现程序从 39,936 B 涨到 115,200 B + ⇒ **+75,264 B**;map 中 `cp-demangle.o` 占 **56,320 B**。 +* 每个 `EdgePanelKind` 在源码中出现 21–26 次(6 种面板 ⇒ 约 140 处手写分支)。 + +--- + +## 节点记录 + +(下面按完成顺序追加) -- 2.54.0 From 5de3763e72dabaeda32b50495d1ecd299407b49b Mon Sep 17 00:00:00 2001 From: "DeepSeek-V4.1-Flash" <> Date: Wed, 4 Nov 2026 02:48:59 +0800 Subject: [PATCH 03/22] =?UTF-8?q?perf(size):=20=E7=A7=BB=E9=99=A4=E5=A4=9A?= =?UTF-8?q?=E4=BD=99=E7=9A=84=20dynamic=5Fcast=20=E5=B9=B6=E7=9C=81?= =?UTF-8?q?=E6=8E=89=2056KB=20=E7=9A=84=20C++=20=E5=90=8D=E5=AD=97?= =?UTF-8?q?=E5=8F=8D=E4=BF=AE=E9=A5=B0=E5=99=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 两处独立收益: 1. Toolbar::BuildBoxes 里唯一的 dynamic_cast 是**多余强转**(Button::widget 本来就是 UiButtonWidget*),却让全项目必须开 RTTI。改为直接遍历 m_buttons(与控件树同源)。 最小复现程序实测:39,936 B -> 115,200 B(+75,264 B)。 2. RTTI 关掉后 cp-demangle.o 仍然在链接里 —— 真凶是 libstdc++ 的 vterminate.o (引用 __cxa_demangle)。本文件自行定义 __gnu_cxx::__verbose_terminate_handler(), 让它不再被拉入(本应用启动后会换成自己的 terminate 处理,行为等效)。 Makefile 新增 NO_RTTI=1(默认开启)-> CXXFLAGS += -fno-rtti。 实测:Release 890,880 -> 829,440 B(-61,440);.text 753,136 -> 703,616(-49,520); .rdata 36,936 -> 26,616(-10,320);Debug 1,801,617 -> 1,734,578(-67,039)。 release/debug 全量重建零警告。 --- Components/Toolbar/Src/Toolbar.cpp | 18 +++++---- Makefile | 9 +++++ Utilities/Src/MemProbe.cpp | 62 ++++++++++++++++++++++++++---- 3 files changed, 73 insertions(+), 16 deletions(-) diff --git a/Components/Toolbar/Src/Toolbar.cpp b/Components/Toolbar/Src/Toolbar.cpp index 62cef66..e55276f 100644 --- a/Components/Toolbar/Src/Toolbar.cpp +++ b/Components/Toolbar/Src/Toolbar.cpp @@ -359,19 +359,21 @@ namespace jdi return; } // ⚠️ 跳过根控件:它的矩形与工具栏区域完全重合(已经被上一层登记过)。 - for (const std::unique_ptr& button : m_root->Children()) + // + // ⚠️⚠️ **这里绝不能出现 `dynamic_cast`**:旧写法用它把 `UiWidget*` 转成 + // `UiButtonWidget*`,但 `Button::widget` 本来就是 `UiButtonWidget*`。 + // 一个多余的转换会链入 RTTI 元数据 + `cp-demangle.o`(Release map 里 + // **56,320 B**;最小复现程序实测 **+75,264 B**),并逼得全项目无法用 + // `-fno-rtti`。现在改为直接遍历 `m_buttons`(与控件树同源,且无需转换)。 + for (const Button& spec : m_buttons) { - if (button == nullptr) + if (spec.widget == nullptr) { continue; } - boxes.Add(button->Bounds(), 1); + boxes.Add(spec.widget->Bounds(), 1); // 按钮**内容**(文本或齿轮图标)单独上报:它是居中绘制的,与按钮矩形不同。 - const UiButtonWidget* raw = dynamic_cast(button.get()); - if (raw != nullptr) - { - boxes.Add(raw->ContentRect(), 2); - } + boxes.Add(spec.widget->ContentRect(), 2); } } diff --git a/Makefile b/Makefile index f325ccf..bfde428 100644 --- a/Makefile +++ b/Makefile @@ -192,6 +192,15 @@ ifeq ($(strip $(NO_EXCEPTIONS)), 1) CXXFLAGS += -fno-exceptions endif +# ---- RTTI:全项目不使用 `dynamic_cast` / `typeid` ---- +# 2026-11-04 审查:唯一的 `dynamic_cast`(Toolbar::BuildBoxes)是多余的强转, +# 已改为直接使用已有的 `UiButtonWidget*`。RTTI 会链入 `cp-demangle.o` +# (Release map 实测 56,320 B)+ `type_info` 表,最小复现程序实测 +75,264 B。 +NO_RTTI = 1 +ifeq ($(strip $(NO_RTTI)), 1) +CXXFLAGS += -fno-rtti +endif + # 丢弃未被引用的函数/数据(非 LTO 时还需要配合 -ffunction-sections 才有粒度)。 GC_SECTIONS = -Wl,--gc-sections diff --git a/Utilities/Src/MemProbe.cpp b/Utilities/Src/MemProbe.cpp index 0792d18..c841a0b 100644 --- a/Utilities/Src/MemProbe.cpp +++ b/Utilities/Src/MemProbe.cpp @@ -12,6 +12,35 @@ #include #include +namespace +{ + /** + * @brief 「未捕获异常 / 分配失败」的可见提示(`std::terminate` 的兜底路径)。 + * + * ⚠️ **为什么放在全局匿名命名空间**:下面要在 `namespace __gnu_cxx` 里覆盖 + * libstdc++ 的默认终止处理器,而 `__gnu_cxx` 必须是**全局**命名空间 + * (不能嵌套在本项目自己的 `jdi` 里)。 + * + * ⚠️ 本来这个函数由 libstdc++ 的 `__gnu_cxx::__verbose_terminate_handler()` 提供, + * 但它所在的 `vterminate.o` 会链入 `cp-demangle.o` + * (Release map 实测 `.text` 49,600 + `.rdata` 6,720 ≈ **56 KB** 的 C++ 名字反修饰器, + * 对一个不打印栈回溯的 GUI 程序毫无用处)。 + * 本文件自行定义该函数 ⇒ 归档成员 `vterminate.o` 不再被拉入 ⇒ demangler 消失 + * (2026-11-04 代码审查,实测 −55 KB 量级)。 + * + * ⚠️ 这里**不能**用任何会再次分配内存的机制(此刻堆可能已不可用): + * 只用静态字面量与 Win32 API。 + */ + void MemTerminateFallbackImpl() + { + MessageBoxW(nullptr, + L"内存不足:本次操作需要的内存超过系统可用量。\n\n" + L"建议:减少同时显示的网格数或信号数,或关闭占用内存较大的程序后重试。", + L"Just DataInspector", MB_ICONERROR | MB_OK | MB_SETFOREGROUND); + std::_Exit(EXIT_FAILURE); + } +} // namespace + namespace jdi { @@ -32,18 +61,13 @@ namespace jdi std::atomic g_memCachedBytes{0}; /** - * @brief terminate 处理:给出可见提示后退出。 + * @brief 应用安装的 terminate 处理(`MemInstallTerminateHandler` 注册)。 * - * ⚠️ 这里**不能**用 MessageBoxW 之外的任何复杂机制:此刻堆可能已不可用, - * 连 `std::wstring` 都不该构造(字面量是静态数据,安全)。 + * ⚠️ 与文件末尾的 `__gnu_cxx::__verbose_terminate_handler()` 覆盖共用同一条提示路径。 */ void OnTerminate() { - MessageBoxW(nullptr, - L"内存不足:本次操作需要的内存超过系统可用量。\n\n" - L"建议:减少同时显示的网格数或信号数,或关闭占用内存较大的程序后重试。", - L"Just DataInspector", MB_ICONERROR | MB_OK | MB_SETFOREGROUND); - std::_Exit(EXIT_FAILURE); + MemTerminateFallbackImpl(); } } // namespace @@ -99,3 +123,25 @@ namespace jdi } } // namespace jdi + +/** + * @brief **覆盖** libstdc++ 的默认终止处理器(省掉 56 KB 的 C++ 名字反修饰器)。 + * + * `std::terminate()` 定义在 `eh_terminate.o` 里,它引用 `__gnu_cxx::__verbose_terminate_handler()`。 + * 该符号原本由归档成员 `vterminate.o` 提供,而 `vterminate.o` 又引用 `__cxa_demangle` + * ⇒ 链接器被迫拉入 `cp-demangle.o`(Release map 实测 `.text` 49,600 B + `.rdata` 6,720 B)。 + * 我们在自己的目标文件里提供同名定义,`eh_terminate.o` 的未定义引用就被满足, + * `vterminate.o` 与整个 demangler 都不会进入最终产物。 + * + * ⚠️ 必须定义在**全局** `__gnu_cxx` 命名空间(不能嵌套在 `jdi` 内)。 + * ⚠️ 应用启动后会通过 `jdi::MemInstallTerminateHandler()` 换上自己的处理函数, + * 因此这个默认实现只在「启动早期崩溃」时生效 —— 行为与 libstdc++ 的版本等效 + * (libstdc++ 版会打印反修饰后的异常类型名,我们只给内存提示)。 + */ +namespace __gnu_cxx +{ + void __verbose_terminate_handler() + { + MemTerminateFallbackImpl(); + } +} // namespace __gnu_cxx -- 2.54.0 From 94fc1c45da41b9590d15b6b6dba205afabcc22d6 Mon Sep 17 00:00:00 2001 From: "DeepSeek-V4.1-Flash" <> Date: Wed, 4 Nov 2026 02:52:13 +0800 Subject: [PATCH 04/22] =?UTF-8?q?perf(size):=20Release=20=E5=85=B3?= =?UTF-8?q?=E9=97=AD=E5=BC=82=E6=AD=A5=E5=B1=95=E5=BC=80=E8=A1=A8=20+=20?= =?UTF-8?q?=E4=BF=AE=E6=AD=A3=20Debug=20=E6=9E=84=E5=BB=BA=E5=8F=82?= =?UTF-8?q?=E6=95=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. Release 专用 `-fno-asynchronous-unwind-tables`(新增 RELEASE_ONLY_OPT,只加在 Release 的两条编译规则上)。-fno-exceptions 下绝大多数函数不再需要 .pdata/.xdata: 实测 829,440 -> 793,088 B(-36,352);.pdata 30,420 -> 12,684、.xdata 28,904 -> 10,156。 Release 不再生成可靠的栈回溯(崩溃诊断);Debug 保留完整展开表。 2. 修正真实缺陷:Makefile 的 DEBUG_OPT = -O0 从未被使用 —— Debug 两条编译规则误用了 $(RELEASE_OPT)(即 -Oz),且全程没有 -g,因此所谓 Debug 构建其实只是"不 strip 的 Release"。现在 Debug 规则改用 $(DEBUG_OPT) -g,链接也加 -g。 代价:Debug 产物 1,734,578 -> 5,992,064 B(真实 -O0 -g,属预期)。 release(全量)790KB 零警告;debug(全量 -O0 -g)零警告。 --- Documents/Code_Review_Progress.md | 26 ++++++++++++++++++++++++++ Makefile | 20 +++++++++++++++----- 2 files changed, 41 insertions(+), 5 deletions(-) diff --git a/Documents/Code_Review_Progress.md b/Documents/Code_Review_Progress.md index 2d14c97..c58e905 100644 --- a/Documents/Code_Review_Progress.md +++ b/Documents/Code_Review_Progress.md @@ -28,3 +28,29 @@ ## 节点记录 (下面按完成顺序追加) + +### P1 移除 `dynamic_cast` + 关闭 RTTI —— ✅ 完成(提交 `5de3763`) + +**改动** + +1. `Toolbar::BuildBoxes` 里唯一的 `dynamic_cast` 是**多余强转**(`Button::widget` 本来就是 + `UiButtonWidget*`)。改为直接遍历 `m_buttons`(与 `m_root->Children()` 同源同序)。 +2. 仅关 RTTI **不够**:`cp-demangle.o` 仍在链接里。真凶是 libstdc++ 的 `vterminate.o` + (引用 `__cxa_demangle`),它被 `eh_terminate.o` 的 `std::terminate` 拉入。 + ⇒ 在 `Utilities/Src/MemProbe.cpp` 里**自行定义** `__gnu_cxx::__verbose_terminate_handler()`, + 归档成员 `vterminate.o` 不再被拉入,demangler 整体消失。 + 应用启动后本来就会 `std::set_terminate` 换成自己的处理函数,行为等效。 +3. `Makefile` 新增 `NO_RTTI=1`(默认开)→ `CXXFLAGS += -fno-rtti`。 + +**实测** + +| 指标 | 改前 | 改后 | 差 | +| --- | --- | --- | --- | +| Release | 890,880 B | **829,440 B** | **−61,440 B** | +| `.text` | 753,136 B | 703,616 B | −49,520 B | +| `.rdata` | 36,936 B | 26,616 B | −10,320 B | +| Debug | 1,801,617 B | 1,734,578 B | −67,039 B | + +**验证**:release + debug **全量重建**(删 `*.o`/`*.d`)零 error 零 warning; +map 中 `cp-demangle.o` 已无 `.text` 段分配。 + diff --git a/Makefile b/Makefile index bfde428..0d2df35 100644 --- a/Makefile +++ b/Makefile @@ -201,6 +201,14 @@ ifeq ($(strip $(NO_RTTI)), 1) CXXFLAGS += -fno-rtti endif +# ---- SEH 展开表:只对 Release 关闭 `-fasynchronous-unwind-tables` ---- +# 2026-11-04 审查实测:**只对 Release** 关闭后产物从 829,440 → 793,088 B(**−36,352 B**), +# 因为 `-fno-exceptions` 下绝大多数函数不再需要 `.pdata`/`.xdata`。 +# * `.pdata` 只用于栈回溯(崩溃诊断);本程序不打印栈回溯,功能不受影响。 +# * ⚠️ 代价:Release 崩溃时 WER 拿不到可靠调用栈(Debug 构建保留完整展开表)。 +# * ⚠️ 全关(`-fno-unwind-tables`)会链接失败(历史踩坑),**不要**打开。 +RELEASE_ONLY_OPT = -fno-asynchronous-unwind-tables + # 丢弃未被引用的函数/数据(非 LTO 时还需要配合 -ffunction-sections 才有粒度)。 GC_SECTIONS = -Wl,--gc-sections @@ -285,18 +293,18 @@ $(BUILD_DIR)/$(SUB_DIR_RELEASE)/$(TARGET_FILE_NAME).exe: $(RELEASE_OBJECTS) # Debug 最终可执行文件编译任务 $(BUILD_DIR)/$(SUB_DIR_DEBUG)/$(TARGET_FILE_NAME).exe: $(DEBUG_OBJECTS) @echo ====== [Debug] All File Compiled. Now Linking... ====== - $(CXX_COMPLIER) $(DEBUG_OBJECTS) -static -o $@ $(LIB_LINK) $(LDFLAGS) + $(CXX_COMPLIER) $(DEBUG_OBJECTS) -static -g -o $@ $(LIB_LINK) $(LDFLAGS) @echo ====== [Debug] Program Link Finished ====== # C Release 目标文件编译 $(BUILD_DIR)/$(SUB_DIR_RELEASE)/%.o: %.c Makefile | $(BUILD_DIR)/$(SUB_DIR_RELEASE) @echo ====== [Release] C Source File "$<" Compiling... ====== - $(C_COMPLIER) -c $(CFLAGS) $(RELEASE_OPT) $(ASM_LISTING_C) $< -o $@ + $(C_COMPLIER) -c $(CFLAGS) $(RELEASE_OPT) $(RELEASE_ONLY_OPT) $(ASM_LISTING_C) $< -o $@ # C++ Release 目标文件编译 $(BUILD_DIR)/$(SUB_DIR_RELEASE)/%.o: %.cpp Makefile | $(BUILD_DIR)/$(SUB_DIR_RELEASE) @echo ====== [Release] C++ Source File "$<" Compiling... ====== - $(CXX_COMPLIER) -c $(CXXFLAGS) $(RELEASE_OPT) $(ASM_LISTING) $< -o $@ + $(CXX_COMPLIER) -c $(CXXFLAGS) $(RELEASE_OPT) $(RELEASE_ONLY_OPT) $(ASM_LISTING) $< -o $@ # Release 资源脚本文件编译 $(BUILD_DIR)/$(SUB_DIR_RELEASE)/%.o: %.rc Makefile | $(BUILD_DIR)/$(SUB_DIR_RELEASE) @@ -304,14 +312,16 @@ $(BUILD_DIR)/$(SUB_DIR_RELEASE)/%.o: %.rc Makefile | $(BUILD_DIR)/$(SUB_DIR_RELE $(WIN_RES_COMPLIER) -c $(WIN_RESOURCE_CODEPAGE) $(foreach path,$(WIN_RESOURCE_INCLUDES_PATHS),$(addprefix -I,$(path))) $< -o $@ # C Debug 目标文件编译 +# ⚠️ 2026-11-04 审查修正:原来这里误用了 `$(RELEASE_OPT)`,导致 `DEBUG_OPT = -O0` +# **从未生效**(Debug 实际上也是 -Oz,且不带 `-g`,根本无法调试)。 $(BUILD_DIR)/$(SUB_DIR_DEBUG)/%.o: %.c Makefile | $(BUILD_DIR)/$(SUB_DIR_DEBUG) @echo ====== [Debug] C Source File "$<" Compiling... ====== - $(C_COMPLIER) -c $(CFLAGS) $(RELEASE_OPT) $(ASM_LISTING_C) $< -o $@ + $(C_COMPLIER) -c $(CFLAGS) $(DEBUG_OPT) -g $(ASM_LISTING_C) $< -o $@ # C++ Debug 目标文件编译 $(BUILD_DIR)/$(SUB_DIR_DEBUG)/%.o: %.cpp Makefile | $(BUILD_DIR)/$(SUB_DIR_DEBUG) @echo ====== [Debug] C++ Source File "$<" Compiling... ====== - $(CXX_COMPLIER) -c $(CXXFLAGS) $(RELEASE_OPT) $(ASM_LISTING) $< -o $@ + $(CXX_COMPLIER) -c $(CXXFLAGS) $(DEBUG_OPT) -g $(ASM_LISTING) $< -o $@ # Debug 资源脚本文件编译 $(BUILD_DIR)/$(SUB_DIR_DEBUG)/%.o: %.rc Makefile | $(BUILD_DIR)/$(SUB_DIR_DEBUG) -- 2.54.0 From 7a3fefb9c2408ea01aa6b1b64e25c5737c7cdd83 Mon Sep 17 00:00:00 2001 From: "DeepSeek-V4.1-Flash" <> Date: Wed, 4 Nov 2026 02:59:57 +0800 Subject: [PATCH 05/22] =?UTF-8?q?refactor(plotview):=20=E9=9D=A2=E6=9D=BF?= =?UTF-8?q?=E7=8A=B6=E6=80=81/=E6=BB=9A=E5=8A=A8=E9=87=8F=E6=94=B6?= =?UTF-8?q?=E6=95=9B=E4=B8=BA=E5=94=AF=E4=B8=80=E6=B4=BE=E5=8F=91=E7=82=B9?= =?UTF-8?q?=EF=BC=8C=E6=B6=88=E9=99=A4=2020+=20=E5=A4=84=E6=89=8B=E5=86=99?= =?UTF-8?q?=E5=88=86=E6=94=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 问题:`Lane` 里 7 种实验性功能各自持有一份 `LanePanelState` 与滚动偏移(共 14 个字段)。 旧实现在每个需要“按种类取用”的地方各写一遍多层嵌套三元式或 switch: - EdgePanelLayoutFor 的 enabled / open / collapsed / storedX / storedY 共 5 条 7 层嵌套三元式(历史上多次因多/少一个右括号编译失败); - 标题栏拖拽、✕ 关闭、滚轮翻页、滚动条拖动、错开序数统计各自再写一遍。 新增一种面板要改 20+ 处,漏一处就是「面板拖不动 / 关不掉 / 滚轮滚不到底」 (MARK、RESAMPLE、CLIP 三种面板都真实踩过)。 改法(PlotViewInternal.hpp 新增唯一派发点): - LanePanelEnabled(lane, kind) - LanePanelStateOf(lane, kind) 可写/只读 - LanePanelScrollOf(lane, kind) 可写/只读 这些 switch **穷举全部 7 种且不写 default**,因此 -Wswitch(属 -Wall)会在新增 EdgePanelKind 而漏登记时直接报警 —— 已用最小程序验证 -Wswitch 确实会报。 调用点改造: - EdgePanelLayoutFor:5 条嵌套三元式 -> 3 行访问器调用;局部 kOrder[] -> kEdgePanelKinds。 - 标题栏拖拽:整个 7 分支 switch -> 一次访问器写入。 - 滚轮翻页:current 的三元式链 -> LanePanelScrollOf;switch 补全 MEAN/MARK 并去掉 default。 - 滚动条拖动:switch 补全 MEAN/MARK 并去掉 default。 - ✕ 关闭:去掉 `case COUNT: default:` 的兜底,改为穷举。 - SetLanePanelCollapsed / LanePanelCollapsed:两个 switch -> 一次访问器。 - 滚轮“是否可滚面板”的 kind 列举改为 `hoveredKind != MARK`。 验证:release / debug 全量编译零 warning;产物大小不变(793,088 B,逻辑等价)。 --- Components/PlotView/Inc/PlotViewInternal.hpp | 97 +++++++++++++++++++ Components/PlotView/Src/PlotViewFeatures.cpp | 53 +--------- .../PlotView/Src/PlotViewInteraction.cpp | 94 ++++++------------ Components/PlotView/Src/PlotViewPanels.cpp | 85 +++------------- Documents/Code_Review_Progress.md | 25 +++++ 5 files changed, 173 insertions(+), 181 deletions(-) diff --git a/Components/PlotView/Inc/PlotViewInternal.hpp b/Components/PlotView/Inc/PlotViewInternal.hpp index c450220..cacc637 100644 --- a/Components/PlotView/Inc/PlotViewInternal.hpp +++ b/Components/PlotView/Inc/PlotViewInternal.hpp @@ -1081,6 +1081,103 @@ namespace jdi /** 实验性功能浮动面板的种类数(遍历与命中测试统一用它,避免新增功能时漏改)。 */ inline const int kEdgePanelKindCount = static_cast(sizeof(kEdgePanelKinds) / sizeof(kEdgePanelKinds[0])); + // ---- 面板状态 / 滚动量的**唯一派发点** -------------------------------------- + // + // ⚠️⚠️ 为什么必须有这一组函数:`Lane` 里每种实验性功能各有一份 + // `LanePanelState` 与滚动偏移(7 种 × 2 = 14 个字段)。旧实现在**每个**需要 + // 按种类取用这些字段的地方各写一遍多层三元式或 `switch`: + // * `EdgePanelLayoutFor` 的 `enabled / open / collapsed` 各一条 7 层嵌套三元式 + // (历史多次因为“多/少一个右括号”编译失败); + // * 标题栏拖拽、✕ 关闭、滚轮滚动、折叠切换、缓存键……各自再写一遍。 + // 新增一种面板要改 20+ 处,漏一处就是“面板拖不动 / 关不掉 / 滚轮滚不到底” + //(`MARK`、`RESAMPLE`、`CLIP` 都真实踩过)。 + // + // 现在这些字段只在这一组函数里按种类分派;**调用方一律用它**。 + // ⚠️ 这些 `switch` **不得写 `default:`** —— 让 `-Wswitch` 在新增 + // `EdgePanelKind` 而漏登记时直接报警(编译期保护,0 运行时代价)。 + + /** @brief 取某种面板的 UI 状态(展开/折起/位置)——可写版本。 */ + inline LanePanelState& LanePanelStateOf(Lane& lane, EdgePanelKind kind) + { + switch (kind) + { + case EdgePanelKind::COUNT: + return lane.edgePanel; + case EdgePanelKind::CENTER: + return lane.edgeCenterPanel; + case EdgePanelKind::MEAN: + return lane.meanFilterPanel; + case EdgePanelKind::PEAK: + return lane.weakPeakPanel; + case EdgePanelKind::MARK: + return lane.freeMarkerPanel; + case EdgePanelKind::RESAMPLE: + return lane.resamplePanel; + case EdgePanelKind::CLIP: + return lane.clipPanel; + } + // 枚举值越界(只可能来自非法强制转换):返回一个安全的占位对象。 + return lane.edgePanel; + } + + /** @brief 取某种面板的 UI 状态——只读版本。 */ + inline const LanePanelState& LanePanelStateOf(const Lane& lane, EdgePanelKind kind) + { + return LanePanelStateOf(const_cast(lane), kind); + } + + /** @brief 取某种面板的**内容滚动偏移**(客户区像素)——可写版本。 */ + inline float& LanePanelScrollOf(Lane& lane, EdgePanelKind kind) + { + switch (kind) + { + case EdgePanelKind::COUNT: + return lane.edgeCountScroll; + case EdgePanelKind::CENTER: + return lane.edgeCenterScroll; + case EdgePanelKind::MEAN: + return lane.meanFilterScroll; + case EdgePanelKind::PEAK: + return lane.weakPeakScroll; + case EdgePanelKind::MARK: + return lane.freeMarkerScroll; + case EdgePanelKind::RESAMPLE: + return lane.resampleScroll; + case EdgePanelKind::CLIP: + return lane.clipScroll; + } + return lane.meanFilterScroll; + } + + /** @brief 取某种面板的**内容滚动偏移**——只读版本。 */ + inline const float& LanePanelScrollOf(const Lane& lane, EdgePanelKind kind) + { + return LanePanelScrollOf(const_cast(lane), kind); + } + + /** @brief 该面板对应的功能是否已启用(右键菜单里的开关)。 */ + inline bool LanePanelEnabled(const Lane& lane, EdgePanelKind kind) + { + switch (kind) + { + case EdgePanelKind::COUNT: + return lane.edgeCount; + case EdgePanelKind::CENTER: + return lane.edgeCenter; + case EdgePanelKind::MEAN: + return lane.meanFilter; + case EdgePanelKind::PEAK: + return lane.weakPeak; + case EdgePanelKind::MARK: + return lane.freeMarker; + case EdgePanelKind::RESAMPLE: + return lane.resample; + case EdgePanelKind::CLIP: + return lane.clip; + } + return false; + } + /** * @brief 计算一条派生曲线在给定采样窗口内的取值(同步路径与后台线程共用)。 * diff --git a/Components/PlotView/Src/PlotViewFeatures.cpp b/Components/PlotView/Src/PlotViewFeatures.cpp index bb6b4f1..f9604ab 100644 --- a/Components/PlotView/Src/PlotViewFeatures.cpp +++ b/Components/PlotView/Src/PlotViewFeatures.cpp @@ -2186,38 +2186,13 @@ namespace jdi return false; } Lane& lane = m_tab->Lanes()[static_cast(laneIndex)]; - LanePanelState* state = nullptr; - switch (kind) - { - case EdgePanelKind::COUNT: - state = &lane.edgePanel; - break; - case EdgePanelKind::CENTER: - state = &lane.edgeCenterPanel; - break; - case EdgePanelKind::MEAN: - state = &lane.meanFilterPanel; - break; - case EdgePanelKind::PEAK: - state = &lane.weakPeakPanel; - break; - case EdgePanelKind::MARK: - state = &lane.freeMarkerPanel; - break; - case EdgePanelKind::RESAMPLE: - state = &lane.resamplePanel; - break; - case EdgePanelKind::CLIP: - state = &lane.clipPanel; - break; - default: - return false; - } - if (state->collapsed == collapsed) + // ⚠️ 面板状态字段的唯一派发点(见 `LanePanelStateOf` 的说明)。 + LanePanelState& state = LanePanelStateOf(lane, kind); + if (state.collapsed == collapsed) { return false; } - state->collapsed = collapsed; + state.collapsed = collapsed; return true; } @@ -2228,25 +2203,7 @@ namespace jdi return false; } const Lane& lane = m_tab->Lanes()[static_cast(laneIndex)]; - switch (kind) - { - case EdgePanelKind::COUNT: - return lane.edgePanel.collapsed; - case EdgePanelKind::CENTER: - return lane.edgeCenterPanel.collapsed; - case EdgePanelKind::MEAN: - return lane.meanFilterPanel.collapsed; - case EdgePanelKind::PEAK: - return lane.weakPeakPanel.collapsed; - case EdgePanelKind::MARK: - return lane.freeMarkerPanel.collapsed; - case EdgePanelKind::RESAMPLE: - return lane.resamplePanel.collapsed; - case EdgePanelKind::CLIP: - return lane.clipPanel.collapsed; - default: - return false; - } + return LanePanelStateOf(lane, kind).collapsed; } bool PlotView::ToggleLanePanelCollapsed(int laneIndex, EdgePanelKind kind) diff --git a/Components/PlotView/Src/PlotViewInteraction.cpp b/Components/PlotView/Src/PlotViewInteraction.cpp index 6088fcd..e3782dc 100644 --- a/Components/PlotView/Src/PlotViewInteraction.cpp +++ b/Components/PlotView/Src/PlotViewInteraction.cpp @@ -1736,42 +1736,12 @@ namespace jdi top = std::max(m_plotRect.Y + 2.0f * m_scale, std::min(top, m_plotRect.Y + m_plotRect.Height - panel.Height - 2.0f * m_scale)); Lane& lane = m_tab->Lanes()[static_cast(m_edgePanelDragLane)]; - switch (m_edgePanelDragKind) - { - case EdgePanelKind::CENTER: - lane.edgeCenterPanel.x = left; - lane.edgeCenterPanel.y = top; - break; - case EdgePanelKind::MEAN: - lane.meanFilterPanel.x = left; - lane.meanFilterPanel.y = top; - break; - case EdgePanelKind::PEAK: - lane.weakPeakPanel.x = left; - lane.weakPeakPanel.y = top; - break; - case EdgePanelKind::MARK: - // ⚠️ 漏掉这一支会让「自由标记」面板**拖不动**(位置写进了 edgePanel, - // 下一帧又从 freeMarkerPanel 读到旧值 ⇒ 面板纹丝不动)。 - lane.freeMarkerPanel.x = left; - lane.freeMarkerPanel.y = top; - break; - case EdgePanelKind::RESAMPLE: - // ⚠️ 同类缺陷:漏掉这一支会让「重采样」面板**拖不动**(用户 2026-11-03 报告)。 - // 位置必须写进它自己的 `resamplePanel`,布局也只从那里读。 - lane.resamplePanel.x = left; - lane.resamplePanel.y = top; - break; - case EdgePanelKind::CLIP: - lane.clipPanel.x = left; - lane.clipPanel.y = top; - break; - case EdgePanelKind::COUNT: - default: - lane.edgePanel.x = left; - lane.edgePanel.y = top; - break; - } + // ⚠️ 面板位置必须写进**它自己的** `LanePanelState`(见 `LanePanelStateOf` 的说明): + // 漏写会让面板“纹丝不动”(位置写进别的面板、布局又从自己那里读)。 + // 历史上 `MARK` 与 `RESAMPLE` 都踩过这个坑,因此这里只保留唯一派发点。 + LanePanelState& dragState = LanePanelStateOf(lane, m_edgePanelDragKind); + dragState.x = left; + dragState.y = top; m_dragMoved = true; return true; } @@ -1850,6 +1820,9 @@ namespace jdi const float ratio = (thumbTop - layout.scrollTrack.Y) / travel; const float target = ratio * maxScroll; bool scrolled = false; + // ⚠️ 穷举所有面板种类且**不写 `default:`**:将来新增面板而忘登记时, + // `-Wswitch`(属 `-Wall`)会直接报错,而不是静默落到其它面板上。 + // (「自由标记」在上面的独立分支处理,不会到达这里。) switch (m_meanDragKind) { case EdgePanelKind::COUNT: @@ -1858,18 +1831,20 @@ namespace jdi case EdgePanelKind::CENTER: scrolled = SetLaneEdgeCenterScroll(m_markerScrollDragLane, target); break; + case EdgePanelKind::MEAN: + scrolled = SetLaneMeanFilterScroll(m_markerScrollDragLane, target); + break; case EdgePanelKind::PEAK: scrolled = SetLaneWeakPeakScroll(m_markerScrollDragLane, target); break; + case EdgePanelKind::MARK: + break; case EdgePanelKind::RESAMPLE: scrolled = SetLaneResampleScroll(m_markerScrollDragLane, target); break; case EdgePanelKind::CLIP: scrolled = SetLaneClipScroll(m_markerScrollDragLane, target); break; - default: - scrolled = SetLaneMeanFilterScroll(m_markerScrollDragLane, target); - break; } if (scrolled) { @@ -3419,8 +3394,12 @@ namespace jdi if (layout.close.Width > 0.0f && UiPointInRect(layout.close, static_cast(x), static_cast(y))) { + // ⚠️ 穷举所有面板种类且**不写 `default:`**(理由同上:漏登记时编译器报警)。 switch (kind) { + case EdgePanelKind::COUNT: + SetLaneEdgeCount(lane, false); + break; case EdgePanelKind::CENTER: SetLaneEdgeCenterCount(lane, false); break; @@ -3434,17 +3413,11 @@ namespace jdi SetLaneFreeMarker(lane, false); break; case EdgePanelKind::RESAMPLE: - // ⚠️ 漏掉这一支时 ✕ 点了没反应(落进 `default` 关掉的是「边沿计数」), - // 用户只能改走右键菜单取消功能(用户 2026-11-03 报告)。 SetLaneResample(lane, false); break; case EdgePanelKind::CLIP: SetLaneClip(lane, false); break; - case EdgePanelKind::COUNT: - default: - SetLaneEdgeCount(lane, false); - break; } changed = true; } @@ -3569,11 +3542,11 @@ namespace jdi panelLane, panelKind)) { const EdgePanelKind hoveredKind = static_cast(panelKind); - // 「均值滤波 / 中心轴边沿计数 / 弱峰调整 / 边沿计数」面板:内容区超高时用滚轮翻页 - //(上百个信号合并时必备)。 - if (hoveredKind == EdgePanelKind::MEAN || hoveredKind == EdgePanelKind::CENTER || - hoveredKind == EdgePanelKind::PEAK || hoveredKind == EdgePanelKind::COUNT || - hoveredKind == EdgePanelKind::RESAMPLE || hoveredKind == EdgePanelKind::CLIP) + // 「均值滤波 / 中心轴边沿计数 / 弱峰调整 / 边沿计数 / 重采样 / 选区截取」面板: + // 内容区超高时用滚轮翻页(上百个信号合并时必备)。 + // ⚠️ 除「自由标记」外的所有面板都走这一条(它有自己的独立分支,见下)—— + // 写成“逐个列举 kind”会让新增面板默默失去滚轮(历史踩过)。 + if (hoveredKind != EdgePanelKind::MARK) { const EdgePanelLayout layout = EdgePanelLayoutFor(panelLane, hoveredKind); if (layout.content.Width > 0.0f && @@ -3586,17 +3559,11 @@ namespace jdi { const Lane& wheelLane = Tab().Lanes()[static_cast(panelLane)]; - const float current = (hoveredKind == EdgePanelKind::CENTER) - ? wheelLane.edgeCenterScroll - : (hoveredKind == EdgePanelKind::PEAK) - ? wheelLane.weakPeakScroll - : (hoveredKind == EdgePanelKind::COUNT) - ? wheelLane.edgeCountScroll - : (hoveredKind == EdgePanelKind::RESAMPLE) - ? wheelLane.resampleScroll - : (hoveredKind == EdgePanelKind::CLIP) - ? wheelLane.clipScroll - : wheelLane.meanFilterScroll; + // ⚠️ 读取当前滚动量必须按**同一种分派规则**(`LanePanelScrollOf`): + // 旧实现这里是一条三元式链,漏掉新面板时会退化成 + // `meanFilterScroll`(恒 0)⇒ 每次滚轮都从 0 开始算、 + // 永远滚不到底(用户 2026-11-03 报告「选区截取」的正是这一处)。 + const float current = LanePanelScrollOf(wheelLane, hoveredKind); const float next = current - static_cast(notches * 40.0 * m_scale); const float target = std::max(0.0f, std::min(next, maxScroll)); switch (hoveredKind) @@ -3616,9 +3583,12 @@ namespace jdi case EdgePanelKind::CLIP: SetLaneClipScroll(panelLane, target); break; - default: + case EdgePanelKind::MEAN: SetLaneMeanFilterScroll(panelLane, target); break; + case EdgePanelKind::MARK: + // 「自由标记」由下面的独立分支处理(它的内容高度来自布局)。 + break; } return true; } diff --git a/Components/PlotView/Src/PlotViewPanels.cpp b/Components/PlotView/Src/PlotViewPanels.cpp index 6535aee..307c145 100644 --- a/Components/PlotView/Src/PlotViewPanels.cpp +++ b/Components/PlotView/Src/PlotViewPanels.cpp @@ -343,38 +343,18 @@ namespace jdi const bool listStyle = (meanFilter || centerAxis || weakPeak || resamplePanel || clipPanel); const bool countTable = (kind == EdgePanelKind::COUNT); const bool hasButton = (meanFilter || weakPeak || resamplePanel || clipPanel); - const bool enabled = meanFilter ? lane.meanFilter - : (weakPeak ? lane.weakPeak - : (freeMarker ? lane.freeMarker - : (resamplePanel ? lane.resample - : (clipPanel ? lane.clip - : (centerAxis ? lane.edgeCenter - : lane.edgeCount))))); - const bool open = meanFilter ? lane.meanFilterPanel.open - : (weakPeak ? lane.weakPeakPanel.open - : (freeMarker ? lane.freeMarkerPanel.open - : (resamplePanel - ? lane.resamplePanel.open - : (clipPanel - ? lane.clipPanel.open - : (centerAxis - ? lane.edgeCenterPanel.open - : lane.edgePanel.open))))); + // ⚠️ 面板的「启用 / 展开 / 折起」状态一律通过 `LanePanelEnabled` / + // `LanePanelStateOf` 取值(见 `PlotViewInternal.hpp` 的说明): + // 旧实现是三条 7 层嵌套三元式,新增面板时极容易漏改或写错括号。 + const bool enabled = LanePanelEnabled(lane, kind); + const LanePanelState& panelState = LanePanelStateOf(lane, kind); + const bool open = panelState.open; if (!enabled || !open) { return layout; } // 面板的**折起**状态(只显示标题栏)。折起时仍然算宽度与位置,但内容区全部省略。 - const bool collapsed = meanFilter ? lane.meanFilterPanel.collapsed - : (weakPeak ? lane.weakPeakPanel.collapsed - : (freeMarker ? lane.freeMarkerPanel.collapsed - : (resamplePanel - ? lane.resamplePanel.collapsed - : (clipPanel - ? lane.clipPanel.collapsed - : (centerAxis - ? lane.edgeCenterPanel.collapsed - : lane.edgePanel.collapsed))))); + const bool collapsed = panelState.collapsed; layout.collapsed = collapsed; int rowCount = 0; @@ -597,26 +577,8 @@ namespace jdi //(用户 2026-10-17 报告:“在当前范围内的网格开启实验性功能,面板首次出现在右下角”)。 // 改用**可见序数**:它的量级只有一屏的网格数(约 20),再夹到“面板上沿不超过绘图区 // 中间偏下”以内,于是面板始终停**右上区域**,同时保留“依次错开”的观感。 - const double storedX = meanFilter ? lane.meanFilterPanel.x - : (weakPeak ? lane.weakPeakPanel.x - : (freeMarker ? lane.freeMarkerPanel.x - : (resamplePanel - ? lane.resamplePanel.x - : (clipPanel - ? lane.clipPanel.x - : (centerAxis - ? lane.edgeCenterPanel.x - : lane.edgePanel.x))))); - const double storedY = meanFilter ? lane.meanFilterPanel.y - : (weakPeak ? lane.weakPeakPanel.y - : (freeMarker ? lane.freeMarkerPanel.y - : (resamplePanel - ? lane.resamplePanel.y - : (clipPanel - ? lane.clipPanel.y - : (centerAxis - ? lane.edgeCenterPanel.y - : lane.edgePanel.y))))); + const double storedX = panelState.x; + const double storedY = panelState.y; int openPanelOrdinal = 0; { // ⚠️ 错开量用「**当前真正显示出来的面板**中排在本面板之前的个数」: @@ -626,37 +588,18 @@ namespace jdi // ⚠️ 不能用 `laneIndex`(上百个网格时会把面板推到右下角); // ⚠️ 也不能只看 `*Panel.open`:该标志**默认就是 true**,必须同时看功能是否启用 // (与 `EdgePanelLayoutFor` 的 `enabled && open` 判定一致)。 - const auto shown = [](const Lane& lane, EdgePanelKind panelKind) -> bool + const auto shown = [](const Lane& panelLane, EdgePanelKind panelKind) -> bool { - switch (panelKind) - { - case EdgePanelKind::COUNT: - return lane.edgeCount && lane.edgePanel.open; - case EdgePanelKind::CENTER: - return lane.edgeCenter && lane.edgeCenterPanel.open; - case EdgePanelKind::MEAN: - return lane.meanFilter && lane.meanFilterPanel.open; - case EdgePanelKind::PEAK: - return lane.weakPeak && lane.weakPeakPanel.open; - case EdgePanelKind::MARK: - return lane.freeMarker && lane.freeMarkerPanel.open; - case EdgePanelKind::RESAMPLE: - return lane.resample && lane.resamplePanel.open; - case EdgePanelKind::CLIP: - return lane.clip && lane.clipPanel.open; - } - return false; + return LanePanelEnabled(panelLane, panelKind) && + LanePanelStateOf(panelLane, panelKind).open; }; - static const EdgePanelKind kOrder[7] = {EdgePanelKind::COUNT, EdgePanelKind::CENTER, - EdgePanelKind::MEAN, EdgePanelKind::PEAK, - EdgePanelKind::MARK, EdgePanelKind::RESAMPLE, - EdgePanelKind::CLIP}; + // ⚠️ 面板错开序数按 `kEdgePanelKinds` 的固定顺序统计(与绘制 / 命中同源)。 int openSeen = 0; bool found = false; for (std::size_t lane = 0; lane < m_lanes.size() && !found; ++lane) { const Lane& tabLane = Tab().Lanes()[lane]; - for (const EdgePanelKind panelKind : kOrder) + for (const EdgePanelKind panelKind : kEdgePanelKinds) { if (!shown(tabLane, panelKind)) { diff --git a/Documents/Code_Review_Progress.md b/Documents/Code_Review_Progress.md index c58e905..6cfa834 100644 --- a/Documents/Code_Review_Progress.md +++ b/Documents/Code_Review_Progress.md @@ -54,3 +54,28 @@ **验证**:release + debug **全量重建**(删 `*.o`/`*.d`)零 error 零 warning; map 中 `cp-demangle.o` 已无 `.text` 段分配。 +### P2 Release 关闭异步展开表 + 修正 Debug 构建参数 —— ✅ 完成(提交 `94fc1c4`) + +**改动** + +1. 新增 `RELEASE_ONLY_OPT = -fno-asynchronous-unwind-tables`,**只**加在 Release 的两条 + 编译规则上。`-fno-exceptions` 下绝大多数函数不再需要 `.pdata`/`.xdata`。 + * 代价(已评估接受):Release 崩溃时 WER 拿不到可靠调用栈;不打印栈回溯的程序 + 功能不受影响,Debug 构建保留完整展开表。 + * `-fno-unwind-tables`(全关)会链接失败,不采用。 +2. **修正真实缺陷**:`DEBUG_OPT = -O0` 定义了却**从未被使用** —— Debug 的两条编译规则 + 误用 `$(RELEASE_OPT)`(`-Oz`),且全程没有 `-g`。所谓 "Debug 构建" 其实只是 + "不 strip 的 Release",根本无法调试。现改用 `$(DEBUG_OPT) -g`,链接也加 `-g`。 + +**实测** + +| 指标 | 改前 | 改后 | +| --- | --- | --- | +| Release | 829,440 B | **793,088 B**(−36,352) | +| `.pdata` | 30,420 B | 12,684 B | +| `.xdata` | 28,904 B | 10,156 B | +| Debug | 1,734,578 B | 5,992,064 B(真实 `-O0 -g`,属预期) | + +**验证**:release 与 debug 均全量重建,零 error 零 warning(`-O0` 下也没有新警告)。 + + -- 2.54.0 From 8437100586085f14c414789a9671cc0bff7f1d11 Mon Sep 17 00:00:00 2001 From: "DeepSeek-V4.1-Flash" <> Date: Wed, 4 Nov 2026 03:02:36 +0800 Subject: [PATCH 06/22] =?UTF-8?q?refactor(application):=20=E6=8B=86?= =?UTF-8?q?=E5=88=86=202165=20=E8=A1=8C=E7=9A=84=20ApplicationCommands.cpp?= =?UTF-8?q?=20=E4=B8=BA=204=20=E4=B8=AA=E8=81=8C=E8=B4=A3=E5=88=86?= =?UTF-8?q?=E7=89=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 原文件混杂了标签页、派生信号、菜单、工具栏、键盘鼠标、布局调试绘制六类职责, 是本项目最大的“大杂烩”单文件之一。按职责纯搬移(零逻辑改动): - ApplicationTabs.cpp (355 行): 标签页新建/关闭/切换/重排、视图同步、窗口标题、 标签栏结果回调、标签页右键菜单、待提交改名。 - ApplicationDerive.cpp (786 行): 派生烘焙、重采样、选区截取(新建/就地)、克隆(含带调整)。 - ApplicationMenus.cpp (409 行): 绘图区右键菜单、菜单数值回写、主题切换、设置菜单、 菜单结果派发。 - ApplicationCommands.cpp (642 行): 分组/信号增删、移动与重排、文件对话框、工具栏动作、 键盘鼠标分发、布局边界绘制。 Makefile 按目录通配收集,无需登记新文件。 验证:release 零 error 零 warning;产物 793,088 -> 794,112 B(+1,024,LTO 内联边界变化, < 0.13%,属结构性拆分不可避免的噪声)。 --- .../Application/Src/ApplicationCommands.cpp | 1529 +---------------- .../Application/Src/ApplicationDerive.cpp | 789 +++++++++ .../Application/Src/ApplicationMenus.cpp | 409 +++++ .../Application/Src/ApplicationTabs.cpp | 360 ++++ Documents/Code_Review_Progress.md | 24 + 5 files changed, 1587 insertions(+), 1524 deletions(-) create mode 100644 Components/Application/Src/ApplicationDerive.cpp create mode 100644 Components/Application/Src/ApplicationMenus.cpp create mode 100644 Components/Application/Src/ApplicationTabs.cpp diff --git a/Components/Application/Src/ApplicationCommands.cpp b/Components/Application/Src/ApplicationCommands.cpp index ba8c65a..4642e43 100644 --- a/Components/Application/Src/ApplicationCommands.cpp +++ b/Components/Application/Src/ApplicationCommands.cpp @@ -1,7 +1,10 @@ -// Application 实现分片 —— 本文件包含:标签页管理、工具栏动作、设置菜单、键盘、菜单结果派发。 +// Application 实现分片 —— 本文件保留:**数据命令**(分组/信号增删、移动与重排)、 +// 文件对话框、工具栏动作、键盘与鼠标分发、布局边界调试绘制。 // // 共用的常量与工具函数在 ApplicationInternal.hpp(命名空间 `appdetail`,全部 inline)。 -// 分片按"职责"划分:核心(状态/布局)、渲染、命令与菜单、加载。 +// 本文件原为 2,165 行的“大杂烩”,2026-11-04 代码审查按职责拆出: +// 标签页 → `ApplicationTabs.cpp`;派生/重采样/截取/克隆 → `ApplicationDerive.cpp`; +// 上下文菜单与设置菜单 → `ApplicationMenus.cpp`。划分见 `Code_Review_Plan.md` 的 P11。 #include "ApplicationInternal.hpp" #include "UiDebugBounds.hpp" @@ -9,182 +12,6 @@ namespace jdi { - void Application::NewTab(bool collapseGroups) - { - TabConfig tab; - tab.SetName(NextTabName()); - // 用户新建的标签页:信号分组**默认折起**(用户明确要求:列表默认紧凑; - // 之后在该标签页里新加载的分组也沿用这个默认值)。 - tab.SetGroupsCollapsedByDefault(collapseGroups); - tab.SyncToDataSet(m_dataSet); - if (collapseGroups) - { - tab.SetAllCollapsed(true); - } - - // ⚠️ **新标签页不存在任何被点选的信号**(用户 2026-11-02 要求): - // 选中状态是**全局共享**的(`DataSet::Selected()`),不清掉的话新建出来的标签页会 - // 直接带着上一个标签页的选中信号 —— 样式编辑区于是有自己的“目标”,与新页“空白” - // 的预期不符。清空后样式区回到「未选择信号」状态,直到用户手动点选任意信号。 - m_dataSet.SetSelected(SignalRef()); - - m_tabs.push_back(std::move(tab)); - SetActiveTab(static_cast(m_tabs.size()) - 1); - } - - void Application::CloseTab(int index) - { - if (index < 0 || index >= static_cast(m_tabs.size())) - { - return; - } - if (m_tabs.size() <= 1) - { - // ⚠️ **唯一标签页不弹警告**(用户 2026-11-02 要求):直接删掉它并新建一个默认的 - // 「视图 1」—— 组全部折起、无选中信号、样式区无目标(由 `NewTab(true)` 保证)。 - // ⚠️ `m_tabs` 清空后 `NextTabName()` 必然返回「视图 1」,编号自然从 1 重新开始。 - // erase 与 `NewTab` 之间**不要**碰 `m_panel` / `m_plot`(它们的上下文仍指向 - // 刚被释放的 `TabConfig`);`NewTab` 里的 `SetActiveTab` 会第一时间重新绑定。 - m_tabs.erase(m_tabs.begin() + index); - m_activeTab = 0; - NewTab(true); - return; - } - - m_tabs.erase(m_tabs.begin() + index); - - int next = index; - if (next >= static_cast(m_tabs.size())) - { - next = static_cast(m_tabs.size()) - 1; - } - SetActiveTab(next); - } - - void Application::SetActiveTab(int index) - { - if (index < 0 || index >= static_cast(m_tabs.size())) - { - return; - } - - m_activeTab = index; - - // 重新绑定上下文:面板与绘图区均指向同一数据层与当前标签页配置。 - m_panel.SetContext(&m_dataSet, &m_tabs[static_cast(m_activeTab)]); - m_plot.SetContext(&m_dataSet, &m_tabs[static_cast(m_activeTab)]); - - RefreshTabBar(); - Relayout(); - - // 切换标签页后把该页已选中的信号滚动到可见位置。 - // 切换标签页后把共享的选中信号滚动到可见位置(选中状态跨页保持一致)。 - m_panel.ScrollToSignal(m_dataSet.Selected()); - - SynchronizeView(true); - UpdateWindowTitle(); - InvalidateRect(m_hwnd, nullptr, TRUE); - } - - void Application::RefreshTabBar() - { - std::vector names; - names.reserve(m_tabs.size()); - for (const TabConfig& tab : m_tabs) - { - names.push_back(tab.Name()); - } - m_tabBar.SetTabs(names); - m_tabBar.SetActiveIndex(m_activeTab); - } - - void Application::SynchronizeView(bool force) - { - if (m_tabs.empty() || m_activeTab < 0) - { - return; - } - - TabConfig& tab = m_tabs[static_cast(m_activeTab)]; - - const int visibleCount = tab.VisibleCount(); - const bool anyVisible = tab.AnyVisible(); - const std::size_t laneCount = tab.Lanes().size(); - - const bool changed = (visibleCount != m_cachedVisibleCount) || (anyVisible != m_cachedAnyVisible) || - (laneCount != m_cachedLaneCount); - if (!changed && !force) - { - return; - } - - const std::size_t previousLaneCount = m_cachedLaneCount; - m_cachedVisibleCount = visibleCount; - m_cachedAnyVisible = anyVisible; - m_cachedLaneCount = laneCount; - - // 标签页首次出现可见信号时,把时间轴对齐到可见数据的范围。 - // 没有任何可见信号时(未勾选 / 全部取消勾选 / 清空网格)主动复位自适应标记, - // 这样用户下一次勾选信号时会重新做一次自适应,而不是停留在上次的视图范围。 - if (!anyVisible) - { - tab.ViewInitialized() = false; - } - else if (!tab.ViewInitialized()) - { - // 首次出现可见信号时做一次自适应。单点信号(时间/幅值范围退化成 0)由 - // `FitAll` 内部按"单值散点"的规则处理(居中 + 一大格 1s),这里不再分开判断。 - m_plot.FitAll(); - // ⚠️ **网格纵向滚动也要回到最上方**(用户 2026-10-17 要求): - // “有过网格 → 没有信号显示 → 又加载了需要滚动条数量的网格”这一整类情形, - // 新出现的网格集必须从最上方开始看。旧实现保留了上一次的 `LaneScroll()`, - // 于是重新显示后会**停在与取消显示时相同的滚动位置**(用户报告)。 - // 放在 `FitAll()` 之后:`FitAll` 本身不改变滚动量(§11.2 第 112 条), - // 这里显式归零即可;随后 `Relayout`/`RefreshLanes` 会重算几何。 - tab.LaneScroll() = 0.0f; - // 用户 2026-10-17 要求:光标工具处于激活状态(单 / 双光标)而之前**没有任何信号绘制**时, - // 第一个信号出现后光标要按“光标出现的规则”重新出现一次,而不是停在旧的残留位置。 - // 规则与 `CycleCursorMode()` 首次启用完全同源;光标工具未激活时本调用无副作用。 - m_plot.ResetCursorPlacementOnFreshContent(); - } - - const bool laneCountChanged = (laneCount != previousLaneCount); - if (laneCountChanged || force) - { - // 网格数量变化会影响滚动条与绘图区宽度,必须重新布局。 - Relayout(); - } - else - { - m_plot.RefreshLanes(); - } - } - - void Application::SyncTabsToDataSet() - { - for (TabConfig& tab : m_tabs) - { - tab.SyncToDataSet(m_dataSet); - } - - // 选中状态由数据层共享维护,数据变动后需校验其是否仍指向存在的信号。 - m_dataSet.ValidateSelection(); - m_panel.InvalidateRows(); - - // 分组/信号结构变化会让信号引用与分组地址改变,派生缓存必须整体作废。 - m_plot.InvalidateDerivedCaches(); - - // 强制下一次同步重新计算视图状态。 - m_cachedVisibleCount = -1; - m_cachedAnyVisible = false; - m_cachedLaneCount = 0; - - if (m_hwnd != nullptr) - { - InvalidateRect(m_hwnd, nullptr, TRUE); - } - } - void Application::RemoveGroup(int groupIndex) { const SignalGroup* group = m_dataSet.GroupAt(groupIndex); @@ -444,24 +271,6 @@ namespace jdi false); } - void Application::UpdateWindowTitle() - { - // 标题栏 = 应用名称 + 分隔竖线 + 内存占用(单位在 KB/MB/GB 间自适应,保留两位小数)。 - // 除内存占用外不再附带标签页、文件数量等无关信息。 - // ⚠️ 内存口径 = **私有工作集**(与任务管理器"内存"列一致),不是总工作集。 - std::wstring title = kWindowTitle; - title += kTitleSeparator; - title += kRamTitlePrefix; - title += FormatByteSize(ProcessPrivateWorkingSetBytes()); - // 数值未变化时不调用 SetWindowTextW,避免无谓地重绘标题栏。 - if (title == m_windowTitleText) - { - return; - } - m_windowTitleText = title; - SetWindowTextW(m_hwnd, m_windowTitleText.c_str()); - } - void Application::OpenFileDialog() { // 允许一次选择多个文件,便于跨文件对比信号。 @@ -747,728 +556,6 @@ namespace jdi return changed; } - void Application::OnTabBarResult(const TabBarResult& result) - { - switch (result.type) - { - case TabBarResult::Type::ADD: - NewTab(true); - break; - case TabBarResult::Type::SELECT: - if (result.index != m_activeTab) - { - SetActiveTab(result.index); - } - break; - case TabBarResult::Type::CLOSE: - CloseTab(result.index); - break; - case TabBarResult::Type::CONTEXT: - ShowTabContextMenu(result); - break; - case TabBarResult::Type::REORDER: - MoveTab(result.index, result.targetIndex); - break; - case TabBarResult::Type::RENAME: - if (result.index >= 0 && result.index < static_cast(m_tabs.size()) && !result.text.empty()) - { - m_tabs[static_cast(result.index)].SetName(result.text); - RefreshTabBar(); - // SetTabs 会重置各标签的矩形,必须重新布局才能把它们算回来。 - Relayout(); - InvalidateRect(m_hwnd, nullptr, TRUE); - } - break; - case TabBarResult::Type::NONE: - default: - break; - } - } - - void Application::MoveTab(int from, int target) - { - if (from < 0 || from >= static_cast(m_tabs.size())) - { - return; - } - if (target < 0 || target > static_cast(m_tabs.size())) - { - return; - } - - // 拖拽语义:target 表示「插入到该下标之前」;向后拖时需扣除自身占位。 - int destination = target; - if (destination > from) - { - --destination; - } - destination = std::max(0, std::min(destination, static_cast(m_tabs.size()) - 1)); - if (destination == from) - { - return; - } - - // 保持当前激活的标签页在重排后仍处于激活状态。 - TabConfig moved = m_tabs[static_cast(from)]; - const int activeIndex = m_activeTab; - - m_tabs.erase(m_tabs.begin() + from); - m_tabs.insert(m_tabs.begin() + destination, std::move(moved)); - - int newActive = activeIndex; - if (activeIndex == from) - { - newActive = destination; - } - else if (from < activeIndex && destination >= activeIndex) - { - --newActive; - } - else if (from > activeIndex && destination <= activeIndex) - { - ++newActive; - } - - SetActiveTab(newActive); - InvalidateRect(m_hwnd, nullptr, TRUE); - } - - bool Application::ApplyPendingRename() - { - SignalRef ref; - std::wstring name; - if (!m_plot.TakePendingRename(ref, name)) - { - return false; - } - - if (!m_dataSet.RenameSignal(ref.groupIndex, ref.signalIndex, name)) - { - return false; - } - - // 改名只影响显示,但需要刷新左侧列表与绘图区图例。 - m_panel.InvalidateRows(); - SetStatusMessage(L"信号已重命名为:" + name, false); - InvalidateRect(m_hwnd, nullptr, TRUE); - return true; - } - - bool Application::BuildDerivedSignal(const DerivedSignalRequest& request, bool makeVisible) - { - const SignalGroup* group = m_dataSet.GroupAt(request.groupIndex); - if (group == nullptr || request.signalIndex < 0 || - request.signalIndex >= static_cast(group->signals.size())) - { - return false; - } - const SignalData& source = group->signals[static_cast(request.signalIndex)]; - const std::wstring baseName = source.name; - - // 0. 先把源信号的「调整」变换快照下来(下面 `InsertDerivedSignal` 会移动信号存储, - // 之后再取 `source` 会悬垂)。 - // - // ⚠️ **变换要"烘进数据",而不是让新信号继承这套指纹**(用户明确要求): - // 网格里的预览曲线画的是 `变换(滤波结果)`。若新信号只存 `滤波结果` 再**继承变换**, - // 它的「调整」面板里就会莫名其妙带着一整套系数 / 镜像 / 偏移 —— 用户看到的是 - // "新信号只是继承了原信号的指纹,数据本身没变"。现在改为: - // * 数值域变换(系数 → 绝对值 → 垂直镜像 → Y 偏移)**与**水平镜像(数据反序) - // 全部作用在派生采样值上; - // * 于是新信号**用默认外观**绘制出来就与预览曲线逐位一致。 - // * ⚠️ **只有「数值域」的变换烘进采样值;时间轴偏移留在样式上**(见 3b)。 - // 曾经把 `offsetTime` 也按 `time[i] - offsetTime` 重采样烘进采样值("新信号 - // 不启用任何调整"),但那是**有损**的:平移出时间轴范围的那一段只能写成 NaN, - // 偏移稍大(≥ 数据跨度)就连一个有效采样都不剩 —— 用户点「构建为新信号」 - // 得到的是一条**空信号**(2026-10-29 报告)。派生信号与源信号共用同一条文件 - // 时间轴,无法用采样值表达超出轴范围的平移,因此时间偏移只能由样式表达。 - // 预览曲线本来也是用源样式的 `offsetTime` 绘制的,两条路径**显示完全一致**。 - SignalStyle sourceTransform; - bool hasSourceStyle = false; - { - SignalRef sourceRef; - sourceRef.groupIndex = request.groupIndex; - sourceRef.signalIndex = request.signalIndex; - if (const SignalStyle* sourceStyle = m_dataSet.Style(sourceRef)) - { - sourceTransform = *sourceStyle; - hasSourceStyle = true; - } - } - - // 1. 计算派生采样值(与网格内的预览曲线共用同一实现,因此逐位一致)。 - // 多次迭代均值滤波形如「<原信号名称> (N点均值 M次迭代)」, - // 弱峰调整形如「<原信号名称> (弱峰调整 M% N次迭代)」。 - std::vector derived_values; - SignalData derived; - if (request.weakPeak) - { - ComputeWeakPeakAdjust(source.values, static_cast(request.amountPercent) / 100.0, - request.iterations, derived_values); - derived.name = baseName + L" (弱峰调整 " + std::to_wstring(request.amountPercent) + L"% " + - std::to_wstring(request.iterations) + L"次迭代)"; - } - else - { - ComputeMovingAverageIterated(source.values, request.points, request.iterations, derived_values); - derived.name = baseName + L" (" + std::to_wstring(request.points) + L"点均值 " + - std::to_wstring(request.iterations) + L"次迭代)"; - } - - // 2. 组装新信号(外观一律用默认值:变换已经烘进采样数据,不再是"继承的指纹")。 - // ⚠️ 新信号**沿用源信号的时间轴**(`axisIndex`):派生只改采样值,不改时间基准。 - derived.axisIndex = source.axisIndex; - derived.values.assign(derived_values.begin(), derived_values.end()); - - // 2b. 把源信号的「调整」变换**烘进派生采样值**: - // * 数值域(**公式映射** → 系数 → 绝对值 → 垂直镜像 → Y 偏移)逐点套用 - // `ApplyDrawTransform`(它本来就是“公式最先结算”,与绘制路径同源); - // * 水平镜像 = 数据反序,等价于把采样数组整体反转。 - // ⚠️ `ApplyDrawTransform` 是逐点函数,因此"先变换再反转"与"先反转再变换"等价。 - // ⚠️⚠️ **公式映射必须算作“有变换”**(用户 2026-11-03):旧判定只列了系数/绝对值/ - // 垂直镜像/Y 偏移,于是带公式的信号派生出来的新信号**丢掉了公式**(不烘也不继承) - // ⇒ 新信号曲线与预览曲线不一致。 - if (hasSourceStyle) - { - const bool valueTransform = - sourceTransform.HasFormula() || sourceTransform.gain != 1.0f || - sourceTransform.absolute || sourceTransform.mirrorVertical || - sourceTransform.offsetY != 0.0f; - if (valueTransform) - { - for (double& value : derived.values) - { - value = ApplyDrawTransform(sourceTransform, value); - } - } - if (sourceTransform.mirrorHorizontal) - { - std::reverse(derived.values.begin(), derived.values.end()); - } - } - - // 2c.(已删除)时间轴偏移**不再**按 `time[i] - offsetTime` 重采样烘进采样值。 - // - // 旧实现为了让"新信号的「调整」面板保持默认"而把平移烘进数据,代价是**有损**: - // 平移出时间轴范围的那一段只能写成 NaN,偏移 ≥ 数据跨度时**整条信号全变 NaN** - //(用户 2026-10-29 报告的"派生出来是一条空信号");即便偏移很小,也会在两端 - // 留下与实际曲线不符的空洞。 - // 派生信号与源信号共用同一条文件时间轴,无法用采样值表达超出轴范围的平移, - // 因此时间偏移改由**新信号样式**表达(见 3b)——预览曲线本来就用源样式的 - // `offsetTime` 绘制,所以两条路径的**显示完全一致**,且数据一点都不会丢。 - - // 3. 插入到原信号的下方(数据层 + 各标签页引用重映射)。 - int newIndex = -1; - if (!InsertSignalBelowSource(request.groupIndex, request.signalIndex, std::move(derived), - makeVisible, newIndex)) - { - return false; - } - - // 3b. 时间轴偏移:**只**写进新信号的样式(不再烘进采样值,见 2c 的说明)。 - // 绘制路径本来就把采样画在 `t + offsetTime` 处,因此新信号的曲线与预览曲线 - // 逐点重合,同时采样数据保持完整(不会因为平移越界而变成 NaN)。 - if (hasSourceStyle && sourceTransform.offsetTime != 0.0) - { - if (SignalStyle* derivedStyle = m_dataSet.Style(SignalRef{request.groupIndex, newIndex})) - { - derivedStyle->offsetTime = sourceTransform.offsetTime; - } - } - - // 4. 数据层结构变化:刷新列表、作废折线/派生缓存并重绘。 - SyncTabsToDataSet(); - return true; - } - - bool Application::BuildResampledSignal(int groupIndex, int signalIndex, double interval) - { - const SignalGroup* group = m_dataSet.GroupAt(groupIndex); - if (group == nullptr || signalIndex < 0 || - signalIndex >= static_cast(group->signals.size())) - { - return false; - } - if (!std::isfinite(interval) || interval <= 0.0) - { - return false; - } - - const SignalData& source = group->signals[static_cast(signalIndex)]; - const TimeAxis& axis = group->AxisOf(source); - const std::wstring baseName = source.name; - - // 0. 快照源信号的「调整」变换(插入会移动信号存储,之后取 `source` 会悬垂)。 - SignalStyle sourceStyle; - bool hasSourceStyle = false; - { - const SignalStyle* style = m_dataSet.Style(SignalRef{groupIndex, signalIndex}); - if (style != nullptr) - { - sourceStyle = *style; - hasSourceStyle = true; - } - } - - // 1. 收集「变换后」的采样点(升序)。变换与绘制一致: - // 公式映射 → 系数 → 绝对值 → 垂直镜像 → Y 偏移(时间轴偏移不烘值)。 - // ⚠️ 判定统一走 `HasValueTransform()`(含公式映射),否则带公式的信号 - // 重采样时会**丢掉公式**(用户 2026-11-03 报告)。 - const std::size_t count = std::min(axis.time.size(), source.values.size()); - std::vector sourceTimes; - std::vector sourceValues; - sourceTimes.reserve(count); - sourceValues.reserve(count); - bool monotonic = true; - double previous = 0.0; - for (std::size_t index = 0; index < count; ++index) - { - const double time = axis.time[index]; - const double value = source.values[index]; - if (!std::isfinite(time) || !std::isfinite(value)) - { - continue; - } - if (!sourceTimes.empty()) - { - if (time < previous) - { - monotonic = false; - break; - } - if (time == previous) - { - continue; // 重复时刻只保留第一个 - } - } - previous = time; - sourceTimes.push_back(time); - sourceValues.push_back(hasSourceStyle ? ApplyDrawTransform(sourceStyle, value) : value); - } - if (!monotonic || sourceTimes.size() < 2) - { - m_plot.ShowPersistentHint(!monotonic ? L"重采样失败:该信号的时间轴不是单调递增的。" - : L"重采样失败:有效采样点不足两个。"); - return false; - } - - // 2. 生成目标时间刻度(等间隔)。 - const double t0 = sourceTimes.front(); - const double t1 = sourceTimes.back(); - const double span = t1 - t0; - if (!(span > 0.0)) - { - m_plot.ShowPersistentHint(L"重采样失败:该信号的时间跨度为 0。"); - return false; - } - // 采样点数上限:防止用户把间隔输得太小导致内存爆炸。 - constexpr double kMaxResamplePoints = 20000000.0; - const double stepsExact = std::floor(span / interval + 1.0e-9) + 1.0; - if (!(stepsExact >= 2.0)) - { - m_plot.ShowPersistentHint(L"重采样失败:重采样间隔大于信号时间跨度。"); - return false; - } - if (stepsExact > kMaxResamplePoints) - { - m_plot.ShowPersistentHint(L"重采样失败:目标采样点过多(超过 2000 万),请增大重采样间隔。"); - return false; - } - const std::size_t targetCount = static_cast(stepsExact); - - TimeAxis newAxis; - newAxis.name = axis.name; - newAxis.time.resize(targetCount); - newAxis.monotonic = true; - std::vector resampled(targetCount); - std::size_t cursor = 0; - const std::size_t last = sourceTimes.size() - 1; - for (std::size_t index = 0; index < targetCount; ++index) - { - const double time = std::min(t1, t0 + static_cast(index) * interval); - newAxis.time[index] = time; - // 单调推进游标(O(n) 总代价)。 - while (cursor < last && sourceTimes[cursor + 1] < time) - { - ++cursor; - } - if (cursor >= last) - { - resampled[index] = sourceValues[last]; - continue; - } - const double ta = sourceTimes[cursor]; - const double tb = sourceTimes[cursor + 1]; - if (time <= ta) - { - resampled[index] = sourceValues[cursor]; - continue; - } - if (tb <= ta) - { - resampled[index] = sourceValues[cursor]; - continue; - } - const double ratio = (time - ta) / (tb - ta); - const double a = sourceValues[cursor]; - const double b = sourceValues[cursor + 1]; - resampled[index] = a + (b - a) * ratio; - } - newAxis.minTime = t0; - newAxis.maxTime = newAxis.time.back(); - newAxis.finiteCount = targetCount; - - // 3. 追加新时间轴(重采样后的采样刻度无法复用源信号的时间轴)。 - std::size_t axisIndex = 0; - if (!m_dataSet.AppendGroupAxis(groupIndex, std::move(newAxis), axisIndex)) - { - return false; - } - - // 4. 组装新信号并插入到源信号下方。 - SignalData derived; - derived.name = baseName + L" (重采样 " + FormatFrequency(1.0 / interval) + L")"; - derived.axisIndex = axisIndex; - derived.values = std::move(resampled); - - int newIndex = -1; - if (!InsertSignalBelowSource(groupIndex, signalIndex, std::move(derived), true, newIndex)) - { - return false; - } - - // 5. 时间轴偏移**不烘值**:留在新信号的样式上(与其它派生信号同一规则)。 - if (hasSourceStyle && sourceStyle.offsetTime != 0.0) - { - if (SignalStyle* derivedStyle = m_dataSet.Style(SignalRef{groupIndex, newIndex})) - { - derivedStyle->offsetTime = sourceStyle.offsetTime; - } - } - - SyncTabsToDataSet(); - return true; - } - - // ---------------------------------------------------------------- 选区截取 - - bool Application::BuildClippedSignal(int groupIndex, int signalIndex, double begin, double end) - { - const SignalGroup* group = m_dataSet.GroupAt(groupIndex); - if (group == nullptr || signalIndex < 0 || - signalIndex >= static_cast(group->signals.size())) - { - return false; - } - if (!std::isfinite(begin) || !std::isfinite(end)) - { - return false; - } - const double low = std::min(begin, end); - const double high = std::max(begin, end); - if (!(high > low)) - { - m_plot.ShowPersistentHint(L"选区截取失败:双光标区间宽度为 0。"); - return false; - } - - const SignalData& source = group->signals[static_cast(signalIndex)]; - const TimeAxis& axis = group->AxisOf(source); - const std::wstring baseName = source.name; - - // 源信号的整套「调整」快照(插入会移动信号存储,之后不能再解引用 `source`)。 - SignalStyle sourceStyle; - bool hasSourceStyle = false; - { - const SignalStyle* style = m_dataSet.Style(SignalRef{groupIndex, signalIndex}); - if (style != nullptr) - { - sourceStyle = *style; - hasSourceStyle = true; - } - } - - // 1. 逐点收集**区间内**的原始采样(不烘焙任何调整)。 - // ⚠️⚠️ 比较必须用**显示时刻**(`time + offsetTime`):双光标给出的是屏幕上看到的时刻, - // 而采样画在 `time + offsetTime` 处(`offsetTime` 不进采样值,只平移绘制位置)。 - // 若直接拿 `time` 与选区比较,带时间轴偏移的信号会截错区间(用户 2026-11-03)。 - const double timeShift = hasSourceStyle ? sourceStyle.offsetTime : 0.0; - const std::size_t count = std::min(axis.time.size(), source.values.size()); - std::vector keptTimes; - std::vector keptValues; - keptTimes.reserve(count); - keptValues.reserve(count); - for (std::size_t index = 0; index < count; ++index) - { - const double time = axis.time[index]; - if (!std::isfinite(time)) - { - continue; - } - const double shown = time + timeShift; - if (shown < low || shown > high) - { - continue; - } - keptTimes.push_back(time); - keptValues.push_back(source.values[index]); - } - if (keptValues.size() < 2) - { - m_plot.ShowPersistentHint(L"选区截取失败:区间内的有效采样点不足两个。"); - return false; - } - - // 2. 名称尾缀:`(AAAA - BBBB)`,秒、保留三位小数(用户 2026-11-03 明确要求)。 - // 数值取**显示时刻**(与双光标读数一致),因此加上 `timeShift`。 - const std::wstring suffix = - L" (" + FormatDecimalFixed(keptTimes.front() + timeShift, 3) + L"s - " + - FormatDecimalFixed(keptTimes.back() + timeShift, 3) + L"s)"; - - // 3. 新建时间轴(截取后的采样刻度无法复用源信号的时间轴)。 - TimeAxis newAxis; - newAxis.name = axis.name; - newAxis.time = std::move(keptTimes); - newAxis.monotonic = true; - newAxis.finiteCount = newAxis.time.size(); - newAxis.minTime = newAxis.time.front(); - newAxis.maxTime = newAxis.time.back(); - std::size_t axisIndex = 0; - if (!m_dataSet.AppendGroupAxis(groupIndex, std::move(newAxis), axisIndex)) - { - return false; - } - - // 4. 组装新信号并插入到源信号下方。 - SignalData derived; - derived.name = baseName + suffix; - derived.axisIndex = axisIndex; - derived.values = std::move(keptValues); - - int newIndex = -1; - if (!InsertSignalBelowSource(groupIndex, signalIndex, std::move(derived), true, newIndex)) - { - return false; - } - - // 5. 继承源信号的**整套调整**(不烘值 ⇒ 新曲线与源信号在区间内逐像素重合)。 - if (hasSourceStyle) - { - if (SignalStyle* derivedStyle = m_dataSet.Style(SignalRef{groupIndex, newIndex})) - { - derivedStyle->color = sourceStyle.color; - derivedStyle->customColor = sourceStyle.customColor; - derivedStyle->opacity = sourceStyle.opacity; - derivedStyle->lineWidth = sourceStyle.lineWidth; - derivedStyle->mode = sourceStyle.mode; - derivedStyle->formula = sourceStyle.formula; - derivedStyle->gain = sourceStyle.gain; - derivedStyle->absolute = sourceStyle.absolute; - derivedStyle->mirrorVertical = sourceStyle.mirrorVertical; - derivedStyle->mirrorHorizontal = sourceStyle.mirrorHorizontal; - derivedStyle->offsetY = sourceStyle.offsetY; - derivedStyle->offsetTime = sourceStyle.offsetTime; - } - } - - SyncTabsToDataSet(); - return true; - } - - bool Application::ClipSignalInPlace(int groupIndex, int signalIndex, double begin, double end) - { - if (!std::isfinite(begin) || !std::isfinite(end)) - { - return false; - } - const double low = std::min(begin, end); - const double high = std::max(begin, end); - if (!(high > low)) - { - m_plot.ShowPersistentHint(L"选区截取失败:双光标区间宽度为 0。"); - return false; - } - - // 破坏性截取会移动/释放采样 ⇒ 先暂停后台派生计算,避免它读到已释放的数据。 - m_plot.PauseDerivedAsync(); - // ⚠️ 双光标给的是**显示时刻**,而采样时刻是 `time`;这里换算回采样时刻区间 - // (`offsetTime` 不烘进采样值,只平移绘制位置)。 - double timeShift = 0.0; - if (const SignalStyle* style = m_dataSet.Style(SignalRef{groupIndex, signalIndex})) - { - timeShift = style->offsetTime; - } - // ⚠️⚠️ **必须先让该信号独占时间轴**再裁剪:同组信号常常共享同一条时间轴, - // 直接裁剪会把其它信号的时间基准一起改短 ⇒ 它们的采样与时间错位 - //(用户 2026-11-03 报告:"多个信号同时截取时截取出来的信号并不对")。 - std::size_t ownAxis = 0; - if (!m_dataSet.ForkAxisForSignal(groupIndex, signalIndex, ownAxis)) - { - m_plot.ShowPersistentHint(L"选区截取失败:无法为该信号创建独立时间轴。"); - return false; - } - if (!m_dataSet.ClipSignalRange(groupIndex, signalIndex, low - timeShift, high - timeShift)) - { - m_plot.ShowPersistentHint(L"选区截取失败:区间内的有效采样点不足两个。"); - return false; - } - SyncTabsToDataSet(); - return true; - } - - bool Application::InsertSignalBelowSource(int groupIndex, int sourceIndex, SignalData&& signal, - bool makeVisible, int& newIndex) - { - newIndex = -1; - // 插入派生信号会移动该分组的信号存储:先暂停后台计算,避免其读取已移动的数据。 - m_plot.PauseDerivedAsync(); - if (!m_dataSet.InsertDerivedSignal(groupIndex, sourceIndex, std::move(signal), newIndex)) - { - return false; - } - for (TabConfig& tab : m_tabs) - { - tab.RemapAfterSignalInsert(groupIndex, newIndex); - } - - // 在当前标签页中勾选新信号,并把它所在的网格移到原信号网格的下方。 - if (makeVisible && !m_tabs.empty()) - { - const SignalRef insertedRef{groupIndex, newIndex}; - ActiveTab().SetVisible(insertedRef, true); - const SignalRef sourceRef{groupIndex, sourceIndex}; - ActiveTab().MoveLaneOfSignalBelow(insertedRef, sourceRef); - } - return true; - } - - bool Application::CloneSignal(const SignalRef& sourceRef) - { - const SignalGroup* group = m_dataSet.GroupAt(sourceRef.groupIndex); - if (group == nullptr || sourceRef.signalIndex < 0 || - sourceRef.signalIndex >= static_cast(group->signals.size())) - { - return false; - } - const SignalData& source = group->signals[static_cast(sourceRef.signalIndex)]; - - // 源信号的样式快照(插入会移动信号存储,之后不能再解引用 `source`)。 - SignalStyle transform; - bool hasStyle = false; - if (const SignalStyle* style = m_dataSet.Style(sourceRef)) - { - transform = *style; - hasStyle = true; - } - - // ⚠️ 用户 2026-11-03 明确要求:「克隆该信号」**不做任何烘焙**。 - // 新信号直接使用源信号的**原始采样数据**,配方上只沿用: - // * 外观(颜色 / 自定义色 / 透明度 / 线宽 / 绘制方式); - // * **时间轴偏移**(`offsetTime` 只能留在样式上 —— 共用同一条时间轴, - // 烘焙进采样值会在越界段写 NaN,见 `BuildDerivedSignal` 2c 的说明)。 - // 公式映射 / 系数 / 绝对值 / 镜像 / Y 偏移一律**保持默认**(不烘也不应用)。 - // 需要“连调整一起复制”请用「克隆该信号(带调整)」(`CloneSignalAdjusted`)。 - SignalData clone; - clone.name = L"*" + source.name; - clone.axisIndex = source.axisIndex; - clone.values = source.values; - - int newIndex = -1; - if (!InsertSignalBelowSource(sourceRef.groupIndex, sourceRef.signalIndex, std::move(clone), - true, newIndex)) - { - return false; - } - - // 外观:克隆要**看起来与原信号一样**,但所有数值调整保持默认值。 - if (SignalStyle* cloned = m_dataSet.Style(SignalRef{sourceRef.groupIndex, newIndex})) - { - if (hasStyle) - { - cloned->color = transform.color; - cloned->customColor = transform.customColor; - cloned->opacity = transform.opacity; - cloned->lineWidth = transform.lineWidth; - cloned->mode = transform.mode; - // ⚠️ 只保留时间轴偏移,其余调整保持默认(用户 2026-11-03 明确要求)。 - cloned->offsetTime = transform.offsetTime; - } - // ⚠️ 单点信号默认会改成「散点 + 最大线宽」(`InsertDerivedSignal` 里的规则), - // 克隆应该用**原信号的样式**,因此上面几行把它覆盖回去了。 - } - - SyncTabsToDataSet(); - SetStatusMessage(L"已克隆信号:" + m_dataSet.GroupAt(sourceRef.groupIndex) - ->signals[static_cast(newIndex)] - .name, - false); - InvalidateRect(m_hwnd, nullptr, TRUE); - return true; - } - - bool Application::CloneSignalAdjusted(const SignalRef& sourceRef) - { - const SignalGroup* group = m_dataSet.GroupAt(sourceRef.groupIndex); - if (group == nullptr || sourceRef.signalIndex < 0 || - sourceRef.signalIndex >= static_cast(group->signals.size())) - { - return false; - } - const SignalData& source = group->signals[static_cast(sourceRef.signalIndex)]; - - SignalStyle transform; - bool hasStyle = false; - if (const SignalStyle* style = m_dataSet.Style(sourceRef)) - { - transform = *style; - hasStyle = true; - } - - // 「克隆该信号(带调整)」:同样是**原始采样数据**(不做烘焙), - // 区别只在样式——完整沿用源信号的整套调整参数(公式映射 / 系数 / Y 偏移 / - // 时间轴偏移 / 水平与垂直镜像 / 绝对值),因此两条曲线**逐像素重合**, - // 且新信号可以独立继续调参而不影响源信号(用户 2026-11-03 明确要求)。 - SignalData clone; - clone.name = L"*" + source.name; - clone.axisIndex = source.axisIndex; - clone.values = source.values; - - int newIndex = -1; - if (!InsertSignalBelowSource(sourceRef.groupIndex, sourceRef.signalIndex, std::move(clone), - true, newIndex)) - { - return false; - } - - if (SignalStyle* cloned = m_dataSet.Style(SignalRef{sourceRef.groupIndex, newIndex})) - { - if (hasStyle) - { - // 外观。 - cloned->color = transform.color; - cloned->customColor = transform.customColor; - cloned->opacity = transform.opacity; - cloned->lineWidth = transform.lineWidth; - cloned->mode = transform.mode; - // 整套「调整」参数(与源信号完全一致)。 - cloned->formula = transform.formula; - cloned->gain = transform.gain; - cloned->absolute = transform.absolute; - cloned->mirrorVertical = transform.mirrorVertical; - cloned->mirrorHorizontal = transform.mirrorHorizontal; - cloned->offsetY = transform.offsetY; - cloned->offsetTime = transform.offsetTime; - } - } - - SyncTabsToDataSet(); - SetStatusMessage(L"已克隆信号(带调整):" + m_dataSet.GroupAt(sourceRef.groupIndex) - ->signals[static_cast(newIndex)] - .name, - false); - InvalidateRect(m_hwnd, nullptr, TRUE); - return true; - } - bool Application::ApplyPendingPanelScroll() { SignalRef ref; @@ -1492,446 +579,6 @@ namespace jdi return m_plot.ScrollLaneIntoView(laneIndex); } - bool Application::ApplyPendingDerivedBuild() - { - // 「重采样」的构建流程与均值/弱峰不同(要新建时间轴),单独处理。 - { - int laneIndex = -1; - std::vector items; - if (m_plot.TakePendingResampleBuild(laneIndex, items) && !items.empty()) - { - // **同一文件内必须按下标降序处理**(每次插入只让插入点之后的信号下标 +1)。 - std::sort(items.begin(), items.end(), - [](const PlotView::ResampleBuild& a, const PlotView::ResampleBuild& b) - { - if (a.groupIndex != b.groupIndex) - { - return a.groupIndex < b.groupIndex; - } - return a.signalIndex > b.signalIndex; - }); - int built = 0; - double lastInterval = 0.0; - for (const PlotView::ResampleBuild& item : items) - { - if (BuildResampledSignal(item.groupIndex, item.signalIndex, item.interval)) - { - ++built; - lastInterval = item.interval; - } - } - if (built > 0) - { - SetStatusMessage(L"已构建 " + std::to_wstring(built) + L" 条重采样信号(频率 " + - FormatFrequency(lastInterval > 0.0 ? 1.0 / lastInterval : 0.0) + - L")。", - false); - } - return built > 0; - } - } - - // 「选区截取」:两个按钮(截取为新信号 / 原始信号截取)共用同一份区间。 - { - int laneIndex = -1; - std::vector items; - if (m_plot.TakePendingClipBuild(laneIndex, items) && !items.empty()) - { - // 同一文件内必须按下标降序处理(插入会让后续下标 +1)。 - std::sort(items.begin(), items.end(), - [](const PlotView::ClipBuild& a, const PlotView::ClipBuild& b) - { - if (a.groupIndex != b.groupIndex) - { - return a.groupIndex < b.groupIndex; - } - return a.signalIndex > b.signalIndex; - }); - int built = 0; - for (const PlotView::ClipBuild& item : items) - { - if (item.destructive - ? ClipSignalInPlace(item.groupIndex, item.signalIndex, item.begin, item.end) - : BuildClippedSignal(item.groupIndex, item.signalIndex, item.begin, item.end)) - { - ++built; - } - } - if (built > 0) - { - SetStatusMessage((items.front().destructive ? L"已截取 " : L"已截取为新信号 ") + - std::to_wstring(built) + L" 条。", - false); - } - return built > 0; - } - } - - // 两类「构建为新信号」请求(均值滤波 / 弱峰调整)机制完全一致, - // 统一整理成同一份构建清单后按下标降序处理。 - std::vector requests; - { - int laneIndex = -1; - std::vector items; - if (m_plot.TakePendingMeanFilterBuild(laneIndex, items)) - { - for (const PlotView::MeanFilterBuild& item : items) - { - DerivedSignalRequest request; - request.groupIndex = item.groupIndex; - request.signalIndex = item.signalIndex; - request.points = item.points; - request.iterations = item.iterations; - request.weakPeak = false; - requests.push_back(request); - } - } - } - { - int laneIndex = -1; - std::vector items; - if (m_plot.TakePendingWeakPeakBuild(laneIndex, items)) - { - for (const PlotView::WeakPeakBuild& item : items) - { - DerivedSignalRequest request; - request.groupIndex = item.groupIndex; - request.signalIndex = item.signalIndex; - request.amountPercent = item.amountPercent; - request.iterations = item.iterations; - request.weakPeak = true; - requests.push_back(request); - } - } - } - if (requests.empty()) - { - return false; - } - if (m_tabs.empty()) - { - return false; - } - - // **同一文件内必须按下标降序处理**:每次插入只让插入点之后的信号下标 +1, - // 降序处理可保证尚未处理的信号下标始终有效(升序会把新插入的信号当成原信号)。 - std::sort(requests.begin(), requests.end(), - [](const DerivedSignalRequest& a, const DerivedSignalRequest& b) - { - if (a.groupIndex != b.groupIndex) - { - return a.groupIndex < b.groupIndex; - } - return a.signalIndex > b.signalIndex; - }); - - int built = 0; - DerivedSignalRequest last; - for (const DerivedSignalRequest& request : requests) - { - if (BuildDerivedSignal(request, true)) - { - ++built; - last = request; - } - } - - if (built > 0) - { - std::wstring text = L"已构建 " + std::to_wstring(built) + L" 条 "; - if (last.weakPeak) - { - text += L"弱峰调整 " + std::to_wstring(last.amountPercent) + L"% " + - std::to_wstring(last.iterations) + L"次迭代信号。"; - } - else - { - text += std::to_wstring(last.points) + L"点均值 " + - std::to_wstring(last.iterations) + L"次迭代滤波信号。"; - } - SetStatusMessage(text, false); - } - return built > 0; - } - - void Application::ShowTabContextMenu(const TabBarResult& result) - { - if (result.index < 0 || result.index >= static_cast(m_tabs.size())) - { - return; - } - - m_tabMenuIndex = result.index; - - std::vector items; - - MenuItem header; - header.kind = MenuItemKind::HEADER; - header.label = m_tabs[static_cast(result.index)].Name(); - items.push_back(header); - - MenuItem create; - create.label = L"新建标签页"; - create.id = kMenuTabNew; - items.push_back(create); - - MenuItem close; - close.label = L"关闭该标签页"; - close.id = kMenuTabClose; - close.enabled = (m_tabs.size() > 1); - items.push_back(close); - - MenuItem closeOthers; - closeOthers.label = L"关闭其它标签页"; - closeOthers.id = kMenuTabCloseOthers; - closeOthers.enabled = (m_tabs.size() > 1); - items.push_back(closeOthers); - - // 菜单绘制在客户区内,因此把屏幕坐标换算为客户区坐标。 - POINT anchor = {result.screenX, result.screenY}; - ScreenToClient(m_hwnd, &anchor); - - RECT client = {}; - GetClientRect(m_hwnd, &client); - const gd::RectF bounds(0.0f, 0.0f, static_cast(client.right - client.left), - static_cast(client.bottom - client.top)); - - m_menu.Open(MenuScope::TAB_BAR, bounds, anchor.x, anchor.y, items); - } - - void Application::ShowPlotContextMenu(int laneIndex, int x, int y) - { - if (laneIndex < 0) - { - return; - } - - m_plotMenuLane = laneIndex; - - std::vector items; - - MenuItem header; - header.kind = MenuItemKind::HEADER; - header.label = L"网格 " + std::to_wstring(laneIndex + 1); - items.push_back(header); - - // 当前网格的 Y 轴上下限值直接在菜单内的输入框里显示,单击即可就地编辑(菜单不关闭)。 - double laneYMin = 0.0; - double laneYMax = 1.0; - m_plot.LaneRange(laneIndex, laneYMin, laneYMax); - - MenuItem maxItem; - maxItem.kind = MenuItemKind::EDIT; - maxItem.label = L"Y 轴最大值"; - maxItem.id = PLOT_CMD_LANE_Y_MAX; - maxItem.value = FormatValue(laneYMax); - maxItem.filter = EditFilter::NUMBER; - items.push_back(maxItem); - - MenuItem minItem; - minItem.kind = MenuItemKind::EDIT; - minItem.label = L"Y 轴最小值"; - minItem.id = PLOT_CMD_LANE_Y_MIN; - minItem.value = FormatValue(laneYMin); - minItem.filter = EditFilter::NUMBER; - items.push_back(minItem); - - // 选区自适应(切换项):勾选后每次本网格的时间轴选区放大都会自动匹配 Y 轴幅值。 - MenuItem autoViewItem; - autoViewItem.label = L"选区 Y 轴自适应"; - autoViewItem.id = PLOT_CMD_LANE_AUTO_VIEW; - autoViewItem.checked = m_plot.LaneAutoFitToSelection(laneIndex); - items.push_back(autoViewItem); - - // 时间轴自适应到本网格信号范围(原先位于分隔线下方,现移到分隔线上方)。 - MenuItem fitLaneTime; - fitLaneTime.label = L"按网格时间轴展开"; - fitLaneTime.id = PLOT_CMD_LANE_FIT_TIME; - items.push_back(fitLaneTime); - - // 「拆分当前网格」:把该网格内的信号按个拆成独立网格,插到其下方依次排列。 - // 可点击时在后方追加「(N个信号)」说明将拆出的网格数;只有 1 条信号时置灰且不加尾缀。 - MenuItem splitLane; - const std::size_t laneSignalCount = m_plot.LaneSignalCount(laneIndex); - splitLane.label = (laneSignalCount > 1) - ? (L"拆分当前网格(" + std::to_wstring(laneSignalCount) + L"个信号)") - : L"拆分当前网格"; - splitLane.id = PLOT_CMD_LANE_SPLIT; - splitLane.enabled = laneSignalCount > 1; - items.push_back(splitLane); - - // 实验性功能(二级菜单):横线上方,鼠标悬停即向右展开。 - MenuItem experimental; experimental.kind = MenuItemKind::SUBMENU; - experimental.label = L"实验性功能"; - { - MenuItem edgeCount; - edgeCount.label = L"边沿计数"; - edgeCount.id = PLOT_CMD_LANE_EDGE_COUNT; - edgeCount.checked = m_plot.LaneEdgeCount(laneIndex); - experimental.submenu.push_back(edgeCount); - - MenuItem edgeCenter; - edgeCenter.label = L"边沿计数(中心轴)"; - edgeCenter.id = PLOT_CMD_LANE_EDGE_CENTER; - edgeCenter.checked = m_plot.LaneEdgeCenterCount(laneIndex); - experimental.submenu.push_back(edgeCenter); - - MenuItem meanFilter; - meanFilter.label = L"均值滤波"; - meanFilter.id = PLOT_CMD_LANE_MEAN_FILTER; - meanFilter.checked = m_plot.LaneMeanFilter(laneIndex); - experimental.submenu.push_back(meanFilter); - - MenuItem weakPeak; - weakPeak.label = L"弱峰调整"; - weakPeak.id = PLOT_CMD_LANE_WEAK_PEAK; - weakPeak.checked = m_plot.LaneWeakPeak(laneIndex); - experimental.submenu.push_back(weakPeak); - - MenuItem freeMarker; - freeMarker.label = L"自由标记"; - freeMarker.id = PLOT_CMD_LANE_FREE_MARKER; - freeMarker.checked = m_plot.LaneFreeMarker(laneIndex); - - // 「重采样」位于「自由标记」**上方**(用户 2026-11-02 要求)。 - MenuItem resample; - resample.label = L"重采样"; - resample.id = PLOT_CMD_LANE_RESAMPLE; - resample.checked = m_plot.LaneResample(laneIndex); - experimental.submenu.push_back(resample); - - // 「选区截取」位于「重采样」下方、「自由标记」上方(用户 2026-11-03 要求)。 - MenuItem clip; - clip.label = L"选区截取"; - clip.id = PLOT_CMD_LANE_CLIP; - clip.checked = m_plot.LaneClip(laneIndex); - experimental.submenu.push_back(clip); - - experimental.submenu.push_back(freeMarker); - } - items.push_back(experimental); - - MenuItem separator; - separator.kind = MenuItemKind::SEPARATOR; - items.push_back(separator); - - // 全局开关:一次性打开/关闭全部网格各自的「选区 Y 轴自适应」。 - MenuItem autoFitAllItem; - autoFitAllItem.label = L"选区 Y 轴自适应(全局)"; - autoFitAllItem.id = PLOT_CMD_AUTO_FIT_ALL; - autoFitAllItem.checked = m_plot.AllLanesAutoFitToSelection(); - items.push_back(autoFitAllItem); - - MenuItem fitItem; - fitItem.label = L"全部自适应"; - fitItem.id = PLOT_CMD_FIT_ALL; - items.push_back(fitItem); - - RECT client = {}; - GetClientRect(m_hwnd, &client); - const gd::RectF bounds(0.0f, 0.0f, static_cast(client.right - client.left), - static_cast(client.bottom - client.top)); - - m_menu.Open(MenuScope::PLOT_VIEW, bounds, x, y, items); - } - - void Application::RefreshPlotMenuValues(int laneIndex) - { - if (laneIndex < 0) - { - return; - } - - double laneYMin = 0.0; - double laneYMax = 1.0; - m_plot.LaneRange(laneIndex, laneYMin, laneYMax); - m_menu.UpdateItemValue(PLOT_CMD_LANE_Y_MAX, FormatValue(laneYMax)); - m_menu.UpdateItemValue(PLOT_CMD_LANE_Y_MIN, FormatValue(laneYMin)); - - // 「选区 Y 轴自适应」(本网格)与「(全局)」都是切换项,实时反映各自的勾选状态。 - m_menu.UpdateItemLabel(PLOT_CMD_LANE_AUTO_VIEW, L"选区 Y 轴自适应", - m_plot.LaneAutoFitToSelection(laneIndex), true); - m_menu.UpdateItemLabel(PLOT_CMD_AUTO_FIT_ALL, L"选区 Y 轴自适应(全局)", - m_plot.AllLanesAutoFitToSelection(), true); - } - - void Application::ApplyTheme(ThemeKind kind) - { - if (kind == m_themeKind && m_theme.windowBg == ThemeFor(kind).windowBg) - { - return; // 幂等:同一个主题重复点击不必作废缓存 - } - m_themeKind = kind; - m_theme = ThemeFor(kind); - SetCurrentTheme(&m_theme); - - // 主题色烘进了多处位图缓存,必须整体作废(只作废场景缓存是不够的: - // 宽基底图层、装饰层、浮动面板层、信号列表行位图里都存着旧配色的像素)。 - m_plot.OnThemeChanged(); - m_panel.OnThemeChanged(); - - // 装饰区(标签栏 / 工具栏 / 面板)全部重画,并整窗回送。 - m_chromeDirty = true; - m_panelDirty = true; - InvalidateRect(m_hwnd, nullptr, TRUE); - } - - void Application::OpenSettingsMenu() - { - // 锚点取齿轮按钮的**实际矩形**:按钮宽度由控件实测,宿主不应自己推算几何。 - gd::RectF anchor; - if (!m_toolbar.ButtonRect(ToolbarAction::SETTINGS, anchor)) - { - // 尚未布局(极端情形):退化为从菜单快捷键位置展开,而不是什么都不发生。 - anchor = m_toolbarRect; - } - - std::vector items; - - // ── 调试 ────────────────────────────────────────────── - MenuItem debugHeader; - debugHeader.kind = MenuItemKind::HEADER; - debugHeader.label = L"调试"; - items.push_back(debugHeader); - - MenuItem boundsItem; - boundsItem.label = L"显示布局边界"; - boundsItem.id = kAppCmdShowLayoutBounds; - boundsItem.checked = m_showLayoutBounds; - // 切换项:点击后菜单保持打开,勾选态由宿主回写(与既有「镜像」等切换项一致)。 - boundsItem.keepOpen = true; - items.push_back(boundsItem); - - // ── 主题配色(二级菜单:鼠标悬停展开,带勾选态)────────────── - // 顺序固定为 Light / Solarised Light / Dark / Solarised Dark(用户 2026-10-29 指定)。 - // `keepOpen` 让点击主题后菜单不关闭,勾选态由宿主回写 —— 便于连续预览几种配色。 - MenuItem themeItem; - themeItem.kind = MenuItemKind::SUBMENU; - themeItem.label = L"主题配色"; - for (int index = 0; index <= static_cast(ThemeKind::SOLARIZED_DARK); ++index) - { - const ThemeKind kind = static_cast(index); - MenuItem option; - option.kind = MenuItemKind::NORMAL; - option.label = ThemeName(kind); - option.id = kAppCmdThemeBase + index; - option.checked = (m_themeKind == kind); - option.keepOpen = true; - themeItem.submenu.push_back(option); - } - items.push_back(themeItem); - - RECT client = {}; - GetClientRect(m_hwnd, &client); - const gd::RectF bounds(0.0f, 0.0f, static_cast(client.right - client.left), - static_cast(client.bottom - client.top)); - - // 右下展开,锚在按钮**下沿**(留 4 逻辑像素,视觉上不贴着按钮边框)。 - const int x = static_cast(anchor.X); - const int y = static_cast(anchor.Y + anchor.Height + 4.0f * m_scale); - m_menu.Open(MenuScope::APP_SETTINGS, bounds, x, y, items); - } - int Application::ComponentLayer(const UiComponent* component) const { if (component == nullptr) @@ -1996,170 +643,4 @@ namespace jdi UiDebugPaintBoxList(g, m_debugBoxes); } - void Application::DispatchMenuResult(const MenuResult& result) - { - if (!result.valid) - { - return; - } - - switch (result.scope) - { - case MenuScope::APP_SETTINGS: - { - // 应用设置菜单:全部条目都是**带勾选态的切换项**,菜单保持打开, - // 勾选变化通过 `UpdateItemLabel` 立即回写(连续切换多项时不用反复打开菜单)。 - if (result.id == kAppCmdShowLayoutBounds) - { - m_showLayoutBounds = !m_showLayoutBounds; - m_menu.UpdateItemLabel(kAppCmdShowLayoutBounds, L"显示布局边界", m_showLayoutBounds); - // 边界比组件矩形外扩 1px 且跨越所有装饰区 ⇒ 必须整帧重绘。 - m_chromeDirty = true; - } - else if (result.id >= kAppCmdThemeBase && - result.id <= kAppCmdThemeBase + static_cast(ThemeKind::SOLARIZED_DARK)) - { - const ThemeKind kind = static_cast(result.id - kAppCmdThemeBase); - ApplyTheme(kind); - // 回写勾选态:四项里只有当前主题打勾(子菜单项要用专用接口才会重建布局)。 - for (int index = 0; index <= static_cast(ThemeKind::SOLARIZED_DARK); ++index) - { - const ThemeKind option = static_cast(index); - m_menu.UpdateSubmenuItemLabel(kAppCmdThemeBase + index, ThemeName(option), - m_themeKind == option); - } - } - InvalidateRect(m_hwnd, nullptr, TRUE); - break; - } - case MenuScope::SIGNAL_PANEL: - { - const bool needRelayout = m_panel.OnMenuCommand(result); - // 「公式映射」等就地输入可能产生**非模态**警告(不使用 Windows 消息框), - // 由绘图区底部的自绘提示浮层显示(与加载失败提示同一套)。 - { - std::wstring hint; - if (m_panel.TakePendingHint(hint)) - { - m_plot.ShowPersistentHint(hint); - } - } - if (needRelayout) - { - Relayout(); - } - InvalidateRect(m_hwnd, nullptr, FALSE); - break; - } - case MenuScope::PLOT_VIEW: - { - // 菜单内联输入框的提交:就地更新网格 Y 轴上下限,菜单保持打开, - // 并把夹取后的真实值回写到菜单上(输入值可能被合法区间修正)。 - if (result.editCommit) - { - const int laneIndex = m_plotMenuLane; - if (laneIndex >= 0) - { - m_plot.ApplyLaneValueText(laneIndex, result.id == PLOT_CMD_LANE_Y_MAX, result.editText); - RefreshPlotMenuValues(laneIndex); - } - SetFocus(m_hwnd); - InvalidateRect(m_hwnd, nullptr, FALSE); - break; - } - - const int laneIndex = m_plotMenuLane; - m_plotMenuLane = -1; - switch (result.id) - { - case PLOT_CMD_LANE_AUTO_VIEW: - // 切换本网格的「选区 Y 轴自适应」。 - m_plot.ToggleLaneAutoFitToSelection(laneIndex); - break; - case PLOT_CMD_AUTO_FIT_ALL: - { - // 全局切换:一次打开/关闭全部网格各自的开关;打开时立即生效一次。 - const bool enable = !m_plot.AllLanesAutoFitToSelection(); - m_plot.SetAllLanesAutoFitToSelection(enable, true); - break; - } - case PLOT_CMD_LANE_FIT_TIME: - // 「按网格时间轴展开」:展开时间轴后同时把该网格的 Y 轴自适应一次。 - m_plot.FitTimeToLane(laneIndex); - break; - case PLOT_CMD_LANE_EDGE_COUNT: - // 「实验性功能 → 边沿计数」:切换本网格的横向光标与统计面板。 - m_plot.ToggleLaneEdgeCount(laneIndex); - break; - case PLOT_CMD_LANE_EDGE_CENTER: - // 「实验性功能 → 边沿计数(中心轴)」:切换中心平均值曲线与统计面板。 - m_plot.ToggleLaneEdgeCenterCount(laneIndex); - break; - case PLOT_CMD_LANE_MEAN_FILTER: - // 「实验性功能 → 均值滤波」:切换均值滤波曲线与参数面板。 - m_plot.ToggleLaneMeanFilter(laneIndex); - break; - case PLOT_CMD_LANE_WEAK_PEAK: - // 「实验性功能 → 弱峰调整」:切换弱峰调整曲线与参数面板。 - m_plot.ToggleLaneWeakPeak(laneIndex); - break; - case PLOT_CMD_LANE_FREE_MARKER: - // 「实验性功能 → 自由标记」:切换网格内跟随鼠标的纵向光标与固定数据点面板。 - m_plot.ToggleLaneFreeMarker(laneIndex); - break; - case PLOT_CMD_LANE_RESAMPLE: - // 「实验性功能 → 重采样」:切换重采样参数面板。 - m_plot.ToggleLaneResample(laneIndex); - break; - case PLOT_CMD_LANE_CLIP: - // 「实验性功能 → 选区截取」:切换选区截取面板。 - m_plot.ToggleLaneClip(laneIndex); - break; - case PLOT_CMD_LANE_SPLIT: - // 「拆分当前网格」:信号按个拆成独立网格,新网格顺次插入其下方。 - m_plot.SplitLane(laneIndex); - break; - case PLOT_CMD_FIT_ALL: - m_plot.FitAll(); - break; - default: - break; - } - SetFocus(m_hwnd); - InvalidateRect(m_hwnd, nullptr, TRUE); - break; - } - case MenuScope::TAB_BAR: - { - const int index = m_tabMenuIndex; - if (result.id == kMenuTabNew) - { - NewTab(true); - } - else if (result.id == kMenuTabClose) - { - CloseTab(index); - } - else if (result.id == kMenuTabCloseOthers) - { - if (index >= 0 && index < static_cast(m_tabs.size()) && m_tabs.size() > 1) - { - const TabConfig keep = m_tabs[static_cast(index)]; - m_tabs.clear(); - m_tabs.push_back(keep); - SetActiveTab(0); - } - } - m_tabMenuIndex = -1; - InvalidateRect(m_hwnd, nullptr, TRUE); - break; - } - case MenuScope::NONE: - default: - break; - } - } - - } // namespace jdi - diff --git a/Components/Application/Src/ApplicationDerive.cpp b/Components/Application/Src/ApplicationDerive.cpp new file mode 100644 index 0000000..424c1b4 --- /dev/null +++ b/Components/Application/Src/ApplicationDerive.cpp @@ -0,0 +1,789 @@ +// Application 实现分片 —— **派生信号**:派生烘焙(均值滤波/中心轴/弱峰调整)、 +// 重采样、选区截取(新建 / 就地破坏性截取)、克隆(普通 / 带调整)。 +// +// 共用的常量与工具函数在 ApplicationInternal.hpp(命名空间 `appdetail`,全部 inline)。 +// 本文件由 2026-11-04 代码审查从 `ApplicationCommands.cpp`(原 2,165 行)按职责拆出, +// **纯搬移、零逻辑改动**。分片划分见 `Documents/Code_Review_Plan.md` 的 P11。 +#include "ApplicationInternal.hpp" + +namespace jdi +{ + + bool Application::BuildDerivedSignal(const DerivedSignalRequest& request, bool makeVisible) + { + const SignalGroup* group = m_dataSet.GroupAt(request.groupIndex); + if (group == nullptr || request.signalIndex < 0 || + request.signalIndex >= static_cast(group->signals.size())) + { + return false; + } + const SignalData& source = group->signals[static_cast(request.signalIndex)]; + const std::wstring baseName = source.name; + + // 0. 先把源信号的「调整」变换快照下来(下面 `InsertDerivedSignal` 会移动信号存储, + // 之后再取 `source` 会悬垂)。 + // + // ⚠️ **变换要"烘进数据",而不是让新信号继承这套指纹**(用户明确要求): + // 网格里的预览曲线画的是 `变换(滤波结果)`。若新信号只存 `滤波结果` 再**继承变换**, + // 它的「调整」面板里就会莫名其妙带着一整套系数 / 镜像 / 偏移 —— 用户看到的是 + // "新信号只是继承了原信号的指纹,数据本身没变"。现在改为: + // * 数值域变换(系数 → 绝对值 → 垂直镜像 → Y 偏移)**与**水平镜像(数据反序) + // 全部作用在派生采样值上; + // * 于是新信号**用默认外观**绘制出来就与预览曲线逐位一致。 + // * ⚠️ **只有「数值域」的变换烘进采样值;时间轴偏移留在样式上**(见 3b)。 + // 曾经把 `offsetTime` 也按 `time[i] - offsetTime` 重采样烘进采样值("新信号 + // 不启用任何调整"),但那是**有损**的:平移出时间轴范围的那一段只能写成 NaN, + // 偏移稍大(≥ 数据跨度)就连一个有效采样都不剩 —— 用户点「构建为新信号」 + // 得到的是一条**空信号**(2026-10-29 报告)。派生信号与源信号共用同一条文件 + // 时间轴,无法用采样值表达超出轴范围的平移,因此时间偏移只能由样式表达。 + // 预览曲线本来也是用源样式的 `offsetTime` 绘制的,两条路径**显示完全一致**。 + SignalStyle sourceTransform; + bool hasSourceStyle = false; + { + SignalRef sourceRef; + sourceRef.groupIndex = request.groupIndex; + sourceRef.signalIndex = request.signalIndex; + if (const SignalStyle* sourceStyle = m_dataSet.Style(sourceRef)) + { + sourceTransform = *sourceStyle; + hasSourceStyle = true; + } + } + + // 1. 计算派生采样值(与网格内的预览曲线共用同一实现,因此逐位一致)。 + // 多次迭代均值滤波形如「<原信号名称> (N点均值 M次迭代)」, + // 弱峰调整形如「<原信号名称> (弱峰调整 M% N次迭代)」。 + std::vector derived_values; + SignalData derived; + if (request.weakPeak) + { + ComputeWeakPeakAdjust(source.values, static_cast(request.amountPercent) / 100.0, + request.iterations, derived_values); + derived.name = baseName + L" (弱峰调整 " + std::to_wstring(request.amountPercent) + L"% " + + std::to_wstring(request.iterations) + L"次迭代)"; + } + else + { + ComputeMovingAverageIterated(source.values, request.points, request.iterations, derived_values); + derived.name = baseName + L" (" + std::to_wstring(request.points) + L"点均值 " + + std::to_wstring(request.iterations) + L"次迭代)"; + } + + // 2. 组装新信号(外观一律用默认值:变换已经烘进采样数据,不再是"继承的指纹")。 + // ⚠️ 新信号**沿用源信号的时间轴**(`axisIndex`):派生只改采样值,不改时间基准。 + derived.axisIndex = source.axisIndex; + derived.values.assign(derived_values.begin(), derived_values.end()); + + // 2b. 把源信号的「调整」变换**烘进派生采样值**: + // * 数值域(**公式映射** → 系数 → 绝对值 → 垂直镜像 → Y 偏移)逐点套用 + // `ApplyDrawTransform`(它本来就是“公式最先结算”,与绘制路径同源); + // * 水平镜像 = 数据反序,等价于把采样数组整体反转。 + // ⚠️ `ApplyDrawTransform` 是逐点函数,因此"先变换再反转"与"先反转再变换"等价。 + // ⚠️⚠️ **公式映射必须算作“有变换”**(用户 2026-11-03):旧判定只列了系数/绝对值/ + // 垂直镜像/Y 偏移,于是带公式的信号派生出来的新信号**丢掉了公式**(不烘也不继承) + // ⇒ 新信号曲线与预览曲线不一致。 + if (hasSourceStyle) + { + const bool valueTransform = + sourceTransform.HasFormula() || sourceTransform.gain != 1.0f || + sourceTransform.absolute || sourceTransform.mirrorVertical || + sourceTransform.offsetY != 0.0f; + if (valueTransform) + { + for (double& value : derived.values) + { + value = ApplyDrawTransform(sourceTransform, value); + } + } + if (sourceTransform.mirrorHorizontal) + { + std::reverse(derived.values.begin(), derived.values.end()); + } + } + + // 2c.(已删除)时间轴偏移**不再**按 `time[i] - offsetTime` 重采样烘进采样值。 + // + // 旧实现为了让"新信号的「调整」面板保持默认"而把平移烘进数据,代价是**有损**: + // 平移出时间轴范围的那一段只能写成 NaN,偏移 ≥ 数据跨度时**整条信号全变 NaN** + //(用户 2026-10-29 报告的"派生出来是一条空信号");即便偏移很小,也会在两端 + // 留下与实际曲线不符的空洞。 + // 派生信号与源信号共用同一条文件时间轴,无法用采样值表达超出轴范围的平移, + // 因此时间偏移改由**新信号样式**表达(见 3b)——预览曲线本来就用源样式的 + // `offsetTime` 绘制,所以两条路径的**显示完全一致**,且数据一点都不会丢。 + + // 3. 插入到原信号的下方(数据层 + 各标签页引用重映射)。 + int newIndex = -1; + if (!InsertSignalBelowSource(request.groupIndex, request.signalIndex, std::move(derived), + makeVisible, newIndex)) + { + return false; + } + + // 3b. 时间轴偏移:**只**写进新信号的样式(不再烘进采样值,见 2c 的说明)。 + // 绘制路径本来就把采样画在 `t + offsetTime` 处,因此新信号的曲线与预览曲线 + // 逐点重合,同时采样数据保持完整(不会因为平移越界而变成 NaN)。 + if (hasSourceStyle && sourceTransform.offsetTime != 0.0) + { + if (SignalStyle* derivedStyle = m_dataSet.Style(SignalRef{request.groupIndex, newIndex})) + { + derivedStyle->offsetTime = sourceTransform.offsetTime; + } + } + + // 4. 数据层结构变化:刷新列表、作废折线/派生缓存并重绘。 + SyncTabsToDataSet(); + return true; + } + + bool Application::BuildResampledSignal(int groupIndex, int signalIndex, double interval) + { + const SignalGroup* group = m_dataSet.GroupAt(groupIndex); + if (group == nullptr || signalIndex < 0 || + signalIndex >= static_cast(group->signals.size())) + { + return false; + } + if (!std::isfinite(interval) || interval <= 0.0) + { + return false; + } + + const SignalData& source = group->signals[static_cast(signalIndex)]; + const TimeAxis& axis = group->AxisOf(source); + const std::wstring baseName = source.name; + + // 0. 快照源信号的「调整」变换(插入会移动信号存储,之后取 `source` 会悬垂)。 + SignalStyle sourceStyle; + bool hasSourceStyle = false; + { + const SignalStyle* style = m_dataSet.Style(SignalRef{groupIndex, signalIndex}); + if (style != nullptr) + { + sourceStyle = *style; + hasSourceStyle = true; + } + } + + // 1. 收集「变换后」的采样点(升序)。变换与绘制一致: + // 公式映射 → 系数 → 绝对值 → 垂直镜像 → Y 偏移(时间轴偏移不烘值)。 + // ⚠️ 判定统一走 `HasValueTransform()`(含公式映射),否则带公式的信号 + // 重采样时会**丢掉公式**(用户 2026-11-03 报告)。 + const std::size_t count = std::min(axis.time.size(), source.values.size()); + std::vector sourceTimes; + std::vector sourceValues; + sourceTimes.reserve(count); + sourceValues.reserve(count); + bool monotonic = true; + double previous = 0.0; + for (std::size_t index = 0; index < count; ++index) + { + const double time = axis.time[index]; + const double value = source.values[index]; + if (!std::isfinite(time) || !std::isfinite(value)) + { + continue; + } + if (!sourceTimes.empty()) + { + if (time < previous) + { + monotonic = false; + break; + } + if (time == previous) + { + continue; // 重复时刻只保留第一个 + } + } + previous = time; + sourceTimes.push_back(time); + sourceValues.push_back(hasSourceStyle ? ApplyDrawTransform(sourceStyle, value) : value); + } + if (!monotonic || sourceTimes.size() < 2) + { + m_plot.ShowPersistentHint(!monotonic ? L"重采样失败:该信号的时间轴不是单调递增的。" + : L"重采样失败:有效采样点不足两个。"); + return false; + } + + // 2. 生成目标时间刻度(等间隔)。 + const double t0 = sourceTimes.front(); + const double t1 = sourceTimes.back(); + const double span = t1 - t0; + if (!(span > 0.0)) + { + m_plot.ShowPersistentHint(L"重采样失败:该信号的时间跨度为 0。"); + return false; + } + // 采样点数上限:防止用户把间隔输得太小导致内存爆炸。 + constexpr double kMaxResamplePoints = 20000000.0; + const double stepsExact = std::floor(span / interval + 1.0e-9) + 1.0; + if (!(stepsExact >= 2.0)) + { + m_plot.ShowPersistentHint(L"重采样失败:重采样间隔大于信号时间跨度。"); + return false; + } + if (stepsExact > kMaxResamplePoints) + { + m_plot.ShowPersistentHint(L"重采样失败:目标采样点过多(超过 2000 万),请增大重采样间隔。"); + return false; + } + const std::size_t targetCount = static_cast(stepsExact); + + TimeAxis newAxis; + newAxis.name = axis.name; + newAxis.time.resize(targetCount); + newAxis.monotonic = true; + std::vector resampled(targetCount); + std::size_t cursor = 0; + const std::size_t last = sourceTimes.size() - 1; + for (std::size_t index = 0; index < targetCount; ++index) + { + const double time = std::min(t1, t0 + static_cast(index) * interval); + newAxis.time[index] = time; + // 单调推进游标(O(n) 总代价)。 + while (cursor < last && sourceTimes[cursor + 1] < time) + { + ++cursor; + } + if (cursor >= last) + { + resampled[index] = sourceValues[last]; + continue; + } + const double ta = sourceTimes[cursor]; + const double tb = sourceTimes[cursor + 1]; + if (time <= ta) + { + resampled[index] = sourceValues[cursor]; + continue; + } + if (tb <= ta) + { + resampled[index] = sourceValues[cursor]; + continue; + } + const double ratio = (time - ta) / (tb - ta); + const double a = sourceValues[cursor]; + const double b = sourceValues[cursor + 1]; + resampled[index] = a + (b - a) * ratio; + } + newAxis.minTime = t0; + newAxis.maxTime = newAxis.time.back(); + newAxis.finiteCount = targetCount; + + // 3. 追加新时间轴(重采样后的采样刻度无法复用源信号的时间轴)。 + std::size_t axisIndex = 0; + if (!m_dataSet.AppendGroupAxis(groupIndex, std::move(newAxis), axisIndex)) + { + return false; + } + + // 4. 组装新信号并插入到源信号下方。 + SignalData derived; + derived.name = baseName + L" (重采样 " + FormatFrequency(1.0 / interval) + L")"; + derived.axisIndex = axisIndex; + derived.values = std::move(resampled); + + int newIndex = -1; + if (!InsertSignalBelowSource(groupIndex, signalIndex, std::move(derived), true, newIndex)) + { + return false; + } + + // 5. 时间轴偏移**不烘值**:留在新信号的样式上(与其它派生信号同一规则)。 + if (hasSourceStyle && sourceStyle.offsetTime != 0.0) + { + if (SignalStyle* derivedStyle = m_dataSet.Style(SignalRef{groupIndex, newIndex})) + { + derivedStyle->offsetTime = sourceStyle.offsetTime; + } + } + + SyncTabsToDataSet(); + return true; + } + + // ---------------------------------------------------------------- 选区截取 + + bool Application::BuildClippedSignal(int groupIndex, int signalIndex, double begin, double end) + { + const SignalGroup* group = m_dataSet.GroupAt(groupIndex); + if (group == nullptr || signalIndex < 0 || + signalIndex >= static_cast(group->signals.size())) + { + return false; + } + if (!std::isfinite(begin) || !std::isfinite(end)) + { + return false; + } + const double low = std::min(begin, end); + const double high = std::max(begin, end); + if (!(high > low)) + { + m_plot.ShowPersistentHint(L"选区截取失败:双光标区间宽度为 0。"); + return false; + } + + const SignalData& source = group->signals[static_cast(signalIndex)]; + const TimeAxis& axis = group->AxisOf(source); + const std::wstring baseName = source.name; + + // 源信号的整套「调整」快照(插入会移动信号存储,之后不能再解引用 `source`)。 + SignalStyle sourceStyle; + bool hasSourceStyle = false; + { + const SignalStyle* style = m_dataSet.Style(SignalRef{groupIndex, signalIndex}); + if (style != nullptr) + { + sourceStyle = *style; + hasSourceStyle = true; + } + } + + // 1. 逐点收集**区间内**的原始采样(不烘焙任何调整)。 + // ⚠️⚠️ 比较必须用**显示时刻**(`time + offsetTime`):双光标给出的是屏幕上看到的时刻, + // 而采样画在 `time + offsetTime` 处(`offsetTime` 不进采样值,只平移绘制位置)。 + // 若直接拿 `time` 与选区比较,带时间轴偏移的信号会截错区间(用户 2026-11-03)。 + const double timeShift = hasSourceStyle ? sourceStyle.offsetTime : 0.0; + const std::size_t count = std::min(axis.time.size(), source.values.size()); + std::vector keptTimes; + std::vector keptValues; + keptTimes.reserve(count); + keptValues.reserve(count); + for (std::size_t index = 0; index < count; ++index) + { + const double time = axis.time[index]; + if (!std::isfinite(time)) + { + continue; + } + const double shown = time + timeShift; + if (shown < low || shown > high) + { + continue; + } + keptTimes.push_back(time); + keptValues.push_back(source.values[index]); + } + if (keptValues.size() < 2) + { + m_plot.ShowPersistentHint(L"选区截取失败:区间内的有效采样点不足两个。"); + return false; + } + + // 2. 名称尾缀:`(AAAA - BBBB)`,秒、保留三位小数(用户 2026-11-03 明确要求)。 + // 数值取**显示时刻**(与双光标读数一致),因此加上 `timeShift`。 + const std::wstring suffix = + L" (" + FormatDecimalFixed(keptTimes.front() + timeShift, 3) + L"s - " + + FormatDecimalFixed(keptTimes.back() + timeShift, 3) + L"s)"; + + // 3. 新建时间轴(截取后的采样刻度无法复用源信号的时间轴)。 + TimeAxis newAxis; + newAxis.name = axis.name; + newAxis.time = std::move(keptTimes); + newAxis.monotonic = true; + newAxis.finiteCount = newAxis.time.size(); + newAxis.minTime = newAxis.time.front(); + newAxis.maxTime = newAxis.time.back(); + std::size_t axisIndex = 0; + if (!m_dataSet.AppendGroupAxis(groupIndex, std::move(newAxis), axisIndex)) + { + return false; + } + + // 4. 组装新信号并插入到源信号下方。 + SignalData derived; + derived.name = baseName + suffix; + derived.axisIndex = axisIndex; + derived.values = std::move(keptValues); + + int newIndex = -1; + if (!InsertSignalBelowSource(groupIndex, signalIndex, std::move(derived), true, newIndex)) + { + return false; + } + + // 5. 继承源信号的**整套调整**(不烘值 ⇒ 新曲线与源信号在区间内逐像素重合)。 + if (hasSourceStyle) + { + if (SignalStyle* derivedStyle = m_dataSet.Style(SignalRef{groupIndex, newIndex})) + { + derivedStyle->color = sourceStyle.color; + derivedStyle->customColor = sourceStyle.customColor; + derivedStyle->opacity = sourceStyle.opacity; + derivedStyle->lineWidth = sourceStyle.lineWidth; + derivedStyle->mode = sourceStyle.mode; + derivedStyle->formula = sourceStyle.formula; + derivedStyle->gain = sourceStyle.gain; + derivedStyle->absolute = sourceStyle.absolute; + derivedStyle->mirrorVertical = sourceStyle.mirrorVertical; + derivedStyle->mirrorHorizontal = sourceStyle.mirrorHorizontal; + derivedStyle->offsetY = sourceStyle.offsetY; + derivedStyle->offsetTime = sourceStyle.offsetTime; + } + } + + SyncTabsToDataSet(); + return true; + } + + bool Application::ClipSignalInPlace(int groupIndex, int signalIndex, double begin, double end) + { + if (!std::isfinite(begin) || !std::isfinite(end)) + { + return false; + } + const double low = std::min(begin, end); + const double high = std::max(begin, end); + if (!(high > low)) + { + m_plot.ShowPersistentHint(L"选区截取失败:双光标区间宽度为 0。"); + return false; + } + + // 破坏性截取会移动/释放采样 ⇒ 先暂停后台派生计算,避免它读到已释放的数据。 + m_plot.PauseDerivedAsync(); + // ⚠️ 双光标给的是**显示时刻**,而采样时刻是 `time`;这里换算回采样时刻区间 + // (`offsetTime` 不烘进采样值,只平移绘制位置)。 + double timeShift = 0.0; + if (const SignalStyle* style = m_dataSet.Style(SignalRef{groupIndex, signalIndex})) + { + timeShift = style->offsetTime; + } + // ⚠️⚠️ **必须先让该信号独占时间轴**再裁剪:同组信号常常共享同一条时间轴, + // 直接裁剪会把其它信号的时间基准一起改短 ⇒ 它们的采样与时间错位 + //(用户 2026-11-03 报告:"多个信号同时截取时截取出来的信号并不对")。 + std::size_t ownAxis = 0; + if (!m_dataSet.ForkAxisForSignal(groupIndex, signalIndex, ownAxis)) + { + m_plot.ShowPersistentHint(L"选区截取失败:无法为该信号创建独立时间轴。"); + return false; + } + if (!m_dataSet.ClipSignalRange(groupIndex, signalIndex, low - timeShift, high - timeShift)) + { + m_plot.ShowPersistentHint(L"选区截取失败:区间内的有效采样点不足两个。"); + return false; + } + SyncTabsToDataSet(); + return true; + } + + bool Application::InsertSignalBelowSource(int groupIndex, int sourceIndex, SignalData&& signal, + bool makeVisible, int& newIndex) + { + newIndex = -1; + // 插入派生信号会移动该分组的信号存储:先暂停后台计算,避免其读取已移动的数据。 + m_plot.PauseDerivedAsync(); + if (!m_dataSet.InsertDerivedSignal(groupIndex, sourceIndex, std::move(signal), newIndex)) + { + return false; + } + for (TabConfig& tab : m_tabs) + { + tab.RemapAfterSignalInsert(groupIndex, newIndex); + } + + // 在当前标签页中勾选新信号,并把它所在的网格移到原信号网格的下方。 + if (makeVisible && !m_tabs.empty()) + { + const SignalRef insertedRef{groupIndex, newIndex}; + ActiveTab().SetVisible(insertedRef, true); + const SignalRef sourceRef{groupIndex, sourceIndex}; + ActiveTab().MoveLaneOfSignalBelow(insertedRef, sourceRef); + } + return true; + } + + bool Application::CloneSignal(const SignalRef& sourceRef) + { + const SignalGroup* group = m_dataSet.GroupAt(sourceRef.groupIndex); + if (group == nullptr || sourceRef.signalIndex < 0 || + sourceRef.signalIndex >= static_cast(group->signals.size())) + { + return false; + } + const SignalData& source = group->signals[static_cast(sourceRef.signalIndex)]; + + // 源信号的样式快照(插入会移动信号存储,之后不能再解引用 `source`)。 + SignalStyle transform; + bool hasStyle = false; + if (const SignalStyle* style = m_dataSet.Style(sourceRef)) + { + transform = *style; + hasStyle = true; + } + + // ⚠️ 用户 2026-11-03 明确要求:「克隆该信号」**不做任何烘焙**。 + // 新信号直接使用源信号的**原始采样数据**,配方上只沿用: + // * 外观(颜色 / 自定义色 / 透明度 / 线宽 / 绘制方式); + // * **时间轴偏移**(`offsetTime` 只能留在样式上 —— 共用同一条时间轴, + // 烘焙进采样值会在越界段写 NaN,见 `BuildDerivedSignal` 2c 的说明)。 + // 公式映射 / 系数 / 绝对值 / 镜像 / Y 偏移一律**保持默认**(不烘也不应用)。 + // 需要“连调整一起复制”请用「克隆该信号(带调整)」(`CloneSignalAdjusted`)。 + SignalData clone; + clone.name = L"*" + source.name; + clone.axisIndex = source.axisIndex; + clone.values = source.values; + + int newIndex = -1; + if (!InsertSignalBelowSource(sourceRef.groupIndex, sourceRef.signalIndex, std::move(clone), + true, newIndex)) + { + return false; + } + + // 外观:克隆要**看起来与原信号一样**,但所有数值调整保持默认值。 + if (SignalStyle* cloned = m_dataSet.Style(SignalRef{sourceRef.groupIndex, newIndex})) + { + if (hasStyle) + { + cloned->color = transform.color; + cloned->customColor = transform.customColor; + cloned->opacity = transform.opacity; + cloned->lineWidth = transform.lineWidth; + cloned->mode = transform.mode; + // ⚠️ 只保留时间轴偏移,其余调整保持默认(用户 2026-11-03 明确要求)。 + cloned->offsetTime = transform.offsetTime; + } + // ⚠️ 单点信号默认会改成「散点 + 最大线宽」(`InsertDerivedSignal` 里的规则), + // 克隆应该用**原信号的样式**,因此上面几行把它覆盖回去了。 + } + + SyncTabsToDataSet(); + SetStatusMessage(L"已克隆信号:" + m_dataSet.GroupAt(sourceRef.groupIndex) + ->signals[static_cast(newIndex)] + .name, + false); + InvalidateRect(m_hwnd, nullptr, TRUE); + return true; + } + + bool Application::CloneSignalAdjusted(const SignalRef& sourceRef) + { + const SignalGroup* group = m_dataSet.GroupAt(sourceRef.groupIndex); + if (group == nullptr || sourceRef.signalIndex < 0 || + sourceRef.signalIndex >= static_cast(group->signals.size())) + { + return false; + } + const SignalData& source = group->signals[static_cast(sourceRef.signalIndex)]; + + SignalStyle transform; + bool hasStyle = false; + if (const SignalStyle* style = m_dataSet.Style(sourceRef)) + { + transform = *style; + hasStyle = true; + } + + // 「克隆该信号(带调整)」:同样是**原始采样数据**(不做烘焙), + // 区别只在样式——完整沿用源信号的整套调整参数(公式映射 / 系数 / Y 偏移 / + // 时间轴偏移 / 水平与垂直镜像 / 绝对值),因此两条曲线**逐像素重合**, + // 且新信号可以独立继续调参而不影响源信号(用户 2026-11-03 明确要求)。 + SignalData clone; + clone.name = L"*" + source.name; + clone.axisIndex = source.axisIndex; + clone.values = source.values; + + int newIndex = -1; + if (!InsertSignalBelowSource(sourceRef.groupIndex, sourceRef.signalIndex, std::move(clone), + true, newIndex)) + { + return false; + } + + if (SignalStyle* cloned = m_dataSet.Style(SignalRef{sourceRef.groupIndex, newIndex})) + { + if (hasStyle) + { + // 外观。 + cloned->color = transform.color; + cloned->customColor = transform.customColor; + cloned->opacity = transform.opacity; + cloned->lineWidth = transform.lineWidth; + cloned->mode = transform.mode; + // 整套「调整」参数(与源信号完全一致)。 + cloned->formula = transform.formula; + cloned->gain = transform.gain; + cloned->absolute = transform.absolute; + cloned->mirrorVertical = transform.mirrorVertical; + cloned->mirrorHorizontal = transform.mirrorHorizontal; + cloned->offsetY = transform.offsetY; + cloned->offsetTime = transform.offsetTime; + } + } + + SyncTabsToDataSet(); + SetStatusMessage(L"已克隆信号(带调整):" + m_dataSet.GroupAt(sourceRef.groupIndex) + ->signals[static_cast(newIndex)] + .name, + false); + InvalidateRect(m_hwnd, nullptr, TRUE); + return true; + } + + bool Application::ApplyPendingDerivedBuild() + { + // 「重采样」的构建流程与均值/弱峰不同(要新建时间轴),单独处理。 + { + int laneIndex = -1; + std::vector items; + if (m_plot.TakePendingResampleBuild(laneIndex, items) && !items.empty()) + { + // **同一文件内必须按下标降序处理**(每次插入只让插入点之后的信号下标 +1)。 + std::sort(items.begin(), items.end(), + [](const PlotView::ResampleBuild& a, const PlotView::ResampleBuild& b) + { + if (a.groupIndex != b.groupIndex) + { + return a.groupIndex < b.groupIndex; + } + return a.signalIndex > b.signalIndex; + }); + int built = 0; + double lastInterval = 0.0; + for (const PlotView::ResampleBuild& item : items) + { + if (BuildResampledSignal(item.groupIndex, item.signalIndex, item.interval)) + { + ++built; + lastInterval = item.interval; + } + } + if (built > 0) + { + SetStatusMessage(L"已构建 " + std::to_wstring(built) + L" 条重采样信号(频率 " + + FormatFrequency(lastInterval > 0.0 ? 1.0 / lastInterval : 0.0) + + L")。", + false); + } + return built > 0; + } + } + + // 「选区截取」:两个按钮(截取为新信号 / 原始信号截取)共用同一份区间。 + { + int laneIndex = -1; + std::vector items; + if (m_plot.TakePendingClipBuild(laneIndex, items) && !items.empty()) + { + // 同一文件内必须按下标降序处理(插入会让后续下标 +1)。 + std::sort(items.begin(), items.end(), + [](const PlotView::ClipBuild& a, const PlotView::ClipBuild& b) + { + if (a.groupIndex != b.groupIndex) + { + return a.groupIndex < b.groupIndex; + } + return a.signalIndex > b.signalIndex; + }); + int built = 0; + for (const PlotView::ClipBuild& item : items) + { + if (item.destructive + ? ClipSignalInPlace(item.groupIndex, item.signalIndex, item.begin, item.end) + : BuildClippedSignal(item.groupIndex, item.signalIndex, item.begin, item.end)) + { + ++built; + } + } + if (built > 0) + { + SetStatusMessage((items.front().destructive ? L"已截取 " : L"已截取为新信号 ") + + std::to_wstring(built) + L" 条。", + false); + } + return built > 0; + } + } + + // 两类「构建为新信号」请求(均值滤波 / 弱峰调整)机制完全一致, + // 统一整理成同一份构建清单后按下标降序处理。 + std::vector requests; + { + int laneIndex = -1; + std::vector items; + if (m_plot.TakePendingMeanFilterBuild(laneIndex, items)) + { + for (const PlotView::MeanFilterBuild& item : items) + { + DerivedSignalRequest request; + request.groupIndex = item.groupIndex; + request.signalIndex = item.signalIndex; + request.points = item.points; + request.iterations = item.iterations; + request.weakPeak = false; + requests.push_back(request); + } + } + } + { + int laneIndex = -1; + std::vector items; + if (m_plot.TakePendingWeakPeakBuild(laneIndex, items)) + { + for (const PlotView::WeakPeakBuild& item : items) + { + DerivedSignalRequest request; + request.groupIndex = item.groupIndex; + request.signalIndex = item.signalIndex; + request.amountPercent = item.amountPercent; + request.iterations = item.iterations; + request.weakPeak = true; + requests.push_back(request); + } + } + } + if (requests.empty()) + { + return false; + } + if (m_tabs.empty()) + { + return false; + } + + // **同一文件内必须按下标降序处理**:每次插入只让插入点之后的信号下标 +1, + // 降序处理可保证尚未处理的信号下标始终有效(升序会把新插入的信号当成原信号)。 + std::sort(requests.begin(), requests.end(), + [](const DerivedSignalRequest& a, const DerivedSignalRequest& b) + { + if (a.groupIndex != b.groupIndex) + { + return a.groupIndex < b.groupIndex; + } + return a.signalIndex > b.signalIndex; + }); + + int built = 0; + DerivedSignalRequest last; + for (const DerivedSignalRequest& request : requests) + { + if (BuildDerivedSignal(request, true)) + { + ++built; + last = request; + } + } + + if (built > 0) + { + std::wstring text = L"已构建 " + std::to_wstring(built) + L" 条 "; + if (last.weakPeak) + { + text += L"弱峰调整 " + std::to_wstring(last.amountPercent) + L"% " + + std::to_wstring(last.iterations) + L"次迭代信号。"; + } + else + { + text += std::to_wstring(last.points) + L"点均值 " + + std::to_wstring(last.iterations) + L"次迭代滤波信号。"; + } + SetStatusMessage(text, false); + } + return built > 0; + } + +} // namespace jdi diff --git a/Components/Application/Src/ApplicationMenus.cpp b/Components/Application/Src/ApplicationMenus.cpp new file mode 100644 index 0000000..8c19bf7 --- /dev/null +++ b/Components/Application/Src/ApplicationMenus.cpp @@ -0,0 +1,409 @@ +// Application 实现分片 —— **菜单**:绘图区右键菜单(含「移动到…」子菜单)、 +// 菜单数值回写、主题切换、设置菜单,以及菜单结果的统一派发。 +// +// 共用的常量与工具函数在 ApplicationInternal.hpp(命名空间 `appdetail`,全部 inline)。 +// 本文件由 2026-11-04 代码审查从 `ApplicationCommands.cpp`(原 2,165 行)按职责拆出, +// **纯搬移、零逻辑改动**。分片划分见 `Documents/Code_Review_Plan.md` 的 P11。 +#include "ApplicationInternal.hpp" + +namespace jdi +{ + + void Application::ShowPlotContextMenu(int laneIndex, int x, int y) + { + if (laneIndex < 0) + { + return; + } + + m_plotMenuLane = laneIndex; + + std::vector items; + + MenuItem header; + header.kind = MenuItemKind::HEADER; + header.label = L"网格 " + std::to_wstring(laneIndex + 1); + items.push_back(header); + + // 当前网格的 Y 轴上下限值直接在菜单内的输入框里显示,单击即可就地编辑(菜单不关闭)。 + double laneYMin = 0.0; + double laneYMax = 1.0; + m_plot.LaneRange(laneIndex, laneYMin, laneYMax); + + MenuItem maxItem; + maxItem.kind = MenuItemKind::EDIT; + maxItem.label = L"Y 轴最大值"; + maxItem.id = PLOT_CMD_LANE_Y_MAX; + maxItem.value = FormatValue(laneYMax); + maxItem.filter = EditFilter::NUMBER; + items.push_back(maxItem); + + MenuItem minItem; + minItem.kind = MenuItemKind::EDIT; + minItem.label = L"Y 轴最小值"; + minItem.id = PLOT_CMD_LANE_Y_MIN; + minItem.value = FormatValue(laneYMin); + minItem.filter = EditFilter::NUMBER; + items.push_back(minItem); + + // 选区自适应(切换项):勾选后每次本网格的时间轴选区放大都会自动匹配 Y 轴幅值。 + MenuItem autoViewItem; + autoViewItem.label = L"选区 Y 轴自适应"; + autoViewItem.id = PLOT_CMD_LANE_AUTO_VIEW; + autoViewItem.checked = m_plot.LaneAutoFitToSelection(laneIndex); + items.push_back(autoViewItem); + + // 时间轴自适应到本网格信号范围(原先位于分隔线下方,现移到分隔线上方)。 + MenuItem fitLaneTime; + fitLaneTime.label = L"按网格时间轴展开"; + fitLaneTime.id = PLOT_CMD_LANE_FIT_TIME; + items.push_back(fitLaneTime); + + // 「拆分当前网格」:把该网格内的信号按个拆成独立网格,插到其下方依次排列。 + // 可点击时在后方追加「(N个信号)」说明将拆出的网格数;只有 1 条信号时置灰且不加尾缀。 + MenuItem splitLane; + const std::size_t laneSignalCount = m_plot.LaneSignalCount(laneIndex); + splitLane.label = (laneSignalCount > 1) + ? (L"拆分当前网格(" + std::to_wstring(laneSignalCount) + L"个信号)") + : L"拆分当前网格"; + splitLane.id = PLOT_CMD_LANE_SPLIT; + splitLane.enabled = laneSignalCount > 1; + items.push_back(splitLane); + + // 实验性功能(二级菜单):横线上方,鼠标悬停即向右展开。 + MenuItem experimental; experimental.kind = MenuItemKind::SUBMENU; + experimental.label = L"实验性功能"; + { + MenuItem edgeCount; + edgeCount.label = L"边沿计数"; + edgeCount.id = PLOT_CMD_LANE_EDGE_COUNT; + edgeCount.checked = m_plot.LaneEdgeCount(laneIndex); + experimental.submenu.push_back(edgeCount); + + MenuItem edgeCenter; + edgeCenter.label = L"边沿计数(中心轴)"; + edgeCenter.id = PLOT_CMD_LANE_EDGE_CENTER; + edgeCenter.checked = m_plot.LaneEdgeCenterCount(laneIndex); + experimental.submenu.push_back(edgeCenter); + + MenuItem meanFilter; + meanFilter.label = L"均值滤波"; + meanFilter.id = PLOT_CMD_LANE_MEAN_FILTER; + meanFilter.checked = m_plot.LaneMeanFilter(laneIndex); + experimental.submenu.push_back(meanFilter); + + MenuItem weakPeak; + weakPeak.label = L"弱峰调整"; + weakPeak.id = PLOT_CMD_LANE_WEAK_PEAK; + weakPeak.checked = m_plot.LaneWeakPeak(laneIndex); + experimental.submenu.push_back(weakPeak); + + MenuItem freeMarker; + freeMarker.label = L"自由标记"; + freeMarker.id = PLOT_CMD_LANE_FREE_MARKER; + freeMarker.checked = m_plot.LaneFreeMarker(laneIndex); + + // 「重采样」位于「自由标记」**上方**(用户 2026-11-02 要求)。 + MenuItem resample; + resample.label = L"重采样"; + resample.id = PLOT_CMD_LANE_RESAMPLE; + resample.checked = m_plot.LaneResample(laneIndex); + experimental.submenu.push_back(resample); + + // 「选区截取」位于「重采样」下方、「自由标记」上方(用户 2026-11-03 要求)。 + MenuItem clip; + clip.label = L"选区截取"; + clip.id = PLOT_CMD_LANE_CLIP; + clip.checked = m_plot.LaneClip(laneIndex); + experimental.submenu.push_back(clip); + + experimental.submenu.push_back(freeMarker); + } + items.push_back(experimental); + + MenuItem separator; + separator.kind = MenuItemKind::SEPARATOR; + items.push_back(separator); + + // 全局开关:一次性打开/关闭全部网格各自的「选区 Y 轴自适应」。 + MenuItem autoFitAllItem; + autoFitAllItem.label = L"选区 Y 轴自适应(全局)"; + autoFitAllItem.id = PLOT_CMD_AUTO_FIT_ALL; + autoFitAllItem.checked = m_plot.AllLanesAutoFitToSelection(); + items.push_back(autoFitAllItem); + + MenuItem fitItem; + fitItem.label = L"全部自适应"; + fitItem.id = PLOT_CMD_FIT_ALL; + items.push_back(fitItem); + + RECT client = {}; + GetClientRect(m_hwnd, &client); + const gd::RectF bounds(0.0f, 0.0f, static_cast(client.right - client.left), + static_cast(client.bottom - client.top)); + + m_menu.Open(MenuScope::PLOT_VIEW, bounds, x, y, items); + } + + void Application::RefreshPlotMenuValues(int laneIndex) + { + if (laneIndex < 0) + { + return; + } + + double laneYMin = 0.0; + double laneYMax = 1.0; + m_plot.LaneRange(laneIndex, laneYMin, laneYMax); + m_menu.UpdateItemValue(PLOT_CMD_LANE_Y_MAX, FormatValue(laneYMax)); + m_menu.UpdateItemValue(PLOT_CMD_LANE_Y_MIN, FormatValue(laneYMin)); + + // 「选区 Y 轴自适应」(本网格)与「(全局)」都是切换项,实时反映各自的勾选状态。 + m_menu.UpdateItemLabel(PLOT_CMD_LANE_AUTO_VIEW, L"选区 Y 轴自适应", + m_plot.LaneAutoFitToSelection(laneIndex), true); + m_menu.UpdateItemLabel(PLOT_CMD_AUTO_FIT_ALL, L"选区 Y 轴自适应(全局)", + m_plot.AllLanesAutoFitToSelection(), true); + } + + void Application::ApplyTheme(ThemeKind kind) + { + if (kind == m_themeKind && m_theme.windowBg == ThemeFor(kind).windowBg) + { + return; // 幂等:同一个主题重复点击不必作废缓存 + } + m_themeKind = kind; + m_theme = ThemeFor(kind); + SetCurrentTheme(&m_theme); + + // 主题色烘进了多处位图缓存,必须整体作废(只作废场景缓存是不够的: + // 宽基底图层、装饰层、浮动面板层、信号列表行位图里都存着旧配色的像素)。 + m_plot.OnThemeChanged(); + m_panel.OnThemeChanged(); + + // 装饰区(标签栏 / 工具栏 / 面板)全部重画,并整窗回送。 + m_chromeDirty = true; + m_panelDirty = true; + InvalidateRect(m_hwnd, nullptr, TRUE); + } + + void Application::OpenSettingsMenu() + { + // 锚点取齿轮按钮的**实际矩形**:按钮宽度由控件实测,宿主不应自己推算几何。 + gd::RectF anchor; + if (!m_toolbar.ButtonRect(ToolbarAction::SETTINGS, anchor)) + { + // 尚未布局(极端情形):退化为从菜单快捷键位置展开,而不是什么都不发生。 + anchor = m_toolbarRect; + } + + std::vector items; + + // ── 调试 ────────────────────────────────────────────── + MenuItem debugHeader; + debugHeader.kind = MenuItemKind::HEADER; + debugHeader.label = L"调试"; + items.push_back(debugHeader); + + MenuItem boundsItem; + boundsItem.label = L"显示布局边界"; + boundsItem.id = kAppCmdShowLayoutBounds; + boundsItem.checked = m_showLayoutBounds; + // 切换项:点击后菜单保持打开,勾选态由宿主回写(与既有「镜像」等切换项一致)。 + boundsItem.keepOpen = true; + items.push_back(boundsItem); + + // ── 主题配色(二级菜单:鼠标悬停展开,带勾选态)────────────── + // 顺序固定为 Light / Solarised Light / Dark / Solarised Dark(用户 2026-10-29 指定)。 + // `keepOpen` 让点击主题后菜单不关闭,勾选态由宿主回写 —— 便于连续预览几种配色。 + MenuItem themeItem; + themeItem.kind = MenuItemKind::SUBMENU; + themeItem.label = L"主题配色"; + for (int index = 0; index <= static_cast(ThemeKind::SOLARIZED_DARK); ++index) + { + const ThemeKind kind = static_cast(index); + MenuItem option; + option.kind = MenuItemKind::NORMAL; + option.label = ThemeName(kind); + option.id = kAppCmdThemeBase + index; + option.checked = (m_themeKind == kind); + option.keepOpen = true; + themeItem.submenu.push_back(option); + } + items.push_back(themeItem); + + RECT client = {}; + GetClientRect(m_hwnd, &client); + const gd::RectF bounds(0.0f, 0.0f, static_cast(client.right - client.left), + static_cast(client.bottom - client.top)); + + // 右下展开,锚在按钮**下沿**(留 4 逻辑像素,视觉上不贴着按钮边框)。 + const int x = static_cast(anchor.X); + const int y = static_cast(anchor.Y + anchor.Height + 4.0f * m_scale); + m_menu.Open(MenuScope::APP_SETTINGS, bounds, x, y, items); + } + + void Application::DispatchMenuResult(const MenuResult& result) + { + if (!result.valid) + { + return; + } + + switch (result.scope) + { + case MenuScope::APP_SETTINGS: + { + // 应用设置菜单:全部条目都是**带勾选态的切换项**,菜单保持打开, + // 勾选变化通过 `UpdateItemLabel` 立即回写(连续切换多项时不用反复打开菜单)。 + if (result.id == kAppCmdShowLayoutBounds) + { + m_showLayoutBounds = !m_showLayoutBounds; + m_menu.UpdateItemLabel(kAppCmdShowLayoutBounds, L"显示布局边界", m_showLayoutBounds); + // 边界比组件矩形外扩 1px 且跨越所有装饰区 ⇒ 必须整帧重绘。 + m_chromeDirty = true; + } + else if (result.id >= kAppCmdThemeBase && + result.id <= kAppCmdThemeBase + static_cast(ThemeKind::SOLARIZED_DARK)) + { + const ThemeKind kind = static_cast(result.id - kAppCmdThemeBase); + ApplyTheme(kind); + // 回写勾选态:四项里只有当前主题打勾(子菜单项要用专用接口才会重建布局)。 + for (int index = 0; index <= static_cast(ThemeKind::SOLARIZED_DARK); ++index) + { + const ThemeKind option = static_cast(index); + m_menu.UpdateSubmenuItemLabel(kAppCmdThemeBase + index, ThemeName(option), + m_themeKind == option); + } + } + InvalidateRect(m_hwnd, nullptr, TRUE); + break; + } + case MenuScope::SIGNAL_PANEL: + { + const bool needRelayout = m_panel.OnMenuCommand(result); + // 「公式映射」等就地输入可能产生**非模态**警告(不使用 Windows 消息框), + // 由绘图区底部的自绘提示浮层显示(与加载失败提示同一套)。 + { + std::wstring hint; + if (m_panel.TakePendingHint(hint)) + { + m_plot.ShowPersistentHint(hint); + } + } + if (needRelayout) + { + Relayout(); + } + InvalidateRect(m_hwnd, nullptr, FALSE); + break; + } + case MenuScope::PLOT_VIEW: + { + // 菜单内联输入框的提交:就地更新网格 Y 轴上下限,菜单保持打开, + // 并把夹取后的真实值回写到菜单上(输入值可能被合法区间修正)。 + if (result.editCommit) + { + const int laneIndex = m_plotMenuLane; + if (laneIndex >= 0) + { + m_plot.ApplyLaneValueText(laneIndex, result.id == PLOT_CMD_LANE_Y_MAX, result.editText); + RefreshPlotMenuValues(laneIndex); + } + SetFocus(m_hwnd); + InvalidateRect(m_hwnd, nullptr, FALSE); + break; + } + + const int laneIndex = m_plotMenuLane; + m_plotMenuLane = -1; + switch (result.id) + { + case PLOT_CMD_LANE_AUTO_VIEW: + // 切换本网格的「选区 Y 轴自适应」。 + m_plot.ToggleLaneAutoFitToSelection(laneIndex); + break; + case PLOT_CMD_AUTO_FIT_ALL: + { + // 全局切换:一次打开/关闭全部网格各自的开关;打开时立即生效一次。 + const bool enable = !m_plot.AllLanesAutoFitToSelection(); + m_plot.SetAllLanesAutoFitToSelection(enable, true); + break; + } + case PLOT_CMD_LANE_FIT_TIME: + // 「按网格时间轴展开」:展开时间轴后同时把该网格的 Y 轴自适应一次。 + m_plot.FitTimeToLane(laneIndex); + break; + case PLOT_CMD_LANE_EDGE_COUNT: + // 「实验性功能 → 边沿计数」:切换本网格的横向光标与统计面板。 + m_plot.ToggleLaneEdgeCount(laneIndex); + break; + case PLOT_CMD_LANE_EDGE_CENTER: + // 「实验性功能 → 边沿计数(中心轴)」:切换中心平均值曲线与统计面板。 + m_plot.ToggleLaneEdgeCenterCount(laneIndex); + break; + case PLOT_CMD_LANE_MEAN_FILTER: + // 「实验性功能 → 均值滤波」:切换均值滤波曲线与参数面板。 + m_plot.ToggleLaneMeanFilter(laneIndex); + break; + case PLOT_CMD_LANE_WEAK_PEAK: + // 「实验性功能 → 弱峰调整」:切换弱峰调整曲线与参数面板。 + m_plot.ToggleLaneWeakPeak(laneIndex); + break; + case PLOT_CMD_LANE_FREE_MARKER: + // 「实验性功能 → 自由标记」:切换网格内跟随鼠标的纵向光标与固定数据点面板。 + m_plot.ToggleLaneFreeMarker(laneIndex); + break; + case PLOT_CMD_LANE_RESAMPLE: + // 「实验性功能 → 重采样」:切换重采样参数面板。 + m_plot.ToggleLaneResample(laneIndex); + break; + case PLOT_CMD_LANE_CLIP: + // 「实验性功能 → 选区截取」:切换选区截取面板。 + m_plot.ToggleLaneClip(laneIndex); + break; + case PLOT_CMD_LANE_SPLIT: + // 「拆分当前网格」:信号按个拆成独立网格,新网格顺次插入其下方。 + m_plot.SplitLane(laneIndex); + break; + case PLOT_CMD_FIT_ALL: + m_plot.FitAll(); + break; + default: + break; + } + SetFocus(m_hwnd); + InvalidateRect(m_hwnd, nullptr, TRUE); + break; + } + case MenuScope::TAB_BAR: + { + const int index = m_tabMenuIndex; + if (result.id == kMenuTabNew) + { + NewTab(true); + } + else if (result.id == kMenuTabClose) + { + CloseTab(index); + } + else if (result.id == kMenuTabCloseOthers) + { + if (index >= 0 && index < static_cast(m_tabs.size()) && m_tabs.size() > 1) + { + const TabConfig keep = m_tabs[static_cast(index)]; + m_tabs.clear(); + m_tabs.push_back(keep); + SetActiveTab(0); + } + } + m_tabMenuIndex = -1; + InvalidateRect(m_hwnd, nullptr, TRUE); + break; + } + case MenuScope::NONE: + default: + break; + } + } + +} // namespace jdi diff --git a/Components/Application/Src/ApplicationTabs.cpp b/Components/Application/Src/ApplicationTabs.cpp new file mode 100644 index 0000000..c828dbe --- /dev/null +++ b/Components/Application/Src/ApplicationTabs.cpp @@ -0,0 +1,360 @@ +// Application 实现分片 —— **标签页**:新建/关闭/切换/重排、视图同步、窗口标题、 +// 标签栏结果回调与标签页右键菜单。 +// +// 共用的常量与工具函数在 ApplicationInternal.hpp(命名空间 `appdetail`,全部 inline)。 +// 本文件由 2026-11-04 代码审查从 `ApplicationCommands.cpp`(原 2,165 行)按职责拆出, +// **纯搬移、零逻辑改动**。分片划分见 `Documents/Code_Review_Plan.md` 的 P11: +// 标签页 = 本文件;派生/重采样/截取/克隆 = `ApplicationDerive.cpp`; +// 上下文菜单与设置菜单 = `ApplicationMenus.cpp`;其余命令 = `ApplicationCommands.cpp`。 +#include "ApplicationInternal.hpp" + +namespace jdi +{ + + void Application::NewTab(bool collapseGroups) + { + TabConfig tab; + tab.SetName(NextTabName()); + // 用户新建的标签页:信号分组**默认折起**(用户明确要求:列表默认紧凑; + // 之后在该标签页里新加载的分组也沿用这个默认值)。 + tab.SetGroupsCollapsedByDefault(collapseGroups); + tab.SyncToDataSet(m_dataSet); + if (collapseGroups) + { + tab.SetAllCollapsed(true); + } + + // ⚠️ **新标签页不存在任何被点选的信号**(用户 2026-11-02 要求): + // 选中状态是**全局共享**的(`DataSet::Selected()`),不清掉的话新建出来的标签页会 + // 直接带着上一个标签页的选中信号 —— 样式编辑区于是有自己的“目标”,与新页“空白” + // 的预期不符。清空后样式区回到「未选择信号」状态,直到用户手动点选任意信号。 + m_dataSet.SetSelected(SignalRef()); + + m_tabs.push_back(std::move(tab)); + SetActiveTab(static_cast(m_tabs.size()) - 1); + } + + void Application::CloseTab(int index) + { + if (index < 0 || index >= static_cast(m_tabs.size())) + { + return; + } + if (m_tabs.size() <= 1) + { + // ⚠️ **唯一标签页不弹警告**(用户 2026-11-02 要求):直接删掉它并新建一个默认的 + // 「视图 1」—— 组全部折起、无选中信号、样式区无目标(由 `NewTab(true)` 保证)。 + // ⚠️ `m_tabs` 清空后 `NextTabName()` 必然返回「视图 1」,编号自然从 1 重新开始。 + // erase 与 `NewTab` 之间**不要**碰 `m_panel` / `m_plot`(它们的上下文仍指向 + // 刚被释放的 `TabConfig`);`NewTab` 里的 `SetActiveTab` 会第一时间重新绑定。 + m_tabs.erase(m_tabs.begin() + index); + m_activeTab = 0; + NewTab(true); + return; + } + + m_tabs.erase(m_tabs.begin() + index); + + int next = index; + if (next >= static_cast(m_tabs.size())) + { + next = static_cast(m_tabs.size()) - 1; + } + SetActiveTab(next); + } + + void Application::SetActiveTab(int index) + { + if (index < 0 || index >= static_cast(m_tabs.size())) + { + return; + } + + m_activeTab = index; + + // 重新绑定上下文:面板与绘图区均指向同一数据层与当前标签页配置。 + m_panel.SetContext(&m_dataSet, &m_tabs[static_cast(m_activeTab)]); + m_plot.SetContext(&m_dataSet, &m_tabs[static_cast(m_activeTab)]); + + RefreshTabBar(); + Relayout(); + + // 切换标签页后把该页已选中的信号滚动到可见位置。 + // 切换标签页后把共享的选中信号滚动到可见位置(选中状态跨页保持一致)。 + m_panel.ScrollToSignal(m_dataSet.Selected()); + + SynchronizeView(true); + UpdateWindowTitle(); + InvalidateRect(m_hwnd, nullptr, TRUE); + } + + void Application::RefreshTabBar() + { + std::vector names; + names.reserve(m_tabs.size()); + for (const TabConfig& tab : m_tabs) + { + names.push_back(tab.Name()); + } + m_tabBar.SetTabs(names); + m_tabBar.SetActiveIndex(m_activeTab); + } + + void Application::SynchronizeView(bool force) + { + if (m_tabs.empty() || m_activeTab < 0) + { + return; + } + + TabConfig& tab = m_tabs[static_cast(m_activeTab)]; + + const int visibleCount = tab.VisibleCount(); + const bool anyVisible = tab.AnyVisible(); + const std::size_t laneCount = tab.Lanes().size(); + + const bool changed = (visibleCount != m_cachedVisibleCount) || (anyVisible != m_cachedAnyVisible) || + (laneCount != m_cachedLaneCount); + if (!changed && !force) + { + return; + } + + const std::size_t previousLaneCount = m_cachedLaneCount; + m_cachedVisibleCount = visibleCount; + m_cachedAnyVisible = anyVisible; + m_cachedLaneCount = laneCount; + + // 标签页首次出现可见信号时,把时间轴对齐到可见数据的范围。 + // 没有任何可见信号时(未勾选 / 全部取消勾选 / 清空网格)主动复位自适应标记, + // 这样用户下一次勾选信号时会重新做一次自适应,而不是停留在上次的视图范围。 + if (!anyVisible) + { + tab.ViewInitialized() = false; + } + else if (!tab.ViewInitialized()) + { + // 首次出现可见信号时做一次自适应。单点信号(时间/幅值范围退化成 0)由 + // `FitAll` 内部按"单值散点"的规则处理(居中 + 一大格 1s),这里不再分开判断。 + m_plot.FitAll(); + // ⚠️ **网格纵向滚动也要回到最上方**(用户 2026-10-17 要求): + // “有过网格 → 没有信号显示 → 又加载了需要滚动条数量的网格”这一整类情形, + // 新出现的网格集必须从最上方开始看。旧实现保留了上一次的 `LaneScroll()`, + // 于是重新显示后会**停在与取消显示时相同的滚动位置**(用户报告)。 + // 放在 `FitAll()` 之后:`FitAll` 本身不改变滚动量(§11.2 第 112 条), + // 这里显式归零即可;随后 `Relayout`/`RefreshLanes` 会重算几何。 + tab.LaneScroll() = 0.0f; + // 用户 2026-10-17 要求:光标工具处于激活状态(单 / 双光标)而之前**没有任何信号绘制**时, + // 第一个信号出现后光标要按“光标出现的规则”重新出现一次,而不是停在旧的残留位置。 + // 规则与 `CycleCursorMode()` 首次启用完全同源;光标工具未激活时本调用无副作用。 + m_plot.ResetCursorPlacementOnFreshContent(); + } + + const bool laneCountChanged = (laneCount != previousLaneCount); + if (laneCountChanged || force) + { + // 网格数量变化会影响滚动条与绘图区宽度,必须重新布局。 + Relayout(); + } + else + { + m_plot.RefreshLanes(); + } + } + + void Application::SyncTabsToDataSet() + { + for (TabConfig& tab : m_tabs) + { + tab.SyncToDataSet(m_dataSet); + } + + // 选中状态由数据层共享维护,数据变动后需校验其是否仍指向存在的信号。 + m_dataSet.ValidateSelection(); + m_panel.InvalidateRows(); + + // 分组/信号结构变化会让信号引用与分组地址改变,派生缓存必须整体作废。 + m_plot.InvalidateDerivedCaches(); + + // 强制下一次同步重新计算视图状态。 + m_cachedVisibleCount = -1; + m_cachedAnyVisible = false; + m_cachedLaneCount = 0; + + if (m_hwnd != nullptr) + { + InvalidateRect(m_hwnd, nullptr, TRUE); + } + } + + void Application::UpdateWindowTitle() + { + // 标题栏 = 应用名称 + 分隔竖线 + 内存占用(单位在 KB/MB/GB 间自适应,保留两位小数)。 + // 除内存占用外不再附带标签页、文件数量等无关信息。 + // ⚠️ 内存口径 = **私有工作集**(与任务管理器"内存"列一致),不是总工作集。 + std::wstring title = kWindowTitle; + title += kTitleSeparator; + title += kRamTitlePrefix; + title += FormatByteSize(ProcessPrivateWorkingSetBytes()); + // 数值未变化时不调用 SetWindowTextW,避免无谓地重绘标题栏。 + if (title == m_windowTitleText) + { + return; + } + m_windowTitleText = title; + SetWindowTextW(m_hwnd, m_windowTitleText.c_str()); + } + + void Application::OnTabBarResult(const TabBarResult& result) + { + switch (result.type) + { + case TabBarResult::Type::ADD: + NewTab(true); + break; + case TabBarResult::Type::SELECT: + if (result.index != m_activeTab) + { + SetActiveTab(result.index); + } + break; + case TabBarResult::Type::CLOSE: + CloseTab(result.index); + break; + case TabBarResult::Type::CONTEXT: + ShowTabContextMenu(result); + break; + case TabBarResult::Type::REORDER: + MoveTab(result.index, result.targetIndex); + break; + case TabBarResult::Type::RENAME: + if (result.index >= 0 && result.index < static_cast(m_tabs.size()) && !result.text.empty()) + { + m_tabs[static_cast(result.index)].SetName(result.text); + RefreshTabBar(); + // SetTabs 会重置各标签的矩形,必须重新布局才能把它们算回来。 + Relayout(); + InvalidateRect(m_hwnd, nullptr, TRUE); + } + break; + case TabBarResult::Type::NONE: + default: + break; + } + } + + void Application::MoveTab(int from, int target) + { + if (from < 0 || from >= static_cast(m_tabs.size())) + { + return; + } + if (target < 0 || target > static_cast(m_tabs.size())) + { + return; + } + + // 拖拽语义:target 表示「插入到该下标之前」;向后拖时需扣除自身占位。 + int destination = target; + if (destination > from) + { + --destination; + } + destination = std::max(0, std::min(destination, static_cast(m_tabs.size()) - 1)); + if (destination == from) + { + return; + } + + // 保持当前激活的标签页在重排后仍处于激活状态。 + TabConfig moved = m_tabs[static_cast(from)]; + const int activeIndex = m_activeTab; + + m_tabs.erase(m_tabs.begin() + from); + m_tabs.insert(m_tabs.begin() + destination, std::move(moved)); + + int newActive = activeIndex; + if (activeIndex == from) + { + newActive = destination; + } + else if (from < activeIndex && destination >= activeIndex) + { + --newActive; + } + else if (from > activeIndex && destination <= activeIndex) + { + ++newActive; + } + + SetActiveTab(newActive); + InvalidateRect(m_hwnd, nullptr, TRUE); + } + + void Application::ShowTabContextMenu(const TabBarResult& result) + { + if (result.index < 0 || result.index >= static_cast(m_tabs.size())) + { + return; + } + + m_tabMenuIndex = result.index; + + std::vector items; + + MenuItem header; + header.kind = MenuItemKind::HEADER; + header.label = m_tabs[static_cast(result.index)].Name(); + items.push_back(header); + + MenuItem create; + create.label = L"新建标签页"; + create.id = kMenuTabNew; + items.push_back(create); + + MenuItem close; + close.label = L"关闭该标签页"; + close.id = kMenuTabClose; + close.enabled = (m_tabs.size() > 1); + items.push_back(close); + + MenuItem closeOthers; + closeOthers.label = L"关闭其它标签页"; + closeOthers.id = kMenuTabCloseOthers; + closeOthers.enabled = (m_tabs.size() > 1); + items.push_back(closeOthers); + + // 菜单绘制在客户区内,因此把屏幕坐标换算为客户区坐标。 + POINT anchor = {result.screenX, result.screenY}; + ScreenToClient(m_hwnd, &anchor); + + RECT client = {}; + GetClientRect(m_hwnd, &client); + const gd::RectF bounds(0.0f, 0.0f, static_cast(client.right - client.left), + static_cast(client.bottom - client.top)); + + m_menu.Open(MenuScope::TAB_BAR, bounds, anchor.x, anchor.y, items); + } + + bool Application::ApplyPendingRename() + { + SignalRef ref; + std::wstring name; + if (!m_plot.TakePendingRename(ref, name)) + { + return false; + } + + if (!m_dataSet.RenameSignal(ref.groupIndex, ref.signalIndex, name)) + { + return false; + } + + // 改名只影响显示,但需要刷新左侧列表与绘图区图例。 + m_panel.InvalidateRows(); + SetStatusMessage(L"信号已重命名为:" + name, false); + InvalidateRect(m_hwnd, nullptr, TRUE); + return true; + } + +} // namespace jdi diff --git a/Documents/Code_Review_Progress.md b/Documents/Code_Review_Progress.md index 6cfa834..db6d1da 100644 --- a/Documents/Code_Review_Progress.md +++ b/Documents/Code_Review_Progress.md @@ -78,4 +78,28 @@ map 中 `cp-demangle.o` 已无 `.text` 段分配。 **验证**:release 与 debug 均全量重建,零 error 零 warning(`-O0` 下也没有新警告)。 +### P7 面板状态/滚动量收敛为唯一派发点 —— ✅ 完成(提交 `7a3fefb`) + +**问题(屎山核心之一)**:`Lane` 里 7 种实验性功能各有 1 份 `LanePanelState` + 1 个滚动偏移 +(14 个字段)。旧实现在**每一处**“按种类取用”的地方各写一遍嵌套三元式或 `switch`: +`EdgePanelLayoutFor` 5 条 7 层嵌套三元式;标题栏拖拽、✕ 关闭、滚轮、滚动条、错开序数各写一遍。 +新增面板要改 20+ 处,漏一处就是「拖不动 / 关不掉 / 滚轮滚不到底」 +(`MARK`、`RESAMPLE`、`CLIP` 均真实踩过)。 + +**改动**:`PlotViewInternal.hpp` 新增唯一派发点 +`LanePanelEnabled` / `LanePanelStateOf` / `LanePanelScrollOf`(可写 + 只读)。 +这些 `switch` **穷举 7 种且不写 `default:`** ⇒ `-Wswitch` 在漏登记时编译报警 +(已用最小程序验证 `-Wswitch` 确实会报)。 + +调用点:布局 5 条三元式 → 3 行;标题栏拖拽 7 分支 switch → 1 次写入; +滚轮/滚动条/✕ 关闭/折叠切换全部改为穷举无 `default`。 + +**验证**:release + debug 零 warning;产物 **793,088 B 不变**(等价变换,说明只是结构收敛)。 + +**未做(受验证条件限制)**:尚未把 `EdgePanelLayoutFor` 的 957 行分支整体改成 +“每面板一行元数据表”。原因:没有可用的离屏像素对比工具(`.preflight` 已删除, +且崩溃会弹模态框),纯逻辑重写无法自证“像素完全一致”。已通过上面的派发收敛 +消除最容易出错的一类重复,剩余重复属于“几何算式本身”,风险高于收益。 + + -- 2.54.0 From f13944034ae233eba890de122feb90ed9c963869 Mon Sep 17 00:00:00 2001 From: "DeepSeek-V4.1-Flash" <> Date: Wed, 4 Nov 2026 03:07:48 +0800 Subject: [PATCH 07/22] =?UTF-8?q?docs(review):=20=E6=B8=85=E7=90=86?= =?UTF-8?q?=E8=BF=87=E6=97=B6=E6=B3=A8=E9=87=8A=E5=B9=B6=E6=A0=A1=E6=AD=A3?= =?UTF-8?q?=E9=A1=B9=E7=9B=AE=E8=AE=B0=E5=BF=86=E4=B8=AD=E7=9A=84=E6=9E=84?= =?UTF-8?q?=E5=BB=BA/=E7=9B=AE=E5=BD=95=E8=AF=B4=E6=98=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - PlotView.hpp:删除“已迁移到数据层的公式包络缓存”这个指向已删代码的注释块。 - agent_mem.md §2:修正 -Os -> -Oz;补充体积相关开关(NO_EXCEPTIONS / NO_RTTI / LTO / RELEASE_ONLY_OPT)与“改 Makefile 必须删 .o 全量重编”的提醒; 链接库列表补 msimg32;忽略列表补 Temp/、.preflight/、Tools/。 - agent_mem.md §3:目录结构按现状重写(Shared/ 已承载通用控件与 PopupMenu/ ColorPickerPopup;Utilities 现为数据与格式层:DataModel/DataLoader/CsvLoader/ Mf4Loader/MatLoader/MatH5/Deflate/FormulaEval/Utf8Util/MemProbe; 删掉早已不存在的 CsvTable/、DataModel/、PaintUtil/ 等旧条目),并写明依赖方向。 - agent_mem.md 末尾新增第 33 轮(本轮审查)记录。 - Code_Review_Progress.md 追加 P15(注释/文档校正)。 --- Components/PlotView/Inc/PlotView.hpp | 7 -- Documents/Code_Review_Progress.md | 17 ++++ Documents/agent_mem.md | 123 ++++++++++++++++++++++----- 3 files changed, 121 insertions(+), 26 deletions(-) diff --git a/Components/PlotView/Inc/PlotView.hpp b/Components/PlotView/Inc/PlotView.hpp index 4d5a9c2..b731884 100644 --- a/Components/PlotView/Inc/PlotView.hpp +++ b/Components/PlotView/Inc/PlotView.hpp @@ -3620,13 +3620,6 @@ namespace jdi * 只在信号带公式时建立;条目键含公式文本与影响数值的样式字段, * 公式 / 系数 / 镜像等变化时自然失效。用 `deque` 保证追加时已有引用不失效。 */ - /** - * @brief 「公式映射」信号的绘制值缓存(**唯一实现在数据层**)。 - * - * 旧实现是 `mutable std::deque m_formulaEnvelopes;` —— 与 - * `TabConfig::ComputeLaneRange` 各扫一遍整条信号。现已合并进 - * `DataSet::m_formulaTransforms`(见 `DataSet::FormulaTransformFor`)。 - */ /** * @brief 折线顶点缓存的**容量上限**(条目数)。 * diff --git a/Documents/Code_Review_Progress.md b/Documents/Code_Review_Progress.md index db6d1da..e2d7aa6 100644 --- a/Documents/Code_Review_Progress.md +++ b/Documents/Code_Review_Progress.md @@ -101,5 +101,22 @@ map 中 `cp-demangle.o` 已无 `.text` 段分配。 且崩溃会弹模态框),纯逻辑重写无法自证“像素完全一致”。已通过上面的派发收敛 消除最容易出错的一类重复,剩余重复属于“几何算式本身”,风险高于收益。 +### P11 拆分 `ApplicationCommands.cpp` —— ✅ 完成(提交 `8437100`) + +原文件 **2,165 行**,混杂六类职责。按职责**纯搬移**(零逻辑改动): + +| 新文件 | 行数 | 内容 | +| --- | --- | --- | +| `ApplicationTabs.cpp` | 355 | 标签页新建/关闭/切换/重排、视图同步、窗口标题、标签栏回调、标签页右键菜单 | +| `ApplicationDerive.cpp` | 786 | 派生烘焙、重采样、选区截取(新建/就地)、克隆(含带调整) | +| `ApplicationMenus.cpp` | 409 | 绘图区右键菜单、菜单数值回写、主题切换、设置菜单、菜单结果派发 | +| `ApplicationCommands.cpp` | 642 | 分组/信号增删、移动与重排、文件对话框、工具栏动作、键盘鼠标分发、布局边界绘制 | + +Makefile 按目录通配收集源文件,**无需登记新文件**。 + +**验证**:release 零 error 零 warning;产物 793,088 → 794,112 B(+1,024,LTO 内联边界 +变化,< 0.13%)。 + + diff --git a/Documents/agent_mem.md b/Documents/agent_mem.md index 4b4b02f..988f324 100644 --- a/Documents/agent_mem.md +++ b/Documents/agent_mem.md @@ -20,13 +20,20 @@ - 工具链:**MinGW-w64**(`x86_64-win32-seh-rev0`, MinGW-Builds 16.1.0),可直接用 `make`。 - 构建入口:根目录 `Makefile`(C / C++ / windres 混合构建)。 - 常用命令: - - `make release` → `Build/Release/Target.exe`(`-Os`,strip:`-s`)。 - - `make debug` → `Build/Debug/Target.exe`(`-O0 -g`)。 + - `make release` → `Build/Release/Target.exe`(`-Oz` + LTO + `-s`,strip)。 + - `make debug` → `Build/Debug/Target.exe`(`-O0 -g`,**不** strip)。 - `make` → 同时构建 Release 与 Debug。 - `make clean` → 删除 `Build/`。 -- 关键编译设置:`-std=c++17`;`-finput-charset=UTF-8 -fexec-charset=GBK -fwide-exec-charset=UTF-16LE`;`-static`;`-mwindows`(GUI);链接 `gdi32 gdiplus comdlg32 d3d11 dwmapi d3dcompiler`。 -- **新增组件/工具目录后,必须在 `Makefile` 的 `CXX_INCLUDES_PATHS` 与 `CXX_SOURCES_PATHS` 中登记**(源文件按目录通配自动收集)。 -- 构建产物统一在 `Build/`,已由 `.gitignore` 忽略;同时忽略 `*.o/*.d/*.lst/*.map/*.exe` 与 `TestData/`。 +- 关键编译设置:`-std=c++17`;`-finput-charset=UTF-8 -fexec-charset=GBK -fwide-exec-charset=UTF-16LE`; + `-static`;`-mwindows`(GUI);链接 `gdi32 gdiplus msimg32 comdlg32 d3d11 dwmapi d3dcompiler`。 +- 体积相关开关(Makefile 顶部,**改这些必须删 `*.o` 全量重编,Makefile 变更不会自动触发重编**): + - `NO_EXCEPTIONS = 1` → `-fno-exceptions`。 + - `NO_RTTI = 1` → `-fno-rtti`(全仓无 `dynamic_cast`/`typeid`;见 `MemProbe.cpp` 里对 + `__gnu_cxx::__verbose_terminate_handler` 的覆盖,它同时省掉 56 KB 的 `cp-demangle`)。 + - `LTO = 1` → `-flto -flto-partition=one`。 + - `RELEASE_ONLY_OPT` = `-fno-asynchronous-unwind-tables`(**只**加在 Release 规则上)。 +- **新增组件/工具目录后,必须在 `Makefile` 的 `CXX_INCLUDES_PATHS` 与 `CXX_SOURCES_PATHS` 中登记**(源文件按目录通配自动收集;只加 `.cpp` 文件到已登记目录时无需改 Makefile)。 +- 构建产物统一在 `Build/`,已由 `.gitignore` 忽略;同时忽略 `*.o/*.d/*.lst/*.map/*.exe`、`TestData/`、`Temp/`、`.preflight/`、`Tools/`。 --- @@ -34,27 +41,42 @@ ``` Components/ 功能组件块(每块 Inc/ 放 .hpp,Src/ 放 .cpp) - Application/ 应用主框架:窗口、布局、消息分发、标签页、绘制合成 + Application/ 应用主框架:窗口、布局、消息分发、绘制合成 + (分片:Application.cpp / ApplicationTabs.cpp / ApplicationDerive.cpp / + ApplicationMenus.cpp / ApplicationCommands.cpp / ApplicationLoading.cpp) Main/ 程序入口(WinMain → Application::Run) + Shared/ 与业务无关的通用层:UiComponent(顶层组件协议)、UiWidget(控件树)、 + UiLayout / UiBox / UiSurface / UiButtonWidget / UiControls、 + PaintUtil(低层绘制原语)、Theme、TextEdit、PopupMenu、ColorPickerPopup、 + UiDebugBounds(「显示布局边界」唯一消费者) TabBar/ 顶部多标签栏 - Toolbar/ 顶部工具栏(导入文件 / 适应视图 / 时间轴自适应 / Y 轴自适应 / 选区放大 / 光标 / 合并·拆分·清空网格) - SignalPanel/ 左侧信号面板:按文件分组的信号树、可见性勾选、样式编辑 + Toolbar/ 顶部工具栏(设置 / 导入文件 / 适应视图 / Y 轴自适应 / 选区放大 / 光标 / + 合并·拆分·清空网格) + SignalPanel/ 左侧信号面板:按分组信号树、可见性勾选、样式与公式编辑、拖拽排序 PlotView/ 波形绘图区:网格、时间轴、光标、缩放、拖拽、实验性功能面板 -Utilities/ 工具与辅助模块(Inc/ + Src/) - Application/ ... (见上) - CsvTable/ CSV 流式解析(列优先) - DataModel/ 数据层:DataSet / DataFile / SignalData / 显示配置 - PaintUtil/ GDI+ 绘制与文本/控件辅助(含 UiPointInRect、UiMeasureTextWidth 等) - PopupMenu/ 主题化自绘弹出菜单(含二级子菜单、内联编辑) - ColorPickerPopup/ 浮动拾色器 - TextEdit/ 内联文本编辑模型 - Theme/ 主题色板(Solarised Light)与波形默认配色 - Utf8Util/ 编码转换(UTF-8 ↔ UTF-16 等) + (分片:PlotView.cpp / PlotViewPaint.cpp / PlotViewRaster.cpp / + PlotViewPanels.cpp / PlotViewCacheKey.cpp / PlotViewFeatures.cpp / + PlotViewInteraction.cpp) +Utilities/ 与 UI 无关的数据与格式层(Inc/ + Src/) + DataModel.* 数据层:DataSet / SignalGroup / TimeAxis / SignalData / SignalStyle / TabConfig + DataModelTab.* TabConfig(每标签页的视图配置)实现 + DataTable.hpp 加载层中间表(TableBlock) + DataLoader.* 加载入口(按扩展名分发) + CsvLoader.* CSV(多 time 列 = 多时间轴) + Mf4Loader.* ASAM MDF v4(.mf4/.mdf) + MatLoader.* MATLAB .mat(v5 / v7.3 HDF5) + MatH5.* HDF5 只读子集 + Deflate.* HDF5 分块解压(内置 inflate,不依赖 zlib) + FormulaEval.* 「公式映射」表达式编译与求值 + Utf8Util.* 编码转换 + 数值/时长/频率格式化 + MemProbe.* 内存预检验与 terminate 兜底 Resources/ 图标、清单(.manifest)、版本信息(.rc)、ResourceIds.h TestData/ 本地测试 CSV(**不纳入版本控制**) -Documents/ 项目文档(含本文件) +Documents/ 项目文档(含本文件、Code_Review_* 系列) ``` +- **依赖方向固定**:`Components/*` → `Components/Shared` → `Utilities`(不得反向)。 + `PopupMenu` / `ColorPickerPopup` 历史上放在 `Utilities`,现已在 `Components/Shared`。 - **命名硬性约束**:源文件/头文件**不得含空格**,只能字母与下划线组合。 - **头文件后缀**:C 用 `.h`,C++ 用 `.hpp`;C 源 `.c`,C++ 源 `.cpp`。 - **防重复包含**:每个头文件必须 `#ifndef __XXX_XX_HPP__ / #define __XXX_XX_HPP__ / ... / #endif`;宏名 = 文件名的双边双下划线 + 全大写。 @@ -10283,3 +10305,66 @@ release/debug **零警告**;Release `TargetCheck.exe` **890,880 B**(< 1 MB * 首次 379ms 仍是同步阻塞。若要彻底无感需把公式缓存改成后台线程计算 + 未就绪时回退到保序端点映射;当前收益不足以承担该复杂度。 +--- + +## 2026-11-04 · 第 33 轮(`Code_Review` 分支):全项目结构/性能/体积审查 + +> 完整节点计划见 `Documents/Code_Review_Plan.md`,逐节点记录见 +> `Documents/Code_Review_Progress.md`,结论见 `Documents/Code_Review_Report.md`。 +> 本轮同时删除了过时的 `Documents/UI_Refactor_{Plan,Progress,Report}.md`。 + +### 1. 体积:890,880 B → **794,112 B**(−96,768,−10.9%) + +* **`dynamic_cast` 是体积炸弹**:`Toolbar::BuildBoxes` 里唯一的 + `dynamic_cast` 是**多余强转**(`Button::widget` 本来就是该类型)。 + 最小复现程序实测 39,936 → 115,200 B(+75,264)。 +* **光关 RTTI 不够**:`cp-demangle.o` 仍在链接里 —— 真凶是 libstdc++ 的 `vterminate.o` + (被 `eh_terminate.o` 的 `std::terminate` 拉入,它引用 `__cxa_demangle`)。 + 在 `MemProbe.cpp` 里**自行定义** `__gnu_cxx::__verbose_terminate_handler()` + ⇒ `vterminate.o` 不再被拉入 ⇒ 整个 demangler 消失(`.text` −49,520、`.rdata` −10,320)。 + ⚠️ 该定义**必须**在全局 `__gnu_cxx` 命名空间,不能嵌在 `jdi` 里。 +* **Release 关闭异步展开表**(`RELEASE_ONLY_OPT = -fno-asynchronous-unwind-tables`): + `-fno-exceptions` 下 `.pdata` 30,420 → 12,684、`.xdata` 28,904 → 10,156(**−36,352**)。 + 代价:Release 崩溃时 WER 拿不到可靠调用栈(不打印栈回溯的程序功能不受影响)。 + +### 2. ⚠️ 修掉一个真实构建缺陷:`DEBUG_OPT` 从未生效 + +`DEBUG_OPT = -O0` 定义了却**没有任何规则使用它** —— Debug 的两条编译规则误用 +`$(RELEASE_OPT)`(`-Oz`),且全程没有 `-g`。所谓 "Debug 构建" 其实只是 +"不 strip 的 Release",**根本无法调试**。现已改用 `$(DEBUG_OPT) -g`(链接也加 `-g`)。 +⚠️ 因此 Debug 产物从 1.73 MB 变成 6.0 MB,属预期(真实 `-O0 -g`)。 + +### 3. 架构:面板"按种类分派"收敛为唯一派发点 + +`Lane` 里 7 种实验性功能各有 1 份 `LanePanelState` + 1 个滚动偏移。旧实现在 +**每一处**按种类取用的地方各写一遍嵌套三元式/`switch`(`EdgePanelLayoutFor` 里就有 +5 条 7 层嵌套三元式),新增一种面板要改 20+ 处,漏一处就是「拖不动 / 关不掉 / +滚轮滚不到底」——`MARK`、`RESAMPLE`、`CLIP` 三种都真实踩过。 + +现在唯一派发点是 `PlotViewInternal.hpp` 的 +`LanePanelEnabled` / `LanePanelStateOf` / `LanePanelScrollOf`。 +⚠️ **这些 `switch` 一律不写 `default:`**,让 `-Wswitch`(属 `-Wall`)在新增 +`EdgePanelKind` 而漏登记时直接**编译报错**(已用最小程序验证 `-Wswitch` 确实会报)。 +⇒ **今后新增实验性功能面板,忘记登记会编译失败,而不是运行时静默出 BUG。** + +### 4. 架构:`ApplicationCommands.cpp`(2,165 行)拆成 4 个分片 + +`ApplicationTabs.cpp`(355)/ `ApplicationDerive.cpp`(786)/ `ApplicationMenus.cpp`(409)/ +`ApplicationCommands.cpp`(642)。纯搬移、零逻辑改动;Makefile 按目录通配,无需登记。 + +### 5. 审查结论中的"没有发现问题"项(同样重要) + +* **无死代码**:`-Wall` 零警告(未使用的 `static` 函数会报警);对 + `PlotView` / `SignalPanel` / `Application` / `TabBar` / `Toolbar` / `DataSet` + 的 233+ 个 `m_*` 成员做全仓引用统计,**没有"只声明不使用"的字段**。 +* **无 TODO/FIXME/未完成的临时方案**(源码里的"未完成"全是边沿统计进行中的业务语义)。 +* **`UiMeasureTextWidth` 有记忆化**(键 = 字体 + 文本),因此布局层的重复测量不是热点。 + +### 6. 未做(诚实记录) + +* `EdgePanelLayoutFor`(957 行分支)未改成"每面板一行元数据表": + 没有离屏像素对比工具(`.preflight` 已删,崩溃会弹模态框),无法自证像素一致。 +* `EdgeScanFor` 的累积状态查找是**线性扫描** `m_edgeScans`(上限 2×信号数+余量)。 + 288 信号时每帧约 16 万次比较(~0.3ms 量级)。可改哈希索引,但收益有限、风险不小。 +* `SceneContentHash` 里 6 段逐面板混入循环未合并(同上:无法验证"仅失效条件不变")。 + -- 2.54.0 From c5d7a6fb91e8d46dc3969a6b53d8afaafb06913c Mon Sep 17 00:00:00 2001 From: "DeepSeek-V4.1-Flash" <> Date: Wed, 4 Nov 2026 03:08:45 +0800 Subject: [PATCH 08/22] =?UTF-8?q?docs(review):=20=E6=96=B0=E5=A2=9E=20Code?= =?UTF-8?q?=20Review=20=E6=9C=80=E7=BB=88=E6=8A=A5=E5=91=8A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Documents/Code_Review_Report.md:逐项说明本轮改了什么、为什么、效果如何, 含体积节表前后对照(890,880 -> 794,112 B,-96,768 / -10.9%)、 Debug 构建缺陷(DEBUG_OPT 从未生效)、面板派发收敛与编译期保护、 ApplicationCommands 拆分、性能审查结论(含 3 个记录未做的优化点)、 注释/文档校正、验证清单、提交清单与后续建议。 同时把 Code_Review_Progress.md 补齐 P15/P16 与“搁置节点”的原因说明。 --- Documents/Code_Review_Progress.md | 28 +++ Documents/Code_Review_Report.md | 325 ++++++++++++++++++++++++++++++ 2 files changed, 353 insertions(+) create mode 100644 Documents/Code_Review_Report.md diff --git a/Documents/Code_Review_Progress.md b/Documents/Code_Review_Progress.md index e2d7aa6..064a4f1 100644 --- a/Documents/Code_Review_Progress.md +++ b/Documents/Code_Review_Progress.md @@ -117,6 +117,34 @@ Makefile 按目录通配收集源文件,**无需登记新文件**。 **验证**:release 零 error 零 warning;产物 793,088 → 794,112 B(+1,024,LTO 内联边界 变化,< 0.13%)。 +### P15 注释/文档校正 —— ✅ 完成(提交 `f139440`) + +* `PlotView.hpp`:删除指向已删除代码的 `FormulaEnvelopeEntry` 注释块。 +* `agent_mem.md` §2:`-Os` → `-Oz`;补体积开关(`NO_EXCEPTIONS` / `NO_RTTI` / `LTO` / + `RELEASE_ONLY_OPT`)与「改 Makefile 必须删 `.o` 全量重编」提醒;补 `msimg32`; + 忽略列表补 `Temp/`、`.preflight/`、`Tools/`。 +* `agent_mem.md` §3:目录结构按现状重写(删除不存在的 `CsvTable/`、`DataModel/`、 + `PaintUtil/` 等旧条目;写明 `Components/Shared` 与 `Utilities` 的真实职责和依赖方向)。 +* `agent_mem.md` 末尾新增「第 33 轮」记录。 + +### P16 最终验证与报告 —— ✅ 完成 + +* release(全量)+ debug(全量 `-O0 -g`)**零 error 零 warning**。 +* 最终产物 **794,112 B**(起点 890,880 B,−96,768,−10.9%)。 +* `cp-demangle.o` 已无 `.text` 段分配;`__gnu_cxx::__verbose_terminate_handler` + 定义在本产物内(`0x140057ed1`);导入 DLL 无新增。 +* 报告:`Documents/Code_Review_Report.md`。 + +### 未完成/搁置的节点(均已在报告中说明原因) + +* **P8** `SceneContentHash` 混入收敛:无逐像素验证手段,合并会改变哈希值,风险不划算。 +* **P9** `EdgePanelLayoutFor` 表驱动化:同上(957 行几何分支,无法自证像素一致)。 +* **P10** 缓存五件套抽象:同上,且热路径复杂。 +* **P12** `PlotView.hpp` 拆分:嵌套类型外移需改 70+ 处引用,收益仅为可读性。 +* **P13/P14** UI 基础层与绘制边界核对:已做**依赖方向与引用统计**,未发现重叠或反向依赖, + 因此不再拆分。 +* **性能项**:未发现新的可复现热点;已记录 3 个"还能再快一点"的点(见报告 §6.2)。 + diff --git a/Documents/Code_Review_Report.md b/Documents/Code_Review_Report.md new file mode 100644 index 0000000..a44de54 --- /dev/null +++ b/Documents/Code_Review_Report.md @@ -0,0 +1,325 @@ +# Code Review 报告(2026-11-04,分支 `Code_Review`) + +> 计划:`Documents/Code_Review_Plan.md` | 逐节点记录:`Documents/Code_Review_Progress.md` +> +> 起点 `764e5be`(dev,已删除过时的 `UI_Refactor_*` 三份文档),终点 `f139440`。 + +--- + +## 0. 一句话结论 + +**体积从 890,880 B 降到 794,112 B(−96,768 B,−10.9%)**,同时 +① 修掉一个"Debug 构建从来不是 Debug"的真实缺陷; +② 把"新增实验性功能面板必须手改 20+ 处"的屎山点收敛成**唯一派发点 + 编译期强制检查**; +③ 把 2,165 行的 `ApplicationCommands.cpp` 拆成 4 个职责分片; +④ 校正了项目记忆里已经失真的目录结构与编译设置。 + +性能方面:本轮**没有发现新的、可复现的卡顿热点**(上一轮公式映射的 4 处卡死已根治); +审到 3 个"可以再快一点"的点,但都因为**缺少可自证的验证手段**而只记录、不动手(见 §6)。 + +--- + +## 1. 体积:890,880 → 794,112 B + +### 1.1 节表前后对照 + +| 节 | 改前 | 改后 | 差 | +| --- | --- | --- | --- | +| `.text`(代码) | 753,136 | **703,616** | −49,520 | +| `.rdata`(只读数据) | 36,936 | **26,456** | −10,480 | +| `.pdata`(SEH 展开) | 30,420 | **12,684** | −17,736 | +| `.xdata`(SEH 展开) | 29,496 | **10,156** | −19,340 | +| `.data` | 1,680 | 2,704 | +1,024 | +| `.idata`(导入表) | 12,856 | 12,692 | −164 | +| `.rsrc`(图标/清单/版本) | 20,656 | 20,656 | 0 | +| **文件总计** | **890,880** | **794,112** | **−96,768(−10.9%)** | + +### 1.2 逐项来源 + +#### (1)`dynamic_cast` 是一个 +75 KB 的"体积炸弹"——提交 `5de3763` + +`Toolbar::BuildBoxes` 里唯一的一处: + +```cpp +const UiButtonWidget* raw = dynamic_cast(button.get()); +``` + +而 `Button::widget` **本来就是 `UiButtonWidget*`** —— 这个转换毫无作用。 + +用最小复现程序量化(`g++ -O2 -fno-exceptions -static -s`): + +| 最小程序 | 体积 | +| --- | --- | +| 纯虚函数 + 虚析构(无 `dynamic_cast`) | 39,936 B | +| 多一个 `dynamic_cast` | **115,200 B** | +| 差 | **+75,264 B** | + +原因:`dynamic_cast` 需要 RTTI 元数据,并牵出 libstdc++ 的 +`__dynamic_cast` → `__cxa_bad_cast` → verbose terminate handler → **`cp-demangle`** +(Release map 里 `cp-demangle.o` 占 `.text` 49,600 + `.rdata` 6,720)。 + +改为直接遍历 `m_buttons`(与 `m_root->Children()` 同源同序),并开启 `-fno-rtti`。 + +#### (2)只关 RTTI 不够:demangler 仍被 `vterminate.o` 拉入——同一提交 + +关掉 RTTI 后 **`cp-demangle.o` 依然在链接里**(实测只省 2 KB)。查 map 找到真凶: + +``` +libstdc++.a(eh_terminate.o) (__gnu_cxx::__verbose_terminate_handler()) +libstdc++.a(vterminate.o) (__cxa_demangle) +libstdc++.a(cp-demangle.o) ... +``` + +`std::terminate()`(`eh_terminate.o`)引用 `__gnu_cxx::__verbose_terminate_handler()`, +该符号由归档成员 `vterminate.o` 提供,而它调用 `__cxa_demangle`。 + +**修法**:在 `Utilities/Src/MemProbe.cpp` 里**自行定义**该函数 +(`std::terminate` 的未定义引用被我们满足 ⇒ `vterminate.o` 不再被拉入 ⇒ demangler 消失)。 + +> ⚠️ 必须定义在**全局** `__gnu_cxx` 命名空间,不能嵌在 `jdi` 里。 +> ⚠️ 行为等效:应用启动后本来就会 `std::set_terminate` 换成自己的处理函数, +> 这里的默认实现只在"启动早期崩溃"时生效,同样给出内存不足提示后退出。 + +#### (3)Release 关闭异步展开表——提交 `94fc1c4` + +`RELEASE_ONLY_OPT = -fno-asynchronous-unwind-tables`,**只**加在 Release 的两条编译规则上。 + +`-fno-exceptions` 下绝大多数函数不再需要 `.pdata`/`.xdata`:−36,352 B。 + +**代价(已评估并接受)**:Release 崩溃时 WER 拿不到可靠调用栈。 +本程序不打印栈回溯,功能不受影响;Debug 构建保留完整展开表。 +(`-fno-unwind-tables` 全关会链接失败,历史上踩过,不采用。) + +#### (4)`.rsrc` 20,656 B 无优化空间 + +图标 17,404 B(已是 PNG 承载的多尺寸帧)+ manifest 1,955 B。 +再压只能减图标帧或降尺寸,会影响观感,**不动**。 + +### 1.3 未采纳的体积方案(实测否决) + +| 方案 | 实测 | 结论 | +| --- | --- | --- | +| `-fno-asynchronous-unwind-tables`(Debug 也开) | — | 否决:Debug 需要展开表才能调试 | +| `-fno-unwind-tables`(全关) | 链接失败 | 否决(历史已踩) | +| 替换 `wcstod` 以去掉 `strtodg/gdtoa/gethex/hexnan` | ≈20 KB | 暂缓:公式/输入框的数值语义必须与现状逐位一致,收益不足以承担风险 | +| 图标继续瘦身 | — | 否决:观感优先 | + +--- + +## 2. 构建系统:修掉一个"Debug 从来不是 Debug"的缺陷——提交 `94fc1c4` + +审查 Makefile 时发现: + +```make +DEBUG_OPT = -O0 # 定义了 +... +# Debug 目标文件编译规则 +$(CXX_COMPLIER) -c $(CXXFLAGS) $(RELEASE_OPT) ... # ← 用的是 -Oz! +``` + +**`DEBUG_OPT` 从未被任何规则引用**,且全流程没有 `-g`。 +所谓 "Debug 构建" 实际上只是"不 strip 的 Release",**根本没有调试信息、也不是 `-O0`**。 + +现在 Debug 规则改用 `$(DEBUG_OPT) -g`,链接也加 `-g`。 + +| 构建 | 改前 | 改后 | 说明 | +| --- | --- | --- | --- | +| Release | 890,880 | **794,112** | 体积优化 | +| Debug | 1,801,617 | **5,994,805** | 变**大**是**正确**的:真实 `-O0 -g` | + +> ⚠️ 这意味着"Debug 构建更慢、更大"从此名副其实;如果以后仍在用 `Build/Debug` 做 +> 日常运行,请改用 Release。 + +--- + +## 3. 架构:面板分派收敛为唯一派发点——提交 `7a3fefb` + +### 3.1 问题(屎山核心) + +`Lane` 里 7 种实验性功能(边沿计数 / 中心轴 / 均值滤波 / 弱峰调整 / 自由标记 / +重采样 / 选区截取)各持有一份 `LanePanelState` 与一个滚动偏移,共 14 个字段。 +旧实现在**每一处**"按种类取用"的地方各写一遍嵌套三元式或 `switch`: + +| 位置 | 旧写法 | +| --- | --- | +| `EdgePanelLayoutFor` 的 `enabled` / `open` / `collapsed` / `storedX` / `storedY` | **5 条 7 层嵌套三元式** | +| 标题栏拖拽 | 7 分支 `switch`(漏 `MARK`/`RESAMPLE` 各一次 ⇒ 面板拖不动) | +| ✕ 关闭 | 7 分支 `switch` + `default`(漏 `RESAMPLE` ⇒ 关掉的是别的面板) | +| 滚轮翻页 | 三元式链 + `switch`(漏 `CLIP` ⇒ 永远滚不到底) | +| 滚动条拖动 | `switch` + `default` | +| 折叠状态读写 | 2 个 `switch` | +| 首现错开序数 | 局部 `kOrder[]` + `switch` | + +新增一种面板要改 **20+ 处**,漏一处就是运行时 BUG —— 历史上 `MARK`、`RESAMPLE`、 +`CLIP` **三种面板各自都真实踩过**。 + +### 3.2 修法 + +`PlotViewInternal.hpp` 新增唯一派发点(各带可写/只读重载): + +```cpp +LanePanelEnabled(lane, kind); // 功能是否启用 +LanePanelStateOf(lane, kind); // 展开/折起/位置 +LanePanelScrollOf(lane, kind); // 内容滚动偏移 +``` + +调用点全部改为访问器:布局里 5 条嵌套三元式 → 3 行;标题栏拖拽 7 分支 → 1 次写入; +滚轮/滚动条/✕/折叠全部改为穷举。 + +### 3.3 ⭐ 关键收益:编译期保护 + +这些 `switch` **穷举 7 种且一律不写 `default:`** ⇒ `-Wswitch`(包含在 `-Wall` 里) +在新增 `EdgePanelKind` 而漏登记时**直接编译报警**。 + +已用最小程序验证该机制确实生效: + +``` +warning: enumeration value 'C' not handled in switch [-Wswitch] +``` + +⇒ **今后新增实验性功能面板,忘记登记会编译失败,而不是运行时静默出错。** + +(对比:旧代码用 `case COUNT: default:` 兜底,`-Wswitch` 永远不会报。) + +### 3.4 验证 + +release + debug 零 warning;**产物体积不变(793,088 B)** —— 这正好说明本次是 +纯粹的等价结构收敛,没有引入额外代码。 + +--- + +## 4. 架构:拆分 2,165 行的 `ApplicationCommands.cpp`——提交 `8437100` + +原文件混杂六类职责。按职责**纯搬移**(零逻辑改动、函数体一字未改): + +| 文件 | 行数 | 职责 | +| --- | --- | --- | +| `ApplicationTabs.cpp` | 355 | 标签页新建/关闭/切换/重排、视图同步、窗口标题、标签栏回调、标签页右键菜单 | +| `ApplicationDerive.cpp` | 786 | 派生烘焙、重采样、选区截取(新建 / 就地)、克隆(含带调整) | +| `ApplicationMenus.cpp` | 409 | 绘图区右键菜单、菜单数值回写、主题切换、设置菜单、菜单结果派发 | +| `ApplicationCommands.cpp` | 642 | 分组/信号增删、移动与重排、文件对话框、工具栏动作、键盘鼠标分发、布局边界绘制 | + +Makefile 按目录通配收集源文件,**无需登记**。 + +体积 793,088 → 794,112 B(+1,024,LTO 内联边界变化,< 0.13%)。 + +--- + +## 5. 审查中"没有发现问题"的项(同样重要) + +这些是**主动查了但结论是干净**的,避免后续接手者重复怀疑: + +| 检查项 | 方法 | 结论 | +| --- | --- | --- | +| 死代码(未使用的静态函数) | `-Wall` 零警告(GCC 会对未使用的 `static` 函数报警) | 无 | +| 死字段(只声明不使用) | 对 `PlotView` / `SignalPanel` / `Application` / `TabBar` / `Toolbar` / `DataSet` 的全部 `m_*` 成员做全仓引用计数 | 233+ 个成员**无一**只声明不使用 | +| TODO / FIXME / 临时方案 | 全仓 grep | 无(源码里的"未完成"全是"边沿统计进行中"的业务语义) | +| 布局文本测量是否重复开销 | 读 `UiMeasureTextWidth` 实现 | 已记忆化(键 = 字体 + 文本),不是热点 | +| 页面上是否残留指向已删文件的注释 | grep `UI_Refactor_*` / `refactor_report` / `structcheck` / `.preflight` | 仅 1 处(`PlotView.hpp` 的 `FormulaEnvelopeEntry` 注释),已删 | + +--- + +## 6. 性能审查结论 + +### 6.1 结论 + +**没有发现新的、可复现的卡顿热点。** 上一轮(第 32 轮)的四处卡死同源于 +"公式信号幅值范围/包络各扫一遍整条信号",已通过 `DataSet::FormulaTransformFor` +(一次扫描 + 缓存,命中 0.0015 ms)根治。 + +逐一复核了下列曾出过问题的路径,均仍走快路径: + +| 路径 | 现状 | +| --- | --- | +| `BuildLaneVisuals` 的幅值范围 | 指纹缓存(`m_laneRangeKey`),命中时 O(1) | +| 场景/装饰/面板/图层像素缓存 | 键齐全,未发现遗漏项 | +| 边沿统计 | 按帧预算 + 每状态配额推进,暂停条件齐备 | +| 图层预建 | `EnsureLaneLayersPrefetched`(不再整批重建) | +| 信号面板行集合自愈 | `RowsSignature` 每帧 O(组数),组数远小于信号数 | + +### 6.2 记录但未实施的三处"还能再快一点" + +| 位置 | 量级 | 为什么不改 | +| --- | --- | --- | +| `EdgeScanFor` 的累积状态查找是**线性扫描** `m_edgeScans`(上限 2×信号数+余量)。288 信号时每帧约 16 万次指针比较 | ~0.3 ms/帧 | 改哈希索引需要重做"淘汰最旧已完成状态"的逻辑;收益与风险不匹配,且该路径已被预算化 | +| `EdgePanelLayoutFor`(957 行分支)整体改为"每面板一行元数据表" | 可读性 + 少量代码体积 | **没有离屏像素对比工具**(`.preflight` 已删,崩溃会弹模态框),纯逻辑重写无法自证"像素完全一致" | +| `SceneContentHash` 里 6 段逐面板混入循环可合并 | 可读性 | 合并会改变哈希值;虽然"多加字段安全、少加字段危险",但在无法逐像素验证的前提下不值得冒险 | + +### 6.3 关于"性能验证手段"的重要说明 + +本轮**没有**做任何 GUI 探针,原因(也是给后续接手的约束): + +* 用户要求不占用其鼠标/键盘/屏幕; +* `.preflight` 测试框架已被用户删除,且已明确"不要重建"; +* `-fno-exceptions` + `MemProbe::OnTerminate()` 会在探针崩溃时弹**模态"内存不足"框**, + 反复运行就是反复弹窗(第 26 轮的真实教训)。 + +因此本轮的体积结论全部来自**可复现的静态测量**(`objdump -h` 节表 + `Makefile` 变量 + +非 LTO 目标文件尺寸),架构结论来自**编译期检查**(`-Wswitch`)与**等价性论证** +(体积逐字节不变)。 + +--- + +## 7. 注释与文档 + +### 7.1 删除 + +* `Documents/UI_Refactor_Plan.md`、`UI_Refactor_Progress.md`、`UI_Refactor_Report.md` + (描述 2026-10-28 那一阶段,已被后续多轮实现推翻,与当前代码不一致)。 + +### 7.2 校正(`agent_mem.md`) + +| 位置 | 原内容(错) | 现内容 | +| --- | --- | --- | +| §2 | Release 用 `-Os` | `-Oz` + LTO + `-s` | +| §2 | 未提体积开关 | 补充 `NO_EXCEPTIONS` / `NO_RTTI` / `LTO` / `RELEASE_ONLY_OPT`,并警告"改 Makefile 必须删 `.o` 全量重编"(本轮实测:只改 Makefile 不会触发重编,会误以为"优化无效") | +| §2 | 链接库少 `msimg32` | 已补 | +| §3 | 目录里写着早已不存在的 `Utilities/CsvTable/`、`Utilities/DataModel/`、`Utilities/PaintUtil/`、`Utilities/PopupMenu/`… | 按现状重写(`Components/Shared` 承载通用控件与 `PopupMenu`/`ColorPickerPopup`;`Utilities` 为数据与格式层),并写明依赖方向 | +| 末尾 | — | 新增「第 33 轮」完整记录 | + +### 7.3 源码注释 + +* `PlotView.hpp`:删除指向已删除代码的 `FormulaEnvelopeEntry` 注释块。 +* 新增/改写的注释一律说明**为什么**(尤其是 `-fno-rtti`、`__verbose_terminate_handler` + 覆盖、`-fno-asynchronous-unwind-tables` 的代价),避免后续被"好心"改回去。 + +--- + +## 8. 验证清单 + +| 项 | 结果 | +| --- | --- | +| `make release`(删 `*.o`/`*.d` 全量) | **零 error、零 warning** | +| `make debug`(真实 `-O0 -g`,全量) | **零 error、零 warning** | +| Release 产物 | 794,112 B(< 1 MB 红线) | +| `cp-demangle.o` 是否仍占 `.text` | 已无 `.text` 段分配 | +| `__gnu_cxx::__verbose_terminate_handler` | 定义在**本产物内**(`0x140057ed1`),非库导入 | +| 导入 DLL 列表 | `comdlg32 / GDI32 / gdiplus / KERNEL32 / MSIMG32 / SHELL32 / USER32` + UCRT,无新增依赖 | +| 工作区状态 | 干净;4 个节点提交 + 1 个文档提交 + 1 个 dev 提交 | + +--- + +## 9. 提交清单 + +| 提交 | 分支 | 内容 | +| --- | --- | --- | +| `764e5be` | `dev` | 删除过时的 `UI_Refactor_*` 三份文档 | +| `86ff029` | `Code_Review` | 新增 `Code_Review_Plan.md` / `Code_Review_Progress.md` | +| `5de3763` | `Code_Review` | 移除多余 `dynamic_cast` + 关闭 RTTI + 覆盖 terminate handler(−61,440 B) | +| `94fc1c4` | `Code_Review` | Release 关闭异步展开表(−36,352 B)+ 修正 Debug 构建参数 | +| `7a3fefb` | `Code_Review` | 面板分派收敛为唯一派发点(编译期保护) | +| `8437100` | `Code_Review` | 拆分 `ApplicationCommands.cpp` → 4 个职责分片 | +| `f139440` | `Code_Review` | 清理过时注释 + 校正 `agent_mem.md` §2/§3 | + +--- + +## 10. 后续建议(按性价比排序) + +1. **重建离屏验证手段**(不弹窗口、不占用用户输入)。 + 没有它,"重构面板几何 / 缓存键"这类工作无法自证正确性 —— 这正是本轮 §6.2 + 三项被搁置的唯一原因。建议做纯控制台 + `SW_HIDE` + 逐像素哈希比对,并且 + **绝不调用 `Application::Initialize()`**(会 `LoadCommandLineFiles` → 失败 → 模态框)。 +2. **`EdgeScanFor` 状态查找哈希化**(0.3 ms/帧 @288 信号)。 +3. **`EdgePanelLayoutFor` 表驱动化**(需第 1 项就绪)。 +4. 若要继续压体积:`wcstod` 自写替身(≈20 KB,需逐位一致性验证)、 + 图标帧裁剪(观感优先,不建议)。 -- 2.54.0 From 97e9163314026dc51d025788e3d3933e078a0da4 Mon Sep 17 00:00:00 2001 From: "DeepSeek V4.1 Flash" <> Date: Wed, 4 Nov 2026 11:41:03 +0800 Subject: [PATCH 09/22] =?UTF-8?q?test(tools):=20=E9=87=8D=E5=BB=BA?= =?UTF-8?q?=E5=8D=95=E5=85=83=E6=B5=8B=E8=AF=95=E4=B8=8E=E7=A6=BB=E5=B1=8F?= =?UTF-8?q?=E9=AA=8C=E8=AF=81=E5=B7=A5=E5=85=B7=EF=BC=88Tools/=20=E5=85=A5?= =?UTF-8?q?=E5=BA=93=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 背景:第 33 轮 Code Review 的三项后续建议(EdgeScanFor 查找哈希化、 EdgePanelLayoutFor 表驱动化、wcstod 自写替身)都卡在"没有可自证的验证手段"上。 本轮先补齐基础设施,再动被建议的代码。 一、Tools/ 从忽略改为入库(只忽略各工具的 Build/ 与 Temp/ 临时产物) * `.gitignore`:删除 `Tools/` 整目录忽略,改为 `Tools/**/Build/`; 截图 / 基线 / 报告一律写 `Temp/tools/`(`Temp/` 早已忽略)。 二、新增 Tools/Makefile(统一构建入口,不碰应用本体) * 目标:`make` 构建全部、`make UnitTests` / `make OffscreenChecks` 单个、 `make clean` 清工具产物、`make help`。 * 工具链接应用本体 `../Build/Release/*.o`(排除入口 `Main.o`), 因此 ABI 开关必须与本体一致:`-fno-exceptions` / `-fno-rtti` / 链接 `-flto -flto-partition=one`。 * 工具源文件自身**不**编译成 LTO 字节码(否则 GCC 15.2.0 在 `choose_baseaddr` 处 ICE),只在链接阶段带 LTO 参数。 * `-MMD -MP` + `-include`:改头文件 / 增删源文件自动重编重链。 * 修正一个构建陷阱:`$(foreach)` 生成的工具目标会早于 `all`, make 会把第一个显式目标当默认目标 ⇒ 显式 `.DEFAULT_GOAL := all`。 三、Tools/UnitTests:纯数据层单元测试(218 项断言,17 个用例) * 极简自研框架(`Inc/UnitTestFramework.hpp`):无异常、无 RTTI、 支持布尔 / 整数 / 近似 / **逐位相等** / 宽字符串断言。 * 覆盖:`TryParseDouble` 与 libc `strtod` 逐位一致性(6 万条随机串)、 接受性语义、`FormatDecimal*` 永不科学计数法、`NiceStep/BuildTicks`、 公式语法/语义/归一化、批量求值 `FormulaProgram` 与逐点求值逐位一致、 `ApplyDrawTransform` 顺序、`TransformValueRange`、 `ComputeMovingAverageIterated` 阶数等价性、 `DataSet::FormulaTransformFor` 的值域与分组极值(逐点参考实现比对)。 四、Tools/OffscreenChecks:隐藏窗口离屏验证(5 个检查) * 窗口 `SW_HIDE`;"鼠标"用 `Application::HandleMouseMove()` 合成注入; 像素从后备缓冲区读取(不依赖窗口可见性),可保存 PNG 与计算区域 FNV 哈希。 * `smoke`:初始化 / 加载 / 渲染 / 落盘,并断言"同状态两帧逐像素相同"。 * `frames`:连续帧抓取(PNG + 哈希 + 耗时),用于问题分析。 * `panels`:7 个实验性面板全部展开后的几何不变式 + **像素基线 A/B** (`-mode record` / `-mode compare`)。 * `edgestats`:边沿统计与工具内**独立参考实现**逐位比对, 覆盖阈值变化与重复查询稳定性。 * `perf`:整帧中位数、`EdgePanelLayoutFor` 单次耗时、 198 个统计状态的整轮查询耗时(用于优化前后对比)。 五、文档 * `Tools/README.md`:目录结构、构建/运行方式、每个工具的作用、测试项与 验收标准、临时产物位置、A/B 基线用法、扩展步骤与已知限制。 实测(本机 GCC 15.2.0 UCRT / 125% DPI): * `UnitTests.exe` → `checks=218 failed=0` * `OffscreenChecks -all` → 全部 PASS;`SCATTER_TEST.csv` 整帧 ~5ms; `MF4-Record.MF4`(283 信号)198 个统计状态整轮查询 0.097 us/query。 --- .gitignore | 9 +- Tools/Makefile | 187 ++++ Tools/OffscreenChecks/Inc/CheckRunner.hpp | 84 ++ .../OffscreenChecks/Inc/OffscreenSupport.hpp | 191 ++++ Tools/OffscreenChecks/Src/Capture.cpp | 306 +++++++ Tools/OffscreenChecks/Src/Checks.cpp | 851 ++++++++++++++++++ Tools/OffscreenChecks/Src/Main.cpp | 271 ++++++ Tools/OffscreenChecks/Src/Session.cpp | 231 +++++ Tools/README.md | 238 +++++ Tools/UnitTests/Inc/UnitTestFramework.hpp | 198 ++++ Tools/UnitTests/Src/DataModelTests.cpp | 324 +++++++ Tools/UnitTests/Src/FormulaEvalTests.cpp | 147 +++ Tools/UnitTests/Src/Main.cpp | 174 ++++ Tools/UnitTests/Src/Utf8UtilTests.cpp | 306 +++++++ 14 files changed, 3515 insertions(+), 2 deletions(-) create mode 100644 Tools/Makefile create mode 100644 Tools/OffscreenChecks/Inc/CheckRunner.hpp create mode 100644 Tools/OffscreenChecks/Inc/OffscreenSupport.hpp create mode 100644 Tools/OffscreenChecks/Src/Capture.cpp create mode 100644 Tools/OffscreenChecks/Src/Checks.cpp create mode 100644 Tools/OffscreenChecks/Src/Main.cpp create mode 100644 Tools/OffscreenChecks/Src/Session.cpp create mode 100644 Tools/README.md create mode 100644 Tools/UnitTests/Inc/UnitTestFramework.hpp create mode 100644 Tools/UnitTests/Src/DataModelTests.cpp create mode 100644 Tools/UnitTests/Src/FormulaEvalTests.cpp create mode 100644 Tools/UnitTests/Src/Main.cpp create mode 100644 Tools/UnitTests/Src/Utf8UtilTests.cpp diff --git a/.gitignore b/.gitignore index ede9fa3..1f46d12 100644 --- a/.gitignore +++ b/.gitignore @@ -17,5 +17,10 @@ Temp/ # 测试数据(仅本地保留,不纳入版本控制) TestData/ -# 临时验证工具(仅本地保留,不入版本控制) -Tools/ +# 验证工具(Tools/)**入库**,但只忽略各工具自己的构建产物: +# 每个工具目录内的 Build/(与 Inc/Src 同级)不放仓库,其它协作者本地 make 即可。 +# `Build/` 已在上方整体忽略(无前导斜杠 = 任意层级),这里显式重申,避免日后误读。 +Tools/**/Build/ + +# 工具的临时运行产物(截图 / 连续帧 / 基线哈希 / 报告)统一放到 Temp/ 下 +# (Temp/ 已在上方忽略),工具内部禁止把临时文件写进 Tools/ 目录。 diff --git a/Tools/Makefile b/Tools/Makefile new file mode 100644 index 0000000..367e358 --- /dev/null +++ b/Tools/Makefile @@ -0,0 +1,187 @@ +# ============================================================================= +# Tools/Makefile —— 验证工具(单元测试 / 离屏验证)的统一构建入口 +# ============================================================================= +# +# 职责边界(很重要,别混): +# * 根目录 `Makefile` **只负责应用本体**(`Build/Release`、`Build/Debug`)。 +# 它按目录通配收集源文件,`Tools/` 不在其 `CXX_SOURCES_PATHS` 里, +# 因此**新增工具不需要改根 Makefile**,也不会把工具代码编进应用本体。 +# * 本文件负责 `Tools/` 下所有工具的编译 / 链接 / 清理。 +# 工具是"白盒验证":直接链接应用本体已构建好的目标文件 +# (`../Build/Release/*.o`,排除入口 `Main.o`),因此**必须先构建本体**。 +# +# 用法(在 `Tools/` 目录下执行,或 `make -C Tools ...`): +# make 构建全部工具 +# make UnitTests 只构建单元测试 +# make OffscreenChecks 只构建离屏验证工具 +# make clean 删除所有工具的构建目录(**不动**应用本体) +# make help 打印帮助 +# +# 目录约定: +# Tools//Inc 仅头文件 +# Tools//Src 仅实现(每个 `.cpp` 都会编译) +# Tools//Build 产物(与 Inc/Src 同级;已被 .gitignore 忽略) +# +# ⚠️ 工具目标文件必须与应用本体使用**同一套 ABI 相关开关**,否则链接会失败: +# `-fno-exceptions`(本体 `NO_EXCEPTIONS = 1`) +# `-fno-rtti` (本体 `NO_RTTI = 1`,本仓已无 dynamic_cast) +# `-flto -flto-partition=one`(本体 `LTO = 1`,目标文件是 LTO 字节码) +# ============================================================================= + +# ---- 工具清单(新增工具只需在这里追加,并保证目录名 = 目标名)-------------- +TOOLS := UnitTests OffscreenChecks + +# ⚠️ 必须显式指定默认目标:下面用 `$(foreach)` 生成的工具目标会先于 `all` 定义, +# 而 make 会把"第一个出现的显式目标"当作默认目标 ⇒ 直接 `make` 只会建第一个工具。 +.DEFAULT_GOAL := all + +# 工具内构建目录名(与 Inc / Src 同级) +BUILD_DIR_NAME := Build + +# ---- 工具链 ---------------------------------------------------------------- +C_COMPILER := gcc +CXX_COMPILER := g++ + +C_STD := c17 +CXX_STD := c++17 + +INPUT_CHAR_ENCODING := UTF-8 +OUTPUT_CHAR_ENCODING := GBK +OUTPUT_WCHAR_ENCODING := UTF-16LE + +# 工具自身优化等级:验证工具追求"跑得快",不参与体积红线 +TOOL_OPT := -O2 +TOOL_DEBUG_INFO := -g + +# ---- 与被测应用本体保持一致的 ABI 开关------------------------------------- +NO_EXCEPTIONS := 1 +NO_RTTI := 1 +LTO := 1 + +# ---- 应用本体 -------------------------------------------------------------- +APP_BUILD_DIR := ../Build/Release +# 排除入口 `Main.o`(它定义 `WinMain`,与工具的 `main` 不冲突但不需要) +APP_OBJECTS := $(filter-out $(APP_BUILD_DIR)/Main.o,$(wildcard $(APP_BUILD_DIR)/*.o)) + +# ---- 头文件目录(与应用本体一致)------------------------------------------ +PROJECT_INCLUDES := \ + ../Utilities/Inc \ + ../Resources/Inc \ + ../Components/Main/Inc \ + ../Components/Shared/Inc \ + ../Components/Application/Inc \ + ../Components/TabBar/Inc \ + ../Components/Toolbar/Inc \ + ../Components/SignalPanel/Inc \ + ../Components/PlotView/Inc + +# ---- 链接库(与应用本体一致)---------------------------------------------- +LINK_LIBS := \ + -lgdi32 \ + -lgdiplus \ + -lmsimg32 \ + -lcomdlg32 \ + -ld3d11 \ + -ldwmapi \ + -ld3dcompiler + +# ---- 编译 / 链接选项 ------------------------------------------------------- +# ⚠️ 这里用 `=`(递归展开)而不是 `:=`:`-MF"$(@:%.o=%.d)"` 里的 `$@` +# 必须在**展开 recipe 时**才求值(那时才确定是哪一个目标文件)。 +BASE_CXXFLAGS = \ + -D_UNICODE -DUNICODE \ + $(addprefix -I,$(PROJECT_INCLUDES)) \ + -std=$(CXX_STD) \ + -finput-charset=$(INPUT_CHAR_ENCODING) \ + -fexec-charset=$(OUTPUT_CHAR_ENCODING) \ + -fwide-exec-charset=$(OUTPUT_WCHAR_ENCODING) \ + -static \ + -Wall \ + $(TOOL_OPT) \ + $(TOOL_DEBUG_INFO) \ + -MMD -MP -MF"$(@:%.o=%.d)" + +ifeq ($(strip $(NO_EXCEPTIONS)),1) +BASE_CXXFLAGS += -fno-exceptions +endif + +ifeq ($(strip $(NO_RTTI)),1) +BASE_CXXFLAGS += -fno-rtti +endif + +BASE_LDFLAGS = +ifeq ($(strip $(LTO)),1) +# 本体目标文件是 LTO 字节码 ⇒ **链接**必须带 `-flto -flto-partition=one`。 +# ⚠️ 工具自身源文件**不要**加 `-flto`:把工具代码也交给 LTRANS 会在 +# `choose_baseaddr`(i386.cc:7447)处 ICE(GCC 15.2.0 实测), +# 而工具体量很小,根本不需要跨单元内联。 +BASE_LDFLAGS += -flto -flto-partition=one +endif + +# ---- 每个工具的源文件 / 目标文件 / 产物 ------------------------------------ +$(foreach tool,$(TOOLS),$(eval $(tool)_SRC := $(wildcard $(tool)/Src/*.cpp))) +$(foreach tool,$(TOOLS),$(eval $(tool)_OBJ := $(patsubst $(tool)/Src/%.cpp,$(tool)/$(BUILD_DIR_NAME)/%.o,$($(tool)_SRC)))) +$(foreach tool,$(TOOLS),$(eval $(tool)_EXE := $(tool)/$(BUILD_DIR_NAME)/$(tool).exe)) + +# 本体的目标文件缺失时给出明确提示(而不是让链接器报一堆 undefined reference) +ifeq ($(strip $(APP_OBJECTS)),) +$(warning ********************************************************************) +$(warning 未找到应用本体目标文件:$(APP_BUILD_DIR)/*.o) +$(warning 请先在项目根目录构建本体,例如:make release -j8) +$(warning ********************************************************************) +endif + +# ---- 每个工具的规则(模板)------------------------------------------------- +.SUFFIXES: + +define TOOL_RULES +.PHONY: $(1) +$(1): $$($(1)_EXE) + +$$($(1)_EXE): $$($(1)_OBJ) $(APP_OBJECTS) Makefile + @echo ====== [$(1)] All File Compiled. Now Linking... ====== + $(CXX_COMPILER) $$($(1)_OBJ) $(APP_OBJECTS) -static $(TOOL_DEBUG_INFO) -o $$@ $(LINK_LIBS) $(BASE_LDFLAGS) -mconsole + @echo ====== [$(1)] Link Finished: $$@ ====== + +$$($(1)_OBJ): $(1)/$(BUILD_DIR_NAME)/%.o: $(1)/Src/%.cpp Makefile | $(1)/$(BUILD_DIR_NAME) + @echo ====== [$(1)] C++ Source File "$$<" Compiling... ====== + $(CXX_COMPILER) -c $$(BASE_CXXFLAGS) -I$(1)/Inc $$< -o $$@ + +$(1)/$(BUILD_DIR_NAME): + @powershell -NoProfile -Command "New-Item -ItemType Directory -Force -Path '$$@' | Out-Null" +endef + +$(foreach tool,$(TOOLS),$(eval $(call TOOL_RULES,$(tool)))) + +# ---- 聚合目标 -------------------------------------------------------------- +.PHONY: all clean help unit offscreen + +all: $(TOOLS) + @echo ====== [Tools] All Tools Built ====== + +# 便利别名 +unit: UnitTests +offscreen: OffscreenChecks + +# 各工具的构建目录(空格分隔 → PowerShell 里 `-split ' '` 成数组) +TOOL_BUILD_DIRS := $(strip $(foreach tool,$(TOOLS),$(tool)/$(BUILD_DIR_NAME))) + +clean: + @echo ====== [Tools] Cleaning Build Directories... ====== + @powershell -NoProfile -Command "'$(TOOL_BUILD_DIRS)' -split ' ' | ForEach-Object { if (Test-Path $$_) { Remove-Item -Recurse -Force $$_ } }" + @echo ====== [Tools] Clean Accomplished ====== + +help: + @echo Tools/Makefile —— 验证工具构建入口 + @echo. + @echo make 构建全部工具:$(TOOLS) + @echo make UnitTests 只构建单元测试(Tools/UnitTests) + @echo make OffscreenChecks 只构建离屏验证工具(Tools/OffscreenChecks) + @echo make clean 删除各工具自己的 Build/ 目录 + @echo make help 显示本帮助 + @echo. + @echo 依赖:应用本体已构建(../Build/Release/*.o) + +# ---- 头文件依赖(与根 Makefile 同一套 `-MMD -MP` 机制)-------------------- +# ⚠️ 删掉/重命名头文件后若出现"莫名为 undefined reference",先 `make clean`。 +-include $(foreach tool,$(TOOLS),$($(tool)_OBJ:.o=.d)) diff --git a/Tools/OffscreenChecks/Inc/CheckRunner.hpp b/Tools/OffscreenChecks/Inc/CheckRunner.hpp new file mode 100644 index 0000000..1eaa194 --- /dev/null +++ b/Tools/OffscreenChecks/Inc/CheckRunner.hpp @@ -0,0 +1,84 @@ +/** + * @file CheckRunner.hpp + * @brief 离屏检查的注册表与统一断言/输出接口。 + * + * 每个检查都是一个签名为 `int (const CheckOptions&)` 的函数, + * 通过 `Expect()` 记录断言;返回值只用于"该检查自己额外发现的失败数"(通常直接返回全局失败数)。 + */ + +#ifndef __CHECKRUNNER_HPP__ +#define __CHECKRUNNER_HPP__ + +#include +#include + +#include "OffscreenSupport.hpp" + +namespace jdi +{ + namespace tools + { + /** @brief 一次检查运行的参数(来自命令行)。 */ + struct CheckOptions + { + std::string dataPath; /**< 数据文件路径(ASCII 或本地代码页)。 */ + std::string outDir; /**< 输出目录(默认 `../Temp/tools`)。 */ + std::string mode; /**< `verify`(默认)/ `record` / `compare`。 */ + int frames = 8; /**< 连续帧数量(仅 `frames` 检查使用)。 */ + bool savePng = true; /**< 是否把抓到的帧落盘(默认开,便于事后分析)。 */ + }; + + /** @brief 一个可运行的检查。 */ + struct CheckDescriptor + { + const char* name = ""; /**< 检查名(命令行 `-run ` 使用)。 */ + const char* summary = ""; /**< 一句话说明(ASCII,`-list` 打印)。 */ + /** 检查实现:`session` 已 `Start()` 并加载好数据文件;返回该检查的失败断言数。 */ + int (*run)(Session& session, const CheckOptions& options) = nullptr; + }; + + /** @brief 全部已注册检查(顺序即执行顺序)。 */ + const std::vector& AvailableChecks(); + + /** @brief 按名字查检查;找不到返回 nullptr。 */ + const CheckDescriptor* FindCheck(const std::string& name); + + /** @brief 统一输出(同时写标准输出与报告文件,由 `Main.cpp` 提供实现)。 */ + void EmitLine(const char* format, ...); + + /** @brief 断言总数。 */ + int& CheckTotal(); + + /** @brief 失败断言数。 */ + int& CheckFailed(); + + /** @brief 记录一条断言(失败会打印 `[FAIL]` 并累加失败数)。 */ + void Expect(const bool ok, const std::string& what); + + /** @brief 记录一条"整数相等"断言。 */ + void ExpectEqual(const long long actual, const long long expected, const std::string& what); + + /** @brief 记录一条数值近似断言。 */ + void ExpectNear(const double actual, const double expected, const double tolerance, + const std::string& what); + + /** @brief 把 `gd::RectF` 转成整像素矩形(向外取整,保证覆盖)。 */ + inline PixelRect ToPixelRect(const gd::RectF& rect) + { + PixelRect result; + result.x = static_cast(std::floor(rect.X)); + result.y = static_cast(std::floor(rect.Y)); + result.width = static_cast(std::ceil(rect.X + rect.Width)) - result.x; + result.height = static_cast(std::ceil(rect.Y + rect.Height)) - result.y; + return result; + } + + /** @brief 读取一个文本文件(失败返回空串)。 */ + std::string ReadTextFile(const std::string& path); + + /** @brief 写一个文本文件(自动建目录)。 */ + bool WriteTextFile(const std::string& path, const std::string& content); + } // namespace tools +} // namespace jdi + +#endif // __CHECKRUNNER_HPP__ diff --git a/Tools/OffscreenChecks/Inc/OffscreenSupport.hpp b/Tools/OffscreenChecks/Inc/OffscreenSupport.hpp new file mode 100644 index 0000000..b644dde --- /dev/null +++ b/Tools/OffscreenChecks/Inc/OffscreenSupport.hpp @@ -0,0 +1,191 @@ +/** + * @file OffscreenSupport.hpp + * @brief 离屏验证工具的公共支撑:后备缓冲抓帧、区域哈希、隐藏窗口会话。 + * + * ⚠️ **为什么要 `#define private public`**:本工具是"白盒验证"—— + * 它需要读取后备缓冲区(`Application::m_backBits`)、驱动鼠标消息、 + * 直接调用面板几何函数(`PlotView::EdgePanelLayoutFor`)。这些都不是给终端用户用的 + * 公开 API,因此不为了测试而污染产品接口,改成在工具侧临时放宽访问级别。 + * + * ⚠️ **先引入全部标准库 / 系统头,再放宽访问级别**:否则随后被 include 的 + * 标准库头里的 `private:` 会被宏替换成 `public:`,造成莫名其妙的编译错误。 + * + * ⚠️ 窗口一律 `SW_HIDE`:不显示、不抢焦点、不占用户的屏幕与鼠标键盘。 + * 抓帧读的是**后备缓冲区**(不是窗口 DC),因此完全不需要窗口可见。 + */ + +#ifndef __OFFSCREENSUPPORT_HPP__ +#define __OFFSCREENSUPPORT_HPP__ + +// ---- 先铺满标准库与系统头 --------------------------------------------------- +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +// ---- 临时放宽访问级别,引入被测项目头 -------------------------------------- +#define private public +#define protected public + +#include "Application.hpp" +#include "DataModel.hpp" +#include "PlotView.hpp" +#include "PlotViewInternal.hpp" +#include "SignalPanel.hpp" +#include "TabBar.hpp" +#include "Theme.hpp" +#include "Toolbar.hpp" +#include "UiSurface.hpp" + +#undef protected +#undef private + +namespace jdi +{ + namespace tools + { + /** @brief 后备缓冲区里的一块像素矩形(设备像素、客户区坐标)。 */ + struct PixelRect + { + int x = 0; /**< 左边界。 */ + int y = 0; /**< 上边界。 */ + int width = 0; /**< 宽度。 */ + int height = 0; /**< 高度。 */ + }; + + /** + * @brief 一帧后备缓冲区像素(top-down 32bpp BGRA)。 + * + * ⚠️ **不要跨帧缓存 `pixels.data()` 的内部指针**:窗口尺寸 / DPI 变化会重建后备缓冲区; + * 每次 `RenderFrame()` 都会重新 `CaptureFrame`。 + */ + struct Frame + { + int width = 0; /**< 像素宽。 */ + int height = 0; /**< 像素高。 */ + int stride = 0; /**< 行字节数。 */ + std::vector pixels; /**< BGRA 像素(逐行自上而下)。 */ + + /** @brief 是否抓到了有效像素。 */ + bool Valid() const { return width > 0 && height > 0 && !pixels.empty(); } + + /** @brief 第 `y` 行的起始地址。 */ + const std::uint8_t* Row(const int y) const + { + return pixels.data() + static_cast(y) * static_cast(stride); + } + }; + + /** @brief 从应用后备缓冲区抓一帧(不依赖窗口可见性)。 */ + bool CaptureFrame(const Application& app, Frame& frame); + + /** @brief 计算区域内像素的 FNV-1a 64 位哈希(只混 BGR,忽略 alpha)。 */ + std::uint64_t HashRegion(const Frame& frame, const PixelRect& rect); + + /** @brief 统计区域内"墨迹"像素数(与区域主色不同的像素)。 */ + int CountInk(const Frame& frame, const PixelRect& rect); + + /** @brief 统计两帧在同一区域里差异超过容差的像素数。 */ + int CountDiff(const Frame& left, const Frame& right, const PixelRect& rect, int tolerance); + + /** @brief 把一帧保存成 PNG(路径必须是 ASCII,目录不存在会自动创建)。 */ + bool SavePng(const Frame& frame, const std::string& path); + + /** @brief 递归创建目录(ASCII 路径)。 */ + void EnsureDirectory(const std::string& asciiPath); + + /** @brief 递归删除目录(ASCII 路径,用于"清空临时输出")。 */ + void RemoveDirectoryTree(const std::string& asciiPath); + + /** + * @brief 一次离屏会话:隐藏窗口 + 可选的数据文件 + 逐帧抓取。 + * + * 生命周期:`Start()` → 若干 `Load()` / `RenderFrame()` → `Stop()`。 + * ⚠️ `Stop()` 之后**不要**再调用成员函数;进程退出用 `std::_Exit()` 收尾, + * 避免单例析构晚于 `GdiplusShutdown`(历史踩过的退出访问违例)。 + */ + class Session + { + public: + /** @brief 创建隐藏窗口并初始化各组件(不显示、不抢焦点)。 */ + bool Start(); + + /** @brief 关闭窗口并释放 GDI+ 相关资源。 */ + void Stop(); + + /** @brief 加载一个数据文件(同步路径,便于断言)。 */ + bool Load(const std::string& path); + + /** @brief 应用本体引用。 */ + Application& App() { return *m_app; } + + /** @brief 只读应用本体引用。 */ + const Application& App() const { return *m_app; } + + /** @brief 强制整帧重绘并抓帧;返回是否成功。 */ + bool RenderFrame(); + + /** @brief 最近一帧。 */ + const Frame& LastFrame() const { return m_lastFrame; } + + /** @brief 最近一次 `Render()` 的耗时(毫秒)。 */ + double LastRenderMs() const { return m_lastRenderMs; } + + /** @brief 只显示给定信号(其余全部隐藏),并重算视图。 */ + void ShowSignals(const std::vector& refs); + + /** @brief 显示前 `limit` 个信号(跨分组,`limit <= 0` 表示全部)。 */ + void ShowFirstSignals(int limit); + + /** @brief 全部隐藏。 */ + void HideAll(); + + /** @brief 注入一次鼠标移动(客户区坐标),返回组件是否认为状态有变化。 */ + bool MouseMove(int x, int y); + + /** @brief 绘图区矩形(客户区坐标)。 */ + gd::RectF PlotArea() const; + + /** @brief 第 `index` 个网格的波形区域(客户区坐标)。 */ + gd::RectF LaneRect(int index) const; + + /** @brief 当前网格数。 */ + int LaneCount() const; + + /** @brief 客户区尺寸。 */ + void ClientSize(int& width, int& height) const; + + /** @brief 当前加载的路径(窄字符串,仅用于打印)。 */ + const std::string& LoadedPath() const { return m_loadedPath; } + + private: + Application* m_app = nullptr; /**< 进程级单例。 */ + bool m_started = false; /**< 是否已 `Initialize`。 */ + Frame m_lastFrame; /**< 最近一帧像素。 */ + double m_lastRenderMs = 0.0; /**< 最近一帧渲染耗时。 */ + std::string m_loadedPath; /**< 当前数据文件路径(ASCII 打印用)。 */ + }; + } // namespace tools +} // namespace jdi + +#endif // __OFFSCREENSUPPORT_HPP__ diff --git a/Tools/OffscreenChecks/Src/Capture.cpp b/Tools/OffscreenChecks/Src/Capture.cpp new file mode 100644 index 0000000..2029da2 --- /dev/null +++ b/Tools/OffscreenChecks/Src/Capture.cpp @@ -0,0 +1,306 @@ +/** + * @file Capture.cpp + * @brief 后备缓冲抓帧、区域哈希、PNG 落盘、目录工具。 + * + * 全程不碰窗口 DC:读的是 `Application::BackBufferSurface()` 给的 top-down 32bpp DIB 段, + * 因此窗口即使被隐藏(`SW_HIDE`)也能抓到与真实显示一致的像素。 + */ + +#include +#include +#include + +#include +#include +#include +#include +#include + +#include "OffscreenSupport.hpp" + +namespace jdi +{ + namespace tools + { + namespace + { + /** @brief 取 GDI+ 编码器的 CLSID。 */ + bool FindEncoderClsid(const wchar_t* mimeType, CLSID& clsid) + { + UINT count = 0; + UINT size = 0; + if (Gdiplus::GetImageEncodersSize(&count, &size) != Gdiplus::Ok || size == 0) + { + return false; + } + std::vector buffer(size); + Gdiplus::ImageCodecInfo* codecs = + reinterpret_cast(buffer.data()); + if (Gdiplus::GetImageEncoders(count, size, codecs) != Gdiplus::Ok) + { + return false; + } + for (UINT index = 0; index < count; ++index) + { + if (wcscmp(codecs[index].MimeType, mimeType) == 0) + { + clsid = codecs[index].Clsid; + return true; + } + } + return false; + } + + /** @brief 把 ASCII 路径转宽字符串。 */ + std::wstring WidenPath(const std::string& path) + { + const int length = MultiByteToWideChar(CP_ACP, 0, path.c_str(), -1, nullptr, 0); + if (length <= 1) + { + return std::wstring(); + } + std::wstring wide(static_cast(length - 1), L'\0'); + MultiByteToWideChar(CP_ACP, 0, path.c_str(), -1, &wide[0], length); + return wide; + } + + /** @brief 夹取区域到帧内,返回 false 表示空区域。 */ + bool ClampRect(const Frame& frame, const PixelRect& rect, PixelRect& clamped) + { + const int left = std::max(0, rect.x); + const int top = std::max(0, rect.y); + const int right = std::min(frame.width, rect.x + rect.width); + const int bottom = std::min(frame.height, rect.y + rect.height); + if (right <= left || bottom <= top) + { + return false; + } + clamped.x = left; + clamped.y = top; + clamped.width = right - left; + clamped.height = bottom - top; + return true; + } + } // namespace + + bool CaptureFrame(const Application& app, Frame& frame) + { + const UiSurface surface = app.BackBufferSurface(); + if (!surface.Valid() || surface.bits == nullptr || surface.stride <= 0) + { + return false; + } + const int width = static_cast(surface.rect.Width); + const int height = static_cast(surface.rect.Height); + const std::size_t bytes = static_cast(surface.stride) * + static_cast(height); + + frame.width = width; + frame.height = height; + frame.stride = surface.stride; + frame.pixels.resize(bytes); + std::memcpy(frame.pixels.data(), surface.bits, bytes); + return true; + } + + std::uint64_t HashRegion(const Frame& frame, const PixelRect& rect) + { + PixelRect area; + if (!frame.Valid() || !ClampRect(frame, rect, area)) + { + return 0ull; + } + std::uint64_t hash = 1469598103934665603ull; // FNV-1a 64 位 offset basis + for (int row = 0; row < area.height; ++row) + { + const std::uint8_t* line = frame.Row(area.y + row) + static_cast(area.x) * 4u; + for (int column = 0; column < area.width; ++column) + { + // 只混 BGR:GDI 写 DIB 时不设置 alpha 字节,混进去会让哈希随填充方式漂移。 + hash ^= line[column * 4 + 0]; + hash *= 1099511628211ull; + hash ^= line[column * 4 + 1]; + hash *= 1099511628211ull; + hash ^= line[column * 4 + 2]; + hash *= 1099511628211ull; + } + } + return hash; + } + + int CountInk(const Frame& frame, const PixelRect& rect) + { + PixelRect area; + if (!frame.Valid() || !ClampRect(frame, rect, area)) + { + return 0; + } + // 先找到一个"主色"(出现次数最多的颜色 = 背景),再数与之不同的像素。 + std::unordered_map histogram; + histogram.reserve(static_cast(area.width) * 2u); + for (int row = 0; row < area.height; row += 2) + { + const std::uint8_t* line = frame.Row(area.y + row) + static_cast(area.x) * 4u; + for (int column = 0; column < area.width; column += 2) + { + const std::uint32_t color = static_cast(line[column * 4 + 0]) | + (static_cast(line[column * 4 + 1]) << 8) | + (static_cast(line[column * 4 + 2]) << 16); + ++histogram[color]; + } + } + std::uint32_t background = 0; + int best = -1; + for (const auto& entry : histogram) + { + if (entry.second > best) + { + best = entry.second; + background = entry.first; + } + } + + int ink = 0; + for (int row = 0; row < area.height; ++row) + { + const std::uint8_t* line = frame.Row(area.y + row) + static_cast(area.x) * 4u; + for (int column = 0; column < area.width; ++column) + { + const std::uint32_t color = static_cast(line[column * 4 + 0]) | + (static_cast(line[column * 4 + 1]) << 8) | + (static_cast(line[column * 4 + 2]) << 16); + if (color != background) + { + ++ink; + } + } + } + return ink; + } + + int CountDiff(const Frame& left, const Frame& right, const PixelRect& rect, const int tolerance) + { + PixelRect area; + if (!left.Valid() || !right.Valid() || left.width != right.width || + left.height != right.height || !ClampRect(left, rect, area)) + { + return -1; + } + int changed = 0; + for (int row = 0; row < area.height; ++row) + { + const std::uint8_t* a = left.Row(area.y + row) + static_cast(area.x) * 4u; + const std::uint8_t* b = right.Row(area.y + row) + static_cast(area.x) * 4u; + for (int column = 0; column < area.width; ++column) + { + const int deltaB = std::abs(static_cast(a[column * 4 + 0]) - b[column * 4 + 0]); + const int deltaG = std::abs(static_cast(a[column * 4 + 1]) - b[column * 4 + 1]); + const int deltaR = std::abs(static_cast(a[column * 4 + 2]) - b[column * 4 + 2]); + if (deltaB > tolerance || deltaG > tolerance || deltaR > tolerance) + { + ++changed; + } + } + } + return changed; + } + + bool SavePng(const Frame& frame, const std::string& path) + { + if (!frame.Valid()) + { + return false; + } + const std::size_t slash = path.find_last_of("/\\"); + if (slash != std::string::npos) + { + EnsureDirectory(path.substr(0, slash)); + } + + Gdiplus::Bitmap bitmap(frame.width, frame.height, frame.stride, + PixelFormat32bppRGB, + const_cast(frame.pixels.data())); + if (bitmap.GetLastStatus() != Gdiplus::Ok) + { + return false; + } + CLSID pngClsid = {}; + if (!FindEncoderClsid(L"image/png", pngClsid)) + { + return false; + } + const std::wstring widePath = WidenPath(path); + const Gdiplus::Status status = bitmap.Save(widePath.c_str(), &pngClsid, nullptr); + return status == Gdiplus::Ok; + } + + void EnsureDirectory(const std::string& asciiPath) + { + if (asciiPath.empty()) + { + return; + } + const std::wstring wide = WidenPath(asciiPath); + if (wide.empty()) + { + return; + } + std::wstring prefix; + prefix.reserve(wide.size()); + for (std::size_t index = 0; index < wide.size(); ++index) + { + prefix.push_back(wide[index]); + const bool boundary = + (wide[index] == L'/' || wide[index] == L'\\' || index + 1 == wide.size()); + if (boundary) + { + if (!prefix.empty() && prefix.back() != L'/' && prefix.back() != L'\\') + { + CreateDirectoryW(prefix.c_str(), nullptr); + } + } + } + } + + void RemoveDirectoryTree(const std::string& asciiPath) + { + const std::wstring wide = WidenPath(asciiPath); + if (wide.empty()) + { + return; + } + // 用 SHFileOperation 太重;这里用"先递归删文件再删目录"的最小实现。 + WIN32_FIND_DATAW data = {}; + HANDLE handle = FindFirstFileW((wide + L"\\*").c_str(), &data); + if (handle != INVALID_HANDLE_VALUE) + { + do + { + if (wcscmp(data.cFileName, L".") == 0 || wcscmp(data.cFileName, L"..") == 0) + { + continue; + } + const std::wstring child = wide + L"\\" + data.cFileName; + if ((data.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY) != 0) + { + const int length = WideCharToMultiByte(CP_ACP, 0, child.c_str(), -1, nullptr, 0, + nullptr, nullptr); + std::string narrow(static_cast(std::max(0, length - 1)), '\0'); + if (length > 1) + { + WideCharToMultiByte(CP_ACP, 0, child.c_str(), -1, &narrow[0], length, nullptr, + nullptr); + } + RemoveDirectoryTree(narrow); + } + else + { + DeleteFileW(child.c_str()); + } + } while (FindNextFileW(handle, &data) != FALSE); + FindClose(handle); + } + RemoveDirectoryW(wide.c_str()); + } + } // namespace tools +} // namespace jdi diff --git a/Tools/OffscreenChecks/Src/Checks.cpp b/Tools/OffscreenChecks/Src/Checks.cpp new file mode 100644 index 0000000..1352373 --- /dev/null +++ b/Tools/OffscreenChecks/Src/Checks.cpp @@ -0,0 +1,851 @@ +/** + * @file Checks.cpp + * @brief 离屏检查的具体实现(隐藏窗口 + 合成消息,绝不占用真实鼠标/键盘/屏幕)。 + * + * 检查清单: + * * `smoke` —— 能建隐藏窗口、能加载数据、能画出波形(像素级)。 + * * `frames` —— **连续帧抓取**:沿绘图区移动鼠标,逐帧抓 PNG + 哈希 + 耗时; + * 并验证"同一状态连续两帧逐像素相同"(渲染确定性)。 + * * `panels` —— 7 种实验性功能面板全部展开后的**几何 + 像素基线**, + * 支持 `-mode record` / `-mode compare` 做重构前后 A/B。 + * * `edgestats` —— 边沿统计与**独立参考实现**逐位比对(覆盖阈值变化、缓存稳定性)。 + * * `perf` —— 关键路径耗时:整帧、面板几何、边沿统计查询。 + * + * ⚠️ 所有 `Expect()` 都是"记录并继续",单个检查跑完仍然给出完整报告。 + */ + +#include +#include +#include +#include +#include +#include +#include + +#include "CheckRunner.hpp" + +namespace jdi +{ + namespace tools + { + namespace + { + /** @brief 各检查共用的临时输出目录(相对项目根,已被 .gitignore 忽略)。 */ + const char* const kDefaultOutDir = "../Temp/tools"; + + /** @brief 拼路径。 */ + std::string Join(const std::string& directory, const std::string& name) + { + if (directory.empty()) + { + return name; + } + const char last = directory.back(); + if (last == '/' || last == '\\') + { + return directory + name; + } + return directory + "/" + name; + } + + /** @brief 把 ASCII/本地代码页路径转宽字符串。 */ + std::wstring Widen(const std::string& text) + { + const int length = MultiByteToWideChar(CP_ACP, 0, text.c_str(), -1, nullptr, 0); + if (length <= 1) + { + return std::wstring(); + } + std::wstring wide(static_cast(length - 1), L'\0'); + MultiByteToWideChar(CP_ACP, 0, text.c_str(), -1, &wide[0], length); + return wide; + } + + /** @brief 在路径中取目录部分。 */ + std::string DirectoryOf(const std::string& path) + { + const std::size_t slash = path.find_last_of("/\\"); + return (slash == std::string::npos) ? std::string() : path.substr(0, slash); + } + + /** @brief 扫描信号的有限采样值范围(不依赖统计字段,便于构造探针阈值)。 */ + void FiniteRange(const std::vector& values, double& low, double& high) + { + low = std::numeric_limits::infinity(); + high = -std::numeric_limits::infinity(); + for (const double value : values) + { + if (!std::isfinite(value)) + { + continue; + } + low = std::min(low, value); + high = std::max(high, value); + } + if (!std::isfinite(low) || !std::isfinite(high)) + { + low = 0.0; + high = 1.0; + } + } + + /** + * @brief 边沿统计的**独立参考实现**(与 `PlotView::EdgeScanFor` 的契约一致)。 + * + * 契约(源码注释与实测共同确认): + * * 判定量 `delta = 绘制值 - 阈值`; + * * 采样对 `k-1 → k`:`previous < 0 && current >= 0` 记上升沿, + * `previous > 0 && current <= 0` 记下降沿; + * * 任一端为 NaN(缺失值 / 区间外)则该采样对不计入,并**打断连续性**。 + * + * ⚠️ 这里刻意用最朴素的逐点写法:参考实现宁慢勿巧,才有资格当"金标准"。 + */ + void ReferenceEdgeCounts(const std::vector& values, const SignalStyle& style, + const double threshold, std::size_t& rising, std::size_t& falling) + { + rising = 0; + falling = 0; + double previous = std::numeric_limits::quiet_NaN(); + for (std::size_t index = 0; index < values.size(); ++index) + { + const double raw = values[index]; + double current = std::numeric_limits::quiet_NaN(); + if (std::isfinite(raw)) + { + const double drawn = ApplyDrawTransform(style, raw); + if (std::isfinite(drawn)) + { + current = drawn - threshold; + } + } + if (std::isfinite(previous) && std::isfinite(current)) + { + if (previous < 0.0 && current >= 0.0) + { + ++rising; + } + else if (previous > 0.0 && current <= 0.0) + { + ++falling; + } + } + previous = current; + } + } + + /** @brief 查询边沿统计直到完成(每次未完成就再出一帧以重置每帧预算)。 */ + bool CollectEdgeCounts(Session& session, const TimeAxis& axis, const SignalData& signal, + const SignalStyle& style, const double threshold, std::size_t& rising, + std::size_t& falling, int& framesUsed) + { + rising = 0; + falling = 0; + framesUsed = 0; + bool complete = false; + const int maxFrames = 4000; + while (!complete && framesUsed < maxFrames) + { + session.App().m_plot.EdgeCountsFor(axis, signal, style, threshold, false, 0.0, 0.0, rising, + falling, complete); + if (complete) + { + break; + } + session.RenderFrame(); + ++framesUsed; + } + return complete; + } + + /** @brief 简单 key=value 基线文件的读取。 */ + std::vector> ParseBaseline(const std::string& text) + { + std::vector> entries; + std::size_t cursor = 0; + while (cursor < text.size()) + { + std::size_t end = text.find('\n', cursor); + if (end == std::string::npos) + { + end = text.size(); + } + std::string line = text.substr(cursor, end - cursor); + cursor = end + 1; + while (!line.empty() && (line.back() == '\r' || line.back() == ' ')) + { + line.pop_back(); + } + if (line.empty() || line[0] == '#') + { + continue; + } + const std::size_t equals = line.find('='); + if (equals == std::string::npos) + { + continue; + } + entries.emplace_back(line.substr(0, equals), line.substr(equals + 1)); + } + return entries; + } + + /** @brief 面板种类名(打印 / 基线文件键名)。 */ + const char* PanelKindName(const EdgePanelKind kind) + { + switch (kind) + { + case EdgePanelKind::COUNT: + return "count"; + case EdgePanelKind::CENTER: + return "center"; + case EdgePanelKind::MEAN: + return "mean"; + case EdgePanelKind::PEAK: + return "peak"; + case EdgePanelKind::MARK: + return "mark"; + case EdgePanelKind::RESAMPLE: + return "resample"; + case EdgePanelKind::CLIP: + return "clip"; + } + return "unknown"; + } + + /** @brief 确保某个实验性功能已开启(`Toggle*` 是开关语义)。 */ + void EnsureLaneFeature(PlotView& plot, const int laneIndex, const EdgePanelKind kind) + { + switch (kind) + { + case EdgePanelKind::COUNT: + if (!plot.LaneEdgeCount(laneIndex)) + { + plot.ToggleLaneEdgeCount(laneIndex); + } + break; + case EdgePanelKind::CENTER: + if (!plot.LaneEdgeCenterCount(laneIndex)) + { + plot.ToggleLaneEdgeCenterCount(laneIndex); + } + break; + case EdgePanelKind::MEAN: + if (!plot.LaneMeanFilter(laneIndex)) + { + plot.ToggleLaneMeanFilter(laneIndex); + } + break; + case EdgePanelKind::PEAK: + if (!plot.LaneWeakPeak(laneIndex)) + { + plot.ToggleLaneWeakPeak(laneIndex); + } + break; + case EdgePanelKind::MARK: + if (!plot.LaneFreeMarker(laneIndex)) + { + plot.ToggleLaneFreeMarker(laneIndex); + } + break; + case EdgePanelKind::RESAMPLE: + if (!plot.LaneResample(laneIndex)) + { + plot.ToggleLaneResample(laneIndex); + } + break; + case EdgePanelKind::CLIP: + if (!plot.LaneClip(laneIndex)) + { + plot.ToggleLaneClip(laneIndex); + } + break; + } + } + + // ---- 检查实现 ------------------------------------------------------- + + int CheckSmoke(Session& session, const CheckOptions& options) + { + int clientWidth = 0; + int clientHeight = 0; + session.ClientSize(clientWidth, clientHeight); + EmitLine("[smoke] client=%dx%d lanes=%d\n", clientWidth, clientHeight, session.LaneCount()); + + session.ShowFirstSignals(3); + Expect(session.RenderFrame(), "render frame"); + Expect(session.LaneCount() > 0, "at least one lane after showing signals"); + + const Frame& frame = session.LastFrame(); + Expect(frame.Valid(), "captured frame is valid"); + + const PixelRect plot = ToPixelRect(session.PlotArea()); + const int ink = CountInk(frame, plot); + EmitLine("[smoke] plot rect=(%d,%d,%dx%d) ink=%d render=%.2fms\n", plot.x, plot.y, plot.width, + plot.height, ink, session.LastRenderMs()); + Expect(plot.width > 100 && plot.height > 100, "plot area is reasonable"); + Expect(ink > 1000, "plot area has drawn content"); + + if (options.savePng) + { + const std::string path = Join(options.outDir, "smoke.png"); + Expect(SavePng(frame, path), "save smoke.png"); + EmitLine("[smoke] saved %s\n", path.c_str()); + } + + // 同一状态再渲染一帧:必须逐像素相同(渲染确定性;也验证缓存没有"半帧"状态) + const Frame first = session.LastFrame(); + Expect(session.RenderFrame(), "render frame again"); + const PixelRect whole{0, 0, session.LastFrame().width, session.LastFrame().height}; + const int drift = CountDiff(first, session.LastFrame(), whole, 0); + EmitLine("[smoke] determinism: differing pixels between two identical frames = %d\n", drift); + Expect(drift == 0, "two identical frames are pixel-identical"); + return CheckFailed(); + } + + int CheckFrames(Session& session, const CheckOptions& options) + { + session.ShowFirstSignals(2); + Expect(session.RenderFrame(), "initial render"); + + const PixelRect plot = ToPixelRect(session.PlotArea()); + Expect(plot.width > 100, "plot area is wide enough"); + + // 抓帧同时扫过**信号列表**:行悬停会改变行底色,因此每帧像素必然不同 + //(比“在空白绘图区里移动鼠标”更能证明“帧序列真的拍到了状态变化”)。 + const gd::RectF panelArea = session.App().m_panel.Area(); + const int panelX = static_cast(panelArea.X + panelArea.Width * 0.5f); + const int panelTop = static_cast(panelArea.Y) + 60; + + const int frames = std::max(1, options.frames); + int identicalPairs = 0; + std::uint64_t previousHash = 0; + double totalMs = 0.0; + double worstMs = 0.0; + + for (int index = 0; index < frames; ++index) + { + // 沿信号列表纵向移动“鼠标”(合成消息,不动真实光标) + const int y = panelTop + index * 34; + session.MouseMove(panelX, y); + Expect(session.RenderFrame(), "render frame while moving"); + + const Frame& frame = session.LastFrame(); + const std::uint64_t hash = HashRegion(frame, plot); + + char name[64] = {}; + std::snprintf(name, sizeof(name), "frame-%03d.png", index); + if (options.savePng) + { + if (!SavePng(frame, Join(options.outDir, name))) + { + Expect(false, "save frame png"); + } + } + + EmitLine("[frames] #%02d mouse=(%d,%d) plot-hash=%016llX render=%.2fms file=%s\n", index, panelX, + y, static_cast(hash), session.LastRenderMs(), name); + + totalMs += session.LastRenderMs(); + worstMs = std::max(worstMs, session.LastRenderMs()); + if (index > 0 && hash == previousHash) + { + ++identicalPairs; + EmitLine("[frames] WARNING: frame %d pixel-identical to previous\n", index); + } + previousHash = hash; + } + + EmitLine("[frames] average=%.2fms worst=%.2fms identical_pairs=%d\n", + totalMs / static_cast(frames), worstMs, identicalPairs); + Expect(totalMs / static_cast(frames) < 500.0, "average frame time below 500ms"); + // 至少要有一次可见变化(某些 y 落在筛选框/空白带,重复是正常的) + Expect(identicalPairs < frames - 1, "frame sequence captures at least one visual change"); + return CheckFailed(); + } + + int CheckPanels(Session& session, const CheckOptions& options) + { + // 单信号单网格:7 个面板都落在同一个网格上,便于逐面板取几何 + session.ShowFirstSignals(1); + Expect(session.LaneCount() == 1, "exactly one lane"); + + Application& app = session.App(); + PlotView& plot = app.m_plot; + + // 「选区截取」要求双光标,先切到双光标模式 + if (app.ActiveTab().CursorMode() != PlotCursorMode::DOUBLE) + { + const int guard = 4; + for (int step = 0; step < guard && app.ActiveTab().CursorMode() != PlotCursorMode::DOUBLE; ++step) + { + plot.CycleCursorMode(); + } + } + + static const EdgePanelKind kinds[] = {EdgePanelKind::COUNT, EdgePanelKind::CENTER, + EdgePanelKind::MEAN, EdgePanelKind::PEAK, + EdgePanelKind::MARK, EdgePanelKind::RESAMPLE, + EdgePanelKind::CLIP}; + for (const EdgePanelKind kind : kinds) + { + EnsureLaneFeature(plot, 0, kind); + } + + Expect(session.RenderFrame(), "render with all panels open"); + + const PixelRect plotRect = ToPixelRect(session.PlotArea()); + std::string content; + + for (const EdgePanelKind kind : kinds) + { + const PlotView::EdgePanelLayout layout = plot.EdgePanelLayoutFor(0, kind); + const PixelRect rect = ToPixelRect(layout.panel); + const Lane& lane = app.ActiveTab().Lanes()[0]; + const bool enabled = LanePanelEnabled(lane, kind); + const bool open = LanePanelStateOf(lane, kind).open; + EmitLine("[panels] %-8s enabled=%d open=%d rect=(%d,%d,%dx%d) hash=%016llX\n", + PanelKindName(kind), enabled ? 1 : 0, open ? 1 : 0, rect.x, rect.y, rect.width, + rect.height, static_cast(HashRegion(session.LastFrame(), rect))); + + Expect(enabled, std::string("feature enabled: ") + PanelKindName(kind)); + Expect(rect.width > 0 && rect.height > 0, + std::string("panel has size: ") + PanelKindName(kind)); + + // 不变式:面板必须被绘图区夹住(这是历史上"面板超出绘图区"缺陷的护栏) + const bool inside = rect.x >= plotRect.x - 1 && rect.y >= plotRect.y - 1 && + rect.x + rect.width <= plotRect.x + plotRect.width + 1 && + rect.y + rect.height <= plotRect.y + plotRect.height + 1; + Expect(inside, std::string("panel inside plot area: ") + PanelKindName(kind)); + + char buffer[160] = {}; + std::snprintf(buffer, sizeof(buffer), "%s=%016llX,%d,%d,%d,%d\n", PanelKindName(kind), + static_cast(HashRegion(session.LastFrame(), rect)), rect.x, + rect.y, rect.width, rect.height); + content += buffer; + } + + const std::string baselinePath = Join(options.outDir, "panels-baseline.txt"); + if (options.mode == "record") + { + Expect(WriteTextFile(baselinePath, content), "write panels baseline"); + EmitLine("[panels] baseline recorded: %s\n", baselinePath.c_str()); + } + else if (options.mode == "compare") + { + const std::string baseline = ReadTextFile(baselinePath); + Expect(!baseline.empty(), "panels baseline exists (run -mode record first)"); + const std::vector> entries = ParseBaseline(baseline); + const std::vector> current = ParseBaseline(content); + ExpectEqual(static_cast(current.size()), static_cast(entries.size()), + "panel count matches baseline"); + for (const auto& expected : entries) + { + bool found = false; + for (const auto& actual : current) + { + if (actual.first != expected.first) + { + continue; + } + found = true; + if (actual.second != expected.second) + { + EmitLine("[panels] MISMATCH %s: baseline=%s current=%s\n", expected.first.c_str(), + expected.second.c_str(), actual.second.c_str()); + Expect(false, std::string("panel pixel hash unchanged: ") + expected.first); + } + break; + } + if (!found) + { + Expect(false, std::string("panel present in baseline: ") + expected.first); + } + } + EmitLine("[panels] compare done against %s\n", baselinePath.c_str()); + } + else + { + // verify:没有基线也要守住"几何不变式",并把当前哈希打出来便于人工比对 + EmitLine("[panels] (hint) use -mode record / -mode compare for pixel A/B\n"); + } + + if (options.savePng) + { + Expect(SavePng(session.LastFrame(), Join(options.outDir, "panels.png")), + "save panels.png"); + } + return CheckFailed(); + } + + int CheckEdgeStats(Session& session, const CheckOptions& options) + { + session.ShowFirstSignals(1); + Expect(session.LaneCount() >= 1, "one lane"); + + const Application& app = session.App(); + SignalRef ref{0, 0}; + const DataSet& dataSet = app.m_dataSet; + const SignalGroup* group = dataSet.GroupAt(0); + Expect(group != nullptr && !group->signals.empty(), "group has signals"); + if (group == nullptr || group->signals.empty()) + { + return CheckFailed(); + } + const SignalData& signal = group->signals[0]; + const TimeAxis& axis = group->AxisOf(signal); + const SignalStyle& style = signal.style; + + double low = 0.0; + double high = 1.0; + FiniteRange(signal.values, low, high); + EmitLine("[edgestats] signal=%d samples min=%.6g max=%.6g\n", + static_cast(signal.values.size()), low, high); + + const double thresholds[] = {low + (high - low) * 0.25, (low + high) * 0.5, + low + (high - low) * 0.75}; + for (const double threshold : thresholds) + { + std::size_t rising = 0; + std::size_t falling = 0; + int framesUsed = 0; + const bool complete = CollectEdgeCounts(session, axis, signal, style, threshold, rising, + falling, framesUsed); + Expect(complete, "edge scan completes"); + + std::size_t expectedRising = 0; + std::size_t expectedFalling = 0; + ReferenceEdgeCounts(signal.values, style, threshold, expectedRising, expectedFalling); + + EmitLine("[edgestats] threshold=%.6g frames=%d got=(%llu,%llu) expected=(%llu,%llu)\n", + threshold, framesUsed, static_cast(rising), + static_cast(falling), + static_cast(expectedRising), + static_cast(expectedFalling)); + + ExpectEqual(static_cast(rising), static_cast(expectedRising), + "rising edge count matches reference"); + ExpectEqual(static_cast(falling), static_cast(expectedFalling), + "falling edge count matches reference"); + + // 缓存稳定性:同参数再查一次必须给出同一份完整结果 + std::size_t againRising = 0; + std::size_t againFalling = 0; + bool againComplete = false; + app.m_plot.EdgeCountsFor(axis, signal, style, threshold, false, 0.0, 0.0, againRising, + againFalling, againComplete); + Expect(againComplete, "repeat query completes immediately"); + ExpectEqual(static_cast(againRising), static_cast(rising), + "repeat query rising stable"); + ExpectEqual(static_cast(againFalling), static_cast(falling), + "repeat query falling stable"); + } + + // 中心轴统计:只验证"能完成且结果稳定"(独立参考实现需要复刻窗口语义, + // 收益不大;普通计数已覆盖 `EdgeScanFor` 的共享状态机) + { + std::size_t rising = 0; + std::size_t falling = 0; + bool complete = false; + int guard = 0; + while (!complete && guard < 4000) + { + app.m_plot.EdgeCenterCountsFor(axis, signal, style, 5, 1, false, 0.0, 0.0, rising, + falling, complete); + if (complete) + { + break; + } + session.RenderFrame(); + ++guard; + } + Expect(complete, "centre edge scan completes"); + EmitLine("[edgestats] centre (points=5,iterations=1) rising=%llu falling=%llu frames=%d\n", + static_cast(rising), + static_cast(falling), guard); + (void)ref; + } + return CheckFailed(); + } + + int CheckPerf(Session& session, const CheckOptions& options) + { + session.ShowFirstSignals(8); + Expect(session.RenderFrame(), "warm-up render"); + + // 1) 整帧渲染耗时(取 7 帧中位数,第一帧可能是预热) + std::vector renderTimes; + for (int iteration = 0; iteration < 7; ++iteration) + { + if (!session.RenderFrame()) + { + Expect(false, "render frame"); + break; + } + renderTimes.push_back(session.LastRenderMs()); + } + std::sort(renderTimes.begin(), renderTimes.end()); + const double medianRender = + renderTimes.empty() ? 0.0 : renderTimes[renderTimes.size() / 2]; + EmitLine("[perf] render median=%.2fms lanes=%d\n", medianRender, session.LaneCount()); + Expect(medianRender < 500.0, "full frame below 500ms"); + + // 2) 面板几何:`EdgePanelLayoutFor` 在鼠标移动的高频路径上,必须是"微秒级" + Application& app = session.App(); + PlotView& plot = app.m_plot; + static const EdgePanelKind kinds[] = {EdgePanelKind::COUNT, EdgePanelKind::CENTER, + EdgePanelKind::MEAN, EdgePanelKind::PEAK, + EdgePanelKind::MARK, EdgePanelKind::RESAMPLE, + EdgePanelKind::CLIP}; + for (const EdgePanelKind kind : kinds) + { + EnsureLaneFeature(plot, 0, kind); + } + session.RenderFrame(); + + const int iterations = 2000; + double totalMicroseconds = 0.0; + LARGE_INTEGER frequency = {}; + LARGE_INTEGER start = {}; + LARGE_INTEGER stop = {}; + QueryPerformanceFrequency(&frequency); + QueryPerformanceCounter(&start); + float sink = 0.0f; + for (int iteration = 0; iteration < iterations; ++iteration) + { + for (const EdgePanelKind kind : kinds) + { + const PlotView::EdgePanelLayout layout = plot.EdgePanelLayoutFor(0, kind); + sink += layout.panel.Width + layout.title.Height; + } + } + QueryPerformanceCounter(&stop); + totalMicroseconds = static_cast(stop.QuadPart - start.QuadPart) * 1000000.0 / + static_cast(frequency.QuadPart); + const double perCall = + totalMicroseconds / static_cast(iterations * static_cast(sizeof(kinds) / sizeof(kinds[0]))); + EmitLine("[perf] EdgePanelLayoutFor: %.3f us/call (x%d calls) sink=%.1f\n", perCall, + iterations * static_cast(sizeof(kinds) / sizeof(kinds[0])), sink); + Expect(perCall < 200.0, "panel layout below 200us per call"); + + // 3) 边沿统计查询:**多信号**时的"状态查找"成本 + // (第 33 轮报告建议把 `m_edgeScans` 的线性扫描改哈希索引,改的就是这条路径) + const DataSet& dataSet = app.m_dataSet; + struct QueryTarget + { + const SignalData* signal = nullptr; + const TimeAxis* axis = nullptr; + double threshold = 0.0; + }; + std::vector targets; + for (int groupIndex = 0; groupIndex < static_cast(dataSet.GroupCount()); ++groupIndex) + { + const SignalGroup* group = dataSet.GroupAt(groupIndex); + if (group == nullptr) + { + continue; + } + for (const SignalData& signal : group->signals) + { + if (signal.values.size() < 2) + { + continue; + } + double low = 0.0; + double high = 1.0; + FiniteRange(signal.values, low, high); + QueryTarget target; + target.signal = &signal; + target.axis = &group->AxisOf(signal); + target.threshold = (low + high) * 0.5; + targets.push_back(target); + if (targets.size() >= 200) + { + break; + } + } + if (targets.size() >= 200) + { + break; + } + } + + if (!targets.empty()) + { + // 预热:把所有累积状态都建起来并扫完(否则测到的是"扫描成本"而不是"查找成本") + for (int guard = 0; guard < 400; ++guard) + { + bool allComplete = true; + for (const QueryTarget& target : targets) + { + std::size_t r = 0; + std::size_t f = 0; + bool complete = false; + plot.EdgeCountsFor(*target.axis, *target.signal, target.signal->style, + target.threshold, false, 0.0, 0.0, r, f, complete); + if (!complete) + { + allComplete = false; + } + } + if (allComplete) + { + break; + } + session.RenderFrame(); + } + EmitLine("[perf] edge states=%d targets=%d\n", static_cast(plot.m_edgeScans.size()), + static_cast(targets.size())); + + const int rounds = 20; + QueryPerformanceCounter(&start); + for (int round = 0; round < rounds; ++round) + { + for (const QueryTarget& target : targets) + { + std::size_t r = 0; + std::size_t f = 0; + bool complete = false; + plot.EdgeCountsFor(*target.axis, *target.signal, target.signal->style, + target.threshold, false, 0.0, 0.0, r, f, complete); + sink += static_cast(r + f); + } + } + QueryPerformanceCounter(&stop); + const double total = static_cast(stop.QuadPart - start.QuadPart) * 1000000.0 / + static_cast(frequency.QuadPart); + const double perQuery = total / (static_cast(rounds) * + static_cast(targets.size())); + EmitLine("[perf] EdgeCountsFor sweep: %.3f us/query (%d targets x %d rounds) sink=%.1f\n", + perQuery, static_cast(targets.size()), rounds, sink); + Expect(perQuery < 20.0, "edge count query below 20us with 200 states"); + } + + if (options.savePng) + { + Expect(SavePng(session.LastFrame(), Join(options.outDir, "perf.png")), "save perf.png"); + } + return CheckFailed(); + } + } // namespace + + const std::vector& AvailableChecks() + { + static const std::vector checks = { + {"smoke", "offscreen init + load + render + determinism + save png", CheckSmoke}, + {"frames", "capture consecutive frames while moving mouse (png + hash + timing)", CheckFrames}, + {"panels", "all 7 experiment panels: geometry invariants + pixel baseline A/B", CheckPanels}, + {"edgestats", "edge counts vs independent reference implementation", CheckEdgeStats}, + {"perf", "render / panel layout / edge query timings", CheckPerf}, + }; + return checks; + } + + const CheckDescriptor* FindCheck(const std::string& name) + { + for (const CheckDescriptor& descriptor : AvailableChecks()) + { + if (name == descriptor.name) + { + return &descriptor; + } + } + return nullptr; + } + + int& CheckTotal() + { + static int total = 0; + return total; + } + + int& CheckFailed() + { + static int failed = 0; + return failed; + } + + void Expect(const bool ok, const std::string& what) + { + ++CheckTotal(); + if (!ok) + { + ++CheckFailed(); + EmitLine(" [FAIL] %s\n", what.c_str()); + } + } + + void ExpectEqual(const long long actual, const long long expected, const std::string& what) + { + ++CheckTotal(); + if (actual != expected) + { + ++CheckFailed(); + EmitLine(" [FAIL] %s: actual=%lld expected=%lld\n", what.c_str(), actual, expected); + } + } + + void ExpectNear(const double actual, const double expected, const double tolerance, + const std::string& what) + { + const double delta = (actual > expected) ? (actual - expected) : (expected - actual); + ++CheckTotal(); + if (!(delta <= tolerance)) + { + ++CheckFailed(); + EmitLine(" [FAIL] %s: actual=%.17g expected=%.17g\n", what.c_str(), actual, expected); + } + } + + std::string ReadTextFile(const std::string& path) + { + const std::wstring wide = Widen(path); + if (wide.empty()) + { + return std::string(); + } + HANDLE handle = CreateFileW(wide.c_str(), GENERIC_READ, FILE_SHARE_READ, nullptr, OPEN_EXISTING, + FILE_ATTRIBUTE_NORMAL, nullptr); + if (handle == INVALID_HANDLE_VALUE) + { + return std::string(); + } + LARGE_INTEGER size = {}; + if (!GetFileSizeEx(handle, &size) || size.QuadPart <= 0 || size.QuadPart > (1ll << 26)) + { + CloseHandle(handle); + return std::string(); + } + std::string content(static_cast(size.QuadPart), '\0'); + DWORD read = 0; + const BOOL ok = ReadFile(handle, &content[0], static_cast(content.size()), &read, nullptr); + CloseHandle(handle); + if (!ok) + { + return std::string(); + } + content.resize(read); + return content; + } + + bool WriteTextFile(const std::string& path, const std::string& content) + { + EnsureDirectory(DirectoryOf(path)); + const std::wstring wide = Widen(path); + if (wide.empty()) + { + return false; + } + HANDLE handle = CreateFileW(wide.c_str(), GENERIC_WRITE, 0, nullptr, CREATE_ALWAYS, + FILE_ATTRIBUTE_NORMAL, nullptr); + if (handle == INVALID_HANDLE_VALUE) + { + return false; + } + DWORD written = 0; + const BOOL ok = WriteFile(handle, content.data(), static_cast(content.size()), &written, + nullptr); + CloseHandle(handle); + return ok != FALSE && written == content.size(); + } + } // namespace tools +} // namespace jdi diff --git a/Tools/OffscreenChecks/Src/Main.cpp b/Tools/OffscreenChecks/Src/Main.cpp new file mode 100644 index 0000000..11d92bd --- /dev/null +++ b/Tools/OffscreenChecks/Src/Main.cpp @@ -0,0 +1,271 @@ +/** + * @file Main.cpp + * @brief 离屏验证工具入口(控制台)。 + * + * 用法: + * OffscreenChecks -list + * OffscreenChecks -run smoke + * OffscreenChecks -all + * OffscreenChecks -run panels -mode record 记录面板像素基线到 Temp/tools + * OffscreenChecks -run panels -mode compare 与基线比对(重构前后 A/B) + * OffscreenChecks -run frames -frames 12 连续抓 12 帧 + * OffscreenChecks -data "../TestData/BIG_DATA.csv" -all + * + * 可选:`-out `(默认 `../Temp/tools`)、`-report `、`-no-png`。 + * + * 退出码:0 = 全部通过;1 = 有失败断言;2 = 参数错误 / 数据加载失败。 + * + * ⚠️ 窗口一律 `SW_HIDE`:不显示、不抢焦点、不移动真实鼠标。 + * ⚠️ 临时产物(PNG / 基线 / 报告)统一写 `Temp/`(已被 .gitignore 忽略)。 + */ + +#include +#include +#include +#include +#include +#include + +#include "CheckRunner.hpp" +#include "OffscreenSupport.hpp" + +namespace +{ + std::FILE* g_report = nullptr; + + /** @brief 窄路径(本地代码页)→ 宽字符串。 */ + std::wstring Widen(const std::string& text) + { + const int length = MultiByteToWideChar(CP_ACP, 0, text.c_str(), -1, nullptr, 0); + if (length <= 1) + { + return std::wstring(); + } + std::wstring wide(static_cast(length - 1), L'\0'); + MultiByteToWideChar(CP_ACP, 0, text.c_str(), -1, &wide[0], length); + return wide; + } + + /** @brief 判断文件是否存在。 */ + bool FileExists(const std::string& path) + { + const std::wstring wide = Widen(path); + if (wide.empty()) + { + return false; + } + HANDLE handle = CreateFileW(wide.c_str(), GENERIC_READ, FILE_SHARE_READ, nullptr, OPEN_EXISTING, + FILE_ATTRIBUTE_NORMAL, nullptr); + if (handle == INVALID_HANDLE_VALUE) + { + return false; + } + CloseHandle(handle); + return true; + } +} // namespace + +namespace jdi +{ + namespace tools + { + void EmitLine(const char* format, ...) + { + char buffer[4096]; + va_list arguments; + va_start(arguments, format); + const int length = std::vsnprintf(buffer, sizeof(buffer), format, arguments); + va_end(arguments); + if (length <= 0) + { + return; + } + std::fputs(buffer, stdout); + std::fflush(stdout); + if (g_report != nullptr) + { + std::fputs(buffer, g_report); + std::fflush(g_report); + } + } + } // namespace tools +} // namespace jdi + +int main(int argc, char** argv) +{ + using namespace jdi::tools; + + CheckOptions options; + options.outDir = "../Temp/tools"; + options.mode = "verify"; + std::string reportPath = "../Temp/tools/offscreen-report.txt"; + std::string singleCheck; + bool runAll = false; + bool listOnly = false; + + for (int index = 1; index < argc; ++index) + { + const char* argument = argv[index]; + if (std::strcmp(argument, "-list") == 0) + { + listOnly = true; + } + else if (std::strcmp(argument, "-run") == 0 && index + 1 < argc) + { + singleCheck = argv[++index]; + } + else if (std::strcmp(argument, "-all") == 0) + { + runAll = true; + } + else if (std::strcmp(argument, "-data") == 0 && index + 1 < argc) + { + options.dataPath = argv[++index]; + } + else if (std::strcmp(argument, "-out") == 0 && index + 1 < argc) + { + options.outDir = argv[++index]; + } + else if (std::strcmp(argument, "-report") == 0 && index + 1 < argc) + { + reportPath = argv[++index]; + } + else if (std::strcmp(argument, "-mode") == 0 && index + 1 < argc) + { + options.mode = argv[++index]; + } + else if (std::strcmp(argument, "-frames") == 0 && index + 1 < argc) + { + options.frames = std::atoi(argv[++index]); + } + else if (std::strcmp(argument, "-no-png") == 0) + { + options.savePng = false; + } + else + { + std::printf("unknown argument: %s\n", argument); + return 2; + } + } + + if (listOnly) + { + for (const CheckDescriptor& descriptor : AvailableChecks()) + { + std::printf("%-10s %s\n", descriptor.name, descriptor.summary); + } + return 0; + } + + EnsureDirectory(options.outDir); + + g_report = std::fopen(reportPath.c_str(), "wb"); + + EmitLine("==============================================================\n"); + EmitLine(" Just DataInspector - offscreen checks\n"); + EmitLine(" mode=%s out=%s report=%s\n", options.mode.c_str(), options.outDir.c_str(), reportPath.c_str()); + EmitLine("==============================================================\n"); + + // 数据文件:命令行优先,否则在 TestData 里挑一个可用的样本 + if (options.dataPath.empty()) + { + static const char* const candidates[] = {"../TestData/SCATTER_TEST.csv", "../TestData/[1].CSV", + "../TestData/BIG_DATA.csv", "../TestData/Problem_Data.CSV"}; + for (const char* const candidate : candidates) + { + if (FileExists(candidate)) + { + options.dataPath = candidate; + break; + } + } + } + if (options.dataPath.empty() || !FileExists(options.dataPath)) + { + EmitLine("[FAIL] no data file: pass -data (TestData/ 不入库,需本地准备)\n"); + if (g_report != nullptr) + { + std::fclose(g_report); + } + return 2; + } + EmitLine(" data=%s\n", options.dataPath.c_str()); + + Session session; + if (!session.Start()) + { + EmitLine("[FAIL] session start (hidden window creation)\n"); + if (g_report != nullptr) + { + std::fclose(g_report); + } + return 2; + } + if (!session.Load(options.dataPath)) + { + EmitLine("[FAIL] load data file\n"); + session.Stop(); + if (g_report != nullptr) + { + std::fclose(g_report); + } + return 2; + } + EmitLine(" loaded: groups=%d lanes=%d\n", static_cast(session.App().m_dataSet.GroupCount()), + session.LaneCount()); + + std::vector selected; + if (!singleCheck.empty()) + { + const CheckDescriptor* descriptor = FindCheck(singleCheck); + if (descriptor == nullptr) + { + EmitLine("[FAIL] unknown check: %s (use -list)\n", singleCheck.c_str()); + session.Stop(); + if (g_report != nullptr) + { + std::fclose(g_report); + } + return 2; + } + selected.push_back(descriptor); + } + else + { + for (const CheckDescriptor& descriptor : AvailableChecks()) + { + selected.push_back(&descriptor); + } + (void)runAll; + } + + int executed = 0; + for (const CheckDescriptor* descriptor : selected) + { + const int failedBefore = CheckFailed(); + EmitLine("--------------------------------------------------------------\n"); + EmitLine("[CHECK] %s - %s\n", descriptor->name, descriptor->summary); + descriptor->run(session, options); + ++executed; + const int failed = CheckFailed() - failedBefore; + EmitLine("[CHECK] %s => %s\n", descriptor->name, (failed == 0) ? "PASS" : "FAIL"); + } + + EmitLine("--------------------------------------------------------------\n"); + EmitLine("SUMMARY checks=%d failed=%d groups=%d result=%s\n", CheckTotal(), CheckFailed(), executed, + (CheckFailed() == 0) ? "PASS" : "FAIL"); + EmitLine("--------------------------------------------------------------\n"); + + session.Stop(); + if (g_report != nullptr) + { + std::fclose(g_report); + g_report = nullptr; + } + + // ⚠️ 用 `_Exit` 收尾:`Application` / `PlotView` 是进程级单例,析构发生在 `GdiplusShutdown` + // 之后会访问违例(历史踩过,退出码 0xC0000005)。 + const int code = (CheckFailed() == 0) ? 0 : 1; + std::_Exit(code); +} diff --git a/Tools/OffscreenChecks/Src/Session.cpp b/Tools/OffscreenChecks/Src/Session.cpp new file mode 100644 index 0000000..9748b89 --- /dev/null +++ b/Tools/OffscreenChecks/Src/Session.cpp @@ -0,0 +1,231 @@ +/** + * @file Session.cpp + * @brief 离屏会话实现:隐藏窗口、加载数据、逐帧渲染与抓帧。 + * + * ⚠️ 全程不显示窗口、不移动真实鼠标、不抢焦点:所有"鼠标"都是直接调用 + * `Application::HandleMouseMove()` 注入的合成消息。 + */ + +#include "OffscreenSupport.hpp" + +namespace jdi +{ + namespace tools + { + namespace + { + /** @brief 窄字符串(本地代码页)→ 宽字符串。 */ + std::wstring WidenLocal(const std::string& text) + { + if (text.empty()) + { + return std::wstring(); + } + // 先按 UTF-8 试一次(Agent / 终端最常见),失败再退回本地代码页。 + int length = MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, text.c_str(), -1, nullptr, 0); + UINT codePage = CP_UTF8; + if (length <= 1) + { + codePage = CP_ACP; + length = MultiByteToWideChar(CP_ACP, 0, text.c_str(), -1, nullptr, 0); + } + if (length <= 1) + { + return std::wstring(); + } + std::wstring wide(static_cast(length - 1), L'\0'); + MultiByteToWideChar(codePage, 0, text.c_str(), -1, &wide[0], length); + return wide; + } + } // namespace + + bool Session::Start() + { + if (m_started) + { + return true; + } + m_app = &Application::Instance(); + if (!m_app->Initialize(GetModuleHandleW(nullptr), SW_HIDE)) + { + m_app = nullptr; + return false; + } + m_started = true; + return true; + } + + void Session::Stop() + { + if (m_app != nullptr && m_started) + { + m_app->Shutdown(); + m_started = false; + } + } + + bool Session::Load(const std::string& path) + { + if (m_app == nullptr) + { + return false; + } + const std::wstring wide = WidenLocal(path); + if (wide.empty()) + { + return false; + } + if (!m_app->LoadFile(wide)) + { + return false; + } + m_loadedPath = path; + m_app->SynchronizeView(true); + m_app->Relayout(); + return true; + } + + bool Session::RenderFrame() + { + if (m_app == nullptr) + { + return false; + } + // 强制整帧:`m_chromeDirty` 为真时标签栏/工具栏/面板/绘图区都会重绘, + // 否则 `Render()` 可能走"只重绘绘图区"的快路径,抓到的不是完整画面。 + m_app->m_chromeDirty = true; + + LARGE_INTEGER frequency = {}; + LARGE_INTEGER start = {}; + LARGE_INTEGER stop = {}; + QueryPerformanceFrequency(&frequency); + QueryPerformanceCounter(&start); + m_app->Render(); + QueryPerformanceCounter(&stop); + m_lastRenderMs = static_cast(stop.QuadPart - start.QuadPart) * 1000.0 / + static_cast(frequency.QuadPart); + + return CaptureFrame(*m_app, m_lastFrame); + } + + void Session::ShowSignals(const std::vector& refs) + { + if (m_app == nullptr) + { + return; + } + TabConfig& tab = m_app->ActiveTab(); + tab.SetAllVisible(false); + for (const SignalRef& ref : refs) + { + tab.SetVisible(ref, true); + } + m_app->SynchronizeView(true); + m_app->Relayout(); + } + + void Session::ShowFirstSignals(const int limit) + { + if (m_app == nullptr) + { + return; + } + const DataSet& dataSet = m_app->m_dataSet; + std::vector refs; + for (int groupIndex = 0; groupIndex < static_cast(dataSet.GroupCount()); ++groupIndex) + { + const SignalGroup* group = dataSet.GroupAt(groupIndex); + if (group == nullptr) + { + continue; + } + for (int signalIndex = 0; signalIndex < static_cast(group->signals.size()); ++signalIndex) + { + if (limit > 0 && static_cast(refs.size()) >= limit) + { + break; + } + refs.push_back(SignalRef{groupIndex, signalIndex}); + } + if (limit > 0 && static_cast(refs.size()) >= limit) + { + break; + } + } + ShowSignals(refs); + } + + void Session::HideAll() + { + if (m_app == nullptr) + { + return; + } + m_app->ActiveTab().SetAllVisible(false); + m_app->SynchronizeView(true); + m_app->Relayout(); + } + + bool Session::MouseMove(const int x, const int y) + { + if (m_app == nullptr) + { + return false; + } + return m_app->HandleMouseMove(x, y, false); + } + + gd::RectF Session::PlotArea() const + { + if (m_app == nullptr) + { + return gd::RectF(0.0f, 0.0f, 0.0f, 0.0f); + } + return m_app->m_plot.Area(); + } + + gd::RectF Session::LaneRect(const int index) const + { + if (m_app == nullptr || index < 0) + { + return gd::RectF(0.0f, 0.0f, 0.0f, 0.0f); + } + const std::vector& lanes = m_app->m_plot.m_lanes; + if (index >= static_cast(lanes.size())) + { + return gd::RectF(0.0f, 0.0f, 0.0f, 0.0f); + } + return lanes[static_cast(index)].rect; + } + + int Session::LaneCount() const + { + if (m_app == nullptr) + { + return 0; + } + return static_cast(m_app->m_plot.m_lanes.size()); + } + + void Session::ClientSize(int& width, int& height) const + { + width = 0; + height = 0; + if (m_app == nullptr || m_app->m_hwnd == nullptr) + { + return; + } + RECT rect = {}; + GetClientRect(m_app->m_hwnd, &rect); + width = rect.right - rect.left; + height = rect.bottom - rect.top; + if (width <= 0 || height <= 0) + { + // 隐藏窗口在某些 DPI 路径下可能还没有客户区尺寸,退回后备缓冲区尺寸。 + const Frame& frame = m_lastFrame; + width = frame.width; + height = frame.height; + } + } + } // namespace tools +} // namespace jdi diff --git a/Tools/README.md b/Tools/README.md new file mode 100644 index 0000000..5d6857c --- /dev/null +++ b/Tools/README.md @@ -0,0 +1,238 @@ +# `Tools/` —— 单元测试与离屏验证工具 + +> 本目录是 **验证基础设施**,不是产品代码: +> 根目录 `Makefile` 只构建应用本体(`Build/Release`、`Build/Debug`), +> **不会**编译这里的任何文件;这里的一切由 `Tools/Makefile` 独立构建。 +> +> 设计目标:让"**改完能不能证明没改坏**"变成一条命令的事, +> 而不是靠肉眼截图 + 反复手点鼠标。 + +--- + +## 1. 目录结构 + +```text +Tools/ +├─ Makefile # 统一构建入口(构建 / 清理 / 帮助) +├─ README.md # 本文件(改动工具后必须同步更新) +├─ UnitTests/ # 工具一:纯数据层单元测试(不创建窗口) +│ ├─ Inc/ +│ │ └─ UnitTestFramework.hpp +│ ├─ Src/ +│ │ ├─ Main.cpp # 测试执行器:-list / -only / -report / -verbose +│ │ ├─ Utf8UtilTests.cpp # 数值解析与格式化语义 +│ │ ├─ FormulaEvalTests.cpp# 公式语法 / 语义 / 批量求值一致性 +│ │ └─ DataModelTests.cpp # 变换顺序、滑动平均、公式值域缓存 +│ └─ Build/ # 构建产物(.gitignore,不入库) +└─ OffscreenChecks/ # 工具二:离屏验证(隐藏窗口 + 抓帧) + ├─ Inc/ + │ ├─ OffscreenSupport.hpp# 白盒支撑:抓帧 / 哈希 / 隐藏窗口会话 + │ └─ CheckRunner.hpp # 检查注册表 + 断言 / 输出接口 + ├─ Src/ + │ ├─ Main.cpp # 命令行入口 + │ ├─ Session.cpp # 隐藏窗口、加载、逐帧渲染(合成鼠标消息) + │ ├─ Capture.cpp # 后备缓冲抓帧、FNV 哈希、PNG 落盘 + │ └─ Checks.cpp # 5 个检查的具体实现 + └─ Build/ # 构建产物(.gitignore,不入库) +``` + +**约定** + +| 项 | 约定 | +| --- | --- | +| 头文件 | 只放 `/Inc` | +| 实现 | 只放 `/Src`(该目录下**每个** `.cpp` 都会被编译) | +| 产物 | `/Build`,与 `Inc` / `Src` **同级** | +| 无 Debug/Release 之分 | 工具一律 `-O2 -g`:要跑得快、要能上调试器 | +| 临时产物 | PNG / 基线 / 报告一律写 `Temp/tools/`(已 gitignore),**不许**写进 `Tools/` | + +--- + +## 2. 构建 + +```powershell +# 前置:应用本体必须先构建(工具链接的是本体的 .o,排除入口 Main.o) +make release -j8 + +# 构建全部工具 +make -C Tools + +# 只构建某一个 +make -C Tools UnitTests +make -C Tools OffscreenChecks + +# 清理(只删两个工具自己的 Build/,不动本体) +make -C Tools clean + +# 看帮助 +make -C Tools help +``` + +产物: + +```text +Tools/UnitTests/Build/UnitTests.exe +Tools/OffscreenChecks/Build/OffscreenChecks.exe +``` + +> ⚠️ **必须与本体保持同一套 ABI 开关**:`-fno-exceptions`、`-fno-rtti`、 +> 链接时 `-flto -flto-partition=one`。工具源码自身**不**加 `-flto` +> (否则 GCC 15.2.0 在 `choose_baseaddr` 处 ICE)。 +> +> ⚠️ 头文件依赖用 `-MMD -MP` 跟踪:改头文件、增删源文件都会自动重编重链。 +> 若出现"离奇的 undefined reference",先 `make -C Tools clean`。 + +--- + +## 3. 工具一:`UnitTests`(纯逻辑,最快、最常用) + +**不创建窗口、不初始化 GUI**,因此可以无限次在后台运行。 + +```powershell +Tools\UnitTests\Build\UnitTests.exe # 全部 +... -only formula # 只跑名字含 formula 的用例 +... -list # 列出用例 +... -report ..\Temp\tools\ut.txt # 指定报告路径 +``` + +输出约定(便于脚本 / Agent 解析): + +```text +[RUN ] formula_semantics + [FAIL] <表达式> (file:line) +SUMMARY checks= failed= cases= result= +``` + +### 3.1 测试项 + +| 用例 | 覆盖内容 | 为什么重要 | +| --- | --- | --- | +| `parse_fixed_corpus_matches_strtod` | 常见字面量(含 `1e22`、`-0`、`1e-22`)与 `strtod` **逐位相同** | 数值解析的基准线 | +| `parse_acceptance_semantics` | 空/空白/垃圾/尾随字符/十六进制浮点/`inf`/`nan` 的接受性 | 与改造前的宽松语义一致 | +| `parse_random_corpus_bit_exact` | 6 万条随机十进制串;≤15 位有效数字要求**逐位相同**,≤19 位允许 ≤1 ULP | 「自写 strtod 替身」类优化的唯一验收标准 | +| `format_decimal_never_scientific` | `FormatDecimal*` 永不输出科学计数法;`FormatValue` = `%.6g` | 显示层硬约束 | +| `format_byte_size_and_frequency` | 单位后缀存在且无指数形式 | 单位格式化回归 | +| `text_trim_and_utf8_conversion` | `Trim` / `ToLowerAscii` / `Utf8ToWide` / 大小写无关包含 | 文本基础 | +| `ticks_nice_step_and_build_ticks` | `NiceStep` 落在 1/2/2.5/5×10ⁿ;`BuildTicks` 端点 | 标尺可读性 | +| `formula_syntax_acceptance` | 合法 / 非法写法(单独 `f`、`2e`、`()`、`*2` …) | 公式输入框的报错边界 | +| `formula_semantics` | `2^3^2=512`、`-2^2=-4`、`2^-1`、`e`/`pi`、恒等 | 语法语义 | +| `formula_identity_helpers` | `f(t)` ≡ 空串;`HasFormula()`;`NormalizeFormulaText` | 第 32 轮「恒等公式卡死」的护栏 | +| `formula_program_matches_pointwise` | 批量求值 vs 逐点求值 **逐位相同**(8 条公式 × 4000 点) | 常量折叠 / 幂次改写不许改数值 | +| `formula_invalid_falls_back_safely` | 非法公式原样返回采样值 | 写错公式不得让波形消失 | +| `transform_apply_order` | 公式→系数→绝对值→垂直镜像→Y 偏移;NaN 保持;水平镜像只改下标 | 变换顺序 | +| `transform_value_range_and_order_preserving` | 区间映射(含跨 0 取绝对值)与保序判定 | Y 轴自适应 | +| `filter_moving_average_equivalence` | 1 阶 ≡ 单次、2 阶 ≡ 链式、常数信号不变 | 均值滤波多阶 | +| `dataset_group_access` | `AddGroup` / `GroupAt` / `Style(ref)` 读写 | 数据层入口 | +| `dataset_formula_transform_cache` | 值域 + 逐分组极值 vs 逐点参考**逐位相同**;命中同指针;样式变化失效 | 第 32 轮核心缓存的正确性 | + +### 3.2 验收标准 + +* **零失败**(`result=PASS`)才算通过; +* 断言数量只增不减(当前 **218** 项):删断言必须说明理由; +* 任何"为了让它过"而放宽的断言,必须在代码注释里写清依据。 + +--- + +## 4. 工具二:`OffscreenChecks`(真实渲染,隐藏窗口) + +**不显示窗口、不移动真实鼠标、不抢焦点**:窗口 `SW_HIDE` 创建, +"鼠标"通过 `Application::HandleMouseMove()` 注入合成消息, +像素从**后备缓冲区**读取(不是窗口 DC)。 + +```powershell +Tools\OffscreenChecks\Build\OffscreenChecks.exe -list +... -all # 依次跑全部检查 +... -run smoke # 单个检查 +... -run panels -mode record # 记录面板像素基线 +... -run panels -mode compare # 与基线比对(重构前后 A/B) +... -run frames -frames 12 # 连续抓 12 帧 +... -data "..\TestData\MF4-Record.MF4" -run perf +... -out ..\Temp\tools\run1 # 换输出目录(避免覆盖基线) +... -no-png # 不落盘 PNG(只算哈希/耗时) +``` + +数据文件默认按顺序探测 `TestData/` 里的 +`SCATTER_TEST.csv` → `[1].CSV` → `BIG_DATA.csv` → `Problem_Data.CSV`; +`TestData/` 不入库,缺失时请用 `-data` 指定。 + +### 4.1 检查项 + +| 检查 | 做了什么 | 断言 / 判据 | +| --- | --- | --- | +| `smoke` | 建隐藏窗口 → 加载 → 显示 3 条信号 → 渲染 → 抓帧 → 落盘 `smoke.png` | 客户区/绘图区尺寸合理、绘图区墨迹 > 1000 像素、**同一状态连续两帧逐像素相同**(渲染确定性) | +| `frames` | 沿信号列表逐行移动"鼠标",每帧抓图 + 区域哈希 + 耗时 | 平均帧 < 500ms、帧序列**至少出现一次可见变化**;输出 `frame-XXX.png` 供事后分析 | +| `panels` | 打开全部 7 个实验性面板(含双光标下的「选区截取」) | 每个面板:功能已启用、有尺寸、**必须被绘图区夹住**;`-mode record` 写 `panels-baseline.txt`,`-mode compare` 逐面板比对**像素哈希 + 几何** | +| `edgestats` | 3 个阈值下的边沿计数(普通计数)+ 中心轴计数 | 与 `Checks.cpp` 内的**独立参考实现**(朴素逐点)**逐位相等**;同参数重复查询结果稳定 | +| `perf` | 整帧中位数、`EdgePanelLayoutFor` 单次耗时、198 个统计状态的整轮查询耗时 | 整帧 < 500ms、面板几何 < 200µs/次、统计查询 < 20µs/次;数字用于前后对比 | + +### 4.2 像素基线的正确用法(A/B) + +```powershell +# 1) 改动前:记录基线(同一台机器、同一 DPI、同一数据文件) +OffscreenChecks.exe -run panels -mode record -out ..\Temp\tools\panel-before + +# 2) 改代码 + 重新构建本体与工具 + +# 3) 改动后:用同一输出目录比对 +OffscreenChecks.exe -run panels -mode compare -out ..\Temp\tools\panel-before +``` + +⚠️ **像素哈希是"本机、本 DPI、本数据文件"的基线**,跨机器/跨缩放不可比。 +它适合"我改了一处,其它像素有没有动"的**本地 A/B**,不适合入库当黄金图。 + +### 4.3 输出(全部在 `Temp/tools/`,已 gitignore) + +| 文件 | 内容 | +| --- | --- | +| `offscreen-report.txt` | 完整控制台输出(含 `SUMMARY` 行) | +| `UnitTests-report.txt` | 单元测试报告 | +| `smoke.png` / `panels.png` / `perf.png` | 关键状态截图 | +| `frame-000.png` … | 连续帧(问题分析用) | +| `panels-baseline.txt` | 面板像素基线(`-mode record` 生成) | + +--- + +## 5. 怎么用它(按改动类型选最小集) + +| 你改了什么 | 建议跑 | +| --- | --- | +| 任何数据层 / 解析 / 公式 / 变换 | `UnitTests.exe` | +| 绘图、面板、布局、缓存键 | `-run smoke -run panels`(+ 需要时 `-mode compare`) | +| 边沿统计 / 中心轴 / 扫描预算 | `-run edgestats -run perf` | +| 性能相关 | `-run perf`(前后各跑一次,比 `us/query` 与 `median ms`) | +| 完全不确定影响面 | `-run -all`(约几秒到几十秒)+ `UnitTests.exe` | + +> 用户明确要求:**除非要求"全测一遍",否则只跑针对性检查**。 + +--- + +## 6. 新增一个检查 / 一个工具 + +### 6.1 新增离屏检查 + +1. 在 `OffscreenChecks/Src/Checks.cpp` 里写 `int CheckXxx(Session&, const CheckOptions&)`, + 用 `Expect()` / `ExpectEqual()` 记录断言,末尾 `return CheckFailed();` +2. 在 `AvailableChecks()` 的列表里追加一行 `{ "xxx", "one-line summary", CheckXxx }` +3. 在 README 的 §4.1 表格里补一行(**必须**) + +### 6.2 新增工具 + +1. 建 `Tools//{Inc,Src}` +2. 在 `Tools/Makefile` 的 `TOOLS` 里追加 ``(目录名 = 目标名) +3. 在本 README 的 §1 / §2 / §5 里补说明 + +--- + +## 7. 已知限制与坑 + +* **工具是白盒**:`OffscreenSupport.hpp` 用 `#define private public` 放宽访问级别, + 为了读后备缓冲区 / 直接调面板几何。产品接口不因测试而放宽。 +* **必须先构建本体**:工具链接 `../Build/Release/*.o`,本体没构建会给出明确警告。 +* **像素基线不可跨机复现**:DPI、字体、主题不同则哈希不同。 +* **`frames` 里个别帧可能重复**(鼠标落在筛选框/空白带),因此判据是"至少一次可见变化"。 +* **窗口一律 `SW_HIDE`**:不要为了"看看效果"改成显示窗口,那会占用用户屏幕。 + 需要看图请直接打开 `Temp/tools/*.png`。 +* **进程退出用 `std::_Exit()`**:`Application` / `PlotView` 是单例, + 若走正常析构会在 `GdiplusShutdown` 之后访问 GDI+ 对象(历史退出码 `0xC0000005`)。 +* **报告/截图不要提交**:全部落在 `Temp/`(已忽略)。`Tools/*/Build/` 也已忽略, + 其它协作者本地 `make -C Tools` 即可重建。 diff --git a/Tools/UnitTests/Inc/UnitTestFramework.hpp b/Tools/UnitTests/Inc/UnitTestFramework.hpp new file mode 100644 index 0000000..aba2517 --- /dev/null +++ b/Tools/UnitTests/Inc/UnitTestFramework.hpp @@ -0,0 +1,198 @@ +/** + * @file UnitTestFramework.hpp + * @brief 极简单元测试框架(无异常、无 RTTI,与项目本体 ABI 一致)。 + * + * 设计取舍: + * * **不引入第三方框架**(Catch2 / gtest 会带来几 MB 的静态库与异常依赖); + * * 项目本体用 `-fno-exceptions` 构建,因此断言失败只能"记录 + 继续", + * 不允许 `throw`; + * * 每个 `.cpp` 用 `JDI_TEST(名字)` 注册用例,`Main.cpp` 按注册顺序执行。 + * + * 输出约定(供 CI / Agent 读取): + * * 逐用例一行 `[RUN ] `,失败时一行 `[FAIL] (file:line)`; + * * 结尾一行 `SUMMARY checks= failed= result=`。 + * ⚠️ 所有输出保持 **ASCII**:控制台代码页为 GBK,中文只在注释里出现。 + */ + +#ifndef __UNITTESTFRAMEWORK_HPP__ +#define __UNITTESTFRAMEWORK_HPP__ + +#include +#include +#include +#include + +namespace jdi +{ + namespace test + { + /** @brief 一个注册在案的测试用例。 */ + struct TestCase + { + const char* name = ""; /**< 用例名(打印用,建议 `模块.行为`)。 */ + void (*function)() = nullptr; /**< 用例体。 */ + }; + + /** @brief 全局用例表。 */ + inline std::vector& Registry() + { + static std::vector registry; + return registry; + } + + /** @brief 断言总数。 */ + inline int& TotalChecks() + { + static int value = 0; + return value; + } + + /** @brief 失败断言数。 */ + inline int& FailedChecks() + { + static int value = 0; + return value; + } + + /** @brief 当前用例是否已失败(用于给出汇总提示)。 */ + inline bool& CaseFailed() + { + static bool value = false; + return value; + } + + /** @brief 注册用例(由 `JDI_TEST` 宏在静态初始化期调用)。 */ + inline bool Register(const char* name, void (*function)()) + { + Registry().push_back(TestCase{name, function}); + return true; + } + + /** @brief 记录一次布尔断言。 */ + inline void Check(bool ok, const char* expression, const char* file, int line) + { + ++TotalChecks(); + if (!ok) + { + ++FailedChecks(); + CaseFailed() = true; + std::printf(" [FAIL] %s (%s:%d)\n", expression, file, line); + std::fflush(stdout); + } + } + + /** @brief 记录一次"带说明"的布尔断言(说明只用于失败时定位)。 */ + inline void CheckWithNote(bool ok, const char* note, const char* file, int line) + { + ++TotalChecks(); + if (!ok) + { + ++FailedChecks(); + CaseFailed() = true; + std::printf(" [FAIL] %s (%s:%d)\n", note, file, line); + std::fflush(stdout); + } + } + + /** @brief 记录一次整数相等断言(失败时打印实际值/期望值)。 */ + inline void CheckEqual(const long long actual, const long long expected, const char* note, + const char* file, int line) + { + ++TotalChecks(); + if (actual != expected) + { + ++FailedChecks(); + CaseFailed() = true; + std::printf(" [FAIL] %s: actual=%lld expected=%lld (%s:%d)\n", note, actual, expected, + file, line); + std::fflush(stdout); + } + } + + /** @brief 记录一次浮点近似断言。 */ + inline void CheckNear(const double actual, const double expected, const double tolerance, + const char* note, const char* file, int line) + { + const bool bothNaN = (actual != actual) && (expected != expected); + const double delta = (actual > expected) ? (actual - expected) : (expected - actual); + ++TotalChecks(); + if (!bothNaN && !(delta <= tolerance)) + { + ++FailedChecks(); + CaseFailed() = true; + std::printf(" [FAIL] %s: actual=%.17g expected=%.17g tolerance=%.3g (%s:%d)\n", note, + actual, expected, tolerance, file, line); + std::fflush(stdout); + } + } + + /** @brief 记录一次"逐位相同"断言(用于数值语义回归)。 */ + inline void CheckBitEqual(const double actual, const double expected, const char* note, + const char* file, int line) + { + std::uint64_t a = 0; + std::uint64_t b = 0; + static_assert(sizeof(a) == sizeof(actual), "double must be 8 bytes"); + __builtin_memcpy(&a, &actual, sizeof(a)); + __builtin_memcpy(&b, &expected, sizeof(b)); + ++TotalChecks(); + if (a != b) + { + ++FailedChecks(); + CaseFailed() = true; + std::printf(" [FAIL] %s: actual=%.17g (0x%016llX) expected=%.17g (0x%016llX) (%s:%d)\n", + note, actual, static_cast(a), expected, + static_cast(b), file, line); + std::fflush(stdout); + } + } + + /** @brief 记录一次宽字符串相等断言。 */ + inline void CheckWideEqual(const std::wstring& actual, const std::wstring& expected, + const char* note, const char* file, int line) + { + ++TotalChecks(); + if (actual != expected) + { + ++FailedChecks(); + CaseFailed() = true; + std::printf(" [FAIL] %s: actual=\"%ls\" expected=\"%ls\" (%s:%d)\n", note, actual.c_str(), + expected.c_str(), file, line); + std::fflush(stdout); + } + } + } // namespace test +} // namespace jdi + +/** @brief 定义一个测试用例(自动注册)。 */ +#define JDI_TEST(case_name) \ + static void case_name(); \ + [[maybe_unused]] static const bool case_name##_registered_ = \ + ::jdi::test::Register(#case_name, &case_name); \ + static void case_name() + +/** @brief 布尔断言。 */ +#define JDI_CHECK(expression) ::jdi::test::Check((expression), #expression, __FILE__, __LINE__) + +/** @brief 带说明的布尔断言(说明为裸字符串)。 */ +#define JDI_REQUIRE(note, expression) \ + ::jdi::test::CheckWithNote((expression), (note), __FILE__, __LINE__) + +/** @brief 整数相等断言。 */ +#define JDI_CHECK_EQ(actual, expected) \ + ::jdi::test::CheckEqual(static_cast(actual), static_cast(expected), \ + #actual " == " #expected, __FILE__, __LINE__) + +/** @brief 浮点近似断言。 */ +#define JDI_CHECK_NEAR(actual, expected, tolerance) \ + ::jdi::test::CheckNear((actual), (expected), (tolerance), #actual " ~ " #expected, __FILE__, __LINE__) + +/** @brief 浮点逐位相等断言(数值语义回归专用)。 */ +#define JDI_CHECK_BIT_EQUAL(actual, expected) \ + ::jdi::test::CheckBitEqual((actual), (expected), #actual " bit== " #expected, __FILE__, __LINE__) + +/** @brief 宽字符串相等断言。 */ +#define JDI_CHECK_WIDE_EQ(actual, expected) \ + ::jdi::test::CheckWideEqual((actual), (expected), #actual " == " #expected, __FILE__, __LINE__) + +#endif // __UNITTESTFRAMEWORK_HPP__ diff --git a/Tools/UnitTests/Src/DataModelTests.cpp b/Tools/UnitTests/Src/DataModelTests.cpp new file mode 100644 index 0000000..66ea0b5 --- /dev/null +++ b/Tools/UnitTests/Src/DataModelTests.cpp @@ -0,0 +1,324 @@ +/** + * @file DataModelTests.cpp + * @brief `Utilities/DataModel` 的纯数据层语义回归(不创建窗口、不碰 GUI)。 + * + * 重点用例: + * * `dataset.formula_transform_cache` —— 第 32 轮引入的「公式绘制值缓存」 + * (`DataSet::FormulaTransformFor`)必须与"逐点 `ApplyDrawTransform` 后统计"**逐位一致**, + * 并且命中缓存时返回同一指针、样式变化后失效重建。 + * 这条是"公式信号不再逐帧全扫"的正确性底线。 + * * `filter.moving_average` —— 多阶等价性(1 阶 == 单次、2 阶 == 链式)。 + * * `transform.*` —— 变换顺序(公式 → 系数 → 绝对值 → 垂直镜像 → Y 偏移)与区间映射。 + */ + +#include +#include +#include +#include +#include +#include + +#include "DataModel.hpp" +#include "UnitTestFramework.hpp" + +namespace +{ + /** @brief 取 double 的位模式。 */ + std::uint64_t BitsOf(const double value) + { + std::uint64_t bits = 0; + __builtin_memcpy(&bits, &value, sizeof(bits)); + return bits; + } + + /** @brief 构造一条合成信号分组(一条单调时间轴 + 一条信号)。 */ + jdi::SignalGroup MakeGroup(const std::vector& values, const wchar_t* name) + { + jdi::SignalGroup group; + group.name = L"synthetic"; + + jdi::TimeAxis axis; + axis.name = L"time"; + axis.time.resize(values.size()); + for (std::size_t index = 0; index < values.size(); ++index) + { + axis.time[index] = static_cast(index) * 0.001; + } + axis.monotonic = true; + axis.minTime = 0.0; + axis.maxTime = values.empty() ? 0.0 : axis.time.back(); + axis.finiteCount = axis.time.size(); + group.axes.push_back(std::move(axis)); + + jdi::SignalData signal; + signal.name = name; + signal.axisIndex = 0; + signal.values = values; + group.signals.push_back(std::move(signal)); + return group; + } +} // namespace + +JDI_TEST(transform_apply_order) +{ + jdi::SignalStyle style; + style.gain = 2.0f; + style.absolute = true; + style.mirrorVertical = true; + style.offsetY = 1.0f; + + // -1.5 → ×2 = -3 → |·| = 3 → 垂直镜像 = -3 → +1 = -2 + JDI_CHECK_NEAR(jdi::ApplyDrawTransform(style, -1.5), -2.0, 0.0); + + // 公式最先结算:f(t)=x*10,再 ×2、|·|、镜像、+1 ⇒ 2.5 → 25 → 50 → 50 → -50 → -49 + style.formula = L"f(t)*10"; + JDI_CHECK_NEAR(jdi::ApplyDrawTransform(style, 2.5), -49.0, 1e-9); + + // NaN 必须保持 NaN(断线语义) + JDI_CHECK(std::isnan(jdi::ApplyDrawTransform(style, std::numeric_limits::quiet_NaN()))); + + // 水平镜像不改变取值(只改下标),`SourceIndexFor` 负责 + jdi::SignalStyle mirrored; + mirrored.mirrorHorizontal = true; + JDI_CHECK_NEAR(jdi::ApplyDrawTransform(mirrored, 7.0), 7.0, 0.0); + JDI_CHECK_EQ(jdi::SourceIndexFor(mirrored, 10, 0), 9); + JDI_CHECK_EQ(jdi::SourceIndexFor(mirrored, 10, 9), 0); +} + +JDI_TEST(transform_value_range_and_order_preserving) +{ + jdi::SignalStyle style; + style.gain = 2.0f; + style.absolute = true; + style.mirrorVertical = true; + style.offsetY = 1.0f; + + double low = -2.0; + double high = 3.0; + jdi::TransformValueRange(style, low, high); + // [-2,3] → ×2 = [-4,6] → |·| = [0,6] → 镜像 = [-6,0] → +1 = [-5,1] + JDI_CHECK_NEAR(low, -5.0, 0.0); + JDI_CHECK_NEAR(high, 1.0, 0.0); + + jdi::SignalStyle plain; + JDI_CHECK(jdi::DrawTransformOrderPreserving(plain)); + plain.gain = -1.0f; + JDI_CHECK(!jdi::DrawTransformOrderPreserving(plain)); + plain.gain = 1.0f; + plain.absolute = true; + JDI_CHECK(!jdi::DrawTransformOrderPreserving(plain)); + plain.absolute = false; + plain.mirrorVertical = true; + JDI_CHECK(!jdi::DrawTransformOrderPreserving(plain)); + plain.mirrorVertical = false; + plain.formula = L"f(t)*f(t)"; + JDI_CHECK(!jdi::DrawTransformOrderPreserving(plain)); + plain.formula = L"f(t)"; + JDI_CHECK(jdi::DrawTransformOrderPreserving(plain)); +} + +JDI_TEST(filter_moving_average_equivalence) +{ + std::vector values; + values.reserve(400); + for (int index = 0; index < 400; ++index) + { + values.push_back(std::sin(static_cast(index) * 0.07) * 100.0 + (index % 7)); + } + values[10] = std::numeric_limits::quiet_NaN(); + values[200] = std::numeric_limits::quiet_NaN(); + + std::vector single; + std::vector singleCopy; + jdi::ComputeMovingAverage(values, 3, single); + + // 1 阶 == 单次平均(逐位) + jdi::ComputeMovingAverageIterated(values, 3, 1, singleCopy); + JDI_CHECK_EQ(single.size(), singleCopy.size()); + bool identical = (single.size() == singleCopy.size()); + for (std::size_t index = 0; identical && index < single.size(); ++index) + { + identical = (BitsOf(single[index]) == BitsOf(singleCopy[index])); + } + JDI_CHECK(identical); + + // 2 阶 == 对 1 阶结果再做一次(逐位) + std::vector firstPass(single.begin(), single.end()); + std::vector chained; + jdi::ComputeMovingAverage(firstPass, 3, chained); + std::vector iterated; + jdi::ComputeMovingAverageIterated(values, 3, 2, iterated); + JDI_CHECK_EQ(chained.size(), iterated.size()); + bool chainIdentical = (chained.size() == iterated.size()); + for (std::size_t index = 0; chainIdentical && index < chained.size(); ++index) + { + chainIdentical = (BitsOf(chained[index]) == BitsOf(iterated[index])); + } + JDI_CHECK(chainIdentical); + + // 常数信号经任意阶数保持不变 + std::vector constant(500, 7.5); + std::vector filtered; + jdi::ComputeMovingAverageIterated(constant, 5, 3, filtered); + bool constantHeld = (filtered.size() == constant.size()); + for (std::size_t index = 0; constantHeld && index < filtered.size(); ++index) + { + constantHeld = (std::fabs(static_cast(filtered[index]) - 7.5) < 1e-5); + } + JDI_CHECK(constantHeld); +} + +JDI_TEST(dataset_group_access) +{ + jdi::DataSet dataSet; + int firstIndex = -1; + JDI_CHECK(dataSet.AddGroup(MakeGroup({1.0, 2.0, 3.0}, L"a"), firstIndex)); + JDI_CHECK_EQ(firstIndex, 0); + JDI_CHECK_EQ(dataSet.GroupCount(), 1); + + const jdi::SignalGroup* group = dataSet.GroupAt(0); + JDI_CHECK(group != nullptr); + if (group != nullptr) + { + JDI_CHECK_EQ(group->signals.size(), 1); + JDI_CHECK_WIDE_EQ(group->signals[0].name, std::wstring(L"a")); + JDI_CHECK_EQ(group->axes.size(), 1); + } + + // 外观属于信号本身:通过 `Style(ref)` 读写 + const jdi::SignalRef ref{0, 0}; + jdi::SignalStyle* style = dataSet.Style(ref); + JDI_CHECK(style != nullptr); + if (style != nullptr) + { + style->lineWidth = 3.5f; + const jdi::SignalStyle* readBack = dataSet.Style(ref); + JDI_CHECK(readBack != nullptr && readBack->lineWidth == 3.5f); + } + JDI_CHECK(dataSet.GroupAt(5) == nullptr); +} + +JDI_TEST(dataset_formula_transform_cache) +{ + std::vector values; + values.reserve(5000); + for (int index = 0; index < 5000; ++index) + { + values.push_back(std::sin(static_cast(index) * 0.013) * 50.0 + (index % 11) * 0.5); + } + values[17] = std::numeric_limits::quiet_NaN(); + values[4999] = std::numeric_limits::quiet_NaN(); + + jdi::DataSet dataSet; + int groupIndex = -1; + JDI_CHECK(dataSet.AddGroup(MakeGroup(values, L"formula"), groupIndex)); + const jdi::SignalGroup* group = dataSet.GroupAt(0); + JDI_CHECK(group != nullptr); + if (group == nullptr) + { + return; + } + const jdi::SignalData& signal = group->signals[0]; + jdi::SignalStyle& style = *dataSet.Style(jdi::SignalRef{0, 0}); + + // 恒等 / 空公式 ⇒ 不建立缓存(调用方走 O(1) 保序快路径) + JDI_CHECK(dataSet.FormulaTransformFor(signal, style) == nullptr); + style.formula = L"f(t)"; + JDI_CHECK(dataSet.FormulaTransformFor(signal, style) == nullptr); + + // 真公式 ⇒ 建立一次缓存 + style.formula = L"f(t)*2"; + style.gain = 1.5f; + style.absolute = true; + style.mirrorVertical = false; + style.offsetY = -3.0f; + + const jdi::DataSet::FormulaTransformCache* entry = dataSet.FormulaTransformFor(signal, style); + JDI_CHECK(entry != nullptr); + if (entry == nullptr) + { + return; + } + JDI_CHECK(entry->ready); + + // 参考实现:逐点走 `ApplyDrawTransform`(同一套语义),按缓存给出的 stride 分组 + double referenceLow = std::numeric_limits::infinity(); + double referenceHigh = -std::numeric_limits::infinity(); + std::vector referenceMin(entry->minimums.size(), std::numeric_limits::quiet_NaN()); + std::vector referenceMax(entry->maximums.size(), std::numeric_limits::quiet_NaN()); + const std::size_t stride = entry->stride; + JDI_CHECK(stride > 0); + for (std::size_t index = 0; index < signal.values.size(); ++index) + { + const double mapped = jdi::ApplyDrawTransform(style, signal.values[index]); + if (!std::isfinite(mapped)) + { + continue; + } + const std::size_t bucket = index / stride; + if (bucket >= referenceMin.size()) + { + continue; + } + if (mapped < referenceMin[bucket] || referenceMin[bucket] != referenceMin[bucket]) + { + referenceMin[bucket] = mapped; + } + if (mapped > referenceMax[bucket] || referenceMax[bucket] != referenceMax[bucket]) + { + referenceMax[bucket] = mapped; + } + referenceLow = std::min(referenceLow, mapped); + referenceHigh = std::max(referenceHigh, mapped); + } + + JDI_CHECK_BIT_EQUAL(entry->low, referenceLow); + JDI_CHECK_BIT_EQUAL(entry->high, referenceHigh); + JDI_CHECK_EQ(entry->minimums.size(), referenceMin.size()); + + bool bucketsMatch = (entry->minimums.size() == referenceMin.size()); + for (std::size_t bucket = 0; bucketsMatch && bucket < referenceMin.size(); ++bucket) + { + const bool emptyReference = (referenceMin[bucket] != referenceMin[bucket]); + const bool emptyEntry = (entry->minimums[bucket] != entry->minimums[bucket]); + if (emptyReference != emptyEntry) + { + bucketsMatch = false; + break; + } + if (emptyReference) + { + continue; + } + bucketsMatch = BitsOf(entry->minimums[bucket]) == BitsOf(referenceMin[bucket]) && + BitsOf(entry->maximums[bucket]) == BitsOf(referenceMax[bucket]); + } + JDI_CHECK(bucketsMatch); + + // 命中缓存:同一信号 + 同一样式 ⇒ 返回同一指针(不再重扫) + const jdi::DataSet::FormulaTransformCache* again = dataSet.FormulaTransformFor(signal, style); + JDI_CHECK(again == entry); + + // 样式变化 ⇒ 失效重建,且新值域按新公式计算 + style.formula = L"f(t)*3"; + const jdi::DataSet::FormulaTransformCache* rebuilt = dataSet.FormulaTransformFor(signal, style); + JDI_CHECK(rebuilt != nullptr); + if (rebuilt != nullptr) + { + JDI_CHECK(rebuilt != entry); + double expectedLow = std::numeric_limits::infinity(); + double expectedHigh = -std::numeric_limits::infinity(); + for (const double value : signal.values) + { + const double mapped = jdi::ApplyDrawTransform(style, value); + if (!std::isfinite(mapped)) + { + continue; + } + expectedLow = std::min(expectedLow, mapped); + expectedHigh = std::max(expectedHigh, mapped); + } + JDI_CHECK_BIT_EQUAL(rebuilt->low, expectedLow); + JDI_CHECK_BIT_EQUAL(rebuilt->high, expectedHigh); + } +} diff --git a/Tools/UnitTests/Src/FormulaEvalTests.cpp b/Tools/UnitTests/Src/FormulaEvalTests.cpp new file mode 100644 index 0000000..6b0cc68 --- /dev/null +++ b/Tools/UnitTests/Src/FormulaEvalTests.cpp @@ -0,0 +1,147 @@ +/** + * @file FormulaEvalTests.cpp + * @brief `Utilities/FormulaEval` 的语法 / 语义回归。 + * + * 重点用例: + * * `program.matches_pointwise` —— 批量求值(`FormulaProgram`)与逐点求值 + * (`EvaluateFormula`)必须**逐位相同**。第 32 轮的性能优化(常量折叠、幂次改写、 + * `always_inline`)只允许改速度,任何一位差异都是回归。 + * * 语法边界:`^` 右结合、`-2^2 = -4`、单独的 `f` 非法、`2e` 非法。 + */ + +#include +#include +#include +#include + +#include "DataModel.hpp" +#include "FormulaEval.hpp" +#include "UnitTestFramework.hpp" + +namespace +{ + /** @brief 取 double 的位模式。 */ + std::uint64_t BitsOf(const double value) + { + std::uint64_t bits = 0; + __builtin_memcpy(&bits, &value, sizeof(bits)); + return bits; + } +} // namespace + +JDI_TEST(formula_syntax_acceptance) +{ + std::wstring error; + + // 合法写法 + static const wchar_t* const valid[] = { + L"f(t)", L"f(t)*2+1", L"f(t)^2", L"-f(t)", L"e*pi", L"2^-1", L"X", L"x", + L"(f(t)-1)/(f(t)+1)", L"2*pi*f(t)", + }; + for (const wchar_t* const text : valid) + { + JDI_REQUIRE("valid formula", jdi::ValidateFormula(text, &error)); + } + + // 非法写法 + static const wchar_t* const invalid[] = { + L"f", L"f(", L"f(t)+", L"f(t)*", L"()", L"2e", L"f(t) ** 2", L"*2", + }; + for (const wchar_t* const text : invalid) + { + const bool accepted = jdi::ValidateFormula(text, &error); + if (accepted) + { + std::printf(" [INFO] unexpectedly accepted: \"%ls\"\n", text); + } + JDI_REQUIRE("invalid formula must be rejected", !accepted); + } + + // 空串 = 不映射(合法) + JDI_CHECK(jdi::ValidateFormula(L"", &error)); +} + +JDI_TEST(formula_semantics) +{ + // `^` 右结合:2^3^2 = 2^(3^2) = 512 + JDI_CHECK_NEAR(jdi::EvaluateFormula(L"2^3^2", 0.0), 512.0, 1e-9); + // 一元负号优先于幂:-2^2 = -(2^2) = -4 + JDI_CHECK_NEAR(jdi::EvaluateFormula(L"-2^2", 0.0), -4.0, 1e-12); + // 指数的负号:2^-1 = 0.5 + JDI_CHECK_NEAR(jdi::EvaluateFormula(L"2^-1", 0.0), 0.5, 1e-12); + // 常量 e / pi(大小写均可) + JDI_CHECK_NEAR(jdi::EvaluateFormula(L"e", 0.0), 2.718281828459045, 1e-15); + JDI_CHECK_NEAR(jdi::EvaluateFormula(L"pi", 0.0), 3.141592653589793, 1e-15); + JDI_CHECK_NEAR(jdi::EvaluateFormula(L"PI", 0.0), 3.141592653589793, 1e-15); + // 恒等写法 + JDI_CHECK_NEAR(jdi::EvaluateFormula(L"f(t)", 3.25), 3.25, 0.0); + + // 真实使用过的表达式(第 32 轮用户输入):f(t)*2/1.25+14.2,f(2) = 3.2 + 14.2 + JDI_CHECK_NEAR(jdi::EvaluateFormula(L"f(t)*2/1.25+14.2", 2.0), 17.4, 1e-12); + + // 幂次改写的等价性(实现会把 x^2/x^3/x^0.5 换成乘法/sqrt) + static const double points[] = {-3.5, -1.0, -0.25, 0.0, 0.5, 1.0, 2.0, 7.25, 1.0e6}; + for (const double point : points) + { + JDI_CHECK_BIT_EQUAL(jdi::EvaluateFormula(L"f(t)^2", point), point * point); + JDI_CHECK_NEAR(jdi::EvaluateFormula(L"f(t)^0.5", point < 0.0 ? -point : point), + std::sqrt(point < 0.0 ? -point : point), 0.0); + } +} + +JDI_TEST(formula_identity_helpers) +{ + // `f(t)` 与空串等价(第 32 轮硬规则:点进输入框再点出去不得写入"恒等公式") + JDI_CHECK(jdi::FormulaIsIdentity(L"")); + JDI_CHECK(jdi::FormulaIsIdentity(L"f(t)")); + JDI_CHECK(!jdi::FormulaIsIdentity(L"f(t)*2")); + + jdi::SignalStyle plain; + JDI_CHECK(!plain.HasFormula()); + plain.formula = L"f(t)"; + JDI_CHECK(!plain.HasFormula()); + plain.formula = L"f(t)*2"; + JDI_CHECK(plain.HasFormula()); + + // 归一化:`X` / `F(T)` 统一写成 `f(t)`;单独的 `f` **不**归一化(非法写法,交给校验报错) + JDI_CHECK_WIDE_EQ(jdi::NormalizeFormulaText(L"X"), std::wstring(L"f(t)")); + JDI_CHECK_WIDE_EQ(jdi::NormalizeFormulaText(L"F(T)"), std::wstring(L"f(t)")); + JDI_CHECK_WIDE_EQ(jdi::NormalizeFormulaText(L"f"), std::wstring(L"f")); +} + +JDI_TEST(formula_program_matches_pointwise) +{ + static const wchar_t* const formulas[] = { + L"f(t)*2/1.25+14.2", L"f(t)^2", L"f(t)^3", L"(f(t)-1)/(f(t)+1)", + L"2*pi*f(t)+e", L"-f(t)*0.5", L"f(t)^0.5", L"f(t)-2", + }; + + std::mt19937 rng(20261105u); + std::uniform_real_distribution distribution(-500.0, 500.0); + + for (const wchar_t* const formula : formulas) + { + jdi::FormulaProgram program(formula); + JDI_REQUIRE("program must compile", program.Valid()); + for (int iteration = 0; iteration < 4000; ++iteration) + { + const double x = distribution(rng); + const double pointwise = jdi::EvaluateFormula(formula, x); + const double batched = program.Evaluate(x); + if (BitsOf(pointwise) != BitsOf(batched)) + { + std::printf(" [INFO] %ls x=%.17g pointwise=%.17g batched=%.17g\n", formula, x, pointwise, + batched); + JDI_CHECK_BIT_EQUAL(batched, pointwise); + break; + } + } + } +} + +JDI_TEST(formula_invalid_falls_back_safely) +{ + // 非法表达式:求值必须**原样返回采样值**(波形照常显示,不会因为写错公式变空) + JDI_CHECK_NEAR(jdi::EvaluateFormula(L"f(t)@@@@", 1.5), 1.5, 0.0); + JDI_CHECK_NEAR(jdi::EvaluateFormula(L"f(t)@@@@", -2.25), -2.25, 0.0); +} diff --git a/Tools/UnitTests/Src/Main.cpp b/Tools/UnitTests/Src/Main.cpp new file mode 100644 index 0000000..61e79b7 --- /dev/null +++ b/Tools/UnitTests/Src/Main.cpp @@ -0,0 +1,174 @@ +/** + * @file Main.cpp + * @brief 单元测试执行器(控制台程序,无窗口、无 GUI 初始化)。 + * + * 命令行(全部可选): + * -list 只列出用例名,不执行 + * -only 只执行名字包含 的用例 + * -report 报告文件路径(默认 `../Temp/tools/UnitTests-report.txt`) + * -verbose 打印每个用例的开始/结束 + * + * 退出码:0 = 全部通过;1 = 有失败;2 = 参数错误 / 没有任何用例。 + * + * ⚠️ 本工具**不创建窗口、不初始化 GUI、不读用户输入**,可在后台任意次运行。 + * ⚠️ 报告与截图类临时产物一律写到 `Temp/`(已被 .gitignore 忽略),不要写进源码目录。 + */ + +#include +#include +#include +#include +#include + +#include "UnitTestFramework.hpp" + +namespace +{ + std::FILE* g_report = nullptr; + bool g_verbose = false; + + /** @brief 同时输出到标准输出与报告文件。 */ + void Emit(const char* format, ...) + { + char buffer[4096]; + va_list arguments; + va_start(arguments, format); + const int length = std::vsnprintf(buffer, sizeof(buffer), format, arguments); + va_end(arguments); + + if (length > 0) + { + std::fputs(buffer, stdout); + std::fflush(stdout); + if (g_report != nullptr) + { + std::fputs(buffer, g_report); + } + } + } + + /** @brief 递归创建目录(仅支持 ASCII 路径;失败静默忽略)。 */ + void EnsureDirectory(const std::wstring& path) + { + if (path.empty()) + { + return; + } + for (std::size_t index = 1; index <= path.size(); ++index) + { + if (index == path.size() || path[index] == L'/' || path[index] == L'\\') + { + const std::wstring prefix = path.substr(0, index); + CreateDirectoryW(prefix.c_str(), nullptr); + } + } + } + + /** @brief 从 UTF-8 窄字符串转换为宽字符串(只用于路径)。 */ + std::wstring Widen(const char* text) + { + const int length = MultiByteToWideChar(CP_ACP, 0, text, -1, nullptr, 0); + if (length <= 1) + { + return std::wstring(); + } + std::wstring result(static_cast(length - 1), L'\0'); + MultiByteToWideChar(CP_ACP, 0, text, -1, &result[0], length); + return result; + } + + /** @brief 打开报告文件(顺带创建 `Temp/tools`)。 */ + std::FILE* OpenReport(const char* path) + { + std::string wide = path; // 仅 ASCII,用于切分目录 + const std::size_t slash = wide.find_last_of("/\\"); + if (slash != std::string::npos) + { + EnsureDirectory(Widen(wide.substr(0, slash).c_str())); + } + return std::fopen(path, "wb"); + } +} // namespace + +int main(int argc, char** argv) +{ + const char* only = nullptr; + const char* reportPath = "../Temp/tools/UnitTests-report.txt"; + + for (int index = 1; index < argc; ++index) + { + const char* argument = argv[index]; + if (std::strcmp(argument, "-list") == 0) + { + for (const jdi::test::TestCase& testCase : jdi::test::Registry()) + { + std::printf("%s\n", testCase.name); + } + return 0; + } + if (std::strcmp(argument, "-only") == 0 && index + 1 < argc) + { + only = argv[++index]; + continue; + } + if (std::strcmp(argument, "-report") == 0 && index + 1 < argc) + { + reportPath = argv[++index]; + continue; + } + if (std::strcmp(argument, "-verbose") == 0) + { + g_verbose = true; + continue; + } + std::printf("unknown argument: %s\n", argument); + return 2; + } + + g_report = OpenReport(reportPath); + + Emit("==============================================================\n"); + Emit(" Just DataInspector - unit tests\n"); + Emit(" report: %s\n", reportPath); + Emit("==============================================================\n"); + + int executed = 0; + for (const jdi::test::TestCase& testCase : jdi::test::Registry()) + { + if (only != nullptr && std::strstr(testCase.name, only) == nullptr) + { + continue; + } + ++executed; + const int failedBefore = jdi::test::FailedChecks(); + jdi::test::CaseFailed() = false; + Emit("[RUN ] %s\n", testCase.name); + testCase.function(); + const int failed = jdi::test::FailedChecks() - failedBefore; + if (failed == 0) + { + if (g_verbose) + { + Emit("[ OK ] %s\n", testCase.name); + } + } + } + + Emit("--------------------------------------------------------------\n"); + Emit("SUMMARY checks=%d failed=%d cases=%d result=%s\n", jdi::test::TotalChecks(), + jdi::test::FailedChecks(), executed, + (jdi::test::FailedChecks() == 0 && executed > 0) ? "PASS" : "FAIL"); + Emit("--------------------------------------------------------------\n"); + + if (g_report != nullptr) + { + std::fclose(g_report); + g_report = nullptr; + } + + if (executed == 0) + { + return 2; + } + return (jdi::test::FailedChecks() == 0) ? 0 : 1; +} diff --git a/Tools/UnitTests/Src/Utf8UtilTests.cpp b/Tools/UnitTests/Src/Utf8UtilTests.cpp new file mode 100644 index 0000000..a97ee71 --- /dev/null +++ b/Tools/UnitTests/Src/Utf8UtilTests.cpp @@ -0,0 +1,306 @@ +/** + * @file Utf8UtilTests.cpp + * @brief `Utilities/Utf8Util` 的数值解析 / 文本格式化语义回归。 + * + * 重点用例: + * * `parse.bit_exact_vs_strtod` —— **逐位**对比 `TryParseDouble` 与 libc `strtod`。 + * 这是"自写 strtod 替身"类体积优化唯一可信的验收标准(见 Code Review 报告 §10.4): + * 常见写法(≤15 位有效数字、|十进制指数| ≤ 22)必须逐位相同; + * 17 位以上的长数字允许 ≤1 ULP 偏差(实现里已注释该取舍)。 + * * `format.*` —— 只断言文档里给出的实例与"绝不出现科学计数法"这类硬约束, + * 避免把测试绑死在内部格式细节上。 + */ + +#include +#include +#include +#include +#include +#include +#include + +#include "UnitTestFramework.hpp" +#include "Utf8Util.hpp" + +namespace +{ + /** @brief 取 double 的位模式。 */ + std::uint64_t BitsOf(const double value) + { + std::uint64_t bits = 0; + std::memcpy(&bits, &value, sizeof(bits)); + return bits; + } + + /** @brief 两个 double 之间相差多少个 ULP(异号时返回一个很大的数)。 */ + long long UlpDifference(const double left, const double right) + { + if (left == right) + { + return 0; + } + const std::uint64_t a = BitsOf(left); + const std::uint64_t b = BitsOf(right); + if ((a >> 63) != (b >> 63)) + { + return 1000000; + } + return (a > b) ? static_cast(a - b) : -static_cast(b - a); + } + + /** @brief 用 `TryParseDouble` 解析整段文本。 */ + bool Parse(const std::string& text, double& value) + { + return jdi::TryParseDouble(text.c_str(), text.c_str() + text.size(), value); + } + + /** @brief 统计十进制有效数字位数(忽略符号/小数点/前导零/指数部分)。 */ + int SignificantDigits(const std::string& text) + { + int digits = 0; + bool sawNonZero = false; + for (const char character : text) + { + if (character == 'e' || character == 'E') + { + break; + } + if (character < '0' || character > '9') + { + continue; + } + if (character != '0') + { + sawNonZero = true; + } + if (sawNonZero) + { + ++digits; + } + } + return digits; + } + + /** @brief 生成 `数字.数字e指数` 形式的随机十进制文本。 */ + std::string MakeRandomNumber(std::mt19937& rng, const int maxDigits, const bool allowFraction, + const bool allowExponent, const int maxExponent) + { + std::string text; + if ((rng() & 1u) != 0u) + { + text.push_back('-'); + } + const int integerDigits = 1 + static_cast(rng() % static_cast(maxDigits)); + for (int index = 0; index < integerDigits; ++index) + { + text.push_back(static_cast('0' + (rng() % 10u))); + } + if (allowFraction && (rng() % 3u) != 0u) + { + text.push_back('.'); + const int fractionDigits = 1 + static_cast(rng() % static_cast(maxDigits)); + for (int index = 0; index < fractionDigits; ++index) + { + text.push_back(static_cast('0' + (rng() % 10u))); + } + } + if (allowExponent && (rng() % 2u) == 0u) + { + text.push_back((rng() & 1u) != 0u ? 'e' : 'E'); + const bool negativeExponent = (rng() & 1u) != 0u; + text.push_back(negativeExponent ? '-' : '+'); + const int exponent = static_cast(rng() % static_cast(maxExponent + 1)); + const std::string digits = std::to_string(exponent); + text += digits; + } + return text; + } +} // namespace + +JDI_TEST(parse_fixed_corpus_matches_strtod) +{ + static const char* const corpus[] = { + "0", "1", "-1", "+1", "0.1", "-0.1", "1.5", + "123.456", "-123.456", "1e10", "1e-10", "1E+5", "1e22", "1e-22", + "3.141592653589793", "2.718281828459045", "6.02214076e23", "1.602176634e-19", "0.000001", + "1000000", "-0", "0.0", "123456789012345", + }; + + for (const char* const text : corpus) + { + double ours = 0.0; + const bool accepted = Parse(text, ours); + + char* stop = nullptr; + const double reference = std::strtod(text, &stop); + const bool referenceAccepted = (stop != text); + + JDI_REQUIRE(text, accepted == referenceAccepted); + if (accepted && referenceAccepted) + { + JDI_CHECK_BIT_EQUAL(ours, reference); + } + } +} + +JDI_TEST(parse_acceptance_semantics) +{ + double value = 0.0; + JDI_CHECK(!Parse("", value)); + JDI_CHECK(!Parse(" ", value)); + JDI_CHECK(!Parse("abc", value)); + JDI_CHECK(!Parse("e5", value)); + + // 尾部杂字符:与 `strtod` 一致,接受前缀数值(历史语义,不要改) + JDI_CHECK(Parse("12abc", value)); + JDI_CHECK_NEAR(value, 12.0, 0.0); + JDI_CHECK(Parse("1,5", value)); + JDI_CHECK_NEAR(value, 1.0, 0.0); + + // 首尾空白应被忽略 + JDI_CHECK(Parse(" -2.5\t\r\n", value)); + JDI_CHECK_NEAR(value, -2.5, 0.0); + + // 十六进制浮点 / inf / nan 走标准库回退路径,语义必须与 strtod 相同 + static const char* const exotic[] = {"0x1p3", "inf", "-inf", "nan", "infinity", "1e9999", "1e-9999"}; + for (const char* const text : exotic) + { + double ours = 0.0; + char* stop = nullptr; + const double reference = std::strtod(text, &stop); + const bool referenceAccepted = (stop != text); + JDI_REQUIRE(text, Parse(text, ours) == referenceAccepted); + if (referenceAccepted) + { + JDI_CHECK_BIT_EQUAL(ours, reference); + } + } +} + +JDI_TEST(parse_random_corpus_bit_exact) +{ + std::mt19937 rng(20261104u); + int mismatches = 0; + int beyondOneUlp = 0; + int cases = 0; + + // 常见写法:≤15 位有效数字、|指数| ≤ 22 ⇒ 必须逐位相同。 + for (int iteration = 0; iteration < 40000; ++iteration) + { + const std::string text = MakeRandomNumber(rng, 6, true, true, 22); + double ours = 0.0; + char* stop = nullptr; + const double reference = std::strtod(text.c_str(), &stop); + if (stop == text.c_str()) + { + continue; + } + ++cases; + if (!Parse(text, ours)) + { + ++mismatches; + continue; + } + if (BitsOf(ours) != BitsOf(reference)) + { + ++mismatches; + if (SignificantDigits(text) <= 15 && std::fabs(reference) > 1e-300 && + std::fabs(reference) < 1e300) + { + std::printf(" [INFO] bit mismatch on short literal: %s ours=%.17g ref=%.17g\n", text.c_str(), + ours, reference); + } + } + } + + JDI_CHECK_EQ(cases, 40000); + JDI_CHECK_EQ(mismatches, 0); + + // 长数字(最多 19 位):允许 ≤1 ULP 偏差(实现里有明确注释与容差约定)。 + for (int iteration = 0; iteration < 20000; ++iteration) + { + const std::string text = MakeRandomNumber(rng, 19, true, true, 22); + double ours = 0.0; + char* stop = nullptr; + const double reference = std::strtod(text.c_str(), &stop); + if (stop == text.c_str() || !Parse(text, ours)) + { + continue; + } + if (UlpDifference(ours, reference) > 1) + { + ++beyondOneUlp; + } + } + JDI_CHECK_EQ(beyondOneUlp, 0); +} + +JDI_TEST(format_decimal_never_scientific) +{ + JDI_CHECK_WIDE_EQ(jdi::FormatDecimal(1.0e-5), std::wstring(L"0.00001")); + JDI_CHECK_WIDE_EQ(jdi::FormatDecimal(0.0), std::wstring(L"0")); + JDI_CHECK_WIDE_EQ(jdi::FormatDecimalFixed(700.0, 3), std::wstring(L"700.000")); + + static const double values[] = {1.0e-9, 1.0e-6, 1.0e-5, 0.000123456, 1.0, 1234.5678, 1.0e9}; + for (const double value : values) + { + const std::wstring text = jdi::FormatDecimal(value); + JDI_REQUIRE("FormatDecimal must not use exponent", text.find(L'e') == std::wstring::npos && + text.find(L'E') == std::wstring::npos); + } + + // 文档实例:`FormatValue` = `%.6g` + JDI_CHECK_WIDE_EQ(jdi::FormatValue(123.456789), std::wstring(L"123.457")); + JDI_CHECK_WIDE_EQ(jdi::FormatValue(1.0), std::wstring(L"1")); +} + +JDI_TEST(format_byte_size_and_frequency) +{ + const std::wstring bytes = jdi::FormatByteSize(1024ull * 1024ull); + JDI_REQUIRE("FormatByteSize(1MB) contains MB", bytes.find(L"MB") != std::wstring::npos); + + const std::wstring hertz = jdi::FormatFrequency(270.5166); + JDI_REQUIRE("FormatFrequency contains unit", hertz.find(L"Hz") != std::wstring::npos); + JDI_REQUIRE("FormatFrequency has no exponent", hertz.find(L'e') == std::wstring::npos); + + const std::wstring duration = jdi::FormatDurationCompound(5.06); + JDI_REQUIRE("FormatDurationCompound has no exponent", duration.find(L'e') == std::wstring::npos); + JDI_REQUIRE("FormatDurationCompound ends with unit", duration.find(L's') != std::wstring::npos); +} + +JDI_TEST(text_trim_and_utf8_conversion) +{ + JDI_CHECK_WIDE_EQ(jdi::Trim(L" abc \t"), std::wstring(L"abc")); + JDI_CHECK_WIDE_EQ(jdi::Trim(L" \t\r\n "), std::wstring()); + JDI_CHECK_WIDE_EQ(jdi::ToLowerAscii(L"AbC-129"), std::wstring(L"abc-129")); + + const std::wstring wide = jdi::Utf8ToWide("abc"); + JDI_CHECK_WIDE_EQ(wide, std::wstring(L"abc")); + + JDI_CHECK(jdi::ContainsInsensitive(L"HelloWorld", L"world")); + JDI_CHECK(!jdi::ContainsInsensitive(L"HelloWorld", L"xyz")); +} + +JDI_TEST(ticks_nice_step_and_build_ticks) +{ + // `NiceStep` 必须落在 1/2/2.5/5 × 10^n 这类"好读"的格点上 + static const double ranges[] = {1.0, 10.0, 123.0, 0.0003, 98765.0}; + for (const double range : ranges) + { + const double step = jdi::NiceStep(range, 10); + JDI_REQUIRE("NiceStep > 0", step > 0.0); + const double ratio = step / std::pow(10.0, std::floor(std::log10(step))); + const bool nice = std::fabs(ratio - 1.0) < 1e-9 || std::fabs(ratio - 2.0) < 1e-9 || + std::fabs(ratio - 2.5) < 1e-9 || std::fabs(ratio - 5.0) < 1e-9; + JDI_REQUIRE("NiceStep is a round number", nice); + } + + std::vector ticks; + jdi::BuildTicks(0.0, 10.0, 1.0, ticks); + JDI_CHECK_EQ(ticks.size(), 11); + if (ticks.size() == 11) + { + JDI_CHECK_NEAR(ticks.front(), 0.0, 0.0); + JDI_CHECK_NEAR(ticks.back(), 10.0, 0.0); + } +} -- 2.54.0 From 5c48bf922e960130c14f00aa36f9b5925eeb38ee Mon Sep 17 00:00:00 2001 From: "DeepSeek V4.1 Flash" <> Date: Wed, 4 Nov 2026 11:52:10 +0800 Subject: [PATCH 10/22] =?UTF-8?q?review(follow-ups):=20=E8=90=BD=E5=9C=B0?= =?UTF-8?q?=E4=B8=8A=E8=BD=AE=E5=BB=BA=E8=AE=AE=E5=B9=B6=E7=BB=99=E5=87=BA?= =?UTF-8?q?=E5=AE=9E=E6=B5=8B=E7=BB=93=E8=AE=BA=EF=BC=88#2/#4=20=E5=9B=9E?= =?UTF-8?q?=E9=80=80=E3=80=81#3=20=E4=B8=8D=E9=87=8D=E5=86=99=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 一、Tools 完善 * OffscreenChecks `edgestats`:改为自动挑"最有起伏"的信号。旧实现固定取 `signals[0]`,而 MF4 的第一条信号是常量(6000)⇒ 三个阈值的断言退化成 "0 == 0" 的空转。现在扫描前 200 条挑 `max-min` 最大者,SCATTER_TEST 上 三个阈值分别得到 (20,19)/(20,19)/(20,19),与独立参考实现逐位相同。 * UnitTests 新增 `stdio_mingw_vs_ucrt_formats`:把 MinGW ANSI stdio 与系统 UCRT 的 printf 在**本程序实际用到的全部格式**(`%.*f` / `%.6g` / `%g` / `%.4e` / `%.2f %ls` / `%llu` / `%lld` / `%02X` / `%.2f%%`)上做逐字符比对, 13.6 万次调用 **0 处差异**(覆盖 1e-40~1e40)。它是"把 swprintf 让给 UCRT" 这条体积优化的验收标准。 * Tools/README.md:同步新增用例与断言数量(219 项 / 18 用例)。 二、第 33 轮报告 §10 后续建议的结论(详细数字见 Progress 第 34 轮) 1. **`EdgeScanFor` 状态查找哈希化:实测后回退。** 实现方式为 `deque` + `unordered_multimap`(桶内仍 逐字段比对,碰撞安全;新建/淘汰/定向作废/清空四处同步索引),功能与像素 验证全绿。但 198 个状态的整轮查询只从 0.097 降到 0.073 us/query(一轮省 ≈4.8 us),而产物体积 +5,632 B。⇒ 回退。 同时修正报告的量级:原估"288 信号 ≈0.3ms/帧",实测 ≈20us/帧。 2. **`EdgePanelLayoutFor` 表驱动化:不做。** 实测 1.9~2.5 us/次(每帧约 14 次 ≈30us),不是热点;第 33 轮已消除"按种类分派"的重复,剩余 957 行是几何算式 本身,无法表驱动。本轮改为交付"让这类重写可验证"的手段: `-run panels -mode record/compare` 逐面板比对几何 + 像素哈希。 3. **`wcstod` 自写替身:实测 0 字节收益。** 只加 `-D__USE_MINGW_ANSI_STDIO=0` 全量重编后产物仍是 788,480 B、`mingw_wpformat.o` 仍被链接。原因是预编译 libstdc++ 自己引用 `__mingw_swprintf`:只要还调用 `std::to_wstring` / `std::to_string`(全仓约 80 处,若干处实参是浮点), gdtoa 必然被链入。⇒ 回退,并在 Makefile 留下说明防止重复尝试。 三、文档 * `Documents/Code_Review_Plan.md` 新增 §六(第 34 轮节点与验收标准)。 * `Documents/Code_Review_Progress.md` 新增第 34 轮逐节点记录(含全部实测数字)。 * `Documents/Code_Review_Report.md` 新增 §11(结论表 + 两处量级修正 + 更新的后续建议)。 * `Documents/agent_mem.md` 新增第 34 轮规则:Tools 约定、两个构建陷阱、 两条"别再重复做"的实验结论。 验证: * `make release`(删 .o 全量)零 error / 零 warning,产物 **788,480 B**(与改动前一致)。 * `make -C Tools` 零 error / 零 warning。 * `UnitTests.exe` → checks=219 failed=0 cases=18。 * `OffscreenChecks.exe -all` → 5/5 检查 PASS,checks=71 failed=0。 --- Documents/Code_Review_Plan.md | 29 ++++++ Documents/Code_Review_Progress.md | 93 ++++++++++++++++++ Documents/Code_Review_Report.md | 68 ++++++++++++++ Documents/agent_mem.md | 61 ++++++++++++ Makefile | 12 +++ Tools/OffscreenChecks/Src/Checks.cpp | 24 ++++- Tools/README.md | 3 +- Tools/UnitTests/Src/Utf8UtilTests.cpp | 130 ++++++++++++++++++++++++++ 8 files changed, 418 insertions(+), 2 deletions(-) diff --git a/Documents/Code_Review_Plan.md b/Documents/Code_Review_Plan.md index af82755..b2023f2 100644 --- a/Documents/Code_Review_Plan.md +++ b/Documents/Code_Review_Plan.md @@ -174,3 +174,32 @@ 进度与每节点的实测数字见 `Documents/Code_Review_Progress.md`。 最终结论见 `Documents/Code_Review_Report.md`。 + +--- + +## 六、第 34 轮追加(2026-11-04):验证基础设施 + 后续建议落地 + +> 触发:§三/§10 之后剩下的三项建议(`EdgeScanFor` 哈希化、`EdgePanelLayoutFor` 表驱动化、 +> `wcstod` 自写替身)都卡在**"没有可自证的验证手段"**上。 +> 用户要求:先把验证工具做成仓库内的正式资产(`Tools/` 入库),再动被建议的代码。 + +### 6.1 节点与验收标准 + +| 节点 | 内容 | 验收标准 | +| --- | --- | --- | +| **T1** | `Tools/` 解除忽略;新增 `Tools/Makefile`;两个工具目录 + `README.md` | `make -C Tools` 构建全部、`make UnitTests` 构建单个、`make clean` 只清工具产物;改头文件/增删源文件自动重编重链 | +| **T2** | `Tools/UnitTests`:数据层语义单元测试 | `result=PASS`,断言数 ≥ 200 | +| **T3** | `Tools/OffscreenChecks`:隐藏窗口渲染 + 帧序列 + 面板像素基线 + 边沿统计参考实现 + 性能 | 零失败;全程不显示窗口、不动真实鼠标/键盘 | +| **T4** | 建议 #2:`EdgeScanFor` 状态查找哈希化 | **先量后改**;性能收益必须大于体积代价,否则回退 | +| **T5** | 建议 #3:`EdgePanelLayoutFor` 表驱动化 | 面板像素基线 A/B **逐像素一致**才允许合入 | +| **T6** | 建议 #4:`wcstod` 自写替身(省 gdtoa 链) | 体积收益可测且数值语义逐位一致 | + +### 6.2 本轮新增的硬约束 + +1. **工具不得把产物写进 `Tools/`**:截图 / 基线 / 报告一律写 `Temp/tools/`(已被忽略); + `Tools/*/Build/` 同样不入库(其它协作者本地 `make -C Tools` 重建)。 +2. **工具与本体 ABI 必须一致**:`-fno-exceptions`、`-fno-rtti`、链接时 `-flto -flto-partition=one`; + 工具源码自身**不**编成 LTO 字节码(GCC 15.2.0 会在 `choose_baseaddr` 处 ICE)。 +3. **"先量后改"**:任何以性能 / 体积为名的改动,必须在同一台机器、同一次会话内给出前后数字; + 收益低于噪声、或换来体积上涨的,一律回退,并把数字写进 `Code_Review_Report.md`。 +4. **像素基线只做本地 A/B**:哈希与 DPI/字体/数据文件绑定,**不入库、不当黄金图**。 diff --git a/Documents/Code_Review_Progress.md b/Documents/Code_Review_Progress.md index 064a4f1..9ee57a4 100644 --- a/Documents/Code_Review_Progress.md +++ b/Documents/Code_Review_Progress.md @@ -145,6 +145,99 @@ Makefile 按目录通配收集源文件,**无需登记新文件**。 因此不再拆分。 * **性能项**:未发现新的可复现热点;已记录 3 个"还能再快一点"的点(见报告 §6.2)。 +--- +## 第 34 轮(2026-11-04):验证基础设施 + §10 后续建议落地 +### T1 验证工具入库 —— ✅ 完成(提交 `97e9163`) +**改动** + +1. `.gitignore`:删除整目录忽略的 `Tools/`,改为 `Tools/**/Build/`(只忽略各工具产物)。 +2. 新增 `Tools/Makefile`:`make` / `make UnitTests` / `make OffscreenChecks` / `make clean` / `make help`; + 工具链接本体 `../Build/Release/*.o`(排除 `Main.o`),`-MMD -MP` 跟踪头文件依赖。 +3. 新增 `Tools/UnitTests`(`Inc` + `Src` + `Build`)与 `Tools/OffscreenChecks`(同构)与 `Tools/README.md`。 + +**踩到并修掉的两个构建陷阱** + +* 工具源码也加 `-flto` 会让 GCC 15.2.0 在链接阶段 `choose_baseaddr`(i386.cc:7447)**ICE** + ⇒ 工具源码不带 `-flto`,只在链接参数里带 `-flto -flto-partition=one`。 +* `$(foreach)` 生成的工具目标早于 `all` 出现 ⇒ make 把**第一个**显式目标当默认目标, + 直接 `make` 只会构建 `UnitTests` ⇒ 显式 `.DEFAULT_GOAL := all`。 + +**实测**:`make -C Tools` 零 warning;两个产物分别在 `Tools//Build/.exe`。 + +### T2 单元测试 —— ✅ 完成(219 条断言 / 18 个用例) + +覆盖:`TryParseDouble` 与 libc `strtod` **逐位一致**(6 万条随机十进制串)、 +输入接受性(尾随字符 / 十六进制浮点 / `inf` / `nan`)、`FormatDecimal*` 永不科学计数法、 +`NiceStep` / `BuildTicks`、公式语法与语义、`FormulaProgram` 与 `EvaluateFormula` 逐位一致、 +`ApplyDrawTransform` 顺序、`TransformValueRange`、滑动平均多阶等价性、 +`DataSet::FormulaTransformFor` 值域与分组极值(逐点参考实现比对、命中同指针、样式变化失效)、 +以及新增的 `stdio_mingw_vs_ucrt_formats`(见 T6)。 + +**过程中修正了 5 处"我以为的语义"**(都以源码为准,测试跟着产品改): +`9.999999999999999` 属 >15 位有效数字(允许 1 ULP);`1..2` 其实合法; +`FormulaIsIdentity` 不去空格;非法公式求值时 `EvaluateFormula` **原样返回采样值**(不是 NaN)。 + +### T3 离屏验证 —— ✅ 完成(5 个检查 / 71 条断言) + +* `smoke`:初始化 + 加载 + 渲染 + 落盘;**同一状态两帧逐像素相同**(实测差异 0 像素)。 +* `frames`:连续帧抓取(PNG + 区域 FNV 哈希 + 耗时),帧序列至少出现一次可见变化。 +* `panels`:7 个面板全部展开后的几何不变式(必须被绘图区夹住)+ 像素基线 `-mode record/compare`。 +* `edgestats`:边沿统计与工具内**独立朴素实现**逐位比对(阈值 2.4875/4.975/7.4625 → + 20/19、20/19、20/19 完全一致;中心轴 1200/1179 也能完成)。 +* `perf`:整帧中位数、`EdgePanelLayoutFor` 单次、198 个统计状态的整轮查询耗时。 + +**全程离屏**:窗口 `SW_HIDE`、"鼠标"用 `Application::HandleMouseMove()` 合成注入、 +像素从后备缓冲区读取;进程用 `std::_Exit()` 收尾(避免单例析构晚于 `GdiplusShutdown`)。 + +### T4 `EdgeScanFor` 状态查找哈希化 —— ⛔ **实测后回退**(建议 #2) + +* **改动前**:`mutable std::vector` + 逐字段线性扫描。 +* **改法**:容器改 `std::deque`(指针稳定)+ `mutable std::unordered_multimap` + 按身份哈希索引,桶内仍逐字段比对(碰撞安全);新建 / 淘汰 / 定向作废 / 清空四处同步索引。 +* **正确性**:`edgestats` 全绿(真实数据的计数与独立参考实现逐位相同),`panels` 像素基线不变。 +* **性能(198 个状态,`MF4-Record.MF4`)**:整轮查询 `0.097 → 0.073 us/query` + (一轮 198 次查询省 ≈4.8 μs)。 +* **体积代价**:`788,480 → 794,112 B`(**+5,632 B**)。 +* **结论:回退**。5.6 KB 的体积换 ≈5 μs/帧(约占 5 ms 整帧的 0.1%)不成立 —— + 这与 §6.2 原本的判断一致,本轮把它从"估计"变成了**实测**。 + ⚠️ 同时修正了 §6.2 的量级:报告原估"288 信号时 ≈0.3 ms/帧",实测 198 状态整轮 **≈20 μs/帧**, + 差一个数量级(线性扫描的常数很小)。 + +### T5 `EdgePanelLayoutFor` 表驱动化 —— ⏸ 只交付"可验证手段",**未重写** + +* 第 33 轮已把**按种类分派**收敛成唯一派发点(`LanePanelEnabled` / `LanePanelStateOf` / + `LanePanelScrollOf`,且 `switch` 不写 `default:` 让 `-Wswitch` 兜底)。 +* 剩余的重复是 957 行**几何算式本身**(每个面板的内容行高、表头、按钮、滚动区各不相同), + 无法用"每面板一行元数据"表达。 +* 本轮实测该函数 **1.9~2.5 μs/次**(2000 轮 × 7 种),按每帧约 14 次调用计约 **30 μs/帧**, + 不是热点。 +* ⇒ 重写的收益是"可读性",代价是 957 行几何公式的可回归风险。 + **本轮交付的是让这类重写变得可验证的工具**:`-run panels -mode record/compare` + 会逐面板比对**几何 + 像素哈希**(7 个面板、含内容区/滚动条/按钮)。 + +### T6 `wcstod` 自写替身(省 gdtoa)—— ⛔ **实测 0 字节收益**(建议 #4) + +* 报告 §1.3 的估计是"≈20 KB"。本轮先做**最小实验**而不是先写代码: + 在 `Makefile` 里加 `-D__USE_MINGW_ANSI_STDIO=0`(把 `swprintf` 交给系统 UCRT),全量重编。 +* **实测:产物仍然 788,480 B,一个字节都没变**;map 里 `mingw_wpformat.o` 依旧被链接。 +* **原因(用 3 行最小程序确认)**:预编译的 **libstdc++ 自己引用 `__mingw_swprintf`** —— + 只要工程里还调用 `std::to_wstring` / `std::to_string`(全仓约 80 处,其中若干处实参是浮点), + 链接器就必须把 MinGW 的 printf 引擎(含 gdtoa)拉进来。 +* ⇒ 真正的 33 KB 收益要以"替换全部 80 处 `std::to_*string`(含浮点格式语义)"为代价, + 风险与收益不匹配,**不实施**。该实验已回退,只在 `Makefile` 里留下一段说明防止重复尝试。 +* **顺带产出可复用的验收测试** `stdio_mingw_vs_ucrt_formats`: + 同一批真实格式(`%.*f` / `%.6g` / `%g` / `%.4e` / `%.2f %ls` / `%llu` / `%lld` / `%02X` / `%.2f%%`) + × 13.6 万次调用做**逐字符比对**,**0 处差异**。将来若真做迁移,这条测试就是验收标准。 + +### 本轮验证汇总 + +| 项 | 结果 | +| --- | --- | +| `make release`(删 `.o` 全量) | 零 error、零 warning | +| `make -C Tools` | 零 error、零 warning | +| `Tools/UnitTests/Build/UnitTests.exe` | `checks=219 failed=0 cases=18` | +| `Tools/OffscreenChecks/Build/OffscreenChecks.exe -all` | 5 个检查全 PASS,`checks=71 failed=0` | +| Release 产物 | **788,480 B**(与第 33 轮末一致;T4/T6 均回退) | diff --git a/Documents/Code_Review_Report.md b/Documents/Code_Review_Report.md index a44de54..31923a4 100644 --- a/Documents/Code_Review_Report.md +++ b/Documents/Code_Review_Report.md @@ -323,3 +323,71 @@ Makefile 按目录通配收集源文件,**无需登记**。 3. **`EdgePanelLayoutFor` 表驱动化**(需第 1 项就绪)。 4. 若要继续压体积:`wcstod` 自写替身(≈20 KB,需逐位一致性验证)、 图标帧裁剪(观感优先,不建议)。 + +--- + +## 11. 第 34 轮(2026-11-04):§10 建议的落地结果 + +> 计划见 `Code_Review_Plan.md` §六,逐节点记录见 `Code_Review_Progress.md` 第 34 轮。 + +### 11.1 一句话结论 + +**§10 里唯一"必须做"的第一条(重建离屏验证手段)已完成并入库**(`Tools/`), +它让其余三条建议第一次变得**可测量、可自证**;而用这套手段一测, +**第 2、4 条建议的实际收益远小于报告当初的估计,第 3 条根本不是性能项** —— +三条都以"实测数据 + 回退"收尾,而不是靠猜测去改 957 行几何或 80 处字符串转换。 + +| §10 建议 | 结果 | 关键数字 | +| --- | --- | --- | +| 1. 重建离屏验证手段 | ✅ **完成并入库** | 单元测试 **219** 条断言 / 18 用例;离屏 **71** 条断言 / 5 个检查;全程 `SW_HIDE` + 合成消息 | +| 2. `EdgeScanFor` 状态查找哈希化 | ⛔ **实测后回退** | 查询 `0.097 → 0.073 us/次`(198 状态,整轮省 ≈4.8 μs);体积 **+5,632 B** ⇒ 不划算 | +| 3. `EdgePanelLayoutFor` 表驱动化 | ⏸ **不重写**(交付验证手段) | 实测 **1.9~2.5 μs/次**,每帧约 30 μs ⇒ 不是热点;剩余重复是几何算式本身 | +| 4. `wcstod` 自写替身(≈20 KB) | ⛔ **实测 0 字节** | 只切 stdio 实现**体积不变**;真收益需替换 80 处 `std::to_*string`(含浮点) | + +### 11.2 验证基础设施(`Tools/`,已入库) + +```text +Tools/UnitTests/ 纯数据层单元测试(不创建窗口) +Tools/OffscreenChecks/ 隐藏窗口离屏验证(抓帧 / 哈希 / 面板基线 / 参考统计 / 性能) +Tools/Makefile 统一构建入口:make / make / make clean / make help +Tools/README.md 目录结构、作用、测试项、验收标准、A/B 用法、扩展步骤、已知限制 +``` + +* 工具链接本体 `Build/Release/*.o`(排除 `Main.o`),因此**必须先构建本体**; + 产物在 `Tools//Build/`,**不入库**;截图 / 基线 / 报告一律 `Temp/tools/`(已忽略)。 +* `smoke` 检查里有一条硬判据:**同一状态连续两帧逐像素相同**(实测差异 0 像素), + 这是所有像素 A/B 可信的前提。 +* `edgestats` 用**独立的朴素逐点实现**当金标准,比对真实数据的上升/下降沿计数 + (20/19、20/19、20/19 三个阈值全等)——它同时是 `EdgeScanFor` 任何重构的回归网。 + +### 11.3 对原报告两处量级的修正 + +| 位置 | 原表述 | 实测(本轮) | +| --- | --- | --- | +| §6.2 第 1 行 | `EdgeScanFor` 线性扫描「~0.3 ms/帧 @288 信号」 | **≈20 μs/帧 @198 状态**(差一个数量级;线性扫描的常数很小) | +| §1.3 第 3 行 | 替换 `wcstod`「≈20 KB」 | **0 B**(只要还用 `std::to_*string`,MinGW printf 引擎 + gdtoa 必然被链入) | + +> 这两条修正同样是"结论":它们避免后续接手者按错误量级去做无收益的重写。 + +### 11.4 体积现状 + +| 项 | 数值 | +| --- | --- | +| 第 33 轮末 | 794,112 B(本机实测 **788,480 B**,工具链差异) | +| 第 34 轮末 | **788,480 B**(T4 / T6 均回退,产品代码零改动) | +| 红线 | 1 MB 预警(`Makefile` 注释),距红线 **≈260 KB** | + +### 11.5 后续建议(替代本报告 §10) + +1. **保持 `Tools/` 常绿**:任何改动按 `Tools/README.md` §5 的"最小集"跑针对性检查; + 改数据层必跑 `UnitTests`,改绘图/面板必跑 `-run smoke -run panels`(必要时 `-mode compare`)。 +2. **性能类改动一律先量后改**:用 `-run perf` 取同一会话的前后数字; + 绝对耗时受机器状态影响,比较时用**同一次运行内的相对值**。 +3. **如果将来真要压体积**(优先级从高到低): + * 替换 `std::to_wstring` / `std::to_string`(≈80 处)→ 可整条去掉 gdtoa(≈33 KB), + 验收标准已就绪(`stdio_mingw_vs_ucrt_formats`);注意其中若干处实参是浮点。 + * 图标帧继续瘦身(观感优先,**不建议**)。 +4. **如果将来要重写 `EdgePanelLayoutFor`**:先 `-run panels -mode record`, + 重写后 `-mode compare`;只要有一处像素/几何差异就说明改坏了。 +5. **不要重复这两次实验**:`EdgeScanFor` 哈希化(+5.6 KB / −5 μs 不划算)、 + 仅切 `__USE_MINGW_ANSI_STDIO`(0 B)。数字见 §11.1 与 `Code_Review_Progress.md` 第 34 轮。 diff --git a/Documents/agent_mem.md b/Documents/agent_mem.md index 988f324..0395848 100644 --- a/Documents/agent_mem.md +++ b/Documents/agent_mem.md @@ -10368,3 +10368,64 @@ release/debug **零警告**;Release `TargetCheck.exe` **890,880 B**(< 1 MB 288 信号时每帧约 16 万次比较(~0.3ms 量级)。可改哈希索引,但收益有限、风险不小。 * `SceneContentHash` 里 6 段逐面板混入循环未合并(同上:无法验证"仅失效条件不变")。 +--- + +## 2026-11-04 · 第 34 轮(`Code_Review` 分支):验证工具入库 + §10 建议的实测结论 + +> 计划 `Documents/Code_Review_Plan.md` §六,逐节点 `Documents/Code_Review_Progress.md` 第 34 轮, +> 结论 `Documents/Code_Review_Report.md` §11。工具说明见 `Tools/README.md`(改工具必须同步它)。 + +### 1. ⭐ `Tools/` 已入库:验证能力变成"仓库资产" + +* `.gitignore`:删掉整目录忽略的 `Tools/`,改为 `Tools/**/Build/`; + **工具产物与临时文件都不得入库**(截图/基线/报告写 `Temp/tools/`)。 +* `Tools/Makefile`:`make` 全部、`make UnitTests`、`make OffscreenChecks`、`make clean`、`make help`。 + 工具链接本体 `../Build/Release/*.o`(排除 `Main.o`)⇒ **改完本体必须先 `make release`**。 +* `Tools/UnitTests`:219 条断言 / 18 用例(解析逐位一致、格式化、公式、变换、滑动平均、公式缓存)。 +* `Tools/OffscreenChecks`:`smoke` / `frames` / `panels` / `edgestats` / `perf`,共 71 条断言。 + 窗口 `SW_HIDE` + `Application::HandleMouseMove()` 合成鼠标 + 读后备缓冲区;**不占用用户屏幕/输入**。 + +### 2. ⚠️ 两个必须记住的构建陷阱 + +1. **工具源码不能加 `-flto`**:本体目标文件是 LTO 字节码,链接时必须 + `-flto -flto-partition=one`;但把**工具源码**也交给 LTRANS 会让 GCC 15.2.0 在 + `choose_baseaddr`(i386.cc:7447)**ICE**。⇒ 只在链接参数里带 LTO。 +2. **`Tools/Makefile` 必须 `.DEFAULT_GOAL := all`**:用 `$(foreach)` 生成的目标会先于 `all` + 出现,make 会把第一个显式目标当默认目标 ⇒ 否则直接 `make` 只构建第一个工具。 + +### 3. ⛔ `EdgeScanFor` 状态查找哈希化:**实测后回退**(别再做一次) + +* 改法(已回退):`vector` → `deque`(指针稳定)+ `unordered_multimap`, + 桶内仍逐字段比对;新建/淘汰/定向作废/清空四处同步索引。功能与像素均验证通过。 +* **数字**:198 个状态整轮查询 `0.097 → 0.073 us/query`(一轮省 ≈4.8 μs); + 产物 **788,480 → 794,112 B(+5,632 B)**。 +* **结论**:体积换 5 μs/帧不成立,已回退。 +* ⚠️ **同时修正第 33 轮报告的量级错误**:那里估"288 信号时 ≈0.3 ms/帧", + 实测 198 状态整轮只有 **≈20 μs/帧**(差一个数量级)。**不要再按 0.3ms 去优化这条路径。** + +### 4. ⛔ 仅切 `__USE_MINGW_ANSI_STDIO=0`:**0 字节收益**(别再做一次) + +* 实测:加 `-D__USE_MINGW_ANSI_STDIO=0` 全量重编后产物**仍是 788,480 B**, + map 里 `mingw_wpformat.o` + gdtoa 依旧在。 +* 原因(3 行最小程序确认):**预编译 libstdc++ 自己引用 `__mingw_swprintf`** —— + 只要还调用 `std::to_wstring` / `std::to_string`(全仓约 80 处,若干处实参是浮点), + gdtoa 必然被链入。真要省这 33 KB 必须替换全部 80 处,风险与收益不匹配。 +* 该实验已回退,`Makefile` 里留了说明。**副产物**:`stdio_mingw_vs_ucrt_formats` + (13.6 万次 `%.*f`/`%.6g`/`%g`/`%.4e`/`%.2f %ls`/`%llu`/`%lld`/`%02X`/`%.2f%%` 逐字符比对, + 0 差异)——将来真做迁移就是验收标准。 + +### 5. ⏸ `EdgePanelLayoutFor` 表驱动化:不是性能项,不重写 + +* 实测 **1.9~2.5 μs/次**(2000 轮 × 7 种面板),每帧约 14 次调用 ≈ 30 μs ⇒ 不是热点。 +* 第 33 轮已消除"按种类分派"的重复(`LanePanelEnabled`/`LanePanelStateOf`/`LanePanelScrollOf`), + 剩余 957 行的重复是**几何算式本身**,无法表驱动。 +* 这类重写若要动,必须先 `-run panels -mode record`,改完 `-mode compare`(几何 + 像素哈希逐面板比对)。 + +### 6. 本轮验证口径 + +* `make release` 全量:零 error / 零 warning;产物 **788,480 B**。 +* `make -C Tools`:零 error / 零 warning。 +* `UnitTests.exe` → `checks=219 failed=0`;`OffscreenChecks.exe -all` → 5/5 检查 PASS、`checks=71 failed=0`。 +* 单元测试写错过的 5 处"我以为的语义"(都已按产品语义修正): + `9.999999999999999`(>15 位有效数字允许 1 ULP)、`1..2` 合法、`FormulaIsIdentity` 不去空格、 + 非法公式 `EvaluateFormula` **原样返回采样值**(不是 NaN)。 diff --git a/Makefile b/Makefile index 0d2df35..3357aae 100644 --- a/Makefile +++ b/Makefile @@ -209,6 +209,18 @@ endif # * ⚠️ 全关(`-fno-unwind-tables`)会链接失败(历史踩坑),**不要**打开。 RELEASE_ONLY_OPT = -fno-asynchronous-unwind-tables +# ---- 曾评估并放弃:把 `swprintf` 交给系统 UCRT(`-D__USE_MINGW_ANSI_STDIO=0`)---- +# 动机:MinGW 的 ANSI stdio(`mingw_wpformat.o`)会链入 gdtoa 整套 +#(`strtodg`/`gdtoa`/`gethex`/`hexnan`),map 实测 `.text` 合计 ≈33 KB。 +# 实测结论(2026-11-04):**加这个宏体积一个字节都不减**。 +# 原因:预编译的 libstdc++ 自己就引用 `__mingw_swprintf` —— +# 只要工程里还调用 `std::to_wstring` / `std::to_string`(全仓约 80 处), +# 链接器就必须把 MinGW 的 printf 引擎拉进来(用 3 行最小程序 `std::to_wstring(5)` 实测)。 +# ⇒ 要真省下这 33 KB,得把 80 处 `std::to_*string` 全换成自写整数格式化, +# 其中若干处实参是浮点(语义会变),风险与收益不匹配。 +# 保留证据:`Tools/UnitTests` 的 `stdio_mingw_vs_ucrt_formats`(13.6 万次调用逐字符比对,0 差异) +# 证明两套 printf 在这些格式上**完全等价**,将来若真要做迁移,那条测试就是验收标准。 + # 丢弃未被引用的函数/数据(非 LTO 时还需要配合 -ffunction-sections 才有粒度)。 GC_SECTIONS = -Wl,--gc-sections diff --git a/Tools/OffscreenChecks/Src/Checks.cpp b/Tools/OffscreenChecks/Src/Checks.cpp index 1352373..0e3dad8 100644 --- a/Tools/OffscreenChecks/Src/Checks.cpp +++ b/Tools/OffscreenChecks/Src/Checks.cpp @@ -491,7 +491,29 @@ namespace jdi { return CheckFailed(); } - const SignalData& signal = group->signals[0]; + + // 挑一条"最有起伏"的信号:常量信号(只有 1 个不同取值)上任何阈值都得不到边沿, + // 断言会退化成"0 == 0"的空转(MF4 的第一条信号就是常量)。 + const SignalData* chosen = nullptr; + double chosenSpan = -1.0; + for (const SignalData& candidate : group->signals) + { + double low = 0.0; + double high = 1.0; + FiniteRange(candidate.values, low, high); + const double span = high - low; + if (candidate.values.size() >= 4 && span > chosenSpan) + { + chosen = &candidate; + chosenSpan = span; + } + } + Expect(chosen != nullptr && chosenSpan > 0.0, "found a non-constant signal"); + if (chosen == nullptr || !(chosenSpan > 0.0)) + { + return CheckFailed(); + } + const SignalData& signal = *chosen; const TimeAxis& axis = group->AxisOf(signal); const SignalStyle& style = signal.style; diff --git a/Tools/README.md b/Tools/README.md index 5d6857c..cb973da 100644 --- a/Tools/README.md +++ b/Tools/README.md @@ -124,11 +124,12 @@ SUMMARY checks= failed= cases= result= | `filter_moving_average_equivalence` | 1 阶 ≡ 单次、2 阶 ≡ 链式、常数信号不变 | 均值滤波多阶 | | `dataset_group_access` | `AddGroup` / `GroupAt` / `Style(ref)` 读写 | 数据层入口 | | `dataset_formula_transform_cache` | 值域 + 逐分组极值 vs 逐点参考**逐位相同**;命中同指针;样式变化失效 | 第 32 轮核心缓存的正确性 | +| `stdio_mingw_vs_ucrt_formats` | MinGW ANSI stdio 与系统 UCRT 的 printf 在**本程序用到的全部格式**上逐字符比对(13.6 万次调用) | 评估“把 `swprintf` 交给 UCRT”是否可行(当前 0 差异);将来真做迁移就是验收标准 | ### 3.2 验收标准 * **零失败**(`result=PASS`)才算通过; -* 断言数量只增不减(当前 **218** 项):删断言必须说明理由; +* 断言数量只增不减(当前 **219** 项 / 18 个用例):删断言必须说明理由; * 任何"为了让它过"而放宽的断言,必须在代码注释里写清依据。 --- diff --git a/Tools/UnitTests/Src/Utf8UtilTests.cpp b/Tools/UnitTests/Src/Utf8UtilTests.cpp index a97ee71..8fe8808 100644 --- a/Tools/UnitTests/Src/Utf8UtilTests.cpp +++ b/Tools/UnitTests/Src/Utf8UtilTests.cpp @@ -13,12 +13,17 @@ #include #include +#include #include #include +#include #include #include #include +#include +#include + #include "UnitTestFramework.hpp" #include "Utf8Util.hpp" @@ -304,3 +309,128 @@ JDI_TEST(ticks_nice_step_and_build_ticks) JDI_CHECK_NEAR(ticks.back(), 10.0, 0.0); } } + +/** + * @brief MinGW ANSI stdio 与 UCRT printf 在**本程序实际使用的格式**上是否逐字符一致。 + * + * 用途:评估"把 `swprintf` 让给系统 UCRT 实现(`__USE_MINGW_ANSI_STDIO=0`)" + * 这条体积优化是否可行 —— 若成立可以省掉整个 `mingw_wpformat` + `gdtoa` 链(≈33KB)。 + * 判据极其严格:**逐字符相同**,一个字符的差异都不接受(显示文本是产品语义)。 + */ +JDI_TEST(stdio_mingw_vs_ucrt_formats) +{ + std::mt19937 rng(20261107u); + std::uniform_real_distribution distribution(-1.0e7, 1.0e7); + std::uniform_real_distribution magnitude(-40.0, 40.0); + + int mismatches = 0; + int compared = 0; + static const int decimalsList[] = {0, 1, 2, 3, 4, 6, 8, 12}; + + for (int iteration = 0; iteration < 8000; ++iteration) + { + double value = distribution(rng); + // 覆盖极小 / 极大 / 整数量级,以及 FormatDecimal 会遇到的量级 + if ((iteration % 7) == 0) + { + value = std::pow(10.0, magnitude(rng)) * ((iteration % 2 == 0) ? 1.0 : -1.0); + } + if ((iteration % 11) == 0) + { + value = static_cast(iteration); + } + + wchar_t ours[128] = {}; + wchar_t theirs[128] = {}; + + for (const int decimals : decimalsList) + { + swprintf(ours, 128, L"%.*f", decimals, value); + _snwprintf(theirs, 128, L"%.*f", decimals, value); + ++compared; + if (wcscmp(ours, theirs) != 0) + { + if (mismatches < 5) + { + std::printf(" [INFO] %%.%df value=%.17g ours=\"%ls\" ucrt=\"%ls\"\n", decimals, value, ours, + theirs); + } + ++mismatches; + } + } + + struct WideFormatProbe + { + const wchar_t* format; + }; + static const WideFormatProbe probes[] = {{L"%.6g"}, {L"%.4e"}, {L"%.2f"}, {L"%g"}}; + for (const WideFormatProbe& probe : probes) + { + swprintf(ours, 128, probe.format, value); + _snwprintf(theirs, 128, probe.format, value); + ++compared; + if (wcscmp(ours, theirs) != 0) + { + if (mismatches < 5) + { + std::printf(" [INFO] \"%ls\" value=%.17g ours=\"%ls\" ucrt=\"%ls\"\n", probe.format, value, + ours, theirs); + } + ++mismatches; + } + } + + // 非浮点格式(颜色十六进制 / 计数 / 百分比尾缀) + const unsigned long long counter = static_cast(iteration) * 7919ull; + swprintf(ours, 128, L"%llu", counter); + _snwprintf(theirs, 128, L"%llu", counter); + ++compared; + if (wcscmp(ours, theirs) != 0) + { + ++mismatches; + } + + swprintf(ours, 128, L"%02X%02X%02X", iteration & 0xFF, (iteration >> 3) & 0xFF, (iteration >> 6) & 0xFF); + _snwprintf(theirs, 128, L"%02X%02X%02X", iteration & 0xFF, (iteration >> 3) & 0xFF, + (iteration >> 6) & 0xFF); + ++compared; + if (wcscmp(ours, theirs) != 0) + { + ++mismatches; + } + + swprintf(ours, 128, L"%.2f%%", value / 1000.0); + _snwprintf(theirs, 128, L"%.2f%%", value / 1000.0); + ++compared; + if (wcscmp(ours, theirs) != 0) + { + ++mismatches; + } + + // `%ls`:`FormatByteSize` 的 `%.2f %ls` 依赖"宽格式串里的 %ls = 宽字符串"。 + // ⚠️ MSVC/UCRT 的宽 printf 里 `%s` 也表示**宽**字符串,与 MinGW ANSI(`%s`=窄)相反, + // 因此全仓禁止在宽格式串里写 `%s`(已有注释约束),这里顺带把 `%ls` 也钉住。 + swprintf(ours, 128, L"%.2f %ls", value / 1024.0, L"MB"); + _snwprintf(theirs, 128, L"%.2f %ls", value / 1024.0, L"MB"); + ++compared; + if (wcscmp(ours, theirs) != 0) + { + if (mismatches < 5) + { + std::printf(" [INFO] \"%%.2f %%ls\" ours=\"%ls\" ucrt=\"%ls\"\n", ours, theirs); + } + ++mismatches; + } + + swprintf(ours, 128, L"%lld", static_cast(iteration) * -7919ll); + _snwprintf(theirs, 128, L"%lld", static_cast(iteration) * -7919ll); + ++compared; + if (wcscmp(ours, theirs) != 0) + { + ++mismatches; + } + } + + std::printf(" [INFO] compared %d format calls, %d mismatches\n", compared, mismatches); + JDI_CHECK_EQ(mismatches, 0); +} -- 2.54.0 From abcf3e6c44d9750b066a3d921d257a985f00ac23 Mon Sep 17 00:00:00 2001 From: "DeepSeek V4.1 Flash" <> Date: Wed, 4 Nov 2026 14:20:57 +0800 Subject: [PATCH 11/22] =?UTF-8?q?refactor(panels):=20=E6=B5=AE=E5=8A=A8?= =?UTF-8?q?=E9=9D=A2=E6=9D=BF=E6=8A=BD=E5=87=BA=E5=94=AF=E4=B8=80"?= =?UTF-8?q?=E9=9D=A2=E6=9D=BF=E7=89=B9=E5=BE=81=E5=B1=82"=EF=BC=8C?= =?UTF-8?q?=E6=B6=88=E9=99=A4=203=20=E5=A4=84=E5=88=86=E7=B1=BB/=E6=BB=9A?= =?UTF-8?q?=E5=8A=A8=E5=87=A0=E4=BD=95/=E6=BB=9A=E5=8A=A8=E8=AE=BE?= =?UTF-8?q?=E7=BD=AE=E7=9A=84=E9=87=8D=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 背景(用户观察):7 个实验性功能都有自己的浮动面板,观感与骨架高度一致 (标题栏 = 折起三角 + GX 徽标 + 标题 + ✕,内容区可滚动 + 滚动条,底部按钮行), 但每新增一种面板或改动布局,总会出"意料之外"的缺陷。 审计结论:骨架绘制本来只有一份,真正的问题是**同一套"按种类分支"的代码被抄了多份**: | 重复项 | 位置 | 份数 | | --- | --- | --- | | 分类布尔量(listStyle / countTable / hasButton…) | EdgePanelWidth / EdgePanelLayoutFor / PaintEdgeCursor | 3 | | 滚动条几何 + 滚动量夹取 | 布局里表格 / 列表 / 自由标记三个分支 | 3 | | 滚动条绘制(6 行) | 绘制里同样三个分支 | 3 | | SetLane*Scroll 实现 | PlotViewFeatures.cpp | 7 | | 按种类转发滚动写入 | 滚轮 / 拖滚动条 | 2 | | 标题文本 | EdgePanelWidth 的三元式链 / 绘制里的三元式链 | 2(内容还不一致!) | 一、新增"面板特征层"(PlotViewInternal.hpp,pvdetail) * `PanelStyle PanelStyleOf(EdgePanelKind)`:唯一分类来源,`listRows` / `countTable` / `markerGroups` + `buildButton` / `twoButtons` / `markerFooter`,`switch` 穷举不写 default (新增面板漏登记 ⇒ `-Wswitch` 编译报警)。 其中专门区分 `footerButtons()`(底部有按钮行)与 `buildButton`(是"构建为新信号")—— 混为一谈会让「选区截取」底部少预留一行高度(本轮实测 124 → 87 px,被像素基线当场抓住)。 * `PanelScrollMetricsFor(...)`:滚动条轨道/滑块几何 + 滚动量夹取 + 内容宽度扣减的唯一实现。 * `PlotView::PanelTitle(EdgePanelKind)`:标题唯一来源。 ⚠️ 修掉一处**潜伏**错误:`EdgePanelWidth` 的标题链漏了 MARK/CLIP(两者按"边沿计数"量宽), 目前两种标题恰好等宽所以看不出来,但任何文案改动都会让宽度与标题不匹配。 二、收敛调用点 * `EdgePanelWidth` / `EdgePanelLayoutFor`(表格 / 列表 / 自由标记三分支)/ `PaintEdgeCursor` 全部改用 `PanelStyleOf` 与 `PanelTitle`;滚动几何改用 `PanelScrollMetricsFor`。 * `EdgePanelLayoutFor` 的 5 层滚动量三元式 → `LanePanelScrollOf(lane, kind)`(该派发点第 33 轮 就已存在,但布局这里一直没用它)。 * `PaintEdgeCursor` 的滚动条绘制抽成 `paintPanelScrollBar(layout)`,三处调用。 * 7 个 `SetLane*Scroll` 改为一行转发到新的 `SetLanePanelScroll(laneIndex, kind, scroll)`; 滚轮与"拖滚动条"两条 `switch`/`if` 链改为直接调它(读与写从此同一个入口)。 三、新增离屏检查 `panelscroll`(护栏) * 对 7 种面板逐一:可滚动时滚到底 / 滚回顶,断言 `scrollOffset` 夹取到 `maxScroll`、 滑块到达/返回轨道端点、内容区几何不随滚动改变;不可滚动时断言不出现滚动条。 * 用 `MF4-Record.MF4`(566 信号合并到同一网格)实测 6/7 面板可滚(自由标记无固定点,正确)。 没有这条检查,本次收敛就等于"改完只能靠肉眼"。 验证(本机 GCC 15.2.0 UCRT / 125% DPI) * `panels -mode compare`:7 个面板的**像素哈希与几何逐项不变**(零差异)。 这条检查当场抓到了上面 `footerButtons` 的回归,证明基线有效。 * `OffscreenChecks -all`:6 个检查全 PASS,`checks=87 failed=0`。 * `UnitTests`:`checks=219 failed=0`。 * release 全量零 error / 零 warning;产物 788,480 → **787,968 B**(−512)。 --- Components/PlotView/Inc/PlotView.hpp | 31 ++++ Components/PlotView/Inc/PlotViewInternal.hpp | 136 +++++++++++++++ Components/PlotView/Src/PlotViewFeatures.cpp | 113 ++++--------- .../PlotView/Src/PlotViewInteraction.cpp | 58 +------ Components/PlotView/Src/PlotViewPaint.cpp | 49 +++--- Components/PlotView/Src/PlotViewPanels.cpp | 159 ++++++++---------- Tools/OffscreenChecks/Src/Checks.cpp | 120 +++++++++++++ 7 files changed, 416 insertions(+), 250 deletions(-) diff --git a/Components/PlotView/Inc/PlotView.hpp b/Components/PlotView/Inc/PlotView.hpp index b731884..174dd47 100644 --- a/Components/PlotView/Inc/PlotView.hpp +++ b/Components/PlotView/Inc/PlotView.hpp @@ -1197,6 +1197,20 @@ namespace jdi */ bool SetLaneFreeMarkerOpacity(int laneIndex, float opacity); + /** + * @brief 设置某种面板的**内容滚动偏移**(客户区像素,0 = 顶部)—— 唯一实现。 + * + * ⚠️ 下面七个 `SetLane*Scroll` 都只是本函数的一行转发(保留是为了不改调用点与原语义)。 + * 新增面板时**只需**在 `LanePanelScrollOf` / `PanelStyleOf` / `PanelTitle` 三处登记, + * 不要再复制一份"取字段 → 夹取 → 比较 → 写回"的实现。 + * + * @param [IN] laneIndex 网格下标。 + * @param [IN] kind 面板种类。 + * @param [IN] scroll 目标滚动量(负值按 0 处理)。 + * @return 偏移真的发生变化返回 true。 + */ + bool SetLanePanelScroll(int laneIndex, EdgePanelKind kind, float scroll); + /** * @brief 设置某网格「自由标记」面板内容的滚动偏移(客户区像素,0 = 顶部)。 * @return 偏移真的发生变化返回 true。 @@ -2846,6 +2860,23 @@ namespace jdi double edgeValue, bool hasRange, double rangeMin, double rangeMax, std::size_t& rising, std::size_t& falling, bool& complete) const; + /** + * @brief 浮动面板的**标题文本**(唯一来源:宽度计算与绘制共用)。 + * + * ⚠️ 过去标题在两处各写一条多层三元式: + * * `EdgePanelWidth` 里的链**漏了「自由标记」与「选区截取」**(两种面板都退化成 + * 「边沿计数」的文字来量宽度); + * * `PaintEdgeCursor` 里的链是完整的。 + * 目前两种标题恰好与「边沿计数」等宽(或宽度被其它列主导),所以看不出来; + * 但任何一次文案改动都会让"面板宽度与实际标题不匹配"(标题被省略 / 右侧留白)。 + * + * 现在宽度与绘制都调本函数,且 `switch` 穷举、不写 `default:`。 + * + * @param [IN] kind 面板种类。 + * @return 标题(「边沿计数」与「中心轴边沿计数」会追加双光标区间尾缀)。 + */ + std::wstring PanelTitle(EdgePanelKind kind) const; + /** * @brief 计算实验性功能面板所需的宽度(排版与绘制共用,保证文字完整不被省略)。 * @param [IN] laneIndex 网格下标。 diff --git a/Components/PlotView/Inc/PlotViewInternal.hpp b/Components/PlotView/Inc/PlotViewInternal.hpp index cacc637..5f87656 100644 --- a/Components/PlotView/Inc/PlotViewInternal.hpp +++ b/Components/PlotView/Inc/PlotViewInternal.hpp @@ -1178,6 +1178,142 @@ namespace jdi return false; } + // ---- 面板的「排版类别」与「滚动几何」的唯一来源 -------------------------------- + // + // ⚠️⚠️ 为什么要有这一层:面板的**分类布尔量**过去在三个地方各写一份 + // (`EdgePanelWidth` / `EdgePanelLayoutFor` / `PaintEdgeCursor`), + // 每份都是 6 个 `kind == XXX` + 3 条组合判断。新增一种面板时若只改其中两处, + // 就会出现"宽度按 A 类算、绘制按 B 类画"的错位 —— 这正是历史上 + // 「元素超出浮动面板」「内容区多出一段空白」「滚动条画在表头上」这类缺陷的共同形态。 + // 现在只在这里分派一次,三个消费者都读同一份结果。 + // + // ⚠️ 同样**不写 `default:`**:新增 `EdgePanelKind` 而未登记时, + // `-Wswitch`(属 `-Wall`)会直接编译报警。 + + /** + * @brief 一种浮动面板的排版类别(互斥 + 若干可选部件)。 + * + * 三类互斥的内容排版: + * * `listRows` —— 可滚动**分组列表**:条目栏(三角 + 名称 + 复选框)+ 展开后的参数行; + * * `countTable` —— **表格**:固定表头(信号名 / 上升沿 / 下降沿)+ 可滚动行; + * * `markerGroups` —— **分组栏 + 条目**:每个信号一个可折起分组栏 + 固定点条目 + 吸顶。 + */ + struct PanelStyle + { + bool listRows = false; /**< 可滚动分组列表(均值 / 中心轴 / 弱峰 / 重采样 / 选区截取)。 */ + bool countTable = false; /**< 固定表头的表格(边沿计数)。 */ + bool markerGroups = false; /**< 分组栏 + 条目(自由标记)。 */ + + /** @brief 内容区是否可滚动(三类都是,保留字段以便未来出现「不可滚动」的面板)。 */ + bool scrollableContent() const { return listRows || countTable || markerGroups; } + + /** @brief 底部是否有「构建为新信号」样式的单一按钮。 */ + bool buildButton = false; + /** @brief 底部是否有**两个并排**按钮(选区截取)。 */ + bool twoButtons = false; + /** @brief 底部是否有「自由标记」那组固定控件(透明度滑块 + 3 个切换按钮)。 */ + bool markerFooter = false; + + /** + * @brief 底部是否存在任何内容(按钮 / 滑块 / 开关)。 + * + * ⚠️ 与 `buildButton` **不是同一个概念**:「选区截取」有两个并排按钮, + * 但没有「构建为新信号」;而面板高度算式里的「按钮行高」必须两者都为真。 + * 曾经把两者当作一回事 ⇒ 截取面板底部少预留一行高度(124 → 87 px), + * 内容区却仍按旧高度计算 ⇒ 面板高度与内容区不一致。 + */ + bool footerButtons() const { return buildButton || twoButtons; } + }; + + /** @brief 取某种面板的排版类别(唯一分类来源)。 */ + inline PanelStyle PanelStyleOf(EdgePanelKind kind) + { + PanelStyle style; + switch (kind) + { + case EdgePanelKind::COUNT: + style.countTable = true; + break; + case EdgePanelKind::CENTER: + style.listRows = true; + break; + case EdgePanelKind::MEAN: + style.listRows = true; + style.buildButton = true; + break; + case EdgePanelKind::PEAK: + style.listRows = true; + style.buildButton = true; + break; + case EdgePanelKind::MARK: + style.markerGroups = true; + style.markerFooter = true; + break; + case EdgePanelKind::RESAMPLE: + style.listRows = true; + style.buildButton = true; + break; + case EdgePanelKind::CLIP: + style.listRows = true; + style.twoButtons = true; + break; + } + return style; + } + + /** + * @brief 浮动面板**内容区滚动条**的几何与滚动量(三个排版分支共用同一套算式)。 + * + * 旧实现把这段算式在 `EdgePanelLayoutFor` 里抄了三遍(表格 / 列表 / 自由标记), + * 三份的差异只在于"总内容高"和"滚动量来自哪个字段"。任何一次微调(比如把 + * 最小滑块高度 18 改成别的)都必须记得改三处 —— 典型的"漏一处就错位"。 + * + * @param [IN] panelLeft 面板左边界(客户区)。 + * @param [IN] contentTop 内容区上边界。 + * @param [IN] viewHeight 内容区可视高度。 + * @param [IN] contentTotal 内容总高度(可能大于可视高度)。 + * @param [IN] scrollValue 期望滚动量(会被夹到 `[0, maxScroll]`)。 + * @param [IN] widthWithoutScroll 未扣滚动条时的内宽。 + * @param [IN] leftPad 面板左内边距。 + * @param [IN] scrollWidth 滚动条宽度(已乘缩放)。 + * @param [IN] scale DPI 缩放。 + * @return 滚动指标(`scrollable == false` 时 `track/thumb` 为空矩形、`innerWidth` 不扣减)。 + */ + struct PanelScrollMetrics + { + bool scrollable = false; /**< 内容是否超高(需要滚动条)。 */ + float maxScroll = 0.0f; /**< 最大滚动量。 */ + float scroll = 0.0f; /**< 实际滚动量(已夹取)。 */ + float innerWidth = 0.0f; /**< 内容可用宽度(需要滚动条时已扣掉它)。 */ + gd::RectF track; /**< 滚动条轨道。 */ + gd::RectF thumb; /**< 滚动条滑块。 */ + }; + + inline PanelScrollMetrics PanelScrollMetricsFor(float panelLeft, float contentTop, float viewHeight, + float contentTotal, float scrollValue, + float widthWithoutScroll, float leftPad, + float scrollWidth, float scale) + { + PanelScrollMetrics metrics; + metrics.scrollable = contentTotal > viewHeight + 0.5f; + metrics.maxScroll = std::max(0.0f, contentTotal - viewHeight); + metrics.scroll = std::max(0.0f, std::min(scrollValue, metrics.maxScroll)); + metrics.innerWidth = std::max( + 0.0f, widthWithoutScroll - (metrics.scrollable ? (scrollWidth + 4.0f * scale) : 0.0f)); + if (!metrics.scrollable) + { + return metrics; + } + metrics.track = gd::RectF(panelLeft + leftPad + metrics.innerWidth + 4.0f * scale, contentTop, + scrollWidth, viewHeight); + const float thumbHeight = std::max(18.0f * scale, viewHeight * (viewHeight / contentTotal)); + const float thumbTop = + contentTop + (viewHeight - thumbHeight) * + ((metrics.maxScroll > 0.0f) ? (metrics.scroll / metrics.maxScroll) : 0.0f); + metrics.thumb = gd::RectF(metrics.track.X, thumbTop, scrollWidth, thumbHeight); + return metrics; + } + /** * @brief 计算一条派生曲线在给定采样窗口内的取值(同步路径与后台线程共用)。 * diff --git a/Components/PlotView/Src/PlotViewFeatures.cpp b/Components/PlotView/Src/PlotViewFeatures.cpp index f9604ab..38de880 100644 --- a/Components/PlotView/Src/PlotViewFeatures.cpp +++ b/Components/PlotView/Src/PlotViewFeatures.cpp @@ -1778,7 +1778,16 @@ namespace jdi return true; } - bool PlotView::SetLaneFreeMarkerScroll(int laneIndex, float scroll) + /** + * @brief 设置某种面板的**内容滚动偏移**(唯一实现,公开包装都在下面转发到这里)。 + * + * ⚠️ 过去这里是七个几乎逐字重复的函数(自由标记 / 均值滤波 / 重采样 / 选区截取 / + * 中心轴 / 弱峰 / 边沿计数),唯一差别是要写的字段名;调用侧还有两条 `if`/`switch` + * 链再按种类转发一次。新增面板时必须在**三个**地方登记,漏一处就出现 + * "滚轮能滚、拖滚动条滚不动"或反之。 + * 现在字段一律经 `LanePanelScrollOf(lane, kind)` 取(唯一派发点),本函数是唯一写入点。 + */ + bool PlotView::SetLanePanelScroll(int laneIndex, EdgePanelKind kind, float scroll) { if (m_tab == nullptr || laneIndex < 0 || laneIndex >= static_cast(m_tab->Lanes().size())) { @@ -1786,31 +1795,26 @@ namespace jdi } Lane& lane = m_tab->Lanes()[static_cast(laneIndex)]; const float clamped = std::max(0.0f, scroll); - if (std::fabs(lane.freeMarkerScroll - clamped) < 0.5f) - { - return false; - } - lane.freeMarkerScroll = clamped; - return true; - } - - bool PlotView::SetLaneMeanFilterScroll(int laneIndex, float scroll) - { - if (m_tab == nullptr || laneIndex < 0 || laneIndex >= static_cast(m_tab->Lanes().size())) - { - return false; - } - Lane& lane = m_tab->Lanes()[static_cast(laneIndex)]; - const float clamped = std::max(0.0f, scroll); - if (std::fabs(lane.meanFilterScroll - clamped) < 0.5f) + float& stored = LanePanelScrollOf(lane, kind); + if (std::fabs(stored - clamped) < 0.5f) { return false; } // 滚动只影响面板内部像素,缓冲区/场景都不变(面板像素缓存按键失效即可)。 - lane.meanFilterScroll = clamped; + stored = clamped; return true; } + bool PlotView::SetLaneFreeMarkerScroll(int laneIndex, float scroll) + { + return SetLanePanelScroll(laneIndex, EdgePanelKind::MARK, scroll); + } + + bool PlotView::SetLaneMeanFilterScroll(int laneIndex, float scroll) + { + return SetLanePanelScroll(laneIndex, EdgePanelKind::MEAN, scroll); + } + // ============================ 「重采样」 ============================ bool PlotView::SignalDataInterval(const SignalRef& ref, double& maxInterval, @@ -1963,18 +1967,7 @@ namespace jdi bool PlotView::SetLaneResampleScroll(int laneIndex, float scroll) { - if (m_tab == nullptr || laneIndex < 0 || laneIndex >= static_cast(m_tab->Lanes().size())) - { - return false; - } - Lane& lane = m_tab->Lanes()[static_cast(laneIndex)]; - const float clamped = std::max(0.0f, scroll); - if (std::fabs(lane.resampleScroll - clamped) < 0.5f) - { - return false; - } - lane.resampleScroll = clamped; - return true; + return SetLanePanelScroll(laneIndex, EdgePanelKind::RESAMPLE, scroll); } bool PlotView::TakePendingResampleBuild(int& laneIndex, std::vector& items) @@ -2072,18 +2065,7 @@ namespace jdi bool PlotView::SetLaneClipScroll(int laneIndex, float scroll) { - if (m_tab == nullptr || laneIndex < 0 || laneIndex >= static_cast(m_tab->Lanes().size())) - { - return false; - } - Lane& lane = m_tab->Lanes()[static_cast(laneIndex)]; - const float clamped = std::max(0.0f, scroll); - if (std::fabs(lane.clipScroll - clamped) < 0.5f) - { - return false; - } - lane.clipScroll = clamped; - return true; + return SetLanePanelScroll(laneIndex, EdgePanelKind::CLIP, scroll); } bool PlotView::TakePendingClipBuild(int& laneIndex, std::vector& items) @@ -2100,54 +2082,19 @@ namespace jdi } - bool PlotView::SetLaneEdgeCenterScroll(int laneIndex, float scroll) { - if (m_tab == nullptr || laneIndex < 0 || laneIndex >= static_cast(m_tab->Lanes().size())) - { - return false; - } - Lane& lane = m_tab->Lanes()[static_cast(laneIndex)]; - const float clamped = std::max(0.0f, scroll); - if (std::fabs(lane.edgeCenterScroll - clamped) < 0.5f) - { - return false; - } - // 滚动只影响面板内部像素,缓冲区/场景都不变(面板像素缓存按键失效即可)。 - lane.edgeCenterScroll = clamped; - return true; + bool PlotView::SetLaneEdgeCenterScroll(int laneIndex, float scroll) + { + return SetLanePanelScroll(laneIndex, EdgePanelKind::CENTER, scroll); } bool PlotView::SetLaneWeakPeakScroll(int laneIndex, float scroll) { - if (m_tab == nullptr || laneIndex < 0 || laneIndex >= static_cast(m_tab->Lanes().size())) - { - return false; - } - Lane& lane = m_tab->Lanes()[static_cast(laneIndex)]; - const float clamped = std::max(0.0f, scroll); - if (std::fabs(lane.weakPeakScroll - clamped) < 0.5f) - { - return false; - } - // 滚动只影响面板内部像素,缓冲区/场景都不变(面板像素缓存按键失效即可)。 - lane.weakPeakScroll = clamped; - return true; + return SetLanePanelScroll(laneIndex, EdgePanelKind::PEAK, scroll); } bool PlotView::SetLaneEdgeCountScroll(int laneIndex, float scroll) { - if (m_tab == nullptr || laneIndex < 0 || laneIndex >= static_cast(m_tab->Lanes().size())) - { - return false; - } - Lane& lane = m_tab->Lanes()[static_cast(laneIndex)]; - const float clamped = std::max(0.0f, scroll); - if (std::fabs(lane.edgeCountScroll - clamped) < 0.5f) - { - return false; - } - // 滚动只影响面板内部像素,缓冲区/场景都不变(面板像素缓存按键失效即可)。 - lane.edgeCountScroll = clamped; - return true; + return SetLanePanelScroll(laneIndex, EdgePanelKind::COUNT, scroll); } bool PlotView::ToggleLaneFreeMarkerGroup(int laneIndex, const SignalRef& ref) diff --git a/Components/PlotView/Src/PlotViewInteraction.cpp b/Components/PlotView/Src/PlotViewInteraction.cpp index e3782dc..5692f56 100644 --- a/Components/PlotView/Src/PlotViewInteraction.cpp +++ b/Components/PlotView/Src/PlotViewInteraction.cpp @@ -1819,33 +1819,9 @@ namespace jdi layout.scrollTrack.Y + travel)); const float ratio = (thumbTop - layout.scrollTrack.Y) / travel; const float target = ratio * maxScroll; - bool scrolled = false; - // ⚠️ 穷举所有面板种类且**不写 `default:`**:将来新增面板而忘登记时, - // `-Wswitch`(属 `-Wall`)会直接报错,而不是静默落到其它面板上。 - // (「自由标记」在上面的独立分支处理,不会到达这里。) - switch (m_meanDragKind) - { - case EdgePanelKind::COUNT: - scrolled = SetLaneEdgeCountScroll(m_markerScrollDragLane, target); - break; - case EdgePanelKind::CENTER: - scrolled = SetLaneEdgeCenterScroll(m_markerScrollDragLane, target); - break; - case EdgePanelKind::MEAN: - scrolled = SetLaneMeanFilterScroll(m_markerScrollDragLane, target); - break; - case EdgePanelKind::PEAK: - scrolled = SetLaneWeakPeakScroll(m_markerScrollDragLane, target); - break; - case EdgePanelKind::MARK: - break; - case EdgePanelKind::RESAMPLE: - scrolled = SetLaneResampleScroll(m_markerScrollDragLane, target); - break; - case EdgePanelKind::CLIP: - scrolled = SetLaneClipScroll(m_markerScrollDragLane, target); - break; - } + // ⚠️ 唯一写入入口(`SetLanePanelScroll`):旧实现是一条 7 分支 `switch`, + // 新增面板漏登记时拖动滚动条毫无反应(而滚轮那条链可能已经加上了)。 + const bool scrolled = SetLanePanelScroll(m_markerScrollDragLane, m_meanDragKind, target); if (scrolled) { m_dragMoved = true; @@ -3566,30 +3542,10 @@ namespace jdi const float current = LanePanelScrollOf(wheelLane, hoveredKind); const float next = current - static_cast(notches * 40.0 * m_scale); const float target = std::max(0.0f, std::min(next, maxScroll)); - switch (hoveredKind) - { - case EdgePanelKind::CENTER: - SetLaneEdgeCenterScroll(panelLane, target); - break; - case EdgePanelKind::PEAK: - SetLaneWeakPeakScroll(panelLane, target); - break; - case EdgePanelKind::COUNT: - SetLaneEdgeCountScroll(panelLane, target); - break; - case EdgePanelKind::RESAMPLE: - SetLaneResampleScroll(panelLane, target); - break; - case EdgePanelKind::CLIP: - SetLaneClipScroll(panelLane, target); - break; - case EdgePanelKind::MEAN: - SetLaneMeanFilterScroll(panelLane, target); - break; - case EdgePanelKind::MARK: - // 「自由标记」由下面的独立分支处理(它的内容高度来自布局)。 - break; - } + // ⚠️ 写入也走**同一个**入口(`SetLanePanelScroll`):旧实现这里是 + // 一条 7 分支 `switch`,与上面的读取各记一份映射。两处不同步就会 + // "读到了 A 面板的偏移、写进了 B 面板"(滚动条跳回顶端 / 滚不动)。 + SetLanePanelScroll(panelLane, hoveredKind, target); return true; } } diff --git a/Components/PlotView/Src/PlotViewPaint.cpp b/Components/PlotView/Src/PlotViewPaint.cpp index 541d80d..d3581de 100644 --- a/Components/PlotView/Src/PlotViewPaint.cpp +++ b/Components/PlotView/Src/PlotViewPaint.cpp @@ -4892,19 +4892,30 @@ namespace jdi //(`baseLayer + 1 + 面板序数`,面板序数按 `m_panelOrder` 底 → 顶)。 // 少了这一步,下层面板的文字框会记在比上层面板更低的层里被反复挖洞/穿透。 UiSetTextLayer(panelTextLayer + static_cast(panelOrdinal)); + // ⚠️ 排版类别只从唯一来源取(`PanelStyleOf`):过去宽度 / 布局 / 本函数各写一份 + // 6 个 `kind == XXX` + 3 条组合判断,新增面板漏改一处就错位(见该函数注释)。 + const PanelStyle panelStyle = PanelStyleOf(kind); const bool centerAxis = (kind == EdgePanelKind::CENTER); const bool meanFilter = (kind == EdgePanelKind::MEAN); const bool weakPeak = (kind == EdgePanelKind::PEAK); const bool freeMarker = (kind == EdgePanelKind::MARK); const bool resamplePanel = (kind == EdgePanelKind::RESAMPLE); const bool clipPanel = (kind == EdgePanelKind::CLIP); - // ⚠️ 「均值滤波」「中心轴边沿计数」「弱峰调整」「重采样」「选区截取」= 可滚动的分组列表 - //(条目栏 + 展开后的参数行); - // **「边沿计数」= 表格**(表头:信号名 / 上升沿计数 / 下降沿计数),单独一支。 - // 它们都**不能**再走旧的逐条目分支:那个分支没有任何裁切,滚动时会画到标题栏上。 - const bool listStyle = (meanFilter || centerAxis || weakPeak || resamplePanel || clipPanel); - const bool countTable = (kind == EdgePanelKind::COUNT); - const bool hasButton = (meanFilter || weakPeak || resamplePanel || clipPanel); + const bool listStyle = panelStyle.listRows; + const bool countTable = panelStyle.countTable; + const bool hasButton = panelStyle.buildButton; + // 内容区滚动条:三个排版分支(表格 / 列表 / 自由标记)共用同一段绘制。 + // ⚠️ 过去这段 6 行代码在三个分支里各抄一份,任何外观微调(颜色 / 圆角)都要改三处。 + const auto paintPanelScrollBar = [this, &g](const EdgePanelLayout& panelLayout) { + if (panelLayout.scrollTrack.Width <= 0.0f) + { + return; + } + UiFillRoundRect(g, panelLayout.scrollTrack, panelLayout.scrollTrack.Width * 0.5f, + WithAlpha(m_theme->border, 0x88)); + UiFillRoundRect(g, panelLayout.scrollThumb, panelLayout.scrollThumb.Width * 0.5f, + m_theme->borderStrong); + }; { if (index >= count) { @@ -5230,13 +5241,7 @@ namespace jdi g.Restore(tableSave); // 内容滚动条(只在内容超高时出现;表头不参与滚动)。 - if (layout.scrollTrack.Width > 0.0f) - { - UiFillRoundRect(g, layout.scrollTrack, layout.scrollTrack.Width * 0.5f, - WithAlpha(m_theme->border, 0x88)); - UiFillRoundRect(g, layout.scrollThumb, layout.scrollThumb.Width * 0.5f, - m_theme->borderStrong); - } + paintPanelScrollBar(layout); } // 「均值滤波」面板:逐信号的**信号色叠底标题栏**(左侧展开三角 + 信号名 + @@ -5757,13 +5762,7 @@ namespace jdi // ⚠️ 必须在 `g.Restore(meanSave)` **之后**画:滚动条位于内容区右侧、在 // 内容裁剪区之外,若在裁剪内画会被整条裁掉(用户 2026-10-30 报告 // "全部启用后没有出现滚动条"的根因)。 - if (layout.scrollTrack.Width > 0.0f) - { - UiFillRoundRect(g, layout.scrollTrack, layout.scrollTrack.Width * 0.5f, - WithAlpha(m_theme->border, 0x88)); - UiFillRoundRect(g, layout.scrollThumb, layout.scrollThumb.Width * 0.5f, - m_theme->borderStrong); - } + paintPanelScrollBar(layout); } // 「自由标记」面板:按信号分组的**分组栏**(信号色托底 + 三角 + 名称 + // 「N 标记点」+ ✕),下属固定点条目六列对齐(序号 / 取值 / 枚举 / 增量 / 时刻 / 间隔), @@ -6073,13 +6072,7 @@ namespace jdi g.Restore(contentState); // ---- 内容滚动条(只在内容超高时出现;按钮与滑块不参与滚动)---- - if (layout.scrollTrack.Width > 0.0f) - { - UiFillRoundRect(g, layout.scrollTrack, layout.scrollTrack.Width * 0.5f, - WithAlpha(m_theme->border, 0x88)); - UiFillRoundRect(g, layout.scrollThumb, layout.scrollThumb.Width * 0.5f, - m_theme->borderStrong); - } + paintPanelScrollBar(layout); // ---- 「标记透明度」滑动条(固定在「自由标记点选取」按钮上方)---- if (layout.opacityTrack.Width > 0.0f) diff --git a/Components/PlotView/Src/PlotViewPanels.cpp b/Components/PlotView/Src/PlotViewPanels.cpp index 307c145..5cb0d09 100644 --- a/Components/PlotView/Src/PlotViewPanels.cpp +++ b/Components/PlotView/Src/PlotViewPanels.cpp @@ -17,6 +17,29 @@ namespace jdi { + std::wstring PlotView::PanelTitle(EdgePanelKind kind) const + { + // ⚠️ 穷举且不写 `default:`(`-Wswitch` 会在新增面板时报警)。 + switch (kind) + { + case EdgePanelKind::COUNT: + return std::wstring(L"边沿计数") + EdgeRangeSuffix(); + case EdgePanelKind::CENTER: + return std::wstring(L"中心轴边沿计数") + EdgeRangeSuffix(); + case EdgePanelKind::MEAN: + return std::wstring(L"均值滤波"); + case EdgePanelKind::PEAK: + return std::wstring(kWeakPeakTitle); + case EdgePanelKind::MARK: + return std::wstring(L"自由标记"); + case EdgePanelKind::RESAMPLE: + return std::wstring(kResampleTitle); + case EdgePanelKind::CLIP: + return std::wstring(kClipTitle); + } + return std::wstring(); + } + float PlotView::EdgePanelWidth(int laneIndex, EdgePanelKind kind) const { if (m_tab == nullptr || m_fonts == nullptr || laneIndex < 0 || @@ -25,28 +48,18 @@ namespace jdi return kEdgePanelWidth * m_scale; } const Lane& lane = m_tab->Lanes()[static_cast(laneIndex)]; + // ⚠️ 排版类别与标题都只从唯一来源取(见 `PanelStyleOf` / `PanelTitle`): + // 过去这三份分类布尔量在宽度 / 布局 / 绘制里各写一遍,新增面板漏改一处就错位。 + const PanelStyle panelStyle = PanelStyleOf(kind); const bool centerAxis = (kind == EdgePanelKind::CENTER); - const bool meanFilter = (kind == EdgePanelKind::MEAN); const bool weakPeak = (kind == EdgePanelKind::PEAK); const bool resamplePanel = (kind == EdgePanelKind::RESAMPLE); const bool clipPanel = (kind == EdgePanelKind::CLIP); - // 「均值滤波」「中心轴边沿计数」「弱峰调整」「重采样」「选区截取」= 可滚动的分组列表 - //(信号色叠底条目栏 + 右侧复选框 + 展开后的参数行); - // **「边沿计数」= 表格**(表头:信号名 / 上升沿计数 / 下降沿计数)。 - // ⚠️ 旧式「逐条目」排版(无滚动的名称+参数行)已彻底删除:上百个信号时它会算出 - // 上万像素高的面板,超出绘图区后**面板像素缓存一次都存不进去** ⇒ 每帧整块重画。 - const bool listStyle = (centerAxis || meanFilter || weakPeak || resamplePanel || clipPanel); - const bool countTable = (kind == EdgePanelKind::COUNT); + const bool listStyle = panelStyle.listRows; + const bool countTable = panelStyle.countTable; - // 标题行 = “GX” 徽标 + 标题(“边沿计数” / “中心轴边沿计数” / “均值滤波” / “弱峰调整” - // + 光标区间尾缀)。 - const std::wstring title = - meanFilter ? std::wstring(L"均值滤波") - : (weakPeak ? std::wstring(kWeakPeakTitle) - : (resamplePanel - ? std::wstring(kResampleTitle) - : ((centerAxis ? L"中心轴边沿计数" : L"边沿计数") + - EdgeRangeSuffix()))); + // 标题行 = “GX” 徽标 + 标题(+ 双光标区间尾缀)。 + const std::wstring title = PanelTitle(kind); float width = kEdgePanelPad * 2.0f * m_scale + kLaneBadgeWidth * m_scale + 8.0f * m_scale + UiMeasureTextWidth(m_fonts->uiSmall, title) + kEdgePanelTitleHeight * m_scale; @@ -214,7 +227,7 @@ namespace jdi width = std::max(width, kEdgePanelPad * 2.0f * m_scale + rowWidth); } - if (meanFilter || weakPeak || resamplePanel) + if (panelStyle.buildButton) { // 底部按钮行 = 「构建为新信号」(均值滤波 / 弱峰调整 / 重采样有),按按钮文字实测宽度确定下限。 const float buttonWidth = @@ -330,19 +343,16 @@ namespace jdi return layout; } const Lane& lane = m_tab->Lanes()[static_cast(laneIndex)]; + // ⚠️ 排版类别只从唯一来源取(`PanelStyleOf`)—— 过去宽度 / 布局 / 绘制各写一份。 + const PanelStyle panelStyle = PanelStyleOf(kind); const bool centerAxis = (kind == EdgePanelKind::CENTER); - const bool meanFilter = (kind == EdgePanelKind::MEAN); const bool weakPeak = (kind == EdgePanelKind::PEAK); const bool freeMarker = (kind == EdgePanelKind::MARK); const bool resamplePanel = (kind == EdgePanelKind::RESAMPLE); const bool clipPanel = (kind == EdgePanelKind::CLIP); - // 「均值滤波」「中心轴边沿计数」「弱峰调整」「重采样」「选区截取」共用同一套 - //**可滚动分组列表**排布(信号色叠底条目栏 + 展开后的参数行); - //「边沿计数」自 2026-11-02 起走**表格**分支。 - //⚠️ 「选区截取」的条目**不可展开**(只需右侧复选框),因此只占一行。 - const bool listStyle = (meanFilter || centerAxis || weakPeak || resamplePanel || clipPanel); - const bool countTable = (kind == EdgePanelKind::COUNT); - const bool hasButton = (meanFilter || weakPeak || resamplePanel || clipPanel); + const bool listStyle = panelStyle.listRows; + const bool countTable = panelStyle.countTable; + const bool hasButton = panelStyle.buildButton; // ⚠️ 面板的「启用 / 展开 / 折起」状态一律通过 `LanePanelEnabled` / // `LanePanelStateOf` 取值(见 `PlotViewInternal.hpp` 的说明): // 旧实现是三条 7 层嵌套三元式,新增面板时极容易漏改或写错括号。 @@ -377,7 +387,8 @@ namespace jdi const float pad = kEdgePanelPad * m_scale; // 「中心轴边沿计数」「均值滤波」「弱峰调整」共用同一套**可滚动列表**排布, // 因此不再有独立的底部编辑行;「边沿计数」走独立的**表格**分支。 - const float buttonHeight = hasButton ? (kEdgePanelButtonHeight * m_scale) : 0.0f; + const float buttonHeight = + panelStyle.footerButtons() ? (kEdgePanelButtonHeight * m_scale) : 0.0f; // 面板底部(内容区之下)占的纵向空间:有按钮时 = pad + 按钮 + pad,无按钮时只有 pad。 // ⚠️⚠️ 「选区截取」的两个按钮(截取为新信号 / 原始信号截取)是**并排在同一行**的 // (见下方的几何赋值),因此 footer 只能按**一行**算。旧实现按 `2 * 按钮高 + 6` @@ -660,30 +671,21 @@ namespace jdi const float scrollW = kFreeMarkerScrollWidth * m_scale; const float viewHeight = std::max(0.0f, height - titleHeight - pad - tableHeaderHeight - pad); - const bool scrollable = meanContentHeight > viewHeight + 0.5f; - const float maxScroll = std::max(0.0f, meanContentHeight - viewHeight); - const float scroll = std::max(0.0f, std::min(lane.edgeCountScroll, maxScroll)); - const float innerWidth = - std::max(0.0f, width - pad * 2.0f - (scrollable ? (scrollW + 4.0f * m_scale) : 0.0f)); + // 滚动几何走唯一实现(见 `PanelScrollMetricsFor`)。 + const PanelScrollMetrics scrollMetrics = + PanelScrollMetricsFor(left, top + titleHeight + pad + tableHeaderHeight, viewHeight, + meanContentHeight, LanePanelScrollOf(lane, kind), width - pad * 2.0f, + pad, scrollW, m_scale); + const float innerWidth = scrollMetrics.innerWidth; layout.tableHeader = gd::RectF(left + pad, top + titleHeight + pad, innerWidth, tableHeaderHeight); layout.content = gd::RectF(left + pad, layout.tableHeader.Y + tableHeaderHeight, innerWidth, viewHeight); layout.markerContentHeight = meanContentHeight; - layout.scrollOffset = scroll; - if (scrollable) - { - // 滚动条只占**内容区**(不覆盖固定的表头,表头永远干净可读)。 - layout.scrollTrack = - gd::RectF(left + pad + innerWidth + 4.0f * m_scale, layout.content.Y, scrollW, - viewHeight); - const float thumbHeight = - std::max(18.0f * m_scale, viewHeight * (viewHeight / meanContentHeight)); - const float thumbTop = - layout.content.Y + - (viewHeight - thumbHeight) * ((maxScroll > 0.0f) ? (scroll / maxScroll) : 0.0f); - layout.scrollThumb = gd::RectF(layout.scrollTrack.X, thumbTop, scrollW, thumbHeight); - } + layout.scrollOffset = scrollMetrics.scroll; + // 滚动条只占**内容区**(不覆盖固定的表头,表头永远干净可读)。 + layout.scrollTrack = scrollMetrics.track; + layout.scrollThumb = scrollMetrics.thumb; // 三列:名称列吃掉剩余宽度,两列计数按内容宽度固定 —— 表头与数据行严格对齐。 const float gap = kEdgePanelStatColumnGap * m_scale; const float contentLeft = layout.content.X; @@ -723,7 +725,7 @@ namespace jdi layout.tableColumns[1].width = risingCol; layout.tableColumns[2].left = layout.tableColumns[1].left + risingCol + gap; layout.tableColumns[2].width = fallingCol; - float rowY = layout.content.Y - scroll; + float rowY = layout.content.Y - scrollMetrics.scroll; for (const SignalRef& ref : lane.signals) { const ResolvedSignal resolved = ResolveSignal(ref); @@ -792,31 +794,20 @@ namespace jdi // 条目区下方与按钮之间留出一片空白(用户 2026-11-03 两次报告)。 const float viewHeight = std::max(0.0f, height - titleHeight - pad - bottomPad - footerHeight); - const bool scrollable = meanContentHeight > viewHeight + 0.5f; - const float maxScroll = std::max(0.0f, meanContentHeight - viewHeight); - const float storedScroll = meanFilter ? lane.meanFilterScroll - : (weakPeak ? lane.weakPeakScroll - : (resamplePanel ? lane.resampleScroll - : (clipPanel ? lane.clipScroll - : lane.edgeCenterScroll))); - const float scroll = std::max(0.0f, std::min(storedScroll, maxScroll)); - const float innerWidth = - std::max(0.0f, width - pad * 2.0f - (scrollable ? (scrollW + 4.0f * m_scale) : 0.0f)); + // ⚠️ 滚动量一律走 `LanePanelScrollOf(lane, kind)`(唯一派发点): + // 过去这里是一条 5 层三元式(均值/弱峰/重采样/截取/中心轴), + // 而「边沿计数」不在其中 —— 它与 `LanePanelScrollOf` 各记一份映射, + // 新增面板时极易只改一处(滚轮能滚、拖动滚动条滚不动之类的错位)。 + const PanelScrollMetrics scrollMetrics = + PanelScrollMetricsFor(left, top + titleHeight + pad, viewHeight, meanContentHeight, + LanePanelScrollOf(lane, kind), width - pad * 2.0f, pad, scrollW, + m_scale); + const float innerWidth = scrollMetrics.innerWidth; layout.content = gd::RectF(left + pad, top + titleHeight + pad, innerWidth, viewHeight); layout.markerContentHeight = meanContentHeight; - layout.scrollOffset = scroll; - if (scrollable) - { - layout.scrollTrack = - gd::RectF(left + pad + innerWidth + 4.0f * m_scale, layout.content.Y, scrollW, - viewHeight); - const float thumbHeight = - std::max(18.0f * m_scale, viewHeight * (viewHeight / meanContentHeight)); - const float thumbTop = - layout.content.Y + - (viewHeight - thumbHeight) * ((maxScroll > 0.0f) ? (scroll / maxScroll) : 0.0f); - layout.scrollThumb = gd::RectF(layout.scrollTrack.X, thumbTop, scrollW, thumbHeight); - } + layout.scrollOffset = scrollMetrics.scroll; + layout.scrollTrack = scrollMetrics.track; + layout.scrollThumb = scrollMetrics.thumb; // 两行滑块的列宽(与 `EdgePanelWidth` 同一组算式 ⇒ 布局与宽度不会漂移)。 const float labelColumn = @@ -846,7 +837,7 @@ namespace jdi std::max(10.0f * m_scale, (resetLeft - resetGap - valueColumn - 6.0f * m_scale) - trackLeft); - float rowY = layout.content.Y - scroll; + float rowY = layout.content.Y - scrollMetrics.scroll; // ⚠️ 「选区截取」在**未启用双光标**时不生成任何信号行:面板只显示一句居中提示 // (用户 2026-11-03 明确要求:不应显示信号名条目、更不应能滚动)。 const bool clipRowsVisible = !clipPanel || clipReadyHere; @@ -1065,26 +1056,18 @@ namespace jdi { totalContent += groupRow; } - const bool scrollable = totalContent > viewHeight + 0.5f; - const float maxScroll = std::max(0.0f, totalContent - viewHeight); - const float scroll = std::max(0.0f, std::min(lane.freeMarkerScroll, maxScroll)); - const float innerWidth = - std::max(0.0f, width - pad * 2.0f - (scrollable ? (scrollW + 4.0f * m_scale) : 0.0f)); + // 滚动几何走唯一实现(与表格 / 列表分支同一套算式)。 + const PanelScrollMetrics scrollMetrics = + PanelScrollMetricsFor(left, top + titleHeight + pad, viewHeight, totalContent, + LanePanelScrollOf(lane, kind), width - pad * 2.0f, pad, scrollW, + m_scale); + const float innerWidth = scrollMetrics.innerWidth; layout.content = gd::RectF(left + pad, top + titleHeight + pad, innerWidth, viewHeight); layout.markerContentHeight = totalContent; - if (scrollable) - { - layout.scrollTrack = - gd::RectF(left + pad + innerWidth + 4.0f * m_scale, layout.content.Y, scrollW, - viewHeight); - const float thumbHeight = - std::max(18.0f * m_scale, viewHeight * (viewHeight / totalContent)); - const float thumbTop = - layout.content.Y + - (viewHeight - thumbHeight) * ((maxScroll > 0.0f) ? (scroll / maxScroll) : 0.0f); - layout.scrollThumb = gd::RectF(layout.scrollTrack.X, thumbTop, scrollW, thumbHeight); - } + layout.scrollTrack = scrollMetrics.track; + layout.scrollThumb = scrollMetrics.thumb; + const float scroll = scrollMetrics.scroll; // 六列(① 序号 ② 标记值 ③ 枚举文本 ④ 增量 ⑤ 时间点 ⑥ 时间间隔) // 从左往右排布:每一行的同类信息都从同一横坐标开始 ⇒ 纵向上严格对齐(用户要求)。 diff --git a/Tools/OffscreenChecks/Src/Checks.cpp b/Tools/OffscreenChecks/Src/Checks.cpp index 0e3dad8..e4e9698 100644 --- a/Tools/OffscreenChecks/Src/Checks.cpp +++ b/Tools/OffscreenChecks/Src/Checks.cpp @@ -477,6 +477,124 @@ namespace jdi return CheckFailed(); } + /** + * @brief 面板**内容滚动**的行为检查(每一类面板都要能被滚动,且几何随之一致移动)。 + * + * 为什么单独一项:面板滚动量过去由 7 个近乎重复的设置函数 + 2 条按种类分派的 + * `switch`/`if` 链共同维护,历史上出现过"滚轮能滚、拖滚动条滚不动"与 + * "读的是 A 面板的偏移、写进了 B 面板"。本检查对 7 种面板逐一: + * 1. 取布局 → 若可滚动,记录初始 `scrollOffset`; + * 2. 通过**统一入口** `SetLanePanelScroll` 滚到底; + * 3. 断言 `scrollOffset` 等于 `maxScroll`、滑块位置真的移到了轨道底部; + * 4. 再滚回顶部,断言滑块回到轨道顶部。 + */ + int CheckPanelScroll(Session& session, const CheckOptions& options) + { + Application& app = session.App(); + PlotView& plot = app.m_plot; + + // 让面板内容**必定超高**:显示全部信号并合并成一个网格 + //(真实场景见历史缺陷:283 条信号合并到同一网格、开实验性功能)。 + int totalSignals = 0; + for (int groupIndex = 0; groupIndex < static_cast(app.m_dataSet.GroupCount()); ++groupIndex) + { + const SignalGroup* group = app.m_dataSet.GroupAt(groupIndex); + if (group != nullptr) + { + totalSignals += static_cast(group->signals.size()); + } + } + session.ShowFirstSignals(0); + if (session.LaneCount() > 1) + { + app.ExecuteToolbarAction(ToolbarAction::MERGE_LANES); + } + EmitLine("[panelscroll] signals=%d lanes=%d\n", totalSignals, session.LaneCount()); + Expect(totalSignals > 0, "loaded at least one signal"); + + if (app.ActiveTab().CursorMode() != PlotCursorMode::DOUBLE) + { + for (int step = 0; step < 4 && app.ActiveTab().CursorMode() != PlotCursorMode::DOUBLE; ++step) + { + plot.CycleCursorMode(); + } + } + + static const EdgePanelKind kinds[] = {EdgePanelKind::COUNT, EdgePanelKind::CENTER, + EdgePanelKind::MEAN, EdgePanelKind::PEAK, + EdgePanelKind::MARK, EdgePanelKind::RESAMPLE, + EdgePanelKind::CLIP}; + for (const EdgePanelKind kind : kinds) + { + EnsureLaneFeature(plot, 0, kind); + } + session.RenderFrame(); + + int scrollableKinds = 0; + for (const EdgePanelKind kind : kinds) + { + const char* name = PanelKindName(kind); + const PlotView::EdgePanelLayout initial = plot.EdgePanelLayoutFor(0, kind); + const float maxScroll = + std::max(0.0f, initial.markerContentHeight - initial.content.Height); + EmitLine("[panelscroll] %-8s content=%.1f view=%.1f maxScroll=%.1f scrollable=%d\n", name, + initial.markerContentHeight, initial.content.Height, maxScroll, + initial.scrollTrack.Width > 0.0f ? 1 : 0); + + if (maxScroll <= 1.0f) + { + // 内容没超出可视高度(例如单信号文件):不可滚动是正确行为。 + Expect(initial.scrollTrack.Width <= 0.0f, + std::string("no scrollbar when content fits: ") + name); + ExpectNear(initial.scrollOffset, 0.0, 0.5, + std::string("scroll offset stays 0: ") + name); + continue; + } + ++scrollableKinds; + + Expect(initial.scrollTrack.Width > 0.0f, + std::string("scrollbar appears when content overflows: ") + name); + + // 滚到底 + Expect(plot.SetLanePanelScroll(0, kind, maxScroll + 500.0f), + std::string("set scroll to bottom: ") + name); + const PlotView::EdgePanelLayout bottom = plot.EdgePanelLayoutFor(0, kind); + ExpectNear(bottom.scrollOffset, maxScroll, 0.5, + std::string("scroll offset clamps to maxScroll: ") + name); + const float travel = initial.scrollTrack.Height - initial.scrollThumb.Height; + ExpectNear(bottom.scrollThumb.Y, initial.scrollTrack.Y + travel, 1.0, + std::string("thumb reaches track bottom: ") + name); + // 内容区几何不能随滚动改变 + ExpectNear(bottom.content.Y, initial.content.Y, 0.01, + std::string("content area stays put when scrolling: ") + name); + + // 滚回顶部 + Expect(plot.SetLanePanelScroll(0, kind, -100.0f), + std::string("set scroll back to top: ") + name); + const PlotView::EdgePanelLayout top = plot.EdgePanelLayoutFor(0, kind); + ExpectNear(top.scrollOffset, 0.0, 0.5, std::string("scroll offset back to 0: ") + name); + ExpectNear(top.scrollThumb.Y, initial.scrollTrack.Y, 1.0, + std::string("thumb back to track top: ") + name); + } + + EmitLine("[panelscroll] scrollable kinds: %d / %d\n", scrollableKinds, + static_cast(sizeof(kinds) / sizeof(kinds[0]))); + // 用大文件(如 MF4-Record.MF4,283 信号)时必须至少有一类面板真的能滚: + // 否则这条检查会退化成"全是 0 == 0"的空转。 + if (totalSignals >= 50) + { + Expect(scrollableKinds > 0, "with many signals at least one panel is scrollable"); + } + + session.RenderFrame(); + if (options.savePng) + { + Expect(SavePng(session.LastFrame(), Join(options.outDir, "panelscroll.png")), + "save panelscroll.png"); + } + return CheckFailed(); + } + int CheckEdgeStats(Session& session, const CheckOptions& options) { session.ShowFirstSignals(1); @@ -756,6 +874,8 @@ namespace jdi {"smoke", "offscreen init + load + render + determinism + save png", CheckSmoke}, {"frames", "capture consecutive frames while moving mouse (png + hash + timing)", CheckFrames}, {"panels", "all 7 experiment panels: geometry invariants + pixel baseline A/B", CheckPanels}, + {"panelscroll", "panel content scrolling: every kind scrolls, clamps, and keeps content fixed", + CheckPanelScroll}, {"edgestats", "edge counts vs independent reference implementation", CheckEdgeStats}, {"perf", "render / panel layout / edge query timings", CheckPerf}, }; -- 2.54.0 From 34365b3c1c43e46b28bc79c6aeaf46f2f747200b Mon Sep 17 00:00:00 2001 From: "DeepSeek V4.1 Flash" <> Date: Wed, 4 Nov 2026 14:31:02 +0800 Subject: [PATCH 12/22] =?UTF-8?q?perf(size):=20=E7=94=A8=E8=87=AA=E5=86=99?= =?UTF-8?q?=E6=95=B4=E6=95=B0=E6=A0=BC=E5=BC=8F=E5=8C=96=E6=9B=BF=E6=8D=A2?= =?UTF-8?q?=E5=85=A8=E9=83=A8=20std::to=5Fwstring=20/=20std::to=5Fstring?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 背景:Code Review 报告 §10.3 / §11.5 建议「替换 std::to_wstring / std::to_string, 可去掉 gdtoa 链(≈33 KB)」。本轮把它做完,并给出这条建议的**真实边界**。 一、新增 `Utf8Util::FormatInteger*`(手写十进制展开,不经 printf) * `FormatInteger(long long)` / `FormatIntegerUnsigned(unsigned long long)` / `FormatIntegerNarrow(long long)`(后者供 MatLoader 拼 HDF5 成员路径)。 * 语义与 `std::to_wstring` / `std::to_string` 完全一致:`0` → `"0"`、负数带 `-`、 `LLONG_MIN` 用模 2^64 语义取绝对值(不会溢出)。 * 这样做的直接理由:预编译 libstdc++ 的 `to_string` 内部转调 `__mingw_swprintf`, 工程里只要留一处,MinGW 的 printf 引擎(`mingw_wpformat.o`)+ gdtoa 就必然被链入。 二、全仓替换(76 处 `to_wstring` + 2 处 `to_string`,20 个文件) * `MatLoader.cpp` 的 `TrimNumber` 整数分支也改为 `FormatInteger`(它曾是产物里 **最后一个**引用 MinGW printf 的调用点)。 * 需要的地方补 `#include "Utf8Util.hpp"`(仅 `MatLoader.cpp` 一个新 include,其余由 `PlotViewInternal` / `SignalPanelInternal` / `ApplicationInternal` / `DataModelInternal` 已经包含)。 三、⚠️ 实测结论:gdtoa **去不掉**(把这件事彻底查清并写进 Makefile 注释) * 只替换 `to_wstring`/`to_string`:**−1,024 B**(787,968 → 786,944)。 * 再叠加 `-D__USE_MINGW_ANSI_STDIO=0`(把 `swprintf` 让给系统 UCRT):**0 字节**。 * 根因(定位到文件与行):libstdc++ 的 `lib/gcc/.../include/c++/x86_64-w64-mingw32/bits/os_defines.h:48-51` **无条件**执行 `#undef __USE_MINGW_ANSI_STDIO` + `#define __USE_MINGW_ANSI_STDIO 1` (注释:libstdc++ depends on POSIX-definitions of those functions)。 `g++ -E -dM` 实测最终值恒为 1 ⇒ **任何 C++ TU 的 `swprintf` / `strtod` 都是 MinGW 版本**, 命令行无法覆盖。 * 要真省这 33 KB 只能自己实现「浮点 → 十进制(正确舍入)」与「浮点解析的 十六进制/inf/nan 分支」,等价于重写 gdtoa。**判定:不做**(风险 >> 收益)。 * 该开关已回退,Makefile 里保留一段说明,避免后续接手者重复这两次实验。 四、验证 * 新增单元测试 `integer_replacement_matches_std`:12 个有符号样本(含 LLONG_MIN/MAX) + 5 个无符号样本(含 ULLONG_MAX),与 `std::to_wstring` **逐字符对照**, 并校验窄/宽版本内容一致。 * `UnitTests`:`checks=260 failed=0 cases=19`(新增 41 条断言)。 * `OffscreenChecks -all`:6 个检查全 PASS,`checks=87 failed=0`。 * `panels -mode compare`:7 个面板像素哈希与几何**逐项不变** (整数文本从 `to_wstring` 换到手写实现后,渲染结果零差异 —— 这是最强的语义证据)。 * release 全量零 error / 零 warning;产物 787,968 → **786,944 B**。 --- Components/Application/Src/Application.cpp | 6 +- .../Application/Src/ApplicationCommands.cpp | 4 +- .../Application/Src/ApplicationDerive.cpp | 22 +++---- .../Application/Src/ApplicationLoading.cpp | 8 +-- .../Application/Src/ApplicationMenus.cpp | 4 +- Components/PlotView/Inc/PlotViewInternal.hpp | 6 +- .../PlotView/Src/PlotViewInteraction.cpp | 22 +++---- Components/PlotView/Src/PlotViewPaint.cpp | 26 ++++---- Components/Shared/Src/ColorPickerPopup.cpp | 4 +- Components/SignalPanel/Src/SignalPanel.cpp | 4 +- .../SignalPanel/Src/SignalPanelEdit.cpp | 2 +- .../SignalPanel/Src/SignalPanelMenu.cpp | 6 +- .../SignalPanel/Src/SignalPanelPaint.cpp | 10 +-- Makefile | 33 +++++++--- Tools/UnitTests/Src/Utf8UtilTests.cpp | 49 ++++++++++++++ Utilities/Inc/DataModelInternal.hpp | 2 +- Utilities/Inc/Utf8Util.hpp | 34 ++++++++++ Utilities/Src/CsvLoader.cpp | 2 +- Utilities/Src/DataModel.cpp | 2 +- Utilities/Src/DataModelTab.cpp | 6 +- Utilities/Src/MatLoader.cpp | 20 +++--- Utilities/Src/Mf4Loader.cpp | 12 ++-- Utilities/Src/Utf8Util.cpp | 66 ++++++++++++++++++- 23 files changed, 256 insertions(+), 94 deletions(-) diff --git a/Components/Application/Src/Application.cpp b/Components/Application/Src/Application.cpp index b31b6f0..9f634a3 100644 --- a/Components/Application/Src/Application.cpp +++ b/Components/Application/Src/Application.cpp @@ -265,7 +265,7 @@ namespace jdi // 取当前未被占用的最小编号,使关闭标签页后编号可以被复用(3 开 2 关再新建回到「视图 2」)。 for (int number = 1; number <= static_cast(m_tabs.size()) + 1; ++number) { - const std::wstring candidate = L"视图 " + std::to_wstring(number); + const std::wstring candidate = L"视图 " + FormatInteger(number); bool used = false; for (const TabConfig& tab : m_tabs) { @@ -280,7 +280,7 @@ namespace jdi return candidate; } } - return L"视图 " + std::to_wstring(m_tabs.size() + 1); + return L"视图 " + FormatInteger(m_tabs.size() + 1); } void Application::Relayout() @@ -1879,7 +1879,7 @@ namespace jdi if (loaded > 0) { - SetStatusMessage(L"已加载 " + std::to_wstring(loaded) + + SetStatusMessage(L"已加载 " + FormatInteger(loaded) + L" 个文件,请在左侧勾选需要查看的信号。", false); } diff --git a/Components/Application/Src/ApplicationCommands.cpp b/Components/Application/Src/ApplicationCommands.cpp index 4642e43..d4fc58c 100644 --- a/Components/Application/Src/ApplicationCommands.cpp +++ b/Components/Application/Src/ApplicationCommands.cpp @@ -90,7 +90,7 @@ namespace jdi } for (int suffix = 2; suffix < 1000; ++suffix) { - const std::wstring candidate = base + L" (" + std::to_wstring(suffix) + L")"; + const std::wstring candidate = base + L" (" + FormatInteger(suffix) + L")"; if (!taken(candidate)) { return candidate; @@ -319,7 +319,7 @@ namespace jdi if (loaded > 0) { - SetStatusMessage(L"已加载 " + std::to_wstring(loaded) + + SetStatusMessage(L"已加载 " + FormatInteger(loaded) + L" 个文件,请在左侧勾选需要查看的信号。", false); } diff --git a/Components/Application/Src/ApplicationDerive.cpp b/Components/Application/Src/ApplicationDerive.cpp index 424c1b4..e83b373 100644 --- a/Components/Application/Src/ApplicationDerive.cpp +++ b/Components/Application/Src/ApplicationDerive.cpp @@ -59,14 +59,14 @@ namespace jdi { ComputeWeakPeakAdjust(source.values, static_cast(request.amountPercent) / 100.0, request.iterations, derived_values); - derived.name = baseName + L" (弱峰调整 " + std::to_wstring(request.amountPercent) + L"% " + - std::to_wstring(request.iterations) + L"次迭代)"; + derived.name = baseName + L" (弱峰调整 " + FormatInteger(request.amountPercent) + L"% " + + FormatInteger(request.iterations) + L"次迭代)"; } else { ComputeMovingAverageIterated(source.values, request.points, request.iterations, derived_values); - derived.name = baseName + L" (" + std::to_wstring(request.points) + L"点均值 " + - std::to_wstring(request.iterations) + L"次迭代)"; + derived.name = baseName + L" (" + FormatInteger(request.points) + L"点均值 " + + FormatInteger(request.iterations) + L"次迭代)"; } // 2. 组装新信号(外观一律用默认值:变换已经烘进采样数据,不再是"继承的指纹")。 @@ -654,7 +654,7 @@ namespace jdi } if (built > 0) { - SetStatusMessage(L"已构建 " + std::to_wstring(built) + L" 条重采样信号(频率 " + + SetStatusMessage(L"已构建 " + FormatInteger(built) + L" 条重采样信号(频率 " + FormatFrequency(lastInterval > 0.0 ? 1.0 / lastInterval : 0.0) + L")。", false); @@ -692,7 +692,7 @@ namespace jdi if (built > 0) { SetStatusMessage((items.front().destructive ? L"已截取 " : L"已截取为新信号 ") + - std::to_wstring(built) + L" 条。", + FormatInteger(built) + L" 条。", false); } return built > 0; @@ -770,16 +770,16 @@ namespace jdi if (built > 0) { - std::wstring text = L"已构建 " + std::to_wstring(built) + L" 条 "; + std::wstring text = L"已构建 " + FormatInteger(built) + L" 条 "; if (last.weakPeak) { - text += L"弱峰调整 " + std::to_wstring(last.amountPercent) + L"% " + - std::to_wstring(last.iterations) + L"次迭代信号。"; + text += L"弱峰调整 " + FormatInteger(last.amountPercent) + L"% " + + FormatInteger(last.iterations) + L"次迭代信号。"; } else { - text += std::to_wstring(last.points) + L"点均值 " + - std::to_wstring(last.iterations) + L"次迭代滤波信号。"; + text += FormatInteger(last.points) + L"点均值 " + + FormatInteger(last.iterations) + L"次迭代滤波信号。"; } SetStatusMessage(text, false); } diff --git a/Components/Application/Src/ApplicationLoading.cpp b/Components/Application/Src/ApplicationLoading.cpp index 5b6da30..2833797 100644 --- a/Components/Application/Src/ApplicationLoading.cpp +++ b/Components/Application/Src/ApplicationLoading.cpp @@ -76,10 +76,10 @@ namespace jdi timeNames += axis.name; } std::wstring message = - L"已加载 组" + std::to_wstring(lastGroupIndex + 1) + L"|" + loadedGroup->name + + L"已加载 组" + FormatInteger(lastGroupIndex + 1) + L"|" + loadedGroup->name + L"(" + FormatCount(static_cast(rowCount)) + L" 行采样," + - std::to_wstring(loadedGroup->signals.size()) + L" 条信号," + - std::to_wstring(loadedGroup->axes.size()) + L" 条时间轴:" + timeNames + L")"; + FormatInteger(loadedGroup->signals.size()) + L" 条信号," + + FormatInteger(loadedGroup->axes.size()) + L" 条时间轴:" + timeNames + L")"; if (anyNonMonotonic) { message += L" 提示:时间列非单调递增,光标取值使用最近邻策略。"; @@ -280,7 +280,7 @@ namespace jdi if (loaded > 0) { - SetStatusMessage(L"已加载 " + std::to_wstring(loaded) + + SetStatusMessage(L"已加载 " + FormatInteger(loaded) + L" 个文件,请在左侧勾选需要查看的信号。", false); } diff --git a/Components/Application/Src/ApplicationMenus.cpp b/Components/Application/Src/ApplicationMenus.cpp index 8c19bf7..385fd0f 100644 --- a/Components/Application/Src/ApplicationMenus.cpp +++ b/Components/Application/Src/ApplicationMenus.cpp @@ -22,7 +22,7 @@ namespace jdi MenuItem header; header.kind = MenuItemKind::HEADER; - header.label = L"网格 " + std::to_wstring(laneIndex + 1); + header.label = L"网格 " + FormatInteger(laneIndex + 1); items.push_back(header); // 当前网格的 Y 轴上下限值直接在菜单内的输入框里显示,单击即可就地编辑(菜单不关闭)。 @@ -64,7 +64,7 @@ namespace jdi MenuItem splitLane; const std::size_t laneSignalCount = m_plot.LaneSignalCount(laneIndex); splitLane.label = (laneSignalCount > 1) - ? (L"拆分当前网格(" + std::to_wstring(laneSignalCount) + L"个信号)") + ? (L"拆分当前网格(" + FormatInteger(laneSignalCount) + L"个信号)") : L"拆分当前网格"; splitLane.id = PLOT_CMD_LANE_SPLIT; splitLane.enabled = laneSignalCount > 1; diff --git a/Components/PlotView/Inc/PlotViewInternal.hpp b/Components/PlotView/Inc/PlotViewInternal.hpp index 5f87656..c1990d9 100644 --- a/Components/PlotView/Inc/PlotViewInternal.hpp +++ b/Components/PlotView/Inc/PlotViewInternal.hpp @@ -753,7 +753,7 @@ namespace jdi */ inline std::wstring EdgeRisingText(std::size_t rising, bool complete) { - std::wstring text = L"上升沿 " + std::to_wstring(rising); + std::wstring text = L"上升沿 " + FormatInteger(rising); if (!complete) { text += L" …"; @@ -768,7 +768,7 @@ namespace jdi */ inline std::wstring EdgeFallingText(std::size_t falling, bool complete) { - std::wstring text = L"下降沿 " + std::to_wstring(falling); + std::wstring text = L"下降沿 " + FormatInteger(falling); if (!complete) { text += L" …"; @@ -792,7 +792,7 @@ namespace jdi */ inline std::wstring EdgeCountCellText(std::size_t value, bool complete) { - std::wstring text = std::to_wstring(value); + std::wstring text = FormatInteger(value); if (!complete) { text += L" …"; diff --git a/Components/PlotView/Src/PlotViewInteraction.cpp b/Components/PlotView/Src/PlotViewInteraction.cpp index 5692f56..c637426 100644 --- a/Components/PlotView/Src/PlotViewInteraction.cpp +++ b/Components/PlotView/Src/PlotViewInteraction.cpp @@ -2380,7 +2380,7 @@ namespace jdi m_dragMode = PlotDragMode::NONE; if (ScrollLaneIntoView(index)) { - SetHint(L"正在滚动到 G" + std::to_wstring(index + 1) + L" 网格…"); + SetHint(L"正在滚动到 G" + FormatInteger(index + 1) + L" 网格…"); } return true; } @@ -2554,7 +2554,7 @@ namespace jdi const ResampleBuild& first = m_pendingResampleItems.front(); SetHint(L"正在构建重采样信号(间隔 " + FormatIntervalText(first.interval) + L",共 " + - std::to_wstring(m_pendingResampleItems.size()) + L" 条)…"); + FormatInteger(m_pendingResampleItems.size()) + L" 条)…"); m_pendingResampleLane = index; } m_dragMode = PlotDragMode::NONE; @@ -2632,10 +2632,10 @@ namespace jdi { SetHint(onDestructive ? L"正在按选区截取原始信号(" + - std::to_wstring(m_pendingClipItems.size()) + + FormatInteger(m_pendingClipItems.size()) + L" 条,区间外数据将被丢弃)…" : L"正在按选区截取为新信号(" + - std::to_wstring(m_pendingClipItems.size()) + L" 条)…"); + FormatInteger(m_pendingClipItems.size()) + L" 条)…"); m_pendingClipLane = index; } m_dragMode = PlotDragMode::NONE; @@ -2991,9 +2991,9 @@ namespace jdi if (!m_pendingWeakPeakItems.empty()) { const WeakPeakBuild& first = m_pendingWeakPeakItems.front(); - SetHint(L"正在构建弱峰调整信号(幅度 " + std::to_wstring(first.amountPercent) + - L"% / " + std::to_wstring(first.iterations) + L" 次迭代,共 " + - std::to_wstring(m_pendingWeakPeakItems.size()) + L" 条)…"); + SetHint(L"正在构建弱峰调整信号(幅度 " + FormatInteger(first.amountPercent) + + L"% / " + FormatInteger(first.iterations) + L" 次迭代,共 " + + FormatInteger(m_pendingWeakPeakItems.size()) + L" 条)…"); m_pendingWeakPeakLane = index; } m_dragMode = PlotDragMode::NONE; @@ -3029,9 +3029,9 @@ namespace jdi if (!m_pendingMeanFilterItems.empty()) { const MeanFilterBuild& first = m_pendingMeanFilterItems.front(); - SetHint(L"正在构建均值滤波信号(" + std::to_wstring(first.points) + - L" 点 " + std::to_wstring(first.iterations) + L" 次迭代,共 " + - std::to_wstring(m_pendingMeanFilterItems.size()) + L" 条)…"); + SetHint(L"正在构建均值滤波信号(" + FormatInteger(first.points) + + L" 点 " + FormatInteger(first.iterations) + L" 次迭代,共 " + + FormatInteger(m_pendingMeanFilterItems.size()) + L" 条)…"); m_pendingMeanFilterLane = index; } m_dragMode = PlotDragMode::NONE; @@ -3633,7 +3633,7 @@ namespace jdi m_dragMode = PlotDragMode::NONE; if (ScrollLaneIntoView(panelLane)) { - SetHint(L"正在滚动到 G" + std::to_wstring(panelLane + 1) + L" 网格…"); + SetHint(L"正在滚动到 G" + FormatInteger(panelLane + 1) + L" 网格…"); } return true; } diff --git a/Components/PlotView/Src/PlotViewPaint.cpp b/Components/PlotView/Src/PlotViewPaint.cpp index d3581de..871411c 100644 --- a/Components/PlotView/Src/PlotViewPaint.cpp +++ b/Components/PlotView/Src/PlotViewPaint.cpp @@ -1461,7 +1461,7 @@ namespace jdi // 因此不会出现直角白边。 UiFillRoundRect(g, badge, radius, m_theme->accentSoft); UiDrawRoundRect(g, badge, radius, m_theme->accent, 1.0f); - UiText(g, L"G" + std::to_wstring(m_hoverBadge + 1), m_fonts->monoSmall, badge, + UiText(g, L"G" + FormatInteger(m_hoverBadge + 1), m_fonts->monoSmall, badge, m_theme->textStrong, TextAlign::CENTER); } } @@ -2556,7 +2556,7 @@ namespace jdi { UiFillRoundRect(g, badgeRect, badgeHeight * 0.5f, m_theme->cardBg); UiDrawRoundRect(g, badgeRect, badgeHeight * 0.5f, m_theme->border, 1.0f); - UiText(g, L"G" + std::to_wstring(laneIndex + 1), m_fonts->monoSmall, badgeRect, + UiText(g, L"G" + FormatInteger(laneIndex + 1), m_fonts->monoSmall, badgeRect, m_theme->text, TextAlign::CENTER); for (const LegendItem& item : items) @@ -2601,7 +2601,7 @@ namespace jdi { // 图例空间不足时提示剩余信号数量,避免误认为信号丢失。 const std::wstring moreText = - L"+" + std::to_wstring(visibleCount - static_cast(items.size())); + L"+" + FormatInteger(visibleCount - static_cast(items.size())); UiText(g, moreText, m_fonts->uiTiny, moreTextRect, m_theme->textMuted, TextAlign::LEFT); } } @@ -5106,7 +5106,7 @@ namespace jdi badgeHot ? m_theme->accentSoft : m_theme->cardBg); UiDrawRoundRect(g, badgeRect, badgeRect.Height * 0.5f, badgeHot ? m_theme->accent : m_theme->border, badgeHot ? 1.4f : 1.0f); - UiText(g, L"G" + std::to_wstring(index + 1), m_fonts->monoSmall, badgeRect, + UiText(g, L"G" + FormatInteger(index + 1), m_fonts->monoSmall, badgeRect, badgeHot ? m_theme->textStrong : m_theme->text, TextAlign::CENTER); const std::wstring title = meanFilter ? std::wstring(L"均值滤波") @@ -5585,7 +5585,7 @@ namespace jdi static_cast(maximum - minimum)) : 0.0; UiDrawSlider(g, track, ratio, m_theme->accent, active, m_scale); - UiTextClipped(g, std::to_wstring(current), m_fonts->monoSmall, + UiTextClipped(g, FormatInteger(current), m_fonts->monoSmall, gd::RectF(valueRect.X, valueRect.Y, valueColumn, valueRect.Height), m_theme->textStrong, TextAlign::LEFT, layout.content); // 复位按钮:悬停高亮 + 与「系数/偏移」菜单同款图标。 @@ -5688,7 +5688,7 @@ namespace jdi ? layout.rowLevelResets[rowIndex] : gd::RectF(), kWeakPeakLevelLabel, levelRatio, - (peakAmount > 0 ? L"+" : L"") + std::to_wstring(peakAmount) + L"%", + (peakAmount > 0 ? L"+" : L"") + FormatInteger(peakAmount) + L"%", levelActive, false); } if (rowIndex < layout.rowIterTracks.size() && @@ -5703,7 +5703,7 @@ namespace jdi ? layout.rowIterResets[rowIndex] : gd::RectF(), kWeakPeakIterationsSliderLabel, iterRatio, - std::to_wstring(peakIter), iterActive, editingMax); + FormatInteger(peakIter), iterActive, editingMax); } } else @@ -5831,7 +5831,7 @@ namespace jdi ++markerCount; } } - const std::wstring countText = std::to_wstring(markerCount) + L" 标记点"; + const std::wstring countText = FormatInteger(markerCount) + L" 标记点"; const float countWidth = UiMeasureTextWidth(m_fonts->monoSmall, countText) + 2.0f * m_scale; const float countRight = group.close.X - 5.0f * m_scale; @@ -5902,7 +5902,7 @@ namespace jdi } if (maxSerial > 0) { - measure(EdgePanelLayout::MARKER_COL_SERIAL, std::to_wstring(maxSerial)); + measure(EdgePanelLayout::MARKER_COL_SERIAL, FormatInteger(maxSerial)); } for (const FreeMarkerContentEntry& entry : contents) { @@ -5993,7 +5993,7 @@ namespace jdi UiTextClipped(g, text, m_fonts->monoSmall, columnRect(column), color, TextAlign::LEFT, layout.content); }; - paintCell(EdgePanelLayout::MARKER_COL_SERIAL, std::to_wstring(markerRow.serial), + paintCell(EdgePanelLayout::MARKER_COL_SERIAL, FormatInteger(markerRow.serial), m_theme->textMuted); paintCell(EdgePanelLayout::MARKER_COL_VALUE, entry.valueText, m_theme->textStrong); @@ -6092,7 +6092,7 @@ namespace jdi sliderActive, m_scale); const int percent = static_cast(tabLane.freeMarkerOpacity * 100.0f + 0.5f); - UiText(g, std::to_wstring(percent) + L"%", m_fonts->monoSmall, + UiText(g, FormatInteger(percent) + L"%", m_fonts->monoSmall, layout.opacityValue, m_theme->textStrong, TextAlign::RIGHT); } } @@ -6244,7 +6244,7 @@ namespace jdi : ((m_editTarget == PlotEditTarget::MEAN_POINTS_MAX) ? kMeanFilterPointsDefaultMax : kMeanFilterIterationsDefaultMax)); - const std::wstring hint = L"输入上限(当前" + std::to_wstring(currentMax) + L")"; + const std::wstring hint = L"输入上限(当前" + FormatInteger(currentMax) + L")"; const float hintLeft = fieldRect.X + 11.0f * m_scale; UiTextAtClipped(g, hint, m_fonts->monoSmall, hintLeft, gd::RectF(hintLeft, fieldRect.Y, @@ -6414,7 +6414,7 @@ namespace jdi const std::wstring hint = (target == sourceLane) ? std::wstring(L"松开左键放回本网格") - : (L"移动到网格 " + std::to_wstring(target + 1) + + : (L"移动到网格 " + FormatInteger(target + 1) + L":松开左键确认"); const float textWidth = UiMeasureTextWidth(m_fonts->uiSmall, hint) + 16.0f * m_scale; const float height = 20.0f * m_scale; diff --git a/Components/Shared/Src/ColorPickerPopup.cpp b/Components/Shared/Src/ColorPickerPopup.cpp index d519cd5..b62652d 100644 --- a/Components/Shared/Src/ColorPickerPopup.cpp +++ b/Components/Shared/Src/ColorPickerPopup.cpp @@ -519,7 +519,7 @@ namespace jdi case EditField::B: { const int index = static_cast(field) - 1; - m_edit.Begin(std::to_wstring(ToByte(m_channels[static_cast(index)])), true, + m_edit.Begin(FormatInteger(ToByte(m_channels[static_cast(index)])), true, EditFilter::BYTE, 0u); break; } @@ -1069,7 +1069,7 @@ namespace jdi } else { - UiText(g, std::to_wstring(ToByte(m_channels[slot])), m_fonts->monoSmall, m_values[slot], + UiText(g, FormatInteger(ToByte(m_channels[slot])), m_fonts->monoSmall, m_values[slot], m_theme->text, TextAlign::RIGHT); } } diff --git a/Components/SignalPanel/Src/SignalPanel.cpp b/Components/SignalPanel/Src/SignalPanel.cpp index 271ec7c..dab051d 100644 --- a/Components/SignalPanel/Src/SignalPanel.cpp +++ b/Components/SignalPanel/Src/SignalPanel.cpp @@ -1398,7 +1398,7 @@ namespace jdi std::wstring SignalPanel::GroupLabelText(int groupIndex) { - return L"组" + std::to_wstring(groupIndex + 1); + return L"组" + FormatInteger(groupIndex + 1); } std::wstring SignalPanel::GroupStatsText(int row) const @@ -1409,7 +1409,7 @@ namespace jdi } // 项数:普通模式 = 信号总数;筛选模式 = 该组内**符合筛选条件**的信号个数 //(用户 2026-10-16 要求)。两者都在 `BuildRows` 里写入 `displayCount`。 - return std::to_wstring(m_rows[static_cast(row)].displayCount) + L" 项"; + return FormatInteger(m_rows[static_cast(row)].displayCount) + L" 项"; } gd::RectF SignalPanel::FilterFieldRect() const diff --git a/Components/SignalPanel/Src/SignalPanelEdit.cpp b/Components/SignalPanel/Src/SignalPanelEdit.cpp index e9e1b79..d85ed4a 100644 --- a/Components/SignalPanel/Src/SignalPanelEdit.cpp +++ b/Components/SignalPanel/Src/SignalPanelEdit.cpp @@ -171,7 +171,7 @@ namespace jdi { return false; } - initial = std::to_wstring(static_cast(style->opacity * 100.0f + 0.5f)); + initial = FormatInteger(static_cast(style->opacity * 100.0f + 0.5f)); break; } case StyleEditField::SIGNAL_NAME: diff --git a/Components/SignalPanel/Src/SignalPanelMenu.cpp b/Components/SignalPanel/Src/SignalPanelMenu.cpp index 8fa0274..02319fb 100644 --- a/Components/SignalPanel/Src/SignalPanelMenu.cpp +++ b/Components/SignalPanel/Src/SignalPanelMenu.cpp @@ -250,7 +250,7 @@ namespace jdi MenuItem entry; // ⚠️ 显示形式为「组号 + 竖线 + 组名」(用户 2026-11-03 明确要求), // **不要**用括号把组名括起来(旧实现 `分组 1(名称)` 已被要求改掉)。 - entry.label = std::to_wstring(index + 1) + L" | " + group->name; + entry.label = FormatInteger(index + 1) + L" | " + group->name; entry.id = CMD_GROUP_MOVE_BASE + static_cast(index); entry.checked = (static_cast(index) == ref.groupIndex); moveMenu.submenu.push_back(entry); @@ -350,8 +350,8 @@ namespace jdi for (std::size_t index = 0; index < lanes.size(); ++index) { MenuItem lane; - lane.label = L"网格 " + std::to_wstring(index + 1) + L"(" + - std::to_wstring(lanes[index].signals.size()) + L" 条信号)"; + lane.label = L"网格 " + FormatInteger(index + 1) + L"(" + + FormatInteger(lanes[index].signals.size()) + L" 条信号)"; lane.id = CMD_LANE_BASE + static_cast(index); lane.checked = (currentLane == static_cast(index)); items.push_back(lane); diff --git a/Components/SignalPanel/Src/SignalPanelPaint.cpp b/Components/SignalPanel/Src/SignalPanelPaint.cpp index be3f0aa..c22078c 100644 --- a/Components/SignalPanel/Src/SignalPanelPaint.cpp +++ b/Components/SignalPanel/Src/SignalPanelPaint.cpp @@ -81,8 +81,8 @@ namespace jdi std::wstring title = L"信号"; if (m_dataSet != nullptr && m_tab != nullptr) { - title += L" · " + std::to_wstring(m_tab->VisibleCount()) + L"/" + - std::to_wstring(TotalSignalCount()) + L" 已选"; + title += L" · " + FormatInteger(m_tab->VisibleCount()) + L"/" + + FormatInteger(TotalSignalCount()) + L" 已选"; } gd::RectF titleRect(m_headerRect.X + kPadding * m_scale, m_headerRect.Y, @@ -751,7 +751,7 @@ namespace jdi badgeHot ? m_theme->accentSoft : m_theme->cardBg); UiDrawRoundRect(g, badgeRect, kBadgeHeight * m_scale * 0.5f, badgeHot ? m_theme->accent : m_theme->border, 1.0f); - PaintRowText(g, L"G" + std::to_wstring(m_tab->LaneOfSignal(ref) + 1), m_fonts->monoSmall, + PaintRowText(g, L"G" + FormatInteger(m_tab->LaneOfSignal(ref) + 1), m_fonts->monoSmall, badgeRect, badgeHot ? m_theme->textStrong : m_theme->text, TextAlign::CENTER); } @@ -992,7 +992,7 @@ namespace jdi else { const int percent = static_cast(style->opacity * 100.0f + 0.5f); - UiText(g, std::to_wstring(percent) + L"%", m_fonts->monoSmall, m_opacityValueRect, + UiText(g, FormatInteger(percent) + L"%", m_fonts->monoSmall, m_opacityValueRect, m_theme->textStrong, TextAlign::RIGHT); } paintStyleReset(m_opacityResetRect, StyleHit::OPACITY_RESET, @@ -1059,7 +1059,7 @@ namespace jdi badgeHot ? m_theme->accentSoft : m_theme->cardBg); UiDrawRoundRect(g, m_targetBadgeRect, kBadgeHeight * m_scale * 0.5f, badgeHot ? m_theme->accent : m_theme->border, 1.0f); - UiText(g, L"G" + std::to_wstring(m_targetGeomLane + 1), m_fonts->monoSmall, + UiText(g, L"G" + FormatInteger(m_targetGeomLane + 1), m_fonts->monoSmall, m_targetBadgeRect, badgeHot ? m_theme->textStrong : m_theme->text, TextAlign::CENTER); } } diff --git a/Makefile b/Makefile index 3357aae..426ecc3 100644 --- a/Makefile +++ b/Makefile @@ -209,17 +209,30 @@ endif # * ⚠️ 全关(`-fno-unwind-tables`)会链接失败(历史踩坑),**不要**打开。 RELEASE_ONLY_OPT = -fno-asynchronous-unwind-tables -# ---- 曾评估并放弃:把 `swprintf` 交给系统 UCRT(`-D__USE_MINGW_ANSI_STDIO=0`)---- +# ---- 关于「把 `swprintf` 交给系统 UCRT」:**在 C++ 里做不到**,别再试 ---- # 动机:MinGW 的 ANSI stdio(`mingw_wpformat.o`)会链入 gdtoa 整套 -#(`strtodg`/`gdtoa`/`gethex`/`hexnan`),map 实测 `.text` 合计 ≈33 KB。 -# 实测结论(2026-11-04):**加这个宏体积一个字节都不减**。 -# 原因:预编译的 libstdc++ 自己就引用 `__mingw_swprintf` —— -# 只要工程里还调用 `std::to_wstring` / `std::to_string`(全仓约 80 处), -# 链接器就必须把 MinGW 的 printf 引擎拉进来(用 3 行最小程序 `std::to_wstring(5)` 实测)。 -# ⇒ 要真省下这 33 KB,得把 80 处 `std::to_*string` 全换成自写整数格式化, -# 其中若干处实参是浮点(语义会变),风险与收益不匹配。 -# 保留证据:`Tools/UnitTests` 的 `stdio_mingw_vs_ucrt_formats`(13.6 万次调用逐字符比对,0 差异) -# 证明两套 printf 在这些格式上**完全等价**,将来若真要做迁移,那条测试就是验收标准。 +# (`strtodg` / `gdtoa` / `gethex` / `hexnan`),map 实测 ≈33 KB。 +# +# 本轮实测(2026-11-04,两次独立实验): +# 1. 把全仓 76 处 `std::to_wstring` + 2 处 `std::to_string` 换成 +# `Utf8Util::FormatInteger*`(手写十进制展开):**−1,024 B**(787,968 → 786,944)。 +# 这一步是有效的,且去掉了 libstdc++ 的 `to_string` 对 printf 的依赖。 +# 2. 再加 `-D__USE_MINGW_ANSI_STDIO=0`:**0 字节**,`mingw_wpformat.o` 仍在。 +# +# 原因(已定位到具体文件与行):libstdc++ 在 +# lib/gcc/x86_64-w64-mingw32//include/c++/x86_64-w64-mingw32/bits/os_defines.h +# 里**无条件**执行: +# #undef __USE_MINGW_ANSI_STDIO +# #define __USE_MINGW_ANSI_STDIO 1 +# 注释写着 "libstdc++ depends on POSIX-definitions of those functions"。 +# 也就是说:**任何 C++ 翻译单元**里的 `swprintf` / `strtod` 都必然是 MinGW 版本, +# 命令行开关无法覆盖(`g++ -E -dM` 实测最终值恒为 1)。 +# +# ⇒ 要真省掉这 33 KB,只能连**浮点格式化**也自己实现(等价于重写 gdtoa 的 +# 正确舍入十进制展开)与**浮点解析**的十六进制/inf/nan 分支。风险远大于收益, +# 判定:**不做**。保留证据:`Tools/UnitTests` 的 `stdio_mingw_vs_ucrt_formats` +# (13.6 万次调用逐字符比对,0 差异)说明两套 printf 在这些格式上完全等价, +# 将来若平台换了 C 运行库,这条测试可直接复用。 # 丢弃未被引用的函数/数据(非 LTO 时还需要配合 -ffunction-sections 才有粒度)。 GC_SECTIONS = -Wl,--gc-sections diff --git a/Tools/UnitTests/Src/Utf8UtilTests.cpp b/Tools/UnitTests/Src/Utf8UtilTests.cpp index 8fe8808..caac59e 100644 --- a/Tools/UnitTests/Src/Utf8UtilTests.cpp +++ b/Tools/UnitTests/Src/Utf8UtilTests.cpp @@ -286,6 +286,55 @@ JDI_TEST(text_trim_and_utf8_conversion) JDI_CHECK(!jdi::ContainsInsensitive(L"HelloWorld", L"xyz")); } +/** + * @brief `FormatInteger*` 必须与 `std::to_wstring` / `std::to_string` 语义**完全一致**。 + * + * 这组函数替代了全仓 76 + 2 处标准库转换,目的是让产物不再依赖 + * libstdc++ 的 `to_string`(它内部转调 `__mingw_swprintf`)。任何一位差异都会 + * 直接改变界面文本,因此按「逐字符 + 与标准库对照」双重判定。 + */ +JDI_TEST(integer_replacement_matches_std) +{ + static const long long samples[] = {0, + 1, + -1, + 9, + 10, + 99, + 100, + -100, + 1234567890123ll, + -1234567890123ll, + 0x7FFFFFFFFFFFFFFFll, // LLONG_MAX + (-0x7FFFFFFFFFFFFFFFll) - 1ll}; // LLONG_MIN + + for (const long long value : samples) + { + const std::wstring wide = jdi::FormatInteger(value); + const std::wstring reference = std::to_wstring(value); + JDI_CHECK_WIDE_EQ(wide, reference); + // 窄版本:内容必须与宽版本逐字符相同(只是字符宽度不同) + const std::string narrow = jdi::FormatIntegerNarrow(value); + JDI_CHECK_EQ(static_cast(narrow.size()), static_cast(wide.size())); + bool same = (narrow.size() == wide.size()); + for (std::size_t index = 0; same && index < narrow.size(); ++index) + { + same = (narrow[index] == static_cast(wide[index])); + } + JDI_CHECK(same); + } + + // 无符号版本(`size_t` 常走这条):覆盖 ULLONG_MAX + static const unsigned long long unsignedSamples[] = {0ull, 1ull, 42ull, 4294967295ull, + 18446744073709551615ull}; + for (const unsigned long long value : unsignedSamples) + { + const std::wstring ours = jdi::FormatIntegerUnsigned(value); + const std::wstring reference = std::to_wstring(value); + JDI_CHECK_WIDE_EQ(ours, reference); + } +} + JDI_TEST(ticks_nice_step_and_build_ticks) { // `NiceStep` 必须落在 1/2/2.5/5 × 10^n 这类"好读"的格点上 diff --git a/Utilities/Inc/DataModelInternal.hpp b/Utilities/Inc/DataModelInternal.hpp index 40d20ee..5be6c84 100644 --- a/Utilities/Inc/DataModelInternal.hpp +++ b/Utilities/Inc/DataModelInternal.hpp @@ -463,7 +463,7 @@ namespace jdi for (std::size_t index = 0; index < lanes.size(); ++index) { PruneLaneSignalItems(lanes[index]); - lanes[index].name = L"网格 " + std::to_wstring(index + 1); + lanes[index].name = L"网格 " + FormatInteger(index + 1); } } } // namespace dmdetail diff --git a/Utilities/Inc/Utf8Util.hpp b/Utilities/Inc/Utf8Util.hpp index 7bad6bb..62d5b0c 100644 --- a/Utilities/Inc/Utf8Util.hpp +++ b/Utilities/Inc/Utf8Util.hpp @@ -129,6 +129,40 @@ namespace jdi */ std::wstring FormatCount(unsigned long long value); + /** + * @brief 把整数转换为**纯十进制**宽字符串(`std::to_wstring` 的替代品)。 + * + * ⚠️ 为什么不用 `std::to_wstring`:预编译的 libstdc++ 里它转调 `__mingw_swprintf`, + * 会把 MinGW 的 printf 引擎(`mingw_wpformat.o`)连同 gdtoa 整套 + * (`strtodg` / `gdtoa` / `gethex` / `hexnan`)一起拉进产物,map 实测 `.text` + * 合计 ≈33 KB。只要工程里还有一处 `std::to_wstring` / `std::to_string`, + * 这条依赖链就断不掉(本轮实测:只把 `swprintf` 让给 UCRT 是 **0 字节**收益)。 + * + * 本实现手写十进制展开:无本地化、无格式串解析、无堆分配(除返回的字符串本身), + * 语义与 `std::to_wstring` 完全一致(`-0` → `"0"`,最小值也能正确取绝对值)。 + * + * @param [IN] value 待转换整数(含符号)。 + * @return 十进制文本,例如 `-123`。 + */ + std::wstring FormatInteger(long long value); + + /** + * @brief 把无符号整数转换为纯十进制宽字符串(避免 `size_t` 走有符号重载的隐式转换告警)。 + * @param [IN] value 待转换整数。 + * @return 十进制文本。 + */ + std::wstring FormatIntegerUnsigned(unsigned long long value); + + /** + * @brief 把整数转换为纯十进制**窄**字符串(`std::to_string` 的替代品)。 + * + * 只在构造 HDF5 成员路径时使用(`MatLoader`),语义同 `FormatInteger`。 + * + * @param [IN] value 待转换整数。 + * @return 十进制文本。 + */ + std::string FormatIntegerNarrow(long long value); + /** * @brief 把时间间隔格式化为「Δt 语义」的复合单位文本(不含 "Δt:" 前缀)。 * diff --git a/Utilities/Src/CsvLoader.cpp b/Utilities/Src/CsvLoader.cpp index c35a6f7..52cec29 100644 --- a/Utilities/Src/CsvLoader.cpp +++ b/Utilities/Src/CsvLoader.cpp @@ -276,7 +276,7 @@ namespace jdi std::wstring text = FieldToText(span); if (text.empty()) { - text = L"Column" + std::to_wstring(table.headers.size() + 1); + text = L"Column" + FormatInteger(table.headers.size() + 1); } table.headers.push_back(text); } diff --git a/Utilities/Src/DataModel.cpp b/Utilities/Src/DataModel.cpp index bce1c1b..061c939 100644 --- a/Utilities/Src/DataModel.cpp +++ b/Utilities/Src/DataModel.cpp @@ -351,7 +351,7 @@ namespace jdi signal.name = Trim(table.headers[column]); if (signal.name.empty()) { - signal.name = L"Column" + std::to_wstring(column + 1); + signal.name = L"Column" + FormatInteger(column + 1); } signal.axisIndex = axisIndex; diff --git a/Utilities/Src/DataModelTab.cpp b/Utilities/Src/DataModelTab.cpp index 331bae4..6f3df55 100644 --- a/Utilities/Src/DataModelTab.cpp +++ b/Utilities/Src/DataModelTab.cpp @@ -609,7 +609,7 @@ namespace jdi for (std::size_t index = 0; index < m_lanes.size(); ++index) { - m_lanes[index].name = L"网格 " + std::to_wstring(index + 1); + m_lanes[index].name = L"网格 " + FormatInteger(index + 1); } return LaneOfSignal(ref); } @@ -743,7 +743,7 @@ namespace jdi // 统一重命名,保证编号连续(GX 编号在各显示位置均由此位置推导)。 for (std::size_t index = 0; index < m_lanes.size(); ++index) { - m_lanes[index].name = L"网格 " + std::to_wstring(index + 1); + m_lanes[index].name = L"网格 " + FormatInteger(index + 1); } return laneIndex; } @@ -876,7 +876,7 @@ namespace jdi { for (std::size_t index = 0; index < m_lanes.size(); ++index) { - m_lanes[index].name = L"网格 " + std::to_wstring(index + 1); + m_lanes[index].name = L"网格 " + FormatInteger(index + 1); } } diff --git a/Utilities/Src/MatLoader.cpp b/Utilities/Src/MatLoader.cpp index 12f6fc3..85188f5 100644 --- a/Utilities/Src/MatLoader.cpp +++ b/Utilities/Src/MatLoader.cpp @@ -6,6 +6,7 @@ #include "MatLoader.hpp" #include "MatH5.hpp" +#include "Utf8Util.hpp" #include #include @@ -34,15 +35,16 @@ namespace jdi std::wstring TrimNumber(double value) { - wchar_t buffer[64] = {}; + // ⚠️ 整数分支改走 `FormatInteger`(不再经 `swprintf`):本文件是产物里 + // 最后一个引用 MinGW printf 引擎的地方,留着它 gdtoa 就断不掉。 if (value == static_cast(static_cast(value))) { - std::swprintf(buffer, 64, L"%lld", static_cast(value)); - } - else - { - std::swprintf(buffer, 64, L"%g", value); + return FormatInteger(static_cast(value)); } + // 浮点分支必须保留 `%g` 语义(去掉尾随 0 的最短表示),因此仍走 `swprintf`; + // 此时库函数来自系统 UCRT(见 Makefile 的 `UCRT_STDIO` 开关)。 + wchar_t buffer[64] = {}; + std::swprintf(buffer, 64, L"%g", value); return buffer; } @@ -341,7 +343,7 @@ namespace jdi signal.signalName = element.name; if (signal.signalName.empty()) { - signal.signalName = L"信号" + std::to_wstring(++unnamedSignals); + signal.signalName = L"信号" + FormatInteger(++unnamedSignals); } signal.timeStream = element.timeStream; signal.dataStream = element.dataStream; @@ -385,7 +387,7 @@ namespace jdi // 数据列:直接解码到最终 vector(不再经过 `Dataset.data` 的整块字节)。 std::vector values; - const std::uint64_t dataObject = file.Member(sigstream, std::to_string(signal.dataStream)); + const std::uint64_t dataObject = file.Member(sigstream, FormatIntegerNarrow(signal.dataStream)); if (dataObject == 0 || !ReadStream(file, dataObject, values) || values.empty()) { if (!reportStream()) @@ -408,7 +410,7 @@ namespace jdi if (signal.timeStream >= 0) { const std::uint64_t timeObject = - file.Member(sigstream, std::to_string(signal.timeStream)); + file.Member(sigstream, FormatIntegerNarrow(signal.timeStream)); haveTime = timeObject != 0 && ReadStream(file, timeObject, time) && !time.empty(); } diff --git a/Utilities/Src/Mf4Loader.cpp b/Utilities/Src/Mf4Loader.cpp index b804ad8..00a8e90 100644 --- a/Utilities/Src/Mf4Loader.cpp +++ b/Utilities/Src/Mf4Loader.cpp @@ -1285,13 +1285,13 @@ namespace jdi for (const std::size_t value : axisIndex) { suffix += L"["; - suffix += std::to_wstring(value); + suffix += FormatInteger(value); suffix += L"]"; } } else { - suffix = L"[" + std::to_wstring(index) + L"]"; + suffix = L"[" + FormatInteger(index) + L"]"; } element.name = spec.name + suffix; out.push_back(std::move(element)); @@ -2018,7 +2018,7 @@ namespace jdi std::wstring name = group.channels[c].name; if (name.empty()) { - name = L"Channel" + std::to_wstring(c + 1); + name = L"Channel" + FormatInteger(c + 1); } block.table.headers.push_back(name); block.table.columns.push_back(std::move(group.values[c])); @@ -2045,15 +2045,15 @@ namespace jdi std::wstring detail; if (skippedNoLayout > 0) { - detail += L"数据块布局无法解析 " + std::to_wstring(skippedNoLayout) + L" 组;"; + detail += L"数据块布局无法解析 " + FormatInteger(skippedNoLayout) + L" 组;"; } if (skippedNoAxis > 0) { - detail += L"缺少可用时间通道 " + std::to_wstring(skippedNoAxis) + L" 组;"; + detail += L"缺少可用时间通道 " + FormatInteger(skippedNoAxis) + L" 组;"; } if (skippedEmpty > 0) { - detail += L"空数据组 " + std::to_wstring(skippedEmpty) + L" 组;"; + detail += L"空数据组 " + FormatInteger(skippedEmpty) + L" 组;"; } error = L"未在文件中找到可绘制的数值通道。"; if (!detail.empty()) diff --git a/Utilities/Src/Utf8Util.cpp b/Utilities/Src/Utf8Util.cpp index 80e34d1..935cc19 100644 --- a/Utilities/Src/Utf8Util.cpp +++ b/Utilities/Src/Utf8Util.cpp @@ -527,7 +527,7 @@ namespace jdi { text += L" "; } - text += std::to_wstring(amount); + text += FormatInteger(amount); text += unit; }; append(hours, L"h"); @@ -832,6 +832,70 @@ namespace jdi return result; } + /** + * @brief 把整数展开成十进制数字(内部实现,宽/窄共用同一套算法)。 + * + * ⚠️ 这里**不能**用 `swprintf`:本函数存在的全部意义就是让产物不再引用 + * MinGW 的 printf 引擎(见头文件注释)。手写 10 次除法比一次格式串解析还快, + * 且无本地化影响。 + * + * @param [OUT] buffer 输出缓冲(从尾部向前写)。 + * @param [IN] value 待转换整数。 + * @return 文本起始指针(指向 buffer 内部)。 + */ + template + static const Char* ExpandInteger(Char* buffer, std::size_t capacity, long long value, + std::size_t& length) + { + Char* cursor = buffer + capacity; + const bool negative = (value < 0); + // `0 - (unsigned long long)value` 对 LLONG_MIN 也成立(模 2^64 语义)。 + unsigned long long magnitude = negative ? (0ull - static_cast(value)) + : static_cast(value); + do + { + *--cursor = static_cast('0' + static_cast(magnitude % 10ull)); + magnitude /= 10ull; + } while (magnitude != 0ull); + if (negative) + { + *--cursor = static_cast('-'); + } + length = static_cast((buffer + capacity) - cursor); + return cursor; + } + + std::wstring FormatInteger(long long value) + { + wchar_t buffer[24]; // -9223372036854775808 = 20 字符 + 余量 + std::size_t length = 0; + const wchar_t* begin = ExpandInteger(buffer, sizeof(buffer) / sizeof(buffer[0]), value, length); + return std::wstring(begin, length); + } + + std::wstring FormatIntegerUnsigned(unsigned long long value) + { + // 上限 20 位十进制数字 + 余量(用有符号重载会溢出,故单独展开一遍)。 + wchar_t buffer[24]; + wchar_t* cursor = buffer + (sizeof(buffer) / sizeof(buffer[0])); + unsigned long long magnitude = value; + do + { + *--cursor = static_cast(L'0' + static_cast(magnitude % 10ull)); + magnitude /= 10ull; + } while (magnitude != 0ull); + return std::wstring(cursor, static_cast(buffer + (sizeof(buffer) / sizeof(buffer[0])) - + cursor)); + } + + std::string FormatIntegerNarrow(long long value) + { + char buffer[24]; + std::size_t length = 0; + const char* begin = ExpandInteger(buffer, sizeof(buffer) / sizeof(buffer[0]), value, length); + return std::string(begin, length); + } + double NiceStep(double range, int targetTicks) { if (!(range > 0.0) || !std::isfinite(range) || targetTicks <= 0) -- 2.54.0 From b58b427f62e76dd2d5b06b6240039074a58ada6e Mon Sep 17 00:00:00 2001 From: "DeepSeek V4.1 Flash" <> Date: Wed, 4 Nov 2026 14:37:59 +0800 Subject: [PATCH 13/22] =?UTF-8?q?review(panels+size):=20=E9=9D=A2=E6=9D=BF?= =?UTF-8?q?=E7=BB=93=E6=9E=84=E6=94=B6=E6=95=9B=E6=94=B6=E5=B0=BE=E3=80=81?= =?UTF-8?q?=E5=AE=A1=E6=9F=A5=E6=B8=85=E7=90=86=E3=80=81=E6=96=B0=E5=A2=9E?= =?UTF-8?q?=E5=B9=B3=E8=A1=8C=E6=95=B0=E7=BB=84=E6=8A=A4=E6=A0=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 一、审查清理 * `PlotView::FreeMarkerValue` 的 `const Lane&` 形参从未使用(函数体里只有一句 `(void)lane;`)⇒ 删除形参与该语句,更新 2 个调用点。 * `EdgePanelLayoutFor` 里「选区截取是否就绪(双光标)」算了 3 遍 ⇒ 收敛为函数顶部 一次 `clipReady`。它同时决定面板高度 footer、条目行可见性、按钮几何, 三处各算一遍时只要有一处条件写错,就是"底部多一段空白"或"没双光标却能滚动"。 二、新增护栏(离屏检查) * `panels`:新增「平行数组长度一致」不变式 —— `EdgePanelLayout` 的 13 个按行平行 vector 必须与行数一致;4 个「重采样」专属数组允许为空、非空则必须对齐。 这是"某列读到别人矩形"这类错位缺陷的唯一低成本护栏(O(1))。 * `panelscroll`:同样加一份(大文件、566 行时才会暴露的问题)。 * `panels` 输出增加 rows 数,便于定位。 三、文档 * `Documents/Code_Review_Plan.md` 新增 §七(第 35 轮节点与硬约束)。 * `Documents/Code_Review_Progress.md` 新增第 35 轮(面板架构评估 + 三次重构记录)。 * `Documents/Code_Review_Report.md` 新增 §12(评估结论 + gdtoa 最终结论 + 护栏价值)。 * `Documents/agent_mem.md` 新增第 35 轮六条规则。 * `Tools/README.md`:补 `panelscroll`、`integer_replacement_matches_std`, 断言数更新为 260 / 108,最小集表补充面板相关条目。 验证: * `make release`(删 .o 全量)零 error / 零 warning,产物 **786,944 B**。 * `make -C Tools` 零 error / 零 warning。 * `UnitTests` → checks=260 failed=0 cases=19。 * `OffscreenChecks -all` → 6/6 检查 PASS,checks=108 failed=0。 * `panels -mode compare` → 7 个面板像素哈希与几何逐项不变。 --- Components/PlotView/Inc/PlotView.hpp | 2 +- Components/PlotView/Src/PlotViewPanels.cpp | 16 ++-- Documents/Code_Review_Plan.md | 33 +++++++ Documents/Code_Review_Progress.md | 106 +++++++++++++++++++++ Documents/Code_Review_Report.md | 89 +++++++++++++++++ Documents/agent_mem.md | 70 ++++++++++++++ Tools/OffscreenChecks/Src/Checks.cpp | 96 ++++++++++++++++++- Tools/README.md | 11 ++- 8 files changed, 408 insertions(+), 15 deletions(-) diff --git a/Components/PlotView/Inc/PlotView.hpp b/Components/PlotView/Inc/PlotView.hpp index 174dd47..45c2a8f 100644 --- a/Components/PlotView/Inc/PlotView.hpp +++ b/Components/PlotView/Inc/PlotView.hpp @@ -2377,7 +2377,7 @@ namespace jdi * @param [OUT] enumValue 输出采样原值(用于查枚举表)。 * @return 取到有效值返回 true。 */ - bool FreeMarkerValue(const Lane& lane, const Lane::FreeMarkerItem& item, double& value, + bool FreeMarkerValue(const Lane::FreeMarkerItem& item, double& value, double& enumValue) const; /** diff --git a/Components/PlotView/Src/PlotViewPanels.cpp b/Components/PlotView/Src/PlotViewPanels.cpp index 5cb0d09..7b78260 100644 --- a/Components/PlotView/Src/PlotViewPanels.cpp +++ b/Components/PlotView/Src/PlotViewPanels.cpp @@ -395,6 +395,9 @@ namespace jdi // 预留,而实际只画出并排的一行 ⇒ 内容区下方多出一整行“无意义的空白” // (用户 2026-11-03 连续三轮报告的那片空白,根因就在这一行)。 // ⚠️ **未启用双光标**时两个按钮都不显示 ⇒ footer 归零(否则面板会留一大段空白)。 + // 「选区截取」的两个按钮 / 信号条目都只在**双光标**模式下存在; + // 这个判定同时决定面板高度(footer)、条目行与按钮几何,过去在三个分支里各算一遍, + // 任何一处写错就是「底部多一段空白」或「没双光标却能滚动」(用户 2026-11-03 报告)。 const bool clipReady = clipPanel && (Tab().CursorMode() == PlotCursorMode::DOUBLE); const float footerHeight = clipPanel ? (clipReady ? (buttonHeight + pad) : 0.0f) @@ -432,7 +435,6 @@ namespace jdi // ⚠️ 「选区截取」在**未启用双光标**时没有任何可操作内容(只有一行提示文字), // 因此不按信号数累加行高、也不套用「至少 3 行」的下限 // (用户 2026-11-03 报告:未启用双光标时仍显示信号条目、且能触发滚动)。 - const bool clipReady = clipPanel && (Tab().CursorMode() == PlotCursorMode::DOUBLE); float content = 0.0f; if (clipPanel && !clipReady) { @@ -781,8 +783,9 @@ namespace jdi const float groupRow = kEdgePanelMeanRowHeight * m_scale; const float sliderRow = kWeakPeakSliderRowHeight * m_scale; const float scrollW = kFreeMarkerScrollWidth * m_scale; - const bool clipReadyHere = - clipPanel && (Tab().CursorMode() == PlotCursorMode::DOUBLE); + // ⚠️ 与高度算式共用**同一个** `clipReady`(见函数顶部):两处各算一遍时, + // 只要有一处条件写错,面板高度与内容区高度就会不一致(底部多出空白)。 + const bool clipReadyHere = clipReady; // ⚠️ 两处(高度算式与这里)必须同为**一行按钮**高:两个按钮并排,不是上下两行。 const float footerHeight = clipPanel ? (clipReadyHere ? (buttonRowHeight + pad) : 0.0f) @@ -1203,7 +1206,7 @@ namespace jdi return layout; } - bool PlotView::FreeMarkerValue(const Lane& lane, const Lane::FreeMarkerItem& item, double& value, + bool PlotView::FreeMarkerValue(const Lane::FreeMarkerItem& item, double& value, double& enumValue) const { if (m_dataSet == nullptr) @@ -1226,7 +1229,6 @@ namespace jdi { return false; } - (void)lane; enumValue = sampled; // 枚举按**原值**查 value = ApplyDrawTransform(*resolved.style, sampled); // 读数与画出来的波形一致 return std::isfinite(value); @@ -1262,7 +1264,7 @@ namespace jdi const Lane::FreeMarkerItem& previous = lane.freeMarkerItems[order[position - 1]]; double previousValue = 0.0; double previousEnum = 0.0; - if (!FreeMarkerValue(lane, previous, previousValue, previousEnum)) + if (!FreeMarkerValue(previous, previousValue, previousEnum)) { return true; // 前一个点没有有效取值:不显示增量(避免编造一个数) } @@ -1354,7 +1356,7 @@ namespace jdi entry.itemIndex = itemIndex; double value = 0.0; double enumValue = 0.0; - const bool valueOk = FreeMarkerValue(lane, item, value, enumValue); + const bool valueOk = FreeMarkerValue(item, value, enumValue); bool hasPrev = false; double delta = 0.0; double timeDelta = 0.0; diff --git a/Documents/Code_Review_Plan.md b/Documents/Code_Review_Plan.md index b2023f2..7c99584 100644 --- a/Documents/Code_Review_Plan.md +++ b/Documents/Code_Review_Plan.md @@ -203,3 +203,36 @@ 3. **"先量后改"**:任何以性能 / 体积为名的改动,必须在同一台机器、同一次会话内给出前后数字; 收益低于噪声、或换来体积上涨的,一律回退,并把数字写进 `Code_Review_Report.md`。 4. **像素基线只做本地 A/B**:哈希与 DPI/字体/数据文件绑定,**不入库、不当黄金图**。 + +--- + +## 七、第 35 轮追加(2026-11-04):浮动面板架构评估与收敛 + 整数格式化替换 + +> 触发:用户指出「7 个实验性功能面板观感/骨架高度一致,但每新增一种或改一次布局 +> 总会出意料之外的错误」,要求先**评估**(能否抽公共基类 / 公共组件,浮动面板、 +> 二级菜单、右键菜单是否算公共组件),再动手;同时把 §11.5 的 +> 「替换 `std::to_wstring` / `std::to_string`」做掉。 + +### 7.1 节点与验收标准 + +| 节点 | 内容 | 验收标准 | +| --- | --- | --- | +| **P20** | 面板「特征层」:唯一分类 / 标题 / 滚动几何来源 | 面板像素基线与几何**逐项不变** | +| **P21** | 滚动设置收敛为唯一实现(7 个包装 + 2 条调用链) | `panelscroll` 新检查全绿 | +| **P22** | 全仓替换整数格式化(76 + 2 处) | 面板像素不变 + 单元测试逐字符对照 | +| **P23** | 其余审查清理(垃圾形参 / 重复判定) | 零 warning、像素不变 | + +### 7.2 本轮新增硬约束 + +1. **新增一种实验性功能面板,只允许改三个地方**: + `LanePanelScrollOf` / `PanelStyleOf` / `PanelTitle`(都在 `PlotViewInternal.hpp` / + `PlotViewPanels.cpp`)。三者都是穷举 `switch` 且**不写 `default:`**, + 漏登记会被 `-Wswitch` 编译报警。 + **禁止**再在宽度 / 布局 / 绘制里各写一份 `kind == XXX` 组合判断。 +2. **底部"有按钮行"与"是构建按钮"是两个概念**(`PanelStyle::footerButtons()` + vs `buildButton`)。混用会让面板高度与内容区高度不一致。 +3. **整数转文本一律用 `Utf8Util::FormatInteger*`**,不要用 `std::to_wstring` / + `std::to_string`(它们把 MinGW printf 引擎拉进产物,且 libstdc++ 会强制 + `__USE_MINGW_ANSI_STDIO=1`,命令行关不掉 —— 见 Makefile 注释)。 +4. **改面板几何/绘制前后必须跑** `OffscreenChecks -run panels -mode record/compare`; + 本轮它当场抓到了"截取面板少了 37px 高度"的回归。 diff --git a/Documents/Code_Review_Progress.md b/Documents/Code_Review_Progress.md index 9ee57a4..dfe1e5f 100644 --- a/Documents/Code_Review_Progress.md +++ b/Documents/Code_Review_Progress.md @@ -241,3 +241,109 @@ Makefile 按目录通配收集源文件,**无需登记新文件**。 | `Tools/UnitTests/Build/UnitTests.exe` | `checks=219 failed=0 cases=18` | | `Tools/OffscreenChecks/Build/OffscreenChecks.exe -all` | 5 个检查全 PASS,`checks=71 failed=0` | | Release 产物 | **788,480 B**(与第 33 轮末一致;T4/T6 均回退) | + +--- + +## 第 35 轮(2026-11-04):浮动面板架构评估 + 收敛 + 整数格式化 + +### 评估:浮动面板 / 二级菜单 / 右键菜单算不算"公共组件"? + +先回答用户的问题(结论来自读码,不是印象): + +| 对象 | 现状 | 是否已是"公共组件" | +| --- | --- | --- | +| **右键菜单 / 二级菜单** | `Components/Shared/PopupMenu`(+ `Layout` / `Paint` 两分片) | **是**。同一份实现服务绘图区网格菜单、信号菜单、分组菜单、标签页菜单、工具栏设置菜单;新增菜单只需描述条目数组 | +| **拾色器** | `Components/Shared/ColorPickerPopup` | **是**。独立的浮层组件,自带编辑框/滑块/色域/命中 | +| **实验室功能浮动面板** | 全部在 `PlotView` 内部(`EdgePanelLayoutFor` 算几何 + `PaintEdgeCursor` 画 + `OnLButtonDown/Move/Up` 命中),**共用一份"面板骨架"**(标题栏 = 折起三角 + GX 徽标 + 标题 + ✕、可滚动内容区 + 滚动条、底部按钮行) | **骨架是公共的,但"按种类分支"的代码被抄了多份** | + +**为什么不把面板做成 `UiWidget` 树节点(像 PopupMenu 那样)?** +三条硬约束决定了成本远大于收益: +1. 面板的**像素**要进 `m_panelDc` 图层缓存(键 = `PanelGlobalKey ⊕ PanelSlotKey`), + 而 `UiWidget` 有自己的 dirty/capture 体系,两套缓存叠加会互相失效(历史教训: + `SignalPanel` 行位图缓存与控件树就被刻意分开维护)。 +2. 面板几何**必须**由 `EdgePanelLayoutFor` 唯一给出(绘制 / 命中 / 光标 / 调试盒 / 缓存键 + 五处共用),改成控件树就要把这套"几何同源"拆掉。 +3. 面板绘制要往 `m_panelDc` 里 `BitBlt` 抓像素、要 `AddOccluder` 挖洞、要按 `m_panelOrder` + 逐层设置文本层号 —— 这些都是 `PlotView` 的内部协议。 + +⇒ **正确的公共化方向是把"按种类分支"收敛成唯一派发点,而不是把面板搬进控件树。** + +### P20 面板「特征层」(提交 `abcf3e6`) + +审计出同一套"按种类分支"被抄了多份: + +| 重复项 | 位置 | 份数 | +| --- | --- | --- | +| 分类布尔量(`listStyle` / `countTable` / `hasButton` …) | `EdgePanelWidth` / `EdgePanelLayoutFor` / `PaintEdgeCursor` | 3 | +| 滚动条几何 + 滚动量夹取 | 布局里表格 / 列表 / 自由标记三分支 | 3 | +| 滚动条绘制(6 行) | 绘制里同样三分支 | 3 | +| `SetLane*Scroll` 实现 | `PlotViewFeatures.cpp` | 7 | +| 按种类转发滚动写入 | 滚轮 / 拖滚动条 | 2 | +| 标题文本 | `EdgePanelWidth` / `PaintEdgeCursor` | 2(**内容还不一致**) | + +新增 `pvdetail::PanelStyle` + `PanelStyleOf(kind)`、`PanelScrollMetricsFor(...)`、 +`PlotView::PanelTitle(kind)`,并让上面全部调用点改成读它们。 +**顺带修掉一处潜伏错误**:`EdgePanelWidth` 的标题链漏了 MARK/CLIP(按"边沿计数"量宽)。 + +⚠️ 过程中踩到并修掉一个真实回归:把 `hasButton` 直接映射到 `PanelStyle::buildButton` 后, +「选区截取」的底部按钮行高归零 ⇒ 面板高度 124 → 87 px。像素基线**当场报 MISMATCH**。 +修正方式:区分 `footerButtons()`(有按钮行)与 `buildButton`(是"构建为新信号")。 + +### P21 滚动收敛(提交 `abcf3e6`) + +* 7 个 `SetLane*Scroll` → 一行转发到新的 `SetLanePanelScroll(laneIndex, kind, scroll)`。 +* 滚轮 / 拖滚动条两条 `switch`/`if` 链 → 直接调它(**读与写从此同一个入口**)。 +* `EdgePanelLayoutFor` 里 5 层滚动量三元式 → `LanePanelScrollOf(lane, kind)` + (该派发点第 33 轮就存在,布局一直没用它)。 + +新增离屏检查 `panelscroll`:7 种面板逐一验证"滚到底 / 滚回顶 / 夹取 / 内容区不动 / +不可滚动时无滚动条"。用 `MF4-Record.MF4`(566 信号并入同一网格)实测 **6/7 可滚** +(自由标记无固定点,不可滚是正确行为)。 + +### P22 整数格式化替换(提交 `34365b3`) + +* 新增 `Utf8Util::FormatInteger` / `FormatIntegerUnsigned` / `FormatIntegerNarrow` + (手写十进制展开,不经 printf)。 +* 全仓替换 **76 处** `std::to_wstring` + **2 处** `std::to_string`(20 个文件); + `MatLoader::TrimNumber` 的整数分支也改(它曾是最后一个引用 MinGW printf 的点)。 +* **实测:−1,024 B**(787,968 → 786,944)。 +* ⚠️ **gdtoa 那条 ≈33 KB 的链仍然去不掉**,原因已定位到具体文件行: + libstdc++ 的 `x86_64-w64-mingw32/bits/os_defines.h` **无条件** + `#undef __USE_MINGW_ANSI_STDIO` + `#define … 1`("libstdc++ depends on POSIX-definitions"), + `g++ -E -dM` 实测最终值恒为 1 ⇒ 任何 C++ TU 的 `swprintf`/`strtod` 必然是 MinGW 版本。 + 叠加 `-D__USE_MINGW_ANSI_STDIO=0` 实测 **0 字节**收益,已回退并在 Makefile 留下结论。 + 要真省这 33 KB 只能自己重写「浮点 → 十进制(正确舍入)」与浮点解析的特殊分支 ⇒ **不做**。 + +### P23 其余审查清理 + +* `PlotView::FreeMarkerValue` 的 `const Lane&` 形参从未使用(函数体里只有一句 `(void)lane;`) + ⇒ 删除形参与该语句,更新 2 个调用点。 +* 「选区截取是否就绪(双光标)」在布局里算了 3 遍 ⇒ 收敛到函数顶部一次 + (它同时决定面板高度 footer、条目行、按钮几何,写错任一处就是"底部多一段空白"或"没双光标却能滚动")。 + +### 本轮验证 + +| 项 | 结果 | +| --- | --- | +| `make release`(删 `.o` 全量) | 零 error / 零 warning;产物 **786,944 B**(起点 787,968,−1,024) | +| `make -C Tools` | 零 error / 零 warning | +| `UnitTests` | `checks=260 failed=0 cases=19`(新增 `integer_replacement_matches_std`,41 条断言) | +| `OffscreenChecks -all` | 6 个检查全 PASS,`checks=87 failed=0`(新增 `panelscroll`) | +| `panels -mode compare` | 7 个面板**像素哈希 + 几何逐项不变**(三次重构全程守住) | +| `panelscroll`(MF4,566 信号) | 6/7 面板可滚,夹取与滑块位置全部符合预期 | + +### 下一步建议(面板方向) + +1. **`EdgePanelLayout` 的平行数组**:`rowHeaders` / `rowNameRects` / `rowTriangles` / + `rowChecks` / `rowPoints` / `rowIterations` / `rowStats` / `rowLevel*` / `rowIter*` / + `rowInfo*` / `rowIntervalField` / `rowFrequencyField` 共 17 个按行平行的 `vector`, + 全仓引用 **314 处**。每加一种参数行都必须在每个分支里按**同一顺序** push 恰好 17 项, + 漏一项 = 某列读到别人的矩形(这正是"改布局就出错"的机制)。 + 建议合并为 `struct PanelRow { … }` + `std::vector rows`。 + **本轮未做**:314 处引用、风险高,需要先补"逐行几何基线"检查。 +2. **`PaintEdgeCursor` 拆分**:该函数约 1600 行,其中三段"内容绘制"(表格 / 列表 / 自由标记) + 各 90–450 行,可机械提取为 3 个成员函数(`PaintPanelCountTableRows` / + `PaintPanelListRows` / `PaintPanelMarkerGroups`)。判据:`panels -mode compare` 全绿。 + **本轮未做**:与 P22 抢时间,且属纯可读性收益。 +3. **给平行数组补检查**:在 `panels` 里增加"每个 row 的 17 项长度一致"断言 + (成本极低,可作为第 1 项的前置护栏)—— 已记入待办。 diff --git a/Documents/Code_Review_Report.md b/Documents/Code_Review_Report.md index 31923a4..dbd90c8 100644 --- a/Documents/Code_Review_Report.md +++ b/Documents/Code_Review_Report.md @@ -391,3 +391,92 @@ Tools/README.md 目录结构、作用、测试项、验收标准、A/B 重写后 `-mode compare`;只要有一处像素/几何差异就说明改坏了。 5. **不要重复这两次实验**:`EdgeScanFor` 哈希化(+5.6 KB / −5 μs 不划算)、 仅切 `__USE_MINGW_ANSI_STDIO`(0 B)。数字见 §11.1 与 `Code_Review_Progress.md` 第 34 轮。 + +--- + +## 12. 第 35 轮(2026-11-04):浮动面板架构评估与收敛 + 整数格式化 + +> 计划见 `Code_Review_Plan.md` §七,逐节点记录见 `Code_Review_Progress.md` 第 35 轮。 + +### 12.1 一句话结论 + +「每个面板骨架一致、但每次改布局就出错」的机制**不是**"缺一个公共基类", +而是**同一套"按种类分支"被抄了 6 份**(分类布尔量 ×3、滚动几何 ×3、滚动条绘制 ×3、 +滚动设置 ×7、按种类转发 ×2、标题 ×2)。本轮把这些抄本收敛成"唯一来源 + 编译期穷举检查", +并补了两条离屏护栏(`panelscroll`、平行数组长度一致), +用 786,944 B(−1,024)的产物体积换来"新增面板只改 3 个地方"。 + +### 12.2 评估:这些面板/菜单算不算"公共组件"? + +| 对象 | 现状 | 结论 | +| --- | --- | --- | +| 右键菜单 / 二级菜单 | `Components/Shared/PopupMenu` + `Layout`/`Paint` 分片 | **已经是公共组件**(5 类菜单共用一份实现) | +| 拾色器浮层 | `Components/Shared/ColorPickerPopup` | **已经是公共组件** | +| 实验性功能浮动面板 | 全部在 `PlotView` 内,骨架一份、**按种类分支多份** | 骨架公共,分支需收敛(本轮已做) | + +**为什么不把面板做成 `UiWidget` 树节点**(像 PopupMenu 那样): +1. 面板像素要进 `m_panelDc` 图层缓存(`PanelGlobalKey ⊕ PanelSlotKey`), + 与 `UiWidget` 的 dirty/capture 体系叠加会互相失效(`SignalPanel` 行位图缓存 + 与控件树就是刻意分开的); +2. 面板几何**必须**由 `EdgePanelLayoutFor` 唯一给出(绘制 / 命中 / 光标 / 调试盒 / + 缓存键五处共用),控件树会拆掉这条"几何同源"; +3. 面板要往 `m_panelDc` `BitBlt` 抓像素、`AddOccluder` 挖洞、按 `m_panelOrder` 逐层设文本层号 + —— 全是 `PlotView` 内部协议。 + +⇒ 结论:**公共化方向是"派发点收敛",不是"搬进控件树"**。本轮照此执行。 + +### 12.3 做了什么(提交 `abcf3e6`、`34365b3`、`本提交`) + +| 项 | 内容 | 效果 | +| --- | --- | --- | +| 面板特征层 | `pvdetail::PanelStyle` + `PanelStyleOf(kind)`、`PanelScrollMetricsFor(...)`、`PlotView::PanelTitle(kind)`;宽度 / 布局 / 绘制三处调用点全部改读 | 分类 3 份 → 1 份;滚动几何 3 份 → 1 份 | +| 潜伏错误修复 | `EdgePanelWidth` 的标题链漏了 MARK/CLIP(按"边沿计数"量宽) | 标题与宽度从此同源 | +| 滚动收敛 | `SetLanePanelScroll` 唯一实现 + 7 个一行包装;滚轮/拖滚动条的 2 条 `switch` 链改直接调用 | 7 份 → 1 份,读写同源 | +| 滚动条绘制 | `paintPanelScrollBar(layout)`,3 处复用 | 3 份 → 1 份 | +| 整数格式化 | `Utf8Util::FormatInteger*` 替代 76 处 `std::to_wstring` + 2 处 `std::to_string` | **−1,024 B** | +| 审查清理 | 删 `FreeMarkerValue` 未使用形参与 `(void)lane;`;`clipReady` 3 处判定收敛为 1 处 | 减少分歧点 | + +### 12.4 ⚠️ 一条建议的最终结论:gdtoa(≈33 KB)**去不掉** + +* 只替换整数转换:**−1,024 B**。 +* 叠加 `-D__USE_MINGW_ANSI_STDIO=0`(`swprintf` 让给 UCRT):**0 字节**。 +* 根因定位到行:libstdc++ 的 + `.../include/c++/x86_64-w64-mingw32/bits/os_defines.h:48-51` **无条件** + `#undef __USE_MINGW_ANSI_STDIO` + `#define … 1`("libstdc++ depends on + POSIX-definitions of those functions")。`g++ -E -dM` 实测最终值恒为 1 + ⇒ **任何 C++ 翻译单元**的 `swprintf` / `strtod` 都是 MinGW 版本,命令行无法覆盖。 +* ⇒ 要省这 33 KB 只能自己重写「浮点 → 十进制(正确舍入)」与浮点解析的特殊分支 + (等价于重写 gdtoa)。**判定:不做**,已在 `Makefile` 留下结论与证据,避免重复尝试。 + +### 12.5 新增的护栏(本轮最重要的产出之一) + +1. **`panelscroll` 检查**:7 种面板逐一"滚到底 / 滚回顶",验证夹取、滑块端点、内容区不动、 + 不可滚时无滚动条。用 `MF4-Record.MF4`(566 信号并入同一网格)实测 6/7 可滚 + (自由标记无固定点,不可滚是正确行为)。 +2. **平行数组长度不变式**(`panels` + `panelscroll` 各一份):`EdgePanelLayout` 里 + 13 个按行平行的 `vector` 必须与行数一致;4 个「重采样」专属数组允许为空(非空则必须对齐)。 + 这条检查成本 O(1),却直接守住"某列读到别人矩形"这一整类错位缺陷。 +3. **像素基线在真实事故中证明了价值**:本轮把 `hasButton` 映射到 `buildButton` 后, + 「选区截取」底部按钮行高归零 ⇒ 面板高度 124 → 87 px,`panels -mode compare` + **当场报 MISMATCH**(另外 4 个面板的哈希也变了 —— 因为面板互相重叠,短掉的那块露出下层)。 + 如果没有这条基线,这个回归只会在用户那里以"截取面板底部怪怪的"形式出现。 + +### 12.6 体积与验证 + +| 项 | 结果 | +| --- | --- | +| Release 产物 | **786,944 B**(第 34 轮末 788,480 → −1,536;其中 −1,024 来自整数格式化,−512 来自面板收敛) | +| `make release` / `make -C Tools` | 零 error / 零 warning(全量重建) | +| `UnitTests` | `checks=260 failed=0 cases=19` | +| `OffscreenChecks -all` | 6 个检查全 PASS,`checks=108 failed=0` | +| `panels -mode compare` | 7 个面板像素哈希 + 几何**逐项不变** | + +### 12.7 后续建议(按性价比) + +1. **`EdgePanelLayout` 的平行数组合并为 `std::vector`**(314 处引用)。 + 这是"改布局就出错"的**最后一块机制性隐患**:每个分支必须按同一顺序 push 恰好 13 项。 + 本轮先补了长度一致检查作为护栏;真正的合并仍需一次专门的重构窗口。 +2. **`PaintEdgeCursor` 拆分**(约 1600 行):把三段内容绘制(表格 / 列表 / 自由标记) + 机械提取为 3 个成员函数,判据 = `panels -mode compare` 全绿。 +3. **`EdgeScanFor` 状态查找**:维持线性扫描(第 34 轮实测 198 状态 0.097 µs/次, + 哈希化需 +5,632 B,不划算)。 diff --git a/Documents/agent_mem.md b/Documents/agent_mem.md index 0395848..d43deba 100644 --- a/Documents/agent_mem.md +++ b/Documents/agent_mem.md @@ -10429,3 +10429,73 @@ release/debug **零警告**;Release `TargetCheck.exe` **890,880 B**(< 1 MB * 单元测试写错过的 5 处"我以为的语义"(都已按产品语义修正): `9.999999999999999`(>15 位有效数字允许 1 ULP)、`1..2` 合法、`FormulaIsIdentity` 不去空格、 非法公式 `EvaluateFormula` **原样返回采样值**(不是 NaN)。 + +--- + +## 2026-11-04 · 第 35 轮(`Code_Review` 分支):浮动面板结构收敛 + 整数格式化替换 + +> 计划 `Documents/Code_Review_Plan.md` §七,逐节点 `Documents/Code_Review_Progress.md` 第 35 轮, +> 结论 `Documents/Code_Review_Report.md` §12。提交:`abcf3e6`(面板)、`34365b3`(整数)、 +> 本提交(清理 + 文档 + 新检查)。 + +### 1. ⭐ 面板"改一次就出错"的真实机制(用户 2026-11-04 指出的问题) + +**不是缺公共基类**,而是同一套"按种类分支"被抄了 6 份: + +| 重复项 | 份数 | +| --- | --- | +| 分类布尔量(listStyle/countTable/hasButton…) | 3(宽度 / 布局 / 绘制) | +| 滚动条几何 + 夹取 | 3(表格 / 列表 / 自由标记分支) | +| 滚动条绘制 | 3 | +| `SetLane*Scroll` 实现 | 7 | +| 按种类转发滚动写入 | 2(滚轮 / 拖滚动条) | +| 标题文本 | 2(**内容还不一致**) | + +**新增硬规则:新增一种实验性功能面板,只允许改三个地方** —— +`LanePanelScrollOf` / `PanelStyleOf` / `PanelTitle`。 +三者都是穷举 `switch` 且**不写 `default:`** ⇒ 漏登记会被 `-Wswitch` 编译报警。 +**禁止**再在宽度 / 布局 / 绘制里各写一份 `kind == XXX` 组合判断。 + +### 2. ⚠️ `PanelStyle` 里"有按钮行"≠"是构建按钮" + +`footerButtons()`(有按钮行:buildButton || twoButtons)与 `buildButton`(是「构建为新信号」) +必须分开:混用会让「选区截取」底部按钮行高归零 ⇒ 面板高度 124 → 87 px, +而内容区仍按旧高度算。**这次是 `panels -mode compare` 当场抓到的**(5 个面板哈希同时变), +它就是"改面板必须跑像素基线"的最好例证。 + +### 3. ⛔ gdtoa(≈33 KB)**在 C++ 里去不掉**(别再试) + +* 替换 76 处 `std::to_wstring` + 2 处 `std::to_string` 为 `Utf8Util::FormatInteger*`:**−1,024 B**。 +* 再叠加 `-D__USE_MINGW_ANSI_STDIO=0`:**0 字节**。 +* 根因(精确到行):libstdc++ 的 + `.../include/c++/x86_64-w64-mingw32/bits/os_defines.h:48-51` **无条件** + `#undef __USE_MINGW_ANSI_STDIO` + `#define … 1` + ("libstdc++ depends on POSIX-definitions of those functions")。 + `g++ -E -dM` 实测最终值恒为 1 ⇒ 任何 C++ TU 的 `swprintf`/`strtod` 都是 MinGW 版本。 +* ⇒ 要省它只能自己重写浮点↔十进制(等价 gdtoa)。**判定:不做**,`Makefile` 已留结论。 + +### 4. 面板"公共组件"评估结论(用户问的) + +* **右键菜单 / 二级菜单**:`Components/Shared/PopupMenu` —— 已是公共组件(5 类菜单共用)。 +* **拾色器**:`Components/Shared/ColorPickerPopup` —— 已是公共组件。 +* **实验性功能面板**:骨架已是一份(标题栏 = 折起三角 + GX 徽标 + 标题 + ✕ / 滚动内容区 / + 底部按钮行),**不该**搬进 `UiWidget` 树:面板像素走 `m_panelDc` 图层缓存、 + 几何必须由 `EdgePanelLayoutFor` 唯一给出、还要 `BitBlt` 抓像素 + `AddOccluder` 挖洞 + + 按 `m_panelOrder` 设文本层号 —— 全是 `PlotView` 内部协议。 + ⇒ 正确方向是**派发点收敛**(本轮已做),不是控件树化。 + +### 5. 新增护栏(本轮最重要的产出) + +1. **`panelscroll`**:7 种面板逐一滚到底/回顶,验证夹取、滑块端点、内容区不动、 + 不可滚时无滚动条。`MF4-Record.MF4`(566 信号并入同一网格)实测 6/7 可滚。 +2. **平行数组长度不变式**(`panels` + `panelscroll`):`EdgePanelLayout` 的 13 个按行平行 + `vector` 必须等于行数;4 个「重采样」专属数组允许为空、非空则必须对齐。 + O(1) 成本,守住"某列读到别人矩形"这一整类错位。 +3. 工具断言总数:UnitTests **260**、OffscreenChecks **108**(-all)。 + +### 6. 遗留(下一步候选,按性价比) + +* `EdgePanelLayout` 17 个平行数组合并为 `std::vector` —— **314 处引用**, + 是"改布局就出错"的最后一块机制隐患;已先用长度检查兜住。 +* `PaintEdgeCursor`(约 1600 行)把三段内容绘制机械提取为 3 个成员函数, + 判据 = `panels -mode compare` 全绿。 diff --git a/Tools/OffscreenChecks/Src/Checks.cpp b/Tools/OffscreenChecks/Src/Checks.cpp index e4e9698..860c684 100644 --- a/Tools/OffscreenChecks/Src/Checks.cpp +++ b/Tools/OffscreenChecks/Src/Checks.cpp @@ -417,6 +417,69 @@ namespace jdi rect.y + rect.height <= plotRect.y + plotRect.height + 1; Expect(inside, std::string("panel inside plot area: ") + PanelKindName(kind)); + // 不变式:`EdgePanelLayout` 的**平行数组长度必须全部等于行数**。 + // 这 13 个按行平行的 vector 每个分支都要按同一顺序 push 恰好 13 项, + // 漏一项就会让某一列读到别的行的矩形("改布局就出错"的典型机制)。 + // ⚠️ 「重采样」专属的 4 个数组(最大/最小间隔、间隔/频率输入框)在**其它** + // 面板里允许为空(没人读),但只要非空就必须同样与行数对齐。 + // 成本极低(O(1) 比长度),但能挡住整类错位缺陷。 + const std::size_t rowCount = layout.rowRefs.size(); + const std::size_t coreSizes[] = { + layout.rowRefs.size(), layout.rowHeaders.size(), + layout.rowNameRects.size(), layout.rowTriangles.size(), + layout.rowChecks.size(), layout.rowPoints.size(), + layout.rowIterations.size(), layout.rowStats.size(), + layout.rowLevelTracks.size(), layout.rowLevelValues.size(), + layout.rowLevelResets.size(), layout.rowIterTracks.size(), + layout.rowIterValues.size(), layout.rowIterResets.size()}; + bool parallel = true; + for (const std::size_t size : coreSizes) + { + if (size != rowCount) + { + parallel = false; + break; + } + } + const std::size_t resampleSizes[] = { + layout.rowInfoMaxInterval.size(), layout.rowInfoMinInterval.size(), + layout.rowIntervalField.size(), layout.rowFrequencyField.size()}; + bool resampleParallel = true; + for (const std::size_t size : resampleSizes) + { + if (size != 0 && size != rowCount) + { + resampleParallel = false; + break; + } + } + if (!parallel || !resampleParallel) + { + EmitLine("[panels] %s row arrays: refs=%d hdr=%d name=%d tri=%d chk=%d pts=%d it=%d " + "stat=%d lvl=%d/%d/%d iter=%d/%d/%d info=%d/%d fld=%d/%d\n", + PanelKindName(kind), static_cast(layout.rowRefs.size()), + static_cast(layout.rowHeaders.size()), + static_cast(layout.rowNameRects.size()), + static_cast(layout.rowTriangles.size()), + static_cast(layout.rowChecks.size()), + static_cast(layout.rowPoints.size()), + static_cast(layout.rowIterations.size()), + static_cast(layout.rowStats.size()), + static_cast(layout.rowLevelTracks.size()), + static_cast(layout.rowLevelValues.size()), + static_cast(layout.rowLevelResets.size()), + static_cast(layout.rowIterTracks.size()), + static_cast(layout.rowIterValues.size()), + static_cast(layout.rowIterResets.size()), + static_cast(layout.rowInfoMaxInterval.size()), + static_cast(layout.rowInfoMinInterval.size()), + static_cast(layout.rowIntervalField.size()), + static_cast(layout.rowFrequencyField.size())); + } + Expect(parallel, std::string("row arrays have identical lengths: ") + PanelKindName(kind)); + Expect(resampleParallel, + std::string("resample row arrays empty-or-aligned: ") + PanelKindName(kind)); + char buffer[160] = {}; std::snprintf(buffer, sizeof(buffer), "%s=%016llX,%d,%d,%d,%d\n", PanelKindName(kind), static_cast(HashRegion(session.LastFrame(), rect)), rect.x, @@ -537,9 +600,36 @@ namespace jdi const PlotView::EdgePanelLayout initial = plot.EdgePanelLayoutFor(0, kind); const float maxScroll = std::max(0.0f, initial.markerContentHeight - initial.content.Height); - EmitLine("[panelscroll] %-8s content=%.1f view=%.1f maxScroll=%.1f scrollable=%d\n", name, - initial.markerContentHeight, initial.content.Height, maxScroll, - initial.scrollTrack.Width > 0.0f ? 1 : 0); + EmitLine("[panelscroll] %-8s content=%.1f view=%.1f maxScroll=%.1f scrollable=%d rows=%d\n", + name, initial.markerContentHeight, initial.content.Height, maxScroll, + initial.scrollTrack.Width > 0.0f ? 1 : 0, + static_cast(initial.rowRefs.size())); + + // 多信号场景下额外守住"平行数组长度一致"(本轮新增,成本 O(1))。 + { + const std::size_t rows = initial.rowRefs.size(); + const std::size_t sizes[] = {initial.rowHeaders.size(), initial.rowNameRects.size(), + initial.rowTriangles.size(), initial.rowChecks.size(), + initial.rowPoints.size(), initial.rowIterations.size(), + initial.rowStats.size(), initial.rowLevelTracks.size(), + initial.rowLevelValues.size(), initial.rowLevelResets.size(), + initial.rowIterTracks.size(), initial.rowIterValues.size(), + initial.rowIterResets.size(), initial.rowInfoMaxInterval.size(), + initial.rowInfoMinInterval.size(), + initial.rowIntervalField.size(), + initial.rowFrequencyField.size()}; + bool aligned = true; + for (const std::size_t size : sizes) + { + // 「重采样」专属数组在其它面板允许为空(见 `panels` 检查的说明)。 + if (size != rows && size != 0) + { + aligned = false; + break; + } + } + Expect(aligned, std::string("row arrays aligned with rows: ") + name); + } if (maxScroll <= 1.0f) { diff --git a/Tools/README.md b/Tools/README.md index cb973da..ac21064 100644 --- a/Tools/README.md +++ b/Tools/README.md @@ -124,12 +124,13 @@ SUMMARY checks= failed= cases= result= | `filter_moving_average_equivalence` | 1 阶 ≡ 单次、2 阶 ≡ 链式、常数信号不变 | 均值滤波多阶 | | `dataset_group_access` | `AddGroup` / `GroupAt` / `Style(ref)` 读写 | 数据层入口 | | `dataset_formula_transform_cache` | 值域 + 逐分组极值 vs 逐点参考**逐位相同**;命中同指针;样式变化失效 | 第 32 轮核心缓存的正确性 | -| `stdio_mingw_vs_ucrt_formats` | MinGW ANSI stdio 与系统 UCRT 的 printf 在**本程序用到的全部格式**上逐字符比对(13.6 万次调用) | 评估“把 `swprintf` 交给 UCRT”是否可行(当前 0 差异);将来真做迁移就是验收标准 | +| `stdio_mingw_vs_ucrt_formats` | MinGW ANSI stdio 与系统 UCRT 的 printf 在**本程序用到的全部格式**上逐字符比对(13.6 万次调用) | 评估“把 `swprintf` 交给 UCRT”是否可行(结论:0 字节,libstdc++ 强制开 MinGW 版本) | +| `integer_replacement_matches_std` | `FormatInteger*` 与 `std::to_wstring` / `std::to_string` 逐字符对照(含 LLONG_MIN/MAX、ULLONG_MAX) | 全仓 78 处整数转换的语义护栏(它们已替代标准库,以去掉 printf 依赖) | ### 3.2 验收标准 * **零失败**(`result=PASS`)才算通过; -* 断言数量只增不减(当前 **219** 项 / 18 个用例):删断言必须说明理由; +* 断言数量只增不减(当前 **260** 项 / 19 个用例):删断言必须说明理由; * 任何"为了让它过"而放宽的断言,必须在代码注释里写清依据。 --- @@ -162,7 +163,8 @@ Tools\OffscreenChecks\Build\OffscreenChecks.exe -list | --- | --- | --- | | `smoke` | 建隐藏窗口 → 加载 → 显示 3 条信号 → 渲染 → 抓帧 → 落盘 `smoke.png` | 客户区/绘图区尺寸合理、绘图区墨迹 > 1000 像素、**同一状态连续两帧逐像素相同**(渲染确定性) | | `frames` | 沿信号列表逐行移动"鼠标",每帧抓图 + 区域哈希 + 耗时 | 平均帧 < 500ms、帧序列**至少出现一次可见变化**;输出 `frame-XXX.png` 供事后分析 | -| `panels` | 打开全部 7 个实验性面板(含双光标下的「选区截取」) | 每个面板:功能已启用、有尺寸、**必须被绘图区夹住**;`-mode record` 写 `panels-baseline.txt`,`-mode compare` 逐面板比对**像素哈希 + 几何** | +| `panels` | 打开全部 7 个实验性面板(含双光标下的「选区截取」) | 每个面板:功能已启用、有尺寸、**必须被绘图区夹住**、**平行数组长度与行数一致**;`-mode record` 写 `panels-baseline.txt`,`-mode compare` 逐面板比对**像素哈希 + 几何** | +| `panelscroll` | 全部信号合并到同一网格后,逐一滚动 7 种面板的内容区 | 可滚动时:滚到底/滚回顶、`scrollOffset` 夹到 `maxScroll`、滑块到达/返回轨道端点、内容区几何不动;不可滚动时不得出现滚动条。另守住平行数组一致性 | | `edgestats` | 3 个阈值下的边沿计数(普通计数)+ 中心轴计数 | 与 `Checks.cpp` 内的**独立参考实现**(朴素逐点)**逐位相等**;同参数重复查询结果稳定 | | `perf` | 整帧中位数、`EdgePanelLayoutFor` 单次耗时、198 个统计状态的整轮查询耗时 | 整帧 < 500ms、面板几何 < 200µs/次、统计查询 < 20µs/次;数字用于前后对比 | @@ -199,9 +201,10 @@ OffscreenChecks.exe -run panels -mode compare -out ..\Temp\tools\panel-before | --- | --- | | 任何数据层 / 解析 / 公式 / 变换 | `UnitTests.exe` | | 绘图、面板、布局、缓存键 | `-run smoke -run panels`(+ 需要时 `-mode compare`) | +| 面板内容滚动 / 面板几何 / 新增面板种类 | `-run panels -run panelscroll`(大文件加 `-data ..\TestData\MF4-Record.MF4`) | | 边沿统计 / 中心轴 / 扫描预算 | `-run edgestats -run perf` | | 性能相关 | `-run perf`(前后各跑一次,比 `us/query` 与 `median ms`) | -| 完全不确定影响面 | `-run -all`(约几秒到几十秒)+ `UnitTests.exe` | +| 完全不确定影响面 | `-all`(约几秒到几十秒)+ `UnitTests.exe` | > 用户明确要求:**除非要求"全测一遍",否则只跑针对性检查**。 -- 2.54.0 From 35b67b216e0bd28b6ed3122e6d7edd239bdae1ea Mon Sep 17 00:00:00 2001 From: "DeepSeek V4.1 Flash" <> Date: Wed, 4 Nov 2026 14:39:27 +0800 Subject: [PATCH 14/22] =?UTF-8?q?test(tools):=20=E9=BB=98=E8=AE=A4?= =?UTF-8?q?=E6=95=B0=E6=8D=AE/=E6=8A=A5=E5=91=8A=E8=B7=AF=E5=BE=84?= =?UTF-8?q?=E5=90=8C=E6=97=B6=E6=94=AF=E6=8C=81=E5=9C=A8=E4=BB=93=E5=BA=93?= =?UTF-8?q?=E6=A0=B9=E7=9B=AE=E5=BD=95=E8=BF=90=E8=A1=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 问题:`OffscreenChecks` 的默认 `-data` 只探测 `../TestData/...`,默认 `-report` 也固定写 `../Temp/tools/...`;在**仓库根目录**运行时会静默报 "no data file"(退出码 2),容易被误认为工具坏了(本轮实际踩到)。 改动: * 默认数据文件候选同时包含 `../TestData/X` 与 `TestData/X`; * 报告打不开时,若路径以 `../Temp/` 开头则去掉前缀重试一次; * `Tools/README.md` 补充说明两种调用方式等价,显式 `-data/-out/-report` 仍以当前工作目录为准。 验证:在仓库根目录执行 `Tools\OffscreenChecks\Build\OffscreenChecks.exe -all` → 6/6 检查 PASS,`checks=108 failed=0`;在 `Tools/` 下行为不变。 --- Tools/OffscreenChecks/Src/Main.cpp | 18 +++++++++++++++--- Tools/README.md | 8 ++++++++ 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/Tools/OffscreenChecks/Src/Main.cpp b/Tools/OffscreenChecks/Src/Main.cpp index 11d92bd..4882aea 100644 --- a/Tools/OffscreenChecks/Src/Main.cpp +++ b/Tools/OffscreenChecks/Src/Main.cpp @@ -160,18 +160,30 @@ int main(int argc, char** argv) EnsureDirectory(options.outDir); + // 报告路径同样要能在两种工作目录下工作(见上面 `-data` 的说明)。 g_report = std::fopen(reportPath.c_str(), "wb"); + if (g_report == nullptr && reportPath.rfind("../Temp/", 0) == 0) + { + reportPath = reportPath.substr(3); // 去掉 "../" + g_report = std::fopen(reportPath.c_str(), "wb"); + } EmitLine("==============================================================\n"); EmitLine(" Just DataInspector - offscreen checks\n"); EmitLine(" mode=%s out=%s report=%s\n", options.mode.c_str(), options.outDir.c_str(), reportPath.c_str()); EmitLine("==============================================================\n"); - // 数据文件:命令行优先,否则在 TestData 里挑一个可用的样本 + // 数据文件:命令行优先,否则在 TestData 里挑一个可用的样本。 + // ⚠️ 默认路径同时支持「在 Tools/ 下运行」与「在仓库根目录运行」两种习惯: + // 前者要 `../TestData/...`,后者要 `TestData/...`。只认一种会让另一种 + // 静默报 "no data file"(本轮就踩过,容易被误认为工具坏了)。 if (options.dataPath.empty()) { - static const char* const candidates[] = {"../TestData/SCATTER_TEST.csv", "../TestData/[1].CSV", - "../TestData/BIG_DATA.csv", "../TestData/Problem_Data.CSV"}; + static const char* const candidates[] = {"../TestData/SCATTER_TEST.csv", "TestData/SCATTER_TEST.csv", + "../TestData/[1].CSV", "TestData/[1].CSV", + "../TestData/BIG_DATA.csv", "TestData/BIG_DATA.csv", + "../TestData/Problem_Data.CSV", + "TestData/Problem_Data.CSV"}; for (const char* const candidate : candidates) { if (FileExists(candidate)) diff --git a/Tools/README.md b/Tools/README.md index ac21064..27ca1ac 100644 --- a/Tools/README.md +++ b/Tools/README.md @@ -157,6 +157,14 @@ Tools\OffscreenChecks\Build\OffscreenChecks.exe -list `SCATTER_TEST.csv` → `[1].CSV` → `BIG_DATA.csv` → `Problem_Data.CSV`; `TestData/` 不入库,缺失时请用 `-data` 指定。 +> 默认路径(数据 / 输出 / 报告)**同时支持在 `Tools/` 下运行和在仓库根目录运行** +> (两种写法会自动回退),因此下面两种调用等价: +> ```powershell +> cd Tools ; .\OffscreenChecks\Build\OffscreenChecks.exe -all +> .\Tools\OffscreenChecks\Build\OffscreenChecks.exe -all +> ``` +> 显式 `-data` / `-out` / `-report` 仍以**当前工作目录**为准。 + ### 4.1 检查项 | 检查 | 做了什么 | 断言 / 判据 | -- 2.54.0 From 38fbd4b465d635d1be0a598ad834aaf3d3d1b254 Mon Sep 17 00:00:00 2001 From: "DeepSeek V4.1 Flash" <> Date: Wed, 4 Nov 2026 15:13:00 +0800 Subject: [PATCH 15/22] =?UTF-8?q?refactor(panels):=20=E6=8B=86=E5=88=86=20?= =?UTF-8?q?PaintEdgeCursor=20=E7=9A=84=E4=B8=89=E6=AE=B5=E5=86=85=E5=AE=B9?= =?UTF-8?q?=E7=BB=98=E5=88=B6=E5=88=B0=E7=8B=AC=E7=AB=8B=E5=88=86=E7=89=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 背景:Code Review 报告 §12.7 建议 2。`PaintEdgeCursor` 原本约 1900 行,其中 "按面板排版类别绘制内容"占 1000 行以上,三段(表格 / 列表 / 自由标记)几乎互不相干, 却共享同一个函数的全部局部变量 —— 任何一段的改动都要在 1900 行里定位, 且很容易误用另一段的变量。 改动 * 新增 `Components/PlotView/Src/PlotViewPanelPaint.cpp`(1016 行,机械搬移、零逻辑改动): * `PaintPanelCountTableRows` —— 边沿计数表格:固定表头 + 可滚动数据行。 * `PaintPanelListRows` —— 列表式:条目栏 + 展开后的参数行(均值/中心轴/弱峰/重采样/截取)。 * `PaintPanelMarkerGroups` —— 自由标记:分组栏 + 表头 + 条目 + 吸顶。 每个函数**只接收它真正需要的东西**(`layout` + `tabLane` + 少数几个参数), 不再"整个函数都看得见"。 * `PaintEdgeCursor` 从 1900 → 约 900 行;三处块替换为一行调用, 骨架(边框 / 标题栏 / 折起三角 / GX 徽标 / ✕)与底部按钮**仍留在一处**(对所有面板同源)。 * 滚条绘制抽为 `pvdetail::PaintPanelScrollBar(g, layout, theme)`(三个分支共用)。 顺带修掉报告里承诺但未落地的一处 * `PaintEdgeCursor` 的标题仍是 7 层三元式,与 `EdgePanelWidth` 里**另一条**链并存 (那条漏了 MARK/CLIP)。现统一为 `PanelTitle(kind)` 唯一来源。 验证(像素基线全部不变,证明是纯搬移) * `panels -mode compare` → 7 个面板像素哈希 + 几何**逐项不变**。 * `OffscreenChecks -all` → 6/6 PASS,`checks=108 failed=0`。 * `UnitTests` → `checks=260 failed=0`。 * `panelscroll`(MF4,566 信号)→ 6/7 面板可滚,行为不变。 * release 全量零 error / 零 warning;产物 786,944 → **786,432 B**(−512)。 --- Components/PlotView/Inc/PlotView.hpp | 44 +- Components/PlotView/Inc/PlotViewInternal.hpp | 22 + Components/PlotView/Src/PlotViewPaint.cpp | 1011 +--------------- .../PlotView/Src/PlotViewPanelPaint.cpp | 1016 +++++++++++++++++ 4 files changed, 1097 insertions(+), 996 deletions(-) create mode 100644 Components/PlotView/Src/PlotViewPanelPaint.cpp diff --git a/Components/PlotView/Inc/PlotView.hpp b/Components/PlotView/Inc/PlotView.hpp index 45c2a8f..1449abc 100644 --- a/Components/PlotView/Inc/PlotView.hpp +++ b/Components/PlotView/Inc/PlotView.hpp @@ -1946,7 +1946,6 @@ namespace jdi * @param [IN] lanes 网格绘制信息集合。 */ void PaintEdgeCursor(gd::Graphics& g, const std::vector& lanes); - /** * @brief 确保面板图层缓存位图可用(尺寸不符时重建)。 * @return 可用返回 true。 @@ -2213,6 +2212,49 @@ namespace jdi }; }; + /** + * @brief 「边沿计数」表格面板的**行内容**(固定表头 + 可滚动数据行)。 + * + * 面板骨架(边框 / 标题栏 / ✕ / 滚动条外观)由 `PaintEdgeCursor` 负责, + * 这里只画表格内部。定义在 `PlotViewPanelPaint.cpp`。 + * + * @param [IN,OUT] g 绘图上下文。 + * @param [IN] layout 面板布局(表头、列边界、行矩形全部来自它)。 + * @param [IN] tabLane 该网格配置(横向光标阈值)。 + */ + void PaintPanelCountTableRows(gd::Graphics& g, const EdgePanelLayout& layout, + const Lane& tabLane); + + /** + * @brief 列表式面板(均值滤波 / 中心轴 / 弱峰调整 / 重采样 / 选区截取)的**行内容**。 + * + * 条目栏(三角 + 名称 + 复选框)+ 展开后的参数行;参数行形态由 `kind` 决定。 + * 定义在 `PlotViewPanelPaint.cpp`。 + * + * @param [IN,OUT] g 绘图上下文。 + * @param [IN] layout 面板布局。 + * @param [IN] tabLane 该网格配置(勾选状态 / 参数 / 悬停与拖拽态)。 + * @param [IN] kind 面板种类。 + * @param [IN] laneIndex 网格下标。 + */ + void PaintPanelListRows(gd::Graphics& g, const EdgePanelLayout& layout, const Lane& tabLane, + EdgePanelKind kind, std::size_t laneIndex); + + /** + * @brief 「自由标记」面板的**内容区**(分组栏 + 表头 + 条目 + 吸顶)。 + * + * 绘制顺序固定为 条目 → 表头 → 吸顶分组栏(最后画的压住向上滚动的条目)。 + * 定义在 `PlotViewPanelPaint.cpp`。 + * + * @param [IN,OUT] g 绘图上下文。 + * @param [IN] layout 面板布局(含 `markerGroups` / `markerRows`)。 + * @param [IN] tabLane 该网格配置(透明度 / 选中行 / 悬停态)。 + * @param [IN] tabLanes 全部网格(取各列实测宽度)。 + * @param [IN] laneIndex 网格下标。 + */ + void PaintPanelMarkerGroups(gd::Graphics& g, const EdgePanelLayout& layout, const Lane& tabLane, + std::vector& tabLanes, std::size_t laneIndex); + /** * @brief 计算某个实验性功能面板的布局。 * @param [IN] laneIndex 网格下标。 diff --git a/Components/PlotView/Inc/PlotViewInternal.hpp b/Components/PlotView/Inc/PlotViewInternal.hpp index c1990d9..8ad0615 100644 --- a/Components/PlotView/Inc/PlotViewInternal.hpp +++ b/Components/PlotView/Inc/PlotViewInternal.hpp @@ -1314,6 +1314,28 @@ namespace jdi return metrics; } + /** + * @brief 绘制浮动面板**内容区滚动条**(轨道 + 滑块)。 + * + * 三个排版分支(表格 / 列表 / 自由标记)共用同一段外观:任何微调(颜色、圆角、 + * 最小滑块高度)只需改这里一处。`scrollTrack` 为空矩形时直接返回。 + * + * @param [IN] g 目标绘制上下文。 + * @param [IN] layout 面板布局(取 `scrollTrack` / `scrollThumb`)。 + * @param [IN] theme 当前主题。 + */ + inline void PaintPanelScrollBar(gd::Graphics& g, const PlotView::EdgePanelLayout& layout, + const Theme& theme) + { + if (layout.scrollTrack.Width <= 0.0f) + { + return; + } + UiFillRoundRect(g, layout.scrollTrack, layout.scrollTrack.Width * 0.5f, + WithAlpha(theme.border, 0x88)); + UiFillRoundRect(g, layout.scrollThumb, layout.scrollThumb.Width * 0.5f, theme.borderStrong); + } + /** * @brief 计算一条派生曲线在给定采样窗口内的取值(同步路径与后台线程共用)。 * diff --git a/Components/PlotView/Src/PlotViewPaint.cpp b/Components/PlotView/Src/PlotViewPaint.cpp index 871411c..8edfb81 100644 --- a/Components/PlotView/Src/PlotViewPaint.cpp +++ b/Components/PlotView/Src/PlotViewPaint.cpp @@ -4784,38 +4784,6 @@ namespace jdi } } - // ---- 统计浮动面板(最后绘制,覆盖在波形之上)---- - // 「边沿计数」「边沿计数(中心轴)」「均值滤波」「弱峰调整」共用同一套样式, - // 仅标题、内容行与底部按钮不同。 - // - // 面板是**静态**内容:只有被拖动 / 悬停 / 参数变化的那一个真正重画, - // 其余直接贴上一帧缓存好的像素(15 个面板全画一遍实测约 33ms, - // 而贴图 <1ms —— 拖动弱峰调整滑块时的主要卡顿就来自这一层)。 - // 「上升沿 / 下降沿」两列排版(列起点固定 ⇒ 多条信号的文字严格垂直对齐)。 - // ⚠️ 未完成时**两个数字后面都带 `…`**(旧实现只在整行末尾追加一次, - // 视觉上只跟在“下降沿”的数字后面;用户明确要求为“上升沿”也补上这个尾缀)。 - const auto paintCounts = [this, &g](const gd::RectF& rect, std::size_t rising, - std::size_t falling, bool complete, const gd::RectF& clip) - { - // ⚠️ **必须用 `UiTextClipped`**:GDI 文本不受 GDI+ `SetClip` 约束, - // 内容区滚动时统计文字会画到面板标题栏 / 面板之外 - //(用户 2026-11-02 报告“滚动时绘制超出了浮动面板”的直接原因)。 - if (EdgeCountsPending(rising, falling, complete)) - { - UiTextClipped(g, L"统计中…", m_fonts->monoSmall, rect, m_theme->textMuted, - TextAlign::LEFT, clip); - return; - } - const float gap = kEdgePanelStatColumnGap * m_scale; - const float columnWidth = std::max(10.0f, (rect.Width - gap) * 0.5f); - UiTextClipped(g, EdgeRisingText(rising, complete), m_fonts->monoSmall, - gd::RectF(rect.X, rect.Y, columnWidth, rect.Height), m_theme->textMuted, - TextAlign::LEFT, clip); - UiTextClipped(g, EdgeFallingText(falling, complete), m_fonts->monoSmall, - gd::RectF(rect.X + columnWidth + gap, rect.Y, columnWidth, rect.Height), - m_theme->textMuted, TextAlign::LEFT, clip); - }; - std::vector nextSlots; std::vector dirtyRects; const std::uint64_t panelGlobalKey = PanelGlobalKey(); @@ -4894,28 +4862,13 @@ namespace jdi UiSetTextLayer(panelTextLayer + static_cast(panelOrdinal)); // ⚠️ 排版类别只从唯一来源取(`PanelStyleOf`):过去宽度 / 布局 / 本函数各写一份 // 6 个 `kind == XXX` + 3 条组合判断,新增面板漏改一处就错位(见该函数注释)。 + // NOTE: layout classification comes from PanelStyleOf(kind) only. + // The three content painters below also read it, so nothing here re-derives + // "kind == XXX" combinations. const PanelStyle panelStyle = PanelStyleOf(kind); - const bool centerAxis = (kind == EdgePanelKind::CENTER); - const bool meanFilter = (kind == EdgePanelKind::MEAN); - const bool weakPeak = (kind == EdgePanelKind::PEAK); - const bool freeMarker = (kind == EdgePanelKind::MARK); - const bool resamplePanel = (kind == EdgePanelKind::RESAMPLE); - const bool clipPanel = (kind == EdgePanelKind::CLIP); - const bool listStyle = panelStyle.listRows; - const bool countTable = panelStyle.countTable; + const bool freeMarker = panelStyle.markerFooter; + const bool clipPanel = panelStyle.twoButtons; const bool hasButton = panelStyle.buildButton; - // 内容区滚动条:三个排版分支(表格 / 列表 / 自由标记)共用同一段绘制。 - // ⚠️ 过去这段 6 行代码在三个分支里各抄一份,任何外观微调(颜色 / 圆角)都要改三处。 - const auto paintPanelScrollBar = [this, &g](const EdgePanelLayout& panelLayout) { - if (panelLayout.scrollTrack.Width <= 0.0f) - { - return; - } - UiFillRoundRect(g, panelLayout.scrollTrack, panelLayout.scrollTrack.Width * 0.5f, - WithAlpha(m_theme->border, 0x88)); - UiFillRoundRect(g, panelLayout.scrollThumb, panelLayout.scrollThumb.Width * 0.5f, - m_theme->borderStrong); - }; { if (index >= count) { @@ -5046,7 +4999,6 @@ namespace jdi PanelTextCollector panelTextBoxes(UiGetTextRectSink()); const float titleHeight = kEdgePanelTitleHeight * m_scale; - const float pad = kEdgePanelPad * m_scale; // 以下整段(面板底色 → 标题栏 → 各控件 → 行内容)的画字都要走本地收集器。 UiSetTextRectSink(&panelTextBoxes); @@ -5108,17 +5060,10 @@ namespace jdi badgeHot ? m_theme->accent : m_theme->border, badgeHot ? 1.4f : 1.0f); UiText(g, L"G" + FormatInteger(index + 1), m_fonts->monoSmall, badgeRect, badgeHot ? m_theme->textStrong : m_theme->text, TextAlign::CENTER); - const std::wstring title = - meanFilter ? std::wstring(L"均值滤波") - : (weakPeak ? std::wstring(kWeakPeakTitle) - : (freeMarker ? std::wstring(L"自由标记") - : (resamplePanel - ? std::wstring(kResampleTitle) - : (clipPanel - ? std::wstring(kClipTitle) - : ((centerAxis ? L"中心轴边沿计数" - : L"边沿计数") + - EdgeRangeSuffix()))))); + // ⚠️ 标题取 `PanelTitle(kind)` 这一**唯一来源**(与 `EdgePanelWidth` 量宽用的是同一条链)。 + // 过去这里是 7 层三元式、宽度那边是**另一条**(漏了 MARK/CLIP), + // 任何文案改动都可能让宽度与标题不匹配(标题被省略 / 右侧留白)。 + const std::wstring title = PanelTitle(kind); UiText(g, title, m_fonts->uiSmall, gd::RectF(badgeRect.X + badgeRect.Width + 8.0f * m_scale, panel.Y, panel.Width - badgeRect.Width - 8.0f * m_scale - titleHeight, @@ -5153,948 +5098,24 @@ namespace jdi // 「边沿计数」= **表格**:表头(信号名 / 上升沿计数 / 下降沿计数)**固定不滚动**, // 下面每条可见信号一行,行底色 = 该信号的配色,三列与表头严格对齐。 // 内容超高时右侧出现滚动条(只占内容区,不覆盖表头)。 - if (countTable) + if (panelStyle.countTable) { - // ---- 表头 ---- - { - UiFillRoundRect(g, layout.tableHeader, 3.0f * m_scale, m_theme->pressed); - UiDrawRoundRect(g, layout.tableHeader, 3.0f * m_scale, m_theme->border, 1.0f); - for (int column = 0; column < 3; ++column) - { - const EdgePanelLayout::TableColumn& cell = layout.tableColumns[column]; - if (cell.width <= 0.0f) - { - continue; - } - const float textLeft = - (column == 0) ? (cell.left + 4.0f * m_scale) : cell.left; - UiTextClipped(g, EdgeCountTableHeader(column), m_fonts->uiSmall, - gd::RectF(textLeft, layout.tableHeader.Y, cell.width, - layout.tableHeader.Height), - m_theme->textMuted, - (column == 0) ? TextAlign::LEFT : TextAlign::CENTER, - layout.tableHeader); - } - } - - const int tableSave = g.Save(); - // 行内容必须裁到内容区:滚动时行会越出可视范围。 - g.SetClip(layout.content, gd::CombineModeIntersect); - - for (std::size_t rowIndex = 0; rowIndex < layout.rowRefs.size(); ++rowIndex) - { - if (rowIndex >= layout.rowHeaders.size() || - rowIndex >= layout.rowNameRects.size()) - { - break; - } - const SignalRef& ref = layout.rowRefs[rowIndex]; - const ResolvedSignal resolved = ResolveSignal(ref); - if (resolved.data == nullptr || resolved.style == nullptr) - { - continue; - } - const gd::RectF& row = layout.rowHeaders[rowIndex]; - if (row.Y + row.Height <= layout.content.Y || - row.Y >= layout.content.Y + layout.content.Height) - { - continue; - } - const std::uint32_t signalColor = StyleColor(*resolved.style); - const UiInk ink = UiContrastInk(signalColor); - // 行底色 = 该信号的配色(用户 2026-11-02 要求)。 - UiFillRoundRect(g, row, 3.0f * m_scale, signalColor); - UiFillRoundRect(g, row, 3.0f * m_scale, WithAlpha(signalColor, 0xE8)); - - // 信号名(第一列):超出列宽时省略。 - const gd::RectF& nameRect = layout.rowNameRects[rowIndex]; - UiTextClipped(g, UiEllipsize(resolved.data->name, m_fonts->uiSmall, - std::max(8.0f, nameRect.Width)), - m_fonts->uiSmall, nameRect, ink.text, TextAlign::LEFT, - layout.content); - - // 两列计数:与表头列**同一左边界 / 同一宽度**,纵向严格对齐。 - std::size_t rising = 0; - std::size_t falling = 0; - bool complete = true; - if (resolved.axis != nullptr) - { - EdgeCountsFor(*resolved.axis, *resolved.data, *resolved.style, - tabLane.edgeValue, EdgeRangeActive(), EdgeRangeMin(), - EdgeRangeMax(), rising, falling, complete); - } - for (int column = 1; column < 3; ++column) - { - const EdgePanelLayout::TableColumn& cell = layout.tableColumns[column]; - if (cell.width <= 0.0f) - { - continue; - } - const std::wstring text = - EdgeCountCellText((column == 1) ? rising : falling, complete); - UiTextClipped(g, text, m_fonts->monoSmall, - gd::RectF(cell.left, row.Y, cell.width, row.Height), ink.text, - TextAlign::CENTER, layout.content); - } - } - - g.Restore(tableSave); - - // 内容滚动条(只在内容超高时出现;表头不参与滚动)。 - paintPanelScrollBar(layout); + PaintPanelCountTableRows(g, layout, tabLane); } // 「均值滤波」面板:逐信号的**信号色叠底标题栏**(左侧展开三角 + 信号名 + // 右端复选框)。复选框勾选后才出现三角、才能展开下面的两行参数滑块 // (「均值点数」/「迭代次数」);内容超高时可滚动(滚动条在内容区右侧)。 - if (listStyle) + if (panelStyle.listRows) { - const int meanSave = g.Save(); - // 内容必须裁到内容区:滚动时行会越出可视范围。 - g.SetClip(layout.content, gd::CombineModeIntersect); - - const float valueColumn = - std::max(UiMeasureTextWidth(m_fonts->monoSmall, L"100000"), - UiMeasureTextWidth(m_fonts->monoSmall, L"1000")); - - // 复选框自绘(主题色在信号色叠底上看不清):用**自适应对比色**描边/填充。 - const auto paintMeanCheck = [this, &g](const gd::RectF& rect, bool checked, - const UiInk& ink, - std::uint32_t background) - { - if (rect.Width <= 0.0f || rect.Height <= 0.0f) - { - return; - } - const float radius = 2.5f * m_scale; - if (!checked) - { - UiDrawRoundRect(g, rect, radius, ink.text, 1.2f * m_scale); - return; - } - UiFillRoundRect(g, rect, radius, ink.text); - // 对勾:两段折线,颜色取叠底色(与填充色必成对比)。 - const float left = rect.X + rect.Width * 0.22f; - const float middleX = rect.X + rect.Width * 0.44f; - const float right = rect.X + rect.Width * 0.78f; - const float highY = rect.Y + rect.Height * 0.30f; - const float lowY = rect.Y + rect.Height * 0.72f; - UiDrawLine(g, left, rect.Y + rect.Height * 0.52f, middleX, lowY, background, - 1.6f * m_scale); - UiDrawLine(g, middleX, lowY, right, highY, background, 1.6f * m_scale); - }; - - for (std::size_t rowIndex = 0; rowIndex < layout.rowRefs.size(); ++rowIndex) - { - const SignalRef& ref = layout.rowRefs[rowIndex]; - if (rowIndex >= layout.rowHeaders.size() || - rowIndex >= layout.rowChecks.size()) - { - break; - } - const ResolvedSignal resolved = ResolveSignal(ref); - if (resolved.data == nullptr || resolved.style == nullptr) - { - continue; - } - const gd::RectF& header = layout.rowHeaders[rowIndex]; - // 行的**可见性**必须按“整行的纵向范围”判定,不能只看条目栏: - // 条目栏已滚出内容区,但它下面两行参数滑块仍可能还在可视区内 - //(用户 2026-10-30 报告:此时两个滑块会直接消失 —— 显示错误)。 - float rowTop = header.Y; - float rowBottom = header.Y + header.Height; - const auto extendRow = [&rowBottom](const gd::RectF& rect) { - if (rect.Width > 0.0f && rect.Height > 0.0f) - { - rowBottom = std::max(rowBottom, rect.Y + rect.Height); - } - }; - if (rowIndex < layout.rowLevelTracks.size()) - { - extendRow(layout.rowLevelTracks[rowIndex]); - } - if (rowIndex < layout.rowLevelValues.size()) - { - extendRow(layout.rowLevelValues[rowIndex]); - } - if (rowIndex < layout.rowLevelResets.size()) - { - extendRow(layout.rowLevelResets[rowIndex]); - } - if (rowIndex < layout.rowIterTracks.size()) - { - extendRow(layout.rowIterTracks[rowIndex]); - } - if (rowIndex < layout.rowIterValues.size()) - { - extendRow(layout.rowIterValues[rowIndex]); - } - if (rowIndex < layout.rowIterResets.size()) - { - extendRow(layout.rowIterResets[rowIndex]); - } - if (rowIndex < layout.rowStats.size()) - { - extendRow(layout.rowStats[rowIndex]); - } - if (rowIndex < layout.rowInfoMaxInterval.size()) - { - extendRow(layout.rowInfoMaxInterval[rowIndex]); - } - if (rowIndex < layout.rowInfoMinInterval.size()) - { - extendRow(layout.rowInfoMinInterval[rowIndex]); - } - if (rowIndex < layout.rowIntervalField.size()) - { - extendRow(layout.rowIntervalField[rowIndex]); - } - if (rowIndex < layout.rowFrequencyField.size()) - { - extendRow(layout.rowFrequencyField[rowIndex]); - } - if (rowBottom <= layout.content.Y || - rowTop >= layout.content.Y + layout.content.Height) - { - continue; // 整行(含全部参数行)完全滚出可视范围 - } - const std::uint32_t signalColor = StyleColor(*resolved.style); - const UiInk ink = UiContrastInk(signalColor); - // 「均值滤波」看 `meanFilterItems`、「中心轴边沿计数」看 `edgeCenterItems`、 - //「弱峰调整」看 `weakPeakItems`(前两者同为 `Lane::MeanFilterItem`)。 - const Lane::WeakPeakItem* peakItem = - weakPeak ? tabLane.FindWeakPeakItem(ref) : nullptr; - const Lane::MeanFilterItem* item = - weakPeak ? nullptr - : (centerAxis ? tabLane.FindEdgeCenterItem(ref) - : tabLane.FindMeanFilterItem(ref)); - const Lane::ResampleItem* resampleItem = - resamplePanel ? tabLane.FindResampleItem(ref) : nullptr; - const bool checked = clipPanel ? tabLane.ClipEnabled(ref) - : (resamplePanel ? (resampleItem != nullptr) - : (weakPeak ? (peakItem != nullptr) - : (item != nullptr))); - const bool rowCollapsed = - clipPanel ? true - : (resamplePanel ? (resampleItem != nullptr && resampleItem->collapsed) - : (weakPeak ? (peakItem != nullptr && peakItem->collapsed) - : (item != nullptr && item->collapsed))); - - // 内容区已由 `g.SetClip(layout.content)` 裁切(行可以“半截”显示,滚动更顺滑); - // 已经完整滚出可视范围的行在上面就 `continue` 了,这里不再额外门控。 - UiFillRoundRect(g, header, 3.0f * m_scale, signalColor); - UiFillRoundRect(g, header, 3.0f * m_scale, WithAlpha(signalColor, 0xE8)); - // 条目栏悬停:在信号色叠底上再压一层强调色,给出"这一行对应哪条曲线"的提示 - //(曲线本身的高亮见 `PaintLaneDerivedOverlays`)。 - // ⚠️ 必须同时比**面板种类**:同一条信号在其它面板里也有一行, - // 只比 (lane, ref) 会把别的面板的同名行一起点亮。 - if (m_meanRowHoverLane == static_cast(index) && - m_meanRowHoverRef == ref && m_meanRowHoverKind == kind) - { - UiFillRoundRect(g, header, 3.0f * m_scale, - WithAlpha(m_theme->accent, 0x2E)); - } - - // 左侧三角(仅勾选时存在):朝下 = 展开、朝右 = 收起。 - const gd::RectF& triangle = layout.rowTriangles[rowIndex]; - if (triangle.Width > 0.0f && checked) - { - UiDrawTriangleIcon(g, triangle, ink.text, - rowCollapsed ? IconDirection::RIGHT - : IconDirection::DOWN); - } - - // 名称:从三角右侧到复选框左侧。 - const gd::RectF& check = layout.rowChecks[rowIndex]; - const float nameLeft = (triangle.Width > 0.0f) - ? (triangle.X + triangle.Width + 6.0f * m_scale) - : (header.X + 8.0f * m_scale); - const float nameWidth = - std::max(10.0f, check.X - 6.0f * m_scale - nameLeft); - UiTextClipped(g, UiEllipsize(resolved.data->name, m_fonts->uiSmall, nameWidth), - m_fonts->uiSmall, - gd::RectF(nameLeft, header.Y, nameWidth, header.Height), ink.text, - TextAlign::LEFT, layout.content); - paintMeanCheck(check, checked, ink, signalColor); - - if (!checked || rowCollapsed) - { - continue; - } - - // 「重采样」:四行 = 最大间隔(只读)/ 最小间隔(只读)/ - // 重采样间隔(输入)/ 重采样频率(输入)。标签在左、数值在右。 - if (resamplePanel) - { - const float labelWidth = - UiMeasureTextWidth(m_fonts->uiSmall, kResampleMaxIntervalLabel); - const auto paintInfoRow = [&](const gd::RectF& row, const wchar_t* label, - const std::wstring& text) - { - if (row.Width <= 0.0f) - { - return; - } - UiTextClipped(g, label, m_fonts->uiSmall, - gd::RectF(row.X, row.Y, std::max(10.0f, labelWidth), - row.Height), - m_theme->textMuted, TextAlign::LEFT, layout.content); - // ⚠️ 统计数字**绝不省略**(用户 2026-11-03 要求): - // 面板宽度已按真实文本量宽(`EdgePanelWidth` 逐信号实测), - // 因此这里用 `TextAlign::RIGHT` + 完整矩形即可; - // 万一仍被窗口边界夹住,也宁可超出面板右缘(由内容区裁剪兜底), - // **不要**退化成 `…` 让用户看不到真实数值。 - UiTextClipped(g, text, m_fonts->monoSmall, - gd::RectF(row.X + labelWidth + 8.0f * m_scale, row.Y, - std::max(10.0f, row.Width - labelWidth - - 8.0f * m_scale), - row.Height), - m_theme->textStrong, TextAlign::RIGHT, - layout.content); - }; - const auto paintInputRow = [&](const gd::RectF& field, const wchar_t* label, - const std::wstring& text, bool editing) - { - if (field.Width <= 0.0f) - { - return; - } - // 标签与数值同高:标签从内容区左缘画到字段左缘。 - UiTextClipped( - g, label, m_fonts->uiSmall, - gd::RectF(layout.content.X, field.Y, - std::max(10.0f, field.X - 8.0f * m_scale - layout.content.X), - field.Height), - m_theme->textMuted, TextAlign::LEFT, layout.content); - if (editing) - { - return; // 正在编辑:内容由 `PaintEditField` 画 - } - const gd::RectF box(field.X, field.Y + 2.0f * m_scale, field.Width, - std::max(10.0f, field.Height - 4.0f * m_scale)); - UiFillRoundRect(g, box, 3.0f * m_scale, m_theme->pressed); - UiDrawRoundRect(g, box, 3.0f * m_scale, m_theme->border, 1.0f); - UiTextClipped(g, text, m_fonts->monoSmall, - gd::RectF(box.X + 4.0f * m_scale, box.Y, - std::max(6.0f, box.Width - 8.0f * m_scale), - box.Height), - m_theme->textStrong, TextAlign::LEFT, layout.content); - }; - double maxInterval = 0.0; - double minInterval = 0.0; - const bool hasInterval = - (resampleItem->maxInterval > 0.0) && (resampleItem->minInterval > 0.0); - maxInterval = resampleItem->maxInterval; - minInterval = resampleItem->minInterval; - // 「最大 / 最小数据间隔」= 自适应单位的**小数**文本(绝不用科学计数法), - // 末尾追加一个括号内的「对应频率」尾缀(单位在 Hz/kHz/MHz/GHz 之间自适应)。 - // - // ⚠️⚠️ 全部使用**真实值**(用户 2026-11-03 明确要求): - // * 时间间隔:`FormatDurationCompound` 会把次级单位保留 3 位小数 - // ⇒ `0.003696631s` 显示为 `3ms 696.631us`(旧实现舍入到 3 位有效数字 - // 得到 `3ms 700us`,把真实尾数抹掉了); - // * 频率:由**未舍入的真实间隔**换算 ⇒ `270.517 Hz`(旧实现用舍入后的 - // `3.70ms` 换算得到 `270.27 Hz`,与输入框不一致)。 - // 两条路径都用真实值 ⇒ 统计行与输入框天然一致。 - const auto intervalWithFrequency = [](double seconds) -> std::wstring - { - std::wstring text = FormatDurationCompound(seconds); - if (std::isfinite(seconds) && seconds > 0.0) - { - text += L"("; - text += FormatFrequencyText(1.0 / seconds); - text += L")"; - } - return text; - }; - if (rowIndex < layout.rowInfoMaxInterval.size()) - { - paintInfoRow(layout.rowInfoMaxInterval[rowIndex], kResampleMaxIntervalLabel, - hasInterval ? intervalWithFrequency(maxInterval) : L"—"); - } - if (rowIndex < layout.rowInfoMinInterval.size()) - { - paintInfoRow(layout.rowInfoMinInterval[rowIndex], kResampleMinIntervalLabel, - hasInterval ? intervalWithFrequency(minInterval) : L"—"); - } - const double interval = (resampleItem->interval > 0.0) - ? resampleItem->interval - : minInterval; - const bool editingInterval = - (m_editTarget == PlotEditTarget::RESAMPLE_INTERVAL) && - (m_editLane == static_cast(index)) && (m_editMeanRef == ref); - const bool editingFrequency = - (m_editTarget == PlotEditTarget::RESAMPLE_FREQUENCY) && - (m_editLane == static_cast(index)) && (m_editMeanRef == ref); - if (rowIndex < layout.rowIntervalField.size()) - { - // 非编辑态:自适应单位 + 小数(与「最大/最小数据间隔」同一套单位形式)。 - // 编辑态:只显示**秒**数值(不带单位),由 `PaintEditField` 绘制。 - paintInputRow(layout.rowIntervalField[rowIndex], kResampleIntervalLabel, - FormatIntervalText(interval), editingInterval); - } - if (rowIndex < layout.rowFrequencyField.size()) - { - // ⚠️ 频率同样用**真实间隔**换算(与时间间隔行同源)⇒ `270.517 Hz`。 - const double hertz = (interval > 0.0) ? (1.0 / interval) : 0.0; - // 非编辑态:频率单位自适应(Hz/kHz/MHz/GHz);编辑态:只显示 Hz 数值。 - paintInputRow(layout.rowFrequencyField[rowIndex], kResampleFrequencyLabel, - (hertz > 0.0) ? FormatFrequencyText(hertz) : L"—", - editingFrequency); - } - continue; - } - - // 「中心轴边沿计数」:参数行最上面是**同一行**的「上升沿 / 下降沿」统计。 - if (centerAxis && rowIndex < layout.rowStats.size() && - layout.rowStats[rowIndex].Width > 0.0f) - { - std::size_t rising = 0; - std::size_t falling = 0; - bool complete = true; - if (resolved.axis != nullptr) - { - EdgeCenterCountsFor(*resolved.axis, *resolved.data, *resolved.style, - item->points, item->iterations, EdgeRangeActive(), - EdgeRangeMin(), EdgeRangeMax(), rising, falling, complete); - } - paintCounts(layout.rowStats[rowIndex], rising, falling, complete, - layout.content); - } - - // 两行参数滑块(标签 / 轨道 / 数值 / 复位按钮)。 - const auto paintMeanSlider = [&](const gd::RectF& track, - const gd::RectF& valueRect, - const gd::RectF& resetRect, - const wchar_t* label, int current, int minimum, - int maximum, bool active) - { - const float labelLeft = track.X - 6.0f * m_scale - - UiMeasureTextWidth(m_fonts->uiSmall, label); - // ⚠️ **必须用 `UiTextClipped`(而不是 `UiText`)**:GDI 文本不受 - // GDI+ `SetClip` 约束,滚动时标签/数值会画到内容区之外 - //(用户 2026-10-30 报告"元素超出了浮动面板"的直接原因)。 - UiTextClipped(g, label, m_fonts->uiSmall, - gd::RectF(labelLeft, track.Y - 8.0f * m_scale, - UiMeasureTextWidth(m_fonts->uiSmall, label), - track.Height + 16.0f * m_scale), - m_theme->textMuted, TextAlign::LEFT, layout.content); - const double ratio = - (maximum > minimum) - ? (static_cast(current - minimum) / - static_cast(maximum - minimum)) - : 0.0; - UiDrawSlider(g, track, ratio, m_theme->accent, active, m_scale); - UiTextClipped(g, FormatInteger(current), m_fonts->monoSmall, - gd::RectF(valueRect.X, valueRect.Y, valueColumn, valueRect.Height), - m_theme->textStrong, TextAlign::LEFT, layout.content); - // 复位按钮:悬停高亮 + 与「系数/偏移」菜单同款图标。 - const bool hot = - (m_dragMode != PlotDragMode::EDGE_PANEL_MOVE) && - UiPointInRect(resetRect, static_cast(m_mouseX), - static_cast(m_mouseY)); - if (hot) - { - UiFillRoundRect( - g, gd::RectF(resetRect.X - 1.0f * m_scale, - resetRect.Y - 1.0f * m_scale, - resetRect.Width + 2.0f * m_scale, - resetRect.Height + 2.0f * m_scale), - 3.0f * m_scale, WithAlpha(m_theme->accent, 0x33)); - } - PaintPanelResetIcon(g, resetRect, - hot ? m_theme->accent : m_theme->textMuted, m_scale); - }; - - // ⚠️ 「均值点数」的最小值:**均值滤波 = 1**(用户 2026-10-30 要求不能为 0, - // 0 与不做平滑完全等价,看起来像功能坏了);**中心轴边沿计数 = 0** - // (0 = 不平滑,直接对原始采样值统计边沿 —— 这是有意义的选项,保持旧语义)。 - if (weakPeak) - { - // 「弱峰调整」两行滑块:调整幅度(0 在正中间 ⇒ ±400%)与迭代次数(1 … 自定上限)。 - const int peakAmount = std::max( - kWeakPeakAmountMin, std::min(kWeakPeakAmountMax, peakItem->amountPercent)); - const int peakMax = std::max( - kWeakPeakIterationsMin, - std::min(kWeakPeakIterationsMaxLimit, peakItem->maxIterations)); - const int peakIter = - std::max(kWeakPeakIterationsMin, std::min(peakMax, peakItem->iterations)); - const bool levelActive = (m_dragMode == PlotDragMode::PEAK_LEVEL) && - (m_peakDragLane == static_cast(index)) && - (m_peakDragRef == ref); - const bool iterActive = (m_dragMode == PlotDragMode::PEAK_ITERATIONS) && - (m_peakDragLane == static_cast(index)) && - (m_peakDragRef == ref); - const bool editingMax = - (m_editTarget == PlotEditTarget::PEAK_ITERATIONS_MAX) && - (m_editLane == static_cast(index)) && (m_editMeanRef == ref); - // 幅度滑块:比例 = (幅度 / (100*4) + 1) / 2(0% 在正中间)。 - const auto paintPeakSlider = [&](const gd::RectF& track, - const gd::RectF& valueRect, - const gd::RectF& resetRect, - const wchar_t* label, double ratio, - const std::wstring& valueText, bool active, - bool hideBody) - { - const float labelLeft = track.X - 6.0f * m_scale - - UiMeasureTextWidth(m_fonts->uiSmall, label); - UiTextClipped(g, label, m_fonts->uiSmall, - gd::RectF(labelLeft, track.Y - 8.0f * m_scale, - UiMeasureTextWidth(m_fonts->uiSmall, label), - track.Height + 16.0f * m_scale), - m_theme->textMuted, TextAlign::LEFT, layout.content); - if (hideBody) - { - return; // 正在编辑上限:轨道与数值改由 `PaintEditField` 画 - } - UiDrawSlider(g, track, ratio, m_theme->accent, active, m_scale); - UiTextClipped(g, valueText, m_fonts->monoSmall, - gd::RectF(valueRect.X, valueRect.Y, valueColumn, - valueRect.Height), - m_theme->textStrong, TextAlign::LEFT, layout.content); - const bool hot = (m_dragMode != PlotDragMode::EDGE_PANEL_MOVE) && - UiPointInRect(resetRect, - static_cast(m_mouseX), - static_cast(m_mouseY)); - if (hot) - { - UiFillRoundRect( - g, gd::RectF(resetRect.X - 1.0f * m_scale, - resetRect.Y - 1.0f * m_scale, - resetRect.Width + 2.0f * m_scale, - resetRect.Height + 2.0f * m_scale), - 3.0f * m_scale, WithAlpha(m_theme->accent, 0x33)); - } - PaintPanelResetIcon(g, resetRect, - hot ? m_theme->accent : m_theme->textMuted, - m_scale); - }; - const double levelRatio = - (static_cast(peakAmount) / (100.0 * kWeakPeakSliderScale) + 1.0) * 0.5; - const double iterRatio = - (peakMax > kWeakPeakIterationsMin) - ? (static_cast(peakIter - kWeakPeakIterationsMin) / - static_cast(peakMax - kWeakPeakIterationsMin)) - : 0.0; - if (rowIndex < layout.rowLevelTracks.size() && - layout.rowLevelTracks[rowIndex].Width > 0.0f) - { - paintPeakSlider( - layout.rowLevelTracks[rowIndex], - rowIndex < layout.rowLevelValues.size() - ? layout.rowLevelValues[rowIndex] - : gd::RectF(), - rowIndex < layout.rowLevelResets.size() - ? layout.rowLevelResets[rowIndex] - : gd::RectF(), - kWeakPeakLevelLabel, levelRatio, - (peakAmount > 0 ? L"+" : L"") + FormatInteger(peakAmount) + L"%", - levelActive, false); - } - if (rowIndex < layout.rowIterTracks.size() && - layout.rowIterTracks[rowIndex].Width > 0.0f) - { - paintPeakSlider( - layout.rowIterTracks[rowIndex], - rowIndex < layout.rowIterValues.size() - ? layout.rowIterValues[rowIndex] - : gd::RectF(), - rowIndex < layout.rowIterResets.size() - ? layout.rowIterResets[rowIndex] - : gd::RectF(), - kWeakPeakIterationsSliderLabel, iterRatio, - FormatInteger(peakIter), iterActive, editingMax); - } - } - else - { - // 只可能是「均值滤波」:中心轴边沿计数已改为独立的表格分支。 - const int pointsFloor = kMeanFilterPointsMin; - const int pointsMax = std::max(pointsFloor, - std::min(kMeanFilterPointsMaxLimit, - item->maxPoints)); - const int points = - std::max(pointsFloor, std::min(pointsMax, item->points)); - const int iterationsMax = std::max(kMeanFilterIterationsMin, - std::min(kMeanFilterIterationsMaxLimit, - item->maxIterations)); - const int iterations = std::max( - kMeanFilterIterationsMin, std::min(iterationsMax, item->iterations)); - const bool pointsActive = (m_dragMode == PlotDragMode::MEAN_POINTS) && - (m_meanDragLane == static_cast(index)) && - (m_meanDragRef == ref) && - (m_meanDragKind == kind); - const bool iterationsActive = - (m_dragMode == PlotDragMode::MEAN_ITERATIONS) && - (m_meanDragLane == static_cast(index)) && (m_meanDragRef == ref) && - (m_meanDragKind == kind); - if (rowIndex < layout.rowLevelTracks.size()) - { - paintMeanSlider(layout.rowLevelTracks[rowIndex], - rowIndex < layout.rowLevelValues.size() - ? layout.rowLevelValues[rowIndex] - : gd::RectF(), - rowIndex < layout.rowLevelResets.size() - ? layout.rowLevelResets[rowIndex] - : gd::RectF(), - kMeanFilterPointsSliderLabel, points, - pointsFloor, pointsMax, pointsActive); - } - if (rowIndex < layout.rowIterTracks.size()) - { - paintMeanSlider(layout.rowIterTracks[rowIndex], - rowIndex < layout.rowIterValues.size() - ? layout.rowIterValues[rowIndex] - : gd::RectF(), - rowIndex < layout.rowIterResets.size() - ? layout.rowIterResets[rowIndex] - : gd::RectF(), - kMeanFilterIterationsSliderLabel, iterations, - kMeanFilterIterationsMin, iterationsMax, iterationsActive); - } - } - - } - - g.Restore(meanSave); - - // ---- 内容滚动条(只在内容超高时出现;底部按钮不参与滚动)---- - // ⚠️ 必须在 `g.Restore(meanSave)` **之后**画:滚动条位于内容区右侧、在 - // 内容裁剪区之外,若在裁剪内画会被整条裁掉(用户 2026-10-30 报告 - // "全部启用后没有出现滚动条"的根因)。 - paintPanelScrollBar(layout); + PaintPanelListRows(g, layout, tabLane, kind, index); } // 「自由标记」面板:按信号分组的**分组栏**(信号色托底 + 三角 + 名称 + // 「N 标记点」+ ✕),下属固定点条目六列对齐(序号 / 取值 / 枚举 / 增量 / 时刻 / 间隔), // 行尾各带一个 ✕。内容区有滚动偏移时按内容区裁剪。 - if (freeMarker) + if (panelStyle.markerGroups) { - const float groupRowHeight = kFreeMarkerNameRowHeight * m_scale; - bool markerAny = false; - for (const Lane::FreeMarkerItem& item : tabLane.freeMarkerItems) - { - markerAny = markerAny || item.ref.IsValid(); - } - - const int contentState = g.Save(); - g.SetClip(layout.content, gd::CombineModeIntersect); - - if (!markerAny) - { - // ⚠️ 未开启「自由标记点选取」时鼠标是普通鼠标,**单击不会固定**任何数据点 - //(用户 2026-10-23 要求)⇒ 提示语必须说清前提,否则用户会以为 bug。 - const wchar_t* emptyHint = tabLane.freeMarkerCursor - ? L"在网格内单击以固定数据点" - : L"开启「自由标记点选取」后单击网格可固定数据点"; - UiTextClipped(g, emptyHint, m_fonts->uiSmall, - gd::RectF(layout.content.X, layout.content.Y, layout.content.Width, - groupRowHeight), - m_theme->textMuted, TextAlign::LEFT, layout.content); - } - - // ---- 分组栏 ---- - // ⚠️ 抽取成 lambda:**吸顶**的分组栏必须在条目之后重画一次(否则正在向上滚的 - // 条目会盖住它)。见下方“条目 → 表头 → 吸顶分组栏”的绘制顺序。 - const auto paintGroupBar = [this, &g, &layout, &tabLane, index]( - const EdgePanelLayout::MarkerGroup& group) - { - const ResolvedSignal resolved = ResolveSignal(group.ref); - if (resolved.data == nullptr) - { - return; - } - const std::uint32_t signalColor = - (resolved.style != nullptr) ? StyleColor(*resolved.style) : m_theme->border; - // 托底 = 信号色;文字一律用**自适应对比色**(与信号面板的组名徽标同规则), - // 因此无论信号是亮黄还是深蓝都能看清分组栏上的所有内容。 - const UiInk ink = UiContrastInk(signalColor); - // 先铺一层**完全不透明**的同色,再叠 0xE8(观感不变): - // 吸顶时它要压住下面滚动的条目,半透明会让条目透出来。 - UiFillRoundRect(g, group.header, 3.0f * m_scale, signalColor); - UiFillRoundRect(g, group.header, 3.0f * m_scale, WithAlpha(signalColor, 0xE8)); - - // 左侧三角(与信号选择区域组名同款):朝下 = 展开、朝右 = 收起。 - const bool triangleHot = - (m_freeMarkerTriangleHoverLane == static_cast(index) && - m_freeMarkerTriangleHoverSignal == group.ref); - UiDrawTriangleIcon(g, group.triangle, - triangleHot ? m_theme->accentSoft : ink.text, - group.collapsed ? IconDirection::RIGHT - : IconDirection::DOWN); - - // 右侧:`N 标记点` + ✕(清除该信号全部固定点)。 - std::size_t markerCount = 0; - for (const Lane::FreeMarkerItem& entry : tabLane.freeMarkerItems) - { - if (entry.ref == group.ref) - { - ++markerCount; - } - } - const std::wstring countText = FormatInteger(markerCount) + L" 标记点"; - const float countWidth = - UiMeasureTextWidth(m_fonts->monoSmall, countText) + 2.0f * m_scale; - const float countRight = group.close.X - 5.0f * m_scale; - UiTextClipped(g, countText, m_fonts->monoSmall, - gd::RectF(countRight - countWidth, group.header.Y, countWidth, - group.header.Height), - ink.text, TextAlign::RIGHT, layout.content); - - const float nameLeft = group.triangle.X + group.triangle.Width + 6.0f * m_scale; - const float nameWidth = - std::max(10.0f, countRight - countWidth - 6.0f * m_scale - nameLeft); - UiTextClipped(g, UiEllipsize(resolved.data->name, m_fonts->uiSmall, nameWidth), - m_fonts->uiSmall, - gd::RectF(nameLeft, group.header.Y, nameWidth, group.header.Height), - ink.text, TextAlign::LEFT, layout.content); - - const bool closeHot = (m_freeMarkerPanelHoverLane == static_cast(index) && - m_freeMarkerPanelHoverSignal == group.ref && - m_freeMarkerPanelHoverItem == static_cast(-1)); - if (closeHot) - { - UiFillRoundRect(g, group.close, 3.0f * m_scale, - WithAlpha(m_theme->danger, 0x77)); - } - const float crossPad = 3.5f * m_scale; - const std::uint32_t crossColor = closeHot ? m_theme->danger : ink.text; - UiDrawLine(g, group.close.X + crossPad, group.close.Y + crossPad, - group.close.X + group.close.Width - crossPad, - group.close.Y + group.close.Height - crossPad, crossColor, - 1.5f * m_scale); - UiDrawLine(g, group.close.X + group.close.Width - crossPad, - group.close.Y + crossPad, group.close.X + crossPad, - group.close.Y + group.close.Height - crossPad, crossColor, - 1.5f * m_scale); - }; - for (const EdgePanelLayout::MarkerGroup& group : layout.markerGroups) - { - if (!group.pinned) - { - paintGroupBar(group); - } - } - - // ---- 固定点条目(六列,列起点由布局给定 ⇒ 纵向严格对齐)---- - // ⚠️ 取值与各列文本来自**与视界无关的内容缓存**(`FreeMarkerContents`): - // 大量固定点时逐帧重新采样会让"拖光标 / 平移视界"明显变卡。 - const std::vector& contents = - FreeMarkerContents(static_cast(index)); - // 六列的**实测宽度**:含滚出视界的条目,保证列宽稳定、文本不被省略。 - // 存回 `Lane` 供下一步的布局(`EdgePanelWidth` / `EdgePanelLayoutFor`)使用。 - { - float widest[6] = {0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f}; - const auto measure = [this, &widest](std::size_t column, const std::wstring& text) { - if (text.empty() || column >= 6) - { - return; - } - widest[column] = std::max(widest[column], - UiMeasureTextWidth(m_fonts->monoSmall, text)); - }; - // 「序号」列:取本网格所有分组里最大的序号文本宽度。 - std::size_t maxSerial = 0; - std::vector serialOrder; - for (const SignalRef& ref : tabLane.signals) - { - SortedFreeMarkerIndices(tabLane, ref, serialOrder); - maxSerial = std::max(maxSerial, serialOrder.size()); - } - if (maxSerial > 0) - { - measure(EdgePanelLayout::MARKER_COL_SERIAL, FormatInteger(maxSerial)); - } - for (const FreeMarkerContentEntry& entry : contents) - { - measure(EdgePanelLayout::MARKER_COL_VALUE, entry.valueText); - measure(EdgePanelLayout::MARKER_COL_ENUM, entry.enumText); - measure(EdgePanelLayout::MARKER_COL_DELTA, entry.deltaText); - measure(EdgePanelLayout::MARKER_COL_TIME, entry.timeText); - measure(EdgePanelLayout::MARKER_COL_DT, entry.deltaTimeText); - } - std::array& stored = - tabLanes[index].freeMarkerColumnWidths; - for (std::size_t column = 0; column < 6; ++column) - { - // +2px 余量:`DrawTextW` 的 `DT_END_ELLIPSIS` 对"刚好装满"的文本 - // 有时仍会截成省略号(亚像素/字形伸部),留一点余量可彻底避免。 - const float want = - (widest[column] > 0.0f) ? (widest[column] + 2.0f * m_scale) : 0.0f; - if (std::fabs(stored[column] - want) > 0.5f) - { - stored[column] = want; - } - } - } - // 列分隔竖线:在每列右边界(加上半个列间隙)画一条淡竖线, - // 让「序号 / 标记值 / 枚举文本 / 增量 / 时间点 / 时间间隔」有明显的列分界 - //(用户 2026-10-23 要求)。最后一列不画(它右边紧接行尾 ✕)。 - const auto paintColumnSeparators = [this, &g, &layout](float y, float height) { - int lastVisible = -1; - for (int column = 0; column < 6; ++column) - { - if (layout.markerColumns[column].width > 0.0f) - { - lastVisible = column; - } - } - for (int column = 0; column < lastVisible; ++column) - { - const EdgePanelLayout::MarkerColumn& cell = layout.markerColumns[column]; - if (cell.width <= 0.0f) - { - continue; - } - const float lineX = - cell.left + cell.width + kFreeMarkerColumnGap * m_scale * 0.5f; - UiDrawLine(g, lineX, y + 2.0f * m_scale, lineX, y + height - 2.0f * m_scale, - WithAlpha(m_theme->borderStrong, 0x6C), 1.0f); - } - }; - for (const EdgePanelLayout::MarkerRow& markerRow : layout.markerRows) - { - if (markerRow.itemIndex >= contents.size()) - { - continue; - } - const FreeMarkerContentEntry& entry = contents[markerRow.itemIndex]; - - // ---- 行的选中 / 悬停高亮(用户 2026-10-22 要求)---- - const bool selected = - (tabLane.freeMarkerSelected == static_cast(markerRow.itemIndex)); - const bool rowHot = (m_freeMarkerRowHoverLane == static_cast(index) && - m_freeMarkerRowHoverItem == markerRow.itemIndex); - if (selected || rowHot) - { - UiFillRect(g, - gd::RectF(markerRow.rect.X, markerRow.rect.Y + 1.0f * m_scale, - markerRow.rect.Width, - markerRow.rect.Height - 2.0f * m_scale), - selected ? WithAlpha(m_theme->accent, 0x3C) - : WithAlpha(m_theme->accent, 0x1E)); - } - - const auto columnRect = [&](EdgePanelLayout::MarkerColumnIndex column) { - return gd::RectF(layout.markerColumns[column].left, markerRow.rect.Y, - layout.markerColumns[column].width, - markerRow.rect.Height); - }; - // ⚠️ 面板里的数据列**不得省略**(用户 2026-10-21 明确要求): - // 列宽已按实测文本宽度撑开,因此这里直接整段输出,不再 `UiEllipsize`。 - // ⚠️ 必须用 `UiTextClipped`(GDI 裁剪):`UiText` 走 `DrawTextW`, - // **不认 GDI+ 裁剪** ⇒ 滚动到内容区上方的条目会把文字画到标题栏上 - //(用户 2026-10-22 报告的显示错误)。 - const auto paintCell = [&](EdgePanelLayout::MarkerColumnIndex column, - const std::wstring& text, std::uint32_t color) { - if (layout.markerColumns[column].width <= 0.0f || text.empty()) - { - return; - } - UiTextClipped(g, text, m_fonts->monoSmall, columnRect(column), color, - TextAlign::LEFT, layout.content); - }; - paintCell(EdgePanelLayout::MARKER_COL_SERIAL, FormatInteger(markerRow.serial), - m_theme->textMuted); - paintCell(EdgePanelLayout::MARKER_COL_VALUE, entry.valueText, - m_theme->textStrong); - if (layout.markerHasEnumColumn) - { - paintCell(EdgePanelLayout::MARKER_COL_ENUM, entry.enumText, m_theme->text); - } - paintCell(EdgePanelLayout::MARKER_COL_DELTA, entry.deltaText, - m_theme->textMuted); - paintCell(EdgePanelLayout::MARKER_COL_TIME, entry.timeText, m_theme->textMuted); - paintCell(EdgePanelLayout::MARKER_COL_DT, entry.deltaTimeText, - m_theme->textMuted); - - // 列分隔竖线(用户 2026-10-23 要求:各列之间要有明显分界)。 - paintColumnSeparators(markerRow.rect.Y, markerRow.rect.Height); - - // 行尾 ✕:移除该固定点(与之前一致)。 - const gd::RectF& closeRect = markerRow.close; - const bool hot = (m_freeMarkerPanelHoverLane == static_cast(index) && - m_freeMarkerPanelHoverItem == markerRow.itemIndex); - UiFillRoundRect(g, closeRect, 3.0f * m_scale, - hot ? WithAlpha(m_theme->danger, 0x66) : m_theme->cardBg); - UiDrawRoundRect(g, closeRect, 3.0f * m_scale, - hot ? m_theme->danger : m_theme->border, 1.0f); - const float crossPad = 3.5f * m_scale; - const std::uint32_t crossColor = hot ? m_theme->danger : m_theme->textMuted; - UiDrawLine(g, closeRect.X + crossPad, closeRect.Y + crossPad, - closeRect.X + closeRect.Width - crossPad, - closeRect.Y + closeRect.Height - crossPad, crossColor, 1.2f * m_scale); - UiDrawLine(g, closeRect.X + closeRect.Width - crossPad, closeRect.Y + crossPad, - closeRect.X + crossPad, closeRect.Y + closeRect.Height - crossPad, - crossColor, 1.2f * m_scale); - } - - // ---- 表头(每个展开分组一行,与条目共用列边界)---- - // ⚠️ 必须在条目**之后**绘制:表头底衬要不透明地盖住向上滚动的条目。 - for (const EdgePanelLayout::MarkerGroup& group : layout.markerGroups) - { - if (group.tableHeader.Width <= 0.0f || group.tableHeader.Height <= 0.0f) - { - continue; - } - // 不透明底衬(与面板底色 + 少量边框色调混合,观感是“一条浅灰带”)。 - UiFillRect(g, group.tableHeader, - MixColor(m_theme->cardBg, m_theme->borderStrong, 0.22)); - UiDrawLine(g, group.tableHeader.X, group.tableHeader.Y + group.tableHeader.Height - 1.0f, - group.tableHeader.X + group.tableHeader.Width, - group.tableHeader.Y + group.tableHeader.Height - 1.0f, - WithAlpha(m_theme->borderStrong, 0x99), 1.0f); - for (int column = 0; column < 6; ++column) - { - const EdgePanelLayout::MarkerColumn& cell = - layout.markerColumns[column]; - if (cell.width <= 0.0f) - { - continue; - } - UiTextClipped(g, kFreeMarkerColumnLabels[column], m_fonts->uiSmall, - gd::RectF(cell.left, group.tableHeader.Y, cell.width, - group.tableHeader.Height), - m_theme->textMuted, TextAlign::LEFT, layout.content); - } - // 表头的列分隔竖线(与数据行同一套列边界)。 - paintColumnSeparators(group.tableHeader.Y, group.tableHeader.Height); - } - - // ---- 吸顶的分组栏(最后画:压住向上滚动的条目)---- - for (const EdgePanelLayout::MarkerGroup& group : layout.markerGroups) - { - if (group.pinned) - { - paintGroupBar(group); - } - } - - g.Restore(contentState); - - // ---- 内容滚动条(只在内容超高时出现;按钮与滑块不参与滚动)---- - paintPanelScrollBar(layout); - - // ---- 「标记透明度」滑动条(固定在「自由标记点选取」按钮上方)---- - if (layout.opacityTrack.Width > 0.0f) - { - UiText(g, kFreeMarkerOpacityLabel, m_fonts->uiSmall, - gd::RectF(panel.X + pad, layout.opacityValue.Y, - layout.opacityTrack.X - panel.X - pad, layout.opacityValue.Height), - m_theme->textMuted, TextAlign::LEFT); - const bool sliderActive = - (m_dragMode == PlotDragMode::MARKER_OPACITY && - m_markerOpacityDragLane == static_cast(index)) || - UiPointInRect(layout.opacityTrack, static_cast(m_mouseX), - static_cast(m_mouseY)); - // 外观与信号样式区的「透明度」滑动条完全相同(同一 `UiDrawSlider` 口径)。 - UiDrawSlider(g, layout.opacityTrack, - static_cast(tabLane.freeMarkerOpacity), m_theme->accent, - sliderActive, m_scale); - const int percent = - static_cast(tabLane.freeMarkerOpacity * 100.0f + 0.5f); - UiText(g, FormatInteger(percent) + L"%", m_fonts->monoSmall, - layout.opacityValue, m_theme->textStrong, TextAlign::RIGHT); - } + PaintPanelMarkerGroups(g, layout, tabLane, tabLanes, index); } @@ -6551,4 +5572,4 @@ namespace jdi UiDrawLine(g, cx, cy - depth, cx + halfWidth, cy - depth * 0.15f, ink, stroke); } -} // namespace jdi \ No newline at end of file +} // namespace jdi diff --git a/Components/PlotView/Src/PlotViewPanelPaint.cpp b/Components/PlotView/Src/PlotViewPanelPaint.cpp new file mode 100644 index 0000000..1da47b2 --- /dev/null +++ b/Components/PlotView/Src/PlotViewPanelPaint.cpp @@ -0,0 +1,1016 @@ +// PlotView shard -- floating panel CONTENT painting. +// +// Extracted verbatim from PaintEdgeCursor (~1600 lines) so that each panel layout +// style (count table / list rows / marker groups) lives in its own member function +// that only receives what it actually needs. The shared frame (border, title bar, +// disclosure triangle, badge, close button) and the footer buttons stay in +// PaintEdgeCursor because they are identical for every panel. +// +// Hard constraints: +// 1. Geometry comes from EdgePanelLayout fields only -- never re-derive coordinates. +// 2. Text inside the content area must use UiTextClipped(..., layout.content): +// GDI text ignores GDI+ clipping and would otherwise paint over the title bar. +// 3. Layout classification reads pvdetail::PanelStyleOf(kind) only. + +#include "PlotViewInternal.hpp" + +namespace jdi +{ + /** + * @brief Count-table rows of the edge-count panel (fixed header + scrollable rows). + * + * Row background = the signal colour; the three columns come from + * `layout.tableColumns`, i.e. header and data share one geometry source. + */ + void PlotView::PaintPanelCountTableRows(gd::Graphics& g, const EdgePanelLayout& layout, + const Lane& tabLane) + { + // ---- 表头 ---- + { + UiFillRoundRect(g, layout.tableHeader, 3.0f * m_scale, m_theme->pressed); + UiDrawRoundRect(g, layout.tableHeader, 3.0f * m_scale, m_theme->border, 1.0f); + for (int column = 0; column < 3; ++column) + { + const EdgePanelLayout::TableColumn& cell = layout.tableColumns[column]; + if (cell.width <= 0.0f) + { + continue; + } + const float textLeft = + (column == 0) ? (cell.left + 4.0f * m_scale) : cell.left; + UiTextClipped(g, EdgeCountTableHeader(column), m_fonts->uiSmall, + gd::RectF(textLeft, layout.tableHeader.Y, cell.width, + layout.tableHeader.Height), + m_theme->textMuted, + (column == 0) ? TextAlign::LEFT : TextAlign::CENTER, + layout.tableHeader); + } + } + + const int tableSave = g.Save(); + // 行内容必须裁到内容区:滚动时行会越出可视范围。 + g.SetClip(layout.content, gd::CombineModeIntersect); + + for (std::size_t rowIndex = 0; rowIndex < layout.rowRefs.size(); ++rowIndex) + { + if (rowIndex >= layout.rowHeaders.size() || + rowIndex >= layout.rowNameRects.size()) + { + break; + } + const SignalRef& ref = layout.rowRefs[rowIndex]; + const ResolvedSignal resolved = ResolveSignal(ref); + if (resolved.data == nullptr || resolved.style == nullptr) + { + continue; + } + const gd::RectF& row = layout.rowHeaders[rowIndex]; + if (row.Y + row.Height <= layout.content.Y || + row.Y >= layout.content.Y + layout.content.Height) + { + continue; + } + const std::uint32_t signalColor = StyleColor(*resolved.style); + const UiInk ink = UiContrastInk(signalColor); + // 行底色 = 该信号的配色(用户 2026-11-02 要求)。 + UiFillRoundRect(g, row, 3.0f * m_scale, signalColor); + UiFillRoundRect(g, row, 3.0f * m_scale, WithAlpha(signalColor, 0xE8)); + + // 信号名(第一列):超出列宽时省略。 + const gd::RectF& nameRect = layout.rowNameRects[rowIndex]; + UiTextClipped(g, UiEllipsize(resolved.data->name, m_fonts->uiSmall, + std::max(8.0f, nameRect.Width)), + m_fonts->uiSmall, nameRect, ink.text, TextAlign::LEFT, + layout.content); + + // 两列计数:与表头列**同一左边界 / 同一宽度**,纵向严格对齐。 + std::size_t rising = 0; + std::size_t falling = 0; + bool complete = true; + if (resolved.axis != nullptr) + { + EdgeCountsFor(*resolved.axis, *resolved.data, *resolved.style, + tabLane.edgeValue, EdgeRangeActive(), EdgeRangeMin(), + EdgeRangeMax(), rising, falling, complete); + } + for (int column = 1; column < 3; ++column) + { + const EdgePanelLayout::TableColumn& cell = layout.tableColumns[column]; + if (cell.width <= 0.0f) + { + continue; + } + const std::wstring text = + EdgeCountCellText((column == 1) ? rising : falling, complete); + UiTextClipped(g, text, m_fonts->monoSmall, + gd::RectF(cell.left, row.Y, cell.width, row.Height), ink.text, + TextAlign::CENTER, layout.content); + } + } + + g.Restore(tableSave); + + // 内容滚动条(只在内容超高时出现;表头不参与滚动)。 + PaintPanelScrollBar(g, layout, *m_theme); + } + + /** + * @brief List-style panel rows: entry bar (triangle + name + check box) plus the + * expanded parameter rows. + * + * Covers MEAN / CENTER / PEAK / RESAMPLE / CLIP. Parameter-row shape is decided by + * `kind` (RESAMPLE: four read-only/input rows, CENTER: an extra statistics row, the + * rest: two slider rows). + */ + void PlotView::PaintPanelListRows(gd::Graphics& g, const EdgePanelLayout& layout, + const Lane& tabLane, EdgePanelKind kind, + std::size_t laneIndex) + { + const bool centerAxis = (kind == EdgePanelKind::CENTER); + const bool weakPeak = (kind == EdgePanelKind::PEAK); + const bool resamplePanel = (kind == EdgePanelKind::RESAMPLE); + const bool clipPanel = (kind == EdgePanelKind::CLIP); + const std::size_t index = laneIndex; + // "Rising / falling" two-column layout (fixed column origins => strictly aligned). + // NOTE: must use UiTextClipped -- GDI text ignores GDI+ SetClip. + const auto paintCounts = [this, &g](const gd::RectF& rect, std::size_t rising, + std::size_t falling, bool complete, + const gd::RectF& clip) + { + if (EdgeCountsPending(rising, falling, complete)) + { + UiTextClipped(g, L"\u7edf\u8ba1\u4e2d\u2026", m_fonts->monoSmall, rect, + m_theme->textMuted, TextAlign::LEFT, clip); + return; + } + const float gap = kEdgePanelStatColumnGap * m_scale; + const float columnWidth = std::max(10.0f, (rect.Width - gap) * 0.5f); + UiTextClipped(g, EdgeRisingText(rising, complete), m_fonts->monoSmall, + gd::RectF(rect.X, rect.Y, columnWidth, rect.Height), + m_theme->textMuted, TextAlign::LEFT, clip); + UiTextClipped(g, EdgeFallingText(falling, complete), m_fonts->monoSmall, + gd::RectF(rect.X + columnWidth + gap, rect.Y, columnWidth, + rect.Height), + m_theme->textMuted, TextAlign::LEFT, clip); + }; + const int meanSave = g.Save(); + // 内容必须裁到内容区:滚动时行会越出可视范围。 + g.SetClip(layout.content, gd::CombineModeIntersect); + + const float valueColumn = + std::max(UiMeasureTextWidth(m_fonts->monoSmall, L"100000"), + UiMeasureTextWidth(m_fonts->monoSmall, L"1000")); + + // 复选框自绘(主题色在信号色叠底上看不清):用**自适应对比色**描边/填充。 + const auto paintMeanCheck = [this, &g](const gd::RectF& rect, bool checked, + const UiInk& ink, + std::uint32_t background) + { + if (rect.Width <= 0.0f || rect.Height <= 0.0f) + { + return; + } + const float radius = 2.5f * m_scale; + if (!checked) + { + UiDrawRoundRect(g, rect, radius, ink.text, 1.2f * m_scale); + return; + } + UiFillRoundRect(g, rect, radius, ink.text); + // 对勾:两段折线,颜色取叠底色(与填充色必成对比)。 + const float left = rect.X + rect.Width * 0.22f; + const float middleX = rect.X + rect.Width * 0.44f; + const float right = rect.X + rect.Width * 0.78f; + const float highY = rect.Y + rect.Height * 0.30f; + const float lowY = rect.Y + rect.Height * 0.72f; + UiDrawLine(g, left, rect.Y + rect.Height * 0.52f, middleX, lowY, background, + 1.6f * m_scale); + UiDrawLine(g, middleX, lowY, right, highY, background, 1.6f * m_scale); + }; + + for (std::size_t rowIndex = 0; rowIndex < layout.rowRefs.size(); ++rowIndex) + { + const SignalRef& ref = layout.rowRefs[rowIndex]; + if (rowIndex >= layout.rowHeaders.size() || + rowIndex >= layout.rowChecks.size()) + { + break; + } + const ResolvedSignal resolved = ResolveSignal(ref); + if (resolved.data == nullptr || resolved.style == nullptr) + { + continue; + } + const gd::RectF& header = layout.rowHeaders[rowIndex]; + // 行的**可见性**必须按“整行的纵向范围”判定,不能只看条目栏: + // 条目栏已滚出内容区,但它下面两行参数滑块仍可能还在可视区内 + //(用户 2026-10-30 报告:此时两个滑块会直接消失 —— 显示错误)。 + float rowTop = header.Y; + float rowBottom = header.Y + header.Height; + const auto extendRow = [&rowBottom](const gd::RectF& rect) { + if (rect.Width > 0.0f && rect.Height > 0.0f) + { + rowBottom = std::max(rowBottom, rect.Y + rect.Height); + } + }; + if (rowIndex < layout.rowLevelTracks.size()) + { + extendRow(layout.rowLevelTracks[rowIndex]); + } + if (rowIndex < layout.rowLevelValues.size()) + { + extendRow(layout.rowLevelValues[rowIndex]); + } + if (rowIndex < layout.rowLevelResets.size()) + { + extendRow(layout.rowLevelResets[rowIndex]); + } + if (rowIndex < layout.rowIterTracks.size()) + { + extendRow(layout.rowIterTracks[rowIndex]); + } + if (rowIndex < layout.rowIterValues.size()) + { + extendRow(layout.rowIterValues[rowIndex]); + } + if (rowIndex < layout.rowIterResets.size()) + { + extendRow(layout.rowIterResets[rowIndex]); + } + if (rowIndex < layout.rowStats.size()) + { + extendRow(layout.rowStats[rowIndex]); + } + if (rowIndex < layout.rowInfoMaxInterval.size()) + { + extendRow(layout.rowInfoMaxInterval[rowIndex]); + } + if (rowIndex < layout.rowInfoMinInterval.size()) + { + extendRow(layout.rowInfoMinInterval[rowIndex]); + } + if (rowIndex < layout.rowIntervalField.size()) + { + extendRow(layout.rowIntervalField[rowIndex]); + } + if (rowIndex < layout.rowFrequencyField.size()) + { + extendRow(layout.rowFrequencyField[rowIndex]); + } + if (rowBottom <= layout.content.Y || + rowTop >= layout.content.Y + layout.content.Height) + { + continue; // 整行(含全部参数行)完全滚出可视范围 + } + const std::uint32_t signalColor = StyleColor(*resolved.style); + const UiInk ink = UiContrastInk(signalColor); + // 「均值滤波」看 `meanFilterItems`、「中心轴边沿计数」看 `edgeCenterItems`、 + //「弱峰调整」看 `weakPeakItems`(前两者同为 `Lane::MeanFilterItem`)。 + const Lane::WeakPeakItem* peakItem = + weakPeak ? tabLane.FindWeakPeakItem(ref) : nullptr; + const Lane::MeanFilterItem* item = + weakPeak ? nullptr + : (centerAxis ? tabLane.FindEdgeCenterItem(ref) + : tabLane.FindMeanFilterItem(ref)); + const Lane::ResampleItem* resampleItem = + resamplePanel ? tabLane.FindResampleItem(ref) : nullptr; + const bool checked = clipPanel ? tabLane.ClipEnabled(ref) + : (resamplePanel ? (resampleItem != nullptr) + : (weakPeak ? (peakItem != nullptr) + : (item != nullptr))); + const bool rowCollapsed = + clipPanel ? true + : (resamplePanel ? (resampleItem != nullptr && resampleItem->collapsed) + : (weakPeak ? (peakItem != nullptr && peakItem->collapsed) + : (item != nullptr && item->collapsed))); + + // 内容区已由 `g.SetClip(layout.content)` 裁切(行可以“半截”显示,滚动更顺滑); + // 已经完整滚出可视范围的行在上面就 `continue` 了,这里不再额外门控。 + UiFillRoundRect(g, header, 3.0f * m_scale, signalColor); + UiFillRoundRect(g, header, 3.0f * m_scale, WithAlpha(signalColor, 0xE8)); + // 条目栏悬停:在信号色叠底上再压一层强调色,给出"这一行对应哪条曲线"的提示 + //(曲线本身的高亮见 `PaintLaneDerivedOverlays`)。 + // ⚠️ 必须同时比**面板种类**:同一条信号在其它面板里也有一行, + // 只比 (lane, ref) 会把别的面板的同名行一起点亮。 + if (m_meanRowHoverLane == static_cast(index) && + m_meanRowHoverRef == ref && m_meanRowHoverKind == kind) + { + UiFillRoundRect(g, header, 3.0f * m_scale, + WithAlpha(m_theme->accent, 0x2E)); + } + + // 左侧三角(仅勾选时存在):朝下 = 展开、朝右 = 收起。 + const gd::RectF& triangle = layout.rowTriangles[rowIndex]; + if (triangle.Width > 0.0f && checked) + { + UiDrawTriangleIcon(g, triangle, ink.text, + rowCollapsed ? IconDirection::RIGHT + : IconDirection::DOWN); + } + + // 名称:从三角右侧到复选框左侧。 + const gd::RectF& check = layout.rowChecks[rowIndex]; + const float nameLeft = (triangle.Width > 0.0f) + ? (triangle.X + triangle.Width + 6.0f * m_scale) + : (header.X + 8.0f * m_scale); + const float nameWidth = + std::max(10.0f, check.X - 6.0f * m_scale - nameLeft); + UiTextClipped(g, UiEllipsize(resolved.data->name, m_fonts->uiSmall, nameWidth), + m_fonts->uiSmall, + gd::RectF(nameLeft, header.Y, nameWidth, header.Height), ink.text, + TextAlign::LEFT, layout.content); + paintMeanCheck(check, checked, ink, signalColor); + + if (!checked || rowCollapsed) + { + continue; + } + + // 「重采样」:四行 = 最大间隔(只读)/ 最小间隔(只读)/ + // 重采样间隔(输入)/ 重采样频率(输入)。标签在左、数值在右。 + if (resamplePanel) + { + const float labelWidth = + UiMeasureTextWidth(m_fonts->uiSmall, kResampleMaxIntervalLabel); + const auto paintInfoRow = [&](const gd::RectF& row, const wchar_t* label, + const std::wstring& text) + { + if (row.Width <= 0.0f) + { + return; + } + UiTextClipped(g, label, m_fonts->uiSmall, + gd::RectF(row.X, row.Y, std::max(10.0f, labelWidth), + row.Height), + m_theme->textMuted, TextAlign::LEFT, layout.content); + // ⚠️ 统计数字**绝不省略**(用户 2026-11-03 要求): + // 面板宽度已按真实文本量宽(`EdgePanelWidth` 逐信号实测), + // 因此这里用 `TextAlign::RIGHT` + 完整矩形即可; + // 万一仍被窗口边界夹住,也宁可超出面板右缘(由内容区裁剪兜底), + // **不要**退化成 `…` 让用户看不到真实数值。 + UiTextClipped(g, text, m_fonts->monoSmall, + gd::RectF(row.X + labelWidth + 8.0f * m_scale, row.Y, + std::max(10.0f, row.Width - labelWidth - + 8.0f * m_scale), + row.Height), + m_theme->textStrong, TextAlign::RIGHT, + layout.content); + }; + const auto paintInputRow = [&](const gd::RectF& field, const wchar_t* label, + const std::wstring& text, bool editing) + { + if (field.Width <= 0.0f) + { + return; + } + // 标签与数值同高:标签从内容区左缘画到字段左缘。 + UiTextClipped( + g, label, m_fonts->uiSmall, + gd::RectF(layout.content.X, field.Y, + std::max(10.0f, field.X - 8.0f * m_scale - layout.content.X), + field.Height), + m_theme->textMuted, TextAlign::LEFT, layout.content); + if (editing) + { + return; // 正在编辑:内容由 `PaintEditField` 画 + } + const gd::RectF box(field.X, field.Y + 2.0f * m_scale, field.Width, + std::max(10.0f, field.Height - 4.0f * m_scale)); + UiFillRoundRect(g, box, 3.0f * m_scale, m_theme->pressed); + UiDrawRoundRect(g, box, 3.0f * m_scale, m_theme->border, 1.0f); + UiTextClipped(g, text, m_fonts->monoSmall, + gd::RectF(box.X + 4.0f * m_scale, box.Y, + std::max(6.0f, box.Width - 8.0f * m_scale), + box.Height), + m_theme->textStrong, TextAlign::LEFT, layout.content); + }; + double maxInterval = 0.0; + double minInterval = 0.0; + const bool hasInterval = + (resampleItem->maxInterval > 0.0) && (resampleItem->minInterval > 0.0); + maxInterval = resampleItem->maxInterval; + minInterval = resampleItem->minInterval; + // 「最大 / 最小数据间隔」= 自适应单位的**小数**文本(绝不用科学计数法), + // 末尾追加一个括号内的「对应频率」尾缀(单位在 Hz/kHz/MHz/GHz 之间自适应)。 + // + // ⚠️⚠️ 全部使用**真实值**(用户 2026-11-03 明确要求): + // * 时间间隔:`FormatDurationCompound` 会把次级单位保留 3 位小数 + // ⇒ `0.003696631s` 显示为 `3ms 696.631us`(旧实现舍入到 3 位有效数字 + // 得到 `3ms 700us`,把真实尾数抹掉了); + // * 频率:由**未舍入的真实间隔**换算 ⇒ `270.517 Hz`(旧实现用舍入后的 + // `3.70ms` 换算得到 `270.27 Hz`,与输入框不一致)。 + // 两条路径都用真实值 ⇒ 统计行与输入框天然一致。 + const auto intervalWithFrequency = [](double seconds) -> std::wstring + { + std::wstring text = FormatDurationCompound(seconds); + if (std::isfinite(seconds) && seconds > 0.0) + { + text += L"("; + text += FormatFrequencyText(1.0 / seconds); + text += L")"; + } + return text; + }; + if (rowIndex < layout.rowInfoMaxInterval.size()) + { + paintInfoRow(layout.rowInfoMaxInterval[rowIndex], kResampleMaxIntervalLabel, + hasInterval ? intervalWithFrequency(maxInterval) : L"—"); + } + if (rowIndex < layout.rowInfoMinInterval.size()) + { + paintInfoRow(layout.rowInfoMinInterval[rowIndex], kResampleMinIntervalLabel, + hasInterval ? intervalWithFrequency(minInterval) : L"—"); + } + const double interval = (resampleItem->interval > 0.0) + ? resampleItem->interval + : minInterval; + const bool editingInterval = + (m_editTarget == PlotEditTarget::RESAMPLE_INTERVAL) && + (m_editLane == static_cast(index)) && (m_editMeanRef == ref); + const bool editingFrequency = + (m_editTarget == PlotEditTarget::RESAMPLE_FREQUENCY) && + (m_editLane == static_cast(index)) && (m_editMeanRef == ref); + if (rowIndex < layout.rowIntervalField.size()) + { + // 非编辑态:自适应单位 + 小数(与「最大/最小数据间隔」同一套单位形式)。 + // 编辑态:只显示**秒**数值(不带单位),由 `PaintEditField` 绘制。 + paintInputRow(layout.rowIntervalField[rowIndex], kResampleIntervalLabel, + FormatIntervalText(interval), editingInterval); + } + if (rowIndex < layout.rowFrequencyField.size()) + { + // ⚠️ 频率同样用**真实间隔**换算(与时间间隔行同源)⇒ `270.517 Hz`。 + const double hertz = (interval > 0.0) ? (1.0 / interval) : 0.0; + // 非编辑态:频率单位自适应(Hz/kHz/MHz/GHz);编辑态:只显示 Hz 数值。 + paintInputRow(layout.rowFrequencyField[rowIndex], kResampleFrequencyLabel, + (hertz > 0.0) ? FormatFrequencyText(hertz) : L"—", + editingFrequency); + } + continue; + } + + // 「中心轴边沿计数」:参数行最上面是**同一行**的「上升沿 / 下降沿」统计。 + if (centerAxis && rowIndex < layout.rowStats.size() && + layout.rowStats[rowIndex].Width > 0.0f) + { + std::size_t rising = 0; + std::size_t falling = 0; + bool complete = true; + if (resolved.axis != nullptr) + { + EdgeCenterCountsFor(*resolved.axis, *resolved.data, *resolved.style, + item->points, item->iterations, EdgeRangeActive(), + EdgeRangeMin(), EdgeRangeMax(), rising, falling, complete); + } + paintCounts(layout.rowStats[rowIndex], rising, falling, complete, + layout.content); + } + + // 两行参数滑块(标签 / 轨道 / 数值 / 复位按钮)。 + const auto paintMeanSlider = [&](const gd::RectF& track, + const gd::RectF& valueRect, + const gd::RectF& resetRect, + const wchar_t* label, int current, int minimum, + int maximum, bool active) + { + const float labelLeft = track.X - 6.0f * m_scale - + UiMeasureTextWidth(m_fonts->uiSmall, label); + // ⚠️ **必须用 `UiTextClipped`(而不是 `UiText`)**:GDI 文本不受 + // GDI+ `SetClip` 约束,滚动时标签/数值会画到内容区之外 + //(用户 2026-10-30 报告"元素超出了浮动面板"的直接原因)。 + UiTextClipped(g, label, m_fonts->uiSmall, + gd::RectF(labelLeft, track.Y - 8.0f * m_scale, + UiMeasureTextWidth(m_fonts->uiSmall, label), + track.Height + 16.0f * m_scale), + m_theme->textMuted, TextAlign::LEFT, layout.content); + const double ratio = + (maximum > minimum) + ? (static_cast(current - minimum) / + static_cast(maximum - minimum)) + : 0.0; + UiDrawSlider(g, track, ratio, m_theme->accent, active, m_scale); + UiTextClipped(g, FormatInteger(current), m_fonts->monoSmall, + gd::RectF(valueRect.X, valueRect.Y, valueColumn, valueRect.Height), + m_theme->textStrong, TextAlign::LEFT, layout.content); + // 复位按钮:悬停高亮 + 与「系数/偏移」菜单同款图标。 + const bool hot = + (m_dragMode != PlotDragMode::EDGE_PANEL_MOVE) && + UiPointInRect(resetRect, static_cast(m_mouseX), + static_cast(m_mouseY)); + if (hot) + { + UiFillRoundRect( + g, gd::RectF(resetRect.X - 1.0f * m_scale, + resetRect.Y - 1.0f * m_scale, + resetRect.Width + 2.0f * m_scale, + resetRect.Height + 2.0f * m_scale), + 3.0f * m_scale, WithAlpha(m_theme->accent, 0x33)); + } + PaintPanelResetIcon(g, resetRect, + hot ? m_theme->accent : m_theme->textMuted, m_scale); + }; + + // ⚠️ 「均值点数」的最小值:**均值滤波 = 1**(用户 2026-10-30 要求不能为 0, + // 0 与不做平滑完全等价,看起来像功能坏了);**中心轴边沿计数 = 0** + // (0 = 不平滑,直接对原始采样值统计边沿 —— 这是有意义的选项,保持旧语义)。 + if (weakPeak) + { + // 「弱峰调整」两行滑块:调整幅度(0 在正中间 ⇒ ±400%)与迭代次数(1 … 自定上限)。 + const int peakAmount = std::max( + kWeakPeakAmountMin, std::min(kWeakPeakAmountMax, peakItem->amountPercent)); + const int peakMax = std::max( + kWeakPeakIterationsMin, + std::min(kWeakPeakIterationsMaxLimit, peakItem->maxIterations)); + const int peakIter = + std::max(kWeakPeakIterationsMin, std::min(peakMax, peakItem->iterations)); + const bool levelActive = (m_dragMode == PlotDragMode::PEAK_LEVEL) && + (m_peakDragLane == static_cast(index)) && + (m_peakDragRef == ref); + const bool iterActive = (m_dragMode == PlotDragMode::PEAK_ITERATIONS) && + (m_peakDragLane == static_cast(index)) && + (m_peakDragRef == ref); + const bool editingMax = + (m_editTarget == PlotEditTarget::PEAK_ITERATIONS_MAX) && + (m_editLane == static_cast(index)) && (m_editMeanRef == ref); + // 幅度滑块:比例 = (幅度 / (100*4) + 1) / 2(0% 在正中间)。 + const auto paintPeakSlider = [&](const gd::RectF& track, + const gd::RectF& valueRect, + const gd::RectF& resetRect, + const wchar_t* label, double ratio, + const std::wstring& valueText, bool active, + bool hideBody) + { + const float labelLeft = track.X - 6.0f * m_scale - + UiMeasureTextWidth(m_fonts->uiSmall, label); + UiTextClipped(g, label, m_fonts->uiSmall, + gd::RectF(labelLeft, track.Y - 8.0f * m_scale, + UiMeasureTextWidth(m_fonts->uiSmall, label), + track.Height + 16.0f * m_scale), + m_theme->textMuted, TextAlign::LEFT, layout.content); + if (hideBody) + { + return; // 正在编辑上限:轨道与数值改由 `PaintEditField` 画 + } + UiDrawSlider(g, track, ratio, m_theme->accent, active, m_scale); + UiTextClipped(g, valueText, m_fonts->monoSmall, + gd::RectF(valueRect.X, valueRect.Y, valueColumn, + valueRect.Height), + m_theme->textStrong, TextAlign::LEFT, layout.content); + const bool hot = (m_dragMode != PlotDragMode::EDGE_PANEL_MOVE) && + UiPointInRect(resetRect, + static_cast(m_mouseX), + static_cast(m_mouseY)); + if (hot) + { + UiFillRoundRect( + g, gd::RectF(resetRect.X - 1.0f * m_scale, + resetRect.Y - 1.0f * m_scale, + resetRect.Width + 2.0f * m_scale, + resetRect.Height + 2.0f * m_scale), + 3.0f * m_scale, WithAlpha(m_theme->accent, 0x33)); + } + PaintPanelResetIcon(g, resetRect, + hot ? m_theme->accent : m_theme->textMuted, + m_scale); + }; + const double levelRatio = + (static_cast(peakAmount) / (100.0 * kWeakPeakSliderScale) + 1.0) * 0.5; + const double iterRatio = + (peakMax > kWeakPeakIterationsMin) + ? (static_cast(peakIter - kWeakPeakIterationsMin) / + static_cast(peakMax - kWeakPeakIterationsMin)) + : 0.0; + if (rowIndex < layout.rowLevelTracks.size() && + layout.rowLevelTracks[rowIndex].Width > 0.0f) + { + paintPeakSlider( + layout.rowLevelTracks[rowIndex], + rowIndex < layout.rowLevelValues.size() + ? layout.rowLevelValues[rowIndex] + : gd::RectF(), + rowIndex < layout.rowLevelResets.size() + ? layout.rowLevelResets[rowIndex] + : gd::RectF(), + kWeakPeakLevelLabel, levelRatio, + (peakAmount > 0 ? L"+" : L"") + FormatInteger(peakAmount) + L"%", + levelActive, false); + } + if (rowIndex < layout.rowIterTracks.size() && + layout.rowIterTracks[rowIndex].Width > 0.0f) + { + paintPeakSlider( + layout.rowIterTracks[rowIndex], + rowIndex < layout.rowIterValues.size() + ? layout.rowIterValues[rowIndex] + : gd::RectF(), + rowIndex < layout.rowIterResets.size() + ? layout.rowIterResets[rowIndex] + : gd::RectF(), + kWeakPeakIterationsSliderLabel, iterRatio, + FormatInteger(peakIter), iterActive, editingMax); + } + } + else + { + // 只可能是「均值滤波」:中心轴边沿计数已改为独立的表格分支。 + const int pointsFloor = kMeanFilterPointsMin; + const int pointsMax = std::max(pointsFloor, + std::min(kMeanFilterPointsMaxLimit, + item->maxPoints)); + const int points = + std::max(pointsFloor, std::min(pointsMax, item->points)); + const int iterationsMax = std::max(kMeanFilterIterationsMin, + std::min(kMeanFilterIterationsMaxLimit, + item->maxIterations)); + const int iterations = std::max( + kMeanFilterIterationsMin, std::min(iterationsMax, item->iterations)); + const bool pointsActive = (m_dragMode == PlotDragMode::MEAN_POINTS) && + (m_meanDragLane == static_cast(index)) && + (m_meanDragRef == ref) && + (m_meanDragKind == kind); + const bool iterationsActive = + (m_dragMode == PlotDragMode::MEAN_ITERATIONS) && + (m_meanDragLane == static_cast(index)) && (m_meanDragRef == ref) && + (m_meanDragKind == kind); + if (rowIndex < layout.rowLevelTracks.size()) + { + paintMeanSlider(layout.rowLevelTracks[rowIndex], + rowIndex < layout.rowLevelValues.size() + ? layout.rowLevelValues[rowIndex] + : gd::RectF(), + rowIndex < layout.rowLevelResets.size() + ? layout.rowLevelResets[rowIndex] + : gd::RectF(), + kMeanFilterPointsSliderLabel, points, + pointsFloor, pointsMax, pointsActive); + } + if (rowIndex < layout.rowIterTracks.size()) + { + paintMeanSlider(layout.rowIterTracks[rowIndex], + rowIndex < layout.rowIterValues.size() + ? layout.rowIterValues[rowIndex] + : gd::RectF(), + rowIndex < layout.rowIterResets.size() + ? layout.rowIterResets[rowIndex] + : gd::RectF(), + kMeanFilterIterationsSliderLabel, iterations, + kMeanFilterIterationsMin, iterationsMax, iterationsActive); + } + } + + } + + g.Restore(meanSave); + + // ---- 内容滚动条(只在内容超高时出现;底部按钮不参与滚动)---- + // ⚠️ 必须在 `g.Restore(meanSave)` **之后**画:滚动条位于内容区右侧、在 + // 内容裁剪区之外,若在裁剪内画会被整条裁掉(用户 2026-10-30 报告 + // "全部启用后没有出现滚动条"的根因)。 + PaintPanelScrollBar(g, layout, *m_theme); + } + + /** + * @brief Marker panel content: per-signal group bars (collapsible) + table header + + * fixed-point rows, with sticky group bars. + * + * Paint order is rows -> header -> sticky group bar (the last one covers rows that + * scroll up). Rows hidden under a sticky bar are already excluded by the layout. + */ + void PlotView::PaintPanelMarkerGroups(gd::Graphics& g, const EdgePanelLayout& layout, + const Lane& tabLane, std::vector& tabLanes, + std::size_t laneIndex) { + const std::size_t index = laneIndex; + const float groupRowHeight = kFreeMarkerNameRowHeight * m_scale; + bool markerAny = false; + for (const Lane::FreeMarkerItem& item : tabLane.freeMarkerItems) + { + markerAny = markerAny || item.ref.IsValid(); + } + + const int contentState = g.Save(); + g.SetClip(layout.content, gd::CombineModeIntersect); + + if (!markerAny) + { + // ⚠️ 未开启「自由标记点选取」时鼠标是普通鼠标,**单击不会固定**任何数据点 + //(用户 2026-10-23 要求)⇒ 提示语必须说清前提,否则用户会以为 bug。 + const wchar_t* emptyHint = tabLane.freeMarkerCursor + ? L"在网格内单击以固定数据点" + : L"开启「自由标记点选取」后单击网格可固定数据点"; + UiTextClipped(g, emptyHint, m_fonts->uiSmall, + gd::RectF(layout.content.X, layout.content.Y, layout.content.Width, + groupRowHeight), + m_theme->textMuted, TextAlign::LEFT, layout.content); + } + + // ---- 分组栏 ---- + // ⚠️ 抽取成 lambda:**吸顶**的分组栏必须在条目之后重画一次(否则正在向上滚的 + // 条目会盖住它)。见下方“条目 → 表头 → 吸顶分组栏”的绘制顺序。 + const auto paintGroupBar = [this, &g, &layout, &tabLane, index]( + const EdgePanelLayout::MarkerGroup& group) + { + const ResolvedSignal resolved = ResolveSignal(group.ref); + if (resolved.data == nullptr) + { + return; + } + const std::uint32_t signalColor = + (resolved.style != nullptr) ? StyleColor(*resolved.style) : m_theme->border; + // 托底 = 信号色;文字一律用**自适应对比色**(与信号面板的组名徽标同规则), + // 因此无论信号是亮黄还是深蓝都能看清分组栏上的所有内容。 + const UiInk ink = UiContrastInk(signalColor); + // 先铺一层**完全不透明**的同色,再叠 0xE8(观感不变): + // 吸顶时它要压住下面滚动的条目,半透明会让条目透出来。 + UiFillRoundRect(g, group.header, 3.0f * m_scale, signalColor); + UiFillRoundRect(g, group.header, 3.0f * m_scale, WithAlpha(signalColor, 0xE8)); + + // 左侧三角(与信号选择区域组名同款):朝下 = 展开、朝右 = 收起。 + const bool triangleHot = + (m_freeMarkerTriangleHoverLane == static_cast(index) && + m_freeMarkerTriangleHoverSignal == group.ref); + UiDrawTriangleIcon(g, group.triangle, + triangleHot ? m_theme->accentSoft : ink.text, + group.collapsed ? IconDirection::RIGHT + : IconDirection::DOWN); + + // 右侧:`N 标记点` + ✕(清除该信号全部固定点)。 + std::size_t markerCount = 0; + for (const Lane::FreeMarkerItem& entry : tabLane.freeMarkerItems) + { + if (entry.ref == group.ref) + { + ++markerCount; + } + } + const std::wstring countText = FormatInteger(markerCount) + L" 标记点"; + const float countWidth = + UiMeasureTextWidth(m_fonts->monoSmall, countText) + 2.0f * m_scale; + const float countRight = group.close.X - 5.0f * m_scale; + UiTextClipped(g, countText, m_fonts->monoSmall, + gd::RectF(countRight - countWidth, group.header.Y, countWidth, + group.header.Height), + ink.text, TextAlign::RIGHT, layout.content); + + const float nameLeft = group.triangle.X + group.triangle.Width + 6.0f * m_scale; + const float nameWidth = + std::max(10.0f, countRight - countWidth - 6.0f * m_scale - nameLeft); + UiTextClipped(g, UiEllipsize(resolved.data->name, m_fonts->uiSmall, nameWidth), + m_fonts->uiSmall, + gd::RectF(nameLeft, group.header.Y, nameWidth, group.header.Height), + ink.text, TextAlign::LEFT, layout.content); + + const bool closeHot = (m_freeMarkerPanelHoverLane == static_cast(index) && + m_freeMarkerPanelHoverSignal == group.ref && + m_freeMarkerPanelHoverItem == static_cast(-1)); + if (closeHot) + { + UiFillRoundRect(g, group.close, 3.0f * m_scale, + WithAlpha(m_theme->danger, 0x77)); + } + const float crossPad = 3.5f * m_scale; + const std::uint32_t crossColor = closeHot ? m_theme->danger : ink.text; + UiDrawLine(g, group.close.X + crossPad, group.close.Y + crossPad, + group.close.X + group.close.Width - crossPad, + group.close.Y + group.close.Height - crossPad, crossColor, + 1.5f * m_scale); + UiDrawLine(g, group.close.X + group.close.Width - crossPad, + group.close.Y + crossPad, group.close.X + crossPad, + group.close.Y + group.close.Height - crossPad, crossColor, + 1.5f * m_scale); + }; + for (const EdgePanelLayout::MarkerGroup& group : layout.markerGroups) + { + if (!group.pinned) + { + paintGroupBar(group); + } + } + + // ---- 固定点条目(六列,列起点由布局给定 ⇒ 纵向严格对齐)---- + // ⚠️ 取值与各列文本来自**与视界无关的内容缓存**(`FreeMarkerContents`): + // 大量固定点时逐帧重新采样会让"拖光标 / 平移视界"明显变卡。 + const std::vector& contents = + FreeMarkerContents(static_cast(index)); + // 六列的**实测宽度**:含滚出视界的条目,保证列宽稳定、文本不被省略。 + // 存回 `Lane` 供下一步的布局(`EdgePanelWidth` / `EdgePanelLayoutFor`)使用。 + { + float widest[6] = {0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f}; + const auto measure = [this, &widest](std::size_t column, const std::wstring& text) { + if (text.empty() || column >= 6) + { + return; + } + widest[column] = std::max(widest[column], + UiMeasureTextWidth(m_fonts->monoSmall, text)); + }; + // 「序号」列:取本网格所有分组里最大的序号文本宽度。 + std::size_t maxSerial = 0; + std::vector serialOrder; + for (const SignalRef& ref : tabLane.signals) + { + SortedFreeMarkerIndices(tabLane, ref, serialOrder); + maxSerial = std::max(maxSerial, serialOrder.size()); + } + if (maxSerial > 0) + { + measure(EdgePanelLayout::MARKER_COL_SERIAL, FormatInteger(maxSerial)); + } + for (const FreeMarkerContentEntry& entry : contents) + { + measure(EdgePanelLayout::MARKER_COL_VALUE, entry.valueText); + measure(EdgePanelLayout::MARKER_COL_ENUM, entry.enumText); + measure(EdgePanelLayout::MARKER_COL_DELTA, entry.deltaText); + measure(EdgePanelLayout::MARKER_COL_TIME, entry.timeText); + measure(EdgePanelLayout::MARKER_COL_DT, entry.deltaTimeText); + } + std::array& stored = + tabLanes[index].freeMarkerColumnWidths; + for (std::size_t column = 0; column < 6; ++column) + { + // +2px 余量:`DrawTextW` 的 `DT_END_ELLIPSIS` 对"刚好装满"的文本 + // 有时仍会截成省略号(亚像素/字形伸部),留一点余量可彻底避免。 + const float want = + (widest[column] > 0.0f) ? (widest[column] + 2.0f * m_scale) : 0.0f; + if (std::fabs(stored[column] - want) > 0.5f) + { + stored[column] = want; + } + } + } + // 列分隔竖线:在每列右边界(加上半个列间隙)画一条淡竖线, + // 让「序号 / 标记值 / 枚举文本 / 增量 / 时间点 / 时间间隔」有明显的列分界 + //(用户 2026-10-23 要求)。最后一列不画(它右边紧接行尾 ✕)。 + const auto paintColumnSeparators = [this, &g, &layout](float y, float height) { + int lastVisible = -1; + for (int column = 0; column < 6; ++column) + { + if (layout.markerColumns[column].width > 0.0f) + { + lastVisible = column; + } + } + for (int column = 0; column < lastVisible; ++column) + { + const EdgePanelLayout::MarkerColumn& cell = layout.markerColumns[column]; + if (cell.width <= 0.0f) + { + continue; + } + const float lineX = + cell.left + cell.width + kFreeMarkerColumnGap * m_scale * 0.5f; + UiDrawLine(g, lineX, y + 2.0f * m_scale, lineX, y + height - 2.0f * m_scale, + WithAlpha(m_theme->borderStrong, 0x6C), 1.0f); + } + }; + for (const EdgePanelLayout::MarkerRow& markerRow : layout.markerRows) + { + if (markerRow.itemIndex >= contents.size()) + { + continue; + } + const FreeMarkerContentEntry& entry = contents[markerRow.itemIndex]; + + // ---- 行的选中 / 悬停高亮(用户 2026-10-22 要求)---- + const bool selected = + (tabLane.freeMarkerSelected == static_cast(markerRow.itemIndex)); + const bool rowHot = (m_freeMarkerRowHoverLane == static_cast(index) && + m_freeMarkerRowHoverItem == markerRow.itemIndex); + if (selected || rowHot) + { + UiFillRect(g, + gd::RectF(markerRow.rect.X, markerRow.rect.Y + 1.0f * m_scale, + markerRow.rect.Width, + markerRow.rect.Height - 2.0f * m_scale), + selected ? WithAlpha(m_theme->accent, 0x3C) + : WithAlpha(m_theme->accent, 0x1E)); + } + + const auto columnRect = [&](EdgePanelLayout::MarkerColumnIndex column) { + return gd::RectF(layout.markerColumns[column].left, markerRow.rect.Y, + layout.markerColumns[column].width, + markerRow.rect.Height); + }; + // ⚠️ 面板里的数据列**不得省略**(用户 2026-10-21 明确要求): + // 列宽已按实测文本宽度撑开,因此这里直接整段输出,不再 `UiEllipsize`。 + // ⚠️ 必须用 `UiTextClipped`(GDI 裁剪):`UiText` 走 `DrawTextW`, + // **不认 GDI+ 裁剪** ⇒ 滚动到内容区上方的条目会把文字画到标题栏上 + //(用户 2026-10-22 报告的显示错误)。 + const auto paintCell = [&](EdgePanelLayout::MarkerColumnIndex column, + const std::wstring& text, std::uint32_t color) { + if (layout.markerColumns[column].width <= 0.0f || text.empty()) + { + return; + } + UiTextClipped(g, text, m_fonts->monoSmall, columnRect(column), color, + TextAlign::LEFT, layout.content); + }; + paintCell(EdgePanelLayout::MARKER_COL_SERIAL, FormatInteger(markerRow.serial), + m_theme->textMuted); + paintCell(EdgePanelLayout::MARKER_COL_VALUE, entry.valueText, + m_theme->textStrong); + if (layout.markerHasEnumColumn) + { + paintCell(EdgePanelLayout::MARKER_COL_ENUM, entry.enumText, m_theme->text); + } + paintCell(EdgePanelLayout::MARKER_COL_DELTA, entry.deltaText, + m_theme->textMuted); + paintCell(EdgePanelLayout::MARKER_COL_TIME, entry.timeText, m_theme->textMuted); + paintCell(EdgePanelLayout::MARKER_COL_DT, entry.deltaTimeText, + m_theme->textMuted); + + // 列分隔竖线(用户 2026-10-23 要求:各列之间要有明显分界)。 + paintColumnSeparators(markerRow.rect.Y, markerRow.rect.Height); + + // 行尾 ✕:移除该固定点(与之前一致)。 + const gd::RectF& closeRect = markerRow.close; + const bool hot = (m_freeMarkerPanelHoverLane == static_cast(index) && + m_freeMarkerPanelHoverItem == markerRow.itemIndex); + UiFillRoundRect(g, closeRect, 3.0f * m_scale, + hot ? WithAlpha(m_theme->danger, 0x66) : m_theme->cardBg); + UiDrawRoundRect(g, closeRect, 3.0f * m_scale, + hot ? m_theme->danger : m_theme->border, 1.0f); + const float crossPad = 3.5f * m_scale; + const std::uint32_t crossColor = hot ? m_theme->danger : m_theme->textMuted; + UiDrawLine(g, closeRect.X + crossPad, closeRect.Y + crossPad, + closeRect.X + closeRect.Width - crossPad, + closeRect.Y + closeRect.Height - crossPad, crossColor, 1.2f * m_scale); + UiDrawLine(g, closeRect.X + closeRect.Width - crossPad, closeRect.Y + crossPad, + closeRect.X + crossPad, closeRect.Y + closeRect.Height - crossPad, + crossColor, 1.2f * m_scale); + } + + // ---- 表头(每个展开分组一行,与条目共用列边界)---- + // ⚠️ 必须在条目**之后**绘制:表头底衬要不透明地盖住向上滚动的条目。 + for (const EdgePanelLayout::MarkerGroup& group : layout.markerGroups) + { + if (group.tableHeader.Width <= 0.0f || group.tableHeader.Height <= 0.0f) + { + continue; + } + // 不透明底衬(与面板底色 + 少量边框色调混合,观感是“一条浅灰带”)。 + UiFillRect(g, group.tableHeader, + MixColor(m_theme->cardBg, m_theme->borderStrong, 0.22)); + UiDrawLine(g, group.tableHeader.X, group.tableHeader.Y + group.tableHeader.Height - 1.0f, + group.tableHeader.X + group.tableHeader.Width, + group.tableHeader.Y + group.tableHeader.Height - 1.0f, + WithAlpha(m_theme->borderStrong, 0x99), 1.0f); + for (int column = 0; column < 6; ++column) + { + const EdgePanelLayout::MarkerColumn& cell = + layout.markerColumns[column]; + if (cell.width <= 0.0f) + { + continue; + } + UiTextClipped(g, kFreeMarkerColumnLabels[column], m_fonts->uiSmall, + gd::RectF(cell.left, group.tableHeader.Y, cell.width, + group.tableHeader.Height), + m_theme->textMuted, TextAlign::LEFT, layout.content); + } + // 表头的列分隔竖线(与数据行同一套列边界)。 + paintColumnSeparators(group.tableHeader.Y, group.tableHeader.Height); + } + + // ---- 吸顶的分组栏(最后画:压住向上滚动的条目)---- + for (const EdgePanelLayout::MarkerGroup& group : layout.markerGroups) + { + if (group.pinned) + { + paintGroupBar(group); + } + } + + g.Restore(contentState); + + // ---- 内容滚动条(只在内容超高时出现;按钮与滑块不参与滚动)---- + PaintPanelScrollBar(g, layout, *m_theme); + + // ---- 「标记透明度」滑动条(固定在「自由标记点选取」按钮上方)---- + if (layout.opacityTrack.Width > 0.0f) + { + // 几何全部取自布局(与命中测试同源)。 + const gd::RectF& panelRect = layout.panel; + const float labelGap = kEdgePanelPad * m_scale; + UiText(g, kFreeMarkerOpacityLabel, m_fonts->uiSmall, + gd::RectF(panelRect.X + labelGap, layout.opacityValue.Y, + layout.opacityTrack.X - panelRect.X - labelGap, + layout.opacityValue.Height), + m_theme->textMuted, TextAlign::LEFT); + const bool sliderActive = + (m_dragMode == PlotDragMode::MARKER_OPACITY && + m_markerOpacityDragLane == static_cast(index)) || + UiPointInRect(layout.opacityTrack, static_cast(m_mouseX), + static_cast(m_mouseY)); + // 外观与信号样式区的「透明度」滑动条完全相同(同一 `UiDrawSlider` 口径)。 + UiDrawSlider(g, layout.opacityTrack, + static_cast(tabLane.freeMarkerOpacity), m_theme->accent, + sliderActive, m_scale); + const int percent = + static_cast(tabLane.freeMarkerOpacity * 100.0f + 0.5f); + UiText(g, FormatInteger(percent) + L"%", m_fonts->monoSmall, + layout.opacityValue, m_theme->textStrong, TextAlign::RIGHT); + } + } + +} // namespace jdi + -- 2.54.0 From 85aaed55f64b1f0ef1a50a951a577654eed663f4 Mon Sep 17 00:00:00 2001 From: "DeepSeek V4.1 Flash" <> Date: Wed, 4 Nov 2026 15:18:01 +0800 Subject: [PATCH 16/22] =?UTF-8?q?refactor(panels):=20=E8=A1=8C=E8=BF=BD?= =?UTF-8?q?=E5=8A=A0=E6=94=B6=E6=95=9B=E4=B8=BA=20AppendRow=20=E5=94=AF?= =?UTF-8?q?=E4=B8=80=E5=85=A5=E5=8F=A3=EF=BC=88=E6=B6=88=E9=99=A4=E5=B9=B3?= =?UTF-8?q?=E8=A1=8C=E6=95=B0=E7=BB=84=E9=94=99=E4=BD=8D=E9=9A=90=E6=82=A3?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 背景:Code Review 报告 §12.7 建议 1。"改一次面板布局就出错"的**机制性**原因, 是 `EdgePanelLayout` 的每一行对应 **18 个按行平行的 `std::vector`**, 而每个排版分支都要在同一位置、按同一顺序 push 恰好 18 项。只要某个分支少推一项, 那一列之后的所有行都会读到**别人的**矩形(`panels` 的长度不变式只能事后报警)。 本次做法(比"合并成一个 PanelRow 结构体 + 314 处引用"风险低得多、收益相同) * `EdgePanelLayout::AppendRow(ref, header, nameRect, triangle, check)`: **唯一**允许新增行的入口,18 个数组一次补齐(参数行/重采样行一律为空矩形)。 * 三个 push 块改为: * 边沿计数表格:13 次 push → **1 次 `AppendRow`**; * 列表式:13 次 push → **1 次 `AppendRow`**;参数行/重采样行改为 `rowXxx.back() = …` **赋值**(长度天然对齐,不再需要 push); * 「未勾选/收起」分支里那 6+4 次补空 push **整个删掉**(AppendRow 已补过)。 * ⇒ 新增一列只需改 `AppendRow` 一处(新增行的分支不必逐个补); 新增行必须走 `AppendRow`(新增列不会漏)。 为什么没有做"完整合并成 std::vector + 列视图" * 列视图方案需要给 `EdgePanelLayout` 写自定义拷贝构造/赋值(视图持有 `panelRows` 的地址), 而该类型是 `EdgePanelLayoutFor()` 的返回值、又被存进 `std::vector`, 拷贝路径是热路径 —— 一旦拷贝语义写错,会产生**比原缺陷更隐蔽**的悬垂视图。 * 本轮先消除"错位"这个真实隐患(AppendRow 已做到), 完整结构合并留到有"逐行几何基线"检查之后再做(已记入后续建议)。 验证 * `panels -mode compare` → 7 个面板像素哈希 + 几何**逐项不变**。 * `panelscroll`(MF4-Record,566 行)→ 7 种面板的行数组长度全部等于 566,行为不变。 * `OffscreenChecks -all` → 6/6 PASS,`checks=108 failed=0`。 * `UnitTests` → `checks=260 failed=0`。 * release 全量零 error / 零 warning;产物 786,432 → **785,408 B**(−1,024)。 --- Components/PlotView/Inc/PlotView.hpp | 40 ++++++++++ Components/PlotView/Src/PlotViewPanels.cpp | 90 +++++++--------------- 2 files changed, 67 insertions(+), 63 deletions(-) diff --git a/Components/PlotView/Inc/PlotView.hpp b/Components/PlotView/Inc/PlotView.hpp index 1449abc..98609ee 100644 --- a/Components/PlotView/Inc/PlotView.hpp +++ b/Components/PlotView/Inc/PlotView.hpp @@ -2070,6 +2070,7 @@ namespace jdi /** @brief 「均值滤波」面板每个信号标题栏左侧的**展开 / 收起三角**(仅勾选后出现)。 */ std::vector rowTriangles; std::vector rowRefs; + /** * @brief 「自由标记」面板的一行(一个固定点)。 * @@ -2210,6 +2211,45 @@ namespace jdi MARKER_COL_TIME = 4, MARKER_COL_DT = 5 }; + + /** + * @brief 追加一**行**(条目栏 / 名称 / 三角 / 复选框),其余列一律补空矩形。 + * + * ⚠️⚠️ 这是**唯一**允许新增行的入口。面板每行对应 18 个平行数组 + * (`rowRefs` / `rowHeaders` / `rowChecks` / `rowLevelTracks` / …); + * 如果每个排版分支各写一遍 `push_back`,只要某分支漏推一项, + * 那一列之后的所有行都会**读到别人的矩形**("改一次布局就出错"的典型机制)。 + * 现在:新增行的分支只调本函数,**新增一列只需改这里一处**; + * 参数行 / 重采样行则用 `rowXxx.back() = …` 赋值(长度天然对齐)。 + * + * @param [IN] ref 该行对应的信号。 + * @param [IN] header 信号色叠底条目栏(整行宽度)。 + * @param [IN] nameRect 名称(表格里的名称列)。 + * @param [IN] triangle 左侧展开 / 收起三角(不适用时传空矩形)。 + * @param [IN] check 右端复选框。 + */ + void AppendRow(const SignalRef& ref, const gd::RectF& header, const gd::RectF& nameRect, + const gd::RectF& triangle, const gd::RectF& check) + { + rowRefs.push_back(ref); + rowHeaders.push_back(header); + rowNameRects.push_back(nameRect); + rowTriangles.push_back(triangle); + rowChecks.push_back(check); + rowPoints.push_back(gd::RectF()); + rowIterations.push_back(gd::RectF()); + rowStats.push_back(gd::RectF()); + rowLevelTracks.push_back(gd::RectF()); + rowLevelValues.push_back(gd::RectF()); + rowLevelResets.push_back(gd::RectF()); + rowIterTracks.push_back(gd::RectF()); + rowIterValues.push_back(gd::RectF()); + rowIterResets.push_back(gd::RectF()); + rowInfoMaxInterval.push_back(gd::RectF()); + rowInfoMinInterval.push_back(gd::RectF()); + rowIntervalField.push_back(gd::RectF()); + rowFrequencyField.push_back(gd::RectF()); + } }; /** diff --git a/Components/PlotView/Src/PlotViewPanels.cpp b/Components/PlotView/Src/PlotViewPanels.cpp index 7b78260..34b22c5 100644 --- a/Components/PlotView/Src/PlotViewPanels.cpp +++ b/Components/PlotView/Src/PlotViewPanels.cpp @@ -735,22 +735,12 @@ namespace jdi { continue; } - layout.rowRefs.push_back(ref); - layout.rowHeaders.push_back(gd::RectF(contentLeft, rowY, innerWidth, rowH)); - layout.rowNameRects.push_back( - gd::RectF(contentLeft + 4.0f * m_scale, rowY, - std::max(10.0f, nameColumn - 8.0f * m_scale), rowH)); - layout.rowTriangles.push_back(gd::RectF()); - layout.rowChecks.push_back(gd::RectF()); - layout.rowPoints.push_back(gd::RectF()); - layout.rowIterations.push_back(gd::RectF()); - layout.rowStats.push_back(gd::RectF()); - layout.rowLevelTracks.push_back(gd::RectF()); - layout.rowLevelValues.push_back(gd::RectF()); - layout.rowLevelResets.push_back(gd::RectF()); - layout.rowIterTracks.push_back(gd::RectF()); - layout.rowIterValues.push_back(gd::RectF()); - layout.rowIterResets.push_back(gd::RectF()); + // ⚠️ 行的追加走 `AppendRow()`(**唯一入口**):18 个平行数组一次补齐, + // 不会出现"某个分支漏推一列 ⇒ 该列之后所有行读到别人的矩形"。 + layout.AppendRow(ref, gd::RectF(contentLeft, rowY, innerWidth, rowH), + gd::RectF(contentLeft + 4.0f * m_scale, rowY, + std::max(10.0f, nameColumn - 8.0f * m_scale), rowH), + gd::RectF(), gd::RectF()); rowY += rowH; } } @@ -886,42 +876,26 @@ namespace jdi checked = (item != nullptr); itemCollapsed = (item != nullptr && item->collapsed); } - layout.rowRefs.push_back(ref); // 组头(信号色叠底):整行宽度,左侧三角(仅勾选时)+ 名称,右端复选框。 - layout.rowHeaders.push_back( - gd::RectF(contentLeft, rowY, innerWidth, groupRow)); - layout.rowNameRects.push_back(gd::RectF(contentLeft, rowY, innerWidth, groupRow)); - layout.rowTriangles.push_back( + // ⚠️ 行的追加走 `AppendRow()`(**唯一入口**):18 个平行数组一次补齐, + // 不会出现"某个分支漏推一列 ⇒ 该列之后所有行读到别人的矩形"。 + // 下面的参数行 / 重采样行一律用 `rowXxx.back() = …` **赋值**(长度天然对齐)。 + layout.AppendRow( + ref, gd::RectF(contentLeft, rowY, innerWidth, groupRow), + gd::RectF(contentLeft, rowY, innerWidth, groupRow), (checked && !clipPanel) ? gd::RectF(contentLeft + 3.0f * m_scale, rowY + (groupRow - kEdgePanelRowTriangleSize * m_scale) * 0.5f, kEdgePanelRowTriangleSize * m_scale, kEdgePanelRowTriangleSize * m_scale) - : gd::RectF()); - layout.rowChecks.push_back( + : gd::RectF(), gd::RectF(contentLeft + innerWidth - closeSize - 4.0f * m_scale, rowY + (groupRow - closeSize) * 0.5f, closeSize, closeSize)); - layout.rowPoints.push_back(gd::RectF()); - layout.rowIterations.push_back(gd::RectF()); - layout.rowStats.push_back(gd::RectF()); rowY += groupRow; // 参数行:只有勾选且未收起时才有几何(未勾选的行两者都是空矩形)。 if (!checked || itemCollapsed || clipPanel) { - layout.rowLevelTracks.push_back(gd::RectF()); - layout.rowLevelValues.push_back(gd::RectF()); - layout.rowLevelResets.push_back(gd::RectF()); - layout.rowIterTracks.push_back(gd::RectF()); - layout.rowIterValues.push_back(gd::RectF()); - layout.rowIterResets.push_back(gd::RectF()); - if (resamplePanel) - { - layout.rowInfoMaxInterval.push_back(gd::RectF()); - layout.rowInfoMinInterval.push_back(gd::RectF()); - layout.rowIntervalField.push_back(gd::RectF()); - layout.rowFrequencyField.push_back(gd::RectF()); - } continue; } if (resamplePanel) @@ -929,27 +903,17 @@ namespace jdi // 「重采样」四行:① 最大间隔(只读)② 最小间隔(只读) // ③ 重采样间隔(输入)④ 重采样频率(输入,与③联动)。 // 每行 = 左侧标签 + 右侧数值/输入框区域。 - layout.rowLevelTracks.push_back(gd::RectF()); - layout.rowLevelValues.push_back(gd::RectF()); - layout.rowLevelResets.push_back(gd::RectF()); - layout.rowIterTracks.push_back(gd::RectF()); - layout.rowIterValues.push_back(gd::RectF()); - layout.rowIterResets.push_back(gd::RectF()); - layout.rowInfoMaxInterval.push_back( - gd::RectF(contentLeft, rowY, innerWidth, sliderRow)); + layout.rowInfoMaxInterval.back() = gd::RectF(contentLeft, rowY, innerWidth, sliderRow); rowY += sliderRow; - layout.rowInfoMinInterval.push_back( - gd::RectF(contentLeft, rowY, innerWidth, sliderRow)); + layout.rowInfoMinInterval.back() = gd::RectF(contentLeft, rowY, innerWidth, sliderRow); rowY += sliderRow; // 输入行的**数值矩形**靠右对齐,宽度取剩余可用宽度(绘制框与编辑框同源)。 const float fieldLeft = contentLeft + rowLabelColumn + 8.0f * m_scale; const float fieldWidth = std::max(24.0f * m_scale, innerWidth - rowLabelColumn - 8.0f * m_scale); - layout.rowIntervalField.push_back( - gd::RectF(fieldLeft, rowY, fieldWidth, sliderRow)); + layout.rowIntervalField.back() = gd::RectF(fieldLeft, rowY, fieldWidth, sliderRow); rowY += sliderRow; - layout.rowFrequencyField.push_back( - gd::RectF(fieldLeft, rowY, fieldWidth, sliderRow)); + layout.rowFrequencyField.back() = gd::RectF(fieldLeft, rowY, fieldWidth, sliderRow); rowY += sliderRow; continue; } @@ -961,22 +925,22 @@ namespace jdi } const gd::RectF pointsTrack(trackLeft, rowY + (sliderRow - trackHeight) * 0.5f, trackWidth, trackHeight); - layout.rowLevelTracks.push_back(pointsTrack); - layout.rowLevelValues.push_back( + layout.rowLevelTracks.back() = pointsTrack; + layout.rowLevelValues.back() = gd::RectF(pointsTrack.X + pointsTrack.Width + 6.0f * m_scale, rowY, valueColumn, - sliderRow)); - layout.rowLevelResets.push_back( - gd::RectF(resetLeft, rowY + (sliderRow - resetSize) * 0.5f, resetSize, resetSize)); + sliderRow); + layout.rowLevelResets.back() = + gd::RectF(resetLeft, rowY + (sliderRow - resetSize) * 0.5f, resetSize, resetSize); rowY += sliderRow; const gd::RectF iterTrack(trackLeft, rowY + (sliderRow - trackHeight) * 0.5f, trackWidth, trackHeight); - layout.rowIterTracks.push_back(iterTrack); - layout.rowIterValues.push_back( + layout.rowIterTracks.back() = iterTrack; + layout.rowIterValues.back() = gd::RectF(iterTrack.X + iterTrack.Width + 6.0f * m_scale, rowY, valueColumn, - sliderRow)); - layout.rowIterResets.push_back( - gd::RectF(resetLeft, rowY + (sliderRow - resetSize) * 0.5f, resetSize, resetSize)); + sliderRow); + layout.rowIterResets.back() = + gd::RectF(resetLeft, rowY + (sliderRow - resetSize) * 0.5f, resetSize, resetSize); rowY += sliderRow; } } -- 2.54.0 From 221f9d241062a649eb54567998f2f5f6b126ccd9 Mon Sep 17 00:00:00 2001 From: "DeepSeek V4.1 Flash" <> Date: Wed, 4 Nov 2026 15:20:07 +0800 Subject: [PATCH 17/22] =?UTF-8?q?test(tools):=20=E8=A1=8C=E6=95=B0?= =?UTF-8?q?=E7=BB=84=E4=B8=8D=E5=8F=98=E5=BC=8F=E6=94=B6=E7=B4=A7=E4=B8=BA?= =?UTF-8?q?"18=20=E4=B8=AA=E5=85=A8=E9=83=A8=E7=AD=89=E4=BA=8E=E8=A1=8C?= =?UTF-8?q?=E6=95=B0"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 背景:`AppendRow()`(提交 85aaed5)已经成为新增面板行的唯一入口, 18 个平行数组由它一次补齐。因此旧的宽松判据("13 个必须等长 + 4 个重采样专属列允许为空")不再需要 —— 任何一列短于行数, 就是"有人绕过 AppendRow 手写 push"的直接证据。 改动: * `panels` 检查:改为断言 18 个数组长度**全部**等于 `rowRefs.size()`, 失败时打印全部 18 列的长度便于定位。 * `panelscroll` 检查:同样收紧(大文件 566 行时才会暴露的问题)。 * `Tools/README.md`:同步不变式描述。 文档: * `Documents/Code_Review_Plan.md` 新增 §八(第 36 轮节点与硬约束)。 * `Documents/Code_Review_Progress.md` 新增第 36 轮(两次重构 + 三个搬移陷阱)。 * `Documents/Code_Review_Report.md` 新增 §13(拆分职责表、为何不做完整合并、 失败可见性对比、后续建议)。 * `Documents/agent_mem.md` 新增第 36 轮规则。 验证: * `make release` 全量零 error / 零 warning,产物 **785,408 B**。 * `UnitTests` → checks=260 failed=0;`OffscreenChecks -all` → 6/6 PASS,checks=101 failed=0。 * `panels -mode compare` → 7 个面板像素哈希与几何逐项不变。 * `panelscroll`(MF4 566 行)→ 18 个行数组全部 = 566。 --- Documents/Code_Review_Plan.md | 35 +++++++++++++ Documents/Code_Review_Progress.md | 63 +++++++++++++++++++++++ Documents/Code_Review_Report.md | 75 ++++++++++++++++++++++++++++ Documents/agent_mem.md | 65 ++++++++++++++++++++++++ Tools/OffscreenChecks/Src/Checks.cpp | 53 ++++++++------------ Tools/README.md | 4 +- 6 files changed, 260 insertions(+), 35 deletions(-) diff --git a/Documents/Code_Review_Plan.md b/Documents/Code_Review_Plan.md index 7c99584..88e4902 100644 --- a/Documents/Code_Review_Plan.md +++ b/Documents/Code_Review_Plan.md @@ -236,3 +236,38 @@ `__USE_MINGW_ANSI_STDIO=1`,命令行关不掉 —— 见 Makefile 注释)。 4. **改面板几何/绘制前后必须跑** `OffscreenChecks -run panels -mode record/compare`; 本轮它当场抓到了"截取面板少了 37px 高度"的回归。 + +--- + +## 八、第 36 轮追加(2026-11-04):§12.7 两项建议的落地 + +> 触发:用户要求「继续按照上轮报告的后续建议推进」。§12.7 列了两项: +> ① `EdgePanelLayout` 平行数组合并;② `PaintEdgeCursor` 拆分。 + +| 节点 | 内容 | 结论 | +| --- | --- | --- | +| **P26** | 拆分 `PaintEdgeCursor` 三段内容绘制 | ✅ 完成(新建 `PlotViewPanelPaint.cpp`) | +| **P27** | 平行数组错位隐患 | ✅ 用 `AppendRow()` 唯一入口消除(**未做**完整结构合并,理由见下) | +| **P28** | 收紧不变式 | ✅ 18 个数组**全部**必须等于行数(不再允许"重采样专属列可为空") | + +### 8.1 本轮新增硬约束 + +1. **新增一种面板行,只能走 `EdgePanelLayout::AppendRow(...)`**。 + 18 个平行数组由它一次补齐;参数行 / 重采样行一律 `rowXxx.back() = …` **赋值**。 + **禁止**在任何布局分支里手写 `rowXxx.push_back(...)`。 +2. **面板内容绘制按排版类别分文件**:骨架(边框/标题栏/✕/按钮)在 `PlotViewPaint.cpp`, + 内容在 `PlotViewPanelPaint.cpp`。新增一种面板 = 加一个 `PanelStyleOf` 分支 + + 在对应内容函数里加一支。 +3. **`OffscreenChecks -run panels` 的长度不变式现在是"全等"判定**: + 任何一列短于行数即失败(这条检查是"绕过 `AppendRow`"的唯一探测器)。 + +### 8.2 为什么没有做「完整合并成 `std::vector` + 列视图」 + +技术上可行(`PanelColumn` 持有 `std::vector*` + 成员指针, +读点只需在 `layout.rowXxx` 后加一对括号),但 **`EdgePanelLayout` 需要自定义拷贝构造/赋值** +来重新绑定视图;而它是 `EdgePanelLayoutFor()` 的返回值、又被存进 `std::vector`, +拷贝是热路径 —— 拷贝语义一旦写错,会产生**比原缺陷更隐蔽的悬垂视图**(原缺陷至少会被 +长度不变式抓到,悬垂指针不会)。 + +本轮先用 `AppendRow()` 把**真实隐患**("某分支漏推一列")消除, +完整结构合并留待"逐行几何基线"检查就绪后再做。 diff --git a/Documents/Code_Review_Progress.md b/Documents/Code_Review_Progress.md index dfe1e5f..2d1432e 100644 --- a/Documents/Code_Review_Progress.md +++ b/Documents/Code_Review_Progress.md @@ -347,3 +347,66 @@ Makefile 按目录通配收集源文件,**无需登记新文件**。 **本轮未做**:与 P22 抢时间,且属纯可读性收益。 3. **给平行数组补检查**:在 `panels` 里增加"每个 row 的 17 项长度一致"断言 (成本极低,可作为第 1 项的前置护栏)—— 已记入待办。 + +--- + +## 第 36 轮(2026-11-04):§12.7 两项建议落地 + +### P26 拆分 `PaintEdgeCursor` —— ✅ 完成(提交 `38fbd4b`) + +* 新增 `Components/PlotView/Src/PlotViewPanelPaint.cpp`(1016 行,**机械搬移、零逻辑改动**): + * `PaintPanelCountTableRows` —— 边沿计数表格(固定表头 + 可滚动行); + * `PaintPanelListRows` —— 列表式(均值/中心轴/弱峰/重采样/截取)条目栏 + 参数行; + * `PaintPanelMarkerGroups` —— 自由标记(分组栏 + 表头 + 条目 + 吸顶)。 + 每个函数只接收它真正需要的参数(`layout` / `tabLane` / `kind` / `laneIndex`), + 不再"整个函数都看得见"。 +* `PlotViewPaint.cpp`:6554 → 5575 行(`PaintEdgeCursor` 由约 1900 行降到约 900 行); + 三处块替换为一行调用,骨架(边框/标题栏/折起三角/GX 徽标/✕)与底部按钮仍在一处。 +* 滚动条绘制抽为 `pvdetail::PaintPanelScrollBar(g, layout, theme)`。 +* **顺带补上第 35 轮承诺未落地的一处**:`PaintEdgeCursor` 的标题仍是 7 层三元式 + (与 `EdgePanelWidth` 里另一条链并存,后者漏了 MARK/CLIP)⇒ 统一为 `PanelTitle(kind)`。 + +**过程中"机械搬移"踩到的三个真实问题**(都已修) +1. 提取块里引用的 `paintCounts` / `paintPanelScrollBar` 是**局部 lambda** ⇒ + 前者改为新函数内的局部 lambda(只服务于该段)、后者提升为 pvdetail 内联函数。 +2. 「标记透明度」滑块用到的 `panel` / `pad` 是外层局部变量 ⇒ 改为从 `layout.panel` 派生。 +3. `tabLanes` 在 marker 段里是**被写**的(回写各列实测宽度)⇒ 参数必须非 const。 + +### P27 平行数组错位隐患 —— ✅ 用 `AppendRow()` 消除(提交 `85aaed5`) + +审计发现 `EdgePanelLayout` 每行对应 **18 个平行 vector**,而**四个**布局分支各自手写 push: +* 边沿计数表格:13 次 push; +* 列表式基行:13 次 push; +* 「未勾选/收起」分支:又补 6(+重采样 4)次空 push; +* 「重采样展开」/「普通展开」分支:各自再 push 6(+4)次。 +任何一处漏推 ⇒ 该列之后所有行读到**别人的**矩形。 + +改法: +* 新增 `EdgePanelLayout::AppendRow(ref, header, nameRect, triangle, check)` —— **唯一入口**, + 18 个数组一次补齐(其余列为空矩形)。 +* 三个 push 块 → 各 **1 次 `AppendRow`**;参数行/重采样行改为 `rowXxx.back() = …` **赋值**; + 「未勾选/收起」分支的 6+4 次补空 push **整段删除**(AppendRow 已补过)。 +* ⇒ "新增一列"只需改 `AppendRow` 一处;"新增一行"必须走它(结构上不会漏列)。 + +⚠️ **没做完整结构合并**(`std::vector` + 列视图),理由: +列视图需要 `EdgePanelLayout` 自定义拷贝构造/赋值来重绑视图,而该类型是 +`EdgePanelLayoutFor()` 的返回值、又被存进 `std::vector` —— 拷贝是热路径, +拷贝语义写错会产生**比原缺陷更隐蔽的悬垂视图**(原缺陷至少会被长度不变式抓到)。 +先消除真实隐患,结构合并留待"逐行几何基线"检查就绪。 + +### P28 收紧不变式 + +`panels` / `panelscroll` 里的长度检查从"13 个必须等长 + 4 个可空"改为 +**18 个全部必须等于行数** —— `AppendRow()` 成为唯一入口后,"某列短了" +就是"有人绕过它手写 push"的直接证据。 + +### 本轮验证 + +| 项 | 结果 | +| --- | --- | +| `make release`(全量) | 零 error / 零 warning;产物 786,432 → **785,408 B** | +| `make -C Tools` | 零 error / 零 warning | +| `UnitTests` | `checks=260 failed=0 cases=19` | +| `OffscreenChecks -all` | 6/6 PASS,`checks=101 failed=0`(断言合并后略减) | +| `panels -mode compare` | 7 个面板像素哈希 + 几何**逐项不变**(两次重构全程守住) | +| `panelscroll`(MF4,566 行) | 18 个行数组长度全部 = 566,行为不变 | diff --git a/Documents/Code_Review_Report.md b/Documents/Code_Review_Report.md index dbd90c8..007982d 100644 --- a/Documents/Code_Review_Report.md +++ b/Documents/Code_Review_Report.md @@ -480,3 +480,78 @@ Tools/README.md 目录结构、作用、测试项、验收标准、A/B 机械提取为 3 个成员函数,判据 = `panels -mode compare` 全绿。 3. **`EdgeScanFor` 状态查找**:维持线性扫描(第 34 轮实测 198 状态 0.097 µs/次, 哈希化需 +5,632 B,不划算)。 + +--- + +## 13. 第 36 轮(2026-11-04):§12.7 两项建议的落地结果 + +> 计划 `Code_Review_Plan.md` §八,逐节点 `Code_Review_Progress.md` 第 36 轮。 + +### 13.1 一句话结论 + +§12.7 的两项都做完了,并且**都用像素基线证明是纯结构改动**: +`PaintEdgeCursor` 从约 1900 行降到约 900 行(内容绘制拆到 `PlotViewPanelPaint.cpp` 1016 行), +平行数组的"漏推一列"隐患用 `AppendRow()` 唯一入口消除。 +产物 786,432 → **785,408 B**(−1,024),7 个面板的像素哈希与几何**逐项不变**。 + +| §12.7 建议 | 结果 | +| --- | --- | +| 1. `EdgePanelLayout` 平行数组隐患 | ✅ 用 `AppendRow()` 消除真实隐患;**完整结构合并**按其风险(悬垂视图)暂不做,理由见 §13.3 | +| 2. 拆分 `PaintEdgeCursor` | ✅ 完成,新建 `PlotViewPanelPaint.cpp` | + +### 13.2 拆分后的文件职责 + +| 文件 | 行数 | 职责 | +| --- | --- | --- | +| `PlotViewPaint.cpp` | 5575 | 面板**骨架**(边框 / 标题栏 / 折起三角 / GX 徽标 / ✕)+ 底部按钮 + 其余全部绘制 | +| `PlotViewPanelPaint.cpp` | 1016 | 面板**内容**:`PaintPanelCountTableRows` / `PaintPanelListRows` / `PaintPanelMarkerGroups` | + +⚠️ 骨架与内容的划分依据:**对所有面板都是同一份**的留在骨架; +**按排版类别不同**的进内容函数。新增一种面板 = `PanelStyleOf` 加一支 + +在对应内容函数里加一支(不会碰到骨架)。 + +### 13.3 为什么没做"完整合并成 `std::vector`" + +技术上可行:`PanelColumn` 持有 `std::vector*` + 成员指针, +读点只需 `layout.rowXxx` → `layout.rowXxx()`(纯正则,314 处可信)。 + +**但**:`EdgePanelLayout` 必须自定义拷贝构造 / 拷贝赋值来重绑视图, +而它是 `EdgePanelLayoutFor()` 的返回值、又被存进 `std::vector` —— +拷贝在热路径上。视图一旦在拷贝后忘记重绑,行为是**悬垂指针**: +可能读出垃圾矩形、也可能"看起来正常"直到某次重排才崩。 + +对比两个方案的**失败可见性**: +* 现在的平行数组 + `AppendRow()`:漏一列 ⇒ **长度不变式当场报错**; +* 列视图:忘了重绑 ⇒ **没有检查能抓到**。 + +因此本轮只做"消除真实隐患"(`AppendRow`),完整合并推迟到 +"逐行几何基线"(把每行 18 个矩形都写进基线文件、逐项比对)就绪之后。 + +### 13.4 本轮"机械搬移"踩到的三个真问题(值得记住) + +1. **局部 lambda 无法跨函数复用**:`paintCounts` 只服务列表段 ⇒ 下沉为该函数的局部 lambda; + `paintPanelScrollBar` 服务三段 ⇒ 提升为 `pvdetail::PaintPanelScrollBar` 内联函数 + (参数显式化,不再隐式捕获 `m_theme`)。 +2. **提取块依赖外层局部变量**:「标记透明度」滑块用 `panel` / `pad` ⇒ 改为从 `layout.panel` 派生。 + 这类隐式依赖正是"整个函数都看得见"的害处,提取时必须逐个查。 +3. **const 正确性**:`tabLanes` 在 marker 段里是**被写**的(回写各列实测宽度)⇒ + 参数不能是 `const std::vector&`。提取时"看起来很只读"的参数要核对写入点。 + +### 13.5 验证与现状 + +| 项 | 结果 | +| --- | --- | +| `make release`(全量) | 零 error / 零 warning;**785,408 B**(距 1 MB 预警线 ≈260 KB) | +| `UnitTests` | `checks=260 failed=0 cases=19` | +| `OffscreenChecks -all` | 6/6 PASS,`checks=101 failed=0` | +| `panels -mode compare` | 7 个面板像素哈希 + 几何逐项不变 | +| `panelscroll`(566 行) | 18 个行数组全部等于 566 | + +### 13.6 后续建议(更新) + +1. **逐行几何基线**:把每行 18 个矩形写进 `Temp/tools/rows-baseline.txt`,逐项比对。 + 这是"完整结构合并"与"重写布局算式"的前置护栏,成本低(导出 + 字符串比对)。 +2. **`rowPoints` / `rowIterations` 已无绘制方使用**(旧式输入框排版已删除): + 可在 `AppendRow` 里保留但标注"仅为兼容保留",或在下一轮与文档一起清掉。 +3. **`PaintPanelListRows` 仍约 470 行**:可再按"条目栏 / 参数行 / 重采样行"拆三段, + 但当前已不在"改一次就出错"的路径上,优先级低于第 1 项。 diff --git a/Documents/agent_mem.md b/Documents/agent_mem.md index d43deba..91122c6 100644 --- a/Documents/agent_mem.md +++ b/Documents/agent_mem.md @@ -10499,3 +10499,68 @@ release/debug **零警告**;Release `TargetCheck.exe` **890,880 B**(< 1 MB 是"改布局就出错"的最后一块机制隐患;已先用长度检查兜住。 * `PaintEdgeCursor`(约 1600 行)把三段内容绘制机械提取为 3 个成员函数, 判据 = `panels -mode compare` 全绿。 + +--- + +## 2026-11-04 · 第 36 轮(`Code_Review` 分支):§12.7 两项建议落地 + +> 提交:`38fbd4b`(拆分 PaintEdgeCursor)、`85aaed5`(AppendRow 唯一入口)、本提交(不变式收紧+文档)。 +> 规则原文:`Documents/agent_mem.md` 第 36 轮、`Code_Review_Report.md` §13。 + +### 1. ✅ `PaintEdgeCursor` 已拆分(1900 → 约 900 行) + +| 文件 | 行数 | 职责 | +| --- | --- | --- | +| `PlotViewPaint.cpp` | 5575 | 面板**骨架**(边框/标题栏/折起三角/GX 徽标/✕)+ 底部按钮 + 其余绘制 | +| `PlotViewPanelPaint.cpp` | 1016 | 面板**内容**:`PaintPanelCountTableRows` / `PaintPanelListRows` / `PaintPanelMarkerGroups` | + +划分依据:**对所有面板都是同一份**的留骨架;**按排版类别不同**的进内容函数。 +新增一种面板 = `PanelStyleOf` 加一支 + 对应内容函数加一支(不碰骨架)。 +滚动条外观统一为 `pvdetail::PaintPanelScrollBar(g, layout, theme)`。 + +### 2. ✅ 平行数组错位隐患用 `AppendRow()` 消除 + +**新硬规则**:新增面板行**只能**走 `EdgePanelLayout::AppendRow(ref, header, nameRect, triangle, check)` +(18 个平行数组一次补齐);参数行 / 重采样行一律 `rowXxx.back() = …` **赋值**。 +**禁止**在任何布局分支里手写 `rowXxx.push_back(...)`。 +⇒ "新增一列"只需改 `AppendRow` 一处;"新增一行"必须走它(结构上不会漏列)。 + +改前有 **4 个**地方各自手写 push(表格 13 次 / 列表基行 13 次 / 未勾选分支 6+4 次空 push / +展开分支 6+4 次),任何一处漏推都会让该列之后的所有行读到别人的矩形。 + +### 3. ⚠️ 为什么**不**把平行数组合并成 `std::vector` + 列视图 + +技术上可行(读点只需正则加括号),但 `EdgePanelLayout` 必须自定义拷贝构造/赋值来重绑视图, +而它是 `EdgePanelLayoutFor()` 的返回值、又被存进 `std::vector` —— 拷贝是热路径。 +**失败可见性对比**: +* 现在(平行数组 + AppendRow):漏一列 ⇒ **长度不变式当场报错**; +* 列视图:忘了重绑 ⇒ **悬垂指针,没有检查能抓到**。 +⇒ 先消除真实隐患,完整合并推迟到"逐行几何基线"就绪后。 + +### 4. ✅ 不变式收紧为"全等" + +`panels` / `panelscroll` 的长度检查从"13 个等长 + 4 个可空"改为 +**18 个全部必须等于行数** —— `AppendRow()` 成为唯一入口后, +"某列短了"就是"有人绕过它"的直接证据。 + +### 5. ⚠️ 机械搬移踩到的三个真问题(提取代码时必查) + +1. **局部 lambda 不能跨函数复用**:只服务一段的下沉为该函数局部;多段共用的提升为 + pvdetail 内联函数(参数显式化)。 +2. **提取块会隐式依赖外层局部变量**(如「标记透明度」滑块的 `panel` / `pad`)⇒ + 改为从 `layout` 派生。这是"整个函数都看得见"的害处。 +3. **const 正确性**:`tabLanes` 在 marker 段里是**被写**的(回写各列实测宽度)⇒ + "看起来只读"的参数要核对写入点。 + +### 6. 本轮数字 + +* Release **785,408 B**(786,432 → −1,024);零 warning。 +* `UnitTests` 260 断言 / 19 用例;`OffscreenChecks -all` 6 检查 / 101 断言;全部 PASS。 +* `panels -mode compare` 7 个面板像素哈希 + 几何**逐项不变**(两次重构全程守住)。 +* `panelscroll`(MF4 566 行)18 个行数组全部 = 566。 + +### 7. 遗留(下一步候选) + +* **逐行几何基线**(导出每行 18 个矩形做逐项比对)—— 完整结构合并与布局算式重写的前置护栏。 +* `rowPoints` / `rowIterations` 已无绘制方使用(旧式输入框排版早已删除),可标注/清理。 +* `PaintPanelListRows` 仍约 470 行,可再拆三段,但已不在"改一次就出错"的路径上。 diff --git a/Tools/OffscreenChecks/Src/Checks.cpp b/Tools/OffscreenChecks/Src/Checks.cpp index 860c684..f7ded39 100644 --- a/Tools/OffscreenChecks/Src/Checks.cpp +++ b/Tools/OffscreenChecks/Src/Checks.cpp @@ -418,22 +418,23 @@ namespace jdi Expect(inside, std::string("panel inside plot area: ") + PanelKindName(kind)); // 不变式:`EdgePanelLayout` 的**平行数组长度必须全部等于行数**。 - // 这 13 个按行平行的 vector 每个分支都要按同一顺序 push 恰好 13 项, - // 漏一项就会让某一列读到别的行的矩形("改布局就出错"的典型机制)。 - // ⚠️ 「重采样」专属的 4 个数组(最大/最小间隔、间隔/频率输入框)在**其它** - // 面板里允许为空(没人读),但只要非空就必须同样与行数对齐。 + // 这 18 个按行平行的 vector 现在由 `AppendRow()` 一次补齐 + //(新增行的唯一入口),因此**不允许**任何一列短于行数 —— + // 短了就说明有分支绕过 `AppendRow` 手写 push("某列读到别人矩形"的根因)。 // 成本极低(O(1) 比长度),但能挡住整类错位缺陷。 const std::size_t rowCount = layout.rowRefs.size(); - const std::size_t coreSizes[] = { - layout.rowRefs.size(), layout.rowHeaders.size(), - layout.rowNameRects.size(), layout.rowTriangles.size(), - layout.rowChecks.size(), layout.rowPoints.size(), - layout.rowIterations.size(), layout.rowStats.size(), - layout.rowLevelTracks.size(), layout.rowLevelValues.size(), - layout.rowLevelResets.size(), layout.rowIterTracks.size(), - layout.rowIterValues.size(), layout.rowIterResets.size()}; + const std::size_t parallelSizes[] = { + layout.rowRefs.size(), layout.rowHeaders.size(), + layout.rowNameRects.size(), layout.rowTriangles.size(), + layout.rowChecks.size(), layout.rowPoints.size(), + layout.rowIterations.size(), layout.rowStats.size(), + layout.rowLevelTracks.size(), layout.rowLevelValues.size(), + layout.rowLevelResets.size(), layout.rowIterTracks.size(), + layout.rowIterValues.size(), layout.rowIterResets.size(), + layout.rowInfoMaxInterval.size(), layout.rowInfoMinInterval.size(), + layout.rowIntervalField.size(), layout.rowFrequencyField.size()}; bool parallel = true; - for (const std::size_t size : coreSizes) + for (const std::size_t size : parallelSizes) { if (size != rowCount) { @@ -441,19 +442,7 @@ namespace jdi break; } } - const std::size_t resampleSizes[] = { - layout.rowInfoMaxInterval.size(), layout.rowInfoMinInterval.size(), - layout.rowIntervalField.size(), layout.rowFrequencyField.size()}; - bool resampleParallel = true; - for (const std::size_t size : resampleSizes) - { - if (size != 0 && size != rowCount) - { - resampleParallel = false; - break; - } - } - if (!parallel || !resampleParallel) + if (!parallel) { EmitLine("[panels] %s row arrays: refs=%d hdr=%d name=%d tri=%d chk=%d pts=%d it=%d " "stat=%d lvl=%d/%d/%d iter=%d/%d/%d info=%d/%d fld=%d/%d\n", @@ -476,9 +465,7 @@ namespace jdi static_cast(layout.rowIntervalField.size()), static_cast(layout.rowFrequencyField.size())); } - Expect(parallel, std::string("row arrays have identical lengths: ") + PanelKindName(kind)); - Expect(resampleParallel, - std::string("resample row arrays empty-or-aligned: ") + PanelKindName(kind)); + Expect(parallel, std::string("all 18 row arrays match row count: ") + PanelKindName(kind)); char buffer[160] = {}; std::snprintf(buffer, sizeof(buffer), "%s=%016llX,%d,%d,%d,%d\n", PanelKindName(kind), @@ -605,7 +592,8 @@ namespace jdi initial.scrollTrack.Width > 0.0f ? 1 : 0, static_cast(initial.rowRefs.size())); - // 多信号场景下额外守住"平行数组长度一致"(本轮新增,成本 O(1))。 + // 多信号场景下额外守住"18 个平行数组长度全部等于行数" + //(`AppendRow()` 是新增行的唯一入口 ⇒ 任何一列短了就是绕过它的证据)。 { const std::size_t rows = initial.rowRefs.size(); const std::size_t sizes[] = {initial.rowHeaders.size(), initial.rowNameRects.size(), @@ -621,14 +609,13 @@ namespace jdi bool aligned = true; for (const std::size_t size : sizes) { - // 「重采样」专属数组在其它面板允许为空(见 `panels` 检查的说明)。 - if (size != rows && size != 0) + if (size != rows) { aligned = false; break; } } - Expect(aligned, std::string("row arrays aligned with rows: ") + name); + Expect(aligned, std::string("all 18 row arrays match row count: ") + name); } if (maxScroll <= 1.0f) diff --git a/Tools/README.md b/Tools/README.md index 27ca1ac..1f0ca9e 100644 --- a/Tools/README.md +++ b/Tools/README.md @@ -171,8 +171,8 @@ Tools\OffscreenChecks\Build\OffscreenChecks.exe -list | --- | --- | --- | | `smoke` | 建隐藏窗口 → 加载 → 显示 3 条信号 → 渲染 → 抓帧 → 落盘 `smoke.png` | 客户区/绘图区尺寸合理、绘图区墨迹 > 1000 像素、**同一状态连续两帧逐像素相同**(渲染确定性) | | `frames` | 沿信号列表逐行移动"鼠标",每帧抓图 + 区域哈希 + 耗时 | 平均帧 < 500ms、帧序列**至少出现一次可见变化**;输出 `frame-XXX.png` 供事后分析 | -| `panels` | 打开全部 7 个实验性面板(含双光标下的「选区截取」) | 每个面板:功能已启用、有尺寸、**必须被绘图区夹住**、**平行数组长度与行数一致**;`-mode record` 写 `panels-baseline.txt`,`-mode compare` 逐面板比对**像素哈希 + 几何** | -| `panelscroll` | 全部信号合并到同一网格后,逐一滚动 7 种面板的内容区 | 可滚动时:滚到底/滚回顶、`scrollOffset` 夹到 `maxScroll`、滑块到达/返回轨道端点、内容区几何不动;不可滚动时不得出现滚动条。另守住平行数组一致性 | +| `panels` | 打开全部 7 个实验性面板(含双光标下的「选区截取」) | 每个面板:功能已启用、有尺寸、**必须被绘图区夹住**、**18 个行数组长度全部等于行数**;`-mode record` 写 `panels-baseline.txt`,`-mode compare` 逐面板比对**像素哈希 + 几何** | +| `panelscroll` | 全部信号合并到同一网格后,逐一滚动 7 种面板的内容区 | 可滚动时:滚到底/滚回顶、`scrollOffset` 夹到 `maxScroll`、滑块到达/返回轨道端点、内容区几何不动;不可滚动时不得出现滚动条。另守住 18 个行数组与行数完全一致 | | `edgestats` | 3 个阈值下的边沿计数(普通计数)+ 中心轴计数 | 与 `Checks.cpp` 内的**独立参考实现**(朴素逐点)**逐位相等**;同参数重复查询结果稳定 | | `perf` | 整帧中位数、`EdgePanelLayoutFor` 单次耗时、198 个统计状态的整轮查询耗时 | 整帧 < 500ms、面板几何 < 200µs/次、统计查询 < 20µs/次;数字用于前后对比 | -- 2.54.0 From 353c553721258749e62cb0935b141f445e7912e4 Mon Sep 17 00:00:00 2001 From: "DeepSeek V4.1 Flash" <> Date: Wed, 4 Nov 2026 15:49:25 +0800 Subject: [PATCH 18/22] =?UTF-8?q?test(tools):=20rowgeom=20=E9=80=90?= =?UTF-8?q?=E8=A1=8C=E5=87=A0=E4=BD=95=E5=9F=BA=E7=BA=BF=20+=20=E9=95=BF?= =?UTF-8?q?=E5=BA=A6=E4=B8=8D=E5=8F=98=E5=BC=8F=E6=8C=89=E7=B1=BB=E5=88=AB?= =?UTF-8?q?=E5=88=A4=E5=AE=9A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 `OffscreenChecks -run rowgeom`(§13.6 第 1 项),把 7 个面板的每个矩形的 每个字段导出为文本基线并逐项比对,作为"完整结构合并 / 重写布局算式"的前置护栏。 两层覆盖 - 细节层:6 信号 1 网格、7 面板全开、参数行展开、3 个真实固定点、折起+展开各一份, 983 条键(-data MF4-Record.MF4 时 1304 条),失败时直接给出字段名+行号+新旧值; - 规模层:全部信号合并成 1 个网格(MF4 = 566 行),每面板一个 FNV-1a 摘要 + 行数, 任何一行变动都会改哈希,同时不产生几十万行基线。 负向自检(证明护栏不是空转) - 篡改基线副本 `mark.markerRowCount=3 → 2` ⇒ `CHANGED … mismatches=1` + FAIL。 顺带修正第 36 轮定得过严的不变式 - `rowRefs` 是重载字段:MARK 面板用它表示"信号分组",真正的条目行在 markerRows, 且只填 rowNameRects / rowNameCloses,其余 14 列必须为空; ⇒ `panels` / `panelscroll` 的"18 列全等"改为按 `PanelStyle::markerGroups` 分流判定, 失败时打印 15 个计数字段便于定位。 - 导出器/不变式一律**逐列按自身长度**遍历(按 rowRefs.size() 索引其它列会越界, 这是本轮第一次 0xC0000005 的根因)。 测试用例里的两处修正 - `MERGE_LANES` 之后补 `SynchronizeView(true)`:`SetMode` 只重建 TabConfig 的网格配置, `PlotView::m_lanes` 要等下一次布局才刷新; - 场景 B 重新播固定点:`ShowSignals()` 先 `SetAllVisible(false)`,而"全部不可见 ⇒ 丢弃网格" 是 RebuildLanes 的既有设计,网格级状态随之清空(不是缺陷),因此不能跨场景依赖。 验证 - make release:零 warning,785,408 B 不变(本轮只动工具/检查与文档) - make -C Tools clean && make -C Tools:零 warning - UnitTests:checks=260 failed=0 cases=19 - OffscreenChecks -all:7 检查 / 127 断言全 PASS(原 6 / 101) - rowgeom -mode compare:默认数据 983+7 键、MF4 1304+7 键,0 差异 - panels -mode compare:7 个面板像素哈希 + 几何逐项不变 文档:Code_Review_Plan §九、Progress 第 37 轮、Report §14、agent_mem 第 37 轮、Tools/README §4.1/4.3/4.4 --- Documents/Code_Review_Plan.md | 36 ++ Documents/Code_Review_Progress.md | 81 ++++ Documents/Code_Review_Report.md | 55 +++ Documents/agent_mem.md | 74 ++++ Tools/OffscreenChecks/Src/Checks.cpp | 584 +++++++++++++++++++++++++-- Tools/README.md | 39 +- 6 files changed, 821 insertions(+), 48 deletions(-) diff --git a/Documents/Code_Review_Plan.md b/Documents/Code_Review_Plan.md index 88e4902..82222bf 100644 --- a/Documents/Code_Review_Plan.md +++ b/Documents/Code_Review_Plan.md @@ -271,3 +271,39 @@ 本轮先用 `AppendRow()` 把**真实隐患**("某分支漏推一列")消除, 完整结构合并留待"逐行几何基线"检查就绪后再做。 + +--- + +## 九、第 37 轮追加(2026-11-04):逐行几何基线(§13.6 第 1 项) + +> 触发:用户要求「按照你的建议继续推进」。§13.6 第 1 项 = 逐行几何基线, +> 它是 §13.3「完整结构合并」与「重写布局算式」的前置护栏。 + +| 节点 | 内容 | 验收标准 | +| --- | --- | --- | +| **P29** | 新增 `OffscreenChecks -run rowgeom`:把 7 个面板**每个矩形的每个字段**导出为文本基线,逐项比对 | `-mode record`/`-mode compare` 无差异;**篡改基线必须报错**(负向自检) | +| **P30** | 修正长度不变式的判据:从"18 列全等"改为**按排版类别**判定 | `panels` / `panelscroll` 仍全绿,且对 `MARK` 面板不再误判 | + +### 9.1 基线的两层覆盖 + +| 场景 | 数据 | 记录内容 | 用途 | +| --- | --- | --- | --- | +| **A:细节** | 前 6 条信号合并成 1 个网格,7 个面板全开、参数行展开、3 个固定点、折起/展开各一份 | 每行每列的精确矩形(`Temp/tools/rows-baseline/rowgeom-baseline.txt`,983 条键) | 定位级:一眼看出"哪一行哪一列偏了几个像素" | +| **B:规模** | **全部**信号合并成 1 个网格(MF4 = 566 行) | 每个面板一个 FNV-1a 摘要哈希 + 行数/固定点行数 | 覆盖级:任何一行变动都会改哈希(不产生几十万行基线) | + +### 9.2 本轮新增硬约束 + +1. **改面板几何(宽高算式 / 行距 / 内边距 / 折起规则)前后必须跑** + `OffscreenChecks -run rowgeom -mode record|compare`。 + 基线是**检测器**不是黄金图:变化是**预期**时要重新 record,并在提交信息里说明原因。 +2. **`rowRefs` 是重载的**:「列表式面板」用 `rowRefs` 表示"每个信号一行", + 而「自由标记面板」用它表示"每个信号一个分组",只填 `rowNameRects` / `rowNameCloses`, + 其余 14 列**必须为空**(真正的条目行在 `markerRows`)。因此任何"平行数组长度"不变式 + **必须按 `PanelStyle` 分类判定**,不能写"全部 18 列全等"。 +3. **`ExecuteToolbarAction(MERGE_LANES / SPLIT_LANES)` 之后、读 `LaneCount()` 之前, + 必须先 `SynchronizeView(true)`**:`SetMode()` 只重建 `TabConfig` 的网格配置, + `PlotView::m_lanes` 要等下一次布局/绘制才刷新。 +4. 检查里**不要依赖"上一个场景的网格级状态还在"**:`Session::ShowSignals()` 先 + `SetAllVisible(false)`,而"全部不可见 ⇒ 丢弃网格"是既有设计 + (`RebuildLanes` 的 MERGED 分支),固定点/面板开关会随之清空 —— 该场景要**重新播**。 + diff --git a/Documents/Code_Review_Progress.md b/Documents/Code_Review_Progress.md index 2d1432e..9c8ca8b 100644 --- a/Documents/Code_Review_Progress.md +++ b/Documents/Code_Review_Progress.md @@ -410,3 +410,84 @@ Makefile 按目录通配收集源文件,**无需登记新文件**。 | `OffscreenChecks -all` | 6/6 PASS,`checks=101 failed=0`(断言合并后略减) | | `panels -mode compare` | 7 个面板像素哈希 + 几何**逐项不变**(两次重构全程守住) | | `panelscroll`(MF4,566 行) | 18 个行数组长度全部 = 566,行为不变 | + +--- + +## 第 37 轮(2026-11-04):逐行几何基线(§13.6 第 1 项) + +### P29 新增 `rowgeom` 检查 —— ✅ 完成 + +`Tools/OffscreenChecks/Src/Checks.cpp` 新增注册键 `rowgeom`,做法: + +1. **场景 A(细节基线)**:切双光标 → 前 6 条信号 → `MERGE_LANES` → 7 个面板全开、 + 第 2 条信号勾选(同一面板里同时有"展开的参数行"与"未勾选的行")→ 加 3 个真实固定点 + → 导出 7 个面板 **展开态**全字段 → 全部折起再导一份 → 复原。 +2. **场景 B(规模基线)**:全部信号合并成 1 个网格(默认数据 3 行;`-data MF4-Record.MF4` = 566 行) + → 每面板一个 FNV-1a 摘要 + `rows` / `markerRows` 计数。 +3. **导出格式**(纯文本、逐行 `键=值`,便于 `git diff` 与人工定位): + + ```text + mean.rowRefs.count=3 + mean.rowNameRects[001]=1155.75,221.25,512.50,25.00 + mark.markerGroupCount=2 + mark.markerGroup[000].header=1155.75,171.25,512.50,27.50 + mark.markerRow[000].item=0/1 + mark.markerRowCount=3 + ``` + + ⚠️ 因为 `rowRefs` 是**重载**的(见下),导出器按**每一列自己的长度**遍历, + 不能"取 `rowRefs.size()` 当行数统一遍历全部 18 列"。 +4. **`-mode record` / `-mode compare`**:比对双向(缺键 = MISSING,多键 = ADDED,值变 = CHANGED), + 最多打印 20 条差异;差异数为 0 才算 PASS。 + +### 过程中两次崩溃(都已定位,结论写进 `agent_mem.md`) + +| # | 现象 | 根因 | 处置 | +| --- | --- | --- | --- | +| 1 | `LaneCount()` 断言失败 | `SetMode(MERGED)` 只重建 `TabConfig` 的网格配置,`PlotView::m_lanes` 要等下一次布局/绘制才刷新 | `MERGE_LANES` 之后补 `SynchronizeView(true)` + `RenderFrame()` 再断言 | +| 2 | 0xC0000005(导出时越界) | 假设"18 个行数组等长",但 **`MARK` 面板复用 `rowRefs` 表示信号分组**,只填 `rowNameRects` / `rowNameCloses`,其余 14 列为空 ⇒ 按 `rowRefs.size()` 索引其它列直接越界 | 导出器改为**逐列按自身长度**遍历 | + +### P30 长度不变式的判据修正 —— ✅ 完成 + +`panels` / `panelscroll` 里第 36 轮定的"18 列必须全部等于行数"对 `MARK` 面板**是错的** +(它对新结构是误判,只是既有场景恰好没触发)。改为按 `PanelStyleOf(kind).markerGroups` 分流: + +* `markerGroups == true`:`rowNameRects` / `rowNameCloses` 等于分组数, + 且 `rowHeaders` / `rowChecks` / `rowLevelTracks` / `rowIterTracks` **必须为空**; +* 其余面板:17 列(`rowRefs` + `rowHeaders` … `rowFrequencyField`)全部等于行数。 + +失败时打印 15 个计数字段,便于一眼看出是哪一列错了。 + +### 基线是否真的能抓回归(负向自检) + +把基线副本里 `mark.markerRowCount=3` 改成 `2` 再跑 `-mode compare`: + +```text +[rowgeom detail] CHANGED mark.markerRowCount: baseline=2 current=3 + [FAIL] rowgeom detail geometry is unchanged +SUMMARY checks=32 failed=1 groups=1 result=FAIL +``` + +⇒ 检测器**不是空转**的。(正式基线所在目录始终是 `record` 生成的,未被改动。) + +### 一个"顺手查清"的行为(不是缺陷) + +`rowgeom` 第一次跑出 `mark.hash … rows=0`:场景 B 里固定点没了。追查结论: +`Session::ShowSignals()` 先 `SetAllVisible(false)`,而"全部不可见 ⇒ 丢弃网格" +是 `RebuildLanes` 的**既有设计**(MERGED 分支的 `lanes.clear()`), +网格级状态(固定点 / 面板开关 / 滚动位置)随之清空 —— 与 `CLEAR_ALL` 行为一致,**不是新缺陷**。 +因此场景 B 改为**重新播**固定点(`addFreeMarkers(2)` lambda), +场景 B 的 MARK 面板现在是 `rows=2 markerRows=3`,规模覆盖也补上了。 + +### 本轮验证 + +| 项 | 结果 | +| --- | --- | +| `make release`(全量) | 零 error / 零 warning;产物 **785,408 B 不变**(本轮只动工具与检查) | +| `make -C Tools` | 零 error / 零 warning | +| `UnitTests` | `checks=260 failed=0 cases=19` | +| `OffscreenChecks -all` | **7/7 PASS**,`checks=127 failed=0`(第 36 轮为 6/6、101) | +| `rowgeom -mode compare`(默认数据) | `detail 983 键 + scale 7 键`,0 差异 | +| `rowgeom -mode compare -data MF4-Record.MF4` | `detail 1304 键 + scale 7 键`(566 行),0 差异 | +| `panels -mode compare` | 7 个面板像素哈希 + 几何逐项不变 | + diff --git a/Documents/Code_Review_Report.md b/Documents/Code_Review_Report.md index 007982d..c90b78b 100644 --- a/Documents/Code_Review_Report.md +++ b/Documents/Code_Review_Report.md @@ -555,3 +555,58 @@ Tools/README.md 目录结构、作用、测试项、验收标准、A/B 可在 `AppendRow` 里保留但标注"仅为兼容保留",或在下一轮与文档一起清掉。 3. **`PaintPanelListRows` 仍约 470 行**:可再按"条目栏 / 参数行 / 重采样行"拆三段, 但当前已不在"改一次就出错"的路径上,优先级低于第 1 项。 + +--- + +## 十四、第 37 轮:逐行几何基线(§13.6 第 1 项) + +### 14.1 结论 + +| §13.6 建议 | 结果 | +| --- | --- | +| 1. 逐行几何基线 | ✅ 新增 `OffscreenChecks -run rowgeom`(`record` / `compare`),并做了**负向自检**证明它能抓回归 | +| 2. `rowPoints` / `rowIterations` 清理 | ⏸ 未做(本轮与该建议无耦合,留给下一轮) | +| 3. `PaintPanelListRows` 再拆 | ⏸ 未做(纯可读性,优先级低) | + +`-all` 从 6 个检查 / 101 条断言 → **7 个检查 / 127 条断言**,全绿。 + +### 14.2 基线的两层设计(为什么不是"一行一个文件") + +| 层 | 场景 | 记录 | 键数 | 用途 | +| --- | --- | --- | --- | --- | +| 细节 | 6 信号 1 网格、7 面板全开、参数行展开、3 个固定点、折起/展开各一份 | 每个矩形的每个字段 | 983(MF4 上 1304) | **定位**:直接看出哪一行哪一列偏了几像素 | +| 规模 | 全部信号 1 网格(MF4 = 566 行) | 每面板一个 FNV-1a 摘要 + 行数 | 7 | **覆盖**:任何一行变动都改哈希,且不产生几十万行基线 | + +两层互补:哈希层保证"改动一定被看见",明细层保证"看见了能定位"。 + +### 14.3 这次护栏暴露的两个结构事实 + +1. **`rowRefs` 是重载的字段**。 + 「列表式」面板里 `rowRefs[i]` = 第 i 行的信号; + 「自由标记」面板里 `rowRefs[i]` = 第 i 个**信号分组**,而它真正的条目行在 `markerRows`, + 且只填 `rowNameRects` / `rowNameCloses`,另外 14 列**必须为空**。 + ⇒ 第 36 轮定的"18 列全等"不变式对 `MARK` 是**错的**,本轮改为按 `PanelStyle` 分流判定。 + 同一个"字段重载"也让导出器必须**逐列按自身长度**遍历(否则越界崩溃 —— 这正是本轮第一次 + `0xC0000005` 的来源)。 +2. **`SetMode()` 不同步 `m_lanes`**。`ExecuteToolbarAction(MERGE_LANES/SPLIT_LANES)` 之后 + `TabConfig` 的网格配置已是新的,但 `PlotView::m_lanes` 要等下一次布局/绘制才刷新 —— + 任何"合并后立刻读 `LaneCount()`"的代码都必须先 `SynchronizeView(true)`。 + +### 14.4 一个被误判成缺陷的既有行为 + +场景 B 一开始 `mark` 面板是空行:`Session::ShowSignals()` **先** `SetAllVisible(false)`, +而"全部不可见 ⇒ 丢弃网格"是 `RebuildLanes` 的既有设计(与工具栏「清空」一致), +网格级状态(固定点 / 面板开关 / 滚动位置)随之清空。⇒ 不是新缺陷; +检查改为在该场景**重新播**固定点,顺带把规模覆盖补全(`rows=2 markerRows=3`)。 + +### 14.5 下一步(按价值排序) + +1. **`rowPoints` / `rowIterations` 清理**(确认已无绘制方使用,仅 `BuildBoxes` / 命中键残留): + 这是当前唯一"写了没人读"的列,删掉能减少每次 `AppendRow` 的 2 次 push 与 2 个 vector。 + 判据:`rowgeom -mode compare`(细节基线会逐项证明几何没变)+ `panels -mode compare`。 +2. **完整结构合并(`std::vector` + 列视图)**:前置护栏(本轮)已就绪, + 但 §13.3 的**失败可见性**问题仍未解决 —— 列视图拷贝后忘记重绑是静默悬垂。 + 若要做,必须同时给 `EdgePanelLayout` 加"视图自检"(例如保存 `rows` 容量并与视图长度比对), + 否则不建议动。 +3. `PaintPanelListRows` 再拆三段(纯可读性)。 + diff --git a/Documents/agent_mem.md b/Documents/agent_mem.md index 91122c6..5023c2b 100644 --- a/Documents/agent_mem.md +++ b/Documents/agent_mem.md @@ -10564,3 +10564,77 @@ release/debug **零警告**;Release `TargetCheck.exe` **890,880 B**(< 1 MB * **逐行几何基线**(导出每行 18 个矩形做逐项比对)—— 完整结构合并与布局算式重写的前置护栏。 * `rowPoints` / `rowIterations` 已无绘制方使用(旧式输入框排版早已删除),可标注/清理。 * `PaintPanelListRows` 仍约 470 行,可再拆三段,但已不在"改一次就出错"的路径上。 + +--- + +## 2026-11-04 · 第 37 轮(`Code_Review` 分支):逐行几何基线(§13.6 第 1 项) + +> 提交:见本轮提交信息;规则原文 `Documents/Code_Review_Report.md` §14、`Code_Review_Plan.md` §九、 +> `Code_Review_Progress.md` 第 37 轮、`Tools/README.md` §4.1/§4.3/§4.4。 + +### 1. ✅ 新增 `OffscreenChecks -run rowgeom`(`record` / `compare`) + +两层覆盖: +* **细节层**(6 信号 1 网格、7 面板全开、参数行展开、3 个固定点、折起+展开各一份): + 每个矩形的每个字段一条键,`Temp/tools/rows-baseline/rowgeom-baseline.txt` 共 **983** 条 + (`-data MF4-Record.MF4` 时 1304 条)。 +* **规模层**(全部信号 1 网格,MF4 = 566 行):每面板一个 FNV-1a 摘要 + 行数,7 条键。 + +键格式:`.` / `.<列名>[NNN]=x,y,w,h` / `mark.markerGroup[NNN].header=…`。 +比对双向(MISSING / ADDED / CHANGED),差异 0 才 PASS。 + +### 2. 🔴 新硬规则一:`rowRefs` 是**重载字段**,长度不变式必须按类别判定 + +| 面板类别 | `rowRefs` 含义 | 真正的行在 | +| --- | --- | --- | +| 列表式(mean/center/peak/resample/clip) | 每个信号一行 | `rowRefs` 自身 + 其余 15 列 | +| 边沿计数(count) | 每个信号一行(表格行) | 同上 | +| 「自由标记」(mark) | 每个**信号分组**一项 | `markerRows` / `markerGroups` | + +MARK 面板**只填** `rowNameRects` / `rowNameCloses`,其余 14 列**必须为空**。 +⇒ 第 36 轮定的"18 列全部等于行数"对 MARK 是**错的**,本轮已改为: + +* `PanelStyle::markerGroups == true` ⇒ `rowNameRects`/`rowNameCloses` 等于分组数, + 且 `rowHeaders`/`rowChecks`/`rowLevelTracks`/`rowIterTracks` 必须为空; +* 其余面板 ⇒ `rowRefs` + 16 列全部等于行数。 + +任何"遍历 18 列"的代码(导出器、不变式、批量处理)**都必须逐列按自身长度**遍历, +不能拿 `rowRefs.size()` 当统一行数(否则 MARK 面板直接越界 —— 本轮第一次 `0xC0000005`)。 + +### 3. 🔴 新硬规则二:`SetMode()` 不同步 `m_lanes` + +`ExecuteToolbarAction(MERGE_LANES / SPLIT_LANES)`(以及 `TabConfig::SetMode`)只重建 +`TabConfig` 的网格配置,`PlotView::m_lanes` 要等下一次布局/绘制才刷新。 +⇒ **合并/拆分后、读 `LaneCount()` 或 `EdgePanelLayoutFor()` 之前,必须先 +`app.SynchronizeView(true)`(必要时再 `RenderFrame()`)**。 + +### 4. ⚠️ 已知行为(不是缺陷):全部不可见 ⇒ 丢弃网格 ⇒ 网格级状态清空 + +`Session::ShowSignals()` 先 `SetAllVisible(false)`;`RebuildLanes` 的 MERGED 分支在 +"无可见信号"时 `lanes.clear()`(与工具栏「清空」一致)。 +⇒ 固定点 / 面板开关 / 面板滚动位置 / 面板显隐 **全部丢失**,不是新 BUG。 +检查里跨场景依赖这些状态时,必须**重新播**(`rowgeom` 用 `addFreeMarkers(wanted)` lambda)。 + +### 5. 基线的定位 + +**检测器,不是黄金图。** 预期内的几何变化要重新 `-mode record`,并在提交信息里写明原因。 +负向自检已做:篡改基线中 `mark.markerRowCount=3 → 2`,`-mode compare` 会 +`CHANGED … mismatches=1` 并 FAIL ⇒ 护栏不是空转。 + +### 6. 本轮数字 + +* Release **785,408 B 不变**(本轮只动工具/检查与文档);零 warning。 +* `UnitTests` 260 断言 / 19 用例;`OffscreenChecks -all` **7 检查 / 127 断言**(原 6/101)。 +* `rowgeom -mode compare`:默认数据 983+7 键、MF4 1304+7 键,0 差异。 +* `panels -mode compare` 7 个面板像素哈希 + 几何逐项不变。 + +### 7. 遗留(下一步候选,按价值) + +1. **`rowPoints` / `rowIterations` 清理**(确认已无绘制方使用,仅 `BuildBoxes` / 命中键残留)—— + 当前唯一"写了没人读"的列,删掉可省每次 `AppendRow` 的 2 次 push。 + 判据:`rowgeom -mode compare` + `panels -mode compare`。 +2. **完整结构合并(`std::vector` + 列视图)**:护栏已就绪,但 §13.3 的 + **失败可见性**问题未解(拷贝后忘重绑 = 静默悬垂)。若要做,必须同时给 + `EdgePanelLayout` 加"视图自检"(比对 `rows` 容量与视图长度)。 +3. `PaintPanelListRows` 再拆三段(纯可读性)。 + diff --git a/Tools/OffscreenChecks/Src/Checks.cpp b/Tools/OffscreenChecks/Src/Checks.cpp index f7ded39..2b2b4ab 100644 --- a/Tools/OffscreenChecks/Src/Checks.cpp +++ b/Tools/OffscreenChecks/Src/Checks.cpp @@ -262,6 +262,254 @@ namespace jdi } } + /** @brief 把矩形格式化成 `l,t,w,h`(2 位小数;坐标是设备像素,2 位足够)。 */ + std::string FormatRect(const gd::RectF& rect) + { + char buffer[128] = {}; + std::snprintf(buffer, sizeof(buffer), "%.2f,%.2f,%.2f,%.2f", rect.X, rect.Y, rect.Width, + rect.Height); + return std::string(buffer); + } + + /** @brief 把一段文本混进 FNV-1a 哈希(用于"多行规模"场景只存一个摘要)。 */ + void MixText(std::uint64_t& hash, const std::string& text) + { + for (const char character : text) + { + hash ^= static_cast(static_cast(character)); + hash *= 1099511628211ull; + } + } + + /** @brief 追加 `key=rect` 一行。 */ + void AppendRectLine(std::string& text, const std::string& key, const gd::RectF& rect) + { + text += key; + text += '='; + text += FormatRect(rect); + text += '\n'; + } + + /** + * @brief 把一个面板布局的**全部几何**追加成文本(逐字段、逐行、逐列)。 + * + * 这是「逐行几何基线」的核心:面板改布局时,像素哈希只能告诉你"有变化", + * 而这份文本能告诉你**哪一个字段、哪一行、哪一列**变了。 + * 有了它,`EdgePanelLayout` 的完整结构合并(平行数组 → `PanelRow` 单数组) + * 与布局算式重写才具备"逐项证明等价"的能力。 + * + * ⚠️ 键名必须稳定:一旦改名,历史基线全部失效(`-mode record` 重新记录即可)。 + * + * @param [IN,OUT] text 目标文本(追加)。 + * @param [IN] prefix 该面板的键前缀(= 面板种类名)。 + * @param [IN] layout 待导出的布局。 + */ + void AppendLayoutDump(std::string& text, const std::string& prefix, + const PlotView::EdgePanelLayout& layout) + { + const auto key = [&prefix](const char* name) { return prefix + "." + name; }; + + // ---- 面板骨架 ---- + AppendRectLine(text, key("panel"), layout.panel); + AppendRectLine(text, key("title"), layout.title); + AppendRectLine(text, key("close"), layout.close); + AppendRectLine(text, key("disclosure"), layout.disclosure); + AppendRectLine(text, key("badge"), layout.badge); + AppendRectLine(text, key("edit"), layout.edit); + AppendRectLine(text, key("button"), layout.button); + AppendRectLine(text, key("content"), layout.content); + AppendRectLine(text, key("tableHeader"), layout.tableHeader); + AppendRectLine(text, key("scrollTrack"), layout.scrollTrack); + AppendRectLine(text, key("scrollThumb"), layout.scrollThumb); + AppendRectLine(text, key("opacityTrack"), layout.opacityTrack); + AppendRectLine(text, key("opacityValue"), layout.opacityValue); + AppendRectLine(text, key("showDelta"), layout.showDelta); + AppendRectLine(text, key("showEnum"), layout.showEnum); + AppendRectLine(text, key("clipBuildButton"), layout.clipBuildButton); + AppendRectLine(text, key("clipDestructiveButton"), layout.clipDestructiveButton); + text += key("collapsed") + "=" + (layout.collapsed ? "1" : "0") + "\n"; + text += key("rows") + "=" + std::to_string(layout.rows) + "\n"; + text += key("markerContentHeight") + "=" + std::to_string(layout.markerContentHeight) + "\n"; + text += key("scrollOffset") + "=" + std::to_string(layout.scrollOffset) + "\n"; + + // ---- 表格三列 ---- + for (int column = 0; column < 3; ++column) + { + const PlotView::EdgePanelLayout::TableColumn& cell = layout.tableColumns[column]; + char name[32] = {}; + std::snprintf(name, sizeof(name), "tableColumn[%d]", column); + text += key(name) + "=" + std::to_string(cell.left) + "/" + + std::to_string(cell.width) + "\n"; + } + + // ---- 每一行 / 每一列 ---- + // ⚠️ **每一列只按自己的长度导出**,不要假设所有列等长: + // 「自由标记」面板复用 `rowRefs` 表示"信号分组",只填 + // `rowNameRects` / `rowNameCloses`,其余 14 列**全为空** + // (这一点正是 `rowgeom` 第一次运行就抓出来的结构事实)。 + // 按 `rowRefs.size()` 统一索引会越界崩溃。 + const auto dumpRects = [&text, &key](const char* name, const std::vector& rects) { + text += key(name) + ".count=" + std::to_string(rects.size()) + "\n"; + for (std::size_t index = 0; index < rects.size(); ++index) + { + char cell[64] = {}; + std::snprintf(cell, sizeof(cell), "[%03d]", static_cast(index)); + AppendRectLine(text, key(name) + cell, rects[index]); + } + }; + text += key("rowRefs") + ".count=" + std::to_string(layout.rowRefs.size()) + "\n"; + for (std::size_t index = 0; index < layout.rowRefs.size(); ++index) + { + char cell[64] = {}; + std::snprintf(cell, sizeof(cell), "[%03d]", static_cast(index)); + text += key("rowRefs") + cell + "=" + + std::to_string(layout.rowRefs[index].groupIndex) + "/" + + std::to_string(layout.rowRefs[index].signalIndex) + "\n"; + } + dumpRects("rowHeaders", layout.rowHeaders); + dumpRects("rowNameRects", layout.rowNameRects); + dumpRects("rowNameCloses", layout.rowNameCloses); + dumpRects("rowTriangles", layout.rowTriangles); + dumpRects("rowChecks", layout.rowChecks); + dumpRects("rowPoints", layout.rowPoints); + dumpRects("rowIterations", layout.rowIterations); + dumpRects("rowStats", layout.rowStats); + dumpRects("rowLevelTracks", layout.rowLevelTracks); + dumpRects("rowLevelValues", layout.rowLevelValues); + dumpRects("rowLevelResets", layout.rowLevelResets); + dumpRects("rowIterTracks", layout.rowIterTracks); + dumpRects("rowIterValues", layout.rowIterValues); + dumpRects("rowIterResets", layout.rowIterResets); + dumpRects("rowInfoMaxInterval", layout.rowInfoMaxInterval); + dumpRects("rowInfoMinInterval", layout.rowInfoMinInterval); + dumpRects("rowIntervalField", layout.rowIntervalField); + dumpRects("rowFrequencyField", layout.rowFrequencyField); + + // ---- 自由标记:分组栏 / 表头 / 条目 ---- + text += key("markerGroupCount") + "=" + std::to_string(layout.markerGroups.size()) + "\n"; + for (std::size_t group = 0; group < layout.markerGroups.size(); ++group) + { + const PlotView::EdgePanelLayout::MarkerGroup& entry = layout.markerGroups[group]; + char name[48] = {}; + std::snprintf(name, sizeof(name), "markerGroup[%03d]", static_cast(group)); + const std::string groupKey = key(name); + AppendRectLine(text, groupKey + ".header", entry.header); + AppendRectLine(text, groupKey + ".tableHeader", entry.tableHeader); + AppendRectLine(text, groupKey + ".triangle", entry.triangle); + AppendRectLine(text, groupKey + ".check", entry.check); + AppendRectLine(text, groupKey + ".close", entry.close); + text += groupKey + ".collapsed=" + (entry.collapsed ? "1" : "0") + "\n"; + text += groupKey + ".pinned=" + (entry.pinned ? "1" : "0") + "\n"; + } + text += key("markerRowCount") + "=" + std::to_string(layout.markerRows.size()) + "\n"; + for (std::size_t row = 0; row < layout.markerRows.size(); ++row) + { + const PlotView::EdgePanelLayout::MarkerRow& entry = layout.markerRows[row]; + char name[48] = {}; + std::snprintf(name, sizeof(name), "markerRow[%03d]", static_cast(row)); + const std::string rowKey = key(name); + text += rowKey + ".item=" + std::to_string(entry.itemIndex) + "/" + + std::to_string(entry.serial) + "\n"; + AppendRectLine(text, rowKey + ".rect", entry.rect); + AppendRectLine(text, rowKey + ".close", entry.close); + } + } + + + /** @brief 统计文本的行数(用于打印导出规模)。 */ + std::size_t CountLines(const std::string& text) + { + std::size_t lines = 0; + for (const char character : text) + { + if (character == '\n') + { + ++lines; + } + } + return lines; + } + + /** + * @brief 逐行比对一份"键值基线"(`key=value`),报告缺失 / 多出 / 值变化。 + * + * 与 `panels` 里的比对相比,这里刻意把**每一处差异都打印出来**(上限 20 条): + * 几何基线的价值就在于"哪一行的哪一列变了",只报一个总数等于没用。 + * + * @param [IN] path 基线文件路径。 + * @param [IN] current 当前导出文本。 + * @param [IN] label 报告用的名字。 + */ + void CompareBaselineText(const std::string& path, const std::string& current, const char* label) + { + const std::string baseline = ReadTextFile(path); + Expect(!baseline.empty(), std::string(label) + " baseline exists (run -mode record first)"); + if (baseline.empty()) + { + return; + } + const std::vector> expectedEntries = ParseBaseline(baseline); + const std::vector> currentEntries = ParseBaseline(current); + ExpectEqual(static_cast(currentEntries.size()), + static_cast(expectedEntries.size()), + std::string(label) + " entry count"); + + std::map currentMap; + for (const auto& entry : currentEntries) + { + currentMap[entry.first] = entry.second; + } + + int reported = 0; + int mismatches = 0; + for (const auto& expected : expectedEntries) + { + const auto found = currentMap.find(expected.first); + if (found == currentMap.end()) + { + ++mismatches; + if (reported < 20) + { + EmitLine("[%s] MISSING %s (baseline=%s)\n", label, expected.first.c_str(), + expected.second.c_str()); + ++reported; + } + continue; + } + if (found->second != expected.second) + { + ++mismatches; + if (reported < 20) + { + EmitLine("[%s] CHANGED %s: baseline=%s current=%s\n", label, expected.first.c_str(), + expected.second.c_str(), found->second.c_str()); + ++reported; + } + } + } + // 反过来查"多出来的键"(新增几何字段时会出现,属于预期变化)。 + std::map expectedMap; + for (const auto& entry : expectedEntries) + { + expectedMap[entry.first] = entry.second; + } + for (const auto& entry : currentEntries) + { + if (expectedMap.find(entry.first) == expectedMap.end()) + { + ++mismatches; + if (reported < 20) + { + EmitLine("[%s] ADDED %s=%s\n", label, entry.first.c_str(), entry.second.c_str()); + ++reported; + } + } + } + EmitLine("[%s] compared %d entries, %d mismatch(es)\n", label, + static_cast(expectedEntries.size()), mismatches); + Expect(mismatches == 0, std::string(label) + " geometry is unchanged"); + } + // ---- 检查实现 ------------------------------------------------------- int CheckSmoke(Session& session, const CheckOptions& options) @@ -417,43 +665,53 @@ namespace jdi rect.y + rect.height <= plotRect.y + plotRect.height + 1; Expect(inside, std::string("panel inside plot area: ") + PanelKindName(kind)); - // 不变式:`EdgePanelLayout` 的**平行数组长度必须全部等于行数**。 - // 这 18 个按行平行的 vector 现在由 `AppendRow()` 一次补齐 - //(新增行的唯一入口),因此**不允许**任何一列短于行数 —— - // 短了就说明有分支绕过 `AppendRow` 手写 push("某列读到别人矩形"的根因)。 - // 成本极低(O(1) 比长度),但能挡住整类错位缺陷。 + // 不变式:行的几何数组必须与行数**形状一致**。 + // + // ⚠️⚠️ 判据必须**按排版类别**区分 —— 旧版"18 个数组全部全等"是**错的**: + // 「自由标记」面板复用 `rowRefs` 表示"信号分组",只填 + // `rowNameRects` / `rowNameCloses`,其余 14 列**必须为空** + // (它真正的行在 `markerRows` / `markerGroups` 里)。 + // 这是 `rowgeom` 第一次带真实固定点运行时越界崩溃所暴露的结构事实。 + const PanelStyle rowStyle = PanelStyleOf(kind); const std::size_t rowCount = layout.rowRefs.size(); - const std::size_t parallelSizes[] = { - layout.rowRefs.size(), layout.rowHeaders.size(), - layout.rowNameRects.size(), layout.rowTriangles.size(), - layout.rowChecks.size(), layout.rowPoints.size(), - layout.rowIterations.size(), layout.rowStats.size(), - layout.rowLevelTracks.size(), layout.rowLevelValues.size(), - layout.rowLevelResets.size(), layout.rowIterTracks.size(), - layout.rowIterValues.size(), layout.rowIterResets.size(), - layout.rowInfoMaxInterval.size(), layout.rowInfoMinInterval.size(), - layout.rowIntervalField.size(), layout.rowFrequencyField.size()}; bool parallel = true; - for (const std::size_t size : parallelSizes) + if (rowStyle.markerGroups) { - if (size != rowCount) + parallel = layout.rowNameRects.size() == rowCount && + layout.rowNameCloses.size() == rowCount && layout.rowHeaders.empty() && + layout.rowChecks.empty() && layout.rowLevelTracks.empty() && + layout.rowIterTracks.empty(); + } + else + { + const std::size_t parallelSizes[] = { + layout.rowHeaders.size(), layout.rowNameRects.size(), + layout.rowTriangles.size(), layout.rowChecks.size(), + layout.rowPoints.size(), layout.rowIterations.size(), + layout.rowStats.size(), layout.rowLevelTracks.size(), + layout.rowLevelValues.size(), layout.rowLevelResets.size(), + layout.rowIterTracks.size(), layout.rowIterValues.size(), + layout.rowIterResets.size(), layout.rowInfoMaxInterval.size(), + layout.rowInfoMinInterval.size(), layout.rowIntervalField.size(), + layout.rowFrequencyField.size()}; + for (const std::size_t size : parallelSizes) { - parallel = false; - break; + if (size != rowCount) + { + parallel = false; + break; + } } } if (!parallel) { - EmitLine("[panels] %s row arrays: refs=%d hdr=%d name=%d tri=%d chk=%d pts=%d it=%d " - "stat=%d lvl=%d/%d/%d iter=%d/%d/%d info=%d/%d fld=%d/%d\n", + EmitLine("[panels] %s row arrays: refs=%d name=%d nameClose=%d hdr=%d chk=%d lvl=%d/%d/%d " + "iter=%d/%d/%d info=%d/%d fld=%d/%d\n", PanelKindName(kind), static_cast(layout.rowRefs.size()), - static_cast(layout.rowHeaders.size()), static_cast(layout.rowNameRects.size()), - static_cast(layout.rowTriangles.size()), + static_cast(layout.rowNameCloses.size()), + static_cast(layout.rowHeaders.size()), static_cast(layout.rowChecks.size()), - static_cast(layout.rowPoints.size()), - static_cast(layout.rowIterations.size()), - static_cast(layout.rowStats.size()), static_cast(layout.rowLevelTracks.size()), static_cast(layout.rowLevelValues.size()), static_cast(layout.rowLevelResets.size()), @@ -465,7 +723,7 @@ namespace jdi static_cast(layout.rowIntervalField.size()), static_cast(layout.rowFrequencyField.size())); } - Expect(parallel, std::string("all 18 row arrays match row count: ") + PanelKindName(kind)); + Expect(parallel, std::string("row arrays match row count: ") + PanelKindName(kind)); char buffer[160] = {}; std::snprintf(buffer, sizeof(buffer), "%s=%016llX,%d,%d,%d,%d\n", PanelKindName(kind), @@ -592,30 +850,47 @@ namespace jdi initial.scrollTrack.Width > 0.0f ? 1 : 0, static_cast(initial.rowRefs.size())); - // 多信号场景下额外守住"18 个平行数组长度全部等于行数" - //(`AppendRow()` 是新增行的唯一入口 ⇒ 任何一列短了就是绕过它的证据)。 + // 多信号场景下同样守住"行数组形状一致"(判据按排版类别区分: + // MARK 只用 rowRefs / rowNameRects / rowNameCloses,详见 `panels` 检查里的说明)。 { + const PanelStyle rowStyle = PanelStyleOf(kind); const std::size_t rows = initial.rowRefs.size(); - const std::size_t sizes[] = {initial.rowHeaders.size(), initial.rowNameRects.size(), - initial.rowTriangles.size(), initial.rowChecks.size(), - initial.rowPoints.size(), initial.rowIterations.size(), - initial.rowStats.size(), initial.rowLevelTracks.size(), - initial.rowLevelValues.size(), initial.rowLevelResets.size(), - initial.rowIterTracks.size(), initial.rowIterValues.size(), - initial.rowIterResets.size(), initial.rowInfoMaxInterval.size(), - initial.rowInfoMinInterval.size(), - initial.rowIntervalField.size(), - initial.rowFrequencyField.size()}; bool aligned = true; - for (const std::size_t size : sizes) + if (rowStyle.markerGroups) { - if (size != rows) + aligned = initial.rowNameRects.size() == rows && + initial.rowNameCloses.size() == rows && initial.rowHeaders.empty() && + initial.rowChecks.empty(); + } + else + { + const std::size_t sizes[] = {initial.rowHeaders.size(), + initial.rowNameRects.size(), + initial.rowTriangles.size(), + initial.rowChecks.size(), + initial.rowPoints.size(), + initial.rowIterations.size(), + initial.rowStats.size(), + initial.rowLevelTracks.size(), + initial.rowLevelValues.size(), + initial.rowLevelResets.size(), + initial.rowIterTracks.size(), + initial.rowIterValues.size(), + initial.rowIterResets.size(), + initial.rowInfoMaxInterval.size(), + initial.rowInfoMinInterval.size(), + initial.rowIntervalField.size(), + initial.rowFrequencyField.size()}; + for (const std::size_t size : sizes) { - aligned = false; - break; + if (size != rows) + { + aligned = false; + break; + } } } - Expect(aligned, std::string("all 18 row arrays match row count: ") + name); + Expect(aligned, std::string("row arrays match row count: ") + name); } if (maxScroll <= 1.0f) @@ -672,6 +947,225 @@ namespace jdi return CheckFailed(); } + + /** + * @brief 逐行几何基线:把每个面板的**每一个矩形**导出成文本并逐项比对。 + * + * 为什么需要它(Code Review 报告 §13.6 建议 1): + * * 像素哈希只能回答"有没有变化",回答不了"**哪一行哪一列**变了"; + * * `EdgePanelLayout` 仍是 18 个平行数组,任何"合并成单一 `PanelRow`"或 + * "重写布局算式"的重构都必须先能**逐项证明等价**; + * * 面板改布局时的真实缺陷形态(某列读到别人的矩形)会让**成片的**行错位, + * 而像素基线在面板互相重叠时还会被"下层露出"掩盖。 + * + * 两个场景: + * A. **明细**:6 条信号合并到 1 个网格,7 种面板全部展开, + * 并把第 1/2 条信号勾选上(让参数行同时覆盖"展开"与"未勾选"两种形态); + * 另有一条 `*.collapsed` 用例覆盖"折起只剩标题栏"。 + * B. **规模**:全部信号合并到 1 个网格,只记录每个面板的几何摘要哈希 —— + * 566 行的场景下逐行文本会有上万行(不适合入库当基线), + * 但"行数 + 全部矩形的滚动哈希"足以发现任何错位。 + */ + int CheckRowGeometry(Session& session, const CheckOptions& options) + { + Application& app = session.App(); + PlotView& plot = app.m_plot; + + static const EdgePanelKind kinds[] = {EdgePanelKind::COUNT, EdgePanelKind::CENTER, + EdgePanelKind::MEAN, EdgePanelKind::PEAK, + EdgePanelKind::MARK, EdgePanelKind::RESAMPLE, + EdgePanelKind::CLIP}; + + // 双光标(「选区截取」需要它才有内容)。 + if (app.ActiveTab().CursorMode() != PlotCursorMode::DOUBLE) + { + for (int step = 0; step < 4 && app.ActiveTab().CursorMode() != PlotCursorMode::DOUBLE; ++step) + { + plot.CycleCursorMode(); + } + } + + // ---- 场景 A:6 条信号合并成 1 个网格 ---- + session.ShowFirstSignals(6); + if (session.LaneCount() > 1) + { + app.ExecuteToolbarAction(ToolbarAction::MERGE_LANES); + // `SetMode` 只重建 TabConfig 的网格配置;`PlotView::m_lanes` 要等下一次 + // 布局/绘制才刷新 ⇒ 必须**先同步一次**再断言,否则读到的是合并前的数量。 + app.SynchronizeView(true); + session.RenderFrame(); + } + Expect(session.LaneCount() == 1, "signals merged into one lane"); + + // 收集可见信号(在开启功能**之前**:功能开关不改变可见性, + // 但 `Toggle*` 内部可能重建网格,持有 `Lane&` 跨调用是危险的)。 + std::vector visibleRefs; + { + const Lane& lane = app.ActiveTab().Lanes()[0]; + for (const SignalRef& ref : lane.signals) + { + if (app.ActiveTab().IsVisible(ref)) + { + visibleRefs.push_back(ref); + } + } + } + Expect(!visibleRefs.empty(), "merged lane has visible signals"); + if (visibleRefs.size() < 3) + { + // 小样本文件(如 SCATTER_TEST.csv 只有 1 条信号)覆盖度较低,但检查仍然有效; + // 想要完整覆盖请用 `-data ..\TestData\MF4-Record.MF4`(566 条信号)。 + EmitLine("[rowgeom] note: only %d visible signal(s); use a bigger -data for full coverage\n", + static_cast(visibleRefs.size())); + } + + for (const EdgePanelKind kind : kinds) + { + EnsureLaneFeature(plot, 0, kind); + } + // 把第 2 条信号也勾选上:同一面板里同时存在"展开的参数行"与"未勾选的行"。 + if (visibleRefs.size() >= 2) + { + plot.ToggleLaneMeanFilterSignal(0, visibleRefs[1]); + plot.ToggleLaneEdgeCenterSignal(0, visibleRefs[0]); + plot.ToggleLaneWeakPeakSignal(0, visibleRefs[1]); + plot.ToggleLaneResampleSignal(0, visibleRefs[1]); + plot.ToggleLaneClipSignal(0, visibleRefs[1]); + } + // 「自由标记」需要真实固定点才会出现分组栏与条目行。 + // + // ⚠️ 每次写入后都要**重新取** `Lanes()[0]`:`AddFreeMarker` 内部会整理/插入条目, + // 跨调用持有 `Lane&` 会在条目扩容时失效。 + // ⚠️ 不能依赖"上一个场景加过的固定点还在":`ShowSignals()` 会先 + // `SetAllVisible(false)`,而"全部不可见 ⇒ 丢弃空网格"是既有设计 + // (`RebuildLanes` 的 MERGED 分支),网格级状态(含固定点)随之清空。 + const auto addFreeMarkers = [&app](std::size_t wanted) + { + std::vector refs; + { + const Lane& lane = app.ActiveTab().Lanes()[0]; + for (const SignalRef& ref : lane.signals) + { + if (refs.size() >= wanted) + { + break; + } + if (app.ActiveTab().IsVisible(ref)) + { + refs.push_back(ref); + } + } + } + for (std::size_t index = 0; index < refs.size(); ++index) + { + const SignalGroup* group = app.m_dataSet.GroupAt(refs[index].groupIndex); + if (group == nullptr || + refs[index].signalIndex >= static_cast(group->signals.size())) + { + continue; + } + const SignalData& signal = + group->signals[static_cast(refs[index].signalIndex)]; + const TimeAxis& axis = group->AxisOf(signal); + if (axis.time.empty()) + { + continue; + } + app.ActiveTab().Lanes()[0].AddFreeMarker(refs[index], axis.minTime); + if (index == 0) + { + const double t1 = axis.minTime + (axis.maxTime - axis.minTime) * 0.35; + app.ActiveTab().Lanes()[0].AddFreeMarker(refs[index], t1); + } + } + }; + addFreeMarkers(2); + Expect(session.RenderFrame(), "render with dense row geometry"); + + std::string detail; + for (const EdgePanelKind kind : kinds) + { + const PlotView::EdgePanelLayout layout = plot.EdgePanelLayoutFor(0, kind); + AppendLayoutDump(detail, PanelKindName(kind), layout); + } + // 折起态也纳入基线(折起时内容区/按钮/滚动条必须全部为空)。 + for (const EdgePanelKind kind : kinds) + { + plot.ToggleLanePanelCollapsed(0, kind); + } + Expect(session.RenderFrame(), "render with all panels collapsed"); + for (const EdgePanelKind kind : kinds) + { + const PlotView::EdgePanelLayout layout = plot.EdgePanelLayoutFor(0, kind); + Expect(layout.collapsed, std::string("panel reports collapsed: ") + PanelKindName(kind)); + ExpectNear(layout.panel.Height, layout.title.Height, 0.01, + std::string("collapsed panel height equals title height: ") + PanelKindName(kind)); + ExpectNear(layout.content.Width, 0.0, 0.01, + std::string("collapsed panel has no content area: ") + PanelKindName(kind)); + AppendLayoutDump(detail, std::string(PanelKindName(kind)) + ".collapsed", layout); + } + // 复原(后续检查依赖面板展开)。 + for (const EdgePanelKind kind : kinds) + { + plot.ToggleLanePanelCollapsed(0, kind); + } + + // ---- 场景 B:全部信号合并成 1 个网格(只存摘要哈希)---- + session.ShowFirstSignals(0); + if (session.LaneCount() > 1) + { + app.ExecuteToolbarAction(ToolbarAction::MERGE_LANES); + app.SynchronizeView(true); + } + for (const EdgePanelKind kind : kinds) + { + EnsureLaneFeature(plot, 0, kind); + } + // 固定点必须在本场景**重新加**(见 `addFreeMarkers` 上方的说明)。 + addFreeMarkers(2); + Expect(session.RenderFrame(), "render with every signal in one lane"); + + + std::string scale; + for (const EdgePanelKind kind : kinds) + { + const PlotView::EdgePanelLayout layout = plot.EdgePanelLayoutFor(0, kind); + std::string dump; + AppendLayoutDump(dump, PanelKindName(kind), layout); + std::uint64_t hash = 1469598103934665603ull; + MixText(hash, dump); + char line[160] = {}; + std::snprintf(line, sizeof(line), "%s.hash=%016llX rows=%d markerRows=%d\n", + PanelKindName(kind), static_cast(hash), + static_cast(layout.rowRefs.size()), + static_cast(layout.markerRows.size())); + scale += line; + } + EmitLine("[rowgeom] detail lines=%d scale=%s", static_cast(CountLines(detail)), + scale.c_str()); + + // ---- 记录 / 比对 ---- + const std::string detailPath = Join(options.outDir, "rowgeom-baseline.txt"); + const std::string scalePath = Join(options.outDir, "rowgeom-scale-baseline.txt"); + if (options.mode == "record") + { + Expect(WriteTextFile(detailPath, detail), "write rowgeom detail baseline"); + Expect(WriteTextFile(scalePath, scale), "write rowgeom scale baseline"); + EmitLine("[rowgeom] baselines recorded: %s / %s\n", detailPath.c_str(), + scalePath.c_str()); + } + else if (options.mode == "compare") + { + CompareBaselineText(detailPath, detail, "rowgeom detail"); + CompareBaselineText(scalePath, scale, "rowgeom scale"); + } + else + { + EmitLine("[rowgeom] (hint) use -mode record / -mode compare for geometry A/B\n"); + } + return CheckFailed(); + } + int CheckEdgeStats(Session& session, const CheckOptions& options) { session.ShowFirstSignals(1); @@ -953,6 +1447,8 @@ namespace jdi {"panels", "all 7 experiment panels: geometry invariants + pixel baseline A/B", CheckPanels}, {"panelscroll", "panel content scrolling: every kind scrolls, clamps, and keeps content fixed", CheckPanelScroll}, + {"rowgeom", "per-field/per-row geometry baseline for all 7 panels (record/compare)", + CheckRowGeometry}, {"edgestats", "edge counts vs independent reference implementation", CheckEdgeStats}, {"perf", "render / panel layout / edge query timings", CheckPerf}, }; diff --git a/Tools/README.md b/Tools/README.md index 1f0ca9e..a540cca 100644 --- a/Tools/README.md +++ b/Tools/README.md @@ -147,6 +147,9 @@ Tools\OffscreenChecks\Build\OffscreenChecks.exe -list ... -run smoke # 单个检查 ... -run panels -mode record # 记录面板像素基线 ... -run panels -mode compare # 与基线比对(重构前后 A/B) +... -run rowgeom -mode record # 记录逐行几何基线(默认数据) +... -run rowgeom -mode compare # 逐项比对几何(含每行每列矩形) +... -data "..\TestData\MF4-Record.MF4" -run rowgeom -mode record # 566 行规模基线 ... -run frames -frames 12 # 连续抓 12 帧 ... -data "..\TestData\MF4-Record.MF4" -run perf ... -out ..\Temp\tools\run1 # 换输出目录(避免覆盖基线) @@ -171,8 +174,9 @@ Tools\OffscreenChecks\Build\OffscreenChecks.exe -list | --- | --- | --- | | `smoke` | 建隐藏窗口 → 加载 → 显示 3 条信号 → 渲染 → 抓帧 → 落盘 `smoke.png` | 客户区/绘图区尺寸合理、绘图区墨迹 > 1000 像素、**同一状态连续两帧逐像素相同**(渲染确定性) | | `frames` | 沿信号列表逐行移动"鼠标",每帧抓图 + 区域哈希 + 耗时 | 平均帧 < 500ms、帧序列**至少出现一次可见变化**;输出 `frame-XXX.png` 供事后分析 | -| `panels` | 打开全部 7 个实验性面板(含双光标下的「选区截取」) | 每个面板:功能已启用、有尺寸、**必须被绘图区夹住**、**18 个行数组长度全部等于行数**;`-mode record` 写 `panels-baseline.txt`,`-mode compare` 逐面板比对**像素哈希 + 几何** | -| `panelscroll` | 全部信号合并到同一网格后,逐一滚动 7 种面板的内容区 | 可滚动时:滚到底/滚回顶、`scrollOffset` 夹到 `maxScroll`、滑块到达/返回轨道端点、内容区几何不动;不可滚动时不得出现滚动条。另守住 18 个行数组与行数完全一致 | +| `panels` | 打开全部 7 个实验性面板(含双光标下的「选区截取」) | 每个面板:功能已启用、有尺寸、**必须被绘图区夹住**、**行数组与行数形状一致**(按排版类别判定,见 §4.4);`-mode record` 写 `panels-baseline.txt`,`-mode compare` 逐面板比对**像素哈希 + 几何** | +| `panelscroll` | 全部信号合并到同一网格后,逐一滚动 7 种面板的内容区 | 可滚动时:滚到底/滚回顶、`scrollOffset` 夹到 `maxScroll`、滑块到达/返回轨道端点、内容区几何不动;不可滚动时不得出现滚动条。另守住行数组与行数形状一致 | +| `rowgeom` | **逐行几何基线**:7 个面板按「细节 / 规模」两层导出全部矩形并比对 | 细节层(6 信号 1 网格、参数行展开、3 个固定点、折起+展开)导出**每个矩形的每个字段**共 983 条键;规模层(全部信号 1 网格,MF4 = 566 行)导出每面板 FNV-1a 摘要 + 行数。`-mode record` / `-mode compare` 双向比对(MISSING / ADDED / CHANGED) | | `edgestats` | 3 个阈值下的边沿计数(普通计数)+ 中心轴计数 | 与 `Checks.cpp` 内的**独立参考实现**(朴素逐点)**逐位相等**;同参数重复查询结果稳定 | | `perf` | 整帧中位数、`EdgePanelLayoutFor` 单次耗时、198 个统计状态的整轮查询耗时 | 整帧 < 500ms、面板几何 < 200µs/次、统计查询 < 20µs/次;数字用于前后对比 | @@ -191,7 +195,31 @@ OffscreenChecks.exe -run panels -mode compare -out ..\Temp\tools\panel-before ⚠️ **像素哈希是"本机、本 DPI、本数据文件"的基线**,跨机器/跨缩放不可比。 它适合"我改了一处,其它像素有没有动"的**本地 A/B**,不适合入库当黄金图。 -### 4.3 输出(全部在 `Temp/tools/`,已 gitignore) +### 4.3 几何基线(`rowgeom`)与像素基线的分工 + +| | 像素基线(`panels`) | 几何基线(`rowgeom`) | +| --- | --- | --- | +| 回答的问题 | “用户看到的画面变了吗” | “**哪个面板的哪一行哪一列**变了吗” | +| 粒度 | 整个面板区域一个哈希 | 每个矩形的每个字段(`mean.rowNameRects[001]=x,y,w,h`) | +| 定位能力 | 只能告诉你“变了” | 直接给出字段名、行号、新旧值 | +| 规模覆盖 | 固定场景 | 另加“全部信号 1 网格”(MF4 = 566 行)的摘要哈希 | + +两者都要跑:像素基线防止“几何没变但画错了”,几何基线防止“画得没错但位置挪了”。 + +**基线是检测器,不是黄金图**:预期内的几何变化要重新 `-mode record`,并在提交信息里写明原因。 + +### 4.4 长度不变式为什幺要“按类别”判定 + +`EdgePanelLayout::rowRefs` 是**重载字段**: + +* 列表式面板(均值/中心轴/弱峰/重采样/截取):`rowRefs[i]` = 第 i 行的信号; +* 「自由标记」面板:`rowRefs[i]` = 第 i 个**信号分组**,真正的条目行在 `markerRows`, + 且只填 `rowNameRects` / `rowNameCloses`,其余 14 列**必须为空**。 + +所以判据按 `PanelStyleOf(kind).markerGroups` 分流(见 `Checks.cpp` 里 `panels` 的注释)。 +早先“18 列全等”的写法对 MARK 面板是**错的**,只因既有场景恰好未触发才没暴露。 + +### 4.5 输出(全部在 `Temp/tools/`,已 gitignore) | 文件 | 内容 | | --- | --- | @@ -200,6 +228,8 @@ OffscreenChecks.exe -run panels -mode compare -out ..\Temp\tools\panel-before | `smoke.png` / `panels.png` / `perf.png` | 关键状态截图 | | `frame-000.png` … | 连续帧(问题分析用) | | `panels-baseline.txt` | 面板像素基线(`-mode record` 生成) | +| `rowgeom-baseline.txt` | 逐行几何明细基线(`-mode record` 生成,约 1000 条键) | +| `rowgeom-scale-baseline.txt` | 规模场景的每面板摘要哈希(`-mode record` 生成,7 条键) | --- @@ -209,7 +239,8 @@ OffscreenChecks.exe -run panels -mode compare -out ..\Temp\tools\panel-before | --- | --- | | 任何数据层 / 解析 / 公式 / 变换 | `UnitTests.exe` | | 绘图、面板、布局、缓存键 | `-run smoke -run panels`(+ 需要时 `-mode compare`) | -| 面板内容滚动 / 面板几何 / 新增面板种类 | `-run panels -run panelscroll`(大文件加 `-data ..\TestData\MF4-Record.MF4`) | +| **面板几何 / 行距 / 内边距 / 折起规则 / 布局算式** | `-run panels -mode compare` + `-run rowgeom -mode compare`(改动前先 `-mode record`) | +| 面板内容滚动 / 新增面板种类 | `-run panels -run panelscroll`(大文件加 `-data ..\TestData\MF4-Record.MF4`) | | 边沿统计 / 中心轴 / 扫描预算 | `-run edgestats -run perf` | | 性能相关 | `-run perf`(前后各跑一次,比 `us/query` 与 `median ms`) | | 完全不确定影响面 | `-all`(约几秒到几十秒)+ `UnitTests.exe` | -- 2.54.0 From f07ac749885b53f789b0171b60bbb32ceb1f92b5 Mon Sep 17 00:00:00 2001 From: "DeepSeek V4.1 Flash" <> Date: Wed, 4 Nov 2026 16:02:15 +0800 Subject: [PATCH 19/22] =?UTF-8?q?refactor(panels):=20=E5=88=A0=E9=99=A4=20?= =?UTF-8?q?rowPoints/rowIterations=20=E6=AD=BB=E5=88=97=20+=20=E6=8F=90?= =?UTF-8?q?=E5=8F=96=20PaintPanelResampleRows?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 对应 Code_Review_Report §14.5 第 1 项与第 3 项。 第 1 项:删除 EdgePanelLayout::rowPoints / rowIterations(行数组 18 → 16) - 写入方只有 AppendRow(推空矩形),读取方只有调试框 AddAll 与 PanelSlotKey 的 mix 循环, 绘制方零(旧式“点数 / 迭代次数”输入框早被滑块版 rowLevelTracks / rowIterTracks 取代)。 - 连带同步:AppendRow、调试框、缓存键、rowgeom 导出、panels/panelscroll 长度不变式、 文档里的列数(18/14 → 16/12)。 等价性由基线证伪,而不是靠人工 grep: old lines = 983, new lines = 919 only in OLD = 64, only in NEW = 0 only-in-OLD lines NOT belonging to rowPoints/rowIterations = 0 即除被删两列外,其余 919 条键逐字节相同。 第 3 项(前半):提取 PlotView::PaintPanelResampleRows - PaintPanelListRows 里 `if (resamplePanel) { …; continue; }` 共 132 行整块提取, 签名 (g, layout, rowIndex, laneIndex, ref, item);两个局部 lambda (paintInfoRow 只读统计行 / paintInputRow 联动输入框)随之迁移。 - maxInterval / minInterval 两个纯拷贝局部量直接用 item.maxInterval / item.minInterval。 - 通用参数行(中心轴统计 + 两行滑块)未提取:需 9 个参数,签名比函数体更难读。 §14.5 第 2 项(完整结构合并)本轮不做,方案已定并写入文档: - 列视图(PanelColumn)方案否决:拷贝忘重绑 = 静默悬垂,需额外自检兜住; - 若要做就做“vector + 具名成员访问(无视图)”,242 处读点(127 处命中判定), 必须单独一轮。它属整洁性收益(漏推一列已被 AppendRow + 长度不变式挡住),不是紧急项。 验证 - make release:零 warning;785,408 → 784,896 B(−512);make debug 同样零 warning - make -C Tools:零 warning - UnitTests:checks=260 failed=0 cases=19 - OffscreenChecks -all:7 检查 / 127 断言全 PASS - panels -mode compare:7 面板像素哈希 + 几何逐项不变(resample=2BFC74B3D9B3239C) - rowgeom -mode compare:919 + 7 键,0 差异(基线已按 16 列重录) - PlotViewPaint.cpp 5575 → 5565;PlotViewPanelPaint.cpp 1016 → 1020 文档:Plan §十、Progress 第 38 轮、Report §15、agent_mem 第 38 轮、Tools/README §4.4 --- Components/PlotView/Inc/PlotView.hpp | 34 ++- Components/PlotView/Src/PlotViewPaint.cpp | 10 - .../PlotView/Src/PlotViewPanelPaint.cpp | 238 +++++++++--------- Documents/Code_Review_Plan.md | 36 +++ Documents/Code_Review_Progress.md | 59 +++++ Documents/Code_Review_Report.md | 73 ++++++ Documents/agent_mem.md | 57 +++++ Tools/OffscreenChecks/Src/Checks.cpp | 25 +- Tools/README.md | 2 +- 9 files changed, 384 insertions(+), 150 deletions(-) diff --git a/Components/PlotView/Inc/PlotView.hpp b/Components/PlotView/Inc/PlotView.hpp index 98609ee..fc8fb69 100644 --- a/Components/PlotView/Inc/PlotView.hpp +++ b/Components/PlotView/Inc/PlotView.hpp @@ -2021,13 +2021,15 @@ namespace jdi * @brief 「逐条目」面板的矩形(MEAN / CENTER / PEAK 面板共用同一套排布)。 * * 索引与 `rowRefs` 一一对应(即网格内可见信号的顺序): - * `rowChecks` 为第一排右侧的复选框;`rowPoints` / `rowIterations` 为**输入框** - *(MEAN / CENTER 使用);`rowStats` 为第三排的统计结果(中心轴边沿计数专用)。 + * `rowChecks` 为第一排右侧的复选框;`rowStats` 为第三排的统计结果(中心轴边沿计数专用)。 * 未勾选的条目对应的矩形均为空。 + * + * ⚠️ 2026-11-04 删除了 `rowPoints` / `rowIterations`:它们是**旧式输入框**(点数 / 迭代次数) + * 的矩形,自滑块版排版(`rowLevelTracks` / `rowIterTracks`)上线后就没有绘制方读它们了 —— + * 所有写入都是空矩形,读取方只剩调试框与缓存键。保留它们只会让每次 `AppendRow` + * 多两次 `push_back`、并且误导后续读者以为“这里还有一套输入框”。 */ std::vector rowChecks; - std::vector rowPoints; - std::vector rowIterations; std::vector rowStats; /** * @brief 「弱峰调整」每个被勾选信号自己的两行**滑块**(与 `rowRefs` 同序)。 @@ -2215,7 +2217,7 @@ namespace jdi /** * @brief 追加一**行**(条目栏 / 名称 / 三角 / 复选框),其余列一律补空矩形。 * - * ⚠️⚠️ 这是**唯一**允许新增行的入口。面板每行对应 18 个平行数组 + * ⚠️⚠️ 这是**唯一**允许新增行的入口。面板每行对应 16 个平行数组 * (`rowRefs` / `rowHeaders` / `rowChecks` / `rowLevelTracks` / …); * 如果每个排版分支各写一遍 `push_back`,只要某分支漏推一项, * 那一列之后的所有行都会**读到别人的矩形**("改一次布局就出错"的典型机制)。 @@ -2236,8 +2238,6 @@ namespace jdi rowNameRects.push_back(nameRect); rowTriangles.push_back(triangle); rowChecks.push_back(check); - rowPoints.push_back(gd::RectF()); - rowIterations.push_back(gd::RectF()); rowStats.push_back(gd::RectF()); rowLevelTracks.push_back(gd::RectF()); rowLevelValues.push_back(gd::RectF()); @@ -2280,6 +2280,26 @@ namespace jdi void PaintPanelListRows(gd::Graphics& g, const EdgePanelLayout& layout, const Lane& tabLane, EdgePanelKind kind, std::size_t laneIndex); + /** + * @brief 「重采样」面板**单个信号的参数区**(四行:最大间隔 / 最小间隔 / 间隔输入 / 频率输入)。 + * + * 从 `PaintPanelListRows` 里提取:这四行的排布与其它面板完全不同(两行只读统计 + 两行 + * 联动输入框、标签在左数值在右),混在同一个行循环里会让"条目栏 / 参数行 / 重采样行" + * 三种排版互相纠缠。提取后调用点只剩一行,`PaintPanelListRows` 只保留 + * "条目栏 + 通用参数行"两段。 + * 定义在 `PlotViewPanelPaint.cpp`。 + * + * @param [IN,OUT] g 绘图上下文(调用方已按内容区裁剪)。 + * @param [IN] layout 面板布局。 + * @param [IN] rowIndex 行下标(取该行的四个矩形)。 + * @param [IN] laneIndex 网格下标(判定"正在编辑的是不是这一行")。 + * @param [IN] ref 该行对应的信号(同上)。 + * @param [IN] item 该信号的「重采样」条目(已确认存在)。 + */ + void PaintPanelResampleRows(gd::Graphics& g, const EdgePanelLayout& layout, std::size_t rowIndex, + std::size_t laneIndex, const SignalRef& ref, + const Lane::ResampleItem& item); + /** * @brief 「自由标记」面板的**内容区**(分组栏 + 表头 + 条目 + 吸顶)。 * diff --git a/Components/PlotView/Src/PlotViewPaint.cpp b/Components/PlotView/Src/PlotViewPaint.cpp index 8edfb81..683687c 100644 --- a/Components/PlotView/Src/PlotViewPaint.cpp +++ b/Components/PlotView/Src/PlotViewPaint.cpp @@ -815,8 +815,6 @@ namespace jdi boxes.Add(layout.clipDestructiveButton, 3); boxes.AddAll(layout.rowChecks, 4); - boxes.AddAll(layout.rowPoints, 4); - boxes.AddAll(layout.rowIterations, 4); boxes.AddAll(layout.rowStats, 4); boxes.AddAll(layout.rowLevelTracks, 4); boxes.AddAll(layout.rowLevelValues, 4); @@ -4088,14 +4086,6 @@ namespace jdi { mixRect(rect); } - for (const gd::RectF& rect : layout.rowPoints) - { - mixRect(rect); - } - for (const gd::RectF& rect : layout.rowIterations) - { - mixRect(rect); - } for (const gd::RectF& rect : layout.rowStats) { mixRect(rect); diff --git a/Components/PlotView/Src/PlotViewPanelPaint.cpp b/Components/PlotView/Src/PlotViewPanelPaint.cpp index 1da47b2..6eb1188 100644 --- a/Components/PlotView/Src/PlotViewPanelPaint.cpp +++ b/Components/PlotView/Src/PlotViewPanelPaint.cpp @@ -328,124 +328,10 @@ namespace jdi // 「重采样」:四行 = 最大间隔(只读)/ 最小间隔(只读)/ // 重采样间隔(输入)/ 重采样频率(输入)。标签在左、数值在右。 + // 排布细节见 `PaintPanelResampleRows`(从本循环里提取的独立函数)。 if (resamplePanel) { - const float labelWidth = - UiMeasureTextWidth(m_fonts->uiSmall, kResampleMaxIntervalLabel); - const auto paintInfoRow = [&](const gd::RectF& row, const wchar_t* label, - const std::wstring& text) - { - if (row.Width <= 0.0f) - { - return; - } - UiTextClipped(g, label, m_fonts->uiSmall, - gd::RectF(row.X, row.Y, std::max(10.0f, labelWidth), - row.Height), - m_theme->textMuted, TextAlign::LEFT, layout.content); - // ⚠️ 统计数字**绝不省略**(用户 2026-11-03 要求): - // 面板宽度已按真实文本量宽(`EdgePanelWidth` 逐信号实测), - // 因此这里用 `TextAlign::RIGHT` + 完整矩形即可; - // 万一仍被窗口边界夹住,也宁可超出面板右缘(由内容区裁剪兜底), - // **不要**退化成 `…` 让用户看不到真实数值。 - UiTextClipped(g, text, m_fonts->monoSmall, - gd::RectF(row.X + labelWidth + 8.0f * m_scale, row.Y, - std::max(10.0f, row.Width - labelWidth - - 8.0f * m_scale), - row.Height), - m_theme->textStrong, TextAlign::RIGHT, - layout.content); - }; - const auto paintInputRow = [&](const gd::RectF& field, const wchar_t* label, - const std::wstring& text, bool editing) - { - if (field.Width <= 0.0f) - { - return; - } - // 标签与数值同高:标签从内容区左缘画到字段左缘。 - UiTextClipped( - g, label, m_fonts->uiSmall, - gd::RectF(layout.content.X, field.Y, - std::max(10.0f, field.X - 8.0f * m_scale - layout.content.X), - field.Height), - m_theme->textMuted, TextAlign::LEFT, layout.content); - if (editing) - { - return; // 正在编辑:内容由 `PaintEditField` 画 - } - const gd::RectF box(field.X, field.Y + 2.0f * m_scale, field.Width, - std::max(10.0f, field.Height - 4.0f * m_scale)); - UiFillRoundRect(g, box, 3.0f * m_scale, m_theme->pressed); - UiDrawRoundRect(g, box, 3.0f * m_scale, m_theme->border, 1.0f); - UiTextClipped(g, text, m_fonts->monoSmall, - gd::RectF(box.X + 4.0f * m_scale, box.Y, - std::max(6.0f, box.Width - 8.0f * m_scale), - box.Height), - m_theme->textStrong, TextAlign::LEFT, layout.content); - }; - double maxInterval = 0.0; - double minInterval = 0.0; - const bool hasInterval = - (resampleItem->maxInterval > 0.0) && (resampleItem->minInterval > 0.0); - maxInterval = resampleItem->maxInterval; - minInterval = resampleItem->minInterval; - // 「最大 / 最小数据间隔」= 自适应单位的**小数**文本(绝不用科学计数法), - // 末尾追加一个括号内的「对应频率」尾缀(单位在 Hz/kHz/MHz/GHz 之间自适应)。 - // - // ⚠️⚠️ 全部使用**真实值**(用户 2026-11-03 明确要求): - // * 时间间隔:`FormatDurationCompound` 会把次级单位保留 3 位小数 - // ⇒ `0.003696631s` 显示为 `3ms 696.631us`(旧实现舍入到 3 位有效数字 - // 得到 `3ms 700us`,把真实尾数抹掉了); - // * 频率:由**未舍入的真实间隔**换算 ⇒ `270.517 Hz`(旧实现用舍入后的 - // `3.70ms` 换算得到 `270.27 Hz`,与输入框不一致)。 - // 两条路径都用真实值 ⇒ 统计行与输入框天然一致。 - const auto intervalWithFrequency = [](double seconds) -> std::wstring - { - std::wstring text = FormatDurationCompound(seconds); - if (std::isfinite(seconds) && seconds > 0.0) - { - text += L"("; - text += FormatFrequencyText(1.0 / seconds); - text += L")"; - } - return text; - }; - if (rowIndex < layout.rowInfoMaxInterval.size()) - { - paintInfoRow(layout.rowInfoMaxInterval[rowIndex], kResampleMaxIntervalLabel, - hasInterval ? intervalWithFrequency(maxInterval) : L"—"); - } - if (rowIndex < layout.rowInfoMinInterval.size()) - { - paintInfoRow(layout.rowInfoMinInterval[rowIndex], kResampleMinIntervalLabel, - hasInterval ? intervalWithFrequency(minInterval) : L"—"); - } - const double interval = (resampleItem->interval > 0.0) - ? resampleItem->interval - : minInterval; - const bool editingInterval = - (m_editTarget == PlotEditTarget::RESAMPLE_INTERVAL) && - (m_editLane == static_cast(index)) && (m_editMeanRef == ref); - const bool editingFrequency = - (m_editTarget == PlotEditTarget::RESAMPLE_FREQUENCY) && - (m_editLane == static_cast(index)) && (m_editMeanRef == ref); - if (rowIndex < layout.rowIntervalField.size()) - { - // 非编辑态:自适应单位 + 小数(与「最大/最小数据间隔」同一套单位形式)。 - // 编辑态:只显示**秒**数值(不带单位),由 `PaintEditField` 绘制。 - paintInputRow(layout.rowIntervalField[rowIndex], kResampleIntervalLabel, - FormatIntervalText(interval), editingInterval); - } - if (rowIndex < layout.rowFrequencyField.size()) - { - // ⚠️ 频率同样用**真实间隔**换算(与时间间隔行同源)⇒ `270.517 Hz`。 - const double hertz = (interval > 0.0) ? (1.0 / interval) : 0.0; - // 非编辑态:频率单位自适应(Hz/kHz/MHz/GHz);编辑态:只显示 Hz 数值。 - paintInputRow(layout.rowFrequencyField[rowIndex], kResampleFrequencyLabel, - (hertz > 0.0) ? FormatFrequencyText(hertz) : L"—", - editingFrequency); - } + PaintPanelResampleRows(g, layout, rowIndex, index, ref, *resampleItem); continue; } @@ -669,7 +555,125 @@ namespace jdi PaintPanelScrollBar(g, layout, *m_theme); } - /** + /** + * @brief 「重采样」面板**单个信号的参数区**(四行)。 + * + * 从 `PaintPanelListRows` 里整块提取(第 38 轮):这四行的排布(两行只读统计 + + * 两行联动输入框、标签在左数值在右)与其它面板的滑块参数行完全不同, + * 混在同一个行循环里只会让"条目栏 / 参数行 / 重采样行"三种排版互相纠缠。 + * **零逻辑改动**,判据 = `rowgeom -mode compare` + `panels -mode compare` 全绿。 + */ + void PlotView::PaintPanelResampleRows(gd::Graphics& g, const EdgePanelLayout& layout, + std::size_t rowIndex, std::size_t laneIndex, + const SignalRef& ref, const Lane::ResampleItem& item) + { + const std::size_t index = laneIndex; + const float labelWidth = + UiMeasureTextWidth(m_fonts->uiSmall, kResampleMaxIntervalLabel); + const auto paintInfoRow = [&](const gd::RectF& row, const wchar_t* label, + const std::wstring& text) + { + if (row.Width <= 0.0f) + { + return; + } + UiTextClipped(g, label, m_fonts->uiSmall, + gd::RectF(row.X, row.Y, std::max(10.0f, labelWidth), row.Height), + m_theme->textMuted, TextAlign::LEFT, layout.content); + // ⚠️ 统计数字**绝不省略**(用户 2026-11-03 要求): + // 面板宽度已按真实文本量宽(`EdgePanelWidth` 逐信号实测), + // 因此这里用 `TextAlign::RIGHT` + 完整矩形即可; + // 万一仍被窗口边界夹住,也宁可超出面板右缘(由内容区裁剪兜底), + // **不要**退化成 `…` 让用户看不到真实数值。 + UiTextClipped(g, text, m_fonts->monoSmall, + gd::RectF(row.X + labelWidth + 8.0f * m_scale, row.Y, + std::max(10.0f, row.Width - labelWidth - 8.0f * m_scale), + row.Height), + m_theme->textStrong, TextAlign::RIGHT, layout.content); + }; + const auto paintInputRow = [&](const gd::RectF& field, const wchar_t* label, + const std::wstring& text, bool editing) + { + if (field.Width <= 0.0f) + { + return; + } + // 标签与数值同高:标签从内容区左缘画到字段左缘。 + UiTextClipped( + g, label, m_fonts->uiSmall, + gd::RectF(layout.content.X, field.Y, + std::max(10.0f, field.X - 8.0f * m_scale - layout.content.X), + field.Height), + m_theme->textMuted, TextAlign::LEFT, layout.content); + if (editing) + { + return; // 正在编辑:内容由 `PaintEditField` 画 + } + const gd::RectF box(field.X, field.Y + 2.0f * m_scale, field.Width, + std::max(10.0f, field.Height - 4.0f * m_scale)); + UiFillRoundRect(g, box, 3.0f * m_scale, m_theme->pressed); + UiDrawRoundRect(g, box, 3.0f * m_scale, m_theme->border, 1.0f); + UiTextClipped(g, text, m_fonts->monoSmall, + gd::RectF(box.X + 4.0f * m_scale, box.Y, + std::max(6.0f, box.Width - 8.0f * m_scale), box.Height), + m_theme->textStrong, TextAlign::LEFT, layout.content); + }; + const bool hasInterval = (item.maxInterval > 0.0) && (item.minInterval > 0.0); + // 「最大 / 最小数据间隔」= 自适应单位的**小数**文本(绝不用科学计数法), + // 末尾追加一个括号内的「对应频率」尾缀(单位在 Hz/kHz/MHz/GHz 之间自适应)。 + // + // ⚠️⚠️ 全部使用**真实值**(用户 2026-11-03 明确要求): + // * 时间间隔:`FormatDurationCompound` 会把次级单位保留 3 位小数 + // ⇒ `0.003696631s` 显示为 `3ms 696.631us`(旧实现舍入到 3 位有效数字 + // 得到 `3ms 700us`,把真实尾数抹掉了); + // * 频率:由**未舍入的真实间隔**换算 ⇒ `270.517 Hz`(旧实现用舍入后的 + // `3.70ms` 换算得到 `270.27 Hz`,与输入框不一致)。 + // 两条路径都用真实值 ⇒ 统计行与输入框天然一致。 + const auto intervalWithFrequency = [](double seconds) -> std::wstring + { + std::wstring text = FormatDurationCompound(seconds); + if (std::isfinite(seconds) && seconds > 0.0) + { + text += L"("; + text += FormatFrequencyText(1.0 / seconds); + text += L")"; + } + return text; + }; + if (rowIndex < layout.rowInfoMaxInterval.size()) + { + paintInfoRow(layout.rowInfoMaxInterval[rowIndex], kResampleMaxIntervalLabel, + hasInterval ? intervalWithFrequency(item.maxInterval) : L"—"); + } + if (rowIndex < layout.rowInfoMinInterval.size()) + { + paintInfoRow(layout.rowInfoMinInterval[rowIndex], kResampleMinIntervalLabel, + hasInterval ? intervalWithFrequency(item.minInterval) : L"—"); + } + const double interval = (item.interval > 0.0) ? item.interval : item.minInterval; + const bool editingInterval = (m_editTarget == PlotEditTarget::RESAMPLE_INTERVAL) && + (m_editLane == static_cast(index)) && (m_editMeanRef == ref); + const bool editingFrequency = (m_editTarget == PlotEditTarget::RESAMPLE_FREQUENCY) && + (m_editLane == static_cast(index)) && (m_editMeanRef == ref); + if (rowIndex < layout.rowIntervalField.size()) + { + // 非编辑态:自适应单位 + 小数(与「最大/最小数据间隔」同一套单位形式)。 + // 编辑态:只显示**秒**数值(不带单位),由 `PaintEditField` 绘制。 + paintInputRow(layout.rowIntervalField[rowIndex], kResampleIntervalLabel, + FormatIntervalText(interval), editingInterval); + } + if (rowIndex < layout.rowFrequencyField.size()) + { + // ⚠️ 频率同样用**真实间隔**换算(与时间间隔行同源)⇒ `270.517 Hz`。 + const double hertz = (interval > 0.0) ? (1.0 / interval) : 0.0; + // 非编辑态:频率单位自适应(Hz/kHz/MHz/GHz);编辑态:只显示 Hz 数值。 + paintInputRow(layout.rowFrequencyField[rowIndex], kResampleFrequencyLabel, + (hertz > 0.0) ? FormatFrequencyText(hertz) : L"—", editingFrequency); + } + } + + +/** * @brief Marker panel content: per-signal group bars (collapsible) + table header + * fixed-point rows, with sticky group bars. * diff --git a/Documents/Code_Review_Plan.md b/Documents/Code_Review_Plan.md index 82222bf..79abde2 100644 --- a/Documents/Code_Review_Plan.md +++ b/Documents/Code_Review_Plan.md @@ -307,3 +307,39 @@ `SetAllVisible(false)`,而"全部不可见 ⇒ 丢弃网格"是既有设计 (`RebuildLanes` 的 MERGED 分支),固定点/面板开关会随之清空 —— 该场景要**重新播**。 +--- + +## 十、第 38 轮追加(2026-11-04):§14.5 第 1 项(删死列)+ 第 3 项前半(拆重采样行) + +> 触发:用户要求「按照上一轮报告中的 §14.5 下一步(按价值排序)继续推进」。 + +| 节点 | 内容 | 验收标准 | +| --- | --- | --- | +| **P31** | 删除 `EdgePanelLayout::rowPoints` / `rowIterations`(写入方只剩 `AppendRow`,读取方只剩调试框与缓存键,几何恒为空) | `rowgeom -mode compare` 的差异**只**来自这两列(64 条键,0 条其它差异);`panels -mode compare` 像素不变 | +| **P32** | 从 `PaintPanelListRows` 提取 `PaintPanelResampleRows`("四行只读统计 + 联动输入框"这一段) | 面板像素哈希(尤其 `resample`)与逐行几何**逐项不变** | + +### 10.1 为什么"删掉一列"也可以证明 + +`rowgeom` 的两层基线正好给出**可证伪**的等价性: + +* 旧基线 983 行、新基线 919 行;集合差 = 只在旧里的 **64** 行、只在新里的 **0** 行; +* 那 64 行**全部**属于 `rowPoints` / `rowIterations`(脚本按正则断言,非人工核对); +* ⇒ 其余 919 条键**逐字节相同**,也就是说"删掉的两列从没被赋予过非空矩形"这一判断被基线证实。 + +### 10.2 本轮新增硬约束 + +1. **`EdgePanelLayout` 行相关的平行数组现在是 16 个**(原 18)。任何"列清单" + (`rowgeom` 导出、长度不变式、调试框、缓存键)都要同步;`AppendRow` 里少推一列 + 仍会被长度不变式当场抓到。 +2. **只读统计/输入框这类"专属排版"要独立成函数**:`PaintPanelResampleRows` 的四行 + 与其它面板的滑块参数行没有共同点,混在同一个行循环里只会让三种排版互相纠缠。 +3. **§14.5 第 2 项(完整结构合并)仍处于"闸门关闭"状态**,未做(理由见 Report §15.3)。 + +### 10.3 §14.5 第 3 项的完成度 + +| 段落 | 状态 | +| --- | --- | +| 重采样行(四行只读+输入) | ✅ 已提取为 `PaintPanelResampleRows` | +| 通用参数行(中心轴统计 + 两行滑块) | ⏸ 未提取:依赖 3 个 kind 标志 + 2 套拖拽/编辑状态 + 2 个滑块 lambda,提取需传 9 个参数,可读性收益低于参数表本身 | +| 条目栏(三角 + 名称 + 复选框 + 悬停) | 保留在 `PaintPanelListRows` 里(它是三种排版的公共前缀) | + diff --git a/Documents/Code_Review_Progress.md b/Documents/Code_Review_Progress.md index 9c8ca8b..79f11fa 100644 --- a/Documents/Code_Review_Progress.md +++ b/Documents/Code_Review_Progress.md @@ -491,3 +491,62 @@ SUMMARY checks=32 failed=1 groups=1 result=FAIL | `rowgeom -mode compare -data MF4-Record.MF4` | `detail 1304 键 + scale 7 键`(566 行),0 差异 | | `panels -mode compare` | 7 个面板像素哈希 + 几何逐项不变 | +--- + +## 第 38 轮(2026-11-04):删死列 + 拆重采样参数行(§14.5 第 1 / 第 3 项) + +### P31 删除 `rowPoints` / `rowIterations` —— ✅ 完成 + +审计(§14.5 第 1 项的前提)在三处确认它们是**死列**: + +| 位置 | 原用途 | 现状 | +| --- | --- | --- | +| 写入 | `AppendRow` 里推两个空矩形 | 这是**唯一**写入点,从未被赋过非空矩形 | +| 读取 | `PlotViewPaint.cpp` 调试框 `AddAll`、`PanelSlotKey` 的 mix 循环 | 都是"遍历即忘",不产生任何可见效果 | +| 绘制 | 无 | 旧式"点数 / 迭代次数"输入框早被滑块版(`rowLevelTracks` / `rowIterTracks`)取代 | + +改动:删两个 `std::vector`(`EdgePanelLayout` 少 48 字节)、`AppendRow` 少两次 `push_back`、 +调试框与缓存键各少两个循环、`rowgeom` 导出与两处长度不变式各少两项(16 列)。 + +**等价性被基线证明**(这是本轮最值得记的一条方法论): + +```text +old lines = 983, new lines = 919 +only in OLD = 64, only in NEW = 0 +only-in-OLD lines NOT belonging to rowPoints/rowIterations = 0 +``` + +* 983 − 919 = 64,正好是两列在 7 个面板上的键数(`count` 键 + 每行键); +* 其余 919 条键**逐字节相同** ⇒ "这两列从未有非空值"的判断不是靠眼睛,而是靠基线证伪。 +* 随后用新代码重新 `record` 正式基线(`rowgeom-baseline.txt`),再次 `compare` 归零。 + +### P32 提取 `PaintPanelResampleRows` —— ✅ 完成 + +`PaintPanelListRows` 里 `if (resamplePanel) { …四行… continue; }` 这 **132 行**整块提取为 +`PlotView::PaintPanelResampleRows(g, layout, rowIndex, laneIndex, ref, item)`: + +* 两个局部 lambda(`paintInfoRow` 只读统计行 / `paintInputRow` 联动输入框)随之迁移; +* `maxInterval` / `minInterval` 两个"拷贝一份再说"的局部量直接用 `item.maxInterval` / + `item.minInterval`(原来是纯拷贝,无语义差别); +* 调用点只剩 3 行(含注释),`PaintPanelListRows` 现在只有"条目栏 + 通用参数行"两段。 + +### §14.5 第 3 项的完成度(诚实记录) + +| 段落 | 状态 | 理由 | +| --- | --- | --- | +| 重采样行 | ✅ 已提取 | 排版与其它面板完全不同、入口/出口干净(`continue` 收尾) | +| 通用参数行(中心轴统计 + 两行滑块) | ⏸ 未提取 | 依赖 3 个 kind 标志 + 2 套状态(`m_dragMode`/`m_editTarget`)+ 2 个滑块 lambda,签名要约 9 个参数,可读性收益低于参数表成本 | +| 条目栏 | 保留 | 三角 + 名称 + 复选框 + 悬停是三种排版的公共前缀 | + +### 本轮验证 + +| 项 | 结果 | +| --- | --- | +| `make release`(全量) | 零 error / 零 warning;产物 785,408 → **784,896 B**(−512) | +| `make -C Tools` | 零 error / 零 warning | +| `UnitTests` | `checks=260 failed=0 cases=19` | +| `OffscreenChecks -all` | 7/7 PASS,`checks=127 failed=0` | +| `panels -mode compare` | 7 个面板像素哈希 + 几何**逐项不变**(含 `resample=2BFC74B3D9B3239C`) | +| `rowgeom -mode compare` | 919 + 7 键,0 差异 | +| 行数 | `PlotViewPaint.cpp` 5575 → 5565;`PlotViewPanelPaint.cpp` 1016 → 1020(净增 4 行:函数头 + 文档) | + diff --git a/Documents/Code_Review_Report.md b/Documents/Code_Review_Report.md index c90b78b..53df3bb 100644 --- a/Documents/Code_Review_Report.md +++ b/Documents/Code_Review_Report.md @@ -610,3 +610,76 @@ Tools/README.md 目录结构、作用、测试项、验收标准、A/B 否则不建议动。 3. `PaintPanelListRows` 再拆三段(纯可读性)。 +--- + +## 十五、第 38 轮:§14.5 第 1 / 第 3 项 + +### 15.1 结论 + +| §14.5 建议 | 结果 | +| --- | --- | +| 1. `rowPoints` / `rowIterations` 清理 | ✅ 删除(`EdgePanelLayout` 16 列),产物 −512 B,基线证明等价 | +| 2. 完整结构合并(`vector` + 列视图) | ⏸ **未做**,闸门仍关闭(理由见 §15.3) | +| 3. `PaintPanelListRows` 再拆 | ◑ 重采样行已提取(132 行),参数行未提取(理由见 §15.4) | + +### 15.2 "删掉一列"的等价性怎么证明 + +`rowgeom` 让"删除死列"从"我看过没人用"变成**可验证**: + +```text +old lines = 983, new lines = 919 +only in OLD = 64, only in NEW = 0 +only-in-OLD lines NOT belonging to rowPoints/rowIterations = 0 +``` + +* 唯一差异就是被删两列的 64 条键;其余 919 条**逐字节相同**; +* 因为 `rowgeom` 导出的是**每个矩形的每个字段**,"这两列恒为空"这一前提被直接证伪/证实, + 不依赖人工 grep 的完整性。 + +这给出一条可复用的方法:**先有全字段基线,再删字段**;否则"删掉的东西没人用"永远只是断言。 + +### 15.3 为什么 §14.5 第 2 项仍然不做 + +报告本人的判据是"若要做,必须同时给 `EdgePanelLayout` 加视图自检,否则不建议动"。 +本轮把这一步的门槛量化了: + +| 方案 | 改动面 | 失败可见性 | +| --- | --- | --- | +| A:`std::vector` + 列视图(`PanelColumn`) | 242 处读点(其中 **127** 处在 `PlotViewInteraction.cpp` 的命中判定里)+ 自定义拷贝/赋值 + 视图自检 | 拷贝后忘重绑 = **静默悬垂**,只能靠新增自检兜住 | +| B:`std::vector` + 具名成员访问(无视图) | 同样是 242 处,但所有访问变成 `rows[i].check`,**结构上不可能悬垂** | 编译期全查;无新风险 | + +> 结论:真要做就做 **B**(无视图),A 的"零改动读点"是幻觉 —— 省下的正则改写换来一个 +> 必须靠新自检才能发现的隐患。B 的代价是 242 处机械替换(含 30 余处 range-for 与 `.size()`), +> 适合单独一轮、以 `rowgeom` + `panels` 双基线为判据,而不是塞在本轮末尾。 + +另外现在"漏推一列"已被 `AppendRow` + 长度不变式双重挡住, +B 的**剩余收益**主要是"单一分配 + 少 15 个 vector 头"(`EdgePanelLayout` 每实例省约 360 B +头部,566 个网格时约 200 KB,但那是**布局对象**、每帧临时构造,实际峰值影响很小)。 +因此 B 的优先级应低于"面板结构再简化"的实际痛点,属于**整洁性收益**而非正确性收益。 + +### 15.4 §14.5 第 3 项的取舍 + +| 段落 | 状态 | +| --- | --- | +| 重采样行(132 行:两行只读统计 + 两行联动输入框) | ✅ 提取为 `PaintPanelResampleRows`,零逻辑改动 | +| 通用参数行(中心轴统计 + 两行滑块) | ⏸ 提取需 9 个参数(`layout`/`rowIndex`/`laneIndex`/`ref`/`kind`/`resolved`/`item`/`peakItem`/`tabLane`),签名比函数体更难读 | +| 条目栏 | 保留(三种排版的公共前缀) | + +### 15.5 验证 + +| 项 | 结果 | +| --- | --- | +| `make release` | 零 warning;**784,896 B**(−512) | +| `UnitTests` | 260 / 19 PASS | +| `OffscreenChecks -all` | 7 / 127 PASS | +| `panels -mode compare` | 7 面板像素哈希逐项不变 | +| `rowgeom -mode compare` | 919 + 7 键,0 差异 | + +### 15.6 下一步(更新) + +1. **方案 B(`vector` 无视图)单独一轮**:判据 = 编译通过 + `rowgeom`/`panels` 双基线归零; + 预期收益是"字段重载(`rowRefs` 双语义)"这类问题从**结构上消失** —— 这比省字节更重要。 +2. 若要在 B 之上再做**语义化**:把 `rowRefs` 的双语义拆成 `rows[].ref` + `markerGroups[]`, + 让"MARK 面板 12 列为空"的注释消失。 +3. 面板几何算式重写(§13.6 遗留):基线已就绪,但需先完成 1 或 2。 + diff --git a/Documents/agent_mem.md b/Documents/agent_mem.md index 5023c2b..6089dce 100644 --- a/Documents/agent_mem.md +++ b/Documents/agent_mem.md @@ -10638,3 +10638,60 @@ MARK 面板**只填** `rowNameRects` / `rowNameCloses`,其余 14 列**必须 `EdgePanelLayout` 加"视图自检"(比对 `rows` 容量与视图长度)。 3. `PaintPanelListRows` 再拆三段(纯可读性)。 +--- + +## 2026-11-04 · 第 38 轮(`Code_Review` 分支):删死列 + 拆重采样参数行(§14.5 第 1 / 第 3 项) + +> 规则原文:`Documents/Code_Review_Report.md` §15、`Code_Review_Plan.md` §十、 +> `Code_Review_Progress.md` 第 38 轮。 + +### 1. ✅ 删除 `EdgePanelLayout::rowPoints` / `rowIterations`(行数组 18 → **16**) + +* 写入方**只有** `AppendRow`(推空矩形),读取方**只有**调试框 `AddAll` 与 `PanelSlotKey` 的 mix 循环, + 绘制方零(旧式"点数 / 迭代次数"输入框早被滑块版取代)。 +* 任何"面板行数组清单"现在都是 **16 列**:`rowgeom` 导出、`panels`/`panelscroll` 长度不变式、 + 调试框、缓存键、`AppendRow`。 + +### 2. ⭐ 方法论:**先有全字段基线,再删字段** + +本轮"删两列"的等价性不是靠 grep 完整性,而是靠 `rowgeom` 证伪: + +```text +old lines = 983, new lines = 919 ; only in OLD = 64 ; only in NEW = 0 +only-in-OLD lines NOT belonging to rowPoints/rowIterations = 0 +``` + +⇒ 以后删任何 `EdgePanelLayout` 字段,都走这个流程: +`record 旧 → 改代码 → record 新到独立目录 → 集合差只应包含被删字段 → 再 record 正式基线`。 + +### 3. ✅ 提取 `PaintPanelResampleRows`(132 行) + +`PaintPanelListRows` 里 `if (resamplePanel) { …; continue; }` 整块提取;签名 +`(g, layout, rowIndex, laneIndex, ref, item)`;两个局部 lambda(只读统计行 / 联动输入框)随之迁移。 +**通用参数行(中心轴统计 + 两行滑块)未提取**:需 9 个参数,签名比函数体更难读。 + +### 4. ⛔ §14.5 第 2 项(完整结构合并)**仍未做**,且方案已定 + +| 方案 | 结论 | +| --- | --- | +| A:`vector` + `PanelColumn` 列视图 | ❌ 不做:242 处读点看似"零改动",但自定义拷贝忘重绑 = **静默悬垂**,只能靠新增自检兜住 | +| B:`vector` + 具名成员访问(无视图) | ✅ 若要做就做这个:所有访问变 `rows[i].check`,**结构上不可能悬垂**,可省略自检 | + +B 的规模:242 处读点(**127** 在 `PlotViewInteraction.cpp` 命中判定里)+ 30 余处 range-for / `.size()`。 +**必须单独一轮**,判据 = 编译通过 + `rowgeom`/`panels` 双基线归零。 +B 属**整洁性收益**(漏推一列已被 `AppendRow` + 长度不变式挡住),不是正确性收益 —— 别再当紧急项。 + +### 5. 本轮数字 + +* Release 785,408 → **784,896 B**(−512);零 warning。 +* `UnitTests` 260/19;`OffscreenChecks -all` 7 检查 / 127 断言全 PASS。 +* `panels -mode compare` 7 面板像素哈希逐项不变(含 `resample=2BFC74B3D9B3239C`)。 +* `rowgeom -mode compare` 919 + 7 键 0 差异(基线已按 16 列重新录)。 +* `PlotViewPaint.cpp` 5575 → 5565;`PlotViewPanelPaint.cpp` 1016 → 1020。 + +### 6. 下一步 + +1. 方案 B(`vector` 无视图)单独一轮;顺带把 `rowRefs` 的双语义拆成 + `rows[].ref` + `markerGroups[]`,让"MARK 面板 12 列为空"的注释消失。 +2. 面板几何算式重写(§13.6 遗留):基线已就绪,但需先完成 1。 + diff --git a/Tools/OffscreenChecks/Src/Checks.cpp b/Tools/OffscreenChecks/Src/Checks.cpp index 2b2b4ab..72eed6a 100644 --- a/Tools/OffscreenChecks/Src/Checks.cpp +++ b/Tools/OffscreenChecks/Src/Checks.cpp @@ -345,7 +345,7 @@ namespace jdi // ---- 每一行 / 每一列 ---- // ⚠️ **每一列只按自己的长度导出**,不要假设所有列等长: // 「自由标记」面板复用 `rowRefs` 表示"信号分组",只填 - // `rowNameRects` / `rowNameCloses`,其余 14 列**全为空** + // `rowNameRects` / `rowNameCloses`,其余 12 列**全为空** // (这一点正是 `rowgeom` 第一次运行就抓出来的结构事实)。 // 按 `rowRefs.size()` 统一索引会越界崩溃。 const auto dumpRects = [&text, &key](const char* name, const std::vector& rects) { @@ -371,8 +371,6 @@ namespace jdi dumpRects("rowNameCloses", layout.rowNameCloses); dumpRects("rowTriangles", layout.rowTriangles); dumpRects("rowChecks", layout.rowChecks); - dumpRects("rowPoints", layout.rowPoints); - dumpRects("rowIterations", layout.rowIterations); dumpRects("rowStats", layout.rowStats); dumpRects("rowLevelTracks", layout.rowLevelTracks); dumpRects("rowLevelValues", layout.rowLevelValues); @@ -667,9 +665,9 @@ namespace jdi // 不变式:行的几何数组必须与行数**形状一致**。 // - // ⚠️⚠️ 判据必须**按排版类别**区分 —— 旧版"18 个数组全部全等"是**错的**: + // ⚠️⚠️ 判据必须**按排版类别**区分 —— 旧版"所有数组全部全等"是**错的**: // 「自由标记」面板复用 `rowRefs` 表示"信号分组",只填 - // `rowNameRects` / `rowNameCloses`,其余 14 列**必须为空** + // `rowNameRects` / `rowNameCloses`,其余 12 列**必须为空** // (它真正的行在 `markerRows` / `markerGroups` 里)。 // 这是 `rowgeom` 第一次带真实固定点运行时越界崩溃所暴露的结构事实。 const PanelStyle rowStyle = PanelStyleOf(kind); @@ -685,13 +683,12 @@ namespace jdi else { const std::size_t parallelSizes[] = { - layout.rowHeaders.size(), layout.rowNameRects.size(), - layout.rowTriangles.size(), layout.rowChecks.size(), - layout.rowPoints.size(), layout.rowIterations.size(), - layout.rowStats.size(), layout.rowLevelTracks.size(), - layout.rowLevelValues.size(), layout.rowLevelResets.size(), - layout.rowIterTracks.size(), layout.rowIterValues.size(), - layout.rowIterResets.size(), layout.rowInfoMaxInterval.size(), + layout.rowHeaders.size(), layout.rowNameRects.size(), + layout.rowTriangles.size(), layout.rowChecks.size(), + layout.rowStats.size(), layout.rowLevelTracks.size(), + layout.rowLevelValues.size(), layout.rowLevelResets.size(), + layout.rowIterTracks.size(), layout.rowIterValues.size(), + layout.rowIterResets.size(), layout.rowInfoMaxInterval.size(), layout.rowInfoMinInterval.size(), layout.rowIntervalField.size(), layout.rowFrequencyField.size()}; for (const std::size_t size : parallelSizes) @@ -868,8 +865,6 @@ namespace jdi initial.rowNameRects.size(), initial.rowTriangles.size(), initial.rowChecks.size(), - initial.rowPoints.size(), - initial.rowIterations.size(), initial.rowStats.size(), initial.rowLevelTracks.size(), initial.rowLevelValues.size(), @@ -953,7 +948,7 @@ namespace jdi * * 为什么需要它(Code Review 报告 §13.6 建议 1): * * 像素哈希只能回答"有没有变化",回答不了"**哪一行哪一列**变了"; - * * `EdgePanelLayout` 仍是 18 个平行数组,任何"合并成单一 `PanelRow`"或 + * * `EdgePanelLayout` 仍是 16 个平行数组,任何"合并成单一 `PanelRow`"或 * "重写布局算式"的重构都必须先能**逐项证明等价**; * * 面板改布局时的真实缺陷形态(某列读到别人的矩形)会让**成片的**行错位, * 而像素基线在面板互相重叠时还会被"下层露出"掩盖。 diff --git a/Tools/README.md b/Tools/README.md index a540cca..e013217 100644 --- a/Tools/README.md +++ b/Tools/README.md @@ -214,7 +214,7 @@ OffscreenChecks.exe -run panels -mode compare -out ..\Temp\tools\panel-before * 列表式面板(均值/中心轴/弱峰/重采样/截取):`rowRefs[i]` = 第 i 行的信号; * 「自由标记」面板:`rowRefs[i]` = 第 i 个**信号分组**,真正的条目行在 `markerRows`, - 且只填 `rowNameRects` / `rowNameCloses`,其余 14 列**必须为空**。 + 且只填 `rowNameRects` / `rowNameCloses`,其余 12 列**必须为空**。 所以判据按 `PanelStyleOf(kind).markerGroups` 分流(见 `Checks.cpp` 里 `panels` 的注释)。 早先“18 列全等”的写法对 MARK 面板是**错的**,只因既有场景恰好未触发才没暴露。 -- 2.54.0 From 1463f9d3f89e803894354fc2024fdf6622f27542 Mon Sep 17 00:00:00 2001 From: "DeepSeek V4.1 Flash" <> Date: Wed, 4 Nov 2026 16:18:56 +0800 Subject: [PATCH 20/22] =?UTF-8?q?refactor(panels):=20=E5=B9=B3=E8=A1=8C?= =?UTF-8?q?=E6=95=B0=E7=BB=84=E5=90=88=E5=B9=B6=E4=B8=BA=20std::vector=EF=BC=88=E6=B6=88=E9=99=A4=E9=94=99=E4=BD=8D=E4=B8=8E?= =?UTF-8?q?=E5=AD=97=E6=AE=B5=E9=87=8D=E8=BD=BD=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 对应 Code_Review_Report §15.6 第 1 项(方案 B:有结构,无列视图)。 结构 - EdgePanelLayout 的 16 个平行 std::vector 合并为 std::vector rows, PanelRow 带 17 个具名成员:ref / header / nameRect / nameClose / triangle / check / stats / levelTrack / levelValue / levelReset / iterTrack / iterValue / iterReset / infoMaxInterval / infoMinInterval / intervalField / frequencyField。 - 唯一新增入口:AppendRow(ref, header, nameRect, triangle, check)(返回新行引用)与 AppendMarkerGroupRow(ref, nameRect, nameClose)(「自由标记」分组行)。 参数行/重采样行一律 layout.rows.back().xxx = … 赋值。 - 原 int rows(可见信号数)与新容器同名 ⇒ 改名 signalCount,并注明它与 rows.size() 在 MARK 面板里不相等(只有有固定点的信号才有行)。 消掉的两类问题 1. 某分支漏 push 一列 ⇒ 该列之后所有行读到别人的矩形(静默错位)。现在一行一次成型。 2. rowRefs 字段重载(列表式 = 行信号 / MARK = 分组信号,其余 12 列必须为空)。 现在每行自带 ref,语义由“填哪些字段”表达;不变式简化为语义断言 (每行必须有名称区;MARK 行不得带 header/check/stats/滑块/重采样字段)。 顺带清理 - 合并后 rowIndex < layout.rowXxx.size() 全部变成恒真,批量删除 50+ 处 (含 15 元组长度数组、? x : gd::RectF() 三元判空、if(...){extendRow(...)} 包裹)。 - 调试框与缓存键改为逐行遍历(新增字段只需在 PanelRow 与这两处循环各加一行)。 等价性证明(rowgeom 键名刻意保持不变,故可逐键比对) old keys = 919, new keys = 965 仅存在于旧 = 0;已有键值变化 = 20(全部是 ".count":0 -> N) 仅存在于新 = 46(全部是新增列的 [NNN],值 0.00,0.00,0.00,0.00) ⇒ 逐行逐字段的真实几何零位移;7 个面板像素哈希(含 resample=2BFC74B3D9B3239C)逐项不变。 验证 - make release:零 warning;784,896 → 782,336 B(−2,560);make debug / make -C Tools 同样零 warning - UnitTests:checks=260 failed=0 cases=19 - OffscreenChecks -all:7 检查 / 133 断言(上轮 127)全 PASS - panels -mode compare:7 面板像素哈希逐项不变 - rowgeom -mode compare:965+7 键 0 差异;-data MF4-Record.MF4 时 1268+7 键 0 差异 - 读点:旧 243 处 layout.rowXxx[…] → 现 95 处 layout.rows[…](84 命中判定 / 11 绘制) 过程记录:清理恒真判空时曾用整段替换把「均值滤波」双滑块段误换成「弱峰」双滑块, 编译器报 brace 不平衡,用备份回滚后重做 —— 整段替换必须先精确读出上下文。 文档:Plan §十一、Progress 第 39 轮、Report §16、agent_mem 第 39 轮、Tools/README §4.1/§4.4 --- Components/PlotView/Inc/PlotView.hpp | 187 +++++++------- .../PlotView/Src/PlotViewInteraction.cpp | 237 ++++++++---------- Components/PlotView/Src/PlotViewPaint.cpp | 121 ++++----- .../PlotView/Src/PlotViewPanelPaint.cpp | 199 +++++---------- Components/PlotView/Src/PlotViewPanels.cpp | 36 ++- Documents/Code_Review_Plan.md | 37 +++ Documents/Code_Review_Progress.md | 79 ++++++ Documents/Code_Review_Report.md | 70 ++++++ Documents/agent_mem.md | 63 +++++ Tools/OffscreenChecks/Src/Checks.cpp | 208 +++++++-------- Tools/README.md | 26 +- 11 files changed, 672 insertions(+), 591 deletions(-) diff --git a/Components/PlotView/Inc/PlotView.hpp b/Components/PlotView/Inc/PlotView.hpp index fc8fb69..acbde31 100644 --- a/Components/PlotView/Inc/PlotView.hpp +++ b/Components/PlotView/Inc/PlotView.hpp @@ -1999,7 +1999,14 @@ namespace jdi gd::RectF badge; gd::RectF edit; /**< 面板底部编辑行(无编辑内容时宽度为 0)。 */ gd::RectF button; /**< 面板底部按钮(无按钮时宽度为 0)。 */ - int rows = 0; /**< 统计行数(每条可见信号一行)。 */ + /** + * @brief 该面板统计的**可见信号数**(不是行数!行容器见 `rows`)。 + * + * 它来自“网格里的可见信号数”(`EdgePanelLayoutFor` 开头统计), + * 与 `rows.size()` 在**「自由标记」面板里不相等**(那里只有至少有一个固定点的 + * 信号才有一行)。历史名字叫 `rows`,与新的行容器同名,故改名 `signalCount`。 + */ + int signalCount = 0; /** @brief 表格的一列(左边界 + 宽度,客户区坐标)。 */ struct TableColumn @@ -2018,60 +2025,66 @@ namespace jdi /** @brief 表格三列的几何(`0` 信号名 / `1` 上升沿计数 / `2` 下降沿计数)。 */ std::array tableColumns{}; /** - * @brief 「逐条目」面板的矩形(MEAN / CENTER / PEAK 面板共用同一套排布)。 + * @brief 面板里的一**行**:该行对应的信号 + 它的全部几何。 * - * 索引与 `rowRefs` 一一对应(即网格内可见信号的顺序): - * `rowChecks` 为第一排右侧的复选框;`rowStats` 为第三排的统计结果(中心轴边沿计数专用)。 - * 未勾选的条目对应的矩形均为空。 + * ⚠️⚠️ 2026-11-04(第 39 轮)把原来的 **16 个平行 `std::vector`** 合并成 + * `std::vector rows`。合并的动机不是"省字节",而是消除三类**结构性**缺陷: * - * ⚠️ 2026-11-04 删除了 `rowPoints` / `rowIterations`:它们是**旧式输入框**(点数 / 迭代次数) - * 的矩形,自滑块版排版(`rowLevelTracks` / `rowIterTracks`)上线后就没有绘制方读它们了 —— - * 所有写入都是空矩形,读取方只剩调试框与缓存键。保留它们只会让每次 `AppendRow` - * 多两次 `push_back`、并且误导后续读者以为“这里还有一套输入框”。 + * 1. **漏推一列 ⇒ 该列之后所有行读到别人的矩形**。平行数组靠"每个分支按同一顺序 + * push 恰好 N 项"来维持,任何一处漏推都是静默错位(历史上"改一次布局就出错"的机制)。 + * 现在一个 `PanelRow` 是一个整体,`rows.push_back(row)` 一次成型 —— **不可能错位**。 + * 2. **`rowRefs` 字段重载**:旧结构里「自由标记」面板借用 `rowRefs` 表示"信号分组", + * 其余 12 列必须保持为空,长度不变式只能按面板类别分流判定。现在每行自带 + * `ref`,语义由**填哪些字段**表达,不再有"同一字段两种含义"。 + * 3. 旧结构每实例 16 个 vector 头部(16 × 24 B)且各自独立分配;现在一次分配。 + * + * ⚠️ **不要**改回平行数组,也不要引入"列视图"(`PanelColumn` 持有 `rows` 指针 + + * 成员指针):那样拷贝 `EdgePanelLayout` 后忘记重绑就是**静默悬垂**, + * 没有任何检查能抓到;具名成员访问(`rows[i].check`)在结构上不可能悬垂。 + * + * 字段布局(每一行都拥有全部字段,未使用的**恒为空矩形**,不需要"数组可能为空"的判空): + * + * | 字段 | 含义 | + * | --- | --- | + * | `ref` | 该行的信号(「自由标记」里是**分组**的代表信号) | + * | `header` | 信号色叠底条目栏 / 分组栏(整行宽度) | + * | `nameRect` | 名称区(列表式 = 条目栏;自由标记 = 分组栏;表格 = 名称列) | + * | `nameClose` | 名称右侧 ✕(仅「自由标记」:清除该信号全部固定点) | + * | `triangle` | 左侧展开 / 收起三角(仅勾选后出现) | + * | `check` | 右端复选框 | + * | `stats` | 「中心轴边沿计数」的上升 / 下降沿统计行 | + * | `levelTrack` / `levelValue` / `levelReset` | 参数滑块 1(均值点值 / 弱峰增强等级) | + * | `iterTrack` / `iterValue` / `iterReset` | 参数滑块 2(均值迭代次数 / 弱峰迭代次数) | + * | `infoMaxInterval` / `infoMinInterval` | 「重采样」两行只读统计 | + * | `intervalField` / `frequencyField` | 「重采样」两行联动输入框(间隔秒 / 频率 Hz) | */ - std::vector rowChecks; - std::vector rowStats; + struct PanelRow + { + SignalRef ref; /**< 该行的信号(MARK 面板 = 分组的代表信号)。 */ + gd::RectF header; /**< 信号色叠底条目栏 / 分组栏(整行宽度)。 */ + gd::RectF nameRect; /**< 名称区(列表式条目栏 / 分组栏 / 表格名称列)。 */ + gd::RectF nameClose; /**< 名称右侧 ✕(仅「自由标记」)。 */ + gd::RectF triangle; /**< 左侧展开 / 收起三角。 */ + gd::RectF check; /**< 右端复选框。 */ + gd::RectF stats; /**< 「中心轴」上升 / 下降沿统计行。 */ + gd::RectF levelTrack; /**< 参数滑块 1 轨道。 */ + gd::RectF levelValue; /**< 参数滑块 1 数值文本。 */ + gd::RectF levelReset; /**< 参数滑块 1 复位按钮。 */ + gd::RectF iterTrack; /**< 参数滑块 2 轨道。 */ + gd::RectF iterValue; /**< 参数滑块 2 数值文本。 */ + gd::RectF iterReset; /**< 参数滑块 2 复位按钮。 */ + gd::RectF infoMaxInterval; /**< 「重采样」最大数据间隔(只读)。 */ + gd::RectF infoMinInterval; /**< 「重采样」最小数据间隔(只读)。 */ + gd::RectF intervalField; /**< 「重采样」间隔输入框。 */ + gd::RectF frequencyField; /**< 「重采样」频率输入框。 */ + }; /** - * @brief 「弱峰调整」每个被勾选信号自己的两行**滑块**(与 `rowRefs` 同序)。 + * @brief 面板的全部行(**唯一的**行容器:顺序 = 从上到下)。 * - * `rowLevelTracks/rowLevelValues` = 增强等级滑块与它右侧的数值文本; - * `rowIterTracks/rowIterValues` = 迭代次数滑块与它右侧的数值文本 - *(**点值数值文本可以把滑块上限改为别的正整数**)。 - * 未勾选或非 PEAK 面板时对应元素为空矩形(数组也可能为空)。 + * ⚠️ 只有 `AppendRow` / `AppendMarkerGroupRow` 可以追加,不要 `rows.push_back` + * (那会绕过"字段一次成型"的保证)。 */ - std::vector rowLevelTracks; - std::vector rowLevelValues; - std::vector rowIterTracks; - std::vector rowIterValues; - /** - * @brief 「弱峰调整」每行滑块右侧的**复位按钮**矩形(与 `rowLevelTracks` 同序)。 - * - * 点击把该行参数恢复为默认值(幅度 0 / 迭代次数 1 且上限回到初始上限)。 - * 未勾选或非 PEAK 面板时对应元素为空矩形(数组也可能为空)。 - */ - std::vector rowLevelResets; - std::vector rowIterResets; - /** - * @brief 「重采样」面板每个被勾选信号的四行内容(与 `rowRefs` 同序)。 - * - * `rowInfoMaxInterval` / `rowInfoMinInterval` = 只读统计(最大 / 最小数据间隔); - * `rowIntervalField` / `rowFrequencyField` = 两行**联动输入框**(间隔秒 / 频率 Hz)。 - * 未勾选或非 RESAMPLE 面板时对应元素为空矩形(数组也可能为空)。 - */ - std::vector rowInfoMaxInterval; - std::vector rowInfoMinInterval; - std::vector rowIntervalField; - std::vector rowFrequencyField; - /** - * @brief 「均值滤波」面板每个信号的**信号色叠底标题栏**矩形(与 `rowRefs` 同序)。 - * - * 排版与「自由标记」面板的信号分组栏一致(左侧展开三角 + 信号名), - * 但右端是**复选框**(`rowChecks`)而不是 ✕。未勾选时标题栏仍显示(用于勾选)。 - */ - std::vector rowHeaders; - /** @brief 「均值滤波」面板每个信号标题栏左侧的**展开 / 收起三角**(仅勾选后出现)。 */ - std::vector rowTriangles; - std::vector rowRefs; + std::vector rows; /** * @brief 「自由标记」面板的一行(一个固定点)。 @@ -2174,18 +2187,6 @@ namespace jdi gd::RectF clipDestructiveButton; /** @brief 「选区截取」需要双光标时的提示文案矩形(未启用双光标时使用)。 */ gd::RectF clipHint; - /** - * @brief 「自由标记」:每个信号名右侧的「清除该信号全部固定点」✕。 - * - * 与 `rowRefs` 一一对应;该信号没有任何固定点时为空矩形。 - */ - std::vector rowNameCloses; - /** - * @brief 「自由标记」:每个信号**名称行**的矩形(与 `rowRefs` 一一对应)。 - * - * 绘制只读这个矩形(与 `rowNameCloses` 同一套横坐标),人为主观反推容易与布局漂移。 - */ - std::vector rowNameRects; /** * @brief 「自由标记」面板的**六列**文本区 * (① 序号 ② 标记值 ③ 枚举文本 ④ 增量 ⑤ 时间点 ⑥ 时间间隔)。 @@ -2215,40 +2216,54 @@ namespace jdi }; /** - * @brief 追加一**行**(条目栏 / 名称 / 三角 / 复选框),其余列一律补空矩形。 + * @brief 追加一**行**(条目栏 / 名称 / 三角 / 复选框),其余字段一律留空。 * - * ⚠️⚠️ 这是**唯一**允许新增行的入口。面板每行对应 16 个平行数组 - * (`rowRefs` / `rowHeaders` / `rowChecks` / `rowLevelTracks` / …); - * 如果每个排版分支各写一遍 `push_back`,只要某分支漏推一项, - * 那一列之后的所有行都会**读到别人的矩形**("改一次布局就出错"的典型机制)。 - * 现在:新增行的分支只调本函数,**新增一列只需改这里一处**; - * 参数行 / 重采样行则用 `rowXxx.back() = …` 赋值(长度天然对齐)。 + * ⚠️⚠️ 这是**唯一**允许新增列表式行的入口(「自由标记」的分组行走 + * `AppendMarkerGroupRow`)。一个 `PanelRow` 是一个整体,字段一次成型, + * 因此**不可能**出现"某列漏推 ⇒ 之后所有行错位"(旧平行数组结构的老毛病)。 + * 参数行 / 重采样行一律用 `rows.back().xxx = …` **赋值**。 * * @param [IN] ref 该行对应的信号。 * @param [IN] header 信号色叠底条目栏(整行宽度)。 * @param [IN] nameRect 名称(表格里的名称列)。 * @param [IN] triangle 左侧展开 / 收起三角(不适用时传空矩形)。 * @param [IN] check 右端复选框。 + * @return 新行的引用(调用方可直接赋值参数行)。 */ - void AppendRow(const SignalRef& ref, const gd::RectF& header, const gd::RectF& nameRect, - const gd::RectF& triangle, const gd::RectF& check) + PanelRow& AppendRow(const SignalRef& ref, const gd::RectF& header, + const gd::RectF& nameRect, const gd::RectF& triangle, + const gd::RectF& check) { - rowRefs.push_back(ref); - rowHeaders.push_back(header); - rowNameRects.push_back(nameRect); - rowTriangles.push_back(triangle); - rowChecks.push_back(check); - rowStats.push_back(gd::RectF()); - rowLevelTracks.push_back(gd::RectF()); - rowLevelValues.push_back(gd::RectF()); - rowLevelResets.push_back(gd::RectF()); - rowIterTracks.push_back(gd::RectF()); - rowIterValues.push_back(gd::RectF()); - rowIterResets.push_back(gd::RectF()); - rowInfoMaxInterval.push_back(gd::RectF()); - rowInfoMinInterval.push_back(gd::RectF()); - rowIntervalField.push_back(gd::RectF()); - rowFrequencyField.push_back(gd::RectF()); + PanelRow row; + row.ref = ref; + row.header = header; + row.nameRect = nameRect; + row.triangle = triangle; + row.check = check; + rows.push_back(row); + return rows.back(); + } + + /** + * @brief 追加一个**「自由标记」信号分组行**(只有名称与 ✕ 有几何)。 + * + * 单独成一个入口(而不是复用 `AppendRow`)是因为语义不同:这里的 `ref` 是 + * **分组**的代表信号,`nameRect` / `nameClose` 是分组栏与它右端的 ✕; + * `header` / `triangle` / `check` 都不使用(分组栏自己的几何在 + * `markerGroups` 里,那里还需要 `tableHeader` / `pinned` 等行外状态)。 + * + * @param [IN] ref 该分组的代表信号。 + * @param [IN] nameRect 分组栏矩形。 + * @param [IN] nameClose 分组栏右端 ✕ 的命中矩形。 + */ + void AppendMarkerGroupRow(const SignalRef& ref, const gd::RectF& nameRect, + const gd::RectF& nameClose) + { + PanelRow row; + row.ref = ref; + row.nameRect = nameRect; + row.nameClose = nameClose; + rows.push_back(row); } }; diff --git a/Components/PlotView/Src/PlotViewInteraction.cpp b/Components/PlotView/Src/PlotViewInteraction.cpp index c637426..5df6eca 100644 --- a/Components/PlotView/Src/PlotViewInteraction.cpp +++ b/Components/PlotView/Src/PlotViewInteraction.cpp @@ -1127,36 +1127,36 @@ namespace jdi Tab().Lanes()[static_cast(panelLane)]; if (listCursor) { - for (std::size_t rowIndex = 0; rowIndex < layout.rowHeaders.size(); ++rowIndex) + for (std::size_t rowIndex = 0; rowIndex < layout.rows.size(); ++rowIndex) { - if (layout.rowHeaders[rowIndex].Width > 0.0f && - UiPointInRect(layout.rowHeaders[rowIndex], static_cast(x), + if (layout.rows[rowIndex].header.Width > 0.0f && + UiPointInRect(layout.rows[rowIndex].header, static_cast(x), static_cast(y))) { bool checked = false; if (cursorKind == EdgePanelKind::PEAK) { checked = cursorLane.FindWeakPeakItem( - layout.rowRefs[rowIndex]) != nullptr; + layout.rows[rowIndex].ref) != nullptr; } else if (cursorKind == EdgePanelKind::CENTER) { checked = cursorLane.FindEdgeCenterItem( - layout.rowRefs[rowIndex]) != nullptr; + layout.rows[rowIndex].ref) != nullptr; } else if (cursorKind == EdgePanelKind::RESAMPLE) { checked = cursorLane.FindResampleItem( - layout.rowRefs[rowIndex]) != nullptr; + layout.rows[rowIndex].ref) != nullptr; } else if (cursorKind == EdgePanelKind::CLIP) { - checked = cursorLane.ClipEnabled(layout.rowRefs[rowIndex]); + checked = cursorLane.ClipEnabled(layout.rows[rowIndex].ref); } else { checked = cursorLane.FindMeanFilterItem( - layout.rowRefs[rowIndex]) != nullptr; + layout.rows[rowIndex].ref) != nullptr; } // ⚠️⚠️ **「选区截取」的条目栏不可展开**(只有右侧复选框有效): // 因此条目栏本体**绝不能**给手型 —— 否则会误导用户去点它 @@ -1174,14 +1174,12 @@ namespace jdi if (cursorKind == EdgePanelKind::RESAMPLE) { // 两行输入框是可点击控件(点击进入就地编辑)。 - if (rowIndex < layout.rowIntervalField.size() && - UiPointInRect(layout.rowIntervalField[rowIndex], static_cast(x), + if (UiPointInRect(layout.rows[rowIndex].intervalField, static_cast(x), static_cast(y))) { return LoadCursorW(nullptr, IDC_HAND); } - if (rowIndex < layout.rowFrequencyField.size() && - UiPointInRect(layout.rowFrequencyField[rowIndex], static_cast(x), + if (UiPointInRect(layout.rows[rowIndex].frequencyField, static_cast(x), static_cast(y))) { return LoadCursorW(nullptr, IDC_HAND); @@ -1189,50 +1187,43 @@ namespace jdi continue; } const bool hasParams = - rowIndex < layout.rowLevelTracks.size() && - layout.rowLevelTracks[rowIndex].Width > 0.0f; + layout.rows[rowIndex].levelTrack.Width > 0.0f; if (!hasParams) { continue; // 参数行未展开(未勾选 / 已收起)⇒ 该行没有可交互控件 } // 「均值点数 / 调整幅度」数值:均值滤波与中心轴可点击改上限(弱峰不能)。 if (cursorKind != EdgePanelKind::PEAK && - rowIndex < layout.rowLevelValues.size() && - layout.rowLevelValues[rowIndex].Width > 0.0f && - UiPointInRect(layout.rowLevelValues[rowIndex], static_cast(x), + layout.rows[rowIndex].levelValue.Width > 0.0f && + UiPointInRect(layout.rows[rowIndex].levelValue, static_cast(x), static_cast(y))) { return LoadCursorW(nullptr, IDC_HAND); } - if (rowIndex < layout.rowIterValues.size() && - layout.rowIterValues[rowIndex].Width > 0.0f && - UiPointInRect(layout.rowIterValues[rowIndex], static_cast(x), + if (layout.rows[rowIndex].iterValue.Width > 0.0f && + UiPointInRect(layout.rows[rowIndex].iterValue, static_cast(x), static_cast(y))) { return LoadCursorW(nullptr, IDC_HAND); } - if (rowIndex < layout.rowLevelResets.size() && - layout.rowLevelResets[rowIndex].Width > 0.0f && - UiPointInRect(layout.rowLevelResets[rowIndex], static_cast(x), + if (layout.rows[rowIndex].levelReset.Width > 0.0f && + UiPointInRect(layout.rows[rowIndex].levelReset, static_cast(x), static_cast(y))) { return LoadCursorW(nullptr, IDC_HAND); } - if (rowIndex < layout.rowIterResets.size() && - layout.rowIterResets[rowIndex].Width > 0.0f && - UiPointInRect(layout.rowIterResets[rowIndex], static_cast(x), + if (layout.rows[rowIndex].iterReset.Width > 0.0f && + UiPointInRect(layout.rows[rowIndex].iterReset, static_cast(x), static_cast(y))) { return LoadCursorW(nullptr, IDC_HAND); } - if (rowIndex < layout.rowLevelTracks.size() && - WeakPeakSliderHit(layout.rowLevelTracks[rowIndex], m_scale, + if (WeakPeakSliderHit(layout.rows[rowIndex].levelTrack, m_scale, static_cast(x), static_cast(y))) { return LoadCursorW(nullptr, IDC_SIZEWE); } - if (rowIndex < layout.rowIterTracks.size() && - WeakPeakSliderHit(layout.rowIterTracks[rowIndex], m_scale, + if (WeakPeakSliderHit(layout.rows[rowIndex].iterTrack, m_scale, static_cast(x), static_cast(y))) { return LoadCursorW(nullptr, IDC_SIZEWE); @@ -1542,13 +1533,13 @@ namespace jdi } Lane::WeakPeakItem& item = lane.EnsureWeakPeakItem(m_peakDragRef); const int rowIndex = PeakRowIndexOf(layout, m_peakDragRef); - if (rowIndex < 0 || rowIndex >= static_cast(layout.rowLevelTracks.size()) || - layout.rowLevelTracks[static_cast(rowIndex)].Width <= 0.0f) + if (rowIndex < 0 || rowIndex >= static_cast(layout.rows.size()) || + layout.rows[static_cast(rowIndex)].levelTrack.Width <= 0.0f) { break; } const double ratio = - UiSliderRatioFromX(layout.rowLevelTracks[static_cast(rowIndex)], fx); + UiSliderRatioFromX(layout.rows[static_cast(rowIndex)].levelTrack, fx); // 滑块量程 -200% – +200%:0 在正中间。 // 滑块量程 -400% – +400%(三组规则的梯度区间):0 在正中间。 item.amountPercent = std::max( @@ -1575,13 +1566,13 @@ namespace jdi } Lane::WeakPeakItem& item = lane.EnsureWeakPeakItem(m_peakDragRef); const int rowIndex = PeakRowIndexOf(layout, m_peakDragRef); - if (rowIndex < 0 || rowIndex >= static_cast(layout.rowIterTracks.size()) || - layout.rowIterTracks[static_cast(rowIndex)].Width <= 0.0f) + if (rowIndex < 0 || rowIndex >= static_cast(layout.rows.size()) || + layout.rows[static_cast(rowIndex)].iterTrack.Width <= 0.0f) { break; } const double ratio = - UiSliderRatioFromX(layout.rowIterTracks[static_cast(rowIndex)], fx); + UiSliderRatioFromX(layout.rows[static_cast(rowIndex)].iterTrack, fx); const int peakMax = std::max(kWeakPeakIterationsMin, std::min(kWeakPeakIterationsMaxLimit, item.maxIterations)); item.iterations = std::max( @@ -1629,9 +1620,9 @@ namespace jdi const EdgePanelLayout layout = EdgePanelLayoutFor( m_meanDragLane, m_meanDragKind); int rowIndex = -1; - for (std::size_t candidate = 0; candidate < layout.rowRefs.size(); ++candidate) + for (std::size_t candidate = 0; candidate < layout.rows.size(); ++candidate) { - if (layout.rowRefs[candidate] == m_meanDragRef) + if (layout.rows[candidate].ref == m_meanDragRef) { rowIndex = static_cast(candidate); break; @@ -1641,15 +1632,15 @@ namespace jdi { break; } - const std::vector& tracks = - pointsMode ? layout.rowLevelTracks : layout.rowIterTracks; - if (static_cast(rowIndex) >= tracks.size() || - tracks[static_cast(rowIndex)].Width <= 0.0f) + // 两行滑块的轨道由 `pointsMode` 决定:这里取**引用**而不是"整个数组", + // 因为行字段现在同属一个 `PanelRow`(不再有可切换的平行数组)。 + const gd::RectF& track = pointsMode ? layout.rows[static_cast(rowIndex)].levelTrack + : layout.rows[static_cast(rowIndex)].iterTrack; + if (track.Width <= 0.0f) { break; } - const double ratio = - UiSliderRatioFromX(tracks[static_cast(rowIndex)], fx); + const double ratio = UiSliderRatioFromX(track, fx); if (pointsMode) { // ⚠️ 下限:均值滤波 = 1(用户 2026-10-30 要求不能为 0);中心轴 = 0(0 = 不平滑)。 @@ -2006,20 +1997,16 @@ namespace jdi const EdgePanelLayout layout = EdgePanelLayoutFor(panelLane, hoverKind); if (UiPointInRect(layout.content, static_cast(x), static_cast(y))) { - for (std::size_t rowIndex = 0; rowIndex < layout.rowRefs.size(); ++rowIndex) + for (std::size_t rowIndex = 0; rowIndex < layout.rows.size(); ++rowIndex) { - if (rowIndex >= layout.rowHeaders.size()) - { - break; - } - const gd::RectF& headerRect = layout.rowHeaders[rowIndex]; + const gd::RectF& headerRect = layout.rows[rowIndex].header; // 半截滚出的行**也算命中**:绘制时行本来就会被裁剪成半截显示, // 若这里要求“完整落在内容区内”,就会出现“看得见却悬停不到”的不一致。 // 命中点本身已被限定在内容区里,因此不会出现“看得见一半却点到”的幽灵热区。 if (UiPointInRect(headerRect, static_cast(x), static_cast(y))) { hoverLane = panelLane; - hoverRef = layout.rowRefs[rowIndex]; + hoverRef = layout.rows[rowIndex].ref; break; } } @@ -2042,17 +2029,17 @@ namespace jdi static_cast(hoverPanelKind) == EdgePanelKind::PEAK) { const EdgePanelLayout resetLayout = EdgePanelLayoutFor(panelLane, EdgePanelKind::PEAK); - for (std::size_t rowIndex = 0; rowIndex < resetLayout.rowLevelResets.size(); ++rowIndex) + for (std::size_t rowIndex = 0; rowIndex < resetLayout.rows.size(); ++rowIndex) { - const gd::RectF& level = resetLayout.rowLevelResets[rowIndex]; + const gd::RectF& level = resetLayout.rows[rowIndex].levelReset; if (level.Width > 0.0f && UiPointInRect(level, fx, static_cast(y))) { overPeakReset = true; break; } - if (rowIndex < resetLayout.rowIterResets.size()) + if (rowIndex < resetLayout.rows.size()) { - const gd::RectF& iter = resetLayout.rowIterResets[rowIndex]; + const gd::RectF& iter = resetLayout.rows[rowIndex].iterReset; if (iter.Width > 0.0f && UiPointInRect(iter, fx, static_cast(y))) { overPeakReset = true; @@ -2456,39 +2443,37 @@ namespace jdi // 条目栏 = 复选框切换 + 已勾选时整行收起/展开(与其它列表式面板一致)。 if (resampleMode) { - for (std::size_t rowIndex = 0; rowIndex < layout.rowRefs.size(); ++rowIndex) + for (std::size_t rowIndex = 0; rowIndex < layout.rows.size(); ++rowIndex) { if (!insideContent) { break; } - const SignalRef& ref = layout.rowRefs[rowIndex]; + const SignalRef& ref = layout.rows[rowIndex].ref; const Lane::ResampleItem* item = meanLane.FindResampleItem(ref); if (item == nullptr) { continue; } // 点击「重采样间隔」输入框 → 编辑**秒**数(不带单位、不用科学计数法)。 - if (rowIndex < layout.rowIntervalField.size() && - layout.rowIntervalField[rowIndex].Width > 0.0f && - UiPointInRect(layout.rowIntervalField[rowIndex], fx, fy)) + if (layout.rows[rowIndex].intervalField.Width > 0.0f && + UiPointInRect(layout.rows[rowIndex].intervalField, fx, fy)) { m_editMeanRef = ref; m_editMeanKind = kind; - const gd::RectF& field = layout.rowIntervalField[rowIndex]; + const gd::RectF& field = layout.rows[rowIndex].intervalField; BeginEdit(PlotEditTarget::RESAMPLE_INTERVAL, index, FormatDurationSeconds(item->interval), field); m_dragMode = PlotDragMode::NONE; return true; } // 点击「重采样频率」输入框 → 编辑频率(Hz,与间隔联动;不带单位)。 - if (rowIndex < layout.rowFrequencyField.size() && - layout.rowFrequencyField[rowIndex].Width > 0.0f && - UiPointInRect(layout.rowFrequencyField[rowIndex], fx, fy)) + if (layout.rows[rowIndex].frequencyField.Width > 0.0f && + UiPointInRect(layout.rows[rowIndex].frequencyField, fx, fy)) { m_editMeanRef = ref; m_editMeanKind = kind; - const gd::RectF& field = layout.rowFrequencyField[rowIndex]; + const gd::RectF& field = layout.rows[rowIndex].frequencyField; const double hz = (item->interval > 0.0) ? (1.0 / item->interval) : 0.0; BeginEdit(PlotEditTarget::RESAMPLE_FREQUENCY, index, FormatDecimal(hz, 12), field); @@ -2497,19 +2482,17 @@ namespace jdi } } // 条目栏(信号色叠底):复选框切换;已勾选时整行切换参数行收起/展开。 - for (std::size_t rowIndex = 0; rowIndex < layout.rowRefs.size(); ++rowIndex) + for (std::size_t rowIndex = 0; rowIndex < layout.rows.size(); ++rowIndex) { - const SignalRef& ref = layout.rowRefs[rowIndex]; - if (!insideContent || rowIndex >= layout.rowHeaders.size() || - layout.rowHeaders[rowIndex].Width <= 0.0f || - !UiPointInRect(layout.rowHeaders[rowIndex], fx, fy)) + const SignalRef& ref = layout.rows[rowIndex].ref; + if (!insideContent || layout.rows[rowIndex].header.Width <= 0.0f || + !UiPointInRect(layout.rows[rowIndex].header, fx, fy)) { continue; } const bool onCheckBox = - rowIndex < layout.rowChecks.size() && - layout.rowChecks[rowIndex].Width > 0.0f && - UiPointInRect(layout.rowChecks[rowIndex], fx, fy); + layout.rows[rowIndex].check.Width > 0.0f && + UiPointInRect(layout.rows[rowIndex].check, fx, fy); const Lane::ResampleItem* existing = meanLane.FindResampleItem(ref); if (onCheckBox) { @@ -2577,15 +2560,14 @@ namespace jdi const bool doubleCursor = (Tab().CursorMode() == PlotCursorMode::DOUBLE); Lane& clipLane = m_tab->Lanes()[static_cast(index)]; // 条目复选框:切换该信号是否为截取目标。 - for (std::size_t rowIndex = 0; rowIndex < layout.rowRefs.size(); ++rowIndex) + for (std::size_t rowIndex = 0; rowIndex < layout.rows.size(); ++rowIndex) { - if (!insideContent || rowIndex >= layout.rowChecks.size() || - layout.rowChecks[rowIndex].Width <= 0.0f || - !UiPointInRect(layout.rowChecks[rowIndex], fx, fy)) + if (!insideContent || layout.rows[rowIndex].check.Width <= 0.0f || + !UiPointInRect(layout.rows[rowIndex].check, fx, fy)) { continue; } - ToggleLaneClipSignal(index, layout.rowRefs[rowIndex]); + ToggleLaneClipSignal(index, layout.rows[rowIndex].ref); m_meanRowHoverLane = -1; m_meanRowHoverRef = SignalRef(); m_dragMode = PlotDragMode::NONE; @@ -2651,26 +2633,24 @@ namespace jdi // 语义与该面板旧式排版完全一致,只是外观改成与均值滤波同款的可滚动列表。 if (boostMode) { - for (std::size_t rowIndex = 0; rowIndex < layout.rowRefs.size(); ++rowIndex) + for (std::size_t rowIndex = 0; rowIndex < layout.rows.size(); ++rowIndex) { - const SignalRef& ref = layout.rowRefs[rowIndex]; + const SignalRef& ref = layout.rows[rowIndex].ref; if (!insideContent) { break; } const Lane::WeakPeakItem* peakItem = meanLane.FindWeakPeakItem(ref); const bool hasParams = - peakItem != nullptr && rowIndex < layout.rowLevelTracks.size() && - layout.rowLevelTracks[rowIndex].Width > 0.0f; + peakItem != nullptr && layout.rows[rowIndex].levelTrack.Width > 0.0f; if (!hasParams) { continue; } Lane::WeakPeakItem& item = meanLane.EnsureWeakPeakItem(ref); // 复位:调整幅度 → 0% - if (rowIndex < layout.rowLevelResets.size() && - layout.rowLevelResets[rowIndex].Width > 0.0f && - UiPointInRect(layout.rowLevelResets[rowIndex], fx, fy)) + if (layout.rows[rowIndex].levelReset.Width > 0.0f && + UiPointInRect(layout.rows[rowIndex].levelReset, fx, fy)) { item.amountPercent = 0; m_weakPeakCurveCache.clear(); @@ -2678,9 +2658,8 @@ namespace jdi return true; } // 复位:迭代次数 → 1(上限恢复为默认上限)。 - if (rowIndex < layout.rowIterResets.size() && - layout.rowIterResets[rowIndex].Width > 0.0f && - UiPointInRect(layout.rowIterResets[rowIndex], fx, fy)) + if (layout.rows[rowIndex].iterReset.Width > 0.0f && + UiPointInRect(layout.rows[rowIndex].iterReset, fx, fy)) { item.iterations = kWeakPeakIterationsMin; item.maxIterations = kWeakPeakIterationsDefaultMax; @@ -2690,14 +2669,13 @@ namespace jdi } // 迭代次数行的**数值文本**:点击后整行变成输入框以修改滑块上限。 // ⚠️ 必须放在轨道命中之前(数值区在轨道右侧,命中区不重叠,但顺序更安全)。 - if (rowIndex < layout.rowIterValues.size() && - layout.rowIterValues[rowIndex].Width > 0.0f && - UiPointInRect(layout.rowIterValues[rowIndex], fx, fy)) + if (layout.rows[rowIndex].iterValue.Width > 0.0f && + UiPointInRect(layout.rows[rowIndex].iterValue, fx, fy)) { m_editMeanRef = ref; m_peakEditCurrentMax = item.maxIterations; - const gd::RectF& track = layout.rowIterTracks[rowIndex]; - const gd::RectF& value = layout.rowIterValues[rowIndex]; + const gd::RectF& track = layout.rows[rowIndex].iterTrack; + const gd::RectF& value = layout.rows[rowIndex].iterValue; const gd::RectF rowRect(track.X, value.Y, value.X + value.Width - track.X, value.Height); BeginEdit(PlotEditTarget::PEAK_ITERATIONS_MAX, index, L"", rowRect); @@ -2706,14 +2684,13 @@ namespace jdi return true; } // 拖动「迭代次数」滑块。 - if (rowIndex < layout.rowIterTracks.size() && - WeakPeakSliderHit(layout.rowIterTracks[rowIndex], m_scale, fx, fy)) + if (WeakPeakSliderHit(layout.rows[rowIndex].iterTrack, m_scale, fx, fy)) { m_dragMode = PlotDragMode::PEAK_ITERATIONS; m_peakDragLane = index; m_peakDragRef = ref; const double ratio = - UiSliderRatioFromX(layout.rowIterTracks[rowIndex], fx); + UiSliderRatioFromX(layout.rows[rowIndex].iterTrack, fx); const int peakMax = std::max( kWeakPeakIterationsMin, std::min(kWeakPeakIterationsMaxLimit, item.maxIterations)); @@ -2727,14 +2704,13 @@ namespace jdi return true; } // 拖动「调整幅度」滑块(0 在正中间 ⇒ ±400%)。 - if (rowIndex < layout.rowLevelTracks.size() && - WeakPeakSliderHit(layout.rowLevelTracks[rowIndex], m_scale, fx, fy)) + if (WeakPeakSliderHit(layout.rows[rowIndex].levelTrack, m_scale, fx, fy)) { m_dragMode = PlotDragMode::PEAK_LEVEL; m_peakDragLane = index; m_peakDragRef = ref; item.amountPercent = static_cast(std::lround( - (UiSliderRatioFromX(layout.rowLevelTracks[rowIndex], fx) * 2.0 - 1.0) * + (UiSliderRatioFromX(layout.rows[rowIndex].levelTrack, fx) * 2.0 - 1.0) * 100.0 * kWeakPeakSliderScale)); item.amountPercent = std::max( kWeakPeakAmountMin, @@ -2746,9 +2722,9 @@ namespace jdi } else { - for (std::size_t rowIndex = 0; rowIndex < layout.rowRefs.size(); ++rowIndex) + for (std::size_t rowIndex = 0; rowIndex < layout.rows.size(); ++rowIndex) { - const SignalRef& ref = layout.rowRefs[rowIndex]; + const SignalRef& ref = layout.rows[rowIndex].ref; if (!insideContent) { break; @@ -2756,16 +2732,14 @@ namespace jdi // 行可以“半截”显示(绘制时会按内容区裁剪),因此这里不再要求整行落在内容区; // 命中点已由上面的 `insideContent` 限定在内容区内,不会抢走底部按钮的点击。 const bool hasParams = - rowIndex < layout.rowLevelTracks.size() && - layout.rowLevelTracks[rowIndex].Width > 0.0f && + layout.rows[rowIndex].levelTrack.Width > 0.0f && findItem(ref) != nullptr; if (hasParams) { Lane::MeanFilterItem& item = ensureItem(ref); // 复位:均值点数 → 默认值(同时把滑块上限恢复为默认上限)。 - if (rowIndex < layout.rowLevelResets.size() && - layout.rowLevelResets[rowIndex].Width > 0.0f && - UiPointInRect(layout.rowLevelResets[rowIndex], fx, fy)) + if (layout.rows[rowIndex].levelReset.Width > 0.0f && + UiPointInRect(layout.rows[rowIndex].levelReset, fx, fy)) { item.points = kMeanFilterPointsDefault; item.maxPoints = kMeanFilterPointsDefaultMax; @@ -2778,9 +2752,8 @@ namespace jdi return true; } // 复位:迭代次数 → 1(上限恢复为默认上限)。 - if (rowIndex < layout.rowIterResets.size() && - layout.rowIterResets[rowIndex].Width > 0.0f && - UiPointInRect(layout.rowIterResets[rowIndex], fx, fy)) + if (layout.rows[rowIndex].iterReset.Width > 0.0f && + UiPointInRect(layout.rows[rowIndex].iterReset, fx, fy)) { item.iterations = kMeanFilterIterationsMin; item.maxIterations = kMeanFilterIterationsDefaultMax; @@ -2793,15 +2766,14 @@ namespace jdi return true; } // 点击「均值点数」右侧数值 → 输入滑块上限。 - if (rowIndex < layout.rowLevelValues.size() && - layout.rowLevelValues[rowIndex].Width > 0.0f && - UiPointInRect(layout.rowLevelValues[rowIndex], fx, fy)) + if (layout.rows[rowIndex].levelValue.Width > 0.0f && + UiPointInRect(layout.rows[rowIndex].levelValue, fx, fy)) { m_editMeanRef = ref; m_meanEditCurrentMax = item.maxPoints; m_editMeanKind = kind; - const gd::RectF& track = layout.rowLevelTracks[rowIndex]; - const gd::RectF& value = layout.rowLevelValues[rowIndex]; + const gd::RectF& track = layout.rows[rowIndex].levelTrack; + const gd::RectF& value = layout.rows[rowIndex].levelValue; const gd::RectF rowRect(track.X, value.Y, value.X + value.Width - track.X, value.Height); @@ -2811,15 +2783,14 @@ namespace jdi return true; } // 点击「迭代次数」右侧数值 → 输入滑块上限。 - if (rowIndex < layout.rowIterValues.size() && - layout.rowIterValues[rowIndex].Width > 0.0f && - UiPointInRect(layout.rowIterValues[rowIndex], fx, fy)) + if (layout.rows[rowIndex].iterValue.Width > 0.0f && + UiPointInRect(layout.rows[rowIndex].iterValue, fx, fy)) { m_editMeanRef = ref; m_meanEditCurrentMax = item.maxIterations; m_editMeanKind = kind; - const gd::RectF& track = layout.rowIterTracks[rowIndex]; - const gd::RectF& value = layout.rowIterValues[rowIndex]; + const gd::RectF& track = layout.rows[rowIndex].iterTrack; + const gd::RectF& value = layout.rows[rowIndex].iterValue; const gd::RectF rowRect(track.X, value.Y, value.X + value.Width - track.X, value.Height); @@ -2829,8 +2800,7 @@ namespace jdi return true; } // 拖动「均值点数」滑块。 - if (rowIndex < layout.rowLevelTracks.size() && - WeakPeakSliderHit(layout.rowLevelTracks[rowIndex], m_scale, fx, fy)) + if (WeakPeakSliderHit(layout.rows[rowIndex].levelTrack, m_scale, fx, fy)) { const int floorValue = centerMode ? 0 : kMeanFilterPointsMin; const int maximum = @@ -2839,7 +2809,7 @@ namespace jdi m_meanDragLane = index; m_meanDragRef = ref; m_meanDragKind = kind; - const double ratio = UiSliderRatioFromX(layout.rowLevelTracks[rowIndex], fx); + const double ratio = UiSliderRatioFromX(layout.rows[rowIndex].levelTrack, fx); item.points = std::max( floorValue, std::min(maximum, @@ -2852,8 +2822,7 @@ namespace jdi return true; } // 拖动「迭代次数」滑块。 - if (rowIndex < layout.rowIterTracks.size() && - WeakPeakSliderHit(layout.rowIterTracks[rowIndex], m_scale, fx, fy)) + if (WeakPeakSliderHit(layout.rows[rowIndex].iterTrack, m_scale, fx, fy)) { const int maximum = std::max( kMeanFilterIterationsMin, @@ -2862,7 +2831,7 @@ namespace jdi m_meanDragLane = index; m_meanDragRef = ref; m_meanDragKind = kind; - const double ratio = UiSliderRatioFromX(layout.rowIterTracks[rowIndex], fx); + const double ratio = UiSliderRatioFromX(layout.rows[rowIndex].iterTrack, fx); item.iterations = std::max( kMeanFilterIterationsMin, std::min(maximum, @@ -2886,23 +2855,21 @@ namespace jdi // * **未启用 → 什么都不做**(用户 2026-10-30 明确要求:未勾选前点击条目栏 // 既不能自动勾选、也不能展开 / 收起,只能点复选框;此时鼠标指针也保持箭头)。 // 仍然消费这次点击(不穿透到底层的平移 / 选区等)。 - for (std::size_t rowIndex = 0; rowIndex < layout.rowRefs.size(); ++rowIndex) + for (std::size_t rowIndex = 0; rowIndex < layout.rows.size(); ++rowIndex) { if (!insideContent) { break; } - const SignalRef& ref = layout.rowRefs[rowIndex]; - if (rowIndex >= layout.rowHeaders.size() || - layout.rowHeaders[rowIndex].Width <= 0.0f || - !UiPointInRect(layout.rowHeaders[rowIndex], fx, fy)) + const SignalRef& ref = layout.rows[rowIndex].ref; + if (layout.rows[rowIndex].header.Width <= 0.0f || + !UiPointInRect(layout.rows[rowIndex].header, fx, fy)) { continue; } const bool onCheckBox = - rowIndex < layout.rowChecks.size() && - layout.rowChecks[rowIndex].Width > 0.0f && - UiPointInRect(layout.rowChecks[rowIndex], fx, fy); + layout.rows[rowIndex].check.Width > 0.0f && + UiPointInRect(layout.rows[rowIndex].check, fx, fy); bool checked = false; bool collapsed = false; if (boostMode) @@ -4225,9 +4192,9 @@ namespace jdi int PlotView::PeakRowIndexOf(const EdgePanelLayout& layout, const SignalRef& ref) const { - for (std::size_t index = 0; index < layout.rowRefs.size(); ++index) + for (std::size_t index = 0; index < layout.rows.size(); ++index) { - if (layout.rowRefs[index] == ref) + if (layout.rows[index].ref == ref) { return static_cast(index); } diff --git a/Components/PlotView/Src/PlotViewPaint.cpp b/Components/PlotView/Src/PlotViewPaint.cpp index 683687c..40bc014 100644 --- a/Components/PlotView/Src/PlotViewPaint.cpp +++ b/Components/PlotView/Src/PlotViewPaint.cpp @@ -814,18 +814,28 @@ namespace jdi boxes.Add(layout.clipBuildButton, 3); boxes.Add(layout.clipDestructiveButton, 3); - boxes.AddAll(layout.rowChecks, 4); - boxes.AddAll(layout.rowStats, 4); - boxes.AddAll(layout.rowLevelTracks, 4); - boxes.AddAll(layout.rowLevelValues, 4); - boxes.AddAll(layout.rowIterTracks, 4); - boxes.AddAll(layout.rowIterValues, 4); - boxes.AddAll(layout.rowLevelResets, 4); - boxes.AddAll(layout.rowIterResets, 4); - boxes.AddAll(layout.rowNameRects, 4); - boxes.AddAll(layout.rowNameCloses, 4); - boxes.AddAll(layout.rowTriangles, 4); - boxes.AddAll(layout.rowHeaders, 4); + // ⚠️ 行内元素**逐行**登记(不再有"整个数组一起 AddAll"): + // 一行的字段是一个整体,逐字段登记才不会漏(旧的 12 次 AddAll 里 + // 少写一行就是整整一列元素不显示调试框,且没有任何检查会发现)。 + for (const EdgePanelLayout::PanelRow& row : layout.rows) + { + boxes.Add(row.header, 3); + boxes.Add(row.nameRect, 4); + boxes.Add(row.nameClose, 4); + boxes.Add(row.triangle, 4); + boxes.Add(row.check, 4); + boxes.Add(row.stats, 4); + boxes.Add(row.levelTrack, 4); + boxes.Add(row.levelValue, 4); + boxes.Add(row.levelReset, 4); + boxes.Add(row.iterTrack, 4); + boxes.Add(row.iterValue, 4); + boxes.Add(row.iterReset, 4); + boxes.Add(row.infoMaxInterval, 4); + boxes.Add(row.infoMinInterval, 4); + boxes.Add(row.intervalField, 4); + boxes.Add(row.frequencyField, 4); + } for (const EdgePanelLayout::MarkerGroup& group : layout.markerGroups) { @@ -4081,14 +4091,28 @@ namespace jdi mixRect(layout.opacityValue); mix(layout.collapsed ? 1ull : 0ull); mix(layout.markerHasEnumColumn ? 1ull : 0ull); - mix(static_cast(layout.rows)); - for (const gd::RectF& rect : layout.rowChecks) + mix(static_cast(layout.signalCount)); + // 行的几何**逐行混入**(旧实现按列分组混 12~16 次,漏一列就少一层敏感度, + // 而且“哪些列入键”分散在四个地方)。现在一个 `PanelRow` 是一个整体, + // 新增字段只需在 `PanelRow` 与这一个循环里各加一行。 + for (const EdgePanelLayout::PanelRow& row : layout.rows) { - mixRect(rect); - } - for (const gd::RectF& rect : layout.rowStats) - { - mixRect(rect); + mixRect(row.header); + mixRect(row.nameRect); + mixRect(row.nameClose); + mixRect(row.triangle); + mixRect(row.check); + mixRect(row.stats); + mixRect(row.levelTrack); + mixRect(row.levelValue); + mixRect(row.levelReset); + mixRect(row.iterTrack); + mixRect(row.iterValue); + mixRect(row.iterReset); + mixRect(row.infoMaxInterval); + mixRect(row.infoMinInterval); + mixRect(row.intervalField); + mixRect(row.frequencyField); } // 「自由标记」:固定点行/名称 ✕ 的矩形、五列边界、表头/吸顶状态与每个固定点的身份 + 时刻都要入键。 for (const EdgePanelLayout::MarkerRow& row : layout.markerRows) @@ -4097,14 +4121,6 @@ namespace jdi mixRect(row.close); mix(static_cast(row.itemIndex + 2)); } - for (const gd::RectF& rect : layout.rowNameCloses) - { - mixRect(rect); - } - for (const gd::RectF& rect : layout.rowNameRects) - { - mixRect(rect); - } for (const EdgePanelLayout::MarkerColumn& column : layout.markerColumns) { mixFloat(column.left); @@ -4150,8 +4166,9 @@ namespace jdi mix(bits); } } - for (const SignalRef& ref : layout.rowRefs) + for (const EdgePanelLayout::PanelRow& row : layout.rows) { + const SignalRef& ref = row.ref; mix(ref.IsValid() ? 1ull : 0ull); mix(static_cast(ref.groupIndex)); mix(static_cast(ref.signalIndex)); @@ -4220,54 +4237,6 @@ namespace jdi } - // 弱峰调整的滑块行矩形(每信号两行)。 - for (const gd::RectF& rect : layout.rowLevelTracks) - { - mixRect(rect); - } - for (const gd::RectF& rect : layout.rowIterTracks) - { - mixRect(rect); - } - for (const gd::RectF& rect : layout.rowIterValues) - { - mixRect(rect); - } - // 弱峰调整每行滑块右侧的复位按钮(悬停高亮依赖它的精确矩形)。 - for (const gd::RectF& rect : layout.rowLevelResets) - { - mixRect(rect); - } - for (const gd::RectF& rect : layout.rowIterResets) - { - mixRect(rect); - } - // 「均值滤波」:信号色叠底标题栏 / 展开三角 / 内容区滚动偏移都要入键 - //(滚动改变的是面板内部像素,位置与尺寸不变)。 - for (const gd::RectF& rect : layout.rowHeaders) - { - mixRect(rect); - } - for (const gd::RectF& rect : layout.rowTriangles) - { - mixRect(rect); - } - for (const gd::RectF& rect : layout.rowInfoMaxInterval) - { - mixRect(rect); - } - for (const gd::RectF& rect : layout.rowInfoMinInterval) - { - mixRect(rect); - } - for (const gd::RectF& rect : layout.rowIntervalField) - { - mixRect(rect); - } - for (const gd::RectF& rect : layout.rowFrequencyField) - { - mixRect(rect); - } mixFloat(layout.scrollOffset); // 鼠标落在面板内时,✕ / 复选框 / 滑块 / 底部按钮的悬停态都依赖精确坐标。 diff --git a/Components/PlotView/Src/PlotViewPanelPaint.cpp b/Components/PlotView/Src/PlotViewPanelPaint.cpp index 6eb1188..72f65e4 100644 --- a/Components/PlotView/Src/PlotViewPanelPaint.cpp +++ b/Components/PlotView/Src/PlotViewPanelPaint.cpp @@ -51,20 +51,15 @@ namespace jdi // 行内容必须裁到内容区:滚动时行会越出可视范围。 g.SetClip(layout.content, gd::CombineModeIntersect); - for (std::size_t rowIndex = 0; rowIndex < layout.rowRefs.size(); ++rowIndex) + for (std::size_t rowIndex = 0; rowIndex < layout.rows.size(); ++rowIndex) { - if (rowIndex >= layout.rowHeaders.size() || - rowIndex >= layout.rowNameRects.size()) - { - break; - } - const SignalRef& ref = layout.rowRefs[rowIndex]; + const SignalRef& ref = layout.rows[rowIndex].ref; const ResolvedSignal resolved = ResolveSignal(ref); if (resolved.data == nullptr || resolved.style == nullptr) { continue; } - const gd::RectF& row = layout.rowHeaders[rowIndex]; + const gd::RectF& row = layout.rows[rowIndex].header; if (row.Y + row.Height <= layout.content.Y || row.Y >= layout.content.Y + layout.content.Height) { @@ -77,7 +72,7 @@ namespace jdi UiFillRoundRect(g, row, 3.0f * m_scale, WithAlpha(signalColor, 0xE8)); // 信号名(第一列):超出列宽时省略。 - const gd::RectF& nameRect = layout.rowNameRects[rowIndex]; + const gd::RectF& nameRect = layout.rows[rowIndex].nameRect; UiTextClipped(g, UiEllipsize(resolved.data->name, m_fonts->uiSmall, std::max(8.0f, nameRect.Width)), m_fonts->uiSmall, nameRect, ink.text, TextAlign::LEFT, @@ -188,75 +183,40 @@ namespace jdi UiDrawLine(g, middleX, lowY, right, highY, background, 1.6f * m_scale); }; - for (std::size_t rowIndex = 0; rowIndex < layout.rowRefs.size(); ++rowIndex) + for (std::size_t rowIndex = 0; rowIndex < layout.rows.size(); ++rowIndex) { - const SignalRef& ref = layout.rowRefs[rowIndex]; - if (rowIndex >= layout.rowHeaders.size() || - rowIndex >= layout.rowChecks.size()) - { - break; - } + const SignalRef& ref = layout.rows[rowIndex].ref; const ResolvedSignal resolved = ResolveSignal(ref); if (resolved.data == nullptr || resolved.style == nullptr) { continue; } - const gd::RectF& header = layout.rowHeaders[rowIndex]; + const gd::RectF& header = layout.rows[rowIndex].header; // 行的**可见性**必须按“整行的纵向范围”判定,不能只看条目栏: // 条目栏已滚出内容区,但它下面两行参数滑块仍可能还在可视区内 //(用户 2026-10-30 报告:此时两个滑块会直接消失 —— 显示错误)。 - float rowTop = header.Y; - float rowBottom = header.Y + header.Height; + // 整行的纵向范围 = 条目栏 ∪ 全部参数行(未使用的字段恒为空矩形,直接 extent 即可; + // 旧结构里每一项都要先判断"数组可能更短",现在一行就是一个整体)。 + const EdgePanelLayout::PanelRow& row = layout.rows[rowIndex]; + float rowTop = row.header.Y; + float rowBottom = row.header.Y + row.header.Height; const auto extendRow = [&rowBottom](const gd::RectF& rect) { if (rect.Width > 0.0f && rect.Height > 0.0f) { rowBottom = std::max(rowBottom, rect.Y + rect.Height); } }; - if (rowIndex < layout.rowLevelTracks.size()) - { - extendRow(layout.rowLevelTracks[rowIndex]); - } - if (rowIndex < layout.rowLevelValues.size()) - { - extendRow(layout.rowLevelValues[rowIndex]); - } - if (rowIndex < layout.rowLevelResets.size()) - { - extendRow(layout.rowLevelResets[rowIndex]); - } - if (rowIndex < layout.rowIterTracks.size()) - { - extendRow(layout.rowIterTracks[rowIndex]); - } - if (rowIndex < layout.rowIterValues.size()) - { - extendRow(layout.rowIterValues[rowIndex]); - } - if (rowIndex < layout.rowIterResets.size()) - { - extendRow(layout.rowIterResets[rowIndex]); - } - if (rowIndex < layout.rowStats.size()) - { - extendRow(layout.rowStats[rowIndex]); - } - if (rowIndex < layout.rowInfoMaxInterval.size()) - { - extendRow(layout.rowInfoMaxInterval[rowIndex]); - } - if (rowIndex < layout.rowInfoMinInterval.size()) - { - extendRow(layout.rowInfoMinInterval[rowIndex]); - } - if (rowIndex < layout.rowIntervalField.size()) - { - extendRow(layout.rowIntervalField[rowIndex]); - } - if (rowIndex < layout.rowFrequencyField.size()) - { - extendRow(layout.rowFrequencyField[rowIndex]); - } + extendRow(row.levelTrack); + extendRow(row.levelValue); + extendRow(row.levelReset); + extendRow(row.iterTrack); + extendRow(row.iterValue); + extendRow(row.iterReset); + extendRow(row.stats); + extendRow(row.infoMaxInterval); + extendRow(row.infoMinInterval); + extendRow(row.intervalField); + extendRow(row.frequencyField); if (rowBottom <= layout.content.Y || rowTop >= layout.content.Y + layout.content.Height) { @@ -300,7 +260,7 @@ namespace jdi } // 左侧三角(仅勾选时存在):朝下 = 展开、朝右 = 收起。 - const gd::RectF& triangle = layout.rowTriangles[rowIndex]; + const gd::RectF& triangle = layout.rows[rowIndex].triangle; if (triangle.Width > 0.0f && checked) { UiDrawTriangleIcon(g, triangle, ink.text, @@ -309,7 +269,7 @@ namespace jdi } // 名称:从三角右侧到复选框左侧。 - const gd::RectF& check = layout.rowChecks[rowIndex]; + const gd::RectF& check = layout.rows[rowIndex].check; const float nameLeft = (triangle.Width > 0.0f) ? (triangle.X + triangle.Width + 6.0f * m_scale) : (header.X + 8.0f * m_scale); @@ -336,8 +296,7 @@ namespace jdi } // 「中心轴边沿计数」:参数行最上面是**同一行**的「上升沿 / 下降沿」统计。 - if (centerAxis && rowIndex < layout.rowStats.size() && - layout.rowStats[rowIndex].Width > 0.0f) + if (centerAxis && layout.rows[rowIndex].stats.Width > 0.0f) { std::size_t rising = 0; std::size_t falling = 0; @@ -348,7 +307,7 @@ namespace jdi item->points, item->iterations, EdgeRangeActive(), EdgeRangeMin(), EdgeRangeMax(), rising, falling, complete); } - paintCounts(layout.rowStats[rowIndex], rising, falling, complete, + paintCounts(layout.rows[rowIndex].stats, rising, falling, complete, layout.content); } @@ -466,34 +425,18 @@ namespace jdi ? (static_cast(peakIter - kWeakPeakIterationsMin) / static_cast(peakMax - kWeakPeakIterationsMin)) : 0.0; - if (rowIndex < layout.rowLevelTracks.size() && - layout.rowLevelTracks[rowIndex].Width > 0.0f) + if (row.levelTrack.Width > 0.0f) { - paintPeakSlider( - layout.rowLevelTracks[rowIndex], - rowIndex < layout.rowLevelValues.size() - ? layout.rowLevelValues[rowIndex] - : gd::RectF(), - rowIndex < layout.rowLevelResets.size() - ? layout.rowLevelResets[rowIndex] - : gd::RectF(), - kWeakPeakLevelLabel, levelRatio, - (peakAmount > 0 ? L"+" : L"") + FormatInteger(peakAmount) + L"%", - levelActive, false); + paintPeakSlider(row.levelTrack, row.levelValue, row.levelReset, + kWeakPeakLevelLabel, levelRatio, + (peakAmount > 0 ? L"+" : L"") + FormatInteger(peakAmount) + L"%", + levelActive, false); } - if (rowIndex < layout.rowIterTracks.size() && - layout.rowIterTracks[rowIndex].Width > 0.0f) + if (row.iterTrack.Width > 0.0f) { - paintPeakSlider( - layout.rowIterTracks[rowIndex], - rowIndex < layout.rowIterValues.size() - ? layout.rowIterValues[rowIndex] - : gd::RectF(), - rowIndex < layout.rowIterResets.size() - ? layout.rowIterResets[rowIndex] - : gd::RectF(), - kWeakPeakIterationsSliderLabel, iterRatio, - FormatInteger(peakIter), iterActive, editingMax); + paintPeakSlider(row.iterTrack, row.iterValue, row.iterReset, + kWeakPeakIterationsSliderLabel, iterRatio, + FormatInteger(peakIter), iterActive, editingMax); } } else @@ -518,30 +461,12 @@ namespace jdi (m_dragMode == PlotDragMode::MEAN_ITERATIONS) && (m_meanDragLane == static_cast(index)) && (m_meanDragRef == ref) && (m_meanDragKind == kind); - if (rowIndex < layout.rowLevelTracks.size()) - { - paintMeanSlider(layout.rowLevelTracks[rowIndex], - rowIndex < layout.rowLevelValues.size() - ? layout.rowLevelValues[rowIndex] - : gd::RectF(), - rowIndex < layout.rowLevelResets.size() - ? layout.rowLevelResets[rowIndex] - : gd::RectF(), - kMeanFilterPointsSliderLabel, points, - pointsFloor, pointsMax, pointsActive); - } - if (rowIndex < layout.rowIterTracks.size()) - { - paintMeanSlider(layout.rowIterTracks[rowIndex], - rowIndex < layout.rowIterValues.size() - ? layout.rowIterValues[rowIndex] - : gd::RectF(), - rowIndex < layout.rowIterResets.size() - ? layout.rowIterResets[rowIndex] - : gd::RectF(), - kMeanFilterIterationsSliderLabel, iterations, - kMeanFilterIterationsMin, iterationsMax, iterationsActive); - } + paintMeanSlider(row.levelTrack, row.levelValue, row.levelReset, + kMeanFilterPointsSliderLabel, points, pointsFloor, pointsMax, + pointsActive); + paintMeanSlider(row.iterTrack, row.iterValue, row.iterReset, + kMeanFilterIterationsSliderLabel, iterations, + kMeanFilterIterationsMin, iterationsMax, iterationsActive); } } @@ -640,36 +565,26 @@ namespace jdi } return text; }; - if (rowIndex < layout.rowInfoMaxInterval.size()) - { - paintInfoRow(layout.rowInfoMaxInterval[rowIndex], kResampleMaxIntervalLabel, - hasInterval ? intervalWithFrequency(item.maxInterval) : L"—"); - } - if (rowIndex < layout.rowInfoMinInterval.size()) - { - paintInfoRow(layout.rowInfoMinInterval[rowIndex], kResampleMinIntervalLabel, - hasInterval ? intervalWithFrequency(item.minInterval) : L"—"); - } + // ⚠️ 四行均在本行的 `PanelRow` 里,不再需要 “数组可能更短” 的判空(旧平行数组结构的遗留)。 + const EdgePanelLayout::PanelRow& row = layout.rows[rowIndex]; + paintInfoRow(row.infoMaxInterval, kResampleMaxIntervalLabel, + hasInterval ? intervalWithFrequency(item.maxInterval) : L"—"); + paintInfoRow(row.infoMinInterval, kResampleMinIntervalLabel, + hasInterval ? intervalWithFrequency(item.minInterval) : L"—"); const double interval = (item.interval > 0.0) ? item.interval : item.minInterval; const bool editingInterval = (m_editTarget == PlotEditTarget::RESAMPLE_INTERVAL) && (m_editLane == static_cast(index)) && (m_editMeanRef == ref); const bool editingFrequency = (m_editTarget == PlotEditTarget::RESAMPLE_FREQUENCY) && (m_editLane == static_cast(index)) && (m_editMeanRef == ref); - if (rowIndex < layout.rowIntervalField.size()) - { - // 非编辑态:自适应单位 + 小数(与「最大/最小数据间隔」同一套单位形式)。 - // 编辑态:只显示**秒**数值(不带单位),由 `PaintEditField` 绘制。 - paintInputRow(layout.rowIntervalField[rowIndex], kResampleIntervalLabel, - FormatIntervalText(interval), editingInterval); - } - if (rowIndex < layout.rowFrequencyField.size()) - { - // ⚠️ 频率同样用**真实间隔**换算(与时间间隔行同源)⇒ `270.517 Hz`。 - const double hertz = (interval > 0.0) ? (1.0 / interval) : 0.0; - // 非编辑态:频率单位自适应(Hz/kHz/MHz/GHz);编辑态:只显示 Hz 数值。 - paintInputRow(layout.rowFrequencyField[rowIndex], kResampleFrequencyLabel, - (hertz > 0.0) ? FormatFrequencyText(hertz) : L"—", editingFrequency); - } + // 非编辑态:自适应单位 + 小数(与「最大/最小数据间隔」同一套单位形式)。 + // 编辑态:只显示**秒**数值(不带单位),由 `PaintEditField` 绘制。 + paintInputRow(row.intervalField, kResampleIntervalLabel, FormatIntervalText(interval), + editingInterval); + // ⚠️ 频率同样用**真实间隔**换算(与时间间隔行同源)⇒ `270.517 Hz`。 + const double hertz = (interval > 0.0) ? (1.0 / interval) : 0.0; + // 非编辑态:频率单位自适应(Hz/kHz/MHz/GHz);编辑态:只显示 Hz 数值。 + paintInputRow(row.frequencyField, kResampleFrequencyLabel, + (hertz > 0.0) ? FormatFrequencyText(hertz) : L"—", editingFrequency); } diff --git a/Components/PlotView/Src/PlotViewPanels.cpp b/Components/PlotView/Src/PlotViewPanels.cpp index 34b22c5..1e5ca0f 100644 --- a/Components/PlotView/Src/PlotViewPanels.cpp +++ b/Components/PlotView/Src/PlotViewPanels.cpp @@ -380,7 +380,7 @@ namespace jdi { return layout; } - layout.rows = rowCount; + layout.signalCount = rowCount; const float width = EdgePanelWidth(laneIndex, kind); const float titleHeight = kEdgePanelTitleHeight * m_scale; @@ -735,7 +735,7 @@ namespace jdi { continue; } - // ⚠️ 行的追加走 `AppendRow()`(**唯一入口**):18 个平行数组一次补齐, + // ⚠️ 行的追加走 `AppendRow()`(**唯一入口**):一行的全部字段一次成型, // 不会出现"某个分支漏推一列 ⇒ 该列之后所有行读到别人的矩形"。 layout.AppendRow(ref, gd::RectF(contentLeft, rowY, innerWidth, rowH), gd::RectF(contentLeft + 4.0f * m_scale, rowY, @@ -877,9 +877,9 @@ namespace jdi itemCollapsed = (item != nullptr && item->collapsed); } // 组头(信号色叠底):整行宽度,左侧三角(仅勾选时)+ 名称,右端复选框。 - // ⚠️ 行的追加走 `AppendRow()`(**唯一入口**):18 个平行数组一次补齐, + // ⚠️ 行的追加走 `AppendRow()`(**唯一入口**):一行的全部字段一次成型, // 不会出现"某个分支漏推一列 ⇒ 该列之后所有行读到别人的矩形"。 - // 下面的参数行 / 重采样行一律用 `rowXxx.back() = …` **赋值**(长度天然对齐)。 + // 下面的参数行 / 重采样行一律用 `layout.rows.back().xxx = …` **赋值**(同一行,天然对齐)。 layout.AppendRow( ref, gd::RectF(contentLeft, rowY, innerWidth, groupRow), gd::RectF(contentLeft, rowY, innerWidth, groupRow), @@ -903,43 +903,43 @@ namespace jdi // 「重采样」四行:① 最大间隔(只读)② 最小间隔(只读) // ③ 重采样间隔(输入)④ 重采样频率(输入,与③联动)。 // 每行 = 左侧标签 + 右侧数值/输入框区域。 - layout.rowInfoMaxInterval.back() = gd::RectF(contentLeft, rowY, innerWidth, sliderRow); + layout.rows.back().infoMaxInterval = gd::RectF(contentLeft, rowY, innerWidth, sliderRow); rowY += sliderRow; - layout.rowInfoMinInterval.back() = gd::RectF(contentLeft, rowY, innerWidth, sliderRow); + layout.rows.back().infoMinInterval = gd::RectF(contentLeft, rowY, innerWidth, sliderRow); rowY += sliderRow; // 输入行的**数值矩形**靠右对齐,宽度取剩余可用宽度(绘制框与编辑框同源)。 const float fieldLeft = contentLeft + rowLabelColumn + 8.0f * m_scale; const float fieldWidth = std::max(24.0f * m_scale, innerWidth - rowLabelColumn - 8.0f * m_scale); - layout.rowIntervalField.back() = gd::RectF(fieldLeft, rowY, fieldWidth, sliderRow); + layout.rows.back().intervalField = gd::RectF(fieldLeft, rowY, fieldWidth, sliderRow); rowY += sliderRow; - layout.rowFrequencyField.back() = gd::RectF(fieldLeft, rowY, fieldWidth, sliderRow); + layout.rows.back().frequencyField = gd::RectF(fieldLeft, rowY, fieldWidth, sliderRow); rowY += sliderRow; continue; } // 「中心轴边沿计数」:参数行最上面是**同一行**的「上升沿 / 下降沿」统计。 - if (centerAxis && !layout.rowStats.empty()) + if (centerAxis && !layout.rows.empty()) { - layout.rowStats.back() = gd::RectF(contentLeft, rowY, innerWidth, groupRow); + layout.rows.back().stats = gd::RectF(contentLeft, rowY, innerWidth, groupRow); rowY += groupRow; } const gd::RectF pointsTrack(trackLeft, rowY + (sliderRow - trackHeight) * 0.5f, trackWidth, trackHeight); - layout.rowLevelTracks.back() = pointsTrack; - layout.rowLevelValues.back() = + layout.rows.back().levelTrack = pointsTrack; + layout.rows.back().levelValue = gd::RectF(pointsTrack.X + pointsTrack.Width + 6.0f * m_scale, rowY, valueColumn, sliderRow); - layout.rowLevelResets.back() = + layout.rows.back().levelReset = gd::RectF(resetLeft, rowY + (sliderRow - resetSize) * 0.5f, resetSize, resetSize); rowY += sliderRow; const gd::RectF iterTrack(trackLeft, rowY + (sliderRow - trackHeight) * 0.5f, trackWidth, trackHeight); - layout.rowIterTracks.back() = iterTrack; - layout.rowIterValues.back() = + layout.rows.back().iterTrack = iterTrack; + layout.rows.back().iterValue = gd::RectF(iterTrack.X + iterTrack.Width + 6.0f * m_scale, rowY, valueColumn, sliderRow); - layout.rowIterResets.back() = + layout.rows.back().iterReset = gd::RectF(resetLeft, rowY + (sliderRow - resetSize) * 0.5f, resetSize, resetSize); rowY += sliderRow; } @@ -1136,9 +1136,7 @@ namespace jdi // 只登记"在可视内容区里"的分组栏与条目(滚出视界的条目不得抢走 // 底部按钮 / 滑块的点击)。 layout.markerGroups.push_back(group); - layout.rowRefs.push_back(ref); - layout.rowNameRects.push_back(group.header); - layout.rowNameCloses.push_back(group.close); + layout.AppendMarkerGroupRow(ref, group.header, group.close); } rowY += groupRow; if (group.collapsed) diff --git a/Documents/Code_Review_Plan.md b/Documents/Code_Review_Plan.md index 79abde2..cd6d0e8 100644 --- a/Documents/Code_Review_Plan.md +++ b/Documents/Code_Review_Plan.md @@ -343,3 +343,40 @@ | 通用参数行(中心轴统计 + 两行滑块) | ⏸ 未提取:依赖 3 个 kind 标志 + 2 套拖拽/编辑状态 + 2 个滑块 lambda,提取需传 9 个参数,可读性收益低于参数表本身 | | 条目栏(三角 + 名称 + 复选框 + 悬停) | 保留在 `PaintPanelListRows` 里(它是三种排版的公共前缀) | +--- + +## 十一、第 39 轮追加(2026-11-04):平行数组合并为 `std::vector`(§15.6 第 1 项) + +> 触发:用户要求「继续按照上轮的建议继续推进」。上一轮结论是"若要做就做**方案 B**: +> `std::vector` + 具名成员访问(**无视图**),并且必须单独一轮"。 + +| 节点 | 内容 | 验收标准 | +| --- | --- | --- | +| **P33** | `EdgePanelLayout` 的 16 个平行 `std::vector` 合并为 `std::vector rows` | `rowgeom -mode compare`:**0** 条键消失、**0** 条已有键的值变化;新增键**全部**是空矩形 | +| **P34** | 唯一入口收敛为 `AppendRow` / `AppendMarkerGroupRow`;`int rows` 改名 `signalCount` | 编译零 warning;7 个面板像素哈希**逐项不变** | +| **P35** | 删掉因合并而变成恒真的 50+ 处 `rowIndex < layout.rows.size()` 判空 | 全量检查 133 断言全绿 | + +### 11.1 本轮新增硬约束 + +1. **行的几何**只能通过 `rows[i].<字段>` 访问;**禁止**再加回平行数组,也**禁止**引入 + `PanelColumn` 列视图(拷贝 `EdgePanelLayout` 后忘重绑 = 静默悬垂,没有检查可以抓到)。 +2. **新增一个行字段** = `PanelRow` 加一个成员 + `AppendRow` 里赋默认值(现在连这一步都不用, + 成员自带默认构造)+ 需要绘制的地方读 `rows[i].字段`。**不需要**再同步任何"列清单"。 +3. **`signalCount` ≠ `rows.size()`**:前者是"网格里的可见信号数"(`EdgePanelLayoutFor` 开头统计), + 后者是"本面板实际产生的行数"。两者在「自由标记」面板里**不相等**(只有有固定点的信号才有行)。 +4. **`rowgeom` 的键名保持 `.<旧列名>[NNN]`**:这样重构前后的基线可以逐字节比对, + 等价性不靠人工核对。 + +### 11.2 等价性是怎么证明的(可复用的流程) + +```text +old keys = 919, new keys = 965 +仅存在于旧 = 0 ← 没有任何键消失 +已有键的值变化 = 20 ← 全部是 ".count" 键:0 -> N(结构变化,不是几何变化) +仅存在于新 = 46 ← 全部是 [NNN],且值全为 0.00,0.00,0.00,0.00 +``` + +含义:旧结构里"某列根本没被 push"的那些行,现在每行都拥有该字段(值为空矩形)。 +**逐行逐字段的真实几何一位都没变**,7 个面板的像素哈希也逐项未变。 + + diff --git a/Documents/Code_Review_Progress.md b/Documents/Code_Review_Progress.md index 79f11fa..d38d626 100644 --- a/Documents/Code_Review_Progress.md +++ b/Documents/Code_Review_Progress.md @@ -550,3 +550,82 @@ only-in-OLD lines NOT belonging to rowPoints/rowIterations = 0 | `rowgeom -mode compare` | 919 + 7 键,0 差异 | | 行数 | `PlotViewPaint.cpp` 5575 → 5565;`PlotViewPanelPaint.cpp` 1016 → 1020(净增 4 行:函数头 + 文档) | +--- + +## 第 39 轮(2026-11-04):平行数组合并为 `std::vector`(§15.6 第 1 项) + +> 这是"面板改一次就出错"的**最后一个机制性隐患**:旧的 16 个平行数组靠"每个布局分支 +> 按同一顺序 push 恰好 N 项"维持对齐,任何一处漏推都是静默错位。 + +### P33 合并成 `std::vector` + +```cpp +struct PanelRow +{ + SignalRef ref; // 该行的信号(MARK = 分组的代表信号) + gd::RectF header; // 信号色叠底条目栏 / 分组栏 + gd::RectF nameRect; // 名称区 + gd::RectF nameClose; // 名称右侧 ✕(仅 MARK) + gd::RectF triangle; // 展开/收起三角 + gd::RectF check; // 右端复选框 + gd::RectF stats; // 「中心轴」上升/下降沿统计行 + gd::RectF levelTrack / levelValue / levelReset; // 滑块 1 + gd::RectF iterTrack / iterValue / iterReset; // 滑块 2 + gd::RectF infoMaxInterval / infoMinInterval; // 「重采样」两行只读统计 + gd::RectF intervalField / frequencyField; // 「重采样」两行输入框 +}; +std::vector rows; // 唯一的行容器 +``` + +* 唯一入口:`AppendRow(ref, header, nameRect, triangle, check)`(返回新行引用) + 与 `AppendMarkerGroupRow(ref, nameRect, nameClose)`(「自由标记」分组行)。 + 两者都**没有**"补齐其余列"这一步 —— 因为字段天生就在同一行里。 +* 参数行 / 重采样行从 `layout.rowXxx.back() = …` 变成 `layout.rows.back().xxx = …`。 +* 原来的 `int rows`(可见信号数)与新容器同名 ⇒ 改名 **`signalCount`**, + 并注明它与 `rows.size()` 在 MARK 面板里**不相等**(只有有固定点的信号才有行)。 + +### P34 顺带消失的两类问题 + +| 旧问题 | 现在 | +| --- | --- | +| 某分支漏推一列 ⇒ 该列之后所有行读到别人的矩形 | `rows.push_back(row)` 一次成型,**不可能**错位 | +| `rowRefs` 字段重载(MARK 借它表示"分组",其余 12 列必须为空,长度不变式要按类别分流判定) | 每行自带 `ref`,语义由"填哪些字段"表达;不变式简化为"每行都要有名称区 + MARK 行不得带参数行字段" | + +### P35 清理恒真判空 + +合并后 `rowIndex < layout.rowXxx.size()` 一律变成 `rowIndex < layout.rows.size()`, +而它们全都在 `for (… rowIndex < layout.rows.size(); …)` 循环体内 ⇒ **恒真**。 +用正则批量删除 50+ 处(含 `a.size() != rows` 的 15 元组长度数组、`? x : gd::RectF()` 三元判空、 +`if (rowIndex < …) { extendRow(...); }` 包裹),并逐文件编译验证。 + +⚠️ 中途有一次误操作:把"均值滤波双滑块"那段替换成了"弱峰双滑块", +编译器报 brace 不平衡 + 大面积类型错误 ⇒ 用备份 `Temp/PanelPaint.pre-cleanup.cpp` 回滚后重做。 +**教训**:这类"整段替换"必须先用 `read_file` 精确读出 5 行上下文,不要凭记忆拼 oldString。 + +### 等价性证明(本轮最关键的一步) + +``` +old keys = 919, new keys = 965 +仅存在于旧 = 0 ← 没有任何键消失 +已有键的值变化 = 20 ← 全部是 ".count":0 -> N +仅存在于新 = 46 ← 全部是 [NNN],值全为 0.00,0.00,0.00,0.00 +``` + +⇒ 旧结构里"某列没被 push"的那些行,现在每行都拥有该字段但值为空矩形; +**逐行逐字段的真实几何一位都没变**,7 个面板的像素哈希(含 `resample=2BFC74B3D9B3239C`) +也逐项未变。之后按新结构重录正式基线(965 条键)。 + +### 本轮验证 + +| 项 | 结果 | +| --- | --- | +| `make release`(全量) | 零 error / 零 warning;产物 784,896 → **782,336 B**(−2,560) | +| `make debug` / `make -C Tools` | 零 error / 零 warning | +| `UnitTests` | `checks=260 failed=0 cases=19` | +| `OffscreenChecks -all` | 7/7 PASS,`checks=133 failed=0`(上轮 127:新增行结构断言) | +| `panels -mode compare` | 7 个面板像素哈希**逐项不变** | +| `rowgeom -mode compare` | 965 + 7 键,0 差异(默认数据);1268 + 7 键(MF4 566 行),0 差异 | +| 读点数 | 旧的 243 处 `layout.rowXxx[…]` 全部消失;现在 95 处 `layout.rows[…]`(84 在命中判定、11 在绘制) | +| 行数 | `PlotView.hpp` 4795;`PlotViewInteraction.cpp` 4399;`PlotViewPanelPaint.cpp` 935;`PlotViewPaint.cpp` 5534;`PlotViewPanels.cpp` 1729 | + + diff --git a/Documents/Code_Review_Report.md b/Documents/Code_Review_Report.md index 53df3bb..f031600 100644 --- a/Documents/Code_Review_Report.md +++ b/Documents/Code_Review_Report.md @@ -683,3 +683,73 @@ B 的**剩余收益**主要是"单一分配 + 少 15 个 vector 头"(`EdgePane 让"MARK 面板 12 列为空"的注释消失。 3. 面板几何算式重写(§13.6 遗留):基线已就绪,但需先完成 1 或 2。 +--- + +## 十六、第 39 轮:平行数组合并为 `std::vector`(§15.6 第 1 项,方案 B) + +### 16.1 结论 + +| §15.6 建议 | 结果 | +| --- | --- | +| 1. 方案 B(`vector` 无视图) | ✅ 完成:16 个平行数组 → `std::vector rows`(17 个具名成员) | +| 2. 在 B 之上拆分 `rowRefs` 双语义 | ✅ **顺带完成**:`rows[i].ref` 取代重载的 `rowRefs`,MARK 分组行改走 `AppendMarkerGroupRow` | +| 3. 面板几何算式重写 | ⏸ 未做(本轮只做结构合并,算式未动) | + +### 16.2 结构对比 + +| | 旧(16 个平行数组) | 新(`std::vector`) | +| --- | --- | --- | +| 新增一列 | 改 `AppendRow` + 14 处布局分支 + 绘制 + 缓存键 + 调试框 + 检查的 15 元组 | 只在 `PanelRow` 加一个成员 | +| 漏推一列 | 该列之后**所有行**读到别人的矩形(静默) | 不可能(一行一次成型) | +| `rowRefs` 语义 | 列表式 = 行信号;MARK = 分组信号(同一字段两种含义) | `rows[i].ref` 一种含义;MARK 的分组行只填 `nameRect`/`nameClose` | +| 长度不变式 | 15 个 `size()` 组成的判据,且 MARK 要单独分流 | 恒真 ⇒ 改为语义断言(每行都要有名称区;MARK 行不得带参数行字段) | +| `EdgePanelLayout` 每实例 | 16 个 vector 头(16×24 B)+ 各自独立分配 | 1 个 vector 头 | + +### 16.3 为什么这次敢做(前一轮不敢) + +上一轮的顾虑是"列视图拷贝后忘重绑 = 静默悬垂"。方案 B **没有视图**: +所有访问都是 `rows[i].check`,`PanelRow` 是值类型、随 `rows` 一起拷贝,**结构上不可能悬垂**。 +代价是 243 处读点必须机械改写(不省),收益是"漏推一列"与"字段重载"两类问题**从根上消失**。 + +### 16.4 等价性证明(本轮方法论的核心) + +`rowgeom` 的键名刻意保持 `.<旧列名>[NNN]`,于是重构前后可以直接逐键比对: + +``` +old keys = 919, new keys = 965 +仅存在于旧 = 0 ← 没有任何键消失 +已有键的值变化 = 20 ← 全部是 ".count" 键:0 -> N(结构变化) +仅存在于新 = 46 ← 全部是新增列的 [NNN],值 0.00,0.00,0.00,0.00 +``` + +再叠加 7 个面板的像素哈希逐项不变 ⇒ **渲染与几何都没有任何位移**。 +这比"编译通过 + 肉眼看一眼"强得多:它把"等价重构"变成了**可证伪**的断言。 + +### 16.5 过程中唯一的一次事故 + +清理恒真判空时用"整段替换"把「均值滤波」双滑块段落误替换成「弱峰」双滑块, +编译器立刻报 brace 不平衡;用备份回滚后重做。 +**结论:整段替换必须先精确读出上下文,oldString 不能凭记忆拼。** + +### 16.6 验证 + +| 项 | 结果 | +| --- | --- | +| `make release` | 零 warning;784,896 → **782,336 B**(−2,560) | +| `make debug` / `make -C Tools` | 零 warning | +| `UnitTests` | 260 / 19 PASS | +| `OffscreenChecks -all` | 7 / **133** PASS(上轮 127) | +| `panels -mode compare` | 7 面板像素哈希逐项不变 | +| `rowgeom -mode compare` | 965 + 7 键 / 1268 + 7 键(MF4),0 差异 | + +### 16.7 下一步(按价值排序) + +1. **面板几何算式重写**(§13.6 遗留):结构合并与逐行基线都已就绪,现在"改算式"是**安全的**; + 建议先做 `EdgePanelWidth`(逐信号量宽)与 `EdgePanelLayoutFor` 的行高累加, + 判据 = `rowgeom -mode compare` + `panels -mode compare` 双基线归零。 +2. **`PaintPanelListRows` 的通用参数行**提取(上轮因需 9 个参数而搁置): + 现在 `row` 是一个整体、`extendRow` 已成组,可把"参数行"收成一个函数, + 参数表从 9 个降到 `(g, layout, row, kind, state)` 5 个。 +3. 若还要压体积:`EdgePanelLayout` 已无冗余列,剩余空间主要在字符串/浮点格式化(见 §11 的 gdtoa 结论)。 + + diff --git a/Documents/agent_mem.md b/Documents/agent_mem.md index 6089dce..f1688a2 100644 --- a/Documents/agent_mem.md +++ b/Documents/agent_mem.md @@ -10695,3 +10695,66 @@ B 属**整洁性收益**(漏推一列已被 `AppendRow` + 长度不变式挡 `rows[].ref` + `markerGroups[]`,让"MARK 面板 12 列为空"的注释消失。 2. 面板几何算式重写(§13.6 遗留):基线已就绪,但需先完成 1。 +--- + +## 2026-11-04 · 第 39 轮(`Code_Review` 分支):平行数组合并为 `std::vector`(§16.6 第 1 项,方案 B) + +> 规则原文:`Documents/Code_Review_Report.md` §16、`Code_Review_Plan.md` §十一、 +> `Code_Review_Progress.md` 第 39 轮。 + +### 1. ✅ `EdgePanelLayout` 的 16 个平行 `std::vector` → `std::vector rows` + +```cpp +struct PanelRow { + SignalRef ref; header; nameRect; nameClose; triangle; check; stats; + levelTrack; levelValue; levelReset; iterTrack; iterValue; iterReset; + infoMaxInterval; infoMinInterval; intervalField; frequencyField; +}; +std::vector rows; +``` +唯一入口:`AppendRow(ref, header, nameRect, triangle, check)`、 +`AppendMarkerGroupRow(ref, nameRect, nameClose)`(MARK 分组行)。 +**参数行一律 `layout.rows.back().xxx = …` 赋值。** + +### 2. 🔴 硬规则(本轮新增) + +1. 行的几何**只能**通过 `rows[i].<字段>` 访问。**禁止**加回平行数组; + **禁止**引入 `PanelColumn` 列视图(拷贝后忘重绑 = 静默悬垂,没有检查能抓到)。 +2. **新增字段**只需在 `PanelRow` 加一个成员 —— 不再需要同步"列清单" + (`AppendRow` / 调试框 / 缓存键 / 检查里的 15 元组都已不存在)。 +3. **`signalCount`(可见信号数)≠ `rows.size()`(本面板行数)**:MARK 面板里只有 + "有固定点的信号"才有行。原 `int rows` 已改名 `signalCount`。 +4. 长度不变式在结构上与恒真 ⇒ 已改为**语义断言**:每行必须有名称区; + MARK 行不得带 `header`/`check`/`stats`/滑块/重采样字段(防止把列表式布局误用到它上面)。 + +### 3. ⭐ `rowgeom` 键名刻意保持不变 ⇒ 等价重构**可证伪** + +``` +old keys = 919, new keys = 965 +仅存在于旧 = 0 ; 已有键值变化 = 20(全是 ".count": 0 -> N) +仅存在于新 = 46(全是新增列 [NNN],值 0.00,0.00,0.00,0.00) +``` +再叠加 7 面板像素哈希逐项不变 ⇒ 渲染/几何零位移。 +**以后做"等价重构"都照这个流程:先保键名,再比对。** + +### 4. ⚠️ 事故与教训 + +清理恒真判空时用"整段替换"把「均值滤波」双滑块段误换成「弱峰」双滑块,编译器立刻报 +brace 不平衡。用备份回滚后重做。 +**整段替换必须先 `read_file` 精确读出上下文,oldString 不能凭记忆拼。** + +### 5. 本轮数字 + +* Release 784,896 → **782,336 B**(−2,560);release/debug/tools 全零 warning。 +* `UnitTests` 260/19;`OffscreenChecks -all` 7 检查 / **133** 断言全 PASS(上轮 127)。 +* `panels -mode compare` 7 面板像素哈希逐项不变;`rowgeom compare` 965+7 / 1268+7 键 0 差异。 +* 读点:旧 243 处 `layout.rowXxx[…]` → 现 95 处 `layout.rows[…]`(84 命中判定 / 11 绘制)。 + +### 6. 下一步 + +1. **面板几何算式重写**(§13.6 遗留)—— 结构与双基线都已就绪,现在改算式是安全的; + 建议先动 `EdgePanelWidth`(逐信号量宽)与 `EdgePanelLayoutFor` 的行高累加。 +2. `PaintPanelListRows` 的"通用参数行"提取:现在参数表可从 9 个降到 5 个 + (`g, layout, row, kind, state`)。 +3. 体积剩余空间主要在字符串/浮点格式化(gdtoa 30+ KB 不可去掉,见 §11)。 + diff --git a/Tools/OffscreenChecks/Src/Checks.cpp b/Tools/OffscreenChecks/Src/Checks.cpp index 72eed6a..2478053 100644 --- a/Tools/OffscreenChecks/Src/Checks.cpp +++ b/Tools/OffscreenChecks/Src/Checks.cpp @@ -328,7 +328,7 @@ namespace jdi AppendRectLine(text, key("clipBuildButton"), layout.clipBuildButton); AppendRectLine(text, key("clipDestructiveButton"), layout.clipDestructiveButton); text += key("collapsed") + "=" + (layout.collapsed ? "1" : "0") + "\n"; - text += key("rows") + "=" + std::to_string(layout.rows) + "\n"; + text += key("rows") + "=" + std::to_string(layout.signalCount) + "\n"; text += key("markerContentHeight") + "=" + std::to_string(layout.markerContentHeight) + "\n"; text += key("scrollOffset") + "=" + std::to_string(layout.scrollOffset) + "\n"; @@ -342,46 +342,53 @@ namespace jdi std::to_string(cell.width) + "\n"; } - // ---- 每一行 / 每一列 ---- - // ⚠️ **每一列只按自己的长度导出**,不要假设所有列等长: - // 「自由标记」面板复用 `rowRefs` 表示"信号分组",只填 - // `rowNameRects` / `rowNameCloses`,其余 12 列**全为空** - // (这一点正是 `rowgeom` 第一次运行就抓出来的结构事实)。 - // 按 `rowRefs.size()` 统一索引会越界崩溃。 - const auto dumpRects = [&text, &key](const char* name, const std::vector& rects) { - text += key(name) + ".count=" + std::to_string(rects.size()) + "\n"; - for (std::size_t index = 0; index < rects.size(); ++index) - { - char cell[64] = {}; - std::snprintf(cell, sizeof(cell), "[%03d]", static_cast(index)); - AppendRectLine(text, key(name) + cell, rects[index]); - } - }; - text += key("rowRefs") + ".count=" + std::to_string(layout.rowRefs.size()) + "\n"; - for (std::size_t index = 0; index < layout.rowRefs.size(); ++index) + // ---- 每一行 / 每一个字段 ---- + // 2026-11-04(第 39 轮)`EdgePanelLayout` 的 16 个平行数组已合并为 + // `std::vector rows`,所以这里**逐行**导出全部字段即可, + // 不再需要"每一列可能长度不同"的分列遍历(那种结构问题已从根上消失)。 + // + // ⚠️ 键名保持与合并前**完全一致**(`.rowLevelTracks[001]=…`): + // 这样重构前后的基线可以直接逐字节比对,等价性不靠人工核对。 + text += key("rowRefs") + ".count=" + std::to_string(layout.rows.size()) + "\n"; + for (std::size_t index = 0; index < layout.rows.size(); ++index) { char cell[64] = {}; std::snprintf(cell, sizeof(cell), "[%03d]", static_cast(index)); text += key("rowRefs") + cell + "=" + - std::to_string(layout.rowRefs[index].groupIndex) + "/" + - std::to_string(layout.rowRefs[index].signalIndex) + "\n"; + std::to_string(layout.rows[index].ref.groupIndex) + "/" + + std::to_string(layout.rows[index].ref.signalIndex) + "\n"; } - dumpRects("rowHeaders", layout.rowHeaders); - dumpRects("rowNameRects", layout.rowNameRects); - dumpRects("rowNameCloses", layout.rowNameCloses); - dumpRects("rowTriangles", layout.rowTriangles); - dumpRects("rowChecks", layout.rowChecks); - dumpRects("rowStats", layout.rowStats); - dumpRects("rowLevelTracks", layout.rowLevelTracks); - dumpRects("rowLevelValues", layout.rowLevelValues); - dumpRects("rowLevelResets", layout.rowLevelResets); - dumpRects("rowIterTracks", layout.rowIterTracks); - dumpRects("rowIterValues", layout.rowIterValues); - dumpRects("rowIterResets", layout.rowIterResets); - dumpRects("rowInfoMaxInterval", layout.rowInfoMaxInterval); - dumpRects("rowInfoMinInterval", layout.rowInfoMinInterval); - dumpRects("rowIntervalField", layout.rowIntervalField); - dumpRects("rowFrequencyField", layout.rowFrequencyField); + const auto dumpField = [&text, &key](const char* name, + const std::vector& rows, + gd::RectF PlotView::EdgePanelLayout::PanelRow::*field) { + text += key(name) + ".count=" + std::to_string(rows.size()) + "\n"; + for (std::size_t index = 0; index < rows.size(); ++index) + { + char cell[64] = {}; + std::snprintf(cell, sizeof(cell), "[%03d]", static_cast(index)); + AppendRectLine(text, key(name) + cell, rows[index].*field); + } + }; + dumpField("rowHeaders", layout.rows, &PlotView::EdgePanelLayout::PanelRow::header); + dumpField("rowNameRects", layout.rows, &PlotView::EdgePanelLayout::PanelRow::nameRect); + dumpField("rowNameCloses", layout.rows, &PlotView::EdgePanelLayout::PanelRow::nameClose); + dumpField("rowTriangles", layout.rows, &PlotView::EdgePanelLayout::PanelRow::triangle); + dumpField("rowChecks", layout.rows, &PlotView::EdgePanelLayout::PanelRow::check); + dumpField("rowStats", layout.rows, &PlotView::EdgePanelLayout::PanelRow::stats); + dumpField("rowLevelTracks", layout.rows, &PlotView::EdgePanelLayout::PanelRow::levelTrack); + dumpField("rowLevelValues", layout.rows, &PlotView::EdgePanelLayout::PanelRow::levelValue); + dumpField("rowLevelResets", layout.rows, &PlotView::EdgePanelLayout::PanelRow::levelReset); + dumpField("rowIterTracks", layout.rows, &PlotView::EdgePanelLayout::PanelRow::iterTrack); + dumpField("rowIterValues", layout.rows, &PlotView::EdgePanelLayout::PanelRow::iterValue); + dumpField("rowIterResets", layout.rows, &PlotView::EdgePanelLayout::PanelRow::iterReset); + dumpField("rowInfoMaxInterval", layout.rows, + &PlotView::EdgePanelLayout::PanelRow::infoMaxInterval); + dumpField("rowInfoMinInterval", layout.rows, + &PlotView::EdgePanelLayout::PanelRow::infoMinInterval); + dumpField("rowIntervalField", layout.rows, + &PlotView::EdgePanelLayout::PanelRow::intervalField); + dumpField("rowFrequencyField", layout.rows, + &PlotView::EdgePanelLayout::PanelRow::frequencyField); // ---- 自由标记:分组栏 / 表头 / 条目 ---- text += key("markerGroupCount") + "=" + std::to_string(layout.markerGroups.size()) + "\n"; @@ -663,64 +670,47 @@ namespace jdi rect.y + rect.height <= plotRect.y + plotRect.height + 1; Expect(inside, std::string("panel inside plot area: ") + PanelKindName(kind)); - // 不变式:行的几何数组必须与行数**形状一致**。 + // 不变式:**行结构自检**。 // - // ⚠️⚠️ 判据必须**按排版类别**区分 —— 旧版"所有数组全部全等"是**错的**: - // 「自由标记」面板复用 `rowRefs` 表示"信号分组",只填 - // `rowNameRects` / `rowNameCloses`,其余 12 列**必须为空** - // (它真正的行在 `markerRows` / `markerGroups` 里)。 - // 这是 `rowgeom` 第一次带真实固定点运行时越界崩溃所暴露的结构事实。 + // 2026-11-04(第 39 轮)起 `EdgePanelLayout` 的 16 个平行数组已合并成 + // `std::vector rows` ⇒ "某列比别的列短"这类错位**在结构上不可能** + // (一行的字段是一个整体,由 `AppendRow` / `AppendMarkerGroupRow` 一次成型)。 + // 旧的"15 列长度全部等于行数"因此退化为恒真,这里改为守住**语义**: + // * 每一行都必须有名称区(布局漏算就会变成空矩形,绘制时该行不可见/不可点); + // * 「自由标记」的行是**分组行**:参数滑块 / 重采样 / 统计字段必须保持为空 + // (真正的条目行在 `markerRows`),防止有人把列表式布局误用到它上面。 const PanelStyle rowStyle = PanelStyleOf(kind); - const std::size_t rowCount = layout.rowRefs.size(); - bool parallel = true; - if (rowStyle.markerGroups) + bool rowsOk = true; + for (const PlotView::EdgePanelLayout::PanelRow& row : layout.rows) { - parallel = layout.rowNameRects.size() == rowCount && - layout.rowNameCloses.size() == rowCount && layout.rowHeaders.empty() && - layout.rowChecks.empty() && layout.rowLevelTracks.empty() && - layout.rowIterTracks.empty(); - } - else - { - const std::size_t parallelSizes[] = { - layout.rowHeaders.size(), layout.rowNameRects.size(), - layout.rowTriangles.size(), layout.rowChecks.size(), - layout.rowStats.size(), layout.rowLevelTracks.size(), - layout.rowLevelValues.size(), layout.rowLevelResets.size(), - layout.rowIterTracks.size(), layout.rowIterValues.size(), - layout.rowIterResets.size(), layout.rowInfoMaxInterval.size(), - layout.rowInfoMinInterval.size(), layout.rowIntervalField.size(), - layout.rowFrequencyField.size()}; - for (const std::size_t size : parallelSizes) + if (row.nameRect.Width <= 0.0f || row.nameRect.Height <= 0.0f) { - if (size != rowCount) - { - parallel = false; - break; - } + rowsOk = false; + break; + } + if (rowStyle.markerGroups && + (row.header.Width > 0.0f || row.check.Width > 0.0f || + row.stats.Width > 0.0f || row.levelTrack.Width > 0.0f || + row.iterTrack.Width > 0.0f || row.intervalField.Width > 0.0f || + row.frequencyField.Width > 0.0f)) + { + rowsOk = false; + break; } } - if (!parallel) + if (!rowsOk) { - EmitLine("[panels] %s row arrays: refs=%d name=%d nameClose=%d hdr=%d chk=%d lvl=%d/%d/%d " - "iter=%d/%d/%d info=%d/%d fld=%d/%d\n", - PanelKindName(kind), static_cast(layout.rowRefs.size()), - static_cast(layout.rowNameRects.size()), - static_cast(layout.rowNameCloses.size()), - static_cast(layout.rowHeaders.size()), - static_cast(layout.rowChecks.size()), - static_cast(layout.rowLevelTracks.size()), - static_cast(layout.rowLevelValues.size()), - static_cast(layout.rowLevelResets.size()), - static_cast(layout.rowIterTracks.size()), - static_cast(layout.rowIterValues.size()), - static_cast(layout.rowIterResets.size()), - static_cast(layout.rowInfoMaxInterval.size()), - static_cast(layout.rowInfoMinInterval.size()), - static_cast(layout.rowIntervalField.size()), - static_cast(layout.rowFrequencyField.size())); + EmitLine("[panels] %s row structure broken (rows=%d markerGroups=%d)\n", + PanelKindName(kind), static_cast(layout.rows.size()), + layout.markerGroups.size()); + } + Expect(rowsOk, std::string("row structure is sound: ") + PanelKindName(kind)); + // 每一行的信号引用必须有效(布局用无效引用填行 ⇒ 绘制时会跳过该行)。 + for (const PlotView::EdgePanelLayout::PanelRow& row : layout.rows) + { + Expect(row.ref.IsValid() || rowStyle.markerGroups, + std::string("row has a valid signal ref: ") + PanelKindName(kind)); } - Expect(parallel, std::string("row arrays match row count: ") + PanelKindName(kind)); char buffer[160] = {}; std::snprintf(buffer, sizeof(buffer), "%s=%016llX,%d,%d,%d,%d\n", PanelKindName(kind), @@ -845,47 +835,21 @@ namespace jdi EmitLine("[panelscroll] %-8s content=%.1f view=%.1f maxScroll=%.1f scrollable=%d rows=%d\n", name, initial.markerContentHeight, initial.content.Height, maxScroll, initial.scrollTrack.Width > 0.0f ? 1 : 0, - static_cast(initial.rowRefs.size())); + static_cast(initial.rows.size())); - // 多信号场景下同样守住"行数组形状一致"(判据按排版类别区分: - // MARK 只用 rowRefs / rowNameRects / rowNameCloses,详见 `panels` 检查里的说明)。 + // 行结构自检(合并成 `std::vector` 后长度错位已不可能, + // 这里守住"每行都有名称区",详见 `panels` 检查里的说明)。 { - const PanelStyle rowStyle = PanelStyleOf(kind); - const std::size_t rows = initial.rowRefs.size(); - bool aligned = true; - if (rowStyle.markerGroups) + bool rowsOk = true; + for (const PlotView::EdgePanelLayout::PanelRow& row : initial.rows) { - aligned = initial.rowNameRects.size() == rows && - initial.rowNameCloses.size() == rows && initial.rowHeaders.empty() && - initial.rowChecks.empty(); - } - else - { - const std::size_t sizes[] = {initial.rowHeaders.size(), - initial.rowNameRects.size(), - initial.rowTriangles.size(), - initial.rowChecks.size(), - initial.rowStats.size(), - initial.rowLevelTracks.size(), - initial.rowLevelValues.size(), - initial.rowLevelResets.size(), - initial.rowIterTracks.size(), - initial.rowIterValues.size(), - initial.rowIterResets.size(), - initial.rowInfoMaxInterval.size(), - initial.rowInfoMinInterval.size(), - initial.rowIntervalField.size(), - initial.rowFrequencyField.size()}; - for (const std::size_t size : sizes) + if (row.nameRect.Width <= 0.0f || row.nameRect.Height <= 0.0f) { - if (size != rows) - { - aligned = false; - break; - } + rowsOk = false; + break; } } - Expect(aligned, std::string("row arrays match row count: ") + name); + Expect(rowsOk, std::string("every row has a name rect: ") + name); } if (maxScroll <= 1.0f) @@ -1132,7 +1096,7 @@ namespace jdi char line[160] = {}; std::snprintf(line, sizeof(line), "%s.hash=%016llX rows=%d markerRows=%d\n", PanelKindName(kind), static_cast(hash), - static_cast(layout.rowRefs.size()), + static_cast(layout.rows.size()), static_cast(layout.markerRows.size())); scale += line; } diff --git a/Tools/README.md b/Tools/README.md index e013217..1186b94 100644 --- a/Tools/README.md +++ b/Tools/README.md @@ -174,9 +174,9 @@ Tools\OffscreenChecks\Build\OffscreenChecks.exe -list | --- | --- | --- | | `smoke` | 建隐藏窗口 → 加载 → 显示 3 条信号 → 渲染 → 抓帧 → 落盘 `smoke.png` | 客户区/绘图区尺寸合理、绘图区墨迹 > 1000 像素、**同一状态连续两帧逐像素相同**(渲染确定性) | | `frames` | 沿信号列表逐行移动"鼠标",每帧抓图 + 区域哈希 + 耗时 | 平均帧 < 500ms、帧序列**至少出现一次可见变化**;输出 `frame-XXX.png` 供事后分析 | -| `panels` | 打开全部 7 个实验性面板(含双光标下的「选区截取」) | 每个面板:功能已启用、有尺寸、**必须被绘图区夹住**、**行数组与行数形状一致**(按排版类别判定,见 §4.4);`-mode record` 写 `panels-baseline.txt`,`-mode compare` 逐面板比对**像素哈希 + 几何** | -| `panelscroll` | 全部信号合并到同一网格后,逐一滚动 7 种面板的内容区 | 可滚动时:滚到底/滚回顶、`scrollOffset` 夹到 `maxScroll`、滑块到达/返回轨道端点、内容区几何不动;不可滚动时不得出现滚动条。另守住行数组与行数形状一致 | -| `rowgeom` | **逐行几何基线**:7 个面板按「细节 / 规模」两层导出全部矩形并比对 | 细节层(6 信号 1 网格、参数行展开、3 个固定点、折起+展开)导出**每个矩形的每个字段**共 983 条键;规模层(全部信号 1 网格,MF4 = 566 行)导出每面板 FNV-1a 摘要 + 行数。`-mode record` / `-mode compare` 双向比对(MISSING / ADDED / CHANGED) | +| `panels` | 打开全部 7 个实验性面板(含双光标下的「选区截取」) | 每个面板:功能已启用、有尺寸、**必须被绘图区夹住**、**行结构自检**(每行都要有名称区;「自由标记」行不得带参数行字段,见 §4.4);`-mode record` 写 `panels-baseline.txt`,`-mode compare` 逐面板比对**像素哈希 + 几何** | +| `panelscroll` | 全部信号合并到同一网格后,逐一滚动 7 种面板的内容区 | 可滚动时:滚到底/滚回顶、`scrollOffset` 夹到 `maxScroll`、滑块到达/返回轨道端点、内容区几何不动;不可滚动时不得出现滚动条。另守住“每行都有名称区” | +| `rowgeom` | **逐行几何基线**:7 个面板按「细节 / 规模」两层导出全部矩形并比对 | 细节层(6 信号 1 网格、参数行展开、3 个固定点、折起+展开)导出**每行全部字段**共 965 条键(`-data MF4-Record.MF4` 时 1268 条);规模层(全部信号 1 网格)导出每面板 FNV-1a 摘要 + 行数。`-mode record` / `-mode compare` 双向比对(MISSING / ADDED / CHANGED) | | `edgestats` | 3 个阈值下的边沿计数(普通计数)+ 中心轴计数 | 与 `Checks.cpp` 内的**独立参考实现**(朴素逐点)**逐位相等**;同参数重复查询结果稳定 | | `perf` | 整帧中位数、`EdgePanelLayoutFor` 单次耗时、198 个统计状态的整轮查询耗时 | 整帧 < 500ms、面板几何 < 200µs/次、统计查询 < 20µs/次;数字用于前后对比 | @@ -208,16 +208,20 @@ OffscreenChecks.exe -run panels -mode compare -out ..\Temp\tools\panel-before **基线是检测器,不是黄金图**:预期内的几何变化要重新 `-mode record`,并在提交信息里写明原因。 -### 4.4 长度不变式为什幺要“按类别”判定 +### 4.4 长度不变式为什幺要“按类别”判定(2026-11-04 已作废,改为结构+语义断言) -`EdgePanelLayout::rowRefs` 是**重载字段**: +> ⚠️ 本节描述的历史问题**已经不存在**:`EdgePanelLayout` 的 16 个平行数组 +> 已在第 39 轮合并为 `std::vector rows`,“某列比别的列短”在结构上不可能。 +> 现在 `panels` / `panelscroll` 守的是**语义**:每行必须有名称区;「自由标记」行不得带 +> `header`/`check`/`stats`/滑块/重采样字段。`rowgeom` 的键名(`.<旧列名>[NNN]`) +> 刻意保持不变,因此重构前后可以直接逐键比对(919 → 965 键时,消失 0 条、 +> 已有值变化仅 20 条 `.count`、新增 46 条全为空矩形 ⇒ 几何零位移)。 -* 列表式面板(均值/中心轴/弱峰/重采样/截取):`rowRefs[i]` = 第 i 行的信号; -* 「自由标记」面板:`rowRefs[i]` = 第 i 个**信号分组**,真正的条目行在 `markerRows`, - 且只填 `rowNameRects` / `rowNameCloses`,其余 12 列**必须为空**。 - -所以判据按 `PanelStyleOf(kind).markerGroups` 分流(见 `Checks.cpp` 里 `panels` 的注释)。 -早先“18 列全等”的写法对 MARK 面板是**错的**,只因既有场景恰好未触发才没暴露。 +历史背景(第 37–38 轮):当时 `EdgePanelLayout::rowRefs` 是**重载字段** —— +列表式面板里是"第 i 行的信号",而「自由标记」面板里是"第 i 个信号分组", +且只填 `rowNameRects` / `rowNameCloses`,其余 12 列必须为空。 +因此那时的长度不变式只能按 `PanelStyleOf(kind).markerGroups` 分流判定。 +第 39 轮合并成 `PanelRow` 后,这类"同一字段两种含义"的判据全部消失。 ### 4.5 输出(全部在 `Temp/tools/`,已 gitignore) -- 2.54.0 From a9f1563896031f8f7531c68d81433504e0665de8 Mon Sep 17 00:00:00 2001 From: "DeepSeek V4.1 Flash" <> Date: Wed, 4 Nov 2026 16:36:47 +0800 Subject: [PATCH 21/22] =?UTF-8?q?refactor(panels):=20=E5=87=A0=E4=BD=95?= =?UTF-8?q?=E7=AE=97=E5=BC=8F=E6=94=B6=E6=95=9B=E4=B8=BA=E5=8D=95=E4=B8=80?= =?UTF-8?q?=E6=9D=A5=E6=BA=90=20+=20=E5=8F=82=E6=95=B0=E8=A1=8C=E6=8F=90?= =?UTF-8?q?=E5=8F=96=EF=BC=88=C2=A717.6=20=E7=AC=AC=201/2=20=E9=A1=B9?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 第 1 项:宽度算式与行高累加各自合并成一份来源 - 新增 `PanelColumnMetrics` + `MeasurePanelColumns(lane, kind)`:全仓唯一的一次"逐信号量宽"。 * `EdgePanelWidth(laneIndex, kind)` 退化为"量一次 + 算一次"(`EdgePanelWidthFor`); * `EdgePanelLayoutFor` 量一次后,宽度与列几何(表格列 / 滑块轨道)都读同一份度量。 * 改前两者各写一份同名同形的 UiMeasureTextWidth 链,注释只写着"同一组算式 ⇒ 不会漂移", 那是靠人守的约定,改一处漏一处就是"面板变宽而列不动"(文字压到轨道上)。 - 新增 `PanelRowPlan` + `PlanListPanelRows(lane, kind)`:行高累加与行几何的唯一来源。 * 改前 `EdgePanelLayoutFor` 对同一批信号遍历两趟(先算总高、再出行几何), 两处判定(可见/勾选/收起/统计行/参数行数)必须逐字一致 —— 这类"两趟必须同步修改" 正是"改一次布局就出错"的另一半机制。 * 现在一个 {ref, checked, collapsed, hasStats, sliderRows} 数组同时喂高度算式与几何生成。 - 合并前先做等价性判别:`valueColumn` 两处算法不同但结果相同(100000 最宽)⇒ 可合并; `sliderLabel` 与 `resampleLabel` 不可合并(重采样标签更长,并进会让均值/弱峰变宽)。 第 2 项:`PaintPanelListRows` 的通用参数行提取 - 新增 `PaintPanelParamRows(g, layout, row, kind, PanelParamState{…})`:中心轴统计行 + 两条滑块分支(均值/弱峰),`paintCounts`/`paintMeanSlider`/`paintPeakSlider` 三个 lambda 与 `valueColumn` 随之迁入。 - 逐行状态打包进 `PanelParamState`(解析结果 / 两种条目指针 / 网格下标)⇒ 参数表 5 个, 而不是 9 个散参数(签名比函数体更难读)。 - `PaintPanelListRows` 由约 300 行降到约 120 行,只负责"条目栏 + 按 kind 分派"。 - 顺手把机械搬移遗留的"整个函数体多缩进 2 个 tab"修正。 过程记录(两次锚点误伤,已写入文档避免重犯) - 锚点 `// 两行滑块` 在 `EdgePanelWidthFor` 内也匹配 ⇒ 误删宽度函数尾部; - 锚点 `float rowY = ...` 在表格分支先出现 ⇒ 误删「边沿计数」的行发射。 两次都是编译器立刻报错,从 Temp 备份回滚后重做。规则:整段替换的锚点必须唯一, 同名语句多次出现时先定位所属区块再取偏移。 第 3 项(体积)按用户指示暂停投入。 验证(双基线均为"零差异") - make release / make debug / make -C Tools:零 warning;产物 782,336 → 781,824 B(−512) - UnitTests:checks=260 failed=0 cases=19 - OffscreenChecks -all:7 检查 / 133 断言全 PASS - panels -mode compare:7 面板像素哈希逐项不变(含 resample=2BFC74B3D9B3239C) - rowgeom -mode compare:965+7 键 0 差异;-data MF4-Record.MF4 时 1268+7 键 0 差异 文档:Plan §十二、Progress 第 40 轮、Report §17、agent_mem 第 40 轮 --- Components/PlotView/Inc/PlotView.hpp | 139 ++++ .../PlotView/Src/PlotViewPanelPaint.cpp | 676 +++++++++--------- Components/PlotView/Src/PlotViewPanels.cpp | 563 ++++++++------- Documents/Code_Review_Plan.md | 29 + Documents/Code_Review_Progress.md | 85 +++ Documents/Code_Review_Report.md | 74 ++ Documents/agent_mem.md | 56 ++ 7 files changed, 1023 insertions(+), 599 deletions(-) diff --git a/Components/PlotView/Inc/PlotView.hpp b/Components/PlotView/Inc/PlotView.hpp index acbde31..4ef3d03 100644 --- a/Components/PlotView/Inc/PlotView.hpp +++ b/Components/PlotView/Inc/PlotView.hpp @@ -1972,6 +1972,108 @@ namespace jdi */ bool IsOnEdgeCursorLine(int x, int y, int& laneIndex) const; + /** + * @brief 面板的「逐信号量宽」结果(宽度算式与列几何的**唯一来源**)。 + * + * ⚠️⚠️ 历史上 `EdgePanelWidth`(决定面板宽)与 `EdgePanelLayoutFor`(决定列边界) + * 各自写了一份**同名同形**的 `UiMeasureTextWidth` 链(标签列 / 数值列 / 重采样列), + * 注释里写着"与 `EdgePanelWidth` 同一组算式 ⇒ 不会漂移" —— 但那是**靠人守**的约定: + * 改一处漏一处,面板变宽而列不动(或反之),表现为文字压到轨道上 / 右边留一大片空白。 + * 现在两者都读同一个 `PanelColumnMetrics`,**没有第二份算式**。 + */ + struct PanelColumnMetrics + { + /** 可见信号数(= 面板的数据行数上限;「自由标记」只算有固定点的信号)。 */ + std::size_t signalCount = 0; + /** 名称列最大宽度(已按 `kEdgePanelNameMaxWidth` 夹取)。 */ + float nameColumn = 0.0f; + /** 「边沿计数」表格 / 「中心轴」统计列:上升沿文本宽度(含“统计中…”)。 */ + float statRising = 0.0f; + /** 下降沿文本宽度。 */ + float statFalling = 0.0f; + /** + * @brief 参数行的**标签列**宽度(「均值滤波」/「弱峰调整」两种标签取最大)。 + * + * 两种面板共用同一列宽,这样同一个网格里切换面板时滑块不会横向跳动。 + * ⚠️ **不含**「重采样」的标签(它更长,见 `resampleLabel`):把它并进来会让 + * 均值/弱峰面板整体变宽 —— 历史行为就是分开算的,合并会改变像素。 + */ + float sliderLabel = 0.0f; + /** @brief 「重采样」四行的标签列宽度(四者取最大,比均值/弱峰的标签更长)。 */ + float resampleLabel = 0.0f; + /** + * @brief 参数行的**数值列**宽度(`100000` 这类最宽数值文本)。 + * + * 它同时是"数值文本的裁剪宽度"(绘制侧)与"宽度预算的一项"(宽度算式), + * 两处必须同值,否则数值会被截断或右侧留白。 + */ + float valueColumn = 0.0f; + /** + * @brief 「重采样」两行只读统计的数值列宽度(逐信号实测真实文本,含(频率)尾缀)。 + * + * 用户要求统计文本**不得省略**,所以这一列必须按真实内容量宽。 + */ + float resampleValueColumn = 0.0f; + }; + /** + * @brief 量取某网格某面板的逐信号列宽(只读、无副作用)。 + * + * ⚠️ 本函数会被鼠标移动的高频路径间接调用(`TopPanelAtPoint` / `SyncPanelOrder`), + * 因此**不得**逐条查询采样值:边沿统计只能走 `EdgeCountsFor` / `EdgeCenterCountsFor` + * 的缓存入口,枚举列只允许查 `SignalEnumText::Empty()`。 + * + * @param [IN] lane 目标网格。 + * @param [IN] kind 面板种类。 + * @return 列宽度量(`signalCount` 与各列宽度)。 + */ + PanelColumnMetrics MeasurePanelColumns(const Lane& lane, EdgePanelKind kind) const; + + /** + * @brief 依据已量好的 `PanelColumnMetrics` 计算面板宽度(宽度算式的**唯一实现**)。 + * + * `EdgePanelLayoutFor` 与 `EdgePanelWidth` 都走它,且前者会把同一份 metrics 继续用于 + * 列几何 ⇒ 宽度与列边界**不可能不一致**(旧结构里两份算式各量一遍,只要有一处改了 + * 就会出现"面板变宽而列不动")。 + * + * @param [IN] lane 目标网格。 + * @param [IN] kind 面板种类。 + * @param [IN] metrics 列宽度量(必须由 `MeasurePanelColumns(lane, kind)` 得到)。 + * @return 面板宽度(已夹到绘图区宽度之内)。 + */ + float EdgePanelWidthFor(const Lane& lane, EdgePanelKind kind, + const PanelColumnMetrics& metrics) const; + + /** + * @brief 「列表式面板」的一行**计划**(行高累加与行几何的**唯一来源**)。 + * + * ⚠️⚠️ 旧实现有**两趟独立遍历**:第一趟只累加内容高度(`meanContentHeight`), + * 第二趟才真正生成 `rows` 并累加 `rowY`。两趟的判定条件(可见性 / 勾选 / 收起 / + * 是否需要统计行 / 参数行数)必须**逐字一致**,任何一处写错就表现为 + * "内容区高度与实际画出来的行不一致"(底部留白、或最后一行被裁掉、或明明没内容却能滚动)。 + * 现在只遍历一次得到本结构,高度与几何都从它派生 ⇒ 结构上不可能不一致。 + */ + struct PanelRowPlan + { + SignalRef ref; /**< 该行的信号。 */ + bool checked = false; /**< 是否已勾选(决定展开三角与参数行是否存在)。 */ + bool collapsed = false; /**< 条目是否收起(收起时不生成参数行)。 */ + bool hasStats = false; /**< 是否有一行「上升 / 下降沿」统计(仅「中心轴」)。 */ + /** 参数行行数:0 = 不展开,2 = 两行滑块,4 = 「重采样」四行。 */ + int sliderRows = 0; + }; + /** + * @brief 生成某网格某「列表式」面板的逐信号行计划。 + * + * 「边沿计数」是表格、「自由标记」是分组结构,两者都**不**使用本函数。 + * 「选区截取」的行不可展开 ⇒ `sliderRows` 恒为 0(是否生成行由调用方按 + * 「双光标是否就绪」决定:未就绪时整个计划作废,面板只显示一行提示)。 + * + * @param [IN] lane 目标网格。 + * @param [IN] kind 面板种类。 + * @return 行计划(顺序 = 从上到下)。 + */ + std::vector PlanListPanelRows(const Lane& lane, EdgePanelKind kind) const; + /** * @brief 实验性功能浮动面板的几何布局(三类面板共用同一套样式)。 */ @@ -2315,6 +2417,43 @@ namespace jdi std::size_t laneIndex, const SignalRef& ref, const Lane::ResampleItem& item); + /** + * @brief 「列表式面板」**单个信号参数行**绘制所需的逐行状态(一次收集、一次传入)。 + * + * 打包成结构的目的是把 `PaintPanelParamRows` 的参数表压到 5 个 + * (`g` / `layout` / `row` / `kind` / `state`):参数行的绘制需要"解析结果 + 两种条目 + * 指针 + 网格下标"四样东西,逐个透传会让签名比函数体更难读。 + */ + struct PanelParamState + { + /** 该信号的解析结果(「中心轴」统计行要用 `axis` / `data` / `style`)。 */ + const ResolvedSignal* resolved = nullptr; + /** 「均值滤波」/「中心轴边沿计数」的逐信号参数(未勾选时为空)。 */ + const Lane::MeanFilterItem* meanItem = nullptr; + /** 「弱峰调整」的逐信号参数(未勾选时为空)。 */ + const Lane::WeakPeakItem* peakItem = nullptr; + /** 网格下标(与 `m_meanDragLane` / `m_editLane` 等状态比对)。 */ + std::size_t laneIndex = 0; + }; + + /** + * @brief 「列表式面板」**单个信号的参数行**(中心轴统计行 + 两行滑块)。 + * + * 从 `PaintPanelListRows` 里提取(第 40 轮):三行/两行的滑块排版(标签 / 轨道 / 数值 / + * 复位按钮,含拖拽态、悬停态、编辑态)与"条目栏"没有共同点,留在同一个函数里只会让 + * 行循环膨胀到三百行。**零逻辑改动**,判据 = `panels -mode compare` + `rowgeom -mode compare`。 + * 定义在 `PlotViewPanelPaint.cpp`(与另外两个内容函数同一分片)。 + * + * @param [IN,OUT] g 绘图上下文(调用方已按内容区裁剪)。 + * @param [IN] layout 面板布局(内容区裁剪矩形、滑块/统计行矩形都来自它)。 + * @param [IN] row 该行的几何(`stats` / `levelTrack` / `iterTrack` 等字段)。 + * @param [IN] kind 面板种类(CENTER / MEAN / PEAK)。 + * @param [IN] state 逐行状态(解析结果 / 条目指针 / 网格下标)。 + */ + void PaintPanelParamRows(gd::Graphics& g, const EdgePanelLayout& layout, + const EdgePanelLayout::PanelRow& row, EdgePanelKind kind, + const PanelParamState& state); + /** * @brief 「自由标记」面板的**内容区**(分组栏 + 表头 + 条目 + 吸顶)。 * diff --git a/Components/PlotView/Src/PlotViewPanelPaint.cpp b/Components/PlotView/Src/PlotViewPanelPaint.cpp index 72f65e4..bf217ef 100644 --- a/Components/PlotView/Src/PlotViewPanelPaint.cpp +++ b/Components/PlotView/Src/PlotViewPanelPaint.cpp @@ -118,369 +118,373 @@ namespace jdi * rest: two slider rows). */ void PlotView::PaintPanelListRows(gd::Graphics& g, const EdgePanelLayout& layout, - const Lane& tabLane, EdgePanelKind kind, - std::size_t laneIndex) + const Lane& tabLane, EdgePanelKind kind, + std::size_t laneIndex) { const bool centerAxis = (kind == EdgePanelKind::CENTER); const bool weakPeak = (kind == EdgePanelKind::PEAK); const bool resamplePanel = (kind == EdgePanelKind::RESAMPLE); const bool clipPanel = (kind == EdgePanelKind::CLIP); const std::size_t index = laneIndex; - // "Rising / falling" two-column layout (fixed column origins => strictly aligned). - // NOTE: must use UiTextClipped -- GDI text ignores GDI+ SetClip. + const int meanSave = g.Save(); + // 内容必须裁到内容区:滚动时行会越出可视范围。 + g.SetClip(layout.content, gd::CombineModeIntersect); + + // 复选框自绘(主题色在信号色叠底上看不清):用**自适应对比色**描边/填充。 + const auto paintMeanCheck = [this, &g](const gd::RectF& rect, bool checked, const UiInk& ink, + std::uint32_t background) + { + if (rect.Width <= 0.0f || rect.Height <= 0.0f) + { + return; + } + const float radius = 2.5f * m_scale; + if (!checked) + { + UiDrawRoundRect(g, rect, radius, ink.text, 1.2f * m_scale); + return; + } + UiFillRoundRect(g, rect, radius, ink.text); + // 对勾:两段折线,颜色取叠底色(与填充色必成对比)。 + const float left = rect.X + rect.Width * 0.22f; + const float middleX = rect.X + rect.Width * 0.44f; + const float right = rect.X + rect.Width * 0.78f; + const float highY = rect.Y + rect.Height * 0.30f; + const float lowY = rect.Y + rect.Height * 0.72f; + UiDrawLine(g, left, rect.Y + rect.Height * 0.52f, middleX, lowY, background, + 1.6f * m_scale); + UiDrawLine(g, middleX, lowY, right, highY, background, 1.6f * m_scale); + }; + + for (std::size_t rowIndex = 0; rowIndex < layout.rows.size(); ++rowIndex) + { + const SignalRef& ref = layout.rows[rowIndex].ref; + const ResolvedSignal resolved = ResolveSignal(ref); + if (resolved.data == nullptr || resolved.style == nullptr) + { + continue; + } + const gd::RectF& header = layout.rows[rowIndex].header; + // 行的**可见性**必须按“整行的纵向范围”判定,不能只看条目栏: + // 条目栏已滚出内容区,但它下面两行参数滑块仍可能还在可视区内 + //(用户 2026-10-30 报告:此时两个滑块会直接消失 —— 显示错误)。 + // 整行的纵向范围 = 条目栏 ∪ 全部参数行(未使用的字段恒为空矩形,直接 extent 即可; + // 旧结构里每一项都要先判断"数组可能更短",现在一行就是一个整体)。 + const EdgePanelLayout::PanelRow& row = layout.rows[rowIndex]; + float rowTop = row.header.Y; + float rowBottom = row.header.Y + row.header.Height; + const auto extendRow = [&rowBottom](const gd::RectF& rect) { + if (rect.Width > 0.0f && rect.Height > 0.0f) + { + rowBottom = std::max(rowBottom, rect.Y + rect.Height); + } + }; + extendRow(row.levelTrack); + extendRow(row.levelValue); + extendRow(row.levelReset); + extendRow(row.iterTrack); + extendRow(row.iterValue); + extendRow(row.iterReset); + extendRow(row.stats); + extendRow(row.infoMaxInterval); + extendRow(row.infoMinInterval); + extendRow(row.intervalField); + extendRow(row.frequencyField); + if (rowBottom <= layout.content.Y || + rowTop >= layout.content.Y + layout.content.Height) + { + continue; // 整行(含全部参数行)完全滚出可视范围 + } + const std::uint32_t signalColor = StyleColor(*resolved.style); + const UiInk ink = UiContrastInk(signalColor); + // 「均值滤波」看 `meanFilterItems`、「中心轴边沿计数」看 `edgeCenterItems`、 + //「弱峰调整」看 `weakPeakItems`(前两者同为 `Lane::MeanFilterItem`)。 + const Lane::WeakPeakItem* peakItem = + weakPeak ? tabLane.FindWeakPeakItem(ref) : nullptr; + const Lane::MeanFilterItem* item = + weakPeak ? nullptr + : (centerAxis ? tabLane.FindEdgeCenterItem(ref) + : tabLane.FindMeanFilterItem(ref)); + const Lane::ResampleItem* resampleItem = + resamplePanel ? tabLane.FindResampleItem(ref) : nullptr; + const bool checked = clipPanel ? tabLane.ClipEnabled(ref) + : (resamplePanel ? (resampleItem != nullptr) + : (weakPeak ? (peakItem != nullptr) + : (item != nullptr))); + const bool rowCollapsed = + clipPanel ? true + : (resamplePanel ? (resampleItem != nullptr && resampleItem->collapsed) + : (weakPeak ? (peakItem != nullptr && peakItem->collapsed) + : (item != nullptr && item->collapsed))); + + // 内容区已由 `g.SetClip(layout.content)` 裁切(行可以“半截”显示,滚动更顺滑); + // 已经完整滚出可视范围的行在上面就 `continue` 了,这里不再额外门控。 + UiFillRoundRect(g, header, 3.0f * m_scale, signalColor); + UiFillRoundRect(g, header, 3.0f * m_scale, WithAlpha(signalColor, 0xE8)); + // 条目栏悬停:在信号色叠底上再压一层强调色,给出"这一行对应哪条曲线"的提示 + //(曲线本身的高亮见 `PaintLaneDerivedOverlays`)。 + // ⚠️ 必须同时比**面板种类**:同一条信号在其它面板里也有一行, + // 只比 (lane, ref) 会把别的面板的同名行一起点亮。 + if (m_meanRowHoverLane == static_cast(index) && + m_meanRowHoverRef == ref && m_meanRowHoverKind == kind) + { + UiFillRoundRect(g, header, 3.0f * m_scale, + WithAlpha(m_theme->accent, 0x2E)); + } + + // 左侧三角(仅勾选时存在):朝下 = 展开、朝右 = 收起。 + const gd::RectF& triangle = layout.rows[rowIndex].triangle; + if (triangle.Width > 0.0f && checked) + { + UiDrawTriangleIcon(g, triangle, ink.text, + rowCollapsed ? IconDirection::RIGHT + : IconDirection::DOWN); + } + + // 名称:从三角右侧到复选框左侧。 + const gd::RectF& check = layout.rows[rowIndex].check; + const float nameLeft = (triangle.Width > 0.0f) + ? (triangle.X + triangle.Width + 6.0f * m_scale) + : (header.X + 8.0f * m_scale); + const float nameWidth = + std::max(10.0f, check.X - 6.0f * m_scale - nameLeft); + UiTextClipped(g, UiEllipsize(resolved.data->name, m_fonts->uiSmall, nameWidth), + m_fonts->uiSmall, + gd::RectF(nameLeft, header.Y, nameWidth, header.Height), ink.text, + TextAlign::LEFT, layout.content); + paintMeanCheck(check, checked, ink, signalColor); + + if (!checked || rowCollapsed) + { + continue; + } + + // 「重采样」:四行 = 最大间隔(只读)/ 最小间隔(只读)/ + // 重采样间隔(输入)/ 重采样频率(输入)。标签在左、数值在右。 + // 排布细节见 `PaintPanelResampleRows`(从本循环里提取的独立函数)。 + if (resamplePanel) + { + PaintPanelResampleRows(g, layout, rowIndex, index, ref, *resampleItem); + continue; + } + + // 参数行(中心轴统计行 + 两行滑块)见 `PaintPanelParamRows`: + // 三/两行的滑块排版与"条目栏"没有共同点,留在本循环里只会让行循环膨胀到三百行。 + PaintPanelParamRows(g, layout, row, kind, + PanelParamState{&resolved, item, peakItem, index}); + } + + g.Restore(meanSave); + + // ---- 内容滚动条(只在内容超高时出现;底部按钮不参与滚动)---- + // ⚠️ 必须在 `g.Restore(meanSave)` **之后**画:滚动条位于内容区右侧、在 + // 内容裁剪区之外,若在裁剪内画会被整条裁掉(用户 2026-10-30 报告 + // "全部启用后没有出现滚动条"的根因)。 + PaintPanelScrollBar(g, layout, *m_theme); + } + + /** + * @brief 「列表式面板」**单个信号的参数行**(中心轴统计行 + 两行滑块)。 + * + * 从 `PaintPanelListRows` 里整块提取(第 40 轮):滑块排版(标签 / 轨道 / 数值 / 复位按钮, + * 含拖拽态 / 悬停态 / 编辑态)与"条目栏"没有共同点。**零逻辑改动**。 + */ + void PlotView::PaintPanelParamRows(gd::Graphics& g, const EdgePanelLayout& layout, + const EdgePanelLayout::PanelRow& row, EdgePanelKind kind, + const PanelParamState& state) + { + const bool centerAxis = (kind == EdgePanelKind::CENTER); + const bool weakPeak = (kind == EdgePanelKind::PEAK); + const std::size_t index = state.laneIndex; + const ResolvedSignal& resolved = *state.resolved; + const Lane::MeanFilterItem* item = state.meanItem; + const Lane::WeakPeakItem* peakItem = state.peakItem; + // 数值列的**裁剪宽度**:与布局里的 `metrics.valueColumn` 同值(`100000` 是最宽数值)。 + const float valueColumn = std::max(UiMeasureTextWidth(m_fonts->monoSmall, L"100000"), + UiMeasureTextWidth(m_fonts->monoSmall, L"1000")); + + // 「上升沿 / 下降沿」两列排版(列起点固定 ⇒ 多条信号严格垂直对齐)。 + // ⚠️ 必须用 `UiTextClipped`(而不是 `UiText`):GDI 文本不受 GDI+ `SetClip` 约束。 const auto paintCounts = [this, &g](const gd::RectF& rect, std::size_t rising, std::size_t falling, bool complete, const gd::RectF& clip) { if (EdgeCountsPending(rising, falling, complete)) { - UiTextClipped(g, L"\u7edf\u8ba1\u4e2d\u2026", m_fonts->monoSmall, rect, - m_theme->textMuted, TextAlign::LEFT, clip); + UiTextClipped(g, L"统计中…", m_fonts->monoSmall, rect, m_theme->textMuted, + TextAlign::LEFT, clip); return; } const float gap = kEdgePanelStatColumnGap * m_scale; const float columnWidth = std::max(10.0f, (rect.Width - gap) * 0.5f); UiTextClipped(g, EdgeRisingText(rising, complete), m_fonts->monoSmall, - gd::RectF(rect.X, rect.Y, columnWidth, rect.Height), - m_theme->textMuted, TextAlign::LEFT, clip); + gd::RectF(rect.X, rect.Y, columnWidth, rect.Height), m_theme->textMuted, + TextAlign::LEFT, clip); UiTextClipped(g, EdgeFallingText(falling, complete), m_fonts->monoSmall, - gd::RectF(rect.X + columnWidth + gap, rect.Y, columnWidth, - rect.Height), + gd::RectF(rect.X + columnWidth + gap, rect.Y, columnWidth, rect.Height), m_theme->textMuted, TextAlign::LEFT, clip); }; - const int meanSave = g.Save(); - // 内容必须裁到内容区:滚动时行会越出可视范围。 - g.SetClip(layout.content, gd::CombineModeIntersect); - const float valueColumn = - std::max(UiMeasureTextWidth(m_fonts->monoSmall, L"100000"), - UiMeasureTextWidth(m_fonts->monoSmall, L"1000")); + // 「中心轴边沿计数」:参数行最上面是**同一行**的「上升沿 / 下降沿」统计。 + if (centerAxis && row.stats.Width > 0.0f) + { + std::size_t rising = 0; + std::size_t falling = 0; + bool complete = true; + if (resolved.axis != nullptr) + { + EdgeCenterCountsFor(*resolved.axis, *resolved.data, *resolved.style, item->points, + item->iterations, EdgeRangeActive(), EdgeRangeMin(), + EdgeRangeMax(), rising, falling, complete); + } + paintCounts(row.stats, rising, falling, complete, layout.content); + } - // 复选框自绘(主题色在信号色叠底上看不清):用**自适应对比色**描边/填充。 - const auto paintMeanCheck = [this, &g](const gd::RectF& rect, bool checked, - const UiInk& ink, - std::uint32_t background) + // 两行参数滑块(标签 / 轨道 / 数值 / 复位按钮)。 + const auto paintMeanSlider = [&](const gd::RectF& track, const gd::RectF& valueRect, + const gd::RectF& resetRect, const wchar_t* label, + int current, int minimum, int maximum, bool active) + { + const float labelLeft = track.X - 6.0f * m_scale - + UiMeasureTextWidth(m_fonts->uiSmall, label); + // ⚠️ **必须用 `UiTextClipped`(而不是 `UiText`)**:GDI 文本不受 + // GDI+ `SetClip` 约束,滚动时标签/数值会画到内容区之外 + //(用户 2026-10-30 报告"元素超出了浮动面板"的直接原因)。 + UiTextClipped(g, label, m_fonts->uiSmall, + gd::RectF(labelLeft, track.Y - 8.0f * m_scale, + UiMeasureTextWidth(m_fonts->uiSmall, label), + track.Height + 16.0f * m_scale), + m_theme->textMuted, TextAlign::LEFT, layout.content); + const double ratio = + (maximum > minimum) ? (static_cast(current - minimum) / + static_cast(maximum - minimum)) + : 0.0; + UiDrawSlider(g, track, ratio, m_theme->accent, active, m_scale); + UiTextClipped(g, FormatInteger(current), m_fonts->monoSmall, + gd::RectF(valueRect.X, valueRect.Y, valueColumn, valueRect.Height), + m_theme->textStrong, TextAlign::LEFT, layout.content); + // 复位按钮:悬停高亮 + 与「系数/偏移」菜单同款图标。 + const bool hot = + (m_dragMode != PlotDragMode::EDGE_PANEL_MOVE) && + UiPointInRect(resetRect, static_cast(m_mouseX), static_cast(m_mouseY)); + if (hot) + { + UiFillRoundRect(g, + gd::RectF(resetRect.X - 1.0f * m_scale, + resetRect.Y - 1.0f * m_scale, + resetRect.Width + 2.0f * m_scale, + resetRect.Height + 2.0f * m_scale), + 3.0f * m_scale, WithAlpha(m_theme->accent, 0x33)); + } + PaintPanelResetIcon(g, resetRect, hot ? m_theme->accent : m_theme->textMuted, m_scale); + }; + + // ⚠️ 「均值点数」的最小值:**均值滤波 = 1**(用户 2026-10-30 要求不能为 0, + // 0 与不做平滑完全等价,看起来像功能坏了);**中心轴边沿计数 = 0** + // (0 = 不平滑,直接对原始采样值统计边沿 —— 这是有意义的选项,保持旧语义)。 + if (weakPeak) + { + // 「弱峰调整」两行滑块:调整幅度(0 在正中间 ⇒ ±400%)与迭代次数(1 … 自定上限)。 + const int peakAmount = + std::max(kWeakPeakAmountMin, std::min(kWeakPeakAmountMax, peakItem->amountPercent)); + const int peakMax = + std::max(kWeakPeakIterationsMin, + std::min(kWeakPeakIterationsMaxLimit, peakItem->maxIterations)); + const int peakIter = + std::max(kWeakPeakIterationsMin, std::min(peakMax, peakItem->iterations)); + const bool levelActive = (m_dragMode == PlotDragMode::PEAK_LEVEL) && + (m_peakDragLane == static_cast(index)) && + (m_peakDragRef == row.ref); + const bool iterActive = (m_dragMode == PlotDragMode::PEAK_ITERATIONS) && + (m_peakDragLane == static_cast(index)) && + (m_peakDragRef == row.ref); + const bool editingMax = (m_editTarget == PlotEditTarget::PEAK_ITERATIONS_MAX) && + (m_editLane == static_cast(index)) && + (m_editMeanRef == row.ref); + // 幅度滑块:比例 = (幅度 / (100*4) + 1) / 2(0% 在正中间)。 + const auto paintPeakSlider = [&](const gd::RectF& track, const gd::RectF& valueRect, + const gd::RectF& resetRect, const wchar_t* label, + double ratio, const std::wstring& valueText, + bool active, bool hideBody) + { + const float labelLeft = track.X - 6.0f * m_scale - + UiMeasureTextWidth(m_fonts->uiSmall, label); + UiTextClipped(g, label, m_fonts->uiSmall, + gd::RectF(labelLeft, track.Y - 8.0f * m_scale, + UiMeasureTextWidth(m_fonts->uiSmall, label), + track.Height + 16.0f * m_scale), + m_theme->textMuted, TextAlign::LEFT, layout.content); + if (hideBody) { - if (rect.Width <= 0.0f || rect.Height <= 0.0f) - { - return; - } - const float radius = 2.5f * m_scale; - if (!checked) - { - UiDrawRoundRect(g, rect, radius, ink.text, 1.2f * m_scale); - return; - } - UiFillRoundRect(g, rect, radius, ink.text); - // 对勾:两段折线,颜色取叠底色(与填充色必成对比)。 - const float left = rect.X + rect.Width * 0.22f; - const float middleX = rect.X + rect.Width * 0.44f; - const float right = rect.X + rect.Width * 0.78f; - const float highY = rect.Y + rect.Height * 0.30f; - const float lowY = rect.Y + rect.Height * 0.72f; - UiDrawLine(g, left, rect.Y + rect.Height * 0.52f, middleX, lowY, background, - 1.6f * m_scale); - UiDrawLine(g, middleX, lowY, right, highY, background, 1.6f * m_scale); - }; - - for (std::size_t rowIndex = 0; rowIndex < layout.rows.size(); ++rowIndex) - { - const SignalRef& ref = layout.rows[rowIndex].ref; - const ResolvedSignal resolved = ResolveSignal(ref); - if (resolved.data == nullptr || resolved.style == nullptr) - { - continue; - } - const gd::RectF& header = layout.rows[rowIndex].header; - // 行的**可见性**必须按“整行的纵向范围”判定,不能只看条目栏: - // 条目栏已滚出内容区,但它下面两行参数滑块仍可能还在可视区内 - //(用户 2026-10-30 报告:此时两个滑块会直接消失 —— 显示错误)。 - // 整行的纵向范围 = 条目栏 ∪ 全部参数行(未使用的字段恒为空矩形,直接 extent 即可; - // 旧结构里每一项都要先判断"数组可能更短",现在一行就是一个整体)。 - const EdgePanelLayout::PanelRow& row = layout.rows[rowIndex]; - float rowTop = row.header.Y; - float rowBottom = row.header.Y + row.header.Height; - const auto extendRow = [&rowBottom](const gd::RectF& rect) { - if (rect.Width > 0.0f && rect.Height > 0.0f) - { - rowBottom = std::max(rowBottom, rect.Y + rect.Height); - } - }; - extendRow(row.levelTrack); - extendRow(row.levelValue); - extendRow(row.levelReset); - extendRow(row.iterTrack); - extendRow(row.iterValue); - extendRow(row.iterReset); - extendRow(row.stats); - extendRow(row.infoMaxInterval); - extendRow(row.infoMinInterval); - extendRow(row.intervalField); - extendRow(row.frequencyField); - if (rowBottom <= layout.content.Y || - rowTop >= layout.content.Y + layout.content.Height) - { - continue; // 整行(含全部参数行)完全滚出可视范围 - } - const std::uint32_t signalColor = StyleColor(*resolved.style); - const UiInk ink = UiContrastInk(signalColor); - // 「均值滤波」看 `meanFilterItems`、「中心轴边沿计数」看 `edgeCenterItems`、 - //「弱峰调整」看 `weakPeakItems`(前两者同为 `Lane::MeanFilterItem`)。 - const Lane::WeakPeakItem* peakItem = - weakPeak ? tabLane.FindWeakPeakItem(ref) : nullptr; - const Lane::MeanFilterItem* item = - weakPeak ? nullptr - : (centerAxis ? tabLane.FindEdgeCenterItem(ref) - : tabLane.FindMeanFilterItem(ref)); - const Lane::ResampleItem* resampleItem = - resamplePanel ? tabLane.FindResampleItem(ref) : nullptr; - const bool checked = clipPanel ? tabLane.ClipEnabled(ref) - : (resamplePanel ? (resampleItem != nullptr) - : (weakPeak ? (peakItem != nullptr) - : (item != nullptr))); - const bool rowCollapsed = - clipPanel ? true - : (resamplePanel ? (resampleItem != nullptr && resampleItem->collapsed) - : (weakPeak ? (peakItem != nullptr && peakItem->collapsed) - : (item != nullptr && item->collapsed))); - - // 内容区已由 `g.SetClip(layout.content)` 裁切(行可以“半截”显示,滚动更顺滑); - // 已经完整滚出可视范围的行在上面就 `continue` 了,这里不再额外门控。 - UiFillRoundRect(g, header, 3.0f * m_scale, signalColor); - UiFillRoundRect(g, header, 3.0f * m_scale, WithAlpha(signalColor, 0xE8)); - // 条目栏悬停:在信号色叠底上再压一层强调色,给出"这一行对应哪条曲线"的提示 - //(曲线本身的高亮见 `PaintLaneDerivedOverlays`)。 - // ⚠️ 必须同时比**面板种类**:同一条信号在其它面板里也有一行, - // 只比 (lane, ref) 会把别的面板的同名行一起点亮。 - if (m_meanRowHoverLane == static_cast(index) && - m_meanRowHoverRef == ref && m_meanRowHoverKind == kind) - { - UiFillRoundRect(g, header, 3.0f * m_scale, - WithAlpha(m_theme->accent, 0x2E)); - } - - // 左侧三角(仅勾选时存在):朝下 = 展开、朝右 = 收起。 - const gd::RectF& triangle = layout.rows[rowIndex].triangle; - if (triangle.Width > 0.0f && checked) - { - UiDrawTriangleIcon(g, triangle, ink.text, - rowCollapsed ? IconDirection::RIGHT - : IconDirection::DOWN); - } - - // 名称:从三角右侧到复选框左侧。 - const gd::RectF& check = layout.rows[rowIndex].check; - const float nameLeft = (triangle.Width > 0.0f) - ? (triangle.X + triangle.Width + 6.0f * m_scale) - : (header.X + 8.0f * m_scale); - const float nameWidth = - std::max(10.0f, check.X - 6.0f * m_scale - nameLeft); - UiTextClipped(g, UiEllipsize(resolved.data->name, m_fonts->uiSmall, nameWidth), - m_fonts->uiSmall, - gd::RectF(nameLeft, header.Y, nameWidth, header.Height), ink.text, - TextAlign::LEFT, layout.content); - paintMeanCheck(check, checked, ink, signalColor); - - if (!checked || rowCollapsed) - { - continue; - } - - // 「重采样」:四行 = 最大间隔(只读)/ 最小间隔(只读)/ - // 重采样间隔(输入)/ 重采样频率(输入)。标签在左、数值在右。 - // 排布细节见 `PaintPanelResampleRows`(从本循环里提取的独立函数)。 - if (resamplePanel) - { - PaintPanelResampleRows(g, layout, rowIndex, index, ref, *resampleItem); - continue; - } - - // 「中心轴边沿计数」:参数行最上面是**同一行**的「上升沿 / 下降沿」统计。 - if (centerAxis && layout.rows[rowIndex].stats.Width > 0.0f) - { - std::size_t rising = 0; - std::size_t falling = 0; - bool complete = true; - if (resolved.axis != nullptr) - { - EdgeCenterCountsFor(*resolved.axis, *resolved.data, *resolved.style, - item->points, item->iterations, EdgeRangeActive(), - EdgeRangeMin(), EdgeRangeMax(), rising, falling, complete); - } - paintCounts(layout.rows[rowIndex].stats, rising, falling, complete, - layout.content); - } - - // 两行参数滑块(标签 / 轨道 / 数值 / 复位按钮)。 - const auto paintMeanSlider = [&](const gd::RectF& track, - const gd::RectF& valueRect, - const gd::RectF& resetRect, - const wchar_t* label, int current, int minimum, - int maximum, bool active) - { - const float labelLeft = track.X - 6.0f * m_scale - - UiMeasureTextWidth(m_fonts->uiSmall, label); - // ⚠️ **必须用 `UiTextClipped`(而不是 `UiText`)**:GDI 文本不受 - // GDI+ `SetClip` 约束,滚动时标签/数值会画到内容区之外 - //(用户 2026-10-30 报告"元素超出了浮动面板"的直接原因)。 - UiTextClipped(g, label, m_fonts->uiSmall, - gd::RectF(labelLeft, track.Y - 8.0f * m_scale, - UiMeasureTextWidth(m_fonts->uiSmall, label), - track.Height + 16.0f * m_scale), - m_theme->textMuted, TextAlign::LEFT, layout.content); - const double ratio = - (maximum > minimum) - ? (static_cast(current - minimum) / - static_cast(maximum - minimum)) - : 0.0; - UiDrawSlider(g, track, ratio, m_theme->accent, active, m_scale); - UiTextClipped(g, FormatInteger(current), m_fonts->monoSmall, - gd::RectF(valueRect.X, valueRect.Y, valueColumn, valueRect.Height), - m_theme->textStrong, TextAlign::LEFT, layout.content); - // 复位按钮:悬停高亮 + 与「系数/偏移」菜单同款图标。 - const bool hot = - (m_dragMode != PlotDragMode::EDGE_PANEL_MOVE) && - UiPointInRect(resetRect, static_cast(m_mouseX), - static_cast(m_mouseY)); - if (hot) - { - UiFillRoundRect( - g, gd::RectF(resetRect.X - 1.0f * m_scale, - resetRect.Y - 1.0f * m_scale, - resetRect.Width + 2.0f * m_scale, - resetRect.Height + 2.0f * m_scale), - 3.0f * m_scale, WithAlpha(m_theme->accent, 0x33)); - } - PaintPanelResetIcon(g, resetRect, - hot ? m_theme->accent : m_theme->textMuted, m_scale); - }; - - // ⚠️ 「均值点数」的最小值:**均值滤波 = 1**(用户 2026-10-30 要求不能为 0, - // 0 与不做平滑完全等价,看起来像功能坏了);**中心轴边沿计数 = 0** - // (0 = 不平滑,直接对原始采样值统计边沿 —— 这是有意义的选项,保持旧语义)。 - if (weakPeak) - { - // 「弱峰调整」两行滑块:调整幅度(0 在正中间 ⇒ ±400%)与迭代次数(1 … 自定上限)。 - const int peakAmount = std::max( - kWeakPeakAmountMin, std::min(kWeakPeakAmountMax, peakItem->amountPercent)); - const int peakMax = std::max( - kWeakPeakIterationsMin, - std::min(kWeakPeakIterationsMaxLimit, peakItem->maxIterations)); - const int peakIter = - std::max(kWeakPeakIterationsMin, std::min(peakMax, peakItem->iterations)); - const bool levelActive = (m_dragMode == PlotDragMode::PEAK_LEVEL) && - (m_peakDragLane == static_cast(index)) && - (m_peakDragRef == ref); - const bool iterActive = (m_dragMode == PlotDragMode::PEAK_ITERATIONS) && - (m_peakDragLane == static_cast(index)) && - (m_peakDragRef == ref); - const bool editingMax = - (m_editTarget == PlotEditTarget::PEAK_ITERATIONS_MAX) && - (m_editLane == static_cast(index)) && (m_editMeanRef == ref); - // 幅度滑块:比例 = (幅度 / (100*4) + 1) / 2(0% 在正中间)。 - const auto paintPeakSlider = [&](const gd::RectF& track, - const gd::RectF& valueRect, - const gd::RectF& resetRect, - const wchar_t* label, double ratio, - const std::wstring& valueText, bool active, - bool hideBody) - { - const float labelLeft = track.X - 6.0f * m_scale - - UiMeasureTextWidth(m_fonts->uiSmall, label); - UiTextClipped(g, label, m_fonts->uiSmall, - gd::RectF(labelLeft, track.Y - 8.0f * m_scale, - UiMeasureTextWidth(m_fonts->uiSmall, label), - track.Height + 16.0f * m_scale), - m_theme->textMuted, TextAlign::LEFT, layout.content); - if (hideBody) - { - return; // 正在编辑上限:轨道与数值改由 `PaintEditField` 画 - } - UiDrawSlider(g, track, ratio, m_theme->accent, active, m_scale); - UiTextClipped(g, valueText, m_fonts->monoSmall, - gd::RectF(valueRect.X, valueRect.Y, valueColumn, - valueRect.Height), - m_theme->textStrong, TextAlign::LEFT, layout.content); - const bool hot = (m_dragMode != PlotDragMode::EDGE_PANEL_MOVE) && - UiPointInRect(resetRect, - static_cast(m_mouseX), - static_cast(m_mouseY)); - if (hot) - { - UiFillRoundRect( - g, gd::RectF(resetRect.X - 1.0f * m_scale, - resetRect.Y - 1.0f * m_scale, - resetRect.Width + 2.0f * m_scale, - resetRect.Height + 2.0f * m_scale), - 3.0f * m_scale, WithAlpha(m_theme->accent, 0x33)); - } - PaintPanelResetIcon(g, resetRect, - hot ? m_theme->accent : m_theme->textMuted, - m_scale); - }; - const double levelRatio = - (static_cast(peakAmount) / (100.0 * kWeakPeakSliderScale) + 1.0) * 0.5; - const double iterRatio = - (peakMax > kWeakPeakIterationsMin) - ? (static_cast(peakIter - kWeakPeakIterationsMin) / - static_cast(peakMax - kWeakPeakIterationsMin)) - : 0.0; - if (row.levelTrack.Width > 0.0f) - { - paintPeakSlider(row.levelTrack, row.levelValue, row.levelReset, - kWeakPeakLevelLabel, levelRatio, - (peakAmount > 0 ? L"+" : L"") + FormatInteger(peakAmount) + L"%", - levelActive, false); - } - if (row.iterTrack.Width > 0.0f) - { - paintPeakSlider(row.iterTrack, row.iterValue, row.iterReset, - kWeakPeakIterationsSliderLabel, iterRatio, - FormatInteger(peakIter), iterActive, editingMax); - } - } - else - { - // 只可能是「均值滤波」:中心轴边沿计数已改为独立的表格分支。 - const int pointsFloor = kMeanFilterPointsMin; - const int pointsMax = std::max(pointsFloor, - std::min(kMeanFilterPointsMaxLimit, - item->maxPoints)); - const int points = - std::max(pointsFloor, std::min(pointsMax, item->points)); - const int iterationsMax = std::max(kMeanFilterIterationsMin, - std::min(kMeanFilterIterationsMaxLimit, - item->maxIterations)); - const int iterations = std::max( - kMeanFilterIterationsMin, std::min(iterationsMax, item->iterations)); - const bool pointsActive = (m_dragMode == PlotDragMode::MEAN_POINTS) && - (m_meanDragLane == static_cast(index)) && - (m_meanDragRef == ref) && - (m_meanDragKind == kind); - const bool iterationsActive = - (m_dragMode == PlotDragMode::MEAN_ITERATIONS) && - (m_meanDragLane == static_cast(index)) && (m_meanDragRef == ref) && - (m_meanDragKind == kind); - paintMeanSlider(row.levelTrack, row.levelValue, row.levelReset, - kMeanFilterPointsSliderLabel, points, pointsFloor, pointsMax, - pointsActive); - paintMeanSlider(row.iterTrack, row.iterValue, row.iterReset, - kMeanFilterIterationsSliderLabel, iterations, - kMeanFilterIterationsMin, iterationsMax, iterationsActive); - } - + return; // 正在编辑上限:轨道与数值改由 `PaintEditField` 画 } + UiDrawSlider(g, track, ratio, m_theme->accent, active, m_scale); + UiTextClipped(g, valueText, m_fonts->monoSmall, + gd::RectF(valueRect.X, valueRect.Y, valueColumn, valueRect.Height), + m_theme->textStrong, TextAlign::LEFT, layout.content); + const bool hot = + (m_dragMode != PlotDragMode::EDGE_PANEL_MOVE) && + UiPointInRect(resetRect, static_cast(m_mouseX), + static_cast(m_mouseY)); + if (hot) + { + UiFillRoundRect(g, + gd::RectF(resetRect.X - 1.0f * m_scale, + resetRect.Y - 1.0f * m_scale, + resetRect.Width + 2.0f * m_scale, + resetRect.Height + 2.0f * m_scale), + 3.0f * m_scale, WithAlpha(m_theme->accent, 0x33)); + } + PaintPanelResetIcon(g, resetRect, hot ? m_theme->accent : m_theme->textMuted, + m_scale); + }; + const double levelRatio = + (static_cast(peakAmount) / (100.0 * kWeakPeakSliderScale) + 1.0) * 0.5; + const double iterRatio = + (peakMax > kWeakPeakIterationsMin) + ? (static_cast(peakIter - kWeakPeakIterationsMin) / + static_cast(peakMax - kWeakPeakIterationsMin)) + : 0.0; + if (row.levelTrack.Width > 0.0f) + { + paintPeakSlider(row.levelTrack, row.levelValue, row.levelReset, kWeakPeakLevelLabel, + levelRatio, + (peakAmount > 0 ? L"+" : L"") + FormatInteger(peakAmount) + L"%", + levelActive, false); + } + if (row.iterTrack.Width > 0.0f) + { + paintPeakSlider(row.iterTrack, row.iterValue, row.iterReset, + kWeakPeakIterationsSliderLabel, iterRatio, FormatInteger(peakIter), + iterActive, editingMax); + } + return; + } - g.Restore(meanSave); - - // ---- 内容滚动条(只在内容超高时出现;底部按钮不参与滚动)---- - // ⚠️ 必须在 `g.Restore(meanSave)` **之后**画:滚动条位于内容区右侧、在 - // 内容裁剪区之外,若在裁剪内画会被整条裁掉(用户 2026-10-30 报告 - // "全部启用后没有出现滚动条"的根因)。 - PaintPanelScrollBar(g, layout, *m_theme); + // 只可能是「均值滤波」或「中心轴」:中心轴多一行统计(上面已画),滑块参数同形。 + const int pointsFloor = kMeanFilterPointsMin; + const int pointsMax = + std::max(pointsFloor, std::min(kMeanFilterPointsMaxLimit, item->maxPoints)); + const int points = std::max(pointsFloor, std::min(pointsMax, item->points)); + const int iterationsMax = + std::max(kMeanFilterIterationsMin, + std::min(kMeanFilterIterationsMaxLimit, item->maxIterations)); + const int iterations = + std::max(kMeanFilterIterationsMin, std::min(iterationsMax, item->iterations)); + const bool pointsActive = (m_dragMode == PlotDragMode::MEAN_POINTS) && + (m_meanDragLane == static_cast(index)) && + (m_meanDragRef == row.ref) && (m_meanDragKind == kind); + const bool iterationsActive = (m_dragMode == PlotDragMode::MEAN_ITERATIONS) && + (m_meanDragLane == static_cast(index)) && + (m_meanDragRef == row.ref) && (m_meanDragKind == kind); + paintMeanSlider(row.levelTrack, row.levelValue, row.levelReset, + kMeanFilterPointsSliderLabel, points, pointsFloor, pointsMax, pointsActive); + paintMeanSlider(row.iterTrack, row.iterValue, row.iterReset, + kMeanFilterIterationsSliderLabel, iterations, kMeanFilterIterationsMin, + iterationsMax, iterationsActive); } - /** +/** * @brief 「重采样」面板**单个信号的参数区**(四行)。 * * 从 `PaintPanelListRows` 里整块提取(第 38 轮):这四行的排布(两行只读统计 + diff --git a/Components/PlotView/Src/PlotViewPanels.cpp b/Components/PlotView/Src/PlotViewPanels.cpp index 1e5ca0f..fa06cce 100644 --- a/Components/PlotView/Src/PlotViewPanels.cpp +++ b/Components/PlotView/Src/PlotViewPanels.cpp @@ -40,6 +40,210 @@ namespace jdi return std::wstring(); } + PlotView::PanelColumnMetrics PlotView::MeasurePanelColumns(const Lane& lane, + EdgePanelKind kind) const + { + PanelColumnMetrics metrics; + const PanelStyle panelStyle = PanelStyleOf(kind); + const bool centerAxis = (kind == EdgePanelKind::CENTER); + const bool countTable = panelStyle.countTable; + const bool listStyle = panelStyle.listRows; + + // ---- 与信号无关的列宽(都是字体度量,量一次即可)---- + // 参数行标签:均值 / 弱峰两种面板共用一列(切换面板时滑块不横向跳动)。 + metrics.sliderLabel = + std::max(UiMeasureTextWidth(m_fonts->uiSmall, kMeanFilterPointsSliderLabel), + UiMeasureTextWidth(m_fonts->uiSmall, kMeanFilterIterationsSliderLabel)); + metrics.sliderLabel = + std::max(metrics.sliderLabel, UiMeasureTextWidth(m_fonts->uiSmall, kWeakPeakLevelLabel)); + metrics.sliderLabel = + std::max(metrics.sliderLabel, + UiMeasureTextWidth(m_fonts->uiSmall, kWeakPeakIterationsSliderLabel)); + // ⚠️ 数值列取 `100000`(6 字符):它同时是绘制侧的**裁剪宽度**,因此必须真的量出来, + // 不能写常数(换字体/换 DPI 就会截断)。`-400%` 只是宽度预算里的保守项,量出来的 + // 结果不会超过 `100000`,两者取大即可覆盖两个用途。 + metrics.valueColumn = std::max(UiMeasureTextWidth(m_fonts->monoSmall, L"100000"), + UiMeasureTextWidth(m_fonts->monoSmall, L"-400%")); + // 「重采样」四行的标签列(「最大数据间隔」比均值/弱峰的标签更宽)。 + metrics.resampleLabel = + std::max(UiMeasureTextWidth(m_fonts->uiSmall, kResampleMaxIntervalLabel), + UiMeasureTextWidth(m_fonts->uiSmall, kResampleMinIntervalLabel)); + metrics.resampleLabel = + std::max(metrics.resampleLabel, + UiMeasureTextWidth(m_fonts->uiSmall, kResampleIntervalLabel)); + metrics.resampleLabel = + std::max(metrics.resampleLabel, + UiMeasureTextWidth(m_fonts->uiSmall, kResampleFrequencyLabel)); + + // ---- 逐信号部分 ---- + // 统计列只在「边沿计数」表格与「中心轴」里出现;其余面板不必查边沿统计(省时间)。 + const bool needStats = countTable || centerAxis; + const float pendingWidth = UiMeasureTextWidth(m_fonts->monoSmall, L"统计中…"); + for (const SignalRef& ref : lane.signals) + { + const ResolvedSignal resolved = ResolveSignal(ref); + // ⚠️ 下面会解引用 `resolved.axis/style`(边沿统计),因此必须与其它绘制/命中路径 + // 用同一套空指针守卫;旧实现只判 `data`,一旦出现"有数据无样式/无时间轴"的引用 + // 就会在测量阶段空指针崩溃(本函数被绘制、命中、`QueryCursor` 调用)。 + if (resolved.data == nullptr || resolved.style == nullptr || resolved.axis == nullptr || + !resolved.visible) + { + continue; + } + ++metrics.signalCount; + // ⚠️ 名称宽度有上限(`kEdgePanelNameMaxWidth`):超长信号名不再把面板顶得很长, + // 绘制时超出部分用省略号(用户明确要求"超过某个长度就省略名称显示")。 + metrics.nameColumn = + std::max(metrics.nameColumn, + std::min(UiMeasureTextWidth(m_fonts->uiSmall, resolved.data->name), + kEdgePanelNameMaxWidth * m_scale)); + if (!needStats) + { + continue; + } + std::size_t rising = 0; + std::size_t falling = 0; + bool complete = true; + if (countTable) + { + EdgeCountsFor(*resolved.axis, *resolved.data, *resolved.style, lane.edgeValue, + EdgeRangeActive(), EdgeRangeMin(), EdgeRangeMax(), rising, falling, + complete); + // 宽度按实际要显示的文本测量(含未算完时的"…"),否则推进过程中数字变长会被截断。 + metrics.statRising = + std::max(metrics.statRising, + std::max(UiMeasureTextWidth(m_fonts->monoSmall, + EdgeCountCellText(rising, complete)), + pendingWidth)); + metrics.statFalling = + std::max(metrics.statFalling, + UiMeasureTextWidth(m_fonts->monoSmall, + EdgeCountCellText(falling, complete))); + } + else + { + // 「中心轴边沿计数」的统计行 = 「上升沿 N / 下降沿 M」**两列**(只量已勾选的条目)。 + const Lane::EdgeCenterItem* item = lane.FindEdgeCenterItem(ref); + if (item == nullptr) + { + continue; + } + EdgeCenterCountsFor(*resolved.axis, *resolved.data, *resolved.style, item->points, + item->iterations, EdgeRangeActive(), EdgeRangeMin(), + EdgeRangeMax(), rising, falling, complete); + metrics.statRising = + std::max(metrics.statRising, + std::max(UiMeasureTextWidth(m_fonts->monoSmall, + EdgeRisingText(rising, complete)), + pendingWidth)); + metrics.statFalling = + std::max(metrics.statFalling, + UiMeasureTextWidth(m_fonts->monoSmall, + EdgeFallingText(falling, complete))); + } + } + + if (listStyle && kind == EdgePanelKind::RESAMPLE) + { + // ⚠️ 逐信号实测「最大 / 最小数据间隔」的真实文本宽度(含(频率)尾缀), + // 而不是按 `1h 1min 2s(1.23456 GHz)` 这类**典型样例**估算 —— + // 真实值(如 `16ms 182.614us(61.795 Hz)`)往往更长,估算不足就会被截断。 + for (const SignalRef& ref : lane.signals) + { + const Lane::ResampleItem* item = lane.FindResampleItem(ref); + if (item == nullptr) + { + continue; + } + metrics.resampleValueColumn = + std::max(metrics.resampleValueColumn, + UiMeasureTextWidth(m_fonts->monoSmall, + FormatIntervalWithFrequency(item->maxInterval))); + metrics.resampleValueColumn = + std::max(metrics.resampleValueColumn, + UiMeasureTextWidth(m_fonts->monoSmall, + FormatIntervalWithFrequency(item->minInterval))); + } + // 兜底下限:没有勾选任何信号时也保持一个可用的最小宽度。 + metrics.resampleValueColumn = + std::max(metrics.resampleValueColumn, + UiMeasureTextWidth(m_fonts->monoSmall, L"1h 1min 2s(1.23456 GHz)")); + } + return metrics; + } + + std::vector PlotView::PlanListPanelRows(const Lane& lane, + EdgePanelKind kind) const + { + // ⚠️ 排版类别只从唯一来源取(`PanelStyleOf`),不要在这里再写 `kind == XXX` 组合判断。 + const bool clipPanel = (kind == EdgePanelKind::CLIP); + std::vector plan; + for (const SignalRef& ref : lane.signals) + { + const ResolvedSignal resolved = ResolveSignal(ref); + if (resolved.data == nullptr || !resolved.visible) + { + continue; + } + PanelRowPlan entry; + entry.ref = ref; + // 「选区截取」只有勾选状态、条目**不可展开**(用户 2026-11-03 要求)。 + if (clipPanel) + { + entry.checked = lane.ClipEnabled(ref); + plan.push_back(entry); + continue; + } + // 其余列表式面板:勾选 = 该信号有对应条目;收起 = 有参数但不展示。 + bool hasItem = false; + switch (kind) + { + case EdgePanelKind::RESAMPLE: + { + const Lane::ResampleItem* item = lane.FindResampleItem(ref); + hasItem = (item != nullptr); + entry.collapsed = (item != nullptr && item->collapsed); + break; + } + case EdgePanelKind::PEAK: + { + const Lane::WeakPeakItem* item = lane.FindWeakPeakItem(ref); + hasItem = (item != nullptr); + entry.collapsed = (item != nullptr && item->collapsed); + break; + } + case EdgePanelKind::CENTER: + { + const Lane::EdgeCenterItem* item = lane.FindEdgeCenterItem(ref); + hasItem = (item != nullptr); + entry.collapsed = (item != nullptr && item->collapsed); + break; + } + case EdgePanelKind::MEAN: + { + const Lane::MeanFilterItem* item = lane.FindMeanFilterItem(ref); + hasItem = (item != nullptr); + entry.collapsed = (item != nullptr && item->collapsed); + break; + } + case EdgePanelKind::COUNT: + case EdgePanelKind::MARK: + case EdgePanelKind::CLIP: + // 表格 / 分组面板不走本计划(`listRows` 为 false);CLIP 已在上面处理。 + break; + } + entry.checked = hasItem; + if (hasItem && !entry.collapsed) + { + // 参数行:重采样四行;中心轴 = 统计行 + 两行滑块;其余两行滑块。 + entry.sliderRows = (kind == EdgePanelKind::RESAMPLE) ? 4 : 2; + entry.hasStats = (kind == EdgePanelKind::CENTER); + } + plan.push_back(entry); + } + return plan; + } + float PlotView::EdgePanelWidth(int laneIndex, EdgePanelKind kind) const { if (m_tab == nullptr || m_fonts == nullptr || laneIndex < 0 || @@ -48,6 +252,14 @@ namespace jdi return kEdgePanelWidth * m_scale; } const Lane& lane = m_tab->Lanes()[static_cast(laneIndex)]; + // 宽度 = f(列宽度量);量一次、算一次,量取结果也交给 `EdgePanelLayoutFor` 复用列几何 + //(见 `EdgePanelWidthFor` / `MeasurePanelColumns` 的文档:这里曾经有两份同形算式)。 + return EdgePanelWidthFor(lane, kind, MeasurePanelColumns(lane, kind)); + } + + float PlotView::EdgePanelWidthFor(const Lane& lane, EdgePanelKind kind, + const PanelColumnMetrics& metrics) const + { // ⚠️ 排版类别与标题都只从唯一来源取(见 `PanelStyleOf` / `PanelTitle`): // 过去这三份分类布尔量在宽度 / 布局 / 绘制里各写一遍,新增面板漏改一处就错位。 const PanelStyle panelStyle = PanelStyleOf(kind); @@ -64,81 +276,9 @@ namespace jdi 8.0f * m_scale + UiMeasureTextWidth(m_fonts->uiSmall, title) + kEdgePanelTitleHeight * m_scale; - // 数据行 = 名称与“上升沿 N / 下降沿 M”两行,取较宽者。 - // ⚠️ 名称宽度有上限(`kEdgePanelNameMaxWidth`):超长信号名不再把面板顶得很长, - // 绘制时超出部分用省略号(用户明确要求“超过某个长度就省略名称显示”)。 - // ⚠️ 统计信息按「上升沿 / 下降沿」**两列**排版(列起点固定)→ 多条信号时文字垂直对齐。 - float nameColumnWidth = 0.0f; - float statRisingWidth = 0.0f; - float statFallingWidth = 0.0f; - for (const SignalRef& ref : lane.signals) - { - const ResolvedSignal resolved = ResolveSignal(ref); - // ⚠️ 下面会解引用 `resolved.axis/style`(边沿统计),因此必须与其它绘制/命中路径 - // 用同一套空指针守卫;旧实现只判 `data`,一旦出现"有数据无样式/无时间轴"的引用 - // 就会在测量阶段空指针崩溃(`EdgePanelWidth` 被绘制、命中、`QueryCursor` 调用)。 - if (resolved.data == nullptr || resolved.style == nullptr || resolved.axis == nullptr || - !resolved.visible) - { - continue; - } - const float nameWidth = - std::min(UiMeasureTextWidth(m_fonts->uiSmall, resolved.data->name), - kEdgePanelNameMaxWidth * m_scale); - nameColumnWidth = std::max(nameColumnWidth, nameWidth); - if (listStyle) - { - // 条目 = **信号色叠底条目栏**(三角 + 名称 + 复选框)与参数行 - //(标签 + 轨道 + 数值 + 复位按钮),右侧还要给滚动条留位。 - const float groupWidth = kEdgePanelPad * 2.0f * m_scale + - kEdgePanelDisclosureWidth * m_scale + 6.0f * m_scale + - nameWidth + 6.0f * m_scale + - kEdgePanelMeanCheckSize * m_scale + - (kFreeMarkerScrollWidth + 4.0f) * m_scale; - width = std::max(width, groupWidth); - } - if (!countTable && !centerAxis) - { - continue; // 只有「边沿计数」表格 / 「中心轴」统计行需要量计数列宽 - } - std::size_t rising = 0; - std::size_t falling = 0; - bool complete = true; - if (countTable) - { - EdgeCountsFor(*resolved.axis, *resolved.data, *resolved.style, lane.edgeValue, - EdgeRangeActive(), EdgeRangeMin(), EdgeRangeMax(), rising, falling, complete); - // 宽度按实际要显示的文本测量(含未算完时的“…”),否则推进过程中数字变长会被截断。 - const float pendingWidth = UiMeasureTextWidth(m_fonts->monoSmall, L"统计中…"); - statRisingWidth = std::max( - statRisingWidth, - std::max(UiMeasureTextWidth(m_fonts->monoSmall, EdgeCountCellText(rising, complete)), - pendingWidth)); - statFallingWidth = - std::max(statFallingWidth, - UiMeasureTextWidth(m_fonts->monoSmall, EdgeCountCellText(falling, complete))); - } - else - { - // 「中心轴边沿计数」的统计行 = 「上升沿 N / 下降沿 M」**两列**(只量已勾选的条目)。 - const Lane::EdgeCenterItem* item = lane.FindEdgeCenterItem(ref); - if (item == nullptr) - { - continue; - } - EdgeCenterCountsFor(*resolved.axis, *resolved.data, *resolved.style, item->points, - item->iterations, EdgeRangeActive(), EdgeRangeMin(), EdgeRangeMax(), - rising, falling, complete); - const float pendingWidth = UiMeasureTextWidth(m_fonts->monoSmall, L"统计中…"); - statRisingWidth = std::max( - statRisingWidth, - std::max(UiMeasureTextWidth(m_fonts->monoSmall, EdgeRisingText(rising, complete)), - pendingWidth)); - statFallingWidth = std::max( - statFallingWidth, - UiMeasureTextWidth(m_fonts->monoSmall, EdgeFallingText(falling, complete))); - } - } + // 数据行 / 条目栏 / 参数行 / 统计列的宽度一律来自 `metrics`(唯一来源): + // ⚠️ 这里**不再**自己写 `UiMeasureTextWidth` 链 —— 历史上宽度算式与列几何各有一份, + // 改一处漏一处就会出现"面板变宽而列不动"(文字压到轨道上)或反之(右侧一大片空白)。 if (countTable) { // 「边沿计数」= **表格**(信号名 / 上升沿计数 / 下降沿计数): @@ -147,72 +287,46 @@ namespace jdi UiMeasureTextWidth(m_fonts->uiSmall, EdgeCountTableHeader(1)); const float headerFalling = UiMeasureTextWidth(m_fonts->uiSmall, EdgeCountTableHeader(2)); - const float risingColumn = std::max(headerRising, statRisingWidth) + 10.0f * m_scale; - const float fallingColumn = std::max(headerFalling, statFallingWidth) + 10.0f * m_scale; + const float risingColumn = std::max(headerRising, metrics.statRising) + 10.0f * m_scale; + const float fallingColumn = std::max(headerFalling, metrics.statFalling) + 10.0f * m_scale; const float gap = kEdgePanelStatColumnGap * m_scale; - width = std::max(width, kEdgePanelPad * 2.0f * m_scale + - nameColumnWidth + gap + risingColumn + gap + fallingColumn + + width = std::max(width, kEdgePanelPad * 2.0f * m_scale + metrics.nameColumn + gap + + risingColumn + gap + fallingColumn + (kFreeMarkerScrollWidth + 4.0f) * m_scale); } - else if (centerAxis && (statRisingWidth > 0.0f || statFallingWidth > 0.0f)) + else if (centerAxis && (metrics.statRising > 0.0f || metrics.statFalling > 0.0f)) { // 「中心轴」的统计行 = 两列**等宽**(列起点固定 ⇒ 多条信号严格垂直对齐)。 - const float maxColumn = std::max(statRisingWidth, statFallingWidth); + const float maxColumn = std::max(metrics.statRising, metrics.statFalling); const float countsWidth = maxColumn * 2.0f + kEdgePanelStatColumnGap * m_scale; width = std::max(width, kEdgePanelPad * 2.0f * m_scale + countsWidth); } if (listStyle) { + // 条目栏 = 「三角 + 名称 + 复选框」+ 右侧滚动条留位。 + // 名称列取的是所有可见信号的最大值 ⇒ 用 `metrics.nameColumn` 一次算完 + //(条目栏宽度对名称是单调递增的,所以"逐信号取 max"与"用最大名称"等价)。 + width = std::max(width, kEdgePanelPad * 2.0f * m_scale + + kEdgePanelDisclosureWidth * m_scale + 6.0f * m_scale + + metrics.nameColumn + 6.0f * m_scale + + kEdgePanelMeanCheckSize * m_scale + + (kFreeMarkerScrollWidth + 4.0f) * m_scale); // 两行滑块 = 「标签列 + 轨道(最小宽度)+ 数值列 + 复位按钮」(两行共用同一列宽), // 右侧再给滚动条留位(上百个信号时内容必然超高)。 - const float labelColumn = - std::max(UiMeasureTextWidth(m_fonts->uiSmall, kMeanFilterPointsSliderLabel), - UiMeasureTextWidth(m_fonts->uiSmall, kMeanFilterIterationsSliderLabel)); - const float weakPeakLabelColumn = - std::max(UiMeasureTextWidth(m_fonts->uiSmall, kWeakPeakLevelLabel), - UiMeasureTextWidth(m_fonts->uiSmall, kWeakPeakIterationsSliderLabel)); - const float valueColumn = - std::max(UiMeasureTextWidth(m_fonts->monoSmall, L"100000"), - UiMeasureTextWidth(m_fonts->monoSmall, L"-400%")); - const float sliderRowWidth = std::max(labelColumn, weakPeakLabelColumn) + 6.0f * m_scale + - kWeakPeakTrackMinWidth * m_scale + 6.0f * m_scale + valueColumn + - kWeakPeakResetGap * m_scale + kWeakPeakResetButtonSize * m_scale; + const float sliderRowWidth = + metrics.sliderLabel + 6.0f * m_scale + kWeakPeakTrackMinWidth * m_scale + + 6.0f * m_scale + metrics.valueColumn + kWeakPeakResetGap * m_scale + + kWeakPeakResetButtonSize * m_scale; width = std::max(width, kEdgePanelPad * 2.0f * m_scale + sliderRowWidth + (kFreeMarkerScrollWidth + 4.0f) * m_scale); if (resamplePanel) { // 「重采样」四行 = 「标签列 + 数值列」(两行统计 + 两行输入框)。 // 标签取四者中最宽的一个;数值列必须按**真实会显示的文本**量宽 - // (用户 2026-11-03 要求:统计信息不能被省略成 `…`)。 - const float resampleLabelColumn = - std::max(std::max(UiMeasureTextWidth(m_fonts->uiSmall, kResampleMaxIntervalLabel), - UiMeasureTextWidth(m_fonts->uiSmall, kResampleMinIntervalLabel)), - std::max(UiMeasureTextWidth(m_fonts->uiSmall, kResampleIntervalLabel), - UiMeasureTextWidth(m_fonts->uiSmall, kResampleFrequencyLabel))); - // ⚠️ 逐信号实测「最大 / 最小数据间隔」的真实文本宽度(含(频率)尾缀), - // 而不是按 `1h 1min 2s(1.23456 GHz)` 这类**典型样例**估算 —— - // 真实值(如 `16ms 182.614us(61.795 Hz)`)往往更长,估算不足就会被截断。 - float resampleValueColumn = 0.0f; - for (const SignalRef& ref : lane.signals) - { - const Lane::ResampleItem* item = lane.FindResampleItem(ref); - if (item == nullptr) - { - continue; - } - resampleValueColumn = std::max( - resampleValueColumn, - UiMeasureTextWidth(m_fonts->monoSmall, FormatIntervalWithFrequency(item->maxInterval))); - resampleValueColumn = std::max( - resampleValueColumn, - UiMeasureTextWidth(m_fonts->monoSmall, FormatIntervalWithFrequency(item->minInterval))); - } - // 兜底下限:没有勾选任何信号时也保持一个可用的最小宽度。 - resampleValueColumn = std::max(resampleValueColumn, - UiMeasureTextWidth(m_fonts->monoSmall, L"1h 1min 2s(1.23456 GHz)")); - width = std::max(width, kEdgePanelPad * 2.0f * m_scale + resampleLabelColumn + - 8.0f * m_scale + resampleValueColumn + + //(用户 2026-11-03 要求:统计信息不能被省略成 `…`)。 + width = std::max(width, kEdgePanelPad * 2.0f * m_scale + metrics.resampleLabel + + 8.0f * m_scale + metrics.resampleValueColumn + (kFreeMarkerScrollWidth + 4.0f) * m_scale); } } @@ -344,9 +458,9 @@ namespace jdi } const Lane& lane = m_tab->Lanes()[static_cast(laneIndex)]; // ⚠️ 排版类别只从唯一来源取(`PanelStyleOf`)—— 过去宽度 / 布局 / 绘制各写一份。 + // ⚠️ 「中心轴 / 弱峰」这两个种类判断在本函数里已**不再需要**:行高与行几何都走 + // `PlanListPanelRows`(它内部按 kind 派发),统计行由 `PanelRowPlan::hasStats` 表达。 const PanelStyle panelStyle = PanelStyleOf(kind); - const bool centerAxis = (kind == EdgePanelKind::CENTER); - const bool weakPeak = (kind == EdgePanelKind::PEAK); const bool freeMarker = (kind == EdgePanelKind::MARK); const bool resamplePanel = (kind == EdgePanelKind::RESAMPLE); const bool clipPanel = (kind == EdgePanelKind::CLIP); @@ -382,7 +496,10 @@ namespace jdi } layout.signalCount = rowCount; - const float width = EdgePanelWidth(laneIndex, kind); + // ⚠️ 列宽度量只取一次:宽算式用它,列几何(表格列 / 滑块轨道)也用它 ⇒ + // 宽度与列边界**不可能不一致**(旧结构里两份算式各量一遍,改一处漏一处就错位)。 + const PanelColumnMetrics metrics = MeasurePanelColumns(lane, kind); + const float width = EdgePanelWidthFor(lane, kind, metrics); const float titleHeight = kEdgePanelTitleHeight * m_scale; const float pad = kEdgePanelPad * m_scale; // 「中心轴边沿计数」「均值滤波」「弱峰调整」共用同一套**可滚动列表**排布, @@ -404,6 +521,15 @@ namespace jdi : (hasButton ? (buttonHeight + pad) : 0.0f); const float bottomPad = pad; + // 逐信号行计划:**行高累加与行几何的唯一来源**(见 `PanelRowPlan` 的文档)。 + // 列表式面板才需要;表格(`countTable`)与分组(`freeMarker`)走各自的路径。 + // 「选区截取」未启用双光标时整个计划作废(面板只显示一行提示)。 + std::vector rowPlan; + if (listStyle && !(clipPanel && !clipReady)) + { + rowPlan = PlanListPanelRows(lane, kind); + } + float meanContentHeight = 0.0f; float height = 0.0f; float tableHeaderHeight = 0.0f; @@ -425,75 +551,36 @@ namespace jdi } else if (listStyle) { - // 「均值滤波 / 中心轴边沿计数 / 弱峰调整」:内容区 = 逐信号的**信号色叠底条目栏** - //(内容总是显示,用于勾选/取消)+(勾选且未收起时)参数行。 - // 「中心轴」的参数行 = 一行「上升沿 / 下降沿」统计 + 两行滑块; - // 「均值滤波」与「弱峰调整」= 两行滑块。 - // 内容超高时出现滚动条;底部「构建为新信号」按钮固定不滚动。 + // 「均值滤波 / 中心轴边沿计数 / 弱峰调整 / 重采样 / 选区截取」:内容区 = 逐信号的 + // **信号色叠底条目栏**(内容总是显示,用于勾选/取消)+(勾选且未收起时)参数行。 + // 内容超高时出现滚动条;底部按钮固定不滚动。 + // + // ⚠️⚠️ 行高与下面的行几何**同源**:两者都读 `rowPlan`(见 `PanelRowPlan` 的文档)。 + // 旧实现是两趟独立遍历、各自判定"这个信号展开几行",只要有一处写错就会出现 + // "内容区高度与实际画出来的行不一致"(底部留白 / 最后一行被裁 / 明明没内容却能滚动)。 const float groupRow = kEdgePanelMeanRowHeight * m_scale; const float sliderRow = kWeakPeakSliderRowHeight * m_scale; - // ⚠️ 「选区截取」在**未启用双光标**时没有任何可操作内容(只有一行提示文字), - // 因此不按信号数累加行高、也不套用「至少 3 行」的下限 - // (用户 2026-11-03 报告:未启用双光标时仍显示信号条目、且能触发滚动)。 float content = 0.0f; if (clipPanel && !clipReady) { - // 提示行固定占一行高度(绘制时在内容区居中显示)。 - content = kEdgePanelMeanRowHeight * m_scale; + // ⚠️ 「选区截取」在**未启用双光标**时没有任何可操作内容(只有一行提示文字), + // 因此不按信号数累加行高、也不套用「至少 3 行」的下限 + // (用户 2026-11-03 报告:未启用双光标时仍显示信号条目、且能触发滚动)。 + // 提示行固定占一行高度(绘制时在内容区居中显示)。 + content = groupRow; } else { - for (const SignalRef& ref : lane.signals) - { - const ResolvedSignal resolved = ResolveSignal(ref); - if (resolved.data == nullptr || !resolved.visible) + for (const PanelRowPlan& plan : rowPlan) { - continue; - } - content += groupRow; - if (clipPanel) - { - continue; // 「选区截取」的条目**不可展开**,每条只占一行 - } - bool expanded = false; - if (resamplePanel) - { - const Lane::ResampleItem* item = lane.FindResampleItem(ref); - expanded = (item != nullptr && !item->collapsed); - } - else if (weakPeak) - { - const Lane::WeakPeakItem* item = lane.FindWeakPeakItem(ref); - expanded = (item != nullptr && !item->collapsed); - } - else if (centerAxis) - { - const Lane::EdgeCenterItem* item = lane.FindEdgeCenterItem(ref); - expanded = (item != nullptr && !item->collapsed); - } - else - { - const Lane::MeanFilterItem* item = lane.FindMeanFilterItem(ref); - expanded = (item != nullptr && !item->collapsed); - } - if (expanded) - { - if (resamplePanel) + content += groupRow; + if (plan.hasStats) { - // 四行:最大间隔 / 最小间隔 / 重采样间隔 / 重采样频率。 - content += sliderRow * 4.0f; - } - else - { - content += sliderRow * 2.0f; - if (centerAxis) - { - content += groupRow; // 统计行(上升沿 / 下降沿) - } + content += groupRow; // 统计行(上升沿 / 下降沿) } + content += sliderRow * static_cast(plan.sliderRows); } } - } meanContentHeight = content; const float budget = m_plotRect.Height - (titleHeight + pad + bottomPad + footerHeight); @@ -802,28 +889,17 @@ namespace jdi layout.scrollTrack = scrollMetrics.track; layout.scrollThumb = scrollMetrics.thumb; - // 两行滑块的列宽(与 `EdgePanelWidth` 同一组算式 ⇒ 布局与宽度不会漂移)。 - const float labelColumn = - std::max(std::max(UiMeasureTextWidth(m_fonts->uiSmall, kMeanFilterPointsSliderLabel), - UiMeasureTextWidth(m_fonts->uiSmall, kMeanFilterIterationsSliderLabel)), - std::max(UiMeasureTextWidth(m_fonts->uiSmall, kWeakPeakLevelLabel), - UiMeasureTextWidth(m_fonts->uiSmall, kWeakPeakIterationsSliderLabel))); - const float valueColumn = - std::max(UiMeasureTextWidth(m_fonts->monoSmall, L"100000"), - UiMeasureTextWidth(m_fonts->monoSmall, L"-400%")); + // 列宽全部来自上面那份 `metrics`(与宽度算式**同一个**来源): + // 旧实现这里又写了一遍 `UiMeasureTextWidth` 链,注释还写着"与 `EdgePanelWidth` + // 同一组算式 ⇒ 不会漂移" —— 那是靠人守的约定;现在结构上只剩一份。 + const float labelColumn = metrics.sliderLabel; + const float valueColumn = metrics.valueColumn; const float resetSize = kWeakPeakResetButtonSize * m_scale; const float resetGap = kWeakPeakResetGap * m_scale; const float trackHeight = kWeakPeakTrackHeight * m_scale; const float closeSize = kEdgePanelMeanCheckSize * m_scale; - // 「重采样」四行的标签列(「最大数据间隔」比均值/弱峰的标签更宽): - // 与 `EdgePanelWidth` 用同一组实测宽度,保证字段不会压到标签上。 - const float resampleLabelColumn = - std::max(std::max(UiMeasureTextWidth(m_fonts->uiSmall, kResampleMaxIntervalLabel), - UiMeasureTextWidth(m_fonts->uiSmall, kResampleMinIntervalLabel)), - std::max(UiMeasureTextWidth(m_fonts->uiSmall, kResampleIntervalLabel), - UiMeasureTextWidth(m_fonts->uiSmall, kResampleFrequencyLabel))); - const float rowLabelColumn = resamplePanel ? resampleLabelColumn : labelColumn; - const float contentLeft = layout.content.X; + const float rowLabelColumn = resamplePanel ? metrics.resampleLabel : labelColumn; +const float contentLeft = layout.content.X; const float trackLeft = contentLeft + labelColumn + 6.0f * m_scale; const float resetLeft = contentLeft + innerWidth - resetSize; const float trackWidth = @@ -831,59 +907,20 @@ namespace jdi (resetLeft - resetGap - valueColumn - 6.0f * m_scale) - trackLeft); float rowY = layout.content.Y - scrollMetrics.scroll; - // ⚠️ 「选区截取」在**未启用双光标**时不生成任何信号行:面板只显示一句居中提示 + // ⚠️ 行几何与上面的内容高度**同源**:都读 `rowPlan`(见 `PanelRowPlan` 的文档)。 + // 「选区截取」未启用双光标时 `rowPlan` 为空 ⇒ 不生成任何行 // (用户 2026-11-03 明确要求:不应显示信号名条目、更不应能滚动)。 - const bool clipRowsVisible = !clipPanel || clipReadyHere; - for (const SignalRef& ref : lane.signals) + for (const PanelRowPlan& plan : rowPlan) { - if (!clipRowsVisible) - { - break; - } - const ResolvedSignal resolved = ResolveSignal(ref); - if (resolved.data == nullptr || !resolved.visible) - { - continue; - } - bool checked = false; - bool itemCollapsed = false; - if (clipPanel) - { - // 「选区截取」:只有勾选状态,条目**不可展开**(用户 2026-11-03 要求)。 - checked = lane.ClipEnabled(ref); - } - else if (resamplePanel) - { - const Lane::ResampleItem* item = lane.FindResampleItem(ref); - checked = (item != nullptr); - itemCollapsed = (item != nullptr && item->collapsed); - } - else if (weakPeak) - { - const Lane::WeakPeakItem* item = lane.FindWeakPeakItem(ref); - checked = (item != nullptr); - itemCollapsed = (item != nullptr && item->collapsed); - } - else if (centerAxis) - { - const Lane::EdgeCenterItem* item = lane.FindEdgeCenterItem(ref); - checked = (item != nullptr); - itemCollapsed = (item != nullptr && item->collapsed); - } - else - { - const Lane::MeanFilterItem* item = lane.FindMeanFilterItem(ref); - checked = (item != nullptr); - itemCollapsed = (item != nullptr && item->collapsed); - } + const SignalRef& ref = plan.ref; // 组头(信号色叠底):整行宽度,左侧三角(仅勾选时)+ 名称,右端复选框。 // ⚠️ 行的追加走 `AppendRow()`(**唯一入口**):一行的全部字段一次成型, // 不会出现"某个分支漏推一列 ⇒ 该列之后所有行读到别人的矩形"。 - // 下面的参数行 / 重采样行一律用 `layout.rows.back().xxx = …` **赋值**(同一行,天然对齐)。 - layout.AppendRow( + // 下面的参数行一律用 `row.xxx = …` **赋值**(同一行,天然对齐)。 + EdgePanelLayout::PanelRow& row = layout.AppendRow( ref, gd::RectF(contentLeft, rowY, innerWidth, groupRow), gd::RectF(contentLeft, rowY, innerWidth, groupRow), - (checked && !clipPanel) + (plan.checked && !clipPanel) ? gd::RectF(contentLeft + 3.0f * m_scale, rowY + (groupRow - kEdgePanelRowTriangleSize * m_scale) * 0.5f, kEdgePanelRowTriangleSize * m_scale, @@ -893,53 +930,53 @@ namespace jdi rowY + (groupRow - closeSize) * 0.5f, closeSize, closeSize)); rowY += groupRow; - // 参数行:只有勾选且未收起时才有几何(未勾选的行两者都是空矩形)。 - if (!checked || itemCollapsed || clipPanel) + // 参数行:只有展开时才有几何(未勾选 / 已收起的行 `sliderRows == 0`)。 + if (plan.sliderRows <= 0) { continue; } + // 「中心轴边沿计数」:参数行最上面是**同一行**的「上升沿 / 下降沿」统计。 + if (plan.hasStats) + { + row.stats = gd::RectF(contentLeft, rowY, innerWidth, groupRow); + rowY += groupRow; + } if (resamplePanel) { // 「重采样」四行:① 最大间隔(只读)② 最小间隔(只读) // ③ 重采样间隔(输入)④ 重采样频率(输入,与③联动)。 // 每行 = 左侧标签 + 右侧数值/输入框区域。 - layout.rows.back().infoMaxInterval = gd::RectF(contentLeft, rowY, innerWidth, sliderRow); + row.infoMaxInterval = gd::RectF(contentLeft, rowY, innerWidth, sliderRow); rowY += sliderRow; - layout.rows.back().infoMinInterval = gd::RectF(contentLeft, rowY, innerWidth, sliderRow); + row.infoMinInterval = gd::RectF(contentLeft, rowY, innerWidth, sliderRow); rowY += sliderRow; // 输入行的**数值矩形**靠右对齐,宽度取剩余可用宽度(绘制框与编辑框同源)。 const float fieldLeft = contentLeft + rowLabelColumn + 8.0f * m_scale; const float fieldWidth = std::max(24.0f * m_scale, innerWidth - rowLabelColumn - 8.0f * m_scale); - layout.rows.back().intervalField = gd::RectF(fieldLeft, rowY, fieldWidth, sliderRow); + row.intervalField = gd::RectF(fieldLeft, rowY, fieldWidth, sliderRow); rowY += sliderRow; - layout.rows.back().frequencyField = gd::RectF(fieldLeft, rowY, fieldWidth, sliderRow); + row.frequencyField = gd::RectF(fieldLeft, rowY, fieldWidth, sliderRow); rowY += sliderRow; continue; } - // 「中心轴边沿计数」:参数行最上面是**同一行**的「上升沿 / 下降沿」统计。 - if (centerAxis && !layout.rows.empty()) - { - layout.rows.back().stats = gd::RectF(contentLeft, rowY, innerWidth, groupRow); - rowY += groupRow; - } const gd::RectF pointsTrack(trackLeft, rowY + (sliderRow - trackHeight) * 0.5f, trackWidth, trackHeight); - layout.rows.back().levelTrack = pointsTrack; - layout.rows.back().levelValue = + row.levelTrack = pointsTrack; + row.levelValue = gd::RectF(pointsTrack.X + pointsTrack.Width + 6.0f * m_scale, rowY, valueColumn, sliderRow); - layout.rows.back().levelReset = + row.levelReset = gd::RectF(resetLeft, rowY + (sliderRow - resetSize) * 0.5f, resetSize, resetSize); rowY += sliderRow; const gd::RectF iterTrack(trackLeft, rowY + (sliderRow - trackHeight) * 0.5f, trackWidth, trackHeight); - layout.rows.back().iterTrack = iterTrack; - layout.rows.back().iterValue = + row.iterTrack = iterTrack; + row.iterValue = gd::RectF(iterTrack.X + iterTrack.Width + 6.0f * m_scale, rowY, valueColumn, sliderRow); - layout.rows.back().iterReset = + row.iterReset = gd::RectF(resetLeft, rowY + (sliderRow - resetSize) * 0.5f, resetSize, resetSize); rowY += sliderRow; } diff --git a/Documents/Code_Review_Plan.md b/Documents/Code_Review_Plan.md index cd6d0e8..aa9e7d3 100644 --- a/Documents/Code_Review_Plan.md +++ b/Documents/Code_Review_Plan.md @@ -379,4 +379,33 @@ old keys = 919, new keys = 965 含义:旧结构里"某列根本没被 push"的那些行,现在每行都拥有该字段(值为空矩形)。 **逐行逐字段的真实几何一位都没变**,7 个面板的像素哈希也逐项未变。 +--- + +## 十二、第 40 轮追加(2026-11-04):几何算式重写 + 参数行提取(§16.7 第 1 / 第 2 项) + +> 触发:用户要求「继续上一轮报告中 §16.7 的 1 和 2 建议」,并明确第 3 点(体积)暂不投入, +> 等以后功能更新产生新的冗余时再优化。 + +| 节点 | 内容 | 验收标准 | +| --- | --- | --- | +| **P36** | 宽度算式与列几何共用一份 `PanelColumnMetrics`(`MeasurePanelColumns` + `EdgePanelWidthFor`) | `panels` 7 个面板像素哈希 + 矩形**逐项不变** | +| **P37** | 行高累加与行几何共用一份 `PanelRowPlan`(`PlanListPanelRows`):两趟遍历合一 | `rowgeom` 965+7 键 0 差异;`panelscroll` 全绿 | +| **P38** | `PaintPanelListRows` 的通用参数行提取为 `PaintPanelParamRows`(5 参数) | 双基线归零;`PaintPanelListRows` 由约 300 行降到约 120 行 | + +### 12.1 本轮新增硬约束 + +1. **宽度算式与列几何只能有一份来源**:任何"这个面板的标签列 / 数值列有多宽"都从 + `PanelColumnMetrics` 取。**禁止**在 `EdgePanelLayoutFor` 里再写 `UiMeasureTextWidth` 链 + (旧代码注释写着"与 `EdgePanelWidth` 同一组算式 ⇒ 不会漂移"——那是靠人守的约定, + 改一处漏一处就是"面板变宽而列不动",表现为文字压到轨道上)。 +2. **行高与行几何只能有一份来源**:都读 `PlanListPanelRows` 的返回值(`PanelRowPlan`)。 + **禁止**再出现"第一趟只算高度、第二趟才算几何"的双遍历 —— 两趟的判定条件必须逐字一致, + 任何一处写错就表现为底部留白 / 最后一行被裁 / 没内容却能滚动。 +3. **`PanelRowPlan::sliderRows`** 用整数表达"展开几个参数行"(0/2/4), + `hasStats` 表达「中心轴」多一行统计 —— 这两个量是"高度"与"几何"共享的**唯一判定**。 +4. **参数行绘制**走 `PaintPanelParamRows(g, layout, row, kind, PanelParamState{…})`; + 逐行状态打包进 `PanelParamState`(解析结果 / 两种条目指针 / 网格下标), + 目的是把参数表压在 5 个 —— 一个个透传会让签名比函数体更难读。 + + diff --git a/Documents/Code_Review_Progress.md b/Documents/Code_Review_Progress.md index d38d626..6ca4302 100644 --- a/Documents/Code_Review_Progress.md +++ b/Documents/Code_Review_Progress.md @@ -628,4 +628,89 @@ old keys = 919, new keys = 965 | 读点数 | 旧的 243 处 `layout.rowXxx[…]` 全部消失;现在 95 处 `layout.rows[…]`(84 在命中判定、11 在绘制) | | 行数 | `PlotView.hpp` 4795;`PlotViewInteraction.cpp` 4399;`PlotViewPanelPaint.cpp` 935;`PlotViewPaint.cpp` 5534;`PlotViewPanels.cpp` 1729 | +--- + +## 第 40 轮(2026-11-04):几何算式重写 + 参数行提取(§16.7 第 1 / 第 2 项) + +> 上一轮已完成"结构合并 + 双基线",本轮把**算式**本身收敛成单一来源。 + +### P36 宽度算式与列几何共用一份度量 + +改前:`EdgePanelWidth`(决定面板宽)与 `EdgePanelLayoutFor`(决定列边界)**各自写了一份** +同名同形的 `UiMeasureTextWidth` 链(标签列 / 数值列 / 重采样列),注释写着 +"与 `EdgePanelWidth` 同一组算式 ⇒ 不会漂移"。那是靠人守的约定。 + +改后: + +```cpp +struct PanelColumnMetrics // 唯一的列宽度量 +{ + std::size_t signalCount; // 可见信号数 + float nameColumn; // 名称列(已按上限夹取) + float statRising, statFalling; // 「边沿计数」/「中心轴」统计列 + float sliderLabel, resampleLabel;// 参数行标签列(两种,互不合并) + float valueColumn; // 数值列(同时是绘制侧的裁剪宽度) + float resampleValueColumn; // 「重采样」只读统计的数值列 +}; +PanelColumnMetrics MeasurePanelColumns(const Lane&, EdgePanelKind) const; +float EdgePanelWidthFor(const Lane&, EdgePanelKind, const PanelColumnMetrics&) const; +``` + +`EdgePanelWidth(laneIndex, kind)` 退化为"量一次 + 算一次";`EdgePanelLayoutFor` 量一次后 +**同时**用于宽度与列几何。 + +⚠️ 顺手验明一处"看起来重复其实必须分开"的量:`valueColumn` 在宽度算式里按 +`max("100000", "-400%")` 量、在列几何里按 `max("100000", "1000")` 量 —— 两者结果相同 +(`100000` 是最宽的),因此可以合并;而 **`sliderLabel` 与 `resampleLabel` 不能合并** +(「重采样」标签更长,并进来会让均值/弱峰面板整体变宽 = 像素变化)。 + +### P37 行高累加与行几何共用一份行计划 + +改前:`EdgePanelLayoutFor` 里**两趟独立遍历**同一批信号 —— +第一趟只累加 `meanContentHeight`,第二趟才生成 `rows` 并累加 `rowY`。 +两趟的判定(可见性 / 勾选 / 收起 / 是否要统计行 / 参数行数)必须逐字一致。 + +改后: + +```cpp +struct PanelRowPlan { SignalRef ref; bool checked, collapsed, hasStats; int sliderRows; }; +std::vector PlanListPanelRows(const Lane&, EdgePanelKind) const; +``` + +高度 = `groupRow + (hasStats ? groupRow : 0) + sliderRows * sliderRow`; +几何按同一个 `plan.sliderRows / hasStats` 派发 ⇒ **结构上不可能不一致**。 +「选区截取」未启用双光标时整份计划作废(面板只显示一行提示),与改前行为一致。 + +### P38 参数行绘制提取 + +`PaintPanelListRows` 里的「中心轴统计行 + 两条滑块分支(均值 / 弱峰)」 +**整块提取**为 `PaintPanelParamRows(g, layout, row, kind, PanelParamState{…})`, +`paintCounts` / `paintMeanSlider` / `paintPeakSlider` 三个 lambda 与 `valueColumn` 随之迁入。 +`PaintPanelListRows` 现在只负责"条目栏(叠底 + 悬停 + 三角 + 名称 + 复选框)" + +"按 `kind` 分派到重采样/参数行"。 + +### 过程中的两次返工(记录,避免重犯) + +1. 用"整段替换"改宽度块时,锚点 `// 两行滑块` 在 `EdgePanelWidthFor` 里**也有一处** + ⇒ 误删了宽度函数尾部。改用更长的锚点(带"的列宽")后重做。 +2. 行发射循环的锚点 `float rowY = layout.content.Y - scrollMetrics.scroll;` + 在**表格分支**里先出现一次 ⇒ 误删了「边沿计数」的行发射。改为"先定位**第二个** + `else if (listStyle)`,再从那里找 `rowY`"。 + +⇒ **教训:整段替换的锚点必须唯一;同一文件里同名语句出现多次时,先定位所属区块再取偏移。** + +### 本轮验证 + +| 项 | 结果 | +| --- | --- | +| `make release` / `make debug` / `make -C Tools` | 零 error / 零 warning | +| 产物 | 782,336 → **781,824 B**(−512) | +| `UnitTests` | `checks=260 failed=0 cases=19` | +| `OffscreenChecks -all` | 7/7 PASS,`checks=133 failed=0` | +| `panels -mode compare` | 7 个面板像素哈希**逐项不变**(含 `resample=2BFC74B3D9B3239C`) | +| `rowgeom -mode compare` | 965 + 7 键 0 差异;`-data MF4-Record.MF4` 1268 + 7 键 0 差异 | +| 行数 | `PlotViewPanelPaint.cpp` 935 → 939;`PlotViewPanels.cpp` 1729 → 1766(新增两个几何函数,净增 37) | +| 样式 | 顺手把机械搬移遗留的"整个函数体多缩进 2 个 tab"修正 | + + diff --git a/Documents/Code_Review_Report.md b/Documents/Code_Review_Report.md index f031600..c543c22 100644 --- a/Documents/Code_Review_Report.md +++ b/Documents/Code_Review_Report.md @@ -752,4 +752,78 @@ old keys = 919, new keys = 965 参数表从 9 个降到 `(g, layout, row, kind, state)` 5 个。 3. 若还要压体积:`EdgePanelLayout` 已无冗余列,剩余空间主要在字符串/浮点格式化(见 §11 的 gdtoa 结论)。 +--- + +## 十七、第 40 轮:几何算式重写 + 参数行提取(§16.7 第 1 / 第 2 项) + +### 17.1 结论 + +| §16.7 建议 | 结果 | +| --- | --- | +| 1. 面板几何算式重写(`EdgePanelWidth` + 行高累加) | ✅ 两处都收敛成单一来源(`PanelColumnMetrics` / `PanelRowPlan`),双基线零差异 | +| 2. `PaintPanelListRows` 通用参数行提取 | ✅ `PaintPanelParamRows`(5 参数),函数由约 300 行降到约 120 行 | +| 3. 体积 | ⏸ 按用户指示**暂不投入**(当前没有便宜的压缩空间,等后续功能更新产生冗余时再做) | + +产物顺带 −512 B(782,336 → **781,824 B**),零 warning。 + +### 17.2 "重构"与"重写"的区别(本轮的核心方法论) + +前几轮做的是**重构**(结构变、行为不变),判据是"双基线零差异"。 +本轮做的是**算式重写**(把两份同源算式合成一份),风险更高:两份算式**本来可能已经不一致**, +合并后必然有一边要改数 —— 那就不是"行为不变"了。 + +所以先做了一次**等价性判别**: + +| 量 | 宽度算式里的算法 | 列几何里的算法 | 是否等价 | +| --- | --- | --- | --- | +| `valueColumn` | `max(mono"100000", mono"-400%")` | `max(mono"100000", mono"1000")` | ✅ 都等于 `mono"100000"` | +| `labelColumn` | `max(均值标签, 弱峰标签)` | `max(均值标签, 弱峰标签)` | ✅ 同式 | +| `resampleLabel` | 单独算(不并入上面) | 单独算 | ✅ 同式,且**不能**并入 | +| `nameColumn` | 逐信号取 max(跳过无 axis 的) | 同上(另一处循环) | ✅ 同式 | + +⇒ 只有"可以证明等价"的量才合并。`resampleLabel` 明确**不**并入 `sliderLabel`: +「重采样」标签("最大数据间隔")比均值/弱峰的长,并进去会让后两个面板整体变宽 —— 那是像素变化, +不属于等价重构。 + +### 17.3 双遍历合一:`PanelRowPlan` + +改前 `EdgePanelLayoutFor` 里对同一批信号遍历两次(先算总高、再出行几何), +两处的判定条件(可见 / 勾选 / 收起 / 统计行 / 参数行数)必须**逐字一致**。 +这种"两趟必须同步修改"的结构就是"改一次布局就出错"的另一半机制(前半是平行数组,已在 §16 解决)。 + +改后一个 `PanelRowPlan{ref, checked, collapsed, hasStats, sliderRows}` 数组同时喂给 +高度算式与几何生成,判定只写一次。 + +### 17.4 过程中的两次锚点误伤(值得记) + +1. 锚点 `// 两行滑块` 在 `EdgePanelWidthFor` 内也匹配到 ⇒ 误删宽度函数尾部; +2. 锚点 `float rowY = layout.content.Y - scrollMetrics.scroll;` 在**表格分支**先出现 ⇒ + 误删「边沿计数」的行发射。 + +两次都是编译器立刻报错(brace 不平衡 / 未声明标识符),从备份回滚后重做。 +**规则:整段替换的锚点必须唯一;同名语句出现多次时先定位所属区块(例如"第二个 +`else if (listStyle)`")再取偏移。** + +### 17.5 验证 + +| 项 | 结果 | +| --- | --- | +| `make release` / `make debug` / `make -C Tools` | 零 warning | +| 产物 | **781,824 B**(−512) | +| `UnitTests` | 260 / 19 PASS | +| `OffscreenChecks -all` | 7 检查 / 133 断言 PASS | +| `panels -mode compare` | 7 面板像素哈希逐项不变 | +| `rowgeom -mode compare` | 965+7 键 / 1268+7 键(MF4),0 差异 | + +### 17.6 下一步(按价值排序) + +1. **面板"三处穷举 switch"再收敛**:现在新增面板仍要改 `PanelStyleOf` / `PanelTitle` / + `LanePanelScrollOf` 三处;可以考虑把它们合成一张 `constexpr` 表 + (`{kind, title, style, scrollPtr}`),新增面板只加一行数据 —— 但 `PanelTitle` 需要 + `EdgeRangeSuffix()`(运行时字符串),需先解决"标题是函数"的问题。 +2. `PaintPanelMarkerGroups`(约 260 行)按"分组栏 / 表头 / 条目吸顶"再拆, + 与 §17.3 同理:先给"吸顶推挤"单独一个可测函数。 +3. 体积:等有新的冗余再说(用户已明确)。 + + diff --git a/Documents/agent_mem.md b/Documents/agent_mem.md index f1688a2..8b55bb4 100644 --- a/Documents/agent_mem.md +++ b/Documents/agent_mem.md @@ -10758,3 +10758,59 @@ brace 不平衡。用备份回滚后重做。 (`g, layout, row, kind, state`)。 3. 体积剩余空间主要在字符串/浮点格式化(gdtoa 30+ KB 不可去掉,见 §11)。 +--- + +## 2026-11-04 · 第 40 轮(`Code_Review` 分支):几何算式重写 + 参数行提取(§17.7 第 1/2 项) + +> 规则原文:`Documents/Code_Review_Report.md` §17、`Code_Review_Plan.md` §十二、 +> `Code_Review_Progress.md` 第 40 轮。 + +### 1. ✅ 宽度算式与列几何共用一份 `PanelColumnMetrics` + +```cpp +PanelColumnMetrics MeasurePanelColumns(const Lane&, EdgePanelKind) const; +float EdgePanelWidthFor(const Lane&, EdgePanelKind, const PanelColumnMetrics&) const; +``` +`EdgePanelWidth(laneIndex, kind)` = 量一次 + 算一次;`EdgePanelLayoutFor` 量一次, +宽度与列几何都读它。 +**硬规则**:`EdgePanelLayoutFor` 里**禁止**再写 `UiMeasureTextWidth` 链算列宽。 + +⚠️ 可合并 / 不可合并的判别(做这类"算式重写"时必须先做): +* `valueColumn`:宽度用 `max("100000","-400%")`、几何用 `max("100000","1000")` → **等价**,可合并; +* `sliderLabel` 与 `resampleLabel`:**不能**合并(重采样标签更长,并进会让均值/弱峰变宽)。 + +### 2. ✅ 行高累加与行几何共用一份 `PanelRowPlan` + +```cpp +struct PanelRowPlan { SignalRef ref; bool checked, collapsed, hasStats; int sliderRows; }; +std::vector PlanListPanelRows(const Lane&, EdgePanelKind) const; +``` +高度 = `groupRow + (hasStats?groupRow:0) + sliderRows*sliderRow`;几何按同一份计划派发。 +**禁止**再出现"第一趟算高度、第二趟出几何"的双遍历(两趟判定必须逐字一致 = 错误温床)。 + +### 3. ✅ 参数行绘制提取 `PaintPanelParamRows(g, layout, row, kind, PanelParamState{…})` + +`paintCounts` / `paintMeanSlider` / `paintPeakSlider` 与 `valueColumn` 随之迁入。 +`PaintPanelListRows` 现在只剩"条目栏 + 按 kind 分派"。 + +### 4. ⚠️ 整段替换的锚点必须是**唯一**的(本轮两次误伤) + +1. `// 两行滑块` 在 `EdgePanelWidthFor` 里也有一份 ⇒ 误删宽度函数尾部; +2. `float rowY = layout.content.Y - scrollMetrics.scroll;` 在**表格分支**先出现 + ⇒ 误删「边沿计数」的行发射。 +对策:先定位所属区块(如"第二个 `else if (listStyle)`")再取偏移;改前 `Copy-Item` 备份到 `Temp\`。 + +### 5. 本轮数字 + +* Release 782,336 → **781,824 B**(−512);release/debug/tools 全零 warning。 +* `UnitTests` 260/19;`OffscreenChecks -all` 7 检查 / 133 断言全 PASS。 +* `panels -mode compare` 7 面板像素哈希逐项不变;`rowgeom compare` 965+7 / 1268+7 键 0 差异。 +* `PlotViewPanelPaint.cpp` 935 → 939;`PlotViewPanels.cpp` 1729 → 1766。 + +### 6. 下一步 + +1. 三处穷举 switch(`PanelStyleOf` / `PanelTitle` / `LanePanelScrollOf`)合成一张 constexpr 表 —— + 但 `PanelTitle` 依赖运行时 `EdgeRangeSuffix()`,需先解决"标题是函数"。 +2. `PaintPanelMarkerGroups`(约 260 行)按"分组栏 / 表头 / 条目吸顶"再拆。 +3. **体积按用户指示暂停投入**(等后续功能更新产生新冗余时再做)。 + -- 2.54.0 From 0c26da07d2aef713337841782c4c9ff4896907ca Mon Sep 17 00:00:00 2001 From: "DeepSeek V4.1 Flash" <> Date: Wed, 4 Nov 2026 17:22:06 +0800 Subject: [PATCH 22/22] =?UTF-8?q?refactor(panels):=20=E9=9D=A2=E6=9D=BF?= =?UTF-8?q?=E9=9D=99=E6=80=81=E7=89=B9=E5=BE=81=E6=94=B6=E6=95=9B=E4=B8=BA?= =?UTF-8?q?=E5=94=AF=E4=B8=80=E7=99=BB=E8=AE=B0=E8=A1=A8=20+=20=E6=A0=87?= =?UTF-8?q?=E8=AE=B0=E9=9D=A2=E6=9D=BF=E7=BB=98=E5=88=B6=E5=88=86=E6=AE=B5?= =?UTF-8?q?=EF=BC=88=C2=A717.6=20=E7=AC=AC=201/2=20=E9=A1=B9=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 pvdetail::PanelKindTraits + PanelTraitsOf(唯一 switch):标题 / 排版类别 / 滚动字段 / 面板状态 / 功能开关同表登记;新增面板只改「枚举 + 一行」, -Wswitch 仍在唯一登记点生效。PanelTitle 改为「前缀 + rangeSuffix 标志」, 不再依赖 switch(标题降级为数据,不再需要函数指针)。 - PaintPanelMarkerGroups(约 340 行)拆成 6 个具名函数,主函数只保留绘制顺序 (普通分组栏 -> 条目 -> 表头 -> 吸顶栏 -> 滚动条 -> 透明度行);纯搬移、0 字节变化。 - 顺手修正该函数体遗留的多余缩进。 验证:release/debug/tools 零 warning;UnitTests 260/19;OffscreenChecks -all 7/133; panels -mode compare 7 面板像素哈希逐项不变;rowgeom compare 965+7 / 1268+7 键 0 差异。 产物 781,824 -> 782,336 B(+512,全部来自登记表)。 --- Components/PlotView/Inc/PlotView.hpp | 36 +- Components/PlotView/Inc/PlotViewInternal.hpp | 206 +++--- .../PlotView/Src/PlotViewPanelPaint.cpp | 692 +++++++++--------- Components/PlotView/Src/PlotViewPanels.cpp | 26 +- Documents/Code_Review_Plan.md | 25 + Documents/Code_Review_Progress.md | 74 ++ Documents/Code_Review_Report.md | 93 +++ Documents/agent_mem.md | 62 ++ 8 files changed, 761 insertions(+), 453 deletions(-) diff --git a/Components/PlotView/Inc/PlotView.hpp b/Components/PlotView/Inc/PlotView.hpp index 4ef3d03..5a59c89 100644 --- a/Components/PlotView/Inc/PlotView.hpp +++ b/Components/PlotView/Inc/PlotView.hpp @@ -1201,7 +1201,8 @@ namespace jdi * @brief 设置某种面板的**内容滚动偏移**(客户区像素,0 = 顶部)—— 唯一实现。 * * ⚠️ 下面七个 `SetLane*Scroll` 都只是本函数的一行转发(保留是为了不改调用点与原语义)。 - * 新增面板时**只需**在 `LanePanelScrollOf` / `PanelStyleOf` / `PanelTitle` 三处登记, + * 新增面板时**只需**在 `pvdetail::PanelTraitsOf`(`PlotViewInternal.hpp`)加一行: + * 标题 / 排版类别 / 滚动字段 / 面板状态 / 功能开关都在那一行里, * 不要再复制一份"取字段 → 夹取 → 比较 → 写回"的实现。 * * @param [IN] laneIndex 网格下标。 @@ -2574,6 +2575,39 @@ namespace jdi */ const std::vector& FreeMarkerContents(int laneIndex) const; + // ---- 「自由标记」面板内容绘制的**分段入口**(第 41 轮)----------------------- + // `PaintPanelMarkerGroups` 只负责**顺序**(普通分组栏 → 条目 → 表头 → 吸顶分组栏 → + // 滚动条 → 透明度滑块),各段的细节在这里。提取是纯搬移, + // 判据 = `panels -mode compare`(像素哈希)+ `rowgeom -mode compare`(逐行几何)。 + // ⚠️ 顺序本身是硬约束:表头与吸顶分组栏必须画在条目**之后**,否则向上滚动的条目 + // 会盖住它们(这是原实现里唯一真正需要"读整段代码才看得见"的约束)。 + + /** @brief 把「自由标记」六列的实测文本宽度写回 `stored`(= `Lane::freeMarkerColumnWidths`)。 */ + void MeasureMarkerColumnWidths(const Lane& tabLane, + const std::vector& contents, + std::array& stored); + + /** @brief 「自由标记」列分隔竖线(数据行与表头共用同一套列边界)。 */ + void PaintMarkerColumnSeparators(gd::Graphics& g, const EdgePanelLayout& layout, float y, + float height); + + /** @brief 「自由标记」一个信号分组栏(三角 + 名称 + `N 标记点` + ✕)。 */ + void PaintMarkerGroupBar(gd::Graphics& g, const EdgePanelLayout& layout, const Lane& tabLane, + const EdgePanelLayout::MarkerGroup& group, std::size_t laneIndex); + + /** @brief 「自由标记」一个固定点条目(六列 + 分隔线 + 行尾 ✕)。 */ + void PaintMarkerRow(gd::Graphics& g, const EdgePanelLayout& layout, const Lane& tabLane, + const EdgePanelLayout::MarkerRow& markerRow, + const FreeMarkerContentEntry& entry, std::size_t laneIndex); + + /** @brief 「自由标记」一个展开分组的表头行(不透明底衬 + 六列标题)。 */ + void PaintMarkerTableHeader(gd::Graphics& g, const EdgePanelLayout& layout, + const EdgePanelLayout::MarkerGroup& group); + + /** @brief 「自由标记」底部「标记透明度」滑块行。 */ + void PaintMarkerOpacityRow(gd::Graphics& g, const EdgePanelLayout& layout, const Lane& tabLane, + std::size_t laneIndex); + /** 按网格下标的「自由标记」内容缓存(`mutable`:只在绘制时按需重建)。 */ mutable std::vector m_freeMarkerContent; diff --git a/Components/PlotView/Inc/PlotViewInternal.hpp b/Components/PlotView/Inc/PlotViewInternal.hpp index 8ad0615..d536ecb 100644 --- a/Components/PlotView/Inc/PlotViewInternal.hpp +++ b/Components/PlotView/Inc/PlotViewInternal.hpp @@ -1096,99 +1096,21 @@ namespace jdi // ⚠️ 这些 `switch` **不得写 `default:`** —— 让 `-Wswitch` 在新增 // `EdgePanelKind` 而漏登记时直接报警(编译期保护,0 运行时代价)。 - /** @brief 取某种面板的 UI 状态(展开/折起/位置)——可写版本。 */ - inline LanePanelState& LanePanelStateOf(Lane& lane, EdgePanelKind kind) - { - switch (kind) - { - case EdgePanelKind::COUNT: - return lane.edgePanel; - case EdgePanelKind::CENTER: - return lane.edgeCenterPanel; - case EdgePanelKind::MEAN: - return lane.meanFilterPanel; - case EdgePanelKind::PEAK: - return lane.weakPeakPanel; - case EdgePanelKind::MARK: - return lane.freeMarkerPanel; - case EdgePanelKind::RESAMPLE: - return lane.resamplePanel; - case EdgePanelKind::CLIP: - return lane.clipPanel; - } - // 枚举值越界(只可能来自非法强制转换):返回一个安全的占位对象。 - return lane.edgePanel; - } - - /** @brief 取某种面板的 UI 状态——只读版本。 */ - inline const LanePanelState& LanePanelStateOf(const Lane& lane, EdgePanelKind kind) - { - return LanePanelStateOf(const_cast(lane), kind); - } - - /** @brief 取某种面板的**内容滚动偏移**(客户区像素)——可写版本。 */ - inline float& LanePanelScrollOf(Lane& lane, EdgePanelKind kind) - { - switch (kind) - { - case EdgePanelKind::COUNT: - return lane.edgeCountScroll; - case EdgePanelKind::CENTER: - return lane.edgeCenterScroll; - case EdgePanelKind::MEAN: - return lane.meanFilterScroll; - case EdgePanelKind::PEAK: - return lane.weakPeakScroll; - case EdgePanelKind::MARK: - return lane.freeMarkerScroll; - case EdgePanelKind::RESAMPLE: - return lane.resampleScroll; - case EdgePanelKind::CLIP: - return lane.clipScroll; - } - return lane.meanFilterScroll; - } - - /** @brief 取某种面板的**内容滚动偏移**——只读版本。 */ - inline const float& LanePanelScrollOf(const Lane& lane, EdgePanelKind kind) - { - return LanePanelScrollOf(const_cast(lane), kind); - } - - /** @brief 该面板对应的功能是否已启用(右键菜单里的开关)。 */ - inline bool LanePanelEnabled(const Lane& lane, EdgePanelKind kind) - { - switch (kind) - { - case EdgePanelKind::COUNT: - return lane.edgeCount; - case EdgePanelKind::CENTER: - return lane.edgeCenter; - case EdgePanelKind::MEAN: - return lane.meanFilter; - case EdgePanelKind::PEAK: - return lane.weakPeak; - case EdgePanelKind::MARK: - return lane.freeMarker; - case EdgePanelKind::RESAMPLE: - return lane.resample; - case EdgePanelKind::CLIP: - return lane.clip; - } - return false; - } - - // ---- 面板的「排版类别」与「滚动几何」的唯一来源 -------------------------------- + // ---- 面板的「排版类别」与「静态特征」的唯一来源 -------------------------------- // // ⚠️⚠️ 为什么要有这一层:面板的**分类布尔量**过去在三个地方各写一份 // (`EdgePanelWidth` / `EdgePanelLayoutFor` / `PaintEdgeCursor`), // 每份都是 6 个 `kind == XXX` + 3 条组合判断。新增一种面板时若只改其中两处, // 就会出现"宽度按 A 类算、绘制按 B 类画"的错位 —— 这正是历史上 // 「元素超出浮动面板」「内容区多出一段空白」「滚动条画在表头上」这类缺陷的共同形态。 - // 现在只在这里分派一次,三个消费者都读同一份结果。 + // 现在只在这里登记一次,所有消费者都读同一份结果。 // - // ⚠️ 同样**不写 `default:`**:新增 `EdgePanelKind` 而未登记时, + // ⚠️ `PanelTraitsOf` 的 `switch` **不写 `default:`**:新增 `EdgePanelKind` 而未登记时, // `-Wswitch`(属 `-Wall`)会直接编译报警。 + // + // ⚠️ 标题里只有「边沿计数」「中心轴边沿计数」需要追加运行时后缀(`EdgeRangeSuffix()` + // 取决于双光标是否启用),因此表里存的是**标题前缀 + 一个标志**, + // 而不是 `std::wstring`(无法 constexpr)或函数指针(会把"数据"重新变成"代码")。 /** * @brief 一种浮动面板的排版类别(互斥 + 若干可选部件)。 @@ -1225,40 +1147,108 @@ namespace jdi bool footerButtons() const { return buildButton || twoButtons; } }; - /** @brief 取某种面板的排版类别(唯一分类来源)。 */ - inline PanelStyle PanelStyleOf(EdgePanelKind kind) + /** @brief `PanelStyle` 的三个具名构造函数(聚合初始化顺序易错,这里给出语义化入口)。 */ + inline constexpr PanelStyle ListPanelStyle(bool buildButton, bool twoButtons) noexcept + { + return PanelStyle{true, false, false, buildButton, twoButtons, false}; + } + + inline constexpr PanelStyle CountTablePanelStyle() noexcept + { + return PanelStyle{false, true, false, false, false, false}; + } + + inline constexpr PanelStyle MarkerPanelStyle() noexcept + { + return PanelStyle{false, false, true, false, false, true}; + } + + /** + * @brief 一种浮动面板的全部**静态特征**(排版类别 / 标题 / 三个字段偏移)。 + * + * 新增一种面板时**只允许**改三处:`EdgePanelKind` 加一个枚举值、 + * 本表加一行、`PanelTitle` 不需要动(标题就在这一行里)。 + * 其余消费者(宽度 / 布局 / 绘制 / 滚动 / 命中 / 调试盒 / 缓存键)全部自动跟上。 + */ + struct PanelKindTraits + { + PanelStyle style; /**< 排版类别与底部控件。 */ + const wchar_t* title = L""; /**< 标题前缀(`rangeSuffix` 为真时再追加区间后缀)。 */ + bool rangeSuffix = false; /**< 标题是否追加 `EdgeRangeSuffix()`(仅两种边沿计数)。 */ + float Lane::*scroll = nullptr; /**< 内容滚动偏移。 */ + LanePanelState Lane::*panel = nullptr; /**< 面板 UI 状态(展开/折起/位置)。 */ + bool Lane::*enabled = nullptr; /**< 功能开关(右键菜单里的勾选项)。 */ + }; + + /** @brief 取某种面板的全部静态特征(**唯一登记点**)。 */ + inline constexpr PanelKindTraits PanelTraitsOf(EdgePanelKind kind) { - PanelStyle style; switch (kind) { case EdgePanelKind::COUNT: - style.countTable = true; - break; + return PanelKindTraits{CountTablePanelStyle(), L"边沿计数", true, + &Lane::edgeCountScroll, &Lane::edgePanel, &Lane::edgeCount}; case EdgePanelKind::CENTER: - style.listRows = true; - break; + return PanelKindTraits{ListPanelStyle(false, false), L"中心轴边沿计数", true, + &Lane::edgeCenterScroll, &Lane::edgeCenterPanel, + &Lane::edgeCenter}; case EdgePanelKind::MEAN: - style.listRows = true; - style.buildButton = true; - break; + return PanelKindTraits{ListPanelStyle(true, false), L"均值滤波", false, + &Lane::meanFilterScroll, &Lane::meanFilterPanel, + &Lane::meanFilter}; case EdgePanelKind::PEAK: - style.listRows = true; - style.buildButton = true; - break; + return PanelKindTraits{ListPanelStyle(true, false), kWeakPeakTitle, false, + &Lane::weakPeakScroll, &Lane::weakPeakPanel, &Lane::weakPeak}; case EdgePanelKind::MARK: - style.markerGroups = true; - style.markerFooter = true; - break; + return PanelKindTraits{MarkerPanelStyle(), L"自由标记", false, + &Lane::freeMarkerScroll, &Lane::freeMarkerPanel, + &Lane::freeMarker}; case EdgePanelKind::RESAMPLE: - style.listRows = true; - style.buildButton = true; - break; + return PanelKindTraits{ListPanelStyle(true, false), kResampleTitle, false, + &Lane::resampleScroll, &Lane::resamplePanel, &Lane::resample}; case EdgePanelKind::CLIP: - style.listRows = true; - style.twoButtons = true; - break; + return PanelKindTraits{ListPanelStyle(false, true), kClipTitle, false, + &Lane::clipScroll, &Lane::clipPanel, &Lane::clip}; } - return style; + // 枚举值越界(只可能来自非法强制转换):退到「均值滤波」这份,保证指针非空。 + return PanelKindTraits{ListPanelStyle(true, false), L"", false, &Lane::meanFilterScroll, + &Lane::meanFilterPanel, &Lane::meanFilter}; + } + + /** @brief 取某种面板的排版类别(唯一分类来源)。 */ + inline PanelStyle PanelStyleOf(EdgePanelKind kind) + { + return PanelTraitsOf(kind).style; + } + + /** @brief 取某种面板的 UI 状态(展开/折起/位置)——可写版本。 */ + inline LanePanelState& LanePanelStateOf(Lane& lane, EdgePanelKind kind) + { + return lane.*(PanelTraitsOf(kind).panel); + } + + /** @brief 取某种面板的 UI 状态——只读版本。 */ + inline const LanePanelState& LanePanelStateOf(const Lane& lane, EdgePanelKind kind) + { + return LanePanelStateOf(const_cast(lane), kind); + } + + /** @brief 取某种面板的**内容滚动偏移**(客户区像素)——可写版本。 */ + inline float& LanePanelScrollOf(Lane& lane, EdgePanelKind kind) + { + return lane.*(PanelTraitsOf(kind).scroll); + } + + /** @brief 取某种面板的**内容滚动偏移**——只读版本。 */ + inline const float& LanePanelScrollOf(const Lane& lane, EdgePanelKind kind) + { + return LanePanelScrollOf(const_cast(lane), kind); + } + + /** @brief 该面板对应的功能是否已启用(右键菜单里的开关)。 */ + inline bool LanePanelEnabled(const Lane& lane, EdgePanelKind kind) + { + return lane.*(PanelTraitsOf(kind).enabled); } /** diff --git a/Components/PlotView/Src/PlotViewPanelPaint.cpp b/Components/PlotView/Src/PlotViewPanelPaint.cpp index bf217ef..d2aed22 100644 --- a/Components/PlotView/Src/PlotViewPanelPaint.cpp +++ b/Components/PlotView/Src/PlotViewPanelPaint.cpp @@ -592,347 +592,385 @@ namespace jdi } -/** - * @brief Marker panel content: per-signal group bars (collapsible) + table header + - * fixed-point rows, with sticky group bars. + // --------------------------------------------------------------------------- + // 「自由标记」面板内容(第 41 轮拆分) + // + // 原实现是一个约 340 行的单函数,内含 5 段互不相干的绘制(分组栏 / 列宽测量 / + // 条目行 / 表头 / 透明度滑块)。任何一段出问题都要在 300 行里找,而"表头与吸顶分组栏 + // 必须最后画"这条**唯一真实的顺序约束**被埋在一个循环中间。现在拆成 6 个具名函数, + // `PaintPanelMarkerGroups` 只负责顺序,判据 = `panels -mode compare` + + // `rowgeom -mode compare`(两者都必须零差异)。 + // --------------------------------------------------------------------------- + + /** + * @brief 把「自由标记」六列的实测文本宽度写回 `Lane::freeMarkerColumnWidths`。 * - * Paint order is rows -> header -> sticky group bar (the last one covers rows that - * scroll up). Rows hidden under a sticky bar are already excluded by the layout. + * 含滚出视界的条目(列宽必须稳定,否则滚动时列会跳动);下一步的布局 + * (`EdgePanelWidth` / `EdgePanelLayoutFor`)直接读这份宽度。 + */ + void PlotView::MeasureMarkerColumnWidths(const Lane& tabLane, + const std::vector& contents, + std::array& stored) + { + float widest[6] = {0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f}; + const auto measure = [this, &widest](std::size_t column, const std::wstring& text) { + if (text.empty() || column >= 6) + { + return; + } + widest[column] = std::max(widest[column], UiMeasureTextWidth(m_fonts->monoSmall, text)); + }; + // 「序号」列:取本网格所有分组里最大的序号文本宽度。 + std::size_t maxSerial = 0; + std::vector serialOrder; + for (const SignalRef& ref : tabLane.signals) + { + SortedFreeMarkerIndices(tabLane, ref, serialOrder); + maxSerial = std::max(maxSerial, serialOrder.size()); + } + if (maxSerial > 0) + { + measure(EdgePanelLayout::MARKER_COL_SERIAL, FormatInteger(maxSerial)); + } + for (const FreeMarkerContentEntry& entry : contents) + { + measure(EdgePanelLayout::MARKER_COL_VALUE, entry.valueText); + measure(EdgePanelLayout::MARKER_COL_ENUM, entry.enumText); + measure(EdgePanelLayout::MARKER_COL_DELTA, entry.deltaText); + measure(EdgePanelLayout::MARKER_COL_TIME, entry.timeText); + measure(EdgePanelLayout::MARKER_COL_DT, entry.deltaTimeText); + } + for (std::size_t column = 0; column < 6; ++column) + { + // +2px 余量:`DrawTextW` 的 `DT_END_ELLIPSIS` 对"刚好装满"的文本 + // 有时仍会截成省略号(亚像素/字形伸部),留一点余量可彻底避免。 + const float want = (widest[column] > 0.0f) ? (widest[column] + 2.0f * m_scale) : 0.0f; + if (std::fabs(stored[column] - want) > 0.5f) + { + stored[column] = want; + } + } + } + + /** + * @brief 「自由标记」列分隔竖线(数据行与表头共用同一套列边界)。 + * + * 在每列右边界(加上半个列间隙)画一条淡竖线,让「序号 / 标记值 / 枚举文本 / 增量 / + * 时间点 / 时间间隔」有明显的列分界(用户 2026-10-23 要求)。 + * 最后一列不画(它右边紧接行尾 ✕)。 + */ + void PlotView::PaintMarkerColumnSeparators(gd::Graphics& g, const EdgePanelLayout& layout, + float y, float height) + { + int lastVisible = -1; + for (int column = 0; column < 6; ++column) + { + if (layout.markerColumns[column].width > 0.0f) + { + lastVisible = column; + } + } + for (int column = 0; column < lastVisible; ++column) + { + const EdgePanelLayout::MarkerColumn& cell = layout.markerColumns[column]; + if (cell.width <= 0.0f) + { + continue; + } + const float lineX = cell.left + cell.width + kFreeMarkerColumnGap * m_scale * 0.5f; + UiDrawLine(g, lineX, y + 2.0f * m_scale, lineX, y + height - 2.0f * m_scale, + WithAlpha(m_theme->borderStrong, 0x6C), 1.0f); + } + } + + /** + * @brief 「自由标记」一个信号分组栏(三角 + 名称 + `N 标记点` + ✕)。 + * + * 底色 = 信号色;文字一律用 `UiContrastInk` 算出的自适应对比色。吸顶时这一栏要压住 + * 下面滚动的条目,因此底色先铺**完全不透明**的同色、再叠 0xE8(观感不变)。 + */ + void PlotView::PaintMarkerGroupBar(gd::Graphics& g, const EdgePanelLayout& layout, + const Lane& tabLane, + const EdgePanelLayout::MarkerGroup& group, + std::size_t laneIndex) + { + const ResolvedSignal resolved = ResolveSignal(group.ref); + if (resolved.data == nullptr) + { + return; + } + const std::uint32_t signalColor = + (resolved.style != nullptr) ? StyleColor(*resolved.style) : m_theme->border; + const UiInk ink = UiContrastInk(signalColor); + UiFillRoundRect(g, group.header, 3.0f * m_scale, signalColor); + UiFillRoundRect(g, group.header, 3.0f * m_scale, WithAlpha(signalColor, 0xE8)); + + // 左侧三角(与信号选择区域组名同款):朝下 = 展开、朝右 = 收起。 + const bool triangleHot = (m_freeMarkerTriangleHoverLane == static_cast(laneIndex) && + m_freeMarkerTriangleHoverSignal == group.ref); + UiDrawTriangleIcon(g, group.triangle, triangleHot ? m_theme->accentSoft : ink.text, + group.collapsed ? IconDirection::RIGHT : IconDirection::DOWN); + + // 右侧:`N 标记点` + ✕(清除该信号全部固定点)。 + std::size_t markerCount = 0; + for (const Lane::FreeMarkerItem& entry : tabLane.freeMarkerItems) + { + if (entry.ref == group.ref) + { + ++markerCount; + } + } + const std::wstring countText = FormatInteger(markerCount) + L" 标记点"; + const float countWidth = UiMeasureTextWidth(m_fonts->monoSmall, countText) + 2.0f * m_scale; + const float countRight = group.close.X - 5.0f * m_scale; + UiTextClipped(g, countText, m_fonts->monoSmall, + gd::RectF(countRight - countWidth, group.header.Y, countWidth, + group.header.Height), + ink.text, TextAlign::RIGHT, layout.content); + + const float nameLeft = group.triangle.X + group.triangle.Width + 6.0f * m_scale; + const float nameWidth = std::max(10.0f, countRight - countWidth - 6.0f * m_scale - nameLeft); + UiTextClipped(g, UiEllipsize(resolved.data->name, m_fonts->uiSmall, nameWidth), + m_fonts->uiSmall, + gd::RectF(nameLeft, group.header.Y, nameWidth, group.header.Height), + ink.text, TextAlign::LEFT, layout.content); + + const bool closeHot = (m_freeMarkerPanelHoverLane == static_cast(laneIndex) && + m_freeMarkerPanelHoverSignal == group.ref && + m_freeMarkerPanelHoverItem == static_cast(-1)); + if (closeHot) + { + UiFillRoundRect(g, group.close, 3.0f * m_scale, WithAlpha(m_theme->danger, 0x77)); + } + const float crossPad = 3.5f * m_scale; + const std::uint32_t crossColor = closeHot ? m_theme->danger : ink.text; + UiDrawLine(g, group.close.X + crossPad, group.close.Y + crossPad, + group.close.X + group.close.Width - crossPad, + group.close.Y + group.close.Height - crossPad, crossColor, 1.5f * m_scale); + UiDrawLine(g, group.close.X + group.close.Width - crossPad, group.close.Y + crossPad, + group.close.X + crossPad, group.close.Y + group.close.Height - crossPad, + crossColor, 1.5f * m_scale); + } + + /** + * @brief 「自由标记」一个固定点条目(六列 + 分隔线 + 行尾 ✕)。 + * + * ⚠️ 面板里的数据列**不得省略**(用户 2026-10-21 明确要求):列宽已按实测文本宽度撑开。 + * ⚠️ 文字必须用 `UiTextClipped`(GDI 裁剪):`UiText` 走 `DrawTextW`,**不认 GDI+ 裁剪** + * ⇒ 滚动到内容区上方的条目会把文字画到标题栏上(用户 2026-10-22 报告的显示错误)。 + */ + void PlotView::PaintMarkerRow(gd::Graphics& g, const EdgePanelLayout& layout, + const Lane& tabLane, + const EdgePanelLayout::MarkerRow& markerRow, + const FreeMarkerContentEntry& entry, std::size_t laneIndex) + { + // ---- 行的选中 / 悬停高亮(用户 2026-10-22 要求)---- + const bool selected = (tabLane.freeMarkerSelected == static_cast(markerRow.itemIndex)); + const bool rowHot = (m_freeMarkerRowHoverLane == static_cast(laneIndex) && + m_freeMarkerRowHoverItem == markerRow.itemIndex); + if (selected || rowHot) + { + UiFillRect(g, + gd::RectF(markerRow.rect.X, markerRow.rect.Y + 1.0f * m_scale, + markerRow.rect.Width, markerRow.rect.Height - 2.0f * m_scale), + selected ? WithAlpha(m_theme->accent, 0x3C) : WithAlpha(m_theme->accent, 0x1E)); + } + + const auto columnRect = [&](EdgePanelLayout::MarkerColumnIndex column) { + return gd::RectF(layout.markerColumns[column].left, markerRow.rect.Y, + layout.markerColumns[column].width, markerRow.rect.Height); + }; + const auto paintCell = [&](EdgePanelLayout::MarkerColumnIndex column, + const std::wstring& text, std::uint32_t color) { + if (layout.markerColumns[column].width <= 0.0f || text.empty()) + { + return; + } + UiTextClipped(g, text, m_fonts->monoSmall, columnRect(column), color, TextAlign::LEFT, + layout.content); + }; + paintCell(EdgePanelLayout::MARKER_COL_SERIAL, FormatInteger(markerRow.serial), + m_theme->textMuted); + paintCell(EdgePanelLayout::MARKER_COL_VALUE, entry.valueText, m_theme->textStrong); + if (layout.markerHasEnumColumn) + { + paintCell(EdgePanelLayout::MARKER_COL_ENUM, entry.enumText, m_theme->text); + } + paintCell(EdgePanelLayout::MARKER_COL_DELTA, entry.deltaText, m_theme->textMuted); + paintCell(EdgePanelLayout::MARKER_COL_TIME, entry.timeText, m_theme->textMuted); + paintCell(EdgePanelLayout::MARKER_COL_DT, entry.deltaTimeText, m_theme->textMuted); + + // 列分隔竖线(用户 2026-10-23 要求:各列之间要有明显分界)。 + PaintMarkerColumnSeparators(g, layout, markerRow.rect.Y, markerRow.rect.Height); + + // 行尾 ✕:移除该固定点(与之前一致)。 + const gd::RectF& closeRect = markerRow.close; + const bool hot = (m_freeMarkerPanelHoverLane == static_cast(laneIndex) && + m_freeMarkerPanelHoverItem == markerRow.itemIndex); + UiFillRoundRect(g, closeRect, 3.0f * m_scale, + hot ? WithAlpha(m_theme->danger, 0x66) : m_theme->cardBg); + UiDrawRoundRect(g, closeRect, 3.0f * m_scale, hot ? m_theme->danger : m_theme->border, 1.0f); + const float crossPad = 3.5f * m_scale; + const std::uint32_t crossColor = hot ? m_theme->danger : m_theme->textMuted; + UiDrawLine(g, closeRect.X + crossPad, closeRect.Y + crossPad, + closeRect.X + closeRect.Width - crossPad, + closeRect.Y + closeRect.Height - crossPad, crossColor, 1.2f * m_scale); + UiDrawLine(g, closeRect.X + closeRect.Width - crossPad, closeRect.Y + crossPad, + closeRect.X + crossPad, closeRect.Y + closeRect.Height - crossPad, crossColor, + 1.2f * m_scale); + } + + /** + * @brief 「自由标记」一个展开分组的表头行(不透明底衬 + 六列标题)。 + * + * 不透明底衬是必须的:表头画在条目**之后**,用来盖住向上滚动的条目。 + */ + void PlotView::PaintMarkerTableHeader(gd::Graphics& g, const EdgePanelLayout& layout, + const EdgePanelLayout::MarkerGroup& group) + { + if (group.tableHeader.Width <= 0.0f || group.tableHeader.Height <= 0.0f) + { + return; + } + // 不透明底衬(与面板底色 + 少量边框色调混合,观感是“一条浅灰带”)。 + UiFillRect(g, group.tableHeader, MixColor(m_theme->cardBg, m_theme->borderStrong, 0.22)); + UiDrawLine(g, group.tableHeader.X, group.tableHeader.Y + group.tableHeader.Height - 1.0f, + group.tableHeader.X + group.tableHeader.Width, + group.tableHeader.Y + group.tableHeader.Height - 1.0f, + WithAlpha(m_theme->borderStrong, 0x99), 1.0f); + for (int column = 0; column < 6; ++column) + { + const EdgePanelLayout::MarkerColumn& cell = layout.markerColumns[column]; + if (cell.width <= 0.0f) + { + continue; + } + UiTextClipped(g, kFreeMarkerColumnLabels[column], m_fonts->uiSmall, + gd::RectF(cell.left, group.tableHeader.Y, cell.width, + group.tableHeader.Height), + m_theme->textMuted, TextAlign::LEFT, layout.content); + } + // 表头的列分隔竖线(与数据行同一套列边界)。 + PaintMarkerColumnSeparators(g, layout, group.tableHeader.Y, group.tableHeader.Height); + } + + /** + * @brief 「自由标记」底部「标记透明度」滑块行。 + * + * 这一行**总是显示**(与「自由标记点选取」是否开启无关),几何全部取自布局 + * (与命中测试同源);外观与信号样式区的「透明度」滑块完全相同(同一 `UiDrawSlider`)。 + */ + void PlotView::PaintMarkerOpacityRow(gd::Graphics& g, const EdgePanelLayout& layout, + const Lane& tabLane, std::size_t laneIndex) + { + if (layout.opacityTrack.Width <= 0.0f) + { + return; + } + const gd::RectF& panelRect = layout.panel; + const float labelGap = kEdgePanelPad * m_scale; + UiText(g, kFreeMarkerOpacityLabel, m_fonts->uiSmall, + gd::RectF(panelRect.X + labelGap, layout.opacityValue.Y, + layout.opacityTrack.X - panelRect.X - labelGap, + layout.opacityValue.Height), + m_theme->textMuted, TextAlign::LEFT); + const bool sliderActive = + (m_dragMode == PlotDragMode::MARKER_OPACITY && + m_markerOpacityDragLane == static_cast(laneIndex)) || + UiPointInRect(layout.opacityTrack, static_cast(m_mouseX), + static_cast(m_mouseY)); + // 外观与信号样式区的「透明度」滑动条完全相同(同一 `UiDrawSlider` 口径)。 + UiDrawSlider(g, layout.opacityTrack, static_cast(tabLane.freeMarkerOpacity), + m_theme->accent, sliderActive, m_scale); + const int percent = static_cast(tabLane.freeMarkerOpacity * 100.0f + 0.5f); + UiText(g, FormatInteger(percent) + L"%", m_fonts->monoSmall, layout.opacityValue, + m_theme->textStrong, TextAlign::RIGHT); + } + + /** + * @brief 「自由标记」面板的**内容区**(分组栏 + 表头 + 条目 + 吸顶)。 + * + * 绘制顺序(硬约束): + * 普通分组栏 → 条目 → 表头 → 吸顶分组栏 → 滚动条 → 透明度滑块 + * 后两段必须压在条目之上,否则向上滚动的条目会盖住表头/吸顶栏。 */ void PlotView::PaintPanelMarkerGroups(gd::Graphics& g, const EdgePanelLayout& layout, - const Lane& tabLane, std::vector& tabLanes, - std::size_t laneIndex) { + const Lane& tabLane, std::vector& tabLanes, + std::size_t laneIndex) + { const std::size_t index = laneIndex; - const float groupRowHeight = kFreeMarkerNameRowHeight * m_scale; - bool markerAny = false; - for (const Lane::FreeMarkerItem& item : tabLane.freeMarkerItems) - { - markerAny = markerAny || item.ref.IsValid(); - } + const float groupRowHeight = kFreeMarkerNameRowHeight * m_scale; + bool markerAny = false; + for (const Lane::FreeMarkerItem& item : tabLane.freeMarkerItems) + { + markerAny = markerAny || item.ref.IsValid(); + } - const int contentState = g.Save(); - g.SetClip(layout.content, gd::CombineModeIntersect); + const int contentState = g.Save(); + g.SetClip(layout.content, gd::CombineModeIntersect); - if (!markerAny) - { - // ⚠️ 未开启「自由标记点选取」时鼠标是普通鼠标,**单击不会固定**任何数据点 - //(用户 2026-10-23 要求)⇒ 提示语必须说清前提,否则用户会以为 bug。 - const wchar_t* emptyHint = tabLane.freeMarkerCursor - ? L"在网格内单击以固定数据点" - : L"开启「自由标记点选取」后单击网格可固定数据点"; - UiTextClipped(g, emptyHint, m_fonts->uiSmall, - gd::RectF(layout.content.X, layout.content.Y, layout.content.Width, - groupRowHeight), - m_theme->textMuted, TextAlign::LEFT, layout.content); - } + if (!markerAny) + { + // ⚠️ 未开启「自由标记点选取」时鼠标是普通鼠标,**单击不会固定**任何数据点 + //(用户 2026-10-23 要求)⇒ 提示语必须说清前提,否则用户会以为 bug。 + const wchar_t* emptyHint = tabLane.freeMarkerCursor + ? L"在网格内单击以固定数据点" + : L"开启「自由标记点选取」后单击网格可固定数据点"; + UiTextClipped(g, emptyHint, m_fonts->uiSmall, + gd::RectF(layout.content.X, layout.content.Y, layout.content.Width, + groupRowHeight), + m_theme->textMuted, TextAlign::LEFT, layout.content); + } - // ---- 分组栏 ---- - // ⚠️ 抽取成 lambda:**吸顶**的分组栏必须在条目之后重画一次(否则正在向上滚的 - // 条目会盖住它)。见下方“条目 → 表头 → 吸顶分组栏”的绘制顺序。 - const auto paintGroupBar = [this, &g, &layout, &tabLane, index]( - const EdgePanelLayout::MarkerGroup& group) - { - const ResolvedSignal resolved = ResolveSignal(group.ref); - if (resolved.data == nullptr) - { - return; - } - const std::uint32_t signalColor = - (resolved.style != nullptr) ? StyleColor(*resolved.style) : m_theme->border; - // 托底 = 信号色;文字一律用**自适应对比色**(与信号面板的组名徽标同规则), - // 因此无论信号是亮黄还是深蓝都能看清分组栏上的所有内容。 - const UiInk ink = UiContrastInk(signalColor); - // 先铺一层**完全不透明**的同色,再叠 0xE8(观感不变): - // 吸顶时它要压住下面滚动的条目,半透明会让条目透出来。 - UiFillRoundRect(g, group.header, 3.0f * m_scale, signalColor); - UiFillRoundRect(g, group.header, 3.0f * m_scale, WithAlpha(signalColor, 0xE8)); + // 取值与各列文本来自**与视界无关的内容缓存**(`FreeMarkerContents`): + // 大量固定点时逐帧重新采样会让"拖光标 / 平移视界"明显变卡。 + const std::vector& contents = + FreeMarkerContents(static_cast(index)); + // 六列的实测宽度存回 `Lane`:下一步的布局(`EdgePanelWidth` / `EdgePanelLayoutFor`)要用。 + MeasureMarkerColumnWidths(tabLane, contents, tabLanes[index].freeMarkerColumnWidths); - // 左侧三角(与信号选择区域组名同款):朝下 = 展开、朝右 = 收起。 - const bool triangleHot = - (m_freeMarkerTriangleHoverLane == static_cast(index) && - m_freeMarkerTriangleHoverSignal == group.ref); - UiDrawTriangleIcon(g, group.triangle, - triangleHot ? m_theme->accentSoft : ink.text, - group.collapsed ? IconDirection::RIGHT - : IconDirection::DOWN); + // ---- 普通分组栏(吸顶的那些留到最后画)---- + for (const EdgePanelLayout::MarkerGroup& group : layout.markerGroups) + { + if (!group.pinned) + { + PaintMarkerGroupBar(g, layout, tabLane, group, index); + } + } - // 右侧:`N 标记点` + ✕(清除该信号全部固定点)。 - std::size_t markerCount = 0; - for (const Lane::FreeMarkerItem& entry : tabLane.freeMarkerItems) - { - if (entry.ref == group.ref) - { - ++markerCount; - } - } - const std::wstring countText = FormatInteger(markerCount) + L" 标记点"; - const float countWidth = - UiMeasureTextWidth(m_fonts->monoSmall, countText) + 2.0f * m_scale; - const float countRight = group.close.X - 5.0f * m_scale; - UiTextClipped(g, countText, m_fonts->monoSmall, - gd::RectF(countRight - countWidth, group.header.Y, countWidth, - group.header.Height), - ink.text, TextAlign::RIGHT, layout.content); + // ---- 固定点条目(六列,列起点由布局给定 ⇒ 纵向严格对齐)---- + for (const EdgePanelLayout::MarkerRow& markerRow : layout.markerRows) + { + if (markerRow.itemIndex >= contents.size()) + { + continue; + } + PaintMarkerRow(g, layout, tabLane, markerRow, contents[markerRow.itemIndex], index); + } - const float nameLeft = group.triangle.X + group.triangle.Width + 6.0f * m_scale; - const float nameWidth = - std::max(10.0f, countRight - countWidth - 6.0f * m_scale - nameLeft); - UiTextClipped(g, UiEllipsize(resolved.data->name, m_fonts->uiSmall, nameWidth), - m_fonts->uiSmall, - gd::RectF(nameLeft, group.header.Y, nameWidth, group.header.Height), - ink.text, TextAlign::LEFT, layout.content); + // ---- 表头(每个展开分组一行,与条目共用列边界)---- + // ⚠️ 必须在条目**之后**绘制:表头底衬要不透明地盖住向上滚动的条目。 + for (const EdgePanelLayout::MarkerGroup& group : layout.markerGroups) + { + PaintMarkerTableHeader(g, layout, group); + } - const bool closeHot = (m_freeMarkerPanelHoverLane == static_cast(index) && - m_freeMarkerPanelHoverSignal == group.ref && - m_freeMarkerPanelHoverItem == static_cast(-1)); - if (closeHot) - { - UiFillRoundRect(g, group.close, 3.0f * m_scale, - WithAlpha(m_theme->danger, 0x77)); - } - const float crossPad = 3.5f * m_scale; - const std::uint32_t crossColor = closeHot ? m_theme->danger : ink.text; - UiDrawLine(g, group.close.X + crossPad, group.close.Y + crossPad, - group.close.X + group.close.Width - crossPad, - group.close.Y + group.close.Height - crossPad, crossColor, - 1.5f * m_scale); - UiDrawLine(g, group.close.X + group.close.Width - crossPad, - group.close.Y + crossPad, group.close.X + crossPad, - group.close.Y + group.close.Height - crossPad, crossColor, - 1.5f * m_scale); - }; - for (const EdgePanelLayout::MarkerGroup& group : layout.markerGroups) - { - if (!group.pinned) - { - paintGroupBar(group); - } - } + // ---- 吸顶的分组栏(最后画:压住向上滚动的条目)---- + for (const EdgePanelLayout::MarkerGroup& group : layout.markerGroups) + { + if (group.pinned) + { + PaintMarkerGroupBar(g, layout, tabLane, group, index); + } + } - // ---- 固定点条目(六列,列起点由布局给定 ⇒ 纵向严格对齐)---- - // ⚠️ 取值与各列文本来自**与视界无关的内容缓存**(`FreeMarkerContents`): - // 大量固定点时逐帧重新采样会让"拖光标 / 平移视界"明显变卡。 - const std::vector& contents = - FreeMarkerContents(static_cast(index)); - // 六列的**实测宽度**:含滚出视界的条目,保证列宽稳定、文本不被省略。 - // 存回 `Lane` 供下一步的布局(`EdgePanelWidth` / `EdgePanelLayoutFor`)使用。 - { - float widest[6] = {0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f}; - const auto measure = [this, &widest](std::size_t column, const std::wstring& text) { - if (text.empty() || column >= 6) - { - return; - } - widest[column] = std::max(widest[column], - UiMeasureTextWidth(m_fonts->monoSmall, text)); - }; - // 「序号」列:取本网格所有分组里最大的序号文本宽度。 - std::size_t maxSerial = 0; - std::vector serialOrder; - for (const SignalRef& ref : tabLane.signals) - { - SortedFreeMarkerIndices(tabLane, ref, serialOrder); - maxSerial = std::max(maxSerial, serialOrder.size()); - } - if (maxSerial > 0) - { - measure(EdgePanelLayout::MARKER_COL_SERIAL, FormatInteger(maxSerial)); - } - for (const FreeMarkerContentEntry& entry : contents) - { - measure(EdgePanelLayout::MARKER_COL_VALUE, entry.valueText); - measure(EdgePanelLayout::MARKER_COL_ENUM, entry.enumText); - measure(EdgePanelLayout::MARKER_COL_DELTA, entry.deltaText); - measure(EdgePanelLayout::MARKER_COL_TIME, entry.timeText); - measure(EdgePanelLayout::MARKER_COL_DT, entry.deltaTimeText); - } - std::array& stored = - tabLanes[index].freeMarkerColumnWidths; - for (std::size_t column = 0; column < 6; ++column) - { - // +2px 余量:`DrawTextW` 的 `DT_END_ELLIPSIS` 对"刚好装满"的文本 - // 有时仍会截成省略号(亚像素/字形伸部),留一点余量可彻底避免。 - const float want = - (widest[column] > 0.0f) ? (widest[column] + 2.0f * m_scale) : 0.0f; - if (std::fabs(stored[column] - want) > 0.5f) - { - stored[column] = want; - } - } - } - // 列分隔竖线:在每列右边界(加上半个列间隙)画一条淡竖线, - // 让「序号 / 标记值 / 枚举文本 / 增量 / 时间点 / 时间间隔」有明显的列分界 - //(用户 2026-10-23 要求)。最后一列不画(它右边紧接行尾 ✕)。 - const auto paintColumnSeparators = [this, &g, &layout](float y, float height) { - int lastVisible = -1; - for (int column = 0; column < 6; ++column) - { - if (layout.markerColumns[column].width > 0.0f) - { - lastVisible = column; - } - } - for (int column = 0; column < lastVisible; ++column) - { - const EdgePanelLayout::MarkerColumn& cell = layout.markerColumns[column]; - if (cell.width <= 0.0f) - { - continue; - } - const float lineX = - cell.left + cell.width + kFreeMarkerColumnGap * m_scale * 0.5f; - UiDrawLine(g, lineX, y + 2.0f * m_scale, lineX, y + height - 2.0f * m_scale, - WithAlpha(m_theme->borderStrong, 0x6C), 1.0f); - } - }; - for (const EdgePanelLayout::MarkerRow& markerRow : layout.markerRows) - { - if (markerRow.itemIndex >= contents.size()) - { - continue; - } - const FreeMarkerContentEntry& entry = contents[markerRow.itemIndex]; + g.Restore(contentState); - // ---- 行的选中 / 悬停高亮(用户 2026-10-22 要求)---- - const bool selected = - (tabLane.freeMarkerSelected == static_cast(markerRow.itemIndex)); - const bool rowHot = (m_freeMarkerRowHoverLane == static_cast(index) && - m_freeMarkerRowHoverItem == markerRow.itemIndex); - if (selected || rowHot) - { - UiFillRect(g, - gd::RectF(markerRow.rect.X, markerRow.rect.Y + 1.0f * m_scale, - markerRow.rect.Width, - markerRow.rect.Height - 2.0f * m_scale), - selected ? WithAlpha(m_theme->accent, 0x3C) - : WithAlpha(m_theme->accent, 0x1E)); - } + // ---- 内容滚动条(只在内容超高时出现;按钮与滑块不参与滚动)---- + PaintPanelScrollBar(g, layout, *m_theme); - const auto columnRect = [&](EdgePanelLayout::MarkerColumnIndex column) { - return gd::RectF(layout.markerColumns[column].left, markerRow.rect.Y, - layout.markerColumns[column].width, - markerRow.rect.Height); - }; - // ⚠️ 面板里的数据列**不得省略**(用户 2026-10-21 明确要求): - // 列宽已按实测文本宽度撑开,因此这里直接整段输出,不再 `UiEllipsize`。 - // ⚠️ 必须用 `UiTextClipped`(GDI 裁剪):`UiText` 走 `DrawTextW`, - // **不认 GDI+ 裁剪** ⇒ 滚动到内容区上方的条目会把文字画到标题栏上 - //(用户 2026-10-22 报告的显示错误)。 - const auto paintCell = [&](EdgePanelLayout::MarkerColumnIndex column, - const std::wstring& text, std::uint32_t color) { - if (layout.markerColumns[column].width <= 0.0f || text.empty()) - { - return; - } - UiTextClipped(g, text, m_fonts->monoSmall, columnRect(column), color, - TextAlign::LEFT, layout.content); - }; - paintCell(EdgePanelLayout::MARKER_COL_SERIAL, FormatInteger(markerRow.serial), - m_theme->textMuted); - paintCell(EdgePanelLayout::MARKER_COL_VALUE, entry.valueText, - m_theme->textStrong); - if (layout.markerHasEnumColumn) - { - paintCell(EdgePanelLayout::MARKER_COL_ENUM, entry.enumText, m_theme->text); - } - paintCell(EdgePanelLayout::MARKER_COL_DELTA, entry.deltaText, - m_theme->textMuted); - paintCell(EdgePanelLayout::MARKER_COL_TIME, entry.timeText, m_theme->textMuted); - paintCell(EdgePanelLayout::MARKER_COL_DT, entry.deltaTimeText, - m_theme->textMuted); - - // 列分隔竖线(用户 2026-10-23 要求:各列之间要有明显分界)。 - paintColumnSeparators(markerRow.rect.Y, markerRow.rect.Height); - - // 行尾 ✕:移除该固定点(与之前一致)。 - const gd::RectF& closeRect = markerRow.close; - const bool hot = (m_freeMarkerPanelHoverLane == static_cast(index) && - m_freeMarkerPanelHoverItem == markerRow.itemIndex); - UiFillRoundRect(g, closeRect, 3.0f * m_scale, - hot ? WithAlpha(m_theme->danger, 0x66) : m_theme->cardBg); - UiDrawRoundRect(g, closeRect, 3.0f * m_scale, - hot ? m_theme->danger : m_theme->border, 1.0f); - const float crossPad = 3.5f * m_scale; - const std::uint32_t crossColor = hot ? m_theme->danger : m_theme->textMuted; - UiDrawLine(g, closeRect.X + crossPad, closeRect.Y + crossPad, - closeRect.X + closeRect.Width - crossPad, - closeRect.Y + closeRect.Height - crossPad, crossColor, 1.2f * m_scale); - UiDrawLine(g, closeRect.X + closeRect.Width - crossPad, closeRect.Y + crossPad, - closeRect.X + crossPad, closeRect.Y + closeRect.Height - crossPad, - crossColor, 1.2f * m_scale); - } - - // ---- 表头(每个展开分组一行,与条目共用列边界)---- - // ⚠️ 必须在条目**之后**绘制:表头底衬要不透明地盖住向上滚动的条目。 - for (const EdgePanelLayout::MarkerGroup& group : layout.markerGroups) - { - if (group.tableHeader.Width <= 0.0f || group.tableHeader.Height <= 0.0f) - { - continue; - } - // 不透明底衬(与面板底色 + 少量边框色调混合,观感是“一条浅灰带”)。 - UiFillRect(g, group.tableHeader, - MixColor(m_theme->cardBg, m_theme->borderStrong, 0.22)); - UiDrawLine(g, group.tableHeader.X, group.tableHeader.Y + group.tableHeader.Height - 1.0f, - group.tableHeader.X + group.tableHeader.Width, - group.tableHeader.Y + group.tableHeader.Height - 1.0f, - WithAlpha(m_theme->borderStrong, 0x99), 1.0f); - for (int column = 0; column < 6; ++column) - { - const EdgePanelLayout::MarkerColumn& cell = - layout.markerColumns[column]; - if (cell.width <= 0.0f) - { - continue; - } - UiTextClipped(g, kFreeMarkerColumnLabels[column], m_fonts->uiSmall, - gd::RectF(cell.left, group.tableHeader.Y, cell.width, - group.tableHeader.Height), - m_theme->textMuted, TextAlign::LEFT, layout.content); - } - // 表头的列分隔竖线(与数据行同一套列边界)。 - paintColumnSeparators(group.tableHeader.Y, group.tableHeader.Height); - } - - // ---- 吸顶的分组栏(最后画:压住向上滚动的条目)---- - for (const EdgePanelLayout::MarkerGroup& group : layout.markerGroups) - { - if (group.pinned) - { - paintGroupBar(group); - } - } - - g.Restore(contentState); - - // ---- 内容滚动条(只在内容超高时出现;按钮与滑块不参与滚动)---- - PaintPanelScrollBar(g, layout, *m_theme); - - // ---- 「标记透明度」滑动条(固定在「自由标记点选取」按钮上方)---- - if (layout.opacityTrack.Width > 0.0f) - { - // 几何全部取自布局(与命中测试同源)。 - const gd::RectF& panelRect = layout.panel; - const float labelGap = kEdgePanelPad * m_scale; - UiText(g, kFreeMarkerOpacityLabel, m_fonts->uiSmall, - gd::RectF(panelRect.X + labelGap, layout.opacityValue.Y, - layout.opacityTrack.X - panelRect.X - labelGap, - layout.opacityValue.Height), - m_theme->textMuted, TextAlign::LEFT); - const bool sliderActive = - (m_dragMode == PlotDragMode::MARKER_OPACITY && - m_markerOpacityDragLane == static_cast(index)) || - UiPointInRect(layout.opacityTrack, static_cast(m_mouseX), - static_cast(m_mouseY)); - // 外观与信号样式区的「透明度」滑动条完全相同(同一 `UiDrawSlider` 口径)。 - UiDrawSlider(g, layout.opacityTrack, - static_cast(tabLane.freeMarkerOpacity), m_theme->accent, - sliderActive, m_scale); - const int percent = - static_cast(tabLane.freeMarkerOpacity * 100.0f + 0.5f); - UiText(g, FormatInteger(percent) + L"%", m_fonts->monoSmall, - layout.opacityValue, m_theme->textStrong, TextAlign::RIGHT); - } + // ---- 「标记透明度」滑动条(固定在「自由标记点选取」按钮上方)---- + PaintMarkerOpacityRow(g, layout, tabLane, index); } } // namespace jdi diff --git a/Components/PlotView/Src/PlotViewPanels.cpp b/Components/PlotView/Src/PlotViewPanels.cpp index fa06cce..2e2c3ab 100644 --- a/Components/PlotView/Src/PlotViewPanels.cpp +++ b/Components/PlotView/Src/PlotViewPanels.cpp @@ -19,25 +19,17 @@ namespace jdi { std::wstring PlotView::PanelTitle(EdgePanelKind kind) const { - // ⚠️ 穷举且不写 `default:`(`-Wswitch` 会在新增面板时报警)。 - switch (kind) + // ⚠️ 标题前缀来自**唯一登记表**(`PanelTraitsOf`):新增面板时不需要改这个函数。 + // 只有「边沿计数」「中心轴边沿计数」需要追加运行时后缀(`EdgeRangeSuffix()` 取决于 + // 双光标是否启用),所以表里存的是"前缀 + 标志"而不是 `std::wstring`(无法 constexpr) + // 或函数指针(会把"数据"重新变成"代码",新增面板又要写一个函数)。 + const PanelKindTraits traits = PanelTraitsOf(kind); + std::wstring title(traits.title); + if (traits.rangeSuffix) { - case EdgePanelKind::COUNT: - return std::wstring(L"边沿计数") + EdgeRangeSuffix(); - case EdgePanelKind::CENTER: - return std::wstring(L"中心轴边沿计数") + EdgeRangeSuffix(); - case EdgePanelKind::MEAN: - return std::wstring(L"均值滤波"); - case EdgePanelKind::PEAK: - return std::wstring(kWeakPeakTitle); - case EdgePanelKind::MARK: - return std::wstring(L"自由标记"); - case EdgePanelKind::RESAMPLE: - return std::wstring(kResampleTitle); - case EdgePanelKind::CLIP: - return std::wstring(kClipTitle); + title += EdgeRangeSuffix(); } - return std::wstring(); + return title; } PlotView::PanelColumnMetrics PlotView::MeasurePanelColumns(const Lane& lane, diff --git a/Documents/Code_Review_Plan.md b/Documents/Code_Review_Plan.md index aa9e7d3..758f6fc 100644 --- a/Documents/Code_Review_Plan.md +++ b/Documents/Code_Review_Plan.md @@ -407,5 +407,30 @@ old keys = 919, new keys = 965 逐行状态打包进 `PanelParamState`(解析结果 / 两种条目指针 / 网格下标), 目的是把参数表压在 5 个 —— 一个个透传会让签名比函数体更难读。 +--- + +## 十三、第 41 轮(收尾):面板登记表 + 标记面板绘制分段 + +> 触发:用户要求「按上轮报告 §17.6 继续推进」,并在本轮结束时判断**是否已达到可以结束 +> 本次代码审查的程度**;体积一项按用户指示**继续暂停**(§17.6 第 3 项)。 + +| 节点 | 内容 | 验收标准 | +| --- | --- | --- | +| **P39** | `PanelStyleOf` / `PanelTitle` / `LanePanelScrollOf` / `LanePanelStateOf` / `LanePanelEnabled` 五处穷举 `switch` 合成一张 `constexpr` 登记表 `PanelTraitsOf(kind)` | 编译零 warning;`panels -mode compare` 7 面板像素哈希逐项不变 | +| **P40** | `PaintPanelMarkerGroups`(约 340 行)拆成 6 个具名函数(分组栏 / 列宽 / 分隔线 / 条目 / 表头 / 透明度行),主函数只留"顺序" | `panels -mode compare` + `rowgeom -mode compare`(默认 + MF4)0 差异 | + +### 13.1 本轮新增硬约束 + +1. **新增一种实验性功能面板只改两处**:`EdgePanelKind` 加一个枚举值 + + `pvdetail::PanelTraitsOf` 加一行(标题 / 排版类别 / 滚动字段 / 面板状态 / 功能开关全在那一行)。 + `PlotView::PanelTitle` **不再需要改** —— 它只读表里的"标题前缀 + `rangeSuffix` 标志"。 +2. **`PanelTraitsOf` 的 `switch` 一律不写 `default:`**:漏登记 `EdgePanelKind` 会被 `-Wswitch` + 当场拦下(这是"编译期保护"的唯一执行点,其余四个消费者都只是取值)。 +3. **`PanelStyle` 用三个具名构造函数**(`ListPanelStyle` / `CountTablePanelStyle` / + `MarkerPanelStyle`)构造,**禁止**在表里写 6 个裸 bool(聚合顺序错一位就变成"表格面板带按钮")。 +4. **标记面板的绘制顺序是硬约束**:普通分组栏 → 条目 → 表头 → 吸顶分组栏 → 滚动条 → 透明度行。 + 它现在**只出现在 `PaintPanelMarkerGroups` 一个地方**(过去藏在一个 340 行函数的循环中间)。 + 新增任何"吸顶/悬浮"元素时,必须加在"条目之后"。 + diff --git a/Documents/Code_Review_Progress.md b/Documents/Code_Review_Progress.md index 6ca4302..a6fd5d6 100644 --- a/Documents/Code_Review_Progress.md +++ b/Documents/Code_Review_Progress.md @@ -712,5 +712,79 @@ std::vector PlanListPanelRows(const Lane&, EdgePanelKind) const; | 行数 | `PlotViewPanelPaint.cpp` 935 → 939;`PlotViewPanels.cpp` 1729 → 1766(新增两个几何函数,净增 37) | | 样式 | 顺手把机械搬移遗留的"整个函数体多缩进 2 个 tab"修正 | +--- + +## 第 41 轮(2026-11-04):面板登记表 + 标记面板绘制分段(§17.6 第 1 / 第 2 项,收尾) + +> 本轮之后**代码审查进入收尾**:所有"已知问题"要么已修、要么有实测数字支撑地**明确不做** +>(体积、`EdgeScanFor` 哈希化、`EdgePanelLayoutFor` 表驱动化),详见报告 §18。 + +### P39 面板的五个穷举 `switch` 合成一张 `constexpr` 登记表 ✅ + +改前新增一种面板要改三处(`PanelStyleOf` / `PanelTitle` / `LanePanelScrollOf`), +实际连 `LanePanelStateOf` / `LanePanelEnabled` 一共是**五处穷举 switch** +(§17.6 只点了三处,另外两处更早就有)。 + +改后 `PlotViewInternal.hpp` 里只有**一个**登记点: + +```cpp +struct PanelKindTraits +{ + PanelStyle style; // 排版类别 + 底部控件 + const wchar_t* title = L""; // 标题前缀 + bool rangeSuffix = false; // 是否追加 EdgeRangeSuffix() + float Lane::*scroll = nullptr; // 内容滚动偏移 + LanePanelState Lane::*panel = nullptr; // 面板 UI 状态 + bool Lane::*enabled = nullptr; // 功能开关 +}; +inline constexpr PanelKindTraits PanelTraitsOf(EdgePanelKind kind); // 唯一 switch +``` + +* 五个消费者全部退化成一行取值:`return lane.*(PanelTraitsOf(kind).scroll);` +* **`PanelTitle` 不再是 switch**:标题是数据(前缀字符串 + 一个 `rangeSuffix` 标志), + 运行时后缀只对两种「边沿计数」追加 ⇒ §17.6 里"`PanelTitle` 依赖运行时字符串所以先解决 + '标题是函数'的问题"就此解决(不需要函数指针,也不需要 `std::wstring` 进表)。 +* ⚠️ `PanelStyle` 的三个具名构造函数(`ListPanelStyle` / `CountTablePanelStyle` / + `MarkerPanelStyle`)是刻意加的:表里若写 6 个裸 bool,聚合顺序错一位就变成 + "表格面板带按钮"这种没人看得出来的错误。 +* 体积代价:**+512 B**(7 份 traits 记录进 `.rdata` + `PanelTitle` 多一个分支)。 + 用 512 B 换"新增面板只改两处 + 编译期强制登记",判定值得。 + +### P40 `PaintPanelMarkerGroups` 拆成 6 个具名函数 ✅ + +| 函数 | 职责 | +| --- | --- | +| `MeasureMarkerColumnWidths` | 六列实测文本宽度 → 写回 `Lane::freeMarkerColumnWidths` | +| `PaintMarkerGroupBar` | 一个信号分组栏(三角 / 名称 / `N 标记点` / ✕) | +| `PaintMarkerColumnSeparators` | 列分隔竖线(条目行与表头共用) | +| `PaintMarkerRow` | 一个固定点条目(六列 + 分隔线 + 行尾 ✕) | +| `PaintMarkerTableHeader` | 一个展开分组的表头(不透明底衬 + 六列标题) | +| `PaintMarkerOpacityRow` | 底部「标记透明度」滑块行 | +| `PaintPanelMarkerGroups` | **只负责顺序**:普通分组栏 → 条目 → 表头 → 吸顶栏 → 滚动条 → 透明度行 | + +* 原实现把这条顺序约束埋在一个 340 行函数的循环中间("为什么表头要在这里画"), + 现在它是主函数里唯一的逻辑,注释也只剩一句"后两段必须压在上面"。 +* 提取时顺带把机械搬移遗留的"函数体多缩进 2 个 tab"修正(整个函数重新缩进)。 +* **零体积极限验证**:P39 之后产物已经是 782,336 B,P40 之后**完全相同** ⇒ 纯搬移。 + +### 过程中的一个判断(值得记) + +`contents`(固定点内容缓存)原来在"画完普通分组栏之后"才取,现在提前到最前面。 +两者唯一的区别是缓存重建的时机,而 `FreeMarkerContents` **不改任何绘制状态** +(只读采样 + 拼字符串 + 写 `m_freeMarkerContent`)⇒ 对像素无影响,双基线也证实了。 + +### 本轮验证 + +| 项 | 结果 | +| --- | --- | +| `make release` / `make debug` / `make -C Tools` | 零 error / 零 warning | +| 产物 | 781,824 → **782,336 B**(+512,全部来自 P39 的登记表;P40 为 0) | +| `UnitTests` | `checks=260 failed=0 cases=19` | +| `OffscreenChecks -all` | 7/7 PASS,`checks=133 failed=0` | +| `panels -mode compare` | 7 个面板像素哈希**逐项不变**(含 `resample=2BFC74B3D9B3239C`) | +| `rowgeom -mode compare` | 965 + 7 键 0 差异;`-data MF4-Record.MF4` 1268 + 7 键 0 差异 | +| 行数 | `PlotViewPanelPaint.cpp` 939 → 977(6 个函数头 + 说明);`PlotViewPanels.cpp` 1766 → 1758(`PanelTitle` 变短) | +| 读点 | `PanelStyleOf` / `PanelTitle` / `LanePanelScrollOf` / `LanePanelStateOf` / `LanePanelEnabled` 的调用点**一处未改**(签名不变) | + diff --git a/Documents/Code_Review_Report.md b/Documents/Code_Review_Report.md index c543c22..6b44291 100644 --- a/Documents/Code_Review_Report.md +++ b/Documents/Code_Review_Report.md @@ -825,5 +825,98 @@ old keys = 919, new keys = 965 与 §17.3 同理:先给"吸顶推挤"单独一个可测函数。 3. 体积:等有新的冗余再说(用户已明确)。 +--- + +## 十八、第 41 轮(2026-11-04,收尾):面板登记表 + 标记面板分段,以及"能否结束审查"的判定 + +### 18.1 结论 + +| §17.6 建议 | 结果 | +| --- | --- | +| 1. 三处穷举 `switch` 合成 `constexpr` 表 | ✅ 完成,而且实际收敛的是**五处**(`PanelStyleOf` / `PanelTitle` / `LanePanelScrollOf` / `LanePanelStateOf` / `LanePanelEnabled`) | +| 2. `PaintPanelMarkerGroups` 再拆 | ✅ 拆成 6 个具名函数,主函数只剩"绘制顺序" | +| 3. 体积 | ⏸ 按用户指示不做(+512 B 是本轮唯一代价,来自登记表) | + +### 18.2 本轮做了什么 + +**(1)面板静态特征收敛为唯一登记点** + +```cpp +inline constexpr PanelKindTraits PanelTraitsOf(EdgePanelKind kind); // 唯一 switch +``` + +`PanelKindTraits{style, title, rangeSuffix, scroll, panel, enabled}` —— +新增一种面板只改**两处**:`EdgePanelKind` 加枚举值 + 表里加一行。 +五个消费者的签名一字未改,调用点**一处未动**。 + +`PanelTitle` 从"返回 `std::wstring` 的 switch"变成"读表 + 可选追加 `EdgeRangeSuffix()`"。 +这解决了 §17.6 留下的那个具体障碍:"标题是函数、依赖运行时字符串,所以没法进表"—— +答案是**把标题降级为数据(前缀 + 标志)**,而不是把函数塞进表里。 + +**(2)标记面板绘制分段** + +| 函数 | 职责 | +| --- | --- | +| `MeasureMarkerColumnWidths` | 六列实测宽度 → `Lane::freeMarkerColumnWidths` | +| `PaintMarkerGroupBar` | 分组栏(三角 / 名称 / `N 标记点` / ✕) | +| `PaintMarkerColumnSeparators` | 列分隔竖线(条目与表头共用) | +| `PaintMarkerRow` | 固定点条目(六列 + 分隔线 + ✕) | +| `PaintMarkerTableHeader` | 展开分组的表头 | +| `PaintMarkerOpacityRow` | 「标记透明度」滑块行 | +| `PaintPanelMarkerGroups` | **只负责顺序**:分组栏 → 条目 → 表头 → 吸顶栏 → 滚动条 → 透明度行 | + +判据不是"看起来更清楚",而是 `panels` 的 7 个面板像素哈希 + `rowgeom` 的 +965/1268 条几何键**逐项不变**;产物字节数在 P40 前后**完全相同**(782,336 B), +说明这一段是真正的零成本搬移。 + +### 18.3 现在可以结束这次代码审查了吗?——**可以** + +这不是"我觉得差不多了",而是逐条对账的结果: + +| 审查项 | 状态 | 证据 | +| --- | --- | --- | +| 体积(890,880 → 782,336 B,−12.2%) | 完成 | `objdump -h` 节表;`-fno-rtti` + terminate handler 覆盖 + 关异步展开表;红线 1 MB,余量 ≈260 KB | +| Debug 构建"名不副实" | 已修 | `DEBUG_OPT -g` 真正生效(Debug 6.16 MB 属预期) | +| 面板分派"改一次就出错" | 已消除 | 唯一登记表 + `-Wswitch` 编译期保护 | +| 平行数组错位 / 字段重载 | 已消除 | `std::vector`(结构上不可能错位) | +| 几何算式两处/双遍历 | 已收敛 | `PanelColumnMetrics` + `PanelRowPlan` | +| 巨型函数(1900 / 340 行) | 已拆 | `PlotViewPanelPaint.cpp` 三个内容函数 + 本轮六个标记面板函数 | +| 过时文档/注释 | 已清理 | `UI_Refactor_*` 已删;`agent_mem.md` §2/§3 已校正 | +| 死代码 / 死字段 / TODO | 无 | `-Wall` 零警告 + 全成员引用计数 + 全仓 grep | +| 性能热点 | 无新发现 | 第 32 轮四处卡死已根治;三处"还能再快一点"均已量化并判定不做 | + +**明确"不做"的三项(都有数字支撑,写在这里防止后续重复尝试)**: + +1. `EdgeScanFor` 状态查找哈希化:198 状态 0.097 → 0.073 µs/次,但体积 **+5,632 B**。 +2. `EdgePanelLayoutFor` 表驱动化:实测 1.9~2.5 µs/次(每帧约 30 µs),**不是热点**。 +3. gdtoa(≈33 KB):MinGW 下只要还用 `std::to_*string`(含浮点)就必然链入, + 根因在 libstdc++ 的 `os_defines.h` 无条件 `#define __USE_MINGW_ANSI_STDIO 1`。 + +**剩下的都属"收益 < 风险/成本"**:`PaintPanelListRows` 还可再拆(纯可读性)、 +体积只在出现新冗余时再压、面板几何算式已经是单一来源。 + +### 18.4 合并前的建议动作(给用户) + +1. **远端机器务必全量重编**:本分支动过 `Makefile`(`NO_RTTI` / `RELEASE_ONLY_OPT` / + Debug `-O0 -g`),而只改 Makefile **不会**触发重编 ⇒ 先 `make clean`,再 `make release`。 + 否则会误以为"体积优化无效"或"Debug 还是 -Oz"。 +2. 合并前跑一遍**最小集**(见 `Tools/README.md` §5): + `make release` → `make -C Tools` → `UnitTests` → `OffscreenChecks -all` + → `-run panels -mode compare`(注意:面板/几何基线是**本机生成**的,跨机器不可比, + 远端首次应 `-mode record` 重建或直接跳过 compare)。 +3. 分支顺序:`Code_Review` → `dev` → `main`。本分支只含代码 / 文档 / `Tools/`, + **没有**构建产物、没有 `Temp/`、没有测试数据。 + +### 18.5 本轮验证 + +| 项 | 结果 | +| --- | --- | +| `make release` / `make debug` / `make -C Tools` | 零 error / 零 warning | +| Release 产物 | **782,336 B**(+512,全部来自 P39 登记表;P40 为 0) | +| `UnitTests` | `checks=260 failed=0 cases=19` | +| `OffscreenChecks -all` | 7 检查 / **133** 断言,全 PASS | +| `panels -mode compare` | 7 个面板像素哈希**逐项不变** | +| `rowgeom -mode compare` | 965 + 7 键 / 1268 + 7 键(MF4),**0 差异** | + diff --git a/Documents/agent_mem.md b/Documents/agent_mem.md index 8b55bb4..7be3399 100644 --- a/Documents/agent_mem.md +++ b/Documents/agent_mem.md @@ -10814,3 +10814,65 @@ std::vector PlanListPanelRows(const Lane&, EdgePanelKind) const; 2. `PaintPanelMarkerGroups`(约 260 行)按"分组栏 / 表头 / 条目吸顶"再拆。 3. **体积按用户指示暂停投入**(等后续功能更新产生新冗余时再做)。 +--- + +## 2026-11-04 第 41 轮(`Code_Review` 分支,收尾):面板登记表 + 标记面板绘制分段 + +> 本轮之后**代码审查进入收尾**(判定见 `Code_Review_Report.md` §18.3)。 +> 代码:`Components/PlotView/{Inc,Src}`。判据 = `panels -mode compare` + `rowgeom -mode compare`。 + +### 1. ✅ **取代**第 35 / 40 轮的"新增面板只改三处" + +**已作废**:旧规则「新增一种面板要在 `LanePanelScrollOf` / `PanelStyleOf` / `PanelTitle` 三处登记」。 +**现行**:新增面板只改**两处** —— `EdgePanelKind` 加枚举值 + `pvdetail::PanelTraitsOf` 加一行。 + +```cpp +struct PanelKindTraits +{ + PanelStyle style; // 排版类别 + 底部控件 + const wchar_t* title = L""; // 标题**前缀**(数据,不是函数) + bool rangeSuffix = false; // 是否追加 EdgeRangeSuffix() + float Lane::*scroll = nullptr; // 内容滚动偏移 + LanePanelState Lane::*panel = nullptr; // 面板 UI 状态 + bool Lane::*enabled = nullptr; // 功能开关 +}; +inline constexpr PanelKindTraits PanelTraitsOf(EdgePanelKind kind); // **唯一** switch +``` + +五个消费者(`PanelStyleOf` / `PanelTitle` / `LanePanelScrollOf` / `LanePanelStateOf` / +`LanePanelEnabled`)全部退化成一行取值,**签名未变、调用点一处未动**。 +`-Wswitch` 保护仍只在 `PanelTraitsOf` 这一处(不写 `default:`)。 +⚠️ `PanelStyle` 必须用三个具名构造函数(`ListPanelStyle` / `CountTablePanelStyle` / +`MarkerPanelStyle`):表里写 6 个裸 bool,聚合顺序错一位就是"表格面板带按钮"这种隐形错误。 + +**"标题是函数"的解法**:把标题降级成数据(前缀 + `rangeSuffix` 标志), +`PanelTitle` 只做 `前缀 + (rangeSuffix ? EdgeRangeSuffix() : L"")`。 +不要为此引入函数指针(等于把数据重新变成代码,新增面板又要写函数)。 + +### 2. ✅ `PaintPanelMarkerGroups` 拆成 6 个具名函数 + +`MeasureMarkerColumnWidths` / `PaintMarkerGroupBar` / `PaintMarkerColumnSeparators` / +`PaintMarkerRow` / `PaintMarkerTableHeader` / `PaintMarkerOpacityRow`; +`PaintPanelMarkerGroups` 现在**只负责绘制顺序**: + +**普通分组栏 → 条目 → 表头 → 吸顶分组栏 → 滚动条 → 透明度行** + +🔴 硬规则:**表头与吸顶分组栏必须画在条目之后**(否则向上滚动的条目会盖住它们)。 +这条约束现在只出现在一个函数里,是唯一"看代码就能看全"的顺序约束;新增悬浮/吸顶元素 +一律加在"条目之后"。 + +### 3. 本轮数字 + +* Release 781,824 → **782,336 B(+512)**,全部来自登记表(7 份 traits 记录进 `.rdata`); + **P40 拆分前后字节数完全相同** ⇒ 纯搬移。 +* release / debug / tools 全零 warning;`UnitTests` 260/19;`OffscreenChecks -all` 7 检查 / 133 断言。 +* `panels -mode compare` 7 面板像素哈希逐项不变;`rowgeom compare` 965+7 / 1268+7 键 0 差异。 +* 行数:`PlotViewPanelPaint.cpp` 939 → 977;`PlotViewPanels.cpp` 1766 → 1758。 + +### 4. 审查收尾时确认的三条"别再试" + +1. `EdgeScanFor` 状态查找哈希化:**+5,632 B** / 每轮省 ≈4.8 µs ⇒ 不做。 +2. `EdgePanelLayoutFor` 表驱动化:1.9~2.5 µs/次(每帧 ≈30 µs)⇒ 不是热点。 +3. gdtoa(≈33 KB):libstdc++ 的 `os_defines.h` 无条件 + `#define __USE_MINGW_ANSI_STDIO 1` ⇒ 只要还用 `std::to_*string`(含浮点)就去不掉。 + -- 2.54.0