fix(ui): 选区截取面板空白真正根因、拖拽指示线层序、新建分组修复、避让动画更顺

一、选区截取面板「条目区与按钮之间的空白」(连续三轮未修好的那个)
根因不是内容区几何,而是**底部固定区高度算了两行**:footerHeight 用
`buttonHeight * 2 + 6 + pad` 预留,而布局把「截取为新信号 / 原始信号截取」
两个按钮并排画在同一行 ⇒ 内容区下边界与按钮上边界之间空出一整行。
改为按一行算(两处:高度算式分支 + 布局几何分支同步修改)。
数值核对(scale=1.25、1 条可见信号):GAP 53.8px → 8.8px(= 一个 pad 的预期留白)。

二、拖拽插入指示线不再盖住被拖条目
PaintSignalDragGhost 的绘制顺序改为「插入指示线 → 阴影 → 幽灵行 → 描边」,
指示线始终位于跟随鼠标的那部分之下。

三、「移动到…」二级菜单
* 条目文案改为「组号 | 组名」(不再是括号形式)。
* 修复「新建分组」点了没反应:DataSet::AddGroup 拒收「无信号且无时间轴」的空分组,
  旧实现「先建空组再搬信号」必然在建组那一步失败。新增
  DataSet::MoveSignalToNewGroup 把建组 + 搬运时间轴 + 搬运信号做成原子操作(失败回滚)。
* TabConfig::RemapAfterSignalMove 在目标分组是新建时自行扩展 m_visible
  (调用方不再先 SyncToDataSet —— 那次同步会按旧下标清理引用)。
* Application::MoveSignalCore 增加 newGroupName 参数并返回 bool,避免失败仍报"已移动"。

四、信号拖拽避让动画更流畅
* 插值系数按真实帧间隔计算:1 - exp(-dt/22ms),dt 夹在 1~60ms,帧间隔抖动时不再忽快忽慢。
* 鼠标移动事件里也推进一次插值,快速拖动时让位效果跟得上指针。

验证:删 .o 全量重编译零 error / 零 warning,产物 875,008 B(< 1MB 预警阈值)。
本轮未占用鼠标/键盘/屏幕,未运行任何 GUI 探针;面板空白改为纯数值算式核对。
规则已同步到 Documents/agent_mem.md(含"改面板几何必须数值验证"的教训条目)。
This commit is contained in:
DeepSeek V4.1 Flash committed 2026-11-03 20:09:01 +08:00
1 parent 97b361dedd
commit 328a3b3b59
10 files changed
+258 -74

No files matched your search

