| @@ -6,6 +6,7 @@ | |||
| namespace { | |||
| // 判断寄存器区域是否为项目支持的 M 区或 D 区 | |||
| bool isSupportedArea(RegisterArea area) | |||
| { | |||
| // 地址值对象只承认项目定义的 M 区和 D 区 | |||
| @@ -14,8 +15,10 @@ bool isSupportedArea(RegisterArea area) | |||
| } // namespace | |||
| // 解析文本中的 M/D 区域和数字下标,并返回具体失败原因 | |||
| RegisterAddressParseResult parseRegisterAddress(const std::string &text) | |||
| { | |||
| // 从两端找到第一个非空白字符,后续解析只处理去掉首尾空白后的内容 | |||
| const auto first = std::find_if_not( | |||
| text.begin(), text.end(), [](unsigned char value) { return std::isspace(value); }); | |||
| const auto last = std::find_if_not( | |||
| @@ -23,9 +26,11 @@ RegisterAddressParseResult parseRegisterAddress(const std::string &text) | |||
| .base(); | |||
| if (first >= last) | |||
| { | |||
| // 首尾空白之外没有任何内容,说明用户没有输入地址 | |||
| return {false, {RegisterArea::M, 0}, RegisterAddressParseError::Empty}; | |||
| } | |||
| // 将首字符统一转成大写,使 m10 和 M10 的解析结果保持一致 | |||
| const char area_character = static_cast<char>( | |||
| std::toupper(static_cast<unsigned char>(*first))); | |||
| RegisterArea area = RegisterArea::M; | |||
| @@ -39,57 +44,68 @@ RegisterAddressParseResult parseRegisterAddress(const std::string &text) | |||
| } | |||
| else | |||
| { | |||
| // 首字符不是 M 或 D,返回“不支持的区域”错误并保留默认地址 | |||
| return {false, {RegisterArea::M, 0}, RegisterAddressParseError::UnsupportedArea}; | |||
| } | |||
| if (first + 1 == last) | |||
| { | |||
| // 只有区域字母而没有数字下标,例如单独输入 M 或 D | |||
| return {false, {area, 0}, RegisterAddressParseError::InvalidFormat}; | |||
| } | |||
| int index = 0; | |||
| // 逐字符读取下标,避免直接调用字符串转数字函数带来格式不透明的问题 | |||
| for (auto current = first + 1; current != last; ++current) | |||
| { | |||
| const unsigned char character = static_cast<unsigned char>(*current); | |||
| if (!std::isdigit(character)) | |||
| { | |||
| // 下标部分只能由十进制数字组成 | |||
| return {false, {area, 0}, RegisterAddressParseError::InvalidFormat}; | |||
| } | |||
| const int digit = *current - '0'; | |||
| if (index > (std::numeric_limits<int>::max() - digit) / 10) | |||
| { | |||
| // 在执行 index * 10 + digit 前先检查,防止整数溢出 | |||
| return {false, {area, 0}, RegisterAddressParseError::OutOfRange}; | |||
| } | |||
| index = index * 10 + digit; | |||
| } | |||
| RegisterAddress address{area, index}; | |||
| // 解析完成后再检查项目边界,区分格式正确但下标超范围的地址 | |||
| return address.isValid() | |||
| ? RegisterAddressParseResult{true, address, RegisterAddressParseError::None} | |||
| : RegisterAddressParseResult{false, address, RegisterAddressParseError::OutOfRange}; | |||
| } | |||
| // 使用指定区域和下标创建寄存器地址对象 | |||
| RegisterAddress::RegisterAddress(RegisterArea area, int index) | |||
| : area_(area), | |||
| index_(index) | |||
| { | |||
| } | |||
| // 返回地址所属的寄存器区域 | |||
| RegisterArea RegisterAddress::area() const | |||
| { | |||
| return area_; | |||
| } | |||
| // 返回地址的数字下标 | |||
| int RegisterAddress::index() const | |||
| { | |||
| return index_; | |||
| } | |||
| // 检查区域和下标是否符合项目支持的地址范围 | |||
| bool RegisterAddress::isValid() const | |||
| { | |||
| return isSupportedArea(area_) | |||
| && index_ >= kMinimumIndex && index_ <= kMaximumIndex; | |||
| } | |||
| // 将地址转换为 M10 或 D4000 这样的显示文本 | |||
| std::string RegisterAddress::toString() const | |||
| { | |||
| const char *areaName = nullptr; | |||
| @@ -109,11 +125,13 @@ std::string RegisterAddress::toString() const | |||
| return std::string(areaName) + std::to_string(index_); | |||
| } | |||
| // 判断当前地址与另一个地址是否完全相同 | |||
| bool RegisterAddress::operator==(const RegisterAddress &other) const | |||
| { | |||
| return area_ == other.area_ && index_ == other.index_; | |||
| } | |||
| // 判断当前地址与另一个地址是否不同 | |||
| bool RegisterAddress::operator!=(const RegisterAddress &other) const | |||
| { | |||
| return !(*this == other); | |||
| @@ -5,18 +5,18 @@ | |||
| // 解析用户输入的 M/D 地址时可能出现的失败原因 | |||
| enum class RegisterAddressParseError | |||
| { | |||
| None, | |||
| Empty, | |||
| UnsupportedArea, | |||
| InvalidFormat, | |||
| OutOfRange | |||
| None, // 地址文本解析成功 | |||
| Empty, // 输入文本为空或只包含空白 | |||
| UnsupportedArea, // 地址区域不是项目支持的 M 或 D | |||
| InvalidFormat, // 地址文本格式不符合要求 | |||
| OutOfRange // 地址下标超出 0~4000 范围 | |||
| }; | |||
| // PLC 中支持的寄存器区域:M 是位,D 是 16 位有符号字 | |||
| enum class RegisterArea | |||
| { | |||
| M, | |||
| D | |||
| M, // M 位寄存器区域 | |||
| D // D 16 位数据寄存器区域 | |||
| }; | |||
| // 表示一个已经拆分为“区域 + 下标”的 M/D 地址 | |||
| @@ -24,33 +24,39 @@ enum class RegisterArea | |||
| class RegisterAddress | |||
| { | |||
| public: | |||
| static constexpr int kMinimumIndex = 0; | |||
| static constexpr int kMaximumIndex = 4000; | |||
| static constexpr int kMinimumIndex = 0; // 地址允许使用的最小下标 | |||
| static constexpr int kMaximumIndex = 4000; // 地址允许使用的最大下标 | |||
| // 构造地址对象;非法下标不会在构造时抛异常,应通过 isValid() 检查 | |||
| RegisterAddress(RegisterArea area, int index); | |||
| // 返回地址区域和数字下标 | |||
| // 返回地址所属的寄存器区域 | |||
| RegisterArea area() const; | |||
| // 返回地址的数字下标 | |||
| int index() const; | |||
| // 判断区域和下标是否满足项目边界 | |||
| bool isValid() const; | |||
| // 转成界面和日志中使用的形式,例如 M10 或 D4000 | |||
| std::string toString() const; | |||
| // 判断两个地址的区域和下标是否都相同 | |||
| bool operator==(const RegisterAddress &other) const; | |||
| // 判断两个地址是否存在区域或下标差异 | |||
| bool operator!=(const RegisterAddress &other) const; | |||
| private: | |||
| RegisterArea area_; | |||
| int index_; | |||
| RegisterArea area_; // 保存地址所属的寄存器区域 | |||
| int index_; // 保存地址的数字下标 | |||
| }; | |||
| // 文本地址解析结果;失败时 address 保持默认值,具体原因见 error | |||
| struct RegisterAddressParseResult | |||
| { | |||
| // 表示文本是否成功解析为有效地址 | |||
| bool succeeded = false; | |||
| // 保存解析出的地址或失败时的默认地址 | |||
| RegisterAddress address{RegisterArea::M, 0}; | |||
| // 保存成功状态或具体失败原因 | |||
| RegisterAddressParseError error = RegisterAddressParseError::InvalidFormat; | |||
| }; | |||
| @@ -578,6 +578,150 @@ HmiEditorResult HmiEditorService::moveControl( | |||
| return {true, HmiEditorError::None, {}, control_id}; | |||
| } | |||
| HmiEditorResult HmiEditorService::alignControls( | |||
| const std::string &page_id, | |||
| const std::vector<std::string> &control_ids, | |||
| HmiAlignment alignment) | |||
| { | |||
| const HmiPage *page = findPage(page_id); | |||
| if (page == nullptr) | |||
| { | |||
| return failure(HmiEditorError::PageNotFound, "未找到 HMI 页面"); | |||
| } | |||
| if (control_ids.size() < 2U) | |||
| { | |||
| 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 控件"); | |||
| } | |||
| } | |||
| const HmiControl *first = findControl(page_id, control_ids.front()); | |||
| int left = first->bounds.x; | |||
| int top = first->bounds.y; | |||
| int right = first->bounds.x + first->bounds.width; | |||
| int bottom = first->bounds.y + first->bounds.height; | |||
| for (std::size_t index = 1U; index < control_ids.size(); ++index) | |||
| { | |||
| const HmiControl *control = findControl(page_id, control_ids[index]); | |||
| left = std::min(left, control->bounds.x); | |||
| top = std::min(top, control->bounds.y); | |||
| right = std::max( | |||
| right, control->bounds.x + control->bounds.width); | |||
| bottom = std::max( | |||
| bottom, control->bounds.y + control->bounds.height); | |||
| } | |||
| bool supported = true; | |||
| switch (alignment) | |||
| { | |||
| case HmiAlignment::Left: | |||
| case HmiAlignment::HorizontalCenter: | |||
| case HmiAlignment::Right: | |||
| case HmiAlignment::Top: | |||
| case HmiAlignment::VerticalCenter: | |||
| case HmiAlignment::Bottom: | |||
| break; | |||
| default: | |||
| supported = false; | |||
| break; | |||
| } | |||
| if (!supported) | |||
| { | |||
| return failure(HmiEditorError::InvalidOperation, "不支持的 HMI 对齐方式"); | |||
| } | |||
| HmiPage candidate_page = *page; | |||
| const int horizontal_center = (left + right) / 2; | |||
| const int vertical_center = (top + bottom) / 2; | |||
| bool changed = false; | |||
| for (HmiControl &control : candidate_page.controls) | |||
| { | |||
| if (selected_ids.find(control.id) == selected_ids.end()) | |||
| { | |||
| continue; | |||
| } | |||
| const HmiRect original = control.bounds; | |||
| switch (alignment) | |||
| { | |||
| case HmiAlignment::Left: | |||
| control.bounds.x = left; | |||
| break; | |||
| case HmiAlignment::HorizontalCenter: | |||
| control.bounds.x = horizontal_center - control.bounds.width / 2; | |||
| break; | |||
| case HmiAlignment::Right: | |||
| control.bounds.x = right - control.bounds.width; | |||
| break; | |||
| case HmiAlignment::Top: | |||
| control.bounds.y = top; | |||
| break; | |||
| case HmiAlignment::VerticalCenter: | |||
| control.bounds.y = vertical_center - control.bounds.height / 2; | |||
| break; | |||
| case HmiAlignment::Bottom: | |||
| control.bounds.y = bottom - control.bounds.height; | |||
| break; | |||
| default: | |||
| break; | |||
| } | |||
| changed = changed || original.x != control.bounds.x | |||
| || original.y != control.bounds.y; | |||
| } | |||
| if (!changed) | |||
| { | |||
| return { | |||
| true, | |||
| HmiEditorError::None, | |||
| {}, | |||
| control_ids.front()}; | |||
| } | |||
| std::string error; | |||
| if (!candidate_page.validate(project_service_.projectLimits(), &error)) | |||
| { | |||
| return failure(HmiEditorError::InvalidControl, error); | |||
| } | |||
| const HistoryState before = captureState(); | |||
| Project &project = project_service_.editProject(); | |||
| auto target_page = std::find_if( | |||
| project.hmiPages.begin(), project.hmiPages.end(), | |||
| [&page_id](const HmiPage &item) { return item.id == page_id; }); | |||
| for (const HmiControl &candidate : candidate_page.controls) | |||
| { | |||
| if (selected_ids.find(candidate.id) == selected_ids.end()) | |||
| { | |||
| continue; | |||
| } | |||
| auto editable = std::find_if( | |||
| target_page->controls.begin(), target_page->controls.end(), | |||
| [&candidate](const HmiControl &item) | |||
| { | |||
| return item.id == candidate.id; | |||
| }); | |||
| editable->bounds = candidate.bounds; | |||
| } | |||
| recordHistory(before); | |||
| return {true, HmiEditorError::None, {}, control_ids.front()}; | |||
| } | |||
| // 更新控件全部属性 | |||
| HmiEditorResult HmiEditorService::updateControl( | |||
| const std::string &page_id, | |||
| @@ -47,6 +47,19 @@ struct HmiEditorResult | |||
| std::string id; // 成功时返回新建或更新后的页面/控件 ID | |||
| }; | |||
| /** | |||
| * @brief HMI 控件批量对齐方向 | |||
| */ | |||
| enum class HmiAlignment | |||
| { | |||
| Left, | |||
| HorizontalCenter, | |||
| Right, | |||
| Top, | |||
| VerticalCenter, | |||
| Bottom | |||
| }; | |||
| /** | |||
| * @brief 编排 HMI 页面和控件的编辑操作,不依赖 Qt 视图 | |||
| * | |||
| @@ -174,6 +187,19 @@ public: | |||
| const std::string &page_id, | |||
| const std::string &control_id, | |||
| const HmiRect &bounds); | |||
| /** | |||
| * @brief 批量对齐同一页面中的 HMI 控件 | |||
| * @param page_id 控件所属页面唯一标识 | |||
| * @param control_ids 待对齐控件 ID 列表,至少包含两个不重复 ID | |||
| * @param alignment 对齐方向,按所选控件整体外接矩形计算 | |||
| * @return 页面、控件、选择列表或对齐结果无效时返回失败结果 | |||
| * | |||
| * 对齐只修改控件位置,整批操作只产生一条撤销记录 | |||
| */ | |||
| HmiEditorResult alignControls( | |||
| const std::string &page_id, | |||
| const std::vector<std::string> &control_ids, | |||
| HmiAlignment alignment); | |||
| /** | |||
| * @brief 更新控件可编辑属性 | |||
| * @param page_id 所属页面唯一标识 | |||
| @@ -1046,6 +1046,33 @@ void HmiEditorWidget::selectControl(const std::string &control_id) | |||
| } | |||
| } | |||
| void HmiEditorWidget::selectControls( | |||
| const std::vector<std::string> &control_ids) | |||
| { | |||
| scene_->clearSelection(); | |||
| HmiGraphicsItem *first_selected = nullptr; | |||
| for (QGraphicsItem *item : scene_->items()) | |||
| { | |||
| HmiGraphicsItem *control_item = asHmiItem(item); | |||
| if (control_item == nullptr | |||
| || std::find( | |||
| control_ids.cbegin(), control_ids.cend(), | |||
| control_item->controlId()) == control_ids.cend()) | |||
| { | |||
| continue; | |||
| } | |||
| control_item->setSelected(true); | |||
| if (first_selected == nullptr) | |||
| { | |||
| first_selected = control_item; | |||
| } | |||
| } | |||
| if (first_selected != nullptr) | |||
| { | |||
| ensureVisible(first_selected); | |||
| } | |||
| } | |||
| std::string HmiEditorWidget::selectedControlId() const | |||
| { | |||
| const std::vector<std::string> ids = selectedControlIds(); | |||
| @@ -70,6 +70,8 @@ public: | |||
| * @param control_id 待选中控件标识,不存在时不改变当前选择 | |||
| */ | |||
| void selectControl(const std::string &control_id); | |||
| /** 选中指定的多个控件并滚动到第一个可见控件 */ | |||
| void selectControls(const std::vector<std::string> &control_ids); | |||
| /** 返回当前选中的单个控件标识 */ | |||
| std::string selectedControlId() const; | |||
| /** 返回当前选中的多个控件标识 */ | |||
| @@ -655,6 +655,42 @@ void MainWindow::configureActions() | |||
| [this] { addHmiControl(HmiControlType::AlarmList); }); | |||
| connect(ui_->deleteControlAction, &QAction::triggered, | |||
| this, &MainWindow::deleteSelectedControl); | |||
| align_left_action_ = new QAction(tr("左对齐"), this); | |||
| align_horizontal_center_action_ = new QAction(tr("水平居中"), this); | |||
| align_right_action_ = new QAction(tr("右对齐"), this); | |||
| align_top_action_ = new QAction(tr("顶部对齐"), this); | |||
| align_vertical_center_action_ = new QAction(tr("垂直居中"), this); | |||
| align_bottom_action_ = new QAction(tr("底部对齐"), this); | |||
| hmi_layout_button_ = addToolbarMenu( | |||
| ui_->hmiToolBar, | |||
| tr("布局"), | |||
| QStringLiteral("hmiLayoutMenuButton"), | |||
| makeUiIcon(UiIcon::Layout), | |||
| QList<QAction *>{ | |||
| align_left_action_, | |||
| align_horizontal_center_action_, | |||
| align_right_action_, | |||
| align_top_action_, | |||
| align_vertical_center_action_, | |||
| align_bottom_action_}); | |||
| connect(align_left_action_, &QAction::triggered, | |||
| this, [this] { alignSelectedHmiControls(HmiAlignment::Left); }); | |||
| connect(align_horizontal_center_action_, &QAction::triggered, | |||
| this, [this] | |||
| { | |||
| alignSelectedHmiControls(HmiAlignment::HorizontalCenter); | |||
| }); | |||
| connect(align_right_action_, &QAction::triggered, | |||
| this, [this] { alignSelectedHmiControls(HmiAlignment::Right); }); | |||
| connect(align_top_action_, &QAction::triggered, | |||
| this, [this] { alignSelectedHmiControls(HmiAlignment::Top); }); | |||
| connect(align_vertical_center_action_, &QAction::triggered, | |||
| this, [this] | |||
| { | |||
| alignSelectedHmiControls(HmiAlignment::VerticalCenter); | |||
| }); | |||
| connect(align_bottom_action_, &QAction::triggered, | |||
| this, [this] { alignSelectedHmiControls(HmiAlignment::Bottom); }); | |||
| addToolbarMenu( | |||
| ui_->hmiToolBar, | |||
| tr("更多控件"), | |||
| @@ -1494,8 +1530,10 @@ void MainWindow::updateEditActions() | |||
| ui_->redoAction->setEnabled( | |||
| editable && ((hmi_active && hmi_editor_service_.canRedo()) | |||
| || (logic_active && logic_editor_service_.canRedo()))); | |||
| const bool hmi_selection = hmi_active | |||
| && !hmi_editor_widget_->selectedControlIds().empty(); | |||
| const std::vector<std::string> hmi_selected_ids = hmi_editor_widget_ == nullptr | |||
| ? std::vector<std::string>{} | |||
| : hmi_editor_widget_->selectedControlIds(); | |||
| const bool hmi_selection = hmi_active && !hmi_selected_ids.empty(); | |||
| const bool logic_selection = logic_active | |||
| && logic_editor_widget_->hasCopyableSelection(); | |||
| ui_->copyAction->setEnabled( | |||
| @@ -1507,6 +1545,25 @@ void MainWindow::updateEditActions() | |||
| || (logic_active && logic_clipboard))); | |||
| ui_->deleteSelectionAction->setEnabled(editable && (hmi_active || logic_active)); | |||
| ui_->clearSelectionAction->setEnabled(editable && (hmi_active || logic_active)); | |||
| const bool hmi_alignment_enabled = | |||
| editable && hmi_active && hmi_selected_ids.size() >= 2U; | |||
| for (QAction *action : { | |||
| align_left_action_, | |||
| align_horizontal_center_action_, | |||
| align_right_action_, | |||
| align_top_action_, | |||
| align_vertical_center_action_, | |||
| align_bottom_action_}) | |||
| { | |||
| if (action != nullptr) | |||
| { | |||
| action->setEnabled(hmi_alignment_enabled); | |||
| } | |||
| } | |||
| if (hmi_layout_button_ != nullptr) | |||
| { | |||
| hmi_layout_button_->setEnabled(hmi_alignment_enabled); | |||
| } | |||
| ui_->insertHorizontalWireAction->setEnabled(editable && logic_active); | |||
| ui_->insertVerticalWireAction->setEnabled(editable && logic_active); | |||
| ui_->insertRungAboveAction->setEnabled(editable && logic_active); | |||
| @@ -1660,6 +1717,30 @@ void MainWindow::deleteSelectedControl() | |||
| property_panel_controller_->deleteSelectedControl(); | |||
| } | |||
| void MainWindow::alignSelectedHmiControls(HmiAlignment alignment) | |||
| { | |||
| if (!runtime_mode_service_.policy().allowsProjectEditing | |||
| || hmi_editor_widget_ == nullptr) | |||
| { | |||
| return; | |||
| } | |||
| const std::vector<std::string> control_ids = | |||
| hmi_editor_widget_->selectedControlIds(); | |||
| const HmiEditorResult result = hmi_editor_service_.alignControls( | |||
| current_hmi_page_id_, control_ids, alignment); | |||
| if (!result.succeeded) | |||
| { | |||
| showProjectResult(tr("对齐 HMI 控件"), fromUtf8(result.message), false); | |||
| return; | |||
| } | |||
| hmi_editor_widget_->reloadPage(); | |||
| hmi_editor_widget_->selectControls(control_ids); | |||
| refreshProjectUi(); | |||
| statusBar()->showMessage( | |||
| tr("已对齐 %1 个 HMI 控件").arg(control_ids.size()), 3000); | |||
| } | |||
| void MainWindow::addLogicCondition(const LogicNodeConfig &config) | |||
| { | |||
| const LogicEditorResult result = logic_editor_widget_->addCondition(config); | |||
| @@ -29,6 +29,7 @@ class QCloseEvent; | |||
| class QEvent; | |||
| class QLabel; | |||
| class QTimer; | |||
| class QToolButton; | |||
| namespace Ui { | |||
| class MainWindow; | |||
| @@ -38,6 +39,7 @@ QT_END_NAMESPACE | |||
| class RuntimeModeService; | |||
| class ProjectService; | |||
| class HmiEditorService; | |||
| enum class HmiAlignment; | |||
| class HmiRuntimeService; | |||
| class AlarmEditorService; | |||
| class AlarmService; | |||
| @@ -172,6 +174,8 @@ private: | |||
| void addHmiControl(HmiControlType type); | |||
| /** 删除当前选中的 HMI 控件 */ | |||
| void deleteSelectedControl(); | |||
| /** 对当前选中的 HMI 控件执行批量对齐 */ | |||
| void alignSelectedHmiControls(HmiAlignment alignment); | |||
| /** 在当前光标节点后添加串联条件,没有选择时追加到网络末尾 */ | |||
| void addLogicCondition(const LogicNodeConfig &config); | |||
| /** 为当前选中的连续逻辑范围建立并联支路 */ | |||
| @@ -304,6 +308,14 @@ private: | |||
| std::unique_ptr<RuntimePanelController> runtime_panel_controller_; | |||
| /** 运行模式互斥动作组 */ | |||
| QActionGroup *mode_action_group_ = nullptr; | |||
| /** HMI 控件布局菜单按钮和对齐动作 */ | |||
| QToolButton *hmi_layout_button_ = nullptr; | |||
| QAction *align_left_action_ = nullptr; | |||
| QAction *align_horizontal_center_action_ = nullptr; | |||
| QAction *align_right_action_ = nullptr; | |||
| QAction *align_top_action_ = nullptr; | |||
| QAction *align_vertical_center_action_ = nullptr; | |||
| QAction *align_bottom_action_ = nullptr; | |||
| /** 主界面的 HMI 编辑器 */ | |||
| HmiEditorWidget *hmi_editor_widget_ = nullptr; | |||
| /** 主界面的梯形图编辑器 */ | |||
| @@ -475,6 +475,15 @@ QPixmap renderIcon(UiIcon icon, int size) | |||
| painter.drawLine(QPointF(14, 19), QPointF(21, 12)); | |||
| break; | |||
| } | |||
| case UiIcon::Layout: | |||
| { | |||
| painter.drawRect(QRectF(3, 5, 7, 7)); | |||
| painter.drawRect(QRectF(14, 5, 7, 7)); | |||
| painter.drawRect(QRectF(3, 15, 7, 5)); | |||
| painter.drawLine(QPointF(11, 8), QPointF(13, 8)); | |||
| painter.drawLine(QPointF(11, 17), QPointF(13, 17)); | |||
| break; | |||
| } | |||
| case UiIcon::More: | |||
| { | |||
| painter.setPen(Qt::NoPen); | |||
| @@ -54,6 +54,7 @@ enum class UiIcon | |||
| Subtract, // SUB 指令 | |||
| Compare, // 比较指令 | |||
| Comment, // 注释 | |||
| Layout, // HMI 控件布局 | |||
| SyntaxCheck, // 梯形图语法检查与规整 | |||
| More, // 更多操作 | |||
| HmiPage, // HMI 页面 | |||
| @@ -68,6 +68,130 @@ void testControlEditing() | |||
| "deleted controls must not remain in the model"); | |||
| } | |||
| void testBatchControlAlignment() | |||
| { | |||
| TestProjectStorage storage; | |||
| ProjectService project_service(storage, defaultProjectLimitSettings()); | |||
| HmiEditorService service(project_service, HmiDefaultSettings{}); | |||
| 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 alignment testing must be created"); | |||
| const std::vector<std::string> ids{first.id, second.id, third.id}; | |||
| const std::vector<HmiRect> original_bounds{ | |||
| {100, 80, 80, 30}, | |||
| {240, 120, 100, 40}, | |||
| {380, 160, 60, 20}}; | |||
| for (std::size_t index = 0U; index < ids.size(); ++index) | |||
| { | |||
| HmiControl control = *service.findControl(page_id, ids[index]); | |||
| control.bounds = original_bounds[index]; | |||
| require(service.updateControl(page_id, ids[index], control).succeeded, | |||
| "alignment fixtures must be positioned successfully"); | |||
| } | |||
| service.clearHistory(); | |||
| require(service.alignControls(page_id, ids, HmiAlignment::Left).succeeded, | |||
| "left alignment must succeed"); | |||
| require(service.findControl(page_id, first.id)->bounds.x == 100 | |||
| && service.findControl(page_id, second.id)->bounds.x == 100 | |||
| && service.findControl(page_id, third.id)->bounds.x == 100, | |||
| "left alignment must use the selected group left edge"); | |||
| require(service.findControl(page_id, second.id)->bounds.y == 120, | |||
| "alignment must preserve the non-aligned coordinate"); | |||
| require(service.undo().succeeded | |||
| && service.findControl(page_id, second.id)->bounds | |||
| .x == original_bounds[1].x, | |||
| "alignment must be restored by one undo step"); | |||
| require(service.redo().succeeded | |||
| && service.findControl(page_id, third.id)->bounds.x == 100, | |||
| "alignment redo must restore the whole batch"); | |||
| const auto resetBounds = [&service, &page_id, &ids, &original_bounds] | |||
| { | |||
| for (std::size_t index = 0U; index < ids.size(); ++index) | |||
| { | |||
| HmiControl control = *service.findControl(page_id, ids[index]); | |||
| control.bounds = original_bounds[index]; | |||
| require(service.updateControl(page_id, ids[index], control).succeeded, | |||
| "alignment fixtures must be reset successfully"); | |||
| } | |||
| }; | |||
| const auto assertBounds = [&service, &page_id, &ids, &resetBounds]( | |||
| HmiAlignment alignment, const std::vector<HmiRect> &expected, | |||
| const char *message) | |||
| { | |||
| resetBounds(); | |||
| service.clearHistory(); | |||
| require(service.alignControls(page_id, ids, alignment).succeeded, message); | |||
| for (std::size_t index = 0U; index < ids.size(); ++index) | |||
| { | |||
| const HmiControl *control = service.findControl(page_id, ids[index]); | |||
| require(control != nullptr | |||
| && control->bounds.x == expected[index].x | |||
| && control->bounds.y == expected[index].y, | |||
| "alignment must calculate deterministic target coordinates"); | |||
| } | |||
| }; | |||
| assertBounds( | |||
| HmiAlignment::HorizontalCenter, | |||
| {{230, 80, 0, 0}, {220, 120, 0, 0}, {240, 160, 0, 0}}, | |||
| "horizontal center alignment must succeed"); | |||
| assertBounds( | |||
| HmiAlignment::Right, | |||
| {{360, 80, 0, 0}, {340, 120, 0, 0}, {380, 160, 0, 0}}, | |||
| "right alignment must succeed"); | |||
| assertBounds( | |||
| HmiAlignment::Top, | |||
| {{100, 80, 0, 0}, {240, 80, 0, 0}, {380, 80, 0, 0}}, | |||
| "top alignment must succeed"); | |||
| assertBounds( | |||
| HmiAlignment::VerticalCenter, | |||
| {{100, 115, 0, 0}, {240, 110, 0, 0}, {380, 120, 0, 0}}, | |||
| "vertical center alignment must succeed"); | |||
| assertBounds( | |||
| HmiAlignment::Bottom, | |||
| {{100, 150, 0, 0}, {240, 140, 0, 0}, {380, 160, 0, 0}}, | |||
| "bottom alignment must succeed"); | |||
| service.clearHistory(); | |||
| const std::vector<HmiRect> before_invalid{ | |||
| service.findControl(page_id, first.id)->bounds, | |||
| service.findControl(page_id, second.id)->bounds, | |||
| service.findControl(page_id, third.id)->bounds}; | |||
| require(!service.alignControls( | |||
| page_id, {first.id, "missing-control"}, HmiAlignment::Left) | |||
| .succeeded, | |||
| "alignment must reject an unknown control before mutation"); | |||
| require(!service.canUndo() | |||
| && service.findControl(page_id, first.id)->bounds.x | |||
| == before_invalid[0].x | |||
| && service.findControl(page_id, second.id)->bounds.y | |||
| == before_invalid[1].y, | |||
| "failed alignment must be atomic and leave history unchanged"); | |||
| require(!service.alignControls(page_id, {first.id}, HmiAlignment::Left).succeeded, | |||
| "alignment must require at least two controls"); | |||
| HmiControl no_op = *service.findControl(page_id, first.id); | |||
| no_op.bounds.x = 100; | |||
| HmiControl no_op_second = *service.findControl(page_id, second.id); | |||
| no_op_second.bounds.x = 100; | |||
| HmiControl no_op_third = *service.findControl(page_id, third.id); | |||
| no_op_third.bounds.x = 100; | |||
| require(service.updateControl(page_id, first.id, no_op).succeeded | |||
| && service.updateControl(page_id, second.id, no_op_second).succeeded | |||
| && service.updateControl(page_id, third.id, no_op_third).succeeded, | |||
| "alignment no-op fixtures must be positioned successfully"); | |||
| service.clearHistory(); | |||
| require(service.alignControls(page_id, ids, HmiAlignment::Left).succeeded | |||
| && !service.canUndo(), | |||
| "a no-op alignment must not create an undo step"); | |||
| } | |||
| void testRuntimeUsesRegisterRepository() | |||
| { | |||
| // 运行服务只能经由仓库接口读写 M/D,不依赖具体离线实现 | |||
| @@ -588,6 +712,7 @@ int main() | |||
| { | |||
| // 编辑和运行场景分别验证服务层两条独立职责 | |||
| testControlEditing(); | |||
| testBatchControlAlignment(); | |||
| testHistoryAndAtomicBatchDelete(); | |||
| testBatchPasteControls(); | |||
| testRuntimeValidationAndPasteBoundaries(); | |||