diff --git a/Components/Application/Src/Application.cpp b/Components/Application/Src/Application.cpp index cd5a898..9d222f1 100644 --- a/Components/Application/Src/Application.cpp +++ b/Components/Application/Src/Application.cpp @@ -32,7 +32,8 @@ namespace jdi const int kMinClientWidth = 860; /**< 窗口最小客户区宽度。 */ const int kMinClientHeight = 540; /**< 窗口最小客户区高度。 */ const UINT kMessageRemoveFile = WM_APP + 1; /**< 面板请求移除文件的自定义消息。 */ - const wchar_t* kStatusHint = L"滚轮缩放 · Shift+滚轮平移 · Ctrl+滚轮调幅值 · 右键框选 · 双击自适应"; + const wchar_t* kStatusHint = + L"滚轮纵向滚动网格 · Shift+滚轮平移 · 双击绘图区恢复自适应 · Esc 关闭光标"; /** @brief 标签页右键菜单命令 ID。 */ const int kMenuTabNew = 1; @@ -576,6 +577,13 @@ namespace jdi m_toolbar.SetStatusText(std::wstring()); } + // 工具栏可切换按钮的选中状态始终与当前标签页配置保持一致。 + m_toolbar.SetToggleState(ToolbarAction::ZOOM_TIME, + m_plot.CurrentZoomMode() == PlotZoomMode::TIME); + m_toolbar.SetToggleState(ToolbarAction::ZOOM_VALUE, + m_plot.CurrentZoomMode() == PlotZoomMode::VALUE); + m_toolbar.SetToggleState(ToolbarAction::CURSOR, m_plot.CursorEnabled()); + { gd::Graphics g(m_backDc); g.SetSmoothingMode(gd::SmoothingModeAntiAlias); @@ -641,7 +649,7 @@ namespace jdi rightText = m_statusMessage; rightColor = m_statusIsError ? m_theme.danger : m_theme.textStrong; } - else if (m_plot.HasCursor() && !m_dataSet.Empty()) + else if (m_plot.CursorEnabled() && !m_dataSet.Empty()) { rightText = L"光标 · " + FormatValue(m_plot.CursorTime()); rightColor = m_theme.text; @@ -813,13 +821,36 @@ namespace jdi OpenFileDialog(); break; case ToolbarAction::FIT_ALL: + // 自适应同时回到初始状态:退出缩放模式并显示全部信号范围。 m_plot.FitAll(); + SetStatusMessage(L"已恢复自适应视图。", false); break; - case ToolbarAction::ZOOM_IN: - m_plot.ZoomTimeStep(1.4); + case ToolbarAction::ZOOM_TIME: + if (m_plot.ToggleZoomMode(PlotZoomMode::TIME)) + { + SetStatusMessage((m_plot.CurrentZoomMode() == PlotZoomMode::TIME) + ? L"时间轴缩放:在绘图区横向拖拽选择区间即可放大。" + : L"已退出时间轴缩放模式。", + false); + } break; - case ToolbarAction::ZOOM_OUT: - m_plot.ZoomTimeStep(1.0 / 1.4); + case ToolbarAction::ZOOM_VALUE: + if (m_plot.ToggleZoomMode(PlotZoomMode::VALUE)) + { + SetStatusMessage((m_plot.CurrentZoomMode() == PlotZoomMode::VALUE) + ? L"Y 轴缩放:在某个网格内纵向拖拽选择区间即可放大。" + : L"已退出 Y 轴缩放模式。", + false); + } + break; + case ToolbarAction::CURSOR: + if (m_plot.ToggleCursor()) + { + SetStatusMessage(m_plot.CursorEnabled() + ? L"时间光标已启用:拖动竖线移动,双击竖线可输入精确时间。" + : L"时间光标已关闭。", + false); + } break; case ToolbarAction::MERGE_LANES: if (!m_dataSet.Empty()) @@ -895,7 +926,7 @@ namespace jdi switch (key) { case VK_ESCAPE: - m_plot.ClearCursor(); + m_plot.DisableCursor(); return true; case VK_ADD: case VK_OEM_PLUS: @@ -1336,6 +1367,12 @@ namespace jdi const int x = GET_X_LPARAM(lParam); const int y = GET_Y_LPARAM(lParam); + // 时间光标处于精确输入态时,点击绘图区之外视为提交(与线宽输入一致)。 + if (m_plot.IsEditingCursor() && !m_plot.HitTest(x, y)) + { + m_plot.OnEditKey(VK_RETURN); + } + // 点击面板之外的位置时收起面板内展开的自定义颜色面板。 if (!m_panel.HitTest(x, y)) { @@ -1496,33 +1533,12 @@ namespace jdi m_activeTarget = Target::NONE; return 0; } - if (m_plot.HitTest(x, y)) - { - SetCapture(hwnd); - m_activeTarget = Target::PLOT; - if (m_plot.OnRButtonDown(x, y)) - { - InvalidateRect(hwnd, nullptr, FALSE); - } - return 0; - } return 0; } case WM_RBUTTONUP: { - const int x = GET_X_LPARAM(lParam); - const int y = GET_Y_LPARAM(lParam); - bool changed = false; - if (m_activeTarget == Target::PLOT) - { - changed = m_plot.OnRButtonUp(x, y); - } ReleaseCapture(); m_activeTarget = Target::NONE; - if (changed) - { - InvalidateRect(hwnd, nullptr, FALSE); - } return 0; } case WM_MOUSEWHEEL: @@ -1531,9 +1547,7 @@ namespace jdi ScreenToClient(hwnd, &point); const int delta = GET_WHEEL_DELTA_WPARAM(wParam); - const bool control = (GET_KEYSTATE_WPARAM(wParam) & MK_CONTROL) != 0; const bool shift = (GET_KEYSTATE_WPARAM(wParam) & MK_SHIFT) != 0; - const bool alt = (GetKeyState(VK_MENU) & 0x8000) != 0; bool changed = false; if (m_panel.HitTest(point.x, point.y)) @@ -1542,7 +1556,7 @@ namespace jdi } else if (m_plot.HitTest(point.x, point.y)) { - changed = m_plot.OnMouseWheel(point.x, point.y, delta, control, shift, alt); + changed = m_plot.OnMouseWheel(point.x, point.y, delta, shift); } if (changed) { @@ -1577,7 +1591,7 @@ namespace jdi break; } case WM_KEYDOWN: - // 线宽数值输入状态优先接管按键(回车提交、Esc 取消、退格删除)。 + // 数值输入状态优先接管按键(回车提交、Esc 取消、退格删除)。 if (m_panel.IsEditingWidth()) { if (m_panel.OnEditKey(wParam)) @@ -1586,6 +1600,14 @@ namespace jdi } return 0; } + if (m_plot.IsEditingCursor()) + { + if (m_plot.OnEditKey(wParam)) + { + InvalidateRect(hwnd, nullptr, FALSE); + } + return 0; + } if (HandleKeyDown(wParam)) { InvalidateRect(hwnd, nullptr, TRUE); @@ -1594,7 +1616,7 @@ namespace jdi break; case WM_CHAR: { - // 线宽数值仅接受数字与小数点,其余字符一律吞掉。 + // 数值输入仅接受数字与小数点(或科学计数法符号),其余字符一律吞掉。 if (m_panel.IsEditingWidth()) { if (m_panel.OnEditChar(static_cast(wParam))) @@ -1603,6 +1625,14 @@ namespace jdi } return 0; } + if (m_plot.IsEditingCursor()) + { + if (m_plot.OnEditChar(static_cast(wParam))) + { + InvalidateRect(hwnd, nullptr, FALSE); + } + return 0; + } break; } case WM_KILLFOCUS: diff --git a/Components/PlotView/Inc/PlotView.hpp b/Components/PlotView/Inc/PlotView.hpp index b19a752..bcc5e80 100644 --- a/Components/PlotView/Inc/PlotView.hpp +++ b/Components/PlotView/Inc/PlotView.hpp @@ -21,10 +21,11 @@ namespace jdi enum class PlotDragMode : int { NONE = 0, /**< 未拖拽。 */ - PAN_TIME, /**< 水平平移时间轴。 */ - ZOOM_Y, /**< 纵向缩放某网格的幅值轴。 */ - BOX_ZOOM, /**< 右键框选缩放。 */ - SCROLL_LANES /**< 拖动网格滚动条。 */ + PAN_TIME, /**< 水平平移时间轴(常规模式下的左键拖拽)。 */ + RUBBER_TIME, /**< 时间轴缩放选区(竖向贯穿全高)。 */ + RUBBER_VALUE, /**< Y 轴缩放选区(限定在某个网格内)。 */ + SCROLL_LANES, /**< 拖动网格滚动条。 */ + CURSOR_MOVE /**< 拖拽时间光标。 */ }; /** @@ -95,35 +96,17 @@ namespace jdi bool OnLButtonUp(int x, int y); /** - * @brief 处理鼠标右键按下(开始框选缩放)。 - * @param [IN] x 客户区横坐标。 - * @param [IN] y 客户区纵坐标。 - * @return 需要重绘返回 true。 - */ - bool OnRButtonDown(int x, int y); - - /** - * @brief 处理鼠标右键抬起(结束框选缩放或清除光标)。 - * @param [IN] x 客户区横坐标。 - * @param [IN] y 客户区纵坐标。 - * @return 需要重绘返回 true。 - */ - bool OnRButtonUp(int x, int y); - - /** - * @brief 处理鼠标滚轮。 + * @brief 处理鼠标滚轮(仅用于平移与网格滚动,不再承担缩放)。 * @param [IN] x 客户区横坐标。 * @param [IN] y 客户区纵坐标。 * @param [IN] delta 滚轮增量。 - * @param [IN] ctrl 是否按下 Ctrl(缩放当前网格幅值)。 - * @param [IN] shift 是否按下 Shift(平移时间轴)。 - * @param [IN] alt 是否按下 Alt(纵向滚动网格)。 + * @param [IN] shift 是否按下 Shift(水平平移时间轴)。 * @return 需要重绘返回 true。 */ - bool OnMouseWheel(int x, int y, int delta, bool ctrl, bool shift, bool alt); + bool OnMouseWheel(int x, int y, int delta, bool shift); /** - * @brief 处理鼠标双击(恢复自适应视图)。 + * @brief 处理鼠标双击(命中时间光标时进入精确时间输入)。 * @param [IN] x 客户区横坐标。 * @param [IN] y 客户区纵坐标。 * @return 需要重绘返回 true。 @@ -182,22 +165,61 @@ namespace jdi float LaneContentHeight() const { return m_laneContentHeight; } /** - * @brief 清除已固定的时间光标。 + * @brief 清除已启用的时间光标。 */ - void ClearCursor(); + void DisableCursor(); /** - * @brief 判断当前是否存在激活的时间光标。 - * @return 存在返回 true。 + * @brief 切换时间光标:启用时置于当前视图中心,再调用则关闭。 + * @return 状态发生变化返回 true。 */ - bool HasCursor() const; + bool ToggleCursor(); /** - * @brief 获取当前激活光标对应的时间。 + * @brief 判断时间光标是否已启用。 + * @return 启用返回 true。 + */ + bool CursorEnabled() const; + + /** + * @brief 获取当前光标对应的时间。 * @return 光标时间。 */ double CursorTime() const; + /** + * @brief 切换拖拽缩放模式(再次调用同一模式则取消)。 + * @param [IN] mode 目标模式。 + * @return 状态发生变化返回 true。 + */ + bool ToggleZoomMode(PlotZoomMode mode); + + /** + * @brief 获取当前拖拽缩放模式。 + * @return 模式枚举。 + */ + PlotZoomMode CurrentZoomMode() const; + + /** + * @brief 判断是否已进入精确时间输入状态。 + * @return 处于编辑态返回 true。 + */ + bool IsEditingCursor() const { return m_cursorEditing; } + + /** + * @brief 处理编辑态下的字符输入。 + * @param [IN] character 输入字符。 + * @return 界面状态发生变化返回 true。 + */ + bool OnEditChar(wchar_t character); + + /** + * @brief 处理编辑态下的按键。 + * @param [IN] key 虚拟键码。 + * @return 界面状态发生变化返回 true。 + */ + bool OnEditKey(WPARAM key); + /** * @brief 获取当前视图左边界时间。 * @return 视图左边界时间。 @@ -334,6 +356,55 @@ namespace jdi */ void ZoomTimeAt(double factor, double anchorTime); + /** + * @brief 将时间轴缩放到指定的时间区间(拖拽选区释放时调用)。 + * @param [IN] x0 选区起点横坐标。 + * @param [IN] x1 选区终点横坐标。 + * @return 产生了有效缩放返回 true。 + */ + bool ApplyTimeSelection(float x0, float x1); + + /** + * @brief 将某个网格的幅值轴缩放到指定区间(拖拽选区释放时调用)。 + * @param [IN] laneIndex 目标网格下标。 + * @param [IN] y0 选区起点纵坐标。 + * @param [IN] y1 选区终点纵坐标。 + * @return 产生了有效缩放返回 true。 + */ + bool ApplyValueSelection(int laneIndex, float y0, float y1); + + /** + * @brief 计算时间光标当前的横坐标。 + * @return 横坐标。 + */ + float CursorX() const; + + /** + * @brief 计算时间读数标签(或编辑框)的矩形。 + * @return 标签矩形,位于绘图区顶部以避免遮挡时间轴刻度。 + */ + gd::RectF CursorLabelRect() const; + + /** + * @brief 判断给定横坐标是否落在时间光标附近(允许拖拽的容差范围)。 + * @param [IN] x 横坐标。 + * @return 命中返回 true。 + */ + bool IsOnCursorLine(int x) const; + + /** + * @brief 进入时间光标的精确时间输入状态。 + * @return 进入成功返回 true。 + */ + bool BeginCursorEdit(); + + /** + * @brief 结束时间光标的精确时间输入。 + * @param [IN] commit 是否提交当前输入。 + * @return 状态发生变化返回 true。 + */ + bool EndCursorEdit(bool commit); + /** * @brief 构建单条信号在当前视图下的折线点序列。 * @param [IN] resolved 已解析的信号视图。 @@ -364,12 +435,18 @@ namespace jdi void PaintTimeAxis(gd::Graphics& g, const std::vector& timeTicks, double timeStep); /** - * @brief 绘制时间光标及其读数。 + * @brief 绘制时间光标、选区及其读数。 * @param [IN,OUT] g 绘图上下文。 * @param [IN] lanes 网格绘制信息集合。 */ void PaintCursor(gd::Graphics& g, const std::vector& lanes); + /** + * @brief 绘制拖动中的缩放选区。 + * @param [IN,OUT] g 绘图上下文。 + */ + void PaintRubberBand(gd::Graphics& g); + /** * @brief 绘制网格纵向滚动条。 * @param [IN,OUT] g 绘图上下文。 @@ -381,16 +458,32 @@ namespace jdi * @param [IN,OUT] g 绘图上下文。 * @param [IN] laneIndex 网格下标。 * @param [IN] lane 网格绘制信息。 + * @param [IN] visibleTop 网格在绘图区内的可见上边界。 + * @param [IN] visibleBottom 网格在绘图区内的可见下边界。 */ - void PaintLaneLegend(gd::Graphics& g, int laneIndex, const LaneVisual& lane); + void PaintLaneLegend(gd::Graphics& g, int laneIndex, const LaneVisual& lane, float visibleTop, + float visibleBottom); /** * @brief 在网格内绘制各信号的幅值读数。 * @param [IN,OUT] g 绘图上下文。 * @param [IN] laneIndex 网格下标。 * @param [IN] lane 网格绘制信息。 + * @param [IN] visibleTop 网格在绘图区内的可见上边界。 + * @param [IN] visibleBottom 网格在绘图区内的可见下边界。 */ - void PaintLaneReadout(gd::Graphics& g, int laneIndex, const LaneVisual& lane); + void PaintLaneReadout(gd::Graphics& g, int laneIndex, const LaneVisual& lane, float visibleTop, + float visibleBottom); + + /** + * @brief 判断给定矩形是否完整落在可见纵向区间内。 + * @param [IN] rect 待测矩形。 + * @param [IN] visibleTop 可见区上边界。 + * @param [IN] visibleBottom 可见区下边界。 + * @return 完整可见返回 true。 + * @note GDI 文本绘制不会遵循 GDI+ 的裁剪区,必须显式判定,否则滚动时文字会溢出到工具栏。 + */ + static bool IsFullyVisible(const gd::RectF& rect, float visibleTop, float visibleBottom); /** * @brief 绘制空状态提示文本。 @@ -424,11 +517,13 @@ namespace jdi int m_dragStartY = 0; /**< 拖拽起点纵坐标。 */ int m_dragLastX = 0; /**< 上一次拖拽横坐标。 */ int m_dragLastY = 0; /**< 上一次拖拽纵坐标。 */ - double m_dragAnchorValue = 0.0; /**< 纵轴缩放锚点幅值。 */ - double m_dragAnchorRatio = 0.5; /**< 纵轴缩放锚点相对位置。 */ - double m_dragStartYMin = 0.0; /**< 拖拽起始幅值下限。 */ - double m_dragStartYMax = 1.0; /**< 拖拽起始幅值上限。 */ - gd::RectF m_boxRect; /**< 框选缩放矩形。 */ + gd::RectF m_rubberRect; /**< 拖拽选区矩形。 */ + + bool m_cursorHover = false; /**< 鼠标是否悬停在时间光标上。 */ + bool m_cursorEditing = false; /**< 是否处于精确时间输入状态。 */ + bool m_cursorEditFresh = false; /**< 编辑框是否处于“首次输入整体替换”状态。 */ + std::wstring m_cursorEditText; /**< 编辑框当前文本。 */ + gd::RectF m_cursorEditRect; /**< 编辑框矩形(用于命中测试与绘制)。 */ int m_hoverLane = -1; /**< 当前悬停网格下标。 */ std::vector m_lanes; /**< 当前网格绘制信息集合。 */ diff --git a/Components/PlotView/Src/PlotView.cpp b/Components/PlotView/Src/PlotView.cpp index c082e89..35fa8b0 100644 --- a/Components/PlotView/Src/PlotView.cpp +++ b/Components/PlotView/Src/PlotView.cpp @@ -95,9 +95,9 @@ namespace jdi m_scale = (scale > 0.1f) ? scale : 1.0f; } - bool PlotView::HasCursor() const + bool PlotView::CursorEnabled() const { - return Tab().CursorVisible(); + return Tab().CursorEnabled(); } double PlotView::CursorTime() const @@ -165,10 +165,11 @@ namespace jdi m_area = area; m_yAxisWidth = 74.0f * m_scale; - m_timeAxisHeight = 32.0f * m_scale; - m_laneGap = 6.0f * m_scale; - m_minLaneHeight = 46.0f * m_scale; + m_timeAxisHeight = 36.0f * m_scale; + m_laneGap = 9.0f * m_scale; + m_minLaneHeight = 54.0f * m_scale; + // 先为时间轴保留固定高度,剩余空间才交给网格,保证时间轴不会被挤压。 const float topPadding = 6.0f * m_scale; const float availHeight = std::max(0.0f, area.Height - m_timeAxisHeight - topPadding); @@ -443,6 +444,10 @@ namespace jdi Tab().ViewMax() = high; Tab().ViewInitialized() = true; Tab().LaneScroll() = 0.0f; + // 自适应即回到初始状态:退出拖拽缩放模式并放下进行中的选区。 + Tab().ZoomMode() = PlotZoomMode::NONE; + m_rubberRect = gd::RectF(); + m_dragMode = PlotDragMode::NONE; if (m_tab != nullptr) { @@ -454,10 +459,285 @@ namespace jdi BuildLaneVisuals(); } - void PlotView::ClearCursor() + void PlotView::DisableCursor() { - Tab().CursorVisible() = false; - Tab().CursorPinned() = false; + Tab().CursorEnabled() = false; + m_cursorHover = false; + m_cursorEditing = false; + m_cursorEditFresh = false; + m_cursorEditText.clear(); + if (m_dragMode == PlotDragMode::CURSOR_MOVE) + { + m_dragMode = PlotDragMode::NONE; + } + } + + bool PlotView::ToggleCursor() + { + if (Tab().CursorEnabled()) + { + DisableCursor(); + return true; + } + + Tab().CursorEnabled() = true; + // 启用时光标居中放置,随后可用鼠标靠近拖拽或双击指定精确时间。 + Tab().CursorTime() = (Tab().ViewMin() + Tab().ViewMax()) * 0.5; + m_cursorHover = true; + return true; + } + + PlotZoomMode PlotView::CurrentZoomMode() const + { + return Tab().ZoomMode(); + } + + bool PlotView::ToggleZoomMode(PlotZoomMode mode) + { + const PlotZoomMode current = Tab().ZoomMode(); + Tab().ZoomMode() = (current == mode) ? PlotZoomMode::NONE : mode; + return true; + } + + float PlotView::CursorX() const + { + return TimeToX(Tab().CursorTime()); + } + + gd::RectF PlotView::CursorLabelRect() const + { + if (m_fonts == nullptr || m_fonts->monoSmall == nullptr) + { + return gd::RectF(); + } + + // 编辑态下取编辑文本与显示文本中较宽者,保证输入过程中框体不跳动。 + std::wstring text = m_cursorEditing ? m_cursorEditText : FormatValue(Tab().CursorTime()); + if (m_cursorEditing) + { + const float displayWidth = UiMeasureTextWidth(m_fonts->monoSmall, FormatValue(Tab().CursorTime())); + const float editWidth = UiMeasureTextWidth(m_fonts->monoSmall, text); + if (editWidth < displayWidth) + { + text = FormatValue(Tab().CursorTime()); + } + } + + const float width = std::max(64.0f * m_scale, UiMeasureTextWidth(m_fonts->monoSmall, text) + + 16.0f * m_scale); + const float height = 20.0f * m_scale; + + // 读数标签固定在绘图区顶部,避免遮挡底部时间轴刻度尺。 + float left = CursorX() - width * 0.5f; + left = std::max(m_plotRect.X, std::min(left, m_plotRect.X + m_plotRect.Width - width)); + const float top = m_plotRect.Y + 2.0f * m_scale; + + return gd::RectF(left, top, width, height); + } + + bool PlotView::IsOnCursorLine(int x) const + { + if (!Tab().CursorEnabled() || m_plotRect.Width <= 0.0f) + { + return false; + } + const float cursorX = CursorX(); + const float tolerance = 6.0f * m_scale; + return (static_cast(x) >= cursorX - tolerance) && (static_cast(x) <= cursorX + tolerance); + } + + bool PlotView::ApplyTimeSelection(float x0, float x1) + { + if (m_plotRect.Width <= 0.0f) + { + return false; + } + + float left = std::min(x0, x1); + float right = std::max(x0, x1); + left = std::max(left, m_plotRect.X); + right = std::min(right, m_plotRect.X + m_plotRect.Width); + if (right - left < 8.0f * m_scale) + { + return false; + } + + double timeA = XToTime(left); + double timeB = XToTime(right); + if (timeB < timeA) + { + std::swap(timeA, timeB); + } + if (!(timeB - timeA > 0.0)) + { + return false; + } + + Tab().ViewMin() = timeA; + Tab().ViewMax() = timeB; + ClampTimeView(); + return true; + } + + bool PlotView::ApplyValueSelection(int laneIndex, float y0, float y1) + { + if (m_tab == nullptr || laneIndex < 0 || laneIndex >= static_cast(m_lanes.size())) + { + return false; + } + + const LaneVisual& visual = m_lanes[static_cast(laneIndex)]; + if (visual.rect.Height <= 0.0f) + { + return false; + } + + float top = std::min(y0, y1); + float bottom = std::max(y0, y1); + top = std::max(top, visual.rect.Y); + bottom = std::min(bottom, visual.rect.Y + visual.rect.Height); + if (bottom - top < 10.0f * m_scale) + { + return false; + } + + const double valueTop = YToValue(top, visual); + const double valueBottom = YToValue(bottom, visual); + if (!(std::fabs(valueTop - valueBottom) > 0.0)) + { + return false; + } + + Lane& lane = m_tab->Lanes()[static_cast(laneIndex)]; + lane.autoRange = false; + lane.yMin = std::min(valueTop, valueBottom); + lane.yMax = std::max(valueTop, valueBottom); + return true; + } + + bool PlotView::BeginCursorEdit() + { + if (!Tab().CursorEnabled() || m_cursorEditing) + { + return false; + } + m_cursorEditText = FormatValue(Tab().CursorTime()); + m_cursorEditing = true; + m_cursorEditFresh = true; + return true; + } + + bool PlotView::EndCursorEdit(bool commit) + { + if (!m_cursorEditing) + { + return false; + } + + if (commit) + { + const std::wstring trimmed = Trim(m_cursorEditText); + if (!trimmed.empty()) + { + wchar_t* stop = nullptr; + const double parsed = std::wcstod(trimmed.c_str(), &stop); + if (stop != trimmed.c_str() && parsed == parsed) + { + double low = 0.0; + double high = 1.0; + TimeBounds(low, high); + const double span = std::max(high - low, kMinTimeSpan); + Tab().CursorTime() = ClampDouble(parsed, low - span * 10.0, high + span * 10.0); + } + } + } + + m_cursorEditing = false; + m_cursorEditFresh = false; + m_cursorEditText.clear(); + return true; + } + + bool PlotView::OnEditChar(wchar_t character) + { + if (!m_cursorEditing) + { + return false; + } + + if (character == L'\r' || character == L'\n') + { + return EndCursorEdit(true); + } + if (character == L'\b') + { + m_cursorEditFresh = false; + if (!m_cursorEditText.empty()) + { + m_cursorEditText.pop_back(); + } + return true; + } + + const bool digit = (character >= L'0' && character <= L'9'); + const bool special = (character == L'.' || character == L'-' || character == L'+' || character == L'e' || + character == L'E'); + if (!digit && !special) + { + // 时间输入只接受数字与科学计数法符号,其余字符吞掉。 + return true; + } + + // 首次键入直接替换原有数值,无需先手动删除(与线宽输入一致)。 + if (m_cursorEditFresh) + { + m_cursorEditText.clear(); + m_cursorEditFresh = false; + } + + if (m_cursorEditText.size() < 16) + { + if (special && character == L'.' && + (m_cursorEditText.find(L'.') != std::wstring::npos || + m_cursorEditText.find(L'e') != std::wstring::npos || + m_cursorEditText.find(L'E') != std::wstring::npos)) + { + return true; + } + if ((character == L'-' || character == L'+') && !m_cursorEditText.empty() && + m_cursorEditText.back() != L'e' && m_cursorEditText.back() != L'E') + { + return true; + } + m_cursorEditText.push_back(character); + } + return true; + } + + bool PlotView::OnEditKey(WPARAM key) + { + if (!m_cursorEditing) + { + return false; + } + + switch (key) + { + case VK_RETURN: + return EndCursorEdit(true); + case VK_ESCAPE: + return EndCursorEdit(false); + case VK_BACK: + m_cursorEditFresh = false; + if (!m_cursorEditText.empty()) + { + m_cursorEditText.pop_back(); + } + return true; + default: + break; + } + return true; } bool PlotView::HitTest(int x, int y) const @@ -470,11 +750,15 @@ namespace jdi switch (m_dragMode) { case PlotDragMode::PAN_TIME: - case PlotDragMode::BOX_ZOOM: return LoadCursorW(nullptr, IDC_SIZEALL); - case PlotDragMode::ZOOM_Y: + case PlotDragMode::RUBBER_TIME: + return LoadCursorW(nullptr, IDC_SIZEWE); + case PlotDragMode::RUBBER_VALUE: + return LoadCursorW(nullptr, IDC_SIZENS); case PlotDragMode::SCROLL_LANES: return LoadCursorW(nullptr, IDC_SIZENS); + case PlotDragMode::CURSOR_MOVE: + return LoadCursorW(nullptr, IDC_SIZEWE); default: break; } @@ -484,18 +768,25 @@ namespace jdi { return LoadCursorW(nullptr, IDC_SIZENS); } - if (static_cast(x) < m_plotRect.X && - UiPointInRect(m_area, static_cast(x), static_cast(y))) + + if (IsOnCursorLine(x) && UiPointInRect(m_area, static_cast(x), static_cast(y))) { - if (LaneAtY(y) >= 0) + return LoadCursorW(nullptr, IDC_SIZEWE); + } + + if (UiPointInRect(m_plotRect, static_cast(x), static_cast(y))) + { + // 仅在缩放模式下才切换到对应的缩放光标,常规模式保持箭头(不出现十字指针)。 + const PlotZoomMode mode = Tab().ZoomMode(); + if (mode == PlotZoomMode::TIME) + { + return LoadCursorW(nullptr, IDC_SIZEWE); + } + if (mode == PlotZoomMode::VALUE) { return LoadCursorW(nullptr, IDC_SIZENS); } } - if (UiPointInRect(m_plotRect, static_cast(x), static_cast(y))) - { - return LoadCursorW(nullptr, IDC_CROSS); - } return nullptr; } @@ -507,7 +798,6 @@ namespace jdi } const float fx = static_cast(x); - const float fy = static_cast(y); switch (m_dragMode) { @@ -531,47 +821,26 @@ namespace jdi } return true; } - case PlotDragMode::ZOOM_Y: + case PlotDragMode::RUBBER_TIME: { + // 竖向选区贯穿整个绘图区高度,只取横向区间用于时间轴缩放。 + const float left = static_cast(std::min(x, m_dragStartX)); + const float width = static_cast(std::abs(x - m_dragStartX)); + m_rubberRect = gd::RectF(left, m_plotRect.Y, width, m_plotRect.Height); + m_dragMoved = true; + return true; + } + case PlotDragMode::RUBBER_VALUE: + { + // 横向选区限定在按下时的网格内,只取纵向区间用于幅值轴缩放。 if (m_dragLane < 0 || m_dragLane >= static_cast(m_lanes.size())) { return false; } - const gd::RectF& rect = m_lanes[static_cast(m_dragLane)].rect; - if (rect.Height <= 0.0f) - { - return false; - } - - // 上拖放大、下拖缩小,并保持按下点处的幅值不动。 - const double travel = static_cast(m_dragStartY - y) / static_cast(rect.Height); - const double factor = std::exp(travel * 2.0); - const double span = m_dragStartYMax - m_dragStartYMin; - if (!(span > 0.0)) - { - return false; - } - - const double scaleRef = std::max(std::fabs(m_dragStartYMax), std::fabs(m_dragStartYMin)); - const double minSpan = std::max(scaleRef * 1.0e-9, 1.0e-12); - const double newSpan = ClampDouble(span / factor, minSpan, scaleRef * 100.0 + 1.0); - - Lane& lane = m_tab->Lanes()[static_cast(m_dragLane)]; - lane.autoRange = false; - lane.yMin = m_dragAnchorValue - m_dragAnchorRatio * newSpan; - lane.yMax = lane.yMin + newSpan; - if (std::abs(y - m_dragStartY) > 2) - { - m_dragMoved = true; - } - return true; - } - case PlotDragMode::BOX_ZOOM: - { - const float left = static_cast(std::min(x, m_dragStartX)); + const gd::RectF& laneRect = m_lanes[static_cast(m_dragLane)].rect; const float top = static_cast(std::min(y, m_dragStartY)); - m_boxRect = gd::RectF(left, top, static_cast(std::abs(x - m_dragStartX)), - static_cast(std::abs(y - m_dragStartY))); + const float bottom = static_cast(std::max(y, m_dragStartY)); + m_rubberRect = gd::RectF(laneRect.X, top, laneRect.Width, bottom - top); m_dragMoved = true; return true; } @@ -589,6 +858,13 @@ namespace jdi BuildLaneVisuals(); return true; } + case PlotDragMode::CURSOR_MOVE: + { + Tab().CursorTime() = XToTime(fx); + m_dragMoved = true; + m_cursorHover = true; + return true; + } case PlotDragMode::NONE: default: break; @@ -603,16 +879,12 @@ namespace jdi changed = true; } - const bool insidePlot = UiPointInRect(m_plotRect, fx, fy); - if (insidePlot && !Tab().CursorPinned()) + // 时间光标不再跟随鼠标移动,仅在鼠标覆盖光标时才显示具体时间读数。 + const bool hover = Tab().CursorEnabled() && IsOnCursorLine(x); + if (hover != m_cursorHover) { - const double time = XToTime(fx); - if (!Tab().CursorVisible() || std::fabs(time - Tab().CursorTime()) > 0.0) - { - Tab().CursorTime() = time; - Tab().CursorVisible() = true; - changed = true; - } + m_cursorHover = hover; + changed = true; } return changed; } @@ -624,11 +896,15 @@ namespace jdi return false; } + const float fx = static_cast(x); + const float fy = static_cast(y); + m_dragStartX = x; m_dragStartY = y; m_dragLastX = x; m_dragLastY = y; m_dragMoved = false; + m_rubberRect = gd::RectF(); if (m_scrollBarVisible && UiPointInRect(m_scrollBarRect, static_cast(x), static_cast(y))) @@ -637,148 +913,93 @@ namespace jdi return true; } - const int laneIndex = LaneAtY(y); - if (laneIndex < 0) + // 精确时间输入中:编辑框外的点击视为提交。 + if (m_cursorEditing) + { + if (!UiPointInRect(m_cursorEditRect, fx, fy)) + { + EndCursorEdit(true); + } + return true; + } + + // 靠近时间光标线时优先进入拖动,避免误触发平移或缩放选区。 + if (Tab().CursorEnabled() && IsOnCursorLine(x) && UiPointInRect(m_area, fx, fy)) + { + m_dragMode = PlotDragMode::CURSOR_MOVE; + m_cursorHover = true; + return true; + } + + if (!UiPointInRect(m_plotRect, fx, fy)) { m_dragMode = PlotDragMode::NONE; return false; } - if (static_cast(x) < m_plotRect.X) + const PlotZoomMode mode = Tab().ZoomMode(); + if (mode == PlotZoomMode::TIME) { - m_dragMode = PlotDragMode::ZOOM_Y; + m_dragMode = PlotDragMode::RUBBER_TIME; + m_rubberRect = gd::RectF(fx, m_plotRect.Y, 0.0f, m_plotRect.Height); + return true; + } + if (mode == PlotZoomMode::VALUE) + { + const int laneIndex = LaneAtY(y); + if (laneIndex < 0) + { + m_dragMode = PlotDragMode::NONE; + return false; + } m_dragLane = laneIndex; - - const LaneVisual& visual = m_lanes[static_cast(laneIndex)]; - Lane& lane = m_tab->Lanes()[static_cast(laneIndex)]; - lane.autoRange = false; - lane.yMin = visual.yMin; - lane.yMax = visual.yMax; - - m_dragStartYMin = visual.yMin; - m_dragStartYMax = visual.yMax; - m_dragAnchorValue = YToValue(static_cast(y), visual); - const double span = visual.yMax - visual.yMin; - m_dragAnchorRatio = (span > 0.0) ? (visual.yMax - m_dragAnchorValue) / span : 0.5; + m_dragMode = PlotDragMode::RUBBER_VALUE; + const gd::RectF& laneRect = m_lanes[static_cast(laneIndex)].rect; + m_rubberRect = gd::RectF(laneRect.X, fy, laneRect.Width, 0.0f); return true; } - if (UiPointInRect(m_plotRect, static_cast(x), static_cast(y))) - { - m_dragMode = PlotDragMode::PAN_TIME; - return true; - } - - m_dragMode = PlotDragMode::NONE; - return false; + m_dragMode = PlotDragMode::PAN_TIME; + return true; } bool PlotView::OnLButtonUp(int x, int y) { bool changed = (m_dragMode != PlotDragMode::NONE); - if (m_dragMode == PlotDragMode::PAN_TIME && !m_dragMoved && - UiPointInRect(m_plotRect, static_cast(x), static_cast(y))) + switch (m_dragMode) { - const double time = XToTime(static_cast(x)); - if (Tab().CursorPinned() && std::fabs(time - Tab().CursorTime()) < 1.0e-12) + case PlotDragMode::RUBBER_TIME: + if (m_dragMoved && + ApplyTimeSelection(m_rubberRect.X, m_rubberRect.X + m_rubberRect.Width)) { - Tab().CursorPinned() = false; - Tab().CursorVisible() = false; + changed = true; } - else + break; + case PlotDragMode::RUBBER_VALUE: + if (m_dragMoved && + ApplyValueSelection(m_dragLane, m_rubberRect.Y, m_rubberRect.Y + m_rubberRect.Height)) { - Tab().CursorTime() = time; - Tab().CursorVisible() = true; - Tab().CursorPinned() = true; + changed = true; } - changed = true; + break; + case PlotDragMode::CURSOR_MOVE: + m_cursorHover = true; + break; + default: + break; } m_dragMode = PlotDragMode::NONE; m_dragLane = -1; m_dragMoved = false; + m_rubberRect = gd::RectF(); + (void)x; + (void)y; return changed; } - bool PlotView::OnRButtonDown(int x, int y) - { - if (!HasDrawableContent()) - { - return false; - } - if (!UiPointInRect(m_plotRect, static_cast(x), static_cast(y))) - { - return false; - } - - m_dragMode = PlotDragMode::BOX_ZOOM; - m_dragStartX = x; - m_dragStartY = y; - m_dragLastX = x; - m_dragLastY = y; - m_dragMoved = false; - m_dragLane = LaneAtY(y); - m_boxRect = gd::RectF(static_cast(x), static_cast(y), 0.0f, 0.0f); - return true; - } - - bool PlotView::OnRButtonUp(int x, int y) - { - if (m_dragMode != PlotDragMode::BOX_ZOOM) - { - return false; - } - - const bool moved = m_dragMoved; - const int laneIndex = m_dragLane; - const gd::RectF box = m_boxRect; - - m_dragMode = PlotDragMode::NONE; - m_dragMoved = false; - m_dragLane = -1; - - if (!moved || box.Width < 6.0f * m_scale) - { - // 未形成有效框:清除未固定的光标。 - if (!Tab().CursorPinned()) - { - Tab().CursorVisible() = false; - } - return true; - } - - double timeA = XToTime(box.X); - double timeB = XToTime(box.X + box.Width); - if (timeB < timeA) - { - std::swap(timeA, timeB); - } - if (timeB - timeA > 0.0) - { - Tab().ViewMin() = timeA; - Tab().ViewMax() = timeB; - ClampTimeView(); - } - - if (laneIndex >= 0 && laneIndex < static_cast(m_lanes.size()) && box.Height > 12.0f * m_scale) - { - const LaneVisual& visual = m_lanes[static_cast(laneIndex)]; - const double valueTop = YToValue(box.Y, visual); - const double valueBottom = YToValue(box.Y + box.Height, visual); - if (std::fabs(valueTop - valueBottom) > 0.0) - { - Lane& lane = m_tab->Lanes()[static_cast(laneIndex)]; - lane.autoRange = false; - lane.yMin = std::min(valueTop, valueBottom); - lane.yMax = std::max(valueTop, valueBottom); - } - } - (void)y; - return true; - } - - bool PlotView::OnMouseWheel(int x, int y, int delta, bool ctrl, bool shift, bool alt) + bool PlotView::OnMouseWheel(int x, int y, int delta, bool shift) { if (!HasDrawableContent() || delta == 0) { @@ -787,13 +1008,7 @@ namespace jdi const double notches = static_cast(delta) / 120.0; - if (alt) - { - Tab().LaneScroll() -= static_cast(notches * 48.0 * m_scale); - BuildLaneVisuals(); - return true; - } - + // 滚轮不再承担缩放:默认纵向滚动网格,按住 Shift 时水平平移时间轴。 if (shift) { const double span = Tab().ViewMax() - Tab().ViewMin(); @@ -804,47 +1019,15 @@ namespace jdi return true; } - if (ctrl) + if (m_plotRect.Height <= 0.0f || m_laneContentHeight <= m_plotRect.Height) { - int laneIndex = LaneAtY(y); - if (laneIndex < 0) - { - laneIndex = m_hoverLane; - } - if (laneIndex < 0 || laneIndex >= static_cast(m_lanes.size())) - { - return false; - } - - const LaneVisual& visual = m_lanes[static_cast(laneIndex)]; - Lane& lane = m_tab->Lanes()[static_cast(laneIndex)]; - if (lane.autoRange) - { - lane.autoRange = false; - } - lane.yMin = visual.yMin; - lane.yMax = visual.yMax; - - const double anchorValue = YToValue(static_cast(y), visual); - const double span = visual.yMax - visual.yMin; - if (!(span > 0.0)) - { - return false; - } - - const double factor = std::pow(1.25, notches); - const double scaleRef = std::max(std::fabs(visual.yMax), std::fabs(visual.yMin)); - const double minSpan = std::max(scaleRef * 1.0e-9, 1.0e-12); - const double newSpan = ClampDouble(span / factor, minSpan, scaleRef * 100.0 + 1.0); - - const double ratio = (visual.yMax - anchorValue) / span; - lane.yMin = anchorValue - ratio * newSpan; - lane.yMax = lane.yMin + newSpan; - return true; + return false; } - const double anchorTime = XToTime(static_cast(x)); - ZoomTimeAt(std::pow(1.25, notches), anchorTime); + Tab().LaneScroll() -= static_cast(notches * 48.0 * m_scale); + BuildLaneVisuals(); + (void)x; + (void)y; return true; } @@ -854,6 +1037,14 @@ namespace jdi { return false; } + + // 命中时间光标:进入精确时间输入(交互与线宽输入框一致)。 + if (Tab().CursorEnabled() && IsOnCursorLine(x)) + { + return BeginCursorEdit(); + } + + // 其它位置:回到初始状态(自适应 + 退出缩放模式)。 FitAll(); return true; } @@ -866,14 +1057,15 @@ namespace jdi m_hoverLane = -1; changed = true; } - if (!Tab().CursorPinned() && Tab().CursorVisible()) + if (m_cursorHover) { - Tab().CursorVisible() = false; + m_cursorHover = false; changed = true; } return changed; } + void PlotView::BuildPolyline(const ResolvedSignal& resolved, const LaneVisual& lane, std::vector& points, std::vector& breaks) const { @@ -1006,14 +1198,9 @@ namespace jdi } PaintTimeAxis(g, timeTicks, timeStep); + PaintRubberBand(g); PaintCursor(g, m_lanes); - if (m_dragMode == PlotDragMode::BOX_ZOOM && m_dragMoved) - { - UiFillRect(g, m_boxRect, WithAlpha(m_theme->accent, 0x2A)); - UiDrawRoundRect(g, m_boxRect, 2.0f * m_scale, m_theme->accent, 1.0f); - } - PaintScrollBar(g); // 绘图区外框。 @@ -1021,11 +1208,68 @@ namespace jdi g.DrawRectangle(&borderPen, m_plotRect.X, m_plotRect.Y, m_plotRect.Width, m_plotRect.Height); } + void PlotView::PaintRubberBand(gd::Graphics& g) + { + if (!m_dragMoved || m_rubberRect.Width <= 0.0f || m_rubberRect.Height <= 0.0f) + { + return; + } + + const bool timeMode = (m_dragMode == PlotDragMode::RUBBER_TIME); + const bool valueMode = (m_dragMode == PlotDragMode::RUBBER_VALUE); + if (!timeMode && !valueMode) + { + return; + } + + // 时间轴缩放用强调色,Y 轴缩放用警示色,便于区分两种模式。 + const std::uint32_t band = timeMode ? m_theme->accent : m_theme->danger; + UiFillRect(g, m_rubberRect, WithAlpha(band, 0x24)); + UiDrawRoundRect(g, m_rubberRect, 2.0f * m_scale, band, 1.2f); + + // 选区跨度提示(时间用秒/数值用幅度量纲),放在选区顶部。 + std::wstring text; + if (timeMode) + { + text = FormatValue(std::fabs(XToTime(m_rubberRect.X + m_rubberRect.Width) - + XToTime(m_rubberRect.X))); + } + else if (m_dragLane >= 0 && m_dragLane < static_cast(m_lanes.size())) + { + const LaneVisual& lane = m_lanes[static_cast(m_dragLane)]; + text = FormatValue(std::fabs(YToValue(m_rubberRect.Y + m_rubberRect.Height, lane) - + YToValue(m_rubberRect.Y, lane))); + } + if (text.empty()) + { + return; + } + + const float textWidth = UiTextWidth(g, text, m_fonts->monoSmall) + 12.0f * m_scale; + const float textHeight = 18.0f * m_scale; + float labelX = m_rubberRect.X + m_rubberRect.Width * 0.5f - textWidth * 0.5f; + labelX = std::max(m_plotRect.X, std::min(labelX, m_plotRect.X + m_plotRect.Width - textWidth)); + const gd::RectF labelRect(labelX, m_rubberRect.Y + 3.0f * m_scale, textWidth, textHeight); + + UiFillRoundRect(g, labelRect, 3.0f * m_scale, band); + UiText(g, text, m_fonts->monoSmall, labelRect, m_theme->textOnAccent, TextAlign::CENTER); + } + void PlotView::PaintLane(gd::Graphics& g, int laneIndex, const LaneVisual& lane, const std::vector& timeTicks, double timeStep) { + // 网格内容必须裁剪在绘图区之内:纵向滚动时网格会部分移出可视范围, + // 若不与 m_plotRect 求交,波形就会溢出到工具栏与时间轴区域。 + const float clipTop = std::max(lane.rect.Y, m_plotRect.Y); + const float clipBottom = + std::min(lane.rect.Y + lane.rect.Height, m_plotRect.Y + m_plotRect.Height); + if (clipBottom <= clipTop) + { + return; + } + const int saveState = g.Save(); - g.SetClip(gd::RectF(m_area.X, lane.rect.Y, m_area.Width, lane.rect.Height)); + g.SetClip(gd::RectF(m_area.X, clipTop, m_area.Width, clipBottom - clipTop)); // 悬停高亮。 if (laneIndex == m_hoverLane && m_dragMode == PlotDragMode::NONE) @@ -1061,6 +1305,11 @@ namespace jdi const gd::RectF labelRect(m_area.X, y - 9.0f * m_scale, m_yAxisWidth - kAxisLabelGap * m_scale, 18.0f * m_scale); + // 文本必须完整落在可见区内,否则会越过绘图区顶部(GDI 文本不受 GDI+ 裁剪约束)。 + if (!IsFullyVisible(labelRect, clipTop, clipBottom)) + { + continue; + } UiText(g, FormatTick(tick, yStep), m_fonts->monoSmall, labelRect, zeroLine ? m_theme->text : m_theme->textMuted, TextAlign::RIGHT); } @@ -1080,7 +1329,8 @@ namespace jdi const std::vector& laneSignals = m_tab->Lanes()[static_cast(laneIndex)].signals; if (laneSignals.empty()) { - UiText(g, L"(空网格)", m_fonts->uiSmall, lane.rect, m_theme->textMuted, TextAlign::CENTER); + const gd::RectF visibleRect(lane.rect.X, clipTop, lane.rect.Width, clipBottom - clipTop); + UiText(g, L"(空网格)", m_fonts->uiSmall, visibleRect, m_theme->textMuted, TextAlign::CENTER); } else { @@ -1145,31 +1395,44 @@ namespace jdi g.Restore(waveSave); } - // 波形区边框与网格标题。 - UiDrawLine(g, lane.rect.X, lane.rect.Y + lane.rect.Height, lane.rect.X + lane.rect.Width, - lane.rect.Y + lane.rect.Height, m_theme->gridStrong, 1.0f); - UiDrawLine(g, m_area.X, lane.rect.Y, m_area.X + m_area.Width, lane.rect.Y, m_theme->border, 1.0f); + // 网格边界:完整矩形描边,使相邻网格之间界限明确(间隔由 m_laneGap 提供)。 + gd::Pen lanePen(ToGdiColor(m_theme->borderStrong), 1.0f); + g.DrawRectangle(&lanePen, lane.rect.X, lane.rect.Y, lane.rect.Width, lane.rect.Height); + + // 左侧标签列的分隔线,让纵轴读数与波形区有明确分界。 + UiDrawLine(g, m_plotRect.X, lane.rect.Y, m_plotRect.X, lane.rect.Y + lane.rect.Height, + m_theme->border, 1.0f); if (lane.rect.Height >= 24.0f * m_scale) { - PaintLaneLegend(g, laneIndex, lane); - if (Tab().CursorVisible() && lane.rect.Height >= 40.0f * m_scale) + PaintLaneLegend(g, laneIndex, lane, clipTop, clipBottom); + if (Tab().CursorEnabled() && lane.rect.Height >= 40.0f * m_scale) { - PaintLaneReadout(g, laneIndex, lane); + PaintLaneReadout(g, laneIndex, lane, clipTop, clipBottom); } } + g.Restore(saveState); (void)timeStep; } - void PlotView::PaintLaneLegend(gd::Graphics& g, int laneIndex, const LaneVisual& lane) + void PlotView::PaintLaneLegend(gd::Graphics& g, int laneIndex, const LaneVisual& lane, float visibleTop, + float visibleBottom) { if (m_tab == nullptr || laneIndex < 0 || laneIndex >= static_cast(m_tab->Lanes().size())) { return; } + // 图例固定在网格顶部:网格被滚动出可视区时整体跳过,避免文字溢出到工具栏。 + const gd::RectF legendBand(lane.rect.X, lane.rect.Y + 4.0f * m_scale, lane.rect.Width, + 18.0f * m_scale); + if (!IsFullyVisible(legendBand, visibleTop, visibleBottom)) + { + return; + } + const std::vector& laneSignals = m_tab->Lanes()[static_cast(laneIndex)].signals; const bool multiFile = LaneSpansMultipleFiles(laneIndex); @@ -1266,7 +1529,8 @@ namespace jdi } } - void PlotView::PaintLaneReadout(gd::Graphics& g, int laneIndex, const LaneVisual& lane) + void PlotView::PaintLaneReadout(gd::Graphics& g, int laneIndex, const LaneVisual& lane, float visibleTop, + float visibleBottom) { if (m_tab == nullptr || laneIndex < 0 || laneIndex >= static_cast(m_tab->Lanes().size())) { @@ -1333,6 +1597,12 @@ namespace jdi const gd::RectF box(lane.rect.X + lane.rect.Width - kLanePadding * m_scale - boxWidth, lane.rect.Y + 4.0f * m_scale, boxWidth, boxHeight); + // 读数框整体必须落在可见区内,否则文本会越过绘图区边界。 + if (!IsFullyVisible(box, visibleTop, visibleBottom)) + { + return; + } + UiFillRoundRect(g, box, 4.0f * m_scale, WithAlpha(m_theme->readoutBg, 0xEE)); UiDrawRoundRect(g, box, 4.0f * m_scale, m_theme->border, 1.0f); @@ -1345,9 +1615,13 @@ namespace jdi } } - void PlotView::PaintTimeAxis(gd::Graphics& g, const std::vector& timeTicks, double timeStep) + bool PlotView::IsFullyVisible(const gd::RectF& rect, float visibleTop, float visibleBottom) { - const float axisY = m_plotRect.Y + m_plotRect.Height; + return (rect.Y >= visibleTop - 0.5f) && (rect.Y + rect.Height <= visibleBottom + 0.5f); + } + + void PlotView::PaintTimeAxis(gd::Graphics& g, const std::vector& timeTicks, double timeStep) + { const float axisY = m_plotRect.Y + m_plotRect.Height; UiDrawLine(g, m_plotRect.X, axisY, m_plotRect.X + m_plotRect.Width, axisY, m_theme->axisLine, 1.0f); for (const double tick : timeTicks) @@ -1360,8 +1634,12 @@ namespace jdi UiDrawLine(g, x, axisY, x, axisY + 4.0f * m_scale, m_theme->axisLine, 1.0f); - const gd::RectF labelRect(x - 48.0f * m_scale, axisY + 4.0f * m_scale, 96.0f * m_scale, - m_timeAxisRect.Height - 6.0f * m_scale); + // 刻度标签夹在绘图区横向范围内,避免越界压到左侧的纵轴/时间列名。 + const float labelWidth = 96.0f * m_scale; + float labelX = x - labelWidth * 0.5f; + labelX = std::max(m_plotRect.X, std::min(labelX, m_plotRect.X + m_plotRect.Width - labelWidth)); + const gd::RectF labelRect(labelX, axisY + 4.0f * m_scale, labelWidth, + std::max(0.0f, m_timeAxisRect.Height - 6.0f * m_scale)); UiText(g, FormatTick(tick, timeStep), m_fonts->monoSmall, labelRect, m_theme->textMuted, TextAlign::CENTER); } @@ -1393,7 +1671,7 @@ namespace jdi void PlotView::PaintCursor(gd::Graphics& g, const std::vector& lanes) { - if (!Tab().CursorVisible() || m_tab == nullptr) + if (!Tab().CursorEnabled() || m_tab == nullptr) { return; } @@ -1405,10 +1683,24 @@ namespace jdi return; } - UiDrawDashedLine(g, x, m_plotRect.Y, x, m_plotRect.Y + m_plotRect.Height, m_theme->cursor, 1.0f, - Tab().CursorPinned() ? gd::DashStyleSolid : gd::DashStyleDash); + // 光标纵向贯穿全部网格,并延伸到时间轴底部,便于与刻度对齐读数。 + const float lineTop = m_plotRect.Y; + const float lineBottom = m_timeAxisRect.Y + m_timeAxisRect.Height; + const float lineWidth = (m_cursorHover || m_dragMode == PlotDragMode::CURSOR_MOVE) ? 2.0f : 1.2f; + UiDrawLine(g, x, lineTop, x, lineBottom, m_theme->cursor, lineWidth * m_scale); - // 波形上的取值标记。 + // 顶部三角把手,提示该线可以被拖拽。 + { + const float arm = 5.0f * m_scale; + gd::PointF points[3]; + points[0] = gd::PointF(x - arm, lineTop); + points[1] = gd::PointF(x + arm, lineTop); + points[2] = gd::PointF(x, lineTop + arm * 1.4f); + gd::SolidBrush handle(ToGdiColor(m_theme->cursor)); + g.FillPolygon(&handle, points, 3); + } + + // 各网格上的取值标记(仅在网格可见范围内绘制)。 for (std::size_t laneIndex = 0; laneIndex < lanes.size(); ++laneIndex) { const LaneVisual& lane = lanes[laneIndex]; @@ -1429,7 +1721,8 @@ namespace jdi } const float y = ValueToY(value, lane); - if (y < lane.rect.Y - 2.0f || y > lane.rect.Y + lane.rect.Height + 2.0f) + if (y < std::max(lane.rect.Y, m_plotRect.Y) - 2.0f || + y > std::min(lane.rect.Y + lane.rect.Height, m_plotRect.Y + m_plotRect.Height) + 2.0f) { continue; } @@ -1443,17 +1736,43 @@ namespace jdi } } - // 时间轴上的光标标签。 - const std::wstring label = FormatValue(cursorTime); - const float textWidth = UiTextWidth(g, label, m_fonts->monoSmall); - const float pillWidth = textWidth + 14.0f * m_scale; - const float pillHeight = 19.0f * m_scale; - float pillX = x - pillWidth * 0.5f; - pillX = std::max(m_plotRect.X, std::min(pillX, m_plotRect.X + m_plotRect.Width - pillWidth)); + // 具体时间显示在绘图区顶部(不遮挡时间轴刻度),且仅在鼠标覆盖光标时出现。 + if (!m_cursorHover && !m_cursorEditing) + { + return; + } - const gd::RectF pill(pillX, m_plotRect.Y + m_plotRect.Height + 2.0f * m_scale, pillWidth, pillHeight); - UiFillRoundRect(g, pill, 3.0f * m_scale, m_theme->cursor); - UiText(g, label, m_fonts->monoSmall, pill, m_theme->textOnAccent, TextAlign::CENTER); + const gd::RectF labelRect = CursorLabelRect(); + if (labelRect.Width <= 0.0f) + { + return; + } + + if (m_cursorEditing) + { + // 编辑态:强调色外框 + 整段高亮文本,首次键入即整体替换(与线宽输入一致)。 + UiFillRoundRect(g, labelRect, 3.0f * m_scale, m_theme->cardBg); + UiDrawRoundRect(g, labelRect, 3.0f * m_scale, m_theme->accent, 1.6f); + + const std::wstring text = m_cursorEditText.empty() ? std::wstring(L" ") : m_cursorEditText; + if (m_cursorEditFresh) + { + const float textWidth = UiTextWidth(g, text, m_fonts->monoSmall) + 6.0f * m_scale; + const gd::RectF highlight(labelRect.X + (labelRect.Width - textWidth) * 0.5f, labelRect.Y + 2.0f * m_scale, + textWidth, labelRect.Height - 4.0f * m_scale); + UiFillRoundRect(g, highlight, 2.0f * m_scale, m_theme->accent); + UiText(g, text, m_fonts->monoSmall, highlight, m_theme->textOnAccent, TextAlign::CENTER); + } + else + { + UiText(g, text + L"|", m_fonts->monoSmall, labelRect, m_theme->textStrong, TextAlign::CENTER); + } + return; + } + + UiFillRoundRect(g, labelRect, 3.0f * m_scale, m_theme->cursor); + UiText(g, FormatValue(cursorTime), m_fonts->monoSmall, labelRect, m_theme->textOnAccent, + TextAlign::CENTER); } void PlotView::PaintScrollBar(gd::Graphics& g) diff --git a/Components/Toolbar/Inc/Toolbar.hpp b/Components/Toolbar/Inc/Toolbar.hpp index 33646b3..438bfc7 100644 --- a/Components/Toolbar/Inc/Toolbar.hpp +++ b/Components/Toolbar/Inc/Toolbar.hpp @@ -21,9 +21,10 @@ namespace jdi { NONE = 0, /**< 未触发任何动作。 */ OPEN_FILE, /**< 打开 CSV 文件。 */ - FIT_ALL, /**< 视图自适应到可见信号范围。 */ - ZOOM_IN, /**< 放大时间轴。 */ - ZOOM_OUT, /**< 缩小时间轴。 */ + FIT_ALL, /**< 视图自适应到可见信号范围(同时退出缩放模式)。 */ + ZOOM_TIME, /**< 切换「在时间轴缩放」模式。 */ + ZOOM_VALUE, /**< 切换「在 Y 轴缩放」模式。 */ + CURSOR, /**< 切换时间光标。 */ MERGE_LANES, /**< 合并为单一网格。 */ SPLIT_LANES, /**< 按信号拆分为多网格。 */ SELECT_ALL, /**< 勾选当前标签页全部信号。 */ @@ -63,6 +64,13 @@ namespace jdi */ void SetStatusText(const std::wstring& text); + /** + * @brief 设置可切换按钮的选中状态。 + * @param [IN] action 目标按钮动作。 + * @param [IN] checked 是否处于选中(生效)状态。 + */ + void SetToggleState(ToolbarAction action, bool checked); + /** * @brief 计算工具栏在指定缩放下的期望高度。 * @return 期望高度(像素)。 @@ -132,8 +140,8 @@ namespace jdi gd::RectF rect; /**< 按钮矩形。 */ float width = 0.0f; /**< 按钮宽度。 */ bool primary = false; /**< 是否为主按钮(强调色填充)。 */ - bool needsDocument = true; /**< 是否依赖已加载数据。 */ - bool hover = false; /**< 悬停状态。 */ + bool needsDocument = true; /**< 是否依赖已加载数据。 */ bool toggle = false; /**< 是否为可切换按钮。 */ + bool checked = false; /**< 可切换按钮的选中状态。 */ bool hover = false; /**< 悬停状态。 */ bool pressed = false; /**< 按下状态。 */ }; diff --git a/Components/Toolbar/Src/Toolbar.cpp b/Components/Toolbar/Src/Toolbar.cpp index 8a82d41..f647123 100644 --- a/Components/Toolbar/Src/Toolbar.cpp +++ b/Components/Toolbar/Src/Toolbar.cpp @@ -67,17 +67,19 @@ namespace jdi const wchar_t* label; bool primary; bool needsDocument; + bool toggle; }; const Spec specs[] = { - {ToolbarAction::OPEN_FILE, L"打开 CSV", true, false}, - {ToolbarAction::FIT_ALL, L"适应视图", false, true}, - {ToolbarAction::ZOOM_IN, L"放大", false, true}, - {ToolbarAction::ZOOM_OUT, L"缩小", false, true}, - {ToolbarAction::MERGE_LANES, L"合并网格", false, true}, - {ToolbarAction::SPLIT_LANES, L"每信号一格", false, true}, - {ToolbarAction::SELECT_ALL, L"全选信号", false, true}, - {ToolbarAction::CLEAR_ALL, L"清空勾选", false, true}, + {ToolbarAction::OPEN_FILE, L"打开 CSV", true, false, false}, + {ToolbarAction::FIT_ALL, L"适应视图", false, true, false}, + {ToolbarAction::ZOOM_TIME, L"时间轴缩放", false, true, true}, + {ToolbarAction::ZOOM_VALUE, L"Y 轴缩放", false, true, true}, + {ToolbarAction::CURSOR, L"光标", false, true, true}, + {ToolbarAction::MERGE_LANES, L"合并网格", false, true, false}, + {ToolbarAction::SPLIT_LANES, L"每信号一格", false, true, false}, + {ToolbarAction::SELECT_ALL, L"全选信号", false, true, false}, + {ToolbarAction::CLEAR_ALL, L"清空勾选", false, true, false}, }; // 按钮宽度按实际文本像素宽 + 固定内边距计算,避免中文标签被裁切。 @@ -93,11 +95,12 @@ namespace jdi button.label = spec.label; button.primary = spec.primary; button.needsDocument = spec.needsDocument; + button.toggle = spec.toggle; button.width = horizontalPadding + UiMeasureTextWidth(m_fonts->ui, button.label); button.rect = gd::RectF(cursorX, top, button.width, height); cursorX += button.width + kButtonGap * m_scale; - if (spec.action == ToolbarAction::OPEN_FILE || spec.action == ToolbarAction::ZOOM_OUT || + if (spec.action == ToolbarAction::OPEN_FILE || spec.action == ToolbarAction::CURSOR || spec.action == ToolbarAction::SPLIT_LANES) { cursorX += kGroupGap * m_scale; @@ -107,6 +110,18 @@ namespace jdi } } + void Toolbar::SetToggleState(ToolbarAction action, bool checked) + { + for (Button& button : m_buttons) + { + if (button.action == action && button.toggle) + { + button.checked = checked; + return; + } + } + } + void Toolbar::Paint(gd::Graphics& g) { if (m_theme == nullptr || m_fonts == nullptr) @@ -139,17 +154,33 @@ namespace jdi } else { - background = m_theme->toolbarBg; + // 可切换按钮在选中时用强调色浅底 + 强调描边,与普通按钮区分。 + background = (button.toggle && button.checked) ? m_theme->accentSoft : m_theme->toolbarBg; if (button.pressed) { background = m_theme->pressed; } else if (enabled && button.hover) { - background = m_theme->hover; + background = (button.toggle && button.checked) ? MixColor(m_theme->accentSoft, m_theme->accent, + 0.16) + : m_theme->hover; } - border = MixColor(m_theme->border, m_theme->toolbarBg, enabled ? 0.0 : 0.5); - textColor = enabled ? m_theme->text : m_theme->textMuted; + if (!enabled) + { + background = m_theme->toolbarBg; + } + + if (button.toggle && button.checked && enabled) + { + border = m_theme->accent; + } + else + { + border = MixColor(m_theme->border, m_theme->toolbarBg, enabled ? 0.0 : 0.5); + } + textColor = enabled ? ((button.toggle && button.checked) ? m_theme->textStrong : m_theme->text) + : m_theme->textMuted; } if (background != m_theme->toolbarBg) diff --git a/Utilities/Inc/DataModel.hpp b/Utilities/Inc/DataModel.hpp index 714f4d0..381cfed 100644 --- a/Utilities/Inc/DataModel.hpp +++ b/Utilities/Inc/DataModel.hpp @@ -21,6 +21,16 @@ namespace jdi LINE_SCATTER = 2 /**< 同时绘制插值直线与采样点散点。 */ }; + /** + * @brief 绘图区拖拽缩放模式(由工具栏按钮切换)。 + */ + enum class PlotZoomMode : int + { + NONE = 0, /**< 常规模式:拖拽平移时间轴,光标为箭头。 */ + TIME = 1, /**< 时间轴缩放:在绘图区拖拽竖向选区,放大时间范围。 */ + VALUE = 2 /**< Y 轴缩放:在某个网格内拖拽横向选区,放大该网格幅值范围。 */ + }; + /** * @brief 将绘制方式转换为中文显示名称。 * @param [IN] mode 绘制方式。 @@ -446,18 +456,18 @@ namespace jdi bool& ViewInitialized() { return m_viewInitialized; } /// @brief 网格纵向滚动偏移量(可读写)。 float& LaneScroll() { return m_laneScroll; } - /// @brief 时间光标是否可见(可读写)。 - bool& CursorVisible() { return m_cursorVisible; } - /// @brief 时间光标是否可见(只读)。 - bool CursorVisible() const { return m_cursorVisible; } - /// @brief 时间光标是否已固定(可读写)。 - bool& CursorPinned() { return m_cursorPinned; } - /// @brief 时间光标是否已固定(只读)。 - bool CursorPinned() const { return m_cursorPinned; } + /// @brief 时间光标是否启用(可读写)。 + bool& CursorEnabled() { return m_cursorEnabled; } + /// @brief 时间光标是否启用(只读)。 + bool CursorEnabled() const { return m_cursorEnabled; } /// @brief 时间光标位置(可读写)。 double& CursorTime() { return m_cursorTime; } /// @brief 时间光标位置(只读)。 double CursorTime() const { return m_cursorTime; } + /// @brief 绘图区拖拽缩放模式(可读写)。 + PlotZoomMode& ZoomMode() { return m_zoomMode; } + /// @brief 绘图区拖拽缩放模式(只读)。 + PlotZoomMode ZoomMode() const { return m_zoomMode; } private: /** @@ -478,9 +488,9 @@ namespace jdi double m_viewMax = 1.0; /**< 视图右边界时间。 */ bool m_viewInitialized = false; /**< 视图范围是否已初始化。 */ float m_laneScroll = 0.0f; /**< 网格纵向滚动偏移量。 */ - bool m_cursorVisible = false; /**< 时间光标是否可见。 */ - bool m_cursorPinned = false; /**< 时间光标是否已固定。 */ + bool m_cursorEnabled = false; /**< 时间光标是否启用(由工具栏按钮切换)。 */ double m_cursorTime = 0.0; /**< 时间光标位置。 */ + PlotZoomMode m_zoomMode = PlotZoomMode::NONE; /**< 绘图区拖拽缩放模式。 */ }; } // namespace jdi