| @@ -8,6 +8,7 @@ | |||
| #include <cctype> | |||
| #include <cstddef> | |||
| #include <map> | |||
| #include <optional> | |||
| #include <set> | |||
| #include <utility> | |||
| @@ -26,6 +27,68 @@ HmiEditorResult failure(HmiEditorError error, const std::string &message) | |||
| return {false, error, message, {}}; | |||
| } | |||
| int hmiBindingWordCount(const HmiControl &control) | |||
| { | |||
| if (control.binding.has_value() && control.binding->area() == RegisterArea::M) | |||
| { | |||
| return 1; | |||
| } | |||
| if (control.type == HmiControlType::NumericDisplay | |||
| || control.type == HmiControlType::NumericInput | |||
| || (control.type == HmiControlType::StatusText | |||
| && control.binding.has_value() | |||
| && control.binding->area() == RegisterArea::D)) | |||
| { | |||
| return registerDataTypeWordCount(control.dataType); | |||
| } | |||
| return 1; | |||
| } | |||
| std::optional<RegisterAddress> nextHmiBindingAddress( | |||
| const Project &project, | |||
| const HmiControlDescriptor &descriptor, | |||
| RegisterDataType data_type) | |||
| { | |||
| std::optional<RegisterArea> area = hmiBindingArea(descriptor.bindingKind); | |||
| if (descriptor.bindingKind == HmiBindingKind::BitOrWord) | |||
| { | |||
| area = RegisterArea::M; | |||
| } | |||
| if (!area.has_value()) | |||
| { | |||
| return std::nullopt; | |||
| } | |||
| int next_index = RegisterAddress::kMinimumIndex; | |||
| for (const HmiPage &page : project.hmiPages) | |||
| { | |||
| for (const HmiControl &control : page.controls) | |||
| { | |||
| if (!control.binding.has_value() | |||
| || control.binding->area() != *area) | |||
| { | |||
| continue; | |||
| } | |||
| next_index = std::max( | |||
| next_index, | |||
| control.binding->index() + hmiBindingWordCount(control)); | |||
| } | |||
| } | |||
| for (int index = next_index; | |||
| index <= RegisterAddress::kMaximumIndex; | |||
| ++index) | |||
| { | |||
| const RegisterAddress candidate{*area, index}; | |||
| if (*area == RegisterArea::D | |||
| && !registerDataTypeAddressIsValid(data_type, candidate)) | |||
| { | |||
| continue; | |||
| } | |||
| return candidate; | |||
| } | |||
| return std::nullopt; | |||
| } | |||
| bool isBlank(const std::string &value) | |||
| { | |||
| return value.empty() | |||
| @@ -459,8 +522,16 @@ HmiEditorResult HmiEditorService::addControl( | |||
| return failure(HmiEditorError::InvalidControl, "不支持的 HMI 控件类型"); | |||
| } | |||
| // 新控件初始不绑定寄存器,避免自动分配地址造成误写风险 | |||
| HmiControl control = makeControl(*page, *descriptor); | |||
| const std::optional<RegisterAddress> binding = nextHmiBindingAddress( | |||
| project_service_.project(), *descriptor, control.dataType); | |||
| if (descriptor->bindingKind != HmiBindingKind::None && !binding.has_value()) | |||
| { | |||
| return failure( | |||
| HmiEditorError::InvalidControl, | |||
| "没有可用的 HMI 默认寄存器地址"); | |||
| } | |||
| control.binding = binding; | |||
| HistoryState before = captureState(); | |||
| Project &project = project_service_.editProject(); | |||
| auto target_page = std::find_if( | |||
| @@ -301,7 +301,7 @@ private: | |||
| * @brief 按控件类型创建带默认属性的新控件 | |||
| * @param page 新控件所属页面,用于确定初始位置和唯一标识 | |||
| * @param descriptor 新控件类型对应的注册描述 | |||
| * @return 未绑定寄存器的默认控件配置 | |||
| * @return 带默认外观和未分配地址的控件配置 | |||
| */ | |||
| static HmiControl makeControl( | |||
| const HmiPage &page, const HmiControlDescriptor &descriptor); | |||
| @@ -626,38 +626,12 @@ | |||
| </item> | |||
| <item row="6" column="0"> | |||
| <widget class="QLabel" name="bindingAreaLabel"> | |||
| <property name="text"> | |||
| <string>绑定区域</string> | |||
| </property> | |||
| </widget> | |||
| </item> | |||
| <item row="6" column="1"> | |||
| <widget class="QComboBox" name="bindingAreaComboBox"> | |||
| <item> | |||
| <property name="text"> | |||
| <string>未绑定</string> | |||
| </property> | |||
| </item> | |||
| <item> | |||
| <property name="text"> | |||
| <string>M</string> | |||
| <string>绑定地址</string> | |||
| </property> | |||
| </item> | |||
| <item> | |||
| <property name="text"> | |||
| <string>D</string> | |||
| </property> | |||
| </item> | |||
| </widget> | |||
| </item> | |||
| <item row="7" column="0"> | |||
| <widget class="QLabel" name="bindingIndexLabel"> | |||
| <property name="text"> | |||
| <string>绑定地址</string> | |||
| </property> | |||
| </widget> | |||
| </item> | |||
| <item row="7" column="1"> | |||
| <item row="6" column="1"> | |||
| <widget class="QSpinBox" name="bindingIndexSpinBox"> | |||
| <property name="maximum"> | |||
| <number>4000</number> | |||
| @@ -13,7 +13,6 @@ | |||
| #include "ui_main_window.h" | |||
| #include <QApplication> | |||
| #include <QCheckBox> | |||
| #include <QColorDialog> | |||
| #include <QComboBox> | |||
| #include <QLabel> | |||
| @@ -99,9 +98,6 @@ void PropertyPanelController::configure() | |||
| ui_.fontSizeSpinBox->setRange( | |||
| ProjectLimits::kMinimumHmiFontPointSize, | |||
| ProjectLimits::kMaximumHmiFontPointSize); | |||
| ui_.bindingAreaComboBox->setItemData(0, -1); | |||
| ui_.bindingAreaComboBox->setItemData(1, 0); | |||
| ui_.bindingAreaComboBox->setItemData(2, 1); | |||
| ui_.buttonOperationComboBox->setItemData( | |||
| 0, static_cast<int>(HmiButtonOperation::SetOn)); | |||
| ui_.buttonOperationComboBox->setItemData( | |||
| @@ -205,7 +201,6 @@ void PropertyPanelController::showControlProperties(const std::string &control_i | |||
| static_cast<QWidget *>(ui_.controlYSpinBox), | |||
| static_cast<QWidget *>(ui_.controlWidthSpinBox), | |||
| static_cast<QWidget *>(ui_.controlHeightSpinBox), | |||
| static_cast<QWidget *>(ui_.bindingAreaComboBox), | |||
| static_cast<QWidget *>(ui_.bindingIndexSpinBox), | |||
| static_cast<QWidget *>(ui_.targetPageComboBox), | |||
| static_cast<QWidget *>(ui_.buttonOperationComboBox), | |||
| @@ -227,8 +222,6 @@ void PropertyPanelController::showControlProperties(const std::string &control_i | |||
| ui_.controlTextEdit->setMaxLength( | |||
| static_cast<int>(ProjectLimits::kMaximumHmiControlTextCharacters)); | |||
| ui_.bindingAreaLabel->setVisible(false); | |||
| ui_.bindingAreaComboBox->setVisible(false); | |||
| ui_.bindingIndexLabel->setVisible(false); | |||
| ui_.bindingIndexSpinBox->setVisible(false); | |||
| ui_.targetPageLabel->setVisible(false); | |||
| ui_.targetPageComboBox->setVisible(false); | |||
| @@ -263,17 +256,22 @@ void PropertyPanelController::showControlProperties(const std::string &control_i | |||
| ui_.controlYSpinBox->setValue(control->bounds.y); | |||
| ui_.controlWidthSpinBox->setValue(control->bounds.width); | |||
| ui_.controlHeightSpinBox->setValue(control->bounds.height); | |||
| ui_.bindingAreaComboBox->setCurrentIndex(!control->binding.has_value() ? 0 | |||
| : control->binding->area() == RegisterArea::M ? 1 : 2); | |||
| const std::optional<RegisterArea> fixed_binding_area = descriptor == nullptr | |||
| ? std::nullopt : hmiBindingArea(descriptor->bindingKind); | |||
| ui_.bindingAreaLabel->setText( | |||
| fixed_binding_area.has_value() | |||
| ? QObject::tr("绑定地址(%1)") | |||
| .arg(*fixed_binding_area == RegisterArea::M ? QStringLiteral("M") | |||
| : QStringLiteral("D")) | |||
| : QObject::tr("绑定地址")); | |||
| ui_.bindingIndexSpinBox->setValue( | |||
| control->binding.has_value() ? control->binding->index() : 0); | |||
| const bool has_binding = descriptor != nullptr | |||
| && descriptor->bindingKind != HmiBindingKind::None | |||
| && !is_status_text; | |||
| ui_.bindingAreaLabel->setVisible(has_binding); | |||
| ui_.bindingAreaComboBox->setVisible(has_binding); | |||
| ui_.bindingIndexLabel->setVisible(has_binding); | |||
| ui_.bindingIndexSpinBox->setVisible(has_binding); | |||
| ui_.bindingIndexSpinBox->setEnabled(has_binding); | |||
| const bool is_button = control->type == HmiControlType::Button; | |||
| ui_.buttonOperationLabel->setVisible(is_button); | |||
| @@ -625,17 +623,16 @@ void PropertyPanelController::applySelectedControlProperties() | |||
| } | |||
| else if (control.type != HmiControlType::StatusText) | |||
| { | |||
| const int binding_area = ui_.bindingAreaComboBox->currentData().toInt(); | |||
| if (binding_area < 0) | |||
| const std::optional<RegisterArea> fixed_binding_area = descriptor == nullptr | |||
| ? std::nullopt : hmiBindingArea(descriptor->bindingKind); | |||
| if (fixed_binding_area.has_value()) | |||
| { | |||
| control.binding.reset(); | |||
| control.binding = RegisterAddress{ | |||
| *fixed_binding_area, ui_.bindingIndexSpinBox->value()}; | |||
| } | |||
| else | |||
| { | |||
| const RegisterArea area = binding_area == 0 | |||
| ? RegisterArea::M : RegisterArea::D; | |||
| control.binding = RegisterAddress{ | |||
| area, ui_.bindingIndexSpinBox->value()}; | |||
| control.binding.reset(); | |||
| } | |||
| } | |||
| if (control.type == HmiControlType::Button) | |||
| @@ -42,6 +42,15 @@ void testControlEditing() | |||
| const HmiPage *page = service.findPage(page_id); | |||
| require(page != nullptr && page->controls.size() == 4, | |||
| "all added controls must be kept in the page model"); | |||
| require(service.findControl(page_id, button.id)->binding | |||
| == RegisterAddress{RegisterArea::M, 0} | |||
| && service.findControl(page_id, indicator.id)->binding | |||
| == RegisterAddress{RegisterArea::M, 1} | |||
| && service.findControl(page_id, display.id)->binding | |||
| == RegisterAddress{RegisterArea::D, 0} | |||
| && service.findControl(page_id, input.id)->binding | |||
| == RegisterAddress{RegisterArea::D, 1}, | |||
| "new basic HMI controls must receive sequential M/D addresses"); | |||
| require(service.moveControl(page_id, button.id, {120, 80, 120, 40}).succeeded, | |||
| "a valid control move must succeed"); | |||
| require(!service.moveControl(page_id, button.id, {790, 460, 120, 40}).succeeded, | |||
| @@ -14,8 +14,10 @@ | |||
| #include "ui/free_monitor_widget.h" | |||
| #include "ui/hmi_editor_widget.h" | |||
| #include "ui/logic_editor_widget.h" | |||
| #include "ui/property_panel_controller.h" | |||
| #include "ui/runtime_monitor_widget.h" | |||
| #include "ui/runtime_panel_controller.h" | |||
| #include "ui_main_window.h" | |||
| #include <QApplication> | |||
| #include <QCoreApplication> | |||
| @@ -28,10 +30,12 @@ | |||
| #include <QKeyEvent> | |||
| #include <QLabel> | |||
| #include <QLineEdit> | |||
| #include <QMainWindow> | |||
| #include <QComboBox> | |||
| #include <QMouseEvent> | |||
| #include <QPainter> | |||
| #include <QScrollBar> | |||
| #include <QSpinBox> | |||
| #include <QWidget> | |||
| #include <algorithm> | |||
| @@ -204,6 +208,111 @@ void testAlarmListKeepsFixedGeometryWhileRecordsChange() | |||
| "removing the last alarm row must keep the fixed alarm control visible"); | |||
| } | |||
| void testHmiPropertyPanelInfersFixedBindingAreas() | |||
| { | |||
| TestProjectStorage storage; | |||
| ProjectService project_service(storage, defaultProjectLimitSettings()); | |||
| VirtualRegisterRepository repository; | |||
| HmiDefaultSettings hmi_defaults; | |||
| HmiEditorService hmi_editor_service(project_service, hmi_defaults); | |||
| HmiRuntimeService hmi_runtime_service(repository); | |||
| AlarmService alarm_service(project_service, repository); | |||
| LogicEditorService logic_editor_service(project_service); | |||
| const HmiEditorResult page = hmi_editor_service.ensureDefaultPage(); | |||
| const HmiEditorResult button = hmi_editor_service.addControl( | |||
| page.id, HmiControlType::Button); | |||
| const HmiEditorResult second_button = hmi_editor_service.addControl( | |||
| page.id, HmiControlType::Button); | |||
| const HmiEditorResult display = hmi_editor_service.addControl( | |||
| page.id, HmiControlType::NumericDisplay); | |||
| const HmiEditorResult second_display = hmi_editor_service.addControl( | |||
| page.id, HmiControlType::NumericDisplay); | |||
| require(page.succeeded && button.succeeded && second_button.succeeded | |||
| && display.succeeded && second_display.succeeded, | |||
| "property-panel fixture must create its page and controls"); | |||
| const HmiControl *default_button = hmi_editor_service.findControl( | |||
| page.id, button.id); | |||
| const HmiControl *default_second_button = hmi_editor_service.findControl( | |||
| page.id, second_button.id); | |||
| const HmiControl *default_display = hmi_editor_service.findControl( | |||
| page.id, display.id); | |||
| const HmiControl *default_second_display = hmi_editor_service.findControl( | |||
| page.id, second_display.id); | |||
| require(default_button != nullptr && default_second_button != nullptr | |||
| && default_display != nullptr && default_second_display != nullptr | |||
| && default_button->binding | |||
| == RegisterAddress{RegisterArea::M, 0} | |||
| && default_second_button->binding | |||
| == RegisterAddress{RegisterArea::M, 1} | |||
| && default_display->binding | |||
| == RegisterAddress{RegisterArea::D, 0} | |||
| && default_second_display->binding | |||
| == RegisterAddress{RegisterArea::D, 1}, | |||
| "new HMI controls must receive sequential default M/D addresses"); | |||
| QMainWindow parent; | |||
| Ui::MainWindow ui; | |||
| ui.setupUi(&parent); | |||
| HmiEditorWidget hmi_editor_widget( | |||
| hmi_editor_service, hmi_runtime_service, alarm_service, &parent); | |||
| LogicEditorWidget logic_editor_widget(logic_editor_service, &parent); | |||
| hmi_editor_widget.setPageId(page.id); | |||
| std::string selected_control_id; | |||
| std::string selected_logic_node_id; | |||
| PropertyPanelController controller( | |||
| parent, | |||
| ui, | |||
| project_service, | |||
| hmi_editor_service, | |||
| logic_editor_service, | |||
| hmi_defaults, | |||
| [&page] { return page.id; }, | |||
| [] { return std::string{}; }, | |||
| selected_control_id, | |||
| selected_logic_node_id, | |||
| [] {}, | |||
| [](const QString &, const QString &message, bool succeeded) | |||
| { | |||
| require(succeeded, message.toStdString()); | |||
| }, | |||
| [](const QString &, int) {}); | |||
| controller.configure(); | |||
| controller.bindEditorWidgets(hmi_editor_widget, logic_editor_widget); | |||
| controller.showControlProperties(button.id); | |||
| require(ui.bindingAreaLabel->text() | |||
| == QStringLiteral("绑定地址(M)") | |||
| && ui.bindingIndexSpinBox->isEnabled() | |||
| && ui.bindingIndexSpinBox->value() == 0, | |||
| "button properties must show the fixed M address"); | |||
| ui.bindingIndexSpinBox->setValue(17); | |||
| controller.applySelectedControlProperties(); | |||
| const HmiControl *updated_button = hmi_editor_service.findControl( | |||
| page.id, button.id); | |||
| require(updated_button != nullptr | |||
| && updated_button->binding | |||
| == RegisterAddress{RegisterArea::M, 17}, | |||
| "button property submission must infer the M area"); | |||
| controller.showControlProperties(display.id); | |||
| require(ui.bindingAreaLabel->text() | |||
| == QStringLiteral("绑定地址(D)") | |||
| && ui.bindingIndexSpinBox->isEnabled() | |||
| && ui.bindingIndexSpinBox->value() == 0, | |||
| "numeric properties must show the fixed D address"); | |||
| ui.bindingIndexSpinBox->setValue(23); | |||
| controller.applySelectedControlProperties(); | |||
| const HmiControl *updated_display = hmi_editor_service.findControl( | |||
| page.id, display.id); | |||
| require(updated_display != nullptr | |||
| && updated_display->binding | |||
| == RegisterAddress{RegisterArea::D, 23}, | |||
| "numeric property submission must infer the D area"); | |||
| } | |||
| void testQueuedOfflineTraceIsIgnoredAfterReturningToEditing() | |||
| { | |||
| TestProjectStorage storage; | |||
| @@ -1283,6 +1392,7 @@ int main(int argc, char *argv[]) | |||
| testAlarmConfigurationOffersMOnAndMOff(); | |||
| testMonitorOffersAllNumericTypes(); | |||
| testAlarmListKeepsFixedGeometryWhileRecordsChange(); | |||
| testHmiPropertyPanelInfersFixedBindingAreas(); | |||
| testQueuedOfflineTraceIsIgnoredAfterReturningToEditing(); | |||
| testLogicEditorGridSelectionAndDeletion(); | |||
| testLadderLayoutAndDragDeletion(); | |||
| @@ -16,11 +16,15 @@ SOURCES += \ | |||
| $$SERVICE_RUNTIME_SOURCES \ | |||
| $$SERVICE_MONITOR_SOURCES \ | |||
| ../src/ui/alarm_configuration_dialog.cpp \ | |||
| ../src/ui/button_extension_dialog.cpp \ | |||
| ../src/ui/hmi_editor_widget.cpp \ | |||
| ../src/ui/logic_instruction_dialog.cpp \ | |||
| ../src/ui/logic_editor_widget.cpp \ | |||
| ../src/ui/property_panel_controller.cpp \ | |||
| ../src/ui/free_monitor_widget.cpp \ | |||
| ../src/ui/runtime_monitor_window.cpp \ | |||
| ../src/ui/runtime_monitor_widget.cpp \ | |||
| ../src/ui/status_text_dialog.cpp \ | |||
| ../src/ui/toolbar_icon_factory.cpp \ | |||
| ../src/ui/runtime_panel_controller.cpp | |||
| @@ -35,17 +39,25 @@ HEADERS += \ | |||
| $$SERVICE_MONITOR_HEADERS \ | |||
| ../src/services/plc_communication_gateway.h \ | |||
| ../src/ui/alarm_configuration_dialog.h \ | |||
| ../src/ui/button_extension_dialog.h \ | |||
| ../src/ui/hmi_editor_widget.h \ | |||
| ../src/ui/logic_instruction_dialog.h \ | |||
| ../src/ui/logic_editor_widget.h \ | |||
| ../src/ui/property_panel_controller.h \ | |||
| ../src/ui/free_monitor_widget.h \ | |||
| ../src/ui/runtime_monitor_window.h \ | |||
| ../src/ui/runtime_monitor_widget.h \ | |||
| ../src/ui/status_text_dialog.h \ | |||
| ../src/ui/toolbar_icon_factory.h \ | |||
| ../src/ui/runtime_panel_controller.h \ | |||
| $$TEST_SUPPORT_HEADERS | |||
| FORMS += \ | |||
| ../src/ui/alarm_configuration_dialog.ui \ | |||
| ../src/ui/button_extension_dialog.ui \ | |||
| ../src/ui/free_monitor_widget.ui \ | |||
| ../src/ui/logic_instruction_dialog.ui \ | |||
| ../src/ui/main_window.ui \ | |||
| ../src/ui/runtime_monitor_window.ui \ | |||
| ../src/ui/runtime_monitor_widget.ui | |||
| ../src/ui/runtime_monitor_widget.ui \ | |||
| ../src/ui/status_text_dialog.ui | |||