| @@ -80,6 +80,7 @@ HEADERS += \ | |||||
| src/domain/runtime_state.h \ | src/domain/runtime_state.h \ | ||||
| src/domain/project_storage.h \ | src/domain/project_storage.h \ | ||||
| src/services/project_service.h \ | src/services/project_service.h \ | ||||
| src/services/editor_history.h \ | |||||
| src/services/alarm_editor_service.h \ | src/services/alarm_editor_service.h \ | ||||
| src/services/alarm_service.h \ | src/services/alarm_service.h \ | ||||
| src/services/hmi_editor_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 <algorithm> | ||||
| #include <cctype> | #include <cctype> | ||||
| #include <cstddef> | #include <cstddef> | ||||
| #include <map> | |||||
| #include <set> | |||||
| #include <utility> | #include <utility> | ||||
| namespace { | 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 查找页面,找不到返回空指针 | // 遍历工程所有 HmiPage,根据页面 ID 查找页面,找不到返回空指针 | ||||
| const HmiPage *HmiEditorService::findPage(const std::string &page_id) const | const HmiPage *HmiEditorService::findPage(const std::string &page_id) const | ||||
| { | { | ||||
| @@ -109,6 +191,7 @@ HmiEditorResult HmiEditorService::ensureDefaultPage() | |||||
| } | } | ||||
| // 仅在首个控件操作前创建默认页面,空工程仍可正常保存 | // 仅在首个控件操作前创建默认页面,空工程仍可正常保存 | ||||
| HistoryState before = captureState(); | |||||
| HmiPage page; | HmiPage page; | ||||
| page.id = "page-1"; | page.id = "page-1"; | ||||
| page.name = "主操作页面"; | page.name = "主操作页面"; | ||||
| @@ -116,6 +199,7 @@ HmiEditorResult HmiEditorService::ensureDefaultPage() | |||||
| Project &project = project_service_.editProject(); | Project &project = project_service_.editProject(); | ||||
| project.hmiPages.push_back(std::move(page)); | project.hmiPages.push_back(std::move(page)); | ||||
| project.initialHmiPageId = project.hmiPages.back().id; | project.initialHmiPageId = project.hmiPages.back().id; | ||||
| recordHistory(std::move(before)); | |||||
| return {true, HmiEditorError::None, {}, project.hmiPages.back().id}; | return {true, HmiEditorError::None, {}, project.hmiPages.back().id}; | ||||
| } | } | ||||
| @@ -137,12 +221,14 @@ HmiEditorResult HmiEditorService::addPage(const std::string &name) | |||||
| HmiPage page; | HmiPage page; | ||||
| page.id = makeUniquePageId(current); | page.id = makeUniquePageId(current); | ||||
| page.name = name; | page.name = name; | ||||
| HistoryState before = captureState(); | |||||
| Project &project = project_service_.editProject(); | Project &project = project_service_.editProject(); | ||||
| project.hmiPages.push_back(std::move(page)); | project.hmiPages.push_back(std::move(page)); | ||||
| if (project.initialHmiPageId.empty()) | if (project.initialHmiPageId.empty()) | ||||
| { | { | ||||
| project.initialHmiPageId = project.hmiPages.back().id; | project.initialHmiPageId = project.hmiPages.back().id; | ||||
| } | } | ||||
| recordHistory(std::move(before)); | |||||
| return {true, HmiEditorError::None, {}, project.hmiPages.back().id}; | return {true, HmiEditorError::None, {}, project.hmiPages.back().id}; | ||||
| } | } | ||||
| @@ -171,12 +257,18 @@ HmiEditorResult HmiEditorService::renamePage( | |||||
| { | { | ||||
| return failure(HmiEditorError::DuplicateName, "HMI 页面名称必须唯一"); | return failure(HmiEditorError::DuplicateName, "HMI 页面名称必须唯一"); | ||||
| } | } | ||||
| if (existing->name == name) | |||||
| { | |||||
| return {true, HmiEditorError::None, {}, page_id}; | |||||
| } | |||||
| HistoryState before = captureState(); | |||||
| Project &project = project_service_.editProject(); | Project &project = project_service_.editProject(); | ||||
| auto page = std::find_if( | auto page = std::find_if( | ||||
| project.hmiPages.begin(), project.hmiPages.end(), | project.hmiPages.begin(), project.hmiPages.end(), | ||||
| [&page_id](const HmiPage &candidate) { return candidate.id == page_id; }); | [&page_id](const HmiPage &candidate) { return candidate.id == page_id; }); | ||||
| page->name = name; | page->name = name; | ||||
| recordHistory(std::move(before)); | |||||
| return {true, HmiEditorError::None, {}, page_id}; | 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 &project = project_service_.editProject(); | ||||
| project.hmiPages.erase( | project.hmiPages.erase( | ||||
| std::remove_if( | std::remove_if( | ||||
| project.hmiPages.begin(), project.hmiPages.end(), | project.hmiPages.begin(), project.hmiPages.end(), | ||||
| [&page_id](const HmiPage &candidate) { return candidate.id == page_id; }), | [&page_id](const HmiPage &candidate) { return candidate.id == page_id; }), | ||||
| project.hmiPages.end()); | project.hmiPages.end()); | ||||
| recordHistory(std::move(before)); | |||||
| return {true, HmiEditorError::None, {}, page_id}; | 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 页面已经位于目标边界"); | return failure(HmiEditorError::InvalidOperation, "HMI 页面已经位于目标边界"); | ||||
| } | } | ||||
| HistoryState before = captureState(); | |||||
| Project &project = project_service_.editProject(); | Project &project = project_service_.editProject(); | ||||
| std::iter_swap( | std::iter_swap( | ||||
| project.hmiPages.begin() + index, | project.hmiPages.begin() + index, | ||||
| project.hmiPages.begin() + target_index); | project.hmiPages.begin() + target_index); | ||||
| recordHistory(std::move(before)); | |||||
| return {true, HmiEditorError::None, {}, page_id}; | return {true, HmiEditorError::None, {}, page_id}; | ||||
| } | } | ||||
| @@ -262,7 +358,13 @@ HmiEditorResult HmiEditorService::setInitialPage(const std::string &page_id) | |||||
| { | { | ||||
| return failure(HmiEditorError::PageNotFound, "未找到 HMI 页面"); | 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; | project_service_.editProject().initialHmiPageId = page_id; | ||||
| recordHistory(std::move(before)); | |||||
| return {true, HmiEditorError::None, {}, page_id}; | return {true, HmiEditorError::None, {}, page_id}; | ||||
| } | } | ||||
| @@ -282,15 +384,17 @@ HmiEditorResult HmiEditorService::addControl( | |||||
| // 新控件初始不绑定寄存器,避免自动分配地址造成误写风险 | // 新控件初始不绑定寄存器,避免自动分配地址造成误写风险 | ||||
| HmiControl control = makeControl(*page, *descriptor); | HmiControl control = makeControl(*page, *descriptor); | ||||
| HistoryState before = captureState(); | |||||
| Project &project = project_service_.editProject(); | Project &project = project_service_.editProject(); | ||||
| auto target_page = std::find_if( | auto target_page = std::find_if( | ||||
| project.hmiPages.begin(), | project.hmiPages.begin(), | ||||
| project.hmiPages.end(), | project.hmiPages.end(), | ||||
| [&page_id](const HmiPage &candidate) | [&page_id](const HmiPage &candidate) | ||||
| { | |||||
| return candidate.id == page_id; | |||||
| { | |||||
| return candidate.id == page_id; | |||||
| }); | }); | ||||
| target_page->controls.push_back(std::move(control)); | target_page->controls.push_back(std::move(control)); | ||||
| recordHistory(std::move(before)); | |||||
| return {true, | return {true, | ||||
| HmiEditorError::None, | HmiEditorError::None, | ||||
| {}, | {}, | ||||
| @@ -299,16 +403,36 @@ HmiEditorResult HmiEditorService::addControl( | |||||
| HmiEditorResult HmiEditorService::removeControl( | HmiEditorResult HmiEditorService::removeControl( | ||||
| const std::string &page_id, const std::string &control_id) | 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) | if (findPage(page_id) == nullptr) | ||||
| { | { | ||||
| return failure(HmiEditorError::PageNotFound, "未找到 HMI 页面"); | 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(); | Project &project = project_service_.editProject(); | ||||
| auto page = std::find_if( | auto page = std::find_if( | ||||
| project.hmiPages.begin(), | project.hmiPages.begin(), | ||||
| @@ -321,12 +445,13 @@ HmiEditorResult HmiEditorService::removeControl( | |||||
| std::remove_if( | std::remove_if( | ||||
| page->controls.begin(), | page->controls.begin(), | ||||
| page->controls.end(), | 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()); | 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); | 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(); | Project &project = project_service_.editProject(); | ||||
| auto target = std::find_if( | auto target = std::find_if( | ||||
| project.hmiPages.begin(), project.hmiPages.end(), | project.hmiPages.begin(), project.hmiPages.end(), | ||||
| @@ -364,6 +497,7 @@ HmiEditorResult HmiEditorService::moveControl( | |||||
| target->controls.begin(), target->controls.end(), | target->controls.begin(), target->controls.end(), | ||||
| [&control_id](const HmiControl &item) { return item.id == control_id; }); | [&control_id](const HmiControl &item) { return item.id == control_id; }); | ||||
| editable->bounds = bounds; | editable->bounds = bounds; | ||||
| recordHistory(std::move(before)); | |||||
| return {true, HmiEditorError::None, {}, control_id}; | return {true, HmiEditorError::None, {}, control_id}; | ||||
| } | } | ||||
| @@ -398,6 +532,11 @@ HmiEditorResult HmiEditorService::updateControl( | |||||
| return failure(HmiEditorError::DuplicateId, "同一 HMI 页面内的控件 ID 必须唯一"); | return failure(HmiEditorError::DuplicateId, "同一 HMI 页面内的控件 ID 必须唯一"); | ||||
| } | } | ||||
| if (controlsEqual(*existing, control)) | |||||
| { | |||||
| return {true, HmiEditorError::None, {}, control.id}; | |||||
| } | |||||
| HistoryState before = captureState(); | |||||
| Project &project = project_service_.editProject(); | Project &project = project_service_.editProject(); | ||||
| auto target = std::find_if( | auto target = std::find_if( | ||||
| project.hmiPages.begin(), project.hmiPages.end(), | project.hmiPages.begin(), project.hmiPages.end(), | ||||
| @@ -406,9 +545,51 @@ HmiEditorResult HmiEditorService::updateControl( | |||||
| target->controls.begin(), target->controls.end(), | target->controls.begin(), target->controls.end(), | ||||
| [&control_id](const HmiControl &item) { return item.id == control_id; }); | [&control_id](const HmiControl &item) { return item.id == control_id; }); | ||||
| *editable = control; | *editable = control; | ||||
| recordHistory(std::move(before)); | |||||
| return {true, HmiEditorError::None, {}, control.id}; | 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( | bool HmiEditorService::validateEditableControl( | ||||
| const HmiPage &page, const HmiControl &control, std::string *error) const | const HmiPage &page, const HmiControl &control, std::string *error) const | ||||
| { | { | ||||
| @@ -8,8 +8,10 @@ | |||||
| #pragma once | #pragma once | ||||
| #include "domain/hmi_model.h" | #include "domain/hmi_model.h" | ||||
| #include "editor_history.h" | |||||
| #include <string> | #include <string> | ||||
| #include <vector> | |||||
| class ProjectService; | class ProjectService; | ||||
| struct HmiControlDescriptor; | struct HmiControlDescriptor; | ||||
| @@ -104,6 +106,9 @@ public: | |||||
| */ | */ | ||||
| HmiEditorResult removeControl( | HmiEditorResult removeControl( | ||||
| const std::string &page_id, const std::string &control_id); | 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 更新控件位置和尺寸 | * @brief 更新控件位置和尺寸 | ||||
| * @param page_id 所属页面唯一标识 | * @param page_id 所属页面唯一标识 | ||||
| @@ -129,7 +134,28 @@ public: | |||||
| const std::string &control_id, | const std::string &control_id, | ||||
| const HmiControl &control); | const HmiControl &control); | ||||
| bool canUndo() const; | |||||
| bool canRedo() const; | |||||
| HmiEditorResult undo(); | |||||
| HmiEditorResult redo(); | |||||
| void clearHistory(); | |||||
| private: | 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 校验控件编辑后仍满足页面内的基础约束 | * @brief 校验控件编辑后仍满足页面内的基础约束 | ||||
| * @param page 控件所属页面 | * @param page 控件所属页面 | ||||
| @@ -173,4 +199,5 @@ private: | |||||
| * @brief 非拥有的工程服务依赖,由应用入口保证生命周期 | * @brief 非拥有的工程服务依赖,由应用入口保证生命周期 | ||||
| */ | */ | ||||
| ProjectService &project_service_; | 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 ControlLogic *LogicEditorService::findLogic(const std::string &logic_id) const | ||||
| { | { | ||||
| const auto &logics = project_service_.project().controlLogics; | const auto &logics = project_service_.project().controlLogics; | ||||
| @@ -394,8 +571,10 @@ LogicEditorResult LogicEditorService::addLogic(const std::string &name) | |||||
| logic.id = makeUniqueLogicId(current); | logic.id = makeUniqueLogicId(current); | ||||
| logic.name = name; | logic.name = name; | ||||
| logic.rungs.push_back({"rung-1", "网络 1", {}, std::nullopt, std::nullopt}); | logic.rungs.push_back({"rung-1", "网络 1", {}, std::nullopt, std::nullopt}); | ||||
| HistoryState before = captureState(); | |||||
| Project &project = project_service_.editProject(); | Project &project = project_service_.editProject(); | ||||
| project.controlLogics.push_back(std::move(logic)); | project.controlLogics.push_back(std::move(logic)); | ||||
| recordHistory(std::move(before)); | |||||
| return {true, LogicEditorError::None, {}, project.controlLogics.back().id}; | return {true, LogicEditorError::None, {}, project.controlLogics.back().id}; | ||||
| } | } | ||||
| @@ -425,11 +604,17 @@ LogicEditorResult LogicEditorService::renameLogic( | |||||
| return failure(LogicEditorError::DuplicateName, "控制逻辑名称必须唯一"); | return failure(LogicEditorError::DuplicateName, "控制逻辑名称必须唯一"); | ||||
| } | } | ||||
| if (logic->name == name) | |||||
| { | |||||
| return {true, LogicEditorError::None, {}, logic_id}; | |||||
| } | |||||
| HistoryState before = captureState(); | |||||
| Project &project = project_service_.editProject(); | Project &project = project_service_.editProject(); | ||||
| auto editable = std::find_if( | auto editable = std::find_if( | ||||
| project.controlLogics.begin(), project.controlLogics.end(), | project.controlLogics.begin(), project.controlLogics.end(), | ||||
| [&logic_id](const ControlLogic &candidate) { return candidate.id == logic_id; }); | [&logic_id](const ControlLogic &candidate) { return candidate.id == logic_id; }); | ||||
| editable->name = name; | editable->name = name; | ||||
| recordHistory(std::move(before)); | |||||
| return {true, LogicEditorError::None, {}, logic_id}; | return {true, LogicEditorError::None, {}, logic_id}; | ||||
| } | } | ||||
| @@ -447,12 +632,14 @@ LogicEditorResult LogicEditorService::removeLogic(const std::string &logic_id) | |||||
| { | { | ||||
| return failure(LogicEditorError::LastLogicRequired, "工程至少需要保留一个控制逻辑"); | return failure(LogicEditorError::LastLogicRequired, "工程至少需要保留一个控制逻辑"); | ||||
| } | } | ||||
| HistoryState before = captureState(); | |||||
| Project &project = project_service_.editProject(); | Project &project = project_service_.editProject(); | ||||
| project.controlLogics.erase( | project.controlLogics.erase( | ||||
| std::remove_if( | std::remove_if( | ||||
| project.controlLogics.begin(), project.controlLogics.end(), | project.controlLogics.begin(), project.controlLogics.end(), | ||||
| [&logic_id](const ControlLogic &candidate) { return candidate.id == logic_id; }), | [&logic_id](const ControlLogic &candidate) { return candidate.id == logic_id; }), | ||||
| project.controlLogics.end()); | project.controlLogics.end()); | ||||
| recordHistory(std::move(before)); | |||||
| return {true, LogicEditorError::None, {}, logic_id}; | return {true, LogicEditorError::None, {}, logic_id}; | ||||
| } | } | ||||
| @@ -478,10 +665,12 @@ LogicEditorResult LogicEditorService::moveLogic(const std::string &logic_id, int | |||||
| { | { | ||||
| return failure(LogicEditorError::InvalidOperation, "控制逻辑已经位于目标边界"); | return failure(LogicEditorError::InvalidOperation, "控制逻辑已经位于目标边界"); | ||||
| } | } | ||||
| HistoryState before = captureState(); | |||||
| Project &project = project_service_.editProject(); | Project &project = project_service_.editProject(); | ||||
| std::iter_swap( | std::iter_swap( | ||||
| project.controlLogics.begin() + index, | project.controlLogics.begin() + index, | ||||
| project.controlLogics.begin() + target_index); | project.controlLogics.begin() + target_index); | ||||
| recordHistory(std::move(before)); | |||||
| return {true, LogicEditorError::None, {}, logic_id}; | return {true, LogicEditorError::None, {}, logic_id}; | ||||
| } | } | ||||
| @@ -492,11 +681,18 @@ LogicEditorResult LogicEditorService::setLogicEnabled( | |||||
| { | { | ||||
| return failure(LogicEditorError::LogicNotFound, "未找到控制逻辑"); | 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(); | Project &project = project_service_.editProject(); | ||||
| auto logic = std::find_if( | auto logic = std::find_if( | ||||
| project.controlLogics.begin(), project.controlLogics.end(), | project.controlLogics.begin(), project.controlLogics.end(), | ||||
| [&logic_id](const ControlLogic &candidate) { return candidate.id == logic_id; }); | [&logic_id](const ControlLogic &candidate) { return candidate.id == logic_id; }); | ||||
| logic->enabled = enabled; | logic->enabled = enabled; | ||||
| recordHistory(std::move(before)); | |||||
| return {true, LogicEditorError::None, {}, logic_id}; | return {true, LogicEditorError::None, {}, logic_id}; | ||||
| } | } | ||||
| @@ -510,11 +706,13 @@ LogicEditorResult LogicEditorService::addRung(const std::string &logic_id) | |||||
| LadderRung rung; | LadderRung rung; | ||||
| rung.id = makeUniqueRungId(*logic); | rung.id = makeUniqueRungId(*logic); | ||||
| rung.name = "网络 " + std::to_string(logic->rungs.size() + 1U); | rung.name = "网络 " + std::to_string(logic->rungs.size() + 1U); | ||||
| HistoryState before = captureState(); | |||||
| Project &project = project_service_.editProject(); | Project &project = project_service_.editProject(); | ||||
| auto target = std::find_if( | auto target = std::find_if( | ||||
| project.controlLogics.begin(), project.controlLogics.end(), | project.controlLogics.begin(), project.controlLogics.end(), | ||||
| [&logic_id](const ControlLogic &candidate) { return candidate.id == logic_id; }); | [&logic_id](const ControlLogic &candidate) { return candidate.id == logic_id; }); | ||||
| target->rungs.push_back(std::move(rung)); | target->rungs.push_back(std::move(rung)); | ||||
| recordHistory(std::move(before)); | |||||
| return {true, LogicEditorError::None, {}, target->rungs.back().id}; | return {true, LogicEditorError::None, {}, target->rungs.back().id}; | ||||
| } | } | ||||
| @@ -534,6 +732,7 @@ LogicEditorResult LogicEditorService::removeRung( | |||||
| { | { | ||||
| return failure(LogicEditorError::InvalidOperation, "控制逻辑至少需要保留一个网络"); | return failure(LogicEditorError::InvalidOperation, "控制逻辑至少需要保留一个网络"); | ||||
| } | } | ||||
| HistoryState before = captureState(); | |||||
| Project &project = project_service_.editProject(); | Project &project = project_service_.editProject(); | ||||
| auto target = std::find_if( | auto target = std::find_if( | ||||
| project.controlLogics.begin(), project.controlLogics.end(), | project.controlLogics.begin(), project.controlLogics.end(), | ||||
| @@ -543,6 +742,7 @@ LogicEditorResult LogicEditorService::removeRung( | |||||
| target->rungs.begin(), target->rungs.end(), | target->rungs.begin(), target->rungs.end(), | ||||
| [&rung_id](const LadderRung &rung) { return rung.id == rung_id; }), | [&rung_id](const LadderRung &rung) { return rung.id == rung_id; }), | ||||
| target->rungs.end()); | target->rungs.end()); | ||||
| recordHistory(std::move(before)); | |||||
| return {true, LogicEditorError::None, {}, rung_id}; | return {true, LogicEditorError::None, {}, rung_id}; | ||||
| } | } | ||||
| @@ -556,9 +756,15 @@ LogicEditorResult LogicEditorService::updateRungComment( | |||||
| { | { | ||||
| return failure(LogicEditorError::RungNotFound, "未找到梯形图网络"); | return failure(LogicEditorError::RungNotFound, "未找到梯形图网络"); | ||||
| } | } | ||||
| if (rung->comment == comment) | |||||
| { | |||||
| return {true, LogicEditorError::None, {}, rung_id}; | |||||
| } | |||||
| HistoryState before = captureState(); | |||||
| LadderRung &editable = *findEditableRung( | LadderRung &editable = *findEditableRung( | ||||
| project_service_.editProject(), logic_id, rung_id); | project_service_.editProject(), logic_id, rung_id); | ||||
| editable.comment = comment; | editable.comment = comment; | ||||
| recordHistory(std::move(before)); | |||||
| return {true, LogicEditorError::None, {}, rung_id}; | return {true, LogicEditorError::None, {}, rung_id}; | ||||
| } | } | ||||
| @@ -580,6 +786,7 @@ LogicEditorResult LogicEditorService::appendCondition( | |||||
| } | } | ||||
| const std::string node_id = makeUniqueNodeId(*logic, nodePrefix(config)); | const std::string node_id = makeUniqueNodeId(*logic, nodePrefix(config)); | ||||
| ConditionExpression leaf = ConditionExpression::fromNode(makeNode(node_id, config)); | ConditionExpression leaf = ConditionExpression::fromNode(makeNode(node_id, config)); | ||||
| HistoryState before = captureState(); | |||||
| Project &project = project_service_.editProject(); | Project &project = project_service_.editProject(); | ||||
| LadderRung *rung = findEditableRung(project, logic_id, rung_id); | LadderRung *rung = findEditableRung(project, logic_id, rung_id); | ||||
| if (!rung->condition.has_value()) | if (!rung->condition.has_value()) | ||||
| @@ -598,6 +805,7 @@ LogicEditorResult LogicEditorService::appendCondition( | |||||
| std::move(*rung->condition), | std::move(*rung->condition), | ||||
| std::move(leaf)); | std::move(leaf)); | ||||
| } | } | ||||
| recordHistory(std::move(before)); | |||||
| return {true, LogicEditorError::None, {}, node_id}; | return {true, LogicEditorError::None, {}, node_id}; | ||||
| } | } | ||||
| @@ -622,6 +830,7 @@ LogicEditorResult LogicEditorService::insertConditionAfter( | |||||
| } | } | ||||
| const std::string node_id = makeUniqueNodeId(*logic, nodePrefix(config)); | const std::string node_id = makeUniqueNodeId(*logic, nodePrefix(config)); | ||||
| ConditionExpression leaf = ConditionExpression::fromNode(makeNode(node_id, config)); | ConditionExpression leaf = ConditionExpression::fromNode(makeNode(node_id, config)); | ||||
| HistoryState before = captureState(); | |||||
| Project &project = project_service_.editProject(); | Project &project = project_service_.editProject(); | ||||
| LadderRung *rung = findEditableRung(project, logic_id, rung_id); | LadderRung *rung = findEditableRung(project, logic_id, rung_id); | ||||
| ConditionExpression *target = findConditionExpression(*rung->condition, target_node_id); | ConditionExpression *target = findConditionExpression(*rung->condition, target_node_id); | ||||
| @@ -645,6 +854,7 @@ LogicEditorResult LogicEditorService::insertConditionAfter( | |||||
| std::move(original), | std::move(original), | ||||
| std::move(leaf)); | std::move(leaf)); | ||||
| } | } | ||||
| recordHistory(std::move(before)); | |||||
| return {true, LogicEditorError::None, {}, node_id}; | return {true, LogicEditorError::None, {}, node_id}; | ||||
| } | } | ||||
| @@ -690,6 +900,7 @@ LogicEditorResult LogicEditorService::addParallelBranch( | |||||
| { | { | ||||
| series_id += "-range"; | series_id += "-range"; | ||||
| } | } | ||||
| HistoryState before = captureState(); | |||||
| Project &project = project_service_.editProject(); | Project &project = project_service_.editProject(); | ||||
| LadderRung *rung = findEditableRung(project, logic_id, rung_id); | LadderRung *rung = findEditableRung(project, logic_id, rung_id); | ||||
| if (!addParallelForSelection( | if (!addParallelForSelection( | ||||
| @@ -703,13 +914,15 @@ LogicEditorResult LogicEditorService::addParallelBranch( | |||||
| LogicEditorError::InvalidOperation, | LogicEditorError::InvalidOperation, | ||||
| "并联选择必须是一个连续的逻辑范围"); | "并联选择必须是一个连续的逻辑范围"); | ||||
| } | } | ||||
| recordHistory(std::move(before)); | |||||
| return {true, LogicEditorError::None, {}, node_id}; | return {true, LogicEditorError::None, {}, node_id}; | ||||
| } | } | ||||
| LogicEditorResult LogicEditorService::setOutput( | LogicEditorResult LogicEditorService::setOutput( | ||||
| const std::string &logic_id, | const std::string &logic_id, | ||||
| const std::string &rung_id, | const std::string &rung_id, | ||||
| const LogicNodeConfig &config) | |||||
| const LogicNodeConfig &config, | |||||
| bool configured) | |||||
| { | { | ||||
| const ControlLogic *logic = findLogic(logic_id); | const ControlLogic *logic = findLogic(logic_id); | ||||
| const LadderRung *existing_rung = findRung(logic_id, rung_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() | const std::string node_id = existing_rung->output.has_value() | ||||
| ? existing_rung->output->id : makeUniqueNodeId(*logic, nodePrefix(config)); | ? 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(); | 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}; | return {true, LogicEditorError::None, {}, node_id}; | ||||
| } | } | ||||
| @@ -748,6 +970,11 @@ LogicEditorResult LogicEditorService::updateNodeConfig( | |||||
| { | { | ||||
| return failure(LogicEditorError::InvalidNode, error); | return failure(LogicEditorError::InvalidNode, error); | ||||
| } | } | ||||
| if (nodesEqual(*node, candidate)) | |||||
| { | |||||
| return {true, LogicEditorError::None, {}, node_id}; | |||||
| } | |||||
| HistoryState before = captureState(); | |||||
| Project &project = project_service_.editProject(); | Project &project = project_service_.editProject(); | ||||
| for (ControlLogic &logic : project.controlLogics) | for (ControlLogic &logic : project.controlLogics) | ||||
| { | { | ||||
| @@ -760,6 +987,7 @@ LogicEditorResult LogicEditorService::updateNodeConfig( | |||||
| if (rung.output.has_value() && rung.output->id == node_id) | if (rung.output.has_value() && rung.output->id == node_id) | ||||
| { | { | ||||
| *rung.output = candidate; | *rung.output = candidate; | ||||
| recordHistory(std::move(before)); | |||||
| return {true, LogicEditorError::None, {}, node_id}; | return {true, LogicEditorError::None, {}, node_id}; | ||||
| } | } | ||||
| if (rung.condition.has_value()) | if (rung.condition.has_value()) | ||||
| @@ -767,6 +995,7 @@ LogicEditorResult LogicEditorService::updateNodeConfig( | |||||
| if (LogicNode *editable = findConditionNode(*rung.condition, node_id)) | if (LogicNode *editable = findConditionNode(*rung.condition, node_id)) | ||||
| { | { | ||||
| *editable = candidate; | *editable = candidate; | ||||
| recordHistory(std::move(before)); | |||||
| return {true, LogicEditorError::None, {}, node_id}; | return {true, LogicEditorError::None, {}, node_id}; | ||||
| } | } | ||||
| } | } | ||||
| @@ -778,19 +1007,72 @@ LogicEditorResult LogicEditorService::updateNodeConfig( | |||||
| LogicEditorResult LogicEditorService::removeNode( | LogicEditorResult LogicEditorService::removeNode( | ||||
| const std::string &logic_id, const std::string &node_id) | 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( | LogicEditorResult LogicEditorService::removeExpression( | ||||
| @@ -802,6 +1084,7 @@ LogicEditorResult LogicEditorService::removeExpression( | |||||
| { | { | ||||
| return failure(LogicEditorError::ExpressionNotFound, "未找到条件支路"); | return failure(LogicEditorError::ExpressionNotFound, "未找到条件支路"); | ||||
| } | } | ||||
| HistoryState before = captureState(); | |||||
| Project &project = project_service_.editProject(); | Project &project = project_service_.editProject(); | ||||
| LadderRung *rung = findEditableRung(project, logic_id, rung_id); | LadderRung *rung = findEditableRung(project, logic_id, rung_id); | ||||
| if (rung->condition->id == expression_id) | if (rung->condition->id == expression_id) | ||||
| @@ -813,9 +1096,47 @@ LogicEditorResult LogicEditorService::removeExpression( | |||||
| removeExpressionRecursive(&*rung->condition, expression_id); | removeExpressionRecursive(&*rung->condition, expression_id); | ||||
| normalizeConditionExpression(&rung->condition); | normalizeConditionExpression(&rung->condition); | ||||
| } | } | ||||
| recordHistory(std::move(before)); | |||||
| return {true, LogicEditorError::None, {}, expression_id}; | 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) | bool LogicEditorService::isConditionConfig(const LogicNodeConfig &config) | ||||
| { | { | ||||
| return std::holds_alternative<ContactNodeConfig>(config) | return std::holds_alternative<ContactNodeConfig>(config) | ||||
| @@ -1,6 +1,7 @@ | |||||
| #pragma once | #pragma once | ||||
| #include "domain/control_logic_model.h" | #include "domain/control_logic_model.h" | ||||
| #include "editor_history.h" | |||||
| #include <string> | #include <string> | ||||
| #include <vector> | #include <vector> | ||||
| @@ -80,19 +81,49 @@ public: | |||||
| LogicEditorResult setOutput( | LogicEditorResult setOutput( | ||||
| const std::string &logic_id, | const std::string &logic_id, | ||||
| const std::string &rung_id, | const std::string &rung_id, | ||||
| const LogicNodeConfig &config); | |||||
| const LogicNodeConfig &config, | |||||
| bool configured = false); | |||||
| LogicEditorResult updateNodeConfig( | LogicEditorResult updateNodeConfig( | ||||
| const std::string &logic_id, | const std::string &logic_id, | ||||
| const std::string &node_id, | const std::string &node_id, | ||||
| const LogicNodeConfig &config); | const LogicNodeConfig &config); | ||||
| LogicEditorResult removeNode( | LogicEditorResult removeNode( | ||||
| const std::string &logic_id, const std::string &node_id); | 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( | LogicEditorResult removeExpression( | ||||
| const std::string &logic_id, | const std::string &logic_id, | ||||
| const std::string &rung_id, | const std::string &rung_id, | ||||
| const std::string &expression_id); | const std::string &expression_id); | ||||
| bool canUndo() const; | |||||
| bool canRedo() const; | |||||
| LogicEditorResult undo(); | |||||
| LogicEditorResult redo(); | |||||
| void clearHistory(); | |||||
| private: | 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 isConditionConfig(const LogicNodeConfig &config); | ||||
| static bool isOutputConfig(const LogicNodeConfig &config); | static bool isOutputConfig(const LogicNodeConfig &config); | ||||
| static std::string nodePrefix(const LogicNodeConfig &config); | static std::string nodePrefix(const LogicNodeConfig &config); | ||||
| @@ -104,4 +135,5 @@ private: | |||||
| LogicEditorError error, const std::string &message); | LogicEditorError error, const std::string &message); | ||||
| ProjectService &project_service_; | ProjectService &project_service_; | ||||
| EditorHistory<HistoryState> history_; | |||||
| }; | }; | ||||
| @@ -751,6 +751,7 @@ void HmiEditorWidget::reloadPage() | |||||
| // 遍历场景所有图元,找到对应 id 的图元,设置选中,视图滚动到把控件显示出来 | // 遍历场景所有图元,找到对应 id 的图元,设置选中,视图滚动到把控件显示出来 | ||||
| void HmiEditorWidget::selectControl(const std::string &control_id) | void HmiEditorWidget::selectControl(const std::string &control_id) | ||||
| { | { | ||||
| scene_->clearSelection(); | |||||
| for (QGraphicsItem *item : scene_->items()) | for (QGraphicsItem *item : scene_->items()) | ||||
| { | { | ||||
| HmiGraphicsItem *control_item = asHmiItem(item); | HmiGraphicsItem *control_item = asHmiItem(item); | ||||
| @@ -765,16 +766,39 @@ void HmiEditorWidget::selectControl(const std::string &control_id) | |||||
| std::string HmiEditorWidget::selectedControlId() const | 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); | const HmiGraphicsItem *control_item = asHmiItem(item); | ||||
| if (control_item != nullptr) | 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() | void HmiEditorWidget::refreshRuntimeValues() | ||||
| @@ -10,6 +10,7 @@ | |||||
| #include <QGraphicsView> | #include <QGraphicsView> | ||||
| #include <string> | #include <string> | ||||
| #include <vector> | |||||
| class HmiEditorService; | class HmiEditorService; | ||||
| class HmiRuntimeService; | class HmiRuntimeService; | ||||
| @@ -69,6 +70,7 @@ public: | |||||
| */ | */ | ||||
| void selectControl(const std::string &control_id); | void selectControl(const std::string &control_id); | ||||
| std::string selectedControlId() const; | std::string selectedControlId() const; | ||||
| std::vector<std::string> selectedControlIds() const; | |||||
| /** | /** | ||||
| * @brief 从统一寄存器仓库读取当前值并刷新画布显示 | * @brief 从统一寄存器仓库读取当前值并刷新画布显示 | ||||
| * | * | ||||
| @@ -1085,10 +1085,11 @@ LogicEditorResult LogicEditorWidget::addParallelBranch(const LogicNodeConfig &co | |||||
| return result; | return result; | ||||
| } | } | ||||
| LogicEditorResult LogicEditorWidget::setOutput(const LogicNodeConfig &config) | |||||
| LogicEditorResult LogicEditorWidget::setOutput( | |||||
| const LogicNodeConfig &config, bool configured) | |||||
| { | { | ||||
| const LogicEditorResult result = editor_service_.setOutput( | const LogicEditorResult result = editor_service_.setOutput( | ||||
| logic_id_, currentRungId(), config); | |||||
| logic_id_, currentRungId(), config, configured); | |||||
| if (result.succeeded) | if (result.succeeded) | ||||
| { | { | ||||
| reloadLogic(); | reloadLogic(); | ||||
| @@ -1108,14 +1109,7 @@ LogicEditorResult LogicEditorWidget::deleteSelected() | |||||
| LogicEditorResult result; | LogicEditorResult result; | ||||
| if (!node_ids.empty()) | 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 | else | ||||
| { | { | ||||
| @@ -39,7 +39,8 @@ public: | |||||
| LogicEditorResult addRung(); | LogicEditorResult addRung(); | ||||
| LogicEditorResult addCondition(const LogicNodeConfig &config); | LogicEditorResult addCondition(const LogicNodeConfig &config); | ||||
| LogicEditorResult addParallelBranch(const LogicNodeConfig &config); | LogicEditorResult addParallelBranch(const LogicNodeConfig &config); | ||||
| LogicEditorResult setOutput(const LogicNodeConfig &config); | |||||
| LogicEditorResult setOutput( | |||||
| const LogicNodeConfig &config, bool configured = false); | |||||
| LogicEditorResult deleteSelected(); | LogicEditorResult deleteSelected(); | ||||
| signals: | signals: | ||||
| @@ -24,20 +24,53 @@ | |||||
| #include "ui_main_window.h" | #include "ui_main_window.h" | ||||
| #include <QActionGroup> | #include <QActionGroup> | ||||
| #include <QAbstractSpinBox> | |||||
| #include <QApplication> | #include <QApplication> | ||||
| #include <QEvent> | |||||
| #include <QFileDialog> | #include <QFileDialog> | ||||
| #include <QGraphicsScene> | |||||
| #include <QInputDialog> | #include <QInputDialog> | ||||
| #include <QIcon> | #include <QIcon> | ||||
| #include <QLabel> | #include <QLabel> | ||||
| #include <QLineEdit> | |||||
| #include <QKeyEvent> | |||||
| #include <QKeySequence> | |||||
| #include <QMessageBox> | #include <QMessageBox> | ||||
| #include <QMenu> | #include <QMenu> | ||||
| #include <QPlainTextEdit> | |||||
| #include <QStatusBar> | #include <QStatusBar> | ||||
| #include <QTabWidget> | #include <QTabWidget> | ||||
| #include <QToolBar> | #include <QToolBar> | ||||
| #include <QToolButton> | #include <QToolButton> | ||||
| #include <QTextEdit> | |||||
| namespace { | 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) | QString modeText(ApplicationMode mode) | ||||
| { | { | ||||
| switch (mode) | switch (mode) | ||||
| @@ -220,6 +253,7 @@ MainWindow::MainWindow( | |||||
| void MainWindow::initializeUi() | void MainWindow::initializeUi() | ||||
| { | { | ||||
| ui_->setupUi(this); | ui_->setupUi(this); | ||||
| qApp->installEventFilter(this); | |||||
| configureAppearance(); | configureAppearance(); | ||||
| property_panel_controller_ = std::make_unique<PropertyPanelController>( | property_panel_controller_ = std::make_unique<PropertyPanelController>( | ||||
| *this, | *this, | ||||
| @@ -253,6 +287,7 @@ void MainWindow::initializeUi() | |||||
| [this] { schedulePlcStatusUpdate(); }); | [this] { schedulePlcStatusUpdate(); }); | ||||
| hmi_editor_service_.ensureDefaultPage(); | hmi_editor_service_.ensureDefaultPage(); | ||||
| logic_editor_service_.ensureDefaultLogic(); | logic_editor_service_.ensureDefaultLogic(); | ||||
| clearEditorHistories(); | |||||
| current_hmi_page_id_ = hmi_editor_service_.firstPageId(); | current_hmi_page_id_ = hmi_editor_service_.firstPageId(); | ||||
| current_logic_id_ = logic_editor_service_.firstLogicId(); | current_logic_id_ = logic_editor_service_.firstLogicId(); | ||||
| refreshProjectUi(); | refreshProjectUi(); | ||||
| @@ -261,9 +296,25 @@ void MainWindow::initializeUi() | |||||
| MainWindow::~MainWindow() | MainWindow::~MainWindow() | ||||
| { | { | ||||
| qApp->removeEventFilter(this); | |||||
| runtime_mode_service_.setPlcStatusChangedCallback({}); | 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 | const std::string &MainWindow::currentHmiPageId() const | ||||
| { | { | ||||
| return current_hmi_page_id_; | return current_hmi_page_id_; | ||||
| @@ -293,6 +344,14 @@ void MainWindow::configureActions() | |||||
| connect(ui_->saveProjectAction, &QAction::triggered, this, &MainWindow::saveProject); | connect(ui_->saveProjectAction, &QAction::triggered, this, &MainWindow::saveProject); | ||||
| connect(ui_->saveAsProjectAction, &QAction::triggered, this, &MainWindow::saveProjectAs); | connect(ui_->saveAsProjectAction, &QAction::triggered, this, &MainWindow::saveProjectAs); | ||||
| connect(ui_->loadProjectAction, &QAction::triggered, this, &MainWindow::loadProject); | 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, | connect(ui_->addButtonAction, &QAction::triggered, this, | ||||
| [this] { addHmiControl(HmiControlType::Button); }); | [this] { addHmiControl(HmiControlType::Button); }); | ||||
| @@ -636,6 +695,10 @@ void MainWindow::configureAppearance() | |||||
| ui_->saveProjectAction->setIcon(makeUiIcon(UiIcon::Save)); | ui_->saveProjectAction->setIcon(makeUiIcon(UiIcon::Save)); | ||||
| ui_->saveAsProjectAction->setIcon(makeUiIcon(UiIcon::SaveAs)); | ui_->saveAsProjectAction->setIcon(makeUiIcon(UiIcon::SaveAs)); | ||||
| ui_->loadProjectAction->setIcon(makeUiIcon(UiIcon::Open)); | 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_->exitAction->setIcon(makeUiIcon(UiIcon::Exit)); | ||||
| ui_->configurePlcAction->setIcon(makeUiIcon(UiIcon::PlcSettings)); | ui_->configurePlcAction->setIcon(makeUiIcon(UiIcon::PlcSettings)); | ||||
| ui_->disconnectPlcAction->setIcon(makeUiIcon(UiIcon::Disconnect)); | ui_->disconnectPlcAction->setIcon(makeUiIcon(UiIcon::Disconnect)); | ||||
| @@ -802,6 +865,10 @@ void MainWindow::configureProjectTree() | |||||
| [this](const QString &message, int timeout_ms) | [this](const QString &message, int timeout_ms) | ||||
| { | { | ||||
| statusBar()->showMessage(message, timeout_ms); | statusBar()->showMessage(message, timeout_ms); | ||||
| }, | |||||
| [this] | |||||
| { | |||||
| updateEditActions(); | |||||
| }); | }); | ||||
| project_workspace_controller_->configure(); | 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() | void MainWindow::refreshProjectUi() | ||||
| { | { | ||||
| project_workspace_controller_->refresh(); | project_workspace_controller_->refresh(); | ||||
| updateEditActions(); | |||||
| } | } | ||||
| void MainWindow::updateProjectTreeActions() | void MainWindow::updateProjectTreeActions() | ||||
| @@ -979,23 +1167,14 @@ void MainWindow::configureAndSetLogicOutput(const LogicNodeConfig &config) | |||||
| return; | return; | ||||
| } | } | ||||
| const LogicNodeConfig configured = dialog.config(); | const LogicNodeConfig configured = dialog.config(); | ||||
| LogicEditorResult result = logic_editor_widget_->setOutput(configured); | |||||
| LogicEditorResult result = logic_editor_widget_->setOutput(configured, true); | |||||
| if (!result.succeeded) | if (!result.succeeded) | ||||
| { | { | ||||
| showProjectResult(tr("设置逻辑输出"), fromUtf8(result.message), false); | showProjectResult(tr("设置逻辑输出"), fromUtf8(result.message), false); | ||||
| return; | return; | ||||
| } | } | ||||
| const std::string node_id = result.id; | 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; | selected_logic_node_id_ = node_id; | ||||
| logic_editor_widget_->reloadLogic(); | |||||
| logic_editor_widget_->selectNode(node_id); | |||||
| showLogicNodeProperties(node_id); | showLogicNodeProperties(node_id); | ||||
| refreshProjectUi(); | refreshProjectUi(); | ||||
| statusBar()->showMessage(tr("逻辑输出已配置"), 3000); | statusBar()->showMessage(tr("逻辑输出已配置"), 3000); | ||||
| @@ -1080,6 +1259,7 @@ void MainWindow::createNewProject() | |||||
| } | } | ||||
| hmi_editor_service_.ensureDefaultPage(); | hmi_editor_service_.ensureDefaultPage(); | ||||
| logic_editor_service_.ensureDefaultLogic(); | logic_editor_service_.ensureDefaultLogic(); | ||||
| clearEditorHistories(); | |||||
| selected_control_id_.clear(); | selected_control_id_.clear(); | ||||
| selected_logic_node_id_.clear(); | selected_logic_node_id_.clear(); | ||||
| current_hmi_page_id_ = project_service_.project().initialHmiPageId; | current_hmi_page_id_ = project_service_.project().initialHmiPageId; | ||||
| @@ -1131,6 +1311,7 @@ void MainWindow::loadProject() | |||||
| showProjectResult(tr("加载工程"), fromUtf8(result.message), false); | showProjectResult(tr("加载工程"), fromUtf8(result.message), false); | ||||
| return; | return; | ||||
| } | } | ||||
| clearEditorHistories(); | |||||
| selected_control_id_.clear(); | selected_control_id_.clear(); | ||||
| selected_logic_node_id_.clear(); | selected_logic_node_id_.clear(); | ||||
| current_hmi_page_id_ = project_service_.project().initialHmiPageId; | current_hmi_page_id_ = project_service_.project().initialHmiPageId; | ||||
| @@ -1285,6 +1466,7 @@ void MainWindow::updateModeUi(const QString &message) | |||||
| ui_->saveAsProjectAction->setEnabled(policy.allowsProjectEditing); | ui_->saveAsProjectAction->setEnabled(policy.allowsProjectEditing); | ||||
| ui_->loadProjectAction->setEnabled(policy.allowsProjectEditing); | ui_->loadProjectAction->setEnabled(policy.allowsProjectEditing); | ||||
| updateProjectTreeActions(); | updateProjectTreeActions(); | ||||
| updateEditActions(); | |||||
| mode_status_label_->setText(modeText(mode)); | mode_status_label_->setText(modeText(mode)); | ||||
| if (mode == ApplicationMode::Editing) | if (mode == ApplicationMode::Editing) | ||||
| @@ -20,6 +20,7 @@ | |||||
| QT_BEGIN_NAMESPACE | QT_BEGIN_NAMESPACE | ||||
| class QAction; | class QAction; | ||||
| class QActionGroup; | class QActionGroup; | ||||
| class QEvent; | |||||
| class QLabel; | class QLabel; | ||||
| namespace Ui { | namespace Ui { | ||||
| @@ -91,6 +92,9 @@ public: | |||||
| const std::string ¤tHmiPageId() const; | const std::string ¤tHmiPageId() const; | ||||
| const std::string ¤tLogicId() const; | const std::string ¤tLogicId() const; | ||||
| protected: | |||||
| bool eventFilter(QObject *watched, QEvent *event) override; | |||||
| private: | private: | ||||
| // 配置菜单和工具栏动作 | // 配置菜单和工具栏动作 | ||||
| void configureActions(); | void configureActions(); | ||||
| @@ -107,6 +111,12 @@ private: | |||||
| void configurePlcConnection(); | void configurePlcConnection(); | ||||
| void configureAlarms(); | void configureAlarms(); | ||||
| void configureRegisterComments(); | void configureRegisterComments(); | ||||
| void undoActiveEditor(); | |||||
| void redoActiveEditor(); | |||||
| void deleteActiveSelection(); | |||||
| void clearActiveSelection(); | |||||
| void updateEditActions(); | |||||
| void clearEditorHistories(); | |||||
| // 刷新工程树和当前 HMI 页面信息 | // 刷新工程树和当前 HMI 页面信息 | ||||
| void refreshProjectUi(); | void refreshProjectUi(); | ||||
| void updateProjectTreeActions(); | void updateProjectTreeActions(); | ||||
| @@ -240,7 +240,17 @@ | |||||
| <addaction name="saveAsProjectAction"/> | <addaction name="saveAsProjectAction"/> | ||||
| <addaction name="loadProjectAction"/> | <addaction name="loadProjectAction"/> | ||||
| <addaction name="separator"/> | <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> | ||||
| <widget class="QMenu" name="runMenu"> | <widget class="QMenu" name="runMenu"> | ||||
| <property name="title"> | <property name="title"> | ||||
| @@ -260,6 +270,7 @@ | |||||
| <addaction name="configureRegisterCommentsAction"/> | <addaction name="configureRegisterCommentsAction"/> | ||||
| </widget> | </widget> | ||||
| <addaction name="fileMenu"/> | <addaction name="fileMenu"/> | ||||
| <addaction name="editMenu"/> | |||||
| <addaction name="runMenu"/> | <addaction name="runMenu"/> | ||||
| <addaction name="viewMenu"/> | <addaction name="viewMenu"/> | ||||
| </widget> | </widget> | ||||
| @@ -888,21 +899,53 @@ | |||||
| <property name="text"> | <property name="text"> | ||||
| <string>新建工程</string> | <string>新建工程</string> | ||||
| </property> | </property> | ||||
| <property name="shortcut"> | |||||
| <string>Ctrl+N</string> | |||||
| </property> | |||||
| </action> | </action> | ||||
| <action name="saveProjectAction"> | <action name="saveProjectAction"> | ||||
| <property name="text"> | <property name="text"> | ||||
| <string>保存工程</string> | <string>保存工程</string> | ||||
| </property> | </property> | ||||
| <property name="shortcut"> | |||||
| <string>Ctrl+S</string> | |||||
| </property> | |||||
| </action> | </action> | ||||
| <action name="saveAsProjectAction"> | <action name="saveAsProjectAction"> | ||||
| <property name="text"> | <property name="text"> | ||||
| <string>工程另存为</string> | <string>工程另存为</string> | ||||
| </property> | </property> | ||||
| <property name="shortcut"> | |||||
| <string>Ctrl+Shift+S</string> | |||||
| </property> | |||||
| </action> | </action> | ||||
| <action name="loadProjectAction"> | <action name="loadProjectAction"> | ||||
| <property name="text"> | <property name="text"> | ||||
| <string>加载工程</string> | <string>加载工程</string> | ||||
| </property> | </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> | ||||
| <action name="configurePlcAction"> | <action name="configurePlcAction"> | ||||
| <property name="text"> | <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_control_properties, | ||||
| std::function<void(const std::string &)> show_logic_properties, | std::function<void(const std::string &)> show_logic_properties, | ||||
| ResultReporter result_reporter, | ResultReporter result_reporter, | ||||
| StatusReporter status_reporter) | |||||
| StatusReporter status_reporter, | |||||
| EditStateChanged edit_state_changed) | |||||
| : parent_(parent), | : parent_(parent), | ||||
| ui_(ui), | ui_(ui), | ||||
| project_service_(project_service), | project_service_(project_service), | ||||
| @@ -72,7 +73,8 @@ ProjectWorkspaceController::ProjectWorkspaceController( | |||||
| show_control_properties_(std::move(show_control_properties)), | show_control_properties_(std::move(show_control_properties)), | ||||
| show_logic_properties_(std::move(show_logic_properties)), | show_logic_properties_(std::move(show_logic_properties)), | ||||
| result_reporter_(std::move(result_reporter)), | 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); | ui_.editorTabWidget->setCurrentWidget(ui_.hmiEditorTab); | ||||
| refresh(); | refresh(); | ||||
| hmi_editor_widget_.reloadPage(); | hmi_editor_widget_.reloadPage(); | ||||
| edit_state_changed_(); | |||||
| } | } | ||||
| void ProjectWorkspaceController::addLogic() | void ProjectWorkspaceController::addLogic() | ||||
| @@ -331,6 +334,7 @@ void ProjectWorkspaceController::addLogic() | |||||
| ui_.editorTabWidget->setCurrentWidget(ui_.logicEditorTab); | ui_.editorTabWidget->setCurrentWidget(ui_.logicEditorTab); | ||||
| refresh(); | refresh(); | ||||
| logic_editor_widget_.reloadLogic(); | logic_editor_widget_.reloadLogic(); | ||||
| edit_state_changed_(); | |||||
| } | } | ||||
| void ProjectWorkspaceController::renameSelectedItem() | void ProjectWorkspaceController::renameSelectedItem() | ||||
| @@ -386,6 +390,7 @@ void ProjectWorkspaceController::renameSelectedItem() | |||||
| } | } | ||||
| } | } | ||||
| refresh(); | refresh(); | ||||
| edit_state_changed_(); | |||||
| } | } | ||||
| void ProjectWorkspaceController::deleteSelectedItem() | void ProjectWorkspaceController::deleteSelectedItem() | ||||
| @@ -423,6 +428,7 @@ void ProjectWorkspaceController::deleteSelectedItem() | |||||
| refresh(); | refresh(); | ||||
| show_control_properties_({}); | show_control_properties_({}); | ||||
| show_logic_properties_({}); | show_logic_properties_({}); | ||||
| edit_state_changed_(); | |||||
| } | } | ||||
| void ProjectWorkspaceController::moveSelectedItem(int offset) | void ProjectWorkspaceController::moveSelectedItem(int offset) | ||||
| @@ -458,6 +464,7 @@ void ProjectWorkspaceController::moveSelectedItem(int offset) | |||||
| return; | return; | ||||
| } | } | ||||
| refresh(); | refresh(); | ||||
| edit_state_changed_(); | |||||
| } | } | ||||
| void ProjectWorkspaceController::setSelectedPageAsInitial() | void ProjectWorkspaceController::setSelectedPageAsInitial() | ||||
| @@ -477,6 +484,7 @@ void ProjectWorkspaceController::setSelectedPageAsInitial() | |||||
| return; | return; | ||||
| } | } | ||||
| refresh(); | refresh(); | ||||
| edit_state_changed_(); | |||||
| } | } | ||||
| void ProjectWorkspaceController::toggleSelectedLogicEnabled() | void ProjectWorkspaceController::toggleSelectedLogicEnabled() | ||||
| @@ -502,6 +510,7 @@ void ProjectWorkspaceController::toggleSelectedLogicEnabled() | |||||
| return; | return; | ||||
| } | } | ||||
| refresh(); | refresh(); | ||||
| edit_state_changed_(); | |||||
| } | } | ||||
| void ProjectWorkspaceController::reportFailure( | void ProjectWorkspaceController::reportFailure( | ||||
| @@ -31,6 +31,7 @@ public: | |||||
| const QString &action, const QString &message, bool succeeded)>; | const QString &action, const QString &message, bool succeeded)>; | ||||
| using StatusReporter = std::function<void( | using StatusReporter = std::function<void( | ||||
| const QString &message, int timeout_ms)>; | const QString &message, int timeout_ms)>; | ||||
| using EditStateChanged = std::function<void()>; | |||||
| ProjectWorkspaceController( | ProjectWorkspaceController( | ||||
| QWidget &parent, | QWidget &parent, | ||||
| @@ -48,7 +49,8 @@ public: | |||||
| std::function<void(const std::string &)> show_control_properties, | std::function<void(const std::string &)> show_control_properties, | ||||
| std::function<void(const std::string &)> show_logic_properties, | std::function<void(const std::string &)> show_logic_properties, | ||||
| ResultReporter result_reporter, | ResultReporter result_reporter, | ||||
| StatusReporter status_reporter); | |||||
| StatusReporter status_reporter, | |||||
| EditStateChanged edit_state_changed); | |||||
| void configure(); | void configure(); | ||||
| void refresh(); | void refresh(); | ||||
| @@ -92,6 +94,7 @@ private: | |||||
| std::function<void(const std::string &)> show_logic_properties_; | std::function<void(const std::string &)> show_logic_properties_; | ||||
| ResultReporter result_reporter_; | ResultReporter result_reporter_; | ||||
| StatusReporter status_reporter_; | StatusReporter status_reporter_; | ||||
| EditStateChanged edit_state_changed_; | |||||
| QAction *add_page_action_ = nullptr; | QAction *add_page_action_ = nullptr; | ||||
| QAction *add_logic_action_ = nullptr; | QAction *add_logic_action_ = nullptr; | ||||
| @@ -21,6 +21,7 @@ | |||||
| #include <cstdint> | #include <cstdint> | ||||
| #include <utility> | #include <utility> | ||||
| #include <vector> | |||||
| namespace { | namespace { | ||||
| @@ -432,12 +433,18 @@ void PropertyPanelController::addHmiControl(HmiControlType type) | |||||
| void PropertyPanelController::deleteSelectedControl() | void PropertyPanelController::deleteSelectedControl() | ||||
| { | { | ||||
| if (selected_control_id_.empty()) | |||||
| if (hmi_editor_widget_ == nullptr) | |||||
| { | { | ||||
| return; | 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) | if (!result.succeeded) | ||||
| { | { | ||||
| reportFailure(QObject::tr("删除控件"), result.message); | reportFailure(QObject::tr("删除控件"), result.message); | ||||
| @@ -198,6 +198,31 @@ QPixmap renderIcon(UiIcon icon, int size) | |||||
| painter.drawPath(folder); | painter.drawPath(folder); | ||||
| break; | 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: | case UiIcon::Exit: | ||||
| { | { | ||||
| painter.drawRect(QRectF(4, 3, 10, 18)); | painter.drawRect(QRectF(4, 3, 10, 18)); | ||||
| @@ -11,6 +11,8 @@ enum class UiIcon | |||||
| Save, | Save, | ||||
| SaveAs, | SaveAs, | ||||
| Open, | Open, | ||||
| Undo, | |||||
| Redo, | |||||
| Exit, | Exit, | ||||
| PlcSettings, | PlcSettings, | ||||
| Disconnect, | Disconnect, | ||||
| @@ -186,6 +186,60 @@ void testRuntimeUsesRegisterRepository() | |||||
| "progress bars must read D values through the repository"); | "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() | void testPageLifecycleAndNavigation() | ||||
| { | { | ||||
| TestProjectStorage storage; | TestProjectStorage storage; | ||||
| @@ -261,6 +315,7 @@ int main() | |||||
| { | { | ||||
| // 编辑和运行场景分别验证服务层两条独立职责 | // 编辑和运行场景分别验证服务层两条独立职责 | ||||
| testControlEditing(); | testControlEditing(); | ||||
| testHistoryAndAtomicBatchDelete(); | |||||
| testRuntimeUsesRegisterRepository(); | testRuntimeUsesRegisterRepository(); | ||||
| testPageLifecycleAndNavigation(); | testPageLifecycleAndNavigation(); | ||||
| } | } | ||||
| @@ -30,6 +30,7 @@ HEADERS += \ | |||||
| ../src/domain/project_model.h \ | ../src/domain/project_model.h \ | ||||
| ../src/domain/project_storage.h \ | ../src/domain/project_storage.h \ | ||||
| ../src/services/project_service.h \ | ../src/services/project_service.h \ | ||||
| ../src/services/editor_history.h \ | |||||
| ../src/services/hmi_editor_service.h \ | ../src/services/hmi_editor_service.h \ | ||||
| ../src/services/hmi_navigation_service.h \ | ../src/services/hmi_navigation_service.h \ | ||||
| ../src/services/hmi_runtime_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"); | "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 | } // namespace | ||||
| int main() | int main() | ||||
| @@ -236,6 +307,7 @@ int main() | |||||
| testRangeParallelInsertion(); | testRangeParallelInsertion(); | ||||
| testLogicLifecycleAndOrdering(); | testLogicLifecycleAndOrdering(); | ||||
| testEdgeTimerNodesAndRungComments(); | testEdgeTimerNodesAndRungComments(); | ||||
| testHistoryAndAtomicBatchDelete(); | |||||
| } | } | ||||
| catch (const std::exception &error) | catch (const std::exception &error) | ||||
| { | { | ||||
| @@ -28,4 +28,5 @@ HEADERS += \ | |||||
| ../src/domain/alarm_model.h \ | ../src/domain/alarm_model.h \ | ||||
| ../src/domain/project_storage.h \ | ../src/domain/project_storage.h \ | ||||
| ../src/services/project_service.h \ | ../src/services/project_service.h \ | ||||
| ../src/services/editor_history.h \ | |||||
| ../src/services/logic_editor_service.h | ../src/services/logic_editor_service.h | ||||
| @@ -603,6 +603,12 @@ void testModeActionsControlEditingAvailability() | |||||
| QAction *add_progress_bar_action = requiredChild<QAction>( | QAction *add_progress_bar_action = requiredChild<QAction>( | ||||
| window, "addProgressBarAction"); | window, "addProgressBarAction"); | ||||
| QAction *delete_control_action = requiredChild<QAction>(window, "deleteControlAction"); | 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>( | QToolButton *hmi_more_controls = requiredChild<QToolButton>( | ||||
| window, "hmiMoreControlsButton"); | window, "hmiMoreControlsButton"); | ||||
| QToolButton *logic_contact_menu = requiredChild<QToolButton>( | QToolButton *logic_contact_menu = requiredChild<QToolButton>( | ||||
| @@ -639,12 +645,15 @@ void testModeActionsControlEditingAvailability() | |||||
| window, "applyPropertiesButton"); | window, "applyPropertiesButton"); | ||||
| HmiEditorWidget *hmi_editor = requiredChild<HmiEditorWidget>( | HmiEditorWidget *hmi_editor = requiredChild<HmiEditorWidget>( | ||||
| window, "hmiEditorWidget"); | window, "hmiEditorWidget"); | ||||
| LogicEditorWidget *logic_editor = requiredChild<LogicEditorWidget>( | |||||
| window, "logicEditorWidget"); | |||||
| QLabel *executor_status = requiredChild<QLabel>(window, "executorStatusLabel"); | QLabel *executor_status = requiredChild<QLabel>(window, "executorStatusLabel"); | ||||
| QWidget *runtime_tab = requiredChild<QWidget>(window, "runtimeMonitorTab"); | QWidget *runtime_tab = requiredChild<QWidget>(window, "runtimeMonitorTab"); | ||||
| QWidget *runtime_hmi = requiredChild<QWidget>(window, "runtimeHmiView"); | QWidget *runtime_hmi = requiredChild<QWidget>(window, "runtimeHmiView"); | ||||
| QWidget *runtime_logic = requiredChild<QWidget>(window, "runtimeLogicView"); | QWidget *runtime_logic = requiredChild<QWidget>(window, "runtimeLogicView"); | ||||
| QWidget *free_monitor = requiredChild<QWidget>(window, "freeMonitorWidget"); | QWidget *free_monitor = requiredChild<QWidget>(window, "freeMonitorWidget"); | ||||
| QTabWidget *editor_tabs = requiredChild<QTabWidget>(window, "editorTabWidget"); | QTabWidget *editor_tabs = requiredChild<QTabWidget>(window, "editorTabWidget"); | ||||
| QTreeWidget *project_tree = requiredChild<QTreeWidget>(window, "projectTree"); | |||||
| require(!runtime_tab->isVisible(), | require(!runtime_tab->isVisible(), | ||||
| "runtime monitor workspace must be hidden while editing"); | "runtime monitor workspace must be hidden while editing"); | ||||
| for (const char *action_name : { | for (const char *action_name : { | ||||
| @@ -653,6 +662,10 @@ void testModeActionsControlEditingAvailability() | |||||
| "saveProjectAction", | "saveProjectAction", | ||||
| "saveAsProjectAction", | "saveAsProjectAction", | ||||
| "loadProjectAction", | "loadProjectAction", | ||||
| "undoAction", | |||||
| "redoAction", | |||||
| "deleteSelectionAction", | |||||
| "clearSelectionAction", | |||||
| "configurePlcAction", | "configurePlcAction", | ||||
| "configureRegisterCommentsAction", | "configureRegisterCommentsAction", | ||||
| "disconnectPlcAction", | "disconnectPlcAction", | ||||
| @@ -739,9 +752,30 @@ void testModeActionsControlEditingAvailability() | |||||
| "HMI page must refit when the window becomes larger"); | "HMI page must refit when the window becomes larger"); | ||||
| require(editing_action->isChecked(), "editing action must be selected initially"); | 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(project_dock->isEnabled(), "project dock must be enabled while editing"); | ||||
| require(properties_dock->isEnabled(), "properties 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(); | add_button_action->trigger(); | ||||
| const std::string page_id = editor_service.firstPageId(); | const std::string page_id = editor_service.firstPageId(); | ||||
| require(editor_service.findPage(page_id)->controls.size() == 1, | require(editor_service.findPage(page_id)->controls.size() == 1, | ||||
| @@ -753,6 +787,14 @@ void testModeActionsControlEditingAvailability() | |||||
| require(button_operation->currentText() == QStringLiteral("瞬时 ON"), | require(button_operation->currentText() == QStringLiteral("瞬时 ON"), | ||||
| "new HMI buttons must default to momentary 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(); | const QList<QGraphicsItem *> unbound_items = hmi_editor->scene()->selectedItems(); | ||||
| require(unbound_items.size() == 1 && unbound_items.front()->boundingRect().top() == 0, | require(unbound_items.size() == 1 && unbound_items.front()->boundingRect().top() == 0, | ||||
| "an unbound HMI control must not reserve an address label area"); | "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(), | require(editor_service.findPage(page_id)->controls.empty(), | ||||
| "deleting a selected control must update the HMI page model"); | "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(); | add_progress_bar_action->trigger(); | ||||
| const HmiControl *default_progress = editor_service.findControl( | const HmiControl *default_progress = editor_service.findControl( | ||||
| page_id, "progress-bar-1"); | page_id, "progress-bar-1"); | ||||
| @@ -817,6 +881,22 @@ void testModeActionsControlEditingAvailability() | |||||
| hmi_editor->reloadPage(); | hmi_editor->reloadPage(); | ||||
| add_normally_open_action->trigger(); | 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(); | parallel_insert_action->trigger(); | ||||
| add_normal_coil_action->trigger(); | add_normal_coil_action->trigger(); | ||||
| const ControlLogic *logic = logic_editor_service.findLogic( | const ControlLogic *logic = logic_editor_service.findLogic( | ||||
| @@ -872,6 +952,9 @@ void testModeActionsControlEditingAvailability() | |||||
| "ProgressBar creation must be disabled while running"); | "ProgressBar creation must be disabled while running"); | ||||
| require(!add_normally_open_action->isEnabled(), | require(!add_normally_open_action->isEnabled(), | ||||
| "logic add nodes must be disabled while running"); | "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() | require(runtime_tab->isVisible() && runtime_hmi->isVisible() | ||||
| && runtime_logic->isVisible() && free_monitor->isVisible(), | && runtime_logic->isVisible() && free_monitor->isVisible(), | ||||
| "offline running must show HMI, ladder trace and free monitor together"); | "offline running must show HMI, ladder trace and free monitor together"); | ||||
| @@ -72,6 +72,7 @@ HEADERS += \ | |||||
| ../src/domain/project_storage.h \ | ../src/domain/project_storage.h \ | ||||
| ../src/domain/runtime_state.h \ | ../src/domain/runtime_state.h \ | ||||
| ../src/services/project_service.h \ | ../src/services/project_service.h \ | ||||
| ../src/services/editor_history.h \ | |||||
| ../src/services/alarm_editor_service.h \ | ../src/services/alarm_editor_service.h \ | ||||
| ../src/services/alarm_service.h \ | ../src/services/alarm_service.h \ | ||||
| ../src/services/hmi_editor_service.h \ | ../src/services/hmi_editor_service.h \ | ||||