Files
DeepSeek V4.1 Flash 24706c906a 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 第九轮」硬规则与踩坑记录。
2026-10-28 16:28:57 +08:00

341 lines
9.4 KiB
C++
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#include "UiButtonWidget.hpp"
#include <algorithm>
#include <cmath>
#include "PaintUtil.hpp"
#include "Theme.hpp"
namespace jdi
{
namespace
{
const float kButtonRadius = 5.0f; /**< 按钮圆角半径(逻辑像素)。 */
/** @brief 图标按钮的宽度(逻辑像素):与其他按钮同高、略窄。 */
const float kIconButtonWidth = 40.0f;
/** @brief 图标自身占用的边长(逻辑像素)。 */
const float kIconSize = 20.0f;
/** @brief 按比例把一个颜色向目标色混合(与旧 Toolbar 的写法保持一致)。 */
std::uint32_t BlendToward(std::uint32_t base, std::uint32_t target, double ratio)
{
return MixColor(base, target, ratio);
}
/**
* @brief 画一个齿轮图标(矢量:8 个齿 + 外环 + 轴孔)。
*
* 走 GDI+ 矢量而不是字体符号 `⚙`:该字符在不同回退字体里的大小与基线不一致,
* 会出现"图标忽大忽小、垂直不居中"。抗锯齿保持开启(圆环/斜齿需要它才不难看)。
*
* 几何全部按「半径占 `half` 的比例」推导,因此任意 DPI 下形状一致:
* * 齿占**最外一圈**(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 < 5.0f)
{
return;
}
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);
// 齿:从外环外缘一直伸到最外圈。
gd::SolidBrush brush(color);
const gd::RectF tooth(-toothWidth * 0.5f, -half, toothWidth, half - toothInner);
for (int index = 0; index < 8; ++index)
{
g.FillRectangle(&brush, tooth);
g.RotateTransform(45.0f);
}
g.ResetTransform();
gd::Pen ringPen(color, ringWidth);
g.DrawEllipse(&ringPen, cx - ringRadius, cy - ringRadius, ringRadius * 2.0f,
ringRadius * 2.0f);
gd::Pen hubPen(color, hubWidth);
g.DrawEllipse(&hubPen, cx - hubRadius, cy - hubRadius, hubRadius * 2.0f,
hubRadius * 2.0f);
g.Restore(state);
}
}
void UiButtonWidget::Configure(const UiFonts* fonts, const Theme* theme, float scale,
std::wstring label, bool primary, bool toggle, float textPadding,
float height)
{
m_fonts = fonts;
m_theme = theme;
m_scale = (scale > 0.1f) ? scale : 1.0f;
m_label = std::move(label);
m_primary = primary;
m_toggle = toggle;
m_textPadding = textPadding;
m_height = height;
InvalidateMeasure();
Invalidate(Bounds());
}
void UiButtonWidget::SetLabel(std::wstring label)
{
if (m_label == label)
{
return;
}
m_label = std::move(label);
InvalidateMeasure();
Invalidate(Bounds());
}
void UiButtonWidget::SetIcon(UiButtonIcon icon)
{
if (m_icon == icon)
{
return;
}
m_icon = icon;
// 图标按钮与文本按钮的期望宽度不同 ⇒ 必须让测量失效(宿主随后会重新 `Layout`)。
InvalidateMeasure();
Invalidate(Bounds());
}
void UiButtonWidget::SetChecked(bool checked)
{
if (m_checked == checked)
{
return;
}
m_checked = checked;
Invalidate(Bounds());
}
void UiButtonWidget::SetEnabled(bool enabled)
{
if (m_enabled == enabled)
{
return;
}
m_enabled = enabled;
Invalidate(Bounds());
}
void UiButtonWidget::CancelPress()
{
if (!m_pressed)
{
return;
}
m_pressed = false;
ReleaseMouse();
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;
if (m_icon != UiButtonIcon::NONE)
{
// 图标按钮宽度固定:不依赖字体测量,避免图标被文本内边距拉宽。
return gd::SizeF(kIconButtonWidth * scale, m_height * scale);
}
const float width = (m_fonts != nullptr && m_fonts->ui != nullptr)
? (m_textPadding * 2.0f * scale + UiMeasureTextWidth(m_fonts->ui, m_label))
: 0.0f;
return gd::SizeF(width, m_height * scale);
}
void UiButtonWidget::OnPaint(UiPaintContext& ctx)
{
if (m_theme == nullptr || m_fonts == nullptr || ctx.g == nullptr)
{
return;
}
const gd::RectF& rect = Bounds();
const bool hovered = Hovered();
const bool enabled = m_enabled;
std::uint32_t background = 0;
std::uint32_t border = 0;
std::uint32_t textColor = 0;
if (m_primary)
{
background = enabled ? m_theme->accent
: BlendToward(m_theme->accent, m_canvasColor, 0.6);
if (m_pressed)
{
background = BlendToward(m_theme->accent, 0xFF000000u, 0.12);
}
else if (enabled && hovered)
{
background = BlendToward(m_theme->accent, 0xFFFFFFFFu, 0.12);
}
border = background;
textColor = m_theme->textOnAccent;
}
else
{
background = (m_toggle && m_checked) ? m_theme->accentSoft : m_canvasColor;
if (m_pressed)
{
background = m_theme->pressed;
}
else if (enabled && hovered)
{
background = (m_toggle && m_checked)
? BlendToward(m_theme->accentSoft, m_theme->accent, 0.16)
: m_theme->hover;
}
if (!enabled)
{
background = m_canvasColor;
}
if (m_toggle && m_checked && enabled)
{
border = m_theme->accent;
}
else
{
border = BlendToward(m_theme->border, m_canvasColor, enabled ? 0.0 : 0.5);
}
textColor = enabled ? ((m_toggle && m_checked) ? m_theme->textStrong : m_theme->text)
: m_theme->textMuted;
}
if (background != m_canvasColor)
{
UiFillRoundRect(*ctx.g, rect, kButtonRadius * m_scale, background);
}
if (!m_primary)
{
UiDrawRoundRect(*ctx.g, rect, kButtonRadius * m_scale, border, 1.0f);
}
// ⚠️ 与旧实现一致:文字走 `UiText`(GDI,不受 GDI+ 裁剪约束)。
// 按钮矩形本身就在裁剪区内(外扩 1px 只影响描边),因此不需要 `UiTextClipped`。
if (m_icon != UiButtonIcon::NONE)
{
const float side = kIconSize * m_scale;
const gd::RectF iconRect(rect.X + (rect.Width - side) * 0.5f,
rect.Y + (rect.Height - side) * 0.5f, side, side);
PaintGearIcon(*ctx.g, iconRect, textColor);
return;
}
UiText(*ctx.g, m_label, m_fonts->ui, rect, textColor, TextAlign::CENTER);
}
UiEventResult UiButtonWidget::OnEvent(UiEvent& event)
{
switch (event.kind)
{
case UiEventKind::MOUSE_DOWN:
if (event.button != UiMouseButton::LEFT || !m_enabled)
{
return UiEventResult();
}
m_pressed = true;
CaptureMouse();
{
UiEventResult result = UiEventResult::Handled();
result.dirty = Bounds();
return result;
}
case UiEventKind::MOUSE_UP:
if (event.button != UiMouseButton::LEFT || !m_pressed)
{
return UiEventResult();
}
// 只有"在自身范围内松开"才算点击(按住移开再松开 = 放弃)。
m_pressed = false;
ReleaseMouse();
{
UiEventResult result = UiEventResult::Handled();
result.dirty = Bounds();
if (Contains(event.x, event.y) && m_enabled && onClick)
{
onClick();
}
return result;
}
case UiEventKind::MOUSE_LEAVE:
if (m_pressed)
{
m_pressed = false;
ReleaseMouse();
UiEventResult result = UiEventResult::Handled();
result.dirty = Bounds();
return result;
}
return UiEventResult();
default:
return UiEventResult();
}
}
HCURSOR UiButtonWidget::OnQueryCursor(int x, int y)
{
(void)x;
(void)y;
// 与旧实现一致:工具栏按钮不改变光标形态(箭头)。
return nullptr;
}
std::uint64_t UiButtonWidget::ContentKey() const
{
std::uint64_t key = BaseContentKey();
key = UiMixKey(key, static_cast<std::uint64_t>(m_label.size()));
key = UiMixKey(key, static_cast<std::uint64_t>(static_cast<int>(m_icon)));
key = UiMixKey(key, m_primary ? 1u : 0u);
key = UiMixKey(key, m_toggle ? 1u : 0u);
key = UiMixKey(key, m_checked ? 1u : 0u);
key = UiMixKey(key, m_enabled ? 1u : 0u);
key = UiMixKey(key, m_pressed ? 1u : 0u);
key = UiMixKey(key, Hovered() ? 1u : 0u);
return key;
}
} // namespace jdi