Fork me on GitHub
4/10/2008

PyQt4-плагины для Qt Designer

В прошлом посте про PyQt я немного рассказал о возможных способах создания GUI и говорил о том, что использование QtDesginer - неплохой вариант. Также я упоминал, что такой подход таит в себе как преимущества, так и недостатки. Преимущества, которые я ощутил на себе: разделение кода представления и кода логики; возможность быстро набросать прототип интерфейса. Недостатки, как это обычно бывает - продолжения достоинств: переработка интерфейса в QtDesigner часто требует больше кропотливого труда, чем в случае правильно организованного ручного кода; существует небольшой диссонанс "я знаю, как это сделать в коде, как же это делается в QtDesigner?".

Сегодня я расскажу об одной приятной возможности QtDesigner - работе с кастомными виджетами.

Шаг 1: простой виджет

В качестве рабочего примера будет QEditBox (обычное поле ввода), дополненный возможностью мигать фоном, сигнализируя о чём-нибудь (у меня была ошибка ввода).

Если бы мы делали без оглядки на QtDesigner, то вышло бы что-то похожее на это:

class QLineEditWErrState(QtGui.QLineEdit):

    def __init__(self, parent=None):
        QtGui.QLineEdit.__init__(self, parent)
        self.timeout = 800
        self.errorCss = 'background-color: antiquewhite'
        self._orig_css = self.styleSheet()

    def setErrorState(self):
        self.emit(QtCore.SIGNAL("errorStateSet()"))
        self.setStyleSheet(self.error_css)
        QtCore.QTimer.singleShot(self.timeout, self.resetErrorState)

    def resetErrorState(self):
        self.setStyleSheet(self._orig_css)
        self.emit(QtCore.SIGNAL("errorStateReseted()"))

В традициях Python, вероятно, стоило описать конструктор как

    def __init__(self, timeout=800, errorCss='background-color: antiquewhite'):
        QtGui.QLineEdit.__init__(self, parent)
        self.timeout = timeout
        self.errorCss = errorCss
        self._orig_css = self.styleSheet()

Но в Qt4 принят такой API, что конструктор принимает лишь один необязательный параметр - родительский объект/виджет, а все дополнительные параметры выставляются вызовом методов/изменением атрибутов. И стоит придерживаться именно такого API, иначе ваш виджет может оказаться неинтероперабельным с нативными Qt-виджетами и/или объектами.

Описание виджета в плагине для QtDesigner

Пусть наш кастомный виджет будет в модуле widget.py. Сделаем так, чтобы QLineEditWErrState можно было добавлять через QtDesigner. Для этого нужно сделать описание виджета, например в модуле widget_plugin.py:

class QLineEditWErrStatePlugin(QtDesigner.QPyDesignerCustomWidgetPlugin):
    """
    QLineEditWErrStatePlugin(QtDesigner.QPyDesignerCustomWidgetPlugin)

    Provides a Python custom plugin for Qt Designer by implementing the
    QDesignerCustomWidgetPlugin via a PyQt-specific custom plugin class.
    """

    def __init__(self, parent=None):
        QtDesigner.QPyDesignerCustomWidgetPlugin.__init__(self, parent)
        self.initialized = True

    def createWidget(self, parent):
        # метод должен вернуть экземпляр класса нашего виджета
        # вот тут и пригодилось согласование с принятым в Qt4 API
        return QLineEditWErrState(parent)

    def name(self):
        # этод метод должен вернуть имя класса виджета
        return "QLineEditWErrState"

    def group(self):
        # имя группы виджета
        return "PyQt custom widgets"

    def icon(self):
        # иконка виджета
        return QtGui.QIcon()

    def toolTip(self):
        # всплывающая подсказка




        return "QLineEdit with error state"

    def whatsThis(self):
        # краткое описание
        return "Custom widget QLineEditWErrState - QLineEdit with error state"

    def isContainer(self):
        # True, если виджет может служить контейнером других виджетов,
        # при этом требуется реализация QDesignerContainerExtension
        # False в противном случае
        return False

    def domXml(self):
        # должен вернуть XML-описание виджета и параметры его свойств.
        # минимально -- класс и имя экземпляра класса
        # вставляется в .ui
        return '<widget class="QLineEditWErrState" name=\"errStateLineEdit\" />\n'

    def includeFile(self):
        # возвращает имя модуля, в котором хранится наш виджет
        # вставляется как `import <includeFile>` в генеренном из .ui Python-коде
        return "widget"

После этого нужно сообщить QtDesigner, чтобы он "подхватил" это описание. Можно положить в условное место, но если стоит системный Qt4, то это не удобно. Я использую переменную PYQTDESIGNERPATH, в которой указываю путь, где искать плагины. Точнее я просто пишу небольшой shell-скрипт, в котором задаю необходимые переменные. Видимо, желающим попробовать это под Windows, придется написать .bat по аналогии ;-)

Результат выглядит примерно так:

Теперь мы можем в какую-нибудь форму добавить наш измененный виджет.

Пример приложения

Для экспериментов мы будем использовать небольшое бесполезное приложение.

