Création de nouveaux widgets PyQt 4 utilisables par Qt Designer

Jean-Paul Vidal

PyQt dispose d'un éditeur visuel d'interfaces graphiques, le Designer. Quelques manipulations assez simples sont nécessaires pour utiliser ses propres widgets dans cet éditeur.

Commentez

- Titre : Création de nouveaux widgets PyQt 4 utilisables par Qt Designer

- Auteur : Jean-Paul Vidal

- Parution : 5 avril 2015

- Mise à jour : 1 juin 2016

- Licence : Les sources présentées sur cette page sont libres de droits et vous pouvez les utiliser à votre convenance. Par contre, la page de présentation constitue une œuvre intellectuelle protégée par les droits d'auteur. Copyright ® 2015-2016 Tyrtamos. Aucune reproduction, même partielle, ne peut être faite de ce site et de l'ensemble de son contenu : textes, documents, images, etc. sans l'autorisation expresse de l'auteur. Sinon vous encourez selon la loi jusqu'à trois ans de prison et jusqu'à 300 000 € de dommages et intérêts.

Sommaire

- I. Problématique

- II. Traitement d'un exemple simple

- II-A. Création du fichier « widget »

- II-B. Création du fichier « plugin »

- II-C. Lancement du Designer

- III. Un autre exemple plus complet

- III-A. Création du fichier widget

- III-B. Création du fichier plugin

- III-C. Lancement du Designer

- IV. Remerciements

I - Problématique

Avec PyQt 4, on peut toujours créer de nouveaux widgets en Python :

- en modifiant les propriétés d'un widget existant ;

- en regroupant plusieurs widgets.

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.

Chaque nouveau widget personnalisé devra comporter deux fichiers :

- un fichier « widget » contenant sa définition sous forme d'une classe ;

- un fichier « plugin » contenant les caractéristiques de ce widget afin de renseigner le Designer, également sous forme d'une classe.

On va placer ces deux fichiers dans deux répertoires différents :

- le fichier « widget » sera dans un répertoire qu'on appellera ici widgets ;

- le fichier « plugin » sera dans un répertoire qu'on appellera ici plugins. Attention : le nom de ce fichier doit impérativement se terminer par plugin (tout en minuscules !), sinon il ne sera pas reconnu. Par exemple : monwidgetplugin.py.

Ces deux répertoires pourront contenir tous les widgets personnalisés à considérer, 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 correspond à ces deux fichiers), mais il y a des cas plus complexes dans les exemples de PyQt 4 (répertoire Designer).

Ceci fait, on appellera le Designer de Qt dans un nouveau processus (avec QProcess) en lui passant des variables d'environnement modifiées : il faut y insérer 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 !

II - Traitement d'un exemple simple

On va traiter un exemple simple: le widget personnalisé sera un QLineEdit qui ne comportera qu'une seule particularité: un fond jaune !

II-A - Création du fichier « widget »

Voilà son code qu'on va mettre dans un fichier qu'on appellera monlineeditwidget.py :

#! /usr/bin/python3
# -*- coding: utf-8 -*-
# Python v3

from PyQt4 import QtCore, QtGui

class MonLineEditWidget(QtGui.QLineEdit):

#========================================================================
def __init__(self, parent=None):
super(MonLineEditWidget, self).__init__(parent)

# mettre un fond de couleur jaune à la ligne de saisie
self.setStyleSheet("background-color: yellow;")

II-B - Création du fichier « plugin »

On va maintenant créer le fichier « plugin » pour renseigner le Designer, qu'on appellera monlineeditplugin.py. On le présente ici dans une version standard, facile à adapter pour d'autres widgets (ce n'est pas utile de repartir d'une page blanche à chaque fois !) :

#! /usr/bin/python3
# -*- coding: utf-8 -*-
# Python v3

from PyQt4 import QtGui, QtDesigner

# ===== à adapter selon le widget! ==========================================
# nom (str) du fichier du widget sans extension
FICHIERWIDGET = "monlineeditwidget"
# nom (str) de la classe du widget importé
NOMCLASSEWIDGET = "MonLineEditWidget"
# nom (str) de l'instance crée dans Designer
NOMWIDGET = "monLineEditWidget"
# groupe (str) de widgets pour 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 QPixmap) pour présenter le widget dans Designer
ICONEWIDGET = QtGui.QIcon()  # sans pixmap, l'icone par défaut est celui de Qt
# ===========================================================================

