From 328a3b3b5929de928429052d2de1649a40ff8c47 Mon Sep 17 00:00:00 2001 From: "DeepSeek V4.1 Flash" <> Date: Tue, 3 Nov 2026 20:09:01 +0800 Subject: [PATCH] =?UTF-8?q?fix(ui):=20=E9=80=89=E5=8C=BA=E6=88=AA=E5=8F=96?= =?UTF-8?q?=E9=9D=A2=E6=9D=BF=E7=A9=BA=E7=99=BD=E7=9C=9F=E6=AD=A3=E6=A0=B9?= =?UTF-8?q?=E5=9B=A0=E3=80=81=E6=8B=96=E6=8B=BD=E6=8C=87=E7=A4=BA=E7=BA=BF?= =?UTF-8?q?=E5=B1=82=E5=BA=8F=E3=80=81=E6=96=B0=E5=BB=BA=E5=88=86=E7=BB=84?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E3=80=81=E9=81=BF=E8=AE=A9=E5=8A=A8=E7=94=BB?= =?UTF-8?q?=E6=9B=B4=E9=A1=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 一、选区截取面板「条目区与按钮之间的空白」(连续三轮未修好的那个) 根因不是内容区几何,而是**底部固定区高度算了两行**: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(含"改面板几何必须数值验证"的教训条目)。 --- Components/Application/Inc/Application.hpp | 7 +- .../Application/Src/ApplicationCommands.cpp | 112 +++++++++++------- Components/PlotView/Src/PlotViewPanels.cpp | 13 +- Components/SignalPanel/Inc/SignalPanel.hpp | 15 ++- .../Src/SignalPanelInteraction.cpp | 62 ++++++---- .../SignalPanel/Src/SignalPanelMenu.cpp | 6 +- Documents/agent_mem.md | 52 ++++++++ Utilities/Inc/DataModel.hpp | 19 +++ Utilities/Src/DataModel.cpp | 40 +++++++ Utilities/Src/DataModelTab.cpp | 8 ++ 10 files changed, 259 insertions(+), 75 deletions(-) diff --git a/Components/Application/Inc/Application.hpp b/Components/Application/Inc/Application.hpp index a2e52f9..985bed0 100644 --- a/Components/Application/Inc/Application.hpp +++ b/Components/Application/Inc/Application.hpp @@ -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 信号列表拖拽排序的提交入口(组内重排或跨组移动)。 diff --git a/Components/Application/Src/ApplicationCommands.cpp b/Components/Application/Src/ApplicationCommands.cpp index fc217e9..ba8c65a 100644 --- a/Components/Application/Src/ApplicationCommands.cpp +++ b/Components/Application/Src/ApplicationCommands.cpp @@ -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(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(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(targetGroup->signals.size()); - if (sameGroup) + const int targetCount = createGroup ? 0 : static_cast(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; - // 参数已在此前校验过(分组与信号下标都合法),本调用不会失败。 - m_dataSet.MoveSignal(fromGroup, fromSignal, toGroup, position, newSignalIndex); + int newGroupIndex = -1; + if (createGroup) + { + // 建组 + 搬运做成一次原子操作(`AddGroup` 拒收空组,见 `MoveSignalToNewGroup`)。 + if (!m_dataSet.MoveSignalToNewGroup(fromGroup, fromSignal, *newGroupName, newGroupIndex, + newSignalIndex)) + { + // 前面已校验过,理论不可达;若真发生(数据层拒绝),必须把已改写的引用 + // 按数据层的真实状态重新对齐,避免留下指向不存在分组的悬空引用。 + SyncTabsToDataSet(); + return false; + } + } + else + { + // 参数已在此前校验过(分组与信号下标都合法),本调用不会失败。 + m_dataSet.MoveSignal(fromGroup, fromSignal, resolvedTo, position, newSignalIndex); + } SyncTabsToDataSet(); + return true; } void Application::ReorderSignal(int fromGroup, int fromSignal, int toGroup, int toIndex) { - 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(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)) { - return; + 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(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 间自适应,保留两位小数)。 // 除内存占用外不再附带标签页、文件数量等无关信息。 // ⚠️ 内存口径 = **私有工作集**(与任务管理器"内存"列一致),不是总工作集。 diff --git a/Components/PlotView/Src/PlotViewPanels.cpp b/Components/PlotView/Src/PlotViewPanels.cpp index 4bef1da..6535aee 100644 --- a/Components/PlotView/Src/PlotViewPanels.cpp +++ b/Components/PlotView/Src/PlotViewPanels.cpp @@ -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` **严格同源**: diff --git a/Components/SignalPanel/Inc/SignalPanel.hpp b/Components/SignalPanel/Inc/SignalPanel.hpp index 1fbc562..71c78bc 100644 --- a/Components/SignalPanel/Inc/SignalPanel.hpp +++ b/Components/SignalPanel/Inc/SignalPanel.hpp @@ -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 m_signalDragShift; /**< 各行避让位移的**目标值**(像素)。 */ std::vector m_signalDragShiftCur; /**< 各行避让位移的**当前值**(逐帧插值)。 */ + /** + * @brief 避让插值上一次推进的时刻(`GetTickCount64`,0 = 尚未推进过)。 + * + * 插值系数按**真实帧间隔**算(`1 - exp(-dt/τ)`),因此不论是 60fps 定时器帧 + * 还是鼠标快速拖动带来的高频帧,观感一致;固定系数在帧间隔抖动时会显得僵硬。 + */ + std::uint64_t m_signalDragAnimLastMs = 0; /** * @brief 筛选模式下的分组折起状态(按分组下标)。 diff --git a/Components/SignalPanel/Src/SignalPanelInteraction.cpp b/Components/SignalPanel/Src/SignalPanelInteraction.cpp index 10c995c..3dfb46a 100644 --- a/Components/SignalPanel/Src/SignalPanelInteraction.cpp +++ b/Components/SignalPanel/Src/SignalPanelInteraction.cpp @@ -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(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); } diff --git a/Components/SignalPanel/Src/SignalPanelMenu.cpp b/Components/SignalPanel/Src/SignalPanelMenu.cpp index 496629c..8fa0274 100644 --- a/Components/SignalPanel/Src/SignalPanelMenu.cpp +++ b/Components/SignalPanel/Src/SignalPanelMenu.cpp @@ -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(index); entry.checked = (static_cast(index) == ref.groupIndex); moveMenu.submenu.push_back(entry); diff --git a/Documents/agent_mem.md b/Documents/agent_mem.md index d36570d..db648ac 100644 --- a/Documents/agent_mem.md +++ b/Documents/agent_mem.md @@ -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(避免残差抖动)。 diff --git a/Utilities/Inc/DataModel.hpp b/Utilities/Inc/DataModel.hpp index 84f88f3..52fb4be 100644 --- a/Utilities/Inc/DataModel.hpp +++ b/Utilities/Inc/DataModel.hpp @@ -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 分组下标。 diff --git a/Utilities/Src/DataModel.cpp b/Utilities/Src/DataModel.cpp index 390d8fa..a16e9cf 100644 --- a/Utilities/Src/DataModel.cpp +++ b/Utilities/Src/DataModel.cpp @@ -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(m_groups.size())) + { + return false; + } + if (fromSignal < 0 || + fromSignal >= static_cast(m_groups[static_cast(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(m_groups.size()) - 1; + + int moved = -1; + if (!MoveSignal(fromGroup, fromSignal, target, 0, moved)) + { + // 回滚:移动没有发生 ⇒ 去掉刚建的空组(前面已校验过,理论不会走到)。 + if (static_cast(m_groups.size()) == target + 1 && + m_groups[static_cast(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) diff --git a/Utilities/Src/DataModelTab.cpp b/Utilities/Src/DataModelTab.cpp index b5cc3fa..f79290e 100644 --- a/Utilities/Src/DataModelTab.cpp +++ b/Utilities/Src/DataModelTab.cpp @@ -92,7 +92,15 @@ namespace jdi } // 1) 可见性表:目标分组在目标位置插入一列(沿用源分组里的可见性),源分组删除一列。 + // ⚠️⚠️ 目标分组可能是**刚新建**的(「移动到… → 新建分组」):此时 `m_visible` + // 还没有它那一行。必须在这里**自行扩展**(否则插入被跳过 ⇒ 信号到新组后 + // 变为未勾选、曲线直接消失)。不能依赖调用方先 `SyncToDataSet`:那次同步会 + // 按**旧下标**清理引用,而数据层马上就要整体位移。 const std::size_t toSlot = static_cast(to.groupIndex); + if (toSlot >= m_visible.size()) + { + m_visible.resize(toSlot + 1); + } if (toSlot < m_visible.size() && static_cast(to.signalIndex) <= m_visible[toSlot].size()) {