UI 重构 P3:Toolbar 迁移到控件树(新增 UiButtonWidget,逐像素 0 差异 + 功能断言)
新增 Components/Shared/{Inc,Src}/UiButtonWidget.*:通用「文字按钮」控件,视觉规则与旧 Toolbar
按钮逐像素一致(主按钮强调色实底 / 普通按钮 1px 描边 / 可切换选中态 / 禁用态配色);
交互由控件自己处理:按下即 CaptureMouse 进入按下态,在自身范围内松开才触发 onClick,
指针离开(不会再收到抬起)时 CancelPress。
Toolbar 内部重写,公开 API 一字未改:
- Button 只保留静态配置 + 控件指针;矩形与外观状态归控件。
- 删除 m_hoverIndex / m_pressedIndex 与死字段 Button::hover,改由 UiWidget 的悬停链与 capture 维护。
- Paint 改为「根控件(底色 + 底部分隔线)+ 按钮子节点」,一次 root->Paint(ctx)。
- OnMouseMove/Down/Up/Leave 统一翻译成 UiEvent 后 root->Dispatch;动作经 onClick 回调登记、抬起后执行。
框架约定调整:UiWidget::Paint 的裁剪从「严格等于自身矩形」改为「自身矩形外扩 1px(kUiClipBleed)」。
边框/描边以矩形边界为中心绘制,严格裁剪会切掉外半侧使控件变细、与旧绘制不再像素一致。
uicheck 对应断言改为「裁剪 ⊇ 自身矩形且不超出外扩范围」。
验证:
- make release / make debug 零警告;Target.exe 881,664 B(< 900KB)。
- 逐像素 A/B(8 场景:全览 / 深放大+自由标记+滤波+弱峰+光标 / 平移 / 悬停按钮 / 按下 / 松开 /
悬停主按钮 / 移出工具栏):迁移前后哈希逐一相同
A 2166933039626F66 B C444ECFEB18FD73D C 7984446D8E7A4418 D BF855879CA12F289
E 1C663E4118616016 F 0535292D8E07AE18 G 7A58172C46577729 H 23F390DD76141113。
- 功能断言:locate-cursor-button PASS (1291,73);click-cursor-changes-mode PASS (1 -> 2);
release-outside-does-not-fire PASS (2 -> 2)。
- Tools/uicheck 62 passed / 0 failed。
This commit is contained in:
1 parent
7e1852c2d2
commit
25264f9074
9 files changed
+663
-223
No files matched your search
@@ -0,0 +1,84 @@
|
||||
#ifndef __UI_BUTTON_WIDGET_HPP__
|
||||
#define __UI_BUTTON_WIDGET_HPP__
|
||||
|
||||
#include <functional>
|
||||
#include <string>
|
||||
|
||||
#include "UiWidget.hpp"
|
||||
|
||||
namespace jdi
|
||||
{
|
||||
|
||||
/**
|
||||
* @brief 一个"文字按钮"控件(工具栏按钮的原语)。
|
||||
*
|
||||
* 视觉规则与旧 `Toolbar` 的按钮**逐像素一致**:
|
||||
* * 主按钮(`primary`)= 强调色实底 + `textOnAccent` 文字,按下 / 悬停时整体提亮或压暗;
|
||||
* * 普通按钮 = 透明底 + 1px 描边,可切换按钮选中时用 `accentSoft` 底 + `accent` 描边;
|
||||
* * 禁用时底色回退为工具栏底色、文字用 `textMuted`、描边与底色混 50%。
|
||||
*
|
||||
* 交互:按下即 `CaptureMouse()` 并进入按下态;**在自身范围内**松开才触发 `onClick`
|
||||
*(与"按住移开再松开 = 放弃"的既有语义一致)。
|
||||
*/
|
||||
class UiButtonWidget : public UiWidget
|
||||
{
|
||||
public:
|
||||
UiButtonWidget() = default;
|
||||
|
||||
/** @brief 一次性注入绘制资源与外观参数(尺寸由 `OnMeasure` 按文本实测得出)。 */
|
||||
void Configure(const UiFonts* fonts, const Theme* theme, float scale, std::wstring label,
|
||||
bool primary, bool toggle, float textPadding, float height);
|
||||
|
||||
/** @brief 修改文本(宽度会在下一次 `InvalidateMeasure` 后重新实测)。 */
|
||||
void SetLabel(std::wstring label);
|
||||
/** @brief 文本(只读)。 */
|
||||
const std::wstring& Label() const { return m_label; }
|
||||
|
||||
/** @brief 设置可切换按钮的选中态。 */
|
||||
void SetChecked(bool checked);
|
||||
/** @brief 选中态。 */
|
||||
bool Checked() const { return m_checked; }
|
||||
/** @brief 是否可切换按钮。 */
|
||||
bool IsToggle() const { return m_toggle; }
|
||||
|
||||
/** @brief 设置可用性(禁用时不可点、不可命中)。 */
|
||||
void SetEnabled(bool enabled);
|
||||
/** @brief 是否可用。 */
|
||||
bool Enabled() const { return m_enabled; }
|
||||
/** @brief 是否处于按下态(按下未松开)。 */
|
||||
bool Pressed() const { return m_pressed; }
|
||||
|
||||
/** @brief 放弃当前按下(指针离开窗口等不会再收到抬起的情形)。 */
|
||||
void CancelPress();
|
||||
|
||||
/** @brief 点击回调(在自身范围内松开时触发)。 */
|
||||
std::function<void()> onClick;
|
||||
|
||||
/** @brief 工具栏底色(普通按钮的底色与禁用配色都以它为基准)。 */
|
||||
void SetCanvasColor(std::uint32_t color) { m_canvasColor = color; }
|
||||
|
||||
protected:
|
||||
gd::SizeF OnMeasure(const UiMeasureContext& ctx) override;
|
||||
void OnPaint(UiPaintContext& ctx) override;
|
||||
UiEventResult OnEvent(UiEvent& event) override;
|
||||
HCURSOR OnQueryCursor(int x, int y) override;
|
||||
std::uint64_t ContentKey() const override;
|
||||
|
||||
private:
|
||||
const UiFonts* m_fonts = nullptr; /**< 字体。 */
|
||||
const Theme* m_theme = nullptr; /**< 主题。 */
|
||||
float m_scale = 1.0f; /**< 缩放系数。 */
|
||||
std::wstring m_label; /**< 按钮文本。 */
|
||||
bool m_primary = false; /**< 主按钮(强调色实底)。 */
|
||||
bool m_toggle = false; /**< 是否可切换。 */
|
||||
bool m_checked = false; /**< 可切换按钮的选中态。 */
|
||||
bool m_enabled = true; /**< 是否可用。 */
|
||||
bool m_pressed = false; /**< 按下态。 */
|
||||
float m_textPadding = 14.0f; /**< 左右内边距(逻辑像素)。 */
|
||||
float m_height = 30.0f; /**< 按钮高度(逻辑像素)。 */
|
||||
std::uint32_t m_canvasColor = 0; /**< 工具栏底色(配色基准)。 */
|
||||
};
|
||||
|
||||
} // namespace jdi
|
||||
|
||||
#endif // __UI_BUTTON_WIDGET_HPP__
|
||||
@@ -75,6 +75,9 @@ namespace jdi
|
||||
/** @brief 内缩(结果可能为空矩形)。 */
|
||||
gd::RectF UiRectDeflate(const gd::RectF& rect, const UiInsets& insets);
|
||||
|
||||
/** @brief 四边外扩(用于“描边允许超出自身矩形”的裁剪补偿)。 */
|
||||
gd::RectF UiRectInflate(const gd::RectF& rect, float amount);
|
||||
|
||||
/**
|
||||
* @brief 把矩形对齐到**设备像素整数边界**(上界 `floor`、下界 `ceil`)。
|
||||
*
|
||||
|
||||
@@ -18,6 +18,15 @@ namespace jdi
|
||||
struct Theme;
|
||||
struct UiFonts;
|
||||
|
||||
/**
|
||||
* @brief 裁剪时允许超出控件矩形的像素数(逻辑像素 × scale)。
|
||||
*
|
||||
* 边框/描边以矩形边界为中心绘制(1px 笔宽有一半在矩形外),若严格按矩形裁剪
|
||||
* 会把外半侧切掉(控件看起来变细)。因此裁剪区固定外扩这么多。
|
||||
* ⚠️ 它只用于**限制控件画到哪**,不参与脏区判定(脏区始终是精确矩形)。
|
||||
*/
|
||||
constexpr float kUiClipBleed = 1.0f;
|
||||
|
||||
/**
|
||||
* @brief 布局测量上下文(由根节点统一下发,子节点只读)。
|
||||
*/
|
||||
@@ -233,6 +242,8 @@ namespace jdi
|
||||
|
||||
/** @brief 当前悬停的控件(根节点维护)。 */
|
||||
UiWidget* HoverWidget() const;
|
||||
/** @brief 本控件当前是否被悬停(由根节点的悬停链维护)。 */
|
||||
bool Hovered() const { return m_hovered; }
|
||||
|
||||
/** @brief 请求键盘焦点(根节点记录;键盘/字符事件沿焦点链派发)。 */
|
||||
void RequestFocus();
|
||||
@@ -314,6 +325,7 @@ namespace jdi
|
||||
bool m_visible = true; /**< 是否可见。 */
|
||||
bool m_hitTransparent = false; /**< 是否命中透明。 */
|
||||
bool m_measureValid = false; /**< 期望尺寸是否有效。 */
|
||||
bool m_hovered = false; /**< 是否被悬停(悬停链写入)。 */
|
||||
|
||||
// 根节点专用状态。
|
||||
UiWidget* m_capture = nullptr; /**< 鼠标捕获者。 */
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
#include "UiButtonWidget.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
|
||||
#include "PaintUtil.hpp"
|
||||
#include "Theme.hpp"
|
||||
|
||||
namespace jdi
|
||||
{
|
||||
|
||||
namespace
|
||||
{
|
||||
const float kButtonRadius = 5.0f; /**< 按钮圆角半径(逻辑像素)。 */
|
||||
|
||||
/** @brief 按比例把一个颜色向目标色混合(与旧 Toolbar 的写法保持一致)。 */
|
||||
std::uint32_t BlendToward(std::uint32_t base, std::uint32_t target, double ratio)
|
||||
{
|
||||
return MixColor(base, target, ratio);
|
||||
}
|
||||
}
|
||||
|
||||
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::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;
|
||||
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`。
|
||||
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, 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
|
||||
@@ -89,6 +89,16 @@ namespace jdi
|
||||
return gd::RectF(rect.X + insets.left, rect.Y + insets.top, width, height);
|
||||
}
|
||||
|
||||
gd::RectF UiRectInflate(const gd::RectF& rect, float amount)
|
||||
{
|
||||
if (!UiRectHasArea(rect) || amount <= 0.0f)
|
||||
{
|
||||
return rect;
|
||||
}
|
||||
return gd::RectF(rect.X - amount, rect.Y - amount, rect.Width + amount * 2.0f,
|
||||
rect.Height + amount * 2.0f);
|
||||
}
|
||||
|
||||
gd::RectF UiRectSnapInward(const gd::RectF& rect, float scale)
|
||||
{
|
||||
const float safeScale = (scale > 0.0f) ? scale : 1.0f;
|
||||
|
||||
@@ -195,10 +195,13 @@ namespace jdi
|
||||
return;
|
||||
}
|
||||
|
||||
// (2) 裁剪 = 祖先裁剪切 ∩ 自身矩形;脏区非空时再与脏区求交(只更新需要更新的部分)。
|
||||
// (2) 裁剪 = 祖先裁剪切 ∩ (自身矩形外扩 1px);脏区非空时再与脏区求交。
|
||||
// ⚠️ 外扩 1px 是刻意设计的:边框/描边是**以矩形边界为中心**画的(`UiDrawRoundRect`
|
||||
// 的 1px 笔宽有一半在矩形外),严格按矩形裁剪会把外半侧切掉 ⇒ 控件看起来变细。
|
||||
// 这是与旧绘制代码保持“逐像素一致”的必要保证。
|
||||
UiPaintContext local = ctx;
|
||||
gd::RectF clip;
|
||||
if (!UiRectIntersect(ctx.clip, m_bounds, clip))
|
||||
if (!UiRectIntersect(ctx.clip, UiRectInflate(m_bounds, kUiClipBleed), clip))
|
||||
{
|
||||
return; // 完全被祖先裁掉
|
||||
}
|
||||
@@ -230,7 +233,13 @@ namespace jdi
|
||||
{
|
||||
continue;
|
||||
}
|
||||
UiPaintContext childContext = local.WithClip(child->m_bounds);
|
||||
UiPaintContext childContext = local;
|
||||
gd::RectF childClip;
|
||||
if (!UiRectIntersect(local.clip, UiRectInflate(child->m_bounds, kUiClipBleed), childClip))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
childContext.clip = childClip;
|
||||
childContext.dirty = local.dirty;
|
||||
child->Paint(childContext);
|
||||
}
|
||||
@@ -465,11 +474,13 @@ namespace jdi
|
||||
m_hover = target;
|
||||
if (previous != nullptr)
|
||||
{
|
||||
previous->m_hovered = false;
|
||||
previous->OnHoverChanged(false);
|
||||
previous->Invalidate(previous->m_bounds);
|
||||
}
|
||||
if (target != nullptr)
|
||||
{
|
||||
target->m_hovered = true;
|
||||
target->OnHoverChanged(true);
|
||||
target->Invalidate(target->m_bounds);
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user