From 4e295a2a7dc16cfb1f1bbd3b4154abd8911305be Mon Sep 17 00:00:00 2001 From: "DeepSeek V4.1 Flash" <> Date: Wed, 30 Sep 2026 11:02:09 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E5=9B=BE=E4=BE=8B?= =?UTF-8?q?=E6=94=B9=E5=90=8D=E4=B8=8D=E5=90=8C=E6=AD=A5=E3=80=81=E7=BB=86?= =?UTF-8?q?=E5=8C=96=E7=BD=91=E6=A0=BC=E6=A0=87=E5=B0=BA=E5=B9=B6=E6=94=AF?= =?UTF-8?q?=E6=8C=81=E6=A0=87=E7=AD=BE=E9=A1=B5=E9=87=8D=E6=8E=92=E4=B8=8E?= =?UTF-8?q?=E6=9B=B2=E7=BA=BF=E5=AE=9E=E8=89=B2=E8=B7=9F=E9=9A=8F=E6=8B=96?= =?UTF-8?q?=E6=8B=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 图例跟随重命名:绘图区图例文本改用信号当前显示名(Title), 左侧列表改名后网格图例立即同步。 - 标签页重排与改名:新增拖拽排序(按住标签左右拖动并显示插入指示条, 重排后保持原激活页)与双击内联改名,新增 MoveTab 正确处理激活下标。 修复改名后未重新布局导致标签页整条消失的问题(SetTabs 会重置标签矩形)。 双击判定改为自行按双击时间识别,不再依赖系统 WM_LBUTTONDBLCLK。 - 信号拖拽:虚影改用原信号颜色与线宽(不再画成虚线),保持原有线段/散点样式; 横向与纵向均自由跟随鼠标位移;拖拽首帧位移为零,虚影始终贴合在鼠标下方。 - 网格与标尺细化:时间轴与幅值轴期望刻度数提高,并在相邻主刻度之间 补绘 5 等分细线,便于目视估读。 - 图例定位与交互:图例移到各网格左上角,与标尺数字冲突时优先显示图例 (标尺数值主动避让);图例条目支持单击选中信号(与左侧列表联动、 样式区随之切换目标)与双击重命名信号。 - 网格划分:合并网格后新勾选的信号不再被并入那个唯一的合并网格, 而是自动切换为自定义划分并独占一个新网格。 - 工具栏:「每信号一格」改名为「拆分信号网格」。 - 清理:移除已无引用的 UiDrawArrowIcon 与 m_styleContentRect。 --- Components/Application/Inc/Application.hpp | 13 + Components/Application/Src/Application.cpp | 118 +++++- Components/PlotView/Inc/PlotView.hpp | 48 ++- Components/PlotView/Src/PlotView.cpp | 402 ++++++++++++++++++--- Components/SignalPanel/Inc/SignalPanel.hpp | 3 +- Components/SignalPanel/Src/SignalPanel.cpp | 3 - Components/TabBar/Inc/TabBar.hpp | 60 ++- Components/TabBar/Src/TabBar.cpp | 206 ++++++++++- Components/Toolbar/Src/Toolbar.cpp | 2 +- Utilities/Inc/PaintUtil.hpp | 11 - Utilities/Src/DataModel.cpp | 14 + Utilities/Src/PaintUtil.cpp | 29 -- 12 files changed, 800 insertions(+), 109 deletions(-) diff --git a/Components/Application/Inc/Application.hpp b/Components/Application/Inc/Application.hpp index eb4bb7d..5600730 100644 --- a/Components/Application/Inc/Application.hpp +++ b/Components/Application/Inc/Application.hpp @@ -207,6 +207,19 @@ namespace jdi */ void DispatchMenuResult(const MenuResult& result); + /** + * @brief 调整标签页顺序(拖拽重排)。 + * @param [IN] from 被移动的标签页下标。 + * @param [IN] target 目标插入位置(插入到该下标之前)。 + */ + void MoveTab(int from, int target); + + /** + * @brief 应用绘图区提交的信号重命名请求。 + * @return 应用了重命名返回 true。 + */ + bool ApplyPendingRename(); + /** * @brief 弹出文件选择对话框并加载所选文件。 */ diff --git a/Components/Application/Src/Application.cpp b/Components/Application/Src/Application.cpp index 6ebae0a..f19b3c8 100644 --- a/Components/Application/Src/Application.cpp +++ b/Components/Application/Src/Application.cpp @@ -955,12 +955,94 @@ namespace jdi case TabBarResult::Type::CONTEXT: ShowTabContextMenu(result); break; + case TabBarResult::Type::REORDER: + MoveTab(result.index, result.targetIndex); + break; + case TabBarResult::Type::RENAME: + if (result.index >= 0 && result.index < static_cast(m_tabs.size()) && !result.text.empty()) + { + m_tabs[static_cast(result.index)].SetName(result.text); + RefreshTabBar(); + // SetTabs 会重置各标签的矩形,必须重新布局才能把它们算回来。 + Relayout(); + InvalidateRect(m_hwnd, nullptr, TRUE); + } + break; case TabBarResult::Type::NONE: default: break; } } + void Application::MoveTab(int from, int target) + { + if (from < 0 || from >= static_cast(m_tabs.size())) + { + return; + } + if (target < 0 || target > static_cast(m_tabs.size())) + { + return; + } + + // 拖拽语义:target 表示「插入到该下标之前」;向后拖时需扣除自身占位。 + int destination = target; + if (destination > from) + { + --destination; + } + destination = std::max(0, std::min(destination, static_cast(m_tabs.size()) - 1)); + if (destination == from) + { + return; + } + + // 保持当前激活的标签页在重排后仍处于激活状态。 + TabConfig moved = m_tabs[static_cast(from)]; + const int activeIndex = m_activeTab; + + m_tabs.erase(m_tabs.begin() + from); + m_tabs.insert(m_tabs.begin() + destination, std::move(moved)); + + int newActive = activeIndex; + if (activeIndex == from) + { + newActive = destination; + } + else if (from < activeIndex && destination >= activeIndex) + { + --newActive; + } + else if (from > activeIndex && destination <= activeIndex) + { + ++newActive; + } + + SetActiveTab(newActive); + InvalidateRect(m_hwnd, nullptr, TRUE); + } + + bool Application::ApplyPendingRename() + { + SignalRef ref; + std::wstring name; + if (!m_plot.TakePendingRename(ref, name)) + { + return false; + } + + if (!m_dataSet.SetSignalDisplayName(ref.fileIndex, ref.signalIndex, name)) + { + return false; + } + + // 改名只影响显示,但需要刷新左侧列表与绘图区图例。 + m_panel.InvalidateRows(); + SetStatusMessage(L"信号已重命名为:" + name, false); + InvalidateRect(m_hwnd, nullptr, TRUE); + return true; + } + void Application::ShowTabContextMenu(const TabBarResult& result) { if (result.index < 0 || result.index >= static_cast(m_tabs.size())) @@ -1388,6 +1470,7 @@ namespace jdi if (m_plot.IsEditing() && !m_plot.HitTest(x, y)) { m_plot.OnEditKey(VK_RETURN); + ApplyPendingRename(); } // 点击面板之外的位置时收起面板内展开的自定义颜色面板。 @@ -1444,6 +1527,8 @@ namespace jdi m_activeTarget = Target::PLOT; if (m_plot.OnLButtonDown(x, y)) { + // 绘图区内的点击也可能提交图例改名,需要立刻落到数据层。 + ApplyPendingRename(); InvalidateRect(hwnd, nullptr, FALSE); } return 0; @@ -1504,7 +1589,12 @@ namespace jdi const int x = GET_X_LPARAM(lParam); const int y = GET_Y_LPARAM(lParam); bool changed = false; - if (m_panel.HitTest(x, y)) + if (m_tabBar.HitTest(x, y)) + { + SetFocus(hwnd); + changed = m_tabBar.OnDoubleClick(x, y); + } + else if (m_panel.HitTest(x, y)) { changed = m_panel.OnDoubleClick(x, y); } @@ -1620,7 +1710,15 @@ namespace jdi break; } case WM_KEYDOWN: - // 数值输入状态优先接管按键(回车提交、Esc 取消、退格删除)。 + // 标签栏重命名与数值输入优先接管按键(回车提交、Esc 取消、退格删除)。 + if (m_tabBar.IsEditing()) + { + TabBarResult tabResult; + m_tabBar.OnEditKey(wParam, tabResult); + OnTabBarResult(tabResult); + InvalidateRect(hwnd, nullptr, FALSE); + return 0; + } if (m_panel.IsEditing()) { if (m_panel.OnEditKey(wParam)) @@ -1631,10 +1729,9 @@ namespace jdi } if (m_plot.IsEditing()) { - if (m_plot.OnEditKey(wParam)) - { - InvalidateRect(hwnd, nullptr, FALSE); - } + m_plot.OnEditKey(wParam); + ApplyPendingRename(); + InvalidateRect(hwnd, nullptr, FALSE); return 0; } if (HandleKeyDown(wParam)) @@ -1646,6 +1743,14 @@ namespace jdi case WM_CHAR: { // 输入状态由对应控件自行校验可接受的字符(数值仅收数字与小数点,名称可收任意可打印字符)。 + if (m_tabBar.IsEditing()) + { + if (m_tabBar.OnEditChar(static_cast(wParam))) + { + InvalidateRect(hwnd, nullptr, FALSE); + } + return 0; + } if (m_panel.IsEditing()) { if (m_panel.OnEditChar(static_cast(wParam))) @@ -1658,6 +1763,7 @@ namespace jdi { if (m_plot.OnEditChar(static_cast(wParam))) { + ApplyPendingRename(); InvalidateRect(hwnd, nullptr, FALSE); } return 0; diff --git a/Components/PlotView/Inc/PlotView.hpp b/Components/PlotView/Inc/PlotView.hpp index feb1d09..a4acb2e 100644 --- a/Components/PlotView/Inc/PlotView.hpp +++ b/Components/PlotView/Inc/PlotView.hpp @@ -37,7 +37,8 @@ namespace jdi NONE = 0, /**< 未处于输入状态。 */ CURSOR_TIME, /**< 编辑时间光标位置。 */ LANE_Y_MAX, /**< 编辑某网格的 Y 轴最大值。 */ - LANE_Y_MIN /**< 编辑某网格的 Y 轴最小值。 */ + LANE_Y_MIN, /**< 编辑某网格的 Y 轴最小值。 */ + SIGNAL_NAME /**< 编辑图例中某信号的显示名称。 */ }; /** @@ -259,6 +260,15 @@ namespace jdi */ bool IsEditing() const { return m_editTarget != PlotEditTarget::NONE; } + /** + * @brief 取出并清除待应用的信号重命名请求。 + * @param [OUT] ref 被重命名的信号引用。 + * @param [OUT] name 新的显示名称。 + * @return 存在待处理请求返回 true。 + * @note 绘图区只持有只读数据层,重命名需由宿主窗口落到 DataSet 上。 + */ + bool TakePendingRename(SignalRef& ref, std::wstring& name); + /** * @brief 处理编辑态下的字符输入。 * @param [IN] character 输入字符。 @@ -616,17 +626,51 @@ namespace jdi SignalRef m_hoverSignal; /**< 鼠标悬停命中的信号(曲线高亮)。 */ SignalRef m_dragSignal; /**< 正在被拖拽的信号。 */ - int m_dragTargetLane = -1; /**< 拖拽信号时的目标网格下标。 */ int m_dragMouseY = 0; /**< 拖拽过程中鼠标的当前纵坐标(虚影跟随)。 */ + int m_dragTargetLane = -1; /**< 拖拽信号时的目标网格下标。 */ + int m_dragMouseX = 0; /**< 拖拽过程中鼠标的当前横坐标(虚影跟随)。 */ + int m_dragMouseY = 0; /**< 拖拽过程中鼠标的当前纵坐标(虚影跟随)。 */ bool m_cursorHover = false; /**< 鼠标是否悬停在时间光标上。 */ PlotEditTarget m_editTarget = PlotEditTarget::NONE; /**< 当前内联输入目标。 */ bool m_editFresh = false; /**< 输入框是否处于“首次输入整体替换”状态。 */ std::wstring m_editText; /**< 输入框当前文本。 */ int m_editLane = -1; /**< 内联输入所作用的网格下标。 */ + SignalRef m_editSignal; /**< 内联输入所作用的信号(图例改名)。 */ + gd::RectF m_editFieldRect; /**< 本次编辑输入框的矩形(用于点外提交判定)。 */ + SignalRef m_pendingRenameRef; /**< 待宿主窗口应用的信号重命名引用。 */ + std::wstring m_pendingRenameName; /**< 待宿主窗口应用的信号新名称。 */ + bool m_pendingRename = false; /**< 是否存在待应用的重命名请求。 */ int m_hoverLane = -1; /**< 当前悬停网格下标。 */ int m_mouseX = 0; /**< 最近一次鼠标横坐标(用于悬停提示定位)。 */ int m_mouseY = 0; /**< 最近一次鼠标纵坐标(用于悬停提示定位)。 */ std::vector m_lanes; /**< 当前网格绘制信息集合。 */ + + /** + * @brief 图例条目的命中信息(用于点选与双击改名)。 + */ + struct LegendHit + { + SignalRef ref; /**< 对应信号。 */ + gd::RectF rect; /**< 条目矩形(已含色块与文本)。 */ + }; + + std::vector m_legendHits; /**< 最近一次绘制时记录的图例条目矩形。 */ + + /** + * @brief 查询坐标命中的图例条目。 + * @param [IN] x 客户区横坐标。 + * @param [IN] y 客户区纵坐标。 + * @return 命中的信号引用,未命中返回无效引用。 + */ + SignalRef LegendAtPoint(int x, int y) const; + + /** + * @brief 计算指定网格图例带所占据的矩形(用于让标尺数字避让)。 + * @param [IN] laneIndex 网格下标。 + * @param [IN] lane 网格绘制信息。 + * @return 图例带矩形,无可见信号时返回空矩形。 + */ + gd::RectF LegendBandRect(int laneIndex, const LaneVisual& lane) const; }; } // namespace jdi diff --git a/Components/PlotView/Src/PlotView.cpp b/Components/PlotView/Src/PlotView.cpp index 0621b37..1347840 100644 --- a/Components/PlotView/Src/PlotView.cpp +++ b/Components/PlotView/Src/PlotView.cpp @@ -11,8 +11,9 @@ namespace jdi namespace { - const int kTimeTickTarget = 8; /**< 时间轴期望刻度数。 */ - const int kYTickTarget = 4; /**< 幅值轴期望刻度数。 */ + const int kTimeTickTarget = 10; /**< 时间轴期望刻度数。 */ + const int kYTickTarget = 6; /**< 幅值轴期望刻度数。 */ + const int kMinorDivisions = 5; /**< 相邻主刻度之间的等分数(用于估读)。 */ const float kScrollBarThickness = 9.0f; /**< 滚动条宽度(逻辑像素)。 */ const float kLanePadding = 8.0f; /**< 网格内边距。 */ const size_t kMaxPolylinePoints = 20000; /**< 单条折线最大顶点数。 */ @@ -25,6 +26,10 @@ namespace jdi const float kLaneDividerWidth = 2.0f; /**< 网格之间分界线的宽度。 */ const float kSignalHitTolerance = 6.0f; /**< 信号曲线命中容差(逻辑像素)。 */ const float kLaneLabelPad = 6.0f; /**< 网格内侧标尺带的内边距。 */ + const float kLegendHeight = 19.0f; /**< 图例条目高度(逻辑像素)。 */ + const float kLegendPad = 5.0f; /**< 图例相对网格左上角的内缩。 */ + const float kLegendSwatchSize = 8.0f; /**< 图例色块尺寸。 */ + const float kLegendItemGap = 14.0f; /**< 图例条目之间的间距。 */ /** * @brief 将数值限制到合法区间。 @@ -47,13 +52,13 @@ namespace jdi } /** - * @brief 组装图例文本:直接使用列名原文(已含序号与括号描述)。 + * @brief 组装图例文本:使用信号当前显示名(用户重命名后立即生效)。 * @param [IN] data 信号数据。 * @return 图例文本。 */ std::wstring BuildLegendText(const SignalData& data) { - return data.name; + return data.Title(); } /** @@ -837,6 +842,17 @@ namespace jdi return gd::RectF(left, m_plotRect.Y + 2.0f * m_scale, width, height); } + if (m_editTarget == PlotEditTarget::SIGNAL_NAME) + { + // 图例改名:输入框直接落在被双击的图例条目位置上。 + if (m_editFieldRect.Width <= 0.0f) + { + return gd::RectF(); + } + const float nameWidth = std::max(width, m_editFieldRect.Width); + return gd::RectF(m_editFieldRect.X, m_editFieldRect.Y, nameWidth, height); + } + // 网格 Y 轴上下限输入框贴在该网格内侧标尺带下方。 if (m_editLane < 0 || m_editLane >= static_cast(m_lanes.size())) { @@ -854,6 +870,12 @@ namespace jdi { return false; } + // 切换编辑目标时清掉上一轮残留(图例改名需由调用方在进入前先设置 m_editSignal)。 + if (target != PlotEditTarget::SIGNAL_NAME) + { + m_editSignal = SignalRef(); + m_editFieldRect = gd::RectF(); + } m_editTarget = target; m_editLane = laneIndex; m_editText = initialText; @@ -861,6 +883,20 @@ namespace jdi return true; } + bool PlotView::TakePendingRename(SignalRef& ref, std::wstring& name) + { + if (!m_pendingRename) + { + return false; + } + ref = m_pendingRenameRef; + name = m_pendingRenameName; + m_pendingRename = false; + m_pendingRenameRef = SignalRef(); + m_pendingRenameName.clear(); + return true; + } + bool PlotView::EndEdit(bool commit) { if (m_editTarget == PlotEditTarget::NONE) @@ -870,8 +906,28 @@ namespace jdi const PlotEditTarget target = m_editTarget; const int laneIndex = m_editLane; + const SignalRef editSignal = m_editSignal; const std::wstring text = Trim(m_editText); + m_editTarget = PlotEditTarget::NONE; + m_editLane = -1; + m_editFresh = false; + m_editSignal = SignalRef(); + m_editFieldRect = gd::RectF(); + m_editText.clear(); + + // 信号改名:交给宿主窗口落到数据层,绘图区只持有只读数据。 + if (target == PlotEditTarget::SIGNAL_NAME) + { + if (commit && !text.empty() && editSignal.IsValid()) + { + m_pendingRenameRef = editSignal; + m_pendingRenameName = text; + m_pendingRename = true; + } + return true; + } + double parsed = 0.0; bool parsedOk = false; if (commit && !text.empty()) @@ -881,11 +937,6 @@ namespace jdi parsedOk = (stop != text.c_str()) && (parsed == parsed) && std::isfinite(parsed); } - m_editTarget = PlotEditTarget::NONE; - m_editLane = -1; - m_editFresh = false; - m_editText.clear(); - if (!parsedOk) { return true; @@ -965,6 +1016,25 @@ namespace jdi return true; } + // 信号改名接受任意可见字符(保留中文、空格与括号等原始信息)。 + if (m_editTarget == PlotEditTarget::SIGNAL_NAME) + { + if (character < L' ') + { + return true; + } + if (m_editFresh) + { + m_editText.clear(); + m_editFresh = false; + } + if (m_editText.size() < 96) + { + m_editText.push_back(character); + } + 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'); @@ -1185,6 +1255,7 @@ namespace jdi { const int target = LaneAtY(y); m_dragMoved = true; + m_dragMouseX = x; m_dragMouseY = y; if (target != m_dragTargetLane) { @@ -1215,10 +1286,15 @@ namespace jdi } // 常规模式下精确检测鼠标是否贴近某条信号曲线,用于给出可拖拽提示。 + // 图例优先于曲线:图例条目本身就是信号的可点选目标(与左侧信号列表一致)。 SignalRef hoverSignal; if (Tab().ZoomMode() == PlotZoomMode::NONE && !m_cursorHover) { - hoverSignal = HitTestSignal(x, y); + hoverSignal = LegendAtPoint(x, y); + if (!hoverSignal.IsValid()) + { + hoverSignal = HitTestSignal(x, y); + } } if (hoverSignal != m_hoverSignal) { @@ -1298,12 +1374,27 @@ namespace jdi return true; } + // 常规模式下:点击图例条目等价于在左侧列表中选中该信号(样式区随之切换目标)。 + if (mode == PlotZoomMode::NONE) + { + const SignalRef legendHit = LegendAtPoint(x, y); + if (legendHit.IsValid()) + { + Tab().SetSelected(legendHit); + m_dragMode = PlotDragMode::NONE; + return true; + } + } + // 常规模式下:先检测是否落在信号曲线上;只有命中提示时才允许拖拽该信号。 const SignalRef hit = HitTestSignal(x, y); if (hit.IsValid()) { m_dragSignal = hit; m_dragTargetLane = Tab().LaneOfSignal(hit); + Tab().SetSelected(hit); + m_dragMouseX = x; + m_dragMouseY = y; m_dragMode = PlotDragMode::MOVE_SIGNAL; return true; } @@ -1407,6 +1498,29 @@ namespace jdi return BeginEdit(PlotEditTarget::CURSOR_TIME, -1, FormatValue(Tab().CursorTime())); } + // 命中图例条目:进入信号改名(与左侧列表双击改名的交互一致)。 + const SignalRef legendHit = LegendAtPoint(x, y); + if (legendHit.IsValid()) + { + const ResolvedSignal resolved = ResolveSignal(legendHit); + if (resolved.data != nullptr) + { + Tab().SetSelected(legendHit); + m_editSignal = legendHit; + + // 先把输入框位置落在被双击的条目上,再进入编辑态。 + for (const LegendHit& hit : m_legendHits) + { + if (hit.ref == legendHit) + { + m_editFieldRect = hit.rect; + break; + } + } + return BeginEdit(PlotEditTarget::SIGNAL_NAME, -1, resolved.data->Title()); + } + } + // 其它位置:回到初始状态(自适应 + 退出缩放模式)。 FitAll(); return true; @@ -1560,6 +1674,9 @@ namespace jdi // 波形区域底色。 UiFillRect(g, m_plotRect, m_theme->cardBg); + // 图例命中矩形每帧重建,避免残留已消失的信号。 + m_legendHits.clear(); + for (std::size_t index = 0; index < m_lanes.size(); ++index) { PaintLane(g, static_cast(index), m_lanes[index], timeTicks); @@ -1657,6 +1774,28 @@ namespace jdi std::vector yTicks; BuildTicks(lane.yMin, lane.yMax, yStep, yTicks); + // 五等分估读线:在相邻主刻度之间再细分,便于目视读取中间值。 + const double yMinorStep = yStep / static_cast(kMinorDivisions); + std::vector yMinorTicks; + BuildTicks(lane.yMin, lane.yMax, yMinorStep, yMinorTicks); + for (const double tick : yMinorTicks) + { + // 与主刻度重合的位置跳过(主刻度会以更重的线条绘制)。 + const double majorIndex = tick / yStep; + if (std::fabs(majorIndex - std::floor(majorIndex + 0.5)) < 1.0e-6) + { + continue; + } + + const float y = ValueToY(tick, lane); + if (y < lane.rect.Y - 1.0f || y > lane.rect.Y + lane.rect.Height + 1.0f) + { + continue; + } + UiDrawLine(g, lane.rect.X, y, lane.rect.X + lane.rect.Width, y, + WithAlpha(m_theme->gridWeak, 0x70), 1.0f); + } + for (const double tick : yTicks) { const float y = ValueToY(tick, lane); @@ -1670,7 +1809,29 @@ namespace jdi zeroLine ? m_theme->gridStrong : m_theme->gridWeak, 1.0f); } - // 时间网格线。 + // 时间网格线(同样补充五等分估读线)。 + const double timeSpan = Tab().ViewMax() - Tab().ViewMin(); + const double timeMajorStep = NiceStep((timeSpan > 0.0) ? timeSpan : 1.0, kTimeTickTarget); + const double timeMinorStep = timeMajorStep / static_cast(kMinorDivisions); + std::vector timeMinorTicks; + BuildTicks(Tab().ViewMin(), Tab().ViewMax(), timeMinorStep, timeMinorTicks); + for (const double tick : timeMinorTicks) + { + const double majorIndex = tick / timeMajorStep; + if (std::fabs(majorIndex - std::floor(majorIndex + 0.5)) < 1.0e-6) + { + continue; + } + + const float x = TimeToX(tick); + if (x < lane.rect.X || x > lane.rect.X + lane.rect.Width) + { + continue; + } + UiDrawLine(g, x, lane.rect.Y, x, lane.rect.Y + lane.rect.Height, + WithAlpha(m_theme->gridWeak, 0x70), 1.0f); + } + for (const double tick : timeTicks) { const float x = TimeToX(tick); @@ -1784,6 +1945,9 @@ namespace jdi // 内侧纵轴标尺作为覆盖层绘制:每个刻度数值带半透明底,保证在波形之上仍清晰可读, // 同时只在数值背后遮挡波形,不牺牲波形本身的可见范围。 { + // 图例固定占用网格左上角,冲突时优先显示图例,故落在图例带内的刻度数值直接跳过。 + const gd::RectF legendBand = LegendBandRect(laneIndex, lane); + const bool showLabels = (lane.rect.Height >= 34.0f * m_scale); float lastLabelY = -1.0e9f; @@ -1816,6 +1980,15 @@ namespace jdi { continue; } + // 与图例带冲突时让位(图例优先)。 + if (legendBand.Width > 0.0f && + pill.X < legendBand.X + legendBand.Width && + pill.X + pill.Width > legendBand.X && + pill.Y < legendBand.Y + legendBand.Height && + pill.Y + pill.Height > legendBand.Y) + { + continue; + } // 文本必须完整落在可见区内,否则会越过绘图区顶部(GDI 文本不受 GDI+ 裁剪约束)。 if (!IsFullyVisible(pill, clipTop, clipBottom)) { @@ -1842,6 +2015,57 @@ namespace jdi g.Restore(saveState); } + gd::RectF PlotView::LegendBandRect(int laneIndex, const LaneVisual& lane) const + { + if (m_tab == nullptr || laneIndex < 0 || laneIndex >= static_cast(m_tab->Lanes().size())) + { + return gd::RectF(); + } + + // 逐条累加已可见图例的宽度,得到整条图例带(用于让标尺数字避让)。 + float width = 0.0f; + for (const SignalRef& ref : m_tab->Lanes()[static_cast(laneIndex)].signals) + { + const ResolvedSignal resolved = ResolveSignal(ref); + if (resolved.data == nullptr || resolved.style == nullptr || !resolved.style->visible) + { + continue; + } + + std::wstring label = BuildLegendText(*resolved.data); + if (LaneSpansMultipleFiles(laneIndex) && resolved.file != nullptr) + { + label += L" · " + resolved.file->Title(); + } + const float textWidth = UiMeasureTextWidth(m_fonts->uiSmall, label); + width += kLegendSwatchSize * m_scale + 5.0f * m_scale + textWidth + kLegendItemGap * m_scale; + } + + if (width <= 0.0f) + { + return gd::RectF(); + } + + const float left = lane.rect.X + kLegendPad * m_scale; + const float maxWidth = std::max(0.0f, lane.rect.Width - kLegendPad * 2.0f * m_scale); + return gd::RectF(left, lane.rect.Y + kLegendPad * m_scale - 1.0f * m_scale, + std::min(width + 6.0f * m_scale, maxWidth), kLegendHeight * m_scale + 2.0f * m_scale); + } + + SignalRef PlotView::LegendAtPoint(int x, int y) const + { + const float fx = static_cast(x); + const float fy = static_cast(y); + for (const LegendHit& hit : m_legendHits) + { + if (UiPointInRect(hit.rect, fx, fy)) + { + return hit.ref; + } + } + return SignalRef(); + } + void PlotView::PaintLaneLegend(gd::Graphics& g, int laneIndex, const LaneVisual& lane, float visibleTop, float visibleBottom) { @@ -1850,9 +2074,10 @@ namespace jdi return; } - // 图例固定在网格顶部:网格被滚动出可视区时整体跳过,避免文字溢出到工具栏。 - const gd::RectF legendBand(lane.rect.X, lane.rect.Y + 4.0f * m_scale, lane.rect.Width, - 18.0f * m_scale); + // 图例固定在网格左上角:网格被滚动出可视区时整体跳过,避免文字溢出到工具栏。 + const gd::RectF legendBand(lane.rect.X + kLegendPad * m_scale, + lane.rect.Y + kLegendPad * m_scale, lane.rect.Width, + kLegendHeight * m_scale); if (!IsFullyVisible(legendBand, visibleTop, visibleBottom)) { return; @@ -1861,10 +2086,9 @@ namespace jdi const std::vector& laneSignals = m_tab->Lanes()[static_cast(laneIndex)].signals; const bool multiFile = LaneSpansMultipleFiles(laneIndex); - const float top = lane.rect.Y + 4.0f * m_scale; - const float height = 18.0f * m_scale; - // 图例从内侧标尺带的右侧开始,避免与纵轴刻度重叠。 - float cursorX = lane.rect.X + m_labelBandWidth + 6.0f * m_scale; + const float top = legendBand.Y; + const float height = kLegendHeight * m_scale; + float cursorX = legendBand.X; const float maxX = lane.rect.X + lane.rect.Width - kLanePadding * m_scale; bool anyVisible = false; @@ -1886,8 +2110,8 @@ namespace jdi { label += L" · " + resolved.file->Title(); } - const float itemWidth = 13.0f * m_scale + UiTextWidth(g, label, m_fonts->uiSmall) + - 14.0f * m_scale; + const float itemWidth = kLegendSwatchSize * m_scale + 5.0f * m_scale + + UiTextWidth(g, label, m_fonts->uiSmall) + kLegendItemGap * m_scale; if (cursorX + legendWidth + itemWidth > maxX) { break; @@ -1896,9 +2120,22 @@ namespace jdi } if (legendWidth > 0.0f) { - UiFillRoundRect(g, gd::RectF(cursorX - 5.0f * m_scale, top - 1.0f * m_scale, - legendWidth + 6.0f * m_scale, height + 2.0f * m_scale), - 3.0f * m_scale, WithAlpha(m_theme->cardBg, 0xD8)); + UiFillRoundRect(g, gd::RectF(cursorX - 4.0f * m_scale, top - 2.0f * m_scale, + legendWidth + 6.0f * m_scale, height + 4.0f * m_scale), + 3.0f * m_scale, WithAlpha(m_theme->cardBg, 0xE8)); + } + + // 记录本次绘制命中的条目矩形,供点击选中与双击改名使用。 + for (auto iterator = m_legendHits.begin(); iterator != m_legendHits.end();) + { + if (m_tab->LaneOfSignal(iterator->ref) == laneIndex) + { + iterator = m_legendHits.erase(iterator); + } + else + { + ++iterator; + } } // 绘制图例条目(与测量阶段保持一致的裁剪条件)。 @@ -1919,29 +2156,59 @@ namespace jdi label += L" · " + resolved.file->Title(); } const float textWidth = UiTextWidth(g, label, m_fonts->uiSmall); - const float itemWidth = 13.0f * m_scale + textWidth + 14.0f * m_scale; + const float itemWidth = + kLegendSwatchSize * m_scale + 5.0f * m_scale + textWidth + kLegendItemGap * m_scale; if (drawnWidth + itemWidth > legendWidth + 0.5f) { break; } drawnWidth += itemWidth; - const float swatchSize = 8.0f * m_scale; + const float itemLeft = cursorX; + const float swatchSize = kLegendSwatchSize * m_scale; + + // 图例条目与被选中的信号保持一致的高亮反馈(与左侧列表选中态呼应)。 + const bool selected = (m_tab->Selected() == ref); + const bool hovered = (ref == m_hoverSignal) || (ref == m_dragSignal); + if (selected || hovered) + { + UiFillRoundRect(g, gd::RectF(itemLeft - 3.0f * m_scale, top - 1.0f * m_scale, + itemWidth + 2.0f * m_scale, height + 2.0f * m_scale), + 3.0f * m_scale, selected ? m_theme->selection : m_theme->hover); + } + UiDrawSwatch(g, gd::RectF(cursorX, top + (height - swatchSize) * 0.5f, swatchSize, swatchSize), resolved.style->color, resolved.style->opacity, 1.5f * m_scale, m_scale); - cursorX += swatchSize + 5.0f * m_scale; - UiText(g, label, m_fonts->uiSmall, gd::RectF(cursorX, top, textWidth + 2.0f, height), - m_theme->text, TextAlign::LEFT); - cursorX += textWidth + 14.0f * m_scale; + // 改名编辑态:在原位置绘制输入框。 + LegendHit hit; + hit.ref = ref; + hit.rect = gd::RectF(itemLeft - 3.0f * m_scale, top - 2.0f * m_scale, + itemWidth + 2.0f * m_scale, height + 4.0f * m_scale); + m_legendHits.push_back(hit); + + if (m_editTarget == PlotEditTarget::SIGNAL_NAME && m_editSignal == ref) + { + m_editFieldRect = hit.rect; + UiDrawEditField(g, hit.rect, m_editText, m_editFresh, m_fonts->uiSmall, m_scale); + cursorX += itemWidth; + ++drawnCount; + continue; + } + + cursorX += swatchSize + 5.0f * m_scale; + UiText(g, label, m_fonts->uiSmall, + gd::RectF(cursorX, top, std::max(8.0f, textWidth + 2.0f), height), + selected ? m_theme->textStrong : m_theme->text, TextAlign::LEFT); + cursorX += textWidth + kLegendItemGap * m_scale; ++drawnCount; } if (!anyVisible) { UiText(g, L"(无可见信号)", m_fonts->uiSmall, - gd::RectF(lane.rect.X + m_labelBandWidth + 6.0f * m_scale, top, lane.rect.Width, height), - m_theme->textMuted, TextAlign::LEFT); + gd::RectF(legendBand.X, top, lane.rect.Width, height), m_theme->textMuted, + TextAlign::LEFT); } else if (drawnCount < visibleCount) { @@ -2238,36 +2505,71 @@ namespace jdi return; } - // 虚影:以源网格的幅值映射构建波形,并整体平移跟随鼠标,形成“拖着曲线走”的观感。 - LaneVisual ghostLane = m_lanes[static_cast(sourceLane)]; - const float follow = static_cast(m_dragMouseY - m_dragStartY); - ghostLane.rect.Y += follow; + // 虚影:以源网格的幅值映射构建波形,再按鼠标位移整体平移。 + // 第一帧位移为 0,因此虚影必然精确落在鼠标下方(曲线形态与抓住时完全一致); + // 之后纵向与横向均自由跟随,形成“拖着曲线走”的观感。 + const LaneVisual& sourceVisual = m_lanes[static_cast(sourceLane)]; + const float offsetX = static_cast(m_dragMouseX - m_dragStartX); + const float offsetY = static_cast(m_dragMouseY - m_dragStartY); { - const float clipTop = std::max(m_plotRect.Y, m_plotRect.Y); - const float clipHeight = m_plotRect.Height; const int saveState = g.Save(); - g.SetClip(gd::RectF(m_plotRect.X, clipTop, m_plotRect.Width, clipHeight), + g.SetClip(gd::RectF(m_plotRect.X, m_plotRect.Y, m_plotRect.Width, m_plotRect.Height), gd::CombineModeIntersect); std::vector points; std::vector breaks; - BuildPolyline(resolved, ghostLane, points, breaks); + BuildPolyline(resolved, sourceVisual, points, breaks); - gd::Pen ghost(ToGdiColor(WithAlpha(m_theme->accent, 0xC0)), - std::max(1.8f, resolved.style->lineWidth) * m_scale); - ghost.SetDashStyle(gd::DashStyleDash); - ghost.SetStartCap(gd::LineCapRound); - ghost.SetEndCap(gd::LineCapRound); - for (std::size_t k = 0; k < breaks.size(); ++k) + for (gd::PointF& point : points) { - const std::size_t start = breaks[k]; - const std::size_t stop = (k + 1 < breaks.size()) ? breaks[k + 1] : points.size(); - if (stop <= start + 1) + point.X += offsetX; + point.Y += offsetY; + } + + const DrawMode mode = resolved.style->mode; + const bool drawLine = (mode != DrawMode::SCATTER); + const bool drawScatter = (mode == DrawMode::SCATTER || mode == DrawMode::LINE_SCATTER); + + // 虚影保持原信号的颜色与样式(不再是虚线),仅以轻微透明区分“尚未落地”。 + const std::uint32_t ghostColor = WithAlpha(resolved.style->color, + static_cast( + std::max(0.35, static_cast(resolved.style->opacity)) * + 255.0 + 0.5)); + + if (drawLine && !breaks.empty()) + { + gd::Pen ghost(ToGdiColor(ghostColor), + std::max(1.0f, resolved.style->lineWidth) * m_scale); + ghost.SetLineJoin(gd::LineJoinRound); + ghost.SetStartCap(gd::LineCapRound); + ghost.SetEndCap(gd::LineCapRound); + for (std::size_t k = 0; k < breaks.size(); ++k) { - continue; + const std::size_t start = breaks[k]; + const std::size_t stop = (k + 1 < breaks.size()) ? breaks[k + 1] : points.size(); + if (stop <= start + 1) + { + continue; + } + g.DrawLines(&ghost, points.data() + start, static_cast(stop - start)); + } + } + + if (drawScatter && !points.empty()) + { + const float radius = static_cast( + ClampDouble(static_cast(resolved.style->lineWidth) * kScatterRadiusScale, + kMinScatterRadius, kMaxScatterRadius) * + m_scale); + const std::size_t scatterStride = + std::max(1, points.size() / kMaxScatterPoints); + gd::SolidBrush brush(ToGdiColor(ghostColor)); + for (std::size_t k = 0; k < points.size(); k += scatterStride) + { + g.FillEllipse(&brush, points[k].X - radius, points[k].Y - radius, radius * 2.0f, + radius * 2.0f); } - g.DrawLines(&ghost, points.data() + start, static_cast(stop - start)); } g.Restore(saveState); diff --git a/Components/SignalPanel/Inc/SignalPanel.hpp b/Components/SignalPanel/Inc/SignalPanel.hpp index 248c01d..bec23ea 100644 --- a/Components/SignalPanel/Inc/SignalPanel.hpp +++ b/Components/SignalPanel/Inc/SignalPanel.hpp @@ -492,9 +492,8 @@ namespace jdi std::array m_modeRects; /**< 绘制样式按钮矩形。 */ - // 样式区固定四行:颜色 / 样式 / 透明度 / 线宽。各行控件共用同一内容列宽度。 + // 样式区固定四行:颜色 / 样式 / 透明度 / 线宽。各行控件共用同一内容列宽度与左边界。 std::array m_styleRowRects; /**< 四行整体矩形(含标签列)。 */ - gd::RectF m_styleContentRect; /**< 四行共用的内容列矩形。 */ gd::RectF m_opacityTrackRect; /**< 不透明度滑块轨道矩形。 */ gd::RectF m_opacityValueRect; /**< 不透明度数值文本矩形。 */ diff --git a/Components/SignalPanel/Src/SignalPanel.cpp b/Components/SignalPanel/Src/SignalPanel.cpp index ed1b697..4615a0f 100644 --- a/Components/SignalPanel/Src/SignalPanel.cpp +++ b/Components/SignalPanel/Src/SignalPanel.cpp @@ -414,9 +414,6 @@ namespace jdi const float groupLeft = m_styleRect.X + padH; const float contentLeft = groupLeft + labelWidth + labelGap; - m_styleContentRect = gd::RectF(contentLeft, firstTop, contentWidth, - kStyleRowCount * rowHeight + (kStyleRowCount - 1.0f) * - (kStyleRowGap * m_scale)); for (int index = 0; index < static_cast(kStyleRowCount); ++index) { m_styleRowRects[static_cast(index)] = diff --git a/Components/TabBar/Inc/TabBar.hpp b/Components/TabBar/Inc/TabBar.hpp index 9ee2488..7d26124 100644 --- a/Components/TabBar/Inc/TabBar.hpp +++ b/Components/TabBar/Inc/TabBar.hpp @@ -28,11 +28,15 @@ namespace jdi SELECT, /**< 切换到指定标签页。 */ CLOSE, /**< 关闭指定标签页。 */ ADD, /**< 新建标签页。 */ - CONTEXT /**< 请求弹出标签页上下文菜单。 */ + CONTEXT, /**< 请求弹出标签页上下文菜单。 */ + REORDER, /**< 拖拽调整标签页顺序。 */ + RENAME /**< 提交标签页重命名。 */ }; Type type = Type::NONE; /**< 动作类型。 */ int index = -1; /**< 标签页下标(ADD 时无意义)。 */ + int targetIndex = -1; /**< REORDER 的目标位置。 */ + std::wstring text; /**< RENAME 的新名称。 */ int screenX = 0; /**< 上下文菜单请求的屏幕横坐标。 */ int screenY = 0; /**< 上下文菜单请求的屏幕纵坐标。 */ }; @@ -124,6 +128,48 @@ namespace jdi */ bool OnRButtonDown(int x, int y, int screenX, int screenY, TabBarResult& result); + /** + * @brief 处理鼠标左键双击(进入标签页重命名)。 + * @param [IN] x 客户区横坐标。 + * @param [IN] y 客户区纵坐标。 + * @return 进入编辑态返回 true。 + */ + bool OnDoubleClick(int x, int y); + + /** + * @brief 判断标签栏是否处于重命名编辑态。 + * @return 处于编辑态返回 true。 + */ + bool IsEditing() const { return m_editIndex >= 0; } + + /** + * @brief 处理编辑态下的字符输入。 + * @param [IN] character 输入字符。 + * @return 界面状态发生变化返回 true。 + */ + bool OnEditChar(wchar_t character); + + /** + * @brief 处理编辑态下的按键。 + * @param [IN] key 虚拟键码。 + * @param [OUT] result 提交重命名时返回结果。 + * @return 事件已被消费返回 true。 + */ + bool OnEditKey(WPARAM key, TabBarResult& result); + + /** + * @brief 查询拖拽排序时应当插入的位置。 + * @param [IN] x 客户区横坐标。 + * @return 插入下标(0..标签数量)。 + */ + int DropIndexAt(int x) const; + + /** + * @brief 判断是否正在拖动标签页。 + * @return 正在拖动返回 true。 + */ + bool IsDragging() const { return m_dragIndex >= 0; } + /** * @brief 处理鼠标离开标签栏。 * @return 视觉状态发生变化返回 true。 @@ -172,6 +218,18 @@ namespace jdi int m_pressedIndex = -1; /**< 当前按下的标签页下标。 */ bool m_pressedClose = false; /**< 是否按在关闭按钮上。 */ bool m_pressedAdd = false; /**< 是否按在新建按钮上。 */ + + int m_dragIndex = -1; /**< 正在拖动排序的标签页下标,-1 表示未拖动。 */ + int m_dragMouseX = 0; /**< 拖动过程中鼠标横坐标。 */ + bool m_dragMoved = false; /**< 拖动过程中是否发生了位移。 */ + int m_pressedX = 0; /**< 按下时的横坐标(用于判定拖动阈值)。 */ + + int m_editIndex = -1; /**< 正在重命名的标签页下标,-1 表示未编辑。 */ + std::wstring m_editText; /**< 重命名输入文本。 */ + bool m_editFresh = false; /**< 是否处于“首次输入整体替换”状态。 */ + + int m_lastClickIndex = -1; /**< 上一次单击的标签页下标(用于自行判定双击)。 */ + DWORD m_lastClickTick = 0; /**< 上一次单击的时刻。 */ }; } // namespace jdi diff --git a/Components/TabBar/Src/TabBar.cpp b/Components/TabBar/Src/TabBar.cpp index 97c30d5..7cf10ec 100644 --- a/Components/TabBar/Src/TabBar.cpp +++ b/Components/TabBar/Src/TabBar.cpp @@ -3,6 +3,8 @@ #include #include +#include "Utf8Util.hpp" + namespace jdi { @@ -134,10 +136,22 @@ namespace jdi const float textLeft = tab.rect.X + kTabPadding * m_scale; const float textWidth = tab.closeRect.X - kCloseGap * m_scale - textLeft; - const std::wstring label = UiEllipsize(g, tab.name, m_fonts->ui, std::max(8.0f, textWidth)); - UiText(g, label, active ? m_fonts->uiBold : m_fonts->ui, - gd::RectF(textLeft, tab.rect.Y, std::max(8.0f, textWidth), tab.rect.Height), - active ? m_theme->textStrong : m_theme->text, TextAlign::LEFT); + + // 重命名编辑态:在标签本体之上叠加输入框(标签底色与边框仍然保留)。 + if (static_cast(index) == m_editIndex) + { + const gd::RectF editRect(textLeft - 3.0f * m_scale, tab.rect.Y + 3.0f * m_scale, + std::max(40.0f, textWidth + 6.0f * m_scale), + tab.rect.Height - 6.0f * m_scale); + UiDrawEditField(g, editRect, m_editText, m_editFresh, m_fonts->ui, m_scale); + } + else + { + const std::wstring label = UiEllipsize(g, tab.name, m_fonts->ui, std::max(8.0f, textWidth)); + UiText(g, label, active ? m_fonts->uiBold : m_fonts->ui, + gd::RectF(textLeft, tab.rect.Y, std::max(8.0f, textWidth), tab.rect.Height), + active ? m_theme->textStrong : m_theme->text, TextAlign::LEFT); + } // 关闭按钮:悬停时显示底色,图标始终可见。 const bool closeHot = hovered && m_hoverClose; @@ -155,6 +169,22 @@ namespace jdi cy + closeIconSize * 0.5f, iconColor, 1.4f * m_scale); } + // 拖拽排序时在插入位置绘制强调色指示条。 + if (m_dragIndex >= 0 && m_dragMoved && !m_tabs.empty()) + { + const int drop = DropIndexAt(m_dragMouseX); + float markerX = m_addRect.X; + if (drop < static_cast(m_tabs.size())) + { + markerX = m_tabs[static_cast(drop)].rect.X - kAddGap * m_scale * 0.5f; + } + + const float top = m_area.Y + kTopMargin * m_scale; + const float bottom = top + kTabHeight * m_scale; + UiFillRoundRect(g, gd::RectF(markerX - 1.5f * m_scale, top, 3.0f * m_scale, bottom - top), + 1.5f * m_scale, m_theme->accent); + } + // 新建按钮。 const std::uint32_t addBg = m_hoverAdd ? m_theme->hover : m_theme->panelBg; UiFillRoundRect(g, m_addRect, kCorner * m_scale, addBg); @@ -193,6 +223,18 @@ namespace jdi bool TabBar::OnMouseMove(int x, int y) { + // 拖动排序中:只跟踪鼠标位置,由界面层绘制插入指示。 + if (m_dragIndex >= 0) + { + if (x != m_dragMouseX) + { + m_dragMouseX = x; + m_dragMoved = true; + return true; + } + return false; + } + const int index = TabAtPoint(x, y); const bool overClose = (index >= 0 && UiPointInRect(m_tabs[static_cast(index)].closeRect, @@ -215,6 +257,13 @@ namespace jdi m_pressedClose = false; m_pressedAdd = false; + // 编辑态下点击其它位置先提交重命名。 + if (IsEditing()) + { + TabBarResult ignored; + OnEditKey(VK_RETURN, ignored); + } + if (UiPointInRect(m_addRect, static_cast(x), static_cast(y))) { m_pressedAdd = true; @@ -228,8 +277,25 @@ namespace jdi } m_pressedIndex = index; + m_dragIndex = index; + m_dragMouseX = x; + m_pressedX = x; + m_dragMoved = false; m_pressedClose = UiPointInRect(m_tabs[static_cast(index)].closeRect, static_cast(x), static_cast(y)); + + // 自行判定双击:不依赖系统 WM_LBUTTONDBLCLK,行为在不同环境下更一致。 + const DWORD now = GetTickCount(); + const bool sameTab = (index == m_lastClickIndex); + const DWORD elapsed = now - m_lastClickTick; + if (!m_pressedClose && sameTab && elapsed <= GetDoubleClickTime()) + { + m_lastClickIndex = -1; + OnDoubleClick(x, y); + return true; + } + m_lastClickIndex = m_pressedClose ? -1 : index; + m_lastClickTick = now; return true; } @@ -249,13 +315,30 @@ namespace jdi if (m_pressedIndex < 0) { + m_dragIndex = -1; return false; } const int index = m_pressedIndex; const bool wasClose = m_pressedClose; + const bool dragged = m_dragMoved && (std::abs(x - m_pressedX) > 3); m_pressedIndex = -1; m_pressedClose = false; + m_dragIndex = -1; + m_dragMoved = false; + + // 拖动超过阈值:按释放位置重新排序。 + if (dragged && !wasClose) + { + const int target = DropIndexAt(x); + if (target >= 0 && target != index && target != index + 1) + { + result.type = TabBarResult::Type::REORDER; + result.index = index; + result.targetIndex = target; + } + return true; + } if (TabAtPoint(x, y) != index) { @@ -275,6 +358,121 @@ namespace jdi return true; } + int TabBar::DropIndexAt(int x) const + { + const float fx = static_cast(x); + for (std::size_t index = 0; index < m_tabs.size(); ++index) + { + const gd::RectF& rect = m_tabs[index].rect; + if (fx < rect.X + rect.Width * 0.5f) + { + return static_cast(index); + } + } + return static_cast(m_tabs.size()); + } + + bool TabBar::OnDoubleClick(int x, int y) + { + const int index = TabAtPoint(x, y); + if (index < 0) + { + return false; + } + // 双击关闭按钮不进入重命名。 + if (UiPointInRect(m_tabs[static_cast(index)].closeRect, static_cast(x), + static_cast(y))) + { + return false; + } + + m_editIndex = index; + m_editText = m_tabs[static_cast(index)].name; + m_editFresh = true; + return true; + } + + bool TabBar::OnEditChar(wchar_t character) + { + if (m_editIndex < 0) + { + return false; + } + if (character == L'\r' || character == L'\n') + { + TabBarResult result; + OnEditKey(VK_RETURN, result); + return true; + } + if (character == L'\b') + { + m_editFresh = false; + if (!m_editText.empty()) + { + m_editText.pop_back(); + } + return true; + } + if (character < L' ') + { + return true; + } + + if (m_editFresh) + { + m_editText.clear(); + m_editFresh = false; + } + if (m_editText.size() < 48) + { + m_editText.push_back(character); + } + return true; + } + + bool TabBar::OnEditKey(WPARAM key, TabBarResult& result) + { + result = TabBarResult(); + if (m_editIndex < 0) + { + return false; + } + + const int index = m_editIndex; + switch (key) + { + case VK_RETURN: + { + const std::wstring text = Trim(m_editText); + m_editIndex = -1; + m_editFresh = false; + m_editText.clear(); + if (!text.empty()) + { + result.type = TabBarResult::Type::RENAME; + result.index = index; + result.text = text; + } + return true; + } + case VK_ESCAPE: + m_editIndex = -1; + m_editFresh = false; + m_editText.clear(); + return true; + case VK_BACK: + m_editFresh = false; + if (!m_editText.empty()) + { + m_editText.pop_back(); + } + return true; + default: + break; + } + return true; + } + bool TabBar::OnRButtonDown(int x, int y, int screenX, int screenY, TabBarResult& result) { result = TabBarResult(); diff --git a/Components/Toolbar/Src/Toolbar.cpp b/Components/Toolbar/Src/Toolbar.cpp index adfb278..eee1dfb 100644 --- a/Components/Toolbar/Src/Toolbar.cpp +++ b/Components/Toolbar/Src/Toolbar.cpp @@ -72,7 +72,7 @@ namespace jdi {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::SPLIT_LANES, L"拆分信号网格", false, true, false}, {ToolbarAction::SELECT_ALL, L"全选信号", false, true, false}, {ToolbarAction::CLEAR_ALL, L"清空勾选", false, true, false}, }; diff --git a/Utilities/Inc/PaintUtil.hpp b/Utilities/Inc/PaintUtil.hpp index 337c474..6bdb323 100644 --- a/Utilities/Inc/PaintUtil.hpp +++ b/Utilities/Inc/PaintUtil.hpp @@ -181,17 +181,6 @@ namespace jdi */ void UiDrawCrossIcon(gd::Graphics& g, const gd::RectF& rect, std::uint32_t argb, float strokeWidth); - /** - * @brief 在矩形中心绘制一个箭头(折线形)图标。 - * @param [IN,OUT] g 绘图上下文。 - * @param [IN] rect 图标布局矩形。 - * @param [IN] argb 线条颜色。 - * @param [IN] strokeWidth 线条宽度。 - * @param [IN] direction 箭头朝向。 - */ - void UiDrawArrowIcon(gd::Graphics& g, const gd::RectF& rect, std::uint32_t argb, float strokeWidth, - IconDirection direction); - /** * @brief 在矩形中心绘制一个实心三角形指示器。 * @param [IN,OUT] g 绘图上下文。 diff --git a/Utilities/Src/DataModel.cpp b/Utilities/Src/DataModel.cpp index 81abd08..a723f7e 100644 --- a/Utilities/Src/DataModel.cpp +++ b/Utilities/Src/DataModel.cpp @@ -808,6 +808,20 @@ namespace jdi return; } style->visible = visible; + + // 合并模式下新勾选的信号独占一个新网格:避免「先合并已有信号、再加入新信号」 + // 时新信号被悄悄塞进那个唯一的合并网格里,导致用户看不到新增的独立泳道。 + if (visible && m_laneMode == LaneMode::MERGED) + { + m_laneMode = LaneMode::CUSTOM; + Lane lane; + lane.autoRange = true; + lane.signals.push_back(ref); + m_lanes.push_back(std::move(lane)); + SyncLanes(dataSet); + return; + } + SyncLanes(dataSet); } diff --git a/Utilities/Src/PaintUtil.cpp b/Utilities/Src/PaintUtil.cpp index a846152..a10b11a 100644 --- a/Utilities/Src/PaintUtil.cpp +++ b/Utilities/Src/PaintUtil.cpp @@ -305,35 +305,6 @@ namespace jdi UiDrawLine(g, cx + arm, cy - arm, cx - arm, cy + arm, argb, strokeWidth); } - void UiDrawArrowIcon(gd::Graphics& g, const gd::RectF& rect, std::uint32_t argb, float strokeWidth, - IconDirection direction) - { - const float cx = rect.X + rect.Width * 0.5f; - const float cy = rect.Y + rect.Height * 0.5f; - const float arm = std::min(rect.Width, rect.Height) * 0.26f; - - switch (direction) - { - case IconDirection::LEFT: - UiDrawLine(g, cx + arm * 0.6f, cy - arm, cx - arm * 0.6f, cy, argb, strokeWidth); - UiDrawLine(g, cx - arm * 0.6f, cy, cx + arm * 0.6f, cy + arm, argb, strokeWidth); - break; - case IconDirection::RIGHT: - UiDrawLine(g, cx - arm * 0.6f, cy - arm, cx + arm * 0.6f, cy, argb, strokeWidth); - UiDrawLine(g, cx + arm * 0.6f, cy, cx - arm * 0.6f, cy + arm, argb, strokeWidth); - break; - case IconDirection::UP: - UiDrawLine(g, cx - arm, cy + arm * 0.6f, cx, cy - arm * 0.6f, argb, strokeWidth); - UiDrawLine(g, cx, cy - arm * 0.6f, cx + arm, cy + arm * 0.6f, argb, strokeWidth); - break; - case IconDirection::DOWN: - default: - UiDrawLine(g, cx - arm, cy - arm * 0.6f, cx, cy + arm * 0.6f, argb, strokeWidth); - UiDrawLine(g, cx, cy + arm * 0.6f, cx + arm, cy - arm * 0.6f, argb, strokeWidth); - break; - } - } - void UiDrawTriangleIcon(gd::Graphics& g, const gd::RectF& rect, std::uint32_t argb, IconDirection direction) { const float cx = rect.X + rect.Width * 0.5f;