Переглянути джерело

feat: 添加编辑器复制粘贴功能

main
suyu 3 тижднів тому
джерело
коміт
7b220e59be
11 змінених файлів з 921 додано та 0 видалено
  1. +97
    -0
      app/src/services/hmi_editor_service.cpp
  2. +14
    -0
      app/src/services/hmi_editor_service.h
  3. +323
    -0
      app/src/services/logic_editor_service.cpp
  4. +44
    -0
      app/src/services/logic_editor_service.h
  5. +72
    -0
      app/src/ui/logic_editor_widget.cpp
  6. +2
    -0
      app/src/ui/logic_editor_widget.h
  7. +171
    -0
      app/src/ui/main_window.cpp
  8. +31
    -0
      app/src/ui/main_window.h
  9. +12
    -0
      app/src/ui/main_window.ui
  10. +41
    -0
      app/tests/hmi_editor_service_tests.cpp
  11. +114
    -0
      app/tests/logic_editor_service_tests.cpp

+ 97
- 0
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<HmiControl> &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<std::string> 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();


+ 14
- 0
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<HmiControl> &controls,
int offset_x = 20,
int offset_y = 20);

/** @brief 判断是否存在可撤销的 HMI 编辑操作 */
bool canUndo() const;
/** @brief 判断是否存在可重做的 HMI 编辑操作 */


+ 323
- 0
app/src/services/logic_editor_service.cpp Переглянути файл

@@ -6,6 +6,7 @@
#include <algorithm>
#include <cctype>
#include <cstddef>
#include <functional>
#include <iterator>
#include <unordered_set>
#include <type_traits>
@@ -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<std::size_t> 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<LogicNode> &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<std::string> 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<std::string> &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<ConditionExpression(const ConditionExpression &)> 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)
{


+ 44
- 0
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<LogicNode> &nodes,
const LogicConditionPasteTarget &target = {});
/** 判断所选条件是否位于同一串联层级并且视觉连续 */
bool areConditionNodesContiguous(
const std::string &logic_id,
const std::string &rung_id,
const std::vector<std::string> &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<HistoryState> history_; // 当前编辑会话的撤销/重做历史
bool suppress_history_ = false; // 复合粘贴期间由外层统一记录一次历史
};

+ 72
- 0
app/src/ui/logic_editor_widget.cpp Переглянути файл

@@ -1713,6 +1713,78 @@ LogicEditorResult LogicEditorWidget::setOutput(
return result;
}

LogicEditorResult LogicEditorWidget::pasteConditionNodes(
const std::vector<LogicNode> &nodes)
{
const std::vector<std::pair<std::string, int>> selected_empty_slots =
selectedEmptySlots();
const std::vector<std::pair<std::string, int>> selected_wire_cells =
selectedWireCells();
const std::vector<std::string> selected_expressions = selectedExpressionIds();
const std::vector<std::string> selected_wires = selectedWireIds();
const std::vector<std::string> 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<std::string> expression_ids = selectedExpressionIds();


+ 2
- 0
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<LogicNode> &nodes);
/** 删除当前选中的节点、线段或网络 */
LogicEditorResult deleteSelected();



+ 171
- 0
app/src/ui/main_window.cpp Переглянути файл

@@ -47,6 +47,8 @@
#include <QToolButton>
#include <QTextEdit>

#include <algorithm>

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<std::string> ids = hmi_editor_widget_->selectedControlIds();
std::vector<HmiControl> 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<std::string> 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<LogicNode> 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<HmiClipboardData>(&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<LogicNodesClipboardData>(&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<LogicOutputClipboardData>(&editor_clipboard_))
{
result = logic_editor_widget_->setOutput(
data->output.config, data->output.configured);
}
else if (const auto *data = std::get_if<LogicRungClipboardData>(&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<HmiClipboardData>(editor_clipboard_);
const bool logic_clipboard = std::holds_alternative<LogicNodesClipboardData>(editor_clipboard_)
|| std::holds_alternative<LogicOutputClipboardData>(editor_clipboard_)
|| std::holds_alternative<LogicRungClipboardData>(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();


+ 31
- 0
app/src/ui/main_window.h Переглянути файл

@@ -16,6 +16,8 @@
#include <QMainWindow>

#include <memory>
#include <variant>
#include <vector>

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<HmiControl> controls;
int pasteCount = 0;
};
struct LogicNodesClipboardData
{
std::vector<LogicNode> nodes;
};
struct LogicOutputClipboardData
{
LogicNode output;
};
struct LogicRungClipboardData
{
LadderRung rung;
};
using EditorClipboard = std::variant<
std::monostate,
HmiClipboardData,
LogicNodesClipboardData,
LogicOutputClipboardData,
LogicRungClipboardData>;
EditorClipboard editor_clipboard_;
};

+ 12
- 0
app/src/ui/main_window.ui Переглянути файл

@@ -237,6 +237,8 @@
</property>
<addaction name="undoAction"/>
<addaction name="redoAction"/>
<addaction name="copyAction"/>
<addaction name="pasteAction"/>
<addaction name="separator"/>
<addaction name="deleteSelectionAction"/>
<addaction name="deleteHorizontalWireAction"/>
@@ -1026,6 +1028,16 @@
<property name="toolTip"><string>重做当前编辑器最近撤销的操作</string></property>
<property name="shortcut"><string>Ctrl+Y</string></property>
</action>
<action name="copyAction">
<property name="text"><string>复制</string></property>
<property name="toolTip"><string>复制当前编辑画布中选中的对象</string></property>
<property name="shortcut"><string>Ctrl+C</string></property>
</action>
<action name="pasteAction">
<property name="text"><string>粘贴</string></property>
<property name="toolTip"><string>粘贴已复制的编辑对象</string></property>
<property name="shortcut"><string>Ctrl+V</string></property>
</action>
<action name="deleteSelectionAction">
<property name="text"><string>删除所选</string></property>
<property name="toolTip"><string>删除当前画布中选中的对象</string></property>


+ 41
- 0
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();


+ 114
- 0
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<CoilNodeConfig>(copy->output->config).address.index() == 20
&& std::get<CoilNodeConfig>(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)
{


Завантаження…
Відмінити
Зберегти