Files
Just-DataInspector/Documents/UI_Refactor_Plan.md
T
DeepSeek V4.1 Flash 24706c906a feat(UI): 布局边界改为四角粗/四边细包角 + 全元素覆盖 + 几何同源(RowRect O(1))
## 1. 边界观感:四角粗、四边细

用户反馈"边框就是实心矩形、粗细不一,效果不佳"。旧实现是四条等宽实心矩形
(层级 0 用 2px、其余 1px),同一元素的角与边粗细不一致,父子矩形共边时还会叠成更粗的一条。

新规则:
- 四条边固定 1 设备像素(四条独立实心矩形;不用 DrawRectangle,笔宽奇偶会偏半像素);
- 四个角额外加粗:层级 0 = 4px、其余 3px,沿水平/垂直各延伸 cornerLen(≤14px 且 ≤ 短边一半);
- 层级 > 0 整体内缩 1px,父子矩形共边不再叠粗;
- 绝不填充(几十层嵌套填充会把内容糊住);
- 整像素对齐 + PixelOffsetModeNone + 关抗锯齿。

四角粗不只是观感:多条边重合时靠角的形状分辨层次(父=青蓝大包角、子=同位置品红小包角)。

## 2. 协议:组件"收集 box",宿主统一绘制

UiComponent::CollectDebugBoxes(UiDebugBoxList&)  默认只登记 Area()
UiWidget::CollectDebugBoxes(boxes, depth)        递归整棵控件树
Application::PaintLayoutBounds                   收集后按 depth 升序一次性描边

刻意保持 box 为**值类型**而不是让它继承控件基类:PlotView 的 4 层位图缓存、
SignalPanel 的 192 槽行位图缓存都要求"绘制由组件自己负责",几何进继承体系会与
缓存键/失效体系打架。box 不参与绘制,因此能让绘制/命中/调试共用同一份几何。

## 3. 覆盖补齐(此前大量元素被裹在外层单位里)

- Toolbar:区域 / 每个按钮 / **按钮内容(文字或齿轮图标,新增 UiButtonWidget::ContentRect())**
- TabBar:区域 / 标签条 / 新建按钮 / 每个 ✕
- SignalPanel:4 分区 + 筛选输入框/清空✕/全部显示 + 滚动轨道与滑块
  + 样式区 5 行及其 12 色块/自定义/3 模式/3 调整/线宽与透明度轨道和数值
  + 目标行/名称/GX 徽标 + **每个可见行**的折起三角/复选框/色块/名称/GX 徽标/✕
  (组行另有组名/计数)+ 内联编辑框
- PlotView:区域 / 每个网格 / GX 徽标 / 装饰层矩形 / 图例托底+色块+文本
  / **5 类浮动面板全部 20+ 字段**(标题/✕/折起三角/徽标/编辑行/按钮/内容区/滚动轨道与滑块
  /透明度轨道与数值/两个开关/各行复选框与数值与滑轨与复位与名称与✕/分组栏/表头/固定点行)
- PopupMenu:浮层 / 每个条目 / 可滚动可视区 / 轨道 / 滑块 / 二级子菜单与条目
- ColorPickerPopup:面板 / 标题 / 预览 / 色号 / 色域 / 色相条 / 3 条滑轨与数值 / 输入框

标签增加**尺寸门槛**(< 56×16 逻辑像素不画):复选框/色块/✕ 紧邻,逐个小框贴字会糊成一片。

## 4. 设置按钮配色 + 齿轮图标

- ToolbarAction::SETTINGS 改为 primary = true,与「导入文件」同为强调色主按钮。
- 齿轮几何重做:齿占最外一圈(0.66R→1.0R)、外环中心线 0.54R/笔宽 0.24R、轴孔 0.17R。
  旧版让齿从环中心线伸出,齿被环的描边吃掉 ⇒ 看起来只剩一个"甜甜圈"。

## 5. 几何同源与性能

- RowRect(row) 原本每次调用都从头累加行高(O(row)),而画一行要调它 **5 次**
  (✕/徽标/名称/组计数/折起三角各一次)⇒ 200 行只见 30 行时每帧上万次加法,
  命中测试(每次鼠标移动)同样走这条路。
  新增 m_rowTop 前缀和(由 UpdateAnimGeometry() 统一重建)⇒ **O(1)**;
  长度与行集合不一致时自动退回逐行累加(正确性优先)。
