按「性能 / 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 条硬约束 与文末本轮小节(含体积评估数据)。
2330 lines
72 KiB
C++
2330 lines
72 KiB
C++
// 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
|
||
|