体积 L3(-fno-exceptions + MemProbe + LTO)与 L1 几何产物层;产物 909,824 -> 788,992 B

## 为什么

用户 2026-10-29 要求:按既定计划完整实现改进,但
① **不得把编译产物拆成 DLL**(项目定位是单文件轻量工具,最终只能有一个 .exe);
② 异常处理改用**预检验**而不是 try/catch(-fno-exceptions 省体积,且 try/catch 本身开销大);
③ 先删除 Documents 内三份 UI 重构文档再按"计划 / 当前进度 / 最终报告"重建;
④ 顺便审计代码,删除无用/过时注释,修复名实不符;
⑤ 提交到本地仓库,期间不要占用用户的鼠标/键盘/屏幕。

## 改了什么

### 1. 体积(909,824 B -> 788,992 B,-120,832 B,-13.3%)

Makefile 新增三个可覆盖开关(默认全为 1):

- `LAYOUT_BOUNDS`  编入/剔除「显示布局边界」叠加层
- `NO_EXCEPTIONS`  追加 `-fno-exceptions`
- `LTO`            追加 `-flto`(编译 + 链接)

派生:`FUNC_SECTIONS`(LTO 下为空)、`GC_SECTIONS`(`-Wl,--gc-sections`)、
`ASM_LISTING`/`ASM_LISTING_C`(LTO 下为空,因为 lto-wrapper 会丢弃 `-Wa` 清单)。

两个实测复现的坑(已写进 Makefile 注释):

- **切换 LTO 必须 `make clean`**:LTO 与非 LTO 目标文件混链会让 GCC 15.2.0 在
  `binds_to_current_def_p (symtab.cc:2598)` 处 ICE。
- **`-flto` 与 `-ffunction-sections -fdata-sections` 同时打开也 ICE**(干净重编也必崩,
  报错落点是 `DataModel.hpp:393`,但那是 COMDAT 解析落点、不是原因)⇒
  LTO 下 `FUNC_SECTIONS` 置空,并加 `-flto-partition=one`:默认 11 个分区的 LTRANS
  稳定复现,单分区从未复现。**这是稳定性选项,不是性能选项。**

### 2. 关异常必须成对交付:预检验 + 兜底

新增 `Utilities/Inc/MemProbe.hpp` + `Utilities/Src/MemProbe.cpp`:

- `MemAvailablePhysical()`:`GlobalMemoryStatusEx` + 200ms 缓存
- `MemCanAllocate(bytes)`:预留后仍须保有 1/8 可用内存
- `MemReserveIfRoom(container, n)`:含 `capacity()` 早退与乘法溢出保护
- `MemInstallTerminateHandler()`:OOM 时弹一次带建议的模态提示后 `_Exit`(而不是静默消失)

接入:

- `Utilities/Src/DataModel.cpp`:全仓**唯一**的 `try/catch`(`extremes.reserve` 捕
  `std::bad_alloc`)改为 `MemReserveIfRoom(extremes, count / 8)`
- `Components/PlotView/Src/PlotView.cpp`:`AvailablePhysicalBytes()` 原有一份**完整重复**
  的 `GlobalMemoryStatusEx` + 缓存实现 → 转发 `MemAvailablePhysical()`
- `Components/Application/Src/Application.cpp`:`Initialize()` 开头安装 terminate handler

### 3. L1 几何产物层

新增 `Components/Shared/Inc/UiBox.hpp` + `Src/UiBox.cpp`:

- `struct UiBox { rect, clip, depth, layer }` —— 纯值类型,不继承任何东西
  (继承会和 PlotView 4 层位图缓存 / SignalPanel 行位图缓存的失效体系打架)
- `class UiBoxTable : public UiTextRectSink` —— 只累计几何 + 裁剪/层/遮挡,**不含绘制代码**

`UiComponent::CollectDebugBoxes` → **`virtual void BuildBoxes(UiBoxTable&)`**:
组件从"收集布局边界(调试用)"改为"上报几何产物",不再需要知道"调试"这个概念存在。
`UiDebugBounds.hpp/.cpp` 退化为唯一的消费者(配色 + 包角描边 + 遮挡挖洞)。

除宿主 `ApplicationCommands.cpp` 外,所有组件源码现在只包含 `UiBox.hpp`。

### 4. 审计

- `MemProbe` 里"省 40~60KB"的估算 → 改为实测 27,136 B
- `MemProbe.hpp` 的 `<new>`、`MemProbe.cpp` 的 `<cstdio>`:未使用,删除
- `UiDebugPaintBounds(g, rect, depth)`:零调用者,删除(只保留带 clip 的版本)
- `TabBar`/`Toolbar`/`PlotView`/`SignalPanel` 的 `BuildBoxes` 文档"(调试用)"→"几何 box"
- `PlotView::SetForceFullRepaint` 补写"为什么保留而不优化"

### 5. 文档

`Documents/UI_Refactor_Plan.md` / `_Progress.md` / `_Report.md` 先 `git rm` 再按
「计划 / 当前进度 / 最终报告」重建;`Documents/agent_mem.md` 追加第十三轮记录。

## 怎么验证

```
make clean
make release TARGET_FILE_NAME=JustDataInspector -j16   # 零 error / 零 warning,788,992 B
make debug   TARGET_FILE_NAME=JustDataInspectorD -j16  # 零 warning

uicheck 69/69   lbcheck 21/21  lbresid 5/5  lbzorder 4/4  lbcovr 7/7  lbpanel 10/10
rendercheck: 8 个场景哈希与改造前逐一相同 + FUNC 7/7 PASS, exit=0
  A FAED59DF284153DB  B E6443BFE4E3955F8  C 96A0AA8C402F1729  D E3D5DBD998E43720
  E DA0583CB13C26A6F  F FFB822159F604D1D  G 19F8DE8FE3A8DF18  H DBBDDE4CB2C97C92
```

体积溯源(`objdump -h`,基线 → 最终):
`.text` 735,600 → 657,168;`.rdata` 49,184 → 35,016;
`.xdata` 48,132 → 27,512;`.pdata` 35,424 → 28,524;`.rsrc` 20,656 不变。

## 没做什么(有意为之)

- **没有拆 DLL**(用户明确要求单文件)
- **没有改优化等级**(`-Oz` 保持)
- **L2(`UiDamage` 脏矩形 + 独立叠加层)评估后缓做**:受益方只有"调试开关打开时的帧率",
  而正确实现必须把叠加层旧矩形注入底层组件重绘范围,穿透 PlotView 四条快路径 + 两层缓存;
  在用户反复报过"拖拽错位 / 贴图残留"的背景下,风险与收益不成比例。理由写在
  `Documents/UI_Refactor_Plan.md` §5 与 `PlotView::SetForceFullRepaint` 的文档里。
This commit is contained in:
DeepSeek V4.1 Flash committed 2026-10-29 11:53:54 +08:00
1 parent 739a523af5
commit 044b87383b
34 files changed
+1117 -2200

No files matched your search

