From 53768b8b25a4e0f222c59270700b9499ac88de73 Mon Sep 17 00:00:00 2001 From: "DeepSeek V4.1 Flash" <> Date: Wed, 30 Sep 2026 13:40:58 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E7=BB=9F=E4=B8=80=E8=BE=93=E5=85=A5?= =?UTF-8?q?=E6=A1=86=E7=BC=96=E8=BE=91=E8=83=BD=E5=8A=9B=E3=80=81=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=E6=8B=96=E6=8B=BD=E5=90=88=E5=B9=B6=E7=BD=91=E6=A0=BC?= =?UTF-8?q?=E5=B9=B6=E6=94=AF=E6=8C=81=E5=85=89=E6=A0=87=E5=90=B8=E9=99=84?= =?UTF-8?q?=E6=95=B0=E6=8D=AE=E7=82=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 统一文本输入(新增 Utilities/Inc/TextEdit.hpp + TextEdit.cpp) - 一个共享的编辑模型,提供光标/选区/过滤/限长,接入全部输入框: 样式区数值与信号名/分组名、波形图例改名、视图标签页改名、 拾色器 R/G/B 与色号、光标时间与网格上下限。 - 新增能力:Ctrl+A/C/X/V(含剪贴板清洗)、Shift+方向键与 Home/End 选区、 退格与删除清除选区、代理对安全的光标步进。 - 渲染改为选区高亮 + 插入符,文本超出框宽时水平滚动并让插入符始终可见; 初始态为整段选中,首字符键入即整体替换(保持"登录框"式交互)。 - 新增 Utf8Util::PasteTextFromClipboard(换行/制表符折叠为空格)。 修复网格合并(Utilities/Src/DataModel.cpp) - 拖拽信号到其它网格时先固定目标网格的锚信号,再移除源信号并按引用找回目标, 避免空网格回收导致下标错位,从"与原网格交换位置"改为真正合并。 - 自定义划分下未分配网格的可见信号改为各自独占一个新网格, 修复"拖拽合并后再勾选新信号被并入该合并网格"的问题。 绘图区(Components/PlotView) - 网格右键菜单新增"时间轴自适应(匹配本网格)",把时间轴对齐到该网格信号范围。 - 时间轴放大到采样点在屏幕上可分辨(>=6 逻辑像素)时,拖动光标吸附到最近数据点; 缩小到无法分辨时保持自由移动,避免光标"跳格"。 验证:真实输入脚本逐项核对——拖拽合并后单网格含两条曲线、拖拽合并再勾选新信号得 G2 独立网格、 Shift 选区 + Delete 清除、Ctrl+A/C/V 复制粘贴、46 字符文本水平滚动、拾色器色号 1E90FF、 标签页改名 A1、光标时间框复制粘贴、全视图拖动得 0.522213(不吸附)而放大后得 0.28125(= 225 × 0.00125,已吸附)。 --- Components/Application/Src/Application.cpp | 11 + Components/PlotView/Inc/PlotView.hpp | 41 ++- Components/PlotView/Src/PlotView.cpp | 275 ++++++++++++----- Components/SignalPanel/Inc/SignalPanel.hpp | 3 +- Components/SignalPanel/Src/SignalPanel.cpp | 118 ++++--- Components/TabBar/Inc/TabBar.hpp | 10 +- Components/TabBar/Src/TabBar.cpp | 76 +++-- Utilities/Inc/ColorPickerPopup.hpp | 10 +- Utilities/Inc/PaintUtil.hpp | 15 +- Utilities/Inc/TextEdit.hpp | 210 +++++++++++++ Utilities/Inc/Utf8Util.hpp | 8 + Utilities/Src/ColorPickerPopup.cpp | 106 +++---- Utilities/Src/DataModel.cpp | 74 +++-- Utilities/Src/PaintUtil.cpp | 85 ++++-- Utilities/Src/TextEdit.cpp | 340 +++++++++++++++++++++ Utilities/Src/Utf8Util.cpp | 41 +++ 16 files changed, 1135 insertions(+), 288 deletions(-) create mode 100644 Utilities/Inc/TextEdit.hpp create mode 100644 Utilities/Src/TextEdit.cpp diff --git a/Components/Application/Src/Application.cpp b/Components/Application/Src/Application.cpp index f19b3c8..e740f23 100644 --- a/Components/Application/Src/Application.cpp +++ b/Components/Application/Src/Application.cpp @@ -191,16 +191,19 @@ namespace jdi m_tabBar.Setup(&m_fonts, &m_theme); m_tabBar.SetScale(m_scale); + m_tabBar.SetHostWindow(m_hwnd); m_toolbar.Setup(&m_fonts, &m_theme); m_toolbar.SetScale(m_scale); m_menu.Setup(&m_fonts, &m_theme); m_menu.SetScale(m_scale); m_colorPicker.Setup(&m_fonts, &m_theme); m_colorPicker.SetScale(m_scale); + m_colorPicker.SetOwnerWindow(m_hwnd); m_panel.Setup(m_hwnd, &m_fonts, &m_theme, &m_menu, &m_colorPicker); m_panel.SetScale(m_scale); m_plot.Setup(&m_fonts, &m_theme); m_plot.SetScale(m_scale); + m_plot.SetHostWindow(m_hwnd); HICON icon = LoadIconW(nullptr, IDI_APPLICATION); SendMessageW(m_hwnd, WM_SETICON, ICON_BIG, reinterpret_cast(icon)); @@ -1127,6 +1130,11 @@ namespace jdi separator.kind = MenuItemKind::SEPARATOR; items.push_back(separator); + MenuItem fitLaneTime; + fitLaneTime.label = L"时间轴自适应(匹配本网格)"; + fitLaneTime.id = PLOT_CMD_LANE_FIT_TIME; + items.push_back(fitLaneTime); + MenuItem fitItem; fitItem.label = L"全部自适应"; fitItem.id = PLOT_CMD_FIT_ALL; @@ -1174,6 +1182,9 @@ namespace jdi case PLOT_CMD_LANE_Y_MIN: m_plot.BeginLaneValueEdit(laneIndex, false); break; + case PLOT_CMD_LANE_FIT_TIME: + m_plot.FitTimeToLane(laneIndex); + break; case PLOT_CMD_FIT_ALL: m_plot.FitAll(); break; diff --git a/Components/PlotView/Inc/PlotView.hpp b/Components/PlotView/Inc/PlotView.hpp index a4acb2e..32bcfa2 100644 --- a/Components/PlotView/Inc/PlotView.hpp +++ b/Components/PlotView/Inc/PlotView.hpp @@ -49,7 +49,8 @@ namespace jdi PLOT_CMD_LANE_AUTO = 1, /**< 该网格恢复 Y 轴自适应。 */ PLOT_CMD_LANE_Y_MAX = 2, /**< 输入该网格 Y 轴最大值。 */ PLOT_CMD_LANE_Y_MIN = 3, /**< 输入该网格 Y 轴最小值。 */ - PLOT_CMD_FIT_ALL = 4 /**< 全部网格与时间轴自适应。 */ + PLOT_CMD_FIT_ALL = 4, /**< 全部网格与时间轴自适应。 */ + PLOT_CMD_LANE_FIT_TIME = 5 /**< 时间轴自适应到本网格信号范围。 */ }; /** @@ -63,6 +64,12 @@ namespace jdi public: PlotView(); + /** + * @brief 设置宿主窗口句柄(用于剪贴板所有者)。 + * @param [IN] host 宿主窗口句柄,允许为空。 + */ + void SetHostWindow(HWND host) { m_host = host; } + /** * @brief 绑定字体与主题资源。 * @param [IN] fonts 字体集合,需在视图生命周期内保持有效。 @@ -143,6 +150,34 @@ namespace jdi */ bool OnMouseLeave(); + /** + * @brief 将时间轴自适应到指定网格内信号的时间范围。 + * @param [IN] laneIndex 网格下标。 + * @return 调整成功返回 true。 + */ + bool FitTimeToLane(int laneIndex); + + /** + * @brief 把时间吸附到距今最近的数据采样点。 + * + * 仅在横向放大到采样点间距足够大(可精确分辨)时生效, + * 避免缩小时光标出现“抖动”难以微调。 + * + * @param [IN] time 原始时间。 + * @param [OUT] snapped 吸附后的时间。 + * @return 成功吸附返回 true。 + */ + bool SnapTimeToSample(double time, double& snapped) const; + + /** + * @brief 计算指定文件中某一时刻附近采样点的时间间隔。 + * @param [IN] file 目标文件。 + * @param [IN] time 目标时刻。 + * @param [OUT] pixels 间隔换算到屏幕上的像素宽度。 + * @return 计算成功返回 true。 + */ + bool SampleSpacingPixels(const DataFile& file, double time, double& pixels) const; + /** * @brief 判断坐标是否落在绘图区范围内。 * @param [IN] x 客户区横坐标。 @@ -602,6 +637,7 @@ namespace jdi const DataSet* m_dataSet = nullptr; /**< 数据集合。 */ TabConfig* m_tab = nullptr; /**< 当前标签页配置。 */ TabConfig m_localTab; /**< 未绑定标签页时使用的占位配置。 */ + HWND m_host = nullptr; /**< 剪贴板所有者窗口句柄。 */ float m_scale = 1.0f; /**< DPI 缩放比例。 */ gd::RectF m_area; /**< 绘图区整体区域。 */ @@ -631,8 +667,7 @@ namespace jdi int m_dragMouseY = 0; /**< 拖拽过程中鼠标的当前纵坐标(虚影跟随)。 */ bool m_cursorHover = false; /**< 鼠标是否悬停在时间光标上。 */ PlotEditTarget m_editTarget = PlotEditTarget::NONE; /**< 当前内联输入目标。 */ - bool m_editFresh = false; /**< 输入框是否处于“首次输入整体替换”状态。 */ - std::wstring m_editText; /**< 输入框当前文本。 */ + TextEdit m_edit; /**< 输入框编辑模型(光标/选区/剪贴板)。 */ int m_editLane = -1; /**< 内联输入所作用的网格下标。 */ SignalRef m_editSignal; /**< 内联输入所作用的信号(图例改名)。 */ gd::RectF m_editFieldRect; /**< 本次编辑输入框的矩形(用于点外提交判定)。 */ diff --git a/Components/PlotView/Src/PlotView.cpp b/Components/PlotView/Src/PlotView.cpp index 1347840..1f60399 100644 --- a/Components/PlotView/Src/PlotView.cpp +++ b/Components/PlotView/Src/PlotView.cpp @@ -25,6 +25,7 @@ namespace jdi const float kPlotSidePadding = 8.0f; /**< 绘图区左右留白(逻辑像素)。 */ const float kLaneDividerWidth = 2.0f; /**< 网格之间分界线的宽度。 */ const float kSignalHitTolerance = 6.0f; /**< 信号曲线命中容差(逻辑像素)。 */ + const float kCursorSnapMinPixels = 6.0f; /**< 光标吸附采样点的最小采样间隔(逻辑像素)。 */ const float kLaneLabelPad = 6.0f; /**< 网格内侧标尺带的内边距。 */ const float kLegendHeight = 19.0f; /**< 图例条目高度(逻辑像素)。 */ const float kLegendPad = 5.0f; /**< 图例相对网格左上角的内缩。 */ @@ -483,6 +484,137 @@ namespace jdi } } + bool PlotView::FitTimeToLane(int laneIndex) + { + if (m_dataSet == nullptr || m_tab == nullptr) + { + return false; + } + + const std::vector& lanes = m_tab->Lanes(); + if (laneIndex < 0 || laneIndex >= static_cast(lanes.size())) + { + return false; + } + + double low = std::numeric_limits::infinity(); + double high = -std::numeric_limits::infinity(); + for (const SignalRef& ref : lanes[static_cast(laneIndex)].signals) + { + const DataFile* file = m_dataSet->FileAt(ref.fileIndex); + if (file == nullptr) + { + continue; + } + low = std::min(low, file->minTime); + high = std::max(high, file->maxTime); + } + + if (!std::isfinite(low) || !std::isfinite(high) || !(high > low)) + { + return false; + } + + Tab().ViewMin() = low; + Tab().ViewMax() = high; + Tab().ViewInitialized() = true; + // 与“适应视图”保持一致:退出选区缩放模式,避免模式按钮与实际状态不符。 + Tab().ZoomMode() = PlotZoomMode::NONE; + m_rubberRect = gd::RectF(); + BuildLaneVisuals(); + return true; + } + + bool PlotView::SampleSpacingPixels(const DataFile& file, double time, double& pixels) const + { + pixels = 0.0; + + const std::vector& samples = file.time; + if (samples.size() < 2 || m_plotRect.Width <= 1.0f) + { + return false; + } + + const double viewSpan = Tab().ViewMax() - Tab().ViewMin(); + if (!(viewSpan > 0.0)) + { + return false; + } + + const int index = FindNearestSample(file, time); + if (index < 0) + { + return false; + } + + double spacing = 0.0; + if (index > 0) + { + spacing = samples[static_cast(index)] - samples[static_cast(index) - 1]; + } + else + { + spacing = samples[1] - samples[0]; + } + // 时间轴非单调时前后差值可能为负,此时不做吸附。 + if (!(spacing > 0.0) || !std::isfinite(spacing)) + { + return false; + } + + pixels = spacing / viewSpan * static_cast(m_plotRect.Width); + return true; + } + + bool PlotView::SnapTimeToSample(double time, double& snapped) const + { + if (m_dataSet == nullptr) + { + return false; + } + + const double minPixels = static_cast(kCursorSnapMinPixels) * static_cast(m_scale); + + bool found = false; + double bestDistance = std::numeric_limits::infinity(); + double bestTime = 0.0; + + for (const DataFile& file : m_dataSet->Files()) + { + // 只有采样点在屏幕上足够稀疏时才吸附,否则缩小视图后光标会“跳格”。 + double pixels = 0.0; + if (!SampleSpacingPixels(file, time, pixels) || pixels < minPixels) + { + continue; + } + + const int index = FindNearestSample(file, time); + if (index < 0) + { + continue; + } + const double candidate = file.time[static_cast(index)]; + if (!std::isfinite(candidate)) + { + continue; + } + + const double distance = std::fabs(candidate - time); + if (distance < bestDistance) + { + bestDistance = distance; + bestTime = candidate; + found = true; + } + } + + if (found) + { + snapped = bestTime; + } + return found; + } + bool PlotView::ToggleCursor() { if (Tab().CursorEnabled()) @@ -830,7 +962,7 @@ namespace jdi const float height = 22.0f * m_scale; float width = std::max(90.0f * m_scale, - UiMeasureTextWidth(m_fonts->monoSmall, m_editText) + 24.0f * m_scale); + UiMeasureTextWidth(m_fonts->monoSmall, m_edit.Text()) + 24.0f * m_scale); if (m_editTarget == PlotEditTarget::CURSOR_TIME) { @@ -878,8 +1010,11 @@ namespace jdi } m_editTarget = target; m_editLane = laneIndex; - m_editText = initialText; - m_editFresh = true; + + // 数值字段限制为数字与小数点,信号名可输入任意字符(保留括号与空格)。 + const bool nameField = (target == PlotEditTarget::SIGNAL_NAME); + m_edit.Begin(initialText, true, nameField ? EditFilter::ANY : EditFilter::NUMBER, + nameField ? 96u : 18u); return true; } @@ -907,14 +1042,13 @@ namespace jdi const PlotEditTarget target = m_editTarget; const int laneIndex = m_editLane; const SignalRef editSignal = m_editSignal; - const std::wstring text = Trim(m_editText); + const std::wstring text = Trim(m_edit.Text()); m_editTarget = PlotEditTarget::NONE; m_editLane = -1; - m_editFresh = false; m_editSignal = SignalRef(); m_editFieldRect = gd::RectF(); - m_editText.clear(); + m_edit.End(); // 信号改名:交给宿主窗口落到数据层,绘图区只持有只读数据。 if (target == PlotEditTarget::SIGNAL_NAME) @@ -1002,72 +1136,40 @@ namespace jdi return false; } + // 剪贴板与全选快捷键经 WM_CHAR 送达(Ctrl+C/X/V/A 分别对应 3/24/22/1)。 + switch (character) + { + case 0x03: // Ctrl+C + m_edit.CopyToClipboard(m_host); + return false; + case 0x18: // Ctrl+X + return m_edit.CutToClipboard(m_host); + case 0x16: // Ctrl+V + return m_edit.PasteFromClipboard(m_host); + case 0x01: // Ctrl+A + m_edit.SelectAll(); + return true; + default: + break; + } + 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; + return m_edit.Backspace(); } - - // 信号改名接受任意可见字符(保留中文、空格与括号等原始信息)。 - if (m_editTarget == PlotEditTarget::SIGNAL_NAME) + if (character == 0x7F) { - if (character < L' ') - { - return true; - } - if (m_editFresh) - { - m_editText.clear(); - m_editFresh = false; - } - if (m_editText.size() < 96) - { - m_editText.push_back(character); - } - return true; + return m_edit.DeleteForward(); } - - const bool digit = (character >= L'0' && character <= L'9'); - const bool special = (character == L'.' || character == L'-' || character == L'+' || character == L'e' || - character == L'E'); - if (!digit && !special) + if (character < 0x20) { - // 数值输入只接受数字与科学计数法符号,其余字符吞掉。 - return true; + return false; } - - // 首次键入直接替换原有数值,无需先手动删除。 - if (m_editFresh) - { - m_editText.clear(); - m_editFresh = false; - } - - if (m_editText.size() < 16) - { - if (special && character == L'.' && - (m_editText.find(L'.') != std::wstring::npos || - m_editText.find(L'e') != std::wstring::npos || - m_editText.find(L'E') != std::wstring::npos)) - { - return true; - } - if ((character == L'-' || character == L'+') && !m_editText.empty() && - m_editText.back() != L'e' && m_editText.back() != L'E') - { - return true; - } - m_editText.push_back(character); - } - return true; + return m_edit.Insert(character); } bool PlotView::OnEditKey(WPARAM key) @@ -1077,6 +1179,9 @@ namespace jdi return false; } + const bool shift = (GetKeyState(VK_SHIFT) & 0x8000) != 0; + const bool control = (GetKeyState(VK_CONTROL) & 0x8000) != 0; + switch (key) { case VK_RETURN: @@ -1084,10 +1189,21 @@ namespace jdi case VK_ESCAPE: return EndEdit(false); case VK_BACK: - m_editFresh = false; - if (!m_editText.empty()) + return m_edit.Backspace(); + case VK_DELETE: + return m_edit.DeleteForward(); + case VK_LEFT: + return m_edit.MoveCaret(-1, shift); + case VK_RIGHT: + return m_edit.MoveCaret(1, shift); + case VK_HOME: + return m_edit.MoveHome(shift); + case VK_END: + return m_edit.MoveEnd(shift); + case 'A': + if (control) { - m_editText.pop_back(); + m_edit.SelectAll(); } return true; default: @@ -1246,7 +1362,14 @@ namespace jdi } case PlotDragMode::CURSOR_MOVE: { - Tab().CursorTime() = XToTime(fx); + // 放大到采样点可分辨时吸附到最近的数据点,便于精确对比取值。 + double target = XToTime(fx); + double snapped = target; + if (SnapTimeToSample(target, snapped)) + { + target = snapped; + } + Tab().CursorTime() = target; m_dragMoved = true; m_cursorHover = true; return true; @@ -2190,7 +2313,7 @@ namespace jdi if (m_editTarget == PlotEditTarget::SIGNAL_NAME && m_editSignal == ref) { m_editFieldRect = hit.rect; - UiDrawEditField(g, hit.rect, m_editText, m_editFresh, m_fonts->uiSmall, m_scale); + UiDrawEditField(g, hit.rect, m_edit, m_fonts->uiSmall, m_scale); cursorX += itemWidth; ++drawnCount; continue; @@ -2467,24 +2590,8 @@ namespace jdi return; } - // 编辑态:强调色外框 + 整段高亮文本,首次键入即整体替换。 - UiFillRoundRect(g, fieldRect, 3.0f * m_scale, m_theme->cardBg); - UiDrawRoundRect(g, fieldRect, 3.0f * m_scale, m_theme->accent, 1.6f); - - const std::wstring text = m_editText.empty() ? std::wstring(L" ") : m_editText; - if (m_editFresh) - { - const float textWidth = UiTextWidth(g, text, m_fonts->monoSmall) + 8.0f * m_scale; - const gd::RectF highlight(fieldRect.X + (fieldRect.Width - textWidth) * 0.5f, - fieldRect.Y + 2.0f * m_scale, textWidth, - fieldRect.Height - 4.0f * m_scale); - UiFillRoundRect(g, highlight, 2.0f * m_scale, m_theme->accent); - UiText(g, text, m_fonts->monoSmall, highlight, m_theme->textOnAccent, TextAlign::CENTER); - } - else - { - UiText(g, text + L"|", m_fonts->monoSmall, fieldRect, m_theme->textStrong, TextAlign::CENTER); - } + // 编辑态:强调色外框 + 选区高亮 + 插入符,首次键入即整体替换(与常规输入框一致)。 + UiDrawEditField(g, fieldRect, m_edit, m_fonts->monoSmall, m_scale); } void PlotView::PaintSignalInteraction(gd::Graphics& g) diff --git a/Components/SignalPanel/Inc/SignalPanel.hpp b/Components/SignalPanel/Inc/SignalPanel.hpp index bec23ea..64f5bc1 100644 --- a/Components/SignalPanel/Inc/SignalPanel.hpp +++ b/Components/SignalPanel/Inc/SignalPanel.hpp @@ -518,8 +518,7 @@ namespace jdi StyleEditField m_editField = StyleEditField::NONE; /**< 当前处于输入状态的字段。 */ int m_editRow = -1; /**< 行内输入所作用的列表行下标。 */ - bool m_editFresh = false; /**< 输入缓冲是否仍为初始值(首次键入应整体替换)。 */ - std::wstring m_editText; /**< 输入缓冲。 */ + TextEdit m_edit; /**< 输入框编辑模型(光标/选区/剪贴板)。 */ bool m_pendingLaneMenu = false; /**< 左键抬起时是否弹出网格归属菜单。 */ SignalRef m_pendingLaneRef; /**< 待弹出菜单的目标信号。 */ diff --git a/Components/SignalPanel/Src/SignalPanel.cpp b/Components/SignalPanel/Src/SignalPanel.cpp index 4615a0f..4616075 100644 --- a/Components/SignalPanel/Src/SignalPanel.cpp +++ b/Components/SignalPanel/Src/SignalPanel.cpp @@ -115,10 +115,9 @@ namespace jdi m_scrollOffset = 0.0f; m_draggingOpacity = false; m_draggingWidth = false; - m_editField = StyleEditField::NONE; + m_edit.End(); m_editRow = -1; - m_editFresh = false; - m_editText.clear(); + m_editField = StyleEditField::NONE; m_pendingLaneMenu = false; m_pendingColorPicker = false; } @@ -678,7 +677,7 @@ namespace jdi if (m_editField == StyleEditField::FILE_NAME && m_editRow == row) { - UiDrawEditField(g, RowNameRect(row), m_editText, m_editFresh, m_fonts->uiSmall, m_scale); + UiDrawEditField(g, RowNameRect(row), m_edit, m_fonts->uiSmall, m_scale); } else { @@ -759,7 +758,7 @@ namespace jdi const float nameLeft = swatchRect.X + swatchRect.Width + 6.0f * m_scale; if (m_editField == StyleEditField::SIGNAL_NAME && m_editRow == row) { - UiDrawEditField(g, RowNameRect(row), m_editText, m_editFresh, m_fonts->ui, m_scale); + UiDrawEditField(g, RowNameRect(row), m_edit, m_fonts->ui, m_scale); } else { @@ -911,7 +910,7 @@ namespace jdi if (m_editField == StyleEditField::OPACITY) { - UiDrawEditField(g, m_opacityValueRect, m_editText, m_editFresh, m_fonts->monoSmall, m_scale); + UiDrawEditField(g, m_opacityValueRect, m_edit, m_fonts->monoSmall, m_scale); } else { @@ -932,7 +931,7 @@ namespace jdi if (m_editField == StyleEditField::WIDTH) { - UiDrawEditField(g, m_widthValueRect, m_editText, m_editFresh, m_fonts->monoSmall, m_scale); + UiDrawEditField(g, m_widthValueRect, m_edit, m_fonts->monoSmall, m_scale); } else { @@ -1095,8 +1094,12 @@ namespace jdi m_editField = field; m_editRow = row; - m_editText = std::move(initial); - m_editFresh = true; + + // 名称类字段可输入任意字符,数值类字段限制为数字与小数点。 + const bool nameField = + (field == StyleEditField::SIGNAL_NAME) || (field == StyleEditField::FILE_NAME); + m_edit.Begin(std::move(initial), true, nameField ? EditFilter::ANY : EditFilter::NUMBER, + nameField ? 64u : 6u); return true; } @@ -1109,12 +1112,11 @@ namespace jdi const StyleEditField field = m_editField; const int row = m_editRow; - const std::wstring text = m_editText; + const std::wstring text = m_edit.Text(); m_editField = StyleEditField::NONE; m_editRow = -1; - m_editFresh = false; - m_editText.clear(); + m_edit.End(); if (!commit || m_tab == nullptr || m_dataSet == nullptr) { @@ -1210,11 +1212,21 @@ namespace jdi return false; } - // Ctrl+C:把当前文本复制到剪贴板。 - if (character == 0x03) + // 剪贴板与全选快捷键经 WM_CHAR 送达(Ctrl+C/X/V/A 分别对应 3/24/22/1)。 + switch (character) { - CopyTextToClipboard(m_host, m_editText); + case 0x03: // Ctrl+C + m_edit.CopyToClipboard(m_host); return false; + case 0x18: // Ctrl+X + return m_edit.CutToClipboard(m_host); + case 0x16: // Ctrl+V + return m_edit.PasteFromClipboard(m_host); + case 0x01: // Ctrl+A + m_edit.SelectAll(); + return true; + default: + break; } if (character == L'\r' || character == L'\n') @@ -1223,57 +1235,22 @@ namespace jdi } if (character == L'\b') { - m_editFresh = false; - if (!m_editText.empty()) - { - m_editText.pop_back(); - } - return true; + return m_edit.Backspace(); } - - // 名称类字段允许任意可打印字符(含空格、方括号、中文)。 - const bool nameField = (m_editField == StyleEditField::SIGNAL_NAME) || - (m_editField == StyleEditField::FILE_NAME); - if (m_editField == StyleEditField::OPACITY && (character == L'%')) + if (character == 0x7F) { - return true; + return m_edit.DeleteForward(); } - if (character < 0x20 || character == 0x7F) + // 百分比字段允许直接输入 % 但会被忽略,其余控制字符一律丢弃。 + if (m_editField == StyleEditField::OPACITY && character == L'%') { - return true; + return false; } - if (nameField && m_editText.size() >= 64) + if (character < 0x20) { - return true; + return false; } - - 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; + return m_edit.Insert(character); } bool SignalPanel::OnEditKey(WPARAM key) @@ -1283,6 +1260,9 @@ namespace jdi return false; } + const bool shift = (GetKeyState(VK_SHIFT) & 0x8000) != 0; + const bool control = (GetKeyState(VK_CONTROL) & 0x8000) != 0; + switch (key) { case VK_RETURN: @@ -1290,12 +1270,24 @@ namespace jdi case VK_ESCAPE: return EndEdit(false); case VK_BACK: - m_editFresh = false; - if (!m_editText.empty()) + return m_edit.Backspace(); + case VK_DELETE: + return m_edit.DeleteForward(); + case VK_LEFT: + return m_edit.MoveCaret(-1, shift); + case VK_RIGHT: + return m_edit.MoveCaret(1, shift); + case VK_HOME: + return m_edit.MoveHome(shift); + case VK_END: + return m_edit.MoveEnd(shift); + case 'A': + if (control) { - m_editText.pop_back(); + m_edit.SelectAll(); + return true; } - return true; + break; default: break; } diff --git a/Components/TabBar/Inc/TabBar.hpp b/Components/TabBar/Inc/TabBar.hpp index 7d26124..cf9be66 100644 --- a/Components/TabBar/Inc/TabBar.hpp +++ b/Components/TabBar/Inc/TabBar.hpp @@ -176,6 +176,12 @@ namespace jdi */ bool OnMouseLeave(); + /** + * @brief 设置宿主窗口句柄(用于剪贴板所有者)。 + * @param [IN] host 宿主窗口句柄,允许为空。 + */ + void SetHostWindow(HWND host) { m_host = host; } + /** * @brief 判断坐标是否落在标签栏区域内。 * @param [IN] x 客户区横坐标。 @@ -225,8 +231,8 @@ namespace jdi int m_pressedX = 0; /**< 按下时的横坐标(用于判定拖动阈值)。 */ int m_editIndex = -1; /**< 正在重命名的标签页下标,-1 表示未编辑。 */ - std::wstring m_editText; /**< 重命名输入文本。 */ - bool m_editFresh = false; /**< 是否处于“首次输入整体替换”状态。 */ + TextEdit m_edit; /**< 输入框编辑模型(光标/选区/剪贴板)。 */ + HWND m_host = nullptr; /**< 宿主窗口句柄(惰性绑定,用于剪贴板)。 */ int m_lastClickIndex = -1; /**< 上一次单击的标签页下标(用于自行判定双击)。 */ DWORD m_lastClickTick = 0; /**< 上一次单击的时刻。 */ diff --git a/Components/TabBar/Src/TabBar.cpp b/Components/TabBar/Src/TabBar.cpp index 7cf10ec..1452044 100644 --- a/Components/TabBar/Src/TabBar.cpp +++ b/Components/TabBar/Src/TabBar.cpp @@ -143,7 +143,7 @@ namespace jdi const gd::RectF editRect(textLeft - 3.0f * m_scale, tab.rect.Y + 3.0f * m_scale, std::max(40.0f, textWidth + 6.0f * m_scale), tab.rect.Height - 6.0f * m_scale); - UiDrawEditField(g, editRect, m_editText, m_editFresh, m_fonts->ui, m_scale); + UiDrawEditField(g, editRect, m_edit, m_fonts->ui, m_scale); } else { @@ -387,8 +387,7 @@ namespace jdi } m_editIndex = index; - m_editText = m_tabs[static_cast(index)].name; - m_editFresh = true; + m_edit.Begin(m_tabs[static_cast(index)].name, true, EditFilter::ANY, 48); return true; } @@ -398,6 +397,24 @@ namespace jdi { return false; } + + // 剪贴板与全选快捷键经 WM_CHAR 送达(Ctrl+C/X/V/A 分别对应 3/24/22/1)。 + switch (character) + { + case 0x03: // Ctrl+C + m_edit.CopyToClipboard(m_host); + return false; + case 0x18: // Ctrl+X + return m_edit.CutToClipboard(m_host); + case 0x16: // Ctrl+V + return m_edit.PasteFromClipboard(m_host); + case 0x01: // Ctrl+A + m_edit.SelectAll(); + return true; + default: + break; + } + if (character == L'\r' || character == L'\n') { TabBarResult result; @@ -406,28 +423,17 @@ namespace jdi } if (character == L'\b') { - m_editFresh = false; - if (!m_editText.empty()) - { - m_editText.pop_back(); - } - return true; + return m_edit.Backspace(); + } + if (character == 0x7F) + { + return m_edit.DeleteForward(); } if (character < L' ') { - return true; + return false; } - - if (m_editFresh) - { - m_editText.clear(); - m_editFresh = false; - } - if (m_editText.size() < 48) - { - m_editText.push_back(character); - } - return true; + return m_edit.Insert(character); } bool TabBar::OnEditKey(WPARAM key, TabBarResult& result) @@ -439,14 +445,16 @@ namespace jdi } const int index = m_editIndex; + const bool shift = (GetKeyState(VK_SHIFT) & 0x8000) != 0; + const bool control = (GetKeyState(VK_CONTROL) & 0x8000) != 0; + switch (key) { case VK_RETURN: { - const std::wstring text = Trim(m_editText); + const std::wstring text = Trim(m_edit.Text()); m_editIndex = -1; - m_editFresh = false; - m_editText.clear(); + m_edit.End(); if (!text.empty()) { result.type = TabBarResult::Type::RENAME; @@ -457,14 +465,24 @@ namespace jdi } case VK_ESCAPE: m_editIndex = -1; - m_editFresh = false; - m_editText.clear(); + m_edit.End(); return true; case VK_BACK: - m_editFresh = false; - if (!m_editText.empty()) + return m_edit.Backspace(); + case VK_DELETE: + return m_edit.DeleteForward(); + case VK_LEFT: + return m_edit.MoveCaret(-1, shift); + case VK_RIGHT: + return m_edit.MoveCaret(1, shift); + case VK_HOME: + return m_edit.MoveHome(shift); + case VK_END: + return m_edit.MoveEnd(shift); + case 'A': + if (control) { - m_editText.pop_back(); + m_edit.SelectAll(); } return true; default: diff --git a/Utilities/Inc/ColorPickerPopup.hpp b/Utilities/Inc/ColorPickerPopup.hpp index a2e035a..8dd7be7 100644 --- a/Utilities/Inc/ColorPickerPopup.hpp +++ b/Utilities/Inc/ColorPickerPopup.hpp @@ -118,6 +118,12 @@ namespace jdi */ bool IsEditing() const { return m_editField != EditField::NONE; } + /** + * @brief 设置剪贴板所有者窗口句柄(用于输入框的复制/粘贴)。 + * @param [IN] owner 宿主窗口句柄,允许为空。 + */ + void SetOwnerWindow(HWND owner) { m_owner = owner; } + /** * @brief 判断坐标是否落在拾色面板内部。 * @param [IN] x 客户区横坐标。 @@ -191,6 +197,7 @@ namespace jdi const UiFonts* m_fonts = nullptr; /**< 字体资源。 */ const Theme* m_theme = nullptr; /**< 主题资源。 */ + HWND m_owner = nullptr; /**< 剪贴板所有者窗口句柄。 */ float m_scale = 1.0f; /**< DPI 缩放比例。 */ bool m_open = false; /**< 是否处于打开状态。 */ @@ -207,8 +214,7 @@ namespace jdi gd::RectF m_hexRect; /**< 十六进制文本矩形。 */ EditField m_editField = EditField::NONE; /**< 当前处于输入状态的字段。 */ - bool m_editFresh = false; /**< 输入缓冲是否仍为初始值(首次键入应整体替换)。 */ - std::wstring m_editText; /**< 输入缓冲。 */ + TextEdit m_edit; /**< 输入框编辑模型(光标/选区/剪贴板)。 */ }; } // namespace jdi diff --git a/Utilities/Inc/PaintUtil.hpp b/Utilities/Inc/PaintUtil.hpp index 6bdb323..7573cf6 100644 --- a/Utilities/Inc/PaintUtil.hpp +++ b/Utilities/Inc/PaintUtil.hpp @@ -8,6 +8,7 @@ #include #include +#include "TextEdit.hpp" #include "Theme.hpp" namespace jdi @@ -211,20 +212,20 @@ namespace jdi bool active, float scale); /** - * @brief 绘制内联数值输入框。 + * @brief 绘制单行内联输入框(含选区高亮、插入符与水平滚动)。 * - * 处于“首次输入整体替换”状态时以高亮底衬表示整段内容将被覆盖, - * 交互观感与常见登录界面的账号/密码输入框一致;已在键入时显示插入符。 + * 初始状态为“整体选中”时以强调色底衬显示全部内容,首字符键入即整体替换, + * 交互观感与常见登录界面的账号/密码输入框一致;文本超出框宽时自动横向滚动, + * 保证插入符始终可见。 * * @param [IN,OUT] g 绘图上下文。 * @param [IN] rect 输入框矩形。 - * @param [IN] text 当前文本(可为空)。 - * @param [IN] fresh 是否仍处于“首次输入整体替换”状态。 + * @param [IN] edit 输入框编辑模型。 * @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); + void UiDrawEditField(gd::Graphics& g, const gd::RectF& rect, const TextEdit& edit, HFONT font, + float scale); /** * @brief 绘制一个带边框的复选框。 diff --git a/Utilities/Inc/TextEdit.hpp b/Utilities/Inc/TextEdit.hpp new file mode 100644 index 0000000..07027ef --- /dev/null +++ b/Utilities/Inc/TextEdit.hpp @@ -0,0 +1,210 @@ +#ifndef __TEXT_EDIT_HPP__ +#define __TEXT_EDIT_HPP__ + +#include + +#include +#include + +namespace jdi +{ + + /** + * @brief 输入框的字符过滤策略。 + */ + enum class EditFilter : int + { + ANY = 0, /**< 接受任意可打印字符(信号名、分组名、视图名)。 */ + NUMBER = 1, /**< 仅接受数字与科学计数法符号(-+.eE)。 */ + HEX = 2, /**< 仅接受 0-9a-fA-F 与 #(色号)。 */ + BYTE = 3 /**< 仅接受整数数字(0-255 分量)。 */ + }; + + /** + * @brief 单行内联输入框的编辑模型。 + * + * 提供与常规输入框一致的行为:光标移动、Shift 选区、全选、 + * 退格/删除清除选区、Ctrl+A/C/X/V、粘贴清洗,以及"首次键入整体替换"。 + * 渲染由 `PaintUtil::UiDrawEditField` 消费本对象完成(含水平滚动)。 + */ + class TextEdit + { + public: + /** + * @brief 进入编辑状态。 + * @param [IN] text 初始文本。 + * @param [IN] selectAll 是否将初始文本整体选中(首次键入即整体替换)。 + * @param [IN] filter 字符过滤策略。 + * @param [IN] maxLength 允许的最大字符数(0 表示不限)。 + */ + void Begin(const std::wstring& text, bool selectAll, EditFilter filter, std::size_t maxLength); + + /** + * @brief 退出编辑状态并清空内容。 + */ + void End(); + + /** + * @brief 判断是否处于编辑状态。 + * @return 处于编辑状态返回 true。 + */ + bool Active() const { return m_active; } + + /** + * @brief 获取当前文本。 + * @return 文本常量引用。 + */ + const std::wstring& Text() const { return m_text; } + + /** + * @brief 获取光标位置(插入符所在偏移)。 + * @return 字符偏移。 + */ + std::size_t Caret() const { return m_caret; } + + /** + * @brief 获取选区锚点位置。 + * @return 字符偏移。 + */ + std::size_t Anchor() const { return m_anchor; } + + /** + * @brief 判断是否存在选区。 + * @return 存在非空选区返回 true。 + */ + bool HasSelection() const { return m_caret != m_anchor; } + + /** + * @brief 获取选区起始偏移。 + * @return 较小的偏移值。 + */ + std::size_t SelectionBegin() const { return (m_caret < m_anchor) ? m_caret : m_anchor; } + + /** + * @brief 获取选区结束偏移。 + * @return 较大的偏移值。 + */ + std::size_t SelectionEnd() const { return (m_caret < m_anchor) ? m_anchor : m_caret; } + + /** + * @brief 判断是否处于"首次输入整体替换"状态。 + * @return 全部文本处于选中状态返回 true。 + */ + bool Fresh() const { return m_fresh; } + + /** + * @brief 在光标处插入一个字符(存在选区时先替换选区)。 + * @param [IN] character 待插入字符。 + * @return 内容发生变化返回 true。 + */ + bool Insert(wchar_t character); + + /** + * @brief 退格:存在选区时删除选区,否则删除光标前一个字符。 + * @return 内容发生变化返回 true。 + */ + bool Backspace(); + + /** + * @brief 删除:存在选区时删除选区,否则删除光标后一个字符。 + * @return 内容发生变化返回 true。 + */ + bool DeleteForward(); + + /** + * @brief 移动光标。 + * @param [IN] delta 位移量(负值向左)。 + * @param [IN] extendSelection 是否按住 Shift 扩展选区。 + * @return 光标或选区发生变化返回 true。 + */ + bool MoveCaret(int delta, bool extendSelection); + + /** + * @brief 移动光标到行首。 + * @param [IN] extendSelection 是否按住 Shift 扩展选区。 + * @return 光标或选区发生变化返回 true。 + */ + bool MoveHome(bool extendSelection); + + /** + * @brief 移动光标到行尾。 + * @param [IN] extendSelection 是否按住 Shift 扩展选区。 + * @return 光标或选区发生变化返回 true。 + */ + bool MoveEnd(bool extendSelection); + + /** + * @brief 全选当前内容。 + */ + void SelectAll(); + + /** + * @brief 复制当前内容到剪贴板(存在选区时只复制选区)。 + * @param [IN] owner 剪贴板所有者窗口句柄,允许为空。 + * @return 复制成功返回 true。 + */ + bool CopyToClipboard(HWND owner) const; + + /** + * @brief 剪切当前内容到剪贴板(存在选区时只剪切选区)。 + * @param [IN] owner 剪贴板所有者窗口句柄,允许为空。 + * @return 内容发生变化返回 true。 + */ + bool CutToClipboard(HWND owner); + + /** + * @brief 从剪贴板粘贴(替换当前选区)。 + * @param [IN] owner 剪贴板所有者窗口句柄,允许为空。 + * @return 内容发生变化返回 true。 + */ + bool PasteFromClipboard(HWND owner); + + /** + * @brief 判断某字符是否符合指定过滤策略。 + * @param [IN] filter 过滤策略。 + * @param [IN] character 待检验字符。 + * @return 允许输入返回 true。 + */ + static bool AcceptsCharacter(EditFilter filter, wchar_t character); + + private: + /** + * @brief 用给定文本替换当前选区并更新光标。 + * @param [IN] replacement 替换文本。 + * @return 内容发生变化返回 true。 + */ + bool ReplaceSelection(const std::wstring& replacement); + + /** + * @brief 把偏移量夹到合法范围并对齐到字符边界。 + * @param [IN] offset 原始偏移。 + * @return 合法偏移。 + */ + std::size_t ClampOffset(std::size_t offset) const; + + /** + * @brief 向左移动一个字符(避免落在代理对中间)。 + * @param [IN] offset 起始偏移。 + * @return 新偏移。 + */ + std::size_t StepLeft(std::size_t offset) const; + + /** + * @brief 向右移动一个字符(避免落在代理对中间)。 + * @param [IN] offset 起始偏移。 + * @return 新偏移。 + */ + std::size_t StepRight(std::size_t offset) const; + + bool m_active = false; /**< 是否处于编辑状态。 */ + bool m_fresh = false; /**< 是否处于“首次输入整体替换”状态。 */ + std::wstring m_text; /**< 当前文本。 */ + std::size_t m_caret = 0; /**< 光标位置。 */ + std::size_t m_anchor = 0; /**< 选区锚点。 */ + EditFilter m_filter = EditFilter::ANY; /**< 字符过滤策略。 */ + std::size_t m_maxLength = 0; /**< 最大字符数,0 表示不限。 */ + }; + +} // namespace jdi + +#endif // __TEXT_EDIT_HPP__ diff --git a/Utilities/Inc/Utf8Util.hpp b/Utilities/Inc/Utf8Util.hpp index 261653a..d39fb25 100644 --- a/Utilities/Inc/Utf8Util.hpp +++ b/Utilities/Inc/Utf8Util.hpp @@ -126,6 +126,14 @@ namespace jdi */ bool CopyTextToClipboard(HWND owner, const std::wstring& text); + /** + * @brief 从系统剪贴板读取文本。 + * @param [IN] owner 剪贴板所有者窗口句柄,允许为空。 + * @param [OUT] text 读出的文本,剪贴板无文本时清空。 + * @return 读到非空文本返回 true。 + */ + bool PasteTextFromClipboard(HWND owner, std::wstring& text); + } // namespace jdi #endif // __UTF8_UTIL_HPP__ diff --git a/Utilities/Src/ColorPickerPopup.cpp b/Utilities/Src/ColorPickerPopup.cpp index c499c54..88e3fef 100644 --- a/Utilities/Src/ColorPickerPopup.cpp +++ b/Utilities/Src/ColorPickerPopup.cpp @@ -213,7 +213,8 @@ namespace jdi case EditField::B: { const int index = static_cast(field) - 1; - m_editText = std::to_wstring(ToByte(m_channels[static_cast(index)])); + m_edit.Begin(std::to_wstring(ToByte(m_channels[static_cast(index)])), true, + EditFilter::BYTE, 3); break; } case EditField::HEX: @@ -222,7 +223,7 @@ namespace jdi 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); + m_edit.Begin(std::wstring(buffer), true, EditFilter::HEX, 7); break; } default: @@ -231,7 +232,6 @@ namespace jdi m_draggingSlider = -1; m_editField = field; - m_editFresh = true; return true; } @@ -243,10 +243,9 @@ namespace jdi } const EditField field = m_editField; - const std::wstring text = m_editText; + const std::wstring text = m_edit.Text(); m_editField = EditField::NONE; - m_editFresh = false; - m_editText.clear(); + m_edit.End(); if (!commit) { @@ -336,11 +335,21 @@ namespace jdi return false; } - // Ctrl+C:把当前文本复制到剪贴板。 - if (character == 0x03) + // 剪贴板与全选快捷键经 WM_CHAR 送达(Ctrl+C/X/V/A 分别对应 3/24/22/1)。 + switch (character) { - CopyTextToClipboard(nullptr, m_editText); + case 0x03: // Ctrl+C + m_edit.CopyToClipboard(m_owner); return false; + case 0x18: // Ctrl+X + return m_edit.CutToClipboard(m_owner); + case 0x16: // Ctrl+V + return m_edit.PasteFromClipboard(m_owner); + case 0x01: // Ctrl+A + m_edit.SelectAll(); + return true; + default: + break; } if (character == L'\r' || character == L'\n') @@ -349,54 +358,17 @@ namespace jdi } if (character == L'\b') { - m_editFresh = false; - if (!m_editText.empty()) - { - m_editText.pop_back(); - } - return true; + return m_edit.Backspace(); } - if (character < 0x20 || character == 0x7F) + if (character == 0x7F) { - return true; + return m_edit.DeleteForward(); } - - if (m_editField == EditField::HEX) + if (character < 0x20) { - 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; + return false; } - - 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; + return m_edit.Insert(character); } bool ColorPickerPopup::ContainsPoint(int x, int y) const @@ -548,6 +520,9 @@ namespace jdi } if (m_editField != EditField::NONE) { + const bool shift = (GetKeyState(VK_SHIFT) & 0x8000) != 0; + const bool control = (GetKeyState(VK_CONTROL) & 0x8000) != 0; + switch (key) { case VK_RETURN: @@ -557,10 +532,27 @@ namespace jdi EndEdit(false); return true; case VK_BACK: - m_editFresh = false; - if (!m_editText.empty()) + m_edit.Backspace(); + return true; + case VK_DELETE: + m_edit.DeleteForward(); + return true; + case VK_LEFT: + m_edit.MoveCaret(-1, shift); + return true; + case VK_RIGHT: + m_edit.MoveCaret(1, shift); + return true; + case VK_HOME: + m_edit.MoveHome(shift); + return true; + case VK_END: + m_edit.MoveEnd(shift); + return true; + case 'A': + if (control) { - m_editText.pop_back(); + m_edit.SelectAll(); } return true; default: @@ -609,7 +601,7 @@ namespace jdi if (m_editField == static_cast(index + 1)) { - UiDrawEditField(g, m_values[slot], m_editText, m_editFresh, m_fonts->monoSmall, m_scale); + UiDrawEditField(g, m_values[slot], m_edit, m_fonts->monoSmall, m_scale); } else { @@ -625,7 +617,7 @@ namespace jdi if (m_editField == EditField::HEX) { - UiDrawEditField(g, m_hexRect, m_editText, m_editFresh, m_fonts->monoSmall, m_scale); + UiDrawEditField(g, m_hexRect, m_edit, m_fonts->monoSmall, m_scale); } else { diff --git a/Utilities/Src/DataModel.cpp b/Utilities/Src/DataModel.cpp index a723f7e..89ca2b7 100644 --- a/Utilities/Src/DataModel.cpp +++ b/Utilities/Src/DataModel.cpp @@ -210,7 +210,9 @@ namespace jdi [](const Lane& lane) { return lane.signals.empty(); }), lanes.end()); - // 未分配网格的可见信号统一追加到第一个网格。 + // 未分配网格的可见信号各自独占一个新网格。 + // 注意:不能把它们塞进已有的第一个网格——那会导致“拖拽合并后新勾选的信号 + // 被悄悄并入合并网格”,用户看不到新增的独立泳道。 for (const SignalRef& ref : visible) { bool placed = false; @@ -227,13 +229,10 @@ namespace jdi continue; } - if (lanes.empty()) - { - Lane lane; - lane.autoRange = true; - lanes.push_back(std::move(lane)); - } - lanes[0].signals.push_back(ref); + Lane lane; + lane.autoRange = true; + lane.signals.push_back(ref); + lanes.push_back(std::move(lane)); } } @@ -809,17 +808,11 @@ namespace jdi } style->visible = visible; - // 合并模式下新勾选的信号独占一个新网格:避免「先合并已有信号、再加入新信号」 - // 时新信号被悄悄塞进那个唯一的合并网格里,导致用户看不到新增的独立泳道。 + // 合并模式下新勾选的信号独占一个新网格:切到自定义划分后, + // RebuildLanes 会把尚未分配网格的可见信号各自放入新网格。 if (visible && m_laneMode == LaneMode::MERGED) { m_laneMode = LaneMode::CUSTOM; - Lane lane; - lane.autoRange = true; - lane.signals.push_back(ref); - m_lanes.push_back(std::move(lane)); - SyncLanes(dataSet); - return; } SyncLanes(dataSet); @@ -978,6 +971,31 @@ namespace jdi return -1; } + const int sourceLane = LaneOfSignal(ref); + if (laneIndex >= 0 && laneIndex == sourceLane) + { + // 拖回原网格:无需变动,避免因网格回收导致顺序抖动。 + return sourceLane; + } + + // 先把目标网格的“锚信号”固定下来:移除信号后空网格会被回收, + // 下标随之变化,必须用不依赖下标的引用找回真正的目标网格 + // (否则会变成“与原网格交换位置”而不是合并进去)。 + SignalRef targetAnchor; + bool hasTarget = (laneIndex >= 0 && laneIndex < static_cast(m_lanes.size())); + if (hasTarget) + { + const std::vector& targetSignals = m_lanes[static_cast(laneIndex)].signals; + for (const SignalRef& candidate : targetSignals) + { + if (!(candidate == ref)) + { + targetAnchor = candidate; + break; + } + } + } + // 手动指定归属意味着切换到自定义划分策略。 m_laneMode = LaneMode::CUSTOM; @@ -990,17 +1008,31 @@ namespace jdi [](const Lane& lane) { return lane.signals.empty(); }), m_lanes.end()); - if (laneIndex < 0 || laneIndex >= static_cast(m_lanes.size())) + int destination = -1; + if (hasTarget && targetAnchor.IsValid()) + { + for (std::size_t index = 0; index < m_lanes.size(); ++index) + { + const std::vector& refs = m_lanes[index].signals; + if (std::find(refs.begin(), refs.end(), targetAnchor) != refs.end()) + { + destination = static_cast(index); + break; + } + } + } + + if (destination >= 0) + { + m_lanes[static_cast(destination)].signals.push_back(ref); + } + else { Lane lane; lane.autoRange = true; lane.signals.push_back(ref); m_lanes.push_back(std::move(lane)); } - else - { - m_lanes[static_cast(laneIndex)].signals.push_back(ref); - } for (std::size_t index = 0; index < m_lanes.size(); ++index) { diff --git a/Utilities/Src/PaintUtil.cpp b/Utilities/Src/PaintUtil.cpp index a10b11a..aaa9135 100644 --- a/Utilities/Src/PaintUtil.cpp +++ b/Utilities/Src/PaintUtil.cpp @@ -428,8 +428,7 @@ namespace jdi knobRadius * 2.0f); } - void UiDrawEditField(gd::Graphics& g, const gd::RectF& rect, const std::wstring& text, bool fresh, - HFONT font, float scale) + void UiDrawEditField(gd::Graphics& g, const gd::RectF& rect, const TextEdit& edit, HFONT font, float scale) { if (rect.Width <= 0.0f || rect.Height <= 0.0f || font == nullptr) { @@ -442,29 +441,79 @@ namespace jdi 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 std::wstring& text = edit.Text(); + if (text.empty()) { - // 整段内容处于“将被覆盖”状态:以强调色底衬表示已全选。 - 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); + // 空内容仍绘制插入符,保持焦点可见。 + const float caretX = rect.X + 6.0f * scale; + UiDrawLine(g, caretX, rect.Y + 4.0f * scale, caretX, rect.Y + rect.Height - 4.0f * scale, + theme.accent, 1.4f * scale); return; } - UiText(g, shown, font, rect, theme.textStrong, TextAlign::CENTER); + const float innerLeft = rect.X + 6.0f * scale; + const float innerWidth = std::max(4.0f, rect.Width - 12.0f * scale); - // 已开始键入:在文本末尾显示插入符。 - 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) + // 光标与选区端点对应的像素位置(以文本左端为原点)。 + const float caretX = UiTextWidth(g, text.substr(0, edit.Caret()), font); + const float selBeginX = + edit.HasSelection() ? UiTextWidth(g, text.substr(0, edit.SelectionBegin()), font) : 0.0f; + const float selEndX = + edit.HasSelection() ? UiTextWidth(g, text.substr(0, edit.SelectionEnd()), font) : 0.0f; + const float textWidth = UiTextWidth(g, text, font); + + // 内容超出可视宽度时横向滚动,保证光标始终可见(向常规输入框靠齐)。 + float scroll = 0.0f; + if (textWidth > innerWidth) { - UiDrawLine(g, caretX, rect.Y + 4.0f * scale, caretX, rect.Y + rect.Height - 4.0f * scale, - theme.accent, 1.4f * scale); + if (caretX > innerWidth) + { + scroll = caretX - innerWidth; + } + const float maximum = textWidth - innerWidth; + scroll = std::max(0.0f, std::min(scroll, maximum)); } + + const gd::RectF clipRect(innerLeft, rect.Y + 1.0f * scale, innerWidth, rect.Height - 2.0f * scale); + const int saveState = g.Save(); + g.SetClip(clipRect, gd::CombineModeIntersect); + + const float textLeft = innerLeft - scroll; + + // 选区高亮绘制在文本之下,整段选中时呈现“已全选”观感。 + const bool fullSelection = edit.HasSelection() && edit.SelectionBegin() == 0 && + edit.SelectionEnd() == text.size(); + if (edit.HasSelection() && selEndX > selBeginX) + { + const gd::RectF highlight(textLeft + selBeginX, rect.Y + 3.0f * scale, + selEndX - selBeginX, rect.Height - 6.0f * scale); + UiFillRect(g, highlight, theme.accent); + } + + UiText(g, text, font, gd::RectF(textLeft, rect.Y, std::max(textWidth, innerWidth), rect.Height), + theme.textStrong, TextAlign::LEFT); + + if (edit.HasSelection() && !fullSelection && selEndX > selBeginX) + { + // 局部选区:在反白底衬上重绘被选中的片段,其余文字保持常规颜色。 + const std::wstring selected = + text.substr(edit.SelectionBegin(), edit.SelectionEnd() - edit.SelectionBegin()); + UiText(g, selected, font, + gd::RectF(textLeft + selBeginX, rect.Y, selEndX - selBeginX, rect.Height), + theme.textOnAccent, TextAlign::LEFT); + } + else if (fullSelection) + { + UiText(g, text, font, gd::RectF(textLeft, rect.Y, std::max(textWidth, innerWidth), rect.Height), + theme.textOnAccent, TextAlign::LEFT); + } + + // 插入符。 + const float caretDrawX = textLeft + caretX; + UiDrawLine(g, caretDrawX, rect.Y + 4.0f * scale, caretDrawX, rect.Y + rect.Height - 4.0f * scale, + theme.textStrong, 1.4f * scale); + + g.Restore(saveState); } bool UiSwatchNeedsOutline(float opacity) diff --git a/Utilities/Src/TextEdit.cpp b/Utilities/Src/TextEdit.cpp new file mode 100644 index 0000000..a4c4062 --- /dev/null +++ b/Utilities/Src/TextEdit.cpp @@ -0,0 +1,340 @@ +#include "TextEdit.hpp" + +#include + +#include "Utf8Util.hpp" + +namespace jdi +{ + + namespace + { + /** @brief 判断字符是否为 UTF-16 高位代理。 */ + bool IsHighSurrogate(wchar_t c) + { + return c >= 0xD800 && c <= 0xDBFF; + } + + /** @brief 判断字符是否为 UTF-16 低位代理。 */ + bool IsLowSurrogate(wchar_t c) + { + return c >= 0xDC00 && c <= 0xDFFF; + } + + /** @brief 判断字符是否符合数值输入(数字与科学计数法符号)。 */ + bool IsNumberChar(wchar_t c) + { + return (c >= L'0' && c <= L'9') || c == L'.' || c == L'-' || c == L'+' || c == L'e' || c == L'E'; + } + + /** @brief 判断字符是否为十六进制数字。 */ + bool IsHexDigit(wchar_t c) + { + return (c >= L'0' && c <= L'9') || (c >= L'a' && c <= L'f') || (c >= L'A' && c <= L'F'); + } + + /** @brief 判断字符是否为十进制数字。 */ + bool IsDigit(wchar_t c) + { + return c >= L'0' && c <= L'9'; + } + } + + bool TextEdit::AcceptsCharacter(EditFilter filter, wchar_t character) + { + switch (filter) + { + case EditFilter::NUMBER: + return IsNumberChar(character); + case EditFilter::HEX: + return IsHexDigit(character) || character == L'#'; + case EditFilter::BYTE: + return IsDigit(character); + case EditFilter::ANY: + default: + return character >= L' ' && character != 0x7F; + } + } + + void TextEdit::Begin(const std::wstring& text, bool selectAll, EditFilter filter, std::size_t maxLength) + { + m_active = true; + m_text = text; + m_filter = filter; + m_maxLength = maxLength; + m_caret = m_text.size(); + m_anchor = selectAll ? 0 : m_text.size(); + m_fresh = selectAll && !m_text.empty(); + } + + void TextEdit::End() + { + m_active = false; + m_fresh = false; + m_text.clear(); + m_caret = 0; + m_anchor = 0; + m_filter = EditFilter::ANY; + m_maxLength = 0; + } + + std::size_t TextEdit::ClampOffset(std::size_t offset) const + { + if (offset > m_text.size()) + { + offset = m_text.size(); + } + // 避免光标落在代理对中间(否则渲染与删除都会错位)。 + if (offset > 0 && offset < m_text.size() && IsLowSurrogate(m_text[offset]) && + IsHighSurrogate(m_text[offset - 1])) + { + --offset; + } + return offset; + } + + std::size_t TextEdit::StepLeft(std::size_t offset) const + { + if (offset == 0) + { + return 0; + } + --offset; + if (offset > 0 && IsLowSurrogate(m_text[offset]) && IsHighSurrogate(m_text[offset - 1])) + { + --offset; + } + return offset; + } + + std::size_t TextEdit::StepRight(std::size_t offset) const + { + if (offset >= m_text.size()) + { + return m_text.size(); + } + ++offset; + if (offset < m_text.size() && IsLowSurrogate(m_text[offset]) && IsHighSurrogate(m_text[offset - 1])) + { + ++offset; + } + return offset; + } + + bool TextEdit::ReplaceSelection(const std::wstring& replacement) + { + const bool hadSelection = HasSelection(); + const std::size_t begin = SelectionBegin(); + const std::size_t end = SelectionEnd(); + + std::wstring merged = m_text.substr(0, begin) + replacement + m_text.substr(end); + if (m_maxLength > 0 && merged.size() > m_maxLength) + { + merged.resize(m_maxLength); + } + + const bool changed = (merged != m_text) || hadSelection; + m_text = std::move(merged); + m_caret = ClampOffset(begin + replacement.size()); + m_anchor = m_caret; + m_fresh = false; + return changed; + } + + bool TextEdit::Insert(wchar_t character) + { + if (!m_active || character == 0) + { + return false; + } + if (!AcceptsCharacter(m_filter, character)) + { + return true; + } + return ReplaceSelection(std::wstring(1, character)); + } + + bool TextEdit::Backspace() + { + if (!m_active) + { + return false; + } + if (HasSelection()) + { + // 存在选区时退格清除整个选区。 + return ReplaceSelection(std::wstring()); + } + if (m_caret == 0) + { + m_fresh = false; + return false; + } + + const std::size_t previous = StepLeft(m_caret); + const std::size_t removed = m_caret - previous; + m_text.erase(previous, removed); + m_caret = previous; + m_anchor = previous; + m_fresh = false; + return true; + } + + bool TextEdit::DeleteForward() + { + if (!m_active) + { + return false; + } + if (HasSelection()) + { + // 存在选区时删除键清除整个选区。 + return ReplaceSelection(std::wstring()); + } + if (m_caret >= m_text.size()) + { + m_fresh = false; + return false; + } + + const std::size_t next = StepRight(m_caret); + m_text.erase(m_caret, next - m_caret); + m_anchor = m_caret; + m_fresh = false; + return true; + } + + bool TextEdit::MoveCaret(int delta, bool extendSelection) + { + if (!m_active || delta == 0) + { + return false; + } + + std::size_t offset = m_caret; + const int steps = (delta > 0) ? delta : -delta; + for (int index = 0; index < steps; ++index) + { + offset = (delta > 0) ? StepRight(offset) : StepLeft(offset); + } + offset = ClampOffset(offset); + + if (offset == m_caret) + { + return false; + } + + m_caret = offset; + if (!extendSelection) + { + m_anchor = offset; + m_fresh = false; + } + else + { + m_fresh = false; + } + return true; + } + + bool TextEdit::MoveHome(bool extendSelection) + { + if (!m_active || m_caret == 0) + { + return false; + } + m_caret = 0; + if (!extendSelection) + { + m_anchor = 0; + } + m_fresh = false; + return true; + } + + bool TextEdit::MoveEnd(bool extendSelection) + { + if (!m_active || m_caret == m_text.size()) + { + return false; + } + m_caret = m_text.size(); + if (!extendSelection) + { + m_anchor = m_caret; + } + m_fresh = false; + return true; + } + + void TextEdit::SelectAll() + { + m_anchor = 0; + m_caret = m_text.size(); + m_fresh = !m_text.empty(); + } + + bool TextEdit::CopyToClipboard(HWND owner) const + { + if (m_text.empty()) + { + return false; + } + const std::wstring payload = + HasSelection() ? m_text.substr(SelectionBegin(), SelectionEnd() - SelectionBegin()) : m_text; + return CopyTextToClipboard(owner, payload); + } + + bool TextEdit::CutToClipboard(HWND owner) + { + if (m_text.empty()) + { + return false; + } + const bool copied = CopyToClipboard(owner); + if (!copied) + { + return false; + } + if (HasSelection()) + { + return ReplaceSelection(std::wstring()); + } + m_text.clear(); + m_caret = 0; + m_anchor = 0; + m_fresh = false; + return true; + } + + bool TextEdit::PasteFromClipboard(HWND owner) + { + if (!m_active) + { + return false; + } + + std::wstring pasted; + if (!PasteTextFromClipboard(owner, pasted)) + { + return false; + } + + // 按当前过滤策略清洗粘贴内容,避免数值框被灌入非法字符。 + std::wstring cleaned; + cleaned.reserve(pasted.size()); + for (const wchar_t c : pasted) + { + if (AcceptsCharacter(m_filter, c)) + { + cleaned.push_back(c); + } + } + if (cleaned.empty()) + { + return false; + } + return ReplaceSelection(cleaned); + } + +} // namespace jdi diff --git a/Utilities/Src/Utf8Util.cpp b/Utilities/Src/Utf8Util.cpp index 10f5f39..fc1e370 100644 --- a/Utilities/Src/Utf8Util.cpp +++ b/Utilities/Src/Utf8Util.cpp @@ -411,4 +411,45 @@ namespace jdi return success; } + bool PasteTextFromClipboard(HWND owner, std::wstring& text) + { + text.clear(); + + if (OpenClipboard(owner) == FALSE) + { + return false; + } + + bool success = false; + if (IsClipboardFormatAvailable(CF_UNICODETEXT) != FALSE) + { + HANDLE handle = GetClipboardData(CF_UNICODETEXT); + if (handle != nullptr) + { + const void* source = GlobalLock(handle); + if (source != nullptr) + { + const wchar_t* wide = static_cast(source); + text.assign(wide); + GlobalUnlock(handle); + success = !text.empty(); + } + } + } + CloseClipboard(); + + // 单行输入框:把换行与制表符折叠为空格,避免内容被截断显示。 + if (success) + { + for (wchar_t& c : text) + { + if (c == L'\r' || c == L'\n' || c == L'\t') + { + c = L' '; + } + } + } + return success; + } + } // namespace jdi