diff --git a/.gitignore b/.gitignore index 7ce5eec..7e8c10e 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,6 @@ Build/ *.lst *.map *.exe + +# 测试数据(仅本地保留,不纳入版本控制) +TestData/ diff --git a/Components/Application/Inc/Application.hpp b/Components/Application/Inc/Application.hpp index 5600730..16a39fd 100644 --- a/Components/Application/Inc/Application.hpp +++ b/Components/Application/Inc/Application.hpp @@ -65,7 +65,8 @@ namespace jdi TOOLBAR, /**< 工具栏。 */ PANEL, /**< 信号面板。 */ PLOT, /**< 波形绘图区。 */ - SPLITTER /**< 面板分隔条。 */ + SPLITTER, /**< 面板分隔条。 */ + EDIT_FIELD /**< 内联输入框(拖拽框选选区)。 */ }; Application() = default; diff --git a/Components/Application/Src/Application.cpp b/Components/Application/Src/Application.cpp index f19d573..bba66bb 100644 --- a/Components/Application/Src/Application.cpp +++ b/Components/Application/Src/Application.cpp @@ -344,7 +344,8 @@ namespace jdi Relayout(); // 切换标签页后把该页已选中的信号滚动到可见位置。 - m_panel.ScrollToSignal(m_tabs[static_cast(m_activeTab)].Selected()); + // 切换标签页后把共享的选中信号滚动到可见位置(选中状态跨页保持一致)。 + m_panel.ScrollToSignal(m_dataSet.Selected()); SynchronizeView(true); UpdateWindowTitle(); @@ -418,6 +419,9 @@ namespace jdi { tab.SyncToDataSet(m_dataSet); } + + // 选中状态由数据层共享维护,数据变动后需校验其是否仍指向存在的信号。 + m_dataSet.ValidateSelection(); m_panel.InvalidateRows(); // 强制下一次同步重新计算视图状态。 @@ -440,11 +444,12 @@ namespace jdi } const std::wstring name = file->fileName; - // 先按旧下标修正各标签页配置,再从数据层移除,最后统一对齐。 + // 先按旧下标修正各标签页配置与共享选中项,再从数据层移除,最后统一对齐。 for (TabConfig& tab : m_tabs) { tab.RemapAfterFileRemoval(fileIndex); } + m_dataSet.RemapSelectionAfterFileRemoval(fileIndex); m_dataSet.RemoveFile(fileIndex); SyncTabsToDataSet(); @@ -921,6 +926,15 @@ namespace jdi return m_toolbar.OnMouseMove(x, y); case Target::TAB_BAR: return m_tabBar.OnMouseMove(x, y); + case Target::EDIT_FIELD: + { + // 输入框拖拽框选:按当前正在编辑的组件分发。 + bool changed = m_tabBar.OnEditMouseMove(x); + changed = m_panel.OnEditMouseMove(x) || changed; + changed = m_plot.OnEditMouseMove(x) || changed; + changed = m_colorPicker.OnEditMouseMove(x) || changed; + return changed; + } case Target::NONE: default: return false; @@ -1107,6 +1121,21 @@ namespace jdi const bool isAuto = m_plot.LaneRangeIsAuto(laneIndex); + // 当前网格的 Y 轴上下限值直接在菜单项中显示,单击即可直接改写。 + double laneYMin = 0.0; + double laneYMax = 1.0; + m_plot.LaneRange(laneIndex, laneYMin, laneYMax); + + MenuItem maxItem; + maxItem.label = L"Y 轴最大值:" + FormatValue(laneYMax); + maxItem.id = PLOT_CMD_LANE_Y_MAX; + items.push_back(maxItem); + + MenuItem minItem; + minItem.label = L"Y 轴最小值:" + FormatValue(laneYMin); + minItem.id = PLOT_CMD_LANE_Y_MIN; + items.push_back(minItem); + MenuItem autoItem; autoItem.label = isAuto ? L"Y 轴自适应(当前)" : L"恢复 Y 轴自适应"; autoItem.id = PLOT_CMD_LANE_AUTO; @@ -1114,16 +1143,6 @@ namespace jdi autoItem.enabled = !isAuto; items.push_back(autoItem); - MenuItem maxItem; - maxItem.label = L"设置 Y 轴最大值…"; - maxItem.id = PLOT_CMD_LANE_Y_MAX; - items.push_back(maxItem); - - MenuItem minItem; - minItem.label = L"设置 Y 轴最小值…"; - minItem.id = PLOT_CMD_LANE_Y_MIN; - items.push_back(minItem); - MenuItem separator; separator.kind = MenuItemKind::SEPARATOR; items.push_back(separator); @@ -1175,10 +1194,10 @@ namespace jdi m_plot.ResetLaneRange(laneIndex); break; case PLOT_CMD_LANE_Y_MAX: - m_plot.BeginLaneValueEdit(laneIndex, true); + m_plot.BeginLaneValueEdit(laneIndex, true, result.itemRect); break; case PLOT_CMD_LANE_Y_MIN: - m_plot.BeginLaneValueEdit(laneIndex, false); + m_plot.BeginLaneValueEdit(laneIndex, false, result.itemRect); break; case PLOT_CMD_LANE_FIT_TIME: m_plot.FitTimeToLane(laneIndex); @@ -1475,6 +1494,16 @@ namespace jdi const int x = GET_X_LPARAM(lParam); const int y = GET_Y_LPARAM(lParam); + // 编辑态下点击输入框内部:定位插入符并开始拖拽框选,不再触发底层控件的选中/拖拽。 + if (m_tabBar.OnEditMouseDown(x, y) || m_panel.OnEditMouseDown(x, y) || + m_plot.OnEditMouseDown(x, y) || m_colorPicker.OnEditMouseDown(x, y)) + { + SetCapture(hwnd); + m_activeTarget = Target::EDIT_FIELD; + InvalidateRect(hwnd, nullptr, FALSE); + return 0; + } + // 内联数值输入处于活动态时,点击绘图区之外视为提交(与线宽输入一致)。 if (m_plot.IsEditing() && !m_plot.HitTest(x, y)) { @@ -1557,6 +1586,20 @@ namespace jdi case Target::SPLITTER: m_draggingSplitter = false; break; + case Target::EDIT_FIELD: + { + // 结束输入框内的拖拽框选(选区保留)。 + bool changed = m_tabBar.OnEditMouseUp(); + changed = m_panel.OnEditMouseUp() || changed; + changed = m_plot.OnEditMouseUp() || changed; + changed = m_colorPicker.OnEditMouseUp() || changed; + m_activeTarget = Target::NONE; + if (changed) + { + InvalidateRect(hwnd, nullptr, FALSE); + } + return 0; + } case Target::TAB_BAR: { TabBarResult result; diff --git a/Components/PlotView/Inc/PlotView.hpp b/Components/PlotView/Inc/PlotView.hpp index 682c5bf..ceb4868 100644 --- a/Components/PlotView/Inc/PlotView.hpp +++ b/Components/PlotView/Inc/PlotView.hpp @@ -82,7 +82,7 @@ namespace jdi * @param [IN] dataSet 数据集合,允许为空。 * @param [IN] tab 当前标签页配置,允许为空。 */ - void SetContext(const DataSet* dataSet, TabConfig* tab); + void SetContext(DataSet* dataSet, TabConfig* tab); /** * @brief 设置 DPI 缩放比例。 @@ -287,7 +287,16 @@ namespace jdi * @param [IN] editMax 为 true 编辑上限,为 false 编辑下限。 * @return 进入成功返回 true。 */ - bool BeginLaneValueEdit(int laneIndex, bool editMax); + bool BeginLaneValueEdit(int laneIndex, bool editMax, const gd::RectF& anchorRect); + + /** + * @brief 查询某网格当前的 Y 轴显示范围。 + * @param [IN] laneIndex 网格下标。 + * @param [OUT] yMin 输出下限。 + * @param [OUT] yMax 输出上限。 + * @return 网格下标合法返回 true。 + */ + bool LaneRange(int laneIndex, double& yMin, double& yMax) const; /** * @brief 判断是否处于任意内联数值输入状态。 @@ -324,6 +333,27 @@ namespace jdi */ bool CollapseEditSelectionToEnd(); + /** + * @brief 编辑态下在输入框内按下鼠标左键:定位插入符并开始拖拽框选。 + * @param [IN] x 客户区横坐标。 + * @param [IN] y 客户区纵坐标。 + * @return 命中输入框返回 true。 + */ + bool OnEditMouseDown(int x, int y); + + /** + * @brief 编辑态下拖拽鼠标:扩展选区。 + * @param [IN] x 客户区横坐标。 + * @return 选区发生变化返回 true。 + */ + bool OnEditMouseMove(int x); + + /** + * @brief 编辑态下松开鼠标左键:结束拖拽框选。 + * @return 之前处于拖拽中返回 true。 + */ + bool OnEditMouseUp(); + /** * @brief 获取当前视图左边界时间。 * @return 视图左边界时间。 @@ -402,6 +432,18 @@ namespace jdi */ bool LaneSpansMultipleFiles(int laneIndex) const; + /** + * @brief 获取当前选中信号(跨标签页共享,切换视图后选中项保持一致)。 + * @return 信号引用。 + */ + const SignalRef& SelectedRef() const; + + /** + * @brief 设置当前选中信号(跨标签页共享)。 + * @param [IN] ref 信号引用。 + */ + void SelectSignal(const SignalRef& ref); + /** * @brief 重新计算各网格的布局与幅值范围。 */ @@ -533,7 +575,8 @@ namespace jdi * @param [IN] initialText 初始文本。 * @return 进入成功返回 true。 */ - bool BeginEdit(PlotEditTarget target, int laneIndex, const std::wstring& initialText); + bool BeginEdit(PlotEditTarget target, int laneIndex, const std::wstring& initialText, + const gd::RectF& anchorRect = gd::RectF()); /** * @brief 结束内联输入。 @@ -641,7 +684,8 @@ namespace jdi const UiFonts* m_fonts = nullptr; /**< 字体资源。 */ const Theme* m_theme = nullptr; /**< 主题资源。 */ - const DataSet* m_dataSet = nullptr; /**< 数据集合。 */ + const DataSet* m_dataSet = nullptr; /**< 数据集合(只读访问)。 */ + DataSet* m_dataSetWritable = nullptr; /**< 数据集合(选中状态写入)。 */ TabConfig* m_tab = nullptr; /**< 当前标签页配置。 */ TabConfig m_localTab; /**< 未绑定标签页时使用的占位配置。 */ HWND m_host = nullptr; /**< 剪贴板所有者窗口句柄。 */ @@ -705,14 +749,6 @@ namespace jdi * @return 命中的信号引用,未命中返回无效引用。 */ SignalRef LegendAtPoint(int x, int y) const; - - /** - * @brief 计算指定网格图例带所占据的矩形(用于让标尺数字避让)。 - * @param [IN] laneIndex 网格下标。 - * @param [IN] lane 网格绘制信息。 - * @return 图例带矩形,无可见信号时返回空矩形。 - */ - gd::RectF LegendBandRect(int laneIndex, const LaneVisual& lane) const; }; } // namespace jdi diff --git a/Components/PlotView/Src/PlotView.cpp b/Components/PlotView/Src/PlotView.cpp index 2d76acc..de2e584 100644 --- a/Components/PlotView/Src/PlotView.cpp +++ b/Components/PlotView/Src/PlotView.cpp @@ -36,6 +36,7 @@ namespace jdi const float kLegendPad = 5.0f; /**< 图例相对网格左上角的内缩。 */ const float kLegendSwatchSize = 8.0f; /**< 图例色块尺寸。 */ const float kLegendItemGap = 14.0f; /**< 图例条目之间的间距。 */ + const float kLegendMaxTextWidth = 200.0f; /**< 图例条目的文本宽度上限(超出则省略)。 */ /** * @brief 将数值限制到合法区间。 @@ -95,9 +96,10 @@ namespace jdi m_theme = theme; } - void PlotView::SetContext(const DataSet* dataSet, TabConfig* tab) + void PlotView::SetContext(DataSet* dataSet, TabConfig* tab) { m_dataSet = dataSet; + m_dataSetWritable = dataSet; m_tab = tab; m_hoverLane = -1; m_dragMode = PlotDragMode::NONE; @@ -216,6 +218,20 @@ namespace jdi BuildLaneVisuals(); } + const SignalRef& PlotView::SelectedRef() const + { + static const SignalRef fallback; + return (m_dataSet != nullptr) ? m_dataSet->Selected() : fallback; + } + + void PlotView::SelectSignal(const SignalRef& ref) + { + if (m_dataSetWritable != nullptr) + { + m_dataSetWritable->SetSelected(ref); + } + } + void PlotView::ClampLaneScroll() { const float maxOffset = std::max(0.0f, m_laneContentHeight - m_plotRect.Height); @@ -837,7 +853,7 @@ namespace jdi BuildLaneVisuals(); } - bool PlotView::BeginLaneValueEdit(int laneIndex, bool editMax) + bool PlotView::BeginLaneValueEdit(int laneIndex, bool editMax, const gd::RectF& anchorRect) { if (m_tab == nullptr || laneIndex < 0 || laneIndex >= static_cast(m_tab->Lanes().size())) { @@ -855,7 +871,27 @@ namespace jdi const LaneVisual& visual = m_lanes[static_cast(laneIndex)]; const double current = editMax ? visual.yMax : visual.yMin; return BeginEdit(editMax ? PlotEditTarget::LANE_Y_MAX : PlotEditTarget::LANE_Y_MIN, laneIndex, - FormatValue(current)); + FormatValue(current), anchorRect); + } + + bool PlotView::LaneRange(int laneIndex, double& yMin, double& yMax) const + { + if (laneIndex < 0 || laneIndex >= static_cast(m_lanes.size())) + { + // 菜单弹出时几何可能尚未建立,回退到配置内的自适应范围。 + if (m_tab == nullptr || m_dataSet == nullptr || laneIndex < 0 || + laneIndex >= static_cast(m_tab->Lanes().size())) + { + return false; + } + m_tab->ComputeLaneRange(*m_dataSet, laneIndex, yMin, yMax); + return true; + } + + const LaneVisual& visual = m_lanes[static_cast(laneIndex)]; + yMin = visual.yMin; + yMax = visual.yMax; + return true; } SignalRef PlotView::HitTestSignal(int x, int y) const @@ -988,7 +1024,18 @@ namespace jdi return gd::RectF(m_editFieldRect.X, m_editFieldRect.Y, nameWidth, height); } - // 网格 Y 轴上下限输入框贴在该网格内侧标尺带下方。 + // 网格 Y 轴上下限输入框:优先落在被点击的菜单条目位置,保证"就地编辑"的观感。 + if (m_editFieldRect.Width > 0.0f && m_editFieldRect.Height > 0.0f) + { + float left = m_editFieldRect.X; + float top = m_editFieldRect.Y; + left = std::max(m_area.X + 2.0f * m_scale, + std::min(left, m_area.X + m_area.Width - width - 2.0f * m_scale)); + top = std::max(m_area.Y + 2.0f * m_scale, + std::min(top, m_area.Y + m_area.Height - height - 2.0f * m_scale)); + return gd::RectF(left, top, width, height); + } + if (m_editLane < 0 || m_editLane >= static_cast(m_lanes.size())) { return gd::RectF(); @@ -999,7 +1046,8 @@ namespace jdi return gd::RectF(left, top, std::min(width, lane.rect.Width - 2.0f * kLaneLabelPad * m_scale), height); } - bool PlotView::BeginEdit(PlotEditTarget target, int laneIndex, const std::wstring& initialText) + bool PlotView::BeginEdit(PlotEditTarget target, int laneIndex, const std::wstring& initialText, + const gd::RectF& anchorRect) { if (target == PlotEditTarget::NONE) { @@ -1009,15 +1057,23 @@ namespace jdi if (target != PlotEditTarget::SIGNAL_NAME) { m_editSignal = SignalRef(); + } + // 调用方可以用锚点矩形指定输入框位置(如菜单条目、图例条目); + // 未提供锚点时由 EditFieldRect() 按目标类型自动推导。 + if (anchorRect.Width > 0.0f && anchorRect.Height > 0.0f) + { + m_editFieldRect = anchorRect; + } + else if (target != PlotEditTarget::SIGNAL_NAME) + { m_editFieldRect = gd::RectF(); } m_editTarget = target; m_editLane = laneIndex; - // 数值字段限制为数字与小数点,信号名可输入任意字符(保留括号与空格)。 + // 名称与数值均不限长度(数值在提交时会做范围夹取)。 const bool nameField = (target == PlotEditTarget::SIGNAL_NAME); - m_edit.Begin(initialText, true, nameField ? EditFilter::ANY : EditFilter::NUMBER, - nameField ? 96u : 18u); + m_edit.Begin(initialText, true, nameField ? EditFilter::ANY : EditFilter::NUMBER, 0u); return true; } @@ -1177,6 +1233,44 @@ namespace jdi return m_edit.DeselectToEnd(); } + bool PlotView::OnEditMouseDown(int x, int y) + { + if (m_editTarget == PlotEditTarget::NONE) + { + return false; + } + const gd::RectF rect = EditFieldRect(); + if (rect.Width <= 0.0f) + { + return false; + } + return UiEditFieldMouseDown(m_fonts->uiSmall, rect, m_edit, m_scale, x, y); + } + + bool PlotView::OnEditMouseMove(int x) + { + if (m_editTarget == PlotEditTarget::NONE) + { + return false; + } + const gd::RectF rect = EditFieldRect(); + if (rect.Width <= 0.0f) + { + return false; + } + return UiEditFieldMouseMove(m_fonts->uiSmall, rect, m_edit, m_scale, x); + } + + bool PlotView::OnEditMouseUp() + { + if (!m_edit.Dragging()) + { + return false; + } + m_edit.EndDrag(); + return true; + } + bool PlotView::OnEditKey(WPARAM key) { if (m_editTarget == PlotEditTarget::NONE) @@ -1508,7 +1602,7 @@ namespace jdi const SignalRef legendHit = LegendAtPoint(x, y); if (legendHit.IsValid()) { - Tab().SetSelected(legendHit); + SelectSignal(legendHit); m_dragMode = PlotDragMode::NONE; return true; } @@ -1520,7 +1614,7 @@ namespace jdi { m_dragSignal = hit; m_dragTargetLane = Tab().LaneOfSignal(hit); - Tab().SetSelected(hit); + SelectSignal(hit); m_dragMouseX = x; m_dragMouseY = y; m_dragMode = PlotDragMode::MOVE_SIGNAL; @@ -1633,7 +1727,7 @@ namespace jdi const ResolvedSignal resolved = ResolveSignal(legendHit); if (resolved.data != nullptr) { - Tab().SetSelected(legendHit); + SelectSignal(legendHit); m_editSignal = legendHit; // 先把输入框位置落在被双击的条目上,再进入编辑态。 @@ -2077,9 +2171,6 @@ namespace jdi // 内侧纵轴标尺作为覆盖层绘制:每个刻度数值带半透明底,保证在波形之上仍清晰可读, // 同时只在数值背后遮挡波形,不牺牲波形本身的可见范围。 { - // 图例固定占用网格左上角,冲突时优先显示图例,故落在图例带内的刻度数值直接跳过。 - const gd::RectF legendBand = LegendBandRect(laneIndex, lane); - const bool showLabels = (lane.rect.Height >= 34.0f * m_scale); float lastLabelY = -1.0e9f; @@ -2117,16 +2208,10 @@ namespace jdi { continue; } - // 与图例带冲突时让位(图例优先)。 - if (legendBand.Width > 0.0f && - pill.X < legendBand.X + legendBand.Width && - pill.X + pill.Width > legendBand.X && - pill.Y < legendBand.Y + legendBand.Height && - pill.Y + pill.Height > legendBand.Y) - { - continue; - } - // 文本必须完整落在可见区内,否则会越过绘图区顶部(GDI 文本不受 GDI+ 裁剪约束)。 + // 与图例重叠时不再跳过:标尺值先绘制,随后绘制的图例会盖在其上层 + // (顶层始终为图例),因此两者都不丢失。 + // 但仍要求数值完整落在可见区内——否则会在网格分界处露出被截断的半行数字, + // 且 GDI 文本不受 GDI+ 裁剪约束会越过绘图区(改用 UiTextAtClipped 兜底)。 if (!IsFullyVisible(pill, clipTop, clipBottom)) { continue; @@ -2152,43 +2237,6 @@ namespace jdi g.Restore(saveState); } - gd::RectF PlotView::LegendBandRect(int laneIndex, const LaneVisual& lane) const - { - if (m_tab == nullptr || laneIndex < 0 || laneIndex >= static_cast(m_tab->Lanes().size())) - { - return gd::RectF(); - } - - // 逐条累加已可见图例的宽度,得到整条图例带(用于让标尺数字避让)。 - float width = 0.0f; - for (const SignalRef& ref : m_tab->Lanes()[static_cast(laneIndex)].signals) - { - const ResolvedSignal resolved = ResolveSignal(ref); - if (resolved.data == nullptr || resolved.style == nullptr || !resolved.visible) - { - continue; - } - - std::wstring label = BuildLegendText(*resolved.data); - if (LaneSpansMultipleFiles(laneIndex) && resolved.file != nullptr) - { - label += L" · " + resolved.file->Title(); - } - const float textWidth = UiMeasureTextWidth(m_fonts->uiSmall, label); - width += kLegendSwatchSize * m_scale + 5.0f * m_scale + textWidth + kLegendItemGap * m_scale; - } - - if (width <= 0.0f) - { - return gd::RectF(); - } - - const float left = lane.rect.X + kLegendPad * m_scale; - const float maxWidth = std::max(0.0f, lane.rect.Width - kLegendPad * 2.0f * m_scale); - return gd::RectF(left, lane.rect.Y + kLegendPad * m_scale - 1.0f * m_scale, - std::min(width + 6.0f * m_scale, maxWidth), kLegendHeight * m_scale + 2.0f * m_scale); - } - SignalRef PlotView::LegendAtPoint(int x, int y) const { const float fx = static_cast(x); @@ -2248,7 +2296,9 @@ namespace jdi label += L" · " + resolved.file->Title(); } const float itemWidth = kLegendSwatchSize * m_scale + 5.0f * m_scale + - UiTextWidth(g, label, m_fonts->uiSmall) + kLegendItemGap * m_scale; + std::min(UiTextWidth(g, label, m_fonts->uiSmall), + kLegendMaxTextWidth * m_scale) + + kLegendItemGap * m_scale; if (cursorX + legendWidth + itemWidth > maxX) { break; @@ -2292,9 +2342,14 @@ namespace jdi { label += L" · " + resolved.file->Title(); } - const float textWidth = UiTextWidth(g, label, m_fonts->uiSmall); - const float itemWidth = - kLegendSwatchSize * m_scale + 5.0f * m_scale + textWidth + kLegendItemGap * m_scale; + // 文本宽度按上限夹取,超出部分用省略号(与测量阶段保持一致)。 + const float maxTextWidth = kLegendMaxTextWidth * m_scale; + const float fullTextWidth = UiTextWidth(g, label, m_fonts->uiSmall); + const float textWidth = std::min(fullTextWidth, maxTextWidth); + const std::wstring drawLabel = + (fullTextWidth > maxTextWidth) ? UiEllipsize(g, label, m_fonts->uiSmall, maxTextWidth) : label; + const float itemWidth = kLegendSwatchSize * m_scale + 5.0f * m_scale + textWidth + + kLegendItemGap * m_scale; if (drawnWidth + itemWidth > legendWidth + 0.5f) { break; @@ -2305,7 +2360,7 @@ namespace jdi const float swatchSize = kLegendSwatchSize * m_scale; // 图例条目与被选中的信号保持一致的高亮反馈(与左侧列表选中态呼应)。 - const bool selected = (m_tab->Selected() == ref); + const bool selected = (SelectedRef() == ref); const bool hovered = (ref == m_hoverSignal) || (ref == m_dragSignal); if (selected || hovered) { @@ -2334,7 +2389,7 @@ namespace jdi } cursorX += swatchSize + 5.0f * m_scale; - UiText(g, label, m_fonts->uiSmall, + UiText(g, drawLabel, m_fonts->uiSmall, gd::RectF(cursorX, top, std::max(8.0f, textWidth + 2.0f), height), selected ? m_theme->textStrong : m_theme->text, TextAlign::LEFT); cursorX += textWidth + kLegendItemGap * m_scale; diff --git a/Components/SignalPanel/Inc/SignalPanel.hpp b/Components/SignalPanel/Inc/SignalPanel.hpp index 834e0b0..00fa658 100644 --- a/Components/SignalPanel/Inc/SignalPanel.hpp +++ b/Components/SignalPanel/Inc/SignalPanel.hpp @@ -188,6 +188,27 @@ namespace jdi */ bool CollapseEditSelectionToEnd(); + /** + * @brief 编辑态下在输入框内按下鼠标左键:定位插入符并开始拖拽框选。 + * @param [IN] x 客户区横坐标。 + * @param [IN] y 客户区纵坐标。 + * @return 命中输入框返回 true。 + */ + bool OnEditMouseDown(int x, int y); + + /** + * @brief 编辑态下拖拽鼠标:扩展选区。 + * @param [IN] x 客户区横坐标。 + * @return 选区发生变化返回 true。 + */ + bool OnEditMouseMove(int x); + + /** + * @brief 编辑态下松开鼠标左键:结束拖拽框选。 + * @return 之前处于拖拽中返回 true。 + */ + bool OnEditMouseUp(); + /** * @brief 获取指定信号的显示外观(只读,全局共享)。 * @param [IN] ref 信号引用。 @@ -202,6 +223,18 @@ namespace jdi */ SignalStyle* StyleOf(const SignalRef& ref); + /** + * @brief 获取当前选中信号(跨标签页共享,因此切换视图后选中项保持一致)。 + * @return 信号引用。 + */ + const SignalRef& SelectedRef() const; + + /** + * @brief 设置当前选中信号(跨标签页共享)。 + * @param [IN] ref 信号引用。 + */ + void SelectSignal(const SignalRef& ref); + /** * @brief 将浮动拾色器的取色结果应用到当前选中信号。 * @param [IN] color 0xAARRGGBB 颜色值。 diff --git a/Components/SignalPanel/Src/SignalPanel.cpp b/Components/SignalPanel/Src/SignalPanel.cpp index 66c2cc5..84dac46 100644 --- a/Components/SignalPanel/Src/SignalPanel.cpp +++ b/Components/SignalPanel/Src/SignalPanel.cpp @@ -715,7 +715,7 @@ namespace jdi } const gd::RectF rect = RowRect(row); - const bool selected = (m_tab->Selected() == ref); + const bool selected = (SelectedRef() == ref); const bool hovered = (m_hoverRow == row); if (selected) @@ -818,8 +818,8 @@ namespace jdi UiFillRect(g, m_styleRect, m_theme->panelBg); - const SignalStyle* style = (m_tab == nullptr) ? nullptr : StyleOf(m_tab->Selected()); - if (style == nullptr || m_tab->Selected().IsValid() == false) + const SignalStyle* style = (m_tab == nullptr) ? nullptr : StyleOf(SelectedRef()); + if (style == nullptr || SelectedRef().IsValid() == false) { UiText(g, L"请选择信号以编辑样式", m_fonts->uiSmall, m_styleRect, m_theme->textMuted, TextAlign::CENTER); @@ -964,7 +964,7 @@ namespace jdi { return false; } - SignalStyle* style = StyleOf(m_tab->Selected()); + SignalStyle* style = StyleOf(SelectedRef()); if (style == nullptr) { return false; @@ -1043,7 +1043,7 @@ namespace jdi { case StyleEditField::WIDTH: { - const SignalStyle* style = StyleOf(m_tab->Selected()); + const SignalStyle* style = StyleOf(SelectedRef()); if (style == nullptr) { return false; @@ -1053,7 +1053,7 @@ namespace jdi } case StyleEditField::OPACITY: { - const SignalStyle* style = StyleOf(m_tab->Selected()); + const SignalStyle* style = StyleOf(SelectedRef()); if (style == nullptr) { return false; @@ -1100,8 +1100,8 @@ namespace jdi // 名称类字段可输入任意字符,数值类字段限制为数字与小数点。 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); + // 不限长度:名称与数值都由各自的提交逻辑处理超长内容。 + m_edit.Begin(std::move(initial), true, nameField ? EditFilter::ANY : EditFilter::NUMBER, 0u); return true; } @@ -1140,7 +1140,7 @@ namespace jdi { break; } - if (SignalStyle* style = StyleOf(m_tab->Selected())) + if (SignalStyle* style = StyleOf(SelectedRef())) { const float value = ClampFloat(static_cast(parsed), kWidthMin, kWidthMax); style->lineWidth = std::round(value * 10.0f) / 10.0f; @@ -1159,7 +1159,7 @@ namespace jdi { break; } - if (SignalStyle* style = StyleOf(m_tab->Selected())) + if (SignalStyle* style = StyleOf(SelectedRef())) { style->opacity = ClampFloat(static_cast(parsed) / 100.0f, 0.0f, 1.0f); } @@ -1299,6 +1299,34 @@ namespace jdi return m_edit.DeselectToEnd(); } + bool SignalPanel::OnEditMouseDown(int x, int y) + { + if (m_editField == StyleEditField::NONE) + { + return false; + } + return UiEditFieldMouseDown(m_fonts->ui, EditFieldRect(), m_edit, m_scale, x, y); + } + + bool SignalPanel::OnEditMouseMove(int x) + { + if (m_editField == StyleEditField::NONE) + { + return false; + } + return UiEditFieldMouseMove(m_fonts->ui, EditFieldRect(), m_edit, m_scale, x); + } + + bool SignalPanel::OnEditMouseUp() + { + if (!m_edit.Dragging()) + { + return false; + } + m_edit.EndDrag(); + return true; + } + const SignalStyle* SignalPanel::StyleOf(const SignalRef& ref) const { return (m_dataSet != nullptr) ? m_dataSet->Style(ref) : nullptr; @@ -1309,13 +1337,27 @@ namespace jdi return (m_dataSetWritable != nullptr) ? m_dataSetWritable->Style(ref) : nullptr; } + const SignalRef& SignalPanel::SelectedRef() const + { + static const SignalRef fallback; + return (m_dataSet != nullptr) ? m_dataSet->Selected() : fallback; + } + + void SignalPanel::SelectSignal(const SignalRef& ref) + { + if (m_dataSetWritable != nullptr) + { + m_dataSetWritable->SetSelected(ref); + } + } + bool SignalPanel::ApplyColor(std::uint32_t color) { if (m_tab == nullptr) { return false; } - SignalStyle* style = StyleOf(m_tab->Selected()); + SignalStyle* style = StyleOf(SelectedRef()); if (style == nullptr) { return false; @@ -1334,7 +1376,7 @@ namespace jdi { return false; } - SignalStyle* style = StyleOf(m_tab->Selected()); + SignalStyle* style = StyleOf(SelectedRef()); if (style == nullptr) { return false; @@ -1415,7 +1457,7 @@ namespace jdi { hoverStyle = StyleHit::TOGGLE_ALL; } - else if (m_tab != nullptr && StyleOf(m_tab->Selected()) != nullptr) + else if (m_tab != nullptr && StyleOf(SelectedRef()) != nullptr) { for (int index = 0; index < m_colorCount; ++index) { @@ -1528,7 +1570,7 @@ namespace jdi } // 样式编辑区。 - if (SignalStyle* style = StyleOf(m_tab->Selected())) + if (SignalStyle* style = StyleOf(SelectedRef())) { int paletteCount = 0; const std::uint32_t* palette = SignalPalette(paletteCount); @@ -1612,7 +1654,7 @@ namespace jdi } const SignalRef ref{item.fileIndex, item.signalIndex}; - m_tab->SetSelected(ref); + SelectSignal(ref); if (part == RowPart::CHECK) { @@ -1656,7 +1698,7 @@ namespace jdi const float fy = static_cast(y); if (UiPointInRect(m_customChipRect, fx, fy) && m_tab != nullptr) { - if (const SignalStyle* style = StyleOf(m_tab->Selected())) + if (const SignalStyle* style = StyleOf(SelectedRef())) { changed = OpenColorPickerPopup(*style) || changed; } @@ -1739,7 +1781,7 @@ namespace jdi } const SignalRef ref{item.fileIndex, item.signalIndex}; - m_tab->SetSelected(ref); + SelectSignal(ref); m_rowsDirty = true; return BeginEdit(StyleEditField::SIGNAL_NAME, row); } @@ -1766,7 +1808,7 @@ namespace jdi } const SignalRef ref{item.fileIndex, item.signalIndex}; - m_tab->SetSelected(ref); + SelectSignal(ref); OpenSignalMenu(ref, x, y); return true; } diff --git a/Components/TabBar/Inc/TabBar.hpp b/Components/TabBar/Inc/TabBar.hpp index be63f6b..c4e4f32 100644 --- a/Components/TabBar/Inc/TabBar.hpp +++ b/Components/TabBar/Inc/TabBar.hpp @@ -163,6 +163,33 @@ namespace jdi */ bool CollapseEditSelectionToEnd(); + /** + * @brief 编辑态下在输入框内按下鼠标左键:定位插入符并开始拖拽框选。 + * @param [IN] x 客户区横坐标。 + * @param [IN] y 客户区纵坐标。 + * @return 命中输入框返回 true。 + */ + bool OnEditMouseDown(int x, int y); + + /** + * @brief 编辑态下拖拽鼠标:扩展选区。 + * @param [IN] x 客户区横坐标。 + * @return 选区发生变化返回 true。 + */ + bool OnEditMouseMove(int x); + + /** + * @brief 编辑态下松开鼠标左键:结束拖拽框选。 + * @return 之前处于拖拽中返回 true。 + */ + bool OnEditMouseUp(); + + /** + * @brief 计算重命名输入框的矩形(绘制与命中测试共用同一套几何)。 + * @return 输入框矩形;不在编辑态时返回空矩形。 + */ + gd::RectF EditRect() const; + /** * @brief 查询拖拽排序时应当插入的位置。 * @param [IN] x 客户区横坐标。 diff --git a/Components/TabBar/Src/TabBar.cpp b/Components/TabBar/Src/TabBar.cpp index 559ee7d..ee7dbda 100644 --- a/Components/TabBar/Src/TabBar.cpp +++ b/Components/TabBar/Src/TabBar.cpp @@ -176,10 +176,7 @@ namespace jdi // 重命名编辑态:在标签本体之上叠加输入框(标签底色与边框仍然保留)。 if (static_cast(index) == m_editIndex) { - const gd::RectF editRect(textLeft - 3.0f * m_scale, tab.rect.Y + 3.0f * m_scale, - std::max(40.0f, textWidth + 6.0f * m_scale), - tab.rect.Height - 6.0f * m_scale); - UiDrawEditField(g, editRect, m_edit, m_fonts->ui, m_scale); + UiDrawEditField(g, EditRect(), m_edit, m_fonts->ui, m_scale); } else { @@ -423,7 +420,7 @@ namespace jdi } m_editIndex = index; - m_edit.Begin(m_tabs[static_cast(index)].name, true, EditFilter::ANY, 48); + m_edit.Begin(m_tabs[static_cast(index)].name, true, EditFilter::ANY, 0u); return true; } @@ -529,6 +526,48 @@ namespace jdi return m_edit.DeselectToEnd(); } + gd::RectF TabBar::EditRect() const + { + if (m_editIndex < 0 || m_editIndex >= static_cast(m_tabs.size()) || m_fonts == nullptr) + { + return gd::RectF(); + } + + const TabItem& tab = m_tabs[static_cast(m_editIndex)]; + const float textLeft = tab.rect.X + kTabPadding * m_scale; + const float textWidth = tab.closeRect.X - kCloseGap * m_scale - textLeft; + return gd::RectF(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); + } + + bool TabBar::OnEditMouseDown(int x, int y) + { + if (m_editIndex < 0) + { + return false; + } + return UiEditFieldMouseDown(m_fonts->ui, EditRect(), m_edit, m_scale, x, y); + } + + bool TabBar::OnEditMouseMove(int x) + { + if (m_editIndex < 0) + { + return false; + } + return UiEditFieldMouseMove(m_fonts->ui, EditRect(), m_edit, m_scale, x); + } + + bool TabBar::OnEditMouseUp() + { + if (!m_edit.Dragging()) + { + return false; + } + m_edit.EndDrag(); + return true; + } + bool TabBar::OnRButtonDown(int x, int y, int screenX, int screenY, TabBarResult& result) { result = TabBarResult(); diff --git a/Utilities/Inc/ColorPickerPopup.hpp b/Utilities/Inc/ColorPickerPopup.hpp index 91764e3..7f72978 100644 --- a/Utilities/Inc/ColorPickerPopup.hpp +++ b/Utilities/Inc/ColorPickerPopup.hpp @@ -124,6 +124,27 @@ namespace jdi */ bool CollapseEditSelectionToEnd(); + /** + * @brief 编辑态下在输入框内按下鼠标左键:定位插入符并开始拖拽框选。 + * @param [IN] x 客户区横坐标。 + * @param [IN] y 客户区纵坐标。 + * @return 命中输入框返回 true。 + */ + bool OnEditMouseDown(int x, int y); + + /** + * @brief 编辑态下拖拽鼠标:扩展选区。 + * @param [IN] x 客户区横坐标。 + * @return 选区发生变化返回 true。 + */ + bool OnEditMouseMove(int x); + + /** + * @brief 编辑态下松开鼠标左键:结束拖拽框选。 + * @return 之前处于拖拽中返回 true。 + */ + bool OnEditMouseUp(); + /** * @brief 设置剪贴板所有者窗口句柄(用于输入框的复制/粘贴)。 * @param [IN] owner 宿主窗口句柄,允许为空。 diff --git a/Utilities/Inc/DataModel.hpp b/Utilities/Inc/DataModel.hpp index 11affdb..d349394 100644 --- a/Utilities/Inc/DataModel.hpp +++ b/Utilities/Inc/DataModel.hpp @@ -249,8 +249,33 @@ namespace jdi */ double MaxTime() const; + /** + * @brief 获取当前选中的信号(全标签页共享)。 + * @return 信号引用。 + */ + const SignalRef& Selected() const { return m_selected; } + + /** + * @brief 设置当前选中的信号(全标签页共享)。 + * @param [IN] ref 信号引用;传入无效值表示取消选择。 + */ + void SetSelected(const SignalRef& ref) { m_selected = ref; } + + /** + * @brief 在数据层移除某文件之前,按旧下标修正选中引用。 + * @param [IN] fileIndex 即将被移除的文件下标。 + */ + void RemapSelectionAfterFileRemoval(int fileIndex); + + /** + * @brief 校验选中引用是否仍指向存在的信号,失效则取消选中。 + * @return 选中状态发生变化返回 true。 + */ + bool ValidateSelection(); + private: std::vector m_files; /**< 已加载文件集合。 */ + SignalRef m_selected; /**< 当前选中信号(跨标签页共享)。 */ }; /** @@ -452,18 +477,6 @@ namespace jdi */ void ToggleCollapsed(int fileIndex); - /** - * @brief 获取当前选中的信号引用。 - * @return 信号引用。 - */ - SignalRef Selected() const { return m_selected; } - - /** - * @brief 设置当前选中的信号。 - * @param [IN] ref 信号引用,无效值表示取消选择。 - */ - void SetSelected(const SignalRef& ref) { m_selected = ref; } - /** * @brief 计算某网格的 Y 轴显示范围。 * @param [IN] dataSet 数据集合。 @@ -513,7 +526,6 @@ namespace jdi std::vector m_collapsed; /**< 文件分组折叠状态,按文件索引。 */ std::vector m_lanes; /**< 网格划分。 */ LaneMode m_laneMode = LaneMode::PER_SIGNAL; /**< 网格划分策略。 */ - SignalRef m_selected; /**< 当前选中信号。 */ double m_viewMin = 0.0; /**< 视图左边界时间。 */ double m_viewMax = 1.0; /**< 视图右边界时间。 */ bool m_viewInitialized = false; /**< 视图范围是否已初始化。 */ diff --git a/Utilities/Inc/PaintUtil.hpp b/Utilities/Inc/PaintUtil.hpp index 7573cf6..17d60d7 100644 --- a/Utilities/Inc/PaintUtil.hpp +++ b/Utilities/Inc/PaintUtil.hpp @@ -154,6 +154,44 @@ namespace jdi */ float UiMeasureTextWidth(HFONT font, const std::wstring& text); + /** + * @brief 在指定裁剪矩形内以显式起点绘制一段文本(使用 GDI ExtTextOut 裁剪)。 + * + * `UiText` 走 `DrawTextW`,**不遵循 GDI+ 裁剪区**;输入框在长内容、水平滚动的情况下 + * 会把字符画到框体之外。本函数用 `ETO_CLIPPING` 让 GDI 自己裁剪,彻底避免越界绘制。 + * + * @param [IN,OUT] g 绘图上下文。 + * @param [IN] text 文本内容。 + * @param [IN] font 字体句柄。 + * @param [IN] x 文本起始横坐标。 + * @param [IN] rect 垂直居中参考矩形。 + * @param [IN] argb 文本颜色。 + * @param [IN] clipRect 裁剪矩形(超出部分不绘制)。 + */ + void UiTextAtClipped(gd::Graphics& g, const std::wstring& text, HFONT font, float x, const gd::RectF& rect, + std::uint32_t argb, const gd::RectF& clipRect); + + /** + * @brief 计算输入框文本的绘制起点(已考虑超出框宽时的水平滚动)。 + * @param [IN] rect 输入框矩形。 + * @param [IN] edit 编辑模型。 + * @param [IN] font 字体句柄。 + * @param [IN] scale 缩放比例。 + * @param [OUT] innerWidth 框内可用宽度输出。 + * @return 文本绘制起点横坐标。 + */ + float UiEditTextLeft(const gd::RectF& rect, const TextEdit& edit, HFONT font, float scale, + float& innerWidth); + + /** + * @brief 把输入框内的横坐标换算为字符偏移(就近取整,用于鼠标定位插入符)。 + * @param [IN] font 字体句柄。 + * @param [IN] text 当前文本。 + * @param [IN] localX 相对文本起点的横坐标(可超出 [0, 文本宽度])。 + * @return 字符偏移。 + */ + std::size_t UiEditOffsetAt(HFONT font, const std::wstring& text, float localX); + /** * @brief 在超出可用宽度时对文本做尾部省略截断。 * @param [IN,OUT] g 绘图上下文。 @@ -164,6 +202,29 @@ namespace jdi */ std::wstring UiEllipsize(gd::Graphics& g, const std::wstring& text, HFONT font, float maxWidth); + /** + * @brief 处理输入框内的鼠标按下:命中则把插入符定位到最近的字符边界并开始拖拽框选。 + * @param [IN] font 字体句柄。 + * @param [IN] rect 输入框矩形。 + * @param [IN,OUT] edit 编辑模型。 + * @param [IN] scale 缩放比例。 + * @param [IN] x 客户区横坐标。 + * @param [IN] y 客户区纵坐标。 + * @return 命中输入框并开始拖拽返回 true。 + */ + bool UiEditFieldMouseDown(HFONT font, const gd::RectF& rect, TextEdit& edit, float scale, int x, int y); + + /** + * @brief 处理输入框内的鼠标拖拽移动(扩展选区)。 + * @param [IN] font 字体句柄。 + * @param [IN] rect 输入框矩形。 + * @param [IN,OUT] edit 编辑模型。 + * @param [IN] scale 缩放比例。 + * @param [IN] x 客户区横坐标。 + * @return 选区发生变化返回 true。 + */ + bool UiEditFieldMouseMove(HFONT font, const gd::RectF& rect, TextEdit& edit, float scale, int x); + /** * @brief 在矩形中心绘制一个加号图标。 * @param [IN,OUT] g 绘图上下文。 diff --git a/Utilities/Inc/PopupMenu.hpp b/Utilities/Inc/PopupMenu.hpp index 365b216..1221432 100644 --- a/Utilities/Inc/PopupMenu.hpp +++ b/Utilities/Inc/PopupMenu.hpp @@ -61,6 +61,7 @@ namespace jdi MenuScope scope = MenuScope::NONE; /**< 来源组件。 */ int id = 0; /**< 被选中项的命令标识。 */ int swatchIndex = -1; /**< 被选中的色块下标(非色块行为 -1)。 */ + gd::RectF itemRect; /**< 被点击条目的矩形(客户区坐标),供就近弹出输入框。 */ }; /** diff --git a/Utilities/Inc/TextEdit.hpp b/Utilities/Inc/TextEdit.hpp index 034b813..d838673 100644 --- a/Utilities/Inc/TextEdit.hpp +++ b/Utilities/Inc/TextEdit.hpp @@ -144,6 +144,37 @@ namespace jdi */ bool DeselectToEnd(); + /** + * @brief 把插入符定位到指定偏移(单击输入框内时使用),并清除选区。 + * @param [IN] offset 目标偏移(内部会夹到合法范围)。 + * @return 光标或选区发生变化返回 true。 + */ + bool PlaceCaret(std::size_t offset); + + /** + * @brief 开始鼠标拖拽框选:把锚点与插入符都放在给定偏移。 + * @param [IN] offset 起始偏移。 + */ + void BeginDrag(std::size_t offset); + + /** + * @brief 拖拽过程中更新插入符(锚点保持不变 → 形成选区)。 + * @param [IN] offset 当前偏移。 + * @return 光标或选区发生变化返回 true。 + */ + bool UpdateDrag(std::size_t offset); + + /** + * @brief 结束鼠标拖拽。 + */ + void EndDrag() { m_dragging = false; } + + /** + * @brief 判断是否正在鼠标拖拽框选。 + * @return 正在拖拽返回 true。 + */ + bool Dragging() const { return m_dragging; } + /** * @brief 复制当前内容到剪贴板(存在选区时只复制选区)。 * @param [IN] owner 剪贴板所有者窗口句柄,允许为空。 @@ -204,6 +235,7 @@ namespace jdi bool m_active = false; /**< 是否处于编辑状态。 */ bool m_fresh = false; /**< 是否处于“首次输入整体替换”状态。 */ + bool m_dragging = false; /**< 是否正在鼠标拖拽框选。 */ std::wstring m_text; /**< 当前文本。 */ std::size_t m_caret = 0; /**< 光标位置。 */ std::size_t m_anchor = 0; /**< 选区锚点。 */ diff --git a/Utilities/Src/ColorPickerPopup.cpp b/Utilities/Src/ColorPickerPopup.cpp index 0f4f75e..75384c9 100644 --- a/Utilities/Src/ColorPickerPopup.cpp +++ b/Utilities/Src/ColorPickerPopup.cpp @@ -214,7 +214,7 @@ namespace jdi { const int index = static_cast(field) - 1; m_edit.Begin(std::to_wstring(ToByte(m_channels[static_cast(index)])), true, - EditFilter::BYTE, 3); + EditFilter::BYTE, 0u); break; } case EditField::HEX: @@ -223,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_edit.Begin(std::wstring(buffer), true, EditFilter::HEX, 7); + m_edit.Begin(std::wstring(buffer), true, EditFilter::HEX, 0u); break; } default: @@ -373,6 +373,34 @@ namespace jdi return m_edit.DeselectToEnd(); } + bool ColorPickerPopup::OnEditMouseDown(int x, int y) + { + if (m_editField == EditField::NONE) + { + return false; + } + return UiEditFieldMouseDown(m_fonts->monoSmall, EditFieldRect(), m_edit, m_scale, x, y); + } + + bool ColorPickerPopup::OnEditMouseMove(int x) + { + if (m_editField == EditField::NONE) + { + return false; + } + return UiEditFieldMouseMove(m_fonts->monoSmall, EditFieldRect(), m_edit, m_scale, x); + } + + bool ColorPickerPopup::OnEditMouseUp() + { + if (!m_edit.Dragging()) + { + return false; + } + m_edit.EndDrag(); + return true; + } + bool ColorPickerPopup::ContainsPoint(int x, int y) const { if (!m_open) diff --git a/Utilities/Src/DataModel.cpp b/Utilities/Src/DataModel.cpp index 4c9879c..937fa4a 100644 --- a/Utilities/Src/DataModel.cpp +++ b/Utilities/Src/DataModel.cpp @@ -550,6 +550,36 @@ namespace jdi return std::isfinite(value) ? value : 1.0; } + void DataSet::RemapSelectionAfterFileRemoval(int fileIndex) + { + if (fileIndex < 0) + { + return; + } + if (m_selected.fileIndex == fileIndex) + { + m_selected = SignalRef(); + } + else if (m_selected.fileIndex > fileIndex) + { + --m_selected.fileIndex; + } + } + + bool DataSet::ValidateSelection() + { + if (!m_selected.IsValid()) + { + return false; + } + if (Resolve(m_selected) != nullptr) + { + return false; + } + m_selected = SignalRef(); + return true; + } + // ============================ 采样辅助 ============================ int FindNearestSample(const DataFile& file, double time) @@ -686,12 +716,7 @@ namespace jdi m_visible[fileIndex].resize(file->signals.size(), false); } - // 清理已失效的选中状态与网格引用。 - if (m_selected.IsValid() && dataSet.Resolve(m_selected) == nullptr) - { - m_selected = SignalRef(); - } - + // 清理已失效的网格引用(选中状态由 DataSet 统一维护)。 for (Lane& lane : m_lanes) { std::vector& refs = lane.signals; @@ -741,15 +766,6 @@ namespace jdi m_lanes.erase(std::remove_if(m_lanes.begin(), m_lanes.end(), [](const Lane& lane) { return lane.signals.empty(); }), m_lanes.end()); - - if (m_selected.fileIndex == fileIndex) - { - m_selected = SignalRef(); - } - else if (m_selected.fileIndex > fileIndex) - { - --m_selected.fileIndex; - } } bool TabConfig::IsVisible(const SignalRef& ref) const diff --git a/Utilities/Src/PaintUtil.cpp b/Utilities/Src/PaintUtil.cpp index aaa9135..f1f782d 100644 --- a/Utilities/Src/PaintUtil.cpp +++ b/Utilities/Src/PaintUtil.cpp @@ -428,6 +428,101 @@ namespace jdi knobRadius * 2.0f); } + float UiEditTextLeft(const gd::RectF& rect, const TextEdit& edit, HFONT font, float scale, + float& innerWidth) + { + innerWidth = std::max(4.0f, rect.Width - 12.0f * scale); + const float textLeft = rect.X + 6.0f * scale; + + const std::wstring& text = edit.Text(); + if (text.empty()) + { + return textLeft; + } + + const float textWidth = UiMeasureTextWidth(font, text); + if (textWidth <= innerWidth) + { + return textLeft; + } + + // 保证插入符可见:插入符超出右边界时向左滚动,并夹到总可滚动范围内。 + const float caretX = UiMeasureTextWidth(font, text.substr(0, edit.Caret())); + float scroll = 0.0f; + if (caretX > innerWidth) + { + scroll = caretX - innerWidth; + } + const float maximum = textWidth - innerWidth; + scroll = std::max(0.0f, std::min(scroll, maximum)); + return textLeft - scroll; + } + + std::size_t UiEditOffsetAt(HFONT font, const std::wstring& text, float localX) + { + if (text.empty() || font == nullptr) + { + return 0; + } + if (localX <= 0.0f) + { + return 0; + } + + // 逐字符累加宽度,找到距离最近的字符边界(文本通常很短,线性扫描足够)。 + float previous = 0.0f; + for (std::size_t index = 0; index < text.size(); ++index) + { + const float current = UiMeasureTextWidth(font, text.substr(0, index + 1)); + if (localX < current) + { + // 落在本字符的左半 → 取左边界,右半 → 取右边界。 + return (localX - previous <= current - localX) ? index : (index + 1); + } + previous = current; + } + return text.size(); + } + + void UiTextAtClipped(gd::Graphics& g, const std::wstring& text, HFONT font, float x, const gd::RectF& rect, + std::uint32_t argb, const gd::RectF& clipRect) + { + if (text.empty() || font == nullptr || clipRect.Width <= 0.0f || clipRect.Height <= 0.0f) + { + return; + } + + HDC hdc = g.GetHDC(); + if (hdc == nullptr) + { + return; + } + + HGDIOBJ oldFont = SelectObject(hdc, font); + const int oldMode = SetBkMode(hdc, TRANSPARENT); + const COLORREF oldColor = SetTextColor(hdc, ToColorRef(argb)); + + TEXTMETRICW metrics = {}; + GetTextMetricsW(hdc, &metrics); + const int baselineY = static_cast( + std::floor(rect.Y + (rect.Height - static_cast(metrics.tmHeight)) * 0.5f)); + + RECT clip = {}; + clip.left = static_cast(std::floor(clipRect.X)); + clip.top = static_cast(std::floor(clipRect.Y)); + clip.right = static_cast(std::ceil(clipRect.X + clipRect.Width)); + clip.bottom = static_cast(std::ceil(clipRect.Y + clipRect.Height)); + + // ETO_CLIPPED 让 GDI 在指定矩形内裁剪,长文本不会再画到输入框之外。 + ExtTextOutW(hdc, static_cast(std::floor(x)), baselineY, ETO_CLIPPED, &clip, text.c_str(), + static_cast(text.size()), nullptr); + + SetTextColor(hdc, oldColor); + SetBkMode(hdc, oldMode); + SelectObject(hdc, oldFont); + g.ReleaseHDC(hdc); + } + 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,70 +537,52 @@ namespace jdi UiDrawRoundRect(g, rect, corner, theme.accent, 1.6f); const std::wstring& text = edit.Text(); + const float innerLeft = rect.X + 6.0f * scale; + + float innerWidth = 0.0f; + const float textLeft = UiEditTextLeft(rect, edit, font, scale, innerWidth); + const gd::RectF clipRect(innerLeft, rect.Y + 1.0f * scale, innerWidth, rect.Height - 2.0f * scale); + if (text.empty()) { // 空内容仍绘制插入符,保持焦点可见。 - const float caretX = rect.X + 6.0f * scale; - UiDrawLine(g, caretX, rect.Y + 4.0f * scale, caretX, rect.Y + rect.Height - 4.0f * scale, + UiDrawLine(g, textLeft, rect.Y + 4.0f * scale, textLeft, rect.Y + rect.Height - 4.0f * scale, theme.accent, 1.4f * scale); return; } - const float innerLeft = rect.X + 6.0f * scale; - const float innerWidth = std::max(4.0f, rect.Width - 12.0f * scale); - // 光标与选区端点对应的像素位置(以文本左端为原点)。 - const float caretX = UiTextWidth(g, text.substr(0, edit.Caret()), font); + const float caretX = UiMeasureTextWidth(font, text.substr(0, edit.Caret())); const float selBeginX = - edit.HasSelection() ? UiTextWidth(g, text.substr(0, edit.SelectionBegin()), font) : 0.0f; + edit.HasSelection() ? UiMeasureTextWidth(font, text.substr(0, edit.SelectionBegin())) : 0.0f; const float selEndX = - edit.HasSelection() ? UiTextWidth(g, text.substr(0, edit.SelectionEnd()), font) : 0.0f; - const float textWidth = UiTextWidth(g, text, font); + edit.HasSelection() ? UiMeasureTextWidth(font, text.substr(0, edit.SelectionEnd())) : 0.0f; - // 内容超出可视宽度时横向滚动,保证光标始终可见(向常规输入框靠齐)。 - float scroll = 0.0f; - if (textWidth > innerWidth) - { - if (caretX > innerWidth) - { - scroll = caretX - innerWidth; - } - const float maximum = textWidth - innerWidth; - scroll = std::max(0.0f, std::min(scroll, maximum)); - } + const bool fullSelection = + edit.HasSelection() && edit.SelectionBegin() == 0 && edit.SelectionEnd() == text.size(); - 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(); + // 选区高亮绘制在文本之下,整段选中时呈现“已全选”观感; + // 叠加上 GDI+ 裁剪,高亮同样不会越过输入框边界。 if (edit.HasSelection() && selEndX > selBeginX) { - const gd::RectF highlight(textLeft + selBeginX, rect.Y + 3.0f * scale, - selEndX - selBeginX, rect.Height - 6.0f * scale); + 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); + // 长文本用 ETO_CLIPPING 交给 GDI 裁剪,避免字符画到输入框之外。 + UiTextAtClipped(g, text, font, textLeft, rect, + fullSelection ? theme.textOnAccent : theme.textStrong, clipRect); - if (edit.HasSelection() && !fullSelection && selEndX > selBeginX) + if (!fullSelection && edit.HasSelection() && 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); + UiTextAtClipped(g, selected, font, textLeft + selBeginX, rect, theme.textOnAccent, clipRect); } // 插入符。 @@ -516,6 +593,36 @@ namespace jdi g.Restore(saveState); } + bool UiEditFieldMouseDown(HFONT font, const gd::RectF& rect, TextEdit& edit, float scale, int x, int y) + { + if (!edit.Active() || font == nullptr || rect.Width <= 0.0f) + { + return false; + } + if (!UiPointInRect(rect, static_cast(x), static_cast(y))) + { + return false; + } + + // 用与绘制完全相同的布局函数换算文本起点,保证点哪儿光标就落在哪儿。 + float innerWidth = 0.0f; + const float textLeft = UiEditTextLeft(rect, edit, font, scale, innerWidth); + edit.BeginDrag(UiEditOffsetAt(font, edit.Text(), static_cast(x) - textLeft)); + return true; + } + + bool UiEditFieldMouseMove(HFONT font, const gd::RectF& rect, TextEdit& edit, float scale, int x) + { + if (!edit.Active() || !edit.Dragging() || font == nullptr || rect.Width <= 0.0f) + { + return false; + } + + float innerWidth = 0.0f; + const float textLeft = UiEditTextLeft(rect, edit, font, scale, innerWidth); + return edit.UpdateDrag(UiEditOffsetAt(font, edit.Text(), static_cast(x) - textLeft)); + } + bool UiSwatchNeedsOutline(float opacity) { return !(opacity >= kSwatchOutlineOpacity); diff --git a/Utilities/Src/PopupMenu.cpp b/Utilities/Src/PopupMenu.cpp index 90afe38..541deb9 100644 --- a/Utilities/Src/PopupMenu.cpp +++ b/Utilities/Src/PopupMenu.cpp @@ -277,6 +277,7 @@ namespace jdi result.scope = m_scope; result.id = item.id; result.swatchIndex = -1; + result.itemRect = m_itemRects[static_cast(m_hoverIndex)]; return true; } @@ -353,6 +354,7 @@ namespace jdi result.scope = m_scope; result.id = item.id; result.swatchIndex = -1; + result.itemRect = m_itemRects[static_cast(index)]; if (item.kind == MenuItemKind::SWATCHES) { diff --git a/Utilities/Src/TextEdit.cpp b/Utilities/Src/TextEdit.cpp index 6612d1f..61d136f 100644 --- a/Utilities/Src/TextEdit.cpp +++ b/Utilities/Src/TextEdit.cpp @@ -71,6 +71,7 @@ namespace jdi { m_active = false; m_fresh = false; + m_dragging = false; m_text.clear(); m_caret = 0; m_anchor = 0; @@ -321,6 +322,50 @@ namespace jdi return changed; } + bool TextEdit::PlaceCaret(std::size_t offset) + { + if (!m_active) + { + return false; + } + + const std::size_t target = ClampOffset(offset); + const bool changed = HasSelection() || (m_caret != target); + m_caret = target; + m_anchor = target; + m_fresh = false; + return changed; + } + + void TextEdit::BeginDrag(std::size_t offset) + { + if (!m_active) + { + return; + } + const std::size_t target = ClampOffset(offset); + m_caret = target; + m_anchor = target; + m_fresh = false; + m_dragging = true; + } + + bool TextEdit::UpdateDrag(std::size_t offset) + { + if (!m_active || !m_dragging) + { + return false; + } + const std::size_t target = ClampOffset(offset); + if (target == m_caret) + { + return false; + } + m_caret = target; + m_fresh = false; + return true; + } + bool TextEdit::CopyToClipboard(HWND owner) const { if (m_text.empty())