| @@ -1645,6 +1645,84 @@ LogicEditorResult LogicEditorService::setVerticalConnection( | |||
| connected); | |||
| } | |||
| LogicVerticalEditResult LogicEditorService::applyVerticalConnectionAndAdvance( | |||
| const std::string &logic_id, | |||
| const std::string &upper_rung_id, | |||
| int column_boundary) | |||
| { | |||
| const ControlLogic *logic = findLogic(logic_id); | |||
| if (logic == nullptr) | |||
| { | |||
| return { | |||
| failure(LogicEditorError::LogicNotFound, "未找到控制逻辑"), | |||
| {}, | |||
| -1, | |||
| false}; | |||
| } | |||
| if (column_boundary < 0 | |||
| || column_boundary > ProjectLimits::kMaximumConditionColumns) | |||
| { | |||
| return { | |||
| failure( | |||
| LogicEditorError::InvalidOperation, | |||
| "请先选择一个列边界或网格,再插入竖线"), | |||
| {}, | |||
| -1, | |||
| false}; | |||
| } | |||
| const std::size_t upper = rungIndex(*logic, upper_rung_id); | |||
| if (upper == logic->rungs.size()) | |||
| { | |||
| return { | |||
| failure(LogicEditorError::RungNotFound, "未找到梯形图行"), | |||
| {}, | |||
| -1, | |||
| false}; | |||
| } | |||
| if (upper + 1U >= logic->rungs.size()) | |||
| { | |||
| return { | |||
| failure( | |||
| LogicEditorError::InvalidOperation, | |||
| "当前已经是末行,无法继续建立竖线"), | |||
| {}, | |||
| -1, | |||
| false}; | |||
| } | |||
| const std::string lower_rung_id = logic->rungs[upper + 1U].id; | |||
| const bool already_connected = std::any_of( | |||
| logic->verticalConnections.cbegin(), | |||
| logic->verticalConnections.cend(), | |||
| [&upper_rung_id, &lower_rung_id, column_boundary]( | |||
| const VerticalConnection &connection) | |||
| { | |||
| return connectionMatches( | |||
| connection, | |||
| upper_rung_id, | |||
| lower_rung_id, | |||
| column_boundary); | |||
| }); | |||
| LogicEditorResult edit = setVerticalConnection( | |||
| logic_id, | |||
| upper_rung_id, | |||
| lower_rung_id, | |||
| column_boundary, | |||
| true); | |||
| if (!edit.succeeded) | |||
| { | |||
| return {std::move(edit), {}, -1, false}; | |||
| } | |||
| edit.message = already_connected | |||
| ? "竖线已经存在,已移至下一行" | |||
| : "竖线连接已建立,已移至下一行"; | |||
| return { | |||
| std::move(edit), | |||
| lower_rung_id, | |||
| column_boundary, | |||
| !already_connected}; | |||
| } | |||
| LogicEditorResult LogicEditorService::setVerticalConnectionRange( | |||
| const std::string &logic_id, | |||
| const std::string &first_rung_id, | |||
| @@ -1672,6 +1750,35 @@ LogicEditorResult LogicEditorService::setVerticalConnectionRange( | |||
| std::swap(first, last); | |||
| } | |||
| bool changed = false; | |||
| for (std::size_t row = first; row < last; ++row) | |||
| { | |||
| const std::string &upper_id = logic->rungs[row].id; | |||
| const std::string &lower_id = logic->rungs[row + 1U].id; | |||
| const bool exists = std::any_of( | |||
| logic->verticalConnections.cbegin(), | |||
| logic->verticalConnections.cend(), | |||
| [&upper_id, &lower_id, column_boundary]( | |||
| const VerticalConnection &connection) | |||
| { | |||
| return connectionMatches( | |||
| connection, upper_id, lower_id, column_boundary); | |||
| }); | |||
| if (exists != connected) | |||
| { | |||
| changed = true; | |||
| break; | |||
| } | |||
| } | |||
| if (!changed) | |||
| { | |||
| return { | |||
| true, | |||
| LogicEditorError::None, | |||
| connected ? "竖线连接已经存在" : "目标位置没有竖线", | |||
| first_rung_id}; | |||
| } | |||
| HistoryState before = captureState(); | |||
| const bool modified_before = project_service_.isModified(); | |||
| Project &project = project_service_.editProject(); | |||
| @@ -127,6 +127,14 @@ struct LogicEditResult | |||
| LogicEditCursor nextCursor; | |||
| }; | |||
| struct LogicVerticalEditResult | |||
| { | |||
| LogicEditorResult edit; | |||
| std::string nextRungId; | |||
| int columnBoundary = -1; | |||
| bool changed = false; | |||
| }; | |||
| struct LogicSyntaxLocation | |||
| { | |||
| std::string logicId; | |||
| @@ -254,6 +262,10 @@ public: | |||
| const std::string &lower_rung_id, | |||
| int column_boundary, | |||
| bool connected); | |||
| LogicVerticalEditResult applyVerticalConnectionAndAdvance( | |||
| const std::string &logic_id, | |||
| const std::string &upper_rung_id, | |||
| int column_boundary); | |||
| LogicEditorResult setVerticalConnectionRange( | |||
| const std::string &logic_id, | |||
| const std::string &first_rung_id, | |||
| @@ -2092,6 +2092,49 @@ void LogicEditorWidget::moveToCursor(const LogicEditCursor &cursor) | |||
| } | |||
| } | |||
| void LogicEditorWidget::moveToVerticalTarget( | |||
| const LogicVerticalEditResult &result) | |||
| { | |||
| const bool keep_cell = selected_cell_; | |||
| const bool keep_output = selected_output_; | |||
| const bool keep_boundary = selected_boundary_; | |||
| clearObjectSelection(); | |||
| selected_rung_id_ = result.nextRungId; | |||
| selected_column_ = result.columnBoundary; | |||
| selected_cell_ = keep_cell; | |||
| selected_output_ = keep_output; | |||
| selected_boundary_ = keep_boundary; | |||
| rebuildScene(); | |||
| notifySelectionChanged(); | |||
| const RowLayout *layout = layoutForRung(result.nextRungId); | |||
| if (layout == nullptr) | |||
| { | |||
| return; | |||
| } | |||
| qreal center_x = kLeftBus | |||
| + (static_cast<qreal>(result.columnBoundary) + 0.5) * kCellWidth; | |||
| qreal target_width = kCellWidth; | |||
| if (keep_boundary) | |||
| { | |||
| center_x = kLeftBus | |||
| + static_cast<qreal>(result.columnBoundary) * kCellWidth; | |||
| } | |||
| else if (keep_output) | |||
| { | |||
| center_x = kConditionRight + kOutputWidth / 2.0; | |||
| target_width = kOutputWidth; | |||
| } | |||
| ensureVisible( | |||
| QRectF( | |||
| center_x - target_width / 2.0, | |||
| layout->centerY - kRowHeight / 2.0, | |||
| target_width, | |||
| kRowHeight), | |||
| 24, | |||
| 24); | |||
| } | |||
| LogicEditorResult LogicEditorWidget::finishCursorEdit( | |||
| const LogicEditResult &result) | |||
| { | |||
| @@ -2412,36 +2455,30 @@ LogicEditorResult LogicEditorWidget::addHorizontalWire() | |||
| LogicEditorResult LogicEditorWidget::addVerticalWire() | |||
| { | |||
| const ControlLogic *logic = editor_service_.findLogic(logic_id_); | |||
| if (logic == nullptr || logic->rungs.size() < 2U) | |||
| { | |||
| return {false, LogicEditorError::InvalidOperation, "至少需要两行才能连接竖线", {}}; | |||
| } | |||
| const std::string upper = selectedRungId(); | |||
| if (upper.empty() || selected_column_ < 0 | |||
| || selected_column_ > ProjectLimits::kMaximumConditionColumns) | |||
| || selected_column_ > ProjectLimits::kMaximumConditionColumns | |||
| || (!selected_cell_ && !selected_output_ && !selected_boundary_)) | |||
| { | |||
| return {false, LogicEditorError::InvalidOperation, | |||
| "请先选择一个列边界或网格,再插入竖线", {}}; | |||
| } | |||
| const int index = rowAt(upper); | |||
| if (index < 0 || static_cast<std::size_t>(index + 1) >= logic->rungs.size()) | |||
| { | |||
| return {false, LogicEditorError::InvalidOperation, "请选择非末行作为连接起点", {}}; | |||
| } | |||
| const LogicEditorResult result = editor_service_.setVerticalConnection( | |||
| logic_id_, upper, logic->rungs[static_cast<std::size_t>(index + 1)].id, | |||
| selected_column_, true); | |||
| if (result.succeeded) | |||
| const LogicVerticalEditResult result = | |||
| editor_service_.applyVerticalConnectionAndAdvance( | |||
| logic_id_, upper, selected_column_); | |||
| if (result.edit.succeeded) | |||
| { | |||
| rebuildScene(); | |||
| emit graphChanged(); | |||
| moveToVerticalTarget(result); | |||
| if (result.changed) | |||
| { | |||
| emit graphChanged(); | |||
| } | |||
| } | |||
| else | |||
| { | |||
| reportFailure(result); | |||
| reportFailure(result.edit); | |||
| } | |||
| return result; | |||
| return result.edit; | |||
| } | |||
| LogicEditorResult LogicEditorWidget::deleteHorizontalWire() | |||
| @@ -121,6 +121,7 @@ private: | |||
| const LogicEditCursor &cursor) const; | |||
| LogicEditCursor conditionInsertionCursor() const; | |||
| void moveToCursor(const LogicEditCursor &cursor); | |||
| void moveToVerticalTarget(const LogicVerticalEditResult &result); | |||
| LogicEditorResult finishCursorEdit(const LogicEditResult &result); | |||
| void rebuildScene(); | |||
| void selectObject(const Hit &hit, bool extend_node_selection = false); | |||
| @@ -1710,7 +1710,10 @@ void MainWindow::addLogicVerticalWire() | |||
| selected_logic_node_id_.clear(); | |||
| showLogicNodeProperties({}); | |||
| refreshProjectUi(); | |||
| statusBar()->showMessage(tr("竖线连接已建立"), 3000); | |||
| statusBar()->showMessage( | |||
| result.message.empty() | |||
| ? tr("竖线连接已建立") : fromUtf8(result.message), | |||
| 3000); | |||
| } | |||
| void MainWindow::deleteLogicHorizontalWire() | |||
| @@ -1206,7 +1206,7 @@ | |||
| <string>竖线</string> | |||
| </property> | |||
| <property name="toolTip"> | |||
| <string>在当前行与下一行的选中列边界建立一段竖线连接</string> | |||
| <string>在当前行与下一行的选中列边界建立竖线,成功后自动下移到下一行</string> | |||
| </property> | |||
| <property name="shortcut"> | |||
| <string>F12</string> | |||
| @@ -136,6 +136,22 @@ void testIndependentVerticalConnectionsAndNetworkSplit() | |||
| && connectionAt(*logic, second, third, 4) != nullptr, | |||
| "a long vertical line must be represented by independent segments"); | |||
| fixture.editor.clearHistory(); | |||
| fixture.projects.restoreModifiedState(false); | |||
| const LogicVerticalEditResult duplicate = | |||
| fixture.editor.applyVerticalConnectionAndAdvance( | |||
| fixture.logicId, first, 4); | |||
| require( | |||
| duplicate.edit.succeeded | |||
| && !duplicate.changed | |||
| && duplicate.nextRungId == second | |||
| && duplicate.columnBoundary == 4 | |||
| && fixture.editor.findLogic(fixture.logicId) | |||
| ->verticalConnections.size() == 2U | |||
| && !fixture.projects.isModified() | |||
| && !fixture.editor.canUndo(), | |||
| "an existing vertical edge must advance without dirty state or empty history"); | |||
| const std::string first_segment = | |||
| connectionAt(*logic, first, second, 4)->id; | |||
| require( | |||
| @@ -31,12 +31,14 @@ | |||
| #include <QComboBox> | |||
| #include <QMouseEvent> | |||
| #include <QPainter> | |||
| #include <QScrollBar> | |||
| #include <QWidget> | |||
| #include <algorithm> | |||
| #include <iostream> | |||
| #include <stdexcept> | |||
| #include <string> | |||
| #include <vector> | |||
| namespace { | |||
| @@ -781,6 +783,95 @@ void testCursorAdvanceAndInlineCommandInput() | |||
| "an inline output must append a row and keep continuous input active"); | |||
| } | |||
| void testVerticalWireShortcutAdvancesDownward() | |||
| { | |||
| TestProjectStorage storage; | |||
| ProjectService project_service(storage); | |||
| LogicEditorService editor(project_service); | |||
| const std::string logic_id = editor.ensureDefaultLogic().id; | |||
| std::vector<std::string> rung_ids; | |||
| for (int row = 0; row < 4; ++row) | |||
| { | |||
| rung_ids.push_back(editor.addRung(logic_id).id); | |||
| } | |||
| editor.clearHistory(); | |||
| LogicEditorWidget widget(editor); | |||
| widget.setLogicId(logic_id); | |||
| widget.resize(1320, 240); | |||
| widget.show(); | |||
| QCoreApplication::processEvents(QEventLoop::AllEvents); | |||
| constexpr int boundary = 4; | |||
| constexpr qreal left_bus = 60.0; | |||
| constexpr qreal cell_width = 96.0; | |||
| constexpr qreal first_grid_top = 46.0; | |||
| constexpr qreal row_height = 78.0; | |||
| const QPoint point = widget.mapFromScene(QPointF( | |||
| left_bus + (static_cast<qreal>(boundary) + 0.5) * cell_width, | |||
| first_grid_top + row_height / 2.0)); | |||
| QMouseEvent press( | |||
| QEvent::MouseButtonPress, | |||
| QPointF(point), | |||
| Qt::LeftButton, | |||
| Qt::LeftButton, | |||
| Qt::NoModifier); | |||
| QApplication::sendEvent(widget.viewport(), &press); | |||
| QMouseEvent release( | |||
| QEvent::MouseButtonRelease, | |||
| QPointF(point), | |||
| Qt::LeftButton, | |||
| Qt::NoButton, | |||
| Qt::NoModifier); | |||
| QApplication::sendEvent(widget.viewport(), &release); | |||
| require(widget.selectedRungId() == rung_ids.front(), | |||
| "the vertical shortcut fixture must select the first row"); | |||
| for (std::size_t row = 0U; row + 1U < rung_ids.size(); ++row) | |||
| { | |||
| const LogicEditorResult result = widget.addVerticalWire(); | |||
| require( | |||
| result.succeeded | |||
| && widget.selectedRungId() == rung_ids[row + 1U], | |||
| "each vertical shortcut must advance to the next row"); | |||
| const ControlLogic *logic = editor.findLogic(logic_id); | |||
| const bool connected = std::any_of( | |||
| logic->verticalConnections.cbegin(), | |||
| logic->verticalConnections.cend(), | |||
| [&rung_ids, row, boundary](const VerticalConnection &connection) | |||
| { | |||
| return connection.upperRungId == rung_ids[row] | |||
| && connection.lowerRungId == rung_ids[row + 1U] | |||
| && connection.columnBoundary == boundary; | |||
| }); | |||
| require(connected, | |||
| "repeated vertical shortcuts must stay on one column boundary"); | |||
| } | |||
| require( | |||
| editor.findLogic(logic_id)->verticalConnections.size() == 3U | |||
| && widget.verticalScrollBar()->value() > 0, | |||
| "the vertical shortcut must create three segments and keep the target visible"); | |||
| const LogicEditorResult at_last_row = widget.addVerticalWire(); | |||
| require( | |||
| !at_last_row.succeeded | |||
| && at_last_row.message.find("末行") != std::string::npos | |||
| && editor.findLogic(logic_id)->verticalConnections.size() == 3U | |||
| && widget.selectedRungId() == rung_ids.back(), | |||
| "the vertical shortcut must stop cleanly at the last existing row"); | |||
| for (int remaining = 2; remaining >= 0; --remaining) | |||
| { | |||
| require( | |||
| editor.undo().succeeded | |||
| && editor.findLogic(logic_id)->verticalConnections.size() | |||
| == static_cast<std::size_t>(remaining), | |||
| "each undo must remove exactly one shortcut-created vertical segment"); | |||
| } | |||
| require(!editor.canUndo(), | |||
| "three vertical shortcuts must create exactly three history entries"); | |||
| } | |||
| void testSegmentLevelTraceProjection() | |||
| { | |||
| TestProjectStorage storage; | |||
| @@ -987,6 +1078,7 @@ int main(int argc, char *argv[]) | |||
| testLogicEditorGridSelectionAndDeletion(); | |||
| testLadderLayoutAndDragDeletion(); | |||
| testCursorAdvanceAndInlineCommandInput(); | |||
| testVerticalWireShortcutAdvancesDownward(); | |||
| testSegmentLevelTraceProjection(); | |||
| testLogicClipboardUsesExplicitObjectAndRowSelection(); | |||
| } | |||