feat(UI): 布局边界改为四角粗/四边细包角 + 全元素覆盖 + 几何同源(RowRect O(1))

## 1. 边界观感:四角粗、四边细

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

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

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

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

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

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

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

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

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

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

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

## 5. 几何同源与性能

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

## 6. 验证

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

## 7. 文档

Documents/UI_Refactor_Plan.md / UI_Refactor_Progress.md / UI_Refactor_Report.md 追加第七阶段;
Documents/agent_mem.md 同步变更日志与「2026-10-28 第九轮」硬规则与踩坑记录。
This commit is contained in:
DeepSeek V4.1 Flash committed 2026-10-28 16:28:57 +08:00
1 parent ab19a6e72e
commit 24706c906a
26 files changed
+1003 -163

No files matched your search

@@ -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<float>(width),
static_cast<float>(height)), 0, m_scale);
UiDebugBoxList boxes;
// 组件区域(底 → 顶;组件自己负责再上报内部结构)。
// 客户区外框:便于确认"窗口边缘 == 最外层边界"。
boxes.Add(gd::RectF(0.0f, 0.0f, static_cast<float>(width), static_cast<float>(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)
+7 -6
View File
@@ -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 处理鼠标移动(含拖拽)。
+85 -6
View File
@@ -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<int>(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<int>(index);
for (int kindValue = static_cast<int>(EdgePanelKind::COUNT);
kindValue <= static_cast<int>(EdgePanelKind::MARK); ++kindValue)
{
const EdgePanelLayout layout =
EdgePanelLayoutFor(laneIndex, static_cast<EdgePanelKind>(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");
}
}
}
}
@@ -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 面板内可进行内联文本输入的字段。
+10
View File
@@ -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。
+10
View File
@@ -68,6 +68,16 @@ namespace jdi
/** @brief 放弃当前按下(指针离开窗口等不会再收到抬起的情形)。 */
void CancelPress();
/**
* @brief 按钮**内容**(文本或图标)实际占用的矩形(客户区坐标)。
*
* 内容按居中绘制,因此它的矩形与按钮矩形不同:调试层用它显示"文字/图标本身的边界"。
* 未摆放或文本为空时返回空矩形。
*
* @return 内容矩形(客户区坐标)。
*/
gd::RectF ContentRect() const;
/** @brief 点击回调(在自身范围内松开时触发)。 */
std::function<void()> onClick;
+10 -9
View File
@@ -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
+74 -12
View File
@@ -5,7 +5,10 @@
#include <objidl.h>
#include <gdiplus.h>
#include <cstddef>
#include <cstdint>
#include <string>
#include <vector>
#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<gd::RectF>& 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<UiDebugBox>& Items() const { return m_boxes; }
private:
std::vector<UiDebugBox> 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
+4 -4
View File
@@ -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);
@@ -1,6 +1,7 @@
#include "ColorPickerPopup.hpp"
#include "UiControls.hpp"
#include "UiDebugBounds.hpp"
#include <algorithm>
#include <cmath>
#include <cstdlib>
@@ -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
+29
View File
@@ -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
+41 -13
View File
@@ -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;
+3 -3
View File
@@ -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
+171 -39
View File
@@ -1,22 +1,36 @@
#include "UiDebugBounds.hpp"
#include <algorithm>
#include <cmath>
#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<float>(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<int>(std::lround(x));
out.top = static_cast<int>(std::lround(y));
out.right = static_cast<int>(std::lround(x + width)) - 1;
out.bottom = static_cast<int>(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<gd::RectF>& 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<int>(std::lround(rect.X));
const int top = static_cast<int>(std::lround(rect.Y));
int right = static_cast<int>(std::lround(rect.X + rect.Width)) - 1;
int bottom = static_cast<int>(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<const UiDebugBox*> 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
+3 -3
View File
@@ -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<UiWidget>& child : m_children)
{
if (child != nullptr)
{
child->PaintDebugBounds(g, scale, depth + 1);
child->CollectDebugBoxes(boxes, depth + 1);
}
}
}
+33 -5
View File
@@ -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<gd::RectF, 3> m_adjustRects;
std::vector<ListRow> 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<float> m_rowTop;
bool m_rowsDirty = true; /**< 列表行集合是否需要重建。 */
float m_contentHeight = 0.0f; /**< 列表内容总高度。 */
float m_listFullWidth = 0.0f; /**< 列表区不含滚动条的完整宽度。 */
+134 -21
View File
@@ -72,29 +72,63 @@ namespace jdi
return UiPointInRect(m_area, static_cast<float>(x), static_cast<float>(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<int>(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<std::size_t>(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<std::size_t>(row);
if (m_rowTop.size() == m_rows.size() + 1)
{
top += m_rows[static_cast<std::size_t>(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<int>(m_rowShift.size()))
if (rowIndex < m_rowShift.size())
{
top -= m_rowShift[static_cast<std::size_t>(row)];
top -= m_rowShift[rowIndex];
}
return gd::RectF(m_listRect.X + renderOffsetX, top + renderOffsetY, m_listRect.Width,
m_rows[static_cast<std::size_t>(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<int>(m_rows.size()));
if (count <= 0)
{
return 0.0f;
}
if (m_rowTop.size() == m_rows.size() + 1)
{
return m_rowTop[static_cast<std::size_t>(count)];
}
float top = 0.0f;
for (int index = 0; index < count; ++index)
{
top += m_rows[static_cast<std::size_t>(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;
@@ -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<int>(std::lround(button.Y - kAdjustMenuGap * m_scale));
m_menu->Open(MenuScope::SIGNAL_PANEL, bounds, static_cast<int>(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<int>(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 要求):
+3 -4
View File
@@ -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:
/**
+7 -7
View File
@@ -484,25 +484,25 @@ namespace jdi
return UiPointInRect(m_area, static_cast<float>(x), static_cast<float>(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<UiWidget>& 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");
}
}
+3 -4
View File
@@ -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:
/**
+15 -7
View File
@@ -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<float>(x), static_cast<float>(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<UiWidget>& 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<const UiButtonWidget*>(button.get());
if (raw != nullptr)
{
boxes.Add(raw->ContentRect(), 2);
}
}
}
+40
View File
@@ -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 徽标与图例、每个浮层控件都有独立包角框。
+98
View File
@@ -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 脚本拼接。
+89
View File
@@ -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` 是主要增量;若继续增长需要把标签文本改回静态字面量并去掉动态编号。
+73
View File
@@ -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。