| @@ -4,6 +4,8 @@ | |||
| #include "project_limits.h" | |||
| #include <algorithm> | |||
| #include <cctype> | |||
| #include <charconv> | |||
| namespace { | |||
| @@ -16,6 +18,75 @@ void setError(std::string *error, const std::string &message) | |||
| } | |||
| } | |||
| bool isHexDigit(char value) | |||
| { | |||
| return std::isxdigit(static_cast<unsigned char>(value)) != 0; | |||
| } | |||
| bool isValidTextColor(const std::string &value) | |||
| { | |||
| return value.size() == 7U | |||
| && value.front() == '#' | |||
| && std::all_of(value.cbegin() + 1, value.cend(), isHexDigit); | |||
| } | |||
| bool parseInteger(const std::string &value, int *result) | |||
| { | |||
| if (value.empty() || result == nullptr) | |||
| { | |||
| return false; | |||
| } | |||
| const char *begin = value.data(); | |||
| const char *end = begin + value.size(); | |||
| const auto parsed = std::from_chars(begin, end, *result); | |||
| return parsed.ec == std::errc{} && parsed.ptr == end; | |||
| } | |||
| bool isBooleanValue(const std::string &value) | |||
| { | |||
| return value == "true" || value == "false"; | |||
| } | |||
| bool validateAppearanceProperty( | |||
| const std::string &key, const std::string &value, std::string *error) | |||
| { | |||
| if (key == HmiAppearanceProperty::kTextColor) | |||
| { | |||
| if (!isValidTextColor(value)) | |||
| { | |||
| setError(error, "HMI 字体颜色必须是 #RRGGBB 格式"); | |||
| return false; | |||
| } | |||
| } | |||
| else if (key == HmiAppearanceProperty::kFontSize) | |||
| { | |||
| int point_size = 0; | |||
| if (!parseInteger(value, &point_size) | |||
| || point_size < ProjectLimits::kMinimumHmiFontPointSize | |||
| || point_size > ProjectLimits::kMaximumHmiFontPointSize) | |||
| { | |||
| setError( | |||
| error, | |||
| "HMI 字号必须在 " | |||
| + std::to_string(ProjectLimits::kMinimumHmiFontPointSize) | |||
| + "~" | |||
| + std::to_string(ProjectLimits::kMaximumHmiFontPointSize) | |||
| + " 范围内"); | |||
| return false; | |||
| } | |||
| } | |||
| else if (key == HmiAppearanceProperty::kFontBold | |||
| || key == HmiAppearanceProperty::kFontItalic) | |||
| { | |||
| if (!isBooleanValue(value)) | |||
| { | |||
| setError(error, "HMI 粗体和斜体属性必须是 true 或 false"); | |||
| return false; | |||
| } | |||
| } | |||
| return true; | |||
| } | |||
| } // namespace | |||
| bool HmiProgressBarConfig::isValid() const | |||
| @@ -87,6 +158,10 @@ bool HmiControl::validate(std::string *error) const | |||
| setError(error, "HMI 控件属性值不能超过 4096 个 UTF-8 字节"); | |||
| return false; | |||
| } | |||
| if (!validateAppearanceProperty(property.first, property.second, error)) | |||
| { | |||
| return false; | |||
| } | |||
| } | |||
| const std::optional<RegisterArea> binding_area = | |||
| hmiBindingArea(descriptor->bindingKind); | |||
| @@ -15,6 +15,16 @@ | |||
| #include <string> | |||
| #include <vector> | |||
| namespace HmiAppearanceProperty | |||
| { | |||
| inline constexpr const char kTextColor[] = "textColor"; | |||
| inline constexpr const char kFontSize[] = "fontSize"; | |||
| inline constexpr const char kFontBold[] = "fontBold"; | |||
| inline constexpr const char kFontItalic[] = "fontItalic"; | |||
| } // namespace HmiAppearanceProperty | |||
| /** | |||
| * @brief 描述 HMI 控件在页面坐标系中的位置和尺寸 | |||
| * | |||
| @@ -32,6 +32,8 @@ constexpr int kMaximumHmiPageWidth = 8192; | |||
| constexpr int kMaximumHmiPageHeight = 8192; | |||
| constexpr int kMaximumHmiControlWidth = 8192; | |||
| constexpr int kMaximumHmiControlHeight = 8192; | |||
| constexpr int kMinimumHmiFontPointSize = 6; | |||
| constexpr int kMaximumHmiFontPointSize = 72; | |||
| constexpr std::size_t kMaximumPollAddresses = 1024U; | |||
| constexpr std::size_t kMaximumPollBlocks = 64U; | |||
| @@ -3,8 +3,10 @@ | |||
| #include "services/hmi_editor_service.h" | |||
| #include "services/hmi_runtime_service.h" | |||
| #include "services/alarm_service.h" | |||
| #include "domain/project_limits.h" | |||
| #include <QDateTime> | |||
| #include <QFont> | |||
| #include <QGraphicsItem> | |||
| #include <QGraphicsRectItem> | |||
| #include <QGraphicsScene> | |||
| @@ -98,6 +100,8 @@ public: | |||
| painter->setPen(QPen(QColor(QStringLiteral("#47545f")), 1)); | |||
| } | |||
| applyConfiguredFont(painter); | |||
| switch (control_.type) | |||
| { | |||
| case HmiControlType::Button: | |||
| @@ -115,7 +119,7 @@ public: | |||
| : QColor(QStringLiteral("#5e816b")); | |||
| const QColor text = disabled | |||
| ? QColor(QStringLiteral("#7a837e")) | |||
| : QColor(QStringLiteral("#205c3b")); | |||
| : configuredTextColor(QColor(QStringLiteral("#205c3b"))); | |||
| // 未按下时保留下沿阴影,按下后将按钮面下移形成明确的回弹感 | |||
| QRectF face = rect.adjusted(0, 0, 0, -2); | |||
| @@ -150,7 +154,7 @@ public: | |||
| painter->setBrush(bit_value_ ? QColor(QStringLiteral("#24a148")) | |||
| : QColor(QStringLiteral("#b8c1c8"))); | |||
| painter->drawEllipse(lamp); | |||
| painter->setPen(QColor(QStringLiteral("#24313b"))); | |||
| painter->setPen(configuredTextColor(QColor(QStringLiteral("#24313b")))); | |||
| painter->drawText( | |||
| QRectF(rect.left(), lamp.bottom() + 1, rect.width(), 16), | |||
| Qt::AlignCenter, | |||
| @@ -163,7 +167,7 @@ public: | |||
| // 数值显示为只读样式,文本由运行值刷新 | |||
| painter->setBrush(QColor(QStringLiteral("#edf2f6"))); | |||
| painter->drawRect(rect); | |||
| painter->setPen(QColor(QStringLiteral("#24313b"))); | |||
| painter->setPen(configuredTextColor(QColor(QStringLiteral("#24313b")))); | |||
| painter->drawText(rect.adjusted(7, 0, -7, 0), | |||
| Qt::AlignVCenter | Qt::AlignLeft, | |||
| textWithValue()); | |||
| @@ -174,7 +178,7 @@ public: | |||
| // 数值输入以白色编辑框样式呈现,双击后才请求写入 | |||
| painter->setBrush(QColor(QStringLiteral("#ffffff"))); | |||
| painter->drawRoundedRect(rect, 3, 3); | |||
| painter->setPen(QColor(QStringLiteral("#24313b"))); | |||
| painter->setPen(configuredTextColor(QColor(QStringLiteral("#24313b")))); | |||
| painter->drawText(rect.adjusted(7, 0, -7, 0), | |||
| Qt::AlignVCenter | Qt::AlignLeft, | |||
| textWithValue()); | |||
| @@ -216,7 +220,7 @@ public: | |||
| ? percentage_text | |||
| : display_text + QStringLiteral(": ") + percentage_text; | |||
| } | |||
| painter->setPen(QColor(QStringLiteral("#20342a"))); | |||
| painter->setPen(configuredTextColor(QColor(QStringLiteral("#20342a")))); | |||
| painter->drawText(rect.adjusted(6, 0, -6, 0), | |||
| Qt::AlignCenter, | |||
| display_text); | |||
| @@ -230,7 +234,7 @@ public: | |||
| painter->setBrush(fill); | |||
| painter->setPen(QPen(QColor(QStringLiteral("#4e789f")), 1)); | |||
| painter->drawRoundedRect(rect, 4, 4); | |||
| painter->setPen(QColor(QStringLiteral("#244b6b"))); | |||
| painter->setPen(configuredTextColor(QColor(QStringLiteral("#244b6b")))); | |||
| painter->drawText(rect, Qt::AlignCenter, textWithValue()); | |||
| break; | |||
| } | |||
| @@ -257,7 +261,7 @@ public: | |||
| maximum_rows, static_cast<int>(alarm_records_.size())); | |||
| if (visible_rows == 0) | |||
| { | |||
| painter->setPen(QColor(QStringLiteral("#6f7a82"))); | |||
| painter->setPen(configuredTextColor(QColor(QStringLiteral("#6f7a82")))); | |||
| painter->drawText( | |||
| QRectF( | |||
| rect.left(), | |||
| @@ -282,7 +286,7 @@ public: | |||
| QColor(QStringLiteral("#fde8e8"))); | |||
| painter->setPen(QColor(QStringLiteral("#d3d8dc"))); | |||
| painter->drawLine(row_rect.bottomLeft(), row_rect.bottomRight()); | |||
| painter->setPen(QColor(QStringLiteral("#8d1f1f"))); | |||
| painter->setPen(configuredTextColor(QColor(QStringLiteral("#8d1f1f")))); | |||
| const QString state = record.acknowledged | |||
| ? QObject::tr("已确认") : QObject::tr("未确认"); | |||
| painter->drawText( | |||
| @@ -309,7 +313,7 @@ public: | |||
| default: | |||
| { | |||
| // 标签只显示固定文本,不绑定寄存器运行值 | |||
| painter->setPen(QColor(QStringLiteral("#24313b"))); | |||
| painter->setPen(configuredTextColor(QColor(QStringLiteral("#24313b")))); | |||
| painter->drawText(rect, Qt::AlignCenter, | |||
| QString::fromUtf8(control_.text.data(), | |||
| static_cast<int>(control_.text.size()))); | |||
| @@ -577,6 +581,56 @@ private: | |||
| : QString{}; | |||
| } | |||
| QColor configuredTextColor(const QColor &fallback) const | |||
| { | |||
| const auto property = control_.properties.find( | |||
| HmiAppearanceProperty::kTextColor); | |||
| if (property == control_.properties.cend()) | |||
| { | |||
| return fallback; | |||
| } | |||
| const QColor color = QColor(QString::fromUtf8( | |||
| property->second.data(), static_cast<int>(property->second.size()))); | |||
| return color.isValid() ? color : fallback; | |||
| } | |||
| void applyConfiguredFont(QPainter *painter) const | |||
| { | |||
| if (painter == nullptr) | |||
| { | |||
| return; | |||
| } | |||
| QFont font = painter->font(); | |||
| const auto font_size = control_.properties.find( | |||
| HmiAppearanceProperty::kFontSize); | |||
| if (font_size != control_.properties.cend()) | |||
| { | |||
| bool ok = false; | |||
| const int point_size = QString::fromUtf8( | |||
| font_size->second.data(), | |||
| static_cast<int>(font_size->second.size())).toInt(&ok); | |||
| if (ok | |||
| && point_size >= ProjectLimits::kMinimumHmiFontPointSize | |||
| && point_size <= ProjectLimits::kMaximumHmiFontPointSize) | |||
| { | |||
| font.setPointSize(point_size); | |||
| } | |||
| } | |||
| const auto font_bold = control_.properties.find( | |||
| HmiAppearanceProperty::kFontBold); | |||
| if (font_bold != control_.properties.cend()) | |||
| { | |||
| font.setBold(font_bold->second == "true"); | |||
| } | |||
| const auto font_italic = control_.properties.find( | |||
| HmiAppearanceProperty::kFontItalic); | |||
| if (font_italic != control_.properties.cend()) | |||
| { | |||
| font.setItalic(font_italic->second == "true"); | |||
| } | |||
| painter->setFont(font); | |||
| } | |||
| // 根据控件类型和运行数据组合当前应绘制的文字 | |||
| QString textWithValue() const | |||
| { | |||
| @@ -679,7 +679,75 @@ | |||
| </property> | |||
| </widget> | |||
| </item> | |||
| <item row="13" column="0" colspan="2"> | |||
| <item row="13" column="0"> | |||
| <widget class="QLabel" name="textColorLabel"> | |||
| <property name="text"> | |||
| <string>字体颜色</string> | |||
| </property> | |||
| </widget> | |||
| </item> | |||
| <item row="13" column="1"> | |||
| <widget class="QWidget" name="textColorContainer"> | |||
| <layout class="QHBoxLayout" name="textColorLayout"> | |||
| <property name="leftMargin"><number>0</number></property> | |||
| <property name="topMargin"><number>0</number></property> | |||
| <property name="rightMargin"><number>0</number></property> | |||
| <property name="bottomMargin"><number>0</number></property> | |||
| <item> | |||
| <widget class="QLineEdit" name="textColorEdit"> | |||
| <property name="placeholderText"> | |||
| <string>默认,例如 #E53935</string> | |||
| </property> | |||
| </widget> | |||
| </item> | |||
| <item> | |||
| <widget class="QPushButton" name="textColorButton"> | |||
| <property name="text"> | |||
| <string>选择</string> | |||
| </property> | |||
| </widget> | |||
| </item> | |||
| </layout> | |||
| </widget> | |||
| </item> | |||
| <item row="14" column="0"> | |||
| <widget class="QLabel" name="fontSizeLabel"> | |||
| <property name="text"> | |||
| <string>字号</string> | |||
| </property> | |||
| </widget> | |||
| </item> | |||
| <item row="14" column="1"> | |||
| <widget class="QSpinBox" name="fontSizeSpinBox"/> | |||
| </item> | |||
| <item row="15" column="0"> | |||
| <widget class="QLabel" name="fontBoldLabel"> | |||
| <property name="text"> | |||
| <string>字体样式</string> | |||
| </property> | |||
| </widget> | |||
| </item> | |||
| <item row="15" column="1"> | |||
| <widget class="QWidget" name="fontStyleContainer"> | |||
| <layout class="QHBoxLayout" name="fontStyleLayout"> | |||
| <property name="leftMargin"><number>0</number></property> | |||
| <property name="topMargin"><number>0</number></property> | |||
| <property name="rightMargin"><number>0</number></property> | |||
| <property name="bottomMargin"><number>0</number></property> | |||
| <item> | |||
| <widget class="QCheckBox" name="fontBoldCheckBox"> | |||
| <property name="text"><string>粗体</string></property> | |||
| </widget> | |||
| </item> | |||
| <item> | |||
| <widget class="QCheckBox" name="fontItalicCheckBox"> | |||
| <property name="text"><string>斜体</string></property> | |||
| </widget> | |||
| </item> | |||
| </layout> | |||
| </widget> | |||
| </item> | |||
| <item row="16" column="0" colspan="2"> | |||
| <widget class="QPushButton" name="applyPropertiesButton"> | |||
| <property name="text"> | |||
| <string>应用属性</string> | |||
| @@ -1,6 +1,7 @@ | |||
| #include "property_panel_controller.h" | |||
| #include "domain/hmi_control_registry.h" | |||
| #include "domain/project_limits.h" | |||
| #include "hmi_editor_widget.h" | |||
| #include "logic_editor_widget.h" | |||
| #include "logic_instruction_dialog.h" | |||
| @@ -9,7 +10,9 @@ | |||
| #include "services/project_service.h" | |||
| #include "ui_main_window.h" | |||
| #include <QApplication> | |||
| #include <QCheckBox> | |||
| #include <QColorDialog> | |||
| #include <QComboBox> | |||
| #include <QLabel> | |||
| #include <QPushButton> | |||
| @@ -19,6 +22,7 @@ | |||
| #include <QStackedWidget> | |||
| #include <QWidget> | |||
| #include <algorithm> | |||
| #include <cstdint> | |||
| #include <utility> | |||
| #include <vector> | |||
| @@ -68,6 +72,9 @@ PropertyPanelController::PropertyPanelController( | |||
| 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); | |||
| @@ -87,6 +94,8 @@ void PropertyPanelController::configure() | |||
| &parent_, [this] { applySelectedControlProperties(); }); | |||
| QObject::connect(ui_.applyLogicPropertiesButton, &QPushButton::clicked, | |||
| &parent_, [this] { applySelectedLogicNodeProperties(); }); | |||
| QObject::connect(ui_.textColorButton, &QPushButton::clicked, | |||
| &parent_, [this] { chooseTextColor(); }); | |||
| ui_.targetPageLabel->setVisible(false); | |||
| ui_.targetPageComboBox->setVisible(false); | |||
| showControlProperties({}); | |||
| @@ -160,6 +169,11 @@ void PropertyPanelController::showControlProperties(const std::string &control_i | |||
| static_cast<QWidget *>(ui_.progressMinimumSpinBox), | |||
| static_cast<QWidget *>(ui_.progressMaximumSpinBox), | |||
| static_cast<QWidget *>(ui_.progressShowValueCheckBox), | |||
| static_cast<QWidget *>(ui_.textColorEdit), | |||
| static_cast<QWidget *>(ui_.textColorButton), | |||
| static_cast<QWidget *>(ui_.fontSizeSpinBox), | |||
| static_cast<QWidget *>(ui_.fontBoldCheckBox), | |||
| static_cast<QWidget *>(ui_.fontItalicCheckBox), | |||
| static_cast<QWidget *>(ui_.applyPropertiesButton)}) | |||
| { | |||
| widget->setEnabled(has_control); | |||
| @@ -181,6 +195,15 @@ void PropertyPanelController::showControlProperties(const std::string &control_i | |||
| ui_.progressMaximumSpinBox->setVisible(false); | |||
| ui_.progressShowValueLabel->setVisible(false); | |||
| ui_.progressShowValueCheckBox->setVisible(false); | |||
| ui_.textColorEdit->clear(); | |||
| ui_.textColorButton->setStyleSheet(QString{}); | |||
| ui_.fontSizeSpinBox->setValue( | |||
| std::clamp( | |||
| QApplication::font().pointSize(), | |||
| ProjectLimits::kMinimumHmiFontPointSize, | |||
| ProjectLimits::kMaximumHmiFontPointSize)); | |||
| ui_.fontBoldCheckBox->setChecked(false); | |||
| ui_.fontItalicCheckBox->setChecked(false); | |||
| return; | |||
| } | |||
| @@ -242,6 +265,48 @@ void PropertyPanelController::showControlProperties(const std::string &control_i | |||
| ui_.progressMinimumSpinBox->setValue(progress_config.minimumValue); | |||
| ui_.progressMaximumSpinBox->setValue(progress_config.maximumValue); | |||
| ui_.progressShowValueCheckBox->setChecked(progress_config.showValue); | |||
| const auto text_color = control->properties.find( | |||
| HmiAppearanceProperty::kTextColor); | |||
| ui_.textColorEdit->setText(text_color == control->properties.cend() | |||
| ? QString{} | |||
| : fromUtf8(text_color->second)); | |||
| const QColor swatch_color(ui_.textColorEdit->text()); | |||
| ui_.textColorButton->setStyleSheet( | |||
| swatch_color.isValid() | |||
| ? QStringLiteral("background-color: %1;").arg( | |||
| swatch_color.name(QColor::HexRgb)) | |||
| : QString{}); | |||
| const int default_font_size = std::clamp( | |||
| QApplication::font().pointSize(), | |||
| ProjectLimits::kMinimumHmiFontPointSize, | |||
| ProjectLimits::kMaximumHmiFontPointSize); | |||
| int font_size = default_font_size; | |||
| const auto font_size_property = control->properties.find( | |||
| HmiAppearanceProperty::kFontSize); | |||
| if (font_size_property != control->properties.cend()) | |||
| { | |||
| bool ok = false; | |||
| const int parsed = fromUtf8(font_size_property->second).toInt(&ok); | |||
| if (ok) | |||
| { | |||
| font_size = parsed; | |||
| } | |||
| } | |||
| ui_.fontSizeSpinBox->setValue(std::clamp( | |||
| font_size, | |||
| ProjectLimits::kMinimumHmiFontPointSize, | |||
| ProjectLimits::kMaximumHmiFontPointSize)); | |||
| const auto font_bold = control->properties.find( | |||
| HmiAppearanceProperty::kFontBold); | |||
| ui_.fontBoldCheckBox->setChecked( | |||
| font_bold != control->properties.cend() && font_bold->second == "true"); | |||
| const auto font_italic = control->properties.find( | |||
| HmiAppearanceProperty::kFontItalic); | |||
| ui_.fontItalicCheckBox->setChecked( | |||
| font_italic != control->properties.cend() | |||
| && font_italic->second == "true"); | |||
| } | |||
| void PropertyPanelController::showLogicNodeProperties(const std::string &node_id) | |||
| @@ -510,6 +575,21 @@ void PropertyPanelController::applySelectedControlProperties() | |||
| static_cast<std::int16_t>(ui_.progressMaximumSpinBox->value()), | |||
| ui_.progressShowValueCheckBox->isChecked()}; | |||
| } | |||
| const std::string text_color = toUtf8(ui_.textColorEdit->text()); | |||
| if (text_color.empty()) | |||
| { | |||
| control.properties.erase(HmiAppearanceProperty::kTextColor); | |||
| } | |||
| else | |||
| { | |||
| control.properties[HmiAppearanceProperty::kTextColor] = text_color; | |||
| } | |||
| control.properties[HmiAppearanceProperty::kFontSize] = std::to_string( | |||
| ui_.fontSizeSpinBox->value()); | |||
| control.properties[HmiAppearanceProperty::kFontBold] = | |||
| ui_.fontBoldCheckBox->isChecked() ? "true" : "false"; | |||
| control.properties[HmiAppearanceProperty::kFontItalic] = | |||
| ui_.fontItalicCheckBox->isChecked() ? "true" : "false"; | |||
| const HmiEditorResult result = hmi_editor_service_.updateControl( | |||
| current_page_id_(), selected_control_id_, control); | |||
| if (!result.succeeded) | |||
| @@ -526,6 +606,24 @@ void PropertyPanelController::applySelectedControlProperties() | |||
| status_reporter_(QObject::tr("控件属性已更新"), 3000); | |||
| } | |||
| void PropertyPanelController::chooseTextColor() | |||
| { | |||
| QColor initial_color(ui_.textColorEdit->text()); | |||
| if (!initial_color.isValid()) | |||
| { | |||
| initial_color = QColor(QStringLiteral("#24313b")); | |||
| } | |||
| const QColor color = QColorDialog::getColor( | |||
| initial_color, &parent_, QObject::tr("选择字体颜色")); | |||
| if (color.isValid()) | |||
| { | |||
| ui_.textColorEdit->setText(color.name(QColor::HexRgb)); | |||
| ui_.textColorButton->setStyleSheet( | |||
| QStringLiteral("background-color: %1;").arg( | |||
| color.name(QColor::HexRgb))); | |||
| } | |||
| } | |||
| void PropertyPanelController::applySelectedLogicNodeProperties() | |||
| { | |||
| const LogicNode *node = logic_editor_service_.findNode( | |||
| @@ -57,6 +57,7 @@ public: | |||
| private: | |||
| void handleHmiEditorError(const QString &message) const; | |||
| void handleLogicEditorError(const QString &message) const; | |||
| void chooseTextColor(); | |||
| void reportFailure( | |||
| const QString &action, const std::string &message) const; | |||
| @@ -205,6 +205,41 @@ void testProgressBarConfigurationBoundaries() | |||
| "a progress bar must reject an M binding"); | |||
| } | |||
| Project makeValidProject(); | |||
| void testHmiAppearancePropertyBoundaries() | |||
| { | |||
| // 外观属性必须在领域层拒绝格式错误,但不能影响未知扩展属性 | |||
| Project project = makeValidProject(); | |||
| HmiControl &button = project.hmiPages.front().controls.front(); | |||
| button.properties[HmiAppearanceProperty::kTextColor] = "#E53935"; | |||
| button.properties[HmiAppearanceProperty::kFontSize] = "18"; | |||
| button.properties[HmiAppearanceProperty::kFontBold] = "true"; | |||
| button.properties[HmiAppearanceProperty::kFontItalic] = "false"; | |||
| require(project.validate(), "valid HMI appearance properties must pass validation"); | |||
| button.properties[HmiAppearanceProperty::kTextColor] = "red"; | |||
| require(!project.validate(), "text colors must use the #RRGGBB format"); | |||
| project = makeValidProject(); | |||
| HmiControl &font_control = project.hmiPages.front().controls.front(); | |||
| font_control.properties[HmiAppearanceProperty::kFontSize] = "5"; | |||
| require(!project.validate(), "font sizes below the minimum must be rejected"); | |||
| font_control.properties[HmiAppearanceProperty::kFontSize] = "73"; | |||
| require(!project.validate(), "font sizes above the maximum must be rejected"); | |||
| font_control.properties[HmiAppearanceProperty::kFontSize] = "large"; | |||
| require(!project.validate(), "non-numeric font sizes must be rejected"); | |||
| project = makeValidProject(); | |||
| HmiControl &style_control = project.hmiPages.front().controls.front(); | |||
| style_control.properties[HmiAppearanceProperty::kFontBold] = "yes"; | |||
| require(!project.validate(), "font style flags must be true or false"); | |||
| project = makeValidProject(); | |||
| project.hmiPages.front().controls.front().properties["legacyColor"] = "green"; | |||
| require(project.validate(), "unknown HMI extension properties must remain supported"); | |||
| } | |||
| Project makeValidProject() | |||
| { | |||
| // 构造包含 HMI 绑定和完整梯形图网络的最小合法工程作为测试基线 | |||
| @@ -856,6 +891,7 @@ int main() | |||
| testRegisterRepositorySeparatesAreas(); | |||
| testHmiControlRegistryCompleteness(); | |||
| testProgressBarConfigurationBoundaries(); | |||
| testHmiAppearancePropertyBoundaries(); | |||
| testLogicNodeConfigurationBoundaries(); | |||
| testTimerAndCommentBoundaries(); | |||
| testTimerReferencesForRunning(); | |||
| @@ -240,6 +240,47 @@ void testHistoryAndAtomicBatchDelete() | |||
| "HMI history must retain exactly the configured 100 most recent steps"); | |||
| } | |||
| void testAppearanceEditing() | |||
| { | |||
| 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, "a label must be available for appearance editing"); | |||
| HmiControl appearance = *service.findControl(page_id, label.id); | |||
| appearance.properties[HmiAppearanceProperty::kTextColor] = "#E53935"; | |||
| appearance.properties[HmiAppearanceProperty::kFontSize] = "18"; | |||
| appearance.properties[HmiAppearanceProperty::kFontBold] = "true"; | |||
| appearance.properties[HmiAppearanceProperty::kFontItalic] = "false"; | |||
| require(service.updateControl(page_id, label.id, appearance).succeeded, | |||
| "valid appearance properties must be applied atomically"); | |||
| const HmiControl *updated = service.findControl(page_id, label.id); | |||
| require(updated != nullptr | |||
| && updated->properties.at(HmiAppearanceProperty::kTextColor) == "#E53935" | |||
| && updated->properties.at(HmiAppearanceProperty::kFontSize) == "18" | |||
| && updated->properties.at(HmiAppearanceProperty::kFontBold) == "true", | |||
| "appearance properties must be stored on the HMI control"); | |||
| HmiControl invalid = *updated; | |||
| invalid.properties[HmiAppearanceProperty::kTextColor] = "invalid"; | |||
| require(!service.updateControl(page_id, label.id, invalid).succeeded, | |||
| "invalid appearance properties must be rejected without a partial update"); | |||
| require(service.findControl(page_id, label.id)->properties.at( | |||
| HmiAppearanceProperty::kTextColor) == "#E53935", | |||
| "failed appearance updates must leave the old color intact"); | |||
| require(service.undo().succeeded, | |||
| "appearance updates must participate in HMI undo history"); | |||
| require(service.findControl(page_id, label.id)->properties.empty(), | |||
| "undo must remove the applied appearance properties"); | |||
| require(service.redo().succeeded | |||
| && service.findControl(page_id, label.id)->properties.at( | |||
| HmiAppearanceProperty::kFontSize) == "18", | |||
| "redo must restore the applied appearance properties"); | |||
| } | |||
| void testPageLifecycleAndNavigation() | |||
| { | |||
| TestProjectStorage storage; | |||
| @@ -316,6 +357,7 @@ int main() | |||
| // 编辑和运行场景分别验证服务层两条独立职责 | |||
| testControlEditing(); | |||
| testHistoryAndAtomicBatchDelete(); | |||
| testAppearanceEditing(); | |||
| testRuntimeUsesRegisterRepository(); | |||
| testPageLifecycleAndNavigation(); | |||
| } | |||
| @@ -783,6 +783,7 @@ void testModeActionsControlEditingAvailability() | |||
| QDockWidget *properties_dock = requiredChild<QDockWidget>(window, "propertiesDock"); | |||
| QLabel *selection = requiredChild<QLabel>(window, "selectionValueLabel"); | |||
| QLineEdit *text_edit = requiredChild<QLineEdit>(window, "controlTextEdit"); | |||
| QLineEdit *text_color_edit = requiredChild<QLineEdit>(window, "textColorEdit"); | |||
| QComboBox *button_operation = requiredChild<QComboBox>( | |||
| window, "buttonOperationComboBox"); | |||
| QComboBox *binding_area = requiredChild<QComboBox>( | |||
| @@ -793,6 +794,9 @@ void testModeActionsControlEditingAvailability() | |||
| window, "progressMinimumSpinBox"); | |||
| QSpinBox *progress_maximum = requiredChild<QSpinBox>( | |||
| window, "progressMaximumSpinBox"); | |||
| QSpinBox *font_size = requiredChild<QSpinBox>(window, "fontSizeSpinBox"); | |||
| QCheckBox *font_bold = requiredChild<QCheckBox>(window, "fontBoldCheckBox"); | |||
| QCheckBox *font_italic = requiredChild<QCheckBox>(window, "fontItalicCheckBox"); | |||
| QCheckBox *progress_show_value = requiredChild<QCheckBox>( | |||
| window, "progressShowValueCheckBox"); | |||
| QPushButton *apply_properties = requiredChild<QPushButton>( | |||
| @@ -966,10 +970,23 @@ void testModeActionsControlEditingAvailability() | |||
| "an unbound HMI control must not reserve an address label area"); | |||
| button_operation->setCurrentIndex( | |||
| button_operation->findData(static_cast<int>(HmiButtonOperation::Toggle))); | |||
| text_color_edit->setText(QStringLiteral("#E53935")); | |||
| font_size->setValue(18); | |||
| font_bold->setChecked(true); | |||
| font_italic->setChecked(true); | |||
| apply_properties->click(); | |||
| require(editor_service.findControl(page_id, "button-1")->buttonOperation | |||
| == HmiButtonOperation::Toggle, | |||
| "the property panel must update the HMI button operation"); | |||
| const HmiControl *styled_button = editor_service.findControl(page_id, "button-1"); | |||
| require(styled_button != nullptr | |||
| && styled_button->buttonOperation == HmiButtonOperation::Toggle | |||
| && styled_button->properties.at(HmiAppearanceProperty::kTextColor) | |||
| == "#E53935" | |||
| && styled_button->properties.at(HmiAppearanceProperty::kFontSize) | |||
| == "18" | |||
| && styled_button->properties.at(HmiAppearanceProperty::kFontBold) | |||
| == "true" | |||
| && styled_button->properties.at(HmiAppearanceProperty::kFontItalic) | |||
| == "true", | |||
| "the property panel must update HMI appearance properties"); | |||
| HmiControl bound_button = *editor_service.findControl(page_id, "button-1"); | |||
| bound_button.binding = RegisterAddress{RegisterArea::M, 0}; | |||
| @@ -1,4 +1,5 @@ | |||
| #include "domain/project_storage.h" | |||
| #include "domain/hmi_model.h" | |||
| #include "infrastructure/json_project_storage.h" | |||
| #include "services/register_comment_service.h" | |||
| #include "services/project_service.h" | |||
| @@ -37,6 +38,10 @@ Project makeExampleProject() | |||
| start_button.binding = RegisterAddress{RegisterArea::M, 0}; | |||
| start_button.buttonOperation = HmiButtonOperation::SetOn; | |||
| start_button.properties.emplace("color", "green"); | |||
| start_button.properties.emplace(HmiAppearanceProperty::kTextColor, "#E53935"); | |||
| start_button.properties.emplace(HmiAppearanceProperty::kFontSize, "18"); | |||
| start_button.properties.emplace(HmiAppearanceProperty::kFontBold, "true"); | |||
| start_button.properties.emplace(HmiAppearanceProperty::kFontItalic, "false"); | |||
| HmiControl running_indicator; | |||
| running_indicator.id = "running-indicator"; | |||
| @@ -487,6 +492,15 @@ void testExampleProjectRoundTrip() | |||
| "HMI button operation must survive round trip"); | |||
| require(project.hmiPages.front().controls.front().properties.at("color") == "green", | |||
| "HMI properties must survive round trip"); | |||
| require(project.hmiPages.front().controls.front().properties.at( | |||
| HmiAppearanceProperty::kTextColor) == "#E53935" | |||
| && project.hmiPages.front().controls.front().properties.at( | |||
| HmiAppearanceProperty::kFontSize) == "18" | |||
| && project.hmiPages.front().controls.front().properties.at( | |||
| HmiAppearanceProperty::kFontBold) == "true" | |||
| && project.hmiPages.front().controls.front().properties.at( | |||
| HmiAppearanceProperty::kFontItalic) == "false", | |||
| "HMI appearance properties must survive round trip"); | |||
| require(project.hmiPages.front().controls.at(1).type == HmiControlType::Indicator, | |||
| "indicator control type must survive round trip"); | |||
| require(project.hmiPages.front().controls.at(2).binding->area() == RegisterArea::D, | |||