- 弱波增强不再做任何前置滤波(删除内部的 5 点滑动平均 BoostShapeAt): 极值点直接在输入采样上识别。需要平滑时用户可以先用「均值滤波」构建一条 滤波后的信号,再对它做弱波增强。 - 新增「阶数 N」参数(1~10,滑块输入),语义与均值滤波的阶数 N 一致 —— 把同一处理重复应用 N 次,每阶以上一阶为输入: 一阶时改定量严格等于「等级」对应的目标值;多阶会让相邻弱波互相抬高彼此的 基准,从而把较宽的弱波区逐步恢复(实测三连弱波的中间峰 550→575→598.4)。 某一阶若不再产生改动会提前结束迭代。 `ComputeWeakWaveBoost(values, level, order, out)`; `Lane::weakBoostOrder`(默认 1)随 LaneCarry / SplitLane / 引用重映射同步, 并进入 SceneContentHash;曲线缓存键变为「信号 + 等级 + 阶数」。 - 浮动面板同步新增一行「阶数 N」滑块(新拖拽模式 BOOST_ORDER、 EdgePanelLayout::sliderOrder);「构建为新信号」命名带上阶数 (形如 `<原信号> (弱波增强 60%×2阶)`)。 - 修复:选区放大时鼠标被捕获到绘图区之外(工具栏 / 信号面板 / 时间轴)会把 选区块画到绘图区之外,而拖拽期间只失效绘图区 → 留下无法擦除的墨迹。 现在拖拽时把选区夹在绘图区内,PaintRubberBand 再对 m_plotRect 求交一次。 - 修复:浮动面板的 ✕ 位于标题栏矩形内部,按下后会继续落到「标题栏 → 拖动面板」 分支,表现为「按住 ✕ 却把面板拖走了」;现在按下 ✕ 直接消费该次点击(抬起时 仍按原语义确认关闭),并把 ✕ 的光标改为手型,与其它关闭按钮一致。 - 修复:✕ / 滑块 / 底部按钮的悬停高亮依赖鼠标坐标,但从同一网格的波形移到面板上 时悬停状态毫无变化 → OnMouseMove 返回 false → 宿主不重绘 → 高亮不出现。 现在「进入 / 离开 / 在面板内移动」都会请求重绘。 - 离屏验证(窗口隐藏,不占用鼠标/键盘/屏幕):345 项检查全部通过,含 阶数扫描(N=1 只改外侧弱峰、N=2 传导到中间弱峰、高 N 逼近健康幅值且不越界、 健康峰在任何阶数下逐位不变)、两个滑块的按压/拖拽/钳制、选区越界零墨迹 (绘图区外像素差 0)、面板 ✕ 悬停高亮与按下不拖动、光标拖拽始终在视界内, 以及真实 20K 电机纹波录波(信号范围保持不变、20%+ 采样被调整)。
2796 lines
82 KiB
C++
2796 lines
82 KiB
C++
#include "Application.hpp"
|
||
|
||
#include <algorithm>
|
||
#include <atomic>
|
||
#include <cmath>
|
||
#include <thread>
|
||
|
||
#include <commdlg.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"; /**< 窗口标题(仅显示应用名称)。 */
|
||
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)。 */
|
||
|
||
/**
|
||
* @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();
|
||
|
||
Relayout();
|
||
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()
|
||
{
|
||
TabConfig tab;
|
||
tab.SetName(NextTabName());
|
||
tab.SyncToDataSet(m_dataSet);
|
||
|
||
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;
|
||
|
||
// 先按旧下标修正各标签页配置与共享选中项,再从数据层移除,最后统一对齐。
|
||
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;
|
||
}
|
||
|
||
// 先按旧下标修正各标签页配置与共享选中项,再从数据层移除(释放采样内存),最后对齐。
|
||
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()
|
||
{
|
||
// 标题栏只显示应用名称,不附带标签页、文件数量等无关信息。
|
||
SetWindowTextW(m_hwnd, kWindowTitle);
|
||
}
|
||
|
||
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;
|
||
// 此处只做一次指针/缓冲移动,不拷贝任何采样数据,因此不会造成可见卡顿。
|
||
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();
|
||
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();
|
||
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();
|
||
|
||
// 1. 计算派生采样值(与网格内的预览曲线共用同一实现,因此逐位一致)。
|
||
// 多阶均值滤波形如「<原信号名称> (N点均值×M阶)」,
|
||
// 弱波增强形如「<原信号名称> (弱波增强 M%)」。
|
||
std::vector<float> derived_values;
|
||
SignalData derived;
|
||
if (request.weakBoost)
|
||
{
|
||
ComputeWeakWaveBoost(source.values, static_cast<double>(request.levelPercent) / 100.0,
|
||
request.order, derived_values);
|
||
derived.name = baseName + L" (弱波增强 " + std::to_wstring(request.levelPercent) + L"%";
|
||
if (request.order > 1)
|
||
{
|
||
derived.name += L"×" + std::to_wstring(request.order) + L"阶";
|
||
}
|
||
derived.name += L")";
|
||
}
|
||
else
|
||
{
|
||
ComputeMovingAverageIterated(source.values, request.points, request.order, derived_values);
|
||
derived.name = baseName + L" (" + std::to_wstring(request.points) + L"点均值";
|
||
if (request.order > 1)
|
||
{
|
||
derived.name += L"×" + std::to_wstring(request.order) + L"阶";
|
||
}
|
||
derived.name += L")";
|
||
}
|
||
|
||
// 2. 组装新信号。
|
||
derived.rawHeader = derived.name;
|
||
derived.unit = source.unit;
|
||
derived.values.assign(derived_values.begin(), derived_values.end());
|
||
|
||
// 3. 插入到原信号的下方(数据层 + 各标签页引用重映射)。
|
||
int newIndex = -1;
|
||
if (!m_dataSet.InsertDerivedSignal(request.fileIndex, request.signalIndex, std::move(derived),
|
||
newIndex))
|
||
{
|
||
return false;
|
||
}
|
||
for (TabConfig& tab : m_tabs)
|
||
{
|
||
tab.RemapAfterSignalInsert(request.fileIndex, newIndex);
|
||
}
|
||
|
||
// 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.order = item.order;
|
||
request.weakBoost = false;
|
||
requests.push_back(request);
|
||
}
|
||
}
|
||
}
|
||
{
|
||
int laneIndex = -1;
|
||
std::vector<PlotView::WeakBoostBuild> items;
|
||
if (m_plot.TakePendingWeakBoostBuild(laneIndex, items))
|
||
{
|
||
for (const PlotView::WeakBoostBuild& item : items)
|
||
{
|
||
DerivedSignalRequest request;
|
||
request.fileIndex = item.fileIndex;
|
||
request.signalIndex = item.signalIndex;
|
||
request.levelPercent = item.levelPercent;
|
||
request.order = item.order;
|
||
request.weakBoost = 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.weakBoost)
|
||
{
|
||
text += L"弱波增强 " + std::to_wstring(last.levelPercent) + L"%" +
|
||
std::to_wstring(last.order) + L"阶信号。";
|
||
}
|
||
else
|
||
{
|
||
text += std::to_wstring(last.points) + L"点均值";
|
||
if (last.order > 1)
|
||
{
|
||
text += L"×" + std::to_wstring(last.order) + L"阶";
|
||
}
|
||
text += 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 weakBoost;
|
||
weakBoost.label = L"弱波增强";
|
||
weakBoost.id = PLOT_CMD_LANE_WEAK_BOOST;
|
||
weakBoost.checked = m_plot.LaneWeakBoost(laneIndex);
|
||
experimental.submenu.push_back(weakBoost);
|
||
}
|
||
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_BOOST:
|
||
// 「实验性功能 → 弱波增强」:切换弱波增强曲线与参数面板。
|
||
m_plot.ToggleLaneWeakBoost(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();
|
||
}
|
||
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)
|
||
{
|
||
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 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)
|
||
{
|
||
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:
|
||
{
|
||
// 提示浮层淡出:仅重绘,不再做其它处理;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
|