Files
Just-DataInspector/Components/SignalPanel/Src/SignalPanelInteraction.cpp
T
DeepSeek V4.1 Flash 0ec8055e95 重构(UI): 统一组件协议 + 去手写链路 + 大文件按职责拆分 + 代码审查 + 剥离 Tools
## 1. 新增 UiComponent 顶层组件协议(不是负优化)

Components/Shared/Inc/UiComponent.hpp:纯接口、无状态,8 个方法
SetScale / Layout / Area / Paint / HitTest / OnMouseLeave / IsEditing / QueryCursor。

Toolbar / TabBar / SignalPanel / PlotView 本来就各自拥有完全同名同签名的成员,
因此改造 = 加 override + 补 Area()(PlotView 另加一个非 const 的 QueryCursor 转发,
因为它自己的查询会惰性建立行缓存),实现代码零改动、静态派发、体积无增长。

刻意不把 UiWidget 当作它的基类:UiWidget 是"树节点"(父给尺寸、父下探脏区、
命中沿树链派发),而 PlotView(4 层位图缓存)/ SignalPanel(行位图缓存)是
"地盘宿主"。强行上树会推翻"父脏则子全脏"的前提,历史上正是这类耦合导致过
"后台渲染覆盖平移结果""命中矩形错位"的事故。头文件里已写明这条理由。
Toolbar/TabBar 是两者的交集:对外实现 UiComponent,对内持有一棵 UiWidget 树。

## 2. 宿主侧:四条手写链 -> 组件表

Application 新增 ComponentEntry{ component, target, chrome } 与 m_components
(底->顶:plot -> panel -> toolbar -> tabBar),BuildComponentTable() 装配一次。

- ApplyDpi        6 行逐个 SetScale  ->  1 个循环
- Relayout        手算三列 x 坐标    ->  UiStackRects(内容区, HORIZONTAL, {panelW, splitW, AUTO})
- WM_MOUSELEAVE   逐个 OnMouseLeave   ->  循环累积 chromeChanged / plotChanged
- WM_SETCURSOR    if/else if 链      ->  从表尾向前找首个命中者,取其 QueryCursor

顺带兑现历史缺口:UiLayout::UiStackRects 自引入以来第一次有了生产调用点。

有意保留显式的三条链(WM_LBUTTONDOWN / WM_LBUTTONUP / WM_MOUSEMOVE):它们各自带
组件专属后置动作(提交重命名、构建派生信号、面板联动滚动、更新拖拽光标、
plotOnlyMove 决定局部失效还是整帧失效)。塞进表里只能靠 lambda 字段,
可读性反而下降 —— 属于用户明确禁止的"过度抽象",因此不做。

## 3. 大文件按职责拆分(纯整函数搬移,逐像素 0 差异)

SignalPanelInteraction.cpp 2329 -> 831 行,拆出:
  SignalPanelEdit.cpp   616  内联编辑(筛选框、组名/信号名、数值、颜色与透明度)
  SignalPanelScroll.cpp 205  动画与滚动(折起、联动滚动、滚动到目标信号)
  SignalPanelMenu.cpp   527  弹出菜单(系数/偏移、镜像、右键菜单与命令回执)

Shared/PopupMenu.cpp 1901 -> 706 行,拆出:
  PopupMenuLayout.cpp 753  尺寸测量、项矩形排版、滚动可见性
  PopupMenuPaint.cpp  397  背景/阴影/项绘制
  Inc/PopupMenuInternal.hpp 103   pmdetail 常量 + inline 辅助(跨 TU 安全)

拆分判据是"职责"而非行数;共享常量一律收敛到 *Internal.hpp。

## 4. 代码审查(修 5 处)

- SignalPanelInteraction.cpp 头注释仍写"4 片",实际 6 片 -> 改为完整清单
- PopupMenuLayout.cpp 机械搬移时把构造函数一起搬走 -> 重复定义,只保留在交互分片
- PopupMenu.cpp 的 #include "PopupMenuInternal.hpp" 落进 namespace jdi 内 -> 移到文件头
- PopupMenuPaint.cpp 缺 UiControls.hpp(UiDrawEditFieldContent 未声明)-> 补 include
- 全仓复查历史删除符号残留引用(m_paintPlot / ClampCursorsAfterPan /
  PaintFreeMarkerTooltip / CaptureDecorCache / FitSinglePointSignal / CsvTable /
  DataFile / .preflight / structcheck)-> 零命中

## 5. Tools/ 剥离

git rm -r --cached Tools + .gitignore 新增 Tools/(本地文件保留,可随时复跑)。
ui_refactor 分支为全本地分支,另做 filter-branch 清除历史中的该目录。

## 6. 验证

- make release / make debug:零 error 零 warning
- rendercheck:A-H 八个场景哈希与改造前逐一相同
  (A 2166933039626F66 B C444ECFEB18FD73D C 7984446D8E7A4418 D BF855879CA12F289
   E 1C663E4118616016 F 0535292D8E07AE18 G 7A58172C46577729 H 23F390DD76141113)
  FUNC 7/7 PASS
- uicheck:69 passed, 0 failed
- TargetCheck.exe 891,392 B(< 900 KB 阈值)

## 7. 文档

Documents/UI_Refactor_Plan.md / UI_Refactor_Progress.md / UI_Refactor_Report.md
新增第五阶段章节;Documents/agent_mem.md 同步变更日志与「第七轮」硬约束。
2026-10-28 14:35:32 +08:00

910 lines
30 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 && 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);
}
// 「调整」行:系数与镜像(按下只登记,**抬起**时才展开各自的二级面板,
// 与自定义颜色入口同一套交互)、绝对值(单击即切换)。
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)
{
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;
}
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