1

I am trying to create a desktop app using Python and Qt for the first time. Since this is my first time creating a gui based application, I hope someone experienced can guide me in the right direction on some of the questions I have.

Background: I am trying to create a desktop app using Python and Qt. The Python logic is used to interface with hardware (sensors and motors) to control a process. The front end gui should allow user to monitor the process as well as change process parameters as it is occurring. Previously I have used LabVIEW to achieve my goals but now I would like to migrate to Python. However, I found gui development in Python is not as easy as LabVIEW. After some research I have found QML and Qt Quick based gui would be easiest as opposed to the typical widget based gui using Qt (pyqt/pyside) or wxPython.

Problem: I am having difficulty trying to understand how I can get two binding between my process variable and the frontend gui. These process variables can be input/output for various hardware in the process such as sensors and motors. I read this tutorial which uses properties based approach to get two-way binding. Another approach for data binding is also explained here. However, it seems one would have to write a lot of code (signal, slot, setter, getter) for each variable in the process. When the backend logic can have tens or hundereds of process parameters that need to be displayed and modified in the front end gui, the code can get very complex.

I wrote a example program which allows user to calculate area from a given input length and width. All these input/ouput params are updated on the gui. Although this example may not capture the true complexities of the backend logic in controlling some hardware and parallel processes, I hope it can help answer some of the questions I have.

Questions:

  • I created a class called twoWayBindedParam which would minimize the code I would have write for signal, slot, setter, getter for each type of process parameter. Is this the best approach to achieve two way binding between backend logic parameters and the front end gui? Is the there something can be improved?
  • In the twoWayBindedParam class, I defined properties qml_prop_*, which would be supplied to the front end gui. However, the problem I am having is that I must specify the type of signal/slot it is emitting (ex: int, float, str, etc...). I can NOT just simply specify it as 'object' type. In addition I can not refer to the self.typeOfParam when creating this property. The documentation on the QtCore Property module is limited. How can I create a property can be interfaced with the front end QML code where I don't have to specify exactly what type of value the signal slot is being created (ex: int, float, str, etc...).
  • In the main() function, I have to inject the QQmlApplicationEngine object to my myBackendLogic object instance. Without this I can't do rootContext().setContextProperty(). Is there a way I can get the current running instance of the QQmlApplicationEngine() in the myBackendLogic class WITHOUT it having to be supplied as instantiating parameter when creating the myBackendLogic object?
  • How can I get the QML front end class a function that is NOT part of a class? For example, in the example code below I want run the function doSomething() when Calculate button is pressed. However, I am unsure how to call this doSomething() function because rootContext().setContextProperty() requires the name of a class object.
  • Is there a suggested or simple approach to the backend logic where I can load tens/hundreds of per-configured parameters stored in some file (ex: json, xml, csv) when the gui app is launched.
  • My current gui works as intended but when I drag the gui window to a second monitor, all the controls become frozen. I need to draw the window back to main monitor when it becomes unfrozen.

I hope those who answer my questions are mindful to use Python instead of C++ for example codes.

main.py

import sys
import os
import random

from PySide2.QtGui import QGuiApplication
from PySide2.QtQml import QQmlApplicationEngine
from PySide2.QtCore import QObject, Signal, Slot, Property

class twoWayBindedParam(QObject):
   # A class to represent a variable that is two-binded in the Python logic and in QML gui

    def __init__(self, value):
        QObject.__init__(self)
        self.value = value
        self.typeOfParam = type(self.value) # Determine if its a str, int, float, etc...

    @Signal
    def valueChanged(self):
        pass

    @Slot(int)
    def set(self, value):
        self.value = value
        self.valueChanged.emit()

    def get(self):
        return self.value

    # Problem: I must create different properties for each type
    # In the QML gui, I must use the correct type of property
    # The problem is when creating the Property() object,
    # I can NOT refer to the self.typeOfParam
    # Chagning the type to 'object' doesn't work: https://stackoverflow.com/a/5186587/4988010
    qml_prop_int = Property(int, get, set, notify=valueChanged)
    qml_prop_float = Property(float, get, set, notify=valueChanged)
    qml_prop_bool = Property(bool, get, set, notify=valueChanged)

