Search code examples
python-3.xqmlpyside6qt6qqmlapplicationengine

QML and PySide6 "TypeError: Cannot read property 'x' of null"


I am working on an application front-end written in PySide6 that uses Qt Widgets for most of the GUI, but I am trying to add some QML dialogs generated using data sent from a separate back-end.

When trying to launch a QML file, I encounter the following errors:

file:///C:/Users/user/dev/qml_dialogs/date_range_dialog/DateRangeDialog.qml:28: TypeError: Cannot read property 'width' of null
file:///C:/Users/user/dev/qml_dialogs/date_range_dialog/DateRangeDialog.qml:71: TypeError: Cannot read property 'endDateModel' of null
file:///C:/Users/user/dev/qml_dialogs/date_range_dialog/DateRangeDialog.qml:52: TypeError: Cannot read property 'startDateModel' of null
file:///C:/Users/user/dev/qml_dialogs/date_range_dialog/DateRangeDialog.qml:31: TypeError: Cannot read property 'height' of null

The combo boxes in my QML ApplicationWindow only show the first of the string items stored in the QStringListModel corresponding to dateRangeDialog.startDateModel, which is referenced in QML as dialog.startDateModel.

After following some of the documentation from https://doc.qt.io/qtforpython-6/PySide6/QtCore/Property.html https://doc.qt.io/qt-6/qtqml-cppintegration-exposecppstate.html for help with the classes and properties, I created a DateRangeDialog class to expose the properties to the QML.

I am trying to use the DialogManager to encapsulate the QQmlApplicationEngine, set the root context, and load the QML file, since the architecture of the front-end doesn't allow for this to happen in the main function.

Below is a minimal reproducible example using the DialogManager, DateRangeDialog, and a DateRangeDialog.qml file, all in the same directory, along with the style.qrc file, __init__.py, etc.

Any advice on how to refactor these classes so that the height, width, and date range properties are accessible from the QML file upon execution?

main.py

import sys

from PySide6.QtWidgets import QApplication

from dialog_manager import DialogManager

import style_rc


def main() -> None:
    app = QApplication(sys.argv)
    dialogManager = DialogManager()
    dialogManager.loadDialog(
            name="DateRangeDialog",
            width=800, 
            height=180, 
            startDates=["14/08/23", "15/08/23"], 
            endDates=["15/08/23"]
    )

    sys.exit(app.exec())


if __name__ == "__main__":
    main()

dialog_manager.py

from pathlib import Path

from PySide6.QtCore import QObject, QStringListModel
from PySide6.QtQml import QQmlApplicationEngine

from date_range_dialog import DateRangeDialog


class DialogManager(QObject):
    _engine: QQmlApplicationEngine

    def __init__(self):
        QObject.__init__(self)
        self._engine = QQmlApplicationEngine()

    def loadDialog(
            self,
            name: str,
            width: float,
            height: float,
            startDates: list[str],
            endDates: list[str]
    ):
        dialog = DateRangeDialog()
        dialog.width = width
        dialog.height = height
        dialog.startDateModel = QStringListModel(startDates)
        dialog.endDateModel = QStringListModel(endDates)

        self._setRootContext(dialog)
        self._loadQmlFile(name)

        root = self._getQQmlEngineRoot()
        try: 
            root.outputDateRange.connect(dialog.outputDateRange)
        except AttributeError as e:
            print(e)

    def _setRootContext(self, dialog: DateRangeDialog):
        self._engine.rootContext().setContextProperty("dialog", dialog)

    def _loadQmlFile(self, qmlName: str) -> None:
        qmlFile = Path(__file__).parent / f"{qmlName}.qml"
        self._engine.load(qmlFile)

    def _getQQmlEngineRoot(self) -> QObject:
        if not self._engine.rootObjects():
            raise AttributeError("Root objects for QQmlApplicationEngine not found")

        root = QObject()
        for rootObject in self._engine.rootObjects():
            if rootObject.inherits("QWindow"): 
                root = rootObject
                break

        return root
       

date_range_dialog.py

rom PySide6.QtCore import Property, QObject, QStringListModel, Signal


class DateRangeDialog(QObject):
    widthChanged = Signal(float)
    heightChanged = Signal(float)
    startDateModelChanged = Signal(QObject)
    endDateModelChanged = Signal(QObject)

    def __init__(self):
        QObject.__init__(self)
        self._width = 800 
        self._height = 180
        self._startDateModel = QStringListModel()
        self._endDateModel = QStringListModel()

    def outputDateRange(self, start_date: str, end_date: str) -> None:
        print(f"Start Date: {start_date}, End Date: {end_date}")
    
    @Property(float, notify=widthChanged)
    def width(self) -> float:
        return self._width

    @width.setter
    def width(self, width: float) -> None:
        if self._width != width:
            self._width = width
            self.widthChanged.emit(width)

    @Property(float, notify=heightChanged)
    def height(self) -> float:
        return self._height

    @height.setter
    def height(self, height: float) -> None:
        if self._height != height:
            self._height = height
            self.heightChanged.emit(height)

    @Property(QObject, notify=startDateModelChanged)
    def startDateModel(self):
        return self._startDateModel

    @startDateModel.setter
    def startDateModel(self, startDateModel: QStringListModel) -> None:
        if self._startDateModel != startDateModel:
            self._startDateModel = startDateModel
            self.startDateModelChanged.emit(startDateModel)

    @Property(QObject, notify=endDateModelChanged)
    def endDateModel(self):
        return self._endDateModel

    @endDateModel.setter
    def endDateModel(self, endDateModel: QStringListModel) -> None:
        if self._endDateModel != endDateModel:
            self._endDateModel = endDateModel
            self.endDateModelChanged.emit(endDateModel)

