Files
Just-DataInspector/Components/Shared/Src/UiWidget.cpp
T
DeepSeek V4.1 Flash 24706c906a feat(UI): 布局边界改为四角粗/四边细包角 + 全元素覆盖 + 几何同源(RowRect O(1))
## 1. 边界观感:四角粗、四边细

用户反馈"边框就是实心矩形、粗细不一,效果不佳"。旧实现是四条等宽实心矩形
(层级 0 用 2px、其余 1px),同一元素的角与边粗细不一致,父子矩形共边时还会叠成更粗的一条。

新规则:
- 四条边固定 1 设备像素(四条独立实心矩形;不用 DrawRectangle,笔宽奇偶会偏半像素);
- 四个角额外加粗:层级 0 = 4px、其余 3px,沿水平/垂直各延伸 cornerLen(≤14px 且 ≤ 短边一半);
- 层级 > 0 整体内缩 1px,父子矩形共边不再叠粗;
- 绝不填充(几十层嵌套填充会把内容糊住);
- 整像素对齐 + PixelOffsetModeNone + 关抗锯齿。

四角粗不只是观感:多条边重合时靠角的形状分辨层次(父=青蓝大包角、子=同位置品红小包角)。

## 2. 协议:组件"收集 box",宿主统一绘制

UiComponent::CollectDebugBoxes(UiDebugBoxList&)  默认只登记 Area()
UiWidget::CollectDebugBoxes(boxes, depth)        递归整棵控件树
Application::PaintLayoutBounds                   收集后按 depth 升序一次性描边

刻意保持 box 为**值类型**而不是让它继承控件基类:PlotView 的 4 层位图缓存、
SignalPanel 的 192 槽行位图缓存都要求"绘制由组件自己负责",几何进继承体系会与
缓存键/失效体系打架。box 不参与绘制,因此能让绘制/命中/调试共用同一份几何。

## 3. 覆盖补齐(此前大量元素被裹在外层单位里)

- Toolbar:区域 / 每个按钮 / **按钮内容(文字或齿轮图标,新增 UiButtonWidget::ContentRect())**
- TabBar:区域 / 标签条 / 新建按钮 / 每个 ✕
- SignalPanel:4 分区 + 筛选输入框/清空✕/全部显示 + 滚动轨道与滑块
  + 样式区 5 行及其 12 色块/自定义/3 模式/3 调整/线宽与透明度轨道和数值
  + 目标行/名称/GX 徽标 + **每个可见行**的折起三角/复选框/色块/名称/GX 徽标/✕
  (组行另有组名/计数)+ 内联编辑框
- PlotView:区域 / 每个网格 / GX 徽标 / 装饰层矩形 / 图例托底+色块+文本
  / **5 类浮动面板全部 20+ 字段**(标题/✕/折起三角/徽标/编辑行/按钮/内容区/滚动轨道与滑块
  /透明度轨道与数值/两个开关/各行复选框与数值与滑轨与复位与名称与✕/分组栏/表头/固定点行)
- PopupMenu:浮层 / 每个条目 / 可滚动可视区 / 轨道 / 滑块 / 二级子菜单与条目
- ColorPickerPopup:面板 / 标题 / 预览 / 色号 / 色域 / 色相条 / 3 条滑轨与数值 / 输入框

标签增加**尺寸门槛**(< 56×16 逻辑像素不画):复选框/色块/✕ 紧邻,逐个小框贴字会糊成一片。

## 4. 设置按钮配色 + 齿轮图标

- ToolbarAction::SETTINGS 改为 primary = true,与「导入文件」同为强调色主按钮。
- 齿轮几何重做:齿占最外一圈(0.66R→1.0R)、外环中心线 0.54R/笔宽 0.24R、轴孔 0.17R。
  旧版让齿从环中心线伸出,齿被环的描边吃掉 ⇒ 看起来只剩一个"甜甜圈"。

## 5. 几何同源与性能

- RowRect(row) 原本每次调用都从头累加行高(O(row)),而画一行要调它 **5 次**
  (✕/徽标/名称/组计数/折起三角各一次)⇒ 200 行只见 30 行时每帧上万次加法,
  命中测试(每次鼠标移动)同样走这条路。
  新增 m_rowTop 前缀和(由 UpdateAnimGeometry() 统一重建)⇒ **O(1)**;
  长度与行集合不一致时自动退回逐行累加(正确性优先)。
- ContentOffsetBeforeRow 同样改用前缀和。
- PanelBounds() 收敛 6 处重复的 (m_clientBounds.Width > 1.0f) ? m_clientBounds : m_area。

