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