feat: 支持多视图标签页、按文件分组的多文件信号管理与网格随勾选自动收敛

数据层与视图配置解耦:新增 DataSet(多文件只读数据层)与 TabConfig(每标签页独立配置),
以 SignalRef{fileIndex,signalIndex} 标识全局信号,替换原有单文件 SignalSet。

- 标签页:新增 TabBar 组件,支持新建/切换/关闭标签页;每页独立保存信号勾选、
  颜色/线宽/显示方式、网格划分、视图范围与时间光标,互不影响。
- 网格收敛:可见性变更统一经 TabConfig 的 Set*Visible 接口并同步重算网格,
  取消勾选后对应的空网格立即回收(修复此前留下空轨道的问题)。
- 多文件分组:左侧面板改为「文件分组 + 信号」树形列表,分组可折叠、可整组勾选,
  分组复选框支持全选/部分/未选三态;新加载文件的信号默认未勾选,由用户自行选择。
- 来源标识:信号行、样式编辑区标题、波形图例与光标读数在跨文件时标注来源文件名。
- 交互补充:面板滚动条支持点击跳转与拖拽,切换标签页自动滚动到已选信号。
- 工具栏精简为「打开 CSV/适应视图/放大/缩小/合并网格/每信号一格/全选信号/清空勾选」,
  按钮宽度改为按实际文本像素测量;打开对话框支持多选文件,拖放支持批量载入。
- 新增 TestData/[2].CSV 作为跨文件对比测试数据。
This commit is contained in:
DeepSeek V4.1 Flash committed 2026-09-29 15:48:04 +08:00
1 parent 132b305544
commit 9c6c374cce
21 files changed
+4086 -1461

No files matched your search

