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 探针,仅编译验证 + 源码级核对。
This commit is contained in:
DeepSeek V4.1 Flash committed 2026-11-03 19:27:43 +08:00
1 parent 21a9865365
commit 97b361dedd
23 files changed
+1581 -74

No files matched your search

@@ -224,6 +224,51 @@ namespace jdi
*/
void RemoveSignal(int groupIndex, int signalIndex);
/**
* @brief 把一条信号移动到另一个分组(`toGroup < 0` 表示新建分组)。
*
* 用户从信号右键菜单的「移动到…」调用。跨分组移动会让 `SignalData::axisIndex`
* 失效(轴按分组存放),因此数据层会把该信号的时间轴一并带走
* (独占则移动、共享则复制),详见 `DataSet::MoveSignalToGroup`。
*
* 移动后:各标签页先 `RemapAfterSignalMove` 修正引用(信号**留在原网格**、
* 曲线继续显示),共享选中项同步改写,最后 `SyncTabsToDataSet` 整体对齐。
*
* @param [IN] fromGroup 源分组下标。
* @param [IN] fromSignal 源分组内的信号下标。
* @param [IN] toGroup 目标分组下标,负数表示新建一个名为「新建分组」的分组。
*/
void MoveSignalToGroup(int fromGroup, int fromSignal, int toGroup);
/**
* @brief 生成一个在现有分组中不重复的分组名(`base` / `base (2)` / `base (3)` …)。
* @param [IN] base 期望名称。
* @return 实际可用名称。
*/
std::wstring UniqueGroupName(const std::wstring& base) const;
/**
* @brief 把一条信号移动到指定分组的指定位置(组内重排 / 跨组移动的统一实现)。
*
* `MoveSignalToGroup` 与信号列表的拖拽排序都汇到这里,保证两处的引用修正、
* 选中项迁移与同步流程完全一致。
*
* @param [IN] fromGroup 源分组下标。
* @param [IN] fromSignal 源分组内的信号下标。
* @param [IN] toGroup 目标分组下标。
* @param [IN] toIndex 目标组内下标(负数表示追加到末尾)。
*/
void MoveSignalCore(int fromGroup, int fromSignal, int toGroup, int toIndex);
/**
* @brief 信号列表拖拽排序的提交入口(组内重排或跨组移动)。
* @param [IN] fromGroup 源分组下标。
* @param [IN] fromSignal 源分组内的信号下标。
* @param [IN] toGroup 目标分组下标。
* @param [IN] toIndex 目标组内下标(-1 表示末尾)。
*/
void ReorderSignal(int fromGroup, int fromSignal, int toGroup, int toIndex);
/**
* @brief 处理标签栏交互结果。
* @param [IN] result 交互结果。
@@ -72,6 +72,22 @@ namespace jdi
* 不烘值 ⇒ 两条曲线逐像素重合,且新信号的「调整」面板与源信号完全一致。
*/
inline const UINT kMessageCloneSignalAdjusted = WM_APP + 6; /**< 面板请求「克隆该信号(带调整)」的自定义消息。 */
/**
* @brief 面板请求「把信号移动到另一个分组」的自定义消息。
*
* `wParam = 源分组下标`;`lParam` 低 16 位 = 目标分组下标(`0xFFFF` 表示**新建分组**),
* 高 16 位 = 源分组内的信号下标。
* 新分组名固定为「新建分组」(重名时自动加序号),见 `Application::MoveSignalToGroup`。
*/
inline const UINT kMessageMoveSignalToGroup = WM_APP + 7;
inline const int kMoveSignalNewGroupMarker = 0xFFFF; /**< `kMessageMoveSignalToGroup` 中「新建分组」的 lParam 标记。 */
/**
* @brief 面板请求「信号列表拖拽排序」的自定义消息。
*
* `wParam` = `(源分组下标 << 16) | 源组内信号下标`;
* `lParam` 低 16 位 = 目标分组下标,高 16 位 = 目标组内下标(`0xFFFF` 表示末尾)。
*/
inline const UINT kMessageReorderSignal = WM_APP + 8;
inline const UINT_PTR kHintTimerId = 1; /**< 提示浮层淡出用的定时器 ID。 */
inline const UINT kHintTimerPeriodMs = 33; /**< 提示浮层淡出的重绘间隔(约 30fps)。 */
inline const UINT_PTR kRamTimerId = 2; /**< 标题栏内存占用刷新的定时器 ID。 */
+35 -3
View File
@@ -506,7 +506,9 @@ namespace jdi
// 不含面板(例如与鼠标消息或 `kMessagePlotDerivedReady` 的失效区域交错),
// 那一帧就**完全不重画面板** ⇒ 用户报告“过渡动画有时直接没了”。
// ✓ 把动画状态直接纳入重绘条件(**只增加重绘条件,不改变任何绘制/回送路径**)。
const bool panelAnimating = m_panel.ScrollAnimationActive() || m_panel.GroupAnimationActive();
const bool panelAnimating = m_panel.ScrollAnimationActive() ||
m_panel.GroupAnimationActive() ||
m_panel.SignalDragAnimationActive();
const bool overlaysVisible = m_menu.IsOpen() || m_colorPicker.IsOpen();
// ⚠️ 两部分分开:
// * `fullChrome` —— 标签栏 / 工具栏 / 整窗底色 / 面板都要重绘(有真内容变化);
@@ -668,6 +670,7 @@ namespace jdi
// 永远看不到最终数字。`PanelRefreshPending()` 为真时保持定时器,等
// `kEdgeScanInteractiveIdleMs` 过去、某个非交互帧把面板按最新数字重画一次。
if (m_panel.ScrollAnimationActive() || m_panel.GroupAnimationActive() ||
m_panel.SignalDragAnimationActive() ||
m_plot.LaneScrollAnimationActive() || m_plot.BackToTopAnimationActive() ||
m_plot.PanelRefreshPending())
{
@@ -1755,6 +1758,32 @@ namespace jdi
CloneSignalAdjusted(ref);
return 0;
}
case kMessageMoveSignalToGroup:
{
// wParam = 源分组下标;lParam 低 16 位 = 目标分组下标(0xFFFF = 新建分组),
// 高 16 位 = 源分组内的信号下标。
const LONG_PTR packed = static_cast<LONG_PTR>(lParam);
const int fromGroup = static_cast<int>(wParam);
const int fromSignal = static_cast<int>(packed >> 16);
const int targetRaw = static_cast<int>(packed & 0xFFFF);
MoveSignalToGroup(fromGroup, fromSignal,
(targetRaw == kMoveSignalNewGroupMarker) ? -1 : targetRaw);
return 0;
}
case kMessageReorderSignal:
{
// wParam = (源分组 << 16) | 源信号下标;
// lParam = (目标下标 << 16) | 目标分组(目标下标 0xFFFF = 末尾)。
const LONG_PTR from = static_cast<LONG_PTR>(wParam);
const LONG_PTR to = static_cast<LONG_PTR>(lParam);
const int fromGroup = static_cast<int>(from >> 16);
const int fromSignal = static_cast<int>(from & 0xFFFF);
const int toGroup = static_cast<int>(to & 0xFFFF);
const int toRaw = static_cast<int>(to >> 16);
ReorderSignal(fromGroup, fromSignal, toGroup,
(toRaw == kMoveSignalNewGroupMarker) ? -1 : toRaw);
return 0;
}
case WM_TIMER:
{
// 标题栏内存占用:只改窗口标题,不动客户区,因此无需重绘。
@@ -1785,14 +1814,17 @@ namespace jdi
// 保证面板的最终位置被完整重绘并回送。
if (wParam == kScrollTimerId)
{
const bool panelAnimating =
m_panel.ScrollAnimationActive() || m_panel.GroupAnimationActive();
const bool panelAnimating = m_panel.ScrollAnimationActive() ||
m_panel.GroupAnimationActive() ||
m_panel.SignalDragAnimationActive();
// 绘图区自己的动画(网格滚动到可视区 / 「回到最上方」淡入淡出)也会改像素:
// 它跟面板动画同时跑时,本帧**不能**跳过绘图区重绘(否则绘图区动画会冻结)。
const bool plotAnimating =
m_plot.LaneScrollAnimationActive() || m_plot.BackToTopAnimationActive();
m_panel.AdvanceScrollAnimation();
m_panel.AdvanceGroupAnimations();
// 信号条目拖拽:幽灵行跟随鼠标 + 其它行的避让插值(用户 2026-11-03 要求)。
m_panel.AdvanceSignalDragAnimation();
m_plot.AdvanceLaneScrollAnimation();
m_plot.AdvanceBackToTopAnimation();
if (panelAnimating)
@@ -244,8 +244,186 @@ namespace jdi
SetStatusMessage(L"已从工作区移除信号:" + name, false);
}
void Application::UpdateWindowTitle()
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 间自适应,保留两位小数)。
// 除内存占用外不再附带标签页、文件数量等无关信息。
// ⚠️ 内存口径 = **私有工作集**(与任务管理器"内存"列一致),不是总工作集。
+10
View File
@@ -4032,6 +4032,16 @@ namespace jdi
*/
void PaintLaneReorder(gd::Graphics& g);
/**
* @brief 网格拖拽重排的**叠加提示**:目标槽位高亮 + 被拖网格轮廓。
*
* 与 `PaintLaneReorder` 分离是因为「显示布局边界」调试模式(`m_forceFullRepaint`)
* 下无法走贴图路径(会残留上一帧的调试框),必须改为整幅重绘;此时网格本体由
* 整幅重绘按位移逐格绘制,而这两项提示仍需同一套代码补画。
* @param [IN,OUT] g 绘图上下文。
*/
void PaintLaneReorderOverlay(gd::Graphics& g);
/**
* @brief 结束网格拖拽重排:若目标位置与原位置不同则真正重排并同步编号。
*/
+2 -2
View File
@@ -618,9 +618,9 @@ namespace jdi
* @brief 未启用双光标模式时面板给出的提示。
*
* 该功能依赖「双光标」定义的截取区间,因此单光标/无光标时无法工作。
* ⚠️ 文案由用户 2026-11-03 指定,不要改写。
*/
inline const wchar_t* const kClipNeedDoubleCursorHint =
L"请先启用「双光标」模式(工具栏 → 光标),再回到此面板截取选区。";
inline const wchar_t* const kClipNeedDoubleCursorHint = L"启用双光标模式以进行选区截取";
// ---- 「公式映射」的分组包络缓存(见 `PlotView::AcquireFormulaEnvelope`)----
/**
@@ -1158,6 +1158,13 @@ namespace jdi
checked = cursorLane.FindMeanFilterItem(
layout.rowRefs[rowIndex]) != nullptr;
}
// ⚠️⚠️ **「选区截取」的条目栏不可展开**(只有右侧复选框有效):
// 因此条目栏本体**绝不能**给手型 —— 否则会误导用户去点它
//(用户 2026-11-03 明确要求)。
if (cursorKind == EdgePanelKind::CLIP)
{
return nullptr;
}
return checked ? LoadCursorW(nullptr, IDC_HAND) : nullptr;
}
if (cursorKind == EdgePanelKind::CLIP)
+69 -14
View File
@@ -1352,9 +1352,41 @@ namespace jdi
m_decorRects.clear();
m_useDecorCache = true;
for (std::size_t index = 0; index < m_lanes.size(); ++index)
// ⚠️ **网格次序拖拽在「显示布局边界」调试模式(`m_forceFullRepaint`)下必须在这里补上位移**。
// 上面的 `PaintLaneReorder` 快路径用静态场景贴图平移,而调试叠加层要求每帧重画边界框,
// 贴回来的旧像素会盖掉调试框,所以那条路径被 `m_forceFullRepaint` 排除;
// 但这里的整幅重绘若不套用 `LaneDragOffset`,被拖网格就**不跟随鼠标**、吸附槽位也
// 无从体现 —— 用户 2026-11-03 报告的"开启布局边界后只能盲拖"正是此因。
// 这里对每个网格临时套用位移重画,被拖网格最后画(压在最上层),再补画提示。
const bool laneReorderLive = (m_dragMode == PlotDragMode::LANE_REORDER) &&
m_laneDragFrom >= 0 &&
m_laneDragFrom < static_cast<int>(m_lanes.size());
if (laneReorderLive)
{
PaintLane(g, static_cast<int>(index), m_lanes[index], timeTicks);
for (std::size_t index = 0; index < m_lanes.size(); ++index)
{
const int laneIndex = static_cast<int>(index);
if (laneIndex == m_laneDragFrom)
{
continue;
}
// 副本平移:`PaintLane` 只把 `rect` 当作几何来源(信号集合按 `laneIndex` 取),
// 因此整条网格(底色/网格线/波形/装饰)都会跟着一起移动。
LaneVisual shifted = m_lanes[index];
shifted.rect.Y += LaneDragOffset(laneIndex);
PaintLane(g, laneIndex, shifted, timeTicks);
}
const std::size_t from = static_cast<std::size_t>(m_laneDragFrom);
LaneVisual shifted = m_lanes[from];
shifted.rect.Y += LaneDragOffset(m_laneDragFrom);
PaintLane(g, m_laneDragFrom, shifted, timeTicks);
}
else
{
for (std::size_t index = 0; index < m_lanes.size(); ++index)
{
PaintLane(g, static_cast<int>(index), m_lanes[index], timeTicks);
}
}
PaintTimeAxis(g, timeTicks, timeStep);
@@ -1373,6 +1405,13 @@ namespace jdi
}
BlitDecorCache(g);
// 网格次序拖拽的吸附槽位 + 被拖网格轮廓(同一套代码,见 `PaintLaneReorderOverlay`):
// 必须画在装饰层**之后**,否则会被 GX 徽标/图例盖住。
if (laneReorderLive)
{
PaintLaneReorderOverlay(g);
}
// 此刻画布上正好是"绘图区底色 + 全部网格 + 时间轴",尚未绘制任何随鼠标变化的内容,
// 因此是捕获静态场景缓存的最佳时点(必须在裁剪区已全部恢复之后执行)。
// ⚠️ 快速帧(交互期间,关闭了抗锯齿)**仍然捕获**(这样拖动参数滑块时能走
@@ -1617,13 +1656,25 @@ namespace jdi
}
g.ReleaseHDC(target);
// 3) 目标位置的高亮提示与被拖网格的轮廓都**必须夹在网格区域内**绘制:
// 鼠标拖到工具栏/时间轴上时,若不裁剪,强调色边框会画到那些区域上。
{
const int clipState = g.Save();
g.SetClip(m_plotRect, gd::CombineModeIntersect);
// 3) 目标位置的高亮提示与被拖网格的轮廓。
PaintLaneReorderOverlay(g);
}
// 目标位置的高亮提示(吸附提示):在被拖网格将要落到的槽位上画一层强调色底 + 边框。
void PlotView::PaintLaneReorderOverlay(gd::Graphics& g)
{
if (m_dragMode != PlotDragMode::LANE_REORDER || m_laneDragFrom < 0 || m_lanes.empty())
{
return;
}
// 目标位置的高亮提示与被拖网格的轮廓都**必须夹在网格区域内**绘制:
// 鼠标拖到工具栏/时间轴上时,若不裁剪,强调色边框会画到那些区域上。
const int clipState = g.Save();
g.SetClip(m_plotRect, gd::CombineModeIntersect);
// 目标位置的高亮提示(吸附提示):在被拖网格将要落到的槽位上画一层强调色底 + 边框。
const float laneHeight = m_lanes.front().rect.Height;
if (laneHeight > 0.0f)
{
const int dropSlot = (m_laneDragTarget >= 0) ? m_laneDragTarget : m_laneDragFrom;
float slotTop = m_lanes.front().rect.Y + static_cast<float>(dropSlot) * laneHeight;
if (dropSlot > m_laneDragFrom)
@@ -1638,8 +1689,11 @@ namespace jdi
slot.Width - 2.0f * m_scale, slot.Height - 2.0f * m_scale),
3.0f * m_scale, m_theme->accent, 1.6f * m_scale);
}
}
// 被拖网格的轮廓:加强"整块网格被拎起"的观感。
// 被拖网格的轮廓:加强"整块网格被拎起"的观感。
if (m_laneDragFrom < static_cast<int>(m_lanes.size()))
{
const gd::RectF& dragged = m_lanes[static_cast<std::size_t>(m_laneDragFrom)].rect;
const float destTop = dragged.Y + LaneDragOffset(m_laneDragFrom);
const gd::RectF outline(dragged.X + 1.0f * m_scale, destTop + 1.0f * m_scale,
@@ -1648,8 +1702,8 @@ namespace jdi
outline.Width, outline.Height),
3.0f * m_scale, 0x22000000u);
UiDrawRoundRect(g, outline, 3.0f * m_scale, m_theme->accent, 1.8f * m_scale);
g.Restore(clipState);
}
g.Restore(clipState);
}
@@ -6154,10 +6208,11 @@ namespace jdi
else
{
// 提示文字不参与滚动,画在内容区正中。
const std::wstring hintText(kClipNeedDoubleCursorHint);
UiTextClipped(g, UiEllipsize(hintText, m_fonts->uiSmall, layout.content.Width),
m_fonts->uiSmall, layout.content, m_theme->textMuted,
TextAlign::CENTER, layout.content);
// ⚠️ **绝不省略**(用户 2026-11-03 明确要求"要能够完整显示这条提示内容"):
// 面板宽度已按提示文字实测撑开(见 `EdgePanelWidth`),这里直接整段输出。
UiTextClipped(g, std::wstring(kClipNeedDoubleCursorHint), m_fonts->uiSmall,
layout.content, m_theme->textMuted, TextAlign::CENTER,
layout.content);
}
}
if (hasButton && !clipPanel)
+15 -4
View File
@@ -230,6 +230,14 @@ namespace jdi
(UiMeasureTextWidth(m_fonts->uiSmall, kClipBuildLabel) + 28.0f * m_scale) + gap +
(UiMeasureTextWidth(m_fonts->uiSmall, kClipDestructiveLabel) + 28.0f * m_scale);
width = std::max(width, kEdgePanelPad * 2.0f * m_scale + twoButtons);
// ⚠️ **未启用双光标**时面板只显示一行提示:宽度必须容得下它的**完整**文本
//(用户 2026-11-03 明确要求"能够完整显示这条提示内容",不能被省略成 `…`)。
if (Tab().CursorMode() != PlotCursorMode::DOUBLE)
{
const float hintWidth = UiMeasureTextWidth(m_fonts->uiSmall, kClipNeedDoubleCursorHint);
width = std::max(width, kEdgePanelPad * 2.0f * m_scale + hintWidth +
(kFreeMarkerScrollWidth + 4.0f) * m_scale);
}
}
if (countTable)
@@ -831,10 +839,13 @@ namespace jdi
const float footerHeight =
clipPanel ? (clipReadyHere ? (buttonRowHeight * 2.0f + 6.0f * m_scale + pad) : 0.0f)
: (hasButton ? (buttonRowHeight + pad) : 0.0f);
// ⚠️ 内容区高度必须与高度算式(`height = title + pad + view + bottomPad + footer`)
// 严格同源:只减 **一个** pad(标题下方那次),不要再多减一个,否则内容区偏矮、
// 滚动条提前出现且底部留出空白(用户 2026-11-03 报告)。
const float viewHeight = std::max(0.0f, height - titleHeight - pad - footerHeight);
// ⚠️⚠️ 内容区高度必须与高度算式
// `height = titleHeight + pad + view + bottomPad + footerHeight` **严格同源**:
// 即 `viewHeight = height - titleHeight - pad - bottomPad - footerHeight`。
// 少减 `bottomPad` 会让内容区比实际可用高度多出一个 pad ⇒
// 条目区下方与按钮之间留出一片空白(用户 2026-11-03 两次报告)。
const float viewHeight =
std::max(0.0f, height - titleHeight - pad - bottomPad - footerHeight);
const bool scrollable = meanContentHeight > viewHeight + 0.5f;
const float maxScroll = std::max(0.0f, meanContentHeight - viewHeight);
const float storedScroll = meanFilter ? lane.meanFilterScroll
+25 -1
View File
@@ -80,6 +80,13 @@ namespace jdi
* 勾选状态通过 `UpdateItemLabel` 回写。默认 false(点击即关):与既有行为一致。
*/
bool keepOpen = false;
/**
* @brief 二级子菜单可滚动条目区的**最大显示行数**(0 = 不限制)。
*
* 与 `MenuLayoutConstraint::maxScrollRows` 同义,只是作用于该子菜单自身:
* 分组/网格数量可能上百,不限高时子菜单会长到屏幕之外。
*/
int submenuMaxRows = 0;
};
/**
@@ -245,7 +252,12 @@ namespace jdi
* @brief 菜单是否因内容过长而启用了滚动(条目区可滚动、前导标题与尾部条目固定)。
* @return 启用滚动返回 true。
*/
bool IsScrollable() const { return m_open && m_scrollEnabled; }
bool IsScrollable() const
{
// 二级子菜单展开且自身可滚动时同样算「可滚动」:宿主据此把滚轮转给菜单,
// 而不是"滚轮即关闭菜单"(否则「移动到…」这类长短不定的子菜单无法翻页)。
return m_open && (m_scrollEnabled || (m_subOpen && m_subScrollEnabled));
}
/**
* @brief 滚动条**轨道**矩形(仅可滚动菜单有效,其余返回空矩形)。
@@ -568,6 +580,18 @@ namespace jdi
std::vector<gd::RectF> m_subItemRects; /**< 子菜单各项矩形。 */
int m_subHoverIndex = -1; /**< 子菜单当前悬停项。 */
int m_subPressedIndex = -1; /**< 子菜单按下时的项下标。 */
/**
* @brief 子菜单的可滚动状态(条目过多时启用)。
*
* 与主菜单的滚动机制**刻意保持独立**:子菜单在主菜单展开期间一直存在,
* 若共用一套偏移,主菜单滚动会连带把子菜单的条目位置打乱。
*/
bool m_subScrollEnabled = false; /**< 子菜单条目区是否可滚动。 */
float m_subScrollOffset = 0.0f; /**< 子菜单滚动偏移(像素,0 = 顶部)。 */
float m_subScrollRegionHeight = 0.0f; /**< 子菜单内容总高(像素)。 */
gd::RectF m_subViewport; /**< 子菜单条目可视区(同时是条目裁剪矩形)。 */
gd::RectF m_subScrollBarRect; /**< 子菜单滚动条轨道矩形。 */
float m_subMaxRowsHeight = 0.0f; /**< 子菜单可视区高度上限(0 = 不限制)。 */
};
} // namespace jdi
+77 -4
View File
@@ -415,7 +415,21 @@ namespace jdi
bool PopupMenu::OnMouseWheel(int delta)
{
if (!m_open || !m_scrollEnabled || delta == 0)
if (!m_open || delta == 0)
{
return false;
}
// 子菜单展开且可滚动时优先滚子菜单:鼠标此时一定悬停在子菜单条目上
//(父项悬停即展开,滚轮自然是"翻这个列表"的意图)。
if (m_subOpen && m_subScrollEnabled)
{
const float previous = m_subScrollOffset;
m_subScrollOffset -= static_cast<float>(delta) / 120.0f * 54.0f * m_scale;
// 重新排布(内部会夹取 `m_subScrollOffset` 并刷新条目矩形)。
BuildSubLayout(m_subParentIndex);
return std::fabs(m_subScrollOffset - previous) > 0.01f;
}
if (!m_scrollEnabled)
{
return false;
}
@@ -447,10 +461,20 @@ namespace jdi
m_subItemRects.clear();
m_subHoverIndex = -1;
m_subPressedIndex = -1;
m_subScrollEnabled = false;
m_subScrollOffset = 0.0f;
m_subScrollRegionHeight = 0.0f;
m_subViewport = gd::RectF();
m_subScrollBarRect = gd::RectF();
m_subMaxRowsHeight = 0.0f;
}
void PopupMenu::BuildSubLayout(int parentIndex)
{
// `CloseSubmenu()` 会清零全部子菜单状态,但滚动(`OnMouseWheel`)需要重新排布时
// **保留滚动位置**:只有切换到另一个父项才从顶部开始。
const float savedScrollOffset = m_subScrollOffset;
const int savedParentIndex = m_subParentIndex;
CloseSubmenu();
if (parentIndex < 0 || parentIndex >= static_cast<int>(m_items.size()))
{
@@ -463,6 +487,13 @@ namespace jdi
}
m_subItems = parent.submenu;
// 同一父项重新排布(滚动)时沿用原来的滚动位置。
m_subScrollOffset = (savedParentIndex == parentIndex) ? savedScrollOffset : 0.0f;
m_subScrollEnabled = false;
m_subScrollRegionHeight = 0.0f;
m_subViewport = gd::RectF();
m_subScrollBarRect = gd::RectF();
m_subMaxRowsHeight = 0.0f;
// 与主菜单一致:宽度取条目中的最大者并夹在上下限之间。
float contentWidth = kMinWidth * m_scale;
@@ -502,8 +533,28 @@ namespace jdi
}
contentWidth = std::min(contentWidth, kMaxWidth * m_scale);
// ------------------------------------------------------------------
// 子菜单条目过多时可滚动(`MenuItem::submenuMaxRows`)。
// 与主菜单的"头部固定 / 尾部固定"不同,这里**整段一起滚**:
// 子菜单的条目形态单一(分组列表 + 末段分隔线 + 新建分组),整体滚动即可,
// 且不会出现"两个固定区各占一半高度、中间只剩一两行"的糟糕观感。
// ------------------------------------------------------------------
const float subContentHeight = totalHeight - kPadV * 2.0f * m_scale;
if (parent.submenuMaxRows > 0)
{
const float rowsLimit =
static_cast<float>(parent.submenuMaxRows) * kRowHeight * m_scale;
m_subMaxRowsHeight = rowsLimit;
if (rowsLimit >= kRowHeight * m_scale && subContentHeight > rowsLimit + 0.5f)
{
m_subScrollEnabled = true;
m_subScrollRegionHeight = subContentHeight;
}
}
const float width = contentWidth;
const float height = totalHeight;
const float height =
m_subScrollEnabled ? (m_subMaxRowsHeight + kPadV * 2.0f * m_scale) : totalHeight;
const gd::RectF& parentRect = m_itemRects[static_cast<std::size_t>(parentIndex)];
const gd::RectF& bounds = m_buildBounds;
@@ -521,12 +572,28 @@ namespace jdi
m_subRect = gd::RectF(left, top, width, height);
m_subItemRects.assign(m_subItems.size(), gd::RectF());
float cursorY = top + kPadV * m_scale;
// 条目可视区:整段滚动模式下就是内容区;让出右侧滚动条的位置。
const float subScrollReserve =
m_subScrollEnabled ? (kScrollBarWidth + kScrollBarGap) * m_scale : 0.0f;
m_subViewport = gd::RectF(left + kPadH * m_scale, top + kPadV * m_scale,
std::max(0.0f, width - kPadH * 2.0f * m_scale - subScrollReserve),
m_subScrollEnabled ? m_subMaxRowsHeight : std::max(0.0f, height - kPadV * 2.0f * m_scale));
if (m_subScrollEnabled)
{
// 偏移夹到合法范围(内容高 - 可视高)。
m_subScrollOffset = std::max(
0.0f, std::min(m_subScrollOffset, std::max(0.0f, subContentHeight - m_subMaxRowsHeight)));
m_subScrollBarRect =
gd::RectF(left + width - kPadH * m_scale - kScrollBarWidth * m_scale,
top + kPadV * m_scale, kScrollBarWidth * m_scale, m_subMaxRowsHeight);
}
float cursorY = top + kPadV * m_scale - m_subScrollOffset;
for (std::size_t index = 0; index < m_subItems.size(); ++index)
{
const float itemHeight = ItemHeight(m_subItems[index]);
m_subItemRects[index] = gd::RectF(left + kPadH * m_scale, cursorY,
std::max(0.0f, width - kPadH * 2.0f * m_scale), itemHeight);
std::max(0.0f, m_subViewport.Width), itemHeight);
cursorY += itemHeight;
}
@@ -544,6 +611,12 @@ namespace jdi
}
const float fx = static_cast<float>(x);
const float fy = static_cast<float>(y);
// 滚动中的子菜单:可视区之外的条目矩形仍然存在(只是被裁掉),必须先按视口过滤,
// 否则滚出视野的条目还能被点中(表现为"点到看不见的东西")。
if (m_subScrollEnabled && !UiPointInRect(m_subViewport, fx, fy))
{
return -1;
}
for (std::size_t index = 0; index < m_subItems.size(); ++index)
{
const MenuItem& item = m_subItems[index];
+38
View File
@@ -305,6 +305,16 @@ namespace jdi
UiFillRoundRect(g, m_subRect, kCorner * m_scale, m_theme->cardBg);
UiDrawRoundRect(g, m_subRect, kCorner * m_scale, m_theme->borderStrong, 1.0f);
// 滚动中的子菜单:条目必须裁剪在可视区内(否则会画到面板圆角之外)。
int subClipState = -1;
if (m_subScrollEnabled)
{
subClipState = g.Save();
g.SetClip(gd::RectF(m_subRect.X + 1.0f * m_scale, m_subRect.Y + 1.0f * m_scale,
m_subRect.Width - 2.0f * m_scale, m_subRect.Height - 2.0f * m_scale),
gd::CombineModeIntersect);
}
for (std::size_t index = 0; index < m_subItems.size(); ++index)
{
const MenuItem& item = m_subItems[index];
@@ -395,6 +405,34 @@ namespace jdi
gd::RectF(textLeft, rect.Y, std::max(8.0f, textWidth), rect.Height), textColor,
TextAlign::LEFT);
}
if (m_subScrollEnabled)
{
if (subClipState >= 0)
{
g.Restore(subClipState);
}
// 滚动条:轨道 + 滑块(与主菜单同一套配色与算法,粗细取 `kScrollBarWidth`)。
if (m_subScrollBarRect.Width > 0.0f && m_subScrollRegionHeight > 0.0f &&
m_subViewport.Height > 0.0f)
{
const float radius = kScrollBarWidth * m_scale * 0.5f;
UiFillRoundRect(g, m_subScrollBarRect, radius, m_theme->gridWeak);
const float viewportHeight = m_subViewport.Height;
const float ratio = std::min(1.0f, viewportHeight / m_subScrollRegionHeight);
const float thumbHeight =
std::max(kScrollBarMinThumb * m_scale, m_subScrollBarRect.Height * ratio);
const float maxScroll = std::max(0.0f, m_subScrollRegionHeight - viewportHeight);
const float progress = (maxScroll > 0.0f) ? (m_subScrollOffset / maxScroll) : 0.0f;
const float thumbTop =
m_subScrollBarRect.Y +
std::max(0.0f, std::min(1.0f, progress)) *
std::max(0.0f, m_subScrollBarRect.Height - thumbHeight);
UiFillRoundRect(
g, gd::RectF(m_subScrollBarRect.X, thumbTop, m_subScrollBarRect.Width, thumbHeight),
radius, m_theme->borderStrong);
}
}
}
} // namespace jdi
@@ -35,6 +35,14 @@ namespace jdi
CMD_GROUP_REMOVE = 12, /**< 从工作区移除该分组。 */
CMD_LANE_BASE = 300, /**< 网格归属基准(配合下标)。 */
CMD_LANE_NEW = 400, /**< 新建网格。 */
/**
* @brief 「移动到…」二级菜单里的分组条目基准(配合分组下标)。
*
* 与 `CMD_LANE_BASE` 同一套约定:`id - CMD_GROUP_MOVE_BASE` 即目标分组下标。
* 取值区间必须与 `CMD_LANE_BASE` 的区间(300–399)和 `CMD_LANE_NEW` 互不重叠。
*/
CMD_GROUP_MOVE_BASE = 600,
CMD_GROUP_MOVE_NEW = 700, /**< 「移动到…」二级菜单底部:新建分组并把信号移进去。 */
CMD_GAIN_EDIT = 500, /**< 「系数」二级面板里的系数输入框(幂等提交)。 */
CMD_GAIN_RESET = 501, /**< 「系数调整」标题栏右侧的还原按钮:把系数重置为 1。 */
CMD_OFFSET_RESET = 502, /**< 「偏移调整」标题栏右侧的还原按钮:两个偏移都恢复为默认值。 */
@@ -108,6 +116,15 @@ namespace jdi
m_pendingClosePart = RowPart::NONE;
m_pendingFilterClear = false;
m_groupNameClickRow = -1;
// 拖拽排序同样保存着行下标与逐行位移,行集合一变就必须整体作废。
m_signalDragPending = false;
m_signalDragging = false;
m_signalDragRow = -1;
m_signalDragDropGroup = -1;
m_signalDragDropIndex = -1;
m_signalDragDropRow = -1;
m_signalDragShift.clear();
m_signalDragShiftCur.clear();
}
/**
@@ -473,6 +490,18 @@ namespace jdi
/** @brief 分组折起/展开动画是否正在进行。 */
bool GroupAnimationActive() const { return !m_groupAnims.empty(); }
/**
* @brief 推进一步「信号拖拽避让」动画(由宿主定时器驱动)。
* @return 仍有行在移动 / 正在拖拽(宿主应保持定时重绘)返回 true。
*/
bool AdvanceSignalDragAnimation();
/** @brief 信号拖拽(含避让动画)是否仍在进行。 */
bool SignalDragAnimationActive() const
{
return m_signalDragPending || m_signalDragging;
}
/**
* @brief 供绘图区联动高亮的「悬停信号」。
*
@@ -489,6 +518,49 @@ namespace jdi
int TargetLaneIndex() const;
private:
/**
* @brief 开始跟踪一次可能变成拖拽的按下(信号行主体)。
* @param [IN] ref 目标信号。
* @param [IN] row 该信号所在的行下标。
* @param [IN] x 按下点横坐标。 @param [IN] y 按下点纵坐标。
*/
void BeginSignalDrag(const SignalRef& ref, int row, int x, int y);
/**
* @brief 拖拽中:更新被拖条目的跟随位置与避让目标。
* @param [IN] x 鼠标横坐标。 @param [IN] y 鼠标纵坐标。
* @return 需要重绘返回 true。
*/
bool UpdateSignalDrag(int x, int y);
/**
* @brief 重新计算拖拽的落点(目标分组 / 组内下标)与各行的避让位移目标。
*/
void UpdateSignalDragTargets();
/**
* @brief 取消拖拽(不发任何命令)。
*/
void CancelSignalDrag();
/**
* @brief 结束拖拽并**提交**重排(落点与源位置不同才提交)。
*/
void FinishSignalDrag();
/**
* @brief 取某行的避让位移(拖拽中);未拖拽时返回 0。
* @param [IN] row 行下标。
* @return 纵向位移(像素,负值 = 上移)。
*/
float SignalDragRowShiftFor(int row) const;
/**
* @brief 绘制被拖拽条目的「浮起」副本(在行循环之后绘制,因此始终在最上层)。
* @param [IN,OUT] g 绘图上下文。
*/
void PaintSignalDragGhost(gd::Graphics& g);
/**
* @brief 样式区与列表行中可进行内联文本编辑的字段。
*/
@@ -1446,6 +1518,27 @@ namespace jdi
*/
std::vector<float> m_rowShift;
// ------------------------------------------------------------------
// 信号条目拖拽排序(用户 2026-11-03 要求):
// * 左键按住信号行主体 → 超过阈值后进入拖拽;
// * 被拖条目**脱离原位置**跟随鼠标绘制(半透明浮起 + 阴影);
// * 其它信号行 / 分组行按插入位置做**避让位移动画**(`m_signalDragShiftCur` 逐帧插值);
// * 松手时提交到宿主(`kMessageReorderSignal`),由数据层重排并修正全部引用。
// ------------------------------------------------------------------
bool m_signalDragPending = false; /**< 已按下信号行主体、尚未越过拖拽阈值。 */
bool m_signalDragging = false; /**< 正在拖拽信号条目。 */
SignalRef m_signalDragRef; /**< 被拖拽的信号。 */
int m_signalDragRow = -1; /**< 被拖拽行在 `m_rows` 中的下标。 */
int m_signalDragStartX = 0; /**< 按下点(阈值判定用)。 */
int m_signalDragStartY = 0;
int m_signalDragMouseY = 0; /**< 最近一次鼠标纵坐标(被拖条目跟随它)。 */
int m_signalDragDropGroup = -1; /**< 当前目标分组下标(-1 = 无效)。 */
int m_signalDragDropIndex = -1; /**< 目标分组内的插入下标。 */
int m_signalDragDropRow = -1; /**< 插入位置对应的行下标(插入指示线画在这里)。 */
bool m_signalDragDropBefore = false; /**< 插入点在该行的上方(否则下方)。 */
std::vector<float> m_signalDragShift; /**< 各行避让位移的**目标值**(像素)。 */
std::vector<float> m_signalDragShiftCur; /**< 各行避让位移的**当前值**(逐帧插值)。 */
/**
* @brief 筛选模式下的分组折起状态(按分组下标)。
*
@@ -756,6 +756,13 @@ namespace jdi
{
top -= m_rowShift[rowIndex];
}
// 信号条目拖拽排序的**避让位移**(用户 2026-11-03 要求):
// 本函数是行内全部派生的唯一几何来源,因此只需在这里叠加一次,
// 名称框 / 复选框 / 徽标 / 关闭 ✕ 等会自动跟随,形成"其它条目让位"的动画。
if (rowIndex < m_signalDragShiftCur.size())
{
top += m_signalDragShiftCur[rowIndex];
}
return gd::RectF(m_listRect.X + renderOffsetX, top + renderOffsetY, m_listRect.Width,
m_rows[rowIndex].height);
}
@@ -37,6 +37,12 @@ namespace jdi
HCURSOR SignalPanel::QueryCursor(int x, int y)
{
// 拖拽排序进行中:用"移动"光标提示当前处于拖动状态(此时不应再切成手型)。
if (m_signalDragging)
{
return LoadCursorW(nullptr, IDC_SIZEALL);
}
// 清空按钮 ✕ 是独立可点击控件,用手型光标给出除高亮之外的额外交互提示。
const gd::RectF clearRect = FilterClearRect();
if (clearRect.Width > 0.0f &&
@@ -99,6 +99,26 @@ namespace jdi
{
return ApplyWidthFromX(static_cast<float>(x));
}
// 信号条目拖拽:按下后越过阈值才真正进入拖拽态(之前只是"可能是点击")。
if (m_signalDragPending || m_signalDragging)
{
const int dx = x - m_signalDragStartX;
const int dy = y - m_signalDragStartY;
if (!m_signalDragging && (dx * dx + dy * dy) >= 16)
{
m_signalDragging = true;
m_signalDragShift.assign(m_rows.size(), 0.0f);
m_signalDragShiftCur.assign(m_rows.size(), 0.0f);
m_hoverRow = -1;
m_hoverPart = RowPart::NONE;
}
if (m_signalDragging)
{
UpdateSignalDrag(x, y);
return true;
}
return false; // 仍在阈值内:按普通移动继续处理(悬停高亮等)
}
if (m_draggingScrollBar)
{
const gd::RectF thumb = ScrollThumbRect();
@@ -574,6 +594,9 @@ namespace jdi
// 点击信号名(行主体):除了选中它,还要让绘图区的**目标网格**完整出现在可视区内
//(上百个独立网格时,旧有高亮根本无法定位)。悬停不触发这一步。
RequestPlotLaneScroll(ref);
// 同时登记"可能开始的拖拽排序":越过后阈值才真正进入拖拽态,
// 因此单击仍然是单击(用户 2026-11-03 要求按住可拖动排序)。
BeginSignalDrag(ref, row, x, y);
}
return true;
}
@@ -582,6 +605,15 @@ namespace jdi
{
bool changed = false;
// ---- 信号条目拖拽 ----
// ⚠️ 必须放在最前面:拖拽结束时列表条目位置可能变化,不能再走下方的 ✕ / 菜单判定。
if (m_signalDragging)
{
FinishSignalDrag();
return true;
}
m_signalDragPending = false;
// ---- 关闭类按钮(✕):按下时记录目标,抬起时若指针仍在同一个 ✕ 上才执行 ----
if (m_pendingFilterClear)
{
@@ -722,6 +754,10 @@ namespace jdi
return false;
}
// 双击进入改名:必须先把"可能开始的拖拽"作废,否则第二次按下会被当成新的拖拽起点,
// 改名后轻微移动就会拖着信号跑(用户 2026-11-03 要求拖拽排序,改名不能被它干扰)。
CancelSignalDrag();
const float fx = static_cast<float>(x);
const float fy = static_cast<float>(y);
@@ -958,9 +994,324 @@ namespace jdi
m_hoverStyleIndex = -1;
// 鼠标离开面板 → 取消绘图区的联动高亮(用户要求"移开鼠标时恢复")。
m_hoveredSignal = SignalRef();
// ⚠️ 拖拽中**不取消**:鼠标被拖到列表外(甚至面板外)时条目应继续跟随,
// 松手才提交(用户 2026-11-03 要求"拖拽时条目跟随鼠标")。
return changed;
}
// ======================================================================
// 信号条目拖拽排序(用户 2026-11-03 要求)
//
// 交互:左键按住信号行主体 → 越过 4px 阈值才进入拖拽(避免与单击选中/联动滚动抢事件);
// 拖拽中被拖条目**脱离原位置**跟随鼠标,其它信号行/分组行按插入位置做避让位移
//(目标值由 `UpdateSignalDragTargets` 给出,实际位移逐帧插值到 `m_signalDragShiftCur`);
// 松手时把(源分组、源下标、目标分组、目标下标)提交给宿主。
// ======================================================================
void SignalPanel::BeginSignalDrag(const SignalRef& ref, int row, int x, int y)
{
if (m_dataSet == nullptr || m_tab == nullptr || !ref.IsValid() || row < 0 ||
row >= static_cast<int>(m_rows.size()))
{
return;
}
m_signalDragPending = true;
m_signalDragging = false;
m_signalDragRef = ref;
m_signalDragRow = row;
m_signalDragStartX = x;
m_signalDragStartY = y;
m_signalDragMouseY = y;
m_signalDragDropGroup = -1;
m_signalDragDropIndex = -1;
m_signalDragDropRow = -1;
m_signalDragDropBefore = false;
m_signalDragShift.clear();
m_signalDragShiftCur.clear();
}
bool SignalPanel::UpdateSignalDrag(int x, int y)
{
(void)x;
if (!m_signalDragging)
{
return false;
}
m_signalDragMouseY = y;
// 拖到列表上下边缘时自动滚动:跨组移动往往需要越过很长的列表
//(上百个信号的 MF4 里拖到另一个分组)。
const float edge = kPadding * m_scale;
if (static_cast<float>(y) < m_listRect.Y + edge)
{
m_scrollOffset -= 6.0f * m_scale;
ClampScroll();
}
else if (static_cast<float>(y) > m_listRect.Y + m_listRect.Height - edge)
{
m_scrollOffset += 6.0f * m_scale;
ClampScroll();
}
UpdateSignalDragTargets();
return true;
}
void SignalPanel::UpdateSignalDragTargets()
{
const std::size_t rowCount = m_rows.size();
m_signalDragDropGroup = -1;
m_signalDragDropIndex = -1;
m_signalDragDropRow = -1;
m_signalDragDropBefore = false;
m_signalDragShift.assign(rowCount, 0.0f);
if (m_signalDragShiftCur.size() != rowCount)
{
m_signalDragShiftCur.resize(rowCount, 0.0f);
}
if (m_dataSet == nullptr || !m_signalDragging || m_signalDragRow < 0 ||
m_signalDragRow >= static_cast<int>(rowCount))
{
return;
}
const float mouseY = static_cast<float>(m_signalDragMouseY);
// 鼠标所在的"自然行":`RowRect` 已叠加避让位移,这里减回去才代表用户看到的静止布局。
int hoverRow = -1;
bool below = false;
for (std::size_t index = 0; index < rowCount; ++index)
{
if (static_cast<int>(index) == m_signalDragRow)
{
continue;
}
const gd::RectF rect = RowRect(static_cast<int>(index));
const float top = rect.Y - m_signalDragShiftCur[index];
const float height = m_rows[index].height;
if (mouseY >= top && mouseY < top + height)
{
hoverRow = static_cast<int>(index);
below = (mouseY >= top + height * 0.5f);
break;
}
}
int dstRow = -1;
int dropGroup = -1;
int dropIndex = -1;
if (hoverRow < 0)
{
if (mouseY < m_listRect.Y && !m_rows.empty())
{
// 列表上方:落到第一个分组的最前面。
dstRow = 0;
dropGroup = m_rows.front().groupIndex;
dropIndex = 0;
}
else if (!m_rows.empty())
{
// 列表下方:落到最后一个含信号的分组的末尾。
for (int row = static_cast<int>(rowCount) - 1; row >= 0; --row)
{
const ListRow& item = m_rows[static_cast<std::size_t>(row)];
if (item.kind != RowKind::SIGNAL)
{
continue;
}
const SignalGroup* group = m_dataSet->GroupAt(item.groupIndex);
dstRow = static_cast<int>(rowCount);
dropGroup = item.groupIndex;
dropIndex = (group != nullptr) ? static_cast<int>(group->signals.size()) : 0;
break;
}
}
}
else
{
const ListRow& item = m_rows[static_cast<std::size_t>(hoverRow)];
if (item.kind == RowKind::GROUP)
{
// 组名行:插入到该组第一个信号之前(组名本身不让位)。
dstRow = hoverRow + 1;
dropGroup = item.groupIndex;
dropIndex = 0;
}
else
{
dstRow = below ? (hoverRow + 1) : hoverRow;
dropGroup = item.groupIndex;
dropIndex = item.signalIndex + (below ? 1 : 0);
}
}
if (dstRow < 0 || dropGroup < 0 || dropIndex < 0)
{
return;
}
// 同组时补回"源信号被摘出"造成的下标偏移(数据层先删除再插入)。
if (dropGroup == m_signalDragRef.groupIndex && dropIndex > m_signalDragRef.signalIndex)
{
--dropIndex;
}
m_signalDragDropGroup = dropGroup;
m_signalDragDropIndex = dropIndex;
m_signalDragDropRow = dstRow;
// 避让:被跨过的行整体让出 / 补上一个行高。
const int srcRow = m_signalDragRow;
const float laneHeight = m_rows[static_cast<std::size_t>(srcRow)].height;
if (dstRow > srcRow)
{
for (int row = srcRow + 1; row < dstRow && row < static_cast<int>(rowCount); ++row)
{
m_signalDragShift[static_cast<std::size_t>(row)] = -laneHeight;
}
}
else if (dstRow < srcRow)
{
for (int row = dstRow; row < srcRow; ++row)
{
m_signalDragShift[static_cast<std::size_t>(row)] = laneHeight;
}
}
}
void SignalPanel::CancelSignalDrag()
{
m_signalDragPending = false;
m_signalDragging = false;
m_signalDragRow = -1;
m_signalDragDropGroup = -1;
m_signalDragDropIndex = -1;
m_signalDragDropRow = -1;
m_signalDragDropBefore = false;
m_signalDragShift.clear();
m_signalDragShiftCur.clear();
}
void SignalPanel::FinishSignalDrag()
{
const SignalRef ref = m_signalDragRef;
const int dropGroup = m_signalDragDropGroup;
const int dropIndex = m_signalDragDropIndex;
const bool moved = m_signalDragging && dropGroup >= 0 && dropIndex >= 0 &&
(dropGroup != ref.groupIndex || dropIndex != ref.signalIndex);
CancelSignalDrag();
if (!moved || m_host == nullptr || !ref.IsValid())
{
return;
}
// wParam = (源分组 << 16) | 源信号下标;lParam = (目标下标 << 16) | 目标分组。
// 目标下标用 0xFFFF 表示"末尾"(与本消息的约定一致,见 Application 的消息处理)。
const LONG_PTR from = (static_cast<LONG_PTR>(ref.groupIndex) << 16) |
(static_cast<LONG_PTR>(ref.signalIndex) & 0xFFFF);
const LONG_PTR to = (static_cast<LONG_PTR>(dropIndex) << 16) |
(static_cast<LONG_PTR>(dropGroup) & 0xFFFF);
PostMessageW(m_host, WM_APP + 8, static_cast<WPARAM>(from), static_cast<LPARAM>(to));
}
float SignalPanel::SignalDragRowShiftFor(int row) const
{
if (row < 0 || static_cast<std::size_t>(row) >= m_signalDragShiftCur.size())
{
return 0.0f;
}
return m_signalDragShiftCur[static_cast<std::size_t>(row)];
}
bool SignalPanel::AdvanceSignalDragAnimation()
{
if (!m_signalDragPending && !m_signalDragging)
{
return false;
}
if (m_signalDragShiftCur.size() != m_signalDragShift.size())
{
m_signalDragShiftCur.resize(m_signalDragShift.size(), 0.0f);
}
// 逐帧向目标位移插值(宿主约 10ms 一帧 ⇒ 约 5 帧收敛,观感是"平滑让位")。
const float factor = 0.32f;
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)
{
current = target;
}
else
{
current += delta * factor;
}
}
return true;
}
void SignalPanel::PaintSignalDragGhost(gd::Graphics& g)
{
if (!m_signalDragging || m_signalDragRow < 0 ||
m_signalDragRow >= static_cast<int>(m_rows.size()) || m_theme == nullptr)
{
return;
}
const gd::RectF natural = RowRect(m_signalDragRow);
const float height = m_rows[static_cast<std::size_t>(m_signalDragRow)].height;
if (height <= 0.5f)
{
return;
}
// 被拖条目跟随鼠标(鼠标位于条目中部)。
float top = static_cast<float>(m_signalDragMouseY) - height * 0.5f;
top = std::max(m_listRect.Y, std::min(top, m_listRect.Y + m_listRect.Height - height));
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);
// 插入位置指示线:明确告诉用户"松手后落在哪里"。
if (m_signalDragDropRow >= 0)
{
float lineY = 0.0f;
if (m_signalDragDropRow < static_cast<int>(m_rows.size()))
{
lineY = RowRect(m_signalDragDropRow).Y;
}
else if (!m_rows.empty())
{
const gd::RectF last = RowRect(static_cast<int>(m_rows.size()) - 1);
lineY = last.Y + last.Height;
}
if (lineY >= m_listRect.Y - 1.0f && lineY <= m_listRect.Y + m_listRect.Height + 1.0f)
{
UiDrawLine(g, m_listRect.X + 4.0f * m_scale, lineY,
m_listRect.X + m_listRect.Width - 4.0f * m_scale, lineY,
m_theme->accent, 2.0f * m_scale);
}
}
g.Restore(state);
}
} // namespace jdi
+60 -1
View File
@@ -232,6 +232,39 @@ namespace jdi
cloneAdjusted.id = CMD_SIGNAL_CLONE_ADJUSTED;
items.push_back(cloneAdjusted);
// 「移动到…」二级菜单(用户 2026-11-03 要求,位置在「仅显示该信号」**之上**):
// 条目为「分组 N(组名)」+ 当前所在分组打勾;末尾分割线之下是「新建分组」。
// 结构与信号名右侧 GX 徽标点击后的「所属网格」菜单保持一致,但**没有标题行**,
// 且底部动作换成「新建分组」(新建分组后把信号移进去)。
MenuItem moveMenu;
moveMenu.kind = MenuItemKind::SUBMENU;
moveMenu.label = L"移动到...";
moveMenu.id = CMD_GROUP_MOVE_BASE; // 父项本身不可点击,id 仅用于区分
for (std::size_t index = 0; index < m_dataSet->GroupCount(); ++index)
{
const SignalGroup* group = m_dataSet->GroupAt(static_cast<int>(index));
if (group == nullptr)
{
continue;
}
MenuItem entry;
entry.label = L"分组 " + std::to_wstring(index + 1) + L"(" + group->name + L")";
entry.id = CMD_GROUP_MOVE_BASE + static_cast<int>(index);
entry.checked = (static_cast<int>(index) == ref.groupIndex);
moveMenu.submenu.push_back(entry);
}
MenuItem moveSeparator;
moveSeparator.kind = MenuItemKind::SEPARATOR;
moveMenu.submenu.push_back(moveSeparator);
MenuItem moveNewGroup;
moveNewGroup.label = L"新建分组";
moveNewGroup.id = CMD_GROUP_MOVE_NEW;
moveMenu.submenu.push_back(moveNewGroup);
// 分组很多(例如一次加载上百个通道组)时二级菜单同样要能滚动:
// 与「所属网格」菜单同一上限(用户 2026-10-16 要求保留 10 行)。
moveMenu.submenuMaxRows = kLaneMenuVisibleRows;
items.push_back(moveMenu);
MenuItem solo;
solo.label = L"仅显示该信号";
solo.id = CMD_SIGNAL_SOLO;
@@ -589,8 +622,34 @@ namespace jdi
needRelayout = true;
}
break;
case CMD_GROUP_MOVE_NEW:
// 「移动到… → 新建分组」:由宿主新建分组并把信号移过去(需要访问全部标签页)。
if (m_menuTargetSignal.IsValid() && m_host != nullptr)
{
PostMessageW(m_host, WM_APP + 7,
static_cast<WPARAM>(m_menuTargetSignal.groupIndex),
static_cast<LPARAM>(((static_cast<LONG_PTR>(m_menuTargetSignal.signalIndex) << 16) |
0xFFFF)));
}
break;
default:
if (result.id >= CMD_LANE_BASE && result.id < CMD_LANE_NEW)
if (result.id >= CMD_GROUP_MOVE_BASE && result.id < CMD_GROUP_MOVE_NEW)
{
// 「移动到… → 分组 N」:同样交给宿主(跨分组移动会重建时间轴归属)。
if (m_menuTargetSignal.IsValid() && m_host != nullptr)
{
const int targetGroup = result.id - CMD_GROUP_MOVE_BASE;
if (targetGroup != m_menuTargetSignal.groupIndex)
{
PostMessageW(m_host, WM_APP + 7,
static_cast<WPARAM>(m_menuTargetSignal.groupIndex),
static_cast<LPARAM>(
(static_cast<LONG_PTR>(m_menuTargetSignal.signalIndex) << 16) |
(static_cast<LONG_PTR>(targetGroup) & 0xFFFF)));
}
}
}
else if (result.id >= CMD_LANE_BASE && result.id < CMD_LANE_NEW)
{
if (m_menuTargetSignal.IsValid())
{
@@ -179,13 +179,20 @@ namespace jdi
const int rowCount = static_cast<int>(m_rows.size());
// 先定出要「悬浮」的分组行(若有):常规循环跳过它,最后统一钉在列表顶部绘制,
// 否则它会先被画一次在滚动位置上、又被画一次在顶部(视觉上重叠两份)。
const int stickyRow = StickyGroupRowIndex(rowCount);
// ⚠️ 拖拽排序期间**不钉**:被拖条目会随鼠标上下移动,钉住组名会让"让位"
// 的观感错乱(用户 2026-11-03 要求拖拽时其它条目做避让动画)。
const int stickyRow = m_signalDragging ? -1 : StickyGroupRowIndex(rowCount);
for (int row = 0; row < rowCount; ++row)
{
if (row == stickyRow)
{
continue;
}
// 被拖拽的条目不再画在原位置:它由 `PaintSignalDragGhost` 画在鼠标处。
if (m_signalDragging && row == m_signalDragRow)
{
continue;
}
const gd::RectF rect = RowRect(row);
if (rect.Y + rect.Height < m_listRect.Y || rect.Y > m_listRect.Y + m_listRect.Height)
{
@@ -220,6 +227,8 @@ namespace jdi
}
PaintStickyGroupRow(g, stickyRow);
// 被拖拽的信号条目:跟随鼠标、脱离列表本体绘制(并在落点画插入指示线)。
PaintSignalDragGhost(g);
}
g.Restore(saveState);
+97
View File
@@ -10015,3 +10015,100 @@ MEAN_SCROLL / 滚轮 / 悬停 / `SetLanePanelCollapsed` / `LanePanelCollapsed` /
* 三处 `switch/tag` 均已逐个 grep 核对(`EdgePanelKind::RESAMPLE` 反查法)。
* 删 `.o` 全量重编译:**零 error、零 warning**,产物 861,184 B(< 1MB)。
* 用户要求不占用鼠标键盘屏幕 ⇒ 本轮**未跑任何 GUI 探针**,仅做编译验证与源码级核对。
---
## 2026-11-03 · 第 29 轮(提示文本 / 显示一致性 / GX 盲拖 / 移动到分组 / 拖拽排序)
### 1. ⭐⭐ 采样统计显示不一致(`1ms` vs `1000us`、`100us 0ns`)
**根因**:`FormatDurationCompound` 直接对 `double` 做 `floor` + `fraction * 1000`。
同一个物理间隔在两个独立计算里可能是 `0.0010000000000000002` 与 `0.0009999999999999998`:
* 前者落到 ms 单位 → `1ms`;后者落到 us 单位,`999.999…us` 进位成 `1000us`;
* 次级单位的浮点噪声还会留下 `100us 0ns` 这种纯噪声尾数。
**现行规则(不得回退)**:
* **先把时长量化成整数皮秒(`llround(value * 1e12)`),再用整数分解**,
绝不对 `double` 做"接近 1000 就进位"的启发式判断。噪声在量化时被彻底消除,
同一数值必然得到同一字符串。
* 分解用 `decompose()` lambda:`whole = totalPs / unit.ps`,
次级值以「千分之一个次级单位」为整数量纲(3 位小数一次舍入到位);
次级舍入到 1000 ⇒ 进位到主单位;主单位到 1000 且无尾数 ⇒ 升级单位(`1000us` → `1ms`)。
* 秒级分支(`value >= 1.0`)与亚秒分支用**同一套** `totalPs` 方案(前者用 `restPs / 1e6` 得毫秒千分位)。
* `FormatFrequency`:单位选择必须按**舍入后的显示值**判断(`999.9999999 Hz` 与 `1000.0 Hz`
必须都是 `1.000 kHz`),并做向下校正避免 `0.001 kHz`。
### 2. 选区截取面板:提示文本、宽度、底部空白
* 提示文本固定为 **`启用双光标模式以进行选区截取`**(用户指定原文,不得加长/改写)。
* `EdgePanelWidth` 的 CLIP 分支必须把**这句提示的实际文本宽度**计入面板宽度,
否则窄面板下提示会被省略成 `…`(用户要求"完整显示、不出现缺省")。
提示本身用 `UiTextClipped`(不是 `UiEllipsize`):宁可贴边也不截断。
* ⚠️ 面板高度算式是 `height = title + pad + view + bottomPad + footer`,
因此 `viewHeight = height - title - pad - bottomPad - footer`。
**漏减 `bottomPad` 就会在条目区与按钮之间留出一条空白**(用户 2026-11-03 报告的空白就是它)。
### 3. 条目栏(未展开)悬停不该改光标
`QueryCursor` 里信号条目栏的命中分支必须 `return nullptr`:
它是**不可展开**的行,手型光标会误导用户以为可以点开(用户 2026-11-03 报告)。
### 4. ⭐⭐ 开启「显示布局边界」后 GX 标签只能盲拖
**根因**:网格重排的视觉全靠 `PaintLaneReorder`(把静态场景缓存按位移**贴图**平移),
而它在 `m_forceFullRepaint`(调试叠加层)下被**刻意跳过**(贴回的旧像素会盖掉调试框)。
跳过之后走整幅重绘,而整幅重绘**没有套用 `LaneDragOffset`** ⇒ 被拖网格不跟随鼠标、
吸附槽位与轮廓都不出现 ⇒ 只能盲拖。
**修法**:
* 把「吸附槽位高亮 + 被拖网格轮廓」抽成 `PaintLaneReorderOverlay(g)`,两条路径共用。
* 整幅重绘的网格循环里,若正在 `LANE_REORDER`:
用 `LaneVisual shifted = m_lanes[i]; shifted.rect.Y += LaneDragOffset(i);` 逐格平移重画,
被拖网格最后画(压在最上层),装饰层之后再补 `PaintLaneReorderOverlay`。
⚠️ **不要保留"先按原位置画一遍"的循环**,否则让位行会在原位留下残影。
* `PaintLane` 只把 `const LaneVisual&` 的 `rect` 当几何来源(信号集合按 `laneIndex` 取),
所以传平移副本即可让整条网格(底色/网格线/波形/装饰)一起移动。
### 5. ⭐⭐ 信号右键「移动到…」二级菜单(新增)
* 位置:**「仅显示该信号」之上**;条目为 `分组 N(组名)`,当前所在组打勾;
**没有**「所属网格」标题行;末尾分隔线之下是「新建分组」。
* `MenuItem::submenuMaxRows` 控制子菜单可滚动行数(本轮新增,默认 0 = 不限制)。
`PopupMenu` 为此新增**独立的**子菜单滚动状态(`m_subScrollEnabled/m_subScrollOffset/
m_subViewport/m_subScrollBarRect/m_subMaxRowsHeight`),不要与主菜单的 `m_scrollOffset` 混用。
`BuildSubLayout` 开头会 `CloseSubmenu()` 清状态 ⇒ 必须在调用前保存 `m_subScrollOffset`
并在**同一父项**重新排布时恢复,否则滚轮永远滚不动。
`SubItemAtPoint` 必须先按 `m_subViewport` 过滤(否则滚出视野的条目还能点中)。
`IsScrollable()` 要把「子菜单可滚」也算进去,否则宿主收到滚轮会直接关菜单。
* 数据层:`DataSet::MoveSignalToGroup` / `MoveSignal`。**时间轴必须随信号一起走**:
独占 → move 到目标组并修正源组其余信号的 `axisIndex`;共享 → 复制一条。
* 引用修正:`TabConfig::RemapAfterSignalMove(from, to, visible)` —— 与「先删后插」不同,
它把**恰好等于源引用**的条目**改写**为目标引用(信号留在原网格、曲线不中断)。
⚠️ 顺序不可调换:**先匹配改写,再做 ±1 位移**。先位移会让源组 `from+1` 的引用
位移成 `from` 而被误判成被移动的信号(曾经的 BUG)。
* `m_visible`:目标组在 `to.signalIndex` 处插入一列(取源可见性),源组删除一列。
若是「新建分组」,必须先对各标签页 `SyncToDataSet` 补出新列,否则插不进去 ⇒ 信号变未勾选、曲线消失。
* 宿主侧统一走 `Application::MoveSignalCore`,两处入口(菜单 / 拖拽)共用,
保证暂停派生计算、引用修正、选中项迁移、`SyncTabsToDataSet` 的流程一致。
### 6. ⭐⭐ 信号列表拖拽排序(新增)
* 状态全部在 `SignalPanel`:`m_signalDragPending(<4px 阈值)/ m_signalDragging /
m_signalDragRow / m_signalDragDropGroup / m_signalDragDropIndex /
m_signalDragShift(目标位移)/ m_signalDragShiftCur(插值位移)`。
* **避让位移只写一处**:`RowRect()` 末尾叠加 `m_signalDragShiftCur[row]`。
该函数是行内全部派生的唯一几何来源 ⇒ 名称框/复选框/GX 徽标/✕ 自动跟随,不必逐个改。
* 拖拽中的条目**不画在原位置**(主循环 `continue`),改由 `PaintSignalDragGhost` 画在鼠标处:
复用 `m_rowYOverrideActive + m_rowYOverrideY`(与「组名悬浮」同一机制)临时把行画到鼠标 y,
再加阴影 + 强调色描边 + 落点插入指示线。
* 拖拽期间**禁用组名悬浮**(`stickyRow = -1`),否则钉住的组名会让让位观感错乱。
* 动画由宿主 `kScrollTimerId` 驱动:`Render` 的重绘判定与定时器判定都要带上
`m_panel.SignalDragAnimationActive()`,并每帧调 `AdvanceSignalDragAnimation()`(0.32 系数插值)。
* 提交:`PostMessage(hwnd, WM_APP + 8 /* kMessageReorderSignal */)`,
`wParam = (源分组 << 16) | 源信号`,`lParam = (目标下标 << 16) | 目标分组`(下标 0xFFFF = 末尾)。
* 数据层 `DataSet::MoveSignal`:同组 → 只改顺序(时间轴完全不动);
跨组 → 按「移动到分组」的规则搬运时间轴。`TabConfig::RemapAfterSignalReorder` 做线性下标映射
(`from < to` 时 `(from, to]` 各前移一位)。
* 交互注意:`BeginSignalDrag` 只在**行主体**(else 分支)登记;`OnLButtonUp` 最前面先判拖拽;
`OnDoubleClick` 一开始就 `CancelSignalDrag()`(否则改名后轻微移动会把信号拖走);
`InvalidateRows()` 必须清空全部拖拽状态(行下标与逐行位移都会失效)。
+76
View File
@@ -706,6 +706,47 @@ namespace jdi
*/
bool ForkAxisForSignal(int groupIndex, int signalIndex, std::size_t& axisIndex);
/**
* @brief 把一条信号移动到**另一个分组**(追加到目标分组末尾)。
*
* 用于信号右键菜单的「移动到…」(用户 2026-11-03 要求):把信号从原分组摘出来、
* 插入另一个分组(含「新建分组」)。
*
* ⚠️ **时间轴必须随信号一起走**:`SignalData::axisIndex` 指向**所属分组**的
* `axes`,跨组移动后原下标毫无意义。这里的处理是:
* * 该信号的轴**只被它自己使用** → 把轴 move 到目标分组(零拷贝,并从源分组删掉,
* 源分组内其余信号的 `axisIndex` 若大于被删下标则整体前移一位);
* * 与源分组内其它信号**共享** → 复制一条到目标分组(不能抽走共享轴)。
*
* ⚠️ 调用方必须紧接着对每个标签页调用
* `TabConfig::RemapAfterSignalMove(from, to, visible)` 修正引用,
* 并在之后 `SyncToDataSet`。源分组即便被移空也**不自动删除**(删除会让分组下标整体左移,
* 牵动所有引用);需要的话由调用方另行处理。
*
* @param [IN] fromGroup 源分组下标。
* @param [IN] fromSignal 源分组内的信号下标。
* @param [IN] toGroup 目标分组下标(必须已存在,且与源分组不同)。
* @param [OUT] newSignalIndex 移动后该信号在目标分组内的下标(= 移动前目标组信号数)。
* @return 移动成功返回 true。
*/
bool MoveSignalToGroup(int fromGroup, int fromSignal, int toGroup, int& newSignalIndex);
/**
* @brief 把一条信号移动到**任意分组内的任意位置**(组内重排 / 跨组移动的统一入口)。
*
* 组内重排(`fromGroup == toGroup`)只调整顺序,时间轴完全不动;
* 跨组移动按 `MoveSignalToGroup` 的规则搬运时间轴。
*
* @param [IN] fromGroup 源分组下标。
* @param [IN] fromSignal 源分组内的信号下标。
* @param [IN] toGroup 目标分组下标。
* @param [IN] toSignalIndex 移动后该信号在目标分组内的下标(负数或越界表示追加到末尾)。
* @param [OUT] newSignalIndex 移动后的实际下标。
* @return 移动成功返回 true。
*/
bool MoveSignal(int fromGroup, int fromSignal, int toGroup, int toSignalIndex,
int& newSignalIndex);
/**
* @brief 移除指定下标的分组,并立即释放其占用的全部采样内存。
* @param [IN] groupIndex 分组下标。
@@ -1725,6 +1766,41 @@ namespace jdi
*/
void RemapAfterSignalInsert(int groupIndex, int insertPos);
/**
* @brief 数据层把一条信号**跨分组移动**之后,修正本页的全部引用。
*
* 与 `RemapAfterSignalRemoval` + `RemapAfterSignalInsert` 组合不同:移动**不应**
* 把信号从网格里删掉(用户期望曲线原地继续显示,只是归属分组变了),
* 因此这里把「恰好等于源引用」的条目**改写**为目标引用,而不是先删后插。
*
* 处理顺序(不可调换):
* 1. 精确等于 `from` 的引用 → 改写为 `to`(其下标已是移动后的最终值);
* 2. 其余引用按「目标分组在 `to.signalIndex` 处插入」/「源分组删除 `from.signalIndex`」
* 做 ±1 位移。
* ⚠️ 若先位移再匹配,源分组里 `from.signalIndex + 1` 的引用会位移成 `from.signalIndex`
* 而被误判成被移动的信号(曾经的 BUG)。
*
* 可见性表:目标分组在 `to.signalIndex` 处插入一列(取 `visible`),源分组删除一列。
*
* @param [IN] from 源引用(移动前)。
* @param [IN] to 目标引用(移动后)。
* @param [IN] visible 该信号在源分组里的可见性(迁移到目标分组后沿用)。
*/
void RemapAfterSignalMove(const SignalRef& from, const SignalRef& to, bool visible);
/**
* @brief 数据层**组内重排**一条信号之后,修正本页在该分组内的全部引用下标。
*
* 只做线性下标映射(可见性表也按同一规则移动),不改变信号的分组归属:
* `from` → `to`;`from < to` 时 `(from, to]` 区间内的下标各前移一位,
* `from > to` 时 `[to, from)` 区间内的下标各后移一位。
*
* @param [IN] groupIndex 发生重排的分组下标。
* @param [IN] from 移动前的下标。
* @param [IN] to 移动后的下标。
*/
void RemapAfterSignalReorder(int groupIndex, int from, int to);
/**
* @brief 把某信号所在的网格移动到另一信号所在网格的下方(紧邻其后)。
*
+106
View File
@@ -653,6 +653,112 @@ namespace jdi
return true;
}
bool DataSet::MoveSignal(int fromGroup, int fromSignal, int toGroup, int toSignalIndex,
int& newSignalIndex)
{
newSignalIndex = -1;
if (fromGroup < 0 || toGroup < 0)
{
return false;
}
if (fromGroup >= static_cast<int>(m_groups.size()) ||
toGroup >= static_cast<int>(m_groups.size()))
{
return false;
}
SignalGroup& source = m_groups[static_cast<std::size_t>(fromGroup)];
if (fromSignal < 0 || fromSignal >= static_cast<int>(source.signals.size()))
{
return false;
}
// ---- 组内重排:只改顺序,时间轴与 axisIndex 完全不动 ----
if (fromGroup == toGroup)
{
std::vector<SignalData>& signals = source.signals;
SignalData moved = std::move(signals[static_cast<std::size_t>(fromSignal)]);
signals.erase(signals.begin() + fromSignal);
int position = toSignalIndex;
if (position < 0 || position > static_cast<int>(signals.size()))
{
position = static_cast<int>(signals.size());
}
signals.insert(signals.begin() + position, std::move(moved));
newSignalIndex = position;
return true;
}
// ---- 跨组移动 ----
SignalGroup& target = m_groups[static_cast<std::size_t>(toGroup)];
// 1) 时间轴随信号一起走(详见 `MoveSignalToGroup` 的说明)。
const std::size_t axisIndex = source.signals[static_cast<std::size_t>(fromSignal)].axisIndex;
const std::size_t safeAxis = (axisIndex < source.axes.size()) ? axisIndex : 0;
bool sharedWithOthers = false;
for (std::size_t index = 0; index < source.signals.size(); ++index)
{
if (static_cast<int>(index) != fromSignal && source.signals[index].axisIndex == axisIndex)
{
sharedWithOthers = true;
break;
}
}
std::size_t newAxisIndex = 0;
if (source.axes.empty())
{
target.axes.push_back(TimeAxis());
newAxisIndex = target.axes.size() - 1;
}
else if (sharedWithOthers)
{
target.axes.push_back(source.axes[safeAxis]);
newAxisIndex = target.axes.size() - 1;
}
else
{
target.axes.push_back(std::move(source.axes[safeAxis]));
newAxisIndex = target.axes.size() - 1;
source.axes.erase(source.axes.begin() + safeAxis);
for (SignalData& other : source.signals)
{
if (other.axisIndex > safeAxis)
{
--other.axisIndex;
}
}
}
// 2) 取出信号并插入目标位置的指定下标。
SignalData moved = std::move(source.signals[static_cast<std::size_t>(fromSignal)]);
moved.axisIndex = newAxisIndex;
source.signals.erase(source.signals.begin() + fromSignal);
int position = toSignalIndex;
if (position < 0 || position > static_cast<int>(target.signals.size()))
{
position = static_cast<int>(target.signals.size());
}
target.signals.insert(target.signals.begin() + position, std::move(moved));
newSignalIndex = position;
return true;
}
bool DataSet::MoveSignalToGroup(int fromGroup, int fromSignal, int toGroup, int& newSignalIndex)
{
if (fromGroup == toGroup)
{
newSignalIndex = -1;
return false;
}
const SignalGroup* target = GroupAt(toGroup);
if (target == nullptr)
{
newSignalIndex = -1;
return false;
}
return MoveSignal(fromGroup, fromSignal, toGroup, static_cast<int>(target->signals.size()),
newSignalIndex);
}
bool DataSet::RemoveGroup(int groupIndex)
{
if (groupIndex < 0 || groupIndex >= static_cast<int>(m_groups.size()))
+134
View File
@@ -84,6 +84,140 @@ namespace jdi
m_lanes.end());
}
void TabConfig::RemapAfterSignalMove(const SignalRef& from, const SignalRef& to, bool visible)
{
if (from.groupIndex < 0 || to.groupIndex < 0 || from.groupIndex == to.groupIndex)
{
return;
}
// 1) 可见性表:目标分组在目标位置插入一列(沿用源分组里的可见性),源分组删除一列。
const std::size_t toSlot = static_cast<std::size_t>(to.groupIndex);
if (toSlot < m_visible.size() &&
static_cast<std::size_t>(to.signalIndex) <= m_visible[toSlot].size())
{
m_visible[toSlot].insert(m_visible[toSlot].begin() + to.signalIndex, visible);
}
const std::size_t fromSlot = static_cast<std::size_t>(from.groupIndex);
if (fromSlot < m_visible.size() &&
static_cast<std::size_t>(from.signalIndex) < m_visible[fromSlot].size())
{
m_visible[fromSlot].erase(m_visible[fromSlot].begin() + from.signalIndex);
}
// 2) 全部信号引用:先改写被移动的那一条,再做 ±1 位移(顺序不可调换,见头文件说明)。
const auto adjust = [&from, &to](SignalRef& ref)
{
if (ref.groupIndex == from.groupIndex && ref.signalIndex == from.signalIndex)
{
ref = to;
return;
}
if (ref.groupIndex == to.groupIndex && ref.signalIndex >= to.signalIndex)
{
++ref.signalIndex;
}
else if (ref.groupIndex == from.groupIndex && ref.signalIndex > from.signalIndex)
{
--ref.signalIndex;
}
};
// 条目集合都是「带 `ref` 成员的结构体」,用泛型 lambda 统一处理(与
// `RemapAfterSignalRemoval` 的 `purgeRefs` 同一手法)。
const auto adjustItems = [&adjust](auto& items)
{
for (auto& item : items)
{
adjust(item.ref);
}
};
for (Lane& lane : m_lanes)
{
for (SignalRef& ref : lane.signals)
{
adjust(ref);
}
adjustItems(lane.meanFilterItems);
adjustItems(lane.edgeCenterItems);
adjustItems(lane.weakPeakItems);
adjustItems(lane.resampleItems);
adjustItems(lane.clipItems);
adjustItems(lane.freeMarkerItems);
}
}
void TabConfig::RemapAfterSignalReorder(int groupIndex, int from, int to)
{
if (groupIndex < 0 || from < 0 || to < 0 || from == to)
{
return;
}
// 线性重排映射:被移动的那条改为 `to`,被它跨过的条目各让一位。
const auto mapIndex = [from, to](int index)
{
if (index == from)
{
return to;
}
if (from < to)
{
if (index > from && index <= to)
{
return index - 1;
}
}
else
{
if (index >= to && index < from)
{
return index + 1;
}
}
return index;
};
// 可见性表整体重排(与信号顺序严格一致)。
const std::size_t slot = static_cast<std::size_t>(groupIndex);
if (slot < m_visible.size() && static_cast<std::size_t>(from) < m_visible[slot].size() &&
static_cast<std::size_t>(to) < m_visible[slot].size())
{
std::vector<bool>& row = m_visible[slot];
const bool moved = row[static_cast<std::size_t>(from)];
row.erase(row.begin() + from);
row.insert(row.begin() + to, moved);
}
const auto adjust = [groupIndex, &mapIndex](SignalRef& ref)
{
if (ref.groupIndex == groupIndex)
{
ref.signalIndex = mapIndex(ref.signalIndex);
}
};
const auto adjustItems = [&adjust](auto& items)
{
for (auto& item : items)
{
adjust(item.ref);
}
};
for (Lane& lane : m_lanes)
{
for (SignalRef& ref : lane.signals)
{
adjust(ref);
}
adjustItems(lane.meanFilterItems);
adjustItems(lane.edgeCenterItems);
adjustItems(lane.weakPeakItems);
adjustItems(lane.resampleItems);
adjustItems(lane.clipItems);
adjustItems(lane.freeMarkerItems);
}
}
void TabConfig::RemapAfterSignalRemoval(int groupIndex, int signalIndex)
{
if (groupIndex < 0 || signalIndex < 0)
+123 -43
View File
@@ -540,65 +540,131 @@ namespace jdi
}
// ---- 顶级单位为 s:整数秒 + 可选 ms ----
// ⚠️⚠️ 与下面的亚秒分支同一原则:**先量化到整数皮秒,再分解**。
// 直接对 double 做 `floor` + `fraction * 1000` 会被浮点噪声坑到:
// `0.9999999999s` 的余数四舍五入后是 `1000.000ms`(应进位成 `1s`),
// 而 `5.0599999999999996s` 与 `5.06s` 会得到不同的尾数(用户 2026-11-03 报告)。
if (value >= 1.0)
{
const double whole = std::floor(value);
const double fraction = value - whole;
std::wstring text = FormatDecimal(whole) + L"s";
const double remainderMs = fraction * 1000.0;
if (remainderMs >= 1.0e-6)
const long long totalPs = static_cast<long long>(std::llround(value * 1.0e12));
long long wholeSeconds = totalPs / 1000000000000LL;
const long long restPs = totalPs % 1000000000000LL;
// 次级(ms)以「千分之一 ms」为整数单位,保证舍入一次到位。
long long milliMs = (restPs + 500000LL) / 1000000LL;
if (milliMs >= 1000LL * 1000LL)
{
++wholeSeconds;
milliMs = 0;
}
std::wstring text = FormatDecimal(static_cast<double>(wholeSeconds)) + L"s";
if (milliMs > 0)
{
text += L" ";
// ⚠️ 次级单位(ms)**最多保留三位小数**(用户 2026-11-03 要求:`3ms 696.631us`
// 这类显示要能看到真实尾数);整数则不加小数点(`5.06s` → `5s 60ms`)。
text += FormatSubUnit(remainderMs, kDurationSubUnitDecimals);
text += L" ";
text += FormatSubUnit(static_cast<double>(milliMs) / 1000.0, kDurationSubUnitDecimals);
text += L"ms";
}
return text;
}
// ---- 顶级单位低于 s:ms / us / ns / ps ----
const DurationUnit subUnits[] = {
{1.0e-3, L"ms"}, {1.0e-6, L"us"}, {1.0e-9, L"ns"}, {1.0e-12, L"ps"},
// ⚠️⚠️ **必须以整数(皮秒)为唯一事实来源做分解**,不能对 double 反复 floor/除法。
// 同一个物理间隔(例如 `0.001s`)在两次独立计算里可能分别是
// `0.0010000000000000002` 与 `0.0009999999999999998`;任何"接近 1000 就进位"
// 的浮点启发式都会在边界漏判,于是同一数值出现 `1ms` 与 `1000us` 两种显示,
// 还会留下 `100us 0ns` 这种纯噪声尾数(用户 2026-11-03 报告)。
// 量化到 ps 之后噪声被彻底消除:同一数值必然得到同一字符串。
const long long totalPs = static_cast<long long>(std::llround(value * 1.0e12));
if (totalPs <= 0)
{
// 小于 0.5ps:无法量化,保留高精度小数直接显示。
return FormatDecimal(value / 1.0e-12) + L" ps";
}
struct SubUnit
{
long long ps;
const wchar_t* name;
};
const std::size_t subCount = sizeof(subUnits) / sizeof(subUnits[0]);
std::size_t index = subCount - 1;
for (std::size_t i = 0; i < subCount; ++i)
const SubUnit subUnits[] = {
{1000000000LL, L"ms"}, {1000000LL, L"us"}, {1000LL, L"ns"}, {1LL, L"ps"},
};
const int subCount = 4;
// 量化后达到 1s ⇒ 等价于上面的秒级分支(`999.9999999999s` → `1s`)。
if (totalPs >= 1000000000000LL)
{
if (value >= subUnits[i].scale)
long long wholeSeconds = totalPs / 1000000000000LL;
const long long restPs = totalPs % 1000000000000LL;
long long milliMs = (restPs + 500000LL) / 1000000LL;
if (milliMs >= 1000LL * 1000LL)
{
index = i;
break;
++wholeSeconds;
milliMs = 0;
}
std::wstring text = FormatDecimal(static_cast<double>(wholeSeconds)) + L"s";
if (milliMs > 0)
{
text += L" ";
text += FormatSubUnit(static_cast<double>(milliMs) / 1000.0, kDurationSubUnitDecimals);
text += L"ms";
}
return text;
}
// 顶级单位为 ps:不再递进次级单位(保留小数)。
if (index + 1 >= subCount)
// 选主单位:第一个"能被 totalPs 容纳"的单位。
int index = 0;
while (index + 1 < subCount && totalPs < subUnits[index].ps)
{
return FormatDecimal(value / subUnits[index].scale) + L" " + subUnits[index].name;
++index;
}
const double scaled = value / subUnits[index].scale;
double whole = std::floor(scaled);
double fraction = scaled - whole;
// 浮点误差可能导致 fraction 极接近 1(例如 4.9999999),归一化一下。
if (fraction >= 1.0 - 1.0e-9)
// 把 totalPs 分解成 [主单位整数] + [次级单位(千分之一为单位的整数)]。
// 抽成 lambda 是因为升级单位后需要用同一个 totalPs 重算,避免累积误差。
long long whole = 0;
long long milliNext = 0;
const auto decompose = [&]()
{
whole += 1.0;
fraction = 0.0;
whole = totalPs / subUnits[index].ps;
milliNext = 0;
if (index + 1 >= subCount)
{
return;
}
const long long restPs = totalPs % subUnits[index].ps;
const long long nextPs = subUnits[index + 1].ps;
// 次级值保留 3 位小数 ⇒ 用「千分之一个次级单位」作为整数量纲。
milliNext = (restPs * 1000LL + nextPs / 2) / nextPs;
// 次级舍入后达到 1000 个次级单位 ⇒ 进位到主单位。
if (milliNext >= 1000LL * 1000LL)
{
++whole;
milliNext = 0;
}
};
decompose();
// 主单位达到 1000 且没有次级尾数 ⇒ 升级到更高的主单位(`1000us` → `1ms`)。
while (index > 0 && whole >= 1000LL && milliNext == 0)
{
--index;
decompose();
}
std::wstring text = FormatValue(whole);
// 主单位已是 ms 且恰好进位到 1000ms ⇒ 直接显示为秒。
if (index == 0 && milliNext == 0 && whole >= 1000LL)
{
return FormatDecimal(static_cast<double>(whole) / 1000.0) + L"s";
}
std::wstring text = FormatDecimal(static_cast<double>(whole));
text += subUnits[index].name;
// 仍是该单位的整数 → 不显示下级单位。
const double remainder = fraction * subUnits[index].scale;
const double inNext = remainder / subUnits[index + 1].scale;
if (inNext >= 1.0e-6)
if (milliNext > 0)
{
text += L" ";
// ⚠️ 次级单位**最多保留三位小数**(用户 2026-11-03 要求):
// `0.003696631s` → 顶级 `3ms` + 次级 `696.631us`(而不是 `3ms 700us`)。
text += FormatSubUnit(inNext, kDurationSubUnitDecimals);
text += L" ";
text += FormatSubUnit(static_cast<double>(milliNext) / 1000.0, kDurationSubUnitDecimals);
text += subUnits[index + 1].name;
}
return text;
@@ -620,23 +686,37 @@ namespace jdi
{
return L"—";
}
const wchar_t* unit = L"Hz";
double scaled = hertz;
if (hertz >= 1.0e9)
// ⚠️⚠️ **单位选择必须按「舍入后的显示值」判定,否则同一物理量会出现两种显示**:
// `999.9999999 Hz` 走 Hz 分支显示 `1000.000 Hz`,而 `1000.0000001 Hz` 走 kHz 分支
// 显示 `1.000 kHz` —— 用户 2026-11-03 报告的 `1000.00 Hz` vs `1.000 kHz` 就是这个。
// 因此:先取单位,再用**保留 3 位小数后的值**判断是否达到 1000,达到就升级。
const wchar_t* units[] = {L"Hz", L"kHz", L"MHz", L"GHz"};
const double scales[] = {1.0, 1.0e3, 1.0e6, 1.0e9};
const int unitCount = 4;
int unitIndex = 0;
for (int i = unitCount - 1; i >= 0; --i)
{
unit = L"GHz";
scaled = hertz / 1.0e9;
if (hertz >= scales[i])
{
unitIndex = i;
break;
}
}
else if (hertz >= 1.0e6)
// 向上校正:舍入到 3 位小数后若达到 1000 个当前单位,就升到上一级。
const double epsilon = 0.5 * std::pow(10.0, -kFrequencyDecimals);
while (unitIndex + 1 < unitCount &&
(hertz / scales[unitIndex]) + epsilon >= 1000.0)
{
unit = L"MHz";
scaled = hertz / 1.0e6;
++unitIndex;
}
else if (hertz >= 1.0e3)
// 向下校正:若当前单位下小于 1(极低频边界),降一级以免出现 `0.001 kHz` 这类。
while (unitIndex > 0 && (hertz / scales[unitIndex]) < 1.0 - epsilon)
{
unit = L"kHz";
scaled = hertz / 1.0e3;
--unitIndex;
}
const wchar_t* unit = units[unitIndex];
const double scaled = hertz / scales[unitIndex];
// ⚠️ 频率同样不得出现科学计数法(用户 2026-11-03 要求):
// `FormatValue` 的 `%.6g` 在 1e-5 Hz 这类量级会输出 “1e-06”,改为十进制形式。
// ⚠️ 并且**基于真实值保留三位小数**(用户 2026-11-03 要求:`270.517 Hz` 而不是