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);
}
}
}