Files
Just-DataInspector/Components/Shared/Inc/UiWidget.hpp
T
DeepSeek V4.1 Flash ab19a6e72e feat(UI): 显示布局边界(Android 风格)+ 工具栏设置菜单(齿轮)+ 三处可见收益优化
## 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 第八轮」硬约束与验证坑。
2026-10-28 15:46:45 +08:00

372 lines
16 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 派发一个事件,并回答"是否需要重绘"。
*
* 等价于 `Dispatch(event); return UiRectHasArea(TakeDirty());` —— 这是
* `Toolbar` / `TabBar` 等容器**唯一**需要的"事件后是否需要重绘"判断。
* 收敛成一个函数是为了避免"忘记取脏区"(取脏区同时会清零,漏掉一次就会
* 把下一次真实变化误判成"无变化")。
*
* @param [IN,OUT] event 事件(`QUERY_CURSOR` 的结果写回 `event.cursor`)。
* @return 事件产生了需要重绘的脏区返回 true。
*/
bool DispatchRedraw(UiEvent& event);
/**
* @brief 递归绘制本节点与全部子节点的布局边界(调试用)。
*
* 只读 `m_bounds`,不参与脏区、不进缓存 ⇒ 关闭开关时完全不执行。
* 悬停/按下/选中态一律不改变边界(那是外观而非布局),因此不需要额外状态。
*
* @param [IN,OUT] g 绘图上下文。
* @param [IN] scale DPI 缩放系数(只用于对齐,不用于线宽)。
* @param [IN] depth 层级(0 = 调用者自身,子节点依次 +1,决定描边颜色)。
*/
void PaintDebugBounds(gd::Graphics& g, float scale, int depth = 0) const;
/** @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 本控件最近一次收到的悬停点(客户区坐标)。
*
* 派生控件据此自行判断"悬停在自身内部的哪个热区"(例如标签的关闭 ✕);
* 未悬停时返回 false,出参不变。
*/
bool HoverPoint(int& x, int& y) const;
/** @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; /**< 是否被悬停(悬停链写入)。 */
int m_hoverX = 0; /**< 最近一次悬停点横坐标(悬停链写入)。 */
int m_hoverY = 0; /**< 最近一次悬停点纵坐标(悬停链写入)。 */
// 根节点专用状态。
UiWidget* m_capture = nullptr; /**< 鼠标捕获者。 */
UiWidget* m_modal = nullptr; /**< 模态子树。 */
UiWidget* m_hover = nullptr; /**< 当前悬停控件。 */ UiWidget* m_focus = nullptr; /**< 键盘焦点控件。 */ };
} // namespace jdi
#endif // __UI_WIDGET_HPP__