Files
Just-DataInspector/Components/SignalPanel/Src/SignalPanelInteraction.cpp
T
DeepSeek V4.1 Flash 97b361dedd 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 探针,仅编译验证 + 源码级核对。
2026-11-03 19:27:43 +08:00

1318 lines
43 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// SignalPanel 实现分片 —— 交互(命中、鼠标/键盘派发、光标形状)。
//
// 共用的常量与工具函数在 SignalPanelInternal.hpp(命名空间 `spdetail`,全部 inline)。
// 六个分片按**职责**划分(不再按函数名前缀):
// SignalPanel.cpp 核心:状态、行集合构建、布局与几何、分组/信号可见性
// SignalPanelPaint.cpp 绘制:列表行(含行位图缓存)、样式区、筛选框、滚动条
// SignalPanelInteraction.cpp 交互:命中测试、鼠标/键盘派发、光标形状(**本文件**)
// SignalPanelEdit.cpp 内联编辑:筛选框、组名/信号名、数值输入、颜色与透明度
// SignalPanelScroll.cpp 动画与滚动:分组折起、联动滚动、"滚动到目标信号"
// SignalPanelMenu.cpp 弹出菜单:系数/偏移、镜像、右键菜单与命令回执
#include "SignalPanelInternal.hpp"
namespace jdi
{
int SignalPanel::HitRow(int x, int y, RowPart& part)
{
part = RowPart::NONE;
EnsureRows();
if (m_listRect.Height <= 0.0f)
{
return -1;
}
const float fx = static_cast<float>(x);
const float fy = static_cast<float>(y);
if (fy < m_listRect.Y || fy > m_listRect.Y + m_listRect.Height)
{
return -1;
}
if (fx < m_listRect.X || fx > m_listRect.X + m_listRect.Width)
{
return -1;
}
// 「悬浮组名」绘制在最上层,因此命中判定也必须最优先 —— 否则点击会**穿透到
// 它下面的那一行**(用户 2026-10-16 报告:组名悬浮时它上面的折叠三角、复选框、
// 关闭 ✕、名称区域全都点不动,点击被下层信号行吃掉)。
const int rowCount = static_cast<int>(m_rows.size());
const int stickyRow = StickyGroupRowIndex(rowCount);
if (stickyRow >= 0)
{
const float stickyTop = StickyGroupRowTop(stickyRow, rowCount);
const float stickyHeight = m_rows[static_cast<std::size_t>(stickyRow)].height;
if (fy >= stickyTop && fy < stickyTop + stickyHeight)
{
// 用覆盖几何计算行内区域(`RowRect` 是行内所有派生的唯一几何来源)。
m_rowYOverrideActive = true;
m_rowYOverrideY = stickyTop;
part = RowHitPart(stickyRow, fx, fy);
m_rowYOverrideActive = false;
return stickyRow;
}
}
// 逐行比较“实际绘制位置”(含折起/展开动画的刚性平移),不能只看滚动偏移:
// 动画中信号行的位置与“自然内容坐标”不再一一对应,用后者会点错行。
float rowTop = m_listRect.Y - m_scrollOffset;
for (std::size_t index = 0; index < m_rows.size(); ++index)
{
const float shift = (index < m_rowShift.size()) ? m_rowShift[index] : 0.0f;
const float previousShift =
(index > 0 && (index - 1) < m_rowShift.size()) ? m_rowShift[index - 1] : 0.0f;
rowTop -= (shift - previousShift);
const ListRow& row = m_rows[index];
const float rowBottom = rowTop + row.height;
if (row.height > 0.0f && fy >= rowTop && fy < rowBottom)
{
const int rowIndex = static_cast<int>(index);
// 折起/展开动画中已“藏进组名栏”的行不再参与命中(否则它会白白吸走一次点击)。
if (rowBottom > AnimationHideTopForRow(rowIndex, stickyRow) + 0.5f)
{
part = RowHitPart(rowIndex, fx, fy);
return rowIndex;
}
return -1;
}
rowTop = rowBottom;
}
return -1;
}
bool SignalPanel::OnMouseMove(int x, int y)
{
// 拖拽优先级最高。
if (m_draggingOpacity)
{
return ApplyOpacityFromX(static_cast<float>(x));
}
if (m_draggingWidth)
{
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();
// ⚠️ 拖拽期间滑块高度不会变(内容高度与列表高度都没变),因此 `thumb.Height` 可安全
// 用于本次换算;但 `m_scrollBarRect.Y` 必须取**按下时**记录的值(局部刷新/重排可能
// 改掉它)。坐标一律用**客户区** y,因此鼠标移到面板外(如绘图区)也能继续跟随。
const float travel = static_cast<float>(y - m_scrollGrabOffset) - m_scrollBarRect.Y;
const float usable = m_scrollBarRect.Height - thumb.Height;
if (usable > 0.0f)
{
const float progress = std::max(0.0f, std::min(1.0f, travel / usable));
m_scrollOffset = progress * (m_contentHeight - m_listRect.Height);
ClampScroll();
}
return true;
}
RowPart part = RowPart::NONE;
const int hoverRow = HitRow(x, y, part);
StyleHit hoverStyle = StyleHit::NONE;
int hoverStyleIndex = -1;
const float fx = static_cast<float>(x);
const float fy = static_cast<float>(y);
if (UiPointInRect(m_toggleAllRect, fx, fy))
{
hoverStyle = StyleHit::TOGGLE_ALL;
}
else if (UiPointInRect(FilterClearRect(), fx, fy))
{
// 筛选框右侧的清空 ✕(仅在筛选框有内容时存在)。
hoverStyle = StyleHit::FILTER_CLEAR;
}
else if (m_tab != nullptr && StyleOf(SelectedRef()) != nullptr)
{
// 「目标」行的信号名 / GX 标签(优先于其余样式控件:它们几何不重叠,
// 但显式放在最前更易读)。
UpdateTargetRowGeometry();
if (UiPointInRect(m_targetRowRect, fx, fy))
{
if (m_targetBadgeRect.Width > 0.0f && UiPointInRect(m_targetBadgeRect, fx, fy))
{
hoverStyle = StyleHit::TARGET_BADGE;
}
else if (m_targetNameRect.Width > 0.0f && UiPointInRect(m_targetNameRect, fx, fy))
{
hoverStyle = StyleHit::TARGET_NAME;
}
}
for (int index = 0; hoverStyle == StyleHit::NONE && index < m_colorCount; ++index)
{
if (UiPointInRect(m_colorRects[static_cast<std::size_t>(index)], fx, fy))
{
hoverStyle = StyleHit::COLOR;
hoverStyleIndex = index;
break;
}
}
if (hoverStyle == StyleHit::NONE && UiPointInRect(m_customChipRect, fx, fy))
{
hoverStyle = StyleHit::CUSTOM_CHIP;
}
if (hoverStyle == StyleHit::NONE)
{
for (int index = 0; index < kModeOptionCount; ++index)
{
// 置灰的选项(单点信号的「线段」/「点线」)不参与悬停高亮 ——
// 它们点不动,高亮会让用户以为可以点击。
if (ModeOptionEnabled(index) && UiPointInRect(m_modeRects[static_cast<std::size_t>(index)], fx, fy))
{
hoverStyle = StyleHit::MODE;
hoverStyleIndex = index;
break;
}
}
}
if (hoverStyle == StyleHit::NONE &&
UiPointInRect(SliderHitRect(m_opacityTrackRect, m_scale), fx, fy))
{
hoverStyle = StyleHit::OPACITY_SLIDER;
}
if (hoverStyle == StyleHit::NONE &&
UiPointInRect(SliderHitRect(m_widthTrackRect, m_scale), fx, fy))
{
hoverStyle = StyleHit::WIDTH_SLIDER;
}
if (hoverStyle == StyleHit::NONE && UiPointInRect(m_opacityValueRect, fx, fy))
{
hoverStyle = StyleHit::OPACITY_VALUE;
}
if (hoverStyle == StyleHit::NONE && UiPointInRect(m_widthValueRect, fx, fy))
{
hoverStyle = StyleHit::WIDTH_VALUE;
}
// 两行右侧的复位按钮(在数值框右边,命中区不重叠,但顺序放在数值之后更安全)。
if (hoverStyle == StyleHit::NONE && m_widthResetRect.Width > 0.0f &&
UiPointInRect(m_widthResetRect, fx, fy))
{
hoverStyle = StyleHit::WIDTH_RESET;
}
if (hoverStyle == StyleHit::NONE && m_opacityResetRect.Width > 0.0f &&
UiPointInRect(m_opacityResetRect, fx, fy))
{
hoverStyle = StyleHit::OPACITY_RESET;
}
if (hoverStyle == StyleHit::NONE && UiPointInRect(m_adjustRects[kAdjustGain], fx, fy))
{
hoverStyle = StyleHit::GAIN_ADJUST;
}
if (hoverStyle == StyleHit::NONE && UiPointInRect(m_adjustRects[kAdjustMirror], fx, fy))
{
hoverStyle = StyleHit::MIRROR_ADJUST;
}
if (hoverStyle == StyleHit::NONE && UiPointInRect(m_adjustRects[kAdjustAbsolute], fx, fy))
{
hoverStyle = StyleHit::ABSOLUTE_TOGGLE;
}
}
if (hoverRow == m_hoverRow && hoverStyle == m_hoverStyle && hoverStyleIndex == m_hoverStyleIndex &&
part == m_hoverPart)
{
return false;
}
m_hoverRow = hoverRow;
m_hoverPart = part;
m_hoverStyle = hoverStyle;
m_hoverStyleIndex = hoverStyleIndex;
// 供绘图区联动高亮的悬停信号(用户 2026-10-15 要求):
// * 列表里悬停某条信号行 → 高亮它所在的网格 + 该信号的曲线;
// * 样式区「目标」行的信号名 / GX 标签上悬停 → 同样联动高亮。
// ⚠️ ✕(移除该信号)不算"悬停到该信号":它是一条即将被删除的信号,
// 高亮它会让人误以为要保留它。
SignalRef hovered;
if (hoverRow >= 0 && hoverRow < static_cast<int>(m_rows.size()) &&
m_rows[static_cast<std::size_t>(hoverRow)].kind == RowKind::SIGNAL &&
part != RowPart::REMOVE_SIGNAL)
{
hovered = SignalRef{m_rows[static_cast<std::size_t>(hoverRow)].groupIndex,
m_rows[static_cast<std::size_t>(hoverRow)].signalIndex};
}
else if (hoverStyle == StyleHit::TARGET_NAME || hoverStyle == StyleHit::TARGET_BADGE)
{
hovered = SelectedRef();
}
m_hoveredSignal = hovered;
return true;
}
bool SignalPanel::OnLButtonDown(int x, int y)
{
if (m_dataSet == nullptr || m_tab == nullptr)
{
return false;
}
const float fx = static_cast<float>(x);
const float fy = static_cast<float>(y);
// 新的按下开始:上一轮留下的"待确认关闭"一律作废。
m_pendingClosePart = RowPart::NONE;
m_pendingCloseRow = -1;
m_pendingFilterClear = false;
// ⚠️⚠️ **内联改名期间,名称区域整行都属于“文本操作区”**:
// 行高大于输入框高度(上下各留 3 逻辑像素),鼠标起点落在内边距上时旧实现会继续往下走
// 到分组行分支 ⇒ 分组被展开/收起(用户 2026-10-19 报告)。
// 这里直接按“文本按下”处理(定位插入符 / 开始拖拽选区),绝不触发行点击。
if (m_editField != StyleEditField::NONE && m_editRow >= 0 &&
m_editRow < static_cast<int>(m_rows.size()))
{
const gd::RectF field = EditFieldRect();
if (field.Width > 0.0f &&
UiPointInRect(NameEditAbsorbRect(field), fx, fy))
{
OnEditMouseDown(x, y);
return true;
}
}
// 「筛选信号」输入框:单击进入编辑;点击其它位置则先结束筛选编辑(文本已实时生效)。
if (IsOnFilterBox(x, y))
{
// 右侧 ✕:**抬起**时才清空(不退出编辑态,便于继续输入);
// 按下后移开再松开视为放弃。
if (UiPointInRect(FilterClearRect(), fx, fy))
{
m_pendingFilterClear = true;
return true;
}
BeginFilterEdit();
return true;
}
if (m_filterEditing)
{
// 文本已实时生效,这里只需收起编辑态,随后继续处理本次点击。
EndFilterEdit();
}
// 滚动条拖拽优先于其它命中测试。
if (m_scrollBarVisible && UiPointInRect(m_scrollBarRect, fx, fy))
{
CancelScrollAnimation();
const gd::RectF thumb = ScrollThumbRect();
m_draggingScrollBar = true;
if (UiPointInRect(thumb, fx, fy))
{
m_scrollGrabOffset = y - static_cast<int>(thumb.Y);
}
else
{
m_scrollGrabOffset = static_cast<int>(thumb.Height * 0.5f);
const float travel = fy - m_scrollBarRect.Y - static_cast<float>(m_scrollGrabOffset);
const float usable = m_scrollBarRect.Height - thumb.Height;
if (usable > 0.0f)
{
const float progress = std::max(0.0f, std::min(1.0f, travel / usable));
m_scrollOffset = progress * (m_contentHeight - m_listRect.Height);
ClampScroll();
}
}
return true;
}
m_pendingLaneMenu = false;
m_pendingColorPicker = false;
m_pendingGainMenu = false;
m_pendingMirrorMenu = false;
m_pendingTargetLaneMenu = false;
// 点击当前输入框以外的位置时提交输入。
if (m_editField != StyleEditField::NONE &&
!UiPointInRect(EditFieldRect(), fx, fy))
{
EndEdit(true);
}
// 全部显示/隐藏。
if (UiPointInRect(m_toggleAllRect, fx, fy))
{
m_tab->SetAllVisible(!AreAllSignalsVisible());
m_rowsDirty = true;
return true;
}
// 样式编辑区。
if (SignalStyle* style = StyleOf(SelectedRef()))
{
// ---- 顶层「目标」行 ----
// 信号名:单击**不进入编辑**(用户明确要求),只做联动(列表滚动 + 绘图区滚动到该网格);
// GX 标签:单击展开「所属网格」二级菜单(与信号列表行结尾的 GX 徽标**同一份菜单**)。
// ⚠️ GX 标签**不做**联动滚动(用户 2026-10-16 要求):点击即开菜单、保持原地,
// 但悬停仍然联动高亮(见 `OnMouseMove`)。
{
UpdateTargetRowGeometry();
const bool onName =
m_targetNameRect.Width > 0.0f && UiPointInRect(m_targetNameRect, fx, fy);
const bool onBadge =
m_targetBadgeRect.Width > 0.0f && UiPointInRect(m_targetBadgeRect, fx, fy);
if (onBadge)
{
// 与网格徽标一致:按下只登记,**左键抬起**且指针仍在同一个徽标上时才展开菜单。
m_pendingTargetLaneMenu = true;
return true;
}
if (onName)
{
ScrollToSignalAnimated(SelectedRef());
RequestPlotLaneScroll(SelectedRef());
return true;
}
}
int paletteCount = 0;
const std::uint32_t* palette = SignalPalette(paletteCount);
for (int index = 0; index < m_colorCount; ++index)
{
if (UiPointInRect(m_colorRects[static_cast<std::size_t>(index)], fx, fy))
{
style->color = palette[index % paletteCount];
// 联动:样式自定义区的任何交互都把列表滚动到正在编辑的目标信号。
ScrollToSignalAnimated(SelectedRef());
return true;
}
}
if (UiPointInRect(m_customChipRect, fx, fy))
{
// 先恢复到记忆的自定义颜色(即便随后收起浮窗也会生效),再在抬起时展开浮窗。
style->color = style->customColor;
m_pendingColorPicker = true;
ScrollToSignalAnimated(SelectedRef());
return true;
}
for (int index = 0; index < kModeOptionCount; ++index)
{
if (UiPointInRect(m_modeRects[static_cast<std::size_t>(index)], fx, fy))
{
// 单点信号的「线段」/「点线」两项置灰 ⇒ 点了不生效(也不做联动滚动)。
if (!ModeOptionEnabled(index))
{
return true;
}
style->mode = kModeOptions[index];
ScrollToSignalAnimated(SelectedRef());
return true;
}
}
if (UiPointInRect(SliderHitRect(m_opacityTrackRect, m_scale), fx, fy))
{
m_draggingOpacity = true;
ApplyOpacityFromX(fx);
ScrollToSignalAnimated(SelectedRef());
return true;
}
if (UiPointInRect(SliderHitRect(m_widthTrackRect, m_scale), fx, fy))
{
m_draggingWidth = true;
ApplyWidthFromX(fx);
ScrollToSignalAnimated(SelectedRef());
return true;
}
// 数值框的命中优先于滑块:两者几何重叠时要能进入输入状态。
if (UiPointInRect(m_widthValueRect, fx, fy))
{
ScrollToSignalAnimated(SelectedRef());
return BeginEdit(StyleEditField::WIDTH);
}
if (UiPointInRect(m_opacityValueRect, fx, fy))
{
ScrollToSignalAnimated(SelectedRef());
return BeginEdit(StyleEditField::OPACITY);
}
// 两行右侧的**复位按钮**:把该行参数恢复为默认值(线宽按信号类型取默认、
// 不透明度回 100%)。与「系数/偏移」还原图标同一交互语义:就地回显、不弹任何东西。
if (m_widthResetRect.Width > 0.0f && UiPointInRect(m_widthResetRect, fx, fy))
{
ResetStyleLineWidth();
ScrollToSignalAnimated(SelectedRef());
return true;
}
if (m_opacityResetRect.Width > 0.0f && UiPointInRect(m_opacityResetRect, fx, fy))
{
ResetStyleOpacity();
ScrollToSignalAnimated(SelectedRef());
return true;
}
// 「调整」行:系数与镜像(按下只登记,**抬起**时才展开各自的二级面板,
// 与自定义颜色入口同一套交互)、绝对值(单击即切换)。
if (UiPointInRect(m_adjustRects[kAdjustGain], fx, fy))
{
m_pendingGainMenu = true;
ScrollToSignalAnimated(SelectedRef());
return true;
}
if (UiPointInRect(m_adjustRects[kAdjustMirror], fx, fy))
{
m_pendingMirrorMenu = true;
ScrollToSignalAnimated(SelectedRef());
return true;
}
if (UiPointInRect(m_adjustRects[kAdjustAbsolute], fx, fy))
{
style->absolute = !style->absolute;
ScrollToSignalAnimated(SelectedRef());
return true;
}
}
// 列表行。
RowPart part = RowPart::NONE;
const int row = HitRow(x, y, part);
if (row < 0)
{
return false;
}
const ListRow item = m_rows[static_cast<std::size_t>(row)];
if (item.kind == RowKind::GROUP)
{
if (part == RowPart::CHECK)
{
// ⚠️ 筛选状态下组复选框**只作用于被筛选出来的信号**(用户 2026-11-03 明确要求):
// 全选时只勾上筛选结果,取消时也只取消筛选结果,
// 不会把未通过筛选的信号一起勾上/取消。
std::vector<bool> filterMask;
if (BuildFilterMask(item.groupIndex, filterMask))
{
const bool allVisible = m_tab->GroupAllVisibleSubset(item.groupIndex, filterMask);
if (allVisible)
{
// 取消:只取消筛选结果里的信号(其余保持原状)。
for (std::size_t i = 0; i < filterMask.size(); ++i)
{
if (filterMask[i])
{
m_tab->SetVisible(SignalRef{item.groupIndex, static_cast<int>(i)}, false);
}
}
}
else
{
m_tab->SetGroupVisibleSubset(item.groupIndex, filterMask);
}
}
else
{
const bool allVisible = m_tab->GroupAllVisible(item.groupIndex);
m_tab->SetGroupVisible(item.groupIndex, !allVisible);
}
m_rowsDirty = true;
return true;
}
if (part == RowPart::CLOSE)
{
// 关闭分组属于数据层操作,交由宿主窗口处理;但**抬起时才生效**,
// 这里只记录目标(按住后移开再松开视为放弃)。
m_pendingClosePart = RowPart::CLOSE;
m_pendingCloseRow = row;
return true;
}
// 折叠 / 展开分组:三角、「组X」标签、**名称本体**、以及右侧的「N 项」计数都生效。
// 用户 2026-10-17 要求:名称本体恢复单击展开/收起(此前为了避开“误入改名”
// 而禁掉了它,结果只能点三角、很不方便);误入改名改由**收紧双击阈值**解决
//(见 `OnDoubleClick`:只有快速双击才改名);
// 右侧的「N 项」计数**只折叠展开**,任何频率的点击都不进入改名(用户 2026-10-17 要求)。
{
// 只有名称区域(BODY)参与“快速双击进改名”的计时;三角 /「组X」/ 计数都不参与,
// 否则“点一次计数、紧接着点一次名称”会被误判成双击。
if (part == RowPart::BODY)
{
m_groupNameClickMs = GetTickCount64();
m_groupNameClickRow = row;
m_groupNameClickWasCollapsed = IsGroupCollapsed(item.groupIndex);
}
}
ToggleGroupFromRow(item.groupIndex, row);
return true;
}
const SignalRef ref{item.groupIndex, item.signalIndex};
// ✕:用户明确表示不再需要该信号 → 交由宿主从数据层移除并释放其采样内存。
// 必须在 SelectSignal 之前处理,避免选中一条即将被删除的信号;
// 且与其它 ✕ 一致,**抬起时才真正删除**(按住后移开再松开视为放弃)。
if (part == RowPart::REMOVE_SIGNAL)
{
m_pendingClosePart = RowPart::REMOVE_SIGNAL;
m_pendingCloseRow = row;
return true;
}
SelectSignal(ref);
if (part == RowPart::CHECK)
{
// 勾选/取消勾选会立即触发网格重新划分(取消后空网格随之消失)。
m_tab->ToggleVisible(ref);
m_rowsDirty = true;
}
else if (part == RowPart::LANE_BADGE)
{
// 在左键抬起时弹出,保证菜单不会随着按键松开而消失。
m_pendingLaneMenu = true;
m_pendingLaneRef = ref;
}
else
{
// 点击信号名(行主体):除了选中它,还要让绘图区的**目标网格**完整出现在可视区内
//(上百个独立网格时,旧有高亮根本无法定位)。悬停不触发这一步。
RequestPlotLaneScroll(ref);
// 同时登记"可能开始的拖拽排序":越过后阈值才真正进入拖拽态,
// 因此单击仍然是单击(用户 2026-11-03 要求按住可拖动排序)。
BeginSignalDrag(ref, row, x, y);
}
return true;
}
bool SignalPanel::OnLButtonUp(int x, int y)
{
bool changed = false;
// ---- 信号条目拖拽 ----
// ⚠️ 必须放在最前面:拖拽结束时列表条目位置可能变化,不能再走下方的 ✕ / 菜单判定。
if (m_signalDragging)
{
FinishSignalDrag();
return true;
}
m_signalDragPending = false;
// ---- 关闭类按钮(✕):按下时记录目标,抬起时若指针仍在同一个 ✕ 上才执行 ----
if (m_pendingFilterClear)
{
m_pendingFilterClear = false;
const float fx = static_cast<float>(x);
const float fy = static_cast<float>(y);
const gd::RectF clearRect = FilterClearRect();
if (clearRect.Width > 0.0f && UiPointInRect(clearRect, fx, fy))
{
ClearFilter();
changed = true;
}
}
if (m_pendingClosePart != RowPart::NONE)
{
const RowPart pendingPart = m_pendingClosePart;
const int pendingRow = m_pendingCloseRow;
m_pendingClosePart = RowPart::NONE;
m_pendingCloseRow = -1;
RowPart part = RowPart::NONE;
const int row = HitRow(x, y, part);
if (row == pendingRow && part == pendingPart && row >= 0)
{
const ListRow& item = m_rows[static_cast<std::size_t>(row)];
if (m_host != nullptr)
{
if (pendingPart == RowPart::CLOSE)
{
PostMessageW(m_host, WM_APP + 1, static_cast<WPARAM>(item.groupIndex), 0);
}
else
{
// wParam = 分组下标,lParam = 组内信号下标(见 Application 的消息处理)。
PostMessageW(m_host, WM_APP + 2, static_cast<WPARAM>(item.groupIndex),
static_cast<LPARAM>(item.signalIndex));
}
}
changed = true;
}
}
if (m_draggingOpacity)
{
m_draggingOpacity = false;
changed = true;
}
if (m_draggingWidth)
{
m_draggingWidth = false;
changed = true;
}
if (m_draggingScrollBar)
{
m_draggingScrollBar = false;
changed = true;
}
if (m_pendingColorPicker)
{
m_pendingColorPicker = false;
const float fx = static_cast<float>(x);
const float fy = static_cast<float>(y);
if (UiPointInRect(m_customChipRect, fx, fy) && m_tab != nullptr)
{
if (const SignalStyle* style = StyleOf(SelectedRef()))
{
changed = OpenColorPickerPopup(*style) || changed;
}
}
}
// 「系数」/「镜像」:按下时只登记,抬起时指针仍在同一按钮上才展开二级面板
//(与自定义颜色入口一致:按住后移开再松开视为放弃)。
if (m_pendingGainMenu)
{
m_pendingGainMenu = false;
const float fx = static_cast<float>(x);
const float fy = static_cast<float>(y);
if (UiPointInRect(m_adjustRects[kAdjustGain], fx, fy) && m_tab != nullptr)
{
changed = OpenGainMenu() || changed;
}
}
if (m_pendingMirrorMenu)
{
m_pendingMirrorMenu = false;
const float fx = static_cast<float>(x);
const float fy = static_cast<float>(y);
if (UiPointInRect(m_adjustRects[kAdjustMirror], fx, fy) && m_tab != nullptr)
{
changed = OpenMirrorMenu() || changed;
}
}
if (m_pendingLaneMenu)
{
m_pendingLaneMenu = false;
RowPart part = RowPart::NONE;
const int row = HitRow(x, y, part);
if (row >= 0 && part == RowPart::LANE_BADGE)
{
const ListRow& item = m_rows[static_cast<std::size_t>(row)];
const SignalRef ref{item.groupIndex, item.signalIndex};
if (ref == m_pendingLaneRef)
{
const gd::RectF badge = SignalBadgeRect(row);
OpenLaneMenu(ref, static_cast<int>(badge.X),
static_cast<int>(badge.Y + badge.Height));
changed = true;
}
}
}
// 「目标」行的 GX 标签:与信号行的网格徽标同一套交互(抬起且仍在同一徽标上才展开)。
if (m_pendingTargetLaneMenu)
{
m_pendingTargetLaneMenu = false;
const float fx = static_cast<float>(x);
const float fy = static_cast<float>(y);
UpdateTargetRowGeometry();
if (m_targetBadgeRect.Width > 0.0f && UiPointInRect(m_targetBadgeRect, fx, fy) &&
m_tab != nullptr && SelectedRef().IsValid())
{
OpenLaneMenu(SelectedRef(), static_cast<int>(m_targetBadgeRect.X),
static_cast<int>(m_targetBadgeRect.Y + m_targetBadgeRect.Height));
changed = true;
}
}
return changed;
}
bool SignalPanel::OnDoubleClick(int x, int y)
{
if (m_dataSet == nullptr || m_tab == nullptr)
{
return false;
}
// 双击进入改名:必须先把"可能开始的拖拽"作废,否则第二次按下会被当成新的拖拽起点,
// 改名后轻微移动就会拖着信号跑(用户 2026-11-03 要求拖拽排序,改名不能被它干扰)。
CancelSignalDrag();
const float fx = static_cast<float>(x);
const float fy = static_cast<float>(y);
// ⚠️⚠️ **编辑态下名称区域的双击必须被吃掉**:
// 窗口带 `CS_DBLCLKS`,编辑时快速再点一下会被合成为 `WM_LBUTTONDBLCLK`,
// 而旧实现直接进入行双击逻辑 ⇒ 分组被展开/收起(或再次进入改名)。
// 输入框内的双击本该是“选词”,本组件只提供整段选择,因此这里保持现状不变、
// 仅保证**不把双击当成行双击**(用户 2026-10-19 报告)。
if (m_editField != StyleEditField::NONE)
{
const gd::RectF field = EditFieldRect();
if (field.Width > 0.0f &&
(UiPointInRect(field, fx, fy) ||
(m_editRow >= 0 && m_editRow < static_cast<int>(m_rows.size()) &&
UiPointInRect(NameEditAbsorbRect(field), fx, fy))))
{
return false;
}
}
// 样式区数值框:双击进入(或保持)输入状态。
if (UiPointInRect(m_widthValueRect, fx, fy))
{
return BeginEdit(StyleEditField::WIDTH);
}
if (UiPointInRect(m_opacityValueRect, fx, fy))
{
return BeginEdit(StyleEditField::OPACITY);
}
// 样式区顶部「目标」行:双击信号名(或它后面的 GX 标签)进入**改名**编辑态。
// ⚠️ 单击不进入编辑(见 `OnLButtonDown`),必须双击 —— 用户明确要求。
if (m_tab != nullptr && StyleOf(SelectedRef()) != nullptr)
{
UpdateTargetRowGeometry();
const bool onName =
m_targetNameRect.Width > 0.0f && UiPointInRect(m_targetNameRect, fx, fy);
const bool onBadge =
m_targetBadgeRect.Width > 0.0f && UiPointInRect(m_targetBadgeRect, fx, fy);
if (onName || onBadge)
{
// `row < 0` 表示编辑目标是"当前选中信号"(即目标行显示的信号)。
m_rowsDirty = true;
return BeginEdit(StyleEditField::SIGNAL_NAME, -1);
}
}
RowPart part = RowPart::NONE;
const int row = HitRow(x, y, part);
if (row < 0)
{
return false;
}
const ListRow item = m_rows[static_cast<std::size_t>(row)];
// 分组行:双击折叠三角用于折叠;双击**名称区域**进入重命名 ——
// ⚠️ 但只有**快速双击**才算(用户 2026-10-17 要求降低灵敏度):
// 按下那一次已经把分组展开/收起了(`OnLButtonDown` 统一处理三角/「组X」/名称),
// 因此这里要把它**撤销**回点击前的状态,然后才进入改名;
// 超过 `kGroupRenameDoubleClickMs` 的“慢双击”只当**又一次单击**(再切一次折叠)。
if (item.kind == RowKind::GROUP)
{
if (part == RowPart::GROUP_COUNT)
{
// 「N 项」计数:**只折叠展开**,绝不进入改名(用户 2026-10-17 要求)。
// 按下那一 次已经切过折叠状态,这里再切一次 = 两次点击各自生效。
ToggleGroupFromRow(item.groupIndex, row);
return true;
}
if (part == RowPart::DISCLOSURE)
{
// 与单击分组行同一入口(悬浮时先滚动就位再播放折起动画)。
ToggleGroupFromRow(item.groupIndex, row);
return true;
}
if (part != RowPart::BODY)
{
return false;
}
// 名称区域:判定“快速双击”。
const std::uint64_t now = GetTickCount64();
const bool fastDoubleClick =
(m_groupNameClickRow == row) && (now >= m_groupNameClickMs) &&
(now - m_groupNameClickMs <= kGroupRenameDoubleClickMs);
if (!fastDoubleClick)
{
// 慢双击 / 两次无关点击:只当另一次单击(切换折叠)。
m_groupNameClickMs = now;
m_groupNameClickRow = row;
m_groupNameClickWasCollapsed = IsGroupCollapsed(item.groupIndex);
ToggleGroupFromRow(item.groupIndex, row);
return true;
}
// 快速双击 ⇒ 改名:先撤销“按下”那一次对折叠状态的切换,再进入编辑。
m_groupNameClickMs = 0;
m_groupNameClickRow = -1;
SetGroupCollapsed(item.groupIndex, m_groupNameClickWasCollapsed);
// ⚠️ **必须同时取消这个分组正在播放的折起/展开动画**:第一次单击已启动
// 150–320ms 的收起动画(`m_groupAnims` 里还留着 `collapsing` 条目)。只回滚折叠
// 状态不清动画的话,改名期间该组仍在播放“收起再瞬间弹回”,输入框画在正在平移的行上。
for (std::size_t index = 0; index < m_groupAnims.size();)
{
if (m_groupAnims[index].groupIndex == item.groupIndex)
{
m_groupAnims.erase(m_groupAnims.begin() + static_cast<std::ptrdiff_t>(index));
}
else
{
++index;
}
}
m_rowsDirty = true;
EnsureRows();
return BeginEdit(StyleEditField::GROUP_TITLE, GroupRowIndex(item.groupIndex));
}
// 复选框、网格徽标与「移除信号」✕ 各自由单击处理,双击不再切换可见性/进入重命名。
if (part == RowPart::CHECK || part == RowPart::LANE_BADGE || part == RowPart::REMOVE_SIGNAL)
{
return false;
}
const SignalRef ref{item.groupIndex, item.signalIndex};
SelectSignal(ref);
m_rowsDirty = true;
return BeginEdit(StyleEditField::SIGNAL_NAME, row);
}
bool SignalPanel::DoubleClickIsPlainPress(int x, int y)
{
if (m_dataSet == nullptr || m_tab == nullptr)
{
return false;
}
const float fx = static_cast<float>(x);
const float fy = static_cast<float>(y);
// 真正有双击语义的位置(保持双击行为):
// * 线宽 / 不透明度数值框:双击进入(或保持)输入状态;
// * 样式区「目标」行的名称与 GX 标签:双击进入改名;
// * 内联输入框自身:双击用于选词 / 定位插入符。
if (m_editField != StyleEditField::NONE && UiPointInRect(EditFieldRect(), fx, fy))
{
return false;
}
if (UiPointInRect(m_widthValueRect, fx, fy) || UiPointInRect(m_opacityValueRect, fx, fy))
{
return false;
}
// 线宽 / 透明度两行右侧的复位按钮是"单次点击即生效"的按钮:双击不得被解释成别的语义。
if ((m_widthResetRect.Width > 0.0f && UiPointInRect(m_widthResetRect, fx, fy)) ||
(m_opacityResetRect.Width > 0.0f && UiPointInRect(m_opacityResetRect, fx, fy)))
{
return false;
}
UpdateTargetRowGeometry();
if (m_targetNameRect.Width > 0.0f && UiPointInRect(m_targetNameRect, fx, fy))
{
return false;
}
if (m_targetBadgeRect.Width > 0.0f && UiPointInRect(m_targetBadgeRect, fx, fy))
{
return false;
}
RowPart part = RowPart::NONE;
const int row = HitRow(x, y, part);
if (row < 0)
{
// 列表之外(样式区按钮 / 筛选框 / 面板空白):全部只有单击语义。
return true;
}
// 行的**名称区域**(分组 / 信号)都靠双击改名 ⇒ 保留双击语义。
if (part == RowPart::BODY)
{
return false;
}
// 其余全部(复选框、折叠三角、「组X」、✕、GX 徽标、全部显示隐藏、色块、绘制方式、
// 滑块、「调整」按钮、筛选框…)都只有单击语义 ⇒ 快速连点必须当作一次次按下。
return true;
}
bool SignalPanel::OnRButtonDown(int x, int y)
{
if (m_dataSet == nullptr || m_tab == nullptr)
{
return false;
}
RowPart part = RowPart::NONE;
const int row = HitRow(x, y, part);
if (row < 0)
{
return false;
}
const ListRow& item = m_rows[static_cast<std::size_t>(row)];
if (item.kind == RowKind::GROUP)
{
OpenGroupMenu(item.groupIndex, x, y);
return true;
}
const SignalRef ref{item.groupIndex, item.signalIndex};
SelectSignal(ref);
OpenSignalMenu(ref, x, y);
return true;
}
bool SignalPanel::OnMouseWheel(int delta)
{
// 用户主动滚动 ⇒ 立即取消正在进行的目标信号滚动动画(否则两者会互相打架)。
CancelScrollAnimation();
if (m_dataSet == nullptr)
{
return false;
}
const float previous = m_scrollOffset;
m_scrollOffset -= static_cast<float>(delta) / 120.0f * 54.0f * m_scale;
ClampScroll();
return std::fabs(previous - m_scrollOffset) > 0.01f;
}
bool SignalPanel::OnMouseLeave()
{
const bool changed =
(m_hoverRow >= 0) || (m_hoverStyle != StyleHit::NONE) || m_hoveredSignal.IsValid();
m_hoverRow = -1;
m_hoverPart = RowPart::NONE;
m_hoverStyle = StyleHit::NONE;
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