Есть два поля ввода - обычное QLineEdit, доступное для редактирования, и наше кастомное QLineEditWErrState, не доступное для редактирования. При изменении текста в QLineEdit, такой же текст должен появляться в QLineEditWErrState и при вводе каждого символа должен моргать фон нашего кастомного виджета. Если перефразировать это в терминах сигналов и слотов, то мы соединим стандартный сигнал QLineEdit - textEdited(QString) с двумя слотами: один стандартный для QLineEdit - setText(QString) (появление текста во втором поле ввода) и второй - определенный только для QLineEditWErrState - setErrorState() (моргание фоном).

Создадим .ui и посмотрим: в него добавилась секция описания нашего виджета:

<customwidgets>
  <customwidget>
    <class>QLineEditWErrState</class>
    <extends>QLineEdit</extends>
    <header>widget</header>
  </customwidget>
</customwidgets>

после генерации из .ui py-файла, получаем дополнительный импорт

from widget import QLineEditWErrState

Так что никакой магии - всё указывается явно:

  • модуль, откуда импортировать (он же тег header в .ui) берется из plugin.includeFile()
  • имя класса, который импортировать (он же тег class в .ui) берется из plugin.name() и plugin.domXml()
  • имя переменной, которой присваивать экземпляр класса - из plugin.domXml()

Теперь если мы желаем достичь описанного поведения, нам нужно написать приложение, которое бы до запуска соединяло нужные сигналы со слотами. Код не очень интересный, я не буду приводить его, а желающие могут посмотреть рабочий пример в hg-репо

Сразу можно отметить небольшое неудобство: для стандартных виджетов мы можем определить связи "сигнал-слот" в дизайнере. С нашим кастомным виджетом такое не получается - дизайнер не знает о том, какие слоты и сигналы доступны. Получается, либо мы стандартные связи описываем в дизайнере, а кастомные - в коде, либо все сигналы описываем в коде. И то и другое не очень здорово (особенно первое).

И второй момент, который можно отметить: управление поведением таким образом реализованного виджета возможно только из кода. Т.е. если в приложении нужно будет в одном виджете нужно моргать зеленым цветом, а в другом - красным, то такое поведение можно задать только в коде. И дальше мы посмотрим, как виджет можно улучшить.

Шаг 2: улучшенный виджет

PyQt4 даёт способ разрешить обе недоработки: при помощи нехитрых конструкций можно сообщить дизайнеру о доступных свойствах, а также описать слоты и сигналы.

Слоты - по факту просто методы, дизайнеру нужно лишь сообщить сигнатуру. Делается это при помощи декоратора pyqtSignature:

@QtCore.pyqtSignature("setErrorState()")
def setErrorState(self):
    ...

Доступные сигналы указываются при помощи атрибута класса __pyqtSignals__:

class QLineEditWErrState(QtGui.QLineEdit):

    __pyqtSignals__ = ("errorStateSet()", "errorStateReseted()")

А свойства задаются функцией pyqtProperty(<тип>, <геттер>, [сеттер], [сброс]):

errorCss = QtCore.pyqtProperty("QString", getErrorCss, setErrorCss, resetErrorCss)

Помимо того что errorCss становится Qt-свойством, оно также ведет себя как и Python-свойство.

По-видимому, этого достаточно, чтобы исправить недостатки "первого шага". Наш виджет после улучшений принимает примерно такой вид:

class QLineEditWErrState(QtGui.QLineEdit):

    __pyqtSignals__ = ("errorStateSet()", "errorStateReseted()")

    def __init__(self, *args):
        QtGui.QLineEdit.__init__(self, *args)
        self.resetTimeout()
        self.resetErrorCss()
        self._orig_css = self.styleSheet()

    @QtCore.pyqtSignature("setErrorState()")
    def setErrorState(self):
        self.emit(QtCore.SIGNAL("errorStateSet()"))
        self.setStyleSheet(self.error_css)
        QtCore.QTimer.singleShot(self.timeout, self.resetErrorState)

    @QtCore.pyqtSignature("resetErrorState()")
    def resetErrorState(self):
        self.setStyleSheet(self._orig_css)
        self.emit(QtCore.SIGNAL("errorStateReseted()"))

    def getErrorCss(self):
        return self.error_css

    def setErrorCss(self, value):
        self.error_css = value

    def resetErrorCss(self):
        self.error_css = 'background-color: antiquewhite'

    errorCss = QtCore.pyqtProperty("QString", getErrorCss, setErrorCss, resetErrorCss)

    def getTimeout(self):
        return self.timeout

    def setTimeout(self, value):
        self.timeout = value

    def resetTimeout(self):
        self.timeout = 800

    stateTimeout = QtCore.pyqtProperty("int", getTimeout, setTimeout, resetTimeout)

Если теперь запустим дизайнер, не изменяя описание виджета, то увидим, что достигли желаемого.

Теперь мы можем соединить все нужные слоты и сигналы в дизайнере.

Код приложения становится тривиальным. Более того, теперь даже генеренный из .ui py-модуль обладает нужным функционалом.

Это, кстати, достаточно интересно, поскольку позволяет задать уже в дизайнере некое поведение формы и посмотреть/протестировать его отдельно. Как мне кажется, это может быть весьма полезным при прототипировании интерфейса и его тестировании.

Ссылки по теме:

Комментарии

Все статьи