一、派生曲线内存治理(按上一轮报告的方案实施;绘制性能优先,同时为小内存设备留出空间) 1. PeakExtremum 字段重排(double value + uint32 index + int8 type):24 → 16 字节。 编码器方波这类极值密集信号省约 1/3 的极值表内存,且**不改变任何数值结果** (value 保持 double,改成 float 会让结果与历史版本不一致): 实测 3M 采样单次计算的工作缓冲峰值 33.23 → 26.73 MiB。 2. ComputeDerivedWindow 对「整段窗口」不再复制采样(直接引用原向量): 8 B/采样 × 2580 万 ≈ 206 MB/条,而“预览曲线一律按整条信号计算”正是最常见情形。 3. 大窗口任务的并发上限由内存压力决定(CurveJob::hugeLimit:内存充足 2、紧张 1): 单条整段曲线工作缓冲约 620 MB(2580 万采样),原先最多 4 条并发 → 峰值 2.5GB+; 实测 4 并发 3M 采样 412 → 324 MiB,且外推值不再随线程数放大。 4. 内存严重不足时**直接拒绝计算**(DerivedMemoryAllows:可用内存 < 输出曲线本体 + 工作缓冲的 最小需求)并只告警一次(新增 kMessagePlotLowMemory;宿主弹一次明确的提示框)。 界面保持可用,绝不会因内存不足抛 bad_alloc 逸出线程导致进程终止。 5. 低内存模式“少量多次”:throttle = true 时并发降为 1、且每阶之间 Sleep(1),慢慢算完。 内存充足时不做任何节流,尽可能快。 6. 取消标志贯通到算法内部(ComputeWeakPeakAdjust / ComputeMovingAverageIterated 新增 cancel 参数,每阶 / 每 1M 采样检查一次)⇒ **过时任务立即退出并归还工作缓冲**; PauseDerivedAsync / StopCurveWorker 先 m_curveCancelAll = true 再 join。 7. AvailablePhysicalBytes() 结果按 200ms 缓存(绘制路径上不再每帧多次系统调用)。 8. 极值表按 count/8 预留容量(try/catch(bad_alloc) 保护),避免 push_back 反复扩容的 1.5x 峰值。 9. MeanFilterCurveFor / WeakPeakCurveFor(整条同步计算)现在只有测试使用; 产品代码一律走 CurveSpanFor(窗口化 + 异步)。 二、边沿统计:统一「逐块累积 + 每 tick 预算」状态机(普通边沿计数与中心轴共用) 1. EdgeScanState + EdgeScanFor 成为唯一实现,EdgeCountsFor / EdgeCenterCountsFor 是薄包装; 删除 EdgeCenterCountEntry / m_edgeCenterCountCache。 2. **未完成时主动请求下一帧**(RequestEdgeScanFrame → PostMessage(kMessagePlotDerivedReady)): 修掉“鼠标不动就一直显示统计中,只有把鼠标移到面板上才更新”(根因就是没有重绘 → 预算不推进)。 3. 阈值/参数变化就地把状态重置(不新建条目,避免拖动时状态爆炸);重置当 tick 不展示部分值 (修掉“除了最后一个信号显示了一个错误的且不更新的数字,其它变成统计中”); 稳定后展示递增的部分值 + “…”;扫完展示精确值(统一走 EdgeCountsText)。 4. 中心轴判定所需曲线**只在后台计算**:只在 AcquireCoveringCurve 命中(键完全相同)时扫块, 否则 ScheduleCurveJob 后本帧不推进。原先该调用没传 allowAsyncCompute(默认 false)⇒ 首帧 / 参数变化后同步算整条均值滤波曲线 ⇒ 界面停顿数秒(用户报告“一改参数整个程序卡住很久”)。 5. MeanFilterValueAt(光标读数)不再调整条同步的 MeanFilterCurveFor,改为窗口化 + 异步。 6. ScheduleCurveJob 改为幂等(m_curveRunningKeys 集合 + 队列去重):统计侧会每帧请求同一份 整条曲线,若每次都当作新请求,正在算的那份立刻被判过时 → 请求→作废→重请求 死循环。 7. 扫描热路径加“无变换/无镜像/非中心轴”的紧凑循环:单次预算耗时 18ms → 1.3ms。 8. 实测(合成方波):6M 采样首帧 1.4ms(部分值)、单次最坏 2.1ms; 拖动横向光标 10 次查询 1.07ms;**同一帧内 5 条信号合计 1.3ms**(预算跨信号共享); 保持不动无需移动鼠标即收敛到与参考实现逐位一致。 三、离屏测试绝不打扰用户的前台工作 - .preflight/uitest 与 .preflight/probe 的 main 开头设置 SetPriorityClass(GetCurrentProcess(), BELOW_NORMAL_PRIORITY_CLASS) + SetThreadPriority(GetCurrentThread(), THREAD_PRIORITY_BELOW_NORMAL), 再长的测试也不会与用户的编辑/浏览抢 CPU(此前“打字不顺畅”的根因)。 - 继续保持:窗口 SW_HIDE、只注入合成消息(不动真实鼠标键盘)、每次注入后立即 ReleaseCapture (SetCapture 是系统级的,不释放会把用户的鼠标输入吸进测试进程)。 四、验证(全部离屏) - .preflight/uitest 新增/强化:预算推进首帧不显示部分值 → 第二帧显示递增部分值 → 保持不动收敛到精确值;未完成时会 PostMessage 请求下一帧(直接断言); 拖动阈值期间 complete == false、不显示部分数字、总耗时 < 60ms; 5 条信号同帧合计 < 120ms(实测 1.3ms);中心轴与参考实现逐位一致且无需鼠标移动即可完成。 - 合计 751/751 通过(连跑 3 次稳定);make release 零警告。 规则同步:Documents/agent_mem.md 新增 §9.23(含内存治理表与实测数据)、§11.2 新增第 32–38 条硬约束、 变更日志补一行。
3017 lines
93 KiB
C++
3017 lines
93 KiB
C++
#include "Application.hpp"
|
||
|
||
#include <algorithm>
|
||
#include <atomic>
|
||
#include <cmath>
|
||
#include <thread>
|
||
|
||
#include <commdlg.h>
|
||
#include <psapi.h>
|
||
#include <shellapi.h>
|
||
#include <windowsx.h>
|
||
|
||
#include "CsvTable.hpp"
|
||
#include "ResourceIds.h"
|
||
#include "Utf8Util.hpp"
|
||
|
||
#ifndef WM_DPICHANGED
|
||
#define WM_DPICHANGED 0x02E0
|
||
#endif
|
||
|
||
#ifndef DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2
|
||
#define DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2 (reinterpret_cast<HANDLE>(-4))
|
||
#endif
|
||
|
||
namespace jdi
|
||
{
|
||
|
||
namespace
|
||
{
|
||
const wchar_t* kWindowClassName = L"JustDataInspectorMainWindow"; /**< 窗口类名。 */
|
||
const wchar_t* kWindowTitle = L"Just DataInspector"; /**< 窗口标题中的应用名称部分。 */
|
||
/**
|
||
* @brief 应用名称与内存占用之间的分隔竖线。
|
||
*
|
||
* 与信号分组标题里「组X | 组名」的分隔方式一致:竖线两侧各留出空隙
|
||
*(`kGroupSeparatorGap` 是那个分隔线的左右间距,这里用两个空格达到同等观感)。
|
||
*/
|
||
const wchar_t* kTitleSeparator = L" | ";
|
||
/** @brief 内存占用的显示前缀(与具体数值之间保留一个空格)。 */
|
||
const wchar_t* kRamTitlePrefix = L"RAM : ";
|
||
const float kSplitterWidth = 4.0f; /**< 分隔条宽度(逻辑像素)。 */
|
||
const float kMinPanelWidth = 260.0f; /**< 面板最小宽度(逻辑像素)。 */
|
||
const float kMaxPanelWidth = 400.0f; /**< 面板最大宽度(逻辑像素)。 */
|
||
const int kMinClientWidth = 860; /**< 窗口最小客户区宽度。 */
|
||
const int kMinClientHeight = 540; /**< 窗口最小客户区高度。 */
|
||
const UINT kMessageRemoveFile = WM_APP + 1; /**< 面板请求移除文件的自定义消息。 */
|
||
const UINT kMessageRemoveSignal = WM_APP + 2; /**< 面板请求移除单个信号的自定义消息。 */
|
||
const UINT_PTR kHintTimerId = 1; /**< 提示浮层淡出用的定时器 ID。 */
|
||
const UINT kHintTimerPeriodMs = 33; /**< 提示浮层淡出的重绘间隔(约 30fps)。 */
|
||
const UINT_PTR kRamTimerId = 2; /**< 标题栏内存占用刷新的定时器 ID。 */
|
||
const UINT kRamTimerPeriodMs = 1000; /**< 标题栏内存占用的刷新间隔(1 秒)。 */
|
||
|
||
/**
|
||
* @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。
|
||
*/
|
||
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 绘图区矩形(客户区坐标)。
|
||
*/
|
||
void InvalidatePlotRect(HWND hwnd, const gd::RectF& plotRect)
|
||
{
|
||
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。 */
|
||
const int kMenuTabNew = 1;
|
||
const int kMenuTabClose = 2;
|
||
const int kMenuTabCloseOthers = 3;
|
||
|
||
/**
|
||
* @brief 从应用资源中按指定尺寸加载图标。
|
||
* @param [IN] cx 期望宽度(像素)。
|
||
* @param [IN] cy 期望高度(像素)。
|
||
* @return 图标句柄,加载失败返回 nullptr。
|
||
*/
|
||
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; /**< 失败原因。 */
|
||
DataFile file; /**< 构建结果。 */
|
||
};
|
||
|
||
/**
|
||
* @brief 工作线程主体:解析 CSV 并构建数据文件(不接触 DataSet)。
|
||
* @param [IN,OUT] state 共享状态。
|
||
* @param [IN] path 文件路径。
|
||
* @param [IN] paletteBase 调色板起始序号。
|
||
*/
|
||
void RunAsyncLoad(AsyncLoadState* state, std::wstring path, std::size_t paletteBase)
|
||
{
|
||
// 解析阶段占总进度的 0-75%,列重组与统计占 75-100%。
|
||
CsvProgress parseProgress = [state](const CsvProgressInfo& 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);
|
||
};
|
||
|
||
CsvTable table;
|
||
std::wstring error;
|
||
if (!LoadCsvTable(path, table, error, parseProgress))
|
||
{
|
||
state->error = error;
|
||
state->done.store(true, std::memory_order_release);
|
||
return;
|
||
}
|
||
|
||
CsvProgress buildProgress = [state](const CsvProgressInfo& info)
|
||
{
|
||
state->progress.store(static_cast<float>(0.75 + info.ratio * 0.25),
|
||
std::memory_order_relaxed);
|
||
return !state->cancelled.load(std::memory_order_relaxed);
|
||
};
|
||
|
||
DataFile file;
|
||
if (!BuildDataFile(std::move(table), path, paletteBase, file, error, buildProgress))
|
||
{
|
||
state->error = error;
|
||
state->done.store(true, std::memory_order_release);
|
||
return;
|
||
}
|
||
|
||
state->file = std::move(file);
|
||
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)。
|
||
*/
|
||
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。
|
||
*/
|
||
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 物理像素值。
|
||
*/
|
||
float ScaleOf(float value, float scale)
|
||
{
|
||
return value * scale;
|
||
}
|
||
}
|
||
|
||
Application& Application::Instance()
|
||
{
|
||
static Application instance;
|
||
return instance;
|
||
}
|
||
|
||
LRESULT CALLBACK Application::WindowProc(HWND hwnd, UINT message, WPARAM wParam, LPARAM lParam)
|
||
{
|
||
Application* self = reinterpret_cast<Application*>(GetWindowLongPtrW(hwnd, GWLP_USERDATA));
|
||
if (message == WM_NCCREATE)
|
||
{
|
||
CREATESTRUCTW* createStruct = reinterpret_cast<CREATESTRUCTW*>(lParam);
|
||
self = reinterpret_cast<Application*>(createStruct->lpCreateParams);
|
||
SetWindowLongPtrW(hwnd, GWLP_USERDATA, reinterpret_cast<LONG_PTR>(self));
|
||
}
|
||
|
||
if (self != nullptr)
|
||
{
|
||
return self->HandleMessage(hwnd, message, wParam, lParam);
|
||
}
|
||
return DefWindowProcW(hwnd, message, wParam, lParam);
|
||
}
|
||
|
||
int Application::Run(HINSTANCE instance, int showCommand)
|
||
{
|
||
if (!Initialize(instance, showCommand))
|
||
{
|
||
return 1;
|
||
}
|
||
|
||
MSG message = {};
|
||
while (GetMessageW(&message, nullptr, 0, 0) > 0)
|
||
{
|
||
TranslateMessage(&message);
|
||
DispatchMessageW(&message);
|
||
}
|
||
|
||
Shutdown();
|
||
return static_cast<int>(message.wParam);
|
||
}
|
||
|
||
bool Application::Initialize(HINSTANCE instance, int showCommand)
|
||
{
|
||
m_instance = instance;
|
||
|
||
EnableDpiAwareness();
|
||
m_dpi = QuerySystemDpi();
|
||
m_scale = static_cast<float>(m_dpi) / 96.0f;
|
||
|
||
gd::GdiplusStartupInput startupInput;
|
||
if (gd::GdiplusStartup(&m_gdiplusToken, &startupInput, nullptr) != gd::Ok)
|
||
{
|
||
MessageBoxW(nullptr, L"GDI+ 初始化失败,程序无法启动。", kWindowTitle, MB_ICONERROR | MB_OK);
|
||
return false;
|
||
}
|
||
m_gdiplusReady = true;
|
||
|
||
m_theme = SolarizedLight();
|
||
if (!m_fonts.Create(m_dpi))
|
||
{
|
||
m_gdiplusReady = false;
|
||
gd::GdiplusShutdown(m_gdiplusToken);
|
||
MessageBoxW(nullptr, L"界面字体创建失败,程序无法启动。", kWindowTitle, MB_ICONERROR | MB_OK);
|
||
return false;
|
||
}
|
||
|
||
WNDCLASSEXW windowClass = {};
|
||
windowClass.cbSize = sizeof(windowClass);
|
||
windowClass.style = CS_HREDRAW | CS_VREDRAW | CS_DBLCLKS;
|
||
windowClass.lpfnWndProc = &Application::WindowProc;
|
||
windowClass.hInstance = instance;
|
||
windowClass.hCursor = LoadCursorW(nullptr, IDC_ARROW);
|
||
windowClass.hbrBackground = nullptr;
|
||
windowClass.lpszClassName = kWindowClassName;
|
||
// 使用应用自己的图标资源(IDI_APP_ICON);
|
||
// 原先取 IDI_APPLICATION 是系统默认图标,导致标题栏与任务栏缩略图显示成"通用应用"图标。
|
||
windowClass.hIcon = LoadAppIcon(GetSystemMetrics(SM_CXICON), GetSystemMetrics(SM_CYICON));
|
||
windowClass.hIconSm = LoadAppIcon(GetSystemMetrics(SM_CXSMICON), GetSystemMetrics(SM_CYSMICON));
|
||
|
||
if (RegisterClassExW(&windowClass) == 0)
|
||
{
|
||
MessageBoxW(nullptr, L"窗口类注册失败。", kWindowTitle, MB_ICONERROR | MB_OK);
|
||
return false;
|
||
}
|
||
|
||
// 依据工作区(排除任务栏)计算默认窗口尺寸并居中,避免窗口超出屏幕。
|
||
RECT workArea = {0, 0, static_cast<LONG>(ScaleOf(1380.0f, m_scale)),
|
||
static_cast<LONG>(ScaleOf(880.0f, m_scale))};
|
||
SystemParametersInfoW(SPI_GETWORKAREA, 0, &workArea, 0);
|
||
|
||
const int workWidth = workArea.right - workArea.left;
|
||
const int workHeight = workArea.bottom - workArea.top;
|
||
const int windowWidth = std::min(static_cast<int>(ScaleOf(1380.0f, m_scale)), workWidth - 24);
|
||
const int windowHeight = std::min(static_cast<int>(ScaleOf(880.0f, m_scale)), workHeight - 24);
|
||
const int windowX = workArea.left + std::max(0, (workWidth - windowWidth) / 2);
|
||
const int windowY = workArea.top + std::max(0, (workHeight - windowHeight) / 2);
|
||
|
||
m_hwnd = CreateWindowExW(WS_EX_ACCEPTFILES, kWindowClassName, kWindowTitle, WS_OVERLAPPEDWINDOW,
|
||
windowX, windowY, windowWidth, windowHeight, nullptr, nullptr, instance,
|
||
this);
|
||
if (m_hwnd == nullptr)
|
||
{
|
||
MessageBoxW(nullptr, L"窗口创建失败。", kWindowTitle, MB_ICONERROR | MB_OK);
|
||
return false;
|
||
}
|
||
|
||
m_tabBar.Setup(&m_fonts, &m_theme);
|
||
m_tabBar.SetScale(m_scale);
|
||
m_tabBar.SetHostWindow(m_hwnd);
|
||
m_toolbar.Setup(&m_fonts, &m_theme);
|
||
m_toolbar.SetScale(m_scale);
|
||
m_menu.Setup(&m_fonts, &m_theme);
|
||
m_menu.SetScale(m_scale);
|
||
m_colorPicker.Setup(&m_fonts, &m_theme);
|
||
m_colorPicker.SetScale(m_scale);
|
||
m_colorPicker.SetOwnerWindow(m_hwnd);
|
||
m_panel.Setup(m_hwnd, &m_fonts, &m_theme, &m_menu, &m_colorPicker);
|
||
m_panel.SetScale(m_scale);
|
||
m_plot.Setup(&m_fonts, &m_theme);
|
||
m_plot.SetScale(m_scale);
|
||
m_plot.SetHostWindow(m_hwnd);
|
||
m_menu.SetHostWindow(m_hwnd);
|
||
|
||
// 标题栏小图标与任务栏/Alt+Tab 缩略图大图标分开设置(同一个多尺寸 ICO 资源的不同尺寸)。
|
||
HICON bigIcon = LoadAppIcon(GetSystemMetrics(SM_CXICON), GetSystemMetrics(SM_CYICON));
|
||
HICON smallIcon = LoadAppIcon(GetSystemMetrics(SM_CXSMICON), GetSystemMetrics(SM_CYSMICON));
|
||
if (bigIcon != nullptr)
|
||
{
|
||
SendMessageW(m_hwnd, WM_SETICON, ICON_BIG, reinterpret_cast<LPARAM>(bigIcon));
|
||
}
|
||
if (smallIcon != nullptr)
|
||
{
|
||
SendMessageW(m_hwnd, WM_SETICON, ICON_SMALL, reinterpret_cast<LPARAM>(smallIcon));
|
||
}
|
||
|
||
// 创建首个标签页(分组保持展开:加载文件后直接就能勾选信号)。
|
||
NewTab(false);
|
||
|
||
Relayout();
|
||
// 标题栏内存占用按固定间隔刷新(不使用每帧刷新,避免频繁改动窗口标题)。
|
||
SetTimer(m_hwnd, kRamTimerId, kRamTimerPeriodMs, nullptr);
|
||
ShowWindow(m_hwnd, showCommand);
|
||
UpdateWindow(m_hwnd);
|
||
LoadCommandLineFiles();
|
||
return true;
|
||
}
|
||
|
||
void Application::Shutdown()
|
||
{
|
||
ReleaseBackBuffer();
|
||
m_fonts.Destroy();
|
||
|
||
// 拾色器缓存了 GDI+ 位图(色域方块):必须在 GdiplusShutdown 之前释放,
|
||
// 否则位图析构会发生在 GDI+ 关闭之后,直接访问违例。
|
||
m_colorPicker.Close();
|
||
|
||
if (m_gdiplusReady)
|
||
{
|
||
gd::GdiplusShutdown(m_gdiplusToken);
|
||
m_gdiplusReady = false;
|
||
}
|
||
}
|
||
|
||
void Application::ApplyDpi(UINT dpi)
|
||
{
|
||
if (dpi == 0)
|
||
{
|
||
return;
|
||
}
|
||
|
||
m_dpi = dpi;
|
||
m_scale = static_cast<float>(dpi) / 96.0f;
|
||
|
||
m_fonts.Create(dpi);
|
||
m_tabBar.SetScale(m_scale);
|
||
m_toolbar.SetScale(m_scale);
|
||
m_menu.SetScale(m_scale);
|
||
m_colorPicker.SetScale(m_scale);
|
||
m_panel.SetScale(m_scale);
|
||
m_plot.SetScale(m_scale);
|
||
ReleaseBackBuffer();
|
||
Relayout();
|
||
}
|
||
|
||
TabConfig& Application::ActiveTab()
|
||
{
|
||
static TabConfig fallback;
|
||
if (m_activeTab < 0 || m_activeTab >= static_cast<int>(m_tabs.size()))
|
||
{
|
||
return fallback;
|
||
}
|
||
return m_tabs[static_cast<std::size_t>(m_activeTab)];
|
||
}
|
||
|
||
const TabConfig& Application::ActiveTab() const
|
||
{
|
||
static const TabConfig fallback;
|
||
if (m_activeTab < 0 || m_activeTab >= static_cast<int>(m_tabs.size()))
|
||
{
|
||
return fallback;
|
||
}
|
||
return m_tabs[static_cast<std::size_t>(m_activeTab)];
|
||
}
|
||
|
||
void Application::NewTab(bool collapseGroups)
|
||
{
|
||
TabConfig tab;
|
||
tab.SetName(NextTabName());
|
||
// 用户新建的标签页:信号分组**默认折起**(用户明确要求:列表默认紧凑;
|
||
// 之后在该标签页里新加载的分组也沿用这个默认值)。
|
||
tab.SetGroupsCollapsedByDefault(collapseGroups);
|
||
tab.SyncToDataSet(m_dataSet);
|
||
if (collapseGroups)
|
||
{
|
||
tab.SetAllCollapsed(true);
|
||
}
|
||
|
||
m_tabs.push_back(std::move(tab));
|
||
SetActiveTab(static_cast<int>(m_tabs.size()) - 1);
|
||
}
|
||
|
||
std::wstring Application::NextTabName() const
|
||
{
|
||
// 取当前未被占用的最小编号,使关闭标签页后编号可以被复用(3 开 2 关再新建回到「视图 2」)。
|
||
for (int number = 1; number <= static_cast<int>(m_tabs.size()) + 1; ++number)
|
||
{
|
||
const std::wstring candidate = L"视图 " + std::to_wstring(number);
|
||
bool used = false;
|
||
for (const TabConfig& tab : m_tabs)
|
||
{
|
||
if (tab.Name() == candidate)
|
||
{
|
||
used = true;
|
||
break;
|
||
}
|
||
}
|
||
if (!used)
|
||
{
|
||
return candidate;
|
||
}
|
||
}
|
||
return L"视图 " + std::to_wstring(m_tabs.size() + 1);
|
||
}
|
||
|
||
void Application::CloseTab(int index)
|
||
{
|
||
if (index < 0 || index >= static_cast<int>(m_tabs.size()))
|
||
{
|
||
return;
|
||
}
|
||
if (m_tabs.size() <= 1)
|
||
{
|
||
SetStatusMessage(L"至少需要保留一个标签页。", true);
|
||
return;
|
||
}
|
||
|
||
m_tabs.erase(m_tabs.begin() + index);
|
||
|
||
int next = index;
|
||
if (next >= static_cast<int>(m_tabs.size()))
|
||
{
|
||
next = static_cast<int>(m_tabs.size()) - 1;
|
||
}
|
||
SetActiveTab(next);
|
||
}
|
||
|
||
void Application::SetActiveTab(int index)
|
||
{
|
||
if (index < 0 || index >= static_cast<int>(m_tabs.size()))
|
||
{
|
||
return;
|
||
}
|
||
|
||
m_activeTab = index;
|
||
|
||
// 重新绑定上下文:面板与绘图区均指向同一数据层与当前标签页配置。
|
||
m_panel.SetContext(&m_dataSet, &m_tabs[static_cast<std::size_t>(m_activeTab)]);
|
||
m_plot.SetContext(&m_dataSet, &m_tabs[static_cast<std::size_t>(m_activeTab)]);
|
||
|
||
RefreshTabBar();
|
||
Relayout();
|
||
|
||
// 切换标签页后把该页已选中的信号滚动到可见位置。
|
||
// 切换标签页后把共享的选中信号滚动到可见位置(选中状态跨页保持一致)。
|
||
m_panel.ScrollToSignal(m_dataSet.Selected());
|
||
|
||
SynchronizeView(true);
|
||
UpdateWindowTitle();
|
||
InvalidateRect(m_hwnd, nullptr, TRUE);
|
||
}
|
||
|
||
void Application::RefreshTabBar()
|
||
{
|
||
std::vector<std::wstring> names;
|
||
names.reserve(m_tabs.size());
|
||
for (const TabConfig& tab : m_tabs)
|
||
{
|
||
names.push_back(tab.Name());
|
||
}
|
||
m_tabBar.SetTabs(names);
|
||
m_tabBar.SetActiveIndex(m_activeTab);
|
||
}
|
||
|
||
void Application::SynchronizeView(bool force)
|
||
{
|
||
if (m_tabs.empty() || m_activeTab < 0)
|
||
{
|
||
return;
|
||
}
|
||
|
||
TabConfig& tab = m_tabs[static_cast<std::size_t>(m_activeTab)];
|
||
|
||
const int visibleCount = tab.VisibleCount();
|
||
const bool anyVisible = tab.AnyVisible();
|
||
const std::size_t laneCount = tab.Lanes().size();
|
||
|
||
const bool changed = (visibleCount != m_cachedVisibleCount) || (anyVisible != m_cachedAnyVisible) ||
|
||
(laneCount != m_cachedLaneCount);
|
||
if (!changed && !force)
|
||
{
|
||
return;
|
||
}
|
||
|
||
const std::size_t previousLaneCount = m_cachedLaneCount;
|
||
m_cachedVisibleCount = visibleCount;
|
||
m_cachedAnyVisible = anyVisible;
|
||
m_cachedLaneCount = laneCount;
|
||
|
||
// 标签页首次出现可见信号时,把时间轴对齐到可见数据的范围。
|
||
// 没有任何可见信号时(未勾选 / 全部取消勾选 / 清空网格)主动复位自适应标记,
|
||
// 这样用户下一次勾选信号时会重新做一次自适应,而不是停留在上次的视图范围。
|
||
if (!anyVisible)
|
||
{
|
||
tab.ViewInitialized() = false;
|
||
}
|
||
else if (!tab.ViewInitialized())
|
||
{
|
||
m_plot.FitAll();
|
||
}
|
||
|
||
const bool laneCountChanged = (laneCount != previousLaneCount);
|
||
if (laneCountChanged || force)
|
||
{
|
||
// 网格数量变化会影响滚动条与绘图区宽度,必须重新布局。
|
||
Relayout();
|
||
}
|
||
else
|
||
{
|
||
m_plot.RefreshLanes();
|
||
}
|
||
}
|
||
|
||
void Application::SyncTabsToDataSet()
|
||
{
|
||
for (TabConfig& tab : m_tabs)
|
||
{
|
||
tab.SyncToDataSet(m_dataSet);
|
||
}
|
||
|
||
// 选中状态由数据层共享维护,数据变动后需校验其是否仍指向存在的信号。
|
||
m_dataSet.ValidateSelection();
|
||
m_panel.InvalidateRows();
|
||
|
||
// 文件/信号结构变化会让信号引用与文件地址改变,派生缓存必须整体作废。
|
||
m_plot.InvalidateDerivedCaches();
|
||
|
||
// 强制下一次同步重新计算视图状态。
|
||
m_cachedVisibleCount = -1;
|
||
m_cachedAnyVisible = false;
|
||
m_cachedLaneCount = 0;
|
||
|
||
if (m_hwnd != nullptr)
|
||
{
|
||
InvalidateRect(m_hwnd, nullptr, TRUE);
|
||
}
|
||
}
|
||
|
||
void Application::RemoveFile(int fileIndex)
|
||
{
|
||
const DataFile* file = m_dataSet.FileAt(fileIndex);
|
||
if (file == nullptr)
|
||
{
|
||
return;
|
||
}
|
||
const std::wstring name = file->fileName;
|
||
|
||
// 移除文件会释放大量采样内存:先暂停后台派生曲线计算,避免其读取已释放内存。
|
||
m_plot.PauseDerivedAsync();
|
||
// 先按旧下标修正各标签页配置与共享选中项,再从数据层移除,最后统一对齐。
|
||
for (TabConfig& tab : m_tabs)
|
||
{
|
||
tab.RemapAfterFileRemoval(fileIndex);
|
||
}
|
||
m_dataSet.RemapSelectionAfterFileRemoval(fileIndex);
|
||
m_dataSet.RemoveFile(fileIndex);
|
||
SyncTabsToDataSet();
|
||
|
||
SetStatusMessage(L"已从工作区移除:" + name, false);
|
||
UpdateWindowTitle();
|
||
}
|
||
|
||
void Application::RemoveSignal(int fileIndex, int signalIndex)
|
||
{
|
||
SignalRef target;
|
||
target.fileIndex = fileIndex;
|
||
target.signalIndex = signalIndex;
|
||
|
||
const SignalData* signal = m_dataSet.Resolve(target);
|
||
const DataFile* file = m_dataSet.FileAt(fileIndex);
|
||
if (signal == nullptr || file == nullptr)
|
||
{
|
||
return;
|
||
}
|
||
const std::wstring name = signal->Title();
|
||
|
||
if (file->signals.size() <= 1)
|
||
{
|
||
// 组内只剩这一条信号:直接移除整个分组,避免在列表里留下一个空分组。
|
||
RemoveFile(fileIndex);
|
||
return;
|
||
}
|
||
|
||
// 移除信号会释放采样内存:先暂停后台派生曲线计算,避免其读取已释放内存。
|
||
m_plot.PauseDerivedAsync();
|
||
// 先按旧下标修正各标签页配置与共享选中项,再从数据层移除(释放采样内存),最后对齐。
|
||
for (TabConfig& tab : m_tabs)
|
||
{
|
||
tab.RemapAfterSignalRemoval(fileIndex, signalIndex);
|
||
}
|
||
m_dataSet.RemapSelectionAfterSignalRemoval(fileIndex, signalIndex);
|
||
m_dataSet.RemoveSignal(fileIndex, signalIndex);
|
||
SyncTabsToDataSet();
|
||
|
||
SetStatusMessage(L"已从工作区移除信号:" + name, false);
|
||
}
|
||
|
||
void Application::Relayout()
|
||
{
|
||
RECT client = {};
|
||
GetClientRect(m_hwnd, &client);
|
||
const float width = static_cast<float>(client.right - client.left);
|
||
const float height = static_cast<float>(client.bottom - client.top);
|
||
if (width <= 1.0f || height <= 1.0f)
|
||
{
|
||
return;
|
||
}
|
||
|
||
const float tabBarHeight = m_tabBar.DesiredHeight();
|
||
const float toolbarHeight = m_toolbar.DesiredHeight();
|
||
const float splitterWidth = ScaleOf(kSplitterWidth, m_scale);
|
||
|
||
m_tabBarRect = gd::RectF(0.0f, 0.0f, width, tabBarHeight);
|
||
m_toolbarRect = gd::RectF(0.0f, m_tabBarRect.Y + m_tabBarRect.Height, width, toolbarHeight);
|
||
|
||
// 不再保留底部状态栏,内容区一直延伸到窗口底部。
|
||
const float contentTop = m_toolbarRect.Y + m_toolbarRect.Height;
|
||
const float contentHeight = std::max(0.0f, height - contentTop);
|
||
|
||
float panelWidth = ScaleOf(m_panelWidth, m_scale);
|
||
const float maximum = std::min(ScaleOf(kMaxPanelWidth, m_scale), width * 0.45f);
|
||
panelWidth = std::max(ScaleOf(kMinPanelWidth, m_scale), std::min(panelWidth, maximum));
|
||
m_panelWidth = panelWidth / m_scale;
|
||
|
||
m_panelRect = gd::RectF(0.0f, contentTop, panelWidth, contentHeight);
|
||
m_splitterRect = gd::RectF(panelWidth, contentTop, splitterWidth, contentHeight);
|
||
m_plotRect = gd::RectF(panelWidth + splitterWidth, contentTop,
|
||
std::max(0.0f, width - panelWidth - splitterWidth), contentHeight);
|
||
|
||
m_tabBar.Layout(m_tabBarRect);
|
||
m_toolbar.Layout(m_toolbarRect);
|
||
m_panel.SetClientBounds(gd::RectF(0.0f, 0.0f, width, height));
|
||
m_panel.Layout(m_panelRect);
|
||
m_plot.Layout(m_plotRect);
|
||
|
||
// 所有控件的目标矩形都变了,装饰区域必须整体重绘一次。
|
||
m_chromeDirty = true;
|
||
}
|
||
|
||
void Application::EnsureBackBuffer(int width, int height)
|
||
{
|
||
if (m_backDc != nullptr && width == m_backWidth && height == m_backHeight)
|
||
{
|
||
return;
|
||
}
|
||
|
||
ReleaseBackBuffer();
|
||
|
||
// 新建的后备位图内容未定义,必须整帧重绘一次。
|
||
m_chromeDirty = true;
|
||
|
||
HDC screenDc = GetDC(m_hwnd);
|
||
if (screenDc == nullptr)
|
||
{
|
||
return;
|
||
}
|
||
m_backDc = CreateCompatibleDC(screenDc);
|
||
if (m_backDc != nullptr)
|
||
{
|
||
m_backBitmap = CreateCompatibleBitmap(screenDc, width, height);
|
||
if (m_backBitmap != nullptr)
|
||
{
|
||
m_backOldBitmap = SelectObject(m_backDc, m_backBitmap);
|
||
m_backWidth = width;
|
||
m_backHeight = height;
|
||
}
|
||
else
|
||
{
|
||
DeleteDC(m_backDc);
|
||
m_backDc = nullptr;
|
||
}
|
||
}
|
||
ReleaseDC(m_hwnd, screenDc);
|
||
}
|
||
|
||
void Application::ReleaseBackBuffer()
|
||
{
|
||
if (m_backDc != nullptr)
|
||
{
|
||
if (m_backOldBitmap != nullptr)
|
||
{
|
||
SelectObject(m_backDc, m_backOldBitmap);
|
||
m_backOldBitmap = nullptr;
|
||
}
|
||
DeleteDC(m_backDc);
|
||
m_backDc = nullptr;
|
||
}
|
||
if (m_backBitmap != nullptr)
|
||
{
|
||
DeleteObject(m_backBitmap);
|
||
m_backBitmap = nullptr;
|
||
}
|
||
m_backWidth = 0;
|
||
m_backHeight = 0;
|
||
}
|
||
|
||
void Application::Render()
|
||
{
|
||
RECT client = {};
|
||
GetClientRect(m_hwnd, &client);
|
||
const int width = client.right - client.left;
|
||
const int height = client.bottom - client.top;
|
||
if (width <= 0 || height <= 0)
|
||
{
|
||
return;
|
||
}
|
||
|
||
EnsureBackBuffer(width, height);
|
||
if (m_backDc == nullptr)
|
||
{
|
||
return;
|
||
}
|
||
|
||
// 面板内展开/收起自定义颜色面板等操作会改变面板所需高度。
|
||
if (m_panel.TakeLayoutDirty())
|
||
{
|
||
Relayout();
|
||
}
|
||
|
||
// 任何交互(勾选、移动网格、切换标签页)之后都在此统一收敛视图状态。
|
||
SynchronizeView(false);
|
||
|
||
m_toolbar.SetDocumentLoaded(!m_dataSet.Empty());
|
||
|
||
// 工具栏可切换按钮的选中状态始终与当前标签页配置保持一致。
|
||
m_toolbar.SetToggleState(ToolbarAction::AUTO_FIT_Y, m_plot.AllLanesAutoFitToSelection());
|
||
m_toolbar.SetToggleState(ToolbarAction::ZOOM_SELECT,
|
||
m_plot.CurrentZoomMode() == PlotZoomMode::SELECT);
|
||
m_toolbar.SetToggleState(ToolbarAction::CURSOR, m_plot.CursorEnabled());
|
||
|
||
// 「光标」按钮文本随模式变化:常态=光标,单光标=光标x1,双光标=光标x2。
|
||
// 文本变化会影响按钮宽度,因此需要重新排布一次。
|
||
{
|
||
const PlotCursorMode cursorMode = m_plot.CursorMode();
|
||
const std::wstring cursorLabel = (cursorMode == PlotCursorMode::SINGLE) ? L"光标x1"
|
||
: (cursorMode == PlotCursorMode::DOUBLE) ? L"光标x2"
|
||
: L"光标";
|
||
if (m_toolbar.SetActionLabel(ToolbarAction::CURSOR, cursorLabel))
|
||
{
|
||
Relayout();
|
||
}
|
||
}
|
||
|
||
// 工具栏/标签栏/面板各自带悬停态,内容只在鼠标位于其中或发生交互时改变。
|
||
// 绘图区内的拖拽(如双光标左右移动)期间这些区域完全静止,跳过其绘制,
|
||
// 每帧省下约 6-8ms;弹出菜单 / 拾色器 / 加载遮罩会覆盖到装饰区域,必须整帧重绘。
|
||
const bool overlaysVisible = m_menu.IsOpen() || m_colorPicker.IsOpen();
|
||
const bool chromeDirty = m_chromeDirty || m_loading || overlaysVisible;
|
||
m_chromeDirty = false;
|
||
// 提示浮层淡出由定时器驱动,其绘制范围局限在绘图区,但为稳妥起见整帧回送。
|
||
const bool fullFrame = chromeDirty || m_plot.HintNeedsAnimation();
|
||
|
||
{
|
||
gd::Graphics g(m_backDc);
|
||
g.SetSmoothingMode(gd::SmoothingModeAntiAlias);
|
||
g.SetPixelOffsetMode(gd::PixelOffsetModeHalf);
|
||
g.SetTextRenderingHint(gd::TextRenderingHintClearTypeGridFit);
|
||
|
||
if (chromeDirty)
|
||
{
|
||
UiFillRect(g, gd::RectF(0.0f, 0.0f, static_cast<float>(width), static_cast<float>(height)),
|
||
m_theme.windowBg);
|
||
|
||
m_tabBar.Paint(g);
|
||
m_toolbar.Paint(g);
|
||
m_panel.Paint(g);
|
||
UiFillRect(g, m_splitterRect, m_theme.border);
|
||
}
|
||
|
||
m_plot.Paint(g);
|
||
|
||
// 弹出菜单与浮动拾色器始终绘制在最上层,保证与主界面风格一致。
|
||
m_menu.Paint(g);
|
||
m_colorPicker.Paint(g);
|
||
|
||
// 加载遮罩层在一切内容之上(包括菜单与拾色器),形成模态观感。
|
||
PaintLoadingOverlay(g, width, height);
|
||
}
|
||
|
||
HDC windowDc = GetDC(m_hwnd);
|
||
if (windowDc != nullptr)
|
||
{
|
||
if (fullFrame)
|
||
{
|
||
BitBlt(windowDc, 0, 0, width, height, m_backDc, 0, 0, SRCCOPY);
|
||
}
|
||
else
|
||
{
|
||
// PlotView 会把自身绘制严格裁剪在 m_plotRect 内,因此只回送该矩形即可。
|
||
const int px = static_cast<int>(std::floor(m_plotRect.X));
|
||
const int py = static_cast<int>(std::floor(m_plotRect.Y));
|
||
const int pw = static_cast<int>(std::ceil(m_plotRect.X + m_plotRect.Width)) - px;
|
||
const int ph = static_cast<int>(std::ceil(m_plotRect.Y + m_plotRect.Height)) - py;
|
||
BitBlt(windowDc, px, py, pw, ph, m_backDc, px, py, SRCCOPY);
|
||
}
|
||
ReleaseDC(m_hwnd, windowDc);
|
||
}
|
||
|
||
// 提示浮层需要停留后淡出:仅在其仍可见时保持定时重绘,淡出结束即停表。
|
||
if (m_plot.HintNeedsAnimation())
|
||
{
|
||
if (m_hintTimerActive == false)
|
||
{
|
||
SetTimer(m_hwnd, kHintTimerId, kHintTimerPeriodMs, nullptr);
|
||
m_hintTimerActive = true;
|
||
}
|
||
}
|
||
else if (m_hintTimerActive)
|
||
{
|
||
KillTimer(m_hwnd, kHintTimerId);
|
||
m_hintTimerActive = false;
|
||
}
|
||
}
|
||
|
||
void Application::SetStatusMessage(const std::wstring& text, bool isError)
|
||
{
|
||
// 界面不再占用底部空间显示常驻提示;仅把真实错误以对话框形式反馈给用户。
|
||
if (!isError || text.empty())
|
||
{
|
||
return;
|
||
}
|
||
MessageBoxW(m_hwnd, text.c_str(), kWindowTitle, MB_ICONWARNING | MB_OK);
|
||
}
|
||
|
||
void Application::UpdateWindowTitle()
|
||
{
|
||
// 标题栏 = 应用名称 + 分隔竖线 + 内存占用(单位在 KB/MB/GB 间自适应,保留两位小数)。
|
||
// 除内存占用外不再附带标签页、文件数量等无关信息。
|
||
// ⚠️ 内存口径 = **私有工作集**(与任务管理器"内存"列一致),不是总工作集。
|
||
std::wstring title = kWindowTitle;
|
||
title += kTitleSeparator;
|
||
title += kRamTitlePrefix;
|
||
title += FormatByteSize(ProcessPrivateWorkingSetBytes());
|
||
// 数值未变化时不调用 SetWindowTextW,避免无谓地重绘标题栏。
|
||
if (title == m_windowTitleText)
|
||
{
|
||
return;
|
||
}
|
||
m_windowTitleText = title;
|
||
SetWindowTextW(m_hwnd, m_windowTitleText.c_str());
|
||
}
|
||
|
||
void Application::OpenFileDialog()
|
||
{
|
||
// 允许一次选择多个文件,便于跨文件对比信号。
|
||
std::vector<wchar_t> buffer(static_cast<std::size_t>(MAX_PATH) * 16, L'\0');
|
||
|
||
OPENFILENAMEW dialog = {};
|
||
dialog.lStructSize = sizeof(dialog);
|
||
dialog.hwndOwner = m_hwnd;
|
||
dialog.lpstrFilter = L"CSV 数据文件 (*.csv)\0*.csv\0文本数据文件 (*.txt)\0*.txt\0所有文件 (*.*)\0*.*\0\0";
|
||
dialog.lpstrFile = buffer.data();
|
||
dialog.nMaxFile = static_cast<DWORD>(buffer.size());
|
||
dialog.lpstrTitle = L"打开仿真数据文件(可多选)";
|
||
dialog.lpstrDefExt = L"csv";
|
||
dialog.Flags = OFN_FILEMUSTEXIST | OFN_PATHMUSTEXIST | OFN_EXPLORER | OFN_NOCHANGEDIR |
|
||
OFN_ALLOWMULTISELECT;
|
||
|
||
if (GetOpenFileNameW(&dialog) == FALSE)
|
||
{
|
||
return;
|
||
}
|
||
|
||
// 多选时首段为目录,其余为文件名;单选时整段即为完整路径。
|
||
const std::wstring first(buffer.data());
|
||
const wchar_t* cursor = buffer.data() + first.size() + 1;
|
||
|
||
int loaded = 0;
|
||
if (*cursor == L'\0')
|
||
{
|
||
if (LoadFile(first))
|
||
{
|
||
++loaded;
|
||
}
|
||
}
|
||
else
|
||
{
|
||
while (*cursor != L'\0')
|
||
{
|
||
const std::wstring name(cursor);
|
||
if (LoadFile(first + L"\\" + name))
|
||
{
|
||
++loaded;
|
||
}
|
||
cursor += name.size() + 1;
|
||
}
|
||
}
|
||
|
||
if (loaded > 0)
|
||
{
|
||
SetStatusMessage(L"已加载 " + std::to_wstring(loaded) +
|
||
L" 个文件,请在左侧勾选需要查看的信号。",
|
||
false);
|
||
}
|
||
}
|
||
|
||
bool Application::LoadFile(const std::wstring& path)
|
||
{
|
||
if (path.empty())
|
||
{
|
||
return false;
|
||
}
|
||
|
||
// 大文件在工作线程解析并构建,主线程播放加载过渡动画,界面不再整块卡住。
|
||
DataFile file;
|
||
std::wstring error;
|
||
if (!LoadFileAsync(path, file, error))
|
||
{
|
||
if (!error.empty())
|
||
{
|
||
SetStatusMessage(L"解析失败:" + error, true);
|
||
}
|
||
InvalidateRect(m_hwnd, nullptr, FALSE);
|
||
return false;
|
||
}
|
||
|
||
int fileIndex = -1;
|
||
// 插入新文件会移动 DataFile/SignalData 的存储位置,必须先暂停后台派生曲线计算,
|
||
// 否则后台线程可能正在读取即将被移动的采样数据。
|
||
m_plot.PauseDerivedAsync();
|
||
// 此处只做一次指针/缓冲移动,不拷贝任何采样数据,因此不会造成可见卡顿。
|
||
if (!m_dataSet.AddDataFile(std::move(file), fileIndex))
|
||
{
|
||
SetStatusMessage(L"解析失败:数据为空。", true);
|
||
InvalidateRect(m_hwnd, nullptr, FALSE);
|
||
return false;
|
||
}
|
||
|
||
// 新文件的信号在全部标签页中默认均为未勾选状态,由用户自行决定查看内容。
|
||
SyncTabsToDataSet();
|
||
UpdateWindowTitle();
|
||
|
||
const DataFile* loaded = m_dataSet.FileAt(fileIndex);
|
||
if (loaded != nullptr)
|
||
{
|
||
std::wstring message = L"已加载 组" + std::to_wstring(fileIndex + 1) + L"|" + loaded->Title() +
|
||
L"(" + FormatCount(static_cast<unsigned long long>(loaded->time.size())) +
|
||
L" 行采样," + std::to_wstring(loaded->signals.size()) +
|
||
L" 条信号,时间列:" + loaded->timeColumnName + L")";
|
||
if (!loaded->timeMonotonic)
|
||
{
|
||
message += L" 提示:时间列非单调递增,光标取值使用最近邻策略。";
|
||
}
|
||
SetStatusMessage(message, false);
|
||
}
|
||
return true;
|
||
}
|
||
|
||
bool Application::LoadFileAsync(const std::wstring& path, DataFile& file, std::wstring& error)
|
||
{
|
||
error.clear();
|
||
|
||
AsyncLoadState state;
|
||
const std::size_t paletteBase = m_dataSet.SignalCount();
|
||
|
||
m_loading = true;
|
||
m_loadingProgress = 0.0f;
|
||
m_loadingBytesRead = 0;
|
||
m_loadingTotalBytes = 0;
|
||
const std::size_t slashPos = path.find_last_of(L"\\/");
|
||
m_loadingText = (slashPos == std::wstring::npos) ? path : path.substr(slashPos + 1);
|
||
|
||
std::thread worker(RunAsyncLoad, &state, path, paletteBase);
|
||
|
||
// 主线程循环:抽送消息 + 刷新动画,直到后台完成或用户取消。
|
||
while (!state.done.load(std::memory_order_acquire))
|
||
{
|
||
MSG message = {};
|
||
while (PeekMessageW(&message, nullptr, 0, 0, PM_REMOVE))
|
||
{
|
||
if (message.message == WM_QUIT)
|
||
{
|
||
// 退出请求:让后台停下并重新投递 WM_QUIT,由主循环正常收尾。
|
||
state.cancelled.store(true, std::memory_order_relaxed);
|
||
PostQuitMessage(static_cast<int>(message.wParam));
|
||
break;
|
||
}
|
||
// 加载期间屏蔽鼠标与其它按键,避免重入加载或误改数据;
|
||
// 保留 Esc 作为取消入口。
|
||
if (message.message >= WM_MOUSEFIRST && message.message <= WM_MOUSELAST)
|
||
{
|
||
continue;
|
||
}
|
||
if (message.message == WM_KEYDOWN && message.wParam == VK_ESCAPE)
|
||
{
|
||
state.cancelled.store(true, std::memory_order_relaxed);
|
||
continue;
|
||
}
|
||
if (message.message >= WM_KEYFIRST && message.message <= WM_KEYLAST)
|
||
{
|
||
continue;
|
||
}
|
||
TranslateMessage(&message);
|
||
DispatchMessageW(&message);
|
||
}
|
||
|
||
m_loadingProgress = state.progress.load(std::memory_order_relaxed);
|
||
m_loadingBytesRead = state.bytesRead.load(std::memory_order_relaxed);
|
||
m_loadingTotalBytes = state.totalBytes.load(std::memory_order_relaxed);
|
||
InvalidateRect(m_hwnd, nullptr, FALSE);
|
||
UpdateWindow(m_hwnd);
|
||
Sleep(15);
|
||
}
|
||
|
||
m_loadingProgress = 1.0f;
|
||
m_loadingBytesRead = m_loadingTotalBytes;
|
||
worker.join();
|
||
m_loading = false;
|
||
|
||
if (state.cancelled.load(std::memory_order_relaxed))
|
||
{
|
||
// 用户主动取消:不弹错误框(与 Esc 取消缩放选区的静默语义一致)。
|
||
InvalidateRect(m_hwnd, nullptr, TRUE);
|
||
return false;
|
||
}
|
||
if (!state.ok)
|
||
{
|
||
error = state.error;
|
||
return false;
|
||
}
|
||
|
||
file = std::move(state.file);
|
||
return true;
|
||
}
|
||
|
||
void Application::PaintLoadingOverlay(gd::Graphics& g, int width, int height)
|
||
{
|
||
if (!m_loading)
|
||
{
|
||
return;
|
||
}
|
||
|
||
// 半透明遮罩压暗整个界面,形成明确的"正在加载"状态。
|
||
UiFillRect(g, gd::RectF(0.0f, 0.0f, static_cast<float>(width), static_cast<float>(height)),
|
||
0x66000000u);
|
||
|
||
const float cardWidth = std::min(420.0f * m_scale, static_cast<float>(width) - 40.0f * m_scale);
|
||
const float cardHeight = 132.0f * m_scale;
|
||
const gd::RectF card((static_cast<float>(width) - cardWidth) * 0.5f,
|
||
(static_cast<float>(height) - cardHeight) * 0.5f, cardWidth, cardHeight);
|
||
|
||
UiFillRoundRect(g, card, 8.0f * m_scale, m_theme.cardBg);
|
||
UiDrawRoundRect(g, card, 8.0f * m_scale, m_theme.borderStrong, 1.0f);
|
||
|
||
// 旋转弧线:用时间驱动的起始角度形成持续的加载动画。
|
||
const float spinnerRadius = 11.0f * m_scale;
|
||
const gd::RectF spinner(card.X + 26.0f * m_scale, card.Y + 30.0f * m_scale - spinnerRadius,
|
||
spinnerRadius * 2.0f, spinnerRadius * 2.0f);
|
||
m_loadingSpin += 12.0f;
|
||
if (m_loadingSpin >= 360.0f)
|
||
{
|
||
m_loadingSpin -= 360.0f;
|
||
}
|
||
gd::Pen trackPen(ToGdiColor(m_theme.border), 3.0f * m_scale);
|
||
gd::Pen spinPen(ToGdiColor(m_theme.accent), 3.0f * m_scale);
|
||
g.DrawArc(&trackPen, spinner, 0.0f, 359.0f);
|
||
g.DrawArc(&spinPen, spinner, m_loadingSpin, 110.0f);
|
||
|
||
const float textLeft = card.X + 52.0f * m_scale;
|
||
const float textWidth = cardWidth - 52.0f * m_scale - 20.0f * m_scale;
|
||
|
||
UiText(g, L"正在加载数据…", m_fonts.uiBold,
|
||
gd::RectF(textLeft, card.Y + 18.0f * m_scale, textWidth, 22.0f * m_scale), m_theme.text,
|
||
TextAlign::LEFT);
|
||
|
||
// 超长文件名用省略号截断,避免撑破卡片。
|
||
const std::wstring nameText =
|
||
UiEllipsize(m_loadingText, m_fonts.uiSmall, textWidth);
|
||
UiText(g, nameText, m_fonts.uiSmall,
|
||
gd::RectF(textLeft, card.Y + 42.0f * m_scale, textWidth, 20.0f * m_scale),
|
||
m_theme.textMuted, TextAlign::LEFT);
|
||
|
||
// 进度条(复用样式区滑块以外的简单圆角矩形,填充宽度严格按比例)。
|
||
const gd::RectF track(card.X + 26.0f * m_scale, card.Y + 76.0f * m_scale,
|
||
cardWidth - 52.0f * m_scale, 8.0f * m_scale);
|
||
UiFillRoundRect(g, track, track.Height * 0.5f, m_theme.pressed);
|
||
float ratio = m_loadingProgress;
|
||
ratio = std::max(0.0f, std::min(ratio, 1.0f));
|
||
const float fillWidth = track.Width * ratio;
|
||
if (fillWidth > 1.0f)
|
||
{
|
||
UiFillRoundRect(g, gd::RectF(track.X, track.Y, fillWidth, track.Height), track.Height * 0.5f,
|
||
m_theme.accent);
|
||
}
|
||
|
||
wchar_t buffer[64];
|
||
swprintf(buffer, sizeof(buffer) / sizeof(buffer[0]), L"%.2f%%", ratio * 100.0);
|
||
|
||
// 已读取 / 总大小:两者都只显示数字与自适应单位(GB / MB / KB),均保留两位小数;
|
||
// 百分比同样是两位小数。总大小未知(如列重组阶段)时只显示百分比。
|
||
{
|
||
std::wstring statusText = buffer;
|
||
if (m_loadingTotalBytes > 0)
|
||
{
|
||
statusText = FormatByteSize(m_loadingBytesRead) + L" / " +
|
||
FormatByteSize(m_loadingTotalBytes) + L" " + buffer;
|
||
}
|
||
UiText(g, statusText, m_fonts.monoSmall,
|
||
gd::RectF(track.X, card.Y + 92.0f * m_scale, track.Width, 20.0f * m_scale),
|
||
m_theme.text, TextAlign::LEFT);
|
||
}
|
||
|
||
UiText(g, L"按 Esc 取消", m_fonts.uiSmall,
|
||
gd::RectF(track.X, card.Y + 92.0f * m_scale, track.Width, 20.0f * m_scale),
|
||
m_theme.textMuted, TextAlign::RIGHT);
|
||
}
|
||
|
||
void Application::LoadCommandLineFiles()
|
||
{
|
||
int argumentCount = 0;
|
||
LPWSTR* arguments = CommandLineToArgvW(GetCommandLineW(), &argumentCount);
|
||
if (arguments == nullptr)
|
||
{
|
||
return;
|
||
}
|
||
|
||
int loaded = 0;
|
||
for (int index = 1; index < argumentCount; ++index)
|
||
{
|
||
const std::wstring argument = arguments[index];
|
||
if (argument.empty() || argument[0] == L'-' || argument[0] == L'/')
|
||
{
|
||
continue;
|
||
}
|
||
if (LoadFile(argument))
|
||
{
|
||
++loaded;
|
||
}
|
||
}
|
||
LocalFree(arguments);
|
||
|
||
if (loaded > 0)
|
||
{
|
||
SetStatusMessage(L"已加载 " + std::to_wstring(loaded) +
|
||
L" 个文件,请在左侧勾选需要查看的信号。",
|
||
false);
|
||
}
|
||
}
|
||
|
||
void Application::ExecuteToolbarAction(ToolbarAction action)
|
||
{
|
||
switch (action)
|
||
{
|
||
case ToolbarAction::OPEN_FILE:
|
||
OpenFileDialog();
|
||
break;
|
||
case ToolbarAction::FIT_ALL:
|
||
// 自适应同时回到初始状态:退出缩放模式并显示全部信号范围。
|
||
m_plot.FitAll();
|
||
SetStatusMessage(L"已恢复自适应视图。", false);
|
||
break;
|
||
case ToolbarAction::AUTO_FIT_Y:
|
||
{
|
||
// 与网格右键菜单中的「选区 Y 轴自适应(全局)」同一开关:
|
||
// 打开后立即按当前视图自适应一次,之后每次时间轴选区放大都会自动匹配。
|
||
const bool enable = !m_plot.AllLanesAutoFitToSelection();
|
||
m_plot.SetAllLanesAutoFitToSelection(enable, true);
|
||
SetStatusMessage(enable ? L"已开启选区 Y 轴自适应:时间轴选区放大后自动匹配各网格 Y 轴。"
|
||
: L"已关闭选区 Y 轴自适应。",
|
||
false);
|
||
break;
|
||
}
|
||
case ToolbarAction::ZOOM_SELECT:
|
||
m_plot.ToggleZoomMode(PlotZoomMode::SELECT);
|
||
break;
|
||
case ToolbarAction::CURSOR:
|
||
m_plot.CycleCursorMode();
|
||
break;
|
||
case ToolbarAction::MERGE_LANES:
|
||
if (!m_dataSet.Empty())
|
||
{
|
||
ActiveTab().SetMode(LaneMode::MERGED);
|
||
SetStatusMessage(L"已将当前标签页的全部可见信号合并到同一网格。", false);
|
||
}
|
||
break;
|
||
case ToolbarAction::SPLIT_LANES:
|
||
if (!m_dataSet.Empty())
|
||
{
|
||
ActiveTab().SetMode(LaneMode::PER_SIGNAL);
|
||
SetStatusMessage(L"当前标签页已切换为「每信号一格」。", false);
|
||
}
|
||
break;
|
||
case ToolbarAction::CLEAR_ALL:
|
||
if (!m_dataSet.Empty())
|
||
{
|
||
ActiveTab().SetAllVisible(false);
|
||
m_panel.InvalidateRows();
|
||
SetStatusMessage(L"已清空当前标签页的全部网格。", false);
|
||
}
|
||
break;
|
||
case ToolbarAction::NONE:
|
||
default:
|
||
break;
|
||
}
|
||
|
||
if (m_hwnd != nullptr)
|
||
{
|
||
InvalidateRect(m_hwnd, nullptr, TRUE);
|
||
}
|
||
}
|
||
|
||
bool Application::HandleKeyDown(WPARAM key)
|
||
{
|
||
const bool control = (GetKeyState(VK_CONTROL) & 0x8000) != 0;
|
||
|
||
if (control && key == L'O')
|
||
{
|
||
OpenFileDialog();
|
||
return true;
|
||
}
|
||
if (control && key == L'T')
|
||
{
|
||
NewTab(true);
|
||
return true;
|
||
}
|
||
if (control && key == L'W')
|
||
{
|
||
CloseTab(m_activeTab);
|
||
return true;
|
||
}
|
||
if (control && key == VK_TAB)
|
||
{
|
||
if (m_tabs.size() > 1)
|
||
{
|
||
const bool backward = (GetKeyState(VK_SHIFT) & 0x8000) != 0;
|
||
const int count = static_cast<int>(m_tabs.size());
|
||
SetActiveTab((m_activeTab + (backward ? count - 1 : 1)) % count);
|
||
}
|
||
return true;
|
||
}
|
||
|
||
switch (key)
|
||
{
|
||
case VK_ESCAPE:
|
||
{
|
||
// Esc 的语义:
|
||
// 1) 放弃当前正在进行的拖拽(典型场景:缩放的框选选区还没松开左键);
|
||
// 取消后不应用选区、视图范围保持不变;
|
||
// 2) 同时退出全部缩放模式,使工具栏「时间轴选区」「Y 轴选区」回到未选中态。
|
||
// Esc 不与时间光标关联:关闭光标请用工具栏「光标」按钮,避免误关指针。
|
||
bool changed = m_plot.CancelDrag();
|
||
changed = m_plot.ExitZoomModes() || changed;
|
||
return changed;
|
||
}
|
||
case VK_ADD:
|
||
case VK_OEM_PLUS:
|
||
m_plot.ZoomTimeStep(1.4);
|
||
return true;
|
||
case VK_SUBTRACT:
|
||
case VK_OEM_MINUS:
|
||
m_plot.ZoomTimeStep(1.0 / 1.4);
|
||
return true;
|
||
case VK_LEFT:
|
||
m_plot.PanTimeByFraction(-0.12);
|
||
return true;
|
||
case VK_RIGHT:
|
||
m_plot.PanTimeByFraction(0.12);
|
||
return true;
|
||
case VK_UP:
|
||
m_plot.ScrollLanesByFraction(-0.12);
|
||
return true;
|
||
case VK_DOWN:
|
||
m_plot.ScrollLanesByFraction(0.12);
|
||
return true;
|
||
default:
|
||
break;
|
||
}
|
||
|
||
if (key == L'F')
|
||
{
|
||
m_plot.FitAll();
|
||
return true;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
void Application::UpdateDragCursor(int x, int y)
|
||
{
|
||
// 鼠标捕获期间不再有 WM_SETCURSOR,只能自己按当前状态设置光标。
|
||
HCURSOR cursor = nullptr;
|
||
if (m_activeTarget == Target::PLOT)
|
||
{
|
||
cursor = m_plot.QueryCursor(x, y);
|
||
}
|
||
SetCursor(cursor != nullptr ? cursor : LoadCursorW(nullptr, IDC_ARROW));
|
||
}
|
||
|
||
bool Application::HandleMouseMove(int x, int y, bool buttonDown)
|
||
{
|
||
if (buttonDown && m_activeTarget != Target::NONE)
|
||
{
|
||
switch (m_activeTarget)
|
||
{
|
||
case Target::SPLITTER:
|
||
if (m_draggingSplitter && m_scale > 0.0f)
|
||
{
|
||
const float desired = static_cast<float>(x - m_splitterGrabOffset) / m_scale;
|
||
const float maximum =
|
||
std::min(kMaxPanelWidth, static_cast<float>(kMinClientWidth) / m_scale);
|
||
m_panelWidth = std::max(kMinPanelWidth, std::min(desired, maximum));
|
||
Relayout();
|
||
return true;
|
||
}
|
||
return false;
|
||
case Target::PLOT:
|
||
return m_plot.OnMouseMove(x, y);
|
||
case Target::PANEL:
|
||
return m_panel.OnMouseMove(x, y);
|
||
case Target::TOOLBAR:
|
||
return m_toolbar.OnMouseMove(x, y);
|
||
case Target::TAB_BAR:
|
||
return m_tabBar.OnMouseMove(x, y);
|
||
case Target::EDIT_FIELD:
|
||
{
|
||
// 输入框拖拽框选:按当前正在编辑的组件分发。
|
||
bool changed = m_tabBar.OnEditMouseMove(x);
|
||
changed = m_panel.OnEditMouseMove(x) || changed;
|
||
changed = m_plot.OnEditMouseMove(x) || changed;
|
||
changed = m_colorPicker.OnEditMouseMove(x) || changed;
|
||
return changed;
|
||
}
|
||
case Target::NONE:
|
||
default:
|
||
return false;
|
||
}
|
||
}
|
||
|
||
bool changed = false;
|
||
changed =
|
||
(m_tabBar.HitTest(x, y) ? m_tabBar.OnMouseMove(x, y) : m_tabBar.OnMouseLeave()) || changed;
|
||
changed =
|
||
(m_toolbar.HitTest(x, y) ? m_toolbar.OnMouseMove(x, y) : m_toolbar.OnMouseLeave()) || changed;
|
||
changed = (m_panel.HitTest(x, y) ? m_panel.OnMouseMove(x, y) : m_panel.OnMouseLeave()) || changed;
|
||
changed = (m_plot.HitTest(x, y) ? m_plot.OnMouseMove(x, y) : m_plot.OnMouseLeave()) || changed;
|
||
return changed;
|
||
}
|
||
|
||
void Application::OnTabBarResult(const TabBarResult& result)
|
||
{
|
||
switch (result.type)
|
||
{
|
||
case TabBarResult::Type::ADD:
|
||
NewTab(true);
|
||
break;
|
||
case TabBarResult::Type::SELECT:
|
||
if (result.index != m_activeTab)
|
||
{
|
||
SetActiveTab(result.index);
|
||
}
|
||
break;
|
||
case TabBarResult::Type::CLOSE:
|
||
CloseTab(result.index);
|
||
break;
|
||
case TabBarResult::Type::CONTEXT:
|
||
ShowTabContextMenu(result);
|
||
break;
|
||
case TabBarResult::Type::REORDER:
|
||
MoveTab(result.index, result.targetIndex);
|
||
break;
|
||
case TabBarResult::Type::RENAME:
|
||
if (result.index >= 0 && result.index < static_cast<int>(m_tabs.size()) && !result.text.empty())
|
||
{
|
||
m_tabs[static_cast<std::size_t>(result.index)].SetName(result.text);
|
||
RefreshTabBar();
|
||
// SetTabs 会重置各标签的矩形,必须重新布局才能把它们算回来。
|
||
Relayout();
|
||
InvalidateRect(m_hwnd, nullptr, TRUE);
|
||
}
|
||
break;
|
||
case TabBarResult::Type::NONE:
|
||
default:
|
||
break;
|
||
}
|
||
}
|
||
|
||
void Application::MoveTab(int from, int target)
|
||
{
|
||
if (from < 0 || from >= static_cast<int>(m_tabs.size()))
|
||
{
|
||
return;
|
||
}
|
||
if (target < 0 || target > static_cast<int>(m_tabs.size()))
|
||
{
|
||
return;
|
||
}
|
||
|
||
// 拖拽语义:target 表示「插入到该下标之前」;向后拖时需扣除自身占位。
|
||
int destination = target;
|
||
if (destination > from)
|
||
{
|
||
--destination;
|
||
}
|
||
destination = std::max(0, std::min(destination, static_cast<int>(m_tabs.size()) - 1));
|
||
if (destination == from)
|
||
{
|
||
return;
|
||
}
|
||
|
||
// 保持当前激活的标签页在重排后仍处于激活状态。
|
||
TabConfig moved = m_tabs[static_cast<std::size_t>(from)];
|
||
const int activeIndex = m_activeTab;
|
||
|
||
m_tabs.erase(m_tabs.begin() + from);
|
||
m_tabs.insert(m_tabs.begin() + destination, std::move(moved));
|
||
|
||
int newActive = activeIndex;
|
||
if (activeIndex == from)
|
||
{
|
||
newActive = destination;
|
||
}
|
||
else if (from < activeIndex && destination >= activeIndex)
|
||
{
|
||
--newActive;
|
||
}
|
||
else if (from > activeIndex && destination <= activeIndex)
|
||
{
|
||
++newActive;
|
||
}
|
||
|
||
SetActiveTab(newActive);
|
||
InvalidateRect(m_hwnd, nullptr, TRUE);
|
||
}
|
||
|
||
bool Application::ApplyPendingRename()
|
||
{
|
||
SignalRef ref;
|
||
std::wstring name;
|
||
if (!m_plot.TakePendingRename(ref, name))
|
||
{
|
||
return false;
|
||
}
|
||
|
||
if (!m_dataSet.SetSignalDisplayName(ref.fileIndex, ref.signalIndex, name))
|
||
{
|
||
return false;
|
||
}
|
||
|
||
// 改名只影响显示,但需要刷新左侧列表与绘图区图例。
|
||
m_panel.InvalidateRows();
|
||
SetStatusMessage(L"信号已重命名为:" + name, false);
|
||
InvalidateRect(m_hwnd, nullptr, TRUE);
|
||
return true;
|
||
}
|
||
|
||
bool Application::BuildDerivedSignal(const DerivedSignalRequest& request, bool makeVisible)
|
||
{
|
||
const DataFile* file = m_dataSet.FileAt(request.fileIndex);
|
||
if (file == nullptr || request.signalIndex < 0 ||
|
||
request.signalIndex >= static_cast<int>(file->signals.size()))
|
||
{
|
||
return false;
|
||
}
|
||
const SignalData& source = file->signals[static_cast<std::size_t>(request.signalIndex)];
|
||
const std::wstring baseName = source.Title();
|
||
|
||
// 0. 先把源信号的「调整」变换快照下来(下面 `InsertDerivedSignal` 会移动信号存储,
|
||
// 之后再取 `source` 会悬垂)。
|
||
//
|
||
// ⚠️ **变换要"烘进数据",而不是让新信号继承这套指纹**(用户明确要求):
|
||
// 网格里的预览曲线画的是 `变换(滤波结果)`。若新信号只存 `滤波结果` 再**继承变换**,
|
||
// 它的「调整」面板里就会莫名其妙带着一整套系数 / 镜像 / 偏移 —— 用户看到的是
|
||
// "新信号只是继承了原信号的指纹,数据本身没变"。现在改为:
|
||
// * 数值域变换(系数 → 绝对值 → 垂直镜像 → Y 偏移)**与**水平镜像(数据反序)
|
||
// 全部作用在派生采样值上;
|
||
// * 于是新信号**用默认外观**绘制出来就与预览曲线逐位一致。
|
||
// * ⚠️ **时间轴偏移也一并烘进数据**(见 2c:按 `time[i] - offsetTime` 重采样)。
|
||
// 新信号与源信号共用同一个文件时间轴,因此时间平移只能写进采样值 —— 这样
|
||
// 新信号**不启用任何「调整」**就与预览逐点重合(用户明确要求)。
|
||
// 只有"无法烘值"的退化情形才退回把 `offsetTime` 带在样式上(见 3b)。
|
||
SignalStyle sourceTransform;
|
||
bool hasSourceStyle = false;
|
||
{
|
||
SignalRef sourceRef;
|
||
sourceRef.fileIndex = request.fileIndex;
|
||
sourceRef.signalIndex = request.signalIndex;
|
||
if (const SignalStyle* sourceStyle = m_dataSet.Style(sourceRef))
|
||
{
|
||
sourceTransform = *sourceStyle;
|
||
hasSourceStyle = true;
|
||
}
|
||
}
|
||
|
||
// 1. 计算派生采样值(与网格内的预览曲线共用同一实现,因此逐位一致)。
|
||
// 多次迭代均值滤波形如「<原信号名称> (N点均值 M次迭代)」,
|
||
// 弱峰调整形如「<原信号名称> (弱峰调整 M% N次迭代)」。
|
||
std::vector<float> derived_values;
|
||
SignalData derived;
|
||
if (request.weakPeak)
|
||
{
|
||
ComputeWeakPeakAdjust(source.values, static_cast<double>(request.amountPercent) / 100.0,
|
||
request.iterations, derived_values);
|
||
derived.name = baseName + L" (弱峰调整 " + std::to_wstring(request.amountPercent) + L"% " +
|
||
std::to_wstring(request.iterations) + L"次迭代)";
|
||
}
|
||
else
|
||
{
|
||
ComputeMovingAverageIterated(source.values, request.points, request.iterations, derived_values);
|
||
derived.name = baseName + L" (" + std::to_wstring(request.points) + L"点均值 " +
|
||
std::to_wstring(request.iterations) + L"次迭代)";
|
||
}
|
||
|
||
// 2. 组装新信号(外观一律用默认值:变换已经烘进采样数据,不再是"继承的指纹")。
|
||
derived.rawHeader = derived.name;
|
||
derived.unit = source.unit;
|
||
derived.values.assign(derived_values.begin(), derived_values.end());
|
||
|
||
// 2b. 把源信号的「调整」变换**烘进派生采样值**:
|
||
// * 数值域(系数 → 绝对值 → 垂直镜像 → Y 偏移)逐点套用 `ApplyDrawTransform`;
|
||
// * 水平镜像 = 数据反序,等价于把采样数组整体反转。
|
||
// ⚠️ `ApplyDrawTransform` 是逐点函数,因此"先变换再反转"与"先反转再变换"等价。
|
||
if (hasSourceStyle)
|
||
{
|
||
const bool valueTransform =
|
||
(sourceTransform.gain != 1.0f || sourceTransform.absolute ||
|
||
sourceTransform.mirrorVertical || sourceTransform.offsetY != 0.0f);
|
||
if (valueTransform)
|
||
{
|
||
for (double& value : derived.values)
|
||
{
|
||
value = ApplyDrawTransform(sourceTransform, value);
|
||
}
|
||
}
|
||
if (sourceTransform.mirrorHorizontal)
|
||
{
|
||
std::reverse(derived.values.begin(), derived.values.end());
|
||
}
|
||
}
|
||
|
||
// 2c. 时间轴偏移:把 `offsetTime` 也**重采样烘进采样值**。
|
||
//
|
||
// 预览曲线是按「源信号样式」绘制的,它的时间轴偏移会把曲线整体横向平移。
|
||
// 派生信号与源信号**共用同一个文件时间轴**,所以时间平移本无法写进"时间列";
|
||
// 但用户明确要求:**新信号不启用任何「调整」也要与预览逐点重合**
|
||
//(新信号的「调整」面板必须是默认值,不允许靠"继承 offsetTime"来表达)。
|
||
// 因此这里把平移直接写进数据:
|
||
// * 新信号第 i 个采样 = 预览曲线在 `time[i]` 时刻显示的值
|
||
// = 已变换的曲线在 `time[i] - offsetTime` 处的取值(取最近的采样点);
|
||
// * 平移后落在数据范围之外的采样置 NaN —— 与预览"该处没有曲线"完全一致。
|
||
// ⚠️ 一次整段重采样需要一份等长的临时缓冲(千万级采样 ≈ 数十 MB),用完立即释放;
|
||
// 这是"构建为新信号"的一次性操作,不在交互路径上。
|
||
// ⚠️ 时间轴非单调时无法按下标单调推进取样(只能整表扫描),此时退回"继承
|
||
// `offsetTime`"(见 3b):两条路径的**显示效果相同**,只是偏移留在样式里。
|
||
bool timeShiftBaked = false;
|
||
if (hasSourceStyle && sourceTransform.offsetTime != 0.0 && file->timeMonotonic)
|
||
{
|
||
const std::vector<double>& times = file->time;
|
||
const std::size_t total = derived.values.size();
|
||
if (total > 1 && times.size() >= total)
|
||
{
|
||
const double shift = sourceTransform.offsetTime;
|
||
std::vector<double> shifted(total, std::numeric_limits<double>::quiet_NaN());
|
||
// 目标时刻 `times[index] - shift` 随 index 单调不减 → 游标只前进,整体 O(n)。
|
||
std::size_t probe = 0;
|
||
for (std::size_t index = 0; index < total; ++index)
|
||
{
|
||
const double target = times[index] - shift;
|
||
if (!std::isfinite(target) || target < times[0] || target > times[total - 1])
|
||
{
|
||
continue; // 平移后越界:该处没有曲线
|
||
}
|
||
while (probe + 1 < total && times[probe + 1] < target)
|
||
{
|
||
++probe;
|
||
}
|
||
std::size_t nearest = probe;
|
||
if (probe + 1 < total &&
|
||
std::fabs(times[probe + 1] - target) < std::fabs(times[probe] - target))
|
||
{
|
||
nearest = probe + 1;
|
||
}
|
||
shifted[index] = derived.values[nearest];
|
||
}
|
||
derived.values.swap(shifted);
|
||
timeShiftBaked = true;
|
||
}
|
||
}
|
||
|
||
// 3. 插入到原信号的下方(数据层 + 各标签页引用重映射)。
|
||
int newIndex = -1;
|
||
// 插入派生信号会移动该文件的信号存储:先暂停后台计算,避免其读取已移动的数据。
|
||
m_plot.PauseDerivedAsync();
|
||
if (!m_dataSet.InsertDerivedSignal(request.fileIndex, request.signalIndex, std::move(derived),
|
||
newIndex))
|
||
{
|
||
return false;
|
||
}
|
||
for (TabConfig& tab : m_tabs)
|
||
{
|
||
tab.RemapAfterSignalInsert(request.fileIndex, newIndex);
|
||
}
|
||
|
||
// 3b. 时间轴偏移正常情况下已烘进采样值(见 2c),新信号的「调整」全部保持默认。
|
||
// ⚠️ 只有"无法烘值"的退化情形(时间轴非单调 / 时间列缺失)才退回到把
|
||
// `offsetTime` 带在新信号样式上 —— 无论如何都要让**显示**与预览一致。
|
||
if (hasSourceStyle && sourceTransform.offsetTime != 0.0 && !timeShiftBaked)
|
||
{
|
||
if (SignalStyle* derivedStyle = m_dataSet.Style(SignalRef{request.fileIndex, newIndex}))
|
||
{
|
||
derivedStyle->offsetTime = sourceTransform.offsetTime;
|
||
}
|
||
}
|
||
|
||
// 4. 在当前标签页中勾选新信号,并把它所在的网格移到原信号网格的下方。
|
||
if (makeVisible && !m_tabs.empty())
|
||
{
|
||
SignalRef derivedRef;
|
||
derivedRef.fileIndex = request.fileIndex;
|
||
derivedRef.signalIndex = newIndex;
|
||
ActiveTab().SetVisible(derivedRef, true);
|
||
|
||
SignalRef sourceRef;
|
||
sourceRef.fileIndex = request.fileIndex;
|
||
sourceRef.signalIndex = request.signalIndex;
|
||
ActiveTab().MoveLaneOfSignalBelow(derivedRef, sourceRef);
|
||
}
|
||
|
||
// 5. 数据层结构变化:刷新列表、作废折线/派生缓存并重绘。
|
||
SyncTabsToDataSet();
|
||
return true;
|
||
}
|
||
|
||
bool Application::ApplyPendingDerivedBuild()
|
||
{
|
||
// 两类「构建为新信号」请求(均值滤波 / 弱峰调整)机制完全一致,
|
||
// 统一整理成同一份构建清单后按下标降序处理。
|
||
std::vector<DerivedSignalRequest> requests;
|
||
{
|
||
int laneIndex = -1;
|
||
std::vector<PlotView::MeanFilterBuild> items;
|
||
if (m_plot.TakePendingMeanFilterBuild(laneIndex, items))
|
||
{
|
||
for (const PlotView::MeanFilterBuild& item : items)
|
||
{
|
||
DerivedSignalRequest request;
|
||
request.fileIndex = item.fileIndex;
|
||
request.signalIndex = item.signalIndex;
|
||
request.points = item.points;
|
||
request.iterations = item.iterations;
|
||
request.weakPeak = false;
|
||
requests.push_back(request);
|
||
}
|
||
}
|
||
}
|
||
{
|
||
int laneIndex = -1;
|
||
std::vector<PlotView::WeakPeakBuild> items;
|
||
if (m_plot.TakePendingWeakPeakBuild(laneIndex, items))
|
||
{
|
||
for (const PlotView::WeakPeakBuild& item : items)
|
||
{
|
||
DerivedSignalRequest request;
|
||
request.fileIndex = item.fileIndex;
|
||
request.signalIndex = item.signalIndex;
|
||
request.amountPercent = item.amountPercent;
|
||
request.iterations = item.iterations;
|
||
request.weakPeak = true;
|
||
requests.push_back(request);
|
||
}
|
||
}
|
||
}
|
||
if (requests.empty())
|
||
{
|
||
return false;
|
||
}
|
||
if (m_tabs.empty())
|
||
{
|
||
return false;
|
||
}
|
||
|
||
// **同一文件内必须按下标降序处理**:每次插入只让插入点之后的信号下标 +1,
|
||
// 降序处理可保证尚未处理的信号下标始终有效(升序会把新插入的信号当成原信号)。
|
||
std::sort(requests.begin(), requests.end(),
|
||
[](const DerivedSignalRequest& a, const DerivedSignalRequest& b)
|
||
{
|
||
if (a.fileIndex != b.fileIndex)
|
||
{
|
||
return a.fileIndex < b.fileIndex;
|
||
}
|
||
return a.signalIndex > b.signalIndex;
|
||
});
|
||
|
||
int built = 0;
|
||
DerivedSignalRequest last;
|
||
for (const DerivedSignalRequest& request : requests)
|
||
{
|
||
if (BuildDerivedSignal(request, true))
|
||
{
|
||
++built;
|
||
last = request;
|
||
}
|
||
}
|
||
|
||
if (built > 0)
|
||
{
|
||
std::wstring text = L"已构建 " + std::to_wstring(built) + L" 条 ";
|
||
if (last.weakPeak)
|
||
{
|
||
text += L"弱峰调整 " + std::to_wstring(last.amountPercent) + L"% " +
|
||
std::to_wstring(last.iterations) + L"次迭代信号。";
|
||
}
|
||
else
|
||
{
|
||
text += std::to_wstring(last.points) + L"点均值 " +
|
||
std::to_wstring(last.iterations) + L"次迭代滤波信号。";
|
||
}
|
||
SetStatusMessage(text, false);
|
||
}
|
||
return built > 0;
|
||
}
|
||
|
||
void Application::ShowTabContextMenu(const TabBarResult& result)
|
||
{
|
||
if (result.index < 0 || result.index >= static_cast<int>(m_tabs.size()))
|
||
{
|
||
return;
|
||
}
|
||
|
||
m_tabMenuIndex = result.index;
|
||
|
||
std::vector<MenuItem> items;
|
||
|
||
MenuItem header;
|
||
header.kind = MenuItemKind::HEADER;
|
||
header.label = m_tabs[static_cast<std::size_t>(result.index)].Name();
|
||
items.push_back(header);
|
||
|
||
MenuItem create;
|
||
create.label = L"新建标签页";
|
||
create.id = kMenuTabNew;
|
||
items.push_back(create);
|
||
|
||
MenuItem close;
|
||
close.label = L"关闭该标签页";
|
||
close.id = kMenuTabClose;
|
||
close.enabled = (m_tabs.size() > 1);
|
||
items.push_back(close);
|
||
|
||
MenuItem closeOthers;
|
||
closeOthers.label = L"关闭其它标签页";
|
||
closeOthers.id = kMenuTabCloseOthers;
|
||
closeOthers.enabled = (m_tabs.size() > 1);
|
||
items.push_back(closeOthers);
|
||
|
||
// 菜单绘制在客户区内,因此把屏幕坐标换算为客户区坐标。
|
||
POINT anchor = {result.screenX, result.screenY};
|
||
ScreenToClient(m_hwnd, &anchor);
|
||
|
||
RECT client = {};
|
||
GetClientRect(m_hwnd, &client);
|
||
const gd::RectF bounds(0.0f, 0.0f, static_cast<float>(client.right - client.left),
|
||
static_cast<float>(client.bottom - client.top));
|
||
|
||
m_menu.Open(MenuScope::TAB_BAR, bounds, anchor.x, anchor.y, items);
|
||
}
|
||
|
||
void Application::ShowPlotContextMenu(int laneIndex, int x, int y)
|
||
{
|
||
if (laneIndex < 0)
|
||
{
|
||
return;
|
||
}
|
||
|
||
m_plotMenuLane = laneIndex;
|
||
|
||
std::vector<MenuItem> items;
|
||
|
||
MenuItem header;
|
||
header.kind = MenuItemKind::HEADER;
|
||
header.label = L"网格 " + std::to_wstring(laneIndex + 1);
|
||
items.push_back(header);
|
||
|
||
// 当前网格的 Y 轴上下限值直接在菜单内的输入框里显示,单击即可就地编辑(菜单不关闭)。
|
||
double laneYMin = 0.0;
|
||
double laneYMax = 1.0;
|
||
m_plot.LaneRange(laneIndex, laneYMin, laneYMax);
|
||
|
||
MenuItem maxItem;
|
||
maxItem.kind = MenuItemKind::EDIT;
|
||
maxItem.label = L"Y 轴最大值";
|
||
maxItem.id = PLOT_CMD_LANE_Y_MAX;
|
||
maxItem.value = FormatValue(laneYMax);
|
||
maxItem.filter = EditFilter::NUMBER;
|
||
items.push_back(maxItem);
|
||
|
||
MenuItem minItem;
|
||
minItem.kind = MenuItemKind::EDIT;
|
||
minItem.label = L"Y 轴最小值";
|
||
minItem.id = PLOT_CMD_LANE_Y_MIN;
|
||
minItem.value = FormatValue(laneYMin);
|
||
minItem.filter = EditFilter::NUMBER;
|
||
items.push_back(minItem);
|
||
|
||
// 选区自适应(切换项):勾选后每次本网格的时间轴选区放大都会自动匹配 Y 轴幅值。
|
||
MenuItem autoViewItem;
|
||
autoViewItem.label = L"选区 Y 轴自适应";
|
||
autoViewItem.id = PLOT_CMD_LANE_AUTO_VIEW;
|
||
autoViewItem.checked = m_plot.LaneAutoFitToSelection(laneIndex);
|
||
items.push_back(autoViewItem);
|
||
|
||
// 时间轴自适应到本网格信号范围(原先位于分隔线下方,现移到分隔线上方)。
|
||
MenuItem fitLaneTime;
|
||
fitLaneTime.label = L"按网格时间轴展开";
|
||
fitLaneTime.id = PLOT_CMD_LANE_FIT_TIME;
|
||
items.push_back(fitLaneTime);
|
||
|
||
// 「拆分当前网格」:把该网格内的信号按个拆成独立网格,插到其下方依次排列。
|
||
// 可点击时在后方追加「(N个信号)」说明将拆出的网格数;只有 1 条信号时置灰且不加尾缀。
|
||
MenuItem splitLane;
|
||
const std::size_t laneSignalCount = m_plot.LaneSignalCount(laneIndex);
|
||
splitLane.label = (laneSignalCount > 1)
|
||
? (L"拆分当前网格(" + std::to_wstring(laneSignalCount) + L"个信号)")
|
||
: L"拆分当前网格";
|
||
splitLane.id = PLOT_CMD_LANE_SPLIT;
|
||
splitLane.enabled = laneSignalCount > 1;
|
||
items.push_back(splitLane);
|
||
|
||
// 实验性功能(二级菜单):横线上方,鼠标悬停即向右展开。
|
||
MenuItem experimental; experimental.kind = MenuItemKind::SUBMENU;
|
||
experimental.label = L"实验性功能";
|
||
{
|
||
MenuItem edgeCount;
|
||
edgeCount.label = L"边沿计数";
|
||
edgeCount.id = PLOT_CMD_LANE_EDGE_COUNT;
|
||
edgeCount.checked = m_plot.LaneEdgeCount(laneIndex);
|
||
experimental.submenu.push_back(edgeCount);
|
||
|
||
MenuItem edgeCenter;
|
||
edgeCenter.label = L"边沿计数(中心轴)";
|
||
edgeCenter.id = PLOT_CMD_LANE_EDGE_CENTER;
|
||
edgeCenter.checked = m_plot.LaneEdgeCenterCount(laneIndex);
|
||
experimental.submenu.push_back(edgeCenter);
|
||
|
||
MenuItem meanFilter;
|
||
meanFilter.label = L"均值滤波";
|
||
meanFilter.id = PLOT_CMD_LANE_MEAN_FILTER;
|
||
meanFilter.checked = m_plot.LaneMeanFilter(laneIndex);
|
||
experimental.submenu.push_back(meanFilter);
|
||
|
||
MenuItem weakPeak;
|
||
weakPeak.label = L"弱峰调整";
|
||
weakPeak.id = PLOT_CMD_LANE_WEAK_PEAK;
|
||
weakPeak.checked = m_plot.LaneWeakPeak(laneIndex);
|
||
experimental.submenu.push_back(weakPeak);
|
||
}
|
||
items.push_back(experimental);
|
||
|
||
MenuItem separator;
|
||
separator.kind = MenuItemKind::SEPARATOR;
|
||
items.push_back(separator);
|
||
|
||
// 全局开关:一次性打开/关闭全部网格各自的「选区 Y 轴自适应」。
|
||
MenuItem autoFitAllItem;
|
||
autoFitAllItem.label = L"选区 Y 轴自适应(全局)";
|
||
autoFitAllItem.id = PLOT_CMD_AUTO_FIT_ALL;
|
||
autoFitAllItem.checked = m_plot.AllLanesAutoFitToSelection();
|
||
items.push_back(autoFitAllItem);
|
||
|
||
MenuItem fitItem;
|
||
fitItem.label = L"全部自适应";
|
||
fitItem.id = PLOT_CMD_FIT_ALL;
|
||
items.push_back(fitItem);
|
||
|
||
RECT client = {};
|
||
GetClientRect(m_hwnd, &client);
|
||
const gd::RectF bounds(0.0f, 0.0f, static_cast<float>(client.right - client.left),
|
||
static_cast<float>(client.bottom - client.top));
|
||
|
||
m_menu.Open(MenuScope::PLOT_VIEW, bounds, x, y, items);
|
||
}
|
||
|
||
void Application::RefreshPlotMenuValues(int laneIndex)
|
||
{
|
||
if (laneIndex < 0)
|
||
{
|
||
return;
|
||
}
|
||
|
||
double laneYMin = 0.0;
|
||
double laneYMax = 1.0;
|
||
m_plot.LaneRange(laneIndex, laneYMin, laneYMax);
|
||
m_menu.UpdateItemValue(PLOT_CMD_LANE_Y_MAX, FormatValue(laneYMax));
|
||
m_menu.UpdateItemValue(PLOT_CMD_LANE_Y_MIN, FormatValue(laneYMin));
|
||
|
||
// 「选区 Y 轴自适应」(本网格)与「(全局)」都是切换项,实时反映各自的勾选状态。
|
||
m_menu.UpdateItemLabel(PLOT_CMD_LANE_AUTO_VIEW, L"选区 Y 轴自适应",
|
||
m_plot.LaneAutoFitToSelection(laneIndex), true);
|
||
m_menu.UpdateItemLabel(PLOT_CMD_AUTO_FIT_ALL, L"选区 Y 轴自适应(全局)",
|
||
m_plot.AllLanesAutoFitToSelection(), true);
|
||
}
|
||
|
||
void Application::DispatchMenuResult(const MenuResult& result)
|
||
{
|
||
if (!result.valid)
|
||
{
|
||
return;
|
||
}
|
||
|
||
switch (result.scope)
|
||
{
|
||
case MenuScope::SIGNAL_PANEL:
|
||
{
|
||
const bool needRelayout = m_panel.OnMenuCommand(result);
|
||
if (needRelayout)
|
||
{
|
||
Relayout();
|
||
}
|
||
InvalidateRect(m_hwnd, nullptr, FALSE);
|
||
break;
|
||
}
|
||
case MenuScope::PLOT_VIEW:
|
||
{
|
||
// 菜单内联输入框的提交:就地更新网格 Y 轴上下限,菜单保持打开,
|
||
// 并把夹取后的真实值回写到菜单上(输入值可能被合法区间修正)。
|
||
if (result.editCommit)
|
||
{
|
||
const int laneIndex = m_plotMenuLane;
|
||
if (laneIndex >= 0)
|
||
{
|
||
m_plot.ApplyLaneValueText(laneIndex, result.id == PLOT_CMD_LANE_Y_MAX, result.editText);
|
||
RefreshPlotMenuValues(laneIndex);
|
||
}
|
||
SetFocus(m_hwnd);
|
||
InvalidateRect(m_hwnd, nullptr, FALSE);
|
||
break;
|
||
}
|
||
|
||
const int laneIndex = m_plotMenuLane;
|
||
m_plotMenuLane = -1;
|
||
switch (result.id)
|
||
{
|
||
case PLOT_CMD_LANE_AUTO_VIEW:
|
||
// 切换本网格的「选区 Y 轴自适应」。
|
||
m_plot.ToggleLaneAutoFitToSelection(laneIndex);
|
||
break;
|
||
case PLOT_CMD_AUTO_FIT_ALL:
|
||
{
|
||
// 全局切换:一次打开/关闭全部网格各自的开关;打开时立即生效一次。
|
||
const bool enable = !m_plot.AllLanesAutoFitToSelection();
|
||
m_plot.SetAllLanesAutoFitToSelection(enable, true);
|
||
break;
|
||
}
|
||
case PLOT_CMD_LANE_FIT_TIME:
|
||
// 「按网格时间轴展开」:展开时间轴后同时把该网格的 Y 轴自适应一次。
|
||
m_plot.FitTimeToLane(laneIndex);
|
||
break;
|
||
case PLOT_CMD_LANE_EDGE_COUNT:
|
||
// 「实验性功能 → 边沿计数」:切换本网格的横向光标与统计面板。
|
||
m_plot.ToggleLaneEdgeCount(laneIndex);
|
||
break;
|
||
case PLOT_CMD_LANE_EDGE_CENTER:
|
||
// 「实验性功能 → 边沿计数(中心轴)」:切换中心平均值曲线与统计面板。
|
||
m_plot.ToggleLaneEdgeCenterCount(laneIndex);
|
||
break;
|
||
case PLOT_CMD_LANE_MEAN_FILTER:
|
||
// 「实验性功能 → 均值滤波」:切换均值滤波曲线与参数面板。
|
||
m_plot.ToggleLaneMeanFilter(laneIndex);
|
||
break;
|
||
case PLOT_CMD_LANE_WEAK_PEAK:
|
||
// 「实验性功能 → 弱峰调整」:切换弱峰调整曲线与参数面板。
|
||
m_plot.ToggleLaneWeakPeak(laneIndex);
|
||
break;
|
||
case PLOT_CMD_LANE_SPLIT:
|
||
// 「拆分当前网格」:信号按个拆成独立网格,新网格顺次插入其下方。
|
||
m_plot.SplitLane(laneIndex);
|
||
break;
|
||
case PLOT_CMD_FIT_ALL:
|
||
m_plot.FitAll();
|
||
break;
|
||
default:
|
||
break;
|
||
}
|
||
SetFocus(m_hwnd);
|
||
InvalidateRect(m_hwnd, nullptr, TRUE);
|
||
break;
|
||
}
|
||
case MenuScope::TAB_BAR:
|
||
{
|
||
const int index = m_tabMenuIndex;
|
||
if (result.id == kMenuTabNew)
|
||
{
|
||
NewTab(true);
|
||
}
|
||
else if (result.id == kMenuTabClose)
|
||
{
|
||
CloseTab(index);
|
||
}
|
||
else if (result.id == kMenuTabCloseOthers)
|
||
{
|
||
if (index >= 0 && index < static_cast<int>(m_tabs.size()) && m_tabs.size() > 1)
|
||
{
|
||
const TabConfig keep = m_tabs[static_cast<std::size_t>(index)];
|
||
m_tabs.clear();
|
||
m_tabs.push_back(keep);
|
||
SetActiveTab(0);
|
||
}
|
||
}
|
||
m_tabMenuIndex = -1;
|
||
InvalidateRect(m_hwnd, nullptr, TRUE);
|
||
break;
|
||
}
|
||
case MenuScope::NONE:
|
||
default:
|
||
break;
|
||
}
|
||
}
|
||
|
||
LRESULT Application::HandleMessage(HWND hwnd, UINT message, WPARAM wParam, LPARAM lParam)
|
||
{
|
||
// 除鼠标移动与鼠标移出(由下面的分支按“是否只影响绘图区”精确判断)之外,
|
||
// 其余消息都可能改变标签栏 / 工具栏 / 面板的外观,统一标记装饰区域为脏。
|
||
// WM_PAINT / WM_TIMER 的重绘范围由各自的处理分支判断,这里不做标记。
|
||
if (message != WM_PAINT && message != WM_MOUSEMOVE && message != WM_MOUSELEAVE &&
|
||
message != WM_SETCURSOR && message != WM_NCHITTEST && message != WM_TIMER &&
|
||
message != WM_ERASEBKGND && message != kMessagePlotDerivedReady)
|
||
{
|
||
m_chromeDirty = true;
|
||
}
|
||
|
||
// 菜单打开时优先消费鼠标与键盘事件,形成模态交互。
|
||
if (m_menu.IsOpen())
|
||
{
|
||
// 菜单接管输入后,取消底层组件可能仍在进行的拖拽。
|
||
if (m_activeTarget != Target::NONE || m_draggingSplitter)
|
||
{
|
||
ReleaseCapture();
|
||
m_activeTarget = Target::NONE;
|
||
m_draggingSplitter = false;
|
||
m_panel.OnLButtonUp(0, 0);
|
||
m_plot.OnLButtonUp(0, 0);
|
||
}
|
||
|
||
switch (message)
|
||
{
|
||
case WM_MOUSEMOVE:
|
||
// 菜单内联输入框拖拽框选优先(不改变悬停项,避免选区被中途清掉)。
|
||
if (m_menu.IsEditing() && m_activeTarget == Target::EDIT_FIELD)
|
||
{
|
||
if (m_menu.OnEditMouseMove(GET_X_LPARAM(lParam)))
|
||
{
|
||
InvalidateRect(hwnd, nullptr, FALSE);
|
||
}
|
||
return 0;
|
||
}
|
||
if (m_menu.OnMouseMove(GET_X_LPARAM(lParam), GET_Y_LPARAM(lParam)))
|
||
{
|
||
InvalidateRect(hwnd, nullptr, FALSE);
|
||
}
|
||
return 0;
|
||
case WM_LBUTTONDOWN:
|
||
{
|
||
const int downX = GET_X_LPARAM(lParam);
|
||
const int downY = GET_Y_LPARAM(lParam);
|
||
|
||
// 点击菜单内联输入框:定位插入符并开始拖拽框选(与常规输入框一致)。
|
||
if (m_menu.IsOnEditField(downX, downY))
|
||
{
|
||
m_menu.OnEditMouseDown(downX, downY);
|
||
SetCapture(hwnd);
|
||
m_activeTarget = Target::EDIT_FIELD;
|
||
InvalidateRect(hwnd, nullptr, FALSE);
|
||
return 0;
|
||
}
|
||
|
||
MenuResult result;
|
||
m_menu.OnLButtonDown(downX, downY, result);
|
||
InvalidateRect(hwnd, nullptr, FALSE);
|
||
// 点击其它位置会先提交正在编辑的数值,需要立即落到数据层并刷新菜单显示。
|
||
if (result.valid)
|
||
{
|
||
DispatchMenuResult(result);
|
||
}
|
||
return 0;
|
||
}
|
||
case WM_LBUTTONUP:
|
||
{
|
||
if (m_menu.IsEditing() && m_activeTarget == Target::EDIT_FIELD)
|
||
{
|
||
ReleaseCapture();
|
||
m_activeTarget = Target::NONE;
|
||
m_menu.OnEditMouseUp();
|
||
InvalidateRect(hwnd, nullptr, FALSE);
|
||
return 0;
|
||
}
|
||
|
||
MenuResult result;
|
||
m_menu.OnLButtonUp(GET_X_LPARAM(lParam), GET_Y_LPARAM(lParam), result);
|
||
InvalidateRect(hwnd, nullptr, FALSE);
|
||
DispatchMenuResult(result);
|
||
return 0;
|
||
}
|
||
case WM_CHAR:
|
||
{
|
||
// 菜单内联输入框的字符输入(数值/名称),回车在此提交。
|
||
MenuResult result;
|
||
if (m_menu.OnChar(static_cast<wchar_t>(wParam), result))
|
||
{
|
||
InvalidateRect(hwnd, nullptr, FALSE);
|
||
}
|
||
if (result.valid)
|
||
{
|
||
DispatchMenuResult(result);
|
||
}
|
||
return 0;
|
||
}
|
||
case WM_RBUTTONDOWN:
|
||
case WM_MBUTTONDOWN:
|
||
case WM_MOUSEWHEEL:
|
||
m_menu.Close();
|
||
InvalidateRect(hwnd, nullptr, FALSE);
|
||
return 0;
|
||
case WM_KEYDOWN:
|
||
{
|
||
MenuResult result;
|
||
m_menu.OnKeyDown(wParam, result);
|
||
InvalidateRect(hwnd, nullptr, FALSE);
|
||
if (result.valid)
|
||
{
|
||
DispatchMenuResult(result);
|
||
}
|
||
return 0;
|
||
}
|
||
case WM_KILLFOCUS:
|
||
case WM_CANCELMODE:
|
||
m_menu.Close();
|
||
InvalidateRect(hwnd, nullptr, FALSE);
|
||
return 0;
|
||
case WM_SETCURSOR:
|
||
SetCursor(LoadCursorW(nullptr, IDC_ARROW));
|
||
return TRUE;
|
||
default:
|
||
break;
|
||
}
|
||
}
|
||
|
||
// 拾色器打开时接管鼠标与键盘(优先级低于弹出菜单、高于常规组件)。
|
||
if (m_colorPicker.IsOpen())
|
||
{
|
||
bool repaint = false;
|
||
bool consume = false;
|
||
|
||
switch (message)
|
||
{
|
||
case WM_MOUSEMOVE:
|
||
repaint = m_colorPicker.OnMouseMove(GET_X_LPARAM(lParam), GET_Y_LPARAM(lParam));
|
||
consume = true;
|
||
break;
|
||
case WM_LBUTTONDOWN:
|
||
{
|
||
const int pickX = GET_X_LPARAM(lParam);
|
||
const int pickY = GET_Y_LPARAM(lParam);
|
||
|
||
// 拾色器接管输入后,取消底层组件可能仍在进行的拖拽。
|
||
m_panel.OnLButtonUp(0, 0);
|
||
m_plot.OnLButtonUp(0, 0);
|
||
m_activeTarget = Target::NONE;
|
||
m_draggingSplitter = false;
|
||
|
||
if (m_colorPicker.ContainsPoint(pickX, pickY))
|
||
{
|
||
// 面板内部:开始拖动滑块,并吃掉本次点击,避免穿透到下方图层。
|
||
m_colorPicker.OnLButtonDown(pickX, pickY);
|
||
SetCapture(hwnd);
|
||
consume = true;
|
||
}
|
||
else
|
||
{
|
||
// 面板外部:收起面板;若落点在信号面板内则放行本次点击,
|
||
// 便于用户在展开状态下直接改选预设色、样式或拖动滑块。
|
||
m_colorPicker.Close();
|
||
if (!m_panel.HitTest(pickX, pickY))
|
||
{
|
||
InvalidateRect(hwnd, nullptr, FALSE);
|
||
return 0;
|
||
}
|
||
}
|
||
repaint = true;
|
||
break;
|
||
}
|
||
case WM_SETCURSOR:
|
||
{
|
||
if (LOWORD(lParam) == HTCLIENT)
|
||
{
|
||
POINT point = {};
|
||
GetCursorPos(&point);
|
||
ScreenToClient(hwnd, &point);
|
||
|
||
HCURSOR cursor = m_colorPicker.QueryCursor(point.x, point.y);
|
||
SetCursor(cursor != nullptr ? cursor : LoadCursorW(nullptr, IDC_ARROW));
|
||
return TRUE;
|
||
}
|
||
break;
|
||
}
|
||
case WM_LBUTTONUP:
|
||
m_colorPicker.OnLButtonUp();
|
||
ReleaseCapture();
|
||
repaint = true;
|
||
consume = true;
|
||
break;
|
||
case WM_KEYDOWN:
|
||
m_colorPicker.OnKeyDown(wParam);
|
||
repaint = true;
|
||
consume = true;
|
||
break;
|
||
case WM_CHAR:
|
||
// 字符输入转发给拾色器,用于 RGB 分量与十六进制色号的内联编辑。
|
||
repaint = m_colorPicker.OnChar(static_cast<wchar_t>(wParam));
|
||
consume = true;
|
||
break;
|
||
case WM_RBUTTONDOWN:
|
||
case WM_MBUTTONDOWN:
|
||
case WM_MOUSEWHEEL:
|
||
m_colorPicker.Close();
|
||
repaint = true;
|
||
consume = true;
|
||
break;
|
||
default:
|
||
break;
|
||
}
|
||
|
||
// 取色变化实时应用到当前选中信号。
|
||
std::uint32_t pickedColor = 0;
|
||
if (m_colorPicker.TakeChanged(pickedColor))
|
||
{
|
||
m_panel.ApplyColor(pickedColor);
|
||
repaint = true;
|
||
}
|
||
|
||
if (repaint)
|
||
{
|
||
InvalidateRect(hwnd, nullptr, FALSE);
|
||
}
|
||
if (consume)
|
||
{
|
||
return 0;
|
||
}
|
||
}
|
||
|
||
switch (message)
|
||
{
|
||
case kMessagePlotDerivedReady:
|
||
// 后台派生曲线计算完成:只失效绘图区,下一帧即可取到最新曲线(双缓冲切换)。
|
||
InvalidatePlotRect(hwnd, m_plotRect);
|
||
return 0;
|
||
case kMessagePlotLowMemory:
|
||
// 可用内存**严重不足**(连一条预览曲线本身都装不下):绘图区已放弃本次派生计算,
|
||
// 这里给出一次明确的告警。界面保持可用,绝不会因为内存不足而崩溃。
|
||
SetStatusMessage(L"可用内存不足,已暂停派生预览曲线与相关统计的计算。\n"
|
||
L"请关闭部分文件分组或信号后重试。",
|
||
true);
|
||
InvalidatePlotRect(hwnd, m_plotRect);
|
||
return 0;
|
||
case WM_SIZE:
|
||
{
|
||
ReleaseBackBuffer();
|
||
Relayout();
|
||
InvalidateRect(hwnd, nullptr, FALSE);
|
||
return 0;
|
||
}
|
||
case WM_GETMINMAXINFO:
|
||
{
|
||
MINMAXINFO* info = reinterpret_cast<MINMAXINFO*>(lParam);
|
||
info->ptMinTrackSize.x = static_cast<LONG>(ScaleOf(static_cast<float>(kMinClientWidth), m_scale));
|
||
info->ptMinTrackSize.y = static_cast<LONG>(ScaleOf(static_cast<float>(kMinClientHeight), m_scale));
|
||
return 0;
|
||
}
|
||
case WM_DPICHANGED:
|
||
{
|
||
const UINT dpi = HIWORD(wParam);
|
||
const RECT* suggested = reinterpret_cast<const RECT*>(lParam);
|
||
if (suggested != nullptr)
|
||
{
|
||
SetWindowPos(hwnd, nullptr, suggested->left, suggested->top,
|
||
suggested->right - suggested->left, suggested->bottom - suggested->top,
|
||
SWP_NOZORDER | SWP_NOACTIVATE);
|
||
}
|
||
ApplyDpi(dpi);
|
||
InvalidateRect(hwnd, nullptr, FALSE);
|
||
return 0;
|
||
}
|
||
case WM_ERASEBKGND:
|
||
return 1;
|
||
case WM_PAINT:
|
||
{
|
||
// BeginPaint 会清空更新区域,因此必须先取出本次待重绘的范围:
|
||
// 只有完全落在绘图区内的更新区域才允许走“局部回送”的快路径,
|
||
// 其余情况(窗口首次显示、其它窗口遮挡后露出、DPI 变化等)必须整帧重绘。
|
||
RECT update = {};
|
||
const bool hasUpdate = GetUpdateRect(hwnd, &update, FALSE) != FALSE;
|
||
if (hasUpdate)
|
||
{
|
||
const bool insidePlot =
|
||
update.left >= static_cast<LONG>(std::floor(m_plotRect.X)) &&
|
||
update.top >= static_cast<LONG>(std::floor(m_plotRect.Y)) &&
|
||
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)
|
||
{
|
||
m_chromeDirty = true;
|
||
}
|
||
}
|
||
|
||
PAINTSTRUCT paintStruct = {};
|
||
BeginPaint(hwnd, &paintStruct);
|
||
Render();
|
||
EndPaint(hwnd, &paintStruct);
|
||
return 0;
|
||
}
|
||
case WM_MOUSEMOVE:
|
||
{
|
||
if (!m_trackingLeave)
|
||
{
|
||
TRACKMOUSEEVENT track = {};
|
||
track.cbSize = sizeof(track);
|
||
track.dwFlags = TME_LEAVE;
|
||
track.hwndTrack = hwnd;
|
||
TrackMouseEvent(&track);
|
||
m_trackingLeave = true;
|
||
}
|
||
|
||
const int x = GET_X_LPARAM(lParam);
|
||
const int y = GET_Y_LPARAM(lParam);
|
||
const bool buttonDown = (wParam & (MK_LBUTTON | MK_RBUTTON | MK_MBUTTON)) != 0;
|
||
|
||
// 仅当本次移动不会影响绘图区之外的区域时,才跳过装饰区域的重绘:
|
||
// 正在拖拽绘图区(鼠标可能被捕获到绘图区外),或鼠标始终停留在绘图区内。
|
||
const bool inPlot = UiPointInRect(m_plotRect, static_cast<float>(x), static_cast<float>(y));
|
||
const bool plotOnlyMove = (m_activeTarget == Target::PLOT) || (inPlot && m_mouseOverPlot);
|
||
if (!plotOnlyMove)
|
||
{
|
||
m_chromeDirty = true;
|
||
}
|
||
m_mouseOverPlot = inPlot;
|
||
|
||
if (HandleMouseMove(x, y, buttonDown))
|
||
{
|
||
if (plotOnlyMove && !m_chromeDirty)
|
||
{
|
||
// 只失效绘图区,配合 WM_PAINT 中的判断即可走局部回送快路径。
|
||
InvalidatePlotRect(hwnd, m_plotRect);
|
||
}
|
||
else
|
||
{
|
||
InvalidateRect(hwnd, nullptr, FALSE);
|
||
}
|
||
}
|
||
// 拖动(鼠标捕获)期间 Windows 不会派发 WM_SETCURSOR,
|
||
// 因此需要主动刷新一次,否则光标形状会一直停在按下的状态。
|
||
if (buttonDown)
|
||
{
|
||
UpdateDragCursor(x, y);
|
||
}
|
||
return 0;
|
||
}
|
||
case WM_MOUSELEAVE:
|
||
{
|
||
m_trackingLeave = false;
|
||
// 只有标签栏 / 工具栏 / 面板的悬停态变化才需要重绘装饰区域;
|
||
// 绘图区自身的悬停态变化(提示浮层、信号光晕)只需重绘绘图区,
|
||
// 这样拖拽光标时鼠标短暂移出窗口也不会退化成整帧重绘。
|
||
bool chromeChanged = m_tabBar.OnMouseLeave();
|
||
chromeChanged = m_toolbar.OnMouseLeave() || chromeChanged;
|
||
chromeChanged = m_panel.OnMouseLeave() || chromeChanged;
|
||
const bool plotChanged = m_plot.OnMouseLeave();
|
||
m_mouseOverPlot = false;
|
||
|
||
if (chromeChanged)
|
||
{
|
||
m_chromeDirty = true;
|
||
}
|
||
|
||
if (plotChanged && !chromeChanged && !m_chromeDirty)
|
||
{
|
||
InvalidatePlotRect(hwnd, m_plotRect);
|
||
}
|
||
else if (chromeChanged || plotChanged)
|
||
{
|
||
InvalidateRect(hwnd, nullptr, FALSE);
|
||
}
|
||
return 0;
|
||
}
|
||
case WM_LBUTTONDOWN:
|
||
{
|
||
SetFocus(hwnd);
|
||
const int x = GET_X_LPARAM(lParam);
|
||
const int y = GET_Y_LPARAM(lParam);
|
||
|
||
// 编辑态下点击输入框内部:定位插入符并开始拖拽框选,不再触发底层控件的选中/拖拽。
|
||
if (m_tabBar.OnEditMouseDown(x, y) || m_panel.OnEditMouseDown(x, y) ||
|
||
m_plot.OnEditMouseDown(x, y) || m_colorPicker.OnEditMouseDown(x, y))
|
||
{
|
||
SetCapture(hwnd);
|
||
m_activeTarget = Target::EDIT_FIELD;
|
||
InvalidateRect(hwnd, nullptr, FALSE);
|
||
return 0;
|
||
}
|
||
|
||
// 标签栏重命名处于编辑态时,点击输入框之外的**任何**位置都提交并退出编辑态。
|
||
// (面板与绘图区的输入框会在各自的 OnLButtonDown 内部提交,但点击它们之外
|
||
// 的区域时宿主根本不会把消息转给标签栏,重命名输入框会一直停留在编辑态。)
|
||
if (m_tabBar.IsEditing())
|
||
{
|
||
TabBarResult renameResult;
|
||
m_tabBar.OnEditKey(VK_RETURN, renameResult);
|
||
OnTabBarResult(renameResult);
|
||
InvalidateRect(hwnd, nullptr, FALSE);
|
||
}
|
||
|
||
// 内联数值输入处于活动态时,点击绘图区之外视为提交(与线宽输入一致)。
|
||
if (m_plot.IsEditing() && !m_plot.HitTest(x, y))
|
||
{
|
||
m_plot.OnEditKey(VK_RETURN);
|
||
ApplyPendingRename();
|
||
}
|
||
|
||
// 点击面板之外的位置时收起面板内展开的自定义颜色面板。
|
||
if (!m_panel.HitTest(x, y))
|
||
{
|
||
if (m_panel.CloseTransientUi())
|
||
{
|
||
InvalidateRect(hwnd, nullptr, FALSE);
|
||
}
|
||
}
|
||
|
||
if (UiPointInRect(m_splitterRect, static_cast<float>(x), static_cast<float>(y)))
|
||
{
|
||
SetCapture(hwnd);
|
||
m_draggingSplitter = true;
|
||
m_activeTarget = Target::SPLITTER;
|
||
m_splitterGrabOffset = x - static_cast<int>(m_splitterRect.X);
|
||
return 0;
|
||
}
|
||
|
||
if (m_tabBar.HitTest(x, y))
|
||
{
|
||
SetCapture(hwnd);
|
||
m_activeTarget = Target::TAB_BAR;
|
||
m_tabBar.OnLButtonDown(x, y);
|
||
InvalidateRect(hwnd, nullptr, FALSE);
|
||
return 0;
|
||
}
|
||
|
||
if (m_toolbar.HitTest(x, y))
|
||
{
|
||
SetCapture(hwnd);
|
||
m_activeTarget = Target::TOOLBAR;
|
||
ToolbarAction action = ToolbarAction::NONE;
|
||
m_toolbar.OnLButtonDown(x, y, action);
|
||
InvalidateRect(hwnd, nullptr, FALSE);
|
||
return 0;
|
||
}
|
||
|
||
if (m_panel.HitTest(x, y))
|
||
{
|
||
SetCapture(hwnd);
|
||
m_activeTarget = Target::PANEL;
|
||
if (m_panel.OnLButtonDown(x, y))
|
||
{
|
||
InvalidateRect(hwnd, nullptr, TRUE);
|
||
}
|
||
return 0;
|
||
}
|
||
|
||
if (m_plot.HitTest(x, y))
|
||
{
|
||
SetCapture(hwnd);
|
||
m_activeTarget = Target::PLOT;
|
||
if (m_plot.OnLButtonDown(x, y))
|
||
{
|
||
// 绘图区内的点击也可能提交图例改名,需要立刻落到数据层。
|
||
ApplyPendingRename();
|
||
// 「构建为新信号」按钮(均值滤波 / 弱峰调整):立即构建新信号并同步各视图。
|
||
ApplyPendingDerivedBuild();
|
||
InvalidateRect(hwnd, nullptr, FALSE);
|
||
}
|
||
// 拖动期间 Windows 不再派发 WM_SETCURSOR,需在按下后立即刷新一次,
|
||
// 使"按下但尚未拖动"时能立刻看到十字/方向箭头提示。
|
||
UpdateDragCursor(x, y);
|
||
return 0;
|
||
}
|
||
|
||
m_activeTarget = Target::NONE;
|
||
return 0;
|
||
}
|
||
case WM_LBUTTONUP:
|
||
{
|
||
const int x = GET_X_LPARAM(lParam);
|
||
const int y = GET_Y_LPARAM(lParam);
|
||
ReleaseCapture();
|
||
|
||
switch (m_activeTarget)
|
||
{
|
||
case Target::SPLITTER:
|
||
m_draggingSplitter = false;
|
||
break;
|
||
case Target::EDIT_FIELD:
|
||
{
|
||
// 结束输入框内的拖拽框选(选区保留)。
|
||
bool changed = m_tabBar.OnEditMouseUp();
|
||
changed = m_panel.OnEditMouseUp(x, y) || changed;
|
||
changed = m_plot.OnEditMouseUp() || changed;
|
||
changed = m_colorPicker.OnEditMouseUp() || changed;
|
||
m_activeTarget = Target::NONE;
|
||
if (changed)
|
||
{
|
||
InvalidateRect(hwnd, nullptr, FALSE);
|
||
}
|
||
return 0;
|
||
}
|
||
case Target::TAB_BAR:
|
||
{
|
||
TabBarResult result;
|
||
m_tabBar.OnLButtonUp(x, y, result);
|
||
m_activeTarget = Target::NONE;
|
||
OnTabBarResult(result);
|
||
InvalidateRect(hwnd, nullptr, FALSE);
|
||
return 0;
|
||
}
|
||
case Target::TOOLBAR:
|
||
{
|
||
ToolbarAction action = ToolbarAction::NONE;
|
||
m_toolbar.OnLButtonUp(x, y, action);
|
||
m_activeTarget = Target::NONE;
|
||
if (action != ToolbarAction::NONE)
|
||
{
|
||
ExecuteToolbarAction(action);
|
||
return 0;
|
||
}
|
||
break;
|
||
}
|
||
case Target::PANEL:
|
||
m_panel.OnLButtonUp(x, y);
|
||
break;
|
||
case Target::PLOT:
|
||
m_plot.OnLButtonUp(x, y);
|
||
break;
|
||
case Target::NONE:
|
||
default:
|
||
break;
|
||
}
|
||
|
||
m_activeTarget = Target::NONE;
|
||
InvalidateRect(hwnd, nullptr, TRUE);
|
||
return 0;
|
||
}
|
||
case WM_MBUTTONDOWN:
|
||
{
|
||
// 鼠标中键:在绘图区内按下即进入自由平移(任何缩放模式下都可用)。
|
||
const int x = GET_X_LPARAM(lParam);
|
||
const int y = GET_Y_LPARAM(lParam);
|
||
if (m_plot.IsEditing())
|
||
{
|
||
// 内联输入中:中键视为提交(与左键点外一致)。
|
||
m_plot.OnEditKey(VK_RETURN);
|
||
ApplyPendingRename();
|
||
}
|
||
if (m_plot.HitTest(x, y))
|
||
{
|
||
SetCapture(hwnd);
|
||
m_middlePanning = true;
|
||
m_activeTarget = Target::PLOT;
|
||
if (m_plot.OnMButtonDown(x, y))
|
||
{
|
||
ApplyPendingRename();
|
||
InvalidateRect(hwnd, nullptr, FALSE);
|
||
}
|
||
UpdateDragCursor(x, y);
|
||
}
|
||
return 0;
|
||
}
|
||
case WM_MBUTTONUP:
|
||
{
|
||
if (m_middlePanning)
|
||
{
|
||
m_middlePanning = false;
|
||
m_plot.OnMButtonUp();
|
||
ReleaseCapture();
|
||
m_activeTarget = Target::NONE;
|
||
UpdateDragCursor(GET_X_LPARAM(lParam), GET_Y_LPARAM(lParam));
|
||
InvalidateRect(hwnd, nullptr, TRUE);
|
||
}
|
||
return 0;
|
||
}
|
||
case WM_LBUTTONDBLCLK:
|
||
{
|
||
const int x = GET_X_LPARAM(lParam);
|
||
const int y = GET_Y_LPARAM(lParam);
|
||
bool changed = false;
|
||
if (m_tabBar.HitTest(x, y))
|
||
{
|
||
SetFocus(hwnd);
|
||
changed = m_tabBar.OnDoubleClick(x, y);
|
||
if (m_tabBar.IsEditing())
|
||
{
|
||
// 进入重命名后该标签会加宽,需要重新排布标签栏。
|
||
Relayout();
|
||
}
|
||
}
|
||
else if (m_panel.HitTest(x, y))
|
||
{
|
||
changed = m_panel.OnDoubleClick(x, y);
|
||
}
|
||
else if (m_plot.HitTest(x, y))
|
||
{
|
||
changed = m_plot.OnDoubleClick(x, y);
|
||
}
|
||
if (changed)
|
||
{
|
||
InvalidateRect(hwnd, nullptr, TRUE);
|
||
}
|
||
return 0;
|
||
}
|
||
case WM_RBUTTONDOWN:
|
||
{
|
||
const int x = GET_X_LPARAM(lParam);
|
||
const int y = GET_Y_LPARAM(lParam);
|
||
|
||
// 「选区放大」正在框选时右键 = 放弃当前选区(显示提示),
|
||
// 与 Esc 不同:这里**不退出**选区放大模式,也不弹出网格菜单。
|
||
// 左键仍按着,因此保留鼠标捕获与激活目标,松手时不会命中被放弃的选区。
|
||
if (m_plot.IsRubberSelecting())
|
||
{
|
||
m_plot.CancelDrag(true);
|
||
InvalidateRect(hwnd, nullptr, FALSE);
|
||
return 0;
|
||
}
|
||
|
||
// 编辑态下右键 = 取消选区并把插入符移到文本末尾(常规输入框行为),
|
||
// 其它操作(弹菜单、拖拽)一并被吞掉。
|
||
if (m_tabBar.IsEditing() || m_panel.IsEditing() || m_plot.IsEditing() || m_colorPicker.IsEditing())
|
||
{
|
||
bool changed = m_tabBar.CollapseEditSelectionToEnd();
|
||
changed = m_panel.CollapseEditSelectionToEnd() || changed;
|
||
changed = m_plot.CollapseEditSelectionToEnd() || changed;
|
||
changed = m_colorPicker.CollapseEditSelectionToEnd() || changed;
|
||
if (changed)
|
||
{
|
||
InvalidateRect(hwnd, nullptr, FALSE);
|
||
}
|
||
return 0;
|
||
}
|
||
|
||
POINT cursor = {x, y};
|
||
ClientToScreen(hwnd, &cursor);
|
||
|
||
if (m_tabBar.HitTest(x, y))
|
||
{
|
||
TabBarResult result;
|
||
if (m_tabBar.OnRButtonDown(x, y, cursor.x, cursor.y, result))
|
||
{
|
||
OnTabBarResult(result);
|
||
// 自绘菜单会覆盖在客户区之上,必须请求重绘才能显示。
|
||
InvalidateRect(hwnd, nullptr, FALSE);
|
||
}
|
||
return 0;
|
||
}
|
||
|
||
if (m_panel.HitTest(x, y))
|
||
{
|
||
SetCapture(hwnd);
|
||
m_activeTarget = Target::PANEL;
|
||
if (m_panel.OnRButtonDown(x, y))
|
||
{
|
||
InvalidateRect(hwnd, nullptr, TRUE);
|
||
}
|
||
ReleaseCapture();
|
||
m_activeTarget = Target::NONE;
|
||
return 0;
|
||
}
|
||
|
||
// 绘图区右键:弹出该网格的 Y 轴设置菜单(含上下限输入)。
|
||
if (m_plot.HitTest(x, y))
|
||
{
|
||
const int laneIndex = m_plot.LaneIndexAt(x, y);
|
||
if (laneIndex >= 0)
|
||
{
|
||
// 菜单打开后鼠标移动被菜单独占,绘图区不再收到移动事件;
|
||
// 若不先清掉悬停状态,右键前停留在曲线上的「可拖拽光晕」与提示气泡
|
||
// 会一直挂在原地(用户报告的"进入网格菜单后高亮/文本提示不消失")。
|
||
m_plot.OnMouseLeave();
|
||
ShowPlotContextMenu(laneIndex, x, y);
|
||
InvalidateRect(hwnd, nullptr, FALSE);
|
||
}
|
||
return 0;
|
||
}
|
||
return 0;
|
||
}
|
||
case WM_RBUTTONUP:
|
||
{
|
||
// 左键仍按着时不释放捕获与激活目标(例如框选过程中右键放弃选区后再松开左键)。
|
||
if ((wParam & MK_LBUTTON) == 0)
|
||
{
|
||
ReleaseCapture();
|
||
m_activeTarget = Target::NONE;
|
||
}
|
||
return 0;
|
||
}
|
||
case WM_MOUSEWHEEL:
|
||
{
|
||
POINT point = {GET_X_LPARAM(lParam), GET_Y_LPARAM(lParam)};
|
||
ScreenToClient(hwnd, &point);
|
||
|
||
const int delta = GET_WHEEL_DELTA_WPARAM(wParam);
|
||
const bool shift = (GET_KEYSTATE_WPARAM(wParam) & MK_SHIFT) != 0;
|
||
|
||
bool changed = false;
|
||
if (m_panel.HitTest(point.x, point.y))
|
||
{
|
||
changed = m_panel.OnMouseWheel(delta);
|
||
}
|
||
else if (m_plot.HitTest(point.x, point.y))
|
||
{
|
||
changed = m_plot.OnMouseWheel(delta, shift);
|
||
}
|
||
if (changed)
|
||
{
|
||
InvalidateRect(hwnd, nullptr, FALSE);
|
||
}
|
||
return 0;
|
||
}
|
||
case WM_SETCURSOR:
|
||
{
|
||
if (LOWORD(lParam) == HTCLIENT)
|
||
{
|
||
POINT point = {};
|
||
GetCursorPos(&point);
|
||
ScreenToClient(hwnd, &point);
|
||
|
||
HCURSOR cursor = nullptr;
|
||
if (UiPointInRect(m_splitterRect, static_cast<float>(point.x), static_cast<float>(point.y)))
|
||
{
|
||
cursor = LoadCursorW(nullptr, IDC_SIZEWE);
|
||
}
|
||
else if (m_plot.HitTest(point.x, point.y))
|
||
{
|
||
cursor = m_plot.QueryCursor(point.x, point.y);
|
||
}
|
||
else if (UiPointInRect(m_panelRect, static_cast<float>(point.x), static_cast<float>(point.y)))
|
||
{
|
||
cursor = m_panel.QueryCursor(point.x, point.y);
|
||
}
|
||
if (cursor == nullptr)
|
||
{
|
||
cursor = LoadCursorW(nullptr, IDC_ARROW);
|
||
}
|
||
SetCursor(cursor);
|
||
return TRUE;
|
||
}
|
||
break;
|
||
}
|
||
case WM_KEYDOWN:
|
||
// 标签栏重命名与数值输入优先接管按键(回车提交、Esc 取消、退格删除)。
|
||
if (m_tabBar.IsEditing())
|
||
{
|
||
TabBarResult tabResult;
|
||
m_tabBar.OnEditKey(wParam, tabResult);
|
||
OnTabBarResult(tabResult);
|
||
if (!m_tabBar.IsEditing())
|
||
{
|
||
// 退出重命名后标签宽度恢复,需要重新排布。
|
||
Relayout();
|
||
}
|
||
InvalidateRect(hwnd, nullptr, FALSE);
|
||
return 0;
|
||
}
|
||
if (m_panel.IsEditing())
|
||
{
|
||
if (m_panel.OnEditKey(wParam))
|
||
{
|
||
InvalidateRect(hwnd, nullptr, FALSE);
|
||
}
|
||
return 0;
|
||
}
|
||
if (m_plot.IsEditing())
|
||
{
|
||
m_plot.OnEditKey(wParam);
|
||
ApplyPendingRename();
|
||
InvalidateRect(hwnd, nullptr, FALSE);
|
||
return 0;
|
||
}
|
||
if (HandleKeyDown(wParam))
|
||
{
|
||
InvalidateRect(hwnd, nullptr, TRUE);
|
||
return 0;
|
||
}
|
||
break;
|
||
case WM_CHAR:
|
||
{
|
||
// 输入状态由对应控件自行校验可接受的字符(数值仅收数字与小数点,名称可收任意可打印字符)。
|
||
if (m_tabBar.IsEditing())
|
||
{
|
||
if (m_tabBar.OnEditChar(static_cast<wchar_t>(wParam)))
|
||
{
|
||
// 标签宽度随输入内容增长(重命名时上限更宽),需重新排布才能看到完整名称。
|
||
if (m_tabBar.IsEditing())
|
||
{
|
||
Relayout();
|
||
}
|
||
InvalidateRect(hwnd, nullptr, FALSE);
|
||
}
|
||
return 0;
|
||
}
|
||
if (m_panel.IsEditing())
|
||
{
|
||
if (m_panel.OnEditChar(static_cast<wchar_t>(wParam)))
|
||
{
|
||
InvalidateRect(hwnd, nullptr, FALSE);
|
||
}
|
||
return 0;
|
||
}
|
||
if (m_plot.IsEditing())
|
||
{
|
||
if (m_plot.OnEditChar(static_cast<wchar_t>(wParam)))
|
||
{
|
||
ApplyPendingRename();
|
||
InvalidateRect(hwnd, nullptr, FALSE);
|
||
}
|
||
return 0;
|
||
}
|
||
break;
|
||
}
|
||
case WM_KILLFOCUS:
|
||
// 失焦时提交输入并收起临时界面,避免失去鼠标前景后控件仍处于编辑态。
|
||
m_panel.CloseTransientUi();
|
||
m_colorPicker.Close();
|
||
m_menu.Close();
|
||
InvalidateRect(hwnd, nullptr, FALSE);
|
||
return 0;
|
||
case kMessageRemoveFile:
|
||
{
|
||
RemoveFile(static_cast<int>(wParam));
|
||
return 0;
|
||
}
|
||
case kMessageRemoveSignal:
|
||
{
|
||
// wParam = 文件下标,lParam = 文件内信号下标。
|
||
RemoveSignal(static_cast<int>(wParam), static_cast<int>(static_cast<LONG_PTR>(lParam)));
|
||
return 0;
|
||
}
|
||
case WM_TIMER:
|
||
{
|
||
// 标题栏内存占用:只改窗口标题,不动客户区,因此无需重绘。
|
||
if (wParam == kRamTimerId)
|
||
{
|
||
UpdateWindowTitle();
|
||
return 0;
|
||
}
|
||
// 提示浮层淡出:仅重绘,不再做其它处理;Render 会在淡出结束后停表。
|
||
if (wParam == kHintTimerId)
|
||
{
|
||
InvalidateRect(hwnd, nullptr, FALSE);
|
||
return 0;
|
||
}
|
||
break;
|
||
}
|
||
case WM_DROPFILES:
|
||
{
|
||
HDROP drop = reinterpret_cast<HDROP>(wParam);
|
||
const UINT count = DragQueryFileW(drop, 0xFFFFFFFF, nullptr, 0);
|
||
int loaded = 0;
|
||
for (UINT index = 0; index < count; ++index)
|
||
{
|
||
const UINT length = DragQueryFileW(drop, index, nullptr, 0);
|
||
if (length == 0)
|
||
{
|
||
continue;
|
||
}
|
||
std::vector<wchar_t> buffer(static_cast<std::size_t>(length) + 1, L'\0');
|
||
if (DragQueryFileW(drop, index, buffer.data(), length + 1) > 0)
|
||
{
|
||
if (LoadFile(std::wstring(buffer.data())))
|
||
{
|
||
++loaded;
|
||
}
|
||
}
|
||
}
|
||
DragFinish(drop);
|
||
|
||
if (loaded > 0)
|
||
{
|
||
SetStatusMessage(L"已加载 " + std::to_wstring(loaded) +
|
||
L" 个文件,请在左侧勾选需要查看的信号。",
|
||
false);
|
||
}
|
||
return 0;
|
||
}
|
||
case WM_DESTROY:
|
||
PostQuitMessage(0);
|
||
return 0;
|
||
default:
|
||
break;
|
||
}
|
||
|
||
return DefWindowProcW(hwnd, message, wParam, lParam);
|
||
}
|
||
|
||
} // namespace jdi
|