Your first Python + Kirigami application

Learn how to write an application with PyQt/PySide.

Prerequisites

For the purposes of this tutorial, we will create the application on Linux.

To use Python together with QML, we can use either PySide, the official Python bindings for the Qt framework, or PyQt, a project by Riverbank Computing that allows you to write Qt applications using Python.

You will need Python installed, and that will be the case in any major Linux distribution. But instead of using pip to install PySide/PyQt and Kirigami, you will need to install them from your distribution. This ensures PySide/PyQt and Kirigami will have been built for the same Qt version, allowing you to package it easily. Any other dependencies can be installed from pip in a Python virtual environment later.

logo of Linux operating system ManjaroManjarologo of Linux operating system Arch LinuxArch
sudo pacman -S python-pyqt6 pyside6 kirigami flatpak-builder qqc2-desktop-style appstream
logo of Linux operating system openSUSEOpenSUSE
sudo zypper install python3-qt6 python3-pyside6 kf6-kirigami-devel flatpak-builder qqc2-desktop-style AppStream-compose
logo of Linux operating system FedoraFedora
sudo dnf install python3-pyqt6 python3-pyside6 kf6-kirigami-devel flatpak-builder qqc2-desktop-style appstream-compose

This tutorial works with our tutorial about building software with distrobox.

Structure

The application will be a simple Markdown viewer called simplemdviewer.

By the end of the tutorial, the project will look like this:

simplemdviewer/
├── README.md
├── LICENSE.txt
├── MANIFEST.in                        # To add our QML file
├── pyproject.toml                     # The main file to manage the project
├── org.kde.simplemdviewer.desktop
├── org.kde.simplemdviewer.json
├── org.kde.simplemdviewer.svg
├── org.kde.simplemdviewer.metainfo.xml
└── src/
    ├── __init__.py                    # To import the src/ directory as a package
    ├── __main__.py                    # To signal simplemdviewer_app as the entrypoint
    ├── simplemdviewer_app.py
    ├── md_converter.py
    └── qml/
        └── main.qml

All of the metadata will be in the root folder, while the actual code will be in src/:

simplemdviewer/
└── src/
    ├── simplemdviewer_app.py
    ├── md_converter.py
    └── qml/
        └── main.qml

Setting up the project

The UI will be created in QML and the logic in Python. Users will write some Markdown text, press a button, and the formatted text will be shown below it.

It is recommended to use a virtual environment. The venv module provides support for virtual environments with their own site directories, optionally isolated from system site directories.

Create a directory and a virtual environment for the project:

mkdir simplemdviewer
cd simplemdviewer
python3 -m venv --system-site-packages env/

A new virtual environment will be created in env/, pulling the required Python modules straight from your distribution packages. Activate it using the activate script:

source env/bin/activate

We can verify that we are working in a virtual environment by checking the VIRTUAL_ENV environment variable with env | grep VIRTUAL_ENV.

It’s time to write some code. At first the application will consist of two files: a file with the QML description of the user interface, and a Python file that loads the QML file.

Create a new directory simplemdviewer/src/ and add a new simplemdviewer_app.py file in this directory:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
#!/usr/bin/env python3

import os
import sys
import signal
from PySide6.QtGui import QGuiApplication
from PySide6.QtCore import QUrl
from PySide6.QtQml import QQmlApplicationEngine

def main():
    """Initializes and manages the application execution"""
    app = QGuiApplication(sys.argv)
    engine = QQmlApplicationEngine()

    """Needed to close the app with Ctrl+C"""
    signal.signal(signal.SIGINT, signal.SIG_DFL)

    """Needed to get proper KDE style outside of Plasma"""
    if not os.environ.get("QT_QUICK_CONTROLS_STYLE"):
        os.environ["QT_QUICK_CONTROLS_STYLE"] = "org.kde.desktop"

    base_path = os.path.abspath(os.path.dirname(__file__))
    url = QUrl(f"file://{base_path}/qml/main.qml")
    engine.load(url)

    if len(engine.rootObjects()) == 0:
        quit()

    app.exec()


if __name__ == "__main__":
    main()
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
#!/usr/bin/env python3

import os
import sys
import signal
from PyQt6.QtGui import QGuiApplication
from PyQt6.QtCore import QUrl
from PyQt6.QtQml import QQmlApplicationEngine

def main():
    """Initializes and manages the application execution"""
    app = QGuiApplication(sys.argv)
    engine = QQmlApplicationEngine()

    """Needed to close the app with Ctrl+C"""
    signal.signal(signal.SIGINT, signal.SIG_DFL)

    """Needed to get proper KDE style outside of Plasma"""
    if not os.environ.get("QT_QUICK_CONTROLS_STYLE"):
        os.environ["QT_QUICK_CONTROLS_STYLE"] = "org.kde.desktop"

    base_path = os.path.abspath(os.path.dirname(__file__))
    url = QUrl(f"file://{base_path}/qml/main.qml")
    engine.load(url)

    if len(engine.rootObjects()) == 0:
        quit()

    app.exec()


