Home | Lehre | Videos | Texte | Vorträge | Software | Person | Impressum, Datenschutzerklärung | Blog RSS

Stand: 2024-06-15
weitgehend formuliert von ChatGPT-4o

Grafische Oberflächen

PySide und Qt

PySide ist die offizielle Python-Bindung für das Qt-Framework, das eine der leistungsfähigsten und umfassendsten Klassenbibliotheken für die Entwicklung grafischer Benutzeroberflächen (Graphical User Interfaces: GUIs) darstellt. Ursprünglich von Nokia entwickelt und jetzt von der Qt Company gepflegt, bietet Qt eine plattformübergreifende Lösung, die es Entwicklern ermöglicht, Anwendungen zu schreiben, die auf Windows, macOS, Linux und weiteren Plattformen laufen. PySide ermöglicht es Python-Entwicklern, diese umfangreiche und vielseitige Bibliothek zu nutzen, um komplexe und interaktive GUIs zu erstellen, ohne sich tiefgehend mit den plattformabhängigen Details beschäftigen zu müssen. PySide6, die derzeit (Mai 2024) neueste Version, basiert auf Qt 6.

Wie schon von den anderen Bibliotheken bekannt, muss auch PySide erst installiert werden (möglichst in das virtuelle Environment): pip install pyside6 Neber der reinen Bibliothek werden dann auch Hilfsprogramme installiert, insbesondere der Qt Designer, mit dem man Fenster, Knöpfe usw. wie in einem Zeichenprogramm erzeugen und anordnen kann. Er lässt sich im virtuellen Environment über die Kommandozeile pyside6-designer starten. Wir werden hier aber nicht den Qt Designer verwenden, sondern, wie es üblich ist, die Elemente der Oberfläche als Code schreiben, weil so viel klarer ist und leichter zu bearbeiten ist, was wie zusammenhängt. Inzwischen kann man einfach der KI eine Beschreibung der gewünschten Oberfläche geben und erhält fast fehlerfrei diesen Code.

Qt und damit PySide stellt sich automatisch auf den Light/Dark-Modus und die Bildschirmauflösung ein. Im Regelfall legt man obendrein die grafische Oberfläche so an, dass die Elemente sich in ihrer Größe automatisch anpassen, um den Platz optimal zu nutzen. Das ist ein erster Schritt hin zum responsive design, bei dem dann vielleicht sogar Inhalte ein- oder ausgeblendet oder umpositioniert werden.

Hintergrund: GUI-Klassenbibliotheken

Klassenbibliotheken sind Sammlungen von vordefinierten Klassen und Funktionen, die Entwicklern helfen, häufige Aufgaben effizienter zu lösen. Sie stellen wiederverwendbare Komponenten bereit, die die Entwicklung beschleunigen und den Code konsistenter und leichter wartbar machen. In der GUI-Entwicklung bieten diese Bibliotheken nicht nur grundlegende Bausteine wie Widgets und Layout-Manager, sondern auch erweiterte Funktionen wie Event-Handling, Grafik-Rendering und Unterstützung für internationale Benutzeroberflächen. Durch die Nutzung solcher Bibliotheken kann man sich auf die Implementierung der spezifischen Logik ihrer Anwendung konzentrieren, anstatt sich mit den Details der Benutzeroberflächenprogrammierung und den Herausforderungen der plattformübergreifenden Kompatibilität auseinandersetzen zu müssen.

Klassenbibliotheken wie Qt und PySide bieten eine strukturierte und effiziente Möglichkeit, grafische Benutzeroberflächen zu entwickeln. Sie umfassen eine Vielzahl von vorgefertigten Widgets (Sammelbegriff für Schaltflächen, Eingabefelder, Menüs usw.) und Layouts. Diese Bibliotheken abstrahieren viele der komplexen Details der betriebssystemspezifischen GUI-Programmierung und stellen sicher, dass Anwendungen auf verschiedenen Betriebssystemen gleichartig aussehen und funktionieren. Neben PySide gibt es für Python andere beliebte GUI-Bibliotheken wie Tkinter, wxPython und Kivy, die jeweils ihre eigenen Vor- und Nachteile haben und für unterschiedliche Anwendungsfälle geeignet sind.

Beispiel: eine App für eine To-Do-Liste

Dies war der ursprüngliche Prompt: Schreibe mir ein PySide-Progrämmchen zur Verwaltung einer To-Do-Liste. Jedes To-Do hat eine Bearbeitungsfrist, einen Titel und die Information, ob es erledigt ist. Im Hauptfenster des Programms sieht man alle To-Dos gemäß ihrer Frist aufgelistet, kann ein neues erzeugen (dazu öffnet sich ein Extra-Fenster, um Frist und Titel einzugeben), kann To-Dos als erledigt markieren.

