#include "Application.hpp" #include #include #include #include #include #include "CsvTable.hpp" #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(-4)) #endif namespace jdi { namespace { const wchar_t* kWindowClassName = L"JustDataInspectorMainWindow"; /**< 窗口类名。 */ const wchar_t* kWindowTitle = L"Just DataInspector"; /**< 默认窗口标题。 */ const float kStatusBarHeight = 26.0f; /**< 状态栏高度(逻辑像素)。 */ 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 wchar_t* kStatusHint = L"滚轮纵向滚动网格 · Shift+滚轮平移 · 双击绘图区恢复自适应 · Esc 关闭光标"; /** @brief 标签页右键菜单命令 ID。 */ const int kMenuTabNew = 1; const int kMenuTabClose = 2; const int kMenuTabCloseOthers = 3; /** * @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( reinterpret_cast(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(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(GetWindowLongPtrW(hwnd, GWLP_USERDATA)); if (message == WM_NCCREATE) { CREATESTRUCTW* createStruct = reinterpret_cast(lParam); self = reinterpret_cast(createStruct->lpCreateParams); SetWindowLongPtrW(hwnd, GWLP_USERDATA, reinterpret_cast(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(message.wParam); } bool Application::Initialize(HINSTANCE instance, int showCommand) { m_instance = instance; EnableDpiAwareness(); m_dpi = QuerySystemDpi(); m_scale = static_cast(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; windowClass.hIcon = LoadIconW(nullptr, IDI_APPLICATION); windowClass.hIconSm = windowClass.hIcon; if (RegisterClassExW(&windowClass) == 0) { MessageBoxW(nullptr, L"窗口类注册失败。", kWindowTitle, MB_ICONERROR | MB_OK); return false; } // 依据工作区(排除任务栏)计算默认窗口尺寸并居中,避免窗口超出屏幕。 RECT workArea = {0, 0, static_cast(ScaleOf(1380.0f, m_scale)), static_cast(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(ScaleOf(1380.0f, m_scale)), workWidth - 24); const int windowHeight = std::min(static_cast(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_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_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); HICON icon = LoadIconW(nullptr, IDI_APPLICATION); SendMessageW(m_hwnd, WM_SETICON, ICON_BIG, reinterpret_cast(icon)); SendMessageW(m_hwnd, WM_SETICON, ICON_SMALL, reinterpret_cast(icon)); // 创建首个标签页。 NewTab(); Relayout(); ShowWindow(m_hwnd, showCommand); UpdateWindow(m_hwnd); LoadCommandLineFiles(); return true; } void Application::Shutdown() { ReleaseBackBuffer(); m_fonts.Destroy(); 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(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(m_tabs.size())) { return fallback; } return m_tabs[static_cast(m_activeTab)]; } const TabConfig& Application::ActiveTab() const { static const TabConfig fallback; if (m_activeTab < 0 || m_activeTab >= static_cast(m_tabs.size())) { return fallback; } return m_tabs[static_cast(m_activeTab)]; } void Application::NewTab() { TabConfig tab; tab.SetName(NextTabName()); tab.SyncToDataSet(m_dataSet); m_tabs.push_back(std::move(tab)); SetActiveTab(static_cast(m_tabs.size()) - 1); } std::wstring Application::NextTabName() const { // 取当前未被占用的最小编号,使关闭标签页后编号可以被复用(3 开 2 关再新建回到「视图 2」)。 for (int number = 1; number <= static_cast(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(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(m_tabs.size())) { next = static_cast(m_tabs.size()) - 1; } SetActiveTab(next); } void Application::SetActiveTab(int index) { if (index < 0 || index >= static_cast(m_tabs.size())) { return; } m_activeTab = index; // 重新绑定上下文:面板与绘图区均指向同一数据层与当前标签页配置。 m_panel.SetContext(&m_dataSet, &m_tabs[static_cast(m_activeTab)]); m_plot.SetContext(&m_dataSet, &m_tabs[static_cast(m_activeTab)]); RefreshTabBar(); Relayout(); // 切换标签页后把该页已选中的信号滚动到可见位置。 m_panel.ScrollToSignal(m_tabs[static_cast(m_activeTab)].Selected()); SynchronizeView(true); UpdateWindowTitle(); InvalidateRect(m_hwnd, nullptr, TRUE); } void Application::RefreshTabBar() { std::vector 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(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()) { 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_panel.InvalidateRows(); // 强制下一次同步重新计算视图状态。 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.RemoveFile(fileIndex); SyncTabsToDataSet(); SetStatusMessage(L"已从工作区移除:" + name, false); UpdateWindowTitle(); } void Application::Relayout() { RECT client = {}; GetClientRect(m_hwnd, &client); const float width = static_cast(client.right - client.left); const float height = static_cast(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 statusHeight = ScaleOf(kStatusBarHeight, m_scale); 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); m_statusRect = gd::RectF(0.0f, height - statusHeight, width, statusHeight); const float contentTop = m_toolbarRect.Y + m_toolbarRect.Height; const float contentHeight = std::max(0.0f, m_statusRect.Y - 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); } void Application::EnsureBackBuffer(int width, int height) { if (m_backDc != nullptr && width == m_backWidth && height == m_backHeight) { return; } ReleaseBackBuffer(); 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()); if (!m_dataSet.Empty()) { m_toolbar.SetStatusText(L"视图 " + FormatValue(m_plot.ViewMinTime()) + L" ~ " + FormatValue(m_plot.ViewMaxTime())); } else { m_toolbar.SetStatusText(std::wstring()); } // 工具栏可切换按钮的选中状态始终与当前标签页配置保持一致。 m_toolbar.SetToggleState(ToolbarAction::ZOOM_TIME, m_plot.CurrentZoomMode() == PlotZoomMode::TIME); m_toolbar.SetToggleState(ToolbarAction::ZOOM_VALUE, m_plot.CurrentZoomMode() == PlotZoomMode::VALUE); m_toolbar.SetToggleState(ToolbarAction::CURSOR, m_plot.CursorEnabled()); { gd::Graphics g(m_backDc); g.SetSmoothingMode(gd::SmoothingModeAntiAlias); g.SetPixelOffsetMode(gd::PixelOffsetModeHalf); g.SetTextRenderingHint(gd::TextRenderingHintClearTypeGridFit); UiFillRect(g, gd::RectF(0.0f, 0.0f, static_cast(width), static_cast(height)), m_theme.windowBg); m_tabBar.Paint(g); m_toolbar.Paint(g); m_panel.Paint(g); m_plot.Paint(g); UiFillRect(g, m_splitterRect, m_theme.border); PaintStatusBar(g); // 弹出菜单与浮动拾色器始终绘制在最上层,保证与主界面风格一致。 m_menu.Paint(g); m_colorPicker.Paint(g); } HDC windowDc = GetDC(m_hwnd); if (windowDc != nullptr) { BitBlt(windowDc, 0, 0, width, height, m_backDc, 0, 0, SRCCOPY); ReleaseDC(m_hwnd, windowDc); } } void Application::PaintStatusBar(gd::Graphics& g) { UiFillRect(g, m_statusRect, m_theme.statusBg); UiDrawLine(g, m_statusRect.X, m_statusRect.Y, m_statusRect.X + m_statusRect.Width, m_statusRect.Y, m_theme.border, 1.0f); const float padding = ScaleOf(12.0f, m_scale); const float halfWidth = m_statusRect.Width * 0.5f; std::wstring leftText; if (!m_dataSet.Empty()) { const TabConfig& tab = ActiveTab(); leftText = std::to_wstring(m_dataSet.FileCount()) + L" 个文件 · " + FormatCount(static_cast(m_dataSet.SignalCount())) + L" 条信号 · " + std::to_wstring(tab.VisibleCount()) + L" 条已选 · " + std::to_wstring(tab.Lanes().size()) + L" 个网格"; } else { leftText = L"未加载数据 · 请点击「打开 CSV」或拖拽文件到窗口"; } UiText(g, UiEllipsize(g, leftText, m_fonts.uiSmall, halfWidth - padding * 2.0f), m_fonts.uiSmall, gd::RectF(m_statusRect.X + padding, m_statusRect.Y, halfWidth - padding, m_statusRect.Height), m_theme.text, TextAlign::LEFT); std::wstring rightText = kStatusHint; std::uint32_t rightColor = m_theme.textMuted; if (!m_statusMessage.empty()) { rightText = m_statusMessage; rightColor = m_statusIsError ? m_theme.danger : m_theme.textStrong; } else if (m_plot.CursorEnabled() && !m_dataSet.Empty()) { rightText = L"光标 · " + FormatValue(m_plot.CursorTime()); rightColor = m_theme.text; } UiText(g, UiEllipsize(g, rightText, m_fonts.uiSmall, halfWidth - padding * 2.0f), m_fonts.uiSmall, gd::RectF(m_statusRect.X + halfWidth, m_statusRect.Y, halfWidth - padding, m_statusRect.Height), rightColor, TextAlign::RIGHT); } void Application::SetStatusMessage(const std::wstring& text, bool isError) { m_statusMessage = text; m_statusIsError = isError; } void Application::UpdateWindowTitle() { std::wstring title = kWindowTitle; if (m_activeTab >= 0 && m_activeTab < static_cast(m_tabs.size())) { title += L" — "; title += m_tabs[static_cast(m_activeTab)].Name(); } if (!m_dataSet.Empty()) { title += L"(" + std::to_wstring(m_dataSet.FileCount()) + L" 个文件)"; } SetWindowTextW(m_hwnd, title.c_str()); } void Application::OpenFileDialog() { // 允许一次选择多个文件,便于跨文件对比信号。 std::vector buffer(static_cast(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(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; } // 同一文件允许多次加载:每次都会新建一个独立分组,各自持有独立的采样数据副本, // 因此后续对原始文件的修改或删除都不会影响已加载内容。 CsvTable table; std::wstring error; if (!LoadCsvTable(path, table, error)) { SetStatusMessage(L"解析失败:" + error, true); InvalidateRect(m_hwnd, nullptr, FALSE); return false; } int fileIndex = -1; if (!m_dataSet.AddFromTable(table, path, error, fileIndex)) { SetStatusMessage(L"解析失败:" + error, true); InvalidateRect(m_hwnd, nullptr, FALSE); return false; } // 新文件的信号在全部标签页中默认均为未勾选状态,由用户自行决定查看内容。 SyncTabsToDataSet(); UpdateWindowTitle(); const DataFile* file = m_dataSet.FileAt(fileIndex); if (file != nullptr) { std::wstring message = L"已加载 组" + std::to_wstring(fileIndex + 1) + L"|" + file->fileName + L"(" + FormatCount(static_cast(file->time.size())) + L" 行采样," + std::to_wstring(file->signals.size()) + L" 条信号,时间列:" + file->timeColumnName + L")"; if (!file->timeMonotonic) { message += L" 提示:时间列非单调递增,光标取值使用最近邻策略。"; } SetStatusMessage(message, false); } return true; } 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::ZOOM_TIME: if (m_plot.ToggleZoomMode(PlotZoomMode::TIME)) { SetStatusMessage((m_plot.CurrentZoomMode() == PlotZoomMode::TIME) ? L"时间轴缩放:在绘图区横向拖拽选择区间即可放大。" : L"已退出时间轴缩放模式。", false); } break; case ToolbarAction::ZOOM_VALUE: if (m_plot.ToggleZoomMode(PlotZoomMode::VALUE)) { SetStatusMessage((m_plot.CurrentZoomMode() == PlotZoomMode::VALUE) ? L"Y 轴缩放:在某个网格内纵向拖拽选择区间即可放大。" : L"已退出 Y 轴缩放模式。", false); } break; case ToolbarAction::CURSOR: if (m_plot.ToggleCursor()) { SetStatusMessage(m_plot.CursorEnabled() ? L"时间光标已启用:拖动竖线移动,双击竖线可输入精确时间。" : L"时间光标已关闭。", false); } break; case ToolbarAction::MERGE_LANES: if (!m_dataSet.Empty()) { ActiveTab().SetMode(m_dataSet, LaneMode::MERGED); SetStatusMessage(L"已将当前标签页的全部可见信号合并到同一网格。", false); } break; case ToolbarAction::SPLIT_LANES: if (!m_dataSet.Empty()) { ActiveTab().SetMode(m_dataSet, LaneMode::PER_SIGNAL); SetStatusMessage(L"当前标签页已切换为「每信号一格」。", false); } break; case ToolbarAction::SELECT_ALL: if (!m_dataSet.Empty()) { ActiveTab().SetAllVisible(m_dataSet, true); m_panel.InvalidateRows(); SetStatusMessage(L"已勾选当前标签页全部信号。", false); } break; case ToolbarAction::CLEAR_ALL: if (!m_dataSet.Empty()) { ActiveTab().SetAllVisible(m_dataSet, 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(m_tabs.size()); SetActiveTab((m_activeTab + (backward ? count - 1 : 1)) % count); } return true; } switch (key) { case VK_ESCAPE: m_plot.DisableCursor(); return true; 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; } 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(x - m_splitterGrabOffset) / m_scale; const float maximum = std::min(kMaxPanelWidth, static_cast(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::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::NONE: default: break; } } void Application::ShowTabContextMenu(const TabBarResult& result) { if (result.index < 0 || result.index >= static_cast(m_tabs.size())) { return; } m_tabMenuIndex = result.index; std::vector items; MenuItem header; header.kind = MenuItemKind::HEADER; header.label = m_tabs[static_cast(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(client.right - client.left), static_cast(client.bottom - client.top)); m_menu.Open(MenuScope::TAB_BAR, bounds, anchor.x, anchor.y, items); } 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::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(m_tabs.size()) && m_tabs.size() > 1) { const TabConfig keep = m_tabs[static_cast(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) { // 菜单打开时优先消费鼠标与键盘事件,形成模态交互。 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.OnMouseMove(GET_X_LPARAM(lParam), GET_Y_LPARAM(lParam))) { InvalidateRect(hwnd, nullptr, FALSE); } return 0; case WM_LBUTTONDOWN: { MenuResult result; m_menu.OnLButtonDown(GET_X_LPARAM(lParam), GET_Y_LPARAM(lParam), result); InvalidateRect(hwnd, nullptr, FALSE); return 0; } case WM_LBUTTONUP: { MenuResult result; m_menu.OnLButtonUp(GET_X_LPARAM(lParam), GET_Y_LPARAM(lParam), result); InvalidateRect(hwnd, nullptr, FALSE); 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_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(lParam); info->ptMinTrackSize.x = static_cast(ScaleOf(static_cast(kMinClientWidth), m_scale)); info->ptMinTrackSize.y = static_cast(ScaleOf(static_cast(kMinClientHeight), m_scale)); return 0; } case WM_DPICHANGED: { const UINT dpi = HIWORD(wParam); const RECT* suggested = reinterpret_cast(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: { 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)) != 0; if (HandleMouseMove(x, y, buttonDown)) { InvalidateRect(hwnd, nullptr, FALSE); } return 0; } case WM_MOUSELEAVE: { m_trackingLeave = false; bool changed = m_tabBar.OnMouseLeave(); changed = m_toolbar.OnMouseLeave() || changed; changed = m_panel.OnMouseLeave() || changed; changed = m_plot.OnMouseLeave() || changed; if (changed) { 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_plot.IsEditingCursor() && !m_plot.HitTest(x, y)) { m_plot.OnEditKey(VK_RETURN); } // 点击面板之外的位置时收起面板内展开的自定义颜色面板。 if (!m_panel.HitTest(x, y)) { if (m_panel.CloseTransientUi()) { InvalidateRect(hwnd, nullptr, FALSE); } } if (UiPointInRect(m_splitterRect, static_cast(x), static_cast(y))) { SetCapture(hwnd); m_draggingSplitter = true; m_activeTarget = Target::SPLITTER; m_splitterGrabOffset = x - static_cast(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)) { InvalidateRect(hwnd, nullptr, FALSE); } 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::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_LBUTTONDBLCLK: { const int x = GET_X_LPARAM(lParam); const int y = GET_Y_LPARAM(lParam); bool changed = false; 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); 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; } return 0; } case WM_RBUTTONUP: { 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(point.x, point.y, 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(point.x), static_cast(point.y))) { cursor = LoadCursorW(nullptr, IDC_SIZEWE); } else if (m_plot.HitTest(point.x, point.y)) { cursor = m_plot.QueryCursor(point.x, point.y); } if (cursor == nullptr) { cursor = LoadCursorW(nullptr, IDC_ARROW); } SetCursor(cursor); return TRUE; } break; } case WM_KEYDOWN: // 数值输入状态优先接管按键(回车提交、Esc 取消、退格删除)。 if (m_panel.IsEditingWidth()) { if (m_panel.OnEditKey(wParam)) { InvalidateRect(hwnd, nullptr, FALSE); } return 0; } if (m_plot.IsEditingCursor()) { if (m_plot.OnEditKey(wParam)) { InvalidateRect(hwnd, nullptr, FALSE); } return 0; } if (HandleKeyDown(wParam)) { InvalidateRect(hwnd, nullptr, TRUE); return 0; } break; case WM_CHAR: { // 数值输入仅接受数字与小数点(或科学计数法符号),其余字符一律吞掉。 if (m_panel.IsEditingWidth()) { if (m_panel.OnEditChar(static_cast(wParam))) { InvalidateRect(hwnd, nullptr, FALSE); } return 0; } if (m_plot.IsEditingCursor()) { if (m_plot.OnEditChar(static_cast(wParam))) { 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(wParam)); return 0; } case WM_DROPFILES: { HDROP drop = reinterpret_cast(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 buffer(static_cast(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