TabBar 的标签 / 「+」/ 覆盖层拆成控件,手势语义原样保留(自行判双击、3px 拖拽阈值、
关闭 ✕ 的「移开取消」、拖拽排序的 drop 位置),公开 API 未变:
- 新增(TabBar.cpp 内实现的内部控件)
* UiTabItemWidget:单格标签的绘制(底色/描边/激活指示条/文本或重命名输入框/关闭 ✕),
关闭按钮热区由基类的悬停点自行判定。
* UiAddWidget:「+」按钮绘制与悬停配色。
* UiOverlayWidget:命中透明的覆盖层,最后绘制「拖拽插入指示条 + 底部分隔线」
(必须晚于标签与「+」,否则会被它们压住)。
* UiTabRootWidget:只铺标签栏底色。
- TabBar 移除 m_hoverIndex / m_hoverClose / m_hoverAdd 三个手工下标,
改为 dispatch MOUSE_MOVE 后由 root->TakeDirty() 判断是否需要重绘。
- 绘制顺序保持:底色 → 各标签 → 「+」→ 拖拽指示条 → 底部分隔线(与旧实现逐像素一致)。
- UiWidget 新增 HoverPoint(x,y):派生控件据此自行判断「悬停在自身内部哪个热区」。
验证:
- 像素:8 场景哈希与迁移前逐一相同(A 2166933…、H 23F390D…)。
- 交互断言(全部 PASS):
* tabbar-hover-marks-dirty (1,0) —— 进入标签只标记一次脏区;
* tabbar-add-creates-tab (1 -> 2) —— 「+」新建标签;
* tabbar-click-selects-tab (active=0) —— 点击标签切换激活页;
* tabbar-close-removes-tab (2 -> 1) —— 点击 ✕ 关闭标签。
- Tools/uicheck 69 passed / 0 failed。
- make release / make debug 零警告;Target.exe 886,784 B(< 900KB)。
15 KiB
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 | 离屏回归网 Tools/uicheck(入库) |
✅ 完成(62 项断言全绿) |
| P1 | UiLayout + UiWidget 基础层 |
✅ 完成(编译零警告,uicheck 覆盖) |
| P2 | PlotViewPaint.cpp 零行为分片 |
✅ 完成(逐像素 0 差异,见 §2/§3) |
| P3 | 迁移 chrome(Toolbar / TabBar / 菜单 / 拾色器 / 列表行) | 🔄 Toolbar 已完成,其余待做 |
| P4 | 迁移 5 个浮动面板 | ⏳ 待做 |
| P5 | 波形管线包成叶子 surface + 共享 DIB | 🔄 UiSurface + 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逐节点下发同一时间戳并汇总"是否仍有动画"。 - 悬停:进入 / 离开各只触发一次,跨子节点迁移时正确配对。
过程中修掉的两个自身缺陷
UiWidget缺AdvanceAnimationSelf/AnimationActiveSelf两个 protected 虚函数(.cpp已引用、头文件漏声明)。- 最初的派发实现是"遍历同层兄弟、未消费就继续" ⇒ 一个点击可能同时被视觉上位于下方的控件处理; 已改为"只沿命中链派发 + 祖先兜底"。回归网里对应的断言从"绝对次数"改为增量断言 (绝对次数会被前面用例计数污染,本轮踩过一次)。
验证
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 / SceneHaloRefSceneContentHash / SceneGeometryHash / SceneDecorHashCollectSceneStyles / FindStyleOnlyLanes / CollectSceneDerived / RepaintSceneCachePatch |
474 |
PlotViewPaint.cpp |
只剩绘制(Paint* / Render* / 输入框绘制) |
9 080 → 6 252(−31%) |
验证(关键)
make release TARGET_FILE_NAME=TargetCheck零警告;Target.exe873 984 → 875 008 B(+1 KB, 属分片后各 TU 的对齐差异;仍远低于 900 KB 阈值)。- 逐像素 A/B:临时写了一个渲染哈希探针(仓库外),对三个场景渲染后备缓冲区并取 FNV 哈希:
- A 默认全览;B 深放大 + 自由标记(含固定点)+ 均值滤波 + 弱峰调整 + 时间光标;C 再平移一帧(触发宽基底滚动/补条)。
- 分片前:
A 2166933039626F66 / B C444ECFEB18FD73D / C 7984446D8E7A4418 - 分片后:完全相同(三个场景逐一一致)。
- 该探针未入库(一次性验证用),结论记录在此以便复现。
Tools/uicheck62/62 全绿。- 顶层注释从"四个分片"更新为"六个分片(按关注点)",并写明"
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。
验证
make release TARGET_FILE_NAME=TargetCheck零警告;Target.exe877 568 B(< 900 KB)。- 逐像素 A/B:同三场景哈希与迁移前完全一致
(
A 2166933039626F66 / B C444ECFEB18FD73D / C 7984446D8E7A4418)。 Tools/uicheck62/62。- 顶层注释更新为"七个分片(按关注点)",并标明
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 对应断言改为"裁剪 ⊇ 自身矩形且不超出外扩范围"。
验证
make release/make debug零警告;Target.exe881 664 B(< 900 KB)。- 逐像素 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 移出工具栏)。 - 功能验证(证明点击真的翻译成动作,而不是"看起来没坏"):
FUNC locate-cursor-button PASS (1291,73)—— 用控件自身矩形定位按钮;FUNC click-cursor-changes-mode PASS (1 -> 2)—— 点击「光标」按钮后光标模式 单→双;FUNC release-outside-does-not-fire PASS (2 -> 2)—— 按下后移出再松开,不触发动作。
Tools/uicheck62/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 32bppCreateDIBSection(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保护保留为防御性正确做法。
验证
- 8 场景像素哈希与 DIB 改造前逐一相同;3 条工具栏功能断言仍 PASS。
Tools/uicheck69/69(新增 7 条UiSurface断言:bits 按 stride 偏移、矩形夹取、 完全在外返回无效表面、version/scale传递)。make release/make debug零警告;Target.exe883 200 B(< 900 KB)。
下一步(接手者请从这里开始)
- P3 续:
TabBar(标签 + 「+」+ 重命名内联 + 拖拽排序)→PopupMenu/ColorPicker条目行。 建议把"标签"做成UiButtonWidget的派生(含 ✕ 热区与拖拽),TabBar只保留重命名编辑与排序语义。 - P4:5 个浮动面板(
EdgePanelLayoutFor651 行 → 真控件;Stack + Abs + Measure-driven), 消掉rowRefs/rowChecks/rowPoints/rowOrder/rowLevelTracks/...平行数组。 ⚠️ 每个面板单独一轮;吸顶 / 滚动 / 滚动条 / 滑块复位 / 行悬停压暗 / 开关联动是 6 轮补丁叠出的隐式约定。 - P5(2):把 56 处
InvalidateRect收敛为Host::Invalidate(rect)的区域并集, 并把UiWidget的脏区树接到Application::Render(一次回送)。 ⚠️ 回送仍必须有无条件else(历史:某分支缺兜底 ⇒ 一个像素都没回送)。 - 每阶段单独提交,判据固定为:
uicheck全绿 + 8+ 场景像素哈希一致 + 功能断言。