Files
Just-DataInspector/Components/Application/Src/ApplicationCommands.cpp
T
DeepSeek V4.1 Flash 34365b3c1c perf(size): 用自写整数格式化替换全部 std::to_wstring / std::to_string
背景:Code Review 报告 §10.3 / §11.5 建议「替换 std::to_wstring / std::to_string,
可去掉 gdtoa 链(≈33 KB)」。本轮把它做完,并给出这条建议的**真实边界**。

一、新增 `Utf8Util::FormatInteger*`(手写十进制展开,不经 printf)
* `FormatInteger(long long)` / `FormatIntegerUnsigned(unsigned long long)` /
  `FormatIntegerNarrow(long long)`(后者供 MatLoader 拼 HDF5 成员路径)。
* 语义与 `std::to_wstring` / `std::to_string` 完全一致:`0` → `"0"`、负数带 `-`、
  `LLONG_MIN` 用模 2^64 语义取绝对值(不会溢出)。
* 这样做的直接理由:预编译 libstdc++ 的 `to_string` 内部转调 `__mingw_swprintf`,
  工程里只要留一处,MinGW 的 printf 引擎(`mingw_wpformat.o`)+ gdtoa 就必然被链入。

二、全仓替换(76 处 `to_wstring` + 2 处 `to_string`,20 个文件)
* `MatLoader.cpp` 的 `TrimNumber` 整数分支也改为 `FormatInteger`(它曾是产物里
  **最后一个**引用 MinGW printf 的调用点)。
* 需要的地方补 `#include "Utf8Util.hpp"`(仅 `MatLoader.cpp` 一个新 include,其余由
  `PlotViewInternal` / `SignalPanelInternal` / `ApplicationInternal` / `DataModelInternal`
  已经包含)。

三、⚠️ 实测结论:gdtoa **去不掉**(把这件事彻底查清并写进 Makefile 注释)
* 只替换 `to_wstring`/`to_string`:**−1,024 B**(787,968 → 786,944)。
* 再叠加 `-D__USE_MINGW_ANSI_STDIO=0`(把 `swprintf` 让给系统 UCRT):**0 字节**。
* 根因(定位到文件与行):libstdc++ 的
  `lib/gcc/.../include/c++/x86_64-w64-mingw32/bits/os_defines.h:48-51` **无条件**执行
  `#undef __USE_MINGW_ANSI_STDIO` + `#define __USE_MINGW_ANSI_STDIO 1`
  (注释:libstdc++ depends on POSIX-definitions of those functions)。
  `g++ -E -dM` 实测最终值恒为 1 ⇒ **任何 C++ TU 的 `swprintf` / `strtod` 都是 MinGW 版本**,
  命令行无法覆盖。
* 要真省这 33 KB 只能自己实现「浮点 → 十进制(正确舍入)」与「浮点解析的
  十六进制/inf/nan 分支」,等价于重写 gdtoa。**判定:不做**(风险 >> 收益)。
* 该开关已回退,Makefile 里保留一段说明,避免后续接手者重复这两次实验。

四、验证
* 新增单元测试 `integer_replacement_matches_std`:12 个有符号样本(含 LLONG_MIN/MAX)
  + 5 个无符号样本(含 ULLONG_MAX),与 `std::to_wstring` **逐字符对照**,
  并校验窄/宽版本内容一致。
* `UnitTests`:`checks=260 failed=0 cases=19`(新增 41 条断言)。
* `OffscreenChecks -all`:6 个检查全 PASS,`checks=87 failed=0`。
* `panels -mode compare`:7 个面板像素哈希与几何**逐项不变**
  (整数文本从 `to_wstring` 换到手写实现后,渲染结果零差异 —— 这是最强的语义证据)。
* release 全量零 error / 零 warning;产物 787,968 → **786,944 B**。
2026-11-04 14:31:02 +08:00

