diff --git a/Components/Application/Src/Application.cpp b/Components/Application/Src/Application.cpp index f3bacf7..6eddcc8 100644 --- a/Components/Application/Src/Application.cpp +++ b/Components/Application/Src/Application.cpp @@ -959,8 +959,8 @@ namespace jdi m_hintTimerActive = false; } - // 信号列表的目标信号滚动动画:仅在动画进行中保持定时重绘。 - if (m_panel.ScrollAnimationActive()) + // 信号列表的动画(目标信号滚动 / 分组折起展开):仅在动画进行中保持定时重绘。 + if (m_panel.ScrollAnimationActive() || m_panel.GroupAnimationActive()) { if (!m_scrollTimerActive) { @@ -3080,10 +3080,12 @@ namespace jdi InvalidateRect(hwnd, nullptr, FALSE); return 0; } - // 信号列表「滚动到目标信号」动画:推进一步并重绘(Render 会在结束后停表)。 + // 信号列表动画(「滚动到目标信号」与「分组折起 / 展开」):推进一步并重绘 + //(Render 会在动画结束后停表)。 if (wParam == kScrollTimerId) { m_panel.AdvanceScrollAnimation(); + m_panel.AdvanceGroupAnimations(); InvalidateRect(hwnd, nullptr, FALSE); return 0; } diff --git a/Components/PlotView/Src/PlotView.cpp b/Components/PlotView/Src/PlotView.cpp index 6d60e6e..574f2be 100644 --- a/Components/PlotView/Src/PlotView.cpp +++ b/Components/PlotView/Src/PlotView.cpp @@ -1684,6 +1684,11 @@ namespace jdi double sharedTime) { // 时间轴:该时刻居中、**一大格 = 1s**(视界 = 大格数 × 1s)。 + // + // 幅值轴与其余场景**共用同一段逻辑**(见函数末尾):`fitYToSelection` 的网格走 + // `AutoRangeLaneToView`、其余走整段自适应(`ComputeLaneRange`),两者都只由 + // `PadValueRange` 提供留白。这样「适应视图」与「Y 轴自适应」互为幂等 —— + // 同一视界下反复切换两者不会改变区间,也不会关掉 Y 轴自适应开关(用户 2026-10-16)。 const double span = static_cast(kTimeTickTarget) * kSinglePointClusterSecondsPerCell; SetTimeView(sharedTime - span * 0.5, sharedTime + span * 0.5); @@ -1701,50 +1706,30 @@ namespace jdi ? std::wstring() : L"横向或纵向拖动以放大选择区域,ESC退出选区模式"); + // ---- 逐网格的幅值轴 ---- + // ⚠️ **必须与 `FitAll()` 的常规路径、以及「Y 轴自适应」(`AutoRangeLaneToView`)同源**: + // 旧实现在这条特例里按“散点半径留白 + 跨度 1”另算一套区间,而 Y 轴自适应走 + // `PadValueRange`,于是对**同一个视界**两者会算出不同的幅值范围 + //(用户 2026-10-16 报告:单数据点信号上「适应视图」与「Y 轴自适应」的范围不一致); + // 并且旧实现还把 `fitYToSelection` 置回 false —— 原本开着「Y 轴自适应」时点一下 + // 「适应视图」会把这个开关**悄悄关掉**。 + // 现在两者共用同一段逻辑:留白只来自 `PadValueRange`,且**不改动** Y 轴自适应开关。 const float laneHeight = FitLaneHeight(); for (std::size_t index = 0; index < perLane.size() && index < m_tab->Lanes().size(); ++index) { - const std::vector& items = perLane[index]; - double valueLow = 0.0; - double valueHigh = 0.0; - float radius = 0.0f; - bool any = false; - for (const FitContribution& item : items) + const int laneIndex = static_cast(index); + if (m_tab->Lanes()[index].fitYToSelection) { - if (!item.singlePoint || !std::isfinite(item.pointValue)) - { - continue; - } - valueLow = any ? std::min(valueLow, item.pointValue) : item.pointValue; - valueHigh = any ? std::max(valueHigh, item.pointValue) : item.pointValue; - radius = std::max(radius, item.dotRadius); - any = true; + // 已开启「选区 Y 轴自适应」的网格按当前(刚设好的)时间视图重新匹配 —— + // 与工具栏开关打开时的算法完全一致,因此两种操作互为幂等。 + AutoRangeLaneToView(laneIndex); } - if (!any) + else { - continue; // 该网格没有可见单值信号:保持原样 + m_tab->Lanes()[index].autoRange = true; } - - // 幅值跨度:以容纳"网格内的全部数据点"为下限(退化成单值时取 1, - // 与"单值信号居中显示"的观感一致),并保证**每个散点都完整在网格内**。 - double range = std::max(1.0, valueHigh - valueLow); - if (laneHeight > 1.0f && radius > 0.0f) - { - for (int pass = 0; pass < 2; ++pass) - { - const double margin = - (static_cast(radius) + kSinglePointBoundaryPadPixels) * range / - static_cast(laneHeight); - range = std::max(1.0, valueHigh - valueLow + 2.0 * margin); - } - } - const double center = (valueLow + valueHigh) * 0.5; - - Lane& lane = m_tab->Lanes()[index]; - lane.autoRange = false; - lane.fitYToSelection = false; - lane.yMin = center - range * 0.5; - lane.yMax = center + range * 0.5; + // 补齐“被单值散点顶住的边界”(散点本来就完整可见时保持不变)。 + ApplySinglePointValueMargins(laneIndex, perLane[index], laneHeight); } InvalidateSceneCache(); diff --git a/Components/SignalPanel/Inc/SignalPanel.hpp b/Components/SignalPanel/Inc/SignalPanel.hpp index f59e45c..039f795 100644 --- a/Components/SignalPanel/Inc/SignalPanel.hpp +++ b/Components/SignalPanel/Inc/SignalPanel.hpp @@ -91,6 +91,9 @@ namespace jdi m_hoveredSignal = SignalRef(); m_hoverRow = -1; m_hoverPart = RowPart::NONE; + // 分组下标可能整体位移 ⇒ 折起动画与挂起的折起请求也不能继续沿用。 + m_groupAnims.clear(); + m_pendingGroupToggle = -1; } /** @@ -327,6 +330,15 @@ namespace jdi /** @brief 滚动动画是否正在进行。 */ bool ScrollAnimationActive() const { return m_scrollAnimActive; } + /** + * @brief 推进一步分组折起/展开动画(由宿主定时器驱动)。 + * @return 仍有分组动画在进行返回 true(宿主应保持定时重绘)。 + */ + bool AdvanceGroupAnimations(); + + /** @brief 分组折起/展开动画是否正在进行。 */ + bool GroupAnimationActive() const { return !m_groupAnims.empty(); } + /** * @brief 供绘图区联动高亮的「悬停信号」。 * @@ -409,6 +421,38 @@ namespace jdi int groupIndex = -1; /**< 所属文件下标。 */ int signalIndex = -1; /**< 信号下标(仅信号行有效)。 */ float height = 0.0f; /**< 行高(像素)。 */ + /** + * @brief 分组行显示的项数(仅分组行有效)。 + * + * 普通模式 = 该分组的信号总数;筛选模式 = 该分组内**符合筛选条件**的信号个数 + *(用户 2026-10-16 要求:筛选时组名后方的项数按筛选结果计数)。 + */ + int displayCount = 0; + /** + * @brief 该行所属分组内「参与列出」的序号(仅信号行有效)。 + * + * 用于折起动画按「离组名越远越快」计算每行的收缩比例。 + */ + int ordinal = 0; + }; + + /** + * @brief 一个分组的折起/展开动画状态。 + * + * 用户 2026-10-16 要求:折起分组时不能下一帧立刻切换布局,而要有一段过渡动画 —— + * 被折起的信号向上收起、下方的分组随之上升;**离组名越远的成员收起越快**, + * 随后整体减速直到收起完成;总时长控制在一个合理范围(百余毫秒到 300ms)。 + * + * 动画期间 `BuildRows` 会按 `GroupRowScale()` 给出每行的收缩比例, + * 因此列表内容高度连续变化、下方分组平滑上升。 + */ + struct GroupAnim + { + int groupIndex = -1; /**< 目标分组下标。 */ + bool collapsing = false; /**< true = 正在折起,false = 正在展开。 */ + int rowCount = 0; /**< 该分组参与动画的信号行数。 */ + std::uint64_t startMs = 0; /**< 动画起始时刻(`GetTickCount64()`)。 */ + std::uint32_t durationMs = 0; /**< 动画总时长(毫秒)。 */ }; /** @@ -423,6 +467,97 @@ namespace jdi */ bool SignalScrollTarget(const SignalRef& ref, float& desired) const; + /** + * @brief 列表内容中某一行顶部相对内容原点的纵向偏移(不包含滚动偏移)。 + * @param [IN] row 行下标。 + * @return 偏移量(像素)。 + */ + float ContentOffsetBeforeRow(int row) const; + + /** + * @brief 启动一段滚动动画。 + * @param [IN] desired 目标滚动偏移(内部会夹取到合法范围)。 + * @param [IN] pendingGroupToggle 滚动到位后要折起/展开的分组下标(-1 = 无)。 + */ + void StartScrollAnimationTo(float desired, int pendingGroupToggle = -1); + + /** + * @brief 判断某分组在**当前模式**(筛选 / 普通)下是否处于折起状态。 + * @param [IN] groupIndex 分组下标。 + * @return 折起返回 true。 + */ + bool IsGroupCollapsed(int groupIndex) const; + + /** + * @brief 设置某分组在**当前模式**下的折起状态。 + * + * ⚠️ 筛选模式与普通模式各有一套折起状态(`m_filterCollapsed` / `TabConfig`): + * 筛选只是临时的一个子集视图,不应污染用户在普通列表里的布局选择。 + * @param [IN] groupIndex 分组下标。 + * @param [IN] collapsed 是否折起。 + */ + void SetGroupCollapsed(int groupIndex, bool collapsed); + + /** + * @brief 某分组当前会列出的信号数量(筛选模式下为符合筛选条件的个数)。 + * @param [IN] groupIndex 分组下标。 + * @return 数量。 + */ + int ShownSignalCount(int groupIndex) const; + + /** + * @brief 查找某分组的折起动画条目下标。 + * @param [IN] groupIndex 分组下标。 + * @return 条目下标;不存在返回 -1。 + */ + int FindGroupAnimation(int groupIndex) const; + + /** + * @brief 启动某分组的折起/展开动画。 + * @param [IN] groupIndex 分组下标。 + * @param [IN] collapsing 是否折起。 + * @param [IN] rowCount 该分组参与动画的信号行数。 + */ + void StartGroupAnimation(int groupIndex, bool collapsing, int rowCount); + + /** + * @brief 取某分组动画中第 ordinal 行的收缩比例。 + * + * 规则(用户 2026-10-16 要求):**离组名越远的成员收起速度越快**,整体减速(ease-out) + * 直到完成;因此越靠下的行越早消失,紧接着下方分组平滑上升。 + * + * @param [IN] groupIndex 分组下标。 + * @param [IN] ordinal 该行在分组内「参与列出」的序号(0 = 紧邻组名的第一行)。 + * @param [IN] rowCount 该分组参与动画的行数。 + * @return 1 = 完整高度,0 = 完全收起。 + */ + float GroupRowScale(int groupIndex, int ordinal, int rowCount) const; + + /** + * @brief 切换分组折起状态并播放动画(不处理“悬浮先滚动”的前置动作)。 + * @param [IN] groupIndex 分组下标。 + */ + void ToggleGroupCollapsedAnimated(int groupIndex); + + /** + * @brief 点击分组行:若该组名此刻正悬浮在列表上方,先平滑滚动到顶部再播放折起动画。 + * + * 用户 2026-10-16 要求:悬浮状态下点击组名折起时,滚动条要先向上滚动, + * 直到悬浮组名处于**真正完整显示**的位置(即它的自然位置),再执行折起动画。 + * @param [IN] groupIndex 分组下标。 + * @param [IN] row 分组行下标。 + */ + void ToggleGroupFromRow(int groupIndex, int row); + + /** + * @brief 计算某行的可交互区域(使用 `RowRect` 的当前几何,含悬浮位置覆盖)。 + * @param [IN] row 行下标。 + * @param [IN] x 客户区横坐标。 + * @param [IN] y 客户区纵坐标。 + * @return 行内区域类型。 + */ + RowPart RowHitPart(int row, float x, float y) const; + /** * @brief 重新计算「目标」行内信号名与 GX 标签的矩形。 * @@ -438,7 +573,13 @@ namespace jdi void PaintTargetRow(gd::Graphics& g); /** @brief 取消正在进行的滚动动画(用户主动滚动时调用)。 */ - void CancelScrollAnimation() { m_scrollAnimActive = false; } + void CancelScrollAnimation() + { + m_scrollAnimActive = false; + // 挂起的“滚动到位后折起”也必须取消:用户已经自己滚动了, + // 再补一次折起会让人莫名其妙。 + m_pendingGroupToggle = -1; + } /** * @brief 重新构建列表行集合。 @@ -501,6 +642,22 @@ namespace jdi */ gd::RectF SignalRemoveRect(int row) const; + /** + * @brief 把后续行内绘制裁剪到指定行矩形(与列表矩形相交)。 + * + * 折起动画期间行高连续缩小,行内容必须裁到行矩形内,否则垂直居中的文本会溢出到相邻行。 + * @param [IN] rowRect 行矩形。 + */ + void SetRowClip(const gd::RectF& rowRect) + { + m_rowClipActive = true; + m_rowClipRect = rowRect; + m_rowClipRect.Intersect(m_listRect); + } + + /** @brief 结束行内裁剪(恢复为整个列表矩形)。 */ + void ClearRowClip() { m_rowClipActive = false; } + /** * @brief 绘制文件分组标题行。 * @param [IN,OUT] g 绘图上下文。 @@ -844,6 +1001,35 @@ namespace jdi float m_scrollAnimTo = 0.0f; std::uint64_t m_scrollAnimStartMs = 0; std::uint32_t m_scrollAnimDurationMs = 0; + /** + * @brief 滚动到位后要折起/展开的分组下标(-1 = 无)。 + * + * 悬浮组名上点击折叠时,必须先滚动到该组名完整可见,再播放折起动画 + *(用户 2026-10-16 要求)。 + */ + int m_pendingGroupToggle = -1; + + /** @brief 正在进行的分组折起/展开动画(可同时有多个分组)。 */ + std::vector m_groupAnims; + + /** + * @brief 筛选模式下的分组折起状态(按分组下标)。 + * + * 用户 2026-10-16 要求筛选结果也能以组为单位折起,但筛选只是一个临时视图, + * 因此使用与普通列表**分开**的一套状态,避免污染用户在普通列表里的布局选择。 + * 表长不足 / 越界 = 展开(筛选结果默认展开,便于直接看到匹配项)。 + */ + std::vector m_filterCollapsed; + + /** + * @brief 当前正在绘制的行的裁剪矩形(用于折起动画中的部分高度行)。 + * + * 行高在折起动画期间连续缩小,若不把行内容裁剪到行矩形内, + * 文本会溢出到相邻行上(`UiText` 是 `DT_VCENTER` 垂直居中, + * 在变矮的矩形里会被裁掉上下两半,这正是想要的“挤压”观感)。 + */ + bool m_rowClipActive = false; + gd::RectF m_rowClipRect; StyleEditField m_editField = StyleEditField::NONE; /**< 当前处于输入状态的字段。 */ int m_editRow = -1; /**< 行内输入所作用的列表行下标。 */ diff --git a/Components/SignalPanel/Src/SignalPanel.cpp b/Components/SignalPanel/Src/SignalPanel.cpp index e6e504a..53281df 100644 --- a/Components/SignalPanel/Src/SignalPanel.cpp +++ b/Components/SignalPanel/Src/SignalPanel.cpp @@ -113,6 +113,29 @@ namespace jdi const float kScrollAnimMinMs = 130.0f; /**< 最短时长(短距离)。 */ const float kScrollAnimMaxMs = 300.0f; /**< 最长时长(长距离)。 */ const float kScrollAnimMsPerPixel = 0.35f; /**< 每像素距离增加的时长。 */ + + // ---- 分组折起/展开动画(用户 2026-10-16 要求)---- + // 总时长 = clamp(基础 + 行数×每行, 下限, 上限);上限与滚动动画同一量级, + // 保证“整个动画过程不会太久”。 + const float kGroupAnimBaseMs = 115.0f; /**< 动画基础时长。 */ + const float kGroupAnimPerRowMs = 14.0f; /**< 每多一行增加的时长。 */ + const float kGroupAnimMinMs = 140.0f; /**< 最短时长。 */ + const float kGroupAnimMaxMs = 300.0f; /**< 最长时长。 */ + /** + * @brief 「离组名越远的成员收起越快」的深度增益。 + * + * 第 ordinal 行的速度倍率 = `1 + 增益 × ordinal / (行数-1)`:最深的一行倍率最高, + * 因此最先收起(实测 1.2 时最深一行在总时长的约 45% 处就已经收完), + * 而紧邻组名的第一行最后收完,配合 ease-out 形成“向上收起 + 减速”的观感。 + */ + const float kGroupAnimDepthBoost = 1.2f; + /** + * @brief 分组折起动画中“已被收成一条线”的行高下限(逻辑像素)。 + * + * 低于该高度的行不再产生(既不绘制也不参与命中),避免出现高度接近 0 + * 却仍能吸走一次点击的空行。 + */ + const float kGroupAnimMinRowHeight = 0.75f; const float kStyleContentMax = 260.0f; /**< 内容列最大宽度(再拉宽面板也不再延伸)。 */ const float kSwatchEdge = 18.0f; /**< 颜色色块边长上限(含自定义颜色入口)。 */ const float kSwatchMin = 11.0f; /**< 颜色色块边长下限。 */ @@ -309,6 +332,10 @@ namespace jdi m_pendingClosePart = RowPart::NONE; m_pendingCloseRow = -1; m_pendingFilterClear = false; + // 切换标签页 / 数据集合后,分组下标与滚动位置都变了:动画与挂起请求一律作废。 + m_groupAnims.clear(); + m_pendingGroupToggle = -1; + m_scrollAnimActive = false; } void SignalPanel::SetScale(float scale) @@ -362,6 +389,10 @@ namespace jdi // 编辑态下一并重置输入模型,避免残留旧文本(Begin 会整段选中/清空)。 m_filterEdit.Begin(std::wstring(), false, EditFilter::ANY, 0u); } + // 退出筛选:筛选模式下临时折起的状态与动画一并作废(行集合会整体重建)。 + m_filterCollapsed.clear(); + m_groupAnims.clear(); + m_pendingGroupToggle = -1; // 清空后回到完整列表:重建行集合、回到顶部。 m_rowsDirty = true; m_scrollOffset = 0.0f; @@ -438,6 +469,11 @@ namespace jdi return; } m_filterText = text; + // 筛选内容变化 ⇒ 筛选结果整体重建:临时折起状态、进行中的折起动画与挂起请求全部作废 + //(行数与分组可见性都会变,继续沿用会算出错误的行集合)。 + m_filterCollapsed.clear(); + m_groupAnims.clear(); + m_pendingGroupToggle = -1; // 筛选结果变化 → 重建行集合、回到列表顶部并重新计算滚动条。 m_rowsDirty = true; m_scrollOffset = 0.0f; @@ -461,8 +497,221 @@ namespace jdi return WildcardMatchNoCase(signal.Title(), pattern); } + bool SignalPanel::IsGroupCollapsed(int groupIndex) const + { + if (groupIndex < 0) + { + return false; + } + // 筛选模式使用**独立**的折起状态:筛选只是临时子集视图,不应污染普通列表的布局选择; + // 且筛选结果默认展开(便于直接看到匹配项)。 + if (!m_filterText.empty()) + { + if (static_cast(groupIndex) >= m_filterCollapsed.size()) + { + return false; + } + return m_filterCollapsed[static_cast(groupIndex)]; + } + return (m_tab != nullptr) && m_tab->IsCollapsed(groupIndex); + } + + void SignalPanel::SetGroupCollapsed(int groupIndex, bool collapsed) + { + if (groupIndex < 0) + { + return; + } + if (!m_filterText.empty()) + { + if (static_cast(groupIndex) >= m_filterCollapsed.size()) + { + m_filterCollapsed.resize(static_cast(groupIndex) + 1, false); + } + m_filterCollapsed[static_cast(groupIndex)] = collapsed; + return; + } + if (m_tab != nullptr && m_tab->IsCollapsed(groupIndex) != collapsed) + { + m_tab->ToggleCollapsed(groupIndex); + } + } + + int SignalPanel::ShownSignalCount(int groupIndex) const + { + if (m_dataSet == nullptr || groupIndex < 0) + { + return 0; + } + const SignalGroup* group = m_dataSet->GroupAt(groupIndex); + if (group == nullptr) + { + return 0; + } + if (m_filterText.empty()) + { + return static_cast(group->signals.size()); + } + int count = 0; + for (const SignalData& signal : group->signals) + { + if (FilterMatchesSignal(signal)) + { + ++count; + } + } + return count; + } + + int SignalPanel::FindGroupAnimation(int groupIndex) const + { + for (std::size_t index = 0; index < m_groupAnims.size(); ++index) + { + if (m_groupAnims[index].groupIndex == groupIndex) + { + return static_cast(index); + } + } + return -1; + } + + void SignalPanel::StartGroupAnimation(int groupIndex, bool collapsing, int rowCount) + { + if (groupIndex < 0 || rowCount <= 0) + { + return; + } + // 同一分组只保留一个动画条目:再次切换时移除旧的,新动画从“当前状态”出发 —— + // 折起与展开的起点本来就分别是干净的 1 与 0,因此重新开始即可。 + const int existing = FindGroupAnimation(groupIndex); + if (existing >= 0) + { + m_groupAnims.erase(m_groupAnims.begin() + existing); + } + + GroupAnim anim; + anim.groupIndex = groupIndex; + anim.collapsing = collapsing; + anim.rowCount = rowCount; + anim.startMs = GetTickCount64(); + // 时长随行数增长,但有上下限 ⇒ “整个动画过程不会太久”。 + const float durationMs = std::max( + kGroupAnimMinMs, + std::min(kGroupAnimMaxMs, + kGroupAnimBaseMs + kGroupAnimPerRowMs * static_cast(rowCount))); + anim.durationMs = static_cast(durationMs + 0.5f); + m_groupAnims.push_back(anim); + m_rowsDirty = true; + } + + float SignalPanel::GroupRowScale(int groupIndex, int ordinal, int rowCount) const + { + const int animIndex = FindGroupAnimation(groupIndex); + if (animIndex < 0) + { + // 没有动画:完全由折起状态决定(折起 = 不占高度)。 + return IsGroupCollapsed(groupIndex) ? 0.0f : 1.0f; + } + + const GroupAnim& anim = m_groupAnims[static_cast(animIndex)]; + const std::uint64_t now = GetTickCount64(); + const std::uint64_t elapsed = (now >= anim.startMs) ? (now - anim.startMs) : 0; + float progress = (anim.durationMs > 0u) + ? static_cast(elapsed) / static_cast(anim.durationMs) + : 1.0f; + if (progress > 1.0f) + { + progress = 1.0f; + } + + // 「被收起的组最下方成员离得越远收起速度越快」:第 ordinal 行的速度倍率 + // 随它与组名的距离线性增大,因此最深的一行最先收起,紧邻组名的一行最后收完。 + const float depth = (rowCount > 1) + ? static_cast(ordinal) / static_cast(rowCount - 1) + : 0.0f; + const float speed = 1.0f + kGroupAnimDepthBoost * depth; + float local = progress * speed; + if (local > 1.0f) + { + local = 1.0f; + } + // ease-out cubic:起步快、接近完成时明显减速(“然后慢慢减速直到收起完成”)。 + const float eased = 1.0f - std::pow(1.0f - local, 3.0f); + return anim.collapsing ? (1.0f - eased) : eased; + } + + bool SignalPanel::AdvanceGroupAnimations() + { + if (m_groupAnims.empty()) + { + return false; + } + + const std::uint64_t now = GetTickCount64(); + 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) + { + // 完成:移除条目,行高随即回到“折起状态”决定的最终值。 + m_groupAnims.erase(m_groupAnims.begin() + static_cast(index)); + } + else + { + ++index; + } + } + + // 动画期间每一帧的行高都不同 ⇒ 必须重建行集合(否则画面静止不动)。 + m_rowsDirty = true; + return !m_groupAnims.empty(); + } + + void SignalPanel::ToggleGroupCollapsedAnimated(int groupIndex) + { + if (m_tab == nullptr || groupIndex < 0) + { + return; + } + const bool collapse = !IsGroupCollapsed(groupIndex); + SetGroupCollapsed(groupIndex, collapse); + // 参与动画的行数 = 该组当前模式下会列出的信号数(筛选时只算匹配项)。 + const int count = ShownSignalCount(groupIndex); + if (count > 0) + { + StartGroupAnimation(groupIndex, collapse, count); + } + else + { + m_rowsDirty = true; + } + } + + void SignalPanel::ToggleGroupFromRow(int groupIndex, int row) + { + EnsureRows(); + if (row < 0 || row >= static_cast(m_rows.size())) + { + ToggleGroupCollapsedAnimated(groupIndex); + return; + } + + // 该分组行的自然位置(内容坐标)恰好就是“让它完整显示在列表顶部”所需的滚动偏移。 + const float alignOffset = ContentOffsetBeforeRow(row); + if (m_scrollOffset > alignOffset + 0.5f) + { + // 此刻组名正悬浮在列表上方(已部分滚出):用户 2026-10-16 要求先平滑滚动, + // 直到悬浮组名处于真正完整显示的位置,滚动到位后再播放折起动画。 + StartScrollAnimationTo(alignOffset, groupIndex); + return; + } + ToggleGroupCollapsedAnimated(groupIndex); + } + void SignalPanel::BuildRows() - { m_rows.clear(); + { + m_rows.clear(); m_rowsDirty = false; if (m_dataSet == nullptr || m_tab == nullptr) @@ -478,58 +727,65 @@ namespace jdi const std::vector& files = m_dataSet->Groups(); for (std::size_t groupIndex = 0; groupIndex < files.size(); ++groupIndex) { - // 筛选时先挑出本组内匹配的信号;整组都没有匹配则**不显示该组**。 - std::vector matched; - if (filtering) + const int gi = static_cast(groupIndex); + + // 参与列出的信号:筛选模式只保留匹配项;整组都没有匹配则**不显示该组**。 + std::vector shown; + const std::size_t signalCount = files[groupIndex].signals.size(); + for (std::size_t signalIndex = 0; signalIndex < signalCount; ++signalIndex) { - const std::size_t signalCount = files[groupIndex].signals.size(); - for (std::size_t signalIndex = 0; signalIndex < signalCount; ++signalIndex) + if (!filtering || FilterMatchesSignal(files[groupIndex].signals[signalIndex])) { - if (FilterMatchesSignal(files[groupIndex].signals[signalIndex])) - { - matched.push_back(static_cast(signalIndex)); - } - } - if (matched.empty()) - { - continue; + shown.push_back(static_cast(signalIndex)); } } + if (filtering && shown.empty()) + { + continue; + } + + const bool collapsed = IsGroupCollapsed(gi); + const int animIndex = FindGroupAnimation(gi); + const GroupAnim* anim = nullptr; + if (animIndex >= 0 && + m_groupAnims[static_cast(animIndex)].rowCount == static_cast(shown.size())) + { + anim = &m_groupAnims[static_cast(animIndex)]; + } ListRow fileRow; fileRow.kind = RowKind::GROUP; - fileRow.groupIndex = static_cast(groupIndex); + fileRow.groupIndex = gi; fileRow.height = fileHeight; + // 「N 项」按当前模式下实际列出的信号数显示:筛选时 = 该组内符合筛选条件的个数。 + fileRow.displayCount = static_cast(shown.size()); m_rows.push_back(fileRow); - // 筛选结果**默认展开**(忽略折叠状态),便于直接看到匹配项。 - if (!filtering && m_tab->IsCollapsed(static_cast(groupIndex))) + // 折起状态与动画共同决定每个信号行的实际高度: + // * 展开且无动画 ⇒ 完整高度; + // * 折起且无动画 ⇒ 不产生任何行; + // * 有动画 ⇒ 按 `GroupRowScale()` 连续缩小(折起)/ 放大(展开)。 + const int rowCount = static_cast(shown.size()); + for (int ordinal = 0; ordinal < rowCount; ++ordinal) { - continue; - } - - if (filtering) - { - for (const int signalIndex : matched) + float scale = collapsed ? 0.0f : 1.0f; + if (anim != nullptr) { - ListRow signalRow; - signalRow.kind = RowKind::SIGNAL; - signalRow.groupIndex = static_cast(groupIndex); - signalRow.signalIndex = signalIndex; - signalRow.height = signalHeight; - m_rows.push_back(signalRow); + scale = GroupRowScale(gi, ordinal, rowCount); + } + const float height = signalHeight * scale; + if (height < kGroupAnimMinRowHeight * m_scale) + { + // 已经收成一条线:不再产生可绘制 / 可命中的行。 + continue; } - continue; - } - const std::size_t signalCount = files[groupIndex].signals.size(); - for (std::size_t signalIndex = 0; signalIndex < signalCount; ++signalIndex) - { ListRow signalRow; signalRow.kind = RowKind::SIGNAL; - signalRow.groupIndex = static_cast(groupIndex); - signalRow.signalIndex = static_cast(signalIndex); - signalRow.height = signalHeight; + signalRow.groupIndex = gi; + signalRow.signalIndex = shown[static_cast(ordinal)]; + signalRow.height = height; + signalRow.ordinal = ordinal; m_rows.push_back(signalRow); } } @@ -688,8 +944,10 @@ namespace jdi m_rowYOverrideY = stickyY; // 悬浮行必须是不透明的,否则下面的信号行会与组名叠在一起看不清; // `FillRowRect` 会把矩形夹到列表矩形(GDI 填充不受 GDI+ 裁剪约束)。 + SetRowClip(gd::RectF(m_listRect.X, stickyY, m_listRect.Width, height)); FillRowRect(g, gd::RectF(m_listRect.X, stickyY, m_listRect.Width, height), m_theme->panelBg); PaintFileRow(g, row); + ClearRowClip(); // 只有真正“浮”在其它行之上时才补一条底部边界线(否则会与正常绘制有差异)。 if (stickyY > naturalY + 0.5f) { @@ -734,6 +992,59 @@ namespace jdi rect.Y + (rect.Height - size) * 0.5f, size, size); } + SignalPanel::RowPart SignalPanel::RowHitPart(int row, float fx, float fy) const + { + if (row < 0 || row >= static_cast(m_rows.size())) + { + return RowPart::NONE; + } + + const ListRow& item = m_rows[static_cast(row)]; + const gd::RectF rect = RowRect(row); + const bool isFile = (item.kind == RowKind::GROUP); + const float baseX = rect.X + kPadding * m_scale + (isFile ? 0.0f : kIndent * m_scale); + + const gd::RectF disclosureRect(baseX, rect.Y, kDisclosureWidth * m_scale, rect.Height); + const gd::RectF checkRect(baseX + kDisclosureWidth * m_scale, + rect.Y + (rect.Height - kCheckSize * m_scale) * 0.5f, + kCheckSize * m_scale, kCheckSize * m_scale); + + if (isFile) + { + if (UiPointInRect(disclosureRect, fx, fy)) + { + return RowPart::DISCLOSURE; + } + if (UiPointInRect(checkRect, fx, fy)) + { + return RowPart::CHECK; + } + if (UiPointInRect(FileCloseRect(row), fx, fy)) + { + return RowPart::CLOSE; + } + return RowPart::BODY; + } + + const SignalRef ref{item.groupIndex, item.signalIndex}; + const bool badgeVisible = (m_tab != nullptr) && m_tab->IsVisible(ref); + + // ✕ 命中优先于复选框:两者紧邻,顺序保证不会互相抢命中。 + if (UiPointInRect(SignalRemoveRect(row), fx, fy)) + { + return RowPart::REMOVE_SIGNAL; + } + if (UiPointInRect(checkRect, fx, fy)) + { + return RowPart::CHECK; + } + if (badgeVisible && UiPointInRect(SignalBadgeRect(row), fx, fy)) + { + return RowPart::LANE_BADGE; + } + return RowPart::BODY; + } + int SignalPanel::HitRow(int x, int y, RowPart& part) { part = RowPart::NONE; @@ -755,6 +1066,26 @@ namespace jdi return -1; } + // 「悬浮组名」绘制在最上层,因此命中判定也必须最优先 —— 否则点击会**穿透到 + // 它下面的那一行**(用户 2026-10-16 报告:组名悬浮时它上面的折叠三角、复选框、 + // 关闭 ✕、名称区域全都点不动,点击被下层信号行吃掉)。 + const int rowCount = static_cast(m_rows.size()); + const int stickyRow = StickyGroupRowIndex(rowCount); + if (stickyRow >= 0) + { + const float stickyTop = StickyGroupRowTop(stickyRow, rowCount); + const float stickyHeight = m_rows[static_cast(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; + } + } + const float local = fy - m_listRect.Y + m_scrollOffset; if (local < 0.0f) { @@ -765,60 +1096,11 @@ namespace jdi for (std::size_t index = 0; index < m_rows.size(); ++index) { const ListRow& row = m_rows[index]; - if (local >= accumulated && local < accumulated + row.height) + // ⚠️ 高度为 0 的行(折起动画中已收尽)不参与命中,否则它会白白吸走一次点击。 + if (row.height > 0.0f && local >= accumulated && local < accumulated + row.height) { const int rowIndex = static_cast(index); - const gd::RectF rect = RowRect(rowIndex); - const bool isFile = (row.kind == RowKind::GROUP); - const float baseX = rect.X + kPadding * m_scale + (isFile ? 0.0f : kIndent * m_scale); - - const gd::RectF disclosureRect(baseX, rect.Y, kDisclosureWidth * m_scale, rect.Height); - const gd::RectF checkRect(baseX + kDisclosureWidth * m_scale, - rect.Y + (rect.Height - kCheckSize * m_scale) * 0.5f, - kCheckSize * m_scale, kCheckSize * m_scale); - - if (isFile) - { - if (UiPointInRect(disclosureRect, fx, fy)) - { - part = RowPart::DISCLOSURE; - } - else if (UiPointInRect(checkRect, fx, fy)) - { - part = RowPart::CHECK; - } - else if (UiPointInRect(FileCloseRect(rowIndex), fx, fy)) - { - part = RowPart::CLOSE; - } - else - { - part = RowPart::BODY; - } - } - else - { - const SignalRef ref{row.groupIndex, row.signalIndex}; - const bool badgeVisible = (m_tab != nullptr) && m_tab->IsVisible(ref); - - // ✕ 命中优先于复选框:两者紧邻,顺序保证不会互相抢命中。 - if (UiPointInRect(SignalRemoveRect(rowIndex), fx, fy)) - { - part = RowPart::REMOVE_SIGNAL; - } - else if (UiPointInRect(checkRect, fx, fy)) - { - part = RowPart::CHECK; - } - else if (badgeVisible && UiPointInRect(SignalBadgeRect(rowIndex), fx, fy)) - { - part = RowPart::LANE_BADGE; - } - else - { - part = RowPart::BODY; - } - } + part = RowHitPart(rowIndex, fx, fy); return rowIndex; } accumulated += row.height; @@ -826,6 +1108,52 @@ namespace jdi return -1; } + float SignalPanel::ContentOffsetBeforeRow(int row) const + { + float top = 0.0f; + const int count = std::min(row, static_cast(m_rows.size())); + for (int index = 0; index < count; ++index) + { + top += m_rows[static_cast(index)].height; + } + return top; + } + + void SignalPanel::StartScrollAnimationTo(float desired, int pendingGroupToggle) + { + m_pendingGroupToggle = pendingGroupToggle; + + 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; + if (m_pendingGroupToggle >= 0) + { + const int groupIndex = m_pendingGroupToggle; + m_pendingGroupToggle = -1; + ToggleGroupCollapsedAnimated(groupIndex); + } + 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(); @@ -1201,6 +1529,9 @@ namespace jdi { continue; } + // 行内容一律裁剪到**本行矩形**:折起动画期间行高连续缩小, + // 若不裁剪,垂直居中的文本会溢出到相邻行上。 + SetRowClip(rect); if (m_rows[static_cast(row)].kind == RowKind::GROUP) { PaintFileRow(g, row); @@ -1209,6 +1540,7 @@ namespace jdi { PaintSignalRow(g, row); } + ClearRowClip(); } PaintStickyGroupRow(g, stickyRow); @@ -1236,9 +1568,11 @@ namespace jdi void SignalPanel::PaintRowText(gd::Graphics& g, const std::wstring& text, HFONT font, const gd::RectF& rect, std::uint32_t argb, TextAlign align) { - // 行内文本必须显式裁剪到列表矩形:GDI 文本不遵循 GDI+ 的 `SetClip`, + // 行内文本必须显式裁剪:GDI 文本不遵循 GDI+ 的 `SetClip`, // 否则列表滚动时顶部那些“部分可见”的行会把组名/信号名画到筛选框上。 - UiTextClipped(g, text, font, rect, argb, align, m_listRect); + // 另外,分组折起动画期间行高会连续缩小 —— 此时必须裁到**本行矩形**内, + // 否则垂直居中的文字会溢出到相邻行上。 + UiTextClipped(g, text, font, rect, argb, align, m_rowClipActive ? m_rowClipRect : m_listRect); } void SignalPanel::FillRowRect(gd::Graphics& g, const gd::RectF& rect, std::uint32_t argb) @@ -1246,7 +1580,7 @@ namespace jdi // 夹取而不是裁剪:纯色填充下两者等价,而夹取对 `UiFillRect` 的 GDI 快路径同样有效 //(大面积不透明矩形会改走 GDI `FillRect`,它同样不受 GDI+ 裁剪约束)。 gd::RectF clipped = rect; - clipped.Intersect(m_listRect); + clipped.Intersect(m_rowClipActive ? m_rowClipRect : m_listRect); if (clipped.Width <= 0.0f || clipped.Height <= 0.0f) { return; @@ -1302,7 +1636,9 @@ namespace jdi // 分组标题:左侧为“组X”标签,右侧以绘制的竖线分隔分组名(不使用字符),再显示可重命名的名称。 const std::wstring groupLabel = L"组" + std::to_wstring(item.groupIndex + 1); - const std::wstring stats = std::to_wstring(group->signals.size()) + L" 项"; + // 项数:普通模式 = 信号总数;筛选模式 = 该组内**符合筛选条件**的信号个数 + //(用户 2026-10-16 要求)。两者都在 `BuildRows` 里写入 `displayCount`。 + const std::wstring stats = std::to_wstring(item.displayCount) + L" 项"; const float groupWidth = FileGroupLabelWidth(row); const float statsWidth = UiMeasureTextWidth(m_fonts->uiSmall, stats); const float statsRight = closeRect.X - 6.0f * m_scale; @@ -1786,25 +2122,8 @@ namespace jdi { 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; + // 目标信号滚动与“悬浮组名折起”的前置滚动共用同一套动画(后者会带挂起请求)。 + StartScrollAnimationTo(desired); } bool SignalPanel::AdvanceScrollAnimation() @@ -1833,6 +2152,14 @@ namespace jdi m_scrollOffset = m_scrollAnimTo; m_scrollAnimActive = false; ClampScroll(); + // 若这次滚动是为了“把悬浮组名滚到完整显示位置后再折起”,此刻滚动已到位, + // 立即开始折起动画(用户 2026-10-16 要求的两段式过渡)。 + if (m_pendingGroupToggle >= 0) + { + const int groupIndex = m_pendingGroupToggle; + m_pendingGroupToggle = -1; + ToggleGroupCollapsedAnimated(groupIndex); + } return false; } return true; @@ -2990,7 +3317,7 @@ namespace jdi return false; } - const ListRow& item = m_rows[static_cast(row)]; + const ListRow item = m_rows[static_cast(row)]; if (item.kind == RowKind::GROUP) { @@ -3009,8 +3336,9 @@ namespace jdi m_pendingCloseRow = row; return true; } - m_tab->ToggleCollapsed(item.groupIndex); - m_rowsDirty = true; + // 折叠 / 展开分组走「带动画 + 悬浮时先滚动就位」的统一入口 + //(用户 2026-10-16 要求)。 + ToggleGroupFromRow(item.groupIndex, row); return true; } @@ -3208,15 +3536,15 @@ namespace jdi return false; } - const ListRow& item = m_rows[static_cast(row)]; + const ListRow item = m_rows[static_cast(row)]; // 分组行:双击折叠三角用于折叠,双击名称区域进入重命名。 if (item.kind == RowKind::GROUP) { if (part == RowPart::DISCLOSURE) { - m_tab->ToggleCollapsed(item.groupIndex); - m_rowsDirty = true; + // 与单击分组行同一入口:悬浮时先滚动就位再播放折起动画。 + ToggleGroupFromRow(item.groupIndex, row); return true; } if (part != RowPart::BODY) diff --git a/Documents/agent_mem.md b/Documents/agent_mem.md index 01b8e0f..717e2ef 100644 --- a/Documents/agent_mem.md +++ b/Documents/agent_mem.md @@ -1848,6 +1848,8 @@ GDI+ 抗锯齿逐段扫描这些**近竖直、满幅**的边,代价爆炸( - `[A]` 无信号时连点 3 次"适应视图" ⇒ `ViewInitialized()` 仍为 false;再勾选单值信号 ⇒ 初次自适应发生(时间跨度 **10**、以该点居中、幅值跨度 **1** 居中、散点落在绘图区正中)。 + ⚠️ 其中"幅值跨度 **1**"的期望**已被 §9.35 取代**:单点簇的幅值轴现在与常规路径/「Y 轴自适应」 + 完全同源(只由 `PadValueRange` 提供留白,退化时跨度 = `2×max(1,|值|×0.1)`)。 - `[B]` 时间边界:只有普通信号 ⇒ 视界**正好**等于数据范围;内部单值点 ⇒ 视界不变; 左/右边界处的单值点 ⇒ 边界外扩,且**外扩量 = 半径 + 1px**(实测 0.013408s),散点完整可见。 - `[C]` 幅值边界:单值点顶住某网格上边界 ⇒ 该网格上边界外扩、散点完整可见; @@ -1942,6 +1944,103 @@ GDI+ 抗锯齿逐段扫描这些**近竖直、满幅**的边,代价爆炸( --- +### 9.35 分组折起/展开动画 + 筛选态可折起 + 悬浮组名可交互(含"先滚动再折起")+ 适应视图与 Y 轴自适应彻底同源(本轮,本机) + +> 本轮四项(用户 2026-10-16,本机 = 另一台设备)。离屏验证 `.preflight/perf/panelanim_main.cpp` +> **94/94 通过**(连跑 3 次稳定);**A/B** 三组回退分别如实失败(4 / 13 / 8 项); +> 既有 `verify.exe` **49/49**(顺带修正了 4 条与远端新规则不符的过期断言)、 +> `datacheck` **69/69**、`edgecheck` **46/46**;`make release` 零警告。 + +#### 一、分组折起 / 展开改为动画过渡(`SignalPanel::GroupAnim`) + +- **需求**(用户):折起时被折起的信号**向上收起**、下方分组随之上升; + **被折起的组中最下方成员离得越远收起速度越快**,随后**慢慢减速**直到完成; + 总时长要在合理范围。 +- **实现**:新增 `m_groupAnims`(每分组一条,可并存)+ `GroupRowScale(group, ordinal, rowCount)`: + * 每行速度倍率 = `1 + kGroupAnimDepthBoost(1.2) × ordinal/(行数−1)`,`ordinal` = 该行在分组内 + 参与列出的序号(0 = 紧邻组名)⇒ **越靠下越快收尽**; + * 每行进度再过 **ease-out cubic** ⇒ 起步快、末段显著减速; + * 总时长 = `clamp(115 + 14×行数, 140, 300)` ms(3 行 ≈157ms;40 行 = 300ms)。 +- **接入点**:`BuildRows()` 把每个信号行的高度乘上比例(不再是非 0 即满高);低于 + `kGroupAnimMinRowHeight`(0.75 逻辑 px) 的行**不产生**;宿主 `WM_TIMER(kScrollTimerId)` + 同时调用 `AdvanceScrollAnimation()` 与 `AdvanceGroupAnimations()`, + `Render` 的定时器判定改为 `ScrollAnimationActive() || GroupAnimationActive()`。 +- **行内容裁剪**:新增 `SetRowClip/ClearRowClip`,`PaintRowText`/`FillRowRect` 在动画期间 + 裁到**本行矩形**(否则 `DT_VCENTER` 的文字会溢出到相邻行)。 +- **实测**(`panelanim.exe`):折起 3 行 = 12 帧 / 169ms,内容高度**单调下降**、末态 = 只剩组行; + 展开回到**逐像素级精确**的原始行高(无残留);中间帧可见"越靠下的行越矮"。 +- **已知取舍**:同一分组在动画中途被再次切换(快速连点折叠三角)会从"干净的一端"重新开始 + ⇒ 会有一次视觉跳变(未做跨方向插值)。 + +#### 二、筛选态下分组也能折起 + 项数按筛选结果计数 + +- **需求**(用户):筛选状态下筛选结果要**能以组为单位折起**,且组名后方的项数显示为 + 该组内**符合筛选条件**的信号个数;动画与上一条相同。 +- **实现**: + * 折起状态分模式保存:普通模式 = `TabConfig::IsCollapsed`(原有),筛选模式 = 新增 + `m_filterCollapsed`(默认**展开**)。`IsGroupCollapsed()/SetGroupCollapsed()` 按当前模式分派。 + ⇒ 筛选里折起不会污染普通列表的布局,清空筛选即恢复。 + * `BuildRows()` 不再"筛选时忽略折叠状态";`ListRow::displayCount` 记录当前模式下列出的 + 信号数,`PaintFileRow` 的「N 项」只读它。 + * 筛选文本变化 / 清空筛选 / 数据变化 ⇒ 清空 `m_filterCollapsed` + `m_groupAnims` + 挂起请求。 +- **实测**:筛选 `sig_b*` ⇒ 只列匹配组、项数 = 2、无匹配的组不出现;筛选里折起 A 组后 + `TabConfig::IsCollapsed(A)` 保持 false;清空筛选后 A 组仍为展开且项数回到 3。 + +#### 三、悬浮组名可正常交互 + 点击时"先滚动就位再折起" + +- **需求①②**(用户):组名悬浮在列表顶部时,它上面的所有元素都应能交互(此前点击会 + **穿透到下层行**);且悬浮状态下点击组名折起时,滚动条要**先向上滚动到组名真正完整显示 + 的位置**,再播放折起动画(筛选状态下同样适用)。 +- **根因**:`HitRow` 只按"滚动偏移把指针映射到内容位置"找行,而悬浮组名是**画在列表顶部**的 + 覆盖层(其自然位置已滚出列表)⇒ 点击它实际命中的是**它下面那一行**。 +- **修法**: + * `HitRow` 先用 `StickyGroupRowIndex()` + `StickyGroupRowTop()` 判定是否落在悬浮行矩形内, + 命中就临时打开 `m_rowYOverrideActive` 并通过新的 `RowHitPart()` 算行内区域(折叠三角 / + 复选框 / ✕ / 名称全部正确);同时跳过**高度为 0** 的行。 + * 新增 `ToggleGroupFromRow(group, row)`:若 `m_scrollOffset > 该行内容偏移`(= 正悬浮)⇒ + `StartScrollAnimationTo(该行内容偏移, groupIndex)`(把挂起请求记在 `m_pendingGroupToggle`), + 滚动到位后由 `AdvanceScrollAnimation` 的结束分支启动折起动画;否则直接切换并播放动画。 + 分组行的单击与"双击折叠三角"都走这个入口。 +- **实测**:悬浮组名的四个可交互区域全部命中该组行(A/B 回退后 13 项如实失败); + 点击组名后 `ScrollAnimationActive()==true` 且 `GroupAnimationActive()==false`(**先滚动**), + 滚动结束后 `m_scrollOffset == 该行的内容偏移`(组名恰好落在列表顶部)、随后才开始折起动画与 + 折叠生效。 + +#### 四、「适应视图」与「Y 轴自适应」彻底同源(单数据点信号) + +- **需求**(用户):以 `TestData/MF4-Record.MF4` 为例加载并只显示**一个单数据点信号**时, + 点「适应视图」与「Y 轴自适应」得到的**范围不一致**;且若原本开着 Y 轴自适应, + 点「适应视图」后 Y 视界会变、并且**「Y 轴自适应」被解除**。 +- **根因**(都在 `PlotView::FitSinglePointCluster`,即"全部可见数据点都是单值且同刻"的特例路径): + * 它给每个网格另算了一套幅值区间("散点半径留白 + **跨度 1**"),而 `AutoRangeLaneToView` + 走 `PadValueRange`(退化时跨度 = `2×max(1,|v|×0.1)`)⇒ 两者对**同一视界**结果不同; + * 它还显式写了 `lane.fitYToSelection = false` ⇒ 点「适应视图」就把 Y 轴自适应**关掉**了。 +- **修法**:时间轴部分仍是"以该时刻居中 + 一大格 1s",但**逐网格幅值轴改为与常规路径、 + 与 Y 轴自适应完全同源**:`fitYToSelection` 的网格 → `AutoRangeLaneToView()`;否则 → + `autoRange = true`;两者之后都接 `ApplySinglePointValueMargins()`。**不再改写 + `fitYToSelection`**。 +- **实测**(真实 MF4,`WIN_Motor_StallCurrentVolBrk` 只有 1 个有效点): + 适应视图的区间 == `PadValueRange(单点绘制值)`(逐位相同);打开 Y 轴自适应后区间**完全不变**; + 再点适应视图后区间不变、`fitYToSelection` 仍为 true、时间视界逐位不变; + 「适应视图 ↔ Y 轴自适应」反复往返**逐位稳定**。单点 + 连续信号同网格(常规路径)同样成立。 +- **行为变化(有意)**:单点信号的「适应视图」幅值跨度从旧的 **1** 变为 + `2×max(1,|值|×0.1)`(即与 `PadValueRange` 一致)。§9.33 用例 [A] 里"幅值跨度 1"的期望 + **已作废**;散点仍在网格正中且完整可见(`ApplySinglePointValueMargins` 兜底)。 + +#### 五、本机探针修正(`.preflight`,不入库) + +- 新增 `.preflight/perf/panelanim_main.cpp`(`panelanim.exe`):用真实 `Application` + (`SW_HIDE`)驱动 `SignalPanel` 做上述 [A]–[D],并用真实 `MF4-Record.MF4` 做 [E]。 +- ⚠️ **教训**:直接调 `Application::Render()` **不会**重绘面板(`m_chromeDirty` 已在上一次 + `Render` 里被清掉)⇒ 探针里必须 `app.m_chromeDirty = true` 再 Render(本轮的"动画模型"辅助函数 + `RenderFrame()`),否则行高不会随动画刷新,"看起来动画没生效"。 +- 顺带修正本机 `verify_main.cpp` 里 4 条与远端新规则不符的过期断言: + 两处幅值留白改为调用 **`PadValueRange`**(唯一来源);派生信号 `offsetTime` 改为断言 **0** + (§9.21 已烘入数据);拖拽换位改为断言**被拖网格自身边界**判据的新目标值。 + ⇒ `verify.exe` 现为 **49/49 全绿**。 + +--- + ## 10. 变更日志(本文件维护记录) | 日期 | 设备/模型 | 摘要 | @@ -1989,6 +2088,7 @@ GDI+ 抗锯齿逐段扫描这些**近竖直、满幅**的边,代价爆炸( | 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` 零警告、链接通过。 | +| 2026-10-16 | DeepSeek-V4.1-Flash(**另一台设备**) | **分组折起/展开动画 + 筛选态可折起(项数按筛选计数)+ 悬浮组名可交互与"先滚动再折起" + 「适应视图」与「Y 轴自适应」彻底同源(详见 §9.35)**。① **折起动画**:`SignalPanel::m_groupAnims` + `GroupRowScale()` —— 每行速度倍率 `1 + 1.2×ordinal/(行数−1)`(**离组名越远越快收尽**)叠加 ease-out cubic(末段减速),总时长 `clamp(115+14×行数, 140, 300)` ms;`BuildRows` 输出**非整行高**,低于 0.75 逻辑 px 的行不再产生;宿主 `WM_TIMER` 同时驱动滚动与折起动画(`Render` 判定改为二者取或);新增 `SetRowClip/ClearRowClip` 把行内容裁到本行(否则垂直居中文本溢出到相邻行)。② **筛选态折起**:筛选模式改用**独立**的 `m_filterCollapsed`(默认展开,不污染普通列表),`ListRow::displayCount` 承载"该组内符合筛选条件的个数",筛选文本/清空/数据变化即清空。③ **悬浮组名可交互**:根因是 `HitRow` 只按滚动偏移映射行 ⇒ 点击穿透到下层;修法:`HitRow` 先用 `StickyGroupRowIndex()`+`StickyGroupRowTop()` 判定悬浮行矩形并临时用 `m_rowYOverrideActive` + 新的 `RowHitPart()` 算行内区域,并跳过高度为 0 的行;新增 `ToggleGroupFromRow()`:正悬浮时 `StartScrollAnimationTo(行内容偏移, groupIndex)`(挂起请求 `m_pendingGroupToggle`),滚动到位后才启动折起动画。④ **单点信号上两种自适应不一致**:`FitSinglePointCluster` 曾另算"半径留白 + 跨度 1"且把 `fitYToSelection` 置回 false ⇒ 点「适应视图」既改区间又**关掉 Y 轴自适应**;修法:单点簇只负责时间轴(居中 + 一大格 1s),**逐网格幅值轴与常规路径/Y 轴自适应完全同源**(`fitYToSelection` → `AutoRangeLaneToView`,否则 `autoRange=true`,再接 `ApplySinglePointValueMargins`),且**绝不改写** `fitYToSelection`。验证:新增离屏探针 `.preflight/perf/panelanim_main.cpp`(真实 `Application` + `SW_HIDE`)**94/94 通过**(连跑 3 次稳定;覆盖折起/展开的分帧过渡、单调收敛、越靠下越矮、末态精确无残留、筛选计数与独立折起状态、悬浮四个可交互区域全部命中且下层行仍可点、点击后"先滚动到位再折起"、真实 MF4 单点信号两种自适应逐位一致/往返稳定);**A/B** 三组(回退绘图区修复 / 禁用悬浮命中优先 / 动画时长置 0)分别 **4 / 13 / 8 项如实失败**;既有 `verify` **49/49**(顺带修正 4 条与远端新规则不符的过期断言)、`datacheck` **69/69**、`edgecheck` **46/46**;`make release` 零警告。 | --- @@ -2281,8 +2381,47 @@ GDI+ 抗锯齿逐段扫描这些**近竖直、满幅**的边,代价爆炸( 常共用同一通道组(同一条轴),用轴判断会误判。 70. ⚠️ **特例:全部可见数据点都是单值信号且时刻完全相同**(`FitSinglePointCluster`): 时间轴**以该时刻居中**、缩放到 **一大格 = 1s**(视界 = `kTimeTickTarget × 1s` = 10s, - 此时 `NiceStep(10,10) == 1.0`);每个网格的幅值区间以“能完整画出该网格内的全部数据点” - 为准(退化成单值时以**跨度 1 居中**)。旧规则“跨度 1s 居中”“各留一小格”**已作废**。 + 此时 `NiceStep(10,10) == 1.0`);**幅值轴与其余场景完全同源**(见第 76 条): + `fitYToSelection` 的网格走 `AutoRangeLaneToView`、其余走整段自适应(`ComputeLaneRange`), + 两者都只由 `PadValueRange` 提供留白 —— 退化(单值)时的区间就是 + `[v − max(1,|v|×0.1), v + max(1,|v|×0.1)]`(跨度 = 2×留白)。 + ⚠️ 旧版曾在这里用"半径留白 + **跨度 1**"另算一套,已作废(会导致 + 「适应视图」与「Y 轴自适应」对同一视界算出**不同区间**)。 + 旧规则"跨度 1s 居中""各留一小格"同样已作废。 +76. ⚠️ **`FitAll()` 的两条路径(常规 / 单点簇)必须共用同一段逐网格幅值逻辑**: + `fitYToSelection` 的网格 → `AutoRangeLaneToView()`;否则 → `autoRange = true`; + 两者之后都接 `ApplySinglePointValueMargins()`。**不得**任何一条路径里另写一套区间 + (否则「适应视图」与「Y 轴自适应」会互相改变对方的结果)。 + ⚠️ 且 **`FitAll` 绝不可写 `lane.fitYToSelection`**:用户开着「Y 轴自适应」时点一下 + 「适应视图」会把它悄悄关掉(用户 2026-10-16 报告)。 +77. ⚠️ **信号面板「分组折起/展开」必须是动画过渡**(用户 2026-10-16 要求): + 被折起的信号**向上收起**、下方分组连续上浮;**离组名越远的成员收起越快** + (`GroupRowScale`:第 ordinal 行速度倍率 = `1 + kGroupAnimDepthBoost × ordinal/(行数−1)`, + 配合 ease-out cubic)⇒ 最深一行最先收尽;总时长 = `clamp(115 + 14×行数, 140, 300)` ms。 + 实现要点:`m_groupAnims`(可按分组并存)+ `AdvanceGroupAnimations()`(宿主 16ms 定时器 + 与滚动动画共用 `kScrollTimerId`,⚠️ `Application::Render` 的判定要同时看 + `ScrollAnimationActive() || GroupAnimationActive()`);`BuildRows` 按比例给出 + **非整行高**;低于 `kGroupAnimMinRowHeight`(0.75 逻辑 px)的行不再产生(不绘制也不命中)。 + ⚠️ 同一分组在动画中途被再次切换会从"干净的一端"重新开始(会有一次视觉跳变)—— 属已知取舍。 +78. ⚠️ **折起动画期间行内容必须裁剪到本行矩形**(`SetRowClip`/`ClearRowClip`,用于 + `PaintRowText` 与 `FillRowRect`):行高连续缩小时,`UiText` 是 `DT_VCENTER` 垂直居中, + 不裁剪就会把文字溢出到相邻行上。 +79. ⚠️ **筛选模式的分组折起状态与普通模式**分开**保存**(`m_filterCollapsed`,默认展开): + 筛选只是临时子集视图,不该污染普通列表的布局选择;筛选文本变化 / 清空筛选 / 数据变化时 + 一律清空它以及进行中的折起动画(行数与分组可见性都变了)。 + 组名后方的项数必须按当前模式显示:普通 = 信号总数,筛选 = 该组内**符合筛选条件**的个数 + (`ListRow::displayCount`,在 `BuildRows` 里填好;`PaintFileRow` 只读它)。 +80. ⚠️ **悬浮(sticky)组名的命中判定必须最优先**:`HitRow` 要先用 + `StickyGroupRowIndex()` + `StickyGroupRowTop()` 判是否落在悬浮行矩形内,命中则临时打开 + `m_rowYOverrideActive` 并用 `RowHitPart()` 算行内区域 —— 否则点击会**穿透到它下面的那一行** + (用户 2026-10-16 报告:悬浮组名的折叠三角/复选框/✕/名称全都点不动)。 + ⚠️ `HitRow` 同时要跳过**高度为 0** 的行(折起动画中已收尽),否则空行会白白吸走一次点击。 +81. ⚠️ **点击悬浮组名折起时,必须先平滑滚动到"组名完整显示在列表顶部"再播放折起动画** + (用户 2026-10-16 要求):`ToggleGroupFromRow()` 判 `m_scrollOffset > 组行内容偏移` + (= 正悬浮)时调 `StartScrollAnimationTo(组行内容偏移, groupIndex)`,滚动完成后由 + `AdvanceScrollAnimation` 的结束分支取出 `m_pendingGroupToggle` 启动折起动画。 + ⚠️ 用户滚轮 / 拖滚动条 / 筛选变化 / 切标签页都必须**清掉挂起请求**(`CancelScrollAnimation` / + `ApplyFilterText` / `ClearFilter` / `InvalidateRows` / `SetContext`)。 71. ⚠️ **`FitAll()` 在没有任何可见信号时不得置位 `Tab().ViewInitialized()`**: 否则用户先连点几次「适应视图」(此时无信号被勾选)再勾选第一个信号, `Application::SynchronizeView` 会**跳过初次自适应**,视界停在“无信号”那次的整段范围上,