diff --git a/app/src/services/hmi_editor_service.cpp b/app/src/services/hmi_editor_service.cpp index 4a95bcb..180767e 100644 --- a/app/src/services/hmi_editor_service.cpp +++ b/app/src/services/hmi_editor_service.cpp @@ -601,6 +601,103 @@ HmiEditorResult HmiEditorService::updateControl( return {true, HmiEditorError::None, {}, control.id}; } +HmiEditorResult HmiEditorService::pasteControls( + const std::string &page_id, + const std::vector &controls, + int offset_x, + int offset_y) +{ + const HmiPage *existing_page = findPage(page_id); + if (existing_page == nullptr) + { + return failure(HmiEditorError::PageNotFound, "未找到 HMI 页面"); + } + if (controls.empty()) + { + return failure(HmiEditorError::InvalidOperation, "请先复制 HMI 控件"); + } + if (controls.size() > ProjectLimits::kMaximumHmiControlsPerPage + || existing_page->controls.size() + controls.size() + > ProjectLimits::kMaximumHmiControlsPerPage) + { + return failure(HmiEditorError::InvalidControl, "单个 HMI 页面最多包含 512 个控件"); + } + + // 让整组控件尽量保留相对位置,靠近页面边界时自动向页面内收拢 + int minimum_x = controls.front().bounds.x; + int minimum_y = controls.front().bounds.y; + int maximum_x = controls.front().bounds.x + controls.front().bounds.width; + int maximum_y = controls.front().bounds.y + controls.front().bounds.height; + for (const HmiControl &control : controls) + { + minimum_x = std::min(minimum_x, control.bounds.x); + minimum_y = std::min(minimum_y, control.bounds.y); + maximum_x = std::max( + maximum_x, control.bounds.x + control.bounds.width); + maximum_y = std::max( + maximum_y, control.bounds.y + control.bounds.height); + } + if (maximum_x - minimum_x > existing_page->width + || maximum_y - minimum_y > existing_page->height) + { + return failure(HmiEditorError::InvalidControl, "复制的控件尺寸超出目标 HMI 页面"); + } + int final_offset_x = offset_x; + int final_offset_y = offset_y; + if (minimum_x + final_offset_x < 0) + { + final_offset_x = -minimum_x; + } + if (minimum_y + final_offset_y < 0) + { + final_offset_y = -minimum_y; + } + if (maximum_x + final_offset_x > existing_page->width) + { + final_offset_x = existing_page->width - maximum_x; + } + if (maximum_y + final_offset_y > existing_page->height) + { + final_offset_y = existing_page->height - maximum_y; + } + + const HistoryState before = captureState(); + const bool modified_before = project_service_.isModified(); + Project &project = project_service_.editProject(); + auto target_page = std::find_if( + project.hmiPages.begin(), project.hmiPages.end(), + [&page_id](const HmiPage &candidate) { return candidate.id == page_id; }); + std::vector new_ids; + new_ids.reserve(controls.size()); + for (const HmiControl &source : controls) + { + const HmiControlDescriptor *descriptor = findHmiControlDescriptor(source.type); + if (descriptor == nullptr) + { + project.hmiPages = before.pages; + project.initialHmiPageId = before.initial_page_id; + project_service_.restoreModifiedState(modified_before); + return failure(HmiEditorError::InvalidControl, "不支持的 HMI 控件类型"); + } + HmiControl candidate = source; + candidate.id = makeUniqueId(*target_page, descriptor->idPrefix); + candidate.bounds.x += final_offset_x; + candidate.bounds.y += final_offset_y; + std::string error; + if (!validateEditableControl(*target_page, candidate, &error)) + { + project.hmiPages = before.pages; + project.initialHmiPageId = before.initial_page_id; + project_service_.restoreModifiedState(modified_before); + return failure(HmiEditorError::InvalidControl, error); + } + new_ids.push_back(candidate.id); + target_page->controls.push_back(std::move(candidate)); + } + recordHistory(before); + return {true, HmiEditorError::None, {}, new_ids.front()}; +} + bool HmiEditorService::canUndo() const { return history_.canUndo(); diff --git a/app/src/services/hmi_editor_service.h b/app/src/services/hmi_editor_service.h index d65f190..b768949 100644 --- a/app/src/services/hmi_editor_service.h +++ b/app/src/services/hmi_editor_service.h @@ -186,6 +186,20 @@ public: const std::string &control_id, const HmiControl &control); + /** + * @brief 批量粘贴 HMI 控件 + * @param page_id 目标页面唯一标识 + * @param controls 待复制的控件配置,不使用其中的 ID + * @param offset_x 控件整体横向偏移 + * @param offset_y 控件整体纵向偏移 + * @return 成功时返回第一个新控件 ID,失败时整批回滚 + */ + HmiEditorResult pasteControls( + const std::string &page_id, + const std::vector &controls, + int offset_x = 20, + int offset_y = 20); + /** @brief 判断是否存在可撤销的 HMI 编辑操作 */ bool canUndo() const; /** @brief 判断是否存在可重做的 HMI 编辑操作 */ diff --git a/app/src/services/logic_editor_service.cpp b/app/src/services/logic_editor_service.cpp index fbc9266..1691a79 100644 --- a/app/src/services/logic_editor_service.cpp +++ b/app/src/services/logic_editor_service.cpp @@ -6,6 +6,7 @@ #include #include #include +#include #include #include #include @@ -144,6 +145,40 @@ bool isSubset(const NodeIdSet &subset, const NodeIdSet &values) [&values](const std::string &value) { return values.count(value) != 0U; }); } +bool hasContiguousDirectNodeRange( + const ConditionExpression &expression, + const NodeIdSet &selected_ids) +{ + if (expression.kind == ConditionExpressionKind::Series) + { + std::vector matching_indices; + for (std::size_t index = 0; index < expression.children.size(); ++index) + { + const ConditionExpression &child = expression.children[index]; + if (child.kind == ConditionExpressionKind::Node + && selected_ids.count(child.node->id) != 0U) + { + matching_indices.push_back(index); + } + } + if (matching_indices.size() == selected_ids.size() + && !matching_indices.empty() + && matching_indices.back() - matching_indices.front() + 1U + == matching_indices.size()) + { + return true; + } + } + for (const ConditionExpression &child : expression.children) + { + if (hasContiguousDirectNodeRange(child, selected_ids)) + { + return true; + } + } + return false; +} + bool sameValues(const NodeIdSet &left, const NodeIdSet &right) { return left.size() == right.size() && isSubset(left, right); @@ -580,6 +615,10 @@ LogicEditorService::HistoryState LogicEditorService::captureState() const void LogicEditorService::recordHistory(HistoryState before) { + if (suppress_history_) + { + return; + } const HistoryState after = captureState(); history_.record(std::move(before), after, &LogicEditorService::statesEqual); } @@ -2178,6 +2217,7 @@ LogicEditorResult LogicEditorService::updateNodeConfig( { return {true, LogicEditorError::None, {}, node_id}; } + HistoryState before = captureState(); const bool modified_before = project_service_.isModified(); Project &project = project_service_.editProject(); @@ -2210,6 +2250,289 @@ LogicEditorResult LogicEditorService::updateNodeConfig( return failure(LogicEditorError::NodeNotFound, "未找到逻辑节点"); } +LogicEditorResult LogicEditorService::pasteConditionNodes( + const std::string &logic_id, + const std::string &rung_id, + const std::vector &nodes, + const LogicConditionPasteTarget &target) +{ + const ControlLogic *existing_logic = findLogic(logic_id); + if (existing_logic == nullptr) + { + return failure(LogicEditorError::LogicNotFound, "未找到控制逻辑"); + } + if (nodes.empty()) + { + return failure(LogicEditorError::InvalidOperation, "请先复制梯形图条件"); + } + if (nodes.size() > ProjectLimits::kMaximumExpressionNodesPerRung) + { + return failure(LogicEditorError::InvalidOperation, "复制的梯形图条件数量超出网络限制"); + } + for (const LogicNode &source : nodes) + { + if (!source.isCondition() || !source.validate()) + { + return failure(LogicEditorError::InvalidNode, "只能粘贴有效的梯形图条件指令"); + } + } + const LadderRung *existing_rung = rung_id.empty() + ? nullptr : findRung(logic_id, rung_id); + if (!rung_id.empty() && existing_rung == nullptr) + { + return failure(LogicEditorError::RungNotFound, "未找到梯形图网络"); + } + if (rung_id.empty() + && existing_logic->rungs.size() >= ProjectLimits::kMaximumRungsPerLogic) + { + return failure(LogicEditorError::InvalidOperation, "单组控制逻辑最多包含 1024 个网络"); + } + + const HistoryState before = captureState(); + const bool modified_before = project_service_.isModified(); + suppress_history_ = true; + std::string target_rung_id = rung_id; + std::vector new_ids; + new_ids.reserve(nodes.size()); + LogicEditorResult current; + for (std::size_t index = 0; index < nodes.size(); ++index) + { + const LogicNode &source = nodes[index]; + if (index > 0U) + { + current = insertConditionAfter( + logic_id, target_rung_id, new_ids.back(), source.config); + } + else + { + switch (target.kind) + { + case LogicConditionPasteTargetKind::EmptyColumn: + current = insertConditionAtColumn( + logic_id, rung_id, target.column, source.config); + break; + case LogicConditionPasteTargetKind::BranchEmptyColumn: + current = insertConditionInBranchAtColumn( + logic_id, rung_id, target.expressionId, target.column, source.config); + break; + case LogicConditionPasteTargetKind::AfterNode: + current = insertConditionAfter( + logic_id, rung_id, target.expressionId, source.config); + break; + case LogicConditionPasteTargetKind::ReplaceWire: + current = replaceWireWithCondition( + logic_id, rung_id, target.expressionId, source.config); + break; + case LogicConditionPasteTargetKind::ReplaceWireColumn: + current = replaceWireColumnWithCondition( + logic_id, rung_id, target.expressionId, target.column, source.config); + break; + case LogicConditionPasteTargetKind::Append: + default: + current = appendCondition(logic_id, rung_id, source.config); + break; + } + } + if (!current.succeeded) + { + suppress_history_ = false; + rollbackEdit(before, modified_before); + return current; + } + if (target_rung_id.empty()) + { + target_rung_id = rungIdForNode(logic_id, current.id); + } + Project &editable_project = project_service_.editProject(); + LadderRung *editable_rung = findEditableRung( + editable_project, logic_id, target_rung_id); + LogicNode *editable_node = editable_rung != nullptr + && editable_rung->condition.has_value() + ? findConditionNode(*editable_rung->condition, current.id) : nullptr; + if (editable_node == nullptr) + { + suppress_history_ = false; + rollbackEdit(before, modified_before); + return failure(LogicEditorError::NodeNotFound, "粘贴后的梯形图节点无法定位"); + } + editable_node->configured = source.configured; + new_ids.push_back(current.id); + } + suppress_history_ = false; + recordHistory(before); + return {true, LogicEditorError::None, {}, new_ids.front()}; +} + +bool LogicEditorService::areConditionNodesContiguous( + const std::string &logic_id, + const std::string &rung_id, + const std::vector &node_ids) const +{ + const LadderRung *rung = findRung(logic_id, rung_id); + if (rung == nullptr || !rung->condition.has_value() || node_ids.empty()) + { + return false; + } + NodeIdSet selected_ids(node_ids.cbegin(), node_ids.cend()); + if (selected_ids.size() != node_ids.size()) + { + return false; + } + for (const std::string &node_id : node_ids) + { + if (findConditionNode(*rung->condition, node_id) == nullptr) + { + return false; + } + } + if (node_ids.size() == 1U) + { + return true; + } + return hasContiguousDirectNodeRange(*rung->condition, selected_ids); +} + +LogicEditorResult LogicEditorService::pasteRung( + const std::string &logic_id, + const LadderRung &source) +{ + const ControlLogic *existing_logic = findLogic(logic_id); + if (existing_logic == nullptr) + { + return failure(LogicEditorError::LogicNotFound, "未找到控制逻辑"); + } + std::string source_error; + if (!source.validate(&source_error)) + { + return failure(LogicEditorError::InvalidOperation, source_error); + } + if (existing_logic->rungs.size() >= ProjectLimits::kMaximumRungsPerLogic) + { + return failure(LogicEditorError::InvalidOperation, "单组控制逻辑最多包含 1024 个网络"); + } + + const HistoryState before = captureState(); + const bool modified_before = project_service_.isModified(); + Project &project = project_service_.editProject(); + auto editable_logic = std::find_if( + project.controlLogics.begin(), project.controlLogics.end(), + [&logic_id](const ControlLogic &candidate) { return candidate.id == logic_id; }); + NodeIdSet reserved_node_ids; + NodeIdSet reserved_expression_ids; + NodeIdSet reserved_wire_ids; + auto unique_node_id = [&editable_logic, &reserved_node_ids]( + const std::string &prefix) + { + for (std::size_t index = 1;; ++index) + { + const std::string candidate = prefix + '-' + std::to_string(index); + if (reserved_node_ids.count(candidate) == 0U + && std::none_of( + editable_logic->rungs.cbegin(), editable_logic->rungs.cend(), + [&candidate](const LadderRung &rung) + { + return (rung.output.has_value() && rung.output->id == candidate) + || (rung.condition.has_value() + && findConditionNode(*rung.condition, candidate) != nullptr); + })) + { + reserved_node_ids.insert(candidate); + return candidate; + } + } + }; + auto unique_wire_id = [&editable_logic, &reserved_wire_ids]() + { + for (std::size_t index = 1;; ++index) + { + const std::string candidate = "wire-" + std::to_string(index); + if (reserved_wire_ids.count(candidate) == 0U + && std::none_of( + editable_logic->rungs.cbegin(), editable_logic->rungs.cend(), + [&candidate](const LadderRung &rung) + { + return rung.condition.has_value() + && findConditionExpression(*rung.condition, candidate) != nullptr; + })) + { + reserved_wire_ids.insert(candidate); + return candidate; + } + } + }; + auto unique_expression_id = [&editable_logic, &reserved_expression_ids]() + { + for (std::size_t index = 1;; ++index) + { + const std::string candidate = "expression-" + std::to_string(index); + if (reserved_expression_ids.count(candidate) == 0U + && std::none_of( + editable_logic->rungs.cbegin(), editable_logic->rungs.cend(), + [&candidate](const LadderRung &rung) + { + return rung.condition.has_value() + && findConditionExpression(*rung.condition, candidate) != nullptr; + })) + { + reserved_expression_ids.insert(candidate); + return candidate; + } + } + }; + auto clone_node = [&unique_node_id](const LogicNode &source_node) + { + return LogicNode{ + unique_node_id(nodePrefix(source_node.config)), + source_node.config, + source_node.configured}; + }; + std::function clone_expression; + clone_expression = [&unique_wire_id, &unique_expression_id, &clone_node, &clone_expression]( + const ConditionExpression &source_expression) + { + ConditionExpression clone; + clone.kind = source_expression.kind; + if (source_expression.kind == ConditionExpressionKind::Node) + { + clone.node = clone_node(*source_expression.node); + clone.id = clone.node->id; + } + else if (source_expression.kind == ConditionExpressionKind::Wire) + { + clone.id = unique_wire_id(); + clone.wire = source_expression.wire; + } + else + { + clone.id = unique_expression_id(); + for (const ConditionExpression &child : source_expression.children) + { + clone.children.push_back(clone_expression(child)); + } + } + return clone; + }; + + LadderRung pasted = source; + pasted.id = makeUniqueRungId(*editable_logic); + if (source.condition.has_value()) + { + pasted.condition = clone_expression(*source.condition); + } + if (source.output.has_value()) + { + pasted.output = clone_node(*source.output); + } + std::string validation_error; + if (!pasted.validate(&validation_error)) + { + rollbackEdit(before, modified_before); + return failure(LogicEditorError::InvalidOperation, validation_error); + } + editable_logic->rungs.push_back(std::move(pasted)); + recordHistory(before); + return {true, LogicEditorError::None, {}, editable_logic->rungs.back().id}; +} LogicEditorResult LogicEditorService::removeNode( const std::string &logic_id, const std::string &node_id) { diff --git a/app/src/services/logic_editor_service.h b/app/src/services/logic_editor_service.h index 1fb4b06..38b1249 100644 --- a/app/src/services/logic_editor_service.h +++ b/app/src/services/logic_editor_service.h @@ -33,6 +33,23 @@ struct LogicEditorResult std::string id; // 成功时返回新建或更新对象的稳定 ID }; +enum class LogicConditionPasteTargetKind +{ + Append, + EmptyColumn, + BranchEmptyColumn, + AfterNode, + ReplaceWire, + ReplaceWireColumn +}; + +struct LogicConditionPasteTarget +{ + LogicConditionPasteTargetKind kind = LogicConditionPasteTargetKind::Append; + std::string expressionId; + int column = 0; +}; + // 负责把 UI 编辑命令转换为结构化表达式树操作,并维护撤销/重做 class LogicEditorService { @@ -191,6 +208,32 @@ public: const std::string &logic_id, const std::string &node_id, const LogicNodeConfig &config); + /** + * @brief 批量粘贴条件节点到目标网络末尾 + * @param logic_id 目标控制逻辑 + * @param rung_id 目标网络;为空时自动创建网络 + * @param nodes 待复制节点,只读取配置和 configured 状态 + * @return 成功时返回第一个新节点 ID,失败时整批回滚 + */ + LogicEditorResult pasteConditionNodes( + const std::string &logic_id, + const std::string &rung_id, + const std::vector &nodes, + const LogicConditionPasteTarget &target = {}); + /** 判断所选条件是否位于同一串联层级并且视觉连续 */ + bool areConditionNodesContiguous( + const std::string &logic_id, + const std::string &rung_id, + const std::vector &node_ids) const; + /** + * @brief 将整条网络复制到目标控制逻辑末尾 + * @param logic_id 目标控制逻辑 + * @param source 要复制的网络 + * @return 成功时返回新网络 ID,所有节点和表达式都会获得新 ID + */ + LogicEditorResult pasteRung( + const std::string &logic_id, + const LadderRung &source); /** @brief 删除指定节点并归一化受影响的表达式树 */ LogicEditorResult removeNode( const std::string &logic_id, const std::string &node_id); @@ -273,4 +316,5 @@ private: ProjectService &project_service_; // 不拥有的工程服务依赖 EditorHistory history_; // 当前编辑会话的撤销/重做历史 + bool suppress_history_ = false; // 复合粘贴期间由外层统一记录一次历史 }; diff --git a/app/src/ui/logic_editor_widget.cpp b/app/src/ui/logic_editor_widget.cpp index 7fafc2c..1215d10 100644 --- a/app/src/ui/logic_editor_widget.cpp +++ b/app/src/ui/logic_editor_widget.cpp @@ -1713,6 +1713,78 @@ LogicEditorResult LogicEditorWidget::setOutput( return result; } +LogicEditorResult LogicEditorWidget::pasteConditionNodes( + const std::vector &nodes) +{ + const std::vector> selected_empty_slots = + selectedEmptySlots(); + const std::vector> selected_wire_cells = + selectedWireCells(); + const std::vector selected_expressions = selectedExpressionIds(); + const std::vector selected_wires = selectedWireIds(); + const std::vector selected_ids = selectedNodeIds(); + LogicEditorResult result; + LogicConditionPasteTarget target; + if (selected_empty_slots.size() > 1U || selected_wire_cells.size() > 1U + || (!selected_empty_slots.empty() + && (!selected_wire_cells.empty() + || !selected_expressions.empty() || !selected_ids.empty())) + || (!selected_wire_cells.empty() + && (!selected_ids.empty() || selected_expressions.size() > 1U)) + || selected_expressions.size() > 1U || selected_wires.size() > 1U + || selected_ids.size() > 1U) + { + result = {false, LogicEditorError::InvalidOperation, + "粘贴梯形图条件时只能选择一个目标位置", {}}; + } + else + { + if (selected_empty_slots.size() == 1U) + { + const auto &slot = selected_empty_slots.front(); + target.kind = slot.first.empty() + ? LogicConditionPasteTargetKind::EmptyColumn + : LogicConditionPasteTargetKind::BranchEmptyColumn; + target.expressionId = slot.first; + target.column = slot.second; + } + else if (selected_wire_cells.size() == 1U) + { + target.kind = LogicConditionPasteTargetKind::ReplaceWireColumn; + target.expressionId = selected_wire_cells.front().first; + target.column = selected_wire_cells.front().second; + } + else if (selected_wires.size() == 1U) + { + target.kind = LogicConditionPasteTargetKind::ReplaceWire; + target.expressionId = selected_wires.front(); + } + else if (selected_ids.size() == 1U) + { + const LogicNode *selected_node = editor_service_.findNode( + logic_id_, selected_ids.front()); + if (selected_node != nullptr && selected_node->isCondition()) + { + target.kind = LogicConditionPasteTargetKind::AfterNode; + target.expressionId = selected_ids.front(); + } + } + result = editor_service_.pasteConditionNodes( + logic_id_, currentRungId(), nodes, target); + } + if (result.succeeded) + { + reloadLogic(); + selectNode(result.id); + emit graphChanged(); + } + else + { + reportFailure(result); + } + return result; +} + LogicEditorResult LogicEditorWidget::deleteSelected() { const std::vector expression_ids = selectedExpressionIds(); diff --git a/app/src/ui/logic_editor_widget.h b/app/src/ui/logic_editor_widget.h index 23f9af2..610d4e1 100644 --- a/app/src/ui/logic_editor_widget.h +++ b/app/src/ui/logic_editor_widget.h @@ -78,6 +78,8 @@ public: /** 设置当前网络的输出线圈 */ LogicEditorResult setOutput( const LogicNodeConfig &config, bool configured = false); + /** 按当前画布选择位置粘贴一组连续条件 */ + LogicEditorResult pasteConditionNodes(const std::vector &nodes); /** 删除当前选中的节点、线段或网络 */ LogicEditorResult deleteSelected(); diff --git a/app/src/ui/main_window.cpp b/app/src/ui/main_window.cpp index c804e62..42a3bf3 100644 --- a/app/src/ui/main_window.cpp +++ b/app/src/ui/main_window.cpp @@ -47,6 +47,8 @@ #include #include +#include + namespace { bool isTextEditingObject(QObject *object) @@ -70,6 +72,8 @@ bool isEditorShortcut(const QKeyEvent &event) { return event.matches(QKeySequence::Undo) || event.matches(QKeySequence::Redo) + || event.matches(QKeySequence::Copy) + || event.matches(QKeySequence::Paste) || (event.modifiers() == Qt::NoModifier && (event.key() == Qt::Key_Delete || event.key() == Qt::Key_Escape)); } @@ -366,6 +370,10 @@ void MainWindow::configureActions() this, &MainWindow::undoActiveEditor); connect(ui_->redoAction, &QAction::triggered, this, &MainWindow::redoActiveEditor); + connect(ui_->copyAction, &QAction::triggered, + this, &MainWindow::copyActiveSelection); + connect(ui_->pasteAction, &QAction::triggered, + this, &MainWindow::pasteActiveSelection); connect(ui_->deleteSelectionAction, &QAction::triggered, this, &MainWindow::deleteActiveSelection); connect(ui_->clearSelectionAction, &QAction::triggered, @@ -930,6 +938,160 @@ void MainWindow::redoActiveEditor() updateEditActions(); } +void MainWindow::copyActiveSelection() +{ + if (!runtime_mode_service_.policy().allowsProjectEditing + || isTextEditingObject(qApp->focusWidget())) + { + return; + } + if (ui_->editorTabWidget->currentWidget() == ui_->hmiEditorTab) + { + const std::vector ids = hmi_editor_widget_->selectedControlIds(); + std::vector controls; + controls.reserve(ids.size()); + for (const std::string &id : ids) + { + const HmiControl *control = hmi_editor_service_.findControl( + current_hmi_page_id_, id); + if (control != nullptr) + { + controls.push_back(*control); + } + } + if (controls.empty()) + { + return; + } + editor_clipboard_ = HmiClipboardData{std::move(controls), 0}; + statusBar()->showMessage(tr("已复制 %1 个 HMI 控件").arg(ids.size()), 3000); + } + else if (ui_->editorTabWidget->currentWidget() == ui_->logicEditorTab) + { + const std::vector ids = logic_editor_widget_->selectedNodeIds(); + if (!ids.empty()) + { + const std::string rung_id = logic_editor_widget_->selectedRungId(); + if (rung_id.empty()) + { + statusBar()->showMessage(tr("只能复制同一网络中的梯形图指令"), 3000); + return; + } + std::vector nodes; + nodes.reserve(ids.size()); + bool all_conditions = true; + for (const std::string &id : ids) + { + const LogicNode *node = logic_editor_service_.findNode( + current_logic_id_, id); + if (node == nullptr) + { + return; + } + all_conditions = all_conditions && node->isCondition(); + nodes.push_back(*node); + } + if (all_conditions + && logic_editor_service_.areConditionNodesContiguous( + current_logic_id_, rung_id, ids)) + { + editor_clipboard_ = LogicNodesClipboardData{std::move(nodes)}; + statusBar()->showMessage(tr("已复制 %1 个梯形图条件").arg(ids.size()), 3000); + } + else if (ids.size() == 1U && nodes.front().isOutput()) + { + editor_clipboard_ = LogicOutputClipboardData{nodes.front()}; + statusBar()->showMessage(tr("已复制梯形图输出指令"), 3000); + } + else + { + statusBar()->showMessage( + tr("只能复制同一串联层级中连续的条件,或单个输出指令"), 3000); + } + updateEditActions(); + return; + } + + const std::string rung_id = logic_editor_widget_->selectedRungId(); + const LadderRung *rung = logic_editor_service_.findRung( + current_logic_id_, rung_id); + if (rung != nullptr) + { + editor_clipboard_ = LogicRungClipboardData{*rung}; + statusBar()->showMessage(tr("已复制整条梯形图网络"), 3000); + } + } + updateEditActions(); +} + +void MainWindow::pasteActiveSelection() +{ + if (!runtime_mode_service_.policy().allowsProjectEditing + || isTextEditingObject(qApp->focusWidget())) + { + return; + } + if (ui_->editorTabWidget->currentWidget() == ui_->hmiEditorTab) + { + auto *data = std::get_if(&editor_clipboard_); + if (data == nullptr) + { + return; + } + const int offset = 20 * std::min(data->pasteCount + 1, 10); + const HmiEditorResult result = hmi_editor_service_.pasteControls( + current_hmi_page_id_, data->controls, offset, offset); + if (!result.succeeded) + { + showProjectResult(tr("粘贴 HMI 控件"), fromUtf8(result.message), false); + return; + } + ++data->pasteCount; + refreshProjectUi(); + hmi_editor_widget_->reloadPage(); + hmi_editor_widget_->selectControl(result.id); + showControlProperties(result.id); + statusBar()->showMessage(tr("已粘贴 HMI 控件"), 3000); + } + else if (ui_->editorTabWidget->currentWidget() == ui_->logicEditorTab) + { + LogicEditorResult result; + if (const auto *data = std::get_if(&editor_clipboard_)) + { + result = logic_editor_widget_->pasteConditionNodes(data->nodes); + if (result.succeeded) + { + selected_logic_node_id_ = result.id; + showLogicNodeProperties(result.id); + } + } + else if (const auto *data = std::get_if(&editor_clipboard_)) + { + result = logic_editor_widget_->setOutput( + data->output.config, data->output.configured); + } + else if (const auto *data = std::get_if(&editor_clipboard_)) + { + result = logic_editor_service_.pasteRung(current_logic_id_, data->rung); + if (result.succeeded) + { + logic_editor_widget_->reloadLogic(); + } + } + if (!result.succeeded) + { + if (!result.message.empty()) + { + showProjectResult(tr("粘贴梯形图"), fromUtf8(result.message), false); + } + return; + } + refreshProjectUi(); + statusBar()->showMessage(tr("已粘贴梯形图对象"), 3000); + } + updateEditActions(); +} + void MainWindow::deleteActiveSelection() { if (ui_->editorTabWidget->currentWidget() == ui_->hmiEditorTab) @@ -970,6 +1132,13 @@ void MainWindow::updateEditActions() ui_->redoAction->setEnabled( editable && ((hmi_active && hmi_editor_service_.canRedo()) || (logic_active && logic_editor_service_.canRedo()))); + ui_->copyAction->setEnabled(editable && (hmi_active || logic_active)); + const bool hmi_clipboard = std::holds_alternative(editor_clipboard_); + const bool logic_clipboard = std::holds_alternative(editor_clipboard_) + || std::holds_alternative(editor_clipboard_) + || std::holds_alternative(editor_clipboard_); + ui_->pasteAction->setEnabled(editable && ((hmi_active && hmi_clipboard) + || (logic_active && logic_clipboard))); ui_->deleteSelectionAction->setEnabled(editable && (hmi_active || logic_active)); ui_->clearSelectionAction->setEnabled(editable && (hmi_active || logic_active)); ui_->insertHorizontalWireAction->setEnabled(editable && logic_active); @@ -1318,6 +1487,7 @@ void MainWindow::createNewProject() clearEditorHistories(); selected_control_id_.clear(); selected_logic_node_id_.clear(); + editor_clipboard_ = std::monostate{}; current_hmi_page_id_ = project_service_.project().initialHmiPageId; current_logic_id_ = logic_editor_service_.firstLogicId(); refreshProjectUi(); @@ -1384,6 +1554,7 @@ void MainWindow::loadProject() clearEditorHistories(); selected_control_id_.clear(); selected_logic_node_id_.clear(); + editor_clipboard_ = std::monostate{}; current_hmi_page_id_ = project_service_.project().initialHmiPageId; current_logic_id_ = logic_editor_service_.firstLogicId(); refreshProjectUi(); diff --git a/app/src/ui/main_window.h b/app/src/ui/main_window.h index dd23a37..66d6614 100644 --- a/app/src/ui/main_window.h +++ b/app/src/ui/main_window.h @@ -16,6 +16,8 @@ #include #include +#include +#include QT_BEGIN_NAMESPACE class QAction; @@ -133,6 +135,10 @@ private: void undoActiveEditor(); /** 重做当前处于焦点的编辑器操作 */ void redoActiveEditor(); + /** 复制当前编辑画布中的选中对象 */ + void copyActiveSelection(); + /** 粘贴剪贴板中的对象到当前编辑画布 */ + void pasteActiveSelection(); /** 删除当前处于焦点的编辑器选中项 */ void deleteActiveSelection(); /** 清除当前处于焦点的编辑器选择 */ @@ -281,4 +287,29 @@ private: std::string current_logic_id_; /** 是否已经安排了待处理的 PLC 状态刷新 */ bool plc_status_update_pending_ = false; + + struct HmiClipboardData + { + std::vector controls; + int pasteCount = 0; + }; + struct LogicNodesClipboardData + { + std::vector nodes; + }; + struct LogicOutputClipboardData + { + LogicNode output; + }; + struct LogicRungClipboardData + { + LadderRung rung; + }; + using EditorClipboard = std::variant< + std::monostate, + HmiClipboardData, + LogicNodesClipboardData, + LogicOutputClipboardData, + LogicRungClipboardData>; + EditorClipboard editor_clipboard_; }; diff --git a/app/src/ui/main_window.ui b/app/src/ui/main_window.ui index 7817a20..fcc9cfc 100644 --- a/app/src/ui/main_window.ui +++ b/app/src/ui/main_window.ui @@ -237,6 +237,8 @@ + + @@ -1026,6 +1028,16 @@ 重做当前编辑器最近撤销的操作 Ctrl+Y + + 复制 + 复制当前编辑画布中选中的对象 + Ctrl+C + + + 粘贴 + 粘贴已复制的编辑对象 + Ctrl+V + 删除所选 删除当前画布中选中的对象 diff --git a/app/tests/hmi_editor_service_tests.cpp b/app/tests/hmi_editor_service_tests.cpp index eb78fd6..f1e4871 100644 --- a/app/tests/hmi_editor_service_tests.cpp +++ b/app/tests/hmi_editor_service_tests.cpp @@ -222,6 +222,46 @@ void testHistoryAndAtomicBatchDelete() "HMI history must retain exactly the configured 100 most recent steps"); } +void testBatchPasteControls() +{ + TestProjectStorage storage; + ProjectService project_service(storage); + HmiEditorService service(project_service); + const std::string page_id = service.ensureDefaultPage().id; + const HmiEditorResult first = service.addControl(page_id, HmiControlType::Label); + const HmiEditorResult second = service.addControl(page_id, HmiControlType::Label); + require(first.succeeded && second.succeeded, + "controls for paste testing must be created"); + + HmiControl first_copy = *service.findControl(page_id, first.id); + HmiControl second_copy = *service.findControl(page_id, second.id); + first_copy.bounds = {100, 80, 80, 32}; + first_copy.text = "复制标签"; + first_copy.properties[HmiAppearanceProperty::kTextColor] = "#E53935"; + second_copy.bounds = {220, 80, 80, 32}; + require(service.updateControl(page_id, first.id, first_copy).succeeded + && service.updateControl(page_id, second.id, second_copy).succeeded, + "source controls must be movable before paste"); + service.clearHistory(); + + const HmiEditorResult pasted = service.pasteControls( + page_id, {first_copy, second_copy}); + require(pasted.succeeded + && service.findPage(page_id)->controls.size() == 4U + && pasted.id != first.id && pasted.id != second.id, + "batch paste must create controls with fresh ids"); + const HmiControl *pasted_control = service.findControl(page_id, pasted.id); + require(pasted_control != nullptr + && pasted_control->bounds.x == first_copy.bounds.x + 20 + && pasted_control->bounds.y == first_copy.bounds.y + 20 + && pasted_control->text == first_copy.text + && pasted_control->properties == first_copy.properties, + "pasted controls must preserve content and appearance with an offset"); + require(service.undo().succeeded + && service.findPage(page_id)->controls.size() == 2U, + "batch control paste must be one undoable operation"); +} + void testAppearanceEditing() { TestProjectStorage storage; @@ -376,6 +416,7 @@ int main() // 编辑和运行场景分别验证服务层两条独立职责 testControlEditing(); testHistoryAndAtomicBatchDelete(); + testBatchPasteControls(); testAppearanceEditing(); testRuntimeUsesRegisterRepository(); testPageLifecycleAndNavigation(); diff --git a/app/tests/logic_editor_service_tests.cpp b/app/tests/logic_editor_service_tests.cpp index 90c671d..0b6d35e 100644 --- a/app/tests/logic_editor_service_tests.cpp +++ b/app/tests/logic_editor_service_tests.cpp @@ -63,6 +63,24 @@ int wireColumns(const ConditionExpression &expression) return columns; } +int conditionNodes(const ConditionExpression &expression) +{ + if (expression.kind == ConditionExpressionKind::Node) + { + return 1; + } + if (expression.kind == ConditionExpressionKind::Wire) + { + return 0; + } + int count = 0; + for (const ConditionExpression &child : expression.children) + { + count += conditionNodes(child); + } + return count; +} + void testEmptyLogicCreatesNetworksOnFirstEdit() { TestProjectStorage storage; @@ -997,6 +1015,101 @@ void testHistoryAndAtomicBatchDelete() (void)second; } +void testBatchPasteNodesAndRung() +{ + TestProjectStorage storage; + ProjectService project_service(storage); + LogicEditorService service(project_service); + const std::string logic_id = service.ensureDefaultLogic().id; + const std::string rung_id = makeEmptyRung(service, logic_id); + const LogicEditorResult first = service.appendCondition( + logic_id, rung_id, contact(10)); + const LogicEditorResult second = service.appendCondition( + logic_id, rung_id, contact(11)); + require(first.succeeded && second.succeeded, + "nodes for paste testing must be created"); + + const LogicNode first_copy = *service.findNode(logic_id, first.id); + const LogicNode second_copy = *service.findNode(logic_id, second.id); + service.clearHistory(); + const LogicEditorResult pasted = service.pasteConditionNodes( + logic_id, rung_id, {first_copy, second_copy}); + require(pasted.succeeded + && service.findNode(logic_id, pasted.id) != nullptr + && service.findRung(logic_id, rung_id)->condition.has_value() + && conditionNodes(*service.findRung(logic_id, rung_id)->condition) == 4U, + "batch condition paste must append fresh nodes"); + require(pasted.id != first.id && pasted.id != second.id + && service.undo().succeeded + && conditionNodes(*service.findRung(logic_id, rung_id)->condition) == 2U, + "batch condition paste must use fresh ids and one undo step"); + require(service.areConditionNodesContiguous( + logic_id, rung_id, {first.id, second.id}), + "adjacent nodes in one series must be copyable as a range"); + + const std::string wire_rung_id = makeEmptyRung(service, logic_id); + const LogicEditorResult wire = service.appendWire(logic_id, wire_rung_id, 3); + service.clearHistory(); + LogicConditionPasteTarget wire_target; + wire_target.kind = LogicConditionPasteTargetKind::ReplaceWireColumn; + wire_target.expressionId = wire.id; + wire_target.column = 1; + const LogicEditorResult pasted_on_wire = service.pasteConditionNodes( + logic_id, wire_rung_id, {first_copy, second_copy}, wire_target); + const LadderRung *wire_rung = service.findRung(logic_id, wire_rung_id); + require(pasted_on_wire.succeeded && wire_rung != nullptr + && wire_rung->condition.has_value() + && conditionNodes(*wire_rung->condition) == 2 + && conditionColumns(*wire_rung->condition) == 3, + "condition paste must replace the selected wire cell and consume following wire cells"); + require(service.undo().succeeded + && wireColumns(*service.findRung(logic_id, wire_rung_id)->condition) == 3, + "wire-targeted paste must be undone as one edit"); + + const std::string full_rung_id = makeEmptyRung(service, logic_id); + for (int address = 0; address < 9; ++address) + { + require(service.appendCondition(logic_id, full_rung_id, contact(address)).succeeded, + "nine conditions must fit before an atomic paste failure test"); + } + service.clearHistory(); + require(!service.pasteConditionNodes( + logic_id, full_rung_id, {first_copy, second_copy}).succeeded + && conditionNodes(*service.findRung(logic_id, full_rung_id)->condition) == 9 + && !service.canUndo(), + "a multi-node paste that exceeds ten columns must roll back completely"); + + const LogicEditorResult third = service.appendCondition( + logic_id, rung_id, contact(12)); + require(third.succeeded + && !service.areConditionNodesContiguous( + logic_id, rung_id, {first.id, third.id}), + "non-adjacent nodes must not be copied as one condition range"); + const LogicEditorResult output = service.setOutput( + logic_id, + rung_id, + CoilNodeConfig{RegisterAddress{RegisterArea::M, 20}, CoilMode::Set}, + true); + require(output.succeeded, "the source rung must accept an output before copying"); + const LadderRung source = *service.findRung(logic_id, rung_id); + service.clearHistory(); + const std::size_t rung_count_before_paste = service.findLogic(logic_id)->rungs.size(); + const LogicEditorResult pasted_rung = service.pasteRung(logic_id, source); + require(pasted_rung.succeeded + && service.findLogic(logic_id)->rungs.size() + == rung_count_before_paste + 1U + && pasted_rung.id != source.id, + "whole rung paste must append a new network"); + const LadderRung *copy = service.findRung(logic_id, pasted_rung.id); + require(copy != nullptr && copy->condition.has_value() + && copy->condition->id != source.condition->id + && copy->output.has_value() && source.output.has_value() + && copy->output->id != source.output->id + && std::get(copy->output->config).address.index() == 20 + && std::get(copy->output->config).mode == CoilMode::Set, + "whole rung paste must regenerate ids and preserve output configuration"); +} + } // namespace int main() @@ -1020,6 +1133,7 @@ int main() testLogicLifecycleAndOrdering(); testEdgeNodesAndRungComments(); testHistoryAndAtomicBatchDelete(); + testBatchPasteNodesAndRung(); } catch (const std::exception &error) {