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 第十八轮」,
并把第十七轮里"尚未启用 → 直接开始预览"改标注为已推翻。
348 lines
14 KiB
C++
348 lines
14 KiB
C++
#ifndef __APPLICATION_INTERNAL_HPP__
|
||
#define __APPLICATION_INTERNAL_HPP__
|
||
|
||
// Application 组件内部共享的常量与工具函数(私有头,仅本组件的 .cpp 使用)。
|
||
//
|
||
// 本组件的实现按功能拆成多个协作文件,它们共用这一份内部工具集。
|
||
//
|
||
// ⚠️ 为让多个翻译单元都能使用,原匿名命名空间改为**具名**命名空间 `appdetail`,
|
||
// 并把其中每个函数与常量标为 `inline` —— 单一定义、单一副本,不会产生代码重复。
|
||
// 各实现文件在包含本头之后用 `using namespace appdetail;` 引入这些工具,
|
||
// 因此函数体里原有的非限定调用一行都不用改。
|
||
|
||
#include <windows.h>
|
||
#include <objidl.h>
|
||
#include <gdiplus.h>
|
||
|
||
#include <algorithm>
|
||
#include <atomic>
|
||
#include <cmath>
|
||
#include <cstdint>
|
||
#include <string>
|
||
#include <thread>
|
||
#include <utility>
|
||
#include <vector>
|
||
|
||
#include <commdlg.h>
|
||
#include <psapi.h>
|
||
#include <shellapi.h>
|
||
#include <windowsx.h>
|
||
|
||
#include "Application.hpp"
|
||
#include "DataLoader.hpp"
|
||
#include "MemProbe.hpp"
|
||
#include "ResourceIds.h"
|
||
#include "Utf8Util.hpp"
|
||
|
||
namespace jdi
|
||
{
|
||
|
||
namespace appdetail
|
||
{
|
||
/**
|
||
* @brief Application 内部工具集(常量与自由函数)。
|
||
*
|
||
* 全部符号都是 `inline` ⇒ 多个翻译单元共用同一个定义:既不重复符号,也不产生多份代码。
|
||
* 不要把它们移回匿名命名空间(那样每个 .cpp 都会各自复制一份)。
|
||
*/
|
||
inline const wchar_t* kWindowClassName = L"JustDataInspectorMainWindow"; /**< 窗口类名。 */
|
||
inline const wchar_t* kWindowTitle = L"Just DataInspector"; /**< 窗口标题中的应用名称部分。 */
|
||
/**
|
||
* @brief 应用名称与内存占用之间的分隔竖线。
|
||
*
|
||
* 与信号分组标题里「组X | 组名」的分隔方式一致:竖线两侧各留出空隙
|
||
*(`kGroupSeparatorGap` 是那个分隔线的左右间距,这里用两个空格达到同等观感)。
|
||
*/
|
||
inline const wchar_t* kTitleSeparator = L" | ";
|
||
/** @brief 内存占用的显示前缀(与具体数值之间保留一个空格)。 */
|
||
inline const wchar_t* kRamTitlePrefix = L"RAM : ";
|
||
inline const float kSplitterWidth = 4.0f; /**< 分隔条宽度(逻辑像素)。 */
|
||
inline const float kMinPanelWidth = 260.0f; /**< 面板最小宽度(逻辑像素)。 */
|
||
inline const float kMaxPanelWidth = 400.0f; /**< 面板最大宽度(逻辑像素)。 */
|
||
inline const int kMinClientWidth = 860; /**< 窗口最小客户区宽度。 */
|
||
inline const int kMinClientHeight = 540; /**< 窗口最小客户区高度。 */
|
||
inline const UINT kMessageRemoveGroup = WM_APP + 1; /**< 面板请求移除分组的自定义消息。 */
|
||
inline const UINT kMessageRemoveSignal = WM_APP + 2; /**< 面板请求移除单个信号的自定义消息。 */
|
||
inline const UINT kMessageCloneSignal = WM_APP + 5; /**< 面板请求克隆单个信号的自定义消息。 */
|
||
inline const UINT_PTR kHintTimerId = 1; /**< 提示浮层淡出用的定时器 ID。 */
|
||
inline const UINT kHintTimerPeriodMs = 33; /**< 提示浮层淡出的重绘间隔(约 30fps)。 */
|
||
inline const UINT_PTR kRamTimerId = 2; /**< 标题栏内存占用刷新的定时器 ID。 */
|
||
inline const UINT kRamTimerPeriodMs = 1000; /**< 标题栏内存占用的刷新间隔(1 秒)。 */
|
||
/**
|
||
* @brief 信号列表「滚动到目标信号」动画的定时器 ID。
|
||
*
|
||
* 用户 2026-10-15 要求:联动时列表要**平滑滚动**(不是立刻翻到目标位置),
|
||
* 且"整个动画过程不要太久"。动画本身由 `SignalPanel::AdvanceScrollAnimation()`
|
||
* 按经过时间推进,这里只负责按帧驱动它(约 60fps)。
|
||
*/
|
||
inline const UINT_PTR kScrollTimerId = 3;
|
||
/**
|
||
* @brief 信号 / 绘图区动画的定时器周期(毫秒)。
|
||
*
|
||
* ⚠️⚠️ **必须是「不超过一个系统 tick」的值**:`SetTimer` 的间隔会被量化到系统 tick
|
||
*(默认 15.6ms)的整数倍上,因此请求 16ms 实际会等到**下一个** tick ⇒ 实测帧距
|
||
* **≈ 26ms(只有约 38fps)**,动画明显卡顿(用户 2026-10-17 报告:所有过渡动画都不平顺)。
|
||
* 请求 10ms 时量化结果正好是一个 tick ⇒ 实测帧距 **≈ 15.5ms(约 64fps)**,
|
||
* 回到“约 60fps 平滑过渡”的设计目标。
|
||
*/
|
||
inline const UINT kScrollTimerPeriodMs = 10;
|
||
|
||
/**
|
||
* @brief 查询本进程当前占用的内存(**私有工作集**,与任务管理器"内存"列同口径)。
|
||
*
|
||
* ⚠️ 原先取的是 `PROCESS_MEMORY_COUNTERS::WorkingSetSize`(**总工作集**),它把映射进来的
|
||
* DLL / 共享页面以及已经释放但尚未被系统回收的驻留页面都算进去 —— 加载一个大文件再
|
||
* 关闭分组后,实际占用的私有内存已经回到几 MB,标题栏却还显示几十 MB,
|
||
* 与任务管理器(资源管理器里看到的"内存")对不上(用户报告)。
|
||
* `PROCESS_MEMORY_COUNTERS_EX2::PrivateWorkingSetSize` 正是任务管理器"内存"列的口径:
|
||
* 只统计**属于本进程的私有、且驻留在物理内存中**的页面。
|
||
*
|
||
* 结构体在这里就地声明(MinGW 的 psapi.h 未提供 `PROCESS_MEMORY_COUNTERS_EX2`),
|
||
* 布局与 Win32 文档一致;EX2 查询失败(老系统)时退回 `PrivateUsage`(私有提交),
|
||
* 再不行退回 `WorkingSetSize`,保证标题栏永远有值。
|
||
*
|
||
* @return 内存占用字节数;查询失败返回 0。
|
||
*/
|
||
inline unsigned long long ProcessPrivateWorkingSetBytes()
|
||
{
|
||
struct MemoryCountersEx2
|
||
{
|
||
DWORD cb;
|
||
DWORD PageFaultCount;
|
||
SIZE_T PeakWorkingSetSize;
|
||
SIZE_T WorkingSetSize;
|
||
SIZE_T QuotaPeakPagedPoolUsage;
|
||
SIZE_T QuotaPagedPoolUsage;
|
||
SIZE_T QuotaPeakNonPagedPoolUsage;
|
||
SIZE_T QuotaNonPagedPoolUsage;
|
||
SIZE_T PagefileUsage;
|
||
SIZE_T PeakPagefileUsage;
|
||
SIZE_T PrivateUsage;
|
||
SIZE_T PrivateWorkingSetSize;
|
||
SIZE_T SharedCommitUsage;
|
||
};
|
||
|
||
MemoryCountersEx2 ex2 = {};
|
||
ex2.cb = sizeof(ex2);
|
||
if (K32GetProcessMemoryInfo(GetCurrentProcess(),
|
||
reinterpret_cast<PROCESS_MEMORY_COUNTERS*>(&ex2),
|
||
sizeof(ex2)) != FALSE &&
|
||
ex2.PrivateWorkingSetSize > 0)
|
||
{
|
||
return static_cast<unsigned long long>(ex2.PrivateWorkingSetSize);
|
||
}
|
||
|
||
PROCESS_MEMORY_COUNTERS_EX counters = {};
|
||
counters.cb = sizeof(counters);
|
||
if (K32GetProcessMemoryInfo(GetCurrentProcess(),
|
||
reinterpret_cast<PROCESS_MEMORY_COUNTERS*>(&counters),
|
||
sizeof(counters)) == FALSE)
|
||
{
|
||
return 0ull;
|
||
}
|
||
if (counters.PrivateUsage > 0)
|
||
{
|
||
return static_cast<unsigned long long>(counters.PrivateUsage);
|
||
}
|
||
return static_cast<unsigned long long>(counters.WorkingSetSize);
|
||
}
|
||
|
||
/**
|
||
* @brief 只失效绘图区所在的矩形。
|
||
*
|
||
* 配合 WM_PAINT 中对更新区域的判断,可以走“只重绘、只回送绘图区”的快路径;
|
||
* 绘图区矩形无效时退化为整窗失效,保证不会漏画。
|
||
*
|
||
* @param [IN] hwnd 窗口句柄。
|
||
* @param [IN] plotRect 绘图区矩形(客户区坐标)。
|
||
* @param [OUT] plotPaintValid `Application::m_plotPaintValid`:置假,表示后备缓冲区里的
|
||
* 绘图区像素已不可信(面板动画帧不得跳过绘图区重绘)。
|
||
*/
|
||
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));
|
||
invalid.right = static_cast<LONG>(std::ceil(plotRect.X + plotRect.Width));
|
||
invalid.bottom = static_cast<LONG>(std::ceil(plotRect.Y + plotRect.Height));
|
||
if (invalid.right > invalid.left && invalid.bottom > invalid.top)
|
||
{
|
||
InvalidateRect(hwnd, &invalid, FALSE);
|
||
}
|
||
else
|
||
{
|
||
InvalidateRect(hwnd, nullptr, FALSE);
|
||
}
|
||
}
|
||
|
||
/** @brief 标签页右键菜单命令 ID。 */
|
||
inline const int kMenuTabNew = 1;
|
||
inline const int kMenuTabClose = 2;
|
||
inline const int kMenuTabCloseOthers = 3;
|
||
|
||
/** @brief 应用设置菜单(工具栏齿轮)命令 ID。 */
|
||
inline const int kAppCmdShowLayoutBounds = 1;
|
||
/**
|
||
* @brief 「主题配色」二级菜单的命令 ID 基准。
|
||
*
|
||
* 第 i 个主题的 ID = `kAppCmdThemeBase + i`,`i` 即 `ThemeKind` 的整型值
|
||
* (顺序就是菜单里的显示顺序:Light / Solarised Light / Dark / Solarised Dark)。
|
||
*/
|
||
inline const int kAppCmdThemeBase = 100;
|
||
|
||
/**
|
||
* @brief 从应用资源中按指定尺寸加载图标。
|
||
* @param [IN] cx 期望宽度(像素)。
|
||
* @param [IN] cy 期望高度(像素)。
|
||
* @return 图标句柄,加载失败返回 nullptr。
|
||
*/
|
||
inline HICON LoadAppIcon(int cx, int cy)
|
||
{
|
||
HINSTANCE module = GetModuleHandleW(nullptr);
|
||
// LR_DEFAULTSIZE 之外的显式尺寸请求会挑选 ICO 中最接近的一帧并由系统缩放,
|
||
// 因此 256/64/48/32/16 的多尺寸资源在任意 DPI 下都能取到合适的一帧。
|
||
HICON icon = static_cast<HICON>(LoadImageW(module, MAKEINTRESOURCEW(IDI_APP_ICON), IMAGE_ICON,
|
||
cx, cy, LR_DEFAULTCOLOR));
|
||
if (icon == nullptr)
|
||
{
|
||
icon = static_cast<HICON>(LoadImageW(module, MAKEINTRESOURCEW(IDI_APP_ICON), IMAGE_ICON, 0, 0,
|
||
LR_DEFAULTCOLOR | LR_DEFAULTSIZE));
|
||
}
|
||
return icon;
|
||
}
|
||
|
||
/**
|
||
* @brief 后台加载任务的共享状态。
|
||
*
|
||
* `done` 是唯一的同步点:工作线程写完其它字段后以 release 语义置位,
|
||
* 主线程以 acquire 语义读取,因此无需额外锁。
|
||
*/
|
||
struct AsyncLoadState
|
||
{
|
||
std::atomic<bool> done{false}; /**< 工作线程是否已结束。 */
|
||
std::atomic<bool> cancelled{false}; /**< 是否请求取消。 */
|
||
std::atomic<float> progress{0.0f}; /**< 0-1 进度。 */
|
||
std::atomic<unsigned long long> bytesRead{0}; /**< 已读取字节数(解析阶段)。 */
|
||
std::atomic<unsigned long long> totalBytes{0}; /**< 文件总字节数(0 表示未知)。 */
|
||
bool ok = false; /**< 加载是否成功。 */
|
||
std::wstring error; /**< 失败原因。 */
|
||
std::vector<SignalGroup> groups; /**< 构建结果(多分组格式可有多个)。 */
|
||
};
|
||
|
||
/**
|
||
* @brief 工作线程主体:按扩展名选择加载器读入文件并构建信号分组(不接触 DataSet)。
|
||
* @param [IN,OUT] state 共享状态。
|
||
* @param [IN] path 文件路径。
|
||
* @param [IN] paletteBase 调色板起始序号。
|
||
*/
|
||
inline void RunAsyncLoad(AsyncLoadState* state, std::wstring path, std::size_t paletteBase)
|
||
{
|
||
// 解析阶段占总进度的 0-75%,列重组与统计占 75-100%。
|
||
LoadProgress parseProgress = [state](const LoadProgressInfo& info)
|
||
{
|
||
state->progress.store(static_cast<float>(info.ratio * 0.75), std::memory_order_relaxed);
|
||
if (info.hasBytes)
|
||
{
|
||
state->bytesRead.store(info.bytesRead, std::memory_order_relaxed);
|
||
state->totalBytes.store(info.totalBytes, std::memory_order_relaxed);
|
||
}
|
||
return !state->cancelled.load(std::memory_order_relaxed);
|
||
};
|
||
|
||
std::vector<LoadedSourceGroup> sources;
|
||
std::wstring error;
|
||
if (!LoadSignalSourceGroups(path, sources, error, parseProgress))
|
||
{
|
||
state->error = error;
|
||
state->done.store(true, std::memory_order_release);
|
||
return;
|
||
}
|
||
|
||
const std::size_t count = std::max<std::size_t>(sources.size(), 1);
|
||
for (std::size_t index = 0; index < sources.size(); ++index)
|
||
{
|
||
LoadProgress buildProgress = [state, index, count](const LoadProgressInfo& info)
|
||
{
|
||
const double base = 0.75 + 0.25 * (static_cast<double>(index) / static_cast<double>(count));
|
||
state->progress.store(
|
||
static_cast<float>(base + 0.25 * info.ratio / static_cast<double>(count)),
|
||
std::memory_order_relaxed);
|
||
return !state->cancelled.load(std::memory_order_relaxed);
|
||
};
|
||
SignalGroup group;
|
||
if (!BuildSignalGroup(std::move(sources[index].source), paletteBase, group, error,
|
||
buildProgress))
|
||
{
|
||
state->error = error;
|
||
state->done.store(true, std::memory_order_release);
|
||
return;
|
||
}
|
||
// 分组名:`.mat` 的嵌套路径由加载器给出(如 `A-A1`);其它格式为空 ⇒ 用文件名。
|
||
if (!sources[index].name.empty())
|
||
{
|
||
group.name = sources[index].name;
|
||
}
|
||
state->groups.push_back(std::move(group));
|
||
}
|
||
|
||
state->ok = true;
|
||
state->progress.store(1.0f, std::memory_order_relaxed);
|
||
state->done.store(true, std::memory_order_release);
|
||
}
|
||
|
||
/**
|
||
* @brief 开启进程级 DPI 感知(优先 Per-Monitor V2)。
|
||
*/
|
||
inline void EnableDpiAwareness()
|
||
{
|
||
HMODULE user32 = GetModuleHandleW(L"user32.dll");
|
||
if (user32 != nullptr)
|
||
{
|
||
typedef BOOL(WINAPI * SetProcessDpiAwarenessContextFn)(HANDLE);
|
||
SetProcessDpiAwarenessContextFn setContext =
|
||
reinterpret_cast<SetProcessDpiAwarenessContextFn>(
|
||
reinterpret_cast<void*>(GetProcAddress(user32, "SetProcessDpiAwarenessContext")));
|
||
if (setContext != nullptr && setContext(DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2) != FALSE)
|
||
{
|
||
return;
|
||
}
|
||
}
|
||
SetProcessDPIAware();
|
||
}
|
||
|
||
/**
|
||
* @brief 查询屏幕 DPI。
|
||
* @return DPI 数值,失败时返回 96。
|
||
*/
|
||
inline UINT QuerySystemDpi()
|
||
{
|
||
HDC hdc = GetDC(nullptr);
|
||
if (hdc == nullptr)
|
||
{
|
||
return 96u;
|
||
}
|
||
const int dpi = GetDeviceCaps(hdc, LOGPIXELSX);
|
||
ReleaseDC(nullptr, hdc);
|
||
return (dpi > 0) ? static_cast<UINT>(dpi) : 96u;
|
||
}
|
||
|
||
/**
|
||
* @brief 依据 DPI 缩放逻辑像素。
|
||
* @param [IN] value 逻辑像素值。
|
||
* @param [IN] scale 缩放比例。
|
||
* @return 物理像素值。
|
||
*/
|
||
inline float ScaleOf(float value, float scale)
|
||
{
|
||
return value * scale;
|
||
}
|
||
} // namespace appdetail
|
||
|
||
using namespace appdetail;
|
||
|
||
} // namespace jdi
|
||
|
||
#endif // __APPLICATION_INTERNAL_HPP__
|
||
|