| @@ -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 \ | |||
| @@ -0,0 +1,84 @@ | |||
| #pragma once | |||
| #include <cstddef> | |||
| #include <optional> | |||
| #include <utility> | |||
| #include <vector> | |||
| /** | |||
| * @brief 保存有限数量的编辑前快照并提供撤销、重做 | |||
| * | |||
| * 历史只存在当前编辑会话内。新编辑会清空重做栈,超过容量时丢弃最早记录。 | |||
| */ | |||
| template <typename State> | |||
| class EditorHistory final | |||
| { | |||
| public: | |||
| static constexpr std::size_t kMaximumEntries = 100U; | |||
| template <typename Equal> | |||
| 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<State> 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<State> 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<State> *states) | |||
| { | |||
| while (states->size() > kMaximumEntries) | |||
| { | |||
| states->erase(states->begin()); | |||
| } | |||
| } | |||
| std::vector<State> undo_states_; | |||
| std::vector<State> redo_states_; | |||
| }; | |||
| @@ -6,6 +6,8 @@ | |||
| #include <algorithm> | |||
| #include <cctype> | |||
| #include <cstddef> | |||
| #include <map> | |||
| #include <set> | |||
| #include <utility> | |||
| 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<std::string> &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<std::string> 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<HistoryState> 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<HistoryState> 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 | |||
| { | |||
| @@ -8,8 +8,10 @@ | |||
| #pragma once | |||
| #include "domain/hmi_model.h" | |||
| #include "editor_history.h" | |||
| #include <string> | |||
| #include <vector> | |||
| 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<std::string> &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<HmiPage> 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<HistoryState> history_; | |||
| }; | |||
| @@ -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<decltype(left_config)>; | |||
| using Right = std::decay_t<decltype(right_config)>; | |||
| if constexpr (!std::is_same_v<Left, Right>) | |||
| { | |||
| return false; | |||
| } | |||
| else if constexpr (std::is_same_v<Left, ContactNodeConfig>) | |||
| { | |||
| return left_config.address == right_config.address | |||
| && left_config.mode == right_config.mode; | |||
| } | |||
| else if constexpr (std::is_same_v<Left, EdgeContactNodeConfig>) | |||
| { | |||
| return left_config.address == right_config.address | |||
| && left_config.mode == right_config.mode; | |||
| } | |||
| else if constexpr (std::is_same_v<Left, TimerContactNodeConfig>) | |||
| { | |||
| return left_config.address == right_config.address | |||
| && left_config.mode == right_config.mode; | |||
| } | |||
| else if constexpr (std::is_same_v<Left, CounterContactNodeConfig>) | |||
| { | |||
| return left_config.address == right_config.address | |||
| && left_config.mode == right_config.mode; | |||
| } | |||
| else if constexpr (std::is_same_v<Left, CoilNodeConfig>) | |||
| { | |||
| return left_config.address == right_config.address | |||
| && left_config.mode == right_config.mode; | |||
| } | |||
| else if constexpr (std::is_same_v<Left, CompareNodeConfig>) | |||
| { | |||
| 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<Left, TonNodeConfig>) | |||
| { | |||
| return left_config.address == right_config.address | |||
| && left_config.presetMs == right_config.presetMs; | |||
| } | |||
| else if constexpr (std::is_same_v<Left, CounterNodeConfig>) | |||
| { | |||
| 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<Left, MoveNodeConfig>) | |||
| { | |||
| 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<std::string> &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<std::string> 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<HistoryState> 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<HistoryState> 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<ContactNodeConfig>(config) | |||
| @@ -1,6 +1,7 @@ | |||
| #pragma once | |||
| #include "domain/control_logic_model.h" | |||
| #include "editor_history.h" | |||
| #include <string> | |||
| #include <vector> | |||
| @@ -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<std::string> &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<ControlLogic> 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<HistoryState> history_; | |||
| }; | |||
| @@ -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<QGraphicsItem *> selected = scene_->selectedItems(); | |||
| for (QGraphicsItem *item : selected) | |||
| const std::vector<std::string> ids = selectedControlIds(); | |||
| return ids.empty() ? std::string{} : ids.front(); | |||
| } | |||
| std::vector<std::string> HmiEditorWidget::selectedControlIds() const | |||
| { | |||
| std::vector<std::pair<QPointF, std::string>> 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<std::string> ids; | |||
| ids.reserve(positioned_ids.size()); | |||
| for (const auto &positioned_id : positioned_ids) | |||
| { | |||
| ids.push_back(positioned_id.second); | |||
| } | |||
| return ids; | |||
| } | |||
| void HmiEditorWidget::refreshRuntimeValues() | |||
| @@ -10,6 +10,7 @@ | |||
| #include <QGraphicsView> | |||
| #include <string> | |||
| #include <vector> | |||
| class HmiEditorService; | |||
| class HmiRuntimeService; | |||
| @@ -69,6 +70,7 @@ public: | |||
| */ | |||
| void selectControl(const std::string &control_id); | |||
| std::string selectedControlId() const; | |||
| std::vector<std::string> selectedControlIds() const; | |||
| /** | |||
| * @brief 从统一寄存器仓库读取当前值并刷新画布显示 | |||
| * | |||
| @@ -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 | |||
| { | |||
| @@ -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: | |||
| @@ -24,20 +24,53 @@ | |||
| #include "ui_main_window.h" | |||
| #include <QActionGroup> | |||
| #include <QAbstractSpinBox> | |||
| #include <QApplication> | |||
| #include <QEvent> | |||
| #include <QFileDialog> | |||
| #include <QGraphicsScene> | |||
| #include <QInputDialog> | |||
| #include <QIcon> | |||
| #include <QLabel> | |||
| #include <QLineEdit> | |||
| #include <QKeyEvent> | |||
| #include <QKeySequence> | |||
| #include <QMessageBox> | |||
| #include <QMenu> | |||
| #include <QPlainTextEdit> | |||
| #include <QStatusBar> | |||
| #include <QTabWidget> | |||
| #include <QToolBar> | |||
| #include <QToolButton> | |||
| #include <QTextEdit> | |||
| namespace { | |||
| bool isTextEditingObject(QObject *object) | |||
| { | |||
| QWidget *widget = qobject_cast<QWidget *>(object); | |||
| while (widget != nullptr) | |||
| { | |||
| if (qobject_cast<QLineEdit *>(widget) != nullptr | |||
| || qobject_cast<QTextEdit *>(widget) != nullptr | |||
| || qobject_cast<QPlainTextEdit *>(widget) != nullptr | |||
| || qobject_cast<QAbstractSpinBox *>(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<PropertyPanelController>( | |||
| *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<QKeyEvent *>(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) | |||
| @@ -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(); | |||
| @@ -240,7 +240,17 @@ | |||
| <addaction name="saveAsProjectAction"/> | |||
| <addaction name="loadProjectAction"/> | |||
| <addaction name="separator"/> | |||
| <addaction name="exitAction"/> | |||
| <addaction name="exitAction"/> | |||
| </widget> | |||
| <widget class="QMenu" name="editMenu"> | |||
| <property name="title"> | |||
| <string>编辑(&E)</string> | |||
| </property> | |||
| <addaction name="undoAction"/> | |||
| <addaction name="redoAction"/> | |||
| <addaction name="separator"/> | |||
| <addaction name="deleteSelectionAction"/> | |||
| <addaction name="clearSelectionAction"/> | |||
| </widget> | |||
| <widget class="QMenu" name="runMenu"> | |||
| <property name="title"> | |||
| @@ -260,6 +270,7 @@ | |||
| <addaction name="configureRegisterCommentsAction"/> | |||
| </widget> | |||
| <addaction name="fileMenu"/> | |||
| <addaction name="editMenu"/> | |||
| <addaction name="runMenu"/> | |||
| <addaction name="viewMenu"/> | |||
| </widget> | |||
| @@ -888,21 +899,53 @@ | |||
| <property name="text"> | |||
| <string>新建工程</string> | |||
| </property> | |||
| <property name="shortcut"> | |||
| <string>Ctrl+N</string> | |||
| </property> | |||
| </action> | |||
| <action name="saveProjectAction"> | |||
| <property name="text"> | |||
| <string>保存工程</string> | |||
| </property> | |||
| <property name="shortcut"> | |||
| <string>Ctrl+S</string> | |||
| </property> | |||
| </action> | |||
| <action name="saveAsProjectAction"> | |||
| <property name="text"> | |||
| <string>工程另存为</string> | |||
| </property> | |||
| <property name="shortcut"> | |||
| <string>Ctrl+Shift+S</string> | |||
| </property> | |||
| </action> | |||
| <action name="loadProjectAction"> | |||
| <property name="text"> | |||
| <string>加载工程</string> | |||
| </property> | |||
| <property name="shortcut"> | |||
| <string>Ctrl+O</string> | |||
| </property> | |||
| </action> | |||
| <action name="undoAction"> | |||
| <property name="text"><string>撤销</string></property> | |||
| <property name="toolTip"><string>撤销当前编辑器的上一步操作</string></property> | |||
| <property name="shortcut"><string>Ctrl+Z</string></property> | |||
| </action> | |||
| <action name="redoAction"> | |||
| <property name="text"><string>重做</string></property> | |||
| <property name="toolTip"><string>重做当前编辑器最近撤销的操作</string></property> | |||
| <property name="shortcut"><string>Ctrl+Y</string></property> | |||
| </action> | |||
| <action name="deleteSelectionAction"> | |||
| <property name="text"><string>删除所选</string></property> | |||
| <property name="toolTip"><string>删除当前画布中选中的对象</string></property> | |||
| <property name="shortcut"><string>Delete</string></property> | |||
| </action> | |||
| <action name="clearSelectionAction"> | |||
| <property name="text"><string>清除选择</string></property> | |||
| <property name="toolTip"><string>清除当前画布选择</string></property> | |||
| <property name="shortcut"><string>Esc</string></property> | |||
| </action> | |||
| <action name="configurePlcAction"> | |||
| <property name="text"> | |||
| @@ -56,7 +56,8 @@ ProjectWorkspaceController::ProjectWorkspaceController( | |||
| std::function<void(const std::string &)> show_control_properties, | |||
| std::function<void(const std::string &)> 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( | |||
| @@ -31,6 +31,7 @@ public: | |||
| const QString &action, const QString &message, bool succeeded)>; | |||
| using StatusReporter = std::function<void( | |||
| const QString &message, int timeout_ms)>; | |||
| using EditStateChanged = std::function<void()>; | |||
| ProjectWorkspaceController( | |||
| QWidget &parent, | |||
| @@ -48,7 +49,8 @@ public: | |||
| std::function<void(const std::string &)> show_control_properties, | |||
| std::function<void(const std::string &)> 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<void(const std::string &)> show_logic_properties_; | |||
| ResultReporter result_reporter_; | |||
| StatusReporter status_reporter_; | |||
| EditStateChanged edit_state_changed_; | |||
| QAction *add_page_action_ = nullptr; | |||
| QAction *add_logic_action_ = nullptr; | |||
| @@ -21,6 +21,7 @@ | |||
| #include <cstdint> | |||
| #include <utility> | |||
| #include <vector> | |||
| 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<std::string> 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); | |||
| @@ -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)); | |||
| @@ -11,6 +11,8 @@ enum class UiIcon | |||
| Save, | |||
| SaveAs, | |||
| Open, | |||
| Undo, | |||
| Redo, | |||
| Exit, | |||
| PlcSettings, | |||
| Disconnect, | |||
| @@ -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(); | |||
| } | |||
| @@ -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 | |||
| @@ -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) | |||
| { | |||
| @@ -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 | |||
| @@ -603,6 +603,12 @@ void testModeActionsControlEditingAvailability() | |||
| QAction *add_progress_bar_action = requiredChild<QAction>( | |||
| window, "addProgressBarAction"); | |||
| QAction *delete_control_action = requiredChild<QAction>(window, "deleteControlAction"); | |||
| QAction *undo_action = requiredChild<QAction>(window, "undoAction"); | |||
| QAction *redo_action = requiredChild<QAction>(window, "redoAction"); | |||
| QAction *delete_selection_action = requiredChild<QAction>( | |||
| window, "deleteSelectionAction"); | |||
| QAction *toggle_logic_enabled_action = requiredChild<QAction>( | |||
| window, "toggleLogicEnabledAction"); | |||
| QToolButton *hmi_more_controls = requiredChild<QToolButton>( | |||
| window, "hmiMoreControlsButton"); | |||
| QToolButton *logic_contact_menu = requiredChild<QToolButton>( | |||
| @@ -639,12 +645,15 @@ void testModeActionsControlEditingAvailability() | |||
| window, "applyPropertiesButton"); | |||
| HmiEditorWidget *hmi_editor = requiredChild<HmiEditorWidget>( | |||
| window, "hmiEditorWidget"); | |||
| LogicEditorWidget *logic_editor = requiredChild<LogicEditorWidget>( | |||
| window, "logicEditorWidget"); | |||
| QLabel *executor_status = requiredChild<QLabel>(window, "executorStatusLabel"); | |||
| QWidget *runtime_tab = requiredChild<QWidget>(window, "runtimeMonitorTab"); | |||
| QWidget *runtime_hmi = requiredChild<QWidget>(window, "runtimeHmiView"); | |||
| QWidget *runtime_logic = requiredChild<QWidget>(window, "runtimeLogicView"); | |||
| QWidget *free_monitor = requiredChild<QWidget>(window, "freeMonitorWidget"); | |||
| QTabWidget *editor_tabs = requiredChild<QTabWidget>(window, "editorTabWidget"); | |||
| QTreeWidget *project_tree = requiredChild<QTreeWidget>(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<QGraphicsItem *> 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<QAction>(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<const LogicNode *> 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"); | |||
| @@ -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 \ | |||