| @@ -1645,6 +1645,84 @@ LogicEditorResult LogicEditorService::setVerticalConnection( | |||||
| connected); | 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( | LogicEditorResult LogicEditorService::setVerticalConnectionRange( | ||||
| const std::string &logic_id, | const std::string &logic_id, | ||||
| const std::string &first_rung_id, | const std::string &first_rung_id, | ||||
| @@ -1672,6 +1750,35 @@ LogicEditorResult LogicEditorService::setVerticalConnectionRange( | |||||
| std::swap(first, last); | 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(); | HistoryState before = captureState(); | ||||
| const bool modified_before = project_service_.isModified(); | const bool modified_before = project_service_.isModified(); | ||||
| Project &project = project_service_.editProject(); | Project &project = project_service_.editProject(); | ||||
| @@ -127,6 +127,14 @@ struct LogicEditResult | |||||
| LogicEditCursor nextCursor; | LogicEditCursor nextCursor; | ||||
| }; | }; | ||||
| struct LogicVerticalEditResult | |||||
| { | |||||
| LogicEditorResult edit; | |||||
| std::string nextRungId; | |||||
| int columnBoundary = -1; | |||||
| bool changed = false; | |||||
| }; | |||||
| struct LogicSyntaxLocation | struct LogicSyntaxLocation | ||||
| { | { | ||||
| std::string logicId; | std::string logicId; | ||||
| @@ -254,6 +262,10 @@ public: | |||||
| const std::string &lower_rung_id, | const std::string &lower_rung_id, | ||||
| int column_boundary, | int column_boundary, | ||||
| bool connected); | bool connected); | ||||
| LogicVerticalEditResult applyVerticalConnectionAndAdvance( | |||||
| const std::string &logic_id, | |||||
| const std::string &upper_rung_id, | |||||
| int column_boundary); | |||||
| LogicEditorResult setVerticalConnectionRange( | LogicEditorResult setVerticalConnectionRange( | ||||
| const std::string &logic_id, | const std::string &logic_id, | ||||
| const std::string &first_rung_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( | LogicEditorResult LogicEditorWidget::finishCursorEdit( | ||||
| const LogicEditResult &result) | const LogicEditResult &result) | ||||
| { | { | ||||
| @@ -2412,36 +2455,30 @@ LogicEditorResult LogicEditorWidget::addHorizontalWire() | |||||
| LogicEditorResult LogicEditorWidget::addVerticalWire() | 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(); | const std::string upper = selectedRungId(); | ||||
| if (upper.empty() || selected_column_ < 0 | if (upper.empty() || selected_column_ < 0 | ||||
| || selected_column_ > ProjectLimits::kMaximumConditionColumns) | |||||
| || selected_column_ > ProjectLimits::kMaximumConditionColumns | |||||
| || (!selected_cell_ && !selected_output_ && !selected_boundary_)) | |||||
| { | { | ||||
| return {false, LogicEditorError::InvalidOperation, | 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 | else | ||||
| { | { | ||||
| reportFailure(result); | |||||
| reportFailure(result.edit); | |||||
| } | } | ||||
| return result; | |||||
| return result.edit; | |||||
| } | } | ||||
| LogicEditorResult LogicEditorWidget::deleteHorizontalWire() | LogicEditorResult LogicEditorWidget::deleteHorizontalWire() | ||||
| @@ -121,6 +121,7 @@ private: | |||||
| const LogicEditCursor &cursor) const; | const LogicEditCursor &cursor) const; | ||||
| LogicEditCursor conditionInsertionCursor() const; | LogicEditCursor conditionInsertionCursor() const; | ||||
| void moveToCursor(const LogicEditCursor &cursor); | void moveToCursor(const LogicEditCursor &cursor); | ||||
| void moveToVerticalTarget(const LogicVerticalEditResult &result); | |||||
| LogicEditorResult finishCursorEdit(const LogicEditResult &result); | LogicEditorResult finishCursorEdit(const LogicEditResult &result); | ||||
| void rebuildScene(); | void rebuildScene(); | ||||
| void selectObject(const Hit &hit, bool extend_node_selection = false); | void selectObject(const Hit &hit, bool extend_node_selection = false); | ||||
| @@ -1710,7 +1710,10 @@ void MainWindow::addLogicVerticalWire() | |||||
| selected_logic_node_id_.clear(); | selected_logic_node_id_.clear(); | ||||
| showLogicNodeProperties({}); | showLogicNodeProperties({}); | ||||
| refreshProjectUi(); | refreshProjectUi(); | ||||
| statusBar()->showMessage(tr("竖线连接已建立"), 3000); | |||||
| statusBar()->showMessage( | |||||
| result.message.empty() | |||||
| ? tr("竖线连接已建立") : fromUtf8(result.message), | |||||
| 3000); | |||||
| } | } | ||||
| void MainWindow::deleteLogicHorizontalWire() | void MainWindow::deleteLogicHorizontalWire() | ||||
| @@ -1206,7 +1206,7 @@ | |||||
| <string>竖线</string> | <string>竖线</string> | ||||
| </property> | </property> | ||||
| <property name="toolTip"> | <property name="toolTip"> | ||||
| <string>在当前行与下一行的选中列边界建立一段竖线连接</string> | |||||
| <string>在当前行与下一行的选中列边界建立竖线,成功后自动下移到下一行</string> | |||||
| </property> | </property> | ||||
| <property name="shortcut"> | <property name="shortcut"> | ||||
| <string>F12</string> | <string>F12</string> | ||||
| @@ -136,6 +136,22 @@ void testIndependentVerticalConnectionsAndNetworkSplit() | |||||
| && connectionAt(*logic, second, third, 4) != nullptr, | && connectionAt(*logic, second, third, 4) != nullptr, | ||||
| "a long vertical line must be represented by independent segments"); | "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 = | const std::string first_segment = | ||||
| connectionAt(*logic, first, second, 4)->id; | connectionAt(*logic, first, second, 4)->id; | ||||
| require( | require( | ||||
| @@ -31,12 +31,14 @@ | |||||
| #include <QComboBox> | #include <QComboBox> | ||||
| #include <QMouseEvent> | #include <QMouseEvent> | ||||
| #include <QPainter> | #include <QPainter> | ||||
| #include <QScrollBar> | |||||
| #include <QWidget> | #include <QWidget> | ||||
| #include <algorithm> | #include <algorithm> | ||||
| #include <iostream> | #include <iostream> | ||||
| #include <stdexcept> | #include <stdexcept> | ||||
| #include <string> | #include <string> | ||||
| #include <vector> | |||||
| namespace { | namespace { | ||||
| @@ -781,6 +783,95 @@ void testCursorAdvanceAndInlineCommandInput() | |||||
| "an inline output must append a row and keep continuous input active"); | "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() | void testSegmentLevelTraceProjection() | ||||
| { | { | ||||
| TestProjectStorage storage; | TestProjectStorage storage; | ||||
| @@ -987,6 +1078,7 @@ int main(int argc, char *argv[]) | |||||
| testLogicEditorGridSelectionAndDeletion(); | testLogicEditorGridSelectionAndDeletion(); | ||||
| testLadderLayoutAndDragDeletion(); | testLadderLayoutAndDragDeletion(); | ||||
| testCursorAdvanceAndInlineCommandInput(); | testCursorAdvanceAndInlineCommandInput(); | ||||
| testVerticalWireShortcutAdvancesDownward(); | |||||
| testSegmentLevelTraceProjection(); | testSegmentLevelTraceProjection(); | ||||
| testLogicClipboardUsesExplicitObjectAndRowSelection(); | testLogicClipboardUsesExplicitObjectAndRowSelection(); | ||||
| } | } | ||||