647 lines
20 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.
// Application 实现分片 —— 本文件保留:**数据命令**(分组/信号增删、移动与重排)、
// 文件对话框、工具栏动作、键盘与鼠标分发、布局边界调试绘制。
//
// 共用的常量与工具函数在 ApplicationInternal.hpp(命名空间 `appdetail`,全部 inline)。
// 本文件原为 2,165 行的“大杂烩”,2026-11-04 代码审查按职责拆出:
// 标签页 → `ApplicationTabs.cpp`;派生/重采样/截取/克隆 → `ApplicationDerive.cpp`;
// 上下文菜单与设置菜单 → `ApplicationMenus.cpp`。划分见 `Code_Review_Plan.md` 的 P11。
#include "ApplicationInternal.hpp"
#include "UiDebugBounds.hpp"
namespace jdi
{
void Application::RemoveGroup(int groupIndex)
{
const SignalGroup* group = m_dataSet.GroupAt(groupIndex);
if (group == nullptr)
{
return;
}
const std::wstring name = group->name;
// 移除分组会释放大量采样内存:先暂停后台派生曲线计算,避免其读取已释放内存。
m_plot.PauseDerivedAsync();
// 先按旧下标修正各标签页配置与共享选中项,再从数据层移除,最后统一对齐。
for (TabConfig& tab : m_tabs)
{
tab.RemapAfterGroupRemoval(groupIndex);
}
m_dataSet.RemapSelectionAfterGroupRemoval(groupIndex);
m_dataSet.RemoveGroup(groupIndex);
SyncTabsToDataSet();
SetStatusMessage(L"已从工作区移除:" + name, false);
UpdateWindowTitle();
}
void Application::RemoveSignal(int groupIndex, int signalIndex)
{
SignalRef target;
target.groupIndex = groupIndex;
target.signalIndex = signalIndex;
const SignalData* signal = m_dataSet.Resolve(target);
const SignalGroup* group = m_dataSet.GroupAt(groupIndex);
if (signal == nullptr || group == nullptr)
{
return;
}
const std::wstring name = signal->name;
if (group->signals.size() <= 1)
{
// 组内只剩这一条信号:直接移除整个分组,避免在列表里留下一个空分组。
RemoveGroup(groupIndex);
return;
}
// 移除信号会释放采样内存:先暂停后台派生曲线计算,避免其读取已释放内存。
m_plot.PauseDerivedAsync();
// 先按旧下标修正各标签页配置与共享选中项,再从数据层移除(释放采样内存),最后对齐。
for (TabConfig& tab : m_tabs)
{
tab.RemapAfterSignalRemoval(groupIndex, signalIndex);
}
m_dataSet.RemapSelectionAfterSignalRemoval(groupIndex, signalIndex);
m_dataSet.RemoveSignal(groupIndex, signalIndex);
SyncTabsToDataSet();
SetStatusMessage(L"已从工作区移除信号:" + name, false);
}
std::wstring Application::UniqueGroupName(const std::wstring& base) const
{
const auto taken = [this](const std::wstring& candidate)
{
for (const SignalGroup& group : m_dataSet.Groups())
{
if (group.name == candidate)
{
return true;
}
}
return false;
};
if (!taken(base))
{
return base;
}
for (int suffix = 2; suffix < 1000; ++suffix)
{
const std::wstring candidate = base + L" (" + FormatInteger(suffix) + L")";
if (!taken(candidate))
{
return candidate;
}
}
return base;
}
bool Application::MoveSignalCore(int fromGroup, int fromSignal, int toGroup, int toIndex,
const std::wstring* newGroupName)
{
const SignalGroup* sourceGroup = m_dataSet.GroupAt(fromGroup);
if (sourceGroup == nullptr || fromSignal < 0 ||
static_cast<std::size_t>(fromSignal) >= sourceGroup->signals.size())
{
return false;
}
// 「移动到全新分组」:目标分组尚不存在,它会被追加到分组列表末尾
//(下标 = 当前分组数,可在此之前预先算出,供引用改写使用)。
const bool createGroup = (toGroup < 0) && (newGroupName != nullptr);
const int resolvedTo = createGroup ? static_cast<int>(m_dataSet.GroupCount()) : toGroup;
const SignalGroup* targetGroup = createGroup ? nullptr : m_dataSet.GroupAt(resolvedTo);
if (!createGroup && targetGroup == nullptr)
{
return false;
}
const bool sameGroup = !createGroup && (fromGroup == resolvedTo);
// 目标下标:负数/越界表示追加到末尾(组内重排时"末尾"= 组内信号数-1,因为该信号会先被摘出)。
int position = toIndex;
const int targetCount = createGroup ? 0 : static_cast<int>(targetGroup->signals.size());
if (createGroup)
{
position = 0;
}
else if (sameGroup)
{
if (position < 0 || position >= targetCount)
{
position = targetCount - 1;
}
if (position == fromSignal)
{
return false;
}
}
else if (position < 0 || position > targetCount)
{
position = targetCount;
}
SignalRef from;
from.groupIndex = fromGroup;
from.signalIndex = fromSignal;
SignalRef to;
to.groupIndex = resolvedTo;
to.signalIndex = position;
// 移动会搬运大量采样缓冲:先暂停后台派生曲线计算,避免其读取正在被移动的内存。
m_plot.PauseDerivedAsync();
// 先按**旧下标**修正各标签页内的全部信号引用,再动数据层。
// ⚠️ 跨组(含新建分组)时目标分组那一行可见性表可能还不存在,
// `RemapAfterSignalMove` 内部会自行扩展 —— 这里**不能**先 `SyncToDataSet`,
// 因为那次同步会按旧下标清理/修正引用,随后下标一变就会指向错误的信号。
if (sameGroup)
{
for (TabConfig& tab : m_tabs)
{
tab.RemapAfterSignalReorder(fromGroup, fromSignal, position);
}
}
else
{
for (TabConfig& tab : m_tabs)
{
tab.RemapAfterSignalMove(from, to, tab.IsVisible(from));
}
}
// 共享选中项:被移动的正是当前选中信号时,改为指向新位置。
if (m_dataSet.Selected().groupIndex == fromGroup)
{
if (sameGroup)
{
// 与 `RemapAfterSignalReorder` 同一套线性映射。
int sel = m_dataSet.Selected().signalIndex;
if (sel == fromSignal)
{
sel = position;
}
else if (fromSignal < position)
{
if (sel > fromSignal && sel <= position)
{
--sel;
}
}
else if (sel >= position && sel < fromSignal)
{
++sel;
}
SignalRef next;
next.groupIndex = fromGroup;
next.signalIndex = sel;
m_dataSet.SetSelected(next);
}
else if (m_dataSet.Selected().signalIndex == fromSignal)
{
m_dataSet.SetSelected(to);
}
}
int newSignalIndex = -1;
int newGroupIndex = -1;
if (createGroup)
{
// 建组 + 搬运做成一次原子操作(`AddGroup` 拒收空组,见 `MoveSignalToNewGroup`)。
if (!m_dataSet.MoveSignalToNewGroup(fromGroup, fromSignal, *newGroupName, newGroupIndex,
newSignalIndex))
{
// 前面已校验过,理论不可达;若真发生(数据层拒绝),必须把已改写的引用
// 按数据层的真实状态重新对齐,避免留下指向不存在分组的悬空引用。
SyncTabsToDataSet();
return false;
}
}
else
{
// 参数已在此前校验过(分组与信号下标都合法),本调用不会失败。
m_dataSet.MoveSignal(fromGroup, fromSignal, resolvedTo, position, newSignalIndex);
}
SyncTabsToDataSet();
return true;
}
void Application::ReorderSignal(int fromGroup, int fromSignal, int toGroup, int toIndex)
{
(void)MoveSignalCore(fromGroup, fromSignal, toGroup, toIndex, nullptr);
}
void Application::MoveSignalToGroup(int fromGroup, int fromSignal, int toGroup)
{
const SignalGroup* sourceGroup = m_dataSet.GroupAt(fromGroup);
if (sourceGroup == nullptr || fromSignal < 0 ||
static_cast<std::size_t>(fromSignal) >= sourceGroup->signals.size())
{
return;
}
const std::wstring signalName = sourceGroup->signals[static_cast<std::size_t>(fromSignal)].name;
if (toGroup < 0)
{
// 「移动到… → 新建分组」:组名固定为「新建分组」(重名自动加序号)。
const std::wstring name = UniqueGroupName(L"新建分组");
if (MoveSignalCore(fromGroup, fromSignal, -1, 0, &name))
{
SetStatusMessage(L"已将信号「" + signalName + L"」移动到新建分组:" + name, false);
}
return;
}
if (toGroup == fromGroup)
{
return; // 已经在该分组,无需移动
}
if (!MoveSignalCore(fromGroup, fromSignal, toGroup, -1, nullptr))
{
return;
}
const SignalGroup* destination = m_dataSet.GroupAt(toGroup);
SetStatusMessage(L"已将信号「" + signalName + L"」移动到分组:" +
(destination != nullptr ? destination->name : std::wstring()),
false);
}
void Application::OpenFileDialog()
{
// 允许一次选择多个文件,便于跨文件对比信号。
std::vector<wchar_t> buffer(static_cast<std::size_t>(MAX_PATH) * 16, L'\0');
OPENFILENAMEW dialog = {};
dialog.lStructSize = sizeof(dialog);
dialog.hwndOwner = m_hwnd;
dialog.lpstrFilter = SignalFileDialogFilter();
dialog.lpstrFile = buffer.data();
dialog.nMaxFile = static_cast<DWORD>(buffer.size());
dialog.lpstrTitle = L"打开测量数据文件(可多选)";
dialog.lpstrDefExt = L"csv";
dialog.Flags = OFN_FILEMUSTEXIST | OFN_PATHMUSTEXIST | OFN_EXPLORER | OFN_NOCHANGEDIR |
OFN_ALLOWMULTISELECT;
if (GetOpenFileNameW(&dialog) == FALSE)
{
return;
}
// 多选时首段为目录,其余为文件名;单选时整段即为完整路径。
const std::wstring first(buffer.data());
const wchar_t* cursor = buffer.data() + first.size() + 1;
int loaded = 0;
if (*cursor == L'\0')
{
if (LoadFile(first))
{
++loaded;
}
}
else
{
while (*cursor != L'\0')
{
const std::wstring name(cursor);
if (LoadFile(first + L"\\" + name))
{
++loaded;
}
cursor += name.size() + 1;
}
}
if (loaded > 0)
{
SetStatusMessage(L"已加载 " + FormatInteger(loaded) +
L" 个文件,请在左侧勾选需要查看的信号。",
false);
}
}
void Application::ExecuteToolbarAction(ToolbarAction action)
{
switch (action)
{
case ToolbarAction::SETTINGS:
// 齿轮按钮只负责**展开菜单**:不改变任何业务状态,因此不必走后面的"整帧失效",
// 只需要把浮层画出来(菜单打开后 `Render` 会因 `overlaysVisible` 自动整帧重绘)。
OpenSettingsMenu();
if (m_hwnd != nullptr)
{
InvalidateRect(m_hwnd, nullptr, TRUE);
}
return;
case ToolbarAction::OPEN_FILE:
OpenFileDialog();
break;
case ToolbarAction::FIT_ALL:
// 自适应同时回到初始状态:退出缩放模式并显示全部信号范围。
m_plot.FitAll();
SetStatusMessage(L"已恢复自适应视图。", false);
break;
case ToolbarAction::AUTO_FIT_Y:
{
// 与网格右键菜单中的「选区 Y 轴自适应(全局)」同一开关:
// 打开后立即按当前视图自适应一次,之后每次时间轴选区放大都会自动匹配。
const bool enable = !m_plot.AllLanesAutoFitToSelection();
m_plot.SetAllLanesAutoFitToSelection(enable, true);
SetStatusMessage(enable ? L"已开启选区 Y 轴自适应:时间轴选区放大后自动匹配各网格 Y 轴。"
: L"已关闭选区 Y 轴自适应。",
false);
break;
}
case ToolbarAction::ZOOM_SELECT:
m_plot.ToggleZoomMode(PlotZoomMode::SELECT);
break;
case ToolbarAction::CURSOR:
m_plot.CycleCursorMode();
break;
case ToolbarAction::MERGE_LANES:
if (!m_dataSet.Empty())
{
ActiveTab().SetMode(LaneMode::MERGED);
SetStatusMessage(L"已将当前标签页的全部可见信号合并到同一网格。", false);
}
break;
case ToolbarAction::SPLIT_LANES:
if (!m_dataSet.Empty())
{
ActiveTab().SetMode(LaneMode::PER_SIGNAL);
SetStatusMessage(L"当前标签页已切换为「每信号一格」。", false);
}
break;
case ToolbarAction::CLEAR_ALL:
if (!m_dataSet.Empty())
{
ActiveTab().SetAllVisible(false);
m_panel.InvalidateRows();
SetStatusMessage(L"已清空当前标签页的全部网格。", false);
}
break;
case ToolbarAction::NONE:
default:
break;
}
if (m_hwnd != nullptr)
{
InvalidateRect(m_hwnd, nullptr, TRUE);
}
}
bool Application::HandleKeyDown(WPARAM key)
{
const bool control = (GetKeyState(VK_CONTROL) & 0x8000) != 0;
if (control && key == L'O')
{
OpenFileDialog();
return true;
}
if (control && key == L'T')
{
NewTab(true);
return true;
}
if (control && key == L'W')
{
CloseTab(m_activeTab);
return true;
}
if (control && key == VK_TAB)
{
if (m_tabs.size() > 1)
{
const bool backward = (GetKeyState(VK_SHIFT) & 0x8000) != 0;
const int count = static_cast<int>(m_tabs.size());
SetActiveTab((m_activeTab + (backward ? count - 1 : 1)) % count);
}
return true;
}
switch (key)
{
case VK_ESCAPE:
{
// Esc 的语义:
// 1) 放弃当前正在进行的拖拽(典型场景:缩放的框选选区还没松开左键);
// 取消后不应用选区、视图范围保持不变;
// 2) 同时退出全部缩放模式,使工具栏「时间轴选区」「Y 轴选区」回到未选中态。
// Esc 不与时间光标关联:关闭光标请用工具栏「光标」按钮,避免误关指针。
bool changed = m_plot.CancelDrag();
changed = m_plot.ExitZoomModes() || changed;
return changed;
}
case VK_ADD:
case VK_OEM_PLUS:
m_plot.ZoomTimeStep(1.4);
return true;
case VK_SUBTRACT:
case VK_OEM_MINUS:
m_plot.ZoomTimeStep(1.0 / 1.4);
return true;
case VK_LEFT:
m_plot.PanTimeByFraction(-0.12);
return true;
case VK_RIGHT:
m_plot.PanTimeByFraction(0.12);
return true;
case VK_UP:
m_plot.ScrollLanesByFraction(-0.12);
return true;
case VK_DOWN:
m_plot.ScrollLanesByFraction(0.12);
return true;
default:
break;
}
if (key == L'F')
{
m_plot.FitAll();
return true;
}
return false;
}
void Application::UpdateDragCursor(int x, int y)
{
// 鼠标捕获期间不再有 WM_SETCURSOR,只能自己按当前状态设置光标。
HCURSOR cursor = nullptr;
switch (m_activeTarget)
{
case Target::PLOT:
cursor = m_plot.QueryCursor(x, y);
break;
case Target::SPLITTER:
// 拖分隔条 → 左右箭头(否则按下瞬间光标会跳回箭头)。
cursor = LoadCursorW(nullptr, IDC_SIZEWE);
break;
case Target::EDIT_FIELD:
// 输入框内拖拽框选 → I 形光标。
cursor = LoadCursorW(nullptr, IDC_IBEAM);
break;
default:
break;
}
SetCursor(cursor != nullptr ? cursor : LoadCursorW(nullptr, IDC_ARROW));
}
bool Application::HandleMouseMove(int x, int y, bool buttonDown)
{
if (buttonDown && m_activeTarget != Target::NONE)
{
switch (m_activeTarget)
{
case Target::SPLITTER:
if (m_draggingSplitter && m_scale > 0.0f)
{
const float desired = static_cast<float>(x - m_splitterGrabOffset) / m_scale;
// ⚠️ 上限必须与 `Relayout()` 里**同一个公式**(`min(kMaxPanelWidth, 客户区宽 × 0.45)`):
// 旧实现用 `min(kMaxPanelWidth, kMinClientWidth / m_scale)` 恒等于 `kMaxPanelWidth`,
// 窄窗口下能把面板拖到 400 逻辑像素,随后 `Relayout` 又悄悄夹回来 ⇒ 拖动时宽度回弹。
const float clientWidth = static_cast<float>(std::max(0, m_backWidth)) / m_scale;
const float maximum = std::min(kMaxPanelWidth, std::max(0.0f, clientWidth) * 0.45f);
m_panelWidth = std::max(kMinPanelWidth, std::min(desired, maximum));
Relayout();
return true;
}
return false;
case Target::PLOT:
return m_plot.OnMouseMove(x, y);
case Target::PANEL:
return m_panel.OnMouseMove(x, y);
case Target::TOOLBAR:
return m_toolbar.OnMouseMove(x, y);
case Target::TAB_BAR:
return m_tabBar.OnMouseMove(x, y);
case Target::EDIT_FIELD:
{
// 输入框拖拽框选:按当前正在编辑的组件分发。
bool changed = m_tabBar.OnEditMouseMove(x);
changed = m_panel.OnEditMouseMove(x) || changed;
changed = m_plot.OnEditMouseMove(x) || changed;
changed = m_colorPicker.OnEditMouseMove(x) || changed;
return changed;
}
case Target::NONE:
default:
return false;
}
}
bool changed = false;
changed =
(m_tabBar.HitTest(x, y) ? m_tabBar.OnMouseMove(x, y) : m_tabBar.OnMouseLeave()) || changed;
// ⚠️ 按住工具栏按钮期间**绝不能**走 `OnMouseLeave()`:它会清掉按下态(`m_pressedIndex`),
// 于是随后在按钮上松手时什么也不会发生 —— 这正是“快速点击工具栏按钮时状态没切换”
// 的另一个成因(鼠标带着按下态从按钮边缘移出时会命中这里)。
if (m_toolbar.HitTest(x, y))
{
changed = m_toolbar.OnMouseMove(x, y) || changed;
}
else if (m_activeTarget != Target::TOOLBAR)
{
changed = m_toolbar.OnMouseLeave() || changed;
}
changed = (m_panel.HitTest(x, y) ? m_panel.OnMouseMove(x, y) : m_panel.OnMouseLeave()) || changed;
changed = (m_plot.HitTest(x, y) ? m_plot.OnMouseMove(x, y) : m_plot.OnMouseLeave()) || changed;
return changed;
}
bool Application::ApplyPendingPanelScroll()
{
SignalRef ref;
if (!m_plot.TakePanelScrollRequest(ref) || !ref.IsValid())
{
return false;
}
// 平滑滚动(带动画):用户要求"是滚动的而不是立刻翻到目标信号位置"。
m_panel.ScrollToSignalAnimated(ref);
return true;
}
bool Application::ApplyPendingPlotLaneScroll()
{
int laneIndex = -1;
if (!m_panel.TakePlotLaneScrollRequest(laneIndex))
{
return false;
}
// 目标网格不在绘图区可视区内时才真正滚动(已完整可见则不动视图)。
return m_plot.ScrollLaneIntoView(laneIndex);
}
int Application::ComponentLayer(const UiComponent* component) const
{
if (component == nullptr)
{
return 0;
}
for (std::size_t index = 0; index < m_components.size(); ++index)
{
if (m_components[index].component == component)
{
return static_cast<int>(index) * kLayersPerComponent;
}
}
return 0;
}
void Application::PaintLayoutBounds(gd::Graphics& g, int width, int height)
{
// ⚠️ 复用成员容器(不是局部变量):文本几何已经在**绘制期间**由 `PaintUtil`
// 通过 `UiSetTextRectSink(&m_debugBoxes)` 收进来了,这里只负责补组件结构。
//
// 层号与 `Render` 里下发的"文本层"**完全同源**(组件表下标 / `OverlayLayer`):
// 只有两者一致,遮挡矩形才能正确地把"浮层下面的内容"(含文字框)挖掉,
// 而不是反过来让下层文字穿透到浮层之上(用户 2026-10-29 报告的缺陷)。
m_debugBoxes.SetLayer(0);
// 客户区外框:便于确认"窗口边缘 == 最外层边界"。
m_debugBoxes.Add(gd::RectF(0.0f, 0.0f, static_cast<float>(width),
static_cast<float>(height)), 0);
// 组件区域(底 → 顶;组件自己负责补齐内部结构)。
// ⚠️ 三点都很重要:
// 1. `SetClip(组件区域)`:组件内部的元素可能落在自己地盘之外(典型:滚出可视区的
// 网格 `lane.rect` 的 Y 为负数),不裁剪会把边框画到工具栏/标签栏上。
// 2. 每个组件收集前**显式**把层号设成它在组件表里的下标:不能依赖 `PushLayer()`
// 累加 —— 组件内部可能为了自己的浮层临时提层(`PlotView` 的浮动面板),
// 一旦它没有复位,后面所有组件的层号会被整体推高,遮挡关系全乱。
// 3. 层号必须与 `Render` 里给文本下发的层号同源(见 `ComponentLayer`)。
for (std::size_t index = 0; index < m_components.size(); ++index)
{
const ComponentEntry& entry = m_components[index];
m_debugBoxes.SetLayer(static_cast<int>(index) * kLayersPerComponent);
if (entry.component != nullptr)
{
m_debugBoxes.SetClip(entry.component->Area());
entry.component->BuildBoxes(m_debugBoxes);
}
}
// 不属于任何组件的两块:面板分隔条(由宿主直接填充色块)、菜单 / 拾色器(浮层)。
// ⚠️ 浮层**不设裁剪矩形**:菜单的二级子菜单会展开到菜单本体之外,设裁剪会把它裁掉;
// 它们自己会把本体登记为"遮挡矩形"(见 `PopupMenu::BuildBoxes`),
// 由绘制阶段把更低层的 box 从浮层区域里挖掉。
m_debugBoxes.SetClip(gd::RectF());
m_debugBoxes.SetLayer(OverlayLayer(0));
m_debugBoxes.Add(m_splitterRect, 0);
m_debugBoxes.SetLayer(OverlayLayer(1));
m_menu.BuildBoxes(m_debugBoxes);
m_debugBoxes.SetLayer(OverlayLayer(2));
m_colorPicker.BuildBoxes(m_debugBoxes);
UiDebugPaintBoxList(g, m_debugBoxes);
}
} // namespace jdi