if __name__ == "__main__":
    main()

We have just created a QGuiApplication object that initializes the application and contains the main event loop. The QQmlApplicationEngine object loads the main.qml file.

Create a new src/qml/main.qml file that specifies the UI of the application:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
import QtQuick
import QtQuick.Layouts
import QtQuick.Controls as Controls
import org.kde.kirigami as Kirigami

Kirigami.ApplicationWindow {
    id: root

    title: qsTr("Simple Markdown viewer")

    minimumWidth: Kirigami.Units.gridUnit * 20
    minimumHeight: Kirigami.Units.gridUnit * 20
    width: minimumWidth
    height: minimumHeight

    pageStack.initialPage: initPage

    Component {
        id: initPage

        Kirigami.Page {
            title: qsTr("Markdown Viewer")

            ColumnLayout {
                anchors {
                    top: parent.top
                    left: parent.left
                    right: parent.right
                }
                Controls.TextArea {
                    id: sourceArea

                    placeholderText: qsTr("Write some Markdown code here")
                    wrapMode: Text.WrapAnywhere
                    Layout.fillWidth: true
                    Layout.minimumHeight: Kirigami.Units.gridUnit * 5 
                }

                RowLayout {
                    Layout.fillWidth: true

                    Controls.Button {
                        text: qsTr("Format")

                        onClicked: formattedText.text = sourceArea.text
                    }

                    Controls.Button {
                        text: qsTr("Clear")

                        onClicked: {
                            sourceArea.text = ""
                            formattedText.text = ""
                        }
                    }
                } 

                Text {
                    id: formattedText

                    textFormat: Text.RichText
                    wrapMode: Text.WordWrap
                    text: sourceArea.text

                    Layout.fillWidth: true
                    Layout.minimumHeight: Kirigami.Units.gridUnit * 5
                }
            }
    	}
    }
}

First test run

We have just created a new QML-Kirigami-Python application. Run it:

python3 simplemdviewer_app.py

At the moment we have not used any interesting Python stuff. In reality, the application can also run as a standalone QML app:

QT_QUICK_CONTROLS_STYLE=org.kde.desktop qml main.qml

It does not format anything; if we click on "Format" it just spits the unformatted text into a text element.

Adding Markdown functionality

Let’s add some Python logic: a simple Markdown converter in a Python, QObject derivative class.

We need this to be a QObject-derived class in order to make use of Qt's powerful signals and slots.

Create a new md_converter.py file in the simplemdviewer/src/ directory:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
from markdown import markdown
from PySide6.QtCore import QObject, Signal, Slot, Property
from PySide6.QtQml import QmlElement

QML_IMPORT_NAME = "org.kde.simplemdviewer"
QML_IMPORT_MAJOR_VERSION = 1

@QmlElement
class MdConverter(QObject):
    """A simple markdown converter"""

    sourceTextChanged = Signal()

    def __init__(self, _source_text=""):
        super().__init__()
        self._source_text = _source_text

    @Property(str, notify=sourceTextChanged)
    def sourceText(self):
        return self._source_text

    @sourceText.setter
    def sourceText(self, val):
        self._source_text = val
        self.sourceTextChanged.emit()

    @Slot(result=str)
    def mdFormat(self):
        return markdown(self._source_text)
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
from markdown import markdown
from PyQt6.QtCore import QObject, pyqtSignal, pyqtSlot, pyqtProperty

class MdConverter(QObject):
    """A simple markdown converter"""

    sourceTextChanged = pyqtSignal()

    def __init__(self, _source_text):
        super().__init__()
        self._source_text = ""

    def readSourceText(self):
        return self._source_text

    def setSourceText(self, val):
        self._source_text = val
        self.sourceTextChanged.emit()

    sourceText = pyqtProperty(
        str, readSourceText, setSourceText, notify=sourceTextChanged
    )

    @pyqtSlot(result=str)
    def mdFormat(self):
        return markdown(self._source_text)

The MdConverter class contains the _source_text member variable. The sourceText property exposes _source_text to the QML system by using a getter/accessor and a setter/modifier.

In the PySide6 case, we use a Property decorator (beginning with @) in lines 18 and 22. Note that the name of the function needs to be the same for both getter and setter: the getter is marked with @Property and the setter is marked as functionName.setter.

In the PyQt6 case, we use a Property as a function by creating a property with the pyqtProperty() function in lines 20-22. Note that the name of the getter and setter needs to be different here.

When setting the sourceText property, the sourceTextChanged signal is emitted to let QML know that the property has changed. Note that the sourceTextChanged needs to be marked with notify= before it can be emitted with .emit().

The mdFormat() function returns the Markdown-formatted text and it has been declared as a slot so as to be invokable by the QML code.

The markdown Python package takes care of formatting. Let’s install it in our virtual environment:

python3 -m pip install markdown

Now, update the simplemdviewer_app.py file to:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
#!/usr/bin/env python3

import os
import sys
import signal
from PySide6.QtGui import QGuiApplication
from PySide6.QtCore import QUrl
from PySide6.QtQml import QQmlApplicationEngine
from md_converter import MdConverter  # noqa: F401