## 6. 验证

- make release / make debug 零 error 零 warning;TargetCheck.exe 910,336 B(889 KiB < 900 KiB)。
- .preflight/lbcheck:21 / 21 PASS(含 bounds-off-restores-pixels:关闭后逐像素回到开启前)。
- Tools/rendercheck:「工具栏带以下」区域哈希 8/8 与上一轮**逐一相同**
  (证明本轮改动只落在工具栏带内);FUNC 断言 7/7 PASS。
- Tools/uicheck:69 passed / 0 failed。

## 7. 文档

Documents/UI_Refactor_Plan.md / UI_Refactor_Progress.md / UI_Refactor_Report.md 追加第七阶段;
Documents/agent_mem.md 同步变更日志与「2026-10-28 第九轮」硬规则与踩坑记录。
2026-10-28 16:28:57 +08:00

667 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.
#include "UiWidget.hpp"
#include <algorithm>
#include <cmath>
#include "UiDebugBounds.hpp"
namespace jdi
{
UiEventResult UiEventResult::Handled()
{
UiEventResult result;
result.consumed = true;
result.redraw = true;
return result;
}
UiEventResult UiEventResult::Swallowed()
{
UiEventResult result;
result.consumed = true;
result.redraw = false;
return result;
}
UiPaintContext UiPaintContext::WithClip(const gd::RectF& rect) const
{
UiPaintContext child = *this;
gd::RectF intersection;
if (UiRectIntersect(clip, rect, intersection))
{
child.clip = intersection;
}
else
{
child.clip = gd::RectF();
}
return child;
}
UiWidget::UiWidget() = default;
UiWidget::~UiWidget()
{
// 子节点析构前先把"指向自己的根级状态"清掉:否则根的 m_capture / m_modal / m_hover
// 会留下悬空指针(子节点被移除后又被派发事件 = 访问违例)。
UiWidget* root = Root();
if (root != nullptr && root != this)
{
if (root->m_capture == this)
{
root->m_capture = nullptr;
}
if (root->m_modal == this)
{
root->m_modal = nullptr;
}
if (root->m_hover == this)
{
root->m_hover = nullptr;
}
}
}
// ------------------------------------------------------------------
// 树结构
// ------------------------------------------------------------------
UiWidget& UiWidget::AddChild(std::unique_ptr<UiWidget> child)
{
if (child == nullptr)
{
return *this;
}
UiWidget* raw = child.get();
raw->m_parent = this;
m_children.push_back(std::move(child));
// 子节点的期望尺寸会影响自己 ⇒ 向上失效。
InvalidateMeasure();
return *this;
}
void UiWidget::RemoveChild(UiWidget* child)
{
if (child == nullptr)
{
return;
}
for (auto iterator = m_children.begin(); iterator != m_children.end(); ++iterator)
{
if (iterator->get() == child)
{
(*iterator)->m_parent = nullptr;
m_children.erase(iterator);
InvalidateMeasure();
return;
}
}
}
UiWidget* UiWidget::Root()
{
UiWidget* node = this;
while (node->m_parent != nullptr)
{
node = node->m_parent;
}
return node;
}
const UiWidget* UiWidget::Root() const
{
const UiWidget* node = this;
while (node->m_parent != nullptr)
{
node = node->m_parent;
}
return node;
}
// ------------------------------------------------------------------
// 布局
// ------------------------------------------------------------------
void UiWidget::InvalidateMeasure()
{
if (!m_measureValid)
{
// 已经失效 ⇒ 不必继续上溯(祖先此前也一定被失效过)。
return;
}
m_measureValid = false;
if (m_parent != nullptr)
{
m_parent->InvalidateMeasure();
}
}
gd::SizeF UiWidget::Measure(const UiMeasureContext& ctx)
{
const gd::SizeF size = m_measureValid ? m_desiredSize : OnMeasure(ctx);
m_desiredSize = size;
m_measureValid = true;
return m_desiredSize;
}
void UiWidget::Arrange(const gd::RectF& bounds)
{
m_bounds = bounds;
OnArrange();
}
void UiWidget::MeasureChildren(const UiMeasureContext& ctx)
{
for (const std::unique_ptr<UiWidget>& child : m_children)
{
if (child == nullptr || !child->m_visible)
{
continue;
}
child->Measure(ctx);
}
}
gd::SizeF UiWidget::ChildDesiredSize(std::size_t index) const
{
if (index >= m_children.size() || m_children[index] == nullptr)
{
return gd::SizeF(0.0f, 0.0f);
}
return m_children[index]->m_desiredSize;
}
// ------------------------------------------------------------------
// 绘制
// ------------------------------------------------------------------
bool UiWidget::DirtyIntersects(const gd::RectF& dirty) const
{
gd::RectF intersection;
return UiRectIntersect(dirty, m_bounds, intersection);
}
void UiWidget::Paint(UiPaintContext& ctx)
{
if (!m_visible || ctx.g == nullptr || !UiRectHasArea(m_bounds))
{
return;
}
// (1) 与脏区求交:**脏区为空表示"未知 ⇒ 全画"**。
gd::RectF effectiveDirty = ctx.dirty;
if (UiRectHasArea(effectiveDirty) && !DirtyIntersects(effectiveDirty))
{
// 本节点及其整棵子树都不在脏区内 ⇒ 直接跳过(这是本框架唯一真正省时间的机制)。
return;
}
// (2) 裁剪 = 祖先裁剪切 ∩ (自身矩形外扩 1px);脏区非空时再与脏区求交。
// ⚠️ 外扩 1px 是刻意设计的:边框/描边是**以矩形边界为中心**画的(`UiDrawRoundRect`
// 的 1px 笔宽有一半在矩形外),严格按矩形裁剪会把外半侧切掉 ⇒ 控件看起来变细。
// 这是与旧绘制代码保持“逐像素一致”的必要保证。
UiPaintContext local = ctx;
gd::RectF clip;
if (!UiRectIntersect(ctx.clip, UiRectInflate(m_bounds, kUiClipBleed), clip))
{
return; // 完全被祖先裁掉
}
if (UiRectHasArea(effectiveDirty))
{
gd::RectF clipWithDirty;
if (!UiRectIntersect(clip, effectiveDirty, clipWithDirty))
{
return;
}
clip = clipWithDirty;
}
local.clip = clip;
// (3) 收紧 GDI+ 裁剪。⚠️ 这里**只**约束 GDI+ 矢量绘制;GDI 绘制(`UiText` /
// `UiFillRect` 的快路径 / `UiDrawSolidLines`)**不受**该裁剪约束 ⇒ 派生类必须把
// `local.clip` 传给 `UiTextClipped` 一类带 `ETO_CLIPPED` 的辅助函数,
// 否则文字会画到本节点之外(本项目"滚动时文字串到标题栏/工具栏"就是这类 BUG)。
const int saveState = ctx.g->Save();
ctx.g->SetClip(local.clip, gd::CombineModeIntersect);
local.dirty = UiRectHasArea(effectiveDirty) ? clip : gd::RectF();
OnPaint(local);
// (4) 子节点:顺序 = z 序(末尾最上层),脏区向下取交集。
for (const std::unique_ptr<UiWidget>& child : m_children)
{
if (child == nullptr)
{
continue;
}
UiPaintContext childContext = local;
gd::RectF childClip;
if (!UiRectIntersect(local.clip, UiRectInflate(child->m_bounds, kUiClipBleed), childClip))
{
continue;
}
childContext.clip = childClip;
childContext.dirty = local.dirty;
child->Paint(childContext);
}
ctx.g->Restore(saveState);
}
bool UiWidget::DispatchRedraw(UiEvent& event)
{
Dispatch(event);
return UiRectHasArea(TakeDirty());
}
void UiWidget::CollectDebugBoxes(UiDebugBoxList& boxes, int depth) const
{
if (!m_visible || !UiRectHasArea(m_bounds))
{
// 不可见 / 未摆放的节点没有边界可画,其子树也不会被 `Paint` 画出来 ⇒ 一并跳过。
return;
}
boxes.Add(m_bounds, depth);
// 子节点按 z 序递归(末尾最上层)—— 与 `Paint` 的顺序一致,重叠时上层压住下层。
for (const std::unique_ptr<UiWidget>& child : m_children)
{
if (child != nullptr)
{
child->CollectDebugBoxes(boxes, depth + 1);
}
}
}
void UiWidget::Invalidate(const gd::RectF& rect)
{
if (!UiRectHasArea(rect))
{
return;
}
m_dirty = UiRectUnion(m_dirty, rect);
if (m_parent != nullptr)
{
// 向上冒泡的是"客户区坐标"的同一块矩形;父层只并集,**不清空**子节点的脏区。
m_parent->Invalidate(rect);
}
}
void UiWidget::InvalidateAll()
{
m_dirty = m_bounds;
if (m_parent != nullptr)
{
m_parent->Invalidate(m_bounds);
}
for (const std::unique_ptr<UiWidget>& child : m_children)
{
if (child != nullptr)
{
child->InvalidateAll();
}
}
}
gd::RectF UiWidget::TakeDirty()
{
const gd::RectF dirty = m_dirty;
m_dirty = gd::RectF();
return dirty;
}
// ------------------------------------------------------------------
// 事件
// ------------------------------------------------------------------
bool UiWidget::Contains(int x, int y) const
{
if (!m_visible || m_hitTransparent || !UiRectHasArea(m_bounds))
{
return false;
}
const float fx = static_cast<float>(x);
const float fy = static_cast<float>(y);
return fx >= m_bounds.X && fx < m_bounds.X + m_bounds.Width && fy >= m_bounds.Y &&
fy < m_bounds.Y + m_bounds.Height;
}
UiWidget* UiWidget::HitTestDeep(int x, int y)
{
if (!Contains(x, y))
{
return nullptr;
}
// 反 z 序:末尾(最上层)优先。
for (auto iterator = m_children.rbegin(); iterator != m_children.rend(); ++iterator)
{
if (*iterator == nullptr)
{
continue;
}
if (UiWidget* hit = (*iterator)->HitTestDeep(x, y))
{
return hit;
}
}
return this;
}
UiEventResult UiWidget::Dispatch(UiEvent& event)
{
UiWidget* root = Root();
if (root == nullptr)
{
return UiEventResult();
}
const bool mouseEvent = (event.kind == UiEventKind::MOUSE_MOVE ||
event.kind == UiEventKind::MOUSE_DOWN ||
event.kind == UiEventKind::MOUSE_UP ||
event.kind == UiEventKind::MOUSE_DOUBLE_CLICK ||
event.kind == UiEventKind::MOUSE_WHEEL ||
event.kind == UiEventKind::QUERY_CURSOR);
// (1) 捕获优先:拖拽期间所有鼠标事件直接投给捕获者(与 Windows `SetCapture` 同义)。
if (mouseEvent && root->m_capture != nullptr)
{
return root->m_capture->OnEvent(event);
}
// (2) 模态:只向模态子树派发(菜单/拾色器等浮层)。
if (root->m_modal != nullptr)
{
return root->m_modal->DispatchAlongHitPath(event);
}
// (3) 悬停链(只在根节点维护:鼠标移动会改变悬停者)。
if (event.kind == UiEventKind::MOUSE_MOVE || event.kind == UiEventKind::MOUSE_LEAVE)
{
root->UpdateHover(event.x, event.y);
}
// (4) 键盘/字符:沿焦点链派发(无焦点时交给根,保证宿主级快捷键仍可用)。
if (event.kind == UiEventKind::KEY_DOWN || event.kind == UiEventKind::CHAR)
{
UiWidget* node = (root->m_focus != nullptr) ? root->m_focus : root;
for (; node != nullptr; node = node->m_parent)
{
UiEventResult result = node->OnEvent(event);
if (result.consumed)
{
return result;
}
}
return UiEventResult();
}
return DispatchAlongHitPath(event);
}
void UiWidget::CollectHitChain(int x, int y, std::vector<UiWidget*>& chain)
{
if (!Contains(x, y))
{
return;
}
chain.push_back(this);
// 反 z 序:末尾(最上层)优先;只进入**真正命中**的那一个子节点。
for (auto iterator = m_children.rbegin(); iterator != m_children.rend(); ++iterator)
{
if (*iterator == nullptr)
{
continue;
}
if ((*iterator)->Contains(x, y))
{
(*iterator)->CollectHitChain(x, y, chain);
return;
}
}
}
UiEventResult UiWidget::DispatchAlongHitPath(UiEvent& event)
{
if (!m_visible)
{
return UiEventResult();
}
const bool positional = (event.kind == UiEventKind::MOUSE_MOVE ||
event.kind == UiEventKind::MOUSE_DOWN ||
event.kind == UiEventKind::MOUSE_UP ||
event.kind == UiEventKind::MOUSE_DOUBLE_CLICK ||
event.kind == UiEventKind::MOUSE_WHEEL ||
event.kind == UiEventKind::QUERY_CURSOR);
if (!positional)
{
// 非位置事件(理论上已被 `Dispatch` 分流):自身优先,再逐个子节点。
UiEventResult result = OnEvent(event);
if (result.consumed)
{
return result;
}
for (const std::unique_ptr<UiWidget>& child : m_children)
{
if (child == nullptr || !child->m_visible)
{
continue;
}
result = child->DispatchAlongHitPath(event);
if (result.consumed)
{
return result;
}
}
return UiEventResult();
}
std::vector<UiWidget*> chain;
CollectHitChain(event.x, event.y, chain);
if (chain.empty())
{
return UiEventResult();
}
// 自最深的命中节点向上冒泡:命中者优先,未消费才交给祖先(合法的"兜底"语义)。
for (UiWidget* node : chain)
{
if (event.kind == UiEventKind::QUERY_CURSOR)
{
if (HCURSOR cursor = node->OnQueryCursor(event.x, event.y))
{
event.cursor = cursor;
return UiEventResult::Swallowed();
}
continue;
}
UiEventResult result = node->OnEvent(event);
if (result.consumed)
{
return result;
}
}
if (event.kind == UiEventKind::QUERY_CURSOR && event.cursor != nullptr)
{
return UiEventResult::Swallowed();
}
return UiEventResult();
}
void UiWidget::UpdateHover(int x, int y)
{
UiWidget* target = (m_visible) ? HitTestDeep(x, y) : nullptr;
if (target == m_hover)
{
return;
}
UiWidget* previous = m_hover;
m_hover = target;
if (previous != nullptr)
{
previous->m_hovered = false;
previous->OnHoverChanged(false);
previous->Invalidate(previous->m_bounds);
}
if (target != nullptr)
{
target->m_hovered = true;
target->m_hoverX = x;
target->m_hoverY = y;
target->OnHoverChanged(true);
target->Invalidate(target->m_bounds);
}
}
// ------------------------------------------------------------------
// 交互仲裁
// ------------------------------------------------------------------
void UiWidget::CaptureMouse()
{
UiWidget* root = Root();
if (root != nullptr)
{
root->m_capture = this;
}
}
void UiWidget::ReleaseMouse()
{
UiWidget* root = Root();
if (root != nullptr && root->m_capture == this)
{
root->m_capture = nullptr;
}
}
UiWidget* UiWidget::MouseCaptureWidget() const
{
const UiWidget* root = Root();
return (root != nullptr) ? root->m_capture : nullptr;
}
void UiWidget::SetModal(bool modal)
{
UiWidget* root = Root();
if (root == nullptr)
{
return;
}
if (modal)
{
root->m_modal = this;
}
else if (root->m_modal == this)
{
root->m_modal = nullptr;
}
}
UiWidget* UiWidget::ModalWidget() const
{
const UiWidget* root = Root();
return (root != nullptr) ? root->m_modal : nullptr;
}
UiWidget* UiWidget::HoverWidget() const
{
const UiWidget* root = Root();
return (root != nullptr) ? root->m_hover : nullptr;
}
bool UiWidget::HoverPoint(int& x, int& y) const
{
if (!m_hovered)
{
return false;
}
x = m_hoverX;
y = m_hoverY;
return true;
}
void UiWidget::RequestFocus()
{
UiWidget* root = Root();
if (root == nullptr || root->m_focus == this)
{
return;
}
if (root->m_focus != nullptr)
{
root->m_focus->Invalidate(root->m_focus->m_bounds);
}
root->m_focus = this;
Invalidate(m_bounds);
}
void UiWidget::ReleaseFocus()
{
UiWidget* root = Root();
if (root != nullptr && root->m_focus == this)
{
root->m_focus = nullptr;
Invalidate(m_bounds);
}
}
UiWidget* UiWidget::FocusWidget() const
{
const UiWidget* root = Root();
return (root != nullptr) ? root->m_focus : nullptr;
}
// ------------------------------------------------------------------
// 动画
// ------------------------------------------------------------------
bool UiWidget::AdvanceAnimation(std::int64_t nowMs)
{
bool active = AdvanceAnimationSelf(nowMs);
for (const std::unique_ptr<UiWidget>& child : m_children)
{
if (child != nullptr && child->AdvanceAnimation(nowMs))
{
active = true;
}
}
return active;
}
bool UiWidget::AnimationActive() const
{
if (AnimationActiveSelf())
{
return true;
}
for (const std::unique_ptr<UiWidget>& child : m_children)
{
if (child != nullptr && child->AnimationActive())
{
return true;
}
}
return false;
}
// ------------------------------------------------------------------
// 缓存键
// ------------------------------------------------------------------
std::uint64_t UiWidget::BaseContentKey() const
{
std::uint64_t key = 0xCBF29CE484222325ull;
key = UiMixKeyFloat(key, m_bounds.X);
key = UiMixKeyFloat(key, m_bounds.Y);
key = UiMixKeyFloat(key, m_bounds.Width);
key = UiMixKeyFloat(key, m_bounds.Height);
key = UiMixKey(key, m_visible ? 1u : 0u);
return key;
}
} // namespace jdi