# importation de la classe du widget
modulewidget = __import__(FICHIERWIDGET, fromlist=[NOMCLASSEWIDGET])
CLASSEWIDGET = getattr(modulewidget, NOMCLASSEWIDGET)

#############################################################################
class GeoLocationPlugin(QtDesigner.QPyDesignerCustomWidgetPlugin):
"""classe pour renseigner Designer sur le widget
nom de classe à renommer selon le widget
"""
#========================================================================
def __init__(self, parent=None):
super(GeoLocationPlugin, self).__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 définit le nouveau widget
"""
return CLASSEWIDGET(parent)
#========================================================================
def name(self):
"""définit le nom du widget dans QtDesigner
"""
return NOMCLASSEWIDGET
#========================================================================
def group(self):
"""définit 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 propriétés 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

Ça parait compliqué comme ça, mais en fait, il n'y a qu'à adapter selon le widget :

- les valeurs des variables en majuscules (utilisez les commentaires dans le code) ;

- le nom de la classe (sans oublier que ce nom se trouve aussi dans super(…)) ;

- bien sûr, le nom du fichier du widget qui devra se terminer par plugin.

II-C - Lancement du Designer

Enfin, voilà le code qui appelle le Designer dans un processus en lui passant les variables d'environnement. On le présente aussi sous forme d'un code standard facile à adapter.

Dans les variables d'environnement du système d'exploitation, on passer :

- le chemin du répertoire plugins dans la variable PYQTDESIGNERPATH ;

- le chemin du répertoire widget dans la variable PYTHONPATH.

En conséquence, on peut placer ces deux répertoires n'importe où, puis « coder en dur » ces deux chemins dans le code de lancement du Designer, mais c'est un peu dommage. On donc simplifie le code sur ce point : fixer l'emplacement relatif entre programme et données pour que le code de lancement du Designer trouve 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)

À 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 d'ordinateur ou de système d'exploitation, 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 appelera ici designer.py (.pyw sous Windows) :

#! /usr/bin/python3
# -*- coding: utf-8 -*-
# Python v3

import sys, os
from PyQt4 import QtCore, QtGui

# lance de la bibliothèque Qt4
app = QtCore.QCoreApplication(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 dans un dictionnaire
envdico = os.environ.copy()

# enregistre dans PYTHONPATH le répertoire des fichiers des widgets
envdico['PYTHONPATH'] = os.path.join(repbase, 'customwidgets', 'widgets')
# enregistre dans PYQTDESIGNERPATH le répertoire des fichiers des plugins
envdico['PYQTDESIGNERPATH'] = os.path.join(repbase, 'customwidgets', 'plugins')

# crée la liste "nom=valeur" des variables d'environnement
listenv = ['%s=%s' % (nom, valeur) for nom, valeur in envdico.items()]

# trouve l'adresse du Designer à lancer selon l'OS (corriger selon la configuration)
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 à définir si nécessaire

# lance Designer dans un nouveau processus avec les variables d'environnement
proc = QtCore.QProcess()
proc.setEnvironment(listenv)
proc.start(designer)
proc.waitForFinished(-1)
sys.exit(proc.exitCode())

Quand vous lancez ce code, 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 »). Et 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).

III - Un autre exemple plus complet

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 trois points:

- le widget personnalisé contiendra plusieurs widgets ;

- le Designer pourra intervenir pour changer certaines propriétés supplémentaires ;

- le widget personnalisé pourra émettre des signaux spécifiques en fonction de certains évènements.

III-A - Création du fichier widget

Le widget personnalisé permettra de contenir des coordonnées GPS, c'est-à-dire la latitude et la longitude d'un lieu.

- Première ligne : un QLabel affichant « Latitude », suivi d'un QDoubleSpinBox permettant l'introduction d'une latitude en nombre décimal.

- Deuxième ligne : un QLabel affichant « Longitude », suivi d'un QDoubleSpinBox permettant l'introduction d'une longitude en nombre décimal.

Cet exemple est inspiré d'un article des Qt Quarterly.

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

from PyQt4 import QtCore, QtGui

