Files
Just-DataInspector/Components/Application/Src/Application.cpp
T
DeepSeek V4.1 Flash eaf5b9cadf feat(ui): 双光标样式统一与配色、光标滞留视野外、差值标签与拖拽、选区放大交互与提示
1) 光标 2 的样式与光标 1 完全一致,并改用更鲜艳的颜色
- 原先光标 2 用朝上的三角把手,观感突兀;现两条光标统一为同款朝下三角
- 主题新增 cursorAlt 并改用 Solarized 洋红 #D33682(原青色偏灰),与光标 1 的
  橙色 #CB4B16 同属暖色且更醒目,靠颜色而非形状区分

2) 光标允许滞留在网格可视区域之外(不再被强行夹回视野)
- 删除"视图变化后夹取光标"的逻辑(时间轴选区放大、适应视图、按网格时间轴展开、
  平移时都不再移动光标);仅在拖拽光标本身时才使用 ClampCursorToView(which)
- 新增 CursorDisplayX(which):光标在视野外时把它的显示位置"钉"在最近的网格边缘,
  取值标注与时间读数因此始终可见;数据点改为边缘三角指示(指向光标实际方向)
- 命中测试改用"显示横坐标",因此被钉在边缘的光标仍可抓住拖回

3) 光标读数增加细描边,避免与背景/曲线颜色过于接近
- 新增 DrawLabelOutline():按文字颜色取反色做 1px 半透明描边,
  网格内的取值胶囊、光标时间读数、差值标签统一使用

4) 双光标差值显示(时间轴上方居中,自适应单位,可拖拽、可编辑)
- 新增 FormatDuration()/FormatDurationNumber()/ChosenDurationUnit():
  在 h / min / s / ms / us / ns / ps 之间自动选择单位(数值落在 [1,1000))
- 差值标签位于两条时间读数的中点,样式与时间读数一致
- 双击进入编辑:输入值采用**当前显示单位**;确认后**前方(时间较小)光标不动**,
  后方光标移动到"前方 + 输入差值"
- 标签支持左右拖拽(新增 PlotDragMode::CURSOR_SPAN_MOVE):两条光标整体平移、间隔不变

5) 双光标内容重叠时的层次与差值标签位置
- 新增 m_activeCursor:最后操作过(拖拽/单击/编辑)的光标其内容绘制在上层,
  两条光标的竖线、取值胶囊、时间读数都按此顺序绘制
- 差值标签改为按**两条时间读数的实际净空**判断:放不下时自动落到主时间轴内部
  (原先按光标线间距判断,光标被钉到边缘时会用差值标签盖住时间读数)

6) 选区放大的光标形状与颜色
- 未开始框选时不再改变鼠标形状(保持箭头);按下但未拖动时为十字 IDC_CROSS;
  确定为时间轴选区后为左右箭头、Y 轴选区后为上下箭头
- 两种方向的选区统一使用强调色(原先 Y 轴选区用警示红,观感不一致)
- 新增 Application::UpdateDragCursor():Windows 在鼠标捕获期间不派发 WM_SETCURSOR,
  必须在 WM_MOUSEMOVE / WM_LBUTTONDOWN 后主动刷新光标形状,否则形状不会变化

7) 提示浮层加大并支持"停留后淡出",光标模式不再提示
- 提示字号由 uiSmall 改为 ui,胶囊 30px 高、内边距更大
- 新增停留 1300ms + 淡出 500ms 的时间控制;宿主用 WM_TIMER(33ms) 驱动重绘,
  淡出结束自动 KillTimer(文字走 GDI 不支持 alpha,改为向胶囊底色混色实现淡出)
- 「光标」三段式切换不再显示任何提示

验证([1].CSV 11 信号,窗口 1725x996,125% DPI):
- 双光标:橙 0.63 / 洋红 0.7812,同款把手;底部时间读数 + 居中差值 "151.2 ms"
- 放大到不含光标的区间:光标不移动,取值以边缘三角 + 胶囊贴边显示
- 拖拽差值标签:两光标从 0.63/0.7812 整体平移到 0.424202/0.575402,差值仍为 151.2 ms
- 差值编辑:输入 300(显示单位为 ms)→ 前方光标保持 0.63,后方移动到 0.93
- 重叠层次:差值设为 0 时洋红(活动)在上;单击光标线后橙色(活动)在上
- 光标形状:未框选 ARROW / 按下 CROSS / 横向 SIZEWE / 纵向 SIZENS(cursor.ps1 实测)
- 提示浮层:出现后约 1.3s 开始淡出、约 1.8s 消失;右键放弃选区后视图不变且模式保持选中
- 空闲 CPU 0.00%;make clean; make all 无警告无错误
2026-10-08 19:37:26 +08:00

