QML Notes
This contains a collection of good to know things when working with QML and Python. Before anything else you should read this book in its entirety Qt6 QML Book.
Table of contents
- Property Binding
- Property pass-through and aliasing
- Python Function Return Values for QML
- Model Classes with Custom Attribute Names
- ListView
- Simple List Models
- ComboBox & Models
- Icon Colors
- Python Object to QML Life Time
- Base Plugin UI Layout
- Resource file generation
- Signal Inheritance
- Component vs. Item
- PyInstaller Considerations
- User Action Plugins
Property Binding
One of the nice properties of QML is that it has property binding, i.e. it can be directly fed with values from any QtObject based instance if it exposes its values properly. This can be achieved through the following:
from PySide6 import QtCore, QtQML
class DemoModel(QtCore.QObject):
variableChanged = QtCore.Signal()
def __init__(self, parent=None)
super().__init__(parent)
self._variable = "Test"
def _get_variable(self) -> str:
return self._variable
def _set_variable(self, value: str) -> None:
if value == self._variable:
return
self._variable = value
self.variableChanged.emit()
variable = QtCore.Property(
str,
fget=_get_variable,
fset=_set_variable,
notify=variableChanged
)
The above skeleton exposes the variable member to QML and will notify the QML side when the value of variable is changed. However, if the QML content representing the value of variable changes this is not sent back to the Python side. Apparently this two way communication is not supported by QML and given this was the case back in 2010 it does not seem likely to ever be added. In order to support such a two way synchronization the QML side needs to actively update the Python side.
Item {
property DemoModel model
TextField {
text: model.variable
onTextChanged: {
model.variable = text
}
}
}
The above QML snippet populates the textfield with the value of the variable from the above model class. Any changes to the value of variable via Python code will notify the QML side and update the visual representation accordingly. To send changes back to the Python model instance the onTextChanged signal needs to be added. To prevent a binding loop the _set_variable method needs to ensure the provided value is different from the currently stored one, as otherwise an event loop would be possible.
Property pass-through and aliasing
Complex widgets are often composed of multiple QML types which have properties that one wants to set on the widget rather than the subcomponent level. To achieve this the main part of the widget has to expose those properties via an alias property. The property being exposed does not have to be specifically set in the child component if it is a property that is exposed by that component by default.
Item {
property alias text: _label.text
RowLayout {
Label {
id: _label
}
}
}
Python Function Return Values for QML
It is possible to call Python functions which return a value from QML as long as these are defined as Slot in a QtCore derived class. The gremlin.ui.backend class is a good example of a class making use of this.
import random
from PySide6 import QtCore
class Backend(QtCore):
def __init__(self, parent=None):
super().__init__(parent)
@QtCore.Slot(int, int, result=int)
def randomInt(self, min_val: int, max_val: int) -> int:
return random.randint(min_val, max_val)
This allows the method to be called from within any QML file which has access to an instance of the Backend class.
Model Classes with Custom Attribute Names
Accessing data from a Python model via custom names is the more convenient than having to deal with possibly changing indices. This is readily supported by QML by specifying additional model roles in the Python model being visualized via QML.
from typing import Any, Dict
from PySide6 import QtCore, QtQML
class ColorModel(QtCore.QAbstractListModel):
roles = {
QtCore.Qt.UserRole + 1: QtCore.QByteArray("name".encode()),
QtCore.Qt.UserRole + 2: QtCore.QByteArray("rgb".encode()),
}
def __init__(self, parent: None):
super().__init__(parent)
self._colors = []
def rowCount(self, parent: QtCore.QModelIndex=...) -> int:
return len(self._colors)
def data(self, index: QtCore.QModelIndex, role: int=...) -> Any:
if role not in ColorModel.roles:
raise("Invalid role specified")
role_name = SimpleModel.roles[role].data().decode()
if role_name == "name":
return self._colors[index.row].name
elif role_name == "rgb":
return self._colors[index.row].rgb
def roleNames(self) -> Dict:
return ColorModel.roles
The above example specifies a simple class which holds colors. To permit QML to access the properties, i.e. name and rgb code, of each color via name the roles dictionary is defined and exposed. Without this there is no way to access these properties via name.
ListView
Frequently models will contain a list of identical items that need to be visualized. As these items might be taking up more space than the ListView component has in the UI it is capable of scrolling. To turn the ListView into a container that has a scroll bar and behaves properly, i.e. like a desktop application and not a phone app the following setup is recommended.
ListView {
id: idListView
anchors.fill: parent
// Make it behave like a sensible scrolling container
ScrollBar.vertical: ScrollBar {}
flickableDirection: Flickable.VerticalFlick
boundsBehavior: Flickable.StopAtBounds
// Content to visualize
model: model
delegate: idDelegate
}
Component {
id: idDelegate
...
}
Simple List Models
At times it is useful to return a simple list of strings to be displayed by a QML view or repeater. Providing the model via property causes some issues as QML is not happy with the actual data types exposed by PySide6. As such to specify the correct type of QVariantList the type information has to be provided as a string.
from PySide6 import QtCore
@QtCore.Property(type="QVariantList")
def listData():
return ["List", "of", "Strings"]
This model can now be used by any QML element that can handle a list model.
ComboBox & Models
A ComboBox widget can use a custom model class to display content or have access to more information. However, once such a model is used both the delegate and textRole fields have to be defined. The delegate should be based on a ItemDelegate to work properly while the textRole needs to specify the role of the model to use for the general display of items, even if the standard displayRole is provided by the model.
ComboBox {
model: _model // Specify the model to use
textRole: "display" // Model role to use when displaying drop down content
width: parent.width // Ensure the entry spans the drop down width
delegate: ItemDelegate { // Visualization of the actual entries
Label {
text: modelData.label
}
}
}
With elements that are driven by a modifiable model it is important to use the onActivated signal rather than the onChanged as the latter will cause a model update, overwriting the correct value and resulting in an endless modification loop.
Icon Colors
Icons on buttons and the like by default will be rendered black and white. This is caused by the tinting ability associated with colors. To display the icon’s actual colors the color property of the icon has to be set to transparent.
// This results in the icon being shown using the colors defined in the image file
Button {
icon.source: "path/to/icon.png"
icon.color: "transparent"
}
// This results in the icon being shown in red
Button {
icon.source: "path/to/icon.png"
icon.color: "red"
}
Python Object to QML Life Time
Returning a QML object instance from Python to QML code will in most cases fail to work as the Python object will be cleaned up, resulting in QML seeing a null object. The correct way to work around this is to use the parent parameter available to every QtObject based class. As such when creating an object in Python which is intended as a return type to QML UI code the parent parameter of the object should never be None but rather an instance of another QML object which will persist longer than the new object being created.
Base Plugin UI Layout
To make new plugins render properly in the UI the following template should be followed. If this is not done there is a good chance that the plugin will not use all available space or be drawn across other elements.
import QtQuick 2.14
import QtQuick.Controls 2.14
import QtQuick.Layouts 1.14
Item {
height: _layout.height
RowLayout {
id: _layout
anchors.left: parent.left
anchors.right: parent.right
Label {
Layout.preferredWidth: 150
text: "Description"
}
Label {
Layout.fillWidth: true
text: "Using the remaining space"
}
}
}
The above setup exploits the fact that when the plugin’s UI code is dynamically created the height of the plugin is retrieved from this UI element while the width is dictated by the parent element in which this UI element is embedded. This UI element is resized during creation to fit within the parent’s width.
Resource file generation
The pyside6-rcc program converts the contents of a QRC file into a python module which can be loaded and used later on. When a venv is used the program resides within the scripts folder. Invoking the program takes the following form.
.\venv\Scripts\pyside6-rcc.exe .\resources.qrc -o .\resources.py
Signal Inheritance
Dealing with signals in an inheritance hierarchy is somewhat annoying, as defining a property in a derived class using a signal defined in a parent doesn’t work. As such, signals and properties have to be defined in the same class. To make matters worse the definition of the property binds the setter and getter function of the class in which the property is defined, making it impossible to redirect to a derived class’s implementation. A solution around this is to have the actual setter/getter implementation be relegated to an implementation method which can be overridden in a derived class.
from PySide6 import QtCore
from PySide6.QtCore import Signal, Property
class Base(QtCore.QObject):
updated = Signal()
def __init__(self):
super().__init__()
self._value = ""
def _get_value(self) -> str:
return self._get_value_impl()
def _get_value_impl(self) -> str:
return self._value
def _set_value(self, new_value: str) -> None:
self._set_value_impl(new_value)
def _set_value_impl(self, new_value: str) -> None:
if self._value != new_value:
self._value = new_value
self.updated.emit()
value = Property(str, _get_value, _set_value, notify=updated)
class Derived(Base):
def __init__(self):
super().__init__()
def _get_value_impl(self) -> str:
return self.value.capitalize()
This scheme allows redefining the behavior by reimplementing the implementation method where desired.
Component vs. Item
A Component is effectively a class, i.e. a template of what an instantiation will look like. As such a Component does not represent a specific object but can be used to create them. This can happen via the use in a delegate which will create an instance for each entry in the model. Another option is for dynamic object creation via the createObject() function of the Component.
An Item on the other hand is an explicitly existing object that is rendered and exists as defined. Its main purpose is the creation of complex user defined widgets that can be integrated to build up a custom UI.
A typical use of Component definitions is to define a reusable component inside a QML file, rather than having to create an entirely new QML file that would implement the desired component.
PyInstaller Considerations
PyInstaller can’t necessarily find all the imports required by the action plugins and UI code as they are not necessarily imported. Therefore, all files falling into this category need to be listed as hidden imports in the joystick_gremlin.spec file such that they are analyzed for required imports. Failure to do so will result in the build completing but not working afterwards.
A way to find out what imports are active in a program you can use the following code snippet, which should give a good overview.
import sys, pprint
pprint.pprint(sorted(sys.modules.keys()))
Entries from this or other imports that should be analyzed need to be put in the hidden_imports variable inside the joystick_gremlin.spec file.
User Action Plugins
R14 allows specifying a folder that contains additional action plugins to show in the normal drop down. These actions need to be implemented following the same rules as the core actions, however, users can create their own and implement them independently of Gremlin Core.
Development Setup
- Create the following folder structure
user_actions/
+- custom_action_1
| +- ...
+- custom_action_2
| +- ...
+ __init__.py
- Create a development environment with the required dependencies, an easy way is to use this
pyproject.tomland use poetry- Optionally make the Gremlin repository visible to the project (for example via
Project -> Project Structure -> Add Content Rootin PyCharm)
- Optionally make the Gremlin repository visible to the project (for example via
- Develop your plugin and then point Gremlin at that folder to use it with the normal Gremlin version