DateRangeDialog.qml

import QtQuick
import QtQuick.Controls
import QtQuick.Window

ApplicationWindow {
    id: root

    signal buttonClicked(string buttonText);
    signal outputDateRange(string startDate, string endDate);

    Component.onCompleted: { 
        console.log("Initialised DateRangeDialog")
        root.buttonClicked.connect(closeDialog); 
    }
    function closeDialog(buttonText) {
        console.log(buttonText + " clicked");

        if (buttonText === "OK") {
            var startDate = startDateComboBox.currentText;
            var endDate = endDateComboBox.currentText;
            root.outputDateRange(startDate, endDate);
        }

        close();
    }

    visible: true
    width: dialog.width
    minimumWidth: width
    maximumWidth: width
    height: dialog.height
    minimumHeight: height
    maximumHeight: height
    flags: Qt.Dialog
    title: qsTr("Enter Report Range")

    Label {
        id: startDateLabel

        anchors.right: startDateComboBox.left
        anchors.rightMargin: parent.width / 20
        anchors.verticalCenter: startDateComboBox.verticalCenter
        text: qsTr("Start Date: ")
    }
    ComboBox {
        id: startDateComboBox

        anchors.right: parent.horizontalCenter
        anchors.rightMargin: parent.width / 40
        anchors.bottom: parent.verticalCenter
        anchors.bottomMargin: parent.height / 8
        model: dialog.startDateModel
        textRole: "display"
    }

    Label {
        id: endDateLabel

        anchors.left: parent.horizontalCenter
        anchors.leftMargin: parent.width / 40
        anchors.verticalCenter: endDateComboBox.verticalCenter
        text: qsTr("End Date: ")
    }
    ComboBox {
        id: endDateComboBox

        anchors.left: endDateLabel.right
        anchors.leftMargin: parent.width / 20
        anchors.bottom: parent.verticalCenter
        anchors.bottomMargin: parent.height / 8
        model: dialog.endDateModel
        textRole: "display"
    }

    Button {
        id: okButton

        onClicked: root.buttonClicked(text);

        anchors.right: parent.horizontalCenter
        anchors.rightMargin: parent.width / 40
        anchors.top: parent.verticalCenter
        anchors.topMargin: parent.height / 6
        text: qsTr("OK")
    }

    Button {
        id: cancelButton

        onClicked: root.buttonClicked(text);

        anchors.left: parent.horizontalCenter
        anchors.leftMargin: parent.width / 40
        anchors.top: parent.verticalCenter
        anchors.topMargin: parent.height / 6
        text: qsTr("Cancel")
    }
}

Solution

  • See the comment from @musicamente for correct answer. Assigning the dialog instance as an attribute of DialogManager prevented the dialog instance from being garbage collected, and it remains as an accessible component in the QML.

    See corrected DialogManager class below.

    dialog_manager.py

    from pathlib import Path
    
    from PySide6.QtCore import QObject, QStringListModel
    from PySide6.QtQml import QQmlApplicationEngine
    
    from date_range_dialog import DateRangeDialog
    
    
    class DialogManager(QObject):
        _engine: QQmlApplicationEngine
        _dialog: DateRangeDialog
    
        def __init__(self):
            QObject.__init__(self)
            self._engine = QQmlApplicationEngine()
    
        def loadDialog(
                self,
                name: str,
                width: float,
                height: float,
                title: str,
                startDates: list[str],
                endDates: list[str]
        ):
            self.dialog = DateRangeDialog()
            self.dialog.width = width
            self.dialog.height = height
            self.dialog.title = title
            self.dialog.startDateModel = QStringListModel(startDates)
            self.dialog.endDateModel = QStringListModel(endDates)
    
            self._setRootContext(self.dialog)
            self._loadQmlFile(name)
    
            root = self._getQQmlEngineRoot()
            try: 
                root.outputDateRange.connect(self.dialog.outputDateRange)
            except AttributeError as e:
                print(e)
    
        def _setRootContext(self, dialog: DateRangeDialog):
            self._engine.rootContext().setContextProperty("dialog", dialog)
    
        def _loadQmlFile(self, qmlName: str) -> None:
            qmlFile = Path(__file__).parent / f"{qmlName}.qml"
            self._engine.load(qmlFile)
    
        def _getQQmlEngineRoot(self) -> QObject:
            if not self._engine.rootObjects():
                raise AttributeError("Root objects for QQmlApplicationEngine not found")
    
            root = QObject()
            for rootObject in self._engine.rootObjects():
                if rootObject.inherits("QWindow"): 
                    root = rootObject
                    break
    
            return root