Outils pour utilisateurs

Outils du site


nouveaux_widgets

Création de nouveaux widgets PyQt5 utilisables par QtDesigner

[modifié le 2/6/2016: adaptation à PyQt5]

[code Python 3.4, PyQt5, multiplateforme]

Problématique

Avec PyQt5, on peut toujours créer de nouveaux widgets en python:

  • en modifiant les propriétés d'un widget existant
  • et/ou en regroupant plusieurs widgets ensemble.

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.

Principe de solution

Chaque nouveau widget personnalisé devra comporter deux fichiers:

  • un fichier “widget” contenant sa définition sous forme d'une classe
  • et 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 minuscule!), sinon il ne sera pas reconnu. Par exemple: “monwidgetplugin.py”.

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!

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!

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 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;")

Création du fichier "plugin"

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

Lancement du Designer

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:

  • 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ù, 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

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 3 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.

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:

  • 1ère ligne: un QLabel affichant “Latitude”, suivi d'un QDoubleSpinBox permettant l'introduction d'une latitude en nombre décimal (type float).
  • 2ème ligne: un QLabel affichant “Longitude”, suivi d'un QDoubleSpinBox permettant l'introduction d'une longitude en nombre décimal (type float).

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:

  • 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. A 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 spinbox, et qui sont identifiées comme slot par le décorateur habituel (ex: @QtCore.pyqtSlot(float)).
  • déclaration des propriétés (QtCore.pyqtProperty) permettant au Designer de reconnaître ces méthodes.

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

Création du fichier "plugin"

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.

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

nouveaux_widgets.txt · Dernière modification: 2016/06/04 08:49 de tyrtamos

Outils de la page