diff --git a/Components/Shared/Inc/UiButtonWidget.hpp b/Components/Shared/Inc/UiButtonWidget.hpp new file mode 100644 index 0000000..c2d8be4 --- /dev/null +++ b/Components/Shared/Inc/UiButtonWidget.hpp @@ -0,0 +1,84 @@ +#ifndef __UI_BUTTON_WIDGET_HPP__ +#define __UI_BUTTON_WIDGET_HPP__ + +#include +#include + +#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 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__ diff --git a/Components/Shared/Inc/UiLayout.hpp b/Components/Shared/Inc/UiLayout.hpp index 6113825..a7b9ccd 100644 --- a/Components/Shared/Inc/UiLayout.hpp +++ b/Components/Shared/Inc/UiLayout.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`)。 * diff --git a/Components/Shared/Inc/UiWidget.hpp b/Components/Shared/Inc/UiWidget.hpp index 108db31..6d86eb2 100644 --- a/Components/Shared/Inc/UiWidget.hpp +++ b/Components/Shared/Inc/UiWidget.hpp @@ -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; /**< 鼠标捕获者。 */ diff --git a/Components/Shared/Src/UiButtonWidget.cpp b/Components/Shared/Src/UiButtonWidget.cpp new file mode 100644 index 0000000..dcc7981 --- /dev/null +++ b/Components/Shared/Src/UiButtonWidget.cpp @@ -0,0 +1,231 @@ +#include "UiButtonWidget.hpp" + +#include +#include + +#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(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 diff --git a/Components/Shared/Src/UiLayout.cpp b/Components/Shared/Src/UiLayout.cpp index e8f38c1..081fed4 100644 --- a/Components/Shared/Src/UiLayout.cpp +++ b/Components/Shared/Src/UiLayout.cpp @@ -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; diff --git a/Components/Shared/Src/UiWidget.cpp b/Components/Shared/Src/UiWidget.cpp index 24942e4..e2afcb7 100644 --- a/Components/Shared/Src/UiWidget.cpp +++ b/Components/Shared/Src/UiWidget.cpp @@ -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); } diff --git a/Components/Toolbar/Inc/Toolbar.hpp b/Components/Toolbar/Inc/Toolbar.hpp index 855d07c..649330c 100644 --- a/Components/Toolbar/Inc/Toolbar.hpp +++ b/Components/Toolbar/Inc/Toolbar.hpp @@ -5,11 +5,15 @@ #include #include +#include #include +#include #include #include "PaintUtil.hpp" #include "Theme.hpp" +#include "UiButtonWidget.hpp" +#include "UiWidget.hpp" namespace jdi { @@ -37,6 +41,7 @@ namespace jdi { public: Toolbar(); + ~Toolbar(); /** * @brief 绑定字体与主题资源。 @@ -135,19 +140,17 @@ namespace jdi private: /** - * @brief 单个工具栏按钮的运行时状态。 + * @brief 单个工具栏按钮的**静态配置**(矩形与外观状态由控件自己维护)。 */ struct Button { ToolbarAction action = ToolbarAction::NONE; /**< 按钮动作。 */ - std::wstring label; /**< 按钮文本。 */ - gd::RectF rect; /**< 按钮矩形。 */ - float width = 0.0f; /**< 按钮宽度。 */ + UiButtonWidget* widget = nullptr; /**< 对应控件(由 `m_root` 拥有)。 */ bool primary = false; /**< 是否为主按钮(强调色填充)。 */ - bool needsDocument = true; /**< 是否依赖已加载数据。 */ bool toggle = false; /**< 是否为可切换按钮。 */ + bool needsDocument = true; /**< 是否依赖已加载数据。 */ + bool toggle = false; /**< 是否为可切换按钮。 */ bool checked = false; /**< 可切换按钮的选中状态。 */ - bool hover = false; /**< 悬停状态(当前由 `Toolbar::m_hoverIndex` 派生,字段保留供未来使用)。 */ - bool pressed = false; /**< 按下状态。 */ + float width = 0.0f; /**< 按钮宽度(由控件实测得出)。 */ }; /** @@ -157,15 +160,20 @@ namespace jdi */ bool IsEnabled(const Button& button) const; + /** @brief 按固定规格表重建控件树(文本变化/首次布局时调用)。 */ + void RebuildWidgets(); + /** @brief 把可用性同步到控件(未加载数据时禁用依赖数据的按钮)。 */ + void SyncEnabledState(); + const UiFonts* m_fonts = nullptr; /**< 字体资源。 */ const Theme* m_theme = nullptr; /**< 主题资源。 */ float m_scale = 1.0f; /**< DPI 缩放比例。 */ bool m_documentLoaded = false; /**< 是否已加载数据。 */ - std::vector