Das objektorientierte Design

Aus dem anfänglichen Prompt ist mit ein wenig Diskussion folgendes objektorientierte Design entstanden:

  1. MainWindow Diese Klasse repräsentiert das Hauptfenster der Anwendung. Sie erbt den Großteil ihrer Funktionalität von der in der PySide-Klassenbibliothek vorgefertigten Klasse QMainWindow. Sie verfügt nur über ein einziges Attribut, das sie nicht geerbt hat: Diese Klasse hat an eigenen, nicht geerbten Methoden folgende, allesamt geschützte:
  2. AddToDoDialog Diese Klasse repräsentiert den Dialog zum Hinzufügen eines neuen To-Do-Elements. Sie erbt den Großteil ihrer Funktionalität von der in der PySide-Klassenbibliothek vorgefertigten Klasse QDialog. Sie hat an eigenen, nicht geerbten Attributen nur öffenliche Attribute; diese werden nach erfolgter Eingabe zum Auslesen der Werte benutzt: Ihre einzige eigene, nicht geerbte Methode ist:
  3. ToDoItem Diese Klasse repräsentiert ein einzelnes To-Do-Element in der Liste alle To-Do-Elemente. Dazu erbt sie von dem allgemeinen Listenelement QListWidgetItem. Sie hat an eigenen, nicht geerbten Attributen nur öffenliche Attribute: Außerdem hat sie die eigenen, nicht geerbten Methoden:

Das Hauptprogramm

Der Rahmen um die Klassen (die man später in eigene Dateien auslagern würde!) sieht so aus:

import sys
from PySide6.QtWidgets import QApplication, QMainWindow, QVBoxLayout, QHBoxLayout, QPushButton, QListWidget, QListWidgetItem, QWidget, QDialog, QLabel, QLineEdit, QDateEdit, QDialogButtonBox
from PySide6.QtGui import QFont
from PySide6.QtCore import Qt, QDate, QTranslator, QLocale, QLibraryInfo
from typing import cast

class MainWindow(QMainWindow):
    # usw.

class AddToDoDialog(QDialog):
    # usw.

class ToDoItem(QListWidgetItem):
    # usw.

app = QApplication(sys.argv)
window = MainWindow()
window.show()
sys.exit(app.exec())

Es wird also Qt gestartet, indem man ein Objekt der Klasse QApplication erzeugt. Dies erhält die Kommandozeile sys.argv, mit der man das Programm aufgerufen hat. (So kann man beim Aufruf zum Beispiel noch Farben und Schritarten wählen.) Dann wird eine Instanz des Hauptfensters erzeugt und angezeigt. Was passiert, wenn man das mit mehreren Instanzen macht? Das Programm befindet sich nach dem kurzen Start die gesamte (!) Zeit in der Funktion app.exec(), wo es in der Ereignisschleife (event loop) auf Eingaben wartet und darauf reagiert. Beim Klick auf den Schließen-Knopf endet app.exec() und gibt die Zahl 0 oder aber einen Fehlercode zurück. Dieses Ergebnis wird an die Funktion sys.exit(...) gegeben, die das Programm dann mit diesem Code beendet.

Übersetzung der Oberfläche

Zunächst ist die Oberfläche englisch mit Cancel, Ctrl usw. beschriftet. Um auf Deutsch mit Abbrechen, Strg usw. umzustellen, sind folgende Zeilen (deren Inhalt Sie zunächst ignorieren können) sofort nach dem Erzeugen der QApplication nötig:

translator = QTranslator()
locale = QLocale(QLocale.German)
qt_translations_path = QLibraryInfo.location(QLibraryInfo.TranslationsPath)
translator.load(locale, 'qtbase', '_', qt_translations_path)
app.installTranslator(translator)

Die Klasse ToDoItem

Die einfachste der drei Klassen ist ToDoItem. Sie ist zum einen ein simpler Datenspeicher für die öffentlichen Attribute title, deadline und completed; zum anderen erbt sie von QListWidgetItem und kann damit auf dem Bildschirm als Listeneintrag in einem QListWidget verwendet werden:

class ToDoItem(QListWidgetItem):
    def __init__(self, title: str, deadline: QDate) -> None:
        super().__init__()
        self.title: str = title
        self.deadline: QDate = deadline
        self.completed = False
        self.setText(f'{self.deadline.toString(Qt.ISODate)}: {self.title}')
    
    def mark_as_done(self) -> None:
        self.completed = True
        font: QFont = self.font()
        font.setStrikeOut(True)
        self.setFont(font)

