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 间自适应,保留两位小数)。
// 除内存占用外不再附带标签页、文件数量等无关信息。
// ⚠️ 内存口径 = **私有工作集**(与任务管理器"内存"列一致),不是总工作集。