Dugan Chen's Homepage

Various Things

A Humble Dialog implementation in PyQt

The following is an implementation of the Humble Dialog Box pattern (no relation to the Humble Bundle) in PyQt. Honestly, no further explanation should be necessary.

The paper opens with a screenshot of the dialog box being used as an example. I have a full implementation of that dialog box’s user interface. The rest is as faithful as possible to the original C++ code.

As you can see, this pattern fits PyQt well.

#!/usr/bin/env python

from sip import setapi
setapi("QDate", 2)
setapi("QDateTime", 2)
setapi("QTextStream", 2)
setapi("QTime", 2)
setapi("QVariant", 2)
setapi("QString", 2)
setapi("QUrl", 2)

from PyQt4.QtCore import QObject
from PyQt4.QtGui import (QApplication, QDialog, QHBoxLayout,
                         QLabel, QListWidget, QPushButton,
                         QVBoxLayout)
import sys


def main():
    app = QApplication(sys.argv)
    view = ChainComposerDialog()
    composed = ChainComposer(view)
    composed.initialize()
    view.show()
    sys.exit(app.exec_())


class ChainComposer(QObject):
    def __init__(self, view, parent=None):
        super(ChainComposer, self).__init__(parent)
        self .__view = view
        view.setComposer(self)

    def initialize(self):
        self.__view.setSelectionList = ('Reverb',)

    def add(self, row):
        self.__view.addToChain(self.__view.filterAt(row))


class ChainComposerDialog(QDialog):

    def __init__(self, parent=None):
        super(ChainComposerDialog, self).__init__(parent)
        self.setWindowTitle('Composer Chain')

        layout = QVBoxLayout()

        mainLayout = QHBoxLayout()

        selectionLayout = QVBoxLayout()
        label = QLabel('Available Filters:')
        selectionLayout.addWidget(label)
        self.__selectionList = QListWidget()
        selectionLayout.addWidget(self.__selectionList)
        mainLayout.addLayout(selectionLayout)

        actionsLayout = QVBoxLayout()
        actionsLayout.addStretch()
        addButton = QPushButton('Add>>')
        addButton.clicked.connect(self.__handleAdd)
        actionsLayout.addWidget(addButton)
        removeButton = QPushButton('Remove')
        actionsLayout.addWidget(removeButton)
        removeAllButton = QPushButton('Remove All')
        actionsLayout.addWidget(removeAllButton)
        actionsLayout.addStretch()
        mainLayout.addLayout(actionsLayout)

        chainLayout = QVBoxLayout()
        chainLayout.addWidget(QLabel('Chain:'))
        self.__chainList = QListWidget()
        chainLayout.addWidget(self.__chainList)
        mainLayout.addLayout(chainLayout)

        layout.addLayout(mainLayout)

        buttonLayout = QHBoxLayout()
        okButton = QPushButton('OK')
        okButton.clicked.connect(self.accept)
        buttonLayout.addWidget(okButton)
        cancelButton = QPushButton('Cancel')
        cancelButton.clicked.connect(self.reject)
        buttonLayout.addWidget(cancelButton)
        layout.addLayout(buttonLayout)

        self.setLayout(layout)

        self.__composer = None

    def setComposer(self, composer):
        self.__composer = composer

    @property
    def selectionList(self):
        return self.__getStrings(self.__selectionList)

    @selectionList.setter
    def setSelectionList(self, filters):
        for i in xrange(self.__selectionList.count()):
            self.__selectionList.takeItem(i)
        self.__selectionList.addItems(filters)

    def filterAt(self, row):
        return self.__selectionList.item(row).text()

    def addToChain(self, filterName):
        self.__chainList.addItem(filterName)

    @property
    def composedFilter(self):
        return self.__getStrings(self.__chainList)

    @staticmethod
    def __getStrings(listWidget):
        return tuple(listWidget.item(i) for i in
                     range(listWidget.count()))

    def __handleAdd(self):
        if self.__composer is None:
            return
        for item in self.__selectionList.selectedItems():
            row = self.__selectionList.row(item)
            self.__composer.add(row)


if __name__ == '__main__':
    main()

The unit test suite (I use nose, as usual) is almost identical that in the paper:

from humbledialog import ChainComposer
from nose.tools import eq_


def test_initialize():
    view = MockChainComposerView()
    composer = ChainComposer(view)
    eq_(0, len(view.selectionList))
    composer.initialize()
    eq_(1, len(view.selectionList))


def test_selectfilter():
    view = MockChainComposerView()
    composer = ChainComposer(view)
    composer.initialize()
    composer.add(0)
    eq_(1, len(view.composedFilter))


class MockChainComposerView(object):

    def __init__(self):
        self.__selectionList = ()
        self.__chainList = []

    @property
    def selectionList(self):
        return self.__selectionList

    @selectionList.setter
    def setSelectionList(self, filters):
        self.__selectionList = filters

    def filterAt(self, row):
        return self.__selectionList[row]

    def addToChain(self, filterName):
        self.__chainList.append(filterName)

    @property
    def composedFilter(self):
        return tuple(self.__chainList)

    def setComposer(self, composer):
        pass

You will notice that the “interface” (as in “implements interface”) layer of the architecture has been eliminated, because we’re using Python. I also don’t redundantly store the same data both in the widgets and in the “composer”, because that’s just ridiculous. (I’m amazed at how many examples of MVn pattern implementations do exactly that).

…you run little risk by not testing the view.

Note that one of the ideas behind this pattern is that the view’s API is minimal both externally (to facilitate mocking) and internally (so that you don’t have to test if you don’t have to). The “to facilitate mocking” part of the requirement means that the dialog box does not use Qt signals to interact with the composer. For the other point: yes, I’m serious. As Martin Fowler points out in his review of the available GUI Architectures, one of the cornerstones of the Humble Dialog is a Passive View. Not having to test the view is the whole point of a passive view.

However, if you want to run the unit test suite with the actual GUI and not a mock, you can do that too. Just create a QApplication instance first, and the unit test runner will run it just fine:

from sip import setapi
setapi("QDate", 2)
setapi("QDateTime", 2)
setapi("QTextStream", 2)
setapi("QTime", 2)
setapi("QVariant", 2)
setapi("QString", 2)
setapi("QUrl", 2)

from PyQt4.QtGui import QApplication
from humbledialog import (ChainComposer,
                          ChainComposerDialog)
from nose.tools import eq_
import sys


app = QApplication(sys.argv)


def test_initialize():
    view = ChainComposerDialog()
    composer = ChainComposer(view)
    eq_(0, len(view.selectionList))
    composer.initialize()
    eq_(1, len(view.selectionList))


def test_selectfilter():
    view = ChainComposerDialog()
    composer = ChainComposer(view)
    composer.initialize()
    composer.add(0)
    eq_(1, len(view.composedFilter))