class myBackendLogic(QObject):

    def __init__(self, app_engine):
        QObject.__init__(self)

        # The app_engine object is needed to use the function rootContext().setContextProperty()
        # Is there a way to get the current instance of the app_engine that is created in the main
        # without it having to be passed as a paramter to the myBackendLogic() object?
        self.eng = app_engine

        self.init_default_params()

    def init_default_params(self):

        random.seed(23)
        length = random.randint(0,100)
        width = random.randint(0,100)
        area = self.whatIsArea(length,width)

        self.length_param = twoWayBindedParam(length)
        self.eng.rootContext().setContextProperty("length_param", self.length_param)

        self.width_param = twoWayBindedParam(width)
        self.eng.rootContext().setContextProperty("width_param", self.width_param)

        self.area_param = twoWayBindedParam(area)
        self.eng.rootContext().setContextProperty("area_param", self.area_param)

        self.continuous_calc_param = twoWayBindedParam(False)
        self.eng.rootContext().setContextProperty("continuous_calc_param", self.continuous_calc_param)

    def whatIsArea(self, l,w):
        result = float(l*w) + random.random() # Add some noise
        return result

    @Slot()
    def calculate_area_param(self):
        area = self.whatIsArea(self.length_param.get(),self.width_param.get())
        self.area_param.set(area)

def doSomething():
    print('Do something')

def main():
    app = QGuiApplication(sys.argv)
    engine = QQmlApplicationEngine()

    mylogic = myBackendLogic(engine)
    engine.rootContext().setContextProperty("mylogic", mylogic)

    engine.load(os.path.join(os.path.dirname(__file__), "main.qml"))

    if not engine.rootObjects():
        sys.exit(-1)
    sys.exit(app.exec_())

if __name__ == "__main__":

    main()

main.qml

import QtQuick 2.13
import QtQuick.Window 2.13
import QtQuick.Controls 2.2

Window {
    title: qsTr("Hello World")
    width: 640
    height: 480
    visible: true

    Column {
        id: column
        x: 131
        y: 63
        width: 72
        height: 263

        TextInput {
            id: textInput_length
            width: 80
            height: 20
            text: length_param.qml_prop_int
            font.pixelSize: 12
        }

        Slider {
            id: slider_length
            to: 100
            orientation: Qt.Vertical
            value: length_param.qml_prop_int
            onValueChanged: {
                length_param.set(value)
                if (continuous_switch.checked) { mylogic.calculate_area_param() }
            }
        }


    }

    Column {
        id: column1
        x: 249
        y: 63
        width: 72
        height: 263

        TextInput {
            id: textInput_width
            width: 80
            height: 20
            text: width_param.qml_prop_int
            font.pixelSize: 12
        }

        Slider {
            id: slider_width
            to: 100
            value: width_param.qml_prop_int
            orientation: Qt.Vertical
            onValueChanged: {
                width_param.set(value)
                if (continuous_switch.checked) { mylogic.calculate_area_param() }
            }
        }
    }

    Row {
        id: row
        x: 110
        y: 332
        width: 274
        height: 53


        Slider {
            id: slider_area
            to: 10000
            value: area_param.qml_prop_float
        }

        Label {
            id: label_area
            text: area_param.qml_prop_float
        }
    }


    Switch {
        id: continuous_switch
        x: 343
        y: 149
        text: qsTr("Continuous calculate")
        checked: continuous_calc_param.qml_prop_bool
    }

    Button {
        id: button
        x: 383
        y: 205
        text: qsTr("Calculate")
        onClicked: {
            mylogic.calculate_area_param()
        }
    }

    Label {
        id: label
        x: 131
        y: 23
        text: qsTr("Length")
        font.pointSize: 12
    }

    Label {
        id: label1
        x: 249
        y: 23
        text: qsTr("Width")
        font.pointSize: 12
    }

    Label {
        id: label3
        x: 196
        y: 377
        text: qsTr("Area")
        font.pointSize: 12
    }

}

enter image description here

wohlstad
  • 12,661
  • 10
  • 26
  • 39
Zythyr
  • 1,142
  • 4
  • 20
  • 33

1 Answers1

2

Your questions are interesting but for a post it can be broad, so for a next question it is recommended that you create a post for each question.

