Comme je le dis toujours à mes étudiants, dans ce métier, de nos jours, il faut savoir tout faire, depuis l'assembleur (mécanique les mains dans le cambouis) jusqu'à l'Ajax (et la cuisine brille !). Voilà comment un ingénieur en informatique embarquée se retrouve à faire de l'IHM pour son backend (qui, je vous rassure chers lecteurs, est de type pilotage de matériel, il ne faut pas pousser non plus).

Une Interface Homme Machine, GUI en anglais, le nom fait peur ; de nos jours, on a plutôt tendance à faire dans le web service, à force de planter des projets de "progiciels" (déjà, quand on s'amuser à changer les noms, c'est qu'il y a du louche). Mais ce qui est demandé par le client, c'est du clickodrôme simple, efficace, qui propose d'exécuter différentes actions. Mon idée : Python + QT. Le résultat : impressionnant. Et en plus (surtout, pour le projet qui m'intéressait) : extrêmement portable (Linux/Windows/Mac/BSD/p'têtre même Solaris, c'est dire).

Prérequis : savoir coder en Python (ce que je prendrai réellement comme prérequis dans cet article : c'est assez facile à appréhender), et connaître la bibliothèque (notamment graphique, mais pas que) Qt de Trolltech, c'est-à-dire à présent Nokia (ce que l'on va étudier ici). Je vous rassure, avant de commencer ce projet, je ne connaissais ni l'un ni l'autre, mais j'en avais un bon a priori. Avec une autoformation rapide (d'abord en Python) et du développement itératif (on dit "méthode agile" pour faire style, de nos jours, mais je préfère toujours les histoires de bazar contre les cathédrales, même en développant seul -- avec mes multiples personnalités, certes, mais elles ne sont pas rémunérées --, ce qui ma foi réussi toujours -- on nomme ceci la "méthode à Gilles"), on y arrive très bien (ce que l'on dit toujours après avoir galéré deux heures à chercher une feature toute bête dans son esprit, et qui finalement marche du feu de dieu) ; merci le Qt Assistant (l'aide complète que l'on peut trouver dans le Qt Designer ou sur le net) et le Python reference manual ; et les (trop) nombreux sites web d'(entre-)aide (bonne chance pour trouver, cependant).

Commencez par installer Python, Qt, et le binding PyQt qui va bien avec vos versions Python/Qt (toute bonne distrib' Linux possède des paquets homogènes, sous windows ce n'est pas bien complexe, pour les autres débrouillez-vous vous l'avez cherché). Juste une précision sur les licences : Python est interpréteur de langage script, il n'y a donc aucun impact ; Qt est passé en LGPL sur ses dernières versions (>=4.5), cela n'a donc pas d'incidence sur votre code (seulement sur les éventuelles modifications que vous apportez à la bibliothèque même) ; en revanche, le binding est sous GPL, ce qui implique donc que votre code le sera aussi. La société privée qui développe le code (et il faut bien qu'ils mangent) propose en revanche une licence commerciale ; le business model est donc similaire à celui de Trolltech (peu) avant son rachat par Nokia (qui se moque à présent de faire de l'argent avec : ils voulaient surtout récupérer leurs technos pour faire... de l'embarqué bien sûr !).

On commence simplement : ouvrir le Qt Designer (Qt Creator maintenant, on n'arrête pas le progrès), et demander de créer une nouvelle fenêtre principale. Hop, la voilà qui s'affiche, on peut à présent la décorer de multiples boutons, label, menus, groupes, et j'en passe, le tout par simple glisser déplacer ; une fenêtre permet aussi de changer les propriétés des éléments graphiques, y compris la fenêtre elle-même (l'aide peut se trouver dans l'assistant). On enregistre : ça crée un fichier en ".ui" (user interface devine-t-on), qui n'est autre que du XML. Il va falloir passer ce fichier à la moulinette pour produire du code non pas C++ mais Python : pour cela, pyuic4 est à disposition (on suppose le fichier en ".ui" enregistré dans le répertoire "ui/").

pyuic4 -o ui_projwindow.py ui/projwindow.ui

Le fichier généré comporte alors une classe du nom de la fenêtre que vous avez créé (propriété Name renseignée dans le designer), par exemple "Ui_ProjWindow" ; celle-ci comporte deux fonctions, "setupUi" qui crée la fenêtre (chaque élément, bouton, label, etc, est créé et ajouté à la fenêtre, et ses propriétés initialisées), et "retranslateUi", qui appelée par la première fonction met en place les textes dans la langue que vous avez choisi. D'ailleurs, ce qui est chouette, avec Qt, c'est la localisation, et c'est ainsi que vous n'avez pas à vous soucier de remplacer "yes" par "oui" dans les boîtes de dialogue : il suffit simplement de connaître les bonnes incantations magiques (voir plus bas).

Nous avons donc une classe fenêtre, il va falloir l'appeler pour qu'elle s'affiche. La tradition agit en deux temps : d'une part une petite instanciation générale dans un fichier ".pyw" (l'extension est mal reconnu sous Linux, mais très bien sous windows ; donnez lui le droit de s'exécuter sous Linux, et appelez-le directement), et d'autre part le code massif de l'application en elle-même. Soit "proj.pyw" la première, et projwindow.py la seconde.

Disséquons proj.pyw :

#!/usr/bin/env python
# -*- coding: iso8859-1 -*-

# on importe de quoi créer une application graphique
from PyQt4.QtGui import QApplication
# on importe un minimum de la bilbiotheque (ce qui est dans QtCore n'est pas graphique)
from PyQt4.QtCore import Qt, QLocale, QTranslator, QLibraryInfo, QString
# on importe la classe qui va gerer l'application graphique
from projwindow import ProjWindow

# on entre ici des l'execution de ce fichier
if __name__ == "__main__":

# creation de l'application
    app = QApplication(sys.argv)

# incantations pour traduire la fenetre Qt dans la bonne langue
    locale = QLocale.system().name()
    translator = QTranslator ()
    translator.load(QString("qt_") + locale,
            QLibraryInfo.location(QLibraryInfo.TranslationsPath))
    app.installTranslator(translator)

# creation de l'interface graphique complete
    window = ProjWindow()
# affichage d'icelle (bloquant)
    window.show()
# en attente de fermeture
    sys.exit(app.exec_())

Jusqu'ici, rien de sorcier. On peut s'amuser à créer un hook (un crochet, quoi) qui affiche les exceptions Python dans une boîte de dialogue simple. Pour cela, après la création de l'application, insérer la ligne "sys.excepthook = excepthook", et définir au dessus de notre code la fonction "excepthook" :

import sys, os, traceback, time
from PyQt4.QtGui import QDialog, QLabel, QVBoxLayout

def excepthook(except_type, except_val, tbck):
    """ crochet python pour gerer les exceptions """
# on recupere la pile d'execution
    tb = traceback.format_exception(except_type, except_val, tbck)
# on cree une boite de dialogue avec un bouton de fermeture
    diag = QDialog(None, Qt.CustomizeWindowHint | Qt.WindowCloseButtonHint)
# defintion du titre
    diag.window().setWindowTitle("Une exception est survenue")
# la trace est une liste, on la concatene avec "join" et on la met dans un label
    lab = QLabel(''.join(tb))
# le label gere les retours a la ligne automatiques (evite d'avoir une boite qui fait la largeur de l'ecran)
    lab.setWordWrap(True)
# on donne la possibilite de selectionner le texte affiche avec la souris
    lab.setTextInteractionFlags(Qt.TextSelectableByMouse)
# on cree une boite verticale
    layout = QVBoxLayout()
# on ajoute le widget label cree au dessus au layout
    layout.addWidget(lab)
# on defini le layout par le precedent
    diag.setLayout(layout)
# on affiche la boite de dialogue
    diag.exec_()

On voit déjà avec cet exemple comment on crée "manuellement" une boîte de dialogue simple (sans bouton, juste un label) et comment on l'affiche. Plusieurs concepts apparaissent déjà : codé en C++, Qt est très hiérarchisé, et tout élément graphique hérite de la classe "QWidget". Les widget sont organisés selon des "layout", soit horizontaux soit verticaux, qui peuvent s'imbriquer. Voir à ce propos cette page très complète (en C++ forcément, mais vous êtes au moins bilingue). On remarque que le "exec()" de C++ est devenu "exec_()" en Python, tout simplement pour éviter une fâcheuse ambigüité, il faut ainsi prendre garde à ce genre d'exceptions très rares au niveau du binding. D'ailleurs il faut bien se rendre compte du travail de lien entre C++ et Python, qui peut ne pas être exempt de bugs (une mauvaise utilisation de la bibliothèque, au lieu de remonter une exception, peut très bien faire segfaulter l'application -- cela reste assez rare). Enfin, il faut remercier ce projet de gestion complète de graphes (un vrai bonheur d'inspiration) pour m'avoir donné l'idée de la fonction ci-dessus (qui s'avèrera je suis sûr fort pratique en cas de bug chez le client -- sait-on jamais, ma divinité est grecque et limitée -- pour nous remonter l'information) : je vous invite à aller admirer ce que l'on peut faire dans le genre hyper-poussé en PyQt !

Mais revenons-en à la création de notre interface en elle-même. Dans le fichier projwindow.py, placé dans le même répertoire, on va trouver la définition de la classe ProjWindow, qui hérite l'interface graphique "visible" créée dans le designer, et à laquelle va être "hooké" des fonctions diverses et variées.

# -*- coding: iso8859-1 -*-

import os, sys
from PyQt4.QtCore import pyqtSignature, QString, Qt, QVariant, QRect, QRectF, QThread, QEvent, QSize, SIGNAL, SLOT
from PyQt4.QtGui import *

from ui_projwindow import Ui_ProjWindow

class ProjWindow(QMainWindow, Ui_ProjWindow):
    def __init__(self, parent = None):
    QMainWindow.__init__(self, parent)
    self.setupUi(self)

Comme on voit, l'initialisation de la classe "ProjWindow", qui hérite de "Ui_ProjWindow" va appeler "setupUi", initialisant les graphismes. Mais cela après avoir appelé l'initialisation de QMainWindow, en faisant dès lors la fenêtre principale du projet (car on peut aussi créer les boîtes de dialogue ou les fenêtres filles avec le designer). On remarque au passage que l'application est codée en iso8859-1, ce qui permet de mettre des accents (faire attention lors de l'enregistrement du fichier) ; je ne l'ai pas mis en utf8 pour cause de problèmes avec l'affichage de texte dans widget purement graphiques (j'y reviendrai), mais on note que le générateur Qt-Python crée les fichiers en utf8, permettant donc l'inclusion de tous les caractères de la création. Il faut à présent compléter la fonction d'init de tout ce que l'on souhaite pour l'initialisation de notre application graphique (création de variables locales à l'objet, calculs divers, ouverture de fichiers, etc), et surtout : des crochets pour l'interactivité.

En Qt, l'intéractivité se gère via les signaux. On peut trouver de la bonne littérature sur le sujet, mais il faut déjà veiller à réadapter la syntaxe pour Python. Prenons quelques exemples.

    self.connect(self.menuSave, SIGNAL("triggered()"), self.menu_save)

On connecte ainsi le "signal" nommé "triggered()" de "menuSave" (un item de menu) à la fonction "menu_save(self)" (qui reste à définir plus bas dans la classe). Je rappelle que "self." indique que l'on se réfère à des procédures ou des variables de l'objet lui-même (équivalent de "this" en C++ ou Java, à ceci près qu'en Python c'est obligatoire de préciser). "triggered()" correspond à un clic sur un menu. On peut trouver "clicked()" sur un bouton, "toggled(bool)" pour une boîte de sélection à cocher (indique que l'on vient de cliquer sur l'un des éléments de la boîte), tout comme il y a "valueChanged(int)" sur un slider ou une boîte numérique (la fonction hookée devra alors prendre en argument un entier, qui prendra la valeur de l'objet graphique), ou encore "currentIndexChanged(QString)" pour une boîte de sélection, à différencier de "currentIndexChanged(int)" (le premier renvoie la chaîne de caractère de ce qui a été sélectionné, le second le numéro de l'index ; le traitement est donc différent dans la fonction appelée, sachant cependant que l'on peut ensuite récupérer ses informations en questionnant les propriétés de l'objet).

On peut aussi s'amuser à "automatiser" le traitement de signal, par exemple le fait de changer la combo box "combo_texts" change le texte du label "label_textcombo".

    self.label_textcombo = QLabel(self)
    self.combo_texts = QComboBox(self)
    self.combo_texts.addItem("toto")
    self.combo_texts.addItem("tata")
    self.combo_texts.addItem("titi")
    self.connect(self.combo_texts, SIGNAL("valueChanged(QString)"), self.label_textcombo.setText)

Pour ne pas se casser la tête, et parce que coder fait mal aux doigts (après il faut traiter l'arthrite, ça fait cher pour la SECU), Qt Designer dispose d'une petite fenêtre sympathique "Signal/Slot Editor" (il y a plusieurs autres onglets : "Action Editor" qui permet de gérer les menus, notamment d'ajouter des raccourcis clavier ; et "resource browser", qui doit servir à gérer les fichiers de langue, par exemple, à vue de nez), qui permet de choisir un élément graphique d'émission, un signal (comme on a vu), un autre élément graphique cette fois de réception, et un slot associé (c'est-à-dire une fonction interne comme "setValue(int)", "setText(QString)", etc) ; tout sera généré ensuite automatiquement en Python.

Évidemment, à toute règle ses exceptions : gérer le surlignement à la souris (passage de la souris par dessus un widget, par exemple un label) se fait via une surcharge de fonction :

        self.my_label.enterEvent = self.my_labelEnter

    def my_labelEnter(self, event):

De même lorsque la souris quitte notre label, ou lorsqu'on clique dessus :
        self.my_label.mousePressEvent = self.my_labelSelect
        self.my_label.leaveEvent = self.my_labelLeave

Idem pour la roulette de la souris, par dessus un QGraphicsView (cf plus bas) pour zoomer, par exemple :

       self.mygraph.wheelEvent = self.wheelZoomGraph

    def wheelZoomGraph(self, event):

Pour les événements de redimensionnement de la fenêtre et de fermeture, il suffit donc de surcharger les fonctions :

    def closeEvent(self, event):

    def resizeEvent(self, event):

Noter que l'on peut annuler un événement :

        event.ignore()

Vous en savez à présent assez pour écrire les doigts dans le nez (pardon, sur le clavier) un classique "hello world", et donc recoder avec un peu plus de temps l'ensemble des applications KDE. Il suffit juste de connaître quelques autres trucs et astuces (liste évidemment non exhaustive).

Création d'une boîte de dialogue simple :

    QMessageBox.critical(self, "Erreur !", "Une action a tout cassé.")

La boîte s'affiche toute seule, et attend que l'on clique sur "OK". D'autres boîtes prédéfinies existent, parfois avec plusieurs boutons :

    ret = QMessageBox.question(self, "Question existentielle", "Suis-je buggué ?",
                       QMessageBox.Yes | QMessageBox.No, QMessageBox.Yes)
    if ret == QMessageBox.No:
        return False

Pour ajouter des boutons personnalisés à une boîte de dialogue standard (on peut aussi ajouter des boutons prédéfinis tel QMessageBox.Abort) :

    msgBox = QMessageBox(QMessageBox.Critical, "Erreur !", "Une action a tout cassé.", QMessageBox.Ok, self)
    quitButton = msgBox.addButton("Quitter urgemment", QMessageBox.ActionRole)
    ret = msgBox.exec_()
    if msgBox.clickedButton() == quitButton:
        sys.exit()

Pour ajouter une option à notre boîte (on s'arrêtera là, on peut tout personnaliser, ça risque de faire long) :

    ch = QCheckBox("Redemander inlassablement", None)
    ch.setCheckState(Qt.Checked)
    msgbox = QMessageBox(QMessageBox.Question, "un tas d'étapes", "Voulez-vous passez à l'étape #2 ?",
                   QMessageBox.Yes | QMessageBox.No, self)
# notez ici l'utilisation du layout pour ajouter le widget
    msgbox.layout().addWidget(ch, 1, 1)
    ret = msgbox.exec_()
    if ret == QMessageBox.No:
        return
    # faire des trucs
    if ch.checkState() == Qt.Checked:
        msgbox.setText("Voulez-vous passer à l'étape #3 ?")
        ret = msgbox.exec_()
        if ret == QMessageBox.No:
            return
    #etc

Il existe aussi des boîtes de dialogue prédéfinies pour diverses actions (que l'on devinera aisément) :

    dirname = QFileDialog.getExistingDirectory(self, "Sélectionner un répertoire", default_path)

    file = QFileDialog.getOpenFileName(self, "Ouvrir un fichier", default_path, "Text (*.txt *.odt)")

    file = QFileDialog.getSaveFileName(self, "Enregistrer", "monbidule", "*.txt")
    if not file:
        return
    if not file.endsWith(".txt"):
        file += ".txt"

Autre fonctionalité sympathique et un peu cachée, l'affichage de texte dans la bande du bas de la fenêtre principale ("status bar") ; ici, durant 4 secondes (optionnel, mais penser alors à effacer manuellement en définissant un texte valant "") :

    self.statusBar().showMessage("la vie est belle", 4000)

Pour afficher/masquer et rendre disponible/indisponible un objet graphique, on procède avec :

   # griser
    self.mything1.setEnabled(False)
    # cacher
    self.mything2.setVisible(False)

Pour changer un texte de couleur et de propriété (c'est un peu lourd, mais extrêmement flexible ensuite) :

    self.color_text_black = QPalette()
    self.color_text_black.setColor(QPalette.WindowText, QColor(0, 0, 0))
    self.color_text_green = QPalette()
    self.color_text_green.setColor(QPalette.WindowText, QColor(65, 155, 50))
    self.color_text_red = QPalette()
    self.color_text_red.setColor(QPalette.WindowText, QColor(185, 50, 20))
    self.fontItBold = QFont()
    self.fontItBold.setItalic(True)
    self.fontItBold.setBold(True)
    self.fontDefault = QFont()
    # vert !
    label.setPalette(self.color_text_green)
    # italique
    label.setFont(self.fontItBold)
    # retour au noir
    label.setPalette(self.color_text_black)
    # font normale
    label.setFont(self.fontDefault)

Il reste l'épineuse question des threads à l'intérieur de l'application, notamment pour les traitements lourds : il est toujours pénible de voir la fenêtre se geler durant un temps indéfini. La méthode peut consister en l'affichage d'une boîte de progression, les exemples sur internet ne manquent pas. Pour cela, Qt met à disposition QThread. Voici comment procéder :

    self.mythread = QThread()
    self.mythread.run = self.myaction
    self.mythread.start()

La fonction myaction sera alors appelée dans un thread. Attention : "mythread" doit bien être une variable de l'objet (ici "WindowProj"), et donc initialisée à "None" avant "__init__", sous peine de voir le thread victime du garbage collector dès la sortie de la fonction appelante ! Une fois dans myaction, on peut faire ce que l'on veut (enregistrer un gros fichier par exemple), sauf interagir directement avec les graphismes. C'est-à-dire qu'il n'est pas possible d'afficher directement une boîte de dialogue "enregistrement terminé", par exemple (ou de le mettre dans la status bar, histoire d'être plus poli). Pour ce faire, il faut passer par des événements personnalisés.

    def myaction(self):
# creation d'un evenement personnalise
        event = QEvent(QEvent.User)
        event.event_type = "my_event"
        # faire des choses interessantes
        if success == False:
# on a detecte que ca a foire
            event.event_action = "failure"
        else:
# on est trop doue (penser a demander une augmentation)
            event.event_action = "success"
# on "poste" notre evenement
        qApp.postEvent(self, event)

# cette fonction est une surcharge pour traiter des event persos
    def customEvent(self, event):
# on filtre
        if event.event_type == "my_event"
            if event.event_action == "success":
                self.statusBar().showMessage("enregistrement réussi", 5000)
            elif event.event_action == "failure":
                QMessageBox.warning(self, "Erreur !", "enregistrement echoue, retenter ?", QMessageBox.Yes | QMessageBox.Abort)


Notons qu'avec Python, on peut ajouter n'importe quel champ simplement à customEvent, sans avoir à le faire hériter (attention à ne pas faire ensuite référence à une variable non existente dans le traitement !). Mais disons que dans l'ensemble, c'est un peu lourd, et que l'on a parfois des résultats pas très chrétiens. Personnellement, je préfère, lorsque l'action peut être découpé en petits morceaux (par exemple pour un enregistrement de fichier dans OpenOffice.org, avec le binding PyUno, on a une ou plusieurs boucles), placer à intervale régulier un :

    QApplication.processEvents()

Ce qui va forcer le traitement des événements en attente sur la fenêtre, et notamment la redessiner. On peut ainsi continuer de cliquer, et tout s'exécutera dans les threads "automatiques" de traitement habituels ; attention cependant à ne pas se prendre les pieds dans le tapis (par exemple, modifier les données que l'on est en train d'enregistrer, ou relancer cette commander : bref, il faut penser à verrouiller ce qui doit l'être, sinon gare au crash !).

Python étant un langage objet, on dispose des mêmes possibilités qu'en C++, et notamment on peut redéfinir certaines fonctions. Citons "resizeEvent(self, event)" et "closeEvent(self, event)" qui si elles sont redéfinies dans notre "ProjWindow" vont permettre d'intercepter les événements de redimensionnement et de fermeture de la fenêtre principale (et donc de l'application pour la fermeture), et pourquoi pas de les annuler avec "event.ignore()".

Gérons un peu de graphisme. Le widget qui sert à afficher est QGraphicsView :

    self.mygraph = QGraphicsView(self)

Il s'agit alors de lui ajouter une "scène", dans laquelle on peut dessiner. On peut ainsi définir plusieurs scènes, et les dessiner hors écran, et les interchanger à l'affichage.

    self.scene_img = QGraphicsScene(self)
    img = QPixmap("graphics/img.jpg")
    self.scene_img.clear()
    self.scene_img.addItem(QGraphicsPixmapItem(img))
    self.mygraph.setScene(self.scene_img)

On peut ainsi ouvrir quasiment tous les formats de fichiers d'image de la création, on peut coller du texte formaté en html, et j'en passe (par exemple, on peut récupérer l'image d'un widget non affiché grâce à "QPixmap.grabWidget(my_widget)", ou encore faire une capture d'écran). Le problème principal est de ne pas s'emmêler entre les QPixmap, les QImage, les QGraphicsPixmapItem -- et ce n'est vraiment pas une mince affaire. Une fois que l'on commence à devenir un Jedi, on peut commencer à ajouter du texte, à zoomer avec la roulette, puis à zoomer automatiquement pour ajuster la vue, et jouer avec la matrice pour ajouter des effets de rotation, etc (il y a des fonctions toutes faites, mais pour savoir comment les utiliser, ça doublerait presque la taille de ce billet -- et puis, je ne vais pas non plus tout vous dire, comment on fait tourner la boîte ensuite, hein, je vous le demande ?).

Il ne reste plus qu'à imprimer !

    def export_to_printer(self):
# creation de l'imprimante
        printer = QPrinter(QPrinter.HighResolution)
# on est en A4 et en couleur
        printer.setPageSize(QPrinter.A4)
        printer.setColorMode(QPrinter.Color)
# différents champs
        printer.setCreator("Dieu")
        printer.setDocName("ma belle image")
# gestion de l'export en PDF (chouette non ?)
        printer.setOutputFileName("image.pdf")

# affichage de la boite de dialogue (automatique)
        dialog = QPrintDialog(printer, self)
        ret = dialog.exec_()
        if ret != QDialog.Accepted:
            return

# creation d'un widget de dessin
        painter = QPainter()
# optionnel : l'antialiasing
#        painter.setRenderHint(QPainter.Antialiasing, False)
#        painter.setRenderHint(QPainter.TextAntialiasing, False)
        self.export_to_pages(painter, printer)

    def export_to_pages(self, painter, printer):
        """ export_to_pages: export the scene on painter with a number of pages
        """
# calcul du nombre de page : arrondi superieur de la hauteur de la scene sur celle du papier A4
        w = printer.pageRect().width()
        h = printer.pageRect().height()
        numPage = int(round(self.scene_img.height() / h))
# on initialise la zone dans laquelle tout ce qui passera par "render" sera sorti sur le QPrinter
        painter.begin(printer)
# boucle d'impression
        for page in range(0, numPage):
# capture de la partie A4 de la scene qui va bien, pour l'envoyer sur papier (avec la meme taille)
            self.scene_img.render(painter, QRectF(0, 0, w, h), QRectF(0, h * page, w, h))
# si l'on cherche a rajouter du texte supplementaire, comme un titre
#           painter.drawText(20, 16, "toto")
# demande de nouvelle page
           if page < numPage - 1:
               printer.newPage()
# fin du rendering
        painter.end()

Voilà c'est tout. On remarque que j'ai sous-traité le "rendering" dans une sous-fonction "export_to_pages" : il s'agit en fait de factoriser l'export en PDF. En effet, en Qt, on peut générer aussi facilement du PDF que ce que l'on imprime. Pour ce faire :

    def export_to_pdf(self):
# boite de dialogue habituelle
        file = QFileDialog.getSaveFileName(self, "Exporter en PDF", "mon_pdf", "*.pdf")
# le printer et le painter
        pdfPrinter = QPrinter()
        pdfPainter = QPainter()
# on veut sortir en PDF !
        pdfPrinter.setOutputFormat(QPrinter.PdfFormat)
# si l'on veut changer la taille du papier manuellement
#        pdfPrinter.setPaperSize(QSize(420, 420), QPrinter.Point)
# mais nous on veut du A4
        pdfPrinter.setPaperSize(QPrinter.A4)
# pour rogner les marges (pas très joli, et l'imprimante risque de vous en vouloir)
#        pdfPrinter.setFullPage(True)
# diverses proprietes (consultable dans votre lecteur PDF favori)
        pdfPrinter.setCreator("Dieu")
        pdfPrinter.setDocName("mon joli PDF")
# definition du nom de fichier de sortie
        if not file.endsWith(".pdf"):
        file += ".pdf"
        pdfPrinter.setOutputFileName(file)
# et l'export tout pareil qu'avant
        self.export_to_pages(pdfPainter, pdfPrinter)

Simple et efficace -- quand on sait comment manier la bête, mais maintenant, vous savez. D'ailleurs, il ne me reste plus grand chose d'important à vous apprendre, chers lecteurs. Allez, peut-être, en bonus : faire un logo de démarrage.

# le texte (a readapter, merci)
splashcopyr='''<font color="black"><b>My Appli v0.2beta<br></b>
Copyright (C) Linagora<br>
Conçu par LINAGORA (Gilles Blanc, gblanc@linagora.com)</font>
'''

def makeSplashLogo():
    """Make a splash screen logo."""
    border = 16
# la taille de l'image a l'ecran, attention au ratio
    xw, yw = 473, 427

# ouverture de "./graphics/logo.png"
    pix = QPixmap(os.path.realpath(os.path.dirname(__file__)) + "/" + 'graphics/logo.png')
# on dessine l'image
    p = QPainter(pix)

# creation d'un document texte
    doc = QTextDocument()
    doc.setPageSize(QSizeF(pix.width(), pix.height()))
    f = qApp.font()
# on ecrit en petit
    f.setPointSize(8)
    doc.setDefaultFont(f)
# on ecrit centre
    doc.setDefaultTextOption(QTextOption(Qt.AlignCenter))
# et paf, on interprete du html en deux coups de cuillere a pot !
    doc.setHtml(splashcopyr)
# translation en bas de l'ecran du texte
    p.translate(0, pix.height() - 80)
# on dessine le texte
    doc.drawContents(p)
# fin du dessin
    p.end()
    return pix

if __name__ == "__main__":

    app = QApplication(sys.argv)
    sys.excepthook = excepthook

# creation du splash screen
    splash = QSplashScreen(makeSplashLogo())
# on affiche le splash screen
    splash.show()
    app.processEvents()

    from tdsdgwindow import TdsDgWindow
#etc (ou retour a la case depart)

Ceci dit, c'est vraiment du cosmétique : les applications Python-Qt s'affichent très vite ! Grosso modo, avec un total de plus de 3000 lignes Python, dont près de 900 générés pour la création du graphisme de la fenêtre, mon application (enfin, la plus grosse) met moins de trois secondes pour s'afficher ! (et bouffer au passage 35Mo de mémoire, environ) En revanche, pour une exécution qui nécessite une recompilation en bytecode du Python (création des fichiers ".pyc" associés aux différents ".py"), il faut deux fois plus de temps. Ce qui normalement, lorsque l'on fait une livraison, n'arrive qu'une seule fois tout au plus.

J'allais oublier un problème épineux (j'avais bien dit pourtant au début que j'y reviendrai, souvenez-vous...) : l'encoding -- on a un nom en French pour ça ? Heu... --, l'encodage de caractères. De nos temps modernes, l'usage de l'utf8 est devenu très courant (tout ça parce qu'il y a des types sur Terre qui ne parlent pas anglais, j'vous jure... Bref). Or, on l'a vu, le fichier projwindow.py est en ascii tout simplement parce que sinon, il se passe des drames dans l'affichage de texte sur les QGraphicsView (vous savez les caractères étranges). Du coup, pour insérer une date en français avec accent (à tester en août, ni en juillet ni en septembre), il faut :

painter.drawText(42, 42, time.strftime("%A %d %B %Y, %H:%M").decode('utf-8')

Pour insérer à présent un texte dans une scène avec "addText", en allant le récupérer depuis un texte de QLabel, on transforme la QString en objet Python "unicode" (ie une "str" en utf-8) :

    caption = unicode(my_label.text())
    text = self.scene_img.addText(caption)
    text.setPos(42, 42)

Voilà, je crois avoir fait le tour de ce qui est le plus important à connaître (je vous ai passé "comment faire un zoom sur une zone graphique avec la roulette", ne m'en veuillez pas, il faut que je garde un peu de savoir-faire sinon je risque le chômage).

Une fois que vous serez passé maître jedi, vous pourrez alors recoder elasticnodes.py, du répertoire "examples/graphicsview" dans les sources du binding, totalement impressionnant. Mais il faudra peut-être lutter un peu, et parcourir un long chemin (petit scarabée), alors sait-on jamais que vous ayez un DIF de côté, contactez-moi donc, je suis sûr que l'on pourra s'arranger...  ;)



Best of webographique :
* sur le site de Python, les bindings de GUI et PyQt en particulier
* des slides de présentation
* intro par la création d'une appli
* intro par l'exemple
* un tuto très complet et très bien fait