From eb868b2679f33d3fa923eab63e28c13874201594 Mon Sep 17 00:00:00 2001 From: "DeepSeek V4.1 Flash" <> Date: Tue, 29 Sep 2026 16:30:48 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E9=87=8D=E6=9E=84=E4=BF=A1=E5=8F=B7?= =?UTF-8?q?=E6=A0=B7=E5=BC=8F=E5=8C=BA=E5=B9=B6=E7=BB=9F=E4=B8=80=E4=B8=BB?= =?UTF-8?q?=E9=A2=98=E5=8C=96=E5=BC=B9=E5=87=BA=E8=8F=9C=E5=8D=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 样式编辑区: - 移除顶部「样式 + 信号名 + 文件名」标题行,选中信号即代表编辑目标。 - 绘制方式取消阶梯选项,仅保留线段/散点/点线,标签由「方式」改为「样式」。 - 线宽改为「最细预设 + 数值旋钮 + 最粗预设」:中间为带左右增减箭头的输入框, 步进 0.2px,取值范围 0.5-6.0px,并标注单位为 px。 - 颜色在 10 个预设色块后新增带加号图标的方格,点击展开 RGB 三通道滑块面板, 可实时预览并显示十六进制值;点击面板外任意位置收起。 - 移除网格归属下拉框,改由每条信号行末尾的 G 徽标承载,点击弹出网格归属菜单。 信号面板与菜单: - 文件分组行新增关闭按钮,可直接从工作区移除该文件,与整体风格保持一致。 - 新增 PopupMenu 组件:绘制在窗口后备缓冲区内的自绘菜单,含圆角、投影、悬停高亮、 单选对勾、色块行与分组标题,彻底替换系统默认弹出菜单; 菜单打开期间接管鼠标与键盘(Esc / 上下键 / 回车),失焦或在菜单外释放时自动关闭。 - 信号、文件、网格、标签页四类右键菜单全部改用该组件。 其他修复: - 信号名称不再保留尾部单位括号,避免名称后再重复显示单位。 - 数据模型移除 DrawMode::STEP 枚举。 - 标签栏与信号面板弹出菜单后补充重绘请求,修复菜单不显示的问题。 --- Components/Application/Inc/Application.hpp | 15 +- Components/Application/Src/Application.cpp | 199 ++- Components/PlotView/Inc/PlotView.hpp | 3 +- Components/PlotView/Src/PlotView.cpp | 15 +- Components/SignalPanel/Inc/SignalPanel.hpp | 176 ++- Components/SignalPanel/Src/SignalPanel.cpp | 1282 +++++++++++++------- Utilities/Inc/DataModel.hpp | 3 +- Utilities/Inc/PaintUtil.hpp | 66 + Utilities/Inc/PopupMenu.hpp | 222 ++++ Utilities/Src/DataModel.cpp | 57 +- Utilities/Src/PaintUtil.cpp | 132 ++ Utilities/Src/PopupMenu.cpp | 509 ++++++++ 12 files changed, 2148 insertions(+), 531 deletions(-) create mode 100644 Utilities/Inc/PopupMenu.hpp create mode 100644 Utilities/Src/PopupMenu.cpp diff --git a/Components/Application/Inc/Application.hpp b/Components/Application/Inc/Application.hpp index bd73aff..7c2b8cb 100644 --- a/Components/Application/Inc/Application.hpp +++ b/Components/Application/Inc/Application.hpp @@ -11,6 +11,7 @@ #include "DataModel.hpp" #include "PaintUtil.hpp" #include "PlotView.hpp" +#include "PopupMenu.hpp" #include "SignalPanel.hpp" #include "TabBar.hpp" #include "Theme.hpp" @@ -186,11 +187,17 @@ namespace jdi void OnTabBarResult(const TabBarResult& result); /** - * @brief 弹出标签页右键菜单。 - * @param [IN] result 上下文菜单请求。 + * @brief 弹出标签页右键菜单(主题化自绘菜单)。 + * @param [IN] result 标签栏交互结果。 */ void ShowTabContextMenu(const TabBarResult& result); + /** + * @brief 派发弹出菜单的选择结果。 + * @param [IN] result 菜单选择结果。 + */ + void DispatchMenuResult(const MenuResult& result); + /** * @brief 弹出文件选择对话框并加载所选文件。 */ @@ -250,8 +257,7 @@ namespace jdi bool m_gdiplusReady = false; /**< GDI+ 是否初始化成功。 */ Theme m_theme; /**< 主题配色。 */ - UiFonts m_fonts; /**< 界面字体。 */ - DataSet m_dataSet; /**< 全局共享的数据集合。 */ + UiFonts m_fonts; /**< 界面字体。 */ PopupMenu m_menu; /**< 与主题一致的自绘弹出菜单。 */ DataSet m_dataSet; /**< 全局共享的数据集合。 */ std::vector m_tabs; /**< 标签页配置集合。 */ int m_activeTab = -1; /**< 当前激活标签页下标。 */ int m_tabNameCounter = 0; /**< 标签页命名计数器。 */ @@ -280,6 +286,7 @@ namespace jdi Target m_activeTarget = Target::NONE; /**< 当前鼠标作用目标。 */ bool m_trackingLeave = false; /**< 是否已注册鼠标离开通知。 */ + int m_tabMenuIndex = -1; /**< 标签栏右键菜单的目标标签页下标。 */ std::wstring m_statusMessage; /**< 状态栏提示文本。 */ bool m_statusIsError = false; /**< 状态提示是否为错误。 */ diff --git a/Components/Application/Src/Application.cpp b/Components/Application/Src/Application.cpp index 1b1fa43..1fc1fea 100644 --- a/Components/Application/Src/Application.cpp +++ b/Components/Application/Src/Application.cpp @@ -195,7 +195,9 @@ namespace jdi m_tabBar.SetScale(m_scale); m_toolbar.Setup(&m_fonts, &m_theme); m_toolbar.SetScale(m_scale); - m_panel.Setup(m_hwnd, &m_fonts, &m_theme); + m_menu.Setup(&m_fonts, &m_theme); + m_menu.SetScale(m_scale); + m_panel.Setup(m_hwnd, &m_fonts, &m_theme, &m_menu); m_panel.SetScale(m_scale); m_plot.Setup(&m_fonts, &m_theme); m_plot.SetScale(m_scale); @@ -239,6 +241,7 @@ namespace jdi m_fonts.Create(dpi); m_tabBar.SetScale(m_scale); m_toolbar.SetScale(m_scale); + m_menu.SetScale(m_scale); m_panel.SetScale(m_scale); m_plot.SetScale(m_scale); ReleaseBackBuffer(); @@ -453,6 +456,7 @@ namespace jdi m_tabBar.Layout(m_tabBarRect); m_toolbar.Layout(m_toolbarRect); + m_panel.SetClientBounds(gd::RectF(0.0f, 0.0f, width, height)); m_panel.Layout(m_panelRect); m_plot.Layout(m_plotRect); } @@ -528,6 +532,12 @@ namespace jdi return; } + // 面板内展开/收起自定义颜色面板等操作会改变面板所需高度。 + if (m_panel.TakeLayoutDirty()) + { + Relayout(); + } + // 任何交互(勾选、移动网格、切换标签页)之后都在此统一收敛视图状态。 SynchronizeView(false); @@ -559,6 +569,9 @@ namespace jdi UiFillRect(g, m_splitterRect, m_theme.border); PaintStatusBar(g); + + // 弹出菜单始终绘制在最上层,保证与主界面风格一致。 + m_menu.Paint(g); } HDC windowDc = GetDC(m_hwnd); @@ -701,6 +714,7 @@ namespace jdi { const DataFile* file = m_dataSet.FileAt(existing); SetStatusMessage(L"该文件已在工作区中:" + (file != nullptr ? file->fileName : path), true); + InvalidateRect(m_hwnd, nullptr, FALSE); return false; } @@ -709,6 +723,7 @@ namespace jdi if (!LoadCsvTable(path, table, error)) { SetStatusMessage(L"解析失败:" + error, true); + InvalidateRect(m_hwnd, nullptr, FALSE); return false; } @@ -716,6 +731,7 @@ namespace jdi if (!m_dataSet.AddFromTable(table, path, error, fileIndex)) { SetStatusMessage(L"解析失败:" + error, true); + InvalidateRect(m_hwnd, nullptr, FALSE); return false; } @@ -968,45 +984,162 @@ namespace jdi return; } - HMENU menu = CreatePopupMenu(); - if (menu == nullptr) + m_tabMenuIndex = result.index; + + std::vector items; + + MenuItem header; + header.kind = MenuItemKind::HEADER; + header.label = m_tabs[static_cast(result.index)].Name(); + items.push_back(header); + + MenuItem create; + create.label = L"新建标签页"; + create.id = kMenuTabNew; + items.push_back(create); + + MenuItem close; + close.label = L"关闭该标签页"; + close.id = kMenuTabClose; + close.enabled = (m_tabs.size() > 1); + items.push_back(close); + + MenuItem closeOthers; + closeOthers.label = L"关闭其它标签页"; + closeOthers.id = kMenuTabCloseOthers; + closeOthers.enabled = (m_tabs.size() > 1); + items.push_back(closeOthers); + + // 菜单绘制在客户区内,因此把屏幕坐标换算为客户区坐标。 + POINT anchor = {result.screenX, result.screenY}; + ScreenToClient(m_hwnd, &anchor); + + RECT client = {}; + GetClientRect(m_hwnd, &client); + const gd::RectF bounds(0.0f, 0.0f, static_cast(client.right - client.left), + static_cast(client.bottom - client.top)); + + m_menu.Open(MenuScope::TAB_BAR, bounds, anchor.x, anchor.y, items); + } + + void Application::DispatchMenuResult(const MenuResult& result) + { + if (!result.valid) { return; } - AppendMenuW(menu, MF_STRING, static_cast(kMenuTabNew), L"新建标签页"); - AppendMenuW(menu, MF_STRING, static_cast(kMenuTabClose), L"关闭该标签页"); - AppendMenuW(menu, MF_STRING, static_cast(kMenuTabCloseOthers), L"关闭其它标签页"); - if (m_tabs.size() <= 1) + switch (result.scope) { - EnableMenuItem(menu, static_cast(kMenuTabClose), MF_BYCOMMAND | MF_GRAYED); - EnableMenuItem(menu, static_cast(kMenuTabCloseOthers), MF_BYCOMMAND | MF_GRAYED); + case MenuScope::SIGNAL_PANEL: + { + const bool needRelayout = m_panel.OnMenuCommand(result); + if (needRelayout) + { + Relayout(); + } + InvalidateRect(m_hwnd, nullptr, FALSE); + break; } - - SetForegroundWindow(m_hwnd); - const int command = TrackPopupMenu(menu, TPM_RETURNCMD | TPM_LEFTALIGN | TPM_TOPALIGN, - result.screenX, result.screenY, 0, m_hwnd, nullptr); - DestroyMenu(menu); - - if (command == kMenuTabNew) + case MenuScope::TAB_BAR: { - NewTab(); + const int index = m_tabMenuIndex; + if (result.id == kMenuTabNew) + { + NewTab(); + } + else if (result.id == kMenuTabClose) + { + CloseTab(index); + } + else if (result.id == kMenuTabCloseOthers) + { + if (index >= 0 && index < static_cast(m_tabs.size()) && m_tabs.size() > 1) + { + const TabConfig keep = m_tabs[static_cast(index)]; + m_tabs.clear(); + m_tabs.push_back(keep); + SetActiveTab(0); + } + } + m_tabMenuIndex = -1; + InvalidateRect(m_hwnd, nullptr, TRUE); + break; } - else if (command == kMenuTabClose) - { - CloseTab(result.index); - } - else if (command == kMenuTabCloseOthers) - { - const TabConfig keep = m_tabs[static_cast(result.index)]; - m_tabs.clear(); - m_tabs.push_back(keep); - SetActiveTab(0); + case MenuScope::NONE: + default: + break; } } LRESULT Application::HandleMessage(HWND hwnd, UINT message, WPARAM wParam, LPARAM lParam) { + // 菜单打开时优先消费鼠标与键盘事件,形成模态交互。 + if (m_menu.IsOpen()) + { + // 菜单接管输入后,取消底层组件可能仍在进行的拖拽。 + if (m_activeTarget != Target::NONE || m_draggingSplitter) + { + ReleaseCapture(); + m_activeTarget = Target::NONE; + m_draggingSplitter = false; + m_panel.OnLButtonUp(0, 0); + m_plot.OnLButtonUp(0, 0); + } + + switch (message) + { + case WM_MOUSEMOVE: + if (m_menu.OnMouseMove(GET_X_LPARAM(lParam), GET_Y_LPARAM(lParam))) + { + InvalidateRect(hwnd, nullptr, FALSE); + } + return 0; + case WM_LBUTTONDOWN: + { + MenuResult result; + m_menu.OnLButtonDown(GET_X_LPARAM(lParam), GET_Y_LPARAM(lParam), result); + InvalidateRect(hwnd, nullptr, FALSE); + return 0; + } + case WM_LBUTTONUP: + { + MenuResult result; + m_menu.OnLButtonUp(GET_X_LPARAM(lParam), GET_Y_LPARAM(lParam), result); + InvalidateRect(hwnd, nullptr, FALSE); + DispatchMenuResult(result); + return 0; + } + case WM_RBUTTONDOWN: + case WM_MBUTTONDOWN: + case WM_MOUSEWHEEL: + m_menu.Close(); + InvalidateRect(hwnd, nullptr, FALSE); + return 0; + case WM_KEYDOWN: + { + MenuResult result; + m_menu.OnKeyDown(wParam, result); + InvalidateRect(hwnd, nullptr, FALSE); + if (result.valid) + { + DispatchMenuResult(result); + } + return 0; + } + case WM_KILLFOCUS: + case WM_CANCELMODE: + m_menu.Close(); + InvalidateRect(hwnd, nullptr, FALSE); + return 0; + case WM_SETCURSOR: + SetCursor(LoadCursorW(nullptr, IDC_ARROW)); + return TRUE; + default: + break; + } + } + switch (message) { case WM_SIZE: @@ -1087,6 +1220,15 @@ namespace jdi const int x = GET_X_LPARAM(lParam); const int y = GET_Y_LPARAM(lParam); + // 点击面板之外的位置时收起面板内展开的自定义颜色面板。 + if (!m_panel.HitTest(x, y)) + { + if (m_panel.CloseTransientUi()) + { + InvalidateRect(hwnd, nullptr, FALSE); + } + } + if (UiPointInRect(m_splitterRect, static_cast(x), static_cast(y))) { SetCapture(hwnd); @@ -1157,6 +1299,7 @@ namespace jdi m_tabBar.OnLButtonUp(x, y, result); m_activeTarget = Target::NONE; OnTabBarResult(result); + InvalidateRect(hwnd, nullptr, FALSE); return 0; } case Target::TOOLBAR: @@ -1219,6 +1362,8 @@ namespace jdi if (m_tabBar.OnRButtonDown(x, y, cursor.x, cursor.y, result)) { OnTabBarResult(result); + // 自绘菜单会覆盖在客户区之上,必须请求重绘才能显示。 + InvalidateRect(hwnd, nullptr, FALSE); } return 0; } diff --git a/Components/PlotView/Inc/PlotView.hpp b/Components/PlotView/Inc/PlotView.hpp index 464f2f1..b19a752 100644 --- a/Components/PlotView/Inc/PlotView.hpp +++ b/Components/PlotView/Inc/PlotView.hpp @@ -338,11 +338,10 @@ namespace jdi * @brief 构建单条信号在当前视图下的折线点序列。 * @param [IN] resolved 已解析的信号视图。 * @param [IN] lane 所属网格绘制信息。 - * @param [IN] stepMode 是否使用阶梯保持连线。 * @param [OUT] points 输出点序列。 * @param [OUT] breaks 输出每段折线的起始下标(用于处理缺失值断线)。 */ - void BuildPolyline(const ResolvedSignal& resolved, const LaneVisual& lane, bool stepMode, + void BuildPolyline(const ResolvedSignal& resolved, const LaneVisual& lane, std::vector& points, std::vector& breaks) const; /** diff --git a/Components/PlotView/Src/PlotView.cpp b/Components/PlotView/Src/PlotView.cpp index f9a3b81..d388c23 100644 --- a/Components/PlotView/Src/PlotView.cpp +++ b/Components/PlotView/Src/PlotView.cpp @@ -864,7 +864,7 @@ namespace jdi return changed; } - void PlotView::BuildPolyline(const ResolvedSignal& resolved, const LaneVisual& lane, bool stepMode, + void PlotView::BuildPolyline(const ResolvedSignal& resolved, const LaneVisual& lane, std::vector& points, std::vector& breaks) const { points.clear(); @@ -921,7 +921,6 @@ namespace jdi const float limitBottom = lane.rect.Y + lane.rect.Height * 5.0f; bool inSegment = false; - double previousY = 0.0; for (std::size_t index = begin; index < end; index += stride) { @@ -943,17 +942,10 @@ namespace jdi points.push_back(gd::PointF(x, y)); inSegment = true; } - else if (stepMode) - { - points.push_back(gd::PointF(x, static_cast(previousY))); - points.push_back(gd::PointF(x, y)); - } else { points.push_back(gd::PointF(x, y)); } - - previousY = y; } } @@ -1097,8 +1089,7 @@ namespace jdi } const DrawMode mode = resolved.style->mode; - const bool stepMode = (mode == DrawMode::STEP); - BuildPolyline(resolved, lane, stepMode, points, breaks); + BuildPolyline(resolved, lane, points, breaks); const bool drawLine = (mode != DrawMode::SCATTER); const bool drawScatter = (mode == DrawMode::SCATTER || mode == DrawMode::LINE_SCATTER); @@ -1106,7 +1097,7 @@ namespace jdi if (drawLine && !breaks.empty()) { gd::Pen pen(ToGdiColor(resolved.style->color), resolved.style->lineWidth * m_scale); - pen.SetLineJoin(stepMode ? gd::LineJoinMiter : gd::LineJoinRound); + pen.SetLineJoin(gd::LineJoinRound); pen.SetStartCap(gd::LineCapRound); pen.SetEndCap(gd::LineCapRound); diff --git a/Components/SignalPanel/Inc/SignalPanel.hpp b/Components/SignalPanel/Inc/SignalPanel.hpp index 6e6e4b3..961d428 100644 --- a/Components/SignalPanel/Inc/SignalPanel.hpp +++ b/Components/SignalPanel/Inc/SignalPanel.hpp @@ -6,21 +6,42 @@ #include #include +#include #include #include #include "DataModel.hpp" #include "PaintUtil.hpp" +#include "PopupMenu.hpp" #include "Theme.hpp" namespace jdi { + /** + * @brief 信号面板弹出菜单的命令标识。 + */ + enum MenuCommand : int + { + CMD_SIGNAL_TOGGLE = 1, /**< 切换该信号可见性。 */ + CMD_SIGNAL_SOLO = 2, /**< 仅显示该信号。 */ + CMD_SIGNAL_SHOW_ALL = 3, /**< 显示全部信号。 */ + CMD_SIGNAL_HIDE_ALL = 4, /**< 隐藏全部信号。 */ + CMD_FILE_SHOW_ALL = 10, /**< 选中该文件全部信号。 */ + CMD_FILE_HIDE_ALL = 11, /**< 取消该文件全部信号。 */ + CMD_FILE_REMOVE = 12, /**< 从工作区移除该文件。 */ + CMD_COLOR_BASE = 100, /**< 预设颜色基准(配合色块下标)。 */ + CMD_MODE_BASE = 200, /**< 绘制样式基准(配合下标)。 */ + CMD_LANE_BASE = 300, /**< 网格归属基准(配合下标)。 */ + CMD_LANE_NEW = 400 /**< 新建网格。 */ + }; + /** * @brief 左侧信号面板:按文件分组的信号树、可见性勾选与样式编辑。 * * 面板只读取数据层(DataSet),全部显示配置读写均落在当前标签页(TabConfig)上, - * 因此不同标签页的信号勾选与样式互不影响。 + * 因此不同标签页的信号勾选与样式互不影响。所有弹出菜单均使用主题化自绘菜单, + * 不出现系统默认菜单样式。 */ class SignalPanel { @@ -28,12 +49,13 @@ namespace jdi SignalPanel(); /** - * @brief 绑定宿主窗口、字体与主题资源。 - * @param [IN] host 宿主窗口句柄,用于弹出上下文菜单。 + * @brief 绑定宿主窗口、字体、主题与共用弹出菜单。 + * @param [IN] host 宿主窗口句柄。 * @param [IN] fonts 字体集合,需在面板生命周期内保持有效。 * @param [IN] theme 主题配色,需在面板生命周期内保持有效。 + * @param [IN] menu 共用的主题化弹出菜单,需在面板生命周期内保持有效。 */ - void Setup(HWND host, const UiFonts* fonts, const Theme* theme); + void Setup(HWND host, const UiFonts* fonts, const Theme* theme, PopupMenu* menu); /** * @brief 绑定数据层与当前标签页配置。 @@ -42,6 +64,12 @@ namespace jdi */ void SetContext(const DataSet* dataSet, TabConfig* tab); + /** + * @brief 设置宿主客户区矩形(用于约束弹出菜单位置)。 + * @param [IN] bounds 客户区矩形。 + */ + void SetClientBounds(const gd::RectF& bounds) { m_clientBounds = bounds; } + /** * @brief 标记列表结构失效(数据或折叠状态变化后调用)。 */ @@ -130,6 +158,25 @@ namespace jdi */ bool OnMouseLeave(); + /** + * @brief 处理弹出菜单的选择结果。 + * @param [IN] result 菜单选择结果。 + * @return 需要重新布局返回 true。 + */ + bool OnMenuCommand(const MenuResult& result); + + /** + * @brief 关闭自定义颜色面板等临时展开的界面。 + * @return 界面状态发生变化返回 true。 + */ + bool CloseTransientUi(); + + /** + * @brief 取出并清除“需要重新布局”标记。 + * @return 需要重新布局返回 true。 + */ + bool TakeLayoutDirty(); + /** * @brief 判断坐标是否落在面板区域内。 * @param [IN] x 客户区横坐标。 @@ -159,10 +206,12 @@ namespace jdi */ enum class RowPart : int { - NONE = 0, /**< 未命中。 */ - DISCLOSURE, /**< 折叠三角。 */ - CHECK, /**< 复选框。 */ - BODY /**< 行主体。 */ + NONE = 0, /**< 未命中。 */ + DISCLOSURE, /**< 折叠三角。 */ + CHECK, /**< 复选框。 */ + BODY, /**< 行主体。 */ + CLOSE, /**< 文件行的关闭按钮。 */ + LANE_BADGE /**< 信号行的网格徽标。 */ }; /** @@ -170,12 +219,15 @@ namespace jdi */ enum class StyleHit : int { - NONE = 0, /**< 未命中。 */ - COLOR, /**< 颜色色块。 */ - WIDTH, /**< 线宽按钮。 */ - MODE, /**< 绘制方式按钮。 */ - LANE, /**< 网格选择按钮。 */ - TOGGLE_ALL /**< 全部显示/隐藏按钮。 */ + NONE = 0, /**< 未命中。 */ + COLOR, /**< 预设颜色色块。 */ + CUSTOM_CHIP, /**< 自定义颜色入口。 */ + WIDTH_PRESET, /**< 线宽预设按钮。 */ + WIDTH_MINUS, /**< 线宽减号。 */ + WIDTH_PLUS, /**< 线宽加号。 */ + MODE, /**< 绘制样式按钮。 */ + SLIDER, /**< 自定义颜色滑块。 */ + TOGGLE_ALL /**< 全部显示/隐藏按钮。 */ }; /** @@ -231,6 +283,20 @@ namespace jdi */ gd::RectF RowRect(int row) const; + /** + * @brief 计算文件行关闭按钮矩形。 + * @param [IN] row 行下标。 + * @return 关闭按钮矩形。 + */ + gd::RectF FileCloseRect(int row) const; + + /** + * @brief 计算信号行网格徽标矩形。 + * @param [IN] row 行下标。 + * @return 徽标矩形。 + */ + gd::RectF SignalBadgeRect(int row) const; + /** * @brief 绘制文件分组标题行。 * @param [IN,OUT] g 绘图上下文。 @@ -251,6 +317,13 @@ namespace jdi */ void PaintStyleSection(gd::Graphics& g); + /** + * @brief 绘制自定义颜色面板。 + * @param [IN,OUT] g 绘图上下文。 + * @param [IN] style 当前编辑的信号样式。 + */ + void PaintColorPicker(gd::Graphics& g, const SignalStyle& style); + /** * @brief 绘制列表滚动条。 * @param [IN,OUT] g 绘图上下文。 @@ -263,26 +336,40 @@ namespace jdi */ gd::RectF ScrollThumbRect() const; + /** + * @brief 布置样式编辑区内各控件的矩形。 + */ + void LayoutStyleControls(); + + /** + * @brief 取得当前样式编辑区所需高度。 + * @return 逻辑高度(未乘缩放比例)。 + */ + float DesiredStyleHeight() const; + /** * @brief 弹出信号右键菜单。 * @param [IN] ref 目标信号引用。 - * @param [IN] screenX 屏幕坐标横坐标。 - * @param [IN] screenY 屏幕坐标纵坐标。 + * @param [IN] x 客户区横坐标。 + * @param [IN] y 客户区纵坐标。 */ - void ShowSignalMenu(const SignalRef& ref, int screenX, int screenY); + void OpenSignalMenu(const SignalRef& ref, int x, int y); /** * @brief 弹出文件分组右键菜单。 * @param [IN] fileIndex 目标文件下标。 - * @param [IN] screenX 屏幕坐标横坐标。 - * @param [IN] screenY 屏幕坐标纵坐标。 + * @param [IN] x 客户区横坐标。 + * @param [IN] y 客户区纵坐标。 */ - void ShowFileMenu(int fileIndex, int screenX, int screenY); + void OpenFileMenu(int fileIndex, int x, int y); /** - * @brief 弹出网格分配菜单。 + * @brief 弹出网格归属菜单(由信号行的网格徽标触发)。 + * @param [IN] ref 目标信号引用。 + * @param [IN] x 客户区横坐标。 + * @param [IN] y 客户区纵坐标。 */ - void ShowLaneMenu(); + void OpenLaneMenu(const SignalRef& ref, int x, int y); /** * @brief 判断当前标签页中全部信号是否均为可见状态。 @@ -296,25 +383,53 @@ namespace jdi */ int TotalSignalCount() const; + /** + * @brief 调整当前信号线宽。 + * @param [IN] delta 增量,正值加粗。 + * @return 取值发生变化返回 true。 + */ + bool StepLineWidth(float delta); + + /** + * @brief 展开自定义颜色面板并同步初始取色。 + * @param [IN] style 当前信号样式。 + * @return 界面状态发生变化返回 true。 + */ + bool OpenColorPicker(const SignalStyle& style); + HWND m_host = nullptr; /**< 宿主窗口句柄。 */ const UiFonts* m_fonts = nullptr; /**< 字体资源。 */ const Theme* m_theme = nullptr; /**< 主题资源。 */ + PopupMenu* m_menu = nullptr; /**< 共用弹出菜单。 */ const DataSet* m_dataSet = nullptr; /**< 数据集合。 */ TabConfig* m_tab = nullptr; /**< 当前标签页配置。 */ float m_scale = 1.0f; /**< DPI 缩放比例。 */ + gd::RectF m_clientBounds; /**< 宿主客户区矩形。 */ gd::RectF m_area; /**< 面板整体区域。 */ gd::RectF m_headerRect; /**< 标题区域。 */ gd::RectF m_listRect; /**< 列表区域。 */ gd::RectF m_styleRect; /**< 样式编辑区域。 */ gd::RectF m_toggleAllRect; /**< 全部显示/隐藏按钮。 */ - gd::RectF m_laneButtonRect; /**< 网格选择按钮。 */ gd::RectF m_scrollBarRect; /**< 滚动条区域。 */ - std::array m_colorRects; /**< 颜色色块矩形。 */ - std::array m_widthRects; /**< 线宽按钮矩形。 */ - std::array m_modeRects; /**< 绘制方式按钮矩形。 */ - int m_colorCount = 0; /**< 实际可用的颜色数量。 */ + std::array m_colorRects; /**< 预设颜色色块矩形。 */ + int m_colorCount = 0; /**< 实际可用的预设颜色数量。 */ + gd::RectF m_customChipRect; /**< 自定义颜色入口矩形。 */ + + gd::RectF m_widthThinRect; /**< 最细线宽预设矩形。 */ + gd::RectF m_widthThickRect; /**< 最粗线宽预设矩形。 */ + gd::RectF m_widthMinusRect; /**< 线宽减号矩形。 */ + gd::RectF m_widthPlusRect; /**< 线宽加号矩形。 */ + gd::RectF m_widthValueRect; /**< 线宽数值显示矩形。 */ + gd::RectF m_widthSpinnerRect; /**< 线宽数值输入框整体矩形。 */ + + std::array m_modeRects; /**< 绘制样式按钮矩形。 */ + + gd::RectF m_pickerRect; /**< 自定义颜色面板整体矩形。 */ + std::array m_sliderRects; /**< RGB 滑块轨道矩形。 */ + gd::RectF m_pickerPreviewRect; /**< 自定义颜色预览矩形。 */ + std::array m_pickerChannels = {0.0, 0.0, 0.0}; /**< 自定义颜色的 RGB 分量(0-1)。 */ std::vector m_rows; /**< 列表行集合(惰性缓存)。 */ bool m_rowsDirty = true; /**< 列表行集合是否需要重建。 */ @@ -329,6 +444,13 @@ namespace jdi RowPart m_hoverPart = RowPart::NONE; /**< 当前悬停的行内区域。 */ StyleHit m_hoverStyle = StyleHit::NONE; /**< 当前悬停的样式条目类型。 */ int m_hoverStyleIndex = -1; /**< 当前悬停的样式条目下标。 */ + + bool m_colorPickerOpen = false; /**< 自定义颜色面板是否展开。 */ + int m_draggingSlider = -1; /**< 正在拖拽的滑块下标,-1 表示未拖拽。 */ + bool m_layoutDirty = false; /**< 是否需要重新布局。 */ + + SignalRef m_menuTargetSignal; /**< 菜单操作的目标信号。 */ + int m_menuTargetFile = -1; /**< 菜单操作的目标文件。 */ }; } // namespace jdi diff --git a/Components/SignalPanel/Src/SignalPanel.cpp b/Components/SignalPanel/Src/SignalPanel.cpp index d34c530..3beeaa2 100644 --- a/Components/SignalPanel/Src/SignalPanel.cpp +++ b/Components/SignalPanel/Src/SignalPanel.cpp @@ -13,52 +13,107 @@ namespace jdi const float kHeaderHeight = 38.0f; /**< 标题区高度(逻辑像素)。 */ const float kFileRowHeight = 30.0f; /**< 文件分组行高。 */ const float kSignalRowHeight = 27.0f; /**< 信号行高。 */ - const float kStyleHeight = 186.0f; /**< 样式编辑区高度。 */ + const float kStyleBaseHeight = 124.0f; /**< 样式编辑区基础高度。 */ + const float kColorPickerHeight = 132.0f;/**< 自定义颜色面板高度。 */ + const float kRowHeight = 26.0f; /**< 样式区单行高度。 */ + const float kRowGap = 10.0f; /**< 样式区行间距。 */ + const float kStylePadTop = 12.0f; /**< 样式区上内边距。 */ + const float kStylePadH = 12.0f; /**< 面板左右内边距。 */ const float kPadding = 12.0f; /**< 通用内边距。 */ const float kCheckSize = 15.0f; /**< 复选框尺寸。 */ - const float kSwatchSize = 12.0f; /**< 颜色小方块尺寸。 */ - const float kBadgeWidth = 30.0f; /**< 网格标签宽度。 */ - const float kBadgeHeight = 17.0f; /**< 网格标签高度。 */ + const float kSwatchSize = 12.0f; /**< 信号行颜色小方块尺寸。 */ + const float kBadgeWidth = 30.0f; /**< 网格徽标宽度。 */ + const float kBadgeHeight = 17.0f; /**< 网格徽标高度。 */ const float kCorner = 4.0f; /**< 通用圆角半径。 */ const float kLineLabelWidth = 40.0f; /**< 样式行标签列宽度。 */ const float kDisclosureWidth = 16.0f; /**< 折叠三角占位宽度。 */ const float kIndent = 20.0f; /**< 信号行相对文件行的缩进。 */ const float kScrollBarThickness = 8.0f; /**< 滚动条宽度。 */ + const float kCloseButtonSize = 18.0f; /**< 文件行关闭按钮尺寸。 */ + const float kSliderLabelWidth = 18.0f; /**< 滑块行标签列宽度。 */ + const float kSliderValueWidth = 42.0f; /**< 滑块数值显示宽度。 */ + const float kSliderRowHeight = 24.0f; /**< 滑块行高。 */ + const float kSliderGap = 4.0f; /**< 滑块行间距。 */ - /** @brief 上下文菜单命令 ID 基准值。 */ - const int kMenuToggleVisible = 1; - const int kMenuSolo = 2; - const int kMenuShowAll = 3; - const int kMenuHideAll = 4; - const int kMenuFileShowAll = 10; - const int kMenuFileHideAll = 11; - const int kMenuFileRemove = 12; - const int kMenuModeBase = 100; - const int kMenuWidthBase = 200; - const int kMenuColorBase = 300; - const int kMenuLaneBase = 400; - const int kMenuLaneNew = 500; + const float kWidthMin = 0.5f; /**< 允许的最小线宽。 */ + const float kWidthMax = 6.0f; /**< 允许的最大线宽。 */ + const float kWidthStep = 0.2f; /**< 线宽增减步进。 */ + const float kWidthThinPreset = 1.0f; /**< 最细线宽预设值。 */ + const float kWidthThickPreset = 3.0f; /**< 最粗线宽预设值。 */ - /** @brief 可选线宽档位。 */ - const float kWidthOptions[] = {1.0f, 1.5f, 2.0f, 3.0f}; + /** @brief 可选绘制样式顺序(已取消阶梯方式)。 */ + const DrawMode kModeOptions[] = {DrawMode::LINE, DrawMode::SCATTER, DrawMode::LINE_SCATTER}; + const int kModeOptionCount = static_cast(sizeof(kModeOptions) / sizeof(kModeOptions[0])); - /** @brief 可选绘制方式顺序。 */ - const DrawMode kModeOptions[] = {DrawMode::LINE, DrawMode::SCATTER, DrawMode::LINE_SCATTER, - DrawMode::STEP}; + /** + * @brief 计算滑块的便捷命中矩形(比轨道略大,便于拖拽)。 + * @param [IN] track 轨道矩形。 + * @param [IN] scale 缩放比例。 + * @return 命中矩形。 + */ + gd::RectF SliderHitRect(const gd::RectF& track, float scale) + { + return gd::RectF(track.X - 4.0f * scale, track.Y - 9.0f * scale, track.Width + 8.0f * scale, + track.Height + 18.0f * scale); + } + + /** + * @brief 绘制水平滑块(轨道 + 已填充部分 + 圆钮)。 + * @param [IN,OUT] g 绘图上下文。 + * @param [IN] track 轨道矩形。 + * @param [IN] ratio 当前值比例(0-1)。 + * @param [IN] fillColor 已填充部分颜色。 + * @param [IN] hovered 是否处于悬停状态。 + * @param [IN] scale 缩放比例。 + */ + void DrawSlider(gd::Graphics& g, const gd::RectF& track, double ratio, std::uint32_t fillColor, + bool hovered, float scale) + { + const float radius = track.Height * 0.5f; + UiFillRoundRect(g, track, radius, 0x2A657B83u); + + ratio = std::max(0.0, std::min(1.0, ratio)); + const float fillWidth = std::max(track.Height, static_cast(ratio) * track.Width); + UiFillRoundRect(g, gd::RectF(track.X, track.Y, fillWidth, track.Height), radius, fillColor); + + const float knobX = track.X + static_cast(ratio) * track.Width; + const float knobRadius = radius + (hovered ? 2.5f * scale : 1.0f * scale); + + gd::SolidBrush knob(ToGdiColor(0xFFFFFFFFu)); + gd::Pen knobPen(ToGdiColor(0xFF586E75u), 1.4f * scale); + g.FillEllipse(&knob, knobX - knobRadius, track.Y + radius - knobRadius, knobRadius * 2.0f, + knobRadius * 2.0f); + g.DrawEllipse(&knobPen, knobX - knobRadius, track.Y + radius - knobRadius, knobRadius * 2.0f, + knobRadius * 2.0f); + } + + /** + * @brief 生成十六进制颜色文本。 + * @param [IN] color 0xAARRGGBB 颜色值。 + * @return 形如 #RRGGBB 的文本。 + */ + std::wstring ColorToHex(std::uint32_t color) + { + wchar_t buffer[16]; + swprintf(buffer, sizeof(buffer) / sizeof(buffer[0]), L"#%02X%02X%02X", (color >> 16) & 0xFFu, + (color >> 8) & 0xFFu, color & 0xFFu); + return std::wstring(buffer); + } } SignalPanel::SignalPanel() { m_colorRects.fill(gd::RectF()); - m_widthRects.fill(gd::RectF()); m_modeRects.fill(gd::RectF()); + m_sliderRects.fill(gd::RectF()); } - void SignalPanel::Setup(HWND host, const UiFonts* fonts, const Theme* theme) + void SignalPanel::Setup(HWND host, const UiFonts* fonts, const Theme* theme, PopupMenu* menu) { m_host = host; m_fonts = fonts; m_theme = theme; + m_menu = menu; } void SignalPanel::SetContext(const DataSet* dataSet, TabConfig* tab) @@ -67,6 +122,8 @@ namespace jdi m_tab = tab; m_rowsDirty = true; m_scrollOffset = 0.0f; + m_colorPickerOpen = false; + m_draggingSlider = -1; } void SignalPanel::SetScale(float scale) @@ -88,6 +145,30 @@ namespace jdi return static_cast(m_dataSet->SignalCount()); } + float SignalPanel::DesiredStyleHeight() const + { + return kStyleBaseHeight + (m_colorPickerOpen ? kColorPickerHeight : 0.0f); + } + + bool SignalPanel::TakeLayoutDirty() + { + const bool dirty = m_layoutDirty; + m_layoutDirty = false; + return dirty; + } + + bool SignalPanel::CloseTransientUi() + { + if (!m_colorPickerOpen) + { + return false; + } + m_colorPickerOpen = false; + m_draggingSlider = -1; + m_layoutDirty = true; + return true; + } + void SignalPanel::BuildRows() { m_rows.clear(); @@ -168,6 +249,7 @@ namespace jdi ClampScroll(); } + float SignalPanel::ContentHeight() const { return m_contentHeight; @@ -194,6 +276,23 @@ namespace jdi return gd::RectF(m_listRect.X, top, m_listRect.Width, m_rows[static_cast(row)].height); } + gd::RectF SignalPanel::FileCloseRect(int row) const + { + const gd::RectF rect = RowRect(row); + const float size = kCloseButtonSize * m_scale; + return gd::RectF(rect.X + rect.Width - kPadding * m_scale - size, + rect.Y + (rect.Height - size) * 0.5f, size, size); + } + + gd::RectF SignalPanel::SignalBadgeRect(int row) const + { + const gd::RectF rect = RowRect(row); + const float width = kBadgeWidth * m_scale; + const float height = kBadgeHeight * m_scale; + return gd::RectF(rect.X + rect.Width - kPadding * m_scale - width, + rect.Y + (rect.Height - height) * 0.5f, width, height); + } + int SignalPanel::HitRow(int x, int y, RowPart& part) { part = RowPart::NONE; @@ -227,7 +326,8 @@ namespace jdi const ListRow& row = m_rows[index]; if (local >= accumulated && local < accumulated + row.height) { - const gd::RectF rect = RowRect(static_cast(index)); + const int rowIndex = static_cast(index); + const gd::RectF rect = RowRect(rowIndex); const bool isFile = (row.kind == RowKind::FILE); const float baseX = rect.X + kPadding * m_scale + (isFile ? 0.0f : kIndent * m_scale); @@ -236,19 +336,44 @@ namespace jdi rect.Y + (rect.Height - kCheckSize * m_scale) * 0.5f, kCheckSize * m_scale, kCheckSize * m_scale); - if (isFile && UiPointInRect(disclosureRect, fx, fy)) + if (isFile) { - part = RowPart::DISCLOSURE; - } - else if (UiPointInRect(checkRect, fx, fy)) - { - part = RowPart::CHECK; + if (UiPointInRect(disclosureRect, fx, fy)) + { + part = RowPart::DISCLOSURE; + } + else if (UiPointInRect(checkRect, fx, fy)) + { + part = RowPart::CHECK; + } + else if (UiPointInRect(FileCloseRect(rowIndex), fx, fy)) + { + part = RowPart::CLOSE; + } + else + { + part = RowPart::BODY; + } } else { - part = RowPart::BODY; + const SignalRef ref{row.fileIndex, row.signalIndex}; + const bool badgeVisible = (m_tab != nullptr) && m_tab->IsVisible(ref); + + if (UiPointInRect(checkRect, fx, fy)) + { + part = RowPart::CHECK; + } + else if (badgeVisible && UiPointInRect(SignalBadgeRect(rowIndex), fx, fy)) + { + part = RowPart::LANE_BADGE; + } + else + { + part = RowPart::BODY; + } } - return static_cast(index); + return rowIndex; } accumulated += row.height; } @@ -285,13 +410,136 @@ namespace jdi } } + void SignalPanel::LayoutStyleControls() + { + const float labelWidth = kLineLabelWidth * m_scale; + const float contentLeft = m_styleRect.X + kStylePadH * m_scale + labelWidth; + const float contentWidth = std::max(40.0f, m_styleRect.Width - kStylePadH * 2.0f * m_scale - labelWidth); + + const float row0Top = m_styleRect.Y + kStylePadTop * m_scale; + const float row1Top = row0Top + (kRowHeight + kRowGap) * m_scale; + const float row2Top = row1Top + (kRowHeight + kRowGap) * m_scale; + + // ---- 第一行:颜色(预设色块 + 自定义颜色入口)---- + int paletteCount = 0; + SignalPalette(paletteCount); + + const float chipSize = 22.0f * m_scale; + const float chipGap = 8.0f * m_scale; + const float swatchGap = 4.0f * m_scale; + const float available = std::max(swatchGap, contentWidth - chipGap - chipSize); + + int count = std::min(paletteCount, static_cast(m_colorRects.size())); + float swatchSize = 18.0f * m_scale; + while (count > 1) + { + const float fitted = + (available - swatchGap * static_cast(count - 1)) / static_cast(count); + if (fitted >= 12.0f * m_scale) + { + swatchSize = std::min(18.0f * m_scale, fitted); + break; + } + --count; + } + if (count <= 1) + { + count = std::max(1, std::min(count, 1)); + swatchSize = std::min(18.0f * m_scale, available); + } + m_colorCount = count; + + const float swatchTop = row0Top + (kRowHeight * m_scale - swatchSize) * 0.5f; + for (int index = 0; index < m_colorCount; ++index) + { + const float x = contentLeft + static_cast(index) * (swatchSize + swatchGap); + m_colorRects[static_cast(index)] = gd::RectF(x, swatchTop, swatchSize, swatchSize); + } + + const float chipLeft = contentLeft + static_cast(m_colorCount) * (swatchSize + swatchGap) - + swatchGap + chipGap; + m_customChipRect = gd::RectF(std::min(chipLeft, contentLeft + contentWidth - chipSize), + row0Top + (kRowHeight * m_scale - chipSize) * 0.5f, chipSize, chipSize); + + // ---- 第二行:线宽(两个预设 + 数值旋钮)---- + float presetWidth = 46.0f * m_scale; + float buttonWidth = 22.0f * m_scale; + float valueWidth = 62.0f * m_scale; + float gap = 6.0f * m_scale; + const float natural = presetWidth * 2.0f + buttonWidth * 2.0f + valueWidth + gap * 2.0f; + if (natural > contentWidth) + { + const float shrink = contentWidth / natural; + presetWidth = std::max(28.0f * m_scale, presetWidth * shrink); + buttonWidth = std::max(16.0f * m_scale, buttonWidth * shrink); + valueWidth = std::max(40.0f * m_scale, valueWidth * shrink); + gap = std::max(3.0f * m_scale, gap * shrink); + } + + const float spinnerWidth = buttonWidth * 2.0f + valueWidth; + const float groupWidth = presetWidth * 2.0f + spinnerWidth + gap * 2.0f; + const float groupLeft = contentLeft + std::max(0.0f, (contentWidth - groupWidth) * 0.5f); + + m_widthThinRect = gd::RectF(groupLeft, row1Top, presetWidth, kRowHeight * m_scale); + m_widthSpinnerRect = gd::RectF(groupLeft + presetWidth + gap, row1Top, spinnerWidth, + kRowHeight * m_scale); + m_widthMinusRect = gd::RectF(m_widthSpinnerRect.X, row1Top, buttonWidth, kRowHeight * m_scale); + m_widthValueRect = gd::RectF(m_widthMinusRect.X + buttonWidth, row1Top, valueWidth, + kRowHeight * m_scale); + m_widthPlusRect = gd::RectF(m_widthValueRect.X + valueWidth, row1Top, buttonWidth, + kRowHeight * m_scale); + m_widthThickRect = gd::RectF(m_widthSpinnerRect.X + spinnerWidth + gap, row1Top, presetWidth, + kRowHeight * m_scale); + + // ---- 第三行:样式(线段 / 散点 / 点线)---- + const float modeGap = 6.0f * m_scale; + const float modeWidth = (contentWidth - modeGap * static_cast(kModeOptionCount - 1)) / + static_cast(kModeOptionCount); + for (int index = 0; index < kModeOptionCount; ++index) + { + const float x = contentLeft + static_cast(index) * (modeWidth + modeGap); + m_modeRects[static_cast(index)] = gd::RectF(x, row2Top, modeWidth, kRowHeight * m_scale); + } + + // ---- 自定义颜色面板 ---- + if (!m_colorPickerOpen) + { + m_pickerRect = gd::RectF(); + return; + } + + const float pickerTop = row2Top + (kRowHeight + kRowGap) * m_scale; + m_pickerRect = gd::RectF(m_styleRect.X + kStylePadH * m_scale, pickerTop, + std::max(40.0f, m_styleRect.Width - kStylePadH * 2.0f * m_scale), + kColorPickerHeight * m_scale - kRowGap * m_scale); + + const float sliderLabelX = m_pickerRect.X + 6.0f * m_scale; + const float trackLeft = sliderLabelX + kSliderLabelWidth * m_scale; + const float trackWidth = + std::max(24.0f, m_pickerRect.Width - (trackLeft - m_pickerRect.X) - 6.0f * m_scale - + kSliderValueWidth * m_scale); + const float trackHeight = 6.0f * m_scale; + const float firstTop = m_pickerRect.Y + 10.0f * m_scale; + + for (int index = 0; index < 3; ++index) + { + const float rowTop = firstTop + static_cast(index) * (kSliderRowHeight + kSliderGap) * m_scale; + m_sliderRects[static_cast(index)] = + gd::RectF(trackLeft, rowTop + (kSliderRowHeight * m_scale - trackHeight) * 0.5f, trackWidth, + trackHeight); + } + + const float previewTop = firstTop + 3.0f * (kSliderRowHeight + kSliderGap) * m_scale; + m_pickerPreviewRect = gd::RectF(trackLeft, previewTop, 54.0f * m_scale, 20.0f * m_scale); + } + void SignalPanel::Layout(const gd::RectF& area) { m_area = area; m_headerRect = gd::RectF(area.X, area.Y, area.Width, kHeaderHeight * m_scale); - const float styleHeight = kStyleHeight * m_scale; + const float styleHeight = DesiredStyleHeight() * m_scale; m_styleRect = gd::RectF(area.X, area.Y + area.Height - styleHeight, area.Width, styleHeight); m_listRect = gd::RectF(area.X, m_headerRect.Y + m_headerRect.Height, area.Width, std::max(0.0f, m_styleRect.Y - m_headerRect.Y - m_headerRect.Height)); @@ -308,58 +556,7 @@ namespace jdi EnsureRows(); RefreshScrollBar(); - // 样式编辑区布局。 - const float labelWidth = kLineLabelWidth * m_scale; - const float contentWidth = m_styleRect.Width - kPadding * 2.0f * m_scale - labelWidth; - const float contentLeft = m_styleRect.X + kPadding * m_scale + labelWidth; - - int paletteCount = 0; - SignalPalette(paletteCount); - m_colorCount = std::min(paletteCount, static_cast(m_colorRects.size())); - - // 色块尺寸按可用宽度自适应,避免超出面板。 - const float colorGap = 4.0f * m_scale; - float swatchSize = 20.0f * m_scale; - if (m_colorCount > 1) - { - const float fitted = (contentWidth - colorGap * static_cast(m_colorCount - 1)) / - static_cast(m_colorCount); - if (fitted < swatchSize) - { - swatchSize = std::max(10.0f * m_scale, fitted); - } - } - const float colorRowTop = m_styleRect.Y + 30.0f * m_scale; - for (int index = 0; index < m_colorCount; ++index) - { - const float x = contentLeft + static_cast(index) * (swatchSize + colorGap); - m_colorRects[static_cast(index)] = - gd::RectF(x, colorRowTop, swatchSize, swatchSize); - } - - const float widthRowTop = colorRowTop + swatchSize + 12.0f * m_scale; - const float widthGap = 6.0f * m_scale; - const float widthButtonWidth = std::min(56.0f * m_scale, (contentWidth - widthGap * 3.0f) / 4.0f); - for (int index = 0; index < 4; ++index) - { - const float x = contentLeft + static_cast(index) * (widthButtonWidth + widthGap); - m_widthRects[static_cast(index)] = - gd::RectF(x, widthRowTop, widthButtonWidth, 26.0f * m_scale); - } - - const float modeRowTop = widthRowTop + 36.0f * m_scale; - const float modeGap = 6.0f * m_scale; - const float modeButtonWidth = (contentWidth - modeGap * 3.0f) / 4.0f; - for (int index = 0; index < 4; ++index) - { - const float x = contentLeft + static_cast(index) * (modeButtonWidth + modeGap); - m_modeRects[static_cast(index)] = - gd::RectF(x, modeRowTop, modeButtonWidth, 26.0f * m_scale); - } - - const float laneRowTop = modeRowTop + 40.0f * m_scale; - m_laneButtonRect = - gd::RectF(contentLeft, laneRowTop, std::min(contentWidth, 150.0f * m_scale), 26.0f * m_scale); + LayoutStyleControls(); } void SignalPanel::Paint(gd::Graphics& g) @@ -382,8 +579,8 @@ namespace jdi } gd::RectF titleRect(m_headerRect.X + kPadding * m_scale, m_headerRect.Y, - m_headerRect.Width - kPadding * 2.0f * m_scale - m_toggleAllRect.Width - - 8.0f * m_scale, + std::max(20.0f, m_headerRect.Width - kPadding * 2.0f * m_scale - + m_toggleAllRect.Width - 8.0f * m_scale), m_headerRect.Height); UiText(g, UiEllipsize(g, title, m_fonts->uiBold, titleRect.Width), m_fonts->uiBold, titleRect, m_theme->textStrong, TextAlign::LEFT); @@ -481,19 +678,11 @@ namespace jdi // 折叠三角。 { const float cx = baseX + kDisclosureWidth * m_scale * 0.5f; - const float arm = 3.0f * m_scale; const std::uint32_t color = (hovered && m_hoverPart == RowPart::DISCLOSURE) ? m_theme->accent : m_theme->text; - if (collapsed) - { - UiDrawLine(g, cx - arm * 0.5f, centerY - arm, cx + arm * 0.7f, centerY, color, 1.5f * m_scale); - UiDrawLine(g, cx + arm * 0.7f, centerY, cx - arm * 0.5f, centerY + arm, color, 1.5f * m_scale); - } - else - { - UiDrawLine(g, cx - arm, centerY - arm * 0.5f, cx, centerY + arm * 0.7f, color, 1.5f * m_scale); - UiDrawLine(g, cx, centerY + arm * 0.7f, cx + arm, centerY - arm * 0.5f, color, 1.5f * m_scale); - } + UiDrawTriangleIcon(g, gd::RectF(cx - 6.0f * m_scale, centerY - 6.0f * m_scale, 12.0f * m_scale, + 12.0f * m_scale), + color, collapsed ? IconDirection::RIGHT : IconDirection::DOWN); } // 分组复选框(三态:全选 / 部分 / 未选)。 @@ -501,44 +690,30 @@ namespace jdi kCheckSize * m_scale, kCheckSize * m_scale); const bool allVisible = m_tab->FileAllVisible(*m_dataSet, item.fileIndex); const bool anyVisible = m_tab->FileHasVisible(*m_dataSet, item.fileIndex); + UiDrawCheckBox(g, checkRect, allVisible ? 1 : (anyVisible ? 2 : 0), 3.0f * m_scale); - if (allVisible) - { - UiFillRoundRect(g, checkRect, 3.0f * m_scale, m_theme->accent); - gd::Pen pen(ToGdiColor(m_theme->textOnAccent), 1.6f * m_scale); - pen.SetStartCap(gd::LineCapRound); - pen.SetEndCap(gd::LineCapRound); - g.DrawLine(&pen, checkRect.X + 3.0f * m_scale, centerY, checkRect.X + 6.0f * m_scale, - checkRect.Y + checkRect.Height - 4.0f * m_scale); - g.DrawLine(&pen, checkRect.X + 6.0f * m_scale, checkRect.Y + checkRect.Height - 4.0f * m_scale, - checkRect.X + checkRect.Width - 3.0f * m_scale, checkRect.Y + 4.0f * m_scale); - } - else if (anyVisible) - { - UiFillRoundRect(g, checkRect, 3.0f * m_scale, m_theme->accentSoft); - UiDrawRoundRect(g, checkRect, 3.0f * m_scale, m_theme->accent, 1.0f); - UiFillRect(g, gd::RectF(checkRect.X + 3.0f * m_scale, centerY - 1.0f * m_scale, - checkRect.Width - 6.0f * m_scale, 2.0f * m_scale), - m_theme->accent); - } - else - { - UiFillRoundRect(g, checkRect, 3.0f * m_scale, m_theme->cardBg); - UiDrawRoundRect(g, checkRect, 3.0f * m_scale, m_theme->borderStrong, 1.0f); - } + const gd::RectF closeRect = FileCloseRect(row); // 文件名称与统计。 const float nameLeft = checkRect.X + checkRect.Width + 8.0f * m_scale; - const std::wstring stats = L" " + std::to_wstring(file->signals.size()) + L" 项"; + const std::wstring stats = std::to_wstring(file->signals.size()) + L" 项"; const float statsWidth = UiMeasureTextWidth(m_fonts->uiSmall, stats); - const float nameWidth = std::max(20.0f, rect.X + rect.Width - kPadding * m_scale - statsWidth - nameLeft); + const float statsRight = closeRect.X - 6.0f * m_scale; + const float nameWidth = std::max(20.0f, statsRight - statsWidth - 6.0f * m_scale - nameLeft); UiText(g, UiEllipsize(g, file->fileName, m_fonts->uiBold, nameWidth), m_fonts->uiBold, gd::RectF(nameLeft, rect.Y, nameWidth, rect.Height), m_theme->textStrong, TextAlign::LEFT); - UiText(g, stats, m_fonts->uiSmall, - gd::RectF(rect.X + rect.Width - kPadding * m_scale - statsWidth, rect.Y, statsWidth, - rect.Height), + UiText(g, stats, m_fonts->uiSmall, gd::RectF(statsRight - statsWidth, rect.Y, statsWidth, rect.Height), m_theme->textMuted, TextAlign::RIGHT); + + // 关闭按钮(从工作区移除该文件)。 + const bool closeHot = hovered && (m_hoverPart == RowPart::CLOSE); + if (closeHot) + { + UiFillRoundRect(g, closeRect, 3.0f * m_scale, m_theme->pressed); + } + UiDrawCrossIcon(g, closeRect, + closeHot ? m_theme->danger : m_theme->textMuted, 1.5f * m_scale); } void SignalPanel::PaintSignalRow(gd::Graphics& g, int row) @@ -577,22 +752,7 @@ namespace jdi // 复选框。 const gd::RectF checkRect(baseX + kDisclosureWidth * m_scale, centerY - kCheckSize * m_scale * 0.5f, kCheckSize * m_scale, kCheckSize * m_scale); - if (style->visible) - { - UiFillRoundRect(g, checkRect, 3.0f * m_scale, m_theme->accent); - gd::Pen pen(ToGdiColor(m_theme->textOnAccent), 1.6f * m_scale); - pen.SetStartCap(gd::LineCapRound); - pen.SetEndCap(gd::LineCapRound); - g.DrawLine(&pen, checkRect.X + 3.0f * m_scale, centerY, checkRect.X + 6.0f * m_scale, - checkRect.Y + checkRect.Height - 4.0f * m_scale); - g.DrawLine(&pen, checkRect.X + 6.0f * m_scale, checkRect.Y + checkRect.Height - 4.0f * m_scale, - checkRect.X + checkRect.Width - 3.0f * m_scale, checkRect.Y + 4.0f * m_scale); - } - else - { - UiFillRoundRect(g, checkRect, 3.0f * m_scale, m_theme->cardBg); - UiDrawRoundRect(g, checkRect, 3.0f * m_scale, m_theme->borderStrong, 1.0f); - } + UiDrawCheckBox(g, checkRect, style->visible ? 1 : 0, 3.0f * m_scale); // 颜色块。 const gd::RectF swatchRect(checkRect.X + checkRect.Width + 8.0f * m_scale, @@ -601,39 +761,44 @@ namespace jdi UiFillRoundRect(g, swatchRect, 2.5f * m_scale, style->visible ? style->color : WithAlpha(style->color, 0x55)); - // 名称。 + // 网格徽标(可点击,用于调整所属网格)。 + const int laneIndex = m_tab->LaneOfSignal(ref); + const bool badgeVisible = (laneIndex >= 0) && style->visible; + const gd::RectF badgeRect = SignalBadgeRect(row); + + float nameRightLimit = rect.X + rect.Width - kPadding * m_scale; + if (badgeVisible) + { + nameRightLimit = badgeRect.X - 6.0f * m_scale; + } + + // 名称与单位。 const float unitWidth = signal->unit.empty() ? 0.0f : UiMeasureTextWidth(m_fonts->uiSmall, L" " + signal->unit) + 4.0f * m_scale; - const float badgeOffset = kBadgeWidth * m_scale + 8.0f * m_scale; const float nameLeft = swatchRect.X + swatchRect.Width + 8.0f * m_scale; - const float nameWidth = rect.X + rect.Width - kPadding * m_scale - badgeOffset - unitWidth - nameLeft; + const float nameWidth = std::max(16.0f, nameRightLimit - unitWidth - nameLeft); const std::uint32_t nameColor = style->visible ? m_theme->text : m_theme->textMuted; - UiText(g, UiEllipsize(g, signal->name, m_fonts->ui, std::max(16.0f, nameWidth)), m_fonts->ui, - gd::RectF(nameLeft, rect.Y, std::max(16.0f, nameWidth), rect.Height), nameColor, - TextAlign::LEFT); + UiText(g, UiEllipsize(g, signal->name, m_fonts->ui, nameWidth), m_fonts->ui, + gd::RectF(nameLeft, rect.Y, nameWidth, rect.Height), nameColor, TextAlign::LEFT); if (!signal->unit.empty()) { const std::wstring unitText = L" " + signal->unit; UiText(g, unitText, m_fonts->uiSmall, - gd::RectF(nameLeft + std::max(16.0f, nameWidth), rect.Y, unitWidth, rect.Height), - m_theme->textMuted, TextAlign::LEFT); + gd::RectF(nameLeft + nameWidth, rect.Y, unitWidth, rect.Height), m_theme->textMuted, + TextAlign::LEFT); } - // 网格标记。 - const int laneIndex = m_tab->LaneOfSignal(ref); - if (laneIndex >= 0) + if (badgeVisible) { - const gd::RectF badgeRect(rect.X + rect.Width - kPadding * m_scale - kBadgeWidth * m_scale, - centerY - kBadgeHeight * m_scale * 0.5f, kBadgeWidth * m_scale, - kBadgeHeight * m_scale); - const bool laneActive = (selected && m_hoverStyle == StyleHit::LANE); + const bool badgeHot = hovered && (m_hoverPart == RowPart::LANE_BADGE); UiFillRoundRect(g, badgeRect, kBadgeHeight * m_scale * 0.5f, - laneActive ? m_theme->accentSoft : m_theme->cardBg); - UiDrawRoundRect(g, badgeRect, kBadgeHeight * m_scale * 0.5f, m_theme->border, 1.0f); - UiText(g, L"G" + std::to_wstring(laneIndex + 1), m_fonts->monoSmall, badgeRect, m_theme->text, - TextAlign::CENTER); + badgeHot ? m_theme->accentSoft : m_theme->cardBg); + UiDrawRoundRect(g, badgeRect, kBadgeHeight * m_scale * 0.5f, + badgeHot ? m_theme->accent : m_theme->border, 1.0f); + UiText(g, L"G" + std::to_wstring(laneIndex + 1), m_fonts->monoSmall, badgeRect, + badgeHot ? m_theme->textStrong : m_theme->text, TextAlign::CENTER); } } @@ -664,7 +829,6 @@ namespace jdi UiFillRoundRect(g, ScrollThumbRect(), kScrollBarThickness * m_scale * 0.5f, m_theme->borderStrong); } - void SignalPanel::PaintStyleSection(gd::Graphics& g) { if (m_styleRect.Height <= 0.0f) @@ -675,35 +839,20 @@ namespace jdi UiFillRect(g, m_styleRect, m_theme->panelBg); const SignalStyle* style = (m_tab == nullptr) ? nullptr : m_tab->Style(m_tab->Selected()); - const SignalData* signal = (m_dataSet == nullptr || m_tab == nullptr) - ? nullptr - : m_dataSet->Resolve(m_tab->Selected()); - if (style == nullptr || signal == nullptr) + if (style == nullptr) { UiText(g, L"请选择信号以编辑样式", m_fonts->uiSmall, m_styleRect, m_theme->textMuted, TextAlign::CENTER); return; } - // 标题行(带上来源文件,便于多文件对比时区分同名信号)。 - std::wstring styleTitle = L"样式 · " + signal->name; - if (const DataFile* file = m_dataSet->FileAt(m_tab->Selected().fileIndex); file != nullptr) - { - styleTitle += L" · " + file->fileName; - } - - const gd::RectF titleRect(m_styleRect.X + kPadding * m_scale, m_styleRect.Y, - m_styleRect.Width - kPadding * 2.0f * m_scale, 28.0f * m_scale); - UiText(g, UiEllipsize(g, styleTitle, m_fonts->uiBold, titleRect.Width), m_fonts->uiBold, titleRect, - m_theme->textStrong, TextAlign::LEFT); - - const float labelX = m_styleRect.X + kPadding * m_scale; + const float labelX = m_styleRect.X + kStylePadH * m_scale; const float labelWidth = kLineLabelWidth * m_scale; - // 颜色。 + // ---- 颜色 ---- { - const float rowTop = m_styleRect.Y + 30.0f * m_scale; - UiText(g, L"颜色", m_fonts->uiSmall, gd::RectF(labelX, rowTop, labelWidth, 20.0f * m_scale), + const float rowTop = m_styleRect.Y + kStylePadTop * m_scale; + UiText(g, L"颜色", m_fonts->uiSmall, gd::RectF(labelX, rowTop, labelWidth, kRowHeight * m_scale), m_theme->textMuted, TextAlign::LEFT); int paletteCount = 0; @@ -725,37 +874,90 @@ namespace jdi active ? 1.6f : 1.0f); } } + + // 自定义颜色入口:带加号图标的小方格。 + const bool chipHot = (m_hoverStyle == StyleHit::CUSTOM_CHIP); + const bool chipActive = m_colorPickerOpen; + UiFillRoundRect(g, m_customChipRect, 4.0f * m_scale, + chipActive ? m_theme->accentSoft : (chipHot ? m_theme->hover : m_theme->cardBg)); + UiDrawRoundRect(g, m_customChipRect, 4.0f * m_scale, + chipActive ? m_theme->accent : m_theme->borderStrong, 1.0f); + UiDrawPlusIcon(g, m_customChipRect, chipActive ? m_theme->accent : m_theme->text, 1.6f * m_scale); } - // 线宽。 + // ---- 线宽:两个预设 + 数值旋钮 ---- { - const gd::RectF& first = m_widthRects[0]; - UiText(g, L"线宽", m_fonts->uiSmall, gd::RectF(labelX, first.Y, labelWidth, first.Height), + UiText(g, L"线宽", m_fonts->uiSmall, + gd::RectF(labelX, m_widthThinRect.Y, labelWidth, m_widthThinRect.Height), m_theme->textMuted, TextAlign::LEFT); - for (int index = 0; index < 4; ++index) + struct PresetInfo { - const gd::RectF& rect = m_widthRects[static_cast(index)]; - const bool active = std::fabs(style->lineWidth - kWidthOptions[index]) < 0.01f; - const bool hovered = (m_hoverStyle == StyleHit::WIDTH && m_hoverStyleIndex == index); + gd::RectF rect; + float width; + int index; + }; + const PresetInfo presets[] = { + {m_widthThinRect, kWidthThinPreset, 0}, + {m_widthThickRect, kWidthThickPreset, 1}, + }; - UiFillRoundRect(g, rect, kCorner * m_scale, + for (const PresetInfo& preset : presets) + { + const bool active = std::fabs(style->lineWidth - preset.width) < 0.01f; + const bool hovered = (m_hoverStyle == StyleHit::WIDTH_PRESET && m_hoverStyleIndex == preset.index); + + UiFillRoundRect(g, preset.rect, kCorner * m_scale, active ? m_theme->accentSoft : (hovered ? m_theme->hover : m_theme->cardBg)); - UiDrawRoundRect(g, rect, kCorner * m_scale, active ? m_theme->accent : m_theme->border, 1.0f); + UiDrawRoundRect(g, preset.rect, kCorner * m_scale, + active ? m_theme->accent : m_theme->border, 1.0f); - const float centerY = rect.Y + rect.Height * 0.5f; - UiDrawLine(g, rect.X + 8.0f * m_scale, centerY, rect.X + rect.Width - 8.0f * m_scale, centerY, - active ? m_theme->accent : m_theme->text, kWidthOptions[index] * m_scale); + const float centerY = preset.rect.Y + preset.rect.Height * 0.5f; + UiDrawLine(g, preset.rect.X + 9.0f * m_scale, centerY, preset.rect.X + preset.rect.Width - 9.0f * m_scale, + centerY, active ? m_theme->accent : m_theme->text, preset.width * m_scale); } + + // 数值输入框。 + UiFillRoundRect(g, m_widthSpinnerRect, kCorner * m_scale, m_theme->cardBg); + UiDrawRoundRect(g, m_widthSpinnerRect, kCorner * m_scale, m_theme->border, 1.0f); + + const std::wstring valueText = FormatNumber(style->lineWidth, 1) + L" px"; + UiText(g, valueText, m_fonts->monoSmall, m_widthValueRect, m_theme->textStrong, TextAlign::CENTER); + + const bool minusHot = (m_hoverStyle == StyleHit::WIDTH_MINUS); + const bool plusHot = (m_hoverStyle == StyleHit::WIDTH_PLUS); + const bool minusEnabled = style->lineWidth > (kWidthMin + 1.0e-4f); + const bool plusEnabled = style->lineWidth < (kWidthMax - 1.0e-4f); + + if (minusHot && minusEnabled) + { + UiFillRoundRect(g, m_widthMinusRect, kCorner * m_scale, m_theme->hover); + } + if (plusHot && plusEnabled) + { + UiFillRoundRect(g, m_widthPlusRect, kCorner * m_scale, m_theme->hover); + } + UiDrawLine(g, m_widthMinusRect.X + m_widthMinusRect.Width, m_widthMinusRect.Y + 3.0f * m_scale, + m_widthMinusRect.X + m_widthMinusRect.Width, m_widthMinusRect.Y + m_widthMinusRect.Height - + 3.0f * m_scale, + m_theme->border, 1.0f); + UiDrawLine(g, m_widthPlusRect.X, m_widthPlusRect.Y + 3.0f * m_scale, m_widthPlusRect.X, + m_widthPlusRect.Y + m_widthPlusRect.Height - 3.0f * m_scale, m_theme->border, 1.0f); + + UiDrawArrowIcon(g, m_widthMinusRect, + minusEnabled ? m_theme->text : m_theme->textMuted, 1.6f * m_scale, + IconDirection::LEFT); + UiDrawArrowIcon(g, m_widthPlusRect, plusEnabled ? m_theme->text : m_theme->textMuted, + 1.6f * m_scale, IconDirection::RIGHT); } - // 绘制方式。 + // ---- 样式 ---- { const gd::RectF& first = m_modeRects[0]; - UiText(g, L"方式", m_fonts->uiSmall, gd::RectF(labelX, first.Y, labelWidth, first.Height), + UiText(g, L"样式", m_fonts->uiSmall, gd::RectF(labelX, first.Y, labelWidth, first.Height), m_theme->textMuted, TextAlign::LEFT); - for (int index = 0; index < 4; ++index) + for (int index = 0; index < kModeOptionCount; ++index) { const gd::RectF& rect = m_modeRects[static_cast(index)]; const bool active = (style->mode == kModeOptions[index]); @@ -769,34 +971,54 @@ namespace jdi } } - // 所属网格。 + if (m_colorPickerOpen) { - UiText(g, L"网格", m_fonts->uiSmall, - gd::RectF(labelX, m_laneButtonRect.Y, labelWidth, m_laneButtonRect.Height), + PaintColorPicker(g, *style); + } + } + + void SignalPanel::PaintColorPicker(gd::Graphics& g, const SignalStyle& style) + { + UiFillRoundRect(g, m_pickerRect, kCorner * m_scale, m_theme->cardBg); + UiDrawRoundRect(g, m_pickerRect, kCorner * m_scale, m_theme->borderStrong, 1.0f); + + static const wchar_t* kChannelNames[3] = {L"R", L"G", L"B"}; + static const std::uint32_t kChannelColors[3] = {0xFFDC322Fu, 0xFF859900u, 0xFF268BD2u}; + + for (int index = 0; index < 3; ++index) + { + const gd::RectF& track = m_sliderRects[static_cast(index)]; + const gd::RectF rowRect(m_pickerRect.X, track.Y - 9.0f * m_scale, + m_pickerRect.Width, kSliderRowHeight * m_scale); + + UiText(g, kChannelNames[index], m_fonts->uiSmall, + gd::RectF(m_pickerRect.X + 6.0f * m_scale, rowRect.Y, kSliderLabelWidth * m_scale, + rowRect.Height), m_theme->textMuted, TextAlign::LEFT); - const bool hovered = (m_hoverStyle == StyleHit::LANE); - UiFillRoundRect(g, m_laneButtonRect, kCorner * m_scale, hovered ? m_theme->hover : m_theme->cardBg); - UiDrawRoundRect(g, m_laneButtonRect, kCorner * m_scale, m_theme->border, 1.0f); + DrawSlider(g, track, m_pickerChannels[static_cast(index)], kChannelColors[index], + m_hoverStyle == StyleHit::SLIDER && m_hoverStyleIndex == index, m_scale); - const int laneIndex = m_tab->LaneOfSignal(m_tab->Selected()); - const std::wstring label = (laneIndex >= 0) ? (L"网格 " + std::to_wstring(laneIndex + 1)) - : L"未分配(勾选后自动分配)"; - - const gd::RectF textRect(m_laneButtonRect.X + 8.0f * m_scale, m_laneButtonRect.Y, - m_laneButtonRect.Width - 26.0f * m_scale, m_laneButtonRect.Height); - UiText(g, UiEllipsize(g, label, m_fonts->uiSmall, textRect.Width), m_fonts->uiSmall, textRect, - m_theme->text, TextAlign::LEFT); - - const float arrowX = m_laneButtonRect.X + m_laneButtonRect.Width - 16.0f * m_scale; - const float arrowY = m_laneButtonRect.Y + m_laneButtonRect.Height * 0.5f; - gd::PointF points[3]; - points[0] = gd::PointF(arrowX - 4.0f * m_scale, arrowY - 2.0f * m_scale); - points[1] = gd::PointF(arrowX + 4.0f * m_scale, arrowY - 2.0f * m_scale); - points[2] = gd::PointF(arrowX, arrowY + 3.0f * m_scale); - gd::SolidBrush brush(ToGdiColor(m_theme->textMuted)); - g.FillPolygon(&brush, points, 3); + const int value = static_cast(m_pickerChannels[static_cast(index)] * 255.0 + 0.5); + UiText(g, std::to_wstring(value), m_fonts->monoSmall, + gd::RectF(track.X + track.Width + 6.0f * m_scale, rowRect.Y, kSliderValueWidth * m_scale, + rowRect.Height), + m_theme->text, TextAlign::RIGHT); } + + // 预览与十六进制值。 + UiText(g, L"预览", m_fonts->uiSmall, + gd::RectF(m_pickerRect.X + 6.0f * m_scale, m_pickerPreviewRect.Y, + kSliderLabelWidth * m_scale + 20.0f * m_scale, m_pickerPreviewRect.Height), + m_theme->textMuted, TextAlign::LEFT); + + UiFillRoundRect(g, m_pickerPreviewRect, 3.0f * m_scale, style.color); + UiDrawRoundRect(g, m_pickerPreviewRect, 3.0f * m_scale, m_theme->borderStrong, 1.0f); + + UiText(g, ColorToHex(style.color), m_fonts->monoSmall, + gd::RectF(m_pickerPreviewRect.X + m_pickerPreviewRect.Width + 8.0f * m_scale, + m_pickerPreviewRect.Y, 96.0f * m_scale, m_pickerPreviewRect.Height), + m_theme->text, TextAlign::LEFT); } bool SignalPanel::AreAllSignalsVisible() const @@ -808,8 +1030,68 @@ namespace jdi return m_tab->VisibleCount() == TotalSignalCount(); } + bool SignalPanel::StepLineWidth(float delta) + { + if (m_tab == nullptr) + { + return false; + } + SignalStyle* style = m_tab->Style(m_tab->Selected()); + if (style == nullptr) + { + return false; + } + + float value = style->lineWidth + delta; + value = std::max(kWidthMin, std::min(kWidthMax, value)); + value = std::round(value * 10.0f) / 10.0f; + if (std::fabs(value - style->lineWidth) < 0.001f) + { + return false; + } + style->lineWidth = value; + return true; + } + + bool SignalPanel::OpenColorPicker(const SignalStyle& style) + { + m_pickerChannels[0] = static_cast((style.color >> 16) & 0xFFu) / 255.0; + m_pickerChannels[1] = static_cast((style.color >> 8) & 0xFFu) / 255.0; + m_pickerChannels[2] = static_cast(style.color & 0xFFu) / 255.0; + + if (m_colorPickerOpen) + { + return true; + } + m_colorPickerOpen = true; + m_layoutDirty = true; + return true; + } + bool SignalPanel::OnMouseMove(int x, int y) { + // 滑块拖拽优先级最高。 + if (m_draggingSlider >= 0 && m_draggingSlider < 3) + { + const gd::RectF& track = m_sliderRects[static_cast(m_draggingSlider)]; + m_pickerChannels[static_cast(m_draggingSlider)] = + UiSliderRatioFromX(track, static_cast(x)); + if (m_tab != nullptr) + { + if (SignalStyle* style = m_tab->Style(m_tab->Selected())) + { + const unsigned int r = + static_cast(m_pickerChannels[0] * 255.0 + 0.5); + const unsigned int g = + static_cast(m_pickerChannels[1] * 255.0 + 0.5); + const unsigned int b = + static_cast(m_pickerChannels[2] * 255.0 + 0.5); + style->color = 0xFF000000u | (r << 16) | (g << 8) | b; + } + } + return true; + } + if (m_draggingScrollBar) { const gd::RectF thumb = ScrollThumbRect(); @@ -837,7 +1119,20 @@ namespace jdi { hoverStyle = StyleHit::TOGGLE_ALL; } - else if (m_tab != nullptr && m_tab->Style(m_tab->Selected()) != nullptr) + else if (m_colorPickerOpen) + { + for (int index = 0; index < 3; ++index) + { + if (UiPointInRect(SliderHitRect(m_sliderRects[static_cast(index)], m_scale), fx, fy)) + { + hoverStyle = StyleHit::SLIDER; + hoverStyleIndex = index; + break; + } + } + } + + if (hoverStyle == StyleHit::NONE && m_tab != nullptr && m_tab->Style(m_tab->Selected()) != nullptr) { for (int index = 0; index < m_colorCount; ++index) { @@ -848,21 +1143,31 @@ namespace jdi break; } } - if (hoverStyle == StyleHit::NONE) + if (hoverStyle == StyleHit::NONE && UiPointInRect(m_customChipRect, fx, fy)) { - for (int index = 0; index < 4; ++index) - { - if (UiPointInRect(m_widthRects[static_cast(index)], fx, fy)) - { - hoverStyle = StyleHit::WIDTH; - hoverStyleIndex = index; - break; - } - } + hoverStyle = StyleHit::CUSTOM_CHIP; + } + if (hoverStyle == StyleHit::NONE && UiPointInRect(m_widthThinRect, fx, fy)) + { + hoverStyle = StyleHit::WIDTH_PRESET; + hoverStyleIndex = 0; + } + if (hoverStyle == StyleHit::NONE && UiPointInRect(m_widthThickRect, fx, fy)) + { + hoverStyle = StyleHit::WIDTH_PRESET; + hoverStyleIndex = 1; + } + if (hoverStyle == StyleHit::NONE && UiPointInRect(m_widthMinusRect, fx, fy)) + { + hoverStyle = StyleHit::WIDTH_MINUS; + } + if (hoverStyle == StyleHit::NONE && UiPointInRect(m_widthPlusRect, fx, fy)) + { + hoverStyle = StyleHit::WIDTH_PLUS; } if (hoverStyle == StyleHit::NONE) { - for (int index = 0; index < 4; ++index) + for (int index = 0; index < kModeOptionCount; ++index) { if (UiPointInRect(m_modeRects[static_cast(index)], fx, fy)) { @@ -872,10 +1177,6 @@ namespace jdi } } } - if (hoverStyle == StyleHit::NONE && UiPointInRect(m_laneButtonRect, fx, fy)) - { - hoverStyle = StyleHit::LANE; - } } if (hoverRow == m_hoverRow && hoverStyle == m_hoverStyle && hoverStyleIndex == m_hoverStyleIndex && @@ -926,6 +1227,28 @@ namespace jdi return true; } + // 自定义颜色滑块:开始拖拽(点击轨道任意处即跳转并进入拖拽)。 + if (m_colorPickerOpen) + { + for (int index = 0; index < 3; ++index) + { + if (UiPointInRect(SliderHitRect(m_sliderRects[static_cast(index)], m_scale), fx, fy)) + { + m_draggingSlider = index; + m_pickerChannels[static_cast(index)] = + UiSliderRatioFromX(m_sliderRects[static_cast(index)], fx); + if (SignalStyle* style = m_tab->Style(m_tab->Selected())) + { + const unsigned int r = static_cast(m_pickerChannels[0] * 255.0 + 0.5); + const unsigned int g = static_cast(m_pickerChannels[1] * 255.0 + 0.5); + const unsigned int b = static_cast(m_pickerChannels[2] * 255.0 + 0.5); + style->color = 0xFF000000u | (r << 16) | (g << 8) | b; + } + return true; + } + } + } + // 全部显示/隐藏。 if (UiPointInRect(m_toggleAllRect, fx, fy)) { @@ -935,7 +1258,7 @@ namespace jdi } // 样式编辑区。 - if (SignalStyle* style = m_tab->Style(m_tab->Selected()); style != nullptr) + if (SignalStyle* style = m_tab->Style(m_tab->Selected())) { int paletteCount = 0; const std::uint32_t* palette = SignalPalette(paletteCount); @@ -947,15 +1270,29 @@ namespace jdi return true; } } - for (int index = 0; index < 4; ++index) + if (UiPointInRect(m_customChipRect, fx, fy)) { - if (UiPointInRect(m_widthRects[static_cast(index)], fx, fy)) - { - style->lineWidth = kWidthOptions[index]; - return true; - } + return OpenColorPicker(*style); } - for (int index = 0; index < 4; ++index) + if (UiPointInRect(m_widthThinRect, fx, fy)) + { + style->lineWidth = kWidthThinPreset; + return true; + } + if (UiPointInRect(m_widthThickRect, fx, fy)) + { + style->lineWidth = kWidthThickPreset; + return true; + } + if (UiPointInRect(m_widthMinusRect, fx, fy)) + { + return StepLineWidth(-kWidthStep); + } + if (UiPointInRect(m_widthPlusRect, fx, fy)) + { + return StepLineWidth(kWidthStep); + } + for (int index = 0; index < kModeOptionCount; ++index) { if (UiPointInRect(m_modeRects[static_cast(index)], fx, fy)) { @@ -963,10 +1300,11 @@ namespace jdi return true; } } - if (UiPointInRect(m_laneButtonRect, fx, fy) && m_tab->Selected().IsValid()) + + // 点击样式区内其它位置(颜色面板之外)时收起自定义颜色面板。 + if (m_colorPickerOpen && !UiPointInRect(m_pickerRect, fx, fy)) { - ShowLaneMenu(); - return true; + return CloseTransientUi(); } } @@ -990,6 +1328,15 @@ namespace jdi m_rowsDirty = true; return true; } + if (part == RowPart::CLOSE) + { + // 关闭文件属于数据层操作,交由宿主窗口处理。 + if (m_host != nullptr) + { + PostMessageW(m_host, WM_APP + 1, static_cast(item.fileIndex), 0); + } + return true; + } // 折叠三角与行主体均视为切换折叠。 m_tab->ToggleCollapsed(item.fileIndex); m_rowsDirty = true; @@ -1005,11 +1352,21 @@ namespace jdi m_tab->ToggleVisible(*m_dataSet, ref); m_rowsDirty = true; } + else if (part == RowPart::LANE_BADGE) + { + const gd::RectF badge = SignalBadgeRect(row); + OpenLaneMenu(ref, static_cast(badge.X), static_cast(badge.Y + badge.Height)); + } return true; } bool SignalPanel::OnLButtonUp(int /*x*/, int /*y*/) { + if (m_draggingSlider >= 0) + { + m_draggingSlider = -1; + return true; + } if (m_draggingScrollBar) { m_draggingScrollBar = false; @@ -1061,22 +1418,16 @@ namespace jdi return false; } - POINT cursor = {x, y}; - if (m_host != nullptr) - { - ClientToScreen(m_host, &cursor); - } - const ListRow& item = m_rows[static_cast(row)]; if (item.kind == RowKind::FILE) { - ShowFileMenu(item.fileIndex, cursor.x, cursor.y); + OpenFileMenu(item.fileIndex, x, y); return true; } const SignalRef ref{item.fileIndex, item.signalIndex}; m_tab->SetSelected(ref); - ShowSignalMenu(ref, cursor.x, cursor.y); + OpenSignalMenu(ref, x, y); return true; } @@ -1106,203 +1457,92 @@ namespace jdi return true; } - void SignalPanel::ShowLaneMenu() + void SignalPanel::OpenSignalMenu(const SignalRef& ref, int x, int y) { - if (m_dataSet == nullptr || m_tab == nullptr || m_host == nullptr || !m_tab->Selected().IsValid()) + if (m_menu == nullptr || m_tab == nullptr || m_dataSet == nullptr) { return; } - const SignalRef ref = m_tab->Selected(); - const int currentLane = m_tab->LaneOfSignal(ref); - - HMENU menu = CreatePopupMenu(); - if (menu == nullptr) - { - return; - } - - const std::vector& lanes = m_tab->Lanes(); - for (std::size_t index = 0; index < lanes.size(); ++index) - { - const std::wstring label = L"网格 " + std::to_wstring(index + 1) + L"(" + - std::to_wstring(lanes[index].signals.size()) + L" 条信号)"; - AppendMenuW(menu, MF_STRING, static_cast(kMenuLaneBase + index), label.c_str()); - } - if (!lanes.empty()) - { - AppendMenuW(menu, MF_SEPARATOR, 0, nullptr); - } - AppendMenuW(menu, MF_STRING, static_cast(kMenuLaneNew), L"新建网格"); - - if (currentLane >= 0 && currentLane < static_cast(lanes.size())) - { - CheckMenuRadioItem(menu, static_cast(kMenuLaneBase), - static_cast(kMenuLaneBase + lanes.size() - 1), - static_cast(kMenuLaneBase + currentLane), MF_BYCOMMAND); - } - - POINT anchor = {static_cast(m_laneButtonRect.X), static_cast(m_laneButtonRect.Y)}; - ClientToScreen(m_host, &anchor); - - SetForegroundWindow(m_host); - const int command = TrackPopupMenu(menu, TPM_RETURNCMD | TPM_LEFTALIGN | TPM_TOPALIGN, anchor.x, - anchor.y + static_cast(m_laneButtonRect.Height), 0, m_host, - nullptr); - DestroyMenu(menu); - - if (command >= kMenuLaneBase && command < kMenuLaneNew) - { - m_tab->MoveSignalToLane(*m_dataSet, ref, command - kMenuLaneBase); - } - else if (command == kMenuLaneNew) - { - m_tab->MoveSignalToLane(*m_dataSet, ref, -1); - } - } - - void SignalPanel::ShowSignalMenu(const SignalRef& ref, int screenX, int screenY) - { - if (m_dataSet == nullptr || m_tab == nullptr || m_host == nullptr) - { - return; - } - - SignalStyle* style = m_tab->Style(ref); + const SignalStyle* style = m_tab->Style(ref); const SignalData* signal = m_dataSet->Resolve(ref); if (style == nullptr || signal == nullptr) { return; } - HMENU menu = CreatePopupMenu(); - if (menu == nullptr) - { - return; - } + m_menuTargetSignal = ref; + m_menuTargetFile = -1; - AppendMenuW(menu, MF_STRING, static_cast(kMenuToggleVisible), - style->visible ? L"隐藏该信号" : L"显示该信号"); - AppendMenuW(menu, MF_STRING, static_cast(kMenuSolo), L"仅显示该信号"); - AppendMenuW(menu, MF_STRING, static_cast(kMenuHideAll), L"隐藏全部信号"); - AppendMenuW(menu, MF_SEPARATOR, 0, nullptr); + std::vector items; + + MenuItem header; + header.kind = MenuItemKind::HEADER; + header.label = signal->name; + items.push_back(header); + + MenuItem toggle; + toggle.label = style->visible ? L"隐藏该信号" : L"显示该信号"; + toggle.id = CMD_SIGNAL_TOGGLE; + items.push_back(toggle); + + MenuItem solo; + solo.label = L"仅显示该信号"; + solo.id = CMD_SIGNAL_SOLO; + items.push_back(solo); + + MenuItem showAll; + showAll.label = L"显示全部信号"; + showAll.id = CMD_SIGNAL_SHOW_ALL; + items.push_back(showAll); + + MenuItem hideAll; + hideAll.label = L"隐藏全部信号"; + hideAll.id = CMD_SIGNAL_HIDE_ALL; + items.push_back(hideAll); + + MenuItem separator; + separator.kind = MenuItemKind::SEPARATOR; + items.push_back(separator); - HMENU colorMenu = CreatePopupMenu(); int paletteCount = 0; const std::uint32_t* palette = SignalPalette(paletteCount); - const wchar_t* colorNames[] = {L"蓝", L"绿", L"红", L"紫", L"黄", - L"青", L"品红", L"橙", L"深灰", L"灰"}; - for (int index = 0; index < SignalColorCount(); ++index) + MenuItem colors; + colors.kind = MenuItemKind::SWATCHES; + colors.id = CMD_COLOR_BASE; + colors.swatchCount = std::min(paletteCount, 16); + colors.checkedSwatch = -1; + for (int index = 0; index < colors.swatchCount; ++index) { - const std::uint32_t color = palette[index % paletteCount]; - const std::wstring label = - (index < 10) ? std::wstring(colorNames[index]) : (L"颜色 " + std::to_wstring(index + 1)); - AppendMenuW(colorMenu, MF_STRING, static_cast(kMenuColorBase + index), label.c_str()); - if (style->color == color) + colors.swatches[index] = palette[index]; + if (style->color == palette[index]) { - CheckMenuRadioItem(colorMenu, static_cast(kMenuColorBase), - static_cast(kMenuColorBase + SignalColorCount() - 1), - static_cast(kMenuColorBase + index), MF_BYCOMMAND); + colors.checkedSwatch = index; } } - AppendMenuW(menu, MF_POPUP, reinterpret_cast(colorMenu), L"颜色"); + items.push_back(colors); - HMENU widthMenu = CreatePopupMenu(); - for (int index = 0; index < 4; ++index) - { - const std::wstring label = FormatNumber(kWidthOptions[index], 1) + L" px"; - AppendMenuW(widthMenu, MF_STRING, static_cast(kMenuWidthBase + index), label.c_str()); - if (std::fabs(style->lineWidth - kWidthOptions[index]) < 0.01f) - { - CheckMenuRadioItem(widthMenu, static_cast(kMenuWidthBase), - static_cast(kMenuWidthBase + 3), - static_cast(kMenuWidthBase + index), MF_BYCOMMAND); - } - } - AppendMenuW(menu, MF_POPUP, reinterpret_cast(widthMenu), L"线宽"); + MenuItem modeHeader; + modeHeader.kind = MenuItemKind::HEADER; + modeHeader.label = L"样式"; + items.push_back(modeHeader); - HMENU modeMenu = CreatePopupMenu(); - for (int index = 0; index < 4; ++index) + for (int index = 0; index < kModeOptionCount; ++index) { - AppendMenuW(modeMenu, MF_STRING, static_cast(kMenuModeBase + index), - DrawModeName(kModeOptions[index])); - if (style->mode == kModeOptions[index]) - { - CheckMenuRadioItem(modeMenu, static_cast(kMenuModeBase), - static_cast(kMenuModeBase + 3), - static_cast(kMenuModeBase + index), MF_BYCOMMAND); - } + MenuItem mode; + mode.label = DrawModeName(kModeOptions[index]); + mode.id = CMD_MODE_BASE + index; + mode.checked = (style->mode == kModeOptions[index]); + items.push_back(mode); } - AppendMenuW(menu, MF_POPUP, reinterpret_cast(modeMenu), L"显示方式"); - HMENU laneMenu = CreatePopupMenu(); - const std::vector& lanes = m_tab->Lanes(); - const int currentLane = m_tab->LaneOfSignal(ref); - for (std::size_t index = 0; index < lanes.size(); ++index) - { - const std::wstring laneLabel = L"网格 " + std::to_wstring(index + 1); - AppendMenuW(laneMenu, MF_STRING, static_cast(kMenuLaneBase + index), laneLabel.c_str()); - } - if (!lanes.empty()) - { - AppendMenuW(laneMenu, MF_SEPARATOR, 0, nullptr); - } - AppendMenuW(laneMenu, MF_STRING, static_cast(kMenuLaneNew), L"新建网格"); - if (currentLane >= 0 && !lanes.empty()) - { - CheckMenuRadioItem(laneMenu, static_cast(kMenuLaneBase), - static_cast(kMenuLaneBase + lanes.size() - 1), - static_cast(kMenuLaneBase + currentLane), MF_BYCOMMAND); - } - AppendMenuW(menu, MF_POPUP, reinterpret_cast(laneMenu), L"所属网格"); - - SetForegroundWindow(m_host); - const int command = TrackPopupMenu(menu, TPM_RETURNCMD | TPM_LEFTALIGN | TPM_TOPALIGN, screenX, screenY, - 0, m_host, nullptr); - DestroyMenu(menu); - - if (command == kMenuToggleVisible) - { - m_tab->ToggleVisible(*m_dataSet, ref); - m_rowsDirty = true; - } - else if (command == kMenuSolo) - { - m_tab->SoloSignal(*m_dataSet, ref); - m_rowsDirty = true; - } - else if (command == kMenuHideAll) - { - m_tab->SetAllVisible(*m_dataSet, false); - m_rowsDirty = true; - } - else if (command >= kMenuModeBase && command < kMenuModeBase + 4) - { - style->mode = kModeOptions[command - kMenuModeBase]; - } - else if (command >= kMenuWidthBase && command < kMenuWidthBase + 4) - { - style->lineWidth = kWidthOptions[command - kMenuWidthBase]; - } - else if (command >= kMenuColorBase && command < kMenuColorBase + SignalColorCount()) - { - style->color = palette[(command - kMenuColorBase) % paletteCount]; - } - else if (command >= kMenuLaneBase && command < kMenuLaneNew) - { - m_tab->MoveSignalToLane(*m_dataSet, ref, command - kMenuLaneBase); - m_rowsDirty = true; - } - else if (command == kMenuLaneNew) - { - m_tab->MoveSignalToLane(*m_dataSet, ref, -1); - m_rowsDirty = true; - } + const gd::RectF bounds = (m_clientBounds.Width > 1.0f) ? m_clientBounds : m_area; + m_menu->Open(MenuScope::SIGNAL_PANEL, bounds, x, y, items); } - void SignalPanel::ShowFileMenu(int fileIndex, int screenX, int screenY) + void SignalPanel::OpenFileMenu(int fileIndex, int x, int y) { - if (m_dataSet == nullptr || m_tab == nullptr || m_host == nullptr) + if (m_menu == nullptr || m_tab == nullptr || m_dataSet == nullptr) { return; } @@ -1313,40 +1553,184 @@ namespace jdi return; } - HMENU menu = CreatePopupMenu(); - if (menu == nullptr) + m_menuTargetFile = fileIndex; + m_menuTargetSignal = SignalRef(); + + std::vector items; + + MenuItem header; + header.kind = MenuItemKind::HEADER; + header.label = file->fileName; + items.push_back(header); + + MenuItem showAll; + showAll.label = L"选中该文件全部信号"; + showAll.id = CMD_FILE_SHOW_ALL; + items.push_back(showAll); + + MenuItem hideAll; + hideAll.label = L"取消该文件全部信号"; + hideAll.id = CMD_FILE_HIDE_ALL; + items.push_back(hideAll); + + MenuItem separator; + separator.kind = MenuItemKind::SEPARATOR; + items.push_back(separator); + + MenuItem remove; + remove.label = L"关闭该文件(从工作区移除)"; + remove.id = CMD_FILE_REMOVE; + items.push_back(remove); + + const gd::RectF bounds = (m_clientBounds.Width > 1.0f) ? m_clientBounds : m_area; + m_menu->Open(MenuScope::SIGNAL_PANEL, bounds, x, y, items); + } + + void SignalPanel::OpenLaneMenu(const SignalRef& ref, int x, int y) + { + if (m_menu == nullptr || m_tab == nullptr || m_dataSet == nullptr) { return; } - AppendMenuW(menu, MF_STRING, static_cast(kMenuFileShowAll), L"选中该文件全部信号"); - AppendMenuW(menu, MF_STRING, static_cast(kMenuFileHideAll), L"取消该文件全部信号"); - AppendMenuW(menu, MF_SEPARATOR, 0, nullptr); - AppendMenuW(menu, MF_STRING, static_cast(kMenuFileRemove), L"从工作区移除该文件"); - - SetForegroundWindow(m_host); - const int command = TrackPopupMenu(menu, TPM_RETURNCMD | TPM_LEFTALIGN | TPM_TOPALIGN, screenX, - screenY, 0, m_host, nullptr); - DestroyMenu(menu); - - if (command == kMenuFileShowAll) + const SignalData* signal = m_dataSet->Resolve(ref); + if (signal == nullptr) { - m_tab->SetFileVisible(*m_dataSet, fileIndex, true); - m_rowsDirty = true; + return; } - else if (command == kMenuFileHideAll) + + m_menuTargetSignal = ref; + m_menuTargetFile = -1; + + std::vector items; + + MenuItem header; + header.kind = MenuItemKind::HEADER; + header.label = L"所属网格"; + items.push_back(header); + + const std::vector& lanes = m_tab->Lanes(); + const int currentLane = m_tab->LaneOfSignal(ref); + for (std::size_t index = 0; index < lanes.size(); ++index) { - m_tab->SetFileVisible(*m_dataSet, fileIndex, false); - m_rowsDirty = true; + MenuItem lane; + lane.label = L"网格 " + std::to_wstring(index + 1) + L"(" + + std::to_wstring(lanes[index].signals.size()) + L" 条信号)"; + lane.id = CMD_LANE_BASE + static_cast(index); + lane.checked = (currentLane == static_cast(index)); + items.push_back(lane); } - else if (command == kMenuFileRemove) + + MenuItem separator; + separator.kind = MenuItemKind::SEPARATOR; + items.push_back(separator); + + MenuItem create; + create.label = L"新建网格"; + create.id = CMD_LANE_NEW; + items.push_back(create); + + const gd::RectF bounds = (m_clientBounds.Width > 1.0f) ? m_clientBounds : m_area; + m_menu->Open(MenuScope::SIGNAL_PANEL, bounds, x, y, items); + } + + bool SignalPanel::OnMenuCommand(const MenuResult& result) + { + if (m_tab == nullptr || m_dataSet == nullptr || !result.valid) { - // 移除文件属于数据层操作,交由宿主窗口处理。 - if (m_host != nullptr) + return false; + } + + bool needRelayout = false; + + switch (result.id) + { + case CMD_SIGNAL_TOGGLE: + if (m_menuTargetSignal.IsValid()) { - PostMessageW(m_host, WM_APP + 1, static_cast(fileIndex), 0); + m_tab->ToggleVisible(*m_dataSet, m_menuTargetSignal); + m_rowsDirty = true; + needRelayout = true; } + break; + case CMD_SIGNAL_SOLO: + if (m_menuTargetSignal.IsValid()) + { + m_tab->SoloSignal(*m_dataSet, m_menuTargetSignal); + m_rowsDirty = true; + needRelayout = true; + } + break; + case CMD_SIGNAL_SHOW_ALL: + m_tab->SetAllVisible(*m_dataSet, true); + m_rowsDirty = true; + needRelayout = true; + break; + case CMD_SIGNAL_HIDE_ALL: + m_tab->SetAllVisible(*m_dataSet, false); + m_rowsDirty = true; + needRelayout = true; + break; + case CMD_FILE_SHOW_ALL: + if (m_menuTargetFile >= 0) + { + m_tab->SetFileVisible(*m_dataSet, m_menuTargetFile, true); + m_rowsDirty = true; + needRelayout = true; + } + break; + case CMD_FILE_HIDE_ALL: + if (m_menuTargetFile >= 0) + { + m_tab->SetFileVisible(*m_dataSet, m_menuTargetFile, false); + m_rowsDirty = true; + needRelayout = true; + } + break; + case CMD_FILE_REMOVE: + if (m_menuTargetFile >= 0 && m_host != nullptr) + { + PostMessageW(m_host, WM_APP + 1, static_cast(m_menuTargetFile), 0); + } + break; + case CMD_LANE_NEW: + if (m_menuTargetSignal.IsValid()) + { + m_tab->MoveSignalToLane(*m_dataSet, m_menuTargetSignal, -1); + m_rowsDirty = true; + needRelayout = true; + } + break; + default: + if (result.id >= CMD_LANE_BASE && result.id < CMD_LANE_NEW) + { + if (m_menuTargetSignal.IsValid()) + { + m_tab->MoveSignalToLane(*m_dataSet, m_menuTargetSignal, result.id - CMD_LANE_BASE); + m_rowsDirty = true; + needRelayout = true; + } + } + else if (result.id == CMD_COLOR_BASE && result.swatchIndex >= 0) + { + if (SignalStyle* style = m_tab->Style(m_menuTargetSignal)) + { + int paletteCount = 0; + const std::uint32_t* palette = SignalPalette(paletteCount); + style->color = palette[result.swatchIndex % std::max(1, paletteCount)]; + } + } + else if (result.id >= CMD_MODE_BASE && result.id < CMD_MODE_BASE + kModeOptionCount) + { + if (SignalStyle* style = m_tab->Style(m_menuTargetSignal)) + { + style->mode = kModeOptions[result.id - CMD_MODE_BASE]; + } + } + break; } + + return needRelayout; } } // namespace jdi diff --git a/Utilities/Inc/DataModel.hpp b/Utilities/Inc/DataModel.hpp index c1bddde..d45ff46 100644 --- a/Utilities/Inc/DataModel.hpp +++ b/Utilities/Inc/DataModel.hpp @@ -18,8 +18,7 @@ namespace jdi { LINE = 0, /**< 相邻采样点之间以插值直线连接。 */ SCATTER = 1, /**< 仅绘制采样点散点。 */ - LINE_SCATTER = 2, /**< 同时绘制插值直线与采样点散点。 */ - STEP = 3 /**< 阶梯保持(零阶保持)连线。 */ + LINE_SCATTER = 2 /**< 同时绘制插值直线与采样点散点。 */ }; /** diff --git a/Utilities/Inc/PaintUtil.hpp b/Utilities/Inc/PaintUtil.hpp index 903f47e..4165289 100644 --- a/Utilities/Inc/PaintUtil.hpp +++ b/Utilities/Inc/PaintUtil.hpp @@ -23,6 +23,17 @@ namespace jdi RIGHT = 2 /**< 右对齐。 */ }; + /** + * @brief 图标朝向。 + */ + enum class IconDirection : int + { + LEFT = 0, /**< 指向左侧。 */ + RIGHT = 1, /**< 指向右侧。 */ + UP = 2, /**< 指向上方。 */ + DOWN = 3 /**< 指向下方。 */ + }; + /** * @brief 按 DPI 创建并缓存整套界面字体(GDI 字体句柄)。 */ @@ -166,6 +177,61 @@ namespace jdi */ std::wstring UiEllipsize(gd::Graphics& g, const std::wstring& text, HFONT font, float maxWidth); + /** + * @brief 在矩形中心绘制一个加号图标。 + * @param [IN,OUT] g 绘图上下文。 + * @param [IN] rect 图标布局矩形。 + * @param [IN] argb 线条颜色。 + * @param [IN] strokeWidth 线条宽度。 + */ + void UiDrawPlusIcon(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 线条宽度。 + */ + 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 绘图上下文。 + * @param [IN] rect 图标布局矩形。 + * @param [IN] argb 填充颜色。 + * @param [IN] direction 三角形朝向。 + */ + void UiDrawTriangleIcon(gd::Graphics& g, const gd::RectF& rect, std::uint32_t argb, IconDirection direction); + + /** + * @brief 将滑块轨道上的横坐标换算为取值比例。 + * @param [IN] trackRect 轨道矩形。 + * @param [IN] x 横坐标。 + * @return 取值比例,范围 0-1。 + */ + double UiSliderRatioFromX(const gd::RectF& trackRect, float x); + + /** + * @brief 绘制一个带边框的复选框。 + * @param [IN,OUT] g 绘图上下文。 + * @param [IN] rect 复选框矩形。 + * @param [IN] state 选中状态:0 未选,1 全选,2 部分选。 + * @param [IN] radius 圆角半径。 + */ + void UiDrawCheckBox(gd::Graphics& g, const gd::RectF& rect, int state, float radius); + } // namespace jdi #endif // __PAINT_UTIL_HPP__ diff --git a/Utilities/Inc/PopupMenu.hpp b/Utilities/Inc/PopupMenu.hpp new file mode 100644 index 0000000..c9b44a2 --- /dev/null +++ b/Utilities/Inc/PopupMenu.hpp @@ -0,0 +1,222 @@ +#ifndef __POPUP_MENU_HPP__ +#define __POPUP_MENU_HPP__ + +#include +#include +#include + +#include +#include +#include + +#include "PaintUtil.hpp" +#include "Theme.hpp" + +namespace jdi +{ + + /** + * @brief 弹出菜单的打开来源,用于把选择结果派发回正确的组件。 + */ + enum class MenuScope : int + { + NONE = 0, /**< 无来源。 */ + SIGNAL_PANEL = 1, /**< 信号面板。 */ + TAB_BAR = 2 /**< 标签栏。 */ + }; + + /** + * @brief 弹出菜单项的显示类型。 + */ + enum class MenuItemKind : int + { + NORMAL = 0, /**< 普通可点击项。 */ + SEPARATOR = 1, /**< 分隔线。 */ + HEADER = 2, /**< 分组标题(不可点击)。 */ + SWATCHES = 3 /**< 颜色色块行。 */ + }; + + /** + * @brief 单个弹出菜单项。 + */ + struct MenuItem + { + MenuItemKind kind = MenuItemKind::NORMAL; /**< 项目类型。 */ + std::wstring label; /**< 显示文本。 */ + int id = 0; /**< 命令标识。 */ + bool enabled = true; /**< 是否可用。 */ + bool checked = false; /**< 是否处于选中(单选)状态。 */ + int swatchCount = 0; /**< 色块数量(仅色块行有效)。 */ + std::uint32_t swatches[16] = {}; /**< 色块颜色。 */ + int checkedSwatch = -1; /**< 被选中的色块下标。 */ + }; + + /** + * @brief 菜单选择结果。 + */ + struct MenuResult + { + bool valid = false; /**< 是否产生了有效选择。 */ + MenuScope scope = MenuScope::NONE; /**< 来源组件。 */ + int id = 0; /**< 被选中项的命令标识。 */ + int swatchIndex = -1; /**< 被选中的色块下标(非色块行为 -1)。 */ + }; + + /** + * @brief 与界面主题一致的自绘弹出菜单。 + * + * 菜单不创建独立窗口,而是作为宿主窗口绘制流程的最上层内容直接绘制到后备缓冲区, + * 因此外观与主界面完全统一,不会出现系统默认菜单样式。 + */ + class PopupMenu + { + public: + PopupMenu(); + + /** + * @brief 绑定字体与主题资源。 + * @param [IN] fonts 字体集合,需在菜单生命周期内保持有效。 + * @param [IN] theme 主题配色,需在菜单生命周期内保持有效。 + */ + void Setup(const UiFonts* fonts, const Theme* theme); + + /** + * @brief 设置 DPI 缩放比例。 + * @param [IN] scale 缩放比例(1.0 对应 96 DPI)。 + */ + void SetScale(float scale); + + /** + * @brief 在指定位置打开菜单。 + * @param [IN] scope 来源组件标识。 + * @param [IN] bounds 宿主客户区矩形,用于把菜单限制在窗口内。 + * @param [IN] x 锚点横坐标(客户区坐标)。 + * @param [IN] y 锚点纵坐标(客户区坐标)。 + * @param [IN] items 菜单项集合。 + */ + void Open(MenuScope scope, const gd::RectF& bounds, int x, int y, const std::vector& items); + + /** + * @brief 关闭菜单。 + */ + void Close(); + + /** + * @brief 判断菜单是否处于打开状态。 + * @return 打开返回 true。 + */ + bool IsOpen() const { return m_open; } + + /** + * @brief 获取当前菜单的来源组件标识。 + * @return 来源标识。 + */ + MenuScope Scope() const { return m_scope; } + + /** + * @brief 绘制菜单(应在宿主绘制的最后一步调用)。 + * @param [IN,OUT] g 绘图上下文。 + */ + void Paint(gd::Graphics& g); + + /** + * @brief 处理鼠标移动。 + * @param [IN] x 客户区横坐标。 + * @param [IN] y 客户区纵坐标。 + * @return 需要重绘返回 true。 + */ + bool OnMouseMove(int x, int y); + + /** + * @brief 处理鼠标左键按下。 + * @param [IN] x 客户区横坐标。 + * @param [IN] y 客户区纵坐标。 + * @param [OUT] result 在菜单外按下时返回被取消的选择结果。 + * @return 事件已被菜单消费返回 true。 + */ + bool OnLButtonDown(int x, int y, MenuResult& result); + + /** + * @brief 处理鼠标左键抬起。 + * @param [IN] x 客户区横坐标。 + * @param [IN] y 客户区纵坐标。 + * @param [OUT] result 选择结果。 + * @return 事件已被菜单消费返回 true。 + */ + bool OnLButtonUp(int x, int y, MenuResult& result); + + /** + * @brief 处理键盘按键。 + * @param [IN] key 虚拟键码。 + * @param [OUT] result 选择结果。 + * @return 事件已被菜单消费返回 true。 + */ + bool OnKeyDown(WPARAM key, MenuResult& result); + + /** + * @brief 判断坐标是否落在菜单内部。 + * @param [IN] x 客户区横坐标。 + * @param [IN] y 客户区纵坐标。 + * @return 落在菜单内返回 true。 + */ + bool ContainsPoint(int x, int y) const; + + private: + /** + * @brief 计算单个菜单项的纵向高度。 + * @param [IN] item 菜单项。 + * @return 高度(像素)。 + */ + float ItemHeight(const MenuItem& item) const; + + /** + * @brief 依据当前项目集合计算整体矩形与各项矩形。 + * @param [IN] bounds 宿主客户区矩形。 + * @param [IN] x 锚点横坐标。 + * @param [IN] y 锚点纵坐标。 + */ + void BuildLayout(const gd::RectF& bounds, int x, int y); + + /** + * @brief 查询坐标命中的菜单项下标。 + * @param [IN] x 客户区横坐标。 + * @param [IN] y 客户区纵坐标。 + * @param [OUT] swatchIndex 命中的色块下标(非色块行为 -1)。 + * @return 项目下标,未命中返回 -1。 + */ + int ItemAtPoint(int x, int y, int& swatchIndex) const; + + /** + * @brief 将悬停项移动到下一个可用项。 + * @param [IN] step 步进(+1 向下,-1 向上)。 + */ + void MoveHover(int step); + + /** + * @brief 确认当前悬停项并生成结果。 + * @param [OUT] result 选择结果。 + * @return 生成了有效选择返回 true。 + */ + bool CommitHover(MenuResult& result); + + const UiFonts* m_fonts = nullptr; /**< 字体资源。 */ + const Theme* m_theme = nullptr; /**< 主题资源。 */ + float m_scale = 1.0f; /**< DPI 缩放比例。 */ + + bool m_open = false; /**< 是否处于打开状态。 */ + MenuScope m_scope = MenuScope::NONE; /**< 来源组件标识。 */ + std::vector m_items; /**< 菜单项集合。 */ + + gd::RectF m_rect; /**< 菜单整体矩形。 */ + std::vector m_itemRects; /**< 各项矩形。 */ + std::vector> m_swatchRects; /**< 各项内的色块矩形。 */ + + int m_hoverIndex = -1; /**< 当前悬停项。 */ + int m_hoverSwatch = -1; /**< 当前悬停色块。 */ + int m_pressedIndex = -1; /**< 按下时的项目下标。 */ + int m_pressedSwatch = -1; /**< 按下时的色块下标。 */ + }; + +} // namespace jdi + +#endif // __POPUP_MENU_HPP__ diff --git a/Utilities/Src/DataModel.cpp b/Utilities/Src/DataModel.cpp index 04e135f..810e249 100644 --- a/Utilities/Src/DataModel.cpp +++ b/Utilities/Src/DataModel.cpp @@ -73,13 +73,12 @@ namespace jdi } /** - * @brief 从列名末尾的括号中提取单位(仅接受形如 mA / V / deg/s 的短标记)。 - * @param [IN] header 原始列名。 + * @brief 从名称末尾的括号中提取单位(仅接受形如 mA / V / deg/s 的短标记)。 + * @param [IN] text 已剥离 "[n]" 前缀的名称。 * @return 单位文本,无法识别时返回空字符串。 */ - std::wstring ExtractUnit(const std::wstring& header) + std::wstring ExtractUnit(const std::wstring& text) { - const std::wstring text = Trim(header); if (text.size() < 3 || text.back() != L')') { return std::wstring(); @@ -110,6 +109,46 @@ namespace jdi return inner; } + /** + * @brief 从名称末尾移除已被识别为单位(或描述)的括号片段。 + * @param [IN] name 已剥离 "[n]" 前缀的名称。 + * @return 移除尾部括号后的名称(括号片段非单位时原样返回)。 + */ + std::wstring StripBracketSuffix(const std::wstring& name) + { + if (name.size() < 3 || name.back() != L')') + { + return name; + } + + const std::size_t open = name.rfind(L'('); + if (open == std::wstring::npos || open == 0 || open + 2 >= name.size()) + { + return name; + } + + const std::wstring inner = Trim(name.substr(open + 1, name.size() - open - 2)); + if (inner.empty() || inner.size() > 10) + { + return name; + } + + // 仅当括号内容为短单位标记时才剥离,避免误伤 "(5 Points)" 这类描述。 + for (const wchar_t c : inner) + { + const bool allowed = iswalpha(c) || iswdigit(c) || c == L'%' || c == L'/' || c == L'^' || + c == L'-' || c == L'.' || c == L'\u00B0' || c == L'\u00B5' || + c == L'\u03A9' || c == L'\u00B2' || c == L'\u00B3' || c == L'\u00B7'; + if (!allowed) + { + return name; + } + } + + const std::wstring head = Trim(name.substr(0, open)); + return head.empty() ? name : head; + } + /** * @brief 依据全局信号序号从调色板取色,保证跨标签页配色一致。 * @param [IN] globalIndex 全局信号序号。 @@ -291,8 +330,6 @@ namespace jdi return L"散点"; case DrawMode::LINE_SCATTER: return L"点线"; - case DrawMode::STEP: - return L"阶梯"; default: return L"线段"; } @@ -383,8 +420,12 @@ namespace jdi SignalData signal; signal.rawHeader = table.headers[column]; - signal.name = MakeDisplayName(signal.rawHeader); - signal.unit = ExtractUnit(signal.rawHeader); + { + // 名称与单位分开保存:名称不再保留尾部单位括号,避免重复显示。 + const std::wstring baseName = MakeDisplayName(signal.rawHeader); + signal.unit = ExtractUnit(baseName); + signal.name = StripBracketSuffix(baseName); + } signal.values.resize(rowCount); double minValue = std::numeric_limits::infinity(); diff --git a/Utilities/Src/PaintUtil.cpp b/Utilities/Src/PaintUtil.cpp index 7c7131d..1e289ed 100644 --- a/Utilities/Src/PaintUtil.cpp +++ b/Utilities/Src/PaintUtil.cpp @@ -288,4 +288,136 @@ namespace jdi return ellipsis; } + void UiDrawPlusIcon(gd::Graphics& g, const gd::RectF& rect, std::uint32_t argb, float strokeWidth) + { + 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.3f; + UiDrawLine(g, cx - arm, cy, cx + arm, cy, argb, strokeWidth); + UiDrawLine(g, cx, cy - arm, cx, cy + arm, argb, strokeWidth); + } + + void UiDrawCrossIcon(gd::Graphics& g, const gd::RectF& rect, std::uint32_t argb, float strokeWidth) + { + 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.3f; + UiDrawLine(g, cx - arm, cy - arm, cx + arm, cy + arm, argb, strokeWidth); + 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; + const float cy = rect.Y + rect.Height * 0.5f; + const float arm = std::min(rect.Width, rect.Height) * 0.28f; + + gd::PointF points[3]; + switch (direction) + { + case IconDirection::LEFT: + points[0] = gd::PointF(cx + arm * 0.7f, cy - arm); + points[1] = gd::PointF(cx - arm * 0.7f, cy); + points[2] = gd::PointF(cx + arm * 0.7f, cy + arm); + break; + case IconDirection::RIGHT: + points[0] = gd::PointF(cx - arm * 0.7f, cy - arm); + points[1] = gd::PointF(cx + arm * 0.7f, cy); + points[2] = gd::PointF(cx - arm * 0.7f, cy + arm); + break; + case IconDirection::UP: + points[0] = gd::PointF(cx - arm, cy + arm * 0.7f); + points[1] = gd::PointF(cx, cy - arm * 0.7f); + points[2] = gd::PointF(cx + arm, cy + arm * 0.7f); + break; + case IconDirection::DOWN: + default: + points[0] = gd::PointF(cx - arm, cy - arm * 0.7f); + points[1] = gd::PointF(cx, cy + arm * 0.7f); + points[2] = gd::PointF(cx + arm, cy - arm * 0.7f); + break; + } + + gd::SolidBrush brush(ToGdiColor(argb)); + g.FillPolygon(&brush, points, 3); + } + + double UiSliderRatioFromX(const gd::RectF& trackRect, float x) + { + if (trackRect.Width <= 0.0f) + { + return 0.0; + } + const double ratio = static_cast(x - trackRect.X) / static_cast(trackRect.Width); + if (ratio < 0.0) + { + return 0.0; + } + if (ratio > 1.0) + { + return 1.0; + } + return ratio; + } + + void UiDrawCheckBox(gd::Graphics& g, const gd::RectF& rect, int state, float radius) + { + const Theme& theme = SolarizedLight(); + const float centerY = rect.Y + rect.Height * 0.5f; + + if (state == 1) + { + UiFillRoundRect(g, rect, radius, theme.accent); + gd::Pen pen(ToGdiColor(theme.textOnAccent), std::max(1.4f, rect.Height * 0.11f)); + pen.SetStartCap(gd::LineCapRound); + pen.SetEndCap(gd::LineCapRound); + g.DrawLine(&pen, rect.X + rect.Width * 0.22f, centerY, rect.X + rect.Width * 0.42f, + rect.Y + rect.Height * 0.72f); + g.DrawLine(&pen, rect.X + rect.Width * 0.42f, rect.Y + rect.Height * 0.72f, + rect.X + rect.Width * 0.78f, rect.Y + rect.Height * 0.28f); + } + else if (state == 2) + { + UiFillRoundRect(g, rect, radius, theme.accentSoft); + UiDrawRoundRect(g, rect, radius, theme.accent, 1.0f); + UiFillRect(g, gd::RectF(rect.X + rect.Width * 0.22f, centerY - rect.Height * 0.08f, + rect.Width * 0.56f, rect.Height * 0.16f), + theme.accent); + } + else + { + UiFillRoundRect(g, rect, radius, theme.cardBg); + UiDrawRoundRect(g, rect, radius, theme.borderStrong, 1.0f); + } + } + } // namespace jdi diff --git a/Utilities/Src/PopupMenu.cpp b/Utilities/Src/PopupMenu.cpp new file mode 100644 index 0000000..90afe38 --- /dev/null +++ b/Utilities/Src/PopupMenu.cpp @@ -0,0 +1,509 @@ +#include "PopupMenu.hpp" + +#include +#include + +namespace jdi +{ + + namespace + { + const float kRowHeight = 27.0f; /**< 普通项高度。 */ + const float kHeaderHeight = 22.0f; /**< 分组标题高度。 */ + const float kSeparatorHeight = 9.0f; /**< 分隔线高度。 */ + const float kSwatchRowHeight = 32.0f; /**< 色块行高度。 */ + const float kPadV = 5.0f; /**< 菜单上下内边距。 */ + const float kPadH = 6.0f; /**< 菜单左右内边距。 */ + const float kMarkerColumn = 22.0f; /**< 选中标记列宽度。 */ + const float kRightPad = 14.0f; /**< 文本右侧留白。 */ + const float kMinWidth = 148.0f; /**< 菜单最小宽度。 */ + const float kMaxWidth = 340.0f; /**< 菜单最大宽度。 */ + const float kCorner = 6.0f; /**< 菜单圆角半径。 */ + const float kSwatchSize = 22.0f; /**< 色块尺寸。 */ + const float kSwatchGap = 5.0f; /**< 色块间距。 */ + } + + PopupMenu::PopupMenu() = default; + + void PopupMenu::Setup(const UiFonts* fonts, const Theme* theme) + { + m_fonts = fonts; + m_theme = theme; + } + + void PopupMenu::SetScale(float scale) + { + m_scale = (scale > 0.1f) ? scale : 1.0f; + } + + float PopupMenu::ItemHeight(const MenuItem& item) const + { + switch (item.kind) + { + case MenuItemKind::SEPARATOR: + return kSeparatorHeight * m_scale; + case MenuItemKind::HEADER: + return kHeaderHeight * m_scale; + case MenuItemKind::SWATCHES: + return kSwatchRowHeight * m_scale; + case MenuItemKind::NORMAL: + default: + return kRowHeight * m_scale; + } + } + + void PopupMenu::BuildLayout(const gd::RectF& bounds, int x, int y) + { + m_itemRects.assign(m_items.size(), gd::RectF()); + m_swatchRects.assign(m_items.size(), std::vector()); + + if (m_fonts == nullptr) + { + m_rect = gd::RectF(); + return; + } + + float contentWidth = kMinWidth * m_scale; + float totalHeight = kPadV * 2.0f * m_scale; + + for (std::size_t index = 0; index < m_items.size(); ++index) + { + const MenuItem& item = m_items[index]; + totalHeight += ItemHeight(item); + + if (item.kind == MenuItemKind::SWATCHES) + { + const int count = std::max(0, std::min(item.swatchCount, 16)); + const float width = kPadH * 2.0f * m_scale + static_cast(count) * kSwatchSize * m_scale + + static_cast(std::max(0, count - 1)) * kSwatchGap * m_scale; + contentWidth = std::max(contentWidth, width); + } + else if (item.kind == MenuItemKind::HEADER) + { + const float width = (kPadH + 8.0f) * m_scale + + UiMeasureTextWidth(m_fonts->uiSmallBold, item.label) + kRightPad * m_scale; + contentWidth = std::max(contentWidth, width); + } + else if (item.kind == MenuItemKind::NORMAL) + { + const float width = (kPadH + kMarkerColumn) * m_scale + + UiMeasureTextWidth(m_fonts->uiSmall, item.label) + kRightPad * m_scale; + contentWidth = std::max(contentWidth, width); + } + } + + contentWidth = std::min(contentWidth, kMaxWidth * m_scale); + const float width = contentWidth; + const float height = totalHeight; + + float left = static_cast(x); + float top = static_cast(y); + + // 优先向右下展开;越界时向左或向上翻转,最后做边界收敛。 + if (left + width > bounds.X + bounds.Width) + { + left = static_cast(x) - width; + } + if (top + height > bounds.Y + bounds.Height) + { + top = static_cast(y) - height; + } + left = std::max(bounds.X + 2.0f, std::min(left, bounds.X + bounds.Width - width - 2.0f)); + top = std::max(bounds.Y + 2.0f, std::min(top, bounds.Y + bounds.Height - height - 2.0f)); + + m_rect = gd::RectF(left, top, width, height); + + float cursorY = top + kPadV * m_scale; + for (std::size_t index = 0; index < m_items.size(); ++index) + { + const MenuItem& item = m_items[index]; + const float itemHeight = ItemHeight(item); + m_itemRects[index] = gd::RectF(left + kPadH * m_scale, cursorY, + std::max(0.0f, width - kPadH * 2.0f * m_scale), itemHeight); + + if (item.kind == MenuItemKind::SWATCHES) + { + const int count = std::max(0, std::min(item.swatchCount, 16)); + std::vector& rects = m_swatchRects[index]; + rects.reserve(static_cast(count)); + const float swatchY = cursorY + (itemHeight - kSwatchSize * m_scale) * 0.5f; + const float startX = left + (kPadH + 4.0f) * m_scale; + for (int swatch = 0; swatch < count; ++swatch) + { + const float swatchX = + startX + static_cast(swatch) * (kSwatchSize + kSwatchGap) * m_scale; + rects.push_back(gd::RectF(swatchX, swatchY, kSwatchSize * m_scale, kSwatchSize * m_scale)); + } + } + + cursorY += itemHeight; + } + } + + void PopupMenu::Open(MenuScope scope, const gd::RectF& bounds, int x, int y, + const std::vector& items) + { + m_items = items; + if (m_items.empty()) + { + Close(); + return; + } + + m_scope = scope; + m_open = true; + m_hoverIndex = -1; + m_hoverSwatch = -1; + m_pressedIndex = -1; + m_pressedSwatch = -1; + + BuildLayout(bounds, x, y); + } + + void PopupMenu::Close() + { + m_open = false; + m_scope = MenuScope::NONE; + m_items.clear(); + m_itemRects.clear(); + m_swatchRects.clear(); + m_hoverIndex = -1; + m_hoverSwatch = -1; + m_pressedIndex = -1; + m_pressedSwatch = -1; + } + + bool PopupMenu::ContainsPoint(int x, int y) const + { + if (!m_open) + { + return false; + } + return UiPointInRect(m_rect, static_cast(x), static_cast(y)); + } + + int PopupMenu::ItemAtPoint(int x, int y, int& swatchIndex) const + { + swatchIndex = -1; + if (!m_open) + { + return -1; + } + + const float fx = static_cast(x); + const float fy = static_cast(y); + + for (std::size_t index = 0; index < m_items.size(); ++index) + { + const MenuItem& item = m_items[index]; + if (item.kind == MenuItemKind::SEPARATOR || item.kind == MenuItemKind::HEADER) + { + continue; + } + if (!UiPointInRect(m_itemRects[index], fx, fy)) + { + continue; + } + + if (item.kind == MenuItemKind::SWATCHES) + { + const std::vector& rects = m_swatchRects[index]; + for (std::size_t swatch = 0; swatch < rects.size(); ++swatch) + { + if (UiPointInRect(rects[swatch], fx, fy)) + { + swatchIndex = static_cast(swatch); + return static_cast(index); + } + } + // 落在色块行的空白处:整行视为可点击,用于定位提示。 + return -1; + } + + if (!item.enabled) + { + return -1; + } + return static_cast(index); + } + return -1; + } + + void PopupMenu::MoveHover(int step) + { + if (m_items.empty()) + { + return; + } + + const int count = static_cast(m_items.size()); + int index = m_hoverIndex; + for (int attempt = 0; attempt < count; ++attempt) + { + index += step; + if (index < 0) + { + index = count - 1; + } + else if (index >= count) + { + index = 0; + } + + const MenuItem& item = m_items[static_cast(index)]; + if (item.kind == MenuItemKind::NORMAL && item.enabled) + { + m_hoverIndex = index; + m_hoverSwatch = -1; + return; + } + } + } + + bool PopupMenu::CommitHover(MenuResult& result) + { + if (m_hoverIndex < 0 || m_hoverIndex >= static_cast(m_items.size())) + { + return false; + } + + const MenuItem& item = m_items[static_cast(m_hoverIndex)]; + if (item.kind != MenuItemKind::NORMAL || !item.enabled) + { + return false; + } + + result.valid = true; + result.scope = m_scope; + result.id = item.id; + result.swatchIndex = -1; + return true; + } + + bool PopupMenu::OnMouseMove(int x, int y) + { + if (!m_open) + { + return false; + } + + int swatchIndex = -1; + const int index = ItemAtPoint(x, y, swatchIndex); + + if (index == m_hoverIndex && swatchIndex == m_hoverSwatch) + { + return false; + } + m_hoverIndex = index; + m_hoverSwatch = swatchIndex; + return true; + } + + bool PopupMenu::OnLButtonDown(int x, int y, MenuResult& result) + { + result = MenuResult(); + if (!m_open) + { + return false; + } + + if (!ContainsPoint(x, y)) + { + // 点击菜单外部:关闭菜单,不产生任何命令。 + Close(); + return true; + } + + int swatchIndex = -1; + m_pressedIndex = ItemAtPoint(x, y, swatchIndex); + m_pressedSwatch = swatchIndex; + return true; + } + + bool PopupMenu::OnLButtonUp(int x, int y, MenuResult& result) + { + result = MenuResult(); + if (!m_open) + { + return false; + } + + int swatchIndex = -1; + const int index = ItemAtPoint(x, y, swatchIndex); + const int pressedIndex = m_pressedIndex; + const int pressedSwatch = m_pressedSwatch; + m_pressedIndex = -1; + m_pressedSwatch = -1; + + // 在菜单之外释放鼠标时视为取消操作。 + if (!ContainsPoint(x, y)) + { + Close(); + return true; + } + + if (index < 0 || index != pressedIndex) + { + return true; + } + + const MenuItem& item = m_items[static_cast(index)]; + + result.valid = true; + result.scope = m_scope; + result.id = item.id; + result.swatchIndex = -1; + + if (item.kind == MenuItemKind::SWATCHES) + { + if (swatchIndex < 0 || swatchIndex != pressedSwatch) + { + result.valid = false; + return true; + } + result.swatchIndex = swatchIndex; + } + + Close(); + return true; + } + + bool PopupMenu::OnKeyDown(WPARAM key, MenuResult& result) + { + result = MenuResult(); + if (!m_open) + { + return false; + } + + switch (key) + { + case VK_ESCAPE: + Close(); + return true; + case VK_DOWN: + MoveHover(1); + return true; + case VK_UP: + MoveHover(-1); + return true; + case VK_RETURN: + { + if (CommitHover(result)) + { + Close(); + } + return true; + } + default: + break; + } + return true; + } + + void PopupMenu::Paint(gd::Graphics& g) + { + if (!m_open || m_theme == nullptr || m_fonts == nullptr || m_rect.Width <= 0.0f) + { + return; + } + + // 投影:用两层递减透明度的圆角矩形模拟,避免引入系统阴影 API。 + UiFillRoundRect(g, gd::RectF(m_rect.X + 1.0f * m_scale, m_rect.Y + 2.0f * m_scale, m_rect.Width, + m_rect.Height), + kCorner * m_scale, 0x14000000u); + UiFillRoundRect(g, gd::RectF(m_rect.X + 2.0f * m_scale, m_rect.Y + 4.0f * m_scale, m_rect.Width, + m_rect.Height), + kCorner * m_scale, 0x10000000u); + + UiFillRoundRect(g, m_rect, kCorner * m_scale, m_theme->cardBg); + UiDrawRoundRect(g, m_rect, kCorner * m_scale, m_theme->borderStrong, 1.0f); + + for (std::size_t index = 0; index < m_items.size(); ++index) + { + const MenuItem& item = m_items[index]; + const gd::RectF& rect = m_itemRects[index]; + + if (item.kind == MenuItemKind::SEPARATOR) + { + const float lineY = rect.Y + rect.Height * 0.5f; + UiDrawLine(g, rect.X + 4.0f * m_scale, lineY, rect.X + rect.Width - 4.0f * m_scale, lineY, + m_theme->border, 1.0f); + continue; + } + + if (item.kind == MenuItemKind::HEADER) + { + UiText(g, item.label, m_fonts->uiSmallBold, + gd::RectF(rect.X + 8.0f * m_scale, rect.Y, rect.Width, rect.Height), + m_theme->textMuted, TextAlign::LEFT); + continue; + } + + const bool hovered = (static_cast(index) == m_hoverIndex) && + (item.kind != MenuItemKind::SWATCHES || m_hoverSwatch < 0); + + if (item.kind == MenuItemKind::SWATCHES) + { + const std::vector& rects = m_swatchRects[index]; + for (std::size_t swatch = 0; swatch < rects.size(); ++swatch) + { + const gd::RectF& swatchRect = rects[swatch]; + UiFillRoundRect(g, swatchRect, 4.0f * m_scale, item.swatches[swatch]); + + const bool swatchHover = (static_cast(index) == m_hoverIndex) && + (static_cast(swatch) == m_hoverSwatch); + if (static_cast(swatch) == item.checkedSwatch || swatchHover) + { + const gd::RectF ring(swatchRect.X - 2.0f * m_scale, swatchRect.Y - 2.0f * m_scale, + swatchRect.Width + 4.0f * m_scale, + swatchRect.Height + 4.0f * m_scale); + UiDrawRoundRect(g, ring, 5.0f * m_scale, + (static_cast(swatch) == item.checkedSwatch) ? m_theme->textStrong + : m_theme->borderStrong, + (static_cast(swatch) == item.checkedSwatch) ? 1.8f : 1.0f); + } + } + continue; + } + + // 普通项。 + std::uint32_t background = 0; + if (hovered && item.enabled) + { + background = m_theme->accent; + } + else if (static_cast(index) == m_pressedIndex) + { + background = m_theme->pressed; + } + if (background != 0) + { + UiFillRoundRect(g, rect, 4.0f * m_scale, background); + } + + const std::uint32_t textColor = !item.enabled ? m_theme->textMuted + : ((hovered && item.enabled) ? m_theme->textOnAccent + : m_theme->text); + + if (item.checked) + { + // 单选标记:对勾。 + const float cx = rect.X + kMarkerColumn * m_scale * 0.5f + 2.0f * m_scale; + const float cy = rect.Y + rect.Height * 0.5f; + UiDrawLine(g, cx - 4.0f * m_scale, cy, cx - 1.0f * m_scale, cy + 3.0f * m_scale, + hovered ? m_theme->textOnAccent : m_theme->accent, 1.8f * m_scale); + UiDrawLine(g, cx - 1.0f * m_scale, cy + 3.0f * m_scale, cx + 4.5f * m_scale, + cy - 4.0f * m_scale, hovered ? m_theme->textOnAccent : m_theme->accent, + 1.8f * m_scale); + } + + const float textLeft = rect.X + kMarkerColumn * m_scale; + const float textWidth = rect.Width - kMarkerColumn * m_scale - 4.0f * m_scale; + UiText(g, UiEllipsize(g, item.label, m_fonts->uiSmall, textWidth), m_fonts->uiSmall, + gd::RectF(textLeft, rect.Y, std::max(8.0f, textWidth), rect.Height), textColor, + TextAlign::LEFT); + } + } + +} // namespace jdi