Files
Just-DataInspector/Components/SignalPanel/Src/SignalPanelInteraction.cpp
T
DeepSeek V4.1 Flash 62220477c0 feat(clip): 新增「选区截取」实验性功能 + 筛选态组复选框只作用于筛选结果 + 重采样统计不被省略
一、重采样面板统计信息不再被省略为 `…`
   - 根因:EdgePanelWidth 里数值列按"典型样例"(1h 1min 2s(1.23456 GHz))估宽,
     真实文本(16ms 182.614us(61.795 Hz))更长 => 绘制矩形不够宽 => 尾部被裁。
   - 新增 FormatIntervalWithFrequency() 作为「间隔(频率)」文本的唯一实现,
     宽度计算与绘制共用它;EdgePanelWidth 改为逐信号实测真实文本宽度取最大值。

二、筛选状态下组复选框只作用于「筛选出来的信号」
   - TabConfig 新增 SetGroupVisibleSubset / GroupAllVisibleSubset / GroupHasVisibleSubset,
     只读写掩码内元素,未通过筛选的信号可见性保持不变。
   - SignalPanel::BuildFilterMask 提供逐信号掩码(未筛选时返回 false,走整组语义)。
   - 组复选框的点击、绘制、行位图缓存键三处统一改用子集判定
     (只改绘制不改缓存键会导致勾选后复选框不刷新)。

三、新增实验性功能「选区截取」(EdgePanelKind::CLIP = 6,PLOT_CMD_LANE_CLIP = 14)
   - 菜单位置:实验性功能二级菜单,「重采样」之下、「自由标记」上方,带勾选。
   - 面板:逐信号一行(信号色叠底条目栏 + 右侧复选框,不可展开),内容可滚动;
     底部两个不滚动的按钮「截取为新信号」与「原始信号截取」;
     未启用双光标时两个按钮为空矩形并给出提示。
   - 截取为新信号:区间内原始数据(不烘焙)+ 继承源信号整套调整,
     名称为 <源信号名> (AAAA - BBBB)(显示时刻、保留三位小数、带 s),插入源信号下方,
     使用 AppendGroupAxis 新建的独立时间轴。
   - 原始信号截取:DataSet::ClipSignalRange 就地裁剪时间轴与采样,
     用新建+swap+shrink_to_fit 真正归还内存,并重算统计与轴的 minTime/maxTime/monotonic。
     调用前先 PauseDerivedAsync() 避免后台派生读到已释放采样。
   - 时间轴偏移参与区间比较:双光标是显示时刻(time+offsetTime),
     BuildClippedSignal 比较时加偏移,ClipSignalInPlace 换算回采样时刻(low-offsetTime)。
   - 同步点已全部补齐:LaneCarry(三处)/SplitLane/PruneLaneSignalItems/RemapAfterSignalInsert/
     purgeRefs/PanelSlotKey/CollectDebugBoxes/SceneContentHash/kEdgePanelKinds/kOrder/
     EdgePanelLayoutFor/EDGE_PANEL_MOVE/m_pendingPanelClose/MEAN_SCROLL/滚轮/悬停/
     SetLanePanelCollapsed/LanePanelCollapsed/QueryCursor。

make release 零 warning,产物 860,160 B。
2026-11-03 16:04:48 +08:00

967 lines
32 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_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);
}
return true;
}
bool SignalPanel::OnLButtonUp(int x, int y)
{
bool changed = 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;
}
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();
return changed;
}
} // namespace jdi