From eaf5b9cadf7abf2db71e284b0d70ed38570d8f93 Mon Sep 17 00:00:00 2001 From: "DeepSeek V4.1 Flash" <> Date: Thu, 8 Oct 2026 19:37:26 +0800 Subject: [PATCH] =?UTF-8?q?feat(ui):=20=E5=8F=8C=E5=85=89=E6=A0=87?= =?UTF-8?q?=E6=A0=B7=E5=BC=8F=E7=BB=9F=E4=B8=80=E4=B8=8E=E9=85=8D=E8=89=B2?= =?UTF-8?q?=E3=80=81=E5=85=89=E6=A0=87=E6=BB=9E=E7=95=99=E8=A7=86=E9=87=8E?= =?UTF-8?q?=E5=A4=96=E3=80=81=E5=B7=AE=E5=80=BC=E6=A0=87=E7=AD=BE=E4=B8=8E?= =?UTF-8?q?=E6=8B=96=E6=8B=BD=E3=80=81=E9=80=89=E5=8C=BA=E6=94=BE=E5=A4=A7?= =?UTF-8?q?=E4=BA=A4=E4=BA=92=E4=B8=8E=E6=8F=90=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1) 光标 2 的样式与光标 1 完全一致,并改用更鲜艳的颜色 - 原先光标 2 用朝上的三角把手,观感突兀;现两条光标统一为同款朝下三角 - 主题新增 cursorAlt 并改用 Solarized 洋红 #D33682(原青色偏灰),与光标 1 的 橙色 #CB4B16 同属暖色且更醒目,靠颜色而非形状区分 2) 光标允许滞留在网格可视区域之外(不再被强行夹回视野) - 删除"视图变化后夹取光标"的逻辑(时间轴选区放大、适应视图、按网格时间轴展开、 平移时都不再移动光标);仅在拖拽光标本身时才使用 ClampCursorToView(which) - 新增 CursorDisplayX(which):光标在视野外时把它的显示位置"钉"在最近的网格边缘, 取值标注与时间读数因此始终可见;数据点改为边缘三角指示(指向光标实际方向) - 命中测试改用"显示横坐标",因此被钉在边缘的光标仍可抓住拖回 3) 光标读数增加细描边,避免与背景/曲线颜色过于接近 - 新增 DrawLabelOutline():按文字颜色取反色做 1px 半透明描边, 网格内的取值胶囊、光标时间读数、差值标签统一使用 4) 双光标差值显示(时间轴上方居中,自适应单位,可拖拽、可编辑) - 新增 FormatDuration()/FormatDurationNumber()/ChosenDurationUnit(): 在 h / min / s / ms / us / ns / ps 之间自动选择单位(数值落在 [1,1000)) - 差值标签位于两条时间读数的中点,样式与时间读数一致 - 双击进入编辑:输入值采用**当前显示单位**;确认后**前方(时间较小)光标不动**, 后方光标移动到"前方 + 输入差值" - 标签支持左右拖拽(新增 PlotDragMode::CURSOR_SPAN_MOVE):两条光标整体平移、间隔不变 5) 双光标内容重叠时的层次与差值标签位置 - 新增 m_activeCursor:最后操作过(拖拽/单击/编辑)的光标其内容绘制在上层, 两条光标的竖线、取值胶囊、时间读数都按此顺序绘制 - 差值标签改为按**两条时间读数的实际净空**判断:放不下时自动落到主时间轴内部 (原先按光标线间距判断,光标被钉到边缘时会用差值标签盖住时间读数) 6) 选区放大的光标形状与颜色 - 未开始框选时不再改变鼠标形状(保持箭头);按下但未拖动时为十字 IDC_CROSS; 确定为时间轴选区后为左右箭头、Y 轴选区后为上下箭头 - 两种方向的选区统一使用强调色(原先 Y 轴选区用警示红,观感不一致) - 新增 Application::UpdateDragCursor():Windows 在鼠标捕获期间不派发 WM_SETCURSOR, 必须在 WM_MOUSEMOVE / WM_LBUTTONDOWN 后主动刷新光标形状,否则形状不会变化 7) 提示浮层加大并支持"停留后淡出",光标模式不再提示 - 提示字号由 uiSmall 改为 ui,胶囊 30px 高、内边距更大 - 新增停留 1300ms + 淡出 500ms 的时间控制;宿主用 WM_TIMER(33ms) 驱动重绘, 淡出结束自动 KillTimer(文字走 GDI 不支持 alpha,改为向胶囊底色混色实现淡出) - 「光标」三段式切换不再显示任何提示 验证([1].CSV 11 信号,窗口 1725x996,125% DPI): - 双光标:橙 0.63 / 洋红 0.7812,同款把手;底部时间读数 + 居中差值 "151.2 ms" - 放大到不含光标的区间:光标不移动,取值以边缘三角 + 胶囊贴边显示 - 拖拽差值标签:两光标从 0.63/0.7812 整体平移到 0.424202/0.575402,差值仍为 151.2 ms - 差值编辑:输入 300(显示单位为 ms)→ 前方光标保持 0.63,后方移动到 0.93 - 重叠层次:差值设为 0 时洋红(活动)在上;单击光标线后橙色(活动)在上 - 光标形状:未框选 ARROW / 按下 CROSS / 横向 SIZEWE / 纵向 SIZENS(cursor.ps1 实测) - 提示浮层:出现后约 1.3s 开始淡出、约 1.8s 消失;右键放弃选区后视图不变且模式保持选中 - 空闲 CPU 0.00%;make clean; make all 无警告无错误 --- Components/Application/Inc/Application.hpp | 12 + Components/Application/Src/Application.cpp | 47 ++ Components/PlotView/Inc/PlotView.hpp | 57 ++- Components/PlotView/Src/PlotView.cpp | 497 ++++++++++++++++----- Utilities/Src/Theme.cpp | 4 +- 5 files changed, 500 insertions(+), 117 deletions(-) diff --git a/Components/Application/Inc/Application.hpp b/Components/Application/Inc/Application.hpp index 19326af..9b6abbc 100644 --- a/Components/Application/Inc/Application.hpp +++ b/Components/Application/Inc/Application.hpp @@ -301,6 +301,17 @@ namespace jdi */ bool HandleMouseMove(int x, int y, bool buttonDown); + /** + * @brief 拖动(鼠标捕获)期间主动刷新光标形状。 + * + * Windows 在鼠标被捕获时不再派发 `WM_SETCURSOR`,若不主动设置, + * 光标会一直停留在按下瞬间的形状(例如选区放大时看不到十字/方向箭头)。 + * + * @param [IN] x 客户区横坐标。 + * @param [IN] y 客户区纵坐标。 + */ + void UpdateDragCursor(int x, int y); + HINSTANCE m_instance = nullptr; /**< 模块实例句柄。 */ HWND m_hwnd = nullptr; /**< 主窗口句柄。 */ UINT m_dpi = 96; /**< 当前 DPI。 */ @@ -339,6 +350,7 @@ namespace jdi Target m_activeTarget = Target::NONE; /**< 当前鼠标作用目标。 */ bool m_trackingLeave = false; /**< 是否已注册鼠标离开通知。 */ + bool m_hintTimerActive = false; /**< 提示浮层淡出定时器是否已启动。 */ int m_tabMenuIndex = -1; /**< 标签栏右键菜单的目标标签页下标。 */ int m_plotMenuLane = -1; /**< 绘图区右键菜单的目标网格下标。 */ diff --git a/Components/Application/Src/Application.cpp b/Components/Application/Src/Application.cpp index adf4905..a9eec76 100644 --- a/Components/Application/Src/Application.cpp +++ b/Components/Application/Src/Application.cpp @@ -34,6 +34,8 @@ namespace jdi const int kMinClientWidth = 860; /**< 窗口最小客户区宽度。 */ const int kMinClientHeight = 540; /**< 窗口最小客户区高度。 */ const UINT kMessageRemoveFile = WM_APP + 1; /**< 面板请求移除文件的自定义消息。 */ + const UINT_PTR kHintTimerId = 1; /**< 提示浮层淡出用的定时器 ID。 */ + const UINT kHintTimerPeriodMs = 33; /**< 提示浮层淡出的重绘间隔(约 30fps)。 */ /** @brief 标签页右键菜单命令 ID。 */ const int kMenuTabNew = 1; @@ -716,6 +718,21 @@ namespace jdi BitBlt(windowDc, 0, 0, width, height, m_backDc, 0, 0, SRCCOPY); ReleaseDC(m_hwnd, windowDc); } + + // 提示浮层需要停留后淡出:仅在其仍可见时保持定时重绘,淡出结束即停表。 + if (m_plot.HintNeedsAnimation()) + { + if (m_hintTimerActive == false) + { + SetTimer(m_hwnd, kHintTimerId, kHintTimerPeriodMs, nullptr); + m_hintTimerActive = true; + } + } + else if (m_hintTimerActive) + { + KillTimer(m_hwnd, kHintTimerId); + m_hintTimerActive = false; + } } void Application::SetStatusMessage(const std::wstring& text, bool isError) @@ -1148,6 +1165,17 @@ namespace jdi return false; } + void Application::UpdateDragCursor(int x, int y) + { + // 鼠标捕获期间不再有 WM_SETCURSOR,只能自己按当前状态设置光标。 + HCURSOR cursor = nullptr; + if (m_activeTarget == Target::PLOT) + { + cursor = m_plot.QueryCursor(x, y); + } + SetCursor(cursor != nullptr ? cursor : LoadCursorW(nullptr, IDC_ARROW)); + } + bool Application::HandleMouseMove(int x, int y, bool buttonDown) { if (buttonDown && m_activeTarget != Target::NONE) @@ -1820,6 +1848,12 @@ namespace jdi { InvalidateRect(hwnd, nullptr, FALSE); } + // 拖动(鼠标捕获)期间 Windows 不会派发 WM_SETCURSOR, + // 因此需要主动刷新一次,否则光标形状会一直停在按下的状态。 + if (buttonDown) + { + UpdateDragCursor(x, y); + } return 0; } case WM_MOUSELEAVE: @@ -1916,6 +1950,9 @@ namespace jdi ApplyPendingRename(); InvalidateRect(hwnd, nullptr, FALSE); } + // 拖动期间 Windows 不再派发 WM_SETCURSOR,需在按下后立即刷新一次, + // 使"按下但尚未拖动"时能立刻看到十字/方向箭头提示。 + UpdateDragCursor(x, y); return 0; } @@ -2225,6 +2262,16 @@ namespace jdi RemoveFile(static_cast(wParam)); return 0; } + case WM_TIMER: + { + // 提示浮层淡出:仅重绘,不再做其它处理;Render 会在淡出结束后停表。 + if (wParam == kHintTimerId) + { + InvalidateRect(hwnd, nullptr, FALSE); + return 0; + } + break; + } case WM_DROPFILES: { HDROP drop = reinterpret_cast(wParam); diff --git a/Components/PlotView/Inc/PlotView.hpp b/Components/PlotView/Inc/PlotView.hpp index 740aa1a..e70c621 100644 --- a/Components/PlotView/Inc/PlotView.hpp +++ b/Components/PlotView/Inc/PlotView.hpp @@ -26,6 +26,7 @@ namespace jdi RUBBER_SELECT, /**< 选区放大:按拖拽方向决定横向(时间轴)或纵向(幅值轴)选区。 */ SCROLL_LANES, /**< 拖动网格滚动条。 */ CURSOR_MOVE, /**< 拖拽时间光标。 */ + CURSOR_SPAN_MOVE, /**< 拖拽双光标差值标签:两条光标整体平移,间隔不变。 */ MOVE_SIGNAL /**< 拖拽信号曲线到其它网格。 */ }; @@ -46,6 +47,7 @@ namespace jdi { NONE = 0, /**< 未处于输入状态。 */ CURSOR_TIME, /**< 编辑时间光标位置。 */ + CURSOR_SPAN, /**< 编辑双光标之间的时间差(前方光标不动,后方光标随之移动)。 */ LANE_Y_MAX, /**< 编辑某网格的 Y 轴最大值。 */ LANE_Y_MIN, /**< 编辑某网格的 Y 轴最小值。 */ SIGNAL_NAME /**< 编辑图例中某信号的显示名称。 */ @@ -257,6 +259,15 @@ namespace jdi */ float LaneContentHeight() const { return m_laneContentHeight; } + /** + * @brief 提示浮层是否仍在显示(含淡出阶段)。 + * + * 宿主可在返回 true 时保持定时重绘,使提示能"停留后淡出"。 + * + * @return 需要继续重绘返回 true。 + */ + bool HintNeedsAnimation() const; + /** * @brief 清除已启用的时间光标。 */ @@ -641,6 +652,13 @@ namespace jdi */ float CursorX(int which) const; + /** + * @brief 计算指定光标在屏幕上的**显示**横坐标(滞留在可视区外时贴到网格边缘)。 + * @param [IN] which 光标序号(1 或 2)。 + * @return 显示横坐标。 + */ + float CursorDisplayX(int which) const; + /** * @brief 计算指定光标时间读数标签的矩形。 * @param [IN] which 光标序号(1 或 2)。 @@ -659,11 +677,25 @@ namespace jdi /** * @brief 将两条光标的时间夹取到当前可见时间范围内。 * - * 缩放/平移/自适应会改变可见区间,若不夹取,光标会落到视图之外并“消失”, - * 表现为“放大后无法再拖动光标”。 + * ⚠️ 现在**只在拖拽光标时**使用。视图变化(平移/放大/自适应)必须**不**移动光标: + * 光标允许滞留在网格可视区域之外,此时仅把它的取值/时间读数贴在网格边缘显示。 + * + * @param [IN] which 要夹取的光标序号(1 或 2)。 */ - void ClampCursorToView(); + void ClampCursorToView(int which); + /** + * @brief 计算双光标之间的时间差(绝对值)。 + * @return 时间差;非双光标模式返回 0。 + */ + double CursorSpan() const; + + /** + * @brief 计算双光标差值标签的矩形(时间轴上方、两条时间读数的中间位置)。 + * @param [OUT] insideAxis 为 true 时表示两条光标过近,标签落在主时间轴内部。 + * @return 标签矩形;非双光标模式返回空矩形。 + */ + gd::RectF CursorSpanRect(bool& insideAxis) const; /** * @brief 获取指定光标对应的显示颜色。 * @param [IN] which 光标序号(1 或 2)。 @@ -672,11 +704,19 @@ namespace jdi std::uint32_t CursorColor(int which) const; /** - * @brief 设置并显示一条临时提示(在绘图区底部以浮层方式绘制)。 - * @param [IN] text 提示文本;传空串则清除提示。 + * @brief 设置并显示一条临时提示(在绘图区底部以浮层方式绘制,短暂停留后淡出)。 + * @param [IN] text 提示文本;传空串则立即清除提示。 */ void SetHint(const std::wstring& text); + /** + * @brief 画文字时给底色胶囊加一层与文字反色的细描边,提升与背景/曲线的对比度。 + * @param [IN] rect 胶囊矩形。 + * @param [IN] radius 圆角半径。 + * @param [IN] textDark 文字是否用深色。 + */ + void DrawLabelOutline(gd::Graphics& g, const gd::RectF& rect, float radius, bool textDark); + /** * @brief 精确检测鼠标位置是否命中某条可见信号曲线。 * @param [IN] x 客户区横坐标。 @@ -837,7 +877,12 @@ namespace jdi int m_dragMouseY = 0; /**< 拖拽过程中鼠标的当前纵坐标(虚影跟随)。 */ bool m_cursorHover = false; /**< 鼠标是否悬停在某条时间光标上。 */ 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; PlotEditTarget m_editTarget = PlotEditTarget::NONE; /**< 当前内联输入目标。 */ TextEdit m_edit; /**< 输入框编辑模型(光标/选区/剪贴板)。 */ int m_editLane = -1; /**< 内联输入所作用的网格下标。 */ @@ -851,6 +896,8 @@ namespace jdi int m_mouseX = 0; /**< 最近一次鼠标横坐标(用于悬停提示定位)。 */ int m_mouseY = 0; /**< 最近一次鼠标纵坐标(用于悬停提示定位)。 */ std::wstring m_hintText; /**< 临时提示文本(空表示不显示)。 */ + std::uint64_t m_hintShownAt = 0; /**< 提示出现的时刻(毫秒,GetTickCount64)。 */ + gd::RectF m_cursorSpanRect; /**< 最近一次绘制时差值标签的矩形(用于命中拖拽/双击)。 */ std::vector m_lanes; /**< 当前网格绘制信息集合。 */ /** diff --git a/Components/PlotView/Src/PlotView.cpp b/Components/PlotView/Src/PlotView.cpp index f96fbdd..aa91faa 100644 --- a/Components/PlotView/Src/PlotView.cpp +++ b/Components/PlotView/Src/PlotView.cpp @@ -44,6 +44,90 @@ namespace jdi const float kLegendSwatchSize = 8.0f; /**< 图例色块尺寸。 */ const float kLegendItemGap = 14.0f; /**< 图例条目之间的间距。 */ const float kLegendMaxTextWidth = 200.0f; /**< 图例条目的文本宽度上限(超出则省略)。 */ + const std::uint64_t kHintHoldMs = 1300; /**< 提示浮层完全显示的停留时长(毫秒)。 */ + const std::uint64_t kHintFadeMs = 500; /**< 提示浮层的淡出时长(毫秒)。 */ + + /** + * @brief 时间间隔的显示单位。 + */ + struct DurationUnit + { + double scale = 1.0; /**< 1 个该单位等于多少秒。 */ + const wchar_t* name = L"s"; /**< 单位名称。 */ + }; + + /** + * @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 把时间间隔格式化为自适应单位的可读文本(含单位)。 + * + * 在 h / min / s / ms / us / ns / ps 之间自动选择最合适的单位。 + * + * @param [IN] seconds 时间间隔(秒)。 + * @return 形如 "12.3 ms" 的文本。 + */ + std::wstring FormatDuration(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; + return text; + } + + /** + * @brief 把时间间隔格式化为"当前显示单位下的纯数值"(不含单位),用于内联编辑。 + * @param [IN] seconds 时间间隔(秒)。 + * @return 形如 "12.3" 的文本。 + */ + std::wstring FormatDurationNumber(double seconds) + { + if (!std::isfinite(seconds)) + { + return L"0"; + } + const DurationUnit unit = ChosenDurationUnit(seconds); + return FormatValue(std::fabs(seconds) / unit.scale); + } + + /** + * @brief 按比例缩放颜色的 alpha 通道(用于提示浮层淡出)。 + * @param [IN] argb 原始 0xAARRGGBB 颜色。 + * @param [IN] alpha 新的 alpha(0-255)。 + * @return 缩放后的颜色。 + */ + std::uint32_t ScaleAlpha(std::uint32_t argb, unsigned int alpha) + { + const unsigned int a = std::min(255u, alpha); + return (argb & 0x00FFFFFFu) | (a << 24); + } /** * @brief 判断颜色是否偏暗(用于在信号色底板上选择可读的文字颜色)。 @@ -524,7 +608,7 @@ namespace jdi lane.autoRange = true; } } - ClampCursorToView(); + // 适应视图已回到初始状态,这里不再移动光标(光标允许停留在可视区外)。 BuildLaneVisuals(); } @@ -533,11 +617,11 @@ namespace jdi Tab().CursorMode() = PlotCursorMode::NONE; m_cursorHover = false; m_hoverCursor = 0; - if (m_editTarget == PlotEditTarget::CURSOR_TIME) + if (m_editTarget == PlotEditTarget::CURSOR_TIME || m_editTarget == PlotEditTarget::CURSOR_SPAN) { EndEdit(false); } - if (m_dragMode == PlotDragMode::CURSOR_MOVE) + if (m_dragMode == PlotDragMode::CURSOR_MOVE || m_dragMode == PlotDragMode::CURSOR_SPAN_MOVE) { m_dragMode = PlotDragMode::NONE; } @@ -545,7 +629,7 @@ namespace jdi bool PlotView::CycleCursorMode() { - // 三态循环:无 → 单光标 → 双光标 → 无。 + // 三态循环:无 → 单光标 → 双光标 → 无。光标模式本身不显示提示。 switch (Tab().CursorMode()) { case PlotCursorMode::NONE: @@ -557,7 +641,7 @@ namespace jdi Tab().CursorTimeAlt() = center; m_cursorHover = true; m_hoverCursor = 1; - SetHint(L"单光标:拖动竖线移动,双击竖线可输入精确时间"); + m_activeCursor = 1; break; } case PlotCursorMode::SINGLE: @@ -568,7 +652,7 @@ namespace jdi const double low = Tab().ViewMin(); const double high = Tab().ViewMax(); const double span = (high > low) ? (high - low) : 1.0; - double offset = span * 0.12; + const double offset = span * 0.12; double second = Tab().CursorTime() + offset; if (second > high - span * 0.02) { @@ -579,16 +663,15 @@ namespace jdi second = low + span * 0.5; } Tab().CursorTimeAlt() = second; - ClampCursorToView(); + ClampCursorToView(2); m_cursorHover = true; m_hoverCursor = 2; - SetHint(L"双光标:右侧光标显示当前值与 ±增量(右键光标可取消当前光标)"); + m_activeCursor = 2; break; } case PlotCursorMode::DOUBLE: default: DisableCursor(); - SetHint(std::wstring()); break; } return true; @@ -597,9 +680,29 @@ namespace jdi void PlotView::SetHint(const std::wstring& text) { m_hintText = text; + m_hintShownAt = (text.empty()) ? 0 : GetTickCount64(); } - void PlotView::ClampCursorToView() + bool PlotView::HintNeedsAnimation() const + { + if (m_hintText.empty() || m_hintShownAt == 0) + { + return false; + } + const std::uint64_t elapsed = GetTickCount64() - m_hintShownAt; + return elapsed < (kHintHoldMs + kHintFadeMs); + } + + double PlotView::CursorSpan() const + { + if (m_tab == nullptr || Tab().CursorMode() != PlotCursorMode::DOUBLE) + { + return 0.0; + } + return std::fabs(Tab().CursorTimeAlt() - Tab().CursorTime()); + } + + void PlotView::ClampCursorToView(int which) { if (m_tab == nullptr || m_plotRect.Width <= 0.0f) { @@ -611,10 +714,17 @@ namespace jdi { return; } - // 不夹到边界上,留一点内缩,保证光标线及把手完整可见。 + // 只在拖拽时夹取;留一点内缩,保证光标线及把手完整可见。 + // 视图变化(平移/缩放/自适应)**不**调用本函数:光标允许滞留在网格可视区域之外。 const double inset = (high - low) * 0.002; - Tab().CursorTime() = ClampDouble(Tab().CursorTime(), low + inset, high - inset); - Tab().CursorTimeAlt() = ClampDouble(Tab().CursorTimeAlt(), low + inset, high - inset); + if (which == 2) + { + Tab().CursorTimeAlt() = ClampDouble(Tab().CursorTimeAlt(), low + inset, high - inset); + } + else + { + Tab().CursorTime() = ClampDouble(Tab().CursorTime(), low + inset, high - inset); + } } std::uint32_t PlotView::CursorColor(int which) const @@ -718,7 +828,7 @@ namespace jdi // 否则展开后会看到“时间对了但幅值区间不合适”的波形。 AutoRangeLaneToView(laneIndex); - ClampCursorToView(); + // 注意:不在这里移动光标——光标允许滞留在网格可视区域之外。 BuildLaneVisuals(); return true; } @@ -836,6 +946,15 @@ namespace jdi return TimeToX(time); } + float PlotView::CursorDisplayX(int which) const + { + // 光标允许滞留在可视区之外:此时把它"钉"在最近的网格边缘上显示, + // 保证它的取值与时间读数始终可见。 + const float raw = CursorX(which); + return std::max(m_plotRect.X + 1.0f * m_scale, + std::min(raw, m_plotRect.X + m_plotRect.Width - 1.0f * m_scale)); + } + gd::RectF PlotView::CursorLabelRect(int which) const { if (m_fonts == nullptr || m_fonts->monoSmall == nullptr) @@ -850,7 +969,7 @@ namespace jdi const float height = 20.0f * m_scale; // 时间读数改到绘图区**底部**:顶部留给网格图例与幅值标尺,避免互相遮挡。 - float left = CursorX(which) - width * 0.5f; + 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; @@ -869,12 +988,13 @@ namespace jdi const float fx = static_cast(x); // 双光标重叠时优先命中光标 1,避免出现“抓不起来第二条”的情况。 - if (std::fabs(fx - CursorX(1)) <= tolerance) + // 使用"显示横坐标":滞留在可视区外的光标被钉在边缘,也应该可以被抓住拖回来。 + if (std::fabs(fx - CursorDisplayX(1)) <= tolerance) { which = 1; return true; } - if (Tab().CursorMode() == PlotCursorMode::DOUBLE && std::fabs(fx - CursorX(2)) <= tolerance) + if (Tab().CursorMode() == PlotCursorMode::DOUBLE && std::fabs(fx - CursorDisplayX(2)) <= tolerance) { which = 2; return true; @@ -912,9 +1032,7 @@ namespace jdi Tab().ViewMin() = timeA; Tab().ViewMax() = timeB; ClampTimeView(); - // 视图范围变化后必须把光标夹回可见区间,否则光标会跑到视野外“消失”, - // 用户会看到“放大后光标无法再拖动”。 - ClampCursorToView(); + // 注意:不在这里移动光标——即使选区不包含光标,也只把它的读数贴在网格边缘显示。 // 「选区 Y 轴自适应」开关开启的网格:时间选区放大后自动把 Y 轴匹配到新的可见区间。 ApplySelectionAutoFit(); @@ -1509,6 +1627,21 @@ namespace jdi height); } + if (m_editTarget == PlotEditTarget::CURSOR_SPAN) + { + // 差值输入框落在差值标签处(时间轴上方或时间轴内部)。 + bool insideAxis = false; + const gd::RectF displayRect = CursorSpanRect(insideAxis); + if (displayRect.Width <= 0.0f) + { + return gd::RectF(); + } + width = std::max(width, displayRect.Width); + float left = displayRect.X + displayRect.Width * 0.5f - width * 0.5f; + left = std::max(m_plotRect.X, std::min(left, m_plotRect.X + m_plotRect.Width - width)); + return gd::RectF(left, displayRect.Y, width, height); + } + if (m_editTarget == PlotEditTarget::SIGNAL_NAME) { // 图例改名:输入框直接落在被双击的图例条目位置上。 @@ -1646,6 +1779,29 @@ namespace jdi { Tab().CursorTime() = clamped; } + m_activeCursor = (m_editCursor == 2) ? 2 : 1; + return true; + } + + if (target == PlotEditTarget::CURSOR_SPAN) + { + // 差值编辑:输入值采用编辑开始时的显示单位(如标签显示 ms 则输入的是 ms)。 + // **前方(时间较小)的光标不动**,后方光标向后移动到"前方 + 输入差值"。 + const double span = std::fabs(parsed) * m_editDurationUnit; + const double front = std::min(Tab().CursorTime(), Tab().CursorTimeAlt()); + const bool secondIsBack = (Tab().CursorTimeAlt() >= Tab().CursorTime()); + const double moved = front + span; + if (secondIsBack) + { + Tab().CursorTime() = front; + Tab().CursorTimeAlt() = moved; + } + else + { + Tab().CursorTimeAlt() = front; + Tab().CursorTime() = moved; + } + m_activeCursor = secondIsBack ? 2 : 1; return true; } @@ -1829,15 +1985,20 @@ namespace jdi case PlotDragMode::PAN: return LoadCursorW(nullptr, IDC_SIZEALL); case PlotDragMode::RUBBER_SELECT: - // 选区放大:按当前轴向给出对应方向的光标提示。 + // 选区放大:轴向未定时为十字,确定后按方向给出上下/左右箭头。 if (m_rubberAxis == PlotRubberAxis::VALUE) { return LoadCursorW(nullptr, IDC_SIZENS); } - return LoadCursorW(nullptr, IDC_SIZEWE); + if (m_rubberAxis == PlotRubberAxis::TIME) + { + return LoadCursorW(nullptr, IDC_SIZEWE); + } + return LoadCursorW(nullptr, IDC_CROSS); case PlotDragMode::SCROLL_LANES: return LoadCursorW(nullptr, IDC_SIZENS); case PlotDragMode::CURSOR_MOVE: + case PlotDragMode::CURSOR_SPAN_MOVE: return LoadCursorW(nullptr, IDC_SIZEWE); case PlotDragMode::MOVE_SIGNAL: return LoadCursorW(nullptr, IDC_SIZEALL); @@ -1851,6 +2012,13 @@ 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))) + { + return LoadCursorW(nullptr, IDC_SIZEWE); + } + { int cursorWhich = 0; if (IsOnCursorLine(x, cursorWhich) && UiPointInRect(m_area, static_cast(x), static_cast(y))) @@ -1867,11 +2035,9 @@ namespace jdi return LoadCursorW(nullptr, IDC_SIZEALL); } - // 选区放大模式下不固定方向,用四向光标表示“横向/纵向都可拉”。 - if (Tab().ZoomMode() == PlotZoomMode::SELECT) - { - return LoadCursorW(nullptr, IDC_SIZEALL); - } + // 选区放大模式下**未开始框选时保持默认箭头**(不改变鼠标形状); + // 只有真正按下并拖动后才切成十字/方向箭头。 + // (m_dragMode == RUBBER_SELECT 的情况已在上面处理。) } return nullptr; } @@ -1906,7 +2072,6 @@ namespace jdi Tab().ViewMin() += delta; Tab().ViewMax() += delta; ClampTimeView(); - ClampCursorToView(); } const int laneIndex = m_dragLane; @@ -1991,11 +2156,6 @@ namespace jdi { target = snapped; } - // 只允许在当前可见区间内移动,避免把光标拖到视野之外而“丢失”。 - const double low = Tab().ViewMin(); - const double high = Tab().ViewMax(); - const double inset = (high > low) ? (high - low) * 0.002 : 0.0; - target = ClampDouble(target, low + inset, high - inset); if (m_hoverCursor == 2 && Tab().CursorMode() == PlotCursorMode::DOUBLE) { @@ -2009,6 +2169,16 @@ namespace jdi m_cursorHover = true; return true; } + case PlotDragMode::CURSOR_SPAN_MOVE: + { + // 拖拽差值标签:两条光标整体平移,间隔保持不变。 + const double startTime = XToTime(static_cast(m_spanDragStartX)); + const double delta = XToTime(fx) - startTime; + Tab().CursorTime() = m_spanDragTime1 + delta; + Tab().CursorTimeAlt() = m_spanDragTime2 + delta; + m_dragMoved = true; + return true; + } case PlotDragMode::MOVE_SIGNAL: { const int target = LaneAtY(y); @@ -2028,13 +2198,6 @@ namespace jdi bool changed = false; - // 网格内再次移动鼠标即认为用户已读到提示,把浮层收起(避免长期遮挡数据点取值)。 - if (!m_hintText.empty()) - { - m_hintText.clear(); - changed = true; - } - const int lane = LaneAtY(y); if (lane != m_hoverLane) { @@ -2112,6 +2275,17 @@ namespace jdi return true; } + // 双光标模式下先检查差值标签:它可拖拽(两条光标整体平移,间隔不变)。 + if (Tab().CursorMode() == PlotCursorMode::DOUBLE && m_cursorSpanRect.Width > 0.0f && + UiPointInRect(m_cursorSpanRect, fx, fy)) + { + m_dragMode = PlotDragMode::CURSOR_SPAN_MOVE; + m_spanDragStartX = x; + m_spanDragTime1 = Tab().CursorTime(); + m_spanDragTime2 = Tab().CursorTimeAlt(); + return true; + } + // 靠近时间光标线时优先进入拖动,避免误触发平移或缩放选区。 { int cursorWhich = 0; @@ -2119,6 +2293,8 @@ namespace jdi { m_dragMode = PlotDragMode::CURSOR_MOVE; m_hoverCursor = cursorWhich; + // 点中的光标成为“活动光标”:它触发的内容将画在上层。 + m_activeCursor = cursorWhich; m_cursorHover = true; return true; } @@ -2201,6 +2377,8 @@ namespace jdi case PlotDragMode::CURSOR_MOVE: m_cursorHover = true; break; + case PlotDragMode::CURSOR_SPAN_MOVE: + break; case PlotDragMode::MOVE_SIGNAL: { const int target = LaneAtY(y); @@ -2267,12 +2445,21 @@ namespace jdi return false; } + // 双光标差值标签:双击进入差值编辑(输入值采用当前显示单位)。 + if (Tab().CursorMode() == PlotCursorMode::DOUBLE && m_cursorSpanRect.Width > 0.0f && + UiPointInRect(m_cursorSpanRect, static_cast(x), static_cast(y))) + { + m_editDurationUnit = ChosenDurationUnit(CursorSpan()).scale; + return BeginEdit(PlotEditTarget::CURSOR_SPAN, -1, FormatDurationNumber(CursorSpan())); + } + // 命中时间光标:进入精确时间输入(交互与线宽输入框一致)。 { int cursorWhich = 0; if (IsOnCursorLine(x, cursorWhich)) { m_editCursor = (cursorWhich == 2) ? 2 : 1; + m_activeCursor = m_editCursor; const double time = (m_editCursor == 2) ? Tab().CursorTimeAlt() : Tab().CursorTime(); return BeginEdit(PlotEditTarget::CURSOR_TIME, -1, FormatValue(time)); @@ -3078,10 +3265,10 @@ namespace jdi 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); + // 两种方向的选区使用**同一种强调色**,保持视觉一致。 + const std::uint32_t band = m_theme->accent; + UiFillRect(g, m_rubberRect, WithAlpha(band, 0x28)); + UiDrawRoundRect(g, m_rubberRect, 2.0f * m_scale, band, 1.4f); // 选区跨度提示(时间用秒/数值用幅度量纲),放在选区顶部。 std::wstring text; @@ -3113,25 +3300,41 @@ namespace jdi void PlotView::PaintHint(gd::Graphics& g) { - if (m_hintText.empty() || m_fonts == nullptr || m_fonts->uiSmall == nullptr) + if (m_hintText.empty() || m_fonts == nullptr || m_fonts->ui == nullptr || m_hintShownAt == 0) { return; } + const std::uint64_t elapsed = GetTickCount64() - m_hintShownAt; + if (elapsed >= (kHintHoldMs + kHintFadeMs)) + { + return; + } + // 停留结束后在 kHintFadeMs 内线性淡出(颜色 alpha 按比例缩放)。 + const double fade = (elapsed <= kHintHoldMs) + ? 1.0 + : (1.0 - static_cast(elapsed - kHintHoldMs) / + static_cast(kHintFadeMs)); + const unsigned int alpha = + static_cast(std::max(0.0, std::min(1.0, fade)) * 255.0); + // 提示条贴在绘图区底部居中;时间光标的时间读数也在底部, // 因此光标启用时把提示条上移一行,避免两者互相遮挡。 - const float reserved = (m_tab != nullptr && Tab().CursorEnabled()) ? 24.0f * m_scale : 0.0f; - const float width = UiTextWidth(g, m_hintText, m_fonts->uiSmall) + 20.0f * m_scale; - const float height = 22.0f * m_scale; + const float reserved = (m_tab != nullptr && Tab().CursorEnabled()) ? 26.0f * m_scale : 0.0f; + const float width = UiTextWidth(g, m_hintText, m_fonts->ui) + 30.0f * m_scale; + const float height = 30.0f * m_scale; float left = m_plotRect.X + (m_plotRect.Width - width) * 0.5f; left = std::max(m_plotRect.X + 2.0f * m_scale, std::min(left, m_plotRect.X + m_plotRect.Width - width - 2.0f * m_scale)); - const gd::RectF pill(left, m_plotRect.Y + m_plotRect.Height - height - 2.0f * m_scale - reserved, + const gd::RectF pill(left, m_plotRect.Y + m_plotRect.Height - height - 4.0f * m_scale - reserved, width, height); - UiFillRoundRect(g, pill, 3.0f * m_scale, WithAlpha(m_theme->readoutBg, 0xF2)); - UiDrawRoundRect(g, pill, 3.0f * m_scale, m_theme->borderStrong, 1.0f); - UiText(g, m_hintText, m_fonts->uiSmall, pill, m_theme->textStrong, TextAlign::CENTER); + UiFillRoundRect(g, pill, 4.0f * m_scale, ScaleAlpha(m_theme->readoutBg, alpha)); + UiDrawRoundRect(g, pill, 4.0f * m_scale, ScaleAlpha(m_theme->borderStrong, alpha), 1.2f); + // 文字走 GDI(不支持 alpha 通道),因此把文字颜色向胶囊底色**混色**来实现淡出。 + const std::uint32_t textColor = + MixColor(m_theme->textStrong, m_theme->readoutBg, 1.0f - static_cast(fade)); + UiText(g, m_hintText, m_fonts->ui, pill, textColor, TextAlign::CENTER); } void PlotView::PaintLane(gd::Graphics& g, int laneIndex, const LaneVisual& lane, @@ -3644,6 +3847,62 @@ namespace jdi } } + + void PlotView::DrawLabelOutline(gd::Graphics& g, const gd::RectF& rect, float radius, bool textDark) + { + // 用与文字同色的半透明细描边:深色文字 → 浅色描边,浅色文字 → 深色描边。 + // 这样胶囊压在同类色背景或信号曲线上时仍有清晰的边界。 + const std::uint32_t outline = textDark ? 0x99FDF6E3u : 0x99073642u; + UiDrawRoundRect(g, rect, radius, outline, 1.0f); + } + + gd::RectF PlotView::CursorSpanRect(bool& insideAxis) const + { + insideAxis = false; + if (m_tab == nullptr || Tab().CursorMode() != PlotCursorMode::DOUBLE || + m_fonts == nullptr || m_fonts->monoSmall == nullptr) + { + return gd::RectF(); + } + + const std::wstring text = FormatDuration(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; + + // 水平位置取两条光标时间读数的中点(与它们同一行,位于时间轴上方)。 + // 注意用"显示横坐标":光标滞留在可视区外时会被钉在网格边缘, + // 此时二者在屏幕上必然很近,差值标签应改放到主时间轴内部。 + const gd::RectF first = CursorLabelRect(1); + const gd::RectF second = CursorLabelRect(2); + float center = (CursorDisplayX(1) + CursorDisplayX(2)) * 0.5f; + if (first.Width > 0.0f && second.Width > 0.0f) + { + center = ((first.X + first.Width * 0.5f) + (second.X + second.Width * 0.5f)) * 0.5f; + } + + float left = center - width * 0.5f; + left = std::max(m_plotRect.X + 2.0f * m_scale, + std::min(left, m_plotRect.X + m_plotRect.Width - width - 2.0f * m_scale)); + + // 两条**时间读数标签**之间的净空不足时,同一行放不下第三个标签 + // → 改放到主时间轴内部(时间轴上方已无空间)。 + const gd::RectF& leftRect = (first.X <= second.X) ? first : second; + const gd::RectF& rightRect = (first.X <= second.X) ? second : first; + const float freeStart = leftRect.X + leftRect.Width; + const float freeEnd = rightRect.X; + const bool fits = (freeStart <= freeEnd) && ((freeEnd - freeStart) >= (width + 2.0f * m_scale)); + if (!fits) + { + insideAxis = true; + const float top = m_timeAxisRect.Y + 2.0f * m_scale; + const float axisHeight = std::max(14.0f * m_scale, m_timeAxisRect.Height - 4.0f * m_scale); + return gd::RectF(left, top, width, std::min(height, axisHeight)); + } + + return gd::RectF(left, m_plotRect.Y + m_plotRect.Height - height - 2.0f * m_scale, width, height); + } + void PlotView::PaintCursor(gd::Graphics& g, const std::vector& lanes) { if (!Tab().CursorEnabled() || m_tab == nullptr) @@ -3652,73 +3911,51 @@ namespace jdi } const bool doubleMode = (Tab().CursorMode() == PlotCursorMode::DOUBLE); - - // 双光标时统一以“时间较小者为左、较大者为右”,右侧光标负责显示 ±增量, - // 这样不论用户拖动哪一条,读数含义都稳定。 - double leftTime = Tab().CursorTime(); - double rightTime = Tab().CursorTimeAlt(); - int leftWhich = 1; - int rightWhich = 2; - if (doubleMode && rightTime < leftTime) - { - std::swap(leftTime, rightTime); - leftWhich = 2; - rightWhich = 1; - } - const int cursorCount = doubleMode ? 2 : 1; + // 重叠时把"最近操作过的那条光标"的内容画在上层,因此先画另一条。 + const int order[2] = { (m_activeCursor == 2) ? 1 : 2, (m_activeCursor == 2) ? 2 : 1 }; + // ---- 光标竖线 + 顶部把手 ---- - for (int which = 1; which <= cursorCount; ++which) + for (int pass = 0; pass < cursorCount; ++pass) { + const int which = doubleMode ? order[pass] : 1; const double time = (which == 2) ? Tab().CursorTimeAlt() : Tab().CursorTime(); const float x = TimeToX(time); if (x < m_plotRect.X - 2.0f || x > m_plotRect.X + m_plotRect.Width + 2.0f) { + // 光标滞留在可视区外:不画竖线(其取值/时间读数会在下面贴边显示)。 continue; } - const bool active = (m_hoverCursor == which) || (m_dragMode == PlotDragMode::CURSOR_MOVE && m_hoverCursor == which); + const bool hot = (m_hoverCursor == which); const std::uint32_t color = CursorColor(which); - // 光标纵向贯穿全部网格,并延伸到时间轴底部,便于与刻度对齐读数。 const float lineTop = m_plotRect.Y; const float lineBottom = m_timeAxisRect.Y + m_timeAxisRect.Height; - UiDrawLine(g, x, lineTop, x, lineBottom, color, (active ? 2.0f : 1.4f) * m_scale); + UiDrawLine(g, x, lineTop, x, lineBottom, color, (hot ? 2.0f : 1.4f) * m_scale); - // 顶部三角把手,提示该线可以被拖拽。(光标 2 用朝下的把手与光标 1 区分) + // 顶部三角把手(两条光标样式一致,靠颜色区分)。 const float arm = 5.0f * m_scale; gd::PointF points[3]; - if (which == 1) - { - points[0] = gd::PointF(x - arm, lineTop); - points[1] = gd::PointF(x + arm, lineTop); - points[2] = gd::PointF(x, lineTop + arm * 1.4f); - } - else - { - const float bottom = lineTop + arm * 1.4f + arm * 1.4f; - points[0] = gd::PointF(x - arm, bottom); - points[1] = gd::PointF(x + arm, bottom); - points[2] = gd::PointF(x, lineTop); - } + 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(color)); g.FillPolygon(&handle, points, 3); } - // ---- 直接在光标穿过的数据点上标注取值 ---- - // 每个网格里每条可见信号都标注一次,颜色与信号自身颜色对应; - // 双光标时右侧光标额外显示“当前值 , ±增量”。 + // ---- 数据点取值标注 ---- for (int pass = 0; pass < cursorCount; ++pass) { - const int which = doubleMode ? ((pass == 0) ? leftWhich : rightWhich) : 1; + const int which = doubleMode ? order[pass] : 1; const double time = (which == 2) ? Tab().CursorTimeAlt() : Tab().CursorTime(); const double otherTime = (which == 2) ? Tab().CursorTime() : Tab().CursorTimeAlt(); - const bool showDelta = doubleMode && (which == rightWhich); - const float x = TimeToX(time); - if (x < m_plotRect.X - 2.0f || x > m_plotRect.X + m_plotRect.Width + 2.0f) - { - continue; - } + const bool showDelta = doubleMode && (which == order[1]); + + // 光标在可视区外时,把标注"钉"在对应的网格边上,保证取值仍然可见。 + const float pinX = CursorDisplayX(which); + const float rawX = TimeToX(time); + const bool pinned = (std::fabs(rawX - pinX) > 0.5f); for (std::size_t laneIndex = 0; laneIndex < lanes.size(); ++laneIndex) { @@ -3755,16 +3992,34 @@ namespace jdi const std::uint32_t signalColor = StyleColor(*resolved.style); - // 数据点:底色环 + 信号色实心点。 + // 数据点:底色环 + 信号色实心点(贴边时改画成三角指示方向)。 const float radius = 3.4f * m_scale; gd::SolidBrush ring(ToGdiColor(m_theme->cardBg)); gd::SolidBrush fill(ToGdiColor(signalColor)); - g.FillEllipse(&ring, x - radius - 1.2f * m_scale, y - radius - 1.2f * m_scale, - (radius + 1.2f * m_scale) * 2.0f, (radius + 1.2f * m_scale) * 2.0f); - g.FillEllipse(&fill, x - radius, y - radius, radius * 2.0f, radius * 2.0f); + if (pinned) + { + const float arm = 5.0f * m_scale; + const bool offLeft = (rawX < pinX); + const float baseX = offLeft ? (pinX - arm) : (pinX + arm); + gd::PointF tri[3]; + tri[0] = gd::PointF(baseX, y - arm); + tri[1] = gd::PointF(baseX, y + arm); + tri[2] = gd::PointF(pinX, y); + g.FillPolygon(&ring, tri, 3); + gd::PointF inner[3]; + inner[0] = gd::PointF(baseX + (offLeft ? 1.4f : -1.4f) * m_scale, y - arm * 0.72f); + inner[1] = gd::PointF(baseX + (offLeft ? 1.4f : -1.4f) * m_scale, y + arm * 0.72f); + inner[2] = gd::PointF(pinX - (offLeft ? -1.4f : 1.4f) * m_scale, y); + g.FillPolygon(&fill, inner, 3); + } + else + { + g.FillEllipse(&ring, pinX - radius - 1.2f * m_scale, y - radius - 1.2f * m_scale, + (radius + 1.2f * m_scale) * 2.0f, (radius + 1.2f * m_scale) * 2.0f); + g.FillEllipse(&fill, pinX - radius, y - radius, radius * 2.0f, radius * 2.0f); + } - // 取值标注:底色用信号色、文字用反色,保证在任何波形上都清晰可读, - // 并让“读数 ↔ 信号”一一对应。 + // 取值标注:底色用信号色、文字用反色,并加一层细描边提升对比度。 std::wstring text = FormatValue(value); if (showDelta) { @@ -3782,14 +4037,14 @@ namespace jdi const float textWidth = UiTextWidth(g, text, m_fonts->monoSmall) + 10.0f * m_scale; const float textHeight = 17.0f * m_scale; - // 默认标在数据点右侧;右侧空间不足时改到左侧。 - float labelLeft = x + radius + 4.0f * m_scale; + float labelLeft = pinX + radius + 4.0f * m_scale; if (labelLeft + textWidth > m_plotRect.X + m_plotRect.Width - 2.0f * m_scale) { - labelLeft = x - radius - 4.0f * m_scale - textWidth; + labelLeft = pinX - radius - 4.0f * m_scale - textWidth; } - labelLeft = std::max(m_plotRect.X + 2.0f * m_scale, - std::min(labelLeft, m_plotRect.X + m_plotRect.Width - textWidth - 2.0f * m_scale)); + labelLeft = std::max( + m_plotRect.X + 2.0f * m_scale, + std::min(labelLeft, m_plotRect.X + m_plotRect.Width - textWidth - 2.0f * m_scale)); float labelTop = y - textHeight * 0.5f; labelTop = std::max(clipTop + 1.0f * m_scale, std::min(labelTop, clipBottom - textHeight - 1.0f * m_scale)); @@ -3797,23 +4052,24 @@ namespace jdi UiFillRoundRect(g, labelRect, 3.0f * m_scale, WithAlpha(signalColor, 0xF0)); const bool dark = IsDarkColor(signalColor); + DrawLabelOutline(g, labelRect, 3.0f * m_scale, dark); UiText(g, text, m_fonts->monoSmall, labelRect, dark ? 0xFFFDF6E3u : 0xFF073642u, TextAlign::CENTER); } } } - // ---- 时间读数:改到绘图区底部 ---- - if (m_editTarget == PlotEditTarget::CURSOR_TIME) + // ---- 底部:时间读数 + 双光标差值 ---- + if (m_editTarget == PlotEditTarget::CURSOR_TIME || + m_editTarget == PlotEditTarget::CURSOR_SPAN) { PaintEditField(g); return; } - // 时间读数固定在绘图区底部,单/双光标都始终显示, - // 这样不必把鼠标移到光线上就能读到时间。 - for (int which = 1; which <= cursorCount; ++which) + for (int pass = 0; pass < cursorCount; ++pass) { + const int which = doubleMode ? order[pass] : 1; const gd::RectF labelRect = CursorLabelRect(which); if (labelRect.Width <= 0.0f) { @@ -3824,10 +4080,31 @@ namespace jdi UiFillRoundRect(g, labelRect, 3.0f * m_scale, highlighted ? color : WithAlpha(color, 0xE0)); const bool dark = IsDarkColor(color); + DrawLabelOutline(g, labelRect, 3.0f * m_scale, dark); UiText(g, FormatValue(which == 2 ? Tab().CursorTimeAlt() : Tab().CursorTime()), m_fonts->monoSmall, labelRect, dark ? 0xFFFDF6E3u : 0xFF073642u, TextAlign::CENTER); } + + // 双光标差值:显示在两条时间读数中间(时间轴上方;过近时落到主时间轴上)。 + if (doubleMode) + { + bool insideAxis = false; + const gd::RectF spanRect = CursorSpanRect(insideAxis); + 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); + } + } + else + { + m_cursorSpanRect = gd::RectF(); + } } void PlotView::PaintEditField(gd::Graphics& g) diff --git a/Utilities/Src/Theme.cpp b/Utilities/Src/Theme.cpp index 04a164f..3bb1c88 100644 --- a/Utilities/Src/Theme.cpp +++ b/Utilities/Src/Theme.cpp @@ -62,8 +62,8 @@ namespace jdi theme.gridStrong = MakeArgb(0xC3B896u); theme.axisLine = MakeArgb(kBase1); theme.cursor = MakeArgb(kOrange); - // 第二光标用 Solarized 青色:与橙色同族饱和度、对比清晰又不刺眼。 - theme.cursorAlt = MakeArgb(kCyan); + // 第二光标用 Solarized 洋红:与橙色同族暖色但更鲜艳,对比清晰又不刺眼。 + theme.cursorAlt = MakeArgb(kMagenta); theme.readoutBg = MakeArgb(0xF6EFDCu); theme.danger = MakeArgb(kRed); theme.warning = MakeArgb(kYellow);