From 261b8ac07d6ceeca38dadb58a0f70766b61f8b41 Mon Sep 17 00:00:00 2001 From: "DeepSeek V4.1 Flash" <> Date: Wed, 30 Sep 2026 09:38:22 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E4=BF=AE=E6=AD=A3=E6=BB=91=E5=9D=97?= =?UTF-8?q?=E5=A1=AB=E5=85=85=E6=AE=8B=E7=95=99=E3=80=81=E6=95=B0=E5=80=BC?= =?UTF-8?q?=E5=86=85=E8=81=94=E8=BE=93=E5=85=A5=E4=B8=8E=E4=BF=A1=E5=8F=B7?= =?UTF-8?q?/=E5=88=86=E7=BB=84=E9=87=8D=E5=91=BD=E5=90=8D=EF=BC=8C?= =?UTF-8?q?=E6=8B=96=E6=8B=BD=E8=99=9A=E5=BD=B1=E8=B7=9F=E9=9A=8F=E9=BC=A0?= =?UTF-8?q?=E6=A0=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 滑块最小值时不再有超出圆钮的填充残留,三处滑块统一为共享实现 - 透明度/RGB/色号支持点击或双击直接输入,整段高亮、首次键入替换、回车提交、Ctrl+C 复制 - 双击信号行或分组名进入重命名编辑;分组竖线改为绘制而非字符 - 拖拽信号时曲线虚影跟随鼠标纵向位移 - 数据层新增展示名覆盖,面板改为持有可写数据集合 --- Components/Application/Src/Application.cpp | 13 +- Components/PlotView/Inc/PlotView.hpp | 3 +- Components/PlotView/Src/PlotView.cpp | 109 +++-- Components/SignalPanel/Inc/SignalPanel.hpp | 83 +++- Components/SignalPanel/Src/SignalPanel.cpp | 534 ++++++++++++++------- Utilities/Inc/ColorPickerPopup.hpp | 51 +- Utilities/Inc/DataModel.hpp | 35 +- Utilities/Inc/PaintUtil.hpp | 28 ++ Utilities/Inc/Utf8Util.hpp | 10 + Utilities/Src/ColorPickerPopup.cpp | 320 +++++++++++- Utilities/Src/DataModel.cpp | 41 ++ Utilities/Src/PaintUtil.cpp | 77 +++ Utilities/Src/Utf8Util.cpp | 44 ++ 13 files changed, 1074 insertions(+), 274 deletions(-) diff --git a/Components/Application/Src/Application.cpp b/Components/Application/Src/Application.cpp index 3d97f8e..6ebae0a 100644 --- a/Components/Application/Src/Application.cpp +++ b/Components/Application/Src/Application.cpp @@ -703,7 +703,7 @@ namespace jdi const DataFile* file = m_dataSet.FileAt(fileIndex); if (file != nullptr) { - std::wstring message = L"已加载 组" + std::to_wstring(fileIndex + 1) + L"|" + file->fileName + + std::wstring message = L"已加载 组" + std::to_wstring(fileIndex + 1) + L"|" + file->Title() + L"(" + FormatCount(static_cast(file->time.size())) + L" 行采样," + std::to_wstring(file->signals.size()) + L" 条信号,时间列:" + file->timeColumnName + L")"; @@ -1270,6 +1270,11 @@ namespace jdi repaint = true; consume = true; break; + case WM_CHAR: + // 字符输入转发给拾色器,用于 RGB 分量与十六进制色号的内联编辑。 + repaint = m_colorPicker.OnChar(static_cast(wParam)); + consume = true; + break; case WM_RBUTTONDOWN: case WM_MBUTTONDOWN: case WM_MOUSEWHEEL: @@ -1616,7 +1621,7 @@ namespace jdi } case WM_KEYDOWN: // 数值输入状态优先接管按键(回车提交、Esc 取消、退格删除)。 - if (m_panel.IsEditingWidth()) + if (m_panel.IsEditing()) { if (m_panel.OnEditKey(wParam)) { @@ -1640,8 +1645,8 @@ namespace jdi break; case WM_CHAR: { - // 数值输入仅接受数字与小数点(或科学计数法符号),其余字符一律吞掉。 - if (m_panel.IsEditingWidth()) + // 输入状态由对应控件自行校验可接受的字符(数值仅收数字与小数点,名称可收任意可打印字符)。 + if (m_panel.IsEditing()) { if (m_panel.OnEditChar(static_cast(wParam))) { diff --git a/Components/PlotView/Inc/PlotView.hpp b/Components/PlotView/Inc/PlotView.hpp index f2ba23d..feb1d09 100644 --- a/Components/PlotView/Inc/PlotView.hpp +++ b/Components/PlotView/Inc/PlotView.hpp @@ -616,8 +616,7 @@ namespace jdi SignalRef m_hoverSignal; /**< 鼠标悬停命中的信号(曲线高亮)。 */ SignalRef m_dragSignal; /**< 正在被拖拽的信号。 */ - int m_dragTargetLane = -1; /**< 拖拽信号时的目标网格下标。 */ - + int m_dragTargetLane = -1; /**< 拖拽信号时的目标网格下标。 */ int m_dragMouseY = 0; /**< 拖拽过程中鼠标的当前纵坐标(虚影跟随)。 */ bool m_cursorHover = false; /**< 鼠标是否悬停在时间光标上。 */ PlotEditTarget m_editTarget = PlotEditTarget::NONE; /**< 当前内联输入目标。 */ bool m_editFresh = false; /**< 输入框是否处于“首次输入整体替换”状态。 */ diff --git a/Components/PlotView/Src/PlotView.cpp b/Components/PlotView/Src/PlotView.cpp index 503a316..0621b37 100644 --- a/Components/PlotView/Src/PlotView.cpp +++ b/Components/PlotView/Src/PlotView.cpp @@ -1185,6 +1185,7 @@ namespace jdi { const int target = LaneAtY(y); m_dragMoved = true; + m_dragMouseY = y; if (target != m_dragTargetLane) { m_dragTargetLane = target; @@ -1883,7 +1884,7 @@ namespace jdi std::wstring label = BuildLegendText(*resolved.data); if (multiFile && resolved.file != nullptr) { - label += L" · " + resolved.file->fileName; + label += L" · " + resolved.file->Title(); } const float itemWidth = 13.0f * m_scale + UiTextWidth(g, label, m_fonts->uiSmall) + 14.0f * m_scale; @@ -1915,7 +1916,7 @@ namespace jdi std::wstring label = BuildLegendText(*resolved.data); if (multiFile && resolved.file != nullptr) { - label += L" · " + resolved.file->fileName; + label += L" · " + resolved.file->Title(); } const float textWidth = UiTextWidth(g, label, m_fonts->uiSmall); const float itemWidth = 13.0f * m_scale + textWidth + 14.0f * m_scale; @@ -1992,10 +1993,10 @@ namespace jdi } ReadoutLine line; - line.text = resolved.data->name; + line.text = resolved.data->Title(); if (multiFile) { - line.text += L" · " + resolved.file->fileName; + line.text += L" · " + resolved.file->Title(); } line.text += L" = " + FormatValue(value); lines.push_back(std::move(line)); @@ -2226,55 +2227,75 @@ namespace jdi return; } - // 拖拽中:在目标网格内以虚线绘制该信号的“虚影”,并高亮目标网格。 + // 拖拽中:虚影跟随鼠标纵向位移,同时在目标网格内高亮落点。 if (m_dragMode == PlotDragMode::MOVE_SIGNAL && m_dragSignal.IsValid()) { - const int target = (m_dragTargetLane >= 0) ? m_dragTargetLane : m_tab->LaneOfSignal(m_dragSignal); + const ResolvedSignal resolved = ResolveSignal(m_dragSignal); + const int sourceLane = m_tab->LaneOfSignal(m_dragSignal); + if (resolved.data == nullptr || resolved.style == nullptr || sourceLane < 0 || + sourceLane >= static_cast(m_lanes.size())) + { + return; + } + + // 虚影:以源网格的幅值映射构建波形,并整体平移跟随鼠标,形成“拖着曲线走”的观感。 + LaneVisual ghostLane = m_lanes[static_cast(sourceLane)]; + const float follow = static_cast(m_dragMouseY - m_dragStartY); + ghostLane.rect.Y += follow; + + { + const float clipTop = std::max(m_plotRect.Y, m_plotRect.Y); + const float clipHeight = m_plotRect.Height; + const int saveState = g.Save(); + g.SetClip(gd::RectF(m_plotRect.X, clipTop, m_plotRect.Width, clipHeight), + gd::CombineModeIntersect); + + std::vector points; + std::vector breaks; + BuildPolyline(resolved, ghostLane, points, breaks); + + gd::Pen ghost(ToGdiColor(WithAlpha(m_theme->accent, 0xC0)), + std::max(1.8f, resolved.style->lineWidth) * m_scale); + ghost.SetDashStyle(gd::DashStyleDash); + ghost.SetStartCap(gd::LineCapRound); + ghost.SetEndCap(gd::LineCapRound); + for (std::size_t k = 0; k < breaks.size(); ++k) + { + const std::size_t start = breaks[k]; + const std::size_t stop = (k + 1 < breaks.size()) ? breaks[k + 1] : points.size(); + if (stop <= start + 1) + { + continue; + } + g.DrawLines(&ghost, points.data() + start, static_cast(stop - start)); + } + + g.Restore(saveState); + } + + // 落点提示:高亮鼠标所在网格并给出文字说明。 + const int target = (m_dragTargetLane >= 0) ? m_dragTargetLane : sourceLane; if (target >= 0 && target < static_cast(m_lanes.size())) { - const LaneVisual& lane = m_lanes[static_cast(target)]; - const ResolvedSignal resolved = ResolveSignal(m_dragSignal); - if (resolved.data != nullptr && resolved.style != nullptr) + const gd::RectF& targetRect = m_lanes[static_cast(target)].rect; + const float visibleTop = std::max(targetRect.Y, m_plotRect.Y); + const float visibleBottom = std::min(targetRect.Y + targetRect.Height, + m_plotRect.Y + m_plotRect.Height); + if (visibleBottom > visibleTop) { - const int saveState = g.Save(); - g.SetClip(gd::RectF(m_plotRect.X, std::max(lane.rect.Y, m_plotRect.Y), m_plotRect.Width, - std::min(lane.rect.Y + lane.rect.Height, - m_plotRect.Y + m_plotRect.Height) - - std::max(lane.rect.Y, m_plotRect.Y)), - gd::CombineModeIntersect); - - // 目标网格边框高亮。 gd::Pen frame(ToGdiColor(m_theme->accent), 2.0f); - g.DrawRectangle(&frame, lane.rect.X, lane.rect.Y, lane.rect.Width, lane.rect.Height); + g.DrawRectangle(&frame, targetRect.X, visibleTop, targetRect.Width, + visibleBottom - visibleTop); - std::vector points; - std::vector breaks; - BuildPolyline(resolved, lane, points, breaks); - - gd::Pen ghost(ToGdiColor(WithAlpha(m_theme->accent, 0xB0)), - std::max(1.6f, resolved.style->lineWidth) * m_scale); - ghost.SetDashStyle(gd::DashStyleDash); - for (std::size_t k = 0; k < breaks.size(); ++k) - { - const std::size_t start = breaks[k]; - const std::size_t stop = (k + 1 < breaks.size()) ? breaks[k + 1] : points.size(); - if (stop <= start + 1) - { - continue; - } - g.DrawLines(&ghost, points.data() + start, static_cast(stop - start)); - } - - g.Restore(saveState); - - // 目标网格名称提示。 - const std::wstring hint = - L"移动到网格 " + std::to_wstring(target + 1) + L":松开左键确认"; + const std::wstring hint = (target == sourceLane) + ? std::wstring(L"松开左键放回本网格") + : (L"移动到网格 " + std::to_wstring(target + 1) + + L":松开左键确认"); const float textWidth = UiTextWidth(g, hint, m_fonts->uiSmall) + 16.0f * m_scale; const float height = 20.0f * m_scale; - float left = lane.rect.X + (lane.rect.Width - textWidth) * 0.5f; + float left = targetRect.X + (targetRect.Width - textWidth) * 0.5f; left = std::max(m_plotRect.X, std::min(left, m_plotRect.X + m_plotRect.Width - textWidth)); - const gd::RectF pill(left, lane.rect.Y + 4.0f * m_scale, textWidth, height); + const gd::RectF pill(left, visibleTop + 4.0f * m_scale, textWidth, height); UiFillRoundRect(g, pill, 3.0f * m_scale, m_theme->accent); UiText(g, hint, m_fonts->uiSmall, pill, m_theme->textOnAccent, TextAlign::CENTER); } @@ -2299,7 +2320,7 @@ namespace jdi } const LaneVisual& lane = m_lanes[static_cast(laneIndex)]; - const std::wstring text = resolved.data->name + L" · 按住拖拽可移入其它网格"; + const std::wstring text = resolved.data->Title() + L" · 按住拖拽可移入其它网格"; const float textWidth = UiTextWidth(g, text, m_fonts->uiSmall) + 16.0f * m_scale; const float height = 20.0f * m_scale; diff --git a/Components/SignalPanel/Inc/SignalPanel.hpp b/Components/SignalPanel/Inc/SignalPanel.hpp index 95e503e..248c01d 100644 --- a/Components/SignalPanel/Inc/SignalPanel.hpp +++ b/Components/SignalPanel/Inc/SignalPanel.hpp @@ -62,10 +62,10 @@ namespace jdi /** * @brief 绑定数据层与当前标签页配置。 - * @param [IN] dataSet 数据集合,允许为空。 + * @param [IN] dataSet 数据集合(可写,用于重命名信号与分组)。 * @param [IN] tab 当前标签页配置,允许为空。 */ - void SetContext(const DataSet* dataSet, TabConfig* tab); + void SetContext(DataSet* dataSet, TabConfig* tab); /** * @brief 设置宿主客户区矩形(用于约束浮动面板位置)。 @@ -163,20 +163,20 @@ namespace jdi bool OnMenuCommand(const MenuResult& result); /** - * @brief 判断线宽数值框是否处于输入状态。 + * @brief 判断面板是否处于内联文本输入状态。 * @return 正在输入返回 true。 */ - bool IsEditingWidth() const { return m_widthEditing; } + bool IsEditing() const { return m_editField != StyleEditField::NONE; } /** - * @brief 向线宽输入框送入一个字符。 - * @param [IN] character 输入字符('\r' 提交、'\b' 退格)。 + * @brief 向当前输入框送入一个字符。 + * @param [IN] character 输入字符('\r' 提交、'\b' 退格、0x03 复制)。 * @return 界面状态发生变化返回 true。 */ bool OnEditChar(wchar_t character); /** - * @brief 向线宽输入框送入一个按键。 + * @brief 向当前输入框送入一个按键。 * @param [IN] key 虚拟键码。 * @return 界面状态发生变化返回 true。 */ @@ -216,6 +216,18 @@ namespace jdi void ScrollToSignal(const SignalRef& ref); private: + /** + * @brief 样式区与列表行中可进行内联文本编辑的字段。 + */ + enum class StyleEditField : int + { + NONE = 0, /**< 未处于编辑状态。 */ + WIDTH = 1, /**< 线宽数值。 */ + OPACITY = 2, /**< 不透明度数值(百分比)。 */ + SIGNAL_NAME = 3, /**< 信号显示名称(列表行内)。 */ + FILE_NAME = 4 /**< 分组显示名称(列表行内)。 */ + }; + /** * @brief 列表行的类型。 */ @@ -243,14 +255,15 @@ namespace jdi */ enum class StyleHit : int { - NONE = 0, /**< 未命中。 */ - COLOR, /**< 预设颜色色块。 */ - CUSTOM_CHIP, /**< 自定义颜色入口。 */ - MODE, /**< 绘制样式按钮。 */ - OPACITY_SLIDER, /**< 不透明度滑块。 */ - WIDTH_SLIDER, /**< 线宽滑块。 */ - WIDTH_VALUE, /**< 线宽数值框(可双击输入)。 */ - TOGGLE_ALL /**< 全部显示/隐藏按钮。 */ + NONE = 0, /**< 未命中。 */ + COLOR, /**< 预设颜色色块。 */ + CUSTOM_CHIP, /**< 自定义颜色入口。 */ + MODE, /**< 绘制样式按钮。 */ + OPACITY_SLIDER, /**< 不透明度滑块。 */ + OPACITY_VALUE, /**< 不透明度数值框(可点击输入)。 */ + WIDTH_SLIDER, /**< 线宽滑块。 */ + WIDTH_VALUE, /**< 线宽数值框(可点击输入)。 */ + TOGGLE_ALL /**< 全部显示/隐藏按钮。 */ }; /** @@ -407,17 +420,39 @@ namespace jdi int TotalSignalCount() const; /** - * @brief 进入线宽数值输入状态。 + * @brief 进入指定字段的内联输入状态。 + * @param [IN] field 目标字段。 + * @param [IN] row 列表行下标(仅行内字段有效)。 * @return 状态发生变化返回 true。 */ - bool BeginWidthEdit(); + bool BeginEdit(StyleEditField field, int row = -1); /** - * @brief 结束线宽数值输入状态。 + * @brief 结束内联输入状态。 * @param [IN] commit 是否提交输入值。 * @return 状态或取值发生变化返回 true。 */ - bool EndWidthEdit(bool commit); + bool EndEdit(bool commit); + + /** + * @brief 计算当前输入框的矩形。 + * @return 输入框矩形,未处于编辑状态时返回空矩形。 + */ + gd::RectF EditFieldRect() const; + + /** + * @brief 计算指定行中名称区域的矩形(列表行内联编辑使用)。 + * @param [IN] row 行下标。 + * @return 名称区域矩形。 + */ + gd::RectF RowNameRect(int row) const; + + /** + * @brief 计算分组行中“组X”标签的像素宽度。 + * @param [IN] row 行下标。 + * @return 标签宽度。 + */ + float FileGroupLabelWidth(int row) const; /** * @brief 调整当前信号不透明度。 @@ -438,7 +473,8 @@ namespace jdi const Theme* m_theme = nullptr; /**< 主题资源。 */ PopupMenu* m_menu = nullptr; /**< 共用弹出菜单。 */ ColorPickerPopup* m_colorPicker = nullptr; /**< 共用浮动拾色器。 */ - const DataSet* m_dataSet = nullptr; /**< 数据集合。 */ + const DataSet* m_dataSet = nullptr; /**< 数据集合(只读访问)。 */ + DataSet* m_dataSetWritable = nullptr; /**< 数据集合(重命名等写入操作)。 */ TabConfig* m_tab = nullptr; /**< 当前标签页配置。 */ float m_scale = 1.0f; /**< DPI 缩放比例。 */ gd::RectF m_clientBounds; /**< 宿主客户区矩形。 */ @@ -481,9 +517,10 @@ namespace jdi StyleHit m_hoverStyle = StyleHit::NONE; /**< 当前悬停的样式条目类型。 */ int m_hoverStyleIndex = -1; /**< 当前悬停的样式条目下标。 */ - bool m_widthEditing = false; /**< 线宽数值是否处于输入状态。 */ - bool m_widthEditFresh = false; /**< 输入缓冲是否仍为初始值(首次键入应整体替换)。 */ - std::wstring m_widthEditText; /**< 线宽输入缓冲。 */ + StyleEditField m_editField = StyleEditField::NONE; /**< 当前处于输入状态的字段。 */ + int m_editRow = -1; /**< 行内输入所作用的列表行下标。 */ + bool m_editFresh = false; /**< 输入缓冲是否仍为初始值(首次键入应整体替换)。 */ + std::wstring m_editText; /**< 输入缓冲。 */ bool m_pendingLaneMenu = false; /**< 左键抬起时是否弹出网格归属菜单。 */ SignalRef m_pendingLaneRef; /**< 待弹出菜单的目标信号。 */ diff --git a/Components/SignalPanel/Src/SignalPanel.cpp b/Components/SignalPanel/Src/SignalPanel.cpp index b0ac070..ed1b697 100644 --- a/Components/SignalPanel/Src/SignalPanel.cpp +++ b/Components/SignalPanel/Src/SignalPanel.cpp @@ -24,6 +24,8 @@ namespace jdi const float kIndent = 14.0f; /**< 信号行相对文件行的缩进。 */ const float kScrollBarThickness = 8.0f; /**< 滚动条宽度。 */ const float kCloseButtonSize = 18.0f; /**< 文件行关闭按钮尺寸。 */ + const float kGroupSeparatorGap = 7.0f; /**< 分组标签与分隔线/名称的间距。 */ + const float kGroupSeparatorWidth = 1.0f; /**< 分组标签右侧分隔线宽度。 */ // 样式编辑区:固定四行网格(颜色 / 样式 / 透明度 / 线宽),行高、间距与内容列宽度统一, // 保证面板宽度变化时四行控件始终对齐,且不会被无限制拉伸。 @@ -62,36 +64,6 @@ namespace jdi track.Height + 18.0f * scale); } - /** - * @brief 绘制水平滑块(轨道 + 已填充部分 + 圆钮)。 - * @param [IN,OUT] g 绘图上下文。 - * @param [IN] track 轨道矩形。 - * @param [IN] ratio 当前值比例(0-1)。 - * @param [IN] fillColor 已填充部分颜色。 - * @param [IN] active 是否处于悬停或拖拽状态。 - * @param [IN] scale 缩放比例。 - */ - void DrawSlider(gd::Graphics& g, const gd::RectF& track, double ratio, std::uint32_t fillColor, - bool active, 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 + (active ? 2.5f * scale : 0.8f * 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 颜色值。 @@ -134,16 +106,19 @@ namespace jdi m_colorPicker = colorPicker; } - void SignalPanel::SetContext(const DataSet* dataSet, TabConfig* tab) + void SignalPanel::SetContext(DataSet* dataSet, TabConfig* tab) { m_dataSet = dataSet; + m_dataSetWritable = dataSet; m_tab = tab; m_rowsDirty = true; m_scrollOffset = 0.0f; m_draggingOpacity = false; m_draggingWidth = false; - m_widthEditing = false; - m_widthEditText.clear(); + m_editField = StyleEditField::NONE; + m_editRow = -1; + m_editFresh = false; + m_editText.clear(); m_pendingLaneMenu = false; m_pendingColorPicker = false; } @@ -682,22 +657,39 @@ namespace jdi const gd::RectF closeRect = FileCloseRect(row); - // 分组标题:以“组X | <文件名>”呈现,X 为该组在当前列表中的序号。 - const std::wstring groupLabel = L"组" + std::to_wstring(item.fileIndex + 1) + L" | "; + // 分组标题:左侧为“组X”标签,右侧以绘制的竖线分隔分组名(不使用字符),再显示可重命名的名称。 + const std::wstring groupLabel = L"组" + std::to_wstring(item.fileIndex + 1); const std::wstring stats = std::to_wstring(file->signals.size()) + L" 项"; - const float groupWidth = UiMeasureTextWidth(m_fonts->uiBold, groupLabel); + const float groupWidth = FileGroupLabelWidth(row); const float statsWidth = UiMeasureTextWidth(m_fonts->uiSmall, stats); const float statsRight = closeRect.X - 6.0f * m_scale; const float nameLeft = checkRect.X + checkRect.Width + 8.0f * m_scale; + const float nameAreaLeft = + nameLeft + groupWidth + kGroupSeparatorGap * 2.0f * m_scale + kGroupSeparatorWidth * m_scale; const float nameWidth = - std::max(20.0f, statsRight - statsWidth - 6.0f * m_scale - nameLeft - groupWidth); + std::max(20.0f, statsRight - statsWidth - 6.0f * m_scale - nameAreaLeft); // 序号以强调色突出,便于在多组之间快速定位。 - UiText(g, groupLabel, m_fonts->uiBold, - gd::RectF(nameLeft, rect.Y, groupWidth, rect.Height), m_theme->accent, TextAlign::LEFT); - UiText(g, UiEllipsize(g, file->fileName, m_fonts->uiBold, nameWidth), m_fonts->uiBold, - gd::RectF(nameLeft + groupWidth, rect.Y, nameWidth, rect.Height), m_theme->textStrong, - TextAlign::LEFT); + UiText(g, groupLabel, m_fonts->uiBold, gd::RectF(nameLeft, rect.Y, groupWidth, rect.Height), + m_theme->accent, TextAlign::LEFT); + + // 竖线分隔符:以绘制方式实现,与文字基线无关,排版更稳。 + UiFillRect(g, gd::RectF(nameLeft + groupWidth + kGroupSeparatorGap * m_scale, + rect.Y + 7.0f * m_scale, kGroupSeparatorWidth * m_scale, + rect.Height - 14.0f * m_scale), + WithAlpha(m_theme->borderStrong, 0xB0)); + + if (m_editField == StyleEditField::FILE_NAME && m_editRow == row) + { + UiDrawEditField(g, RowNameRect(row), m_editText, m_editFresh, m_fonts->uiSmall, m_scale); + } + else + { + UiText(g, UiEllipsize(g, file->Title(), m_fonts->uiBold, nameWidth), m_fonts->uiBold, + gd::RectF(nameAreaLeft, rect.Y, nameWidth, rect.Height), m_theme->textStrong, + TextAlign::LEFT); + } + UiText(g, stats, m_fonts->uiSmall, gd::RectF(statsRight - statsWidth, rect.Y, statsWidth, rect.Height), m_theme->textMuted, TextAlign::RIGHT); @@ -766,12 +758,19 @@ namespace jdi nameRightLimit = badgeRect.X - 6.0f * m_scale; } - // 名称:完全保留文件内的列名(含 "[n]" 序号与括号描述)。 + // 名称:完全保留文件内的列名(含 "[n]" 序号与括号描述),双击可重命名。 const float nameLeft = swatchRect.X + swatchRect.Width + 6.0f * m_scale; - const float nameWidth = std::max(16.0f, nameRightLimit - nameLeft); - const std::uint32_t nameColor = style->visible ? m_theme->text : m_theme->textMuted; - 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 (m_editField == StyleEditField::SIGNAL_NAME && m_editRow == row) + { + UiDrawEditField(g, RowNameRect(row), m_editText, m_editFresh, m_fonts->ui, m_scale); + } + else + { + const float nameWidth = std::max(16.0f, nameRightLimit - nameLeft); + const std::uint32_t nameColor = style->visible ? m_theme->text : m_theme->textMuted; + UiText(g, UiEllipsize(g, signal->Title(), m_fonts->ui, nameWidth), m_fonts->ui, + gd::RectF(nameLeft, rect.Y, nameWidth, rect.Height), nameColor, TextAlign::LEFT); + } if (badgeVisible) { @@ -830,7 +829,6 @@ namespace jdi } const float labelWidth = kStyleLabelWidth * m_scale; - const float rowHeight = kStyleRowHeight * m_scale; // 行标签统一以整行为基准垂直居中,确保与同行控件纵向对齐。 auto paintRowLabel = [&](int rowIndex, const wchar_t* text) @@ -906,65 +904,38 @@ namespace jdi } } - // ---- 第三行:透明度 ---- + // ---- 第三行:透明度(数值可点击输入)---- { paintRowLabel(2, L"透明度"); const bool active = m_draggingOpacity || (m_hoverStyle == StyleHit::OPACITY_SLIDER); - DrawSlider(g, m_opacityTrackRect, static_cast(style->opacity), m_theme->accent, active, - m_scale); + UiDrawSlider(g, m_opacityTrackRect, static_cast(style->opacity), m_theme->accent, active, + m_scale); - const int percent = static_cast(style->opacity * 100.0f + 0.5f); - UiText(g, std::to_wstring(percent) + L"%", m_fonts->monoSmall, m_opacityValueRect, - m_theme->textStrong, TextAlign::RIGHT); + if (m_editField == StyleEditField::OPACITY) + { + UiDrawEditField(g, m_opacityValueRect, m_editText, m_editFresh, m_fonts->monoSmall, m_scale); + } + else + { + const int percent = static_cast(style->opacity * 100.0f + 0.5f); + UiText(g, std::to_wstring(percent) + L"%", m_fonts->monoSmall, m_opacityValueRect, + m_theme->textStrong, TextAlign::RIGHT); + } } - // ---- 第四行:线宽(与透明度行几何一致)---- + // ---- 第四行:线宽(与透明度行几何一致,数值可点击输入)---- { paintRowLabel(3, L"线宽"); const double ratio = (static_cast(style->lineWidth) - kWidthMin) / (kWidthMax - kWidthMin); const bool active = m_draggingWidth || (m_hoverStyle == StyleHit::WIDTH_SLIDER); - DrawSlider(g, m_widthTrackRect, ratio, m_theme->accent, active, m_scale); + UiDrawSlider(g, m_widthTrackRect, ratio, m_theme->accent, active, m_scale); - if (m_widthEditing) + if (m_editField == StyleEditField::WIDTH) { - // 输入状态:数值整体居中;处于“全部选中”状态时以高亮背景表示内容将被覆盖, - // 交互观感与常见登录界面的账号/密码输入框一致。 - UiFillRoundRect(g, m_widthValueRect, kCorner * m_scale, m_theme->cardBg); - UiDrawRoundRect(g, m_widthValueRect, kCorner * m_scale, m_theme->accent, 1.6f); - - const std::wstring editText = m_widthEditText.empty() ? std::wstring(L" ") : m_widthEditText; - const float textWidth = UiTextWidth(g, editText, m_fonts->monoSmall); - const float textX = - m_widthValueRect.X + std::max(2.0f * m_scale, (m_widthValueRect.Width - textWidth) * 0.5f); - - if (m_widthEditFresh) - { - // 高亮选区:覆盖整段数值,提示首次键入即整体替换。 - const float padX = 3.0f * m_scale; - UiFillRoundRect(g, gd::RectF(textX - padX, m_widthValueRect.Y + 4.0f * m_scale, - textWidth + padX * 2.0f, - m_widthValueRect.Height - 8.0f * m_scale), - 2.0f * m_scale, m_theme->accent); - UiText(g, editText, m_fonts->monoSmall, m_widthValueRect, m_theme->textOnAccent, - TextAlign::CENTER); - } - else - { - UiText(g, editText, m_fonts->monoSmall, m_widthValueRect, m_theme->textStrong, - TextAlign::CENTER); - - // 已开始键入:在文本末尾显示插入符。 - const float caretX = textX + textWidth + 1.0f * m_scale; - if (caretX < m_widthValueRect.X + m_widthValueRect.Width - 2.0f * m_scale) - { - UiDrawLine(g, caretX, m_widthValueRect.Y + 5.0f * m_scale, caretX, - m_widthValueRect.Y + rowHeight - 5.0f * m_scale, m_theme->accent, - 1.4f * m_scale); - } - } + UiDrawEditField(g, m_widthValueRect, m_editText, m_editFresh, m_fonts->monoSmall, m_scale); } else { @@ -1013,115 +984,304 @@ namespace jdi return true; } - bool SignalPanel::BeginWidthEdit() + gd::RectF SignalPanel::RowNameRect(int row) const { - if (m_tab == nullptr) + if (m_dataSet == nullptr || m_tab == nullptr || row < 0 || row >= static_cast(m_rows.size())) + { + return gd::RectF(); + } + + const gd::RectF rect = RowRect(row); + const bool isFile = (m_rows[static_cast(row)].kind == RowKind::FILE); + const float baseX = rect.X + kPadding * m_scale + (isFile ? 0.0f : kIndent * m_scale); + const float checkRight = baseX + kDisclosureWidth * m_scale + kCheckSize * m_scale; + + float left = 0.0f; + float right = 0.0f; + if (isFile) + { + // 分组名需要跳过左侧的“组X”标签与分隔线。 + left = checkRight + 8.0f * m_scale + FileGroupLabelWidth(row) + kGroupSeparatorGap * 2.0f * m_scale + + kGroupSeparatorWidth * m_scale; + right = FileCloseRect(row).X - 6.0f * m_scale; + } + else + { + left = checkRight + 6.0f * m_scale + kSwatchSize * m_scale + 6.0f * m_scale; + right = SignalBadgeRect(row).X - 6.0f * m_scale; + } + + if (right <= left) + { + return gd::RectF(); + } + return gd::RectF(left, rect.Y + 3.0f * m_scale, right - left, rect.Height - 6.0f * m_scale); + } + + float SignalPanel::FileGroupLabelWidth(int row) const + { + if (m_fonts == nullptr || m_fonts->uiBold == nullptr || row < 0 || + row >= static_cast(m_rows.size())) + { + return 0.0f; + } + const std::wstring label = L"组" + std::to_wstring(m_rows[static_cast(row)].fileIndex + 1); + return UiMeasureTextWidth(m_fonts->uiBold, label); + } + + bool SignalPanel::BeginEdit(StyleEditField field, int row) + { + if (m_tab == nullptr || field == StyleEditField::NONE) { return false; } - const SignalStyle* style = m_tab->Style(m_tab->Selected()); - if (style == nullptr) - { - return false; - } - if (m_widthEditing) + if (m_editField == field && m_editRow == row) { return false; } - m_widthEditText = FormatNumber(style->lineWidth, 1); - m_widthEditing = true; - m_widthEditFresh = true; + std::wstring initial; + switch (field) + { + case StyleEditField::WIDTH: + { + const SignalStyle* style = m_tab->Style(m_tab->Selected()); + if (style == nullptr) + { + return false; + } + initial = FormatNumber(style->lineWidth, 1); + break; + } + case StyleEditField::OPACITY: + { + const SignalStyle* style = m_tab->Style(m_tab->Selected()); + if (style == nullptr) + { + return false; + } + initial = std::to_wstring(static_cast(style->opacity * 100.0f + 0.5f)); + break; + } + case StyleEditField::SIGNAL_NAME: + { + if (row < 0 || row >= static_cast(m_rows.size())) + { + return false; + } + const ListRow& item = m_rows[static_cast(row)]; + const SignalData* signal = m_dataSet->Resolve(SignalRef{item.fileIndex, item.signalIndex}); + if (signal == nullptr) + { + return false; + } + initial = signal->Title(); + break; + } + case StyleEditField::FILE_NAME: + { + if (row < 0 || row >= static_cast(m_rows.size())) + { + return false; + } + const DataFile* file = m_dataSet->FileAt(m_rows[static_cast(row)].fileIndex); + if (file == nullptr) + { + return false; + } + initial = file->Title(); + break; + } + default: + return false; + } + + m_editField = field; + m_editRow = row; + m_editText = std::move(initial); + m_editFresh = true; return true; } - bool SignalPanel::EndWidthEdit(bool commit) + bool SignalPanel::EndEdit(bool commit) { - if (!m_widthEditing) + if (m_editField == StyleEditField::NONE) { return false; } - bool changed = true; - if (commit && m_tab != nullptr) + const StyleEditField field = m_editField; + const int row = m_editRow; + const std::wstring text = m_editText; + + m_editField = StyleEditField::NONE; + m_editRow = -1; + m_editFresh = false; + m_editText.clear(); + + if (!commit || m_tab == nullptr || m_dataSet == nullptr) { - const std::wstring trimmed = Trim(m_widthEditText); - if (!trimmed.empty()) - { - wchar_t* stop = nullptr; - const double parsed = std::wcstod(trimmed.c_str(), &stop); - if (stop != trimmed.c_str() && parsed == parsed) - { - if (SignalStyle* style = m_tab->Style(m_tab->Selected())) - { - const float value = ClampFloat(static_cast(parsed), kWidthMin, kWidthMax); - style->lineWidth = std::round(value * 10.0f) / 10.0f; - } - } - } + return true; } - m_widthEditing = false; - m_widthEditFresh = false; - m_widthEditText.clear(); - return changed; + const std::wstring trimmed = Trim(text); + switch (field) + { + case StyleEditField::WIDTH: + { + if (trimmed.empty()) + { + break; + } + wchar_t* stop = nullptr; + const double parsed = std::wcstod(trimmed.c_str(), &stop); + if (stop == trimmed.c_str() || parsed != parsed) + { + break; + } + if (SignalStyle* style = m_tab->Style(m_tab->Selected())) + { + const float value = ClampFloat(static_cast(parsed), kWidthMin, kWidthMax); + style->lineWidth = std::round(value * 10.0f) / 10.0f; + } + break; + } + case StyleEditField::OPACITY: + { + if (trimmed.empty()) + { + break; + } + wchar_t* stop = nullptr; + const double parsed = std::wcstod(trimmed.c_str(), &stop); + if (stop == trimmed.c_str() || parsed != parsed) + { + break; + } + if (SignalStyle* style = m_tab->Style(m_tab->Selected())) + { + style->opacity = ClampFloat(static_cast(parsed) / 100.0f, 0.0f, 1.0f); + } + break; + } + case StyleEditField::SIGNAL_NAME: + { + if (row < 0 || row >= static_cast(m_rows.size()) || m_dataSetWritable == nullptr) + { + break; + } + const ListRow& item = m_rows[static_cast(row)]; + m_dataSetWritable->SetSignalDisplayName(item.fileIndex, item.signalIndex, trimmed); + break; + } + case StyleEditField::FILE_NAME: + { + if (row < 0 || row >= static_cast(m_rows.size()) || m_dataSetWritable == nullptr) + { + break; + } + m_dataSetWritable->SetFileDisplayName(m_rows[static_cast(row)].fileIndex, trimmed); + break; + } + default: + break; + } + return true; + } + + gd::RectF SignalPanel::EditFieldRect() const + { + switch (m_editField) + { + case StyleEditField::WIDTH: + return m_widthValueRect; + case StyleEditField::OPACITY: + return m_opacityValueRect; + case StyleEditField::SIGNAL_NAME: + case StyleEditField::FILE_NAME: + return RowNameRect(m_editRow); + case StyleEditField::NONE: + default: + return gd::RectF(); + } } bool SignalPanel::OnEditChar(wchar_t character) { - if (!m_widthEditing) + if (m_editField == StyleEditField::NONE) { return false; } + // Ctrl+C:把当前文本复制到剪贴板。 + if (character == 0x03) + { + CopyTextToClipboard(m_host, m_editText); + return false; + } + if (character == L'\r' || character == L'\n') { - return EndWidthEdit(true); + return EndEdit(true); } if (character == L'\b') { - m_widthEditFresh = false; - if (!m_widthEditText.empty()) + m_editFresh = false; + if (!m_editText.empty()) { - m_widthEditText.pop_back(); + m_editText.pop_back(); } return true; } - if (character >= L'0' && character <= L'9') + // 名称类字段允许任意可打印字符(含空格、方括号、中文)。 + const bool nameField = (m_editField == StyleEditField::SIGNAL_NAME) || + (m_editField == StyleEditField::FILE_NAME); + if (m_editField == StyleEditField::OPACITY && (character == L'%')) { - // 首次键入直接替换原有数值,无需先手动删除。 - if (m_widthEditFresh) - { - m_widthEditText.clear(); - m_widthEditFresh = false; - } - if (m_widthEditText.size() < 5) - { - m_widthEditText.push_back(character); - } return true; } - if (character == L'.') + if (character < 0x20 || character == 0x7F) { - if (m_widthEditFresh) - { - m_widthEditText.clear(); - m_widthEditFresh = false; - } - // 只允许一个小数点。 - if (m_widthEditText.find(L'.') == std::wstring::npos && m_widthEditText.size() < 4) - { - m_widthEditText.push_back(character); - } return true; } + if (nameField && m_editText.size() >= 64) + { + return true; + } + + const bool numericField = !nameField; + if (numericField) + { + const bool digit = (character >= L'0' && character <= L'9'); + const bool dot = (character == L'.'); + if (!digit && !dot) + { + return true; + } + if (dot && m_editText.find(L'.') != std::wstring::npos) + { + return true; + } + if (!dot && m_editText.size() >= 5) + { + return true; + } + } + + // 首次键入直接替换原有内容,无需先手动删除。 + if (m_editFresh) + { + m_editText.clear(); + m_editFresh = false; + } + m_editText.push_back(character); return true; } bool SignalPanel::OnEditKey(WPARAM key) { - if (!m_widthEditing) + if (m_editField == StyleEditField::NONE) { return false; } @@ -1129,14 +1289,14 @@ namespace jdi switch (key) { case VK_RETURN: - return EndWidthEdit(true); + return EndEdit(true); case VK_ESCAPE: - return EndWidthEdit(false); + return EndEdit(false); case VK_BACK: - m_widthEditFresh = false; - if (!m_widthEditText.empty()) + m_editFresh = false; + if (!m_editText.empty()) { - m_widthEditText.pop_back(); + m_editText.pop_back(); } return true; default: @@ -1201,9 +1361,9 @@ namespace jdi bool SignalPanel::CloseTransientUi() { bool changed = false; - if (m_widthEditing) + if (m_editField != StyleEditField::NONE) { - changed = EndWidthEdit(true) || changed; + changed = EndEdit(true) || changed; } if (m_colorPicker != nullptr && m_colorPicker->IsOpen()) { @@ -1288,6 +1448,10 @@ namespace jdi { hoverStyle = StyleHit::WIDTH_SLIDER; } + if (hoverStyle == StyleHit::NONE && UiPointInRect(m_opacityValueRect, fx, fy)) + { + hoverStyle = StyleHit::OPACITY_VALUE; + } if (hoverStyle == StyleHit::NONE && UiPointInRect(m_widthValueRect, fx, fy)) { hoverStyle = StyleHit::WIDTH_VALUE; @@ -1344,10 +1508,11 @@ namespace jdi m_pendingLaneMenu = false; m_pendingColorPicker = false; - // 点击样式区以外的位置时结束线宽输入。 - if (m_widthEditing && !UiPointInRect(m_widthValueRect, fx, fy)) + // 点击当前输入框以外的位置时提交输入。 + if (m_editField != StyleEditField::NONE && + !UiPointInRect(EditFieldRect(), fx, fy)) { - EndWidthEdit(true); + EndEdit(true); } // 全部显示/隐藏。 @@ -1398,10 +1563,14 @@ namespace jdi ApplyWidthFromX(fx); return true; } + // 数值框的命中优先于滑块:两者几何重叠时要能进入输入状态。 if (UiPointInRect(m_widthValueRect, fx, fy)) { - // 单击即进入输入状态,双击亦等效。 - return BeginWidthEdit(); + return BeginEdit(StyleEditField::WIDTH); + } + if (UiPointInRect(m_opacityValueRect, fx, fy)) + { + return BeginEdit(StyleEditField::OPACITY); } } @@ -1522,10 +1691,14 @@ namespace jdi const float fx = static_cast(x); const float fy = static_cast(y); - // 线宽数值框:双击进入(或保持)输入状态。 + // 样式区数值框:双击进入(或保持)输入状态。 if (UiPointInRect(m_widthValueRect, fx, fy)) { - return BeginWidthEdit(); + return BeginEdit(StyleEditField::WIDTH); + } + if (UiPointInRect(m_opacityValueRect, fx, fy)) + { + return BeginEdit(StyleEditField::OPACITY); } RowPart part = RowPart::NONE; @@ -1536,24 +1709,35 @@ namespace jdi } const ListRow& item = m_rows[static_cast(row)]; + + // 分组行:双击折叠三角用于折叠,双击名称区域进入重命名。 if (item.kind == RowKind::FILE) { - m_tab->ToggleCollapsed(item.fileIndex); + if (part == RowPart::DISCLOSURE) + { + m_tab->ToggleCollapsed(item.fileIndex); + m_rowsDirty = true; + return true; + } + if (part != RowPart::BODY) + { + return false; + } + // 重命名分组会改变列表行高度以外的展示内容,重建行缓存以保证文字刷新。 m_rowsDirty = true; - return true; + return BeginEdit(StyleEditField::FILE_NAME, row); } - // 网格徽标单击已可打开菜单,双击不再切换可见性。 - if (part == RowPart::LANE_BADGE) + // 复选框与网格徽标各自由单击处理,双击不再切换可见性。 + if (part == RowPart::CHECK || part == RowPart::LANE_BADGE) { return false; } const SignalRef ref{item.fileIndex, item.signalIndex}; m_tab->SetSelected(ref); - m_tab->ToggleVisible(*m_dataSet, ref); m_rowsDirty = true; - return true; + return BeginEdit(StyleEditField::SIGNAL_NAME, row); } bool SignalPanel::OnRButtonDown(int x, int y) @@ -1630,7 +1814,7 @@ namespace jdi MenuItem header; header.kind = MenuItemKind::HEADER; - header.label = signal->name; + header.label = signal->Title(); items.push_back(header); MenuItem toggle; @@ -1712,7 +1896,7 @@ namespace jdi MenuItem header; header.kind = MenuItemKind::HEADER; - header.label = file->fileName; + header.label = file->Title(); items.push_back(header); MenuItem showAll; diff --git a/Utilities/Inc/ColorPickerPopup.hpp b/Utilities/Inc/ColorPickerPopup.hpp index 42d3f3a..a2e035a 100644 --- a/Utilities/Inc/ColorPickerPopup.hpp +++ b/Utilities/Inc/ColorPickerPopup.hpp @@ -99,12 +99,25 @@ namespace jdi bool OnLButtonUp(); /** - * @brief 处理键盘按键(Esc 收起)。 + * @brief 处理键盘按键(Esc 收起、回车提交输入)。 * @param [IN] key 虚拟键码。 * @return 事件已被消费返回 true。 */ bool OnKeyDown(WPARAM key); + /** + * @brief 处理字符输入。 + * @param [IN] character 输入字符('\r' 提交、'\b' 退格、0x03 复制)。 + * @return 界面状态发生变化返回 true。 + */ + bool OnChar(wchar_t character); + + /** + * @brief 判断面板是否处于内联文本输入状态。 + * @return 正在输入返回 true。 + */ + bool IsEditing() const { return m_editField != EditField::NONE; } + /** * @brief 判断坐标是否落在拾色面板内部。 * @param [IN] x 客户区横坐标。 @@ -122,6 +135,18 @@ namespace jdi HCURSOR QueryCursor(int x, int y) const; private: + /** + * @brief 面板内可进行内联文本输入的字段。 + */ + enum class EditField : int + { + NONE = 0, /**< 未处于编辑状态。 */ + R = 1, /**< 红色分量(0-255)。 */ + G = 2, /**< 绿色分量(0-255)。 */ + B = 3, /**< 蓝色分量(0-255)。 */ + HEX = 4 /**< 十六进制色号。 */ + }; + /** * @brief 依据锚点矩形与客户区计算面板矩形与内部控件矩形。 * @param [IN] bounds 宿主客户区矩形。 @@ -129,6 +154,26 @@ namespace jdi */ void BuildLayout(const gd::RectF& bounds, const gd::RectF& anchor); + /** + * @brief 进入指定字段的内联输入状态。 + * @param [IN] field 目标字段。 + * @return 状态发生变化返回 true。 + */ + bool BeginEdit(EditField field); + + /** + * @brief 结束内联输入状态。 + * @param [IN] commit 是否提交输入值。 + * @return 状态发生变化返回 true。 + */ + bool EndEdit(bool commit); + + /** + * @brief 计算当前输入框的矩形。 + * @return 输入框矩形。 + */ + gd::RectF EditFieldRect() const; + /** * @brief 查询坐标命中的滑块下标。 * @param [IN] x 客户区横坐标。 @@ -160,6 +205,10 @@ namespace jdi std::array m_values; /**< 数值文本矩形。 */ gd::RectF m_previewRect; /**< 预览色块矩形。 */ gd::RectF m_hexRect; /**< 十六进制文本矩形。 */ + + EditField m_editField = EditField::NONE; /**< 当前处于输入状态的字段。 */ + bool m_editFresh = false; /**< 输入缓冲是否仍为初始值(首次键入应整体替换)。 */ + std::wstring m_editText; /**< 输入缓冲。 */ }; } // namespace jdi diff --git a/Utilities/Inc/DataModel.hpp b/Utilities/Inc/DataModel.hpp index 381cfed..5522825 100644 --- a/Utilities/Inc/DataModel.hpp +++ b/Utilities/Inc/DataModel.hpp @@ -85,13 +85,20 @@ namespace jdi */ struct SignalData { - std::wstring name; /**< 显示名称(已剥离 "[n]" 序号前缀)。 */ + std::wstring name; /**< 名称(取自文件列名,完全保留原样)。 */ + std::wstring displayName; /**< 用户重命名的显示名;为空时使用 name。 */ std::wstring rawHeader; /**< 原始列名。 */ std::wstring unit; /**< 从列名括号中提取的单位,可能为空。 */ std::vector values; /**< 采样值,缺失处为 NaN。 */ double minValue = 0.0; /**< 有限采样值最小值。 */ double maxValue = 0.0; /**< 有限采样值最大值。 */ std::size_t finiteCount = 0; /**< 有限采样点数量。 */ + + /** + * @brief 获取用于界面展示的名称。 + * @return 用户重命名后的显示名,未重命名时返回文件中的原始名称。 + */ + const std::wstring& Title() const { return displayName.empty() ? name : displayName; } }; /** @@ -99,14 +106,21 @@ namespace jdi */ struct DataFile { - std::wstring filePath; /**< 文件完整路径。 */ + std::wstring filePath; /**< 文件完整路径(仅用于状态提示)。 */ std::wstring fileName; /**< 文件名称。 */ + std::wstring displayName; /**< 用户重命名的分组名;为空时使用 fileName。 */ std::wstring timeColumnName; /**< 时间列列名。 */ std::vector time; /**< 时间轴数值。 */ std::vector signals; /**< 该文件包含的信号。 */ double minTime = 0.0; /**< 时间轴最小值。 */ double maxTime = 0.0; /**< 时间轴最大值。 */ bool timeMonotonic = true; /**< 时间轴是否单调不减。 */ + + /** + * @brief 获取用于界面展示的分组名。 + * @return 用户重命名后的显示名,未重命名时返回文件名。 + */ + const std::wstring& Title() const { return displayName.empty() ? fileName : displayName; } }; /** @@ -168,6 +182,23 @@ namespace jdi */ const DataFile* FileAt(int fileIndex) const; + /** + * @brief 修改某个信号在界面上的显示名称。 + * @param [IN] fileIndex 文件下标。 + * @param [IN] signalIndex 文件内信号下标。 + * @param [IN] name 新的显示名;传入空串表示恢复使用原始列名。 + * @return 修改成功返回 true。 + */ + bool SetSignalDisplayName(int fileIndex, int signalIndex, const std::wstring& name); + + /** + * @brief 修改某个文件分组的显示名称。 + * @param [IN] fileIndex 文件下标。 + * @param [IN] name 新的分组名;传入空串表示恢复使用文件名。 + * @return 修改成功返回 true。 + */ + bool SetFileDisplayName(int fileIndex, const std::wstring& name); + /** * @brief 解析信号引用。 * @param [IN] ref 信号引用。 diff --git a/Utilities/Inc/PaintUtil.hpp b/Utilities/Inc/PaintUtil.hpp index 1b66a32..337c474 100644 --- a/Utilities/Inc/PaintUtil.hpp +++ b/Utilities/Inc/PaintUtil.hpp @@ -209,6 +209,34 @@ namespace jdi */ double UiSliderRatioFromX(const gd::RectF& trackRect, float x); + /** + * @brief 绘制水平滑块(轨道 + 已填充部分 + 圆钮)。 + * @param [IN,OUT] g 绘图上下文。 + * @param [IN] track 轨道矩形。 + * @param [IN] ratio 当前值比例(0-1)。 + * @param [IN] fillColor 已填充部分颜色。 + * @param [IN] active 是否处于悬停或拖拽状态(影响圆钮半径)。 + * @param [IN] scale DPI 缩放比例。 + */ + void UiDrawSlider(gd::Graphics& g, const gd::RectF& track, double ratio, std::uint32_t fillColor, + bool active, float scale); + + /** + * @brief 绘制内联数值输入框。 + * + * 处于“首次输入整体替换”状态时以高亮底衬表示整段内容将被覆盖, + * 交互观感与常见登录界面的账号/密码输入框一致;已在键入时显示插入符。 + * + * @param [IN,OUT] g 绘图上下文。 + * @param [IN] rect 输入框矩形。 + * @param [IN] text 当前文本(可为空)。 + * @param [IN] fresh 是否仍处于“首次输入整体替换”状态。 + * @param [IN] font 文本字体。 + * @param [IN] scale DPI 缩放比例。 + */ + void UiDrawEditField(gd::Graphics& g, const gd::RectF& rect, const std::wstring& text, bool fresh, + HFONT font, float scale); + /** * @brief 绘制一个带边框的复选框。 * @param [IN,OUT] g 绘图上下文。 diff --git a/Utilities/Inc/Utf8Util.hpp b/Utilities/Inc/Utf8Util.hpp index c346fc5..261653a 100644 --- a/Utilities/Inc/Utf8Util.hpp +++ b/Utilities/Inc/Utf8Util.hpp @@ -1,6 +1,8 @@ #ifndef __UTF8_UTIL_HPP__ #define __UTF8_UTIL_HPP__ +#include + #include #include #include @@ -116,6 +118,14 @@ namespace jdi */ void BuildTicks(double minValue, double maxValue, double step, std::vector& ticks); + /** + * @brief 将文本复制到系统剪贴板。 + * @param [IN] owner 剪贴板所有者窗口句柄,允许为空。 + * @param [IN] text 待复制文本。 + * @return 复制成功返回 true。 + */ + bool CopyTextToClipboard(HWND owner, const std::wstring& text); + } // namespace jdi #endif // __UTF8_UTIL_HPP__ diff --git a/Utilities/Src/ColorPickerPopup.cpp b/Utilities/Src/ColorPickerPopup.cpp index cb231e1..c499c54 100644 --- a/Utilities/Src/ColorPickerPopup.cpp +++ b/Utilities/Src/ColorPickerPopup.cpp @@ -2,6 +2,9 @@ #include #include +#include + +#include "Utf8Util.hpp" namespace jdi { @@ -30,7 +33,7 @@ namespace jdi const std::uint32_t kChannelColors[3] = {0xFFDC322Fu, 0xFF859900u, 0xFF268BD2u}; /** - * @brief 绘制水平滑块(轨道 + 已填充部分 + 圆钮)。 + * @brief 绘制水平滑块(与样式区共用同一实现,保证行为一致)。 * @param [IN,OUT] g 绘图上下文。 * @param [IN] track 轨道矩形。 * @param [IN] ratio 当前值比例(0-1)。 @@ -41,22 +44,7 @@ namespace jdi void DrawChannelSlider(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 : 0.8f * 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); + UiDrawSlider(g, track, ratio, fillColor, hovered, scale); } /** @@ -179,11 +167,238 @@ namespace jdi void ColorPickerPopup::Close() { + // 收起前先提交正在编辑的取值,避免用户输入被静默丢弃。 + if (m_editField != EditField::NONE) + { + EndEdit(true); + } m_open = false; m_draggingSlider = -1; m_hoverSlider = -1; } + gd::RectF ColorPickerPopup::EditFieldRect() const + { + switch (m_editField) + { + case EditField::R: + return m_values[0]; + case EditField::G: + return m_values[1]; + case EditField::B: + return m_values[2]; + case EditField::HEX: + return m_hexRect; + case EditField::NONE: + default: + return gd::RectF(); + } + } + + bool ColorPickerPopup::BeginEdit(EditField field) + { + if (!m_open || field == EditField::NONE) + { + return false; + } + if (m_editField == field) + { + return false; + } + + switch (field) + { + case EditField::R: + case EditField::G: + case EditField::B: + { + const int index = static_cast(field) - 1; + m_editText = std::to_wstring(ToByte(m_channels[static_cast(index)])); + break; + } + case EditField::HEX: + { + const std::uint32_t color = CurrentColor(); + wchar_t buffer[16]; + swprintf(buffer, sizeof(buffer) / sizeof(buffer[0]), L"%02X%02X%02X", (color >> 16) & 0xFFu, + (color >> 8) & 0xFFu, color & 0xFFu); + m_editText = std::wstring(buffer); + break; + } + default: + return false; + } + + m_draggingSlider = -1; + m_editField = field; + m_editFresh = true; + return true; + } + + bool ColorPickerPopup::EndEdit(bool commit) + { + if (m_editField == EditField::NONE) + { + return false; + } + + const EditField field = m_editField; + const std::wstring text = m_editText; + m_editField = EditField::NONE; + m_editFresh = false; + m_editText.clear(); + + if (!commit) + { + return true; + } + + const std::wstring trimmed = Trim(text); + if (field == EditField::HEX) + { + // 接受带或不带 '#' 的 6 位十六进制色号。 + std::wstring digits = trimmed; + if (!digits.empty() && digits.front() == L'#') + { + digits.erase(digits.begin()); + } + if (digits.size() == 6) + { + unsigned int value = 0; + bool valid = true; + for (const wchar_t c : digits) + { + unsigned int digit = 0; + if (c >= L'0' && c <= L'9') + { + digit = static_cast(c - L'0'); + } + else if (c >= L'a' && c <= L'f') + { + digit = static_cast(c - L'a') + 10u; + } + else if (c >= L'A' && c <= L'F') + { + digit = static_cast(c - L'A') + 10u; + } + else + { + valid = false; + break; + } + value = (value << 4) | digit; + } + if (valid) + { + m_channels[0] = static_cast((value >> 16) & 0xFFu) / 255.0; + m_channels[1] = static_cast((value >> 8) & 0xFFu) / 255.0; + m_channels[2] = static_cast(value & 0xFFu) / 255.0; + m_changed = true; + } + } + return true; + } + + if (trimmed.empty()) + { + return true; + } + wchar_t* stop = nullptr; + const double parsed = std::wcstod(trimmed.c_str(), &stop); + if (stop == trimmed.c_str() || parsed != parsed) + { + return true; + } + + double value = parsed; + if (value < 0.0) + { + value = 0.0; + } + if (value > 255.0) + { + value = 255.0; + } + + const int index = static_cast(field) - 1; + if (index >= 0 && index < 3) + { + m_channels[static_cast(index)] = value / 255.0; + m_changed = true; + } + return true; + } + + bool ColorPickerPopup::OnChar(wchar_t character) + { + if (m_editField == EditField::NONE) + { + return false; + } + + // Ctrl+C:把当前文本复制到剪贴板。 + if (character == 0x03) + { + CopyTextToClipboard(nullptr, m_editText); + return false; + } + + if (character == L'\r' || character == L'\n') + { + return EndEdit(true); + } + if (character == L'\b') + { + m_editFresh = false; + if (!m_editText.empty()) + { + m_editText.pop_back(); + } + return true; + } + if (character < 0x20 || character == 0x7F) + { + return true; + } + + if (m_editField == EditField::HEX) + { + const bool hexDigit = (character >= L'0' && character <= L'9') || + (character >= L'a' && character <= L'f') || + (character >= L'A' && character <= L'F') || (character == L'#'); + if (!hexDigit) + { + return true; + } + if (m_editFresh) + { + m_editText.clear(); + m_editFresh = false; + } + if (m_editText.size() < 7) + { + m_editText.push_back(character); + } + return true; + } + + const bool digit = (character >= L'0' && character <= L'9'); + if (!digit) + { + return true; + } + if (m_editFresh) + { + m_editText.clear(); + m_editFresh = false; + } + if (m_editText.size() < 3) + { + m_editText.push_back(character); + } + return true; + } + bool ColorPickerPopup::ContainsPoint(int x, int y) const { if (!m_open) @@ -283,6 +498,29 @@ namespace jdi return true; } + // 点击当前输入框以外的位置时提交输入。 + if (m_editField != EditField::NONE && !UiPointInRect(EditFieldRect(), static_cast(x), + static_cast(y))) + { + EndEdit(true); + } + + // 数值与色号文本优先于滑块:两者几何重叠时要能进入输入状态。 + for (int index = 0; index < 3; ++index) + { + if (UiPointInRect(m_values[static_cast(index)], static_cast(x), + static_cast(y))) + { + BeginEdit(static_cast(index + 1)); + return true; + } + } + if (UiPointInRect(m_hexRect, static_cast(x), static_cast(y))) + { + BeginEdit(EditField::HEX); + return true; + } + m_draggingSlider = SliderAtPoint(x, y); if (m_draggingSlider >= 0) { @@ -308,6 +546,28 @@ namespace jdi { return false; } + if (m_editField != EditField::NONE) + { + switch (key) + { + case VK_RETURN: + EndEdit(true); + return true; + case VK_ESCAPE: + EndEdit(false); + return true; + case VK_BACK: + m_editFresh = false; + if (!m_editText.empty()) + { + m_editText.pop_back(); + } + return true; + default: + break; + } + return true; + } if (key == VK_ESCAPE) { Close(); @@ -347,8 +607,15 @@ namespace jdi const bool active = (m_draggingSlider == index) || (m_hoverSlider == index); DrawChannelSlider(g, track, m_channels[slot], kChannelColors[index], active, m_scale); - UiText(g, std::to_wstring(ToByte(m_channels[slot])), m_fonts->monoSmall, m_values[slot], - m_theme->text, TextAlign::RIGHT); + if (m_editField == static_cast(index + 1)) + { + UiDrawEditField(g, m_values[slot], m_editText, m_editFresh, m_fonts->monoSmall, m_scale); + } + else + { + UiText(g, std::to_wstring(ToByte(m_channels[slot])), m_fonts->monoSmall, m_values[slot], + m_theme->text, TextAlign::RIGHT); + } } // 预览与十六进制值。 @@ -356,10 +623,17 @@ namespace jdi UiFillRoundRect(g, m_previewRect, 3.0f * m_scale, color); UiDrawRoundRect(g, m_previewRect, 3.0f * m_scale, m_theme->borderStrong, 1.0f); - wchar_t buffer[16]; - swprintf(buffer, sizeof(buffer) / sizeof(buffer[0]), L"#%02X%02X%02X", (color >> 16) & 0xFFu, - (color >> 8) & 0xFFu, color & 0xFFu); - UiText(g, std::wstring(buffer), m_fonts->monoSmall, m_hexRect, m_theme->text, TextAlign::LEFT); + if (m_editField == EditField::HEX) + { + UiDrawEditField(g, m_hexRect, m_editText, m_editFresh, m_fonts->monoSmall, m_scale); + } + else + { + wchar_t buffer[16]; + swprintf(buffer, sizeof(buffer) / sizeof(buffer[0]), L"#%02X%02X%02X", (color >> 16) & 0xFFu, + (color >> 8) & 0xFFu, color & 0xFFu); + UiText(g, std::wstring(buffer), m_fonts->monoSmall, m_hexRect, m_theme->text, TextAlign::LEFT); + } } } // namespace jdi diff --git a/Utilities/Src/DataModel.cpp b/Utilities/Src/DataModel.cpp index 73d7c12..81abd08 100644 --- a/Utilities/Src/DataModel.cpp +++ b/Utilities/Src/DataModel.cpp @@ -446,6 +446,47 @@ namespace jdi return &m_files[static_cast(fileIndex)]; } + bool DataSet::SetSignalDisplayName(int fileIndex, int signalIndex, const std::wstring& name) + { + if (fileIndex < 0 || fileIndex >= static_cast(m_files.size())) + { + return false; + } + + DataFile& file = m_files[static_cast(fileIndex)]; + if (signalIndex < 0 || signalIndex >= static_cast(file.signals.size())) + { + return false; + } + + // 统一去除首尾空白;结果为空则视为恢复原始列名。 + std::wstring trimmed = Trim(name); + SignalData& signal = file.signals[static_cast(signalIndex)]; + if (trimmed == signal.displayName) + { + return false; + } + signal.displayName = std::move(trimmed); + return true; + } + + bool DataSet::SetFileDisplayName(int fileIndex, const std::wstring& name) + { + if (fileIndex < 0 || fileIndex >= static_cast(m_files.size())) + { + return false; + } + + std::wstring trimmed = Trim(name); + DataFile& file = m_files[static_cast(fileIndex)]; + if (trimmed == file.displayName) + { + return false; + } + file.displayName = std::move(trimmed); + return true; + } + const SignalData* DataSet::Resolve(const SignalRef& ref) const { const DataFile* file = FileAt(ref.fileIndex); diff --git a/Utilities/Src/PaintUtil.cpp b/Utilities/Src/PaintUtil.cpp index 3cd342d..a846152 100644 --- a/Utilities/Src/PaintUtil.cpp +++ b/Utilities/Src/PaintUtil.cpp @@ -419,6 +419,83 @@ namespace jdi } } + void UiDrawSlider(gd::Graphics& g, const gd::RectF& track, double ratio, std::uint32_t fillColor, + bool active, float scale) + { + if (track.Width <= 0.0f || track.Height <= 0.0f) + { + return; + } + + if (ratio < 0.0) + { + ratio = 0.0; + } + if (ratio > 1.0) + { + ratio = 1.0; + } + + const float radius = track.Height * 0.5f; + UiFillRoundRect(g, track, radius, 0x2A657B83u); + + // 已填充部分严格止于圆钮中心,最小值时不会留下超出圆钮的填充残留。 + const float fillWidth = static_cast(ratio) * track.Width; + if (fillWidth > 0.5f) + { + UiFillRoundRect(g, gd::RectF(track.X, track.Y, fillWidth, track.Height), radius, fillColor); + } + + const float knobRadius = radius + (active ? 2.5f * scale : 0.8f * scale); + const float knobX = track.X + static_cast(ratio) * track.Width; + + 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); + } + + void UiDrawEditField(gd::Graphics& g, const gd::RectF& rect, const std::wstring& text, bool fresh, + HFONT font, float scale) + { + if (rect.Width <= 0.0f || rect.Height <= 0.0f || font == nullptr) + { + return; + } + + const Theme& theme = SolarizedLight(); + const float corner = 3.0f * scale; + + UiFillRoundRect(g, rect, corner, theme.cardBg); + UiDrawRoundRect(g, rect, corner, theme.accent, 1.6f); + + const std::wstring shown = text.empty() ? std::wstring(L" ") : text; + + if (fresh) + { + // 整段内容处于“将被覆盖”状态:以强调色底衬表示已全选。 + const float textWidth = UiTextWidth(g, shown, font) + 8.0f * scale; + const gd::RectF highlight(rect.X + (rect.Width - textWidth) * 0.5f, rect.Y + 2.0f * scale, + textWidth, rect.Height - 4.0f * scale); + UiFillRoundRect(g, highlight, 2.0f * scale, theme.accent); + UiText(g, shown, font, highlight, theme.textOnAccent, TextAlign::CENTER); + return; + } + + UiText(g, shown, font, rect, theme.textStrong, TextAlign::CENTER); + + // 已开始键入:在文本末尾显示插入符。 + const float textWidth = UiTextWidth(g, shown, font); + const float caretX = rect.X + (rect.Width + textWidth) * 0.5f + 1.0f * scale; + if (caretX < rect.X + rect.Width - 2.0f * scale) + { + UiDrawLine(g, caretX, rect.Y + 4.0f * scale, caretX, rect.Y + rect.Height - 4.0f * scale, + theme.accent, 1.4f * scale); + } + } + bool UiSwatchNeedsOutline(float opacity) { return !(opacity >= kSwatchOutlineOpacity); diff --git a/Utilities/Src/Utf8Util.cpp b/Utilities/Src/Utf8Util.cpp index 135a318..10f5f39 100644 --- a/Utilities/Src/Utf8Util.cpp +++ b/Utilities/Src/Utf8Util.cpp @@ -367,4 +367,48 @@ namespace jdi } } + bool CopyTextToClipboard(HWND owner, const std::wstring& text) + { + if (text.empty()) + { + return false; + } + + if (OpenClipboard(owner) == FALSE) + { + return false; + } + + bool success = false; + if (EmptyClipboard() != FALSE) + { + const SIZE_T bytes = (text.size() + 1) * sizeof(wchar_t); + HGLOBAL handle = GlobalAlloc(GMEM_MOVEABLE, bytes); + if (handle != nullptr) + { + void* target = GlobalLock(handle); + if (target != nullptr) + { + std::memcpy(target, text.c_str(), bytes); + GlobalUnlock(handle); + if (SetClipboardData(CF_UNICODETEXT, handle) != nullptr) + { + success = true; + } + else + { + GlobalFree(handle); + } + } + else + { + GlobalFree(handle); + } + } + } + + CloseClipboard(); + return success; + } + } // namespace jdi