Files
Just-DataInspector/Components/Application/Src/ApplicationCommands.cpp
T
DeepSeek V4.1 Flash 97b361dedd fix(ui): 采样统计显示一致化、GX 拖拽在布局边界下可用、信号「移动到…」与拖拽排序
一、采样间隔/频率显示不一致(用户报告 1ms vs 1000us、100us 0ns)
* FormatDurationCompound 改为「先量化到整数皮秒、再整数分解」,
  彻底消除同一数值因浮点噪声落到不同单位/留下零尾数的问题;
  秒级分支与亚秒分支共用 totalPs 方案,次级进位/单位升级集中在一处。
* FormatFrequency 的单位选择改为按「舍入后的显示值」判定(999.9999999Hz
  与 1000Hz 均为 1.000kHz),并做向下校正避免 0.001kHz。

二、选区截取面板
* 提示文本改为用户指定原文「启用双光标模式以进行选区截取」。
* EdgePanelWidth 计入该提示的实际文本宽度;绘制改用 UiTextClipped 不再省略。
* viewHeight 补回漏减的 bottomPad,修掉条目区与按钮之间的空白。

三、未展开的信号条目栏悬停不再变成手型光标。

四、开启「显示布局边界」后 GX 标签可正常拖拽排序
* 抽出 PaintLaneReorderOverlay(吸附槽位 + 被拖轮廓),贴图与整幅重绘共用。
* 整幅重绘在 LANE_REORDER 时按 LaneDragOffset 逐格平移重画(被拖网格最后画),
  修掉调试模式下不跟随鼠标、无吸附提示、只能盲拖的问题。

五、信号右键新增「移动到…」二级菜单(位于「仅显示该信号」之上)
* 条目为「分组 N(组名)」,当前组打勾;无标题行;底部「新建分组」。
* MenuItem::submenuMaxRows + PopupMenu 独立子菜单滚动(滚动条、滚轮、视口命中过滤),
  IsScrollable 计入子菜单可滚状态。
* 数据层 DataSet::MoveSignalToGroup/MoveSignal 让时间轴随信号搬运
  (独占则移动、共享则复制);TabConfig::RemapAfterSignalMove 改写引用而非先删后插。
* Application::MoveSignalCore 统一入口,保证派生计算暂停、引用修正、选中迁移与同步一致。

六、信号列表支持按住拖拽排序(组内重排 / 跨组移动)
* 4px 阈值进入拖拽;条目脱离原位跟随鼠标(阴影 + 强调色描边 + 插入指示线)。
* 其它信号行/分组行按插入位置做避让位移动画(RowRect 统一叠加插值位移,
  宿主 kScrollTimerId 驱动 AdvanceSignalDragAnimation)。
* 数据层 DataSet::MoveSignal 支持任意位置;TabConfig::RemapAfterSignalReorder 线性重映射。
* 拖拽中禁用组名悬浮;双击改名与 InvalidateRows 均正确终止拖拽态。

验证:删 .o 全量重编译零 error / 零 warning;产物 873,984 B(< 1MB 预警阈值)。
本轮未使用鼠标/键盘/屏幕,未运行任何 GUI 探针,仅编译验证 + 源码级核对。
2026-11-03 19:27:43 +08:00

2144 lines
73 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)。
// 分片按"职责"划分:核心(状态/布局)、渲染、命令与菜单、加载。
#include "ApplicationInternal.hpp"
#include "UiDebugBounds.hpp"
namespace jdi
{
void Application::NewTab(bool collapseGroups)
{
TabConfig tab;
tab.SetName(NextTabName());
// 用户新建的标签页:信号分组**默认折起**(用户明确要求:列表默认紧凑;
// 之后在该标签页里新加载的分组也沿用这个默认值)。
tab.SetGroupsCollapsedByDefault(collapseGroups);
tab.SyncToDataSet(m_dataSet);
if (collapseGroups)
{
tab.SetAllCollapsed(true);
}
// ⚠️ **新标签页不存在任何被点选的信号**(用户 2026-11-02 要求):
// 选中状态是**全局共享**的(`DataSet::Selected()`),不清掉的话新建出来的标签页会
// 直接带着上一个标签页的选中信号 —— 样式编辑区于是有自己的“目标”,与新页“空白”
// 的预期不符。清空后样式区回到「未选择信号」状态,直到用户手动点选任意信号。
m_dataSet.SetSelected(SignalRef());
m_tabs.push_back(std::move(tab));
SetActiveTab(static_cast<int>(m_tabs.size()) - 1);
}
void Application::CloseTab(int index)
{
if (index < 0 || index >= static_cast<int>(m_tabs.size()))
{
return;
}
if (m_tabs.size() <= 1)
{
// ⚠️ **唯一标签页不弹警告**(用户 2026-11-02 要求):直接删掉它并新建一个默认的
// 「视图 1」—— 组全部折起、无选中信号、样式区无目标(由 `NewTab(true)` 保证)。
// ⚠️ `m_tabs` 清空后 `NextTabName()` 必然返回「视图 1」,编号自然从 1 重新开始。
// erase 与 `NewTab` 之间**不要**碰 `m_panel` / `m_plot`(它们的上下文仍指向
// 刚被释放的 `TabConfig`);`NewTab` 里的 `SetActiveTab` 会第一时间重新绑定。
m_tabs.erase(m_tabs.begin() + index);
m_activeTab = 0;
NewTab(true);
return;
}
m_tabs.erase(m_tabs.begin() + index);
int next = index;
if (next >= static_cast<int>(m_tabs.size()))
{
next = static_cast<int>(m_tabs.size()) - 1;
}
SetActiveTab(next);
}
void Application::SetActiveTab(int index)
{
if (index < 0 || index >= static_cast<int>(m_tabs.size()))
{
return;
}
m_activeTab = index;
// 重新绑定上下文:面板与绘图区均指向同一数据层与当前标签页配置。
m_panel.SetContext(&m_dataSet, &m_tabs[static_cast<std::size_t>(m_activeTab)]);
m_plot.SetContext(&m_dataSet, &m_tabs[static_cast<std::size_t>(m_activeTab)]);
RefreshTabBar();
Relayout();
// 切换标签页后把该页已选中的信号滚动到可见位置。
// 切换标签页后把共享的选中信号滚动到可见位置(选中状态跨页保持一致)。
m_panel.ScrollToSignal(m_dataSet.Selected());
SynchronizeView(true);
UpdateWindowTitle();
InvalidateRect(m_hwnd, nullptr, TRUE);
}
void Application::RefreshTabBar()
{
std::vector<std::wstring> names;
names.reserve(m_tabs.size());
for (const TabConfig& tab : m_tabs)
{
names.push_back(tab.Name());
}
m_tabBar.SetTabs(names);
m_tabBar.SetActiveIndex(m_activeTab);
}
void Application::SynchronizeView(bool force)
{
if (m_tabs.empty() || m_activeTab < 0)
{
return;
}
TabConfig& tab = m_tabs[static_cast<std::size_t>(m_activeTab)];
const int visibleCount = tab.VisibleCount();
const bool anyVisible = tab.AnyVisible();
const std::size_t laneCount = tab.Lanes().size();
const bool changed = (visibleCount != m_cachedVisibleCount) || (anyVisible != m_cachedAnyVisible) ||
(laneCount != m_cachedLaneCount);
if (!changed && !force)
{
return;
}
const std::size_t previousLaneCount = m_cachedLaneCount;
m_cachedVisibleCount = visibleCount;
m_cachedAnyVisible = anyVisible;
m_cachedLaneCount = laneCount;
// 标签页首次出现可见信号时,把时间轴对齐到可见数据的范围。
// 没有任何可见信号时(未勾选 / 全部取消勾选 / 清空网格)主动复位自适应标记,
// 这样用户下一次勾选信号时会重新做一次自适应,而不是停留在上次的视图范围。
if (!anyVisible)
{
tab.ViewInitialized() = false;
}
else if (!tab.ViewInitialized())
{
// 首次出现可见信号时做一次自适应。单点信号(时间/幅值范围退化成 0)由
// `FitAll` 内部按"单值散点"的规则处理(居中 + 一大格 1s),这里不再分开判断。
m_plot.FitAll();
// ⚠️ **网格纵向滚动也要回到最上方**(用户 2026-10-17 要求):
// “有过网格 → 没有信号显示 → 又加载了需要滚动条数量的网格”这一整类情形,
// 新出现的网格集必须从最上方开始看。旧实现保留了上一次的 `LaneScroll()`,
// 于是重新显示后会**停在与取消显示时相同的滚动位置**(用户报告)。
// 放在 `FitAll()` 之后:`FitAll` 本身不改变滚动量(§11.2 第 112 条),
// 这里显式归零即可;随后 `Relayout`/`RefreshLanes` 会重算几何。
tab.LaneScroll() = 0.0f;
// 用户 2026-10-17 要求:光标工具处于激活状态(单 / 双光标)而之前**没有任何信号绘制**时,
// 第一个信号出现后光标要按“光标出现的规则”重新出现一次,而不是停在旧的残留位置。
// 规则与 `CycleCursorMode()` 首次启用完全同源;光标工具未激活时本调用无副作用。
m_plot.ResetCursorPlacementOnFreshContent();
}
const bool laneCountChanged = (laneCount != previousLaneCount);
if (laneCountChanged || force)
{
// 网格数量变化会影响滚动条与绘图区宽度,必须重新布局。
Relayout();
}
else
{
m_plot.RefreshLanes();
}
}
void Application::SyncTabsToDataSet()
{
for (TabConfig& tab : m_tabs)
{
tab.SyncToDataSet(m_dataSet);
}
// 选中状态由数据层共享维护,数据变动后需校验其是否仍指向存在的信号。
m_dataSet.ValidateSelection();
m_panel.InvalidateRows();
// 分组/信号结构变化会让信号引用与分组地址改变,派生缓存必须整体作废。
m_plot.InvalidateDerivedCaches();
// 强制下一次同步重新计算视图状态。
m_cachedVisibleCount = -1;
m_cachedAnyVisible = false;
m_cachedLaneCount = 0;
if (m_hwnd != nullptr)
{
InvalidateRect(m_hwnd, nullptr, TRUE);
}
}
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" (" + std::to_wstring(suffix) + L")";
if (!taken(candidate))
{
return candidate;
}
}
return base;
}
void Application::MoveSignalCore(int fromGroup, int fromSignal, int toGroup, int toIndex)
{
const SignalGroup* sourceGroup = m_dataSet.GroupAt(fromGroup);
const SignalGroup* targetGroup = m_dataSet.GroupAt(toGroup);
if (sourceGroup == nullptr || targetGroup == nullptr || fromSignal < 0 ||
static_cast<std::size_t>(fromSignal) >= sourceGroup->signals.size())
{
return;
}
const bool sameGroup = (fromGroup == toGroup);
// 目标下标:负数/越界表示追加到末尾(组内重排时"末尾"= 组内信号数-1,因为该信号会先被摘出)。
int position = toIndex;
const int targetCount = static_cast<int>(targetGroup->signals.size());
if (sameGroup)
{
if (position < 0 || position >= targetCount)
{
position = targetCount - 1;
}
if (position == fromSignal)
{
return;
}
}
else if (position < 0 || position > targetCount)
{
position = targetCount;
}
SignalRef from;
from.groupIndex = fromGroup;
from.signalIndex = fromSignal;
SignalRef to;
to.groupIndex = toGroup;
to.signalIndex = position;
const bool visible = m_tabs.empty() ? false : m_tabs.front().IsVisible(from);
// 移动会搬运大量采样缓冲:先暂停后台派生曲线计算,避免其读取正在被移动的内存。
m_plot.PauseDerivedAsync();
// 跨组且目标分组是多标签页里"新出现"的(例如刚建的分组)时,先补齐可见性表列,
// 否则 `RemapAfterSignalMove` 无法在新组里插入"沿用源可见性"的那一列,
// 移动过去后信号会变成未勾选、曲线直接消失。
if (!sameGroup)
{
for (TabConfig& tab : m_tabs)
{
tab.SyncToDataSet(m_dataSet);
}
for (TabConfig& tab : m_tabs)
{
tab.RemapAfterSignalMove(from, to, tab.IsVisible(from));
}
}
else
{
for (TabConfig& tab : m_tabs)
{
tab.RemapAfterSignalReorder(fromGroup, fromSignal, position);
}
}
(void)visible;
// 共享选中项:被移动的正是当前选中信号时,改为指向新位置。
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;
// 参数已在此前校验过(分组与信号下标都合法),本调用不会失败。
m_dataSet.MoveSignal(fromGroup, fromSignal, toGroup, position, newSignalIndex);
SyncTabsToDataSet();
}
void Application::ReorderSignal(int fromGroup, int fromSignal, int toGroup, int toIndex)
{
MoveSignalCore(fromGroup, fromSignal, toGroup, toIndex);
}
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;
// 「新建分组」:先追加一个空分组(名字不重复),再按普通跨组移动处理。
int targetGroup = toGroup;
if (targetGroup < 0)
{
SignalGroup group;
group.name = UniqueGroupName(L"新建分组");
if (!m_dataSet.AddGroup(std::move(group), targetGroup))
{
return;
}
}
if (targetGroup == fromGroup)
{
return; // 已经在该分组,无需移动
}
const SignalGroup* targetGroupData = m_dataSet.GroupAt(targetGroup);
if (targetGroupData == nullptr)
{
return;
}
MoveSignalCore(fromGroup, fromSignal, targetGroup,
static_cast<int>(targetGroupData->signals.size()));
const SignalGroup* destination = m_dataSet.GroupAt(targetGroup);
SetStatusMessage(L"已将信号「" + signalName + L"」移动到分组:" +
(destination != nullptr ? destination->name : std::wstring()),
false);
}
void Application::UpdateWindowTitle() {
// 标题栏 = 应用名称 + 分隔竖线 + 内存占用(单位在 KB/MB/GB 间自适应,保留两位小数)。
// 除内存占用外不再附带标签页、文件数量等无关信息。
// ⚠️ 内存口径 = **私有工作集**(与任务管理器"内存"列一致),不是总工作集。
std::wstring title = kWindowTitle;
title += kTitleSeparator;
title += kRamTitlePrefix;
title += FormatByteSize(ProcessPrivateWorkingSetBytes());
// 数值未变化时不调用 SetWindowTextW,避免无谓地重绘标题栏。
if (title == m_windowTitleText)
{
return;
}
m_windowTitleText = title;
SetWindowTextW(m_hwnd, m_windowTitleText.c_str());
}
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"已加载 " + std::to_wstring(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;
}
void Application::OnTabBarResult(const TabBarResult& result)
{
switch (result.type)
{
case TabBarResult::Type::ADD:
NewTab(true);
break;
case TabBarResult::Type::SELECT:
if (result.index != m_activeTab)
{
SetActiveTab(result.index);
}
break;
case TabBarResult::Type::CLOSE:
CloseTab(result.index);
break;
case TabBarResult::Type::CONTEXT:
ShowTabContextMenu(result);
break;
case TabBarResult::Type::REORDER:
MoveTab(result.index, result.targetIndex);
break;
case TabBarResult::Type::RENAME:
if (result.index >= 0 && result.index < static_cast<int>(m_tabs.size()) && !result.text.empty())
{
m_tabs[static_cast<std::size_t>(result.index)].SetName(result.text);
RefreshTabBar();
// SetTabs 会重置各标签的矩形,必须重新布局才能把它们算回来。
Relayout();
InvalidateRect(m_hwnd, nullptr, TRUE);
}
break;
case TabBarResult::Type::NONE:
default:
break;
}
}
void Application::MoveTab(int from, int target)
{
if (from < 0 || from >= static_cast<int>(m_tabs.size()))
{
return;
}
if (target < 0 || target > static_cast<int>(m_tabs.size()))
{
return;
}
// 拖拽语义:target 表示「插入到该下标之前」;向后拖时需扣除自身占位。
int destination = target;
if (destination > from)
{
--destination;
}
destination = std::max(0, std::min(destination, static_cast<int>(m_tabs.size()) - 1));
if (destination == from)
{
return;
}
// 保持当前激活的标签页在重排后仍处于激活状态。
TabConfig moved = m_tabs[static_cast<std::size_t>(from)];
const int activeIndex = m_activeTab;
m_tabs.erase(m_tabs.begin() + from);
m_tabs.insert(m_tabs.begin() + destination, std::move(moved));
int newActive = activeIndex;
if (activeIndex == from)
{
newActive = destination;
}
else if (from < activeIndex && destination >= activeIndex)
{
--newActive;
}
else if (from > activeIndex && destination <= activeIndex)
{
++newActive;
}
SetActiveTab(newActive);
InvalidateRect(m_hwnd, nullptr, TRUE);
}
bool Application::ApplyPendingRename()
{
SignalRef ref;
std::wstring name;
if (!m_plot.TakePendingRename(ref, name))
{
return false;
}
if (!m_dataSet.RenameSignal(ref.groupIndex, ref.signalIndex, name))
{
return false;
}
// 改名只影响显示,但需要刷新左侧列表与绘图区图例。
m_panel.InvalidateRows();
SetStatusMessage(L"信号已重命名为:" + name, false);
InvalidateRect(m_hwnd, nullptr, TRUE);
return true;
}
bool Application::BuildDerivedSignal(const DerivedSignalRequest& request, bool makeVisible)
{
const SignalGroup* group = m_dataSet.GroupAt(request.groupIndex);
if (group == nullptr || request.signalIndex < 0 ||
request.signalIndex >= static_cast<int>(group->signals.size()))
{
return false;
}
const SignalData& source = group->signals[static_cast<std::size_t>(request.signalIndex)];
const std::wstring baseName = source.name;
// 0. 先把源信号的「调整」变换快照下来(下面 `InsertDerivedSignal` 会移动信号存储,
// 之后再取 `source` 会悬垂)。
//
// ⚠️ **变换要"烘进数据",而不是让新信号继承这套指纹**(用户明确要求):
// 网格里的预览曲线画的是 `变换(滤波结果)`。若新信号只存 `滤波结果` 再**继承变换**,
// 它的「调整」面板里就会莫名其妙带着一整套系数 / 镜像 / 偏移 —— 用户看到的是
// "新信号只是继承了原信号的指纹,数据本身没变"。现在改为:
// * 数值域变换(系数 → 绝对值 → 垂直镜像 → Y 偏移)**与**水平镜像(数据反序)
// 全部作用在派生采样值上;
// * 于是新信号**用默认外观**绘制出来就与预览曲线逐位一致。
// * ⚠️ **只有「数值域」的变换烘进采样值;时间轴偏移留在样式上**(见 3b)。
// 曾经把 `offsetTime` 也按 `time[i] - offsetTime` 重采样烘进采样值("新信号
// 不启用任何调整"),但那是**有损**的:平移出时间轴范围的那一段只能写成 NaN,
// 偏移稍大(≥ 数据跨度)就连一个有效采样都不剩 —— 用户点「构建为新信号」
// 得到的是一条**空信号**(2026-10-29 报告)。派生信号与源信号共用同一条文件
// 时间轴,无法用采样值表达超出轴范围的平移,因此时间偏移只能由样式表达。
// 预览曲线本来也是用源样式的 `offsetTime` 绘制的,两条路径**显示完全一致**。
SignalStyle sourceTransform;
bool hasSourceStyle = false;
{
SignalRef sourceRef;
sourceRef.groupIndex = request.groupIndex;
sourceRef.signalIndex = request.signalIndex;
if (const SignalStyle* sourceStyle = m_dataSet.Style(sourceRef))
{
sourceTransform = *sourceStyle;
hasSourceStyle = true;
}
}
// 1. 计算派生采样值(与网格内的预览曲线共用同一实现,因此逐位一致)。
// 多次迭代均值滤波形如「<原信号名称> (N点均值 M次迭代)」,
// 弱峰调整形如「<原信号名称> (弱峰调整 M% N次迭代)」。
std::vector<float> derived_values;
SignalData derived;
if (request.weakPeak)
{
ComputeWeakPeakAdjust(source.values, static_cast<double>(request.amountPercent) / 100.0,
request.iterations, derived_values);
derived.name = baseName + L" (弱峰调整 " + std::to_wstring(request.amountPercent) + L"% " +
std::to_wstring(request.iterations) + L"次迭代)";
}
else
{
ComputeMovingAverageIterated(source.values, request.points, request.iterations, derived_values);
derived.name = baseName + L" (" + std::to_wstring(request.points) + L"点均值 " +
std::to_wstring(request.iterations) + L"次迭代)";
}
// 2. 组装新信号(外观一律用默认值:变换已经烘进采样数据,不再是"继承的指纹")。
// ⚠️ 新信号**沿用源信号的时间轴**(`axisIndex`):派生只改采样值,不改时间基准。
derived.axisIndex = source.axisIndex;
derived.values.assign(derived_values.begin(), derived_values.end());
// 2b. 把源信号的「调整」变换**烘进派生采样值**:
// * 数值域(**公式映射** → 系数 → 绝对值 → 垂直镜像 → Y 偏移)逐点套用
// `ApplyDrawTransform`(它本来就是“公式最先结算”,与绘制路径同源);
// * 水平镜像 = 数据反序,等价于把采样数组整体反转。
// ⚠️ `ApplyDrawTransform` 是逐点函数,因此"先变换再反转"与"先反转再变换"等价。
// ⚠️⚠️ **公式映射必须算作“有变换”**(用户 2026-11-03):旧判定只列了系数/绝对值/
// 垂直镜像/Y 偏移,于是带公式的信号派生出来的新信号**丢掉了公式**(不烘也不继承)
// ⇒ 新信号曲线与预览曲线不一致。
if (hasSourceStyle)
{
const bool valueTransform =
sourceTransform.HasFormula() || sourceTransform.gain != 1.0f ||
sourceTransform.absolute || sourceTransform.mirrorVertical ||
sourceTransform.offsetY != 0.0f;
if (valueTransform)
{
for (double& value : derived.values)
{
value = ApplyDrawTransform(sourceTransform, value);
}
}
if (sourceTransform.mirrorHorizontal)
{
std::reverse(derived.values.begin(), derived.values.end());
}
}
// 2c.(已删除)时间轴偏移**不再**按 `time[i] - offsetTime` 重采样烘进采样值。
//
// 旧实现为了让"新信号的「调整」面板保持默认"而把平移烘进数据,代价是**有损**:
// 平移出时间轴范围的那一段只能写成 NaN,偏移 ≥ 数据跨度时**整条信号全变 NaN**
//(用户 2026-10-29 报告的"派生出来是一条空信号");即便偏移很小,也会在两端
// 留下与实际曲线不符的空洞。
// 派生信号与源信号共用同一条文件时间轴,无法用采样值表达超出轴范围的平移,
// 因此时间偏移改由**新信号样式**表达(见 3b)——预览曲线本来就用源样式的
// `offsetTime` 绘制,所以两条路径的**显示完全一致**,且数据一点都不会丢。
// 3. 插入到原信号的下方(数据层 + 各标签页引用重映射)。
int newIndex = -1;
if (!InsertSignalBelowSource(request.groupIndex, request.signalIndex, std::move(derived),
makeVisible, newIndex))
{
return false;
}
// 3b. 时间轴偏移:**只**写进新信号的样式(不再烘进采样值,见 2c 的说明)。
// 绘制路径本来就把采样画在 `t + offsetTime` 处,因此新信号的曲线与预览曲线
// 逐点重合,同时采样数据保持完整(不会因为平移越界而变成 NaN)。
if (hasSourceStyle && sourceTransform.offsetTime != 0.0)
{
if (SignalStyle* derivedStyle = m_dataSet.Style(SignalRef{request.groupIndex, newIndex}))
{
derivedStyle->offsetTime = sourceTransform.offsetTime;
}
}
// 4. 数据层结构变化:刷新列表、作废折线/派生缓存并重绘。
SyncTabsToDataSet();
return true;
}
bool Application::BuildResampledSignal(int groupIndex, int signalIndex, double interval)
{
const SignalGroup* group = m_dataSet.GroupAt(groupIndex);
if (group == nullptr || signalIndex < 0 ||
signalIndex >= static_cast<int>(group->signals.size()))
{
return false;
}
if (!std::isfinite(interval) || interval <= 0.0)
{
return false;
}
const SignalData& source = group->signals[static_cast<std::size_t>(signalIndex)];
const TimeAxis& axis = group->AxisOf(source);
const std::wstring baseName = source.name;
// 0. 快照源信号的「调整」变换(插入会移动信号存储,之后取 `source` 会悬垂)。
SignalStyle sourceStyle;
bool hasSourceStyle = false;
{
const SignalStyle* style = m_dataSet.Style(SignalRef{groupIndex, signalIndex});
if (style != nullptr)
{
sourceStyle = *style;
hasSourceStyle = true;
}
}
// 1. 收集「变换后」的采样点(升序)。变换与绘制一致:
// 公式映射 → 系数 → 绝对值 → 垂直镜像 → Y 偏移(时间轴偏移不烘值)。
// ⚠️ 判定统一走 `HasValueTransform()`(含公式映射),否则带公式的信号
// 重采样时会**丢掉公式**(用户 2026-11-03 报告)。
const std::size_t count = std::min(axis.time.size(), source.values.size());
std::vector<double> sourceTimes;
std::vector<double> sourceValues;
sourceTimes.reserve(count);
sourceValues.reserve(count);
bool monotonic = true;
double previous = 0.0;
for (std::size_t index = 0; index < count; ++index)
{
const double time = axis.time[index];
const double value = source.values[index];
if (!std::isfinite(time) || !std::isfinite(value))
{
continue;
}
if (!sourceTimes.empty())
{
if (time < previous)
{
monotonic = false;
break;
}
if (time == previous)
{
continue; // 重复时刻只保留第一个
}
}
previous = time;
sourceTimes.push_back(time);
sourceValues.push_back(hasSourceStyle ? ApplyDrawTransform(sourceStyle, value) : value);
}
if (!monotonic || sourceTimes.size() < 2)
{
m_plot.ShowPersistentHint(!monotonic ? L"重采样失败:该信号的时间轴不是单调递增的。"
: L"重采样失败:有效采样点不足两个。");
return false;
}
// 2. 生成目标时间刻度(等间隔)。
const double t0 = sourceTimes.front();
const double t1 = sourceTimes.back();
const double span = t1 - t0;
if (!(span > 0.0))
{
m_plot.ShowPersistentHint(L"重采样失败:该信号的时间跨度为 0。");
return false;
}
// 采样点数上限:防止用户把间隔输得太小导致内存爆炸。
constexpr double kMaxResamplePoints = 20000000.0;
const double stepsExact = std::floor(span / interval + 1.0e-9) + 1.0;
if (!(stepsExact >= 2.0))
{
m_plot.ShowPersistentHint(L"重采样失败:重采样间隔大于信号时间跨度。");
return false;
}
if (stepsExact > kMaxResamplePoints)
{
m_plot.ShowPersistentHint(L"重采样失败:目标采样点过多(超过 2000 万),请增大重采样间隔。");
return false;
}
const std::size_t targetCount = static_cast<std::size_t>(stepsExact);
TimeAxis newAxis;
newAxis.name = axis.name;
newAxis.time.resize(targetCount);
newAxis.monotonic = true;
std::vector<double> resampled(targetCount);
std::size_t cursor = 0;
const std::size_t last = sourceTimes.size() - 1;
for (std::size_t index = 0; index < targetCount; ++index)
{
const double time = std::min(t1, t0 + static_cast<double>(index) * interval);
newAxis.time[index] = time;
// 单调推进游标(O(n) 总代价)。
while (cursor < last && sourceTimes[cursor + 1] < time)
{
++cursor;
}
if (cursor >= last)
{
resampled[index] = sourceValues[last];
continue;
}
const double ta = sourceTimes[cursor];
const double tb = sourceTimes[cursor + 1];
if (time <= ta)
{
resampled[index] = sourceValues[cursor];
continue;
}
if (tb <= ta)
{
resampled[index] = sourceValues[cursor];
continue;
}
const double ratio = (time - ta) / (tb - ta);
const double a = sourceValues[cursor];
const double b = sourceValues[cursor + 1];
resampled[index] = a + (b - a) * ratio;
}
newAxis.minTime = t0;
newAxis.maxTime = newAxis.time.back();
newAxis.finiteCount = targetCount;
// 3. 追加新时间轴(重采样后的采样刻度无法复用源信号的时间轴)。
std::size_t axisIndex = 0;
if (!m_dataSet.AppendGroupAxis(groupIndex, std::move(newAxis), axisIndex))
{
return false;
}
// 4. 组装新信号并插入到源信号下方。
SignalData derived;
derived.name = baseName + L" (重采样 " + FormatFrequency(1.0 / interval) + L")";
derived.axisIndex = axisIndex;
derived.values = std::move(resampled);
int newIndex = -1;
if (!InsertSignalBelowSource(groupIndex, signalIndex, std::move(derived), true, newIndex))
{
return false;
}
// 5. 时间轴偏移**不烘值**:留在新信号的样式上(与其它派生信号同一规则)。
if (hasSourceStyle && sourceStyle.offsetTime != 0.0)
{
if (SignalStyle* derivedStyle = m_dataSet.Style(SignalRef{groupIndex, newIndex}))
{
derivedStyle->offsetTime = sourceStyle.offsetTime;
}
}
SyncTabsToDataSet();
return true;
}
// ---------------------------------------------------------------- 选区截取
bool Application::BuildClippedSignal(int groupIndex, int signalIndex, double begin, double end)
{
const SignalGroup* group = m_dataSet.GroupAt(groupIndex);
if (group == nullptr || signalIndex < 0 ||
signalIndex >= static_cast<int>(group->signals.size()))
{
return false;
}
if (!std::isfinite(begin) || !std::isfinite(end))
{
return false;
}
const double low = std::min(begin, end);
const double high = std::max(begin, end);
if (!(high > low))
{
m_plot.ShowPersistentHint(L"选区截取失败:双光标区间宽度为 0。");
return false;
}
const SignalData& source = group->signals[static_cast<std::size_t>(signalIndex)];
const TimeAxis& axis = group->AxisOf(source);
const std::wstring baseName = source.name;
// 源信号的整套「调整」快照(插入会移动信号存储,之后不能再解引用 `source`)。
SignalStyle sourceStyle;
bool hasSourceStyle = false;
{
const SignalStyle* style = m_dataSet.Style(SignalRef{groupIndex, signalIndex});
if (style != nullptr)
{
sourceStyle = *style;
hasSourceStyle = true;
}
}
// 1. 逐点收集**区间内**的原始采样(不烘焙任何调整)。
// ⚠️⚠️ 比较必须用**显示时刻**(`time + offsetTime`):双光标给出的是屏幕上看到的时刻,
// 而采样画在 `time + offsetTime` 处(`offsetTime` 不进采样值,只平移绘制位置)。
// 若直接拿 `time` 与选区比较,带时间轴偏移的信号会截错区间(用户 2026-11-03)。
const double timeShift = hasSourceStyle ? sourceStyle.offsetTime : 0.0;
const std::size_t count = std::min(axis.time.size(), source.values.size());
std::vector<double> keptTimes;
std::vector<double> keptValues;
keptTimes.reserve(count);
keptValues.reserve(count);
for (std::size_t index = 0; index < count; ++index)
{
const double time = axis.time[index];
if (!std::isfinite(time))
{
continue;
}
const double shown = time + timeShift;
if (shown < low || shown > high)
{
continue;
}
keptTimes.push_back(time);
keptValues.push_back(source.values[index]);
}
if (keptValues.size() < 2)
{
m_plot.ShowPersistentHint(L"选区截取失败:区间内的有效采样点不足两个。");
return false;
}
// 2. 名称尾缀:`(AAAA - BBBB)`,秒、保留三位小数(用户 2026-11-03 明确要求)。
// 数值取**显示时刻**(与双光标读数一致),因此加上 `timeShift`。
const std::wstring suffix =
L" (" + FormatDecimalFixed(keptTimes.front() + timeShift, 3) + L"s - " +
FormatDecimalFixed(keptTimes.back() + timeShift, 3) + L"s)";
// 3. 新建时间轴(截取后的采样刻度无法复用源信号的时间轴)。
TimeAxis newAxis;
newAxis.name = axis.name;
newAxis.time = std::move(keptTimes);
newAxis.monotonic = true;
newAxis.finiteCount = newAxis.time.size();
newAxis.minTime = newAxis.time.front();
newAxis.maxTime = newAxis.time.back();
std::size_t axisIndex = 0;
if (!m_dataSet.AppendGroupAxis(groupIndex, std::move(newAxis), axisIndex))
{
return false;
}
// 4. 组装新信号并插入到源信号下方。
SignalData derived;
derived.name = baseName + suffix;
derived.axisIndex = axisIndex;
derived.values = std::move(keptValues);
int newIndex = -1;
if (!InsertSignalBelowSource(groupIndex, signalIndex, std::move(derived), true, newIndex))
{
return false;
}
// 5. 继承源信号的**整套调整**(不烘值 ⇒ 新曲线与源信号在区间内逐像素重合)。
if (hasSourceStyle)
{
if (SignalStyle* derivedStyle = m_dataSet.Style(SignalRef{groupIndex, newIndex}))
{
derivedStyle->color = sourceStyle.color;
derivedStyle->customColor = sourceStyle.customColor;
derivedStyle->opacity = sourceStyle.opacity;
derivedStyle->lineWidth = sourceStyle.lineWidth;
derivedStyle->mode = sourceStyle.mode;
derivedStyle->formula = sourceStyle.formula;
derivedStyle->gain = sourceStyle.gain;
derivedStyle->absolute = sourceStyle.absolute;
derivedStyle->mirrorVertical = sourceStyle.mirrorVertical;
derivedStyle->mirrorHorizontal = sourceStyle.mirrorHorizontal;
derivedStyle->offsetY = sourceStyle.offsetY;
derivedStyle->offsetTime = sourceStyle.offsetTime;
}
}
SyncTabsToDataSet();
return true;
}
bool Application::ClipSignalInPlace(int groupIndex, int signalIndex, double begin, double end)
{
if (!std::isfinite(begin) || !std::isfinite(end))
{
return false;
}
const double low = std::min(begin, end);
const double high = std::max(begin, end);
if (!(high > low))
{
m_plot.ShowPersistentHint(L"选区截取失败:双光标区间宽度为 0。");
return false;
}
// 破坏性截取会移动/释放采样 ⇒ 先暂停后台派生计算,避免它读到已释放的数据。
m_plot.PauseDerivedAsync();
// ⚠️ 双光标给的是**显示时刻**,而采样时刻是 `time`;这里换算回采样时刻区间
// (`offsetTime` 不烘进采样值,只平移绘制位置)。
double timeShift = 0.0;
if (const SignalStyle* style = m_dataSet.Style(SignalRef{groupIndex, signalIndex}))
{
timeShift = style->offsetTime;
}
// ⚠️⚠️ **必须先让该信号独占时间轴**再裁剪:同组信号常常共享同一条时间轴,
// 直接裁剪会把其它信号的时间基准一起改短 ⇒ 它们的采样与时间错位
//(用户 2026-11-03 报告:"多个信号同时截取时截取出来的信号并不对")。
std::size_t ownAxis = 0;
if (!m_dataSet.ForkAxisForSignal(groupIndex, signalIndex, ownAxis))
{
m_plot.ShowPersistentHint(L"选区截取失败:无法为该信号创建独立时间轴。");
return false;
}
if (!m_dataSet.ClipSignalRange(groupIndex, signalIndex, low - timeShift, high - timeShift))
{
m_plot.ShowPersistentHint(L"选区截取失败:区间内的有效采样点不足两个。");
return false;
}
SyncTabsToDataSet();
return true;
}
bool Application::InsertSignalBelowSource(int groupIndex, int sourceIndex, SignalData&& signal,
bool makeVisible, int& newIndex)
{
newIndex = -1;
// 插入派生信号会移动该分组的信号存储:先暂停后台计算,避免其读取已移动的数据。
m_plot.PauseDerivedAsync();
if (!m_dataSet.InsertDerivedSignal(groupIndex, sourceIndex, std::move(signal), newIndex))
{
return false;
}
for (TabConfig& tab : m_tabs)
{
tab.RemapAfterSignalInsert(groupIndex, newIndex);
}
// 在当前标签页中勾选新信号,并把它所在的网格移到原信号网格的下方。
if (makeVisible && !m_tabs.empty())
{
const SignalRef insertedRef{groupIndex, newIndex};
ActiveTab().SetVisible(insertedRef, true);
const SignalRef sourceRef{groupIndex, sourceIndex};
ActiveTab().MoveLaneOfSignalBelow(insertedRef, sourceRef);
}
return true;
}
bool Application::CloneSignal(const SignalRef& sourceRef)
{
const SignalGroup* group = m_dataSet.GroupAt(sourceRef.groupIndex);
if (group == nullptr || sourceRef.signalIndex < 0 ||
sourceRef.signalIndex >= static_cast<int>(group->signals.size()))
{
return false;
}
const SignalData& source = group->signals[static_cast<std::size_t>(sourceRef.signalIndex)];
// 源信号的样式快照(插入会移动信号存储,之后不能再解引用 `source`)。
SignalStyle transform;
bool hasStyle = false;
if (const SignalStyle* style = m_dataSet.Style(sourceRef))
{
transform = *style;
hasStyle = true;
}
// ⚠️ 用户 2026-11-03 明确要求:「克隆该信号」**不做任何烘焙**。
// 新信号直接使用源信号的**原始采样数据**,配方上只沿用:
// * 外观(颜色 / 自定义色 / 透明度 / 线宽 / 绘制方式);
// * **时间轴偏移**(`offsetTime` 只能留在样式上 —— 共用同一条时间轴,
// 烘焙进采样值会在越界段写 NaN,见 `BuildDerivedSignal` 2c 的说明)。
// 公式映射 / 系数 / 绝对值 / 镜像 / Y 偏移一律**保持默认**(不烘也不应用)。
// 需要“连调整一起复制”请用「克隆该信号(带调整)」(`CloneSignalAdjusted`)。
SignalData clone;
clone.name = L"*" + source.name;
clone.axisIndex = source.axisIndex;
clone.values = source.values;
int newIndex = -1;
if (!InsertSignalBelowSource(sourceRef.groupIndex, sourceRef.signalIndex, std::move(clone),
true, newIndex))
{
return false;
}
// 外观:克隆要**看起来与原信号一样**,但所有数值调整保持默认值。
if (SignalStyle* cloned = m_dataSet.Style(SignalRef{sourceRef.groupIndex, newIndex}))
{
if (hasStyle)
{
cloned->color = transform.color;
cloned->customColor = transform.customColor;
cloned->opacity = transform.opacity;
cloned->lineWidth = transform.lineWidth;
cloned->mode = transform.mode;
// ⚠️ 只保留时间轴偏移,其余调整保持默认(用户 2026-11-03 明确要求)。
cloned->offsetTime = transform.offsetTime;
}
// ⚠️ 单点信号默认会改成「散点 + 最大线宽」(`InsertDerivedSignal` 里的规则),
// 克隆应该用**原信号的样式**,因此上面几行把它覆盖回去了。
}
SyncTabsToDataSet();
SetStatusMessage(L"已克隆信号:" + m_dataSet.GroupAt(sourceRef.groupIndex)
->signals[static_cast<std::size_t>(newIndex)]
.name,
false);
InvalidateRect(m_hwnd, nullptr, TRUE);
return true;
}
bool Application::CloneSignalAdjusted(const SignalRef& sourceRef)
{
const SignalGroup* group = m_dataSet.GroupAt(sourceRef.groupIndex);
if (group == nullptr || sourceRef.signalIndex < 0 ||
sourceRef.signalIndex >= static_cast<int>(group->signals.size()))
{
return false;
}
const SignalData& source = group->signals[static_cast<std::size_t>(sourceRef.signalIndex)];
SignalStyle transform;
bool hasStyle = false;
if (const SignalStyle* style = m_dataSet.Style(sourceRef))
{
transform = *style;
hasStyle = true;
}
// 「克隆该信号(带调整)」:同样是**原始采样数据**(不做烘焙),
// 区别只在样式——完整沿用源信号的整套调整参数(公式映射 / 系数 / Y 偏移 /
// 时间轴偏移 / 水平与垂直镜像 / 绝对值),因此两条曲线**逐像素重合**,
// 且新信号可以独立继续调参而不影响源信号(用户 2026-11-03 明确要求)。
SignalData clone;
clone.name = L"*" + source.name;
clone.axisIndex = source.axisIndex;
clone.values = source.values;
int newIndex = -1;
if (!InsertSignalBelowSource(sourceRef.groupIndex, sourceRef.signalIndex, std::move(clone),
true, newIndex))
{
return false;
}
if (SignalStyle* cloned = m_dataSet.Style(SignalRef{sourceRef.groupIndex, newIndex}))
{
if (hasStyle)
{
// 外观。
cloned->color = transform.color;
cloned->customColor = transform.customColor;
cloned->opacity = transform.opacity;
cloned->lineWidth = transform.lineWidth;
cloned->mode = transform.mode;
// 整套「调整」参数(与源信号完全一致)。
cloned->formula = transform.formula;
cloned->gain = transform.gain;
cloned->absolute = transform.absolute;
cloned->mirrorVertical = transform.mirrorVertical;
cloned->mirrorHorizontal = transform.mirrorHorizontal;
cloned->offsetY = transform.offsetY;
cloned->offsetTime = transform.offsetTime;
}
}
SyncTabsToDataSet();
SetStatusMessage(L"已克隆信号(带调整):" + m_dataSet.GroupAt(sourceRef.groupIndex)
->signals[static_cast<std::size_t>(newIndex)]
.name,
false);
InvalidateRect(m_hwnd, nullptr, TRUE);
return true;
}
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);
}
bool Application::ApplyPendingDerivedBuild()
{
// 「重采样」的构建流程与均值/弱峰不同(要新建时间轴),单独处理。
{
int laneIndex = -1;
std::vector<PlotView::ResampleBuild> items;
if (m_plot.TakePendingResampleBuild(laneIndex, items) && !items.empty())
{
// **同一文件内必须按下标降序处理**(每次插入只让插入点之后的信号下标 +1)。
std::sort(items.begin(), items.end(),
[](const PlotView::ResampleBuild& a, const PlotView::ResampleBuild& b)
{
if (a.groupIndex != b.groupIndex)
{
return a.groupIndex < b.groupIndex;
}
return a.signalIndex > b.signalIndex;
});
int built = 0;
double lastInterval = 0.0;
for (const PlotView::ResampleBuild& item : items)
{
if (BuildResampledSignal(item.groupIndex, item.signalIndex, item.interval))
{
++built;
lastInterval = item.interval;
}
}
if (built > 0)
{
SetStatusMessage(L"已构建 " + std::to_wstring(built) + L" 条重采样信号(频率 " +
FormatFrequency(lastInterval > 0.0 ? 1.0 / lastInterval : 0.0) +
L")。",
false);
}
return built > 0;
}
}
// 「选区截取」:两个按钮(截取为新信号 / 原始信号截取)共用同一份区间。
{
int laneIndex = -1;
std::vector<PlotView::ClipBuild> items;
if (m_plot.TakePendingClipBuild(laneIndex, items) && !items.empty())
{
// 同一文件内必须按下标降序处理(插入会让后续下标 +1)。
std::sort(items.begin(), items.end(),
[](const PlotView::ClipBuild& a, const PlotView::ClipBuild& b)
{
if (a.groupIndex != b.groupIndex)
{
return a.groupIndex < b.groupIndex;
}
return a.signalIndex > b.signalIndex;
});
int built = 0;
for (const PlotView::ClipBuild& item : items)
{
if (item.destructive
? ClipSignalInPlace(item.groupIndex, item.signalIndex, item.begin, item.end)
: BuildClippedSignal(item.groupIndex, item.signalIndex, item.begin, item.end))
{
++built;
}
}
if (built > 0)
{
SetStatusMessage((items.front().destructive ? L"已截取 " : L"已截取为新信号 ") +
std::to_wstring(built) + L" 条。",
false);
}
return built > 0;
}
}
// 两类「构建为新信号」请求(均值滤波 / 弱峰调整)机制完全一致,
// 统一整理成同一份构建清单后按下标降序处理。
std::vector<DerivedSignalRequest> requests;
{
int laneIndex = -1;
std::vector<PlotView::MeanFilterBuild> items;
if (m_plot.TakePendingMeanFilterBuild(laneIndex, items))
{
for (const PlotView::MeanFilterBuild& item : items)
{
DerivedSignalRequest request;
request.groupIndex = item.groupIndex;
request.signalIndex = item.signalIndex;
request.points = item.points;
request.iterations = item.iterations;
request.weakPeak = false;
requests.push_back(request);
}
}
}
{
int laneIndex = -1;
std::vector<PlotView::WeakPeakBuild> items;
if (m_plot.TakePendingWeakPeakBuild(laneIndex, items))
{
for (const PlotView::WeakPeakBuild& item : items)
{
DerivedSignalRequest request;
request.groupIndex = item.groupIndex;
request.signalIndex = item.signalIndex;
request.amountPercent = item.amountPercent;
request.iterations = item.iterations;
request.weakPeak = true;
requests.push_back(request);
}
}
}
if (requests.empty())
{
return false;
}
if (m_tabs.empty())
{
return false;
}
// **同一文件内必须按下标降序处理**:每次插入只让插入点之后的信号下标 +1,
// 降序处理可保证尚未处理的信号下标始终有效(升序会把新插入的信号当成原信号)。
std::sort(requests.begin(), requests.end(),
[](const DerivedSignalRequest& a, const DerivedSignalRequest& b)
{
if (a.groupIndex != b.groupIndex)
{
return a.groupIndex < b.groupIndex;
}
return a.signalIndex > b.signalIndex;
});
int built = 0;
DerivedSignalRequest last;
for (const DerivedSignalRequest& request : requests)
{
if (BuildDerivedSignal(request, true))
{
++built;
last = request;
}
}
if (built > 0)
{
std::wstring text = L"已构建 " + std::to_wstring(built) + L" 条 ";
if (last.weakPeak)
{
text += L"弱峰调整 " + std::to_wstring(last.amountPercent) + L"% " +
std::to_wstring(last.iterations) + L"次迭代信号。";
}
else
{
text += std::to_wstring(last.points) + L"点均值 " +
std::to_wstring(last.iterations) + L"次迭代滤波信号。";
}
SetStatusMessage(text, false);
}
return built > 0;
}
void Application::ShowTabContextMenu(const TabBarResult& result)
{
if (result.index < 0 || result.index >= static_cast<int>(m_tabs.size()))
{
return;
}
m_tabMenuIndex = result.index;
std::vector<MenuItem> items;
MenuItem header;
header.kind = MenuItemKind::HEADER;
header.label = m_tabs[static_cast<std::size_t>(result.index)].Name();
items.push_back(header);
MenuItem create;
create.label = L"新建标签页";
create.id = kMenuTabNew;
items.push_back(create);
MenuItem close;
close.label = L"关闭该标签页";
close.id = kMenuTabClose;
close.enabled = (m_tabs.size() > 1);
items.push_back(close);
MenuItem closeOthers;
closeOthers.label = L"关闭其它标签页";
closeOthers.id = kMenuTabCloseOthers;
closeOthers.enabled = (m_tabs.size() > 1);
items.push_back(closeOthers);
// 菜单绘制在客户区内,因此把屏幕坐标换算为客户区坐标。
POINT anchor = {result.screenX, result.screenY};
ScreenToClient(m_hwnd, &anchor);
RECT client = {};
GetClientRect(m_hwnd, &client);
const gd::RectF bounds(0.0f, 0.0f, static_cast<float>(client.right - client.left),
static_cast<float>(client.bottom - client.top));
m_menu.Open(MenuScope::TAB_BAR, bounds, anchor.x, anchor.y, items);
}
void Application::ShowPlotContextMenu(int laneIndex, int x, int y)
{
if (laneIndex < 0)
{
return;
}
m_plotMenuLane = laneIndex;
std::vector<MenuItem> items;
MenuItem header;
header.kind = MenuItemKind::HEADER;
header.label = L"网格 " + std::to_wstring(laneIndex + 1);
items.push_back(header);
// 当前网格的 Y 轴上下限值直接在菜单内的输入框里显示,单击即可就地编辑(菜单不关闭)。
double laneYMin = 0.0;
double laneYMax = 1.0;
m_plot.LaneRange(laneIndex, laneYMin, laneYMax);
MenuItem maxItem;
maxItem.kind = MenuItemKind::EDIT;
maxItem.label = L"Y 轴最大值";
maxItem.id = PLOT_CMD_LANE_Y_MAX;
maxItem.value = FormatValue(laneYMax);
maxItem.filter = EditFilter::NUMBER;
items.push_back(maxItem);
MenuItem minItem;
minItem.kind = MenuItemKind::EDIT;
minItem.label = L"Y 轴最小值";
minItem.id = PLOT_CMD_LANE_Y_MIN;
minItem.value = FormatValue(laneYMin);
minItem.filter = EditFilter::NUMBER;
items.push_back(minItem);
// 选区自适应(切换项):勾选后每次本网格的时间轴选区放大都会自动匹配 Y 轴幅值。
MenuItem autoViewItem;
autoViewItem.label = L"选区 Y 轴自适应";
autoViewItem.id = PLOT_CMD_LANE_AUTO_VIEW;
autoViewItem.checked = m_plot.LaneAutoFitToSelection(laneIndex);
items.push_back(autoViewItem);
// 时间轴自适应到本网格信号范围(原先位于分隔线下方,现移到分隔线上方)。
MenuItem fitLaneTime;
fitLaneTime.label = L"按网格时间轴展开";
fitLaneTime.id = PLOT_CMD_LANE_FIT_TIME;
items.push_back(fitLaneTime);
// 「拆分当前网格」:把该网格内的信号按个拆成独立网格,插到其下方依次排列。
// 可点击时在后方追加「(N个信号)」说明将拆出的网格数;只有 1 条信号时置灰且不加尾缀。
MenuItem splitLane;
const std::size_t laneSignalCount = m_plot.LaneSignalCount(laneIndex);
splitLane.label = (laneSignalCount > 1)
? (L"拆分当前网格(" + std::to_wstring(laneSignalCount) + L"个信号)")
: L"拆分当前网格";
splitLane.id = PLOT_CMD_LANE_SPLIT;
splitLane.enabled = laneSignalCount > 1;
items.push_back(splitLane);
// 实验性功能(二级菜单):横线上方,鼠标悬停即向右展开。
MenuItem experimental; experimental.kind = MenuItemKind::SUBMENU;
experimental.label = L"实验性功能";
{
MenuItem edgeCount;
edgeCount.label = L"边沿计数";
edgeCount.id = PLOT_CMD_LANE_EDGE_COUNT;
edgeCount.checked = m_plot.LaneEdgeCount(laneIndex);
experimental.submenu.push_back(edgeCount);
MenuItem edgeCenter;
edgeCenter.label = L"边沿计数(中心轴)";
edgeCenter.id = PLOT_CMD_LANE_EDGE_CENTER;
edgeCenter.checked = m_plot.LaneEdgeCenterCount(laneIndex);
experimental.submenu.push_back(edgeCenter);
MenuItem meanFilter;
meanFilter.label = L"均值滤波";
meanFilter.id = PLOT_CMD_LANE_MEAN_FILTER;
meanFilter.checked = m_plot.LaneMeanFilter(laneIndex);
experimental.submenu.push_back(meanFilter);
MenuItem weakPeak;
weakPeak.label = L"弱峰调整";
weakPeak.id = PLOT_CMD_LANE_WEAK_PEAK;
weakPeak.checked = m_plot.LaneWeakPeak(laneIndex);
experimental.submenu.push_back(weakPeak);
MenuItem freeMarker;
freeMarker.label = L"自由标记";
freeMarker.id = PLOT_CMD_LANE_FREE_MARKER;
freeMarker.checked = m_plot.LaneFreeMarker(laneIndex);
// 「重采样」位于「自由标记」**上方**(用户 2026-11-02 要求)。
MenuItem resample;
resample.label = L"重采样";
resample.id = PLOT_CMD_LANE_RESAMPLE;
resample.checked = m_plot.LaneResample(laneIndex);
experimental.submenu.push_back(resample);
// 「选区截取」位于「重采样」下方、「自由标记」上方(用户 2026-11-03 要求)。
MenuItem clip;
clip.label = L"选区截取";
clip.id = PLOT_CMD_LANE_CLIP;
clip.checked = m_plot.LaneClip(laneIndex);
experimental.submenu.push_back(clip);
experimental.submenu.push_back(freeMarker);
}
items.push_back(experimental);
MenuItem separator;
separator.kind = MenuItemKind::SEPARATOR;
items.push_back(separator);
// 全局开关:一次性打开/关闭全部网格各自的「选区 Y 轴自适应」。
MenuItem autoFitAllItem;
autoFitAllItem.label = L"选区 Y 轴自适应(全局)";
autoFitAllItem.id = PLOT_CMD_AUTO_FIT_ALL;
autoFitAllItem.checked = m_plot.AllLanesAutoFitToSelection();
items.push_back(autoFitAllItem);
MenuItem fitItem;
fitItem.label = L"全部自适应";
fitItem.id = PLOT_CMD_FIT_ALL;
items.push_back(fitItem);
RECT client = {};
GetClientRect(m_hwnd, &client);
const gd::RectF bounds(0.0f, 0.0f, static_cast<float>(client.right - client.left),
static_cast<float>(client.bottom - client.top));
m_menu.Open(MenuScope::PLOT_VIEW, bounds, x, y, items);
}
void Application::RefreshPlotMenuValues(int laneIndex)
{
if (laneIndex < 0)
{
return;
}
double laneYMin = 0.0;
double laneYMax = 1.0;
m_plot.LaneRange(laneIndex, laneYMin, laneYMax);
m_menu.UpdateItemValue(PLOT_CMD_LANE_Y_MAX, FormatValue(laneYMax));
m_menu.UpdateItemValue(PLOT_CMD_LANE_Y_MIN, FormatValue(laneYMin));
// 「选区 Y 轴自适应」(本网格)与「(全局)」都是切换项,实时反映各自的勾选状态。
m_menu.UpdateItemLabel(PLOT_CMD_LANE_AUTO_VIEW, L"选区 Y 轴自适应",
m_plot.LaneAutoFitToSelection(laneIndex), true);
m_menu.UpdateItemLabel(PLOT_CMD_AUTO_FIT_ALL, L"选区 Y 轴自适应(全局)",
m_plot.AllLanesAutoFitToSelection(), true);
}
void Application::ApplyTheme(ThemeKind kind)
{
if (kind == m_themeKind && m_theme.windowBg == ThemeFor(kind).windowBg)
{
return; // 幂等:同一个主题重复点击不必作废缓存
}
m_themeKind = kind;
m_theme = ThemeFor(kind);
SetCurrentTheme(&m_theme);
// 主题色烘进了多处位图缓存,必须整体作废(只作废场景缓存是不够的:
// 宽基底图层、装饰层、浮动面板层、信号列表行位图里都存着旧配色的像素)。
m_plot.OnThemeChanged();
m_panel.OnThemeChanged();
// 装饰区(标签栏 / 工具栏 / 面板)全部重画,并整窗回送。
m_chromeDirty = true;
m_panelDirty = true;
InvalidateRect(m_hwnd, nullptr, TRUE);
}
void Application::OpenSettingsMenu()
{
// 锚点取齿轮按钮的**实际矩形**:按钮宽度由控件实测,宿主不应自己推算几何。
gd::RectF anchor;
if (!m_toolbar.ButtonRect(ToolbarAction::SETTINGS, anchor))
{
// 尚未布局(极端情形):退化为从菜单快捷键位置展开,而不是什么都不发生。
anchor = m_toolbarRect;
}
std::vector<MenuItem> items;
// ── 调试 ──────────────────────────────────────────────
MenuItem debugHeader;
debugHeader.kind = MenuItemKind::HEADER;
debugHeader.label = L"调试";
items.push_back(debugHeader);
MenuItem boundsItem;
boundsItem.label = L"显示布局边界";
boundsItem.id = kAppCmdShowLayoutBounds;
boundsItem.checked = m_showLayoutBounds;
// 切换项:点击后菜单保持打开,勾选态由宿主回写(与既有「镜像」等切换项一致)。
boundsItem.keepOpen = true;
items.push_back(boundsItem);
// ── 主题配色(二级菜单:鼠标悬停展开,带勾选态)──────────────
// 顺序固定为 Light / Solarised Light / Dark / Solarised Dark(用户 2026-10-29 指定)。
// `keepOpen` 让点击主题后菜单不关闭,勾选态由宿主回写 —— 便于连续预览几种配色。
MenuItem themeItem;
themeItem.kind = MenuItemKind::SUBMENU;
themeItem.label = L"主题配色";
for (int index = 0; index <= static_cast<int>(ThemeKind::SOLARIZED_DARK); ++index)
{
const ThemeKind kind = static_cast<ThemeKind>(index);
MenuItem option;
option.kind = MenuItemKind::NORMAL;
option.label = ThemeName(kind);
option.id = kAppCmdThemeBase + index;
option.checked = (m_themeKind == kind);
option.keepOpen = true;
themeItem.submenu.push_back(option);
}
items.push_back(themeItem);
RECT client = {};
GetClientRect(m_hwnd, &client);
const gd::RectF bounds(0.0f, 0.0f, static_cast<float>(client.right - client.left),
static_cast<float>(client.bottom - client.top));
// 右下展开,锚在按钮**下沿**(留 4 逻辑像素,视觉上不贴着按钮边框)。
const int x = static_cast<int>(anchor.X);
const int y = static_cast<int>(anchor.Y + anchor.Height + 4.0f * m_scale);
m_menu.Open(MenuScope::APP_SETTINGS, bounds, x, y, items);
}
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);
}
void Application::DispatchMenuResult(const MenuResult& result)
{
if (!result.valid)
{
return;
}
switch (result.scope)
{
case MenuScope::APP_SETTINGS:
{
// 应用设置菜单:全部条目都是**带勾选态的切换项**,菜单保持打开,
// 勾选变化通过 `UpdateItemLabel` 立即回写(连续切换多项时不用反复打开菜单)。
if (result.id == kAppCmdShowLayoutBounds)
{
m_showLayoutBounds = !m_showLayoutBounds;
m_menu.UpdateItemLabel(kAppCmdShowLayoutBounds, L"显示布局边界", m_showLayoutBounds);
// 边界比组件矩形外扩 1px 且跨越所有装饰区 ⇒ 必须整帧重绘。
m_chromeDirty = true;
}
else if (result.id >= kAppCmdThemeBase &&
result.id <= kAppCmdThemeBase + static_cast<int>(ThemeKind::SOLARIZED_DARK))
{
const ThemeKind kind = static_cast<ThemeKind>(result.id - kAppCmdThemeBase);
ApplyTheme(kind);
// 回写勾选态:四项里只有当前主题打勾(子菜单项要用专用接口才会重建布局)。
for (int index = 0; index <= static_cast<int>(ThemeKind::SOLARIZED_DARK); ++index)
{
const ThemeKind option = static_cast<ThemeKind>(index);
m_menu.UpdateSubmenuItemLabel(kAppCmdThemeBase + index, ThemeName(option),
m_themeKind == option);
}
}
InvalidateRect(m_hwnd, nullptr, TRUE);
break;
}
case MenuScope::SIGNAL_PANEL:
{
const bool needRelayout = m_panel.OnMenuCommand(result);
// 「公式映射」等就地输入可能产生**非模态**警告(不使用 Windows 消息框),
// 由绘图区底部的自绘提示浮层显示(与加载失败提示同一套)。
{
std::wstring hint;
if (m_panel.TakePendingHint(hint))
{
m_plot.ShowPersistentHint(hint);
}
}
if (needRelayout)
{
Relayout();
}
InvalidateRect(m_hwnd, nullptr, FALSE);
break;
}
case MenuScope::PLOT_VIEW:
{
// 菜单内联输入框的提交:就地更新网格 Y 轴上下限,菜单保持打开,
// 并把夹取后的真实值回写到菜单上(输入值可能被合法区间修正)。
if (result.editCommit)
{
const int laneIndex = m_plotMenuLane;
if (laneIndex >= 0)
{
m_plot.ApplyLaneValueText(laneIndex, result.id == PLOT_CMD_LANE_Y_MAX, result.editText);
RefreshPlotMenuValues(laneIndex);
}
SetFocus(m_hwnd);
InvalidateRect(m_hwnd, nullptr, FALSE);
break;
}
const int laneIndex = m_plotMenuLane;
m_plotMenuLane = -1;
switch (result.id)
{
case PLOT_CMD_LANE_AUTO_VIEW:
// 切换本网格的「选区 Y 轴自适应」。
m_plot.ToggleLaneAutoFitToSelection(laneIndex);
break;
case PLOT_CMD_AUTO_FIT_ALL:
{
// 全局切换:一次打开/关闭全部网格各自的开关;打开时立即生效一次。
const bool enable = !m_plot.AllLanesAutoFitToSelection();
m_plot.SetAllLanesAutoFitToSelection(enable, true);
break;
}
case PLOT_CMD_LANE_FIT_TIME:
// 「按网格时间轴展开」:展开时间轴后同时把该网格的 Y 轴自适应一次。
m_plot.FitTimeToLane(laneIndex);
break;
case PLOT_CMD_LANE_EDGE_COUNT:
// 「实验性功能 → 边沿计数」:切换本网格的横向光标与统计面板。
m_plot.ToggleLaneEdgeCount(laneIndex);
break;
case PLOT_CMD_LANE_EDGE_CENTER:
// 「实验性功能 → 边沿计数(中心轴)」:切换中心平均值曲线与统计面板。
m_plot.ToggleLaneEdgeCenterCount(laneIndex);
break;
case PLOT_CMD_LANE_MEAN_FILTER:
// 「实验性功能 → 均值滤波」:切换均值滤波曲线与参数面板。
m_plot.ToggleLaneMeanFilter(laneIndex);
break;
case PLOT_CMD_LANE_WEAK_PEAK:
// 「实验性功能 → 弱峰调整」:切换弱峰调整曲线与参数面板。
m_plot.ToggleLaneWeakPeak(laneIndex);
break;
case PLOT_CMD_LANE_FREE_MARKER:
// 「实验性功能 → 自由标记」:切换网格内跟随鼠标的纵向光标与固定数据点面板。
m_plot.ToggleLaneFreeMarker(laneIndex);
break;
case PLOT_CMD_LANE_RESAMPLE:
// 「实验性功能 → 重采样」:切换重采样参数面板。
m_plot.ToggleLaneResample(laneIndex);
break;
case PLOT_CMD_LANE_CLIP:
// 「实验性功能 → 选区截取」:切换选区截取面板。
m_plot.ToggleLaneClip(laneIndex);
break;
case PLOT_CMD_LANE_SPLIT:
// 「拆分当前网格」:信号按个拆成独立网格,新网格顺次插入其下方。
m_plot.SplitLane(laneIndex);
break;
case PLOT_CMD_FIT_ALL:
m_plot.FitAll();
break;
default:
break;
}
SetFocus(m_hwnd);
InvalidateRect(m_hwnd, nullptr, TRUE);
break;
}
case MenuScope::TAB_BAR:
{
const int index = m_tabMenuIndex;
if (result.id == kMenuTabNew)
{
NewTab(true);
}
else if (result.id == kMenuTabClose)
{
CloseTab(index);
}
else if (result.id == kMenuTabCloseOthers)
{
if (index >= 0 && index < static_cast<int>(m_tabs.size()) && m_tabs.size() > 1)
{
const TabConfig keep = m_tabs[static_cast<std::size_t>(index)];
m_tabs.clear();
m_tabs.push_back(keep);
SetActiveTab(0);
}
}
m_tabMenuIndex = -1;
InvalidateRect(m_hwnd, nullptr, TRUE);
break;
}
case MenuScope::NONE:
default:
break;
}
}
} // namespace jdi