Designing Custom Controls with PyQt

Материал из Wiki.crossplatform.ru

Перейти к: навигация, поиск
Image:qt-logo_new.png Image:qq-title-article.png
Qt Quarterly | Выпуск 26 | Документация


__NOTOC__

Содержание

Designing Custom Controls with PyQt

by David Boddie

Одной из скрытых особенностей PyQt является его способность разрешить пользовательские плагины виджетов, которые будут созданы для Qt Designer'а, используя модули написаные на чистом Python. Это не только открывает интересные возможности для Python программистов, но и позволяет разработчикам и дизайнерам экспериментировать с быстрым прототипированием без нервотрепки создания общей библиотеки для плагинов.

When Qt Designer was redesigned and rewritten for Qt 4, one of the main aims was tomake it easier for developers to add their own custom controls to thestandard Qt widgets available to designers. Although the creators of Qt Designer hadC++ programmers in mind when they implemented this feature, such extensibilityisn't limited to just this one language—any set of language bindings that useQt's meta-object system can join in the fun.

center

In this article, we will show how to use PyQt to create widgets that can beused in Qt Designer, describe the process of making plugins, and look at themechanisms that expose Qt's meta-object system to Python.

Creating a Custom Widget

PyQt exposes the Qt APIs to Python in a fairly conservative way, making theconstruction of custom widgets a familiar experience to C++ programmers. Newwidgets are subclassed from QWidget in the usual way, as we can see withthis fully-functioning widget for entering latitude and longitude values:

class GeoLocationWidget(QWidget):
 
  __pyqtSignals__ = ("latitudeChanged(double)",
                     "longitudeChanged(double)")
 
  def __init__(self, parent = None):
 
     QWidget.__init__(self, parent)
 
     latitudeLabel = QLabel(self.tr("Latitude:"))
     self.latitudeSpinBox = QDoubleSpinBox()
     self.latitudeSpinBox.setRange(-90.0, 90.0)
     self.latitudeSpinBox.setDecimals(5)
 
     longitudeLabel = QLabel(self.tr("Longitude:"))
     self.longitudeSpinBox = QDoubleSpinBox()
     self.longitudeSpinBox.setRange(-180.0, 180.0)
     self.longitudeSpinBox.setDecimals(5)
 
     self.connect(self.latitudeSpinBox,
         SIGNAL("valueChanged(double)"),
         self, SIGNAL("latitudeChanged(double)"))
     self.connect(self.longitudeSpinBox,
         SIGNAL("valueChanged(double)"),
         self, SIGNAL("longitudeChanged(double)"))
 
     layout = QGridLayout(self)
     layout.addWidget(latitudeLabel, 0, 0)
     layout.addWidget(self.latitudeSpinBox, 0, 1)
     layout.addWidget(longitudeLabel, 1, 0)
     layout.addWidget(self.longitudeSpinBox, 1, 1)

Two points of interest are worth noting. Firstly, unlike in Qt Jambi andQt Script, the syntax for connecting signals and slots follows the patternused in C++. Secondly, the signal declarations at the start of the classdefinition are not strictly required by PyQt—signals with arbitrary namescan be emitted without prior declaration—we will return to this pointlater.

Although the widget is useful as it stands, it doesn't expose any highlevel properties. As in C++, we define these by creating getter andsetter methods inside the class definition, and use some magic to exposethem to Qt's meta-object system. Here are the getter and setter methods forthe latitude property:

  def latitude(self):
     return self.latitudeSpinBox.value()
 
  @pyqtSignature("setLatitude(double)")
 
  def setLatitude(self, latitude):
 
     if latitude != self.latitudeSpinBox.value():
         self.latitudeSpinBox.setValue(latitude)
         self.emit(SIGNAL("latitudeChanged(double)"),
                   latitude)

Just as in a C++ class, we define methods called latitude() andsetLatitude() to provide the property's functionality. The declarationimmediately before the setter is a special Python decorator that tellsQt that setLatitude() is a slot and that it accepts double precisionfloating point values. Typically, this kind of declarationis not required with PyQt—any function or method can be used as aslot—but this makes interacting with Qt Designer easier later on.

The pyqtProperty() function is used to register the property with Qt:

  latitude = pyqtProperty("double", latitude, setLatitude)

Note that the latitude name is bound to the resulting property, andthe method we defined earlier is no longer directly accessible. If wewanted to keep it around, we could have used different names for thegetter and for the property.

Producing a Plugin

