面板动画帧不再重绘绘图区(组名展开/收起卡顿根治)+ 均值面板两处修正 + 信号行集合自愈

1. 组名展开/收起卡顿的真因(用户报告:288 个信号同一网格、双光标/实验性功能开启时,
   点组名展开收起的动画非常卡顿,而网格里没有任何需要重绘的变化)
   - 滚动定时器给面板动画帧做的是整窗失效,WM_PAINT 里"更新区域不在绘图区内 ⇒
     m_chromeDirty = true"这条启发式把整帧判成脏 ⇒ Render 走 fullChrome 分支 ⇒
     标签栏/工具栏/绘图区全部重绘。绘图区走场景缓存很快,但**光标叠加层每帧都要重画**:
     288 个信号的取值胶囊(圆角 + GDI 文本 + 重叠避让)实测 24~46ms/帧。
   - 新增 Application::m_panelAnimationFrame:滚动定时器在只有面板动画时置位,
     WM_PAINT 命中它且没有任何其它消息要求装饰区重绘时不再把整帧判脏 ⇒ 走 panelOnly。
   - 新增 Application::m_plotPaintValid:panelOnly 帧且该标志为真时跳过 PlotView::Paint
     (面板不覆盖绘图区,上一帧像素原样留在后备缓冲区,整窗回送也不会错)。
     作废点:InvalidatePlotRect(签名多收一个 bool&,5 个调用点统一传入)、Relayout、
     EnsureBackBuffer、WM_PAINT 中"更新区域就在绘图区内"、以及"面板动画帧里绘图区的
     滚动/回到最上方动画同时也在跑"(否则会把绘图区动画冻住)。
   - panelOnly 分支画分隔条时把右边界夹到 floor(m_plotRect.X):分隔条是浮点矩形
     (457.5…462.5),GDI+ 会把最后一列按 50% 混进去;整帧重绘时绘图区随后会盖掉它,
     但跳过绘图区的帧会留下**一整列 1px 接缝**(实测 847 像素)。
   - 实测(283 信号同网格 + 双光标):动画帧 34~43ms → 1.6~2.1ms。

2. 均值滤波面板
   - **未勾选的条目栏点击完全无响应**:未勾选时点名称区域不再自动勾选、也不展开/收起,
     只能点右端复选框(点击仍被消费,不穿透)。
   - **滚动时参数滑块不再消失**:原实现按条目栏矩形判断整行可见性,条目栏滚出内容区后
     整行(含两行参数滑块)被跳过。现在按整行纵向范围(条目栏 ∪ 两行轨道/数值/复位)判定。

3. 信号列表:行集合与折叠状态自愈(用户报告:开自由标记并标记点后,点 MF4 组名,
   三角显示已展开但下属 283 条信号行一条都不出现)
   - 新增 SignalPanel::RowsSignature()(FNV 混合 分组集合/每组信号数/是否折起/是否有动画),
     BuildRows 记录它,EnsureRows 每帧 O(分组数) 校验,不一致就地重建。
     这样任何"改了折叠状态却忘记置 m_rowsDirty"的路径都不会再让行集合与折叠状态脱节。

4. 验证(全部离屏,不占鼠标/键盘/屏幕)
   uicheck 69 / lbcheck 21 / lbresid 5 / lbzorder 4 / lbcovr 7 / lbpanel 10 /
   derivecheck 16 / round15check 28 / themecheck 17 / meancheck 21 全绿;
   新增 .preflight/panelbug 14/14(外部改折叠状态→行集合自愈、动画中途改状态→位移清零、
   未勾选行点击无响应、条目栏滚出后滑块仍绘制);
   新增 .preflight/panelperf 12/12(动画帧 <16ms、且跳过绘图区后该区域像素与紧接的
   整幅重绘逐像素完全相同);
   rendercheck A–H 8 个场景哈希与基线一致;make release / make debug 零 warning
   (805,888 B)。

同步文档:Documents/agent_mem.md 追加「2026-10-30 第十八轮」,
并把第十七轮里"尚未启用 → 直接开始预览"改标注为已推翻。
This commit is contained in:
DeepSeek V4.1 Flash committed 2026-10-30 15:06:01 +08:00
1 parent 2d83b494a3
commit 8271879ede
8 files changed
+268 -27

No files matched your search