#############################################################################
class GeoLocationWidget(QtGui.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(GeoLocationWidget, self).__init__(parent)

# crée le 1er spinbox: pour la latitude
latitudeLabel = QtGui.QLabel("Latitude:")
self.latitudeSpinBox = QtGui.QDoubleSpinBox()
self.latitudeSpinBox.setRange(-90.0, 90.0)
self.latitudeSpinBox.setDecimals(12)
# le signal valuechanged est redirigée sur le signal global latitudeChanged
self.latitudeSpinBox.valueChanged.connect(GeoLocationWidget.latitudeChanged)

# crée le 2e spinbox: pour la longitude
longitudeLabel = QtGui.QLabel("Longitude:")
self.longitudeSpinBox = QtGui.QDoubleSpinBox()
self.longitudeSpinBox.setRange(-180.0, 180.0)
self.longitudeSpinBox.setDecimals(12)
# le signal valuechanged est redirigée sur le signal global longitudeChanged
self.longitudeSpinBox.valueChanged.connect(GeoLocationWidget.longitudeChanged)

# positionne les 4 objets graphiques en deux lignes dans le QWidget
layout = QtGui.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/réinitialiser 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)

@QtCore.pyqtSlot()
def razLatitude(self):
""" remet à zéro le widget latitudeSpinBox"""
self.latitudeSpinBox.setValue(0.0)

# définit la propriété pour permettre la configuration par Designer
latitude = QtCore.pyqtProperty(float, getLatitude, setLatitude, razLatitude)
#========================================================================
# permet au Designer de lire/écrire/réinitialiser 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)

@QtCore.pyqtSlot()
def razLongitude(self):
""" remet à zéro le widget longitudeSpinBox"""
self.longitudeSpinBox.setValue(0.0)

# définit la propriété pour permettre la configuration par Designer
longitude = QtCore.pyqtProperty(float, getLongitude, setLongitude, razLongitude)

En plus du code absolument nécessaire pour définir ce nouveau widget, plusieurs lignes d'instructions ont été ajoutées :

- déclaration des deux signaux générés par le changement de latitude (latitudeChanged) et de longitude (longitudeChanged). Ces deux signaux portent le nom qu'on veut et seront les signaux de sortie du widget personnalisé : on n'aura donc pas besoin de tenir compte des QDoubleSpinBox individuellement ;

- déclaration des connexions (connect) entre les signaux générés par les deux QDoubleSpinBox et les nouveaux signaux définis ci-dessus. À noter qu'on connecte ainsi un signal à un autre signal, ce qui est prévu dans la documentation de PyQt ;

- déclaration des méthodes qui seront utilisées directement par Designer pour lire, modifier ou réinitialiser les QSpinBox, identifiées comme slots par le décorateur habituel (par exemple, @QtCore.pyqtSlot(float)) ;

- déclaration des propriétés (QtCore.pyqtProperty) permettant au Designer de reconnaître ces méthodes.

Ainsi, Qt Designer pourra non seulement reconnaître ce widget, mais aussi savoir lui affecter des valeurs et l'interfacer avec les autres widgets.

III-B - Création du fichier plugin

Pour le fichier plugin de ce nouveau widget personnalisé, on reprend simplement le code de l'exemple précédent (il est fait pour ça) en adaptant ses données, le résultat est mis dans le fichier geolocationplugin.py :

...
...
# nom (str) du fichier du widget sans extension
FICHIERWIDGET = "geolocationwidget"
# nom (str) de la classe du widget importé
NOMCLASSEWIDGET = "GeoLocationWidget"
# nom (str) de l'instance crée dans Designer
NOMWIDGET = "geoLocationWidget"
# groupe (str) de widgets pour 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 QPixmap) pour présenter le widget dans Designer
ICONEWIDGET = QtGui.QIcon()
...
...
#############################################################################
class GeoLocationPlugin(QtDesigner.QPyDesignerCustomWidgetPlugin):
"""classe pour renseigner Designer sur le widget
à renommer selon le widget
"""
#========================================================================
def __init__(self, parent=None):
super(GeoLocationPlugin, self).__init__(parent)
self.initialized = False
...
...

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.

III-C - Lancement du Designer

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 deux 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 !

IV - Remerciements

Merci à Malick Seck pour la gabarisation et à Thibaut Cuvelier pour sa relecture !