| @@ -6,6 +6,7 @@ | |||||
| namespace { | namespace { | ||||
| // 判断寄存器区域是否为项目支持的 M 区或 D 区 | |||||
| bool isSupportedArea(RegisterArea area) | bool isSupportedArea(RegisterArea area) | ||||
| { | { | ||||
| // 地址值对象只承认项目定义的 M 区和 D 区 | // 地址值对象只承认项目定义的 M 区和 D 区 | ||||
| @@ -14,8 +15,10 @@ bool isSupportedArea(RegisterArea area) | |||||
| } // namespace | } // namespace | ||||
| // 解析文本中的 M/D 区域和数字下标,并返回具体失败原因 | |||||
| RegisterAddressParseResult parseRegisterAddress(const std::string &text) | RegisterAddressParseResult parseRegisterAddress(const std::string &text) | ||||
| { | { | ||||
| // 从两端找到第一个非空白字符,后续解析只处理去掉首尾空白后的内容 | |||||
| const auto first = std::find_if_not( | const auto first = std::find_if_not( | ||||
| text.begin(), text.end(), [](unsigned char value) { return std::isspace(value); }); | text.begin(), text.end(), [](unsigned char value) { return std::isspace(value); }); | ||||
| const auto last = std::find_if_not( | const auto last = std::find_if_not( | ||||
| @@ -23,9 +26,11 @@ RegisterAddressParseResult parseRegisterAddress(const std::string &text) | |||||
| .base(); | .base(); | ||||
| if (first >= last) | if (first >= last) | ||||
| { | { | ||||
| // 首尾空白之外没有任何内容,说明用户没有输入地址 | |||||
| return {false, {RegisterArea::M, 0}, RegisterAddressParseError::Empty}; | return {false, {RegisterArea::M, 0}, RegisterAddressParseError::Empty}; | ||||
| } | } | ||||
| // 将首字符统一转成大写,使 m10 和 M10 的解析结果保持一致 | |||||
| const char area_character = static_cast<char>( | const char area_character = static_cast<char>( | ||||
| std::toupper(static_cast<unsigned char>(*first))); | std::toupper(static_cast<unsigned char>(*first))); | ||||
| RegisterArea area = RegisterArea::M; | RegisterArea area = RegisterArea::M; | ||||
| @@ -39,57 +44,68 @@ RegisterAddressParseResult parseRegisterAddress(const std::string &text) | |||||
| } | } | ||||
| else | else | ||||
| { | { | ||||
| // 首字符不是 M 或 D,返回“不支持的区域”错误并保留默认地址 | |||||
| return {false, {RegisterArea::M, 0}, RegisterAddressParseError::UnsupportedArea}; | return {false, {RegisterArea::M, 0}, RegisterAddressParseError::UnsupportedArea}; | ||||
| } | } | ||||
| if (first + 1 == last) | if (first + 1 == last) | ||||
| { | { | ||||
| // 只有区域字母而没有数字下标,例如单独输入 M 或 D | |||||
| return {false, {area, 0}, RegisterAddressParseError::InvalidFormat}; | return {false, {area, 0}, RegisterAddressParseError::InvalidFormat}; | ||||
| } | } | ||||
| int index = 0; | int index = 0; | ||||
| // 逐字符读取下标,避免直接调用字符串转数字函数带来格式不透明的问题 | |||||
| for (auto current = first + 1; current != last; ++current) | for (auto current = first + 1; current != last; ++current) | ||||
| { | { | ||||
| const unsigned char character = static_cast<unsigned char>(*current); | const unsigned char character = static_cast<unsigned char>(*current); | ||||
| if (!std::isdigit(character)) | if (!std::isdigit(character)) | ||||
| { | { | ||||
| // 下标部分只能由十进制数字组成 | |||||
| return {false, {area, 0}, RegisterAddressParseError::InvalidFormat}; | return {false, {area, 0}, RegisterAddressParseError::InvalidFormat}; | ||||
| } | } | ||||
| const int digit = *current - '0'; | const int digit = *current - '0'; | ||||
| if (index > (std::numeric_limits<int>::max() - digit) / 10) | if (index > (std::numeric_limits<int>::max() - digit) / 10) | ||||
| { | { | ||||
| // 在执行 index * 10 + digit 前先检查,防止整数溢出 | |||||
| return {false, {area, 0}, RegisterAddressParseError::OutOfRange}; | return {false, {area, 0}, RegisterAddressParseError::OutOfRange}; | ||||
| } | } | ||||
| index = index * 10 + digit; | index = index * 10 + digit; | ||||
| } | } | ||||
| RegisterAddress address{area, index}; | RegisterAddress address{area, index}; | ||||
| // 解析完成后再检查项目边界,区分格式正确但下标超范围的地址 | |||||
| return address.isValid() | return address.isValid() | ||||
| ? RegisterAddressParseResult{true, address, RegisterAddressParseError::None} | ? RegisterAddressParseResult{true, address, RegisterAddressParseError::None} | ||||
| : RegisterAddressParseResult{false, address, RegisterAddressParseError::OutOfRange}; | : RegisterAddressParseResult{false, address, RegisterAddressParseError::OutOfRange}; | ||||
| } | } | ||||
| // 使用指定区域和下标创建寄存器地址对象 | |||||
| RegisterAddress::RegisterAddress(RegisterArea area, int index) | RegisterAddress::RegisterAddress(RegisterArea area, int index) | ||||
| : area_(area), | : area_(area), | ||||
| index_(index) | index_(index) | ||||
| { | { | ||||
| } | } | ||||
| // 返回地址所属的寄存器区域 | |||||
| RegisterArea RegisterAddress::area() const | RegisterArea RegisterAddress::area() const | ||||
| { | { | ||||
| return area_; | return area_; | ||||
| } | } | ||||
| // 返回地址的数字下标 | |||||
| int RegisterAddress::index() const | int RegisterAddress::index() const | ||||
| { | { | ||||
| return index_; | return index_; | ||||
| } | } | ||||
| // 检查区域和下标是否符合项目支持的地址范围 | |||||
| bool RegisterAddress::isValid() const | bool RegisterAddress::isValid() const | ||||
| { | { | ||||
| return isSupportedArea(area_) | return isSupportedArea(area_) | ||||
| && index_ >= kMinimumIndex && index_ <= kMaximumIndex; | && index_ >= kMinimumIndex && index_ <= kMaximumIndex; | ||||
| } | } | ||||
| // 将地址转换为 M10 或 D4000 这样的显示文本 | |||||
| std::string RegisterAddress::toString() const | std::string RegisterAddress::toString() const | ||||
| { | { | ||||
| const char *areaName = nullptr; | const char *areaName = nullptr; | ||||
| @@ -109,11 +125,13 @@ std::string RegisterAddress::toString() const | |||||
| return std::string(areaName) + std::to_string(index_); | return std::string(areaName) + std::to_string(index_); | ||||
| } | } | ||||
| // 判断当前地址与另一个地址是否完全相同 | |||||
| bool RegisterAddress::operator==(const RegisterAddress &other) const | bool RegisterAddress::operator==(const RegisterAddress &other) const | ||||
| { | { | ||||
| return area_ == other.area_ && index_ == other.index_; | return area_ == other.area_ && index_ == other.index_; | ||||
| } | } | ||||
| // 判断当前地址与另一个地址是否不同 | |||||
| bool RegisterAddress::operator!=(const RegisterAddress &other) const | bool RegisterAddress::operator!=(const RegisterAddress &other) const | ||||
| { | { | ||||
| return !(*this == other); | return !(*this == other); | ||||
| @@ -5,18 +5,18 @@ | |||||
| // 解析用户输入的 M/D 地址时可能出现的失败原因 | // 解析用户输入的 M/D 地址时可能出现的失败原因 | ||||
| enum class RegisterAddressParseError | enum class RegisterAddressParseError | ||||
| { | { | ||||
| None, | |||||
| Empty, | |||||
| UnsupportedArea, | |||||
| InvalidFormat, | |||||
| OutOfRange | |||||
| None, // 地址文本解析成功 | |||||
| Empty, // 输入文本为空或只包含空白 | |||||
| UnsupportedArea, // 地址区域不是项目支持的 M 或 D | |||||
| InvalidFormat, // 地址文本格式不符合要求 | |||||
| OutOfRange // 地址下标超出 0~4000 范围 | |||||
| }; | }; | ||||
| // PLC 中支持的寄存器区域:M 是位,D 是 16 位有符号字 | // PLC 中支持的寄存器区域:M 是位,D 是 16 位有符号字 | ||||
| enum class RegisterArea | enum class RegisterArea | ||||
| { | { | ||||
| M, | |||||
| D | |||||
| M, // M 位寄存器区域 | |||||
| D // D 16 位数据寄存器区域 | |||||
| }; | }; | ||||
| // 表示一个已经拆分为“区域 + 下标”的 M/D 地址 | // 表示一个已经拆分为“区域 + 下标”的 M/D 地址 | ||||
| @@ -24,33 +24,39 @@ enum class RegisterArea | |||||
| class RegisterAddress | class RegisterAddress | ||||
| { | { | ||||
| public: | public: | ||||
| static constexpr int kMinimumIndex = 0; | |||||
| static constexpr int kMaximumIndex = 4000; | |||||
| static constexpr int kMinimumIndex = 0; // 地址允许使用的最小下标 | |||||
| static constexpr int kMaximumIndex = 4000; // 地址允许使用的最大下标 | |||||
| // 构造地址对象;非法下标不会在构造时抛异常,应通过 isValid() 检查 | // 构造地址对象;非法下标不会在构造时抛异常,应通过 isValid() 检查 | ||||
| RegisterAddress(RegisterArea area, int index); | RegisterAddress(RegisterArea area, int index); | ||||
| // 返回地址区域和数字下标 | |||||
| // 返回地址所属的寄存器区域 | |||||
| RegisterArea area() const; | RegisterArea area() const; | ||||
| // 返回地址的数字下标 | |||||
| int index() const; | int index() const; | ||||
| // 判断区域和下标是否满足项目边界 | // 判断区域和下标是否满足项目边界 | ||||
| bool isValid() const; | bool isValid() const; | ||||
| // 转成界面和日志中使用的形式,例如 M10 或 D4000 | // 转成界面和日志中使用的形式,例如 M10 或 D4000 | ||||
| std::string toString() const; | std::string toString() const; | ||||
| // 判断两个地址的区域和下标是否都相同 | |||||
| bool operator==(const RegisterAddress &other) const; | bool operator==(const RegisterAddress &other) const; | ||||
| // 判断两个地址是否存在区域或下标差异 | |||||
| bool operator!=(const RegisterAddress &other) const; | bool operator!=(const RegisterAddress &other) const; | ||||
| private: | private: | ||||
| RegisterArea area_; | |||||
| int index_; | |||||
| RegisterArea area_; // 保存地址所属的寄存器区域 | |||||
| int index_; // 保存地址的数字下标 | |||||
| }; | }; | ||||
| // 文本地址解析结果;失败时 address 保持默认值,具体原因见 error | // 文本地址解析结果;失败时 address 保持默认值,具体原因见 error | ||||
| struct RegisterAddressParseResult | struct RegisterAddressParseResult | ||||
| { | { | ||||
| // 表示文本是否成功解析为有效地址 | |||||
| bool succeeded = false; | bool succeeded = false; | ||||
| // 保存解析出的地址或失败时的默认地址 | |||||
| RegisterAddress address{RegisterArea::M, 0}; | RegisterAddress address{RegisterArea::M, 0}; | ||||
| // 保存成功状态或具体失败原因 | |||||
| RegisterAddressParseError error = RegisterAddressParseError::InvalidFormat; | RegisterAddressParseError error = RegisterAddressParseError::InvalidFormat; | ||||
| }; | }; | ||||
| @@ -578,6 +578,150 @@ HmiEditorResult HmiEditorService::moveControl( | |||||
| return {true, HmiEditorError::None, {}, control_id}; | 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( | HmiEditorResult HmiEditorService::updateControl( | ||||
| const std::string &page_id, | const std::string &page_id, | ||||
| @@ -47,6 +47,19 @@ struct HmiEditorResult | |||||
| std::string id; // 成功时返回新建或更新后的页面/控件 ID | std::string id; // 成功时返回新建或更新后的页面/控件 ID | ||||
| }; | }; | ||||
| /** | |||||
| * @brief HMI 控件批量对齐方向 | |||||
| */ | |||||
| enum class HmiAlignment | |||||
| { | |||||
| Left, | |||||
| HorizontalCenter, | |||||
| Right, | |||||
| Top, | |||||
| VerticalCenter, | |||||
| Bottom | |||||
| }; | |||||
| /** | /** | ||||
| * @brief 编排 HMI 页面和控件的编辑操作,不依赖 Qt 视图 | * @brief 编排 HMI 页面和控件的编辑操作,不依赖 Qt 视图 | ||||
| * | * | ||||
| @@ -174,6 +187,19 @@ public: | |||||
| const std::string &page_id, | const std::string &page_id, | ||||
| const std::string &control_id, | const std::string &control_id, | ||||
| const HmiRect &bounds); | 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 更新控件可编辑属性 | * @brief 更新控件可编辑属性 | ||||
| * @param page_id 所属页面唯一标识 | * @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 | std::string HmiEditorWidget::selectedControlId() const | ||||
| { | { | ||||
| const std::vector<std::string> ids = selectedControlIds(); | const std::vector<std::string> ids = selectedControlIds(); | ||||
| @@ -70,6 +70,8 @@ public: | |||||
| * @param control_id 待选中控件标识,不存在时不改变当前选择 | * @param control_id 待选中控件标识,不存在时不改变当前选择 | ||||
| */ | */ | ||||
| void selectControl(const std::string &control_id); | void selectControl(const std::string &control_id); | ||||
| /** 选中指定的多个控件并滚动到第一个可见控件 */ | |||||
| void selectControls(const std::vector<std::string> &control_ids); | |||||
| /** 返回当前选中的单个控件标识 */ | /** 返回当前选中的单个控件标识 */ | ||||
| std::string selectedControlId() const; | std::string selectedControlId() const; | ||||
| /** 返回当前选中的多个控件标识 */ | /** 返回当前选中的多个控件标识 */ | ||||
| @@ -655,6 +655,42 @@ void MainWindow::configureActions() | |||||
| [this] { addHmiControl(HmiControlType::AlarmList); }); | [this] { addHmiControl(HmiControlType::AlarmList); }); | ||||
| connect(ui_->deleteControlAction, &QAction::triggered, | connect(ui_->deleteControlAction, &QAction::triggered, | ||||
| this, &MainWindow::deleteSelectedControl); | 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( | addToolbarMenu( | ||||
| ui_->hmiToolBar, | ui_->hmiToolBar, | ||||
| tr("更多控件"), | tr("更多控件"), | ||||
| @@ -1494,8 +1530,10 @@ void MainWindow::updateEditActions() | |||||
| ui_->redoAction->setEnabled( | ui_->redoAction->setEnabled( | ||||
| editable && ((hmi_active && hmi_editor_service_.canRedo()) | editable && ((hmi_active && hmi_editor_service_.canRedo()) | ||||
| || (logic_active && logic_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 | const bool logic_selection = logic_active | ||||
| && logic_editor_widget_->hasCopyableSelection(); | && logic_editor_widget_->hasCopyableSelection(); | ||||
| ui_->copyAction->setEnabled( | ui_->copyAction->setEnabled( | ||||
| @@ -1507,6 +1545,25 @@ void MainWindow::updateEditActions() | |||||
| || (logic_active && logic_clipboard))); | || (logic_active && logic_clipboard))); | ||||
| ui_->deleteSelectionAction->setEnabled(editable && (hmi_active || logic_active)); | ui_->deleteSelectionAction->setEnabled(editable && (hmi_active || logic_active)); | ||||
| ui_->clearSelectionAction->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_->insertHorizontalWireAction->setEnabled(editable && logic_active); | ||||
| ui_->insertVerticalWireAction->setEnabled(editable && logic_active); | ui_->insertVerticalWireAction->setEnabled(editable && logic_active); | ||||
| ui_->insertRungAboveAction->setEnabled(editable && logic_active); | ui_->insertRungAboveAction->setEnabled(editable && logic_active); | ||||
| @@ -1660,6 +1717,30 @@ void MainWindow::deleteSelectedControl() | |||||
| property_panel_controller_->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) | void MainWindow::addLogicCondition(const LogicNodeConfig &config) | ||||
| { | { | ||||
| const LogicEditorResult result = logic_editor_widget_->addCondition(config); | const LogicEditorResult result = logic_editor_widget_->addCondition(config); | ||||
| @@ -29,6 +29,7 @@ class QCloseEvent; | |||||
| class QEvent; | class QEvent; | ||||
| class QLabel; | class QLabel; | ||||
| class QTimer; | class QTimer; | ||||
| class QToolButton; | |||||
| namespace Ui { | namespace Ui { | ||||
| class MainWindow; | class MainWindow; | ||||
| @@ -38,6 +39,7 @@ QT_END_NAMESPACE | |||||
| class RuntimeModeService; | class RuntimeModeService; | ||||
| class ProjectService; | class ProjectService; | ||||
| class HmiEditorService; | class HmiEditorService; | ||||
| enum class HmiAlignment; | |||||
| class HmiRuntimeService; | class HmiRuntimeService; | ||||
| class AlarmEditorService; | class AlarmEditorService; | ||||
| class AlarmService; | class AlarmService; | ||||
| @@ -172,6 +174,8 @@ private: | |||||
| void addHmiControl(HmiControlType type); | void addHmiControl(HmiControlType type); | ||||
| /** 删除当前选中的 HMI 控件 */ | /** 删除当前选中的 HMI 控件 */ | ||||
| void deleteSelectedControl(); | void deleteSelectedControl(); | ||||
| /** 对当前选中的 HMI 控件执行批量对齐 */ | |||||
| void alignSelectedHmiControls(HmiAlignment alignment); | |||||
| /** 在当前光标节点后添加串联条件,没有选择时追加到网络末尾 */ | /** 在当前光标节点后添加串联条件,没有选择时追加到网络末尾 */ | ||||
| void addLogicCondition(const LogicNodeConfig &config); | void addLogicCondition(const LogicNodeConfig &config); | ||||
| /** 为当前选中的连续逻辑范围建立并联支路 */ | /** 为当前选中的连续逻辑范围建立并联支路 */ | ||||
| @@ -304,6 +308,14 @@ private: | |||||
| std::unique_ptr<RuntimePanelController> runtime_panel_controller_; | std::unique_ptr<RuntimePanelController> runtime_panel_controller_; | ||||
| /** 运行模式互斥动作组 */ | /** 运行模式互斥动作组 */ | ||||
| QActionGroup *mode_action_group_ = nullptr; | 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 编辑器 */ | /** 主界面的 HMI 编辑器 */ | ||||
| HmiEditorWidget *hmi_editor_widget_ = nullptr; | HmiEditorWidget *hmi_editor_widget_ = nullptr; | ||||
| /** 主界面的梯形图编辑器 */ | /** 主界面的梯形图编辑器 */ | ||||
| @@ -475,6 +475,15 @@ QPixmap renderIcon(UiIcon icon, int size) | |||||
| painter.drawLine(QPointF(14, 19), QPointF(21, 12)); | painter.drawLine(QPointF(14, 19), QPointF(21, 12)); | ||||
| break; | 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: | case UiIcon::More: | ||||
| { | { | ||||
| painter.setPen(Qt::NoPen); | painter.setPen(Qt::NoPen); | ||||
| @@ -54,6 +54,7 @@ enum class UiIcon | |||||
| Subtract, // SUB 指令 | Subtract, // SUB 指令 | ||||
| Compare, // 比较指令 | Compare, // 比较指令 | ||||
| Comment, // 注释 | Comment, // 注释 | ||||
| Layout, // HMI 控件布局 | |||||
| SyntaxCheck, // 梯形图语法检查与规整 | SyntaxCheck, // 梯形图语法检查与规整 | ||||
| More, // 更多操作 | More, // 更多操作 | ||||
| HmiPage, // HMI 页面 | HmiPage, // HMI 页面 | ||||
| @@ -68,6 +68,130 @@ void testControlEditing() | |||||
| "deleted controls must not remain in the model"); | "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() | void testRuntimeUsesRegisterRepository() | ||||
| { | { | ||||
| // 运行服务只能经由仓库接口读写 M/D,不依赖具体离线实现 | // 运行服务只能经由仓库接口读写 M/D,不依赖具体离线实现 | ||||
| @@ -588,6 +712,7 @@ int main() | |||||
| { | { | ||||
| // 编辑和运行场景分别验证服务层两条独立职责 | // 编辑和运行场景分别验证服务层两条独立职责 | ||||
| testControlEditing(); | testControlEditing(); | ||||
| testBatchControlAlignment(); | |||||
| testHistoryAndAtomicBatchDelete(); | testHistoryAndAtomicBatchDelete(); | ||||
| testBatchPasteControls(); | testBatchPasteControls(); | ||||
| testRuntimeValidationAndPasteBoundaries(); | testRuntimeValidationAndPasteBoundaries(); | ||||