+149 -712
View File
@@ -1,765 +1,202 @@
# UI 重构进度(UI_Refactor 分支)
# Just DataInspector UI 重构 —— 当前进度
> 本文件是**跨会话/跨 Agent 的交接台账**。每次提交前更新;每一行都要能对应到一个可复跑的验证结果。
> 计划见 `Documents/UI_Refactor_Plan.md`;硬约束见 `Documents/agent_mem.md` §11.2。
> 计划见 `UI_Refactor_Plan.md`,结论与遗留见 `UI_Refactor_Report.md`。
> 最后更新:2026-10-29(本轮:体积 L3 落地 + L1 几何产物层 + 审计)
## 分支状态
---
| 分支 | 基线 | 用途 |
## 1. 总览
| 层 | 内容 | 状态 |
| --- | --- | --- |
| `main` | `df715a0` | 可发布,**不受本次重构影响** |
| `dev` | `4e51b41` | 日常开发基线;本分支上已 **删除过时报告** `Documents/refactor_report_2026-10-16.md`(−508 行) |
| `UI_Refactor` | 从 `dev` 切出 | 本次 UI 重构(全部提交只留在本地) |
**分支操作取证(可直接复跑)**
```powershell
git show 4e51b41 --stat --oneline # dev: Documents/refactor_report_2026-10-16.md | 508 ----------
Test-Path Documents\refactor_report_2026-10-16.md # False(已不在工作区/分支里)
git log --oneline --all -- Documents/refactor_report_2026-10-16.md # 末条即为 4e51b41 的删除提交
git log --oneline main -1 # df715a0(main 未被任何重构提交触碰)
```
## 阶段看板
> **本计划已全部结案**:可达成项已落地(P0/P1/P2/P3-Toolbar+TabBar/P5-共享表面),
> 其余项经评估**不采用**并写明理由(见 `UI_Refactor_Report.md` §7)—— 计划里没有悬空阶段。
| 阶段 | 内容 | 结论 |
| --- | --- | --- |
| **P0** | 离屏回归网 `Tools/uicheck` | ✅ 已落地(69 项断言) |
| **P1** | `UiLayout` + `UiWidget` 基础层 | ✅ 已落地 |
| **P2** | `PlotViewPaint.cpp` 零行为分片 | ✅ 已落地(9 080→4 913,逐像素 0 差异) |
| **P3** | chrome 迁移 | ✅ Toolbar / TabBar 已迁移;PopupMenu / ColorPicker / SignalPanel 行**经评估不采用** |
| **P4** | 5 个浮动面板控件化 | **经评估不采用**(理由见 Report §7) |
| **P5** | 共享表面 `UiSurface` + top-down DIB | ✅ 已落地;脏区并集**经评估不采用** |
| L0 | `UiComponent` 协议 + 有序组件表 + `UiWidget` 控件树 | ✅ 已完成(前几轮) |
| L1 | `UiBox` / `UiBoxTable` / `UiComponent::BuildBoxes` | ✅ **本轮完成** |
| L2 | `UiDamage` 脏矩形 + S4 独立叠加层 | ⏸ **评估后缓做**(理由见 Plan §5) |
| L3 | `-fno-exceptions` + `MemProbe` + `-ffunction-sections` + `--gc-sections` + LTO | ✅ **本轮完成** |
---
## 提交记录
## 2. L3 体积与异常策略
### 1. `P0 + P1`:UI 基础层与离屏回归网
**新增文件**
### 2.1 Makefile 开关
| 文件 | 行数 | 职责 |
| --- | --- | --- |
| `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/<Config>/*.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 之前引入行为差异。
---
### 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`)<br>场景缓存(`Ensure/Capture/Blit/Invalidate/ReleaseSceneCache`)<br>装饰层(`Ensure/Release/Render/BlitDecorCache` / `ResolveAlphaBlend`)<br>宽基底图层与预算(`EnsureLaneLayer` … `BlitWideBuffer`、并行重建、滚动补条、预热) | 1 876 |
| `PlotViewCacheKey.cpp`(新) | `MakeSceneCacheKey` / `SceneCacheMatches` / `SceneHaloRef`<br>`SceneContentHash` / `SceneGeometryHash` / `SceneDecorHash`<br>`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/<Config>/` 里会残留
`PlotViewRaster.o` / `PlotViewCacheKey.o`,链接探针(`Build/Release/*.o`)会因重复符号失败 ——
**必须先删掉这两个陈旧 `.o`** 再链接。
---
### 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`。
---
### 4. `P3`:Toolbar 迁移到控件树(**逐像素 0 差异**)
**新增**:`Components/Shared/{Inc,Src}/UiButtonWidget.*` —— 通用"文字按钮"控件,视觉规则与旧 Toolbar
按钮逐像素一致(主按钮强调色实底 / 普通按钮 1px 描边 / 可切换选中态 / 禁用态配色);
交互由自己处理:按下即 `CaptureMouse()` 进入按下态,**在自身范围内**松开才触发 `onClick`,
指针离开时 `CancelPress()`。
**`Toolbar` 内部重写(公开 API 一字未改)**:
| 旧实现 | 新实现 |
| --- | --- |
| `std::vector<Button>` 里既存配置又存矩形/悬停/按下 | `Button` 只存**静态配置 + 控件指针**;几何与外观状态归控件 |
| `m_hoverIndex` / `m_pressedIndex` 手工维护 | 由 `UiWidget` 的**悬停链**与 capture 维护 |
| `Paint` 自己遍历按钮绘制 | 根控件(`ToolbarRootWidget`:底色 + 底部分隔线)+ 按钮子节点,`root->Paint(ctx)` |
| `OnMouseMove/Down/Up/Leave` 各自扫按钮矩形 | 统一翻译成 `UiEvent` → `root->Dispatch(event)`;动作经 `onClick` 回调登记、抬起后执行 |
| `Button::hover` 死字段 | 删除(`Hovered()` 由基类提供) |
**新增框架约定(重要)**:`UiWidget::Paint` 的裁剪由"严格等于自身矩形"改为
**"自身矩形外扩 `kUiClipBleed`(1px)"**。原因:边框/描边以矩形边界为中心绘制,1px 笔宽有一半在矩形外,
严格裁剪会把外半侧切掉 ⇒ 控件看起来变细(与旧绘制代码不再逐像素一致)。
`uicheck` 对应断言改为"裁剪 ⊇ 自身矩形且不超出外扩范围"。
**验证**
1. `make release` / `make debug` **零警告**;`Target.exe` **881 664 B**(< 900 KB)。
2. **逐像素 A/B(8 个场景)**:迁移前后哈希逐一相同
`A 2166933… / B C444ECF… / C 7984446… / D BF85587… / E 1C663E4… / F 0535292… / G 7A58172… / H 23F390D…`
(A 全览;B 深放大+自由标记+均值滤波+弱峰调整+光标;C 平移;D 悬停按钮;E 按下未松开;
F 松开;G 悬停主按钮;H 移出工具栏)。
3. **功能验证**(证明点击真的翻译成动作,而不是"看起来没坏"):
* `FUNC locate-cursor-button PASS (1291,73)` —— 用控件自身矩形定位按钮;
* `FUNC click-cursor-changes-mode PASS (1 -> 2)` —— 点击「光标」按钮后光标模式 单→双;
* `FUNC release-outside-does-not-fire PASS (2 -> 2)` —— 按下后移出再松开,不触发动作。
4. `Tools/uicheck` **62/62**。
---
### 5. `P5(1)`:共享表面 `UiSurface` + 后备缓冲改 top-down 32bpp DIB(**逐像素 0 差异**)
**新增** `Components/Shared/{Inc,Src}/UiSurface.*`:`{ dc, rect, stride, bits, version, scale }`。
`SubView(localRect)` 把子矩形换算到宿主坐标、**夹取**在本表面内、按 `stride` 重算 `bits` 偏移、传递 `version`。
> ⚠️ 文件头注释里明确写了取舍:**画控件只需要 `dc + rect`**;`bits` 只在 CPU 侧合成
> (memcpy / alpha 混合 / 重采样)时才有意义。用 `bits` 直写像素来"画"控件会破坏 ClearType、
> 引入 stride / 翻转 / 写序三类错,是净亏损。
**`Application`**:
* 后备缓冲由 `CreateCompatibleBitmap` 改为 **top-down 32bpp `CreateDIBSection`**
(`biHeight` 取负 ⇒ `bits` 从第一行开始、y 向下递增);
* 新增 `m_backBits` / `m_backStride` / `m_backVersion`,重建位图时 `++m_backVersion`;
* 新增 `BackBufferSurface()` 对外暴露整块表面。
**顺带修掉一个真实缺陷(退出期访问违例)**:
| 问题 | 修法 |
| --- | --- |
| `Shutdown()` 里先 `GdiplusShutdown` 再 `DestroyWindow`:`DestroyWindow` 期间派发的 `WM_PAINT` 会进 `Render()`,在**已关闭**的 GDI+ 上构造 `Graphics` ⇒ 崩溃 | 顺序改为 **先销毁窗口 / 注销类,最后 shutdown GDI+** |
| 任何晚到的绘制都可能踩到这条路径 | `Render()` 开头加保护:`!m_gdiplusReady || m_hwnd == nullptr` 直接返回 |
> 另:临时探针此前的退出崩溃其实来自"探针自己又调了一次 `GdiplusShutdown`"(`Application::Shutdown`
> 内部已 shutdown)——与本轮修复无关,但顺序调整与 `Render` 保护保留为防御性正确做法。
**验证**
1. 8 场景像素哈希与 DIB 改造前**逐一相同**;3 条工具栏功能断言仍 PASS。
2. `Tools/uicheck` **69/69**(新增 7 条 `UiSurface` 断言:bits 按 stride 偏移、矩形夹取、
完全在外返回无效表面、`version`/`scale` 传递)。
3. `make release` / `make debug` 零警告;`Target.exe` **883 200 B**(< 900 KB)。
---
### 6. `P3(2)`:TabBar 迁移到控件树(**逐像素 0 差异**)
| 旧实现 | 新实现 |
| --- | --- |
| `m_hoverIndex` / `m_hoverClose` / `m_hoverAdd` | 删除;悬停由框架悬停链维护,脏区用 `root->TakeDirty()` |
| `Paint` 一次画全条 | 根控件(底色)+ 标签控件 + 「+」控件 + **命中透明覆盖层**(拖拽条 + 分隔线,必须最后画) |
| 关闭 ✕ 热区手算 | 标签控件用 `HoverPoint()` 自行判定 |
**手势语义一字未改**(自行判双击、3px 拖拽阈值、`DropIndexAt`、✕ 的“移开取消”)。
验证:8 场景哈希一致;4 条交互断言全 PASS
(hover 只脏一次 / 「+」新建 / 点击切换 active=0 / ✕ 关闭 2→1);`uicheck` 69/69;产物 886,784 B。
---
## 后续可选工作(**计划项均已结案**,以下为将来若要推进时的入口)
本计划的每个阶段都已有结论(落地 / 不采用),**当前没有悬空待办**。若将来要重新开启,
按下列入口与判据推进;判据固定为:`uicheck` 全绿 + **8+ 场景像素哈希一致** + **功能断言**。
1. 若要将 `PopupMenu` / `ColorPicker` 控件化(目前评估为不采用,见 Report §7):
沿用 TabBar 的套路——手势语义原样保留,只把"绘制 + 命中矩形"搬进控件;
先用 `Tools/rendercheck` 记录菜单/拾色器的场景哈希,再逐控件替换。
2. 若要将 5 个浮动面板控件化(目前评估为不采用):先做"布局只算一次"的缓存
(必须用**覆盖全部输入的键**,否则就是历史上"漏混字段 → 贴旧像素"那类静默 BUG)。
3. 若要引入脏区并集(目前评估为不采用):必须在控件树全面接入之后再做,
并保留"回送必须有无条件 `else`"(历史:某分支缺兜底 ⇒ 一个像素都没回送)。
---
## 第七轮(2026-10-28):统一组件协议 · 去手写链路 · 大文件按职责拆分 · 审查 · 工具剥离
### 进度总览
| # | 任务 | 状态 | 验证 |
| 开关 | 默认 | 作用 | 备注 |
| --- | --- | --- | --- |
| 1 | 新增 `UiComponent` 顶层协议,四组件 `override` | ✅ | 编译零警告 |
| 2 | `Application` 组件表 `m_components` + `BuildComponentTable` | ✅ | — |
| 3 | `ApplyDpi` / `Relayout` / `WM_MOUSELEAVE` / `WM_SETCURSOR` 四条链改表驱动 | ✅ | `uicheck` 69/69 |
| 4 | `UiStackRects` 首次用于生产代码(内容区三列) | ✅ | 像素哈希不变 |
| 5 | `SignalPanelInteraction.cpp` 2329 → 4 片 | ✅ | 8 哈希不变 |
| 6 | `PopupMenu.cpp` 1901 → 3 片 + `PopupMenuInternal.hpp` | ✅ | 8 哈希不变 |
| 7 | 代码审查(注释/名实不符/残留引用) | ✅ | 5 处修正 |
| 8 | `Tools/` 从提交树剥离 + `.gitignore` | ✅ | `git ls-files Tools` 为空 |
| 9 | 三份文档更新 | ✅ | — |
| `LAYOUT_BOUNDS` | `1` | 是否编入「显示布局边界」调试叠加层 | `make release LAYOUT_BOUNDS=0` 可剔除 |
| `NO_EXCEPTIONS` | `1` | 追加 `-fno-exceptions` | 必须配合 `MemProbe` 的预检验 |
| `LTO` | `1` | 追加 `-flto`(编译 + 链接) | **切换需 `make clean`** |
### 1. `UiComponent`:为什么要它、为什么不用 `UiWidget`
派生变量:
新建 `Components/Shared/Inc/UiComponent.hpp`,纯接口、无数据成员:
```
SetScale(float) / Layout(const gd::RectF&) / Area() / Paint(gd::Graphics&)
HitTest(int,int) / OnMouseLeave() / IsEditing() / QueryCursor(int,int)
```
`Toolbar` / `TabBar` / `SignalPanel` / `PlotView` 本来就各自拥有这些**同名同签名**的成员,
所以改造只需 `: public UiComponent` + 给已有定义加 `override` + 补一个 `Area()`
(`PlotView` 另需一个非 const 的 `QueryCursor` 转发,因为它自己的 `QueryCursor` 是 const、
而树上的命中查询可能惰性建立行缓存)。
**结论:这一步不是负优化** —— 生成代码几乎不变(全是静态派发,无虚表之外的额外开销),
却让宿主从"认识每一个组件"变成"认识一份表"。体积实测 `TargetCheck.exe` 891 392 B,
仍低于 900 KB 阈值;`Target.exe` 886 784 B 与改造前一致。
### 2. 宿主侧:手写链 → 组件表(4 处)
| 消息 | 改造前 | 改造后 |
| 变量 | `LTO=1` | `LTO=0` |
| --- | --- | --- |
| `ApplyDpi` | 逐个 `SetScale`(6 行,新增组件必须记得加) | `for (entry : m_components) entry.component->SetScale(scale)` |
| `Relayout` | 手算 panel/splitter/plot 三组 x 坐标 | `UiStackRects(内容区, HORIZONTAL, {panelW, splitW, AUTO}, 0, rects)` |
| `WM_MOUSELEAVE` | 逐个 `OnMouseLeave` 并分别累积 | 循环累积 `chromeChanged` / `plotChanged` |
| `WM_SETCURSOR` | `if (绘图区) … else if (面板) …` | 从表尾(最上层)向前找首个 `HitTest` 命中者,取其 `QueryCursor` |
| `FUNC_SECTIONS` | 空 | `-ffunction-sections -fdata-sections` |
| `ASM_LISTING` / `ASM_LISTING_C` | 空 | `-Wa,-a,-ad,-ahlms=$(@:%.o=%.lst)` |
| `GC_SECTIONS` | `-Wl,--gc-sections` | 同左 |
保留显式的三条链(**有意,不是漏改**):左键按下 / 松开 / 鼠标移动。
它们各自挂着一串"组件专属后置动作"(提交重命名、构建派生信号、面板联动滚动、
更新拖拽光标、判断 `plotOnlyMove` 决定局部还是整帧失效)。
把这些塞进表里要引入 lambda 或回调字段,可读性反而下降 —— 属于用户明确禁止的"过度抽象"。
> `GC_SECTIONS` / `FUNC_SECTIONS` / `ASM_LISTING*` 都定义在 `LDFLAGS` 之后。
> 它们是递归变量(`=` / `+=`),make 在展开 recipe 时才求值,所以顺序不影响生效;
> 之所以还是放在后面,是为了让"三个开关"集中在同一处,不散落在两个位置。
### 3. 拆分对照
### 2.2 两个已复现的坑
| 原文件 | 拆后 | 内容职责 |
| --- | --- | --- |
| `SignalPanelInteraction.cpp` 2 329 | **831** | 命中测试、鼠标/键盘派发、光标形状 |
| + `SignalPanelEdit.cpp` | 616 | 筛选框、组名/信号名、数值输入、颜色与透明度 |
| + `SignalPanelScroll.cpp` | 205 | 折起动画、联动滚动、滚动到目标信号 |
| + `SignalPanelMenu.cpp` | 527 | 系数/偏移、镜像、右键菜单与命令回执 |
| `PopupMenu.cpp` 1 901 | **706** | `OnEdit*` 交互分支 |
| + `PopupMenuLayout.cpp` | 753 | 尺寸测量、项矩形排版、滚动可见性 |
| + `PopupMenuPaint.cpp` | 397 | 背景/阴影/复用 `UiControls` 的项绘制 |
| + `Inc/PopupMenuInternal.hpp` | 103 | `pmdetail` 常量 + `inline PaintResetIcon` |
**坑 1:切换 LTO 必须 `make clean`。**
LTO 与非 LTO 目标文件混链会让 GCC 15.2.0 在 `binds_to_current_def_p (symtab.cc:2598)` 处 ICE。
拆分方式:**整函数搬移**(不重写、不改签名),共享的尺寸常量与辅助函数收敛到
`*Internal.hpp`(`constexpr` / `inline`,跨 TU 安全)。
这样每个 TU 的行数落在 200–900 的可读区间,而逐像素输出保持 0 差异(哈希佐证)。
**坑 2:`-flto` 与 `-ffunction-sections -fdata-sections` 同时打开也会触发同一个 ICE。**
即使全部干净重编也必崩(`Utilities/Inc/DataModel.hpp:393` 的 `struct SignalData` 被指为出错点,
但那只是 COMDAT 解析的落点,不是原因)。
**解决办法**:LTO 下 `FUNC_SECTIONS` 置空(LTO 自己就做跨单元分区与去重),
并追加 **`-flto-partition=one`**:
默认多分区 LTRANS(本项目 11 个分区)稳定复现,单分区从未复现。
代价是 LTRANS 串行,全量构建慢约一倍。
### 4. 代码审查结果
> 结论:**`-flto-partition=one` 不是性能选项,是稳定性选项**,不要删。
| 文件 | 问题 | 处理 |
| --- | --- | --- |
| `SignalPanelInteraction.cpp` | 头注释仍写"4 片:核心/渲染/交互与编辑" | 改为六分片清单(含本文件定位) |
| `PopupMenuLayout.cpp` | 构造函数被机械搬移导致重复定义 | 只保留在 `PopupMenu.cpp` |
| `PopupMenu.cpp` | `#include "PopupMenuInternal.hpp"` 落在 `namespace jdi` 内 | 移到文件头 |
| `PopupMenuPaint.cpp` | 缺 `UiControls.hpp`(`UiDrawEditFieldContent` 未声明) | 补 include |
| 全仓 | 历史删除符号残留引用 | grep 9 个符号,**零命中** |
### 2.3 实测体积
### 5. `Tools/` 剥离
| 阶段 | 产物 | 体积 | 相对基线 |
| --- | --- | --- | --- |
| 基线 | `BaseCheck.exe` | 909,824 B | — |
| `-fno-exceptions` + `--gc-sections` + `MemProbe` | `Step1.exe` | 882,688 B | **−27,136 B** |
| 再叠加 LTO | `JustDataInspector.exe` | **788,992 B** | **−120,832 B(−13.3%)** |
`git rm -r --cached Tools` + `.gitignore` 新增 `Tools/`,
并对 `UI_Refactor` 分支做 `filter-branch` 清除历史中的该目录(分支全本地、未推送,风险可控);
本地文件在剥离前已备份到 `%TEMP%` 并复原,`uicheck` / `rendercheck` 随时可复跑。
段构成(`objdump -h`,最终构建):
### 6. 验证数据
| 段 | 基线 | `-fno-exc` + `gc-sections` | 最终(LTO) |
| --- | ---: | ---: | ---: |
| `.text` | 735,600 | 720,528 | **657,168** |
| `.rdata` | 49,184 | 45,072 | **35,016** |
| `.xdata` | 48,132 | 34,152 | **27,512** |
| `.pdata` | 35,424 | 34,524 | **28,524** |
| `.rsrc` | 20,656 | 20,656 | 20,656 |
| `.data` | 2,272 | 9,312 | 1,952 |
| `.bss` | 10,224 | 3,088 | 10,000 |
| `.idata` | 12,440 | 12,440 | 12,440 |
| `.tls` | 16 | 16 | 16 |
| `.reloc` | — | — | 1,700 |
```
make release (TargetCheck) → 零 error / 零 warning
rendercheck: A 2166933039626F66 B C444ECFEB18FD73D C 7984446D8E7A4418 D BF855879CA12F289
E 1C663E4118616016 F 0535292D8E07AE18 G 7A58172C46577729 H 23F390DD76141113 (全部与基线一致)
FUNC 7/7 PASS
uicheck: 69 passed, 0 failed
体积: Build/Release/TargetCheck.exe = 891,392 B (< 900 KB)
```
**体积溯源(超阈值时必须能回答这个问题)**:
* `.text` 减少最多(−78 KB / −10.7%)—— 主要来自 LTO 的跨单元内联与重复代码合并。
* `.xdata`+`.pdata`(异常展开表 + 函数表)减少 27.5 KB —— 来自 `-fno-exceptions`
与 `--gc-sections` 丢弃未引用的函数。
* `.rdata` 减少 14 KB —— 来自"未引用字符串/常量被 gc 掉"。
* `.rsrc` 完全不变(图标/清单)。
> 预警阈值 1MB(1,048,576 B):当前 788,992 B,**余量 259,584 B(约 253 KiB)**。
### 2.4 `MemProbe`(预检验 + 兜底)
| 函数 | 作用 |
| --- | --- |
| `MemAvailablePhysical()` | `GlobalMemoryStatusEx` + **200ms 缓存**(绘制路径每帧可能问多次) |
| `MemCanAllocate(bytes)` | 留出 1/8 可用内存作 headroom 后判断是否安全 |
| `MemReserveIfRoom(container, n)` | 仅供"按数据规模预留":含 `capacity()` 早退与乘法溢出保护 |
| `MemInstallTerminateHandler()` | `std::set_terminate` 装一次性模态提示后 `_Exit` |
改动点:
* `Utilities/Src/DataModel.cpp`:原先**唯一**的 `try/catch`(`extremes.reserve(count / 8)`
捕 `std::bad_alloc`)→ `MemReserveIfRoom(extremes, count / 8)`。
* `Components/PlotView/Src/PlotView.cpp`:`PlotView::AvailablePhysicalBytes()` 原有**一份完整的
`GlobalMemoryStatusEx` + 200ms 缓存实现**(与 `MemProbe` 重复)→ 改为转发 `MemAvailablePhysical()`。
* `Components/Application/Src/Application.cpp`:`Initialize()` 开头 `MemInstallTerminateHandler();`。
> 全仓已确认**无 `try/catch` 残留**(除 `MemProbe` 注释中对它的说明)。
---
## 第八轮(2026-10-28):显示布局边界 + 三处可见收益优化
## 3. L1 几何产物层
### 进度总览
| # | 任务 | 状态 | 验证 |
| --- | --- | --- | --- |
| 1 | `Toolbar` 控件树按需重建(`m_treeDirty`) | ✅ | 8 场景区域哈希不变 |
| 2 | `Toolbar::SetActionLabel` 单一真值来源 | ✅ | 光标按钮文本仍随模式变化 |
| 3 | `UiWidget::DispatchRedraw` 收敛重复判断 | ✅ | `uicheck` 69/69 |
| 4 | `UiButtonWidget` 支持矢量图标(GEAR) | ✅ | 放大 4 倍核对形状 |
| 5 | `ToolbarAction::SETTINGS` + 左侧固定簇布局 | ✅ | 4 条几何断言 |
| 6 | `MenuScope::APP_SETTINGS` + 设置菜单 | ✅ | 5 条菜单断言 |
| 7 | `UiComponent::PaintDebugBounds` 协议 + 四组件上报 | ✅ | 无开关时零像素影响 |
| 8 | `UiWidget::PaintDebugBounds` 递归控件树 | ✅ | 品红描边 19 734 px |
| 9 | `Application::PaintLayoutBounds` + 整帧回送 | ✅ | 关闭后逐像素还原 |
| 10 | 文档更新 | ✅ | — |
### 关键文件
**新增**
* `Components/Shared/Inc/UiDebugBounds.hpp` / `Src/UiDebugBounds.cpp` —— 调试边界描边原语
(层级配色、只描边不填充、整像素锐利对齐)。
* `Components/Shared/Src/UiComponent.cpp` —— `PaintDebugBounds` 默认实现(只画 `Area()`)。
**修改**
* `Shared/Inc/UiComponent.hpp`、`Shared/Inc|Src/UiWidget.hpp/.cpp`、`Shared/Inc|Src/UiButtonWidget.hpp/.cpp`
* `Toolbar/Inc|Src/*`、`TabBar/Inc|Src/*`、`SignalPanel/Inc/SignalPanel.hpp` + `Src/SignalPanel.cpp`
* `PlotView/Inc/PlotView.hpp` + `Src/PlotViewPaint.cpp`
* `Shared/Inc/PopupMenu.hpp`(`MenuScope::APP_SETTINGS`、`Rect()`)、`Shared/Inc/ColorPickerPopup.hpp`(`Rect()`)
* `Application/Inc/Application.hpp`、`Inc/ApplicationInternal.hpp`、
`Src/Application.cpp`(Render 末尾叠加 + `fullFrame`)、`Src/ApplicationCommands.cpp`(菜单/派发/边界绘制)
### 验证数据
### 3.1 结构
```
make release (TargetCheck) -> 零 error / 零 warning, 897,024 B
.preflight/lbcheck -> 21 passed, 0 failed
INFO area-pixels=24930 node-pixels=19734
INFO gear=(15.0,55.0 50.0x37.5) menu=(15.0,97.0 185.0x73.8)
rendercheck 工具栏以下区域哈希(改造前 == 改造后):
AB AABF558F499583EA BB 1B8B2D389D41B988 CB CAC17AA78C515079 DB 154617738CD9C422
EB DA484B02EEEC778A FB B3CE740F60CF3688 GB 4DD14C0C2EC4CE91 HB D84400B723089F43
rendercheck FUNC 7/7 PASS
uicheck 69 passed, 0 failed
UiBox.hpp struct UiBox { rect, clip, depth, layer }; ← 纯值,无行为
class UiBoxTable : UiTextRectSink ← 只累积几何 + 裁剪/层/遮挡
UiDebugBounds.hpp UiDebugBoundsColor / UiDebugPaintBoundsClipped / UiDebugPaintBoxList
↑ 唯一的消费者(调试描边),与几何解耦
```
> 说明:整帧哈希 A–H 因为工具栏左侧多了一个齿轮按钮而整体变化(预期),
> 因此给探针加了「工具栏带以下」的区域哈希用于**证明改动范围被完全限制在工具栏内**。
`UiComponent::BuildBoxes(UiBoxTable&)` 默认实现:
### 验证坑(本轮新增)
```cpp
void UiComponent::BuildBoxes(UiBoxTable& boxes) { boxes.Add(Area(), 0); }
```
* PS 5.1 读中文 `.ps1` 会乱码 ⇒ 探针/脚本里只写 ASCII,中文注释另存 UTF-8 无 BOM 数据文件。
* 想拿"改造前"的区域基线必须 `git stash push -u`(**含未跟踪文件**),否则新增的
`UiDebugBounds.cpp` / `UiComponent.cpp` 仍会被 Makefile 的 `Src/*.cpp` 通配扫到而编译失败。
* 「工具栏带以下」的起点必须用**运行时** `m_toolbarRect.Y + Height`;写死 70 会切进工具栏,
齿轮正好落在被统计的区间里,导致本应相同的哈希全都不同。
各组件覆盖情况:
| 组件 | 上报内容 |
| --- | --- |
| `SignalPanel` | 表头 / 筛选框 / 列表 / 样式区 / 每个可见行 / 行内元素(复选框、色块、名称、GX 徽标、✕、折起三角、组计数)/ 滚动轨道与滑块 / 编辑框 |
| `PlotView` | 网格、GX 徽标、图例条目、**5 类浮动面板**(含逐字段)+ 面板遮挡与裁剪 |
| `Toolbar` | 区域、每个按钮、按钮内容(文本/图标) |
| `TabBar` | 区域、每个标签条、新建按钮、标签 ✕、控件树 |
| `PopupMenu` | 条目(裁到 `m_scrollEnabled ? m_scrollViewport : m_rect`)、可视区、轨道、滑块、二级子菜单 |
| `ColorPickerPopup` | 各控件 |
### 3.2 与 `UiDebugBounds` 的边界
* 几何(矩形 / 层级 / 层号 / 裁剪 / 遮挡)→ `UiBox.hpp`
* 观感(配色 / 包角粗细 / 图元数量 / 抗锯齿开关)→ `UiDebugBounds.hpp`
这样 `SignalPanel.cpp` / `PlotViewPaint.cpp` / `PopupMenu.cpp` 等**只包含 `UiBox.hpp`**,
不再把"调试描边"这个依赖拉进自己的编译单元;只有宿主 `ApplicationCommands.cpp` 同时包含两者。
### 3.3 零像素回归的证明
L1 落地前后 `JustDataInspector.exe` **字节数完全相同(788,992 B)**,
`rendercheck` 8 个场景哈希逐一相同 —— 因为这只是"把几何从调试绘制里搬出来",
没有动任何绘制顺序或图元。
---
## 第九轮(2026-10-28):边界观感重做 + 覆盖补全 + 几何同源
## 4. 回归结果(最终构建上跑)
### 进度总览
| # | 任务 | 状态 | 验证 |
| --- | --- | --- | --- |
| 1 | 齿轮按钮 `primary = true`(与「导入文件」同色) | ✅ | 截图核对(两按钮同为强调色) |
| 2 | 边界改为「四角粗 + 四边细」包角 | ✅ | 截图核对 |
| 3 | 层级 > 0 内缩 1px | ✅ | 同上 |
| 4 | 协议改为 `CollectDebugBoxes`(收集 → 宿主统一绘制) | ✅ | 编译 + 探针 |
| 5 | `SignalPanel` 行内元素全覆盖 | ✅ | box 计数 |
| 6 | `PlotView` GX 徽标 / 图例 / 5 类浮动面板 | ✅ | box 计数 |
| 7 | `Toolbar` 按钮内容矩形 | ✅ | 截图(文字/图标各有框) |
| 8 | `PopupMenu` / `ColorPickerPopup` 内部结构 | ✅ | 截图(菜单项有框) |
| 9 | `RowRect` O(row) → O(1) | ✅ | 8 场景区域哈希不变 |
| 10 | `PanelBounds()` 收敛 6 处重复 | ✅ | 编译 |
| 11 | 文档 + 共享记忆 | ✅ | — |
### 1. 描边样式:`⌜ ⌝ ⌞ ⌟` 包角
之前是四条**等宽实心矩形**(层级 0 用 2px、其余 1px),所以同一元素的"角"和"边"粗细不一致,
而且父子矩形共边时会叠成更粗的一条线。
现在:
| 部位 | 规则 |
| 探针 | 结果 |
| --- | --- |
| 四边 | 固定 **1 设备像素** |
| 四角 | 额外加粗(层级 0 = 4px,其余 3px),沿水平/垂直各延伸 `cornerLen`(≤14px,且不超过短边一半) |
| 内缩 | 层级 > 0 整体内缩 **1px**,与父矩形共边时不再叠粗 |
| 填充 | **完全不填充**(几十层嵌套填充会把内容糊住) |
| `Tools/uicheck` | **69 passed, 0 failed** |
| `.preflight/lbcheck` | **21 passed, 0 failed** |
| `.preflight/lbresid` | **5 passed, 0 failed** |
| `.preflight/lbzorder` | **4 passed, 0 failed** |
| `.preflight/lbcovr` | **7 passed, 0 failed** |
| `.preflight/lbpanel` | **10 passed, 0 failed** |
实现要点:整像素对齐 + `PixelOffsetModeNone` + 关抗锯齿,四条边与八个角段全部用
`FillPixels` 实心矩形画(`DrawRectangle` 会因笔宽奇偶偏移半像素)。
### 2. 覆盖率:从"一根大框"到"逐元素"
协议改成**两段式**:组件只负责"收集 box",宿主统一描边。
`Tools/rendercheck`(逐像素哈希,必须与基线逐一相同):
```
UiComponent::CollectDebugBoxes(UiDebugBoxList&) 默认只登记 Area()
UiWidget::CollectDebugBoxes(boxes, depth) 递归整棵控件树
宿主 PaintLayoutBounds 收集完一次性画(按 depth 升序)
A FAED59DF284153DB B E6443BFE4E3955F8 C 96A0AA8C402F1729 D E3D5DBD998E43720
E DA0583CB13C26A6F F FFB822159F604D1D G 19F8DE8FE3A8DF18 H DBBDDE4CB2C97C92
FUNC 7/7 PASS, exit=0
```
新增覆盖:
构建:`make release` 零 error / 零 warning;`make debug` 零 warning。
| 组件 | 本轮新增的 box |
| --- | --- |
| `Toolbar` | 每个按钮 + **按钮内容**(文本/齿轮图标,居中矩形) |
| `TabBar` | 标签条 / 新建按钮 / 每个 ✕ |
| `SignalPanel` | 表头 / 筛选框 / 列表 / 样式区;筛选输入框 + 清空 ✕ + 全部显示;滚动条轨道 + 滑块;样式 5 行 + 12 个色块 + 自定义 + 3 个模式按钮 + 3 个调整按钮 + 线宽/透明度轨道与数值;目标行 + 名称 + GX 徽标;**每行**(仅可见)+ 折起三角 + 复选框 + 色块 + 名称 + GX 徽标 + ✕ / 组名 + 计数;内联编辑框 |
| `PlotView` | 每个网格 + GX 徽标 + 装饰层矩形;图例条目(托底/色块/文本);5 类浮动面板的面板/标题/✕/折起三角/徽标/编辑行/按钮/内容区/滚动轨道/滑块/透明度轨道与数值/两个显示开关 + 各行复选框/数值/滑轨/复位/名称/✕ + 分组栏/表头/三角/✕ + 每个固定点行与 ✕ |
| `PopupMenu` | 菜单浮层 + 每个条目 + 可滚动可视区 + 轨道 + 滑块 + 二级子菜单与其条目 |
| `ColorPickerPopup` | 面板 + 标题/预览/色号/色域/色相条 + 3 条分量滑轨与数值 + 输入框 |
**标签做尺寸门槛**:小于 56×16 逻辑像素的 box 不画文字标签(复选框/色块/✕ 紧邻,
逐个小框贴字会糊成一片;它们的归属靠包角形状与颜色层级就能分辨)。
### 3. 几何同源与性能
* `RowRect(row)`:原来每次都从头累加行高(**O(row)**),而画一行要调它 **5 次**
(✕ / 徽标 / 名称 / 组计数 / 折起三角)。新增 `m_rowTop` 前缀和,由
`UpdateAnimGeometry()` 统一重建 ⇒ **O(1)**;长度不一致时自动退回逐行累加(正确性优先)。
* `ContentOffsetBeforeRow()` 同样改用前缀和。
* `PanelBounds()` 收敛 `(m_clientBounds.Width > 1.0f) ? m_clientBounds : m_area`
在 6 处的重复。
### 4. 验证数据
```
make release / make debug 零 error 零 warning
TargetCheck.exe 910,336 B(889 KiB,< 900 KiB)
.preflight/lbcheck 21 passed, 0 failed
INFO area-pixels=16080 node-pixels=20629
uicheck 69 passed, 0 failed
rendercheck 「工具栏带以下」区域哈希(与上一轮逐一相同):
AB AABF558F499583EA BB 1B8B2D389D41B988 CB CAC17AA78C515079 DB 154617738CD9C422
EB DA484B02EEEC778A FB B3CE740F60CF3688 GB 4DD14C0C2EC4CE91 HB D84400B723089F43
rendercheck FUNC 7/7 PASS
```
> 整帧哈希必然变化(齿轮变主色 + 图标重画),因此继续用「工具栏带以下」的区域哈希
> 证明"改动被完全限制在工具栏带内"。
### 5. 本轮踩到的坑
* `UiDebugPaintBounds` 的旧签名带 `scale` 参数,但线宽刻意不随 DPI 变 —— 重构时应当**删掉这个
参数**而不是留个 `(void)scale`(已删)。
* 用 PowerShell 按行替换大段代码时,`while ($lines[$end].TrimEnd() -ne "}")` 找结尾会失败:
文件里的闭括号带**前导缩进**,必须用 `Trim()` 而不是 `TrimEnd()`;否则会越界并报
`InvokeMethodOnNull`,而且**已经把开头删掉了**,必须在替换后逐个核对括号配对。
* 齿轮图标在 22px 时,"齿从环中心线伸出"会让齿被环的描边吃掉,看起来只剩一个甜甜圈。
必须让**齿占最外一圈、外环缩到 0.54R**,齿才会明显。
* 中文不能写进 `.ps1`(PS 5.1 按 GBK 读),文本块要落到 UTF-8 数据文件再由 ASCII 脚本拼接。
> ⚠️ 探针 `build.ps1` 链接的是 `Build/Release/*.o`。**LTO 下这些是 LTO 字节码**,
> 因此 7 个探针脚本的链接参数里都补了 `-flto -flto-partition=one`。
> 若把 `LTO` 关掉,这两个参数无害(可与普通目标文件混链)。
---
## 第十轮(2026-10-28):包角自适应 + 文字标签移除 + 绘制残留与层级缺陷修复
## 5. 本轮审计清单
### 进度总览
| # | 任务 | 状态 | 验证 |
| --- | --- | --- | --- |
| 1 | 包角粗细/长度按短边自适应 | ✅ | 3 倍放大截图 |
| 2 | 小方框只画角、不画边 | ✅ | 同上 |
| 3 | 删除文字标签与 `UiDebugPaintStyle` | ✅ | 体积 −1.5 KB |
| 4 | 修复拖滚动条时的绘制残留(三条快路径) | ✅ | `.preflight/lbresid` |
| 5 | 修复 `m_decorRects` 幽灵框 | ✅ | box 清单打印 |
| 6 | box `clip`(组件地盘) | ✅ | 差异 bbox 不再越过绘图区上沿 |
| 7 | `(layer, depth)` 双排序键 | ✅ | `.preflight/lbzorder` |
| 8 | 浮层 `occluder` 挖洞 | ✅ | 菜单区 `amber=0 green=0` |
| 9 | 文档 + 共享记忆 | ✅ | — |
### 1. 描边按尺寸自适应(修复"小元素变成粗描边")
旧规则:四角固定 3–4px、四边 1px。对 15px 的复选框来说,3px 包角占短边 20%,看起来就是粗边框。
新规则(全部以**短边**为基准):
| 量 | 规则 |
| 项 | 处理 |
| --- | --- |
| 四边 | 固定 1 设备像素 |
| 包角粗细 | `min(基础值 4/3, 短边/10)`,再夹到 `[1, 短边/4]` |
| 包角长度 | `min(14, 短边/3)`,且不小于包角粗细 |
| 短边 ≤ 12 | **只画四个 L 形角标记、不画边**(一圈连续描边会让小方框显得"被框住") |
| 短边 ≤ 5 | 完全不画(再画就盖住元素本身) |
| 内缩 | 仅层级 > 0 **且短边 ≥ 10** 时内缩 1px |
同时**删除了文字标签**(含 `UiDebugPaintStyle`):黑底白字在小元素上必然连成一片,
且体积上省掉约 1.5 KB。
### 2. 绘制残留:三个独立成因,逐个修复
| # | 成因 | 修复 |
| --- | --- | --- |
| R1 | `PlotView::CollectDebugBoxes` 读了 `m_decorRects` —— 那是**装饰层位图缓存**的登记矩形,坐标处在**位图/图层坐标系**(带负 Y、超出绘图区)。于是出现 `(472,-27 1213x101)` 这类**横跨工具栏的幽灵巨框** | 不再读 `m_decorRects`;只保留 `LaneBadgeRect` 与 `m_legendHits`(屏幕坐标) |
| R2 | `Paint` 的三条快路径(静态场景贴图 / 宽基底贴图 / 样式补丁)只重画**变化的部分**,而调试边框是画在整个窗口上的叠加内容 ⇒ 上一帧的边框残留在未被重画的像素上 | 新增 `PlotView::SetForceFullRepaint(bool)`:开启调试叠加层时禁用全部快路径 |
| R3 | 组件内元素可能落在自己地盘之外(滚出可视区的 `lane.rect` Y 为负),不裁剪就画到工具栏/标签栏上 | box 增加 `clip`:宿主按组件 `Area()` 设置,`Add()` 跳过无交集、绘制时求交 |
另外把 `Render` 的 `fullChrome` 也并入 `m_showLayoutBounds`,保证装饰区每帧都是完整一帧。
### 3. 层级:`(layer, depth)` 双排序键 + 浮层挖洞
* `layer` 由 `UiDebugBoxList::PushLayer()` 分配,宿主每收集完一个组件就 +1 ⇒ **后收集的压住先收集的**
(右键二级菜单是最后收集的浮层)。
* `depth` 在同一层内先画父再画子,子元素的彩色包角不被父的边压住。
* ⚠️ 仅靠排序**不够**:调试线画在所有内容之后,下层组件的线仍会显示在不透明菜单上面。
因此新增 `AddOccluder()`:菜单/拾色器把本体登记为遮挡矩形,绘制时对**更低层**的 box
用 `Region::Exclude` 挖洞。实测菜单区与子菜单区的琥珀/绿像素均为 **0**。
### 4. 验证数据
```
make release / make debug 零 error 零 warning
TargetCheck.exe 906,752 B(885.5 KiB,< 900 KiB)
.preflight/lbresid 5/5 toolbar-unchanged-while-scrolling / repaint-same-state-is-identical
/ toolbar-restored-by-full-repaint / residual-never-above-plot-area
/ later-layer-wins-over-earlier
INFO toolbar diff pixels=0;same-state 两帧 settled after 1 extra frame 后逐像素相同
.preflight/lbzorder 4/4 menu 区 amber=0 green=0;submenu 区 amber=0 green=0
.preflight/lbcheck 21/21
uicheck 69/69
rendercheck 「工具栏带以下」区域哈希 8/8 与上一轮逐一相同 + FUNC 7/7
```
### 5. 本轮踩到的坑
* `Region::MakeInfinite()` 之后必须 `Intersect` 一个足够大的有限矩形,否则 `Exclude` 的行为
在部分 GDI+ 版本上不可预期;这里用 `(-1e5,-1e5) + 2e5 见方`。
* 给浮层设 `clip = 菜单本体` 会把**展开到本体之外**的二级子菜单整个裁掉
(首版就是这样,子菜单一个框都没有)。正确做法是浮层不设 clip,改用它自己登记的 occluder。
* 探针判定"是否有残留"不能拿"拖拽帧 vs 松开后一帧"直接比:两者渲染路径不同
(图层预热进度不同)会得到几十万像素的**合法**差异。正确判据是
**同一状态连续两帧必须逐像素相同**。
| `MemProbe.hpp` / `.cpp` 里"省 40~60KB"的估算 | 改为实测值 27,136 B(909,824 → 882,688) |
| `MemProbe.hpp` 的 `#include <new>` | 未使用,删除 |
| `MemProbe.cpp` 的 `#include <cstdio>` | 未使用,删除 |
| `UiDebugPaintBounds(g, rect, depth)` | **零调用者**(唯一调用者是它自己),删除;只保留带 `clip` 的版本 |
| `TabBar::BuildBoxes` / `Toolbar::BuildBoxes` / `PlotView::BuildBoxes` / `SignalPanel::BuildBoxes` 的"(调试用)" | 改为"几何 box"(层级已变,它们不再关心调试) |
| `PlotView::SetForceFullRepaint` 文档 | 补写"为什么保留而不优化"(L2 缓做的理由) |
| 全仓旧名 `UiDebugBoxList` / `UiDebugBox` / `CollectDebugBoxes` | 源码零残留(仅 `Documents/agent_mem.md` 历史记录保留原样) |
---
## 第十一轮(2026-10-28):取消内缩 + 文本获得几何身份 + 补全漏项 + 描边提速
## 6. 已知问题 / 未做
### 进度总览
| # | 任务 | 状态 | 验证 |
| --- | --- | --- | --- |
| 1 | 取消内缩(GX 胶囊圆弧不再落在框外) | ✅ | `box-gx-badge-matches-pill` |
| 2 | `UiTextRectSink`:文本墨迹矩形统一上报 | ✅ | `text-boxes=89` |
| 3 | 行位图缓存内的文本按原点回放 | ✅ | `row-text-boxes-from-cached-rows` |
| 4 | 补 `PlotView` 漏项(时间轴/滚动条/回到最上方/光标标签/输入框) | ✅ | 4 条 box 断言 |
| 5 | 描边图元 12→8 | ✅ | 5.892ms → **4.028ms** |
| 6 | 关闭开关时清空容器 | ✅ | `boxes-cleared-when-off` |
| 7 | 文档 + 共享记忆 | ✅ | — |
### 1. 取消内缩(这是"边框不清晰"的主因)
上一轮为"父子矩形共边不叠粗"给 `depth > 0` 的 box 加了 1px 内缩。结果:
GX 徽标是 `30×17` 逻辑像素的**胶囊**(圆角 = 高的一半),外接矩形正好等于 `LaneBadgeRect`;
内缩 1px 之后框比形状小一圈 ⇒ 左右尖端与上下圆弧全部落在框外。
**现行规则:框 = 可见形状的外接矩形,绝不内缩。** 父子共边时让"后画的覆盖"即可 ——
这本来就是正确的图层语义。层级 > 0 只影响**配色**,不再影响几何。
### 2. 文本第一次有了几何身份
文本是本项目唯一"没有几何身份"的元素:全仓 65 处绘制点各自现算一个 `gd::RectF`、
画完即弃。逐处补登记必漏,因此在**唯一出口**回报一次:
```
PaintUtil 新增:
class UiTextRectSink { virtual void OnTextRect(const gd::RectF&) = 0; };
void UiSetTextRectSink(UiTextRectSink*); // 安装 / 卸载(nullptr)
void UiSetTextRectOffset(float dx, float dy); // 离屏位图渲染时的坐标换算
UiTextRectSink* UiGetTextRectSink();
```
`DrawUiText()` 与 `UiTextAtClipped()` 在画完之后回报**收紧后的墨迹矩形**
(按对齐方式 + `UiMeasureTextWidth/Height` 实测值收窄),因此每个文本 box
就是文字真正占据的那一小块 —— 而不是整行。
`UiDebugBoxList` 直接继承 `UiTextRectSink`,所以宿主只需在 `Render` 里
`UiSetTextRectSink(&m_debugBoxes)` 一句。
### 3. 缓存命中的行也要有文本框
面板的行是"先画进位图再贴回"。命中缓存的帧**根本不调用 `UiText`** ⇒
绘制期收集器收不到任何东西,文本框会在第二帧起全部消失。
解法:行渲染时用一个本地 `RowTextCollector` 收集(**位图坐标**),连同位图一起存进
`RowBitmapCache::textRects`;贴图时按 `(originX, originY)` 偏移**回放**给外层收集器。
并且**无条件收集**(不看外层是否有收集器)—— 否则"关闭调试时滚过列表、再打开调试"
会命中缓存而永远拿不到文本框。
### 4. 补全 `PlotView` 漏项
都已有成员变量,直接读:
| 元素 | 来源 |
| --- | --- |
| 时间轴整条区域 | `m_timeAxisRect` |
| 网格滚动条 | `m_scrollBarRect` |
| 「回到最上方」浮动按钮 | `BackToTopRect()` |
| 双光标差值标签 | `m_cursorSpanRect` |
| 光标读数标签(避让后实际位置) | `m_cursorLabelRects` |
| 内联输入框 | `EditFieldRect()` |
### 5. 描边提速(12 → 8 图元)
微基准结论:**图元数量**是唯一瓶颈(每次 `FillRectangle` ≈ 1µs;`FillPath` 单次 ≈ 20µs,
合并路径反而更慢;GDI `PolyPolygon` 单次 5688 矩形要 114ms)。
旧实现把每条边拆成"中段 1px + 两端各一段加粗" = 12 次;新实现 = **4 条 1px 边 + 4 个角块**。
实测 `paintBoxes` 从 **5.892ms → 4.028ms(−32%)**,观感几乎一致(角仍是两条粗边的交点)。
### 6. 验证
```
make release / make debug 零 error 零 warning
TargetCheck.exe 909,824 B(888.5 KiB < 900 KiB)
.preflight/lbcovr (新增) 7/7 box-time-axis / box-plot-scrollbar / box-back-to-top
/ box-gx-badge-matches-pill / text-boxes-collected
/ row-text-boxes-from-cached-rows / boxes-cleared-when-off
.preflight/lbcheck 21/21 ;lbresid 5/5 ;lbzorder 4/4 ;uicheck 69/69
帧耗时分解(MF4 / 283 网格):base 9.06ms / 仅强制整帧 6.66ms / 完整调试 11.35ms
collect 0.082ms / paintBoxes 4.028ms(上轮 5.892ms)
rendercheck 8 个场景的整帧与区域哈希与上一轮**逐一相同** + FUNC 7/7
```
### 7. 本轮踩到的坑
* 只在"外层有收集器"时才收集行文本 ⇒ 关闭调试时缓存被填充、打开调试后永远拿不到文本框。
**必须无条件收集**(成本只发生在缓存未命中的行上)。
* 把浮层的 `clip` 设成菜单本体会把展开到本体外的二级子菜单整个裁掉(上一轮遗留,已确认仍正确)。
* `UiDebugBoxList` 直接当收集器用时,`OnTextRect` 需要有自己的 `depth`(文本用 4),
不能沿用调用点当时的 depth —— 否则文本与容器同色。
---
## 第十二轮(2026-10-29):分支同步 + 既有性能问题 + 浮层层级修复
### 0. 仓库同步
| 项 | 结果 |
| --- | --- |
| `git fetch gitea --prune` | 新增 `gitea/UI_Refactor`;`gitea/main` 前进到合并提交 `322b658` |
| `main` | 快进到 `322b658`(本地原先落后 22 个提交,现在与远端一致) |
| `dev` | 远端不存在该分支 ⇒ **本地已删除**(用户确认是意外产物) |
| `UI_Refactor` | 快进到 `322b658`(它原本是合并提交的祖先),继续作为工作分支 |
### 1. 进度总览
| # | 任务 | 状态 | 验证 |
| --- | --- | --- | --- |
| 1 | 分支同步 / 删 `dev` | ✅ | `git branch -vv` |
| 2 | 定位"空闲帧比强制整帧慢"的真因 | ✅ | 两级计时探针 |
| 3 | `RollWideBuffer` 把可视性判断提到最前 | ✅ | 排空后稳态 0.53ms |
| 4 | 浮动面板另起绘制层 + 遮挡矩形 + 内容裁剪 | ✅ | `lbpanel` |
| 5 | 菜单条目裁到可视区 | ✅ | `lbpanel` |
| 6 | `EdgePanelLayoutFor` 前置廉价判据 | ✅ | 收集耗时 0.082→0.075ms |
| 7 | 文档 + 共享记忆 | ✅ | — |
### 2. "既有性能问题"的真相
用两级计时(先整体、再拆到 `WarmLaneLayersStep` 内部)测出:
```
排空前 idle(静态场景命中) 2.60 ms ← 之前误以为是稳态
排空后 idle(稳态) 0.53 ms
强制整帧 idle 0.46 ms
[warm] render=1.3~2.8ms (pending=16→0) ← 每帧补 1 个网格图层
```
结论修正:**不存在**每帧白烧的固定开销;那 2.4ms 是"图层预热队列排空"的**瞬时**成本
(16 个网格 × 每帧补 1 个,约 16 帧后结束)。
但确实发现一处顺序问题:`RollWideBuffer` 对**所有** 283 个网格计算 `LaneLayerKey()`
(逐网格内容哈希,实测约 0.7ms/帧),然后才判断是否可视并 `continue`;
而且离屏网格的"结构不可用"分支还会把它们**逐帧塞进待重画队列**(再被 `remove_if` 清掉)。
现在把可视性判断提到循环最前面 ⇒ 离屏网格既不哈希也不入队。
### 3. 浮动面板被网格边界穿透(层级 + 挖洞)
根因:`PlotView::CollectDebugBoxes` 把**网格盒子与面板盒子放在同一个绘制层**,
只靠 `depth` 排序 —— 而调试线画在所有内容之后,"后画"只能决定谁压在谁上面,
**不能阻止下层组件的线仍然可见**。
修复三步:
1. 收集面板前 `boxes.PushLayer()` ⇒ 面板层高于网格层;
2. 每个面板 `boxes.AddOccluder(layout.panel)` ⇒ 绘制更低层盒子时用 `Region::Exclude` 挖洞;
3. `boxes.SetClip(layout.panel)` ⇒ 面板内部的元素(如滚动出内容区的行)也不再登记/绘制到面板外。
### 4. 滚动时面板边界闪烁 / 菜单条目"仍然在绘制"
同一类问题:**滚动容器里的条目矩形仍然存在,只是不绘制**。
* 面板:内容区滚出的行 → 现在裁到 `layout.panel`;
* 菜单:`m_itemRects` 含**滚出视口**的条目 → 现在裁到 `m_scrollViewport`(可滚动时)或 `m_rect`;
二级子菜单条目裁到 `m_subRect`。
实测(`lbpanel`):
```
panel 层 escaped=0;菜单层 belowViewport=0;菜单层 fully-outside=0
面板内网格层颜色像素 = 0
滚动 0.0 → 1295.1(确认真滚动了),面板盒子签名 11/11 帧完全一致
```
### 5. 验证
```
make release / make debug 零 error 零 warning;TargetCheck.exe 909,824 B(888.5 KiB)
lbcovr 7/7 · lbpanel 10/10(新增)· lbzorder 4/4 · lbresid 5/5 · lbcheck 21/21 · uicheck 69/69
rendercheck 8 场景哈希与上一轮逐一相同 + FUNC 7/7
```
### 6. 踩坑
* 探针里"盒子是否越界"必须**按绘制层过滤**:别的组件(信号面板样式区、标签栏 ✕)
可能恰好与菜单同列,直接按坐标判会把它们误当成"菜单条目溢出"。
* 构造"所属网格"菜单必须带**分隔线 + 尾部固定项**,否则 `PopupMenuLayout` 的
滚动区根本不会启用(`pinnedFrom` 只在找到分隔线时才 > 0)。
* 计时探针的迭代次数太少(20 次)时,首帧的图层补齐会污染平均值 —— 必须先排空预热队列再测稳态。
1. **L2 未做**(见 Plan §5)。`SetForceFullRepaint` 仍在,布局边界打开时 `PlotView` 走整幅重绘。
2. `Tools/` 与 `.preflight/` 下的探针脚本未被 git 跟踪(本地验证工具),
本轮给它们补的 `-flto` 参数**不会进版本库**。若 LTO 长期默认开启,
建议把探针脚本纳入版本管理,否则下次 `make clean` 后重建探针需要手工补参数。
3. `.lst` 汇编清单在 `LTO=1` 时不再生成(`lto-wrapper` 会丢弃 `-Wa` 参数)。
需要看汇编时用 `make release LTO=0`。