@@ -646,6 +646,28 @@ namespace jdi
*/
bool m_panelDirty = false;
bool m_mouseOverPlot = false; /**< 上一次鼠标移动是否位于绘图区内。 */
/**
* @brief 本次 `WM_PAINT` 是不是「面板动画帧」(由滚动定时器置位)。
*
* 面板动画每帧都会把**整个窗口**标脏,而 `WM_PAINT` 里"更新区域不在绘图区内 ⇒
* 置 `m_chromeDirty`"这条启发式会因此把整帧判定为脏 ⇒ 每帧都重绘标签栏/工具栏/
* **绘图区**。绘图区一帧要 12~46ms(283 个信号同网格、双光标时实测 46ms),
* 面板动画于是被拖到 43ms/帧(用户 2026-10-30 报告:组名展开/收起非常卡顿,
* 而网格里根本没有任何需要重绘的变化)。
* ✓ 定时器置位后,`WM_PAINT` 在"没有其它任何原因要求整帧脏"时**不**再置 `m_chromeDirty`。
*/
bool m_panelAnimationFrame = false;
/**
* @brief 后备缓冲区里的**绘图区像素是否仍然有效**(可跳过重绘)。
*
* 只有「面板动画帧」且该标志为真时才跳过 `PlotView::Paint`:此时面板不覆盖绘图区,
* 上一帧画好的绘图区像素原样保留在缓冲区里,回送整窗也不会错。
* 任何"绘图区需要重绘"的来源都必须把它置假(见 `InvalidatePlotRect` 的各调用点、
* `WM_PAINT` 里"更新区域就在绘图区内"的分支、以及后备缓冲区重建)。
*/
bool m_plotPaintValid = false;
};
} // namespace jdi
@@ -155,9 +155,12 @@ namespace jdi
*
* @param [IN] hwnd 窗口句柄。
* @param [IN] plotRect 绘图区矩形(客户区坐标)。
* @param [OUT] plotPaintValid `Application::m_plotPaintValid`:置假,表示后备缓冲区里的
* 绘图区像素已不可信(面板动画帧不得跳过绘图区重绘)。
*/
inline void InvalidatePlotRect(HWND hwnd, const gd::RectF& plotRect)
inline void InvalidatePlotRect(HWND hwnd, const gd::RectF& plotRect, bool& plotPaintValid)
{
plotPaintValid = false;
RECT invalid = {};
invalid.left = static_cast<LONG>(std::floor(plotRect.X));
invalid.top = static_cast<LONG>(std::floor(plotRect.Y));
+52 -8
View File
@@ -345,6 +345,8 @@ namespace jdi
// 所有控件的目标矩形都变了,装饰区域必须整体重绘一次。
m_chromeDirty = true;
// 绘图区矩形也变了 ⇒ 后备缓冲区里的绘图区像素不再是本布局的内容。
m_plotPaintValid = false;
}
void Application::EnsureBackBuffer(int width, int height)
@@ -358,6 +360,7 @@ namespace jdi
// 新建的后备位图内容未定义,必须整帧重绘一次。
m_chromeDirty = true;
m_plotPaintValid = false;
HDC screenDc = GetDC(m_hwnd);
if (screenDc == nullptr)
@@ -580,11 +583,29 @@ namespace jdi
// 只有面板像素变了 ⇒ 只重画面板与分隔条(`SignalPanel::Paint` 会铺满自己的区域)。
UiSetTextLayer(ComponentLayer(&m_panel));
m_panel.Paint(g);
UiFillRect(g, m_splitterRect, m_theme.border);
// ⚠️ 分隔条的右边界必须**夹到绘图区左边界取整之前**:分隔条矩形是浮点的
//(例如 457.5…462.5),GDI+ 会把最后一列(x=462)按 50% 覆盖率混进去;
// 整帧重绘时绘图区随后会把它整列盖掉,但**面板动画帧不重绘绘图区**,
// 这一列就会残留成一条 1px 接缝(实测 847 像素、整列)。
gd::RectF splitter = m_splitterRect;
const float splitterLimit = std::floor(m_plotRect.X);
if (splitter.X + splitter.Width > splitterLimit)
{
splitter.Width = std::max(0.0f, splitterLimit - splitter.X);
}
UiFillRect(g, splitter, m_theme.border);
}
UiSetTextLayer(ComponentLayer(&m_plot));
m_plot.Paint(g);
// ⚠️ 「面板动画帧」且绘图区像素仍然有效时**跳过绘图区重绘**:
// 面板不覆盖绘图区,上一帧的像素原样留在后备缓冲区里(随后整窗回送也不出错),
// 而绘图区一帧要 12~46ms(283 个信号同网格 + 双光标实测 46ms)——
// 这正是“组名展开/收起非常卡顿、网格里却没有任何需要重绘的变化”的真因。
if (!panelOnly || !m_plotPaintValid)
{
m_plot.Paint(g);
m_plotPaintValid = true;
}
// 弹出菜单与浮动拾色器始终绘制在最上层,保证与主界面风格一致。
// ⚠️ 调试:它们是不透明浮层,文本要记入更高的绘制层,
@@ -936,7 +957,7 @@ namespace jdi
{
case kMessagePlotDerivedReady:
// 后台派生曲线计算完成:只失效绘图区,下一帧即可取到最新曲线(双缓冲切换)。
InvalidatePlotRect(hwnd, m_plotRect);
InvalidatePlotRect(hwnd, m_plotRect, m_plotPaintValid);
return 0;
case kMessagePlotLowMemory:
// 可用内存**严重不足**(连一条预览曲线本身都装不下):绘图区已放弃本次派生计算,
@@ -944,7 +965,7 @@ namespace jdi
SetStatusMessage(L"可用内存不足,已暂停派生预览曲线与相关统计的计算。\n"
L"请关闭部分分组或信号后重试。",
true);
InvalidatePlotRect(hwnd, m_plotRect);
InvalidatePlotRect(hwnd, m_plotRect, m_plotPaintValid);
return 0;
case WM_SIZE:
{
@@ -983,6 +1004,9 @@ namespace jdi
// 其余情况(窗口首次显示、其它窗口遮挡后露出、DPI 变化等)必须整帧重绘。
RECT update = {};
const bool hasUpdate = GetUpdateRect(hwnd, &update, FALSE) != FALSE;
// 面板动画帧标记是一次性的(只对紧随其后的这一帧生效)。
const bool panelFrame = m_panelAnimationFrame;
m_panelAnimationFrame = false;
if (hasUpdate)
{
const bool insidePlot =
@@ -991,8 +1015,15 @@ namespace jdi
update.right <= static_cast<LONG>(std::ceil(m_plotRect.X + m_plotRect.Width)) &&
update.bottom <= static_cast<LONG>(std::ceil(m_plotRect.Y + m_plotRect.Height)) &&
update.right > update.left && update.bottom > update.top;
if (!insidePlot)
if (insidePlot)
{
// 本次更新的就是绘图区 ⇒ 绘图区像素不再可信,必须重绘。
m_plotPaintValid = false;
}
else if (!panelFrame || m_chromeDirty)
{
// ⚠️ `panelFrame` 只是“本次失效来自面板动画”的标志:若其它任何消息
// 已经把装饰区标脏(`m_chromeDirty`),仍按原来的规则走整帧重绘。
m_chromeDirty = true;
}
}
@@ -1043,7 +1074,7 @@ namespace jdi
if (plotOnlyMove && !m_chromeDirty)
{
// 只失效绘图区,配合 WM_PAINT 中的判断即可走局部回送快路径。
InvalidatePlotRect(hwnd, m_plotRect);
InvalidatePlotRect(hwnd, m_plotRect, m_plotPaintValid);
}
else
{
@@ -1090,7 +1121,7 @@ namespace jdi
if (plotChanged && !chromeChanged && !m_chromeDirty)
{
InvalidatePlotRect(hwnd, m_plotRect);
InvalidatePlotRect(hwnd, m_plotRect, m_plotPaintValid);
}
else if (chromeChanged || plotChanged)
{
@@ -1741,6 +1772,10 @@ namespace jdi
{
const bool panelAnimating =
m_panel.ScrollAnimationActive() || m_panel.GroupAnimationActive();
// 绘图区自己的动画(网格滚动到可视区 / 「回到最上方」淡入淡出)也会改像素:
// 它跟面板动画同时跑时,本帧**不能**跳过绘图区重绘(否则绘图区动画会冻结)。
const bool plotAnimating =
m_plot.LaneScrollAnimationActive() || m_plot.BackToTopAnimationActive();
m_panel.AdvanceScrollAnimation();
m_panel.AdvanceGroupAnimations();
m_plot.AdvanceLaneScrollAnimation();
@@ -1753,11 +1788,20 @@ namespace jdi
// ⚠️ 用**推进之前**的状态判定:动画结束那一帧仍需整窗重绘,
// 保证面板的最终位置被完整重绘并回送。
m_panelDirty = !m_chromeDirty;
// 标记本帧是「面板动画帧」:`WM_PAINT` 里不再把整窗失效当成
// “装饰+绘图区都脏”,否则每帧都会重绘绘图区(283 信号 + 双光标时
// 实测 46ms/帧,用户 2026-10-30 报告的卡顿真因)。
m_panelAnimationFrame = true;
if (plotAnimating)
{
// 绘图区本帧有变化 ⇒ 作废“绘图区像素仍然有效”的假定。
m_plotPaintValid = false;
}
InvalidateRect(hwnd, nullptr, FALSE);
}
else
{
InvalidatePlotRect(hwnd, m_plotRect);
InvalidatePlotRect(hwnd, m_plotRect, m_plotPaintValid);
}
return 0;
}
@@ -2370,13 +2370,13 @@ namespace jdi
return true;
}
}
// 条目栏(信号色叠底那一整行):**整行都能点**(用户 2026-10-30 要求,
// 与「自由标记」分组栏一致:不再只能点小小的展开三角)。
// 语义:
// 条目栏(信号色叠底那一整行):
// * 点在右端复选框上 → 切换"是否启用该信号的预览"(与复选框同一语义,
// 因为整行已被本函数消费,复选框自己收不到点击了);
// * 已在预览中 → 切换参数行的收起 / 展开;
// * 尚未启用 → 直接启用(用户点这一行显然是想看它的预览)。
// * 已启用 → 整行切换参数行的收起 / 展开;
// * **未启用 → 什么都不做**(用户 2026-10-30 明确要求:未勾选前点击条目栏
// 既不能自动勾选、也不能展开 / 收起,只能点复选框)。
// 仍然消费这次点击(不穿透到底层的平移/选区等)。
if (rowIndex < layout.rowHeaders.size() &&
layout.rowHeaders[rowIndex].Width > 0.0f &&
UiPointInRect(layout.rowHeaders[rowIndex], fx, fy))
@@ -2385,22 +2385,18 @@ namespace jdi
rowIndex < layout.rowChecks.size() &&
layout.rowChecks[rowIndex].Width > 0.0f &&
UiPointInRect(layout.rowChecks[rowIndex], fx, fy);
const Lane::MeanFilterItem* existing = meanLane.FindMeanFilterItem(ref);
Lane::MeanFilterItem* existing = const_cast<Lane::MeanFilterItem*>(
meanLane.FindMeanFilterItem(ref));
if (onCheckBox)
{
ToggleLaneMeanFilterSignal(index, ref);
m_meanRowHoverLane = -1;
m_meanRowHoverRef = SignalRef();
}
else if (existing == nullptr)
{
// 未启用:点击整行即开始预览(用户点这一行显然是想看它的预览)。
meanLane.EnsureMeanFilterItem(ref);
}
else
else if (existing != nullptr)
{
// 已启用:整行切换参数行的收起 / 展开。
meanLane.EnsureMeanFilterItem(ref).collapsed = !existing->collapsed;
existing->collapsed = !existing->collapsed;
}
m_dragMode = PlotDragMode::NONE;
return true;
+38 -3
View File
@@ -4903,10 +4903,45 @@ namespace jdi
continue;
}
const gd::RectF& header = layout.rowHeaders[rowIndex];
if (header.Y + header.Height <= layout.content.Y ||
header.Y >= layout.content.Y + layout.content.Height)
// 行的**可见性**必须按“整行的纵向范围”判定,不能只看条目栏:
// 条目栏已滚出内容区,但它下面两行参数滑块仍可能还在可视区内
//(用户 2026-10-30 报告:此时两个滑块会直接消失 —— 显示错误)。
float rowTop = header.Y;
float rowBottom = header.Y + header.Height;
const auto extendRow = [&rowBottom](const gd::RectF& rect) {
if (rect.Width > 0.0f && rect.Height > 0.0f)
{
rowBottom = std::max(rowBottom, rect.Y + rect.Height);
}
};
if (rowIndex < layout.rowLevelTracks.size())
{
continue; // 完全滚出可视范围
extendRow(layout.rowLevelTracks[rowIndex]);
}
if (rowIndex < layout.rowLevelValues.size())
{
extendRow(layout.rowLevelValues[rowIndex]);
}
if (rowIndex < layout.rowLevelResets.size())
{
extendRow(layout.rowLevelResets[rowIndex]);
}
if (rowIndex < layout.rowIterTracks.size())
{
extendRow(layout.rowIterTracks[rowIndex]);
}
if (rowIndex < layout.rowIterValues.size())
{
extendRow(layout.rowIterValues[rowIndex]);
}
if (rowIndex < layout.rowIterResets.size())
{
extendRow(layout.rowIterResets[rowIndex]);
}
if (rowBottom <= layout.content.Y ||
rowTop >= layout.content.Y + layout.content.Height)
{
continue; // 整行(含全部参数行)完全滚出可视范围
}
const std::uint32_t signalColor = StyleColor(*resolved.style);
const UiInk ink = UiContrastInk(signalColor);
@@ -749,6 +749,15 @@ namespace jdi
*/
void BuildRows();
/**
* @brief 行集合的“定义状态”签名(分组集合 / 每组可见项数 / 折起状态 / 是否有动画)。
*
* `EnsureRows` 用它做**自愈校验**:只要签名与“上一次建行集合时的签名”不一致,
* 就重建行集合。这样可以彻底堵住“改了折起状态却忘了置 `m_rowsDirty`”这类缺陷
*(用户 2026-10-30 报告:组名三角已显示展开,但下属信号行完全不出现)。
*/
std::uint64_t RowsSignature() const;
/**
* @brief 在需要时重建列表行集合。
*/
@@ -1337,6 +1346,7 @@ namespace jdi
*/
std::vector<float> m_rowTop;
bool m_rowsDirty = true; /**< 列表行集合是否需要重建。 */
std::uint64_t m_rowsSignature = 0; /**< 建行集合时的 `RowsSignature()`(用于自愈校验)。 */
float m_contentHeight = 0.0f; /**< 列表内容总高度。 */
float m_listFullWidth = 0.0f; /**< 列表区不含滚动条的完整宽度。 */
bool m_scrollBarVisible = false; /**< 是否需要显示滚动条。 */
@@ -518,6 +518,8 @@ namespace jdi
{
m_rows.clear();
m_rowsDirty = false;
// 记住“本行集合对应的定义状态”(含空数据集的早退路径),供 `EnsureRows()` 做自愈校验。
m_rowsSignature = RowsSignature();
// 行集合重建后行下标的含义可能变化(增删/折起展开)⇒ 行位图缓存整体作废。
// 这同时把"不再显示的行"的位图内存还回去(缓存条目按行数增长)。
InvalidateRowCache();
@@ -588,6 +590,37 @@ namespace jdi
UpdateAnimGeometry();
}
std::uint64_t SignalPanel::RowsSignature() const
{
// FNV-1a:混合“每个分组是否列出、列出多少项、是否折起、是否有折起动画”。
// 这四项恰好完全决定行集合的内容,因此它们不变时无需重建。
std::uint64_t hash = 1469598103934665603ull;
const auto mix = [&hash](std::uint64_t value)
{
hash = (hash ^ value) * 1099511628211ull;
};
if (m_dataSet == nullptr || m_tab == nullptr)
{
return hash;
}
const std::vector<SignalGroup>& groups = m_dataSet->Groups();
mix(static_cast<std::uint64_t>(groups.size() + 1));
// 筛选态下“每组列多少项”要逐信号比对(成本 O(信号数)),因此只在非筛选时入签名;
// 筛选文本变化本身会走 `ApplyFilterText()` 置脏,无需靠签名发现。
const bool filtering = !m_filterText.empty();
mix(filtering ? 1ull : 0ull);
for (std::size_t index = 0; index < groups.size(); ++index)
{
const int groupIndex = static_cast<int>(index);
const std::uint64_t count =
filtering ? 0u : static_cast<std::uint64_t>(groups[index].signals.size());
mix(count + 2u);
mix(IsGroupCollapsed(groupIndex) ? 3u : 0u);
mix((FindGroupAnimation(groupIndex) >= 0) ? 5u : 0u);
}
return hash;
}
void SignalPanel::EnsureRows()
{
if (m_rowsDirty)
@@ -596,6 +629,17 @@ namespace jdi
RefreshScrollBar();
return;
}
// ⚠️ **自愈不变量**:行集合必须与“分组集合 / 每组可见项数 / 折起状态 / 是否有动画”
// 严格一致。历史上确实出现过“折起状态变了但忘了置 `m_rowsDirty`”的路径 ——
// 表现就是用户 2026-10-30 报告的:组名三角已经显示“展开”,
// 但下属信号行一条都不出现(行集合里根本没有它们)。
// 这里每帧花 O(分组数) 校验一次签名,不一致就地重建,不再依赖每个调用点自觉置脏。
if (m_rowsSignature != RowsSignature())
{
BuildRows();
RefreshScrollBar();
return;
}
if (m_rowShift.size() != m_rows.size())
{
// 行集合没变但位移缓存与它不同步(例如首次构建后)—— 补齐,避免读到越界值。