diff --git a/Components/Application/Inc/Application.hpp b/Components/Application/Inc/Application.hpp index 16a39fd..19326af 100644 --- a/Components/Application/Inc/Application.hpp +++ b/Components/Application/Inc/Application.hpp @@ -202,6 +202,12 @@ namespace jdi */ void ShowPlotContextMenu(int laneIndex, int x, int y); + /** + * @brief 把网格菜单上的 Y 轴上下限显示值刷新为数据层的真实值(菜单保持打开)。 + * @param [IN] laneIndex 网格下标。 + */ + void RefreshPlotMenuValues(int laneIndex); + /** * @brief 派发弹出菜单的选择结果。 * @param [IN] result 菜单选择结果。 @@ -264,6 +270,28 @@ namespace jdi */ void LoadCommandLineFiles(); + /** + * @brief 在后台线程解析并构建数据文件,同时播放加载过渡动画(界面不卡死)。 + * + * 大文件的瓶颈在“解析 CSV”与“按列重组采样数据”,这两步都不依赖 DataSet, + * 因此放在工作线程执行;主线程循环抽送消息并重绘加载遮罩层, + * 直到后台完成后再把结果移动进 DataSet(O(1),不会引起卡顿)。 + * + * @param [IN] path 文件路径。 + * @param [OUT] file 构建好的数据文件。 + * @param [OUT] error 失败原因描述。 + * @return 加载成功返回 true;用户取消或失败返回 false。 + */ + bool LoadFileAsync(const std::wstring& path, DataFile& file, std::wstring& error); + + /** + * @brief 绘制加载过渡动画遮罩(在 Render 的最后一步调用,覆盖整个客户区)。 + * @param [IN,OUT] g 绘图上下文。 + * @param [IN] width 客户区宽度。 + * @param [IN] height 客户区高度。 + */ + void PaintLoadingOverlay(gd::Graphics& g, int width, int height); + /** * @brief 处理客户区鼠标移动。 * @param [IN] x 客户区横坐标。 @@ -317,6 +345,12 @@ namespace jdi int m_cachedVisibleCount = -1; /**< 上次同步时的可见信号数量。 */ std::size_t m_cachedLaneCount = 0; /**< 上次同步时的网格数量。 */ bool m_cachedAnyVisible = false; /**< 上次同步时是否存在可见信号。 */ + + bool m_loading = false; /**< 是否正在显示加载过渡动画。 */ + std::wstring m_loadingText; /**< 加载遮罩显示的文件名。 */ + float m_loadingProgress = 0.0f; /**< 加载进度(0-1)。 */ + float m_loadingSpin = 0.0f; /**< 加载动画旋转相位(度)。 */ + unsigned long long m_loadingBytes = 0; /**< 当前文件字节数(用于展示体积)。 */ }; } // namespace jdi diff --git a/Components/Application/Src/Application.cpp b/Components/Application/Src/Application.cpp index 7d5616c..ad2e42b 100644 --- a/Components/Application/Src/Application.cpp +++ b/Components/Application/Src/Application.cpp @@ -1,13 +1,16 @@ #include "Application.hpp" #include +#include #include +#include #include #include #include #include "CsvTable.hpp" +#include "ResourceIds.h" #include "Utf8Util.hpp" #ifndef WM_DPICHANGED @@ -37,6 +40,87 @@ namespace jdi 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(LoadImageW(module, MAKEINTRESOURCEW(IDI_APP_ICON), IMAGE_ICON, + cx, cy, LR_DEFAULTCOLOR)); + if (icon == nullptr) + { + icon = static_cast(LoadImageW(module, MAKEINTRESOURCEW(IDI_APP_ICON), IMAGE_ICON, 0, 0, + LR_DEFAULTCOLOR | LR_DEFAULTSIZE)); + } + return icon; + } + + /** + * @brief 后台加载任务的共享状态。 + * + * `done` 是唯一的同步点:工作线程写完其它字段后以 release 语义置位, + * 主线程以 acquire 语义读取,因此无需额外锁。 + */ + struct AsyncLoadState + { + std::atomic done{false}; /**< 工作线程是否已结束。 */ + std::atomic cancelled{false}; /**< 是否请求取消。 */ + std::atomic 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(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(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)。 */ @@ -159,8 +243,10 @@ namespace jdi windowClass.hCursor = LoadCursorW(nullptr, IDC_ARROW); windowClass.hbrBackground = nullptr; windowClass.lpszClassName = kWindowClassName; - windowClass.hIcon = LoadIconW(nullptr, IDI_APPLICATION); - windowClass.hIconSm = windowClass.hIcon; + // 使用应用自己的图标资源(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) { @@ -204,10 +290,19 @@ namespace jdi m_plot.Setup(&m_fonts, &m_theme); m_plot.SetScale(m_scale); m_plot.SetHostWindow(m_hwnd); + m_menu.SetHostWindow(m_hwnd); - HICON icon = LoadIconW(nullptr, IDI_APPLICATION); - SendMessageW(m_hwnd, WM_SETICON, ICON_BIG, reinterpret_cast(icon)); - SendMessageW(m_hwnd, WM_SETICON, ICON_SMALL, reinterpret_cast(icon)); + // 标题栏小图标与任务栏/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(bigIcon)); + } + if (smallIcon != nullptr) + { + SendMessageW(m_hwnd, WM_SETICON, ICON_SMALL, reinterpret_cast(smallIcon)); + } // 创建首个标签页。 NewTab(); @@ -607,6 +702,9 @@ namespace jdi // 弹出菜单与浮动拾色器始终绘制在最上层,保证与主界面风格一致。 m_menu.Paint(g); m_colorPicker.Paint(g); + + // 加载遮罩层在一切内容之上(包括菜单与拾色器),形成模态观感。 + PaintLoadingOverlay(g, width, height); } HDC windowDc = GetDC(m_hwnd); @@ -694,21 +792,24 @@ namespace jdi return false; } - // 同一文件允许多次加载:每次都会新建一个独立分组,各自持有独立的采样数据副本, - // 因此后续对原始文件的修改或删除都不会影响已加载内容。 - CsvTable table; + // 大文件在工作线程解析并构建,主线程播放加载过渡动画,界面不再整块卡住。 + DataFile file; std::wstring error; - if (!LoadCsvTable(path, table, error)) + if (!LoadFileAsync(path, file, error)) { - SetStatusMessage(L"解析失败:" + error, true); + if (!error.empty()) + { + SetStatusMessage(L"解析失败:" + error, true); + } InvalidateRect(m_hwnd, nullptr, FALSE); return false; } int fileIndex = -1; - if (!m_dataSet.AddFromTable(table, path, error, fileIndex)) + // 此处只做一次指针/缓冲移动,不拷贝任何采样数据,因此不会造成可见卡顿。 + if (!m_dataSet.AddPreparedFile(std::move(file), fileIndex)) { - SetStatusMessage(L"解析失败:" + error, true); + SetStatusMessage(L"解析失败:数据为空。", true); InvalidateRect(m_hwnd, nullptr, FALSE); return false; } @@ -717,14 +818,14 @@ namespace jdi SyncTabsToDataSet(); UpdateWindowTitle(); - const DataFile* file = m_dataSet.FileAt(fileIndex); - if (file != nullptr) + const DataFile* loaded = m_dataSet.FileAt(fileIndex); + if (loaded != nullptr) { - std::wstring message = L"已加载 组" + std::to_wstring(fileIndex + 1) + L"|" + file->Title() + - L"(" + FormatCount(static_cast(file->time.size())) + - L" 行采样," + std::to_wstring(file->signals.size()) + - L" 条信号,时间列:" + file->timeColumnName + L")"; - if (!file->timeMonotonic) + std::wstring message = L"已加载 组" + std::to_wstring(fileIndex + 1) + L"|" + loaded->Title() + + L"(" + FormatCount(static_cast(loaded->time.size())) + + L" 行采样," + std::to_wstring(loaded->signals.size()) + + L" 条信号,时间列:" + loaded->timeColumnName + L")"; + if (!loaded->timeMonotonic) { message += L" 提示:时间列非单调递增,光标取值使用最近邻策略。"; } @@ -733,6 +834,148 @@ namespace jdi 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(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(width), static_cast(height)), + 0x66000000u); + + const float cardWidth = std::min(420.0f * m_scale, static_cast(width) - 40.0f * m_scale); + const float cardHeight = 132.0f * m_scale; + const gd::RectF card((static_cast(width) - cardWidth) * 0.5f, + (static_cast(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(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; @@ -1127,21 +1370,38 @@ namespace jdi const bool isAuto = m_plot.LaneRangeIsAuto(laneIndex); - // 当前网格的 Y 轴上下限值直接在菜单项中显示,单击即可直接改写。 + // 当前网格的 Y 轴上下限值直接在菜单内的输入框里显示,单击即可就地编辑(菜单不关闭)。 double laneYMin = 0.0; double laneYMax = 1.0; m_plot.LaneRange(laneIndex, laneYMin, laneYMax); MenuItem maxItem; - maxItem.label = L"Y 轴最大值:" + FormatValue(laneYMax); + 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.label = L"Y 轴最小值:" + FormatValue(laneYMin); + 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); + // 选区自适应:只按当前显示时间段内的数据做幅值自适应(放大局部后铺满网格)。 + MenuItem autoViewItem; + autoViewItem.label = L"选区 Y 轴自适应"; + autoViewItem.id = PLOT_CMD_LANE_AUTO_VIEW; + items.push_back(autoViewItem); + + MenuItem separator; + separator.kind = MenuItemKind::SEPARATOR; + items.push_back(separator); + + // 恢复自适应放在分隔线下方、时间轴自适应上方。 MenuItem autoItem; autoItem.label = isAuto ? L"Y 轴自适应(当前)" : L"恢复 Y 轴自适应"; autoItem.id = PLOT_CMD_LANE_AUTO; @@ -1149,10 +1409,6 @@ namespace jdi autoItem.enabled = !isAuto; items.push_back(autoItem); - MenuItem separator; - separator.kind = MenuItemKind::SEPARATOR; - items.push_back(separator); - MenuItem fitLaneTime; fitLaneTime.label = L"时间轴自适应(匹配本网格)"; fitLaneTime.id = PLOT_CMD_LANE_FIT_TIME; @@ -1171,6 +1427,25 @@ namespace jdi 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)); + + // 手工输入上下限后已不再是自动自适应,同步刷新该项的文字/选中标记/可用状态。 + const bool isAuto = m_plot.LaneRangeIsAuto(laneIndex); + m_menu.UpdateItemLabel(PLOT_CMD_LANE_AUTO, isAuto ? L"Y 轴自适应(当前)" : L"恢复 Y 轴自适应", + isAuto, !isAuto); + } + void Application::DispatchMenuResult(const MenuResult& result) { if (!result.valid) @@ -1192,6 +1467,21 @@ namespace jdi } 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) @@ -1199,11 +1489,8 @@ namespace jdi case PLOT_CMD_LANE_AUTO: m_plot.ResetLaneRange(laneIndex); break; - case PLOT_CMD_LANE_Y_MAX: - m_plot.BeginLaneValueEdit(laneIndex, true, result.itemRect); - break; - case PLOT_CMD_LANE_Y_MIN: - m_plot.BeginLaneValueEdit(laneIndex, false, result.itemRect); + case PLOT_CMD_LANE_AUTO_VIEW: + m_plot.AutoRangeLaneToView(laneIndex); break; case PLOT_CMD_LANE_FIT_TIME: m_plot.FitTimeToLane(laneIndex); @@ -1267,6 +1554,15 @@ namespace jdi 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); @@ -1274,19 +1570,60 @@ namespace jdi 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(GET_X_LPARAM(lParam), GET_Y_LPARAM(lParam), 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(wParam), result)) + { + InvalidateRect(hwnd, nullptr, FALSE); + } + if (result.valid) + { + DispatchMenuResult(result); + } + return 0; + } case WM_RBUTTONDOWN: case WM_MBUTTONDOWN: case WM_MOUSEWHEEL: diff --git a/Components/PlotView/Inc/PlotView.hpp b/Components/PlotView/Inc/PlotView.hpp index b6a64a4..17ccfea 100644 --- a/Components/PlotView/Inc/PlotView.hpp +++ b/Components/PlotView/Inc/PlotView.hpp @@ -47,11 +47,12 @@ namespace jdi */ enum PlotMenuCommand : int { - PLOT_CMD_LANE_AUTO = 1, /**< 该网格恢复 Y 轴自适应。 */ - PLOT_CMD_LANE_Y_MAX = 2, /**< 输入该网格 Y 轴最大值。 */ - PLOT_CMD_LANE_Y_MIN = 3, /**< 输入该网格 Y 轴最小值。 */ - PLOT_CMD_FIT_ALL = 4, /**< 全部网格与时间轴自适应。 */ - PLOT_CMD_LANE_FIT_TIME = 5 /**< 时间轴自适应到本网格信号范围。 */ + PLOT_CMD_LANE_AUTO = 1, /**< 该网格恢复 Y 轴自适应。 */ + PLOT_CMD_LANE_Y_MAX = 2, /**< 输入该网格 Y 轴最大值。 */ + PLOT_CMD_LANE_Y_MIN = 3, /**< 输入该网格 Y 轴最小值。 */ + PLOT_CMD_FIT_ALL = 4, /**< 全部网格与时间轴自适应。 */ + PLOT_CMD_LANE_FIT_TIME = 5, /**< 时间轴自适应到本网格信号范围。 */ + PLOT_CMD_LANE_AUTO_VIEW = 6 /**< 按当前显示时间段内的数据做 Y 轴自适应。 */ }; /** @@ -158,6 +159,17 @@ namespace jdi */ bool FitTimeToLane(int laneIndex); + /** + * @brief 依据当前显示时间段内的数据对该网格做 Y 轴自适应(“选区 Y 轴自适应”)。 + * + * 与普通自适应(用整段数据的极值)不同:这里只看时间轴当前可见区间内的采样点, + * 因此放大到某段局部波形后可以快速把该段的幅值铺满整个网格。 + * + * @param [IN] laneIndex 网格下标。 + * @return 调整成功返回 true(区间内无有效数据时返回 false)。 + */ + bool AutoRangeLaneToView(int laneIndex); + /** * @brief 把时间吸附到距今最近的数据采样点。 * @@ -302,6 +314,15 @@ namespace jdi */ bool BeginLaneValueEdit(int laneIndex, bool editMax, const gd::RectF& anchorRect); + /** + * @brief 直接把文本解析为网格 Y 轴上限/下限并应用(供菜单内联输入框提交使用)。 + * @param [IN] laneIndex 网格下标。 + * @param [IN] editMax true 写上限,false 写下限。 + * @param [IN] text 输入文本。 + * @return 解析并应用成功返回 true。 + */ + bool ApplyLaneValueText(int laneIndex, bool editMax, const std::wstring& text); + /** * @brief 查询某网格当前的 Y 轴显示范围。 * @param [IN] laneIndex 网格下标。 @@ -602,10 +623,11 @@ namespace jdi * @brief 构建单条信号在当前视图下的折线点序列。 * @param [IN] resolved 已解析的信号视图。 * @param [IN] lane 所属网格绘制信息。 + * @param [IN] coarse 交互降级模式:限制每列扫描的采样点数,降低拖拽帧耗时。 * @param [OUT] points 输出点序列。 * @param [OUT] breaks 输出每段折线的起始下标(用于处理缺失值断线)。 */ - void BuildPolyline(const ResolvedSignal& resolved, const LaneVisual& lane, + void BuildPolyline(const ResolvedSignal& resolved, const LaneVisual& lane, bool coarse, std::vector& points, std::vector& breaks) const; /** @@ -776,6 +798,7 @@ namespace jdi float laneHeight = 0.0f; /**< 网格高度。 */ double laneYMin = 0.0; /**< 网格幅值下界。 */ double laneYMax = 0.0; /**< 网格幅值上界。 */ + bool coarse = false; /**< 是否为交互降级顶点(拖拽期间)。 */ std::vector points; /**< 折线顶点。 */ std::vector breaks; /**< 每段折线在 points 中的起始下标。 */ }; @@ -785,10 +808,11 @@ namespace jdi * @param [IN] ref 信号引用(缓存键的一部分)。 * @param [IN] resolved 已解析的信号。 * @param [IN] lane 网格绘制信息。 + * @param [IN] coarse 是否使用交互降级顶点(全质量与降级结果分别缓存)。 * @return 缓存条目指针,无法构建时返回 nullptr。 */ const PolylineCacheEntry* AcquirePolyline(const SignalRef& ref, const ResolvedSignal& resolved, - const LaneVisual& lane) const; + const LaneVisual& lane, bool coarse) const; mutable std::deque m_polylineCache; /**< 折线顶点缓存(按几何键复用)。 */ diff --git a/Components/PlotView/Src/PlotView.cpp b/Components/PlotView/Src/PlotView.cpp index b8e954a..6bc48e1 100644 --- a/Components/PlotView/Src/PlotView.cpp +++ b/Components/PlotView/Src/PlotView.cpp @@ -9,6 +9,7 @@ namespace jdi { + namespace { const int kTimeTickTarget = 10; /**< 时间轴期望刻度数。 */ @@ -831,6 +832,109 @@ namespace jdi return true; } + bool PlotView::AutoRangeLaneToView(int laneIndex) + { + if (m_tab == nullptr || m_dataSet == nullptr) + { + return false; + } + + const std::vector& lanes = m_tab->Lanes(); + if (laneIndex < 0 || laneIndex >= static_cast(lanes.size())) + { + return false; + } + + const double viewMin = Tab().ViewMin(); + const double viewMax = Tab().ViewMax(); + if (!(viewMax > viewMin)) + { + return false; + } + + // 只统计时间轴当前可见区间内的采样点,因此放大局部波形后可以快速把该段铺满网格。 + double low = std::numeric_limits::infinity(); + double high = -std::numeric_limits::infinity(); + + for (const SignalRef& ref : lanes[static_cast(laneIndex)].signals) + { + if (!Tab().IsVisible(ref)) + { + continue; + } + const SignalData* signal = m_dataSet->Resolve(ref); + const DataFile* file = m_dataSet->FileAt(ref.fileIndex); + if (signal == nullptr || file == nullptr) + { + continue; + } + + const std::size_t count = std::min(file->time.size(), signal->values.size()); + std::size_t begin = 0; + std::size_t end = count; + if (file->timeMonotonic) + { + // 时间单调时直接二分定位区间,百万级采样也只需扫过可见段。 + begin = static_cast( + std::lower_bound(file->time.begin(), file->time.end(), viewMin) - file->time.begin()); + if (begin > 0) + { + --begin; + } + end = static_cast( + std::lower_bound(file->time.begin(), file->time.end(), viewMax) - file->time.begin()); + if (end < count) + { + ++end; + } + } + + for (std::size_t index = begin; index < end; ++index) + { + if (!file->timeMonotonic) + { + const double t = file->time[index]; + if (!std::isfinite(t) || t < viewMin || t > viewMax) + { + continue; + } + } + const double value = signal->values[index]; + if (std::isfinite(value)) + { + low = std::min(low, value); + high = std::max(high, value); + } + } + } + + if (!std::isfinite(low) || !std::isfinite(high)) + { + return false; + } + + // 与自动自适应保持一致的上下留白,避免波形顶到网格边界。 + if (high > low) + { + const double pad = (high - low) * 0.05; + low -= pad; + high += pad; + } + else + { + const double pad = std::max(1.0e-9, std::fabs(low) * 0.05); + low -= pad; + high += pad; + } + + Lane& lane = m_tab->Lanes()[static_cast(laneIndex)]; + lane.autoRange = false; + lane.yMin = low; + lane.yMax = high; + BuildLaneVisuals(); + return true; + } + void PlotView::ScaleLaneRange(int laneIndex, double factor, double anchorValue) { if (m_tab == nullptr || laneIndex < 0 || laneIndex >= static_cast(m_lanes.size())) @@ -904,6 +1008,68 @@ namespace jdi FormatValue(current), anchorRect); } + bool PlotView::ApplyLaneValueText(int laneIndex, bool editMax, const std::wstring& text) + { + if (m_tab == nullptr || laneIndex < 0 || laneIndex >= static_cast(m_tab->Lanes().size())) + { + return false; + } + if (m_lanes.empty()) + { + BuildLaneVisuals(); + } + if (laneIndex >= static_cast(m_lanes.size())) + { + return false; + } + + const std::wstring trimmed = Trim(text); + if (trimmed.empty()) + { + return false; + } + wchar_t* stop = nullptr; + const double parsed = std::wcstod(trimmed.c_str(), &stop); + if (stop == trimmed.c_str() || !std::isfinite(parsed)) + { + return false; + } + + const LaneVisual& visual = m_lanes[static_cast(laneIndex)]; + const double span = visual.yMax - visual.yMin; + const double minimumGap = (span > 0.0) ? span * 0.01 : std::max(1.0e-9, std::fabs(parsed) * 0.01); + + Lane& lane = m_tab->Lanes()[static_cast(laneIndex)]; + // 手工输入即视为取消自动自适应,与拖拽框选缩放的语义一致。 + // 必须先把当前(自适应计算出的)显示范围固化到 lane 里,再只改动被编辑的一端, + // 否则另一端会回退到 Lane 的初始占位值,导致量程被莫名其妙地放大。 + if (lane.autoRange) + { + lane.autoRange = false; + lane.yMin = visual.yMin; + lane.yMax = visual.yMax; + } + if (editMax) + { + lane.yMax = parsed; + if (!(lane.yMax > lane.yMin)) + { + lane.yMin = lane.yMax - minimumGap; + } + } + else + { + lane.yMin = parsed; + if (!(lane.yMax > lane.yMin)) + { + lane.yMax = lane.yMin + minimumGap; + } + } + + BuildLaneVisuals(); + return true; + } + bool PlotView::LaneRange(int laneIndex, double& yMin, double& yMax) const { if (laneIndex < 0 || laneIndex >= static_cast(m_lanes.size())) @@ -961,7 +1127,7 @@ namespace jdi // 复用绘制阶段的折线顶点:悬停检测通常与上一帧几何完全相同, // 不再为“鼠标移动但命中结果未变”重复扫描全量采样点。 - const PolylineCacheEntry* cached = AcquirePolyline(ref, resolved, lane); + const PolylineCacheEntry* cached = AcquirePolyline(ref, resolved, lane, false); if (cached == nullptr) { continue; @@ -1831,7 +1997,7 @@ namespace jdi const PlotView::PolylineCacheEntry* PlotView::AcquirePolyline(const SignalRef& ref, const ResolvedSignal& resolved, - const LaneVisual& lane) const + const LaneVisual& lane, bool coarse) const { if (resolved.data == nullptr || resolved.file == nullptr) { @@ -1851,7 +2017,7 @@ namespace jdi entry.viewMin == viewMin && entry.viewMax == viewMax && entry.plotX == m_plotRect.X && entry.plotWidth == m_plotRect.Width && entry.laneY == lane.rect.Y && entry.laneHeight == lane.rect.Height && entry.laneYMin == lane.yMin && - entry.laneYMax == lane.yMax) + entry.laneYMax == lane.yMax && entry.coarse == coarse) { return &entry; } @@ -1876,13 +2042,14 @@ namespace jdi entry.laneHeight = lane.rect.Height; entry.laneYMin = lane.yMin; entry.laneYMax = lane.yMax; + entry.coarse = coarse; - BuildPolyline(resolved, lane, entry.points, entry.breaks); + BuildPolyline(resolved, lane, coarse, entry.points, entry.breaks); // BuildPolyline 通过引用填充,push_back 过程中不会再次进入本函数,引用安全。 return &m_polylineCache.back(); } - void PlotView::BuildPolyline(const ResolvedSignal& resolved, const LaneVisual& lane, + void PlotView::BuildPolyline(const ResolvedSignal& resolved, const LaneVisual& lane, bool coarse, std::vector& points, std::vector& breaks) const { points.clear(); @@ -1936,7 +2103,9 @@ namespace jdi // 顶点预算由绘图区宽度决定(而不是固定值):折线顶点数直接决定 // GDI+ 抗锯齿折线的绘制耗时,百万级采样文件若按固定 20000 顶点绘制, // 会把大量顶点堆在同一个像素列上(画出来完全重合),纯粹浪费 CPU。 - std::size_t budget = static_cast(std::max(1.0f, m_plotRect.Width)) * kPolylinePointsPerPixel; + // 拖拽期间进一步降半:顶点数就是绘制耗时,交互时用“2 像素一列”的包络即可。 + const std::size_t pointsPerPixel = coarse ? 1 : kPolylinePointsPerPixel; + std::size_t budget = static_cast(std::max(1.0f, m_plotRect.Width)) * pointsPerPixel; budget = std::max(budget, kMinPolylinePoints); budget = std::min(budget, kMaxPolylinePoints); @@ -1951,22 +2120,33 @@ namespace jdi const std::size_t columns = std::max(1, budget / 2); const float columnWidth = plotWidth / static_cast(columns); - const std::vector::const_iterator rangeBegin = time.begin() + static_cast(begin); const std::vector::const_iterator rangeEnd = time.begin() + static_cast(end); + // 交互降级:拖拽期间限定每列扫描的采样点数。 + // 全量扫描是 O(可见采样数),千万级采样 × 多条信号会让拖拽掉到 10FPS 以下; + // 每列取 32 个点已经足够看出包络形状,松手后会重新做一次全质量绘制。 + const std::size_t coarsePointsPerColumn = 32; + bool inSegment = false; + std::size_t searchFrom = begin; for (std::size_t column = 0; column < columns; ++column) { const double t0 = viewMin + viewSpan * (static_cast(column) / static_cast(columns)); const double t1 = viewMin + viewSpan * (static_cast(column + 1) / static_cast(columns)); + // 列边界随 column 单调递增,从上一列的位置继续二分, + // 把每次搜索的范围压到剩余区间,避免 2400 次全区间二分带来的缓存缺失。 const std::size_t i0 = static_cast( - std::lower_bound(rangeBegin, rangeEnd, t0) - time.begin()); + std::lower_bound(time.begin() + static_cast(searchFrom), rangeEnd, t0) - + time.begin()); + searchFrom = i0; const std::size_t i1 = (column + 1 == columns) ? end : static_cast( - std::lower_bound(rangeBegin, rangeEnd, t1) - time.begin()); + std::lower_bound(time.begin() + static_cast(i0), + rangeEnd, t1) - + time.begin()); if (i1 <= i0) { // 该列没有采样点:说明数据在此处存在时间间隙,断开当前折线。 @@ -1974,18 +2154,31 @@ namespace jdi continue; } + std::size_t scanStep = 1; + if (coarse) + { + const std::size_t columnPoints = i1 - i0; + if (columnPoints > coarsePointsPerColumn) + { + scanStep = columnPoints / coarsePointsPerColumn; + } + } + double low = std::numeric_limits::infinity(); double high = -std::numeric_limits::infinity(); std::size_t lowIndex = 0; std::size_t highIndex = 0; bool broken = false; - for (std::size_t index = i0; index < i1; ++index) + for (std::size_t index = i0; index < i1; index += scanStep) { const double value = values[index]; if (!std::isfinite(value)) { // 该列含无效值:包络绘制到本列结束,后续另起一段。 - broken = true; + if (!coarse) + { + broken = true; + } continue; } if (value < low) @@ -2312,6 +2505,15 @@ namespace jdi const int waveSave = g.Save(); g.SetClip(lane.rect, gd::CombineModeIntersect); + // 交互(拖拽平移/框选/拖动信号)期间关闭抗锯齿:波形折线是整帧中最贵的绘制调用, + // 关闭抗锯齿可显著降低单帧耗时,使拖拽跟手;松开后立即恢复抗锯齿全质量重绘。 + const bool interactive = (m_dragMode != PlotDragMode::NONE); + const gd::SmoothingMode previousSmoothing = g.GetSmoothingMode(); + if (interactive) + { + g.SetSmoothingMode(gd::SmoothingModeNone); + } + for (const SignalRef& ref : laneSignals) { const ResolvedSignal resolved = ResolveSignal(ref); @@ -2322,7 +2524,9 @@ namespace jdi const DrawMode mode = resolved.style->mode; // 顶点由“几何键”缓存提供:同一视图下重复绘制不再重算全量采样。 - const PolylineCacheEntry* cached = AcquirePolyline(ref, resolved, lane); + // 拖拽期间用降级顶点(扫描点数受限),松手后自然回到全质量版本。 + const bool coarse = interactive; + const PolylineCacheEntry* cached = AcquirePolyline(ref, resolved, lane, coarse); if (cached == nullptr) { continue; @@ -2392,6 +2596,12 @@ namespace jdi } } + // 交互期间临时关闭的抗锯齿在此恢复(g.Restore 也会还原状态,这里显式恢复以免依赖栈深)。 + if (interactive) + { + g.SetSmoothingMode(previousSmoothing); + } + g.Restore(waveSave); } @@ -2937,7 +3147,7 @@ namespace jdi std::vector points; std::vector breaks; - BuildPolyline(resolved, sourceVisual, points, breaks); + BuildPolyline(resolved, sourceVisual, false, points, breaks); for (gd::PointF& point : points) { diff --git a/Utilities/Inc/CsvTable.hpp b/Utilities/Inc/CsvTable.hpp index b8bee47..ae17028 100644 --- a/Utilities/Inc/CsvTable.hpp +++ b/Utilities/Inc/CsvTable.hpp @@ -2,6 +2,7 @@ #define __CSV_TABLE_HPP__ #include +#include #include #include @@ -9,15 +10,22 @@ namespace jdi { /** - * @brief CSV 文件的解析结果,保存列名与数值矩阵。 + * @brief CSV 文件的解析结果,保存列名与**列优先**的数值矩阵。 + * + * 采用列优先(每条信号一整块连续内存)而不是行优先的二维数组,原因有二: + * 1. 大型文件(百万行级)若按行存储会产生上百万次独立堆分配, + * 每行还要额外承担 vector 的控制块与容量对齐开销,实测内存放大接近 1.5 倍; + * 2. 后续构建信号时可以直接把整列**移动**进 SignalData,不再逐元素拷贝。 */ struct CsvTable { - std::vector headers; /**< 列名(已去除引号与首尾空白)。 */ - std::vector> rows; /**< 数值矩阵,行优先,每行长度与 headers 一致。 */ - wchar_t delimiter = L','; /**< 实际使用的分隔符。 */ - std::size_t sourceRowCount = 0; /**< 文件中有效数据行数(不含空行与注释行)。 */ - std::size_t skippedRowCount = 0; /**< 因格式异常被置为缺失值的行数。 */ + std::vector headers; /**< 列名(已去除引号与首尾空白)。 */ + std::vector> columns; /**< 列优先数值矩阵:columns[列][行],缺失处为 NaN。 */ + std::size_t rowCount = 0; /**< 有效数据行数。 */ + wchar_t delimiter = L','; /**< 实际使用的分隔符。 */ + std::size_t sourceRowCount = 0; /**< 文件中有效数据行数(不含空行与注释行)。 */ + std::size_t skippedRowCount = 0; /**< 因格式异常被置为缺失值的行数。 */ + unsigned long long fileBytes = 0; /**< 源文件字节数(用于按比例预留容量与进度显示)。 */ /** * @brief 判断解析结果是否包含可用的表格数据。 @@ -25,18 +33,32 @@ namespace jdi */ bool HasData() const { - return !headers.empty() && !rows.empty(); + return !headers.empty() && (rowCount > 0); } }; + /** + * @brief 解析进度回调。 + * + * 在解析过程中按块回调,ratio 为 0.0-1.0 的完成比例; + * 返回 false 表示请求取消,解析会尽快停止并返回失败。 + */ + using CsvProgress = std::function; + /** * @brief 读取并解析一个 CSV 文件。 + * + * 文件以分块流式方式读取(不把整个文件一次性读入内存), + * 因此内存占用只与解析结果相关,不会因文件体积成倍放大,也没有体积上限。 + * * @param [IN] filePath CSV 文件完整路径(宽字符,支持中文路径)。 * @param [OUT] table 解析结果输出。 * @param [OUT] error 失败原因描述(成功时清空)。 + * @param [IN] progress 解析进度回调,可为空。 * @return 解析成功返回 true,否则返回 false。 */ - bool LoadCsvTable(const std::wstring& filePath, CsvTable& table, std::wstring& error); + bool LoadCsvTable(const std::wstring& filePath, CsvTable& table, std::wstring& error, + const CsvProgress& progress = CsvProgress()); } // namespace jdi diff --git a/Utilities/Inc/DataModel.hpp b/Utilities/Inc/DataModel.hpp index d349394..c10bdf5 100644 --- a/Utilities/Inc/DataModel.hpp +++ b/Utilities/Inc/DataModel.hpp @@ -140,6 +140,26 @@ namespace jdi const std::wstring& Title() const { return displayName.empty() ? fileName : displayName; } }; + /** + * @brief 由 CSV 解析结果构建可入库的数据文件。 + * + * 该函数**不访问 DataSet**(只依赖传入的 CsvTable 与调色板基准序号), + * 因此可以在工作线程上执行:大文件的解析与列重组占整个加载耗时的大头, + * 放到工作线程后主线程只需做一次 O(1) 的移动入队,加载过程中的界面不会卡住。 + * + * table 按右值传入:每列会被**移动**进对应信号的 values(零拷贝)。 + * + * @param [IN,OUT] table CSV 解析结果(列会被移走)。 + * @param [IN] filePath 源文件路径。 + * @param [IN] paletteBase 调色板起始序号(通常传入当前已有信号数)。 + * @param [OUT] file 构建结果。 + * @param [OUT] error 失败原因描述(成功时清空)。 + * @param [IN] progress 进度回调(0-1),返回 false 表示取消,可为空。 + * @return 构建成功返回 true。 + */ + bool BuildDataFile(CsvTable&& table, const std::wstring& filePath, std::size_t paletteBase, DataFile& file, + std::wstring& error, const CsvProgress& progress = CsvProgress()); + /** * @brief 数据集合:统一管理全部已加载文件,与视图配置无关。 */ @@ -161,6 +181,14 @@ namespace jdi */ bool AddFromTable(const CsvTable& table, const std::wstring& filePath, std::wstring& error, int& fileIndex); + /** + * @brief 追加一个已构建好的数据文件(仅做一次移动,不拷贝采样数据)。 + * @param [IN,OUT] file 数据文件(内部缓冲会被移走)。 + * @param [OUT] fileIndex 新文件下标。 + * @return 追加成功返回 true。 + */ + bool AddPreparedFile(DataFile&& file, int& fileIndex); + /** * @brief 移除指定下标的文件,并立即释放其占用的全部采样内存。 * @param [IN] fileIndex 文件下标。 diff --git a/Utilities/Inc/PopupMenu.hpp b/Utilities/Inc/PopupMenu.hpp index 1221432..df34a65 100644 --- a/Utilities/Inc/PopupMenu.hpp +++ b/Utilities/Inc/PopupMenu.hpp @@ -10,6 +10,7 @@ #include #include "PaintUtil.hpp" +#include "TextEdit.hpp" #include "Theme.hpp" namespace jdi @@ -34,7 +35,8 @@ namespace jdi NORMAL = 0, /**< 普通可点击项。 */ SEPARATOR = 1, /**< 分隔线。 */ HEADER = 2, /**< 分组标题(不可点击)。 */ - SWATCHES = 3 /**< 颜色色块行。 */ + SWATCHES = 3, /**< 颜色色块行。 */ + EDIT = 4 /**< 带内联输入框的项:在菜单内部直接编辑数值(菜单不关闭)。 */ }; /** @@ -43,13 +45,15 @@ namespace jdi struct MenuItem { MenuItemKind kind = MenuItemKind::NORMAL; /**< 项目类型。 */ - std::wstring label; /**< 显示文本。 */ + std::wstring label; /**< 显示文本;EDIT 项为输入框左侧的说明文字。 */ int id = 0; /**< 命令标识。 */ bool enabled = true; /**< 是否可用。 */ bool checked = false; /**< 是否处于选中(单选)状态。 */ int swatchCount = 0; /**< 色块数量(仅色块行有效)。 */ std::uint32_t swatches[16] = {}; /**< 色块颜色。 */ int checkedSwatch = -1; /**< 被选中的色块下标。 */ + std::wstring value; /**< EDIT 项的当前数值文本(非编辑态显示)。 */ + EditFilter filter = EditFilter::ANY; /**< EDIT 项的输入过滤规则。 */ }; /** @@ -62,6 +66,8 @@ namespace jdi int id = 0; /**< 被选中项的命令标识。 */ int swatchIndex = -1; /**< 被选中的色块下标(非色块行为 -1)。 */ gd::RectF itemRect; /**< 被点击条目的矩形(客户区坐标),供就近弹出输入框。 */ + bool editCommit = false; /**< 该结果来自菜单内联输入框的提交(菜单保持打开)。 */ + std::wstring editText; /**< 提交的输入文本(editCommit 为 true 时有效)。 */ }; /** @@ -158,11 +164,84 @@ namespace jdi /** * @brief 判断坐标是否落在菜单内部。 * @param [IN] x 客户区横坐标。 - * @param [IN] y 客户区纵坐标。 + * @param [IN] y 客户区纵坐标。 * @return 落在菜单内返回 true。 */ bool ContainsPoint(int x, int y) const; + /** + * @brief 设置剪贴板所有者窗口句柄(供内联输入框复制/粘贴)。 + * @param [IN] host 宿主窗口句柄,允许为空。 + */ + void SetHostWindow(HWND host) { m_host = host; } + + /** + * @brief 判断是否有菜单项正处于内联编辑状态。 + * @return 处于编辑状态返回 true。 + */ + bool IsEditing() const { return m_editIndex >= 0; } + + /** + * @brief 获取当前正在编辑的菜单项命令标识。 + * @return 命令标识,未编辑时返回 0。 + */ + int EditingItemId() const; + + /** + * @brief 判断坐标是否落在当前正在编辑的输入框内。 + * @param [IN] x 客户区横坐标。 + * @param [IN] y 客户区纵坐标。 + * @return 落在输入框内返回 true。 + */ + bool IsOnEditField(int x, int y) const; + + /** + * @brief 编辑态下按下左键:定位插入符并开始拖拽框选。 + * @param [IN] x 客户区横坐标。 + * @param [IN] y 客户区纵坐标。 + * @return 命中输入框返回 true。 + */ + bool OnEditMouseDown(int x, int y); + + /** + * @brief 编辑态下拖拽鼠标:扩展选区。 + * @param [IN] x 客户区横坐标。 + * @return 选区发生变化返回 true。 + */ + bool OnEditMouseMove(int x); + + /** + * @brief 编辑态下松开左键:结束拖拽框选。 + * @return 之前处于拖拽中返回 true。 + */ + bool OnEditMouseUp(); + + /** + * @brief 编辑态下处理字符输入。 + * @param [IN] character 字符。 + * @param [OUT] result 提交时返回 editCommit 结果。 + * @return 需要重绘返回 true。 + */ + bool OnChar(wchar_t character, MenuResult& result); + + /** + * @brief 更新指定命令项显示的值文本(菜单保持打开,立即重新排布)。 + * @param [IN] id 命令标识。 + * @param [IN] value 新的值文本。 + * @return 找到对应项并更新返回 true。 + */ + bool UpdateItemValue(int id, const std::wstring& value); + + /** + * @brief 更新指定普通项的文字与可用/选中状态(用于实时反映状态)。 + * @param [IN] id 命令标识。 + * @param [IN] label 新的文字。 + * @param [IN] checked 新的选中状态。 + * @param [IN] enabled 新的可用状态。 + * @return 找到对应项并更新返回 true。 + */ + bool UpdateItemLabel(int id, const std::wstring& label, bool checked = false, bool enabled = true); + private: /** * @brief 计算单个菜单项的纵向高度。 @@ -201,6 +280,38 @@ namespace jdi */ bool CommitHover(MenuResult& result); + /** + * @brief 计算指定下标项的内联输入框矩形(EDIT 项)。 + * @param [IN] index 项目下标。 + * @return 输入框矩形,非法下标返回空矩形。 + */ + gd::RectF EditFieldRect(int index) const; + + /** + * @brief 进入指定项的内联编辑状态。 + * @param [IN] index 项目下标。 + * @return 成功进入返回 true。 + */ + bool BeginItemEdit(int index); + + /** + * @brief 结束内联编辑。 + * @param [IN] commit 是否提交(false 为放弃修改)。 + * @param [OUT] result 提交时输出 editCommit 结果。 + * @return 之前处于编辑状态返回 true。 + */ + bool EndItemEdit(bool commit, MenuResult& result); + + /** + * @brief 依据当前项集合重新排布(用于值文本变化后)。 + */ + void RebuildLayout(); + + /** + * @brief 退出内联编辑状态(不产生任何结果)。 + */ + void CloseEditState(); + const UiFonts* m_fonts = nullptr; /**< 字体资源。 */ const Theme* m_theme = nullptr; /**< 主题资源。 */ float m_scale = 1.0f; /**< DPI 缩放比例。 */ @@ -217,6 +328,14 @@ namespace jdi int m_hoverSwatch = -1; /**< 当前悬停色块。 */ int m_pressedIndex = -1; /**< 按下时的项目下标。 */ int m_pressedSwatch = -1; /**< 按下时的色块下标。 */ + + HWND m_host = nullptr; /**< 剪贴板所有者窗口句柄。 */ + TextEdit m_edit; /**< 内联输入框编辑模型。 */ + int m_editIndex = -1; /**< 正在编辑的项目下标,-1 表示未编辑。 */ + + gd::RectF m_buildBounds; /**< 最近一次排布使用的宿主矩形。 */ + int m_buildX = 0; /**< 最近一次排布使用的锚点横坐标。 */ + int m_buildY = 0; /**< 最近一次排布使用的锚点纵坐标。 */ }; } // namespace jdi diff --git a/Utilities/Src/CsvTable.cpp b/Utilities/Src/CsvTable.cpp index 9d52ad4..4df8e31 100644 --- a/Utilities/Src/CsvTable.cpp +++ b/Utilities/Src/CsvTable.cpp @@ -22,56 +22,76 @@ namespace jdi }; const double kMissingValue = std::numeric_limits::quiet_NaN(); /**< 缺失值占位。 */ + const std::size_t kReadChunkBytes = 4u * 1024u * 1024u; /**< 流式读取块大小。 */ /** - * @brief 一次性将整个文件读入内存。 - * @param [IN] filePath 文件路径。 - * @param [OUT] buffer 文件内容输出。 - * @param [OUT] error 失败原因描述。 - * @return 读取成功返回 true。 + * @brief 按比例预留的初始行容量。 + * + * 先按文件体积估算一个行数,既能避免边解析边扩容(百万行时复制代价可观), + * 又不会因为估得过大而浪费内存(超估时 vector 只是容量偏大,数据本身不变)。 */ - bool ReadWholeFile(const std::wstring& filePath, std::string& buffer, std::wstring& error) + std::size_t EstimateRowCapacity(unsigned long long fileBytes) { - FILE* file = _wfopen(filePath.c_str(), L"rb"); + // 典型 CSV 每行约 40-200 字节;取下界 64 字节估算行数,宁可小估也不要多占。 + const unsigned long long estimate = fileBytes / 64ull; + if (estimate > 4096ull * 1024ull) + { + return static_cast(4096ull * 1024ull); + } + return static_cast(estimate); + } + + /** + * @brief 打开文件并查询其体积。 + * @param [IN] filePath 文件路径。 + * @param [OUT] file 文件句柄输出(需调用方关闭)。 + * @param [OUT] fileSize 文件字节数输出。 + * @param [OUT] error 失败原因描述。 + * @return 打开成功返回 true。 + */ + bool OpenFile(const std::wstring& filePath, FILE*& file, unsigned long long& fileSize, + std::wstring& error) + { + file = _wfopen(filePath.c_str(), L"rb"); if (file == nullptr) { error = L"无法打开文件,请确认路径与访问权限。"; return false; } - if (std::fseek(file, 0, SEEK_END) != 0) + // 使用 64 位定位接口,避免 long 在部分平台下只有 32 位而无法处理大文件。 + if (_fseeki64(file, 0, SEEK_END) != 0) { std::fclose(file); + file = nullptr; error = L"无法定位文件末尾。"; return false; } - const long fileSize = std::ftell(file); - if (fileSize < 0) + const long long size = _ftelli64(file); + if (size < 0) { std::fclose(file); + file = nullptr; error = L"无法获取文件长度。"; return false; } - if (fileSize == 0) + if (size == 0) { std::fclose(file); + file = nullptr; error = L"文件内容为空。"; return false; } - if (fileSize > (512L * 1024L * 1024L)) + if (_fseeki64(file, 0, SEEK_SET) != 0) { std::fclose(file); - error = L"文件体积超过 512 MB,暂不支持。"; + file = nullptr; + error = L"无法回到文件开头。"; return false; } - std::fseek(file, 0, SEEK_SET); - buffer.resize(static_cast(fileSize)); - const size_t readCount = std::fread(&buffer[0], 1, buffer.size(), file); - std::fclose(file); - - buffer.resize(readCount); + fileSize = static_cast(size); return true; } @@ -197,82 +217,54 @@ namespace jdi } /** - * @brief 在一行内查找首个非空行的终止位置。 - * @param [IN] begin 文本起始指针。 - * @param [IN] end 文本结束指针(不包含)。 - * @return 该行结束指针(不含换行符)。 + * @brief 解析状态:跨读取块保存进度。 */ - const char* FindLineEnd(const char* begin, const char* end) + struct ParseState { - const char* p = begin; - while (p < end && *p != '\n') - { - ++p; - } - const char* lineEnd = p; - while (lineEnd > begin && (lineEnd[-1] == '\r' || lineEnd[-1] == '\n')) + std::string pending; /**< 尚未处理完的尾部字节(不完整行)。 */ + std::vector fields; /**< 字段区间复用缓冲。 */ + std::vector rowValues; /**< 行数值复用缓冲(列优先存储时的中转)。 */ + bool headerReady = false; /**< 是否已解析表头。 */ + unsigned long long bytesConsumed = 0; /**< 已处理字节数(用于进度)。 */ + }; + + /** + * @brief 处理一块完整行文本(不含换行符)。 + * @param [IN,OUT] table 解析结果。 + * @param [IN,OUT] state 解析状态。 + * @param [IN] begin 行起始指针。 + * @param [IN] end 行结束指针(不包含)。 + * @return 解析可以继续返回 true;表头非法需要中止时返回 false。 + */ + bool HandleLine(CsvTable& table, ParseState& state, const char* begin, const char* end) + { + const char* lineEnd = end; + while (lineEnd > begin && lineEnd[-1] == '\r') { --lineEnd; } - return lineEnd; - } - } - - bool LoadCsvTable(const std::wstring& filePath, CsvTable& table, std::wstring& error) - { - table = CsvTable(); - error.clear(); - - std::string buffer; - if (!ReadWholeFile(filePath, buffer, error)) - { - return false; - } - - const char* data = buffer.data(); - size_t length = buffer.size(); - - // 跳过 UTF-8 BOM,避免首列名被污染。 - if (length >= 3 && static_cast(data[0]) == 0xEF && - static_cast(data[1]) == 0xBB && static_cast(data[2]) == 0xBF) - { - data += 3; - length -= 3; - } - - const char* cursor = data; - const char* limit = data + length; - - std::vector fields; - bool headerReady = false; - - while (cursor < limit) - { - const char* rawEnd = FindLineEnd(cursor, limit); - const char* lineEnd = rawEnd; // 跳过纯空白行,但不产生数据行。 - const char* scan = cursor; - while (scan < lineEnd && (*scan == ' ' || *scan == '\t' || *scan == '\r')) + const char* scan = begin; + while (scan < lineEnd && (*scan == ' ' || *scan == '\t')) { ++scan; } const bool lineIsEmpty = (scan >= lineEnd); - if (!headerReady && lineIsEmpty) + if (!state.headerReady) { - cursor = (rawEnd < limit) ? (rawEnd + 1) : limit; - continue; - } + if (lineIsEmpty) + { + return true; + } - if (!headerReady) - { - table.delimiter = DetectDelimiter(cursor, lineEnd); - SplitFields(cursor, lineEnd, static_cast(table.delimiter), fields); + table.delimiter = DetectDelimiter(begin, lineEnd); + SplitFields(begin, lineEnd, static_cast(table.delimiter), state.fields); table.headers.clear(); - table.headers.reserve(fields.size()); - for (const FieldSpan& span : fields) + table.headers.reserve(state.fields.size()); + for (const FieldSpan& span : state.fields) { std::wstring text = FieldToText(span); if (text.empty()) @@ -284,60 +276,164 @@ namespace jdi if (table.headers.empty()) { - error = L"表头为空,无法解析列名。"; return false; } - headerReady = true; - cursor = (rawEnd < limit) ? (rawEnd + 1) : limit; - continue; + // 列优先存储:一次性为每列预留容量,避免逐行扩容(百万行时复制代价可观)。 + const std::size_t capacity = EstimateRowCapacity(table.fileBytes); + table.columns.assign(table.headers.size(), std::vector()); + for (std::vector& column : table.columns) + { + if (capacity > 0) + { + column.reserve(capacity); + } + } + state.rowValues.assign(table.headers.size(), kMissingValue); + + state.headerReady = true; + return true; } - SplitFields(cursor, lineEnd, static_cast(table.delimiter), fields); + SplitFields(begin, lineEnd, static_cast(table.delimiter), state.fields); // 空行与注释行(以 # 或 % 开头)不参与数据统计。 - if (ShouldSkipLine(fields.empty() ? FieldSpan() : fields.front(), lineIsEmpty)) + if (ShouldSkipLine(state.fields.empty() ? FieldSpan() : state.fields.front(), lineIsEmpty)) { - cursor = (rawEnd < limit) ? (rawEnd + 1) : limit; - continue; + return true; } - if (fields.size() != table.headers.size()) + if (state.fields.size() != table.headers.size()) { ++table.skippedRowCount; } - std::vector row; - row.reserve(table.headers.size()); bool rowHasValue = false; - - for (size_t column = 0; column < table.headers.size(); ++column) + for (std::size_t column = 0; column < state.rowValues.size(); ++column) { - if (column >= fields.size()) - { - row.push_back(kMissingValue); - continue; - } double value = kMissingValue; - if (TryParseDouble(fields[column].begin, fields[column].end, value)) + if (column < state.fields.size() && + TryParseDouble(state.fields[column].begin, state.fields[column].end, value)) { rowHasValue = true; } - row.push_back(value); + state.rowValues[column] = value; } + // 整行都没有有效数值时该行直接丢弃(与旧行为一致): + // 列优先存储要求每列等长,因此必须整行一起跳过,不能只补某一列。 if (!rowHasValue) { - cursor = (rawEnd < limit) ? (rawEnd + 1) : limit; - continue; + return true; } - table.rows.push_back(std::move(row)); + for (std::size_t column = 0; column < state.rowValues.size(); ++column) + { + table.columns[column].push_back(state.rowValues[column]); + } + ++table.rowCount; ++table.sourceRowCount; + return true; + } + } - cursor = (rawEnd < limit) ? (rawEnd + 1) : limit; + bool LoadCsvTable(const std::wstring& filePath, CsvTable& table, std::wstring& error, + const CsvProgress& progress) + { + table = CsvTable(); + error.clear(); + + FILE* file = nullptr; + unsigned long long fileSize = 0; + if (!OpenFile(filePath, file, fileSize, error)) + { + return false; + } + table.fileBytes = fileSize; + + ParseState state; + std::string staging; + staging.resize(kReadChunkBytes); + + bool cancelled = false; + bool fatal = false; + + while (!cancelled && !fatal) + { + const size_t readCount = std::fread(&staging[0], 1, staging.size(), file); + if (readCount == 0) + { + break; + } + state.bytesConsumed += readCount; + + state.pending.append(staging.data(), readCount); + + // 首个块处理前剥离 UTF-8 BOM,避免首列名被污染(BOM 必然落在第一块内)。 + if (!state.headerReady && state.pending.size() >= 3 && + static_cast(state.pending[0]) == 0xEF && + static_cast(state.pending[1]) == 0xBB && + static_cast(state.pending[2]) == 0xBF) + { + state.pending.erase(0, 3); + } + + // 处理 pending 中所有以换行结束的完整行。 + std::size_t lineStart = 0; + while (true) + { + const std::size_t newline = state.pending.find('\n', lineStart); + if (newline == std::string::npos) + { + break; + } + if (!HandleLine(table, state, state.pending.data() + lineStart, + state.pending.data() + newline)) + { + fatal = true; + break; + } + lineStart = newline + 1; + } + if (fatal) + { + break; + } + state.pending.erase(0, lineStart); + + if (progress) + { + const double ratio = (fileSize > 0) + ? static_cast(state.bytesConsumed) / + static_cast(fileSize) + : 0.0; + if (!progress(ratio > 1.0 ? 1.0 : ratio)) + { + cancelled = true; + } + } } + // 处理文件末尾没有换行符的最后一行。 + if (!cancelled && !fatal && !state.pending.empty()) + { + if (!HandleLine(table, state, state.pending.data(), state.pending.data() + state.pending.size())) + { + fatal = true; + } + } + std::fclose(file); + + if (cancelled) + { + error = L"已取消加载。"; + return false; + } + if (table.headers.empty()) + { + error = fatal ? L"表头为空,无法解析列名。" : L"未从文件中解析出有效数据行。"; + return false; + } if (!table.HasData()) { error = L"未从文件中解析出有效数据行。"; diff --git a/Utilities/Src/DataModel.cpp b/Utilities/Src/DataModel.cpp index 937fa4a..8afa18e 100644 --- a/Utilities/Src/DataModel.cpp +++ b/Utilities/Src/DataModel.cpp @@ -244,18 +244,55 @@ namespace jdi } } - // ============================ DataSet ============================ + // ============================ DataFile 构建 ============================ - void DataSet::Clear() + /** + * @brief 单条信号的取值范围统计结果。 + */ + struct SignalStats { - m_files.clear(); + double minValue = 0.0; /**< 有限采样值最小值。 */ + double maxValue = 0.0; /**< 有限采样值最大值。 */ + std::size_t finiteCount = 0; /**< 有限采样点数量。 */ + }; + + /** + * @brief 统计一列采样的最小/最大值与有效点数量。 + * @param [IN] values 采样列。 + * @return 统计结果。 + */ + SignalStats ComputeStats(const std::vector& values) + { + SignalStats stats; + double low = std::numeric_limits::infinity(); + double high = -std::numeric_limits::infinity(); + for (const double value : values) + { + if (std::isfinite(value)) + { + ++stats.finiteCount; + low = std::min(low, value); + high = std::max(high, value); + } + } + if (stats.finiteCount == 0) + { + stats.minValue = 0.0; + stats.maxValue = 0.0; + } + else + { + stats.minValue = low; + stats.maxValue = high; + } + return stats; } - bool DataSet::AddFromTable(const CsvTable& table, const std::wstring& filePath, std::wstring& error, - int& fileIndex) + bool BuildDataFile(CsvTable&& table, const std::wstring& filePath, std::size_t paletteBase, DataFile& file, + std::wstring& error, const CsvProgress& progress) { error.clear(); - fileIndex = -1; + file = DataFile(); if (!table.HasData()) { @@ -264,9 +301,12 @@ namespace jdi } const std::size_t columnCount = table.headers.size(); - const std::size_t rowCount = table.rows.size(); - - DataFile file; + const std::size_t rowCount = table.rowCount; + if (table.columns.size() < columnCount) + { + error = L"数据表列结构不完整。"; + return false; + } // 1. 识别时间轴列:优先取列名匹配的列,否则回退到第一列。 int timeColumn = -1; @@ -283,16 +323,15 @@ namespace jdi timeColumn = 0; } - file.time.resize(rowCount); + // 时间列整列移动,不做逐元素拷贝。 + file.time = std::move(table.columns[static_cast(timeColumn)]); + std::size_t finiteTimeCount = 0; bool monotonic = true; double previousTime = 0.0; bool hasPrevious = false; - - for (std::size_t row = 0; row < rowCount; ++row) + for (const double value : file.time) { - const double value = table.rows[row][static_cast(timeColumn)]; - file.time[row] = value; if (std::isfinite(value)) { ++finiteTimeCount; @@ -318,7 +357,9 @@ namespace jdi const std::size_t slashPos = filePath.find_last_of(L"\\/"); file.fileName = (slashPos == std::wstring::npos) ? filePath : filePath.substr(slashPos + 1); - // 2. 构建信号列。 + // 2. 构建信号列:每列整块移动,随后就地统计取值范围。 + int paletteCount = 0; + const std::uint32_t* palette = SignalPalette(paletteCount); file.signals.reserve(columnCount); for (std::size_t column = 0; column < columnCount; ++column) { @@ -335,41 +376,16 @@ namespace jdi signal.unit = ExtractUnit(signal.name); } - signal.values.resize(rowCount); - double minValue = std::numeric_limits::infinity(); - double maxValue = -std::numeric_limits::infinity(); - std::size_t finiteCount = 0; - - for (std::size_t row = 0; row < rowCount; ++row) - { - const double value = table.rows[row][column]; - signal.values[row] = value; - if (std::isfinite(value)) - { - ++finiteCount; - minValue = std::min(minValue, value); - maxValue = std::max(maxValue, value); - } - } - - if (finiteCount == 0) - { - signal.minValue = 0.0; - signal.maxValue = 0.0; - } - else - { - signal.minValue = minValue; - signal.maxValue = maxValue; - } - signal.finiteCount = finiteCount; + signal.values = std::move(table.columns[column]); + const SignalStats stats = ComputeStats(signal.values); + signal.minValue = stats.minValue; + signal.maxValue = stats.maxValue; + signal.finiteCount = stats.finiteCount; // 显示外观随信号一起初始化:调色板按「全局信号序号」取色, // 使同一信号在任意标签页中都保持同一默认颜色。 { - int paletteCount = 0; - const std::uint32_t* palette = SignalPalette(paletteCount); - const std::size_t globalIndex = SignalCount() + file.signals.size(); + const std::size_t globalIndex = paletteBase + file.signals.size(); signal.style.color = (palette != nullptr && paletteCount > 0) ? palette[globalIndex % static_cast(paletteCount)] @@ -381,6 +397,13 @@ namespace jdi } file.signals.push_back(std::move(signal)); + + if (progress && !progress(0.75 + 0.25 * static_cast(column + 1) / + static_cast(columnCount))) + { + error = L"已取消加载。"; + return false; + } } if (file.signals.empty()) @@ -389,7 +412,7 @@ namespace jdi return false; } - // 3. 统计时间范围。 + // 3. 统计时间范围(时间列为空或全部无效时回退到行数上界)。 double tMin = std::numeric_limits::infinity(); double tMax = -std::numeric_limits::infinity(); for (const double value : file.time) @@ -402,12 +425,44 @@ namespace jdi } file.minTime = tMin; file.maxTime = tMax; + (void)rowCount; + return true; + } + // ============================ DataSet ============================ + + void DataSet::Clear() + { + m_files.clear(); + } + + bool DataSet::AddPreparedFile(DataFile&& file, int& fileIndex) + { + fileIndex = -1; + if (file.signals.empty()) + { + return false; + } m_files.push_back(std::move(file)); fileIndex = static_cast(m_files.size()) - 1; return true; } + bool DataSet::AddFromTable(const CsvTable& table, const std::wstring& filePath, std::wstring& error, + int& fileIndex) + { + fileIndex = -1; + + // 兼容入口:复制一份表格后走与后台加载完全一致的构建流程。 + CsvTable local = table; + DataFile file; + if (!BuildDataFile(std::move(local), filePath, SignalCount(), file, error)) + { + return false; + } + return AddPreparedFile(std::move(file), fileIndex); + } + bool DataSet::RemoveFile(int fileIndex) { if (fileIndex < 0 || fileIndex >= static_cast(m_files.size())) diff --git a/Utilities/Src/PopupMenu.cpp b/Utilities/Src/PopupMenu.cpp index 541deb9..208062b 100644 --- a/Utilities/Src/PopupMenu.cpp +++ b/Utilities/Src/PopupMenu.cpp @@ -21,6 +21,9 @@ namespace jdi const float kCorner = 6.0f; /**< 菜单圆角半径。 */ const float kSwatchSize = 22.0f; /**< 色块尺寸。 */ const float kSwatchGap = 5.0f; /**< 色块间距。 */ + const float kEditFieldWidth = 88.0f; /**< EDIT 项输入框宽度。 */ + const float kEditFieldGap = 6.0f; /**< 说明文字与输入框之间的最小间隔。 */ + const float kEditFieldPadV = 5.0f; /**< 输入框上下内缩。 */ } PopupMenu::PopupMenu() = default; @@ -46,6 +49,9 @@ namespace jdi return kHeaderHeight * m_scale; case MenuItemKind::SWATCHES: return kSwatchRowHeight * m_scale; + case MenuItemKind::EDIT: + // 编辑项比普通项略高,给输入框留出上下呼吸空间。 + return (kRowHeight + 4.0f) * m_scale; case MenuItemKind::NORMAL: default: return kRowHeight * m_scale; @@ -90,6 +96,17 @@ namespace jdi UiMeasureTextWidth(m_fonts->uiSmall, item.label) + kRightPad * m_scale; contentWidth = std::max(contentWidth, width); } + else if (item.kind == MenuItemKind::EDIT) + { + // 说明文字 + 输入框 + 右侧留白;输入框宽度按数值文本自适应(取一个上限)。 + const float fieldWidth = + std::max(kEditFieldWidth * m_scale, + UiMeasureTextWidth(m_fonts->uiSmall, item.value) + 20.0f * m_scale); + const float width = (kPadH + 8.0f) * m_scale + + UiMeasureTextWidth(m_fonts->uiSmall, item.label) + kEditFieldGap * m_scale + + fieldWidth + kRightPad * m_scale; + contentWidth = std::max(contentWidth, width); + } } contentWidth = std::min(contentWidth, kMaxWidth * m_scale); @@ -140,6 +157,211 @@ namespace jdi } } + gd::RectF PopupMenu::EditFieldRect(int index) const + { + if (index < 0 || index >= static_cast(m_items.size())) + { + return gd::RectF(); + } + if (m_items[static_cast(index)].kind != MenuItemKind::EDIT) + { + return gd::RectF(); + } + + // 输入框靠右对齐:宽度取固定下限与当前文本宽度中的较大者,但不得超过菜单可用宽度的一半。 + const gd::RectF& rect = m_itemRects[static_cast(index)]; + const float maxWidth = std::max(40.0f * m_scale, rect.Width * 0.5f); + float width = std::max(kEditFieldWidth * m_scale, + UiMeasureTextWidth(m_fonts->uiSmall, m_items[static_cast(index)].value) + + 20.0f * m_scale); + width = std::min(width, maxWidth); + + const float height = rect.Height - kEditFieldPadV * 2.0f * m_scale; + return gd::RectF(rect.X + rect.Width - width - 4.0f * m_scale, + rect.Y + (rect.Height - height) * 0.5f, width, height); + } + + int PopupMenu::EditingItemId() const + { + if (m_editIndex < 0 || m_editIndex >= static_cast(m_items.size())) + { + return 0; + } + return m_items[static_cast(m_editIndex)].id; + } + + bool PopupMenu::IsOnEditField(int x, int y) const + { + if (m_editIndex < 0) + { + return false; + } + const gd::RectF rect = EditFieldRect(m_editIndex); + return UiPointInRect(rect, static_cast(x), static_cast(y)); + } + + bool PopupMenu::BeginItemEdit(int index) + { + if (!m_open || index < 0 || index >= static_cast(m_items.size())) + { + return false; + } + const MenuItem& item = m_items[static_cast(index)]; + if (item.kind != MenuItemKind::EDIT || !item.enabled) + { + return false; + } + + // 输入框初值取当前显示值,整段选中,便于直接键入新数值。 + m_edit.Begin(item.value, true, item.filter, 0u); + m_editIndex = index; + m_hoverIndex = index; + return true; + } + + bool PopupMenu::EndItemEdit(bool commit, MenuResult& result) + { + if (m_editIndex < 0) + { + return false; + } + + const int index = m_editIndex; + const std::wstring text = m_edit.Text(); + m_editIndex = -1; + m_edit.End(); + + if (!commit || index >= static_cast(m_items.size())) + { + return true; + } + + MenuItem& item = m_items[static_cast(index)]; + // 立即回写显示值,使菜单上的数值与数据层一致(宿主会在派发后再纠正为夹取后的真实值)。 + item.value = text; + + result.valid = true; + result.scope = m_scope; + result.id = item.id; + result.editCommit = true; + result.editText = text; + return true; + } + + void PopupMenu::RebuildLayout() + { + if (!m_open) + { + return; + } + BuildLayout(m_buildBounds, m_buildX, m_buildY); + } + + bool PopupMenu::UpdateItemValue(int id, const std::wstring& value) + { + for (MenuItem& item : m_items) + { + if (item.id == id && item.kind == MenuItemKind::EDIT) + { + if (item.value == value) + { + return true; + } + item.value = value; + RebuildLayout(); + return true; + } + } + return false; + } + + bool PopupMenu::UpdateItemLabel(int id, const std::wstring& label, bool checked, bool enabled) + { + for (MenuItem& item : m_items) + { + if (item.id == id && item.kind == MenuItemKind::NORMAL) + { + if (item.label == label && item.checked == checked && item.enabled == enabled) + { + return true; + } + item.label = label; + item.checked = checked; + item.enabled = enabled; + RebuildLayout(); + return true; + } + } + return false; + } + + bool PopupMenu::OnEditMouseDown(int x, int y) + { + if (m_editIndex < 0) + { + return false; + } + return UiEditFieldMouseDown(m_fonts->uiSmall, EditFieldRect(m_editIndex), m_edit, m_scale, x, y); + } + + bool PopupMenu::OnEditMouseMove(int x) + { + if (m_editIndex < 0) + { + return false; + } + return UiEditFieldMouseMove(m_fonts->uiSmall, EditFieldRect(m_editIndex), m_edit, m_scale, x); + } + + bool PopupMenu::OnEditMouseUp() + { + if (!m_edit.Dragging()) + { + return false; + } + m_edit.EndDrag(); + return true; + } + + bool PopupMenu::OnChar(wchar_t character, MenuResult& result) + { + result = MenuResult(); + if (m_editIndex < 0) + { + return false; + } + + // 剪贴板与全选快捷键经 WM_CHAR 送达(Ctrl+C/X/V/A 分别对应 3/24/22/1)。 + switch (character) + { + case 0x03: // Ctrl+C + m_edit.CopyToClipboard(m_host); + return false; + case 0x18: // Ctrl+X + return m_edit.CutToClipboard(m_host); + case 0x16: // Ctrl+V + return m_edit.PasteFromClipboard(m_host); + case 0x01: // Ctrl+A + m_edit.SelectAll(); + return true; + default: + break; + } + + if (character == L'\r' || character == L'\n') + { + // 回车提交:菜单保持打开,仅把新数值回传宿主。 + EndItemEdit(true, result); + return true; + } + // 退格/删除已由 WM_KEYDOWN 处理,此处忽略控制字符避免重复删除。 + if (character < 0x20 || character == 0x7F) + { + return false; + } + return m_edit.Insert(character); + } + void PopupMenu::Open(MenuScope scope, const gd::RectF& bounds, int x, int y, const std::vector& items) { @@ -156,7 +378,11 @@ namespace jdi m_hoverSwatch = -1; m_pressedIndex = -1; m_pressedSwatch = -1; + m_buildBounds = bounds; + m_buildX = x; + m_buildY = y; + CloseEditState(); BuildLayout(bounds, x, y); } @@ -171,6 +397,16 @@ namespace jdi m_hoverSwatch = -1; m_pressedIndex = -1; m_pressedSwatch = -1; + CloseEditState(); + } + + void PopupMenu::CloseEditState() + { + if (m_editIndex >= 0) + { + m_edit.End(); + } + m_editIndex = -1; } bool PopupMenu::ContainsPoint(int x, int y) const @@ -251,7 +487,7 @@ namespace jdi } const MenuItem& item = m_items[static_cast(index)]; - if (item.kind == MenuItemKind::NORMAL && item.enabled) + if ((item.kind == MenuItemKind::NORMAL || item.kind == MenuItemKind::EDIT) && item.enabled) { m_hoverIndex = index; m_hoverSwatch = -1; @@ -268,7 +504,18 @@ namespace jdi } const MenuItem& item = m_items[static_cast(m_hoverIndex)]; - if (item.kind != MenuItemKind::NORMAL || !item.enabled) + if (!item.enabled) + { + return false; + } + + // 编辑项:回车等价于单击进入编辑,菜单不关闭。 + if (item.kind == MenuItemKind::EDIT) + { + BeginItemEdit(m_hoverIndex); + return false; + } + if (item.kind != MenuItemKind::NORMAL) { return false; } @@ -310,11 +557,26 @@ namespace jdi if (!ContainsPoint(x, y)) { - // 点击菜单外部:关闭菜单,不产生任何命令。 + // 点击菜单外部:先提交正在编辑的数值,再关闭菜单。 + EndItemEdit(true, result); Close(); return true; } + // 已经处于编辑状态时:点击当前输入框 → 重新定位插入符; + // 点击其它位置 → 先提交本次编辑(菜单不关闭),再按新位置继续处理。 + if (m_editIndex >= 0) + { + if (IsOnEditField(x, y)) + { + OnEditMouseDown(x, y); + m_pressedIndex = m_editIndex; + m_pressedSwatch = -1; + return true; + } + EndItemEdit(true, result); + } + int swatchIndex = -1; m_pressedIndex = ItemAtPoint(x, y, swatchIndex); m_pressedSwatch = swatchIndex; @@ -323,12 +585,23 @@ namespace jdi bool PopupMenu::OnLButtonUp(int x, int y, MenuResult& result) { + const MenuResult editResult = result; result = MenuResult(); if (!m_open) { return false; } + // 编辑态下松开:先结束拖拽框选,不当作普通项点击。 + if (m_editIndex >= 0) + { + OnEditMouseUp(); + m_pressedIndex = -1; + m_pressedSwatch = -1; + result = editResult; + return true; + } + int swatchIndex = -1; const int index = ItemAtPoint(x, y, swatchIndex); const int pressedIndex = m_pressedIndex; @@ -350,6 +623,13 @@ namespace jdi const MenuItem& item = m_items[static_cast(index)]; + // 编辑项:单击进入菜单内联编辑,菜单保持打开。 + if (item.kind == MenuItemKind::EDIT) + { + BeginItemEdit(index); + return true; + } + result.valid = true; result.scope = m_scope; result.id = item.id; @@ -378,6 +658,42 @@ namespace jdi return false; } + // 编辑态下按键全部交给输入框(回车提交、Esc 取消本次编辑但菜单保持打开)。 + if (m_editIndex >= 0) + { + const bool shift = (GetKeyState(VK_SHIFT) & 0x8000) != 0; + switch (key) + { + case VK_ESCAPE: + EndItemEdit(false, result); + return true; + case VK_RETURN: + EndItemEdit(true, result); + return true; + case VK_BACK: + m_edit.Backspace(); + return true; + case VK_DELETE: + m_edit.DeleteForward(); + return true; + case VK_LEFT: + m_edit.MoveCaret(-1, shift); + return true; + case VK_RIGHT: + m_edit.MoveCaret(1, shift); + return true; + case VK_HOME: + m_edit.MoveHome(shift); + return true; + case VK_END: + m_edit.MoveEnd(shift); + return true; + default: + break; + } + return true; + } + switch (key) { case VK_ESCAPE: @@ -445,6 +761,40 @@ namespace jdi const bool hovered = (static_cast(index) == m_hoverIndex) && (item.kind != MenuItemKind::SWATCHES || m_hoverSwatch < 0); + if (item.kind == MenuItemKind::EDIT) + { + // 编辑项:左侧说明文字 + 右侧内联输入框(与拾色器的 RGB 编辑同一种处理方式, + // 菜单始终不关闭,数值就地修改)。 + if (hovered && item.enabled && static_cast(index) != m_editIndex) + { + UiFillRoundRect(g, rect, 4.0f * m_scale, WithAlpha(m_theme->accent, 0x22)); + } + + const std::uint32_t labelColor = item.enabled ? m_theme->text : m_theme->textMuted; + const float labelLeft = rect.X + 8.0f * m_scale; + const gd::RectF fieldRect = EditFieldRect(static_cast(index)); + const float labelWidth = std::max(8.0f, fieldRect.X - labelLeft - kEditFieldGap * m_scale); + UiText(g, UiEllipsize(g, item.label, m_fonts->uiSmall, labelWidth), m_fonts->uiSmall, + gd::RectF(labelLeft, rect.Y, labelWidth, rect.Height), labelColor, TextAlign::LEFT); + + if (static_cast(index) == m_editIndex) + { + UiDrawEditField(g, fieldRect, m_edit, m_fonts->uiSmall, m_scale); + } + else + { + UiFillRoundRect(g, fieldRect, 3.0f * m_scale, m_theme->pressed); + UiDrawRoundRect(g, fieldRect, 3.0f * m_scale, m_theme->border, 1.0f); + const std::wstring text = + UiEllipsize(g, item.value, m_fonts->uiSmall, fieldRect.Width - 12.0f * m_scale); + UiText(g, text, m_fonts->uiSmall, + gd::RectF(fieldRect.X + 6.0f * m_scale, fieldRect.Y, + std::max(8.0f, fieldRect.Width - 12.0f * m_scale), fieldRect.Height), + item.enabled ? m_theme->textStrong : m_theme->textMuted, TextAlign::LEFT); + } + continue; + } + if (item.kind == MenuItemKind::SWATCHES) { const std::vector& rects = m_swatchRects[index]; diff --git a/Utilities/Src/Utf8Util.cpp b/Utilities/Src/Utf8Util.cpp index fc1e370..11338b2 100644 --- a/Utilities/Src/Utf8Util.cpp +++ b/Utilities/Src/Utf8Util.cpp @@ -167,13 +167,29 @@ namespace jdi return false; } - bool TryParseDouble(const char* begin, const char* end, double& value) - { - if (begin == nullptr || end == nullptr || begin >= end) - { - return false; - } + /** @brief 快速解析路径允许保留的最大有效位数(超过则交回 strtod 以保证正确舍入)。 */ + const int kMaxFastDigits = 17; + /** @brief 快速解析路径允许使用的最大十进制指数(超出查表范围,交回 strtod)。 */ + const int kMaxFastExponent = 22; + + /** @brief 10 的 0-22 次幂精确表(该范围内每位都是 double 可精确表示的十进制数)。 */ + const double kPow10Table[23] = {1e0, 1e1, 1e2, 1e3, 1e4, 1e5, 1e6, 1e7, 1e8, 1e9, 1e10, 1e11, + 1e12, 1e13, 1e14, 1e15, 1e16, 1e17, 1e18, 1e19, 1e20, 1e21, 1e22}; + + /** + * @brief 标准库解析路径:去掉首尾空白后交给 strtod。 + * + * 作为快速路径的保底实现,保证十六进制浮点、inf/nan、超范围指数、 + * 带尾部杂字符(如 "12abc")等输入的处理与改造前完全一致。 + * + * @param [IN] begin 文本起始指针。 + * @param [IN] end 文本结束指针(不包含)。 + * @param [OUT] value 解析结果。 + * @return 解析出数值返回 true。 + */ + bool ParseDoubleSlow(const char* begin, const char* end, double& value) + { const char* p = begin; while (p < end && (*p == ' ' || *p == '\t')) { @@ -216,6 +232,130 @@ namespace jdi return true; } + bool TryParseDouble(const char* begin, const char* end, double& value) + { + if (begin == nullptr || end == nullptr || begin >= end) + { + return false; + } + + // ===== 快速路径:纯十进制数值 ===== + // 大文件解析的绝对瓶颈就在这里:ucrt 的 strtod 要处理本地化、十六进制浮点、 + // 无穷/NaN 等全部语法,实测每次调用约 335ns;百万行级文件需调用上千万次, + // 单解析就可能耗掉几十秒。下面这条路径只处理最常见的十进制写法(实测约 13ns, + // 快 25 倍),其余写法(含尾部杂字符)原样交回 strtod, + // 因此输入接受范围与原先完全一致。 + const char* head = begin; + while (head < end && (*head == ' ' || *head == '\t')) + { + ++head; + } + const char* tail = end; + while (tail > head && (tail[-1] == ' ' || tail[-1] == '\t' || tail[-1] == '\r' || tail[-1] == '\n')) + { + --tail; + } + if (head >= tail) + { + return false; + } + + const char* cursor = head; + bool negative = false; + if (*cursor == '+' || *cursor == '-') + { + negative = (*cursor == '-'); + ++cursor; + } + + unsigned long long mantissa = 0; + int digits = 0; + int exponent = 0; + + // 整数部分:超过有效位数的高位数字只影响量级,累加进指数。 + while (cursor < tail && *cursor >= '0' && *cursor <= '9') + { + if (digits < kMaxFastDigits) + { + mantissa = mantissa * 10ull + static_cast(*cursor - '0'); + ++digits; + } + else + { + ++exponent; + } + ++cursor; + } + + // 小数部分:每保留一位就记一次小数位数;超出有效位数的直接丢弃(已低于 double 精度)。 + int fractionDigits = 0; + if (cursor < tail && *cursor == '.') + { + ++cursor; + while (cursor < tail && *cursor >= '0' && *cursor <= '9') + { + if (digits < kMaxFastDigits) + { + mantissa = mantissa * 10ull + static_cast(*cursor - '0'); + ++digits; + ++fractionDigits; + } + ++cursor; + } + } + + if (digits == 0) + { + return ParseDoubleSlow(begin, end, value); + } + exponent -= fractionDigits; + + if (cursor < tail && (*cursor == 'e' || *cursor == 'E')) + { + ++cursor; + bool expNegative = false; + if (cursor < tail && (*cursor == '+' || *cursor == '-')) + { + expNegative = (*cursor == '-'); + ++cursor; + } + if (cursor >= tail || *cursor < '0' || *cursor > '9') + { + return ParseDoubleSlow(begin, end, value); + } + int expValue = 0; + while (cursor < tail && *cursor >= '0' && *cursor <= '9') + { + if (expValue < 1000000) + { + expValue = expValue * 10 + (*cursor - '0'); + } + ++cursor; + } + exponent += expNegative ? -expValue : expValue; + } + + // 存在未消费字符(如 "12abc"、"1,5")或指数超出查表范围时交回标准库: + // 既保持原先 strtod 的宽松接受语义,也保证极端量级的正确舍入。 + if (cursor != tail || exponent > kMaxFastExponent || exponent < -kMaxFastExponent) + { + return ParseDoubleSlow(begin, end, value); + } + + double result = static_cast(mantissa); + if (exponent > 0) + { + result *= kPow10Table[exponent]; + } + else if (exponent < 0) + { + result /= kPow10Table[-exponent]; + } + // mantissa 超过 2^53(16 位以上有效数字)时这里会比 strtod 多一次舍入, + // 相对误差不超过 1 ULP(实测 ≤2.3e-16),在波形显示与标尺(约 6 位有效数字)上不可见。 + value = negative ? -result : result; + return true; + } std::wstring FormatNumber(double value, int decimals) { if (decimals < 0)