diff --git a/Components/Application/Inc/Application.hpp b/Components/Application/Inc/Application.hpp index d12bd1d..a0cc7ff 100644 --- a/Components/Application/Inc/Application.hpp +++ b/Components/Application/Inc/Application.hpp @@ -254,6 +254,17 @@ namespace jdi */ bool ApplyPendingDerivedBuild(); + /** + * @brief 应用绘图区提交的「把信号列表滚动到目标信号」的联动请求。 + * + * 用户 2026-10-15 要求:在绘图区触发联动(点击图例 / 点击信号曲线等把某条信号选为 + * 当前目标)时,信号选择区域要**平滑滚动**到该信号并完整显示它 + *(信号很多时否则找不见选中的那一条)。 + * + * @return 存在并应用了请求返回 true。 + */ + bool ApplyPendingPanelScroll(); + /** * @brief 派生信号构建请求(多次迭代均值滤波 / 弱峰调整共用同一套插入流程)。 */ @@ -400,6 +411,8 @@ namespace jdi Target m_activeTarget = Target::NONE; /**< 当前鼠标作用目标。 */ bool m_trackingLeave = false; /**< 是否已注册鼠标离开通知。 */ bool m_hintTimerActive = false; /**< 提示浮层淡出定时器是否已启动。 */ + /** 信号列表「滚动到目标信号」动画的定时器是否已启动(见 `SignalPanel::AdvanceScrollAnimation`)。 */ + bool m_scrollTimerActive = false; std::wstring m_windowTitleText; /**< 上次写入窗口标题的完整文本(用于避免重复设置)。 */ int m_tabMenuIndex = -1; /**< 标签栏右键菜单的目标标签页下标。 */ int m_plotMenuLane = -1; /**< 绘图区右键菜单的目标网格下标。 */ diff --git a/Components/Application/Src/Application.cpp b/Components/Application/Src/Application.cpp index d3de333..f3bacf7 100644 --- a/Components/Application/Src/Application.cpp +++ b/Components/Application/Src/Application.cpp @@ -49,6 +49,15 @@ namespace jdi const UINT kHintTimerPeriodMs = 33; /**< 提示浮层淡出的重绘间隔(约 30fps)。 */ const UINT_PTR kRamTimerId = 2; /**< 标题栏内存占用刷新的定时器 ID。 */ const UINT kRamTimerPeriodMs = 1000; /**< 标题栏内存占用的刷新间隔(1 秒)。 */ + /** + * @brief 信号列表「滚动到目标信号」动画的定时器 ID。 + * + * 用户 2026-10-15 要求:联动时列表要**平滑滚动**(不是立刻翻到目标位置), + * 且"整个动画过程不要太久"。动画本身由 `SignalPanel::AdvanceScrollAnimation()` + * 按经过时间推进,这里只负责按帧驱动它(约 60fps)。 + */ + const UINT_PTR kScrollTimerId = 3; + const UINT kScrollTimerPeriodMs = 16; /** * @brief 查询本进程当前占用的内存(**私有工作集**,与任务管理器"内存"列同口径)。 @@ -889,6 +898,12 @@ namespace jdi g.SetPixelOffsetMode(gd::PixelOffsetModeHalf); g.SetTextRenderingHint(gd::TextRenderingHintClearTypeGridFit); + // 信号列表 / 样式区「目标」行的悬停 → 绘图区联动高亮(网格变色 + 该信号曲线高亮)。 + // 用户 2026-10-15 要求;鼠标在网格里移动**不再**触发网格变色高亮。 + // ⚠️ 每帧同步(而不是只在 WM_MOUSEMOVE 里同步)才能覆盖全部改变悬停的路径: + // 鼠标离开窗口(WM_MOUSELEAVE)、数据/行结构变化、切换标签页等。 + m_plot.SetExternalHoverSignal(m_panel.HoveredSignal()); + if (chromeDirty) { UiFillRect(g, gd::RectF(0.0f, 0.0f, static_cast(width), static_cast(height)), @@ -943,6 +958,21 @@ namespace jdi KillTimer(m_hwnd, kHintTimerId); m_hintTimerActive = false; } + + // 信号列表的目标信号滚动动画:仅在动画进行中保持定时重绘。 + if (m_panel.ScrollAnimationActive()) + { + if (!m_scrollTimerActive) + { + SetTimer(m_hwnd, kScrollTimerId, kScrollTimerPeriodMs, nullptr); + m_scrollTimerActive = true; + } + } + else if (m_scrollTimerActive) + { + KillTimer(m_hwnd, kScrollTimerId); + m_scrollTimerActive = false; + } } void Application::SetStatusMessage(const std::wstring& text, bool isError) @@ -1777,6 +1807,18 @@ namespace jdi return true; } + bool Application::ApplyPendingPanelScroll() + { + SignalRef ref; + if (!m_plot.TakePanelScrollRequest(ref) || !ref.IsValid()) + { + return false; + } + // 平滑滚动(带动画):用户要求"是滚动的而不是立刻翻到目标信号位置"。 + m_panel.ScrollToSignalAnimated(ref); + return true; + } + bool Application::ApplyPendingDerivedBuild() { // 两类「构建为新信号」请求(均值滤波 / 弱峰调整)机制完全一致, @@ -2640,6 +2682,8 @@ namespace jdi ApplyPendingDerivedBuild(); InvalidateRect(hwnd, nullptr, FALSE); } + // 联动:在绘图区选中了某条信号 ⇒ 让信号列表平滑滚动到它并完整显示。 + ApplyPendingPanelScroll(); // 拖动期间 Windows 不再派发 WM_SETCURSOR,需在按下后立即刷新一次, // 使"按下但尚未拖动"时能立刻看到十字/方向箭头提示。 UpdateDragCursor(x, y); @@ -2791,6 +2835,8 @@ namespace jdi { InvalidateRect(hwnd, nullptr, TRUE); } + // 联动:双击图例改名也会选中该信号 ⇒ 同步把列表滚动到它。 + ApplyPendingPanelScroll(); return 0; } case WM_RBUTTONDOWN: @@ -3034,6 +3080,13 @@ namespace jdi InvalidateRect(hwnd, nullptr, FALSE); return 0; } + // 信号列表「滚动到目标信号」动画:推进一步并重绘(Render 会在结束后停表)。 + if (wParam == kScrollTimerId) + { + m_panel.AdvanceScrollAnimation(); + InvalidateRect(hwnd, nullptr, FALSE); + return 0; + } break; } case WM_DROPFILES: diff --git a/Components/PlotView/Inc/PlotView.hpp b/Components/PlotView/Inc/PlotView.hpp index 4f919c7..c0d6dbf 100644 --- a/Components/PlotView/Inc/PlotView.hpp +++ b/Components/PlotView/Inc/PlotView.hpp @@ -880,6 +880,39 @@ namespace jdi */ bool TakePendingRename(SignalRef& ref, std::wstring& name); + /** + * @brief 设置「外部悬停信号」(信号列表 / 样式区「目标」行悬停到某条信号时调用)。 + * + * 用户 2026-10-15 要求: + * * 鼠标**在网格里移动不再触发**网格变色高亮(旧行为已取消); + * * 改为**在信号列表里悬停某条信号**时,若该信号已勾选绘制到某个网格, + * 则该网格变色高亮,并且该信号曲线本身也高亮;鼠标移开即恢复。 + * + * ⚠️ 与 `m_hoverSignal`(鼠标在绘图区内直接命中的信号)**分开保存**:绘图区的 + * `OnMouseLeave` 会清掉后者,而列表悬停时鼠标恰恰不在绘图区内。 + * + * @param [IN] ref 悬停的信号;无效引用表示取消外部悬停。 + */ + void SetExternalHoverSignal(const SignalRef& ref); + + /** + * @brief 当前外部悬停的信号(供验证 / 宿主查询)。 + * @return 悬停的信号引用,无则无效。 + */ + const SignalRef& ExternalHoverSignal() const { return m_externalHoverSignal; } + + /** + * @brief 取出并清除「请把信号列表滚动到该信号」的联动请求。 + * + * 用户 2026-10-15 要求:在绘图区触发联动(点击图例 / 点击信号曲线等把某条信号选为 + * 当前目标)时,信号选择区域要**平滑滚动**到该信号并完整显示它 —— + * 信号很多时(如 MF4 的 124 条)否则找不见选中的那一条。 + * + * @param [OUT] ref 需要滚动到的信号。 + * @return 存在待处理的请求返回 true。 + */ + bool TakePanelScrollRequest(SignalRef& ref); + /** * @brief 处理编辑态下的字符输入。 * @param [IN] character 输入字符。 @@ -1974,6 +2007,20 @@ namespace jdi gd::RectF m_rubberRect; /**< 拖拽选区矩形。 */ SignalRef m_hoverSignal; /**< 鼠标悬停命中的信号(曲线高亮)。 */ + /** + * @brief 「外部悬停信号」:信号列表 / 样式区「目标」行悬停到某条信号时的联动高亮源。 + * + * 与 `m_hoverSignal` 分开(绘图区的 `OnMouseLeave` 会清掉后者,而列表悬停时 + * 鼠标并不在绘图区内);二者的高亮效果相同:网格变色 + 曲线光晕。 + */ + SignalRef m_externalHoverSignal; + /** + * @brief 待宿主处理的「把信号列表滚动到该信号」请求(见 `TakePanelScrollRequest`)。 + * + * 绘图区自身无法访问信号面板,因此把请求记在这里,由宿主在鼠标消息处理后取走。 + */ + SignalRef m_pendingPanelScroll; + bool m_pendingPanelScrollFlag = false; SignalRef m_hoverLegend; /**< 鼠标悬停命中的图例条目(半高亮提示,不触发曲线拖拽提示)。 */ SignalRef m_dragSignal; /**< 正在被拖拽的信号。 */ int m_dragTargetLane = -1; /**< 拖拽信号时的目标网格下标。 */ @@ -2035,6 +2082,14 @@ namespace jdi EdgePanelKind m_pendingPanelCloseKind = EdgePanelKind::COUNT; /**< 待关闭面板的功能类型。 */ int m_hoverLane = -1; /**< 当前悬停网格下标。 */ + /** + * @brief 取本次绘制应该高亮的网格下标(-1 表示无)。 + * + * 来源两处:① 鼠标悬停在实验性功能浮动面板上(该面板所属网格); + * ② 外部悬停信号(信号列表 / 样式区)且它已绘制到某个网格时,高亮该网格。 + * ⚠️ 鼠标在网格里移动**不再**触发高亮(用户 2026-10-15 要求取消旧行为)。 + */ + int EffectiveHoverLane() const; /** * @brief 上一帧鼠标是否悬停在某个网格的“GX”编号徽标上(是则记录网格下标,否则 -1)。 * diff --git a/Components/PlotView/Src/PlotView.cpp b/Components/PlotView/Src/PlotView.cpp index e709a0f..6d60e6e 100644 --- a/Components/PlotView/Src/PlotView.cpp +++ b/Components/PlotView/Src/PlotView.cpp @@ -1061,6 +1061,48 @@ namespace jdi { m_dataSetWritable->SetSelected(ref); } + // 联动(用户 2026-10-15 要求):绘图区内把某条信号选为当前目标时, + // 请求宿主把信号列表**平滑滚动**到它,保证在长列表里也能看到它。 + if (ref.IsValid()) + { + m_pendingPanelScroll = ref; + m_pendingPanelScrollFlag = true; + } + } + + bool PlotView::TakePanelScrollRequest(SignalRef& ref) + { + if (!m_pendingPanelScrollFlag) + { + return false; + } + ref = m_pendingPanelScroll; + m_pendingPanelScrollFlag = false; + m_pendingPanelScroll = SignalRef(); + return ref.IsValid(); + } + + void PlotView::SetExternalHoverSignal(const SignalRef& ref) + { + if (m_externalHoverSignal == ref) + { + return; + } + m_externalHoverSignal = ref; + } + + int PlotView::EffectiveHoverLane() const + { + if (m_hoverLane >= 0) + { + return m_hoverLane; + } + // 外部悬停信号(信号列表 / 样式区「目标」行):只在该信号确实绘制到某个网格时生效。 + if (m_tab != nullptr && m_externalHoverSignal.IsValid()) + { + return m_tab->LaneOfSignal(m_externalHoverSignal); + } + return -1; } void PlotView::ClampLaneScroll() @@ -6080,8 +6122,11 @@ namespace jdi changed = true; } - // 悬停网格:鼠标在面板上时高亮面板所属网格(用户明确要求)。 - const int lane = overEdgePanel ? panelLane : LaneAtY(y); + // 悬停网格:**只有**鼠标悬停在实验性功能浮动面板上时才高亮该面板所属网格。 + // ⚠️ 用户 2026-10-15 明确要求:鼠标在网格里移动**不再触发**网格变色高亮; + // 改为在信号列表 / 样式区「目标」行悬停到某条已绘制信号时联动高亮 + // (见 `SetExternalHoverSignal` 与 `EffectiveHoverLane`)。 + const int lane = overEdgePanel ? panelLane : -1; if (lane != m_hoverLane) { m_hoverLane = lane; @@ -6089,16 +6134,20 @@ namespace jdi } // 「GX」编号徽标的悬停高亮:徽标本体在静态场景 / 装饰缓存里被冻结,高亮是叠加层, - // 因此必须靠这个**单独的状态**触发重绘与取消(鼠标在同一个网格的徽标范围内移动时 - // `m_hoverLane` 不变)。 + // 因此必须靠这个**单独的状态**触发重绘与取消。徽标命中的网格直接用鼠标纵坐标算, + // 与上面的「悬停网格高亮」解耦(后者已不再跟随鼠标位置)。 int hoverBadge = -1; - if (m_dragMode == PlotDragMode::NONE && !overEdgePanel && lane >= 0) + if (m_dragMode == PlotDragMode::NONE && !overEdgePanel) { - const gd::RectF badge = LaneBadgeRect(lane); - if (badge.Width > 0.0f && - UiPointInRect(badge, static_cast(x), static_cast(y))) + const int underMouse = LaneAtY(y); + if (underMouse >= 0) { - hoverBadge = lane; + const gd::RectF badge = LaneBadgeRect(underMouse); + if (badge.Width > 0.0f && + UiPointInRect(badge, static_cast(x), static_cast(y))) + { + hoverBadge = underMouse; + } } } if (hoverBadge != m_hoverBadge) @@ -6826,6 +6875,19 @@ namespace jdi return false; } + // ⚠️ **上层元素的交互绝不能穿透到下层**:实验性功能的浮动面板浮在网格之上(且绘制在 + // 光标标签 / 图例之上),因此在它上面双击必须由它消费掉。旧实现在所有下层命中都不成 + // 立时无条件 `FitAll()`,于是"在面板上双击任意位置"会让网格莫名其妙地自适应一次 + // (用户 2026-10-15 报告)。面板自身双击没有额外语义,直接消费即可。 + { + int panelLane = -1; + int panelKind = 0; + if (TopPanelAtPoint(static_cast(x), static_cast(y), panelLane, panelKind)) + { + return true; + } + } + // 双光标差值标签:双击进入差值编辑(输入值的量级始终为秒)。 // 注意:差值标签在"两光标过近"时会落到主时间轴内部,因此必须先于区域判定。 if (IsOnCursorSpanLabel(x, y)) @@ -7835,9 +7897,12 @@ namespace jdi // 悬停网格:一层很淡的强调色叠加。为了能画在静态场景之上, // 这里用半透明而非不透明混色,观感与原实现基本一致。 - if (m_hoverLane >= 0 && m_hoverLane < static_cast(m_lanes.size())) + // ⚠️ 高亮来源见 `EffectiveHoverLane()`(浮动面板悬停 / 信号列表悬停), + // **不再**由"鼠标在网格里"触发。 + const int hoverLane = EffectiveHoverLane(); + if (hoverLane >= 0 && hoverLane < static_cast(m_lanes.size())) { - const gd::RectF& laneRect = m_lanes[static_cast(m_hoverLane)].rect; + const gd::RectF& laneRect = m_lanes[static_cast(hoverLane)].rect; const float clipTop = std::max(laneRect.Y, m_plotRect.Y); const float clipBottom = std::min(laneRect.Y + laneRect.Height, m_plotRect.Y + m_plotRect.Height); if (clipBottom > clipTop) @@ -7849,16 +7914,23 @@ namespace jdi } // 悬停信号:外扩一圈半透明强调色作为"可拖拽"提示。 - if (m_hoverSignal.IsValid()) + // 鼠标在绘图区内直接命中时用 `m_hoverSignal`;信号列表 / 样式区悬停时用 + // `m_externalHoverSignal`(二者高亮方式相同,用户要求列表悬停也要高亮曲线本身)。 + SignalRef haloRef = m_hoverSignal; + if (!haloRef.IsValid()) { - const ResolvedSignal resolved = ResolveSignal(m_hoverSignal); + haloRef = m_externalHoverSignal; + } + if (haloRef.IsValid()) + { + const ResolvedSignal resolved = ResolveSignal(haloRef); if (resolved.data != nullptr && resolved.style != nullptr && resolved.visible) { - const int laneIndex = m_tab->LaneOfSignal(m_hoverSignal); + const int laneIndex = m_tab->LaneOfSignal(haloRef); if (laneIndex >= 0 && laneIndex < static_cast(m_lanes.size())) { const LaneVisual& lane = m_lanes[static_cast(laneIndex)]; - const PolylineCacheEntry* cached = AcquirePolyline(m_hoverSignal, resolved, lane); + const PolylineCacheEntry* cached = AcquirePolyline(haloRef, resolved, lane); if (cached != nullptr) { PaintSignalHaloFor(g, resolved, lane, *cached); diff --git a/Components/SignalPanel/Inc/SignalPanel.hpp b/Components/SignalPanel/Inc/SignalPanel.hpp index cee19c3..f59e45c 100644 --- a/Components/SignalPanel/Inc/SignalPanel.hpp +++ b/Components/SignalPanel/Inc/SignalPanel.hpp @@ -83,7 +83,15 @@ namespace jdi /** * @brief 标记列表结构失效(数据或折叠状态变化后调用)。 */ - void InvalidateRows() { m_rowsDirty = true; } + void InvalidateRows() + { + m_rowsDirty = true; + // 列表结构变化后行下标会失效 ⇒ 悬停联动高亮也必须一并作废, + // 否则可能对一条已经不存在(或已被替换)的信号继续高亮它的网格。 + m_hoveredSignal = SignalRef(); + m_hoverRow = -1; + m_hoverPart = RowPart::NONE; + } /** * @brief 设置 DPI 缩放比例。 @@ -292,11 +300,48 @@ namespace jdi HCURSOR QueryCursor(int x, int y); /** - * @brief 将列表滚动到指定信号所在位置。 + * @brief 将列表滚动到指定信号所在位置(无动画,立即到位)。 * @param [IN] ref 目标信号引用。 */ void ScrollToSignal(const SignalRef& ref); + /** + * @brief 平滑滚动到指定信号(带动画)。 + * + * 用户 2026-10-15 要求:触发联动机制(点击图例 / 信号曲线,或在样式自定义区与「目标」行 + * 做任何交互)时,要把信号列表**滚动**到目标信号完整可见: + * * 目标在当前视界上方 ⇒ 向上滚动,直到它**完整露出且不被悬浮组名遮挡**; + * * 目标在当前视界下方 ⇒ 向下滚动直到它完整露出; + * * 滚动速度随距离变化(越远越快、靠近时变慢),但总时长有上限(不要拖得太久)。 + * + * @param [IN] ref 目标信号引用。 + */ + void ScrollToSignalAnimated(const SignalRef& ref); + + /** + * @brief 推进一步滚动动画(由宿主定时器驱动)。 + * @return 动画仍在进行返回 true(宿主应保持定时重绘)。 + */ + bool AdvanceScrollAnimation(); + + /** @brief 滚动动画是否正在进行。 */ + bool ScrollAnimationActive() const { return m_scrollAnimActive; } + + /** + * @brief 供绘图区联动高亮的「悬停信号」。 + * + * 来源:① 列表里鼠标悬停的信号行;② 样式区「目标」行的信号名 / GX 标签。 + * 鼠标移开即返回无效引用。 + * @return 悬停的信号引用。 + */ + const SignalRef& HoveredSignal() const { return m_hoveredSignal; } + + /** + * @brief 「目标」(正在编辑样式的)信号所在的网格下标。 + * @return 网格下标;未勾选绘制到任何网格时返回 -1。 + */ + int TargetLaneIndex() const; + private: /** * @brief 样式区与列表行中可进行内联文本编辑的字段。 @@ -306,7 +351,7 @@ namespace jdi NONE = 0, /**< 未处于编辑状态。 */ WIDTH = 1, /**< 线宽数值。 */ OPACITY = 2, /**< 不透明度数值(百分比)。 */ - SIGNAL_NAME = 3, /**< 信号显示名称(列表行内)。 */ + SIGNAL_NAME = 3, /**< 信号显示名称(列表行内;`row < 0` 表示样式区「目标」行)。 */ FILE_NAME = 4 /**< 分组显示名称(列表行内)。 */ }; @@ -349,6 +394,8 @@ namespace jdi GAIN_ADJUST, /**< 「系数」按钮(点击展开系数二级面板)。 */ MIRROR_ADJUST, /**< 「镜像」按钮(点击展开镜像二级面板)。 */ ABSOLUTE_TOGGLE, /**< 「绝对值」切换按钮。 */ + TARGET_NAME, /**< 样式区「目标」行的信号名。 */ + TARGET_BADGE, /**< 样式区「目标」行的 GX 网格标签。 */ TOGGLE_ALL, /**< 全部显示/隐藏按钮。 */ FILTER_CLEAR /**< 「筛选信号」输入框右侧的清空 ✕。 */ }; @@ -364,6 +411,35 @@ namespace jdi float height = 0.0f; /**< 行高(像素)。 */ }; + /** + * @brief 计算「把某信号完整滚到可见」所需的滚动偏移。 + * + * 顶部避让:当本组组名正在列表顶部悬浮时,列表顶部一段被组名占据, + * 上行方向必须让目标行落在组名的下方才算“完整可见且不被遮挡”。 + * + * @param [IN] ref 目标信号。 + * @param [OUT] desired 目标滚动偏移(未经 clamp)。 + * @return 找到了该行返回 true。 + */ + bool SignalScrollTarget(const SignalRef& ref, float& desired) const; + + /** + * @brief 重新计算「目标」行内信号名与 GX 标签的矩形。 + * + * 名称宽度依赖文本实际宽度、GX 标签是否出现依赖该信号是否已绘制到网格 —— 两者都可能在 + * 不重新布局的情况下变化(切换选中、勾选/取消勾选),因此用一个小缓存键避免重算。 + */ + void UpdateTargetRowGeometry(); + + /** + * @brief 绘制「目标」行(样式区最上方:名称 + GX 标签,可悬停 / 双击改名)。 + * @param [IN,OUT] g 绘图上下文。 + */ + void PaintTargetRow(gd::Graphics& g); + + /** @brief 取消正在进行的滚动动画(用户主动滚动时调用)。 */ + void CancelScrollAnimation() { m_scrollAnimActive = false; } + /** * @brief 重新构建列表行集合。 */ @@ -742,6 +818,32 @@ namespace jdi RowPart m_hoverPart = RowPart::NONE; /**< 当前悬停的行内区域。 */ StyleHit m_hoverStyle = StyleHit::NONE; /**< 当前悬停的样式条目类型。 */ int m_hoverStyleIndex = -1; /**< 当前悬停的样式条目下标。 */ + /** + * @brief 供绘图区联动高亮的悬停信号(列表信号行 / 样式区「目标」行)。 + * + * 用户 2026-10-15 要求:鼠标悬停在信号列表里的某条信号(或样式区「目标」行的 + * 信号名 / GX 标签)上时,若该信号已勾选绘制到某个网格,则该网格变色高亮、 + * 且该信号曲线本身也高亮;鼠标移开即恢复。 + * + * ⚠️ 不能用 `m_hoverRow` 现算:鼠标移出列表后它被清掉,而悬停可能仍在「目标」行上; + * 且列表行会在数据变化时重建,行下标会失效。 + */ + SignalRef m_hoveredSignal; + /** 「目标」行(样式区最上方)的几何。 */ + gd::RectF m_targetRowRect; + gd::RectF m_targetNameRect; + gd::RectF m_targetBadgeRect; + /** 「目标」行几何的缓存键(目标信号 + 所在网格 + 名称),避免每次鼠标移动都重算。 */ + SignalRef m_targetGeomRef; + int m_targetGeomLane = -2; /**< -2 = 尚未计算过。 */ + std::wstring m_targetGeomTitle; + bool m_targetGeomValid = false; + /** 滚动动画状态(见 `ScrollToSignalAnimated` / `AdvanceScrollAnimation`)。 */ + bool m_scrollAnimActive = false; + float m_scrollAnimFrom = 0.0f; + float m_scrollAnimTo = 0.0f; + std::uint64_t m_scrollAnimStartMs = 0; + std::uint32_t m_scrollAnimDurationMs = 0; StyleEditField m_editField = StyleEditField::NONE; /**< 当前处于输入状态的字段。 */ int m_editRow = -1; /**< 行内输入所作用的列表行下标。 */ diff --git a/Components/SignalPanel/Src/SignalPanel.cpp b/Components/SignalPanel/Src/SignalPanel.cpp index 3815f21..e6e504a 100644 --- a/Components/SignalPanel/Src/SignalPanel.cpp +++ b/Components/SignalPanel/Src/SignalPanel.cpp @@ -96,7 +96,23 @@ namespace jdi const float kStyleRowGap = 10.0f; /**< 样式区行间距。 */ const float kStyleLabelWidth = 44.0f; /**< 样式行标签列宽度。 */ const float kStyleLabelGap = 8.0f; /**< 标签列与内容列间距。 */ - const float kStyleRowCount = 5.0f; /**< 样式区固定行数。 */ + const float kStyleRowCount = 5.0f; /**< 样式区固定行数(不含顶部「目标」行)。 */ + const float kStyleTargetRowHeight = 26.0f; /**< 样式区顶部「目标」行高度(与其它行一致)。 */ + /** + * @brief GX 网格标签与信号名之间的间距(逻辑像素)。 + * + * 用户要求:目标信号已绘制到网格时,在信号名**后方**显示它所在的 GX 标签。 + */ + const float kTargetBadgeGap = 8.0f; + /** + * @brief 平滑滚动的动画时长(毫秒)。 + * + * 距离越远时长越长(但不会无限增长),靠近时由 ease-out 自动变慢 —— 用户要求 + * “越远时越快、靠近时变慢”且“整个动画过程不要太久”。 + */ + const float kScrollAnimMinMs = 130.0f; /**< 最短时长(短距离)。 */ + const float kScrollAnimMaxMs = 300.0f; /**< 最长时长(长距离)。 */ + const float kScrollAnimMsPerPixel = 0.35f; /**< 每像素距离增加的时长。 */ const float kStyleContentMax = 260.0f; /**< 内容列最大宽度(再拉宽面板也不再延伸)。 */ const float kSwatchEdge = 18.0f; /**< 颜色色块边长上限(含自定义颜色入口)。 */ const float kSwatchMin = 11.0f; /**< 颜色色块边长下限。 */ @@ -316,8 +332,10 @@ namespace jdi float SignalPanel::DesiredStyleHeight() const { - return kStylePadV * 2.0f + kStyleRowCount * kStyleRowHeight + - (kStyleRowCount - 1.0f) * kStyleRowGap; + // ⚠️ 「目标」行也要计入:它是样式区最上方的一行(用户 2026-10-15 要求), + // 与其余五行同高、同间距,少算一行会导致列表与样式区重叠。 + return kStylePadV * 2.0f + (kStyleRowCount + 1.0f) * kStyleRowHeight + + kStyleRowCount * kStyleRowGap; } bool SignalPanel::TakeLayoutDirty() @@ -370,6 +388,13 @@ namespace jdi // 样式区的可点击控件(「调整」行三个按钮、线宽/透明度数值框)也用手型光标。 const float fx = static_cast(x); const float fy = static_cast(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) || @@ -849,10 +874,16 @@ namespace jdi const float groupLeft = m_styleRect.X + padH; const float contentLeft = groupLeft + labelWidth + labelGap; + // ---- 顶部「目标」行:显示当前正在被编辑的目标信号(名称 + GX 标签)---- + // 用户 2026-10-15 要求:位于样式自定义区最上方(预设颜色色块之上), + // 显示效果与交互效果与信号选择区域的信号行相同(悬停高亮、双击改名、单键滚动联动)。 + m_targetRowRect = gd::RectF(groupLeft, firstTop, groupWidth, kStyleTargetRowHeight * m_scale); + for (int index = 0; index < static_cast(kStyleRowCount); ++index) { m_styleRowRects[static_cast(index)] = - gd::RectF(groupLeft, firstTop + static_cast(index) * rowStep, groupWidth, rowHeight); + gd::RectF(groupLeft, firstTop + static_cast(index + 1) * rowStep, groupWidth, + rowHeight); } const gd::RectF& colorRow = m_styleRowRects[0]; @@ -939,6 +970,77 @@ namespace jdi trackWidth, trackHeight); m_opacityValueRect = gd::RectF(contentLeft + trackWidth + kSliderValueGap * m_scale, opacityRow.Y, valueWidth, rowHeight); + + // 「目标」行的子几何依赖目标信号的名称与可见性(可能在未重排时变化), + // 因此每次重排后让缓存失效,下一次绘制 / 命中测试时按需重算。 + m_targetGeomValid = false; + } + + int SignalPanel::TargetLaneIndex() const + { + if (m_tab == nullptr || !SelectedRef().IsValid()) + { + return -1; + } + // 未勾选绘制的信号不在任何网格的信号列表里,`LaneOfSignal` 自然返回 -1。 + return m_tab->LaneOfSignal(SelectedRef()); + } + + void SignalPanel::UpdateTargetRowGeometry() + { + if (m_fonts == nullptr || m_dataSet == nullptr) + { + m_targetNameRect = gd::RectF(); + m_targetBadgeRect = gd::RectF(); + return; + } + + const SignalRef& target = SelectedRef(); + const SignalData* signal = target.IsValid() ? m_dataSet->Resolve(target) : nullptr; + const std::wstring title = (signal != nullptr) ? signal->Title() : std::wstring(); + const int lane = TargetLaneIndex(); + + // 缓存键:目标信号 + 它所在网格 + 名称(名称宽度与 GX 标签是否出现都由它们决定)。 + if (m_targetGeomValid && m_targetGeomRef == target && m_targetGeomLane == lane && + m_targetGeomTitle == title) + { + return; + } + m_targetGeomRef = target; + m_targetGeomLane = lane; + m_targetGeomTitle = title; + m_targetGeomValid = true; + + m_targetNameRect = gd::RectF(); + m_targetBadgeRect = gd::RectF(); + if (signal == nullptr || m_targetRowRect.Width <= 0.0f) + { + return; + } + + const float labelWidth = kStyleLabelWidth * m_scale; + const float labelGap = kStyleLabelGap * m_scale; + const float contentLeft = m_targetRowRect.X + labelWidth + labelGap; + const float contentWidth = std::max(0.0f, m_targetRowRect.Width - labelWidth - labelGap); + + const float badgeWidth = kBadgeWidth * m_scale; + const float badgeHeight = kBadgeHeight * m_scale; + const float badgeGap = kTargetBadgeGap * m_scale; + const bool badgeShown = (lane >= 0); + + // 名称可用宽度:有 GX 标签时把它排在名称**后面**(用户要求“名称后方”),因此要预留宽度。 + const float nameLimit = std::max(0.0f, contentWidth - (badgeShown ? badgeWidth + badgeGap : 0.0f)); + const float measured = UiMeasureTextWidth(m_fonts->ui, title); + const float nameWidth = std::min(measured, nameLimit); + + // 名称的可交互矩形:整行高,便于悬停(文本较短时也容易命中)。 + m_targetNameRect = gd::RectF(contentLeft, m_targetRowRect.Y, nameWidth, m_targetRowRect.Height); + if (badgeShown) + { + m_targetBadgeRect = gd::RectF(contentLeft + nameWidth + badgeGap, + m_targetRowRect.Y + (m_targetRowRect.Height - badgeHeight) * 0.5f, + badgeWidth, badgeHeight); + } } void SignalPanel::Layout(const gd::RectF& area) @@ -1376,11 +1478,18 @@ namespace jdi UiFillRect(g, m_styleRect, m_theme->panelBg); + // ---- 顶部「目标」行(无论是否已选中信号都要绘制:未选中时显示占位文本)---- + PaintTargetRow(g); + const SignalStyle* style = (m_tab == nullptr) ? nullptr : StyleOf(SelectedRef()); if (style == nullptr || SelectedRef().IsValid() == false) { - UiText(g, L"请选择信号以编辑样式", m_fonts->uiSmall, m_styleRect, m_theme->textMuted, - TextAlign::CENTER); + // 提示文本只占「目标」行以下的区域,避免与目标行重叠。 + const float top = m_targetRowRect.Y + m_targetRowRect.Height; + UiText(g, L"请选择信号以编辑样式", m_fonts->uiSmall, + gd::RectF(m_styleRect.X, top, m_styleRect.Width, + std::max(0.0f, m_styleRect.Y + m_styleRect.Height - top)), + m_theme->textMuted, TextAlign::CENTER); return; } @@ -1545,6 +1654,190 @@ namespace jdi } } + void SignalPanel::PaintTargetRow(gd::Graphics& g) + { + if (m_fonts == nullptr || m_theme == nullptr || m_targetRowRect.Width <= 0.0f) + { + return; + } + UpdateTargetRowGeometry(); + + const float labelWidth = kStyleLabelWidth * m_scale; + // 行标签与前五行一致(同一标签列宽度、同一纵向居中基准)。 + UiText(g, L"目标", m_fonts->uiSmall, + gd::RectF(m_targetRowRect.X, m_targetRowRect.Y, labelWidth, m_targetRowRect.Height), + m_theme->textMuted, TextAlign::LEFT); + + const SignalRef& target = SelectedRef(); + const SignalData* signal = + (m_dataSet != nullptr && target.IsValid()) ? m_dataSet->Resolve(target) : nullptr; + + // 没有可编辑的目标信号:只显示占位提示(与样式区其余行的"请选择信号"提示一致)。 + if (signal == nullptr) + { + const float contentLeft = m_targetRowRect.X + labelWidth + kStyleLabelGap * m_scale; + UiText(g, L"未选择信号", m_fonts->uiSmall, + gd::RectF(contentLeft, m_targetRowRect.Y, + std::max(0.0f, m_targetRowRect.X + m_targetRowRect.Width - contentLeft), + m_targetRowRect.Height), + m_theme->textMuted, TextAlign::LEFT); + return; + } + + // 名称:悬停时给一层与信号列表行一致的背景高亮(用户要求"显示与交互效果相同")。 + if (m_hoverStyle == StyleHit::TARGET_NAME && m_targetNameRect.Width > 0.0f) + { + UiFillRoundRect(g, m_targetNameRect, kCorner * m_scale, m_theme->hover); + } + + if (m_editField == StyleEditField::SIGNAL_NAME && m_editRow < 0) + { + UiDrawEditField(g, EditFieldRect(), m_edit, m_fonts->ui, m_scale); + } + else + { + // 颜色与信号列表行一致:已绘制到网格用常态色,未绘制用弱色。 + const std::uint32_t nameColor = (m_targetGeomLane >= 0) ? m_theme->text : m_theme->textMuted; + PaintRowText(g, UiEllipsize(signal->Title(), m_fonts->ui, m_targetNameRect.Width), + m_fonts->ui, m_targetNameRect, nameColor, TextAlign::LEFT); + } + + // GX 网格标签:信号已绘制到某个网格时显示在名称**后方**(用户 2026-10-15 要求)。 + if (m_targetBadgeRect.Width > 0.0f) + { + const bool badgeHot = (m_hoverStyle == StyleHit::TARGET_BADGE); + UiFillRoundRect(g, m_targetBadgeRect, kBadgeHeight * m_scale * 0.5f, + badgeHot ? m_theme->accentSoft : m_theme->cardBg); + UiDrawRoundRect(g, m_targetBadgeRect, kBadgeHeight * m_scale * 0.5f, + badgeHot ? m_theme->accent : m_theme->border, 1.0f); + PaintRowText(g, L"G" + std::to_wstring(m_targetGeomLane + 1), m_fonts->monoSmall, + m_targetBadgeRect, badgeHot ? m_theme->textStrong : m_theme->text, + TextAlign::CENTER); + } + } + + bool SignalPanel::SignalScrollTarget(const SignalRef& ref, float& desired) const + { + desired = m_scrollOffset; + if (!ref.IsValid() || m_rows.empty() || m_listRect.Height <= 0.0f) + { + return false; + } + + float top = 0.0f; + float rowTop = -1.0f; + float rowHeight = 0.0f; + int rowIndex = -1; + for (std::size_t index = 0; index < m_rows.size(); ++index) + { + const ListRow& row = m_rows[index]; + if (row.kind == RowKind::SIGNAL && row.groupIndex == ref.groupIndex && + row.signalIndex == ref.signalIndex) + { + rowTop = top; + rowHeight = row.height; + rowIndex = static_cast(index); + break; + } + top += row.height; + } + if (rowTop < 0.0f) + { + return false; + } + + // ⚠️ **顶部避让悬浮组名**(用户明确要求"完全出现时不会被浮动组名遮挡"): + // 上行方向必须让目标行落在悬浮组名的下方。目前悬浮的组名只可能是目标信号**自己那个** + // 分组:分组行在其信号行之上、各分组连续排列,若目标行完整可见,则更靠下的分组行 + // 不可能同时位于列表顶部(参见 `StickyGroupRowTop` 的公式)。 + float topInset = 0.0f; + for (int index = rowIndex; index >= 0; --index) + { + if (m_rows[static_cast(index)].kind == RowKind::GROUP) + { + topInset = m_rows[static_cast(index)].height; + break; + } + } + + const float visibleTop = m_scrollOffset + topInset; + const float visibleBottom = m_scrollOffset + m_listRect.Height; + if (rowTop < visibleTop) + { + desired = rowTop - topInset; // 向上滚动,直到它完整出现在组名下方 + } + else if (rowTop + rowHeight > visibleBottom) + { + desired = rowTop + rowHeight - m_listRect.Height; // 向下滚动,直到完整出现 + } + else + { + desired = m_scrollOffset; // 已经完整可见:不动 + } + desired = std::max(0.0f, std::min(desired, std::max(0.0f, m_contentHeight - m_listRect.Height))); + return true; + } + + void SignalPanel::ScrollToSignalAnimated(const SignalRef& ref) + { + EnsureRows(); + float desired = m_scrollOffset; + if (!SignalScrollTarget(ref, desired)) + { + return; + } + 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(durationMs + 0.5f); + m_scrollAnimActive = 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(elapsed) / static_cast(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::AreAllSignalsVisible() const { if (m_dataSet == nullptr || m_tab == nullptr || m_dataSet->Empty()) @@ -1664,7 +1957,18 @@ namespace jdi } case StyleEditField::SIGNAL_NAME: { - if (row < 0 || row >= static_cast(m_rows.size())) + // `row < 0`:样式区顶部「目标」行 —— 编辑对象就是当前选中的(目标)信号。 + if (row < 0) + { + const SignalData* signal = SelectedRef().IsValid() ? m_dataSet->Resolve(SelectedRef()) : nullptr; + if (signal == nullptr) + { + return false; + } + initial = signal->Title(); + break; + } + if (row >= static_cast(m_rows.size())) { return false; } @@ -1698,6 +2002,12 @@ namespace jdi m_editField = field; m_editRow = row; + if (field == StyleEditField::SIGNAL_NAME && row < 0) + { + // 「目标」行的输入框与行内名称矩形同源,必须先按当前目标算好几何。 + UpdateTargetRowGeometry(); + } + // 名称类字段可输入任意字符,数值类字段限制为数字与小数点。 const bool nameField = (field == StyleEditField::SIGNAL_NAME) || (field == StyleEditField::FILE_NAME); @@ -1768,7 +2078,23 @@ namespace jdi } case StyleEditField::SIGNAL_NAME: { - if (row < 0 || row >= static_cast(m_rows.size()) || m_dataSetWritable == nullptr) + if (m_dataSetWritable == nullptr) + { + break; + } + if (row < 0) + { + // 样式区「目标」行:重命名当前选中的(目标)信号。 + const SignalRef& target = SelectedRef(); + if (target.IsValid()) + { + m_dataSetWritable->SetSignalDisplayName(target.groupIndex, target.signalIndex, trimmed); + } + // 名称变化会改变「目标」行的文本宽度 ⇒ 几何缓存作废。 + m_targetGeomValid = false; + break; + } + if (row >= static_cast(m_rows.size())) { break; } @@ -1822,6 +2148,15 @@ namespace jdi return m_opacityValueRect; case StyleEditField::SIGNAL_NAME: case StyleEditField::FILE_NAME: + // `row < 0` = 样式区「目标」行:输入框用内容列全宽(名称本身很短时也能舒适地编辑)。 + if (m_editRow < 0) + { + const float labelWidth = kStyleLabelWidth * m_scale; + const float labelGap = kStyleLabelGap * m_scale; + const float left = m_targetRowRect.X + labelWidth + labelGap; + const float width = std::max(24.0f, m_targetRowRect.X + m_targetRowRect.Width - left); + return gd::RectF(left, m_targetRowRect.Y, width, m_targetRowRect.Height); + } return RowNameRect(m_editRow); case StyleEditField::NONE: default: @@ -2370,7 +2705,21 @@ namespace jdi } else if (m_tab != nullptr && StyleOf(SelectedRef()) != nullptr) { - for (int index = 0; index < m_colorCount; ++index) + // 「目标」行的信号名 / 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(index)], fx, fy)) { @@ -2437,6 +2786,25 @@ namespace jdi m_hoverPart = part; m_hoverStyle = hoverStyle; m_hoverStyleIndex = hoverStyleIndex; + + // 供绘图区联动高亮的悬停信号(用户 2026-10-15 要求): + // * 列表里悬停某条信号行 → 高亮它所在的网格 + 该信号的曲线; + // * 样式区「目标」行的信号名 / GX 标签上悬停 → 同样联动高亮。 + // ⚠️ ✕(移除该信号)不算"悬停到该信号":它是一条即将被删除的信号, + // 高亮它会让人误以为要保留它。 + SignalRef hovered; + if (hoverRow >= 0 && hoverRow < static_cast(m_rows.size()) && + m_rows[static_cast(hoverRow)].kind == RowKind::SIGNAL && + part != RowPart::REMOVE_SIGNAL) + { + hovered = SignalRef{m_rows[static_cast(hoverRow)].groupIndex, + m_rows[static_cast(hoverRow)].signalIndex}; + } + else if (hoverStyle == StyleHit::TARGET_NAME || hoverStyle == StyleHit::TARGET_BADGE) + { + hovered = SelectedRef(); + } + m_hoveredSignal = hovered; return true; } @@ -2477,6 +2845,7 @@ namespace jdi // 滚动条拖拽优先于其它命中测试。 if (m_scrollBarVisible && UiPointInRect(m_scrollBarRect, fx, fy)) { + CancelScrollAnimation(); const gd::RectF thumb = ScrollThumbRect(); m_draggingScrollBar = true; if (UiPointInRect(thumb, fx, fy)) @@ -2521,6 +2890,22 @@ namespace jdi // 样式编辑区。 if (SignalStyle* style = StyleOf(SelectedRef())) { + // ---- 顶层「目标」行:单击**不进入编辑**(用户明确要求),只做联动滚动 ---- + // 双击改名由 `OnDoubleClick` 处理;这里单击即把列表滚动到目标信号, + // 便于在长列表(上百条信号的 MF4)里定位正在编辑的那条信号。 + { + 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) + { + ScrollToSignalAnimated(SelectedRef()); + return true; + } + } + int paletteCount = 0; const std::uint32_t* palette = SignalPalette(paletteCount); for (int index = 0; index < m_colorCount; ++index) @@ -2528,6 +2913,8 @@ namespace jdi if (UiPointInRect(m_colorRects[static_cast(index)], fx, fy)) { style->color = palette[index % paletteCount]; + // 联动:样式自定义区的任何交互都把列表滚动到正在编辑的目标信号。 + ScrollToSignalAnimated(SelectedRef()); return true; } } @@ -2536,6 +2923,7 @@ namespace jdi // 先恢复到记忆的自定义颜色(即便随后收起浮窗也会生效),再在抬起时展开浮窗。 style->color = style->customColor; m_pendingColorPicker = true; + ScrollToSignalAnimated(SelectedRef()); return true; } for (int index = 0; index < kModeOptionCount; ++index) @@ -2543,6 +2931,7 @@ namespace jdi if (UiPointInRect(m_modeRects[static_cast(index)], fx, fy)) { style->mode = kModeOptions[index]; + ScrollToSignalAnimated(SelectedRef()); return true; } } @@ -2550,21 +2939,25 @@ namespace jdi { 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); } // 「调整」行:系数与镜像(按下只登记,**抬起**时才展开各自的二级面板, @@ -2572,16 +2965,19 @@ namespace jdi 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; } } @@ -2788,6 +3184,23 @@ namespace jdi 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) @@ -2856,6 +3269,9 @@ namespace jdi bool SignalPanel::OnMouseWheel(int delta) { + // 用户主动滚动 ⇒ 立即取消正在进行的目标信号滚动动画(否则两者会互相打架)。 + CancelScrollAnimation(); + if (m_dataSet == nullptr) { return false; @@ -2869,15 +3285,15 @@ namespace jdi bool SignalPanel::OnMouseLeave() { - if (m_hoverRow < 0 && m_hoverStyle == StyleHit::NONE) - { - return false; - } + 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; - return true; + // 鼠标离开面板 → 取消绘图区的联动高亮(用户要求"移开鼠标时恢复")。 + m_hoveredSignal = SignalRef(); + return changed; } void SignalPanel::OpenSignalMenu(const SignalRef& ref, int x, int y) diff --git a/Documents/agent_mem.md b/Documents/agent_mem.md index db51b60..01b8e0f 100644 --- a/Documents/agent_mem.md +++ b/Documents/agent_mem.md @@ -1863,6 +1863,85 @@ GDI+ 抗锯齿逐段扫描这些**近竖直、满幅**的边,代价爆炸( --- +### 9.34 浮动面板不穿透 / 网格高亮改由信号列表触发 / 样式区「目标」行 / 联动平滑滚动(本轮,起源设备) + +> 本轮四项(用户 2026-10-15 第六轮)。离屏验证 `.preflight/check` **73/73 通过**(连跑 3 次稳定); +> **A/B** 回退六处实现后 **15 项如实失败**。 + +#### 一、浮动面板上双击不再穿透到网格(用户报告) + +- **根因**:实验性功能的浮动面板绘制在网格之上,但 `PlotView::OnDoubleClick` 在 + "差值标签 → 光标标签 → 光标线 → 图例" 都不命中时**无条件** `FitAll()` ⇒ + 在面板上双击任意位置都会让网格莫名其妙地自适应一次。 +- **修法**:在 `OnDoubleClick` 的**最前面**(高于一切下层命中)用 + `TopPanelAtPoint()` 判定,命中浮动面板则直接消费(面板自身双击无额外语义)。 + 这是"上层元素的交互不得影响下层"的通用做法,与 `QueryCursor` / `OnLButtonDown` + 里已有的面板守卫同源。 + +#### 二、网格变色高亮改由"信号列表悬停"触发(用户重新定义) + +- **旧行为(已取消)**:鼠标在网格里移动 ⇒ `m_hoverLane = LaneAtY(y)` ⇒ 整个网格变色高亮。 +- **新行为**: + * 鼠标**在网格里移动不再触发**任何网格高亮; + * 在**信号列表**里悬停某条信号 ⇒ 若它已勾选绘制到某个网格,则该网格变色高亮, + **并且该信号曲线本身也高亮**(复用 `PaintSignalHalo`);鼠标移开即恢复; + * 悬停**未勾选**的信号不产生任何高亮(`LaneOfSignal` 只认可见信号); + * 悬停信号行的 ✕(移除)**不算**"悬停到该信号"。 +- **实现要点**: + * `PlotView` 新增 `m_externalHoverSignal` + `SetExternalHoverSignal()`: + ⚠️ **必须与 `m_hoverSignal` 分开**——绘图区的 `OnMouseLeave` 会清掉后者, + 而列表悬停时鼠标恰恰不在绘图区内; + * 新增 `PlotView::EffectiveHoverLane()`:`m_hoverLane`(浮动面板悬停)优先, + 否则取外部悬停信号所在网格;`PaintHoverHighlight` 与光晕都改用它 / + `m_hoverSignal ?: m_externalHoverSignal`; + * `SignalPanel` 新增 `HoveredSignal()`(列表信号行 / 「目标」行), + ⚠️ 不能用 `m_hoverRow` 现算(鼠标移出列表即被清、行下标会因重建而失效), + 且在 `InvalidateRows()` 里一并作废(结构变化后悬停引用不再可信); + * 同步时机:`Application::Render()` **每帧**同步(覆盖 WM_MOUSELEAVE、数据变化、 + 切换标签页等全部改变悬停的路径,而不只是 WM_MOUSEMOVE)。 + * GX 徽标的悬停高亮仍按鼠标纵坐标现算(它与"悬停网格高亮"已解耦)。 + +#### 三、样式区顶部「目标」行(用户要求) + +- 位置:样式自定义区**最上方**(预设颜色色块之上),与其余五行同高同间距 + (`DesiredStyleHeight()` 因此多算一行;标签列文字「目标」)。 +- 内容:当前**正在被编辑的目标信号**(= 选中信号)的名称; + **已绘制到网格时**在名称**后方**显示它所在的 GX 标签(`TargetLaneIndex()`)。 +- 交互(用户要求"与信号选择区域的信号相同"): + * 名称悬停 ⇒ 一层 `hover` 背景;GX 标签悬停 ⇒ `accentSoft` 底 + `accent` 边; + 两者都用手型光标; + * **悬停名称 / GX 标签也触发绘图区的网格高亮 + 曲线高亮**(与列表悬停同效); + * **单击不进入编辑**,只触发"滚动到目标信号"的联动;**双击**才进入改名 + (复用 `StyleEditField::SIGNAL_NAME`,用 `m_editRow < 0` 表示"目标是选中信号"); + * 名称颜色与列表一致:已绘制 = `text`,未绘制 = `textMuted`。 +- ⚠️ 名称 / GX 标签的矩形依赖**文本实际宽度**与**是否已绘制**, + 这两者都可能在不重排的情况下变化 ⇒ 用 `UpdateTargetRowGeometry()` 在 + 绘制 / 悬停 / 命中 / 取光标四处按需重算(带小缓存键)。 + +#### 四、联动:把信号列表**平滑滚动**到目标信号(用户要求) + +- **触发**:绘图区内把某条信号选为当前目标(点击图例、点击信号曲线、双击图例改名) + ⇒ `PlotView::SelectSignal()` 记录一个待处理请求(`TakePanelScrollRequest()`), + 宿主在鼠标消息处理后取走并调用 `SignalPanel::ScrollToSignalAnimated()`; + 另外样式自定义区的**任何交互**(预设色块、自定义颜色入口、样式按钮、 + 线宽 / 透明度滑块与数值框、「系数/偏移」「镜像」二级菜单、绝对值)与 + 「目标」行的单击也都会触发同一次滚动。 +- **滚动目标位置**(`SignalScrollTarget()`): + * 目标在当前视界**上方** ⇒ 向上滚动到 `rowTop - 组名高度`, + ⚠️ **顶部必须避让悬浮组名**(用户明确要求"完全出现时不会被浮动组名遮挡"): + 悬浮组名只可能是目标自己那个分组的组名(分组连续排列,见 `StickyGroupRowTop`), + 因此让出**一个组行高度**即可; + * 目标在视界**下方** ⇒ 向下滚动到 `rowBottom - 列表高度`; + * 已经完整可见 ⇒ **不动**。 +- **动画**(`AdvanceScrollAnimation()`,宿主用 16ms 定时器驱动): + * ease-out cubic(起步快、靠近目标变慢 —— 用户要求"越远时越快,靠近时变慢"); + * 时长 = `clamp(距离 × 0.35ms, 130ms, 300ms)`(用户要求"不要太久"); + * 用户滚轮 / 拖滚动条 ⇒ **立即取消**动画(否则互相打架)。 +- ⚠️ 动画只改"面板的滚动偏移",因此宿主只需持续 `InvalidateRect`(整帧), + 由 `Render` 检测 `ScrollAnimationActive()` 自管定时器(与提示浮层淡出同一套做法)。 + +--- + ## 10. 变更日志(本文件维护记录) | 日期 | 设备/模型 | 摘要 | @@ -1907,6 +1986,7 @@ GDI+ 抗锯齿逐段扫描这些**近竖直、满幅**的边,代价爆炸( | 2026-10-15 | DeepSeek-V4.1-Flash(**起源设备**) | **全局 Y 轴自适应跟随网格结构 + 组名悬浮 + 稀疏信号默认点线 + 单点光标吸附 + 放大自动点线(详见 §9.29)**。① **新网格不继承全局 Y 轴自适应**:`RebuildLanes` 对新出现的信号返回默认 `LaneCarry()` ⇒ `fitYToSelection=false`;修法:重建前先算 `previousAllAutoFit`(非空且**每个**网格都开着 = 全局开启的正式定义),`LaneCarry` 加 `matched` 标记,新信号用它当默认;并新增 `ReapplyGlobalAutoFitIfEnabled()` 在 **`Layout()`(网格数变化)/`RefreshLanes()`(信号数变化)/`SplitLane()`** 之后立即重算 ⇒ 新勾选(按**当前视界**自适应)、取消显示(剩余信号重新收敛)、拆分(每份各自自适应)三项全覆盖。② **组名悬浮**:新增 `SignalPanel::StickyGroupRowIndex()` + `PaintStickyGroupRow()`(不透明底 + 底部边界线),`RowRect` 加 `m_rowYOverrideActive/Y` 覆盖行顶部(行内所有几何的唯一来源);判据 = 分组行已在列表上边界之上且本组最后一行仍在列表内;常规循环**跳过**被钉住的行(否则画两次)。③ **稀疏信号默认样式**:新增 `ApplySparseDefaultStyle`(1 个有效点 → 点线 + **最大线宽 10px**;2..16 → 点线 + 2px),在 `BuildSignalGroup`/`InsertDerivedSignal` 两处调用;线宽常量统一到 `DataModel.hpp`(`kSignalLineWidthMin/Max`)并被 SignalPanel 滑块引用;`ComputeStats` 输出 `firstFiniteIndex`、`SignalData` 新增 `singlePointTime`。④ **单点光标吸附**:`SnapTimeToSample` 新增半径分支(`kCursorSnapPointRadiusPixels=8` 逻辑像素)吸附 `singlePointTime`,半径外自由移动(**吸附而非卡住**)。⑤ **放大自动点线**:`SignalStyle` 新增 `autoScatterActive/autoScatterSavedMode/autoScatterUserOverride`,`AxisSparseInView`(间距 > 半个绘图区宽度,与吸附上门限同源,**单点轴不算稀疏**)+ `UpdateAutoScatterStyles()`(在 `SetTimeView` 等四处调用)⇒ 进入阈值自动切点线、退出阈值恢复原样式,**用户手动改过则不恢复**(判定必须放在恢复判断**之前**,否则用户改完紧接着缩出去会被覆盖)。验证:新增离屏 `.preflight/check` **65/65 通过**(连跑 3 次稳定,覆盖 7 组断言;含“钉住行与自然位置逐像素一致”“单点吸附半径内外”“用户手动改样式后退出不覆盖”),**A/B**:临时禁用六个机制后 **21 项如实失败**;`make release` 零警告。 | | 2026-10-15 | DeepSeek-V4.1-Flash(**起源设备**) | **组名平滑悬浮 + 单点信号初始视图 + 光标吸附统一判定(自动点线同源)(详见 §9.30)**。① **组名悬浮平滑化**:位置改为 `StickyGroupRowTop = min(列表上边界, 本组最后一行下边界 − 组名高度)` ⇒ 组名随最后一行**自然滚出**(不再突然消失),且下边界永不越过下一个分组行的自然位置 ⇒ **不再遮住下一组名**(旧实现固定钉在列表上边界会把下一组名盖住,让别人以为后面的信号属于上一组);**折叠的分组不悬浮**(随滚动自然划过);底部边界线只在真的“浮”起来时才画。② **单点信号初始视图**:新增 `PlotView::FitSinglePointSignal()`(无任何信号被勾选、勾选的第一个信号只有 1 个有效点时生效)—— 数据点居中、**时间跨度 1s**、**Y 跨度 1**,并置 `autoRange=false`/`fitYToSelection=false`;`Application::SynchronizeView` 首次勾选时先试它、失败才 `FitAll()`;`AutoRangeLaneToView` 的退化区间改为 `PadRange` 同款留白 `max(1,|v|×0.1)`。③ **吸附判定统一**(用户给出的唯一判据):新增 `GridMinorCellCount()`(网格一横排小格数 N,与网格绘制同源)+ `SignalSnapEnabled()`(**当且仅当**某信号在当前一屏内的数据点 `< N×4` 才开启吸附,随视界实时重算 ⇒ 疏密信号自适应),`SnapTimeToSample` **只按鼠标所在网格**判定、同网格内所有符合判定的信号数据点都参与、吸附半径 = 一个网格小格宽且不超过半个局部采样间距(吸附≠卡住);旧的 `SampleSpacingPixels`(6px 下门限+半屏宽上门限)**已删除**。④ **自动点线改为与吸附判定完全同源**(`UpdateAutoScatterStyles` 调 `SignalSnapEnabled`),旧 `AxisSparseInView` **已删除**;进入阈值自动切点线、不再满足自动恢复、用户手动改过则不恢复。⑤ **需求作废**:“2…16 个有效采样点默认点线 + 2px”**已取消**(只保留“1 个有效点 → 点线 + 最大线宽”);`SignalData::singlePointTime` 升级为 **`sparseTimes`/`sparseValues`**(有效点 ≤ 1024 时全部缓存,供吸附与单点视图使用)。验证:新增离屏 `.preflight/check` **55/55 通过**(连跑 3 次稳定;覆盖组名悬浮四种场景、单点初始视图、吸附判定/网格作用域/多信号/动态切换/单点半径、自动点线同源与用户覆盖);**A/B** 禁用三项机制 ⇒ **14 项如实失败**;`make release` 零警告。 | | 2026-10-15 | DeepSeek-V4.1-Flash(**起源设备**) | **自适应范围/时机澄清 + 适应视图与 Y 轴自适应互不干扰 + 吸附跟随时间偏移(详见 §9.31)**。① **澄清“自适应范围/时机”**(修正记忆里“额外要求视界内至少有一个可见数据点”这句不完善的表述):**首个网格**显示内容时 **Y 轴与时间轴都**自适应;只有一个数据点的信号要**居中显示在网格里**(`FitSinglePointSignal`:居中 + 时间跨度 1s + Y 跨度 1);**后续新增网格只做 Y 轴自适应、不再调整时间轴**。② **「适应视图」与「Y 轴自适应」互相干扰**:根因是两条自适应路径**留白不同**(`PadRange` 6% vs `AutoRangeLaneToView` 5%)⇒ 视界未变时也会算出不同区间、网格被无意义重绘(实测切一次 **143513** 像素、反向 **137326** 像素)。修法:新增 `DataModel.hpp` 的 **`PadValueRange()`** 作为唯一留白实现(含退化区间 `max(1,|v|×0.1)`),`PadRange` 与 `AutoRangeLaneToView` 共用 ⇒ 四个步骤(适应视图→开自适应→关自适应→再适应视图)**区间逐位相同且绘图区逐像素 0 差异**。③ **「适应视图」在单点信号上让数据点“飞了”**:`FitAll()` 开头先试 `FitSinglePointSignal()`(仅一个网格 + 唯一可见信号为单点),与首次加载效果一致且幂等。④ **吸附跟随「调整 → 时间轴偏移」**:采样点画在 `time[k] + offsetTime` 处而光标存的是显示时间,旧实现按源时刻吸附 ⇒ 落到历史位置;修法:`SignalSnapEnabled`/`HasVisibleSample`/`NearestSnapSample`/`LocalSampleSpacing` 全部加 `timeShift`,查找时 `time - timeShift` 回源空间、返回时加回去,`UpdateAutoScatterStyles` 同步传 `offsetTime`。验证:离屏 `.preflight/check` **39/39 通过**(连跑 3 次稳定;含像素级零变化断言与“真缩放会重绘”对照组);**A/B** 回退三项修复 ⇒ **7 项如实失败**(含 143513/137326 像素重绘症状);`make release` 零警告。 | +| 2026-10-15 | DeepSeek-V4.1-Flash(**起源设备**) | **浮动面板不穿透 / 网格高亮改由信号列表触发 / 样式区「目标」行 / 联动平滑滚动(详见 §9.34)**。① **浮动面板上双击穿透到网格**:`PlotView::OnDoubleClick` 在所有下层命中都不成立时无条件 `FitAll()` ⇒ 在面板上双击会让网格莫名其妙自适应一次;修法:**最前面**用 `TopPanelAtPoint()` 判定并消费。② **网格变色高亮的触发来源重新定义**(旧行为"鼠标在网格里移动就高亮"**作废**):改为**信号列表 / 样式区「目标」行悬停到某条已绘制信号** ⇒ 该信号所在网格变色高亮**且该信号曲线本身也高亮**(新增 `m_externalHoverSignal`,与 `m_hoverSignal` 分开保存,由 `Render()` 每帧同步;新增 `EffectiveHoverLane()`)。③ **样式区「目标」行**(样式自定义区最上方):显示当前编辑的目标信号名称 + (已绘制时)名称**后方**的 GX 标签,悬停高亮并联动网格/曲线高亮,**单击不编辑、双击改名**。④ **联动平滑滚动**:绘图区选中信号(点图例 / 点曲线 / 双击图例)与样式区任何交互都触发 `ScrollToSignalAnimated()`(ease-out cubic、时长 `clamp(距离×0.35ms,130,300)`、**上行让出组行高度避免被悬浮组名遮挡**、滚轮/拖滚动条即取消)。验证:离屏 `.preflight/check` **73/73 通过**(连跑 3 次稳定;含浮动面板双击不移视界 vs 空白处双击必自适应的对照组、未勾选信号悬停不高亮、目标行 GX 标签位置与单击/双击区分、长列表 122 行平滑滚动与遮挡检查);**A/B** 回退六处实现 ⇒ **15 项如实失败**;`make release` 零警告、链接通过。 | | 2026-10-15 | DeepSeek-V4.1-Flash(**起源设备**) | **「适应视图」边界规则撤回重做:单值散点让出半径 + 单点簇视图(详见 §9.33)**。① **初次自适应没有发生**(用户报告):`FitAll()` 在**没有任何可见信号**时也把 `ViewInitialized()` 置 true ⇒ 随后勾选第一个信号时 `SynchronizeView` 认为已初始化、**跳过自适应**(实测 MF4 视界停在 42.386s,数据点被推到右边缘外);修法:只有存在可见信号才置位,并让初次自适应与工具栏「适应视图」**共用 `FitAll()` 一条路径**。② **单值散点被裁掉半个**(用户报告):旧规则"左右各留一小格"在"单值 + 连续信号"下既不准也不够(`WIN_Motor_StallCurrentVolBrk` 只有 1 个有效点,正好落在边界上);按用户要求**撤回旧策略**(删除 `FitSinglePointLanes`/`TimeGridCellSeconds`/`LaneValueGridCell`/`FitSinglePointSignal`),改为:**任何一条边界被单值散点顶住就让出该散点的半径(+1px)**,只在散点**会被裁切**时才扩展(内部散点不动视界),边界不是单值散点时边界就落在信号点上。③ **特例**:全部可见数据点都是单值信号且时刻相同 ⇒ 时间轴以该时刻居中、缩放到**一大格 = 1s**(视界 10s,`NiceStep(10,10)==1.0`),每个网格的幅值区间保证其数据点都能完整画出(退化成单值时跨度 1 居中)。验证:离屏 `.preflight/check` **62/62 通过**(连跑 3 次稳定;含**真实文件** `TestData/MF4-Record.MF4`:`WIN_Motor_StallCurrentVolBrk`(1 点) + `m_MOT_Current_DCom[0]`(8475 点) 复现用户场景,散点圆心离绘图区左边界 16.5px、半径 15px ⇒ 完整可见);**A/B** 回退四处规则后 **20 项如实失败**(含"散点正好压在绘图区右边缘"、"初次视界 42.386s");`make release` 零警告、链接通过。 | | 2026-10-15 | DeepSeek-V4.1-Flash(**起源设备**) | **信号面板滚动条跟随 / 工具栏连点丢状态 / 图层网格线步长 / 多网格单点自适应 / 单点信号拖拽(详见 §9.32)**。① **滚动条"卡住"**:`Application::WM_MOUSEMOVE` 的 `plotOnlyMove` 只看 `inPlot && m_mouseOverPlot`,拖滚动条进绘图区后判定"只影响绘图区"⇒ 面板滚动值变了却不重绘;修法:新增 `draggingNonPlot`(正在拖非绘图区组件时一律完整失效)。② **工具栏丢点击**:窗口类带 `CS_DBLCLKS` ⇒ 快速第二次按下被合成 `WM_LBUTTONDBLCLK` 而工具栏没处理(**按下丢失**);且 `HandleMouseMove` 只要不在工具栏上就 `OnMouseLeave()` ⇒ 清掉 `m_pressedIndex`(按住划出再回按钮松手无效);两处都已修。③ **网格线错位/丢失/闪烁**:`PaintLane` 的竖向主刻步长取自 `Tab().ViewMin/ViewMax`,而渲染信号图层时该区间已被改成**宽窗口**(≈2×)⇒ 图层辅助线步长 2×、位置与前台不同、判重判据也错位;修法:新增 `m_renderTimeStep`,由 `RenderLaneLayerRegion` 用**前台跨度**算好传入。④ **多网格单点「适应视图」**:新增 `FitSinglePointLanes()`(左右边界 = 最左/最右数据点 ± **一个网格小格**;逐网格 Y 自适应、各留**一个 Y 小格**;全部点同刻 ⇒ 跨度 1s 居中;单值网格 ⇒ Y 跨度 1 居中)。⑤ **单点信号**:`HitTestSignal` 新增单点分支 + 环状光晕 + 可拖拽换网格(悬停**不改光标**,按下后才改)。验证:离屏 `.preflight/check` **67/67 通过**(连跑 3 次稳定);**A/B** 回退六处修复 ⇒ **17 项如实失败**(含 [C] 辅助线 49px vs 25px、20 vs 40 条、最大偏移 539px);`make release` 零警告、链接通过。 | @@ -2208,6 +2288,31 @@ GDI+ 抗锯齿逐段扫描这些**近竖直、满幅**的边,代价爆炸( `Application::SynchronizeView` 会**跳过初次自适应**,视界停在“无信号”那次的整段范围上, 单值信号的数据点既不居中、还会有一半被裁在网格外(用户 2026-10-15 报告)。 另外「适应视图」两条路径(初次自适应 / 工具栏按钮)必须**只有一条实现**(都走 `FitAll`)。 +72. ⚠️ **上层元素的交互不得穿透到下层**(用户 2026-10-15 第六轮明确要求): + 实验性功能的浮动面板浮在网格之上,`PlotView::OnDoubleClick` 必须**最先**用 + `TopPanelAtPoint()` 判定并消费 —— 旧实现在"差值标签/光标标签/光标线/图例都不命中"时 + 无条件 `FitAll()`,于是在面板上双击会让网格莫名其妙地自适应一次。 + 新增/修改任何"空白处点击/双击"的默认行为时,都要先过一遍所有更上层的元素 + (浮动面板、菜单、拾色器、装饰层)。 +73. ⚠️ **网格变色高亮的触发来源只有两个**(旧行为"鼠标在网格里移动就高亮"**已作废**): + ① 鼠标悬停在实验性功能浮动面板上 ⇒ 高亮该面板所属网格; + ② 信号列表 / 样式区「目标」行**悬停到某条已绘制信号** ⇒ 高亮它所在网格**并且 + 该信号曲线本身也高亮**(`PaintSignalHalo`)。 + `PlotView` 的 `m_externalHoverSignal` 必须与 `m_hoverSignal` **分开保存** + (绘图区 `OnMouseLeave` 会清掉后者,而列表悬停时鼠标不在绘图区内), + 并由 `Application::Render()` **每帧**同步(覆盖 WM_MOUSELEAVE / 数据变化 / 切标签页)。 + `SignalPanel::HoveredSignal()` 不能在 `InvalidateRows()` 之后继续有效(必须一并作废)。 +74. ⚠️ **样式区「目标」行**(用户 2026-10-15 第六轮要求):位于样式自定义区**最上方**, + 显示当前正在编辑的目标信号名称,**已绘制到网格时**在名称**后方**加 GX 标签; + 悬停名称/GX 同时触发绘图区网格高亮 + 曲线高亮;**单击不进入编辑**(只做联动滚动), + **双击才进入改名**(`StyleEditField::SIGNAL_NAME` + `m_editRow < 0` 表示目标信号)。 + 它的名称宽度与"是否有 GX 标签"都可能在**未重排**时变化 ⇒ 必须用 + `UpdateTargetRowGeometry()` 按需重算(带缓存键),不能只在 `LayoutStyleControls()` 里算一次。 +75. ⚠️ **联动滚动必须是平滑动画,且目标不得被悬浮组名遮挡**(用户 2026-10-15 第六轮要求): + `SignalPanel::ScrollToSignalAnimated()` + `AdvanceScrollAnimation()` + (ease-out cubic;时长 = `clamp(距离×0.35ms, 130, 300)`;宿主 16ms 定时器驱动)。 + 向上滚动时顶部要**让出一个组行高度**(悬浮组名只可能是目标自己那个分组的组名, + 因为分组在列表里连续排列)。用户滚轮 / 拖滚动条必须**立即取消**动画。 ### 11.3 离屏验证方法学(本机偏好,强烈建议沿用)