diff --git a/app/integrated_platform.pro b/app/integrated_platform.pro index dffe7f8..4936aa4 100644 --- a/app/integrated_platform.pro +++ b/app/integrated_platform.pro @@ -80,6 +80,7 @@ HEADERS += \ src/domain/runtime_state.h \ src/domain/project_storage.h \ src/services/project_service.h \ + src/services/editor_history.h \ src/services/alarm_editor_service.h \ src/services/alarm_service.h \ src/services/hmi_editor_service.h \ diff --git a/app/src/services/editor_history.h b/app/src/services/editor_history.h new file mode 100644 index 0000000..a42f7a3 --- /dev/null +++ b/app/src/services/editor_history.h @@ -0,0 +1,84 @@ +#pragma once + +#include +#include +#include +#include + +/** + * @brief 保存有限数量的编辑前快照并提供撤销、重做 + * + * 历史只存在当前编辑会话内。新编辑会清空重做栈,超过容量时丢弃最早记录。 + */ +template +class EditorHistory final +{ +public: + static constexpr std::size_t kMaximumEntries = 100U; + + template + void record(State before, const State &after, Equal equal) + { + if (equal(before, after)) + { + return; + } + undo_states_.push_back(std::move(before)); + trim(&undo_states_); + redo_states_.clear(); + } + + std::optional undo(State current) + { + if (undo_states_.empty()) + { + return std::nullopt; + } + redo_states_.push_back(std::move(current)); + trim(&redo_states_); + State target = std::move(undo_states_.back()); + undo_states_.pop_back(); + return target; + } + + std::optional redo(State current) + { + if (redo_states_.empty()) + { + return std::nullopt; + } + undo_states_.push_back(std::move(current)); + trim(&undo_states_); + State target = std::move(redo_states_.back()); + redo_states_.pop_back(); + return target; + } + + void clear() + { + undo_states_.clear(); + redo_states_.clear(); + } + + bool canUndo() const + { + return !undo_states_.empty(); + } + + bool canRedo() const + { + return !redo_states_.empty(); + } + +private: + static void trim(std::vector *states) + { + while (states->size() > kMaximumEntries) + { + states->erase(states->begin()); + } + } + + std::vector undo_states_; + std::vector redo_states_; +}; diff --git a/app/src/services/hmi_editor_service.cpp b/app/src/services/hmi_editor_service.cpp index 45f860a..1185aff 100644 --- a/app/src/services/hmi_editor_service.cpp +++ b/app/src/services/hmi_editor_service.cpp @@ -6,6 +6,8 @@ #include #include #include +#include +#include #include namespace { @@ -55,6 +57,86 @@ HmiEditorService::HmiEditorService(ProjectService &project_service) { } +HmiEditorService::HistoryState HmiEditorService::captureState() const +{ + const Project &project = project_service_.project(); + return {project.hmiPages, project.initialHmiPageId}; +} + +void HmiEditorService::recordHistory(HistoryState before) +{ + const HistoryState after = captureState(); + history_.record(std::move(before), after, &HmiEditorService::statesEqual); +} + +bool HmiEditorService::statesEqual( + const HistoryState &left, const HistoryState &right) +{ + if (left.initial_page_id != right.initial_page_id + || left.pages.size() != right.pages.size()) + { + return false; + } + for (std::size_t index = 0; index < left.pages.size(); ++index) + { + if (!pagesEqual(left.pages[index], right.pages[index])) + { + return false; + } + } + return true; +} + +bool HmiEditorService::pagesEqual( + const HmiPage &left, const HmiPage &right) +{ + if (left.id != right.id || left.name != right.name + || left.width != right.width || left.height != right.height + || left.controls.size() != right.controls.size()) + { + return false; + } + for (std::size_t index = 0; index < left.controls.size(); ++index) + { + if (!controlsEqual(left.controls[index], right.controls[index])) + { + return false; + } + } + return true; +} + +bool HmiEditorService::controlsEqual( + const HmiControl &left, const HmiControl &right) +{ + const bool progress_equal = left.progressBar.has_value() + == right.progressBar.has_value() + && (!left.progressBar.has_value() + || (left.progressBar->minimumValue == right.progressBar->minimumValue + && left.progressBar->maximumValue == right.progressBar->maximumValue + && left.progressBar->showValue == right.progressBar->showValue)); + const bool page_jump_equal = left.pageJump.has_value() == right.pageJump.has_value() + && (!left.pageJump.has_value() + || left.pageJump->targetPageId == right.pageJump->targetPageId); + return left.id == right.id + && left.type == right.type + && left.bounds.x == right.bounds.x + && left.bounds.y == right.bounds.y + && left.bounds.width == right.bounds.width + && left.bounds.height == right.bounds.height + && left.text == right.text + && left.binding == right.binding + && left.properties == right.properties + && left.buttonOperation == right.buttonOperation + && page_jump_equal + && progress_equal; +} + +HmiEditorResult HmiEditorService::historyFailure(const std::string &message) +{ + return {false, HmiEditorError::InvalidOperation, message, {}}; +} + // 遍历工程所有 HmiPage,根据页面 ID 查找页面,找不到返回空指针 const HmiPage *HmiEditorService::findPage(const std::string &page_id) const { @@ -109,6 +191,7 @@ HmiEditorResult HmiEditorService::ensureDefaultPage() } // 仅在首个控件操作前创建默认页面,空工程仍可正常保存 + HistoryState before = captureState(); HmiPage page; page.id = "page-1"; page.name = "主操作页面"; @@ -116,6 +199,7 @@ HmiEditorResult HmiEditorService::ensureDefaultPage() Project &project = project_service_.editProject(); project.hmiPages.push_back(std::move(page)); project.initialHmiPageId = project.hmiPages.back().id; + recordHistory(std::move(before)); return {true, HmiEditorError::None, {}, project.hmiPages.back().id}; } @@ -137,12 +221,14 @@ HmiEditorResult HmiEditorService::addPage(const std::string &name) HmiPage page; page.id = makeUniquePageId(current); page.name = name; + HistoryState before = captureState(); Project &project = project_service_.editProject(); project.hmiPages.push_back(std::move(page)); if (project.initialHmiPageId.empty()) { project.initialHmiPageId = project.hmiPages.back().id; } + recordHistory(std::move(before)); return {true, HmiEditorError::None, {}, project.hmiPages.back().id}; } @@ -171,12 +257,18 @@ HmiEditorResult HmiEditorService::renamePage( { return failure(HmiEditorError::DuplicateName, "HMI 页面名称必须唯一"); } + if (existing->name == name) + { + return {true, HmiEditorError::None, {}, page_id}; + } + HistoryState before = captureState(); Project &project = project_service_.editProject(); auto page = std::find_if( project.hmiPages.begin(), project.hmiPages.end(), [&page_id](const HmiPage &candidate) { return candidate.id == page_id; }); page->name = name; + recordHistory(std::move(before)); return {true, HmiEditorError::None, {}, page_id}; } @@ -218,12 +310,14 @@ HmiEditorResult HmiEditorService::removePage(const std::string &page_id) } } + HistoryState before = captureState(); Project &project = project_service_.editProject(); project.hmiPages.erase( std::remove_if( project.hmiPages.begin(), project.hmiPages.end(), [&page_id](const HmiPage &candidate) { return candidate.id == page_id; }), project.hmiPages.end()); + recordHistory(std::move(before)); return {true, HmiEditorError::None, {}, page_id}; } @@ -249,10 +343,12 @@ HmiEditorResult HmiEditorService::movePage(const std::string &page_id, int offse return failure(HmiEditorError::InvalidOperation, "HMI 页面已经位于目标边界"); } + HistoryState before = captureState(); Project &project = project_service_.editProject(); std::iter_swap( project.hmiPages.begin() + index, project.hmiPages.begin() + target_index); + recordHistory(std::move(before)); return {true, HmiEditorError::None, {}, page_id}; } @@ -262,7 +358,13 @@ HmiEditorResult HmiEditorService::setInitialPage(const std::string &page_id) { return failure(HmiEditorError::PageNotFound, "未找到 HMI 页面"); } + if (project_service_.project().initialHmiPageId == page_id) + { + return {true, HmiEditorError::None, {}, page_id}; + } + HistoryState before = captureState(); project_service_.editProject().initialHmiPageId = page_id; + recordHistory(std::move(before)); return {true, HmiEditorError::None, {}, page_id}; } @@ -282,15 +384,17 @@ HmiEditorResult HmiEditorService::addControl( // 新控件初始不绑定寄存器,避免自动分配地址造成误写风险 HmiControl control = makeControl(*page, *descriptor); + HistoryState before = captureState(); 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; + { + return candidate.id == page_id; }); target_page->controls.push_back(std::move(control)); + recordHistory(std::move(before)); return {true, HmiEditorError::None, {}, @@ -299,16 +403,36 @@ HmiEditorResult HmiEditorService::addControl( HmiEditorResult HmiEditorService::removeControl( const std::string &page_id, const std::string &control_id) +{ + return removeControls(page_id, {control_id}); +} + +HmiEditorResult HmiEditorService::removeControls( + const std::string &page_id, + const std::vector &control_ids) { if (findPage(page_id) == nullptr) { return failure(HmiEditorError::PageNotFound, "未找到 HMI 页面"); } - if (findControl(page_id, control_id) == nullptr) + if (control_ids.empty()) { - return failure(HmiEditorError::ControlNotFound, "未找到 HMI 控件"); + return failure(HmiEditorError::InvalidOperation, "请先选择要删除的 HMI 控件"); + } + std::set selected_ids; + for (const std::string &control_id : control_ids) + { + if (!selected_ids.insert(control_id).second) + { + return failure(HmiEditorError::InvalidOperation, "删除列表中存在重复控件"); + } + if (findControl(page_id, control_id) == nullptr) + { + return failure(HmiEditorError::ControlNotFound, "未找到 HMI 控件"); + } } + HistoryState before = captureState(); Project &project = project_service_.editProject(); auto page = std::find_if( project.hmiPages.begin(), @@ -321,12 +445,13 @@ HmiEditorResult HmiEditorService::removeControl( std::remove_if( page->controls.begin(), page->controls.end(), - [&control_id](const HmiControl &candidate) + [&selected_ids](const HmiControl &candidate) { - return candidate.id == control_id; + return selected_ids.find(candidate.id) != selected_ids.end(); }), page->controls.end()); - return {true, HmiEditorError::None, {}, control_id}; + recordHistory(std::move(before)); + return {true, HmiEditorError::None, {}, control_ids.front()}; } // 移动 / 缩放控件 @@ -355,7 +480,15 @@ HmiEditorResult HmiEditorService::moveControl( { return failure(HmiEditorError::InvalidControl, error); } + if (control->bounds.x == bounds.x + && control->bounds.y == bounds.y + && control->bounds.width == bounds.width + && control->bounds.height == bounds.height) + { + return {true, HmiEditorError::None, {}, control_id}; + } + HistoryState before = captureState(); Project &project = project_service_.editProject(); auto target = std::find_if( project.hmiPages.begin(), project.hmiPages.end(), @@ -364,6 +497,7 @@ HmiEditorResult HmiEditorService::moveControl( target->controls.begin(), target->controls.end(), [&control_id](const HmiControl &item) { return item.id == control_id; }); editable->bounds = bounds; + recordHistory(std::move(before)); return {true, HmiEditorError::None, {}, control_id}; } @@ -398,6 +532,11 @@ HmiEditorResult HmiEditorService::updateControl( return failure(HmiEditorError::DuplicateId, "同一 HMI 页面内的控件 ID 必须唯一"); } + if (controlsEqual(*existing, control)) + { + return {true, HmiEditorError::None, {}, control.id}; + } + HistoryState before = captureState(); Project &project = project_service_.editProject(); auto target = std::find_if( project.hmiPages.begin(), project.hmiPages.end(), @@ -406,9 +545,51 @@ HmiEditorResult HmiEditorService::updateControl( target->controls.begin(), target->controls.end(), [&control_id](const HmiControl &item) { return item.id == control_id; }); *editable = control; + recordHistory(std::move(before)); return {true, HmiEditorError::None, {}, control.id}; } +bool HmiEditorService::canUndo() const +{ + return history_.canUndo(); +} + +bool HmiEditorService::canRedo() const +{ + return history_.canRedo(); +} + +HmiEditorResult HmiEditorService::undo() +{ + const std::optional target = history_.undo(captureState()); + if (!target.has_value()) + { + return historyFailure("没有可撤销的 HMI 编辑操作"); + } + Project &project = project_service_.editProject(); + project.hmiPages = target->pages; + project.initialHmiPageId = target->initial_page_id; + return {true, HmiEditorError::None, {}, {}}; +} + +HmiEditorResult HmiEditorService::redo() +{ + const std::optional target = history_.redo(captureState()); + if (!target.has_value()) + { + return historyFailure("没有可重做的 HMI 编辑操作"); + } + Project &project = project_service_.editProject(); + project.hmiPages = target->pages; + project.initialHmiPageId = target->initial_page_id; + return {true, HmiEditorError::None, {}, {}}; +} + +void HmiEditorService::clearHistory() +{ + history_.clear(); +} + bool HmiEditorService::validateEditableControl( const HmiPage &page, const HmiControl &control, std::string *error) const { diff --git a/app/src/services/hmi_editor_service.h b/app/src/services/hmi_editor_service.h index b831aeb..de3b3f6 100644 --- a/app/src/services/hmi_editor_service.h +++ b/app/src/services/hmi_editor_service.h @@ -8,8 +8,10 @@ #pragma once #include "domain/hmi_model.h" +#include "editor_history.h" #include +#include class ProjectService; struct HmiControlDescriptor; @@ -104,6 +106,9 @@ public: */ HmiEditorResult removeControl( const std::string &page_id, const std::string &control_id); + HmiEditorResult removeControls( + const std::string &page_id, + const std::vector &control_ids); /** * @brief 更新控件位置和尺寸 * @param page_id 所属页面唯一标识 @@ -129,7 +134,28 @@ public: const std::string &control_id, const HmiControl &control); + bool canUndo() const; + bool canRedo() const; + HmiEditorResult undo(); + HmiEditorResult redo(); + void clearHistory(); + private: + struct HistoryState + { + std::vector pages; + std::string initial_page_id; + }; + + HistoryState captureState() const; + void recordHistory(HistoryState before); + static bool statesEqual( + const HistoryState &left, const HistoryState &right); + static bool controlsEqual( + const HmiControl &left, const HmiControl &right); + static bool pagesEqual(const HmiPage &left, const HmiPage &right); + static HmiEditorResult historyFailure(const std::string &message); + /** * @brief 校验控件编辑后仍满足页面内的基础约束 * @param page 控件所属页面 @@ -173,4 +199,5 @@ private: * @brief 非拥有的工程服务依赖,由应用入口保证生命周期 */ ProjectService &project_service_; + EditorHistory history_; }; diff --git a/app/src/services/logic_editor_service.cpp b/app/src/services/logic_editor_service.cpp index fd68b8a..e5441fb 100644 --- a/app/src/services/logic_editor_service.cpp +++ b/app/src/services/logic_editor_service.cpp @@ -263,6 +263,183 @@ LogicEditorService::LogicEditorService(ProjectService &project_service) { } +LogicEditorService::HistoryState LogicEditorService::captureState() const +{ + return {project_service_.project().controlLogics}; +} + +void LogicEditorService::recordHistory(HistoryState before) +{ + const HistoryState after = captureState(); + history_.record(std::move(before), after, &LogicEditorService::statesEqual); +} + +bool LogicEditorService::statesEqual( + const HistoryState &left, const HistoryState &right) +{ + if (left.logics.size() != right.logics.size()) + { + return false; + } + for (std::size_t index = 0; index < left.logics.size(); ++index) + { + if (!logicsEqual(left.logics[index], right.logics[index])) + { + return false; + } + } + return true; +} + +bool LogicEditorService::logicsEqual( + const ControlLogic &left, const ControlLogic &right) +{ + if (left.id != right.id || left.name != right.name + || left.enabled != right.enabled || left.rungs.size() != right.rungs.size()) + { + return false; + } + for (std::size_t index = 0; index < left.rungs.size(); ++index) + { + if (!rungsEqual(left.rungs[index], right.rungs[index])) + { + return false; + } + } + return true; +} + +bool LogicEditorService::rungsEqual( + const LadderRung &left, const LadderRung &right) +{ + if (left.id != right.id || left.name != right.name + || left.comment != right.comment + || left.condition.has_value() != right.condition.has_value() + || left.output.has_value() != right.output.has_value()) + { + return false; + } + return (!left.condition.has_value() + || expressionsEqual(*left.condition, *right.condition)) + && (!left.output.has_value() || nodesEqual(*left.output, *right.output)); +} + +bool LogicEditorService::expressionsEqual( + const ConditionExpression &left, const ConditionExpression &right) +{ + if (left.id != right.id || left.kind != right.kind + || left.node.has_value() != right.node.has_value() + || left.children.size() != right.children.size()) + { + return false; + } + if (left.node.has_value() && !nodesEqual(*left.node, *right.node)) + { + return false; + } + for (std::size_t index = 0; index < left.children.size(); ++index) + { + if (!expressionsEqual(left.children[index], right.children[index])) + { + return false; + } + } + return true; +} + +bool LogicEditorService::nodesEqual( + const LogicNode &left, const LogicNode &right) +{ + return left.id == right.id && left.config.index() == right.config.index() + && left.configured == right.configured && configsEqual(left.config, right.config); +} + +bool LogicEditorService::configsEqual( + const LogicNodeConfig &left, const LogicNodeConfig &right) +{ + return std::visit( + [](const auto &left_config, const auto &right_config) + { + using Left = std::decay_t; + using Right = std::decay_t; + if constexpr (!std::is_same_v) + { + return false; + } + else if constexpr (std::is_same_v) + { + return left_config.address == right_config.address + && left_config.mode == right_config.mode; + } + else if constexpr (std::is_same_v) + { + return left_config.address == right_config.address + && left_config.mode == right_config.mode; + } + else if constexpr (std::is_same_v) + { + return left_config.address == right_config.address + && left_config.mode == right_config.mode; + } + else if constexpr (std::is_same_v) + { + return left_config.address == right_config.address + && left_config.mode == right_config.mode; + } + else if constexpr (std::is_same_v) + { + return left_config.address == right_config.address + && left_config.mode == right_config.mode; + } + else if constexpr (std::is_same_v) + { + return left_config.address == right_config.address + && left_config.comparison == right_config.comparison + && left_config.value == right_config.value; + } + else if constexpr (std::is_same_v) + { + return left_config.address == right_config.address + && left_config.presetMs == right_config.presetMs; + } + else if constexpr (std::is_same_v) + { + return left_config.address == right_config.address + && left_config.mode == right_config.mode + && left_config.currentValueAddress == right_config.currentValueAddress + && left_config.preset.kind == right_config.preset.kind + && left_config.preset.address == right_config.preset.address + && left_config.preset.constant == right_config.preset.constant + && left_config.resetAddress == right_config.resetAddress; + } + else if constexpr (std::is_same_v) + { + return left_config.source.kind == right_config.source.kind + && left_config.source.address == right_config.source.address + && left_config.source.constant == right_config.source.constant + && left_config.destination == right_config.destination; + } + else + { + return left_config.operation == right_config.operation + && left_config.left.kind == right_config.left.kind + && left_config.left.address == right_config.left.address + && left_config.left.constant == right_config.left.constant + && left_config.right.kind == right_config.right.kind + && left_config.right.address == right_config.right.address + && left_config.right.constant == right_config.right.constant + && left_config.destination == right_config.destination; + } + }, + left, + right); +} + +LogicEditorResult LogicEditorService::historyFailure(const std::string &message) +{ + return {false, LogicEditorError::InvalidOperation, message, {}}; +} + const ControlLogic *LogicEditorService::findLogic(const std::string &logic_id) const { const auto &logics = project_service_.project().controlLogics; @@ -394,8 +571,10 @@ LogicEditorResult LogicEditorService::addLogic(const std::string &name) logic.id = makeUniqueLogicId(current); logic.name = name; logic.rungs.push_back({"rung-1", "网络 1", {}, std::nullopt, std::nullopt}); + HistoryState before = captureState(); Project &project = project_service_.editProject(); project.controlLogics.push_back(std::move(logic)); + recordHistory(std::move(before)); return {true, LogicEditorError::None, {}, project.controlLogics.back().id}; } @@ -425,11 +604,17 @@ LogicEditorResult LogicEditorService::renameLogic( return failure(LogicEditorError::DuplicateName, "控制逻辑名称必须唯一"); } + if (logic->name == name) + { + return {true, LogicEditorError::None, {}, logic_id}; + } + HistoryState before = captureState(); Project &project = project_service_.editProject(); auto editable = std::find_if( project.controlLogics.begin(), project.controlLogics.end(), [&logic_id](const ControlLogic &candidate) { return candidate.id == logic_id; }); editable->name = name; + recordHistory(std::move(before)); return {true, LogicEditorError::None, {}, logic_id}; } @@ -447,12 +632,14 @@ LogicEditorResult LogicEditorService::removeLogic(const std::string &logic_id) { return failure(LogicEditorError::LastLogicRequired, "工程至少需要保留一个控制逻辑"); } + HistoryState before = captureState(); Project &project = project_service_.editProject(); project.controlLogics.erase( std::remove_if( project.controlLogics.begin(), project.controlLogics.end(), [&logic_id](const ControlLogic &candidate) { return candidate.id == logic_id; }), project.controlLogics.end()); + recordHistory(std::move(before)); return {true, LogicEditorError::None, {}, logic_id}; } @@ -478,10 +665,12 @@ LogicEditorResult LogicEditorService::moveLogic(const std::string &logic_id, int { return failure(LogicEditorError::InvalidOperation, "控制逻辑已经位于目标边界"); } + HistoryState before = captureState(); Project &project = project_service_.editProject(); std::iter_swap( project.controlLogics.begin() + index, project.controlLogics.begin() + target_index); + recordHistory(std::move(before)); return {true, LogicEditorError::None, {}, logic_id}; } @@ -492,11 +681,18 @@ LogicEditorResult LogicEditorService::setLogicEnabled( { return failure(LogicEditorError::LogicNotFound, "未找到控制逻辑"); } + const ControlLogic *existing = findLogic(logic_id); + if (existing->enabled == enabled) + { + return {true, LogicEditorError::None, {}, logic_id}; + } + HistoryState before = captureState(); Project &project = project_service_.editProject(); auto logic = std::find_if( project.controlLogics.begin(), project.controlLogics.end(), [&logic_id](const ControlLogic &candidate) { return candidate.id == logic_id; }); logic->enabled = enabled; + recordHistory(std::move(before)); return {true, LogicEditorError::None, {}, logic_id}; } @@ -510,11 +706,13 @@ LogicEditorResult LogicEditorService::addRung(const std::string &logic_id) LadderRung rung; rung.id = makeUniqueRungId(*logic); rung.name = "网络 " + std::to_string(logic->rungs.size() + 1U); + HistoryState before = captureState(); Project &project = project_service_.editProject(); auto target = std::find_if( project.controlLogics.begin(), project.controlLogics.end(), [&logic_id](const ControlLogic &candidate) { return candidate.id == logic_id; }); target->rungs.push_back(std::move(rung)); + recordHistory(std::move(before)); return {true, LogicEditorError::None, {}, target->rungs.back().id}; } @@ -534,6 +732,7 @@ LogicEditorResult LogicEditorService::removeRung( { return failure(LogicEditorError::InvalidOperation, "控制逻辑至少需要保留一个网络"); } + HistoryState before = captureState(); Project &project = project_service_.editProject(); auto target = std::find_if( project.controlLogics.begin(), project.controlLogics.end(), @@ -543,6 +742,7 @@ LogicEditorResult LogicEditorService::removeRung( target->rungs.begin(), target->rungs.end(), [&rung_id](const LadderRung &rung) { return rung.id == rung_id; }), target->rungs.end()); + recordHistory(std::move(before)); return {true, LogicEditorError::None, {}, rung_id}; } @@ -556,9 +756,15 @@ LogicEditorResult LogicEditorService::updateRungComment( { return failure(LogicEditorError::RungNotFound, "未找到梯形图网络"); } + if (rung->comment == comment) + { + return {true, LogicEditorError::None, {}, rung_id}; + } + HistoryState before = captureState(); LadderRung &editable = *findEditableRung( project_service_.editProject(), logic_id, rung_id); editable.comment = comment; + recordHistory(std::move(before)); return {true, LogicEditorError::None, {}, rung_id}; } @@ -580,6 +786,7 @@ LogicEditorResult LogicEditorService::appendCondition( } const std::string node_id = makeUniqueNodeId(*logic, nodePrefix(config)); ConditionExpression leaf = ConditionExpression::fromNode(makeNode(node_id, config)); + HistoryState before = captureState(); Project &project = project_service_.editProject(); LadderRung *rung = findEditableRung(project, logic_id, rung_id); if (!rung->condition.has_value()) @@ -598,6 +805,7 @@ LogicEditorResult LogicEditorService::appendCondition( std::move(*rung->condition), std::move(leaf)); } + recordHistory(std::move(before)); return {true, LogicEditorError::None, {}, node_id}; } @@ -622,6 +830,7 @@ LogicEditorResult LogicEditorService::insertConditionAfter( } const std::string node_id = makeUniqueNodeId(*logic, nodePrefix(config)); ConditionExpression leaf = ConditionExpression::fromNode(makeNode(node_id, config)); + HistoryState before = captureState(); Project &project = project_service_.editProject(); LadderRung *rung = findEditableRung(project, logic_id, rung_id); ConditionExpression *target = findConditionExpression(*rung->condition, target_node_id); @@ -645,6 +854,7 @@ LogicEditorResult LogicEditorService::insertConditionAfter( std::move(original), std::move(leaf)); } + recordHistory(std::move(before)); return {true, LogicEditorError::None, {}, node_id}; } @@ -690,6 +900,7 @@ LogicEditorResult LogicEditorService::addParallelBranch( { series_id += "-range"; } + HistoryState before = captureState(); Project &project = project_service_.editProject(); LadderRung *rung = findEditableRung(project, logic_id, rung_id); if (!addParallelForSelection( @@ -703,13 +914,15 @@ LogicEditorResult LogicEditorService::addParallelBranch( LogicEditorError::InvalidOperation, "并联选择必须是一个连续的逻辑范围"); } + recordHistory(std::move(before)); return {true, LogicEditorError::None, {}, node_id}; } LogicEditorResult LogicEditorService::setOutput( const std::string &logic_id, const std::string &rung_id, - const LogicNodeConfig &config) + const LogicNodeConfig &config, + bool configured) { const ControlLogic *logic = findLogic(logic_id); const LadderRung *existing_rung = findRung(logic_id, rung_id); @@ -721,8 +934,17 @@ LogicEditorResult LogicEditorService::setOutput( } const std::string node_id = existing_rung->output.has_value() ? existing_rung->output->id : makeUniqueNodeId(*logic, nodePrefix(config)); + LogicNode node = makeNode(node_id, config); + node.configured = configured; + if (existing_rung->output.has_value() + && nodesEqual(*existing_rung->output, node)) + { + return {true, LogicEditorError::None, {}, node_id}; + } + HistoryState before = captureState(); Project &project = project_service_.editProject(); - findEditableRung(project, logic_id, rung_id)->output = makeNode(node_id, config); + findEditableRung(project, logic_id, rung_id)->output = std::move(node); + recordHistory(std::move(before)); return {true, LogicEditorError::None, {}, node_id}; } @@ -748,6 +970,11 @@ LogicEditorResult LogicEditorService::updateNodeConfig( { return failure(LogicEditorError::InvalidNode, error); } + if (nodesEqual(*node, candidate)) + { + return {true, LogicEditorError::None, {}, node_id}; + } + HistoryState before = captureState(); Project &project = project_service_.editProject(); for (ControlLogic &logic : project.controlLogics) { @@ -760,6 +987,7 @@ LogicEditorResult LogicEditorService::updateNodeConfig( if (rung.output.has_value() && rung.output->id == node_id) { *rung.output = candidate; + recordHistory(std::move(before)); return {true, LogicEditorError::None, {}, node_id}; } if (rung.condition.has_value()) @@ -767,6 +995,7 @@ LogicEditorResult LogicEditorService::updateNodeConfig( if (LogicNode *editable = findConditionNode(*rung.condition, node_id)) { *editable = candidate; + recordHistory(std::move(before)); return {true, LogicEditorError::None, {}, node_id}; } } @@ -778,19 +1007,72 @@ LogicEditorResult LogicEditorService::updateNodeConfig( LogicEditorResult LogicEditorService::removeNode( const std::string &logic_id, const std::string &node_id) { - const std::string rung_id = rungIdForNode(logic_id, node_id); - if (rung_id.empty()) + return removeNodes(logic_id, {node_id}); +} + +LogicEditorResult LogicEditorService::removeNodes( + const std::string &logic_id, + const std::vector &node_ids) +{ + if (findLogic(logic_id) == nullptr) { - return failure(LogicEditorError::NodeNotFound, "未找到逻辑节点"); + return failure(LogicEditorError::LogicNotFound, "未找到控制逻辑"); } - const LadderRung *rung = findRung(logic_id, rung_id); - if (rung->output.has_value() && rung->output->id == node_id) + if (node_ids.empty()) { - Project &project = project_service_.editProject(); - findEditableRung(project, logic_id, rung_id)->output.reset(); - return {true, LogicEditorError::None, {}, node_id}; + return failure(LogicEditorError::InvalidOperation, "请先选择要删除的逻辑节点"); } - return removeExpression(logic_id, rung_id, node_id); + std::unordered_set selected_ids; + for (const std::string &node_id : node_ids) + { + if (!selected_ids.insert(node_id).second) + { + return failure(LogicEditorError::InvalidOperation, "删除列表中存在重复节点"); + } + if (findNode(logic_id, node_id) == nullptr) + { + return failure(LogicEditorError::NodeNotFound, "未找到逻辑节点"); + } + } + + HistoryState before = captureState(); + Project &project = project_service_.editProject(); + ControlLogic *editable_logic = nullptr; + for (ControlLogic &candidate : project.controlLogics) + { + if (candidate.id == logic_id) + { + editable_logic = &candidate; + break; + } + } + for (LadderRung &rung : editable_logic->rungs) + { + if (rung.output.has_value() + && selected_ids.find(rung.output->id) != selected_ids.end()) + { + rung.output.reset(); + } + if (rung.condition.has_value()) + { + for (const std::string &node_id : node_ids) + { + if (rung.condition->kind == ConditionExpressionKind::Node + && rung.condition->id == node_id) + { + rung.condition.reset(); + break; + } + removeExpressionRecursive(&*rung.condition, node_id); + } + if (rung.condition.has_value()) + { + normalizeConditionExpression(&rung.condition); + } + } + } + recordHistory(std::move(before)); + return {true, LogicEditorError::None, {}, node_ids.front()}; } LogicEditorResult LogicEditorService::removeExpression( @@ -802,6 +1084,7 @@ LogicEditorResult LogicEditorService::removeExpression( { return failure(LogicEditorError::ExpressionNotFound, "未找到条件支路"); } + HistoryState before = captureState(); Project &project = project_service_.editProject(); LadderRung *rung = findEditableRung(project, logic_id, rung_id); if (rung->condition->id == expression_id) @@ -813,9 +1096,47 @@ LogicEditorResult LogicEditorService::removeExpression( removeExpressionRecursive(&*rung->condition, expression_id); normalizeConditionExpression(&rung->condition); } + recordHistory(std::move(before)); return {true, LogicEditorError::None, {}, expression_id}; } +bool LogicEditorService::canUndo() const +{ + return history_.canUndo(); +} + +bool LogicEditorService::canRedo() const +{ + return history_.canRedo(); +} + +LogicEditorResult LogicEditorService::undo() +{ + const std::optional target = history_.undo(captureState()); + if (!target.has_value()) + { + return historyFailure("没有可撤销的梯形图编辑操作"); + } + project_service_.editProject().controlLogics = target->logics; + return {true, LogicEditorError::None, {}, {}}; +} + +LogicEditorResult LogicEditorService::redo() +{ + const std::optional target = history_.redo(captureState()); + if (!target.has_value()) + { + return historyFailure("没有可重做的梯形图编辑操作"); + } + project_service_.editProject().controlLogics = target->logics; + return {true, LogicEditorError::None, {}, {}}; +} + +void LogicEditorService::clearHistory() +{ + history_.clear(); +} + bool LogicEditorService::isConditionConfig(const LogicNodeConfig &config) { return std::holds_alternative(config) diff --git a/app/src/services/logic_editor_service.h b/app/src/services/logic_editor_service.h index 79f3d5b..c2045ae 100644 --- a/app/src/services/logic_editor_service.h +++ b/app/src/services/logic_editor_service.h @@ -1,6 +1,7 @@ #pragma once #include "domain/control_logic_model.h" +#include "editor_history.h" #include #include @@ -80,19 +81,49 @@ public: LogicEditorResult setOutput( const std::string &logic_id, const std::string &rung_id, - const LogicNodeConfig &config); + const LogicNodeConfig &config, + bool configured = false); LogicEditorResult updateNodeConfig( const std::string &logic_id, const std::string &node_id, const LogicNodeConfig &config); LogicEditorResult removeNode( const std::string &logic_id, const std::string &node_id); + LogicEditorResult removeNodes( + const std::string &logic_id, + const std::vector &node_ids); LogicEditorResult removeExpression( const std::string &logic_id, const std::string &rung_id, const std::string &expression_id); + bool canUndo() const; + bool canRedo() const; + LogicEditorResult undo(); + LogicEditorResult redo(); + void clearHistory(); + private: + struct HistoryState + { + std::vector logics; + }; + + HistoryState captureState() const; + void recordHistory(HistoryState before); + static bool statesEqual( + const HistoryState &left, const HistoryState &right); + static bool logicsEqual( + const ControlLogic &left, const ControlLogic &right); + static bool rungsEqual( + const LadderRung &left, const LadderRung &right); + static bool expressionsEqual( + const ConditionExpression &left, const ConditionExpression &right); + static bool nodesEqual(const LogicNode &left, const LogicNode &right); + static bool configsEqual( + const LogicNodeConfig &left, const LogicNodeConfig &right); + static LogicEditorResult historyFailure(const std::string &message); + static bool isConditionConfig(const LogicNodeConfig &config); static bool isOutputConfig(const LogicNodeConfig &config); static std::string nodePrefix(const LogicNodeConfig &config); @@ -104,4 +135,5 @@ private: LogicEditorError error, const std::string &message); ProjectService &project_service_; + EditorHistory history_; }; diff --git a/app/src/ui/hmi_editor_widget.cpp b/app/src/ui/hmi_editor_widget.cpp index cf5f8fa..37013cf 100644 --- a/app/src/ui/hmi_editor_widget.cpp +++ b/app/src/ui/hmi_editor_widget.cpp @@ -751,6 +751,7 @@ void HmiEditorWidget::reloadPage() // 遍历场景所有图元,找到对应 id 的图元,设置选中,视图滚动到把控件显示出来 void HmiEditorWidget::selectControl(const std::string &control_id) { + scene_->clearSelection(); for (QGraphicsItem *item : scene_->items()) { HmiGraphicsItem *control_item = asHmiItem(item); @@ -765,16 +766,39 @@ void HmiEditorWidget::selectControl(const std::string &control_id) std::string HmiEditorWidget::selectedControlId() const { - const QList selected = scene_->selectedItems(); - for (QGraphicsItem *item : selected) + const std::vector ids = selectedControlIds(); + return ids.empty() ? std::string{} : ids.front(); +} + +std::vector HmiEditorWidget::selectedControlIds() const +{ + std::vector> positioned_ids; + for (QGraphicsItem *item : scene_->selectedItems()) { const HmiGraphicsItem *control_item = asHmiItem(item); if (control_item != nullptr) { - return control_item->controlId(); + positioned_ids.emplace_back( + control_item->scenePos(), control_item->controlId()); } } - return {}; + std::sort( + positioned_ids.begin(), positioned_ids.end(), + [](const auto &left, const auto &right) + { + if (!qFuzzyCompare(left.first.y(), right.first.y())) + { + return left.first.y() < right.first.y(); + } + return left.first.x() < right.first.x(); + }); + std::vector ids; + ids.reserve(positioned_ids.size()); + for (const auto &positioned_id : positioned_ids) + { + ids.push_back(positioned_id.second); + } + return ids; } void HmiEditorWidget::refreshRuntimeValues() diff --git a/app/src/ui/hmi_editor_widget.h b/app/src/ui/hmi_editor_widget.h index 0c8fe48..5eca681 100644 --- a/app/src/ui/hmi_editor_widget.h +++ b/app/src/ui/hmi_editor_widget.h @@ -10,6 +10,7 @@ #include #include +#include class HmiEditorService; class HmiRuntimeService; @@ -69,6 +70,7 @@ public: */ void selectControl(const std::string &control_id); std::string selectedControlId() const; + std::vector selectedControlIds() const; /** * @brief 从统一寄存器仓库读取当前值并刷新画布显示 * diff --git a/app/src/ui/logic_editor_widget.cpp b/app/src/ui/logic_editor_widget.cpp index c8b9cef..6d0b5c7 100644 --- a/app/src/ui/logic_editor_widget.cpp +++ b/app/src/ui/logic_editor_widget.cpp @@ -1085,10 +1085,11 @@ LogicEditorResult LogicEditorWidget::addParallelBranch(const LogicNodeConfig &co return result; } -LogicEditorResult LogicEditorWidget::setOutput(const LogicNodeConfig &config) +LogicEditorResult LogicEditorWidget::setOutput( + const LogicNodeConfig &config, bool configured) { const LogicEditorResult result = editor_service_.setOutput( - logic_id_, currentRungId(), config); + logic_id_, currentRungId(), config, configured); if (result.succeeded) { reloadLogic(); @@ -1108,14 +1109,7 @@ LogicEditorResult LogicEditorWidget::deleteSelected() LogicEditorResult result; if (!node_ids.empty()) { - for (const std::string &node_id : node_ids) - { - result = editor_service_.removeNode(logic_id_, node_id); - if (!result.succeeded) - { - break; - } - } + result = editor_service_.removeNodes(logic_id_, node_ids); } else { diff --git a/app/src/ui/logic_editor_widget.h b/app/src/ui/logic_editor_widget.h index e975dcc..1ea7505 100644 --- a/app/src/ui/logic_editor_widget.h +++ b/app/src/ui/logic_editor_widget.h @@ -39,7 +39,8 @@ public: LogicEditorResult addRung(); LogicEditorResult addCondition(const LogicNodeConfig &config); LogicEditorResult addParallelBranch(const LogicNodeConfig &config); - LogicEditorResult setOutput(const LogicNodeConfig &config); + LogicEditorResult setOutput( + const LogicNodeConfig &config, bool configured = false); LogicEditorResult deleteSelected(); signals: diff --git a/app/src/ui/main_window.cpp b/app/src/ui/main_window.cpp index 840abf3..2b7c3d4 100644 --- a/app/src/ui/main_window.cpp +++ b/app/src/ui/main_window.cpp @@ -24,20 +24,53 @@ #include "ui_main_window.h" #include +#include #include +#include #include +#include #include #include #include +#include +#include +#include #include #include +#include #include #include #include #include +#include namespace { +bool isTextEditingObject(QObject *object) +{ + QWidget *widget = qobject_cast(object); + while (widget != nullptr) + { + if (qobject_cast(widget) != nullptr + || qobject_cast(widget) != nullptr + || qobject_cast(widget) != nullptr + || qobject_cast(widget) != nullptr) + { + return true; + } + widget = widget->parentWidget(); + } + return false; +} + +bool isEditorShortcut(const QKeyEvent &event) +{ + return event.matches(QKeySequence::Undo) + || event.matches(QKeySequence::Redo) + || (event.modifiers() == Qt::NoModifier + && (event.key() == Qt::Key_Delete || event.key() == Qt::Key_Escape)); +} + QString modeText(ApplicationMode mode) { switch (mode) @@ -220,6 +253,7 @@ MainWindow::MainWindow( void MainWindow::initializeUi() { ui_->setupUi(this); + qApp->installEventFilter(this); configureAppearance(); property_panel_controller_ = std::make_unique( *this, @@ -253,6 +287,7 @@ void MainWindow::initializeUi() [this] { schedulePlcStatusUpdate(); }); hmi_editor_service_.ensureDefaultPage(); logic_editor_service_.ensureDefaultLogic(); + clearEditorHistories(); current_hmi_page_id_ = hmi_editor_service_.firstPageId(); current_logic_id_ = logic_editor_service_.firstLogicId(); refreshProjectUi(); @@ -261,9 +296,25 @@ void MainWindow::initializeUi() MainWindow::~MainWindow() { + qApp->removeEventFilter(this); runtime_mode_service_.setPlcStatusChangedCallback({}); } +bool MainWindow::eventFilter(QObject *watched, QEvent *event) +{ + if (event->type() == QEvent::ShortcutOverride + && isTextEditingObject(watched)) + { + auto *key_event = static_cast(event); + if (isEditorShortcut(*key_event)) + { + event->accept(); + return true; + } + } + return QMainWindow::eventFilter(watched, event); +} + const std::string &MainWindow::currentHmiPageId() const { return current_hmi_page_id_; @@ -293,6 +344,14 @@ void MainWindow::configureActions() connect(ui_->saveProjectAction, &QAction::triggered, this, &MainWindow::saveProject); connect(ui_->saveAsProjectAction, &QAction::triggered, this, &MainWindow::saveProjectAs); connect(ui_->loadProjectAction, &QAction::triggered, this, &MainWindow::loadProject); + connect(ui_->undoAction, &QAction::triggered, + this, &MainWindow::undoActiveEditor); + connect(ui_->redoAction, &QAction::triggered, + this, &MainWindow::redoActiveEditor); + connect(ui_->deleteSelectionAction, &QAction::triggered, + this, &MainWindow::deleteActiveSelection); + connect(ui_->clearSelectionAction, &QAction::triggered, + this, &MainWindow::clearActiveSelection); connect(ui_->addButtonAction, &QAction::triggered, this, [this] { addHmiControl(HmiControlType::Button); }); @@ -636,6 +695,10 @@ void MainWindow::configureAppearance() ui_->saveProjectAction->setIcon(makeUiIcon(UiIcon::Save)); ui_->saveAsProjectAction->setIcon(makeUiIcon(UiIcon::SaveAs)); ui_->loadProjectAction->setIcon(makeUiIcon(UiIcon::Open)); + ui_->undoAction->setIcon(makeUiIcon(UiIcon::Undo)); + ui_->redoAction->setIcon(makeUiIcon(UiIcon::Redo)); + ui_->deleteSelectionAction->setIcon(makeUiIcon(UiIcon::Delete)); + ui_->clearSelectionAction->setIcon(makeUiIcon(UiIcon::ClearList)); ui_->exitAction->setIcon(makeUiIcon(UiIcon::Exit)); ui_->configurePlcAction->setIcon(makeUiIcon(UiIcon::PlcSettings)); ui_->disconnectPlcAction->setIcon(makeUiIcon(UiIcon::Disconnect)); @@ -802,6 +865,10 @@ void MainWindow::configureProjectTree() [this](const QString &message, int timeout_ms) { statusBar()->showMessage(message, timeout_ms); + }, + [this] + { + updateEditActions(); }); project_workspace_controller_->configure(); } @@ -844,9 +911,130 @@ void MainWindow::configureRegisterComments() }); } +void MainWindow::undoActiveEditor() +{ + if (!runtime_mode_service_.policy().allowsProjectEditing) + { + return; + } + if (ui_->editorTabWidget->currentWidget() == ui_->hmiEditorTab) + { + if (!hmi_editor_service_.undo().succeeded) + { + return; + } + selected_control_id_.clear(); + showControlProperties({}); + refreshProjectUi(); + hmi_editor_widget_->reloadPage(); + statusBar()->showMessage(tr("已撤销 HMI 编辑操作"), 3000); + } + else if (ui_->editorTabWidget->currentWidget() == ui_->logicEditorTab) + { + if (!logic_editor_service_.undo().succeeded) + { + return; + } + selected_logic_node_id_.clear(); + showLogicNodeProperties({}); + refreshProjectUi(); + logic_editor_widget_->reloadLogic(); + statusBar()->showMessage(tr("已撤销梯形图编辑操作"), 3000); + } + updateEditActions(); +} + +void MainWindow::redoActiveEditor() +{ + if (!runtime_mode_service_.policy().allowsProjectEditing) + { + return; + } + if (ui_->editorTabWidget->currentWidget() == ui_->hmiEditorTab) + { + if (!hmi_editor_service_.redo().succeeded) + { + return; + } + selected_control_id_.clear(); + showControlProperties({}); + refreshProjectUi(); + hmi_editor_widget_->reloadPage(); + statusBar()->showMessage(tr("已重做 HMI 编辑操作"), 3000); + } + else if (ui_->editorTabWidget->currentWidget() == ui_->logicEditorTab) + { + if (!logic_editor_service_.redo().succeeded) + { + return; + } + selected_logic_node_id_.clear(); + showLogicNodeProperties({}); + refreshProjectUi(); + logic_editor_widget_->reloadLogic(); + statusBar()->showMessage(tr("已重做梯形图编辑操作"), 3000); + } + updateEditActions(); +} + +void MainWindow::deleteActiveSelection() +{ + if (ui_->editorTabWidget->currentWidget() == ui_->hmiEditorTab) + { + deleteSelectedControl(); + } + else if (ui_->editorTabWidget->currentWidget() == ui_->logicEditorTab) + { + deleteSelectedLogicObject(); + } +} + +void MainWindow::clearActiveSelection() +{ + if (ui_->editorTabWidget->currentWidget() == ui_->hmiEditorTab) + { + hmi_editor_widget_->scene()->clearSelection(); + selected_control_id_.clear(); + showControlProperties({}); + } + else if (ui_->editorTabWidget->currentWidget() == ui_->logicEditorTab) + { + logic_editor_widget_->scene()->clearSelection(); + selected_logic_node_id_.clear(); + showLogicNodeProperties({}); + } +} + +void MainWindow::updateEditActions() +{ + const bool editable = runtime_mode_service_.policy().allowsProjectEditing; + const bool hmi_active = ui_->editorTabWidget->currentWidget() == ui_->hmiEditorTab; + const bool logic_active = + ui_->editorTabWidget->currentWidget() == ui_->logicEditorTab; + ui_->undoAction->setEnabled( + editable && ((hmi_active && hmi_editor_service_.canUndo()) + || (logic_active && logic_editor_service_.canUndo()))); + ui_->redoAction->setEnabled( + editable && ((hmi_active && hmi_editor_service_.canRedo()) + || (logic_active && logic_editor_service_.canRedo()))); + ui_->deleteSelectionAction->setEnabled(editable && (hmi_active || logic_active)); + ui_->clearSelectionAction->setEnabled(editable && (hmi_active || logic_active)); +} + +void MainWindow::clearEditorHistories() +{ + hmi_editor_service_.clearHistory(); + logic_editor_service_.clearHistory(); + if (ui_->undoAction != nullptr) + { + updateEditActions(); + } +} + void MainWindow::refreshProjectUi() { project_workspace_controller_->refresh(); + updateEditActions(); } void MainWindow::updateProjectTreeActions() @@ -979,23 +1167,14 @@ void MainWindow::configureAndSetLogicOutput(const LogicNodeConfig &config) return; } const LogicNodeConfig configured = dialog.config(); - LogicEditorResult result = logic_editor_widget_->setOutput(configured); + LogicEditorResult result = logic_editor_widget_->setOutput(configured, true); if (!result.succeeded) { showProjectResult(tr("设置逻辑输出"), fromUtf8(result.message), false); return; } const std::string node_id = result.id; - result = logic_editor_service_.updateNodeConfig( - current_logic_id_, node_id, configured); - if (!result.succeeded) - { - showProjectResult(tr("配置逻辑输出"), fromUtf8(result.message), false); - return; - } selected_logic_node_id_ = node_id; - logic_editor_widget_->reloadLogic(); - logic_editor_widget_->selectNode(node_id); showLogicNodeProperties(node_id); refreshProjectUi(); statusBar()->showMessage(tr("逻辑输出已配置"), 3000); @@ -1080,6 +1259,7 @@ void MainWindow::createNewProject() } hmi_editor_service_.ensureDefaultPage(); logic_editor_service_.ensureDefaultLogic(); + clearEditorHistories(); selected_control_id_.clear(); selected_logic_node_id_.clear(); current_hmi_page_id_ = project_service_.project().initialHmiPageId; @@ -1131,6 +1311,7 @@ void MainWindow::loadProject() showProjectResult(tr("加载工程"), fromUtf8(result.message), false); return; } + clearEditorHistories(); selected_control_id_.clear(); selected_logic_node_id_.clear(); current_hmi_page_id_ = project_service_.project().initialHmiPageId; @@ -1285,6 +1466,7 @@ void MainWindow::updateModeUi(const QString &message) ui_->saveAsProjectAction->setEnabled(policy.allowsProjectEditing); ui_->loadProjectAction->setEnabled(policy.allowsProjectEditing); updateProjectTreeActions(); + updateEditActions(); mode_status_label_->setText(modeText(mode)); if (mode == ApplicationMode::Editing) diff --git a/app/src/ui/main_window.h b/app/src/ui/main_window.h index 9166191..b677526 100644 --- a/app/src/ui/main_window.h +++ b/app/src/ui/main_window.h @@ -20,6 +20,7 @@ QT_BEGIN_NAMESPACE class QAction; class QActionGroup; +class QEvent; class QLabel; namespace Ui { @@ -91,6 +92,9 @@ public: const std::string ¤tHmiPageId() const; const std::string ¤tLogicId() const; +protected: + bool eventFilter(QObject *watched, QEvent *event) override; + private: // 配置菜单和工具栏动作 void configureActions(); @@ -107,6 +111,12 @@ private: void configurePlcConnection(); void configureAlarms(); void configureRegisterComments(); + void undoActiveEditor(); + void redoActiveEditor(); + void deleteActiveSelection(); + void clearActiveSelection(); + void updateEditActions(); + void clearEditorHistories(); // 刷新工程树和当前 HMI 页面信息 void refreshProjectUi(); void updateProjectTreeActions(); diff --git a/app/src/ui/main_window.ui b/app/src/ui/main_window.ui index 4741035..2e5f7f3 100644 --- a/app/src/ui/main_window.ui +++ b/app/src/ui/main_window.ui @@ -240,7 +240,17 @@ - + + + + + 编辑(&E) + + + + + + @@ -260,6 +270,7 @@ + @@ -888,21 +899,53 @@ 新建工程 + + Ctrl+N + 保存工程 + + Ctrl+S + 工程另存为 + + Ctrl+Shift+S + 加载工程 + + Ctrl+O + + + + 撤销 + 撤销当前编辑器的上一步操作 + Ctrl+Z + + + 重做 + 重做当前编辑器最近撤销的操作 + Ctrl+Y + + + 删除所选 + 删除当前画布中选中的对象 + Delete + + + 清除选择 + 清除当前画布选择 + Esc diff --git a/app/src/ui/project_workspace_controller.cpp b/app/src/ui/project_workspace_controller.cpp index 66612a3..cd7ee29 100644 --- a/app/src/ui/project_workspace_controller.cpp +++ b/app/src/ui/project_workspace_controller.cpp @@ -56,7 +56,8 @@ ProjectWorkspaceController::ProjectWorkspaceController( std::function show_control_properties, std::function show_logic_properties, ResultReporter result_reporter, - StatusReporter status_reporter) + StatusReporter status_reporter, + EditStateChanged edit_state_changed) : parent_(parent), ui_(ui), project_service_(project_service), @@ -72,7 +73,8 @@ ProjectWorkspaceController::ProjectWorkspaceController( show_control_properties_(std::move(show_control_properties)), show_logic_properties_(std::move(show_logic_properties)), result_reporter_(std::move(result_reporter)), - status_reporter_(std::move(status_reporter)) + status_reporter_(std::move(status_reporter)), + edit_state_changed_(std::move(edit_state_changed)) { } @@ -310,6 +312,7 @@ void ProjectWorkspaceController::addPage() ui_.editorTabWidget->setCurrentWidget(ui_.hmiEditorTab); refresh(); hmi_editor_widget_.reloadPage(); + edit_state_changed_(); } void ProjectWorkspaceController::addLogic() @@ -331,6 +334,7 @@ void ProjectWorkspaceController::addLogic() ui_.editorTabWidget->setCurrentWidget(ui_.logicEditorTab); refresh(); logic_editor_widget_.reloadLogic(); + edit_state_changed_(); } void ProjectWorkspaceController::renameSelectedItem() @@ -386,6 +390,7 @@ void ProjectWorkspaceController::renameSelectedItem() } } refresh(); + edit_state_changed_(); } void ProjectWorkspaceController::deleteSelectedItem() @@ -423,6 +428,7 @@ void ProjectWorkspaceController::deleteSelectedItem() refresh(); show_control_properties_({}); show_logic_properties_({}); + edit_state_changed_(); } void ProjectWorkspaceController::moveSelectedItem(int offset) @@ -458,6 +464,7 @@ void ProjectWorkspaceController::moveSelectedItem(int offset) return; } refresh(); + edit_state_changed_(); } void ProjectWorkspaceController::setSelectedPageAsInitial() @@ -477,6 +484,7 @@ void ProjectWorkspaceController::setSelectedPageAsInitial() return; } refresh(); + edit_state_changed_(); } void ProjectWorkspaceController::toggleSelectedLogicEnabled() @@ -502,6 +510,7 @@ void ProjectWorkspaceController::toggleSelectedLogicEnabled() return; } refresh(); + edit_state_changed_(); } void ProjectWorkspaceController::reportFailure( diff --git a/app/src/ui/project_workspace_controller.h b/app/src/ui/project_workspace_controller.h index 0943351..f308ef1 100644 --- a/app/src/ui/project_workspace_controller.h +++ b/app/src/ui/project_workspace_controller.h @@ -31,6 +31,7 @@ public: const QString &action, const QString &message, bool succeeded)>; using StatusReporter = std::function; + using EditStateChanged = std::function; ProjectWorkspaceController( QWidget &parent, @@ -48,7 +49,8 @@ public: std::function show_control_properties, std::function show_logic_properties, ResultReporter result_reporter, - StatusReporter status_reporter); + StatusReporter status_reporter, + EditStateChanged edit_state_changed); void configure(); void refresh(); @@ -92,6 +94,7 @@ private: std::function show_logic_properties_; ResultReporter result_reporter_; StatusReporter status_reporter_; + EditStateChanged edit_state_changed_; QAction *add_page_action_ = nullptr; QAction *add_logic_action_ = nullptr; diff --git a/app/src/ui/property_panel_controller.cpp b/app/src/ui/property_panel_controller.cpp index f135cef..d5b0848 100644 --- a/app/src/ui/property_panel_controller.cpp +++ b/app/src/ui/property_panel_controller.cpp @@ -21,6 +21,7 @@ #include #include +#include namespace { @@ -432,12 +433,18 @@ void PropertyPanelController::addHmiControl(HmiControlType type) void PropertyPanelController::deleteSelectedControl() { - if (selected_control_id_.empty()) + if (hmi_editor_widget_ == nullptr) { return; } - const HmiEditorResult result = hmi_editor_service_.removeControl( - current_page_id_(), selected_control_id_); + const std::vector selected_ids = + hmi_editor_widget_->selectedControlIds(); + if (selected_ids.empty()) + { + return; + } + const HmiEditorResult result = hmi_editor_service_.removeControls( + current_page_id_(), selected_ids); if (!result.succeeded) { reportFailure(QObject::tr("删除控件"), result.message); diff --git a/app/src/ui/toolbar_icon_factory.cpp b/app/src/ui/toolbar_icon_factory.cpp index 071416c..7fb3b06 100644 --- a/app/src/ui/toolbar_icon_factory.cpp +++ b/app/src/ui/toolbar_icon_factory.cpp @@ -198,6 +198,31 @@ QPixmap renderIcon(UiIcon icon, int size) painter.drawPath(folder); break; } + case UiIcon::Undo: + case UiIcon::Redo: + { + const bool redo = icon == UiIcon::Redo; + QPainterPath arrow; + if (redo) + { + arrow.moveTo(5, 7); + arrow.cubicTo(12, 3, 20, 7, 20, 15); + arrow.lineTo(20, 19); + painter.drawPath(arrow); + painter.drawLine(QPointF(20, 19), QPointF(16, 15)); + painter.drawLine(QPointF(20, 19), QPointF(23, 15)); + } + else + { + arrow.moveTo(19, 7); + arrow.cubicTo(12, 3, 4, 7, 4, 15); + arrow.lineTo(4, 19); + painter.drawPath(arrow); + painter.drawLine(QPointF(4, 19), QPointF(1, 15)); + painter.drawLine(QPointF(4, 19), QPointF(8, 15)); + } + break; + } case UiIcon::Exit: { painter.drawRect(QRectF(4, 3, 10, 18)); diff --git a/app/src/ui/toolbar_icon_factory.h b/app/src/ui/toolbar_icon_factory.h index e68693d..04f8fe6 100644 --- a/app/src/ui/toolbar_icon_factory.h +++ b/app/src/ui/toolbar_icon_factory.h @@ -11,6 +11,8 @@ enum class UiIcon Save, SaveAs, Open, + Undo, + Redo, Exit, PlcSettings, Disconnect, diff --git a/app/tests/hmi_editor_service_tests.cpp b/app/tests/hmi_editor_service_tests.cpp index 3579e5d..cc09dc6 100644 --- a/app/tests/hmi_editor_service_tests.cpp +++ b/app/tests/hmi_editor_service_tests.cpp @@ -186,6 +186,60 @@ void testRuntimeUsesRegisterRepository() "progress bars must read D values through the repository"); } +void testHistoryAndAtomicBatchDelete() +{ + 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); + const HmiEditorResult third = service.addControl(page_id, HmiControlType::Label); + require(first.succeeded && second.succeeded && third.succeeded, + "controls for history testing must be created"); + + service.clearHistory(); + require(!service.removeControls(page_id, {first.id, "missing-control"}).succeeded, + "batch deletion must reject an unknown control before changing the page"); + require(service.findPage(page_id)->controls.size() == 3U + && !service.canUndo(), + "failed batch deletion must be atomic and leave history unchanged"); + + require(service.removeControls(page_id, {first.id, second.id}).succeeded, + "batch deletion of valid controls must succeed"); + require(service.findPage(page_id)->controls.size() == 1U + && service.canUndo(), + "valid batch deletion must remove all selected controls in one history step"); + require(service.undo().succeeded && service.findPage(page_id)->controls.size() == 3U, + "HMI undo must restore a deleted batch"); + require(service.redo().succeeded && service.findPage(page_id)->controls.size() == 1U, + "HMI redo must reapply a deleted batch"); + + service.clearHistory(); + const HmiControl *remaining = service.findControl(page_id, third.id); + require(remaining != nullptr, + "the unselected control must survive a batch deletion"); + require(service.moveControl(page_id, third.id, remaining->bounds).succeeded + && !service.canUndo(), + "a no-op control move must not consume an undo step"); + + service.clearHistory(); + HmiControl candidate = *service.findControl(page_id, third.id); + for (int index = 1; index <= 101; ++index) + { + candidate.bounds.x = index; + require(service.updateControl(page_id, third.id, candidate).succeeded, + "repeated valid HMI edits must succeed"); + } + int undo_count = 0; + while (service.undo().succeeded) + { + ++undo_count; + } + require(undo_count == 100, + "HMI history must retain exactly the configured 100 most recent steps"); +} + void testPageLifecycleAndNavigation() { TestProjectStorage storage; @@ -261,6 +315,7 @@ int main() { // 编辑和运行场景分别验证服务层两条独立职责 testControlEditing(); + testHistoryAndAtomicBatchDelete(); testRuntimeUsesRegisterRepository(); testPageLifecycleAndNavigation(); } diff --git a/app/tests/hmi_editor_service_tests.pro b/app/tests/hmi_editor_service_tests.pro index a4b74ab..b504ff9 100644 --- a/app/tests/hmi_editor_service_tests.pro +++ b/app/tests/hmi_editor_service_tests.pro @@ -30,6 +30,7 @@ HEADERS += \ ../src/domain/project_model.h \ ../src/domain/project_storage.h \ ../src/services/project_service.h \ + ../src/services/editor_history.h \ ../src/services/hmi_editor_service.h \ ../src/services/hmi_navigation_service.h \ ../src/services/hmi_runtime_service.h diff --git a/app/tests/logic_editor_service_tests.cpp b/app/tests/logic_editor_service_tests.cpp index 799ee30..a11823b 100644 --- a/app/tests/logic_editor_service_tests.cpp +++ b/app/tests/logic_editor_service_tests.cpp @@ -226,6 +226,77 @@ void testEdgeTimerNodesAndRungComments() "edge, T contact, TON and rung comment updates must remain in the model"); } +void testHistoryAndAtomicBatchDelete() +{ + TestProjectStorage storage; + ProjectService project_service(storage); + LogicEditorService service(project_service); + const std::string logic_id = service.ensureDefaultLogic().id; + const std::string first_rung_id = service.firstRungId(logic_id); + const LogicEditorResult first = service.appendCondition( + logic_id, first_rung_id, contact(0)); + const LogicEditorResult second = service.appendCondition( + logic_id, first_rung_id, contact(1)); + const LogicEditorResult second_rung = service.addRung(logic_id); + const LogicEditorResult third = service.appendCondition( + logic_id, second_rung.id, contact(2)); + require(first.succeeded && second.succeeded && second_rung.succeeded + && third.succeeded, + "nodes for history testing must be created"); + + service.clearHistory(); + require(!service.removeNodes(logic_id, {first.id, "missing-node"}).succeeded, + "batch node deletion must validate every id before changing the logic"); + require(service.findNode(logic_id, first.id) != nullptr + && service.findNode(logic_id, third.id) != nullptr + && !service.canUndo(), + "failed batch node deletion must be atomic and leave history unchanged"); + + require(service.removeNodes(logic_id, {first.id, third.id}).succeeded, + "valid nodes across multiple rungs must be deleted together"); + require(service.findNode(logic_id, first.id) == nullptr + && service.findNode(logic_id, third.id) == nullptr, + "all selected nodes must be removed by one batch operation"); + require(service.undo().succeeded + && service.findNode(logic_id, first.id) != nullptr + && service.findNode(logic_id, third.id) != nullptr, + "logic undo must restore a cross-rung batch deletion"); + require(service.redo().succeeded + && service.findNode(logic_id, first.id) == nullptr + && service.findNode(logic_id, third.id) == nullptr, + "logic redo must reapply a cross-rung batch deletion"); + + service.clearHistory(); + const ControlLogic *logic = service.findLogic(logic_id); + require(logic != nullptr && service.setLogicEnabled(logic_id, logic->enabled).succeeded + && !service.canUndo(), + "setting an unchanged logic state must not consume history"); + + service.clearHistory(); + for (int index = 1; index <= 101; ++index) + { + require(service.updateRungComment( + logic_id, first_rung_id, "comment-" + std::to_string(index)) + .succeeded, + "repeated valid rung edits must succeed"); + } + int undo_count = 0; + while (service.undo().succeeded) + { + ++undo_count; + } + require(undo_count == 100, + "logic history must retain exactly the configured 100 most recent steps"); + + require(service.redo().succeeded, + "logic redo must be available after an undo"); + require(service.appendCondition(logic_id, first_rung_id, contact(5)).succeeded, + "a new logic edit must succeed after undo"); + require(!service.canRedo(), + "a new logic edit must clear the redo history"); + (void)second; +} + } // namespace int main() @@ -236,6 +307,7 @@ int main() testRangeParallelInsertion(); testLogicLifecycleAndOrdering(); testEdgeTimerNodesAndRungComments(); + testHistoryAndAtomicBatchDelete(); } catch (const std::exception &error) { diff --git a/app/tests/logic_editor_service_tests.pro b/app/tests/logic_editor_service_tests.pro index b566480..7d29eed 100644 --- a/app/tests/logic_editor_service_tests.pro +++ b/app/tests/logic_editor_service_tests.pro @@ -28,4 +28,5 @@ HEADERS += \ ../src/domain/alarm_model.h \ ../src/domain/project_storage.h \ ../src/services/project_service.h \ + ../src/services/editor_history.h \ ../src/services/logic_editor_service.h diff --git a/app/tests/main_window_tests.cpp b/app/tests/main_window_tests.cpp index 60426a4..ffd9ac3 100644 --- a/app/tests/main_window_tests.cpp +++ b/app/tests/main_window_tests.cpp @@ -603,6 +603,12 @@ void testModeActionsControlEditingAvailability() QAction *add_progress_bar_action = requiredChild( window, "addProgressBarAction"); QAction *delete_control_action = requiredChild(window, "deleteControlAction"); + QAction *undo_action = requiredChild(window, "undoAction"); + QAction *redo_action = requiredChild(window, "redoAction"); + QAction *delete_selection_action = requiredChild( + window, "deleteSelectionAction"); + QAction *toggle_logic_enabled_action = requiredChild( + window, "toggleLogicEnabledAction"); QToolButton *hmi_more_controls = requiredChild( window, "hmiMoreControlsButton"); QToolButton *logic_contact_menu = requiredChild( @@ -639,12 +645,15 @@ void testModeActionsControlEditingAvailability() window, "applyPropertiesButton"); HmiEditorWidget *hmi_editor = requiredChild( window, "hmiEditorWidget"); + LogicEditorWidget *logic_editor = requiredChild( + window, "logicEditorWidget"); QLabel *executor_status = requiredChild(window, "executorStatusLabel"); QWidget *runtime_tab = requiredChild(window, "runtimeMonitorTab"); QWidget *runtime_hmi = requiredChild(window, "runtimeHmiView"); QWidget *runtime_logic = requiredChild(window, "runtimeLogicView"); QWidget *free_monitor = requiredChild(window, "freeMonitorWidget"); QTabWidget *editor_tabs = requiredChild(window, "editorTabWidget"); + QTreeWidget *project_tree = requiredChild(window, "projectTree"); require(!runtime_tab->isVisible(), "runtime monitor workspace must be hidden while editing"); for (const char *action_name : { @@ -653,6 +662,10 @@ void testModeActionsControlEditingAvailability() "saveProjectAction", "saveAsProjectAction", "loadProjectAction", + "undoAction", + "redoAction", + "deleteSelectionAction", + "clearSelectionAction", "configurePlcAction", "configureRegisterCommentsAction", "disconnectPlcAction", @@ -739,9 +752,30 @@ void testModeActionsControlEditingAvailability() "HMI page must refit when the window becomes larger"); require(editing_action->isChecked(), "editing action must be selected initially"); + require(undo_action->shortcut() == QKeySequence(Qt::CTRL | Qt::Key_Z) + && redo_action->shortcut() == QKeySequence(Qt::CTRL | Qt::Key_Y) + && delete_selection_action->shortcut() == QKeySequence(Qt::Key_Delete), + "core editor actions must expose the agreed keyboard shortcuts"); require(project_dock->isEnabled(), "project dock must be enabled while editing"); require(properties_dock->isEnabled(), "properties dock must be enabled while editing"); + const std::string default_logic_id = logic_editor_service.firstLogicId(); + project_tree->setCurrentItem(project_tree->topLevelItem(1)->child(0)); + QApplication::processEvents(); + const bool default_logic_enabled = + logic_editor_service.findLogic(default_logic_id)->enabled; + toggle_logic_enabled_action->trigger(); + require(undo_action->isEnabled() + && logic_editor_service.findLogic(default_logic_id)->enabled + != default_logic_enabled, + "project-tree edits must immediately enable ladder undo"); + undo_action->trigger(); + require(logic_editor_service.findLogic(default_logic_id)->enabled + == default_logic_enabled, + "ladder undo must restore a project-tree edit"); + editor_tabs->setCurrentIndex(0); + QApplication::processEvents(); + add_button_action->trigger(); const std::string page_id = editor_service.firstPageId(); require(editor_service.findPage(page_id)->controls.size() == 1, @@ -753,6 +787,14 @@ void testModeActionsControlEditingAvailability() require(button_operation->currentText() == QStringLiteral("瞬时 ON"), "new HMI buttons must default to momentary ON"); + text_edit->setFocus(); + text_edit->selectAll(); + QTest::keyClicks(text_edit, "modified"); + QTest::keyClick(text_edit, Qt::Key_Z, Qt::ControlModifier); + require(text_edit->text() == QStringLiteral("按钮") + && editor_service.findControl(page_id, "button-1") != nullptr, + "Ctrl+Z in a property input must undo text without undoing the HMI model"); + const QList unbound_items = hmi_editor->scene()->selectedItems(); require(unbound_items.size() == 1 && unbound_items.front()->boundingRect().top() == 0, "an unbound HMI control must not reserve an address label area"); @@ -777,6 +819,28 @@ void testModeActionsControlEditingAvailability() require(editor_service.findPage(page_id)->controls.empty(), "deleting a selected control must update the HMI page model"); + QAction *add_label_action = requiredChild(window, "addLabelAction"); + add_label_action->trigger(); + add_label_action->trigger(); + add_label_action->trigger(); + hmi_editor->scene()->clearSelection(); + for (QGraphicsItem *item : hmi_editor->scene()->items()) + { + if (item->zValue() >= 0.0) + { + item->setSelected(true); + } + } + delete_selection_action->trigger(); + require(editor_service.findPage(page_id)->controls.empty(), + "Delete must remove every selected HMI control in one operation"); + undo_action->trigger(); + require(editor_service.findPage(page_id)->controls.size() == 3U, + "HMI undo must restore a batch deletion"); + redo_action->trigger(); + require(editor_service.findPage(page_id)->controls.empty(), + "HMI redo must reapply a batch deletion"); + add_progress_bar_action->trigger(); const HmiControl *default_progress = editor_service.findControl( page_id, "progress-bar-1"); @@ -817,6 +881,22 @@ void testModeActionsControlEditingAvailability() hmi_editor->reloadPage(); add_normally_open_action->trigger(); + editor_tabs->setCurrentIndex(1); + QApplication::processEvents(); + undo_action->trigger(); + require(logic_editor_service.findLogic(logic_editor_service.firstLogicId()) + ->rungs.front().condition.has_value() == false, + "logic undo must use the ladder history when the ladder tab is active"); + redo_action->trigger(); + const LadderRung &restored_rung = logic_editor_service.findLogic( + logic_editor_service.firstLogicId())->rungs.front(); + require(restored_rung.condition.has_value(), + "logic redo must restore the ladder edit"); + std::vector restored_nodes; + collectConditionNodes(*restored_rung.condition, &restored_nodes); + require(restored_nodes.size() == 1U, + "logic redo must restore the original condition node"); + logic_editor->selectNode(restored_nodes.front()->id); parallel_insert_action->trigger(); add_normal_coil_action->trigger(); const ControlLogic *logic = logic_editor_service.findLogic( @@ -872,6 +952,9 @@ void testModeActionsControlEditingAvailability() "ProgressBar creation must be disabled while running"); require(!add_normally_open_action->isEnabled(), "logic add nodes must be disabled while running"); + require(!undo_action->isEnabled() && !redo_action->isEnabled() + && !delete_selection_action->isEnabled(), + "undo, redo and delete must be disabled while running"); require(runtime_tab->isVisible() && runtime_hmi->isVisible() && runtime_logic->isVisible() && free_monitor->isVisible(), "offline running must show HMI, ladder trace and free monitor together"); diff --git a/app/tests/main_window_tests.pro b/app/tests/main_window_tests.pro index cf489d4..b43e90c 100644 --- a/app/tests/main_window_tests.pro +++ b/app/tests/main_window_tests.pro @@ -72,6 +72,7 @@ HEADERS += \ ../src/domain/project_storage.h \ ../src/domain/runtime_state.h \ ../src/services/project_service.h \ + ../src/services/editor_history.h \ ../src/services/alarm_editor_service.h \ ../src/services/alarm_service.h \ ../src/services/hmi_editor_service.h \