- ContentOffsetBeforeRow 同样改用前缀和。
- PanelBounds() 收敛 6 处重复的 (m_clientBounds.Width > 1.0f) ? m_clientBounds : m_area。

## 6. 验证

- make release / make debug 零 error 零 warning;TargetCheck.exe 910,336 B(889 KiB < 900 KiB)。
- .preflight/lbcheck:21 / 21 PASS(含 bounds-off-restores-pixels:关闭后逐像素回到开启前)。
- Tools/rendercheck:「工具栏带以下」区域哈希 8/8 与上一轮**逐一相同**
  (证明本轮改动只落在工具栏带内);FUNC 断言 7/7 PASS。
- Tools/uicheck:69 passed / 0 failed。

## 7. 文档

Documents/UI_Refactor_Plan.md / UI_Refactor_Progress.md / UI_Refactor_Report.md 追加第七阶段;
Documents/agent_mem.md 同步变更日志与「2026-10-28 第九轮」硬规则与踩坑记录。
2026-10-28 16:28:57 +08:00

485 lines
30 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_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<br/>持有 app-sized 32bpp top-down DIB<br/>z 序 · 脏区并集 · 一次 BitBlt 回送 · 动画 tick]
H --> R[UiRoot : UiWidget]
R --> D{脏区树<br/>dirty ∩ bounds 空 ⇒ 整棵子树 return}
D --> A[普通控件:直接画进共享缓冲<br/>按钮 / 文本 / 列表行 / 时间轴 / 图例]
D --> B[高成本叶子:自持位图,完成即 BitBlt 贴回<br/>波形网格 / 装饰层 / 光晕 / 浮动面板]
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<UiWidget> child);
void RemoveChild(UiWidget* child);
const std::vector<std::unique_ptr<UiWidget>>& 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+ 条硬约束的断言骨架 | ✅ **已完成**(69 项断言) |
| **P1** | `UiLayout` + `UiWidget` 基础层(本次) | ✅ **已完成**(编译零警告;`uicheck` 覆盖) |
| **P2** | 零行为变更分片:`PlotViewPaint.cpp` 拆成 `Paint`(只画)/ `Raster`(缓存与预算)/ `CacheKey`(指纹)/ `Geometry`(面板与命中);`EdgePanelLayoutFor`、`ProbeFreeMarker` 迁回交互侧 | ✅ **已完成**(**逐像素 0 差异**) |
| **P3** | 迁移 chrome:Toolbar → TabBar → PopupMenu → ColorPicker → SignalPanel 行 | ✅ Toolbar/TabBar 已迁移;**PopupMenu/ColorPicker/SignalPanel 行经评估不采用**(见 Report §7 的结论与理由) |
| **P4** | 迁移 5 个浮动面板 | **经评估不采用**(面板布局已是单一函数且已缓存像素层;控件化无帧时间收益且会浮空吸顶/滚动语义,见 Report §7) |
| **P5** | 波形管线包成叶子 surface(**只包不改**)+ 共享 DIB 落地 | 🔄 **共享 DIB + `UiSurface` 已完成**;「56 处失效收敛为脏区并集」经评估**不采用**(`InvalidatePlotRect()` 已是按消息的批处理点,逐处替换只增间接层,详见 Report §7) |
**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 一轮即收敛" |
---
# 第五阶段计划:统一组件协议 + 去手写链路 + 拆分收尾(2026-10-28)
> 状态:✅ 已完成(详见 `UI_Refactor_Progress.md` 第七轮 / `UI_Refactor_Report.md` 第五阶段)
## 目标
1. 让"顶层组件"有统一协议,宿主不再逐个点名;
2. 用同一套协议把 `SetScale` / `Layout` / `MouseLeave` / `SetCursor` 四条手写链改成循环;
3. 把剩余两个"千行以上"的文件按**职责**拆分(不再按函数名前缀);
4. 一轮代码审查(错误注释 / 过期注释 / 名实不符 / 残留引用);
5. 把误入库的临时验证工具 `Tools/` 从提交树剥离并写入 ignore。
## 设计决定(先证明不是负优化)
* **协议只做"同签名归一"**:`UiComponent` 是纯接口、无状态,四个组件本就各自拥有
完全同名同签名的方法 ⇒ 改造 = 加 `override` + 补 `Area()`,实现零改动、生成码几乎不变。
* **不把 `UiComponent` 做成 `UiWidget` 的基类**:树节点是"父给尺寸 + 父下探脏区",
而 `PlotView`(4 层位图缓存)/ `SignalPanel`(行位图缓存)是"地盘宿主"。
强行上树会推翻缓存前提(历史事故:后台渲染覆盖平移、命中矩形错位)。
* **不做的一律写明理由**:按下/松开/移动三条链带组件专属后置动作,保留显式分支。
## 任务清单
| # | 任务 | 验证手段 |
| --- | --- | --- |
| P5-1 | 新增 `Shared/Inc/UiComponent.hpp`,四组件继承 + `override` | 编译零警告 |
| P5-2 | `Application`:`ComponentEntry` 表 + `BuildComponentTable()` | — |
| P5-3 | `ApplyDpi` / `Relayout` / `WM_MOUSELEAVE` / `WM_SETCURSOR` 表驱动 | `uicheck` 69/69 |
| P5-4 | `Relayout` 内容区改用 `UiStackRects`(布局库首个生产调用点) | `rendercheck` 8 哈希不变 |
| P5-5 | `SignalPanelInteraction.cpp` 拆 4 片 + `SignalPanelEdit/Scroll/Menu.cpp` | 8 哈希不变 |
| P5-6 | `PopupMenu.cpp` 拆 3 片 + `PopupMenuInternal.hpp` | 8 哈希不变 |
| P5-7 | 代码审查(注释准确性、构造函数重复、include 位置) | 人工 + 编译 |
| P5-8 | `git rm -r --cached Tools` + `.gitignore` + 历史过滤器 | `git ls-files Tools` 为空 |
| P5-9 | 三份文档更新 + 提交 | `git status` 干净 |
## 风险与对策
| 风险 | 对策 |
| --- | --- |
| 表驱动改动影响绘制顺序(toolbar 与 tabBar 重叠区) | 表按"底→顶"排,`WM_SETCURSOR` 从表尾向前命中,与旧 `if` 顺序一致 |
| 拆分导致逐像素差异 | 纯整函数搬移;每拆一个文件立刻跑 `rendercheck` 比对 8 哈希 |
| `filter-branch` 会清掉工作区被剔除的路径 | 先备份 `Tools/` 到 `%TEMP%`,重写后还原为未跟踪文件 |
| 中文注释在 PS 5.1 下乱码 | 文档编辑走 VS Code 编辑工具(UTF-8),终端只做 ASCII 拼接 |
## 完成判据
* `make release` / `make debug` 零 error 零 warning;
* `Tools/rendercheck` 8 个场景哈希与基线**逐一相同** + 7 条 FUNC 断言 PASS;
* `Tools/uicheck` 69/69;
* `TargetCheck.exe` < 900 KB;
* `git ls-files Tools` 返回空、`git status` 干净、`main` / `dev` 未被触碰。
---
# 第六阶段(2026-10-28):「显示布局边界」调试开关 + 三处可见收益优化
> 用户要求:先做可见收益的优化,然后在「导入文件」左侧加一个齿轮按钮,点击展开二级菜单,
> 菜单里放「显示布局边界」勾选项,风格参考 Android 调试功能;先落地,后续再改进。
## 1. 可见收益的优化(3 项,均为纯收益、行为不变)
| # | 位置 | 改前 | 改后 | 收益 |
| --- | --- | --- | --- | --- |
| O1 | `Toolbar::Layout` | **每次**都 `RebuildWidgets()`(销毁 + 重建整棵树) | 新增 `m_treeDirty`,只在规格/字体/主题/缩放/动态文本变化时重建 | 拖动面板分隔条时 `Relayout` 每帧调用一次 ⇒ 每帧省 10 次堆分配 + 8 次测量缓存失效 |
| O2 | `Toolbar::SetActionLabel` | 同时写「覆盖表」和「控件文本」两处 | 只写覆盖表 + 置树脏 | 消除双写可能不同步(旧实现下次重建会把文本退回默认值) |
| O3 | `Toolbar` / `TabBar` 的 `OnMouseMove` | 各自手写 `Dispatch(); return UiRectHasArea(TakeDirty());` | 新增 `UiWidget::DispatchRedraw(event)` | 两处重复收敛成一处;`TakeDirty()` 会清零,漏掉一次就把下一次真实变化误判为"无变化" |
## 2. 新功能:齿轮按钮 → 应用设置菜单 → 显示布局边界
### 2.1 入口
* `ToolbarAction::SETTINGS` + `kButtonSpecs[0]`(`UiButtonIcon::GEAR`)。
* 工具栏布局从"首按钮靠左 + 其余靠右"改为**左侧固定簇**(`kLeftClusterCount = 2`:齿轮 + 导入文件)+ 右侧按钮组。
* 齿轮是**矢量绘制**(`PaintGearIcon`:8 齿 + 外环 + 轴孔,全部按 `half` 的比例推导),
不用字体里的 `⚙` —— 该字符在不同回退字体里的大小与基线不一致,会"忽大忽小、垂直不居中"。
* `UiButtonWidget` 新增 `UiButtonIcon`(`NONE`/`GEAR`)与 `SetIcon`;图标按钮宽度固定 40 逻辑像素。
* `Application::OpenSettingsMenu()` 用 `Toolbar::ButtonRect(SETTINGS, …)` 取**控件实测矩形**做锚点
(宿主不自己推算按钮几何),菜单在按钮下沿 +4 逻辑像素处右下展开。
### 2.2 菜单
`MenuScope::APP_SETTINGS`(新增)→ 宿主自己处理,不下发到组件。
条目:`HEADER "调试"` + `NORMAL "显示布局边界"`(`keepOpen = true`,点击后菜单保持打开,
勾选态由 `PopupMenu::UpdateItemLabel` 回写)。
### 2.3 布局边界:为什么放在共同基类
`UiComponent` 新增 **`virtual void PaintDebugBounds(gd::Graphics&, float scale)`**,默认只画 `Area()`。
四个组件各自覆盖:先调基类(自己的区域),再补画**层级更深的**矩形。
这正是上一轮回答里"是不是只需要在共同基类内实现"的答案:
**能覆盖顶层 4 块区域,覆盖不了组件内部的手绘结构**。所以采用"基类给出协议 + 组件自己上报":
| 组件 | 上报内容 |
| --- | --- |
| 全部 | `Area()`(层级 0) |
| `Toolbar` / `TabBar` | 跳过与区域重合的根控件,直接遍历 `m_root->Children()`(层级 1);TabBar 另加每个 ✕(层级 2) |
| `SignalPanel` | 表头 / 筛选框 / 列表 / 样式区(1)+ 滚动条、样式行、**当前可见**的列表行(2) |
| `PlotView` | 每个 `LaneVisual::rect`(1);**不**上报曲线/胶囊/提示这类随数据变化的瞬态几何 |
| 宿主 | 客户区外框、分隔条、打开的菜单 / 拾色器(层级 0) |
`UiWidget::PaintDebugBounds(g, scale, depth)` 递归整棵控件树 —— 凡已上树的控件**零成本**获得边界。
### 2.4 视觉与实现约束
* **只描边、不填充**(与 Android 一致):填充会在几十个嵌套矩形上层层叠加把内容糊住。
* 层级配色:0 青蓝 `#29B6F6`(2px)/ 1 品红 `#E040FB` / 2 琥珀 `#FFB300` / ≥3 绿 `#66BB6A`(1px)。
* 线宽按**设备像素**固定(不乘 `scale`),并临时 `PixelOffsetModeNone` + 关抗锯齿 + 矩形对齐整像素,
保证 1px 就是 1px。
* 绘制位置:`Render` 的**最后一步**(加载遮罩之后)。⚠️ **绝不进入任何位图缓存**
(`PlotView` 4 层缓存、`SignalPanel` 行位图缓存)—— 否则关掉开关后线条会残留。
* `fullFrame` 追加 `m_showLayoutBounds`:开启时必须整帧回送,否则"只回送绘图区"的快路径会让
装饰区的边界停在旧帧上。
## 3. 验证
| 项 | 结果 |
| --- | --- |
| `make release` / `make debug` | 零 error / 零 warning |
| **新增离屏探针 `.preflight/lbcheck`** | **21 / 21 PASS** |
| `Tools/rendercheck` 8 场景**工具栏以下区域**哈希 | 与改造前**逐一相同**(改动只落在工具栏带内) |
| `Tools/rendercheck` 7 条功能断言 | 7 / 7 PASS |
| `Tools/uicheck` | 69 / 69 |
| `TargetCheck.exe` | 897 024 B(< 900 KB) |
新增断言覆盖:齿轮存在 / 在「导入文件」左侧 / 同高 / 更窄;开机后无调试色;
开启后青蓝 24 930 px、品红 19 734 px;**关闭后逐像素回到开启前**;菜单 scope、锚定贴合按钮、
宽度未被撑大、条目初始未勾选、点击翻转开关、菜单保持打开。
## 4. 后续可改进(用户:"先落地实现然后再进一步改进")
1. 边界左上角标短 tag(`row#12` / `lane3`)—— 需要处理 GDI 文本不受 GDI+ 裁剪约束的问题。
2. `PlotView` 的浮动面板(`EdgePanelLayout` 里的 `panel/title/close/…`)目前未上报;
它们已经是集中结构,插入一个循环即可。
3. `SignalPanel` 的复选框 / 徽标 / ✕ 等行内子块(现在只到"行"这一层)。
4. 开关状态目前**不持久化**(重启恢复为关闭);若要持久化需要引入配置存储。
5. 菜单只有一项;后续设置项按 `HEADER` 分组追加即可。
---
# 第七阶段计划(2026-10-28 第九轮):边界观感重做 + 覆盖补全 + 几何同源
> 用户反馈:① 设置齿轮按钮的颜色应与「导入文件」一致;
> ② 边界绘制"就是实心矩形、粗细不一",希望是**四角粗、其他边细**;
> ③ 按上一轮总结的覆盖率问题着手改进(很多元素没有边界)。
## 任务清单
| # | 任务 | 验证手段 |
| --- | --- | --- |
| P7-1 | 齿轮按钮改为主按钮(`primary = true`),与「导入文件」同色 | 探针断言两按钮底色一致 |
| P7-2 | 边界的描边改为**四角粗(3–4px)+ 四边细(1px)**的包角样式;只描边不填充 | 逐像素探针 + 人工核对 |
| P7-3 | 层级 > 0 的 box 内缩 1px,避免与父矩形共边时叠成粗线 | 同上 |
| P7-4 | 协议从「组件自己画」改为「组件**收集 box**、宿主统一画」(`CollectDebugBoxes`) | 编译 + 探针 |
| P7-5 | `SignalPanel`:行内元素全部上报(复选框/色块/名称/GX 徽标/✕/折起三角/组名/计数)+ 筛选框/清空✕/滚动条/滑块/样式区 5 行与行内控件/目标行/编辑框 | 探针断言 box 数量 |
| P7-6 | `PlotView`:GX 徽标、装饰层矩形、图例条目(托底/色块/文本)、5 类浮动面板全部字段 | 同上 |
| P7-7 | `Toolbar`:按钮内部**文本/图标**矩形(新增 `UiButtonWidget::ContentRect()`) | 同上 |
| P7-8 | `PopupMenu` / `ColorPickerPopup`:条目、滚动区/轨道/滑块、子菜单;拾色器各控件 | 同上 |
| P7-9 | `RowRect` 从 **O(row) → O(1)**(行顶前缀和 `m_rowTop`) | 行为不变(像素哈希) |
| P7-10 | `PanelBounds()` 收敛 6 处重复三元表达式 | 编译 |
| P7-11 | 三份文档 + `agent_mem.md` 同步;`TargetCheck.exe` < 900 KiB | 构建 |
## 风险与对策
| 风险 | 对策 |
| --- | --- |
| 前缀和与行集合短暂不一致 | `RowRect` / `ContentOffsetBeforeRow` 检测长度后**退回逐行累加**(正确性优先) |
| box 数量暴涨导致调试帧变慢 | 只在开关打开时执行;标签设**尺寸门槛**(< 56×16 逻辑像素不画标签) |
| 调试层污染位图缓存 | 仍只在 `Render` 最后一步叠加,探针 `bounds-off-restores-pixels` 钉住 |
| 浮层 box 需要读取私有几何 | 给两个浮层加 `CollectDebugBoxes`,几何仍由它们自己给出(不外泄成员) |
## 完成判据
* `make release` / `make debug` 零 error 零 warning;
* 「工具栏带以下」区域哈希与上一轮**逐一相同**(证明改动只落在工具栏带内);
* `lbcheck` 21/21、`rendercheck` FUNC 7/7、`uicheck` 69/69;
* 打开开关后:每行的复选框/色块/名称/GX 徽标/✕、每个网格的 GX 徽标与图例、每个浮层控件都有独立包角框。