Files
DeepSeek V4.1 Flash 044b87383b 体积 L3(-fno-exceptions + MemProbe + LTO)与 L1 几何产物层;产物 909,824 -> 788,992 B
## 为什么

用户 2026-10-29 要求:按既定计划完整实现改进,但
① **不得把编译产物拆成 DLL**(项目定位是单文件轻量工具,最终只能有一个 .exe);
② 异常处理改用**预检验**而不是 try/catch(-fno-exceptions 省体积,且 try/catch 本身开销大);
③ 先删除 Documents 内三份 UI 重构文档再按"计划 / 当前进度 / 最终报告"重建;
④ 顺便审计代码,删除无用/过时注释,修复名实不符;
⑤ 提交到本地仓库,期间不要占用用户的鼠标/键盘/屏幕。

## 改了什么

### 1. 体积(909,824 B -> 788,992 B,-120,832 B,-13.3%)

Makefile 新增三个可覆盖开关(默认全为 1):

- `LAYOUT_BOUNDS`  编入/剔除「显示布局边界」叠加层
- `NO_EXCEPTIONS`  追加 `-fno-exceptions`
- `LTO`            追加 `-flto`(编译 + 链接)

派生:`FUNC_SECTIONS`(LTO 下为空)、`GC_SECTIONS`(`-Wl,--gc-sections`)、
`ASM_LISTING`/`ASM_LISTING_C`(LTO 下为空,因为 lto-wrapper 会丢弃 `-Wa` 清单)。

两个实测复现的坑(已写进 Makefile 注释):

- **切换 LTO 必须 `make clean`**:LTO 与非 LTO 目标文件混链会让 GCC 15.2.0 在
  `binds_to_current_def_p (symtab.cc:2598)` 处 ICE。
- **`-flto` 与 `-ffunction-sections -fdata-sections` 同时打开也 ICE**(干净重编也必崩,
  报错落点是 `DataModel.hpp:393`,但那是 COMDAT 解析落点、不是原因)⇒
  LTO 下 `FUNC_SECTIONS` 置空,并加 `-flto-partition=one`:默认 11 个分区的 LTRANS
  稳定复现,单分区从未复现。**这是稳定性选项,不是性能选项。**

### 2. 关异常必须成对交付:预检验 + 兜底

新增 `Utilities/Inc/MemProbe.hpp` + `Utilities/Src/MemProbe.cpp`:

- `MemAvailablePhysical()`:`GlobalMemoryStatusEx` + 200ms 缓存
- `MemCanAllocate(bytes)`:预留后仍须保有 1/8 可用内存
- `MemReserveIfRoom(container, n)`:含 `capacity()` 早退与乘法溢出保护
- `MemInstallTerminateHandler()`:OOM 时弹一次带建议的模态提示后 `_Exit`(而不是静默消失)

接入:

- `Utilities/Src/DataModel.cpp`:全仓**唯一**的 `try/catch`(`extremes.reserve` 捕
  `std::bad_alloc`)改为 `MemReserveIfRoom(extremes, count / 8)`
- `Components/PlotView/Src/PlotView.cpp`:`AvailablePhysicalBytes()` 原有一份**完整重复**
  的 `GlobalMemoryStatusEx` + 缓存实现 → 转发 `MemAvailablePhysical()`
- `Components/Application/Src/Application.cpp`:`Initialize()` 开头安装 terminate handler

### 3. L1 几何产物层

新增 `Components/Shared/Inc/UiBox.hpp` + `Src/UiBox.cpp`:

- `struct UiBox { rect, clip, depth, layer }` —— 纯值类型,不继承任何东西
  (继承会和 PlotView 4 层位图缓存 / SignalPanel 行位图缓存的失效体系打架)
- `class UiBoxTable : public UiTextRectSink` —— 只累计几何 + 裁剪/层/遮挡,**不含绘制代码**

`UiComponent::CollectDebugBoxes` → **`virtual void BuildBoxes(UiBoxTable&)`**:
组件从"收集布局边界(调试用)"改为"上报几何产物",不再需要知道"调试"这个概念存在。
`UiDebugBounds.hpp/.cpp` 退化为唯一的消费者(配色 + 包角描边 + 遮挡挖洞)。

除宿主 `ApplicationCommands.cpp` 外,所有组件源码现在只包含 `UiBox.hpp`。

### 4. 审计

- `MemProbe` 里"省 40~60KB"的估算 → 改为实测 27,136 B
- `MemProbe.hpp` 的 `<new>`、`MemProbe.cpp` 的 `<cstdio>`:未使用,删除
- `UiDebugPaintBounds(g, rect, depth)`:零调用者,删除(只保留带 clip 的版本)
- `TabBar`/`Toolbar`/`PlotView`/`SignalPanel` 的 `BuildBoxes` 文档"(调试用)"→"几何 box"
- `PlotView::SetForceFullRepaint` 补写"为什么保留而不优化"

### 5. 文档

`Documents/UI_Refactor_Plan.md` / `_Progress.md` / `_Report.md` 先 `git rm` 再按
「计划 / 当前进度 / 最终报告」重建;`Documents/agent_mem.md` 追加第十三轮记录。

## 怎么验证

```
make clean
make release TARGET_FILE_NAME=JustDataInspector -j16   # 零 error / 零 warning,788,992 B
make debug   TARGET_FILE_NAME=JustDataInspectorD -j16  # 零 warning

uicheck 69/69   lbcheck 21/21  lbresid 5/5  lbzorder 4/4  lbcovr 7/7  lbpanel 10/10
rendercheck: 8 个场景哈希与改造前逐一相同 + FUNC 7/7 PASS, exit=0
  A FAED59DF284153DB  B E6443BFE4E3955F8  C 96A0AA8C402F1729  D E3D5DBD998E43720
  E DA0583CB13C26A6F  F FFB822159F604D1D  G 19F8DE8FE3A8DF18  H DBBDDE4CB2C97C92
```

体积溯源(`objdump -h`,基线 → 最终):
`.text` 735,600 → 657,168;`.rdata` 49,184 → 35,016;
`.xdata` 48,132 → 27,512;`.pdata` 35,424 → 28,524;`.rsrc` 20,656 不变。

## 没做什么(有意为之)

- **没有拆 DLL**(用户明确要求单文件)
- **没有改优化等级**(`-Oz` 保持)
- **L2(`UiDamage` 脏矩形 + 独立叠加层)评估后缓做**:受益方只有"调试开关打开时的帧率",
  而正确实现必须把叠加层旧矩形注入底层组件重绘范围,穿透 PlotView 四条快路径 + 两层缓存;
  在用户反复报过"拖拽错位 / 贴图残留"的背景下,风险与收益不成比例。理由写在
  `Documents/UI_Refactor_Plan.md` §5 与 `PlotView::SetForceFullRepaint` 的文档里。
2026-10-29 11:53:54 +08:00

372 lines
16 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_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;
class UiBoxTable;
/**
* @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] boxes 收集器。
* @param [IN] depth 层级(0 = 调用者自身,子节点依次 +1,决定描边颜色)。
*/
void BuildBoxes(UiBoxTable& boxes, int depth) 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__