diff --git a/README.md b/README.md index db31fb8..69f780e 100644 --- a/README.md +++ b/README.md @@ -199,7 +199,7 @@ The full illustrated guide is on the [website](https://modusignal.cn/pyqt-ui-des 1. **Design the UI**: drag widgets from the widget panel on the left onto the canvas, and set their properties in the property editor on the right. 2. **Bind data**: select a display widget (numeric display, LED, progress bar and so on) and set its binding key (绑定键, the field name in the device's data) and parse mode (解析模式: key-value matching, JSON path, HEX byte extraction or Modbus). -3. **Configure commands**: select an interactive widget (button, switch, slider and so on) and set the send mode (text, HEX or Modbus), the command, and optionally a `{value}` command template. +3. **Configure commands**: select an interactive widget (button, switch, slider and so on) and set the send mode (text, HEX, JSON or Modbus), the command, and optionally a `{value}` command template. 4. **Preview**: switch to the Preview page (预览运行) and click Run preview (运行预览), then connect to the device with a serial, TCP, UDP or MQTT config card to test it live. 5. **Export and package**: the shortcut for exporting code is `Ctrl+Shift+E` on Windows and `⌘⇧E` on a Mac, or click Export code (导出代码) in the navigation bar. On the Preview page, click Package (一键打包) and choose a folder; it exports, compiles with Nuitka and zips the result automatically. Packaging needs a C/C++ compiler on the machine: the Xcode Command Line Tools on macOS, and on Windows a compiler that Nuitka supports (MSVC for Python 3.13+). diff --git a/README_zh.md b/README_zh.md index f01e842..8837334 100644 --- a/README_zh.md +++ b/README_zh.md @@ -197,7 +197,7 @@ https://raw.githubusercontent.com/txp666/PyQt-UI-Designer/main/docs/uidp-format. 1. **设计界面**:从左侧控件面板把控件拖到画布上,在右侧属性编辑器里配置属性。 2. **绑定数据**:选中显示类控件(数值显示、LED、进度条等),设置"绑定键"(设备数据中的字段名)和"解析模式"(键值匹配、JSON 路径、HEX 字节提取或 Modbus)。 -3. **配置命令**:选中交互类控件(按钮、开关、滑块等),设置发送模式(文本、HEX 或 Modbus)、命令内容,以及可选的 `{value}` 命令模板。 +3. **配置命令**:选中交互类控件(按钮、开关、滑块等),设置发送模式(文本、HEX、JSON 或 Modbus)、命令内容,以及可选的 `{value}` 命令模板。 4. **预览运行**:切换到"预览运行"页面,点击"运行预览",用串口、TCP、UDP 或 MQTT 配置卡片连接设备后即可实时测试。 5. **导出与打包**:导出代码的快捷键在 Windows 上是 `Ctrl+Shift+E`,在 Mac 上是 `⌘⇧E`,也可以点击导航栏的"导出代码";在预览页面点击"一键打包",选择目录后会自动导出、用 Nuitka 编译并压缩。打包需要本机有 C/C++ 编译器:macOS 使用 Xcode Command Line Tools,Windows 使用 Nuitka 支持的编译器(Python 3.13+ 使用 MSVC)。 diff --git a/app/common/runtime_sources.py b/app/common/runtime_sources.py index 4205073..f259989 100644 --- a/app/common/runtime_sources.py +++ b/app/common/runtime_sources.py @@ -4,7 +4,7 @@ RUNTIME_FILES = ("theme_runtime.py", "protocol_parser.py", "serial_runtime.py", "tcp_runtime.py", "udp_runtime.py", "mqtt_runtime.py", "tls_runtime.py", "comm_runtime.py", - "chart_history.py", "chart_runtime.py", "gauge_runtime.py", "table_runtime.py", + "chart_history.py", "chart_runtime.py", "gauge_runtime.py", "image_runtime.py", "table_runtime.py", "log_runtime.py", "serial_card.py", "link_polling.py", "link_card.py", "tcp_card.py", "udp_card.py", "mqtt_card.py", "modbus_master.py", "modbus_poller.py", "modbus_slave.py", "slave_runtime.py", "slave_table.py", diff --git a/app/core/code_generator.py b/app/core/code_generator.py index 5b36055..52f07b3 100644 --- a/app/core/code_generator.py +++ b/app/core/code_generator.py @@ -480,6 +480,15 @@ def _send_modbus(self, slave, func, address, value=1, data_type="uint16", reg_or except ValueError as exc: self._command_error(exc) + def _send_json_command(self, value, template="{value}", newline="无"): # i18n: value + if hasattr(self, '_comm_hub') and self._comm_hub.is_connected: + try: + frame = json_command({"commandTemplate": template, "commandJsonNewline": newline}, value) + except ValueError as exc: + self._command_error(exc) + return + self._comm_hub.send(frame) + def _send_packed_value(self, value, fmt, cast=int, tpl="{value}", header="", tail=""): if hasattr(self, '_comm_hub') and self._comm_hub.is_connected: try: @@ -604,6 +613,9 @@ def _gen_command_connection(wid: str, wtype: str, props: dict) -> list: def _tpl_send(val_expr: str) -> str: """Generate a template-aware send expression.""" + if mode == "JSON": + json_newline = props.get("commandJsonNewline", "无") # i18n: value + return f"self._send_json_command(str({val_expr}), {tpl_repr}, {json_newline!r})" if mode == "HEX" and is_struct: fmt = _FMT_MAP[dtype] bo = ">" if border == "大端(BE)" else "<" # i18n: value @@ -838,12 +850,15 @@ def _widget_create_code(wid: str, wtype: str, props: dict, parent_var: str = "se # Display / Composite widgets if wtype == "ImageDisplay": - img = props.get("imagePath", "") - line = f"self.widgets[{wid!r}] = ImageDisplay({pv})" - if img: - line += (f"; self.widgets[{wid!r}].set_image_path(" - f"str(Path(__file__).resolve().parent / {img!r}))") - return line + from app.core.image_runtime import ImageDisplay + code = [f"self.widgets[{wid!r}] = ImageDisplay({pv})"] + for prop, method, default in ImageDisplay.PROPERTY_SETTERS: + value = props.get(prop, default) + if value != default: + argument = (f"str(Path(__file__).resolve().parent / {value!r})" if prop == "imagePath" + else repr(value)) + code.append(f"self.widgets[{wid!r}].{method}({argument})") + return "; ".join(code) if wtype == "ProgressRingDisplay": label = p("label", "进度") # i18n: value return (f"self.widgets[{wid!r}] = ProgressRingDisplay({pv}); " @@ -1449,57 +1464,8 @@ def _data_table_class() -> str: def _image_display_class() -> str: - return ''' -from PyQt5.QtCore import QSize -from PyQt5.QtGui import QImage -from PyQt5.QtWidgets import QLayout -from qfluentwidgets import ImageLabel - -class ImageDisplay(QWidget): - def __init__(self, parent=None): - super().__init__(parent) - self._path = "" - self._image = QImage() - self._label = ImageLabel(self); self._label.hide() - self._placeholder = CaptionLabel(tr("未选择图片"), self) - self._placeholder.setTextColor(QColor("#606060"), QColor("#b3b3b3")) - self._placeholder.setAlignment(Qt.AlignCenter) - lo = QVBoxLayout(self); lo.setContentsMargins(0,0,0,0) - lo.setSizeConstraint(QLayout.SetNoConstraint) # the picture's size never pins the minimum - lo.addWidget(self._label, 0, Qt.AlignCenter); lo.addWidget(self._placeholder, 0, Qt.AlignCenter) - self._theme_connections = SignalConnections(self) - self._theme_connections.connect(qconfig.themeChangedFinished, self.update) - def set_image_path(self, path): - self._path = path - self._image = QImage(path) if path else QImage() - if self._image.isNull(): - self._label.hide() - self._placeholder.setText(tr("无法加载图片") if path else tr("未选择图片")) - self._placeholder.show() - else: - self._placeholder.hide() - self._label.setImage(self._image) - self._fit_image() - self._label.show() - self.update() - def _fit_image(self): - if not self._image.isNull(): - self._label.setScaledSize(self._image.size().scaled(self.size(), Qt.KeepAspectRatio)) - def sizeHint(self): return QSize(200, 150) - def minimumSizeHint(self): return QSize(1, 1) - def resizeEvent(self, event): - super().resizeEvent(event) - self._fit_image() - def paintEvent(self, event): - if not self._image.isNull(): - return - dark = isDarkTheme() - painter = QPainter(self) - painter.setRenderHint(QPainter.Antialiasing) - painter.setPen(QPen(QColor("#606060" if dark else "#c8c8c8"), 1, Qt.DashLine)) - painter.setBrush(QColor("#292929" if dark else "#f5f5f5")) - painter.drawRoundedRect(QRectF(self.rect()).adjusted(0.5, 0.5, -0.5, -0.5), 6, 6) -''' + return _runtime("image_runtime.py").replace( + "from .signal_lifetime import SignalConnections", "") def _progress_ring_display_class() -> str: diff --git a/app/core/command_runtime.py b/app/core/command_runtime.py index e98f4fb..10a901a 100644 --- a/app/core/command_runtime.py +++ b/app/core/command_runtime.py @@ -1,4 +1,5 @@ """Command encoders shared by preview and standalone applications.""" +import json import math import struct @@ -24,6 +25,97 @@ def command_hex_frame(command, header="", tail=""): return _command_hex(header) + _command_hex(command) + _command_hex(tail) +_JSON_NEWLINES = {"无": "", "\\r\\n": "\r\n", "\\n": "\n", "\\r": "\r"} # i18n: value + + +def _reject_constant(name): + raise ValueError(name) + + +_JSON_MAX_DEPTH = 512 + + +def _json_depth_ok(text): + """No more than _JSON_MAX_DEPTH objects and arrays nested in each other (outside strings), as in + FluentHMI Studio; deeper text is not JSON, so json.loads never runs out of stack.""" + depth, in_str, esc = 0, False, False + for ch in text: + if in_str: + if esc: + esc = False + elif ch == "\\": + esc = True + elif ch == '"': + in_str = False + elif ch == '"': + in_str = True + elif ch in "[{": + depth += 1 + if depth > _JSON_MAX_DEPTH: + return False + elif ch in "]}": + depth -= 1 + return True + + +def _is_json(text): + """Exactly one JSON value under RFC 8259 (NaN and Infinity are not JSON), surrounding whitespace allowed.""" + if not _json_depth_ok(text): + return False + try: + json.loads(text, parse_constant=_reject_constant) + except ValueError: + return False + return True + + +def _json_fill(text, value_json, placeholders=True): + """One pass over ``text``: drop JSON whitespace outside strings and, with ``placeholders``, replace each + {value} outside strings by ``value_json``; None when a {value} sits inside a string literal.""" + out, i, in_str, esc = [], 0, False, False + while i < len(text): + ch = text[i] + if in_str: + if placeholders and text.startswith("{value}", i): + return None + out.append(ch) + if esc: + esc = False + elif ch == "\\": + esc = True + elif ch == '"': + in_str = False + i += 1 + elif placeholders and text.startswith("{value}", i): + out.append(value_json) + i += len("{value}") + else: + if ch not in " \t\n\r": + out.append(ch) + if ch == '"': + in_str = True + i += 1 + return "".join(out) + + +def json_command(props, value): + """The bytes a control in JSON mode sends: ``commandTemplate`` (blank = {value}) with each {value} + outside strings replaced by the value (used compacted when it is one JSON value, else as a JSON + string), whitespace outside strings removed, then the ``commandJsonNewline`` line ending. + A template that is not one JSON value with every {value} as null raises ValueError.""" + template = props.get("commandTemplate", "{value}") + effective = template if template.strip() else "{value}" + shape = _json_fill(effective, "null") + if shape is None or not _is_json(shape): + raise ValueError(tr("JSON 模板无效: %1", template)) + value = str(value) + value_json = (_json_fill(value, "", placeholders=False) if _is_json(value) + else json.dumps(value, ensure_ascii=False)) + document = _json_fill(effective, value_json) + newline = _JSON_NEWLINES.get(props.get("commandJsonNewline", "无"), "") # i18n: value + return (document + newline).encode("utf-8") + + def command_packed_frame(value, fmt, cast=int, template="{value}", header="", tail=""): try: number = cast(value) diff --git a/app/core/image_runtime.py b/app/core/image_runtime.py new file mode 100644 index 0000000..c3c1ee7 --- /dev/null +++ b/app/core/image_runtime.py @@ -0,0 +1,119 @@ +"""Image display shared by the designer, the preview and exported applications.""" +import math + +from PyQt5.QtCore import Qt, QPointF, QRectF, QSize +from PyQt5.QtGui import QColor, QImage, QPainter, QPen, QTransform +from PyQt5.QtWidgets import QWidget, QVBoxLayout +from qfluentwidgets import CaptionLabel, isDarkTheme, qconfig + +from .signal_lifetime import SignalConnections +from .i18n import tr + +SCALE_FIT, SCALE_STRETCH, SCALE_ORIGINAL = "适应", "拉伸", "原始大小" # i18n: value +SCALE_MODES = (SCALE_FIT, SCALE_STRETCH, SCALE_ORIGINAL) + + +def image_transform(box_w, box_h, img_w, img_h, rotation, mirror_h, mirror_v, scale_mode) -> QTransform: + """Where an img_w x img_h picture goes in a box_w x box_h widget: picture pixel coordinates to widget + coordinates. Mirrored in its own frame, the picture turns ``rotation`` degrees clockwise about the box + centre and is scaled so that all of it stays inside the box, which never changes: SCALE_FIT as large as + fits, SCALE_STRETCH from the box's size (swapped near a quarter turn, so it still fills the box), + SCALE_ORIGINAL at its pixel size, or smaller when that does not fit.""" + theta = int(rotation) % 360 + if theta % 90 == 0: # exact for quarter turns + cos, sin = ((1, 0), (0, 1), (-1, 0), (0, -1))[theta // 90] + else: + cos, sin = math.cos(math.radians(theta)), math.sin(math.radians(theta)) + c, n = abs(cos), abs(sin) + if scale_mode == SCALE_STRETCH: + bw, bh = (box_h, box_w) if 45 < theta % 180 < 135 else (box_w, box_h) + else: + bw, bh = img_w, img_h + k = min(box_w / (bw * c + bh * n), box_h / (bw * n + bh * c)) # fits the turned rect's bounding box + if scale_mode == SCALE_ORIGINAL: + k = min(1.0, k) + sx = (-k if mirror_h else k) * bw / img_w + sy = (-k if mirror_v else k) * bh / img_h + # Qt applies the last step first: centre the picture on the origin, scale and mirror it, then turn it + # clockwise on screen and move it to the box centre. + transform = QTransform(cos, sin, -sin, cos, box_w / 2, box_h / 2) + transform.scale(sx, sy) + transform.translate(-img_w / 2, -img_h / 2) + return transform + + +class ImageDisplay(QWidget): + """A static picture placed by image_transform, or a placeholder card when there is none.""" + + # Each .uidp property, the setter that applies it and its default. + PROPERTY_SETTERS = ( + ("imagePath", "set_image_path", ""), + ("scaleMode", "set_scale_mode", SCALE_FIT), + ("rotation", "set_rotation", 0), + ("mirrorH", "set_mirror_h", False), + ("mirrorV", "set_mirror_v", False), + ) + + def __init__(self, parent=None): + super().__init__(parent) + self._path = "" + self._image = QImage() + self._scale_mode = SCALE_FIT + self._rotation = 0 + self._mirror_h = False + self._mirror_v = False + self._placeholder = CaptionLabel(tr("未选择图片"), self) + self._placeholder.setTextColor(QColor("#606060"), QColor("#b3b3b3")) + self._placeholder.setAlignment(Qt.AlignCenter) + layout = QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.addWidget(self._placeholder, 0, Qt.AlignCenter) + self._theme_connections = SignalConnections(self) + self._theme_connections.connect(qconfig.themeChangedFinished, self.update) + + def set_image_path(self, path): + self._path = path + self._image = QImage(path) if path else QImage() + self._placeholder.setText(tr("无法加载图片") if path else tr("未选择图片")) + self._placeholder.setVisible(self._image.isNull()) + self.update() + + def set_scale_mode(self, mode): + self._scale_mode = mode + self.update() + + def set_rotation(self, degrees): + self._rotation = int(degrees) + self.update() + + def set_mirror_h(self, on): + self._mirror_h = bool(on) + self.update() + + def set_mirror_v(self, on): + self._mirror_v = bool(on) + self.update() + + def sizeHint(self): + return QSize(200, 150) + + def minimumSizeHint(self): + return QSize(1, 1) + + def paintEvent(self, event): + painter = QPainter(self) + painter.setRenderHint(QPainter.Antialiasing) + if self._image.isNull(): + dark = isDarkTheme() + painter.setPen(QPen(QColor("#606060" if dark else "#c8c8c8"), 1, Qt.DashLine)) + painter.setBrush(QColor("#292929" if dark else "#f5f5f5")) + painter.drawRoundedRect(QRectF(self.rect()).adjusted(0.5, 0.5, -0.5, -0.5), 6, 6) + return + if self.width() <= 0 or self.height() <= 0: + return + # Drawn from the original picture through the transform, so it stays sharp on a 2x screen and zoomed. + painter.setRenderHint(QPainter.SmoothPixmapTransform) + painter.setTransform(image_transform( + self.width(), self.height(), self._image.width(), self._image.height(), + self._rotation, self._mirror_h, self._mirror_v, self._scale_mode)) + painter.drawImage(QPointF(0, 0), self._image) diff --git a/app/designer/property_editor.py b/app/designer/property_editor.py index b298545..80c80b4 100644 --- a/app/designer/property_editor.py +++ b/app/designer/property_editor.py @@ -71,10 +71,11 @@ def _value_combo(values, current=None, on_change=None): "文本": {"command", "onCommand", "offCommand", "commandNewline", "commandTemplate"}, # i18n: value "HEX": {"command", "onCommand", "offCommand", "commandTemplate", "frameHeader", "frameTail", "dataType", "byteOrder"}, + "JSON": {"command", "onCommand", "offCommand", "commandTemplate", "commandJsonNewline"}, "Modbus": {"mbSlave", "mbFunc", "mbAddress", "mbValue", "mbDataType", "mbRegOrder"}, } _MODE_FIELDS = { - "command", "onCommand", "offCommand", "commandNewline", "commandTemplate", + "command", "onCommand", "offCommand", "commandNewline", "commandTemplate", "commandJsonNewline", "frameHeader", "frameTail", "dataType", "byteOrder", "mbSlave", "mbFunc", "mbAddress", "mbValue", "mbDataType", "mbRegOrder", } diff --git a/app/resource/i18n/en.json b/app/resource/i18n/en.json index 670c1a9..4104d76 100644 --- a/app/resource/i18n/en.json +++ b/app/resource/i18n/en.json @@ -38,6 +38,7 @@ "HEX模式": "HEX mode", "JSON 格式错误: %1": "Invalid JSON: %1", "JSON 格式错误: %1(第 %2 个字符)": "Invalid JSON: %1 (character %2)", + "JSON 模板无效: %1": "Invalid JSON template: %1", "JSON路径": "JSON path", "LED指示灯": "LED indicator", "MQTT 参数无效: %1": "Invalid MQTT settings: %1", @@ -186,6 +187,7 @@ "卡片 %1 的 parserConfig 必须是对象": "parserConfig of card %1 must be an object", "卡片 %1: %2": "Card %1: %2", "卡片容器": "Card container", + "原始大小": "Original size", "发": "Tx", "发布主题": "Publish topic", "发布信息不是有效的 JSON: %1": "Release info isn't valid JSON: %1", @@ -237,6 +239,7 @@ "垂直": "Vertical", "垂直居中": "Center vertically", "垂直布局": "Vertical layout", + "垂直镜像": "Mirror vertically", "基础控件": "Basic", "复制": "Copy", "外观": "Appearance", @@ -345,6 +348,7 @@ "打开输出目录": "Open output folder", "打开项目": "Open project", "找不到 FluentHMI Runtime,请确认它与 Studio 一同安装": "FluentHMI Runtime not found. Make sure it's installed with Studio", + "拉伸": "Stretch", "按下通知": "Notify on press", "按钮": "Button", "按钮已按下": "Button pressed", @@ -398,6 +402,7 @@ "新版本已就绪。重启 Studio 即完成更新;也可以稍后,退出时会自动更新。": "The new version is ready. Restart Studio to finish updating, or leave it for later: it updates when you quit.", "新项目的默认串口波特率": "Default serial baud rate for new projects", "方向": "Direction", + "旋转(°)": "Rotation (°)", "无": "None", "无可用串口": "No serial ports", "无响应": "No reply", @@ -523,6 +528,7 @@ "水平": "Horizontal", "水平居中": "Center horizontally", "水平布局": "Horizontal layout", + "水平镜像": "Mirror horizontally", "没有 Modbus 绑定": "No Modbus bindings", "没有从站链路卡片": "No link card in the slave role", "没有写入 %1 的权限": "No permission to write to %1", @@ -587,6 +593,7 @@ "编码": "Encoding", "编码只能是 utf-8、gbk 或 ascii": "Encoding must be utf-8, gbk or ascii", "缩小": "Zoom out", + "缩放方式": "Scale mode", "缺少 ',' 或 ']'": "Missing ',' or ']'", "缺少 ',' 或 '}'": "Missing ',' or '}'", "缺少 ':'": "Missing ':'", @@ -664,6 +671,7 @@ "连接超时": "Connection timed out", "连接配置": "Connection config", "退出 Studio 时会自动完成更新": "The update finishes when you quit Studio", + "适应": "Fit", "适应窗口": "Fit to window", "选中发送": "Send on select", "选择 Fluent 控件的强调色": "Choose the accent color of Fluent controls", diff --git a/app/view/preview_interface.py b/app/view/preview_interface.py index 75121a2..ec5a0b9 100644 --- a/app/view/preview_interface.py +++ b/app/view/preview_interface.py @@ -29,7 +29,7 @@ from app.core.signal_lifetime import SignalConnections from app.core.slider_release import SendOnRelease from app.core.command_runtime import ( - MODBUS_FUNCTIONS, MODBUS_ORDERS, command_hex_frame, command_packed_frame, modbus_request, + MODBUS_FUNCTIONS, MODBUS_ORDERS, command_hex_frame, command_packed_frame, json_command, modbus_request, send_modbus_command, slave_command_write, slave_control_write, slave_devices, ) @@ -238,11 +238,19 @@ def _apply_template(self, tpl: str, value: str, mode: str, newline: str, props: """Apply template and send via the appropriate mode. For HEX mode with a struct data type, packs the value as binary first, - then applies the template to the hex representation. + then applies the template to the hex representation. JSON mode sends json_command's bytes. """ if mode == "Modbus": self._send_modbus_frame(props, value) return + if mode == "JSON": + try: + frame = json_command(props, value) + except ValueError as exc: + PreviewCanvas._command_error(exc) + return + PreviewCanvas._send_serial_bytes(frame) + return if mode == "HEX": dt = props.get("dataType", "HEX字符串") # i18n: value fmt_info = PreviewCanvas._STRUCT_FMT.get(dt) diff --git a/app/widgets/basic.py b/app/widgets/basic.py index a2d1498..e9d432d 100644 --- a/app/widgets/basic.py +++ b/app/widgets/basic.py @@ -34,9 +34,11 @@ def _modbus_functions(*codes): PropDef("text", N_("文本"), PropType.STRING, N_("按钮")), PropDef("enabled", N_("启用"), PropType.BOOL, True), PropDef("commandMode", N_("发送模式"), PropType.ENUM, "文本", # i18n: value - options=["文本", "HEX", "Modbus"]), # i18n: value + options=["文本", "HEX", "JSON", "Modbus"]), # i18n: value PropDef("command", N_("发送内容"), PropType.COMMAND, ""), PropDef("commandTemplate", N_("发送模板"), PropType.STRING, "{value}"), + PropDef("commandJsonNewline", N_("换行符"), PropType.ENUM, "无", # i18n: value + options=["无", "\\r\\n", "\\n", "\\r"]), # i18n: value PropDef("frameHeader", N_("帧头(HEX)"), PropType.STRING, ""), PropDef("frameTail", N_("帧尾(HEX)"), PropType.STRING, ""), PropDef("dataType", N_("数据类型"), PropType.ENUM, "HEX字符串", # i18n: value @@ -97,8 +99,10 @@ def _modbus_functions(*codes): PropDef("readOnly", N_("只读"), PropType.BOOL, False), PropDef("sendOnEnter", N_("回车发送"), PropType.BOOL, False), PropDef("commandMode", N_("发送模式"), PropType.ENUM, "文本", # i18n: value - options=["文本", "HEX", "Modbus"]), # i18n: value + options=["文本", "HEX", "JSON", "Modbus"]), # i18n: value PropDef("commandTemplate", N_("发送模板"), PropType.STRING, "{value}"), + PropDef("commandJsonNewline", N_("换行符"), PropType.ENUM, "无", # i18n: value + options=["无", "\\r\\n", "\\n", "\\r"]), # i18n: value PropDef("frameHeader", N_("帧头(HEX)"), PropType.STRING, ""), PropDef("frameTail", N_("帧尾(HEX)"), PropType.STRING, ""), PropDef("dataType", N_("数据类型"), PropType.ENUM, "HEX字符串", # i18n: value @@ -131,8 +135,10 @@ def _modbus_functions(*codes): PropDef("sendOnSelect", N_("选中发送"), PropType.BOOL, False), PropDef("commands", N_("发送命令表"), PropType.TEXT, "",), PropDef("commandMode", N_("发送模式"), PropType.ENUM, "文本", # i18n: value - options=["文本", "HEX", "Modbus"]), # i18n: value + options=["文本", "HEX", "JSON", "Modbus"]), # i18n: value PropDef("commandTemplate", N_("发送模板"), PropType.STRING, "{value}"), + PropDef("commandJsonNewline", N_("换行符"), PropType.ENUM, "无", # i18n: value + options=["无", "\\r\\n", "\\n", "\\r"]), # i18n: value PropDef("frameHeader", N_("帧头(HEX)"), PropType.STRING, ""), PropDef("frameTail", N_("帧尾(HEX)"), PropType.STRING, ""), PropDef("dataType", N_("数据类型"), PropType.ENUM, "HEX字符串", # i18n: value @@ -165,10 +171,12 @@ def _modbus_functions(*codes): PropDef("label", N_("标签"), PropType.STRING, N_("开关")), PropDef("checked", N_("默认开启"), PropType.BOOL, False), PropDef("commandMode", N_("发送模式"), PropType.ENUM, "文本", # i18n: value - options=["文本", "HEX", "Modbus"]), # i18n: value + options=["文本", "HEX", "JSON", "Modbus"]), # i18n: value PropDef("onCommand", N_("开启命令"), PropType.COMMAND, ""), PropDef("offCommand", N_("关闭命令"), PropType.COMMAND, ""), PropDef("commandTemplate", N_("发送模板"), PropType.STRING, "{value}"), + PropDef("commandJsonNewline", N_("换行符"), PropType.ENUM, "无", # i18n: value + options=["无", "\\r\\n", "\\n", "\\r"]), # i18n: value PropDef("frameHeader", N_("帧头(HEX)"), PropType.STRING, ""), PropDef("frameTail", N_("帧尾(HEX)"), PropType.STRING, ""), PropDef("dataType", N_("数据类型"), PropType.ENUM, "HEX字符串", # i18n: value @@ -202,8 +210,10 @@ def _modbus_functions(*codes): PropDef("value", N_("默认值"), PropType.INT, 50, min_val=-99999, max_val=99999), PropDef("sendOnChange", N_("变化发送"), PropType.BOOL, False), PropDef("commandMode", N_("发送模式"), PropType.ENUM, "文本", # i18n: value - options=["文本", "HEX", "Modbus"]), # i18n: value + options=["文本", "HEX", "JSON", "Modbus"]), # i18n: value PropDef("commandTemplate", N_("发送模板"), PropType.STRING, "{value}"), + PropDef("commandJsonNewline", N_("换行符"), PropType.ENUM, "无", # i18n: value + options=["无", "\\r\\n", "\\n", "\\r"]), # i18n: value PropDef("frameHeader", N_("帧头(HEX)"), PropType.STRING, ""), PropDef("frameTail", N_("帧尾(HEX)"), PropType.STRING, ""), PropDef("commandNewline", N_("换行符"), PropType.ENUM, "\\r\\n", @@ -237,8 +247,10 @@ def _modbus_functions(*codes): PropDef("suffix", N_("后缀"), PropType.STRING, ""), PropDef("sendOnChange", N_("变化发送"), PropType.BOOL, False), PropDef("commandMode", N_("发送模式"), PropType.ENUM, "文本", # i18n: value - options=["文本", "HEX", "Modbus"]), # i18n: value + options=["文本", "HEX", "JSON", "Modbus"]), # i18n: value PropDef("commandTemplate", N_("发送模板"), PropType.STRING, "{value}"), + PropDef("commandJsonNewline", N_("换行符"), PropType.ENUM, "无", # i18n: value + options=["无", "\\r\\n", "\\n", "\\r"]), # i18n: value PropDef("frameHeader", N_("帧头(HEX)"), PropType.STRING, ""), PropDef("frameTail", N_("帧尾(HEX)"), PropType.STRING, ""), PropDef("commandNewline", N_("换行符"), PropType.ENUM, "\\r\\n", diff --git a/app/widgets/display.py b/app/widgets/display.py index df82011..14d3ce9 100644 --- a/app/widgets/display.py +++ b/app/widgets/display.py @@ -1,17 +1,16 @@ # coding: utf-8 """Display widgets: LED indicator, numeric display, progress bar, progress ring, image display.""" from pathlib import Path -from PyQt5.QtCore import Qt, QRectF, QSize -from PyQt5.QtGui import QPainter, QColor, QFont, QImage, QPen -from PyQt5.QtWidgets import QWidget, QVBoxLayout, QLayout -from qfluentwidgets import ( - ProgressBar, ProgressRing, BodyLabel, CaptionLabel, ImageLabel, isDarkTheme, qconfig, themeColor, -) +from PyQt5.QtCore import Qt +from PyQt5.QtGui import QPainter, QColor, QFont +from PyQt5.QtWidgets import QWidget, QVBoxLayout +from qfluentwidgets import ProgressBar, ProgressRing, BodyLabel, CaptionLabel, isDarkTheme, qconfig, themeColor from app.widgets.base import WidgetMeta, WidgetCategory, PropDef, PropType from app.core.i18n import tr, N_ from app.core.widget_registry import registry from app.core.signal_lifetime import SignalConnections +from app.core.image_runtime import ImageDisplay, SCALE_FIT, SCALE_MODES # shared with exports def resolved_image_properties(properties, base_dir=None): @@ -170,67 +169,6 @@ def set_label(self, t): self._label.setText(t) def set_ring_size(self, s): self._ring.setFixedSize(s, s) -class ImageDisplay(QWidget): - """Static image display widget: the picture aspect-fit and centred, or a placeholder.""" - - def __init__(self, parent=None): - super().__init__(parent) - self._path = "" - self._image = QImage() - self._label = ImageLabel(self) - self._label.hide() - self._placeholder = CaptionLabel(tr("未选择图片"), self) - self._placeholder.setTextColor(QColor("#606060"), QColor("#b3b3b3")) - self._placeholder.setAlignment(Qt.AlignCenter) - layout = QVBoxLayout(self) - layout.setContentsMargins(0, 0, 0, 0) - # The picture's fixed size must never become the widget's minimum. - layout.setSizeConstraint(QLayout.SetNoConstraint) - layout.addWidget(self._label, 0, Qt.AlignCenter) - layout.addWidget(self._placeholder, 0, Qt.AlignCenter) - self._theme_connections = SignalConnections(self) - self._theme_connections.connect(qconfig.themeChangedFinished, self.update) - - def set_image_path(self, path: str): - self._path = path - self._image = QImage(path) if path else QImage() - if self._image.isNull(): - self._label.hide() - self._placeholder.setText(tr("无法加载图片") if path else tr("未选择图片")) - self._placeholder.show() - else: - self._placeholder.hide() - self._label.setImage(self._image) - self._fit_image() - self._label.show() - self.update() - - def _fit_image(self): - if not self._image.isNull(): - self._label.setScaledSize(self._image.size().scaled(self.size(), Qt.KeepAspectRatio)) - - def sizeHint(self): - return QSize(200, 150) - - def minimumSizeHint(self): - return QSize(1, 1) - - def resizeEvent(self, event): - super().resizeEvent(event) - self._fit_image() - - def paintEvent(self, event): - if not self._image.isNull(): - return - dark = isDarkTheme() - painter = QPainter(self) - painter.setRenderHint(QPainter.Antialiasing) - pen = QPen(QColor("#606060" if dark else "#c8c8c8"), 1, Qt.DashLine) - painter.setPen(pen) - painter.setBrush(QColor("#292929" if dark else "#f5f5f5")) - painter.drawRoundedRect(QRectF(self.rect()).adjusted(0.5, 0.5, -0.5, -0.5), 6, 6) - - # --------------------------------------------------------------------------- # Widget metadata # --------------------------------------------------------------------------- @@ -304,6 +242,14 @@ def paintEvent(self, event): default_width=200, default_height=150, properties=[ PropDef("imagePath", N_("图片路径"), PropType.STRING, ""), + PropDef("scaleMode", N_("缩放方式"), PropType.ENUM, SCALE_FIT, options=list(SCALE_MODES), + description="适应:保持比例放到最大;拉伸:铺满控件框;原始大小:按图片的像素大小,放不下时缩小"), # i18n: ignore + PropDef("rotation", N_("旋转(°)"), PropType.INT, 0, min_val=0, max_val=359, + description="顺时针,绕中心转;控件框不动,整张图片缩小到放得进框里"), # i18n: ignore + PropDef("mirrorH", N_("水平镜像"), PropType.BOOL, False, + description="左右翻转,在旋转之前"), # i18n: ignore + PropDef("mirrorV", N_("垂直镜像"), PropType.BOOL, False, + description="上下翻转,在旋转之前"), # i18n: ignore ], ) @@ -351,7 +297,8 @@ def create_display_widget(type_name: str, props: dict, parent=None) -> QWidget: if type_name == "ImageDisplay": w = ImageDisplay(parent) - w.set_image_path(props.get("imagePath", "")) + for prop, method, default in w.PROPERTY_SETTERS: + getattr(w, method)(props.get(prop, default)) return w return None @@ -391,5 +338,6 @@ def apply_display_property(widget, type_name: str, prop_name: str, value): widget.set_maximum(value) elif type_name == "ImageDisplay": - if prop_name == "imagePath": - widget.set_image_path(value) + for prop, method, _default in widget.PROPERTY_SETTERS: + if prop == prop_name: + getattr(widget, method)(value) diff --git a/demo/ui.py b/demo/ui.py index 8bc2847..edb44b5 100644 --- a/demo/ui.py +++ b/demo/ui.py @@ -142,6 +142,7 @@ def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) """Command encoders shared by preview and standalone applications.""" +import json import math import struct @@ -167,6 +168,97 @@ def command_hex_frame(command, header="", tail=""): return _command_hex(header) + _command_hex(command) + _command_hex(tail) +_JSON_NEWLINES = {"无": "", "\\r\\n": "\r\n", "\\n": "\n", "\\r": "\r"} # i18n: value + + +def _reject_constant(name): + raise ValueError(name) + + +_JSON_MAX_DEPTH = 512 + + +def _json_depth_ok(text): + """No more than _JSON_MAX_DEPTH objects and arrays nested in each other (outside strings), as in + FluentHMI Studio; deeper text is not JSON, so json.loads never runs out of stack.""" + depth, in_str, esc = 0, False, False + for ch in text: + if in_str: + if esc: + esc = False + elif ch == "\\": + esc = True + elif ch == '"': + in_str = False + elif ch == '"': + in_str = True + elif ch in "[{": + depth += 1 + if depth > _JSON_MAX_DEPTH: + return False + elif ch in "]}": + depth -= 1 + return True + + +def _is_json(text): + """Exactly one JSON value under RFC 8259 (NaN and Infinity are not JSON), surrounding whitespace allowed.""" + if not _json_depth_ok(text): + return False + try: + json.loads(text, parse_constant=_reject_constant) + except ValueError: + return False + return True + + +def _json_fill(text, value_json, placeholders=True): + """One pass over ``text``: drop JSON whitespace outside strings and, with ``placeholders``, replace each + {value} outside strings by ``value_json``; None when a {value} sits inside a string literal.""" + out, i, in_str, esc = [], 0, False, False + while i < len(text): + ch = text[i] + if in_str: + if placeholders and text.startswith("{value}", i): + return None + out.append(ch) + if esc: + esc = False + elif ch == "\\": + esc = True + elif ch == '"': + in_str = False + i += 1 + elif placeholders and text.startswith("{value}", i): + out.append(value_json) + i += len("{value}") + else: + if ch not in " \t\n\r": + out.append(ch) + if ch == '"': + in_str = True + i += 1 + return "".join(out) + + +def json_command(props, value): + """The bytes a control in JSON mode sends: ``commandTemplate`` (blank = {value}) with each {value} + outside strings replaced by the value (used compacted when it is one JSON value, else as a JSON + string), whitespace outside strings removed, then the ``commandJsonNewline`` line ending. + A template that is not one JSON value with every {value} as null raises ValueError.""" + template = props.get("commandTemplate", "{value}") + effective = template if template.strip() else "{value}" + shape = _json_fill(effective, "null") + if shape is None or not _is_json(shape): + raise ValueError(tr("JSON 模板无效: %1", template)) + value = str(value) + value_json = (_json_fill(value, "", placeholders=False) if _is_json(value) + else json.dumps(value, ensure_ascii=False)) + document = _json_fill(effective, value_json) + newline = _JSON_NEWLINES.get(props.get("commandJsonNewline", "无"), "") # i18n: value + return (document + newline).encode("utf-8") + + def command_packed_frame(value, fmt, cast=int, template="{value}", header="", tail=""): try: number = cast(value) @@ -2815,6 +2907,15 @@ def _send_modbus(self, slave, func, address, value=1, data_type="uint16", reg_or except ValueError as exc: self._command_error(exc) + def _send_json_command(self, value, template="{value}", newline="无"): # i18n: value + if hasattr(self, '_comm_hub') and self._comm_hub.is_connected: + try: + frame = json_command({"commandTemplate": template, "commandJsonNewline": newline}, value) + except ValueError as exc: + self._command_error(exc) + return + self._comm_hub.send(frame) + def _send_packed_value(self, value, fmt, cast=int, tpl="{value}", header="", tail=""): if hasattr(self, '_comm_hub') and self._comm_hub.is_connected: try: diff --git a/docs/protocol.md b/docs/protocol.md index 7f27734..1da0f76 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -16,7 +16,8 @@ - [三、下行发送协议(发送)](#三下行发送协议发送) - [1. 文本模式](#1-文本模式) - [2. HEX模式](#2-hex模式) - - [3. Modbus模式](#3-modbus模式)(含[接 PLC:自动轮询](#接-plc自动轮询)) + - [3. JSON模式](#3-json模式) + - [4. Modbus模式](#4-modbus模式)(含[接 PLC:自动轮询](#接-plc自动轮询)) - [四、行协议自动识别规则](#四行协议自动识别规则) - [五、Modbus 帧格式参考](#五modbus-帧格式参考) - [六、传输链路:串口、TCP、UDP、MQTT](#六传输链路串口tcpudpmqtt) @@ -34,7 +35,7 @@ │ │ 解析器输出 ──┤ │ │ │ └─ serialDataReceived ─→ 键值/JSON/MB │ │ │ │ -│ │ ←── 所有已连接的链路 ←── 文本/HEX/Modbus帧 ←── 控件操作 │ +│ │ ←── 所有已连接的链路 ←── 文本/HEX/JSON/Modbus帧 ←── 控件操作 │ └──────────┘ └──────────┘ ``` @@ -729,7 +730,54 @@ byteOrder: 大端(BE) --- -### 3. Modbus模式 +### 3. JSON模式 + +把值填进 JSON 模板,作为一条 JSON 发送。 + +#### 参数 + +| 参数 | 类型 | 说明 | +|------|------|------| +| `command` | string | 发送内容 | +| `commandTemplate` | string | JSON 模板,默认 `{value}`,`{value}` 被替换为值的 JSON | +| `commandJsonNewline` | enum | `无`(默认) / `\\r\\n` / `\\n` / `\\r` | + +- 值是合法 JSON(`1`、`true`、`{"a":1}`)就照用,否则转义成字符串;输入框也一样,输入 `"123"` 才发字符串。 +- 模板须是一个合法 JSON,`{value}` 不能在引号里;没有 `{value}` 就发模板本身。无效时不发,提示 `JSON 模板无效: <模板>`。 +- 字符串以外的空白去掉后发送。 + +#### 示例 + +**开关** + +``` +控件: ToggleSwitch (onCommand: 1, offCommand: 0) +commandTemplate: {"led":{value}} +``` + +打开 → 发送:`{"led":1}`;关闭 → 发送:`{"led":0}` + +**滑块** + +``` +控件: Slider (当前值 75, sendOnChange: true) +commandTemplate: {"cmd":"setSpeed","value":{value}} +``` + +拖动到75 → 发送:`{"cmd":"setSpeed","value":75}` + +**输入框** + +``` +控件: LineEdit (用户输入 say "hi", sendOnEnter: true) +commandTemplate: {"name":{value}} +``` + +回车 → 发送:`{"name":"say \"hi\""}` + +--- + +### 4. Modbus模式 自动构建标准 Modbus 请求帧,并按链路选择帧格式:解析模式为 `modbus_tcp` 的链路收到 Modbus TCP 请求(MBAP 头,事务标识符逐帧递增,无 CRC),解析模式为 `modbus_ascii` 的链路收到 Modbus ASCII 请求(`:`、大写十六进制、LRC、`\r\n`,见[五、Modbus ASCII](#modbus-ascii)),其余链路收到 Modbus RTU 请求(含 CRC16 校验)。控件只配置从站、功能码、地址和值,不需要区分 RTU、TCP 和 ASCII。 @@ -885,7 +933,7 @@ mbDataType: float32 | `第 2 块无响应(从站 1 功能码 3 地址 200)` | 上一轮出问题的块里排在最前的一块超时 | | `第 2 块异常 02 非法数据地址` | 上一轮出问题的块里排在最前的一块收到异常响应 | -**手动读写。** 要手动发一条 Modbus 请求(读一次某个地址、写一个寄存器或线圈),放一个按钮、输入框、开关等控件,把命令模式设成 `Modbus`(见[3. Modbus模式](#3-modbus模式))。这些请求同样经过链路的请求队列,排在读请求前面。 +**手动读写。** 要手动发一条 Modbus 请求(读一次某个地址、写一个寄存器或线圈),放一个按钮、输入框、开关等控件,把命令模式设成 `Modbus`(见[4. Modbus模式](#4-modbus模式))。这些请求同样经过链路的请求队列,排在读请求前面。 例如示例 `10-PLC恒压供水.uidp` 里那些绑定(保持寄存器 100、102 的 float32,104、105,200 的 float32,线圈 0–3),自动规划成三块:`从站 1 功能码 1 地址 0 数量 4`、`从站 1 功能码 3 地址 100 数量 6`、`从站 1 功能码 3 地址 200 数量 2`,每块 500 ms 读一次,一次一条。 diff --git a/docs/uidp-format.md b/docs/uidp-format.md index 34a7d3c..dcacfab 100644 --- a/docs/uidp-format.md +++ b/docs/uidp-format.md @@ -175,6 +175,7 @@ python tools/check_uidp.py motor.uidp - **`"文本"`(默认)**:`commandTemplate` 里的 `{value}` 换成要发的值,末尾加 `commandNewline`。`commandNewline` 可选 `"无"`、`"\\r\\n"`(默认)、`"\\n"`、`"\\r"`。在 JSON 里要写成两个反斜杠,因为这个值是反斜杠加字母的文字。 - **`"HEX"`**:`dataType` 为 `"HEX字符串"` 时,值本身就是 HEX 文本(如 `"01 03 00 00"`);为数值类型时,按 `dataType` 和 `byteOrder` 打包后放进模板。可加 `frameHeader` / `frameTail`。 +- **`"JSON"`**:`commandTemplate` 里的 `{value}` 换成值的 JSON:值本身是合法 JSON(`1`、`true`、`{"a":1}`)就照用,否则当字符串、自动转义,输入框也一样。如 `{"cmd":"setSpeed","value":{value}}` 发 `{"cmd":"setSpeed","value":75}`。整条须是合法 JSON(`{value}` 不能在引号里),去掉空白后发,末尾加 `commandJsonNewline`(默认 `"无"`)。 - **`"Modbus"`**:用 `mbSlave`、`mbFunc`(如 `"06 写单寄存器"`)、`mbAddress` 组请求;连接的链路解析模式是 `modbus_tcp` 时自动用 MBAP 帧,`modbus_ascii` 时用 ASCII 帧,否则用 RTU 帧加 CRC。`mbDataType`(默认 `"uint16"`)和 `mbRegOrder` 决定写入的值怎么放进寄存器:`"06 写单寄存器"` 可用 `"uint16"`、`"int16"`;`"16 写多个寄存器"` 还可用 `"uint32"`、`"int32"`、`"float32"`,32 位类型写两个寄存器。按钮写的值是 `mbValue`,只能是整数(int16、int32 可以是负数);写小数用输入框。 每种控件发什么值: @@ -183,7 +184,7 @@ python tools/check_uidp.py motor.uidp | --- | --- | --- | | `PushButton` | `command` | 点击 | | `ToggleSwitch` | 打开时 `onCommand`,关闭时 `offCommand` | 用户拨动 | -| `Slider`、`SpinBox` | 当前数值 | `sendOnChange` 为 `true` 时数值一变就发;拖动滑块时不发,松手发一次(键盘、滚轮每一步都发);文本和 HEX 模式不打开它就不发 | +| `Slider`、`SpinBox` | 当前数值 | `sendOnChange` 为 `true` 时数值一变就发;拖动滑块时不发,松手发一次(键盘、滚轮每一步都发);Modbus 以外的模式不打开它就不发 | | `LineEdit` | 输入的文字 | `sendOnEnter` 为 `true` 时按回车 | | `ComboBox` | `commands` 里和选项同一行的命令(多行文本,一行对应 `items` 的一项);那一行是空的就发选项文字本身 | `sendOnSelect` 为 `true` 时选中 | diff --git a/docs/uidp-widgets.md b/docs/uidp-widgets.md index 9638bc0..4127349 100644 --- a/docs/uidp-widgets.md +++ b/docs/uidp-widgets.md @@ -12,9 +12,10 @@ - `text`(文本,文本)默认 `"按钮"` - `enabled`(启用,true/false)默认 `true` -- `commandMode`(发送模式,选项)默认 `"文本"`,可选 `"文本"` `"HEX"` `"Modbus"` +- `commandMode`(发送模式,选项)默认 `"文本"`,可选 `"文本"` `"HEX"` `"JSON"` `"Modbus"` - `command`(发送内容,命令)默认 `""` - `commandTemplate`(发送模板,文本)默认 `"{value}"` +- `commandJsonNewline`(换行符,选项)默认 `"无"`,可选 `"无"` `"\\r\\n"` `"\\n"` `"\\r"` - `frameHeader`(帧头(HEX),文本)默认 `""` - `frameTail`(帧尾(HEX),文本)默认 `""` - `dataType`(数据类型,选项)默认 `"HEX字符串"`,可选 `"HEX字符串"` `"uint8"` `"int8"` `"uint16"` `"int16"` `"uint32"` `"int32"` `"float32"` @@ -51,8 +52,9 @@ - `text`(默认值,文本)默认 `""` - `readOnly`(只读,true/false)默认 `false` - `sendOnEnter`(回车发送,true/false)默认 `false` -- `commandMode`(发送模式,选项)默认 `"文本"`,可选 `"文本"` `"HEX"` `"Modbus"` +- `commandMode`(发送模式,选项)默认 `"文本"`,可选 `"文本"` `"HEX"` `"JSON"` `"Modbus"` - `commandTemplate`(发送模板,文本)默认 `"{value}"` +- `commandJsonNewline`(换行符,选项)默认 `"无"`,可选 `"无"` `"\\r\\n"` `"\\n"` `"\\r"` - `frameHeader`(帧头(HEX),文本)默认 `""` - `frameTail`(帧尾(HEX),文本)默认 `""` - `dataType`(数据类型,选项)默认 `"HEX字符串"`,可选 `"HEX字符串"` `"uint8"` `"int8"` `"uint16"` `"int16"` `"uint32"` `"int32"` `"float32"` @@ -72,8 +74,9 @@ - `currentIndex`(默认选项,整数)默认 `0`,范围 0–99 - `sendOnSelect`(选中发送,true/false)默认 `false` - `commands`(发送命令表,多行文本)默认 `""` -- `commandMode`(发送模式,选项)默认 `"文本"`,可选 `"文本"` `"HEX"` `"Modbus"` +- `commandMode`(发送模式,选项)默认 `"文本"`,可选 `"文本"` `"HEX"` `"JSON"` `"Modbus"` - `commandTemplate`(发送模板,文本)默认 `"{value}"` +- `commandJsonNewline`(换行符,选项)默认 `"无"`,可选 `"无"` `"\\r\\n"` `"\\n"` `"\\r"` - `frameHeader`(帧头(HEX),文本)默认 `""` - `frameTail`(帧尾(HEX),文本)默认 `""` - `dataType`(数据类型,选项)默认 `"HEX字符串"`,可选 `"HEX字符串"` `"uint8"` `"int8"` `"uint16"` `"int16"` `"uint32"` `"int32"` `"float32"` @@ -92,10 +95,11 @@ - `label`(标签,文本)默认 `"开关"` - `checked`(默认开启,true/false)默认 `false` -- `commandMode`(发送模式,选项)默认 `"文本"`,可选 `"文本"` `"HEX"` `"Modbus"` +- `commandMode`(发送模式,选项)默认 `"文本"`,可选 `"文本"` `"HEX"` `"JSON"` `"Modbus"` - `onCommand`(开启命令,命令)默认 `""` - `offCommand`(关闭命令,命令)默认 `""` - `commandTemplate`(发送模板,文本)默认 `"{value}"` +- `commandJsonNewline`(换行符,选项)默认 `"无"`,可选 `"无"` `"\\r\\n"` `"\\n"` `"\\r"` - `frameHeader`(帧头(HEX),文本)默认 `""` - `frameTail`(帧尾(HEX),文本)默认 `""` - `dataType`(数据类型,选项)默认 `"HEX字符串"`,可选 `"HEX字符串"` `"uint8"` `"int8"` `"uint16"` `"int16"` `"uint32"` `"int32"` `"float32"` @@ -117,8 +121,9 @@ - `maximum`(最大值,整数)默认 `100`,范围 -99999–99999 - `value`(默认值,整数)默认 `50`,范围 -99999–99999 - `sendOnChange`(变化发送,true/false)默认 `false` -- `commandMode`(发送模式,选项)默认 `"文本"`,可选 `"文本"` `"HEX"` `"Modbus"` +- `commandMode`(发送模式,选项)默认 `"文本"`,可选 `"文本"` `"HEX"` `"JSON"` `"Modbus"` - `commandTemplate`(发送模板,文本)默认 `"{value}"` +- `commandJsonNewline`(换行符,选项)默认 `"无"`,可选 `"无"` `"\\r\\n"` `"\\n"` `"\\r"` - `frameHeader`(帧头(HEX),文本)默认 `""` - `frameTail`(帧尾(HEX),文本)默认 `""` - `commandNewline`(换行符,选项)默认 `"\\r\\n"`,可选 `"无"` `"\\r\\n"` `"\\n"` `"\\r"` @@ -139,8 +144,9 @@ - `value`(默认值,整数)默认 `0`,范围 -99999–99999 - `suffix`(后缀,文本)默认 `""` - `sendOnChange`(变化发送,true/false)默认 `false` -- `commandMode`(发送模式,选项)默认 `"文本"`,可选 `"文本"` `"HEX"` `"Modbus"` +- `commandMode`(发送模式,选项)默认 `"文本"`,可选 `"文本"` `"HEX"` `"JSON"` `"Modbus"` - `commandTemplate`(发送模板,文本)默认 `"{value}"` +- `commandJsonNewline`(换行符,选项)默认 `"无"`,可选 `"无"` `"\\r\\n"` `"\\n"` `"\\r"` - `frameHeader`(帧头(HEX),文本)默认 `""` - `frameTail`(帧尾(HEX),文本)默认 `""` - `commandNewline`(换行符,选项)默认 `"\\r\\n"`,可选 `"无"` `"\\r\\n"` `"\\n"` `"\\r"` @@ -247,6 +253,10 @@ 默认尺寸 200×150,最小 30×20 - `imagePath`(图片路径,文本)默认 `""` +- `scaleMode`(缩放方式,选项)默认 `"适应"`,可选 `"适应"` `"拉伸"` `"原始大小"`,适应:保持比例放到最大;拉伸:铺满控件框;原始大小:按图片的像素大小,放不下时缩小 +- `rotation`(旋转(°),整数)默认 `0`,范围 0–359,顺时针,绕中心转;控件框不动,整张图片缩小到放得进框里 +- `mirrorH`(水平镜像,true/false)默认 `false`,左右翻转,在旋转之前 +- `mirrorV`(垂直镜像,true/false)默认 `false`,上下翻转,在旋转之前 ### 图表控件 diff --git a/tests/data/image_placement_vectors.json b/tests/data/image_placement_vectors.json new file mode 100644 index 0000000..83f6d6a --- /dev/null +++ b/tests/data/image_placement_vectors.json @@ -0,0 +1,550 @@ +{ + "tolerance": 1e-06, + "note": "corners: where the picture's pixel corners (0,0) (w,0) (w,h) (0,h) land in widget logical px", + "vectors": [ + { + "box": [ + 200, + 150 + ], + "image": [ + 400, + 300 + ], + "rotation": 0, + "mirrorH": false, + "mirrorV": false, + "scaleMode": "适应", + "corners": [ + [ + 0.0, + 0.0 + ], + [ + 200.0, + 0.0 + ], + [ + 200.0, + 150.0 + ], + [ + 0.0, + 150.0 + ] + ] + }, + { + "box": [ + 200, + 150 + ], + "image": [ + 400, + 300 + ], + "rotation": 90, + "mirrorH": false, + "mirrorV": false, + "scaleMode": "适应", + "corners": [ + [ + 156.25, + 0.0 + ], + [ + 156.25, + 150.0 + ], + [ + 43.75, + 150.0 + ], + [ + 43.75, + 0.0 + ] + ] + }, + { + "box": [ + 200, + 150 + ], + "image": [ + 400, + 300 + ], + "rotation": 45, + "mirrorH": false, + "mirrorV": false, + "scaleMode": "适应", + "corners": [ + [ + 89.285714286, + 0.0 + ], + [ + 175.0, + 85.714285714 + ], + [ + 110.714285714, + 150.0 + ], + [ + 25.0, + 64.285714286 + ] + ] + }, + { + "box": [ + 200, + 150 + ], + "image": [ + 400, + 300 + ], + "rotation": 180, + "mirrorH": true, + "mirrorV": false, + "scaleMode": "适应", + "corners": [ + [ + 0.0, + 150.0 + ], + [ + 200.0, + 150.0 + ], + [ + 200.0, + 0.0 + ], + [ + 0.0, + 0.0 + ] + ] + }, + { + "box": [ + 200, + 150 + ], + "image": [ + 400, + 300 + ], + "rotation": 270, + "mirrorH": false, + "mirrorV": true, + "scaleMode": "适应", + "corners": [ + [ + 156.25, + 150.0 + ], + [ + 156.25, + 0.0 + ], + [ + 43.75, + 0.0 + ], + [ + 43.75, + 150.0 + ] + ] + }, + { + "box": [ + 200, + 150 + ], + "image": [ + 400, + 300 + ], + "rotation": 30, + "mirrorH": true, + "mirrorV": true, + "scaleMode": "适应", + "corners": [ + [ + 132.036794164, + 150.0 + ], + [ + 19.029697634, + 84.755322398 + ], + [ + 67.963205836, + 0.0 + ], + [ + 180.970302366, + 65.244677602 + ] + ] + }, + { + "box": [ + 120, + 120 + ], + "image": [ + 64, + 32 + ], + "rotation": -90, + "mirrorH": false, + "mirrorV": false, + "scaleMode": "适应", + "corners": [ + [ + 30.0, + 120.0 + ], + [ + 30.0, + 0.0 + ], + [ + 90.0, + 0.0 + ], + [ + 90.0, + 120.0 + ] + ] + }, + { + "box": [ + 200, + 150 + ], + "image": [ + 400, + 300 + ], + "rotation": 0, + "mirrorH": false, + "mirrorV": false, + "scaleMode": "拉伸", + "corners": [ + [ + 0.0, + 0.0 + ], + [ + 200.0, + 0.0 + ], + [ + 200.0, + 150.0 + ], + [ + 0.0, + 150.0 + ] + ] + }, + { + "box": [ + 200, + 150 + ], + "image": [ + 64, + 64 + ], + "rotation": 90, + "mirrorH": false, + "mirrorV": false, + "scaleMode": "拉伸", + "corners": [ + [ + 200.0, + 0.0 + ], + [ + 200.0, + 150.0 + ], + [ + 0.0, + 150.0 + ], + [ + 0.0, + 0.0 + ] + ] + }, + { + "box": [ + 200, + 150 + ], + "image": [ + 64, + 64 + ], + "rotation": 30, + "mirrorH": false, + "mirrorV": false, + "scaleMode": "拉伸", + "corners": [ + [ + 67.963205836, + 0.0 + ], + [ + 180.970302366, + 65.244677602 + ], + [ + 132.036794164, + 150.0 + ], + [ + 19.029697634, + 84.755322398 + ] + ] + }, + { + "box": [ + 200, + 150 + ], + "image": [ + 64, + 64 + ], + "rotation": 46, + "mirrorH": false, + "mirrorV": false, + "scaleMode": "拉伸", + "corners": [ + [ + 112.053471889, + 0.0 + ], + [ + 175.374972129, + 65.571333013 + ], + [ + 87.946528111, + 150.0 + ], + [ + 24.625027871, + 84.428666987 + ] + ] + }, + { + "box": [ + 200, + 150 + ], + "image": [ + 64, + 64 + ], + "rotation": 135, + "mirrorH": true, + "mirrorV": false, + "scaleMode": "拉伸", + "corners": [ + [ + 89.285714286, + 150.0 + ], + [ + 175.0, + 64.285714286 + ], + [ + 110.714285714, + 0.0 + ], + [ + 25.0, + 85.714285714 + ] + ] + }, + { + "box": [ + 200, + 150 + ], + "image": [ + 64, + 64 + ], + "rotation": 0, + "mirrorH": false, + "mirrorV": false, + "scaleMode": "原始大小", + "corners": [ + [ + 68.0, + 43.0 + ], + [ + 132.0, + 43.0 + ], + [ + 132.0, + 107.0 + ], + [ + 68.0, + 107.0 + ] + ] + }, + { + "box": [ + 200, + 150 + ], + "image": [ + 64, + 64 + ], + "rotation": 45, + "mirrorH": false, + "mirrorV": true, + "scaleMode": "原始大小", + "corners": [ + [ + 54.745166004, + 75.0 + ], + [ + 100.0, + 120.254833996 + ], + [ + 145.254833996, + 75.0 + ], + [ + 100.0, + 29.745166004 + ] + ] + }, + { + "box": [ + 200, + 150 + ], + "image": [ + 400, + 300 + ], + "rotation": 0, + "mirrorH": false, + "mirrorV": false, + "scaleMode": "原始大小", + "corners": [ + [ + 0.0, + 0.0 + ], + [ + 200.0, + 0.0 + ], + [ + 200.0, + 150.0 + ], + [ + 0.0, + 150.0 + ] + ] + }, + { + "box": [ + 200, + 150 + ], + "image": [ + 160, + 40 + ], + "rotation": 300, + "mirrorH": true, + "mirrorV": false, + "scaleMode": "原始大小", + "corners": [ + [ + 121.45456978, + 0.0 + ], + [ + 45.775379606, + 131.080202457 + ], + [ + 78.54543022, + 150.0 + ], + [ + 154.224620394, + 18.919797543 + ] + ] + }, + { + "box": [ + 30, + 20 + ], + "image": [ + 400, + 300 + ], + "rotation": 359, + "mirrorH": false, + "mirrorV": false, + "scaleMode": "适应", + "corners": [ + [ + 1.799340509, + 0.454881744 + ], + [ + 27.859498183, + 0.0 + ], + [ + 28.200659491, + 19.545118256 + ], + [ + 2.140501817, + 20.0 + ] + ] + } + ] +} diff --git a/tests/data/json_command_vectors.json b/tests/data/json_command_vectors.json new file mode 100644 index 0000000..bf9d364 --- /dev/null +++ b/tests/data/json_command_vectors.json @@ -0,0 +1,788 @@ +{ + "cases": [ + { + "name": "switch on", + "widget": "ToggleSwitch", + "props": { + "commandMode": "JSON", + "commandTemplate": "{\"led\":{value}}" + }, + "value": "1", + "out": "{\"led\":1}" + }, + { + "name": "switch off", + "widget": "ToggleSwitch", + "props": { + "commandMode": "JSON", + "commandTemplate": "{\"led\":{value}}" + }, + "value": "0", + "out": "{\"led\":0}" + }, + { + "name": "switch bool", + "widget": "ToggleSwitch", + "props": { + "commandMode": "JSON", + "commandTemplate": "{\"led\":{value}}" + }, + "value": "true", + "out": "{\"led\":true}" + }, + { + "name": "switch bare word is a string", + "widget": "ToggleSwitch", + "props": { + "commandMode": "JSON", + "commandTemplate": "{\"led\":{value}}" + }, + "value": "on", + "out": "{\"led\":\"on\"}" + }, + { + "name": "switch quoted string", + "widget": "ToggleSwitch", + "props": { + "commandMode": "JSON", + "commandTemplate": "{\"led\":{value}}" + }, + "value": "\"on\"", + "out": "{\"led\":\"on\"}" + }, + { + "name": "slider nested", + "widget": "Slider", + "props": { + "commandMode": "JSON", + "commandTemplate": "{\"cfg\":{\"speed\":{value}}}" + }, + "value": "42", + "out": "{\"cfg\":{\"speed\":42}}" + }, + { + "name": "slider fixed fields", + "widget": "Slider", + "props": { + "commandMode": "JSON", + "commandTemplate": "{\"cmd\":\"setSpeed\",\"value\":{value}}" + }, + "value": "75", + "out": "{\"cmd\":\"setSpeed\",\"value\":75}" + }, + { + "name": "spinbox negative", + "widget": "SpinBox", + "props": { + "commandMode": "JSON", + "commandTemplate": "{\"t\":{value}}" + }, + "value": "-5", + "out": "{\"t\":-5}" + }, + { + "name": "lineedit string escaped", + "widget": "LineEdit", + "props": { + "commandMode": "JSON", + "commandTemplate": "{\"name\":{value}}" + }, + "value": "say \"hi\"", + "out": "{\"name\":\"say \\\"hi\\\"\"}" + }, + { + "name": "two placeholders in an array", + "widget": "SpinBox", + "props": { + "commandMode": "JSON", + "commandTemplate": "[{value},{value}]" + }, + "value": "3", + "out": "[3,3]" + }, + { + "name": "two placeholders in an object", + "widget": "LineEdit", + "props": { + "commandMode": "JSON", + "commandTemplate": "{\"a\":{value},\"b\":{value}}" + }, + "value": "x", + "out": "{\"a\":\"x\",\"b\":\"x\"}" + }, + { + "name": "no placeholder ignores the value", + "widget": "PushButton", + "props": { + "commandMode": "JSON", + "commandTemplate": "{\"cmd\":\"start\",\"speed\":50}" + }, + "value": "ignored", + "out": "{\"cmd\":\"start\",\"speed\":50}" + }, + { + "name": "template absent is {value}", + "widget": "PushButton", + "props": { + "commandMode": "JSON" + }, + "value": "START", + "out": "\"START\"" + }, + { + "name": "default template sends the bare value", + "widget": "SpinBox", + "props": { + "commandMode": "JSON", + "commandTemplate": "{value}" + }, + "value": "7", + "out": "7" + }, + { + "name": "empty template is {value}", + "widget": "PushButton", + "props": { + "commandMode": "JSON", + "commandTemplate": "" + }, + "value": "{\"cmd\": \"start\", \"speed\": 50}", + "out": "{\"cmd\":\"start\",\"speed\":50}" + }, + { + "name": "blank template is {value}", + "widget": "PushButton", + "props": { + "commandMode": "JSON", + "commandTemplate": " \t " + }, + "value": "[1, 2, 3]", + "out": "[1,2,3]" + }, + { + "name": "template whitespace and newlines minified", + "widget": "ToggleSwitch", + "props": { + "commandMode": "JSON", + "commandTemplate": "{\n \"led\" : {value} ,\r\n\t\"src\": \"ui panel\"\n}" + }, + "value": "1", + "out": "{\"led\":1,\"src\":\"ui panel\"}" + }, + { + "name": "value text that looks like the placeholder", + "widget": "LineEdit", + "props": { + "commandMode": "JSON", + "commandTemplate": "{\"v\":{value}}" + }, + "value": "{value}", + "out": "{\"v\":\"{value}\"}" + }, + { + "name": "value JSON string holding the placeholder text", + "widget": "LineEdit", + "props": { + "commandMode": "JSON", + "commandTemplate": "{\"v\":{value}}" + }, + "value": "\"{value}\"", + "out": "{\"v\":\"{value}\"}" + }, + { + "name": "value object inside template", + "widget": "ComboBox", + "props": { + "commandMode": "JSON", + "commandTemplate": "{\"set\":{value}}" + }, + "value": "{\"a\" : [1, 2]}", + "out": "{\"set\":{\"a\":[1,2]}}" + }, + { + "name": "error: unbalanced", + "widget": "Slider", + "props": { + "commandMode": "JSON", + "commandTemplate": "{\"a\":{value}" + }, + "value": "1", + "error": "JSON 模板无效: {\"a\":{value}" + }, + { + "name": "error: placeholder inside a string", + "widget": "Slider", + "props": { + "commandMode": "JSON", + "commandTemplate": "{\"m\":\"v={value}\"}" + }, + "value": "1", + "error": "JSON 模板无效: {\"m\":\"v={value}\"}" + }, + { + "name": "error: placeholder after an escaped quote inside a string", + "widget": "Slider", + "props": { + "commandMode": "JSON", + "commandTemplate": "{\"m\":\"a\\\"{value}\"}" + }, + "value": "1", + "error": "JSON 模板无效: {\"m\":\"a\\\"{value}\"}" + }, + { + "name": "error: placeholder as a whole string", + "widget": "Slider", + "props": { + "commandMode": "JSON", + "commandTemplate": "{\"k\":\"{value}\"}" + }, + "value": "1", + "error": "JSON 模板无效: {\"k\":\"{value}\"}" + }, + { + "name": "error: two values", + "widget": "Slider", + "props": { + "commandMode": "JSON", + "commandTemplate": "{value}{value}" + }, + "value": "1", + "error": "JSON 模板无效: {value}{value}" + }, + { + "name": "error: two values with a space", + "widget": "Slider", + "props": { + "commandMode": "JSON", + "commandTemplate": "{value} {value}" + }, + "value": "1", + "error": "JSON 模板无效: {value} {value}" + }, + { + "name": "error: placeholder as a key", + "widget": "Slider", + "props": { + "commandMode": "JSON", + "commandTemplate": "{{value}:1}" + }, + "value": "1", + "error": "JSON 模板无效: {{value}:1}" + }, + { + "name": "error: placeholder glued to a number", + "widget": "Slider", + "props": { + "commandMode": "JSON", + "commandTemplate": "{\"a\":1{value}}" + }, + "value": "1", + "error": "JSON 模板无效: {\"a\":1{value}}" + }, + { + "name": "error: single quotes", + "widget": "Slider", + "props": { + "commandMode": "JSON", + "commandTemplate": "'{value}'" + }, + "value": "1", + "error": "JSON 模板无效: '{value}'" + }, + { + "name": "error: fixed template not JSON", + "widget": "PushButton", + "props": { + "commandMode": "JSON", + "commandTemplate": "LED_ON" + }, + "value": "1", + "error": "JSON 模板无效: LED_ON" + }, + { + "name": "error: fixed template trailing comma", + "widget": "PushButton", + "props": { + "commandMode": "JSON", + "commandTemplate": "{\"a\":1,}" + }, + "value": "1", + "error": "JSON 模板无效: {\"a\":1,}" + }, + { + "name": "value null", + "widget": "PushButton", + "props": { + "commandMode": "JSON", + "commandTemplate": "{\"v\":{value}}" + }, + "value": "null", + "out": "{\"v\":null}" + }, + { + "name": "value empty object", + "widget": "PushButton", + "props": { + "commandMode": "JSON", + "commandTemplate": "{\"v\":{value}}" + }, + "value": "{}", + "out": "{\"v\":{}}" + }, + { + "name": "value empty array", + "widget": "PushButton", + "props": { + "commandMode": "JSON", + "commandTemplate": "{\"v\":{value}}" + }, + "value": "[]", + "out": "{\"v\":[]}" + }, + { + "name": "lineedit quotes and backslash escaped", + "widget": "LineEdit", + "props": { + "commandMode": "JSON", + "commandTemplate": "{\"v\":{value}}" + }, + "value": "say \"hi\" \\ bye", + "out": "{\"v\":\"say \\\"hi\\\" \\\\ bye\"}" + }, + { + "name": "lineedit lone backslash", + "widget": "LineEdit", + "props": { + "commandMode": "JSON", + "commandTemplate": "{\"v\":{value}}" + }, + "value": "\\", + "out": "{\"v\":\"\\\\\"}" + }, + { + "name": "lineedit decimal", + "widget": "LineEdit", + "props": { + "commandMode": "JSON", + "commandTemplate": "{\"v\":{value}}" + }, + "value": "3.14", + "out": "{\"v\":3.14}" + }, + { + "name": "lineedit exponent kept as written", + "widget": "LineEdit", + "props": { + "commandMode": "JSON", + "commandTemplate": "{\"v\":{value}}" + }, + "value": "1e3", + "out": "{\"v\":1e3}" + }, + { + "name": "lineedit exponent sign", + "widget": "LineEdit", + "props": { + "commandMode": "JSON", + "commandTemplate": "{\"v\":{value}}" + }, + "value": "2.5E-3", + "out": "{\"v\":2.5E-3}" + }, + { + "name": "lineedit big integer kept as written", + "widget": "LineEdit", + "props": { + "commandMode": "JSON", + "commandTemplate": "{\"v\":{value}}" + }, + "value": "12345678901234567890", + "out": "{\"v\":12345678901234567890}" + }, + { + "name": "lineedit huge exponent is still JSON", + "widget": "LineEdit", + "props": { + "commandMode": "JSON", + "commandTemplate": "{\"v\":{value}}" + }, + "value": "1e400", + "out": "{\"v\":1e400}" + }, + { + "name": "lineedit minus zero", + "widget": "LineEdit", + "props": { + "commandMode": "JSON", + "commandTemplate": "{\"v\":{value}}" + }, + "value": "-0", + "out": "{\"v\":-0}" + }, + { + "name": "lineedit leading zero is a string", + "widget": "LineEdit", + "props": { + "commandMode": "JSON", + "commandTemplate": "{\"v\":{value}}" + }, + "value": "01", + "out": "{\"v\":\"01\"}" + }, + { + "name": "lineedit trailing dot is a string", + "widget": "LineEdit", + "props": { + "commandMode": "JSON", + "commandTemplate": "{\"v\":{value}}" + }, + "value": "1.", + "out": "{\"v\":\"1.\"}" + }, + { + "name": "lineedit leading dot is a string", + "widget": "LineEdit", + "props": { + "commandMode": "JSON", + "commandTemplate": "{\"v\":{value}}" + }, + "value": ".5", + "out": "{\"v\":\".5\"}" + }, + { + "name": "lineedit plus sign is a string", + "widget": "LineEdit", + "props": { + "commandMode": "JSON", + "commandTemplate": "{\"v\":{value}}" + }, + "value": "+1", + "out": "{\"v\":\"+1\"}" + }, + { + "name": "lineedit split minus is a string", + "widget": "LineEdit", + "props": { + "commandMode": "JSON", + "commandTemplate": "{\"v\":{value}}" + }, + "value": "- 1", + "out": "{\"v\":\"- 1\"}" + }, + { + "name": "lineedit NaN is a string", + "widget": "LineEdit", + "props": { + "commandMode": "JSON", + "commandTemplate": "{\"v\":{value}}" + }, + "value": "NaN", + "out": "{\"v\":\"NaN\"}" + }, + { + "name": "lineedit Infinity is a string", + "widget": "LineEdit", + "props": { + "commandMode": "JSON", + "commandTemplate": "{\"v\":{value}}" + }, + "value": "Infinity", + "out": "{\"v\":\"Infinity\"}" + }, + { + "name": "lineedit -Infinity is a string", + "widget": "LineEdit", + "props": { + "commandMode": "JSON", + "commandTemplate": "{\"v\":{value}}" + }, + "value": "-Infinity", + "out": "{\"v\":\"-Infinity\"}" + }, + { + "name": "lineedit True is a string", + "widget": "LineEdit", + "props": { + "commandMode": "JSON", + "commandTemplate": "{\"v\":{value}}" + }, + "value": "True", + "out": "{\"v\":\"True\"}" + }, + { + "name": "lineedit truncated literal is a string", + "widget": "LineEdit", + "props": { + "commandMode": "JSON", + "commandTemplate": "{\"v\":{value}}" + }, + "value": "tru", + "out": "{\"v\":\"tru\"}" + }, + { + "name": "lineedit quoted digits stay a string", + "widget": "LineEdit", + "props": { + "commandMode": "JSON", + "commandTemplate": "{\"v\":{value}}" + }, + "value": "\"123\"", + "out": "{\"v\":\"123\"}" + }, + { + "name": "lineedit single quotes are a string", + "widget": "LineEdit", + "props": { + "commandMode": "JSON", + "commandTemplate": "{\"v\":{value}}" + }, + "value": "{'a': 1}", + "out": "{\"v\":\"{'a': 1}\"}" + }, + { + "name": "lineedit trailing comma in array is a string", + "widget": "LineEdit", + "props": { + "commandMode": "JSON", + "commandTemplate": "{\"v\":{value}}" + }, + "value": "[1,]", + "out": "{\"v\":\"[1,]\"}" + }, + { + "name": "lineedit trailing comma in object is a string", + "widget": "LineEdit", + "props": { + "commandMode": "JSON", + "commandTemplate": "{\"v\":{value}}" + }, + "value": "{\"a\":1,}", + "out": "{\"v\":\"{\\\"a\\\":1,}\"}" + }, + { + "name": "lineedit text after JSON is a string", + "widget": "LineEdit", + "props": { + "commandMode": "JSON", + "commandTemplate": "{\"v\":{value}}" + }, + "value": "{\"a\":1} x", + "out": "{\"v\":\"{\\\"a\\\":1} x\"}" + }, + { + "name": "lineedit two documents are a string", + "widget": "LineEdit", + "props": { + "commandMode": "JSON", + "commandTemplate": "{\"v\":{value}}" + }, + "value": "{\"a\":1}{\"b\":2}", + "out": "{\"v\":\"{\\\"a\\\":1}{\\\"b\\\":2}\"}" + }, + { + "name": "lineedit unterminated string is a string", + "widget": "LineEdit", + "props": { + "commandMode": "JSON", + "commandTemplate": "{\"v\":{value}}" + }, + "value": "\"open", + "out": "{\"v\":\"\\\"open\"}" + }, + { + "name": "lineedit raw tab inside a JSON string is a string", + "widget": "LineEdit", + "props": { + "commandMode": "JSON", + "commandTemplate": "{\"v\":{value}}" + }, + "value": "\"a\tb\"", + "out": "{\"v\":\"\\\"a\\tb\\\"\"}" + }, + { + "name": "lineedit tab escaped", + "widget": "LineEdit", + "props": { + "commandMode": "JSON", + "commandTemplate": "{\"v\":{value}}" + }, + "value": "a\tb", + "out": "{\"v\":\"a\\tb\"}" + }, + { + "name": "lineedit control characters escaped", + "widget": "LineEdit", + "props": { + "commandMode": "JSON", + "commandTemplate": "{\"v\":{value}}" + }, + "value": "\u0001\b\f\u001f", + "out": "{\"v\":\"\\u0001\\b\\f\\u001f\"}" + }, + { + "name": "lineedit empty value is an empty string", + "widget": "LineEdit", + "props": { + "commandMode": "JSON", + "commandTemplate": "{\"v\":{value}}" + }, + "value": "", + "out": "{\"v\":\"\"}" + }, + { + "name": "lineedit surrounding whitespace dropped", + "widget": "LineEdit", + "props": { + "commandMode": "JSON", + "commandTemplate": "{\"v\":{value}}" + }, + "value": " 1 ", + "out": "{\"v\":1}" + }, + { + "name": "lineedit escapes inside JSON kept as written", + "widget": "LineEdit", + "props": { + "commandMode": "JSON", + "commandTemplate": "{\"v\":{value}}" + }, + "value": "\"caf\\u00e9 \\ud83d\\ude00 \\/\"", + "out": "{\"v\":\"caf\\u00e9 \\ud83d\\ude00 \\/\"}" + }, + { + "name": "lineedit slash not escaped", + "widget": "LineEdit", + "props": { + "commandMode": "JSON", + "commandTemplate": "{\"v\":{value}}" + }, + "value": "a/b", + "out": "{\"v\":\"a/b\"}" + }, + { + "name": "combo Chinese text", + "widget": "ComboBox", + "props": { + "commandMode": "JSON", + "commandTemplate": "{\"mode\":{value}}" + }, + "value": "自动", + "out": "{\"mode\":\"自动\"}" + }, + { + "name": "combo Chinese key and value", + "widget": "ComboBox", + "props": { + "commandMode": "JSON", + "commandTemplate": "{\"状态\":{value}}" + }, + "value": "开", + "out": "{\"状态\":\"开\"}" + }, + { + "name": "combo emoji", + "widget": "ComboBox", + "props": { + "commandMode": "JSON", + "commandTemplate": "{\"pet\":{value}}" + }, + "value": "🐱", + "out": "{\"pet\":\"🐱\"}" + }, + { + "name": "combo spaces inside strings kept", + "widget": "ComboBox", + "props": { + "commandMode": "JSON", + "commandTemplate": "" + }, + "value": "{\"msg\": \"a b\"}", + "out": "{\"msg\":\"a b\"}" + }, + { + "name": "combo multi-line JSON compacted", + "widget": "ComboBox", + "props": { + "commandMode": "JSON", + "commandTemplate": "" + }, + "value": "{\n \"a\" : [ 1 , 2 ],\r\n\t\"b\": {\"c\": false}\n}", + "out": "{\"a\":[1,2],\"b\":{\"c\":false}}" + }, + { + "name": "combo duplicate keys kept as written", + "widget": "ComboBox", + "props": { + "commandMode": "JSON", + "commandTemplate": "" + }, + "value": "{\"a\":1,\"a\":2}", + "out": "{\"a\":1,\"a\":2}" + }, + { + "name": "combo brackets inside strings", + "widget": "ComboBox", + "props": { + "commandMode": "JSON", + "commandTemplate": "" + }, + "value": "{\"s\": \"} ]\\\" {\"}", + "out": "{\"s\":\"} ]\\\" {\"}" + }, + { + "name": "newline none", + "widget": "ToggleSwitch", + "props": { + "commandMode": "JSON", + "commandTemplate": "{\"led\":{value}}", + "commandJsonNewline": "无" + }, + "value": "1", + "out": "{\"led\":1}" + }, + { + "name": "newline CRLF", + "widget": "ToggleSwitch", + "props": { + "commandMode": "JSON", + "commandTemplate": "{\"led\":{value}}", + "commandJsonNewline": "\\r\\n" + }, + "value": "1", + "out": "{\"led\":1}\r\n" + }, + { + "name": "newline LF", + "widget": "ToggleSwitch", + "props": { + "commandMode": "JSON", + "commandTemplate": "{\"led\":{value}}", + "commandJsonNewline": "\\n" + }, + "value": "1", + "out": "{\"led\":1}\n" + }, + { + "name": "newline CR", + "widget": "ToggleSwitch", + "props": { + "commandMode": "JSON", + "commandTemplate": "{\"led\":{value}}", + "commandJsonNewline": "\\r" + }, + "value": "1", + "out": "{\"led\":1}\r" + }, + { + "name": "error has no line ending either", + "widget": "ToggleSwitch", + "props": { + "commandMode": "JSON", + "commandTemplate": "{\"led\":{value}", + "commandJsonNewline": "\\r\\n" + }, + "value": "1", + "error": "JSON 模板无效: {\"led\":{value}" + } + ] +} diff --git a/tests/test_export_portability.py b/tests/test_export_portability.py index 80f7bed..ce4da0c 100644 --- a/tests/test_export_portability.py +++ b/tests/test_export_portability.py @@ -98,9 +98,8 @@ def test_absolute_and_nested_relative_images_survive_relocation(self): for name, color in (("absolute", "#0000ff"), ("relative", "#ff0000"), ("duplicate", "#ff0000")): widget = window.widgets[name] - pixmap = widget._label.pixmap() - assert pixmap is not None and not pixmap.isNull(), name - assert pixmap.toImage().pixelColor(0, 0).name() == color, name + assert not widget._image.isNull(), name + assert widget._image.pixelColor(0, 0).name() == color, name assert Path(widget._path).is_relative_to(Path(sys.argv[1]).resolve()) assert not any(name == "app" or name.startswith("app.") for name in sys.modules) window.deleteLater() diff --git a/tests/test_image_placement_vectors.py b/tests/test_image_placement_vectors.py new file mode 100644 index 0000000..a584001 --- /dev/null +++ b/tests/test_image_placement_vectors.py @@ -0,0 +1,31 @@ +"""ImageDisplay places its picture as tests/data/image_placement_vectors.json says, which FluentHMI Studio's image +widget is checked against too, so a picture lands in the same place in either app.""" +import json +from pathlib import Path +import unittest + +from PyQt5.QtCore import QPointF + +from app.core.image_runtime import image_transform + +VECTORS = json.loads((Path(__file__).resolve().parent / "data" / "image_placement_vectors.json") + .read_text(encoding="utf-8")) + + +class ImagePlacementVectorTests(unittest.TestCase): + def test_picture_corners_land_where_the_shared_vectors_say(self): + tolerance = VECTORS["tolerance"] + for index, case in enumerate(VECTORS["vectors"]): + (box_w, box_h), (img_w, img_h) = case["box"], case["image"] + transform = image_transform(box_w, box_h, img_w, img_h, case["rotation"], + case["mirrorH"], case["mirrorV"], case["scaleMode"]) + corners = [transform.map(QPointF(x, y)) + for x, y in ((0, 0), (img_w, 0), (img_w, img_h), (0, img_h))] + with self.subTest(vector=index): + for point, (x, y) in zip(corners, case["corners"]): + self.assertTrue(abs(point.x() - x) <= tolerance and abs(point.y() - y) <= tolerance, + f"vector {index}: {(point.x(), point.y())} != {(x, y)}") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_json_command_vectors.py b/tests/test_json_command_vectors.py new file mode 100644 index 0000000..fe46224 --- /dev/null +++ b/tests/test_json_command_vectors.py @@ -0,0 +1,25 @@ +"""JSON send mode matches tests/data/json_command_vectors.json, which FluentHMI Studio's encoder is checked +against too, so both apps send the same bytes for the same .uidp.""" +import json +from pathlib import Path +import unittest + +from app.core.command_runtime import json_command + +VECTORS = json.loads((Path(__file__).parent / "data" / "json_command_vectors.json").read_text(encoding="utf-8")) + + +class JsonCommandVectorTests(unittest.TestCase): + def test_encoded_bytes_and_errors_match_the_shared_vectors(self): + for case in VECTORS["cases"]: + with self.subTest(case["name"]): + if "error" in case: + with self.assertRaises(ValueError) as caught: + json_command(case["props"], case["value"]) + self.assertEqual(str(caught.exception), case["error"]) + else: + self.assertEqual(json_command(case["props"], case["value"]), case["out"].encode("utf-8")) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_project_runtime_geometry.py b/tests/test_project_runtime_geometry.py index 6bba179..cd57671 100644 --- a/tests/test_project_runtime_geometry.py +++ b/tests/test_project_runtime_geometry.py @@ -203,9 +203,9 @@ def test_relative_images_load_edit_save_as_and_export_without_changing_reference manager.load_to_scene(self.scene, str(project_file)) canvas_image = self.scene.top_level_canvas_items()[0]._container_children[0] self.assertEqual(canvas_image.properties["imagePath"], "images/sample.png") - self.assertEqual(canvas_image._inner_widget._label.pixmap().toImage().pixelColor(0, 0).name(), "#12ab34") + self.assertEqual(canvas_image._inner_widget._image.pixelColor(0, 0).name(), "#12ab34") canvas_image.set_property("imagePath", "./images/sample.png") - self.assertFalse(canvas_image._inner_widget._label.pixmap().isNull()) + self.assertFalse(canvas_image._inner_widget._image.isNull()) destination = self.folder / "saved-elsewhere" destination.mkdir() renamed = destination / "copy.uidp" @@ -226,7 +226,7 @@ def test_relative_images_load_edit_save_as_and_export_without_changing_reference self.assertEqual(editor._editors["imagePath"].findChild(LineEdit).text(), reference) self.assertFalse(Path(reference).is_absolute()) self.assertEqual((destination / reference).resolve(), (source / "images" / "sample.png").resolve()) - self.assertFalse(canvas_image._inner_widget._label.pixmap().isNull()) + self.assertFalse(canvas_image._inner_widget._image.isNull()) manager.load_to_scene(self.scene, str(renamed)) image_data = manager.current.widgets[0]["children"][0] self.assertEqual(image_data["properties"]["imagePath"], reference) @@ -235,13 +235,12 @@ def test_relative_images_load_edit_save_as_and_export_without_changing_reference item("pasted_image", "ImageDisplay", {"imagePath": reference}), ])) self.scene.add_canvas_item(pasted) - self.assertFalse(pasted._container_children[0]._inner_widget._label.pixmap().isNull()) + self.assertFalse(pasted._container_children[0]._inner_widget._image.isNull()) self.run_both(manager.current, r''' from pathlib import Path image = widgets["image"] - pixmap = image._label.pixmap() - assert pixmap is not None and not pixmap.isNull() - assert pixmap.toImage().pixelColor(0, 0).name() == "#12ab34" + assert not image._image.isNull() + assert image._image.pixelColor(0, 0).name() == "#12ab34" assert Path(image._path).is_absolute() if os.environ["APPLICATION_MODE"] == "export": assert Path(image._path).parent.name == "assets"