+5 -2
View File
@@ -255,10 +255,13 @@ namespace jdi
*
* @param [IN] fromGroup 源分组下标。
* @param [IN] fromSignal 源分组内的信号下标。
* @param [IN] toGroup 目标分组下标。
* @param [IN] toGroup 目标分组下标(负数 + `newGroupName` 非空 = 新建分组)。
* @param [IN] toIndex 目标组内下标(负数表示追加到末尾)。
* @param [IN] newGroupName 非空且 `toGroup < 0` 时,新建该名称的分组并把信号搬进去。
* @return 确实发生了移动返回 true。
*/
void MoveSignalCore(int fromGroup, int fromSignal, int toGroup, int toIndex);
bool MoveSignalCore(int fromGroup, int fromSignal, int toGroup, int toIndex,
const std::wstring* newGroupName);
/**
* @brief 信号列表拖拽排序的提交入口(组内重排或跨组移动)。
@@ -272,22 +272,36 @@ namespace jdi
return base;
}
void Application::MoveSignalCore(int fromGroup, int fromSignal, int toGroup, int toIndex)
bool Application::MoveSignalCore(int fromGroup, int fromSignal, int toGroup, int toIndex,
const std::wstring* newGroupName)
{
const SignalGroup* sourceGroup = m_dataSet.GroupAt(fromGroup);
const SignalGroup* targetGroup = m_dataSet.GroupAt(toGroup);
if (sourceGroup == nullptr || targetGroup == nullptr || fromSignal < 0 ||
if (sourceGroup == nullptr || fromSignal < 0 ||
static_cast<std::size_t>(fromSignal) >= sourceGroup->signals.size())
{
return;
return false;
}
const bool sameGroup = (fromGroup == toGroup);
// 「移动到全新分组」:目标分组尚不存在,它会被追加到分组列表末尾
//(下标 = 当前分组数,可在此之前预先算出,供引用改写使用)。
const bool createGroup = (toGroup < 0) && (newGroupName != nullptr);
const int resolvedTo = createGroup ? static_cast<int>(m_dataSet.GroupCount()) : toGroup;
const SignalGroup* targetGroup = createGroup ? nullptr : m_dataSet.GroupAt(resolvedTo);
if (!createGroup && targetGroup == nullptr)
{
return false;
}
const bool sameGroup = !createGroup && (fromGroup == resolvedTo);
// 目标下标:负数/越界表示追加到末尾(组内重排时"末尾"= 组内信号数-1,因为该信号会先被摘出)。
int position = toIndex;
const int targetCount = static_cast<int>(targetGroup->signals.size());
if (sameGroup)
const int targetCount = createGroup ? 0 : static_cast<int>(targetGroup->signals.size());
if (createGroup)
{
position = 0;
}
else if (sameGroup)
{
if (position < 0 || position >= targetCount)
{
@@ -295,7 +309,7 @@ namespace jdi
}
if (position == fromSignal)
{
return;
return false;
}
}
else if (position < 0 || position > targetCount)
@@ -307,36 +321,30 @@ namespace jdi
from.groupIndex = fromGroup;
from.signalIndex = fromSignal;
SignalRef to;
to.groupIndex = toGroup;
to.groupIndex = resolvedTo;
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
// 先按**旧下标**修正各标签页内的全部信号引用,再动数据层。
// ⚠️ 跨组(含新建分组)时目标分组那一行可见性表可能还不存在,
// `RemapAfterSignalMove` 内部会自行扩展 —— 这里**不能**先 `SyncToDataSet`,
// 因为那次同步会按旧下标清理/修正引用,随后下标一变就会指向错误的信号。
if (sameGroup)
{
for (TabConfig& tab : m_tabs)
{
tab.RemapAfterSignalReorder(fromGroup, fromSignal, position);
}
}
(void)visible;
else
{
for (TabConfig& tab : m_tabs)
{
tab.RemapAfterSignalMove(from, to, tab.IsVisible(from));
}
}
// 共享选中项:被移动的正是当前选中信号时,改为指向新位置。
if (m_dataSet.Selected().groupIndex == fromGroup)
@@ -372,15 +380,32 @@ namespace jdi
}
int newSignalIndex = -1;
int newGroupIndex = -1;
if (createGroup)
{
// 建组 + 搬运做成一次原子操作(`AddGroup` 拒收空组,见 `MoveSignalToNewGroup`)。
if (!m_dataSet.MoveSignalToNewGroup(fromGroup, fromSignal, *newGroupName, newGroupIndex,
newSignalIndex))
{
// 前面已校验过,理论不可达;若真发生(数据层拒绝),必须把已改写的引用
// 按数据层的真实状态重新对齐,避免留下指向不存在分组的悬空引用。
SyncTabsToDataSet();
return false;
}
}
else
{
// 参数已在此前校验过(分组与信号下标都合法),本调用不会失败。
m_dataSet.MoveSignal(fromGroup, fromSignal, toGroup, position, newSignalIndex);
m_dataSet.MoveSignal(fromGroup, fromSignal, resolvedTo, position, newSignalIndex);
}
SyncTabsToDataSet();
return true;
}
void Application::ReorderSignal(int fromGroup, int fromSignal, int toGroup, int toIndex)
{
MoveSignalCore(fromGroup, fromSignal, toGroup, toIndex);
(void)MoveSignalCore(fromGroup, fromSignal, toGroup, toIndex, nullptr);
}
void Application::MoveSignalToGroup(int fromGroup, int fromSignal, int toGroup)
@@ -393,37 +418,34 @@ namespace jdi
}
const std::wstring signalName = sourceGroup->signals[static_cast<std::size_t>(fromSignal)].name;
// 「新建分组」:先追加一个空分组(名字不重复),再按普通跨组移动处理。
int targetGroup = toGroup;
if (targetGroup < 0)
if (toGroup < 0)
{
SignalGroup group;
group.name = UniqueGroupName(L"新建分组");
if (!m_dataSet.AddGroup(std::move(group), targetGroup))
// 「移动到… → 新建分组」:组名固定为「新建分组」(重名自动加序号)。
const std::wstring name = UniqueGroupName(L"新建分组");
if (MoveSignalCore(fromGroup, fromSignal, -1, 0, &name))
{
SetStatusMessage(L"已将信号「" + signalName + L"」移动到新建分组:" + name, false);
}
return;
}
}
if (targetGroup == fromGroup)
if (toGroup == fromGroup)
{
return; // 已经在该分组,无需移动
}
const SignalGroup* targetGroupData = m_dataSet.GroupAt(targetGroup);
if (targetGroupData == nullptr)
if (!MoveSignalCore(fromGroup, fromSignal, toGroup, -1, nullptr))
{
return;
}
MoveSignalCore(fromGroup, fromSignal, targetGroup,
static_cast<int>(targetGroupData->signals.size()));
const SignalGroup* destination = m_dataSet.GroupAt(targetGroup);
const SignalGroup* destination = m_dataSet.GroupAt(toGroup);
SetStatusMessage(L"已将信号「" + signalName + L"」移动到分组:" +
(destination != nullptr ? destination->name : std::wstring()),
false);
}
void Application::UpdateWindowTitle() {
void Application::UpdateWindowTitle()
{
// 标题栏 = 应用名称 + 分隔竖线 + 内存占用(单位在 KB/MB/GB 间自适应,保留两位小数)。
// 除内存占用外不再附带标签页、文件数量等无关信息。
// ⚠️ 内存口径 = **私有工作集**(与任务管理器"内存"列一致),不是总工作集。
+8 -5
View File
@@ -399,12 +399,14 @@ namespace jdi
// 因此不再有独立的底部编辑行;「边沿计数」走独立的**表格**分支。
const float buttonHeight = hasButton ? (kEdgePanelButtonHeight * m_scale) : 0.0f;
// 面板底部(内容区之下)占的纵向空间:有按钮时 = pad + 按钮 + pad,无按钮时只有 pad。
// ⚠️ 「选区截取」底部是**两个**按钮行(截取为新信号 / 原始信号截取),
// 因此 footer 要按两行算(用户 2026-11-03 要求)。
// ⚠️ **未启用双光标**时两个按钮都不显示 ⇒ footer 归零(否则按钮位置会留一大段空白)。
// ⚠️⚠️ 「选区截取」的两个按钮(截取为新信号 / 原始信号截取)是**并排在同一行**的
// (见下方的几何赋值),因此 footer 只能按**一行**算。旧实现按 `2 * 按钮高 + 6`
// 预留,而实际只画出并排的一行 ⇒ 内容区下方多出一整行“无意义的空白”
// (用户 2026-11-03 连续三轮报告的那片空白,根因就在这一行)。
// ⚠️ **未启用双光标**时两个按钮都不显示 ⇒ footer 归零(否则面板会留一大段空白)。
const bool clipReady = clipPanel && (Tab().CursorMode() == PlotCursorMode::DOUBLE);
const float footerHeight =
clipPanel ? (clipReady ? (buttonHeight * 2.0f + 6.0f * m_scale + pad) : 0.0f)
clipPanel ? (clipReady ? (buttonHeight + pad) : 0.0f)
: (hasButton ? (buttonHeight + pad) : 0.0f);
const float bottomPad = pad;
@@ -836,8 +838,9 @@ namespace jdi
const float scrollW = kFreeMarkerScrollWidth * m_scale;
const bool clipReadyHere =
clipPanel && (Tab().CursorMode() == PlotCursorMode::DOUBLE);
// ⚠️ 两处(高度算式与这里)必须同为**一行按钮**高:两个按钮并排,不是上下两行。
const float footerHeight =
clipPanel ? (clipReadyHere ? (buttonRowHeight * 2.0f + 6.0f * m_scale + pad) : 0.0f)
clipPanel ? (clipReadyHere ? (buttonRowHeight + pad) : 0.0f)
: (hasButton ? (buttonRowHeight + pad) : 0.0f);
// ⚠️⚠️ 内容区高度必须与高度算式
// `height = titleHeight + pad + view + bottomPad + footerHeight` **严格同源**:
+14 -1
View File
@@ -42,7 +42,13 @@ namespace jdi
* 取值区间必须与 `CMD_LANE_BASE` 的区间(300–399)和 `CMD_LANE_NEW` 互不重叠。
*/
CMD_GROUP_MOVE_BASE = 600,
CMD_GROUP_MOVE_NEW = 700, /**< 「移动到…」二级菜单底部:新建分组并把信号移进去。 */
/**
* @brief 「移动到…」二级菜单底部:新建分组并把信号移进去。
*
* ⚠️ 与 `CMD_GROUP_MOVE_BASE` 之间留足空间(600–949 = 最多 350 个分组条目),
* 否则分组数量一多,`CMD_GROUP_MOVE_BASE + index` 就会撞上本命令。
*/
CMD_GROUP_MOVE_NEW = 950,
CMD_GAIN_EDIT = 500, /**< 「系数」二级面板里的系数输入框(幂等提交)。 */
CMD_GAIN_RESET = 501, /**< 「系数调整」标题栏右侧的还原按钮:把系数重置为 1。 */
CMD_OFFSET_RESET = 502, /**< 「偏移调整」标题栏右侧的还原按钮:两个偏移都恢复为默认值。 */
@@ -1538,6 +1544,13 @@ namespace jdi
bool m_signalDragDropBefore = false; /**< 插入点在该行的上方(否则下方)。 */
std::vector<float> m_signalDragShift; /**< 各行避让位移的**目标值**(像素)。 */
std::vector<float> m_signalDragShiftCur; /**< 各行避让位移的**当前值**(逐帧插值)。 */
/**
* @brief 避让插值上一次推进的时刻(`GetTickCount64`,0 = 尚未推进过)。
*
* 插值系数按**真实帧间隔**算(`1 - exp(-dt/τ)`),因此不论是 60fps 定时器帧
* 还是鼠标快速拖动带来的高频帧,观感一致;固定系数在帧间隔抖动时会显得僵硬。
*/
std::uint64_t m_signalDragAnimLastMs = 0;
/**
* @brief 筛选模式下的分组折起状态(按分组下标)。
@@ -1028,6 +1028,7 @@ namespace jdi
m_signalDragDropBefore = false;
m_signalDragShift.clear();
m_signalDragShiftCur.clear();
m_signalDragAnimLastMs = 0;
}
bool SignalPanel::UpdateSignalDrag(int x, int y)
@@ -1054,6 +1055,10 @@ namespace jdi
}
UpdateSignalDragTargets();
// ⚠️ 鼠标移动时**也**推进一步插值:宿主定时器约 10ms 一帧,快速拖动时
// 避让动画会明显落后于鼠标(用户 2026-11-03 反馈“快速拖动略显僵硬”)。
// 在移动事件里补推进一次,让让位效果跟得上指针。
AdvanceSignalDragAnimation();
return true;
}
@@ -1190,6 +1195,7 @@ namespace jdi
m_signalDragDropBefore = false;
m_signalDragShift.clear();
m_signalDragShiftCur.clear();
m_signalDragAnimLastMs = 0;
}
void SignalPanel::FinishSignalDrag()
@@ -1233,14 +1239,27 @@ namespace jdi
{
m_signalDragShiftCur.resize(m_signalDragShift.size(), 0.0f);
}
// 逐帧向目标位移插值(宿主约 10ms 一帧 ⇒ 约 5 帧收敛,观感是"平滑让位")。
const float factor = 0.32f;
// 按**真实帧间隔**求插值系数:`1 - exp(-dt/τ)`。
// ⚠️ 不能用固定系数:鼠标移动帧(可能 3~5ms)与定时器帧(约 10ms)混杂时,
// 固定系数会让动画忽快忽慢、拖动时显得僵硬(用户 2026-11-03 反馈)。
const std::uint64_t now = GetTickCount64();
float deltaMs = 16.0f;
if (m_signalDragAnimLastMs != 0 && now >= m_signalDragAnimLastMs)
{
deltaMs = static_cast<float>(now - m_signalDragAnimLastMs);
}
m_signalDragAnimLastMs = now;
deltaMs = std::max(1.0f, std::min(deltaMs, 60.0f));
// τ = 22ms:16ms 一帧时系数约 0.52(约 3 帧收敛),既跟手又有过渡。
const float factor = 1.0f - std::exp(-deltaMs / 22.0f);
for (std::size_t index = 0; index < m_signalDragShiftCur.size(); ++index)
{
const float target = m_signalDragShift[index];
float& current = m_signalDragShiftCur[index];
const float delta = target - current;
if (std::fabs(delta) < 0.5f)
if (std::fabs(delta) < 0.4f)
{
current = target;
}
@@ -1273,23 +1292,10 @@ namespace jdi
const int state = g.Save();
g.SetClip(m_listRect, gd::CombineModeIntersect);
// 浮起的阴影:让条目明显"脱离"列表本体。
UiFillRoundRect(g, gd::RectF(natural.X + 2.0f * m_scale, top + 3.0f * m_scale,
natural.Width, height),
4.0f * m_scale, 0x33000000u);
// 复用行绘制:临时把行顶部覆盖到鼠标位置(与「组名悬浮」同一机制),
// 因此名称、复选框、GX 徽标、✕ 等全部随行一起移动,无需另写一套绘制。
m_rowYOverrideActive = true;
m_rowYOverrideY = top;
PaintSignalRow(g, m_signalDragRow);
m_rowYOverrideActive = false;
UiDrawRoundRect(g, gd::RectF(natural.X + 1.0f * m_scale, top + 1.0f * m_scale,
natural.Width - 2.0f * m_scale, height - 2.0f * m_scale),
4.0f * m_scale, m_theme->accent, 1.6f * m_scale);
// 插入位置指示线:明确告诉用户"松手后落在哪里"。
// ⚠️⚠️ **插入位置指示线必须最先画**(= 位于被拖条目之下):
// 用户 2026-11-03 明确要求“不要覆盖到正在拖拽的那个元素上,
// 始终处于跟随鼠标移动的绘制部分下方”。旧实现把它画在幽灵行之后,
// 条目恰好被拖到落点附近时,蓝线会横穿条目本体。
if (m_signalDragDropRow >= 0)
{
float lineY = 0.0f;
@@ -1310,6 +1316,22 @@ namespace jdi
}
}
// 浮起的阴影:让条目明显"脱离"列表本体(画在指示线之上,遮住穿过的线)。
UiFillRoundRect(g, gd::RectF(natural.X + 2.0f * m_scale, top + 3.0f * m_scale,
natural.Width, height),
4.0f * m_scale, 0x33000000u);
// 复用行绘制:临时把行顶部覆盖到鼠标位置(与「组名悬浮」同一机制),
// 因此名称、复选框、GX 徽标、✕ 等全部随行一起移动,无需另写一套绘制。
m_rowYOverrideActive = true;
m_rowYOverrideY = top;
PaintSignalRow(g, m_signalDragRow);
m_rowYOverrideActive = false;
UiDrawRoundRect(g, gd::RectF(natural.X + 1.0f * m_scale, top + 1.0f * m_scale,
natural.Width - 2.0f * m_scale, height - 2.0f * m_scale),
4.0f * m_scale, m_theme->accent, 1.6f * m_scale);
g.Restore(state);
}
@@ -233,7 +233,7 @@ namespace jdi
items.push_back(cloneAdjusted);
// 「移动到…」二级菜单(用户 2026-11-03 要求,位置在「仅显示该信号」**之上**):
// 条目为「分组 N(组名)」+ 当前所在分组打勾;末尾分割线之下是「新建分组」。
// 条目为「组号 | 组名」+ 当前所在分组打勾;末尾分割线之下是「新建分组」。
// 结构与信号名右侧 GX 徽标点击后的「所属网格」菜单保持一致,但**没有标题行**,
// 且底部动作换成「新建分组」(新建分组后把信号移进去)。
MenuItem moveMenu;
@@ -248,7 +248,9 @@ namespace jdi
continue;
}
MenuItem entry;
entry.label = L"分组 " + std::to_wstring(index + 1) + L"(" + group->name + L")";
// ⚠️ 显示形式为「组号 + 竖线 + 组名」(用户 2026-11-03 明确要求),
// **不要**用括号把组名括起来(旧实现 `分组 1(名称)` 已被要求改掉)。
entry.label = std::to_wstring(index + 1) + L" | " + group->name;
entry.id = CMD_GROUP_MOVE_BASE + static_cast<int>(index);
entry.checked = (static_cast<int>(index) == ref.groupIndex);
moveMenu.submenu.push_back(entry);
+52
View File
@@ -10112,3 +10112,55 @@ MEAN_SCROLL / 滚轮 / 悬停 / `SetLanePanelCollapsed` / `LanePanelCollapsed` /
* 交互注意:`BeginSignalDrag` 只在**行主体**(else 分支)登记;`OnLButtonUp` 最前面先判拖拽;
`OnDoubleClick` 一开始就 `CancelSignalDrag()`(否则改名后轻微移动会把信号拖走);
`InvalidateRows()` 必须清空全部拖拽状态(行下标与逐行位移都会失效)。
---
## 2026-11-03 · 第 30 轮(选区截取空白真正的根因 / 拖拽排序打磨 / 新建分组)
> ⚠️ **教训(必须遵守)**:第 29 轮我向用户报告「已修复空白」,但只做了「高度算式与内容几何同源」
> 这一步,**没有核对底部固定区的高度与实际按钮几何是否一致**,于是连续三轮没修好。
> 今后凡是改**面板几何/布局**:必须把算式在纯数值层面跑一遍(python 或纯控制台程序,
> 不启动 GUI),打印出「内容区下边界 vs 按钮上边界」这类**具体像素值**再下结论。
### 1. ⭐⭐⭐ 选区截取面板「条目区与按钮之间的空白」——真正的根因
**根因**:底部固定区高度 `footerHeight` 按**两行按钮**预留
(`buttonHeight * 2 + 6 + pad`),但布局阶段把「截取为新信号 / 原始信号截取」两个按钮
**并排放在同一行**(`buttonTop = top + height - pad - buttonRowHeight`)。
⇒ 内容区下边界与按钮上边界之间恰好空出 `buttonHeight + 6` 一整行。
**现行规则**:
* 两个按钮**并排一行**,所以 `footerHeight = kEdgePanelButtonHeight * m_scale + pad`(**一行**)。
* `footerHeight` 在**两处**(高度算式分支 + 布局/几何分支)必须同为一行,改一处必改另一处。
* 数值验证(`scale = 1.25`、1 条可见信号):
修前 `GAP = 53.8px`(截图里的空白),修后 `GAP = 8.8px` = 一个 `pad`(预期留白)。
### 2. 拖拽插入指示线不得盖住被拖条目
用户要求:指示线**始终在被跟随鼠标绘制的那部分之下**。
⇒ `PaintSignalDragGhost` 内的绘制顺序固定为:**插入指示线 → 阴影 → 幽灵行 → 强调色描边**。
(旧实现把指示线放在最后,条目被拖到落点附近时蓝线会横穿条目本体。)
### 3. 「移动到…」二级菜单两个问题
* 条目文案从 `分组 N(组名)` 改为 **`N | 组名`**(组号 + 竖线 + 组名)。
* **「新建分组」点了没反应**:`DataSet::AddGroup` 的守卫是
`if (group.signals.empty() || group.axes.empty()) return false;`
⇒ 「先 AddGroup 建空组、再搬信号」**必然在建组那一步就失败返回**。
(用户的推测——空分组被当作不需要而拒收——完全正确。)
**修法**:新增 `DataSet::MoveSignalToNewGroup(from, signal, name, &groupIndex, &signalIndex)`,
把「建组 + 搬运时间轴 + 搬运信号」做成一次原子操作(失败则回滚空组)。
⚠️ 不要再去改 `AddGroup` 的守卫:它服务加载器/派生信号,放宽会让空分组到处出现。
* 目标分组是**新建**的(`m_visible` 还没有它那一行):
`TabConfig::RemapAfterSignalMove` 必须**自行扩展** `m_visible`(`resize(toSlot + 1)`)。
⚠️ 调用方**不能**先 `SyncToDataSet` 来补列 —— 那次同步会按**旧下标**清理/修正引用,
随后下标一变更就指向错误的信号。
* `Application::MoveSignalCore` 增加可选参数 `newGroupName`(`toGroup < 0 && 非空` = 新建分组),
并返回 `bool`,调用方据其结果决定是否报告「已移动」。
### 4. 拖拽避让动画更流畅
* 插值系数改为按**真实帧间隔**求:`factor = 1 - exp(-dt / 22ms)`(`dt` 夹在 1–60ms)。
固定系数在「鼠标移动帧(3–5ms)+ 定时器帧(约 10ms)」混杂时会忽快忽慢。
* `UpdateSignalDrag` 里在更新落点后**再推进一次插值**:快速拖动时不必等宿主定时器那 10ms。
* 快照阈值 0.4px(避免残差抖动)。
+19
View File
@@ -747,6 +747,25 @@ namespace jdi
bool MoveSignal(int fromGroup, int fromSignal, int toGroup, int toSignalIndex,
int& newSignalIndex);
/**
* @brief 新建一个分组,并把一条信号(连同它的时间轴)搬进去。
*
* ⚠️ **不能写成「先 `AddGroup` 再 `MoveSignal`」**:`AddGroup` 拒绝“既没有信号、
* 也没有时间轴”的空分组(它用于加载器/派生信号,需要完整内容)⇒ 先建空组必然
* 失败返回,表现出来就是“点了「新建分组」毫无反应”(用户 2026-11-03 报告,
* 其推测——空分组被当作不需要而拒收——完全正确)。
* 本函数把“建组 + 搬运”做成一次原子操作:先推入空组,再搬信号进来,失败则回滚。
*
* @param [IN] fromGroup 源分组下标。
* @param [IN] fromSignal 源分组内的信号下标。
* @param [IN] groupName 新分组名称(调用方负责去重)。
* @param [OUT] newGroupIndex 新分组下标(恒为原分组数的位置)。
* @param [OUT] newSignalIndex 该信号在新分组内的下标(恒为 0)。
* @return 成功返回 true。
*/
bool MoveSignalToNewGroup(int fromGroup, int fromSignal, const std::wstring& groupName,
int& newGroupIndex, int& newSignalIndex);
/**
* @brief 移除指定下标的分组,并立即释放其占用的全部采样内存。
* @param [IN] groupIndex 分组下标。
+40
View File
@@ -742,6 +742,46 @@ namespace jdi
return true;
}
bool DataSet::MoveSignalToNewGroup(int fromGroup, int fromSignal, const std::wstring& groupName,
int& newGroupIndex, int& newSignalIndex)
{
newGroupIndex = -1;
newSignalIndex = -1;
if (fromGroup < 0 || fromGroup >= static_cast<int>(m_groups.size()))
{
return false;
}
if (fromSignal < 0 ||
fromSignal >= static_cast<int>(m_groups[static_cast<std::size_t>(fromGroup)].signals.size()))
{
return false;
}
// ⚠️ 不能走 `AddGroup`:它拒绝“既没有信号、也没有时间轴”的空分组
// (那正是「新建分组」点了没反应的根本原因,用户 2026-11-03 的推测是对的)。
// 这里先把空组推进去,再把信号(连同它的时间轴)搬进来。
SignalGroup created;
created.name = groupName;
m_groups.push_back(std::move(created));
const int target = static_cast<int>(m_groups.size()) - 1;
int moved = -1;
if (!MoveSignal(fromGroup, fromSignal, target, 0, moved))
{
// 回滚:移动没有发生 ⇒ 去掉刚建的空组(前面已校验过,理论不会走到)。
if (static_cast<int>(m_groups.size()) == target + 1 &&
m_groups[static_cast<std::size_t>(target)].signals.empty())
{
m_groups.pop_back();
}
return false;
}
newGroupIndex = target;
newSignalIndex = moved;
return true;
}
bool DataSet::MoveSignalToGroup(int fromGroup, int fromSignal, int toGroup, int& newSignalIndex)
{
if (fromGroup == toGroup)
+8
View File
@@ -92,7 +92,15 @@ namespace jdi
}
// 1) 可见性表:目标分组在目标位置插入一列(沿用源分组里的可见性),源分组删除一列。
// ⚠️⚠️ 目标分组可能是**刚新建**的(「移动到… → 新建分组」):此时 `m_visible`
// 还没有它那一行。必须在这里**自行扩展**(否则插入被跳过 ⇒ 信号到新组后
// 变为未勾选、曲线直接消失)。不能依赖调用方先 `SyncToDataSet`:那次同步会
// 按**旧下标**清理引用,而数据层马上就要整体位移。
const std::size_t toSlot = static_cast<std::size_t>(to.groupIndex);
if (toSlot >= m_visible.size())
{
m_visible.resize(toSlot + 1);
}
if (toSlot < m_visible.size() &&
static_cast<std::size_t>(to.signalIndex) <= m_visible[toSlot].size())
{