[modifié le 2/6/2016: adaptation à PyQt5]
[code Python 3.4, PyQt5, multiplateforme]
Avec PyQt5, on peut toujours créer de nouveaux widgets en python:
Mais c'est encore mieux quand ces nouveaux widgets personnalisés peuvent aussi être utilisés par le Designer de Qt pour dessiner des fenêtres: c'est l'objet de ce tutoriel de définir comment on fait pour y arriver.
Chaque nouveau widget personnalisé devra comporter deux fichiers:
On va placer ces deux fichiers dans deux répertoires différents:
Ces deux répertoires pourront contenir tous les widgets personnalisés qu'on aura, avec pour chacun d'entre eux un fichier “widget” et un fichier “plugin”. On va se contenter dans cette page de ce cas simple (un widget = ces deux fichiers), mais il y a des cas plus complexes dans les exemples de PyQt5 (répertoire “Designer”).
Ceci fait, on appellera le Designer de Qt dans un nouveau processus (avec QProcess) en lui passant les variables d'environnement de l'OS, dans lesquelles on aura mis les adresses de ces deux répertoires “widgets” et “plugins”: le Designer mettra alors à disposition les widgets personnalisés, qu'on pourra utiliser comme n'importe quel autre widget d'origine!
On va traiter un exemple simple: le widget personnalisé sera un QLineEdit qui ne comportera qu'une seule particularité: un fond jaune!
Voilà son code qu'on va mettre dans un fichier qu'on appellera “monlineeditwidget.py:
#! /usr/bin/python3 # -*- coding: utf-8 -*- # Python v3 PyQt5 from PyQt5 import QtWidgets ############################################################################# class MonLineEditWidget(QtWidgets.QLineEdit): #======================================================================== def __init__(self, parent=None): super().__init__(parent) # mettre un fond de couleur jaune à la ligne de saisie self.setStyleSheet("background-color: yellow;")
On va maintenant créer le fichier “plugin” pour renseigner Designer, qu'on appellera “monlineeditplugin.py” (ce nom de fichier doit se terminer par “plugin” en minuscules). On le présente ici dans une version standard qui sera facile à adapter pour d'autres widgets: ce n'est pas utile de repartir d'une page blanche à chaque fois!.
Ainsi, seule la 1ère partie mentionnée “adapter selon le widget” est à adapter, ainsi que le nom de la classe. Pour des cas plus complexes, il faut voir les exemples de PyQt5, et la doc (bon courage!):
#! /usr/bin/python3 # -*- coding: utf-8 -*- # Python v3 PyQt5 from PyQt5 import QtGui, QtDesigner # ===== adapter selon le widget! ========================================== # nom (str) du fichier du widget sans extension FICHIERWIDGET = "monlineeditwidget" # nom (str) de la classe du widget pour importer NOMCLASSEWIDGET = "MonLineEditWidget" # nom (str) de l'instance dans Designer NOMWIDGET = "monLineEditWidget" # groupe (str) de widgets pour affichage dans Designer GROUPEWIDGET = "Mes widgets perso" # texte (str) pour le toolTip dans Designer TEXTETOOLTIP = "Un QLineEdit avec un fond jaune" # texte (str) pour le whatsThis dans Designer TEXTEWHATSTHIS = "Un QLineEdit avec un fond jaune" # icone (rien ou un fichier image ou un QPixmap) pour afficher dans Designer ICONEWIDGET = QtGui.QIcon() # sans image, l'icone est celui de Qt # =========================================================================== # importation de la classe du widget modulewidget = __import__(FICHIERWIDGET, fromlist=[NOMCLASSEWIDGET]) CLASSEWIDGET = getattr(modulewidget, NOMCLASSEWIDGET) ############################################################################# class MonLineEditPlugin(QtDesigner.QPyDesignerCustomWidgetPlugin): """classe pour renseigner Designer sur le widget nom de classe a renommer selon le widget """ #======================================================================== def __init__(self, parent=None): super().__init__(parent) self.initialized = False #======================================================================== def initialize(self, core): if self.initialized: return self.initialized = True #======================================================================== def isInitialized(self): return self.initialized #======================================================================== def createWidget(self, parent): """retourne une instance de la classe qui definit le nouveau widget """ return CLASSEWIDGET(parent) #======================================================================== def name(self): """definit le nom du widget dans QtDesigner """ return NOMCLASSEWIDGET #======================================================================== def group(self): """definit le nom du groupe de widgets dans QtDesigner """ return GROUPEWIDGET #======================================================================== def icon(self): """retourne l'icone qui represente le widget dans Designer => un QtGui.QIcon() ou un QtGui.QIcon(imagepixmap) """ return ICONEWIDGET #======================================================================== def toolTip(self): """retourne une courte description du widget comme tooltip """ return TEXTETOOLTIP #======================================================================== def whatsThis(self): """retourne une courte description du widget pour le "What's this?" """ return TEXTEWHATSTHIS #======================================================================== def isContainer(self): """dit si le nouveau widget est un conteneur ou pas """ return False #======================================================================== def domXml(self): """donne des proprietes du widget pour utilisation dans Designer """ return ('<widget class="{}" name="{}">\n' \ ' <property name="toolTip" >\n' \ ' <string>{}</string>\n' \ ' </property>\n' \ ' <property name="whatsThis" >\n' \ ' <string>{}</string>\n' \ ' </property>\n' \ '</widget>\n'\ ).format(NOMCLASSEWIDGET, NOMWIDGET, TEXTETOOLTIP, TEXTEWHATSTHIS) #======================================================================== def includeFile(self): """retourne le nom du fichier (str sans extension) du widget """ return FICHIERWIDGET
Enfin, voilà le code qui va appeler Designer dans un processus en lui passant les variables d'environnement. On va aussi le présenter sous forme d'un code standard qui sera facile à adapter.
Dans les variables d'environnement de l'OS, on va passer:
En conséquence, on peut placer ces deux répertoires n'importe où, et “coder en dur” ces 2 chemins dans le code de lancement du Designer, mais c'est un peu dommage. On va donc simplifier ici sur ce point: fixer l'emplacement relatif entre programme et données pour permettre au code de lancement du Designer de trouver automatiquement par calcul les répertoires des widgets. Voilà la structure choisie ici sur le disque:
customwidgets <== répertoire pour tous les widgets personnalisés widgets <== répertoire qui regroupe les fichiers "widget" monlineeditwidget.py <== fichier "widget" du widget MonLineEditWidget plugins <== répertoire qui regroupe les fichiers "plugin" monlineeditplugin.py <== fichier "plugin" du widget MonLineEditWidget designer.py <== fichier pour lancer Designer dans un processus (.pyw sous Windows)
A noter que s'il est normal que les widgets personnalisés soient gérés comme un projet général dans le but de mettre ces widgets à disposition de tous les projets, il est recommandé de recopier en plus la structure ci-dessus au sein même de chacun des projets que vous développez (en ne mettant que les widgets utilisés!). La raison est simple à comprendre: si vous sauvegardez ces projets, si vous changez de PC ou d'OS, etc… ces projets ne fonctionneront plus parce qu'ils auront besoin de ces widgets personnalisés pour s'exécuter!
Voilà dans ce cas le code pour lancer le Designer qu'on appellera ici “designer.py” (.pyw sous Windows):
#! /usr/bin/python3 # -*- coding: utf-8 -*- # Python 3 PyQt5 import sys import os from PyQt5 import (QtWidgets, QtCore) # lancement de la bibliothèque Qt5 app = QtWidgets.QApplication(sys.argv) # trouve le répertoire d'exécution du présent programme repbase = os.path.abspath(os.path.dirname(__file__)) # lit les variables d'environnement envir = QtCore.QProcessEnvironment().systemEnvironment() # enregistre dans PYTHONPATH le répertoire des fichiers des widgets envir.insert('PYTHONPATH', os.path.join(repbase, 'customwidgets', 'widgets')) # enregistre dans PYQTDESIGNERPATH le répertoire des fichiers des plugins envir.insert('PYQTDESIGNERPATH', os.path.join(repbase, 'customwidgets', 'plugins')) # trouve l'adresse du Designer à lancer designer = QtCore.QLibraryInfo.location(QtCore.QLibraryInfo.BinariesPath) if sys.platform == 'win32': designer += r'\designer.exe' # Windows elif sys.platform == 'linux': designer += '/designer' # Linux elif sys.platform == 'darwin': designern += '/Designer.app/Contents/MacOS/Designer' # Mac OS X else: pass # autre cas à traiter # lance Designer dans un nouveau processus avec les variables d'environnement proc = QtCore.QProcess() proc.setProcessEnvironment(envir) proc.start(designer) proc.waitForFinished(-1) sys.exit(proc.exitCode())
Quand vous lancez ce code Python, le Designer vient à l'écran, et vous trouvez dans la colonne de gauche le widget personnalisé “MonLineEditWidget” dans la liste des widgets disponibles, et dans la catégorie définie (“Mes widgets perso”). Vous pouvez alors l'utiliser comme n'importe quel autre widget. Dans cet exemple, vous voyez dans les propriétés de la colonne de droite que Designer a reconnu le type QLineEdit, et a initialisé les propriétés définies dans le fichier “plugin” (toolTip, whatsThis, styleSheet).
A noter que ce script qui appelle le Designer nécessite de pouvoir retrouver automatiquement les adresses à l'intérieur de PyQt5. Or, sous Windows, ces adresses sont données par un fichier qt.conf, situé à l'installation dans le répertoire de python.exe, et dont les données sont fausses! Il faut donc le corriger comme suit:
[Paths] Prefix = Lib/site-packages/PyQt5 Binaries = . Libraries = . Plugins = plugins Translations = translations
L'exemple ci-dessus était particulièrement simple, puisqu'il s'agissait d'un simple QLineEdit. On va prendre un exemple un peu plus complet sur 3 points:
Le widget personnalisé permettra de contenir des coordonnées GPS, c'est à dire la latitude et la longitude d'un lieu:
Pour cet exemple, je me suis inspiré du tutoriel traduit en français ici: http://qt-quarterly.developpez.com/qq-26/composants-personnalises-pyqt/.
Voilà le code définissant ce nouveau widget, qui sera contenu dans le fichier “geolocationwidget.py” du sous-répertoire “widgets”:
#! /usr/bin/python3 # -*- coding: utf-8 -*- # Python v3 PyQt5 from PyQt5 import QtCore, QtWidgets ############################################################################# class GeoLocationWidget(QtWidgets.QWidget): """widget personnalisé: deux QDoubleSpinBox pour latitude et longitude """ # crée 2 nouveaux signaux du widget, émis à chaque changement des valeurs latitudeChanged = QtCore.pyqtSignal(float) longitudeChanged = QtCore.pyqtSignal(float) #========================================================================= def __init__(self, parent=None): super().__init__(parent) # crée le 1er spinbox: pour la latitude latitudeLabel = QtWidgets.QLabel("Latitude:") self.latitudeSpinBox = QtWidgets.QDoubleSpinBox() self.latitudeSpinBox.setRange(-90.0, 90.0) self.latitudeSpinBox.setDecimals(12) # le signal valuechanged du spinbox est redirigée sur le signal global latitudeChanged self.latitudeSpinBox.valueChanged.connect(self.latitudeChanged) # crée le 2e spinbox: pour la longitude longitudeLabel = QtWidgets.QLabel("Longitude:") self.longitudeSpinBox = QtWidgets.QDoubleSpinBox() self.longitudeSpinBox.setRange(-180.0, 180.0) self.longitudeSpinBox.setDecimals(12) # le signal valuechanged du spinbox est redirigée sur le signal global longitudeChanged self.longitudeSpinBox.valueChanged.connect(self.longitudeChanged) # positionne les 4 objets graphiques en deux lignes dans le QWidget layout = QtWidgets.QGridLayout(self) layout.addWidget(latitudeLabel, 0, 0) layout.addWidget(self.latitudeSpinBox, 0, 1) layout.addWidget(longitudeLabel, 1, 0) layout.addWidget(self.longitudeSpinBox, 1, 1) self.setLayout(layout) #======================================================================== # permet au Designer de lire/écrire la latitude @QtCore.pyqtSlot() def getLatitude(self): """retourne la valeur du widget latitudeSpinBox """ return self.latitudeSpinBox.value() @QtCore.pyqtSlot(float) def setLatitude(self, latitude): """affecte une nouvelle valeur au widget latitudeSpinBox """ if latitude != self.latitudeSpinBox.value(): self.latitudeSpinBox.setValue(latitude) # définit la propriété pour permettre la configuration par Designer latitude = QtCore.pyqtProperty(float, fget=getLatitude, fset=setLatitude) #======================================================================== # permet au Designer de lire/écrire la longitude @QtCore.pyqtSlot() def getLongitude(self): """retourne la valeur du widget longitudeSpinBox """ return self.longitudeSpinBox.value() @QtCore.pyqtSlot(float) def setLongitude(self, longitude): """affecte une nouvelle valeur au widget longitudeSpinBox """ if longitude != self.longitudeSpinBox.value(): self.longitudeSpinBox.setValue(longitude) # définit la propriété pour permettre la configuration par Designer longitude = QtCore.pyqtProperty(float, fget=getLongitude, fset=setLongitude)
Vous noterez qu'en plus du code absolument nécessaire pour définir ce nouveau widget, il a été ajouté plusieurs lignes d'instructions:
Ainsi QtDesigner pourra non seulement reconnaître ce widget, mais aussi savoir lui affecter des valeurs et l'interfacer avec les autres widgets.
Pour le fichier “plugin” de ce nouveau widget personnalisé, on va simplement reprendre le code de l'exemple précédent (il est fait pour ça) en adaptant ses données, et mettre le résultat dans le fichier “geolocationplugin.py”:
... ... # nom (str) du fichier du widget sans extension FICHIERWIDGET = "geolocationwidget" # nom (str) de la classe du widget pour importer NOMCLASSEWIDGET = "GeoLocationWidget" # nom (str) de l'instance dans Designer NOMWIDGET = "geoLocationWidget" # groupe (str) de widgets pour affichage dans Designer GROUPEWIDGET = "Mes widgets perso" # texte (str) pour le toolTip dans Designer TEXTETOOLTIP = "Coordonnées GPS" # texte (str) pour le whatsThis dans Designer TEXTEWHATSTHIS = "Coordonnées GPS" # icone (rien ou un fichier image ou un QPixmap) pour afficher dans Designer ICONEWIDGET = QtGui.QIcon() # sans image, l'icone est celui de Qt ... ... ############################################################################# class GeoLocationPlugin(QtDesigner.QPyDesignerCustomWidgetPlugin): ... ...
Avec ce fichier “plugin”, le Designer affichera dans la colonne de gauche le nouveau widget “GeoLocationWidget” dans la catégorie “Mes widgets perso” (vous l'appelez comme vous voulez!). Vous pourrez utiliser ce nouveau widget dans le Designer comme n'importe quel autre widget.
Vous verrez aussi dans la colonne de droite les deux nouvelles propriétés qu'on a définies: “latitude” et “longitude” que le Designer permettra maintenant de modifier et de réinitialiser.
Enfin, vous pouvez définir des liens et des actions entre les nouveaux signaux et les autres widgets de la fenêtre.
Si vous avez déjà mis en place la structure vue à l'exemple simple précédent, ainsi que le lanceur designer.py: il n'y a rien de plus à faire: ce même lanceur fera reconnaître tous les widgets personnalisés qu'il trouvera dans les répertoires “plugins” et “widgets”.
En mettant les 2 exemples de ce tutoriel ensemble, cela donnera:
customwidgets <== répertoire pour tous les widgets personnalisés widgets <== répertoire qui regroupe les fichiers "widget" monlineeditwidget.py <== fichier "widget" du widget MonLineEditWidget geolocationwidget.py <== fichier "widget" du widget GeoLocationWidget plugins <== répertoire qui regroupe les fichiers "plugin" monlineeditplugin.py <== fichier "plugin" du widget MonLineEditWidget geolocationplugin.py <== fichier "plugin" du widget GeoLocationWidget designer.py <== fichier pour lancer Designer dans un processus (.pyw sous Windows)
Amusez-vous bien!