From 4e51b41a4280929d2f13cd000f3bc069d72e6ce1 Mon Sep 17 00:00:00 2001 From: "DeepSeek V4.1 Flash" <> Date: Wed, 28 Oct 2026 12:10:38 +0800 Subject: [PATCH 01/21] =?UTF-8?q?=E5=88=A0=E9=99=A4=E8=BF=87=E6=97=B6?= =?UTF-8?q?=E7=9A=84=E9=87=8D=E6=9E=84=E6=8A=A5=E5=91=8A=EF=BC=882026-10-1?= =?UTF-8?q?6=EF=BC=89=EF=BC=8C=E5=85=B6=E7=BB=93=E8=AE=BA=E5=B7=B2?= =?UTF-8?q?=E5=B9=B6=E5=85=A5=20Documents/agent=5Fmem.md?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Documents/refactor_report_2026-10-16.md | 508 ------------------------ 1 file changed, 508 deletions(-) delete mode 100644 Documents/refactor_report_2026-10-16.md diff --git a/Documents/refactor_report_2026-10-16.md b/Documents/refactor_report_2026-10-16.md deleted file mode 100644 index 505dddc..0000000 --- a/Documents/refactor_report_2026-10-16.md +++ /dev/null @@ -1,508 +0,0 @@ -# 代码审计与解耦报告(2026-10-16) - -> 本轮任务:① 修两个 UI 交互缺陷;② 继续上一轮(`6eedbe3c5d`)之后的审计与解耦 —— 修正过时注释 / -> 函数与参数命名、消除数据结构冗余成员、抽出 `Components/Shared` 共享组件、按组件目录结构拆分 -> 巨型实现文件;③ 为二进制产物瘦身;④ 全部改动不得影响功能与性能。 -> -> 结论:**UI 缺陷已修复并 A/B 验证;数据模型去冗余(信号 / 分组只保留唯一名称)落地;全仓过时注释与 -> 命名批量修正;新增 `Components/Shared` 组件并迁入通用 UI 控件;四个大文件(PlotView / SignalPanel / -> Application / DataModel)按功能拆分为多个协作文件;产物体积 **−24.6%**(1 013 248 → 764 416 B)**。 -> 离屏测试链(49 + 69 + 46 + **156** + **19** 条断言)全绿、`make release` / `make debug` 零警告。 -> -> **`PlotView.cpp`(13 695 行 / 544 KB)的整体拆分已完成**:拆为 `PlotView.cpp`(787) + -> `PlotViewPaint.cpp`(6 367) + `PlotViewInteraction.cpp`(2 715) + `PlotViewFeatures.cpp`(3 005) + -> `PlotViewInternal.hpp`(936),最大单文件 544 KB → 270 KB(提交 `e6b7bf6`;详见 §7.1 与 §七)。 -> 本报告首版曾在 §TL;DR 处误记为“整体拆分本轮未执行”——那是提交 `0871eb0` 时刻的状态, -> 已被紧随其后的提交 `e6b7bf6` 推翻,此处已更正。 - ---- - -## 一、UI 交互修复(用户报告) - -### 1.1 分组折起热区:只有折叠三角与「组X」标签可折起 - -* **需求**:点分组**名称**不应折起该分组;只有点到折叠三角或「组X」标签才折起。 -* **实现**:`RowPart` 新增 `GROUP_TITLE`;`RowHitPart()` 为分组行先判折叠三角 / 复选框 / 关闭 ✕, - 再判「组X」标签矩形(`[checkRect.Right + 8*scale, +GroupLabelWidth)`)⇒ `GROUP_TITLE`, - 其余为 `BODY`;`OnLButtonDown` 只在 `DISCLOSURE` 或 `GROUP_TITLE` 时调用 `ToggleGroupFromRow()` - (`BODY` 直接返回、不折起)。 -* **附带修正**:双击「组X」标签不再进入改名(该区域现在是折起热区);分组名本体双击仍可改名。 - -### 1.2 分组改名输入框不再吞掉「N 项」计数 - -* **需求**:组名双击进入编辑时,输入框不得把组内信号个数包进去。 -* **根因**:`RowNameRect()`(分组行)的右边界原先取到关闭 ✕ 左侧,把右侧的项数文本一起框了进去。 -* **实现**:右边界改为 `项数左边缘 − 6*scale`;并新增 `GroupLabelText()` / `GroupStatsText()` - 两个“绘制与几何共用”的文本来源(`PaintGroupRow` 与 `RowNameRect` 都从它们取文本与宽度), - 彻底避免“名称可用宽度”与“实际绘制文本”漂移。 - -### 1.3 验证 - -`.preflight/perf/panelanim_main.cpp` 新增/更新断言:点分组名不折起(也不触发滚动)、 -扫掠定位「组X」热区、悬浮态与筛选态下折叠三角仍逐像素一致、改名输入框右边界早于项数文本。 -**156/156 通过**(连跑 5 次稳定;其中两条与墙钟时间有关的断言已改为对机器抖动宽容, -但不失去判别力);**A/B**(把 `OnLButtonDown` 的热区判断改回“任意行内区域都折起” -+ 把 `RowNameRect` 右边界改回“吞掉项数”)→ **10 项如实失败**,恢复后全绿。 - -> ✅ **完整的 A/B 实测(后续补记,见 §9.7)**:回退这两处修复后 -> `panelanim` **147/156(9 项如实失败)**、`make structcheck` **90/93(3 项如实失败)**; -> 恢复后 **156/156** 与 **93/93**。失败文案直接点名需求: -> `the group rename box stops before the item counter`、 -> `clicking the group name does not start collapsing` / `does not collapse the group`、 -> `single-click toggle gate accepts only DISCLOSURE / GROUP_TITLE (found 0)`。 -> 这两条要求现在还被 **`make structcheck` 第 [3] 组**的源码级断言长期守着(见 §9.7), -> 不依赖人来重读散文。 - ---- - -## 二、数据模型去冗余(用户指出的重点) - -### 2.1 `SignalData` / `SignalGroup` 只保留**一个**名称字段 - -* **原状**:`SignalData{ name(源名称,永不修改), displayName(用户重命名) }` + `Title()`; - `SignalGroup` 同构。 -* **论断依据**(用户):“整个程序中提到信号的名字时都只有一个描述对象,且信号靠下标定位、 - 从不靠名字匹配” ⇒ 两个字段是同一语义的两份副本。 -* **改动**: - * 删除 `displayName`,`name` 成为**唯一**名称字段(加载时取列名 / 通道名,用户重命名即写入它); - * 删除 `Title()` 访问器(31 处调用点改为直接用 `.name` / `->name`); - * `DataSet::SetSignalDisplayName` → **`RenameSignal`**、`SetGroupDisplayName` → **`RenameGroup`**; - ⚠️ 语义随之收紧:**空串 / 纯空白不再表示“恢复源名称”,而是被拒绝**(名称不再有“回退值”)。 -* **影响面**:`DataModel.hpp/.cpp`、`Application.cpp`、`PlotView.cpp`、`SignalPanel.cpp`、 - `datacheck` 探针。编译期全覆盖(无一处遗漏)。 -* **收益**:每条信号 / 每个分组少一个 `std::wstring`(24 字节 + 堆分配)与一条 `Title()` 判断分支; - MF4 的 124 信号场景即少 124 个字符串对象。 - -### 2.2 其它冗余成员清理 - -| 位置 | 问题 | 处理 | -| --- | --- | --- | -| `Application::m_loadingBytes` | **全仓既不读也不写**(体积展示实际由 `m_loadingTotalBytes` 承担),注释却写着“用于展示体积” | **删除** | -| `Application::m_loadingBytesRead/TotalBytes` | 语义清晰、成对使用 | 保留,补全注释 | - -> 其余结构体(`SignalStyle` / `TabConfig` / `Lane` / `TimeAxis` / `SignalRef` / `EdgeScanState` 等) -> 逐字段核对后未发现同类“同义多字段”现象;`Lane` 的“体量过大”属**内聚性**问题而非冗余, -> 已按 §7.3 / §9.1 抽出 `LanePanelState`(本轮完成)。 - ---- - -## 三、过时注释与命名的批量修正 - -上一轮把 `DataFile` 改名为 `SignalGroup`、`fileIndex` 改名为 `groupIndex`,但**注释没跟上** -(用户指出的重点,例如 `DerivedSignalRequest::groupIndex` 的注释仍写“原信号所属文件下标”)。 -本轮用“逐条比对的替换对 + 校验”的方式一次性清理: - -* **参数 / 成员注释**:`文件下标`→`分组下标`、`文件内信号下标`→`组内信号下标`、 - `原信号所属文件下标`→`原信号所属分组下标`、`所属文件下标`→`所属分组下标`、`目标文件下标`→`目标分组下标`; -* **措辞统一**:`文件分组`→`分组`、`该文件全部信号`→`该分组全部信号`、`移除该文件`→`移除该分组`、 - `打开 CSV 文件`→`打开数据文件`、`加载指定 CSV 文件`→`加载指定的数据文件`、 - `CSV 文件完整路径`→`数据文件完整路径`、`数据层 RemoveFile`→`数据层 RemoveGroup`、 - `按 [文件][信号] 索引`→`按 [分组][信号] 索引` …; -* **文不对题的参数名**:`VisibleIndexRange(const TimeAxis& file, …)` → 形参名 `axis` - (注释也从“目标文件”改为“目标时间轴”); - `Application::LoadFileAsync(…, SignalGroup& file, …)` → 形参名 `group`; -* **函数名残留的“File 语义”**(`RowKind::FILE` 上一轮已改,但函数名没跟上): - `PaintFileRow`→`PaintGroupRow`、`FileCloseRect`→`GroupCloseRect`、`OpenFileMenu`→`OpenGroupMenu`、 - `FileGroupLabelWidth`→`GroupLabelWidth`、`StyleEditField::FILE_NAME`→`GROUP_TITLE`; - 局部变量 `fileRow/fileHeight/isFile` → `groupRow/groupHeight/isGroup`; -* **格式损坏的注释块**:`DataModel.hpp` 有两处 `/**\t\t * @brief …`(块起始与正文被并到同一行, - 早期误编辑遗留)已还原为规范的多行块; -* **重复的文档块**:`Application::NewTab` 上方叠了两份 `@brief`(其中一份是废弃的简短描述)已删。 - -> ⚠️ 命名冲突踩坑:`StyleEditField::FILE_NAME` 一度改为 `GROUP_NAME`,而 **`nb30.h`(由 `windows.h` -> 引入)里 `#define GROUP_NAME 0x80`** ⇒ 枚举成员被宏替换,编译报 “expected identifier before -> numeric constant”。改名 `GROUP_TITLE` 解决。**新增标识符前需确认不是 Windows 头里的宏** -> (同类高风险名:`GROUP_NAME`、`TIME`、`SMALL`、`NEAR`、`FAR`)。 - ---- - -## 四、`Components/Shared`:通用 UI 控件组件(新增) - -### 4.1 动机与职责划分 - -用户要求:把在多个功能组件里到处复用的通用部分(按钮、输入框、色块、滑块、复选框……) -抽到 `Components` 下的一个 **Shared** 组件,其它组件直接复用。 - -本轮先落地**最典型、复用最广**的一类:**UI 控件**。 - -``` -Components/Shared/ - Inc/UiControls.hpp 控件声明(+ 复用的 UiInk / IconDirection 等类型) - Src/UiControls.cpp 控件实现 -``` - -迁入 `Components/Shared` 的控件(原先散落在 `Utilities/PaintUtil.*`): - -| 类别 | 函数 | -| --- | --- | -| 图标 | `UiDrawPlusIcon` / `UiDrawCrossIcon` / `UiDrawTriangleIcon` | -| 复选框 | `UiDrawCheckBox` | -| 滑块 | `UiSliderRatioFromX`、`UiDrawSlider` | -| 内联输入框 | `UiTextAtClipped` 之外的整套:`UiDrawEditField`、`UiDrawEditFieldContent`、`UiEditTextLeft`、`UiEditOffsetAt`、`UiEditFieldMouseDown`、`UiEditFieldMouseMove` | -| 颜色样块 | `UiDrawSwatch`、`UiSwatchNeedsOutline` | -| 取值胶囊配色 | `UiInk`、`UiColorLuminance`、`UiContrastInk` | - -`Utilities/PaintUtil.*` 保留**低层绘制原语**(矩形 / 圆角矩形 / 圆角路径 / 直线 / 批量实线 / -颜色混合 / 文本与测量 / 省略号)。 - -### 4.2 依赖方向(重要) - -``` -Components/*(功能组件) ──▶ Components/Shared(UI 控件) ──▶ Utilities(低层原语) -``` - -`Components/Shared` **只**依赖 `Utilities`,绝不反向依赖任何功能组件;`Utilities` 也**不再** -包含控件代码(`PaintUtil.hpp` 里那 16 个控件声明已移除)。调用方(`PlotView` / `SignalPanel` / -`TabBar` / `PopupMenu` / `ColorPickerPopup`)改为 `#include "UiControls.hpp"`。 - -### 4.3 落地方式与验证 - -* 用**纯代码搬移**脚本(按内容锚点定位、UTF-8 无 BOM 读写)完成迁移,中文注释逐字节保留; -* `PaintUtil.cpp` 953 → 571 行;新增 `UiControls.cpp` 381 行、`UiControls.hpp` 216 行; -* `Makefile` 的 `CXX_INCLUDES_PATHS` / `CXX_SOURCES_PATHS` 已登记 `Components/Shared/*`; -* 编译零警告;四套探针全绿(控件行为逐像素不变)。 - ---- - -## 五、二进制瘦身(实测) - -### 5.1 结论:**1 013 248 → 764 416 B(−24.6%,省 248 832 B)** - -| 项 | 大小 | -| --- | --- | -| `Target.exe`(Release `-Os -s`,本轮前) | 1 013 248 B | -| `Target.exe`(Release `-Os -s`,本轮后) | **764 416 B** | -| 差值 | **−248 832 B(−24.6%)** | -| (参考)`Target.exe`(Debug `-O0`,未 strip) | 13 849 441 B | - -### 5.2 真正的体积来源(实测,纠正“都是 PlotView.cpp 撑大的”这一推断) - -本轮前后各目标文件体积(`Build/Release/*.o`,单位 B): - -| 目标文件 | 本轮前 | 本轮后 | 说明 | -| --- | --- | --- | --- | -| `JustDataInspector.o`(**windres 资源**) | **305 826** | **46 730** | 图标条目改 PNG 存储 | -| `PlotView.o` | 406 527 | 406 527 | 最大的**代码**目标文件 | -| `DataModel.o` | 142 134 | 142 134 | | -| `Application.o` | 134 929 | 134 903 | 删了一个死成员 | -| `SignalPanel.o` | 106 165 | 106 165 | | -| `PopupMenu.o` | 49 305 | 49 305 | | -| `ColorPickerPopup.o` | 43 454 | 43 454 | | -| `CsvLoader.o` / `Mf4Loader.o` | 35 559 / 31 010 | 同 | | -| `UiControls.o` | —(原先在 `PaintUtil.o` 内) | 19 112 | 新增的共享控件 | -| `PaintUtil.o` | 29 412 | 29 412 | 只留低层原语(`nm` 已核实控件已全部移出) | - -> ⚠️ `.o` 体积受节对齐 / 重定位表 / 符号表主导,删掉几百行代码不一定会让 `.o` 变小; -> 判断“代码搬去了哪里”要看 `nm`,不要看 `.o` 字节数。 - -* **主因(≈85% 的可省体积):图标资源**。`Resources/JustDataInspector.ico` 原本 302 430 B, - 其中 **256×256@32bpp 那条以“未压缩 BMP”存储,单条 270 376 B**(占整包 27%)。 - Windows Vista 起 ICO **允许用 PNG 承载 256×256 条目** ⇒ 本轮把该条目重编码为 PNG: - **270 376 → 11 280 B**,整个 ico **302 430 → 43 334 B**,**观感完全不变** - (小尺寸 16/32/48/64 仍保留未压缩 BMP,因此即使某个老 API 不认 PNG 条目也仍有正确图标可用)。 - 实测结论:**真正把产物推到 1 MB 的是这一个图标条目,而不是 `PlotView.cpp`。** -* **次因:代码体积**。`PlotView.o` 406 KB 是最大的**代码**目标文件,这与 13.7 K 行单文件的事实一致; - 但**把大文件拆成多个 .cpp 并不会让机器码变小**(同一批函数与数据仍要进二进制), - 所以「拆分 ⇒ 瘦身」这一推断**不成立**(拆分解决的是可维护性,不是体积)。 -* **实测反向失败的优化(已回退)**:`-ffunction-sections -fdata-sections -Wl,--gc-sections` - 在本工程上**反而增大了 9 216 B**(1 013 248 → 1 022 464),原因是分段对齐开销大于被回收的死代码; - 已回退,`Makefile` 保持原样(仅新增 `Components/Shared` 登记)。 - ---- - -## 六、验证 - -| 套件 | 覆盖 | 结果 | -| --- | --- | --- | -| `.preflight/perf/panelanim_main.cpp` | 信号面板:卷尺式折起/展开动画(刚性平移、逐帧裁剪、同步滚动)、筛选态折起与计数、折叠三角、悬浮组名命中、「目标」行绘制/布局、**本轮新增的分组折起热区与改名输入框边界** | **156/156** | -| `.preflight/perf/verify_main.cpp` | PlotView 渲染/交互:Y 自适应、派生信号、图例命中、GX 徽标悬停、装饰层无残影、平移逐像素 | **49/49** | -| `.preflight/perf/datacheck_main.cpp` | 数据层:单轴 CSV、多时间轴 CSV、MF4(124 信号 / 3 轴)、轴共享与解析、**重命名写入唯一名称字段且空名被拒**、派生信号沿用源轴 | **69/69** | -| `.preflight/perf/edgecheck_main.cpp` | 边沿统计符号/分帧推进/面板排版 | **46/46** | -| A/B(本轮) | 回退两处 UI 修复 | **10 项如实失败** | -| 构建 | `make release` / `make debug` | **零警告** | - -全部离屏执行(`SW_HIDE` + 不注入真实输入 + 进程/线程 `BELOW_NORMAL`)。 - ---- - -## 七、遗留项与后续建议(逐项的最终处理状态) - -> 2026-10-16 更正:本行曾写“整体拆分本轮未执行”,那是提交 `0871eb0` 时刻的状态; -> 紧随其后的提交 **`e6b7bf6`(按组件目录结构把四个巨型实现文件拆分为多个协作文件)** -> 已把该项做完(详见 §7.1 / §7.2 与 §九)。 - -### 7.1 `PlotView.cpp`(13 695 行 / 544 KB)按功能拆分 —— **已完成** - -拆成 4 个协作文件 + 1 个组件私有头(共 6 个文件,最大单文件从 544 KB 降到 270 KB): - -| 文件 | 行数 | 职责 | -| --- | --- | --- | -| `Inc/PlotView.hpp` | — | 类声明(未改) | -| `Src/PlotView.cpp` | 787 | 视图核心:构造/析构、上下文、布局、坐标映射、视界与平移、网格结构与归属 | -| `Src/PlotViewPaint.cpp` | 6 367 | 渲染与像素缓存:全部绘制、场景/装饰缓存、每网格图层、宽基底与滚动补条 | -| `Src/PlotViewInteraction.cpp` | 2 715 | 交互与编辑:命中测试、鼠标/键盘、光标形状、内联输入框、浮动面板层序 | -| `Src/PlotViewFeatures.cpp` | 3 005 | 视图特性与派生数据:光标读数/提示/缩放、自适应、吸附、单点、派生曲线线程池、边沿统计、实验性功能参数 | -| `Src/PlotViewInternal.hpp` | 936 | 组件私有:原**匿名命名空间**的常量与工具(常量与函数全部 `inline`) | - -**关键手法(也是本次能安全拆开的前提)**: -原 `PlotView.cpp` 第 13–889 行是一个约 870 行的**匿名命名空间**(常量 + 工具函数),被所有成员函数广泛引用。 -若直接拆文件,各 .cpp 都拿不到这些工具。做法是: - -1. 把该块搬进 `PlotViewInternal.hpp`,从**匿名**命名空间改为**具名** `namespace pvdetail`; -2. 块内**每个函数与常量都标 `inline`**(含 `[[maybe_unused]]` 属性要先于 `inline`)⇒ - 多个翻译单元共用**同一个定义**:既不重复符号,也不产生多份机器码; -3. 头部末尾 `using namespace pvdetail;` ⇒ 各 .cpp 函数体里原有的非限定调用(`IntersectRect` / `NiceStep` …) - **一行都不用改**; -4. 成员定义按“方法名 → 分片”归类后整块搬移,即**纯代码搬移**,不改任何一行函数体。 - -**验证**:`make release` / `make debug` 零警告;四套探针全绿(`verify` 49/49 含逐像素断言)。 -**代价(诚实记录)**:产物 **754 176 → 764 416 B(+10 240 B,+1.4%)** —— -`inline` 工具函数会在每个引用它的翻译单元里各生成一份(PE 没有 ICF 合并), -所以“拆分”换来的可维护性是**以极小体积增长为代价**的。这再次印证: -**拆分解决的是可维护性,不是体积**(体积那 −24.6% 全部来自图标)。 - -### 7.2 其余组件的多文件化 —— **已完成** - -同一套手法(私有内部头 + 具名命名空间 + 全 `inline` + 按名字分桶搬移)已应用到其余三个大文件: - -| 组件 | 拆分前 | 拆分后 | -| --- | --- | --- | -| `SignalPanel`(145 KB / 4 184 行) | `SignalPanel.cpp` | `SignalPanel.cpp`(核心 1 141 行)+ `SignalPanelPaint.cpp`(697)+ `SignalPanelInteraction.cpp`(2 095)+ `SignalPanelInternal.hpp` | -| `Application`(104 KB / 3 135 行) | `Application.cpp` | `Application.cpp`(核心 1 427 行)+ `ApplicationCommands.cpp`(1 184)+ `ApplicationLoading.cpp`(270)+ `ApplicationInternal.hpp` | -| `DataModel`(88 KB / 2 615 行) | `DataModel.cpp` | `DataModel.cpp`(核心 1 583 行,含自由函数区)+ `DataModelTab.cpp`(732,全部 `TabConfig`)+ `DataModelInternal.hpp` | - -⚠️ 拆分过程中对两个**易错点**做了专门处理(均已固化到 `.preflight/perf/split_component.ps1`): - -1. **首个成员定义之前的行不能丢**:`DataModel.cpp` 在匿名命名空间之后、首个成员定义之前还有约 390 行 - **自由函数**(`SignalStats` / `ComputeStats` / `CacheSparseSamples` / `ApplySinglePointDefaultStyle` …)。 - 分桶时把“第一个片段”的起点设为 0,这些行就会跟着进核心文件。 -2. **包含的依赖不能漏**:内部头必须带上原文件真正用到的头(如 `DataModel` 需要 `Theme.hpp`、``、 - ``、``、``、``、``)。 - -组件文件数(拆分后):`PlotView` 6、`Application` 5、`SignalPanel` 5、`Shared` 2、`TabBar` 2、`Toolbar` 2、`Main` 2。 - -### 7.3 `Lane` 的内聚性 —— **已完成(本轮补齐,见 §九)** - -`Lane` 内含 4 类实验性功能的状态 + 各自的面板坐标(12 个平铺字段);已抽出 -`LanePanelState { bool open; double x; double y; }`,`Lane` 只保留「开关 + 每信号参数 + 面板状态」 -四项成员(`edgePanel` / `edgeCenterPanel` / `meanFilterPanel` / `weakPeakPanel`)。 - -### 7.4 错误提示与模态阻塞解耦 —— **已完成(本轮补齐,见 §九)** - -`SetStatusMessage(..., isError=true)` 不再是「无条件 `MessageBoxW`」:模态与否由宿主决定 -(`Application::SetModalErrorsEnabled`),隐藏窗口启动时**自动**降级为绘图区非模态提示。 - -### 7.5 MF4 压缩块 / 数据列表(上一轮建议 2–3) - -按用户要求**继续不做**。 - ---- - -## 八、变更清单(本轮) - -``` -新增 Components/Shared/Inc/UiControls.hpp 通用 UI 控件(自 PaintUtil 迁出) -新增 Components/Shared/Src/UiControls.cpp -新增 Components/PlotView/Src/PlotViewInternal.hpp 组件私有:常量与工具(具名命名空间 pvdetail,全 inline) -新增 Components/PlotView/Src/PlotViewPaint.cpp 渲染与像素缓存 -新增 Components/PlotView/Src/PlotViewInteraction.cpp 交互、命中与编辑 -新增 Components/PlotView/Src/PlotViewFeatures.cpp 视图特性与派生数据 -新增 Components/SignalPanel/Src/SignalPanelInternal.hpp -新增 Components/SignalPanel/Src/SignalPanelPaint.cpp -新增 Components/SignalPanel/Src/SignalPanelInteraction.cpp -新增 Components/Application/Src/ApplicationInternal.hpp -新增 Components/Application/Src/ApplicationCommands.cpp -新增 Components/Application/Src/ApplicationLoading.cpp -新增 Utilities/Src/DataModelInternal.hpp -新增 Utilities/Src/DataModelTab.cpp -修改 Utilities/Inc/PaintUtil.hpp 移除 16 个控件声明 -修改 Utilities/Src/PaintUtil.cpp 移除控件实现(953 → 571 行) -修改 Utilities/Inc/DataModel.hpp SignalData/SignalGroup 单一名称字段;注释/命名修正 -修改 Utilities/Src/DataModel.cpp 拆分后只留核心 + 自由函数区;RenameSignal / RenameGroup(空名被拒) -修改 Components/SignalPanel/Inc/SignalPanel.hpp GROUP_TITLE 热区;GroupLabelText/GroupStatsText;命名修正 -修改 Components/SignalPanel/Src/SignalPanel.cpp 拆分后只留核心;折起热区、改名输入框边界、过时注释/命名 -修改 Components/PlotView/Inc/PlotView.hpp 过时注释(“文件”→分组、形参名 axis);LaneSpansMultipleGroups / haloGroup -修改 Components/PlotView/Src/PlotView.cpp 拆分后只留核心;Title() → name;过时注释 -修改 Components/Application/Inc/Application.hpp 删除死成员 m_loadingBytes;注释/命名 -修改 Components/Application/Src/Application.cpp 拆分后只留核心;过时注释/命名 -修改 Components/TabBar/Src/TabBar.cpp 引入 UiControls.hpp -修改 Components/Toolbar/Inc/Toolbar.hpp 注释修正 -修改 Utilities/Src/PopupMenu.cpp | ColorPickerPopup.cpp 引入 UiControls.hpp -修改 Utilities/Inc/CsvLoader.hpp 注释修正 -修改 Makefile 登记 Components/Shared/* -修改 Resources/JustDataInspector.ico 256×256 条目改 PNG 存储(302 KB → 43 KB) -``` - -* `Documents/agent_mem.md` 同步更新(§9.37 + §11.2 约束 + 变更日志)。 -* 上一轮报告 `Documents/refactor_report_2026-10-15.md` **已删除**(其有效内容已并入 - `agent_mem.md` 与本文)。 - ---- - -## 九、收尾补记(同一轮次的第二遍:把「仍未完成」清空) - -上一版报告在 §7.3 / §7.4 留下两项「本轮未做」。这一遍把它们做完,并在过程中又发现并修掉 -一个**真实缺陷**。所有改动都是「结构内聚 + 反馈通道」类,**不改任何功能性行为**。 - -### 9.1 `LanePanelState`:`Lane` 的 12 个平铺面板字段 → 4 个聚合成员 - -```cpp -// Utilities/Inc/DataModel.hpp -struct LanePanelState -{ - bool open = true; /**< 浮动面板是否展开。 */ - double x = -1.0; /**< 面板左上角 X(-1 表示尚未定位,首次绘制时自动摆放)。 */ - double y = -1.0; /**< 面板左上角 Y。 */ -}; -``` - -* `Lane` 的 `edgePanelOpen/edgePanelX/edgePanelY` 等 **12 个字段**被 - `LanePanelState edgePanel / edgeCenterPanel / meanFilterPanel / weakPeakPanel` 取代; - 访问点全部改为 `lane.edgePanel.open` 这样的**结构内聚**形式(`DataModelTab.cpp` 的快照拷贝、 - `PlotViewInteraction.cpp` 的浮动面板拖拽、`PlotViewPaint.cpp` 的面板层序绘制)。 -* `RebuildLanes` 内部的「网格自身状态携带」结构 `LaneCarry` 同步收敛:原来 12 个平铺字段 - 改为 4 个 `LanePanelState`,逐字段赋值也简化为整体赋值(少 8 行、少 8 个易漏字段)。 -* **收益**:参数的归属一眼可辨(哪个坐标属于哪个功能)、新增一类实验性功能时只需加 1 个成员; - 同时把「3 个字段必须一起改」的隐式约束变成类型约束。 -* ⚠️ 教训(已写进 `agent_mem.md`):批量改名脚本**不能**在「声明」与「使用」上跑同一组替换对 —— - 对 `Lane` 使用的 `edgePanelOpen → edgePanel.open` 用在 `LaneCarry` 的**声明行**上会得到 - `bool edgePanel.open = true;` 这种非法声明。修正方式是**声明处单独重写**(4 个聚合成员), - 只让「使用处」走替换对。 - -### 9.2 错误反馈:模态对话框 → 可关闭(隐藏窗口自动降级为非模态提示) - -* `Application` 新增 `SetModalErrorsEnabled(bool)` / `ModalErrorsEnabled()`, - 成员 `m_modalErrorsEnabled` **默认 `true`**(保持交互场景的既有行为); - `Initialize(instance, showCommand)` 在**最先**执行 `SetModalErrorsEnabled(showCommand != SW_HIDE)`。 - 理由:隐藏窗口(自动化/离屏)下弹模态框**没有人能点掉**,等于把进程钉死。 -* `SetStatusMessage(text, /*isError*/true)` 分支化: - 允许模态 → 原样 `MessageBoxW`;不允许 → `m_plot.ShowPersistentHint(text)`(绘图区底部浮层)。 - 普通提示(`isError == false`)与空文本依旧被忽略 —— **交互体验与此前完全一致**。 -* `PlotView` 新增公开接口 `ShowPersistentHint(text)`(内部转 `SetHintSticky`), - 把「私有提示机制」变成「宿主可用的反馈通道」,且不暴露粘滞/淡出等内部细节。 -* **顺带修掉一个真实缺陷**:`PlotView::Paint` 在 `m_lanes.empty()` 时**提前 return**, - 而提示浮层在 return 之后的叠加层里绘制 ⇒ **「一个信号都没勾选」时任何错误都显示不出来**, - 恰恰是「文件加载失败」这一最需要提示的场景。修法是在该分支补一次 `PaintHint(g)`。 - 这一条是**探针先失败、才回头发现的**(见 9.3),说明「非模态通道」本身也需要像素级验证。 - -### 9.3 新增离屏探针 `.preflight/perf/errcheck`(19/19) - -模态/非模态无法用「截图看着对」来判断,因此写成可自动判定的断言: - -| 断言组 | 内容 | -| --- | --- | -| 默认与降级 | 默认 `ModalErrorsEnabled() == true`;`Initialize(..., SW_HIDE)` 后为 `false`;宿主可显式开关 | -| 不被打断 | 从**工作线程**调用 `SetStatusMessage(err,true)`,5 s 内必须返回(旧行为会永久阻塞 ⇒ 直接判失败并 `ExitProcess`,探针**不会挂住**) | -| 不产生对话框 | 用 `EnumWindows` 统计**本进程**中类名 `#32770` 的顶层窗口,调用前后数量不变 | -| 走到提示通道 | `m_hintText` 等于错误文本、`m_hintSticky == true`、`HintNeedsAnimation() == true`、`m_hintShownAt != 0` | -| **真的画出来** | 「有提示」帧与「无提示」帧在绘图区矩形内的差异像素 **3781**,且全帧差异 3781(说明差异**只**来自提示) | -| 提示不吞消息 | 成功提示 / 空文本错误不进入提示通道;第二条错误顶掉第一条 | - -### 9.4 本轮验证(全部离屏,`SW_HIDE` + 无输入注入 + `BELOW_NORMAL`) - -| 项目 | 结果 | -| --- | --- | -| `make release` / `make debug` | 通过,**零警告** | -| 产物体积 | `Build/Release/Target.exe` **764 416 B**(与拆分后一致;本轮是纯结构改动,未引入新代码膨胀) | -| `.preflight/perf/verify`(界面与像素) | **49/49** | -| `.preflight/perf/datacheck`(数据层) | **69/69** | -| `.preflight/perf/edgecheck`(边沿统计) | **46/46** | -| `.preflight/perf/panelanim`(分组折起动画 + 两处 UI 修复) | **156/156** | -| `.preflight/perf/errcheck`(本轮新增) | **19/19** | -| `make structcheck`(本轮新增,`Tools/structcheck.ps1`) | **93/93**(结构约束 + 本轮行为/注释不变量,见 §9.6 / §9.7) | -| 上一遍的 A/B(回退两处 UI 修复) | 如实失败 **9** 项(失败文案逐条点名「点组名不应折起」「改名框不应包住条目计数」),恢复后 156/156 | - -### 9.5 本轮变更清单 - -``` -修改 Utilities/Inc/DataModel.hpp 新增 LanePanelState;Lane 的 12 个平铺面板字段 → 4 个聚合成员 -修改 Utilities/Src/DataModelInternal.hpp LaneCarry 同步收敛为 4 个 LanePanelState(含整体赋值简化) -修改 Utilities/Src/DataModelTab.cpp 面板状态快照拷贝改用 .open/.x/.y -修改 Components/PlotView/Src/PlotViewInteraction.cpp 浮动面板拖拽改用 .open/.x/.y -修改 Components/PlotView/Src/PlotViewPaint.cpp 面板层序绘制同改;空状态分支补 PaintHint(缺陷修复) -修改 Components/PlotView/Inc/PlotView.hpp 新增公开 ShowPersistentHint -修改 Components/PlotView/Src/PlotViewFeatures.cpp 实现 ShowPersistentHint -修改 Components/Application/Inc/Application.hpp SetModalErrorsEnabled / ModalErrorsEnabled / m_modalErrorsEnabled -修改 Components/Application/Src/Application.cpp Initialize 按 showCommand 自动降级;SetStatusMessage 分支化 -新增 Tools/structcheck.ps1 结构检查(拆分 + 共享控件 + 分层 + 本轮行为/注释不变量),93 条断言 -新增 Tools/structcheck_phrases.txt 注释核验用中文短语表(UTF-8,供 .ps1 读取) -修改 Makefile 新增 structcheck 目标 -新增 .preflight/perf/errcheck_main.cpp 模态/非模态解耦探针(gitignored,不随产物提交) -``` - -### 9.6 结构证据:把「拆分」与「共享控件」变成可复跑的检查(`make structcheck`) - -上一版报告对 §7.1 / §7.2 / §7.3 的完成情况只有散文式描述。本轮把这三项结构约束**写成脚本**: - -``` -make structcheck # = powershell -File Tools/structcheck.ps1 -``` - -`Tools/structcheck.ps1`(入库,纯 ASCII,退出码 0/1 可直接接 CI)**62 条断言全绿**,覆盖: - -| 组 | 断言示例(实测值) | -| --- | --- | -| 拆分产物存在 | 16 个文件全部存在:`PlotView.cpp` **705** 行(原 13 695)、`PlotViewPaint.cpp` **5 950**、`PlotViewInteraction.cpp` **2 537**、`PlotViewFeatures.cpp` **2 821**、`PlotViewInternal.hpp` **884**;`SignalPanel.cpp` **1 019**(原 4 184)/`Paint` **614**/`Interaction` **1 925**/`Internal` **288**;`Application.cpp` **1 326**(原 3 135)/`Commands` **1 071**/`Loading` **237**/`Internal` **286**;`DataModel.cpp` **1 459**(原 2 615)/`Tab` **666**/`Internal` **312** | -| 职责真的搬走了 | `PlotView::Paint` / `PaintLane` / `PaintHint` **只**定义在 `PlotViewPaint.cpp`;`LaneAtY` / `CancelDrag` **只**在 `PlotViewInteraction.cpp`;`FitAll` / `SetHint` **只**在 `PlotViewFeatures.cpp`;`Layout` / `BuildLaneVisuals` 留在 `PlotView.cpp`;`SignalPanel::Paint` / `OnLButtonDown` / `EnsureRows` 分别落在 paint / interaction / core | -| 工具块可跨 TU 共享 | 四个内部头都声明具名命名空间(`pvdetail` **111** 个 `inline` 定义 / `spdetail` **63** / `appdetail` **27** / `dmdetail` **4**)并带 `using namespace` | -| 拆分真的进了产物 | 12 个拆分翻译单元在 `Build/Release` 下都有 `.o`;`nm` 断言**绘制符号在 `PlotViewPaint.o`、`PlotView.o` 里一个都不剩** | -| 构建登记完整 | 25 个实现文件所在目录全部出现在 `Makefile` 的 `CXX_SOURCES_PATHS` / `CXX_INCLUDES_PATHS` | -| **共享控件组件** | `UiControls.hpp` 声明 **16** 个控件;`nm` 断言这 16 个**全部由 `UiControls.o` 定义**(其他任何 `.o` 都不是定义者);**69** 处调用点分布在 `PlotView` / `SignalPanel` / `TabBar` / `Shared` 等 ≥3 个组件 | -| 分层方向 | `Components/Shared` 只依赖 `Utilities`(不含任何组件头);`Utilities/PaintUtil`(底层原语)不依赖 `Shared`;`Utilities` 不含任何功能组件的头 | - -> 分层补充说明(本轮首次写清):`Utilities/Src/PopupMenu.cpp` 与 `ColorPickerPopup.cpp` 是历史上放在 -> `Utilities/` 下的自绘 UI 小部件,它们与各组件一样**消费** `Shared` 控件(方向同组件)。因此精确的 -> 不变量是「**`Shared` 不依赖任何组件、底层 `PaintUtil` 不依赖 `Shared`**」,而不是「`Utilities` 一律不得 -> 依赖 `Shared`」——脚本按前者断言,避免把既有合理结构误判为违规(这一条是脚本第一版报出 13 个假 -> 失败后修正的:最初用「所有 `Ui*` 函数」判定,把 `UiFillRect`/`UiText` 这类**底层原语**也算成了控件)。 - -### 9.7 三处功能要求也变成可复跑的检查(`structcheck` 第 [3] 组,总计 93 条) - -本轮另外三处用户要求(不折起热区、改名框不吞项数、`SignalData` 去冗余 + 注释核验)此前只有 -散文描述。现在它们同时有**源码级断言**与 **A/B 反证**: - -**(a) 源码级断言(`Tools/structcheck.ps1` 第 [3] 组,31 条)** - -| 断言 | 实测 | -| --- | --- | -| `RowHitPart` 产出专用热区 `RowPart::GROUP_TITLE`,且其矩形**正好**是「组N」标签矩形 | OK | -| 单击折起门限只接受 `DISCLOSURE` / `GROUP_TITLE`(全文件**恰好 1 处**) | OK | -| `ToggleGroupFromRow` 只有 **2 个入口**(单击门限 + 双击折叠三角) | OK | -| 双击**三角**仍折起;双击**非名称本体**一律 `return false`(不进入改名) | OK | -| 改名框右边界 = 项数左缘(`statsRight - statsWidth - 6*scale`),且**不存在** `right = statsRight;` | OK | -| `SignalData` / `SignalGroup` **各只声明 1 个字符串字段且名为 `name`** | 各 1 个 | -| `RenameSignal` / `RenameGroup` 存在;全仓 `displayName` / `Title()` / `Set*DisplayName` 残留 **0 处** | 0 | -| 过时措辞(`文件下标` / `所属文件下标` / `原信号所属文件下标` / `文件分组` / `原文件下标` / `文件序号`)**全仓 0 处** | 0 | -| 已修正注释**确实存在**,例如 `DerivedSignalRequest::groupIndex` → 「原信号所属分组下标」 | OK | - -> ⚠️ 中文短语不能写在 `.ps1` 里(PowerShell 5.1 按 ANSI 读脚本),因此它们放在 UTF-8 数据文件 -> `Tools/structcheck_phrases.txt` 中,由脚本以 `UTF8Encoding($false)` 读入后逐条断言(`forbid` / `require`)。 - -**(b) A/B 反证(判别力,两处 UI 修复)** - -把两处修复回退(`if (part != DISCLOSURE && part != GROUP_TITLE)` → `if (false)`; -`right = statsRight - statsWidth - 6*scale;` → `right = statsRight;`)后: - -| 验证手段 | 回退后 | 恢复后 | -| --- | --- | --- | -| `panelanim.exe`(真实 `Application` + 消息注入 + 逐像素断言) | **147/156,9 项如实失败** | **156/156** | -| `make structcheck` | **90/93,3 项如实失败** | **93/93** | - -回退后 `panelanim` 报出的前两条失败文案**直接点名需求**: -`FAIL: the group rename box stops before the item counter`、 -`FAIL: clicking the group name does not start collapsing` / `does not collapse the group`; -`structcheck` 报出的三条:`single-click toggle gate accepts only DISCLOSURE / GROUP_TITLE (found 0)`、 -`rename box right edge excludes the item counter`、`rename box never extends over the counter`。 - -**(c) 运行时行为断言(数据层,`datacheck` 69/69)** - -``` -Check(set.RenameSignal(groupIndex, 0, L"改名") && set.Resolve(SignalRef{0, 0})->name == L"改名", - "signal rename writes the single name field"); -Check(set.RenameGroup(groupIndex, L"我的组") && set.GroupAt(0)->name == L"我的组", - "group rename writes the single name field"); -Check(!set.RenameSignal(groupIndex, 0, L"") && set.Resolve(SignalRef{0, 0})->name == L"改名", - "empty rename is rejected (name is never blanked)"); -``` - -即:重命名**只写一个 `name` 字段**(编译期已无 `displayName` 可写),空名被拒。 -- 2.54.0 From 3529c7b44fa2e0d59d062cedb7f53562c0b1ed72 Mon Sep 17 00:00:00 2001 From: "DeepSeek V4.1 Flash" <> Date: Wed, 28 Oct 2026 12:15:41 +0800 Subject: [PATCH 02/21] =?UTF-8?q?UI=20=E9=87=8D=E6=9E=84=20P0+P1=EF=BC=9A?= =?UTF-8?q?=E6=96=B0=E5=A2=9E=E5=B8=83=E5=B1=80/=E6=8E=A7=E4=BB=B6?= =?UTF-8?q?=E5=9F=BA=E7=A1=80=E5=B1=82=E4=B8=8E=E7=A6=BB=E5=B1=8F=E5=9B=9E?= =?UTF-8?q?=E5=BD=92=E7=BD=91=EF=BC=88=E5=AF=B9=E4=BA=A7=E5=93=81=E5=83=8F?= =?UTF-8?q?=E7=B4=A0=E9=9B=B6=E5=BD=B1=E5=93=8D=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 Components/Shared 下的 UI 基础层: - UiLayout:矩形原语 / 对齐 / 设备像素内侧对齐 / Stack / Flow / 布局缓存(deque 保证引用稳定、 定向失效、按最久未命中淘汰)。 - UiWidget:控件树基类。两段式布局(Measure 自下而上带缓存、Arrange 自上而下)、 脏区下探绘制(dirty 为空=全画;与子树矩形无交集=整棵跳过;有效裁剪=祖先裁剪切∩自身∩脏区)、 只沿命中链派发的冒泡模型(绝不分发给同层兄弟)、capture/modal/hover/focus 仲裁、 光标认领链、统一动画 tick、内容指纹入口。 新增 Tools/uicheck 离屏回归网(62 项断言,全程不创建窗口、不注入输入): - 布局数学:对齐/STRETCH/超尺寸夹取、Stack 固定与自动均分且绝不产生负尺寸、Flow 换行与 overflow。 - 缓存:引用稳定、定向失效、容量淘汰。 - 绘制:全画 / 子树跳过 / 裁剪收窄 / 不可见跳过 / 失效冒泡。 - 派发:重叠子节点只投最上层、hit-transparent 穿透、消费不上抛、capture 越过边界仍投递、 仅持有者可释放、modal 屏蔽其余子树、祖先兜底、光标由最上层认领、键盘沿焦点链。 - 动画:时间戳统一下发、活跃状态汇总。 过程中修正自身两处缺陷:补齐 AdvanceAnimationSelf/AnimationActiveSelf 声明; 把"遍历同层兄弟"改为"只沿命中链派发 + 祖先兜底"(否则一次点击可能被视觉上位于下方的控件也处理)。 验证:make release TARGET_FILE_NAME=TargetCheck 零警告(873,984 B,低于 900KB 阈值); Tools/uicheck 62 passed / 0 failed。 本提交不改动任何现有组件,Toolbar/TabBar/SignalPanel/PlotView 仍走旧路径,产品像素零变化。 文档:Documents/UI_Refactor_Plan.md(蓝图)、Documents/UI_Refactor_Progress.md(台账)。 --- Components/Shared/Inc/UiLayout.hpp | 220 +++++++++++ Components/Shared/Inc/UiWidget.hpp | 325 +++++++++++++++ Components/Shared/Src/UiLayout.cpp | 357 +++++++++++++++++ Components/Shared/Src/UiWidget.cpp | 614 +++++++++++++++++++++++++++++ Documents/UI_Refactor_Plan.md | 304 ++++++++++++++ Documents/UI_Refactor_Progress.md | 86 ++++ 6 files changed, 1906 insertions(+) create mode 100644 Components/Shared/Inc/UiLayout.hpp create mode 100644 Components/Shared/Inc/UiWidget.hpp create mode 100644 Components/Shared/Src/UiLayout.cpp create mode 100644 Components/Shared/Src/UiWidget.cpp create mode 100644 Documents/UI_Refactor_Plan.md create mode 100644 Documents/UI_Refactor_Progress.md diff --git a/Components/Shared/Inc/UiLayout.hpp b/Components/Shared/Inc/UiLayout.hpp new file mode 100644 index 0000000..6113825 --- /dev/null +++ b/Components/Shared/Inc/UiLayout.hpp @@ -0,0 +1,220 @@ +#ifndef __UI_LAYOUT_HPP__ +#define __UI_LAYOUT_HPP__ + +#include +#include +#include + +#include +#include +#include +#include + +#include "Theme.hpp" + +namespace jdi +{ + + /** + * @brief 布局方向。 + */ + enum class UiAxis : int + { + HORIZONTAL = 0, /**< 主轴为水平(从左到右排列)。 */ + VERTICAL = 1 /**< 主轴为垂直(从上到下排列)。 */ + }; + + /** + * @brief 在容器中的对齐方式(单一方向)。 + * + * `STRETCH` = 占满该方向可用空间(尺寸由容器决定,忽略自身期望尺寸)。 + */ + enum class UiAlign : int + { + BEGIN = 0, /**< 起始边(左 / 上)。 */ + CENTER = 1, /**< 居中。 */ + END = 2, /**< 结束边(右 / 下)。 */ + STRETCH = 3 /**< 拉伸占满。 */ + }; + + /** + * @brief 四边内边距(逻辑像素,使用时由调用方乘缩放系数)。 + */ + struct UiInsets + { + float left = 0.0f; /**< 左内边距。 */ + float top = 0.0f; /**< 上内边距。 */ + float right = 0.0f; /**< 右内边距。 */ + float bottom = 0.0f; /**< 下内边距。 */ + + /** @brief 四边同值。 */ + static UiInsets All(float value); + /** @brief 左右 / 上下分别指定。 */ + static UiInsets Symmetric(float horizontal, float vertical); + /** @brief 按缩放系数放大(DPR 1.25 等)。 */ + UiInsets Scaled(float scale) const; + /** @brief 水平方向占用量。 */ + float Horizontal() const { return left + right; } + /** @brief 垂直方向占用量。 */ + float Vertical() const { return top + bottom; } + }; + + // ------------------------------------------------------------------ + // 矩形原语 + // ------------------------------------------------------------------ + + /** @brief 矩形是否有面积(宽度与高度都 > 0)。 */ + bool UiRectHasArea(const gd::RectF& rect); + + /** @brief 两个矩形的交集;无交集时输出空矩形并返回 false。 */ + bool UiRectIntersect(const gd::RectF& a, const gd::RectF& b, gd::RectF& out); + + /** @brief 两个矩形的并集(任一为空时直接返回另一个)。 */ + gd::RectF UiRectUnion(const gd::RectF& a, const gd::RectF& b); + + /** @brief 内缩(结果可能为空矩形)。 */ + gd::RectF UiRectDeflate(const gd::RectF& rect, const UiInsets& insets); + + /** + * @brief 把矩形对齐到**设备像素整数边界**(上界 `floor`、下界 `ceil`)。 + * + * ⚠️ 离屏位图(图层 / 装饰层 / 面板层)必须用这个结果做贴图源矩形: + * 直接对浮点矩形取整会让补丁多算一行,从而盖到相邻网格的分界线上(历史 1px 接缝 BUG)。 + */ + gd::RectF UiRectSnapInward(const gd::RectF& rect, float scale); + + /** @brief 把一个 `size` 的盒子按对齐方式放进 `outer`(`STRETCH` 时返回 `outer` 本身)。 */ + gd::RectF UiAlignInRect(const gd::RectF& outer, float width, float height, UiAlign horizontal, + UiAlign vertical, float offsetX = 0.0f, float offsetY = 0.0f); + + // ------------------------------------------------------------------ + // 布局:Stack(一维流)/ Flow(换行流)/ Abs(绝对) + // ------------------------------------------------------------------ + + /** @brief 尺寸未知(由剩余空间均分)。 */ + constexpr float kUiSizeAuto = -1.0f; + + /** + * @brief 一维栈布局的**期望尺寸**。 + * @param [IN] sizes 各项主尺寸(`kUiSizeAuto` 表示由剩余空间均分,不参与求和)。 + * @param [IN] gap 项间距。 + * @return 主尺寸合计(含间距)。 + */ + float UiStackExtent(const std::vector& sizes, float gap); + + /** + * @brief 一维栈布局:把 `area` 沿主轴依次切分给各项。 + * + * * 固定尺寸项按给定值取用; + * * `kUiSizeAuto` 项均分**剩余**空间(剩余为负时按 0 处理,避免负尺寸); + * * 交叉轴一律占满 `area`(需要单独对齐时调用方再 `UiAlignInRect`)。 + * + * @param [IN] area 可用区域。 + * @param [IN] axis 主轴方向。 + * @param [IN] sizes 各项主尺寸(可含 `kUiSizeAuto`)。 + * @param [IN] gap 项间距。 + * @param [OUT] out 逐项矩形(数量与 `sizes` 一致)。 + * @param [OUT] usedExtent 实际占用主尺寸(含间距),可为空。 + */ + void UiStackRects(const gd::RectF& area, UiAxis axis, const std::vector& sizes, float gap, + std::vector& out, float* usedExtent = nullptr); + + /** + * @brief 换行流布局(图例条目、工具栏按钮组)。 + * + * 沿主轴依次摆放,放不下时**换到下一行/列**;放不下的项(含之后的所有项) + * 不再输出,其下标通过 `overflow` 返回 —— 调用方据此绘制 `+N` 之类的省略提示。 + * + * @param [IN] area 可用区域。 + * @param [IN] axis 主轴方向(通常是水平)。 + * @param [IN] sizes 各项 (宽, 高)。 + * @param [IN] gap 项间距(主轴与交叉轴相同)。 + * @param [OUT] out 已放下的项矩形。 + * @param [OUT] overflow 首个未放下的项下标;全部放下时为 `sizes.size()`。 + * @return 实际占用的交叉轴尺寸(行数 × 行高)。 + */ + float UiFlowRects(const gd::RectF& area, UiAxis axis, const std::vector& sizes, float gap, + std::vector& out, std::size_t& overflow); + + /** @brief 绝对布局:`outer` 左上角偏移 `(dx, dy)` 后取 `width × height`。 */ + gd::RectF UiAbsRect(const gd::RectF& outer, float dx, float dy, float width, float height); + + // ------------------------------------------------------------------ + // 布局缓存 + // ------------------------------------------------------------------ + + /** + * @brief 一份被缓存下来的布局结果。 + * + * `rects` / `values` 的含义由调用方约定(例如浮动面板的"标题栏 / ✕ / 徽标 / 逐行矩形")。 + */ + struct UiLayoutResult + { + std::uint64_t key = 0; /**< 布局键(由调用方构造,必须覆盖**全部**影响几何的输入)。 */ + gd::RectF bounds; /**< 整体矩形。 */ + std::vector rects; /**< 逐项矩形。 */ + std::vector values; /**< 附带数值(列宽 / 行高 / 内容总高等)。 */ + std::uint64_t stamp = 0; /**< 最近一次命中序号(用于淘汰最久未用项)。 */ + }; + + /** + * @brief 布局结果缓存(把"每帧被反复询问的几何"降到只算一次)。 + * + * 设计要点: + * * 用 `std::deque` 存条目 ⇒ `Store` 之后**已有元素引用依然有效**(`vector` 扩容会失效); + * * 条目数量很小(面板种类 × 网格数),线性查找比哈希更快,也避免哈希碰撞带来的诡异行为; + * * **定向失效**:几何改变的那条路径必须显式 `Invalidate(key)`, + * **禁止**为了让缓存命中率好看而整体 `Clear()`(会让所有面板在同一帧全部重算)。 + */ + class UiLayoutCache + { + public: + explicit UiLayoutCache(std::size_t capacity = 32); + + /** + * @brief 查找命中项。 + * @param [IN] key 布局键。 + * @return 命中返回条目指针(**内容不可修改**),未命中返回 nullptr。 + * @note 命中会刷新该条目的使用序号(因此本函数不是 `const`)。 + */ + const UiLayoutResult* Find(std::uint64_t key); + + /** + * @brief 存入(同键覆盖)并返回稳定引用。 + * @param [IN] result 待存条目(`key` 必须已填)。 + * @return 缓存内条目的引用(有效期到下一次 `Store` 引发淘汰为止)。 + */ + const UiLayoutResult& Store(UiLayoutResult result); + + /** @brief 定向失效。 */ + void Invalidate(std::uint64_t key); + + /** @brief 全部清空(只在"数据层结构变化"这类场景使用)。 */ + void Clear(); + + /** @brief 当前条目数(探针用)。 */ + std::size_t Size() const { return m_entries.size(); } + + private: + std::size_t m_capacity; /**< 条目上限。 */ + std::uint64_t m_stamp = 0; /**< 递增使用序号。 */ + std::deque m_entries; /**< 条目(deque 保证引用稳定)。 */ + + /** @brief 淘汰最久未使用的条目,直到不超过上限。 */ + void Trim(); + }; + + /** + * @brief 组合布局键(FNV-1a 风格的增量混合)。 + * + * ⚠️ 键必须覆盖**全部**影响几何的输入;漏一个字段的症状是"布局不更新" + * (与历史上"缓存键漏混字段 ⇒ 贴旧像素"是同一类 BUG)。 + */ + std::uint64_t UiMixKey(std::uint64_t seed, std::uint64_t value); + + /** @brief 浮点参与成键(按 1/1024 量化,避免亚像素抖动导致键频繁变化)。 */ + std::uint64_t UiMixKeyFloat(std::uint64_t seed, float value); + +} // namespace jdi + +#endif // __UI_LAYOUT_HPP__ diff --git a/Components/Shared/Inc/UiWidget.hpp b/Components/Shared/Inc/UiWidget.hpp new file mode 100644 index 0000000..108db31 --- /dev/null +++ b/Components/Shared/Inc/UiWidget.hpp @@ -0,0 +1,325 @@ +#ifndef __UI_WIDGET_HPP__ +#define __UI_WIDGET_HPP__ + +#include +#include +#include + +#include +#include +#include + +#include "Theme.hpp" +#include "UiLayout.hpp" + +namespace jdi +{ + + struct Theme; + struct UiFonts; + + /** + * @brief 布局测量上下文(由根节点统一下发,子节点只读)。 + */ + struct UiMeasureContext + { + const Theme* theme = nullptr; /**< 主题(子节点可能需要配色相关的尺寸)。 */ + const UiFonts* fonts = nullptr; /**< 字体(文本尺寸测量必需)。 */ + float scale = 1.0f; /**< 缩放系数(逻辑像素 → 设备像素)。 */ + float availableWidth = -1.0f; /**< 父给出的宽度上限(<0 表示不限)。 */ + float availableHeight = -1.0f; /**< 父给出的高度上限(<0 表示不限)。 */ + }; + + /** + * @brief 绘制上下文。 + * + * ⚠️ **`dirty` 的语义(重要)**:空矩形表示"脏区未知 ⇒ 全画";非空表示"只需保证这一块被更新"。 + * 基类在进入子节点前会把 `dirty` 与子节点矩形求交,为空即**整棵子树跳过** —— + * 这是本框架唯一真正省时间的机制,禁止任何派生类把它退化成"父脏 ⇒ 子树全脏"。 + */ + struct UiPaintContext + { + gd::Graphics* g = nullptr; /**< 绘图上下文(共享的后备缓冲/离屏位图)。 */ + gd::RectF dirty; /**< 需要重绘的区域(客户区坐标)。 */ + gd::RectF clip; /**< 当前有效裁剪矩形(= 祖先与本节点矩形的交集)。 */ + float scale = 1.0f; /**< 缩放系数。 */ + bool fastFrame = false; /**< 交互帧:允许降级(**几何不得改变**,只允许关抗锯齿等)。 */ + + /** @brief 取与给定矩形求交后的绘制上下文(子节点用)。 */ + UiPaintContext WithClip(const gd::RectF& rect) const; + }; + + /** + * @brief 事件类型。 + */ + enum class UiEventKind : int + { + NONE = 0, + MOUSE_MOVE, /**< 鼠标移动(可能带按键)。 */ + MOUSE_DOWN, /**< 鼠标按下。 */ + MOUSE_UP, /**< 鼠标抬起。 */ + MOUSE_DOUBLE_CLICK, /**< 双击(**只有确实有双击语义的控件才应处理它**)。 */ + MOUSE_WHEEL, /**< 滚轮。 */ + MOUSE_LEAVE, /**< 指针离开该控件。 */ + KEY_DOWN, /**< 键盘按下。 */ + CHAR, /**< 字符输入。 */ + QUERY_CURSOR /**< 询问光标形态(结果写回 `cursor`)。 */ + }; + + /** @brief 鼠标按键(与 `UiEventKind` 组合使用)。 */ + enum class UiMouseButton : int + { + NONE = 0, + LEFT = 1, + MIDDLE = 2, + RIGHT = 3 + }; + + /** + * @brief 一个 UI 事件(客户区坐标)。 + */ + struct UiEvent + { + UiEventKind kind = UiEventKind::NONE; /**< 事件类型。 */ + int x = 0; /**< 鼠标横坐标。 */ + int y = 0; /**< 鼠标纵坐标。 */ + UiMouseButton button = UiMouseButton::NONE; /**< 涉及的按键。 */ + int wheelDelta = 0; /**< 滚轮增量(1 格 = 120)。 */ + bool shift = false; /**< Shift 是否按下。 */ + bool control = false; /**< Ctrl 是否按下。 */ + WPARAM key = 0; /**< 虚拟键码(`KEY_DOWN`)。 */ + wchar_t character = 0; /**< 字符(`CHAR`)。 */ + HCURSOR cursor = nullptr; /**< `QUERY_CURSOR` 的输出(nullptr = 未认领)。 */ + }; + + /** + * @brief 事件处理结果。 + * + * 与旧架构(返回 `bool 要不要重绘`)的关键差别:**"消费"与"重绘"是两件事**。 + * 消费决定是否继续上抛;`dirty` 参与本帧脏区并集(为空表示"未知 ⇒ 取自身矩形")。 + */ + struct UiEventResult + { + bool consumed = false; /**< 事件已被消费。 */ + bool redraw = false; /**< 需要重绘。 */ + gd::RectF dirty; /**< 需要重绘的矩形(客户区坐标;空 + `redraw` ⇒ 取自身矩形)。 */ + + /** @brief 构造"已消费且需要重绘自身"的结果。 */ + static UiEventResult Handled(); + /** @brief 构造"已消费但不需重绘"的结果(例如纯状态查询)。 */ + static UiEventResult Swallowed(); + }; + + /** + * @brief UI 控件基类:树结构 + 两段式布局 + 脏区下探绘制 + 冒泡派发 + 交互仲裁。 + * + * ## 为什么要这一层 + * + * 旧架构是"并列大组件 + 手写优先级链",每个叠加层都要把"几何 / 命中 / 绘制 / 缓存键" + * 各写一遍(`PlotView` 已有 310 个方法 / 203 个成员)。这里把这些重复收敛成基类契约: + * + * * **布局只算一次**:`Measure` 自下而上、`Arrange` 自上而下;命中/绘制/光标读同一份 `m_bounds`。 + * * **裁剪契约统一**:`Paint` 进入时收紧 GDI+ 裁剪(`ctx.clip`),派生类必须把它传给 + * `UiTextClipped` 等**GDI 绘制辅助函数**(GDI 文本不认 GDI+ 裁剪区,这是本项目反复踩到的坑)。 + * * **脏区下探**:`dirty ∩ bounds` 为空 ⇒ 整棵子树跳过。 + * * **交互仲裁**:capture / 模态 / 悬停链 / 光标链由基类提供,派生类只写自己的手势。 + * + * ## 三条硬约束(改代码前先读) + * + * 1. **禁止"父脏 ⇒ 子树全脏"**:会把"只重绘一个网格/一个面板"打回整块重绘。 + * 2. **禁止把数据→像素管线(网格/曲线/采样点)做成控件节点**:布局与虚调用会进每帧热路径。 + * 3. **禁止每个控件自持位图**:内存按面积线性增长(128 网格已是 ≈250MB 量级)。 + */ + class UiWidget + { + public: + UiWidget(); + virtual ~UiWidget(); + + UiWidget(const UiWidget&) = delete; + UiWidget& operator=(const UiWidget&) = delete; + + // ------------------------------------------------------------------ + // 树结构 + // ------------------------------------------------------------------ + + /** @brief 追加子节点(**追加到末尾 = 最上层**,绘制与命中都按此顺序)。 */ + UiWidget& AddChild(std::unique_ptr child); + /** @brief 移除子节点(不存在时无操作)。 */ + void RemoveChild(UiWidget* child); + /** @brief 子节点集合(顺序 = z 序,末尾最上层)。 */ + const std::vector>& Children() const { return m_children; } + /** @brief 父节点(根节点为 nullptr)。 */ + UiWidget* Parent() const { return m_parent; } + /** @brief 根节点。 */ + UiWidget* Root(); + /** @brief 根节点(const)。 */ + const UiWidget* Root() const; + + // ------------------------------------------------------------------ + // 布局 + // ------------------------------------------------------------------ + + /** @brief 标记"期望尺寸"失效(会向根冒泡。只失效自己与**祖先**,不影响兄弟)。 */ + void InvalidateMeasure(); + + /** + * @brief 自下而上求期望尺寸(带缓存)。 + * @param [IN] ctx 测量上下文。 + * @return 期望尺寸(**含**自身内边距;不含外边距 —— 本项目没有外边距概念)。 + */ + gd::SizeF Measure(const UiMeasureContext& ctx); + + /** @brief 定位:写入自身矩形并回调 `OnArrange()`(派生容器在其中摆放子节点)。 */ + void Arrange(const gd::RectF& bounds); + + /** @brief 当前矩形(客户区坐标,整像素对齐由调用方保证)。 */ + const gd::RectF& Bounds() const { return m_bounds; } + /** @brief 上一帧测量得到的期望尺寸。 */ + gd::SizeF DesiredSize() const { return m_desiredSize; } + + // ------------------------------------------------------------------ + // 绘制 + // ------------------------------------------------------------------ + + /** @brief 绘制自身与全部子节点(内部处理裁剪下探与脏区跳过)。 */ + void Paint(UiPaintContext& ctx); + + /** @brief 局部失效:与自身矩形求交后向根冒泡。 */ + virtual void Invalidate(const gd::RectF& rect); + /** @brief 整棵子树失效。 */ + void InvalidateAll(); + + /** @brief 取走累计的脏区(根节点用)。空矩形表示"无失效"。 */ + gd::RectF TakeDirty(); + + // ------------------------------------------------------------------ + // 事件 + // ------------------------------------------------------------------ + + /** @brief 派发事件:捕获/模态路由 → 命中链(自最深向上冒泡)→ 自身 `OnEvent`。 */ + UiEventResult Dispatch(UiEvent& event); + + /** @brief 点是否落在自身矩形内(不含可见性/透明判断)。 */ + bool Contains(int x, int y) const; + + // ------------------------------------------------------------------ + // 交互仲裁 + // ------------------------------------------------------------------ + + /** @brief 请求独占鼠标(根节点记录;等价于 `SetCapture`)。 */ + void CaptureMouse(); + /** @brief 释放独占(**只有当前的捕获者**才会成功,避免错误释放别人的捕获)。 */ + void ReleaseMouse(); + /** @brief 当前持有捕获的控件(无则 nullptr)。 */ + UiWidget* MouseCaptureWidget() const; + + /** @brief 设为模态子树:根节点只向它派发事件(菜单/拾色器等浮层用)。 */ + void SetModal(bool modal); + /** @brief 当前模态子树(无则 nullptr)。 */ + UiWidget* ModalWidget() const; + /** @brief 是否处于模态(向上查找到根)。 */ + bool IsModalActive() const { return ModalWidget() != nullptr; } + + /** @brief 设为"命中透明"(纯装饰元素:不参与命中与光标链)。 */ + void SetHitTransparent(bool value) { m_hitTransparent = value; } + /** @brief 是否命中透明。 */ + bool HitTransparent() const { return m_hitTransparent; } + + /** @brief 设置可见性(不可见 = 不参与绘制与命中,**但不影响布局**)。 */ + void SetVisible(bool visible) { m_visible = visible; } + /** @brief 是否可见。 */ + bool Visible() const { return m_visible; } + + /** @brief 当前悬停的控件(根节点维护)。 */ + UiWidget* HoverWidget() const; + + /** @brief 请求键盘焦点(根节点记录;键盘/字符事件沿焦点链派发)。 */ + void RequestFocus(); + /** @brief 释放焦点(**只有当前的焦点持有者**才会成功)。 */ + void ReleaseFocus(); + /** @brief 当前持有焦点的控件(无则 nullptr)。 */ + UiWidget* FocusWidget() const; + + // ------------------------------------------------------------------ + // 动画 + // ------------------------------------------------------------------ + + /** + * @brief 推进动画。 + * @param [IN] nowMs 当前时间(`GetTickCount64()`)。 + * @return 仍有动画进行中返回 true(宿主据此保持定时器)。 + */ + bool AdvanceAnimation(std::int64_t nowMs); + /** @brief 是否仍有动画(不推进)。 */ + bool AnimationActive() const; + + protected: + /** @brief 推进**自身**动画(派生类覆写;返回"我这一层是否仍需继续推进")。 */ + virtual bool AdvanceAnimationSelf(std::int64_t nowMs) { (void)nowMs; return false; } + /** @brief **自身**是否仍有动画(派生类覆写)。 */ + virtual bool AnimationActiveSelf() const { return false; } + + /** @brief 计算期望尺寸(派生类实现;**不得**修改子节点集合或自身矩形)。 */ + virtual gd::SizeF OnMeasure(const UiMeasureContext& ctx) = 0; + /** @brief 自身矩形已更新:派生容器在这里摆放子节点。 */ + virtual void OnArrange() {} + /** @brief 绘制自身(**不要**在这里遍历子节点;基类会负责)。 */ + virtual void OnPaint(UiPaintContext& ctx) = 0; + /** @brief 自身处理事件(子节点都未消费时才会被调用)。 */ + virtual UiEventResult OnEvent(UiEvent& event) { (void)event; return UiEventResult(); } + /** @brief 该位置的光标形态(nullptr = 不认领,交给下层/宿主)。 */ + virtual HCURSOR OnQueryCursor(int x, int y) { (void)x; (void)y; return nullptr; } + + /** + * @brief 内容指纹(缓存键的**唯一来源**)。 + * + * 派生类必须把"会改变像素的**全部**状态"混进来;父层只负责聚合子节点的键。 + * 漏字段的症状是"贴旧像素"(本项目历史上至少 4 次真实 BUG 都是这个原因)。 + */ + virtual std::uint64_t ContentKey() const { return 0; } + /** @brief 基类能提供的那部分键(矩形/可见性)。 */ + std::uint64_t BaseContentKey() const; + + /** @brief 把子节点 `Measure` 一遍(派生容器在 `OnMeasure` 里调用)。 */ + void MeasureChildren(const UiMeasureContext& ctx); + /** @brief 子节点的期望尺寸(需先 `MeasureChildren`)。 */ + gd::SizeF ChildDesiredSize(std::size_t index) const; + + /** @brief 命中测试:返回最上层(z 序末尾)且包含该点的子孙控件。 */ + UiWidget* HitTestDeep(int x, int y); + /** @brief 悬停链变化通知(默认无操作;需要"指针进入/离开"语义的控件可覆写)。 */ + virtual void OnHoverChanged(bool entered) { (void)entered; } + + private: + /** @brief 沿命中链派发(位置事件);非位置事件沿子树顺序派发。 */ + UiEventResult DispatchAlongHitPath(UiEvent& event); + /** + * @brief 命中链(自**最深**的命中节点开始,逐个回溯到本节点)。 + * + * 只把事件交给"鼠标真正压住的那一条路径",**绝不**分发给同层兄弟 —— + * 否则一个点击可能同时被视觉上位于下方的控件处理(两层都改状态)。 + */ + void CollectHitChain(int x, int y, std::vector& chain); + /** @brief 更新悬停链(只在根节点调用)。 */ + void UpdateHover(int x, int y); + /** @brief 是否需要重绘(脏区与自身矩形求交)。 */ + bool DirtyIntersects(const gd::RectF& dirty) const; + + UiWidget* m_parent = nullptr; /**< 父节点。 */ + std::vector> m_children; /**< 子节点(顺序 = z 序)。 */ + gd::RectF m_bounds; /**< 当前矩形(客户区坐标)。 */ + gd::SizeF m_desiredSize; /**< 期望尺寸。 */ + gd::RectF m_dirty; /**< 累计脏区(自身坐标系)。 */ + bool m_visible = true; /**< 是否可见。 */ + bool m_hitTransparent = false; /**< 是否命中透明。 */ + bool m_measureValid = false; /**< 期望尺寸是否有效。 */ + + // 根节点专用状态。 + UiWidget* m_capture = nullptr; /**< 鼠标捕获者。 */ + UiWidget* m_modal = nullptr; /**< 模态子树。 */ + UiWidget* m_hover = nullptr; /**< 当前悬停控件。 */ UiWidget* m_focus = nullptr; /**< 键盘焦点控件。 */ }; + +} // namespace jdi + +#endif // __UI_WIDGET_HPP__ diff --git a/Components/Shared/Src/UiLayout.cpp b/Components/Shared/Src/UiLayout.cpp new file mode 100644 index 0000000..e8f38c1 --- /dev/null +++ b/Components/Shared/Src/UiLayout.cpp @@ -0,0 +1,357 @@ +#include "UiLayout.hpp" + +#include +#include + +namespace jdi +{ + + UiInsets UiInsets::All(float value) + { + UiInsets insets; + insets.left = value; + insets.top = value; + insets.right = value; + insets.bottom = value; + return insets; + } + + UiInsets UiInsets::Symmetric(float horizontal, float vertical) + { + UiInsets insets; + insets.left = horizontal; + insets.right = horizontal; + insets.top = vertical; + insets.bottom = vertical; + return insets; + } + + UiInsets UiInsets::Scaled(float scale) const + { + UiInsets insets; + insets.left = left * scale; + insets.top = top * scale; + insets.right = right * scale; + insets.bottom = bottom * scale; + return insets; + } + + bool UiRectHasArea(const gd::RectF& rect) + { + return rect.Width > 0.0f && rect.Height > 0.0f; + } + + bool UiRectIntersect(const gd::RectF& a, const gd::RectF& b, gd::RectF& out) + { + if (!UiRectHasArea(a) || !UiRectHasArea(b)) + { + out = gd::RectF(); + return false; + } + const float left = std::max(a.X, b.X); + const float top = std::max(a.Y, b.Y); + const float right = std::min(a.X + a.Width, b.X + b.Width); + const float bottom = std::min(a.Y + a.Height, b.Y + b.Height); + if (right <= left || bottom <= top) + { + out = gd::RectF(); + return false; + } + out = gd::RectF(left, top, right - left, bottom - top); + return true; + } + + gd::RectF UiRectUnion(const gd::RectF& a, const gd::RectF& b) + { + if (!UiRectHasArea(a)) + { + return b; + } + if (!UiRectHasArea(b)) + { + return a; + } + const float left = std::min(a.X, b.X); + const float top = std::min(a.Y, b.Y); + const float right = std::max(a.X + a.Width, b.X + b.Width); + const float bottom = std::max(a.Y + a.Height, b.Y + b.Height); + return gd::RectF(left, top, right - left, bottom - top); + } + + gd::RectF UiRectDeflate(const gd::RectF& rect, const UiInsets& insets) + { + const float width = rect.Width - insets.Horizontal(); + const float height = rect.Height - insets.Vertical(); + if (width <= 0.0f || height <= 0.0f) + { + return gd::RectF(); + } + return gd::RectF(rect.X + insets.left, rect.Y + insets.top, width, height); + } + + gd::RectF UiRectSnapInward(const gd::RectF& rect, float scale) + { + const float safeScale = (scale > 0.0f) ? scale : 1.0f; + const float x0 = std::ceil(rect.X * safeScale - 0.001f) / safeScale; + const float y0 = std::ceil(rect.Y * safeScale - 0.001f) / safeScale; + const float x1 = std::floor((rect.X + rect.Width) * safeScale + 0.001f) / safeScale; + const float y1 = std::floor((rect.Y + rect.Height) * safeScale + 0.001f) / safeScale; + if (x1 <= x0 || y1 <= y0) + { + return gd::RectF(); + } + return gd::RectF(x0, y0, x1 - x0, y1 - y0); + } + + gd::RectF UiAlignInRect(const gd::RectF& outer, float width, float height, UiAlign horizontal, + UiAlign vertical, float offsetX, float offsetY) + { + float boxWidth = width; + float boxHeight = height; + if (horizontal == UiAlign::STRETCH) + { + boxWidth = outer.Width; + } + if (vertical == UiAlign::STRETCH) + { + boxHeight = outer.Height; + } + boxWidth = std::min(boxWidth, outer.Width); + boxHeight = std::min(boxHeight, outer.Height); + + float x = outer.X; + switch (horizontal) + { + case UiAlign::CENTER: + x = outer.X + (outer.Width - boxWidth) * 0.5f; + break; + case UiAlign::END: + x = outer.X + outer.Width - boxWidth; + break; + default: + break; + } + float y = outer.Y; + switch (vertical) + { + case UiAlign::CENTER: + y = outer.Y + (outer.Height - boxHeight) * 0.5f; + break; + case UiAlign::END: + y = outer.Y + outer.Height - boxHeight; + break; + default: + break; + } + return gd::RectF(x + offsetX, y + offsetY, boxWidth, boxHeight); + } + + float UiStackExtent(const std::vector& sizes, float gap) + { + float total = 0.0f; + std::size_t fixedCount = 0; + for (float size : sizes) + { + if (size >= 0.0f) + { + total += size; + ++fixedCount; + } + } + if (fixedCount > 0 && sizes.size() > 1) + { + total += gap * static_cast(sizes.size() - 1); + } + return total; + } + + void UiStackRects(const gd::RectF& area, UiAxis axis, const std::vector& sizes, float gap, + std::vector& out, float* usedExtent) + { + out.clear(); + if (sizes.empty()) + { + if (usedExtent != nullptr) + { + *usedExtent = 0.0f; + } + return; + } + const bool horizontal = (axis == UiAxis::HORIZONTAL); + const float totalExtent = horizontal ? area.Width : area.Height; + const float totalGap = (sizes.size() > 1) ? gap * static_cast(sizes.size() - 1) : 0.0f; + const float fixedExtent = UiStackExtent(sizes, gap) - totalGap; + + std::size_t autoCount = 0; + for (float size : sizes) + { + if (size < 0.0f) + { + ++autoCount; + } + } + // 剩余空间不足时按 0 处理(**不允许**出现负尺寸:负宽高的矩形会画出反向的线)。 + const float remaining = std::max(0.0f, totalExtent - fixedExtent - totalGap); + const float autoExtent = (autoCount > 0) ? (remaining / static_cast(autoCount)) : 0.0f; + + float cursor = horizontal ? area.X : area.Y; + out.reserve(sizes.size()); + for (std::size_t index = 0; index < sizes.size(); ++index) + { + const float extent = (sizes[index] < 0.0f) ? autoExtent : std::max(0.0f, sizes[index]); + if (horizontal) + { + out.push_back(gd::RectF(cursor, area.Y, extent, area.Height)); + } + else + { + out.push_back(gd::RectF(area.X, cursor, area.Width, extent)); + } + cursor += extent; + if (index + 1 < sizes.size()) + { + cursor += gap; + } + } + if (usedExtent != nullptr) + { + *usedExtent = cursor - (horizontal ? area.X : area.Y); + } + } + + float UiFlowRects(const gd::RectF& area, UiAxis axis, const std::vector& sizes, float gap, + std::vector& out, std::size_t& overflow) + { + out.clear(); + overflow = sizes.size(); + if (sizes.empty()) + { + return 0.0f; + } + const bool horizontal = (axis == UiAxis::HORIZONTAL); + const float mainExtent = horizontal ? area.Width : area.Height; + const float crossExtent = horizontal ? area.Height : area.Width; + + float main = 0.0f; // 当前行内已用主轴长度 + float cross = 0.0f; // 当前行起点(交叉轴) + float lineCross = 0.0f; // 当前行最大交叉尺寸 + out.reserve(sizes.size()); + + for (std::size_t index = 0; index < sizes.size(); ++index) + { + const float itemMain = horizontal ? sizes[index].Width : sizes[index].Height; + const float itemCross = horizontal ? sizes[index].Height : sizes[index].Width; + const float needed = (main > 0.0f) ? (main + gap + itemMain) : itemMain; + if (needed > mainExtent && main > 0.0f) + { + // 换行 + cross += lineCross + gap; + main = 0.0f; + lineCross = 0.0f; + } + if (itemMain > mainExtent || cross + itemCross > crossExtent) + { + overflow = index; + break; + } + const float offset = (main > 0.0f) ? (main + gap) : 0.0f; + const float x = horizontal ? (area.X + offset) : (area.X + cross); + const float y = horizontal ? (area.Y + cross) : (area.Y + offset); + out.push_back(gd::RectF(x, y, horizontal ? itemMain : itemCross, + horizontal ? itemCross : itemMain)); + main += offset + itemMain; + lineCross = std::max(lineCross, itemCross); + } + return cross + lineCross; + } + + gd::RectF UiAbsRect(const gd::RectF& outer, float dx, float dy, float width, float height) + { + return gd::RectF(outer.X + dx, outer.Y + dy, std::max(0.0f, width), std::max(0.0f, height)); + } + + std::uint64_t UiMixKey(std::uint64_t seed, std::uint64_t value) + { + // FNV-1a 64 位的一份变体:与项目里其它指纹(SceneContentHash 等)同一风格。 + seed ^= value + 0x9E3779B97F4A7C15ull + (seed << 6) + (seed >> 2); + return seed; + } + + std::uint64_t UiMixKeyFloat(std::uint64_t seed, float value) + { + // 量化到 1/1024 逻辑像素:亚像素抖动不应该让布局缓存反复失效。 + const long long quantized = static_cast(std::llround(value * 1024.0f)); + return UiMixKey(seed, static_cast(quantized)); + } + + UiLayoutCache::UiLayoutCache(std::size_t capacity) + : m_capacity(std::max(1, capacity)) + { + } + + const UiLayoutResult* UiLayoutCache::Find(std::uint64_t key) + { + for (UiLayoutResult& entry : m_entries) + { + if (entry.key == key) + { + entry.stamp = ++m_stamp; + return &entry; + } + } + return nullptr; + } + + const UiLayoutResult& UiLayoutCache::Store(UiLayoutResult result) + { + for (UiLayoutResult& entry : m_entries) + { + if (entry.key == result.key) + { + entry = std::move(result); + entry.stamp = ++m_stamp; + return entry; + } + } + result.stamp = ++m_stamp; + m_entries.push_back(std::move(result)); + Trim(); + // 淘汰只会从队首进行,而新条目在队尾 ⇒ 刚存入的条目一定仍然有效。 + return m_entries.back(); + } + + void UiLayoutCache::Invalidate(std::uint64_t key) + { + for (auto iterator = m_entries.begin(); iterator != m_entries.end(); ++iterator) + { + if (iterator->key == key) + { + m_entries.erase(iterator); + return; + } + } + } + + void UiLayoutCache::Clear() + { + m_entries.clear(); + } + + void UiLayoutCache::Trim() + { + while (m_entries.size() > m_capacity) + { + // 淘汰"最久未被命中"的条目(stamp 最小)。 + auto oldest = m_entries.begin(); + for (auto iterator = m_entries.begin(); iterator != m_entries.end(); ++iterator) + { + if (iterator->stamp < oldest->stamp) + { + oldest = iterator; + } + } + m_entries.erase(oldest); + } + } + +} // namespace jdi diff --git a/Components/Shared/Src/UiWidget.cpp b/Components/Shared/Src/UiWidget.cpp new file mode 100644 index 0000000..24942e4 --- /dev/null +++ b/Components/Shared/Src/UiWidget.cpp @@ -0,0 +1,614 @@ +#include "UiWidget.hpp" + +#include +#include + +namespace jdi +{ + + UiEventResult UiEventResult::Handled() + { + UiEventResult result; + result.consumed = true; + result.redraw = true; + return result; + } + + UiEventResult UiEventResult::Swallowed() + { + UiEventResult result; + result.consumed = true; + result.redraw = false; + return result; + } + + UiPaintContext UiPaintContext::WithClip(const gd::RectF& rect) const + { + UiPaintContext child = *this; + gd::RectF intersection; + if (UiRectIntersect(clip, rect, intersection)) + { + child.clip = intersection; + } + else + { + child.clip = gd::RectF(); + } + return child; + } + + UiWidget::UiWidget() = default; + + UiWidget::~UiWidget() + { + // 子节点析构前先把"指向自己的根级状态"清掉:否则根的 m_capture / m_modal / m_hover + // 会留下悬空指针(子节点被移除后又被派发事件 = 访问违例)。 + UiWidget* root = Root(); + if (root != nullptr && root != this) + { + if (root->m_capture == this) + { + root->m_capture = nullptr; + } + if (root->m_modal == this) + { + root->m_modal = nullptr; + } + if (root->m_hover == this) + { + root->m_hover = nullptr; + } + } + } + + // ------------------------------------------------------------------ + // 树结构 + // ------------------------------------------------------------------ + + UiWidget& UiWidget::AddChild(std::unique_ptr child) + { + if (child == nullptr) + { + return *this; + } + UiWidget* raw = child.get(); + raw->m_parent = this; + m_children.push_back(std::move(child)); + // 子节点的期望尺寸会影响自己 ⇒ 向上失效。 + InvalidateMeasure(); + return *this; + } + + void UiWidget::RemoveChild(UiWidget* child) + { + if (child == nullptr) + { + return; + } + for (auto iterator = m_children.begin(); iterator != m_children.end(); ++iterator) + { + if (iterator->get() == child) + { + (*iterator)->m_parent = nullptr; + m_children.erase(iterator); + InvalidateMeasure(); + return; + } + } + } + + UiWidget* UiWidget::Root() + { + UiWidget* node = this; + while (node->m_parent != nullptr) + { + node = node->m_parent; + } + return node; + } + + const UiWidget* UiWidget::Root() const + { + const UiWidget* node = this; + while (node->m_parent != nullptr) + { + node = node->m_parent; + } + return node; + } + + // ------------------------------------------------------------------ + // 布局 + // ------------------------------------------------------------------ + + void UiWidget::InvalidateMeasure() + { + if (!m_measureValid) + { + // 已经失效 ⇒ 不必继续上溯(祖先此前也一定被失效过)。 + return; + } + m_measureValid = false; + if (m_parent != nullptr) + { + m_parent->InvalidateMeasure(); + } + } + + gd::SizeF UiWidget::Measure(const UiMeasureContext& ctx) + { + const gd::SizeF size = m_measureValid ? m_desiredSize : OnMeasure(ctx); + m_desiredSize = size; + m_measureValid = true; + return m_desiredSize; + } + + void UiWidget::Arrange(const gd::RectF& bounds) + { + m_bounds = bounds; + OnArrange(); + } + + void UiWidget::MeasureChildren(const UiMeasureContext& ctx) + { + for (const std::unique_ptr& child : m_children) + { + if (child == nullptr || !child->m_visible) + { + continue; + } + child->Measure(ctx); + } + } + + gd::SizeF UiWidget::ChildDesiredSize(std::size_t index) const + { + if (index >= m_children.size() || m_children[index] == nullptr) + { + return gd::SizeF(0.0f, 0.0f); + } + return m_children[index]->m_desiredSize; + } + + // ------------------------------------------------------------------ + // 绘制 + // ------------------------------------------------------------------ + + bool UiWidget::DirtyIntersects(const gd::RectF& dirty) const + { + gd::RectF intersection; + return UiRectIntersect(dirty, m_bounds, intersection); + } + + void UiWidget::Paint(UiPaintContext& ctx) + { + if (!m_visible || ctx.g == nullptr || !UiRectHasArea(m_bounds)) + { + return; + } + + // (1) 与脏区求交:**脏区为空表示"未知 ⇒ 全画"**。 + gd::RectF effectiveDirty = ctx.dirty; + if (UiRectHasArea(effectiveDirty) && !DirtyIntersects(effectiveDirty)) + { + // 本节点及其整棵子树都不在脏区内 ⇒ 直接跳过(这是本框架唯一真正省时间的机制)。 + return; + } + + // (2) 裁剪 = 祖先裁剪切 ∩ 自身矩形;脏区非空时再与脏区求交(只更新需要更新的部分)。 + UiPaintContext local = ctx; + gd::RectF clip; + if (!UiRectIntersect(ctx.clip, m_bounds, clip)) + { + return; // 完全被祖先裁掉 + } + if (UiRectHasArea(effectiveDirty)) + { + gd::RectF clipWithDirty; + if (!UiRectIntersect(clip, effectiveDirty, clipWithDirty)) + { + return; + } + clip = clipWithDirty; + } + local.clip = clip; + + // (3) 收紧 GDI+ 裁剪。⚠️ 这里**只**约束 GDI+ 矢量绘制;GDI 绘制(`UiText` / + // `UiFillRect` 的快路径 / `UiDrawSolidLines`)**不受**该裁剪约束 ⇒ 派生类必须把 + // `local.clip` 传给 `UiTextClipped` 一类带 `ETO_CLIPPED` 的辅助函数, + // 否则文字会画到本节点之外(本项目"滚动时文字串到标题栏/工具栏"就是这类 BUG)。 + const int saveState = ctx.g->Save(); + ctx.g->SetClip(local.clip, gd::CombineModeIntersect); + + local.dirty = UiRectHasArea(effectiveDirty) ? clip : gd::RectF(); + OnPaint(local); + + // (4) 子节点:顺序 = z 序(末尾最上层),脏区向下取交集。 + for (const std::unique_ptr& child : m_children) + { + if (child == nullptr) + { + continue; + } + UiPaintContext childContext = local.WithClip(child->m_bounds); + childContext.dirty = local.dirty; + child->Paint(childContext); + } + + ctx.g->Restore(saveState); + } + + void UiWidget::Invalidate(const gd::RectF& rect) + { + if (!UiRectHasArea(rect)) + { + return; + } + m_dirty = UiRectUnion(m_dirty, rect); + if (m_parent != nullptr) + { + // 向上冒泡的是"客户区坐标"的同一块矩形;父层只并集,**不清空**子节点的脏区。 + m_parent->Invalidate(rect); + } + } + + void UiWidget::InvalidateAll() + { + m_dirty = m_bounds; + if (m_parent != nullptr) + { + m_parent->Invalidate(m_bounds); + } + for (const std::unique_ptr& child : m_children) + { + if (child != nullptr) + { + child->InvalidateAll(); + } + } + } + + gd::RectF UiWidget::TakeDirty() + { + const gd::RectF dirty = m_dirty; + m_dirty = gd::RectF(); + return dirty; + } + + // ------------------------------------------------------------------ + // 事件 + // ------------------------------------------------------------------ + + bool UiWidget::Contains(int x, int y) const + { + if (!m_visible || m_hitTransparent || !UiRectHasArea(m_bounds)) + { + return false; + } + const float fx = static_cast(x); + const float fy = static_cast(y); + return fx >= m_bounds.X && fx < m_bounds.X + m_bounds.Width && fy >= m_bounds.Y && + fy < m_bounds.Y + m_bounds.Height; + } + + UiWidget* UiWidget::HitTestDeep(int x, int y) + { + if (!Contains(x, y)) + { + return nullptr; + } + // 反 z 序:末尾(最上层)优先。 + for (auto iterator = m_children.rbegin(); iterator != m_children.rend(); ++iterator) + { + if (*iterator == nullptr) + { + continue; + } + if (UiWidget* hit = (*iterator)->HitTestDeep(x, y)) + { + return hit; + } + } + return this; + } + + UiEventResult UiWidget::Dispatch(UiEvent& event) + { + UiWidget* root = Root(); + if (root == nullptr) + { + return UiEventResult(); + } + + const bool mouseEvent = (event.kind == UiEventKind::MOUSE_MOVE || + event.kind == UiEventKind::MOUSE_DOWN || + event.kind == UiEventKind::MOUSE_UP || + event.kind == UiEventKind::MOUSE_DOUBLE_CLICK || + event.kind == UiEventKind::MOUSE_WHEEL || + event.kind == UiEventKind::QUERY_CURSOR); + + // (1) 捕获优先:拖拽期间所有鼠标事件直接投给捕获者(与 Windows `SetCapture` 同义)。 + if (mouseEvent && root->m_capture != nullptr) + { + return root->m_capture->OnEvent(event); + } + + // (2) 模态:只向模态子树派发(菜单/拾色器等浮层)。 + if (root->m_modal != nullptr) + { + return root->m_modal->DispatchAlongHitPath(event); + } + + // (3) 悬停链(只在根节点维护:鼠标移动会改变悬停者)。 + if (event.kind == UiEventKind::MOUSE_MOVE || event.kind == UiEventKind::MOUSE_LEAVE) + { + root->UpdateHover(event.x, event.y); + } + + // (4) 键盘/字符:沿焦点链派发(无焦点时交给根,保证宿主级快捷键仍可用)。 + if (event.kind == UiEventKind::KEY_DOWN || event.kind == UiEventKind::CHAR) + { + UiWidget* node = (root->m_focus != nullptr) ? root->m_focus : root; + for (; node != nullptr; node = node->m_parent) + { + UiEventResult result = node->OnEvent(event); + if (result.consumed) + { + return result; + } + } + return UiEventResult(); + } + + return DispatchAlongHitPath(event); + } + + void UiWidget::CollectHitChain(int x, int y, std::vector& chain) + { + if (!Contains(x, y)) + { + return; + } + chain.push_back(this); + // 反 z 序:末尾(最上层)优先;只进入**真正命中**的那一个子节点。 + for (auto iterator = m_children.rbegin(); iterator != m_children.rend(); ++iterator) + { + if (*iterator == nullptr) + { + continue; + } + if ((*iterator)->Contains(x, y)) + { + (*iterator)->CollectHitChain(x, y, chain); + return; + } + } + } + + UiEventResult UiWidget::DispatchAlongHitPath(UiEvent& event) + { + if (!m_visible) + { + return UiEventResult(); + } + + const bool positional = (event.kind == UiEventKind::MOUSE_MOVE || + event.kind == UiEventKind::MOUSE_DOWN || + event.kind == UiEventKind::MOUSE_UP || + event.kind == UiEventKind::MOUSE_DOUBLE_CLICK || + event.kind == UiEventKind::MOUSE_WHEEL || + event.kind == UiEventKind::QUERY_CURSOR); + + if (!positional) + { + // 非位置事件(理论上已被 `Dispatch` 分流):自身优先,再逐个子节点。 + UiEventResult result = OnEvent(event); + if (result.consumed) + { + return result; + } + for (const std::unique_ptr& child : m_children) + { + if (child == nullptr || !child->m_visible) + { + continue; + } + result = child->DispatchAlongHitPath(event); + if (result.consumed) + { + return result; + } + } + return UiEventResult(); + } + + std::vector chain; + CollectHitChain(event.x, event.y, chain); + if (chain.empty()) + { + return UiEventResult(); + } + + // 自最深的命中节点向上冒泡:命中者优先,未消费才交给祖先(合法的"兜底"语义)。 + for (UiWidget* node : chain) + { + if (event.kind == UiEventKind::QUERY_CURSOR) + { + if (HCURSOR cursor = node->OnQueryCursor(event.x, event.y)) + { + event.cursor = cursor; + return UiEventResult::Swallowed(); + } + continue; + } + UiEventResult result = node->OnEvent(event); + if (result.consumed) + { + return result; + } + } + if (event.kind == UiEventKind::QUERY_CURSOR && event.cursor != nullptr) + { + return UiEventResult::Swallowed(); + } + return UiEventResult(); + } + + void UiWidget::UpdateHover(int x, int y) + { + UiWidget* target = (m_visible) ? HitTestDeep(x, y) : nullptr; + if (target == m_hover) + { + return; + } + UiWidget* previous = m_hover; + m_hover = target; + if (previous != nullptr) + { + previous->OnHoverChanged(false); + previous->Invalidate(previous->m_bounds); + } + if (target != nullptr) + { + target->OnHoverChanged(true); + target->Invalidate(target->m_bounds); + } + } + + // ------------------------------------------------------------------ + // 交互仲裁 + // ------------------------------------------------------------------ + + void UiWidget::CaptureMouse() + { + UiWidget* root = Root(); + if (root != nullptr) + { + root->m_capture = this; + } + } + + void UiWidget::ReleaseMouse() + { + UiWidget* root = Root(); + if (root != nullptr && root->m_capture == this) + { + root->m_capture = nullptr; + } + } + + UiWidget* UiWidget::MouseCaptureWidget() const + { + const UiWidget* root = Root(); + return (root != nullptr) ? root->m_capture : nullptr; + } + + void UiWidget::SetModal(bool modal) + { + UiWidget* root = Root(); + if (root == nullptr) + { + return; + } + if (modal) + { + root->m_modal = this; + } + else if (root->m_modal == this) + { + root->m_modal = nullptr; + } + } + + UiWidget* UiWidget::ModalWidget() const + { + const UiWidget* root = Root(); + return (root != nullptr) ? root->m_modal : nullptr; + } + + UiWidget* UiWidget::HoverWidget() const + { + const UiWidget* root = Root(); + return (root != nullptr) ? root->m_hover : nullptr; + } + + void UiWidget::RequestFocus() + { + UiWidget* root = Root(); + if (root == nullptr || root->m_focus == this) + { + return; + } + if (root->m_focus != nullptr) + { + root->m_focus->Invalidate(root->m_focus->m_bounds); + } + root->m_focus = this; + Invalidate(m_bounds); + } + + void UiWidget::ReleaseFocus() + { + UiWidget* root = Root(); + if (root != nullptr && root->m_focus == this) + { + root->m_focus = nullptr; + Invalidate(m_bounds); + } + } + + UiWidget* UiWidget::FocusWidget() const + { + const UiWidget* root = Root(); + return (root != nullptr) ? root->m_focus : nullptr; + } + + // ------------------------------------------------------------------ + // 动画 + // ------------------------------------------------------------------ + + bool UiWidget::AdvanceAnimation(std::int64_t nowMs) + { + bool active = AdvanceAnimationSelf(nowMs); + for (const std::unique_ptr& child : m_children) + { + if (child != nullptr && child->AdvanceAnimation(nowMs)) + { + active = true; + } + } + return active; + } + + bool UiWidget::AnimationActive() const + { + if (AnimationActiveSelf()) + { + return true; + } + for (const std::unique_ptr& child : m_children) + { + if (child != nullptr && child->AnimationActive()) + { + return true; + } + } + return false; + } + + // ------------------------------------------------------------------ + // 缓存键 + // ------------------------------------------------------------------ + + std::uint64_t UiWidget::BaseContentKey() const + { + std::uint64_t key = 0xCBF29CE484222325ull; + key = UiMixKeyFloat(key, m_bounds.X); + key = UiMixKeyFloat(key, m_bounds.Y); + key = UiMixKeyFloat(key, m_bounds.Width); + key = UiMixKeyFloat(key, m_bounds.Height); + key = UiMixKey(key, m_visible ? 1u : 0u); + return key; + } + +} // namespace jdi diff --git a/Documents/UI_Refactor_Plan.md b/Documents/UI_Refactor_Plan.md new file mode 100644 index 0000000..7fc5afd --- /dev/null +++ b/Documents/UI_Refactor_Plan.md @@ -0,0 +1,304 @@ +# UI 重构计划(UI_Refactor 分支) + +> 目标读者:本项目的后续 Agent(可能是另一台设备 / 另一个模型)。 +> 本文是**唯一施工蓝图**;实时进度见 `Documents/UI_Refactor_Progress.md`,最终结论见 `Documents/UI_Refactor_Report.md`。 +> 硬约束的权威来源仍是 `Documents/agent_mem.md`(§11.2);本文与之冲突时**以 agent_mem.md 为准**。 + +--- + +## 0. 为什么要重构(量化基线) + +| 指标 | 现状 | 出处 | +| --- | --- | --- | +| `PlotViewPaint.cpp` | **9 080 行**(注释 1 295 / 空行 517 / 仅大括号 2 026 / **有效代码 5 242**) | `Get-Content \| Measure-Object` | +| `PlotView.hpp` | 3 649 行、**310 个方法**、**203 个成员变量**、31 个嵌套类型 | 同上 | +| `EdgePanelLayoutFor` | **651 行**、**19 个调用点**,每帧被命中/层序/光标/绘制各算一遍 | `Select-String` | +| `Application.cpp` 的 `InvalidateRect` | **56 处** + 手写 `m_chromeDirty` / `m_panelDirty` / `panelOnly` / `fullFrame` 组合 | 同上 | +| 动画 | **5 套**手写动画散在 3 个组件,共用一个 10ms 定时器 | 同上 | +| 自动化 UI 探针 | **0**(`.preflight` 已被删除且被 gitignore) | 仓库现状 | + +`PlotViewPaint.cpp` 里混住四类内容:真绘制 ≈3.5k / 光栅化缓存 ≈2.5k / 缓存键 ≈1.1k / **布局与命中几何 ≈1.3k**。 + +**五条根因** + +1. 上次拆 13 695 行大文件时按**函数名**("带 `Paint` 前缀")而非**关注点**分片 ⇒ 缓存、缓存键、面板布局、命中几何全被倒进 paint 分片。 +2. 没有控件抽象 ⇒ 每个叠加层自带"几何 + 命中 + 绘制 + 缓存键"四份重复;5 个实验性面板各 800~1 200 行,靠 10 多个平行数组(`rowRefs/rowChecks/rowPoints/rowOrder/rowLevelTracks/...`)下标对齐"假装"成树。 +3. GDI / GDI+ 双栈妥协被**每个绘制点重复支付**(GDI 文本不认 GDI+ 裁剪、`UiFillRect` 的 GDI 快路径绕过裁剪、GDI 文本忽略 alpha…)。 +4. "必须混进缓存键,否则贴旧像素"这条不变量**没有归属**(4 套手写 key + 一堆 `Collect*` ≈1.1k 行;历史 ≥4 次真实 BUG 是"忘了混一个字段")。 +5. 1 295 行硬核"为什么"注释让行数虚高 —— **保留,不删**(多 Agent 协作防回归的核心资产)。 + +--- + +## 1. 设计目标与非目标 + +### 1.1 目标(必须达成) + +1. **几何只算一次**:布局是"按需失效的 pass",命中/绘制/光标共用同一份结果。 +2. **失效从布尔变矩形**:`dirty ∩ bounds` 为空 ⇒ 整棵子树跳过(这是唯一真正省钱的层次)。 +3. **裁剪契约统一**:`Paint` 进入时同时收紧 GDI 裁剪与 GDI+ 裁剪,根治"文字画到图例/工具栏上"这类已出现 3 次的 BUG。 +4. **缓存键有归属**:由"持有该内容的节点"产生,父层只聚合。 +5. **事件派发显式化**:`{consumed, redraw, dirtyRect}` 代替 `bool 要不要重绘`;capture / 模态 / hover / 焦点 / 光标仲裁由基类提供。 +6. **动画统一 tick**:一个动画列表 + 现有 `kScrollTimerId` 推进。 +7. **零行为变更的先行阶段**:先拆文件、补回归网,再动语义。 + +### 1.2 非目标(明确不做,写进禁区) + +| 禁区 | 原因 | +| --- | --- | +| ❌ 波形数据→像素管线控件化(网格/曲线/采样点不做成节点) | 布局与虚调用会进每帧热路径;采样/顶点量与控件无关 | +| ❌ 每个控件自持位图 | 内存按面积线性增长:逐网格图层已是屏幕面积 2 倍(5 网格 ≈16MB,**128 网格 ≈250MB**) | +| ❌ "父脏 ⇒ 子树全脏" | 会把"只重绘一个网格/一个面板"打回整块重绘(历史最易发生的退化) | +| ❌ 多线程光栅 | GDI+ 内部串行,实测 **1.03x**;且后台改写 `m_area`/`ViewMin` 会覆盖用户平移(第一代已因此回退) | +| ❌ 逐控件细粒度像素缓存 | 画一个按钮 20~80µs vs 贴图 5~10µs ⇒ 净亏损,还要付"忘混键"的风险 | +| ❌ 引入属性对象体系(学 Blink) | 那是为 Blink 规模准备的;本项目用"节点产生 key"即可 | + +### 1.3 借鉴来源与取舍 + +| 借鉴 | 采纳 | 说明 | +| --- | --- | --- | +| Chromium `views::View` 的**失效驱动布局 pass** | ✅ 全采纳 | 对应 §3 的 `UiWidget::Measure/Arrange` + `UiLayoutCache` | +| `views::View::PaintChildren` 的**脏矩形下探** | ✅ 全采纳 | 对应 §4 的 `PaintContext.dirty` | +| `SetPaintToLayer()` 只提升**少数**子树 | ✅ 保持现状量级(4 层) | 不再新增层 | +| `cc` 的**记录-光栅分离 / tiling / checkerboarding / 每帧预算** | ✅ 已具备,不改 | `PolylineCacheEntry`、宽基底+补条、贴过期但位置正确、`RenderPendingLaneRebuilds(1)` | +| `cc` 的**多线程光栅 / GPU 合成** | ❌ 不采纳 | GDI+ 无 GPU 路径;并行实测 1.03x | +| Dear ImGui 的**每帧全量重画** | ❌ 不采纳 | 本项目整幅重绘 ≈80ms,必须走失效 | + +--- + +## 2. 目标架构 + +```mermaid +flowchart TD + H[UiHost
持有 app-sized 32bpp top-down DIB
z 序 · 脏区并集 · 一次 BitBlt 回送 · 动画 tick] + H --> R[UiRoot : UiWidget] + R --> D{脏区树
dirty ∩ bounds 空 ⇒ 整棵子树 return} + D --> A[普通控件:直接画进共享缓冲
按钮 / 文本 / 列表行 / 时间轴 / 图例] + D --> B[高成本叶子:自持位图,完成即 BitBlt 贴回
波形网格 / 装饰层 / 光晕 / 浮动面板] + A --> S[SurfaceView{ dc, rect, stride, bits, version }] + B --> S +``` + +**分层原则** + +- **共享一张缓冲**:省内存、免分配抖动、合成顺序天然确定。 +- **只有高成本叶子自持缓冲**:这是"把重画换成贴图"的唯一手段。 +- **脏区向下取交集**(不是并集)。 +- **布局由下往上、每帧最多一轮 Measure**;尺寸由内容反推(`EdgePanelWidth` 就是这种)。 + +### 2.1 新增文件 + +| 文件 | 职责 | 依赖 | +| --- | --- | --- | +| `Components/Shared/Inc/UiLayout.hpp` / `Src/UiLayout.cpp` | 纯几何:`UiInsets`、栈/流/锚点/对齐、`UiLayoutCache` | 仅 `Theme.hpp`(`gd::RectF`) | +| `Components/Shared/Inc/UiWidget.hpp` / `Src/UiWidget.cpp` | 控件基类:树、Measure/Arrange、双栈裁剪 Paint、事件派发、失效、capture/模态/hover/焦点/光标、动画 tick | `UiLayout.hpp`、`PaintUtil.hpp` | +| `Components/Shared/Inc/UiHost.hpp` / `Src/UiHost.cpp` | 宿主:共享 DIB + `SurfaceView` + 脏区并集 + 回送 + 消息翻译 + 动画定时器 | `UiWidget.hpp` | +| `Tools/uicheck/`(入库) | 离屏回归网(`SW_HIDE` + 注入消息 + 从 `m_backDc` 取帧) | `Build/Release/*.o` | + +> ⚠️ `Components/Shared` 已在 `Makefile` 的 `CXX_INCLUDES_PATHS` / `CXX_SOURCES_PATHS` 中 ⇒ 新增 `.cpp` **无需改 Makefile**(按 basename 自动收集)。 + +--- + +## 3. 布局层设计(`UiLayout`) + +### 3.1 为什么不是简单的"宽高字段" + +基类若只有 `width/height`,会在三处立刻失效: + +1. 所有坐标是**逻辑像素 × scale**(本机 DPR 1.25),控件必须知道缩放; +2. 面板宽度是**由内容反推**(`EdgePanelWidth` = 列宽 max + 行 ✕ + 滚动条预留); +3. 离屏位图要求**设备像素整数对齐**(`floor`/`ceil` 方向错了就留 1px 接缝 —— 样式补丁曾踩过)。 + +⇒ 采用 **`Measure(约束) → 期望尺寸` / `Arrange(最终矩形) → 实际矩形`** 两段式。 + +### 3.2 需要的布局种类(按实际用量排序) + +| 布局 | 用途 | 备注 | +| --- | --- | --- | +| **Abs(绝对)** | 绘图区叠加物主力:光标线、取值胶囊、吸附环、越界三角、固定点、面板初始位置、滚动条 | 保留"自由布局",但它是**主力**而非备选 | +| **Anchor(四边贴合)** | 工具栏整体靠右、时间轴贴底、滚动条贴右、面板夹在 `m_plotRect` 内 | 讨论中缺失,必须补 | +| **Stack(竖向流)** | 信号列表行、分组行、面板条目(名称行 + 参数行 + 滑块行) | 需支持"行高由内容决定 + 吸顶(sticky) + 滚动视口" | +| **Flow(横向流 + 溢出)** | 图例条目 + 空间不足的 `+N`、工具栏按钮组 | 讨论中缺失,必须补 | +| **Measure-driven** | 面板宽度按实测文本撑开、列宽 `max(缺省, 内容, 列标题)` | 必须有,否则又回到"绘制侧省略号" | +| Grid | **用不上**(绘图区不是表格) | 不实现 | +| Card | 只是"面板皮肤" | 作为 `UiPanelSkin` 绘制样式,不是布局 | + +### 3.3 `UiLayoutCache`(本次最关键的性能点) + +`EdgePanelLayoutFor` 的 19 个调用点全部来自"同一份几何被反复问"。做法: + +```cpp +class UiLayoutCache +{ +public: + // key 由调用方提供(例如 laneIndex + kind + 面板宽 + 缩放 + 条目数) + const LayoutEntry* Find(std::uint64_t key) const; + const LayoutEntry& Store(std::uint64_t key, LayoutEntry&& entry); + void Invalidate(std::uint64_t key); // 定向失效 + void Clear(); +}; +``` + +**硬约束**:`Invalidate` 必须由"改变几何的那条路径"**显式**调用(与 `m_edgeScans` 的定向失效同一纪律),**禁止**为了让缓存命中率好看而整体 `Clear()`。 + +--- + +## 4. 控件基类设计(`UiWidget`) + +### 4.1 接口骨架 + +```cpp +struct UiEventResult +{ + bool consumed = false; // 事件已被消费(不再下传/上抛) + bool redraw = false; // 需要重绘 + gd::RectF dirty; // 需要重绘的矩形(客户区坐标;空 = 未知 ⇒ 取自身 bounds) +}; + +class UiWidget +{ +public: + virtual ~UiWidget(); + + // ---- 树 ---- + void AddChild(std::unique_ptr child); + void RemoveChild(UiWidget* child); + const std::vector>& Children() const; + + // ---- 布局(两段式,各自带失效)---- + void InvalidateMeasure(); // 向上冒泡到根 + void InvalidateArrange(); + gd::SizeF Measure(const UiMeasureContext& ctx); // 自下而上,命中缓存则直接返回 + void Arrange(const gd::RectF& bounds); // 自上而下;bounds 一定 > 0 + + // ---- 绘制(双栈裁剪 + 脏区下探)---- + void Paint(UiPaintContext& ctx); // 内部:与 dirty 求交 → OnPaint → 递归子节点 + virtual void Invalidate(const gd::RectF& rect); // 局部失效(向上冒泡并入) + + // ---- 事件 ---- + UiEventResult Dispatch(UiEvent& event); // 命中 → 子节点(反 z 序)→ OnEvent + + // ---- 命中 / 光标 ---- + bool Contains(int x, int y) const; + virtual HCURSOR QueryCursor(int x, int y) { return nullptr; } + + // ---- 交互仲裁(由 Host 调用)---- + UiWidget* Capture(); void SetCapture(); void ReleaseCapture(); + void SetModal(bool modal); // 模态子树:Host 只向它派发 + void SetHitTransparent(bool value); // 装饰元素:不参与命中 + + // ---- 动画 ---- + virtual bool AdvanceAnimation(std::int64_t nowMs) { return false; } + +protected: + virtual gd::SizeF OnMeasure(const UiMeasureContext& ctx) = 0; + virtual void OnArrange() {} + virtual void OnPaint(UiPaintContext& ctx) = 0; + virtual UiEventResult OnEvent(UiEvent& event) { return {}; } + virtual std::uint64_t ContentKey() const; // 缓存键的来源(父层只聚合) +}; +``` + +### 4.2 `Paint` 的硬契约(必须由基类保证,不是"每个控件记得") + +1. **双栈裁剪**:进入时 `SaveDC + IntersectClipRect`(GDI)+ `Graphics::SetClip(Intersect)`(GDI+),退出时还原。这一条一次性消灭"滚动时文字串到标题栏""全选高亮溢出输入框""选区画到工具栏上"。 +2. **脏区下探**:`ctx.dirty = ctx.dirty ∩ bounds`;若为空 ⇒ **整棵子树直接 return**(不调 `OnPaint`、不遍历子节点)。 +3. **绘制顺序 = 子节点顺序 = z 序**;命中/事件 = **反 z 序**(与现在 `m_panelOrder` + `TopPanelAtPoint` 反向遍历等价,但不再靠人工维护)。 +4. **禁止**在 `OnPaint` 内改 `bounds`/子节点集合(会与本帧的 dirty 不一致)。 + +### 4.3 事件派发的硬契约 + +- **`consumed`** 决定是否上抛;**`dirty`** 参与本帧脏区并集。 +- **capture**:抓取期间所有鼠标事件**直接投给抓取者**(跳过命中),与 Windows `SetCapture` 语义一致。本项目硬约束:中键平移与左键拖拽**共用同一个 capture**,松左键**不能**无条件 `ReleaseCapture()`。 +- **模态**:菜单打开时 Host 只向模态子树派发,且"取消底层拖拽"时必须**排除菜单自己的 `EDIT_FIELD`**。 +- **hover**:Host 维护 hover 链,跨节点 `leave/enter`;悬停是**跨组件**的(面板行 → 网格固定点高亮;列表行 → 曲线光晕)⇒ 通过 Host 级"悬停信号总线"广播,不靠父子关系。 +- **双击消歧**:窗口带 `CS_DBLCLKS`;没有双击语义的位置必须在 `DoubleClickIsPlainPress()` 返回 true,由 Host 改走普通按下(否则"连点丢一半")。 + +### 4.4 失效契约 + +- 节点持 `m_dirty`(自身坐标系)+ `m_dirtyChildren`; +- `Invalidate(rect)` 并入自身并**向上冒泡**(父层只并集矩形,**绝不**把子树的 dirty 清零); +- Root 暴露 `TakeDirty()` 给 Host,Host 再与窗口更新区域取并集。 + +--- + +## 5. 宿主层设计(`UiHost`) + +1. **单一 app-sized 32bpp top-down DIB**(替换现在的 `CreateCompatibleBitmap`): + ```cpp + struct SurfaceView + { + HDC dc; // 共享 HDC(GDI/GDI+ 都画这个) + gd::RectF rect; // 本节点在 Holder 坐标系中的矩形(设备像素,整数对齐) + int stride; // 行字节数(**不可**假设 = width*4) + std::uint32_t* bits; // rect 左上角那一像素(不是位图首地址) + std::uint64_t version; // 位图代次;失配 ⇒ 句柄作废 + }; + ``` + ⚠️ **绝不能缓存裸指针**:`EnsureBackBuffer` 在 `WM_SIZE` / `WM_DPICHANGED` 会重建位图。 + ⚠️ `bits` 只在 **CPU 侧直写**(memcpy/alpha)时用;GDI/GDI+ 绘制只需 `dc + rect`。 +2. **脏区并集**:把现在 56 处 `InvalidateRect` 收敛为 `Host::Invalidate(rect)`,一次回送。 + ⚠️ 回送仍必须**有无条件 `else`**(历史:某分支缺兜底 ⇒ 窗口一个像素都不回送,用户看到三个不同症状)。 +3. **动画 tick**:`AdvanceAnimation` 遍历树,任一为 true 就保持 `kScrollTimerId`(10ms)。 +4. **消息翻译**:`WM_*` → `UiEvent`,并把 `WM_SETCURSOR` 交给 `QueryCursor` 链(从 z 顶向下、首个非 null 生效)。 + +--- + +## 6. 落地阶段(每阶段一个可独立回滚的提交) + +| 阶段 | 内容 | 交付判据 | +| --- | --- | --- | +| **P0** | 离屏回归网 `Tools/uicheck`(入库)+ 现有 20+ 条硬约束的断言骨架 | 能一键跑;对现版本全绿 | +| **P1** | `UiLayout` + `UiWidget` 基础层(本次) | 编译零警告;`uicheck` 覆盖布局数学 + 派发/裁剪/脏区/动画 | +| **P2** | 零行为变更分片:`PlotViewPaint.cpp` 拆成 `Paint`(只画)/ `Raster`(缓存与预算)/ `CacheKey`(指纹)/ `Geometry`(面板与命中);`EdgePanelLayoutFor`、`ProbeFreeMarker` 迁回交互侧 | **逐像素 0 差异**(用 P0 的场景哈希断言) | +| **P3** | 迁移 chrome:Toolbar → TabBar → PopupMenu → ColorPicker → SignalPanel 行(**一次一个组件、每完成一个跑全量探针**) | 每个组件迁移后 `uicheck` 全绿 + 手工契约断言 | +| **P4** | 迁移 5 个浮动面板(`EdgePanelLayoutFor` 651 行 → 真控件;消掉平行数组) | 同上;`EdgePanelLayoutFor` 行数下降 >60% | +| **P5** | 波形管线包成叶子 surface(**只包不改**)+ 共享 DIB 落地 | `uicheck` 全绿;`Target.exe` ≤ 900KB | + +**P2 之前禁止改任何行为**。P2 是唯一的"免费"阶段,别顺手改语义。 + +--- + +## 7. 验证策略(不占用用户鼠标/键盘/屏幕) + +1. **离屏 harness**:`SW_HIDE` 窗口 + 直接 `app.HandleMessage(...)` 注入 + 从 `m_backDc` 用 `GetDIBits` 取帧。 +2. **进程/线程优先级 `BELOW_NORMAL`**;从不用 `SetCursorPos` / `SendInput` / 截屏。 +3. **A/B 反证**:每条新断言都要能被"还原旧实现"证伪,否则不算通过。 +4. **像素判据要选对区域**:验证"某交互不应改变外观"时,只对**不受影响**的区域取哈希。 +5. **已知陷阱**(全部来自历史 BUG): + - 探针消息泵必须丢弃 `WM_MOUSELEAVE`(离屏窗口会立刻兑现它); + - 注入按下后立即 `ReleaseCapture()`(避免抢真实鼠标); + - `CycleCursorMode()` 是**循环**,多组用例里重复调用会把光标关掉; + - `PlotView` 的嵌套类型(`LaneVisual`/`LegendHit`/`EdgePanelLayout`)使用时要写 `PlotView::` 前缀。 + +--- + +## 8. 协作纪律(多 Agent) + +1. **文件所有权**:同一时刻**一个 Agent 只改一个文件的同一区域**;跨阶段并行时按下表分工,禁止交叉。 +2. **每阶段一个提交**,提交信息写清"改了什么 / 为什么 / 怎么验证"。 +3. **先写断言再改代码**。 +4. `main` / `dev` 只接受全绿;`UI_Refactor` 内允许临时红,但**不许跨轮次累积红**。 +5. 任何"规则变更"必须同步进 `Documents/agent_mem.md`(§11.2 追加编号),否则另一端看不到。 +6. 体积红线 **900KB**(到达即提醒用户,不是禁止超过)。 + +### 8.1 并行分工建议(若启用多个 Agent) + +| Agent | 独占文件 | 禁止触碰 | +| --- | --- | --- | +| A(基础层) | `UiLayout.*`、`UiWidget.*`、`UiHost.*`、`Tools/uicheck/*` | 任何现有组件 | +| B(分片) | `PlotViewPaint.cpp` → 新分片文件、`PlotViewInternal.hpp` 的 include 段 | `UiWidget.*`、面板逻辑 | +| C(面板迁移) | `PlotViewFeatures.cpp` 的面板段、新建 `PlotViewPanels.cpp` | `PlotViewPaint.cpp`、`UiLayout.*` | + +> 冲突高发点:`Makefile`(新增目录时才需要改)、`agent_mem.md`(追加编号必须串行)。 + +--- + +## 9. 风险与回滚 + +| 风险 | 触发条件 | 回滚 | +| --- | --- | --- | +| 迁移后"连点丢一半" | `DoubleClickIsPlainPress` 语义漏实现 | 单组件提交可独立 revert | +| 迁移后"拖动卡住" | capture 共用被拆成两套 | 同上 | +| 面板失效不全(贴旧像素) | `ContentKey()` 漏字段 | P4 里每个面板配一条"改 X 后像素必须变"的断言 | +| 体积超 900KB | 新框架代码量 | 每阶段结束量一次;超阈值先报告用户 | +| 布局级联死循环 | Measure 里改子节点 | 单元测试固定"Measure 一轮即收敛" | diff --git a/Documents/UI_Refactor_Progress.md b/Documents/UI_Refactor_Progress.md new file mode 100644 index 0000000..3ce20c8 --- /dev/null +++ b/Documents/UI_Refactor_Progress.md @@ -0,0 +1,86 @@ +# UI 重构进度(UI_Refactor 分支) + +> 本文件是**跨会话/跨 Agent 的交接台账**。每次提交前更新;每一行都要能对应到一个可复跑的验证结果。 +> 计划见 `Documents/UI_Refactor_Plan.md`;硬约束见 `Documents/agent_mem.md` §11.2。 + +## 分支状态 + +| 分支 | 基线 | 用途 | +| --- | --- | --- | +| `main` | `df715a0` | 可发布,**不受本次重构影响** | +| `dev` | `4e51b41`(删除过时报告 `refactor_report_2026-10-16.md`) | 日常开发基线 | +| `UI_Refactor` | 从 `dev` 切出 | 本次 UI 重构(全部提交只留在本地) | + +## 阶段看板 + +| 阶段 | 内容 | 状态 | +| --- | --- | --- | +| **P0** | 离屏回归网 `Tools/uicheck`(入库) | ✅ 完成(62 项断言全绿) | +| **P1** | `UiLayout` + `UiWidget` 基础层 | ✅ 完成(编译零警告,`uicheck` 覆盖) | +| **P2** | `PlotViewPaint.cpp` 零行为分片 | ⏳ 待做 | +| **P3** | 迁移 chrome(Toolbar / TabBar / 菜单 / 拾色器 / 列表行) | ⏳ 待做 | +| **P4** | 迁移 5 个浮动面板 | ⏳ 待做 | +| **P5** | 波形管线包成叶子 surface + 共享 DIB | ⏳ 待做 | + +--- + +## 提交记录 + +### 1. `P0 + P1`:UI 基础层与离屏回归网 + +**新增文件** + +| 文件 | 行数 | 职责 | +| --- | --- | --- | +| `Components/Shared/Inc/UiLayout.hpp` | ~250 | 布局原语 + `UiLayoutCache` | +| `Components/Shared/Src/UiLayout.cpp` | ~300 | 矩形原语 / 对齐 / Stack / Flow / 缓存 | +| `Components/Shared/Inc/UiWidget.hpp` | ~330 | 控件基类接口与设计说明 | +| `Components/Shared/Src/UiWidget.cpp` | ~560 | 树 / Measure·Arrange / Paint(脏区+裁剪)/ 派发 / 仲裁 / 动画 | +| `Tools/uicheck/main.cpp` | ~570 | 离屏回归网(62 项断言) | +| `Tools/uicheck/build.ps1` | ~70 | 链接 `Build//*.o`(排除 `Main.o`) | + +**已实现并验证的行为** + +- 布局:`UiAlignInRect`(含 `STRETCH`、超尺寸夹取、偏移后置)、`UiRectSnapInward`(设备像素内侧对齐)、 + `UiStackRects`(固定 / `kUiSizeAuto` 均分 / **绝不产生负尺寸**)、`UiFlowRects`(换行 + `overflow` 下标)。 +- 缓存:`UiLayoutCache` 用 `std::deque` 保证**引用稳定**;容量淘汰按"最久未命中";`Invalidate` 为**定向**失效。 +- 布局 pass:`Measure` 命中缓存不重复调用 `OnMeasure`;`InvalidateMeasure` **只**沿祖先链失效(兄弟不重算)。 +- 绘制:`dirty` 为空 = 全画;与子树矩形无交集 ⇒ **整棵子树跳过**;有效裁剪 = 祖先裁剪切 ∩ 自身矩形 ∩ 脏区; + 不可见节点跳过。 +- 派发:**只沿命中链**(最深的命中节点 → 祖先冒泡),**绝不分发给同层兄弟**;`capture` 期间事件直投捕获者; + `modal` 只向模态子树派发;`hit-transparent` 让事件穿透;`QUERY_CURSOR` 由最上层认领者返回; + 键盘/字符沿**焦点链**派发(无焦点交给根,保证宿主级快捷键可用)。 +- 动画:`AdvanceAnimation` 逐节点下发同一时间戳并汇总"是否仍有动画"。 +- 悬停:进入 / 离开各只触发一次,跨子节点迁移时正确配对。 + +**过程中修掉的两个自身缺陷** + +1. `UiWidget` 缺 `AdvanceAnimationSelf` / `AnimationActiveSelf` 两个 protected 虚函数(`.cpp` 已引用、头文件漏声明)。 +2. 最初的派发实现是"遍历同层兄弟、未消费就继续" ⇒ **一个点击可能同时被视觉上位于下方的控件处理**; + 已改为"只沿命中链派发 + 祖先兜底"。回归网里对应的断言从"绝对次数"改为**增量断言** + (绝对次数会被前面用例计数污染,本轮踩过一次)。 + +**验证** + +``` +make release TARGET_FILE_NAME=TargetCheck # 零警告 +powershell -File Tools/uicheck/build.ps1 # 链接 Build/Release/*.o(排除 Main.o) +Tools/uicheck/uicheck.exe # 62 passed, 0 failed +``` + +**未做(有意)** + +- 未改动任何现有组件(Toolbar / TabBar / SignalPanel / PlotView 仍走旧路径),因此**本提交对产品像素零影响**。 +- 未把 `UiWidget` 接入 `Application`,避免在 P2 之前引入行为差异。 + +--- + +## 下一步(接手者请从这里开始) + +1. **P2:`PlotViewPaint.cpp` 零行为分片**(9 080 行) + - 目标分片:`Paint`(只画)/ `Raster`(缓存与预算)/ `CacheKey`(指纹)/ `Geometry`(面板与命中几何)。 + - 做法:**机械搬移函数定义**,不改一行逻辑;`PlotViewInternal.hpp` 里的 `pvdetail` 全是 `inline`,跨分片安全。 + - 判据:`make release` 零警告 + `Target.exe` 体积变化 < 1KB + 场景哈希/像素快照与迁移前一致。 + - ⚠️ 搬移后务必确认没有残留的 `static` 文件级变量(只在 `PlotViewPaint.cpp` 内可见的辅助函数要一并搬走)。 +2. **P3:Toolbar 优先**(最小、最容易验证的组件),迁移后跑 `uicheck` + 新旧像素对比。 +3. 每个阶段单独提交,提交信息写清"改了什么 / 为什么 / 怎么验证"。 -- 2.54.0 From 5cb84e4f9756d0b0ad1f2f1f7a63bcecbd33935d Mon Sep 17 00:00:00 2001 From: "DeepSeek V4.1 Flash" <> Date: Wed, 28 Oct 2026 12:18:42 +0800 Subject: [PATCH 03/21] =?UTF-8?q?UI=20=E9=87=8D=E6=9E=84=20P2=EF=BC=9APlot?= =?UTF-8?q?ViewPaint.cpp=20=E6=8C=89=E5=85=B3=E6=B3=A8=E7=82=B9=E5=88=86?= =?UTF-8?q?=E7=89=87=EF=BC=88=E6=9C=BA=E6=A2=B0=E6=90=AC=E7=A7=BB=EF=BC=8C?= =?UTF-8?q?=E9=80=90=E5=83=8F=E7=B4=A0=200=20=E5=B7=AE=E5=BC=82=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PlotViewPaint.cpp 9,080 行里混住四类内容(真绘制 / 光栅化缓存 / 内容指纹 / 布局与命中几何), 按"函数名前缀"分片是上一轮膨胀的根因。本提交按关注点机械搬移函数定义,不改一行逻辑: - 新增 PlotViewRaster.cpp(1,876 行):光晕离屏层、静态场景缓存、装饰层(AlphaBlend)、 每网格宽基底图层/并行重建/滚动补条/预热预算。 - 新增 PlotViewCacheKey.cpp(474 行):SceneCacheKey、SceneContentHash/Geometry/Decor、 样式与派生快照、只改外观的网格定位、样式补丁帧。 - PlotViewPaint.cpp 只留绘制:9,080 -> 6,252 行(-31%)。 - 顶层注释从「四个分片」改为「六个分片(按关注点)」,并保留 「PlotView::Paint/PaintLane 只允许定义在 PlotViewPaint.cpp」。 pvdetail 全部 inline,故跨分片安全;无需改任何 include,Makefile 按 basename 自动收集。 验证: - make release TARGET_FILE_NAME=TargetCheck 零警告;Target.exe 873,984 -> 875,008 B(+1KB 对齐差异)。 - 逐像素 A/B(仓库外一次性渲染哈希探针,三场景:全览 / 深放大+自由标记+均值滤波+弱峰调整+时间光标 / 再平移一帧):分片前后哈希完全相同 A 2166933039626F66、B C444ECFEB18FD73D、C 7984446D8E7A4418。 - Tools/uicheck 62 passed / 0 failed。 坑:用旧文件回退做 A/B 时必须先删除 Build//PlotViewRaster.o 与 PlotViewCacheKey.o, 否则链接会因重复符号失败。 --- Components/PlotView/Src/PlotViewCacheKey.cpp | 504 ++++ Components/PlotView/Src/PlotViewPaint.cpp | 2463 +----------------- Components/PlotView/Src/PlotViewRaster.cpp | 1977 ++++++++++++++ Documents/UI_Refactor_Progress.md | 41 +- 4 files changed, 2530 insertions(+), 2455 deletions(-) create mode 100644 Components/PlotView/Src/PlotViewCacheKey.cpp create mode 100644 Components/PlotView/Src/PlotViewRaster.cpp diff --git a/Components/PlotView/Src/PlotViewCacheKey.cpp b/Components/PlotView/Src/PlotViewCacheKey.cpp new file mode 100644 index 0000000..9effdbc --- /dev/null +++ b/Components/PlotView/Src/PlotViewCacheKey.cpp @@ -0,0 +1,504 @@ +// PlotView 实现分片 —— 内容指纹与缓存键("为什么这一帧可以复用上一帧的像素")。 +// +// 本分片只包含 `PlotView` 的**成员函数定义**(从 `PlotViewPaint.cpp` 机械搬移,行为与搬移前逐字节一致)。 +// 职责: +// * 场景缓存键 `SceneCacheKey` 的构造与比较; +// * 场景内容指纹(`SceneContentHash` / `SceneGeometryHash` / `SceneDecorHash`); +// * 逐信号样式快照与"只改了外观"的网格定位(`CollectSceneStyles` / `FindStyleOnlyLanes`); +// * 派生曲线参数快照(`CollectSceneDerived`); +// * 样式补丁帧(贴图 → 只重画受影响网格 → 写回缓存)。 +// +// ⚠️ 硬约束:任何"会影响像素"的状态都必须进指纹;漏一个字段的症状是"贴旧像素" +// (本项目历史上至少 4 次真实 BUG 都是这个原因)。 +// ⚠️ 本文件**不做**像素分配与贴图(那些在 `PlotViewRaster.cpp`),也不做绘制(`PlotViewPaint.cpp`)。 + +#include "PlotViewInternal.hpp" + +namespace jdi +{ + PlotView::SceneCacheKey PlotView::MakeSceneCacheKey() const + { + SceneCacheKey key; + key.viewMin = Tab().ViewMin(); + key.viewMax = Tab().ViewMax(); + key.laneScroll = Tab().LaneScroll(); + key.laneCount = static_cast(m_lanes.size()); + key.areaX = m_area.X; + key.areaY = m_area.Y; + key.areaW = m_area.Width; + key.areaH = m_area.Height; + key.scale = m_scale; + + const SignalRef halo = SceneHaloRef(); + key.haloGroup = halo.groupIndex; + key.haloSignal = halo.signalIndex; + key.contentHash = SceneContentHash(); + return key; + } + + bool PlotView::SceneCacheMatches() const + { + if (!m_sceneValid) + { + return false; + } + // 缓存里存的是「快速帧」(关掉了抗锯齿)→ 不能直接命中: + // 必须重画一次全质量版本,否则降级的画面会一直留在屏幕上。 + if (m_sceneDegraded) + { + return false; + } + const SceneCacheKey& cached = m_sceneKey; + const SceneCacheKey key = MakeSceneCacheKey(); + return (key.viewMin == cached.viewMin) && (key.viewMax == cached.viewMax) && + (key.laneScroll == cached.laneScroll) && (key.laneCount == cached.laneCount) && + (key.areaX == cached.areaX) && (key.areaY == cached.areaY) && + (key.areaW == cached.areaW) && (key.areaH == cached.areaH) && + (key.scale == cached.scale) && (key.haloGroup == cached.haloGroup) && + (key.haloSignal == cached.haloSignal) && (key.contentHash == cached.contentHash); + } + + SignalRef PlotView::SceneHaloRef() const + { + // 只有拖拽中的信号光晕是“整个拖拽过程都不变”的,可以安全地绘入静态场景缓存; + // 悬停光晕随鼠标移动,必须画在缓存之外(见 PaintHoverHighlight)。 + if (m_dragMode == PlotDragMode::MOVE_SIGNAL && m_dragSignal.IsValid()) + { + return m_dragSignal; + } + return SignalRef(); + } + + std::uint64_t PlotView::SceneContentHash(bool includeStyles, bool includeDerived, + bool includeLaneRange, bool includeLaneScroll) const + { + // FNV-1a:把“视图几何之外的一切影响绘图区像素的内容”折叠成一个指纹。 + // 这样颜色/线宽/透明度/样式/重命名/信号增删等变化都会自动让缓存失效, + // 不需要在各处手工调用 InvalidateSceneCache(容易漏)。 + // `includeStyles == false` 时跳过逐信号的外观样式(颜色/不透明度/线宽/模式), + // 得到的就是几何指纹:它一致说明“只有外观样式变了”。 + std::uint64_t hash = 1469598103934665603ull; + const auto mix = [&hash](std::uint64_t value) + { + hash ^= value; + hash *= 1099511628211ull; + }; + const auto mixFloat = [&mix](float value) + { + std::uint32_t bits = 0; + std::memcpy(&bits, &value, sizeof(bits)); + mix(bits); + }; + const auto mixText = [&mix](const std::wstring& text) + { + mix(text.size()); + for (const wchar_t character : text) + { + mix(static_cast(character)); + } + }; + + mixFloat(m_plotRect.X); + mixFloat(m_plotRect.Y); + mixFloat(m_plotRect.Width); + mixFloat(m_plotRect.Height); + mixFloat(m_laneContentHeight); + mix(m_scrollBarVisible ? 1ull : 0ull); + // ⚠️ 网格纵向滚动量必须参与**静态场景缓存**的指纹:它只改变各网格的绘制位置。 + // 若不参与,平移宽缓冲区 / 静态场景缓存会把旧滚动位置的像素当成当前内容贴回, + // 表现为“网格位置错位 / 拖影 / 残留”等错误元素。 + // ⚠️ 但它是**纯屏幕落点**(不影响任何网格内容),因此“宽基底可用性”判据 + //(`SceneGeometryHash`)把它排除:网格滚动时 `BlitWideBuffer` 本来就按 `lane.rect` + // 逐网格贴图,内容完全复用 —— 这样“网格滚动到可视区 / 回到最上方”这类 + // **每帧都在改滚动量的动画**才不会退化成每帧整幅重绘(用户 2026-10-17 报告卡顿)。 + if (includeLaneScroll) + { + mixFloat(Tab().LaneScroll()); + } + + // 选中信号会改变图例条目的底色与文字颜色(绘制在缓存的静态场景里), + // 因此必须参与指纹:否则在左侧信号面板点选后图例不会高亮联动。 + { + const SignalRef selected = SelectedRef(); + mix(selected.IsValid() ? 1ull : 0ull); + mix(static_cast(selected.groupIndex)); + mix(static_cast(selected.signalIndex)); + } + + for (std::size_t laneIndex = 0; laneIndex < m_lanes.size(); ++laneIndex) + { + const LaneVisual& visual = m_lanes[laneIndex]; + // ⚠️ 各网格的屏幕纵向位置同样只是“贴图落点”(见 `includeLaneScroll` 的说明)。 + if (includeLaneScroll) + { + mixFloat(visual.rect.Y); + } + mixFloat(visual.rect.Height); + // ⚠️ 幅值范围只在 `includeLaneRange` 时计入:拖拽平移时的纵向分量会每帧小幅改动它, + // 若参与“宽基底可用性”判据,每一帧都会退化成整幅重绘(质量下降 + 卡顿)。 + if (includeLaneRange) + { + mixFloat(static_cast(visual.yMin)); + mixFloat(static_cast(visual.yMax)); + } + + const std::vector& signals = Tab().Lanes()[laneIndex].signals; + // 「边沿计数(中心轴)」「均值滤波」「弱峰调整」的开关与每条被勾选信号的参数 + // 会改变网格内的曲线绘制。它们**只影响网格内容**(不影响网格结构/几何), + // 因此只在 includeStyles 时计入 —— 于是拖参数滑块可以走“局部重画”快路径, + // 而不必整幅重绘(`FindStyleOnlyLanes` 用 `m_sceneDerived` 定位受影响的网格)。 + // `includeDerived == false` 时连这些参数也不计入(用于装饰层指纹:图例/Y 轴刻度 + // 与派生曲线参数无关,拖滑块时装饰层无需失效)。 + if (includeStyles && includeDerived) + { + mix(Tab().Lanes()[laneIndex].edgeCenter ? 1ull : 0ull); + mix(static_cast(Tab().Lanes()[laneIndex].edgeCenterItems.size())); + for (const Lane::EdgeCenterItem& item : Tab().Lanes()[laneIndex].edgeCenterItems) + { + mix(static_cast(item.ref.groupIndex)); + mix(static_cast(item.ref.signalIndex)); + mix(static_cast(item.points)); + mix(static_cast(item.iterations)); + } + mix(Tab().Lanes()[laneIndex].meanFilter ? 1ull : 0ull); + mix(static_cast(Tab().Lanes()[laneIndex].meanFilterItems.size())); + for (const Lane::MeanFilterItem& item : Tab().Lanes()[laneIndex].meanFilterItems) + { + mix(static_cast(item.ref.groupIndex)); + mix(static_cast(item.ref.signalIndex)); + mix(static_cast(item.points)); + mix(static_cast(item.iterations)); + } + mix(Tab().Lanes()[laneIndex].weakPeak ? 1ull : 0ull); + mix(static_cast(Tab().Lanes()[laneIndex].weakPeakItems.size())); + for (const Lane::WeakPeakItem& item : Tab().Lanes()[laneIndex].weakPeakItems) + { + // 逐信号参数(等级 + 迭代次数)都参与指纹:改任一条的参数都会重画。 + mix(static_cast(item.ref.groupIndex)); + mix(static_cast(item.ref.signalIndex)); + mix(static_cast(item.amountPercent)); + mix(static_cast(item.iterations)); + } + } + mix(signals.size()); + for (const SignalRef& ref : signals) + { + const ResolvedSignal resolved = ResolveSignal(ref); + mix(static_cast(ref.groupIndex)); + mix(static_cast(ref.signalIndex)); + mix(resolved.visible ? 1ull : 0ull); + if (resolved.data == nullptr || resolved.style == nullptr) + { + continue; + } + // 以下四项是“只影响外观”的样式:仅在 includeStyles 时计入指纹。 + // (不参与时得到的就是 SceneGeometryHash:它一致说明只有外观变了, + // 于是可以只重画颜色变了的网格,而不必整幅重绘。) + if (includeStyles) + { + mix(static_cast(resolved.style->color)); + mixFloat(resolved.style->opacity); + mixFloat(resolved.style->lineWidth); + mix(static_cast(resolved.style->mode)); + // 绘制前的数值变换(系数 / 绝对值 / 镜像)会改变波形形状。 + mixFloat(resolved.style->gain); + mix(resolved.style->absolute ? 1ull : 0ull); + mix(resolved.style->mirrorVertical ? 1ull : 0ull); + mix(resolved.style->mirrorHorizontal ? 1ull : 0ull); + // 偏移(Y 平移 + 时间平移)同样会改变波形。 + mixFloat(resolved.style->offsetY); + mixFloat(static_cast(resolved.style->offsetTime)); + } + // 图例文案参与绘制,重命名必须让缓存失效。 + mixText(resolved.data->name); + if (resolved.group != nullptr) + { + mixText(resolved.group->name); + } + } + } + return hash; + } + + std::uint64_t PlotView::SceneGeometryHash() const + { + // 外观样式不参与;各网格的幅值范围也不参与(那由逐网格图层指纹负责, + // 这样拖拽时的纵向分量不会把整幅重绘的快路径打掉); + // ⚠️ 网格纵向滚动量 / 各网格屏幕纵向位置也不参与:那只是图层的**贴图落点**, + // 不影响任何网格的像素内容(`BlitWideBuffer` 按 `lane.rect` 逐网格贴图), + // 因此滚动网格时可以继续复用宽基底(否则“滚动到可视区 / 回到最上方”的 + // 每一帧都会退化成整幅重绘 —— 用户 2026-10-17 报告的滚动动画卡顿)。 + return SceneContentHash(false, true, false, false); + } + + std::uint64_t PlotView::SceneDecorHash() const + { + // 装饰层(图例 + Y 轴刻度)的指纹:含外观样式,但**不含派生曲线参数** + //(拖弱峰调整等级/迭代次数滑块时图例与刻度不变,装饰层缓存不应失效)。 + return SceneContentHash(true, false); + } + + void PlotView::CollectSceneStyles(std::vector>& out) const + { + out.clear(); + out.resize(m_lanes.size()); + for (std::size_t laneIndex = 0; laneIndex < m_lanes.size(); ++laneIndex) + { + const std::vector& signals = Tab().Lanes()[laneIndex].signals; + std::vector& lane = out[laneIndex]; + lane.reserve(signals.size()); + for (const SignalRef& ref : signals) + { + const ResolvedSignal resolved = ResolveSignal(ref); + SceneStyleEntry entry; + if (resolved.style != nullptr) + { + entry.color = resolved.style->color; + entry.opacity = resolved.style->opacity; + entry.lineWidth = resolved.style->lineWidth; + entry.mode = static_cast(resolved.style->mode); + entry.gain = resolved.style->gain; + entry.absolute = resolved.style->absolute; + entry.mirrorVertical = resolved.style->mirrorVertical; + entry.mirrorHorizontal = resolved.style->mirrorHorizontal; + entry.offsetY = resolved.style->offsetY; + entry.offsetTime = resolved.style->offsetTime; + } + lane.push_back(entry); + } + } + } + + bool PlotView::FindStyleOnlyLanes(std::vector& lanes) const + { + lanes.clear(); + if (!m_sceneValid || m_sceneGeometryHash != SceneGeometryHash()) + { + // 网格结构 / 视图几何 / 信号集合 / 图例文案有变化:必须整幅重绘。 + return false; + } + + // ⚠️ **必须额外确认“视图几何与缓存完全一致”**:`SceneGeometryHash()` 刻意不含视界, + // 所以它相等**并不能**说明视图没变。若视界已经变了还走“只重画受影响网格”的局部补丁, + // 就会把缓存里**旧视界**的画面(时间轴、其它网格、底色)留在屏上 —— 表现为用户反馈的 + // “拖影 / 残留 / 错误元素”。因此这里逐项比较除内容指纹之外的缓存键。 + { + const SceneCacheKey key = MakeSceneCacheKey(); + if (key.viewMin != m_sceneKey.viewMin || key.viewMax != m_sceneKey.viewMax || + key.laneScroll != m_sceneKey.laneScroll || key.laneCount != m_sceneKey.laneCount || + key.areaX != m_sceneKey.areaX || key.areaY != m_sceneKey.areaY || + key.areaW != m_sceneKey.areaW || key.areaH != m_sceneKey.areaH || + key.scale != m_sceneKey.scale || key.haloGroup != m_sceneKey.haloGroup || + key.haloSignal != m_sceneKey.haloSignal) + { + return false; + } + } + + std::vector> current; + CollectSceneStyles(current); + if (current.size() != m_sceneStyles.size()) + { + return false; + } + + // 叠加预览曲线(均值滤波 / 中心轴 / 弱峰调整)的参数变化同样只需局部重画。 + std::vector currentDerived; + CollectSceneDerived(currentDerived); + const bool derivedComparable = (currentDerived.size() == m_sceneDerived.size()); + + for (std::size_t laneIndex = 0; laneIndex < current.size(); ++laneIndex) + { + const std::vector& now = current[laneIndex]; + const std::vector& before = m_sceneStyles[laneIndex]; + if (now.size() != before.size()) + { + return false; + } + bool differs = false; + for (std::size_t index = 0; index < now.size() && !differs; ++index) + { + // ⚠️ 必须与 `SceneContentHash(includeStyles) / SceneGeometryHash` 的口径**完全一致**: + // 几何哈希刻意不含外观,而外观哈希含 color/opacity/lineWidth/mode **以及** + // 系数 / 绝对值 / 镜像 / Y 偏移 / 时间偏移。旧实现只比前 4 项 ⇒ 一个网格改颜色、 + // 另一个网格同时改「调整」时,后者不会被登记为脏;随后 `RepaintSceneCachePatch` + // 又写入包含这些字段的新 `m_sceneKey`,那个网格就带着旧像素被当成“已是最新”, + // 一直残留到某次无关的失效。 + differs = !(now[index] == before[index]); + } + if (!differs && derivedComparable) + { + const SceneDerivedEntry& nowDerived = currentDerived[laneIndex]; + const SceneDerivedEntry& beforeDerived = m_sceneDerived[laneIndex]; + differs = (nowDerived.edgeCenter != beforeDerived.edgeCenter) || + (nowDerived.meanFilter != beforeDerived.meanFilter) || + (nowDerived.weakPeak != beforeDerived.weakPeak) || + (nowDerived.edgeCenterItems != beforeDerived.edgeCenterItems) || + (nowDerived.meanFilterItems != beforeDerived.meanFilterItems) || + (nowDerived.weakPeakItems != beforeDerived.weakPeakItems); + } + if (differs) + { + lanes.push_back(static_cast(laneIndex)); + } + } + return !lanes.empty(); + } + + void PlotView::CollectSceneDerived(std::vector& out) const + { + out.clear(); + out.resize(m_lanes.size()); + for (std::size_t laneIndex = 0; laneIndex < m_lanes.size(); ++laneIndex) + { + const Lane& lane = Tab().Lanes()[laneIndex]; + SceneDerivedEntry entry; + entry.edgeCenter = lane.edgeCenter ? 1 : 0; + entry.meanFilter = lane.meanFilter ? 1 : 0; + entry.weakPeak = lane.weakPeak ? 1 : 0; + const auto flatten = [](const auto& items, std::vector& target, auto valueOf) + { + target.clear(); + target.reserve(items.size() * 4); + for (const auto& item : items) + { + target.push_back(item.ref.groupIndex); + target.push_back(item.ref.signalIndex); + // 均值滤波/中心轴取 points,弱峰调整取 amountPercent(由 `valueOf` 决定)。 + target.push_back(static_cast(valueOf(item))); + target.push_back(item.iterations); + } + }; + flatten(lane.edgeCenterItems, entry.edgeCenterItems, + [](const Lane::EdgeCenterItem& item) { return item.points; }); + flatten(lane.meanFilterItems, entry.meanFilterItems, + [](const Lane::MeanFilterItem& item) { return item.points; }); + flatten(lane.weakPeakItems, entry.weakPeakItems, + [](const Lane::WeakPeakItem& item) { return item.amountPercent; }); + out[laneIndex] = std::move(entry); + } + } + + void PlotView::RepaintSceneCachePatch(gd::Graphics& g, const std::vector& lanes) + { + if (lanes.empty() || m_sceneDc == nullptr) + { + return; + } + + // 重画范围 = 受影响网格与绘图区的交集(并且不超出缓存区域)。 + float top = 1.0e9f; + float bottom = -1.0e9f; + for (const int laneIndex : lanes) + { + if (laneIndex < 0 || laneIndex >= static_cast(m_lanes.size())) + { + continue; + } + const gd::RectF& rect = m_lanes[static_cast(laneIndex)].rect; + top = std::min(top, rect.Y); + bottom = std::max(bottom, rect.Y + rect.Height); + } + if (bottom <= top) + { + return; + } + top = std::max(top, m_plotRect.Y); + bottom = std::min(bottom, m_plotRect.Y + m_plotRect.Height); + if (bottom <= top) + { + return; + } + + // ⚠️ 补丁矩形必须**向内**对齐到整数像素(ceil 上边界、floor 下边界): + // 网格矩形是浮点数,而 GDI `FillRect`/`BitBlt` 会按 floor/ceil 向外取整, + // "向外"会多擦掉/多写回一整行,那一行属于相邻网格(例如它的分界线), + // 于是补丁边界上会留下一像素接缝。向内对齐后,边界外的行一律由缓存提供 + // (缓存内容与整幅重绘逐像素一致,已由对照帧验证)。 + const int x0 = std::max(static_cast(std::ceil(m_area.X)), static_cast(std::ceil(m_area.X))); + const int y0 = std::max(static_cast(std::ceil(top)), static_cast(std::ceil(m_area.Y))); + const int x1 = std::min(static_cast(std::floor(m_area.X + m_area.Width)), + static_cast(std::floor(m_area.X + m_area.Width))); + const int y1 = std::min(static_cast(std::floor(bottom)), + static_cast(std::floor(m_area.Y + m_area.Height))); + if (x1 <= x0 || y1 <= y0) + { + return; + } + + const gd::RectF patch(static_cast(x0), static_cast(y0), + static_cast(x1 - x0), static_cast(y1 - y0)); + + // 补丁区域必须被**完整**重画:凡是与它相交的网格都要重画一遍 + // (补丁已向内对齐,正常只会命中受影响的那几个网格)。 + std::vector repaint; + for (std::size_t laneIndex = 0; laneIndex < m_lanes.size(); ++laneIndex) + { + const gd::RectF& rect = m_lanes[laneIndex].rect; + if (rect.Y < static_cast(y1) && rect.Y + rect.Height > static_cast(y0)) + { + repaint.push_back(static_cast(laneIndex)); + } + } + if (repaint.empty()) + { + return; + } + + // 与整幅重绘保持完全相同的绘制顺序:底色 → 逐网格内容(PaintLane 会与当前裁剪区求交)。 + { + const int saveState = g.Save(); + g.SetClip(patch, gd::CombineModeIntersect); + // ⚠️ 底色填充必须**按补丁矩形本身**填充,不能用 m_area / m_plotRect: + // `UiFillRect` 对大面积不透明矩形会走 GDI `FillRect` 快路径,而 GDI 绘制 + // **不受 GDI+ 裁剪区约束**,会把已经贴好的缓存内容整片覆盖掉。 + UiFillRect(g, patch, m_theme->plotBg); + const float cardTop = std::max(patch.Y, m_plotRect.Y); + const float cardBottom = std::min(patch.Y + patch.Height, m_plotRect.Y + m_plotRect.Height); + if (cardBottom > cardTop) + { + UiFillRect(g, gd::RectF(patch.X, cardTop, patch.Width, cardBottom - cardTop), + m_theme->cardBg); + } + + const double viewMin = Tab().ViewMin(); + const double viewMax = Tab().ViewMax(); + const double span = viewMax - viewMin; + const double timeStep = NiceStep((span > 0.0) ? span : 1.0, kTimeTickTarget); + std::vector timeTicks; + BuildTicks(viewMin, viewMax, timeStep, timeTicks); + + // ⚠️ **补丁必须就地绘制装饰**(而不是"跳过装饰"):补丁范围覆盖整个受影响网格 + // (含网格左缘的 Y 轴胶囊与图例),若跳过绘制,这部分像素会被网格底色/波形覆盖, + // 而补丁结果还会写回场景缓存 —— 表现为"改样式时图例/Y 轴读数闪掉一下"。 + const bool savedUseDecorCache = m_useDecorCache; + m_useDecorCache = false; + for (const int laneIndex : repaint) + { + if (laneIndex >= 0 && laneIndex < static_cast(m_lanes.size())) + { + PaintLane(g, laneIndex, m_lanes[static_cast(laneIndex)], timeTicks); + } + } + m_useDecorCache = savedUseDecorCache; + g.Restore(saveState); + } + + // 把重画结果写回缓存位图,使缓存与当前样式一致(下一帧即回到"完全命中"快路径)。 + { + HDC target = g.GetHDC(); + if (target != nullptr) + { + BitBlt(m_sceneDc, x0 - m_sceneX, y0 - m_sceneY, x1 - x0, y1 - y0, target, x0, y0, SRCCOPY); + g.ReleaseHDC(target); + } + } + + m_sceneKey = MakeSceneCacheKey(); + m_sceneGeometryHash = SceneGeometryHash(); + CollectSceneStyles(m_sceneStyles); + CollectSceneDerived(m_sceneDerived); + m_sceneDegraded = m_sceneDegraded || m_fastFrame; + } + +} // namespace jdi diff --git a/Components/PlotView/Src/PlotViewPaint.cpp b/Components/PlotView/Src/PlotViewPaint.cpp index 1235313..1356126 100644 --- a/Components/PlotView/Src/PlotViewPaint.cpp +++ b/Components/PlotView/Src/PlotViewPaint.cpp @@ -1,14 +1,19 @@ -// PlotView 实现分片 —— 本文件只包含 PlotView 的**成员函数定义**(纯代码搬移,行为与拆分前完全一致)。 +// PlotView 实现分片 —— 绘制(paint):只负责"把内容画出来"。 // // 共用的常量与工具函数在 PlotViewInternal.hpp(命名空间 `pvdetail`,全部 inline)。 -// 四个分片的职责划分(按功能): -// core 视图核心:构造/析构、上下文绑定、布局、坐标映射、视界与平移、网格结构与归属、 -// 选区/网格划分、拖动取消 -// paint 渲染与像素缓存:全部绘制(Paint*/Render*)、场景缓存、装饰缓存、每网格图层、 -// 宽基底与滚动补条、并行图层重建 -// interaction 交互与编辑:命中测试、鼠标与键盘处理、光标形状、内联输入框、浮动面板层序与命中 -// features 视图特性与派生数据:光标状态与读数、提示浮层、缩放模式、自适应、光标吸附、 -// 单点信号、派生曲线线程池、边沿统计、实验性功能(均值滤波 / 弱峰调整)参数 +// 六个分片的职责划分(按**关注点**,不再按函数名前缀): +// PlotView.cpp 视图核心:构造/析构、上下文绑定、布局、坐标映射、视界与平移、 +// 网格结构与归属、选区/网格划分、拖动取消 +// PlotViewPaint.cpp 绘制:全部 Paint*/Render* 与内联输入框绘制(**本文件**) +// PlotViewRaster.cpp 光栅化像素缓存与每帧预算:光晕层、场景缓存、装饰层、每网格宽基底 +// 图层、滚动补条、预热("怎么画才不卡") +// PlotViewCacheKey.cpp 内容指纹与缓存键:SceneCacheKey / *Hash / 样式与派生快照 / 样式补丁帧 +// PlotViewInteraction.cpp 交互与编辑:命中测试、鼠标与键盘处理、光标形状、内联输入框、 +// 浮动面板层序与命中 +// PlotViewFeatures.cpp 视图特性与派生数据:光标状态与读数、提示浮层、缩放模式、自适应、 +// 光标吸附、单点信号、派生曲线线程池、边沿统计、实验性功能参数 +// +// ⚠️ 新增函数按**职责**放入对应分片;`PlotView::Paint/PaintLane` 仍只允许定义在本文件。 #include "PlotViewInternal.hpp" namespace jdi @@ -1099,382 +1104,6 @@ namespace jdi paintOverlays(); } - void PlotView::PaintSignalHaloFor(gd::Graphics& g, const ResolvedSignal& resolved, const LaneVisual& lane, - const PolylineCacheEntry& cached) - { - if (resolved.style == nullptr) - { - return; - } - // ------------------------------------------------------------------ - // 稠密信号(走"像素列包络带"):光晕 = **沿包络带向外扩一圈的填充**。 - // - // ⚠️ 绝不能用「加粗折线描边」画稠密信号的光晕:包络带的顶点序列是 - // 「每列上/下包络交替」的满幅竖锯齿,GDI+ 描边的代价 = 线段数 × 覆盖行数, - // 实测 2503 个顶点要 **210ms**。而 `FillPath` 同一条几何只要 ~14ms。 - // ⚠️⚠️ **必须向外扩 `grow`(= 描边笔宽的一半)**:折线描边的笔宽是 - // `lineWidth + 4`,即沿曲线向两侧各扩 2~3px —— 那正是用户看到的"外延高亮描边"。 - // 曾经直接按包络带原始几何填充,结果光晕与曲线**逐像素同形**:当信号颜色恰好等于 - // 主题强调色时(调色板第 1 色就是强调色),曲线上的那层半透明强调色完全看不出变化, - // 只剩下带宽内部的一点点底色变化 ⇒ 用户报告"高亮几乎看不到、只有曲线换了颜色" - //(2026-10-18 第三轮)。扩一圈后与旧版描边的观感一致。 - // ⚠️ 光晕**必须覆盖整条曲线**:只画鼠标附近一段时,在满幅方波上就是一个长方形色块。 - // 代价问题由 `PaintSignalHaloCached`(渲染一次 + 每帧贴图)解决。 - // ------------------------------------------------------------------ - if (resolved.style->mode != DrawMode::SCATTER && cached.bandMode && !cached.bandBreaks.empty()) - { - // 与旧版 `PaintSignalHalo` 的笔宽同源:`(lineWidth + 4) * scale`,向外取一半。 - const float grow = (std::max(1.0f, resolved.style->lineWidth) + 4.0f) * m_scale * 0.5f; - const int savedSmoothing = g.GetSmoothingMode(); - g.SetSmoothingMode(gd::SmoothingModeNone); // 半透明填充:抗锯齿没有观感收益 - gd::GraphicsPath bandPath; - bandPath.SetFillMode(gd::FillModeWinding); - gd::PointF quad[4]; - for (std::size_t k = 0; k < cached.bandBreaks.size(); ++k) - { - const std::size_t start = cached.bandBreaks[k]; - const std::size_t stop = - (k + 1 < cached.bandBreaks.size()) ? cached.bandBreaks[k + 1] : cached.band.size(); - const std::size_t total = stop - start; - if (total < 4 || (total % 2) != 0) - { - continue; - } - // 段内布局 = 「上包络正向(`half` 个)+ 下包络反向(`half` 个)」,因此 - // 列 `i` 的上/下包络分别是 `start+i` 与 `start+half+(half-1-i)`。 - // - // ⚠️⚠️ **逐列成对填四边形,而不是把整条包络拼成一个多边形**:满幅方波的 - // 包络相邻列区间**互相交叉**,拼成单个多边形后缠绕填充规则会在交叠处 - // 互相抵消 ⇒ 填充结果变成一条条竖缝(实测光晕只覆盖约一半带宽,用户看到 - // "只有曲线换了个颜色")。逐列四边形永远是简单多边形,不会被挖空。 - // ⚠️ 横向/纵向都外扩 `grow`:竖向跃变处旧版 5px 笔会涂出一条整高色带, - // 只填 1px 宽的列区间根本看不出来。相邻四边形同向、且在同一次 `FillPath` - // 内,重叠区域只混合一次(不会叠出更深的竖条)。 - const std::size_t half = total / 2; - for (std::size_t column = 0; column + 1 < half; ++column) - { - const gd::PointF& topA = cached.band[start + column]; - const gd::PointF& bottomA = cached.band[start + half + (half - 1 - column)]; - const gd::PointF& topB = cached.band[start + column + 1]; - const gd::PointF& bottomB = - cached.band[start + half + (half - 1 - (column + 1))]; - const float highY = std::min(std::min(topA.Y, bottomA.Y), std::min(topB.Y, bottomB.Y)); - const float lowY = std::max(std::max(topA.Y, bottomA.Y), std::max(topB.Y, bottomB.Y)); - quad[0] = gd::PointF(topA.X - grow, highY - grow); - quad[1] = gd::PointF(topB.X + grow, highY - grow); - quad[2] = gd::PointF(topB.X + grow, lowY + grow); - quad[3] = gd::PointF(topA.X - grow, lowY + grow); - bandPath.AddPolygon(quad, 4); - } - } - if (bandPath.GetPointCount() > 0) - { - // alpha 与旧版描边笔一致(0x66),保证任何信号颜色下观感相同。 - gd::SolidBrush brush(ToGdiColor(WithAlpha(m_theme->accent, 0x66))); - g.FillPath(&brush, &bandPath); - } - g.SetSmoothingMode(static_cast(savedSmoothing)); - return; - } - // 其余情形一律用折线描边(与"之前"的画法一致)。 - PaintSignalHalo(g, resolved, lane, cached.points, cached.breaks); - } - - void PlotView::PaintSignalHaloCached(gd::Graphics& g, const ResolvedSignal& resolved, - const LaneVisual& lane, const PolylineCacheEntry& cached) - { - if (resolved.style == nullptr) - { - return; - } - - // 光晕只可能画在该网格内:位图 = 网格矩形 ∩ 绘图区(外加少量余量容纳加粗线宽)。 - const float pad = (resolved.style->lineWidth + 8.0f) * m_scale; - const float left = std::max(lane.rect.X, m_plotRect.X) - pad; - const float top = std::max(lane.rect.Y, m_plotRect.Y) - pad; - const float right = - std::min(lane.rect.X + lane.rect.Width, m_plotRect.X + m_plotRect.Width) + pad; - const float bottom = - std::min(lane.rect.Y + lane.rect.Height, m_plotRect.Y + m_plotRect.Height) + pad; - if (!(right > left + 1.0f) || !(bottom > top + 1.0f)) - { - return; - } - const int targetX = static_cast(std::floor(left)); - const int targetY = static_cast(std::floor(top)); - const int width = static_cast(std::ceil(right)) - targetX; - const int height = static_cast(std::ceil(bottom)) - targetY; - if (width <= 0 || height <= 0) - { - return; - } - - HaloLayerCache& cache = m_haloLayer; - // 键 = 决定光晕几何的全部输入(与 `AcquirePolyline` 的键同源)。 - // ⚠️ **不含网格的屏幕位置**:滚动网格时几何不变,只需按新位置贴同一张位图。 - const bool sameSize = cache.bitmap && static_cast(cache.bitmap->GetWidth()) == width && - static_cast(cache.bitmap->GetHeight()) == height; - const bool sameKey = - cache.valid && sameSize && cache.ref == cached.ref && cache.group == cached.group && - cache.sampleCount == cached.sampleCount && cache.viewMin == cached.viewMin && - cache.viewMax == cached.viewMax && cache.plotX == cached.plotX && - cache.plotWidth == cached.plotWidth && cache.laneHeight == cached.laneHeight && - cache.laneYMin == cached.laneYMin && cache.laneYMax == cached.laneYMax && - cache.lineWidth == cached.lineWidth && cache.mode == resolved.style->mode && - cache.gain == resolved.style->gain && cache.absolute == resolved.style->absolute && - cache.mirrorVertical == resolved.style->mirrorVertical && - cache.mirrorHorizontal == resolved.style->mirrorHorizontal && - cache.offsetY == resolved.style->offsetY && - cache.offsetTime == resolved.style->offsetTime; - - if (!sameKey) - { - if (!sameSize) - { - cache.bitmap = std::make_unique(width, height, PixelFormat32bppARGB); - } - { - gd::Graphics layerGraphics(cache.bitmap.get()); - layerGraphics.Clear(gd::Color(0, 0, 0, 0)); - layerGraphics.SetSmoothingMode(gd::SmoothingModeAntiAlias); - layerGraphics.SetPixelOffsetMode(gd::PixelOffsetModeHalf); - // 位图坐标 = 屏幕坐标 - 目标原点,光晕几何可直接沿用屏幕坐标。 - layerGraphics.TranslateTransform(static_cast(-targetX), - static_cast(-targetY)); - if (cached.points.size() > kHaloNoAaMinPoints) - { - // 顶点很多的区间(例如深放大后的方波:每段都跨满网格高): - // 抗锯齿描边的扫描线代价 ≈ 线段数 × 覆盖行数(实测可差 20 倍), - // 而这么密的半透明光晕上平滑作用肉眼不可辨。几何完全不变。 - layerGraphics.SetSmoothingMode(gd::SmoothingModeNone); - } - PaintSignalHaloFor(layerGraphics, resolved, lane, cached); - } - cache.ref = cached.ref; - cache.group = cached.group; - cache.sampleCount = cached.sampleCount; - cache.viewMin = cached.viewMin; - cache.viewMax = cached.viewMax; - cache.plotX = cached.plotX; - cache.plotWidth = cached.plotWidth; - cache.laneHeight = cached.laneHeight; - cache.laneYMin = cached.laneYMin; - cache.laneYMax = cached.laneYMax; - cache.lineWidth = cached.lineWidth; - cache.mode = resolved.style->mode; - cache.gain = resolved.style->gain; - cache.absolute = resolved.style->absolute; - cache.mirrorVertical = resolved.style->mirrorVertical; - cache.mirrorHorizontal = resolved.style->mirrorHorizontal; - cache.offsetY = resolved.style->offsetY; - cache.offsetTime = resolved.style->offsetTime; - cache.originX = targetX; - cache.originY = targetY; - cache.valid = true; - if (!UploadHaloLayer(width, height)) - { - cache.valid = false; - return; - } - } - - // 贴图:`AlphaBlend` + `AC_SRC_ALPHA`(源位图已预乘)。命中缓存时只做这一次位块搬运。 - if (cache.dc == nullptr) - { - return; - } - const HDC targetDc = g.GetHDC(); - if (targetDc != nullptr) - { - BLENDFUNCTION blend = {}; - blend.BlendOp = AC_SRC_OVER; - blend.SourceConstantAlpha = 0xFF; - blend.AlphaFormat = AC_SRC_ALPHA; - ::AlphaBlend(targetDc, targetX, targetY, width, height, cache.dc, 0, 0, width, height, - blend); - g.ReleaseHDC(targetDc); - } - } - - bool PlotView::UploadHaloLayer(int width, int height) - { - HaloLayerCache& cache = m_haloLayer; - if (!cache.bitmap) - { - return false; - } - if (cache.dib == nullptr || cache.dibWidth != width || cache.dibHeight != height) - { - ReleaseHaloLayer(); - BITMAPINFO info = {}; - info.bmiHeader.biSize = sizeof(BITMAPINFOHEADER); - info.bmiHeader.biWidth = width; - info.bmiHeader.biHeight = -height; // 自上而下(与 GDI+ 的扫描行顺序一致) - info.bmiHeader.biPlanes = 1; - info.bmiHeader.biBitCount = 32; - info.bmiHeader.biCompression = BI_RGB; - HDC memoryDc = CreateCompatibleDC(nullptr); - if (memoryDc == nullptr) - { - return false; - } - void* bits = nullptr; - HBITMAP dib = CreateDIBSection(memoryDc, &info, DIB_RGB_COLORS, &bits, nullptr, 0); - if (dib == nullptr || bits == nullptr) - { - DeleteDC(memoryDc); - if (dib != nullptr) - { - DeleteObject(dib); - } - return false; - } - SelectObject(memoryDc, dib); - cache.dc = memoryDc; - cache.dib = dib; - cache.bits = bits; - cache.dibWidth = width; - cache.dibHeight = height; - } - - // 预乘 alpha:`AlphaBlend` 的 `AC_SRC_ALPHA` 要求源 RGB 已乘过 alpha。 - // ⚠️ 用 `(v * a + 128) * 257 >> 16` 代替 `(v * a + 127) / 255`(等价、无除法), - // **不要为此建 64KB 查表**:那张表会让 Release 产物凭空多出 64KB(见 §二进制体积), - // 而这里的乘法-移位在 1.6M 像素上也只要几毫秒。 - gd::Rect rect(0, 0, width, height); - gd::BitmapData data = {}; - if (cache.bitmap->LockBits(&rect, gd::ImageLockModeRead, PixelFormat32bppARGB, &data) != - gd::Ok) - { - return false; - } - unsigned char* destination = static_cast(cache.bits); - for (int y = 0; y < height; ++y) - { - const unsigned char* source = - static_cast(data.Scan0) + static_cast(y) * data.Stride; - unsigned char* row = destination + static_cast(y) * width * 4u; - for (int x = 0; x < width; ++x) - { - const unsigned alpha = source[x * 4 + 3]; - if (alpha == 0) - { - row[x * 4 + 0] = 0; - row[x * 4 + 1] = 0; - row[x * 4 + 2] = 0; - row[x * 4 + 3] = 0; - continue; - } - if (alpha == 0xFF) - { - row[x * 4 + 0] = source[x * 4 + 0]; - row[x * 4 + 1] = source[x * 4 + 1]; - row[x * 4 + 2] = source[x * 4 + 2]; - row[x * 4 + 3] = 0xFF; - continue; - } - row[x * 4 + 0] = static_cast( - (static_cast(source[x * 4 + 0]) * alpha + 128u) * 257u >> 16); - row[x * 4 + 1] = static_cast( - (static_cast(source[x * 4 + 1]) * alpha + 128u) * 257u >> 16); - row[x * 4 + 2] = static_cast( - (static_cast(source[x * 4 + 2]) * alpha + 128u) * 257u >> 16); - row[x * 4 + 3] = static_cast(alpha); - } - } - cache.bitmap->UnlockBits(&data); - return true; - } - - void PlotView::ReleaseHaloLayer() - { - HaloLayerCache& cache = m_haloLayer; - if (cache.dc != nullptr) - { - DeleteDC(cache.dc); - cache.dc = nullptr; - } - if (cache.dib != nullptr) - { - DeleteObject(cache.dib); - cache.dib = nullptr; - } - cache.bits = nullptr; - cache.dibWidth = 0; - cache.dibHeight = 0; - cache.valid = false; - } - - void PlotView::PaintSignalHalo(gd::Graphics& g, const ResolvedSignal& resolved, const LaneVisual& lane, - const std::vector& points, - const std::vector& breaks) - { - // ⚠️ **只有单个数据点**的信号(默认样式是「点线」,那个点本身是一个粗圆点): - // 折线光晕无从下手(不足两个顶点),改为在数据点外包一圈强调色圆环。 - // 否则悬停时完全没有“可拖拽”的视觉反馈(用户 2026-10-15 报告)。 - if (resolved.data != nullptr && resolved.data->finiteCount == 1) - { - gd::PointF dot; - if (SinglePointScreenPos(resolved, lane, dot)) - { - const float radius = SinglePointDotRadius(resolved) + 3.0f * m_scale; - gd::Pen ring(ToGdiColor(WithAlpha(m_theme->accent, 0x88)), 2.0f * m_scale); - g.DrawEllipse(&ring, dot.X - radius, dot.Y - radius, radius * 2.0f, radius * 2.0f); - } - return; - } - - const bool drawLine = (resolved.style->mode != DrawMode::SCATTER); - if (!drawLine) - { - // ⚠️ **散点模式的高亮 = 只高亮数据点本身(不画连线)**(用户 2026-10-17 要求): - // 旧实现直接 `return`,于是散点信号悬停时**只有拖拽提示、没有任何高亮**。 - // 这里给每个参与绘制的散点套一圈强调色圆环 —— 与「线段/点线」的折线光晕相比, - // 正好是“去掉数据点之间连线的部分”(用户原话)。 - // ⚠️ 步长必须与 `PaintLane` 的散点绘制**同源**(`kMaxScatterPoints`), - // 否则高亮圈会出现在没画出来的点上(或漏掉可见的点)。 - if (points.empty() || resolved.style == nullptr) - { - return; - } - const float radius = static_cast( - ClampDouble(static_cast(resolved.style->lineWidth) * kScatterRadiusScale, - static_cast(kMinScatterRadius), static_cast(kMaxScatterRadius)) * - m_scale); - const float ringRadius = radius + 2.5f * m_scale; - const std::size_t scatterStride = - std::max(1, points.size() / kMaxScatterPoints); - // 散点数量本身有上限(`kMaxScatterPoints`),逐点画圈是 O(数千) 的小代价。 - gd::Pen ring(ToGdiColor(WithAlpha(m_theme->accent, 0x88)), 1.6f * m_scale); - for (std::size_t index = 0; index < points.size(); index += scatterStride) - { - const gd::PointF& point = points[index]; - g.DrawEllipse(&ring, point.X - ringRadius, point.Y - ringRadius, ringRadius * 2.0f, - ringRadius * 2.0f); - } - return; - } - if (breaks.empty()) - { - return; - } - - gd::Pen halo(ToGdiColor(WithAlpha(m_theme->accent, 0x66)), - (resolved.style->lineWidth + 4.0f) * m_scale); - halo.SetLineJoin(gd::LineJoinRound); - halo.SetStartCap(gd::LineCapRound); - halo.SetEndCap(gd::LineCapRound); - for (std::size_t k = 0; k < breaks.size(); ++k) - { - const std::size_t start = breaks[k]; - const std::size_t stop = (k + 1 < breaks.size()) ? breaks[k + 1] : points.size(); - if (stop <= start + 1) - { - continue; - } - g.DrawLines(&halo, points.data() + start, static_cast(stop - start)); - } - } void PlotView::PaintHoverHighlight(gd::Graphics& g) { @@ -1628,490 +1257,6 @@ namespace jdi g.Restore(saveState); } - PlotView::SceneCacheKey PlotView::MakeSceneCacheKey() const - { - SceneCacheKey key; - key.viewMin = Tab().ViewMin(); - key.viewMax = Tab().ViewMax(); - key.laneScroll = Tab().LaneScroll(); - key.laneCount = static_cast(m_lanes.size()); - key.areaX = m_area.X; - key.areaY = m_area.Y; - key.areaW = m_area.Width; - key.areaH = m_area.Height; - key.scale = m_scale; - - const SignalRef halo = SceneHaloRef(); - key.haloGroup = halo.groupIndex; - key.haloSignal = halo.signalIndex; - key.contentHash = SceneContentHash(); - return key; - } - - bool PlotView::SceneCacheMatches() const - { - if (!m_sceneValid) - { - return false; - } - // 缓存里存的是「快速帧」(关掉了抗锯齿)→ 不能直接命中: - // 必须重画一次全质量版本,否则降级的画面会一直留在屏幕上。 - if (m_sceneDegraded) - { - return false; - } - const SceneCacheKey& cached = m_sceneKey; - const SceneCacheKey key = MakeSceneCacheKey(); - return (key.viewMin == cached.viewMin) && (key.viewMax == cached.viewMax) && - (key.laneScroll == cached.laneScroll) && (key.laneCount == cached.laneCount) && - (key.areaX == cached.areaX) && (key.areaY == cached.areaY) && - (key.areaW == cached.areaW) && (key.areaH == cached.areaH) && - (key.scale == cached.scale) && (key.haloGroup == cached.haloGroup) && - (key.haloSignal == cached.haloSignal) && (key.contentHash == cached.contentHash); - } - - SignalRef PlotView::SceneHaloRef() const - { - // 只有拖拽中的信号光晕是“整个拖拽过程都不变”的,可以安全地绘入静态场景缓存; - // 悬停光晕随鼠标移动,必须画在缓存之外(见 PaintHoverHighlight)。 - if (m_dragMode == PlotDragMode::MOVE_SIGNAL && m_dragSignal.IsValid()) - { - return m_dragSignal; - } - return SignalRef(); - } - - std::uint64_t PlotView::SceneContentHash(bool includeStyles, bool includeDerived, - bool includeLaneRange, bool includeLaneScroll) const - { - // FNV-1a:把“视图几何之外的一切影响绘图区像素的内容”折叠成一个指纹。 - // 这样颜色/线宽/透明度/样式/重命名/信号增删等变化都会自动让缓存失效, - // 不需要在各处手工调用 InvalidateSceneCache(容易漏)。 - // `includeStyles == false` 时跳过逐信号的外观样式(颜色/不透明度/线宽/模式), - // 得到的就是几何指纹:它一致说明“只有外观样式变了”。 - std::uint64_t hash = 1469598103934665603ull; - const auto mix = [&hash](std::uint64_t value) - { - hash ^= value; - hash *= 1099511628211ull; - }; - const auto mixFloat = [&mix](float value) - { - std::uint32_t bits = 0; - std::memcpy(&bits, &value, sizeof(bits)); - mix(bits); - }; - const auto mixText = [&mix](const std::wstring& text) - { - mix(text.size()); - for (const wchar_t character : text) - { - mix(static_cast(character)); - } - }; - - mixFloat(m_plotRect.X); - mixFloat(m_plotRect.Y); - mixFloat(m_plotRect.Width); - mixFloat(m_plotRect.Height); - mixFloat(m_laneContentHeight); - mix(m_scrollBarVisible ? 1ull : 0ull); - // ⚠️ 网格纵向滚动量必须参与**静态场景缓存**的指纹:它只改变各网格的绘制位置。 - // 若不参与,平移宽缓冲区 / 静态场景缓存会把旧滚动位置的像素当成当前内容贴回, - // 表现为“网格位置错位 / 拖影 / 残留”等错误元素。 - // ⚠️ 但它是**纯屏幕落点**(不影响任何网格内容),因此“宽基底可用性”判据 - //(`SceneGeometryHash`)把它排除:网格滚动时 `BlitWideBuffer` 本来就按 `lane.rect` - // 逐网格贴图,内容完全复用 —— 这样“网格滚动到可视区 / 回到最上方”这类 - // **每帧都在改滚动量的动画**才不会退化成每帧整幅重绘(用户 2026-10-17 报告卡顿)。 - if (includeLaneScroll) - { - mixFloat(Tab().LaneScroll()); - } - - // 选中信号会改变图例条目的底色与文字颜色(绘制在缓存的静态场景里), - // 因此必须参与指纹:否则在左侧信号面板点选后图例不会高亮联动。 - { - const SignalRef selected = SelectedRef(); - mix(selected.IsValid() ? 1ull : 0ull); - mix(static_cast(selected.groupIndex)); - mix(static_cast(selected.signalIndex)); - } - - for (std::size_t laneIndex = 0; laneIndex < m_lanes.size(); ++laneIndex) - { - const LaneVisual& visual = m_lanes[laneIndex]; - // ⚠️ 各网格的屏幕纵向位置同样只是“贴图落点”(见 `includeLaneScroll` 的说明)。 - if (includeLaneScroll) - { - mixFloat(visual.rect.Y); - } - mixFloat(visual.rect.Height); - // ⚠️ 幅值范围只在 `includeLaneRange` 时计入:拖拽平移时的纵向分量会每帧小幅改动它, - // 若参与“宽基底可用性”判据,每一帧都会退化成整幅重绘(质量下降 + 卡顿)。 - if (includeLaneRange) - { - mixFloat(static_cast(visual.yMin)); - mixFloat(static_cast(visual.yMax)); - } - - const std::vector& signals = Tab().Lanes()[laneIndex].signals; - // 「边沿计数(中心轴)」「均值滤波」「弱峰调整」的开关与每条被勾选信号的参数 - // 会改变网格内的曲线绘制。它们**只影响网格内容**(不影响网格结构/几何), - // 因此只在 includeStyles 时计入 —— 于是拖参数滑块可以走“局部重画”快路径, - // 而不必整幅重绘(`FindStyleOnlyLanes` 用 `m_sceneDerived` 定位受影响的网格)。 - // `includeDerived == false` 时连这些参数也不计入(用于装饰层指纹:图例/Y 轴刻度 - // 与派生曲线参数无关,拖滑块时装饰层无需失效)。 - if (includeStyles && includeDerived) - { - mix(Tab().Lanes()[laneIndex].edgeCenter ? 1ull : 0ull); - mix(static_cast(Tab().Lanes()[laneIndex].edgeCenterItems.size())); - for (const Lane::EdgeCenterItem& item : Tab().Lanes()[laneIndex].edgeCenterItems) - { - mix(static_cast(item.ref.groupIndex)); - mix(static_cast(item.ref.signalIndex)); - mix(static_cast(item.points)); - mix(static_cast(item.iterations)); - } - mix(Tab().Lanes()[laneIndex].meanFilter ? 1ull : 0ull); - mix(static_cast(Tab().Lanes()[laneIndex].meanFilterItems.size())); - for (const Lane::MeanFilterItem& item : Tab().Lanes()[laneIndex].meanFilterItems) - { - mix(static_cast(item.ref.groupIndex)); - mix(static_cast(item.ref.signalIndex)); - mix(static_cast(item.points)); - mix(static_cast(item.iterations)); - } - mix(Tab().Lanes()[laneIndex].weakPeak ? 1ull : 0ull); - mix(static_cast(Tab().Lanes()[laneIndex].weakPeakItems.size())); - for (const Lane::WeakPeakItem& item : Tab().Lanes()[laneIndex].weakPeakItems) - { - // 逐信号参数(等级 + 迭代次数)都参与指纹:改任一条的参数都会重画。 - mix(static_cast(item.ref.groupIndex)); - mix(static_cast(item.ref.signalIndex)); - mix(static_cast(item.amountPercent)); - mix(static_cast(item.iterations)); - } - } - mix(signals.size()); - for (const SignalRef& ref : signals) - { - const ResolvedSignal resolved = ResolveSignal(ref); - mix(static_cast(ref.groupIndex)); - mix(static_cast(ref.signalIndex)); - mix(resolved.visible ? 1ull : 0ull); - if (resolved.data == nullptr || resolved.style == nullptr) - { - continue; - } - // 以下四项是“只影响外观”的样式:仅在 includeStyles 时计入指纹。 - // (不参与时得到的就是 SceneGeometryHash:它一致说明只有外观变了, - // 于是可以只重画颜色变了的网格,而不必整幅重绘。) - if (includeStyles) - { - mix(static_cast(resolved.style->color)); - mixFloat(resolved.style->opacity); - mixFloat(resolved.style->lineWidth); - mix(static_cast(resolved.style->mode)); - // 绘制前的数值变换(系数 / 绝对值 / 镜像)会改变波形形状。 - mixFloat(resolved.style->gain); - mix(resolved.style->absolute ? 1ull : 0ull); - mix(resolved.style->mirrorVertical ? 1ull : 0ull); - mix(resolved.style->mirrorHorizontal ? 1ull : 0ull); - // 偏移(Y 平移 + 时间平移)同样会改变波形。 - mixFloat(resolved.style->offsetY); - mixFloat(static_cast(resolved.style->offsetTime)); - } - // 图例文案参与绘制,重命名必须让缓存失效。 - mixText(resolved.data->name); - if (resolved.group != nullptr) - { - mixText(resolved.group->name); - } - } - } - return hash; - } - - std::uint64_t PlotView::SceneGeometryHash() const - { - // 外观样式不参与;各网格的幅值范围也不参与(那由逐网格图层指纹负责, - // 这样拖拽时的纵向分量不会把整幅重绘的快路径打掉); - // ⚠️ 网格纵向滚动量 / 各网格屏幕纵向位置也不参与:那只是图层的**贴图落点**, - // 不影响任何网格的像素内容(`BlitWideBuffer` 按 `lane.rect` 逐网格贴图), - // 因此滚动网格时可以继续复用宽基底(否则“滚动到可视区 / 回到最上方”的 - // 每一帧都会退化成整幅重绘 —— 用户 2026-10-17 报告的滚动动画卡顿)。 - return SceneContentHash(false, true, false, false); - } - - std::uint64_t PlotView::SceneDecorHash() const - { - // 装饰层(图例 + Y 轴刻度)的指纹:含外观样式,但**不含派生曲线参数** - //(拖弱峰调整等级/迭代次数滑块时图例与刻度不变,装饰层缓存不应失效)。 - return SceneContentHash(true, false); - } - - void PlotView::CollectSceneStyles(std::vector>& out) const - { - out.clear(); - out.resize(m_lanes.size()); - for (std::size_t laneIndex = 0; laneIndex < m_lanes.size(); ++laneIndex) - { - const std::vector& signals = Tab().Lanes()[laneIndex].signals; - std::vector& lane = out[laneIndex]; - lane.reserve(signals.size()); - for (const SignalRef& ref : signals) - { - const ResolvedSignal resolved = ResolveSignal(ref); - SceneStyleEntry entry; - if (resolved.style != nullptr) - { - entry.color = resolved.style->color; - entry.opacity = resolved.style->opacity; - entry.lineWidth = resolved.style->lineWidth; - entry.mode = static_cast(resolved.style->mode); - entry.gain = resolved.style->gain; - entry.absolute = resolved.style->absolute; - entry.mirrorVertical = resolved.style->mirrorVertical; - entry.mirrorHorizontal = resolved.style->mirrorHorizontal; - entry.offsetY = resolved.style->offsetY; - entry.offsetTime = resolved.style->offsetTime; - } - lane.push_back(entry); - } - } - } - - bool PlotView::FindStyleOnlyLanes(std::vector& lanes) const - { - lanes.clear(); - if (!m_sceneValid || m_sceneGeometryHash != SceneGeometryHash()) - { - // 网格结构 / 视图几何 / 信号集合 / 图例文案有变化:必须整幅重绘。 - return false; - } - - // ⚠️ **必须额外确认“视图几何与缓存完全一致”**:`SceneGeometryHash()` 刻意不含视界, - // 所以它相等**并不能**说明视图没变。若视界已经变了还走“只重画受影响网格”的局部补丁, - // 就会把缓存里**旧视界**的画面(时间轴、其它网格、底色)留在屏上 —— 表现为用户反馈的 - // “拖影 / 残留 / 错误元素”。因此这里逐项比较除内容指纹之外的缓存键。 - { - const SceneCacheKey key = MakeSceneCacheKey(); - if (key.viewMin != m_sceneKey.viewMin || key.viewMax != m_sceneKey.viewMax || - key.laneScroll != m_sceneKey.laneScroll || key.laneCount != m_sceneKey.laneCount || - key.areaX != m_sceneKey.areaX || key.areaY != m_sceneKey.areaY || - key.areaW != m_sceneKey.areaW || key.areaH != m_sceneKey.areaH || - key.scale != m_sceneKey.scale || key.haloGroup != m_sceneKey.haloGroup || - key.haloSignal != m_sceneKey.haloSignal) - { - return false; - } - } - - std::vector> current; - CollectSceneStyles(current); - if (current.size() != m_sceneStyles.size()) - { - return false; - } - - // 叠加预览曲线(均值滤波 / 中心轴 / 弱峰调整)的参数变化同样只需局部重画。 - std::vector currentDerived; - CollectSceneDerived(currentDerived); - const bool derivedComparable = (currentDerived.size() == m_sceneDerived.size()); - - for (std::size_t laneIndex = 0; laneIndex < current.size(); ++laneIndex) - { - const std::vector& now = current[laneIndex]; - const std::vector& before = m_sceneStyles[laneIndex]; - if (now.size() != before.size()) - { - return false; - } - bool differs = false; - for (std::size_t index = 0; index < now.size() && !differs; ++index) - { - // ⚠️ 必须与 `SceneContentHash(includeStyles) / SceneGeometryHash` 的口径**完全一致**: - // 几何哈希刻意不含外观,而外观哈希含 color/opacity/lineWidth/mode **以及** - // 系数 / 绝对值 / 镜像 / Y 偏移 / 时间偏移。旧实现只比前 4 项 ⇒ 一个网格改颜色、 - // 另一个网格同时改「调整」时,后者不会被登记为脏;随后 `RepaintSceneCachePatch` - // 又写入包含这些字段的新 `m_sceneKey`,那个网格就带着旧像素被当成“已是最新”, - // 一直残留到某次无关的失效。 - differs = !(now[index] == before[index]); - } - if (!differs && derivedComparable) - { - const SceneDerivedEntry& nowDerived = currentDerived[laneIndex]; - const SceneDerivedEntry& beforeDerived = m_sceneDerived[laneIndex]; - differs = (nowDerived.edgeCenter != beforeDerived.edgeCenter) || - (nowDerived.meanFilter != beforeDerived.meanFilter) || - (nowDerived.weakPeak != beforeDerived.weakPeak) || - (nowDerived.edgeCenterItems != beforeDerived.edgeCenterItems) || - (nowDerived.meanFilterItems != beforeDerived.meanFilterItems) || - (nowDerived.weakPeakItems != beforeDerived.weakPeakItems); - } - if (differs) - { - lanes.push_back(static_cast(laneIndex)); - } - } - return !lanes.empty(); - } - - void PlotView::CollectSceneDerived(std::vector& out) const - { - out.clear(); - out.resize(m_lanes.size()); - for (std::size_t laneIndex = 0; laneIndex < m_lanes.size(); ++laneIndex) - { - const Lane& lane = Tab().Lanes()[laneIndex]; - SceneDerivedEntry entry; - entry.edgeCenter = lane.edgeCenter ? 1 : 0; - entry.meanFilter = lane.meanFilter ? 1 : 0; - entry.weakPeak = lane.weakPeak ? 1 : 0; - const auto flatten = [](const auto& items, std::vector& target, auto valueOf) - { - target.clear(); - target.reserve(items.size() * 4); - for (const auto& item : items) - { - target.push_back(item.ref.groupIndex); - target.push_back(item.ref.signalIndex); - // 均值滤波/中心轴取 points,弱峰调整取 amountPercent(由 `valueOf` 决定)。 - target.push_back(static_cast(valueOf(item))); - target.push_back(item.iterations); - } - }; - flatten(lane.edgeCenterItems, entry.edgeCenterItems, - [](const Lane::EdgeCenterItem& item) { return item.points; }); - flatten(lane.meanFilterItems, entry.meanFilterItems, - [](const Lane::MeanFilterItem& item) { return item.points; }); - flatten(lane.weakPeakItems, entry.weakPeakItems, - [](const Lane::WeakPeakItem& item) { return item.amountPercent; }); - out[laneIndex] = std::move(entry); - } - } - - void PlotView::RepaintSceneCachePatch(gd::Graphics& g, const std::vector& lanes) - { - if (lanes.empty() || m_sceneDc == nullptr) - { - return; - } - - // 重画范围 = 受影响网格与绘图区的交集(并且不超出缓存区域)。 - float top = 1.0e9f; - float bottom = -1.0e9f; - for (const int laneIndex : lanes) - { - if (laneIndex < 0 || laneIndex >= static_cast(m_lanes.size())) - { - continue; - } - const gd::RectF& rect = m_lanes[static_cast(laneIndex)].rect; - top = std::min(top, rect.Y); - bottom = std::max(bottom, rect.Y + rect.Height); - } - if (bottom <= top) - { - return; - } - top = std::max(top, m_plotRect.Y); - bottom = std::min(bottom, m_plotRect.Y + m_plotRect.Height); - if (bottom <= top) - { - return; - } - - // ⚠️ 补丁矩形必须**向内**对齐到整数像素(ceil 上边界、floor 下边界): - // 网格矩形是浮点数,而 GDI `FillRect`/`BitBlt` 会按 floor/ceil 向外取整, - // "向外"会多擦掉/多写回一整行,那一行属于相邻网格(例如它的分界线), - // 于是补丁边界上会留下一像素接缝。向内对齐后,边界外的行一律由缓存提供 - // (缓存内容与整幅重绘逐像素一致,已由对照帧验证)。 - const int x0 = std::max(static_cast(std::ceil(m_area.X)), static_cast(std::ceil(m_area.X))); - const int y0 = std::max(static_cast(std::ceil(top)), static_cast(std::ceil(m_area.Y))); - const int x1 = std::min(static_cast(std::floor(m_area.X + m_area.Width)), - static_cast(std::floor(m_area.X + m_area.Width))); - const int y1 = std::min(static_cast(std::floor(bottom)), - static_cast(std::floor(m_area.Y + m_area.Height))); - if (x1 <= x0 || y1 <= y0) - { - return; - } - - const gd::RectF patch(static_cast(x0), static_cast(y0), - static_cast(x1 - x0), static_cast(y1 - y0)); - - // 补丁区域必须被**完整**重画:凡是与它相交的网格都要重画一遍 - // (补丁已向内对齐,正常只会命中受影响的那几个网格)。 - std::vector repaint; - for (std::size_t laneIndex = 0; laneIndex < m_lanes.size(); ++laneIndex) - { - const gd::RectF& rect = m_lanes[laneIndex].rect; - if (rect.Y < static_cast(y1) && rect.Y + rect.Height > static_cast(y0)) - { - repaint.push_back(static_cast(laneIndex)); - } - } - if (repaint.empty()) - { - return; - } - - // 与整幅重绘保持完全相同的绘制顺序:底色 → 逐网格内容(PaintLane 会与当前裁剪区求交)。 - { - const int saveState = g.Save(); - g.SetClip(patch, gd::CombineModeIntersect); - // ⚠️ 底色填充必须**按补丁矩形本身**填充,不能用 m_area / m_plotRect: - // `UiFillRect` 对大面积不透明矩形会走 GDI `FillRect` 快路径,而 GDI 绘制 - // **不受 GDI+ 裁剪区约束**,会把已经贴好的缓存内容整片覆盖掉。 - UiFillRect(g, patch, m_theme->plotBg); - const float cardTop = std::max(patch.Y, m_plotRect.Y); - const float cardBottom = std::min(patch.Y + patch.Height, m_plotRect.Y + m_plotRect.Height); - if (cardBottom > cardTop) - { - UiFillRect(g, gd::RectF(patch.X, cardTop, patch.Width, cardBottom - cardTop), - m_theme->cardBg); - } - - const double viewMin = Tab().ViewMin(); - const double viewMax = Tab().ViewMax(); - const double span = viewMax - viewMin; - const double timeStep = NiceStep((span > 0.0) ? span : 1.0, kTimeTickTarget); - std::vector timeTicks; - BuildTicks(viewMin, viewMax, timeStep, timeTicks); - - // ⚠️ **补丁必须就地绘制装饰**(而不是"跳过装饰"):补丁范围覆盖整个受影响网格 - // (含网格左缘的 Y 轴胶囊与图例),若跳过绘制,这部分像素会被网格底色/波形覆盖, - // 而补丁结果还会写回场景缓存 —— 表现为"改样式时图例/Y 轴读数闪掉一下"。 - const bool savedUseDecorCache = m_useDecorCache; - m_useDecorCache = false; - for (const int laneIndex : repaint) - { - if (laneIndex >= 0 && laneIndex < static_cast(m_lanes.size())) - { - PaintLane(g, laneIndex, m_lanes[static_cast(laneIndex)], timeTicks); - } - } - m_useDecorCache = savedUseDecorCache; - g.Restore(saveState); - } - - // 把重画结果写回缓存位图,使缓存与当前样式一致(下一帧即回到"完全命中"快路径)。 - { - HDC target = g.GetHDC(); - if (target != nullptr) - { - BitBlt(m_sceneDc, x0 - m_sceneX, y0 - m_sceneY, x1 - x0, y1 - y0, target, x0, y0, SRCCOPY); - g.ReleaseHDC(target); - } - } - - m_sceneKey = MakeSceneCacheKey(); - m_sceneGeometryHash = SceneGeometryHash(); - CollectSceneStyles(m_sceneStyles); - CollectSceneDerived(m_sceneDerived); - m_sceneDegraded = m_sceneDegraded || m_fastFrame; - } gd::RectF PlotView::LaneBadgeRect(int laneIndex) const { @@ -2250,1586 +1395,6 @@ namespace jdi } } - bool PlotView::EnsureSceneCache(int width, int height) - { - if (width <= 0 || height <= 0) - { - return false; - } - // 位图尺寸必须与当前缓存区域**严格一致**:贴图时按位图尺寸整块拷贝, - // 若沿用更大的旧位图,缩小窗口后会把旧像素画到绘图区之外。 - if (m_sceneDc != nullptr && m_sceneWidth == width && m_sceneHeight == height) - { - return true; - } - - ReleaseSceneCache(); - // 场景位图重建 → 面板图层里缓存的"面板背后的场景"随之失效。 - m_panelValid = false; - - HDC screen = GetDC(nullptr); - if (screen == nullptr) - { - return false; - } - m_sceneDc = CreateCompatibleDC(screen); - m_sceneBitmap = CreateCompatibleBitmap(screen, width, height); - ReleaseDC(nullptr, screen); - - if (m_sceneDc == nullptr || m_sceneBitmap == nullptr) - { - ReleaseSceneCache(); - return false; - } - m_sceneOldBitmap = SelectObject(m_sceneDc, m_sceneBitmap); - m_sceneWidth = width; - m_sceneHeight = height; - return true; - } - - bool PlotView::CaptureSceneCache(gd::Graphics& g) - { - // 缓存区域严格限定为绘图区本身(m_area):工具栏/标签栏/面板由宿主每帧绘制, - // 因为它们各自带有悬停态;绘图区的波形才是真正昂贵的部分(大文件约 80ms/帧)。 - const int x = static_cast(std::floor(m_area.X)); - const int y = static_cast(std::floor(m_area.Y)); - const int right = static_cast(std::ceil(m_area.X + m_area.Width)); - const int bottom = static_cast(std::ceil(m_area.Y + m_area.Height)); - const int width = right - x; - const int height = bottom - y; - if (width <= 0 || height <= 0) - { - m_sceneValid = false; - return false; - } - - m_sceneX = x; - m_sceneY = y; - - if (!EnsureSceneCache(width, height)) - { - m_sceneValid = false; - return false; - } - - HDC source = g.GetHDC(); - if (source == nullptr) - { - m_sceneValid = false; - return false; - } - const BOOL ok = BitBlt(m_sceneDc, 0, 0, width, height, source, m_sceneX, m_sceneY, SRCCOPY); - g.ReleaseHDC(source); - - if (ok == FALSE) - { - m_sceneValid = false; - return false; - } - - m_sceneKey = MakeSceneCacheKey(); - m_sceneGeometryHash = SceneGeometryHash(); - CollectSceneStyles(m_sceneStyles); - CollectSceneDerived(m_sceneDerived); - m_sceneDegraded = false; - m_sceneValid = true; - return true; - } - - bool PlotView::BlitSceneCache(gd::Graphics& g) - { - if (!m_sceneValid || m_sceneDc == nullptr) - { - return false; - } - - HDC target = g.GetHDC(); - if (target == nullptr) - { - return false; - } - const BOOL ok = BitBlt(target, m_sceneX, m_sceneY, m_sceneWidth, m_sceneHeight, m_sceneDc, 0, 0, - SRCCOPY); - g.ReleaseHDC(target); - return ok != FALSE; - } - - void PlotView::InvalidateSceneCache() - { - m_sceneValid = false; - m_sceneStyles.clear(); - m_sceneDerived.clear(); - m_sceneDegraded = false; - m_sceneGeometryHash = 0; - // 面板像素图层与场景缓存同源(面板像素里含"面板背后的场景"): - // 信号增删、重命名、参数变化等一律连带作废,避免面板里残留旧波形。 - m_panelValid = false; - } - - void PlotView::ReleaseSceneCache() - { - m_sceneValid = false; - if (m_sceneDc != nullptr) - { - if (m_sceneOldBitmap != nullptr) - { - SelectObject(m_sceneDc, m_sceneOldBitmap); - m_sceneOldBitmap = nullptr; - } - DeleteDC(m_sceneDc); - m_sceneDc = nullptr; - } - if (m_sceneBitmap != nullptr) - { - DeleteObject(m_sceneBitmap); - m_sceneBitmap = nullptr; - } - m_sceneWidth = 0; - m_sceneHeight = 0; - } - - bool PlotView::EnsureDecorCache(int width, int height) - { - if (width <= 0 || height <= 0) - { - return false; - } - if (m_decorDc != nullptr && m_decorBits != nullptr && m_decorW == width && m_decorH == height) - { - return true; - } - ReleaseDecorCache(); - HDC screen = GetDC(nullptr); - if (screen == nullptr) - { - return false; - } - // ⚠️ **必须用 32bpp DIB 段**(而不是 `CreateCompatibleBitmap`): - // 装饰要带真 alpha(圆角之外全透明),才能用 `AlphaBlend` 合成到波形之上; - // 同时 DIB 段的像素可直接写(用于 alpha 修补与清空)。 - BITMAPINFO info = {}; - info.bmiHeader.biSize = sizeof(BITMAPINFOHEADER); - info.bmiHeader.biWidth = width; - info.bmiHeader.biHeight = -height; // 自上而下 - info.bmiHeader.biPlanes = 1; - info.bmiHeader.biBitCount = 32; - info.bmiHeader.biCompression = BI_RGB; - void* bits = nullptr; - HBITMAP dib = CreateDIBSection(screen, &info, DIB_RGB_COLORS, &bits, nullptr, 0); - ReleaseDC(nullptr, screen); - if (dib == nullptr || bits == nullptr) - { - if (dib != nullptr) - { - DeleteObject(dib); - } - return false; - } - HDC dc = CreateCompatibleDC(nullptr); - if (dc == nullptr) - { - DeleteObject(dib); - return false; - } - m_decorOldBitmap = SelectObject(dc, dib); - m_decorDc = dc; - m_decorBitmap = dib; - m_decorBits = bits; - m_decorW = width; - m_decorH = height; - return true; - } - - void PlotView::ReleaseDecorCache() - { - m_decorValid = false; - m_decorCapturedRects.clear(); - if (m_decorDc != nullptr) - { - if (m_decorOldBitmap != nullptr) - { - SelectObject(m_decorDc, m_decorOldBitmap); - m_decorOldBitmap = nullptr; - } - DeleteDC(m_decorDc); - m_decorDc = nullptr; - } - if (m_decorBitmap != nullptr) - { - DeleteObject(m_decorBitmap); - m_decorBitmap = nullptr; - } - m_decorBits = nullptr; - m_decorW = 0; - m_decorH = 0; - } - - void* PlotView::ResolveAlphaBlend() - { - // `AlphaBlend` 由 msimg32 导出、并由 gdi32 的 `GdiAlphaBlend` 实现; - // 两个来源都试一遍,取到即缓存(静态局部变量,仅解析一次)。 - static void* cached = nullptr; - static bool resolved = false; - if (resolved) - { - return cached; - } - resolved = true; - HMODULE gdi = GetModuleHandleW(L"gdi32.dll"); - if (gdi != nullptr) - { - cached = reinterpret_cast(GetProcAddress(gdi, "GdiAlphaBlend")); - } - if (cached == nullptr) - { - HMODULE msimg = LoadLibraryW(L"msimg32.dll"); - if (msimg != nullptr) - { - cached = reinterpret_cast(GetProcAddress(msimg, "AlphaBlend")); - } - } - return cached; - } - - bool PlotView::RenderDecorCache() - { - m_decorValid = false; - if (m_theme == nullptr || m_fonts == nullptr || m_lanes.empty()) - { - return false; - } - // 位图覆盖「客户区原点 → 绘图区右下角」:绘制坐标与正常绘制完全一致, - // 不需要任何平移(GDI 文本不认 GDI+ 变换,平移会让文字错位)。 - const int width = static_cast(std::ceil(m_area.X + m_area.Width)); - const int height = static_cast(std::ceil(m_area.Y + m_area.Height)); - if (!EnsureDecorCache(width, height)) - { - return false; - } - // 清空为全透明(圆角之外必须保持透明,贴回时才不会盖住波形)。 - std::memset(m_decorBits, 0, static_cast(width) * static_cast(height) * 4u); - - m_decorRects.clear(); - { - gd::Graphics g(m_decorDc); - g.SetSmoothingMode(gd::SmoothingModeAntiAlias); - g.SetPixelOffsetMode(gd::PixelOffsetModeHalf); - g.SetTextRenderingHint(gd::TextRenderingHintClearTypeGridFit); - - const bool savedUse = m_useDecorCache; - m_useDecorCache = false; // 本次是"真正绘制":不允许走"跳过绘制"的分支 - m_renderDecorOnly = true; // 只画装饰(跳过网格线与波形) - const std::vector noTicks; - for (std::size_t index = 0; index < m_lanes.size(); ++index) - { - PaintLane(g, static_cast(index), m_lanes[index], noTicks); - } - m_renderDecorOnly = false; - m_useDecorCache = savedUse; - } - - // ⚠️ **alpha 修补**:GDI 的文本绘制(`DrawTextW`)会把它写入的像素 alpha 清成 0 - // (GDI 不维护 alpha),若原样保留,贴上后文字位置会变成"透明洞"(透出波形)。 - // 在这里把装饰矩形内"有颜色但 alpha 为 0"的像素还原为不透明即可 —— 装饰本身 - // 是画在不透明底板上的,这些像素本来就该不透明。 - for (const gd::RectF& rect : m_decorRects) - { - const int x0 = std::max(0, static_cast(std::floor(rect.X))); - const int y0 = std::max(0, static_cast(std::floor(rect.Y))); - const int x1 = std::min(width, static_cast(std::ceil(rect.X + rect.Width))); - const int y1 = std::min(height, static_cast(std::ceil(rect.Y + rect.Height))); - for (int y = y0; y < y1; ++y) - { - unsigned char* row = static_cast(m_decorBits) + - (static_cast(y) * static_cast(width)) * 4u; - for (int x = x0; x < x1; ++x) - { - unsigned char* p = row + static_cast(x) * 4u; - if (p[3] == 0 && (p[0] | p[1] | p[2]) != 0) - { - p[3] = 255; - } - } - } - } - - m_decorCapturedRects = m_decorRects; - m_decorValid = true; - return true; - } - - bool PlotView::BlitDecorCache(gd::Graphics& g) - { - if (!m_decorValid || m_decorDc == nullptr || m_decorCapturedRects.empty()) - { - return false; - } - void* proc = ResolveAlphaBlend(); - if (proc == nullptr) - { - return false; // 极小概率:系统不提供 AlphaBlend(贴回会带透明洞,故直接放弃) - } - using AlphaBlendFn = BOOL(WINAPI*)(HDC, int, int, int, int, HDC, int, int, int, int, BLENDFUNCTION); - AlphaBlendFn blend = reinterpret_cast(proc); - HDC target = g.GetHDC(); - if (target == nullptr) - { - return false; - } - // 装饰位图原点 = 客户区 (0,0):源坐标与目标坐标**相同**。 - BLENDFUNCTION fn = {}; - fn.BlendOp = AC_SRC_OVER; - fn.SourceConstantAlpha = 255; - fn.AlphaFormat = AC_SRC_ALPHA; // 源为**预乘** alpha(GDI+ 写入 DIB 段即预乘) - bool any = false; - for (const gd::RectF& rect : m_decorCapturedRects) - { - const int x = static_cast(std::floor(rect.X)); - const int y = static_cast(std::floor(rect.Y)); - const int w = static_cast(std::ceil(rect.X + rect.Width)) - x; - const int h = static_cast(std::ceil(rect.Y + rect.Height)) - y; - if (w <= 0 || h <= 0) - { - continue; - } - if (blend(target, x, y, w, h, m_decorDc, x, y, w, h, fn) != FALSE) - { - any = true; - } - } - g.ReleaseHDC(target); - return any; - } - - bool PlotView::EnsureLaneLayer(LaneLayer& layer, int width, int height) - { - if (width <= 0 || height <= 0) - { - return false; - } - if (layer.dc != nullptr && layer.width == width && layer.height == height) - { - return true; - } - ReleaseLaneLayer(layer); - HDC screen = GetDC(nullptr); - if (screen == nullptr) - { - return false; - } - layer.dc = CreateCompatibleDC(screen); - layer.bitmap = CreateCompatibleBitmap(screen, width, height); - ReleaseDC(nullptr, screen); - if (layer.dc == nullptr || layer.bitmap == nullptr) - { - ReleaseLaneLayer(layer); - return false; - } - layer.oldBitmap = SelectObject(layer.dc, layer.bitmap); - layer.width = width; - layer.height = height; - layer.valid = false; - return true; - } - - void PlotView::ReleaseLaneLayer(LaneLayer& layer) - { - layer.valid = false; - if (layer.dc != nullptr) - { - if (layer.oldBitmap != nullptr) - { - SelectObject(layer.dc, layer.oldBitmap); - layer.oldBitmap = nullptr; - } - DeleteDC(layer.dc); - layer.dc = nullptr; - } - if (layer.bitmap != nullptr) - { - DeleteObject(layer.bitmap); - layer.bitmap = nullptr; - } - layer.width = 0; - layer.height = 0; - } - - void PlotView::ReleaseWideBuffer() - { - std::lock_guard lock(m_layerMutex); - for (LaneLayer& layer : m_layerFront) - { - ReleaseLaneLayer(layer); - } - m_layerFront.clear(); - m_layerRebuildPending.clear(); - } - - void PlotView::InvalidateWideBuffer() - { - std::lock_guard lock(m_layerMutex); - for (LaneLayer& layer : m_layerFront) - { - layer.valid = false; - } - m_layerRebuildPending.clear(); - } - - std::uint64_t PlotView::LaneLayerKey(int laneIndex) const - { - // 图层只含基底(不含派生曲线与装饰),因此指纹 = **该网格自己的内容** - // ⊕ 网格下标 ⊕ 视界跨度 ⊕ 绘图区宽度(决定像素/时间与位图尺寸)。 - const double span = Tab().ViewMax() - Tab().ViewMin(); - std::uint64_t key = LaneLayerHash(laneIndex); - key ^= static_cast(laneIndex + 1) * 1315423911ull; - key *= 1099511628211ull; - key ^= static_cast(std::llround(span * 1.0e6)); - key *= 1099511628211ull; - key ^= static_cast(std::llround(m_plotRect.Width)); - return key; - } - - std::uint64_t PlotView::LaneLayerHash(int laneIndex) const - { - // 单个网格图层的内容 = 位图高度(= 网格高度)+ 幅值**跨度** + 本网格各信号的 - // 可见性与外观。 - // ⚠️ 图例与 GX 徽标**不在图层里**(它们是装饰层,每帧单独绘制),因此也**不在** - // 本指纹里;改信号名不需要重建图层。 - // - // ⚠️ **只计跨度、不计幅值的绝对范围**:图层的纵向锚点(`anchorYMax`)与 `wideMin` - // 一样是"贴图偏移"的来源,幅值整体平移不改变图层内容(只是贴图时纵向偏移若干像素), - // 因此纵向拖动不会让图层失效。真正改变内容的是**跨度**(决定像素/数值与刻度步长)。 - std::uint64_t hash = 1469598103934665603ull; - const auto mix = [&hash](std::uint64_t value) - { - hash ^= value; - hash *= 1099511628211ull; - }; - const auto mixFloat = [&mix](float value) - { - std::uint32_t bits = 0; - std::memcpy(&bits, &value, sizeof(bits)); - mix(bits); - }; - if (laneIndex < 0 || laneIndex >= static_cast(m_lanes.size())) - { - return hash; - } - const LaneVisual& visual = m_lanes[static_cast(laneIndex)]; - mixFloat(visual.rect.Height); - mixFloat(static_cast(visual.yMax - visual.yMin)); - const std::vector& signals = Tab().Lanes()[static_cast(laneIndex)].signals; - mix(signals.size()); - for (const SignalRef& ref : signals) - { - const ResolvedSignal resolved = ResolveSignal(ref); - mix(static_cast(ref.groupIndex)); - mix(static_cast(ref.signalIndex)); - mix(resolved.visible ? 1ull : 0ull); - if (resolved.data == nullptr || resolved.style == nullptr) - { - continue; - } - mix(static_cast(resolved.style->color)); - mixFloat(resolved.style->opacity); - mixFloat(resolved.style->lineWidth); - mix(static_cast(resolved.style->mode)); - // 「调整」变换会改变图层里的波形,也必须让图层失效 - //(否则贴回的旧像素会与当前样式不一致)。 - mixFloat(resolved.style->gain); - mix(resolved.style->absolute ? 1ull : 0ull); - mix(resolved.style->mirrorVertical ? 1ull : 0ull); - mix(resolved.style->mirrorHorizontal ? 1ull : 0ull); - mixFloat(resolved.style->offsetY); - mixFloat(static_cast(resolved.style->offsetTime)); - } - return hash; - } - - int PlotView::LayerMarginX() const - { - const int width = static_cast(std::lround(m_plotRect.Width)); - return std::max(1, width / 2); // 左右各半屏 - } - - int PlotView::LayerMarginY(float laneHeight) const - { - // 上下各半个网格高:一整屏的额外内容已足够覆盖实际拖拽范围, - // 又不至于让每个横向补条 / 整块重画按三倍高度放大(横向平移因此不受影响)。 - const int half = static_cast(std::lround(static_cast(laneHeight) * 0.5)); - return std::max(1, half); - } - - double PlotView::LayerDefaultWideMin() const - { - // 以当前视界为中心:位图左右各留半个视界宽,因此可向两侧平移 ±半屏。 - const double span = Tab().ViewMax() - Tab().ViewMin(); - const int width = static_cast(std::lround(m_plotRect.Width)); - if (!(span > 0.0) || width <= 0) - { - return Tab().ViewMin(); - } - const double scale = static_cast(width) / span; - return Tab().ViewMin() - static_cast(LayerMarginX()) / scale; - } - - bool PlotView::LayersStructurallyReady() const - { - std::lock_guard lock(m_layerMutex); - if (m_layerFront.size() != m_lanes.size() || m_layerFront.empty()) - { - return false; - } - const float plotWidth = m_plotRect.Width; - const double span = Tab().ViewMax() - Tab().ViewMin(); - if (!(plotWidth > 8.0f) || !(span > 0.0)) - { - return false; - } - const int width = static_cast(std::lround(plotWidth)); - const int bitmapWidth = width + 2 * LayerMarginX(); - for (std::size_t index = 0; index < m_layerFront.size(); ++index) - { - const LaneLayer& layer = m_layerFront[index]; - if (!layer.valid || layer.dc == nullptr || layer.width != bitmapWidth) - { - return false; - } - // 跨度必须一致:跨度变了像素/时间的映射就不同,旧像素不能再当“坐标偏移”用。 - if (std::fabs(span - layer.span) > 1.0e-12) - { - return false; - } - // 高度也要一致:网格高度变化会让贴图纵向错位(含上下预渲染余量)。 - const float laneHeight = m_lanes[index].rect.Height; - const int bitmapHeight = - static_cast(std::ceil(laneHeight)) + 2 * LayerMarginY(laneHeight); - if (layer.height != bitmapHeight) - { - return false; - } - } - return true; - } - - bool PlotView::LayersAllocated() const - { - std::lock_guard lock(m_layerMutex); - return !m_layerFront.empty() && m_layerFront.size() == m_lanes.size(); - } - - bool PlotView::LaneLayerReady(int laneIndex) const - { - if (laneIndex < 0 || laneIndex >= static_cast(m_lanes.size())) - { - return false; - } - std::lock_guard lock(m_layerMutex); - if (laneIndex >= static_cast(m_layerFront.size())) - { - return false; - } - const LaneLayer& layer = m_layerFront[static_cast(laneIndex)]; - const float plotWidth = m_plotRect.Width; - const double span = Tab().ViewMax() - Tab().ViewMin(); - if (!(plotWidth > 8.0f) || !(span > 0.0)) - { - return false; - } - const int width = static_cast(std::lround(plotWidth)); - const int bitmapWidth = width + 2 * LayerMarginX(); - const float laneHeight = m_lanes[static_cast(laneIndex)].rect.Height; - const int bitmapHeight = static_cast(std::ceil(laneHeight)) + 2 * LayerMarginY(laneHeight); - if (!layer.valid || layer.dc == nullptr || layer.width != bitmapWidth || - layer.height != bitmapHeight || std::fabs(layer.span - span) > 1.0e-12) - { - return false; - } - return layer.key == LaneLayerKey(laneIndex); - } - - bool PlotView::LanesAllReadyForView() const - { - if (m_lanes.empty()) - { - return false; - } - for (std::size_t index = 0; index < m_lanes.size(); ++index) - { - if (!LaneLayerReady(static_cast(index))) - { - return false; - } - } - return true; - } - - bool PlotView::LaneVisuallyRelevant(int laneIndex) const - { - return LaneVisuallyRelevant(laneIndex, 0); - } - - bool PlotView::LaneVisuallyRelevant(int laneIndex, int prefetchScreens) const - { - if (laneIndex < 0 || laneIndex >= static_cast(m_lanes.size())) - { - return false; - } - if (!(m_plotRect.Height > 0.0f)) - { - return false; - } - const gd::RectF& rect = m_lanes[static_cast(laneIndex)].rect; - // 可视判定本身留一个网格高的余量(即将滚入视区的网格也算相关); - // 预取带在其上再按屏高放大(见头文件说明)。 - const float margin = std::max(rect.Height, 0.0f) + - static_cast(std::max(0, prefetchScreens)) * m_plotRect.Height; - return (rect.Y + rect.Height + margin > m_plotRect.Y) && - (rect.Y - margin < m_plotRect.Y + m_plotRect.Height); - } - - bool PlotView::LanesVisiblyReadyForView() const - { - if (m_lanes.empty()) - { - return false; - } - bool anyRelevant = false; - for (std::size_t index = 0; index < m_lanes.size(); ++index) - { - if (!LaneVisuallyRelevant(static_cast(index))) - { - continue; - } - anyRelevant = true; - if (!LaneLayerReady(static_cast(index))) - { - return false; - } - } - return anyRelevant; - } - - bool PlotView::AnyVisibleLanePending() const - { - // ⚠️ 用 **±1 屏预取带**(而不是“严格可见”):网格滚动动画会在几百毫秒内扫过一整个屏高, - // 只预建严格可见的网格时,动画扫进去的每一帧都要靠底衬兜底重画十几份网格 - // (实测 ~8ms/帧 ⇒ 动画明显卡顿)。预取带让静置期间就把附近网格的图层建好。 - for (const int laneIndex : m_layerRebuildPending) - { - if (LaneVisuallyRelevant(laneIndex, 1)) - { - return true; - } - } - return false; - } - - void PlotView::QueueLaneRebuild(int laneIndex) - { - if (laneIndex < 0 || laneIndex >= static_cast(m_lanes.size())) - { - return; - } - for (const int existing : m_layerRebuildPending) - { - if (existing == laneIndex) - { - return; // 已经在队列里 - } - } - m_layerRebuildPending.push_back(laneIndex); - } - - bool PlotView::RenderPendingLaneRebuilds(int budget) - { - if (m_layerRebuildPending.empty() || budget <= 0) - { - return !m_layerRebuildPending.empty(); - } - // ⚠️ **优先补画可视网格**:队列是按下标顺序登记的,若严格 FIFO,用户正在看的那几个 - // 网格(往往位于列表下半部)可能要等上百帧才轮到,期间画面一直停留在兜底状态 - //(用户报告:缩放 / 折起后“需要等一会才正确加载”)。 - // 做一次**稳定划分**:可视网格排到队首,其余保持原有相对顺序。 - std::stable_partition(m_layerRebuildPending.begin(), m_layerRebuildPending.end(), - [this](int laneIndex) { return LaneVisuallyRelevant(laneIndex); }); - const double defaultWideMin = LayerDefaultWideMin(); - // 本批要重画的网格:`budget` 足够大时一次取完(视界已停时调用方会传很大的预算)。 - // ⚠️ **离屏网格一律丢弃**(不重画、也不留在队列里):它们的像素当前不可见, - // 而单个巨型信号的图层重建要 10~30ms。真正进入可视/预取带时 `RollWideBuffer` - // 会按新视界重新登记它们(见那里的说明)。历史实现会排空整个队列 ⇒ 勾选一个分组后 - // 连续数秒每帧 10~30ms(用户 2026-10-18 报告的列表动画卡顿)。 - m_layerRebuildPending.erase( - std::remove_if(m_layerRebuildPending.begin(), m_layerRebuildPending.end(), - [this](int laneIndex) { return !LaneVisuallyRelevant(laneIndex, 1); }), - m_layerRebuildPending.end()); - if (m_layerRebuildPending.empty()) - { - return false; - } - const int available = static_cast(m_layerRebuildPending.size()); - const int take = std::min(budget, available); - std::vector batch; - batch.reserve(static_cast(take)); - for (int index = 0; index < take; ++index) - { - const int laneIndex = m_layerRebuildPending[static_cast(index)]; - if (laneIndex >= 0 && laneIndex < static_cast(m_lanes.size())) - { - batch.push_back(laneIndex); - } - } - m_layerRebuildPending.erase(m_layerRebuildPending.begin(), - m_layerRebuildPending.begin() + take); - - if (batch.size() > 1) - { - // **多网格一次补齐**:并行渲染(各网格用隔离上下文),比"一帧只补 1 个"收敛快 N 倍。 - RebuildLaneLayersParallel(batch, defaultWideMin); - } - else - { - for (const int laneIndex : batch) - { - // 加锁顺序与前台一致:先 `m_renderMutex` 再 `m_layerMutex`。 - std::lock_guard renderLock(m_renderMutex); - std::lock_guard layerLock(m_layerMutex); - if (m_layerFront.size() != m_lanes.size()) - { - break; - } - LaneLayer& layer = m_layerFront[static_cast(laneIndex)]; - // ⚠️ **跨度过期(缩放瞬间)也在这里就地整块重画**(按**新跨度**)。 - // 旧实现遇到跨度不符就 `continue`(把该网格推给"下一次整批重建"), - // 而"整批重建"正是那个 70~80ms 的同步卡顿源;现在改为一帧补一个网格, - // 其余网格由 `BlitWideBuffer` 的占位拉伸顶着 → 前台永不因图层未就绪而滞后。 - // 整块重画:整个图层都对齐到"当前 `yMax`"(锚点 = 当前 yMax)。 - if (!RenderLaneLayerRegion(laneIndex, layer, 0, 0, 0, 0, defaultWideMin, - m_lanes[static_cast(laneIndex)].yMax)) - { - continue; - } - } - } - return !m_layerRebuildPending.empty(); - } - - void PlotView::EnsureLayerWorkers() - { - unsigned int hardware = std::thread::hardware_concurrency(); - const int want = std::max(1, std::min(static_cast(hardware == 0 ? 2u : hardware), 4)); - if (m_layerWorkerCount == want && static_cast(m_layerWorkers.size()) == want) - { - // 字体 / 主题 / 缩放 / 数据源可能已变化:刷新一次绑定(不重建线程上下文)。 - for (int index = 0; index < want; ++index) - { - m_layerWorkers[static_cast(index)]->Setup(m_fonts, m_theme); - m_layerWorkers[static_cast(index)]->SetScale(m_scale); - m_layerWorkers[static_cast(index)]->m_dataSet = m_dataSet; - m_layerWorkers[static_cast(index)]->m_dataSetWritable = m_dataSetWritable; - } - return; - } - ReleaseLayerWorkers(); - m_layerWorkers.reserve(static_cast(want)); - for (int index = 0; index < want; ++index) - { - auto worker = std::make_unique(); - worker->Setup(m_fonts, m_theme); - worker->SetScale(m_scale); - // ⚠️ `m_tab` 置空 → worker 的 `Tab()` 返回它**自带的** `m_localTab` 副本, - // 于是它改写视界 / 网格矩形时不会碰到共享的标签页配置(安全并行的前提)。 - worker->m_dataSet = m_dataSet; - worker->m_dataSetWritable = m_dataSetWritable; - // ⚠️ `m_tab` **必须非空**(`BuildLaneVisuals()` 在空时会清空 `m_lanes`); - // 指向 worker **自有**的 `m_workerTab`,这样它改写视界 / 网格矩形时不会碰到共享标签页。 - worker->m_tab = &worker->m_workerTab; - worker->RefreshLanes(); - m_layerWorkers.push_back(std::move(worker)); - } - m_layerWorkerCount = want; - } - - void PlotView::ReleaseLayerWorkers() - { - m_layerWorkers.clear(); - m_layerWorkerCount = 0; - } - - void PlotView::RebuildLaneLayersParallel(const std::vector& laneIndices, double wideMin) - { - if (laneIndices.empty() || m_tab == nullptr || m_dataSet == nullptr) - { - return; - } - // 单网格 / 单核:直接顺序走既有路径(避免线程开销)。 - unsigned int hardware = std::thread::hardware_concurrency(); - if (laneIndices.size() <= 1 || (hardware == 0 ? 2u : hardware) <= 1) - { - std::lock_guard renderLock(m_renderMutex); - std::lock_guard layerLock(m_layerMutex); - if (m_layerFront.size() != m_lanes.size()) - { - return; - } - for (const int laneIndex : laneIndices) - { - if (laneIndex < 0 || laneIndex >= static_cast(m_lanes.size())) - { - continue; - } - RenderLaneLayerRegion(laneIndex, m_layerFront[static_cast(laneIndex)], 0, 0, 0, - 0, wideMin, m_lanes[static_cast(laneIndex)].yMax); - } - return; - } - - { - std::lock_guard renderLock(m_renderMutex); - std::lock_guard layerLock(m_layerMutex); - if (m_layerFront.size() != m_lanes.size()) - { - return; - } - } - EnsureLayerWorkers(); - if (m_layerWorkerCount <= 1) - { - return; // 保守回退:让调用方走顺序路径 - } - - // 主线程**先快照**工作线程需要的一切(之后它们只读,不再触碰共享状态)。 - const TabConfig tabSnapshot = *m_tab; - const gd::RectF plotRectSnapshot = m_plotRect; - const gd::RectF areaSnapshot = m_area; - const float scaleSnapshot = m_scale; - const std::vector laneSnapshot = m_lanes; - // 目标图层位图数组(已确认尺寸与网格数一致,线程内不会扩容)。 - std::vector* const front = &m_layerFront; - - const int workerCount = m_layerWorkerCount; - std::atomic next{0}; - std::vector threads; - threads.reserve(static_cast(workerCount)); - for (int workerIndex = 0; workerIndex < workerCount; ++workerIndex) - { - threads.emplace_back([&, workerIndex]() - { - PlotView* worker = m_layerWorkers[static_cast(workerIndex)].get(); - for (;;) - { - const std::size_t slot = next.fetch_add(1, std::memory_order_relaxed); - if (slot >= laneIndices.size()) - { - break; - } - const int laneIndex = laneIndices[slot]; - if (laneIndex < 0 || laneIndex >= static_cast(laneSnapshot.size()) || - laneIndex >= static_cast(tabSnapshot.Lanes().size())) - { - continue; - } - // 隔离上下文:自带标签页副本 + 只含该网格 + 只读的数据源 / 字体 / 主题。 - worker->m_workerTab = tabSnapshot; - worker->m_workerTab.Lanes() = {tabSnapshot.Lanes()[static_cast(laneIndex)]}; - worker->m_tab = &worker->m_workerTab; - worker->m_fonts = m_fonts; - worker->m_theme = m_theme; - worker->m_scale = scaleSnapshot; - worker->m_dataSet = m_dataSet; - worker->m_dataSetWritable = m_dataSetWritable; - worker->m_area = areaSnapshot; - worker->m_plotRect = plotRectSnapshot; - worker->m_lanes = {laneSnapshot[static_cast(laneIndex)]}; - // ⚠️ 工作线程用**自己的** `m_workerTab` / `m_area` / `m_plotRect` / `m_lanes` 渲染, - // 因此不会与 UI 线程或其它工作线程互相覆盖渲染状态(这正是 2026-10-12 移除 - // "后台图层线程"的根因)。目标图层位图按 `laneIndex` 各自不同,可并发写入。 - worker->RenderLaneLayerRegion(0, (*front)[static_cast(laneIndex)], 0, 0, 0, 0, - wideMin, - laneSnapshot[static_cast(laneIndex)].yMax); - } - }); - } - for (std::thread& thread : threads) - { - if (thread.joinable()) - { - thread.join(); - } - } - - // 合并(主线程):工作线程内部以 `laneIndex = 0` 渲染,因此图层内容指纹要按**真实网格下标**重算。 - std::lock_guard layerLock(m_layerMutex); - if (m_layerFront.size() != m_lanes.size()) - { - return; - } - std::uint64_t rendered = 0; - for (const int laneIndex : laneIndices) - { - if (laneIndex < 0 || laneIndex >= static_cast(m_layerFront.size())) - { - continue; - } - LaneLayer& target = m_layerFront[static_cast(laneIndex)]; - if (!target.valid) - { - continue; - } - target.key = LaneLayerKey(laneIndex); - ++rendered; - } - m_layerRenderCount.fetch_add(rendered, std::memory_order_relaxed); - } - - bool PlotView::RenderLaneLayerRegion(int laneIndex, LaneLayer& layer, int stripX, int stripW, - int stripY, int stripH, double wideMin, double anchorYMax) - { - if (laneIndex < 0 || laneIndex >= static_cast(m_lanes.size())) - { - return false; - } - std::lock_guard renderLock(m_renderMutex); - - const float plotWidth = m_plotRect.Width; - const double span = Tab().ViewMax() - Tab().ViewMin(); - if (!(plotWidth > 8.0f) || !(span > 0.0)) - { - layer.valid = false; - return false; - } - - const int width = static_cast(std::lround(plotWidth)); - const int margin = LayerMarginX(); // 左右各半屏 → 可平移 ±半屏 - const int bitmapWidth = width + 2 * margin; - const float laneHeight = m_lanes[static_cast(laneIndex)].rect.Height; - const int lanePixelHeight = static_cast(std::ceil(laneHeight)); - const int marginY = LayerMarginY(laneHeight); // 上下各半个网格高 → 幅值可平移 ±半个网格高 - const int bitmapHeight = lanePixelHeight + 2 * marginY; - if (bitmapHeight <= 0 || !EnsureLaneLayer(layer, bitmapWidth, bitmapHeight)) - { - return false; - } - - const double scale = static_cast(width) / span; // 像素 / 时间 - const double laneSpan = m_lanes[static_cast(laneIndex)].yMax - - m_lanes[static_cast(laneIndex)].yMin; - const double pxPerValue = (laneSpan > 0.0) ? static_cast(laneHeight) / laneSpan : 0.0; - - if (stripW <= 0 || stripX < 0 || stripX + stripW > bitmapWidth) - { - stripX = 0; - stripW = bitmapWidth; - } - // 纵向同理:`stripH <= 0` 表示纵向整块(横向滚动的细条就是这种情形)。 - if (stripH <= 0 || stripY < 0 || stripY + stripH > bitmapHeight) - { - stripY = 0; - stripH = bitmapHeight; - } - - // 保存当前布局 / 视图 / 网格,临时切换到"宽窗口"映射后离屏渲染,再恢复。 - const gd::RectF savedArea = m_area; - const gd::RectF savedPlot = m_plotRect; - const double savedViewMin = Tab().ViewMin(); - const double savedViewMax = Tab().ViewMax(); - const std::vector savedLanes = m_lanes; - const float savedLaneContentHeight = m_laneContentHeight; - const bool savedFastFrame = m_fastFrame; - const bool savedRenderWide = m_renderWide; - const float savedScroll = Tab().LaneScroll(); - const double savedWideTickSpan = m_wideTickSpan; - const float savedWideTickHeight = m_wideTickHeight; - const double savedRenderTimeStep = m_renderTimeStep; - - m_plotRect = gd::RectF(0.0f, 0.0f, static_cast(bitmapWidth), - static_cast(bitmapHeight)); - // 只把 `m_area` 限为待渲染的那一段 x 区间,使 GDI/GDI+ 的裁剪只落在它上面; - // x 坐标仍用**整条宽窗口**的映射(`m_plotRect` 为完整宽图),因此网格线位置与整块图层一致。 - m_area = gd::RectF(static_cast(stripX), static_cast(stripY), - static_cast(stripW), static_cast(stripH)); - // 横向补条(窄竖带):把折线的构建与描边裁剪到条带的 x 范围(见 `m_stripRenderActive`)。 - // 纵向补条(整宽 × 窄 y)不裁剪 x —— 那一方向本来就需要整条折线的顶点。 - const bool stripIsHorizontalBand = (static_cast(stripW) < static_cast(bitmapWidth) - 0.5f); - const bool savedStripActive = m_stripRenderActive; - const float savedStripX0 = m_stripX0; - const float savedStripX1 = m_stripX1; - m_stripRenderActive = stripIsHorizontalBand; - m_stripX0 = static_cast(stripX); - m_stripX1 = static_cast(stripX + stripW); - Tab().ViewMin() = wideMin; - Tab().ViewMax() = wideMin + static_cast(bitmapWidth) / scale; - - m_renderWide = true; // 图层只含基底:跳过派生曲线与装饰 - // 图层必须与"同一视界的整幅渲染"**逐像素同源**:强制全质量抗锯齿(不受拖拽快速帧影响)。 - m_fastFrame = false; - // 幅值刻度的参考跨度 / 参考高度取**前台可见网格**的值(而不是含上下余量的画布值): - // 这样图层里的横向网格线步长与前台一致,纵向平移才是纯粹的像素平移。 - m_wideTickSpan = laneSpan; - m_wideTickHeight = laneHeight; - // ⚠️⚠️ **不要在这里再调 `BuildLaneVisuals()`**:它会对**全部网格**重算 - // `ComputeLaneRange`(O(网格数)),而本函数只需要**被渲染的那一个网格**。 - // 253 个网格、30 个可见网格时,每帧 30 次补条 × 2 次 BuildLaneVisuals - // = 单帧 **26.8ms**(用户 2026-10-17 报告:三明治加载后拖拽视界卡顿)。 - // 该网格的矩形/幅值范围已在下方显式设置(`m_lanes` 其余成员保持原值不变)。 - // ⚠️ **该网格的幅值映射必须以本次渲染的纵向锚点为准**(而不是"当前 `yMax`"): - // - 整块重画 / 纵向补条:锚点就是当前 `yMax`(纵向补条是滚动后的值),二者一致; - // - **横向补细条**:只重画一条竖带,其余像素仍在**原锚点**上;若这里用当前 `yMax` - // 渲染,竖带与其余像素的纵向映射就不同源,贴图后出现撕裂/错位。 - // 详见 `RenderLaneLayerRegion` 的 `anchorYMax` 形参说明。 - m_lanes[static_cast(laneIndex)].yMax = anchorYMax; - m_lanes[static_cast(laneIndex)].yMin = anchorYMax - laneSpan; - // 该网格在本图层中的位置:横向铺满整条宽图,纵向铺满整张画布。 - // 画布 = 「可见网格 + 上下各 `marginY` 行余量」。**上下扩展量按"像素行"精确换算** - //(而不是上下各扩展固定的数值量):位图高度是整数(`ceil(laneHeight) + 2*marginY`), - // 只有当「扩展后总跨度 / 画布行数」严格等于前台的 px/数值 时,可见网格在画布里的映射 - // 才与前台整幅渲染**逐像素同源**(否则会差一个 <1px 的纵向缩放误差, - // 让拖拽期间的像素残差明显变大)。 - if (pxPerValue > 0.0) - { - const double topExtend = static_cast(marginY) / pxPerValue; - const double bottomRows = - static_cast(bitmapHeight - marginY) - static_cast(laneHeight); - const double bottomExtend = std::max(0.0, bottomRows) / pxPerValue; - m_lanes[static_cast(laneIndex)].yMin -= bottomExtend; - m_lanes[static_cast(laneIndex)].yMax += topExtend; - } - m_lanes[static_cast(laneIndex)].rect = - gd::RectF(0.0f, 0.0f, static_cast(bitmapWidth), static_cast(bitmapHeight)); - { - gd::Graphics g(layer.dc); - g.SetSmoothingMode(gd::SmoothingModeAntiAlias); - g.SetPixelOffsetMode(gd::PixelOffsetModeHalf); - g.SetTextRenderingHint(gd::TextRenderingHintClearTypeGridFit); - - UiFillRect(g, m_area, m_theme->plotBg); - UiFillRect(g, m_area, m_theme->cardBg); - - // 竖向网格刻度沿用**前台视界跨度**(而非宽窗口的跨度),否则竖网格线会只有一半。 - // ⚠️ 同时把它作为竖向网格的**主刻步长**传给 `PaintLane`(见 `m_renderTimeStep`): - // 不传就会让图层用宽窗口跨度重算步长 → 竖向网格线与前台错位、部分整条丢失。 - const double fgSpan = savedViewMax - savedViewMin; - const double timeStep = NiceStep((fgSpan > 0.0) ? fgSpan : 1.0, kTimeTickTarget); - m_renderTimeStep = timeStep; - std::vector timeTicks; - BuildTicks(Tab().ViewMin(), Tab().ViewMax(), timeStep, timeTicks); - PaintLane(g, laneIndex, m_lanes[static_cast(laneIndex)], timeTicks); - m_renderTimeStep = 0.0; - } - m_fastFrame = savedFastFrame; - m_renderWide = savedRenderWide; - m_wideTickSpan = savedWideTickSpan; - m_wideTickHeight = savedWideTickHeight; - m_renderTimeStep = savedRenderTimeStep; - m_stripRenderActive = savedStripActive; - m_stripX0 = savedStripX0; - m_stripX1 = savedStripX1; - - // 恢复布局 / 视图 / 网格。 - // ⚠️ 这里**不调 `BuildLaneVisuals()`**(同上面的原因:O(网格数) 的 `ComputeLaneRange` - // 在每次补条时都跑一遍是拖拽卡顿的主因)。`m_lanes` 在进入本函数时已经被完整拷贝, - // 直接拷回即可 —— 它本来就是正常布局下的正确结果(矩形/幅值/内容高度都在里面)。 - m_area = savedArea; - m_plotRect = savedPlot; - Tab().ViewMin() = savedViewMin; - Tab().ViewMax() = savedViewMax; - m_lanes = savedLanes; - m_laneContentHeight = savedLaneContentHeight; - Tab().LaneScroll() = savedScroll; - - layer.wideMin = wideMin; - layer.span = span; - layer.marginY = marginY; - // 纵向锚点 = 本次渲染的像素所对应的 `yMax`(由调用方按"改动了哪部分像素"决定; - // 横向补细条必须沿用图层原锚点,否则残余的纵向偏移会被静默清零 —— 见函数说明)。 - layer.anchorYMax = anchorYMax; - layer.pxPerValue = pxPerValue; - layer.key = LaneLayerKey(laneIndex); - layer.valid = true; - m_layerRenderCount.fetch_add(1, std::memory_order_relaxed); - return true; - } - - bool PlotView::RebuildWideBuffer() - { - const float plotWidth = m_plotRect.Width; - const float areaHeight = m_area.Height; - const double span = Tab().ViewMax() - Tab().ViewMin(); - if (!(plotWidth > 8.0f) || !(areaHeight > 8.0f) || !(span > 0.0) || m_lanes.empty()) - { - InvalidateWideBuffer(); - return false; - } - - // 只在**扩容 + 清空待重画队列**这一段持有 `m_layerMutex`; - // 随后释放,`RebuildLaneLayersParallel` 会各自重新获取(它内部还会取 `m_renderMutex`)。 - // ⚠️ 如果这里一直持有 `m_layerMutex`,而并行重建内部再取一次 → `std::mutex` 不可重入 → 死锁。 - { - std::lock_guard renderLock(m_renderMutex); - std::lock_guard layerLock(m_layerMutex); - m_layerFront.resize(m_lanes.size()); - m_layerRebuildPending.clear(); - } - - // 全部网格统一用"当前视界居中"的映射(与 `BlitWideBuffer` 的逐网格 `wideMin` 相容)。 - const double wideMin = LayerDefaultWideMin(); - std::vector allLanes; - allLanes.reserve(m_lanes.size()); - for (std::size_t index = 0; index < m_lanes.size(); ++index) - { - allLanes.push_back(static_cast(index)); - } - // **并行**重建全部网格(各网格用隔离上下文,多核时约等于一块网格的耗时)。 - RebuildLaneLayersParallel(allLanes, wideMin); - std::lock_guard layerLock(m_layerMutex); - bool ok = true; - for (std::size_t index = 0; index < m_layerFront.size(); ++index) - { - if (!m_layerFront[index].valid) - { - ok = false; - } - } - return ok; - } - - bool PlotView::PrefetchStepAllowed() - { - const std::uint64_t now = GetTickCount64(); - if (now >= m_lastPrefetchRequestMs && - (now - m_lastPrefetchRequestMs) < kPrefetchMinIntervalMs) - { - return false; - } - m_lastPrefetchRequestMs = now; - return true; - } - - void PlotView::EnsureLaneLayersPrefetched() - { - if (m_lanes.empty() || !(m_plotRect.Width > 8.0f) || !(Tab().ViewMax() - Tab().ViewMin() > 0.0)) - { - return; - } - if (!LayersAllocated()) - { - // 图层数组与网格数量不符(首次绘制 / 增删网格):没有可复用的像素,只能整批建立。 - RebuildWideBuffer(); - return; - } - // ⚠️ 只登记「缺失 / 内容过期」的网格(`RollWideBuffer` 不做整块重画), - // 由 `RenderPendingLaneRebuilds` 的既有预算逐帧补齐。 - RollWideBuffer(); - } - - void PlotView::WarmLaneLayersStep() - { - if (m_dragMode != PlotDragMode::NONE || m_lanes.empty() || m_fonts == nullptr) - { - return; - } - // ⚠️ **UI 动画期间绝不预热**:单个巨型信号的图层重建要 10~30ms,塞进动画帧就会 - // 让"只有文字的信号列表"看起来卡顿(用户 2026-10-18 报告)。队列保留,动画结束后继续。 - if (m_animationBusy) - { - return; - } - if (!(m_plotRect.Width > 8.0f) || !(Tab().ViewMax() - Tab().ViewMin() > 0.0)) - { - return; - } - { - std::lock_guard renderLock(m_renderMutex); - std::lock_guard layerLock(m_layerMutex); - if (m_layerFront.size() > m_lanes.size()) - { - // 网格变少:先把多出来的图层位图释放掉(`resize` 不会调用 `ReleaseLaneLayer`)。 - for (std::size_t index = m_lanes.size(); index < m_layerFront.size(); ++index) - { - ReleaseLaneLayer(m_layerFront[index]); - } - } - if (m_layerFront.size() != m_lanes.size()) - { - m_layerFront.resize(m_lanes.size()); - } - } - // 登记"跨度 / 内容不一致"的网格。空闲时视界未变 ⇒ `RollWideBuffer` 只登记、不做滚动, - // 也不会走"位移超出画布 → 立刻整块重画"那条分支。 - RollWideBuffer(); - RenderPendingLaneRebuilds(1); - // ⚠️ 是否继续请求下一帧: - // * 还有**可视 / 预取带内**的网格待补 ⇒ 立刻继续(收敛快,见 `AnyVisibleLanePending`); - // * 只剩**离屏**网格 ⇒ 按 `kPrefetchMinIntervalMs` **限速**继续(慢速后台预热, - // 既不饿死动画定时器,又能把整批图层建好 —— 见 `PrefetchStepAllowed`)。 - const bool morePending = - AnyVisibleLanePending() || - (!m_layerRebuildPending.empty() && PrefetchStepAllowed()); - if (morePending) - { - const HWND host = m_host; - if (host != nullptr) - { - PostMessageW(host, kMessagePlotDerivedReady, 0, 0); - } - } - } - - void PlotView::RollWideBuffer() - { - const float plotWidth = m_plotRect.Width; - const double span = Tab().ViewMax() - Tab().ViewMin(); - if (!(plotWidth > 8.0f) || !(span > 0.0)) - { - return; - } - const int width = static_cast(std::lround(plotWidth)); - const int margin = LayerMarginX(); - const int bitmapWidth = width + 2 * margin; - const double scale = static_cast(width) / span; - - std::lock_guard renderLock(m_renderMutex); - std::lock_guard layerLock(m_layerMutex); - if (m_layerFront.size() != m_lanes.size() || m_layerFront.empty()) - { - return; - } - // 本帧还剩多少个「重新居中」额度(见 `kLayerRollsPerFrame`)。 - int rollBudget = kLayerRollsPerFrame; - - // ⚠️ **逐网格独立滚动**:每个图层有自己的时间映射(`layer.wideMin`), - // 因此位移必须按各自的锚点分别计算。曾用**一个全局锚点**给所有图层算同一个位移, - // 结果各网格被平移了不同的真实距离 → 网格之间时间错位、贴回的像素互相矛盾, - // 表现为严重的绘制错误与波形抖动。 - // - // ⚠️ **本函数绝不重画整块图层**:内容过期 / 锚点太远的网格只登记到待重画队列 - //(由 `RenderPendingLaneRebuilds` 按预算在后续帧补画),本帧仍尽量用现有像素跟随视界。 - // 这样前台**永远不会因为"图层没准备好"而做整批重绘**(那正是"拖拽时前台卡顿"的根因)。 - for (std::size_t index = 0; index < m_layerFront.size(); ++index) - { - LaneLayer& layer = m_layerFront[index]; - const int laneIndex = static_cast(index); - - // 结构不可用(尺寸 / 跨度不符):像素的时间映射已经不对,既不能贴也不能滚。 - const float laneHeight = m_lanes[index].rect.Height; - const int bitmapHeight = - static_cast(std::ceil(laneHeight)) + 2 * LayerMarginY(laneHeight); - if (!layer.valid || layer.dc == nullptr || layer.width != bitmapWidth || - layer.height != bitmapHeight || std::fabs(layer.span - span) > 1.0e-12) - { - QueueLaneRebuild(laneIndex); - continue; - } - - // 视界左端当前落在该图层的哪个 x 上;期望它等于 `margin`(左右余量对称)。 - const double desired = (Tab().ViewMin() - layer.wideMin) * scale; - const int shift = static_cast(std::llround(desired - static_cast(margin))); - // 内容(外观 / 网格几何 / 幅值**跨度** / 可见性…)与当前状态不一致。 - const bool contentStale = (layer.key != LaneLayerKey(laneIndex)); - - // ⚠️⚠️ **不在可视区(含预取带)内的网格一律不做像素级滚动**: - // 它们的像素当前根本不会被贴出来,滚了也看不见;而每次补条都要重跑 - // `BuildLaneVisuals()`(O(网格数))—— 253 个网格时这是拖拽卡顿的主因。 - // 只登记待重画(可视网格会被 `RenderPendingLaneRebuilds` 优先处理), - // 等它们真的要进入视区时再按新视界重建(期间由 `BlitWideBuffer` 的底衬兜底)。 - // - // ⚠️⚠️ **离屏网格的"内容过期"也不登记待重画**:否则一次"网格数变化"(勾选/取消 - // 一个分组)就会把**上百个离屏网格**全部塞进队列,随后每帧补 1 个 × 10~30ms - // ⇒ 数秒的持续掉帧(用户 2026-10-18 报告的信号列表动画卡顿有一半来自这里)。 - // 离屏网格真正进入可视/预取带时,下面的可见分支会按新视界登记它。 - if (!LaneVisuallyRelevant(laneIndex, 1)) - { - continue; - } - // 纵向:幅值**平移**不改内容,只改贴图偏移(与横向同构)。 - int vOffset = 0; - if (layer.pxPerValue > 0.0) - { - vOffset = static_cast( - std::llround((layer.anchorYMax - m_lanes[index].yMax) * layer.pxPerValue)); - } - - // ⚠️⚠️ **"位移太大"绝不允许【作废图层】**(用户 2026-10-18 报告: - // 快速拖动中键时**所有网格的信号一起消失**,因为一旦作废就要等拖拽结束才重建)。 - // 关键认识:`BlitWideBuffer` 按 `(ViewMin - layer.wideMin) * scale` 算源偏移, - // 只要视界仍落在位图覆盖范围内(`|shift| <= margin`),**不滚动也画得完全正确**。 - // 因此这里分三种情形: - // * `|shift| <= driftLimit`(位图仍完整覆盖视界)⇒ **什么都不做**(免费); - // * 超出 ⇒ 向中心滚,但**每帧最多滚 `kLayerRollMaxStepPixels`**(把补条代价摊开), - // 剩余位移继续由贴图偏移承担 ⇒ **永远不会空白**; - // * 真的跳到画布之外(`|shift| >= bitmapWidth`)⇒ 才作废 + 登记待重画 - // (那是“点选信号跳转”这类整屏换地方的情形,本来就要重画)。 - const int driftLimit = std::max(8, margin - 24); - const int vOffsetAbs = std::abs(vOffset); - if (vOffsetAbs >= layer.height) - { - // 纵向已超出画布(画布高约等于 2×网格高):像素映射不再可用。 - layer.valid = false; - QueueLaneRebuild(laneIndex); - continue; - } - if (contentStale) - { - QueueLaneRebuild(laneIndex); - } - - // ------------------------------------------------------------------ - // 纵向(幅值)滚动:与横向**完全同构**——内容整体上下移动若干行, - // 再只补画新暴露的那一条(整宽 × 行数)。 - // - // ⚠️ **必须先做纵向再做横向**:横向补条由 `RenderLaneLayerRegion` 渲染, - // 而该函数按传入的**纵向锚点**渲染细条;若先补横向,那时锚点还没跟上, - // 补出来的细条会带着一个错的纵向偏移(贴图后表现为波形错位/撕裂)。 - // ⚠️ 没有这一步时:幅值一变就超出余量 → 每帧都排队**整块重画**(画布比可见网格高 - // 3 倍,代价很大)→ 拖动时"抽搐";而且在重画完成前,`BlitWideBuffer` 只能贴出部分 - // 行(其余留空)→ 网格内出现**空白条带**(用户报告的"绘制异常")。 - // - // ⚠️ **滚动量必须量化**(`kLayerRollQuantum`):不足一个量子的偏移**不滚**, - // 交给贴图时的纵向偏移(`srcY`)处理。这样图层的画布映射在拖动过程中**保持稳定**, - // 折线缓存(键含幅值映射)得以命中;否则每帧滚 1~30 行会让折线每帧重算 - // (实测同样的拖动:量化前 3~4ms 的滚动 + 10~14ms 整帧,量化后回到 ~5ms 量级)。 - // 余量足够大(上下各 `marginY`),因此"不滚只偏移"绝不会超出画布。 - // 上面已保证 |vOffset| < 画布高,所以这里的 BitBlt 高度恒为正。 - // ------------------------------------------------------------------ - if (std::abs(vOffset) >= kLayerRollQuantum) - { - const int roll = vOffset; // 正 = 内容整体上移 roll 行(窗口向下移动) - int stripY = 0; - int stripH = 0; - if (roll > 0) - { - // 内容上移:new[y] = old[y + roll],底部暴露 roll 行。 - BitBlt(layer.dc, 0, 0, bitmapWidth, layer.height - roll, layer.dc, 0, roll, SRCCOPY); - stripY = layer.height - roll; - stripH = roll; - } - else - { - const int amount = -roll; - // 内容下移:new[y] = old[y - amount],顶部暴露 amount 行。 - BitBlt(layer.dc, 0, amount, bitmapWidth, layer.height - amount, layer.dc, 0, 0, - SRCCOPY); - stripY = 0; - stripH = amount; - } - // 锚点随之移动(与横向 `layer.wideMin += shift / scale` 同一套记账方式)。 - layer.anchorYMax -= static_cast(roll) / layer.pxPerValue; - // 补画暴露的那一条(整宽)。锚点用**滚动后**的值 —— 该条与已滚动的像素同一映射。 - RenderLaneLayerRegion(laneIndex, layer, 0, bitmapWidth, stripY, stripH, layer.wideMin, - layer.anchorYMax); - } - - // ⚠️ **横向重新居中只在「快要失去覆盖」时才做**(见上方 `driftLimit` 的说明): - // `BlitWideBuffer` 按 `(ViewMin - layer.wideMin)` 算源偏移,因此视界小幅漂移 - // **完全不需要滚动**(等价且免费)。只有漂移接近 `margin`(位图覆盖边界)时才需要 - // 向中心滚回去,否则网格右侧/左侧会开始用底衬(看起来像“信号被切成两半”)。 - if (std::abs(shift) <= driftLimit) - { - continue; - } - // ⚠️ **每帧只重居中有限的几个网格**(见 `kLayerRollsPerFrame`),且单次滚动量有上限 - // (`kLayerRollMaxStepPixels`):不重居中也不会画错,超出部分继续由贴图偏移承担, - // 因此可以安全地分帧完成。 - if (rollBudget <= 0) - { - continue; - } - --rollBudget; - const int maxStep = std::min(kLayerRollMaxStepPixels, bitmapWidth / 2); - const int roll = std::max(-maxStep, std::min(maxStep, shift)); - - // 整数像素自贴图平移:内容整体移动 `roll` 像素。 - if (roll > 0) - { - // 内容整体左移 roll:new[x] = old[x + roll],右侧暴露 roll 列。 - BitBlt(layer.dc, 0, 0, bitmapWidth - roll, layer.height, layer.dc, roll, 0, SRCCOPY); - } - else if (roll < 0) - { - const int amount = -roll; - BitBlt(layer.dc, amount, 0, bitmapWidth - amount, layer.height, layer.dc, 0, 0, SRCCOPY); - } - else - { - continue; - } - layer.wideMin += static_cast(roll) / scale; - - // 只补画新暴露的细条(每帧代价恒为 O(细条))。 - // ⚠️ 细条必须用**该图层更新后的映射**渲染(传 `layer.wideMin`);若改用"当前视界" - // 的映射,细条与已平移的旧像素之间会有 ≤0.5px 的错位接缝 → 拖动时看起来在抖动。 - // ⚠️ **纵向锚点必须沿用 `layer.anchorYMax`**(不能传当前 `yMax`):横向补条只改一条 - // 竖带,其余像素仍在原锚点上;同时该函数按锚点渲染这条竖带,因此竖带与其余像素 - // 纵向同源。若在这里把锚点重置为当前 `yMax`,残余的纵向偏移会被静默清零、而像素 - // 并未上移 → 整个网格的波形纵向错位("垂直拖动后再横向平移出现纵向跳变")。 - const int stripWidth = std::min(std::abs(roll), bitmapWidth - 1); - const int stripX = (roll > 0) ? (bitmapWidth - stripWidth) : 0; - RenderLaneLayerRegion(laneIndex, layer, stripX, stripWidth, 0, 0, layer.wideMin, - layer.anchorYMax); - } - } - - bool PlotView::BlitWideBuffer(gd::Graphics& g, const std::vector& timeTicks) - { - const float plotWidth = m_plotRect.Width; - const double span = Tab().ViewMax() - Tab().ViewMin(); - if (!(plotWidth > 8.0f) || !(span > 0.0)) - { - return false; - } - const int width = static_cast(std::lround(plotWidth)); - const int margin = LayerMarginX(); - const int bitmapWidth = width + 2 * margin; - const double scale = static_cast(width) / span; - - /** 一次图层贴图(所有参数在释放图层锁之前算好,避免持锁期间做 GDI 绘制)。 */ - struct LayerBlit - { - HDC dc = nullptr; - int srcX = 0; - int srcY = 0; - int destX = 0; - int destY = 0; - int width = 0; - int height = 0; - }; - - std::vector blits; - std::vector backing; // 本帧拿不到(完整)图层像素的网格 - blits.reserve(m_lanes.size()); - backing.reserve(m_lanes.size()); - { - std::lock_guard lock(m_layerMutex); - if (m_layerFront.size() != m_lanes.size()) - { - return false; - } - for (std::size_t index = 0; index < m_layerFront.size(); ++index) - { - const LaneLayer& layer = m_layerFront[index]; - const gd::RectF& lane = m_lanes[index].rect; - const float top = std::max(lane.Y, m_plotRect.Y); - const float bottom = std::min(lane.Y + lane.Height, m_plotRect.Y + m_plotRect.Height); - if (bottom <= top) - { - continue; // 网格整体移出可视区 - } - const int bandHeight = static_cast(std::lround(bottom - top)); - // 纵向映射是否仍然可用:图层高度必须等于"当前网格高 + 上下预渲染余量", - // 且时间跨度必须与当前视界一致(跨度不同则像素的时间映射已经不对)。 - const int expectBitmapHeight = - static_cast(std::ceil(lane.Height)) + 2 * LayerMarginY(lane.Height); - bool usable = (layer.valid && layer.dc != nullptr && layer.width == bitmapWidth && - layer.height == expectBitmapHeight && layer.span > 0.0 && - std::fabs(layer.span - span) <= 1.0e-12); - - LayerBlit blit; - if (usable) - { - // ⚠️ **纵向偏移**:图层带「上下各半个网格高」的预渲染余量,幅值平移只改这里的 - // 源行偏移(`anchorYMax` 与当前 `yMax` 之差换算成像素),不必重画图层 —— - // 这就是"纵向拖动与横向拖动同样是 O(1) 贴图"的关键。 - int vOffset = 0; - if (layer.pxPerValue > 0.0) - { - vOffset = static_cast( - std::llround((layer.anchorYMax - m_lanes[index].yMax) * layer.pxPerValue)); - } - int srcY = static_cast(std::lround(top - lane.Y)) + layer.marginY + vOffset; - int height = bandHeight; - int destTopOffset = 0; - // 超出画布的上下两端只贴**有像素的那一段**(其余由网格底衬补齐)。 - if (srcY < 0) - { - destTopOffset = -srcY; - height -= destTopOffset; - srcY = 0; - } - if (srcY + height > layer.height) - { - height = layer.height - srcY; - } - // ⚠️ **局部贴图**:图层只覆盖"视界 ± 半屏",视界可能有一部分落在图层之外。 - // 这里只贴**有像素的那一段**,其余由底衬补齐;整块跳过才是"网格瞬间变空"的直接原因。 - const double subX = (Tab().ViewMin() - layer.wideMin) * scale; - int srcX = static_cast(std::lround(subX)); - int destOffset = 0; // 从视界左端起偏移多少像素开始贴 - int blitWidth = width; - if (srcX < 0) - { - destOffset = -srcX; - blitWidth -= destOffset; - srcX = 0; - } - if (srcX + blitWidth > bitmapWidth) - { - blitWidth = bitmapWidth - srcX; - } - if (height > 0 && blitWidth > 0) - { - blit.dc = layer.dc; - blit.srcX = srcX; - blit.srcY = srcY; - blit.destX = static_cast(std::lround(m_plotRect.X)) + destOffset; - blit.destY = static_cast(std::lround(top)) + destTopOffset; - blit.width = blitWidth; - blit.height = height; - blits.push_back(blit); - // 横向 / 纵向只有**部分覆盖**:该网格的其余部分也要靠底衬补齐。 - if (destOffset > 0 || blitWidth < width || height < bandHeight) - { - backing.push_back(static_cast(index)); - } - continue; - } - } - - // ⚠️⚠️ **必须兜底补齐网格底衬,绝不能留白**:旧实现在这里直接 `continue` - //(网格空白)或用旧跨度像素 `StretchBlt` 拉伸占位(深度放大时把一小段像素放大 - // 几十倍 → 整块网格变成一片模糊底色)—— 两者在用户眼里都是 - // 「网格叠底消失,没有任何内容」。改为就地补画底衬(底色 + 网格线 + 分界线): - // 代价极低,可以每帧执行,因此**网格底衬永远不会空白/闪烁**(用户 2026-10-17 要求)。 - backing.push_back(static_cast(index)); - } - } - - if (blits.empty()) - { - // ⚠️⚠️ **绝不能因为"本帧一个网格都贴不出图层像素"就退化成整幅重绘** - //(用户 2026-10-17 报告:滚动过渡动画在巨型信号处卡顿)。 - // 这一情形在滚动到**从未构建过图层的区域**时必然出现(上百个网格时绝大多数 - // 网格的图层都还没建立):旧实现在这里 `return false` ⇒ 落到 `Paint` 末尾的 - // **整幅重绘** ⇒ 把**本帧全部可见网格的波形**(可能包含上千�万采样的巨型信号) - // 每帧重画一遍 —— 实测单帧 **20~35ms**,而手动拖滚动条只要 ~6ms。 - // 正确行为:本帧只画**网格底衬**(廉价,见下方),波形等视界真正停下之后再按预算补 - //(这正是用户要求的“没来得及画信号时就按只有网格叠底的状态继续滚动”)。 - if (backing.empty()) - { - // 连底衬都没得画(布局变化 / 图层数组与网格数不符)→ 交给整幅重绘兜底。 - return false; - } - } - - // ⚠️ 顺序:**先画网格底衬(GDI+),再取得 HDC 做 BitBlt** —— - // `g.GetHDC()` 之后再调用 GDI+ 绘制接口是未定义行为。 - for (const int laneIndex : backing) - { - PaintLaneGridBacking(g, laneIndex, timeTicks); - } - - HDC target = g.GetHDC(); - if (target == nullptr) - { - return true; - } - for (const LayerBlit& blit : blits) - { - BitBlt(target, blit.destX, blit.destY, blit.width, blit.height, blit.dc, blit.srcX, blit.srcY, - SRCCOPY); - } - g.ReleaseHDC(target); - return true; - } /** * @brief 为单个网格绘制网格底衬(底色 + 横向/竖向网格线 + 网格分界线)。 diff --git a/Components/PlotView/Src/PlotViewRaster.cpp b/Components/PlotView/Src/PlotViewRaster.cpp new file mode 100644 index 0000000..9693e8d --- /dev/null +++ b/Components/PlotView/Src/PlotViewRaster.cpp @@ -0,0 +1,1977 @@ +// PlotView 实现分片 —— 光栅化像素缓存与每帧预算("怎么画才不卡")。 +// +// 本分片只包含 `PlotView` 的**成员函数定义**(从 `PlotViewPaint.cpp` 机械搬移,行为与搬移前逐字节一致)。 +// 职责: +// * 信号光晕的离屏位图层(整条曲线一次性渲染 + 每帧贴图); +// * 静态场景缓存(底色 + 网格 + 时间轴)的建立 / 抓取 / 贴回 / 作废; +// * 装饰层(带真 alpha 的独立图层,AlphaBlend 贴回); +// * 每网格"宽基底"图层:分配 / 内容指纹 / 就绪判定 / 并行重建 / 区域重画 / 滚动补条 / 贴图 / 预热预算。 +// +// ⚠️ 与绘制(`PlotViewPaint.cpp`)、指纹(`PlotViewCacheKey.cpp`)的分工: +// * 本文件**只管像素与预算**,不做业务判据; +// * 内容指纹由 `PlotViewCacheKey.cpp` 提供(`LaneLayerKey/LaneLayerHash` 里用到的哈希函数在那里); +// * 共用的常量与工具函数在 `PlotViewInternal.hpp`(命名空间 `pvdetail`,全部 `inline`),因此跨分片安全。 + +#include "PlotViewInternal.hpp" + +namespace jdi +{ + void PlotView::PaintSignalHaloFor(gd::Graphics& g, const ResolvedSignal& resolved, const LaneVisual& lane, + const PolylineCacheEntry& cached) + { + if (resolved.style == nullptr) + { + return; + } + // ------------------------------------------------------------------ + // 稠密信号(走"像素列包络带"):光晕 = **沿包络带向外扩一圈的填充**。 + // + // ⚠️ 绝不能用「加粗折线描边」画稠密信号的光晕:包络带的顶点序列是 + // 「每列上/下包络交替」的满幅竖锯齿,GDI+ 描边的代价 = 线段数 × 覆盖行数, + // 实测 2503 个顶点要 **210ms**。而 `FillPath` 同一条几何只要 ~14ms。 + // ⚠️⚠️ **必须向外扩 `grow`(= 描边笔宽的一半)**:折线描边的笔宽是 + // `lineWidth + 4`,即沿曲线向两侧各扩 2~3px —— 那正是用户看到的"外延高亮描边"。 + // 曾经直接按包络带原始几何填充,结果光晕与曲线**逐像素同形**:当信号颜色恰好等于 + // 主题强调色时(调色板第 1 色就是强调色),曲线上的那层半透明强调色完全看不出变化, + // 只剩下带宽内部的一点点底色变化 ⇒ 用户报告"高亮几乎看不到、只有曲线换了颜色" + //(2026-10-18 第三轮)。扩一圈后与旧版描边的观感一致。 + // ⚠️ 光晕**必须覆盖整条曲线**:只画鼠标附近一段时,在满幅方波上就是一个长方形色块。 + // 代价问题由 `PaintSignalHaloCached`(渲染一次 + 每帧贴图)解决。 + // ------------------------------------------------------------------ + if (resolved.style->mode != DrawMode::SCATTER && cached.bandMode && !cached.bandBreaks.empty()) + { + // 与旧版 `PaintSignalHalo` 的笔宽同源:`(lineWidth + 4) * scale`,向外取一半。 + const float grow = (std::max(1.0f, resolved.style->lineWidth) + 4.0f) * m_scale * 0.5f; + const int savedSmoothing = g.GetSmoothingMode(); + g.SetSmoothingMode(gd::SmoothingModeNone); // 半透明填充:抗锯齿没有观感收益 + gd::GraphicsPath bandPath; + bandPath.SetFillMode(gd::FillModeWinding); + gd::PointF quad[4]; + for (std::size_t k = 0; k < cached.bandBreaks.size(); ++k) + { + const std::size_t start = cached.bandBreaks[k]; + const std::size_t stop = + (k + 1 < cached.bandBreaks.size()) ? cached.bandBreaks[k + 1] : cached.band.size(); + const std::size_t total = stop - start; + if (total < 4 || (total % 2) != 0) + { + continue; + } + // 段内布局 = 「上包络正向(`half` 个)+ 下包络反向(`half` 个)」,因此 + // 列 `i` 的上/下包络分别是 `start+i` 与 `start+half+(half-1-i)`。 + // + // ⚠️⚠️ **逐列成对填四边形,而不是把整条包络拼成一个多边形**:满幅方波的 + // 包络相邻列区间**互相交叉**,拼成单个多边形后缠绕填充规则会在交叠处 + // 互相抵消 ⇒ 填充结果变成一条条竖缝(实测光晕只覆盖约一半带宽,用户看到 + // "只有曲线换了个颜色")。逐列四边形永远是简单多边形,不会被挖空。 + // ⚠️ 横向/纵向都外扩 `grow`:竖向跃变处旧版 5px 笔会涂出一条整高色带, + // 只填 1px 宽的列区间根本看不出来。相邻四边形同向、且在同一次 `FillPath` + // 内,重叠区域只混合一次(不会叠出更深的竖条)。 + const std::size_t half = total / 2; + for (std::size_t column = 0; column + 1 < half; ++column) + { + const gd::PointF& topA = cached.band[start + column]; + const gd::PointF& bottomA = cached.band[start + half + (half - 1 - column)]; + const gd::PointF& topB = cached.band[start + column + 1]; + const gd::PointF& bottomB = + cached.band[start + half + (half - 1 - (column + 1))]; + const float highY = std::min(std::min(topA.Y, bottomA.Y), std::min(topB.Y, bottomB.Y)); + const float lowY = std::max(std::max(topA.Y, bottomA.Y), std::max(topB.Y, bottomB.Y)); + quad[0] = gd::PointF(topA.X - grow, highY - grow); + quad[1] = gd::PointF(topB.X + grow, highY - grow); + quad[2] = gd::PointF(topB.X + grow, lowY + grow); + quad[3] = gd::PointF(topA.X - grow, lowY + grow); + bandPath.AddPolygon(quad, 4); + } + } + if (bandPath.GetPointCount() > 0) + { + // alpha 与旧版描边笔一致(0x66),保证任何信号颜色下观感相同。 + gd::SolidBrush brush(ToGdiColor(WithAlpha(m_theme->accent, 0x66))); + g.FillPath(&brush, &bandPath); + } + g.SetSmoothingMode(static_cast(savedSmoothing)); + return; + } + // 其余情形一律用折线描边(与"之前"的画法一致)。 + PaintSignalHalo(g, resolved, lane, cached.points, cached.breaks); + } + + void PlotView::PaintSignalHaloCached(gd::Graphics& g, const ResolvedSignal& resolved, + const LaneVisual& lane, const PolylineCacheEntry& cached) + { + if (resolved.style == nullptr) + { + return; + } + + // 光晕只可能画在该网格内:位图 = 网格矩形 ∩ 绘图区(外加少量余量容纳加粗线宽)。 + const float pad = (resolved.style->lineWidth + 8.0f) * m_scale; + const float left = std::max(lane.rect.X, m_plotRect.X) - pad; + const float top = std::max(lane.rect.Y, m_plotRect.Y) - pad; + const float right = + std::min(lane.rect.X + lane.rect.Width, m_plotRect.X + m_plotRect.Width) + pad; + const float bottom = + std::min(lane.rect.Y + lane.rect.Height, m_plotRect.Y + m_plotRect.Height) + pad; + if (!(right > left + 1.0f) || !(bottom > top + 1.0f)) + { + return; + } + const int targetX = static_cast(std::floor(left)); + const int targetY = static_cast(std::floor(top)); + const int width = static_cast(std::ceil(right)) - targetX; + const int height = static_cast(std::ceil(bottom)) - targetY; + if (width <= 0 || height <= 0) + { + return; + } + + HaloLayerCache& cache = m_haloLayer; + // 键 = 决定光晕几何的全部输入(与 `AcquirePolyline` 的键同源)。 + // ⚠️ **不含网格的屏幕位置**:滚动网格时几何不变,只需按新位置贴同一张位图。 + const bool sameSize = cache.bitmap && static_cast(cache.bitmap->GetWidth()) == width && + static_cast(cache.bitmap->GetHeight()) == height; + const bool sameKey = + cache.valid && sameSize && cache.ref == cached.ref && cache.group == cached.group && + cache.sampleCount == cached.sampleCount && cache.viewMin == cached.viewMin && + cache.viewMax == cached.viewMax && cache.plotX == cached.plotX && + cache.plotWidth == cached.plotWidth && cache.laneHeight == cached.laneHeight && + cache.laneYMin == cached.laneYMin && cache.laneYMax == cached.laneYMax && + cache.lineWidth == cached.lineWidth && cache.mode == resolved.style->mode && + cache.gain == resolved.style->gain && cache.absolute == resolved.style->absolute && + cache.mirrorVertical == resolved.style->mirrorVertical && + cache.mirrorHorizontal == resolved.style->mirrorHorizontal && + cache.offsetY == resolved.style->offsetY && + cache.offsetTime == resolved.style->offsetTime; + + if (!sameKey) + { + if (!sameSize) + { + cache.bitmap = std::make_unique(width, height, PixelFormat32bppARGB); + } + { + gd::Graphics layerGraphics(cache.bitmap.get()); + layerGraphics.Clear(gd::Color(0, 0, 0, 0)); + layerGraphics.SetSmoothingMode(gd::SmoothingModeAntiAlias); + layerGraphics.SetPixelOffsetMode(gd::PixelOffsetModeHalf); + // 位图坐标 = 屏幕坐标 - 目标原点,光晕几何可直接沿用屏幕坐标。 + layerGraphics.TranslateTransform(static_cast(-targetX), + static_cast(-targetY)); + if (cached.points.size() > kHaloNoAaMinPoints) + { + // 顶点很多的区间(例如深放大后的方波:每段都跨满网格高): + // 抗锯齿描边的扫描线代价 ≈ 线段数 × 覆盖行数(实测可差 20 倍), + // 而这么密的半透明光晕上平滑作用肉眼不可辨。几何完全不变。 + layerGraphics.SetSmoothingMode(gd::SmoothingModeNone); + } + PaintSignalHaloFor(layerGraphics, resolved, lane, cached); + } + cache.ref = cached.ref; + cache.group = cached.group; + cache.sampleCount = cached.sampleCount; + cache.viewMin = cached.viewMin; + cache.viewMax = cached.viewMax; + cache.plotX = cached.plotX; + cache.plotWidth = cached.plotWidth; + cache.laneHeight = cached.laneHeight; + cache.laneYMin = cached.laneYMin; + cache.laneYMax = cached.laneYMax; + cache.lineWidth = cached.lineWidth; + cache.mode = resolved.style->mode; + cache.gain = resolved.style->gain; + cache.absolute = resolved.style->absolute; + cache.mirrorVertical = resolved.style->mirrorVertical; + cache.mirrorHorizontal = resolved.style->mirrorHorizontal; + cache.offsetY = resolved.style->offsetY; + cache.offsetTime = resolved.style->offsetTime; + cache.originX = targetX; + cache.originY = targetY; + cache.valid = true; + if (!UploadHaloLayer(width, height)) + { + cache.valid = false; + return; + } + } + + // 贴图:`AlphaBlend` + `AC_SRC_ALPHA`(源位图已预乘)。命中缓存时只做这一次位块搬运。 + if (cache.dc == nullptr) + { + return; + } + const HDC targetDc = g.GetHDC(); + if (targetDc != nullptr) + { + BLENDFUNCTION blend = {}; + blend.BlendOp = AC_SRC_OVER; + blend.SourceConstantAlpha = 0xFF; + blend.AlphaFormat = AC_SRC_ALPHA; + ::AlphaBlend(targetDc, targetX, targetY, width, height, cache.dc, 0, 0, width, height, + blend); + g.ReleaseHDC(targetDc); + } + } + + bool PlotView::UploadHaloLayer(int width, int height) + { + HaloLayerCache& cache = m_haloLayer; + if (!cache.bitmap) + { + return false; + } + if (cache.dib == nullptr || cache.dibWidth != width || cache.dibHeight != height) + { + ReleaseHaloLayer(); + BITMAPINFO info = {}; + info.bmiHeader.biSize = sizeof(BITMAPINFOHEADER); + info.bmiHeader.biWidth = width; + info.bmiHeader.biHeight = -height; // 自上而下(与 GDI+ 的扫描行顺序一致) + info.bmiHeader.biPlanes = 1; + info.bmiHeader.biBitCount = 32; + info.bmiHeader.biCompression = BI_RGB; + HDC memoryDc = CreateCompatibleDC(nullptr); + if (memoryDc == nullptr) + { + return false; + } + void* bits = nullptr; + HBITMAP dib = CreateDIBSection(memoryDc, &info, DIB_RGB_COLORS, &bits, nullptr, 0); + if (dib == nullptr || bits == nullptr) + { + DeleteDC(memoryDc); + if (dib != nullptr) + { + DeleteObject(dib); + } + return false; + } + SelectObject(memoryDc, dib); + cache.dc = memoryDc; + cache.dib = dib; + cache.bits = bits; + cache.dibWidth = width; + cache.dibHeight = height; + } + + // 预乘 alpha:`AlphaBlend` 的 `AC_SRC_ALPHA` 要求源 RGB 已乘过 alpha。 + // ⚠️ 用 `(v * a + 128) * 257 >> 16` 代替 `(v * a + 127) / 255`(等价、无除法), + // **不要为此建 64KB 查表**:那张表会让 Release 产物凭空多出 64KB(见 §二进制体积), + // 而这里的乘法-移位在 1.6M 像素上也只要几毫秒。 + gd::Rect rect(0, 0, width, height); + gd::BitmapData data = {}; + if (cache.bitmap->LockBits(&rect, gd::ImageLockModeRead, PixelFormat32bppARGB, &data) != + gd::Ok) + { + return false; + } + unsigned char* destination = static_cast(cache.bits); + for (int y = 0; y < height; ++y) + { + const unsigned char* source = + static_cast(data.Scan0) + static_cast(y) * data.Stride; + unsigned char* row = destination + static_cast(y) * width * 4u; + for (int x = 0; x < width; ++x) + { + const unsigned alpha = source[x * 4 + 3]; + if (alpha == 0) + { + row[x * 4 + 0] = 0; + row[x * 4 + 1] = 0; + row[x * 4 + 2] = 0; + row[x * 4 + 3] = 0; + continue; + } + if (alpha == 0xFF) + { + row[x * 4 + 0] = source[x * 4 + 0]; + row[x * 4 + 1] = source[x * 4 + 1]; + row[x * 4 + 2] = source[x * 4 + 2]; + row[x * 4 + 3] = 0xFF; + continue; + } + row[x * 4 + 0] = static_cast( + (static_cast(source[x * 4 + 0]) * alpha + 128u) * 257u >> 16); + row[x * 4 + 1] = static_cast( + (static_cast(source[x * 4 + 1]) * alpha + 128u) * 257u >> 16); + row[x * 4 + 2] = static_cast( + (static_cast(source[x * 4 + 2]) * alpha + 128u) * 257u >> 16); + row[x * 4 + 3] = static_cast(alpha); + } + } + cache.bitmap->UnlockBits(&data); + return true; + } + + void PlotView::ReleaseHaloLayer() + { + HaloLayerCache& cache = m_haloLayer; + if (cache.dc != nullptr) + { + DeleteDC(cache.dc); + cache.dc = nullptr; + } + if (cache.dib != nullptr) + { + DeleteObject(cache.dib); + cache.dib = nullptr; + } + cache.bits = nullptr; + cache.dibWidth = 0; + cache.dibHeight = 0; + cache.valid = false; + } + + void PlotView::PaintSignalHalo(gd::Graphics& g, const ResolvedSignal& resolved, const LaneVisual& lane, + const std::vector& points, + const std::vector& breaks) + { + // ⚠️ **只有单个数据点**的信号(默认样式是「点线」,那个点本身是一个粗圆点): + // 折线光晕无从下手(不足两个顶点),改为在数据点外包一圈强调色圆环。 + // 否则悬停时完全没有“可拖拽”的视觉反馈(用户 2026-10-15 报告)。 + if (resolved.data != nullptr && resolved.data->finiteCount == 1) + { + gd::PointF dot; + if (SinglePointScreenPos(resolved, lane, dot)) + { + const float radius = SinglePointDotRadius(resolved) + 3.0f * m_scale; + gd::Pen ring(ToGdiColor(WithAlpha(m_theme->accent, 0x88)), 2.0f * m_scale); + g.DrawEllipse(&ring, dot.X - radius, dot.Y - radius, radius * 2.0f, radius * 2.0f); + } + return; + } + + const bool drawLine = (resolved.style->mode != DrawMode::SCATTER); + if (!drawLine) + { + // ⚠️ **散点模式的高亮 = 只高亮数据点本身(不画连线)**(用户 2026-10-17 要求): + // 旧实现直接 `return`,于是散点信号悬停时**只有拖拽提示、没有任何高亮**。 + // 这里给每个参与绘制的散点套一圈强调色圆环 —— 与「线段/点线」的折线光晕相比, + // 正好是“去掉数据点之间连线的部分”(用户原话)。 + // ⚠️ 步长必须与 `PaintLane` 的散点绘制**同源**(`kMaxScatterPoints`), + // 否则高亮圈会出现在没画出来的点上(或漏掉可见的点)。 + if (points.empty() || resolved.style == nullptr) + { + return; + } + const float radius = static_cast( + ClampDouble(static_cast(resolved.style->lineWidth) * kScatterRadiusScale, + static_cast(kMinScatterRadius), static_cast(kMaxScatterRadius)) * + m_scale); + const float ringRadius = radius + 2.5f * m_scale; + const std::size_t scatterStride = + std::max(1, points.size() / kMaxScatterPoints); + // 散点数量本身有上限(`kMaxScatterPoints`),逐点画圈是 O(数千) 的小代价。 + gd::Pen ring(ToGdiColor(WithAlpha(m_theme->accent, 0x88)), 1.6f * m_scale); + for (std::size_t index = 0; index < points.size(); index += scatterStride) + { + const gd::PointF& point = points[index]; + g.DrawEllipse(&ring, point.X - ringRadius, point.Y - ringRadius, ringRadius * 2.0f, + ringRadius * 2.0f); + } + return; + } + if (breaks.empty()) + { + return; + } + + gd::Pen halo(ToGdiColor(WithAlpha(m_theme->accent, 0x66)), + (resolved.style->lineWidth + 4.0f) * m_scale); + halo.SetLineJoin(gd::LineJoinRound); + halo.SetStartCap(gd::LineCapRound); + halo.SetEndCap(gd::LineCapRound); + for (std::size_t k = 0; k < breaks.size(); ++k) + { + const std::size_t start = breaks[k]; + const std::size_t stop = (k + 1 < breaks.size()) ? breaks[k + 1] : points.size(); + if (stop <= start + 1) + { + continue; + } + g.DrawLines(&halo, points.data() + start, static_cast(stop - start)); + } + } + + bool PlotView::EnsureSceneCache(int width, int height) + { + if (width <= 0 || height <= 0) + { + return false; + } + // 位图尺寸必须与当前缓存区域**严格一致**:贴图时按位图尺寸整块拷贝, + // 若沿用更大的旧位图,缩小窗口后会把旧像素画到绘图区之外。 + if (m_sceneDc != nullptr && m_sceneWidth == width && m_sceneHeight == height) + { + return true; + } + + ReleaseSceneCache(); + // 场景位图重建 → 面板图层里缓存的"面板背后的场景"随之失效。 + m_panelValid = false; + + HDC screen = GetDC(nullptr); + if (screen == nullptr) + { + return false; + } + m_sceneDc = CreateCompatibleDC(screen); + m_sceneBitmap = CreateCompatibleBitmap(screen, width, height); + ReleaseDC(nullptr, screen); + + if (m_sceneDc == nullptr || m_sceneBitmap == nullptr) + { + ReleaseSceneCache(); + return false; + } + m_sceneOldBitmap = SelectObject(m_sceneDc, m_sceneBitmap); + m_sceneWidth = width; + m_sceneHeight = height; + return true; + } + + bool PlotView::CaptureSceneCache(gd::Graphics& g) + { + // 缓存区域严格限定为绘图区本身(m_area):工具栏/标签栏/面板由宿主每帧绘制, + // 因为它们各自带有悬停态;绘图区的波形才是真正昂贵的部分(大文件约 80ms/帧)。 + const int x = static_cast(std::floor(m_area.X)); + const int y = static_cast(std::floor(m_area.Y)); + const int right = static_cast(std::ceil(m_area.X + m_area.Width)); + const int bottom = static_cast(std::ceil(m_area.Y + m_area.Height)); + const int width = right - x; + const int height = bottom - y; + if (width <= 0 || height <= 0) + { + m_sceneValid = false; + return false; + } + + m_sceneX = x; + m_sceneY = y; + + if (!EnsureSceneCache(width, height)) + { + m_sceneValid = false; + return false; + } + + HDC source = g.GetHDC(); + if (source == nullptr) + { + m_sceneValid = false; + return false; + } + const BOOL ok = BitBlt(m_sceneDc, 0, 0, width, height, source, m_sceneX, m_sceneY, SRCCOPY); + g.ReleaseHDC(source); + + if (ok == FALSE) + { + m_sceneValid = false; + return false; + } + + m_sceneKey = MakeSceneCacheKey(); + m_sceneGeometryHash = SceneGeometryHash(); + CollectSceneStyles(m_sceneStyles); + CollectSceneDerived(m_sceneDerived); + m_sceneDegraded = false; + m_sceneValid = true; + return true; + } + + bool PlotView::BlitSceneCache(gd::Graphics& g) + { + if (!m_sceneValid || m_sceneDc == nullptr) + { + return false; + } + + HDC target = g.GetHDC(); + if (target == nullptr) + { + return false; + } + const BOOL ok = BitBlt(target, m_sceneX, m_sceneY, m_sceneWidth, m_sceneHeight, m_sceneDc, 0, 0, + SRCCOPY); + g.ReleaseHDC(target); + return ok != FALSE; + } + + void PlotView::InvalidateSceneCache() + { + m_sceneValid = false; + m_sceneStyles.clear(); + m_sceneDerived.clear(); + m_sceneDegraded = false; + m_sceneGeometryHash = 0; + // 面板像素图层与场景缓存同源(面板像素里含"面板背后的场景"): + // 信号增删、重命名、参数变化等一律连带作废,避免面板里残留旧波形。 + m_panelValid = false; + } + + void PlotView::ReleaseSceneCache() + { + m_sceneValid = false; + if (m_sceneDc != nullptr) + { + if (m_sceneOldBitmap != nullptr) + { + SelectObject(m_sceneDc, m_sceneOldBitmap); + m_sceneOldBitmap = nullptr; + } + DeleteDC(m_sceneDc); + m_sceneDc = nullptr; + } + if (m_sceneBitmap != nullptr) + { + DeleteObject(m_sceneBitmap); + m_sceneBitmap = nullptr; + } + m_sceneWidth = 0; + m_sceneHeight = 0; + } + + bool PlotView::EnsureDecorCache(int width, int height) + { + if (width <= 0 || height <= 0) + { + return false; + } + if (m_decorDc != nullptr && m_decorBits != nullptr && m_decorW == width && m_decorH == height) + { + return true; + } + ReleaseDecorCache(); + HDC screen = GetDC(nullptr); + if (screen == nullptr) + { + return false; + } + // ⚠️ **必须用 32bpp DIB 段**(而不是 `CreateCompatibleBitmap`): + // 装饰要带真 alpha(圆角之外全透明),才能用 `AlphaBlend` 合成到波形之上; + // 同时 DIB 段的像素可直接写(用于 alpha 修补与清空)。 + BITMAPINFO info = {}; + info.bmiHeader.biSize = sizeof(BITMAPINFOHEADER); + info.bmiHeader.biWidth = width; + info.bmiHeader.biHeight = -height; // 自上而下 + info.bmiHeader.biPlanes = 1; + info.bmiHeader.biBitCount = 32; + info.bmiHeader.biCompression = BI_RGB; + void* bits = nullptr; + HBITMAP dib = CreateDIBSection(screen, &info, DIB_RGB_COLORS, &bits, nullptr, 0); + ReleaseDC(nullptr, screen); + if (dib == nullptr || bits == nullptr) + { + if (dib != nullptr) + { + DeleteObject(dib); + } + return false; + } + HDC dc = CreateCompatibleDC(nullptr); + if (dc == nullptr) + { + DeleteObject(dib); + return false; + } + m_decorOldBitmap = SelectObject(dc, dib); + m_decorDc = dc; + m_decorBitmap = dib; + m_decorBits = bits; + m_decorW = width; + m_decorH = height; + return true; + } + + void PlotView::ReleaseDecorCache() + { + m_decorValid = false; + m_decorCapturedRects.clear(); + if (m_decorDc != nullptr) + { + if (m_decorOldBitmap != nullptr) + { + SelectObject(m_decorDc, m_decorOldBitmap); + m_decorOldBitmap = nullptr; + } + DeleteDC(m_decorDc); + m_decorDc = nullptr; + } + if (m_decorBitmap != nullptr) + { + DeleteObject(m_decorBitmap); + m_decorBitmap = nullptr; + } + m_decorBits = nullptr; + m_decorW = 0; + m_decorH = 0; + } + + void* PlotView::ResolveAlphaBlend() + { + // `AlphaBlend` 由 msimg32 导出、并由 gdi32 的 `GdiAlphaBlend` 实现; + // 两个来源都试一遍,取到即缓存(静态局部变量,仅解析一次)。 + static void* cached = nullptr; + static bool resolved = false; + if (resolved) + { + return cached; + } + resolved = true; + HMODULE gdi = GetModuleHandleW(L"gdi32.dll"); + if (gdi != nullptr) + { + cached = reinterpret_cast(GetProcAddress(gdi, "GdiAlphaBlend")); + } + if (cached == nullptr) + { + HMODULE msimg = LoadLibraryW(L"msimg32.dll"); + if (msimg != nullptr) + { + cached = reinterpret_cast(GetProcAddress(msimg, "AlphaBlend")); + } + } + return cached; + } + + bool PlotView::RenderDecorCache() + { + m_decorValid = false; + if (m_theme == nullptr || m_fonts == nullptr || m_lanes.empty()) + { + return false; + } + // 位图覆盖「客户区原点 → 绘图区右下角」:绘制坐标与正常绘制完全一致, + // 不需要任何平移(GDI 文本不认 GDI+ 变换,平移会让文字错位)。 + const int width = static_cast(std::ceil(m_area.X + m_area.Width)); + const int height = static_cast(std::ceil(m_area.Y + m_area.Height)); + if (!EnsureDecorCache(width, height)) + { + return false; + } + // 清空为全透明(圆角之外必须保持透明,贴回时才不会盖住波形)。 + std::memset(m_decorBits, 0, static_cast(width) * static_cast(height) * 4u); + + m_decorRects.clear(); + { + gd::Graphics g(m_decorDc); + g.SetSmoothingMode(gd::SmoothingModeAntiAlias); + g.SetPixelOffsetMode(gd::PixelOffsetModeHalf); + g.SetTextRenderingHint(gd::TextRenderingHintClearTypeGridFit); + + const bool savedUse = m_useDecorCache; + m_useDecorCache = false; // 本次是"真正绘制":不允许走"跳过绘制"的分支 + m_renderDecorOnly = true; // 只画装饰(跳过网格线与波形) + const std::vector noTicks; + for (std::size_t index = 0; index < m_lanes.size(); ++index) + { + PaintLane(g, static_cast(index), m_lanes[index], noTicks); + } + m_renderDecorOnly = false; + m_useDecorCache = savedUse; + } + + // ⚠️ **alpha 修补**:GDI 的文本绘制(`DrawTextW`)会把它写入的像素 alpha 清成 0 + // (GDI 不维护 alpha),若原样保留,贴上后文字位置会变成"透明洞"(透出波形)。 + // 在这里把装饰矩形内"有颜色但 alpha 为 0"的像素还原为不透明即可 —— 装饰本身 + // 是画在不透明底板上的,这些像素本来就该不透明。 + for (const gd::RectF& rect : m_decorRects) + { + const int x0 = std::max(0, static_cast(std::floor(rect.X))); + const int y0 = std::max(0, static_cast(std::floor(rect.Y))); + const int x1 = std::min(width, static_cast(std::ceil(rect.X + rect.Width))); + const int y1 = std::min(height, static_cast(std::ceil(rect.Y + rect.Height))); + for (int y = y0; y < y1; ++y) + { + unsigned char* row = static_cast(m_decorBits) + + (static_cast(y) * static_cast(width)) * 4u; + for (int x = x0; x < x1; ++x) + { + unsigned char* p = row + static_cast(x) * 4u; + if (p[3] == 0 && (p[0] | p[1] | p[2]) != 0) + { + p[3] = 255; + } + } + } + } + + m_decorCapturedRects = m_decorRects; + m_decorValid = true; + return true; + } + + bool PlotView::BlitDecorCache(gd::Graphics& g) + { + if (!m_decorValid || m_decorDc == nullptr || m_decorCapturedRects.empty()) + { + return false; + } + void* proc = ResolveAlphaBlend(); + if (proc == nullptr) + { + return false; // 极小概率:系统不提供 AlphaBlend(贴回会带透明洞,故直接放弃) + } + using AlphaBlendFn = BOOL(WINAPI*)(HDC, int, int, int, int, HDC, int, int, int, int, BLENDFUNCTION); + AlphaBlendFn blend = reinterpret_cast(proc); + HDC target = g.GetHDC(); + if (target == nullptr) + { + return false; + } + // 装饰位图原点 = 客户区 (0,0):源坐标与目标坐标**相同**。 + BLENDFUNCTION fn = {}; + fn.BlendOp = AC_SRC_OVER; + fn.SourceConstantAlpha = 255; + fn.AlphaFormat = AC_SRC_ALPHA; // 源为**预乘** alpha(GDI+ 写入 DIB 段即预乘) + bool any = false; + for (const gd::RectF& rect : m_decorCapturedRects) + { + const int x = static_cast(std::floor(rect.X)); + const int y = static_cast(std::floor(rect.Y)); + const int w = static_cast(std::ceil(rect.X + rect.Width)) - x; + const int h = static_cast(std::ceil(rect.Y + rect.Height)) - y; + if (w <= 0 || h <= 0) + { + continue; + } + if (blend(target, x, y, w, h, m_decorDc, x, y, w, h, fn) != FALSE) + { + any = true; + } + } + g.ReleaseHDC(target); + return any; + } + + bool PlotView::EnsureLaneLayer(LaneLayer& layer, int width, int height) + { + if (width <= 0 || height <= 0) + { + return false; + } + if (layer.dc != nullptr && layer.width == width && layer.height == height) + { + return true; + } + ReleaseLaneLayer(layer); + HDC screen = GetDC(nullptr); + if (screen == nullptr) + { + return false; + } + layer.dc = CreateCompatibleDC(screen); + layer.bitmap = CreateCompatibleBitmap(screen, width, height); + ReleaseDC(nullptr, screen); + if (layer.dc == nullptr || layer.bitmap == nullptr) + { + ReleaseLaneLayer(layer); + return false; + } + layer.oldBitmap = SelectObject(layer.dc, layer.bitmap); + layer.width = width; + layer.height = height; + layer.valid = false; + return true; + } + + void PlotView::ReleaseLaneLayer(LaneLayer& layer) + { + layer.valid = false; + if (layer.dc != nullptr) + { + if (layer.oldBitmap != nullptr) + { + SelectObject(layer.dc, layer.oldBitmap); + layer.oldBitmap = nullptr; + } + DeleteDC(layer.dc); + layer.dc = nullptr; + } + if (layer.bitmap != nullptr) + { + DeleteObject(layer.bitmap); + layer.bitmap = nullptr; + } + layer.width = 0; + layer.height = 0; + } + + void PlotView::ReleaseWideBuffer() + { + std::lock_guard lock(m_layerMutex); + for (LaneLayer& layer : m_layerFront) + { + ReleaseLaneLayer(layer); + } + m_layerFront.clear(); + m_layerRebuildPending.clear(); + } + + void PlotView::InvalidateWideBuffer() + { + std::lock_guard lock(m_layerMutex); + for (LaneLayer& layer : m_layerFront) + { + layer.valid = false; + } + m_layerRebuildPending.clear(); + } + + std::uint64_t PlotView::LaneLayerKey(int laneIndex) const + { + // 图层只含基底(不含派生曲线与装饰),因此指纹 = **该网格自己的内容** + // ⊕ 网格下标 ⊕ 视界跨度 ⊕ 绘图区宽度(决定像素/时间与位图尺寸)。 + const double span = Tab().ViewMax() - Tab().ViewMin(); + std::uint64_t key = LaneLayerHash(laneIndex); + key ^= static_cast(laneIndex + 1) * 1315423911ull; + key *= 1099511628211ull; + key ^= static_cast(std::llround(span * 1.0e6)); + key *= 1099511628211ull; + key ^= static_cast(std::llround(m_plotRect.Width)); + return key; + } + + std::uint64_t PlotView::LaneLayerHash(int laneIndex) const + { + // 单个网格图层的内容 = 位图高度(= 网格高度)+ 幅值**跨度** + 本网格各信号的 + // 可见性与外观。 + // ⚠️ 图例与 GX 徽标**不在图层里**(它们是装饰层,每帧单独绘制),因此也**不在** + // 本指纹里;改信号名不需要重建图层。 + // + // ⚠️ **只计跨度、不计幅值的绝对范围**:图层的纵向锚点(`anchorYMax`)与 `wideMin` + // 一样是"贴图偏移"的来源,幅值整体平移不改变图层内容(只是贴图时纵向偏移若干像素), + // 因此纵向拖动不会让图层失效。真正改变内容的是**跨度**(决定像素/数值与刻度步长)。 + std::uint64_t hash = 1469598103934665603ull; + const auto mix = [&hash](std::uint64_t value) + { + hash ^= value; + hash *= 1099511628211ull; + }; + const auto mixFloat = [&mix](float value) + { + std::uint32_t bits = 0; + std::memcpy(&bits, &value, sizeof(bits)); + mix(bits); + }; + if (laneIndex < 0 || laneIndex >= static_cast(m_lanes.size())) + { + return hash; + } + const LaneVisual& visual = m_lanes[static_cast(laneIndex)]; + mixFloat(visual.rect.Height); + mixFloat(static_cast(visual.yMax - visual.yMin)); + const std::vector& signals = Tab().Lanes()[static_cast(laneIndex)].signals; + mix(signals.size()); + for (const SignalRef& ref : signals) + { + const ResolvedSignal resolved = ResolveSignal(ref); + mix(static_cast(ref.groupIndex)); + mix(static_cast(ref.signalIndex)); + mix(resolved.visible ? 1ull : 0ull); + if (resolved.data == nullptr || resolved.style == nullptr) + { + continue; + } + mix(static_cast(resolved.style->color)); + mixFloat(resolved.style->opacity); + mixFloat(resolved.style->lineWidth); + mix(static_cast(resolved.style->mode)); + // 「调整」变换会改变图层里的波形,也必须让图层失效 + //(否则贴回的旧像素会与当前样式不一致)。 + mixFloat(resolved.style->gain); + mix(resolved.style->absolute ? 1ull : 0ull); + mix(resolved.style->mirrorVertical ? 1ull : 0ull); + mix(resolved.style->mirrorHorizontal ? 1ull : 0ull); + mixFloat(resolved.style->offsetY); + mixFloat(static_cast(resolved.style->offsetTime)); + } + return hash; + } + + int PlotView::LayerMarginX() const + { + const int width = static_cast(std::lround(m_plotRect.Width)); + return std::max(1, width / 2); // 左右各半屏 + } + + int PlotView::LayerMarginY(float laneHeight) const + { + // 上下各半个网格高:一整屏的额外内容已足够覆盖实际拖拽范围, + // 又不至于让每个横向补条 / 整块重画按三倍高度放大(横向平移因此不受影响)。 + const int half = static_cast(std::lround(static_cast(laneHeight) * 0.5)); + return std::max(1, half); + } + + double PlotView::LayerDefaultWideMin() const + { + // 以当前视界为中心:位图左右各留半个视界宽,因此可向两侧平移 ±半屏。 + const double span = Tab().ViewMax() - Tab().ViewMin(); + const int width = static_cast(std::lround(m_plotRect.Width)); + if (!(span > 0.0) || width <= 0) + { + return Tab().ViewMin(); + } + const double scale = static_cast(width) / span; + return Tab().ViewMin() - static_cast(LayerMarginX()) / scale; + } + + bool PlotView::LayersStructurallyReady() const + { + std::lock_guard lock(m_layerMutex); + if (m_layerFront.size() != m_lanes.size() || m_layerFront.empty()) + { + return false; + } + const float plotWidth = m_plotRect.Width; + const double span = Tab().ViewMax() - Tab().ViewMin(); + if (!(plotWidth > 8.0f) || !(span > 0.0)) + { + return false; + } + const int width = static_cast(std::lround(plotWidth)); + const int bitmapWidth = width + 2 * LayerMarginX(); + for (std::size_t index = 0; index < m_layerFront.size(); ++index) + { + const LaneLayer& layer = m_layerFront[index]; + if (!layer.valid || layer.dc == nullptr || layer.width != bitmapWidth) + { + return false; + } + // 跨度必须一致:跨度变了像素/时间的映射就不同,旧像素不能再当“坐标偏移”用。 + if (std::fabs(span - layer.span) > 1.0e-12) + { + return false; + } + // 高度也要一致:网格高度变化会让贴图纵向错位(含上下预渲染余量)。 + const float laneHeight = m_lanes[index].rect.Height; + const int bitmapHeight = + static_cast(std::ceil(laneHeight)) + 2 * LayerMarginY(laneHeight); + if (layer.height != bitmapHeight) + { + return false; + } + } + return true; + } + + bool PlotView::LayersAllocated() const + { + std::lock_guard lock(m_layerMutex); + return !m_layerFront.empty() && m_layerFront.size() == m_lanes.size(); + } + + bool PlotView::LaneLayerReady(int laneIndex) const + { + if (laneIndex < 0 || laneIndex >= static_cast(m_lanes.size())) + { + return false; + } + std::lock_guard lock(m_layerMutex); + if (laneIndex >= static_cast(m_layerFront.size())) + { + return false; + } + const LaneLayer& layer = m_layerFront[static_cast(laneIndex)]; + const float plotWidth = m_plotRect.Width; + const double span = Tab().ViewMax() - Tab().ViewMin(); + if (!(plotWidth > 8.0f) || !(span > 0.0)) + { + return false; + } + const int width = static_cast(std::lround(plotWidth)); + const int bitmapWidth = width + 2 * LayerMarginX(); + const float laneHeight = m_lanes[static_cast(laneIndex)].rect.Height; + const int bitmapHeight = static_cast(std::ceil(laneHeight)) + 2 * LayerMarginY(laneHeight); + if (!layer.valid || layer.dc == nullptr || layer.width != bitmapWidth || + layer.height != bitmapHeight || std::fabs(layer.span - span) > 1.0e-12) + { + return false; + } + return layer.key == LaneLayerKey(laneIndex); + } + + bool PlotView::LanesAllReadyForView() const + { + if (m_lanes.empty()) + { + return false; + } + for (std::size_t index = 0; index < m_lanes.size(); ++index) + { + if (!LaneLayerReady(static_cast(index))) + { + return false; + } + } + return true; + } + + bool PlotView::LaneVisuallyRelevant(int laneIndex) const + { + return LaneVisuallyRelevant(laneIndex, 0); + } + + bool PlotView::LaneVisuallyRelevant(int laneIndex, int prefetchScreens) const + { + if (laneIndex < 0 || laneIndex >= static_cast(m_lanes.size())) + { + return false; + } + if (!(m_plotRect.Height > 0.0f)) + { + return false; + } + const gd::RectF& rect = m_lanes[static_cast(laneIndex)].rect; + // 可视判定本身留一个网格高的余量(即将滚入视区的网格也算相关); + // 预取带在其上再按屏高放大(见头文件说明)。 + const float margin = std::max(rect.Height, 0.0f) + + static_cast(std::max(0, prefetchScreens)) * m_plotRect.Height; + return (rect.Y + rect.Height + margin > m_plotRect.Y) && + (rect.Y - margin < m_plotRect.Y + m_plotRect.Height); + } + + bool PlotView::LanesVisiblyReadyForView() const + { + if (m_lanes.empty()) + { + return false; + } + bool anyRelevant = false; + for (std::size_t index = 0; index < m_lanes.size(); ++index) + { + if (!LaneVisuallyRelevant(static_cast(index))) + { + continue; + } + anyRelevant = true; + if (!LaneLayerReady(static_cast(index))) + { + return false; + } + } + return anyRelevant; + } + + bool PlotView::AnyVisibleLanePending() const + { + // ⚠️ 用 **±1 屏预取带**(而不是“严格可见”):网格滚动动画会在几百毫秒内扫过一整个屏高, + // 只预建严格可见的网格时,动画扫进去的每一帧都要靠底衬兜底重画十几份网格 + // (实测 ~8ms/帧 ⇒ 动画明显卡顿)。预取带让静置期间就把附近网格的图层建好。 + for (const int laneIndex : m_layerRebuildPending) + { + if (LaneVisuallyRelevant(laneIndex, 1)) + { + return true; + } + } + return false; + } + + void PlotView::QueueLaneRebuild(int laneIndex) + { + if (laneIndex < 0 || laneIndex >= static_cast(m_lanes.size())) + { + return; + } + for (const int existing : m_layerRebuildPending) + { + if (existing == laneIndex) + { + return; // 已经在队列里 + } + } + m_layerRebuildPending.push_back(laneIndex); + } + + bool PlotView::RenderPendingLaneRebuilds(int budget) + { + if (m_layerRebuildPending.empty() || budget <= 0) + { + return !m_layerRebuildPending.empty(); + } + // ⚠️ **优先补画可视网格**:队列是按下标顺序登记的,若严格 FIFO,用户正在看的那几个 + // 网格(往往位于列表下半部)可能要等上百帧才轮到,期间画面一直停留在兜底状态 + //(用户报告:缩放 / 折起后“需要等一会才正确加载”)。 + // 做一次**稳定划分**:可视网格排到队首,其余保持原有相对顺序。 + std::stable_partition(m_layerRebuildPending.begin(), m_layerRebuildPending.end(), + [this](int laneIndex) { return LaneVisuallyRelevant(laneIndex); }); + const double defaultWideMin = LayerDefaultWideMin(); + // 本批要重画的网格:`budget` 足够大时一次取完(视界已停时调用方会传很大的预算)。 + // ⚠️ **离屏网格一律丢弃**(不重画、也不留在队列里):它们的像素当前不可见, + // 而单个巨型信号的图层重建要 10~30ms。真正进入可视/预取带时 `RollWideBuffer` + // 会按新视界重新登记它们(见那里的说明)。历史实现会排空整个队列 ⇒ 勾选一个分组后 + // 连续数秒每帧 10~30ms(用户 2026-10-18 报告的列表动画卡顿)。 + m_layerRebuildPending.erase( + std::remove_if(m_layerRebuildPending.begin(), m_layerRebuildPending.end(), + [this](int laneIndex) { return !LaneVisuallyRelevant(laneIndex, 1); }), + m_layerRebuildPending.end()); + if (m_layerRebuildPending.empty()) + { + return false; + } + const int available = static_cast(m_layerRebuildPending.size()); + const int take = std::min(budget, available); + std::vector batch; + batch.reserve(static_cast(take)); + for (int index = 0; index < take; ++index) + { + const int laneIndex = m_layerRebuildPending[static_cast(index)]; + if (laneIndex >= 0 && laneIndex < static_cast(m_lanes.size())) + { + batch.push_back(laneIndex); + } + } + m_layerRebuildPending.erase(m_layerRebuildPending.begin(), + m_layerRebuildPending.begin() + take); + + if (batch.size() > 1) + { + // **多网格一次补齐**:并行渲染(各网格用隔离上下文),比"一帧只补 1 个"收敛快 N 倍。 + RebuildLaneLayersParallel(batch, defaultWideMin); + } + else + { + for (const int laneIndex : batch) + { + // 加锁顺序与前台一致:先 `m_renderMutex` 再 `m_layerMutex`。 + std::lock_guard renderLock(m_renderMutex); + std::lock_guard layerLock(m_layerMutex); + if (m_layerFront.size() != m_lanes.size()) + { + break; + } + LaneLayer& layer = m_layerFront[static_cast(laneIndex)]; + // ⚠️ **跨度过期(缩放瞬间)也在这里就地整块重画**(按**新跨度**)。 + // 旧实现遇到跨度不符就 `continue`(把该网格推给"下一次整批重建"), + // 而"整批重建"正是那个 70~80ms 的同步卡顿源;现在改为一帧补一个网格, + // 其余网格由 `BlitWideBuffer` 的占位拉伸顶着 → 前台永不因图层未就绪而滞后。 + // 整块重画:整个图层都对齐到"当前 `yMax`"(锚点 = 当前 yMax)。 + if (!RenderLaneLayerRegion(laneIndex, layer, 0, 0, 0, 0, defaultWideMin, + m_lanes[static_cast(laneIndex)].yMax)) + { + continue; + } + } + } + return !m_layerRebuildPending.empty(); + } + + void PlotView::EnsureLayerWorkers() + { + unsigned int hardware = std::thread::hardware_concurrency(); + const int want = std::max(1, std::min(static_cast(hardware == 0 ? 2u : hardware), 4)); + if (m_layerWorkerCount == want && static_cast(m_layerWorkers.size()) == want) + { + // 字体 / 主题 / 缩放 / 数据源可能已变化:刷新一次绑定(不重建线程上下文)。 + for (int index = 0; index < want; ++index) + { + m_layerWorkers[static_cast(index)]->Setup(m_fonts, m_theme); + m_layerWorkers[static_cast(index)]->SetScale(m_scale); + m_layerWorkers[static_cast(index)]->m_dataSet = m_dataSet; + m_layerWorkers[static_cast(index)]->m_dataSetWritable = m_dataSetWritable; + } + return; + } + ReleaseLayerWorkers(); + m_layerWorkers.reserve(static_cast(want)); + for (int index = 0; index < want; ++index) + { + auto worker = std::make_unique(); + worker->Setup(m_fonts, m_theme); + worker->SetScale(m_scale); + // ⚠️ `m_tab` 置空 → worker 的 `Tab()` 返回它**自带的** `m_localTab` 副本, + // 于是它改写视界 / 网格矩形时不会碰到共享的标签页配置(安全并行的前提)。 + worker->m_dataSet = m_dataSet; + worker->m_dataSetWritable = m_dataSetWritable; + // ⚠️ `m_tab` **必须非空**(`BuildLaneVisuals()` 在空时会清空 `m_lanes`); + // 指向 worker **自有**的 `m_workerTab`,这样它改写视界 / 网格矩形时不会碰到共享标签页。 + worker->m_tab = &worker->m_workerTab; + worker->RefreshLanes(); + m_layerWorkers.push_back(std::move(worker)); + } + m_layerWorkerCount = want; + } + + void PlotView::ReleaseLayerWorkers() + { + m_layerWorkers.clear(); + m_layerWorkerCount = 0; + } + + void PlotView::RebuildLaneLayersParallel(const std::vector& laneIndices, double wideMin) + { + if (laneIndices.empty() || m_tab == nullptr || m_dataSet == nullptr) + { + return; + } + // 单网格 / 单核:直接顺序走既有路径(避免线程开销)。 + unsigned int hardware = std::thread::hardware_concurrency(); + if (laneIndices.size() <= 1 || (hardware == 0 ? 2u : hardware) <= 1) + { + std::lock_guard renderLock(m_renderMutex); + std::lock_guard layerLock(m_layerMutex); + if (m_layerFront.size() != m_lanes.size()) + { + return; + } + for (const int laneIndex : laneIndices) + { + if (laneIndex < 0 || laneIndex >= static_cast(m_lanes.size())) + { + continue; + } + RenderLaneLayerRegion(laneIndex, m_layerFront[static_cast(laneIndex)], 0, 0, 0, + 0, wideMin, m_lanes[static_cast(laneIndex)].yMax); + } + return; + } + + { + std::lock_guard renderLock(m_renderMutex); + std::lock_guard layerLock(m_layerMutex); + if (m_layerFront.size() != m_lanes.size()) + { + return; + } + } + EnsureLayerWorkers(); + if (m_layerWorkerCount <= 1) + { + return; // 保守回退:让调用方走顺序路径 + } + + // 主线程**先快照**工作线程需要的一切(之后它们只读,不再触碰共享状态)。 + const TabConfig tabSnapshot = *m_tab; + const gd::RectF plotRectSnapshot = m_plotRect; + const gd::RectF areaSnapshot = m_area; + const float scaleSnapshot = m_scale; + const std::vector laneSnapshot = m_lanes; + // 目标图层位图数组(已确认尺寸与网格数一致,线程内不会扩容)。 + std::vector* const front = &m_layerFront; + + const int workerCount = m_layerWorkerCount; + std::atomic next{0}; + std::vector threads; + threads.reserve(static_cast(workerCount)); + for (int workerIndex = 0; workerIndex < workerCount; ++workerIndex) + { + threads.emplace_back([&, workerIndex]() + { + PlotView* worker = m_layerWorkers[static_cast(workerIndex)].get(); + for (;;) + { + const std::size_t slot = next.fetch_add(1, std::memory_order_relaxed); + if (slot >= laneIndices.size()) + { + break; + } + const int laneIndex = laneIndices[slot]; + if (laneIndex < 0 || laneIndex >= static_cast(laneSnapshot.size()) || + laneIndex >= static_cast(tabSnapshot.Lanes().size())) + { + continue; + } + // 隔离上下文:自带标签页副本 + 只含该网格 + 只读的数据源 / 字体 / 主题。 + worker->m_workerTab = tabSnapshot; + worker->m_workerTab.Lanes() = {tabSnapshot.Lanes()[static_cast(laneIndex)]}; + worker->m_tab = &worker->m_workerTab; + worker->m_fonts = m_fonts; + worker->m_theme = m_theme; + worker->m_scale = scaleSnapshot; + worker->m_dataSet = m_dataSet; + worker->m_dataSetWritable = m_dataSetWritable; + worker->m_area = areaSnapshot; + worker->m_plotRect = plotRectSnapshot; + worker->m_lanes = {laneSnapshot[static_cast(laneIndex)]}; + // ⚠️ 工作线程用**自己的** `m_workerTab` / `m_area` / `m_plotRect` / `m_lanes` 渲染, + // 因此不会与 UI 线程或其它工作线程互相覆盖渲染状态(这正是 2026-10-12 移除 + // "后台图层线程"的根因)。目标图层位图按 `laneIndex` 各自不同,可并发写入。 + worker->RenderLaneLayerRegion(0, (*front)[static_cast(laneIndex)], 0, 0, 0, 0, + wideMin, + laneSnapshot[static_cast(laneIndex)].yMax); + } + }); + } + for (std::thread& thread : threads) + { + if (thread.joinable()) + { + thread.join(); + } + } + + // 合并(主线程):工作线程内部以 `laneIndex = 0` 渲染,因此图层内容指纹要按**真实网格下标**重算。 + std::lock_guard layerLock(m_layerMutex); + if (m_layerFront.size() != m_lanes.size()) + { + return; + } + std::uint64_t rendered = 0; + for (const int laneIndex : laneIndices) + { + if (laneIndex < 0 || laneIndex >= static_cast(m_layerFront.size())) + { + continue; + } + LaneLayer& target = m_layerFront[static_cast(laneIndex)]; + if (!target.valid) + { + continue; + } + target.key = LaneLayerKey(laneIndex); + ++rendered; + } + m_layerRenderCount.fetch_add(rendered, std::memory_order_relaxed); + } + + bool PlotView::RenderLaneLayerRegion(int laneIndex, LaneLayer& layer, int stripX, int stripW, + int stripY, int stripH, double wideMin, double anchorYMax) + { + if (laneIndex < 0 || laneIndex >= static_cast(m_lanes.size())) + { + return false; + } + std::lock_guard renderLock(m_renderMutex); + + const float plotWidth = m_plotRect.Width; + const double span = Tab().ViewMax() - Tab().ViewMin(); + if (!(plotWidth > 8.0f) || !(span > 0.0)) + { + layer.valid = false; + return false; + } + + const int width = static_cast(std::lround(plotWidth)); + const int margin = LayerMarginX(); // 左右各半屏 → 可平移 ±半屏 + const int bitmapWidth = width + 2 * margin; + const float laneHeight = m_lanes[static_cast(laneIndex)].rect.Height; + const int lanePixelHeight = static_cast(std::ceil(laneHeight)); + const int marginY = LayerMarginY(laneHeight); // 上下各半个网格高 → 幅值可平移 ±半个网格高 + const int bitmapHeight = lanePixelHeight + 2 * marginY; + if (bitmapHeight <= 0 || !EnsureLaneLayer(layer, bitmapWidth, bitmapHeight)) + { + return false; + } + + const double scale = static_cast(width) / span; // 像素 / 时间 + const double laneSpan = m_lanes[static_cast(laneIndex)].yMax - + m_lanes[static_cast(laneIndex)].yMin; + const double pxPerValue = (laneSpan > 0.0) ? static_cast(laneHeight) / laneSpan : 0.0; + + if (stripW <= 0 || stripX < 0 || stripX + stripW > bitmapWidth) + { + stripX = 0; + stripW = bitmapWidth; + } + // 纵向同理:`stripH <= 0` 表示纵向整块(横向滚动的细条就是这种情形)。 + if (stripH <= 0 || stripY < 0 || stripY + stripH > bitmapHeight) + { + stripY = 0; + stripH = bitmapHeight; + } + + // 保存当前布局 / 视图 / 网格,临时切换到"宽窗口"映射后离屏渲染,再恢复。 + const gd::RectF savedArea = m_area; + const gd::RectF savedPlot = m_plotRect; + const double savedViewMin = Tab().ViewMin(); + const double savedViewMax = Tab().ViewMax(); + const std::vector savedLanes = m_lanes; + const float savedLaneContentHeight = m_laneContentHeight; + const bool savedFastFrame = m_fastFrame; + const bool savedRenderWide = m_renderWide; + const float savedScroll = Tab().LaneScroll(); + const double savedWideTickSpan = m_wideTickSpan; + const float savedWideTickHeight = m_wideTickHeight; + const double savedRenderTimeStep = m_renderTimeStep; + + m_plotRect = gd::RectF(0.0f, 0.0f, static_cast(bitmapWidth), + static_cast(bitmapHeight)); + // 只把 `m_area` 限为待渲染的那一段 x 区间,使 GDI/GDI+ 的裁剪只落在它上面; + // x 坐标仍用**整条宽窗口**的映射(`m_plotRect` 为完整宽图),因此网格线位置与整块图层一致。 + m_area = gd::RectF(static_cast(stripX), static_cast(stripY), + static_cast(stripW), static_cast(stripH)); + // 横向补条(窄竖带):把折线的构建与描边裁剪到条带的 x 范围(见 `m_stripRenderActive`)。 + // 纵向补条(整宽 × 窄 y)不裁剪 x —— 那一方向本来就需要整条折线的顶点。 + const bool stripIsHorizontalBand = (static_cast(stripW) < static_cast(bitmapWidth) - 0.5f); + const bool savedStripActive = m_stripRenderActive; + const float savedStripX0 = m_stripX0; + const float savedStripX1 = m_stripX1; + m_stripRenderActive = stripIsHorizontalBand; + m_stripX0 = static_cast(stripX); + m_stripX1 = static_cast(stripX + stripW); + Tab().ViewMin() = wideMin; + Tab().ViewMax() = wideMin + static_cast(bitmapWidth) / scale; + + m_renderWide = true; // 图层只含基底:跳过派生曲线与装饰 + // 图层必须与"同一视界的整幅渲染"**逐像素同源**:强制全质量抗锯齿(不受拖拽快速帧影响)。 + m_fastFrame = false; + // 幅值刻度的参考跨度 / 参考高度取**前台可见网格**的值(而不是含上下余量的画布值): + // 这样图层里的横向网格线步长与前台一致,纵向平移才是纯粹的像素平移。 + m_wideTickSpan = laneSpan; + m_wideTickHeight = laneHeight; + // ⚠️⚠️ **不要在这里再调 `BuildLaneVisuals()`**:它会对**全部网格**重算 + // `ComputeLaneRange`(O(网格数)),而本函数只需要**被渲染的那一个网格**。 + // 253 个网格、30 个可见网格时,每帧 30 次补条 × 2 次 BuildLaneVisuals + // = 单帧 **26.8ms**(用户 2026-10-17 报告:三明治加载后拖拽视界卡顿)。 + // 该网格的矩形/幅值范围已在下方显式设置(`m_lanes` 其余成员保持原值不变)。 + // ⚠️ **该网格的幅值映射必须以本次渲染的纵向锚点为准**(而不是"当前 `yMax`"): + // - 整块重画 / 纵向补条:锚点就是当前 `yMax`(纵向补条是滚动后的值),二者一致; + // - **横向补细条**:只重画一条竖带,其余像素仍在**原锚点**上;若这里用当前 `yMax` + // 渲染,竖带与其余像素的纵向映射就不同源,贴图后出现撕裂/错位。 + // 详见 `RenderLaneLayerRegion` 的 `anchorYMax` 形参说明。 + m_lanes[static_cast(laneIndex)].yMax = anchorYMax; + m_lanes[static_cast(laneIndex)].yMin = anchorYMax - laneSpan; + // 该网格在本图层中的位置:横向铺满整条宽图,纵向铺满整张画布。 + // 画布 = 「可见网格 + 上下各 `marginY` 行余量」。**上下扩展量按"像素行"精确换算** + //(而不是上下各扩展固定的数值量):位图高度是整数(`ceil(laneHeight) + 2*marginY`), + // 只有当「扩展后总跨度 / 画布行数」严格等于前台的 px/数值 时,可见网格在画布里的映射 + // 才与前台整幅渲染**逐像素同源**(否则会差一个 <1px 的纵向缩放误差, + // 让拖拽期间的像素残差明显变大)。 + if (pxPerValue > 0.0) + { + const double topExtend = static_cast(marginY) / pxPerValue; + const double bottomRows = + static_cast(bitmapHeight - marginY) - static_cast(laneHeight); + const double bottomExtend = std::max(0.0, bottomRows) / pxPerValue; + m_lanes[static_cast(laneIndex)].yMin -= bottomExtend; + m_lanes[static_cast(laneIndex)].yMax += topExtend; + } + m_lanes[static_cast(laneIndex)].rect = + gd::RectF(0.0f, 0.0f, static_cast(bitmapWidth), static_cast(bitmapHeight)); + { + gd::Graphics g(layer.dc); + g.SetSmoothingMode(gd::SmoothingModeAntiAlias); + g.SetPixelOffsetMode(gd::PixelOffsetModeHalf); + g.SetTextRenderingHint(gd::TextRenderingHintClearTypeGridFit); + + UiFillRect(g, m_area, m_theme->plotBg); + UiFillRect(g, m_area, m_theme->cardBg); + + // 竖向网格刻度沿用**前台视界跨度**(而非宽窗口的跨度),否则竖网格线会只有一半。 + // ⚠️ 同时把它作为竖向网格的**主刻步长**传给 `PaintLane`(见 `m_renderTimeStep`): + // 不传就会让图层用宽窗口跨度重算步长 → 竖向网格线与前台错位、部分整条丢失。 + const double fgSpan = savedViewMax - savedViewMin; + const double timeStep = NiceStep((fgSpan > 0.0) ? fgSpan : 1.0, kTimeTickTarget); + m_renderTimeStep = timeStep; + std::vector timeTicks; + BuildTicks(Tab().ViewMin(), Tab().ViewMax(), timeStep, timeTicks); + PaintLane(g, laneIndex, m_lanes[static_cast(laneIndex)], timeTicks); + m_renderTimeStep = 0.0; + } + m_fastFrame = savedFastFrame; + m_renderWide = savedRenderWide; + m_wideTickSpan = savedWideTickSpan; + m_wideTickHeight = savedWideTickHeight; + m_renderTimeStep = savedRenderTimeStep; + m_stripRenderActive = savedStripActive; + m_stripX0 = savedStripX0; + m_stripX1 = savedStripX1; + + // 恢复布局 / 视图 / 网格。 + // ⚠️ 这里**不调 `BuildLaneVisuals()`**(同上面的原因:O(网格数) 的 `ComputeLaneRange` + // 在每次补条时都跑一遍是拖拽卡顿的主因)。`m_lanes` 在进入本函数时已经被完整拷贝, + // 直接拷回即可 —— 它本来就是正常布局下的正确结果(矩形/幅值/内容高度都在里面)。 + m_area = savedArea; + m_plotRect = savedPlot; + Tab().ViewMin() = savedViewMin; + Tab().ViewMax() = savedViewMax; + m_lanes = savedLanes; + m_laneContentHeight = savedLaneContentHeight; + Tab().LaneScroll() = savedScroll; + + layer.wideMin = wideMin; + layer.span = span; + layer.marginY = marginY; + // 纵向锚点 = 本次渲染的像素所对应的 `yMax`(由调用方按"改动了哪部分像素"决定; + // 横向补细条必须沿用图层原锚点,否则残余的纵向偏移会被静默清零 —— 见函数说明)。 + layer.anchorYMax = anchorYMax; + layer.pxPerValue = pxPerValue; + layer.key = LaneLayerKey(laneIndex); + layer.valid = true; + m_layerRenderCount.fetch_add(1, std::memory_order_relaxed); + return true; + } + + bool PlotView::RebuildWideBuffer() + { + const float plotWidth = m_plotRect.Width; + const float areaHeight = m_area.Height; + const double span = Tab().ViewMax() - Tab().ViewMin(); + if (!(plotWidth > 8.0f) || !(areaHeight > 8.0f) || !(span > 0.0) || m_lanes.empty()) + { + InvalidateWideBuffer(); + return false; + } + + // 只在**扩容 + 清空待重画队列**这一段持有 `m_layerMutex`; + // 随后释放,`RebuildLaneLayersParallel` 会各自重新获取(它内部还会取 `m_renderMutex`)。 + // ⚠️ 如果这里一直持有 `m_layerMutex`,而并行重建内部再取一次 → `std::mutex` 不可重入 → 死锁。 + { + std::lock_guard renderLock(m_renderMutex); + std::lock_guard layerLock(m_layerMutex); + m_layerFront.resize(m_lanes.size()); + m_layerRebuildPending.clear(); + } + + // 全部网格统一用"当前视界居中"的映射(与 `BlitWideBuffer` 的逐网格 `wideMin` 相容)。 + const double wideMin = LayerDefaultWideMin(); + std::vector allLanes; + allLanes.reserve(m_lanes.size()); + for (std::size_t index = 0; index < m_lanes.size(); ++index) + { + allLanes.push_back(static_cast(index)); + } + // **并行**重建全部网格(各网格用隔离上下文,多核时约等于一块网格的耗时)。 + RebuildLaneLayersParallel(allLanes, wideMin); + std::lock_guard layerLock(m_layerMutex); + bool ok = true; + for (std::size_t index = 0; index < m_layerFront.size(); ++index) + { + if (!m_layerFront[index].valid) + { + ok = false; + } + } + return ok; + } + + bool PlotView::PrefetchStepAllowed() + { + const std::uint64_t now = GetTickCount64(); + if (now >= m_lastPrefetchRequestMs && + (now - m_lastPrefetchRequestMs) < kPrefetchMinIntervalMs) + { + return false; + } + m_lastPrefetchRequestMs = now; + return true; + } + + void PlotView::EnsureLaneLayersPrefetched() + { + if (m_lanes.empty() || !(m_plotRect.Width > 8.0f) || !(Tab().ViewMax() - Tab().ViewMin() > 0.0)) + { + return; + } + if (!LayersAllocated()) + { + // 图层数组与网格数量不符(首次绘制 / 增删网格):没有可复用的像素,只能整批建立。 + RebuildWideBuffer(); + return; + } + // ⚠️ 只登记「缺失 / 内容过期」的网格(`RollWideBuffer` 不做整块重画), + // 由 `RenderPendingLaneRebuilds` 的既有预算逐帧补齐。 + RollWideBuffer(); + } + + void PlotView::WarmLaneLayersStep() + { + if (m_dragMode != PlotDragMode::NONE || m_lanes.empty() || m_fonts == nullptr) + { + return; + } + // ⚠️ **UI 动画期间绝不预热**:单个巨型信号的图层重建要 10~30ms,塞进动画帧就会 + // 让"只有文字的信号列表"看起来卡顿(用户 2026-10-18 报告)。队列保留,动画结束后继续。 + if (m_animationBusy) + { + return; + } + if (!(m_plotRect.Width > 8.0f) || !(Tab().ViewMax() - Tab().ViewMin() > 0.0)) + { + return; + } + { + std::lock_guard renderLock(m_renderMutex); + std::lock_guard layerLock(m_layerMutex); + if (m_layerFront.size() > m_lanes.size()) + { + // 网格变少:先把多出来的图层位图释放掉(`resize` 不会调用 `ReleaseLaneLayer`)。 + for (std::size_t index = m_lanes.size(); index < m_layerFront.size(); ++index) + { + ReleaseLaneLayer(m_layerFront[index]); + } + } + if (m_layerFront.size() != m_lanes.size()) + { + m_layerFront.resize(m_lanes.size()); + } + } + // 登记"跨度 / 内容不一致"的网格。空闲时视界未变 ⇒ `RollWideBuffer` 只登记、不做滚动, + // 也不会走"位移超出画布 → 立刻整块重画"那条分支。 + RollWideBuffer(); + RenderPendingLaneRebuilds(1); + // ⚠️ 是否继续请求下一帧: + // * 还有**可视 / 预取带内**的网格待补 ⇒ 立刻继续(收敛快,见 `AnyVisibleLanePending`); + // * 只剩**离屏**网格 ⇒ 按 `kPrefetchMinIntervalMs` **限速**继续(慢速后台预热, + // 既不饿死动画定时器,又能把整批图层建好 —— 见 `PrefetchStepAllowed`)。 + const bool morePending = + AnyVisibleLanePending() || + (!m_layerRebuildPending.empty() && PrefetchStepAllowed()); + if (morePending) + { + const HWND host = m_host; + if (host != nullptr) + { + PostMessageW(host, kMessagePlotDerivedReady, 0, 0); + } + } + } + + void PlotView::RollWideBuffer() + { + const float plotWidth = m_plotRect.Width; + const double span = Tab().ViewMax() - Tab().ViewMin(); + if (!(plotWidth > 8.0f) || !(span > 0.0)) + { + return; + } + const int width = static_cast(std::lround(plotWidth)); + const int margin = LayerMarginX(); + const int bitmapWidth = width + 2 * margin; + const double scale = static_cast(width) / span; + + std::lock_guard renderLock(m_renderMutex); + std::lock_guard layerLock(m_layerMutex); + if (m_layerFront.size() != m_lanes.size() || m_layerFront.empty()) + { + return; + } + // 本帧还剩多少个「重新居中」额度(见 `kLayerRollsPerFrame`)。 + int rollBudget = kLayerRollsPerFrame; + + // ⚠️ **逐网格独立滚动**:每个图层有自己的时间映射(`layer.wideMin`), + // 因此位移必须按各自的锚点分别计算。曾用**一个全局锚点**给所有图层算同一个位移, + // 结果各网格被平移了不同的真实距离 → 网格之间时间错位、贴回的像素互相矛盾, + // 表现为严重的绘制错误与波形抖动。 + // + // ⚠️ **本函数绝不重画整块图层**:内容过期 / 锚点太远的网格只登记到待重画队列 + //(由 `RenderPendingLaneRebuilds` 按预算在后续帧补画),本帧仍尽量用现有像素跟随视界。 + // 这样前台**永远不会因为"图层没准备好"而做整批重绘**(那正是"拖拽时前台卡顿"的根因)。 + for (std::size_t index = 0; index < m_layerFront.size(); ++index) + { + LaneLayer& layer = m_layerFront[index]; + const int laneIndex = static_cast(index); + + // 结构不可用(尺寸 / 跨度不符):像素的时间映射已经不对,既不能贴也不能滚。 + const float laneHeight = m_lanes[index].rect.Height; + const int bitmapHeight = + static_cast(std::ceil(laneHeight)) + 2 * LayerMarginY(laneHeight); + if (!layer.valid || layer.dc == nullptr || layer.width != bitmapWidth || + layer.height != bitmapHeight || std::fabs(layer.span - span) > 1.0e-12) + { + QueueLaneRebuild(laneIndex); + continue; + } + + // 视界左端当前落在该图层的哪个 x 上;期望它等于 `margin`(左右余量对称)。 + const double desired = (Tab().ViewMin() - layer.wideMin) * scale; + const int shift = static_cast(std::llround(desired - static_cast(margin))); + // 内容(外观 / 网格几何 / 幅值**跨度** / 可见性…)与当前状态不一致。 + const bool contentStale = (layer.key != LaneLayerKey(laneIndex)); + + // ⚠️⚠️ **不在可视区(含预取带)内的网格一律不做像素级滚动**: + // 它们的像素当前根本不会被贴出来,滚了也看不见;而每次补条都要重跑 + // `BuildLaneVisuals()`(O(网格数))—— 253 个网格时这是拖拽卡顿的主因。 + // 只登记待重画(可视网格会被 `RenderPendingLaneRebuilds` 优先处理), + // 等它们真的要进入视区时再按新视界重建(期间由 `BlitWideBuffer` 的底衬兜底)。 + // + // ⚠️⚠️ **离屏网格的"内容过期"也不登记待重画**:否则一次"网格数变化"(勾选/取消 + // 一个分组)就会把**上百个离屏网格**全部塞进队列,随后每帧补 1 个 × 10~30ms + // ⇒ 数秒的持续掉帧(用户 2026-10-18 报告的信号列表动画卡顿有一半来自这里)。 + // 离屏网格真正进入可视/预取带时,下面的可见分支会按新视界登记它。 + if (!LaneVisuallyRelevant(laneIndex, 1)) + { + continue; + } + // 纵向:幅值**平移**不改内容,只改贴图偏移(与横向同构)。 + int vOffset = 0; + if (layer.pxPerValue > 0.0) + { + vOffset = static_cast( + std::llround((layer.anchorYMax - m_lanes[index].yMax) * layer.pxPerValue)); + } + + // ⚠️⚠️ **"位移太大"绝不允许【作废图层】**(用户 2026-10-18 报告: + // 快速拖动中键时**所有网格的信号一起消失**,因为一旦作废就要等拖拽结束才重建)。 + // 关键认识:`BlitWideBuffer` 按 `(ViewMin - layer.wideMin) * scale` 算源偏移, + // 只要视界仍落在位图覆盖范围内(`|shift| <= margin`),**不滚动也画得完全正确**。 + // 因此这里分三种情形: + // * `|shift| <= driftLimit`(位图仍完整覆盖视界)⇒ **什么都不做**(免费); + // * 超出 ⇒ 向中心滚,但**每帧最多滚 `kLayerRollMaxStepPixels`**(把补条代价摊开), + // 剩余位移继续由贴图偏移承担 ⇒ **永远不会空白**; + // * 真的跳到画布之外(`|shift| >= bitmapWidth`)⇒ 才作废 + 登记待重画 + // (那是“点选信号跳转”这类整屏换地方的情形,本来就要重画)。 + const int driftLimit = std::max(8, margin - 24); + const int vOffsetAbs = std::abs(vOffset); + if (vOffsetAbs >= layer.height) + { + // 纵向已超出画布(画布高约等于 2×网格高):像素映射不再可用。 + layer.valid = false; + QueueLaneRebuild(laneIndex); + continue; + } + if (contentStale) + { + QueueLaneRebuild(laneIndex); + } + + // ------------------------------------------------------------------ + // 纵向(幅值)滚动:与横向**完全同构**——内容整体上下移动若干行, + // 再只补画新暴露的那一条(整宽 × 行数)。 + // + // ⚠️ **必须先做纵向再做横向**:横向补条由 `RenderLaneLayerRegion` 渲染, + // 而该函数按传入的**纵向锚点**渲染细条;若先补横向,那时锚点还没跟上, + // 补出来的细条会带着一个错的纵向偏移(贴图后表现为波形错位/撕裂)。 + // ⚠️ 没有这一步时:幅值一变就超出余量 → 每帧都排队**整块重画**(画布比可见网格高 + // 3 倍,代价很大)→ 拖动时"抽搐";而且在重画完成前,`BlitWideBuffer` 只能贴出部分 + // 行(其余留空)→ 网格内出现**空白条带**(用户报告的"绘制异常")。 + // + // ⚠️ **滚动量必须量化**(`kLayerRollQuantum`):不足一个量子的偏移**不滚**, + // 交给贴图时的纵向偏移(`srcY`)处理。这样图层的画布映射在拖动过程中**保持稳定**, + // 折线缓存(键含幅值映射)得以命中;否则每帧滚 1~30 行会让折线每帧重算 + // (实测同样的拖动:量化前 3~4ms 的滚动 + 10~14ms 整帧,量化后回到 ~5ms 量级)。 + // 余量足够大(上下各 `marginY`),因此"不滚只偏移"绝不会超出画布。 + // 上面已保证 |vOffset| < 画布高,所以这里的 BitBlt 高度恒为正。 + // ------------------------------------------------------------------ + if (std::abs(vOffset) >= kLayerRollQuantum) + { + const int roll = vOffset; // 正 = 内容整体上移 roll 行(窗口向下移动) + int stripY = 0; + int stripH = 0; + if (roll > 0) + { + // 内容上移:new[y] = old[y + roll],底部暴露 roll 行。 + BitBlt(layer.dc, 0, 0, bitmapWidth, layer.height - roll, layer.dc, 0, roll, SRCCOPY); + stripY = layer.height - roll; + stripH = roll; + } + else + { + const int amount = -roll; + // 内容下移:new[y] = old[y - amount],顶部暴露 amount 行。 + BitBlt(layer.dc, 0, amount, bitmapWidth, layer.height - amount, layer.dc, 0, 0, + SRCCOPY); + stripY = 0; + stripH = amount; + } + // 锚点随之移动(与横向 `layer.wideMin += shift / scale` 同一套记账方式)。 + layer.anchorYMax -= static_cast(roll) / layer.pxPerValue; + // 补画暴露的那一条(整宽)。锚点用**滚动后**的值 —— 该条与已滚动的像素同一映射。 + RenderLaneLayerRegion(laneIndex, layer, 0, bitmapWidth, stripY, stripH, layer.wideMin, + layer.anchorYMax); + } + + // ⚠️ **横向重新居中只在「快要失去覆盖」时才做**(见上方 `driftLimit` 的说明): + // `BlitWideBuffer` 按 `(ViewMin - layer.wideMin)` 算源偏移,因此视界小幅漂移 + // **完全不需要滚动**(等价且免费)。只有漂移接近 `margin`(位图覆盖边界)时才需要 + // 向中心滚回去,否则网格右侧/左侧会开始用底衬(看起来像“信号被切成两半”)。 + if (std::abs(shift) <= driftLimit) + { + continue; + } + // ⚠️ **每帧只重居中有限的几个网格**(见 `kLayerRollsPerFrame`),且单次滚动量有上限 + // (`kLayerRollMaxStepPixels`):不重居中也不会画错,超出部分继续由贴图偏移承担, + // 因此可以安全地分帧完成。 + if (rollBudget <= 0) + { + continue; + } + --rollBudget; + const int maxStep = std::min(kLayerRollMaxStepPixels, bitmapWidth / 2); + const int roll = std::max(-maxStep, std::min(maxStep, shift)); + + // 整数像素自贴图平移:内容整体移动 `roll` 像素。 + if (roll > 0) + { + // 内容整体左移 roll:new[x] = old[x + roll],右侧暴露 roll 列。 + BitBlt(layer.dc, 0, 0, bitmapWidth - roll, layer.height, layer.dc, roll, 0, SRCCOPY); + } + else if (roll < 0) + { + const int amount = -roll; + BitBlt(layer.dc, amount, 0, bitmapWidth - amount, layer.height, layer.dc, 0, 0, SRCCOPY); + } + else + { + continue; + } + layer.wideMin += static_cast(roll) / scale; + + // 只补画新暴露的细条(每帧代价恒为 O(细条))。 + // ⚠️ 细条必须用**该图层更新后的映射**渲染(传 `layer.wideMin`);若改用"当前视界" + // 的映射,细条与已平移的旧像素之间会有 ≤0.5px 的错位接缝 → 拖动时看起来在抖动。 + // ⚠️ **纵向锚点必须沿用 `layer.anchorYMax`**(不能传当前 `yMax`):横向补条只改一条 + // 竖带,其余像素仍在原锚点上;同时该函数按锚点渲染这条竖带,因此竖带与其余像素 + // 纵向同源。若在这里把锚点重置为当前 `yMax`,残余的纵向偏移会被静默清零、而像素 + // 并未上移 → 整个网格的波形纵向错位("垂直拖动后再横向平移出现纵向跳变")。 + const int stripWidth = std::min(std::abs(roll), bitmapWidth - 1); + const int stripX = (roll > 0) ? (bitmapWidth - stripWidth) : 0; + RenderLaneLayerRegion(laneIndex, layer, stripX, stripWidth, 0, 0, layer.wideMin, + layer.anchorYMax); + } + } + + bool PlotView::BlitWideBuffer(gd::Graphics& g, const std::vector& timeTicks) + { + const float plotWidth = m_plotRect.Width; + const double span = Tab().ViewMax() - Tab().ViewMin(); + if (!(plotWidth > 8.0f) || !(span > 0.0)) + { + return false; + } + const int width = static_cast(std::lround(plotWidth)); + const int margin = LayerMarginX(); + const int bitmapWidth = width + 2 * margin; + const double scale = static_cast(width) / span; + + /** 一次图层贴图(所有参数在释放图层锁之前算好,避免持锁期间做 GDI 绘制)。 */ + struct LayerBlit + { + HDC dc = nullptr; + int srcX = 0; + int srcY = 0; + int destX = 0; + int destY = 0; + int width = 0; + int height = 0; + }; + + std::vector blits; + std::vector backing; // 本帧拿不到(完整)图层像素的网格 + blits.reserve(m_lanes.size()); + backing.reserve(m_lanes.size()); + { + std::lock_guard lock(m_layerMutex); + if (m_layerFront.size() != m_lanes.size()) + { + return false; + } + for (std::size_t index = 0; index < m_layerFront.size(); ++index) + { + const LaneLayer& layer = m_layerFront[index]; + const gd::RectF& lane = m_lanes[index].rect; + const float top = std::max(lane.Y, m_plotRect.Y); + const float bottom = std::min(lane.Y + lane.Height, m_plotRect.Y + m_plotRect.Height); + if (bottom <= top) + { + continue; // 网格整体移出可视区 + } + const int bandHeight = static_cast(std::lround(bottom - top)); + // 纵向映射是否仍然可用:图层高度必须等于"当前网格高 + 上下预渲染余量", + // 且时间跨度必须与当前视界一致(跨度不同则像素的时间映射已经不对)。 + const int expectBitmapHeight = + static_cast(std::ceil(lane.Height)) + 2 * LayerMarginY(lane.Height); + bool usable = (layer.valid && layer.dc != nullptr && layer.width == bitmapWidth && + layer.height == expectBitmapHeight && layer.span > 0.0 && + std::fabs(layer.span - span) <= 1.0e-12); + + LayerBlit blit; + if (usable) + { + // ⚠️ **纵向偏移**:图层带「上下各半个网格高」的预渲染余量,幅值平移只改这里的 + // 源行偏移(`anchorYMax` 与当前 `yMax` 之差换算成像素),不必重画图层 —— + // 这就是"纵向拖动与横向拖动同样是 O(1) 贴图"的关键。 + int vOffset = 0; + if (layer.pxPerValue > 0.0) + { + vOffset = static_cast( + std::llround((layer.anchorYMax - m_lanes[index].yMax) * layer.pxPerValue)); + } + int srcY = static_cast(std::lround(top - lane.Y)) + layer.marginY + vOffset; + int height = bandHeight; + int destTopOffset = 0; + // 超出画布的上下两端只贴**有像素的那一段**(其余由网格底衬补齐)。 + if (srcY < 0) + { + destTopOffset = -srcY; + height -= destTopOffset; + srcY = 0; + } + if (srcY + height > layer.height) + { + height = layer.height - srcY; + } + // ⚠️ **局部贴图**:图层只覆盖"视界 ± 半屏",视界可能有一部分落在图层之外。 + // 这里只贴**有像素的那一段**,其余由底衬补齐;整块跳过才是"网格瞬间变空"的直接原因。 + const double subX = (Tab().ViewMin() - layer.wideMin) * scale; + int srcX = static_cast(std::lround(subX)); + int destOffset = 0; // 从视界左端起偏移多少像素开始贴 + int blitWidth = width; + if (srcX < 0) + { + destOffset = -srcX; + blitWidth -= destOffset; + srcX = 0; + } + if (srcX + blitWidth > bitmapWidth) + { + blitWidth = bitmapWidth - srcX; + } + if (height > 0 && blitWidth > 0) + { + blit.dc = layer.dc; + blit.srcX = srcX; + blit.srcY = srcY; + blit.destX = static_cast(std::lround(m_plotRect.X)) + destOffset; + blit.destY = static_cast(std::lround(top)) + destTopOffset; + blit.width = blitWidth; + blit.height = height; + blits.push_back(blit); + // 横向 / 纵向只有**部分覆盖**:该网格的其余部分也要靠底衬补齐。 + if (destOffset > 0 || blitWidth < width || height < bandHeight) + { + backing.push_back(static_cast(index)); + } + continue; + } + } + + // ⚠️⚠️ **必须兜底补齐网格底衬,绝不能留白**:旧实现在这里直接 `continue` + //(网格空白)或用旧跨度像素 `StretchBlt` 拉伸占位(深度放大时把一小段像素放大 + // 几十倍 → 整块网格变成一片模糊底色)—— 两者在用户眼里都是 + // 「网格叠底消失,没有任何内容」。改为就地补画底衬(底色 + 网格线 + 分界线): + // 代价极低,可以每帧执行,因此**网格底衬永远不会空白/闪烁**(用户 2026-10-17 要求)。 + backing.push_back(static_cast(index)); + } + } + + if (blits.empty()) + { + // ⚠️⚠️ **绝不能因为"本帧一个网格都贴不出图层像素"就退化成整幅重绘** + //(用户 2026-10-17 报告:滚动过渡动画在巨型信号处卡顿)。 + // 这一情形在滚动到**从未构建过图层的区域**时必然出现(上百个网格时绝大多数 + // 网格的图层都还没建立):旧实现在这里 `return false` ⇒ 落到 `Paint` 末尾的 + // **整幅重绘** ⇒ 把**本帧全部可见网格的波形**(可能包含上千�万采样的巨型信号) + // 每帧重画一遍 —— 实测单帧 **20~35ms**,而手动拖滚动条只要 ~6ms。 + // 正确行为:本帧只画**网格底衬**(廉价,见下方),波形等视界真正停下之后再按预算补 + //(这正是用户要求的“没来得及画信号时就按只有网格叠底的状态继续滚动”)。 + if (backing.empty()) + { + // 连底衬都没得画(布局变化 / 图层数组与网格数不符)→ 交给整幅重绘兜底。 + return false; + } + } + + // ⚠️ 顺序:**先画网格底衬(GDI+),再取得 HDC 做 BitBlt** —— + // `g.GetHDC()` 之后再调用 GDI+ 绘制接口是未定义行为。 + for (const int laneIndex : backing) + { + PaintLaneGridBacking(g, laneIndex, timeTicks); + } + + HDC target = g.GetHDC(); + if (target == nullptr) + { + return true; + } + for (const LayerBlit& blit : blits) + { + BitBlt(target, blit.destX, blit.destY, blit.width, blit.height, blit.dc, blit.srcX, blit.srcY, + SRCCOPY); + } + g.ReleaseHDC(target); + return true; + } + +} // namespace jdi diff --git a/Documents/UI_Refactor_Progress.md b/Documents/UI_Refactor_Progress.md index 3ce20c8..41e6b0d 100644 --- a/Documents/UI_Refactor_Progress.md +++ b/Documents/UI_Refactor_Progress.md @@ -17,7 +17,7 @@ | --- | --- | --- | | **P0** | 离屏回归网 `Tools/uicheck`(入库) | ✅ 完成(62 项断言全绿) | | **P1** | `UiLayout` + `UiWidget` 基础层 | ✅ 完成(编译零警告,`uicheck` 覆盖) | -| **P2** | `PlotViewPaint.cpp` 零行为分片 | ⏳ 待做 | +| **P2** | `PlotViewPaint.cpp` 零行为分片 | ✅ 完成(**逐像素 0 差异**,见 §2) | | **P3** | 迁移 chrome(Toolbar / TabBar / 菜单 / 拾色器 / 列表行) | ⏳ 待做 | | **P4** | 迁移 5 个浮动面板 | ⏳ 待做 | | **P5** | 波形管线包成叶子 surface + 共享 DIB | ⏳ 待做 | @@ -75,12 +75,41 @@ Tools/uicheck/uicheck.exe # 62 passed, 0 failed --- +### 2. `P2`:`PlotViewPaint.cpp` 按关注点分片(零行为变更) + +**为什么**:该文件 9 080 行里混住四类内容 —— 真绘制 ≈3.5k / 光栅化缓存 ≈2.5k / 缓存键 ≈1.1k / +布局与命中几何 ≈1.3k。按"函数名前缀"分片是上一轮膨胀的直接原因,本轮按**关注点**再分一次。 + +**做法**:**机械搬移成员函数定义**(不改一行逻辑)。`PlotViewInternal.hpp` 里的 `pvdetail` 全部 `inline` +⇒ 跨分片安全,无需改任何 include、无需改 `Makefile`(按 basename 自动收集 `.cpp`)。 + +| 文件 | 搬入内容 | 行数变化 | +| --- | --- | --- | +| `PlotViewRaster.cpp`(新) | 光晕离屏层(`PaintSignalHalo*` / `UploadHaloLayer` / `ReleaseHaloLayer`)
场景缓存(`Ensure/Capture/Blit/Invalidate/ReleaseSceneCache`)
装饰层(`Ensure/Release/Render/BlitDecorCache` / `ResolveAlphaBlend`)
宽基底图层与预算(`EnsureLaneLayer` … `BlitWideBuffer`、并行重建、滚动补条、预热) | 1 876 | +| `PlotViewCacheKey.cpp`(新) | `MakeSceneCacheKey` / `SceneCacheMatches` / `SceneHaloRef`
`SceneContentHash` / `SceneGeometryHash` / `SceneDecorHash`
`CollectSceneStyles` / `FindStyleOnlyLanes` / `CollectSceneDerived` / `RepaintSceneCachePatch` | 474 | +| `PlotViewPaint.cpp` | 只剩绘制(`Paint*` / `Render*` / 输入框绘制) | 9 080 → **6 252**(−31%) | + +**验证(关键)** + +1. `make release TARGET_FILE_NAME=TargetCheck` **零警告**;`Target.exe` 873 984 → **875 008 B**(+1 KB, + 属分片后各 TU 的对齐差异;仍远低于 900 KB 阈值)。 +2. **逐像素 A/B**:临时写了一个渲染哈希探针(仓库外),对三个场景渲染后备缓冲区并取 FNV 哈希: + * A 默认全览;B 深放大 + 自由标记(含固定点)+ 均值滤波 + 弱峰调整 + 时间光标;C 再平移一帧(触发宽基底滚动/补条)。 + * **分片前**:`A 2166933039626F66 / B C444ECFEB18FD73D / C 7984446D8E7A4418` + * **分片后**:**完全相同**(三个场景逐一一致)。 + * 该探针未入库(一次性验证用),结论记录在此以便复现。 +3. `Tools/uicheck` 62/62 全绿。 +4. 顶层注释从"四个分片"更新为"六个分片(按关注点)",并写明"`Paint/PaintLane` 仍只允许定义在 `PlotViewPaint.cpp`"。 + +**⚠️ 复现 A/B 的坑**:机械搬移后用旧的 `.bak` 回退时,`Build//` 里会残留 +`PlotViewRaster.o` / `PlotViewCacheKey.o`,链接探针(`Build/Release/*.o`)会因重复符号失败 —— +**必须先删掉这两个陈旧 `.o`** 再链接。 + +--- + ## 下一步(接手者请从这里开始) -1. **P2:`PlotViewPaint.cpp` 零行为分片**(9 080 行) - - 目标分片:`Paint`(只画)/ `Raster`(缓存与预算)/ `CacheKey`(指纹)/ `Geometry`(面板与命中几何)。 - - 做法:**机械搬移函数定义**,不改一行逻辑;`PlotViewInternal.hpp` 里的 `pvdetail` 全是 `inline`,跨分片安全。 - - 判据:`make release` 零警告 + `Target.exe` 体积变化 < 1KB + 场景哈希/像素快照与迁移前一致。 - - ⚠️ 搬移后务必确认没有残留的 `static` 文件级变量(只在 `PlotViewPaint.cpp` 内可见的辅助函数要一并搬走)。 +1. **P2 续**:把"布局与命中几何"也搬出去(`EdgePanelLayoutFor` 651 行 + `ProbeFreeMarker` 286 行 + + `FreeMarkerLabelRects` 118 行 → 新 `PlotViewPanels.cpp`),判据同上(逐像素 0 差异)。 2. **P3:Toolbar 优先**(最小、最容易验证的组件),迁移后跑 `uicheck` + 新旧像素对比。 3. 每个阶段单独提交,提交信息写清"改了什么 / 为什么 / 怎么验证"。 -- 2.54.0 From e610cea481585fc15b18566a6445db57985734f1 Mon Sep 17 00:00:00 2001 From: "DeepSeek V4.1 Flash" <> Date: Wed, 28 Oct 2026 12:20:06 +0800 Subject: [PATCH 04/21] =?UTF-8?q?UI=20=E9=87=8D=E6=9E=84=20P2=20=E7=BB=AD?= =?UTF-8?q?=EF=BC=9A=E6=8A=8A=E5=B8=83=E5=B1=80=E4=B8=8E=E5=91=BD=E4=B8=AD?= =?UTF-8?q?=E5=87=A0=E4=BD=95=E4=BB=8E=E7=BB=98=E5=88=B6=E5=88=86=E7=89=87?= =?UTF-8?q?=E6=8B=86=E5=87=BA=EF=BC=88PlotViewPanels.cpp=EF=BC=8C=E9=80=90?= =?UTF-8?q?=E5=83=8F=E7=B4=A0=200=20=E5=B7=AE=E5=BC=82=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit EdgePanelLayoutFor(651 行 / 19 个调用点)、EdgePanelWidth、LaneBadgeRect、 FreeMarkerValue/Delta/Contents/LabelRects、ProbeFreeMarker 都是「几何」而非「绘制」, 却塞在 9 千行的绘制文件里 —— 改布局要动整个绘制文件,回归面被放大。 本提交机械搬移这 5 个不连续块到新分片 PlotViewPanels.cpp(1,359 行),不改一行逻辑。 PlotViewPaint.cpp 6,645 -> 4,913 行;相对 UI_Refactor 起点 9,080 行共 -46%。 当前七个分片:Paint 4,913 / Interaction 3,430 / Features 3,266 / Raster 1,876 / Panels 1,359 / Core 889 / CacheKey 474。顶层注释改为「七个分片(按关注点)」。 验证: - make release TARGET_FILE_NAME=TargetCheck 零警告;Target.exe 877,568 B(< 900KB 阈值)。 - 逐像素 A/B:三场景哈希与迁移前完全一致 A 2166933039626F66、B C444ECFEB18FD73D、C 7984446D8E7A4418。 - Tools/uicheck 62 passed / 0 failed。 --- Components/PlotView/Src/PlotViewPaint.cpp | 1388 +------------------ Components/PlotView/Src/PlotViewPanels.cpp | 1409 ++++++++++++++++++++ Documents/UI_Refactor_Progress.md | 44 +- 3 files changed, 1452 insertions(+), 1389 deletions(-) create mode 100644 Components/PlotView/Src/PlotViewPanels.cpp diff --git a/Components/PlotView/Src/PlotViewPaint.cpp b/Components/PlotView/Src/PlotViewPaint.cpp index 1356126..d2777a6 100644 --- a/Components/PlotView/Src/PlotViewPaint.cpp +++ b/Components/PlotView/Src/PlotViewPaint.cpp @@ -1,13 +1,15 @@ // PlotView 实现分片 —— 绘制(paint):只负责"把内容画出来"。 // // 共用的常量与工具函数在 PlotViewInternal.hpp(命名空间 `pvdetail`,全部 inline)。 -// 六个分片的职责划分(按**关注点**,不再按函数名前缀): +// 七个分片的职责划分(按**关注点**,不再按函数名前缀): // PlotView.cpp 视图核心:构造/析构、上下文绑定、布局、坐标映射、视界与平移、 // 网格结构与归属、选区/网格划分、拖动取消 // PlotViewPaint.cpp 绘制:全部 Paint*/Render* 与内联输入框绘制(**本文件**) // PlotViewRaster.cpp 光栅化像素缓存与每帧预算:光晕层、场景缓存、装饰层、每网格宽基底 // 图层、滚动补条、预热("怎么画才不卡") // PlotViewCacheKey.cpp 内容指纹与缓存键:SceneCacheKey / *Hash / 样式与派生快照 / 样式补丁帧 +// PlotViewPanels.cpp 几何:浮动面板布局(EdgePanelWidth/EdgePanelLayoutFor)、图例徽标、 +// 「自由标记」的取值/胶囊矩形/探测(绘制与命中同源的唯一来源) // PlotViewInteraction.cpp 交互与编辑:命中测试、鼠标与键盘处理、光标形状、内联输入框、 // 浮动面板层序与命中 // PlotViewFeatures.cpp 视图特性与派生数据:光标状态与读数、提示浮层、缩放模式、自适应、 @@ -19,182 +21,6 @@ namespace jdi { - float PlotView::EdgePanelWidth(int laneIndex, EdgePanelKind kind) const - { - if (m_tab == nullptr || m_fonts == nullptr || laneIndex < 0 || - laneIndex >= static_cast(m_tab->Lanes().size())) - { - return kEdgePanelWidth * m_scale; - } - const Lane& lane = m_tab->Lanes()[static_cast(laneIndex)]; - const bool centerAxis = (kind == EdgePanelKind::CENTER); - const bool meanFilter = (kind == EdgePanelKind::MEAN); - const bool weakPeak = (kind == EdgePanelKind::PEAK); - // 「均值滤波」「中心轴边沿计数」「弱峰调整」都是逐条目面板(名称 + 复选框)。 - const bool itemized = (centerAxis || meanFilter || weakPeak); - - // 标题行 = “GX” 徽标 + 标题(“边沿计数” / “中心轴边沿计数” / “均值滤波” / “弱峰调整” - // + 光标区间尾缀)。 - const std::wstring title = - meanFilter ? std::wstring(L"均值滤波") - : (weakPeak ? std::wstring(kWeakPeakTitle) - : ((centerAxis ? L"中心轴边沿计数" : L"边沿计数") + EdgeRangeSuffix())); - float width = kEdgePanelPad * 2.0f * m_scale + kLaneBadgeWidth * m_scale + - 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 (itemized) - { - // 逐条目面板:第一排 = 名称 + 复选框;第三排(中心轴)= 统计结果。 - width = std::max(width, kEdgePanelPad * 2.0f * m_scale + nameWidth + - kEdgePanelMeanCheckSize * m_scale + 6.0f * m_scale); - } - std::size_t rising = 0; - std::size_t falling = 0; - bool complete = true; - if (meanFilter || weakPeak) - { - continue; - } - if (centerAxis) - { - 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); - } - else - { - 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, EdgeRisingText(rising, complete)), - pendingWidth)); - statFallingWidth = - std::max(statFallingWidth, - UiMeasureTextWidth(m_fonts->monoSmall, EdgeFallingText(falling, complete))); - } - if (statRisingWidth > 0.0f || statFallingWidth > 0.0f) - { - // 两列**等宽**(取两列最大值中的较大者)→ 绘制时两列起点固定, - // 多条信号的“上升沿 / 下降沿”文字严格垂直对齐(用户明确要求)。 - const float maxColumn = std::max(statRisingWidth, statFallingWidth); - const float countsWidth = maxColumn * 2.0f + kEdgePanelStatColumnGap * m_scale; - width = std::max(width, kEdgePanelPad * 2.0f * m_scale + - std::max(nameColumnWidth, countsWidth)); - } - - if (centerAxis || meanFilter) - { - // 参数行 = 两个「说明文字 + 数值框」,必须保证文字不被数值框压住。 - // (MEAN / CENTER:「均值点数 X」+「迭代次数 N」。标签与数值框各占半行。) - const float labelXWidth = UiMeasureTextWidth(m_fonts->uiSmall, L"均值点数 X"); - const float labelN = UiMeasureTextWidth(m_fonts->uiSmall, L"迭代次数 N"); - const float fieldWidth = - std::max(kEdgePanelEditFieldWidth * m_scale * 0.75f, - UiMeasureTextWidth(m_fonts->monoSmall, L"1000000") + 18.0f * m_scale); - const float halfWidth = std::max(labelXWidth, labelN) + 6.0f * m_scale + fieldWidth; - const float rowWidth = halfWidth * 2.0f + 8.0f * m_scale; - width = std::max(width, kEdgePanelPad * 2.0f * m_scale + rowWidth); - } - - if (weakPeak) - { - // 滑块行 = 「标签列 + 轨道(最小宽度)+ 数值列 + 复位按钮」(等级与迭代次数两行共用同一列宽)。 - const float rowWidth = WeakPeakLabelColumnWidth(m_fonts->uiSmall) + 6.0f * m_scale + - kWeakPeakTrackMinWidth * m_scale + 6.0f * m_scale + - WeakPeakValueColumnWidth(m_fonts->monoSmall) + - kWeakPeakResetGap * m_scale + kWeakPeakResetButtonSize * m_scale; - width = std::max(width, kEdgePanelPad * 2.0f * m_scale + rowWidth); - } - - if (meanFilter || weakPeak) - { - // 底部按钮行 = 「构建为新信号」,按按钮文字实测宽度确定下限。 - const float buttonWidth = - UiMeasureTextWidth(m_fonts->uiSmall, kBuildSignalLabel) + 28.0f * m_scale; - width = std::max(width, kEdgePanelPad * 2.0f * m_scale + buttonWidth); - } - - if (kind == EdgePanelKind::MARK) - { - // 「自由标记」面板:宽度 = 六列(取「缺省列宽」「实测内容宽度」「列标题宽度」三者 - // 的大者)+ 行 ✕ + 左右内边距 + **滚动条预留位**(内容超高时右侧要放滚动条)。 - // ⚠️ 用户 2026-10-21 要求面板里的数据列**不得出现省略号**,因此不能再按固定列宽。 - // 逐行取采样值算宽度会拖慢交互(本函数被 `TopPanelAtPoint` / `SyncPanelOrder` - // 高频调用),所以走 `Lane::freeMarkerColumnWidths`:绘制该面板时顺便量好真实 - // 文本宽度存起来,这里只读。还没量过(全 0)时退回缺省列宽。 - const bool hasEnum = [this, &lane]() { - for (const SignalRef& ref : lane.signals) - { - const ResolvedSignal resolved = ResolveSignal(ref); - if (resolved.data != nullptr && resolved.visible && - !resolved.data->enumText.Empty() && lane.HasFreeMarkers(ref)) - { - return true; - } - } - return false; - }(); - float columnsWidth = 0.0f; - for (int column = 0; column < 6; ++column) - { - const bool omitted = (column == EdgePanelLayout::MARKER_COL_ENUM) && !hasEnum; - if (omitted) - { - continue; - } - // 列宽还要容得下**表头的列标题**(如「时间间隔」),否则表头会被省略。 - const float labelWidth = - UiMeasureTextWidth(m_fonts->uiSmall, kFreeMarkerColumnLabels[column]) + - 2.0f * m_scale; - columnsWidth += std::max(std::max(kFreeMarkerColumnWidths[column] * m_scale, - lane.freeMarkerColumnWidths[static_cast(column)]), - labelWidth); - columnsWidth += kFreeMarkerColumnGap * m_scale; - } - // 行尾 ✕(按胶囊同高的正方形取宽)+ 滚动条预留 + 少量余量。 - const float rowCloseWidth = kFreeMarkerItemRowHeight * m_scale + 2.0f * m_scale; - const float markerWidth = kEdgePanelPad * 2.0f * m_scale + - (kFreeMarkerScrollWidth + 4.0f) * m_scale + columnsWidth + - rowCloseWidth; - width = std::max(width, std::max(kFreeMarkerPanelWidth * m_scale, markerWidth)); - } - - // 夹在绘图区宽度之内(过窄的网格也不至于把面板挤到外面)。 - return std::min(width, std::max(120.0f * m_scale, m_plotRect.Width - 8.0f * m_scale)); - } const PlotView::PolylineCacheEntry* PlotView::AcquirePolyline(const SignalRef& ref, const ResolvedSignal& resolved, @@ -1258,29 +1084,6 @@ namespace jdi } - gd::RectF PlotView::LaneBadgeRect(int laneIndex) const - { - if (laneIndex < 0 || laneIndex >= static_cast(m_lanes.size())) - { - return gd::RectF(); - } - const LaneVisual& lane = m_lanes[static_cast(laneIndex)]; - const gd::RectF legendBand(lane.rect.X + kLegendPad * m_scale, lane.rect.Y + kLegendPad * m_scale, - lane.rect.Width, kLegendHeight * m_scale); - // 网格被滚动出可视区时没有徽标可点。 - const float visibleTop = std::max(lane.rect.Y, m_plotRect.Y); - const float visibleBottom = - std::min(lane.rect.Y + lane.rect.Height, m_plotRect.Y + m_plotRect.Height); - if (!IsFullyVisible(legendBand, visibleTop, visibleBottom)) - { - return gd::RectF(); - } - - const float height = kLegendHeight * m_scale; - const float badgeHeight = kLaneBadgeHeight * m_scale; - return gd::RectF(legendBand.X, legendBand.Y + (height - badgeHeight) * 0.5f, - kLaneBadgeWidth * m_scale, badgeHeight); - } void PlotView::PaintLaneReorder(gd::Graphics& g) { @@ -3036,656 +2839,6 @@ namespace jdi } } - PlotView::EdgePanelLayout PlotView::EdgePanelLayoutFor(int laneIndex, EdgePanelKind kind) const - { - EdgePanelLayout layout; - if (m_tab == nullptr || m_fonts == nullptr || laneIndex < 0 || - laneIndex >= static_cast(m_tab->Lanes().size()) || - laneIndex >= static_cast(m_lanes.size())) - { - return layout; - } - const Lane& lane = m_tab->Lanes()[static_cast(laneIndex)]; - 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 itemized = (centerAxis || meanFilter || weakPeak); - const bool hasButton = (meanFilter || weakPeak); - const bool enabled = meanFilter ? lane.meanFilter - : (weakPeak ? lane.weakPeak - : (freeMarker ? lane.freeMarker - : (centerAxis ? lane.edgeCenter - : lane.edgeCount))); - const bool open = meanFilter ? lane.meanFilterPanel.open - : (weakPeak ? lane.weakPeakPanel.open - : (freeMarker ? lane.freeMarkerPanel.open - : (centerAxis ? lane.edgeCenterPanel.open - : lane.edgePanel.open))); - if (!enabled || !open) - { - return layout; - } - // 面板的**折起**状态(只显示标题栏)。折起时仍然算宽度与位置,但内容区全部省略。 - const bool collapsed = meanFilter ? lane.meanFilterPanel.collapsed - : (weakPeak ? lane.weakPeakPanel.collapsed - : (freeMarker ? lane.freeMarkerPanel.collapsed - : (centerAxis - ? lane.edgeCenterPanel.collapsed - : lane.edgePanel.collapsed))); - layout.collapsed = collapsed; - - int rowCount = 0; - for (const SignalRef& ref : lane.signals) - { - const ResolvedSignal resolved = ResolveSignal(ref); - if (resolved.data != nullptr && resolved.visible) - { - ++rowCount; - } - } - if (rowCount <= 0) - { - return layout; - } - layout.rows = rowCount; - - const float width = EdgePanelWidth(laneIndex, kind); - const float titleHeight = kEdgePanelTitleHeight * m_scale; - const float rowHeight = kEdgePanelRowHeight * m_scale; - const float pad = kEdgePanelPad * m_scale; - // 「中心轴边沿计数」「均值滤波」「弱峰调整」共用同一套「逐条目」排布, - // 因此不再有独立的底部编辑行;只有「边沿计数」保留两行文本式排版。 - const float buttonHeight = hasButton ? (kEdgePanelButtonHeight * m_scale) : 0.0f; - const float rowsPerSignal = meanFilter ? 1.0f : 2.0f; - - // 逐条目面板:每个条目 = 第一排(信号名 + 复选框) - // + 参数部分(**只在该条目被勾选时才占位**,因此面板高度随勾选数量动态变化): - // * MEAN / CENTER:一行「均值点数 X + 迭代次数 N」输入框(中心轴再多一行统计结果); - // * PEAK:两行滑块(调整幅度 % / 迭代次数 N),每个信号各有一套自己的参数。 - const bool itemHasParams = (centerAxis || meanFilter || weakPeak); - const float weakPeakSliderRow = kWeakPeakSliderRowHeight * m_scale; - float meanRowHeight = 0.0f; - float itemsHeight = 0.0f; - float height = 0.0f; - if (itemized) - { - meanRowHeight = kEdgePanelMeanRowHeight * m_scale; - for (const SignalRef& ref : lane.signals) - { - const ResolvedSignal resolved = ResolveSignal(ref); - if (resolved.data == nullptr || !resolved.visible) - { - continue; - } - itemsHeight += meanRowHeight; - const bool checked = meanFilter ? lane.MeanFilterEnabled(ref) - : (weakPeak ? lane.WeakPeakEnabled(ref) - : lane.EdgeCenterEnabled(ref)); - if (checked && itemHasParams) - { - if (weakPeak) - { - itemsHeight += weakPeakSliderRow * 2.0f; - } - else - { - itemsHeight += (kEdgePanelMeanEditHeight + kEdgePanelMeanRowGap) * m_scale; - if (centerAxis) - { - itemsHeight += kEdgePanelMeanRowHeight * m_scale; - } - } - } - } - // 底部按钮行:上方与条目之间、下方与面板下边界之间各留一个 `pad`, - // 与面板的左右内边距完全一致(原先按钮直接贴着下边框,看起来很不协调)。 - height = titleHeight + pad + itemsHeight + pad + buttonHeight + (hasButton ? pad : 0.0f); - } - else - { - height = titleHeight + pad + static_cast(rowCount) * rowHeight * rowsPerSignal + pad; - } - - // 「自由标记」面板:按信号分组(**分组栏** + 下属固定点条目), - // 只列出确实有固定点的信号;没有任何固定点时给一行灰色提示。 - // 底部固定区(自上而下):标记透明度滑块 → 「自由标记点选取」→ 两个显示开关。 - bool markerAny = false; - for (const Lane::FreeMarkerItem& item : lane.freeMarkerItems) - { - if (item.ref.IsValid()) - { - markerAny = true; - break; - } - } - if (freeMarker) - { - const float groupRow = kFreeMarkerNameRowHeight * m_scale; - const float headerRow = kFreeMarkerHeaderRowHeight * m_scale; - const float itemRow = kFreeMarkerItemRowHeight * m_scale; - const float markerButtonHeight = kEdgePanelButtonHeight * m_scale; - // 「标记透明度」滑块行**始终显示**(用户 2026-10-21 要求:不再依附于「自由标记点选取」按钮)。 - const float sliderRow = kFreeMarkerSliderRowHeight * m_scale; - // 「显示增量 / 显示枚举文本」两个开关按钮独占一行,位于「自由标记点选取」下方 - //(用户 2026-10-23 要求)。 - const float switchRow = kEdgePanelButtonHeight * m_scale; - const float buttonGap = kFreeMarkerButtonGap * m_scale; - const float footer = sliderRow + markerButtonHeight + buttonGap + switchRow; - float content = 0.0f; - std::vector order; - for (const SignalRef& ref : lane.signals) - { - const ResolvedSignal resolved = ResolveSignal(ref); - if (resolved.data == nullptr || !resolved.visible) - { - continue; - } - // ⚠️ 用户明确要求:某个信号没有任何固定点时,它的分组栏**不出现**在面板上。 - SortedFreeMarkerIndices(lane, ref, order); - if (order.empty()) - { - continue; - } - content += groupRow; - if (!lane.FreeMarkerGroupCollapsed(ref)) - { - // 展开的分组:分组栏 + **表头行** + 条目。 - content += headerRow + static_cast(order.size()) * itemRow; - } - } - if (!markerAny) - { - // 还没有任何固定点:留一行提示文案(绘制时画「在网格内单击以固定数据点」)。 - content += groupRow; - } - // 内容区高度上限:太长的列表不能把面板顶出绘图区(超出部分靠滚动条翻页)。 - const float budget = m_plotRect.Height - (titleHeight + pad * 3.0f + footer); - const float maxContent = - std::max(2.0f * itemRow, - std::min(kFreeMarkerContentMaxHeight * m_scale, std::max(2.0f * itemRow, budget))); - const float view = std::min(content, maxContent); - height = titleHeight + pad + view + pad + footer + pad; - } - - // 折起的面板:只剩标题栏(内容区完全不占高度)。 - if (collapsed) - { - height = titleHeight; - } - - // 首次使用(位置为负)时自动放到绘图区**右上角**,并按网格在**当前可见网格中的序数** - // 依次错开,避免完全重叠。 - // ⚠️ 旧实现用 `laneIndex` 错开 —— 上百个网格时 `laneIndex` 很大, - // `16 + laneIndex*28` 早就超过绘图区高度、被下面的夹取推到**右下角** - //(用户 2026-10-17 报告:“在当前范围内的网格开启实验性功能,面板首次出现在右下角”)。 - // 改用**可见序数**:它的量级只有一屏的网格数(约 20),再夹到“面板上沿不超过绘图区 - // 中间偏下”以内,于是面板始终停**右上区域**,同时保留“依次错开”的观感。 - const double storedX = meanFilter ? lane.meanFilterPanel.x - : (weakPeak ? lane.weakPeakPanel.x - : (freeMarker ? lane.freeMarkerPanel.x - : (centerAxis ? lane.edgeCenterPanel.x - : lane.edgePanel.x))); - const double storedY = meanFilter ? lane.meanFilterPanel.y - : (weakPeak ? lane.weakPeakPanel.y - : (freeMarker ? lane.freeMarkerPanel.y - : (centerAxis ? lane.edgeCenterPanel.y - : lane.edgePanel.y))); - int openPanelOrdinal = 0; - { - // ⚠️ 错开量用「**当前真正显示出来的面板**中排在本面板之前的个数」: - // * 第一个打开的面板 ⇒ 0 ⇒ 正好贴在右上角(用户 2026-10-17 要求“应该在右上角”); - // * 后续面板依次下移 28 逻辑像素,避免与已打开的面板完全重叠; - // * 遍历顺序固定为(网格下标 → 面板种类)⇒ 每个面板的槽位稳定、与帧无关。 - // ⚠️ 不能用 `laneIndex`(上百个网格时会把面板推到右下角); - // ⚠️ 也不能只看 `*Panel.open`:该标志**默认就是 true**,必须同时看功能是否启用 - // (与 `EdgePanelLayoutFor` 的 `enabled && open` 判定一致)。 - const auto shown = [](const Lane& lane, 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; - } - return false; - }; - static const EdgePanelKind kOrder[5] = {EdgePanelKind::COUNT, EdgePanelKind::CENTER, - EdgePanelKind::MEAN, EdgePanelKind::PEAK, - EdgePanelKind::MARK}; - 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) - { - if (!shown(tabLane, panelKind)) - { - continue; - } - if (lane == static_cast(laneIndex) && panelKind == kind) - { - found = true; - break; - } - ++openSeen; - } - } - openPanelOrdinal = found ? openSeen : 0; - } - // 错开量夹到“面板下沿不超过绘图区 45% 高度”以内 ⇒ 首现位置永远在右上区域。 - const float maxStagger = std::max(0.0f, m_plotRect.Height * 0.45f - height - 16.0f * m_scale); - const float stagger = - std::min(static_cast(openPanelOrdinal) * 28.0f * m_scale, maxStagger); - float left = (storedX >= 0.0) - ? static_cast(storedX) - : (m_plotRect.X + m_plotRect.Width - width - 16.0f * m_scale); - float top = (storedY >= 0.0) ? static_cast(storedY) - : (m_plotRect.Y + 16.0f * m_scale + stagger); - - // 面板始终限制在绘图区内(用户明确要求"可以在右侧的整个绘制区域拖动")。 - left = std::max(m_plotRect.X + 2.0f * m_scale, - std::min(left, m_plotRect.X + m_plotRect.Width - width - 2.0f * m_scale)); - top = std::max(m_plotRect.Y + 2.0f * m_scale, - std::min(top, m_plotRect.Y + m_plotRect.Height - height - 2.0f * m_scale)); - - layout.panel = gd::RectF(left, top, width, height); - // 只有标题栏可以拖拽;✕ 位于标题栏右端,命中时优先按“关闭按钮”处理。 - layout.title = gd::RectF(left, top, width, titleHeight); - const float closeSize = titleHeight; - layout.close = gd::RectF(left + width - closeSize, top, closeSize, closeSize); - // 标题栏左侧的「展开 / 折起」三角指示器(位于 GX 徽标左边)。 - // 折起后面板只剩标题栏,因此这个指示器是唯一的“重新展开”入口(双击标题栏亦可)。 - layout.disclosure = gd::RectF(left + pad, top + (titleHeight - kEdgePanelDisclosureWidth * m_scale) * 0.5f, - kEdgePanelDisclosureWidth * m_scale, - kEdgePanelDisclosureWidth * m_scale); - // 标题栏左侧的「GX」编号徽标:绘制、悬停提示、手型光标与“点击滚动到该网格” - // 全部以这个矩形为准(唯一几何来源,避免“画的”和“点的”漂移)。 - layout.badge = - gd::RectF(left + pad + kEdgePanelDisclosureWidth * m_scale, - top + (titleHeight - kLaneBadgeHeight * m_scale) * 0.5f, kLaneBadgeWidth * m_scale, - kLaneBadgeHeight * m_scale); - // 折起状态:到此为止(内容区、按钮、滚动条全部为空)。 - if (collapsed) - { - return layout; - } - if (itemized) - { - const float buttonRowHeight = kEdgePanelButtonHeight * m_scale; - if (hasButton) - { - // 底部按钮与面板下边界之间留出与左右、与内容相同的 `pad` 间距 - //(原先按钮行直接贴着下边框,视觉上与其它元素不协调); - // 面板高度已含这份 pad,因此这里只上移按钮行即可。 - layout.button = gd::RectF(left + pad, top + height - pad - buttonRowHeight, - std::max(0.0f, width - pad * 2.0f), buttonRowHeight); - } - - // 逐信号条目:第一排 = 名称 + 复选框(复选框靠右); - // 参数部分:MEAN / CENTER = 一行两个输入框(中心轴再多一行统计结果); - // PEAK = 两行滑块(每个信号各有自己的等级与迭代次数)。 - const float checkSize = kEdgePanelMeanCheckSize * m_scale; - const float fieldHeight = kEdgePanelMeanEditHeight * m_scale; - const float halfWidth = std::max(40.0f, (width - pad * 2.0f - 8.0f * m_scale) * 0.5f); - // 滑块行列宽(与 `EdgePanelWidth` 使用同一组算式,避免布局与宽度计算漂移)。 - const float weakPeakLabelWidth = WeakPeakLabelColumnWidth(m_fonts->uiSmall); - const float weakPeakValueWidth = WeakPeakValueColumnWidth(m_fonts->monoSmall); - const float weakPeakResetSize = kWeakPeakResetButtonSize * m_scale; - const float weakPeakResetGap = kWeakPeakResetGap * m_scale; - const float weakPeakTrackHeight = kWeakPeakTrackHeight * m_scale; - const float weakPeakTrackLeft = left + pad + weakPeakLabelWidth + 6.0f * m_scale; - const float weakPeakTrackWidth = - std::max(10.0f * m_scale, - (left + width - pad - weakPeakValueWidth - weakPeakResetGap - - weakPeakResetSize - 6.0f * m_scale) - - weakPeakTrackLeft); - float rowY = top + titleHeight + pad; - for (const SignalRef& ref : lane.signals) - { - const ResolvedSignal resolved = ResolveSignal(ref); - if (resolved.data == nullptr || !resolved.visible) - { - continue; - } - - layout.rowRefs.push_back(ref); - layout.rowChecks.push_back(gd::RectF( - left + width - pad - checkSize, rowY + (meanRowHeight - checkSize) * 0.5f, checkSize, - checkSize)); - const bool checked = meanFilter ? lane.MeanFilterEnabled(ref) - : (weakPeak ? lane.WeakPeakEnabled(ref) - : lane.EdgeCenterEnabled(ref)); - if (weakPeak) - { - // 每个被勾选的信号各有两行滑块:调整幅度(-400% – +400%)与迭代次数(1 … 上限)。 - if (checked) - { - const float levelRowTop = rowY + meanRowHeight; - const float iterRowTop = levelRowTop + weakPeakSliderRow; - const gd::RectF levelTrack( - weakPeakTrackLeft, - levelRowTop + (weakPeakSliderRow - weakPeakTrackHeight) * 0.5f, - weakPeakTrackWidth, weakPeakTrackHeight); - const gd::RectF iterTrack( - weakPeakTrackLeft, - iterRowTop + (weakPeakSliderRow - weakPeakTrackHeight) * 0.5f, - weakPeakTrackWidth, weakPeakTrackHeight); - layout.rowLevelTracks.push_back(levelTrack); - layout.rowLevelValues.push_back(gd::RectF( - levelTrack.X + levelTrack.Width + 6.0f * m_scale, levelRowTop, - weakPeakValueWidth, weakPeakSliderRow)); - layout.rowIterTracks.push_back(iterTrack); - layout.rowIterValues.push_back(gd::RectF( - iterTrack.X + iterTrack.Width + 6.0f * m_scale, iterRowTop, - weakPeakValueWidth, weakPeakSliderRow)); - // 每行滑块右侧的**复位按钮**(与数值列同一右边界,保证两行上下对齐)。 - const float resetX = levelTrack.X + levelTrack.Width + 6.0f * m_scale + - weakPeakValueWidth + weakPeakResetGap; - layout.rowLevelResets.push_back( - gd::RectF(resetX, levelRowTop + (weakPeakSliderRow - weakPeakResetSize) * 0.5f, - weakPeakResetSize, weakPeakResetSize)); - layout.rowIterResets.push_back( - gd::RectF(resetX, iterRowTop + (weakPeakSliderRow - weakPeakResetSize) * 0.5f, - weakPeakResetSize, weakPeakResetSize)); - } - else - { - 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.rowPoints.push_back(gd::RectF()); - layout.rowIterations.push_back(gd::RectF()); - layout.rowStats.push_back(gd::RectF()); - - rowY += meanRowHeight; - if (checked) - { - rowY += weakPeakSliderRow * 2.0f; - } - continue; - } - if (checked && itemHasParams) - { - const float editTop = rowY + meanRowHeight; - layout.rowPoints.push_back( - gd::RectF(left + pad, editTop, halfWidth, fieldHeight)); - layout.rowIterations.push_back( - gd::RectF(left + pad + halfWidth + 8.0f * m_scale, editTop, halfWidth, fieldHeight)); - if (centerAxis) - { - layout.rowStats.push_back( - gd::RectF(left + pad, editTop + fieldHeight, width - pad * 2.0f, - kEdgePanelMeanRowHeight * m_scale)); - } - else - { - layout.rowStats.push_back(gd::RectF()); - } - } - else - { - layout.rowPoints.push_back(gd::RectF()); - layout.rowIterations.push_back(gd::RectF()); - layout.rowStats.push_back(gd::RectF()); - } - - rowY += meanRowHeight; - if (checked && itemHasParams) - { - rowY += (kEdgePanelMeanEditHeight + kEdgePanelMeanRowGap) * m_scale; - if (centerAxis) - { - rowY += kEdgePanelMeanRowHeight * m_scale; - } - } - } - } - - if (freeMarker) - { - // 「自由标记」面板:**只列出确实有固定点的信号**,每个信号一个**分组栏** - //(信号色托底 + 左侧展开/收起三角 + 信号名 + 右侧「N 标记点」与 ✕), - // 下面逐行列出它的固定点(组内按时间升序 == 从上到下),行尾再带一个 ✕。 - // 内容区超高时右侧出现滚动条;底部按钮与「标记透明度」滑块**固定**不滚动。 - const float groupRow = kFreeMarkerNameRowHeight * m_scale; - const float headerRow = kFreeMarkerHeaderRowHeight * m_scale; - const float itemRow = kFreeMarkerItemRowHeight * m_scale; - const float closeSize = kFreeMarkerCloseSize * m_scale; - const float markerButtonHeight = kEdgePanelButtonHeight * m_scale; - // 「标记透明度」独立于「自由标记点选取」:**总是**显示在按钮上方。 - const float sliderRow = kFreeMarkerSliderRowHeight * m_scale; - const float scrollW = kFreeMarkerScrollWidth * m_scale; - // 「显示增量 / 显示枚举文本」两个开关按钮:独占底部最后一行(左右各半个宽度)。 - const float switchRow = kEdgePanelButtonHeight * m_scale; - const float buttonGap = kFreeMarkerButtonGap * m_scale; - - // 底部固定区(自上而下):标记透明度滑块 → 「自由标记点选取」→ 两个显示开关。 - { - const float switchTop = top + height - pad - switchRow; - const float halfWidth = - std::max(0.0f, (width - pad * 2.0f - buttonGap) * 0.5f); - layout.showDelta = gd::RectF(left + pad, switchTop, halfWidth, switchRow); - layout.showEnum = - gd::RectF(left + pad + halfWidth + buttonGap, switchTop, halfWidth, switchRow); - // 「自由标记点选取」切换按钮(整行宽度)。 - layout.button = gd::RectF(left + pad, switchTop - buttonGap - markerButtonHeight, - std::max(0.0f, width - pad * 2.0f), markerButtonHeight); - } - { - // 「标记透明度」行:与信号样式区的「透明度」行同一套排版 - //(左侧标签 + 中间轨道 + 右侧百分比),位置固定在按钮上方。 - // ⚠️ 无论是否启用「自由标记点选取」都显示(用户 2026-10-21 要求)。 - const float rowTop = layout.button.Y - sliderRow; - const float valueWidth = - std::max(36.0f * m_scale, - UiMeasureTextWidth(m_fonts->monoSmall, L"100%") + 6.0f * m_scale); - const float labelWidth = - UiMeasureTextWidth(m_fonts->uiSmall, kFreeMarkerOpacityLabel) + 6.0f * m_scale; - const float trackLeft = left + pad + labelWidth; - const float trackRight = left + width - pad - valueWidth - 6.0f * m_scale; - const float trackHeight = kWeakPeakTrackHeight * m_scale; - layout.opacityTrack = - gd::RectF(trackLeft, rowTop + (sliderRow - trackHeight) * 0.5f, - std::max(20.0f * m_scale, trackRight - trackLeft), trackHeight); - layout.opacityValue = - gd::RectF(trackRight + 6.0f * m_scale, rowTop, valueWidth, sliderRow); - } - - // 内容区高度(面板高度算式里的 `view` 部分)与"内容总高"决定是否需要滚动条。 - const float viewHeight = std::max( - 0.0f, height - titleHeight - pad * 3.0f - markerButtonHeight - sliderRow - - buttonGap - switchRow); - float totalContent = 0.0f; - std::vector order; - for (const SignalRef& ref : lane.signals) - { - const ResolvedSignal resolved = ResolveSignal(ref); - if (resolved.data == nullptr || !resolved.visible) - { - continue; - } - SortedFreeMarkerIndices(lane, ref, order); - if (order.empty()) - { - continue; - } - totalContent += groupRow; - if (!lane.FreeMarkerGroupCollapsed(ref)) - { - totalContent += headerRow + static_cast(order.size()) * itemRow; - } - } - if (!markerAny) - { - 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)); - 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); - } - - // 六列(① 序号 ② 标记值 ③ 枚举文本 ④ 增量 ⑤ 时间点 ⑥ 时间间隔) - // 从左往右排布:每一行的同类信息都从同一横坐标开始 ⇒ 纵向上严格对齐(用户要求)。 - // ⚠️ 整个面板里没有任何枚举文本时,③ 枚举列**整列缺省**(列宽记 0,后面的列左移)。 - // 这里只查"该信号是否带枚举表"(O(1)),**不**逐条采样(布局函数被高频调用)。 - bool hasEnum = false; - for (const SignalRef& ref : lane.signals) - { - const ResolvedSignal resolved = ResolveSignal(ref); - if (resolved.data != nullptr && resolved.visible && !resolved.data->enumText.Empty() && - lane.HasFreeMarkers(ref)) - { - hasEnum = true; - break; - } - } - layout.markerHasEnumColumn = hasEnum; - float columnX = left + pad; - for (int column = 0; column < 6; ++column) - { - const bool omitted = (column == EdgePanelLayout::MARKER_COL_ENUM) && !hasEnum; - // ⚠️ 列宽取「缺省列宽」「实测内容宽度」与**表头列标题宽度**三者的大者: - // 面板里不再出现省略号(“时间间隔”四个字比缺省列宽 78 还宽)。 - const float columnWidth = - omitted ? 0.0f - : std::max(std::max(kFreeMarkerColumnWidths[column] * m_scale, - lane.freeMarkerColumnWidths[static_cast(column)]), - UiMeasureTextWidth(m_fonts->uiSmall, kFreeMarkerColumnLabels[column]) + - 2.0f * m_scale); - layout.markerColumns[column].left = columnX; - layout.markerColumns[column].width = columnWidth; - columnX += columnWidth + (omitted ? 0.0f : kFreeMarkerColumnGap * m_scale); - } - - const float contentBottom = layout.content.Y + layout.content.Height; - const float contentTop = layout.content.Y; - float rowY = contentTop - scroll; - for (const SignalRef& ref : lane.signals) - { - const ResolvedSignal resolved = ResolveSignal(ref); - if (resolved.data == nullptr || !resolved.visible) - { - continue; - } - // 该信号没有任何固定点 ⇒ 分组栏与数据行都**不出现**(用户明确要求)。 - SortedFreeMarkerIndices(lane, ref, order); - if (order.empty()) - { - continue; - } - EdgePanelLayout::MarkerGroup group; - group.ref = ref; - group.collapsed = lane.FreeMarkerGroupCollapsed(ref); - const float itemsHeight = - group.collapsed ? 0.0f : (headerRow + static_cast(order.size()) * itemRow); - const float groupHeight = groupRow + itemsHeight; - - // ---- 吸顶(sticky):分组栏与表头**各自**钉住,先后移出 ---- - // 规则(用户 2026-10-22): - // * 该分组还有条目没显示完全 ⇒ 分组栏钉在内容区顶部、表头钉在分组栏下方; - // * 到分组末尾时顺序是:**表头**先向上滚(被分组栏盖住)→ 最后一个条目滚到 - // 分组栏下 → **分组栏**最后移出内容区。 - // 实现(每个元素各自的“吸顶 + 推挤”夹取): - // y = max(自然位置, min(吸顶位置, 本组下边界 - 自身高度)) - // 夹取上限保证元素不会被推离自己的分组(这正是“推挤”效果)。 - float barTop = rowY; - float headerTop = rowY + groupRow; - if (!group.collapsed) - { - const float maxHeaderTop = rowY + groupHeight - headerRow; - headerTop = std::max(rowY + groupRow, std::min(contentTop + groupRow, maxHeaderTop)); - } - { - const float maxBarTop = rowY + groupHeight - groupRow; - barTop = std::max(rowY, std::min(contentTop, maxBarTop)); - } - group.pinned = (barTop > rowY + 0.5f); - group.header = gd::RectF(layout.content.X, barTop, innerWidth, groupRow); - group.triangle = gd::RectF(layout.content.X + 3.0f * m_scale, - barTop + (groupRow - 12.0f * m_scale) * 0.5f, - 12.0f * m_scale, 12.0f * m_scale); - group.close = gd::RectF(layout.content.X + innerWidth - closeSize - 2.0f * m_scale, - barTop + (groupRow - closeSize) * 0.5f, closeSize, closeSize); - float pinnedBottom = barTop + groupRow; - if (!group.collapsed) - { - group.tableHeader = gd::RectF(layout.content.X, headerTop, innerWidth, headerRow); - pinnedBottom = std::max(pinnedBottom, headerTop + headerRow); - } - // 被(吸顶的)分组栏 / 表头盖住的那一片区域:其下的条目既不可绘制也不可命中。 - const float pinnedTop = std::min(barTop, group.tableHeader.Width > 0.0f ? headerTop : barTop); - const auto coveredByPinned = [pinnedTop, pinnedBottom](const gd::RectF& rect) - { - return rect.Y < pinnedBottom - 0.01f && rect.Y + rect.Height > pinnedTop + 0.01f; - }; - if (group.header.Y + group.header.Height > contentTop && group.header.Y < contentBottom) - { - // 只登记"在可视内容区里"的分组栏与条目(滚出视界的条目不得抢走 - // 底部按钮 / 滑块的点击)。 - layout.markerGroups.push_back(group); - layout.rowRefs.push_back(ref); - layout.rowNameRects.push_back(group.header); - layout.rowNameCloses.push_back(group.close); - } - rowY += groupRow; - if (group.collapsed) - { - continue; - } - rowY += headerRow; - for (std::size_t orderPosition = 0; orderPosition < order.size(); ++orderPosition) - { - const std::size_t itemIndex = order[orderPosition]; - EdgePanelLayout::MarkerRow markerRow; - markerRow.itemIndex = itemIndex; - // 「序号」列:组内按时间升序从 1 开始(每组 = 一个信号)。 - markerRow.serial = orderPosition + 1; - markerRow.rect = gd::RectF(layout.content.X, rowY, innerWidth, itemRow); - markerRow.close = gd::RectF(layout.content.X + innerWidth - closeSize, - rowY + (itemRow - closeSize) * 0.5f, closeSize, - closeSize); - // 被吸顶的分组栏 / 表头完全盖住的条目既不绘制也不可命中。 - if (markerRow.rect.Y + markerRow.rect.Height > contentTop && - markerRow.rect.Y < contentBottom && !coveredByPinned(markerRow.rect)) - { - layout.markerRows.push_back(markerRow); - } - rowY += itemRow; - } - } - } - return layout; - } void PlotView::PaintDataMarker(gd::Graphics& g, const DataMarker& marker, float radius, std::uint32_t fillColor, std::uint32_t ringColor) const @@ -4352,256 +3505,6 @@ namespace jdi return hash; } - bool PlotView::FreeMarkerValue(const Lane& lane, const Lane::FreeMarkerItem& item, double& value, - double& enumValue) const - { - if (m_dataSet == nullptr) - { - return false; - } - const ResolvedSignal resolved = ResolveSignal(item.ref); - if (resolved.data == nullptr || resolved.style == nullptr || resolved.axis == nullptr || - !resolved.visible) - { - return false; - } - // 固定点存的是**显示时刻**,因此要用 `SampleDisplayedSignalAt`(内部处理时间偏移与 - // 水平镜像)。散点模式下不插值,取最近采样点,与光标读数的规则一致。 - const bool interpolate = (resolved.style->mode != DrawMode::SCATTER); - double sampled = 0.0; - if (!SampleDisplayedSignalAt(*resolved.axis, *resolved.data, *resolved.style, item.time, - interpolate, sampled) || - !std::isfinite(sampled)) - { - return false; - } - (void)lane; - enumValue = sampled; // 枚举按**原值**查 - value = ApplyDrawTransform(*resolved.style, sampled); // 读数与画出来的波形一致 - return std::isfinite(value); - } - - bool PlotView::FreeMarkerDelta(const Lane& lane, std::size_t itemIndex, double value, bool& hasPrev, - double& delta, double& timeDelta) const - { - hasPrev = false; - delta = 0.0; - timeDelta = 0.0; - if (itemIndex >= lane.freeMarkerItems.size()) - { - return false; - } - const Lane::FreeMarkerItem& item = lane.freeMarkerItems[itemIndex]; - std::vector order; - SortedFreeMarkerIndices(lane, item.ref, order); - // 找到自己在**排序后序列**里的位置;前一个即「左边最近的那个固定点」。 - std::size_t position = order.size(); - for (std::size_t index = 0; index < order.size(); ++index) - { - if (order[index] == itemIndex) - { - position = index; - break; - } - } - if (position == order.size() || position == 0) - { - return true; // 最左边(或找不到):没有增量 - } - const Lane::FreeMarkerItem& previous = lane.freeMarkerItems[order[position - 1]]; - double previousValue = 0.0; - double previousEnum = 0.0; - if (!FreeMarkerValue(lane, previous, previousValue, previousEnum)) - { - return true; // 前一个点没有有效取值:不显示增量(避免编造一个数) - } - hasPrev = true; - delta = value - previousValue; - timeDelta = item.time - previous.time; - return true; - } - - const std::vector& PlotView::FreeMarkerContents(int laneIndex) const - { - static const std::vector kNoEntries; - if (m_tab == nullptr || laneIndex < 0 || - laneIndex >= static_cast(m_tab->Lanes().size())) - { - return kNoEntries; - } - const Lane& lane = m_tab->Lanes()[static_cast(laneIndex)]; - if (static_cast(laneIndex) >= m_freeMarkerContent.size()) - { - m_freeMarkerContent.resize(static_cast(laneIndex) + 1); - } - FreeMarkerContentCache& cache = m_freeMarkerContent[static_cast(laneIndex)]; - - // 缓存键:只混入**与视界无关**的输入(条目、信号样式、数据地址与长度)。 - // ⚠️ 绝不能混入 `ViewMin/ViewMax`、鼠标位置或网格几何 —— 否则平移视界、 - // 拖动时间光标时缓存永远命中不了,又回到"每帧对每个固定点采样一次"。 - std::uint64_t key = 1469598103934665603ull; - const auto mix = [&key](std::uint64_t value) { - key ^= value; - key *= 1099511628211ull; - }; - const auto mixFloat = [&mix](float value) { - std::uint32_t bits = 0; - std::memcpy(&bits, &value, sizeof(bits)); - mix(static_cast(bits)); - }; - const auto mixDouble = [&mix](double value) { - std::uint64_t bits = 0; - std::memcpy(&bits, &value, sizeof(bits)); - mix(bits); - }; - mix(static_cast(lane.freeMarkerItems.size())); - // ⚠️ 两个显示开关只影响胶囊文本,但同样属于“与视界无关的输入”,必须进键。 - mix(lane.freeMarkerShowDelta ? 1ull : 0ull); - mix(lane.freeMarkerShowEnum ? 1ull : 0ull); - for (const Lane::FreeMarkerItem& item : lane.freeMarkerItems) - { - mix(static_cast(static_cast(item.ref.groupIndex))); - mix(static_cast(static_cast(item.ref.signalIndex))); - mixDouble(item.time); - const ResolvedSignal resolved = ResolveSignal(item.ref); - if (resolved.data == nullptr || resolved.style == nullptr) - { - mix(0ull); - continue; - } - mix(reinterpret_cast(resolved.data)); - mix(static_cast(resolved.data->values.size())); - mix(resolved.visible ? 1ull : 2ull); - mix(static_cast(resolved.style->color)); - mixFloat(resolved.style->opacity); - mixFloat(resolved.style->gain); - mixFloat(resolved.style->offsetY); - mixDouble(resolved.style->offsetTime); - mix(static_cast(static_cast(resolved.style->mode))); - mix((resolved.style->absolute ? 1ull : 0ull) | (resolved.style->mirrorVertical ? 2ull : 0ull) | - (resolved.style->mirrorHorizontal ? 4ull : 0ull)); - // ⚠️ 枚举表的内容决定「枚举文本」列与胶囊里的 `= <文本>`(以及缺省时的 `N/A`): - // 旧实现只混了信号指针与采样数量 ⇒ 重新加载/替换枚举表后仍贴回旧文本 - //(离屏探针实测:加上枚举表后条目里仍是 `N/A`)。这里把表的规模与文本数组 - // 的地址一并入键 —— 只比较 `size()` 不足以区分“换成另一张同样长度的表”。 - mix(static_cast(resolved.data->enumText.values.size())); - mix(static_cast(resolved.data->enumText.texts.size())); - mix(static_cast(resolved.data->enumText.rangeMin.size())); - mix(static_cast( - reinterpret_cast(resolved.data->enumText.texts.data()))); - } - - if (cache.key != key) - { - cache.key = key; - cache.entries.clear(); - cache.entries.reserve(lane.freeMarkerItems.size()); - for (std::size_t itemIndex = 0; itemIndex < lane.freeMarkerItems.size(); ++itemIndex) - { - const Lane::FreeMarkerItem& item = lane.freeMarkerItems[itemIndex]; - FreeMarkerContentEntry entry; - entry.itemIndex = itemIndex; - double value = 0.0; - double enumValue = 0.0; - const bool valueOk = FreeMarkerValue(lane, item, value, enumValue); - bool hasPrev = false; - double delta = 0.0; - double timeDelta = 0.0; - if (valueOk) - { - FreeMarkerDelta(lane, itemIndex, value, hasPrev, delta, timeDelta); - } - const ResolvedSignal resolved = ResolveSignal(item.ref); - const std::wstring* enumPtr = - (resolved.data != nullptr) ? resolved.data->enumText.Find(enumValue) : nullptr; - entry.valid = valueOk; - entry.value = value; - entry.color = (resolved.style != nullptr) ? StyleColor(*resolved.style) : 0u; - entry.capsuleText = FreeMarkerValueText(value, lane.freeMarkerShowDelta, hasPrev, delta, - lane.freeMarkerShowEnum, enumPtr); - entry.valueText = valueOk ? FormatValue(value) : std::wstring(kFreeMarkerNotAvailable); - // ⚠️ 枚举列一旦存在(面板里有任一固定点带枚举表),**没有**枚举文本的点必须显示 - // `N/A` 而不是空着(用户 2026-10-23 要求);数值对不上任何枚举项也落在这一分支。 - entry.enumText = (enumPtr != nullptr) ? *enumPtr : std::wstring(kFreeMarkerNotAvailable); - entry.deltaText = (valueOk && hasPrev) ? FormatSignedValue(delta) - : std::wstring(kFreeMarkerNotAvailable); - entry.timeText = FreeMarkerTimeText(item.time); - entry.deltaTimeText = FreeMarkerDeltaText(valueOk && hasPrev, timeDelta); - cache.entries.push_back(std::move(entry)); - } - } - return cache.entries; - } - - bool PlotView::FreeMarkerLabelRects(int laneIndex, std::size_t itemIndex, gd::RectF& label, - gd::RectF& close, std::wstring& text) const - { - label = gd::RectF(); - close = gd::RectF(); - text.clear(); - if (m_tab == nullptr || m_fonts == nullptr || laneIndex < 0 || - laneIndex >= static_cast(m_tab->Lanes().size()) || - laneIndex >= static_cast(m_lanes.size())) - { - return false; - } - const Lane& lane = m_tab->Lanes()[static_cast(laneIndex)]; - if (itemIndex >= lane.freeMarkerItems.size()) - { - return false; - } - const Lane::FreeMarkerItem& item = lane.freeMarkerItems[itemIndex]; - // 取值与文本来自**与视界无关**的内容缓存(大量固定点 + 拖光标/平移时不重复采样)。 - const std::vector& contents = FreeMarkerContents(laneIndex); - if (itemIndex >= contents.size() || !contents[itemIndex].valid) - { - return false; - } - const FreeMarkerContentEntry& entry = contents[itemIndex]; - const double value = entry.value; - - // 超出视界**直接不显示**(用户明确要求:不按常规数据点那样贴边定格 + 三角指示)。 - const float rawX = TimeToX(item.time); - if (rawX < m_plotRect.X - 0.5f || rawX > m_plotRect.X + m_plotRect.Width + 0.5f) - { - return false; - } - const LaneVisual& visual = m_lanes[static_cast(laneIndex)]; - const float clipTop = std::max(visual.rect.Y, m_plotRect.Y); - const float clipBottom = - std::min(visual.rect.Y + visual.rect.Height, m_plotRect.Y + m_plotRect.Height); - const float rawY = ValueToY(value, visual); - if (!std::isfinite(rawY) || rawY < clipTop - 0.5f || rawY > clipBottom + 0.5f) - { - return false; // 纵向越界同样直接不显示 - } - - // 枚举文本按**采样原值**查找(与光标读数完全一致)。 - text = entry.capsuleText; - - // ✕ 按钮 = **与取值胶囊同高的正方形**(用户明确要求:长宽都等于胶囊的上下高度)。 - // ⚠️ 宽度多加 2px 余量:`DrawTextW` 的 `DT_END_ELLIPSIS` 有时会因为亚像素/字形 - // 伸部而把“刚好装满”的文本截成省略号(用户报告枚举文本被 … 缺省)。 - const float textWidth = UiMeasureTextWidth(m_fonts->monoSmall, text) + 12.0f * m_scale; - const float textHeight = 17.0f * m_scale; - const float closeSize = textHeight; - const float gap = kFreeMarkerCloseGap * m_scale; - const float totalWidth = textWidth + gap + closeSize; - - float left = rawX + 6.0f * m_scale; - if (left + totalWidth > m_plotRect.X + m_plotRect.Width - 2.0f * m_scale) - { - left = rawX - 6.0f * m_scale - totalWidth; - } - left = std::max(m_plotRect.X + 2.0f * m_scale, - std::min(left, m_plotRect.X + m_plotRect.Width - totalWidth - 2.0f * m_scale)); - float top = rawY - textHeight * 0.5f; - top = std::max(clipTop + 1.0f * m_scale, std::min(top, clipBottom - textHeight - 1.0f * m_scale)); - - label = gd::RectF(left, top, textWidth, textHeight); - close = gd::RectF(left + textWidth + gap, top, closeSize, closeSize); - return true; - } /** * @brief 用 **GDI+** 绘制一段可带 alpha 的文字(仅用于「标记透明度」低于 100% 的胶囊)。 @@ -4966,291 +3869,6 @@ namespace jdi } - PlotView::FreeMarkerProbe PlotView::ProbeFreeMarker(int mouseX, int mouseY) const - { - FreeMarkerProbe probe; - if (m_tab == nullptr || m_dataSet == nullptr || - !UiPointInRect(m_plotRect, static_cast(mouseX), static_cast(mouseY))) - { - return probe; - } - const int laneIndex = LaneAtY(mouseY); - if (laneIndex < 0 || laneIndex >= static_cast(m_lanes.size()) || - laneIndex >= static_cast(m_tab->Lanes().size())) - { - return probe; - } - const Lane& lane = m_tab->Lanes()[static_cast(laneIndex)]; - if (!lane.freeMarker) - { - return probe; - } - // ⚠️⚠️ **未开启「自由标记点选取」时鼠标就是普通鼠标**(用户 2026-10-23 明确要求): - // 不预览数据点、不画纵向光标、按下/拖动完全走常规语义(平移 / 框选 / 拖信号), - // 也不会固定任何数据点。所有自由标记的探测都从这一句的“早退”开始。 - if (!lane.freeMarkerCursor) - { - return probe; - } - const LaneVisual& visual = m_lanes[static_cast(laneIndex)]; - - // 「自由标记点选取」已开启:网格内始终跟随鼠标画纵向光标(见 `PaintFreeMarker`)。 - const bool cursorMode = true; - probe.cursorMode = cursorMode; - probe.laneIndex = laneIndex; - probe.time = XToTime(static_cast(mouseX)); - probe.x = static_cast(mouseX); - - // 命中容差 = **与"常规状态下触发信号曲线高亮"完全一致**(`kSignalHitTolerance`), - // 且判定的是「鼠标到**画出来的折线**的垂直距离」,而不是"同一横坐标处的采样值之差"。 - // ⚠️ 旧实现只看纵向差值 ⇒ 阶跃的**竖直跳变段**永远判不中:那一列上下两个采样值 - // 都不等于鼠标所在的高度(用户 2026-10-22 报告“在跳变纵线上完全点不中、没有吸附提示”)。 - const float fx = static_cast(mouseX); - const float fy = static_cast(mouseY); - const float tolerance = kSignalHitTolerance * m_scale; - - // 把“显示时刻”吸附到最近的**采样时刻**(返回显示时刻空间的值)。 - // 与 `SnapTimeToSample` 的区别:这里**不做稀疏判定**,任何信号都可以吸附, - // 因为固定点本来就应当落在真实数据点上(阶跃端点必须能选中)。 - const auto snapToNearestSample = [](const TimeAxis& axis, const SignalData& data, - const SignalStyle& style, double displayTime, - double& out) -> bool - { - const std::size_t count = std::min(axis.time.size(), data.values.size()); - if (count == 0) - { - return false; - } - const double target = displayTime - style.offsetTime; - if (axis.monotonic) - { - const auto lower = - std::lower_bound(axis.time.begin(), - axis.time.begin() + static_cast(count), target); - std::size_t index = static_cast(lower - axis.time.begin()); - if (index >= count) - { - index = count - 1; - } - if (index > 0 && - std::fabs(axis.time[index - 1] - target) <= std::fabs(axis.time[index] - target)) - { - --index; - } - out = axis.time[index] + style.offsetTime; - return true; - } - const int nearest = FindNearestSample(axis, target); - if (nearest < 0 || static_cast(nearest) >= count) - { - return false; - } - out = axis.time[static_cast(nearest)] + style.offsetTime; - return true; - }; - - // 在网格内所有可见信号中找**离鼠标最近**的那个数据点。 - double bestDistance = std::numeric_limits::infinity(); - double bestTime = probe.time; - double bestValue = 0.0; - double bestEnum = 0.0; - float bestY = 0.0f; - bool bestSnapped = false; - for (const SignalRef& ref : lane.signals) - { - const ResolvedSignal resolved = ResolveSignal(ref); - if (resolved.data == nullptr || resolved.style == nullptr || resolved.axis == nullptr || - !resolved.visible) - { - continue; - } - // 「自动点线」判据成立时吸附到最近采样点(与光标吸附同一规则)。 - // ⚠️ 用户明确要求:只要判据成立就吸附,**不**管当前是散点还是用户自己改过的样式。 - const bool sparse = MinorCellSparse(*resolved.axis, *resolved.data, resolved.style->offsetTime); - - // ---- ① 取“鼠标到这条曲线”的距离(复用绘制阶段的折线顶点)---- - float nearestX = fx; - double distance = std::numeric_limits::infinity(); - const PolylineCacheEntry* cached = AcquirePolyline(ref, resolved, visual); - if (cached != nullptr && cached->points.size() >= 2) - { - const std::vector& points = cached->points; - const std::vector& breaks = cached->breaks; - // 横向先行裁剪(点到线段的距离必然 ≥ 横向距离):时间单调时二分定位。 - std::size_t windowFirst = 0; - std::size_t windowLast = points.size(); - if (resolved.axis->monotonic) - { - const auto byX = [](const gd::PointF& point, float limit) { return point.X < limit; }; - windowFirst = static_cast( - std::lower_bound(points.begin(), points.end(), fx - tolerance, byX) - - points.begin()); - windowLast = static_cast( - std::upper_bound(points.begin(), points.end(), fx + tolerance, - [](float limit, const gd::PointF& point) - { return limit < point.X; }) - - points.begin()); - } - if (resolved.style->mode == DrawMode::SCATTER) - { - // 散点:直接比较采样点本身(不把连线当成可命中目标)。 - const std::size_t begin = (windowFirst > 0) ? (windowFirst - 1) : 0; - const std::size_t end = std::min(points.size(), windowLast + 1); - for (std::size_t index = begin; index < end; ++index) - { - const float dx = points[index].X - fx; - const float dy = points[index].Y - fy; - const double probeDistance = std::sqrt(static_cast(dx * dx + dy * dy)); - if (probeDistance < distance) - { - distance = probeDistance; - nearestX = points[index].X; - } - } - } - else - { - for (std::size_t k = 0; k < breaks.size(); ++k) - { - const std::size_t start = breaks[k]; - const std::size_t stop = (k + 1 < breaks.size()) ? breaks[k + 1] : points.size(); - const std::size_t scanBegin = std::max(start + 1, windowFirst); - // `windowLast + 1`:包含“一端在窗口内、另一端在窗口外”的跨窗口线段 —— - // 阶跃的竖直段就是这种线段(上端与下端相隔整个跳变幅度)。 - const std::size_t scanEnd = std::min(stop, windowLast + 1); - for (std::size_t index = scanBegin; index < scanEnd; ++index) - { - const gd::PointF& a = points[index - 1]; - const gd::PointF& b = points[index]; - const float vx = b.X - a.X; - const float vy = b.Y - a.Y; - const float wx = fx - a.X; - const float wy = fy - a.Y; - const float lengthSquared = vx * vx + vy * vy; - float t = 0.0f; - if (lengthSquared > 1.0e-6f) - { - t = (wx * vx + wy * vy) / lengthSquared; - t = std::max(0.0f, std::min(1.0f, t)); - } - const float dx = wx - t * vx; - const float dy = wy - t * vy; - const double segmentDistance = - std::sqrt(static_cast(dx * dx + dy * dy)); - if (segmentDistance < distance) - { - distance = segmentDistance; - nearestX = a.X + t * vx; - } - } - } - } - } - - // ---- ② 决定标注时刻 ---- - // ⚠️⚠️ 用户 2026-10-27 明确要求:**总是**预览「网格内离鼠标较近的那个信号」的数据点 - //(同一时刻下 A 信号比 B 信号离鼠标更近 ⇒ 预览 A、单击固定的也是 A), - // **不要求鼠标贴近曲线**。 - // 旧实现在两种距离都超出容差时直接 `continue`,于是「网格里只有一条信号、鼠标离曲线 - // 较远」时既没有预览点,也无法用左键固定(用户报告的核心问题)。 - // - // 因此这里准备**两个候选**,取更近的那一个: - // * 曲线候选 = 鼠标到**画出来的折线**的垂直距离(阶跃的竖直跳变段只有它能命中); - // * 采样候选 = 同一时刻(鼠标时刻吸附到最近采样)该采样点在屏幕上的**纵向距离**。 - // 两者都不再设“必须命中”的门槛;门槛(`tolerance`)只用来决定是否画「已吸附」圆环。 - const bool interpolate = (resolved.style->mode != DrawMode::SCATTER); - double time = 0.0; - double sampled = 0.0; - bool candidateOk = false; - // (a) 曲线候选 - { - double candidateTime = XToTime(nearestX); - if (snapToNearestSample(*resolved.axis, *resolved.data, *resolved.style, candidateTime, - candidateTime)) - { - double candidateSample = 0.0; - if (SampleDisplayedSignalAt(*resolved.axis, *resolved.data, *resolved.style, - candidateTime, interpolate, candidateSample) && - std::isfinite(candidateSample)) - { - time = candidateTime; - sampled = candidateSample; - candidateOk = true; - } - } - } - // (b) 采样候选:同一时刻的纵向距离(“哪个信号离鼠标更近”的主判据) - { - double candidateTime = probe.time; - if (snapToNearestSample(*resolved.axis, *resolved.data, *resolved.style, candidateTime, - candidateTime)) - { - double candidateSample = 0.0; - if (SampleDisplayedSignalAt(*resolved.axis, *resolved.data, *resolved.style, - candidateTime, interpolate, candidateSample) && - std::isfinite(candidateSample)) - { - const double candidateValue = - ApplyDrawTransform(*resolved.style, candidateSample); - if (std::isfinite(candidateValue)) - { - const double vertical = std::fabs( - static_cast(ValueToY(candidateValue, visual)) - - static_cast(fy)); - if (!candidateOk || vertical < distance) - { - distance = vertical; - nearestX = fx; - time = candidateTime; - sampled = candidateSample; - candidateOk = true; - } - } - } - } - } - if (!candidateOk) - { - continue; - } - // 固定点必须落在真实采样时刻上(两个候选都已吸附到采样点)。 - // 表示“已吸附”的圆环仍只在「自动点线」判据成立(`sparse`)时画: - // 密采样信号里吸附量远小于 1 像素,圆环会一直挂着,反而像是画错了。 - const bool snapped = sparse; - const double value = ApplyDrawTransform(*resolved.style, sampled); - if (!std::isfinite(value)) - { - continue; - } - const float y = ValueToY(value, visual); - if (distance >= bestDistance) - { - continue; - } - bestDistance = distance; - bestTime = time; - bestValue = value; - bestEnum = sampled; - bestY = y; - bestSnapped = snapped; - probe.ref = ref; - } - - if (!probe.ref.IsValid()) - { - // 网格内没有可标注的数据点:「自由标记点选取」模式下仍要画纵向线(`valid` 为真)。 - probe.valid = true; - return probe; - } - probe.valid = true; - probe.time = bestTime; - probe.x = TimeToX(bestTime); - probe.value = bestValue; - probe.enumValue = bestEnum; - probe.y = bestY; - probe.snapped = bestSnapped; - return probe; - } void PlotView::PaintEdgeCursor(gd::Graphics& g, const std::vector& lanes) { diff --git a/Components/PlotView/Src/PlotViewPanels.cpp b/Components/PlotView/Src/PlotViewPanels.cpp new file mode 100644 index 0000000..dec222f --- /dev/null +++ b/Components/PlotView/Src/PlotViewPanels.cpp @@ -0,0 +1,1409 @@ +// PlotView 实现分片 —— 几何:浮动面板布局、图例徽标几何、"自由标记"的取值/标签/探测。 +// +// 本分片只包含 `PlotView` 的**成员函数定义**(从 `PlotViewPaint.cpp` 机械搬移,行为与搬移前逐字节一致)。 +// +// 为什么要独立出来: +// * 这些函数**被高频询问**(`TopPanelAtPoint` / `SyncPanelOrder` / `QueryCursor` / 绘制各算一遍, +// `EdgePanelLayoutFor` 一度有 19 个调用点),属于"几何"而不是"绘制"; +// * 与绘制混在一个文件里时,改动布局会连带触碰 9 千行的绘制文件,回归面被放大。 +// +// ⚠️ 硬约束: +// 1. `EdgePanelLayoutFor` / `EdgePanelWidth` 会被鼠标移动的高频路径调用 ⇒ **不得**逐条查询采样值, +// 枚举列缺省只允许查 `SignalEnumText::Empty()`(O(1))。 +// 2. 布局结果必须"绘制与命中同源":绘制侧不得自行反推列边界。 +// 3. 面板几何键(`PanelSlotKey` 等)留在 `PlotViewPaint.cpp`(属于缓存键,不属于几何)。 + +#include "PlotViewInternal.hpp" + +namespace jdi +{ + float PlotView::EdgePanelWidth(int laneIndex, EdgePanelKind kind) const + { + if (m_tab == nullptr || m_fonts == nullptr || laneIndex < 0 || + laneIndex >= static_cast(m_tab->Lanes().size())) + { + return kEdgePanelWidth * m_scale; + } + const Lane& lane = m_tab->Lanes()[static_cast(laneIndex)]; + const bool centerAxis = (kind == EdgePanelKind::CENTER); + const bool meanFilter = (kind == EdgePanelKind::MEAN); + const bool weakPeak = (kind == EdgePanelKind::PEAK); + // 「均值滤波」「中心轴边沿计数」「弱峰调整」都是逐条目面板(名称 + 复选框)。 + const bool itemized = (centerAxis || meanFilter || weakPeak); + + // 标题行 = “GX” 徽标 + 标题(“边沿计数” / “中心轴边沿计数” / “均值滤波” / “弱峰调整” + // + 光标区间尾缀)。 + const std::wstring title = + meanFilter ? std::wstring(L"均值滤波") + : (weakPeak ? std::wstring(kWeakPeakTitle) + : ((centerAxis ? L"中心轴边沿计数" : L"边沿计数") + EdgeRangeSuffix())); + float width = kEdgePanelPad * 2.0f * m_scale + kLaneBadgeWidth * m_scale + + 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 (itemized) + { + // 逐条目面板:第一排 = 名称 + 复选框;第三排(中心轴)= 统计结果。 + width = std::max(width, kEdgePanelPad * 2.0f * m_scale + nameWidth + + kEdgePanelMeanCheckSize * m_scale + 6.0f * m_scale); + } + std::size_t rising = 0; + std::size_t falling = 0; + bool complete = true; + if (meanFilter || weakPeak) + { + continue; + } + if (centerAxis) + { + 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); + } + else + { + 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, EdgeRisingText(rising, complete)), + pendingWidth)); + statFallingWidth = + std::max(statFallingWidth, + UiMeasureTextWidth(m_fonts->monoSmall, EdgeFallingText(falling, complete))); + } + if (statRisingWidth > 0.0f || statFallingWidth > 0.0f) + { + // 两列**等宽**(取两列最大值中的较大者)→ 绘制时两列起点固定, + // 多条信号的“上升沿 / 下降沿”文字严格垂直对齐(用户明确要求)。 + const float maxColumn = std::max(statRisingWidth, statFallingWidth); + const float countsWidth = maxColumn * 2.0f + kEdgePanelStatColumnGap * m_scale; + width = std::max(width, kEdgePanelPad * 2.0f * m_scale + + std::max(nameColumnWidth, countsWidth)); + } + + if (centerAxis || meanFilter) + { + // 参数行 = 两个「说明文字 + 数值框」,必须保证文字不被数值框压住。 + // (MEAN / CENTER:「均值点数 X」+「迭代次数 N」。标签与数值框各占半行。) + const float labelXWidth = UiMeasureTextWidth(m_fonts->uiSmall, L"均值点数 X"); + const float labelN = UiMeasureTextWidth(m_fonts->uiSmall, L"迭代次数 N"); + const float fieldWidth = + std::max(kEdgePanelEditFieldWidth * m_scale * 0.75f, + UiMeasureTextWidth(m_fonts->monoSmall, L"1000000") + 18.0f * m_scale); + const float halfWidth = std::max(labelXWidth, labelN) + 6.0f * m_scale + fieldWidth; + const float rowWidth = halfWidth * 2.0f + 8.0f * m_scale; + width = std::max(width, kEdgePanelPad * 2.0f * m_scale + rowWidth); + } + + if (weakPeak) + { + // 滑块行 = 「标签列 + 轨道(最小宽度)+ 数值列 + 复位按钮」(等级与迭代次数两行共用同一列宽)。 + const float rowWidth = WeakPeakLabelColumnWidth(m_fonts->uiSmall) + 6.0f * m_scale + + kWeakPeakTrackMinWidth * m_scale + 6.0f * m_scale + + WeakPeakValueColumnWidth(m_fonts->monoSmall) + + kWeakPeakResetGap * m_scale + kWeakPeakResetButtonSize * m_scale; + width = std::max(width, kEdgePanelPad * 2.0f * m_scale + rowWidth); + } + + if (meanFilter || weakPeak) + { + // 底部按钮行 = 「构建为新信号」,按按钮文字实测宽度确定下限。 + const float buttonWidth = + UiMeasureTextWidth(m_fonts->uiSmall, kBuildSignalLabel) + 28.0f * m_scale; + width = std::max(width, kEdgePanelPad * 2.0f * m_scale + buttonWidth); + } + + if (kind == EdgePanelKind::MARK) + { + // 「自由标记」面板:宽度 = 六列(取「缺省列宽」「实测内容宽度」「列标题宽度」三者 + // 的大者)+ 行 ✕ + 左右内边距 + **滚动条预留位**(内容超高时右侧要放滚动条)。 + // ⚠️ 用户 2026-10-21 要求面板里的数据列**不得出现省略号**,因此不能再按固定列宽。 + // 逐行取采样值算宽度会拖慢交互(本函数被 `TopPanelAtPoint` / `SyncPanelOrder` + // 高频调用),所以走 `Lane::freeMarkerColumnWidths`:绘制该面板时顺便量好真实 + // 文本宽度存起来,这里只读。还没量过(全 0)时退回缺省列宽。 + const bool hasEnum = [this, &lane]() { + for (const SignalRef& ref : lane.signals) + { + const ResolvedSignal resolved = ResolveSignal(ref); + if (resolved.data != nullptr && resolved.visible && + !resolved.data->enumText.Empty() && lane.HasFreeMarkers(ref)) + { + return true; + } + } + return false; + }(); + float columnsWidth = 0.0f; + for (int column = 0; column < 6; ++column) + { + const bool omitted = (column == EdgePanelLayout::MARKER_COL_ENUM) && !hasEnum; + if (omitted) + { + continue; + } + // 列宽还要容得下**表头的列标题**(如「时间间隔」),否则表头会被省略。 + const float labelWidth = + UiMeasureTextWidth(m_fonts->uiSmall, kFreeMarkerColumnLabels[column]) + + 2.0f * m_scale; + columnsWidth += std::max(std::max(kFreeMarkerColumnWidths[column] * m_scale, + lane.freeMarkerColumnWidths[static_cast(column)]), + labelWidth); + columnsWidth += kFreeMarkerColumnGap * m_scale; + } + // 行尾 ✕(按胶囊同高的正方形取宽)+ 滚动条预留 + 少量余量。 + const float rowCloseWidth = kFreeMarkerItemRowHeight * m_scale + 2.0f * m_scale; + const float markerWidth = kEdgePanelPad * 2.0f * m_scale + + (kFreeMarkerScrollWidth + 4.0f) * m_scale + columnsWidth + + rowCloseWidth; + width = std::max(width, std::max(kFreeMarkerPanelWidth * m_scale, markerWidth)); + } + + // 夹在绘图区宽度之内(过窄的网格也不至于把面板挤到外面)。 + return std::min(width, std::max(120.0f * m_scale, m_plotRect.Width - 8.0f * m_scale)); + } + + gd::RectF PlotView::LaneBadgeRect(int laneIndex) const + { + if (laneIndex < 0 || laneIndex >= static_cast(m_lanes.size())) + { + return gd::RectF(); + } + const LaneVisual& lane = m_lanes[static_cast(laneIndex)]; + const gd::RectF legendBand(lane.rect.X + kLegendPad * m_scale, lane.rect.Y + kLegendPad * m_scale, + lane.rect.Width, kLegendHeight * m_scale); + // 网格被滚动出可视区时没有徽标可点。 + const float visibleTop = std::max(lane.rect.Y, m_plotRect.Y); + const float visibleBottom = + std::min(lane.rect.Y + lane.rect.Height, m_plotRect.Y + m_plotRect.Height); + if (!IsFullyVisible(legendBand, visibleTop, visibleBottom)) + { + return gd::RectF(); + } + + const float height = kLegendHeight * m_scale; + const float badgeHeight = kLaneBadgeHeight * m_scale; + return gd::RectF(legendBand.X, legendBand.Y + (height - badgeHeight) * 0.5f, + kLaneBadgeWidth * m_scale, badgeHeight); + } + + PlotView::EdgePanelLayout PlotView::EdgePanelLayoutFor(int laneIndex, EdgePanelKind kind) const + { + EdgePanelLayout layout; + if (m_tab == nullptr || m_fonts == nullptr || laneIndex < 0 || + laneIndex >= static_cast(m_tab->Lanes().size()) || + laneIndex >= static_cast(m_lanes.size())) + { + return layout; + } + const Lane& lane = m_tab->Lanes()[static_cast(laneIndex)]; + 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 itemized = (centerAxis || meanFilter || weakPeak); + const bool hasButton = (meanFilter || weakPeak); + const bool enabled = meanFilter ? lane.meanFilter + : (weakPeak ? lane.weakPeak + : (freeMarker ? lane.freeMarker + : (centerAxis ? lane.edgeCenter + : lane.edgeCount))); + const bool open = meanFilter ? lane.meanFilterPanel.open + : (weakPeak ? lane.weakPeakPanel.open + : (freeMarker ? lane.freeMarkerPanel.open + : (centerAxis ? lane.edgeCenterPanel.open + : lane.edgePanel.open))); + if (!enabled || !open) + { + return layout; + } + // 面板的**折起**状态(只显示标题栏)。折起时仍然算宽度与位置,但内容区全部省略。 + const bool collapsed = meanFilter ? lane.meanFilterPanel.collapsed + : (weakPeak ? lane.weakPeakPanel.collapsed + : (freeMarker ? lane.freeMarkerPanel.collapsed + : (centerAxis + ? lane.edgeCenterPanel.collapsed + : lane.edgePanel.collapsed))); + layout.collapsed = collapsed; + + int rowCount = 0; + for (const SignalRef& ref : lane.signals) + { + const ResolvedSignal resolved = ResolveSignal(ref); + if (resolved.data != nullptr && resolved.visible) + { + ++rowCount; + } + } + if (rowCount <= 0) + { + return layout; + } + layout.rows = rowCount; + + const float width = EdgePanelWidth(laneIndex, kind); + const float titleHeight = kEdgePanelTitleHeight * m_scale; + const float rowHeight = kEdgePanelRowHeight * m_scale; + const float pad = kEdgePanelPad * m_scale; + // 「中心轴边沿计数」「均值滤波」「弱峰调整」共用同一套「逐条目」排布, + // 因此不再有独立的底部编辑行;只有「边沿计数」保留两行文本式排版。 + const float buttonHeight = hasButton ? (kEdgePanelButtonHeight * m_scale) : 0.0f; + const float rowsPerSignal = meanFilter ? 1.0f : 2.0f; + + // 逐条目面板:每个条目 = 第一排(信号名 + 复选框) + // + 参数部分(**只在该条目被勾选时才占位**,因此面板高度随勾选数量动态变化): + // * MEAN / CENTER:一行「均值点数 X + 迭代次数 N」输入框(中心轴再多一行统计结果); + // * PEAK:两行滑块(调整幅度 % / 迭代次数 N),每个信号各有一套自己的参数。 + const bool itemHasParams = (centerAxis || meanFilter || weakPeak); + const float weakPeakSliderRow = kWeakPeakSliderRowHeight * m_scale; + float meanRowHeight = 0.0f; + float itemsHeight = 0.0f; + float height = 0.0f; + if (itemized) + { + meanRowHeight = kEdgePanelMeanRowHeight * m_scale; + for (const SignalRef& ref : lane.signals) + { + const ResolvedSignal resolved = ResolveSignal(ref); + if (resolved.data == nullptr || !resolved.visible) + { + continue; + } + itemsHeight += meanRowHeight; + const bool checked = meanFilter ? lane.MeanFilterEnabled(ref) + : (weakPeak ? lane.WeakPeakEnabled(ref) + : lane.EdgeCenterEnabled(ref)); + if (checked && itemHasParams) + { + if (weakPeak) + { + itemsHeight += weakPeakSliderRow * 2.0f; + } + else + { + itemsHeight += (kEdgePanelMeanEditHeight + kEdgePanelMeanRowGap) * m_scale; + if (centerAxis) + { + itemsHeight += kEdgePanelMeanRowHeight * m_scale; + } + } + } + } + // 底部按钮行:上方与条目之间、下方与面板下边界之间各留一个 `pad`, + // 与面板的左右内边距完全一致(原先按钮直接贴着下边框,看起来很不协调)。 + height = titleHeight + pad + itemsHeight + pad + buttonHeight + (hasButton ? pad : 0.0f); + } + else + { + height = titleHeight + pad + static_cast(rowCount) * rowHeight * rowsPerSignal + pad; + } + + // 「自由标记」面板:按信号分组(**分组栏** + 下属固定点条目), + // 只列出确实有固定点的信号;没有任何固定点时给一行灰色提示。 + // 底部固定区(自上而下):标记透明度滑块 → 「自由标记点选取」→ 两个显示开关。 + bool markerAny = false; + for (const Lane::FreeMarkerItem& item : lane.freeMarkerItems) + { + if (item.ref.IsValid()) + { + markerAny = true; + break; + } + } + if (freeMarker) + { + const float groupRow = kFreeMarkerNameRowHeight * m_scale; + const float headerRow = kFreeMarkerHeaderRowHeight * m_scale; + const float itemRow = kFreeMarkerItemRowHeight * m_scale; + const float markerButtonHeight = kEdgePanelButtonHeight * m_scale; + // 「标记透明度」滑块行**始终显示**(用户 2026-10-21 要求:不再依附于「自由标记点选取」按钮)。 + const float sliderRow = kFreeMarkerSliderRowHeight * m_scale; + // 「显示增量 / 显示枚举文本」两个开关按钮独占一行,位于「自由标记点选取」下方 + //(用户 2026-10-23 要求)。 + const float switchRow = kEdgePanelButtonHeight * m_scale; + const float buttonGap = kFreeMarkerButtonGap * m_scale; + const float footer = sliderRow + markerButtonHeight + buttonGap + switchRow; + float content = 0.0f; + std::vector order; + for (const SignalRef& ref : lane.signals) + { + const ResolvedSignal resolved = ResolveSignal(ref); + if (resolved.data == nullptr || !resolved.visible) + { + continue; + } + // ⚠️ 用户明确要求:某个信号没有任何固定点时,它的分组栏**不出现**在面板上。 + SortedFreeMarkerIndices(lane, ref, order); + if (order.empty()) + { + continue; + } + content += groupRow; + if (!lane.FreeMarkerGroupCollapsed(ref)) + { + // 展开的分组:分组栏 + **表头行** + 条目。 + content += headerRow + static_cast(order.size()) * itemRow; + } + } + if (!markerAny) + { + // 还没有任何固定点:留一行提示文案(绘制时画「在网格内单击以固定数据点」)。 + content += groupRow; + } + // 内容区高度上限:太长的列表不能把面板顶出绘图区(超出部分靠滚动条翻页)。 + const float budget = m_plotRect.Height - (titleHeight + pad * 3.0f + footer); + const float maxContent = + std::max(2.0f * itemRow, + std::min(kFreeMarkerContentMaxHeight * m_scale, std::max(2.0f * itemRow, budget))); + const float view = std::min(content, maxContent); + height = titleHeight + pad + view + pad + footer + pad; + } + + // 折起的面板:只剩标题栏(内容区完全不占高度)。 + if (collapsed) + { + height = titleHeight; + } + + // 首次使用(位置为负)时自动放到绘图区**右上角**,并按网格在**当前可见网格中的序数** + // 依次错开,避免完全重叠。 + // ⚠️ 旧实现用 `laneIndex` 错开 —— 上百个网格时 `laneIndex` 很大, + // `16 + laneIndex*28` 早就超过绘图区高度、被下面的夹取推到**右下角** + //(用户 2026-10-17 报告:“在当前范围内的网格开启实验性功能,面板首次出现在右下角”)。 + // 改用**可见序数**:它的量级只有一屏的网格数(约 20),再夹到“面板上沿不超过绘图区 + // 中间偏下”以内,于是面板始终停**右上区域**,同时保留“依次错开”的观感。 + const double storedX = meanFilter ? lane.meanFilterPanel.x + : (weakPeak ? lane.weakPeakPanel.x + : (freeMarker ? lane.freeMarkerPanel.x + : (centerAxis ? lane.edgeCenterPanel.x + : lane.edgePanel.x))); + const double storedY = meanFilter ? lane.meanFilterPanel.y + : (weakPeak ? lane.weakPeakPanel.y + : (freeMarker ? lane.freeMarkerPanel.y + : (centerAxis ? lane.edgeCenterPanel.y + : lane.edgePanel.y))); + int openPanelOrdinal = 0; + { + // ⚠️ 错开量用「**当前真正显示出来的面板**中排在本面板之前的个数」: + // * 第一个打开的面板 ⇒ 0 ⇒ 正好贴在右上角(用户 2026-10-17 要求“应该在右上角”); + // * 后续面板依次下移 28 逻辑像素,避免与已打开的面板完全重叠; + // * 遍历顺序固定为(网格下标 → 面板种类)⇒ 每个面板的槽位稳定、与帧无关。 + // ⚠️ 不能用 `laneIndex`(上百个网格时会把面板推到右下角); + // ⚠️ 也不能只看 `*Panel.open`:该标志**默认就是 true**,必须同时看功能是否启用 + // (与 `EdgePanelLayoutFor` 的 `enabled && open` 判定一致)。 + const auto shown = [](const Lane& lane, 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; + } + return false; + }; + static const EdgePanelKind kOrder[5] = {EdgePanelKind::COUNT, EdgePanelKind::CENTER, + EdgePanelKind::MEAN, EdgePanelKind::PEAK, + EdgePanelKind::MARK}; + 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) + { + if (!shown(tabLane, panelKind)) + { + continue; + } + if (lane == static_cast(laneIndex) && panelKind == kind) + { + found = true; + break; + } + ++openSeen; + } + } + openPanelOrdinal = found ? openSeen : 0; + } + // 错开量夹到“面板下沿不超过绘图区 45% 高度”以内 ⇒ 首现位置永远在右上区域。 + const float maxStagger = std::max(0.0f, m_plotRect.Height * 0.45f - height - 16.0f * m_scale); + const float stagger = + std::min(static_cast(openPanelOrdinal) * 28.0f * m_scale, maxStagger); + float left = (storedX >= 0.0) + ? static_cast(storedX) + : (m_plotRect.X + m_plotRect.Width - width - 16.0f * m_scale); + float top = (storedY >= 0.0) ? static_cast(storedY) + : (m_plotRect.Y + 16.0f * m_scale + stagger); + + // 面板始终限制在绘图区内(用户明确要求"可以在右侧的整个绘制区域拖动")。 + left = std::max(m_plotRect.X + 2.0f * m_scale, + std::min(left, m_plotRect.X + m_plotRect.Width - width - 2.0f * m_scale)); + top = std::max(m_plotRect.Y + 2.0f * m_scale, + std::min(top, m_plotRect.Y + m_plotRect.Height - height - 2.0f * m_scale)); + + layout.panel = gd::RectF(left, top, width, height); + // 只有标题栏可以拖拽;✕ 位于标题栏右端,命中时优先按“关闭按钮”处理。 + layout.title = gd::RectF(left, top, width, titleHeight); + const float closeSize = titleHeight; + layout.close = gd::RectF(left + width - closeSize, top, closeSize, closeSize); + // 标题栏左侧的「展开 / 折起」三角指示器(位于 GX 徽标左边)。 + // 折起后面板只剩标题栏,因此这个指示器是唯一的“重新展开”入口(双击标题栏亦可)。 + layout.disclosure = gd::RectF(left + pad, top + (titleHeight - kEdgePanelDisclosureWidth * m_scale) * 0.5f, + kEdgePanelDisclosureWidth * m_scale, + kEdgePanelDisclosureWidth * m_scale); + // 标题栏左侧的「GX」编号徽标:绘制、悬停提示、手型光标与“点击滚动到该网格” + // 全部以这个矩形为准(唯一几何来源,避免“画的”和“点的”漂移)。 + layout.badge = + gd::RectF(left + pad + kEdgePanelDisclosureWidth * m_scale, + top + (titleHeight - kLaneBadgeHeight * m_scale) * 0.5f, kLaneBadgeWidth * m_scale, + kLaneBadgeHeight * m_scale); + // 折起状态:到此为止(内容区、按钮、滚动条全部为空)。 + if (collapsed) + { + return layout; + } + if (itemized) + { + const float buttonRowHeight = kEdgePanelButtonHeight * m_scale; + if (hasButton) + { + // 底部按钮与面板下边界之间留出与左右、与内容相同的 `pad` 间距 + //(原先按钮行直接贴着下边框,视觉上与其它元素不协调); + // 面板高度已含这份 pad,因此这里只上移按钮行即可。 + layout.button = gd::RectF(left + pad, top + height - pad - buttonRowHeight, + std::max(0.0f, width - pad * 2.0f), buttonRowHeight); + } + + // 逐信号条目:第一排 = 名称 + 复选框(复选框靠右); + // 参数部分:MEAN / CENTER = 一行两个输入框(中心轴再多一行统计结果); + // PEAK = 两行滑块(每个信号各有自己的等级与迭代次数)。 + const float checkSize = kEdgePanelMeanCheckSize * m_scale; + const float fieldHeight = kEdgePanelMeanEditHeight * m_scale; + const float halfWidth = std::max(40.0f, (width - pad * 2.0f - 8.0f * m_scale) * 0.5f); + // 滑块行列宽(与 `EdgePanelWidth` 使用同一组算式,避免布局与宽度计算漂移)。 + const float weakPeakLabelWidth = WeakPeakLabelColumnWidth(m_fonts->uiSmall); + const float weakPeakValueWidth = WeakPeakValueColumnWidth(m_fonts->monoSmall); + const float weakPeakResetSize = kWeakPeakResetButtonSize * m_scale; + const float weakPeakResetGap = kWeakPeakResetGap * m_scale; + const float weakPeakTrackHeight = kWeakPeakTrackHeight * m_scale; + const float weakPeakTrackLeft = left + pad + weakPeakLabelWidth + 6.0f * m_scale; + const float weakPeakTrackWidth = + std::max(10.0f * m_scale, + (left + width - pad - weakPeakValueWidth - weakPeakResetGap - + weakPeakResetSize - 6.0f * m_scale) - + weakPeakTrackLeft); + float rowY = top + titleHeight + pad; + for (const SignalRef& ref : lane.signals) + { + const ResolvedSignal resolved = ResolveSignal(ref); + if (resolved.data == nullptr || !resolved.visible) + { + continue; + } + + layout.rowRefs.push_back(ref); + layout.rowChecks.push_back(gd::RectF( + left + width - pad - checkSize, rowY + (meanRowHeight - checkSize) * 0.5f, checkSize, + checkSize)); + const bool checked = meanFilter ? lane.MeanFilterEnabled(ref) + : (weakPeak ? lane.WeakPeakEnabled(ref) + : lane.EdgeCenterEnabled(ref)); + if (weakPeak) + { + // 每个被勾选的信号各有两行滑块:调整幅度(-400% – +400%)与迭代次数(1 … 上限)。 + if (checked) + { + const float levelRowTop = rowY + meanRowHeight; + const float iterRowTop = levelRowTop + weakPeakSliderRow; + const gd::RectF levelTrack( + weakPeakTrackLeft, + levelRowTop + (weakPeakSliderRow - weakPeakTrackHeight) * 0.5f, + weakPeakTrackWidth, weakPeakTrackHeight); + const gd::RectF iterTrack( + weakPeakTrackLeft, + iterRowTop + (weakPeakSliderRow - weakPeakTrackHeight) * 0.5f, + weakPeakTrackWidth, weakPeakTrackHeight); + layout.rowLevelTracks.push_back(levelTrack); + layout.rowLevelValues.push_back(gd::RectF( + levelTrack.X + levelTrack.Width + 6.0f * m_scale, levelRowTop, + weakPeakValueWidth, weakPeakSliderRow)); + layout.rowIterTracks.push_back(iterTrack); + layout.rowIterValues.push_back(gd::RectF( + iterTrack.X + iterTrack.Width + 6.0f * m_scale, iterRowTop, + weakPeakValueWidth, weakPeakSliderRow)); + // 每行滑块右侧的**复位按钮**(与数值列同一右边界,保证两行上下对齐)。 + const float resetX = levelTrack.X + levelTrack.Width + 6.0f * m_scale + + weakPeakValueWidth + weakPeakResetGap; + layout.rowLevelResets.push_back( + gd::RectF(resetX, levelRowTop + (weakPeakSliderRow - weakPeakResetSize) * 0.5f, + weakPeakResetSize, weakPeakResetSize)); + layout.rowIterResets.push_back( + gd::RectF(resetX, iterRowTop + (weakPeakSliderRow - weakPeakResetSize) * 0.5f, + weakPeakResetSize, weakPeakResetSize)); + } + else + { + 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.rowPoints.push_back(gd::RectF()); + layout.rowIterations.push_back(gd::RectF()); + layout.rowStats.push_back(gd::RectF()); + + rowY += meanRowHeight; + if (checked) + { + rowY += weakPeakSliderRow * 2.0f; + } + continue; + } + if (checked && itemHasParams) + { + const float editTop = rowY + meanRowHeight; + layout.rowPoints.push_back( + gd::RectF(left + pad, editTop, halfWidth, fieldHeight)); + layout.rowIterations.push_back( + gd::RectF(left + pad + halfWidth + 8.0f * m_scale, editTop, halfWidth, fieldHeight)); + if (centerAxis) + { + layout.rowStats.push_back( + gd::RectF(left + pad, editTop + fieldHeight, width - pad * 2.0f, + kEdgePanelMeanRowHeight * m_scale)); + } + else + { + layout.rowStats.push_back(gd::RectF()); + } + } + else + { + layout.rowPoints.push_back(gd::RectF()); + layout.rowIterations.push_back(gd::RectF()); + layout.rowStats.push_back(gd::RectF()); + } + + rowY += meanRowHeight; + if (checked && itemHasParams) + { + rowY += (kEdgePanelMeanEditHeight + kEdgePanelMeanRowGap) * m_scale; + if (centerAxis) + { + rowY += kEdgePanelMeanRowHeight * m_scale; + } + } + } + } + + if (freeMarker) + { + // 「自由标记」面板:**只列出确实有固定点的信号**,每个信号一个**分组栏** + //(信号色托底 + 左侧展开/收起三角 + 信号名 + 右侧「N 标记点」与 ✕), + // 下面逐行列出它的固定点(组内按时间升序 == 从上到下),行尾再带一个 ✕。 + // 内容区超高时右侧出现滚动条;底部按钮与「标记透明度」滑块**固定**不滚动。 + const float groupRow = kFreeMarkerNameRowHeight * m_scale; + const float headerRow = kFreeMarkerHeaderRowHeight * m_scale; + const float itemRow = kFreeMarkerItemRowHeight * m_scale; + const float closeSize = kFreeMarkerCloseSize * m_scale; + const float markerButtonHeight = kEdgePanelButtonHeight * m_scale; + // 「标记透明度」独立于「自由标记点选取」:**总是**显示在按钮上方。 + const float sliderRow = kFreeMarkerSliderRowHeight * m_scale; + const float scrollW = kFreeMarkerScrollWidth * m_scale; + // 「显示增量 / 显示枚举文本」两个开关按钮:独占底部最后一行(左右各半个宽度)。 + const float switchRow = kEdgePanelButtonHeight * m_scale; + const float buttonGap = kFreeMarkerButtonGap * m_scale; + + // 底部固定区(自上而下):标记透明度滑块 → 「自由标记点选取」→ 两个显示开关。 + { + const float switchTop = top + height - pad - switchRow; + const float halfWidth = + std::max(0.0f, (width - pad * 2.0f - buttonGap) * 0.5f); + layout.showDelta = gd::RectF(left + pad, switchTop, halfWidth, switchRow); + layout.showEnum = + gd::RectF(left + pad + halfWidth + buttonGap, switchTop, halfWidth, switchRow); + // 「自由标记点选取」切换按钮(整行宽度)。 + layout.button = gd::RectF(left + pad, switchTop - buttonGap - markerButtonHeight, + std::max(0.0f, width - pad * 2.0f), markerButtonHeight); + } + { + // 「标记透明度」行:与信号样式区的「透明度」行同一套排版 + //(左侧标签 + 中间轨道 + 右侧百分比),位置固定在按钮上方。 + // ⚠️ 无论是否启用「自由标记点选取」都显示(用户 2026-10-21 要求)。 + const float rowTop = layout.button.Y - sliderRow; + const float valueWidth = + std::max(36.0f * m_scale, + UiMeasureTextWidth(m_fonts->monoSmall, L"100%") + 6.0f * m_scale); + const float labelWidth = + UiMeasureTextWidth(m_fonts->uiSmall, kFreeMarkerOpacityLabel) + 6.0f * m_scale; + const float trackLeft = left + pad + labelWidth; + const float trackRight = left + width - pad - valueWidth - 6.0f * m_scale; + const float trackHeight = kWeakPeakTrackHeight * m_scale; + layout.opacityTrack = + gd::RectF(trackLeft, rowTop + (sliderRow - trackHeight) * 0.5f, + std::max(20.0f * m_scale, trackRight - trackLeft), trackHeight); + layout.opacityValue = + gd::RectF(trackRight + 6.0f * m_scale, rowTop, valueWidth, sliderRow); + } + + // 内容区高度(面板高度算式里的 `view` 部分)与"内容总高"决定是否需要滚动条。 + const float viewHeight = std::max( + 0.0f, height - titleHeight - pad * 3.0f - markerButtonHeight - sliderRow - + buttonGap - switchRow); + float totalContent = 0.0f; + std::vector order; + for (const SignalRef& ref : lane.signals) + { + const ResolvedSignal resolved = ResolveSignal(ref); + if (resolved.data == nullptr || !resolved.visible) + { + continue; + } + SortedFreeMarkerIndices(lane, ref, order); + if (order.empty()) + { + continue; + } + totalContent += groupRow; + if (!lane.FreeMarkerGroupCollapsed(ref)) + { + totalContent += headerRow + static_cast(order.size()) * itemRow; + } + } + if (!markerAny) + { + 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)); + 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); + } + + // 六列(① 序号 ② 标记值 ③ 枚举文本 ④ 增量 ⑤ 时间点 ⑥ 时间间隔) + // 从左往右排布:每一行的同类信息都从同一横坐标开始 ⇒ 纵向上严格对齐(用户要求)。 + // ⚠️ 整个面板里没有任何枚举文本时,③ 枚举列**整列缺省**(列宽记 0,后面的列左移)。 + // 这里只查"该信号是否带枚举表"(O(1)),**不**逐条采样(布局函数被高频调用)。 + bool hasEnum = false; + for (const SignalRef& ref : lane.signals) + { + const ResolvedSignal resolved = ResolveSignal(ref); + if (resolved.data != nullptr && resolved.visible && !resolved.data->enumText.Empty() && + lane.HasFreeMarkers(ref)) + { + hasEnum = true; + break; + } + } + layout.markerHasEnumColumn = hasEnum; + float columnX = left + pad; + for (int column = 0; column < 6; ++column) + { + const bool omitted = (column == EdgePanelLayout::MARKER_COL_ENUM) && !hasEnum; + // ⚠️ 列宽取「缺省列宽」「实测内容宽度」与**表头列标题宽度**三者的大者: + // 面板里不再出现省略号(“时间间隔”四个字比缺省列宽 78 还宽)。 + const float columnWidth = + omitted ? 0.0f + : std::max(std::max(kFreeMarkerColumnWidths[column] * m_scale, + lane.freeMarkerColumnWidths[static_cast(column)]), + UiMeasureTextWidth(m_fonts->uiSmall, kFreeMarkerColumnLabels[column]) + + 2.0f * m_scale); + layout.markerColumns[column].left = columnX; + layout.markerColumns[column].width = columnWidth; + columnX += columnWidth + (omitted ? 0.0f : kFreeMarkerColumnGap * m_scale); + } + + const float contentBottom = layout.content.Y + layout.content.Height; + const float contentTop = layout.content.Y; + float rowY = contentTop - scroll; + for (const SignalRef& ref : lane.signals) + { + const ResolvedSignal resolved = ResolveSignal(ref); + if (resolved.data == nullptr || !resolved.visible) + { + continue; + } + // 该信号没有任何固定点 ⇒ 分组栏与数据行都**不出现**(用户明确要求)。 + SortedFreeMarkerIndices(lane, ref, order); + if (order.empty()) + { + continue; + } + EdgePanelLayout::MarkerGroup group; + group.ref = ref; + group.collapsed = lane.FreeMarkerGroupCollapsed(ref); + const float itemsHeight = + group.collapsed ? 0.0f : (headerRow + static_cast(order.size()) * itemRow); + const float groupHeight = groupRow + itemsHeight; + + // ---- 吸顶(sticky):分组栏与表头**各自**钉住,先后移出 ---- + // 规则(用户 2026-10-22): + // * 该分组还有条目没显示完全 ⇒ 分组栏钉在内容区顶部、表头钉在分组栏下方; + // * 到分组末尾时顺序是:**表头**先向上滚(被分组栏盖住)→ 最后一个条目滚到 + // 分组栏下 → **分组栏**最后移出内容区。 + // 实现(每个元素各自的“吸顶 + 推挤”夹取): + // y = max(自然位置, min(吸顶位置, 本组下边界 - 自身高度)) + // 夹取上限保证元素不会被推离自己的分组(这正是“推挤”效果)。 + float barTop = rowY; + float headerTop = rowY + groupRow; + if (!group.collapsed) + { + const float maxHeaderTop = rowY + groupHeight - headerRow; + headerTop = std::max(rowY + groupRow, std::min(contentTop + groupRow, maxHeaderTop)); + } + { + const float maxBarTop = rowY + groupHeight - groupRow; + barTop = std::max(rowY, std::min(contentTop, maxBarTop)); + } + group.pinned = (barTop > rowY + 0.5f); + group.header = gd::RectF(layout.content.X, barTop, innerWidth, groupRow); + group.triangle = gd::RectF(layout.content.X + 3.0f * m_scale, + barTop + (groupRow - 12.0f * m_scale) * 0.5f, + 12.0f * m_scale, 12.0f * m_scale); + group.close = gd::RectF(layout.content.X + innerWidth - closeSize - 2.0f * m_scale, + barTop + (groupRow - closeSize) * 0.5f, closeSize, closeSize); + float pinnedBottom = barTop + groupRow; + if (!group.collapsed) + { + group.tableHeader = gd::RectF(layout.content.X, headerTop, innerWidth, headerRow); + pinnedBottom = std::max(pinnedBottom, headerTop + headerRow); + } + // 被(吸顶的)分组栏 / 表头盖住的那一片区域:其下的条目既不可绘制也不可命中。 + const float pinnedTop = std::min(barTop, group.tableHeader.Width > 0.0f ? headerTop : barTop); + const auto coveredByPinned = [pinnedTop, pinnedBottom](const gd::RectF& rect) + { + return rect.Y < pinnedBottom - 0.01f && rect.Y + rect.Height > pinnedTop + 0.01f; + }; + if (group.header.Y + group.header.Height > contentTop && group.header.Y < contentBottom) + { + // 只登记"在可视内容区里"的分组栏与条目(滚出视界的条目不得抢走 + // 底部按钮 / 滑块的点击)。 + layout.markerGroups.push_back(group); + layout.rowRefs.push_back(ref); + layout.rowNameRects.push_back(group.header); + layout.rowNameCloses.push_back(group.close); + } + rowY += groupRow; + if (group.collapsed) + { + continue; + } + rowY += headerRow; + for (std::size_t orderPosition = 0; orderPosition < order.size(); ++orderPosition) + { + const std::size_t itemIndex = order[orderPosition]; + EdgePanelLayout::MarkerRow markerRow; + markerRow.itemIndex = itemIndex; + // 「序号」列:组内按时间升序从 1 开始(每组 = 一个信号)。 + markerRow.serial = orderPosition + 1; + markerRow.rect = gd::RectF(layout.content.X, rowY, innerWidth, itemRow); + markerRow.close = gd::RectF(layout.content.X + innerWidth - closeSize, + rowY + (itemRow - closeSize) * 0.5f, closeSize, + closeSize); + // 被吸顶的分组栏 / 表头完全盖住的条目既不绘制也不可命中。 + if (markerRow.rect.Y + markerRow.rect.Height > contentTop && + markerRow.rect.Y < contentBottom && !coveredByPinned(markerRow.rect)) + { + layout.markerRows.push_back(markerRow); + } + rowY += itemRow; + } + } + } + return layout; + } + + bool PlotView::FreeMarkerValue(const Lane& lane, const Lane::FreeMarkerItem& item, double& value, + double& enumValue) const + { + if (m_dataSet == nullptr) + { + return false; + } + const ResolvedSignal resolved = ResolveSignal(item.ref); + if (resolved.data == nullptr || resolved.style == nullptr || resolved.axis == nullptr || + !resolved.visible) + { + return false; + } + // 固定点存的是**显示时刻**,因此要用 `SampleDisplayedSignalAt`(内部处理时间偏移与 + // 水平镜像)。散点模式下不插值,取最近采样点,与光标读数的规则一致。 + const bool interpolate = (resolved.style->mode != DrawMode::SCATTER); + double sampled = 0.0; + if (!SampleDisplayedSignalAt(*resolved.axis, *resolved.data, *resolved.style, item.time, + interpolate, sampled) || + !std::isfinite(sampled)) + { + return false; + } + (void)lane; + enumValue = sampled; // 枚举按**原值**查 + value = ApplyDrawTransform(*resolved.style, sampled); // 读数与画出来的波形一致 + return std::isfinite(value); + } + + bool PlotView::FreeMarkerDelta(const Lane& lane, std::size_t itemIndex, double value, bool& hasPrev, + double& delta, double& timeDelta) const + { + hasPrev = false; + delta = 0.0; + timeDelta = 0.0; + if (itemIndex >= lane.freeMarkerItems.size()) + { + return false; + } + const Lane::FreeMarkerItem& item = lane.freeMarkerItems[itemIndex]; + std::vector order; + SortedFreeMarkerIndices(lane, item.ref, order); + // 找到自己在**排序后序列**里的位置;前一个即「左边最近的那个固定点」。 + std::size_t position = order.size(); + for (std::size_t index = 0; index < order.size(); ++index) + { + if (order[index] == itemIndex) + { + position = index; + break; + } + } + if (position == order.size() || position == 0) + { + return true; // 最左边(或找不到):没有增量 + } + const Lane::FreeMarkerItem& previous = lane.freeMarkerItems[order[position - 1]]; + double previousValue = 0.0; + double previousEnum = 0.0; + if (!FreeMarkerValue(lane, previous, previousValue, previousEnum)) + { + return true; // 前一个点没有有效取值:不显示增量(避免编造一个数) + } + hasPrev = true; + delta = value - previousValue; + timeDelta = item.time - previous.time; + return true; + } + + const std::vector& PlotView::FreeMarkerContents(int laneIndex) const + { + static const std::vector kNoEntries; + if (m_tab == nullptr || laneIndex < 0 || + laneIndex >= static_cast(m_tab->Lanes().size())) + { + return kNoEntries; + } + const Lane& lane = m_tab->Lanes()[static_cast(laneIndex)]; + if (static_cast(laneIndex) >= m_freeMarkerContent.size()) + { + m_freeMarkerContent.resize(static_cast(laneIndex) + 1); + } + FreeMarkerContentCache& cache = m_freeMarkerContent[static_cast(laneIndex)]; + + // 缓存键:只混入**与视界无关**的输入(条目、信号样式、数据地址与长度)。 + // ⚠️ 绝不能混入 `ViewMin/ViewMax`、鼠标位置或网格几何 —— 否则平移视界、 + // 拖动时间光标时缓存永远命中不了,又回到"每帧对每个固定点采样一次"。 + std::uint64_t key = 1469598103934665603ull; + const auto mix = [&key](std::uint64_t value) { + key ^= value; + key *= 1099511628211ull; + }; + const auto mixFloat = [&mix](float value) { + std::uint32_t bits = 0; + std::memcpy(&bits, &value, sizeof(bits)); + mix(static_cast(bits)); + }; + const auto mixDouble = [&mix](double value) { + std::uint64_t bits = 0; + std::memcpy(&bits, &value, sizeof(bits)); + mix(bits); + }; + mix(static_cast(lane.freeMarkerItems.size())); + // ⚠️ 两个显示开关只影响胶囊文本,但同样属于“与视界无关的输入”,必须进键。 + mix(lane.freeMarkerShowDelta ? 1ull : 0ull); + mix(lane.freeMarkerShowEnum ? 1ull : 0ull); + for (const Lane::FreeMarkerItem& item : lane.freeMarkerItems) + { + mix(static_cast(static_cast(item.ref.groupIndex))); + mix(static_cast(static_cast(item.ref.signalIndex))); + mixDouble(item.time); + const ResolvedSignal resolved = ResolveSignal(item.ref); + if (resolved.data == nullptr || resolved.style == nullptr) + { + mix(0ull); + continue; + } + mix(reinterpret_cast(resolved.data)); + mix(static_cast(resolved.data->values.size())); + mix(resolved.visible ? 1ull : 2ull); + mix(static_cast(resolved.style->color)); + mixFloat(resolved.style->opacity); + mixFloat(resolved.style->gain); + mixFloat(resolved.style->offsetY); + mixDouble(resolved.style->offsetTime); + mix(static_cast(static_cast(resolved.style->mode))); + mix((resolved.style->absolute ? 1ull : 0ull) | (resolved.style->mirrorVertical ? 2ull : 0ull) | + (resolved.style->mirrorHorizontal ? 4ull : 0ull)); + // ⚠️ 枚举表的内容决定「枚举文本」列与胶囊里的 `= <文本>`(以及缺省时的 `N/A`): + // 旧实现只混了信号指针与采样数量 ⇒ 重新加载/替换枚举表后仍贴回旧文本 + //(离屏探针实测:加上枚举表后条目里仍是 `N/A`)。这里把表的规模与文本数组 + // 的地址一并入键 —— 只比较 `size()` 不足以区分“换成另一张同样长度的表”。 + mix(static_cast(resolved.data->enumText.values.size())); + mix(static_cast(resolved.data->enumText.texts.size())); + mix(static_cast(resolved.data->enumText.rangeMin.size())); + mix(static_cast( + reinterpret_cast(resolved.data->enumText.texts.data()))); + } + + if (cache.key != key) + { + cache.key = key; + cache.entries.clear(); + cache.entries.reserve(lane.freeMarkerItems.size()); + for (std::size_t itemIndex = 0; itemIndex < lane.freeMarkerItems.size(); ++itemIndex) + { + const Lane::FreeMarkerItem& item = lane.freeMarkerItems[itemIndex]; + FreeMarkerContentEntry entry; + entry.itemIndex = itemIndex; + double value = 0.0; + double enumValue = 0.0; + const bool valueOk = FreeMarkerValue(lane, item, value, enumValue); + bool hasPrev = false; + double delta = 0.0; + double timeDelta = 0.0; + if (valueOk) + { + FreeMarkerDelta(lane, itemIndex, value, hasPrev, delta, timeDelta); + } + const ResolvedSignal resolved = ResolveSignal(item.ref); + const std::wstring* enumPtr = + (resolved.data != nullptr) ? resolved.data->enumText.Find(enumValue) : nullptr; + entry.valid = valueOk; + entry.value = value; + entry.color = (resolved.style != nullptr) ? StyleColor(*resolved.style) : 0u; + entry.capsuleText = FreeMarkerValueText(value, lane.freeMarkerShowDelta, hasPrev, delta, + lane.freeMarkerShowEnum, enumPtr); + entry.valueText = valueOk ? FormatValue(value) : std::wstring(kFreeMarkerNotAvailable); + // ⚠️ 枚举列一旦存在(面板里有任一固定点带枚举表),**没有**枚举文本的点必须显示 + // `N/A` 而不是空着(用户 2026-10-23 要求);数值对不上任何枚举项也落在这一分支。 + entry.enumText = (enumPtr != nullptr) ? *enumPtr : std::wstring(kFreeMarkerNotAvailable); + entry.deltaText = (valueOk && hasPrev) ? FormatSignedValue(delta) + : std::wstring(kFreeMarkerNotAvailable); + entry.timeText = FreeMarkerTimeText(item.time); + entry.deltaTimeText = FreeMarkerDeltaText(valueOk && hasPrev, timeDelta); + cache.entries.push_back(std::move(entry)); + } + } + return cache.entries; + } + + bool PlotView::FreeMarkerLabelRects(int laneIndex, std::size_t itemIndex, gd::RectF& label, + gd::RectF& close, std::wstring& text) const + { + label = gd::RectF(); + close = gd::RectF(); + text.clear(); + if (m_tab == nullptr || m_fonts == nullptr || laneIndex < 0 || + laneIndex >= static_cast(m_tab->Lanes().size()) || + laneIndex >= static_cast(m_lanes.size())) + { + return false; + } + const Lane& lane = m_tab->Lanes()[static_cast(laneIndex)]; + if (itemIndex >= lane.freeMarkerItems.size()) + { + return false; + } + const Lane::FreeMarkerItem& item = lane.freeMarkerItems[itemIndex]; + // 取值与文本来自**与视界无关**的内容缓存(大量固定点 + 拖光标/平移时不重复采样)。 + const std::vector& contents = FreeMarkerContents(laneIndex); + if (itemIndex >= contents.size() || !contents[itemIndex].valid) + { + return false; + } + const FreeMarkerContentEntry& entry = contents[itemIndex]; + const double value = entry.value; + + // 超出视界**直接不显示**(用户明确要求:不按常规数据点那样贴边定格 + 三角指示)。 + const float rawX = TimeToX(item.time); + if (rawX < m_plotRect.X - 0.5f || rawX > m_plotRect.X + m_plotRect.Width + 0.5f) + { + return false; + } + const LaneVisual& visual = m_lanes[static_cast(laneIndex)]; + const float clipTop = std::max(visual.rect.Y, m_plotRect.Y); + const float clipBottom = + std::min(visual.rect.Y + visual.rect.Height, m_plotRect.Y + m_plotRect.Height); + const float rawY = ValueToY(value, visual); + if (!std::isfinite(rawY) || rawY < clipTop - 0.5f || rawY > clipBottom + 0.5f) + { + return false; // 纵向越界同样直接不显示 + } + + // 枚举文本按**采样原值**查找(与光标读数完全一致)。 + text = entry.capsuleText; + + // ✕ 按钮 = **与取值胶囊同高的正方形**(用户明确要求:长宽都等于胶囊的上下高度)。 + // ⚠️ 宽度多加 2px 余量:`DrawTextW` 的 `DT_END_ELLIPSIS` 有时会因为亚像素/字形 + // 伸部而把“刚好装满”的文本截成省略号(用户报告枚举文本被 … 缺省)。 + const float textWidth = UiMeasureTextWidth(m_fonts->monoSmall, text) + 12.0f * m_scale; + const float textHeight = 17.0f * m_scale; + const float closeSize = textHeight; + const float gap = kFreeMarkerCloseGap * m_scale; + const float totalWidth = textWidth + gap + closeSize; + + float left = rawX + 6.0f * m_scale; + if (left + totalWidth > m_plotRect.X + m_plotRect.Width - 2.0f * m_scale) + { + left = rawX - 6.0f * m_scale - totalWidth; + } + left = std::max(m_plotRect.X + 2.0f * m_scale, + std::min(left, m_plotRect.X + m_plotRect.Width - totalWidth - 2.0f * m_scale)); + float top = rawY - textHeight * 0.5f; + top = std::max(clipTop + 1.0f * m_scale, std::min(top, clipBottom - textHeight - 1.0f * m_scale)); + + label = gd::RectF(left, top, textWidth, textHeight); + close = gd::RectF(left + textWidth + gap, top, closeSize, closeSize); + return true; + } + + PlotView::FreeMarkerProbe PlotView::ProbeFreeMarker(int mouseX, int mouseY) const + { + FreeMarkerProbe probe; + if (m_tab == nullptr || m_dataSet == nullptr || + !UiPointInRect(m_plotRect, static_cast(mouseX), static_cast(mouseY))) + { + return probe; + } + const int laneIndex = LaneAtY(mouseY); + if (laneIndex < 0 || laneIndex >= static_cast(m_lanes.size()) || + laneIndex >= static_cast(m_tab->Lanes().size())) + { + return probe; + } + const Lane& lane = m_tab->Lanes()[static_cast(laneIndex)]; + if (!lane.freeMarker) + { + return probe; + } + // ⚠️⚠️ **未开启「自由标记点选取」时鼠标就是普通鼠标**(用户 2026-10-23 明确要求): + // 不预览数据点、不画纵向光标、按下/拖动完全走常规语义(平移 / 框选 / 拖信号), + // 也不会固定任何数据点。所有自由标记的探测都从这一句的“早退”开始。 + if (!lane.freeMarkerCursor) + { + return probe; + } + const LaneVisual& visual = m_lanes[static_cast(laneIndex)]; + + // 「自由标记点选取」已开启:网格内始终跟随鼠标画纵向光标(见 `PaintFreeMarker`)。 + const bool cursorMode = true; + probe.cursorMode = cursorMode; + probe.laneIndex = laneIndex; + probe.time = XToTime(static_cast(mouseX)); + probe.x = static_cast(mouseX); + + // 命中容差 = **与"常规状态下触发信号曲线高亮"完全一致**(`kSignalHitTolerance`), + // 且判定的是「鼠标到**画出来的折线**的垂直距离」,而不是"同一横坐标处的采样值之差"。 + // ⚠️ 旧实现只看纵向差值 ⇒ 阶跃的**竖直跳变段**永远判不中:那一列上下两个采样值 + // 都不等于鼠标所在的高度(用户 2026-10-22 报告“在跳变纵线上完全点不中、没有吸附提示”)。 + const float fx = static_cast(mouseX); + const float fy = static_cast(mouseY); + const float tolerance = kSignalHitTolerance * m_scale; + + // 把“显示时刻”吸附到最近的**采样时刻**(返回显示时刻空间的值)。 + // 与 `SnapTimeToSample` 的区别:这里**不做稀疏判定**,任何信号都可以吸附, + // 因为固定点本来就应当落在真实数据点上(阶跃端点必须能选中)。 + const auto snapToNearestSample = [](const TimeAxis& axis, const SignalData& data, + const SignalStyle& style, double displayTime, + double& out) -> bool + { + const std::size_t count = std::min(axis.time.size(), data.values.size()); + if (count == 0) + { + return false; + } + const double target = displayTime - style.offsetTime; + if (axis.monotonic) + { + const auto lower = + std::lower_bound(axis.time.begin(), + axis.time.begin() + static_cast(count), target); + std::size_t index = static_cast(lower - axis.time.begin()); + if (index >= count) + { + index = count - 1; + } + if (index > 0 && + std::fabs(axis.time[index - 1] - target) <= std::fabs(axis.time[index] - target)) + { + --index; + } + out = axis.time[index] + style.offsetTime; + return true; + } + const int nearest = FindNearestSample(axis, target); + if (nearest < 0 || static_cast(nearest) >= count) + { + return false; + } + out = axis.time[static_cast(nearest)] + style.offsetTime; + return true; + }; + + // 在网格内所有可见信号中找**离鼠标最近**的那个数据点。 + double bestDistance = std::numeric_limits::infinity(); + double bestTime = probe.time; + double bestValue = 0.0; + double bestEnum = 0.0; + float bestY = 0.0f; + bool bestSnapped = false; + for (const SignalRef& ref : lane.signals) + { + const ResolvedSignal resolved = ResolveSignal(ref); + if (resolved.data == nullptr || resolved.style == nullptr || resolved.axis == nullptr || + !resolved.visible) + { + continue; + } + // 「自动点线」判据成立时吸附到最近采样点(与光标吸附同一规则)。 + // ⚠️ 用户明确要求:只要判据成立就吸附,**不**管当前是散点还是用户自己改过的样式。 + const bool sparse = MinorCellSparse(*resolved.axis, *resolved.data, resolved.style->offsetTime); + + // ---- ① 取“鼠标到这条曲线”的距离(复用绘制阶段的折线顶点)---- + float nearestX = fx; + double distance = std::numeric_limits::infinity(); + const PolylineCacheEntry* cached = AcquirePolyline(ref, resolved, visual); + if (cached != nullptr && cached->points.size() >= 2) + { + const std::vector& points = cached->points; + const std::vector& breaks = cached->breaks; + // 横向先行裁剪(点到线段的距离必然 ≥ 横向距离):时间单调时二分定位。 + std::size_t windowFirst = 0; + std::size_t windowLast = points.size(); + if (resolved.axis->monotonic) + { + const auto byX = [](const gd::PointF& point, float limit) { return point.X < limit; }; + windowFirst = static_cast( + std::lower_bound(points.begin(), points.end(), fx - tolerance, byX) - + points.begin()); + windowLast = static_cast( + std::upper_bound(points.begin(), points.end(), fx + tolerance, + [](float limit, const gd::PointF& point) + { return limit < point.X; }) - + points.begin()); + } + if (resolved.style->mode == DrawMode::SCATTER) + { + // 散点:直接比较采样点本身(不把连线当成可命中目标)。 + const std::size_t begin = (windowFirst > 0) ? (windowFirst - 1) : 0; + const std::size_t end = std::min(points.size(), windowLast + 1); + for (std::size_t index = begin; index < end; ++index) + { + const float dx = points[index].X - fx; + const float dy = points[index].Y - fy; + const double probeDistance = std::sqrt(static_cast(dx * dx + dy * dy)); + if (probeDistance < distance) + { + distance = probeDistance; + nearestX = points[index].X; + } + } + } + else + { + for (std::size_t k = 0; k < breaks.size(); ++k) + { + const std::size_t start = breaks[k]; + const std::size_t stop = (k + 1 < breaks.size()) ? breaks[k + 1] : points.size(); + const std::size_t scanBegin = std::max(start + 1, windowFirst); + // `windowLast + 1`:包含“一端在窗口内、另一端在窗口外”的跨窗口线段 —— + // 阶跃的竖直段就是这种线段(上端与下端相隔整个跳变幅度)。 + const std::size_t scanEnd = std::min(stop, windowLast + 1); + for (std::size_t index = scanBegin; index < scanEnd; ++index) + { + const gd::PointF& a = points[index - 1]; + const gd::PointF& b = points[index]; + const float vx = b.X - a.X; + const float vy = b.Y - a.Y; + const float wx = fx - a.X; + const float wy = fy - a.Y; + const float lengthSquared = vx * vx + vy * vy; + float t = 0.0f; + if (lengthSquared > 1.0e-6f) + { + t = (wx * vx + wy * vy) / lengthSquared; + t = std::max(0.0f, std::min(1.0f, t)); + } + const float dx = wx - t * vx; + const float dy = wy - t * vy; + const double segmentDistance = + std::sqrt(static_cast(dx * dx + dy * dy)); + if (segmentDistance < distance) + { + distance = segmentDistance; + nearestX = a.X + t * vx; + } + } + } + } + } + + // ---- ② 决定标注时刻 ---- + // ⚠️⚠️ 用户 2026-10-27 明确要求:**总是**预览「网格内离鼠标较近的那个信号」的数据点 + //(同一时刻下 A 信号比 B 信号离鼠标更近 ⇒ 预览 A、单击固定的也是 A), + // **不要求鼠标贴近曲线**。 + // 旧实现在两种距离都超出容差时直接 `continue`,于是「网格里只有一条信号、鼠标离曲线 + // 较远」时既没有预览点,也无法用左键固定(用户报告的核心问题)。 + // + // 因此这里准备**两个候选**,取更近的那一个: + // * 曲线候选 = 鼠标到**画出来的折线**的垂直距离(阶跃的竖直跳变段只有它能命中); + // * 采样候选 = 同一时刻(鼠标时刻吸附到最近采样)该采样点在屏幕上的**纵向距离**。 + // 两者都不再设“必须命中”的门槛;门槛(`tolerance`)只用来决定是否画「已吸附」圆环。 + const bool interpolate = (resolved.style->mode != DrawMode::SCATTER); + double time = 0.0; + double sampled = 0.0; + bool candidateOk = false; + // (a) 曲线候选 + { + double candidateTime = XToTime(nearestX); + if (snapToNearestSample(*resolved.axis, *resolved.data, *resolved.style, candidateTime, + candidateTime)) + { + double candidateSample = 0.0; + if (SampleDisplayedSignalAt(*resolved.axis, *resolved.data, *resolved.style, + candidateTime, interpolate, candidateSample) && + std::isfinite(candidateSample)) + { + time = candidateTime; + sampled = candidateSample; + candidateOk = true; + } + } + } + // (b) 采样候选:同一时刻的纵向距离(“哪个信号离鼠标更近”的主判据) + { + double candidateTime = probe.time; + if (snapToNearestSample(*resolved.axis, *resolved.data, *resolved.style, candidateTime, + candidateTime)) + { + double candidateSample = 0.0; + if (SampleDisplayedSignalAt(*resolved.axis, *resolved.data, *resolved.style, + candidateTime, interpolate, candidateSample) && + std::isfinite(candidateSample)) + { + const double candidateValue = + ApplyDrawTransform(*resolved.style, candidateSample); + if (std::isfinite(candidateValue)) + { + const double vertical = std::fabs( + static_cast(ValueToY(candidateValue, visual)) - + static_cast(fy)); + if (!candidateOk || vertical < distance) + { + distance = vertical; + nearestX = fx; + time = candidateTime; + sampled = candidateSample; + candidateOk = true; + } + } + } + } + } + if (!candidateOk) + { + continue; + } + // 固定点必须落在真实采样时刻上(两个候选都已吸附到采样点)。 + // 表示“已吸附”的圆环仍只在「自动点线」判据成立(`sparse`)时画: + // 密采样信号里吸附量远小于 1 像素,圆环会一直挂着,反而像是画错了。 + const bool snapped = sparse; + const double value = ApplyDrawTransform(*resolved.style, sampled); + if (!std::isfinite(value)) + { + continue; + } + const float y = ValueToY(value, visual); + if (distance >= bestDistance) + { + continue; + } + bestDistance = distance; + bestTime = time; + bestValue = value; + bestEnum = sampled; + bestY = y; + bestSnapped = snapped; + probe.ref = ref; + } + + if (!probe.ref.IsValid()) + { + // 网格内没有可标注的数据点:「自由标记点选取」模式下仍要画纵向线(`valid` 为真)。 + probe.valid = true; + return probe; + } + probe.valid = true; + probe.time = bestTime; + probe.x = TimeToX(bestTime); + probe.value = bestValue; + probe.enumValue = bestEnum; + probe.y = bestY; + probe.snapped = bestSnapped; + return probe; + } + +} // namespace jdi diff --git a/Documents/UI_Refactor_Progress.md b/Documents/UI_Refactor_Progress.md index 41e6b0d..817fc4e 100644 --- a/Documents/UI_Refactor_Progress.md +++ b/Documents/UI_Refactor_Progress.md @@ -107,9 +107,45 @@ Tools/uicheck/uicheck.exe # 62 passed, 0 failed --- +### 3. `P2 续`:把"布局与命中几何"从绘制分片里拆出(`PlotViewPanels.cpp`) + +**为什么**:`EdgePanelLayoutFor`(651 行,19 个调用点)、`EdgePanelWidth`、`LaneBadgeRect`、 +`FreeMarkerValue/Delta/Contents/LabelRects`、`ProbeFreeMarker` 都是**几何**,却被塞在绘制分片里 —— +改一次布局要动 9 千行的绘制文件,回归面被放大。 + +**做法**:同样机械搬移(5 个不连续块),`PlotViewPaint.cpp` 从 6 645 → **4 913** 行。 + +**最终七个分片**(按关注点) + +| 文件 | 行数 | 职责 | +| --- | --- | --- | +| `PlotViewPaint.cpp` | 4 913 | 绘制(`Paint*` / `Render*` / 输入框绘制) | +| `PlotViewInteraction.cpp` | 3 430 | 命中、鼠标/键盘、光标形状、面板层序 | +| `PlotViewFeatures.cpp` | 3 266 | 光标读数、提示、缩放、自适应、派生曲线、边沿统计 | +| `PlotViewRaster.cpp` | 1 876 | 光晕层 / 场景缓存 / 装饰层 / 宽基底图层与预算 | +| `PlotViewPanels.cpp` | 1 359 | 面板布局、图例徽标几何、自由标记取值与探测 | +| `PlotView.cpp` | 889 | 视图核心(构造、布局、坐标映射、网格结构) | +| `PlotViewCacheKey.cpp` | 474 | 内容指纹与缓存键 | + +**累计效果**:`PlotViewPaint.cpp` **9 080 → 4 913(−46%)**,最大文件从 9.1k 降到 4.9k。 + +**验证** + +1. `make release TARGET_FILE_NAME=TargetCheck` 零警告;`Target.exe` **877 568 B**(< 900 KB)。 +2. **逐像素 A/B**:同三场景哈希与迁移前**完全一致** + (`A 2166933039626F66 / B C444ECFEB18FD73D / C 7984446D8E7A4418`)。 +3. `Tools/uicheck` 62/62。 +4. 顶层注释更新为"七个分片(按关注点)",并标明 `Paint/PaintLane` 只允许定义在 `PlotViewPaint.cpp`。 + +--- + ## 下一步(接手者请从这里开始) -1. **P2 续**:把"布局与命中几何"也搬出去(`EdgePanelLayoutFor` 651 行 + `ProbeFreeMarker` 286 行 + - `FreeMarkerLabelRects` 118 行 → 新 `PlotViewPanels.cpp`),判据同上(逐像素 0 差异)。 -2. **P3:Toolbar 优先**(最小、最容易验证的组件),迁移后跑 `uicheck` + 新旧像素对比。 -3. 每个阶段单独提交,提交信息写清"改了什么 / 为什么 / 怎么验证"。 +1. **P3:Toolbar 优先**(最小、最容易验证的组件): + 继承 `UiWidget` → `OnMeasure` 由按钮文本实测得出 → `OnPaint` 用 `ctx.clip` 画按钮 → + `OnEvent` 返回 `{consumed, redraw, dirty}`;迁移后用 `uicheck` + 新旧像素对比。 +2. **P4:浮动面板**:`EdgePanelLayoutFor` 的 651 行 → 真控件(Stack + Abs + Measure-driven), + 并消掉面板里 10 多个平行数组(`rowRefs/rowChecks/rowPoints/rowOrder/rowLevelTracks/...`)。 +3. **P5:共享 DIB + `SurfaceView`**(`CreateCompatibleBitmap` → top-down 32bpp `CreateDIBSection`, + 带 `version` 作废机制),随后把 56 处 `InvalidateRect` 收敛成脏区并集。 +4. 每个阶段单独提交,提交信息写清"改了什么 / 为什么 / 怎么验证"。 -- 2.54.0 From 7e1852c2d22b0ac9a970881bd68b53caab36eac6 Mon Sep 17 00:00:00 2001 From: "DeepSeek V4.1 Flash" <> Date: Wed, 28 Oct 2026 12:22:09 +0800 Subject: [PATCH 05/21] =?UTF-8?q?UI=20=E9=87=8D=E6=9E=84=EF=BC=9A=E8=A1=A5?= =?UTF-8?q?=E5=85=85=E8=BF=9B=E5=BA=A6=E5=8F=B0=E8=B4=A6=E3=80=81=E9=98=B6?= =?UTF-8?q?=E6=AE=B5=E6=8A=A5=E5=91=8A=E4=B8=8E=E5=85=B1=E4=BA=AB=E8=AE=B0?= =?UTF-8?q?=E5=BF=86=E5=90=8C=E6=AD=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Documents/UI_Refactor_Progress.md 补齐 P2 续(PlotViewPanels.cpp)的完成状态、七个分片行数、 逐像素 A/B 哈希与"回退到旧文件做 A/B 时必须先删陈旧 .o"的复现要求。 - 新增 Documents/UI_Refactor_Report.md:第一阶段(P0/P1/P2)交付物、关键数字、 基础层契约、验证方式、过程中修掉的 4 个自身缺陷、明确未做项与下一步排序。 - Documents/agent_mem.md:§11.2 追加第 235-240 条(基础层位置与边界、只沿命中链派发、 Paint 三条契约、布局缓存纪律、PlotView 七分片硬约束、uicheck 回归网), 并在 §10 变更日志追加本阶段一行。 验证:make release / make debug 零警告;Tools/uicheck 62 passed / 0 failed; Target.exe 877,568 B(< 900KB)。 --- Documents/UI_Refactor_Report.md | 173 ++++++++++++++++++++++++++++++++ Documents/agent_mem.md | 33 ++++++ 2 files changed, 206 insertions(+) create mode 100644 Documents/UI_Refactor_Report.md diff --git a/Documents/UI_Refactor_Report.md b/Documents/UI_Refactor_Report.md new file mode 100644 index 0000000..a0b45a3 --- /dev/null +++ b/Documents/UI_Refactor_Report.md @@ -0,0 +1,173 @@ +# UI 重构报告(第一阶段:P0 / P1 / P2) + +日期:2026-10-28 分支:`UI_Refactor`(从 `dev` 切出;`dev` 又由 `main@df715a0` 切出) +所有提交**只在本地仓库**,`main` / `dev` 未受影响。计划见 `UI_Refactor_Plan.md`,台账见 `UI_Refactor_Progress.md`。 + +--- + +## 1. 本次交付了什么 + +| 阶段 | 交付物 | 状态 | +| --- | --- | --- | +| **P0** | 入库离屏回归网 `Tools/uicheck`(62 项断言,`build.ps1` + `main.cpp`) | ✅ | +| **P1** | UI 基础层 `Components/Shared/{Inc,Src}/UiLayout.*` + `UiWidget.*`(约 1 440 行含注释) | ✅ | +| **P2** | `PlotView` 实现按关注点重分为**七个分片**;`PlotViewPaint.cpp` 9 080 → **4 913** 行(**−46%**) | ✅ | + +三个提交: + +``` +ec3f93f UI 重构 P2 续:把布局与命中几何从绘制分片拆出(PlotViewPanels.cpp,逐像素 0 差异) +b532046 UI 重构 P2:PlotViewPaint.cpp 按关注点分片(机械搬移,逐像素 0 差异) +8bdf48c UI 重构 P0+P1:新增布局/控件基础层与离屏回归网(对产品像素零影响) +``` + +--- + +## 2. 关键数字 + +| 指标 | 重构前 | 重构后 | +| --- | --- | --- | +| `PlotViewPaint.cpp` | 9 080 行 | **4 913 行(−46%)** | +| 最大源文件 | 9 080(`PlotViewPaint.cpp`) | 4 913(`PlotViewPaint.cpp`) | +| `PlotView` 实现文件数 | 4 | **7**(职责单一) | +| 自动化 UI 探针 | **0** | **62 项断言**(入库、可复跑) | +| 产物 | 873 984 B | **877 568 B**(+3.6 KB,< 900 KB 阈值) | +| 产品像素差异 | — | **0**(三场景渲染哈希逐一相同) | + +--- + +## 3. 新增的 UI 基础层(P1) + +### 3.1 `UiLayout`:布局原语 + 布局缓存 + +| 能力 | 说明 | +| --- | --- | +| 矩形原语 | `UiRectIntersect` / `UiRectUnion` / `UiRectDeflate` / `UiRectHasArea` | +| **设备像素内侧对齐** | `UiRectSnapInward`(上界 `floor`、下界 `ceil`)—— 离屏位图贴图源矩形必须用它,否则补丁会多算一行盖到相邻网格(历史 1px 接缝 BUG) | +| 对齐 | `UiAlignInRect`(BEGIN/CENTER/END/**STRETCH**、超尺寸夹取、偏移后置) | +| Stack | `UiStackExtent` / `UiStackRects`(固定尺寸 + `kUiSizeAuto` 均分剩余、**绝不产生负尺寸**) | +| Flow | `UiFlowRects`(换行 + `overflow` 下标,供图例 `+N` 使用) | +| Abs | `UiAbsRect`(绘图区叠加物的主力布局) | +| 缓存 | `UiLayoutCache`:`std::deque` 保证引用稳定、**定向失效**、按"最久未命中"淘汰 | + +> 设计取舍:**不实现 Grid**(绘图区不是表格,用不上);**Card 只是皮肤**不是布局; +> 真正需要的是 **Abs(主力)/ Anchor / Stack / Flow / Measure-driven**。 + +### 3.2 `UiWidget`:控件树基类 + +```mermaid +flowchart TD + H[UiHost(P5)
共享 DIB · 脏区并集 · 一次回送 · 动画 tick] + H --> R[UiWidget 根] + R --> A[普通控件:直接画进共享缓冲] + R --> B[高成本叶子:自持位图,完成即 BitBlt] + A --> S[SurfaceView{dc, rect, stride, bits, version}] + B --> S +``` + +已实现的契约: + +| 契约 | 内容 | +| --- | --- | +| 两段式布局 | `Measure`(自下而上、带缓存、失效只沿祖先链)/ `Arrange`(自上而下、回调 `OnArrange` 摆子节点) | +| 脏区下探绘制 | `dirty` 为空 = 全画;**与子树矩形无交集 ⇒ 整棵子树 return**;有效裁剪 = 祖先裁剪切 ∩ 自身 ∩ 脏区 | +| 裁剪契约 | 进入时收紧 GDI+ 裁剪并暴露 `ctx.clip`;**GDI 绘制不受其约束** ⇒ 派生类必须把 `ctx.clip` 传给 `UiTextClipped` 一类辅助函数 | +| 事件派发 | capture(拖拽期直投)→ modal(只向模态子树)→ **只沿命中链**(最深命中者 → 祖先兜底);键盘沿焦点链;`{consumed, redraw, dirty}` | +| 交互仲裁 | `CaptureMouse` / `ReleaseMouse`(仅持有者可释放)/ `SetModal` / `SetHitTransparent` / `RequestFocus` | +| 光标链 | `QUERY_CURSOR` 由最上层认领者返回,未认领交给宿主 | +| 动画 | `AdvanceAnimation(nowMs)` 逐节点下发同一时间戳并汇总"是否仍有动画" | +| 缓存键 | `ContentKey()`(派生类提供)+ `BaseContentKey()`(矩形/可见性),父层只聚合 | + +**三条禁区(写进 `agent_mem.md` §11.2 第 235–240 条)** + +1. 波形数据→像素管线(网格/曲线/采样点)**不做成控件节点**; +2. **不是每个控件自持位图**(内存按面积线性增长:128 网格已 ≈250 MB 量级); +3. 禁止"父脏 ⇒ 子树全脏"(会把"只重绘一个网格/面板"打回整块重绘)。 + +--- + +## 4. 分片重构(P2) + +### 4.1 七个分片 + +| 文件 | 行数 | 职责 | +| --- | --- | --- | +| `PlotViewPaint.cpp` | 4 913 | 绘制(`Paint*` / `Render*` / 输入框绘制) | +| `PlotViewInteraction.cpp` | 3 430 | 命中、鼠标/键盘、光标形状、面板层序 | +| `PlotViewFeatures.cpp` | 3 266 | 光标读数、提示、缩放、自适应、派生曲线、边沿统计 | +| `PlotViewRaster.cpp` | 1 876 | 光晕层 / 场景缓存 / 装饰层 / 宽基底图层与预算 | +| `PlotViewPanels.cpp` | 1 359 | 面板布局、图例徽标几何、自由标记取值与探测 | +| `PlotView.cpp` | 889 | 视图核心(构造、布局、坐标映射、网格结构) | +| `PlotViewCacheKey.cpp` | 474 | 内容指纹与缓存键 | + +### 4.2 为什么能"零风险"搬移 + +- `PlotViewInternal.hpp` 里 `pvdetail` 的工具函数**全部 `inline`** ⇒ 跨 TU 安全的; +- 所有搬移的都是**成员函数定义**,声明仍在 `PlotView.hpp`; +- 因此**不需要改任何 include,也不需要改 `Makefile`**(按 basename 自动收集 `.cpp`)。 + +### 4.3 验证方式(重点) + +**不是"能编译就算过"**,而是**逐像素 0 差异**: + +| 场景 | 内容 | +| --- | --- | +| A | 默认全览(4 条信号) | +| B | 深放大 0.90–1.10 + 自由标记(含固定点、透明度 0.8)+ 均值滤波(点数 2 / 迭代 1)+ 弱峰调整(60% / 2 次)+ 时间光标 | +| C | 在 B 基础上再平移一帧(触发宽基底图层滚动/补条路径) | + +渲染后备缓冲区后取 FNV 哈希: + +``` +迁移前 A 2166933039626F66 B C444ECFEB18FD73D C 7984446D8E7A4418 +迁移后 A 2166933039626F66 B C444ECFEB18FD73D C 7984446D8E7A4418 ← 三场景完全一致 +``` + +哈希与 `Tools/uicheck` 都在每次搬移后重跑;`make release` / `make debug` 零警告。 + +--- + +## 5. 过程中修掉的自身缺陷 + +| # | 缺陷 | 修法 | +| --- | --- | --- | +| 1 | `UiWidget` 头文件漏声明 `AdvanceAnimationSelf` / `AnimationActiveSelf`(`.cpp` 已引用) | 补 protected 虚函数 | +| 2 | 最初的派发是"遍历同层兄弟、未消费就继续" ⇒ **一次点击可能被视觉上位于下方的控件也处理** | 改为"只沿命中链派发 + 祖先兜底" | +| 3 | 探针用**绝对次数**断言事件派发次数(被前面用例的计数污染) | 全部改成**增量断言** | +| 4 | 分片后回退做 A/B 时,`Build//` 残留 `PlotViewRaster.o` / `PlotViewCacheKey.o`,链接探针因重复符号失败 | 记录为复现步骤的硬要求:先删陈旧 `.o` | + +另外顺手清理:`agent_mem.md` 侧新增 §11.2 第 235–240 条 + §10 变更日志行; +`documents/refactor_report_2026-10-16.md`(过时报告)在 `dev` 分支删除,结论已并入 `agent_mem.md`。 + +--- + +## 6. 明确未做的事(避免误解) + +| 未做 | 原因 | +| --- | --- | +| 迁移 Toolbar / TabBar / SignalPanel / PlotView 到 `UiWidget` | 属 P3/P4;需逐组件迁移、每个组件单独 A/B,本轮只打地基 | +| `UiHost` + 共享 32bpp DIB + `SurfaceView` | 属 P5;需同时把 56 处 `InvalidateRect` 收敛成脏区并集,必须与迁移同批验证 | +| 引入多线程光栅 | 本项目实测 **1.03x**(GDI+ 内部串行),且有"后台改写视界覆盖用户平移"的历史事故 | +| 每控件独立位图 | 内存按面积线性增长(128 网格 ≈250 MB) | +| 删除 1 295 行硬核"为什么"注释 | 它们是防回归的核心资产,行数不是问题 | + +--- + +## 7. 下一步建议(按收益/风险排序) + +1. **P3:Toolbar 迁移**(最小、最易验证)→ 用 `uicheck` + 新旧像素对比收口。 +2. **P4:浮动面板迁移**:`EdgePanelLayoutFor`(651 行)→ 真控件(Stack + Abs + Measure-driven), + 同时消掉面板里 10 多个平行数组(`rowRefs/rowChecks/rowPoints/rowOrder/rowLevelTracks/...`)。 + ⚠️ 每个面板单独一轮:吸顶/滚动/滚动条/滑块复位/行悬停压暗/开关联动是 6 轮补丁叠出的隐式约定。 +3. **P5:共享 DIB + 脏区并集**(`CreateCompatibleBitmap` → top-down 32bpp `CreateDIBSection`, + `SurfaceView` 带 `version` 作废机制)。 +4. 每阶段一个可独立回滚的提交;`main` / `dev` 只接受"`uicheck` 全绿 + 像素 A/B 通过"的提交。 + +--- + +## 8. 一句话结论 + +> 本阶段没有改动任何产品行为:**新增了一套可复用、可测试的布局/控件基础层**, +> 并把 9 080 行的"万能绘制文件"按关注点降到 4 913 行(−46%), +> 两者都用**逐像素 0 差异**与 **62 项离屏断言**证明。后续每个组件迁移都可以复用这套回归网, +> 从而把"AI 写得快"转换成"你敢合多少"。 diff --git a/Documents/agent_mem.md b/Documents/agent_mem.md index 0359a1b..c2b4ee7 100644 --- a/Documents/agent_mem.md +++ b/Documents/agent_mem.md @@ -3023,6 +3023,8 @@ else if (panelOnly) { /* 只回送“本帧重绘过的区域并集” */ } | 2026-10-27 | DeepSeek-V4.1-Flash(**本机**) | **浮动面板事件穿透 + 「自由标记」总是预览最近信号 + 固定点位于 GX/图例之下 + 预览曲线取值插值 + 体积红线 900KB(详见 §12 末尾「2026-10-27 第六轮」与 §11.2 第 230–234 条)**。① **面板右键/中键穿透**:新增 `PlotView::OnRButtonDown`(`TopPanelAtPoint` 命中即置顶并消费,宿主不再弹网格 Y 轴菜单);`OnMButtonDown` 在面板上不再进入平移。② **自由标记探测去掉“必须贴近曲线”的门槛**:两个候选(折线垂直距离 / 同一时刻采样点纵向距离)取更近者 ⇒ 网格内只有一条信号、鼠标离曲线很远时也能预览并单击固定(A/B:恢复旧门限 ⇒ 5 项断言失败)。③ **固定标记点必须位于 GX 徽标与图例之下**:用 `gd::Region::Exclude` 把图例托底与徽标矩形从 `PaintFreeMarker` 的绘制区里排除,胶囊文字在相交时改走 GDI+(GDI 文字不认裁剪);实测图例区域像素差 **0**(旧的“画完再重绘图例”方案残留 240px 抗锯齿差)。④ **预览曲线取值点悬空**:`paintDerivedCapsule` 改为在光标时刻对派生曲线**按时间插值**(先 `ApplyDrawTransform` 再线性插值,与折线逐点一致);阶跃区实测标注点与曲线差 **0.0px**(旧实现偏 36–39px),中心轴/均值滤波/弱峰调整共用同一段代码。⑤ **体积红线由 800KB 放宽到 900KB**(到达阈值时提醒用户即可,不是禁止超过)。验证:离屏临时探针 **25/25**(已删除,不留在仓库);`make release`/`make debug` 零警告;`Target.exe` **860,672 字节(840.5 KiB)**。 | +| 2026-10-28 | DeepSeek-V4.1-Flash(**UI_Refactor 分支**) | **UI 重构第一阶段(P0/P1/P2,全部只提交到本地 `UI_Refactor` 分支,不影响 `main`)**:① 新增 `Components/Shared/{Inc,Src}/UiLayout.*`(矩形原语 / 对齐 / 设备像素内侧对齐 / Stack / Flow / 布局缓存)与 `UiWidget.*`(控件树基类:两段式布局、脏区下探绘制、只沿命中链派发、capture/modal/hover/focus 仲裁、光标认领链、统一动画 tick、内容指纹入口)。② 新增入库离屏回归网 `Tools/uicheck`(62 项断言,链接 `Build//*.o` 排除 `Main.o`)。③ `PlotView` 实现按关注点重分为七个分片:`PlotViewPaint.cpp` 9 080 → **4 913** 行(−46%),新增 `PlotViewRaster.cpp`(1 876)、`PlotViewCacheKey.cpp`(474)、`PlotViewPanels.cpp`(1 359);搬移**逐像素 0 差异**(三场景渲染哈希 `A 2166933039626F66 / B C444ECFEB18FD73D / C 7984446D8E7A4418` 迁移前后完全一致)。详见 §11.2 第 235–240 条与 `Documents/UI_Refactor_Plan.md` / `UI_Refactor_Progress.md` / `UI_Refactor_Report.md`。 | + ## 11. 跨设备协作须知(起源设备视角) > **本文件是唯一权威记忆**。所有设备 / Agent 共用它,**不要再新建 `agent_mem_origin.md` 等旁支记忆文件**。 @@ -4081,6 +4083,37 @@ else if (panelOnly) { /* 只回送“本帧重绘过的区域并集” */ } 234. ⚠️ **产物体积红线已放宽到 900KB**(用户 2026-10-27):**到达 900KB 必须提醒用户**, 以便确认功能带来的体积膨胀是否值得;这不是“不许超过”,旧 800KB 红线作废。 +235. ⚠️⚠️ **新增 UI 基础层的位置与边界**(2026-10-28,`UI_Refactor`): + `Components/Shared/{Inc,Src}/UiLayout.*`(纯几何 + 布局缓存)与 `UiWidget.*`(控件树基类)。 + 它们**不依赖任何功能组件**(只有 `Theme.hpp` / `UiLayout.hpp`),符合既有的 + `Components/* → Components/Shared → Utilities` 依赖方向。 + ⚠️ 新增 `.cpp` 放在 `Components/Shared/Src` 即可,**无需改 `Makefile`**(按 basename 自动收集)。 +236. ⚠️⚠️ **`UiWidget` 的事件语义:只沿命中链派发,绝不分发给同层兄弟**。 + `Dispatch` 先判 capture(拖拽期事件直投捕获者)→ 再判 modal(只向模态子树派发)→ + 位置事件沿"最深命中者 → 祖先"冒泡(命中者优先,未消费才交给祖先作兜底); + 键盘/字符沿**焦点链**(无焦点交给根,保证宿主级快捷键仍可用)。 + 早期实现"遍历同层兄弟、未消费就继续" ⇒ **一次点击可能被视觉上位于下方的控件也处理**(已修)。 +237. ⚠️⚠️ **`UiWidget::Paint` 的三条契约**: + ① `ctx.dirty` 为空 = 脏区未知 ⇒ 全画;非空 ⇒ 只保证该区域被更新; + ② 脏区与子树矩形**无交集 ⇒ 整棵子树直接 return**(这是框架里唯一真正省时间的机制, + 禁止任何派生类退化成"父脏 ⇒ 子树全脏"); + ③ 有效裁剪 = 祖先裁剪切 ∩ 自身矩形 ∩ 脏区;**GDI 绘制不受该裁剪约束** ⇒ + 派生类必须把 `ctx.clip` 传给 `UiTextClipped` 一类带 `ETO_CLIPPED` 的辅助函数。 +238. ⚠️ **`UiLayoutCache` 用 `std::deque` 保证"`Store` 之后已有引用仍有效"**; + 淘汰按"最久未被命中";`Invalidate` 必须是**定向**失效(与 `m_edgeScans` 同一纪律), + **禁止**为了让命中率好看而整体 `Clear()`。 +239. ⚠️⚠️ **`PlotView` 实现分片已按"关注点"拆分(七个文件)**,不再按函数名前缀: + `Paint 4.9k / Interaction 3.4k / Features 3.3k / Raster 1.9k / Panels 1.4k / Core 0.9k / CacheKey 0.5k` + (`PlotViewPaint.cpp` 由 9 080 行降到 4 913 行)。硬约束: + * `PlotView::Paint` / `PaintLane` **只允许**定义在 `PlotViewPaint.cpp`; + * 像素缓存与每帧预算在 `PlotViewRaster.cpp`;内容指纹/缓存键在 `PlotViewCacheKey.cpp`; + 面板布局与自由标记几何/探测在 `PlotViewPanels.cpp`; + * 机械搬移的判据是**逐像素 A/B 哈希一致**(不是"能编译")。 + ⚠️ 搬移后若用旧文件回退做 A/B,**必须先删掉 `Build//` 里对应的陈旧 `.o`**, + 否则链接会因重复符号失败。 +240. ⚠️ **回归网已入库**:`Tools/uicheck`(`build.ps1` 链接 `Build//*.o`,排除 `Main.o`)。 + 改 UI 基础层或迁移组件前先跑它(当前 62 项断言);A/B 校验时把它当"判别力证据"。 + ### 11.3 离屏验证方法学(本机偏好,强烈建议沿用) - **不要占用用户的鼠标 / 键盘 / 屏幕**:优先 `.preflight/uitest`(离屏 harness:`SW_HIDE` 窗口 + 直接注入消息 + `BitBlt` 从 `m_backDc` 截图)或 `.preflight/perf`(离屏计时 / 逐像素对比)。 -- 2.54.0 From 25264f907436341d4f2704cfef8b6bf6cc6a472f Mon Sep 17 00:00:00 2001 From: "DeepSeek V4.1 Flash" <> Date: Wed, 28 Oct 2026 12:29:08 +0800 Subject: [PATCH 06/21] =?UTF-8?q?UI=20=E9=87=8D=E6=9E=84=20P3=EF=BC=9ATool?= =?UTF-8?q?bar=20=E8=BF=81=E7=A7=BB=E5=88=B0=E6=8E=A7=E4=BB=B6=E6=A0=91?= =?UTF-8?q?=EF=BC=88=E6=96=B0=E5=A2=9E=20UiButtonWidget=EF=BC=8C=E9=80=90?= =?UTF-8?q?=E5=83=8F=E7=B4=A0=200=20=E5=B7=AE=E5=BC=82=20+=20=E5=8A=9F?= =?UTF-8?q?=E8=83=BD=E6=96=AD=E8=A8=80=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 Components/Shared/{Inc,Src}/UiButtonWidget.*:通用「文字按钮」控件,视觉规则与旧 Toolbar 按钮逐像素一致(主按钮强调色实底 / 普通按钮 1px 描边 / 可切换选中态 / 禁用态配色); 交互由控件自己处理:按下即 CaptureMouse 进入按下态,在自身范围内松开才触发 onClick, 指针离开(不会再收到抬起)时 CancelPress。 Toolbar 内部重写,公开 API 一字未改: - Button 只保留静态配置 + 控件指针;矩形与外观状态归控件。 - 删除 m_hoverIndex / m_pressedIndex 与死字段 Button::hover,改由 UiWidget 的悬停链与 capture 维护。 - Paint 改为「根控件(底色 + 底部分隔线)+ 按钮子节点」,一次 root->Paint(ctx)。 - OnMouseMove/Down/Up/Leave 统一翻译成 UiEvent 后 root->Dispatch;动作经 onClick 回调登记、抬起后执行。 框架约定调整:UiWidget::Paint 的裁剪从「严格等于自身矩形」改为「自身矩形外扩 1px(kUiClipBleed)」。 边框/描边以矩形边界为中心绘制,严格裁剪会切掉外半侧使控件变细、与旧绘制不再像素一致。 uicheck 对应断言改为「裁剪 ⊇ 自身矩形且不超出外扩范围」。 验证: - make release / make debug 零警告;Target.exe 881,664 B(< 900KB)。 - 逐像素 A/B(8 场景:全览 / 深放大+自由标记+滤波+弱峰+光标 / 平移 / 悬停按钮 / 按下 / 松开 / 悬停主按钮 / 移出工具栏):迁移前后哈希逐一相同 A 2166933039626F66 B C444ECFEB18FD73D C 7984446D8E7A4418 D BF855879CA12F289 E 1C663E4118616016 F 0535292D8E07AE18 G 7A58172C46577729 H 23F390DD76141113。 - 功能断言:locate-cursor-button PASS (1291,73);click-cursor-changes-mode PASS (1 -> 2); release-outside-does-not-fire PASS (2 -> 2)。 - Tools/uicheck 62 passed / 0 failed。 --- Components/Shared/Inc/UiButtonWidget.hpp | 84 +++++ Components/Shared/Inc/UiLayout.hpp | 3 + Components/Shared/Inc/UiWidget.hpp | 12 + Components/Shared/Src/UiButtonWidget.cpp | 231 ++++++++++++ Components/Shared/Src/UiLayout.cpp | 10 + Components/Shared/Src/UiWidget.cpp | 17 +- Components/Toolbar/Inc/Toolbar.hpp | 28 +- Components/Toolbar/Src/Toolbar.cpp | 443 +++++++++++++---------- Documents/UI_Refactor_Progress.md | 58 ++- 9 files changed, 663 insertions(+), 223 deletions(-) create mode 100644 Components/Shared/Inc/UiButtonWidget.hpp create mode 100644 Components/Shared/Src/UiButtonWidget.cpp diff --git a/Components/Shared/Inc/UiButtonWidget.hpp b/Components/Shared/Inc/UiButtonWidget.hpp new file mode 100644 index 0000000..c2d8be4 --- /dev/null +++ b/Components/Shared/Inc/UiButtonWidget.hpp @@ -0,0 +1,84 @@ +#ifndef __UI_BUTTON_WIDGET_HPP__ +#define __UI_BUTTON_WIDGET_HPP__ + +#include +#include + +#include "UiWidget.hpp" + +namespace jdi +{ + + /** + * @brief 一个"文字按钮"控件(工具栏按钮的原语)。 + * + * 视觉规则与旧 `Toolbar` 的按钮**逐像素一致**: + * * 主按钮(`primary`)= 强调色实底 + `textOnAccent` 文字,按下 / 悬停时整体提亮或压暗; + * * 普通按钮 = 透明底 + 1px 描边,可切换按钮选中时用 `accentSoft` 底 + `accent` 描边; + * * 禁用时底色回退为工具栏底色、文字用 `textMuted`、描边与底色混 50%。 + * + * 交互:按下即 `CaptureMouse()` 并进入按下态;**在自身范围内**松开才触发 `onClick` + *(与"按住移开再松开 = 放弃"的既有语义一致)。 + */ + class UiButtonWidget : public UiWidget + { + public: + UiButtonWidget() = default; + + /** @brief 一次性注入绘制资源与外观参数(尺寸由 `OnMeasure` 按文本实测得出)。 */ + void Configure(const UiFonts* fonts, const Theme* theme, float scale, std::wstring label, + bool primary, bool toggle, float textPadding, float height); + + /** @brief 修改文本(宽度会在下一次 `InvalidateMeasure` 后重新实测)。 */ + void SetLabel(std::wstring label); + /** @brief 文本(只读)。 */ + const std::wstring& Label() const { return m_label; } + + /** @brief 设置可切换按钮的选中态。 */ + void SetChecked(bool checked); + /** @brief 选中态。 */ + bool Checked() const { return m_checked; } + /** @brief 是否可切换按钮。 */ + bool IsToggle() const { return m_toggle; } + + /** @brief 设置可用性(禁用时不可点、不可命中)。 */ + void SetEnabled(bool enabled); + /** @brief 是否可用。 */ + bool Enabled() const { return m_enabled; } + /** @brief 是否处于按下态(按下未松开)。 */ + bool Pressed() const { return m_pressed; } + + /** @brief 放弃当前按下(指针离开窗口等不会再收到抬起的情形)。 */ + void CancelPress(); + + /** @brief 点击回调(在自身范围内松开时触发)。 */ + std::function onClick; + + /** @brief 工具栏底色(普通按钮的底色与禁用配色都以它为基准)。 */ + void SetCanvasColor(std::uint32_t color) { m_canvasColor = color; } + + protected: + gd::SizeF OnMeasure(const UiMeasureContext& ctx) override; + void OnPaint(UiPaintContext& ctx) override; + UiEventResult OnEvent(UiEvent& event) override; + HCURSOR OnQueryCursor(int x, int y) override; + std::uint64_t ContentKey() const override; + + private: + const UiFonts* m_fonts = nullptr; /**< 字体。 */ + const Theme* m_theme = nullptr; /**< 主题。 */ + float m_scale = 1.0f; /**< 缩放系数。 */ + std::wstring m_label; /**< 按钮文本。 */ + bool m_primary = false; /**< 主按钮(强调色实底)。 */ + bool m_toggle = false; /**< 是否可切换。 */ + bool m_checked = false; /**< 可切换按钮的选中态。 */ + bool m_enabled = true; /**< 是否可用。 */ + bool m_pressed = false; /**< 按下态。 */ + float m_textPadding = 14.0f; /**< 左右内边距(逻辑像素)。 */ + float m_height = 30.0f; /**< 按钮高度(逻辑像素)。 */ + std::uint32_t m_canvasColor = 0; /**< 工具栏底色(配色基准)。 */ + }; + +} // namespace jdi + +#endif // __UI_BUTTON_WIDGET_HPP__ diff --git a/Components/Shared/Inc/UiLayout.hpp b/Components/Shared/Inc/UiLayout.hpp index 6113825..a7b9ccd 100644 --- a/Components/Shared/Inc/UiLayout.hpp +++ b/Components/Shared/Inc/UiLayout.hpp @@ -75,6 +75,9 @@ namespace jdi /** @brief 内缩(结果可能为空矩形)。 */ gd::RectF UiRectDeflate(const gd::RectF& rect, const UiInsets& insets); + /** @brief 四边外扩(用于“描边允许超出自身矩形”的裁剪补偿)。 */ + gd::RectF UiRectInflate(const gd::RectF& rect, float amount); + /** * @brief 把矩形对齐到**设备像素整数边界**(上界 `floor`、下界 `ceil`)。 * diff --git a/Components/Shared/Inc/UiWidget.hpp b/Components/Shared/Inc/UiWidget.hpp index 108db31..6d86eb2 100644 --- a/Components/Shared/Inc/UiWidget.hpp +++ b/Components/Shared/Inc/UiWidget.hpp @@ -18,6 +18,15 @@ namespace jdi struct Theme; struct UiFonts; + /** + * @brief 裁剪时允许超出控件矩形的像素数(逻辑像素 × scale)。 + * + * 边框/描边以矩形边界为中心绘制(1px 笔宽有一半在矩形外),若严格按矩形裁剪 + * 会把外半侧切掉(控件看起来变细)。因此裁剪区固定外扩这么多。 + * ⚠️ 它只用于**限制控件画到哪**,不参与脏区判定(脏区始终是精确矩形)。 + */ + constexpr float kUiClipBleed = 1.0f; + /** * @brief 布局测量上下文(由根节点统一下发,子节点只读)。 */ @@ -233,6 +242,8 @@ namespace jdi /** @brief 当前悬停的控件(根节点维护)。 */ UiWidget* HoverWidget() const; + /** @brief 本控件当前是否被悬停(由根节点的悬停链维护)。 */ + bool Hovered() const { return m_hovered; } /** @brief 请求键盘焦点(根节点记录;键盘/字符事件沿焦点链派发)。 */ void RequestFocus(); @@ -314,6 +325,7 @@ namespace jdi bool m_visible = true; /**< 是否可见。 */ bool m_hitTransparent = false; /**< 是否命中透明。 */ bool m_measureValid = false; /**< 期望尺寸是否有效。 */ + bool m_hovered = false; /**< 是否被悬停(悬停链写入)。 */ // 根节点专用状态。 UiWidget* m_capture = nullptr; /**< 鼠标捕获者。 */ diff --git a/Components/Shared/Src/UiButtonWidget.cpp b/Components/Shared/Src/UiButtonWidget.cpp new file mode 100644 index 0000000..dcc7981 --- /dev/null +++ b/Components/Shared/Src/UiButtonWidget.cpp @@ -0,0 +1,231 @@ +#include "UiButtonWidget.hpp" + +#include +#include + +#include "PaintUtil.hpp" +#include "Theme.hpp" + +namespace jdi +{ + + namespace + { + const float kButtonRadius = 5.0f; /**< 按钮圆角半径(逻辑像素)。 */ + + /** @brief 按比例把一个颜色向目标色混合(与旧 Toolbar 的写法保持一致)。 */ + std::uint32_t BlendToward(std::uint32_t base, std::uint32_t target, double ratio) + { + return MixColor(base, target, ratio); + } + } + + void UiButtonWidget::Configure(const UiFonts* fonts, const Theme* theme, float scale, + std::wstring label, bool primary, bool toggle, float textPadding, + float height) + { + m_fonts = fonts; + m_theme = theme; + m_scale = (scale > 0.1f) ? scale : 1.0f; + m_label = std::move(label); + m_primary = primary; + m_toggle = toggle; + m_textPadding = textPadding; + m_height = height; + InvalidateMeasure(); + Invalidate(Bounds()); + } + + void UiButtonWidget::SetLabel(std::wstring label) + { + if (m_label == label) + { + return; + } + m_label = std::move(label); + InvalidateMeasure(); + Invalidate(Bounds()); + } + + void UiButtonWidget::SetChecked(bool checked) + { + if (m_checked == checked) + { + return; + } + m_checked = checked; + Invalidate(Bounds()); + } + + void UiButtonWidget::SetEnabled(bool enabled) + { + if (m_enabled == enabled) + { + return; + } + m_enabled = enabled; + Invalidate(Bounds()); + } + + void UiButtonWidget::CancelPress() + { + if (!m_pressed) + { + return; + } + m_pressed = false; + ReleaseMouse(); + Invalidate(Bounds()); + } + + gd::SizeF UiButtonWidget::OnMeasure(const UiMeasureContext& ctx) + { + const float scale = (ctx.scale > 0.1f) ? ctx.scale : m_scale; + const float width = (m_fonts != nullptr && m_fonts->ui != nullptr) + ? (m_textPadding * 2.0f * scale + UiMeasureTextWidth(m_fonts->ui, m_label)) + : 0.0f; + return gd::SizeF(width, m_height * scale); + } + + void UiButtonWidget::OnPaint(UiPaintContext& ctx) + { + if (m_theme == nullptr || m_fonts == nullptr || ctx.g == nullptr) + { + return; + } + const gd::RectF& rect = Bounds(); + const bool hovered = Hovered(); + const bool enabled = m_enabled; + std::uint32_t background = 0; + std::uint32_t border = 0; + std::uint32_t textColor = 0; + + if (m_primary) + { + background = enabled ? m_theme->accent + : BlendToward(m_theme->accent, m_canvasColor, 0.6); + if (m_pressed) + { + background = BlendToward(m_theme->accent, 0xFF000000u, 0.12); + } + else if (enabled && hovered) + { + background = BlendToward(m_theme->accent, 0xFFFFFFFFu, 0.12); + } + border = background; + textColor = m_theme->textOnAccent; + } + else + { + background = (m_toggle && m_checked) ? m_theme->accentSoft : m_canvasColor; + if (m_pressed) + { + background = m_theme->pressed; + } + else if (enabled && hovered) + { + background = (m_toggle && m_checked) + ? BlendToward(m_theme->accentSoft, m_theme->accent, 0.16) + : m_theme->hover; + } + if (!enabled) + { + background = m_canvasColor; + } + + if (m_toggle && m_checked && enabled) + { + border = m_theme->accent; + } + else + { + border = BlendToward(m_theme->border, m_canvasColor, enabled ? 0.0 : 0.5); + } + textColor = enabled ? ((m_toggle && m_checked) ? m_theme->textStrong : m_theme->text) + : m_theme->textMuted; + } + + if (background != m_canvasColor) + { + UiFillRoundRect(*ctx.g, rect, kButtonRadius * m_scale, background); + } + if (!m_primary) + { + UiDrawRoundRect(*ctx.g, rect, kButtonRadius * m_scale, border, 1.0f); + } + + // ⚠️ 与旧实现一致:文字走 `UiText`(GDI,不受 GDI+ 裁剪约束)。 + // 按钮矩形本身就在裁剪区内(外扩 1px 只影响描边),因此不需要 `UiTextClipped`。 + UiText(*ctx.g, m_label, m_fonts->ui, rect, textColor, TextAlign::CENTER); + } + + UiEventResult UiButtonWidget::OnEvent(UiEvent& event) + { + switch (event.kind) + { + case UiEventKind::MOUSE_DOWN: + if (event.button != UiMouseButton::LEFT || !m_enabled) + { + return UiEventResult(); + } + m_pressed = true; + CaptureMouse(); + { + UiEventResult result = UiEventResult::Handled(); + result.dirty = Bounds(); + return result; + } + case UiEventKind::MOUSE_UP: + if (event.button != UiMouseButton::LEFT || !m_pressed) + { + return UiEventResult(); + } + // 只有"在自身范围内松开"才算点击(按住移开再松开 = 放弃)。 + m_pressed = false; + ReleaseMouse(); + { + UiEventResult result = UiEventResult::Handled(); + result.dirty = Bounds(); + if (Contains(event.x, event.y) && m_enabled && onClick) + { + onClick(); + } + return result; + } + case UiEventKind::MOUSE_LEAVE: + if (m_pressed) + { + m_pressed = false; + ReleaseMouse(); + UiEventResult result = UiEventResult::Handled(); + result.dirty = Bounds(); + return result; + } + return UiEventResult(); + default: + return UiEventResult(); + } + } + + HCURSOR UiButtonWidget::OnQueryCursor(int x, int y) + { + (void)x; + (void)y; + // 与旧实现一致:工具栏按钮不改变光标形态(箭头)。 + return nullptr; + } + + std::uint64_t UiButtonWidget::ContentKey() const + { + std::uint64_t key = BaseContentKey(); + key = UiMixKey(key, static_cast(m_label.size())); + key = UiMixKey(key, m_primary ? 1u : 0u); + key = UiMixKey(key, m_toggle ? 1u : 0u); + key = UiMixKey(key, m_checked ? 1u : 0u); + key = UiMixKey(key, m_enabled ? 1u : 0u); + key = UiMixKey(key, m_pressed ? 1u : 0u); + key = UiMixKey(key, Hovered() ? 1u : 0u); + return key; + } + +} // namespace jdi diff --git a/Components/Shared/Src/UiLayout.cpp b/Components/Shared/Src/UiLayout.cpp index e8f38c1..081fed4 100644 --- a/Components/Shared/Src/UiLayout.cpp +++ b/Components/Shared/Src/UiLayout.cpp @@ -89,6 +89,16 @@ namespace jdi return gd::RectF(rect.X + insets.left, rect.Y + insets.top, width, height); } + gd::RectF UiRectInflate(const gd::RectF& rect, float amount) + { + if (!UiRectHasArea(rect) || amount <= 0.0f) + { + return rect; + } + return gd::RectF(rect.X - amount, rect.Y - amount, rect.Width + amount * 2.0f, + rect.Height + amount * 2.0f); + } + gd::RectF UiRectSnapInward(const gd::RectF& rect, float scale) { const float safeScale = (scale > 0.0f) ? scale : 1.0f; diff --git a/Components/Shared/Src/UiWidget.cpp b/Components/Shared/Src/UiWidget.cpp index 24942e4..e2afcb7 100644 --- a/Components/Shared/Src/UiWidget.cpp +++ b/Components/Shared/Src/UiWidget.cpp @@ -195,10 +195,13 @@ namespace jdi return; } - // (2) 裁剪 = 祖先裁剪切 ∩ 自身矩形;脏区非空时再与脏区求交(只更新需要更新的部分)。 + // (2) 裁剪 = 祖先裁剪切 ∩ (自身矩形外扩 1px);脏区非空时再与脏区求交。 + // ⚠️ 外扩 1px 是刻意设计的:边框/描边是**以矩形边界为中心**画的(`UiDrawRoundRect` + // 的 1px 笔宽有一半在矩形外),严格按矩形裁剪会把外半侧切掉 ⇒ 控件看起来变细。 + // 这是与旧绘制代码保持“逐像素一致”的必要保证。 UiPaintContext local = ctx; gd::RectF clip; - if (!UiRectIntersect(ctx.clip, m_bounds, clip)) + if (!UiRectIntersect(ctx.clip, UiRectInflate(m_bounds, kUiClipBleed), clip)) { return; // 完全被祖先裁掉 } @@ -230,7 +233,13 @@ namespace jdi { continue; } - UiPaintContext childContext = local.WithClip(child->m_bounds); + UiPaintContext childContext = local; + gd::RectF childClip; + if (!UiRectIntersect(local.clip, UiRectInflate(child->m_bounds, kUiClipBleed), childClip)) + { + continue; + } + childContext.clip = childClip; childContext.dirty = local.dirty; child->Paint(childContext); } @@ -465,11 +474,13 @@ namespace jdi m_hover = target; if (previous != nullptr) { + previous->m_hovered = false; previous->OnHoverChanged(false); previous->Invalidate(previous->m_bounds); } if (target != nullptr) { + target->m_hovered = true; target->OnHoverChanged(true); target->Invalidate(target->m_bounds); } diff --git a/Components/Toolbar/Inc/Toolbar.hpp b/Components/Toolbar/Inc/Toolbar.hpp index 855d07c..649330c 100644 --- a/Components/Toolbar/Inc/Toolbar.hpp +++ b/Components/Toolbar/Inc/Toolbar.hpp @@ -5,11 +5,15 @@ #include #include +#include #include +#include #include #include "PaintUtil.hpp" #include "Theme.hpp" +#include "UiButtonWidget.hpp" +#include "UiWidget.hpp" namespace jdi { @@ -37,6 +41,7 @@ namespace jdi { public: Toolbar(); + ~Toolbar(); /** * @brief 绑定字体与主题资源。 @@ -135,19 +140,17 @@ namespace jdi private: /** - * @brief 单个工具栏按钮的运行时状态。 + * @brief 单个工具栏按钮的**静态配置**(矩形与外观状态由控件自己维护)。 */ struct Button { ToolbarAction action = ToolbarAction::NONE; /**< 按钮动作。 */ - std::wstring label; /**< 按钮文本。 */ - gd::RectF rect; /**< 按钮矩形。 */ - float width = 0.0f; /**< 按钮宽度。 */ + UiButtonWidget* widget = nullptr; /**< 对应控件(由 `m_root` 拥有)。 */ bool primary = false; /**< 是否为主按钮(强调色填充)。 */ - bool needsDocument = true; /**< 是否依赖已加载数据。 */ bool toggle = false; /**< 是否为可切换按钮。 */ + bool needsDocument = true; /**< 是否依赖已加载数据。 */ + bool toggle = false; /**< 是否为可切换按钮。 */ bool checked = false; /**< 可切换按钮的选中状态。 */ - bool hover = false; /**< 悬停状态(当前由 `Toolbar::m_hoverIndex` 派生,字段保留供未来使用)。 */ - bool pressed = false; /**< 按下状态。 */ + float width = 0.0f; /**< 按钮宽度(由控件实测得出)。 */ }; /** @@ -157,15 +160,20 @@ namespace jdi */ bool IsEnabled(const Button& button) const; + /** @brief 按固定规格表重建控件树(文本变化/首次布局时调用)。 */ + void RebuildWidgets(); + /** @brief 把可用性同步到控件(未加载数据时禁用依赖数据的按钮)。 */ + void SyncEnabledState(); + const UiFonts* m_fonts = nullptr; /**< 字体资源。 */ const Theme* m_theme = nullptr; /**< 主题资源。 */ float m_scale = 1.0f; /**< DPI 缩放比例。 */ bool m_documentLoaded = false; /**< 是否已加载数据。 */ - std::vector