The process of making the widget work is very similar tothat for C++ widgets. Each custom widget class is represented by aplugin class that creates instances of it (as described inQt Quarterly 16).The main difference is that Qt Designer's plugin interfaces are used viaspecial PyQt-specific plugin classes.

center

To keep things short, we will only look at part of the definition ofthe GeoLocationPlugin class:

class GeoLocationPlugin(QPyDesignerCustomWidgetPlugin):
 
   def __init__(self, parent = None):
 
      QPyDesignerCustomWidgetPlugin.__init__(self)
      self.initialized = False

The code to initialize an instance of the class should be familiarto writers of C++ widget plugins.

The initialize() method is called by Qt Designer after the plugin hasbeen loaded. Plugins use this opportunity to install extensions tothe form editor.

   def initialize(self, formEditor):
 
      if self.initialized:
          return
 
      manager = formEditor.extensionManager()
      if manager:
          self.factory = \
              GeoLocationTaskMenuFactory(manager)
          manager.registerExtensions(
              self.factory,
              "com.trolltech.Qt.Designer.TaskMenu")
 
      self.initialized = True

For this plugin, we install a task menu extension to let users configure ourcustom widget on the form via the context menu. This is done by creatingand registering a task menu factory with the form editor's extension manager.

There are a number of other methods that need to be implemented in the pluginclass, but the most important ones create individual widgets and provideinformation about the name and location of the custom widget class:

   def createWidget(self, parent):
      return GeoLocationWidget(parent)
 
   def name(self):
      return "GeoLocationWidget"
 
   def includeFile(self):
      return "QQ_Widgets.geolocationwidget"

For widgets created with Python, the includeFile() method returns thename of the module that provides the named class. In this case, the moduleresides within the QQ_Widgets package.

Making a Menu

Although it is quite easy to edit the properties of the custom widget in Qt Designer'sproperty editor, we will create a dialog that the user can open to change themin a more natural way. The dialog will be available from a new entry on theform's context menu whenever the user has selected the custom widget.

The dialog itself isn't very special. It operates on a widget passed to itfrom elsewhere, providing another GeoLocationWidget for the user to modifyand preview changes in.

class GeoLocationDialog(QDialog):
 
   def __init__(self, widget, parent = None):
 
      QDialog.__init__(self, parent)
 
      self.widget = widget
 
      self.previewWidget = GeoLocationWidget()
      self.previewWidget.latitude = widget.latitude
      self.previewWidget.longitude = widget.longitude
 
      buttonBox = QDialogButtonBox()
      okButton = buttonBox.addButton(buttonBox.Ok)
      cancelButton = \
         buttonBox.addButton(buttonBox.Cancel)
 
      self.connect(okButton, SIGNAL("clicked()"),
                   self.updateWidget)
      self.connect(cancelButton, SIGNAL("clicked()"),
                   self, SLOT("reject()"))
 
      layout = QGridLayout()
      layout.addWidget(self.previewWidget, 1, 0, 1, 2)
      layout.addWidget(buttonBox, 2, 0, 1, 2)
      self.setLayout(layout)
 
      self.setWindowTitle(self.tr("Update Location"))

The interesting part is the slot where the changes to the preview widget arecommitted to the widget on the form:

   def updateWidget(self):
 
      formWindow = \
        QDesignerFormWindowInterface.findFormWindow(
            self.widget)
 
      if formWindow:
          formWindow.cursor().setProperty("latitude",
              QVariant(self.previewWidget.latitude))
          formWindow.cursor().setProperty("longitude",
              QVariant(self.previewWidget.longitude))
 
      self.accept()

Here, we make modifications via the form window's interface to ensure that theuser's changes are recorded. This way, if the user decides that they have madea mistake, they can simply undo the changes in the normal way.

The dialog is invoked from a custom task menu extension—an object thatconnects a menu entry with code to open a dialog. When created, thisprovides an Update Location... action that the form editor adds to itscontext menu. We connect this action to a slot so that we can open the dialogwhen the user selects the corresponding menu entry.

class GeoLocationMenuEntry(QPyDesignerTaskMenuExtension):
 
  def __init__(self, widget, parent):
 
      QPyDesignerTaskMenuExtension.__init__(self, parent)
 
      self.widget = widget
      self.editStateAction = QAction(
          self.tr("Update Location..."), self)
      self.connect(self.editStateAction,
          SIGNAL("triggered()"), self.updateLocation)
 
  def preferredEditAction(self):
      return self.editStateAction
 
  def taskActions(self):
      return [self.editStateAction]
 
  def updateLocation(self):
      dialog = GeoLocationDialog(self.widget)
      dialog.exec_()

