回退 5b52eef 并修复「屏幕不刷新」根因:拖动滚动条卡顿 / 悬停高亮卡住 / 信号拖拽无效果

根因(代码级确认)
- 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,探针须丢弃该消息;
③ 网格回收会让下标左移,断言要用锚信号。
This commit is contained in:
DeepSeek V4.1 Flash committed 2026-10-16 17:33:08 +08:00
1 parent 82b5ebe4f9
commit f845616938
10 files changed
+460 -8

No files matched your search

+53
View File
@@ -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; /**< 拖拽所作用的网格下标。 */
+93
View File
@@ -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<std::uint32_t>(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<float>(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<int>(m_tab->Lanes().size()))
+6 -2
View File
@@ -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<double>(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();
@@ -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<float>(x), static_cast<float>(y)))
{
return LoadCursorW(nullptr, IDC_HAND);
}
}
// 实验性功能面板与横向光标优先于信号悬停提示判定(面板会盖住波形)。
// 面板**只有标题栏**可以拖拽(显示抓握光标);✕ 与面板主体一律保持原始箭头。
if (m_tab != nullptr)
@@ -982,6 +994,19 @@ namespace jdi
const float fx = static_cast<float>(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<float>(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<float>(x), static_cast<float>(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<float>(x), static_cast<float>(y)))
{
ScrollLanesToTop();
}
return true;
}
// 实验性功能面板的 ✕:只有"按下后仍在同一个 ✕ 上抬起"才真正关闭该功能。
if (m_pendingPanelClose)
{
@@ -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; /**< 数据点越界贴边时距网格可见边界的间距。 */
+52
View File
@@ -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<std::uint32_t>(static_cast<float>((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