Files
Just-DataInspector/Components/Shared/Inc/UiLayout.hpp
T
DeepSeek V4.1 Flash 3529c7b44f UI 重构 P0+P1:新增布局/控件基础层与离屏回归网(对产品像素零影响)
新增 Components/Shared 下的 UI 基础层:
- UiLayout:矩形原语 / 对齐 / 设备像素内侧对齐 / Stack / Flow / 布局缓存(deque 保证引用稳定、
  定向失效、按最久未命中淘汰)。
- UiWidget:控件树基类。两段式布局(Measure 自下而上带缓存、Arrange 自上而下)、
  脏区下探绘制(dirty 为空=全画;与子树矩形无交集=整棵跳过;有效裁剪=祖先裁剪切∩自身∩脏区)、
  只沿命中链派发的冒泡模型(绝不分发给同层兄弟)、capture/modal/hover/focus 仲裁、
  光标认领链、统一动画 tick、内容指纹入口。

新增 Tools/uicheck 离屏回归网(62 项断言,全程不创建窗口、不注入输入):
- 布局数学:对齐/STRETCH/超尺寸夹取、Stack 固定与自动均分且绝不产生负尺寸、Flow 换行与 overflow。
- 缓存:引用稳定、定向失效、容量淘汰。
- 绘制:全画 / 子树跳过 / 裁剪收窄 / 不可见跳过 / 失效冒泡。
- 派发:重叠子节点只投最上层、hit-transparent 穿透、消费不上抛、capture 越过边界仍投递、
  仅持有者可释放、modal 屏蔽其余子树、祖先兜底、光标由最上层认领、键盘沿焦点链。
- 动画:时间戳统一下发、活跃状态汇总。

过程中修正自身两处缺陷:补齐 AdvanceAnimationSelf/AnimationActiveSelf 声明;
把"遍历同层兄弟"改为"只沿命中链派发 + 祖先兜底"(否则一次点击可能被视觉上位于下方的控件也处理)。

验证:make release TARGET_FILE_NAME=TargetCheck 零警告(873,984 B,低于 900KB 阈值);
Tools/uicheck 62 passed / 0 failed。

本提交不改动任何现有组件,Toolbar/TabBar/SignalPanel/PlotView 仍走旧路径,产品像素零变化。
文档:Documents/UI_Refactor_Plan.md(蓝图)、Documents/UI_Refactor_Progress.md(台账)。
2026-10-28 12:15:41 +08:00

