// SignalPanel 实现分片 —— 动画与滚动(分组折起、联动滚动、"滚动到目标信号")。 // // 本文件只包含 SignalPanel 的成员函数定义(机械搬移,行为不变)。 // ⚠️ 折起动画是"抽拉卷尺"式的刚性平移:只改 `m_rowShift`,**行高永不变**, // 动画期间不得重建行集合(见 agent_mem.md 的硬约束)。 #include "SignalPanelInternal.hpp" namespace jdi { 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(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(m_rows.size()) && m_rows[static_cast(row)].kind == RowKind::GROUP && m_rows[static_cast(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); } 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(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(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; } } // namespace jdi