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

+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