221 lines
8.5 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_LAYOUT_HPP__
#define __UI_LAYOUT_HPP__
#include <windows.h>
#include <objidl.h>
#include <gdiplus.h>
#include <cstdint>
#include <cstddef>
#include <deque>
#include <vector>
#include "Theme.hpp"
namespace jdi
{
/**
* @brief 布局方向。
*/
enum class UiAxis : int
{
HORIZONTAL = 0, /**< 主轴为水平(从左到右排列)。 */
VERTICAL = 1 /**< 主轴为垂直(从上到下排列)。 */
};
/**
* @brief 在容器中的对齐方式(单一方向)。
*
* `STRETCH` = 占满该方向可用空间(尺寸由容器决定,忽略自身期望尺寸)。
*/
enum class UiAlign : int
{
BEGIN = 0, /**< 起始边(左 / 上)。 */
CENTER = 1, /**< 居中。 */
END = 2, /**< 结束边(右 / 下)。 */
STRETCH = 3 /**< 拉伸占满。 */
};
/**
* @brief 四边内边距(逻辑像素,使用时由调用方乘缩放系数)。
*/
struct UiInsets
{
float left = 0.0f; /**< 左内边距。 */
float top = 0.0f; /**< 上内边距。 */
float right = 0.0f; /**< 右内边距。 */
float bottom = 0.0f; /**< 下内边距。 */
/** @brief 四边同值。 */
static UiInsets All(float value);
/** @brief 左右 / 上下分别指定。 */
static UiInsets Symmetric(float horizontal, float vertical);
/** @brief 按缩放系数放大(DPR 1.25 等)。 */
UiInsets Scaled(float scale) const;
/** @brief 水平方向占用量。 */
float Horizontal() const { return left + right; }
/** @brief 垂直方向占用量。 */
float Vertical() const { return top + bottom; }
};
// ------------------------------------------------------------------
// 矩形原语
// ------------------------------------------------------------------
/** @brief 矩形是否有面积(宽度与高度都 > 0)。 */
bool UiRectHasArea(const gd::RectF& rect);
/** @brief 两个矩形的交集;无交集时输出空矩形并返回 false。 */
bool UiRectIntersect(const gd::RectF& a, const gd::RectF& b, gd::RectF& out);
/** @brief 两个矩形的并集(任一为空时直接返回另一个)。 */
gd::RectF UiRectUnion(const gd::RectF& a, const gd::RectF& b);
/** @brief 内缩(结果可能为空矩形)。 */
gd::RectF UiRectDeflate(const gd::RectF& rect, const UiInsets& insets);
/**
* @brief 把矩形对齐到**设备像素整数边界**(上界 `floor`、下界 `ceil`)。
*
* ⚠️ 离屏位图(图层 / 装饰层 / 面板层)必须用这个结果做贴图源矩形:
* 直接对浮点矩形取整会让补丁多算一行,从而盖到相邻网格的分界线上(历史 1px 接缝 BUG)。
*/
gd::RectF UiRectSnapInward(const gd::RectF& rect, float scale);
/** @brief 把一个 `size` 的盒子按对齐方式放进 `outer`(`STRETCH` 时返回 `outer` 本身)。 */
gd::RectF UiAlignInRect(const gd::RectF& outer, float width, float height, UiAlign horizontal,
UiAlign vertical, float offsetX = 0.0f, float offsetY = 0.0f);
// ------------------------------------------------------------------
// 布局:Stack(一维流)/ Flow(换行流)/ Abs(绝对)
// ------------------------------------------------------------------
/** @brief 尺寸未知(由剩余空间均分)。 */
constexpr float kUiSizeAuto = -1.0f;
/**
* @brief 一维栈布局的**期望尺寸**。
* @param [IN] sizes 各项主尺寸(`kUiSizeAuto` 表示由剩余空间均分,不参与求和)。
* @param [IN] gap 项间距。
* @return 主尺寸合计(含间距)。
*/
float UiStackExtent(const std::vector<float>& sizes, float gap);
/**
* @brief 一维栈布局:把 `area` 沿主轴依次切分给各项。
*
* * 固定尺寸项按给定值取用;
* * `kUiSizeAuto` 项均分**剩余**空间(剩余为负时按 0 处理,避免负尺寸);
* * 交叉轴一律占满 `area`(需要单独对齐时调用方再 `UiAlignInRect`)。
*
* @param [IN] area 可用区域。
* @param [IN] axis 主轴方向。
* @param [IN] sizes 各项主尺寸(可含 `kUiSizeAuto`)。
* @param [IN] gap 项间距。
* @param [OUT] out 逐项矩形(数量与 `sizes` 一致)。
* @param [OUT] usedExtent 实际占用主尺寸(含间距),可为空。
*/
void UiStackRects(const gd::RectF& area, UiAxis axis, const std::vector<float>& sizes, float gap,
std::vector<gd::RectF>& out, float* usedExtent = nullptr);
/**
* @brief 换行流布局(图例条目、工具栏按钮组)。
*
* 沿主轴依次摆放,放不下时**换到下一行/列**;放不下的项(含之后的所有项)
* 不再输出,其下标通过 `overflow` 返回 —— 调用方据此绘制 `+N` 之类的省略提示。
*
* @param [IN] area 可用区域。
* @param [IN] axis 主轴方向(通常是水平)。
* @param [IN] sizes 各项 (宽, 高)。
* @param [IN] gap 项间距(主轴与交叉轴相同)。
* @param [OUT] out 已放下的项矩形。
* @param [OUT] overflow 首个未放下的项下标;全部放下时为 `sizes.size()`。
* @return 实际占用的交叉轴尺寸(行数 × 行高)。
*/
float UiFlowRects(const gd::RectF& area, UiAxis axis, const std::vector<gd::SizeF>& sizes, float gap,
std::vector<gd::RectF>& out, std::size_t& overflow);
/** @brief 绝对布局:`outer` 左上角偏移 `(dx, dy)` 后取 `width × height`。 */
gd::RectF UiAbsRect(const gd::RectF& outer, float dx, float dy, float width, float height);
// ------------------------------------------------------------------
// 布局缓存
// ------------------------------------------------------------------
/**
* @brief 一份被缓存下来的布局结果。
*
* `rects` / `values` 的含义由调用方约定(例如浮动面板的"标题栏 / ✕ / 徽标 / 逐行矩形")。
*/
struct UiLayoutResult
{
std::uint64_t key = 0; /**< 布局键(由调用方构造,必须覆盖**全部**影响几何的输入)。 */
gd::RectF bounds; /**< 整体矩形。 */
std::vector<gd::RectF> rects; /**< 逐项矩形。 */
std::vector<float> values; /**< 附带数值(列宽 / 行高 / 内容总高等)。 */
std::uint64_t stamp = 0; /**< 最近一次命中序号(用于淘汰最久未用项)。 */
};
/**
* @brief 布局结果缓存(把"每帧被反复询问的几何"降到只算一次)。
*
* 设计要点:
* * 用 `std::deque` 存条目 ⇒ `Store` 之后**已有元素引用依然有效**(`vector` 扩容会失效);
* * 条目数量很小(面板种类 × 网格数),线性查找比哈希更快,也避免哈希碰撞带来的诡异行为;
* * **定向失效**:几何改变的那条路径必须显式 `Invalidate(key)`,
* **禁止**为了让缓存命中率好看而整体 `Clear()`(会让所有面板在同一帧全部重算)。
*/
class UiLayoutCache
{
public:
explicit UiLayoutCache(std::size_t capacity = 32);
/**
* @brief 查找命中项。
* @param [IN] key 布局键。
* @return 命中返回条目指针(**内容不可修改**),未命中返回 nullptr。
* @note 命中会刷新该条目的使用序号(因此本函数不是 `const`)。
*/
const UiLayoutResult* Find(std::uint64_t key);
/**
* @brief 存入(同键覆盖)并返回稳定引用。
* @param [IN] result 待存条目(`key` 必须已填)。
* @return 缓存内条目的引用(有效期到下一次 `Store` 引发淘汰为止)。
*/
const UiLayoutResult& Store(UiLayoutResult result);
/** @brief 定向失效。 */
void Invalidate(std::uint64_t key);
/** @brief 全部清空(只在"数据层结构变化"这类场景使用)。 */
void Clear();
/** @brief 当前条目数(探针用)。 */
std::size_t Size() const { return m_entries.size(); }
private:
std::size_t m_capacity; /**< 条目上限。 */
std::uint64_t m_stamp = 0; /**< 递增使用序号。 */
std::deque<UiLayoutResult> m_entries; /**< 条目(deque 保证引用稳定)。 */
/** @brief 淘汰最久未使用的条目,直到不超过上限。 */
void Trim();
};
/**
* @brief 组合布局键(FNV-1a 风格的增量混合)。
*
* ⚠️ 键必须覆盖**全部**影响几何的输入;漏一个字段的症状是"布局不更新"
* (与历史上"缓存键漏混字段 ⇒ 贴旧像素"是同一类 BUG)。
*/
std::uint64_t UiMixKey(std::uint64_t seed, std::uint64_t value);
/** @brief 浮点参与成键(按 1/1024 量化,避免亚像素抖动导致键频繁变化)。 */
std::uint64_t UiMixKeyFloat(std::uint64_t seed, float value);
} // namespace jdi
#endif // __UI_LAYOUT_HPP__