Perhaps the logic that Qt requires seems complicated to you, and that it is also transferred to python binding(as PyQt5 and PySide2), but if you work with more time in the world of Qt you will realize that it is not.

Unlike LabView which has specialized elements to represent instruments (mechanical, electrical, etc.). Qt is a generic library so obviously you will have to invest more time to achieve what you want, if your objective is a specific area then it would be good if you believe a library on top of Qt.


In this section I will try to answer each question so I will list them:

  1. To many it seems extensive and unnecessary to have to implement a lot of code for trivial functionality as seen in this question: How to create PyQt Properties dynamically, so you could take the answers as a base.

  2. PySide2/PyQt5 are C++ object wrappers so the strong typing language restrictions set so you couldn't set an "object" type. That has an advantage and justification: Qt wants to use the least amount of memory possible since many of its applications are for embedded applications, and it also reduces the latency of each task. On the other hand, the PySide2 documentation is still working, so you can take the Qt docs as a base, for example the Property is an equivalent of a Q_PROPERTY, or the documentation of its PyQt5 brother.

  3. To use a QObject implemented in python from QML it is not necessary to use setContextProperty(). There are 2 ways to expose QObjects to QML:

    • Expose a QObject through setContextProperty(), it will behave like a singleton that has a global scope.

    • Expose a class that inherits from QObject through qmlRegisterType(see here), then you can create an object of that QML class like the other items like TextInput, Slider, etc.

    So it is not necessary to access the engine to expose a class to QML.

  4. It is not possible to expose a function to QML, you can only expose QObjects objects, some basic types like int, string, etc, and QObject based classes. One solution is to create a class that has that method and invoke it.

  5. Depending on the data structure in Qt, it is recommended to use models (QAbstract{Item, Table, List}Model, QStandardItemModel, QSql{Query, Table}Model, etc.) and create the widgets using QListView, QGridView, Repeaters. Those models can take the information from sources like json, xml, csv, for example there is already an Item called XmlListModel, and building models based on json or csv is not usually complicated.

  6. I do not have a second monitor within reach so I cannot test what you indicate, but if so, then it is a Qt bug so I recommend reporting it.

customproperty.py

from PySide2 import QtCore, QtGui, QtQml


class PropertyMeta(type(QtCore.QObject)):
    def __new__(cls, name, bases, attrs):
        for key in list(attrs.keys()):
            attr = attrs[key]
            if not isinstance(attr, Property):
                continue
            initial_value = attr.initial_value
            type_ = type(initial_value)
            notifier = QtCore.Signal(type_)
            attrs[key] = PropertyImpl(
                initial_value, name=key, type_=type_, notify=notifier
            )
            attrs[signal_attribute_name(key)] = notifier
        return super().__new__(cls, name, bases, attrs)


class Property:
    """ Property definition.

    This property will be patched by the PropertyMeta metaclass into a PropertyImpl type.
    """

    def __init__(self, initial_value, name=""):
        self.initial_value = initial_value
        self.name = name


class PropertyImpl(QtCore.Property):
    """ Actual property implementation using a signal to notify any change. """

    def __init__(self, initial_value, name="", type_=None, notify=None):
        super().__init__(type_, self.getter, self.setter, notify=notify)
        self.initial_value = initial_value
        self.name = name

    def getter(self, inst):
        return getattr(inst, value_attribute_name(self.name), self.initial_value)

    def setter(self, inst, value):
        last_value = getattr(inst, self.name)
        if last_value != value:
            setattr(inst, value_attribute_name(self.name), value)
            notifier_signal = getattr(inst, signal_attribute_name(self.name))
            notifier_signal.emit(value)


def signal_attribute_name(property_name):
    """ Return a magic key for the attribute storing the signal name. """
    return f"_{property_name}_prop_signal_"


def value_attribute_name(property_name):
    """ Return a magic key for the attribute storing the property value. """
    return f"_{property_name}_prop_value_"

main.py

import os
import random

from PySide2 import QtCore, QtGui, QtQml

from customproperty import Property, PropertyMeta


def whatIsArea(l, w):
    result = float(l * w) + random.random()
    return result


class Utils(QtCore.QObject):
    @QtCore.Slot()
    def doSomething(self):
        print("Do something")


