Files
Just-DataInspector/Documents/UI_Refactor_Progress.md
T
DeepSeek V4.1 Flash aa4925a2b7 fix(UI): 边界框不再内缩 + 文本获得几何身份 + 补全漏项 + 描边提速 32%
## 1. 取消内缩(这是"边框不清晰"的主因)

第九轮为"父子矩形共边不叠粗"给 depth > 0 的 box 加了 1px 内缩。结果 GX 徽标
(30x17 逻辑像素的胶囊,圆角 = 高的一半,外接矩形正好等于 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() 画完后按对齐方式 + 实测宽高把布局矩形**收窄成墨迹矩形**
再回报。UiDebugBoxList 直接继承该接口,宿主只需 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)。
旧实现把每条边拆成"中段 1px + 两端各一段加粗"= 12 次;新实现 = 4 条 1px 边 + 4 个自适应角块。
实测 paintBoxes 5.892ms → 4.028ms(-32%),观感几乎一致(角仍是两条粗边的交点)。
⚠️ 不要改写成 GraphicsPath/FillPath(单次约 20µs,比 12 段还慢);
GDI PolyPolygon 一次画 5688 个矩形要 114ms,更不能用。

## 6. 关闭开关时清空容器(释放内存,避免误读)

## 7. 验证

- 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(容差 0.5px)/ text-boxes-collected(89 个)/
  row-text-boxes-from-cached-rows / boxes-cleared-when-off。
- 回归:lbcheck 21/21、lbresid 5/5、lbzorder 4/4、uicheck 69/69。
- rendercheck:8 个场景的整帧与区域哈希与上一轮**逐一相同** + FUNC 7/7
  (证明调试功能对正常绘制零影响)。
- 帧耗时分解(MF4 / 283 网格):base 9.06ms、仅强制整帧 6.66ms、完整调试 11.35ms;
  collect 0.082ms、paintBoxes 4.028ms(上一轮 5.892ms)。

## 8. 文档

Documents/UI_Refactor_Plan.md / UI_Refactor_Progress.md / UI_Refactor_Report.md 追加第九阶段;
Documents/agent_mem.md 同步变更日志与「2026-10-28 第十一轮」硬约束与踩坑。
2026-10-28 17:38:55 +08:00