Note that the updateLocation() slot is not decorated in this case—sincewe make the connection, there's no need to declare it. Another short cut isthe use of a Python list in the taskActions() method.

Each task menu extension is created by the task menu factory that weregistered in the initialize() method of our custom widget plugin.When the user opens a context menu over a custom widget, Qt Designer creates a newtask menu extension by calling the factory's createExtension() method.

class GeoLocationTaskMenuFactory(QExtensionFactory):
 
  def __init__(self, parent = None):
 
      QExtensionFactory.__init__(self, parent)
 
  def createExtension(self, obj, iid, parent):
 
      if iid != "com.trolltech.Qt.Designer.TaskMenu":
          return None
 
      if isinstance(obj, GeoLocationWidget):
          return GeoLocationMenuEntry(obj, parent)
 
      return None

The createExtension() method checks that the extension requested hasthe appropriate interface for a task menu extension, and only returns aninstance of one if the widget it is needed for is a GeoLocationWidget.

With the task menu factory creating GeoLocationTaskMenuEntry objects ondemand, the GeoLocationDialog class can be used to edit instances ofGeoLocationWidget when it is installed.

Putting Things in Place

On systems where Qt Designer is ableto use third party plugins, and where PyQt includes the QtDesignermodule, it should be possible to use plugins written with PyQt. Unlike C++plugins, those written in Python do not have to be compiled or otherwiseprepared before we install them—we can simply copy the sources to theappropriate locations so that Qt Designer can find them.

The Python modules that provide the plugin and task menu extension aretypically stored together as files in the same directory. Qt Designer plugins writtenin C++ are usually installed in a designer subdirectory within thedirectory described by QLibraryInfo::PluginPath. The convention forPython plugins is to create a python directory alongside the C++ pluginsand store them in there.

The widgets themselves need to be placed in a standard location so that thePython interpreter can find them. Often, it is convenient to store them in thePython installation's site-packages directory since they are going to beneeded by applications that use forms containing those widgets. Theinstallation procedure involves creating a setup.py file which we won'tdiscuss here—the code archive accompanying this article contains a filethat can be used as a starting point for your own projects.

As an alternative to installation, environment variables can be set to referto the locations of plugins and custom widgets. Developers familiar withPython will know that PYTHONPATH can be used to add new directories to thelist of known locations of modules and packages.Similarly, PY QTDESIGNERPATH can be used to add locations of Python modulescontaining plugins and extensions for Qt Designer.

With the plugin, extension and custom widget installed, or their locationsspecified using the environment variables described above, we can now run Qt Designerand use our custom widget—it should be available from theQt Quarterly Examples section of the widget box.

Forms that contain custom widgets can be processed with the pyuic4 tool ina way that should be familiar to users of uic. Code to import the customwidgets is generated along with the code to create the form, so developerssimply need to make sure that the modules containing them are available whentheir applications are run.

Behind the Scenes

When writing the GeoLocationWidget class, we used three features that arenot always needed when writing PyQt widgets:

  • We declared signals in advance, rather than just emitting custom signalswhen needed.
  • We decorated certain methods with @pyqtSignature() to indicate thatthey are slots which accept certain argument types.
  • We created properties with pyqtProperty() to expose them to Qt Designer'sproperty editor.

Since the use of each feature makes information available to other Qtcomponents, widgets written like this can be supplied to other QObject-based plugin systems as long as there is a way to executetheir Python source code.

The use of slot and property declarations are also generally useful to Pythonprogrammers. Properties defined in this way behave just like normal Pythonproperties. The slot declarations can be used to help connect signals toslots in a class derived from a form.

   @pyqtSignature("on_pushButton_clicked()")
   def on_pushButton_clicked(self):
      ...
      self.listWidget.addItem(
            u"%i\\xb0 %i' %i\\" N, " % self.dms(lat) + \
            u"%i\\xb0 %i' %i\\" E" % self.dms(lng))

In this case, the decorator ensures that the method is only called when a pushbutton's clicked() signal is emitted, but not when its clicked(bool)signal is emitted.

Taking Things Further

Instructions for building and installing the widget plugins described in thisarticle are included in the examples archive,available from the Qt Quarterly Web site. We encourage you to try them out and apply the same techniques to your ownwidgets.

More detailed instructions about Qt Designer plugins, PyQt and the Python tools thatmake them work together is available from the RiverbankComputing Web site.