From f84561693813f5310d3c71db08ecd0ebd265114c Mon Sep 17 00:00:00 2001 From: "DeepSeek V4.1 Flash" <> Date: Fri, 16 Oct 2026 17:33:08 +0800 Subject: [PATCH] =?UTF-8?q?=E5=9B=9E=E9=80=80=205b52eef=20=E5=B9=B6?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E3=80=8C=E5=B1=8F=E5=B9=95=E4=B8=8D=E5=88=B7?= =?UTF-8?q?=E6=96=B0=E3=80=8D=E6=A0=B9=E5=9B=A0=EF=BC=9A=E6=8B=96=E5=8A=A8?= =?UTF-8?q?=E6=BB=9A=E5=8A=A8=E6=9D=A1=E5=8D=A1=E9=A1=BF=20/=20=E6=82=AC?= =?UTF-8?q?=E5=81=9C=E9=AB=98=E4=BA=AE=E5=8D=A1=E4=BD=8F=20/=20=E4=BF=A1?= =?UTF-8?q?=E5=8F=B7=E6=8B=96=E6=8B=BD=E6=97=A0=E6=95=88=E6=9E=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 根因(代码级确认) - Render() 把界面画进后备缓冲区后用 GetDC + BitBlt 回送窗口,5b52eef 把回送写成 `if (fullFrame) { BitBlt(整窗) } else if (panelOnly) { BitBlt(本帧重绘区域并集) }`,没有兜底 else。 - 「鼠标只在绘图区内移动」的帧(WM_MOUSEMOVE 的 plotOnlyMove 路径只 InvalidatePlotRect)恰好 fullFrame == false && panelOnly == false ⇒ 后备缓冲区被正确重绘,窗口 DC 一个像素都不回送。 - 这一个根因同时造成用户报告的三个症状:悬停光晕卡住(移开不消失)、拖动滚动条不跟手/非常卡顿、 拖拽信号换网格看不到任何结果。 修法 - git reset --hard HEAD~1 撤销 5b52eef(保留 backup-bad-commit 标签供 A/B)。 - 只挑回该提交里与更新区域无关的功能改动(用户明确要求过、本身正确): * SignalPanel::ExpandGroupForSignal():联动滚动前立刻展开折起的分组(不播展开动画); * FitAll() / FitSinglePointCluster() 不再复位 Tab().LaneScroll()(适应视图不影响滚动条); * 右下角「回到最上方」浮动按钮 + 网格滚动到可视区的平滑动画。 - Application.cpp 只保留 3 处最小改动:Render 里把 panelAnimating 计入整帧脏、WM_TIMER 推进 AdvanceBackToTopAnimation() 且保持整窗失效、定时器存活判定带 BackToTopAnimationActive()。 Render 的绘制与 BitBlt 路径逐字节等同于 82b5ebe。 - 彻底删除 m_paintPlot / m_panelDirty / panelOnly / InvalidatePanelRect / 并集回送。 验证(全部离屏执行,不移动真实光标、不抢焦点、不截屏) - .preflight/diag(新增离屏探针,19/19):窗口移到屏幕外 + WS_EX_TOOLWINDOW + SW_SHOWNOACTIVATE, 保证有真实更新区域与 WM_PAINT 但不影响用户。断言滚动条拖动(滑块与绘图区都在后备缓冲区被重绘)、 悬停光晕出现与消退(像素完全回到基线)、在曲线上按下进入 MOVE_SIGNAL 且拖到另一网格后归属正确。 - .preflight/check 137/137。 - .preflight/diag/verify_blit.ps1(新增源码级护栏,10/10):断言 Render() 的回送块必须有无条件 else、 不得出现 else if、m_paintPlot/m_panelDirty/panelOnly 必须不存在、失效只允许整窗或仅绘图区。 A/B:换回 backup-bad-commit ⇒ 4 项如实失败;换回修复版 ⇒ 10/10。 - make release 零错误零警告。 文档:Documents/agent_mem.md 新增 §9.41、§11.2 第 108–116 条(含 §9.40 与旧 108–114 条的作废标注) 与变更日志一行。 教训(写进记忆):① 离屏探针无法验证「窗口回送 / 合成」(屏幕外 DWM 不合成、窗口 DC 恒为空白)⇒ 回送类约束必须有源码级护栏;② 离屏窗口 + TrackMouseEvent 会立刻收到 WM_MOUSELEAVE,探针须丢弃该消息; ③ 网格回收会让下标左移,断言要用锚信号。 --- Components/Application/Src/Application.cpp | 26 +++- Components/PlotView/Inc/PlotView.hpp | 53 +++++++ Components/PlotView/Src/PlotView.cpp | 93 ++++++++++++ Components/PlotView/Src/PlotViewFeatures.cpp | 8 +- .../PlotView/Src/PlotViewInteraction.cpp | 50 +++++++ Components/PlotView/Src/PlotViewInternal.hpp | 14 ++ Components/PlotView/Src/PlotViewPaint.cpp | 52 +++++++ Components/SignalPanel/Inc/SignalPanel.hpp | 12 ++ .../Src/SignalPanelInteraction.cpp | 27 ++++ Documents/agent_mem.md | 133 ++++++++++++++++++ 10 files changed, 460 insertions(+), 8 deletions(-) diff --git a/Components/Application/Src/Application.cpp b/Components/Application/Src/Application.cpp index 681c709..584e8dc 100644 --- a/Components/Application/Src/Application.cpp +++ b/Components/Application/Src/Application.cpp @@ -399,8 +399,17 @@ namespace jdi // 工具栏/标签栏/面板各自带悬停态,内容只在鼠标位于其中或发生交互时改变。 // 绘图区内的拖拽(如双光标左右移动)期间这些区域完全静止,跳过其绘制, // 每帧省下约 6-8ms;弹出菜单 / 拾色器 / 加载遮罩会覆盖到装饰区域,必须整帧重绘。 + // + // ⚠️⚠️ **面板动画(滚动到目标信号 / 分组折起、展开)必须由动画状态本身驱动重绘**: + // 这两个动画每帧都在改变面板像素,而它们只由 `WM_TIMER` 驱动 —— 而 `WM_TIMER` + // **不会**置 `m_chromeDirty`(它刻意在排除列表里),于是旧实现完全依赖 + // `WM_PAINT` 里“更新区域不在绘图区内 ⇒ 置脏”这个启发式:一旦某帧的更新区域恰好 + // 不含面板(例如与鼠标消息或 `kMessagePlotDerivedReady` 的失效区域交错), + // 那一帧就**完全不重画面板** ⇒ 用户报告“过渡动画有时直接没了”。 + // ✓ 把动画状态直接纳入重绘条件(**只增加重绘条件,不改变任何绘制/回送路径**)。 + const bool panelAnimating = m_panel.ScrollAnimationActive() || m_panel.GroupAnimationActive(); const bool overlaysVisible = m_menu.IsOpen() || m_colorPicker.IsOpen(); - const bool chromeDirty = m_chromeDirty || m_loading || overlaysVisible; + const bool chromeDirty = m_chromeDirty || m_loading || overlaysVisible || panelAnimating; m_chromeDirty = false; // 提示浮层淡出由定时器驱动,其绘制范围局限在绘图区,但为稳妥起见整帧回送。 const bool fullFrame = chromeDirty || m_plot.HintNeedsAnimation(); @@ -472,10 +481,10 @@ namespace jdi m_hintTimerActive = false; } - // 信号列表与绘图区的动画(目标信号滚动 / 分组折起展开 / 网格滚动到可视区): - // 仅在动画进行中保持定时重绘。 + // 信号列表与绘图区的动画(目标信号滚动 / 分组折起展开 / 网格滚动到可视区 / + //「回到最上方」按钮的淡入淡出):仅在动画进行中保持定时重绘。 if (m_panel.ScrollAnimationActive() || m_panel.GroupAnimationActive() || - m_plot.LaneScrollAnimationActive()) + m_plot.LaneScrollAnimationActive() || m_plot.BackToTopAnimationActive()) { if (!m_scrollTimerActive) { @@ -1403,13 +1412,18 @@ namespace jdi InvalidateRect(hwnd, nullptr, FALSE); return 0; } - // 信号列表与绘图区动画(「滚动到目标信号」/「分组折起、展开」/「网格滚动到可视区」): - // 推进一步并重绘(Render 会在动画结束后停表)。 + // 信号列表与绘图区动画(「滚动到目标信号」/「分组折起、展开」/「网格滚动到可视区」/ + //「回到最上方」按钮的淡入淡出):推进一步并整窗重绘(Render 会在动画结束后停表)。 + // ⚠️ 这里**保持整窗失效**(不要改成“只失效动画所在的区域”):面板与绘图区的 + // 回送范围由 `Render` 按 `fullFrame` 决定,任何“精确失效”都可能让屏幕上留下 + // 未刷新的区域(历史上就因此让滚动条/时间轴不跟手);而整窗失效下动画帧 + // 也只有 ~6-8ms(124 网格),完全在 16ms 预算内。 if (wParam == kScrollTimerId) { m_panel.AdvanceScrollAnimation(); m_panel.AdvanceGroupAnimations(); m_plot.AdvanceLaneScrollAnimation(); + m_plot.AdvanceBackToTopAnimation(); InvalidateRect(hwnd, nullptr, FALSE); return 0; } diff --git a/Components/PlotView/Inc/PlotView.hpp b/Components/PlotView/Inc/PlotView.hpp index 1d53529..ec74371 100644 --- a/Components/PlotView/Inc/PlotView.hpp +++ b/Components/PlotView/Inc/PlotView.hpp @@ -953,6 +953,27 @@ namespace jdi /** @brief 网格滚动动画是否正在进行。 */ bool LaneScrollAnimationActive() const { return m_laneScrollAnimActive; } + /** + * @brief 把网格滚动**平滑地**回到最上方(供右下角浮动按钮调用)。 + * @return 开始了动画(或已在上方)返回 true。 + */ + bool ScrollLanesToTop(); + + /** + * @brief 右下角「回到最上方」浮动按钮是否可见(含淡入淡出过程中的可见性)。 + * @return 可见返回 true。 + */ + bool BackToTopVisible() const { return m_backToTopAlpha > 0.01f; } + + /** + * @brief 推进一步「回到最上方」按钮的淡入/淡出过渡(由宿主定时器驱动)。 + * @return 仍在过渡中返回 true(宿主应保持定时重绘)。 + */ + bool AdvanceBackToTopAnimation(); + + /** @brief 「回到最上方」按钮是否正在淡入/淡出。 */ + bool BackToTopAnimationActive() const { return m_backToTopAnimating; } + /** * @brief 取消网格滚动动画(用户自己滚动网格时调用,避免动画与手动滚动抢滚动位置)。 */ @@ -1994,6 +2015,25 @@ namespace jdi */ void PaintScrollBar(gd::Graphics& g); + /** + * @brief 计算右下角「回到最上方」浮动按钮的矩形(客户区坐标)。 + * @return 按钮矩形;当前不该显示时返回空矩形(`m_backToTopAlpha` 为 0 时仍返回几何, + * 便于命中测试与过渡动画)。 + */ + gd::RectF BackToTopRect() const; + + /** + * @brief 绘制右下角「回到最上方」浮动按钮(含按 `m_backToTopAlpha` 的淡入淡出)。 + * @param [IN,OUT] g 绘图上下文。 + */ + void PaintBackToTop(gd::Graphics& g); + + /** + * @brief 依据当前滚动位置刷新「回到最上方」按钮的目标可见性(需要时启动过渡)。 + * @return 视觉状态发生变化返回 true。 + */ + bool UpdateBackToTopTarget(); + /** * @brief 绘制网格标题(信号图例)。 * @param [IN,OUT] g 绘图上下文。 @@ -2048,6 +2088,19 @@ namespace jdi std::uint64_t m_laneScrollAnimStartMs = 0; /**< 动画起始时刻(`GetTickCount64()`)。 */ std::uint32_t m_laneScrollAnimDurationMs = 0; /**< 动画总时长(毫秒)。 */ + /** + * @brief 右下角「回到最上方」浮动按钮(只在网格需要滚动条、且已向下滚动时出现)。 + * + * 用户 2026-10-16 要求:参考长文档网页的常见做法,用一个向上箭头的浮动按钮; + * 仅在向下滚动时出现、回到上方时消失,并且**出现/消失都要有过渡动画**(不能突变)。 + */ + float m_backToTopAlpha = 0.0f; /**< 当前不透明度(0 = 完全隐藏)。 */ + bool m_backToTopFadeIn = false; /**< 当前过渡方向(true = 正在淡入)。 */ + bool m_backToTopAnimating = false; /**< 是否正在淡入/淡出。 */ + bool m_backToTopHover = false; /**< 鼠标是否悬停在按钮上。 */ + bool m_backToTopPressed = false; /**< 是否正在按钮上按住左键。 */ + std::uint64_t m_backToTopAnimStartMs = 0; /**< 本段过渡的起始时刻。 */ + PlotDragMode m_dragMode = PlotDragMode::NONE; /**< 当前拖拽模式。 */ PlotRubberAxis m_rubberAxis = PlotRubberAxis::UNDECIDED; /**< 选区放大拖拽的轴向。 */ int m_dragLane = -1; /**< 拖拽所作用的网格下标。 */ diff --git a/Components/PlotView/Src/PlotView.cpp b/Components/PlotView/Src/PlotView.cpp index 8c6c69e..017061a 100644 --- a/Components/PlotView/Src/PlotView.cpp +++ b/Components/PlotView/Src/PlotView.cpp @@ -273,6 +273,99 @@ namespace jdi return true; } + bool PlotView::ScrollLanesToTop() + { + // 已经在最上方:什么都不做(按钮此时也不该可见)。 + if (Tab().LaneScroll() <= 0.5f) + { + Tab().LaneScroll() = 0.0f; + m_laneScrollAnimActive = false; + return true; + } + + // 复用与「点选信号滚入可视区」完全相同的平滑过渡(ease-out cubic)。 + const float distance = Tab().LaneScroll(); + const float durationMs = std::max( + kLaneScrollAnimMinMs, std::min(kLaneScrollAnimMaxMs, distance * kLaneScrollAnimMsPerPixel)); + m_laneScrollAnimFrom = Tab().LaneScroll(); + m_laneScrollAnimTo = 0.0f; + m_laneScrollAnimStartMs = GetTickCount64(); + m_laneScrollAnimDurationMs = static_cast(durationMs + 0.5f); + m_laneScrollAnimActive = true; + return true; + } + + bool PlotView::UpdateBackToTopTarget() + { + // 目标可见性 = 网格需要滚动条(说明网格多到需要滚动)且已经向下滚动了一段距离。 + const bool want = m_scrollBarVisible && + (Tab().LaneScroll() > kBackToTopShowOffset * m_scale); + if (want == m_backToTopFadeIn && !m_backToTopAnimating) + { + return false; + } + if (want == m_backToTopFadeIn) + { + return false; // 方向未变且正在过渡:让它继续 + } + m_backToTopFadeIn = want; + m_backToTopAnimating = true; + m_backToTopAnimStartMs = GetTickCount64(); + if (!want) + { + // 淡出开始时取消悬停/按下态(按钮即将消失)。 + m_backToTopHover = false; + m_backToTopPressed = false; + } + return true; + } + + bool PlotView::AdvanceBackToTopAnimation() + { + if (!m_backToTopAnimating) + { + return false; + } + + 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; + if (progress > 1.0f) + { + progress = 1.0f; + } + // ease-out cubic:与其它过渡一致的手感(起步快、末尾慢)。 + const float eased = 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; + + if (progress >= 1.0f) + { + m_backToTopAlpha = to; + m_backToTopAnimating = false; + return false; + } + return true; + } + + gd::RectF PlotView::BackToTopRect() const + { + if (!m_scrollBarVisible) + { + return gd::RectF(); + } + const float size = kBackToTopSize * m_scale; + const float margin = kBackToTopMargin * m_scale; + // ⚠️ 贴着**绘图区**右下角(`m_plotRect` 已经让出了右侧滚动条的位置, + // 因此按钮不会压到滚动条上)。 + return gd::RectF(m_plotRect.X + m_plotRect.Width - margin - size, + m_plotRect.Y + m_plotRect.Height - margin - size, size, size); + } + bool PlotView::LaneSpansMultipleGroups(int laneIndex) const { if (m_tab == nullptr || laneIndex < 0 || laneIndex >= static_cast(m_tab->Lanes().size())) diff --git a/Components/PlotView/Src/PlotViewFeatures.cpp b/Components/PlotView/Src/PlotViewFeatures.cpp index 7c398ee..a144a2c 100644 --- a/Components/PlotView/Src/PlotViewFeatures.cpp +++ b/Components/PlotView/Src/PlotViewFeatures.cpp @@ -163,7 +163,10 @@ namespace jdi { Tab().ViewInitialized() = true; } - Tab().LaneScroll() = 0.0f; + // ⚠️ **不再复位网格纵向滚动(`Tab().LaneScroll()`)**:用户 2026-10-16 明确要求 + // “适应视图不要影响滚动条” —— 加载了大量网格并向下翻动后点「适应视图」, + // 旧实现会把视界瞬移回最上方(用户报告)。适应视图只负责时间轴/幅值轴。 + m_laneScrollAnimActive = false; // 只放下进行中的选区与拖拽,**不改变「选区放大」等缩放模式的启用状态** // (用户明确要求:点适应视图后仍保持已启用的缩放模式)。 m_rubberRect = gd::RectF(); @@ -359,7 +362,8 @@ namespace jdi static_cast(kTimeTickTarget) * kSinglePointClusterSecondsPerCell; SetTimeView(sharedTime - span * 0.5, sharedTime + span * 0.5); Tab().ViewInitialized() = true; - Tab().LaneScroll() = 0.0f; + // ⚠️ 同 `FitAll()`:**不复位网格纵向滚动**(用户 2026-10-16 要求)。 + m_laneScrollAnimActive = false; // ⚠️ **不要**在这里复位 `ZoomMode()`:用户明确要求"点适应视图后仍保持已启用的 // 缩放模式"(`FitAll` 的常规路径同样只更新提示、不动缩放模式)。 m_rubberRect = gd::RectF(); diff --git a/Components/PlotView/Src/PlotViewInteraction.cpp b/Components/PlotView/Src/PlotViewInteraction.cpp index a4b74a4..4b98296 100644 --- a/Components/PlotView/Src/PlotViewInteraction.cpp +++ b/Components/PlotView/Src/PlotViewInteraction.cpp @@ -847,6 +847,18 @@ namespace jdi return LoadCursorW(nullptr, IDC_SIZENS); } + // 右下角「回到最上方」浮动按钮:与其它可点击控件一致,用手型光标作为提示。 + // ⚠️ 必须在信号悬停 / 图例 / 面板判定**之前**(它绘制在最上层)。 + if (m_backToTopAlpha > 0.01f) + { + const gd::RectF backToTop = BackToTopRect(); + if (backToTop.Width > 0.0f && + UiPointInRect(backToTop, static_cast(x), static_cast(y))) + { + return LoadCursorW(nullptr, IDC_HAND); + } + } + // 实验性功能面板与横向光标优先于信号悬停提示判定(面板会盖住波形)。 // 面板**只有标题栏**可以拖拽(显示抓握光标);✕ 与面板主体一律保持原始箭头。 if (m_tab != nullptr) @@ -982,6 +994,19 @@ namespace jdi const float fx = static_cast(x); + // 右下角「回到最上方」按钮的悬停高亮(仅未拖拽时;按住其它东西时不该抢)。 + if (m_dragMode == PlotDragMode::NONE) + { + const gd::RectF backToTop = BackToTopRect(); + const bool hot = m_backToTopAlpha > 0.01f && backToTop.Width > 0.0f && + UiPointInRect(backToTop, fx, static_cast(y)); + if (hot != m_backToTopHover) + { + m_backToTopHover = hot; + return true; + } + } + switch (m_dragMode) { case PlotDragMode::PAN: @@ -1479,6 +1504,18 @@ namespace jdi m_pendingPanelClose = false; m_pendingPanelCloseLane = -1; + // 右下角「回到最上方」浮动按钮:按下只登记,**抬起**时仍命中才真正生效 + //(与其它 ✕ / 按钮的「可取消」交互一致),且**不**进入任何拖拽模式。 + if (m_backToTopAlpha > 0.01f) + { + const gd::RectF backToTop = BackToTopRect(); + if (backToTop.Width > 0.0f && UiPointInRect(backToTop, fx, fy)) + { + m_backToTopPressed = true; + return true; + } + } + if (m_scrollBarVisible && UiPointInRect(m_scrollBarRect, static_cast(x), static_cast(y))) { @@ -1968,6 +2005,19 @@ namespace jdi { bool changed = (m_dragMode != PlotDragMode::NONE); + // 右下角「回到最上方」按钮:按下后仍在按钮上抬起 ⇒ 平滑滚回最上方。 + if (m_backToTopPressed) + { + m_backToTopPressed = false; + const gd::RectF backToTop = BackToTopRect(); + if (backToTop.Width > 0.0f && + UiPointInRect(backToTop, static_cast(x), static_cast(y))) + { + ScrollLanesToTop(); + } + return true; + } + // 实验性功能面板的 ✕:只有"按下后仍在同一个 ✕ 上抬起"才真正关闭该功能。 if (m_pendingPanelClose) { diff --git a/Components/PlotView/Src/PlotViewInternal.hpp b/Components/PlotView/Src/PlotViewInternal.hpp index f4a947e..10d487a 100644 --- a/Components/PlotView/Src/PlotViewInternal.hpp +++ b/Components/PlotView/Src/PlotViewInternal.hpp @@ -73,6 +73,20 @@ namespace jdi inline const float kLaneScrollAnimMinMs = 130.0f; /**< 最短时长(短距离)。 */ inline const float kLaneScrollAnimMaxMs = 300.0f; /**< 最长时长(长距离)。 */ inline const float kLaneScrollAnimMsPerPixel = 0.35f; /**< 每像素距离增加的时长。 */ + /** + * @brief 右下角「回到最上方」浮动按钮的几何与过渡参数。 + * + * 用户 2026-10-16 要求:参考长文档网页的常见做法(向上箭头浮动按钮), + * 仅在向下滚动时出现、回到上方时消失,出现/消失都要有过渡动画。 + */ + inline const float kBackToTopSize = 36.0f; /**< 按钮边长(逻辑像素,圆形直径)。 */ + inline const float kBackToTopMargin = 12.0f; /**< 与绘图区右下角的边距。 */ + inline const float kBackToTopFadeMs = 170.0f; /**< 淡入/淡出时长(毫秒)。 */ + /** 向下滚动超过该距离(逻辑像素)才显示按钮(避免轻微滚动就弹出)。 */ + inline const float kBackToTopShowOffset = 24.0f; + /** 向上箭头相对按钮尺寸的比例(箭头顶端到中心、半宽)。 */ + inline const float kBackToTopArrowDepth = 0.24f; + inline const float kBackToTopArrowHalfWidth = 0.20f; inline const float kEnvelopeMinPixelSpan = 0.5f; /**< 包络纵向跨度小于该像素值时合并为单个顶点。 */ inline const float kCursorHandleReserve = 9.0f; /**< 光标顶部三角把手占用的高度(数据点贴边时需避让)。 */ inline const float kMarkerEdgeInset = 7.0f; /**< 数据点越界贴边时距网格可见边界的间距。 */ diff --git a/Components/PlotView/Src/PlotViewPaint.cpp b/Components/PlotView/Src/PlotViewPaint.cpp index fcafad1..4d18fab 100644 --- a/Components/PlotView/Src/PlotViewPaint.cpp +++ b/Components/PlotView/Src/PlotViewPaint.cpp @@ -662,6 +662,10 @@ namespace jdi BuildLaneVisuals(); + // 「回到最上方」浮动按钮:目标可见性由当前滚动位置决定(过渡由宿主定时器推进)。 + // 在绘制前刷新一次,保证本帧就画出正确的过渡形态。 + UpdateBackToTopTarget(); + // 「边沿计数」的扫描预算**按帧重置**(见 `m_edgeScanFrame` 的说明): // 旧实现按 `GetTickCount64()` 分桶,帧率一高(`PostMessage` 自持重绘会把它拉高) // 就会在同一系统 tick 内连画多帧而**完全不推进**统计 —— 用户报告 @@ -735,6 +739,8 @@ namespace jdi PaintEditField(g); } PaintScrollBar(g); + // 右下角「回到最上方」浮动按钮:在滚动条之上绘制(随 `m_backToTopAlpha` 淡入淡出)。 + PaintBackToTop(g); // 绘图区外框(比网格内部分界线更重,形成明确整体边界)。 // 用 GDI 画 4 条线:GDI+ 的 DrawRectangle 带抗锯齿时单帧要花掉 1ms 以上。 @@ -6380,5 +6386,51 @@ namespace jdi UiFillRoundRect(g, thumb, kScrollBarThickness * m_scale * 0.5f, m_theme->borderStrong); } + void PlotView::PaintBackToTop(gd::Graphics& g) + { + if (m_backToTopAlpha <= 0.01f) + { + return; + } + const gd::RectF rect = BackToTopRect(); + if (rect.Width <= 1.0f) + { + return; + } + + // 过渡:整个按钮(底色/描边/箭头/投影)按 `m_backToTopAlpha` 淡入淡出。 + const float alpha = std::min(1.0f, std::max(0.0f, m_backToTopAlpha)); + const auto faded = [alpha](std::uint32_t color) -> std::uint32_t + { + const std::uint32_t a = + static_cast(static_cast((color >> 24) & 0xFFu) * alpha); + return (color & 0x00FFFFFFu) | ((a & 0xFFu) << 24); + }; + + const float radius = rect.Width * 0.5f; // 圆形按钮(与图例托底/GX 徽标同一套配色) + // 投影:两层递减透明度的圆角矩形(与弹出菜单/浮动面板一致,不引入系统阴影 API)。 + UiFillRoundRect(g, gd::RectF(rect.X + 1.0f * m_scale, rect.Y + 2.0f * m_scale, rect.Width, + rect.Height), + radius, faded(0x1A000000u)); + UiFillRoundRect(g, gd::RectF(rect.X, rect.Y + 1.0f * m_scale, rect.Width, rect.Height), radius, + faded(0x14000000u)); + + const bool hot = m_backToTopHover || m_backToTopPressed; + UiFillRoundRect(g, rect, radius, faded(hot ? m_theme->accentSoft : m_theme->cardBg)); + UiDrawRoundRect(g, rect, radius, faded(hot ? m_theme->accent : m_theme->borderStrong), + hot ? 1.6f : 1.0f); + + // 向上箭头:竖杆 + 两条斜线组成的箭头(自绘,不依赖字体)。 + const float cx = rect.X + rect.Width * 0.5f; + const float cy = rect.Y + rect.Height * 0.5f; + const float depth = rect.Width * kBackToTopArrowDepth; + const float halfWidth = rect.Width * kBackToTopArrowHalfWidth; + const std::uint32_t ink = faded(hot ? m_theme->accent : m_theme->textStrong); + const float stroke = std::max(1.6f, 2.0f * m_scale); + UiDrawLine(g, cx, cy + depth, cx, cy - depth, ink, stroke); + UiDrawLine(g, cx, cy - depth, cx - halfWidth, cy - depth * 0.15f, ink, stroke); + UiDrawLine(g, cx, cy - depth, cx + halfWidth, cy - depth * 0.15f, ink, stroke); + } + } // namespace jdi diff --git a/Components/SignalPanel/Inc/SignalPanel.hpp b/Components/SignalPanel/Inc/SignalPanel.hpp index b233c74..9a24258 100644 --- a/Components/SignalPanel/Inc/SignalPanel.hpp +++ b/Components/SignalPanel/Inc/SignalPanel.hpp @@ -343,6 +343,18 @@ namespace jdi */ void ScrollToSignalAnimated(const SignalRef& ref); + /** + * @brief 若目标信号所在分组处于**当前模式的折起状态**,自动把它展开。 + * + * 折起的分组不会列出信号行 ⇒ 行集合里找不到目标信号,任何“滚动到该信号”都会落空 + *(用户 2026-10-16 报告:点网格图例联动时,被折起的组不会自动展开、列表不动)。 + * 展开是**立刻生效**的(不播放展开动画),因为随后的滚动目标依赖稳定的行位置。 + * + * @param [IN] ref 目标信号。 + * @return 发生了展开返回 true。 + */ + bool ExpandGroupForSignal(const SignalRef& ref); + /** * @brief 推进一步滚动动画(由宿主定时器驱动)。 * @return 动画仍在进行返回 true(宿主应保持定时重绘)。 diff --git a/Components/SignalPanel/Src/SignalPanelInteraction.cpp b/Components/SignalPanel/Src/SignalPanelInteraction.cpp index bfbc33b..c293dc0 100644 --- a/Components/SignalPanel/Src/SignalPanelInteraction.cpp +++ b/Components/SignalPanel/Src/SignalPanelInteraction.cpp @@ -325,6 +325,14 @@ namespace jdi void SignalPanel::ScrollToSignalAnimated(const SignalRef& ref) { EnsureRows(); + if (!ref.IsValid()) + { + return; + } + // ⚠️ **目标信号所在分组若处于折起状态,行集合里根本没有它** —— 无论怎么滚都看不到它。 + // 用户 2026-10-16 报告:点击网格内的图例触发联动时,被折起的组不会自动展开 ⇒ 联动失效。 + // 因此先自动展开该分组(立刻生效,不做展开动画:滚动目标需要稳定的行位置)。 + ExpandGroupForSignal(ref); float desired = m_scrollOffset; if (!SignalScrollTarget(ref, desired)) { @@ -334,6 +342,25 @@ namespace jdi StartScrollAnimationTo(desired); } + bool SignalPanel::ExpandGroupForSignal(const SignalRef& ref) + { + if (ref.groupIndex < 0 || !IsGroupCollapsed(ref.groupIndex)) + { + return false; + } + SetGroupCollapsed(ref.groupIndex, false); + // 该分组若正在播放折起动画,一并作废(目标行的位置会跳回展开态,动画没有意义了)。 + const int anim = FindGroupAnimation(ref.groupIndex); + if (anim >= 0) + { + m_groupAnims.erase(m_groupAnims.begin() + anim); + } + // 行集合必须重建(展开后信号行才会出现)——滚动目标依赖它。 + m_rowsDirty = true; + EnsureRows(); + return true; + } + bool SignalPanel::AdvanceScrollAnimation() { if (!m_scrollAnimActive) diff --git a/Documents/agent_mem.md b/Documents/agent_mem.md index 92a4f8d..42978d1 100644 --- a/Documents/agent_mem.md +++ b/Documents/agent_mem.md @@ -2447,6 +2447,90 @@ Components/Shared/Src/UiControls.cpp --- +### 9.41 回退 `5b52eef` 并修复「屏幕不刷新」根因(拖动滚动条卡顿 / 悬停高亮卡住 / 信号拖拽无效果)(本轮,本机) + +> ⚠️⚠️ **本轮撤销提交 `5b52eef`(§9.40 随之作废,作废范围见下与 §11.2 的编号注记)。** +> 用户报告:以加载 `TestData/MF4-Record.MF4` 并显示全部信号为例 —— ① 拖动网格右侧滑动条非常卡顿、 +> 几乎拖不动;② 悬停到某条信号触发高亮后,鼠标移开(不落到另一条曲线上)高亮**仍然卡住**; +> ③ 几乎无法正常拖拽信号到其它网格;④ 预期的流畅动画帧没有实现。用户要求「撤销上次的提交并着手定位并修复」。 + +#### 一、根因:`Render()` 的回送被写成「两个条件都不满足 ⇒ 一帧都不回送」(代码级确认) + +`Application::Render()` 把整个界面画进后备缓冲区 `m_backDc`,最后 `GetDC` + `BitBlt` 回送窗口。 +`5b52eef` 把它改成了: + +```cpp +if (fullFrame) { BitBlt(windowDc, 0, 0, width, height, m_backDc, 0, 0, SRCCOPY); } +else if (panelOnly) { /* 只回送“本帧重绘过的区域并集” */ } +// ← 再也没有兜底 else +``` + +而 `fullFrame = chromeDirty || overlaysVisible || m_plot.HintNeedsAnimation()`, +`panelOnly = !chromeDirty && (m_panelDirty || panelAnimating)`。 +**「鼠标在绘图区内移动」这一类帧恰好满足 `fullFrame == false && panelOnly == false`** +(`WM_MOUSEMOVE` 的 `plotOnlyMove` 路径只 `InvalidatePlotRect`,既不动 `m_chromeDirty` 也不动 `m_panelDirty`) +⇒ **后备缓冲区被正确重绘,窗口 DC 一个像素都没更新。** + +这**一个根因**同时解释了用户报告的三条症状: + +* 悬停光晕已画进后备缓冲区却不回送 ⇒ 屏幕停在旧像素 ⇒ **“高亮卡住、移开不消失”**; +* 拖动滚动条时滑块与波形都不回送 ⇒ 只有偶尔整窗失效(定时器 / 装饰脏)那一帧才刷新 + ⇒ **“非常卡顿 / 拖不动”**; +* 拖拽信号换网格后不回送 ⇒ 用户看不到任何结果 ⇒ **“几乎无法拖拽”**。 + +(§9.40 想省下的“面板动画帧重画绘图区的 ~5ms”,代价就是这条正确性,得不偿失。) + +#### 二、修法:整体撤销该机制,回到「整窗失效 + 无条件兜底回送」 + +- `git reset --hard HEAD~1` 撤销 `5b52eef`(先打标签 `backup-bad-commit` 供 A/B)。 +- **只挑回该提交里“与更新区域无关”的**功能改动(它们本身是对的,用户也明确要求过): + - `SignalPanel::ExpandGroupForSignal()`(联动滚动前**立刻展开**折起的分组,不播展开动画); + - `FitAll()` / `FitSinglePointCluster()` **不再复位** `Tab().LaneScroll()`(“适应视图不影响滚动条”); + - 右下角「回到最上方」浮动按钮(`BackToTopRect` / 170ms 淡入淡出 / `ScrollLanesToTop` / `PaintBackToTop`) + 与「网格滚动到可视区」的平滑动画(`ScrollLaneIntoView` 只启动、`AdvanceLaneScrollAnimation` 推进、 + 手动滚动时 `CancelLaneScrollAnimation()`)。 +- 宿主 `Application.cpp` 只保留 **3 处最小改动**:`Render` 里把 `panelAnimating` 计入整帧脏; + `WM_TIMER` 推进 `AdvanceBackToTopAnimation()` 并**保持整窗失效**;定时器存活判定带上 + `BackToTopAnimationActive()`。**`Render` 的绘制与 BitBlt 路径逐字节等同于 `82b5ebe`。** +- **彻底删除** `m_paintPlot` / `m_panelDirty` / `panelOnly` / `InvalidatePanelRect` / 并集回送。 + +#### 三、验证(全部离屏,不占用用户的鼠标 / 键盘 / 屏幕) + +- **`.preflight/diag`(本轮新增,19/19 通过)**:把窗口 `MoveWindow` 到屏幕外(`-30000,-30000`) + + `WS_EX_TOOLWINDOW` + `SW_SHOWNOACTIVATE` ⇒ **窗口真实可见、有真实更新区域与 `WM_PAINT`, + 但完全不出现在用户屏幕上、不抢焦点、不进任务栏**。四项断言: + **[A]** 后备缓冲区可读;**[B]** 拖动网格滚动条 ⇒ 滚动量变化 + **滑块与绘图区都在后备缓冲区里被重绘**; + **[C]** 悬停曲线 ⇒ 进入悬停态且绘图区像素变化(光晕可见),移开 ⇒ 悬停态清除且**像素完全回到基线**; + **[D]** 在曲线上按下进入 `MOVE_SIGNAL`(**不是** `LANE_REORDER`)、拖到另一网格松手后该信号归属正确。 +- **`.preflight/check` 137/137**(原 [J]「面板动画帧跳过绘图区」改写为「面板动画帧**必须**重绘、 + 且不把绘图区画坏」,并新增“只改绘图区的帧仍必须在后备缓冲区里更新”)。 +- **`.preflight/diag/verify_blit.ps1`(本轮新增,源码级护栏,10/10 通过)**:断言 `Render()` 的回送块里 + **必须有无条件 `else`**、**不得出现 `else if`**、`m_paintPlot`/`m_panelDirty`/`panelOnly` **必须不存在**、 + 失效方式只允许“整窗”或“仅绘图区”。 +- **A/B**:把 `Application.cpp` 换回 `backup-bad-commit` ⇒ 护栏 **4 项如实失败**;换回修复版 ⇒ 10/10。 +- `make release` **零错误零警告**。 + +#### 四、⚠️ 方法论教训(已写进 §11.2 第 108–116 条) + +1. **离屏探针读不到“窗口回送”**:窗口在屏幕外时 DWM 不会合成,`GetDC` 读回的窗口 DC 恒为空白 + ⇒ “画进了后备缓冲区但没回送”的缺陷在离屏探针里**永远假绿**(本轮就是这样假绿的)。 + 凡是**回送 / 合成**相关的约束,必须有**源码级护栏**兜底。 +2. **离屏窗口 + `TrackMouseEvent` 的陷阱**:产品在 `WM_MOUSEMOVE` 里注册 `TME_LEAVE`, + 而离屏窗口上真实鼠标并不在它上面 ⇒ 系统**立刻**兑现一条 `WM_MOUSELEAVE`,刚设置的悬停态 + 马上被清掉、探针永远观察不到悬停效果。探针的消息泵必须**丢弃 `WM_MOUSELEAVE`**(仅限探针,不改产品)。 +3. **对“网格会被回收 / 下标会左移”的断言必须换成锚信号**:`MoveSignalToLane` 后源网格若被移空会被回收, + 其后所有网格下标**左移**(本轮实测 124 → 123)⇒ 「移动后下标 == 目标下标」会假失败。 +4. **失败时先怀疑探针自己的取点与判据**:本轮先后误判“悬停不生效”“信号没换网格”, + 实际都是探针取点踩到了「GX 重排徽标 / 图例 / 时间光标线」这些**优先级更高的热区**, + 或用了错误的比较基准(拿滚动后的 rect 当比较区域)。 +5. `Copy-Item` **保留时间戳** ⇒ 用备份覆盖源文件后必须 `(Get-Item f).LastWriteTime = Get-Date` + 再 `make`,否则测的还是旧目标文件(本轮 A/B 又踩了一次,与 §9.40 同)。 +6. 不要对**源码文件**做 PowerShell 文本往返(`Get-Content`/`Set-Content`): + PS 5.1 按 ANSI/GBK 解码 UTF-8,会把 C++ 源里的中文注释**整份写坏**并加上 BOM。 + 只能改用文件编辑工具改源文件(本轮就这样毁过一次 `.preflight/diag/main.cpp`)。 + +--- + ## 10. 变更日志(本文件维护记录) | 日期 | 设备/模型 | 摘要 | @@ -2503,6 +2587,8 @@ Components/Shared/Src/UiControls.cpp --- +| 2026-10-16 | DeepSeek-V4.1-Flash(**本机**) | **回退 5b52eef 并修复“屏幕不刷新”根因(拖动滚动条卡顿 / 悬停高亮卡住 / 信号拖拽无效果,详见 §9.41;§9.40 随之作废)**。根因(代码级):`Render()` 的回送被写成 `if (fullFrame) … else if (panelOnly) …` 而**没有兜底 else**,而“鼠标只在绘图区内移动”的帧恰好两个条件都不满足 ⇒ 后备缓冲区画对了、窗口 DC 一个像素都不回送(悬停光晕 / 滑块 / 拖拽结果全部停在旧像素)。修法:`git reset --hard` 撤销该提交(留 `backup-bad-commit` 标签),只挑回与更新区域无关的功能改动(联动前展开折起组 / 适应视图不动滚动条 / 「回到最上方」按钮 / 网格滚动平滑动画),彻底删除 `m_paintPlot`/`m_panelDirty`/`panelOnly`/并集回送;宿主只保留 3 处最小改动且 `Render` 的绘制与 BitBlt 与 `82b5ebe` 逐字节相同。验证:`.preflight/diag` **19/19**(新增离屏可见窗口探针:滚动条拖动 / 悬停光晕出现与消退 / 信号拖拽换网格)、`.preflight/check` **137/137**、源码护栏 `.preflight/diag/verify_blit.ps1` **10/10**(A/B:换回坏版本 ⇒ 4 项如实失败)、`make release` 零警告。教训:① 离屏探针**测不到窗口回送**(屏幕外 DWM 不合成、窗口 DC 恒空白)⇒ 回送类约束必须有源码护栏;② 离屏窗口 + `TrackMouseEvent` 会**立刻**收到 `WM_MOUSELEAVE`,探针须丢弃该消息;③ 网格回收会让下标左移,断言要用锚信号。 | + ## 11. 跨设备协作须知(起源设备视角) > **本文件是唯一权威记忆**。所有设备 / Agent 共用它,**不要再新建 `agent_mem_origin.md` 等旁支记忆文件**。 @@ -3020,6 +3106,53 @@ Components/Shared/Src/UiControls.cpp 对“瞬移”同样成立、毫无判别力。要断言:触发后立即仍几乎未动、中间帧单调递进、 收尾精确到位(本轮 A/B 就是靠这三条排除了“直接赋值”的实现)。 +> ⚠️⚠️ **编号注记(2026-10-16)**:提交 `5b52eef`(已于 **§9.41** 整体回退)曾以 **108–114** 号 +> 写入下列规则,**它们全部作废**:108「`panelOnly = !chromeDirty && (m_panelDirty || panelAnimating)`」、 +> 109「动画帧必须**精确失效**、禁止整窗失效(`InvalidatePanelRect`/`InvalidatePlotRect`)」、 +> 110「`WM_PAINT` 辨认“更新区域完全在面板内”(`m_panelDirty`)+ `m_paintPlot` 跳过整个 `PlotView::Paint` +> + 回送范围改为“本帧重绘区域并集”」、111「联动滚动前先展开折起分组」、112「适应视图不得改变 `LaneScroll`」、 +> 113「右下角回到最上方浮动按钮」、114「`Copy-Item` 保留时间戳」。 +> **其中 111 / 112 / 113 / 114 的内容本身是对的,已并入下面的 111–113 条继续生效**; +> **108 的一半(“动画由动画状态本身驱动重绘”)正确、实现改为“计入整帧脏 + 整窗失效”; +> 109 / 110 属于被证伪的方案 —— 禁止再用。** +108. ⚠️⚠️ **`Render()` 的窗口回送必须存在“无条件兜底分支”**: + `if (fullFrame) { BitBlt(整窗) } else { BitBlt(绘图区) }` —— **绝不能**写成 + `if (fullFrame) … else if (某条件) …`。否则存在“两个条件都不满足”的帧组合 + (典型:`WM_MOUSEMOVE` 的 `plotOnlyMove` 路径只失效绘图区,既不动 `m_chromeDirty` 也不动面板脏), + 这些帧会**一个像素都不回送** ⇒ 后备缓冲区正确、屏幕却停在旧画面 + (用户表现:悬停高亮卡住、滚动条滑块跟不动、拖拽信号看不到结果)。 + **回送区域永远“宁多勿少”**;省回送带宽的收益远小于一次屏幕不刷新的代价。 +109. ⚠️⚠️ **禁止“按窗口更新区域决定本帧画什么 / 回送什么”的优化**(`m_paintPlot` / `m_panelDirty` / + `panelOnly` / 并集回送这一整套都已删除)。它把正确性绑在 `GetUpdateRect` 与各失效点“恰好配套”上, + 一旦出现新的失效路径(如本轮“只失效绘图区”的鼠标移动)就会静默出错; + 而且**它无法被离屏探针验证**(见第 113 条)。 +110. ⚠️ **面板动画必须由“动画状态本身”驱动重绘**(正确洞见保留):`WM_TIMER` 不置 `m_chromeDirty`, + 所以 `Render` 要把 `m_panel.ScrollAnimationActive() || m_panel.GroupAnimationActive()` 计入整帧脏, + 否则某些帧根本不重画面板(用户:“过渡动画有时直接没了”)。 + **实现方式改为整窗失效**(不是“只失效面板矩形”)。 +111. ⚠️ **联动滚动前必须先展开折起的分组**:折起的分组不列出信号行 ⇒ 行集合里找不到目标, + `SignalScrollTarget` 直接返回、列表不动。 + ✓ `SignalPanel::ExpandGroupForSignal()`(**立刻生效**、不播展开动画,因为滚动目标需要稳定行位置), + 由 `ScrollToSignalAnimated()` 在算滚动目标前调用。 +112. ⚠️ **「适应视图」不得改变网格纵向滚动**:`FitAll()` 与 `FitSinglePointCluster()` 里那两次 + `Tab().LaneScroll() = 0` 已删除(用户明确要求“适应视图不要影响滚动条”),只取消滚动动画。 + 右下角「回到最上方」浮动按钮(仅 `m_scrollBarVisible` 且已下滚 `> kBackToTopShowOffset`(24) + 时出现;170ms ease-out cubic 淡入淡出;按下只登记 `m_backToTopPressed`、**不进入拖拽**; + 抬起仍在同一按钮上才 `ScrollLanesToTop()`)与「网格滚动到可视区」的平滑动画继续生效。 +113. ⚠️⚠️ **离屏探针无法验证“窗口回送 / 合成”**:窗口在屏幕外时 DWM 不合成,`GetDC` 读回的窗口 DC + 恒为空白 ⇒ “画进了后备缓冲区但没回送窗口”的缺陷在离屏探针里**永远假绿**(本轮就是这样)。 + **回送 / 合成相关的约束必须有源码级护栏**(本机:`.preflight/diag/verify_blit.ps1`,断言回送块 + 存在无条件 `else`、无 `else if`、且 `m_paintPlot`/`m_panelDirty`/`panelOnly` 不存在)。 +114. ⚠️ **离屏探针的消息泵必须丢弃 `WM_MOUSELEAVE`**:产品在 `WM_MOUSEMOVE` 里注册 + `TrackMouseEvent(TME_LEAVE)`,而离屏窗口上真实鼠标不在它上面 ⇒ 系统**立刻**兑现一条 + `WM_MOUSELEAVE`,刚设置的悬停态马上被清掉(表现为“探针永远测不到悬停”)。仅影响探针。 +115. ⚠️ **涉及“网格会被回收 / 下标会左移”的断言必须用锚信号**:`MoveSignalToLane` 把源网格移空后 + 该网格被回收,其后所有网格下标**左移**(实测 124 → 123)⇒ 「移动后下标 == 目标下标」会假失败; + 改为「移动后与**目标网格里的某个锚信号**同网格」。 +116. ⚠️ **对“看起来坏了”的现象,先怀疑探针自己的取点与判据**:本轮先后误判“悬停不生效” + “信号没换网格”,实际都是探针取点踩到了「GX 重排徽标 / 图例 / 时间光标线」这些 + **优先级更高的热区**,或用了错误的比较基准(拿滚动后的 rect 当比较区域)。 + ### 11.3 离屏验证方法学(本机偏好,强烈建议沿用) - **不要占用用户的鼠标 / 键盘 / 屏幕**:优先 `.preflight/uitest`(离屏 harness:`SW_HIDE` 窗口 + 直接注入消息 + `BitBlt` 从 `m_backDc` 截图)或 `.preflight/perf`(离屏计时 / 逐像素对比)。