Die von QListWidgetItem geerbten Methoden setText und setFont stellen ein, welcher Text in welcher Schriftart für diesen Listeneintrag in der Liste auf dem Bildschirm erscheint.

Die Klasse AddToDoDialog

Weil die Klasse AddToDoDialog den Großteil ihrer Funktionalität von QDialog aus der Klassenbibliothek erbt, benötigt sie nichts anderes als einen recht kurzen Initialisierer:

class AddToDoDialog(QDialog):
    def __init__(self, parent: QWidget) -> None:
        super().__init__(parent)

        self.setWindowTitle('Neues To-Do')
        layout = QVBoxLayout(self)

        layout.addWidget(QLabel('Titel:'))
        self.title_input = QLineEdit()
        layout.addWidget(self.title_input)

        layout.addWidget(QLabel('Frist:'))
        self.deadline_input = QDateEdit()
        self.deadline_input.setCalendarPopup(True)
        self.deadline_input.setDate(QDate.currentDate())
        layout.addWidget(self.deadline_input)

        buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
        layout.addWidget(buttons)
        buttons.accepted.connect(self.accept)
        buttons.rejected.connect(self.reject)

Der Initalisierer erhält eine Referenz parent auf das logisch übergeordnete Widget, bei uns dann das Hauptfenster der Anwendung. Diese reicht sie an den Initialisierer der Elternklasse QDialog weiter. Diese Information wird dort je nach Typ des Widgets zum Beispiel zur Speicherverwaltung verwendet.

Ein QVBoxLayout wird erstellt und gleichzeitig im Dialogfenster (self) installiert. Das QVBoxLayout ordnet die enthaltenen Widgets vertikal von oben nach unten an. Solche Layout-Manager in Qt verwalten die Positionierung und Größenänderung der darin enthaltenen Widgets automatisch, um den Platz auf dem Bildschirm optimal zu nutzen. Andere Layout-Manager als QVBoxLayout erzeugen zum Beispiel Raster- oder Umblätter-Anordnungen.

Diesem layout werden fünf Widgets hinzugefügt und damit dann von oben nach unten angezeigt:

  1. Ein QLabel mit dem Text Titel: als Beschriftung für das Eingabefeld.
  2. Ein QLineEdit zur Eingabe des Titels des To-Do-Elements.
  3. Ein QLabel mit dem Text Frist: als Beschriftung für das Datumseingabefeld.
  4. Ein QDateEdit zur Eingabe des Fälligkeitsdatums, das auf das aktuelle Datum gesetzt ist und bei Klick einen Kalender anzeigt.
  5. Eine QDialogButtonBox mit OK- und Abbrechen-Schaltflächen.
Screenshot des AddToDoDialog

Die OK- und Abbrechen-Schaltflächen werden mit den (von der Klasse QDialog geerbten) Methoden accept und reject des Dialogs verdrahtet (connect). Sowohl accept als auch reject schließen das Dialogfenster. Obendrein speichern diese beiden Methoden, ob das, was man im Dialog eingestellt hat, gelten soll (accept) oder aber verworfen werden soll (reject). Diese Information wird dann in der Klasse MainWindow weiterverwendet.

Initialisierer der Klasse MainWindow

Die Klasse MainWindow verrichtet die Hauptarbeit des Programms. Sie erbt zwar viel Funktionalität von der Klasse QMainWindow aus der Klassenbibliothek, aber dennoch ist hier einiges an Anpassung nötig. Beginnen wir mit dem Initialisierer:

class MainWindow(QMainWindow):
    def __init__(self) -> None:
        super().__init__()

        self.setWindowTitle('To-Do-Liste')
        central_widget = QWidget()
        self.setCentralWidget(central_widget)
        layout = QVBoxLayout(central_widget)

        self._todo_list_widget = QListWidget()
        layout.addWidget(self._todo_list_widget)
        self._todo_list_widget.setSortingEnabled(True)

        buttons_layout = QHBoxLayout()
        layout.addLayout(buttons_layout)

        add_button = QPushButton('Neues To-Do...')
        buttons_layout.addWidget(add_button)
        add_button.clicked.connect(self._open_add_todo_dialog)

        mark_done_button = QPushButton('Als erledigt markieren')
        buttons_layout.addWidget(mark_done_button)
        mark_done_button.clicked.connect(self._mark_as_done)

