refactor(application): 拆分 2165 行的 ApplicationCommands.cpp 为 4 个职责分片

原文件混杂了标签页、派生信号、菜单、工具栏、键盘鼠标、布局调试绘制六类职责,
是本项目最大的“大杂烩”单文件之一。按职责纯搬移(零逻辑改动):

- ApplicationTabs.cpp   (355 行): 标签页新建/关闭/切换/重排、视图同步、窗口标题、
                                   标签栏结果回调、标签页右键菜单、待提交改名。
- ApplicationDerive.cpp (786 行): 派生烘焙、重采样、选区截取(新建/就地)、克隆(含带调整)。
- ApplicationMenus.cpp  (409 行): 绘图区右键菜单、菜单数值回写、主题切换、设置菜单、
                                   菜单结果派发。
- ApplicationCommands.cpp (642 行): 分组/信号增删、移动与重排、文件对话框、工具栏动作、
                                   键盘鼠标分发、布局边界绘制。

Makefile 按目录通配收集,无需登记新文件。

验证:release 零 error 零 warning;产物 793,088 -> 794,112 B(+1,024,LTO 内联边界变化,
< 0.13%,属结构性拆分不可避免的噪声)。
This commit is contained in:
DeepSeek-V4.1-Flash committed 2026-11-04 03:02:36 +08:00
1 parent 7a3fefb9c2
commit 8437100586
5 files changed
+1587 -1524

No files matched your search

File diff suppressed because it is too large. Load diff
@@ -0,0 +1,789 @@
// Application 实现分片 —— **派生信号**:派生烘焙(均值滤波/中心轴/弱峰调整)、
// 重采样、选区截取(新建 / 就地破坏性截取)、克隆(普通 / 带调整)。
//
// 共用的常量与工具函数在 ApplicationInternal.hpp(命名空间 `appdetail`,全部 inline)。
// 本文件由 2026-11-04 代码审查从 `ApplicationCommands.cpp`(原 2,165 行)按职责拆出,
// **纯搬移、零逻辑改动**。分片划分见 `Documents/Code_Review_Plan.md` 的 P11。
#include "ApplicationInternal.hpp"
namespace jdi
{
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::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;
}
} // namespace jdi
@@ -0,0 +1,409 @@
// Application 实现分片 —— **菜单**:绘图区右键菜单(含「移动到…」子菜单)、
// 菜单数值回写、主题切换、设置菜单,以及菜单结果的统一派发。
//
// 共用的常量与工具函数在 ApplicationInternal.hpp(命名空间 `appdetail`,全部 inline)。
// 本文件由 2026-11-04 代码审查从 `ApplicationCommands.cpp`(原 2,165 行)按职责拆出,
// **纯搬移、零逻辑改动**。分片划分见 `Documents/Code_Review_Plan.md` 的 P11。
#include "ApplicationInternal.hpp"
namespace jdi
{
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);
}
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
@@ -0,0 +1,360 @@
// Application 实现分片 —— **标签页**:新建/关闭/切换/重排、视图同步、窗口标题、
// 标签栏结果回调与标签页右键菜单。
//
// 共用的常量与工具函数在 ApplicationInternal.hpp(命名空间 `appdetail`,全部 inline)。
// 本文件由 2026-11-04 代码审查从 `ApplicationCommands.cpp`(原 2,165 行)按职责拆出,
// **纯搬移、零逻辑改动**。分片划分见 `Documents/Code_Review_Plan.md` 的 P11:
// 标签页 = 本文件;派生/重采样/截取/克隆 = `ApplicationDerive.cpp`;
// 上下文菜单与设置菜单 = `ApplicationMenus.cpp`;其余命令 = `ApplicationCommands.cpp`。
#include "ApplicationInternal.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::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::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);
}
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);
}
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;
}
} // namespace jdi
+24
View File
@@ -78,4 +78,28 @@ map 中 `cp-demangle.o` 已无 `.text` 段分配。
**验证**:release 与 debug 均全量重建,零 error 零 warning(`-O0` 下也没有新警告)。
### P7 面板状态/滚动量收敛为唯一派发点 —— ✅ 完成(提交 `7a3fefb`)
**问题(屎山核心之一)**:`Lane` 里 7 种实验性功能各有 1 份 `LanePanelState` + 1 个滚动偏移
(14 个字段)。旧实现在**每一处**“按种类取用”的地方各写一遍嵌套三元式或 `switch`:
`EdgePanelLayoutFor` 5 条 7 层嵌套三元式;标题栏拖拽、✕ 关闭、滚轮、滚动条、错开序数各写一遍。
新增面板要改 20+ 处,漏一处就是「拖不动 / 关不掉 / 滚轮滚不到底」
(`MARK`、`RESAMPLE`、`CLIP` 均真实踩过)。
**改动**:`PlotViewInternal.hpp` 新增唯一派发点
`LanePanelEnabled` / `LanePanelStateOf` / `LanePanelScrollOf`(可写 + 只读)。
这些 `switch` **穷举 7 种且不写 `default:`** ⇒ `-Wswitch` 在漏登记时编译报警
(已用最小程序验证 `-Wswitch` 确实会报)。
调用点:布局 5 条三元式 → 3 行;标题栏拖拽 7 分支 switch → 1 次写入;
滚轮/滚动条/✕ 关闭/折叠切换全部改为穷举无 `default`。
**验证**:release + debug 零 warning;产物 **793,088 B 不变**(等价变换,说明只是结构收敛)。
**未做(受验证条件限制)**:尚未把 `EdgePanelLayoutFor` 的 957 行分支整体改成
“每面板一行元数据表”。原因:没有可用的离屏像素对比工具(`.preflight` 已删除,
且崩溃会弹模态框),纯逻辑重写无法自证“像素完全一致”。已通过上面的派发收敛
消除最容易出错的一类重复,剩余重复属于“几何算式本身”,风险高于收益。