Files
Just-DataInspector/Components/SignalPanel/Src/SignalPanelInteraction.cpp
T
DeepSeek V4.1 Flash 53b981a2a9 全项目大检查:修复数据层/UI 真 BUG、清理死代码与过期注释、并评估二进制体积
按「性能 / BUG / 错误 / 过期注释 / 命名与实际含义不符」四项对全仓做了一次审查,
四个只读子审查交叉核对后逐条回到源码确认再改。

一、数据层真 BUG
- MatH5:U8/U16/U32/U64 用逐字节 Cached() 拼装,而 Cached 只服务「完全落在同一个
  64KB 块内」的请求 => 跨块时静默读成 0(U32 整段为 0、U64 高 32 位为 0),分块 B-tree
  的 chunk 偏移/子节点地址被破坏且无任何报错。新增 ScalarLE():命中缓存用缓存,
  否则退回一次 ReadAt;U64 顺带从 8 次缓存查找降到 1 次。
- Mf4Loader::IsBlock 未校验链接表是否落在块内 => DataOffset 越界,随后
  block_len - (data - offset) 在 uint64 上下溢,得到一个约 2^64 的"可用字节数",
  下游 vector(usable+1) 直接 bad_alloc。同时把 offset + bytes > size 改成不产生回绕的写法。
- DecodeRawValue 的整数路径:bit_offset%8 != 0 且位宽 64 时需 9 个字节,先拼成 64 位
  再整体右移会把最高字节静默丢弃 => 改为逐字节带位偏移累加。
- cg_cycle_count 加「数据段总字节 / 记录字节」上限(压缩段按 deflate 最大膨胀比估算)。
- CsvLoader 表头分支同样跳过 #/% 注释行(否则整份文件按 1 列解析并被丢弃)。
- MatLoader:double -> int 的越界转换加了范围检查;普通对象递归加深度上限与成环检测。
- Utf8Util::BuildTicks:double -> long long 越界转换前先判商是否落在 ±9e18 内。

二、界面 BUG
- 工具栏按钮永远不显示悬停高亮:Paint 读的 Button::hover 全仓从未赋值,改为按 m_hoverIndex 判断。
- 菜单打开时"取消底层拖拽"对每个消息都执行,把菜单自己的 Target::EDIT_FIELD 也清掉
  => 菜单内联输入框无法拖拽框选。排除 EDIT_FIELD。
- GdiplusShutdown 之前未释放 PlotView 的 GDI+ 位图(Application 是进程单例,成员析构在
  WinMain 之后)=> 新增 PlotView::ReleaseGraphicsResources() 并在 Shutdown 里调用。
- WM_KILLFOCUS 现在会提交标签页/绘图区编辑并复位捕获与拖拽状态。
- WM_LBUTTONUP 不再抢走中键平移的鼠标捕获。
- TextEdit:被过滤器拒绝的字符返回 false;无选区时 Ctrl+X 只复制、不再清空整段文本。
- SignalPanel::InvalidateRows 作废编辑/待确认/改名计时等行下标状态。
- EndFilterEdit 改走 ApplyFilterText(否则输入纯空格后回车,列表仍停在筛选结果)。
- 新增 SignalBadgeVisible 作为唯一判据(原来命中只看"可见",绘制还要求"已分配网格")。
- 折起动画结束后夹取滚动量;双击分组名改名时清掉该组正在播放的折起动画。
- ScrollToSignal 复用 SignalScrollTarget(含悬浮组名避让)。
- RowNameRect 与绘制使用同一最小宽度(原来退化时返回空矩形)。
- 分隔条拖拽上限改用与 Relayout 同一公式;UpdateDragCursor 补 SPLITTER/EDIT_FIELD。
- SignalPanel 里几个守卫改为判断真正被解引用的指针;分组行 ✕ 也返回手型光标。

三、PlotView
- FindStyleOnlyLanes 改用完整的 SceneStyleEntry::operator==(原来只比 4 个字段,
  另一个网格同时改"调整"时带着旧像素被当成最新)。
- 宽基底图层不再烘焙拖拽光晕(LaneLayerHash 不含它),改由 PaintHoverHighlight 每帧叠加。
- PaintLaneLegend 提前返回前清掉本网格的命中矩形(否则能点中看不见的图例条目)。
- 面板主体改为完全不透明(消除平移后 4% 透出的旧波形,零额外代价)。
- PAN / Shift+滚轮改走 SetTimeView,使"自动点线"判据随平移重算。
- 内联输入框命中字体与绘制统一为 monoSmall。
- 派生曲线取值与窗口按 SourceIndexFor 映射(水平镜像时不再读到镜像另一端)。
- EdgePanelWidth 补 style/axis 空指针守卫;二分结果夹到 count。

四、清理
- 删除 Toolbar::ActionLabel、Theme::statusBg、PopupMenu 的 editResult 死机制、
  Toolbar::Layout 的重复 clear()、HitRow 的恒假分支、OnDoubleClick 的重复判定、
  PaintUtil 里孤立的注释块。
- 修正一批过期/错误注释(包络带"不做加宽"、LaneLayerHash 的图例说法、
  均值滤波曲线的窗口说法、样式区行数与「调整」按钮顺序、FIT_ALL 退出缩放模式、
  RemoveSignal 的 ✕ 位置、.mat/.dat 格式文档、MatH5 的"映射视图"、DataModel 的
  "点线/被移除文件"、SignalPanel 的"行高连续缩小"、TextEdit 的 PaintUtil 引用等)。

五、二进制体积评估(按要求只评估,未做压缩修改)
- release 809,472 字节;.text 652KB、.xdata+.pdata 约 75KB、.rdata 42KB、
  .rsrc 20KB、.idata 11.5KB。
- 去掉 -ld3d11 -ldwmapi -ld3dcompiler(仓内无任何调用)实测 0 字节收益
  (静态库只拉取被引用的对象)=> 删链接库不是压缩手段,只是配置整洁。
- 仓内已无 <iostream>/<sstream>/<filesystem>/<regex>/<locale> 等体积炸弹。
- 结论:已无明显的浪费可捡;要显著变小只能改变取舍(动态 CRT / UPX / 砍功能),
  需要用户决策,本轮未动。

验证
- editcheck 20/20、round3check 31/31、round4check 34/34、round5check 5/5、convcheck 18/18。
- check 136/137:唯一失败项经 git stash 回到改动前复现同一数值,属该探针自身的既有
  不稳定项(取决于前置用例留下的缓存/时序),与本轮改动无关。
- 真实数据:4 个 MF4 => 283/8/18/24 个信号;.mat 样例 8 信号、
  带无障碍对照-防夹力曲线数据 8 信号、BIG_MAT.mat 5 信号(私有内存 +741MB)。
- make release / make debug 零警告。

文档:Documents/agent_mem.md 同步 §10 变更日志、§11.2 第 171-180 条硬约束
与文末本轮小节(含体积评估数据)。
2026-10-20 16:10:29 +08:00

2330 lines
72 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 实现分片 —— 本文件只包含 SignalPanel 的**成员函数定义**(纯代码搬移,行为与拆分前完全一致)。
//
// 共用的常量与自由函数在 SignalPanelInternal.hpp(命名空间 `spdetail`,全部 inline)。
// 分片按“职责”划分:核心(状态/布局/几何)、渲染、交互与编辑,见各文件名。
#include "SignalPanelInternal.hpp"
namespace jdi
{
bool SignalPanel::IsOnFilterBox(int x, int y) const
{
return UiPointInRect(m_filterRect, static_cast<float>(x), static_cast<float>(y));
}
void SignalPanel::ClearFilter()
{
if (m_filterText.empty() && !m_filterEditing)
{
return;
}
m_filterText.clear();
if (m_filterEditing)
{
// 编辑态下一并重置输入模型,避免残留旧文本(Begin 会整段选中/清空)。
m_filterEdit.Begin(std::wstring(), false, EditFilter::ANY, 0u);
}
// 退出筛选:筛选模式下临时折起的状态与动画一并作废(行集合会整体重建)。
m_filterCollapsed.clear();
m_groupAnims.clear();
m_rowShift.clear();
// 清空后回到完整列表:重建行集合、回到顶部。
m_rowsDirty = true;
m_scrollOffset = 0.0f;
m_layoutDirty = true;
}
HCURSOR SignalPanel::QueryCursor(int x, int y)
{
// 清空按钮 ✕ 是独立可点击控件,用手型光标给出除高亮之外的额外交互提示。
const gd::RectF clearRect = FilterClearRect();
if (clearRect.Width > 0.0f &&
UiPointInRect(clearRect, static_cast<float>(x), static_cast<float>(y)))
{
return LoadCursorW(nullptr, IDC_HAND);
}
// 信号行尾的「移除该信号」✕ 与分组行尾的「关闭该分组」✕ 视觉完全一致 ⇒ 光标必须一致。
RowPart part = RowPart::NONE;
if (HitRow(x, y, part) >= 0 && (part == RowPart::REMOVE_SIGNAL || part == RowPart::CLOSE))
{
return LoadCursorW(nullptr, IDC_HAND);
}
// 样式区的可点击控件(「调整」行三个按钮、线宽/透明度数值框)也用手型光标。
const float fx = static_cast<float>(x);
const float fy = static_cast<float>(y);
// 样式区顶部「目标」行的信号名与 GX 标签:与信号列表行一致,用手型光标提示可交互。
UpdateTargetRowGeometry();
if ((m_targetNameRect.Width > 0.0f && UiPointInRect(m_targetNameRect, fx, fy)) ||
(m_targetBadgeRect.Width > 0.0f && UiPointInRect(m_targetBadgeRect, fx, fy)))
{
return LoadCursorW(nullptr, IDC_HAND);
}
if (UiPointInRect(m_adjustRects[kAdjustGain], fx, fy) ||
UiPointInRect(m_adjustRects[kAdjustMirror], fx, fy) ||
UiPointInRect(m_adjustRects[kAdjustAbsolute], fx, fy) || UiPointInRect(m_widthValueRect, fx, fy) ||
UiPointInRect(m_opacityValueRect, fx, fy))
{
return LoadCursorW(nullptr, IDC_HAND);
}
return nullptr;
}
void SignalPanel::BeginFilterEdit()
{
if (m_filterEditing)
{
return;
}
m_filterEditing = true;
// 单击进入编辑:光标落在末尾,不做全选(用户可以继续追加筛选词)。
m_filterEdit.Begin(m_filterText, false, EditFilter::ANY, 0u);
}
bool SignalPanel::EndFilterEdit()
{
if (!m_filterEditing)
{
return false;
}
// **必须**在 `End()` 之前取出文本:`TextEdit::End()` 会清空内部缓冲区。
const std::wstring finalText = Trim(m_filterEdit.Text());
m_filterEditing = false;
m_filterEdit.End();
// ⚠️ 必须走 `ApplyFilterText`(而不是直接赋值):只赋值会让 `m_filterText` 变成
// “普通模式”而行集合仍是筛选后的结果(输入纯空格后回车 → 列表仍然缺分组/信号)。
ApplyFilterText(finalText);
return true;
}
bool SignalPanel::AdvanceGroupAnimations()
{
if (m_groupAnims.empty())
{
return false;
}
const std::uint64_t now = GetTickCount64();
bool animationFinished = false;
for (std::size_t index = 0; index < m_groupAnims.size();)
{
const GroupAnim& anim = m_groupAnims[index];
const std::uint64_t elapsed = (now >= anim.startMs) ? (now - anim.startMs) : 0;
if (anim.durationMs == 0u || elapsed >= anim.durationMs)
{
// 完成:把同步滚动精确落到终点(最后一帧的 eased 进度未必正好是 1)。
if (anim.scrollSynced)
{
m_scrollOffset = anim.scrollTo;
}
animationFinished = true;
m_groupAnims.erase(m_groupAnims.begin() + static_cast<std::ptrdiff_t>(index));
}
else
{
++index;
}
}
// ⚠️ **动画结束(折起与展开都要)后必须重建行集合**:
// * 折起:该组的信号行要真正从行集合里移除;
// * 展开:行集合不变,但 `m_contentHeight` 会在这一帧从“收起态”跳回“展开态”,
// 而滚动条可见性只在 `EnsureRows` / `Layout` 里刷新 ⇒ 不重建的话滚动条会
// 一直停在“隐藏”状态(用户报告:先折起再展开后,信号列表的滚动条消失)。
// ⚠️ **不设置 `m_rowsDirty`**(动画进行中):行高不随动画变化,只需重算逐行位移与内容高度 ——
// 这正是本动画方案比“逐行缩放”流畅得多的原因(无需每帧重建行集合)。
if (animationFinished)
{
m_rowsDirty = true;
BuildRows(); // 内部会重算内容高度与逐行位移
}
else
{
UpdateAnimGeometry();
}
// 内容高度随动画逐帧变化 ⇒ 滚动条的可见性与列表可用宽度也要同步刷新(否则动画期间
// 列表宽度/滚动条与内容不匹配)。
// ⚠️ 动画进行中**不夹取**滚动偏移:与滚动同步的折起动画正按自己的进度推进
// `m_scrollOffset`,此时夹取会打断它。但动画**全部结束**后必须夹一次 —— 否则
// 在长列表底部折起一个可见分组时,内容高度变小而滚动量保持原值:列表底部出现空白条,
// 且滚动条滑块按未夹取的 `progress` 被画到轨道之外。
RefreshScrollBar(animationFinished);
return !m_groupAnims.empty();
}
void SignalPanel::ToggleGroupFromRow(int groupIndex, int row)
{
EnsureRows();
if (m_tab == nullptr || groupIndex < 0)
{
return;
}
const int headerRow =
(row >= 0 && row < static_cast<int>(m_rows.size()) &&
m_rows[static_cast<std::size_t>(row)].kind == RowKind::GROUP &&
m_rows[static_cast<std::size_t>(row)].groupIndex == groupIndex)
? row
: GroupRowIndex(groupIndex);
const bool collapse = !IsGroupCollapsed(groupIndex);
SetGroupCollapsed(groupIndex, collapse);
// 悬浮(组名的自然位置已在列表上边界之上)且要折起时:**一边向上滚动一边收起**,
// 保证悬浮组名始终留在列表最上方,动画结束时滚动偏移恰好落在“组名正好在最上方”。
//(用户 2026-10-16 第二轮明确要求:不再“先滚动到位、再播放收起动画”。)
float scrollFrom = m_scrollOffset;
float scrollTo = m_scrollOffset;
bool syncScroll = false;
if (collapse && headerRow >= 0)
{
const float alignOffset = ContentOffsetBeforeRow(headerRow);
if (m_scrollOffset > alignOffset + 0.5f)
{
// 目标滚动量受“折起后的最终内容高度”限制:若内容比列表还短,
// 就无法把组名顶到最上方(此时取可达的最大滚动量,避免结束时被强行夹取)。
const float finalContent =
std::max(0.0f, NaturalContentHeight() - ShownBlockHeight(groupIndex));
const float maxOffset = std::max(0.0f, finalContent - m_listRect.Height);
scrollTo = std::max(0.0f, std::min(alignOffset, maxOffset));
syncScroll = true;
}
}
StartGroupToggle(groupIndex, collapse, scrollFrom, scrollTo, syncScroll);
}
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;
}
void SignalPanel::StartScrollAnimationTo(float desired)
{
const float maxOffset = std::max(0.0f, m_contentHeight - m_listRect.Height);
desired = std::max(0.0f, std::min(desired, maxOffset));
const float distance = std::fabs(desired - m_scrollOffset);
if (distance < 1.0f)
{
// 已经到位:不需要动画(但仍然要把残余的亚像素差抹平)。
m_scrollOffset = desired;
ClampScroll();
m_scrollAnimActive = false;
return;
}
// 时长随距离增长(越远越快 = 同样时间内走更多距离),到上限后不再增长,
// 因此"整个动画过程不会太久"(最长 300ms)。
const float durationMs =
std::max(kScrollAnimMinMs, std::min(kScrollAnimMaxMs, distance * kScrollAnimMsPerPixel));
m_scrollAnimFrom = m_scrollOffset;
m_scrollAnimTo = desired;
m_scrollAnimStartMs = GetTickCount64();
m_scrollAnimDurationMs = static_cast<std::uint32_t>(durationMs + 0.5f);
m_scrollAnimActive = true;
}
void SignalPanel::ScrollToSignal(const SignalRef& ref)
{
EnsureRows();
if (!ref.IsValid())
{
return;
}
// ⚠️ 必须与动画版(`SignalScrollTarget`)共用同一套几何:非动画版原来把目标行直接顶到
// 列表上边界,而该区域被**悬浮组名**占据(`StickyGroupRowIndex`),于是切标签页后
// 选中的信号会被完全遮住。这里直接复用 `SignalScrollTarget`(含 `topInset` 避让)。
// ⚠️ 折起的组同样要先展开,否则行集合里根本没有目标行。
ExpandGroupForSignal(ref);
float desired = m_scrollOffset;
if (SignalScrollTarget(ref, desired))
{
m_scrollOffset = desired;
ClampScroll();
}
m_scrollAnimActive = false;
}
void SignalPanel::ScrollToSignalAnimated(const SignalRef& ref)
{
EnsureRows();
if (!ref.IsValid())
{
return;
}
// ⚠️ **目标信号所在分组若处于折起状态,行集合里根本没有它** —— 无论怎么滚都看不到它。
// 用户 2026-10-16 报告:点击网格内的图例触发联动时,被折起的组不会自动展开 ⇒ 联动失效。
// 因此先自动展开该分组(立刻生效,不做展开动画:滚动目标需要稳定的行位置)。
ExpandGroupForSignal(ref);
float desired = m_scrollOffset;
if (!SignalScrollTarget(ref, desired))
{
return;
}
// 把列表平滑滚动到目标信号完整可见。
StartScrollAnimationTo(desired);
}
bool SignalPanel::ExpandGroupForSignal(const SignalRef& ref)
{
if (ref.groupIndex < 0 || !IsGroupCollapsed(ref.groupIndex))
{
return false;
}
SetGroupCollapsed(ref.groupIndex, false);
// 该分组若正在播放折起动画,一并作废(目标行的位置会跳回展开态,动画没有意义了)。
const int anim = FindGroupAnimation(ref.groupIndex);
if (anim >= 0)
{
m_groupAnims.erase(m_groupAnims.begin() + anim);
}
// 行集合必须重建(展开后信号行才会出现)——滚动目标依赖它。
m_rowsDirty = true;
EnsureRows();
return true;
}
bool SignalPanel::AdvanceScrollAnimation()
{
if (!m_scrollAnimActive)
{
return false;
}
const std::uint64_t now = GetTickCount64();
const std::uint64_t elapsed = (now >= m_scrollAnimStartMs) ? (now - m_scrollAnimStartMs) : 0;
float progress = (m_scrollAnimDurationMs > 0u)
? static_cast<float>(elapsed) / static_cast<float>(m_scrollAnimDurationMs)
: 1.0f;
if (progress > 1.0f)
{
progress = 1.0f;
}
// ease-out cubic:起步快、靠近目标时变慢(用户要求"越远时越快,靠近时变慢")。
const float eased = 1.0f - std::pow(1.0f - progress, 3.0f);
m_scrollOffset = m_scrollAnimFrom + (m_scrollAnimTo - m_scrollAnimFrom) * eased;
if (progress >= 1.0f)
{
m_scrollOffset = m_scrollAnimTo;
m_scrollAnimActive = false;
ClampScroll();
return false;
}
return true;
}
bool SignalPanel::ApplyWidthFromX(float x)
{
// ⚠️ 判的是**实际被解引用**的指针:本函数只经 `StyleOf()` 碰 `m_dataSetWritable`,
// 与 `m_tab` 无关(旧实现在这里判 `m_tab`,真出现“有 tab、无 dataset”时仍会空指针)。
if (m_dataSetWritable == nullptr)
{
return false;
}
SignalStyle* style = StyleOf(SelectedRef());
if (style == nullptr)
{
return false;
}
const double ratio = UiSliderRatioFromX(m_widthTrackRect, x);
float value = static_cast<float>(kWidthMin + ratio * (kWidthMax - kWidthMin));
value = std::round(value * 10.0f) / 10.0f;
value = ClampFloat(value, kWidthMin, kWidthMax);
if (std::fabs(value - style->lineWidth) < 0.001f)
{
return false;
}
style->lineWidth = value;
return true;
}
bool SignalPanel::BeginEdit(StyleEditField field, int row)
{
// `m_dataSet` 在下面的 `Resolve()` / `GroupAt()` 里被直接解引用,必须一并判空。
if (m_tab == nullptr || m_dataSet == nullptr || field == StyleEditField::NONE)
{
return false;
}
if (m_editField == field && m_editRow == row)
{
return false;
}
std::wstring initial;
switch (field)
{
case StyleEditField::WIDTH:
{
const SignalStyle* style = StyleOf(SelectedRef());
if (style == nullptr)
{
return false;
}
initial = FormatNumber(style->lineWidth, 1);
break;
}
case StyleEditField::OPACITY:
{
const SignalStyle* style = StyleOf(SelectedRef());
if (style == nullptr)
{
return false;
}
initial = std::to_wstring(static_cast<int>(style->opacity * 100.0f + 0.5f));
break;
}
case StyleEditField::SIGNAL_NAME:
{
// `row < 0`:样式区顶部「目标」行 —— 编辑对象就是当前选中的(目标)信号。
if (row < 0)
{
const SignalData* signal = SelectedRef().IsValid() ? m_dataSet->Resolve(SelectedRef()) : nullptr;
if (signal == nullptr)
{
return false;
}
initial = signal->name;
break;
}
if (row >= static_cast<int>(m_rows.size()))
{
return false;
}
const ListRow& item = m_rows[static_cast<std::size_t>(row)];
const SignalData* signal = m_dataSet->Resolve(SignalRef{item.groupIndex, item.signalIndex});
if (signal == nullptr)
{
return false;
}
initial = signal->name;
break;
}
case StyleEditField::GROUP_TITLE:
{
if (row < 0 || row >= static_cast<int>(m_rows.size()))
{
return false;
}
const SignalGroup* group = m_dataSet->GroupAt(m_rows[static_cast<std::size_t>(row)].groupIndex);
if (group == nullptr)
{
return false;
}
initial = group->name;
break;
}
default:
return false;
}
m_editField = field;
m_editRow = row;
if (field == StyleEditField::SIGNAL_NAME && row < 0)
{
// 「目标」行的输入框与行内名称矩形同源,必须先按当前目标算好几何。
UpdateTargetRowGeometry();
}
// 名称类字段可输入任意字符,数值类字段限制为数字与小数点。
const bool nameField =
(field == StyleEditField::SIGNAL_NAME) || (field == StyleEditField::GROUP_TITLE);
// 不限长度:名称与数值都由各自的提交逻辑处理超长内容。
m_edit.Begin(std::move(initial), true, nameField ? EditFilter::ANY : EditFilter::NUMBER, 0u);
return true;
}
bool SignalPanel::EndEdit(bool commit)
{
if (m_editField == StyleEditField::NONE)
{
return false;
}
const StyleEditField field = m_editField;
const int row = m_editRow;
const std::wstring text = m_edit.Text();
m_editField = StyleEditField::NONE;
m_editRow = -1;
m_edit.End();
if (!commit || m_tab == nullptr || m_dataSet == nullptr)
{
return true;
}
const std::wstring trimmed = Trim(text);
switch (field)
{
case StyleEditField::WIDTH:
{
if (trimmed.empty())
{
break;
}
wchar_t* stop = nullptr;
const double parsed = std::wcstod(trimmed.c_str(), &stop);
if (stop == trimmed.c_str() || parsed != parsed)
{
break;
}
if (SignalStyle* style = StyleOf(SelectedRef()))
{
const float value = ClampFloat(static_cast<float>(parsed), kWidthMin, kWidthMax);
style->lineWidth = std::round(value * 10.0f) / 10.0f;
}
break;
}
case StyleEditField::OPACITY:
{
if (trimmed.empty())
{
break;
}
wchar_t* stop = nullptr;
const double parsed = std::wcstod(trimmed.c_str(), &stop);
if (stop == trimmed.c_str() || parsed != parsed)
{
break;
}
if (SignalStyle* style = StyleOf(SelectedRef()))
{
style->opacity = ClampFloat(static_cast<float>(parsed) / 100.0f, 0.0f, 1.0f);
}
break;
}
case StyleEditField::SIGNAL_NAME:
{
if (m_dataSetWritable == nullptr)
{
break;
}
if (row < 0)
{
// 样式区「目标」行:重命名当前选中的(目标)信号。
const SignalRef& target = SelectedRef();
if (target.IsValid())
{
m_dataSetWritable->RenameSignal(target.groupIndex, target.signalIndex, trimmed);
}
// 名称变化会改变「目标」行的文本宽度 ⇒ 几何缓存作废。
m_targetGeomValid = false;
break;
}
if (row >= static_cast<int>(m_rows.size()))
{
break;
}
const ListRow& item = m_rows[static_cast<std::size_t>(row)];
m_dataSetWritable->RenameSignal(item.groupIndex, item.signalIndex, trimmed);
break;
}
case StyleEditField::GROUP_TITLE:
{
if (row < 0 || row >= static_cast<int>(m_rows.size()) || m_dataSetWritable == nullptr)
{
break;
}
m_dataSetWritable->RenameGroup(m_rows[static_cast<std::size_t>(row)].groupIndex, trimmed);
break;
}
default:
break;
}
return true;
}
bool SignalPanel::OnEditChar(wchar_t character)
{
// 「筛选信号」输入框:字符实时进入筛选(无需回车即可看到结果变化)。
if (m_filterEditing)
{
switch (character)
{
case 0x03: // Ctrl+C
m_filterEdit.CopyToClipboard(m_host);
return false;
case 0x18: // Ctrl+X
m_filterEdit.CutToClipboard(m_host);
ApplyFilterText(m_filterEdit.Text());
return true;
case 0x16: // Ctrl+V
m_filterEdit.PasteFromClipboard(m_host);
ApplyFilterText(m_filterEdit.Text());
return true;
case 0x01: // Ctrl+A
m_filterEdit.SelectAll();
return true;
default:
break;
}
if (character == L'\r' || character == L'\n')
{
return EndFilterEdit();
}
// 退格/删除已由 WM_KEYDOWN 的 OnEditKey 处理,这里忽略控制字符避免重复删除。
if (character < 0x20 || character == 0x7F)
{
return false;
}
if (!m_filterEdit.Insert(character))
{
return false;
}
ApplyFilterText(m_filterEdit.Text());
return true;
}
if (m_editField == StyleEditField::NONE)
{
return false;
}
// 剪贴板与全选快捷键经 WM_CHAR 送达(Ctrl+C/X/V/A 分别对应 3/24/22/1)。
switch (character)
{
case 0x03: // Ctrl+C
m_edit.CopyToClipboard(m_host);
return false;
case 0x18: // Ctrl+X
return m_edit.CutToClipboard(m_host);
case 0x16: // Ctrl+V
return m_edit.PasteFromClipboard(m_host);
case 0x01: // Ctrl+A
m_edit.SelectAll();
return true;
default:
break;
}
if (character == L'\r' || character == L'\n')
{
return EndEdit(true);
}
// 退格/删除已由 WM_KEYDOWN 的 OnEditKey 处理:这里必须忽略控制字符,
// 否则同一次按键会被处理两次(一次删除两个字符)。
if (character < 0x20 || character == 0x7F)
{
return false;
}
// 百分比字段允许直接输入 % 但会被忽略,其余控制字符一律丢弃。
if (m_editField == StyleEditField::OPACITY && character == L'%')
{
return false;
}
return m_edit.Insert(character);
}
bool SignalPanel::OnEditKey(WPARAM key)
{
// 「筛选信号」输入框的键盘处理(回车结束编辑,Esc 清空并结束)。
if (m_filterEditing)
{
const bool shift = (GetKeyState(VK_SHIFT) & 0x8000) != 0;
const bool control = (GetKeyState(VK_CONTROL) & 0x8000) != 0;
switch (key)
{
case VK_RETURN:
return EndFilterEdit();
case VK_ESCAPE:
{
const bool changed = m_filterEdit.Text() != m_filterText;
EndFilterEdit();
return changed;
}
case VK_BACK:
{
if (!m_filterEdit.Backspace())
{
return false;
}
ApplyFilterText(m_filterEdit.Text());
return true;
}
case VK_DELETE:
{
if (!m_filterEdit.DeleteForward())
{
return false;
}
ApplyFilterText(m_filterEdit.Text());
return true;
}
case VK_LEFT:
return m_filterEdit.MoveCaret(-1, shift);
case VK_RIGHT:
return m_filterEdit.MoveCaret(1, shift);
case VK_HOME:
return m_filterEdit.MoveHome(shift);
case VK_END:
return m_filterEdit.MoveEnd(shift);
case 'A':
if (control)
{
m_filterEdit.SelectAll();
return true;
}
break;
default:
break;
}
return true;
}
if (m_editField == StyleEditField::NONE)
{
return false;
}
const bool shift = (GetKeyState(VK_SHIFT) & 0x8000) != 0;
const bool control = (GetKeyState(VK_CONTROL) & 0x8000) != 0;
switch (key)
{
case VK_RETURN:
return EndEdit(true);
case VK_ESCAPE:
return EndEdit(false);
case VK_BACK:
return m_edit.Backspace();
case VK_DELETE:
return m_edit.DeleteForward();
case VK_LEFT:
return m_edit.MoveCaret(-1, shift);
case VK_RIGHT:
return m_edit.MoveCaret(1, shift);
case VK_HOME:
return m_edit.MoveHome(shift);
case VK_END:
return m_edit.MoveEnd(shift);
case 'A':
if (control)
{
m_edit.SelectAll();
return true;
}
break;
default:
break;
}
return true;
}
bool SignalPanel::CollapseEditSelectionToEnd()
{
if (m_filterEditing)
{
return m_filterEdit.DeselectToEnd();
}
if (m_editField == StyleEditField::NONE)
{
return false;
}
return m_edit.DeselectToEnd();
}
bool SignalPanel::OnEditMouseDown(int x, int y)
{
if (m_filterEditing)
{
// 编辑态下 ✕ 位于输入框内容区之内,必须优先于插入符定位处理,
// 否则点击会被当成"把光标移到此处"而清空不了内容。
// 与其它 ✕ 一致:按下只记录目标,抬起时仍在 ✕ 上才真正清空。
const gd::RectF clearRect = FilterClearRect();
if (clearRect.Width > 0.0f &&
UiPointInRect(clearRect, static_cast<float>(x), static_cast<float>(y)))
{
m_pendingFilterClear = true;
return true;
}
return UiEditFieldMouseDown(m_fonts->uiSmall, FilterFieldRect(), m_filterEdit, m_scale, x, y);
}
if (m_editField == StyleEditField::NONE)
{
return false;
}
// ⚠️⚠️ **编辑态下,按在被编辑的那一行名称区域内(含输入框上下各 3px 的行内边距)
// 都必须当作“文本操作”**,不能穿透成行点击。否则:容器行高 > 输入框高度,
// 鼠标起点只要落在内边距上就会走 `OnLButtonDown` 的分组分支 ⇒ **分组被展开/收起**
//(用户 2026-10-19 报告:“框选编辑文本时组会展开、收起”)。
// 横向仍限定在名称区域内:同一行的复选框 / 「N 项」/ GX 徽标 / ✕ 继续可点。
const gd::RectF field = EditFieldRect();
if (field.Width > 0.0f)
{
const gd::RectF absorb = NameEditAbsorbRect(field);
if (UiPointInRect(absorb, static_cast<float>(x), static_cast<float>(y)) &&
!UiPointInRect(field, static_cast<float>(x), static_cast<float>(y)))
{
// 把落点夹回输入框,保证插入符换算与绘制使用同一套几何
//(`UiEditFieldMouseDown` 自己会再算一次文本起点)。
const int clampedX = static_cast<int>(std::lround(
std::max(field.X + 1.0f, std::min(static_cast<float>(x), field.X + field.Width - 2.0f))));
const int clampedY = static_cast<int>(std::lround(
std::max(field.Y + 1.0f, std::min(static_cast<float>(y), field.Y + field.Height - 2.0f))));
return UiEditFieldMouseDown(EditFieldFont(), field, m_edit, m_scale, clampedX, clampedY);
}
}
return UiEditFieldMouseDown(EditFieldFont(), field, m_edit, m_scale, x, y);
}
bool SignalPanel::OnEditMouseMove(int x)
{
if (m_filterEditing)
{
return UiEditFieldMouseMove(m_fonts->uiSmall, FilterFieldRect(), m_filterEdit, m_scale, x);
}
if (m_editField == StyleEditField::NONE)
{
return false;
}
return UiEditFieldMouseMove(EditFieldFont(), EditFieldRect(), m_edit, m_scale, x);
}
bool SignalPanel::OnEditMouseUp(int x, int y)
{
if (m_filterEditing)
{
// 筛选框右端的清空 ✕ 在编辑态下的**按下**走 OnEditMouseDown(优先于插入符定位),
// 抬起同样由宿主送进 EDIT_FIELD 分支,因此确认逻辑要在这里结算:
// 只有抬起时指针仍在 ✕ 上才真正清空,移开后松开视为放弃。
if (m_pendingFilterClear)
{
m_pendingFilterClear = false;
const gd::RectF clearRect = FilterClearRect();
if (clearRect.Width > 0.0f &&
UiPointInRect(clearRect, static_cast<float>(x), static_cast<float>(y)))
{
ClearFilter();
return true;
}
}
if (!m_filterEdit.Dragging())
{
return false;
}
m_filterEdit.EndDrag();
return true;
}
if (!m_edit.Dragging())
{
return false;
}
m_edit.EndDrag();
return true;
}
void SignalPanel::SelectSignal(const SignalRef& ref)
{
if (m_dataSetWritable != nullptr)
{
m_dataSetWritable->SetSelected(ref);
}
}
bool SignalPanel::ApplyColor(std::uint32_t color)
{
// 同 `ApplyWidthFromX`:只经 `StyleOf()` 使用 `m_dataSetWritable`。
if (m_dataSetWritable == nullptr)
{
return false;
}
SignalStyle* style = StyleOf(SelectedRef());
if (style == nullptr)
{
return false;
}
// 同时记住最后一次自定义取值,便于切换到预设色后一键回到该颜色。
const bool changed = (style->color != color) || (style->customColor != color);
style->color = color;
style->customColor = color;
return changed;
}
bool SignalPanel::ApplyOpacityFromX(float x)
{
// 同 `ApplyWidthFromX`:只经 `StyleOf()` 使用 `m_dataSetWritable`。
if (m_dataSetWritable == nullptr)
{
return false;
}
SignalStyle* style = StyleOf(SelectedRef());
if (style == nullptr)
{
return false;
}
const float value = static_cast<float>(UiSliderRatioFromX(m_opacityTrackRect, x));
if (std::fabs(value - style->opacity) < 0.005f)
{
return false;
}
style->opacity = value;
return true;
}
bool SignalPanel::OpenGainMenu()
{
if (m_menu == nullptr || m_tab == nullptr)
{
return false;
}
const SignalStyle* style = StyleOf(SelectedRef());
if (style == nullptr)
{
return false;
}
// 二级面板:两张“标题(带还原图标)+ 输入框”的组合:
// 1. 「系数调整」:一整行系数输入框;
// 2. 「偏移调整」:下方两行分别为「Y轴」与「时间轴」的偏移输入框。
//(与网格 Y 轴上下限的菜单内联编辑同一套做法:提交后面板保持打开,可连续微调。)
std::vector<MenuItem> items;
MenuItem gainHeader;
gainHeader.kind = MenuItemKind::HEADER_ACTION;
gainHeader.label = L"系数调整";
gainHeader.id = CMD_GAIN_RESET; // 点击标题栏右侧的还原图标 → 系数重置为 1
items.push_back(gainHeader);
// 非编辑态带正号(用户明确要求);进入编辑后的初始文本用不带符号的纯数值。
const std::wstring gainText = FormatGainText(style->gain);
const std::wstring gainEditText = FormatNumberText(static_cast<double>(style->gain));
MenuItem gainEdit;
gainEdit.kind = MenuItemKind::EDIT;
gainEdit.label.clear(); // 不显示说明文字:输入框独占整行
gainEdit.id = CMD_GAIN_EDIT;
gainEdit.value = gainText;
gainEdit.editValue = gainEditText;
gainEdit.filter = EditFilter::NUMBER;
items.push_back(gainEdit);
MenuItem offsetHeader;
offsetHeader.kind = MenuItemKind::HEADER_ACTION;
offsetHeader.label = L"偏移调整";
offsetHeader.id = CMD_OFFSET_RESET; // 点击还原图标 → 两个偏移都恢复默认值
items.push_back(offsetHeader);
// 「Y轴」:对绘制值加上一个常数偏移(波形上下移动),非编辑态带正负号。
MenuItem offsetY;
offsetY.kind = MenuItemKind::EDIT;
offsetY.label = L"Y轴";
offsetY.id = CMD_OFFSET_Y;
offsetY.value = FormatSignedNumber(static_cast<double>(style->offsetY));
offsetY.editValue = FormatNumberText(static_cast<double>(style->offsetY));
offsetY.filter = EditFilter::NUMBER;
items.push_back(offsetY);
// 「时间轴」:把采样沿时间轴平移(画在 t + offsetTime)。
// 非编辑态显示与双光标差值同一套复合单位文本,并**带正负号**(用户明确要求);
// 编辑态显示以秒为单位的数值,并在输入框最右侧提示单位 `s`。
MenuItem offsetTime;
offsetTime.kind = MenuItemKind::EDIT;
offsetTime.label = L"时间轴";
offsetTime.id = CMD_OFFSET_TIME;
offsetTime.value = FormatOffsetTimeText(style->offsetTime);
offsetTime.editValue = FormatNumberText(style->offsetTime);
offsetTime.editSuffix = L"s";
offsetTime.filter = EditFilter::NUMBER;
items.push_back(offsetTime);
// 锚定在按钮**左上角的上方一点**:二级面板**向上展开**,其左下角对准按钮左上角,
// 并再往上让出 `kAdjustMenuGap`(用户明确要求)。
//(旧实现在按钮下沿向右下展开,面板会盖住下方内容。)
const gd::RectF& button = m_adjustRects[kAdjustGain];
const gd::RectF bounds = (m_clientBounds.Width > 1.0f) ? m_clientBounds : m_area;
const int anchorY =
static_cast<int>(std::lround(button.Y - kAdjustMenuGap * m_scale));
m_menu->Open(MenuScope::SIGNAL_PANEL, bounds, static_cast<int>(button.X), anchorY, items,
true);
// ⚠️ 菜单一旦打开就会接管鼠标事件,样式区不会再收到 WM_MOUSEMOVE →
// 必须在这里主动清掉按钮的悬停态,否则按钮会"像鼠标一直按着"一样持续高亮。
ClearStyleHover();
return true;
}
bool SignalPanel::OpenMirrorMenu()
{
if (m_menu == nullptr || m_tab == nullptr)
{
return false;
}
const SignalStyle* style = StyleOf(SelectedRef());
if (style == nullptr)
{
return false;
}
// 两行带复选框的选项:点击即切换、且**保持菜单打开**(可连续勾选两项)。
std::vector<MenuItem> items;
MenuItem horizontal;
horizontal.kind = MenuItemKind::NORMAL;
horizontal.label = L"水平镜像";
horizontal.id = CMD_MIRROR_HORIZONTAL;
horizontal.checked = style->mirrorHorizontal;
horizontal.keepOpen = true;
items.push_back(horizontal);
MenuItem vertical;
vertical.kind = MenuItemKind::NORMAL;
vertical.label = L"垂直镜像";
vertical.id = CMD_MIRROR_VERTICAL;
vertical.checked = style->mirrorVertical;
vertical.keepOpen = true;
items.push_back(vertical);
const gd::RectF& button = m_adjustRects[kAdjustMirror];
const gd::RectF bounds = (m_clientBounds.Width > 1.0f) ? m_clientBounds : m_area;
// 与「系数/偏移」面板同一套锛定:向上展开,左下角 = 按钮左上角再上移一点。
const int anchorY =
static_cast<int>(std::lround(button.Y - kAdjustMenuGap * m_scale));
m_menu->Open(MenuScope::SIGNAL_PANEL, bounds, static_cast<int>(button.X), anchorY, items,
true);
ClearStyleHover();
return true;
}
void SignalPanel::ClearStyleHover()
{
if (m_hoverStyle == StyleHit::NONE && m_hoverStyleIndex < 0)
{
return;
}
m_hoverStyle = StyleHit::NONE;
m_hoverStyleIndex = -1;
}
bool SignalPanel::CloseTransientUi()
{
bool changed = false;
if (m_editField != StyleEditField::NONE)
{
changed = EndEdit(true) || changed;
}
// 点击面板之外也要结束「筛选信号」的编辑状态。
if (m_filterEditing)
{
changed = EndFilterEdit() || changed;
}
if (m_colorPicker != nullptr && m_colorPicker->IsOpen())
{
m_colorPicker->Close();
changed = true;
}
// 面板外点击:取消尚未抬起确认的关闭操作。
m_pendingClosePart = RowPart::NONE;
m_pendingCloseRow = -1;
m_pendingFilterClear = false;
m_pendingGainMenu = false;
m_pendingMirrorMenu = false;
m_pendingTargetLaneMenu = false;
return changed;
}
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;
}
void SignalPanel::OpenSignalMenu(const SignalRef& ref, int x, int y)
{
if (m_menu == nullptr || m_tab == nullptr || m_dataSet == nullptr)
{
return;
}
const SignalStyle* style = StyleOf(ref);
const SignalData* signal = m_dataSet->Resolve(ref);
if (style == nullptr || signal == nullptr)
{
return;
}
m_menuTargetSignal = ref;
m_menuTargetGroup = -1;
std::vector<MenuItem> items;
MenuItem header;
header.kind = MenuItemKind::HEADER;
header.label = signal->name;
items.push_back(header);
MenuItem toggle;
toggle.label = m_tab->IsVisible(ref) ? L"隐藏该信号" : L"显示该信号";
toggle.id = CMD_SIGNAL_TOGGLE;
items.push_back(toggle);
MenuItem solo;
solo.label = L"仅显示该信号";
solo.id = CMD_SIGNAL_SOLO;
items.push_back(solo);
MenuItem showAll;
showAll.label = L"显示全部信号";
showAll.id = CMD_SIGNAL_SHOW_ALL;
items.push_back(showAll);
MenuItem hideAll;
hideAll.label = L"隐藏全部信号";
hideAll.id = CMD_SIGNAL_HIDE_ALL;
items.push_back(hideAll);
MenuItem separator;
separator.kind = MenuItemKind::SEPARATOR;
items.push_back(separator);
int paletteCount = 0;
const std::uint32_t* palette = SignalPalette(paletteCount);
MenuItem colors;
colors.kind = MenuItemKind::SWATCHES;
colors.id = CMD_COLOR_BASE;
colors.swatchCount = std::min(paletteCount, 16);
colors.checkedSwatch = -1;
for (int index = 0; index < colors.swatchCount; ++index)
{
colors.swatches[index] = palette[index];
if (style->color == palette[index])
{
colors.checkedSwatch = index;
}
}
items.push_back(colors);
MenuItem modeHeader;
modeHeader.kind = MenuItemKind::HEADER;
modeHeader.label = L"样式";
items.push_back(modeHeader);
for (int index = 0; index < kModeOptionCount; ++index)
{
MenuItem mode;
mode.label = DrawModeName(kModeOptions[index]);
mode.id = CMD_MODE_BASE + index;
mode.checked = (style->mode == kModeOptions[index]);
items.push_back(mode);
}
const gd::RectF bounds = (m_clientBounds.Width > 1.0f) ? m_clientBounds : m_area;
m_menu->Open(MenuScope::SIGNAL_PANEL, bounds, x, y, items);
}
void SignalPanel::OpenGroupMenu(int groupIndex, int x, int y)
{
if (m_menu == nullptr || m_tab == nullptr || m_dataSet == nullptr)
{
return;
}
const SignalGroup* group = m_dataSet->GroupAt(groupIndex);
if (group == nullptr)
{
return;
}
m_menuTargetGroup = groupIndex;
m_menuTargetSignal = SignalRef();
std::vector<MenuItem> items;
MenuItem header;
header.kind = MenuItemKind::HEADER;
header.label = group->name;
items.push_back(header);
MenuItem showAll;
showAll.label = L"选中该分组全部信号";
showAll.id = CMD_GROUP_SHOW_ALL;
items.push_back(showAll);
MenuItem hideAll;
hideAll.label = L"取消该分组全部信号";
hideAll.id = CMD_GROUP_HIDE_ALL;
items.push_back(hideAll);
MenuItem separator;
separator.kind = MenuItemKind::SEPARATOR;
items.push_back(separator);
MenuItem remove;
remove.label = L"关闭该分组(从工作区移除)";
remove.id = CMD_GROUP_REMOVE;
items.push_back(remove);
const gd::RectF bounds = (m_clientBounds.Width > 1.0f) ? m_clientBounds : m_area;
m_menu->Open(MenuScope::SIGNAL_PANEL, bounds, x, y, items);
}
void SignalPanel::OpenLaneMenu(const SignalRef& ref, int x, int y)
{
if (m_menu == nullptr || m_tab == nullptr || m_dataSet == nullptr)
{
return;
}
const SignalData* signal = m_dataSet->Resolve(ref);
if (signal == nullptr)
{
return;
}
m_menuTargetSignal = ref;
m_menuTargetGroup = -1;
std::vector<MenuItem> items;
MenuItem header;
header.kind = MenuItemKind::HEADER;
header.label = L"所属网格";
items.push_back(header);
const std::vector<Lane>& lanes = m_tab->Lanes();
const int currentLane = m_tab->LaneOfSignal(ref);
for (std::size_t index = 0; index < lanes.size(); ++index)
{
MenuItem lane;
lane.label = L"网格 " + std::to_wstring(index + 1) + L"(" +
std::to_wstring(lanes[index].signals.size()) + L" 条信号)";
lane.id = CMD_LANE_BASE + static_cast<int>(index);
lane.checked = (currentLane == static_cast<int>(index));
items.push_back(lane);
}
MenuItem separator;
separator.kind = MenuItemKind::SEPARATOR;
items.push_back(separator);
MenuItem create;
create.label = L"新建网格";
create.id = CMD_LANE_NEW;
items.push_back(create);
const gd::RectF bounds = (m_clientBounds.Width > 1.0f) ? m_clientBounds : m_area;
// ⚠️ 网格可能是**上百个**(例如把 MF4 的全部信号各自放进独立网格):
// 不限制高度时菜单会长到屏幕之外、且逐条绘制上百行会明显卡顿。
// 这里限制可滚动条目区**最多显示 10 行**(用户 2026-10-16 要求):
// 超出部分用滚轮 / 右侧滚动条翻动,而顶部「所属网格」标题与底部的
// 「新建网格」**始终固定不动**。
MenuLayoutConstraint constraint;
constraint.maxScrollRows = kLaneMenuVisibleRows;
// 额外加一道硬上限:窗口很矮时也不得超出客户区(与行数上限取更严格者)。
constraint.maxHeight = std::max(180.0f, bounds.Height * 0.8f);
m_menu->Open(MenuScope::SIGNAL_PANEL, bounds, x, y, items, false, constraint);
}
bool SignalPanel::OnMenuCommand(const MenuResult& result)
{
if (m_tab == nullptr || m_dataSet == nullptr || !result.valid)
{
return false;
}
// 「系数」/「偏移」二级面板里的输入框提交:就地更新,面板保持打开
//(与网格 Y 轴上下限的菜单内联编辑一致),并把夹取后的真实值回写到输入框上。
if (result.editCommit)
{
SignalStyle* style = StyleOf(SelectedRef());
if (style != nullptr)
{
const std::wstring trimmed = Trim(result.editText);
wchar_t* stop = nullptr;
const double parsed =
trimmed.empty() ? 0.0 : std::wcstod(trimmed.c_str(), &stop);
// 非法/空输入保持原值(只把真实值回写到输入框,避免用户看到错误的数字)。
const bool valid = !trimmed.empty() && stop != trimmed.c_str() && std::isfinite(parsed);
switch (result.id)
{
case CMD_GAIN_EDIT:
{
if (valid)
{
style->gain =
static_cast<float>(std::max(-kGainLimit, std::min(parsed, kGainLimit)));
}
if (m_menu != nullptr)
{
m_menu->UpdateItemValue(CMD_GAIN_EDIT, FormatGainText(style->gain),
FormatNumberText(static_cast<double>(style->gain)));
}
return false;
}
case CMD_OFFSET_Y:
{
if (valid)
{
style->offsetY = static_cast<float>(
std::max(-static_cast<double>(kGainLimit), std::min(parsed, kGainLimit)));
}
if (m_menu != nullptr)
{
m_menu->UpdateItemValue(
CMD_OFFSET_Y, FormatSignedNumber(static_cast<double>(style->offsetY)),
FormatNumberText(static_cast<double>(style->offsetY)));
}
return false;
}
case CMD_OFFSET_TIME:
{
if (valid)
{
style->offsetTime =
std::max(-kGainLimit, std::min(parsed, kGainLimit));
}
if (m_menu != nullptr)
{
m_menu->UpdateItemValue(CMD_OFFSET_TIME,
FormatOffsetTimeText(style->offsetTime),
FormatNumberText(style->offsetTime));
}
return false;
}
default:
break;
}
}
else
{
return false;
}
}
// 标题栏右侧的「还原」图标:系数重置为默认值 1(面板保持打开,输入框立即回显)。
if (result.id == CMD_GAIN_RESET)
{
if (SignalStyle* style = StyleOf(SelectedRef()))
{
style->gain = 1.0f;
if (m_menu != nullptr)
{
m_menu->UpdateItemValue(CMD_GAIN_EDIT, FormatGainText(style->gain),
FormatNumberText(static_cast<double>(style->gain)));
}
}
return false;
}
// 「偏移调整」的「还原」图标:Y 与时间偏移都恢复为默认值 0。
if (result.id == CMD_OFFSET_RESET)
{
if (SignalStyle* style = StyleOf(SelectedRef()))
{
style->offsetY = 0.0f;
style->offsetTime = 0.0;
if (m_menu != nullptr)
{
m_menu->UpdateItemValue(CMD_OFFSET_Y, FormatSignedNumber(0.0),
FormatNumberText(0.0));
m_menu->UpdateItemValue(CMD_OFFSET_TIME, FormatOffsetTimeText(0.0),
FormatNumberText(0.0));
}
}
return false;
}
// 「镜像」二级面板:两行带复选框的切换项(面板保持打开,勾选状态就地回写)。
if (result.id == CMD_MIRROR_HORIZONTAL || result.id == CMD_MIRROR_VERTICAL)
{
if (SignalStyle* style = StyleOf(SelectedRef()))
{
const bool horizontal = (result.id == CMD_MIRROR_HORIZONTAL);
bool& flag = horizontal ? style->mirrorHorizontal : style->mirrorVertical;
flag = !flag;
if (!horizontal)
{
// ⚠️ Y 偏移以**未镜像空间**为准:垂直镜像开关变化时同步取反偏移量,
// 使绘制等价于「先加上偏移再镜像」(用户明确要求)。
style->offsetY = -style->offsetY;
if (m_menu != nullptr)
{
m_menu->UpdateItemValue(
CMD_OFFSET_Y, FormatSignedNumber(static_cast<double>(style->offsetY)),
FormatNumberText(static_cast<double>(style->offsetY)));
}
}
if (m_menu != nullptr)
{
m_menu->UpdateItemLabel(result.id, horizontal ? L"水平镜像" : L"垂直镜像", flag, true);
}
}
return false;
}
bool needRelayout = false;
switch (result.id)
{
case CMD_SIGNAL_TOGGLE:
if (m_menuTargetSignal.IsValid())
{
m_tab->ToggleVisible(m_menuTargetSignal);
m_rowsDirty = true;
needRelayout = true;
}
break;
case CMD_SIGNAL_SOLO:
if (m_menuTargetSignal.IsValid())
{
m_tab->SoloSignal(m_menuTargetSignal);
m_rowsDirty = true;
needRelayout = true;
}
break;
case CMD_SIGNAL_SHOW_ALL:
m_tab->SetAllVisible(true);
m_rowsDirty = true;
needRelayout = true;
break;
case CMD_SIGNAL_HIDE_ALL:
m_tab->SetAllVisible(false);
m_rowsDirty = true;
needRelayout = true;
break;
case CMD_GROUP_SHOW_ALL:
if (m_menuTargetGroup >= 0)
{
m_tab->SetGroupVisible(m_menuTargetGroup, true);
m_rowsDirty = true;
needRelayout = true;
}
break;
case CMD_GROUP_HIDE_ALL:
if (m_menuTargetGroup >= 0)
{
m_tab->SetGroupVisible(m_menuTargetGroup, false);
m_rowsDirty = true;
needRelayout = true;
}
break;
case CMD_GROUP_REMOVE:
if (m_menuTargetGroup >= 0 && m_host != nullptr)
{
PostMessageW(m_host, WM_APP + 1, static_cast<WPARAM>(m_menuTargetGroup), 0);
}
break;
case CMD_LANE_NEW:
if (m_menuTargetSignal.IsValid())
{
m_tab->MoveSignalToLane(*m_dataSet, m_menuTargetSignal, -1);
m_rowsDirty = true;
needRelayout = true;
}
break;
default:
if (result.id >= CMD_LANE_BASE && result.id < CMD_LANE_NEW)
{
if (m_menuTargetSignal.IsValid())
{
m_tab->MoveSignalToLane(*m_dataSet, m_menuTargetSignal, result.id - CMD_LANE_BASE);
m_rowsDirty = true;
needRelayout = true;
}
}
else if (result.id == CMD_COLOR_BASE && result.swatchIndex >= 0)
{
if (SignalStyle* style = StyleOf(m_menuTargetSignal))
{
int paletteCount = 0;
const std::uint32_t* palette = SignalPalette(paletteCount);
style->color = palette[result.swatchIndex % std::max(1, paletteCount)];
}
}
else if (result.id >= CMD_MODE_BASE && result.id < CMD_MODE_BASE + kModeOptionCount)
{
if (SignalStyle* style = StyleOf(m_menuTargetSignal))
{
style->mode = kModeOptions[result.id - CMD_MODE_BASE];
}
}
break;
}
return needRelayout;
}
} // namespace jdi