diff --git a/Components/SignalPanel/Inc/SignalPanel.hpp b/Components/SignalPanel/Inc/SignalPanel.hpp index 039f795..838888c 100644 --- a/Components/SignalPanel/Inc/SignalPanel.hpp +++ b/Components/SignalPanel/Inc/SignalPanel.hpp @@ -91,9 +91,9 @@ namespace jdi m_hoveredSignal = SignalRef(); m_hoverRow = -1; m_hoverPart = RowPart::NONE; - // 分组下标可能整体位移 ⇒ 折起动画与挂起的折起请求也不能继续沿用。 + // 分组下标可能整体位移 ⇒ 折起动画(含与滚动的同步)也不能继续沿用。 m_groupAnims.clear(); - m_pendingGroupToggle = -1; + m_rowShift.clear(); } /** @@ -439,18 +439,32 @@ namespace jdi /** * @brief 一个分组的折起/展开动画状态。 * - * 用户 2026-10-16 要求:折起分组时不能下一帧立刻切换布局,而要有一段过渡动画 —— - * 被折起的信号向上收起、下方的分组随之上升;**离组名越远的成员收起越快**, - * 随后整体减速直到收起完成;总时长控制在一个合理范围(百余毫秒到 300ms)。 + * 用户 2026-10-16 第二轮要求(**推翻**第一轮的按行缩放):“整个展开和收起的过程类似于 + * 抽拉卷尺,卷尺上的元素相对位置是不会变的” —— 即**整组信号作为一个刚性整体平移**: + * 收起时所有信号一起向上平移、逐条消失在组名栏(越过组名下边界的部分被裁掉), + * 展开时逐条从组名栏下方冒出来。因为相对位置不变,所以**行高不发生变化** + * (无需每帧重建行集合),仅一个 `m_rowShift` 平移量在变 —— 这是动画流畅的关键。 * - * 动画期间 `BuildRows` 会按 `GroupRowScale()` 给出每行的收缩比例, - * 因此列表内容高度连续变化、下方分组平滑上升。 + * 另外:**悬浮(sticky)组名折起时不再“先滚动到位再折起”**,而是把滚动偏移与 + * 平移**同步推进**(`scrollSynced`),保证悬浮组名始终留在列表最上方, + * 动画结束时滚动偏移恰好落在“组名正好处于最上方”的位置。 */ struct GroupAnim { int groupIndex = -1; /**< 目标分组下标。 */ bool collapsing = false; /**< true = 正在折起,false = 正在展开。 */ int rowCount = 0; /**< 该分组参与动画的信号行数。 */ + float blockHeight = 0.0f; /**< 该分组全部信号行的总高(展开态)。 */ + /** + * @brief 动画起始时的平移量(0 = 全部露出,`blockHeight` = 全部藏进组名栏)。 + * + * 一般是“干净的一端”(折起从 0、展开从 `blockHeight`);但若动画中途被反向 + * 点击(折起没播完就点展开),就从**当前位移**续接,因此不会出现跳变。 + */ + float fromShift = 0.0f; + bool scrollSynced = false; /**< 是否让滚动偏移随动画同步推进。 */ + float scrollFrom = 0.0f; /**< 动画起始滚动偏移。 */ + float scrollTo = 0.0f; /**< 动画结束滚动偏移(仅 `scrollSynced` 时生效)。 */ std::uint64_t startMs = 0; /**< 动画起始时刻(`GetTickCount64()`)。 */ std::uint32_t durationMs = 0; /**< 动画总时长(毫秒)。 */ }; @@ -468,7 +482,10 @@ namespace jdi bool SignalScrollTarget(const SignalRef& ref, float& desired) const; /** - * @brief 列表内容中某一行顶部相对内容原点的纵向偏移(不包含滚动偏移)。 + * @brief 列表内容中某一行顶部相对内容原点的**自然**纵向偏移。 + * + * 不含滚动偏移,也不含折起动画的刚性平移(动画只平移“动画分组之后”的行, + * 而分组行之前的行不受影响)⇒ 该值就是滚动到“本行正好处于列表最上方”所需的偏移。 * @param [IN] row 行下标。 * @return 偏移量(像素)。 */ @@ -477,9 +494,8 @@ namespace jdi /** * @brief 启动一段滚动动画。 * @param [IN] desired 目标滚动偏移(内部会夹取到合法范围)。 - * @param [IN] pendingGroupToggle 滚动到位后要折起/展开的分组下标(-1 = 无)。 */ - void StartScrollAnimationTo(float desired, int pendingGroupToggle = -1); + void StartScrollAnimationTo(float desired); /** * @brief 判断某分组在**当前模式**(筛选 / 普通)下是否处于折起状态。 @@ -513,37 +529,58 @@ namespace jdi int FindGroupAnimation(int groupIndex) const; /** - * @brief 启动某分组的折起/展开动画。 - * @param [IN] groupIndex 分组下标。 - * @param [IN] collapsing 是否折起。 - * @param [IN] rowCount 该分组参与动画的信号行数。 + * @brief 取动画的**缓动后进度**(0 → 1,ease-out cubic:起步快、末尾减速)。 + * @param [IN] anim 动画条目。 + * @return 缓动后的进度。 */ - void StartGroupAnimation(int groupIndex, bool collapsing, int rowCount); + float AnimEasedProgress(const GroupAnim& anim) const; /** - * @brief 取某分组动画中第 ordinal 行的收缩比例。 - * - * 规则(用户 2026-10-16 要求):**离组名越远的成员收起速度越快**,整体减速(ease-out) - * 直到完成;因此越靠下的行越早消失,紧接着下方分组平滑上升。 - * + * @brief 某个分组行在列表中的下标。 * @param [IN] groupIndex 分组下标。 - * @param [IN] ordinal 该行在分组内「参与列出」的序号(0 = 紧邻组名的第一行)。 - * @param [IN] rowCount 该分组参与动画的行数。 - * @return 1 = 完整高度,0 = 完全收起。 + * @return 行下标;不存在返回 -1。 */ - float GroupRowScale(int groupIndex, int ordinal, int rowCount) const; + int GroupRowIndex(int groupIndex) const; /** - * @brief 切换分组折起状态并播放动画(不处理“悬浮先滚动”的前置动作)。 - * @param [IN] groupIndex 分组下标。 + * @brief 全部行的自然总高度(不含折起动画的折减)。 + * @return 高度(像素)。 */ - void ToggleGroupCollapsedAnimated(int groupIndex); + float NaturalContentHeight() const; /** - * @brief 点击分组行:若该组名此刻正悬浮在列表上方,先平滑滚动到顶部再播放折起动画。 + * @brief 某分组当前模式下会列出的信号行总高(展开态)。 + * @param [IN] groupIndex 分组下标。 + * @return 高度(像素)。 + */ + float ShownBlockHeight(int groupIndex) const; + + /** + * @brief 重新计算动画造成的“逐行刚性位移”与内容高度。 * - * 用户 2026-10-16 要求:悬浮状态下点击组名折起时,滚动条要先向上滚动, - * 直到悬浮组名处于**真正完整显示**的位置(即它的自然位置),再执行折起动画。 + * 每帧(或行集合重建后)调用一次:把每个进行中的动画的位移量累加到它之后的每一行上, + * 并据此得出 `m_contentHeight`;与滚动同步的动画同时推进 `m_scrollOffset`。 + * 行高本身不变,因此**不需要重建行集合**。 + */ + void UpdateAnimGeometry(); + + /** + * @brief 开始一个分组的折起/展开动画。 + * @param [IN] groupIndex 分组下标。 + * @param [IN] collapse true = 折起,false = 展开。 + * @param [IN] scrollFrom 动画起始滚动偏移。 + * @param [IN] scrollTo 动画结束滚动偏移(仅 `syncScroll` 为 true 时生效)。 + * @param [IN] syncScroll 是否让滚动偏移随动画同步推进(悬浮组名折起时用)。 + * @return 已开始动画返回 true(行数为 0 时无需动画,返回 false)。 + */ + bool StartGroupToggle(int groupIndex, bool collapse, float scrollFrom, float scrollTo, bool syncScroll); + + /** + * @brief 点击分组行:折起/展开该分组(悬浮时与滚动同步推进)。 + * + * 用户 2026-10-16 第二轮要求:悬浮组名折起时**不再**“先滚动到位再折起”, + * 而是**一边向上滚动一边收起**,保证悬浮组名始终在最上方;动画完成时滚动偏移 + * 恰好落在“组名正好在最上方”的位置,且信号已经收起。 * @param [IN] groupIndex 分组下标。 * @param [IN] row 分组行下标。 */ @@ -558,6 +595,16 @@ namespace jdi */ RowPart RowHitPart(int row, float x, float y) const; + /** + * @brief 折起/展开动画中,某信号行“被藏进组名栏”的上边界(组名的**绘制**下边界)。 + * + * 仅对本行所属分组正在播放动画的信号行有效;其余情况返回一个很小的值(等价于不裁剪)。 + * @param [IN] row 行下标。 + * @param [IN] stickyRow 当前悬浮的分组行下标(`StickyGroupRowIndex()` 的结果)。 + * @return 裁剪上边界(屏幕 Y 坐标)。 + */ + float AnimationHideTopForRow(int row, int stickyRow) const; + /** * @brief 重新计算「目标」行内信号名与 GX 标签的矩形。 * @@ -576,9 +623,12 @@ namespace jdi void CancelScrollAnimation() { m_scrollAnimActive = false; - // 挂起的“滚动到位后折起”也必须取消:用户已经自己滚动了, - // 再补一次折起会让人莫名其妙。 - m_pendingGroupToggle = -1; + // 与滚动同步的折起动画也不能再接管滚动偏移:用户已经自己滚动了, + // 继续同步会“抢”滚动条。折起/展开本身继续播放(只是不驱动滚动)。 + for (GroupAnim& anim : m_groupAnims) + { + anim.scrollSynced = false; + } } /** @@ -645,7 +695,8 @@ namespace jdi /** * @brief 把后续行内绘制裁剪到指定行矩形(与列表矩形相交)。 * - * 折起动画期间行高连续缩小,行内容必须裁到行矩形内,否则垂直居中的文本会溢出到相邻行。 + * 行内容必须裁到行矩形内:折起动画中信号行会滑到组名栏之下, + * 不裁剪的话行内容会溢出到相邻行(或组名栏)上。 * @param [IN] rowRect 行矩形。 */ void SetRowClip(const gd::RectF& rowRect) @@ -655,6 +706,21 @@ namespace jdi m_rowClipRect.Intersect(m_listRect); } + /** + * @brief 把当前行裁剪矩形的**上边界**抬到给定值(折起动画中信号行“藏进组名栏”用)。 + * @param [IN] top 新的裁剪上边界(屏幕 Y 坐标)。 + */ + void ClampRowClipTop(float top) + { + if (!m_rowClipActive || top <= m_rowClipRect.Y) + { + return; + } + const float bottom = m_rowClipRect.Y + m_rowClipRect.Height; + m_rowClipRect.Y = top; + m_rowClipRect.Height = std::max(0.0f, bottom - top); + } + /** @brief 结束行内裁剪(恢复为整个列表矩形)。 */ void ClearRowClip() { m_rowClipActive = false; } @@ -719,11 +785,13 @@ namespace jdi void PaintStickyGroupRow(gd::Graphics& g, int row); /** - * @brief 在列表区内绘制行内文本(**显式裁剪到列表矩形**)。 + * @brief 在列表区内绘制行内文本(**显式裁剪到列表矩形 / 当前行矩形**)。 * * ⚠️ `UiText` 走 GDI `DrawTextW`,**不遵循 GDI+ 的 `SetClip`**:列表需要滚动时, * 被滚出视口的行(顶部部分可见的两三行)会把组名/信号名画到上方筛选框上。 - * 因此行内文本一律经本函数绘制(见 `UiTextClipped`)。 + * 因此**列表行**内文本一律经本函数绘制(见 `UiTextClipped`)。 + * ⚠️ **样式区(筛选框上方之外的区域,如「目标」行)不得使用本函数** —— + * 它会把文本裁到 `m_listRect`(列表矩形之外的内容全都画不出来)。 * * @param [IN,OUT] g 绘图上下文。 * @param [IN] text 文本内容。 @@ -866,8 +934,10 @@ namespace jdi /** * @brief 计算当前输入框的矩形。 * @return 输入框矩形,未处于编辑状态时返回空矩形。 + * @note 「目标」行编辑时需要按当前目标信号的名称 / GX 标签重算几何(名称宽度), + * 因此不是 const。 */ - gd::RectF EditFieldRect() const; + gd::RectF EditFieldRect(); /** * @brief 计算「筛选信号」输入框的内容矩形(编辑与显示共用,保证不跳动)。 @@ -1001,17 +1071,19 @@ 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 动画造成的逐行刚性位移(与 `m_rows` 同长;内容坐标,绘制时从行顶部减去)。 + * + * 只有“动画分组的组名行之后”的行会被平移,因此组名本身始终留在原处, + * 信号行从它下面滑过去并被裁掉 —— 这就是“抽拉卷尺”观感的来源。 + * 行高不变 ⇒ 动画期间无需重建行集合(性能关键)。 + */ + std::vector m_rowShift; + /** * @brief 筛选模式下的分组折起状态(按分组下标)。 * @@ -1022,11 +1094,11 @@ namespace jdi std::vector m_filterCollapsed; /** - * @brief 当前正在绘制的行的裁剪矩形(用于折起动画中的部分高度行)。 + * @brief 当前正在绘制的行的裁剪矩形(用于折起动画中“藏进组名栏”的信号行)。 * - * 行高在折起动画期间连续缩小,若不把行内容裁剪到行矩形内, - * 文本会溢出到相邻行上(`UiText` 是 `DT_VCENTER` 垂直居中, - * 在变矮的矩形里会被裁掉上下两半,这正是想要的“挤压”观感)。 + * `SetRowClip` 先把行内容裁到本行矩形(∩ 列表矩形),折起/展开动画中再用 + * `ClampRowClipTop` 把上边界抬到组名栏的下边界 ⇒ 越过的部分被裁掉, + * 形成“滑进组名栏”的观感。 */ bool m_rowClipActive = false; gd::RectF m_rowClipRect; diff --git a/Components/SignalPanel/Src/SignalPanel.cpp b/Components/SignalPanel/Src/SignalPanel.cpp index 53281df..88d4650 100644 --- a/Components/SignalPanel/Src/SignalPanel.cpp +++ b/Components/SignalPanel/Src/SignalPanel.cpp @@ -115,27 +115,13 @@ namespace jdi const float kScrollAnimMsPerPixel = 0.35f; /**< 每像素距离增加的时长。 */ // ---- 分组折起/展开动画(用户 2026-10-16 要求)---- - // 总时长 = clamp(基础 + 行数×每行, 下限, 上限);上限与滚动动画同一量级, - // 保证“整个动画过程不会太久”。 + // 整组信号作为一个**刚性整体**向上平移(收起)/ 向下平移(展开), + // 越过组名下边界的部分被裁掉 —— 即“抽拉卷尺”观感:元素相对位置不变。 + // 总时长 = 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 kGroupAnimPerRowMs = 6.0f; /**< 每多一行增加的时长。 */ + const float kGroupAnimMinMs = 150.0f; /**< 最短时长。 */ + const float kGroupAnimMaxMs = 320.0f; /**< 最长时长。 */ const float kStyleContentMax = 260.0f; /**< 内容列最大宽度(再拉宽面板也不再延伸)。 */ const float kSwatchEdge = 18.0f; /**< 颜色色块边长上限(含自定义颜色入口)。 */ const float kSwatchMin = 11.0f; /**< 颜色色块边长下限。 */ @@ -332,9 +318,9 @@ namespace jdi m_pendingClosePart = RowPart::NONE; m_pendingCloseRow = -1; m_pendingFilterClear = false; - // 切换标签页 / 数据集合后,分组下标与滚动位置都变了:动画与挂起请求一律作废。 + // 切换标签页 / 数据集合后,分组下标与滚动位置都变了:动画与位移缓存一律作废。 m_groupAnims.clear(); - m_pendingGroupToggle = -1; + m_rowShift.clear(); m_scrollAnimActive = false; } @@ -392,7 +378,7 @@ namespace jdi // 退出筛选:筛选模式下临时折起的状态与动画一并作废(行集合会整体重建)。 m_filterCollapsed.clear(); m_groupAnims.clear(); - m_pendingGroupToggle = -1; + m_rowShift.clear(); // 清空后回到完整列表:重建行集合、回到顶部。 m_rowsDirty = true; m_scrollOffset = 0.0f; @@ -469,11 +455,11 @@ namespace jdi return; } m_filterText = text; - // 筛选内容变化 ⇒ 筛选结果整体重建:临时折起状态、进行中的折起动画与挂起请求全部作废 + // 筛选内容变化 ⇒ 筛选结果整体重建:临时折起状态、进行中的折起动画全部作废 //(行数与分组可见性都会变,继续沿用会算出错误的行集合)。 m_filterCollapsed.clear(); m_groupAnims.clear(); - m_pendingGroupToggle = -1; + m_rowShift.clear(); // 筛选结果变化 → 重建行集合、回到列表顶部并重新计算滚动条。 m_rowsDirty = true; m_scrollOffset = 0.0f; @@ -575,45 +561,35 @@ namespace jdi return -1; } - void SignalPanel::StartGroupAnimation(int groupIndex, bool collapsing, int rowCount) + int SignalPanel::GroupRowIndex(int groupIndex) const { - if (groupIndex < 0 || rowCount <= 0) + for (std::size_t index = 0; index < m_rows.size(); ++index) { - return; + if (m_rows[index].kind == RowKind::GROUP && m_rows[index].groupIndex == groupIndex) + { + return static_cast(index); + } } - // 同一分组只保留一个动画条目:再次切换时移除旧的,新动画从“当前状态”出发 —— - // 折起与展开的起点本来就分别是干净的 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; + return -1; } - float SignalPanel::GroupRowScale(int groupIndex, int ordinal, int rowCount) const + float SignalPanel::NaturalContentHeight() const { - const int animIndex = FindGroupAnimation(groupIndex); - if (animIndex < 0) + float total = 0.0f; + for (const ListRow& row : m_rows) { - // 没有动画:完全由折起状态决定(折起 = 不占高度)。 - return IsGroupCollapsed(groupIndex) ? 0.0f : 1.0f; + total += row.height; } + return total; + } - const GroupAnim& anim = m_groupAnims[static_cast(animIndex)]; + float SignalPanel::ShownBlockHeight(int groupIndex) const + { + return static_cast(ShownSignalCount(groupIndex)) * kSignalRowHeight * m_scale; + } + + float SignalPanel::AnimEasedProgress(const GroupAnim& anim) const + { const std::uint64_t now = GetTickCount64(); const std::uint64_t elapsed = (now >= anim.startMs) ? (now - anim.startMs) : 0; float progress = (anim.durationMs > 0u) @@ -623,21 +599,112 @@ namespace jdi { progress = 1.0f; } + // ease-out cubic:起步快、接近完成时明显减速(“快速展开 / 收起后慢慢减速”)。 + return 1.0f - std::pow(1.0f - progress, 3.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) + void SignalPanel::UpdateAnimGeometry() + { + m_rowShift.assign(m_rows.size(), 0.0f); + + float totalShift = 0.0f; + for (const GroupAnim& anim : m_groupAnims) { - local = 1.0f; + const float progress = AnimEasedProgress(anim); + // 收起:从起始位移推到“全部藏进组名栏”(= blockHeight); + // 展开:从起始位移推回 0(逐条从组名栏下方冒出)。 + // 起始位移在“中途反向点击”时就是当时的位移 ⇒ 不会跳变。 + const float target = anim.collapsing ? anim.blockHeight : 0.0f; + const float shift = anim.fromShift + (target - anim.fromShift) * progress; + + if (anim.scrollSynced) + { + // 与滚动同步推进:让悬浮组名始终留在列表最上方 + //(滚动偏移与平移量按同一进度推进,二者的合成让组名的屏幕位置恒定)。 + m_scrollOffset = anim.scrollFrom + (anim.scrollTo - anim.scrollFrom) * progress; + } + + const int headerRow = GroupRowIndex(anim.groupIndex); + if (headerRow < 0 || shift <= 0.0f) + { + continue; + } + // ⚠️ 平移只作用于**组名行之后**的行:组名自己是“卷尺的外壳”,始终不动, + // 信号行从它下面滑过去并被裁掉。 + for (int row = headerRow + 1; row < static_cast(m_rowShift.size()); ++row) + { + m_rowShift[static_cast(row)] += shift; + } + totalShift += shift; } - // ease-out cubic:起步快、接近完成时明显减速(“然后慢慢减速直到收起完成”)。 - const float eased = 1.0f - std::pow(1.0f - local, 3.0f); - return anim.collapsing ? (1.0f - eased) : eased; + + m_contentHeight = std::max(0.0f, NaturalContentHeight() - totalShift); + } + + bool SignalPanel::StartGroupToggle(int groupIndex, bool collapse, float scrollFrom, float scrollTo, + bool syncScroll) + { + if (groupIndex < 0 || m_tab == nullptr) + { + return false; + } + + // 同一分组只保留一个动画条目:再次切换时移除旧的,并从**当前位移**续接 + //(用户可能在折起没播完时就点展开,此时不能跳回干净的一端)。 + float fromShift = 0.0f; + const int existing = FindGroupAnimation(groupIndex); + if (existing >= 0) + { + const GroupAnim& previous = m_groupAnims[static_cast(existing)]; + const float previousProgress = AnimEasedProgress(previous); + const float previousTarget = previous.collapsing ? previous.blockHeight : 0.0f; + fromShift = previous.fromShift + (previousTarget - previous.fromShift) * previousProgress; + m_groupAnims.erase(m_groupAnims.begin() + existing); + } + + const float blockHeight = ShownBlockHeight(groupIndex); + if (blockHeight <= 0.5f) + { + // 没有信号行可动画(空分组):直接把状态定下来,并清掉可能的残留位移。 + UpdateAnimGeometry(); + m_rowsDirty = true; + return false; + } + if (existing < 0) + { + // 没有正在播放的动画 ⇒ 从“干净的一端”出发(展开 = 全部藏在组名栏内)。 + fromShift = collapse ? 0.0f : blockHeight; + } + + GroupAnim anim; + anim.groupIndex = groupIndex; + anim.collapsing = collapse; + anim.rowCount = ShownSignalCount(groupIndex); + anim.blockHeight = blockHeight; + anim.fromShift = ClampFloat(fromShift, 0.0f, blockHeight); + anim.scrollFrom = scrollFrom; + anim.scrollTo = scrollTo; + anim.scrollSynced = syncScroll && (std::fabs(scrollTo - scrollFrom) > 0.5f); + anim.startMs = GetTickCount64(); + // 时长随行数增长,但有上下限 ⇒ “整个动画过程不会太久”。 + const float durationMs = std::max( + kGroupAnimMinMs, + std::min(kGroupAnimMaxMs, + kGroupAnimBaseMs + kGroupAnimPerRowMs * static_cast(anim.rowCount))); + anim.durationMs = static_cast(durationMs + 0.5f); + + if (anim.scrollSynced) + { + // 立即接管滚动偏移(后续每帧由 `UpdateAnimGeometry` 推进)。 + m_scrollOffset = scrollFrom; + } + + m_groupAnims.push_back(anim); + // 折起时该组的信号行必须**留在行集合里**(它们正在“滑进组名栏”), + // 因此需要按“有动画”的规则重建一次行集合。 + m_rowsDirty = true; + UpdateAnimGeometry(); + return true; } bool SignalPanel::AdvanceGroupAnimations() @@ -648,13 +715,24 @@ namespace jdi } const std::uint64_t now = GetTickCount64(); + bool rowsBecameStale = 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; + } + // 折起完成后该组的信号行要**真正移除** ⇒ 行集合需重建 + //(展开完成后行集合本来就是展开态,无需重建;平移量归零即可)。 + if (anim.collapsing) + { + rowsBecameStale = true; + } m_groupAnims.erase(m_groupAnims.begin() + static_cast(index)); } else @@ -663,50 +741,80 @@ namespace jdi } } - // 动画期间每一帧的行高都不同 ⇒ 必须重建行集合(否则画面静止不动)。 - 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`**:行高不随动画变化,只需重算逐行位移与内容高度 —— + // 这正是本动画方案比“逐行缩放”流畅得多的原因(无需每帧重建行集合)。 + UpdateAnimGeometry(); + if (rowsBecameStale) { m_rowsDirty = true; } + return !m_groupAnims.empty(); } void SignalPanel::ToggleGroupFromRow(int groupIndex, int row) { EnsureRows(); - if (row < 0 || row >= static_cast(m_rows.size())) + if (m_tab == nullptr || groupIndex < 0) { - ToggleGroupCollapsedAnimated(groupIndex); return; } - // 该分组行的自然位置(内容坐标)恰好就是“让它完整显示在列表顶部”所需的滚动偏移。 - const float alignOffset = ContentOffsetBeforeRow(row); - if (m_scrollOffset > alignOffset + 0.5f) + 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) { - // 此刻组名正悬浮在列表上方(已部分滚出):用户 2026-10-16 要求先平滑滚动, - // 直到悬浮组名处于真正完整显示的位置,滚动到位后再播放折起动画。 - StartScrollAnimationTo(alignOffset, groupIndex); - return; + 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; + } } - ToggleGroupCollapsedAnimated(groupIndex); + + StartGroupToggle(groupIndex, collapse, scrollFrom, scrollTo, syncScroll); + } + + float SignalPanel::AnimationHideTopForRow(int row, int stickyRow) const + { + if (m_groupAnims.empty() || row < 0 || row >= static_cast(m_rows.size())) + { + return -1.0e9f; + } + const ListRow& item = m_rows[static_cast(row)]; + if (item.kind != RowKind::SIGNAL || FindGroupAnimation(item.groupIndex) < 0) + { + return -1.0e9f; + } + const int headerRow = GroupRowIndex(item.groupIndex); + if (headerRow < 0) + { + return -1.0e9f; + } + // 组名的**绘制**下边界:悬浮时用悬浮位置,否则用自然位置。 + // 信号行越过这条线即被裁掉 ⇒ 形成“滑进组名栏”的观感。 + const float headerTop = (stickyRow == headerRow) + ? StickyGroupRowTop(headerRow, static_cast(m_rows.size())) + : RowRect(headerRow).Y; + return headerTop + m_rows[static_cast(headerRow)].height; } void SignalPanel::BuildRows() @@ -717,6 +825,7 @@ namespace jdi if (m_dataSet == nullptr || m_tab == nullptr) { m_contentHeight = 0.0f; + m_rowShift.clear(); return; } @@ -745,13 +854,7 @@ namespace jdi } 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)]; - } + const bool animating = (FindGroupAnimation(gi) >= 0); ListRow fileRow; fileRow.kind = RowKind::GROUP; @@ -761,40 +864,28 @@ namespace jdi fileRow.displayCount = static_cast(shown.size()); m_rows.push_back(fileRow); - // 折起状态与动画共同决定每个信号行的实际高度: - // * 展开且无动画 ⇒ 完整高度; - // * 折起且无动画 ⇒ 不产生任何行; - // * 有动画 ⇒ 按 `GroupRowScale()` 连续缩小(折起)/ 放大(展开)。 - const int rowCount = static_cast(shown.size()); - for (int ordinal = 0; ordinal < rowCount; ++ordinal) + // 折起且没有动画 ⇒ 该组的信号行完全不占高度(不出现在行集合里)。 + // ⚠️ 有动画时必须**保留全部信号行**(含正在折起的):它们整组向上平移, + // 逐条滑进组名栏并被裁掉 —— 行高不变,因此动画期间无需重建行集合。 + if (collapsed && !animating) { - float scale = collapsed ? 0.0f : 1.0f; - if (anim != nullptr) - { - scale = GroupRowScale(gi, ordinal, rowCount); - } - const float height = signalHeight * scale; - if (height < kGroupAnimMinRowHeight * m_scale) - { - // 已经收成一条线:不再产生可绘制 / 可命中的行。 - continue; - } + continue; + } + for (int ordinal = 0; ordinal < static_cast(shown.size()); ++ordinal) + { ListRow signalRow; signalRow.kind = RowKind::SIGNAL; signalRow.groupIndex = gi; signalRow.signalIndex = shown[static_cast(ordinal)]; - signalRow.height = height; + signalRow.height = signalHeight; signalRow.ordinal = ordinal; m_rows.push_back(signalRow); } } - m_contentHeight = 0.0f; - for (const ListRow& row : m_rows) - { - m_contentHeight += row.height; - } + // 内容高度与逐行位移由动画状态推出(无动画时即“自然高度”)。 + UpdateAnimGeometry(); } void SignalPanel::EnsureRows() @@ -803,6 +894,12 @@ namespace jdi { BuildRows(); RefreshScrollBar(); + return; + } + if (m_rowShift.size() != m_rows.size()) + { + // 行集合没变但位移缓存与它不同步(例如首次构建后)—— 补齐,避免读到越界值。 + UpdateAnimGeometry(); } } @@ -858,6 +955,11 @@ namespace jdi { top += m_rows[static_cast(index)].height; } + // 折起/展开动画的刚性平移:组名行之后的行整组上移。 + if (row < static_cast(m_rowShift.size())) + { + top -= m_rowShift[static_cast(row)]; + } return gd::RectF(m_listRect.X, top, m_listRect.Width, m_rows[static_cast(row)].height); } @@ -1092,18 +1194,30 @@ namespace jdi return -1; } - float accumulated = 0.0f; + // 逐行比较“实际绘制位置”(含折起/展开动画的刚性平移),不能只看滚动偏移: + // 动画中信号行的位置与“自然内容坐标”不再一一对应,用后者会点错行。 + float rowTop = m_listRect.Y - m_scrollOffset; for (std::size_t index = 0; index < m_rows.size(); ++index) { + const float shift = (index < m_rowShift.size()) ? m_rowShift[index] : 0.0f; + const float previousShift = + (index > 0 && (index - 1) < m_rowShift.size()) ? m_rowShift[index - 1] : 0.0f; + rowTop -= (shift - previousShift); + const ListRow& row = m_rows[index]; - // ⚠️ 高度为 0 的行(折起动画中已收尽)不参与命中,否则它会白白吸走一次点击。 - if (row.height > 0.0f && local >= accumulated && local < accumulated + row.height) + const float rowBottom = rowTop + row.height; + if (row.height > 0.0f && fy >= rowTop && fy < rowBottom) { const int rowIndex = static_cast(index); - part = RowHitPart(rowIndex, fx, fy); - return rowIndex; + // 折起/展开动画中已“藏进组名栏”的行不再参与命中(否则它会白白吸走一次点击)。 + if (rowBottom > AnimationHideTopForRow(rowIndex, stickyRow) + 0.5f) + { + part = RowHitPart(rowIndex, fx, fy); + return rowIndex; + } + return -1; } - accumulated += row.height; + rowTop = rowBottom; } return -1; } @@ -1119,27 +1233,18 @@ namespace jdi return top; } - void SignalPanel::StartScrollAnimationTo(float desired, int pendingGroupToggle) + void SignalPanel::StartScrollAnimationTo(float desired) { - 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; } @@ -1354,18 +1459,23 @@ namespace jdi 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 bool badgeShown = (lane >= 0) && (badgeWidth + badgeGap < contentWidth); - // 名称可用宽度:有 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); + // 布局(用户 2026-10-16 要求“信号名与 GX 标签同时出现时左右宽度与其它控件对齐”): + // * 名称从**内容列左边缘**开始(与样式区其余行的控件同一左边界); + // * GX 标签**右对齐到内容列右边缘**(与滑轨/数值框等的右边界对齐), + // 而不是紧跟在名称后面(旧的“紧接名称”会让右边缘参差不齐); + // * 名称的可用宽度 = 到 GX 标签左侧(无标签时 = 整个内容列,因此名称可以更宽)。 + const float badgeLeft = contentLeft + contentWidth - badgeWidth; + const float nameRight = badgeShown ? (badgeLeft - badgeGap) : (contentLeft + contentWidth); + const float nameWidth = std::max(0.0f, nameRight - contentLeft); // 名称的可交互矩形:整行高,便于悬停(文本较短时也容易命中)。 m_targetNameRect = gd::RectF(contentLeft, m_targetRowRect.Y, nameWidth, m_targetRowRect.Height); if (badgeShown) { - m_targetBadgeRect = gd::RectF(contentLeft + nameWidth + badgeGap, + m_targetBadgeRect = gd::RectF(badgeLeft, m_targetRowRect.Y + (m_targetRowRect.Height - badgeHeight) * 0.5f, badgeWidth, badgeHeight); } @@ -1529,9 +1639,26 @@ namespace jdi { continue; } - // 行内容一律裁剪到**本行矩形**:折起动画期间行高连续缩小, - // 若不裁剪,垂直居中的文本会溢出到相邻行上。 + + // 折起/展开动画:信号行整组向上平移,越过组名下边界的部分要“消失(藏进组名栏)”。 + // 已经完全越过的行整行跳过(省一次绘制),部分越过的行把裁剪上边界抬到组名下边界; + // ⚠️ 此时还必须把 **GDI+ 裁剪**也收紧到同一条线 —— 行内的色块 / 复选框 / 网格徽标 + // 走的是 GDI+ 绘制(只受 `SetClip` 约束),不收紧就会画到组名栏上面去。 + const int rowSave = g.Save(); SetRowClip(rect); + const float hideTop = AnimationHideTopForRow(row, stickyRow); + if (hideTop > rect.Y + 0.5f) + { + if (rect.Y + rect.Height <= hideTop + 0.5f) + { + g.Restore(rowSave); + ClearRowClip(); + continue; + } + ClampRowClipTop(hideTop); + g.SetClip(m_rowClipRect, gd::CombineModeIntersect); + } + if (m_rows[static_cast(row)].kind == RowKind::GROUP) { PaintFileRow(g, row); @@ -1540,6 +1667,7 @@ namespace jdi { PaintSignalRow(g, row); } + g.Restore(rowSave); ClearRowClip(); } @@ -1613,7 +1741,7 @@ namespace jdi const float baseX = rect.X + kPadding * m_scale; const float centerY = rect.Y + rect.Height * 0.5f; - const bool collapsed = m_tab->IsCollapsed(item.groupIndex); + const bool collapsed = IsGroupCollapsed(item.groupIndex); // 折叠三角。 { @@ -2034,11 +2162,14 @@ namespace jdi { // 颜色与信号列表行一致:已绘制到网格用常态色,未绘制用弱色。 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); + // ⚠️ 这里**不能**用 `PaintRowText`(它把文本裁到 `m_listRect`,而「目标」行在样式区、 + // 列表矩形之外 ⇒ 文本会被完全裁掉,用户报告“信号名与 GX 标签都没正确绘制”)。 + UiText(g, UiEllipsize(signal->Title(), m_fonts->ui, m_targetNameRect.Width), m_fonts->ui, + m_targetNameRect, nameColor, TextAlign::LEFT); } - // GX 网格标签:信号已绘制到某个网格时显示在名称**后方**(用户 2026-10-15 要求)。 + // GX 网格标签:信号已绘制到某个网格时显示在名称**后方**(用户 2026-10-15 要求), + // 并与其它控件一样**右对齐到内容列右边缘**。 if (m_targetBadgeRect.Width > 0.0f) { const bool badgeHot = (m_hoverStyle == StyleHit::TARGET_BADGE); @@ -2046,9 +2177,8 @@ namespace jdi 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); + UiText(g, L"G" + std::to_wstring(m_targetGeomLane + 1), m_fonts->monoSmall, + m_targetBadgeRect, badgeHot ? m_theme->textStrong : m_theme->text, TextAlign::CENTER); } } @@ -2122,7 +2252,7 @@ namespace jdi { return; } - // 目标信号滚动与“悬浮组名折起”的前置滚动共用同一套动画(后者会带挂起请求)。 + // 把列表平滑滚动到目标信号完整可见。 StartScrollAnimationTo(desired); } @@ -2152,14 +2282,6 @@ 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; @@ -2465,7 +2587,7 @@ namespace jdi return gd::RectF(right - size, m_filterRect.Y + (m_filterRect.Height - size) * 0.5f, size, size); } - gd::RectF SignalPanel::EditFieldRect() const + gd::RectF SignalPanel::EditFieldRect() { switch (m_editField) { @@ -2475,14 +2597,12 @@ namespace jdi return m_opacityValueRect; case StyleEditField::SIGNAL_NAME: case StyleEditField::FILE_NAME: - // `row < 0` = 样式区「目标」行:输入框用内容列全宽(名称本身很短时也能舒适地编辑)。 + // `row < 0` = 样式区「目标」行:输入框只覆盖**信号名**那一块, + // 不得把名称后面的 GX 标签也包进去(用户 2026-10-16 报告)。 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); + UpdateTargetRowGeometry(); + return m_targetNameRect; } return RowNameRect(m_editRow); case StyleEditField::NONE: diff --git a/Documents/agent_mem.md b/Documents/agent_mem.md index 717e2ef..619f2e3 100644 --- a/Documents/agent_mem.md +++ b/Documents/agent_mem.md @@ -1946,6 +1946,10 @@ GDI+ 抗锯齿逐段扫描这些**近竖直、满幅**的边,代价爆炸( ### 9.35 分组折起/展开动画 + 筛选态可折起 + 悬浮组名可交互(含"先滚动再折起")+ 适应视图与 Y 轴自适应彻底同源(本轮,本机) +> ⚠️ **本节的「一」(按行缩放动画)与「三」里的“先滚动到位再折起”已被 §9.36 推翻/取代** +> (用户 2026-10-16 第二轮明确要求“抽拉卷尺”式整体平移,且悬浮组名折起改为**一边滚动一边收起**)。 +> 仍成立的部分:二(筛选态折起 + 项数按筛选计数)、三(悬浮组名命中不得穿透)、四(自适应同源)。 + > 本轮四项(用户 2026-10-16,本机 = 另一台设备)。离屏验证 `.preflight/perf/panelanim_main.cpp` > **94/94 通过**(连跑 3 次稳定);**A/B** 三组回退分别如实失败(4 / 13 / 8 项); > 既有 `verify.exe` **49/49**(顺带修正了 4 条与远端新规则不符的过期断言)、 @@ -2039,6 +2043,92 @@ GDI+ 抗锯齿逐段扫描这些**近竖直、满幅**的边,代价爆炸( (§9.21 已烘入数据);拖拽换位改为断言**被拖网格自身边界**判据的新目标值。 ⇒ `verify.exe` 现为 **49/49 全绿**。 +### 9.36 动画改为「抽拉卷尺」式刚性平移 + 筛选态折叠三角修正 + 样式区「目标」行绘制/布局修正(本轮,本机) + +> 本轮四项(用户 2026-10-16 第二轮,本机 = 另一台设备)。 +> 离屏验证 `.preflight/perf/panelanim_main.cpp` **147/147 通过**(连跑 3 次稳定); +> **A/B** 五组回退分别如实失败(4 / 6 / 6 / 2 / 1 项); +> 既有 `verify` **49/49**、`datacheck` **69/69**、`edgecheck` **46/46**;`make clean && make all` 零警告。 + +#### 一、分组动画重做为「抽拉卷尺」式**刚性平移**(推翻 §9.35 一) + +- **用户原话要点**:折起时所有信号一起向上平移、逐条消失在组名栏里;展开时逐条从组名栏向下冒出; + **卷尺上的元素相对位置不会变**;收起/展开要流畅(性能)。 +- **旧实现错在哪**:按「行高×比例」逐行缩放 —— 元素被压扁(相对位置改变), + 与“卷尺”完全不同;而且每帧行高都在变 ⇒ 每帧都要重建行集合(本可用性能)。 +- **新实现**(`SignalPanel::UpdateAnimGeometry` + `m_rowShift`): + * 动画只产生**一个逐行平移量** `m_rowShift[row]`(内容坐标),**行高永不变**; + 平移只作用于**组名行之后**的行 ⇒ 组名是“卷尺外壳”(始终不动),信号行从它下面滑过。 + * 收起:`shift = fromShift + (blockHeight − fromShift) × eased(p)`(推到全部藏进组名栏); + 展开:推到 0(逐条冒出)。`eased` = ease-out cubic;时长 `clamp(115 + 6×行数, 150, 320)` ms。 + * **中途反向切换从当前位移续接**(`GroupAnim::fromShift`)⇒ 连点折叠三角不会跳变 + (旧实现会跳回“干净的一端”)。 + * `m_contentHeight = 自然高度 − Σ shift`;`RowRect()` 减去本行位移; + `HitRow()` 改为**逐行比较实际绘制位置**(动画中行位置与“内容坐标”不再一一对应)。 + * ⚠️ **不得再改成“逐行缩放”**:方案的关键收益是**行高不变 ⇒ 动画期间无需重建行集合** + (探针直接断言中间帧 `m_rowsDirty == false`)。 +- **“藏进组名栏”的裁剪**:信号行越过**组名绘制位置的下边界**即被裁掉(`AnimationHideTopForRow`)。 + 已经完全越过的行整行跳过(省绘制)。 + ⚠️⚠️ **除了 GDI 裁剪(`PaintRowText`/`FillRowRect` 用的 `m_rowClipRect`)外,必须同时用 + `g.SetClip(clip, CombineModeIntersect)` 收紧 GDI+ 裁剪** —— 行内的色块/复选框/网格徽标走 GDI+, + 只受 `SetClip` 约束;不收紧就会画到组名栏上面去(A/B 实测失败)。 +- **实测**:3 行折叠 ≈ 4–5 帧、平均 **~10.8ms/帧**(离屏 harness 含整帧重绘)、最坏 ~13ms。 + +#### 二、悬浮组名折起:一遗滚动一遗收起(推翻 §9.35 三 的“先滚动到位再折起”) + +- **用户要求**:悬浮组名折起时不再先上滚再播放动画;而是**一遗向上滚动一遗收起**, + 保证悬浮组名始终在最上方;动画完成时滚动偏移恰好落在“组名正好在最上方”,且信号已经收起。 +- **实现**:`GroupAnim::scrollSynced` —— 滚动偏移与平移量按**同一进度**推进 + (`m_scrollOffset = scrollFrom + (scrollTo − scrollFrom) × eased`)。 + 数学上这让组名的屏幕位置**恒定**(只要起始越界量 Δ0 ≤ blockHeight,即“组名确实悬浮”的常态)。 + 目标 `scrollTo = min(组名的内容偏移, 折起后可达的最大滚动量)` —— 后者用于“折起后内容比列表还短” + 的情况(此时组名在物理上无法停到最上方,取可达值以避免结束时被强行夹取 ⇒ **无跳变**)。 +- 旧的 `m_pendingGroupToggle` / `PumpScroll` 两段式机制**已删除**; + 用户滚轮 / 拖滚动条 ⇒ `CancelScrollAnimation()` 仅解除同步(折叠/展开继续播放)。 +- **实测**:逐帧断言组名的**绘制位置**始终在列表上沿(<1.5px)、滚动单调上移、 + 结束时 `m_scrollOffset == 组名的内容偏移`(<1px),且随后 `ClampScroll()` 不再移动(无跳变)。 + +#### 三、筛选态折叠三角状态修正 + +- **用户报告**:筛选时分组行的小三角维持在了筛选前的状态,展开/折起都不变化。 +- **根因**:`PaintFileRow` 直接读 `m_tab->IsCollapsed(...)`(普通模式的折叠状态), + 而筛选模式用的是**独立**的 `m_filterCollapsed`(§9.35 二)⇒ 筛选里折叠时三角形不变。 +- **修法**:改为统一的 `IsGroupCollapsed(groupIndex)`(按当前模式分派)。 +- **验证**:离屏像素对比 —— 「筛选态折叠」与「普通态折叠」的组名行(含三角)**逐像素 0 差异**; + 且筛选态折叠前后三角区域确实有变化(有判别力)。A/B 失败 2 项。 + +#### 四、样式区「目标」行:绘制与布局修正 + +- **用户报告**:目标行显示的信号名与 GX 标签都没有正确绘制;双击进入编辑时输入框把 GX 标签包了进去。 +- **根因①②**: + * **没画出来**:`PaintTargetRow` 用了 `PaintRowText`,而它把文本裁到 `m_listRect`(列表矩形)—— + 目标行在**样式区**、根本不在列表矩形内 ⇒ 名称与 GX 文本被**完全裁掉**。 + ✓ 修法:目标行(以及样式区其它文本)一律用**不裁剪**的 `UiText`; + 并在 `PaintRowText` 的注释里写明“**样式区不得使用**”。 + * **输入框包住 GX 标签**:`EditFieldRect()` 对 `m_editRow < 0` 返回“内容列全宽”, + 而 GX 标签在列内(旧实现是紧跟名称)⇒ 输入框盖住标签。 + ✓ 修法:返回 `m_targetNameRect`(只管名称那一块),并断言“输入框与名称矩形逐值相等”。 +- **布局对齐**(用户要求:“同时出现时左右宽度与其它控件对齐;不显示 GX 标签时名称可以更宽”): + * 名称从**内容列左边缘**开始(与线宽滑轨/数值框同一左边界); + * GX 标签**右对齐到内容列右边缘**(而不是紧跟在名称后面); + * 名称可用宽度 = 到 GX 标签左侧;**无标签时 = 整个内容列**(因此更宽,但仍左对齐)。 +- **验证**:断言名称左缘 == `m_widthTrackRect.X`、GX 右缘 == `m_widthValueRect` 右缘、 + 两者不重叠;并用 `PixelsNear` 断言名称与 GX 的**文本像素确实存在**(旧实现下名称为 0 像素); + 无 GX 标签时名称更宽且仍两端对齐。A/B 失败 6 项。 +- ⚠️ 注意:`PaintTargetRow` 里的**悬停/点击/双击**三种交互仍要在绘制前重建几何 + (`m_targetRowRect` 只依赖布局,而 `m_targetNameRect/m_targetBadgeRect` 依赖名称与网格归属)。 + +#### 五、本轮探针(`.preflight/perf/panelanim_main.cpp`,不入库) + +- 新增/重写的断言组:`[A]` 刚性平移(行高不变、间距恒定、组名行不动、可见行数单调减少、 + **逐帧断言组名栏未被覆盖**、无行集合重建、帧耗时)、`[A3]` 中途反向切换续接不跳变、 + `[B]` 筛选态折起与计数、`[B2]` 筛选/普通态折叠三角逐像素一致、`[C]` 悬浮组名命中、 + `[D]/[D2]` 同步滚动(逐帧钉住 + 恰好落位 + 无跳变)、`[E]` 自适应同源、`[F]` 目标行绘制/布局/输入框。 +- ⚠️ **教训(已写入 §11.3)**:探针里直接 `app.Render()` 不会重绘面板(`m_chromeDirty` 已被上次清掉) + ⇒ 必须先 `app.m_chromeDirty = true`(`RenderFrame()`)。 + ⚠️ 动画循环里**最后一帧**会移除行集合并把位移归零,断言必须在 + `if (!GroupAnimationActive()) break;` **之后**再继续,否则会把“正常收尾”误判为“位移倒退”。 + --- ## 10. 变更日志(本文件维护记录) @@ -2089,6 +2179,7 @@ GDI+ 抗锯齿逐段扫描这些**近竖直、满幅**的边,代价爆炸( | 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` 零警告。 | +| 2026-10-16 | DeepSeek-V4.1-Flash(**另一台设备**) | **动画改「抽拉卷尺」式刚性平移 + 筛选态折叠三角修正 + 样式区「目标」行绘制/布局修正(详见 §9.36)**。① **动画重做**(推翻上一轮的“按行缩放”):整组信号作为**刚性整体**平移,**行高永不变**、只维护一个逐行平移量 `m_rowShift` ⇒ 动画期间**无需重建行集合**(流畅关键);收起时逐条被组名栏吞掉、展开时逐条冒出;中途反向点击**从当前位移续接**不跳变;时长 `clamp(115+6×行数, 150, 320)` ms。② **藏进组名栏的裁剪**:信号行越过组名的**绘制**下边界即裁掉,且除 GDI 裁剪外**必须用 `g.SetClip(..., CombineModeIntersect)` 同时收紧 GDI+ 裁剪**(否则行内色块/复选框/徽标画到组名栏上)。③ **悬浮组名折起改为“一边滚动一边收起”**(推翻“先滚动到位再折起”):`scrollSynced` 让滚动与平移按同一进度推进 ⇒ 组名屏幕位置恒定;结束时滚动偏移恰好落在“组名正好在最上方”,并且**无跳变**;旧 `m_pendingGroupToggle` 机制删除。④ **筛选态折叠三角**:`PaintFileRow` 改读 `IsGroupCollapsed()`(筛选模式用的是独立的 `m_filterCollapsed`),修复“三角维持筛选前状态”。⑤ **「目标」行**:`PaintTargetRow` 误用列表裁剪版 `PaintRowText` ⇒ 名称与 GX 文本被完全裁掉(改用 `UiText`);GX 标签改为**右对齐到内容列右边缘**、名称无标签时占满内容列;`EditFieldRect()` 对目标行只返回名称矩形(否则改名输入框把 GX 标签包进去)。验证:离屏探针 `.preflight/perf/panelanim_main.cpp` **147/147 通过**(连跑 3 次稳定;含逐帧断言组名栏像素零变化、`m_rowsDirty` 恒 false、滚动单调且恰好落位、筛选/普通态折叠三角逐像素 0 差异、目标行文本像素存在与两端对齐);**A/B** 五组(回退筛选三角+非刚性平移 / 回退目标行绘制与布局 / 关闭同步滚动 / 去掉 GDI+ 收紧裁剪 / 不续接位移)分别 **4 / 6 / 6 / 2 / 1 项如实失败**;`verify` **49/49**、`datacheck` **69/69**、`edgecheck` **46/46**;`make clean && make all` 零警告。 | --- @@ -2394,34 +2485,53 @@ GDI+ 抗锯齿逐段扫描这些**近竖直、满幅**的边,代价爆炸( (否则「适应视图」与「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` 垂直居中, - 不裁剪就会把文字溢出到相邻行上。 +77. ⚠️ **信号面板「分组折起/展开」必须是「**抽拉卷尺**」式动画**(用户 2026-10-16 第二轮, + 推翻第一轮的“按行缩放”):整组信号作为一个**刚性整体**平移 —— 收起时所有信号一起向上移动、 + 逐条消失(被组名栏吞掉),展开时逐条从组名栏下方冒出;**卷尺上的元素相对位置不变** + ⇒ **行高绝不改变**,只维护一个逐行平移量 `m_rowShift`(`UpdateAnimGeometry`)。 + 收起:`shift = fromShift + (blockHeight − fromShift) × easeOutCubic(p)`;展开:推到 0; + 时长 = `clamp(115 + 6×行数, 150, 320)` ms。 + ⚠️ **动画期间不得重建行集合**(`AdvanceGroupAnimations` 不能设 `m_rowsDirty`)—— + 这正是该方案流畅的关键(探针直接断言中间帧 `m_rowsDirty == false`)。 + ⚠️ 中途反向点击(折叠没播完就点展开)必须**从当前位移续接**(`GroupAnim::fromShift`),不许跳变。 +78. ⚠️ **平移只作用于“组名行之后”的行,并且必须双向裁剪**:组名是“卷尺外壳”始终不动。 + 信号行越过**组名绘制位置的下边界**就要被裁掉(`AnimationHideTopForRow` + `ClampRowClipTop`), + 已经完全越过的行整行跳过。 + ⚠️⚠️ 除了 GDI 裁剪(`PaintRowText`/`FillRowRect` 用的 `m_rowClipRect`),**必须同时用 + `g.SetClip(clip, CombineModeIntersect)` 收紧 GDI+ 裁剪** —— 行内的色块 / 复选框 / 网格徽标 + 走 GDI+,只受 `SetClip` 约束,不收紧就会画到组名栏上面(A/B 实测会失败)。 + (探针逐帧断言“组名栏区域像素零变化”。) 79. ⚠️ **筛选模式的分组折起状态与普通模式**分开**保存**(`m_filterCollapsed`,默认展开): 筛选只是临时子集视图,不该污染普通列表的布局选择;筛选文本变化 / 清空筛选 / 数据变化时 一律清空它以及进行中的折起动画(行数与分组可见性都变了)。 组名后方的项数必须按当前模式显示:普通 = 信号总数,筛选 = 该组内**符合筛选条件**的个数 (`ListRow::displayCount`,在 `BuildRows` 里填好;`PaintFileRow` 只读它)。 + ⚠️ 推论:**所有“读取折起状态”的地方都必须用 `IsGroupCollapsed()`(按模式分派)**, + 不能直接读 `m_tab->IsCollapsed()` —— 否则筛选态折叠时**折叠三角不会变** + (用户 2026-10-16 报告;已用“筛选态 vs 普通态折叠后组名行逐像素 0 差异”锁定)。 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`)。 + ⚠️ `HitRow` 必须**逐行比较实际绘制位置**(`m_rowShift` 参与),不能用“滚动偏移→内容坐标” + 的一刀切映射;并且要跳过完全藏进组名栏的行(否则空行会白白吸走一次点击)。 +81. ⚠️ **悬浮组名折起:一边向上滚动一边收起**(用户 2026-10-16 第二轮,**推翻** §9.35 三的 + “先滚动到位再折起”):`GroupAnim::scrollSynced` 让滚动偏移与平移量按**同一进度**推进 + (`m_scrollOffset = scrollFrom + (scrollTo − scrollFrom) × eased`), + 从而**组名的屏幕位置恒定**(悬浮组名始终在最上方)。 + 目标 `scrollTo = min(组名的内容偏移, 折起后可达的最大滚动量)`(后者处理“折起后内容比列表短”)。 + ⚠️ `m_pendingGroupToggle` 两段式机制**已删除,不要再恢复**; + 用户滚轮 / 拖滚动条 ⇒ `CancelScrollAnimation()` **只解除同步**,折叠/展开继续播放。 +82. ⚠️ **`PaintRowText` 只能用于“列表行”**(它把文本裁到 `m_listRect`,见第 53 条)。 + 样式区(如「目标」行)里的文本**必须用不裁剪的 `UiText`** —— 否则文本会被完全裁掉 + (用户 2026-10-16 报告:“目标”行的信号名与 GX 标签都没有正确绘制;A/B 实测该区域文本像素为 0)。 +83. ⚠️ **样式区「目标」行的布局**(用户 2026-10-16 要求):名称从**内容列左边缘**开始、 + GX 标签**右对齐到内容列右边缘**(与线宽滑轨/数值框同列宽同两端),两者不得重叠; + **无 GX 标签时名称可用宽度 = 整个内容列**(因此更宽,仍左对齐)。 + ⚠️ `EditFieldRect()` 对 `m_editRow < 0` 必须返回 `m_targetNameRect`(**只管名称那一块**), + 不能返回内容列全宽 —— 否则改名输入框会把 GX 标签包进去(用户报告)。 +84. ⚠️ **筛选态的折叠三角必须用 `IsGroupCollapsed()`**(见第 79 条推论):`PaintFileRow` 直接读 + `m_tab->IsCollapsed()` 时,筛选模式里折叠/展开三角**不会变化**(用户 2026-10-16 报告)。 71. ⚠️ **`FitAll()` 在没有任何可见信号时不得置位 `Tab().ViewInitialized()`**: 否则用户先连点几次「适应视图」(此时无信号被勾选)再勾选第一个信号, `Application::SynchronizeView` 会**跳过初次自适应**,视界停在“无信号”那次的整段范围上, @@ -2452,6 +2562,8 @@ GDI+ 抗锯齿逐段扫描这些**近竖直、满幅**的边,代价爆炸( (ease-out cubic;时长 = `clamp(距离×0.35ms, 130, 300)`;宿主 16ms 定时器驱动)。 向上滚动时顶部要**让出一个组行高度**(悬浮组名只可能是目标自己那个分组的组名, 因为分组在列表里连续排列)。用户滚轮 / 拖滚动条必须**立即取消**动画。 + ⚠️ 宿主定时器同时驱动「折起/展开动画」(见第 77 条):`Render` 的判定必须是 + `ScrollAnimationActive() || GroupAnimationActive()`。 ### 11.3 离屏验证方法学(本机偏好,强烈建议沿用)