一、Lane 的内聚性(上一轮建议 5,本轮完成)
- 新增 struct LanePanelState { bool open = true; double x = -1.0; double y = -1.0; };
- Lane 的 12 个平铺面板字段(edgePanelOpen/X/Y 等 × 4 类实验性功能)
→ 4 个聚合成员 edgePanel / edgeCenterPanel / meanFilterPanel / weakPeakPanel;
- 访问点全部改为 lane.edgePanel.open 形式(DataModelTab.cpp 快照拷贝、
PlotViewInteraction.cpp 面板拖拽、PlotViewPaint.cpp 面板层序绘制);
- RebuildLanes 内部的 LaneCarry 同步收敛为 4 个 LanePanelState,
12 行逐字段赋值简化为 4 行整体赋值。
- 教训:批量改名不能对“声明”和“使用”跑同一组替换对,
否则声明行会变成 `bool edgePanel.open = true;` 这类非法代码。
二、错误提示与模态阻塞解耦(上一轮建议 1,本轮完成)
- Application 新增 SetModalErrorsEnabled(bool) / ModalErrorsEnabled(),
成员 m_modalErrorsEnabled 默认 true(交互场景行为不变);
- Initialize 最先执行 SetModalErrorsEnabled(showCommand != SW_HIDE):
隐藏窗口(自动化/离屏)下没人能点掉模态框,等于把进程钉死;
- SetStatusMessage(text, isError=true):允许模态时原样 MessageBoxW,
否则改走非模态通道 m_plot.ShowPersistentHint(text)(绘图区底部浮层);
普通提示与空文本依旧被忽略;
- PlotView 新增公开接口 ShowPersistentHint(text)(内部转 SetHintSticky),
不向宿主暴露粘滞/淡出等内部细节。
三、顺带修掉一个真实缺陷
- PlotView::Paint 在 m_lanes.empty() 时提前 return,而提示浮层在其后的
叠加层绘制 ⇒ 一个信号都没勾选时任何错误都显示不出来,恰恰是
“文件加载失败”最需要提示的场景;该分支补一次 PaintHint(g)。
四、验证(全部离屏:SW_HIDE + 不注入输入 + BELOW_NORMAL)
- make release / make debug:通过,零警告;Build/Release/Target.exe = 764416 B;
- verify 49/49、datacheck 69/69、edgecheck 46/46、panelanim 156/156;
- 新增探针 .preflight/perf/errcheck 19/19:默认值 / 隐藏窗口自动降级 / 宿主显式开关;
工作线程 + 5s 超时判定“不阻塞”(旧行为会永久阻塞,判失败并 ExitProcess,
探针不会挂住);EnumWindows 统计本进程 #32770 顶层窗口数量前后不变;
错误文本进入 m_hintText / m_hintSticky / HintNeedsAnimation / m_hintShownAt;
像素级:有提示帧与无提示帧在绘图区内的差异像素 3781,且全帧差异同为 3781
(证明差异只来自提示)。
五、文档
- Documents/agent_mem.md:§9.37 新增第八节(本轮补齐)、第九节(仅剩 MF4 项)、
§11.2 新增第 93–95 条硬约束、变更日志新增一行;
- Documents/refactor_report_2026-10-16.md:§7.3 / §7.4 标记完成,新增 §九 收尾补记
(含 9.1–9.5:聚合化、解耦、探针、验证结果、变更清单)。
1438 lines
43 KiB
C++
1438 lines
43 KiB
C++
// Application 实现分片 —— 本文件只包含 Application 的**成员函数定义**(纯代码搬移,行为与拆分前完全一致)。
|
||
//
|
||
// 共用的常量与自由函数在 ApplicationInternal.hpp(命名空间 `appdetail`,全部 inline)。
|
||
// 分片按“职责”划分:核心(状态/布局/几何)、渲染、交互与编辑,见各文件名。
|
||
#include "ApplicationInternal.hpp"
|
||
|
||
namespace jdi
|
||
{
|
||
|
||
|
||
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;
|
||
|
||
// 隐藏窗口启动(自动化/后台)时没人能点掉模态错误框,会自动降级为非模态提示。
|
||
SetModalErrorsEnabled(showCommand != SW_HIDE);
|
||
|
||
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)];
|
||
}
|
||
|
||
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::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);
|
||
|
||
// 信号列表 / 样式区「目标」行的悬停 → 绘图区联动高亮(网格变色 + 该信号曲线高亮)。
|
||
// 用户 2026-10-15 要求;鼠标在网格里移动**不再**触发网格变色高亮。
|
||
// ⚠️ 每帧同步(而不是只在 WM_MOUSEMOVE 里同步)才能覆盖全部改变悬停的路径:
|
||
// 鼠标离开窗口(WM_MOUSELEAVE)、数据/行结构变化、切换标签页等。
|
||
m_plot.SetExternalHoverSignal(m_panel.HoveredSignal());
|
||
|
||
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;
|
||
}
|
||
|
||
// 信号列表的动画(目标信号滚动 / 分组折起展开):仅在动画进行中保持定时重绘。
|
||
if (m_panel.ScrollAnimationActive() || m_panel.GroupAnimationActive())
|
||
{
|
||
if (!m_scrollTimerActive)
|
||
{
|
||
SetTimer(m_hwnd, kScrollTimerId, kScrollTimerPeriodMs, nullptr);
|
||
m_scrollTimerActive = true;
|
||
}
|
||
}
|
||
else if (m_scrollTimerActive)
|
||
{
|
||
KillTimer(m_hwnd, kScrollTimerId);
|
||
m_scrollTimerActive = false;
|
||
}
|
||
}
|
||
|
||
void Application::SetStatusMessage(const std::wstring& text, bool isError)
|
||
{
|
||
// 界面不再占用底部空间显示常驻提示;仅把真实错误反馈给用户。
|
||
if (!isError || text.empty())
|
||
{
|
||
return;
|
||
}
|
||
// 模态错误框会抢断用户当前操作,因此把"是否需要打断"交给宿主决定:
|
||
// 允许时弹框;不允许时改为绘图区底部的非模态浮层提示。
|
||
if (m_modalErrorsEnabled)
|
||
{
|
||
MessageBoxW(m_hwnd, text.c_str(), kWindowTitle, MB_ICONWARNING | MB_OK);
|
||
return;
|
||
}
|
||
m_plot.ShowPersistentHint(text);
|
||
}
|
||
|
||
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;
|
||
|
||
// 仅当本次移动不会影响绘图区之外的区域时,才跳过装饰区域的重绘:
|
||
// 正在拖拽绘图区(鼠标可能被捕获到绘图区外),或鼠标始终停留在绘图区内。
|
||
//
|
||
// ⚠️ **必须同时看“当前正在拖拽哪个组件”**:拖拽信号面板的滚动条时,鼠标很容易
|
||
// 移动到绘图区上方,若只看 `inPlot && m_mouseOverPlot` 就会判定为“只影响绘图区”、
|
||
// 只失效绘图区 —— 面板的滚动位置明明已经变了,却一直不重绘(
|
||
// 用户报告的“拖住滚动条移到网格区域后滑块卡在原地不动”)。
|
||
const bool draggingNonPlot =
|
||
(m_activeTarget != Target::NONE) && (m_activeTarget != Target::PLOT) &&
|
||
(m_activeTarget != Target::EDIT_FIELD);
|
||
const bool inPlot = UiPointInRect(m_plotRect, static_cast<float>(x), static_cast<float>(y));
|
||
const bool plotOnlyMove =
|
||
!draggingNonPlot && ((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);
|
||
}
|
||
// 联动:在绘图区选中了某条信号 ⇒ 让信号列表平滑滚动到它并完整显示。
|
||
ApplyPendingPanelScroll();
|
||
// 拖动期间 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);
|
||
|
||
// ⚠️ **工具栏必须在这里也当作“按下”处理**:窗口类带 `CS_DBLCLKS`,快速连点同一个
|
||
// 按钮时,第二次(及之后的快速按下)会被 Windows 合成为 `WM_LBUTTONDBLCLK`。
|
||
// 工具栏没有双击语义,若这里不处理,那一次按下就完全丢失 ——
|
||
// 用户看到的正是“连点好几下、开关状态只切换了几次”。
|
||
if (m_toolbar.HitTest(x, y))
|
||
{
|
||
SetFocus(hwnd);
|
||
SetCapture(hwnd);
|
||
m_activeTarget = Target::TOOLBAR;
|
||
ToolbarAction action = ToolbarAction::NONE;
|
||
m_toolbar.OnLButtonDown(x, y, action);
|
||
InvalidateRect(hwnd, nullptr, FALSE);
|
||
return 0;
|
||
}
|
||
|
||
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);
|
||
}
|
||
// 联动:双击图例改名也会选中该信号 ⇒ 同步把列表滚动到它。
|
||
ApplyPendingPanelScroll();
|
||
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;
|
||
}
|
||
// 信号列表动画(「滚动到目标信号」与「分组折起 / 展开」):推进一步并重绘
|
||
//(Render 会在动画结束后停表)。
|
||
if (wParam == kScrollTimerId)
|
||
{
|
||
m_panel.AdvanceScrollAnimation();
|
||
m_panel.AdvanceGroupAnimations();
|
||
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
|
||
|