Files
Just-DataInspector/Components/Application/Src/Application.cpp
T
DeepSeek V4.1 Flash 57cc714c1d 组名平滑悬浮 + 单点信号初始视图 + 光标吸附统一判定(自动点线同源)
一、信号列表「组名悬浮」平滑化、且不再遮住下一个组名
 * 悬浮位置改为 min(列表上边界, 本组最后一行下边界 − 组名高度):
   - 本组还有行在列表里时钉在列表上边界;
   - 本组最后一行开始滑出上边界时,组名跟着一起上移、随滚动条自然离界
     (不再像旧实现那样突然消失);
   - 「本组最后一行的下边界」恰好就是下一个分组行的自然位置,因此悬浮组名的下边界
     永远不超过下一组名 —— 旧实现固定钉在列表上边界会把下一组名盖住,让别人以为
     后面的信号属于上一组。
 * 折叠的分组不再悬浮(折叠时本组“最后一行”就是它自己,悬浮位置 = 自然位置,
   于是随滚动自然划过,不会停在最上方)。
 * 底部边界线只在真的“浮”在其它行之上时才画,避免与正常绘制出现像素差。

二、单点信号的初始视图
 * 新增 PlotView::FitSinglePointSignal():当前没有任何信号被勾选、而勾选的第一个信号
   只有一个有效数据点时,把该数据点居中放置,时间跨度 = 1s、Y 轴跨度 = 1
   (各为一个大格),并关闭该网格的整段自适应以免被覆盖。
   Application::SynchronizeView 在首次勾选时先尝试它,失败才走 FitAll()。
 * AutoRangeLaneToView 的退化区间(可见区间内只有一个取值)改用与 PadRange 一致的留白
   max(1, |v|×0.1),不再产生“几乎为 0”的区间(那会让幅值网格线挤成一团、看不出数据点)。
 * 需求作废:上一轮“2…16 个有效采样点默认改为点线 + 2px”已取消(放大自动点线已覆盖该场景);
   只保留“只有一个有效采样点 → 点线 + 最大线宽”。
 * SignalData::singlePointTime 升级为 sparseTimes/sparseValues(有效点 ≤ 1024 时在加载/插入
   时缓存全部有效点),同时服务单点初始视图与光标吸附。

三、光标吸附的统一判定
 * N = GridMinorCellCount():网格底衬一横排的小格数,与网格绘制用同一套
   NiceStep + 五等分算出(因此不随缩放等级大幅变化)。
 * SignalSnapEnabled():当且仅当某信号在当前一屏内的数据点个数 < N×4 时为它开启吸附;
   判据随视界实时重算 ⇒ 疏密变化的信号在稀疏区自动吸附、移回稠密区自动自由移动。
   额外要求视界内至少有一个可见数据点(否则“0 < N×4”会把数据全在视界之外的信号
   误判为稀疏)。统计只在可见下标区间内进行且攒够阈值即早退,代价有界。
 * SnapTimeToSample():判定只针对鼠标所在网格(同一个时间轴上不同网格结果可以不同);
   同一网格内所有符合判定的信号的数据点都参与吸附比较;
   吸附半径 = 一个网格小格的宽度,且不超过半个局部采样间距 ⇒ 数据点很稀疏
   (一屏只有一两个点)时光标在点之间仍可自由移动(吸附是提示,不是卡住)。
 * 删除旧的 SampleSpacingPixels(6px 下门限 + 半屏宽上门限)。

四、放大自动切点线改为与吸附判定完全同源
 * UpdateAutoScatterStyles() 改用 SignalSnapEnabled:进入判定自动切「点线」,
   不再满足(适应视图、或视界从稀疏区移到稠密区)自动恢复原样式;
   自动期间用户手动改过则不恢复(尊重用户选择,判定放在恢复之前)。
 * 删除旧的 AxisSparseInView(“采样间距 > 半屏宽”)。

五、验证(离屏 .preflight/check:SW_HIDE 窗口、不注入真实鼠标键盘、进程/线程 BELOW_NORMAL)
 55/55 通过(连跑 3 次稳定):[A] 组名悬浮(钉住/平滑上移/整组滚过后换下一组/折叠组不悬浮/
 悬浮下边界不超过下一组名)、[B] 单点初始视图(跨度 1s 与 1、居中、点线 + 最大线宽、
 两信号时不再走单点路径)、[C] 吸附判定(满视图都关闭/稀疏窗口开启/无可见点不开启/
 同一时间轴不同网格结果不同/同网格多信号的点都参与/稠密↔稀疏双向动态切换/
 单点靠近吸附远离自由)、[D] 自动点线(同源判定/恢复/用户手动改过不覆盖)。
 A/B:临时禁用「悬浮位置公式 / 单点初始视图 / 吸附判定」后重跑 ⇒ 14 项如实失败。
 make release 零警告(用户正运行 Target.exe,链接用临时 -o 名字验证通过)。

六、规则同步
 Documents/agent_mem.md 新增 §9.30、§11.2 第 58–61 条硬约束与变更日志。
2026-10-15 15:10:36 +08:00