680 lines
39 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# UI 重构进度(UI_Refactor 分支)
> 本文件是**跨会话/跨 Agent 的交接台账**。每次提交前更新;每一行都要能对应到一个可复跑的验证结果。
> 计划见 `Documents/UI_Refactor_Plan.md`;硬约束见 `Documents/agent_mem.md` §11.2。
## 分支状态
| 分支 | 基线 | 用途 |
| --- | --- | --- |
| `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 | ✅ 已落地;脏区并集**经评估不采用** |
---
## 提交记录
### 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/<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 | 三份文档更新 | ✅ | — |
### 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 处)
| 消息 | 改造前 | 改造后 |
| --- | --- | --- |
| `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` |
保留显式的三条链(**有意,不是漏改**):左键按下 / 松开 / 鼠标移动。
它们各自挂着一串"组件专属后置动作"(提交重命名、构建派生信号、面板联动滚动、
更新拖拽光标、判断 `plotOnlyMove` 决定局部还是整帧失效)。
把这些塞进表里要引入 lambda 或回调字段,可读性反而下降 —— 属于用户明确禁止的"过度抽象"。
### 3. 拆分对照
| 原文件 | 拆后 | 内容职责 |
| --- | --- | --- |
| `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` |
拆分方式:**整函数搬移**(不重写、不改签名),共享的尺寸常量与辅助函数收敛到
`*Internal.hpp`(`constexpr` / `inline`,跨 TU 安全)。
这样每个 TU 的行数落在 200–900 的可读区间,而逐像素输出保持 0 差异(哈希佐证)。
### 4. 代码审查结果
| 文件 | 问题 | 处理 |
| --- | --- | --- |
| `SignalPanelInteraction.cpp` | 头注释仍写"4 片:核心/渲染/交互与编辑" | 改为六分片清单(含本文件定位) |
| `PopupMenuLayout.cpp` | 构造函数被机械搬移导致重复定义 | 只保留在 `PopupMenu.cpp` |
| `PopupMenu.cpp` | `#include "PopupMenuInternal.hpp"` 落在 `namespace jdi` 内 | 移到文件头 |
| `PopupMenuPaint.cpp` | 缺 `UiControls.hpp`(`UiDrawEditFieldContent` 未声明) | 补 include |
| 全仓 | 历史删除符号残留引用 | grep 9 个符号,**零命中** |
### 5. `Tools/` 剥离
`git rm -r --cached Tools` + `.gitignore` 新增 `Tools/`,
并对 `UI_Refactor` 分支做 `filter-branch` 清除历史中的该目录(分支全本地、未推送,风险可控);
本地文件在剥离前已备份到 `%TEMP%` 并复原,`uicheck` / `rendercheck` 随时可复跑。
### 6. 验证数据
```
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)
```
---
## 第八轮(2026-10-28):显示布局边界 + 三处可见收益优化
### 进度总览
| # | 任务 | 状态 | 验证 |
| --- | --- | --- | --- |
| 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`(菜单/派发/边界绘制)
### 验证数据
```
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
```
> 说明:整帧哈希 A–H 因为工具栏左侧多了一个齿轮按钮而整体变化(预期),
> 因此给探针加了「工具栏带以下」的区域哈希用于**证明改动范围被完全限制在工具栏内**。
### 验证坑(本轮新增)
* PS 5.1 读中文 `.ps1` 会乱码 ⇒ 探针/脚本里只写 ASCII,中文注释另存 UTF-8 无 BOM 数据文件。
* 想拿"改造前"的区域基线必须 `git stash push -u`(**含未跟踪文件**),否则新增的
`UiDebugBounds.cpp` / `UiComponent.cpp` 仍会被 Makefile 的 `Src/*.cpp` 通配扫到而编译失败。
* 「工具栏带以下」的起点必须用**运行时** `m_toolbarRect.Y + Height`;写死 70 会切进工具栏,
齿轮正好落在被统计的区间里,导致本应相同的哈希全都不同。
---
## 第九轮(2026-10-28):边界观感重做 + 覆盖补全 + 几何同源
### 进度总览
| # | 任务 | 状态 | 验证 |
| --- | --- | --- | --- |
| 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**,与父矩形共边时不再叠粗 |
| 填充 | **完全不填充**(几十层嵌套填充会把内容糊住) |
实现要点:整像素对齐 + `PixelOffsetModeNone` + 关抗锯齿,四条边与八个角段全部用
`FillPixels` 实心矩形画(`DrawRectangle` 会因笔宽奇偶偏移半像素)。
### 2. 覆盖率:从"一根大框"到"逐元素"
协议改成**两段式**:组件只负责"收集 box",宿主统一描边。
```
UiComponent::CollectDebugBoxes(UiDebugBoxList&) 默认只登记 Area()
UiWidget::CollectDebugBoxes(boxes, depth) 递归整棵控件树
宿主 PaintLayoutBounds 收集完一次性画(按 depth 升序)
```
新增覆盖:
| 组件 | 本轮新增的 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 脚本拼接。
---
## 第十轮(2026-10-28):包角自适应 + 文字标签移除 + 绘制残留与层级缺陷修复
### 进度总览
| # | 任务 | 状态 | 验证 |
| --- | --- | --- | --- |
| 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 松开后一帧"直接比:两者渲染路径不同
(图层预热进度不同)会得到几十万像素的**合法**差异。正确判据是
**同一状态连续两帧必须逐像素相同**。
---
## 第十一轮(2026-10-28):取消内缩 + 文本获得几何身份 + 补全漏项 + 描边提速
### 进度总览
| # | 任务 | 状态 | 验证 |
| --- | --- | --- | --- |
| 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 —— 否则文本与容器同色。