def main():
    """Initializes and manages the application execution"""
    app = QGuiApplication(sys.argv)
    engine = QQmlApplicationEngine()

    """Needed to close the app with Ctrl+C"""
    signal.signal(signal.SIGINT, signal.SIG_DFL)

    """Needed to get proper KDE style outside of Plasma"""
    if not os.environ.get("QT_QUICK_CONTROLS_STYLE"):
        os.environ["QT_QUICK_CONTROLS_STYLE"] = "org.kde.desktop"

    base_path = os.path.abspath(os.path.dirname(__file__))
    url = QUrl(f"file://{base_path}/qml/main.qml")
    engine.load(url)

    if len(engine.rootObjects()) == 0:
        quit()

    app.exec()


if __name__ == "__main__":
    main()
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
#!/usr/bin/env python3

import os
import sys
import signal
from PyQt6.QtGui import QGuiApplication
from PyQt6.QtCore import QUrl
from PyQt6.QtQml import QQmlApplicationEngine, qmlRegisterType
from md_converter import MdConverter

def main():
    """Initializes and manages the application execution"""
    app = QGuiApplication(sys.argv)
    engine = QQmlApplicationEngine()

    """Needed to close the app with Ctrl+C"""
    signal.signal(signal.SIGINT, signal.SIG_DFL)

    """Needed to get proper KDE style outside of Plasma"""
    if not os.environ.get("QT_QUICK_CONTROLS_STYLE"):
        os.environ["QT_QUICK_CONTROLS_STYLE"] = "org.kde.desktop"

    qmlRegisterType(MdConverter, "org.kde.simplemdviewer", 1, 0, "MdConverter")

    base_path = os.path.abspath(os.path.dirname(__file__))
    url = QUrl(f"file://{base_path}/qml/main.qml")
    engine.load(url)

    if len(engine.rootObjects()) == 0:
        quit()

    app.exec()


if __name__ == "__main__":
    main()

The Python import from md_converter import MdConverter in simplemdviewer_app.py takes care of making both Python and the QML engine aware of the new MdConverter. In PySide we add # noqa: F401 just so later on linters don't complain about the unused import. In PyQt the import is used in line 23.

In PyQt, the qmlRegisterType() function registers the MdConverter type in the QML system, under the import name org.kde.simplemdviewer, version 1.0.

In PySide, this registration is done in the file where the class is defined, namely through the @QmlElement decorator in md_converter.py. Let's revisit it:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
from markdown import markdown
from PySide6.QtCore import QObject, Signal, Slot, Property
from PySide6.QtQml import QmlElement

QML_IMPORT_NAME = "org.kde.simplemdviewer"
QML_IMPORT_MAJOR_VERSION = 1

@QmlElement
class MdConverter(QObject):
    """A simple markdown converter"""

The import name and version of the MdConverter type is set through the variables QML_IMPORT_NAME and QML_IMPORT_MAJOR_VERSION.

Change main.qml to:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
import QtQuick
import QtQuick.Layouts
import QtQuick.Controls as Controls
import org.kde.kirigami as Kirigami
import org.kde.simplemdviewer 1.0

Kirigami.ApplicationWindow {
    id: root

    title: qsTr("Simple Markdown viewer")

    minimumWidth: Kirigami.Units.gridUnit * 20
    minimumHeight: Kirigami.Units.gridUnit * 20
    width: minimumWidth
    height: minimumHeight

    pageStack.initialPage: initPage

    Component {
        id: initPage

        Kirigami.Page {
            title: qsTr("Markdown Viewer")

            MdConverter {
                id: mdconverter

                sourceText: sourceArea.text
            }
  
            ColumnLayout {
                anchors {
                    top: parent.top
                    left: parent.left
                    right: parent.right
                }
                Controls.TextArea {
                    id: sourceArea

                    placeholderText: qsTr("Write some Markdown code here")
                    wrapMode: Text.WrapAnywhere
                    Layout.fillWidth: true
                    Layout.minimumHeight: Kirigami.Units.gridUnit * 5 
                }

                RowLayout {
                    Layout.fillWidth: true

                    Controls.Button {
                        text: qsTr("Format")

                        onClicked: formattedText.text = mdconverter.mdFormat()
                    }

                    Controls.Button {
                        text: qsTr("Clear")

                        onClicked: {
                            sourceArea.text = ""
                            formattedText.text = ""
                        }
                    }
                } 

                Text {
                    id: formattedText

                    textFormat: Text.RichText
                    wrapMode: Text.WordWrap

                    Layout.fillWidth: true
                    Layout.minimumHeight: Kirigami.Units.gridUnit * 5
                }
            }
    	}
    }
}

The updated QML code:

  1. imports the org.kde.simplemdviewer library
  2. creates an MdConverter object
  3. updates the onClicked signal handler of the Format button to call the mdFormat() function of the converter object

Final test run

At last, test your new application:

python3 simplemdviewer_app.py

Play with adding some Markdown text:

Hooray!