class Backend(QtCore.QObject, metaclass=PropertyMeta):
    length = Property(0)
    width = Property(0)
    area = Property(0)
    is_continuous = Property(False)

    @QtCore.Slot()
    def calculate_area(self):
        self.area = whatIsArea(self.length, self.width)


CURRENT_DIR = os.path.dirname(__file__)

QtQml.qmlRegisterType(Backend, "MyLibrary", 1, 0, "Backend")


def main():
    import sys

    app = QtGui.QGuiApplication(sys.argv)

    engine = QtQml.QQmlApplicationEngine()

    utils = Utils()
    engine.rootContext().setContextProperty("Utils", utils)

    engine.load(os.path.join(CURRENT_DIR, "main.qml"))

    if not engine.rootObjects():
        sys.exit(-1)
    sys.exit(app.exec_())


if __name__ == "__main__":
    main()
import QtQuick 2.13
import QtQuick.Window 2.13
import QtQuick.Controls 2.2

import MyLibrary 1.0

Window {
    title: qsTr("Hello World")
    width: 640
    height: 480
    visible: true

    Backend{
        id: backend
        length: 55
        width: 87
        Component.onCompleted: calculate_area()
    }

    Column {
        id: column
        x: 131
        y: 63
        width: 72
        height: 263

        TextInput {
            id: textInput_length
            width: 80
            height: 20
            text: backend.length
            font.pixelSize: 12
        }

        Slider {
            id: slider_length
            to: 100
            orientation: Qt.Vertical
            value: backend.length
            onValueChanged: {
                backend.length = value
                if (backend.is_continuous) { backend.calculate_area() }
            }
        }
    }

    Column {
        id: column1
        x: 249
        y: 63
        width: 72
        height: 263

        TextInput {
            id: textInput_width
            width: 80
            height: 20
            text: backend.width
            font.pixelSize: 12
        }

        Slider {
            id: slider_width
            to: 100
            value: backend.width
            orientation: Qt.Vertical
            onValueChanged: {
                backend.width = value
                if (backend.is_continuous) { backend.calculate_area() }
            }
        }
    }

    Row {
        id: row
        x: 110
        y: 332
        width: 274
        height: 53


        Slider {
            id: slider_area
            to: 10000
            value: backend.area
        }

        Label {
            id: label_area
            text: backend.area
        }
    }


    Switch {
        id: continuous_switch
        x: 343
        y: 149
        text: qsTr("Continuous calculate")
        checked: backend.is_continuous
        onCheckedChanged: backend.is_continuous = checked
    }

    Button {
        id: button
        x: 383
        y: 205
        text: qsTr("Calculate")
        onClicked: {
            backend.calculate_area()
        }
    }

    Label {
        id: label
        x: 131
        y: 23
        text: qsTr("Length")
        font.pointSize: 12
    }

    Label {
        id: label1
        x: 249
        y: 23
        text: qsTr("Width")
        font.pointSize: 12
    }

    Label {
        id: label3
        x: 196
        y: 377
        text: qsTr("Area")
        font.pointSize: 12
    }

    Component.onCompleted: Utils.doSomething()

}
eyllanesc
  • 235,170
  • 19
  • 170
  • 241
  • Thank for for the detailed answers. My initial follow up question is regarding how the two-way binded variables (_Property_ object) are created in the class _Backend_ in your example code. It seems I can only create these Property objects as class attributes and not as an instance attribute. Therefore, I can NOT do `self.length = Property(0)` inside the `__init__()` method of the _Backend_ class. I do have some additional follow up questions but I am still trying to understand and digest your example code which uses metaclasses. – Zythyr Jun 15 '20 at 18:27
  • @Zythyr 1) The properties are declared in this way, 2) Qt only exposes some attributes to QML, remember that Qt is written in C ++ so the scope concepts: private, public and protected exist and that is also extrapolated to their binding, also that helps us to separate the logic easily. 3) Metaclasses are used to add functionalities to a class. 4) If you have follow-up questions, please do so in another post, and try not to be very broad as I pointed out at the beginning of my answer. Also if you have n questions then you must create a post since you will be more likely to have someone answer y – eyllanesc Jun 15 '20 at 18:46