Anders als der zuvor besprochene Dialog benötigt ein QMainWindow ein zentrales Widget. Typischerweise nimmt man hier das allgemeine QWidget, das keinen besonderen Inhalt hat. Wie beim zuvor besprochenen Dialog ist ein Layout nötig, um die in diesem zentralen Widget enthaltenen (Unter-)Widgets zu organisieren: ein QListWidget als Attribut self._todo_list_widget und ein Unter-Layout buttons_layout, das zwei Schaltflächen nebeneinander (QHBoxLayout statt QVBoxLayout!) enthält.

Screenshot des MainWindow

Die beiden Schaltflächen werden mit den Methoden open_add_todo_dialog und mark_as_done verdrahtet: Bei Klick auf eine davon ruft PySide die jeweilige Methode auf.

Methode zum Markieren eines Eintrags als erledigt

Zwar hat schon der einzelne Listeneintrag in seiner Klasse ToDoItem eine Methode, um sich als erledigt zu markieren, aber diese Methode muss nun noch aus dem Hauptfenster aufgerufen werden. Der Verständlichkeit halber nennen wir die Methode von MainWindow, die das tut, ebenfalls _mark_as_done, allerdings mit Unterstrich, denn diese Methode ist nicht zur Verwedung außerhalb von MainWindow gedacht:

    def _mark_as_done(self) -> None:
        current_item: ToDoItem | None = cast(ToDoItem | None, self._todo_list_widget.currentItem())
        if current_item is not None:
            current_item.mark_as_done()

Diese Methode _mark_as_done wurde schon im Initialisierer von MainWindow mit dem Klick auf die Als erledigt markieren-Schaltfläche verdrahtet. Auf diesen Klick hin wird also eine Referenz auf den derzeit ausgewählten Listeneintrag geholt. Ist nichts ausgewählt, erhält man die Nullreferenz None. Beachtet man letzteren Fall nicht, stürzt das Programm dann ab.

Die zweite Zeile wäre ohne Type Hints deutlich einfacher: current_item = self._todo_list_widget.currentItem(). Aber wenn man vergessen hat, das gefährliche None zu behandeln, liefern die Type Hints hier eine wichtige Warnung. Das cast(X, a) sagt dabei dem Typprüfer, dass man sich sicher ist, dass in a ein Objekt des Typs X steckt. (In anderen Sprachen wie C macht Casting tatsächlich etwas mit den Daten, nicht so in Python.) Bei cast(ToDoItem | None, ...) nutzen wir aus, dass in der Liste nur Objekte der Klasse ToDoItem enthalten sein können, denn andere fügen wir nicht hinzu. Aber die Nullreferenz None darf herauskommen, denn es könnte in der Liste nichts ausgewählt sein.

Methode zum Hinzufügen eines Eintrags

Nun fehlt noch eine Methode in MainWindow, um überhaupt Einträge in der To-Do-Liste zu erzeugen:

    def _open_add_todo_dialog(self) -> None:
        dialog = AddToDoDialog(self)
        if dialog.exec() == QDialog.Accepted:
            title: str = dialog.title_input.text()
            deadline: QDate = dialog.deadline_input.date()
            self._todo_list_widget.addItem(ToDoItem(title, deadline))

Eine Instanz des AddToDoDialog wird erzeugt und mit exec() ausgeführt, das heißt, der Dialog erscheint auf dem Bildschirm und wartet auf Eingaben; gleichzeitig ist das Hauptfenster blockiert. Beendet man den Dialog mit OK (oder Return-Taste) oder Abbrechen (oder Escape-Taste oder Klick auf das X), endetet dialog.exec(). (Vergleiche mit dem Verhalten von exec() im Hauptprogramm! Übrigens läuft das dialog.exec() hier als ein tief verschachtelter Funktionsaufruf innerhalb des exec() des Hauptprogramms. Warum?)

Der Aufruf dialog.exec() hat einen Rückgabewert. Dieser Rückgabewert zeigt an, ob der Dialog mit OK verlassen wurde oder nicht. Falls ersteres, holen wir title und deadline aus den Widgets des Dialogs. (Merke: Man kann auf die Widgets des Dialogs auch dann zugreifen, bevor oder nachdem der auf dem Bildschirm zu sehen ist/war!). Aus title und deadline bauen wir eine Instanz von ToDoItem und fügen jene der Liste hinzu. Warum erscheint der neue Eintrag in der Liste nicht am Ende, sondern so weit vorne oder hinten, wie es seinem Datum entspricht?

Weitere Schritte

Ergänzen Sie diese Funktionalitäten: