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

39 KiB
Raw Blame History

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 重构(全部提交只留在本地)

分支操作取证(可直接复跑)

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)
场景缓存(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/<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

另:临时探针此前的退出崩溃其实来自"探针自己又调了一次 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 —— 否则文本与容器同色。