+496 -112
View File
@@ -27,12 +27,18 @@ namespace jdi
const wchar_t* kWindowTitle = L"Just DataInspector"; /**< 默认窗口标题。 */
const float kStatusBarHeight = 26.0f; /**< 状态栏高度(逻辑像素)。 */
const float kSplitterWidth = 4.0f; /**< 分隔条宽度(逻辑像素)。 */
const float kMinPanelWidth = 210.0f; /**< 面板最小宽度(逻辑像素)。 */
const float kMaxPanelWidth = 460.0f; /**< 面板最大宽度(逻辑像素)。 */
const int kMinClientWidth = 820; /**< 窗口最小客户区宽度。 */
const int kMinClientHeight = 520; /**< 窗口最小客户区高度。 */
const float kMinPanelWidth = 220.0f; /**< 面板最小宽度(逻辑像素)。 */
const float kMaxPanelWidth = 480.0f; /**< 面板最大宽度(逻辑像素)。 */
const int kMinClientWidth = 860; /**< 窗口最小客户区宽度。 */
const int kMinClientHeight = 540; /**< 窗口最小客户区高度。 */
const UINT kMessageRemoveFile = WM_APP + 1; /**< 面板请求移除文件的自定义消息。 */
const wchar_t* kStatusHint = L"滚轮缩放 · Shift+滚轮平移 · Ctrl+滚轮调幅值 · 右键框选 · 双击自适应";
/** @brief 标签页右键菜单命令 ID。 */
const int kMenuTabNew = 1;
const int kMenuTabClose = 2;
const int kMenuTabCloseOthers = 3;
/**
* @brief 开启进程级 DPI 感知(优先 Per-Monitor V2)。
*/
@@ -45,8 +51,7 @@ namespace jdi
SetProcessDpiAwarenessContextFn setContext =
reinterpret_cast<SetProcessDpiAwarenessContextFn>(
reinterpret_cast<void*>(GetProcAddress(user32, "SetProcessDpiAwarenessContext")));
if (setContext != nullptr &&
setContext(DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2) != FALSE)
if (setContext != nullptr && setContext(DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2) != FALSE)
{
return;
}
@@ -166,14 +171,14 @@ namespace jdi
}
// 依据工作区(排除任务栏)计算默认窗口尺寸并居中,避免窗口超出屏幕。
RECT workArea = {0, 0, static_cast<LONG>(ScaleOf(1340.0f, m_scale)),
static_cast<LONG>(ScaleOf(860.0f, m_scale))};
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(1340.0f, m_scale)), workWidth - 24);
const int windowHeight = std::min(static_cast<int>(ScaleOf(860.0f, m_scale)), workHeight - 24);
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);
@@ -186,6 +191,8 @@ namespace jdi
return false;
}
m_tabBar.Setup(&m_fonts, &m_theme);
m_tabBar.SetScale(m_scale);
m_toolbar.Setup(&m_fonts, &m_theme);
m_toolbar.SetScale(m_scale);
m_panel.Setup(m_hwnd, &m_fonts, &m_theme);
@@ -197,10 +204,13 @@ namespace jdi
SendMessageW(m_hwnd, WM_SETICON, ICON_BIG, reinterpret_cast<LPARAM>(icon));
SendMessageW(m_hwnd, WM_SETICON, ICON_SMALL, reinterpret_cast<LPARAM>(icon));
// 创建首个标签页。
NewTab();
Relayout();
ShowWindow(m_hwnd, showCommand);
UpdateWindow(m_hwnd);
LoadCommandLineFile();
LoadCommandLineFiles();
return true;
}
@@ -227,6 +237,7 @@ namespace jdi
m_scale = static_cast<float>(dpi) / 96.0f;
m_fonts.Create(dpi);
m_tabBar.SetScale(m_scale);
m_toolbar.SetScale(m_scale);
m_panel.SetScale(m_scale);
m_plot.SetScale(m_scale);
@@ -234,6 +245,179 @@ namespace jdi
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()
{
++m_tabNameCounter;
TabConfig tab;
tab.SetName(L"视图 " + std::to_wstring(m_tabNameCounter));
tab.SyncToDataSet(m_dataSet);
m_tabs.push_back(std::move(tab));
SetActiveTab(static_cast<int>(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_tabs[static_cast<std::size_t>(m_activeTab)].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())
{
m_plot.FitAll();
}
const bool laneCountChanged = (laneCount != previousLaneCount);
if (laneCountChanged || force)
{
// 网格数量变化会影响滚动条与绘图区宽度,必须重新布局。
Relayout();
}
else
{
m_plot.RefreshLanes();
}
}
void Application::SyncTabsToDataSet()
{
for (TabConfig& tab : m_tabs)
{
tab.SyncToDataSet(m_dataSet);
}
m_panel.InvalidateRows();
// 强制下一次同步重新计算视图状态。
m_cachedVisibleCount = -1;
m_cachedAnyVisible = false;
m_cachedLaneCount = 0;
if (m_hwnd != nullptr)
{
InvalidateRect(m_hwnd, nullptr, TRUE);
}
}
void Application::RemoveFile(int fileIndex)
{
const DataFile* file = m_dataSet.FileAt(fileIndex);
if (file == nullptr)
{
return;
}
const std::wstring name = file->fileName;
// 先按旧下标修正各标签页配置,再从数据层移除,最后统一对齐。
for (TabConfig& tab : m_tabs)
{
tab.RemapAfterFileRemoval(fileIndex);
}
m_dataSet.RemoveFile(fileIndex);
SyncTabsToDataSet();
SetStatusMessage(L"已从工作区移除:" + name, false);
UpdateWindowTitle();
}
void Application::Relayout()
{
RECT client = {};
@@ -245,25 +429,29 @@ namespace jdi
return;
}
const float tabBarHeight = m_tabBar.DesiredHeight();
const float toolbarHeight = m_toolbar.DesiredHeight();
const float statusHeight = ScaleOf(kStatusBarHeight, m_scale);
const float splitterWidth = ScaleOf(kSplitterWidth, m_scale);
m_toolbarRect = gd::RectF(0.0f, 0.0f, width, toolbarHeight);
m_tabBarRect = gd::RectF(0.0f, 0.0f, width, tabBarHeight);
m_toolbarRect = gd::RectF(0.0f, m_tabBarRect.Y + m_tabBarRect.Height, width, toolbarHeight);
m_statusRect = gd::RectF(0.0f, height - statusHeight, width, statusHeight);
const float contentHeight = std::max(0.0f, height - toolbarHeight - statusHeight);
const float contentTop = m_toolbarRect.Y + m_toolbarRect.Height;
const float contentHeight = std::max(0.0f, m_statusRect.Y - contentTop);
float panelWidth = ScaleOf(m_panelWidth, m_scale);
const float maximum = std::min(ScaleOf(kMaxPanelWidth, m_scale), width * 0.45f);
panelWidth = std::max(ScaleOf(kMinPanelWidth, m_scale), std::min(panelWidth, maximum));
m_panelWidth = panelWidth / m_scale;
m_panelRect = gd::RectF(0.0f, toolbarHeight, panelWidth, contentHeight);
m_splitterRect = gd::RectF(panelWidth, toolbarHeight, splitterWidth, contentHeight);
m_plotRect = gd::RectF(panelWidth + splitterWidth, toolbarHeight,
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.Layout(m_panelRect);
m_plot.Layout(m_plotRect);
@@ -340,16 +528,11 @@ namespace jdi
return;
}
// 网格数量变化(如合并/拆分/移动信号)会影响滚动条与绘图区宽度,需要重新布局。
const std::size_t laneCount = m_document.IsLoaded() ? m_document.Lanes().size() : 0;
if (laneCount != m_lastLaneCount)
{
m_lastLaneCount = laneCount;
Relayout();
}
// 任何交互(勾选、移动网格、切换标签页)之后都在此统一收敛视图状态。
SynchronizeView(false);
m_toolbar.SetDocumentLoaded(m_document.IsLoaded());
if (m_document.IsLoaded())
m_toolbar.SetDocumentLoaded(!m_dataSet.Empty());
if (!m_dataSet.Empty())
{
m_toolbar.SetStatusText(L"视图 " + FormatValue(m_plot.ViewMinTime()) + L" ~ " +
FormatValue(m_plot.ViewMaxTime()));
@@ -368,6 +551,7 @@ namespace jdi
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);
@@ -395,19 +579,20 @@ namespace jdi
const float halfWidth = m_statusRect.Width * 0.5f;
std::wstring leftText;
if (m_document.IsLoaded())
if (!m_dataSet.Empty())
{
leftText = m_document.FileName() + L" · " +
FormatCount(static_cast<unsigned long long>(m_document.SampleCount())) + L" 采样点 · " +
std::to_wstring(m_document.Signals().size()) + L" 信号 · " +
std::to_wstring(m_document.Lanes().size()) + L" 网格";
const TabConfig& tab = ActiveTab();
leftText = std::to_wstring(m_dataSet.FileCount()) + L" 个文件 · " +
FormatCount(static_cast<unsigned long long>(m_dataSet.SignalCount())) + L" 条信号 · " +
std::to_wstring(tab.VisibleCount()) + L" 条已选 · " +
std::to_wstring(tab.Lanes().size()) + L" 个网格";
}
else
{
leftText = L"未加载数据 · 请点击「打开 CSV」或拖拽文件到窗口";
}
UiText(g, leftText, m_fonts.uiSmall,
UiText(g, UiEllipsize(g, leftText, m_fonts.uiSmall, halfWidth - padding * 2.0f), m_fonts.uiSmall,
gd::RectF(m_statusRect.X + padding, m_statusRect.Y, halfWidth - padding, m_statusRect.Height),
m_theme.text, TextAlign::LEFT);
@@ -418,13 +603,13 @@ namespace jdi
rightText = m_statusMessage;
rightColor = m_statusIsError ? m_theme.danger : m_theme.textStrong;
}
else if (m_plot.HasCursor() && m_document.IsLoaded())
else if (m_plot.HasCursor() && !m_dataSet.Empty())
{
rightText = L"光标 · " + FormatValue(m_plot.CursorTime());
rightColor = m_theme.text;
}
UiText(g, rightText, m_fonts.uiSmall,
UiText(g, UiEllipsize(g, rightText, m_fonts.uiSmall, halfWidth - padding * 2.0f), m_fonts.uiSmall,
gd::RectF(m_statusRect.X + halfWidth, m_statusRect.Y, halfWidth - padding, m_statusRect.Height),
rightColor, TextAlign::RIGHT);
}
@@ -438,75 +623,123 @@ namespace jdi
void Application::UpdateWindowTitle()
{
std::wstring title = kWindowTitle;
if (m_document.IsLoaded())
if (m_activeTab >= 0 && m_activeTab < static_cast<int>(m_tabs.size()))
{
title += L" — ";
title += m_document.FileName();
title += m_tabs[static_cast<std::size_t>(m_activeTab)].Name();
}
if (!m_dataSet.Empty())
{
title += L"(" + std::to_wstring(m_dataSet.FileCount()) + L" 个文件)";
}
SetWindowTextW(m_hwnd, title.c_str());
}
void Application::OpenFileDialog()
{
wchar_t buffer[MAX_PATH * 4] = {};
// 允许一次选择多个文件,便于跨文件对比信号。
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;
dialog.nMaxFile = MAX_PATH * 4;
dialog.lpstrTitle = L"打开仿真数据文件";
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;
dialog.Flags = OFN_FILEMUSTEXIST | OFN_PATHMUSTEXIST | OFN_EXPLORER | OFN_NOCHANGEDIR |
OFN_ALLOWMULTISELECT;
if (GetOpenFileNameW(&dialog) == FALSE)
{
return;
}
LoadFile(buffer);
// 多选时首段为目录,其余为文件名;单选时整段即为完整路径。
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;
}
const int existing = m_dataSet.IndexOfFile(path);
if (existing >= 0)
{
const DataFile* file = m_dataSet.FileAt(existing);
SetStatusMessage(L"该文件已在工作区中:" + (file != nullptr ? file->fileName : path), true);
return false;
}
CsvTable table;
std::wstring error;
if (!LoadCsvTable(path, table, error))
{
SetStatusMessage(L"解析失败:" + error, true);
InvalidateRect(m_hwnd, nullptr, FALSE);
return false;
}
SignalSet loaded;
if (!loaded.BuildFromTable(table, path, error))
int fileIndex = -1;
if (!m_dataSet.AddFromTable(table, path, error, fileIndex))
{
SetStatusMessage(L"解析失败:" + error, true);
InvalidateRect(m_hwnd, nullptr, FALSE);
return false;
}
m_document = std::move(loaded);
m_panel.SetDocument(&m_document);
m_plot.SetDocument(&m_document);
m_plot.FitAll();
Relayout();
std::wstring message = L"已加载 " + m_document.FileName() + L"(" +
FormatCount(static_cast<unsigned long long>(m_document.SampleCount())) +
L" 行采样,时间列:" + m_document.TimeColumnName() + L")";
if (!m_document.TimeMonotonic())
{
message += L" 提示:时间列非单调递增,光标取值使用最近邻策略。";
}
SetStatusMessage(message, false);
// 新文件的信号在全部标签页中默认均为未勾选状态,由用户自行决定查看内容。
SyncTabsToDataSet();
UpdateWindowTitle();
InvalidateRect(m_hwnd, nullptr, TRUE);
const DataFile* file = m_dataSet.FileAt(fileIndex);
if (file != nullptr)
{
std::wstring message = L"已加载 " + file->fileName + L"(" +
FormatCount(static_cast<unsigned long long>(file->time.size())) +
L" 行采样," + std::to_wstring(file->signals.size()) +
L" 条信号,时间列:" + file->timeColumnName + L")";
if (!file->timeMonotonic)
{
message += L" 提示:时间列非单调递增,光标取值使用最近邻策略。";
}
SetStatusMessage(message, false);
}
return true;
}
void Application::LoadCommandLineFile()
void Application::LoadCommandLineFiles()
{
int argumentCount = 0;
LPWSTR* arguments = CommandLineToArgvW(GetCommandLineW(), &argumentCount);
@@ -515,6 +748,7 @@ namespace jdi
return;
}
int loaded = 0;
for (int index = 1; index < argumentCount; ++index)
{
const std::wstring argument = arguments[index];
@@ -522,10 +756,19 @@ namespace jdi
{
continue;
}
LoadFile(argument);
break;
if (LoadFile(argument))
{
++loaded;
}
}
LocalFree(arguments);
if (loaded > 0)
{
SetStatusMessage(L"已加载 " + std::to_wstring(loaded) +
L" 个文件,请在左侧勾选需要查看的信号。",
false);
}
}
void Application::ExecuteToolbarAction(ToolbarAction action)
@@ -538,9 +781,6 @@ namespace jdi
case ToolbarAction::FIT_ALL:
m_plot.FitAll();
break;
case ToolbarAction::FIT_TIME:
m_plot.FitTime();
break;
case ToolbarAction::ZOOM_IN:
m_plot.ZoomTimeStep(1.4);
break;
@@ -548,33 +788,44 @@ namespace jdi
m_plot.ZoomTimeStep(1.0 / 1.4);
break;
case ToolbarAction::MERGE_LANES:
if (m_document.IsLoaded())
if (!m_dataSet.Empty())
{
m_document.MergeIntoSingleLane();
m_panel.SetDocument(&m_document);
m_plot.FitAll();
Relayout();
SetStatusMessage(L"已将全部信号合并到同一网格。", false);
ActiveTab().SetMode(m_dataSet, LaneMode::MERGED);
SetStatusMessage(L"已将当前标签页的全部可见信号合并到同一网格。", false);
}
break;
case ToolbarAction::SPLIT_LANES:
if (m_document.IsLoaded())
if (!m_dataSet.Empty())
{
m_document.SplitIntoLanes();
m_panel.SetDocument(&m_document);
m_plot.FitAll();
Relayout();
SetStatusMessage(L"已按信号拆分为独立网格。", false);
ActiveTab().SetMode(m_dataSet, LaneMode::PER_SIGNAL);
SetStatusMessage(L"当前标签页已切换为「每信号一格」。", false);
}
break;
case ToolbarAction::RESET_VIEW:
m_plot.FitAll();
case ToolbarAction::SELECT_ALL:
if (!m_dataSet.Empty())
{
ActiveTab().SetAllVisible(m_dataSet, true);
m_panel.InvalidateRows();
SetStatusMessage(L"已勾选当前标签页全部信号。", false);
}
break;
case ToolbarAction::CLEAR_ALL:
if (!m_dataSet.Empty())
{
ActiveTab().SetAllVisible(m_dataSet, false);
m_panel.InvalidateRows();
SetStatusMessage(L"已清空当前标签页的信号勾选。", false);
}
break;
case ToolbarAction::NONE:
default:
break;
}
InvalidateRect(m_hwnd, nullptr, TRUE);
if (m_hwnd != nullptr)
{
InvalidateRect(m_hwnd, nullptr, TRUE);
}
}
bool Application::HandleKeyDown(WPARAM key)
@@ -586,6 +837,26 @@ namespace jdi
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)
{
@@ -621,11 +892,6 @@ namespace jdi
m_plot.FitAll();
return true;
}
if (key == L'G')
{
m_plot.FitTime();
return true;
}
return false;
}
@@ -639,8 +905,8 @@ namespace jdi
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);
const float maximum =
std::min(kMaxPanelWidth, static_cast<float>(kMinClientWidth) / m_scale);
m_panelWidth = std::max(kMinPanelWidth, std::min(desired, maximum));
Relayout();
return true;
@@ -652,6 +918,8 @@ namespace jdi
return m_panel.OnMouseMove(x, y);
case Target::TOOLBAR:
return m_toolbar.OnMouseMove(x, y);
case Target::TAB_BAR:
return m_tabBar.OnMouseMove(x, y);
case Target::NONE:
default:
return false;
@@ -659,12 +927,84 @@ namespace jdi
}
bool changed = false;
changed = (m_toolbar.HitTest(x, y) ? m_toolbar.OnMouseMove(x, y) : m_toolbar.OnMouseLeave()) || changed;
changed =
(m_tabBar.HitTest(x, y) ? m_tabBar.OnMouseMove(x, y) : m_tabBar.OnMouseLeave()) || changed;
changed =
(m_toolbar.HitTest(x, y) ? m_toolbar.OnMouseMove(x, y) : m_toolbar.OnMouseLeave()) || changed;
changed = (m_panel.HitTest(x, y) ? m_panel.OnMouseMove(x, y) : m_panel.OnMouseLeave()) || changed;
changed = (m_plot.HitTest(x, y) ? m_plot.OnMouseMove(x, y) : m_plot.OnMouseLeave()) || changed;
return changed;
}
void Application::OnTabBarResult(const TabBarResult& result)
{
switch (result.type)
{
case TabBarResult::Type::ADD:
NewTab();
break;
case TabBarResult::Type::SELECT:
if (result.index != m_activeTab)
{
SetActiveTab(result.index);
}
break;
case TabBarResult::Type::CLOSE:
CloseTab(result.index);
break;
case TabBarResult::Type::CONTEXT:
ShowTabContextMenu(result);
break;
case TabBarResult::Type::NONE:
default:
break;
}
}
void Application::ShowTabContextMenu(const TabBarResult& result)
{
if (result.index < 0 || result.index >= static_cast<int>(m_tabs.size()))
{
return;
}
HMENU menu = CreatePopupMenu();
if (menu == nullptr)
{
return;
}
AppendMenuW(menu, MF_STRING, static_cast<UINT_PTR>(kMenuTabNew), L"新建标签页");
AppendMenuW(menu, MF_STRING, static_cast<UINT_PTR>(kMenuTabClose), L"关闭该标签页");
AppendMenuW(menu, MF_STRING, static_cast<UINT_PTR>(kMenuTabCloseOthers), L"关闭其它标签页");
if (m_tabs.size() <= 1)
{
EnableMenuItem(menu, static_cast<UINT>(kMenuTabClose), MF_BYCOMMAND | MF_GRAYED);
EnableMenuItem(menu, static_cast<UINT>(kMenuTabCloseOthers), MF_BYCOMMAND | MF_GRAYED);
}
SetForegroundWindow(m_hwnd);
const int command = TrackPopupMenu(menu, TPM_RETURNCMD | TPM_LEFTALIGN | TPM_TOPALIGN,
result.screenX, result.screenY, 0, m_hwnd, nullptr);
DestroyMenu(menu);
if (command == kMenuTabNew)
{
NewTab();
}
else if (command == kMenuTabClose)
{
CloseTab(result.index);
}
else if (command == kMenuTabCloseOthers)
{
const TabConfig keep = m_tabs[static_cast<std::size_t>(result.index)];
m_tabs.clear();
m_tabs.push_back(keep);
SetActiveTab(0);
}
}
LRESULT Application::HandleMessage(HWND hwnd, UINT message, WPARAM wParam, LPARAM lParam)
{
switch (message)
@@ -731,7 +1071,8 @@ namespace jdi
case WM_MOUSELEAVE:
{
m_trackingLeave = false;
bool changed = m_toolbar.OnMouseLeave();
bool changed = m_tabBar.OnMouseLeave();
changed = m_toolbar.OnMouseLeave() || changed;
changed = m_panel.OnMouseLeave() || changed;
changed = m_plot.OnMouseLeave() || changed;
if (changed)
@@ -755,6 +1096,15 @@ namespace jdi
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);
@@ -771,7 +1121,7 @@ namespace jdi
m_activeTarget = Target::PANEL;
if (m_panel.OnLButtonDown(x, y))
{
InvalidateRect(hwnd, nullptr, FALSE);
InvalidateRect(hwnd, nullptr, TRUE);
}
return 0;
}
@@ -796,19 +1146,23 @@ namespace jdi
const int y = GET_Y_LPARAM(lParam);
ReleaseCapture();
bool changed = false;
switch (m_activeTarget)
{
case Target::SPLITTER:
m_draggingSplitter = false;
break;
case Target::TAB_BAR:
{
TabBarResult result;
m_tabBar.OnLButtonUp(x, y, result);
m_activeTarget = Target::NONE;
OnTabBarResult(result);
return 0;
}
case Target::TOOLBAR:
{
ToolbarAction action = ToolbarAction::NONE;
if (m_toolbar.OnLButtonUp(x, y, action))
{
changed = true;
}
m_toolbar.OnLButtonUp(x, y, action);
m_activeTarget = Target::NONE;
if (action != ToolbarAction::NONE)
{
@@ -818,10 +1172,10 @@ namespace jdi
break;
}
case Target::PANEL:
changed = m_panel.OnLButtonUp(x, y) || changed;
m_panel.OnLButtonUp(x, y);
break;
case Target::PLOT:
changed = m_plot.OnLButtonUp(x, y) || changed;
m_plot.OnLButtonUp(x, y);
break;
case Target::NONE:
default:
@@ -829,11 +1183,7 @@ namespace jdi
}
m_activeTarget = Target::NONE;
changed = true;
if (changed)
{
InvalidateRect(hwnd, nullptr, FALSE);
}
InvalidateRect(hwnd, nullptr, TRUE);
return 0;
}
case WM_LBUTTONDBLCLK:
@@ -851,7 +1201,7 @@ namespace jdi
}
if (changed)
{
InvalidateRect(hwnd, nullptr, FALSE);
InvalidateRect(hwnd, nullptr, TRUE);
}
return 0;
}
@@ -860,13 +1210,26 @@ namespace jdi
const int x = GET_X_LPARAM(lParam);
const int y = GET_Y_LPARAM(lParam);
POINT cursor = {x, y};
ClientToScreen(hwnd, &cursor);
if (m_tabBar.HitTest(x, y))
{
TabBarResult result;
if (m_tabBar.OnRButtonDown(x, y, cursor.x, cursor.y, result))
{
OnTabBarResult(result);
}
return 0;
}
if (m_panel.HitTest(x, y))
{
SetCapture(hwnd);
m_activeTarget = Target::PANEL;
if (m_panel.OnRButtonDown(x, y))
{
InvalidateRect(hwnd, nullptr, FALSE);
InvalidateRect(hwnd, nullptr, TRUE);
}
ReleaseCapture();
m_activeTarget = Target::NONE;
@@ -955,23 +1318,44 @@ namespace jdi
case WM_KEYDOWN:
if (HandleKeyDown(wParam))
{
InvalidateRect(hwnd, nullptr, FALSE);
InvalidateRect(hwnd, nullptr, TRUE);
return 0;
}
break;
case kMessageRemoveFile:
{
RemoveFile(static_cast<int>(wParam));
return 0;
}
case WM_DROPFILES:
{
HDROP drop = reinterpret_cast<HDROP>(wParam);
const UINT length = DragQueryFileW(drop, 0, nullptr, 0);
if (length > 0)
const UINT count = DragQueryFileW(drop, 0xFFFFFFFF, nullptr, 0);
int loaded = 0;
for (UINT index = 0; index < count; ++index)
{
std::vector<wchar_t> buffer(static_cast<std::size_t>(length) + 1, L'\0');
if (DragQueryFileW(drop, 0, buffer.data(), length + 1) > 0)
const UINT length = DragQueryFileW(drop, index, nullptr, 0);
if (length == 0)
{
LoadFile(std::wstring(buffer.data()));
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: