## 为什么
用户 2026-10-29 报告/要求五件事:
1. 派生信号的「时间轴偏移保留在样式上、其它调整烘值」维持现状(确认无需改动);
2. 信号名右键菜单里很多功能已被下方样式自定义区覆盖 ⇒ 只保留「仅显示该信号」,
并在它**上方**新增「克隆该信号」;
3. 设置菜单新增「主题配色」二级菜单(带 √ 状态),顺序 Light / Solarised Light /
Dark / Solarised Dark,默认主题维持现状;
4. 布局边界:中键平移视界时浮动面板上的边界**闪烁**;光标取值胶囊的**文字**框会**穿透**
到浮动面板上;并请核对网格内固定构件(图例 / Y 轴标尺 / GX 标签 / 回到最上方 /
浮动面板)的层级关系;
5. 单数据点信号在拖动光标时的**吸附**功能消失。
## 根因
### 布局边界层级(两个都很隐蔽)
- **文本层号写死**:文本是在绘制过程中由 `UiTextRectSink` 收集的,而宿主把所有文本都记在
固定层 2;组件几何层却是"组件表下标"(`PlotView` = 0)。面板层 = PlotView 层 + 1 = 1,
**低于**文本层 2 ⇒ 面板的遮挡矩形挖不到文本 ⇒ 胶囊文字画在面板表面。
- **面板像素缓存**:面板"先画进图层、命中缓存就贴图",贴图那一帧不画字 ⇒ 文本框凭空消失
⇒ 面板上的边界闪烁。
- 顺带发现 `PlotView::BuildBoxes` 用 `PushLayer()` 却**从不复位**,会把宿主后续所有组件的
层号整体推高。
### 单点信号吸附
`MinorCellSparse` 在"把判定范围收窄到数据实际时间跨度"的分支里有
`if (!(dataMax > dataMin)) return false;`。单个有效采样点时 `dataMax == dataMin`,
于是被判定成"不稀疏" ⇒ 不吸附。
## 改了什么
### 1. 布局边界层级
- `UiTextRectSink` 新增 `OnTextLayer(int)` / `TextLayer()`;新增 `UiSetTextLayer` /
`UiGetTextLayer`;`UiBoxTable` 实现为改 `m_layer`,并暴露 `Layer()`。
- `Application::Render` 在绘制每个组件前下发 `UiSetTextLayer(ComponentLayer(&component))`,
浮层用 `OverlayLayer(1/2)`;新增 `ComponentLayer()` / `OverlayLayer()` /
`kLayersPerComponent = 3`,与 `PaintLayoutBounds` 的层号**完全同源**。
- `PaintLayoutBounds` 改为**显式** `SetLayer(下标 × 3)`(不再依赖 `PushLayer` 累加)。
- `PlotView`:`BuildBoxes` 用 `baseLayer + 1` 放浮动面板(并登记遮挡)、
`baseLayer + 2` 放绘制在面板之上的滚动条 / 回到最上方 / 输入框,结束**复位**层号;
`paintOverlays` 同步分两次抬高文本层。
- 面板像素图层新增 `PanelSlot::textRects` + 转发型收集器 `PanelTextCollector`:
渲染时存文本框,命中缓存贴图时回放 ⇒ 几何与缓存命中无关(消除闪烁)。
### 2. 信号右键菜单 + 克隆
- 菜单只剩:标题、**克隆该信号**、**仅显示该信号**;删除显示/隐藏、显示全部/隐藏全部、
预设色块行、样式三项,以及对应的 `CMD_SIGNAL_TOGGLE/SHOW_ALL/HIDE_ALL`、
`CMD_COLOR_BASE`、`CMD_MODE_BASE` 及其分支(死代码)。
- 新增 `CMD_SIGNAL_CLONE`(post `WM_APP + 5`)→ `Application::CloneSignal`:
把「调整」(系数 / 绝对值 / 镜像 / Y 偏移)烘进采样值、**时间轴偏移保留在样式上**,
名称前置 `*`,插入到源信号**正下方**,并**保留原外观**(颜色/透明度/线宽/绘制样式)。
- 抽出 `Application::InsertSignalBelowSource(...)` 供派生与克隆共用。
### 3. 主题配色
- `ThemeKind{LIGHT, SOLARIZED_LIGHT, DARK, SOLARIZED_DARK}` + `ThemeFor()` / `ThemeName()`;
Light 取 VS Code 2026 默认浅色、Dark 取 Dark+、Solarized Dark 取 VS Code solarized dark。
**默认仍是 Solarised Light**。
- 新增 `CurrentTheme()` / `SetCurrentTheme()`:宿主交出自己的 `m_theme` 地址,
切换主题只改内容;`UiControls` 里没有 `Theme` 形参的原语改用它(原来硬编码
`SolarizedLight()`,换主题不生效)。
- `Application::ApplyTheme()`:改配色 → `PlotView::OnThemeChanged()`(作废场景/宽基底/
装饰/光晕/面板图层)+ `SignalPanel::OnThemeChanged()`(作废行位图)→ 整窗重绘。
- 菜单用 `SUBMENU` + 4 个 `keepOpen` 子项;新增 `PopupMenu::UpdateSubmenuItemLabel()`
回写勾选,并让子菜单分支尊重 `keepOpen`(原为无条件关闭)。
### 4. 单点信号吸附
`MinorCellSparse` 改为只在 `dataMax < dataMin` 时放弃;相等时按"那一个小格"判定
(`need = 5 > finiteCount = 1` ⇒ 稀疏 ⇒ 吸附恢复)。
### 5. 审计
`Makefile` 的 `LAYOUT_BOUNDS` / `JDI_LAYOUT_BOUNDS` **从未被任何 `#if` 引用**
(`LAYOUT_BOUNDS=0` 产物字节数完全相同),属名实不符的死开关 ⇒ 删除并注明原因。
## 怎么验证
新增两支离屏探针(不占鼠标 / 键盘 / 屏幕):
```
.preflight/round15check 28 passed, 0 failed
单点吸附 7 条(含"远处不吸附")、面板层级 5 条、面板文本框跨帧与中键平移稳定 2 条、
克隆 14 条(烘值逐点比对 / 名称前缀 / 偏移与外观保留 / 调整复位)
.preflight/themecheck 17 passed, 0 failed
4 套主题可读性与可区分、默认 = Solarised Light、逐主题换色且无旧底色残留、
切回默认逐像素还原(diff = 0)、子菜单 4 项 / 顺序 / 唯一勾选 / 命令切换 / 菜单保持打开
```
回归全绿:
```
uicheck 69/69 lbcheck 21/21 lbresid 5/5 lbzorder 4/4
lbcovr 7/7 lbpanel 10/10 derivecheck 16/16
rendercheck: 8 个场景哈希与上一轮逐一相同 + FUNC 7/7, exit=0
A FAED59DF284153DB B E6443BFE4E3955F8 C 96A0AA8C402F1729 D E3D5DBD998E43720
E DA0583CB13C26A6F F FFB822159F604D1D G 19F8DE8FE3A8DF18 H DBBDDE4CB2C97C92
```
`make release` / `make debug` 零 error / 零 warning;产物 **791,552 B**
(单一 exe,未拆 DLL,未改 `-Oz`;离 1MB 预警线约 250 KB)。
1809 lines
60 KiB
C++
1809 lines
60 KiB
C++
// Application 实现分片 —— 本文件只包含 Application 的**成员函数定义**(纯代码搬移,行为与拆分前完全一致)。
|
||
//
|
||
// 共用的常量与自由函数在 ApplicationInternal.hpp(命名空间 `appdetail`,全部 inline)。
|
||
// 分片按“职责”划分:核心(状态/布局/几何)、渲染、交互与编辑,见各文件名。
|
||
#include "ApplicationInternal.hpp"
|
||
|
||
namespace jdi
|
||
{
|
||
|
||
|
||
Application& Application::Instance()
|
||
{
|
||
static Application instance;
|
||
return instance;
|
||
}
|
||
|
||
LRESULT CALLBACK Application::WindowProc(HWND hwnd, UINT message, WPARAM wParam, LPARAM lParam)
|
||
{
|
||
Application* self = reinterpret_cast<Application*>(GetWindowLongPtrW(hwnd, GWLP_USERDATA));
|
||
if (message == WM_NCCREATE)
|
||
{
|
||
CREATESTRUCTW* createStruct = reinterpret_cast<CREATESTRUCTW*>(lParam);
|
||
self = reinterpret_cast<Application*>(createStruct->lpCreateParams);
|
||
SetWindowLongPtrW(hwnd, GWLP_USERDATA, reinterpret_cast<LONG_PTR>(self));
|
||
}
|
||
|
||
if (self != nullptr)
|
||
{
|
||
return self->HandleMessage(hwnd, message, wParam, lParam);
|
||
}
|
||
return DefWindowProcW(hwnd, message, wParam, lParam);
|
||
}
|
||
|
||
int Application::Run(HINSTANCE instance, int showCommand)
|
||
{
|
||
if (!Initialize(instance, showCommand))
|
||
{
|
||
return 1;
|
||
}
|
||
|
||
MSG message = {};
|
||
while (GetMessageW(&message, nullptr, 0, 0) > 0)
|
||
{
|
||
TranslateMessage(&message);
|
||
DispatchMessageW(&message);
|
||
}
|
||
|
||
Shutdown();
|
||
return static_cast<int>(message.wParam);
|
||
}
|
||
|
||
bool Application::Initialize(HINSTANCE instance, int showCommand)
|
||
{
|
||
m_instance = instance;
|
||
|
||
// ⚠️ 本项目以 `-fno-exceptions` 构建(省 `.xdata`+`.pdata` 约 40~60KB):
|
||
// 任何遗漏的分配失败都会走到 `std::terminate`(默认行为是静默 abort,用户只看到程序
|
||
// 突然消失)。这里安装一个兜底处理器:弹一次带建议的模态提示后干净退出。
|
||
// 真正的大块分配已在各处通过 `MemReserveIfRoom()` 预检验(见 `MemProbe.hpp`)。
|
||
MemInstallTerminateHandler();
|
||
|
||
// 隐藏窗口启动(自动化/后台)时没人能点掉模态错误框,会自动降级为非模态提示。
|
||
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 = ThemeFor(m_themeKind);
|
||
// ⚠️ 把**宿主自己那个** `Theme` 实例的地址交给全局指针:这样切换主题只需改
|
||
// `m_theme` 的内容,所有持有 `const Theme*` 的组件会自动看到新配色,
|
||
// 而 `UiControls` 里那些没有 `Theme` 形参的原语也能跟随(见 `CurrentTheme`)。
|
||
SetCurrentTheme(&m_theme);
|
||
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);
|
||
|
||
// 组件表在这里一次装配好:之后所有"设置缩放 / 摆放 / 悬停离开 / 光标 / 装饰区绘制"
|
||
// 都由 `m_components` 驱动,宿主不再逐个点名组件。
|
||
BuildComponentTable();
|
||
|
||
// 标题栏小图标与任务栏/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();
|
||
// 绘图组件同样持有 GDI+ 位图(场景/图层/装饰/面板/光晕 + 并行图层 worker),
|
||
// 而 `Application` 是进程级单例 ⇒ 成员要等 `WinMain` 返回后才析构,那时 GDI+ 已关闭。
|
||
m_plot.ReleaseGraphicsResources();
|
||
|
||
// ⚠️ **窗口必须在 GdiplusShutdown 之前销毁**:
|
||
// 前一帧的 `InvalidateRect` 可能还留下未处理的更新区域,`DestroyWindow` 期间会派发
|
||
// `WM_PAINT` ⇒ `Render()` ⇒ 在**已关闭**的 GDI+ 上构造 `Graphics` ⇒ 退出时访问违例
|
||
//(离屏探针实测退出码 0xC0000005,正是这个顺序造成的)。
|
||
// 注销窗口类放在销毁之后:否则同一进程里再次创建 `Application` 会命中
|
||
// `ERROR_CLASS_ALREADY_EXISTS`(离屏测试需要多次构造/销毁)。
|
||
if (m_hwnd != nullptr)
|
||
{
|
||
DestroyWindow(m_hwnd);
|
||
m_hwnd = nullptr;
|
||
}
|
||
UnregisterClassW(kWindowClassName, m_instance);
|
||
|
||
if (m_gdiplusReady)
|
||
{
|
||
gd::GdiplusShutdown(m_gdiplusToken);
|
||
m_gdiplusReady = false;
|
||
}
|
||
}
|
||
|
||
void Application::BuildComponentTable()
|
||
{
|
||
// 底 → 顶:绘图区在最底,面板/工具栏/标签栏依次在上面。
|
||
// 四个组件的区域互不重叠,因此命中顺序在这里不影响结果(保留顶优先只是为了与
|
||
// 旧实现的方向一致,且将来若出现重叠会有确定的语义)。
|
||
m_components.clear();
|
||
m_components.push_back(ComponentEntry{ &m_plot, Target::PLOT, false });
|
||
m_components.push_back(ComponentEntry{ &m_panel, Target::PANEL, true });
|
||
m_components.push_back(ComponentEntry{ &m_toolbar, Target::TOOLBAR, true });
|
||
m_components.push_back(ComponentEntry{ &m_tabBar, Target::TAB_BAR, true });
|
||
}
|
||
|
||
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_menu.SetScale(m_scale);
|
||
m_colorPicker.SetScale(m_scale);
|
||
for (const ComponentEntry& entry : m_components)
|
||
{
|
||
entry.component->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;
|
||
|
||
// 内容区:左面板 + 分隔条 + 绘图区。用 `UiStackRects` 表达"固定 + 占满剩余"的切分,
|
||
// 避免再次手算三组坐标(这也是 `UiLayout` 第一个真实生产调用点)。
|
||
const std::vector<float> contentSizes{ panelWidth, splitterWidth, kUiSizeAuto };
|
||
std::vector<gd::RectF> contentRects;
|
||
UiStackRects(gd::RectF(0.0f, contentTop, width, contentHeight), UiAxis::HORIZONTAL,
|
||
contentSizes, 0.0f, contentRects);
|
||
m_panelRect = contentRects[0];
|
||
m_splitterRect = contentRects[1];
|
||
m_plotRect = contentRects[2];
|
||
|
||
// 四个组件的区域一次摆完(顺序无关:各区域互不重叠)。
|
||
m_panel.SetClientBounds(gd::RectF(0.0f, 0.0f, width, height));
|
||
for (const ComponentEntry& entry : m_components)
|
||
{
|
||
switch (entry.target)
|
||
{
|
||
case Target::TAB_BAR:
|
||
entry.component->Layout(m_tabBarRect);
|
||
break;
|
||
case Target::TOOLBAR:
|
||
entry.component->Layout(m_toolbarRect);
|
||
break;
|
||
case Target::PANEL:
|
||
entry.component->Layout(m_panelRect);
|
||
break;
|
||
case Target::PLOT:
|
||
entry.component->Layout(m_plotRect);
|
||
break;
|
||
default:
|
||
break;
|
||
}
|
||
}
|
||
|
||
// 所有控件的目标矩形都变了,装饰区域必须整体重绘一次。
|
||
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)
|
||
{
|
||
// ⚠️ 用 **top-down 32bpp DIB 段**(而不是 `CreateCompatibleBitmap`):
|
||
// * top-down(`biHeight` 取负)才让 `bits` 从第一行开始、y 向下递增;
|
||
// * 32bpp 才能稳定给出像素指针供 CPU 侧合成使用(`CreateCompatibleBitmap`
|
||
// 返回设备格式位图,没有 `bits`,也无从保证 32 位)。
|
||
// 绘制结果与旧的 compatible bitmap 逐像素一致(`BitsPerPixel` 同为 32)。
|
||
BITMAPINFO info = {};
|
||
info.bmiHeader.biSize = sizeof(BITMAPINFOHEADER);
|
||
info.bmiHeader.biWidth = width;
|
||
info.bmiHeader.biHeight = -height;
|
||
info.bmiHeader.biPlanes = 1;
|
||
info.bmiHeader.biBitCount = 32;
|
||
info.bmiHeader.biCompression = BI_RGB;
|
||
void* bits = nullptr;
|
||
m_backBitmap = CreateDIBSection(screenDc, &info, DIB_RGB_COLORS, &bits, nullptr, 0);
|
||
if (m_backBitmap != nullptr)
|
||
{
|
||
m_backOldBitmap = SelectObject(m_backDc, m_backBitmap);
|
||
m_backWidth = width;
|
||
m_backHeight = height;
|
||
m_backBits = static_cast<std::uint32_t*>(bits);
|
||
m_backStride = width * 4;
|
||
++m_backVersion;
|
||
}
|
||
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;
|
||
m_backBits = nullptr;
|
||
m_backStride = 0;
|
||
}
|
||
|
||
UiSurface Application::BackBufferSurface() const
|
||
{
|
||
UiSurface surface;
|
||
if (m_backDc == nullptr || m_backWidth <= 0 || m_backHeight <= 0)
|
||
{
|
||
return surface;
|
||
}
|
||
surface.dc = m_backDc;
|
||
surface.rect = gd::RectF(0.0f, 0.0f, static_cast<float>(m_backWidth),
|
||
static_cast<float>(m_backHeight));
|
||
surface.stride = m_backStride;
|
||
surface.bits = m_backBits;
|
||
surface.version = m_backVersion;
|
||
surface.scale = m_scale;
|
||
return surface;
|
||
}
|
||
|
||
void Application::Render()
|
||
{
|
||
// 关闭流程中(GDI+ 已 shutdown)或窗口已销毁时**绝不能再进入绘制**:
|
||
// `DestroyWindow` 期间仍可能派发 WM_PAINT,在已关闭的 GDI+ 上构造 Graphics 会直接崩溃。
|
||
if (!m_gdiplusReady || m_hwnd == nullptr)
|
||
{
|
||
return;
|
||
}
|
||
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;弹出菜单 / 拾色器 / 加载遮罩会覆盖到装饰区域,必须整帧重绘。
|
||
//
|
||
// ⚠️⚠️ **面板动画(滚动到目标信号 / 分组折起、展开)必须由动画状态本身驱动重绘**:
|
||
// 这两个动画每帧都在改变面板像素,而它们只由 `WM_TIMER` 驱动 —— 而 `WM_TIMER`
|
||
// **不会**置 `m_chromeDirty`(它刻意在排除列表里),于是旧实现完全依赖
|
||
// `WM_PAINT` 里“更新区域不在绘图区内 ⇒ 置脏”这个启发式:一旦某帧的更新区域恰好
|
||
// 不含面板(例如与鼠标消息或 `kMessagePlotDerivedReady` 的失效区域交错),
|
||
// 那一帧就**完全不重画面板** ⇒ 用户报告“过渡动画有时直接没了”。
|
||
// ✓ 把动画状态直接纳入重绘条件(**只增加重绘条件,不改变任何绘制/回送路径**)。
|
||
const bool panelAnimating = m_panel.ScrollAnimationActive() || m_panel.GroupAnimationActive();
|
||
const bool overlaysVisible = m_menu.IsOpen() || m_colorPicker.IsOpen();
|
||
// ⚠️ 两部分分开:
|
||
// * `fullChrome` —— 标签栏 / 工具栏 / 整窗底色 / 面板都要重绘(有真内容变化);
|
||
// * `panelOnly` —— **只有面板像素变了**(面板滚动动画),标签栏/工具栏/整窗底色
|
||
// 与上一帧逐像素相同,没必要重绘(实测 129 网格时约 3~4ms/帧)。
|
||
const bool fullChrome = m_chromeDirty || m_loading || overlaysVisible || m_showLayoutBounds;
|
||
const bool panelOnly = !fullChrome && (panelAnimating || m_panelDirty);
|
||
m_chromeDirty = false;
|
||
m_panelDirty = false;
|
||
// 仍需要“重绘装饰区域”这个判定(决定回送整窗还是只回送绘图区)。
|
||
const bool chromeDirty = fullChrome || panelOnly;
|
||
// 提示浮层淡出由定时器驱动,其绘制范围局限在绘图区,但为稳妥起见整帧回送。
|
||
// ⚠️ 开启「显示布局边界」后同样必须**整帧回送**:边界跨所有装饰区且比组件矩形外扩
|
||
// 1px,走"只回送绘图区"的快路径会让装饰区的边界停留在旧帧上(看起来像没开)。
|
||
const bool fullFrame = chromeDirty || m_plot.HintNeedsAnimation() || m_showLayoutBounds;
|
||
|
||
// ⚠️⚠️ **调试叠加层必须让绘图区走整幅重绘**:`PlotView` 的三条快路径(静态场景贴图 /
|
||
// 宽基底贴图 / 样式补丁)只重画**变化的部分**,而调试边框是画在整个窗口上的叠加内容 ——
|
||
// 底层不逐像素重画时,上一帧的边框会与新内容叠在一起(用户 2026-10-28 报告:拖网格
|
||
// 滚动条时"工具栏和网格区域上出现大量绘制残留,松开并移动鼠标才消失")。
|
||
// 关掉开关后立即恢复快路径,因此只有调试时损失帧率。
|
||
m_plot.SetForceFullRepaint(m_showLayoutBounds);
|
||
|
||
{
|
||
gd::Graphics g(m_backDc);
|
||
g.SetSmoothingMode(gd::SmoothingModeAntiAlias);
|
||
g.SetPixelOffsetMode(gd::PixelOffsetModeHalf);
|
||
g.SetTextRenderingHint(gd::TextRenderingHintClearTypeGridFit);
|
||
|
||
// ⚠️ 调试:文本是本项目唯一"没有几何身份"的绘制元素(60+ 个调用点现算矩形、
|
||
// 画完即弃),因此在这里安装收集器,让 `PaintUtil` 在绘制时把**实际墨迹矩形**
|
||
// 回报给 `m_debugBoxes`。开关关闭时完全不安装(空指针,一次判空即返回)。
|
||
if (m_showLayoutBounds)
|
||
{
|
||
m_debugBoxes.Clear();
|
||
UiSetTextRectSink(&m_debugBoxes);
|
||
}
|
||
else if (!m_debugBoxes.Empty())
|
||
{
|
||
// 关闭后释放容器(否则上一次的几何会一直占着内存,也容易被误读成"仍在收集")。
|
||
m_debugBoxes.Clear();
|
||
}
|
||
// 信号列表 / 样式区「目标」行的悬停 → 绘图区联动高亮(网格变色 + 该信号曲线高亮)。
|
||
// 用户 2026-10-15 要求;鼠标在网格里移动**不再**触发网格变色高亮。
|
||
// ⚠️ 每帧同步(而不是只在 WM_MOUSEMOVE 里同步)才能覆盖全部改变悬停的路径:
|
||
// 鼠标离开窗口(WM_MOUSELEAVE)、数据/行结构变化、切换标签页等。
|
||
m_plot.SetExternalHoverSignal(m_panel.HoveredSignal());
|
||
|
||
// ⚠️ 面板动画期间**暂停绘图区的网格图层预热**:单个巨型信号的图层重建要 10~30ms,
|
||
// 塞进动画帧就会让"只有文字的信号列表"看起来卡顿(用户 2026-10-18 报告)。
|
||
// 预热只是被推迟(待重画队列保留),动画结束后由既有预算继续补齐。
|
||
m_plot.SetAnimationBusy(panelAnimating || panelOnly);
|
||
|
||
if (fullChrome)
|
||
{
|
||
UiFillRect(g, gd::RectF(0.0f, 0.0f, static_cast<float>(width), static_cast<float>(height)),
|
||
m_theme.windowBg);
|
||
|
||
// ⚠️ 文本必须记到**与它所在组件相同的层**:
|
||
// `UiDebugPaintBoxList` 按 `(layer, depth)` 排序并拿更高的层当遮挡矩形,
|
||
// 若把所有文本都固定记到某一层,就会出现“胶囊文字框穿透浮动面板”
|
||
//(文本层比面板层低 ⇒ 被挖洞,或被当成上层直接画在面板上)。
|
||
// 这里逐组件下发,`ComponentLayer` 与 `PaintLayoutBounds` 里的层号完全同源。
|
||
UiSetTextLayer(ComponentLayer(&m_tabBar));
|
||
m_tabBar.Paint(g);
|
||
UiSetTextLayer(ComponentLayer(&m_toolbar));
|
||
m_toolbar.Paint(g);
|
||
UiSetTextLayer(ComponentLayer(&m_panel));
|
||
m_panel.Paint(g);
|
||
UiFillRect(g, m_splitterRect, m_theme.border);
|
||
}
|
||
else if (panelOnly)
|
||
{
|
||
// 只有面板像素变了 ⇒ 只重画面板与分隔条(`SignalPanel::Paint` 会铺满自己的区域)。
|
||
UiSetTextLayer(ComponentLayer(&m_panel));
|
||
m_panel.Paint(g);
|
||
UiFillRect(g, m_splitterRect, m_theme.border);
|
||
}
|
||
|
||
UiSetTextLayer(ComponentLayer(&m_plot));
|
||
m_plot.Paint(g);
|
||
|
||
// 弹出菜单与浮动拾色器始终绘制在最上层,保证与主界面风格一致。
|
||
// ⚠️ 调试:它们是不透明浮层,文本要记入更高的绘制层,
|
||
// 否则会被自己登记到遮挡矩形里的"挖洞"逻辑裁掉。
|
||
UiSetTextLayer(OverlayLayer(1));
|
||
m_menu.Paint(g);
|
||
UiSetTextLayer(OverlayLayer(2));
|
||
m_colorPicker.Paint(g);
|
||
// 加载遮罩层在一切内容之上(包括菜单与拾色器),形成模态观感。
|
||
PaintLoadingOverlay(g, width, height);
|
||
|
||
// 布局边界是**纯调试叠加层**:必须在所有内容(含遮罩)之上、且绝不进入任何
|
||
// 位图缓存 —— 否则关掉开关后线条会残留在缓存里。
|
||
if (m_showLayoutBounds)
|
||
{
|
||
UiSetTextRectSink(nullptr);
|
||
PaintLayoutBounds(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() ||
|
||
m_plot.LaneScrollAnimationActive() || m_plot.BackToTopAnimationActive())
|
||
{
|
||
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())
|
||
{
|
||
// 菜单接管输入后,取消**底层组件**可能仍在进行的拖拽。
|
||
//
|
||
// ⚠️ 必须排除 `Target::EDIT_FIELD`:它属于菜单**自己的**内联输入框。
|
||
// 旧实现在每个消息(含 WM_MOUSEMOVE)都无条件取消,按下去后下一条消息就把
|
||
// capture 释放、把 target 清回 NONE ⇒ 菜单里的输入框既无法拖拽框选,
|
||
// 指针移出窗口后也收不到移动。
|
||
if (m_activeTarget != Target::NONE && m_activeTarget != Target::EDIT_FIELD)
|
||
{
|
||
ReleaseCapture();
|
||
m_activeTarget = Target::NONE;
|
||
m_panel.OnLButtonUp(0, 0);
|
||
m_plot.OnLButtonUp(0, 0);
|
||
}
|
||
if (m_draggingSplitter)
|
||
{
|
||
ReleaseCapture();
|
||
m_draggingSplitter = false;
|
||
}
|
||
|
||
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 = false;
|
||
bool plotChanged = false;
|
||
for (const ComponentEntry& entry : m_components)
|
||
{
|
||
if (!entry.component->OnMouseLeave())
|
||
{
|
||
continue;
|
||
}
|
||
if (entry.chrome)
|
||
{
|
||
chromeChanged = true;
|
||
}
|
||
else
|
||
{
|
||
plotChanged = true;
|
||
}
|
||
}
|
||
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()`**:中键平移(`m_middlePanning`)也持有同一个
|
||
// Windows 捕获。中键拖动中误按/松开左键会把捕获抢走 ⇒ `m_middlePanning` 仍为真、
|
||
// 但指针离开客户区后收不到移动 ⇒ 平移“卡住”。
|
||
if (m_activeTarget != Target::NONE || GetCapture() == hwnd)
|
||
{
|
||
if (!m_middlePanning)
|
||
{
|
||
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`。若这里不处理,那一次按下就完全丢失 ——
|
||
// 用户看到的正是“连点好几下、状态只切换了几次”(不跟手)。
|
||
// 之前只给工具栏打了这个补丁;本轮(用户 2026-10-17)扩展到:
|
||
// 弹出菜单 / 拾色器 / 信号面板(复选框、全部显示隐藏、绝对值、折叠三角、「组X」…)/
|
||
// 绘图区(浮动面板上的复选框与按钮、滚动条、GX 徽标)。
|
||
// 组件各自用 `DoubleClickIsPlainPress(x, y)` 判定“这个落点有没有双击语义”。
|
||
|
||
// 弹出菜单打开时接管输入:菜单条目只有单击语义。
|
||
if (m_menu.IsOpen())
|
||
{
|
||
if (m_menu.IsOnEditField(x, y))
|
||
{
|
||
m_menu.OnEditMouseDown(x, y);
|
||
SetCapture(hwnd);
|
||
m_activeTarget = Target::EDIT_FIELD;
|
||
InvalidateRect(hwnd, nullptr, FALSE);
|
||
return 0;
|
||
}
|
||
MenuResult result;
|
||
m_menu.OnLButtonDown(x, y, result);
|
||
InvalidateRect(hwnd, nullptr, FALSE);
|
||
if (result.valid)
|
||
{
|
||
DispatchMenuResult(result);
|
||
}
|
||
return 0;
|
||
}
|
||
|
||
// 拾色器打开时接管输入(低一行于菜单、高于常规组件):其内部只有拖动语义。
|
||
if (m_colorPicker.IsOpen())
|
||
{
|
||
if (m_colorPicker.ContainsPoint(x, y))
|
||
{
|
||
m_colorPicker.OnLButtonDown(x, y);
|
||
SetCapture(hwnd);
|
||
InvalidateRect(hwnd, nullptr, FALSE);
|
||
return 0;
|
||
}
|
||
}
|
||
|
||
// 工具栏没有双击语义,若这里不当作按下,那一次按下就完全丢失。
|
||
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;
|
||
}
|
||
|
||
// 信号面板 / 绘图区:只有“改名”等少数落点保留双击语义,其余当作按下。
|
||
if (m_panel.HitTest(x, y))
|
||
{
|
||
SetFocus(hwnd);
|
||
if (m_panel.DoubleClickIsPlainPress(x, y))
|
||
{
|
||
SetCapture(hwnd);
|
||
m_activeTarget = Target::PANEL;
|
||
if (m_panel.OnLButtonDown(x, y))
|
||
{
|
||
ApplyPendingPlotLaneScroll();
|
||
InvalidateRect(hwnd, nullptr, TRUE);
|
||
}
|
||
return 0;
|
||
}
|
||
bool changed = m_panel.OnDoubleClick(x, y);
|
||
if (changed)
|
||
{
|
||
InvalidateRect(hwnd, nullptr, TRUE);
|
||
}
|
||
ApplyPendingPanelScroll();
|
||
return 0;
|
||
}
|
||
if (m_plot.HitTest(x, y))
|
||
{
|
||
SetFocus(hwnd);
|
||
if (m_plot.DoubleClickIsPlainPress(x, y))
|
||
{
|
||
SetCapture(hwnd);
|
||
m_activeTarget = Target::PLOT;
|
||
if (m_plot.OnLButtonDown(x, y))
|
||
{
|
||
ApplyPendingRename();
|
||
ApplyPendingDerivedBuild();
|
||
InvalidateRect(hwnd, nullptr, FALSE);
|
||
}
|
||
ApplyPendingPanelScroll();
|
||
UpdateDragCursor(x, y);
|
||
return 0;
|
||
}
|
||
if (m_plot.OnDoubleClick(x, y))
|
||
{
|
||
InvalidateRect(hwnd, nullptr, TRUE);
|
||
}
|
||
ApplyPendingPanelScroll();
|
||
ApplyPendingRename();
|
||
return 0;
|
||
}
|
||
|
||
bool changed = false;
|
||
if (m_tabBar.HitTest(x, y))
|
||
{
|
||
SetFocus(hwnd);
|
||
changed = m_tabBar.OnDoubleClick(x, y);
|
||
if (m_tabBar.IsEditing())
|
||
{
|
||
// 进入重命名后该标签会加宽,需要重新排布标签栏。
|
||
Relayout();
|
||
}
|
||
}
|
||
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` 后紧接着 `ReleaseCapture`,并把 `m_activeTarget` 设成
|
||
// PANEL 又在下一行清回 NONE,是一对没有任何净效果的死操作。)
|
||
if (m_panel.OnRButtonDown(x, y))
|
||
{
|
||
InvalidateRect(hwnd, nullptr, TRUE);
|
||
}
|
||
return 0;
|
||
}
|
||
|
||
// 浮动面板:右键属于面板自身,**不得穿透**到下层网格(否则会在面板上弹出
|
||
// 网格的 Y 轴菜单)。必须先于下方的"绘图区右键 → 网格菜单"判定。
|
||
if (m_plot.OnRButtonDown(x, y))
|
||
{
|
||
InvalidateRect(hwnd, nullptr, FALSE);
|
||
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
|
||
{
|
||
// 光标仲裁:从组件表**顶部往下**找第一个命中的组件;它返回 nullptr 即视为
|
||
// "该区域用箭头"(与旧实现里 `if (绘图区命中) 取它的光标,否则看面板` 一致)。
|
||
for (auto iterator = m_components.rbegin(); iterator != m_components.rend(); ++iterator)
|
||
{
|
||
if (!iterator->component->HitTest(point.x, point.y))
|
||
{
|
||
continue;
|
||
}
|
||
cursor = iterator->component->QueryCursor(point.x, point.y);
|
||
break;
|
||
}
|
||
}
|
||
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:
|
||
// 失焦时**提交**所有内联输入并收起临时界面:旧实现只收起面板/拾色器/菜单,
|
||
// 标签页重命名与绘图区内联输入仍停在编辑态 ⇒ Alt+Tab 回来后继续往残留的输入框里打字。
|
||
{
|
||
if (m_tabBar.IsEditing())
|
||
{
|
||
TabBarResult lostFocusResult;
|
||
m_tabBar.OnEditKey(VK_RETURN, lostFocusResult);
|
||
OnTabBarResult(lostFocusResult);
|
||
}
|
||
if (m_plot.IsEditing())
|
||
{
|
||
m_plot.OnEditKey(VK_RETURN);
|
||
ApplyPendingRename();
|
||
}
|
||
m_panel.CloseTransientUi();
|
||
m_colorPicker.Close();
|
||
m_menu.Close();
|
||
// 捕获与拖拽目标一并复位(否则失焦期间的按下/抬起会错配)。
|
||
if (GetCapture() == hwnd)
|
||
{
|
||
ReleaseCapture();
|
||
}
|
||
m_activeTarget = Target::NONE;
|
||
m_middlePanning = false;
|
||
m_draggingSplitter = false;
|
||
}
|
||
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 kMessageCloneSignal:
|
||
{
|
||
// wParam = 分组下标,lParam = 组内信号下标。
|
||
SignalRef ref;
|
||
ref.groupIndex = static_cast<int>(wParam);
|
||
ref.signalIndex = static_cast<int>(static_cast<LONG_PTR>(lParam));
|
||
CloneSignal(ref);
|
||
return 0;
|
||
}
|
||
case WM_TIMER:
|
||
{
|
||
// 标题栏内存占用:只改窗口标题,不动客户区,因此无需重绘。
|
||
if (wParam == kRamTimerId)
|
||
{
|
||
UpdateWindowTitle();
|
||
return 0;
|
||
}
|
||
// 提示浮层淡出:仅重绘,不再做其它处理;Render 会在淡出结束后停表。
|
||
if (wParam == kHintTimerId)
|
||
{
|
||
InvalidateRect(hwnd, nullptr, FALSE);
|
||
return 0;
|
||
}
|
||
// 信号列表与绘图区动画(「滚动到目标信号」/「分组折起、展开」/「网格滚动到可视区」/
|
||
//「回到最上方」按钮的淡入淡出):推进一步并重绘(Render 会在动画结束后停表)。
|
||
// ⚠️ **失效范围必须与 `Render` 的绘制/回送判定完全配套**(用户 2026-10-17 报告:
|
||
// “滚动过渡动画甚至不如手动拖滚动条”)。旧实现无条件整窗失效 ⇒ `WM_PAINT` 看到
|
||
// 更新区域不在绘图区内 ⇒ 把 `m_chromeDirty` 置真 ⇒ **每一帧都重绘标签栏/工具栏/面板**
|
||
// (实测 +4.7ms/帧:纯绘图区帧 6.7ms → 11.7ms),动画因此挤不进 15.6ms 的定时器预算
|
||
// 而掉帧、位置跳动。
|
||
// 现在的判据:
|
||
// * 面板动画(滚动到目标信号 / 分组折起展开)**确实**要改面板像素 ⇒ 整窗失效;
|
||
// * 只有绘图区动画(网格滚动到可视区 / 回到最上方)⇒ **只失效绘图区**,
|
||
// `WM_PAINT` 判定为“更新区域在绘图区内” ⇒ `m_chromeDirty` 保持 false ⇒
|
||
// `Render` 只重绘并只回送绘图区(回送仍有无条件 else 兜底,未改 §9.41 的结构)。
|
||
// ⚠️ 用**推进之前**的面板动画状态判定:动画结束的那一帧仍整窗失效,
|
||
// 保证面板的最终位置被完整重绘并回送。
|
||
if (wParam == kScrollTimerId)
|
||
{
|
||
const bool panelAnimating =
|
||
m_panel.ScrollAnimationActive() || m_panel.GroupAnimationActive();
|
||
m_panel.AdvanceScrollAnimation();
|
||
m_panel.AdvanceGroupAnimations();
|
||
m_plot.AdvanceLaneScrollAnimation();
|
||
m_plot.AdvanceBackToTopAnimation();
|
||
if (panelAnimating)
|
||
{
|
||
// 面板动画:只有面板像素会变(且通常绘图区动画同时也在跑)⇒
|
||
// 用 `m_panelDirty` 让 `Render` 只重画面板 + 分隔条,而**不是**把
|
||
// 标签栏/工具栏/整窗底色也重画一遍(约 3~4ms/帧)。
|
||
// ⚠️ 用**推进之前**的状态判定:动画结束那一帧仍需整窗重绘,
|
||
// 保证面板的最终位置被完整重绘并回送。
|
||
m_panelDirty = !m_chromeDirty;
|
||
InvalidateRect(hwnd, nullptr, FALSE);
|
||
}
|
||
else
|
||
{
|
||
InvalidatePlotRect(hwnd, m_plotRect);
|
||
}
|
||
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
|
||
|