diff --git a/Components/Application/Src/Application.cpp b/Components/Application/Src/Application.cpp index 584e8dc..86c7a74 100644 --- a/Components/Application/Src/Application.cpp +++ b/Components/Application/Src/Application.cpp @@ -1129,10 +1129,49 @@ namespace jdi const int x = GET_X_LPARAM(lParam); const int y = GET_Y_LPARAM(lParam); - // ⚠️ **工具栏必须在这里也当作“按下”处理**:窗口类带 `CS_DBLCLKS`,快速连点同一个 - // 按钮时,第二次(及之后的快速按下)会被 Windows 合成为 `WM_LBUTTONDBLCLK`。 - // 工具栏没有双击语义,若这里不处理,那一次按下就完全丢失 —— - // 用户看到的正是“连点好几下、开关状态只切换了几次”。 + // ⚠️⚠️ **没有双击语义的控件必须把这次「双击」当作一次普通按下**: + // 窗口类带 `CS_DBLCLKS`,快速连点同一个控件时第二次(及之后)按下会被 Windows + // 合成为 `WM_LBUTTONDBLCLK`。若这里不处理,那一次按下就完全丢失 —— + // 用户看到的正是“连点好几下、状态只切换了几次”(不跟手)。 + // 之前只给工具栏打了这个补丁;本轮(用户 2026-10-17)扩展到: + // 弹出菜单 / 拾色器 / 信号面板(复选框、全部显示隐藏、绝对值、折叠三角、「组X」…)/ + // 绘图区(浮动面板上的复选框与按钮、滚动条、GX 徽标)。 + // 组件各自用 `DoubleClickIsPlainPress(x, y)` 判定“这个落点有没有双击语义”。 + + // 弹出菜单打开时接管输入:菜单条目只有单击语义。 + if (m_menu.IsOpen()) + { + if (m_menu.IsOnEditField(x, y)) + { + m_menu.OnEditMouseDown(x, y); + SetCapture(hwnd); + m_activeTarget = Target::EDIT_FIELD; + InvalidateRect(hwnd, nullptr, FALSE); + return 0; + } + MenuResult result; + m_menu.OnLButtonDown(x, y, result); + InvalidateRect(hwnd, nullptr, FALSE); + if (result.valid) + { + DispatchMenuResult(result); + } + return 0; + } + + // 拾色器打开时接管输入(低一行于菜单、高于常规组件):其内部只有拖动语义。 + if (m_colorPicker.IsOpen()) + { + if (m_colorPicker.ContainsPoint(x, y)) + { + m_colorPicker.OnLButtonDown(x, y); + SetCapture(hwnd); + InvalidateRect(hwnd, nullptr, FALSE); + return 0; + } + } + + // 工具栏没有双击语义,若这里不当作按下,那一次按下就完全丢失。 if (m_toolbar.HitTest(x, y)) { SetFocus(hwnd); @@ -1144,6 +1183,55 @@ namespace jdi return 0; } + // 信号面板 / 绘图区:只有“改名”等少数落点保留双击语义,其余当作按下。 + if (m_panel.HitTest(x, y)) + { + SetFocus(hwnd); + if (m_panel.DoubleClickIsPlainPress(x, y)) + { + SetCapture(hwnd); + m_activeTarget = Target::PANEL; + if (m_panel.OnLButtonDown(x, y)) + { + ApplyPendingPlotLaneScroll(); + InvalidateRect(hwnd, nullptr, TRUE); + } + return 0; + } + bool changed = m_panel.OnDoubleClick(x, y); + if (changed) + { + InvalidateRect(hwnd, nullptr, TRUE); + } + ApplyPendingPanelScroll(); + return 0; + } + if (m_plot.HitTest(x, y)) + { + SetFocus(hwnd); + if (m_plot.DoubleClickIsPlainPress(x, y)) + { + SetCapture(hwnd); + m_activeTarget = Target::PLOT; + if (m_plot.OnLButtonDown(x, y)) + { + ApplyPendingRename(); + ApplyPendingDerivedBuild(); + InvalidateRect(hwnd, nullptr, FALSE); + } + ApplyPendingPanelScroll(); + UpdateDragCursor(x, y); + return 0; + } + if (m_plot.OnDoubleClick(x, y)) + { + InvalidateRect(hwnd, nullptr, TRUE); + } + ApplyPendingPanelScroll(); + ApplyPendingRename(); + return 0; + } + bool changed = false; if (m_tabBar.HitTest(x, y)) { @@ -1155,20 +1243,10 @@ namespace jdi Relayout(); } } - else if (m_panel.HitTest(x, y)) - { - changed = m_panel.OnDoubleClick(x, y); - } - else if (m_plot.HitTest(x, y)) - { - changed = m_plot.OnDoubleClick(x, y); - } if (changed) { InvalidateRect(hwnd, nullptr, TRUE); } - // 联动:双击图例改名也会选中该信号 ⇒ 同步把列表滚动到它。 - ApplyPendingPanelScroll(); return 0; } case WM_RBUTTONDOWN: @@ -1413,18 +1491,35 @@ namespace jdi return 0; } // 信号列表与绘图区动画(「滚动到目标信号」/「分组折起、展开」/「网格滚动到可视区」/ - //「回到最上方」按钮的淡入淡出):推进一步并整窗重绘(Render 会在动画结束后停表)。 - // ⚠️ 这里**保持整窗失效**(不要改成“只失效动画所在的区域”):面板与绘图区的 - // 回送范围由 `Render` 按 `fullFrame` 决定,任何“精确失效”都可能让屏幕上留下 - // 未刷新的区域(历史上就因此让滚动条/时间轴不跟手);而整窗失效下动画帧 - // 也只有 ~6-8ms(124 网格),完全在 16ms 预算内。 + //「回到最上方」按钮的淡入淡出):推进一步并重绘(Render 会在动画结束后停表)。 + // ⚠️ **失效范围必须与 `Render` 的绘制/回送判定完全配套**(用户 2026-10-17 报告: + // “滚动过渡动画甚至不如手动拖滚动条”)。旧实现无条件整窗失效 ⇒ `WM_PAINT` 看到 + // 更新区域不在绘图区内 ⇒ 把 `m_chromeDirty` 置真 ⇒ **每一帧都重绘标签栏/工具栏/面板** + // (实测 +4.7ms/帧:纯绘图区帧 6.7ms → 11.7ms),动画因此挤不进 15.6ms 的定时器预算 + // 而掉帧、位置跳动。 + // 现在的判据: + // * 面板动画(滚动到目标信号 / 分组折起展开)**确实**要改面板像素 ⇒ 整窗失效; + // * 只有绘图区动画(网格滚动到可视区 / 回到最上方)⇒ **只失效绘图区**, + // `WM_PAINT` 判定为“更新区域在绘图区内” ⇒ `m_chromeDirty` 保持 false ⇒ + // `Render` 只重绘并只回送绘图区(回送仍有无条件 else 兜底,未改 §9.41 的结构)。 + // ⚠️ 用**推进之前**的面板动画状态判定:动画结束的那一帧仍整窗失效, + // 保证面板的最终位置被完整重绘并回送。 if (wParam == kScrollTimerId) { + const bool panelAnimating = + m_panel.ScrollAnimationActive() || m_panel.GroupAnimationActive(); m_panel.AdvanceScrollAnimation(); m_panel.AdvanceGroupAnimations(); m_plot.AdvanceLaneScrollAnimation(); m_plot.AdvanceBackToTopAnimation(); - InvalidateRect(hwnd, nullptr, FALSE); + if (panelAnimating) + { + InvalidateRect(hwnd, nullptr, FALSE); + } + else + { + InvalidatePlotRect(hwnd, m_plotRect); + } return 0; } break; diff --git a/Components/Application/Src/ApplicationCommands.cpp b/Components/Application/Src/ApplicationCommands.cpp index 9b18754..c545557 100644 --- a/Components/Application/Src/ApplicationCommands.cpp +++ b/Components/Application/Src/ApplicationCommands.cpp @@ -120,6 +120,13 @@ namespace jdi // 首次出现可见信号时做一次自适应。单点信号(时间/幅值范围退化成 0)由 // `FitAll` 内部按"单值散点"的规则处理(居中 + 一大格 1s),这里不再分开判断。 m_plot.FitAll(); + // ⚠️ **网格纵向滚动也要回到最上方**(用户 2026-10-17 要求): + // “有过网格 → 没有信号显示 → 又加载了需要滚动条数量的网格”这一整类情形, + // 新出现的网格集必须从最上方开始看。旧实现保留了上一次的 `LaneScroll()`, + // 于是重新显示后会**停在与取消显示时相同的滚动位置**(用户报告)。 + // 放在 `FitAll()` 之后:`FitAll` 本身不改变滚动量(§11.2 第 112 条), + // 这里显式归零即可;随后 `Relayout`/`RefreshLanes` 会重算几何。 + tab.LaneScroll() = 0.0f; // 用户 2026-10-17 要求:光标工具处于激活状态(单 / 双光标)而之前**没有任何信号绘制**时, // 第一个信号出现后光标要按“光标出现的规则”重新出现一次,而不是停在旧的残留位置。 // 规则与 `CycleCursorMode()` 首次启用完全同源;光标工具未激活时本调用无副作用。 diff --git a/Components/PlotView/Inc/PlotView.hpp b/Components/PlotView/Inc/PlotView.hpp index c928693..261a683 100644 --- a/Components/PlotView/Inc/PlotView.hpp +++ b/Components/PlotView/Inc/PlotView.hpp @@ -251,6 +251,24 @@ namespace jdi */ bool OnDoubleClick(int x, int y); + /** + * @brief 双击落点是否应**当作普通「按下」**处理(该位置没有双击语义)。 + * + * ⚠️ 窗口类带 `CS_DBLCLKS`:快速连点同一个控件时,第二次(及之后)按下会被 Windows + * 合成为 `WM_LBUTTONDBLCLK`。若宿主把它交给 `OnDoubleClick`,而该位置本来只有 + * 「按下 / 单击」语义(浮动面板上的复选框与按钮、滚动条、GX 徽标…),那一次按下就 + * **完全丢失** —— 用户看到的正是“连点好几下、状态只切换了几次”(不跟手)。 + * + * ✓ 本函数为这些位置返回 true,宿主改走 `OnLButtonDown`(与单击完全相同的路径)。 + * 只有真正有双击语义的位置(时间光标线 / 读数标签 / 图例条目名 / 绘图区空白处 + * 的自适应)返回 false。 + * + * @param [IN] x 客户区横坐标。 + * @param [IN] y 客户区纵坐标。 + * @return 应当作普通按下返回 true。 + */ + bool DoubleClickIsPlainPress(int x, int y) const; + /** * @brief 处理鼠标离开绘图区。 * @return 需要重绘返回 true。 @@ -314,8 +332,8 @@ namespace jdi * * 用户 2026-10-17 **重新定义判据**(推翻此前的“一屏内数据点少于 N×4”): * 按**缩放后的横向时间轴网格**把小格切出来,**只要存在任何一个小格内的数据点数 - * 少于 `kAutoPointLineMinPointsPerCell`(=5)** 就自动切到「点线」提示用户数据点的位置 - *(见 `AutoPointLineNeeded`);一旦不再满足(适应视图、或把视界从稀疏区移到稠密区) + * 少于 `kSparseMinPointsPerCell`(=5)** 就自动切到「点线」提示用户数据点的位置 + *(见 `MinorCellSparse`);一旦不再满足(适应视图、或把视界从稀疏区移到稠密区) * 自动恢复**原来的**绘制方式。 * * ⚠️ 自动启用期间若用户**手动**改了绘制方式(`SignalStyle::autoScatterUserOverride` @@ -337,40 +355,29 @@ namespace jdi int GridMinorCellCount() const; /** - * @brief 判断某信号在当前视界下是否**开启光标吸附**。 + * @brief 判断某信号在当前视界下是否**按小格判定为稀疏**。 + * + * 用户 2026-10-17 定义的判据(**推翻**此前的“一屏内数据点 < N×4”): + * 按缩放后的**横向时间轴网格**把小格切出来(`GridMinorCellCount()`), + * **只要存在任何一个小格内的有效数据点少于 `kSparseMinPointsPerCell`(=5)** 就返回 true。 + * + * ⚠️ **这一个判定同时驱动两处**(用户 2026-10-17 明确要求“同样的状态判定”): + * * 「放大自动切点线」(`UpdateAutoScatterStyles`); + * * 「光标吸附」(`SnapTimeToSample`)—— 判定**完全不看绘制方式**, + * 因此即使用户手动改过显示样式,该吸附的信号照样吸附。 * - * 用户 2026-10-15 的定义:**当且仅当**该信号在“当前一屏”(当前视界)内的数据点 - * 个数 `< N × 4`(N = `GridMinorCellCount()`)时开启吸附。 * 判据随视界实时重算 ⇒ 疏密变化的信号在稀疏区自动吸附、稠密区自动自由移动。 - * - * ⚠️ 额外要求视界内**至少有一个可见数据点**:数据点全在视界之外的信号既没有可吸附 - * 的对象、也没有可提示的内容(否则它会被判成“0 < N×4 稀疏”而白切一次「点线」)。 + * ⚠️ 额外要求视界内**至少有一个可见数据点**:数据点全在视界之外的信号既没有可吸附的 + * 对象、也没有可提示的内容(否则它会被判成“小格全空”而白切一次「点线」)。 * ⚠️ 可见性一律按**显示时间**(`采样时刻 + timeShift`)判断 —— `timeShift` 就是该信号的 * `SignalStyle::offsetTime`(存在时间轴偏移时不能拿源时刻去比视界)。 * * @param [IN] axis 该信号使用的时间轴。 * @param [IN] signal 目标信号。 * @param [IN] timeShift 该信号的显示时间偏移(`offsetTime`)。 - * @return 开启吸附返回 true。 + * @return 存在少于 `kSparseMinPointsPerCell` 个数据点的小格返回 true。 */ - bool SignalSnapEnabled(const TimeAxis& axis, const SignalData& signal, double timeShift) const; - - /** - * @brief 判断某信号在当前视界下是否需要**自动切换到「点线」**(放大自动点线)。 - * - * 用户 2026-10-17 **重新定义**的判据(推翻此前的“一屏内数据点 < N×4”): - * 按缩放后的**横向时间轴网格**把小格切出来,**只要存在任何一个小格内的数据点数 - * 少于 `kAutoPointLineMinPointsPerCell`(=5)**,就返回 true。 - * - * 其余规则不变:不再满足时恢复原绘制方式;用户手动改过绘制方式时不恢复 - *(见 `UpdateAutoScatterStyles`)。光标吸附**仍用** `SignalSnapEnabled` 的整屏判据。 - * - * @param [IN] axis 该信号使用的时间轴。 - * @param [IN] signal 目标信号。 - * @param [IN] timeShift 该信号的显示时间偏移(`offsetTime`)。 - * @return 需要自动切换到「点线」返回 true。 - */ - bool AutoPointLineNeeded(const TimeAxis& axis, const SignalData& signal, double timeShift) const; + bool MinorCellSparse(const TimeAxis& axis, const SignalData& signal, double timeShift) const; /** * @brief 判断某信号在给定可见下标区间内是否存在**落在视界内**的有效采样点。 @@ -417,10 +424,13 @@ namespace jdi /** * @brief 把时间吸附到距今最近的数据采样点。 * - * 判定与半径(用户 2026-10-15 统一后的定义): - * * **判定只针对鼠标所在的网格**:该网格内的信号各自按 `SignalSnapEnabled` 判定, - * 符合条件的信号的数据点全部参与吸附;网格内没有符合条件的信号时完全自由移动; - * * 同一网格内有多条信号时,所有符合条件的数据点都参与比较; + * 判定与半径(用户 2026-10-17 修订): + * * **判定只针对鼠标下方所在的网格**:该网格内的信号各自按 `MinorCellSparse` + * (“任一小格内数据点少于 5 个”)判定,**符合条件的信号的数据点全部参与吸附**; + * * 网格内**既有符合条件也有不符合条件的信号**时:只吸附符合条件那条信号的数据点 + * (用户明确要求); + * * 网格内**没有**符合条件的信号时:光标完全自由移动(不吸附); + * * 判定**不看绘制方式** ⇒ 即使用户手动改了显示样式,该吸附的信号照样吸附; * * 吸附半径 = **一个网格小格的宽度**(与判定同源),且**不超过半个局部采样间距** * ⇒ 数据点很稀疏(一屏只有一两个点)时光标在点与点之间仍可自由移动, * 而不是被“死死吸住”(吸附只是提示性响应,不是卡住光标)。 @@ -3121,6 +3131,20 @@ namespace jdi * @return 可视返回 true。 */ bool LaneVisuallyRelevant(int laneIndex) const; + /** + * @brief 在「可视」基础上再多算 `prefetchScreens` 个绘图区高度的**预取带**。 + * + * ⚠️ 预取带只用于决定「是否还要请求下一帧去补图层」(`AnyVisibleLanePending` / 预热): + * 网格滚动动画很可能在几百毫秒内扫过一整个屏高,若只预建「严格可见」的网格, + * 动画扫进去的那几帧会落在一堆**尚未建过图层**的网格上 ⇒ 每帧都要靠底衬兜底 + * 画十几份网格(实测 ~8ms/帧)⇒ 动画明显卡顿(用户 2026-10-17 报告)。 + * 预取带让静置期间就把附近网格的图层建好。 + * ⚠️ **「结算」不能带预取带**(它要求的是“当前可见区域像素精确”)。 + * @param [IN] laneIndex 网格下标。 + * @param [IN] prefetchScreens 预取带宽度(倍绘图区高度,0 = 严格可见)。 + * @return 可视(或在预取带内)返回 true。 + */ + bool LaneVisuallyRelevant(int laneIndex, int prefetchScreens) const; /** @brief 全部**可视**网格图层是否都已就绪(用于判断能否做结算的整幅精确帧)。 */ bool LanesVisiblyReadyForView() const; /** @@ -3134,6 +3158,23 @@ namespace jdi * @return 仍有可视网格待重画返回 true。 */ bool AnyVisibleLanePending() const; + + /** + * @brief 「图层预热」是否允许再请求一帧(**限速**)。 + * + * ⚠️⚠️ 背景:待重画队列在“全部信号各自一块网格”的场景下可能有上百个**离屏**网格。 + * 若“队列非空就每帧都请求重绘”,会持续一秒以上地高频重绘,而 `WM_TIMER` 的优先级 + * **低于投递消息** ⇒ 动画定时器被饿死(实测 `frames=124 / 动画 tick=1`)。 + * 若反过来“离屏的一律不问”,则用户快速滚动到那些网格时它们**还没建过图层**, + * 每帧要靠底衬兜底画十几份网格(实测 ~8ms/帧)⇒ 动画卡顿 + *(用户 2026-10-17 报告:MF4 + VERY_BIG_DATA 时点选最后一个信号,滚动明显卡)。 + * + * ✓ 折中:以约 `kPrefetchMinIntervalMs` 的间隔**慢速**继续请求,让整批图层在静置期间 + * 逐步建好(对 130 个网格大约几秒),期间不会把动画的定时器挤出预算。 + * + * @return 允许请求返回 true(并记录本次时刻)。 + */ + bool PrefetchStepAllowed(); /** @brief 把某网格排进"待重画"队列(去重;视界/跨度后续变化时会在渲染时再次校验)。 */ void QueueLaneRebuild(int laneIndex); /** @@ -3250,6 +3291,14 @@ namespace jdi std::uint64_t m_wideLastKey = 0; bool m_wideLastValid = false; + /** + * @brief 上一次为「图层预热」请求宿主重绘的时刻(`GetTickCount64()`)。 + * + * 用来给预热**限速**(见 `PrefetchStepAllowed`):既保证离屏网格最终都会建好图层, + * 又不会高频请求重绘把动画的 `WM_TIMER` 饿死。 + */ + mutable std::uint64_t m_lastPrefetchRequestMs = 0; + /** * @brief 实验性功能浮动面板的像素图层项。 * diff --git a/Components/PlotView/Src/PlotView.cpp b/Components/PlotView/Src/PlotView.cpp index 017061a..fafd2ef 100644 --- a/Components/PlotView/Src/PlotView.cpp +++ b/Components/PlotView/Src/PlotView.cpp @@ -330,15 +330,19 @@ namespace jdi const std::uint64_t now = GetTickCount64(); const std::uint64_t elapsed = (now >= m_backToTopAnimStartMs) ? (now - m_backToTopAnimStartMs) : 0; - float progress = (kBackToTopFadeMs > 0.0f) - ? static_cast(elapsed) / kBackToTopFadeMs - : 1.0f; + // ⚠️ 用户 2026-10-17 要求:**淡出与提示浮层(`PaintHint`)完全一致** —— + // 到达顶部后**立即开始**淡出(不停留),时长 = `kBackToTopFadeOutMs`(= `kHintFadeMs`), + // 且 alpha **线性**变化(提示浮层就是线性淡出,看起来比 ease-out 更"渐变")。 + // 淡入(按钮出现)保留原来的 ease-out 手感,用户没有意见。 + const bool fadingOut = !m_backToTopFadeIn; + const float durationMs = fadingOut ? kBackToTopFadeOutMs : kBackToTopFadeInMs; + float progress = (durationMs > 0.0f) ? (static_cast(elapsed) / durationMs) : 1.0f; if (progress > 1.0f) { progress = 1.0f; } - // ease-out cubic:与其它过渡一致的手感(起步快、末尾慢)。 - const float eased = 1.0f - std::pow(1.0f - progress, 3.0f); + // 淡入:ease-out cubic(起步快、末尾慢);淡出:线性(与提示浮层同款)。 + const float eased = fadingOut ? progress : (1.0f - std::pow(1.0f - progress, 3.0f)); const float from = m_backToTopFadeIn ? 0.0f : 1.0f; const float to = m_backToTopFadeIn ? 1.0f : 0.0f; m_backToTopAlpha = from + (to - from) * eased; diff --git a/Components/PlotView/Src/PlotViewFeatures.cpp b/Components/PlotView/Src/PlotViewFeatures.cpp index 4433562..310f1ea 100644 --- a/Components/PlotView/Src/PlotViewFeatures.cpp +++ b/Components/PlotView/Src/PlotViewFeatures.cpp @@ -736,76 +736,8 @@ namespace jdi return true; } - bool PlotView::SignalSnapEnabled(const TimeAxis& axis, const SignalData& signal, - double timeShift) const - { - const int cells = GridMinorCellCount(); - if (cells <= 0 || signal.finiteCount == 0) - { - return false; - } - const std::size_t threshold = - static_cast(cells) * static_cast(kSnapVisibleCellsPerPoint); - if (threshold == 0) - { - return false; - } - - const std::size_t count = std::min(axis.time.size(), signal.values.size()); - if (count == 0) - { - return false; - } - std::size_t begin = 0; - std::size_t end = 0; - VisibleIndexRange(axis, count, begin, end, timeShift); - if (end <= begin) - { - return false; - } - - // ⚠️ 视界内**至少要有一个可见数据点**:一个数据点都不在屏幕上的信号既没有可吸附的 - // 对象、也没有可提示的内容。少了这一条,“数据点全都落在视界之外”的信号会被判成 - // “稀疏”(0 < N×4)而被自动切成「点线」,属于无意义的状态抖动。 - if (signal.finiteCount < threshold) - { - // 有效点总数本来就不足阈值 ⇒ 只需确认视界内确实有点(稀疏点已缓存时二分判定)。 - return HasVisibleSample(axis, signal, begin, end, timeShift); - } - - // 一般信号:统计**当前视界内**的有效点数量(攒够阈值立刻早退,代价有界)。 - // 连续 NaN 不计入有效点,因此再给一个**有界**的扫描窗口,防止极端情况下长循环。 - const std::size_t window = - std::min(std::min(end, signal.values.size()), begin + threshold * kSnapScanWindowFactor + 8u); - std::size_t seen = 0; - for (std::size_t index = begin; index < window; ++index) - { - if (!std::isfinite(signal.values[index])) - { - continue; - } - // ⚠️ 必须按**绘制时刻**判断可见性(`t + offsetTime`),否则存在时间轴偏移时 - // 会把“已经移出视界”的采样点算进来(用户报告吸附到历史位置)。 - const double time = axis.time[index]; - if (!std::isfinite(time)) - { - continue; - } - const double displayTime = time + timeShift; - if (displayTime < Tab().ViewMin() || displayTime > Tab().ViewMax()) - { - continue; - } - if (++seen >= threshold) - { - return false; // 一屏内的数据点已经足够密:保持自由移动 - } - } - return seen > 0; - } - - bool PlotView::AutoPointLineNeeded(const TimeAxis& axis, const SignalData& signal, - double timeShift) const + bool PlotView::MinorCellSparse(const TimeAxis& axis, const SignalData& signal, + double timeShift) const { const int cells = GridMinorCellCount(); if (cells <= 0 || signal.finiteCount == 0) @@ -839,7 +771,7 @@ namespace jdi } // 鸽巢原理:有效点总数都不足“每格 5 个” ⇒ 必然存在一个稀疏小格(O(1) 充分判定)。 const std::size_t need = static_cast(cells) * - static_cast(kAutoPointLineMinPointsPerCell); + static_cast(kSparseMinPointsPerCell); if (signal.finiteCount < need) { return true; @@ -864,7 +796,7 @@ namespace jdi std::lower_bound(axis.time.begin(), axis.time.end(), cellMin) - axis.time.begin()); const std::size_t hi = static_cast( std::lower_bound(axis.time.begin(), axis.time.end(), cellMax) - axis.time.begin()); - const std::size_t cap = std::min(hi, lo + kAutoPointLineCellScanCap); + const std::size_t cap = std::min(hi, lo + kSparseCellScanCap); std::size_t valid = 0; for (std::size_t index = lo; index < cap; ++index) { @@ -872,12 +804,12 @@ namespace jdi { continue; } - if (++valid >= static_cast(kAutoPointLineMinPointsPerCell)) + if (++valid >= static_cast(kSparseMinPointsPerCell)) { break; } } - if (valid < static_cast(kAutoPointLineMinPointsPerCell)) + if (valid < static_cast(kSparseMinPointsPerCell)) { return true; // 该小格稀疏 ⇒ 自动切「点线」 } @@ -888,7 +820,7 @@ namespace jdi // 非单调时间轴:一次有界扫描,按小格分桶计数。 std::vector buckets(static_cast(cells), 0); const std::size_t scanLimit = - kAutoPointLineCellScanCap * static_cast(cells); + kSparseCellScanCap * static_cast(cells); std::size_t scanned = 0; for (std::size_t index = begin; index < end; ++index) { @@ -915,14 +847,14 @@ namespace jdi cell = cells - 1; } const std::size_t slot = static_cast(cell); - if (buckets[slot] < kAutoPointLineMinPointsPerCell) + if (buckets[slot] < kSparseMinPointsPerCell) { ++buckets[slot]; } } for (int cell = 0; cell < cells; ++cell) { - if (buckets[static_cast(cell)] < kAutoPointLineMinPointsPerCell) + if (buckets[static_cast(cell)] < kSparseMinPointsPerCell) { return true; } @@ -1073,9 +1005,11 @@ namespace jdi // 少了这一步,信号一旦设置过「调整 → 时间轴偏移」,吸附就会落在**历史位置** // (用户 2026-10-15 报告“存在时间轴偏移调整时无法吸附到正确位置”)。 const double timeShift = (style != nullptr) ? style->offsetTime : 0.0; - // 只有“一屏内数据点少于 N×4”的信号参与吸附;判据随视界实时重算, - // 因此疏密变化的信号在稀疏区自动吸附、移到稠密区自动恢复自由移动。 - if (!SignalSnapEnabled(*axis, *signal, timeShift)) + // ⚠️ 判定与「放大自动切点线」**完全同源**(用户 2026-10-17 明确要求“同样的状态判定”): + // 只要存在任何一个小格内的数据点少于 5 个就参与吸附; + // 判定**不看绘制方式** ⇒ 即使用户手动改过显示样式,该吸附的照样吸附。 + // 网格内既有符合又有不符合的信号时,只有符合的那条的数据点进入比较(用户要求)。 + if (!MinorCellSparse(*axis, *signal, timeShift)) { continue; } @@ -3106,8 +3040,8 @@ namespace jdi // **只要存在任何一个小格内的数据点少于 5 个**就自动切「点线」(局部稀疏即提示); // 不再满足时(适应视图、或把视界移到信号的稠密区)自动恢复原样式。 // ⚠️ 可见性同样按**显示时间**判断(含 `offsetTime`)。 - // ⚠️ 「光标吸附」仍用整屏判据(`SignalSnapEnabled`),用户明确要求“其它规则不变”。 - if (AutoPointLineNeeded(*axis, *data, style->offsetTime)) + // ⚠️ 「光标吸附」用的是**同一个判定**(`MinorCellSparse`),用户要求两者“同样的状态判定”。 + if (MinorCellSparse(*axis, *data, style->offsetTime)) { if (!style->autoScatterActive) { diff --git a/Components/PlotView/Src/PlotViewInteraction.cpp b/Components/PlotView/Src/PlotViewInteraction.cpp index 4b98296..472d432 100644 --- a/Components/PlotView/Src/PlotViewInteraction.cpp +++ b/Components/PlotView/Src/PlotViewInteraction.cpp @@ -2238,6 +2238,46 @@ namespace jdi return true; } + bool PlotView::DoubleClickIsPlainPress(int x, int y) const + { + const float fx = static_cast(x); + const float fy = static_cast(y); + if (!UiPointInRect(m_area, fx, fy)) + { + return false; + } + // 实验性功能的浮动面板(含其上的复选框 / 滑块 / 复位按钮 / 关闭 ✕ / 标题栏): + // 只有「按下」语义 ⇒ 快速连点必须当作一次次按下(否则连点丢失)。 + { + int panelLane = -1; + int panelKind = 0; + if (TopPanelAtPoint(fx, fy, panelLane, panelKind)) + { + return true; + } + } + // 右下角「回到最上方」浮动按钮:按下只登记、抬起才生效 ⇒ 只有单击语义。 + if (m_backToTopAlpha > 0.01f) + { + const gd::RectF backToTop = BackToTopRect(); + if (backToTop.Width > 0.0f && UiPointInRect(backToTop, fx, fy)) + { + return true; + } + } + // 绘图区右侧滚动条:拖动语义。 + if (m_scrollBarVisible && UiPointInRect(m_scrollBarRect, fx, fy)) + { + return true; + } + // 网格 GX 徽标:按下即(抬起时)弹出「所属网格」菜单,双击没有额外含义。 + if (LaneBadgeAtPoint(x, y) >= 0) + { + return true; + } + return false; + } + bool PlotView::OnMouseLeave() { bool changed = false; diff --git a/Components/PlotView/Src/PlotViewInternal.hpp b/Components/PlotView/Src/PlotViewInternal.hpp index 9f78681..7f2f8f4 100644 --- a/Components/PlotView/Src/PlotViewInternal.hpp +++ b/Components/PlotView/Src/PlotViewInternal.hpp @@ -64,6 +64,17 @@ namespace jdi * 折线缓存(键含幅值映射)能命中,避免"每帧滚 1 行 → 每帧重算折线"的性能陷阱。 */ inline const int kLayerRollQuantum = 16; + + /** + * @brief 「图层预热」两次请求重绘之间的最小间隔(毫秒)。 + * + * 上百个网格时待重画队列可能长期非空(每个网格的图层约 2~15ms)。若每帧都请求重绘会把 + * 动画的 `WM_TIMER` 饿死(见 `PrefetchStepAllowed`);完全不请求则滚动到那些网格时 + * 它们还没建过图层,每帧要靠底衬兜底重画十几份网格(~8ms/帧)而卡顿。 + * 取 32ms ≈ 31 次/秒:既留足了动画定时器(15.5ms 周期)的预算,又能让 130 个网格 + * 在大约 4 秒内置好。 + */ + inline const std::uint64_t kPrefetchMinIntervalMs = 32; /** * @brief 「点选信号 ⇒ 把目标网格滚入可视区」的平滑过渡参数(与信号列表同源)。 * @@ -81,7 +92,9 @@ namespace jdi */ inline const float kBackToTopSize = 36.0f; /**< 按钮边长(逻辑像素,圆形直径)。 */ inline const float kBackToTopMargin = 12.0f; /**< 与绘图区右下角的边距。 */ - inline const float kBackToTopFadeMs = 170.0f; /**< 淡入/淡出时长(毫秒)。 */ + /** 淡入时长(毫秒):出现时的 ease-out 手感保持原样。 */ + inline const float kBackToTopFadeInMs = 170.0f; + // ⚠️ 淡出时长 = `kBackToTopFadeOutMs`(**与提示浮层一致**),见下方 `kHintFadeMs` 处。 /** 向下滚动超过该距离(逻辑像素)才显示按钮(避免轻微滚动就弹出)。 */ inline const float kBackToTopShowOffset = 24.0f; /** 向上箭头相对按钮尺寸的比例(箭头顶端到中心、半宽)。 */ @@ -176,15 +189,6 @@ namespace jdi g.FillPolygon(&brush, arrow, 3); } - /** - * @brief 光标吸附的**判定系数**:一屏内的数据点少于 `网格小格数 × 该系数` 时才开启吸附。 - * - * 用户 2026-10-15 明确定义:网格底衬一横排有 N 个小格(见 `GridMinorCellCount`), - * **当且仅当**某信号在当前视界内的数据点个数 `< N × 4` 时为它开启光标吸附。 - * 该判据随视界实时重算 ⇒ 疏密变化的信号在稀疏区自动吸附、稠密区自动自由移动。 - */ - inline const int kSnapVisibleCellsPerPoint = 4; - /** * @brief 吸附半径(以**网格小格**为单位):一个数据点只用其**一个小格宽**内的移动来吸。 * @@ -203,34 +207,26 @@ namespace jdi inline const int kSnapSampleProbeSteps = 64; /** - * @brief 判定“可见数据点是否足够少”时允许扫描的最大样本数(相对阈值的倍数)。 - * - * 判定只需知道“有效点是否少于阈值”,因此扫到阈值个有效点就早退; - * 但连续 NaN 不会被计入,因此再给一个**有界**的扫描窗口防止极端情况下的长循环。 - */ - inline const std::size_t kSnapScanWindowFactor = 64; - - /** - * @brief 「放大后自动切点线」的新判据:**单个横向网格小格**内的最少数据点数。 + * @brief 「按小格判定稀疏」的最少数据点数:**单个横向网格小格**内少于该值即视为稀疏。 * * 用户 2026-10-17 重新定义(**推翻**此前的“一屏内数据点 < N×4”): * 按缩放后的横向时间轴网格划分小格,**只要存在任何一个小格内的数据点少于 5 个**, - * 就自动切到「点线」(其余规则不变:不再满足时自动恢复,用户手动改过则不恢复)。 - * 这比旧判据更贴近用户意图:**局部**稀疏(例如数据只覆盖视界的一部分、或中间有空洞) - * 也会提示出数据点的位置,而不是等整屏都变稀疏。 + * 就视为稀疏。这比旧判据更贴近用户意图:**局部**稀疏(例如数据只覆盖视界的一部分、 + * 或中间有空洞)也会提示出数据点的位置,而不是等整屏都变稀疏。 * - * ⚠️ 该判据**只用于**「放大自动切点线」(`UpdateAutoScatterStyles`); - * 光标吸附仍用 `kSnapVisibleCellsPerPoint` 的整屏判据(用户明确要求“其它规则不变”)。 + * ⚠️ 该判据是**同一个判定**的两处用途(用户 2026-10-17 明确要求“同样的状态判定”): + * * 「放大自动切点线」(`UpdateAutoScatterStyles`); + * * 「光标吸附」(`SnapTimeToSample`)—— 即使用户手动改过显示样式,也照样吸附。 */ - inline const int kAutoPointLineMinPointsPerCell = 5; + inline const int kSparseMinPointsPerCell = 5; /** * @brief 单个小格内“判定为稠密”所需的扫描上限(样本数)。 * - * 新判据需要逐小格数点,但只需知道“是否 ≥ `kAutoPointLineMinPointsPerCell`” + * 新判据需要逐小格数点,但只需知道“是否 ≥ `kSparseMinPointsPerCell`” * ⇒ 累计到该值即早退;这里再给一个上限,保证含大量 NaN 的极端小格也不会长循环。 */ - inline const std::size_t kAutoPointLineCellScanCap = 512; + inline const std::size_t kSparseCellScanCap = 512; /** * @brief 「所有可见数据点都是单值信号、且时刻完全相同」时,时间轴**一个大格**对应的秒数。 @@ -674,6 +670,14 @@ namespace jdi inline const std::uint64_t kHintHoldMs = 1300; /**< 提示浮层完全显示的停留时长(毫秒)。 */ inline const std::uint64_t kHintFadeMs = 500; /**< 提示浮层的淡出时长(毫秒)。 */ + /** + * @brief 「回到最上方」浮动按钮的**淡出**时长(毫秒)—— **与提示浮层完全一致**。 + * + * 用户 2026-10-17 明确要求(参考「选区放大框选过程中的浮动提示淡出」): + * 到达顶部后按钮**立即开始**淡出(**不停留**,不像提示浮层那样先停 `kHintHoldMs`), + * 且淡出时长与 alpha 曲线(**线性**)都跟 `PaintHint` 的淡出一模一样。 + */ + inline const float kBackToTopFadeOutMs = static_cast(kHintFadeMs); /** * @brief 实验性功能浮动面板的遍历顺序(命中测试与绘制共用,保证层序一致)。 diff --git a/Components/PlotView/Src/PlotViewPaint.cpp b/Components/PlotView/Src/PlotViewPaint.cpp index 9570249..922cf65 100644 --- a/Components/PlotView/Src/PlotViewPaint.cpp +++ b/Components/PlotView/Src/PlotViewPaint.cpp @@ -856,13 +856,16 @@ namespace jdi // 真并行(3.98x),但**图层光栅化(GDI+)本身会被串行化**,并行重建 5 个网格 ≈ 逐个重建 //(1.03x)→ 一次性补齐只会把一帧从 ~10ms 抬到 ~31ms(更容易掉帧)。 // 仍按预算逐个补:不用一次把全部过期网格塞进同一帧(并行重建≈逐个重建)。 - // ⚠️ **“是否再来一帧”只看还有没有【可视】网格待补**:上百个离屏网格若每帧都 - // 请求重绘,会形成持续一秒以上的高频重绘,把动画定时器饿死(见 `AnyVisibleLanePending`)。 + // ⚠️ **“是否再来一帧”的判据**(见 `AnyVisibleLanePending` / `PrefetchStepAllowed`): + // * 还有可视 / 预取带内的网格待补 ⇒ 立刻继续; + // * 只剩离屏网格 ⇒ 按 `kPrefetchMinIntervalMs` 限速继续(不会饿死动画定时器)。 if (viewStopped) { RenderPendingLaneRebuilds(1); } - const bool morePending = AnyVisibleLanePending(); + const bool morePending = + AnyVisibleLanePending() || + (!m_layerRebuildPending.empty() && PrefetchStepAllowed()); // 绘图区底色(覆盖时间轴与侧边留白区域)。 UiFillRect(g, m_area, m_theme->plotBg); // 时间轴(宽缓冲不含它,前台按当前视图绘制)—— 必须在贴图**之前**算好: @@ -2441,6 +2444,11 @@ namespace jdi } bool PlotView::LaneVisuallyRelevant(int laneIndex) const + { + return LaneVisuallyRelevant(laneIndex, 0); + } + + bool PlotView::LaneVisuallyRelevant(int laneIndex, int prefetchScreens) const { if (laneIndex < 0 || laneIndex >= static_cast(m_lanes.size())) { @@ -2451,8 +2459,10 @@ namespace jdi return false; } const gd::RectF& rect = m_lanes[static_cast(laneIndex)].rect; - // 用一个网格高的余量:即将滚入视区的网格也算相关,避免滚动时出现一帧空档。 - const float margin = std::max(rect.Height, 0.0f); + // 可视判定本身留一个网格高的余量(即将滚入视区的网格也算相关); + // 预取带在其上再按屏高放大(见头文件说明)。 + const float margin = std::max(rect.Height, 0.0f) + + static_cast(std::max(0, prefetchScreens)) * m_plotRect.Height; return (rect.Y + rect.Height + margin > m_plotRect.Y) && (rect.Y - margin < m_plotRect.Y + m_plotRect.Height); } @@ -2481,9 +2491,12 @@ namespace jdi bool PlotView::AnyVisibleLanePending() const { + // ⚠️ 用 **±1 屏预取带**(而不是“严格可见”):网格滚动动画会在几百毫秒内扫过一整个屏高, + // 只预建严格可见的网格时,动画扫进去的每一帧都要靠底衬兜底重画十几份网格 + // (实测 ~8ms/帧 ⇒ 动画明显卡顿)。预取带让静置期间就把附近网格的图层建好。 for (const int laneIndex : m_layerRebuildPending) { - if (LaneVisuallyRelevant(laneIndex)) + if (LaneVisuallyRelevant(laneIndex, 1)) { return true; } @@ -2959,6 +2972,18 @@ namespace jdi return ok; } + bool PlotView::PrefetchStepAllowed() + { + const std::uint64_t now = GetTickCount64(); + if (now >= m_lastPrefetchRequestMs && + (now - m_lastPrefetchRequestMs) < kPrefetchMinIntervalMs) + { + return false; + } + m_lastPrefetchRequestMs = now; + return true; + } + void PlotView::WarmLaneLayersStep() { if (m_dragMode != PlotDragMode::NONE || m_lanes.empty() || m_fonts == nullptr) @@ -2989,9 +3014,13 @@ namespace jdi // 也不会走"位移超出画布 → 立刻整块重画"那条分支。 RollWideBuffer(); RenderPendingLaneRebuilds(1); - // ⚠️ 只在还有**可视**网格待补时才请求再送一帧:离屏网格的数量可能上百, - // 若按“队列非空”每帧请求重绘,会持续一秒以上地高频重绘并饿死动画(见 `AnyVisibleLanePending`)。 - const bool morePending = AnyVisibleLanePending(); + // ⚠️ 是否继续请求下一帧: + // * 还有**可视 / 预取带内**的网格待补 ⇒ 立刻继续(收敛快,见 `AnyVisibleLanePending`); + // * 只剩**离屏**网格 ⇒ 按 `kPrefetchMinIntervalMs` **限速**继续(慢速后台预热, + // 既不饿死动画定时器,又能把整批图层建好 —— 见 `PrefetchStepAllowed`)。 + const bool morePending = + AnyVisibleLanePending() || + (!m_layerRebuildPending.empty() && PrefetchStepAllowed()); if (morePending) { const HWND host = m_host; diff --git a/Components/SignalPanel/Inc/SignalPanel.hpp b/Components/SignalPanel/Inc/SignalPanel.hpp index 9a24258..da574c9 100644 --- a/Components/SignalPanel/Inc/SignalPanel.hpp +++ b/Components/SignalPanel/Inc/SignalPanel.hpp @@ -146,6 +146,24 @@ namespace jdi */ bool OnDoubleClick(int x, int y); + /** + * @brief 双击落点是否应**当作普通「按下」**处理(该位置没有双击语义)。 + * + * ⚠️ 窗口类带 `CS_DBLCLKS`:快速连点同一个控件时,第二次(及之后)按下会被 Windows + * 合成为 `WM_LBUTTONDBLCLK`。若宿主把它交给 `OnDoubleClick`,而该位置本来只有 + * 「按下 / 单击」语义(复选框、全部显示隐藏、绝对值、折叠三角、「组X」、✕、GX 徽标…), + * 那一次按下就**完全丢失** —— 用户看到的正是“连点好几下、状态只切换了几次”(不跟手)。 + * + * ✓ 本函数为这些位置返回 true,宿主改走 `OnLButtonDown`(与单击完全相同的路径)。 + * 只有真正有双击语义的位置(线宽/不透明度数值框、样式区「目标」行名称与 GX 标签、 + * 分组 / 信号行的**名称区域**改名)返回 false。 + * + * @param [IN] x 客户区横坐标。 + * @param [IN] y 客户区纵坐标。 + * @return 应当作普通按下返回 true。 + */ + bool DoubleClickIsPlainPress(int x, int y); + /** * @brief 处理鼠标右键按下(弹出上下文菜单)。 * @param [IN] x 客户区横坐标。 @@ -1211,6 +1229,17 @@ namespace jdi * ✕ 上才真正执行;按住后移开再松开视为放弃(与常见"可取消按钮"交互一致)。 */ RowPart m_pendingClosePart = RowPart::NONE; /**< 待确认的 ✕ 所在行内区域(CLOSE / REMOVE_SIGNAL)。 */ + + /** + * @brief 最近一次「点击分组行名称区域」的时刻 / 行 / 点击前的折叠状态。 + * + * 用户 2026-10-17 要求:分组**名称区域**单击也展开/收起(恢复),但**只有快速双击** + * 才进入改名编辑(降低灵敏度:较慢但明显不是双击的点击频率只当两次单击)。 + * 因此需要记住上一次单击的信息,以便在双击时**撤销那一次折叠切换**再进入编辑。 + */ + std::uint64_t m_groupNameClickMs = 0; + int m_groupNameClickRow = -1; + bool m_groupNameClickWasCollapsed = false; int m_pendingCloseRow = -1; /**< 待确认的 ✕ 所在行下标。 */ bool m_pendingFilterClear = false; /**< 待确认的筛选框清空 ✕。 */ diff --git a/Components/SignalPanel/Src/SignalPanelInteraction.cpp b/Components/SignalPanel/Src/SignalPanelInteraction.cpp index c293dc0..c8926fc 100644 --- a/Components/SignalPanel/Src/SignalPanelInteraction.cpp +++ b/Components/SignalPanel/Src/SignalPanelInteraction.cpp @@ -1446,11 +1446,14 @@ namespace jdi m_pendingCloseRow = row; return true; } - // 折叠 / 展开分组:**只有**点到「折叠三角」或「组X」标签才生效 - //(用户 2026-10-16 要求:点到分组名本体不折起)。 - if (part != RowPart::DISCLOSURE && part != RowPart::GROUP_TITLE) + // 折叠 / 展开分组:三角、「组X」标签、**以及名称本体**都生效。 + // 用户 2026-10-17 要求:名称本体恢复单击展开/收起(此前为了避开“误入改名” + // 而禁掉了它,结果只能点三角、很不方便);误入改名改由**收紧双击阈值**解决 + //(见 `OnDoubleClick`:只有快速双击才改名)。 { - return true; + m_groupNameClickMs = GetTickCount64(); + m_groupNameClickRow = row; + m_groupNameClickWasCollapsed = IsGroupCollapsed(item.groupIndex); } ToggleGroupFromRow(item.groupIndex, row); return true; @@ -1674,12 +1677,16 @@ namespace jdi const ListRow item = m_rows[static_cast(row)]; - // 分组行:双击折叠三角用于折叠,双击名称区域进入重命名。 + // 分组行:双击折叠三角用于折叠;双击**名称区域**进入重命名 —— + // ⚠️ 但只有**快速双击**才算(用户 2026-10-17 要求降低灵敏度): + // 按下那一次已经把分组展开/收起了(`OnLButtonDown` 统一处理三角/「组X」/名称), + // 因此这里要把它**撤销**回点击前的状态,然后才进入改名; + // 超过 `kGroupRenameDoubleClickMs` 的“慢双击”只当**又一次单击**(再切一次折叠)。 if (item.kind == RowKind::GROUP) { if (part == RowPart::DISCLOSURE) { - // 与单击分组行同一入口:悬浮时先滚动就位再播放折起动画。 + // 与单击分组行同一入口(悬浮时先滚动就位再播放折起动画)。 ToggleGroupFromRow(item.groupIndex, row); return true; } @@ -1687,9 +1694,27 @@ namespace jdi { return false; } - // 重命名分组会改变列表行高度以外的展示内容,重建行缓存以保证文字刷新。 + // 名称区域:判定“快速双击”。 + const std::uint64_t now = GetTickCount64(); + const bool fastDoubleClick = + (m_groupNameClickRow == row) && (now >= m_groupNameClickMs) && + (now - m_groupNameClickMs <= kGroupRenameDoubleClickMs); + if (!fastDoubleClick) + { + // 慢双击 / 两次无关点击:只当另一次单击(切换折叠)。 + m_groupNameClickMs = now; + m_groupNameClickRow = row; + m_groupNameClickWasCollapsed = IsGroupCollapsed(item.groupIndex); + ToggleGroupFromRow(item.groupIndex, row); + return true; + } + // 快速双击 ⇒ 改名:先撤销“按下”那一次对折叠状态的切换,再进入编辑。 + m_groupNameClickMs = 0; + m_groupNameClickRow = -1; + SetGroupCollapsed(item.groupIndex, m_groupNameClickWasCollapsed); m_rowsDirty = true; - return BeginEdit(StyleEditField::GROUP_TITLE, row); + EnsureRows(); + return BeginEdit(StyleEditField::GROUP_TITLE, GroupRowIndex(item.groupIndex)); } // 复选框、网格徽标与「移除信号」✕ 各自由单击处理,双击不再切换可见性/进入重命名。 @@ -1704,6 +1729,54 @@ namespace jdi return BeginEdit(StyleEditField::SIGNAL_NAME, row); } + bool SignalPanel::DoubleClickIsPlainPress(int x, int y) + { + if (m_dataSet == nullptr || m_tab == nullptr) + { + return false; + } + const float fx = static_cast(x); + const float fy = static_cast(y); + + // 真正有双击语义的位置(保持双击行为): + // * 线宽 / 不透明度数值框:双击进入(或保持)输入状态; + // * 样式区「目标」行的名称与 GX 标签:双击进入改名; + // * 内联输入框自身:双击用于选词 / 定位插入符。 + if (m_editField != StyleEditField::NONE && UiPointInRect(EditFieldRect(), fx, fy)) + { + return false; + } + if (UiPointInRect(m_widthValueRect, fx, fy) || UiPointInRect(m_opacityValueRect, fx, fy)) + { + return false; + } + UpdateTargetRowGeometry(); + if (m_targetNameRect.Width > 0.0f && UiPointInRect(m_targetNameRect, fx, fy)) + { + return false; + } + if (m_targetBadgeRect.Width > 0.0f && UiPointInRect(m_targetBadgeRect, fx, fy)) + { + return false; + } + + RowPart part = RowPart::NONE; + const int row = HitRow(x, y, part); + if (row < 0) + { + // 列表之外(样式区按钮 / 筛选框 / 面板空白):全部只有单击语义。 + return true; + } + // 行的**名称区域**(分组 / 信号)都靠双击改名 ⇒ 保留双击语义。 + if (part == RowPart::BODY) + { + return false; + } + // 其余全部(复选框、折叠三角、「组X」、✕、GX 徽标、全部显示隐藏、色块、绘制方式、 + // 滑块、「调整」按钮、筛选框…)都只有单击语义 ⇒ 快速连点必须当作一次次按下。 + return true; + } + bool SignalPanel::OnRButtonDown(int x, int y) { if (m_dataSet == nullptr || m_tab == nullptr) diff --git a/Components/SignalPanel/Src/SignalPanelInternal.hpp b/Components/SignalPanel/Src/SignalPanelInternal.hpp index fea620a..ae8b33a 100644 --- a/Components/SignalPanel/Src/SignalPanelInternal.hpp +++ b/Components/SignalPanel/Src/SignalPanelInternal.hpp @@ -156,6 +156,17 @@ namespace jdi inline const float kGroupAnimPerRowMs = 6.0f; /**< 每多一行增加的时长。 */ inline const float kGroupAnimMinMs = 150.0f; /**< 最短时长。 */ inline const float kGroupAnimMaxMs = 320.0f; /**< 最长时长。 */ + + /** + * @brief 分组**名称区域**进入改名编辑所需的「快速双击」时间上限(毫秒)。 + * + * 用户 2026-10-17 要求:名称区域单击 = 展开/收起(恢复),**只有快速双击**才进入编辑框; + * “较慢但明显不是双击的点击频率”必须只当两次单击。 + * Windows 的双击判定上限通常是 500ms(`GetDoubleClickTime()`),太宽松 —— + * 用户快速连点两次想“展开看一眼再收起”时会误入编辑态。这里收紧到 300ms: + * 真双击(一般 <250ms)照常进入编辑,稍慢的两次单击则各自触发展开/收起。 + */ + inline const std::uint64_t kGroupRenameDoubleClickMs = 300; inline const float kStyleContentMax = 260.0f; /**< 内容列最大宽度(再拉宽面板也不再延伸)。 */ inline const float kSwatchEdge = 18.0f; /**< 颜色色块边长上限(含自定义颜色入口)。 */ inline const float kSwatchMin = 11.0f; /**< 颜色色块边长下限。 */ diff --git a/Documents/agent_mem.md b/Documents/agent_mem.md index 7e72bf3..5c6c31e 100644 --- a/Documents/agent_mem.md +++ b/Documents/agent_mem.md @@ -2627,6 +2627,123 @@ else if (panelOnly) { /* 只回送“本帧重绘过的区域并集” */ } --- +### 9.43 滚动过渡动画根治 + 快速连点跟手 + 分组名单击展开 + 光标吸附同源 + 按钮淡出对齐 + 滚动位置复位(本轮) + +> 用户 2026-10-17 第七轮(本机)。六项需求: +> ① 鼠标拖光标时**鼠标下方网格内**按「进入自动点线」**同一判定**的数据点吸附(用户改过显示样式也吸附); +> ② 滚动过渡动画(点选最后一个信号 / 「回到最上方」)卡顿,甚至不如手拖滚动条; +> ③ 「回到最上方」按钮的**渐变消失**要和「选区放大浮动提示」一致; +> ④ 「组X」快速点击不跟手 + 分组**名称**恢复单击展开收起(只有**快速双击**才进入改名); +> ⑤ 所有复选框 / 「全部显示隐藏」/ 「绝对值」快速点击不跟手; +> ⑥ 「有过需滚动条的网格 → 没有信号 → 又出现需滚动条数量的网格」时滚动条**回到最上方**。 +> 离屏验证:`gridanim` **93/93**(含真实消息循环帧距 / 逐帧渲染耗时对比);`verify` 49/49、 +> `panelanim` **158/158**(按新规则更新 8 条断言)、`datacheck` 69/69、`edgecheck` 46/46、`errcheck` 19/19; +> `make release` / `make debug` 零警告。 + +#### 一、滚动过渡动画卡顿 —— **三处独立根因**(用「与手动拖滚动条对比」定位) + +新增评估手段(用户要求「找出精准评估动画流畅性的方式」):**同一个二进制内对比** +「手动拖滚动条逐帧渲染」与「动画帧渲染」,并用**真实消息循环**统计帧距/逐帧耗时。 + +1. ⚠️⚠️ **`WM_TIMER` 的失效范围把绘图区动画变成「整窗重绘 + 整窗回送」**: + 旧实现无条件 `InvalidateRect(hwnd, nullptr, FALSE)` ⇒ `WM_PAINT` 看到更新区域**不在绘图区内** + ⇒ 把 `m_chromeDirty` 置真 ⇒ **每帧重绘标签栏/工具栏/面板**。实测纯绘图区帧 **4.4ms → 8.6ms(带整窗装饰)**, + 「回到最上方」动画帧 **13.6ms → 3.5ms**(修好后)。 + ✓ 修法:用**推进之前**的面板动画状态决定失效范围 —— + * 面板动画(滚动到目标信号 / 分组折起展开)**确实**要改面板像素 ⇒ 整窗失效; + * 只有绘图区动画(网格滚动到可视区 / 回到最上方)⇒ **只失效绘图区** + (`WM_PAINT` 判定为“更新区域在绘图区内” ⇒ `m_chromeDirty` 保持 false ⇒ 只重绘、只回送绘图区)。 + ⚠️ 这不违反 §9.41:回送分支仍是 `if (fullFrame) {整窗} else {绘图区}`,**未改成“按更新区域决定画什么”**。 +2. ⚠️⚠️ **图层预热不足 ⇒ 快速滚动时每帧要靠「网格底衬兜底」重画十几份网格**: + 「全部信号各自一块网格」时待重画队列有上百个**离屏**网格。上一轮为避免高频重绘饿死动画定时器, + 把「是否再请求一帧」限定为「还有**可视**网格待补」⇒ 离屏网格长期不建图层; + 一旦用户点最后一个信号(长距离滚动动画)扫进这片区域,**每一帧都有十几个网格没有图层像素**, + 只能就地补画网格底衬 —— 实测 **+8ms/帧**(`BlitWideBuffer()` 只画网格不打波形,代价 ~0.5ms/网格)。 + ✓ 修法(两步): + * `LaneVisuallyRelevant(laneIndex, prefetchScreens)`:新增**预取带**(±1 屏高), + `AnyVisibleLanePending()` 用它 ⇒ 静置时把**附近**网格的图层先建好; + * `PrefetchStepAllowed()`(`kPrefetchMinIntervalMs = 32`):队列里只剩离屏网格时, + 以约 31 次/秒**限速**继续请求重绘 ⇒ 整批图层在静置期间(130 个网格约 4 秒)逐步建好, + 同时不会把动画的 `WM_TIMER`(15.5ms 周期)挤出预算。 + **实测(相同二进制、相同起始状态)**:`E4b lane-only+big` 帧 **13.9ms → 5.3ms**; + `E4 signal-click+big` **18.3ms → 10.3ms**。 +3. 「网格滚动到可视区 / 回到最上方」每帧都在改 `LaneScroll()`,而它原本参与「宽基底可用性」判据 + ⇒ 每一帧都退化成整幅重绘(上一轮已修:`SceneContentHash(..., includeLaneScroll=false)`)。 + +**最终实测(124 网格;手动拖滚动条 = 参照)**: + +| 场景 | 修前 | 修后 | +| --- | --- | --- | +| 手动拖滚动条(参照) | 6.6ms | **4.2ms** | +| 「回到最上方」动画 | 13.6ms / 帧距 22.6ms | **3.5ms / 帧距 15.6ms** | +| 点选最后信号(面板+绘图区,含 VERY_BIG_DATA)| 18.6ms | **10.3ms** | +| 点选最后信号(仅绘图区,含 VERY_BIG_DATA)| 14.8ms | **5.3ms** | + +⚠️ 「点选最后信号」的剩余 10.3ms 里约 4.5ms 是**面板动画必须重绘面板+工具栏**的固有代价 +(`E1d` 直接驱动同一套帧 ≈ 7.8ms,说明绘制本身在预算内,残余来自消息循环/调度)。 + +#### 二、光标吸附改用「自动点线」的**同一判定**(用户 2026-10-17 修订) + +- `AutoPointLineNeeded` → 改名 **`MinorCellSparse`**(中性名,**同一判定两处用途**): + 「放大自动切点线」(`UpdateAutoScatterStyles`)与「光标吸附」(`SnapTimeToSample`)。 +- 判据:任一小格内有效数据点 < `kSparseMinPointsPerCell`(=5); + ⚠️ 判定**完全不看绘制方式** ⇒ **即使用户手动改过显示样式也照样吸附**(用户明确要求)。 +- 网格内**既有符合又有不符合**的信号时,只有**符合**的那条的数据点进入比较; + 网格内**没有**符合条件的信号时,光标**完全自由移动**。 +- 删除旧的整屏判据 `SignalSnapEnabled` 与 `kSnapVisibleCellsPerPoint`(成为死代码)。 +- ⚠️ §9.30 记的「自动点线 / 吸附同源」在本轮**重新成立**(判据换成了小格判据)。 + +#### 三、「回到最上方」按钮淡出与「浮动提示」一致(用户要求) + +- `kBackToTopFadeOutMs = kHintFadeMs`(500ms)+ **线性** alpha;到达顶部**立即开始**淡出(不停留)。 +- 淡入保留原来的 `kBackToTopFadeInMs`(170ms,ease-out)—— 用户只要求淡出对齐。 +- 实测:50% 时刻 alpha = **0.500**(线性),旧 ease-out 会是 0.875。 + +#### 四、快速连点跟手:`WM_LBUTTONDBLCLK` 必须当作一次「按下」(根因,覆盖全部控件) + +- ⚠️⚠️ 窗口类带 `CS_DBLCLKS`:**快速连点同一个控件**时,第二次(及之后)按下会被 Windows 合成为 + `WM_LBUTTONDBLCLK`。旧实现只给**工具栏**打了补丁(§9.32),其余控件(复选框 /「全部显示隐藏」/ + 「绝对值」/「组X」/ 折叠三角 / ✕ / GX 徽标 / 浮动面板上的复选框…)的那一次按下**完全丢失** + ⇒ 用户看到的正是“连点好几下、状态只切换了几次”。 +- ✓ 修法:组件各自提供 `DoubleClickIsPlainPress(x, y)`(**该落点有没有双击语义**), + 宿主 `WM_LBUTTONDBLCLK` 据此决定走 `OnLButtonDown`(当作按下)还是 `OnDoubleClick`: + * `PlotView`:浮动面板(含复选框/滑块/复位/✕/标题栏)、「回到最上方」按钮、绘图区滚动条、GX 徽标 ⇒ 按下; + 时间光标线 / 读数标签 / 图例条目名 / 绘图区空白 ⇒ 保留双击; + * `SignalPanel`:样式区数值框、「目标」行名称与 GX 标签、行**名称区域** ⇒ 保留双击; + **其余全部**(含列表之外的样式区按钮/筛选框/空白)⇒ 按下; + * 弹出菜单 / 拾色器打开时,`WM_LBUTTONDBLCLK` 也按各自 `OnLButtonDown` 走。 + +#### 五、分组行:名称恢复单击展开收起 + 只有**快速双击**进入改名(用户 2026-10-17) + +- `OnLButtonDown` 的分组分支不再排除 `RowPart::BODY`:**三角 /「组X」/ 名称本体**统一折叠展开。 +- `OnDoubleClick` 的名称区域:**只有快速双击**才进入改名 —— 阈值 `kGroupRenameDoubleClickMs = 300` + (比 Windows 的 `GetDoubleClickTime()`(通常 500ms)**更紧**,用户要求“降低灵敏度”); + 超出阈值的“慢双击”只当**又一次单击**(再切一次折叠);快速双击则**先撤销**按下那一次对折叠状态的 + 切换(`m_groupNameClickWasCollapsed`)再进入改名,保证进编辑时折叠状态与点击前一致。 + ⚠️ 需要记录「最近一次名称点击的时刻 / 行 / 点击前折叠状态」三个成员。 + +#### 六、信号重现时网格滚动回到最上方(用户要求) + +- ⚠️ 这一整类情形(“有几屏网格 → 没有任何信号显示 → 又出现几屏网格”)必须从最上方开始看。 + 旧实现保留了上一次的 `Tab().LaneScroll()` ⇒ 重新显示后**停在与取消显示时相同的滚动位置**。 +- ✓ `Application::SynchronizeView` 的 `else if (!tab.ViewInitialized())`(= 首次出现可见信号)分支里 + 在 `FitAll()` **之后** `tab.LaneScroll() = 0.0f`(`FitAll` 本身不动滚动量,见 §11.2 第 112 条)。 + +#### 七、⚠️ 本轮方法论教训 + +1. ⚠️⚠️ **绝对不要用 PowerShell `Get-Content -Raw | Set-Content -Encoding UTF8` 改源码**(本轮又踩了一次): + PS 5.1 按 ANSI/GBK 解码 UTF-8 ⇒ 中文注释整份变乱码 + 加 BOM。 + ✓ **补救**:乱码是**无损可逆**的 —— 读成 UTF-8 字符串再按 `GetEncoding(936).GetBytes()` 写回, + 即可还原原始 UTF-8 字节(再删掉 BOM 变成的 `?` 首字节)。✅ 但**正确做法**是: + 用**编辑工具**改源码,或用 `[System.IO.File]::ReadAllText/WirteAllText(..., UTF8Encoding($false))`。 +2. **动画耗时必须在同一二进制、同一起始状态下对比**:早期用「改代码 + 重编」做 A/B, + 结果被两次运行之间**不同的预热状态**污染,得出「底衬兜底 +8ms」这种误导结论; + 改成**运行时开关**(环境变量)在**同一次运行**里对比才可靠。 +3. **对比参照物非常重要**:把「手动拖滚动条」当作流畅度的基准线, + 才能一眼看出动画帧「贵在哪」(整窗重绘 vs 面板固有代价 vs 预热不足)。 + +--- + ## 10. 变更日志(本文件维护记录) | 日期 | 设备/模型 | 摘要 | @@ -2687,6 +2804,8 @@ else if (panelOnly) { /* 只回送“本帧重绘过的区域并集” */ } | 2026-10-17 | DeepSeek-V4.1-Flash(**本机**) | **网格底衬永不空白 + 动画流畅性根治 + 光标随首个信号重现 + 「放大自动点线」新判据(详见 §9.42)**。① **网格底衬消失**:旧跨度图层的 `StretchBlt` 占位拉伸在深度放大时把一小段像素放大几十倍 ⇒ 整块网格变成**无网格的模糊色块**;叠加待重画队列**按下标顺序**消费(可视网格要等上百帧)⇒「消失 + 要等一会才刷新好」。修法:**删除占位拉伸**,改为对拿不到像素的网格**就地补画网格底衬**(`PaintLaneGridBacking` + `m_renderGridOnly`,代价极低可每帧执行),队列改**可视网格优先**,结算判据改 `LanesVisiblyReadyForView()`。② **动画卡顿(两处根因)**:**(a)**「`m_layerRebuildPending` 非空就每帧请求再送一帧」在上百个网格时挤出 100+ 次重绘、把优先级更低的 `WM_TIMER` **饿死**(实测 `frames=124/timerTicks=1`)⇒ 改用 `AnyVisibleLanePending()`(`frames=25/timerTicks=16`);**(b)** `SetTimer(...,16,...)` 被系统 tick **量化成 ~26ms(≈38fps)** ⇒ 周期改 **10ms**(实测 ~15.5ms ≈ 64fps,真实消息循环帧距 ~25ms → ~16ms);**(c)** 网格滚动量原本参与「宽基底可用性」判据 ⇒ 滚动动画每帧整幅重绘 ⇒ 新增 `SceneContentHash(..., includeLaneScroll)`(判据不计、场景缓存仍计),单帧 **18.36ms → 1.19ms**。③ **光标随首个信号重现**:新增 `ResetCursorPlacementOnFreshContent()`(与 `CycleCursorMode()` 首次启用同源),接入 `SynchronizeView` 的「首次出现可见信号」分支。④ **「放大自动点线」新判据**:任一小格内数据点 < 5 即切换(`AutoPointLineNeeded`;二分/鸽巢封顶);⚠️ 光标吸附仍用旧整屏判据(用户要求"其它规则不变")。验证:新增 `.preflight/perf/gridanim_main.cpp` **55/55**(含真实消息循环帧距统计、光标重现、点线规则);`verify` 49/49、`panelanim` 156/156、`datacheck` 69/69、`edgecheck` 46/46、`errcheck` 19/19;`make release` 零警告。⚠️ 教训:`cardBg == plotBg` ⇒ 颜色判据色盲;后备缓冲区持久 ⇒ 「看不到空白」不能证明没问题;网格分界线会让「有锐边」判据假绿(须要求同列多行)。§11.2 新增第 117–124 条。 | +| 2026-10-17 | DeepSeek-V4.1-Flash(**本机**) | **滚动过渡动画根治 + 快速连点跟手 + 分组名单击展开 + 光标吸附同源 + 按钮淡出对齐 + 滚动位置复位(详见 §9.43)**。① **滚动动画卡顿(三处根因)**:**(a)** `WM_TIMER(kScrollTimerId)` 无条件整窗失效 ⇒ `WM_PAINT` 把 `m_chromeDirty` 置真 ⇒ **每帧重绘标签栏/工具栏/面板**(绘图区动画帧 13.6→**3.5ms**;手动拖滚动条参照 4.2ms);**(b)** 图层预热不足 ⇒ 长距离滚动扫进未建图层的网格、每帧靠底衬重画十几份网格(+8ms/帧)⇒ 可视判定加 **±1 屏预取带** + `PrefetchStepAllowed()` 限速后台预热(`lane-only+VERY_BIG_DATA` 13.9→**5.3ms**);**(c)** 网格滚动量参与宽基底可用性判据(上一轮已修)。② **光标吸附改用「自动点线」同一判定**:`AutoPointLineNeeded` 改名 `MinorCellSparse`,吸附与自动点线共用;判定**不看绘制方式** ⇒ 用户改过样式也吸附;删除旧整屏判据 `SignalSnapEnabled`。③ **「回到最上方」按钮淡出与浮动提示一致**:`kBackToTopFadeOutMs = kHintFadeMs`(500ms) + **线性** alpha + 到达顶部立即淡出(实测 50% 时刻 alpha=0.500,旧 ease-out 为 0.875)。④ **快速连点跟手**:`CS_DBLCLKS` 会把快速连点的第 2 次按下合成为 `WM_LBUTTONDBLCLK`,旧实现只给工具栏打补丁 ⇒ 复选框/全部显示隐藏/绝对值/组X/折叠三角/浮动面板复选框的按下**丢失**;新增 `DoubleClickIsPlainPress(x,y)`(`PlotView` + `SignalPanel`),宿主据此选择走 `OnLButtonDown`。⑤ **分组行:名称恢复单击展开收起**(推翻 §9.37 ①),只有**快速双击**(`kGroupRenameDoubleClickMs=300`,比系统 500ms 紧)才进入改名,且进入前**撤销**按下那次的折叠切换。⑥ **信号重现时网格滚动回到最上方**(`SynchronizeView` 首次出现可见信号分支里 `LaneScroll()=0`)。验证:`gridanim` **93/93**(真实消息循环帧距 + 逐帧耗时对比 + 行为断言)、`verify` 49/49、`panelanim` **158/158**(按新规则更新 8 条断言)、`datacheck` 69/69、`edgecheck` 46/46、`errcheck` 19/19;`make release`/`make debug` 零警告。§11.2 新增第 125–133 条。 + ## 11. 跨设备协作须知(起源设备视角) > **本文件是唯一权威记忆**。所有设备 / Agent 共用它,**不要再新建 `agent_mem_origin.md` 等旁支记忆文件**。 @@ -3295,6 +3414,48 @@ else if (panelOnly) { /* 只回送“本帧重绘过的区域并集” */ } 判断网格是否画出必须用结构判据(竖直锐边),且必须要求「同一列多行都有锐变」(否则网格**分界线**会让 每列都像有锐边)。⚠️ **后备缓冲区是持久的**:宽基底路径只把绘图区填成 plotBg(==cardBg), 本帧没被覆盖的网格**保留上一帧像素** ⇒ 探针「看不到空白」不能证明没问题。 +125. ⚠️⚠️ **动画定时器的失效范围必须与 `Render` 的绘制/回送判定配套**(用户 2026-10-17 报告: + “滚动过渡动画甚至不如手动拖滚动条”):`WM_TIMER(kScrollTimerId)` **不能无条件整窗失效** —— + 整窗更新区域会让 `WM_PAINT` 把 `m_chromeDirty` 置真 ⇒ **每帧重绘标签栏/工具栏/面板**。 + ✓ 用**推进之前**的面板动画状态判定:面板动画 ⇒ `InvalidateRect(nullptr)`; + 只有绘图区动画 ⇒ `InvalidatePlotRect`(只重绘、只回送绘图区)。 + ⚠️ 这不违反第 109 条:回送分支仍是 `if (fullFrame) {整窗} else {绘图区}`,**没有**按更新区域决定画什么。 + 实测:绘图区动画帧 **13.6ms → 3.5ms**(手动拖滚动条参照 4.2ms)。 +126. ⚠️⚠️ **「是否再请求一帧去补图层」必须兼顾两个极端**(`AnyVisibleLanePending` + `PrefetchStepAllowed`): + * 只看「严格可见」⇒ 离屏网格长期不建图层,长距离滚动动画扫进去时**每帧十几个网格没有图层像素**, + 只能靠底衬兜底重画网格(实测 **+8ms/帧**,动画明显卡);因此可视判定要带 **±1 屏预取带** + (`LaneVisuallyRelevant(laneIndex, prefetchScreens)`); + * 只看「队列非空」⇒ 上百个离屏网格挤出一秒以上的高频重绘,把 `WM_TIMER`(优先级低于投递消息)**饿死**; + 因此只剩离屏网格时要**限速**(`PrefetchStepAllowed()`,`kPrefetchMinIntervalMs = 32` ⇒ 约 31 次/秒)。 + 实测:`lane-only + VERY_BIG_DATA` 帧 **13.9ms → 5.3ms**。 +127. ⚠️ **「宽基底快路径」对拿不到图层像素的网格要就地补画网格底衬**(见第 117 条), + 但**底衬不是免费的**:约 **0.5ms/网格**(`PaintLane` 的网格部分:y/时间刻度 + 3 次批量 GDI)。 + ⇒ 必须靠第 126 条的预热把「可见但没建过图层的网格」数量压到接近 0。 +128. ⚠️⚠️ **快速连点会丢一次按下**(`CS_DBLCLKS`):第二次及之后的快速按下被合成为 `WM_LBUTTONDBLCLK`。 + **所有没有双击语义的控件**都必须把它当作一次「按下」(不只是工具栏,见第 84 条): + ✓ 组件各自实现 `DoubleClickIsPlainPress(x, y)`,宿主据此在 `WM_LBUTTONDBLCLK` 里选择走 + `OnLButtonDown` 还是 `OnDoubleClick`。覆盖:`PlotView`(浮动面板及其复选框/滑块/复位/✕/标题栏、 + 「回到最上方」按钮、绘图区滚动条、GX 徽标)与 `SignalPanel`(复选框、折叠三角、「组X」、✕、 + GX 徽标、全部显示隐藏、绝对值、色块、绘制方式、滑块、调整按钮、筛选框、样式区/空白区域)。 + ⚠️ `SignalPanel::DoubleClickIsPlainPress` 对**列表之外**的落点必须返回 **true**(样式区按钮只有单击语义); + 返回 false 会让连点被 `OnDoubleClick` 吃掉。 +129. ⚠️ **分组行点击(用户 2026-10-17 修订,推翻 §9.37 ①)**: + **三角 / 「组X」标签 / 名称本体**都折叠展开分组(不再“点名称不折起”); + 只有**快速双击**名称才进入改名,阈值 `kGroupRenameDoubleClickMs = 300`(**比 `GetDoubleClickTime()` 紧**), + 超阈值的“慢双击”只当**又一次单击**;快速双击进入改名时要**先撤销**按下那一次造成的折叠切换 + (`m_groupNameClickWasCollapsed`),保证进编辑时折叠状态与点击前一致。 +130. ⚠️⚠️ **滚动过渡动画的三个独立根因**(排查时用「手动拖滚动条」当参照物): + ① 定时器失效范围(第 125 条);② 图层预热不足(第 126 条); + ③ 网格滚动量参与“宽基底可用性”判据(第 121 条)。 +131. ⚠️ **「全部信号各自一块网格」时待重画队列天然有上百项**:任何「队列非空就做 X」的逻辑都要 + 重新审视(重绘请求、预热、结算判据…),否则要么饿死动画定时器、要么让画面长期停在兜底状态。 +132. ⚠️⚠️ **不要用 PowerShell `Get-Content -Raw | Set-Content -Encoding UTF8` 改源码**(本轮再次踩到): + PS 5.1 按 ANSI/GBK 解码 UTF-8 ⇒ 中文注释整份变乱码 + BOM。✓ 乱码**无损可逆**: + 读成 UTF-8 字符串 → `GetEncoding(936).GetBytes()` 写回即可还原(再删掉 BOM 变成的 `?` 首字节)。 + 正确做法:用编辑工具,或 `[System.IO.File]::ReadAllText/WriteAllText(..., UTF8Encoding($false))`。 +133. ⚠️ **动画性能 A/B 必须在同一二进制、同一起始状态下对比**:靠“改代码重编”做 A/B 会被 + **两次运行之间不同的预热状态**污染(本轮因此得出过“底衬兜底 +8ms”的误导结论); + 应加**运行时开关**在**同一次运行**内切换。 ### 11.3 离屏验证方法学(本机偏好,强烈建议沿用)