Files
Just-DataInspector/Components/Shared/Inc/UiWidget.hpp
T
DeepSeek V4.1 Flash 25264f9074 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。
2026-10-28 12:29:08 +08:00

338 lines
15 KiB
C++
Raw 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.
#ifndef __UI_WIDGET_HPP__
#define __UI_WIDGET_HPP__
#include <windows.h>
#include <objidl.h>
#include <gdiplus.h>
#include <cstdint>
#include <memory>
#include <vector>
#include "Theme.hpp"
#include "UiLayout.hpp"
namespace jdi
{
struct Theme;
struct UiFonts;
/**
* @brief 裁剪时允许超出控件矩形的像素数(逻辑像素 × scale)。
*
* 边框/描边以矩形边界为中心绘制(1px 笔宽有一半在矩形外),若严格按矩形裁剪
* 会把外半侧切掉(控件看起来变细)。因此裁剪区固定外扩这么多。
* ⚠️ 它只用于**限制控件画到哪**,不参与脏区判定(脏区始终是精确矩形)。
*/
constexpr float kUiClipBleed = 1.0f;
/**
* @brief 布局测量上下文(由根节点统一下发,子节点只读)。
*/
struct UiMeasureContext
{
const Theme* theme = nullptr; /**< 主题(子节点可能需要配色相关的尺寸)。 */
const UiFonts* fonts = nullptr; /**< 字体(文本尺寸测量必需)。 */
float scale = 1.0f; /**< 缩放系数(逻辑像素 → 设备像素)。 */
float availableWidth = -1.0f; /**< 父给出的宽度上限(<0 表示不限)。 */
float availableHeight = -1.0f; /**< 父给出的高度上限(<0 表示不限)。 */
};
/**
* @brief 绘制上下文。
*
* ⚠️ **`dirty` 的语义(重要)**:空矩形表示"脏区未知 ⇒ 全画";非空表示"只需保证这一块被更新"。
* 基类在进入子节点前会把 `dirty` 与子节点矩形求交,为空即**整棵子树跳过** ——
* 这是本框架唯一真正省时间的机制,禁止任何派生类把它退化成"父脏 ⇒ 子树全脏"。
*/
struct UiPaintContext
{
gd::Graphics* g = nullptr; /**< 绘图上下文(共享的后备缓冲/离屏位图)。 */
gd::RectF dirty; /**< 需要重绘的区域(客户区坐标)。 */
gd::RectF clip; /**< 当前有效裁剪矩形(= 祖先与本节点矩形的交集)。 */
float scale = 1.0f; /**< 缩放系数。 */
bool fastFrame = false; /**< 交互帧:允许降级(**几何不得改变**,只允许关抗锯齿等)。 */
/** @brief 取与给定矩形求交后的绘制上下文(子节点用)。 */
UiPaintContext WithClip(const gd::RectF& rect) const;
};
/**
* @brief 事件类型。
*/
enum class UiEventKind : int
{
NONE = 0,
MOUSE_MOVE, /**< 鼠标移动(可能带按键)。 */
MOUSE_DOWN, /**< 鼠标按下。 */
MOUSE_UP, /**< 鼠标抬起。 */
MOUSE_DOUBLE_CLICK, /**< 双击(**只有确实有双击语义的控件才应处理它**)。 */
MOUSE_WHEEL, /**< 滚轮。 */
MOUSE_LEAVE, /**< 指针离开该控件。 */
KEY_DOWN, /**< 键盘按下。 */
CHAR, /**< 字符输入。 */
QUERY_CURSOR /**< 询问光标形态(结果写回 `cursor`)。 */
};
/** @brief 鼠标按键(与 `UiEventKind` 组合使用)。 */
enum class UiMouseButton : int
{
NONE = 0,
LEFT = 1,
MIDDLE = 2,
RIGHT = 3
};
/**
* @brief 一个 UI 事件(客户区坐标)。
*/
struct UiEvent
{
UiEventKind kind = UiEventKind::NONE; /**< 事件类型。 */
int x = 0; /**< 鼠标横坐标。 */
int y = 0; /**< 鼠标纵坐标。 */
UiMouseButton button = UiMouseButton::NONE; /**< 涉及的按键。 */
int wheelDelta = 0; /**< 滚轮增量(1 格 = 120)。 */
bool shift = false; /**< Shift 是否按下。 */
bool control = false; /**< Ctrl 是否按下。 */
WPARAM key = 0; /**< 虚拟键码(`KEY_DOWN`)。 */
wchar_t character = 0; /**< 字符(`CHAR`)。 */
HCURSOR cursor = nullptr; /**< `QUERY_CURSOR` 的输出(nullptr = 未认领)。 */
};
/**
* @brief 事件处理结果。
*
* 与旧架构(返回 `bool 要不要重绘`)的关键差别:**"消费"与"重绘"是两件事**。
* 消费决定是否继续上抛;`dirty` 参与本帧脏区并集(为空表示"未知 ⇒ 取自身矩形")。
*/
struct UiEventResult
{
bool consumed = false; /**< 事件已被消费。 */
bool redraw = false; /**< 需要重绘。 */
gd::RectF dirty; /**< 需要重绘的矩形(客户区坐标;空 + `redraw` ⇒ 取自身矩形)。 */
/** @brief 构造"已消费且需要重绘自身"的结果。 */
static UiEventResult Handled();
/** @brief 构造"已消费但不需重绘"的结果(例如纯状态查询)。 */
static UiEventResult Swallowed();
};
/**
* @brief UI 控件基类:树结构 + 两段式布局 + 脏区下探绘制 + 冒泡派发 + 交互仲裁。
*
* ## 为什么要这一层
*
* 旧架构是"并列大组件 + 手写优先级链",每个叠加层都要把"几何 / 命中 / 绘制 / 缓存键"
* 各写一遍(`PlotView` 已有 310 个方法 / 203 个成员)。这里把这些重复收敛成基类契约:
*
* * **布局只算一次**:`Measure` 自下而上、`Arrange` 自上而下;命中/绘制/光标读同一份 `m_bounds`。
* * **裁剪契约统一**:`Paint` 进入时收紧 GDI+ 裁剪(`ctx.clip`),派生类必须把它传给
* `UiTextClipped` 等**GDI 绘制辅助函数**(GDI 文本不认 GDI+ 裁剪区,这是本项目反复踩到的坑)。
* * **脏区下探**:`dirty ∩ bounds` 为空 ⇒ 整棵子树跳过。
* * **交互仲裁**:capture / 模态 / 悬停链 / 光标链由基类提供,派生类只写自己的手势。
*
* ## 三条硬约束(改代码前先读)
*
* 1. **禁止"父脏 ⇒ 子树全脏"**:会把"只重绘一个网格/一个面板"打回整块重绘。
* 2. **禁止把数据→像素管线(网格/曲线/采样点)做成控件节点**:布局与虚调用会进每帧热路径。
* 3. **禁止每个控件自持位图**:内存按面积线性增长(128 网格已是 ≈250MB 量级)。
*/
class UiWidget
{
public:
UiWidget();
virtual ~UiWidget();
UiWidget(const UiWidget&) = delete;
UiWidget& operator=(const UiWidget&) = delete;
// ------------------------------------------------------------------
// 树结构
// ------------------------------------------------------------------
/** @brief 追加子节点(**追加到末尾 = 最上层**,绘制与命中都按此顺序)。 */
UiWidget& AddChild(std::unique_ptr<UiWidget> child);
/** @brief 移除子节点(不存在时无操作)。 */
void RemoveChild(UiWidget* child);
/** @brief 子节点集合(顺序 = z 序,末尾最上层)。 */
const std::vector<std::unique_ptr<UiWidget>>& Children() const { return m_children; }
/** @brief 父节点(根节点为 nullptr)。 */
UiWidget* Parent() const { return m_parent; }
/** @brief 根节点。 */
UiWidget* Root();
/** @brief 根节点(const)。 */
const UiWidget* Root() const;
// ------------------------------------------------------------------
// 布局
// ------------------------------------------------------------------
/** @brief 标记"期望尺寸"失效(会向根冒泡。只失效自己与**祖先**,不影响兄弟)。 */
void InvalidateMeasure();
/**
* @brief 自下而上求期望尺寸(带缓存)。
* @param [IN] ctx 测量上下文。
* @return 期望尺寸(**含**自身内边距;不含外边距 —— 本项目没有外边距概念)。
*/
gd::SizeF Measure(const UiMeasureContext& ctx);
/** @brief 定位:写入自身矩形并回调 `OnArrange()`(派生容器在其中摆放子节点)。 */
void Arrange(const gd::RectF& bounds);
/** @brief 当前矩形(客户区坐标,整像素对齐由调用方保证)。 */
const gd::RectF& Bounds() const { return m_bounds; }
/** @brief 上一帧测量得到的期望尺寸。 */
gd::SizeF DesiredSize() const { return m_desiredSize; }
// ------------------------------------------------------------------
// 绘制
// ------------------------------------------------------------------
/** @brief 绘制自身与全部子节点(内部处理裁剪下探与脏区跳过)。 */
void Paint(UiPaintContext& ctx);
/** @brief 局部失效:与自身矩形求交后向根冒泡。 */
virtual void Invalidate(const gd::RectF& rect);
/** @brief 整棵子树失效。 */
void InvalidateAll();
/** @brief 取走累计的脏区(根节点用)。空矩形表示"无失效"。 */
gd::RectF TakeDirty();
// ------------------------------------------------------------------
// 事件
// ------------------------------------------------------------------
/** @brief 派发事件:捕获/模态路由 → 命中链(自最深向上冒泡)→ 自身 `OnEvent`。 */
UiEventResult Dispatch(UiEvent& event);
/** @brief 点是否落在自身矩形内(不含可见性/透明判断)。 */
bool Contains(int x, int y) const;
// ------------------------------------------------------------------
// 交互仲裁
// ------------------------------------------------------------------
/** @brief 请求独占鼠标(根节点记录;等价于 `SetCapture`)。 */
void CaptureMouse();
/** @brief 释放独占(**只有当前的捕获者**才会成功,避免错误释放别人的捕获)。 */
void ReleaseMouse();
/** @brief 当前持有捕获的控件(无则 nullptr)。 */
UiWidget* MouseCaptureWidget() const;
/** @brief 设为模态子树:根节点只向它派发事件(菜单/拾色器等浮层用)。 */
void SetModal(bool modal);
/** @brief 当前模态子树(无则 nullptr)。 */
UiWidget* ModalWidget() const;
/** @brief 是否处于模态(向上查找到根)。 */
bool IsModalActive() const { return ModalWidget() != nullptr; }
/** @brief 设为"命中透明"(纯装饰元素:不参与命中与光标链)。 */
void SetHitTransparent(bool value) { m_hitTransparent = value; }
/** @brief 是否命中透明。 */
bool HitTransparent() const { return m_hitTransparent; }
/** @brief 设置可见性(不可见 = 不参与绘制与命中,**但不影响布局**)。 */
void SetVisible(bool visible) { m_visible = visible; }
/** @brief 是否可见。 */
bool Visible() const { return m_visible; }
/** @brief 当前悬停的控件(根节点维护)。 */
UiWidget* HoverWidget() const;
/** @brief 本控件当前是否被悬停(由根节点的悬停链维护)。 */
bool Hovered() const { return m_hovered; }
/** @brief 请求键盘焦点(根节点记录;键盘/字符事件沿焦点链派发)。 */
void RequestFocus();
/** @brief 释放焦点(**只有当前的焦点持有者**才会成功)。 */
void ReleaseFocus();
/** @brief 当前持有焦点的控件(无则 nullptr)。 */
UiWidget* FocusWidget() const;
// ------------------------------------------------------------------
// 动画
// ------------------------------------------------------------------
/**
* @brief 推进动画。
* @param [IN] nowMs 当前时间(`GetTickCount64()`)。
* @return 仍有动画进行中返回 true(宿主据此保持定时器)。
*/
bool AdvanceAnimation(std::int64_t nowMs);
/** @brief 是否仍有动画(不推进)。 */
bool AnimationActive() const;
protected:
/** @brief 推进**自身**动画(派生类覆写;返回"我这一层是否仍需继续推进")。 */
virtual bool AdvanceAnimationSelf(std::int64_t nowMs) { (void)nowMs; return false; }
/** @brief **自身**是否仍有动画(派生类覆写)。 */
virtual bool AnimationActiveSelf() const { return false; }
/** @brief 计算期望尺寸(派生类实现;**不得**修改子节点集合或自身矩形)。 */
virtual gd::SizeF OnMeasure(const UiMeasureContext& ctx) = 0;
/** @brief 自身矩形已更新:派生容器在这里摆放子节点。 */
virtual void OnArrange() {}
/** @brief 绘制自身(**不要**在这里遍历子节点;基类会负责)。 */
virtual void OnPaint(UiPaintContext& ctx) = 0;
/** @brief 自身处理事件(子节点都未消费时才会被调用)。 */
virtual UiEventResult OnEvent(UiEvent& event) { (void)event; return UiEventResult(); }
/** @brief 该位置的光标形态(nullptr = 不认领,交给下层/宿主)。 */
virtual HCURSOR OnQueryCursor(int x, int y) { (void)x; (void)y; return nullptr; }
/**
* @brief 内容指纹(缓存键的**唯一来源**)。
*
* 派生类必须把"会改变像素的**全部**状态"混进来;父层只负责聚合子节点的键。
* 漏字段的症状是"贴旧像素"(本项目历史上至少 4 次真实 BUG 都是这个原因)。
*/
virtual std::uint64_t ContentKey() const { return 0; }
/** @brief 基类能提供的那部分键(矩形/可见性)。 */
std::uint64_t BaseContentKey() const;
/** @brief 把子节点 `Measure` 一遍(派生容器在 `OnMeasure` 里调用)。 */
void MeasureChildren(const UiMeasureContext& ctx);
/** @brief 子节点的期望尺寸(需先 `MeasureChildren`)。 */
gd::SizeF ChildDesiredSize(std::size_t index) const;
/** @brief 命中测试:返回最上层(z 序末尾)且包含该点的子孙控件。 */
UiWidget* HitTestDeep(int x, int y);
/** @brief 悬停链变化通知(默认无操作;需要"指针进入/离开"语义的控件可覆写)。 */
virtual void OnHoverChanged(bool entered) { (void)entered; }
private:
/** @brief 沿命中链派发(位置事件);非位置事件沿子树顺序派发。 */
UiEventResult DispatchAlongHitPath(UiEvent& event);
/**
* @brief 命中链(自**最深**的命中节点开始,逐个回溯到本节点)。
*
* 只把事件交给"鼠标真正压住的那一条路径",**绝不**分发给同层兄弟 ——
* 否则一个点击可能同时被视觉上位于下方的控件处理(两层都改状态)。
*/
void CollectHitChain(int x, int y, std::vector<UiWidget*>& chain);
/** @brief 更新悬停链(只在根节点调用)。 */
void UpdateHover(int x, int y);
/** @brief 是否需要重绘(脏区与自身矩形求交)。 */
bool DirtyIntersects(const gd::RectF& dirty) const;
UiWidget* m_parent = nullptr; /**< 父节点。 */
std::vector<std::unique_ptr<UiWidget>> m_children; /**< 子节点(顺序 = z 序)。 */
gd::RectF m_bounds; /**< 当前矩形(客户区坐标)。 */
gd::SizeF m_desiredSize; /**< 期望尺寸。 */
gd::RectF m_dirty; /**< 累计脏区(自身坐标系)。 */
bool m_visible = true; /**< 是否可见。 */
bool m_hitTransparent = false; /**< 是否命中透明。 */
bool m_measureValid = false; /**< 期望尺寸是否有效。 */
bool m_hovered = false; /**< 是否被悬停(悬停链写入)。 */
// 根节点专用状态。
UiWidget* m_capture = nullptr; /**< 鼠标捕获者。 */
UiWidget* m_modal = nullptr; /**< 模态子树。 */
UiWidget* m_hover = nullptr; /**< 当前悬停控件。 */ UiWidget* m_focus = nullptr; /**< 键盘焦点控件。 */ };
} // namespace jdi
#endif // __UI_WIDGET_HPP__