3050 lines
95 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.
#include "Application.hpp"
#include <algorithm>
#include <atomic>
#include <cmath>
#include <thread>
#include <commdlg.h>
#include <psapi.h>
#include <shellapi.h>
#include <windowsx.h>
#include "DataLoader.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 kMessageRemoveGroup = 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; /**< 失败原因。 */
SignalGroup group; /**< 构建结果。 */
};
/**
* @brief 工作线程主体:按扩展名选择加载器读入文件并构建信号分组(不接触 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%。
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);
};
LoadedSource source;
std::wstring error;
if (!LoadSignalSource(path, source, error, parseProgress))
{
state->error = error;
state->done.store(true, std::memory_order_release);
return;
}
LoadProgress buildProgress = [state](const LoadProgressInfo& 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);
};
SignalGroup group;
if (!BuildSignalGroup(std::move(source), paletteBase, group, error, buildProgress))
{
state->error = error;
state->done.store(true, std::memory_order_release);
return;
}
state->group = 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)。
*/
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));
// ⚠️ 同一个进程里再次创建 `Application`(离屏测试、单进程多次启动)时窗口类已经注册,
// 这时**不是错误**:直接复用已注册的类即可。旧实现把它当致命错误弹模态框,
// 会让调用方永远阻塞在对话框上。
if (RegisterClassExW(&windowClass) == 0 && GetLastError() != ERROR_CLASS_ALREADY_EXISTS)
{
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;
}
// 注销窗口类:否则同一进程里再次创建 `Application` 时会命中
// `ERROR_CLASS_ALREADY_EXISTS`(离屏测试需要多次构造/销毁)。
if (m_hwnd != nullptr)
{
DestroyWindow(m_hwnd);
m_hwnd = nullptr;
}
UnregisterClassW(kWindowClassName, m_instance);
}
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())
{
// 「单点信号」有专门的初始视图(数据点居中、时间跨度 1s、幅值跨度 1):
// 整段自适应对它不适用(时间轴会覆盖整条时间列、幅值区间退化成几乎为 0)。
if (!m_plot.FitSinglePointSignal())
{
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::RemoveGroup(int groupIndex)
{
const SignalGroup* group = m_dataSet.GroupAt(groupIndex);
if (group == nullptr)
{
return;
}
const std::wstring name = group->name;
// 移除文件会释放大量采样内存:先暂停后台派生曲线计算,避免其读取已释放内存。
m_plot.PauseDerivedAsync();
// 先按旧下标修正各标签页配置与共享选中项,再从数据层移除,最后统一对齐。
for (TabConfig& tab : m_tabs)
{
tab.RemapAfterGroupRemoval(groupIndex);
}
m_dataSet.RemapSelectionAfterGroupRemoval(groupIndex);
m_dataSet.RemoveGroup(groupIndex);
SyncTabsToDataSet();
SetStatusMessage(L"已从工作区移除:" + name, false);
UpdateWindowTitle();
}
void Application::RemoveSignal(int groupIndex, int signalIndex)
{
SignalRef target;
target.groupIndex = groupIndex;
target.signalIndex = signalIndex;
const SignalData* signal = m_dataSet.Resolve(target);
const SignalGroup* group = m_dataSet.GroupAt(groupIndex);
if (signal == nullptr || group == nullptr)
{
return;
}
const std::wstring name = signal->Title();
if (group->signals.size() <= 1)
{
// 组内只剩这一条信号:直接移除整个分组,避免在列表里留下一个空分组。
RemoveGroup(groupIndex);
return;
}
// 移除信号会释放采样内存:先暂停后台派生曲线计算,避免其读取已释放内存。
m_plot.PauseDerivedAsync();
// 先按旧下标修正各标签页配置与共享选中项,再从数据层移除(释放采样内存),最后对齐。
for (TabConfig& tab : m_tabs)
{
tab.RemapAfterSignalRemoval(groupIndex, signalIndex);
}
m_dataSet.RemapSelectionAfterSignalRemoval(groupIndex, signalIndex);
m_dataSet.RemoveSignal(groupIndex, 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 = SignalFileDialogFilter();
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;
}
// 大文件在工作线程解析并构建,主线程播放加载过渡动画,界面不再整块卡住。
SignalGroup group;
std::wstring error;
if (!LoadFileAsync(path, group, error))
{
if (!error.empty())
{
SetStatusMessage(L"解析失败:" + error, true);
}
InvalidateRect(m_hwnd, nullptr, FALSE);
return false;
}
int groupIndex = -1;
// 插入新分组会移动 SignalGroup/SignalData 的存储位置,必须先暂停后台派生曲线计算,
// 否则后台线程可能正在读取即将被移动的采样数据。
m_plot.PauseDerivedAsync();
// 此处只做一次指针/缓冲移动,不拷贝任何采样数据,因此不会造成可见卡顿。
if (!m_dataSet.AddGroup(std::move(group), groupIndex))
{
SetStatusMessage(L"解析失败:数据为空。", true);
InvalidateRect(m_hwnd, nullptr, FALSE);
return false;
}
// 新分组的信号在全部标签页中默认均为未勾选状态,由用户自行决定查看内容。
SyncTabsToDataSet();
UpdateWindowTitle();
const SignalGroup* loaded = m_dataSet.GroupAt(groupIndex);
if (loaded != nullptr)
{
// 行数取各时间轴的**最大值**(多时间轴文件的各轴长度可能不同)。
std::size_t rowCount = 0;
bool anyNonMonotonic = false;
std::wstring timeNames;
for (const TimeAxis& axis : loaded->axes)
{
rowCount = std::max(rowCount, axis.Count());
anyNonMonotonic = anyNonMonotonic || !axis.monotonic;
if (!timeNames.empty())
{
timeNames += L"、";
}
timeNames += axis.name;
}
std::wstring message = L"已加载 组" + std::to_wstring(groupIndex + 1) + L"|" + loaded->Title() +
L"(" + FormatCount(static_cast<unsigned long long>(rowCount)) +
L" 行采样," + std::to_wstring(loaded->signals.size()) +
L" 条信号," + std::to_wstring(loaded->axes.size()) + L" 条时间轴:" +
timeNames + L")";
if (anyNonMonotonic)
{
message += L" 提示:时间列非单调递增,光标取值使用最近邻策略。";
}
SetStatusMessage(message, false);
}
return true;
}
bool Application::LoadFileAsync(const std::wstring& path, SignalGroup& group, 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;
}
group = std::move(state.group);
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.groupIndex, 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 SignalGroup* group = m_dataSet.GroupAt(request.groupIndex);
if (group == nullptr || request.signalIndex < 0 ||
request.signalIndex >= static_cast<int>(group->signals.size()))
{
return false;
}
const SignalData& source = group->signals[static_cast<std::size_t>(request.signalIndex)];
const TimeAxis& axis = group->AxisOf(source);
const std::wstring baseName = source.Title();
// 0. 先把源信号的「调整」变换快照下来(下面 `InsertDerivedSignal` 会移动信号存储,
// 之后再取 `source` 会悬垂)。
//
// ⚠️ **变换要"烘进数据",而不是让新信号继承这套指纹**(用户明确要求):
// 网格里的预览曲线画的是 `变换(滤波结果)`。若新信号只存 `滤波结果` 再**继承变换**,
// 它的「调整」面板里就会莫名其妙带着一整套系数 / 镜像 / 偏移 —— 用户看到的是
// "新信号只是继承了原信号的指纹,数据本身没变"。现在改为:
// * 数值域变换(系数 → 绝对值 → 垂直镜像 → Y 偏移)**与**水平镜像(数据反序)
// 全部作用在派生采样值上;
// * 于是新信号**用默认外观**绘制出来就与预览曲线逐位一致。
// * ⚠️ **时间轴偏移也一并烘进数据**(见 2c:按 `time[i] - offsetTime` 重采样)。
// 新信号与源信号共用同一个文件时间轴,因此时间平移只能写进采样值 —— 这样
// 新信号**不启用任何「调整」**就与预览逐点重合(用户明确要求)。
// 只有"无法烘值"的退化情形才退回把 `offsetTime` 带在样式上(见 3b)。
SignalStyle sourceTransform;
bool hasSourceStyle = false;
{
SignalRef sourceRef;
sourceRef.groupIndex = request.groupIndex;
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. 组装新信号(外观一律用默认值:变换已经烘进采样数据,不再是"继承的指纹")。
// ⚠️ 新信号**沿用源信号的时间轴**(`axisIndex`):派生只改采样值,不改时间基准。
derived.axisIndex = source.axisIndex;
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 && axis.monotonic)
{
const std::vector<double>& times = axis.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.groupIndex, request.signalIndex, std::move(derived),
newIndex))
{
return false;
}
for (TabConfig& tab : m_tabs)
{
tab.RemapAfterSignalInsert(request.groupIndex, newIndex);
}
// 3b. 时间轴偏移正常情况下已烘进采样值(见 2c),新信号的「调整」全部保持默认。
// ⚠️ 只有"无法烘值"的退化情形(时间轴非单调 / 时间列缺失)才退回到把
// `offsetTime` 带在新信号样式上 —— 无论如何都要让**显示**与预览一致。
if (hasSourceStyle && sourceTransform.offsetTime != 0.0 && !timeShiftBaked)
{
if (SignalStyle* derivedStyle = m_dataSet.Style(SignalRef{request.groupIndex, newIndex}))
{
derivedStyle->offsetTime = sourceTransform.offsetTime;
}
}
// 4. 在当前标签页中勾选新信号,并把它所在的网格移到原信号网格的下方。
if (makeVisible && !m_tabs.empty())
{
SignalRef derivedRef;
derivedRef.groupIndex = request.groupIndex;
derivedRef.signalIndex = newIndex;
ActiveTab().SetVisible(derivedRef, true);
SignalRef sourceRef;
sourceRef.groupIndex = request.groupIndex;
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.groupIndex = item.groupIndex;
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.groupIndex = item.groupIndex;
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.groupIndex != b.groupIndex)
{
return a.groupIndex < b.groupIndex;
}
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 kMessageRemoveGroup:
{
RemoveGroup(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