2317 lines
64 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#include "Application.hpp"
#include <algorithm>
#include <atomic>
#include <cmath>
#include <thread>
#include <commdlg.h>
#include <shellapi.h>
#include <windowsx.h>
#include "CsvTable.hpp"
#include "ResourceIds.h"
#include "Utf8Util.hpp"
#ifndef WM_DPICHANGED
#define WM_DPICHANGED 0x02E0
#endif
#ifndef DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2
#define DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2 (reinterpret_cast<HANDLE>(-4))
#endif
namespace jdi
{
namespace
{
const wchar_t* kWindowClassName = L"JustDataInspectorMainWindow"; /**< 窗口类名。 */
const wchar_t* kWindowTitle = L"Just DataInspector"; /**< 窗口标题(仅显示应用名称)。 */
const float kSplitterWidth = 4.0f; /**< 分隔条宽度(逻辑像素)。 */
const float kMinPanelWidth = 260.0f; /**< 面板最小宽度(逻辑像素)。 */
const float kMaxPanelWidth = 400.0f; /**< 面板最大宽度(逻辑像素)。 */
const int kMinClientWidth = 860; /**< 窗口最小客户区宽度。 */
const int kMinClientHeight = 540; /**< 窗口最小客户区高度。 */
const UINT kMessageRemoveFile = WM_APP + 1; /**< 面板请求移除文件的自定义消息。 */
const UINT_PTR kHintTimerId = 1; /**< 提示浮层淡出用的定时器 ID。 */
const UINT kHintTimerPeriodMs = 33; /**< 提示浮层淡出的重绘间隔(约 30fps)。 */
/** @brief 标签页右键菜单命令 ID。 */
const int kMenuTabNew = 1;
const int kMenuTabClose = 2;
const int kMenuTabCloseOthers = 3;
/**
* @brief 从应用资源中按指定尺寸加载图标。
* @param [IN] cx 期望宽度(像素)。
* @param [IN] cy 期望高度(像素)。
* @return 图标句柄,加载失败返回 nullptr。
*/
HICON LoadAppIcon(int cx, int cy)
{
HINSTANCE module = GetModuleHandleW(nullptr);
// LR_DEFAULTSIZE 之外的显式尺寸请求会挑选 ICO 中最接近的一帧并由系统缩放,
// 因此 256/64/48/32/16 的多尺寸资源在任意 DPI 下都能取到合适的一帧。
HICON icon = static_cast<HICON>(LoadImageW(module, MAKEINTRESOURCEW(IDI_APP_ICON), IMAGE_ICON,
cx, cy, LR_DEFAULTCOLOR));
if (icon == nullptr)
{
icon = static_cast<HICON>(LoadImageW(module, MAKEINTRESOURCEW(IDI_APP_ICON), IMAGE_ICON, 0, 0,
LR_DEFAULTCOLOR | LR_DEFAULTSIZE));
}
return icon;
}
/**
* @brief 后台加载任务的共享状态。
*
* `done` 是唯一的同步点:工作线程写完其它字段后以 release 语义置位,
* 主线程以 acquire 语义读取,因此无需额外锁。
*/
struct AsyncLoadState
{
std::atomic<bool> done{false}; /**< 工作线程是否已结束。 */
std::atomic<bool> cancelled{false}; /**< 是否请求取消。 */
std::atomic<float> progress{0.0f}; /**< 0-1 进度。 */
bool ok = false; /**< 加载是否成功。 */
std::wstring error; /**< 失败原因。 */
DataFile file; /**< 构建结果。 */
};
/**
* @brief 工作线程主体:解析 CSV 并构建数据文件(不接触 DataSet)。
* @param [IN,OUT] state 共享状态。
* @param [IN] path 文件路径。
* @param [IN] paletteBase 调色板起始序号。
*/
void RunAsyncLoad(AsyncLoadState* state, std::wstring path, std::size_t paletteBase)
{
// 解析阶段占总进度的 0-75%,列重组与统计占 75-100%。
CsvProgress parseProgress = [state](double ratio)
{
state->progress.store(static_cast<float>(ratio * 0.75), std::memory_order_relaxed);
return !state->cancelled.load(std::memory_order_relaxed);
};
CsvTable table;
std::wstring error;
if (!LoadCsvTable(path, table, error, parseProgress))
{
state->error = error;
state->done.store(true, std::memory_order_release);
return;
}
CsvProgress buildProgress = [state](double ratio)
{
state->progress.store(static_cast<float>(0.75 + ratio * 0.25), std::memory_order_relaxed);
return !state->cancelled.load(std::memory_order_relaxed);
};
DataFile file;
if (!BuildDataFile(std::move(table), path, paletteBase, file, error, buildProgress))
{
state->error = error;
state->done.store(true, std::memory_order_release);
return;
}
state->file = std::move(file);
state->ok = true;
state->progress.store(1.0f, std::memory_order_relaxed);
state->done.store(true, std::memory_order_release);
}
/**
* @brief 开启进程级 DPI 感知(优先 Per-Monitor V2)。
*/
void EnableDpiAwareness()
{
HMODULE user32 = GetModuleHandleW(L"user32.dll");
if (user32 != nullptr)
{
typedef BOOL(WINAPI * SetProcessDpiAwarenessContextFn)(HANDLE);
SetProcessDpiAwarenessContextFn setContext =
reinterpret_cast<SetProcessDpiAwarenessContextFn>(
reinterpret_cast<void*>(GetProcAddress(user32, "SetProcessDpiAwarenessContext")));
if (setContext != nullptr && setContext(DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2) != FALSE)
{
return;
}
}
SetProcessDPIAware();
}
/**
* @brief 查询屏幕 DPI。
* @return DPI 数值,失败时返回 96。
*/
UINT QuerySystemDpi()
{
HDC hdc = GetDC(nullptr);
if (hdc == nullptr)
{
return 96u;
}
const int dpi = GetDeviceCaps(hdc, LOGPIXELSX);
ReleaseDC(nullptr, hdc);
return (dpi > 0) ? static_cast<UINT>(dpi) : 96u;
}
/**
* @brief 依据 DPI 缩放逻辑像素。
* @param [IN] value 逻辑像素值。
* @param [IN] scale 缩放比例。
* @return 物理像素值。
*/
float ScaleOf(float value, float scale)
{
return value * scale;
}
}
Application& Application::Instance()
{
static Application instance;
return instance;
}
LRESULT CALLBACK Application::WindowProc(HWND hwnd, UINT message, WPARAM wParam, LPARAM lParam)
{
Application* self = reinterpret_cast<Application*>(GetWindowLongPtrW(hwnd, GWLP_USERDATA));
if (message == WM_NCCREATE)
{
CREATESTRUCTW* createStruct = reinterpret_cast<CREATESTRUCTW*>(lParam);
self = reinterpret_cast<Application*>(createStruct->lpCreateParams);
SetWindowLongPtrW(hwnd, GWLP_USERDATA, reinterpret_cast<LONG_PTR>(self));
}
if (self != nullptr)
{
return self->HandleMessage(hwnd, message, wParam, lParam);
}
return DefWindowProcW(hwnd, message, wParam, lParam);
}
int Application::Run(HINSTANCE instance, int showCommand)
{
if (!Initialize(instance, showCommand))
{
return 1;
}
MSG message = {};
while (GetMessageW(&message, nullptr, 0, 0) > 0)
{
TranslateMessage(&message);
DispatchMessageW(&message);
}
Shutdown();
return static_cast<int>(message.wParam);
}
bool Application::Initialize(HINSTANCE instance, int showCommand)
{
m_instance = instance;
EnableDpiAwareness();
m_dpi = QuerySystemDpi();
m_scale = static_cast<float>(m_dpi) / 96.0f;
gd::GdiplusStartupInput startupInput;
if (gd::GdiplusStartup(&m_gdiplusToken, &startupInput, nullptr) != gd::Ok)
{
MessageBoxW(nullptr, L"GDI+ 初始化失败,程序无法启动。", kWindowTitle, MB_ICONERROR | MB_OK);
return false;
}
m_gdiplusReady = true;
m_theme = SolarizedLight();
if (!m_fonts.Create(m_dpi))
{
m_gdiplusReady = false;
gd::GdiplusShutdown(m_gdiplusToken);
MessageBoxW(nullptr, L"界面字体创建失败,程序无法启动。", kWindowTitle, MB_ICONERROR | MB_OK);
return false;
}
WNDCLASSEXW windowClass = {};
windowClass.cbSize = sizeof(windowClass);
windowClass.style = CS_HREDRAW | CS_VREDRAW | CS_DBLCLKS;
windowClass.lpfnWndProc = &Application::WindowProc;
windowClass.hInstance = instance;
windowClass.hCursor = LoadCursorW(nullptr, IDC_ARROW);
windowClass.hbrBackground = nullptr;
windowClass.lpszClassName = kWindowClassName;
// 使用应用自己的图标资源(IDI_APP_ICON);
// 原先取 IDI_APPLICATION 是系统默认图标,导致标题栏与任务栏缩略图显示成"通用应用"图标。
windowClass.hIcon = LoadAppIcon(GetSystemMetrics(SM_CXICON), GetSystemMetrics(SM_CYICON));
windowClass.hIconSm = LoadAppIcon(GetSystemMetrics(SM_CXSMICON), GetSystemMetrics(SM_CYSMICON));
if (RegisterClassExW(&windowClass) == 0)
{
MessageBoxW(nullptr, L"窗口类注册失败。", kWindowTitle, MB_ICONERROR | MB_OK);
return false;
}
// 依据工作区(排除任务栏)计算默认窗口尺寸并居中,避免窗口超出屏幕。
RECT workArea = {0, 0, static_cast<LONG>(ScaleOf(1380.0f, m_scale)),
static_cast<LONG>(ScaleOf(880.0f, m_scale))};
SystemParametersInfoW(SPI_GETWORKAREA, 0, &workArea, 0);
const int workWidth = workArea.right - workArea.left;
const int workHeight = workArea.bottom - workArea.top;
const int windowWidth = std::min(static_cast<int>(ScaleOf(1380.0f, m_scale)), workWidth - 24);
const int windowHeight = std::min(static_cast<int>(ScaleOf(880.0f, m_scale)), workHeight - 24);
const int windowX = workArea.left + std::max(0, (workWidth - windowWidth) / 2);
const int windowY = workArea.top + std::max(0, (workHeight - windowHeight) / 2);
m_hwnd = CreateWindowExW(WS_EX_ACCEPTFILES, kWindowClassName, kWindowTitle, WS_OVERLAPPEDWINDOW,
windowX, windowY, windowWidth, windowHeight, nullptr, nullptr, instance,
this);
if (m_hwnd == nullptr)
{
MessageBoxW(nullptr, L"窗口创建失败。", kWindowTitle, MB_ICONERROR | MB_OK);
return false;
}
m_tabBar.Setup(&m_fonts, &m_theme);
m_tabBar.SetScale(m_scale);
m_tabBar.SetHostWindow(m_hwnd);
m_toolbar.Setup(&m_fonts, &m_theme);
m_toolbar.SetScale(m_scale);
m_menu.Setup(&m_fonts, &m_theme);
m_menu.SetScale(m_scale);
m_colorPicker.Setup(&m_fonts, &m_theme);
m_colorPicker.SetScale(m_scale);
m_colorPicker.SetOwnerWindow(m_hwnd);
m_panel.Setup(m_hwnd, &m_fonts, &m_theme, &m_menu, &m_colorPicker);
m_panel.SetScale(m_scale);
m_plot.Setup(&m_fonts, &m_theme);
m_plot.SetScale(m_scale);
m_plot.SetHostWindow(m_hwnd);
m_menu.SetHostWindow(m_hwnd);
// 标题栏小图标与任务栏/Alt+Tab 缩略图大图标分开设置(同一个多尺寸 ICO 资源的不同尺寸)。
HICON bigIcon = LoadAppIcon(GetSystemMetrics(SM_CXICON), GetSystemMetrics(SM_CYICON));
HICON smallIcon = LoadAppIcon(GetSystemMetrics(SM_CXSMICON), GetSystemMetrics(SM_CYSMICON));
if (bigIcon != nullptr)
{
SendMessageW(m_hwnd, WM_SETICON, ICON_BIG, reinterpret_cast<LPARAM>(bigIcon));
}
if (smallIcon != nullptr)
{
SendMessageW(m_hwnd, WM_SETICON, ICON_SMALL, reinterpret_cast<LPARAM>(smallIcon));
}
// 创建首个标签页。
NewTab();
Relayout();
ShowWindow(m_hwnd, showCommand);
UpdateWindow(m_hwnd);
LoadCommandLineFiles();
return true;
}
void Application::Shutdown()
{
ReleaseBackBuffer();
m_fonts.Destroy();
if (m_gdiplusReady)
{
gd::GdiplusShutdown(m_gdiplusToken);
m_gdiplusReady = false;
}
}
void Application::ApplyDpi(UINT dpi)
{
if (dpi == 0)
{
return;
}
m_dpi = dpi;
m_scale = static_cast<float>(dpi) / 96.0f;
m_fonts.Create(dpi);
m_tabBar.SetScale(m_scale);
m_toolbar.SetScale(m_scale);
m_menu.SetScale(m_scale);
m_colorPicker.SetScale(m_scale);
m_panel.SetScale(m_scale);
m_plot.SetScale(m_scale);
ReleaseBackBuffer();
Relayout();
}
TabConfig& Application::ActiveTab()
{
static TabConfig fallback;
if (m_activeTab < 0 || m_activeTab >= static_cast<int>(m_tabs.size()))
{
return fallback;
}
return m_tabs[static_cast<std::size_t>(m_activeTab)];
}
const TabConfig& Application::ActiveTab() const
{
static const TabConfig fallback;
if (m_activeTab < 0 || m_activeTab >= static_cast<int>(m_tabs.size()))
{
return fallback;
}
return m_tabs[static_cast<std::size_t>(m_activeTab)];
}
void Application::NewTab()
{
TabConfig tab;
tab.SetName(NextTabName());
tab.SyncToDataSet(m_dataSet);
m_tabs.push_back(std::move(tab));
SetActiveTab(static_cast<int>(m_tabs.size()) - 1);
}
std::wstring Application::NextTabName() const
{
// 取当前未被占用的最小编号,使关闭标签页后编号可以被复用(3 开 2 关再新建回到「视图 2」)。
for (int number = 1; number <= static_cast<int>(m_tabs.size()) + 1; ++number)
{
const std::wstring candidate = L"视图 " + std::to_wstring(number);
bool used = false;
for (const TabConfig& tab : m_tabs)
{
if (tab.Name() == candidate)
{
used = true;
break;
}
}
if (!used)
{
return candidate;
}
}
return L"视图 " + std::to_wstring(m_tabs.size() + 1);
}
void Application::CloseTab(int index)
{
if (index < 0 || index >= static_cast<int>(m_tabs.size()))
{
return;
}
if (m_tabs.size() <= 1)
{
SetStatusMessage(L"至少需要保留一个标签页。", true);
return;
}
m_tabs.erase(m_tabs.begin() + index);
int next = index;
if (next >= static_cast<int>(m_tabs.size()))
{
next = static_cast<int>(m_tabs.size()) - 1;
}
SetActiveTab(next);
}
void Application::SetActiveTab(int index)
{
if (index < 0 || index >= static_cast<int>(m_tabs.size()))
{
return;
}
m_activeTab = index;
// 重新绑定上下文:面板与绘图区均指向同一数据层与当前标签页配置。
m_panel.SetContext(&m_dataSet, &m_tabs[static_cast<std::size_t>(m_activeTab)]);
m_plot.SetContext(&m_dataSet, &m_tabs[static_cast<std::size_t>(m_activeTab)]);
RefreshTabBar();
Relayout();
// 切换标签页后把该页已选中的信号滚动到可见位置。
// 切换标签页后把共享的选中信号滚动到可见位置(选中状态跨页保持一致)。
m_panel.ScrollToSignal(m_dataSet.Selected());
SynchronizeView(true);
UpdateWindowTitle();
InvalidateRect(m_hwnd, nullptr, TRUE);
}
void Application::RefreshTabBar()
{
std::vector<std::wstring> names;
names.reserve(m_tabs.size());
for (const TabConfig& tab : m_tabs)
{
names.push_back(tab.Name());
}
m_tabBar.SetTabs(names);
m_tabBar.SetActiveIndex(m_activeTab);
}
void Application::SynchronizeView(bool force)
{
if (m_tabs.empty() || m_activeTab < 0)
{
return;
}
TabConfig& tab = m_tabs[static_cast<std::size_t>(m_activeTab)];
const int visibleCount = tab.VisibleCount();
const bool anyVisible = tab.AnyVisible();
const std::size_t laneCount = tab.Lanes().size();
const bool changed = (visibleCount != m_cachedVisibleCount) || (anyVisible != m_cachedAnyVisible) ||
(laneCount != m_cachedLaneCount);
if (!changed && !force)
{
return;
}
const std::size_t previousLaneCount = m_cachedLaneCount;
m_cachedVisibleCount = visibleCount;
m_cachedAnyVisible = anyVisible;
m_cachedLaneCount = laneCount;
// 标签页首次出现可见信号时,把时间轴对齐到可见数据的范围。
// 没有任何可见信号时(未勾选 / 全部取消勾选 / 清空网格)主动复位自适应标记,
// 这样用户下一次勾选信号时会重新做一次自适应,而不是停留在上次的视图范围。
if (!anyVisible)
{
tab.ViewInitialized() = false;
}
else if (!tab.ViewInitialized())
{
m_plot.FitAll();
}
const bool laneCountChanged = (laneCount != previousLaneCount);
if (laneCountChanged || force)
{
// 网格数量变化会影响滚动条与绘图区宽度,必须重新布局。
Relayout();
}
else
{
m_plot.RefreshLanes();
}
}
void Application::SyncTabsToDataSet()
{
for (TabConfig& tab : m_tabs)
{
tab.SyncToDataSet(m_dataSet);
}
// 选中状态由数据层共享维护,数据变动后需校验其是否仍指向存在的信号。
m_dataSet.ValidateSelection();
m_panel.InvalidateRows();
// 文件/信号结构变化会让信号引用与文件地址改变,折线顶点缓存必须作废。
m_plot.InvalidatePolylineCache();
// 强制下一次同步重新计算视图状态。
m_cachedVisibleCount = -1;
m_cachedAnyVisible = false;
m_cachedLaneCount = 0;
if (m_hwnd != nullptr)
{
InvalidateRect(m_hwnd, nullptr, TRUE);
}
}
void Application::RemoveFile(int fileIndex)
{
const DataFile* file = m_dataSet.FileAt(fileIndex);
if (file == nullptr)
{
return;
}
const std::wstring name = file->fileName;
// 先按旧下标修正各标签页配置与共享选中项,再从数据层移除,最后统一对齐。
for (TabConfig& tab : m_tabs)
{
tab.RemapAfterFileRemoval(fileIndex);
}
m_dataSet.RemapSelectionAfterFileRemoval(fileIndex);
m_dataSet.RemoveFile(fileIndex);
SyncTabsToDataSet();
SetStatusMessage(L"已从工作区移除:" + name, false);
UpdateWindowTitle();
}
void Application::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);
}
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());
// 工具栏可切换按钮的选中状态始终与当前标签页配置保持一致。
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());
{
gd::Graphics g(m_backDc);
g.SetSmoothingMode(gd::SmoothingModeAntiAlias);
g.SetPixelOffsetMode(gd::PixelOffsetModeHalf);
g.SetTextRenderingHint(gd::TextRenderingHintClearTypeGridFit);
// 工具栏/标签栏/面板各自带悬停态,必须每帧绘制;
// 真正昂贵的是绘图区的波形(大文件约 80ms/帧),
// 由 PlotView 内部的静态场景缓存消除,与数据量无关。
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);
m_plot.Paint(g);
UiFillRect(g, m_splitterRect, m_theme.border);
// 弹出菜单与浮动拾色器始终绘制在最上层,保证与主界面风格一致。
m_menu.Paint(g);
m_colorPicker.Paint(g);
// 加载遮罩层在一切内容之上(包括菜单与拾色器),形成模态观感。
PaintLoadingOverlay(g, width, height);
}
HDC windowDc = GetDC(m_hwnd);
if (windowDc != nullptr)
{
BitBlt(windowDc, 0, 0, width, height, m_backDc, 0, 0, SRCCOPY);
ReleaseDC(m_hwnd, windowDc);
}
// 提示浮层需要停留后淡出:仅在其仍可见时保持定时重绘,淡出结束即停表。
if (m_plot.HintNeedsAnimation())
{
if (m_hintTimerActive == false)
{
SetTimer(m_hwnd, kHintTimerId, kHintTimerPeriodMs, nullptr);
m_hintTimerActive = true;
}
}
else if (m_hintTimerActive)
{
KillTimer(m_hwnd, kHintTimerId);
m_hintTimerActive = false;
}
}
void Application::SetStatusMessage(const std::wstring& text, bool isError)
{
// 界面不再占用底部空间显示常驻提示;仅把真实错误以对话框形式反馈给用户。
if (!isError || text.empty())
{
return;
}
MessageBoxW(m_hwnd, text.c_str(), kWindowTitle, MB_ICONWARNING | MB_OK);
}
void Application::UpdateWindowTitle()
{
// 标题栏只显示应用名称,不附带标签页、文件数量等无关信息。
SetWindowTextW(m_hwnd, kWindowTitle);
}
void Application::OpenFileDialog()
{
// 允许一次选择多个文件,便于跨文件对比信号。
std::vector<wchar_t> buffer(static_cast<std::size_t>(MAX_PATH) * 16, L'\0');
OPENFILENAMEW dialog = {};
dialog.lStructSize = sizeof(dialog);
dialog.hwndOwner = m_hwnd;
dialog.lpstrFilter = L"CSV 数据文件 (*.csv)\0*.csv\0文本数据文件 (*.txt)\0*.txt\0所有文件 (*.*)\0*.*\0\0";
dialog.lpstrFile = buffer.data();
dialog.nMaxFile = static_cast<DWORD>(buffer.size());
dialog.lpstrTitle = L"打开仿真数据文件(可多选)";
dialog.lpstrDefExt = L"csv";
dialog.Flags = OFN_FILEMUSTEXIST | OFN_PATHMUSTEXIST | OFN_EXPLORER | OFN_NOCHANGEDIR |
OFN_ALLOWMULTISELECT;
if (GetOpenFileNameW(&dialog) == FALSE)
{
return;
}
// 多选时首段为目录,其余为文件名;单选时整段即为完整路径。
const std::wstring first(buffer.data());
const wchar_t* cursor = buffer.data() + first.size() + 1;
int loaded = 0;
if (*cursor == L'\0')
{
if (LoadFile(first))
{
++loaded;
}
}
else
{
while (*cursor != L'\0')
{
const std::wstring name(cursor);
if (LoadFile(first + L"\\" + name))
{
++loaded;
}
cursor += name.size() + 1;
}
}
if (loaded > 0)
{
SetStatusMessage(L"已加载 " + std::to_wstring(loaded) +
L" 个文件,请在左侧勾选需要查看的信号。",
false);
}
}
bool Application::LoadFile(const std::wstring& path)
{
if (path.empty())
{
return false;
}
// 大文件在工作线程解析并构建,主线程播放加载过渡动画,界面不再整块卡住。
DataFile file;
std::wstring error;
if (!LoadFileAsync(path, file, error))
{
if (!error.empty())
{
SetStatusMessage(L"解析失败:" + error, true);
}
InvalidateRect(m_hwnd, nullptr, FALSE);
return false;
}
int fileIndex = -1;
// 此处只做一次指针/缓冲移动,不拷贝任何采样数据,因此不会造成可见卡顿。
if (!m_dataSet.AddPreparedFile(std::move(file), fileIndex))
{
SetStatusMessage(L"解析失败:数据为空。", true);
InvalidateRect(m_hwnd, nullptr, FALSE);
return false;
}
// 新文件的信号在全部标签页中默认均为未勾选状态,由用户自行决定查看内容。
SyncTabsToDataSet();
UpdateWindowTitle();
const DataFile* loaded = m_dataSet.FileAt(fileIndex);
if (loaded != nullptr)
{
std::wstring message = L"已加载 组" + std::to_wstring(fileIndex + 1) + L"|" + loaded->Title() +
L"(" + FormatCount(static_cast<unsigned long long>(loaded->time.size())) +
L" 行采样," + std::to_wstring(loaded->signals.size()) +
L" 条信号,时间列:" + loaded->timeColumnName + L")";
if (!loaded->timeMonotonic)
{
message += L" 提示:时间列非单调递增,光标取值使用最近邻策略。";
}
SetStatusMessage(message, false);
}
return true;
}
bool Application::LoadFileAsync(const std::wstring& path, DataFile& file, std::wstring& error)
{
error.clear();
AsyncLoadState state;
const std::size_t paletteBase = m_dataSet.SignalCount();
m_loading = true;
m_loadingProgress = 0.0f;
const std::size_t slashPos = path.find_last_of(L"\\/");
m_loadingText = (slashPos == std::wstring::npos) ? path : path.substr(slashPos + 1);
std::thread worker(RunAsyncLoad, &state, path, paletteBase);
// 主线程循环:抽送消息 + 刷新动画,直到后台完成或用户取消。
while (!state.done.load(std::memory_order_acquire))
{
MSG message = {};
while (PeekMessageW(&message, nullptr, 0, 0, PM_REMOVE))
{
if (message.message == WM_QUIT)
{
// 退出请求:让后台停下并重新投递 WM_QUIT,由主循环正常收尾。
state.cancelled.store(true, std::memory_order_relaxed);
PostQuitMessage(static_cast<int>(message.wParam));
break;
}
// 加载期间屏蔽鼠标与其它按键,避免重入加载或误改数据;
// 保留 Esc 作为取消入口。
if (message.message >= WM_MOUSEFIRST && message.message <= WM_MOUSELAST)
{
continue;
}
if (message.message == WM_KEYDOWN && message.wParam == VK_ESCAPE)
{
state.cancelled.store(true, std::memory_order_relaxed);
continue;
}
if (message.message >= WM_KEYFIRST && message.message <= WM_KEYLAST)
{
continue;
}
TranslateMessage(&message);
DispatchMessageW(&message);
}
m_loadingProgress = state.progress.load(std::memory_order_relaxed);
InvalidateRect(m_hwnd, nullptr, FALSE);
UpdateWindow(m_hwnd);
Sleep(15);
}
m_loadingProgress = 1.0f;
worker.join();
m_loading = false;
if (state.cancelled.load(std::memory_order_relaxed))
{
// 用户主动取消:不弹错误框(与 Esc 取消缩放选区的静默语义一致)。
InvalidateRect(m_hwnd, nullptr, TRUE);
return false;
}
if (!state.ok)
{
error = state.error;
return false;
}
file = std::move(state.file);
return true;
}
void Application::PaintLoadingOverlay(gd::Graphics& g, int width, int height)
{
if (!m_loading)
{
return;
}
// 半透明遮罩压暗整个界面,形成明确的"正在加载"状态。
UiFillRect(g, gd::RectF(0.0f, 0.0f, static_cast<float>(width), static_cast<float>(height)),
0x66000000u);
const float cardWidth = std::min(420.0f * m_scale, static_cast<float>(width) - 40.0f * m_scale);
const float cardHeight = 132.0f * m_scale;
const gd::RectF card((static_cast<float>(width) - cardWidth) * 0.5f,
(static_cast<float>(height) - cardHeight) * 0.5f, cardWidth, cardHeight);
UiFillRoundRect(g, card, 8.0f * m_scale, m_theme.cardBg);
UiDrawRoundRect(g, card, 8.0f * m_scale, m_theme.borderStrong, 1.0f);
// 旋转弧线:用时间驱动的起始角度形成持续的加载动画。
const float spinnerRadius = 11.0f * m_scale;
const gd::RectF spinner(card.X + 26.0f * m_scale, card.Y + 30.0f * m_scale - spinnerRadius,
spinnerRadius * 2.0f, spinnerRadius * 2.0f);
m_loadingSpin += 12.0f;
if (m_loadingSpin >= 360.0f)
{
m_loadingSpin -= 360.0f;
}
gd::Pen trackPen(ToGdiColor(m_theme.border), 3.0f * m_scale);
gd::Pen spinPen(ToGdiColor(m_theme.accent), 3.0f * m_scale);
g.DrawArc(&trackPen, spinner, 0.0f, 359.0f);
g.DrawArc(&spinPen, spinner, m_loadingSpin, 110.0f);
const float textLeft = card.X + 52.0f * m_scale;
const float textWidth = cardWidth - 52.0f * m_scale - 20.0f * m_scale;
UiText(g, L"正在加载数据…", m_fonts.uiBold,
gd::RectF(textLeft, card.Y + 18.0f * m_scale, textWidth, 22.0f * m_scale), m_theme.text,
TextAlign::LEFT);
// 超长文件名用省略号截断,避免撑破卡片。
const std::wstring nameText =
UiEllipsize(g, m_loadingText, m_fonts.uiSmall, textWidth);
UiText(g, nameText, m_fonts.uiSmall,
gd::RectF(textLeft, card.Y + 42.0f * m_scale, textWidth, 20.0f * m_scale),
m_theme.textMuted, TextAlign::LEFT);
// 进度条(复用样式区滑块以外的简单圆角矩形,填充宽度严格按比例)。
const gd::RectF track(card.X + 26.0f * m_scale, card.Y + 76.0f * m_scale,
cardWidth - 52.0f * m_scale, 8.0f * m_scale);
UiFillRoundRect(g, track, track.Height * 0.5f, m_theme.pressed);
float ratio = m_loadingProgress;
ratio = std::max(0.0f, std::min(ratio, 1.0f));
const float fillWidth = track.Width * ratio;
if (fillWidth > 1.0f)
{
UiFillRoundRect(g, gd::RectF(track.X, track.Y, fillWidth, track.Height), track.Height * 0.5f,
m_theme.accent);
}
wchar_t buffer[64];
swprintf(buffer, sizeof(buffer) / sizeof(buffer[0]), L"%d%%", static_cast<int>(ratio * 100.0f + 0.5f));
UiText(g, buffer, m_fonts.monoSmall,
gd::RectF(track.X, card.Y + 92.0f * m_scale, track.Width, 20.0f * m_scale), m_theme.text,
TextAlign::LEFT);
UiText(g, L"按 Esc 取消", m_fonts.uiSmall,
gd::RectF(track.X, card.Y + 92.0f * m_scale, track.Width, 20.0f * m_scale),
m_theme.textMuted, TextAlign::RIGHT);
}
void Application::LoadCommandLineFiles()
{
int argumentCount = 0;
LPWSTR* arguments = CommandLineToArgvW(GetCommandLineW(), &argumentCount);
if (arguments == nullptr)
{
return;
}
int loaded = 0;
for (int index = 1; index < argumentCount; ++index)
{
const std::wstring argument = arguments[index];
if (argument.empty() || argument[0] == L'-' || argument[0] == L'/')
{
continue;
}
if (LoadFile(argument))
{
++loaded;
}
}
LocalFree(arguments);
if (loaded > 0)
{
SetStatusMessage(L"已加载 " + std::to_wstring(loaded) +
L" 个文件,请在左侧勾选需要查看的信号。",
false);
}
}
void Application::ExecuteToolbarAction(ToolbarAction action)
{
switch (action)
{
case ToolbarAction::OPEN_FILE:
OpenFileDialog();
break;
case ToolbarAction::FIT_ALL:
// 自适应同时回到初始状态:退出缩放模式并显示全部信号范围。
m_plot.FitAll();
SetStatusMessage(L"已恢复自适应视图。", false);
break;
case ToolbarAction::AUTO_FIT_Y:
{
// 与网格右键菜单中的「选区 Y 轴自适应(全局)」同一开关:
// 打开后立即按当前视图自适应一次,之后每次时间轴选区放大都会自动匹配。
const bool enable = !m_plot.AllLanesAutoFitToSelection();
m_plot.SetAllLanesAutoFitToSelection(enable, true);
SetStatusMessage(enable ? L"已开启选区 Y 轴自适应:时间轴选区放大后自动匹配各网格 Y 轴。"
: L"已关闭选区 Y 轴自适应。",
false);
break;
}
case ToolbarAction::ZOOM_SELECT:
m_plot.ToggleZoomMode(PlotZoomMode::SELECT);
break;
case ToolbarAction::CURSOR:
m_plot.CycleCursorMode();
break;
case ToolbarAction::MERGE_LANES:
if (!m_dataSet.Empty())
{
ActiveTab().SetMode(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::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<int>(m_tabs.size());
SetActiveTab((m_activeTab + (backward ? count - 1 : 1)) % count);
}
return true;
}
switch (key)
{
case VK_ESCAPE:
{
// Esc 的语义:
// 1) 放弃当前正在进行的拖拽(典型场景:缩放的框选选区还没松开左键);
// 取消后不应用选区、视图范围保持不变;
// 2) 同时退出全部缩放模式,使工具栏「时间轴选区」「Y 轴选区」回到未选中态。
// Esc 不与时间光标关联:关闭光标请用工具栏「光标」按钮,避免误关指针。
bool changed = m_plot.CancelDrag();
changed = m_plot.ExitZoomModes() || changed;
return changed;
}
case VK_ADD:
case VK_OEM_PLUS:
m_plot.ZoomTimeStep(1.4);
return true;
case VK_SUBTRACT:
case VK_OEM_MINUS:
m_plot.ZoomTimeStep(1.0 / 1.4);
return true;
case VK_LEFT:
m_plot.PanTimeByFraction(-0.12);
return true;
case VK_RIGHT:
m_plot.PanTimeByFraction(0.12);
return true;
case VK_UP:
m_plot.ScrollLanesByFraction(-0.12);
return true;
case VK_DOWN:
m_plot.ScrollLanesByFraction(0.12);
return true;
default:
break;
}
if (key == L'F')
{
m_plot.FitAll();
return true;
}
return false;
}
void Application::UpdateDragCursor(int x, int y)
{
// 鼠标捕获期间不再有 WM_SETCURSOR,只能自己按当前状态设置光标。
HCURSOR cursor = nullptr;
if (m_activeTarget == Target::PLOT)
{
cursor = m_plot.QueryCursor(x, y);
}
SetCursor(cursor != nullptr ? cursor : LoadCursorW(nullptr, IDC_ARROW));
}
bool Application::HandleMouseMove(int x, int y, bool buttonDown)
{
if (buttonDown && m_activeTarget != Target::NONE)
{
switch (m_activeTarget)
{
case Target::SPLITTER:
if (m_draggingSplitter && m_scale > 0.0f)
{
const float desired = static_cast<float>(x - m_splitterGrabOffset) / m_scale;
const float maximum =
std::min(kMaxPanelWidth, static_cast<float>(kMinClientWidth) / m_scale);
m_panelWidth = std::max(kMinPanelWidth, std::min(desired, maximum));
Relayout();
return true;
}
return false;
case Target::PLOT:
return m_plot.OnMouseMove(x, y);
case Target::PANEL:
return m_panel.OnMouseMove(x, y);
case Target::TOOLBAR:
return m_toolbar.OnMouseMove(x, y);
case Target::TAB_BAR:
return m_tabBar.OnMouseMove(x, y);
case Target::EDIT_FIELD:
{
// 输入框拖拽框选:按当前正在编辑的组件分发。
bool changed = m_tabBar.OnEditMouseMove(x);
changed = m_panel.OnEditMouseMove(x) || changed;
changed = m_plot.OnEditMouseMove(x) || changed;
changed = m_colorPicker.OnEditMouseMove(x) || changed;
return changed;
}
case Target::NONE:
default:
return false;
}
}
bool changed = false;
changed =
(m_tabBar.HitTest(x, y) ? m_tabBar.OnMouseMove(x, y) : m_tabBar.OnMouseLeave()) || changed;
changed =
(m_toolbar.HitTest(x, y) ? m_toolbar.OnMouseMove(x, y) : m_toolbar.OnMouseLeave()) || changed;
changed = (m_panel.HitTest(x, y) ? m_panel.OnMouseMove(x, y) : m_panel.OnMouseLeave()) || changed;
changed = (m_plot.HitTest(x, y) ? m_plot.OnMouseMove(x, y) : m_plot.OnMouseLeave()) || changed;
return changed;
}
void Application::OnTabBarResult(const TabBarResult& result)
{
switch (result.type)
{
case TabBarResult::Type::ADD:
NewTab();
break;
case TabBarResult::Type::SELECT:
if (result.index != m_activeTab)
{
SetActiveTab(result.index);
}
break;
case TabBarResult::Type::CLOSE:
CloseTab(result.index);
break;
case TabBarResult::Type::CONTEXT:
ShowTabContextMenu(result);
break;
case TabBarResult::Type::REORDER:
MoveTab(result.index, result.targetIndex);
break;
case TabBarResult::Type::RENAME:
if (result.index >= 0 && result.index < static_cast<int>(m_tabs.size()) && !result.text.empty())
{
m_tabs[static_cast<std::size_t>(result.index)].SetName(result.text);
RefreshTabBar();
// SetTabs 会重置各标签的矩形,必须重新布局才能把它们算回来。
Relayout();
InvalidateRect(m_hwnd, nullptr, TRUE);
}
break;
case TabBarResult::Type::NONE:
default:
break;
}
}
void Application::MoveTab(int from, int target)
{
if (from < 0 || from >= static_cast<int>(m_tabs.size()))
{
return;
}
if (target < 0 || target > static_cast<int>(m_tabs.size()))
{
return;
}
// 拖拽语义:target 表示「插入到该下标之前」;向后拖时需扣除自身占位。
int destination = target;
if (destination > from)
{
--destination;
}
destination = std::max(0, std::min(destination, static_cast<int>(m_tabs.size()) - 1));
if (destination == from)
{
return;
}
// 保持当前激活的标签页在重排后仍处于激活状态。
TabConfig moved = m_tabs[static_cast<std::size_t>(from)];
const int activeIndex = m_activeTab;
m_tabs.erase(m_tabs.begin() + from);
m_tabs.insert(m_tabs.begin() + destination, std::move(moved));
int newActive = activeIndex;
if (activeIndex == from)
{
newActive = destination;
}
else if (from < activeIndex && destination >= activeIndex)
{
--newActive;
}
else if (from > activeIndex && destination <= activeIndex)
{
++newActive;
}
SetActiveTab(newActive);
InvalidateRect(m_hwnd, nullptr, TRUE);
}
bool Application::ApplyPendingRename()
{
SignalRef ref;
std::wstring name;
if (!m_plot.TakePendingRename(ref, name))
{
return false;
}
if (!m_dataSet.SetSignalDisplayName(ref.fileIndex, ref.signalIndex, name))
{
return false;
}
// 改名只影响显示,但需要刷新左侧列表与绘图区图例。
m_panel.InvalidateRows();
SetStatusMessage(L"信号已重命名为:" + name, false);
InvalidateRect(m_hwnd, nullptr, TRUE);
return true;
}
void Application::ShowTabContextMenu(const TabBarResult& result)
{
if (result.index < 0 || result.index >= static_cast<int>(m_tabs.size()))
{
return;
}
m_tabMenuIndex = result.index;
std::vector<MenuItem> items;
MenuItem header;
header.kind = MenuItemKind::HEADER;
header.label = m_tabs[static_cast<std::size_t>(result.index)].Name();
items.push_back(header);
MenuItem create;
create.label = L"新建标签页";
create.id = kMenuTabNew;
items.push_back(create);
MenuItem close;
close.label = L"关闭该标签页";
close.id = kMenuTabClose;
close.enabled = (m_tabs.size() > 1);
items.push_back(close);
MenuItem closeOthers;
closeOthers.label = L"关闭其它标签页";
closeOthers.id = kMenuTabCloseOthers;
closeOthers.enabled = (m_tabs.size() > 1);
items.push_back(closeOthers);
// 菜单绘制在客户区内,因此把屏幕坐标换算为客户区坐标。
POINT anchor = {result.screenX, result.screenY};
ScreenToClient(m_hwnd, &anchor);
RECT client = {};
GetClientRect(m_hwnd, &client);
const gd::RectF bounds(0.0f, 0.0f, static_cast<float>(client.right - client.left),
static_cast<float>(client.bottom - client.top));
m_menu.Open(MenuScope::TAB_BAR, bounds, anchor.x, anchor.y, items);
}
void Application::ShowPlotContextMenu(int laneIndex, int x, int y)
{
if (laneIndex < 0)
{
return;
}
m_plotMenuLane = laneIndex;
std::vector<MenuItem> items;
MenuItem header;
header.kind = MenuItemKind::HEADER;
header.label = L"网格 " + std::to_wstring(laneIndex + 1);
items.push_back(header);
// 当前网格的 Y 轴上下限值直接在菜单内的输入框里显示,单击即可就地编辑(菜单不关闭)。
double laneYMin = 0.0;
double laneYMax = 1.0;
m_plot.LaneRange(laneIndex, laneYMin, laneYMax);
MenuItem maxItem;
maxItem.kind = MenuItemKind::EDIT;
maxItem.label = L"Y 轴最大值";
maxItem.id = PLOT_CMD_LANE_Y_MAX;
maxItem.value = FormatValue(laneYMax);
maxItem.filter = EditFilter::NUMBER;
items.push_back(maxItem);
MenuItem minItem;
minItem.kind = MenuItemKind::EDIT;
minItem.label = L"Y 轴最小值";
minItem.id = PLOT_CMD_LANE_Y_MIN;
minItem.value = FormatValue(laneYMin);
minItem.filter = EditFilter::NUMBER;
items.push_back(minItem);
// 选区自适应(切换项):勾选后每次本网格的时间轴选区放大都会自动匹配 Y 轴幅值。
MenuItem autoViewItem;
autoViewItem.label = L"选区 Y 轴自适应";
autoViewItem.id = PLOT_CMD_LANE_AUTO_VIEW;
autoViewItem.checked = m_plot.LaneAutoFitToSelection(laneIndex);
items.push_back(autoViewItem);
// 时间轴自适应到本网格信号范围(原先位于分隔线下方,现移到分隔线上方)。
MenuItem fitLaneTime;
fitLaneTime.label = L"按网格时间轴展开";
fitLaneTime.id = PLOT_CMD_LANE_FIT_TIME;
items.push_back(fitLaneTime);
MenuItem separator;
separator.kind = MenuItemKind::SEPARATOR;
items.push_back(separator);
// 全局开关:一次性打开/关闭全部网格各自的「选区 Y 轴自适应」。
MenuItem autoFitAllItem;
autoFitAllItem.label = L"选区 Y 轴自适应(全局)";
autoFitAllItem.id = PLOT_CMD_AUTO_FIT_ALL;
autoFitAllItem.checked = m_plot.AllLanesAutoFitToSelection();
items.push_back(autoFitAllItem);
MenuItem fitItem;
fitItem.label = L"全部自适应";
fitItem.id = PLOT_CMD_FIT_ALL;
items.push_back(fitItem);
RECT client = {};
GetClientRect(m_hwnd, &client);
const gd::RectF bounds(0.0f, 0.0f, static_cast<float>(client.right - client.left),
static_cast<float>(client.bottom - client.top));
m_menu.Open(MenuScope::PLOT_VIEW, bounds, x, y, items);
}
void Application::RefreshPlotMenuValues(int laneIndex)
{
if (laneIndex < 0)
{
return;
}
double laneYMin = 0.0;
double laneYMax = 1.0;
m_plot.LaneRange(laneIndex, laneYMin, laneYMax);
m_menu.UpdateItemValue(PLOT_CMD_LANE_Y_MAX, FormatValue(laneYMax));
m_menu.UpdateItemValue(PLOT_CMD_LANE_Y_MIN, FormatValue(laneYMin));
// 「选区 Y 轴自适应」(本网格)与「(全局)」都是切换项,实时反映各自的勾选状态。
m_menu.UpdateItemLabel(PLOT_CMD_LANE_AUTO_VIEW, L"选区 Y 轴自适应",
m_plot.LaneAutoFitToSelection(laneIndex), true);
m_menu.UpdateItemLabel(PLOT_CMD_AUTO_FIT_ALL, L"选区 Y 轴自适应(全局)",
m_plot.AllLanesAutoFitToSelection(), true);
}
void Application::DispatchMenuResult(const MenuResult& result)
{
if (!result.valid)
{
return;
}
switch (result.scope)
{
case MenuScope::SIGNAL_PANEL:
{
const bool needRelayout = m_panel.OnMenuCommand(result);
if (needRelayout)
{
Relayout();
}
InvalidateRect(m_hwnd, nullptr, FALSE);
break;
}
case MenuScope::PLOT_VIEW:
{
// 菜单内联输入框的提交:就地更新网格 Y 轴上下限,菜单保持打开,
// 并把夹取后的真实值回写到菜单上(输入值可能被合法区间修正)。
if (result.editCommit)
{
const int laneIndex = m_plotMenuLane;
if (laneIndex >= 0)
{
m_plot.ApplyLaneValueText(laneIndex, result.id == PLOT_CMD_LANE_Y_MAX, result.editText);
RefreshPlotMenuValues(laneIndex);
}
SetFocus(m_hwnd);
InvalidateRect(m_hwnd, nullptr, FALSE);
break;
}
const int laneIndex = m_plotMenuLane;
m_plotMenuLane = -1;
switch (result.id)
{
case PLOT_CMD_LANE_AUTO_VIEW:
// 切换本网格的「选区 Y 轴自适应」。
m_plot.ToggleLaneAutoFitToSelection(laneIndex);
break;
case PLOT_CMD_AUTO_FIT_ALL:
{
// 全局切换:一次打开/关闭全部网格各自的开关;打开时立即生效一次。
const bool enable = !m_plot.AllLanesAutoFitToSelection();
m_plot.SetAllLanesAutoFitToSelection(enable, true);
break;
}
case PLOT_CMD_LANE_FIT_TIME:
// 「按网格时间轴展开」:展开时间轴后同时把该网格的 Y 轴自适应一次。
m_plot.FitTimeToLane(laneIndex);
break;
case PLOT_CMD_FIT_ALL:
m_plot.FitAll();
break;
default:
break;
}
SetFocus(m_hwnd);
InvalidateRect(m_hwnd, nullptr, TRUE);
break;
}
case MenuScope::TAB_BAR:
{
const int index = m_tabMenuIndex;
if (result.id == kMenuTabNew)
{
NewTab();
}
else if (result.id == kMenuTabClose)
{
CloseTab(index);
}
else if (result.id == kMenuTabCloseOthers)
{
if (index >= 0 && index < static_cast<int>(m_tabs.size()) && m_tabs.size() > 1)
{
const TabConfig keep = m_tabs[static_cast<std::size_t>(index)];
m_tabs.clear();
m_tabs.push_back(keep);
SetActiveTab(0);
}
}
m_tabMenuIndex = -1;
InvalidateRect(m_hwnd, nullptr, TRUE);
break;
}
case MenuScope::NONE:
default:
break;
}
}
LRESULT Application::HandleMessage(HWND hwnd, UINT message, WPARAM wParam, LPARAM lParam)
{
// 菜单打开时优先消费鼠标与键盘事件,形成模态交互。
if (m_menu.IsOpen())
{
// 菜单接管输入后,取消底层组件可能仍在进行的拖拽。
if (m_activeTarget != Target::NONE || m_draggingSplitter)
{
ReleaseCapture();
m_activeTarget = Target::NONE;
m_draggingSplitter = false;
m_panel.OnLButtonUp(0, 0);
m_plot.OnLButtonUp(0, 0);
}
switch (message)
{
case WM_MOUSEMOVE:
// 菜单内联输入框拖拽框选优先(不改变悬停项,避免选区被中途清掉)。
if (m_menu.IsEditing() && m_activeTarget == Target::EDIT_FIELD)
{
if (m_menu.OnEditMouseMove(GET_X_LPARAM(lParam)))
{
InvalidateRect(hwnd, nullptr, FALSE);
}
return 0;
}
if (m_menu.OnMouseMove(GET_X_LPARAM(lParam), GET_Y_LPARAM(lParam)))
{
InvalidateRect(hwnd, nullptr, FALSE);
}
return 0;
case WM_LBUTTONDOWN:
{
const int downX = GET_X_LPARAM(lParam);
const int downY = GET_Y_LPARAM(lParam);
// 点击菜单内联输入框:定位插入符并开始拖拽框选(与常规输入框一致)。
if (m_menu.IsOnEditField(downX, downY))
{
m_menu.OnEditMouseDown(downX, downY);
SetCapture(hwnd);
m_activeTarget = Target::EDIT_FIELD;
InvalidateRect(hwnd, nullptr, FALSE);
return 0;
}
MenuResult result;
m_menu.OnLButtonDown(downX, downY, result);
InvalidateRect(hwnd, nullptr, FALSE);
// 点击其它位置会先提交正在编辑的数值,需要立即落到数据层并刷新菜单显示。
if (result.valid)
{
DispatchMenuResult(result);
}
return 0;
}
case WM_LBUTTONUP:
{
if (m_menu.IsEditing() && m_activeTarget == Target::EDIT_FIELD)
{
ReleaseCapture();
m_activeTarget = Target::NONE;
m_menu.OnEditMouseUp();
InvalidateRect(hwnd, nullptr, FALSE);
return 0;
}
MenuResult result;
m_menu.OnLButtonUp(GET_X_LPARAM(lParam), GET_Y_LPARAM(lParam), result);
InvalidateRect(hwnd, nullptr, FALSE);
DispatchMenuResult(result);
return 0;
}
case WM_CHAR:
{
// 菜单内联输入框的字符输入(数值/名称),回车在此提交。
MenuResult result;
if (m_menu.OnChar(static_cast<wchar_t>(wParam), result))
{
InvalidateRect(hwnd, nullptr, FALSE);
}
if (result.valid)
{
DispatchMenuResult(result);
}
return 0;
}
case WM_RBUTTONDOWN:
case WM_MBUTTONDOWN:
case WM_MOUSEWHEEL:
m_menu.Close();
InvalidateRect(hwnd, nullptr, FALSE);
return 0;
case WM_KEYDOWN:
{
MenuResult result;
m_menu.OnKeyDown(wParam, result);
InvalidateRect(hwnd, nullptr, FALSE);
if (result.valid)
{
DispatchMenuResult(result);
}
return 0;
}
case WM_KILLFOCUS:
case WM_CANCELMODE:
m_menu.Close();
InvalidateRect(hwnd, nullptr, FALSE);
return 0;
case WM_SETCURSOR:
SetCursor(LoadCursorW(nullptr, IDC_ARROW));
return TRUE;
default:
break;
}
}
// 拾色器打开时接管鼠标与键盘(优先级低于弹出菜单、高于常规组件)。
if (m_colorPicker.IsOpen())
{
bool repaint = false;
bool consume = false;
switch (message)
{
case WM_MOUSEMOVE:
repaint = m_colorPicker.OnMouseMove(GET_X_LPARAM(lParam), GET_Y_LPARAM(lParam));
consume = true;
break;
case WM_LBUTTONDOWN:
{
const int pickX = GET_X_LPARAM(lParam);
const int pickY = GET_Y_LPARAM(lParam);
// 拾色器接管输入后,取消底层组件可能仍在进行的拖拽。
m_panel.OnLButtonUp(0, 0);
m_plot.OnLButtonUp(0, 0);
m_activeTarget = Target::NONE;
m_draggingSplitter = false;
if (m_colorPicker.ContainsPoint(pickX, pickY))
{
// 面板内部:开始拖动滑块,并吃掉本次点击,避免穿透到下方图层。
m_colorPicker.OnLButtonDown(pickX, pickY);
SetCapture(hwnd);
consume = true;
}
else
{
// 面板外部:收起面板;若落点在信号面板内则放行本次点击,
// 便于用户在展开状态下直接改选预设色、样式或拖动滑块。
m_colorPicker.Close();
if (!m_panel.HitTest(pickX, pickY))
{
InvalidateRect(hwnd, nullptr, FALSE);
return 0;
}
}
repaint = true;
break;
}
case WM_SETCURSOR:
{
if (LOWORD(lParam) == HTCLIENT)
{
POINT point = {};
GetCursorPos(&point);
ScreenToClient(hwnd, &point);
HCURSOR cursor = m_colorPicker.QueryCursor(point.x, point.y);
SetCursor(cursor != nullptr ? cursor : LoadCursorW(nullptr, IDC_ARROW));
return TRUE;
}
break;
}
case WM_LBUTTONUP:
m_colorPicker.OnLButtonUp();
ReleaseCapture();
repaint = true;
consume = true;
break;
case WM_KEYDOWN:
m_colorPicker.OnKeyDown(wParam);
repaint = true;
consume = true;
break;
case WM_CHAR:
// 字符输入转发给拾色器,用于 RGB 分量与十六进制色号的内联编辑。
repaint = m_colorPicker.OnChar(static_cast<wchar_t>(wParam));
consume = true;
break;
case WM_RBUTTONDOWN:
case WM_MBUTTONDOWN:
case WM_MOUSEWHEEL:
m_colorPicker.Close();
repaint = true;
consume = true;
break;
default:
break;
}
// 取色变化实时应用到当前选中信号。
std::uint32_t pickedColor = 0;
if (m_colorPicker.TakeChanged(pickedColor))
{
m_panel.ApplyColor(pickedColor);
repaint = true;
}
if (repaint)
{
InvalidateRect(hwnd, nullptr, FALSE);
}
if (consume)
{
return 0;
}
}
switch (message)
{
case WM_SIZE:
{
ReleaseBackBuffer();
Relayout();
InvalidateRect(hwnd, nullptr, FALSE);
return 0;
}
case WM_GETMINMAXINFO:
{
MINMAXINFO* info = reinterpret_cast<MINMAXINFO*>(lParam);
info->ptMinTrackSize.x = static_cast<LONG>(ScaleOf(static_cast<float>(kMinClientWidth), m_scale));
info->ptMinTrackSize.y = static_cast<LONG>(ScaleOf(static_cast<float>(kMinClientHeight), m_scale));
return 0;
}
case WM_DPICHANGED:
{
const UINT dpi = HIWORD(wParam);
const RECT* suggested = reinterpret_cast<const RECT*>(lParam);
if (suggested != nullptr)
{
SetWindowPos(hwnd, nullptr, suggested->left, suggested->top,
suggested->right - suggested->left, suggested->bottom - suggested->top,
SWP_NOZORDER | SWP_NOACTIVATE);
}
ApplyDpi(dpi);
InvalidateRect(hwnd, nullptr, FALSE);
return 0;
}
case WM_ERASEBKGND:
return 1;
case WM_PAINT:
{
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);
}
// 拖动(鼠标捕获)期间 Windows 不会派发 WM_SETCURSOR,
// 因此需要主动刷新一次,否则光标形状会一直停在按下的状态。
if (buttonDown)
{
UpdateDragCursor(x, y);
}
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_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;
}
// 内联数值输入处于活动态时,点击绘图区之外视为提交(与线宽输入一致)。
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();
InvalidateRect(hwnd, nullptr, FALSE);
}
// 拖动期间 Windows 不再派发 WM_SETCURSOR,需在按下后立即刷新一次,
// 使"按下但尚未拖动"时能立刻看到十字/方向箭头提示。
UpdateDragCursor(x, y);
return 0;
}
m_activeTarget = Target::NONE;
return 0;
}
case WM_LBUTTONUP:
{
const int x = GET_X_LPARAM(lParam);
const int y = GET_Y_LPARAM(lParam);
ReleaseCapture();
switch (m_activeTarget)
{
case Target::SPLITTER:
m_draggingSplitter = false;
break;
case Target::EDIT_FIELD:
{
// 结束输入框内的拖拽框选(选区保留)。
bool changed = m_tabBar.OnEditMouseUp();
changed = m_panel.OnEditMouseUp() || 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_LBUTTONDBLCLK:
{
const int x = GET_X_LPARAM(lParam);
const int y = GET_Y_LPARAM(lParam);
bool changed = false;
if (m_tabBar.HitTest(x, y))
{
SetFocus(hwnd);
changed = m_tabBar.OnDoubleClick(x, y);
if (m_tabBar.IsEditing())
{
// 进入重命名后该标签会加宽,需要重新排布标签栏。
Relayout();
}
}
else if (m_panel.HitTest(x, y))
{
changed = m_panel.OnDoubleClick(x, y);
}
else if (m_plot.HitTest(x, y))
{
changed = m_plot.OnDoubleClick(x, y);
}
if (changed)
{
InvalidateRect(hwnd, nullptr, TRUE);
}
return 0;
}
case WM_RBUTTONDOWN:
{
const int x = GET_X_LPARAM(lParam);
const int y = GET_Y_LPARAM(lParam);
// 「选区放大」正在框选时右键 = 放弃当前选区(显示提示),
// 与 Esc 不同:这里**不退出**选区放大模式,也不弹出网格菜单。
// 左键仍按着,因此保留鼠标捕获与激活目标,松手时不会命中被放弃的选区。
if (m_plot.IsRubberSelecting())
{
m_plot.CancelDrag(true);
InvalidateRect(hwnd, nullptr, FALSE);
return 0;
}
// 编辑态下右键 = 取消选区并把插入符移到文本末尾(常规输入框行为),
// 其它操作(弹菜单、拖拽)一并被吞掉。
if (m_tabBar.IsEditing() || m_panel.IsEditing() || m_plot.IsEditing() || m_colorPicker.IsEditing())
{
bool changed = m_tabBar.CollapseEditSelectionToEnd();
changed = m_panel.CollapseEditSelectionToEnd() || changed;
changed = m_plot.CollapseEditSelectionToEnd() || changed;
changed = m_colorPicker.CollapseEditSelectionToEnd() || changed;
if (changed)
{
InvalidateRect(hwnd, nullptr, FALSE);
}
return 0;
}
POINT cursor = {x, y};
ClientToScreen(hwnd, &cursor);
if (m_tabBar.HitTest(x, y))
{
TabBarResult result;
if (m_tabBar.OnRButtonDown(x, y, cursor.x, cursor.y, result))
{
OnTabBarResult(result);
// 自绘菜单会覆盖在客户区之上,必须请求重绘才能显示。
InvalidateRect(hwnd, nullptr, FALSE);
}
return 0;
}
if (m_panel.HitTest(x, y))
{
SetCapture(hwnd);
m_activeTarget = Target::PANEL;
if (m_panel.OnRButtonDown(x, y))
{
InvalidateRect(hwnd, nullptr, TRUE);
}
ReleaseCapture();
m_activeTarget = Target::NONE;
return 0;
}
// 绘图区右键:弹出该网格的 Y 轴设置菜单(含上下限输入)。
if (m_plot.HitTest(x, y))
{
const int laneIndex = m_plot.LaneIndexAt(x, y);
if (laneIndex >= 0)
{
ShowPlotContextMenu(laneIndex, x, y);
InvalidateRect(hwnd, nullptr, FALSE);
}
return 0;
}
return 0;
}
case WM_RBUTTONUP:
{
// 左键仍按着时不释放捕获与激活目标(例如框选过程中右键放弃选区后再松开左键)。
if ((wParam & MK_LBUTTON) == 0)
{
ReleaseCapture();
m_activeTarget = Target::NONE;
}
return 0;
}
case WM_MOUSEWHEEL:
{
POINT point = {GET_X_LPARAM(lParam), GET_Y_LPARAM(lParam)};
ScreenToClient(hwnd, &point);
const int delta = GET_WHEEL_DELTA_WPARAM(wParam);
const bool shift = (GET_KEYSTATE_WPARAM(wParam) & MK_SHIFT) != 0;
bool changed = false;
if (m_panel.HitTest(point.x, point.y))
{
changed = m_panel.OnMouseWheel(delta);
}
else if (m_plot.HitTest(point.x, point.y))
{
changed = m_plot.OnMouseWheel(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<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);
}
if (cursor == nullptr)
{
cursor = LoadCursorW(nullptr, IDC_ARROW);
}
SetCursor(cursor);
return TRUE;
}
break;
}
case WM_KEYDOWN:
// 标签栏重命名与数值输入优先接管按键(回车提交、Esc 取消、退格删除)。
if (m_tabBar.IsEditing())
{
TabBarResult tabResult;
m_tabBar.OnEditKey(wParam, tabResult);
OnTabBarResult(tabResult);
if (!m_tabBar.IsEditing())
{
// 退出重命名后标签宽度恢复,需要重新排布。
Relayout();
}
InvalidateRect(hwnd, nullptr, FALSE);
return 0;
}
if (m_panel.IsEditing())
{
if (m_panel.OnEditKey(wParam))
{
InvalidateRect(hwnd, nullptr, FALSE);
}
return 0;
}
if (m_plot.IsEditing())
{
m_plot.OnEditKey(wParam);
ApplyPendingRename();
InvalidateRect(hwnd, nullptr, FALSE);
return 0;
}
if (HandleKeyDown(wParam))
{
InvalidateRect(hwnd, nullptr, TRUE);
return 0;
}
break;
case WM_CHAR:
{
// 输入状态由对应控件自行校验可接受的字符(数值仅收数字与小数点,名称可收任意可打印字符)。
if (m_tabBar.IsEditing())
{
if (m_tabBar.OnEditChar(static_cast<wchar_t>(wParam)))
{
// 标签宽度随输入内容增长(重命名时上限更宽),需重新排布才能看到完整名称。
if (m_tabBar.IsEditing())
{
Relayout();
}
InvalidateRect(hwnd, nullptr, FALSE);
}
return 0;
}
if (m_panel.IsEditing())
{
if (m_panel.OnEditChar(static_cast<wchar_t>(wParam)))
{
InvalidateRect(hwnd, nullptr, FALSE);
}
return 0;
}
if (m_plot.IsEditing())
{
if (m_plot.OnEditChar(static_cast<wchar_t>(wParam)))
{
ApplyPendingRename();
InvalidateRect(hwnd, nullptr, FALSE);
}
return 0;
}
break;
}
case WM_KILLFOCUS:
// 失焦时提交输入并收起临时界面,避免失去鼠标前景后控件仍处于编辑态。
m_panel.CloseTransientUi();
m_colorPicker.Close();
m_menu.Close();
InvalidateRect(hwnd, nullptr, FALSE);
return 0;
case kMessageRemoveFile:
{
RemoveFile(static_cast<int>(wParam));
return 0;
}
case WM_TIMER:
{
// 提示浮层淡出:仅重绘,不再做其它处理;Render 会在淡出结束后停表。
if (wParam == kHintTimerId)
{
InvalidateRect(hwnd, nullptr, FALSE);
return 0;
}
break;
}
case WM_DROPFILES:
{
HDROP drop = reinterpret_cast<HDROP>(wParam);
const UINT count = DragQueryFileW(drop, 0xFFFFFFFF, nullptr, 0);
int loaded = 0;
for (UINT index = 0; index < count; ++index)
{
const UINT length = DragQueryFileW(drop, index, nullptr, 0);
if (length == 0)
{
continue;
}
std::vector<wchar_t> buffer(static_cast<std::size_t>(length) + 1, L'\0');
if (DragQueryFileW(drop, index, buffer.data(), length + 1) > 0)
{
if (LoadFile(std::wstring(buffer.data())))
{
++loaded;
}
}
}
DragFinish(drop);
if (loaded > 0)
{
SetStatusMessage(L"已加载 " + std::to_wstring(loaded) +
L" 个文件,请在左侧勾选需要查看的信号。",
false);
}
return 0;
}
case WM_DESTROY:
PostQuitMessage(0);
return 0;
default:
break;
}
return DefWindowProcW(hwnd, message, wParam, lParam);
}
} // namespace jdi