## 1. 新功能:齿轮按钮 -> 应用设置菜单 -> 显示布局边界 - ToolbarAction::SETTINGS 位于「导入文件」左侧;工具栏布局改为 「左侧固定簇(kLeftClusterCount = 2:齿轮 + 导入文件)+ 右侧按钮组」。 - 齿轮是矢量化绘制(8 齿 + 外环 + 轴孔,全部按 half 的比例推导), 不用字体字符「⚙」——该字符在不同回退字体里大小与基线不一致,会忽大忽小、垂直不居中。 UiButtonWidget 新增 UiButtonIcon(NONE / GEAR)与 SetIcon。 - MenuScope::APP_SETTINGS(新增)+ 设置菜单:HEADER「调试」+ 勾选切换项「显示布局边界」 (keepOpen:点击后菜单保持打开,勾选态由 UpdateItemLabel 回写)。 锚点取 Toolbar::ButtonRect(SETTINGS) 的控件实测矩形,宿主不再自己推算按钮几何。 ## 2. 布局边界:基类给协议,组件自己上报 - UiComponent 新增 virtual PaintDebugBounds(g, scale),默认只画 Area()。 - Toolbar / TabBar 覆盖:跳过与区域重合的根控件,遍历 m_root->Children()(层级 1); TabBar 另上报每个 ✕(层级 2)。 - SignalPanel 覆盖:表头 / 筛选框 / 列表 / 样式区(1)+ 滚动条、样式行、当前可见列表行(2)。 - PlotView 覆盖:每个 LaneVisual::rect(1);不上报随数据变化的瞬态几何(曲线/胶囊/提示)。 - UiWidget 新增 PaintDebugBounds(g, scale, depth) 递归整棵控件树 —— 已上树的控件零成本获得边界。 - 宿主 PaintLayoutBounds 补客户区外框、分隔条、打开的菜单/拾色器。 视觉与实现约束(Android「Show layout bounds」风): - 只描边、不填充(几十个嵌套矩形叠加填充会把内容糊住); - 层级配色 青蓝(2px) / 品红 / 琥珀 / 绿(1px); - 线宽按设备像素固定(不乘 scale),临时 PixelOffsetModeNone + 关抗锯齿 + 整像素对齐, 保证 1px 就是 1px; - ⚠️ 只在 Render 最后一步叠加,绝不进入任何位图缓存(PlotView 4 层缓存 / SignalPanel 行位图缓存), 否则关掉开关后线条会残留; - ⚠️ fullFrame 并入 m_showLayoutBounds:开启时必须整帧回送,否则「只回送绘图区」的快路径 会让装饰区边界停在旧帧上。 ## 3. 可见收益优化(行为不变) - Toolbar::Layout:控件树改为按需重建(m_treeDirty),只在规格/字体/主题/缩放/动态文本 变化时重建。拖动面板分隔条时 Relayout 每帧调用一次 -> 不再每帧 10 次堆分配 + 8 次测量缓存失效。 - Toolbar::SetActionLabel:只写 m_labelOverrides(唯一权威)+ 置树脏,消除「覆盖表与控件文本 双写」可能不同步的问题。 - UiWidget::DispatchRedraw(event):新增;收敛 Toolbar / TabBar 里重复的 Dispatch(); return UiRectHasArea(TakeDirty());(TakeDirty 会清零,漏一次就把下一次真实变化 误判成「无变化」)。 ## 4. 验证 - make release / make debug:零 error 零 warning;TargetCheck.exe 897,024 B(< 900 KB)。 - 新增离屏探针 .preflight/lbcheck:21 / 21 PASS (齿轮在导入文件左侧/同高/更窄;开启后青蓝 24,930 px、品红 19,734 px;关闭后逐像素还原; 菜单 scope、锚定贴合按钮、宽度未被撑大、条目初始未勾选、点击翻转开关、菜单保持打开)。 - Tools/rendercheck:8 个场景的「工具栏带以下区域」哈希与改造前**逐一相同** (证明改动像素被完全限制在工具栏带内);整帧哈希因新增按钮整体变化,属预期。 FUNC 断言 7 / 7 PASS。 - Tools/uicheck:69 passed / 0 failed。 ## 5. 文档 Documents/UI_Refactor_Plan.md / UI_Refactor_Progress.md / UI_Refactor_Report.md 追加第六阶段; Documents/agent_mem.md 同步变更日志与「2026-10-28 第八轮」硬约束与验证坑。
313 lines
8.5 KiB
C++
313 lines
8.5 KiB
C++
#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 = 18.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.70)伸到最外圈(1.0),环描边宽度(0.30)刚好与齿根相接。
|
|
*/
|
|
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)
|
|
{
|
|
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 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);
|
|
for (int index = 0; index < 8; ++index)
|
|
{
|
|
g.FillRectangle(&brush, tooth);
|
|
g.RotateTransform(45.0f);
|
|
}
|
|
g.ResetTransform();
|
|
|
|
gd::Pen pen(color, ringWidth);
|
|
g.DrawEllipse(&pen, 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::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
|