Files
Just-DataInspector/Components/Toolbar/Src/Toolbar.cpp
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

482 lines
14 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 "Toolbar.hpp"
#include <algorithm>
#include <cmath>
#include <utility>
#include "UiDebugBounds.hpp"
namespace jdi
{
namespace
{
const float kButtonHeight = 30.0f; /**< 按钮高度(逻辑像素)。 */
const float kButtonGap = 6.0f; /**< 按钮间距。 */
const float kButtonPadding = 14.0f; /**< 按钮左右内边距。 */
const float kGroupGap = 18.0f; /**< 分组间距。 */
const float kSidePadding = 12.0f; /**< 工具栏左右留白。 */
const float kTopPadding = 8.0f; /**< 工具栏上下留白。 */
/**
* @brief 工具栏的根控件:只负责"整条底色 + 底部分隔线"。
*
* 按钮是它的子节点(`UiButtonWidget`),由 `Toolbar::Layout` 逐个 `Arrange`。
* 之所以把根做成控件而不是让 `Toolbar` 自己遍历按钮绘制:命中 / 悬停 / 按下的仲裁
* 全部交给 `UiWidget` 的派发链与悬停链,`Toolbar` 只做"规格表 → 控件"的翻译。
*/
class ToolbarRootWidget : public UiWidget
{
public:
void Configure(const Theme* theme, float scale)
{
m_theme = theme;
m_scale = scale;
}
/** @brief 根尺寸由宿主(`Toolbar::Layout`)给定,不参与父级测量。 */
gd::SizeF OnMeasure(const UiMeasureContext& ctx) override
{
(void)ctx;
return gd::SizeF(Bounds().Width, Bounds().Height);
}
void OnPaint(UiPaintContext& ctx) override
{
if (m_theme == nullptr || ctx.g == nullptr || !UiRectHasArea(Bounds()))
{
return;
}
const gd::RectF& area = Bounds();
UiFillRect(*ctx.g, area, m_theme->toolbarBg);
// 底部分隔线:按钮不会覆盖到这一行,因此"底色 + 分隔线先画"与旧实现像素一致。
UiDrawLine(*ctx.g, area.X, area.Y + area.Height - 1.0f, area.X + area.Width,
area.Y + area.Height - 1.0f, m_theme->border, 1.0f);
}
private:
const Theme* m_theme = nullptr; /**< 主题。 */
float m_scale = 1.0f; /**< 缩放系数。 */
};
/** @brief 按钮规格(顺序即绘制顺序)。 */
struct ButtonSpec
{
ToolbarAction action; /**< 动作。 */
const wchar_t* label; /**< 默认文本(图标按钮留空)。 */
bool primary; /**< 是否主按钮。 */
bool needsDocument; /**< 是否依赖已加载数据。 */
bool toggle; /**< 是否可切换。 */
UiButtonIcon icon; /**< 图标(`NONE` = 纯文本按钮)。 */
};
// ⚠️ **前两项构成"左侧固定簇"**(设置齿轮 + 导入文件),其余按钮作为整体靠右。
// `Layout` 里用 `kLeftClusterCount` 引用这个约定的长度,增删左侧按钮时必须同步。
const ButtonSpec kButtonSpecs[] = {
{ToolbarAction::SETTINGS, L"", false, false, false, UiButtonIcon::GEAR},
{ToolbarAction::OPEN_FILE, L"导入文件", true, false, false, UiButtonIcon::NONE},
{ToolbarAction::FIT_ALL, L"适应视图", false, true, false, UiButtonIcon::NONE},
{ToolbarAction::AUTO_FIT_Y, L"Y 轴自适应", false, true, true, UiButtonIcon::NONE},
{ToolbarAction::ZOOM_SELECT, L"选区放大", false, true, true, UiButtonIcon::NONE},
{ToolbarAction::CURSOR, L"光标", false, true, true, UiButtonIcon::NONE},
{ToolbarAction::MERGE_LANES, L"合并网格", false, true, false, UiButtonIcon::NONE},
{ToolbarAction::SPLIT_LANES, L"拆分网格", false, true, false, UiButtonIcon::NONE},
{ToolbarAction::CLEAR_ALL, L"清空网格", false, true, false, UiButtonIcon::NONE},
};
/** @brief 左侧固定簇的按钮数量(从 `kButtonSpecs` 开头算起)。 */
constexpr std::size_t kLeftClusterCount = 2;
} // namespace
Toolbar::Toolbar() = default;
// 控件树用 `std::unique_ptr<UiWidget>` 持有,而具体根类型只在本 TU 可见
// ⇒ 析构函数必须在这里显式定义(否则其它 TU 里实例化会因类型不完整而编译失败)。
Toolbar::~Toolbar() = default;
void Toolbar::Setup(const UiFonts* fonts, const Theme* theme)
{
m_fonts = fonts;
m_theme = theme;
m_treeDirty = true;
}
void Toolbar::SetScale(float scale)
{
const float next = (scale > 0.1f) ? scale : 1.0f;
if (next == m_scale)
{
return;
}
m_scale = next;
// 控件里缓存了旧的缩放(字体实测宽度、图标尺寸)⇒ 必须重建。
m_treeDirty = true;
}
void Toolbar::SetDocumentLoaded(bool hasDocument)
{
if (m_documentLoaded == hasDocument)
{
return;
}
m_documentLoaded = hasDocument;
SyncEnabledState();
}
float Toolbar::DesiredHeight() const
{
return (kButtonHeight + kTopPadding * 2.0f) * m_scale;
}
bool Toolbar::IsEnabled(const Button& button) const
{
return button.needsDocument ? m_documentLoaded : true;
}
void Toolbar::SyncEnabledState()
{
for (Button& button : m_buttons)
{
if (button.widget != nullptr)
{
button.widget->SetEnabled(IsEnabled(button));
}
}
}
void Toolbar::RebuildWidgets()
{
m_root = std::make_unique<ToolbarRootWidget>();
static_cast<ToolbarRootWidget*>(m_root.get())->Configure(m_theme, m_scale);
m_buttons.clear();
const std::size_t specCount = sizeof(kButtonSpecs) / sizeof(kButtonSpecs[0]);
m_buttons.reserve(specCount);
for (std::size_t index = 0; index < specCount; ++index)
{
const ButtonSpec& spec = kButtonSpecs[index];
// 动态文本覆盖(例如「光标」按当前模式显示 光标 / 光标x1 / 光标x2)。
std::wstring label = spec.label;
for (const std::pair<ToolbarAction, std::wstring>& entry : m_labelOverrides)
{
if (entry.first == spec.action)
{
label = entry.second;
break;
}
}
Button button;
button.action = spec.action;
button.primary = spec.primary;
button.needsDocument = spec.needsDocument;
button.toggle = spec.toggle;
button.icon = spec.icon;
button.checked = false;
auto widget = std::make_unique<UiButtonWidget>();
UiButtonWidget* raw = widget.get();
raw->Configure(m_fonts, m_theme, m_scale, label, spec.primary, spec.toggle, kButtonPadding,
kButtonHeight);
raw->SetIcon(spec.icon);
if (m_theme != nullptr)
{
raw->SetCanvasColor(m_theme->toolbarBg);
}
raw->SetEnabled(IsEnabled(button));
// 点击回调只登记动作,由宿主在鼠标抬起后统一执行(与旧语义一致)。
raw->onClick = [this, action = spec.action]() { m_pendingAction = action; };
button.widget = raw;
m_root->AddChild(std::move(widget));
m_buttons.push_back(button);
}
}
void Toolbar::Layout(const gd::RectF& area)
{
m_area = area;
if (m_fonts == nullptr || m_fonts->ui == nullptr || m_theme == nullptr)
{
return;
}
// 控件树**只在必要时重建**(先记下各 toggle 的勾选状态,否则重建会把选中态清掉)。
// 纯粹的尺寸变化不走重建:按钮几何由 `Arrange` 决定,与树结构无关。
std::vector<std::pair<ToolbarAction, bool>> previousChecked;
if (m_treeDirty || m_root == nullptr)
{
previousChecked.reserve(m_buttons.size());
for (const Button& button : m_buttons)
{
previousChecked.emplace_back(button.action, button.checked);
}
RebuildWidgets();
m_treeDirty = false;
}
m_root->Arrange(area);
UiMeasureContext measure;
measure.theme = m_theme;
measure.fonts = m_fonts;
measure.scale = m_scale;
// 宽度由控件实测(`内边距 × 2 + 文本宽`),与旧公式完全一致;中文标签因此不会被裁切。
for (Button& button : m_buttons)
{
button.width = button.widget->Measure(measure).Width;
for (const std::pair<ToolbarAction, bool>& entry : previousChecked)
{
if (entry.first == button.action)
{
button.checked = entry.second;
button.widget->SetChecked(entry.second);
break;
}
}
}
// 左侧固定簇(设置齿轮 + 导入文件)靠左,其余按钮作为整体靠右。
const float top = area.Y + kTopPadding * m_scale;
const float height = kButtonHeight * m_scale;
const float leftX = area.X + kSidePadding * m_scale;
float leftWidth = 0.0f;
for (std::size_t index = 0; index < kLeftClusterCount && index < m_buttons.size(); ++index)
{
leftWidth += m_buttons[index].width;
if (index > 0)
{
leftWidth += kButtonGap * m_scale;
}
}
float cursorX = leftX;
for (std::size_t index = 0; index < kLeftClusterCount && index < m_buttons.size(); ++index)
{
m_buttons[index].widget->Arrange(gd::RectF(cursorX, top, m_buttons[index].width, height));
cursorX += m_buttons[index].width + kButtonGap * m_scale;
}
float groupWidth = 0.0f;
for (std::size_t index = kLeftClusterCount; index < m_buttons.size(); ++index)
{
groupWidth += m_buttons[index].width;
if (index + 1 < m_buttons.size())
{
groupWidth += kButtonGap * m_scale;
}
}
const float rightEdge = area.X + area.Width - kSidePadding * m_scale;
cursorX = std::max(leftX + leftWidth + kGroupGap * m_scale, rightEdge - groupWidth);
for (std::size_t index = kLeftClusterCount; index < m_buttons.size(); ++index)
{
m_buttons[index].widget->Arrange(gd::RectF(cursorX, top, m_buttons[index].width, height));
cursorX += m_buttons[index].width + kButtonGap * m_scale;
}
}
bool Toolbar::ButtonRect(ToolbarAction action, gd::RectF& rect) const
{
for (const Button& button : m_buttons)
{
if (button.action == action && button.widget != nullptr)
{
rect = button.widget->Bounds();
return UiRectHasArea(rect);
}
}
return false;
}
void Toolbar::SetToggleState(ToolbarAction action, bool checked)
{
for (Button& button : m_buttons)
{
if (button.action == action && button.toggle)
{
button.checked = checked;
if (button.widget != nullptr)
{
button.widget->SetChecked(checked);
}
return;
}
}
}
bool Toolbar::SetActionLabel(ToolbarAction action, const std::wstring& label)
{
// ⚠️ 只更新**唯一权威来源**(`m_labelOverrides`)并置树脏,不再逐个回写控件:
// 旧实现既要写覆盖表又要写控件,两处一旦不同步,下一次重建就会把文本退回去。
// 调用方拿到 true 后会重排(`Relayout`),届时由 `RebuildWidgets` 统一生效。
for (std::pair<ToolbarAction, std::wstring>& entry : m_labelOverrides)
{
if (entry.first == action)
{
if (entry.second == label)
{
return false;
}
entry.second = label;
m_treeDirty = true;
return true;
}
}
m_labelOverrides.emplace_back(action, label);
m_treeDirty = true;
return true;
}
void Toolbar::Paint(gd::Graphics& g)
{
if (m_theme == nullptr || m_fonts == nullptr || m_root == nullptr)
{
return;
}
UiPaintContext ctx;
ctx.g = &g;
ctx.clip = m_area;
ctx.scale = m_scale;
m_root->Paint(ctx);
}
bool Toolbar::HitTest(int x, int y) const
{
return UiPointInRect(m_area, static_cast<float>(x), static_cast<float>(y));
}
void Toolbar::PaintDebugBounds(gd::Graphics& g, float scale)
{
UiComponent::PaintDebugBounds(g, scale);
if (m_root == nullptr)
{
return;
}
// ⚠️ 跳过根控件:它的矩形与工具栏区域完全重合(已经被上一层画过),
// 再画一次只会多出一条压住自己的粗框。直接从**按钮**(层级 1)开始上报。
for (const std::unique_ptr<UiWidget>& button : m_root->Children())
{
if (button != nullptr)
{
button->PaintDebugBounds(g, scale, 1);
}
}
}
bool Toolbar::OnMouseMove(int x, int y)
{
if (m_root == nullptr)
{
return false;
}
UiEvent event;
event.kind = UiEventKind::MOUSE_MOVE;
event.x = x;
event.y = y;
// 悬停链切换会通过 `Invalidate` 把矩形冒泡到根 ⇒ 用脏区判断"是否需要重绘"
//(与旧实现"悬停下标变化才重绘"等价,但不再手工维护下标)。
return m_root->DispatchRedraw(event);
}
bool Toolbar::OnLButtonDown(int x, int y, ToolbarAction& action)
{
action = ToolbarAction::NONE;
if (m_root == nullptr)
{
return false;
}
UiEvent event;
event.kind = UiEventKind::MOUSE_DOWN;
event.x = x;
event.y = y;
event.button = UiMouseButton::LEFT;
m_root->Dispatch(event);
(void)m_root->TakeDirty();
for (const Button& button : m_buttons)
{
if (button.widget != nullptr && button.widget->Pressed())
{
action = button.action;
return true;
}
}
return false;
}
bool Toolbar::OnLButtonUp(int x, int y, ToolbarAction& action)
{
action = ToolbarAction::NONE;
if (m_root == nullptr)
{
return false;
}
// 旧语义:没有按下过就不进入抬起处理。
bool hadPressed = false;
for (const Button& button : m_buttons)
{
if (button.widget != nullptr && button.widget->Pressed())
{
hadPressed = true;
break;
}
}
if (!hadPressed)
{
return false;
}
m_pendingAction = ToolbarAction::NONE;
UiEvent event;
event.kind = UiEventKind::MOUSE_UP;
event.x = x;
event.y = y;
event.button = UiMouseButton::LEFT;
m_root->Dispatch(event);
// 旧实现会在抬起时按落点重算悬停态;派发一条 MOUSE_MOVE 让悬停链同步。
UiEvent sync;
sync.kind = UiEventKind::MOUSE_MOVE;
sync.x = x;
sync.y = y;
m_root->Dispatch(sync);
(void)m_root->TakeDirty();
action = m_pendingAction;
m_pendingAction = ToolbarAction::NONE;
return true;
}
bool Toolbar::OnMouseLeave()
{
if (m_root == nullptr)
{
return false;
}
UiEvent event;
event.kind = UiEventKind::MOUSE_LEAVE;
event.x = 0;
event.y = 0;
m_root->Dispatch(event);
// 指针离开窗口后不会再收到 MOUSE_UP ⇒ 在这里放弃"按下"(与旧实现一致)。
bool changed = false;
for (Button& button : m_buttons)
{
if (button.widget != nullptr && button.widget->Pressed())
{
button.widget->CancelPress();
changed = true;
}
}
changed = UiRectHasArea(m_root->TakeDirty()) || changed;
return changed;
}
} // namespace jdi