Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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+).

Expand Down
2 changes: 1 addition & 1 deletion README_zh.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)。

Expand Down
2 changes: 1 addition & 1 deletion app/common/runtime_sources.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
80 changes: 23 additions & 57 deletions app/core/code_generator.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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}); "
Expand Down Expand Up @@ -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:
Expand Down
92 changes: 92 additions & 0 deletions app/core/command_runtime.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
"""Command encoders shared by preview and standalone applications."""
import json
import math
import struct

Expand All @@ -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)
Expand Down
119 changes: 119 additions & 0 deletions app/core/image_runtime.py
Original file line number Diff line number Diff line change
@@ -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)
3 changes: 2 additions & 1 deletion app/designer/property_editor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
}
Expand Down
Loading
Loading