Files
Just-DataInspector/Components/Application/Inc/ApplicationInternal.hpp
T
DeepSeek V4.1 Flash 9fdbf49841 fix(resample,formula,clone): 重采样面板拖动/关闭失效 + 统计频率与输入框不一致 + 派生烘焙接入公式映射 + 克隆拆分
一、「重采样」浮动面板拖不动 / ✕ 关不掉(两个独立缺陷)
1. 拖不动:OnLButtonDown 的 resampleMode 分支处理完条目与按钮后无条件 return true,
   而「标题栏 -> EDGE_PANEL_MOVE」的判定在面板分支最末尾 => 标题栏按下被吞掉。
   改为只消费 insideContent 之内的点击。
2. 拖不动(第二处):OnMouseMove 的 EDGE_PANEL_MOVE -> switch (m_edgePanelDragKind)
   漏了 case RESAMPLE => 位置写进 edgePanel、又从 resamplePanel 读 => 纹丝不动。
3. ✕ 关不掉:OnLButtonUp 的 m_pendingPanelClose -> switch (kind) 漏了 RESAMPLE
   => 落进 default 关掉的是「边沿计数」。补 SetLaneResample(lane,false)。

二、统计「最小数据间隔」与「重采样频率」输入框不一致(用户报告)
   实例 MF4-Record.MF4 / m_MOT_Current_DCom[0]:最小间隔 0.003696631s
   - 统计行先舍入到 3 位有效数字(3.70ms)再换算 => 270.27 Hz;
   - 输入框用未舍入原值换算 => 270.517 Hz。
   真值 270.5166 Hz,两条路径基准不同。修法:频率一律由 RoundIntervalForDisplay
   之后的同一份显示值换算 => 输入框与统计行同为 270.27 Hz、间隔文本同为 3ms 700us。
   未改动面板行数与任何 UI 布局。

三、派生烘焙接入「公式映射」
   BuildDerivedSignal 与 CloneSignal 各写了一份
   gain!=1 || absolute || mirrorVertical || offsetY!=0,漏掉 HasFormula()
   => 带公式的信号派生/克隆后公式被静默丢弃。改为含 HasFormula() 的统一判定。
   BuildResampledSignal 本就走 ApplyDrawTransform 全程烘值,天然包含公式。

四、信号右键菜单:克隆拆成两个语义(均不烘焙采样值)
   - 「克隆该信号」:原始数据 + 外观 + 仅保留时间轴偏移,其余调整复位;
   - 「克隆该信号(带调整)」:原始数据 + 外观 + 整套调整参数。
   新增 Application::CloneSignalAdjusted、CMD_SIGNAL_CLONE_ADJUSTED=6、
   kMessageCloneSignalAdjusted=WM_APP+6。

五、教训:离屏探针崩溃会经 MemProbe::OnTerminate 弹模态「内存不足」框,
   反复重跑即反复弹窗(用户看到十几次报警,真实进程并无内存问题)。
   探针应避免 Initialize+Render,或只做数据层验证;必须无缓冲输出阶段标记。

验证:.preflight/r26 三个探针(真实 MF4 数据一致性 SAME;逻辑 7/7;端到端 12/12);
      make release 零 warning。
2026-11-03 11:09:25 +08:00

356 lines
14 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#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; /**< 面板请求克隆单个信号的自定义消息。 */
/**
* @brief 面板请求「克隆该信号(带调整)」的自定义消息。
*
* `wParam = groupIndex`、`lParam = signalIndex`(与 `kMessageCloneSignal` 同一参数约定)。
* 新信号**沿用源信号的全部调整参数**(公式映射 / 系数 / 偏移 / 镜像 / 绝对值),
* 不烘值 ⇒ 两条曲线逐像素重合,且新信号的「调整」面板与源信号完全一致。
*/
inline const UINT kMessageCloneSignalAdjusted = WM_APP + 6; /**< 面板请求「克隆该信号(带调整)」的自定义消息。 */
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__