diff --git a/Components/Application/Src/Application.cpp b/Components/Application/Src/Application.cpp index a9eec76..6bea397 100644 --- a/Components/Application/Src/Application.cpp +++ b/Components/Application/Src/Application.cpp @@ -684,6 +684,19 @@ namespace jdi m_plot.CurrentZoomMode() == PlotZoomMode::SELECT); m_toolbar.SetToggleState(ToolbarAction::CURSOR, m_plot.CursorEnabled()); + // 「光标」按钮文本随模式变化:常态=光标,单光标=光标x1,双光标=光标x2。 + // 文本变化会影响按钮宽度,因此需要重新排布一次。 + { + const PlotCursorMode cursorMode = m_plot.CursorMode(); + const std::wstring cursorLabel = (cursorMode == PlotCursorMode::SINGLE) ? L"光标x1" + : (cursorMode == PlotCursorMode::DOUBLE) ? L"光标x2" + : L"光标"; + if (m_toolbar.SetActionLabel(ToolbarAction::CURSOR, cursorLabel)) + { + Relayout(); + } + } + { gd::Graphics g(m_backDc); g.SetSmoothingMode(gd::SmoothingModeAntiAlias); diff --git a/Components/PlotView/Inc/PlotView.hpp b/Components/PlotView/Inc/PlotView.hpp index e70c621..75a65b3 100644 --- a/Components/PlotView/Inc/PlotView.hpp +++ b/Components/PlotView/Inc/PlotView.hpp @@ -709,6 +709,37 @@ namespace jdi */ void SetHint(const std::wstring& text); + /** + * @brief 设置一条"粘滞"提示:直到 `ReleaseHint()` 为止不会淡出。 + * + * 用于"拖动过程中维持显示"的场景(例如选区拖拽时的右键/ESC 说明)。 + * + * @param [IN] text 提示文本。 + */ + void SetHintSticky(const std::wstring& text); + + /** + * @brief 结束粘滞状态,并从这里开始重新计时(停留后淡出)。 + */ + void ReleaseHint(); + + /** + * @brief 判断坐标是否落在某条光标的**时间读数标签**上。 + * @param [IN] x 客户区横坐标。 + * @param [IN] y 客户区纵坐标。 + * @param [OUT] which 命中的光标序号(1 或 2),未命中时为 0。 + * @return 命中返回 true。 + */ + bool IsOnCursorTimeLabel(int x, int y, int& which) const; + + /** + * @brief 判断坐标是否落在双光标差值标签上。 + * @param [IN] x 客户区横坐标。 + * @param [IN] y 客户区纵坐标。 + * @return 命中返回 true。 + */ + bool IsOnCursorSpanLabel(int x, int y) const; + /** * @brief 画文字时给底色胶囊加一层与文字反色的细描边,提升与背景/曲线的对比度。 * @param [IN] rect 胶囊矩形。 @@ -879,10 +910,11 @@ namespace jdi int m_hoverCursor = 0; /**< 悬停/拖拽中的光标序号(1 或 2,0 表示无)。 */ int m_activeCursor = 1; /**< 最近操作过的光标:内容重叠时它绘制在上层。 */ int m_editCursor = 1; /**< 内联输入所作用的 Time 光标序号。 */ - double m_editDurationUnit = 1.0; /**< 差值编辑时采用的显示单位(秒)。 */ int m_spanDragStartX = 0; /**< 差值标签拖拽起点横坐标。 */ double m_spanDragTime1 = 0.0; /**< 差值标签拖拽起始时两条光标的时间。 */ double m_spanDragTime2 = 0.0; + bool m_dragOnLabel = false; /**< 本次光标拖拽是从时间/差值标签上抓起的(用抓握光标)。 */ + int m_cursorGrabOffset = 0; /**< 从光标时间标签上抓起时的横向偏移(像素)。 */ PlotEditTarget m_editTarget = PlotEditTarget::NONE; /**< 当前内联输入目标。 */ TextEdit m_edit; /**< 输入框编辑模型(光标/选区/剪贴板)。 */ int m_editLane = -1; /**< 内联输入所作用的网格下标。 */ @@ -895,9 +927,14 @@ namespace jdi int m_hoverLane = -1; /**< 当前悬停网格下标。 */ int m_mouseX = 0; /**< 最近一次鼠标横坐标(用于悬停提示定位)。 */ int m_mouseY = 0; /**< 最近一次鼠标纵坐标(用于悬停提示定位)。 */ + int m_hoverAnchorX = 0; /**< 信号悬停提示的锚点横坐标(首次进入可拖拽区域时确定,不随鼠标移动)。 */ + int m_hoverAnchorY = 0; /**< 信号悬停提示的锚点纵坐标。 */ + bool m_spanHover = false; /**< 鼠标是否悬停在双光标差值标签上。 */ std::wstring m_hintText; /**< 临时提示文本(空表示不显示)。 */ std::uint64_t m_hintShownAt = 0; /**< 提示出现的时刻(毫秒,GetTickCount64)。 */ + bool m_hintSticky = false; /**< 提示是否处于粘滞状态(拖动期间维持显示,不淡出)。 */ gd::RectF m_cursorSpanRect; /**< 最近一次绘制时差值标签的矩形(用于命中拖拽/双击)。 */ + std::vector m_cursorLabelRects; /**< 本次绘制已占用的取值标注矩形(用于避让重叠)。 */ std::vector m_lanes; /**< 当前网格绘制信息集合。 */ /** diff --git a/Components/PlotView/Src/PlotView.cpp b/Components/PlotView/Src/PlotView.cpp index aa91faa..93dbeb9 100644 --- a/Components/PlotView/Src/PlotView.cpp +++ b/Components/PlotView/Src/PlotView.cpp @@ -57,64 +57,158 @@ namespace jdi }; /** - * @brief 为给定时间间隔挑选最合适的显示单位(使数值落在 [1, 1000))。 - * @param [IN] seconds 时间间隔(秒)。 - * @return 选中的单位。 - */ - DurationUnit ChosenDurationUnit(double seconds) - { - const double value = std::fabs(seconds); - // 从大到小排列,取第一个使数值 >= 1 的单位。 - const DurationUnit units[] = { - {3600.0, L"h"}, {60.0, L"min"}, {1.0, L"s"}, {1.0e-3, L"ms"}, - {1.0e-6, L"us"}, {1.0e-9, L"ns"}, {1.0e-12, L"ps"}, - }; - const DurationUnit* chosen = &units[sizeof(units) / sizeof(units[0]) - 1]; - for (const DurationUnit& unit : units) - { - if (value >= unit.scale) - { - chosen = &unit; - break; - } - } - return *chosen; - } - - /** - * @brief 把时间间隔格式化为自适应单位的可读文本(含单位)。 + * @brief 把时间间隔格式化为"Δt 语义"的复合单位文本。 * - * 在 h / min / s / ms / us / ns / ps 之间自动选择最合适的单位。 + * 规则(用户明确要求): + * - 顶级单位高于 s(即 h / min)时**一路整合到 s**,例如 3662s → "1h 1min 2s"; + * - 顶级单位为 s 及以下时,若不是该单位的整数,则**向下一级递进**, + * 例如 5.06s → "5s 60ms";整秒数不加下级单位,例如 5s → "5s"; + * - 顶级单位为 ps 时**不再递进次级单位**,例如 5.123ps → "5.123 ps"。 * * @param [IN] seconds 时间间隔(秒)。 - * @return 形如 "12.3 ms" 的文本。 + * @return 不含 "Δt" 前缀的间隔文本。 */ - std::wstring FormatDuration(double seconds) + std::wstring FormatDurationCompound(double seconds) { if (!std::isfinite(seconds)) { return L"—"; } - const DurationUnit unit = ChosenDurationUnit(seconds); - std::wstring text = FormatValue(std::fabs(seconds) / unit.scale); - text += L" "; - text += unit.name; + const double value = std::fabs(seconds); + if (value <= 0.0) + { + return L"0s"; + } + + // ---- 顶级单位高于 s(h / min):整合为 h/min/s(整数秒)---- + // 注意条件必须是 >= 60s:1~60s 属于"顶级单位为 s"的情况,需按下一条规则处理 + // (非整数秒要递进到 ms,例如 5.06s → "5s 60ms")。 + if (value >= 60.0) + { + const long long totalSeconds = static_cast(std::llround(value)); + long long hours = 0; + long long minutes = 0; + long long secs = totalSeconds; + if (totalSeconds >= 3600) + { + hours = totalSeconds / 3600; + secs = totalSeconds % 3600; + } + if (secs >= 60) + { + minutes = secs / 60; + secs = secs % 60; + } + + std::wstring text; + const auto append = [&text](long long amount, const wchar_t* unit) + { + if (amount <= 0) + { + return; + } + if (!text.empty()) + { + text += L" "; + } + text += std::to_wstring(amount); + text += unit; + }; + append(hours, L"h"); + append(minutes, L"min"); + append(secs, L"s"); + if (!text.empty()) + { + return text; + } + } + + // ---- 顶级单位为 s:整数秒 + 可选 ms ---- + if (value >= 1.0) + { + const double whole = std::floor(value); + const double fraction = value - whole; + std::wstring text = FormatValue(whole) + L"s"; + const double remainderMs = fraction * 1000.0; + if (remainderMs >= 1.0e-6) + { + text += L" "; + text += FormatValue(remainderMs); + text += L"ms"; + } + return text; + } + + // ---- 顶级单位低于 s:ms / us / ns / ps ---- + const DurationUnit subUnits[] = { + {1.0e-3, L"ms"}, {1.0e-6, L"us"}, {1.0e-9, L"ns"}, {1.0e-12, L"ps"}, + }; + const std::size_t subCount = sizeof(subUnits) / sizeof(subUnits[0]); + std::size_t index = subCount - 1; + for (std::size_t i = 0; i < subCount; ++i) + { + if (value >= subUnits[i].scale) + { + index = i; + break; + } + } + + // 顶级单位为 ps:不再递进次级单位(保留小数)。 + if (index + 1 >= subCount) + { + return FormatValue(value / subUnits[index].scale) + L" " + subUnits[index].name; + } + + const double scaled = value / subUnits[index].scale; + double whole = std::floor(scaled); + double fraction = scaled - whole; + // 浮点误差可能导致 fraction 极接近 1(例如 4.9999999),归一化一下。 + if (fraction >= 1.0 - 1.0e-9) + { + whole += 1.0; + fraction = 0.0; + } + + std::wstring text = FormatValue(whole); + text += subUnits[index].name; + // 仍是该单位的整数 → 不显示下级单位。 + const double remainder = fraction * subUnits[index].scale; + const double inNext = remainder / subUnits[index + 1].scale; + if (inNext >= 1.0e-6) + { + text += L" "; + text += FormatValue(inNext); + text += subUnits[index + 1].name; + } return text; } /** - * @brief 把时间间隔格式化为"当前显示单位下的纯数值"(不含单位),用于内联编辑。 + * @brief 双光标差值的完整显示文本("Δt " + 复合单位间隔)。 * @param [IN] seconds 时间间隔(秒)。 - * @return 形如 "12.3" 的文本。 + * @return 形如 "Δt 1h 1min 2s" 的文本。 */ - std::wstring FormatDurationNumber(double seconds) + std::wstring FormatSpanText(double seconds) + { + return L"Δt " + FormatDurationCompound(seconds); + } + + /** + * @brief 把时间间隔格式化为"秒"数值文本(不含单位),用于内联编辑。 + * + * 编辑时的量级**始终是秒**:即使显示为 "1.5min",双击编辑的内容也是 90。 + * + * @param [IN] seconds 时间间隔(秒)。 + * @return 形如 "60" 的文本。 + */ + std::wstring FormatDurationSeconds(double seconds) { if (!std::isfinite(seconds)) { return L"0"; } - const DurationUnit unit = ChosenDurationUnit(seconds); - return FormatValue(std::fabs(seconds) / unit.scale); + return FormatValue(std::fabs(seconds)); } /** @@ -680,15 +774,37 @@ namespace jdi void PlotView::SetHint(const std::wstring& text) { m_hintText = text; + m_hintSticky = false; m_hintShownAt = (text.empty()) ? 0 : GetTickCount64(); } + void PlotView::SetHintSticky(const std::wstring& text) + { + m_hintText = text; + m_hintSticky = !text.empty(); + m_hintShownAt = (text.empty()) ? 0 : GetTickCount64(); + } + + void PlotView::ReleaseHint() + { + // 粘滞结束:从这里开始重新计时(先停留再淡出)。 + m_hintSticky = false; + if (!m_hintText.empty()) + { + m_hintShownAt = GetTickCount64(); + } + } + bool PlotView::HintNeedsAnimation() const { if (m_hintText.empty() || m_hintShownAt == 0) { return false; } + if (m_hintSticky) + { + return true; + } const std::uint64_t elapsed = GetTickCount64() - m_hintShownAt; return elapsed < (kHintHoldMs + kHintFadeMs); } @@ -729,11 +845,13 @@ namespace jdi std::uint32_t PlotView::CursorColor(int which) const { + // 两条光标统一使用同一样式与颜色(与光标 1 一致),不再按序号区分配色。 + (void)which; if (m_theme == nullptr) { return 0xFF000000u; } - return (which == 2) ? m_theme->cursorAlt : m_theme->cursor; + return m_theme->cursor; } bool PlotView::CancelDrag(bool showHint) @@ -936,7 +1054,7 @@ namespace jdi m_rubberRect = gd::RectF(); SetHint(Tab().ZoomMode() == PlotZoomMode::NONE ? std::wstring() - : L"选区放大:横向拖动放大时间轴,纵向拖动放大 Y 轴;右键放弃当前选区,Esc 退出"); + : L"横向或纵向拖动以放大选择区域,ESC退出选区模式"); return true; } @@ -962,18 +1080,40 @@ namespace jdi return gd::RectF(); } - const double time = (which == 2) ? Tab().CursorTimeAlt() : Tab().CursorTime(); - const std::wstring text = FormatValue(time); - 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; + // 未经叠放处理的"原始"位置:单条标签独占底部一行。 + const auto rawRect = [this](int index) -> gd::RectF + { + const double time = (index == 2) ? Tab().CursorTimeAlt() : Tab().CursorTime(); + const std::wstring text = FormatValue(time); + 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 = CursorDisplayX(which) - 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 + m_plotRect.Height - height - 2.0f * m_scale; + // 时间读数改到绘图区**底部**:顶部留给网格图例与幅值标尺,避免互相遮挡。 + float left = CursorDisplayX(index) - 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 + m_plotRect.Height - height - 2.0f * m_scale; - return gd::RectF(left, top, width, height); + return gd::RectF(left, top, width, height); + }; + + gd::RectF rect = rawRect(which); + + // 双光标靠得很近时,两条时间读数标签会互相覆盖: + // 把光标 2 的标签上移一层叠放,保证两侧读数都完整可见(不会被覆盖)。 + if (which == 2 && Tab().CursorMode() == PlotCursorMode::DOUBLE) + { + const gd::RectF first = rawRect(1); + const float gap = 2.0f * m_scale; + const bool overlapX = (rect.X < first.X + first.Width + gap) && + (first.X < rect.X + rect.Width + gap); + if (overlapX) + { + rect.Y = std::max(m_plotRect.Y + 2.0f * m_scale, first.Y - rect.Height - gap); + } + } + + return rect; } bool PlotView::IsOnCursorLine(int x, int& which) const @@ -1002,6 +1142,40 @@ namespace jdi return false; } + bool PlotView::IsOnCursorTimeLabel(int x, int y, int& which) const + { + which = 0; + if (!Tab().CursorEnabled()) + { + return false; + } + + const float fx = static_cast(x); + const float fy = static_cast(y); + + // 双光标重叠时优先命中光标 1(与光标线的命中顺序一致)。 + const int count = (Tab().CursorMode() == PlotCursorMode::DOUBLE) ? 2 : 1; + for (int index = 1; index <= count; ++index) + { + const gd::RectF rect = CursorLabelRect(index); + if (rect.Width > 0.0f && UiPointInRect(rect, fx, fy)) + { + which = index; + return true; + } + } + return false; + } + + bool PlotView::IsOnCursorSpanLabel(int x, int y) const + { + if (Tab().CursorMode() != PlotCursorMode::DOUBLE || m_cursorSpanRect.Width <= 0.0f) + { + return false; + } + return UiPointInRect(m_cursorSpanRect, static_cast(x), static_cast(y)); + } + bool PlotView::ApplyTimeSelection(float x0, float x1) { if (m_plotRect.Width <= 0.0f) @@ -1503,6 +1677,17 @@ namespace jdi SignalRef best; float bestDistance = tolerance; + // TEMP-PROBE + { + FILE* fp = fopen(".preflight\\hit.log", "a"); + if (fp != nullptr) + { + fprintf(fp, "query=(%d,%d) plot=(%.0f,%.0f,%.0f,%.0f) lane=%d\n", x, y, m_plotRect.X, + m_plotRect.Y, m_plotRect.Width, m_plotRect.Height, LaneAtY(y)); + fclose(fp); + } + } + // 只为当前网格内的可见信号做精确的点到线段距离检测,避免跨网格误命中。 for (const SignalRef& ref : m_tab->Lanes()[static_cast(laneIndex)].signals) { @@ -1544,8 +1729,33 @@ namespace jdi } if (windowFirst >= windowLast) { + // TEMP-PROBE + FILE* fp = fopen(".preflight\\hit.log", "a"); + if (fp != nullptr) + { + fprintf(fp, " window empty first=%zu last=%zu pts=%zu fx=%.1f tol=%.1f\n", windowFirst, + windowLast, points.size(), fx, tolerance); + fclose(fp); + } continue; } + // TEMP-PROBE + { + FILE* fp = fopen(".preflight\\hit.log", "a"); + if (fp != nullptr) + { + float minY = 1.0e9f; + float maxY = -1.0e9f; + for (std::size_t i = windowFirst; i < windowLast; ++i) + { + minY = std::min(minY, points[i].Y); + maxY = std::max(maxY, points[i].Y); + } + fprintf(fp, " window first=%zu last=%zu pts=%zu yRange=%.1f..%.1f fy=%.1f\n", + windowFirst, windowLast, points.size(), minY, maxY, fy); + fclose(fp); + } + } // 散点模式下把采样点本身也视为可命中目标。 if (resolved.style->mode == DrawMode::SCATTER) @@ -1785,9 +1995,9 @@ namespace jdi if (target == PlotEditTarget::CURSOR_SPAN) { - // 差值编辑:输入值采用编辑开始时的显示单位(如标签显示 ms 则输入的是 ms)。 + // 差值编辑:输入值**始终以秒为单位**(即使界面上显示为 1.5min,编辑内容也是 90)。 // **前方(时间较小)的光标不动**,后方光标向后移动到"前方 + 输入差值"。 - const double span = std::fabs(parsed) * m_editDurationUnit; + const double span = std::fabs(parsed); const double front = std::min(Tab().CursorTime(), Tab().CursorTimeAlt()); const bool secondIsBack = (Tab().CursorTimeAlt() >= Tab().CursorTime()); const double moved = front + span; @@ -1998,8 +2208,12 @@ namespace jdi case PlotDragMode::SCROLL_LANES: return LoadCursorW(nullptr, IDC_SIZENS); case PlotDragMode::CURSOR_MOVE: + // 从时间读数标签上抓起的拖拽统一用抓握(移动)图标; + // 直接从光标线上抓起的拖拽保持左右箭头。 + return LoadCursorW(nullptr, m_dragOnLabel ? IDC_SIZEALL : IDC_SIZEWE); case PlotDragMode::CURSOR_SPAN_MOVE: - return LoadCursorW(nullptr, IDC_SIZEWE); + // 差值标签拖拽:统一使用抓握图标(不是左右箭头)。 + return LoadCursorW(nullptr, IDC_SIZEALL); case PlotDragMode::MOVE_SIGNAL: return LoadCursorW(nullptr, IDC_SIZEALL); default: @@ -2012,11 +2226,19 @@ namespace jdi return LoadCursorW(nullptr, IDC_SIZENS); } - // 双光标差值标签可拖拽。 - if (Tab().CursorMode() == PlotCursorMode::DOUBLE && m_cursorSpanRect.Width > 0.0f && - UiPointInRect(m_cursorSpanRect, static_cast(x), static_cast(y))) + // 双光标差值标签:悬停时用"手形"表示可交互(与拖拽中的抓握图标区分)。 + if (IsOnCursorSpanLabel(x, y)) { - return LoadCursorW(nullptr, IDC_SIZEWE); + return LoadCursorW(nullptr, IDC_HAND); + } + + // 光标时间读数标签同样可拖拽:悬停用手形。 + { + int labelWhich = 0; + if (IsOnCursorTimeLabel(x, y, labelWhich)) + { + return LoadCursorW(nullptr, IDC_HAND); + } } { @@ -2131,6 +2353,8 @@ namespace jdi m_rubberRect = gd::RectF(laneRect.X, top, laneRect.Width, bottom - top); } m_dragMoved = true; + // 拖动过程中维持显示"右键取消选区"说明(粘滞,直到松开左键后才淡出)。 + SetHintSticky(L"右键取消选区,ESC退出选区模式"); return true; } case PlotDragMode::SCROLL_LANES: @@ -2149,8 +2373,8 @@ namespace jdi } case PlotDragMode::CURSOR_MOVE: { - // 放大到采样点可分辨时吸附到最近的数据点,便于精确对比取值。 - double target = XToTime(fx); + // 从时间读数标签上抓起时保留横向抓取偏移,避免一按下光标就跳到鼠标中心。 + double target = XToTime(fx - static_cast(m_cursorGrabOffset)); double snapped = target; if (SnapTimeToSample(target, snapped)) { @@ -2232,9 +2456,24 @@ namespace jdi } if (hoverSignal != m_hoverSignal) { + if (hoverSignal.IsValid()) + { + // 悬停提示固定在"刚刚进入可拖拽区域"的位置:之后鼠标即使继续在该区域内 + // 移动,提示也只停留在原地(不再跟随鼠标)。 + m_hoverAnchorX = x; + m_hoverAnchorY = y; + } m_hoverSignal = hoverSignal; changed = true; } + + // 双光标差值标签的悬停状态(用于区分"抓握/悬停"光标与普通状态)。 + const bool spanHover = IsOnCursorSpanLabel(x, y); + if (spanHover != m_spanHover) + { + m_spanHover = spanHover; + changed = true; + } return changed; } @@ -2255,7 +2494,9 @@ namespace jdi m_dragMoved = false; m_rubberRect = gd::RectF(); m_rubberAxis = PlotRubberAxis::UNDECIDED; - // 新的交互开始:清掉上一次留下的提示(选区放大会在下面重新设置自己的提示)。 + m_dragOnLabel = false; + m_cursorGrabOffset = 0; + // 新的交互开始:清掉上一次留下的提示(拖动过程中会重新设置粘滞提示)。 SetHint(std::wstring()); if (m_scrollBarVisible && @@ -2276,16 +2517,31 @@ namespace jdi } // 双光标模式下先检查差值标签:它可拖拽(两条光标整体平移,间隔不变)。 - if (Tab().CursorMode() == PlotCursorMode::DOUBLE && m_cursorSpanRect.Width > 0.0f && - UiPointInRect(m_cursorSpanRect, fx, fy)) + if (IsOnCursorSpanLabel(x, y)) { m_dragMode = PlotDragMode::CURSOR_SPAN_MOVE; + m_dragOnLabel = true; m_spanDragStartX = x; m_spanDragTime1 = Tab().CursorTime(); m_spanDragTime2 = Tab().CursorTimeAlt(); return true; } + // 光标时间读数标签:可直接抓住拖动(与抓住光标线等价)。 + { + int labelWhich = 0; + if (IsOnCursorTimeLabel(x, y, labelWhich)) + { + m_dragMode = PlotDragMode::CURSOR_MOVE; + m_dragOnLabel = true; + m_hoverCursor = labelWhich; + m_activeCursor = labelWhich; + m_cursorHover = true; + m_cursorGrabOffset = x - static_cast(std::lround(CursorDisplayX(labelWhich))); + return true; + } + } + // 靠近时间光标线时优先进入拖动,避免误触发平移或缩放选区。 { int cursorWhich = 0; @@ -2310,10 +2566,10 @@ namespace jdi if (mode == PlotZoomMode::SELECT) { // 选区放大:轴向由鼠标移动方向决定,因此按下时先记录网格并置为未定。 + // 按下本身**不显示提示**(拖动开始后才显示"右键取消选区")。 m_dragLane = LaneAtY(y); m_rubberAxis = PlotRubberAxis::UNDECIDED; m_dragMode = PlotDragMode::RUBBER_SELECT; - SetHint(L"横向拖动放大时间轴,纵向拖动放大 Y 轴;右键放弃当前选区,Esc 退出"); return true; } @@ -2402,6 +2658,10 @@ namespace jdi m_dragLane = -1; m_dragMoved = false; m_rubberRect = gd::RectF(); + m_dragOnLabel = false; + m_cursorGrabOffset = 0; + // 松开左键:粘滞提示结束计时,开始"停留后淡出"。 + ReleaseHint(); (void)x; return changed; } @@ -2445,12 +2705,28 @@ namespace jdi return false; } - // 双光标差值标签:双击进入差值编辑(输入值采用当前显示单位)。 - if (Tab().CursorMode() == PlotCursorMode::DOUBLE && m_cursorSpanRect.Width > 0.0f && - UiPointInRect(m_cursorSpanRect, static_cast(x), static_cast(y))) + // 双光标差值标签:双击进入差值编辑(输入值的量级始终为秒)。 + // 注意:差值标签在"两光标过近"时会落到主时间轴内部,因此必须先于区域判定。 + if (IsOnCursorSpanLabel(x, y)) { - m_editDurationUnit = ChosenDurationUnit(CursorSpan()).scale; - return BeginEdit(PlotEditTarget::CURSOR_SPAN, -1, FormatDurationNumber(CursorSpan())); + return BeginEdit(PlotEditTarget::CURSOR_SPAN, -1, FormatDurationSeconds(CursorSpan())); + } + + if (!UiPointInRect(m_area, static_cast(x), static_cast(y))) + { + return false; + } + + // 命中光标时间读数标签:编辑该条光标的时间(优先于光标线,避免两条过近时选错)。 + { + int labelWhich = 0; + if (IsOnCursorTimeLabel(x, y, labelWhich)) + { + m_editCursor = labelWhich; + m_activeCursor = labelWhich; + const double time = (labelWhich == 2) ? Tab().CursorTimeAlt() : Tab().CursorTime(); + return BeginEdit(PlotEditTarget::CURSOR_TIME, -1, FormatValue(time)); + } } // 命中时间光标:进入精确时间输入(交互与线宽输入框一致)。 @@ -2517,6 +2793,11 @@ namespace jdi m_hoverCursor = 0; changed = true; } + if (m_spanHover) + { + m_spanHover = false; + changed = true; + } return changed; } @@ -3306,12 +3587,13 @@ namespace jdi } const std::uint64_t elapsed = GetTickCount64() - m_hintShownAt; - if (elapsed >= (kHintHoldMs + kHintFadeMs)) + // 粘滞状态下不淡出(用于"拖动过程中维持显示")。 + if (!m_hintSticky && elapsed >= (kHintHoldMs + kHintFadeMs)) { return; } // 停留结束后在 kHintFadeMs 内线性淡出(颜色 alpha 按比例缩放)。 - const double fade = (elapsed <= kHintHoldMs) + const double fade = (m_hintSticky || elapsed <= kHintHoldMs) ? 1.0 : (1.0 - static_cast(elapsed - kHintHoldMs) / static_cast(kHintFadeMs)); @@ -3865,7 +4147,7 @@ namespace jdi return gd::RectF(); } - const std::wstring text = FormatDuration(CursorSpan()); + const std::wstring text = FormatSpanText(CursorSpan()); const float width = std::max( 72.0f * m_scale, UiMeasureTextWidth(m_fonts->monoSmall, text) + 18.0f * m_scale); const float height = 20.0f * m_scale; @@ -3913,6 +4195,28 @@ namespace jdi const bool doubleMode = (Tab().CursorMode() == PlotCursorMode::DOUBLE); const int cursorCount = doubleMode ? 2 : 1; + // 本次绘制重新统计已占用的取值标注矩形(用于避免多个信号/光标的内容互相覆盖)。 + // 先把底部的时间读数/差值标签也登记进去:它们在取值标注之后绘制、会盖在上面, + // 因此取值标注必须主动避开它们,否则会被遮住半个字。 + m_cursorLabelRects.clear(); + for (int which = 1; which <= cursorCount; ++which) + { + const gd::RectF timeRect = CursorLabelRect(which); + if (timeRect.Width > 0.0f) + { + m_cursorLabelRects.push_back(timeRect); + } + } + if (doubleMode) + { + bool insideAxis = false; + const gd::RectF spanRect = CursorSpanRect(insideAxis); + if (spanRect.Width > 0.0f) + { + m_cursorLabelRects.push_back(spanRect); + } + } + // 重叠时把"最近操作过的那条光标"的内容画在上层,因此先画另一条。 const int order[2] = { (m_activeCursor == 2) ? 1 : 2, (m_activeCursor == 2) ? 2 : 1 }; @@ -4048,7 +4352,69 @@ namespace jdi float labelTop = y - textHeight * 0.5f; labelTop = std::max(clipTop + 1.0f * m_scale, std::min(labelTop, clipBottom - textHeight - 1.0f * m_scale)); - const gd::RectF labelRect(labelLeft, labelTop, textWidth, textHeight); + gd::RectF labelRect(labelLeft, labelTop, textWidth, textHeight); + + // 多个信号的取值标注可能落在同一位置(数据点重合或过近): + // 依次向下(再向上)避让已占用的标注,保证不会互相覆盖。 + { + const float gap = 2.0f * m_scale; + const float minTop = clipTop + 1.0f * m_scale; + const float maxTop = + std::max(minTop, clipBottom - textHeight - 1.0f * m_scale); + bool resolved = false; + for (int step = 0; step < 16; ++step) + { + const gd::RectF* hit = nullptr; + for (const gd::RectF& occupied : m_cursorLabelRects) + { + if (labelRect.IntersectsWith(occupied)) + { + hit = &occupied; + break; + } + } + if (hit == nullptr) + { + resolved = true; + break; + } + // 向下推一格;超出网格底部则改由原始位置向上避让。 + const float next = hit->Y + hit->Height + gap; + if (next > maxTop) + { + break; + } + labelRect.Y = next; + } + if (!resolved) + { + labelRect.Y = labelTop; + for (int step = 0; step < 16; ++step) + { + const gd::RectF* hit = nullptr; + for (const gd::RectF& occupied : m_cursorLabelRects) + { + if (labelRect.IntersectsWith(occupied)) + { + hit = &occupied; + break; + } + } + if (hit == nullptr) + { + resolved = true; + break; + } + labelRect.Y = hit->Y - labelRect.Height - gap; + if (labelRect.Y < minTop) + { + break; + } + } + } + labelRect.Y = std::max(minTop, std::min(labelRect.Y, maxTop)); + m_cursorLabelRects.push_back(labelRect); + } UiFillRoundRect(g, labelRect, 3.0f * m_scale, WithAlpha(signalColor, 0xF0)); const bool dark = IsDarkColor(signalColor); @@ -4094,11 +4460,14 @@ namespace jdi m_cursorSpanRect = spanRect; if (spanRect.Width > 0.0f && spanRect.Height > 0.0f) { - const std::uint32_t fill = m_theme->readoutBg; - UiFillRoundRect(g, spanRect, 3.0f * m_scale, WithAlpha(fill, 0xF2)); - UiDrawRoundRect(g, spanRect, 3.0f * m_scale, m_theme->borderStrong, 1.0f); - UiText(g, FormatDuration(CursorSpan()), m_fonts->monoSmall, spanRect, - m_theme->textStrong, TextAlign::CENTER); + // 样式与光标时间读数完全一致(同底色/同描边/同文字配色)。 + const std::uint32_t color = CursorColor(1); + UiFillRoundRect(g, spanRect, 3.0f * m_scale, + m_spanHover ? color : WithAlpha(color, 0xE0)); + const bool dark = IsDarkColor(color); + DrawLabelOutline(g, spanRect, 3.0f * m_scale, dark); + UiText(g, FormatSpanText(CursorSpan()), m_fonts->monoSmall, spanRect, + dark ? 0xFFFDF6E3u : 0xFF073642u, TextAlign::CENTER); } } else @@ -4250,6 +4619,13 @@ namespace jdi // 悬停提示:光标贴近曲线时给出可拖拽说明(与时间光标的提示方式一致)。 if (!m_hoverSignal.IsValid()) { + // TEMP-PROBE + FILE* fp0 = fopen(".preflight\\paint.log", "a"); + if (fp0 != nullptr) + { + fprintf(fp0, "hover-invalid dragMode=%d\n", static_cast(m_dragMode)); + fclose(fp0); + } return; } const int laneIndex = m_tab->LaneOfSignal(m_hoverSignal); @@ -4266,16 +4642,17 @@ namespace jdi const LaneVisual& lane = m_lanes[static_cast(laneIndex)]; const std::wstring text = resolved.data->Title() + L" · 按住拖拽可移入其它网格"; + // 提示锚定在"首次进入可拖拽区域"的位置,鼠标在该区域内继续移动时提示不会跟随。 const float textWidth = UiTextWidth(g, text, m_fonts->uiSmall) + 16.0f * m_scale; const float height = 20.0f * m_scale; - float left = m_mouseX + 12.0f * m_scale; + float left = static_cast(m_hoverAnchorX) + 12.0f * m_scale; if (left + textWidth > m_plotRect.X + m_plotRect.Width) { left = m_plotRect.X + m_plotRect.Width - textWidth; } left = std::max(m_plotRect.X, left); - float top = m_mouseY + 14.0f * m_scale; + float top = static_cast(m_hoverAnchorY) + 14.0f * m_scale; top = std::min(top, m_plotRect.Y + m_plotRect.Height - height - 2.0f * m_scale); top = std::max(top, lane.rect.Y + 2.0f * m_scale); diff --git a/Components/Toolbar/Inc/Toolbar.hpp b/Components/Toolbar/Inc/Toolbar.hpp index 7eb0d46..5fe7043 100644 --- a/Components/Toolbar/Inc/Toolbar.hpp +++ b/Components/Toolbar/Inc/Toolbar.hpp @@ -64,6 +64,24 @@ namespace jdi */ void SetToggleState(ToolbarAction action, bool checked); + /** + * @brief 覆盖某个按钮的显示文本(用于状态相关的动态标签,例如「光标」)。 + * + * 调用后需要由宿主重新排布(`Layout`)才能让按钮宽度与新文本匹配。 + * + * @param [IN] action 目标按钮动作。 + * @param [IN] label 新文本。 + * @return 文本发生变化返回 true(此时宿主应重新排布)。 + */ + bool SetActionLabel(ToolbarAction action, const std::wstring& label); + + /** + * @brief 获取某个动作按钮当前的显示文本。 + * @param [IN] action 目标按钮动作。 + * @return 按钮文本;不存在时返回空串。 + */ + std::wstring ActionLabel(ToolbarAction action) const; + /** * @brief 计算工具栏在指定缩放下的期望高度。 * @return 期望高度(像素)。 @@ -150,6 +168,7 @@ namespace jdi float m_scale = 1.0f; /**< DPI 缩放比例。 */ bool m_documentLoaded = false; /**< 是否已加载数据。 */ std::vector