Files
Just-DataInspector/Components/Application/Src/Application.cpp
T
DeepSeek V4.1 Flash 2904699eae 修复五项交互缺陷:折起后滚动条 / 单点信号样式 / 线宽实时生效 / 网格菜单限高滚动 / 点选信号滚动绘图区
1) 分组折起后再展开,信号列表的滚动条消失(筛选态与常规态均可复现)
   - 根因:AdvanceGroupAnimations 只在“折起完成”时重建行集合;而“展开完成”那一帧
     m_contentHeight 从收起态跳回展开态,却没有任何路径重算滚动条可见性 ⇒ 滚动条停在隐藏。
   - 修法:任一方向动画结束都 m_rowsDirty = true + BuildRows(),并每帧 RefreshScrollBar(false)。
     ⚠️ 动画期间不夹取滚动偏移,避免打断与滚动同步的折起动画(scrollSynced)。

2) 单点信号(finiteCount == 1)默认绘制方式由「点线」改为「散点」
   - ApplySinglePointDefaultStyle → DrawMode::SCATTER(仍取最大线宽)。
   - 新增 SignalPanel::ModeOptionEnabled:单点信号的「线段」「点线」置灰、不可点、
     且不参与悬停高亮(避免“看着能点其实点不动”);样式区的绘制与命中都用它。
   - PlotView::UpdateAutoScatterStyles 跳过单点信号,否则“放大自动点线”会把它又改回「点线」。

3) 拖动线宽滑块完全不生效(拖动时线宽不变、松手瞬间变了、再移动鼠标又变回去)
   - 根因一:AcquirePolyline 的折线缓存键没有 lineWidth,而 BuildPolyline 把线宽烘进
     包络带几何(每个像素列的上下包络各向外扩 max(1,线宽)*scale*0.5,「梳状」判定也依赖它)
     ⇒ 稠密信号(采样数 > 绘图区宽度)永远复用旧线宽的顶点与包络带。
   - 根因二:样式补丁只更新“静态场景缓存 + 后备缓冲区”,而每网格图层(宽基底)仍是旧样式像素,
     下一帧命中“宽基底快路径”时把新样式覆盖回旧像素。
   - 修法:PolylineCacheEntry 增加 lineWidth 并进比较;
     样式补丁之后 QueueLaneRebuild(受影响网格) + RenderPendingLaneRebuilds(1)(沿用每帧至多 1 个的预算)。

4) 「目标」行的 GX 标签复用「所属网格」菜单,并先给 PopupMenu 增加「限高 + 条目区滚动 + 尾部固定项」
   - MenuLayoutConstraint::maxHeight(OpenLaneMenu 传客户区高度的 62%);
   - 固定区 = 最后一个分隔线及其之后的条目(即「新建网格」)始终钉在菜单底部,其余条目可滚动;
   - 滚轮滚动条目区(Application 在菜单内滚动时不再关闭菜单,其余情形保持既有行为);
   - 键盘移动悬停项自动滚入可视区(EnsureItemVisible);ItemAtPoint 要求条目同时命中滚动视口;
   - 滚动区文本用 UiTextClipped(GDI 文本不遵循 GDI+ SetClip),并跳过完全不可见的条目(性能);
   - BuildLayout 不再无条件清零 m_scrollOffset(否则 RebuildLayout 会把滚轮位置吃掉);
   - 「目标」行 GX 标签点击**不参与**联动滚动(悬停联动保留)。

5) 点选信号名 ⇒ 绘图区自动把目标网格滚动到可视区内(上百个独立网格时旧有高亮无法定位)
   - SignalPanel::RequestPlotLaneScroll / TakePlotLaneScrollRequest;
     Application::ApplyPendingPlotLaneScroll(WM_LBUTTONDOWN 的面板分支);
     PlotView::ScrollLaneIntoView(在可视区上方就上滚、在下方就下滚、已完整可见则不动)。
   - 只有“点击信号名”触发;悬停绝不改变视图;勾选框 / GX 徽标 / 移除 ✕ 也不触发。

验证(全部离屏执行,窗口 SW_HIDE + 只注入合成消息 + 进程与线程 BELOW_NORMAL,
不移动真实光标、不抢焦点、不截屏):
- 新增 .preflight/check(不入库)68/68 通过;
- A/B:五处修复全部回退 ⇒ 17 项如实失败(B 4 / C 4 / D 7 / E 2);“折起后滚动条”需要
  逐帧重绘驱动动画才能复现,单独回退时 ⇒ 5 项如实失败;恢复后 68/68;
- make release / make debug 零警告。

其它:
- 按要求删除工作区 Tools/(structcheck 脚本)并移除 Makefile 的 structcheck 目标;
- Documents/agent_mem.md 同步:新增 §9.38、§11.2 第 97–103 条与变更日志一行。
2026-10-16 11:06:31 +08:00

1458 lines
44 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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_MOUSEWHEEL:
{
// 可滚动菜单(条目过多、按最大高度截断的菜单,如「所属网格」):
// 鼠标在菜单内时滚轮用来翻动条目区,**不关闭菜单**;其余情形仍是
// “滚轮关闭菜单”(保持既有行为)。
POINT wheelPoint = {GET_X_LPARAM(lParam), GET_Y_LPARAM(lParam)};
ScreenToClient(hwnd, &wheelPoint);
if (m_menu.IsScrollable() && m_menu.ContainsPoint(wheelPoint.x, wheelPoint.y))
{
if (m_menu.OnMouseWheel(GET_WHEEL_DELTA_WPARAM(wParam)))
{
InvalidateRect(hwnd, nullptr, FALSE);
}
return 0;
}
m_menu.Close();
InvalidateRect(hwnd, nullptr, FALSE);
return 0;
}
case WM_RBUTTONDOWN:
case WM_MBUTTONDOWN:
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))
{
// 点击信号名 ⇒ 让绘图区把目标网格滚动到可视区内(用户 2026-10-16 要求)。
ApplyPendingPlotLaneScroll();
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