From 24706c906a2d8de7085aa3a69bccf74fcdcf018d Mon Sep 17 00:00:00 2001 From: "DeepSeek V4.1 Flash" <> Date: Wed, 28 Oct 2026 16:28:57 +0800 Subject: [PATCH] =?UTF-8?q?feat(UI):=20=E5=B8=83=E5=B1=80=E8=BE=B9?= =?UTF-8?q?=E7=95=8C=E6=94=B9=E4=B8=BA=E5=9B=9B=E8=A7=92=E7=B2=97/?= =?UTF-8?q?=E5=9B=9B=E8=BE=B9=E7=BB=86=E5=8C=85=E8=A7=92=20+=20=E5=85=A8?= =?UTF-8?q?=E5=85=83=E7=B4=A0=E8=A6=86=E7=9B=96=20+=20=E5=87=A0=E4=BD=95?= =?UTF-8?q?=E5=90=8C=E6=BA=90=EF=BC=88RowRect=20O(1)=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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 第九轮」硬规则与踩坑记录。 --- .../Application/Src/ApplicationCommands.cpp | 31 +-- Components/PlotView/Inc/PlotView.hpp | 13 +- Components/PlotView/Src/PlotViewPaint.cpp | 91 +++++++- Components/Shared/Inc/ColorPickerPopup.hpp | 10 + Components/Shared/Inc/PopupMenu.hpp | 10 + Components/Shared/Inc/UiButtonWidget.hpp | 10 + Components/Shared/Inc/UiComponent.hpp | 19 +- Components/Shared/Inc/UiDebugBounds.hpp | 86 ++++++- Components/Shared/Inc/UiWidget.hpp | 8 +- Components/Shared/Src/ColorPickerPopup.cpp | 30 +++ Components/Shared/Src/PopupMenu.cpp | 29 +++ Components/Shared/Src/UiButtonWidget.cpp | 54 +++-- Components/Shared/Src/UiComponent.cpp | 6 +- Components/Shared/Src/UiDebugBounds.cpp | 210 ++++++++++++++---- Components/Shared/Src/UiWidget.cpp | 6 +- Components/SignalPanel/Inc/SignalPanel.hpp | 38 +++- Components/SignalPanel/Src/SignalPanel.cpp | 155 +++++++++++-- .../SignalPanel/Src/SignalPanelMenu.cpp | 10 +- Components/TabBar/Inc/TabBar.hpp | 7 +- Components/TabBar/Src/TabBar.cpp | 14 +- Components/Toolbar/Inc/Toolbar.hpp | 7 +- Components/Toolbar/Src/Toolbar.cpp | 22 +- Documents/UI_Refactor_Plan.md | 40 ++++ Documents/UI_Refactor_Progress.md | 98 ++++++++ Documents/UI_Refactor_Report.md | 89 ++++++++ Documents/agent_mem.md | 73 ++++++ 26 files changed, 1003 insertions(+), 163 deletions(-) diff --git a/Components/Application/Src/ApplicationCommands.cpp b/Components/Application/Src/ApplicationCommands.cpp index cef8914..0ad8637 100644 --- a/Components/Application/Src/ApplicationCommands.cpp +++ b/Components/Application/Src/ApplicationCommands.cpp @@ -1156,29 +1156,30 @@ namespace jdi void Application::PaintLayoutBounds(gd::Graphics& g, int width, int height) { - // 客户区外框:便于确认"窗口边缘 == 最外层边界"。 - UiDebugPaintBounds(g, gd::RectF(0.0f, 0.0f, static_cast(width), - static_cast(height)), 0, m_scale); + UiDebugBoxList boxes; - // 组件区域(底 → 顶;组件自己负责再上报内部结构)。 + // 客户区外框:便于确认"窗口边缘 == 最外层边界"。 + boxes.Add(gd::RectF(0.0f, 0.0f, static_cast(width), static_cast(height)), 0, + L"window"); + + // 组件区域(底 → 顶;组件自己负责补齐内部结构)。 for (const ComponentEntry& entry : m_components) { if (entry.component != nullptr) { - entry.component->PaintDebugBounds(g, m_scale); + entry.component->CollectDebugBoxes(boxes); } } - // 不属于任何组件的两块:面板分隔条(由宿主直接填充色块)、菜单/拾色器(浮层)。 - UiDebugPaintBounds(g, m_splitterRect, 0, m_scale); - if (m_menu.IsOpen()) - { - UiDebugPaintBounds(g, m_menu.Rect(), 0, m_scale); - } - if (m_colorPicker.IsOpen()) - { - UiDebugPaintBounds(g, m_colorPicker.Rect(), 0, m_scale); - } + // 不属于任何组件的两块:面板分隔条(由宿主直接填充色块)、菜单 / 拾色器(浮层)。 + boxes.Add(m_splitterRect, 0); + m_menu.CollectDebugBoxes(boxes); + m_colorPicker.CollectDebugBoxes(boxes); + + UiDebugPaintStyle style; + style.font = m_fonts.uiSmall; + style.scale = m_scale; + UiDebugPaintBoxList(g, boxes, style); } void Application::DispatchMenuResult(const MenuResult& result) diff --git a/Components/PlotView/Inc/PlotView.hpp b/Components/PlotView/Inc/PlotView.hpp index 6d24f7f..753b38c 100644 --- a/Components/PlotView/Inc/PlotView.hpp +++ b/Components/PlotView/Inc/PlotView.hpp @@ -25,6 +25,8 @@ namespace jdi { + class UiDebugBoxList; + /** * @brief 后台派生曲线计算完成后由 `PlotView` 投递给宿主的自定义消息。 * @@ -205,15 +207,14 @@ namespace jdi void Paint(gd::Graphics& g) override; /** - * @brief 绘制绘图区区域与**每个网格波形区**的布局边界(调试用)。 + * @brief 收集绘图区区域、**每个网格**、GX 徽标、图例条目与浮动面板的布局边界(调试用)。 * - * 只上报“布局结构”(区域 + 网格),不上报曲线/胶囊/提示这类随数据变化的瞬态几何: - * 后者每帧不同,画出来只是噪声。 + * 只上报“布局结构”,**不上报**曲线折线、取值胶囊、悬停提示这类随数据/鼠标逐帧变化的 + * 瞬态几何 —— 它们每帧都不同,叠上去只是噪声。 * - * @param [IN,OUT] g 绘图上下文。 - * @param [IN] scale DPI 缩放系数。 + * @param [IN,OUT] boxes 收集器。 */ - void PaintDebugBounds(gd::Graphics& g, float scale) override; + void CollectDebugBoxes(UiDebugBoxList& boxes) override; /** * @brief 处理鼠标移动(含拖拽)。 diff --git a/Components/PlotView/Src/PlotViewPaint.cpp b/Components/PlotView/Src/PlotViewPaint.cpp index 698dd26..c7af690 100644 --- a/Components/PlotView/Src/PlotViewPaint.cpp +++ b/Components/PlotView/Src/PlotViewPaint.cpp @@ -19,6 +19,7 @@ #include "PlotViewInternal.hpp" #include "UiDebugBounds.hpp" +#include "UiLayout.hpp" namespace jdi { @@ -562,14 +563,92 @@ namespace jdi UiText(g, message, m_fonts->ui, m_area, m_theme->textMuted, TextAlign::CENTER); } - void PlotView::PaintDebugBounds(gd::Graphics& g, float scale) + void PlotView::CollectDebugBoxes(UiDebugBoxList& boxes) { - UiComponent::PaintDebugBounds(g, scale); - for (const LaneVisual& lane : m_lanes) + UiComponent::CollectDebugBoxes(boxes); + + // 层级 1:每个网格的波形区;层级 2:该网格的 GX 编号徽标与装饰层矩形。 + for (std::size_t index = 0; index < m_lanes.size(); ++index) { - // 层级 1:网格波形区。不画曲线/取值胶囊/提示这类随数据变化的瞬态几何 - //(它们每帧都不同,叠上去只是噪声)。 - UiDebugPaintBounds(g, lane.rect, 1, scale); + const LaneVisual& lane = m_lanes[index]; + boxes.Add(lane.rect, 1, L"lane " + std::to_wstring(index + 1)); + + // GX 编号徽标:矩形由 `LaneBadgeRect` 统一给出(绘制与命中都用它)。 + boxes.Add(LaneBadgeRect(static_cast(index)), 2, L"G"); + + // 装饰层矩形(图例托底 / “无信号” / “更多”):绘制时已登记在 `m_decorRects`。 + for (const gd::RectF& decor : m_decorRects) + { + if (decor.Y >= lane.rect.Y && decor.Y < lane.rect.Y + lane.rect.Height) + { + boxes.Add(decor, 2); + } + } + } + + // 层级 2/3:图例条目(托底 + 色块 + 文本),绘制时已登记在 `m_legendHits`。 + for (const LegendHit& hit : m_legendHits) + { + boxes.Add(hit.rect, 2, L"legend"); + boxes.Add(hit.swatch, 3); + boxes.Add(hit.text, 3); + } + + // 层级 2/3/4:5 类实验性功能浮动面板的内部控件。 + // 这些矩形由 `EdgePanelLayoutFor` 统一算出(绘制与命中共用),一个循环即可全部上报。 + for (std::size_t index = 0; index < m_lanes.size(); ++index) + { + const int laneIndex = static_cast(index); + for (int kindValue = static_cast(EdgePanelKind::COUNT); + kindValue <= static_cast(EdgePanelKind::MARK); ++kindValue) + { + const EdgePanelLayout layout = + EdgePanelLayoutFor(laneIndex, static_cast(kindValue)); + if (!UiRectHasArea(layout.panel)) + { + continue; + } + boxes.Add(layout.panel, 2, L"panel"); + boxes.Add(layout.title, 3, L"title"); + boxes.Add(layout.close, 3, L"x"); + boxes.Add(layout.disclosure, 3); + boxes.Add(layout.badge, 3, L"G"); + boxes.Add(layout.edit, 3); + boxes.Add(layout.button, 3); + boxes.Add(layout.content, 3, L"content"); + boxes.Add(layout.scrollTrack, 3, L"scroll"); + boxes.Add(layout.scrollThumb, 4); + boxes.Add(layout.opacityTrack, 3, L"track"); + boxes.Add(layout.opacityValue, 3, L"value"); + boxes.Add(layout.showDelta, 3); + boxes.Add(layout.showEnum, 3); + + boxes.AddAll(layout.rowChecks, 4); + boxes.AddAll(layout.rowPoints, 4); + boxes.AddAll(layout.rowIterations, 4); + boxes.AddAll(layout.rowStats, 4); + boxes.AddAll(layout.rowLevelTracks, 4); + boxes.AddAll(layout.rowLevelValues, 4); + boxes.AddAll(layout.rowIterTracks, 4); + boxes.AddAll(layout.rowIterValues, 4); + boxes.AddAll(layout.rowLevelResets, 4); + boxes.AddAll(layout.rowIterResets, 4); + boxes.AddAll(layout.rowNameRects, 4); + boxes.AddAll(layout.rowNameCloses, 4); + + for (const EdgePanelLayout::MarkerGroup& group : layout.markerGroups) + { + boxes.Add(group.header, 3, L"group"); + boxes.Add(group.tableHeader, 3); + boxes.Add(group.triangle, 4); + boxes.Add(group.close, 4, L"x"); + } + for (const EdgePanelLayout::MarkerRow& row : layout.markerRows) + { + boxes.Add(row.rect, 4, L"marker"); + boxes.Add(row.close, 4, L"x"); + } + } } } diff --git a/Components/Shared/Inc/ColorPickerPopup.hpp b/Components/Shared/Inc/ColorPickerPopup.hpp index 663da20..66976b2 100644 --- a/Components/Shared/Inc/ColorPickerPopup.hpp +++ b/Components/Shared/Inc/ColorPickerPopup.hpp @@ -15,6 +15,8 @@ namespace jdi { + class UiDebugBoxList; + /** * @brief 浮动式 RGB 自定义颜色拾取器。 * @@ -175,6 +177,14 @@ namespace jdi */ const gd::RectF& Rect() const { return m_rect; } + /** + * @brief 收集拾色器与其内部控件的布局边界(调试用)。 + * + * 面板未打开时什么都不登记。 + * @param [IN,OUT] boxes 收集器。 + */ + void CollectDebugBoxes(UiDebugBoxList& boxes) const; + private: /** * @brief 面板内可进行内联文本输入的字段。 diff --git a/Components/Shared/Inc/PopupMenu.hpp b/Components/Shared/Inc/PopupMenu.hpp index d5c5efa..1720017 100644 --- a/Components/Shared/Inc/PopupMenu.hpp +++ b/Components/Shared/Inc/PopupMenu.hpp @@ -16,6 +16,8 @@ namespace jdi { + class UiDebugBoxList; + /** * @brief 弹出菜单的打开来源,用于把选择结果派发回正确的组件。 */ @@ -231,6 +233,14 @@ namespace jdi */ const gd::RectF& Rect() const { return m_rect; } + /** + * @brief 收集菜单与其内部结构的布局边界(调试用)。 + * + * 菜单未打开时什么都不登记。条目、滚动区、滚动条与二级子菜单各自成为独立的 box。 + * @param [IN,OUT] boxes 收集器。 + */ + void CollectDebugBoxes(UiDebugBoxList& boxes) const; + /** * @brief 菜单是否因内容过长而启用了滚动(条目区可滚动、前导标题与尾部条目固定)。 * @return 启用滚动返回 true。 diff --git a/Components/Shared/Inc/UiButtonWidget.hpp b/Components/Shared/Inc/UiButtonWidget.hpp index 0004bda..4a18f86 100644 --- a/Components/Shared/Inc/UiButtonWidget.hpp +++ b/Components/Shared/Inc/UiButtonWidget.hpp @@ -68,6 +68,16 @@ namespace jdi /** @brief 放弃当前按下(指针离开窗口等不会再收到抬起的情形)。 */ void CancelPress(); + /** + * @brief 按钮**内容**(文本或图标)实际占用的矩形(客户区坐标)。 + * + * 内容按居中绘制,因此它的矩形与按钮矩形不同:调试层用它显示"文字/图标本身的边界"。 + * 未摆放或文本为空时返回空矩形。 + * + * @return 内容矩形(客户区坐标)。 + */ + gd::RectF ContentRect() const; + /** @brief 点击回调(在自身范围内松开时触发)。 */ std::function onClick; diff --git a/Components/Shared/Inc/UiComponent.hpp b/Components/Shared/Inc/UiComponent.hpp index 45a6f7c..5e054bf 100644 --- a/Components/Shared/Inc/UiComponent.hpp +++ b/Components/Shared/Inc/UiComponent.hpp @@ -10,6 +10,8 @@ namespace jdi { + class UiDebugBoxList; + /** * @brief 顶层 UI 组件的统一协议(**纯接口,无状态**)。 * @@ -75,21 +77,20 @@ namespace jdi } /** - * @brief 绘制自身的**布局边界**(调试用,Android「Show layout bounds」风格)。 + * @brief 收集本组件的**布局边界**(调试用,「显示布局边界」开启时由宿主调用)。 * - * 默认实现只画 `Area()` 一个矩形;自身内部还有可辨识子块(控件树节点、列表行、 - * 网格区域……)的组件可以覆盖它,先调本基类再补画**层级更深**的矩形。 + * 默认实现只登记 `Area()`;自身内部还有可辨识子块(控件树节点、列表行、网格、 + * 复选框、徽标、✕、面板控件……)的组件覆盖它,先调本基类再补登记**层级更深**的 box。 * - * 之所以把入口放在这里而不是让宿主逐个点名:宿主只持有 `UiComponent*` 的表, - * 遍历一次就能覆盖全部顶层区域;组件内部结构由组件自己上报(它才知道几何)。 + * 之所以把入口放在这里而不是让宿主逐个点名:宿主只持有 `UiComponent*` 的组件表, + * 遍历一次就能覆盖全部顶层区域;组件内部结构由组件自己上报(只有它知道几何)。 * * ⚠️ 只在宿主开启「显示布局边界」时才被调用 —— 平时**零开销**(不进任何绘制路径、 - * 不进任何缓存)。实现时**禁止**写进自持位图缓存,否则关掉开关后线条会残留。 + * 不进任何缓存)。实现时**禁止**把几何写进自持位图缓存,否则关掉开关后线条会残留。 * - * @param [IN,OUT] g 绘图上下文(宿主绘制的最后一步,覆盖在所有内容之上)。 - * @param [IN] scale DPI 缩放系数。 + * @param [IN,OUT] boxes 收集器(宿主每帧新建,全程只在开关打开时存在)。 */ - virtual void PaintDebugBounds(gd::Graphics& g, float scale); + virtual void CollectDebugBoxes(UiDebugBoxList& boxes); }; } // namespace jdi diff --git a/Components/Shared/Inc/UiDebugBounds.hpp b/Components/Shared/Inc/UiDebugBounds.hpp index c8706de..fab63ed 100644 --- a/Components/Shared/Inc/UiDebugBounds.hpp +++ b/Components/Shared/Inc/UiDebugBounds.hpp @@ -5,7 +5,10 @@ #include #include +#include #include +#include +#include #include "Theme.hpp" @@ -13,7 +16,7 @@ namespace jdi { /** - * @brief 布局边界描边颜色(按层级取色,风格参考 Android 的「Show layout bounds」)。 + * @brief 布局边界描边颜色(按层级取色)。 * * 层级只用于**区分归属**,不代表重要性:层级越深说明越靠近叶子控件。 * @@ -22,25 +25,84 @@ namespace jdi */ std::uint32_t UiDebugBoundsColor(int depth); + /** + * @brief 一个待显示的布局边界 box。 + * + * 这里刻意用**值类型**而不是控件基类:`PlotView` 的 4 层位图缓存、`SignalPanel` 的 + * 行位图缓存都要求"绘制由组件自己负责",把几何也塞进继承体系会和缓存体系打架。 + * box 只描述"哪里有一个矩形、属于第几层、叫什么名字",因此**几何可以被绘制、命中与 + * 调试共用**,也不需要任何虚函数或生命周期管理。 + */ + struct UiDebugBox + { + gd::RectF rect; /**< 矩形(客户区坐标)。 */ + int depth = 0; /**< 层级(决定配色与角的粗细)。 */ + std::wstring tag; /**< 可选短标签(空 = 不画标签)。 */ + }; + + /** + * @brief box 收集器(值语义容器)。 + * + * 由宿主每帧构造一次、交给各组件填充、再一次性画完 —— 全程只在「显示布局边界」打开时执行。 + */ + class UiDebugBoxList + { + public: + /** @brief 清空(复用同一实例时调用)。 */ + void Clear() { m_boxes.clear(); } + /** @brief 追加一个 box(空矩形自动忽略)。 */ + void Add(const gd::RectF& rect, int depth, std::wstring tag = std::wstring()); + /** @brief 追加一组同层级的 box(空矩形逐个忽略)。 */ + void AddAll(const std::vector& rects, int depth); + /** @brief 是否为空。 */ + bool Empty() const { return m_boxes.empty(); } + /** @brief box 数量。 */ + std::size_t Count() const { return m_boxes.size(); } + /** @brief 只读访问。 */ + const std::vector& Items() const { return m_boxes; } + + private: + std::vector m_boxes; + }; + + /** @brief 绘制 box 列表所需的附加资源。 */ + struct UiDebugPaintStyle + { + HFONT font = nullptr; /**< 标签字体(`nullptr` = 一律不画标签)。 */ + float scale = 1.0f; /**< DPI 缩放(只用于标签排版,不用于线宽)。 */ + }; + /** * @brief 绘制一个矩形的布局边界。 * - * 视觉规则(Android「Show layout bounds」风): - * * 层级 0(顶层组件区域):2 设备像素描边 + 青蓝; - * * 层级 1(控件节点):1 设备像素描边 + 品红; - * * 层级 ≥2(内部子块):1 设备像素描边 + 琥珀 / 绿。 - * * **只描边、不填充** —— 填充会在几十个嵌套矩形上层层叠加,把内容糊成一片。 + * 视觉规则(**四角粗、四边细**,便于一眼定位元素的实际边界): + * * 四条边固定 1 设备像素; + * * 四个角额外加粗(层级 0 为 4px,其余 3px),每个角沿水平与垂直方向各延伸一段 + * `cornerLen`,形成 `⌜ ⌝ ⌞ ⌟` 的包角; + * * 只描边、不填充 —— 填充会在几十个嵌套矩形上层层叠加,把内容糊成一片; + * * 层级 > 0 的矩形整体内缩 1 像素,避免与父矩形共边时叠成一条"更粗的边"。 * - * ⚠️ **描边宽度按设备像素固定**(不乘 `scale`):这类调试线一旦随 DPI 变粗就会盖住内容。 - * ⚠️ 描绘边时**临时切到 `PixelOffsetModeNone` + 无抗锯齿**并把矩形对齐到整像素, - * 否则 1px 线会被抗锯齿摊成 2px 灰边(难以判断真实边界)。 + * ⚠️ **线宽按设备像素固定**(不乘 `scale`):调试线一旦随 DPI 变粗就会盖住内容。 + * ⚠️ 描绘边时临时切到 `PixelOffsetModeNone` + 无抗锯齿,并把矩形对齐到整像素, + * 否则 1px 线会被抗锯齿摊成 2px 灰边(无法判断真实边界)。 * * @param [IN,OUT] g 绘图上下文。 * @param [IN] rect 目标矩形(客户区坐标)。 - * @param [IN] depth 层级(决定颜色与粗细,见上)。 - * @param [IN] scale DPI 缩放系数(只用于对齐,不用于线宽)。 + * @param [IN] depth 层级(决定配色、角的粗细与内缩)。 */ - void UiDebugPaintBounds(gd::Graphics& g, const gd::RectF& rect, int depth, float scale); + void UiDebugPaintBounds(gd::Graphics& g, const gd::RectF& rect, int depth); + + /** + * @brief 一次性画完整个 box 列表(在宿主绘制的最后一步调用)。 + * + * 按层级从浅到深排序后绘制:深层的彩色包角因此不会被父级的边压住。 + * + * @param [IN,OUT] g 绘图上下文。 + * @param [IN] boxes box 列表。 + * @param [IN] style 字体与缩放(标签用)。 + */ + void UiDebugPaintBoxList(gd::Graphics& g, const UiDebugBoxList& boxes, + const UiDebugPaintStyle& style); } // namespace jdi diff --git a/Components/Shared/Inc/UiWidget.hpp b/Components/Shared/Inc/UiWidget.hpp index c7db84f..d86e03b 100644 --- a/Components/Shared/Inc/UiWidget.hpp +++ b/Components/Shared/Inc/UiWidget.hpp @@ -17,6 +17,7 @@ namespace jdi struct Theme; struct UiFonts; + class UiDebugBoxList; /** * @brief 裁剪时允许超出控件矩形的像素数(逻辑像素 × scale)。 @@ -208,16 +209,15 @@ namespace jdi bool DispatchRedraw(UiEvent& event); /** - * @brief 递归绘制本节点与全部子节点的布局边界(调试用)。 + * @brief 递归收集本节点与全部子节点的布局边界(调试用)。 * * 只读 `m_bounds`,不参与脏区、不进缓存 ⇒ 关闭开关时完全不执行。 * 悬停/按下/选中态一律不改变边界(那是外观而非布局),因此不需要额外状态。 * - * @param [IN,OUT] g 绘图上下文。 - * @param [IN] scale DPI 缩放系数(只用于对齐,不用于线宽)。 + * @param [IN,OUT] boxes 收集器。 * @param [IN] depth 层级(0 = 调用者自身,子节点依次 +1,决定描边颜色)。 */ - void PaintDebugBounds(gd::Graphics& g, float scale, int depth = 0) const; + void CollectDebugBoxes(UiDebugBoxList& boxes, int depth) const; /** @brief 局部失效:与自身矩形求交后向根冒泡。 */ virtual void Invalidate(const gd::RectF& rect); diff --git a/Components/Shared/Src/ColorPickerPopup.cpp b/Components/Shared/Src/ColorPickerPopup.cpp index 02b6580..db04764 100644 --- a/Components/Shared/Src/ColorPickerPopup.cpp +++ b/Components/Shared/Src/ColorPickerPopup.cpp @@ -1,6 +1,7 @@ #include "ColorPickerPopup.hpp" #include "UiControls.hpp" +#include "UiDebugBounds.hpp" #include #include #include @@ -1093,4 +1094,33 @@ namespace jdi } } + void ColorPickerPopup::CollectDebugBoxes(UiDebugBoxList& boxes) const + { + if (!m_open) + { + return; + } + + // 层级 0:面板浮层整体。 + boxes.Add(m_rect, 0, L"picker"); + + // 层级 1:面板内部的固定控件。 + boxes.Add(m_titleRect, 1, L"title"); + boxes.Add(m_previewRect, 1); + boxes.Add(m_hexRect, 1, L"hex"); + boxes.Add(m_gamutRect, 1, L"gamut"); + boxes.Add(m_hueRect, 1, L"hue"); + + // 层级 2:三条分量滑块的轨道与数值。 + for (const gd::RectF& track : m_tracks) + { + boxes.Add(track, 2, L"track"); + } + for (const gd::RectF& value : m_values) + { + boxes.Add(value, 2); + } + + boxes.Add(EditFieldRect(), 2, L"edit"); + } } // namespace jdi diff --git a/Components/Shared/Src/PopupMenu.cpp b/Components/Shared/Src/PopupMenu.cpp index e0b5884..3096dfb 100644 --- a/Components/Shared/Src/PopupMenu.cpp +++ b/Components/Shared/Src/PopupMenu.cpp @@ -5,6 +5,7 @@ #include "PopupMenuInternal.hpp" #include "UiControls.hpp" +#include "UiDebugBounds.hpp" namespace jdi { @@ -704,4 +705,32 @@ namespace jdi } + void PopupMenu::CollectDebugBoxes(UiDebugBoxList& boxes) const + { + if (!m_open) + { + return; + } + + // 层级 0:菜单浮层整体。 + boxes.Add(m_rect, 0, L"menu"); + + // 层级 1:每个条目(含标题 / 分隔线 / 色块行,它们的矩形都已算好)。 + for (const gd::RectF& item : m_itemRects) + { + boxes.Add(item, 1); + } + + // 层级 1/2:可滚动菜单的可视区、轨道与滑块。 + boxes.Add(m_scrollViewport, 1, L"view"); + boxes.Add(m_scrollBarRect, 2, L"track"); + boxes.Add(ScrollBarThumbRect(), 2); + + // 层级 0/1:二级子菜单。 + boxes.Add(m_subRect, 0, L"sub"); + for (const gd::RectF& item : m_subItemRects) + { + boxes.Add(item, 1); + } + } } // namespace jdi diff --git a/Components/Shared/Src/UiButtonWidget.cpp b/Components/Shared/Src/UiButtonWidget.cpp index 930b962..edb3673 100644 --- a/Components/Shared/Src/UiButtonWidget.cpp +++ b/Components/Shared/Src/UiButtonWidget.cpp @@ -15,7 +15,7 @@ namespace jdi /** @brief 图标按钮的宽度(逻辑像素):与其他按钮同高、略窄。 */ const float kIconButtonWidth = 40.0f; /** @brief 图标自身占用的边长(逻辑像素)。 */ - const float kIconSize = 18.0f; + const float kIconSize = 20.0f; /** @brief 按比例把一个颜色向目标色混合(与旧 Toolbar 的写法保持一致)。 */ std::uint32_t BlendToward(std::uint32_t base, std::uint32_t target, double ratio) @@ -24,38 +24,42 @@ namespace jdi } /** - * @brief 画一个齿轮图标(矢量:8 个齿 + 外环 + 轴孔环)。 + * @brief 画一个齿轮图标(矢量:8 个齿 + 外环 + 轴孔)。 * * 走 GDI+ 矢量而不是字体符号 `⚙`:该字符在不同回退字体里的大小与基线不一致, * 会出现"图标忽大忽小、垂直不居中"。抗锯齿保持开启(圆环/斜齿需要它才不难看)。 * * 几何全部按「半径占 `half` 的比例」推导,因此任意 DPI 下形状一致: - * 齿从环中心线(0.70)伸到最外圈(1.0),环描边宽度(0.30)刚好与齿根相接。 + * * 齿占**最外一圈**(0.66R → 1.0R),宽 0.30R —— 必须与外环外缘明显错开, + * 否则齿会被环的描边吃掉,看起来只剩一个"甜甜圈"; + * * 外环中心线 0.54R、笔宽 0.24R(覆盖 0.42R~0.66R),与齿根恰好相接; + * * 轴孔中心线 0.17R、笔宽 0.20R。 */ void PaintGearIcon(gd::Graphics& g, const gd::RectF& rect, std::uint32_t argb) { const float cx = rect.X + rect.Width * 0.5f; const float cy = rect.Y + rect.Height * 0.5f; const float half = std::min(rect.Width, rect.Height) * 0.5f; - if (half < 4.0f) + if (half < 5.0f) { return; } - const float ringRadius = half * 0.70f; // 外环的中心线半径 - const float ringWidth = half * 0.30f; // 外环描边宽度(与齿根相接) - const float toothWidth = half * 0.32f; // 齿宽 - const float hubRadius = half * 0.28f; // 轴孔中心线半径 - const float hubWidth = std::max(1.0f, half * 0.22f); + const float toothInner = half * 0.66f; // 齿根半径(= 外环外缘) + const float toothWidth = std::max(2.0f, half * 0.30f); + const float ringRadius = half * 0.54f; // 外环中心线半径 + const float ringWidth = std::max(1.6f, half * 0.24f); + const float hubRadius = half * 0.17f; + const float hubWidth = std::max(1.4f, half * 0.20f); const gd::Color color(argb); const gd::GraphicsState state = g.Save(); g.SetSmoothingMode(gd::SmoothingModeAntiAlias); g.TranslateTransform(cx, cy); - // 齿:从环中心线向外伸到最外圈(`-half` 到 `-ringRadius`)。 + // 齿:从外环外缘一直伸到最外圈。 gd::SolidBrush brush(color); - const gd::RectF tooth(-toothWidth * 0.5f, -half, toothWidth, half - ringRadius); + const gd::RectF tooth(-toothWidth * 0.5f, -half, toothWidth, half - toothInner); for (int index = 0; index < 8; ++index) { g.FillRectangle(&brush, tooth); @@ -63,8 +67,8 @@ namespace jdi } g.ResetTransform(); - gd::Pen pen(color, ringWidth); - g.DrawEllipse(&pen, cx - ringRadius, cy - ringRadius, ringRadius * 2.0f, + gd::Pen ringPen(color, ringWidth); + g.DrawEllipse(&ringPen, cx - ringRadius, cy - ringRadius, ringRadius * 2.0f, ringRadius * 2.0f); gd::Pen hubPen(color, hubWidth); @@ -145,6 +149,30 @@ namespace jdi Invalidate(Bounds()); } + gd::RectF UiButtonWidget::ContentRect() const + { + const gd::RectF& rect = Bounds(); + if (!(rect.Width > 0.0f) || !(rect.Height > 0.0f)) + { + return gd::RectF(); + } + + if (m_icon != UiButtonIcon::NONE) + { + const float side = kIconSize * m_scale; + return gd::RectF(rect.X + (rect.Width - side) * 0.5f, + rect.Y + (rect.Height - side) * 0.5f, side, side); + } + + if (m_label.empty() || m_fonts == nullptr || m_fonts->ui == nullptr) + { + return gd::RectF(); + } + const float textWidth = UiMeasureTextWidth(m_fonts->ui, m_label); + return gd::RectF(rect.X + (rect.Width - textWidth) * 0.5f, rect.Y, textWidth, + rect.Height); + } + gd::SizeF UiButtonWidget::OnMeasure(const UiMeasureContext& ctx) { const float scale = (ctx.scale > 0.1f) ? ctx.scale : m_scale; diff --git a/Components/Shared/Src/UiComponent.cpp b/Components/Shared/Src/UiComponent.cpp index 7a6fc94..a72490f 100644 --- a/Components/Shared/Src/UiComponent.cpp +++ b/Components/Shared/Src/UiComponent.cpp @@ -6,10 +6,10 @@ namespace jdi { // 默认实现放在 .cpp 而不是头文件内联:`UiComponent.hpp` 是四个大组件都要包含的 - // 公共头,不应把 `UiDebugBounds.hpp`(GDI+ 绘制辅助)也拉进它们的编译单元。 - void UiComponent::PaintDebugBounds(gd::Graphics& g, float scale) + // 公共头,不应把 `UiDebugBounds.hpp`(box 收集器)也拉进它们的编译单元。 + void UiComponent::CollectDebugBoxes(UiDebugBoxList& boxes) { - UiDebugPaintBounds(g, Area(), 0, scale); + boxes.Add(Area(), 0); } } // namespace jdi diff --git a/Components/Shared/Src/UiDebugBounds.cpp b/Components/Shared/Src/UiDebugBounds.cpp index d7104cc..8451aa9 100644 --- a/Components/Shared/Src/UiDebugBounds.cpp +++ b/Components/Shared/Src/UiDebugBounds.cpp @@ -1,22 +1,36 @@ #include "UiDebugBounds.hpp" +#include #include +#include "PaintUtil.hpp" + namespace jdi { namespace { - /** @brief 顶层组件区域的描边宽度(设备像素)。 */ - const int kAreaStroke = 2; - /** @brief 内部控件 / 子块的描边宽度(设备像素)。 */ - const int kNodeStroke = 1; + /** @brief 四条边的描边宽度(设备像素)。 */ + constexpr int kEdgeStroke = 1; + /** @brief 层级 0 的包角粗细(设备像素)。 */ + constexpr int kAreaCornerThick = 4; + /** @brief 层级 ≥1 的包角粗细(设备像素)。 */ + constexpr int kNodeCornerThick = 3; + /** @brief 包角沿边延伸的长度上限(设备像素;小矩形按比例收缩)。 */ + constexpr int kCornerLenMax = 14; + /** @brief 层级 > 0 的矩形内缩量(设备像素):避免与父矩形共边时叠成粗线。 */ + constexpr int kNestedInset = 1; + + /** @brief 标签底衬的不透明度(保证文字在任何内容上都可读)。 */ + constexpr std::uint32_t kTagBackdrop = 0xD9101014u; + /** @brief 标签文字颜色。 */ + constexpr std::uint32_t kTagInk = 0xFFFFFFFFu; /** * @brief 用整数像素矩形填充(要求调用方已把像素偏移模式设为 `None`)。 * * 走 GDI+ 而不是 `UiFillRect`:这里需要**精确的 1px 覆盖**,而 `UiFillRect` 的快 - * 路径按 `floor/ceil` 取整并可能转交 GDI,混进调试层会带来额外的状态切换开销。 + * 路径按 `floor/ceil` 取整并可能转交 GDI,混进调试层会带来额外状态切换开销。 */ void FillPixels(gd::Graphics& g, int x, int y, int width, int height, std::uint32_t argb) { @@ -27,8 +41,70 @@ namespace jdi gd::SolidBrush brush{gd::Color(argb)}; g.FillRectangle(&brush, x, y, width, height); } + + /** @brief 一个已对齐到整像素、可直接填充的矩形。 */ + struct PixelRect + { + int left = 0; + int top = 0; + int right = -1; + int bottom = -1; + int Width() const { return right - left + 1; } + int Height() const { return bottom - top + 1; } + bool Valid() const { return right >= left && bottom >= top; } + }; + + /** @brief 把逻辑矩形对齐到整像素(`inset` 先内缩,用于避免父子共边)。 */ + PixelRect AlignToPixels(const gd::RectF& rect, int inset) + { + const float shrink = static_cast(inset); + const float x = rect.X + shrink; + const float y = rect.Y + shrink; + const float width = rect.Width - shrink * 2.0f; + const float height = rect.Height - shrink * 2.0f; + if (!(width > 0.0f) || !(height > 0.0f)) + { + return PixelRect(); + } + + PixelRect out; + out.left = static_cast(std::lround(x)); + out.top = static_cast(std::lround(y)); + out.right = static_cast(std::lround(x + width)) - 1; + out.bottom = static_cast(std::lround(y + height)) - 1; + if (out.right < out.left) + { + out.right = out.left; + } + if (out.bottom < out.top) + { + out.bottom = out.top; + } + return out; + } } // namespace + void UiDebugBoxList::Add(const gd::RectF& rect, int depth, std::wstring tag) + { + if (!(rect.Width > 0.0f) || !(rect.Height > 0.0f)) + { + return; // 空矩形(未布局 / 该元素当前不显示)不登记。 + } + UiDebugBox box; + box.rect = rect; + box.depth = (depth < 0) ? 0 : depth; + box.tag = std::move(tag); + m_boxes.push_back(std::move(box)); + } + + void UiDebugBoxList::AddAll(const std::vector& rects, int depth) + { + for (const gd::RectF& rect : rects) + { + Add(rect, depth); + } + } + std::uint32_t UiDebugBoundsColor(int depth) { // 与主题无关的固定配色:调试层必须"永远显眼",不能跟随用户主题变成接近底色。 @@ -37,60 +113,116 @@ namespace jdi case 0: return 0xFF29B6F6u; // 青蓝:顶层组件区域 case 1: - return 0xFFE040FBu; // 品红:控件节点 + return 0xFFE040FBu; // 品红:组件内部的功能分区 case 2: - return 0xFFFFB300u; // 琥珀:内部子块 + return 0xFFFFB300u; // 琥珀:具体控件 / 行 default: - return 0xFF66BB6Au; // 绿:更深的子块 + return 0xFF66BB6Au; // 绿:更深的子块(复选框、文本、✕ …) } } - void UiDebugPaintBounds(gd::Graphics& g, const gd::RectF& rect, int depth, float scale) + void UiDebugPaintBounds(gd::Graphics& g, const gd::RectF& rect, int depth) { - (void)scale; // 线宽刻意不随 DPI 变化(见头文件说明),此参数保留以便将来做对齐细分。 - - if (!(rect.Width > 0.0f) || !(rect.Height > 0.0f)) + const int level = (depth < 0) ? 0 : depth; + const int inset = (level > 0) ? kNestedInset : 0; + const PixelRect pixels = AlignToPixels(rect, inset); + if (!pixels.Valid()) { return; } - // 对齐到整像素:把矩形四边四舍五入,保证 1px 描边正好压住一格像素。 - const int left = static_cast(std::lround(rect.X)); - const int top = static_cast(std::lround(rect.Y)); - int right = static_cast(std::lround(rect.X + rect.Width)) - 1; - int bottom = static_cast(std::lround(rect.Y + rect.Height)) - 1; - if (right < left) - { - right = left; - } - if (bottom < top) - { - bottom = top; - } - - const int width = right - left + 1; - const int height = bottom - top + 1; - const int level = (depth < 0) ? 0 : depth; + const int width = pixels.Width(); + const int height = pixels.Height(); const std::uint32_t color = UiDebugBoundsColor(level); - const int stroke = (level == 0) ? kAreaStroke : kNodeStroke; + const int cornerThick = std::min((level == 0) ? kAreaCornerThick : kNodeCornerThick, + std::max(1, std::min(width, height))); + // 包角沿边延伸的长度:默认 14px,但不超过短边的一半(否则四角会连成实线框)。 + const int cornerLen = std::max(cornerThick + 1, + std::min(kCornerLenMax, std::max(1, std::min(width, height) / 2))); const gd::GraphicsState state = g.Save(); // 描边必须锐利:关抗锯齿 + 关半像素偏移,矩形已对齐整像素 ⇒ 1px 就是 1px。 g.SetSmoothingMode(gd::SmoothingModeNone); g.SetPixelOffsetMode(gd::PixelOffsetModeNone); - // ⚠️ **只描边、不填充**(与 Android「Show layout bounds」一致): - // 填充会在几十个嵌套矩形上层层叠加,把内容糊成一片,反而看不出真实边界。 + // (1) 四边:统一 1px,用实心矩形而不是 `DrawRectangle` + // (后者会因笔宽奇偶而偏移半像素,且抗锯齿会糊成 2px)。 + FillPixels(g, pixels.left, pixels.top, width, kEdgeStroke, color); // 上 + FillPixels(g, pixels.left, pixels.bottom, width, kEdgeStroke, color); // 下 + FillPixels(g, pixels.left, pixels.top, kEdgeStroke, height, color); // 左 + FillPixels(g, pixels.right, pixels.top, kEdgeStroke, height, color); // 右 - // 四条边(用实心矩形而不是 `DrawRectangle`:后者会因笔宽奇偶而偏移半像素)。 - const int thick = (stroke * 2 <= width) ? stroke : 1; - const int thickY = (stroke * 2 <= height) ? stroke : 1; - FillPixels(g, left, top, width, thickY, color); // 上 - FillPixels(g, left, bottom - thickY + 1, width, thickY, color); // 下 - FillPixels(g, left, top, thick, height, color); // 左 - FillPixels(g, right - thick + 1, top, thick, height, color); // 右 + // (2) 四个角:加粗成 ⌜ ⌝ ⌞ ⌟ 包角 —— 这是"一眼定位元素边界"的关键, + // 即使多个矩形共边也能靠角的形状分辨层次。 + const int len = std::max(1, std::min(cornerLen, std::max(1, std::min(width, height)))); + const int thick = std::min(cornerThick, std::max(1, std::min(width, height))); + // 左上 + FillPixels(g, pixels.left, pixels.top, len, thick, color); + FillPixels(g, pixels.left, pixels.top, thick, len, color); + // 右上 + FillPixels(g, pixels.right - len + 1, pixels.top, len, thick, color); + FillPixels(g, pixels.right - thick + 1, pixels.top, thick, len, color); + // 左下 + FillPixels(g, pixels.left, pixels.bottom - thick + 1, len, thick, color); + FillPixels(g, pixels.left, pixels.bottom - len + 1, thick, len, color); + // 右下 + FillPixels(g, pixels.right - len + 1, pixels.bottom - thick + 1, len, thick, color); + FillPixels(g, pixels.right - thick + 1, pixels.bottom - len + 1, thick, len, color); g.Restore(state); } + void UiDebugPaintBoxList(gd::Graphics& g, const UiDebugBoxList& boxes, + const UiDebugPaintStyle& style) + { + if (boxes.Empty()) + { + return; + } + + // 浅层先画、深层后画:深层的包角不会被父级的边盖住。 + std::vector ordered; + ordered.reserve(boxes.Count()); + for (const UiDebugBox& box : boxes.Items()) + { + ordered.push_back(&box); + } + std::stable_sort(ordered.begin(), ordered.end(), + [](const UiDebugBox* lhs, const UiDebugBox* rhs) + { return lhs->depth < rhs->depth; }); + + for (const UiDebugBox* box : ordered) + { + UiDebugPaintBounds(g, box->rect, box->depth); + + if (box->tag.empty() || style.font == nullptr) + { + continue; + } + + // ⚠️ **尺寸门槛**:只在"装得下标签"的矩形上画标签。 + // 复选框、色块、✕ 这类小方框彼此紧邻,每个都贴一条文字会连成一片糊字, + // 反而看不出边界 —— 它们的归属靠包角形状与颜色层级就能分辨。 + const float scale = (style.scale > 0.1f) ? style.scale : 1.0f; + if (box->rect.Width < 56.0f * scale || box->rect.Height < 16.0f * scale) + { + continue; + } + + // 标签贴在**左上角内侧**,带深色底衬(内容可能很亮,白字直接压上去会看不清)。 + // ⚠️ GDI 文本不受 GDI+ 裁剪约束 ⇒ 用 `UiTextClipped` 明确给出裁剪矩形。 + const float textWidth = UiMeasureTextWidth(style.font, box->tag); + const float padX = 3.0f * scale; + const float padY = 1.0f * scale; + const float height = std::round(15.0f * scale); + const gd::RectF backdrop(box->rect.X + 2.0f * scale, box->rect.Y + 2.0f * scale, + textWidth + padX * 2.0f, height); + UiFillRect(g, backdrop, kTagBackdrop); + + const gd::RectF text(backdrop.X + padX, backdrop.Y, textWidth, + std::max(1.0f, backdrop.Height - padY * 2.0f)); + UiTextClipped(g, box->tag, style.font, text, kTagInk, TextAlign::LEFT, backdrop); + } + } + } // namespace jdi diff --git a/Components/Shared/Src/UiWidget.cpp b/Components/Shared/Src/UiWidget.cpp index 62c859e..7d726f4 100644 --- a/Components/Shared/Src/UiWidget.cpp +++ b/Components/Shared/Src/UiWidget.cpp @@ -255,7 +255,7 @@ namespace jdi return UiRectHasArea(TakeDirty()); } - void UiWidget::PaintDebugBounds(gd::Graphics& g, float scale, int depth) const + void UiWidget::CollectDebugBoxes(UiDebugBoxList& boxes, int depth) const { if (!m_visible || !UiRectHasArea(m_bounds)) { @@ -263,14 +263,14 @@ namespace jdi return; } - UiDebugPaintBounds(g, m_bounds, depth, scale); + boxes.Add(m_bounds, depth); // 子节点按 z 序递归(末尾最上层)—— 与 `Paint` 的顺序一致,重叠时上层压住下层。 for (const std::unique_ptr& child : m_children) { if (child != nullptr) { - child->PaintDebugBounds(g, scale, depth + 1); + child->CollectDebugBoxes(boxes, depth + 1); } } } diff --git a/Components/SignalPanel/Inc/SignalPanel.hpp b/Components/SignalPanel/Inc/SignalPanel.hpp index 264b33e..744720d 100644 --- a/Components/SignalPanel/Inc/SignalPanel.hpp +++ b/Components/SignalPanel/Inc/SignalPanel.hpp @@ -20,6 +20,8 @@ namespace jdi { + class UiDebugBoxList; + /** * @brief 信号面板弹出菜单的命令标识。 */ @@ -364,15 +366,19 @@ namespace jdi HCURSOR QueryCursor(int x, int y) override; /** - * @brief 绘制面板区域与其内部结构(表头/筛选框/列表/样式区/可见行)的布局边界。 + * @brief 收集面板区域与其内部结构(表头 / 筛选框 / 列表 / 样式区 / 可见行 / 行内元素) + * 的布局边界(调试用)。 * - * 面板的行位图缓存与本函数**无关**:调试边界只画进宿主后备缓冲的最后一步, + * 行内元素(复选框、色块、名称、GX 徽标、✕、折起三角、组计数)的几何与 + * `RowHitPart` / `PaintRow` **同源**:都基于同一个 `RowRect` 与同一批常量, + * 因此"画出来的框"就是"能点到的区域"。 + * + * 面板的行位图缓存与本函数**无关**:调试 box 只由宿主在最后一步统一描边, * 不进任何缓存 ⇒ 关掉开关后不会留下残影。 * - * @param [IN,OUT] g 绘图上下文。 - * @param [IN] scale DPI 缩放系数。 + * @param [IN,OUT] boxes 收集器。 */ - void PaintDebugBounds(gd::Graphics& g, float scale) override; + void CollectDebugBoxes(UiDebugBoxList& boxes) override; /** * @brief 将列表滚动到指定信号所在位置(无动画,立即到位)。 @@ -1019,6 +1025,19 @@ namespace jdi */ gd::RectF ScrollThumbRect() const; + /** + * @brief 面板的**客户区矩形**(宿主已告知客户区时用它,否则退化为自身区域)。 + * + * 弹出菜单 / 拾色器需要"限制在窗口内"的边界;此前这段三元表达式在 6 处重复, + * 一旦判据变化就要全改,故收敛成一个函数。 + * + * @return 客户区矩形(客户区坐标)。 + */ + gd::RectF PanelBounds() const + { + return (m_clientBounds.Width > 1.0f) ? m_clientBounds : m_area; + } + /** * @brief 布置样式编辑区内各控件的矩形。 */ @@ -1256,6 +1275,15 @@ namespace jdi std::array m_adjustRects; std::vector m_rows; /**< 列表行集合(惰性缓存)。 */ + /** + * @brief 行顶前缀和:`m_rowTop[i]` = 第 0..i-1 行的高度之和(长度 = `m_rows.size() + 1`)。 + * + * ⚠️ **性能硬约束**:`RowRect(row)` 原来每次调用都从头累加行高(O(row)), + * 而画一行要调它 5 次(✕ / 徽标 / 名称 / 组计数 / 折起三角各一次)。 + * 200 行列表只看 30 行时每帧就是上万次加法,命中测试(每次鼠标移动)同样走这条路。 + * 改成前缀和之后一次查表即可(O(1)),统一由 `UpdateAnimGeometry()` 重算。 + */ + std::vector m_rowTop; bool m_rowsDirty = true; /**< 列表行集合是否需要重建。 */ float m_contentHeight = 0.0f; /**< 列表内容总高度。 */ float m_listFullWidth = 0.0f; /**< 列表区不含滚动条的完整宽度。 */ diff --git a/Components/SignalPanel/Src/SignalPanel.cpp b/Components/SignalPanel/Src/SignalPanel.cpp index 15ad107..8905657 100644 --- a/Components/SignalPanel/Src/SignalPanel.cpp +++ b/Components/SignalPanel/Src/SignalPanel.cpp @@ -72,29 +72,63 @@ namespace jdi return UiPointInRect(m_area, static_cast(x), static_cast(y)); } - void SignalPanel::PaintDebugBounds(gd::Graphics& g, float scale) + void SignalPanel::CollectDebugBoxes(UiDebugBoxList& boxes) { - UiComponent::PaintDebugBounds(g, scale); + UiComponent::CollectDebugBoxes(boxes); if (m_fonts == nullptr || !UiRectHasArea(m_area)) { - return; // 尚未完成布局:内部矩形全是空的,画出来只会误导。 + return; // 尚未完成布局:内部矩形全是空的,登记出来只会误导。 } - // 层级 1:面板内部的功能分区。 - UiDebugPaintBounds(g, m_headerRect, 1, scale); - UiDebugPaintBounds(g, m_filterRect, 1, scale); - UiDebugPaintBounds(g, m_listRect, 1, scale); - UiDebugPaintBounds(g, m_styleRect, 1, scale); + // 行几何依赖行集合 ⇒ 先确保它是最新的(与绘制走同一个入口)。 + EnsureRows(); - // 层级 2:滚动条与样式区的每一行。 - UiDebugPaintBounds(g, m_scrollBarRect, 2, scale); - for (const gd::RectF& row : m_styleRowRects) + // ── 层级 1:面板内部的四个功能分区 ──────────────────────────────── + boxes.Add(m_headerRect, 1, L"header"); + boxes.Add(m_filterRect, 1, L"filter"); + boxes.Add(m_listRect, 1, L"list"); + boxes.Add(m_styleRect, 1, L"style"); + + // ── 层级 2:分区内部的固定控件 ──────────────────────────────────── + boxes.Add(m_filterRect, 2); // 输入框外框(与分区边框区分) + boxes.Add(FilterFieldRect(), 3, L"field"); + boxes.Add(FilterClearRect(), 3, L"x"); + boxes.Add(m_toggleAllRect, 2); + + boxes.Add(m_scrollBarRect, 2, L"scroll"); + boxes.Add(ScrollThumbRect(), 3); + + // ── 样式区:五行 + 每行内部的色块/按钮/滑轨/数值 ────────────────── + for (std::size_t index = 0; index < m_styleRowRects.size(); ++index) { - UiDebugPaintBounds(g, row, 2, scale); + boxes.Add(m_styleRowRects[index], 2); } + for (const gd::RectF& swatch : m_colorRects) + { + boxes.Add(swatch, 3); + } + boxes.Add(m_customChipRect, 3, L"custom"); + for (const gd::RectF& mode : m_modeRects) + { + boxes.Add(mode, 3); + } + for (const gd::RectF& adjust : m_adjustRects) + { + boxes.Add(adjust, 3); + } + boxes.Add(m_widthTrackRect, 3, L"track"); + boxes.Add(m_widthValueRect, 3, L"value"); + boxes.Add(m_opacityTrackRect, 3, L"track"); + boxes.Add(m_opacityValueRect, 3, L"value"); - // 层级 2:列表里**当前可见**的行(被滚出列表的行不画,否则边界会糊出列表区)。 + // ── 「目标」行(样式区顶部,展示当前选中信号) ──────────────────── + boxes.Add(m_targetRowRect, 2, L"target"); + boxes.Add(m_targetNameRect, 3, L"name"); + boxes.Add(m_targetBadgeRect, 3, L"G"); + + // ── 列表里**当前可见**的行,以及每行内部的元素 ──────────────────── + // 被滚出列表的行不登记,否则边界会糊到列表区之外。 const float listTop = m_listRect.Y; const float listBottom = m_listRect.Y + m_listRect.Height; const int rowCount = static_cast(m_rows.size()); @@ -109,7 +143,50 @@ namespace jdi { continue; } - UiDebugPaintBounds(g, rect, 2, scale); + + boxes.Add(rect, 2, L"row"); + + const ListRow& item = m_rows[static_cast(row)]; + const bool isGroup = (item.kind == RowKind::GROUP); + // ⚠️ 行内子块的几何必须与 `RowHitPart` / `PaintRow` **完全同源**: + // 这里复用同一批常量与同一个 `RowRect`,不另写一套推算。 + const float baseX = rect.X + kPadding * m_scale + (isGroup ? 0.0f : kIndent * m_scale); + const float centerY = rect.Y + rect.Height * 0.5f; + + const gd::RectF disclosureRect(baseX, rect.Y, kDisclosureWidth * m_scale, rect.Height); + const gd::RectF checkRect(baseX + kDisclosureWidth * m_scale, + centerY - kCheckSize * m_scale * 0.5f, + kCheckSize * m_scale, kCheckSize * m_scale); + boxes.Add(disclosureRect, 3, L"disclose"); + boxes.Add(checkRect, 3, L"check"); + + if (isGroup) + { + const float labelLeft = checkRect.X + checkRect.Width + 8.0f * m_scale; + boxes.Add(gd::RectF(labelLeft, rect.Y, GroupLabelWidth(row), rect.Height), 3, + L"group"); + boxes.Add(GroupStatsRect(row), 3, L"count"); + boxes.Add(GroupCloseRect(row), 3, L"x"); + continue; + } + + const SignalRef ref{item.groupIndex, item.signalIndex}; + const gd::RectF swatchRect(checkRect.X + checkRect.Width + 6.0f * m_scale, + centerY - kSwatchSize * m_scale * 0.5f, + kSwatchSize * m_scale, kSwatchSize * m_scale); + boxes.Add(swatchRect, 3, L"swatch"); + boxes.Add(RowNameRect(row), 3, L"name"); + if (SignalBadgeVisible(ref)) + { + boxes.Add(SignalBadgeRect(row), 3, L"G"); + } + boxes.Add(SignalRemoveRect(row), 3, L"x"); + } + + // ── 内联编辑框(正在重命名时才有意义) ─────────────────────────── + if (m_editField != StyleEditField::NONE) + { + boxes.Add(EditFieldRect(), 4, L"edit"); } } @@ -296,6 +373,22 @@ namespace jdi { m_rowShift.assign(m_rows.size(), 0.0f); + // 行顶前缀和:`m_rowTop[i]` = 第 0..i-1 行高度之和(长度 = 行数 + 1)。 + // 行集合与行高只在这里变化 ⇒ 在此重建是唯一权威的时机。 + m_rowTop.assign(m_rows.size() + 1, 0.0f); + for (std::size_t index = 0; index < m_rows.size(); ++index) + { + m_rowTop[index + 1] = m_rowTop[index] + m_rows[index].height; + } + + // 行顶前缀和:`m_rowTop[i]` = 第 0..i-1 行高度之和(长度 = 行数 + 1)。 + // 行集合与行高只在这里变化 ⇒ 在此重建是唯一权威的时机。 + m_rowTop.assign(m_rows.size() + 1, 0.0f); + for (std::size_t index = 0; index < m_rows.size(); ++index) + { + m_rowTop[index + 1] = m_rowTop[index] + m_rows[index].height; + } + float totalShift = 0.0f; for (const GroupAnim& anim : m_groupAnims) { @@ -565,17 +658,28 @@ namespace jdi } float top = m_listRect.Y - m_scrollOffset; - for (int index = 0; index < row; ++index) + // O(1) 查表:前缀和由 `UpdateAnimGeometry()` 维护。 + // ⚠️ 前缀和与行集合长度不一致只可能出现在"行集合刚变、几何还没更新"的极短窗口, + // 此时退回逐行累加以保证结果仍然正确(不追求速度)。 + const std::size_t rowIndex = static_cast(row); + if (m_rowTop.size() == m_rows.size() + 1) { - top += m_rows[static_cast(index)].height; + top += m_rowTop[rowIndex]; + } + else + { + for (std::size_t index = 0; index < rowIndex; ++index) + { + top += m_rows[index].height; + } } // 折起/展开动画的刚性平移:组名行之后的行整组上移。 - if (row < static_cast(m_rowShift.size())) + if (rowIndex < m_rowShift.size()) { - top -= m_rowShift[static_cast(row)]; + top -= m_rowShift[rowIndex]; } return gd::RectF(m_listRect.X + renderOffsetX, top + renderOffsetY, m_listRect.Width, - m_rows[static_cast(row)].height); + m_rows[rowIndex].height); } int SignalPanel::GroupLastRowIndex(int row, int rowCount) const @@ -750,8 +854,17 @@ namespace jdi float SignalPanel::ContentOffsetBeforeRow(int row) const { - float top = 0.0f; + // 与 `RowRect` 共用同一份前缀和(O(1)),不再逐行累加。 const int count = std::min(row, static_cast(m_rows.size())); + if (count <= 0) + { + return 0.0f; + } + if (m_rowTop.size() == m_rows.size() + 1) + { + return m_rowTop[static_cast(count)]; + } + float top = 0.0f; for (int index = 0; index < count; ++index) { top += m_rows[static_cast(index)].height; @@ -1292,7 +1405,7 @@ namespace jdi return false; } - const gd::RectF bounds = (m_clientBounds.Width > 1.0f) ? m_clientBounds : m_area; + const gd::RectF bounds = PanelBounds(); // 以记忆的自定义颜色作为初始取值,实现“再次打开即恢复上次取色”。 m_colorPicker->Open(bounds, m_customChipRect, style.customColor); return true; diff --git a/Components/SignalPanel/Src/SignalPanelMenu.cpp b/Components/SignalPanel/Src/SignalPanelMenu.cpp index ec60c65..564fa1d 100644 --- a/Components/SignalPanel/Src/SignalPanelMenu.cpp +++ b/Components/SignalPanel/Src/SignalPanelMenu.cpp @@ -77,7 +77,7 @@ namespace jdi // 并再往上让出 `kAdjustMenuGap`(用户明确要求)。 //(旧实现在按钮下沿向右下展开,面板会盖住下方内容。) const gd::RectF& button = m_adjustRects[kAdjustGain]; - const gd::RectF bounds = (m_clientBounds.Width > 1.0f) ? m_clientBounds : m_area; + const gd::RectF bounds = PanelBounds(); const int anchorY = static_cast(std::lround(button.Y - kAdjustMenuGap * m_scale)); m_menu->Open(MenuScope::SIGNAL_PANEL, bounds, static_cast(button.X), anchorY, items, @@ -120,7 +120,7 @@ namespace jdi items.push_back(vertical); const gd::RectF& button = m_adjustRects[kAdjustMirror]; - const gd::RectF bounds = (m_clientBounds.Width > 1.0f) ? m_clientBounds : m_area; + const gd::RectF bounds = PanelBounds(); // 与「系数/偏移」面板同一套锛定:向上展开,左下角 = 按钮左上角再上移一点。 const int anchorY = static_cast(std::lround(button.Y - kAdjustMenuGap * m_scale)); @@ -246,7 +246,7 @@ namespace jdi items.push_back(mode); } - const gd::RectF bounds = (m_clientBounds.Width > 1.0f) ? m_clientBounds : m_area; + const gd::RectF bounds = PanelBounds(); m_menu->Open(MenuScope::SIGNAL_PANEL, bounds, x, y, items); } @@ -292,7 +292,7 @@ namespace jdi remove.id = CMD_GROUP_REMOVE; items.push_back(remove); - const gd::RectF bounds = (m_clientBounds.Width > 1.0f) ? m_clientBounds : m_area; + const gd::RectF bounds = PanelBounds(); m_menu->Open(MenuScope::SIGNAL_PANEL, bounds, x, y, items); } @@ -340,7 +340,7 @@ namespace jdi create.id = CMD_LANE_NEW; items.push_back(create); - const gd::RectF bounds = (m_clientBounds.Width > 1.0f) ? m_clientBounds : m_area; + const gd::RectF bounds = PanelBounds(); // ⚠️ 网格可能是**上百个**(例如把 MF4 的全部信号各自放进独立网格): // 不限制高度时菜单会长到屏幕之外、且逐条绘制上百行会明显卡顿。 // 这里限制可滚动条目区**最多显示 10 行**(用户 2026-10-16 要求): diff --git a/Components/TabBar/Inc/TabBar.hpp b/Components/TabBar/Inc/TabBar.hpp index 09254fd..b53987b 100644 --- a/Components/TabBar/Inc/TabBar.hpp +++ b/Components/TabBar/Inc/TabBar.hpp @@ -225,11 +225,10 @@ namespace jdi bool HitTest(int x, int y) const override; /** - * @brief 绘制标签栏区域与**每个标签条/新建按钮**的布局边界(调试用)。 - * @param [IN,OUT] g 绘图上下文。 - * @param [IN] scale DPI 缩放系数。 + * @brief 收集标签栏区域、**每个标签条 / 新建按钮 / 标签 ✕** 的布局边界(调试用)。 + * @param [IN,OUT] boxes 收集器。 */ - void PaintDebugBounds(gd::Graphics& g, float scale) override; + void CollectDebugBoxes(UiDebugBoxList& boxes) override; private: /** diff --git a/Components/TabBar/Src/TabBar.cpp b/Components/TabBar/Src/TabBar.cpp index 8a10eb7..157b50e 100644 --- a/Components/TabBar/Src/TabBar.cpp +++ b/Components/TabBar/Src/TabBar.cpp @@ -484,25 +484,25 @@ namespace jdi return UiPointInRect(m_area, static_cast(x), static_cast(y)); } - void TabBar::PaintDebugBounds(gd::Graphics& g, float scale) + void TabBar::CollectDebugBoxes(UiDebugBoxList& boxes) { - UiComponent::PaintDebugBounds(g, scale); + UiComponent::CollectDebugBoxes(boxes); if (m_root != nullptr) { - // ⚠️ 跳过根控件:它的矩形与标签栏区域完全重合(已被上一层画过)。 - // 从控件树的**子节点**(各标签条 / 新建按钮,层级 1)开始上报。 + // ⚠️ 跳过根控件:它的矩形与标签栏区域完全重合(已被上一层登记过)。 + // 从控件树的**子节点**(各标签条 / 新建按钮)开始,层级 1。 for (const std::unique_ptr& child : m_root->Children()) { if (child != nullptr) { - child->PaintDebugBounds(g, scale, 1); + child->CollectDebugBoxes(boxes, 1); } } } - // 关闭按钮是「标签条内部的子块」,控件树里没有对应节点(由标签条控件自绘)⇒ 单独上报。 + // 关闭按钮是「标签条内部的子块」,控件树里没有对应节点(由标签条控件自绘)⇒ 单独登记。 for (const TabItem& tab : m_tabs) { - UiDebugPaintBounds(g, tab.closeRect, 2, scale); + boxes.Add(tab.closeRect, 2, L"x"); } } diff --git a/Components/Toolbar/Inc/Toolbar.hpp b/Components/Toolbar/Inc/Toolbar.hpp index 8e0f9ec..92ee379 100644 --- a/Components/Toolbar/Inc/Toolbar.hpp +++ b/Components/Toolbar/Inc/Toolbar.hpp @@ -154,11 +154,10 @@ namespace jdi bool HitTest(int x, int y) const override; /** - * @brief 绘制工具栏区域与**每个按钮**的布局边界(调试用)。 - * @param [IN,OUT] g 绘图上下文。 - * @param [IN] scale DPI 缩放系数。 + * @brief 收集工具栏区域、**每个按钮**与按钮内容(文本/图标)的布局边界(调试用)。 + * @param [IN,OUT] boxes 收集器。 */ - void PaintDebugBounds(gd::Graphics& g, float scale) override; + void CollectDebugBoxes(UiDebugBoxList& boxes) override; private: /** diff --git a/Components/Toolbar/Src/Toolbar.cpp b/Components/Toolbar/Src/Toolbar.cpp index 1272604..3015c91 100644 --- a/Components/Toolbar/Src/Toolbar.cpp +++ b/Components/Toolbar/Src/Toolbar.cpp @@ -72,8 +72,10 @@ namespace jdi // ⚠️ **前两项构成"左侧固定簇"**(设置齿轮 + 导入文件),其余按钮作为整体靠右。 // `Layout` 里用 `kLeftClusterCount` 引用这个约定的长度,增删左侧按钮时必须同步。 + // 齿轮与「导入文件」**同为强调色主按钮**(用户 2026-10-28 要求配色一致), + // 两者视觉上构成一组"文件/设置"入口,因此 primary 都为 true。 const ButtonSpec kButtonSpecs[] = { - {ToolbarAction::SETTINGS, L"", false, false, false, UiButtonIcon::GEAR}, + {ToolbarAction::SETTINGS, L"", true, false, false, UiButtonIcon::GEAR}, {ToolbarAction::OPEN_FILE, L"导入文件", true, false, false, UiButtonIcon::NONE}, {ToolbarAction::FIT_ALL, L"适应视图", false, true, false, UiButtonIcon::NONE}, {ToolbarAction::AUTO_FIT_Y, L"Y 轴自适应", false, true, true, UiButtonIcon::NONE}, @@ -349,20 +351,26 @@ namespace jdi return UiPointInRect(m_area, static_cast(x), static_cast(y)); } - void Toolbar::PaintDebugBounds(gd::Graphics& g, float scale) + void Toolbar::CollectDebugBoxes(UiDebugBoxList& boxes) { - UiComponent::PaintDebugBounds(g, scale); + UiComponent::CollectDebugBoxes(boxes); if (m_root == nullptr) { return; } - // ⚠️ 跳过根控件:它的矩形与工具栏区域完全重合(已经被上一层画过), - // 再画一次只会多出一条压住自己的粗框。直接从**按钮**(层级 1)开始上报。 + // ⚠️ 跳过根控件:它的矩形与工具栏区域完全重合(已经被上一层登记过)。 for (const std::unique_ptr& button : m_root->Children()) { - if (button != nullptr) + if (button == nullptr) { - button->PaintDebugBounds(g, scale, 1); + continue; + } + boxes.Add(button->Bounds(), 1); + // 按钮**内容**(文本或齿轮图标)单独上报:它是居中绘制的,与按钮矩形不同。 + const UiButtonWidget* raw = dynamic_cast(button.get()); + if (raw != nullptr) + { + boxes.Add(raw->ContentRect(), 2); } } } diff --git a/Documents/UI_Refactor_Plan.md b/Documents/UI_Refactor_Plan.md index 74ac979..dc8bd92 100644 --- a/Documents/UI_Refactor_Plan.md +++ b/Documents/UI_Refactor_Plan.md @@ -442,3 +442,43 @@ protected: 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 徽标与图例、每个浮层控件都有独立包角框。 diff --git a/Documents/UI_Refactor_Progress.md b/Documents/UI_Refactor_Progress.md index 3ed5c5d..92dfea1 100644 --- a/Documents/UI_Refactor_Progress.md +++ b/Documents/UI_Refactor_Progress.md @@ -401,3 +401,101 @@ uicheck 69 passed, 0 failed `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 脚本拼接。 diff --git a/Documents/UI_Refactor_Report.md b/Documents/UI_Refactor_Report.md index 21986f3..6b057d1 100644 --- a/Documents/UI_Refactor_Report.md +++ b/Documents/UI_Refactor_Report.md @@ -350,3 +350,92 @@ lbcheck 21/21 · rendercheck FUNC 7/7 · 区域哈希 8/8 相同 · uicheck 69/6 两者的几何都已经集中,属于"再插一个循环"的量级。 3. 开关状态不持久化 —— 重启回到关闭。 4. 设置菜单目前只有一项,但分组结构(`HEADER`)已经就位,后续按组追加即可。 + +--- + +# 第七阶段报告:把"布局边界"从一根框变成一张可以逐元素对照的几何图 + +## 1. 上一版为什么不理想 + +两个独立的问题被混在一起: + +1. **观感**:四条边等宽(层级 0 用 2px、其余 1px),同一个元素的"角"比"边"粗; + 父子矩形共边时两条同色线叠在一起 ⇒ 更粗。于是看起来"就是包了个边、粗细不一"。 +2. **覆盖**:`PlotView` / `SignalPanel` 的内部元素全是"按需计算的矩形函数", + 没有任何代码把它们交给调试层 ⇒ 整块区域只有一个大框,"复选框、信号名、GX、✕" + 全被裹在外层单位里。 + +第 2 点并不是调试功能的缺陷,而是**几何没有统一出口**的症状。本轮把它一次解决。 + +## 2. 描边:四角粗、四边细 + +``` +边:固定 1 设备像素(4 条独立实心矩形,非 DrawRectangle —— 后者会偏移半像素) +角:层级 0 = 4px、其余 3px,沿水平/垂直各延伸 cornerLen(≤14px 且 ≤ 短边一半) +内缩:层级 > 0 整体内缩 1px,父子共边不再叠成粗线 +填充:无 +``` + +包角的意义不只是好看:**多条边重合时,靠"角的形状"就能分辨层次**(父是青蓝大包角、 +子是同位置的品红小包角),这是纯直线做不到的。 + +## 3. 覆盖率:从"组件自己画"改成"组件收集、宿主统一画" + +```cpp +class UiDebugBoxList; // 值类型容器,装 {rect, depth, tag} +virtual void CollectDebugBoxes(UiDebugBoxList&); // UiComponent 默认只登记 Area() +void UiWidget::CollectDebugBoxes(boxes, depth); // 递归整棵控件树 +Application::PaintLayoutBounds(...) // 收集完一次性按 depth 升序描边 +``` + +**为什么不是"所有元素都继承同一个基类"**:`PlotView` 的 4 层位图缓存、 +`SignalPanel` 的 192 槽行位图缓存,都要求"绘制由组件自己负责"。 +把几何塞进继承体系会与缓存体系打架(谁脏了谁重画、缓存键里要不要混调试状态……)。 +而 box 是**纯值类型**,只描述"哪里有一个矩形",不参与绘制 —— 于是 +**几何可以被绘制、命中与调试共用**,还不需要任何生命周期管理。 + +这正是项目里已有三处雏形(`m_decorRects` / `m_legendHits` / `m_freeMarkerHits`, +即"绘制时登记、命中时读取")的正式版本。 + +## 4. 覆盖率数据 + +| 组件 | 覆盖内容 | +| --- | --- | +| `Toolbar` | 区域 / 每个按钮 / **按钮内容(文字或齿轮图标)** | +| `TabBar` | 区域 / 每个标签条 / 新建按钮 / 每个 ✕ | +| `SignalPanel` | 4 个分区 + 筛选框与清空✕ + 全部显示 + 滚动轨道/滑块 + 样式区 5 行及其 12 色块/自定义/3 模式/3 调整/线宽与透明度轨道和数值 + 目标行/名称/GX + **每个可见行**的折起三角/复选框/色块/名称/GX/✕(组行另加组名/计数)+ 内联编辑框 | +| `PlotView` | 区域 / 每个网格 / GX 徽标 / 装饰层矩形 / 图例托底 + 色块 + 文本 / 5 类浮动面板的 20+ 个字段(面板、标题、✕、折起三角、徽标、编辑行、按钮、内容区、滚动轨道与滑块、透明度轨道与数值、两个开关、各行复选框/数值/滑轨/复位/名称/✕、分组栏/表头/三角/✕、每个固定点行与 ✕) | +| `PopupMenu` | 浮层 / 每个条目 / 可滚动可视区 / 轨道 / 滑块 / 二级子菜单与其条目 | +| `ColorPickerPopup` | 面板 / 标题 / 预览 / 色号 / 色域 / 色相条 / 3 条分量滑轨与数值 / 输入框 | + +标签增加了**尺寸门槛**(< 56×16 逻辑像素不画):复选框、色块、✕ 紧邻,逐个小框贴字会糊成一片。 + +## 5. 顺带的几何同源与性能 + +`RowRect(row)` 是 **O(row)** 的逐行累加,而画一行要调它 **5 次**(✕ / 徽标 / 名称 / 组计数 / +折起三角各一次)——200 行列表只见 30 行时每帧上万次加法,命中测试每次鼠标移动同样走这条路。 +本轮加了 `m_rowTop` 前缀和(由 `UpdateAnimGeometry()` 统一重建),查询变 **O(1)**, +长度不一致时自动退回逐行累加以保证正确性。 + +另外把 `(m_clientBounds.Width > 1.0f) ? m_clientBounds : m_area` 在 6 处的重复收敛成 +`PanelBounds()`;齿轮按钮改为主按钮配色,与「导入文件」构成一组文件/设置入口; +齿轮图标重画(齿占最外一圈、外环缩到 0.54R),否则齿会被环的描边吃掉只剩"甜甜圈"。 + +## 6. 验证 + +``` +make release / make debug 零 error 零 warning +TargetCheck.exe 910,336 B(889 KiB) +lbcheck 21/21 PASS(含"关闭后逐像素还原") +rendercheck 「工具栏带以下」区域哈希 8/8 与上一轮逐一相同 + FUNC 7/7 +uicheck 69/69 +``` + +## 7. 遗留 + +1. 仍有两类几何没有上报:`PlotView` 的坐标轴刻度带、`SignalPanel` 的样式区滑块**滑块**(只有轨道)。 +2. `m_decorRects` 按"是否落在该网格纵向范围内"归属,跨网格时可能归到相邻网格; + 调试用途可接受,若要精确应改成绘制时就带上 laneIndex。 +3. 开关状态仍未持久化。 +4. `TargetCheck.exe` 已接近 900 KiB(889 KiB),`CollectDebugBoxes` 的文字常量与 + `std::to_wstring` 是主要增量;若继续增长需要把标签文本改回静态字面量并去掉动态编号。 diff --git a/Documents/agent_mem.md b/Documents/agent_mem.md index 7b234d4..c6a29b0 100644 --- a/Documents/agent_mem.md +++ b/Documents/agent_mem.md @@ -3029,6 +3029,8 @@ else if (panelOnly) { /* 只回送“本帧重绘过的区域并集” */ } | 2026-10-28 | DeepSeek-V4.1-Flash(**UI_Refactor 分支**) | **「显示布局边界」调试开关(Android 风格)+ 工具栏设置菜单(齿轮)+ 三处可见收益优化(详见 §12 末尾「2026-10-28 第八轮」)**。① **齿轮按钮**:`ToolbarAction::SETTINGS` 位于「导入文件」左侧(工具栏布局改为「左侧固定簇 `kLeftClusterCount=2` + 右侧组」),矢量化绘制(8 齿 + 外环 + 轴孔,按 `half` 比例推导;不用字体字符 `⚙`,因为不同回退字体大小/基线不一致)。② **设置菜单**:`MenuScope::APP_SETTINGS` + `HEADER "调试"` + 勾选切换项「显示布局边界」(`keepOpen`,点击后菜单保持打开、勾选态回写);锚点取 `Toolbar::ButtonRect(SETTINGS)` 的**控件实测矩形**。③ **布局边界**:`UiComponent::PaintDebugBounds(g,scale)` 虚函数(默认只画 `Area()`),`Toolbar`/`TabBar` 跳过根控件上报控件树子节点、`SignalPanel` 上报表头/筛选框/列表/样式区 + 可见行、`PlotView` 上报每个网格区域,宿主补客户区外框/分隔条/浮层;`UiWidget::PaintDebugBounds(g,scale,depth)` 递归整棵树。**只描边不填充**、线宽按设备像素固定、临时 `PixelOffsetModeNone` + 关抗锯齿 + 整像素对齐;层级配色 青蓝/品红/琥珀/绿。⚠️ 调试层**绝不进任何位图缓存**(只在 `Render` 最后一步叠加),且开启时 `fullFrame` 必须并入开关(否则只回送绘图区会让装饰区边界留在旧帧)。④ **可见收益优化**:`Toolbar::Layout` 控件树改按需重建(`m_treeDirty`,拖分隔条每帧不再重建);`SetActionLabel` 只写覆盖表(消除双写不同步);新增 `UiWidget::DispatchRedraw` 收敛 `Dispatch()+TakeDirty()` 重复写法。验证:新增离屏探针 `.preflight/lbcheck` **21/21**(含"关闭后逐像素还原""菜单锚定/勾选翻转");`rendercheck` **工具栏带以下区域哈希 8/8 与改造前逐一相同**(整帧哈希因新增按钮整体变化,属预期)+ FUNC 7/7;`uicheck` 69/69;`make release/debug` 零警告;`TargetCheck.exe` **897,024 字节(< 900KB)**。 | +| 2026-10-28 | DeepSeek-V4.1-Flash(**UI_Refactor 分支**) | **布局边界观感重做(四角粗/四边细)+ 全覆盖 + 几何同源(详见 §12 末尾「2026-10-28 第九轮」)**。① **描边规则改了**:四条边固定 **1 设备像素**,四个角加粗(层级 0 = 4px、其余 3px,沿边延伸 ≤14px 且 ≤ 短边一半),层级 > 0 整体**内缩 1px**(父子共边不再叠粗),**绝不填充**;整像素对齐 + `PixelOffsetModeNone` + 关抗锯齿。② **协议改为「收集 → 统一绘制」**:`UiComponent::CollectDebugBoxes(UiDebugBoxList&)`(默认只登记 `Area()`)+ `UiWidget::CollectDebugBoxes(boxes, depth)` 递归控件树;**不要**改成"所有元素继承同一基类"(会与 `PlotView` 4 层缓存 / `SignalPanel` 行位图缓存的失效体系打架)。③ **覆盖补齐**:`Toolbar` 按钮内容(新增 `UiButtonWidget::ContentRect()`)、`TabBar` ✕、`SignalPanel` 每个可见行的折起三角/复选框/色块/名称/GX 徽标/✕(组行另有组名/计数)+ 筛选框/清空✕/滚动轨道与滑块/样式 5 行与 12 色块/3 模式/3 调整/线宽与透明度轨道和数值/目标行/编辑框、`PlotView` GX 徽标/装饰层矩形/图例托底+色块+文本/**5 类浮动面板全部 20+ 字段**、`PopupMenu` 条目/可视区/轨道/滑块/二级子菜单、`ColorPickerPopup` 各控件。标签设**尺寸门槛**(< 56×16 逻辑像素不画)避免糊字。④ **性能**:`RowRect` 曾是 O(row) 逐行累加且画一行调 5 次 ⇒ 改用 `m_rowTop` 前缀和(`UpdateAnimGeometry()` 重建,长度不一致时退回累加)⇒ O(1);`ContentOffsetBeforeRow` 同步;`PanelBounds()` 收敛 6 处重复三元式。⑤ **齿轮按钮改主按钮配色**(与「导入文件」一致),图标重画(齿占最外一圈 0.66R→1.0R、外环 0.54R,否则齿被环描边吃掉只剩甜甜圈)。验证:`lbcheck` 21/21(含关闭后逐像素还原)、`rendercheck` 「工具栏带以下」区域哈希 **8/8 与上一轮逐一相同** + FUNC 7/7、`uicheck` 69/69、零警告、`TargetCheck.exe` **910,336 字节(889 KiB)**。 | + ## 11. 跨设备协作须知(起源设备视角) > **本文件是唯一权威记忆**。所有设备 / Agent 共用它,**不要再新建 `agent_mem_origin.md` 等旁支记忆文件**。 @@ -8056,3 +8058,74 @@ rendercheck FUNC 7/7 PASS; uicheck 69 passed * 区域哈希起点必须用**运行时** `m_toolbarRect.Y + Height`;写死 `70` 会切进工具栏, 齿轮正好落在统计区间内,导致本应相同的哈希全都不同。 * 图标不要用字体字符 `⚙`(不同回退字体大小/基线不一致);用 GDI+ 矢量按 `half` 比例画。 + +--- + +## 本轮(2026-10-28 第九轮,本机):布局边界观感重做(四角粗/四边细)+ 全覆盖 + 几何同源 + +### 1. 边界描边的硬规则(**再改动必须守住**) + +| 部位 | 规则 | +| --- | --- | +| 四边 | 固定 **1 设备像素**(四条独立实心矩形;**不要用 `DrawRectangle`**,笔宽奇偶会偏半像素) | +| 四角 | 额外加粗:层级 0 = **4px**、其余 **3px**,沿水平/垂直各延伸 `cornerLen`(≤14px 且 ≤ 短边一半) | +| 内缩 | 层级 > 0 整体内缩 **1px** ⇒ 父子矩形共边时不会叠成粗线 | +| 填充 | **绝不填充**(几十层嵌套填充会把内容糊住) | +| 对齐 | 整像素对齐 + `PixelOffsetModeNone` + 关抗锯齿 ⇒ 1px 就是 1px | + +「四角粗」不只是观感:**多条边重合时靠角的形状分辨层次**(父=青蓝大包角、子=同位置品红小包角)。 + +### 2. 协议:组件"收集 box",宿主统一绘制 + +``` +class UiDebugBoxList; // 值类型:{rect, depth, tag} +virtual void UiComponent::CollectDebugBoxes(UiDebugBoxList&); // 默认只登记 Area() +void UiWidget::CollectDebugBoxes(boxes, depth); // 递归整棵控件树 +Application::PaintLayoutBounds(...) // 收集完按 depth 升序一次性描边 +``` + +⚠️ **不要**改成"所有元素继承同一基类":`PlotView` 4 层位图缓存 / `SignalPanel` 192 槽行位图缓存 +要求绘制归组件自己,几何进继承体系会与缓存键/失效体系打架。box 是值类型、不参与绘制, +所以能让**绘制/命中/调试共用同一份几何**且零生命周期成本。 + +### 3. 覆盖清单(本轮补齐) + +- `Toolbar`:区域 / 按钮 / **按钮内容(文字或齿轮)** +- `TabBar`:区域 / 标签条 / 新建按钮 / 每个 ✕ +- `SignalPanel`:4 分区 + 筛选框/清空✕/全部显示 + 滚动轨道/滑块 + 样式 5 行与 12 色块/自定义/3 模式/3 调整/线宽与透明度轨道和数值 + 目标行/名称/GX + **每个可见行**的折起三角/复选框/色块/名称/GX/✕(组行另有组名/计数)+ 内联编辑框 +- `PlotView`:区域 / 每网格 / GX 徽标 / 装饰层矩形 / 图例托底+色块+文本 / **5 类浮动面板全部 20+ 字段** +- `PopupMenu`:浮层 / 条目 / 可视区 / 轨道 / 滑块 / 二级子菜单与条目 +- `ColorPickerPopup`:面板 / 标题 / 预览 / 色号 / 色域 / 色相条 / 3 条滑轨与数值 / 输入框 + +标签有**尺寸门槛**(< 56×16 逻辑像素不画):小方框紧邻时逐个小框贴字会糊成一片。 + +### 4. 性能与一致性 + +- `RowRect(row)` 曾是 **O(row)** 逐行累加,而画一行要调 **5 次**(✕/徽标/名称/组计数/折起三角) + ⇒ 200 行只看 30 行时每帧上万次加法,命中测试同样走这条路。 + 现在用 `m_rowTop` 前缀和(由 `UpdateAnimGeometry()` 统一重建)⇒ **O(1)**; + 长度不一致时**退回逐行累加**(正确性优先)。 +- `ContentOffsetBeforeRow()` 同样用前缀和。 +- `PanelBounds()` 收敛 6 处 `(m_clientBounds.Width > 1.0f) ? m_clientBounds : m_area`。 + +### 5. 图标 + +* 齿轮按钮 `primary = true`(与「导入文件」同为强调色主按钮)。 +* 齿轮矢量几何:**齿占最外一圈(0.66R→1.0R)、外环中心线 0.54R、笔宽 0.24R**。 + ⚠️ 齿若从环中心线伸出会被环的描边吃掉,只剩"甜甜圈"。 + +### 6. 验证 + +``` +make release / make debug 零 error 零 warning;TargetCheck.exe 910,336 B(889 KiB) +.preflight/lbcheck 21/21(含 bounds-off-restores-pixels) +uicheck 69/69 +rendercheck 「工具栏带以下」区域哈希 8/8 与上一轮逐一相同 + FUNC 7/7 +``` + +### 7. 踩坑 + +* 用 PS 按行替换大段代码时,找函数结尾必须 `Trim() -eq "}"` 而不是 `TrimEnd()`: + 文件里的闭括号带前导缩进,用 `TrimEnd()` 会越界(`InvokeMethodOnNull`)且**开头已被删掉**, + 要在替换后逐个核对括号配对。 +* 中文不能写进 `.ps1`(PS 5.1 按 GBK 读)⇒ 文本块落 UTF-8 数据文件、脚本保持纯 ASCII。