Files
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

224 lines
8.7 KiB
C++
Raw Permalink 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 四边外扩(用于“描边允许超出自身矩形”的裁剪补偿)。 */
gd::RectF UiRectInflate(const gd::RectF& rect, float amount);
/**
* @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__