| @@ -8,6 +8,7 @@ | |||
| #pragma once | |||
| #include "register_address.h" | |||
| #include "project_limits.h" | |||
| #include <cstdint> | |||
| #include <map> | |||
| @@ -125,8 +126,8 @@ struct HmiPage | |||
| { | |||
| std::string id; | |||
| std::string name; | |||
| int width = 800; | |||
| int height = 480; | |||
| int width = ProjectLimits::kDefaultHmiPageWidth; | |||
| int height = ProjectLimits::kDefaultHmiPageHeight; | |||
| std::vector<HmiControl> controls; | |||
| /** | |||
| @@ -33,8 +33,12 @@ constexpr std::size_t kMaximumPropertyValueBytes = 4096U; | |||
| constexpr std::size_t kMaximumRegisterCommentBytes = 64U; | |||
| constexpr std::size_t kMaximumRungCommentBytes = 128U; | |||
| constexpr int kMaximumHmiPageWidth = 8192; | |||
| constexpr int kMaximumHmiPageHeight = 8192; | |||
| constexpr int kMinimumHmiPageWidth = 320; | |||
| constexpr int kMaximumHmiPageWidth = 1600; | |||
| constexpr int kMinimumHmiPageHeight = 200; | |||
| constexpr int kMaximumHmiPageHeight = 800; | |||
| constexpr int kDefaultHmiPageWidth = 800; | |||
| constexpr int kDefaultHmiPageHeight = 400; | |||
| constexpr int kMaximumHmiControlWidth = 8192; | |||
| constexpr int kMaximumHmiControlHeight = 8192; | |||
| constexpr int kMinimumHmiFontPointSize = 6; | |||
| @@ -876,10 +876,12 @@ bool parseHmiPage( | |||
| object, "id", context, &page->id, state, | |||
| ProjectLimits::kMaximumIdBytes) | |||
| || !readString(object, "name", context, &page->name, state) | |||
| || !readInt(object, "width", context, 1, | |||
| || !readInt(object, "width", context, | |||
| ProjectLimits::kMinimumHmiPageWidth, | |||
| ProjectLimits::kMaximumHmiPageWidth, | |||
| &page->width, state) | |||
| || !readInt(object, "height", context, 1, | |||
| || !readInt(object, "height", context, | |||
| ProjectLimits::kMinimumHmiPageHeight, | |||
| ProjectLimits::kMaximumHmiPageHeight, | |||
| &page->height, state) | |||
| || !readArray( | |||
| @@ -241,6 +241,40 @@ HmiEditorResult HmiEditorService::addPage(const std::string &name) | |||
| return {true, HmiEditorError::None, {}, project.hmiPages.back().id}; | |||
| } | |||
| HmiEditorResult HmiEditorService::resizePage( | |||
| const std::string &page_id, int width, int height) | |||
| { | |||
| const HmiPage *page = findPage(page_id); | |||
| if (page == nullptr) | |||
| { | |||
| return failure(HmiEditorError::PageNotFound, "未找到 HMI 页面"); | |||
| } | |||
| if (page->width == width && page->height == height) | |||
| { | |||
| return {true, HmiEditorError::None, {}, page_id}; | |||
| } | |||
| // 先在副本上校验新页面边界,失败时不触碰当前工程 | |||
| HmiPage candidate = *page; | |||
| candidate.width = width; | |||
| candidate.height = height; | |||
| std::string error; | |||
| if (!candidate.validate(&error)) | |||
| { | |||
| return failure(HmiEditorError::InvalidPage, error); | |||
| } | |||
| HistoryState before = captureState(); | |||
| Project &project = project_service_.editProject(); | |||
| auto editable = std::find_if( | |||
| project.hmiPages.begin(), project.hmiPages.end(), | |||
| [&page_id](const HmiPage &item) { return item.id == page_id; }); | |||
| editable->width = width; | |||
| editable->height = height; | |||
| recordHistory(std::move(before)); | |||
| return {true, HmiEditorError::None, {}, page_id}; | |||
| } | |||
| HmiEditorResult HmiEditorService::renamePage( | |||
| const std::string &page_id, const std::string &name) | |||
| { | |||
| @@ -83,6 +83,8 @@ public: | |||
| */ | |||
| HmiEditorResult ensureDefaultPage(); | |||
| HmiEditorResult addPage(const std::string &name); | |||
| HmiEditorResult resizePage( | |||
| const std::string &page_id, int width, int height); | |||
| HmiEditorResult renamePage( | |||
| const std::string &page_id, const std::string &name); | |||
| HmiEditorResult removePage(const std::string &page_id); | |||
| @@ -293,6 +293,7 @@ void MainWindow::initializeUi() | |||
| clearEditorHistories(); | |||
| current_hmi_page_id_ = hmi_editor_service_.firstPageId(); | |||
| current_logic_id_ = logic_editor_service_.firstLogicId(); | |||
| showControlProperties({}); | |||
| refreshProjectUi(); | |||
| updateModeUi(tr("系统已进入编辑态")); | |||
| } | |||
| @@ -132,7 +132,7 @@ | |||
| <item> | |||
| <widget class="QLabel" name="hmiPageSizeLabel"> | |||
| <property name="text"> | |||
| <string>800 x 480</string> | |||
| <string>800 x 400</string> | |||
| </property> | |||
| <property name="alignment"> | |||
| <set>Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter</set> | |||
| @@ -452,6 +452,54 @@ | |||
| </item> | |||
| <item> | |||
| <widget class="QStackedWidget" name="propertyStack"> | |||
| <widget class="QWidget" name="pagePropertiesPage"> | |||
| <layout class="QFormLayout" name="pagePropertiesForm"> | |||
| <property name="fieldGrowthPolicy"> | |||
| <enum>QFormLayout::AllNonFixedFieldsGrow</enum> | |||
| </property> | |||
| <item row="0" column="0"> | |||
| <widget class="QLabel" name="pageWidthLabel"> | |||
| <property name="text"> | |||
| <string>页面宽度</string> | |||
| </property> | |||
| </widget> | |||
| </item> | |||
| <item row="0" column="1"> | |||
| <widget class="QSpinBox" name="pageWidthSpinBox"> | |||
| <property name="minimum"> | |||
| <number>320</number> | |||
| </property> | |||
| <property name="maximum"> | |||
| <number>1600</number> | |||
| </property> | |||
| </widget> | |||
| </item> | |||
| <item row="1" column="0"> | |||
| <widget class="QLabel" name="pageHeightLabel"> | |||
| <property name="text"> | |||
| <string>页面高度</string> | |||
| </property> | |||
| </widget> | |||
| </item> | |||
| <item row="1" column="1"> | |||
| <widget class="QSpinBox" name="pageHeightSpinBox"> | |||
| <property name="minimum"> | |||
| <number>200</number> | |||
| </property> | |||
| <property name="maximum"> | |||
| <number>800</number> | |||
| </property> | |||
| </widget> | |||
| </item> | |||
| <item row="2" column="0" colspan="2"> | |||
| <widget class="QPushButton" name="applyPagePropertiesButton"> | |||
| <property name="text"> | |||
| <string>应用页面尺寸</string> | |||
| </property> | |||
| </widget> | |||
| </item> | |||
| </layout> | |||
| </widget> | |||
| <widget class="QWidget" name="hmiPropertiesPage"> | |||
| <layout class="QFormLayout" name="hmiPropertiesForm"> | |||
| <property name="fieldGrowthPolicy"> | |||
| @@ -86,6 +86,12 @@ PropertyPanelController::PropertyPanelController( | |||
| void PropertyPanelController::configure() | |||
| { | |||
| ui_.pageWidthSpinBox->setRange( | |||
| ProjectLimits::kMinimumHmiPageWidth, | |||
| ProjectLimits::kMaximumHmiPageWidth); | |||
| ui_.pageHeightSpinBox->setRange( | |||
| ProjectLimits::kMinimumHmiPageHeight, | |||
| ProjectLimits::kMaximumHmiPageHeight); | |||
| ui_.fontSizeSpinBox->setRange( | |||
| ProjectLimits::kMinimumHmiFontPointSize, | |||
| ProjectLimits::kMaximumHmiFontPointSize); | |||
| @@ -106,6 +112,8 @@ void PropertyPanelController::configure() | |||
| } | |||
| QObject::connect(ui_.applyPropertiesButton, &QPushButton::clicked, | |||
| &parent_, [this] { applySelectedControlProperties(); }); | |||
| QObject::connect(ui_.applyPagePropertiesButton, &QPushButton::clicked, | |||
| &parent_, [this] { applySelectedPageProperties(); }); | |||
| QObject::connect(ui_.applyLogicPropertiesButton, &QPushButton::clicked, | |||
| &parent_, [this] { applySelectedLogicNodeProperties(); }); | |||
| QObject::connect(ui_.textColorButton, &QPushButton::clicked, | |||
| @@ -156,6 +164,11 @@ void PropertyPanelController::bindEditorWidgets( | |||
| void PropertyPanelController::showControlProperties(const std::string &control_id) | |||
| { | |||
| if (control_id.empty()) | |||
| { | |||
| showPageProperties(current_page_id_()); | |||
| return; | |||
| } | |||
| ui_.propertyStack->setCurrentWidget(ui_.hmiPropertiesPage); | |||
| selected_control_id_ = control_id; | |||
| const HmiControl *control = hmi_editor_service_.findControl( | |||
| @@ -324,6 +337,33 @@ void PropertyPanelController::showControlProperties(const std::string &control_i | |||
| && font_italic->second == "true"); | |||
| } | |||
| void PropertyPanelController::showPageProperties(const std::string &page_id) | |||
| { | |||
| ui_.propertyStack->setCurrentWidget(ui_.pagePropertiesPage); | |||
| selected_control_id_.clear(); | |||
| const HmiPage *page = hmi_editor_service_.findPage(page_id); | |||
| const bool has_page = page != nullptr; | |||
| ui_.pageWidthSpinBox->setRange( | |||
| ProjectLimits::kMinimumHmiPageWidth, | |||
| ProjectLimits::kMaximumHmiPageWidth); | |||
| ui_.pageHeightSpinBox->setRange( | |||
| ProjectLimits::kMinimumHmiPageHeight, | |||
| ProjectLimits::kMaximumHmiPageHeight); | |||
| ui_.selectionValueLabel->setText( | |||
| has_page ? fromUtf8(page->name) : QObject::tr("未选择")); | |||
| ui_.pageWidthSpinBox->setEnabled(has_page); | |||
| ui_.pageHeightSpinBox->setEnabled(has_page); | |||
| ui_.applyPagePropertiesButton->setEnabled(has_page); | |||
| if (!has_page) | |||
| { | |||
| ui_.pageWidthSpinBox->setValue(ProjectLimits::kDefaultHmiPageWidth); | |||
| ui_.pageHeightSpinBox->setValue(ProjectLimits::kDefaultHmiPageHeight); | |||
| return; | |||
| } | |||
| ui_.pageWidthSpinBox->setValue(page->width); | |||
| ui_.pageHeightSpinBox->setValue(page->height); | |||
| } | |||
| void PropertyPanelController::showLogicNodeProperties(const std::string &node_id) | |||
| { | |||
| ui_.propertyStack->setCurrentWidget(ui_.logicPropertiesPage); | |||
| @@ -621,6 +661,31 @@ void PropertyPanelController::applySelectedControlProperties() | |||
| status_reporter_(QObject::tr("控件属性已更新"), 3000); | |||
| } | |||
| void PropertyPanelController::applySelectedPageProperties() | |||
| { | |||
| const std::string page_id = current_page_id_(); | |||
| if (hmi_editor_service_.findPage(page_id) == nullptr) | |||
| { | |||
| return; | |||
| } | |||
| const HmiEditorResult result = hmi_editor_service_.resizePage( | |||
| page_id, | |||
| ui_.pageWidthSpinBox->value(), | |||
| ui_.pageHeightSpinBox->value()); | |||
| if (!result.succeeded) | |||
| { | |||
| reportFailure(QObject::tr("应用页面尺寸"), result.message); | |||
| return; | |||
| } | |||
| if (hmi_editor_widget_ != nullptr) | |||
| { | |||
| hmi_editor_widget_->reloadPage(); | |||
| } | |||
| showPageProperties(page_id); | |||
| refresh_project_ui_(); | |||
| status_reporter_(QObject::tr("页面尺寸已更新"), 3000); | |||
| } | |||
| void PropertyPanelController::chooseTextColor() | |||
| { | |||
| QColor initial_color(ui_.textColorEdit->text()); | |||
| @@ -48,10 +48,12 @@ public: | |||
| LogicEditorWidget &logic_editor_widget); | |||
| void showControlProperties(const std::string &control_id); | |||
| void showPageProperties(const std::string &page_id); | |||
| void showLogicNodeProperties(const std::string &node_id); | |||
| void addHmiControl(HmiControlType type); | |||
| void deleteSelectedControl(); | |||
| void applySelectedControlProperties(); | |||
| void applySelectedPageProperties(); | |||
| void applySelectedLogicNodeProperties(); | |||
| private: | |||
| @@ -352,11 +352,11 @@ void testQuantityBoundaries() | |||
| "page-" + std::to_string(index), | |||
| "Page " + std::to_string(index), | |||
| 800, | |||
| 480, | |||
| 400, | |||
| {}}); | |||
| } | |||
| require(project.validate(), "an HMI page count of 128 must be accepted"); | |||
| project.hmiPages.push_back({"page-over", "Page over", 800, 480, {}}); | |||
| project.hmiPages.push_back({"page-over", "Page over", 800, 400, {}}); | |||
| require(!project.validate(), "an HMI page count of 129 must be rejected"); | |||
| project = makeValidProject(); | |||
| @@ -384,9 +384,14 @@ void testQuantityBoundaries() | |||
| project = makeValidProject(); | |||
| project.hmiPages.front().width = ProjectLimits::kMaximumHmiPageWidth; | |||
| project.hmiPages.front().height = ProjectLimits::kMaximumHmiPageHeight; | |||
| require(project.validate(), "an HMI page size of 8192 by 8192 must be accepted"); | |||
| require(project.validate(), "an HMI page size of 1600 by 800 must be accepted"); | |||
| project.hmiPages.front().width = ProjectLimits::kMaximumHmiPageWidth + 1; | |||
| require(!project.validate(), "an HMI page width of 8193 must be rejected"); | |||
| require(!project.validate(), "an HMI page width of 1601 must be rejected"); | |||
| project.hmiPages.front().width = ProjectLimits::kMinimumHmiPageWidth - 1; | |||
| require(!project.validate(), "an HMI page width of 319 must be rejected"); | |||
| project.hmiPages.front().width = ProjectLimits::kMaximumHmiPageWidth; | |||
| project.hmiPages.front().height = ProjectLimits::kMinimumHmiPageHeight - 1; | |||
| require(!project.validate(), "an HMI page height of 199 must be rejected"); | |||
| ConditionExpression leaf = ConditionExpression::fromNode({ | |||
| "depth-node-0", | |||
| @@ -329,6 +329,43 @@ void testPageLifecycleAndNavigation() | |||
| "stopping runtime navigation must clear session state"); | |||
| } | |||
| void testPageResizeIsAtomicAndUndoable() | |||
| { | |||
| TestProjectStorage storage; | |||
| ProjectService project_service(storage); | |||
| HmiEditorService service(project_service); | |||
| const std::string page_id = service.ensureDefaultPage().id; | |||
| const HmiEditorResult label = service.addControl(page_id, HmiControlType::Label); | |||
| require(label.succeeded, "page resize fixture must add a control"); | |||
| HmiControl control = *service.findControl(page_id, label.id); | |||
| control.bounds = {700, 340, 80, 32}; | |||
| require(service.updateControl(page_id, label.id, control).succeeded, | |||
| "page resize fixture must place the control near the page edge"); | |||
| require(service.resizePage(page_id, 1024, 600).succeeded, | |||
| "an HMI page must be resizable to a larger valid rectangle"); | |||
| require(service.findPage(page_id)->width == 1024 | |||
| && service.findPage(page_id)->height == 600, | |||
| "page resize must update both dimensions"); | |||
| require(service.resizePage(page_id, 700, 300).error | |||
| == HmiEditorError::InvalidPage, | |||
| "page resize must reject dimensions that clip an existing control"); | |||
| require(service.findPage(page_id)->width == 1024 | |||
| && service.findPage(page_id)->height == 600, | |||
| "a rejected page resize must leave the original dimensions intact"); | |||
| require(service.undo().succeeded | |||
| && service.findPage(page_id)->width == 800 | |||
| && service.findPage(page_id)->height == 400, | |||
| "page resize must participate in HMI undo history"); | |||
| require(service.redo().succeeded | |||
| && service.findPage(page_id)->width == 1024 | |||
| && service.findPage(page_id)->height == 600, | |||
| "page resize redo must restore the new dimensions"); | |||
| require(service.resizePage(page_id, 319, 600).error | |||
| == HmiEditorError::InvalidPage, | |||
| "page width below the business minimum must be rejected"); | |||
| } | |||
| } // namespace | |||
| int main() | |||
| @@ -341,6 +378,7 @@ int main() | |||
| testAppearanceEditing(); | |||
| testRuntimeUsesRegisterRepository(); | |||
| testPageLifecycleAndNavigation(); | |||
| testPageResizeIsAtomicAndUndoable(); | |||
| } | |||
| catch (const std::exception &error) | |||
| { | |||
| @@ -2174,6 +2174,21 @@ void testMultiPageAndLogicMainWindowIntegration() | |||
| QApplication::processEvents(); | |||
| require(window.currentHmiPageId() == settings_page_id, | |||
| "selecting a page tree node must change the current HMI page id"); | |||
| QSpinBox *page_width = requiredChild<QSpinBox>(window, "pageWidthSpinBox"); | |||
| QSpinBox *page_height = requiredChild<QSpinBox>(window, "pageHeightSpinBox"); | |||
| QPushButton *apply_page_properties = requiredChild<QPushButton>( | |||
| window, "applyPagePropertiesButton"); | |||
| require(page_width->isEnabled() && page_height->isEnabled() | |||
| && page_width->value() == 800 && page_height->value() == 400, | |||
| "selecting an HMI page must expose its page dimensions"); | |||
| page_width->setValue(1024); | |||
| page_height->setValue(600); | |||
| apply_page_properties->click(); | |||
| require(editor_service.findPage(settings_page_id)->width == 1024 | |||
| && editor_service.findPage(settings_page_id)->height == 600 | |||
| && requiredChild<QLabel>(window, "hmiPageSizeLabel")->text() | |||
| == QStringLiteral("1024 x 600"), | |||
| "applying page properties must update the model and page summary"); | |||
| QAction *add_label = requiredChild<QAction>(window, "addLabelAction"); | |||
| add_label->trigger(); | |||
| require(editor_service.findPage(settings_page_id)->controls.size() == 1U | |||
| @@ -866,6 +866,88 @@ void testQuantityFileLimits() | |||
| "a condition expression deeper than 20 levels must be rejected while parsing"); | |||
| } | |||
| void testHmiPageJsonBoundaries() | |||
| { | |||
| // JSON 入口必须和页面领域校验使用同一组业务边界 | |||
| QTemporaryDir directory; | |||
| require(directory.isValid(), "temporary directory must be valid"); | |||
| JsonProjectStorage storage; | |||
| ProjectService service(storage); | |||
| Project boundary_project = makeExampleProject(); | |||
| boundary_project.hmiPages.front().controls.clear(); | |||
| service.editProject() = std::move(boundary_project); | |||
| const QString fixture_path = directory.filePath("hmi-page-boundary-fixture.json"); | |||
| require(service.saveAs(fixture_path.toStdString()).succeeded, | |||
| "the HMI boundary fixture must be saved"); | |||
| const QJsonObject original_root = | |||
| QJsonDocument::fromJson(readBytes(fixture_path)).object(); | |||
| const auto loadWithSize = [&](int width, int height, const QString &name) | |||
| { | |||
| QJsonObject root = original_root; | |||
| QJsonArray pages = root.value(QStringLiteral("hmiPages")).toArray(); | |||
| QJsonObject page = pages.at(0).toObject(); | |||
| page.insert(QStringLiteral("width"), width); | |||
| page.insert(QStringLiteral("height"), height); | |||
| pages.replace(0, page); | |||
| root.insert(QStringLiteral("hmiPages"), pages); | |||
| const QString path = directory.filePath(name); | |||
| writeText(path, QJsonDocument(root).toJson(QJsonDocument::Compact)); | |||
| return storage.load(path.toStdString()); | |||
| }; | |||
| require(!loadWithSize( | |||
| ProjectLimits::kMinimumHmiPageWidth - 1, | |||
| ProjectLimits::kDefaultHmiPageHeight, | |||
| QStringLiteral("width-below-minimum.json")) | |||
| .succeeded, | |||
| "JSON must reject an HMI page width below 320"); | |||
| require(!loadWithSize( | |||
| ProjectLimits::kMaximumHmiPageWidth + 1, | |||
| ProjectLimits::kDefaultHmiPageHeight, | |||
| QStringLiteral("width-above-maximum.json")) | |||
| .succeeded, | |||
| "JSON must reject an HMI page width above 1600"); | |||
| require(!loadWithSize( | |||
| ProjectLimits::kDefaultHmiPageWidth, | |||
| ProjectLimits::kMinimumHmiPageHeight - 1, | |||
| QStringLiteral("height-below-minimum.json")) | |||
| .succeeded, | |||
| "JSON must reject an HMI page height below 200"); | |||
| require(!loadWithSize( | |||
| ProjectLimits::kDefaultHmiPageWidth, | |||
| ProjectLimits::kMaximumHmiPageHeight + 1, | |||
| QStringLiteral("height-above-maximum.json")) | |||
| .succeeded, | |||
| "JSON must reject an HMI page height above 800"); | |||
| const ProjectLoadResult minimum_result = loadWithSize( | |||
| ProjectLimits::kMinimumHmiPageWidth, | |||
| ProjectLimits::kMinimumHmiPageHeight, | |||
| QStringLiteral("minimum-size.json")); | |||
| require(minimum_result.succeeded | |||
| && minimum_result.project.hmiPages.front().width | |||
| == ProjectLimits::kMinimumHmiPageWidth | |||
| && minimum_result.project.hmiPages.front().height | |||
| == ProjectLimits::kMinimumHmiPageHeight, | |||
| "JSON must accept the minimum HMI page size 320x200"); | |||
| const ProjectLoadResult maximum_result = loadWithSize( | |||
| ProjectLimits::kMaximumHmiPageWidth, | |||
| ProjectLimits::kMaximumHmiPageHeight, | |||
| QStringLiteral("maximum-size.json")); | |||
| require(maximum_result.succeeded | |||
| && maximum_result.project.hmiPages.front().width | |||
| == ProjectLimits::kMaximumHmiPageWidth | |||
| && maximum_result.project.hmiPages.front().height | |||
| == ProjectLimits::kMaximumHmiPageHeight, | |||
| "JSON must accept the maximum HMI page size 1600x800"); | |||
| } | |||
| void testServiceStateAndSaveErrors() | |||
| { | |||
| // 保存路径和修改标记只在成功持久化后更新 | |||
| @@ -954,6 +1036,7 @@ int main() | |||
| testRegisterCommentService(); | |||
| testInvalidFiles(); | |||
| testQuantityFileLimits(); | |||
| testHmiPageJsonBoundaries(); | |||
| testServiceStateAndSaveErrors(); | |||
| } | |||
| catch (const std::exception &error) | |||