Monday, January 30, 2012

Qt 4.8 Address Book Example - Part 5

In this last part, two private methods, not included in the original example, have been added to the AddressWidget: _revName() and _switchTab() and the addEntry() and _setUpTabs() methods have been modified to accomodate their use.

_revName() was created to allow the Address Book entry names to be sorted, and displayed, based on last, rather than first, names.

_switchTab() was added to flip the active tab to the tab containing the newly added name.



#!/usr/bin/env python3
# -*- coding: utf-8 -*-

'''
    PyQt4 conversion of Qt Address Book (Tabbed) Example
    
        The address book example shows how to use proxy models to display 
        different views onto data from a single model.
        
        This example provides an address book that allows contacts to be 
        grouped alphabetically into 9 groups: 
            ABC, DEF, GHI, ... , VW, ..., XYZ. 
        This is achieved by using multiple views on the same model, each of 
        which is filtered using an instance of the QSortFilterProxyModel class.
          
    BEHAVIOUR:
    =========
    Entries are sorted on their last names.
    When a new entry is added, the display switches to the tab
    containing the entry
    
    NOTES:
    =====
    AddressWidget
        - added private method _revName(name), called by addEntry()
          to put the input name into Last Name, First Name order so 
          sort occurs on last name
          
        - added private method _swtichTab(name), called by addEntry()
          to switch to the tab of the newly entered name. Required
          changing 'groups' list in _setUpTabs() to a private
          attribute self._groups
          
        - modified _removeEntry() to switch to 'Address Book' tab
          if their are no entries
    
last modified: 2012-01-29 jg
ref:
    http://developer.qt.nokia.com/doc/qt-4.8/itemviews-addressbook.html
    
'''
import pickle
from PyQt4.QtGui import (QApplication, QMainWindow, QWidget, QAction, QFileDialog,
                         QLabel, QPushButton, QVBoxLayout, QSortFilterProxyModel,
                         QTableView, QAbstractItemView, QTabWidget, QItemSelection,
                         QDialog, QLineEdit, QTextEdit, QGridLayout, QHBoxLayout,
                         QMessageBox, QItemSelectionModel)
from PyQt4.QtCore import (pyqtSlot, pyqtSignal, Qt, QAbstractTableModel, QModelIndex,
                          QRegExp)

class AddDialog(QDialog):
    def __init__(self, parent=None):    # initialise base class
        super(AddDialog, self).__init__(parent)

        nameLabel = QLabel(self.tr("Name"))
        addrLabel = QLabel(self.tr("Address"))
        okBtn = QPushButton(self.tr("OK"))
        cancelBtn = QPushButton(self.tr("Cancel"))

        self.nameText = QLineEdit()
        self.addrText = QTextEdit()

        grid = QGridLayout()
        grid.setColumnStretch(1, 2)
        grid.addWidget(nameLabel, 0, 0)
        grid.addWidget(self.nameText, 0, 1)

        grid.addWidget(addrLabel, 1, 0, Qt.AlignLeft | Qt.AlignTop)
        grid.addWidget(self.addrText, 1, 1, Qt.AlignLeft)

        btnLayout = QHBoxLayout()
        btnLayout.addWidget(okBtn)
        btnLayout.addWidget(cancelBtn)
        grid.addLayout(btnLayout, 2, 1, Qt.AlignRight)

        mainLayout = QVBoxLayout()
        mainLayout.addLayout(grid)
        self.setLayout(mainLayout)

        # connect signals/slots for event handling
        okBtn.clicked.connect(self.accept)
        cancelBtn.clicked.connect(self.reject)

        self.setWindowTitle(self.tr("Add a Contact"))


class NewAddressTab(QWidget):
    def __init__(self, parent=None):    # initialise base class
        super(NewAddressTab, self).__init__(parent)

        descripLabel = QLabel(self.tr(
                        "There are currently no contacts in your Address Book"
                        "\nClick Add to add new contacts."))
        addBtn = QPushButton(self.tr("Add"))
        addBtn.clicked.connect(self._addEntry)

        layout = QVBoxLayout()
        layout.addWidget(descripLabel)
        layout.addWidget(addBtn)
        self.setLayout(layout)

    # signal ------------------------------------------------------------------
    sendDetails = pyqtSignal(str, str, name="sendDetails")

    # private methods ---------------------------------------------------------
    def _addEntry(self):
        aDialog = AddDialog()
        if aDialog.exec():
            name = aDialog.nameText.text()
            addr = aDialog.addrText.toPlainText()

            self.sendDetails.emit(name, addr)


class TableModel(QAbstractTableModel):
    def __init__(self, parent=None, pairs=[['', '']]):    # initialise base class
        super(TableModel, self).__init__(parent)
        self._listOfPairs = pairs

    # overridden methods ------------------------------------------------------
    def rowCount(self, parent=QModelIndex()):
        return len(self._listOfPairs)

    def columnCount(self, parent=QModelIndex()):
        return 2

    def data(self, index, role=Qt.DisplayRole):
        # displays the row contents
        if not index.isValid():
            return None

        if (index.row() >= len(self._listOfPairs) or
            index.row() < 0):
            return None

        if role == Qt.DisplayRole:
            pair = self._listOfPairs[index.row()]

            if index.column() == 0:
                return pair[0]
            elif index.column() == 1:
                return pair[1]

        return None

    def headerData(self, section, orientation, role):
        if role != Qt.DisplayRole:
            return None

        if orientation == Qt.Horizontal:
            if section == 0: return self.tr("Name")
            elif section == 1: return self.tr("Address")
            else: return None

        return None

    def insertRows(self, position, rows, index=QModelIndex()):
        self.beginInsertRows(QModelIndex(), position, position + rows - 1)

        for r in range(rows):
            pair = [" ", " "]
            self._listOfPairs.insert(position, pair)

        self.endInsertRows()
        return True

    def removeRows(self, position, rows, index=QModelIndex()):
        self.beginRemoveRows(QModelIndex(), position, position + rows - 1)

        for r in range(rows):
            # use list slicing to remove rows
            self._listOfPairs = (self._listOfPairs[:position] +
                                 self._listOfPairs[position + rows:])

        self.endRemoveRows()
        return True

    def setData(self, index, value, role=Qt.EditRole):
        # updates the table model and emits a signal
        # to notify views that data has changed
        if index.isValid() and role == Qt.EditRole:
            row = index.row()
            pair = self._listOfPairs[row]

            if index.column() == 0:
                pair[0] = value
            elif index.column() == 1:
                pair[1] = value
            else:
                return False

            self._listOfPairs[row] = pair

            # this signal is defined in the QAbstractTableModel 
            # parent class
            self.dataChanged.emit(index, index)
            return True

        return False

    def flags(self, index):
        if not index.isValid():
            return Qt.ItemIsEnabled
        return Qt.ItemFlags(QAbstractTableModel.flags(self, index) |
                            Qt.ItemIsEditable)


    # public methods ----------------------------------------------------------
    def getList(self):
        return self._listOfPairs

class AddressWidget(QTabWidget):
    def __init__(self, parent=None):    # initialise base class
        super(AddressWidget, self).__init__(parent)

        self._table = TableModel(self)
        self._newAddressTab = NewAddressTab(self)
        self._newAddressTab.sendDetails.connect(self.addEntry)
        self.addTab(self._newAddressTab, "Address Book")

        self._setUpTabs()

    # signals --------------------------------------------------------------
    selectionChanged = pyqtSignal(QItemSelection, name="selectionChanged")

    # public slots ---------------------------------------------------------
    @pyqtSlot(str, str)
    def addEntry(self, name=None, address=None):

        if not name:
            aDialog = AddDialog()
            if aDialog.exec():
                name = aDialog.nameText.text()
                address = aDialog.addrText.toPlainText()

                # recursive call
                self.addEntry(name, address)
        else:
            entries = self._table.getList()
            name = self._revName(name)      # use last name for sorting
            pair = [name, address]
            if not pair in entries:
                self._table.insertRows(0, 1, QModelIndex())

                index = self._table.index(0, 0, QModelIndex())
                self._table.setData(index, name, Qt.EditRole)

                index = self._table.index(0, 1, QModelIndex())
                self._table.setData(index, address, Qt.EditRole)

                self.removeTab(self.indexOf(self._newAddressTab))

                self._switchTab(name)
            else:
                QMessageBox.information(
                    self,
                    self.tr("Duplicate Name"),
                    self.tr("The name {0} already exists.".format(name)))

    @pyqtSlot()
    def editEntry(self):
        # only the address can be edited
        temp = self.currentWidget()
        proxy = temp.model()
        selModel = temp.selectionModel()

        indexes = selModel.selectedRows()
        row = -1

        for index in indexes:
            row = proxy.mapToSource(index).row()
            i = self._table.index(row, 0, QModelIndex())
            name = self._table.data(i, Qt.DisplayRole)

            i = self._table.index(row, 1, QModelIndex())
            address = self._table.data(i, Qt.DisplayRole)

        aDialog = AddDialog()
        aDialog.setWindowTitle(self.tr("Edit an Entry"))
        aDialog.nameText.setReadOnly(True)
        aDialog.nameText.setText(name)
        aDialog.addrText.setText(address)

        if aDialog.exec():
            newAddr = aDialog.addrText.toPlainText()
            if newAddr != address:
                i = self._table.index(row, 1, QModelIndex())
                self._table.setData(i, newAddr, Qt.EditRole)

    @pyqtSlot()
    def removeEntry(self):
        temp = self.currentWidget()
        proxy = temp.model()
        selModel = temp.selectionModel()

        indexes = selModel.selectedRows()
        for index in indexes:
            row = proxy.mapToSource(index).row()
            self._table.removeRows(row, 1, QModelIndex())

        # at this point, 'rowCount' still includes the deleted entry
        if self._table.rowCount(QModelIndex()) <= 1:
            self.insertTab(0, self._newAddressTab, "Address Book")
            self.setCurrentIndex(0)

    # public methods ---------------------------------------------------------
    def readFromFile(self, fname):
        with open(fname, 'rb') as f:
            pairs = pickle.load(f)

        for i in range(len(pairs) - 1):
            p = pairs[i]
            self.addEntry(p[0], p[1])


    def writeToFile(self, fname):
        with open(fname, 'wb') as f:
            pickle.dump(self._table.getList(), f, pickle.HIGHEST_PROTOCOL)


    # private methods ---------------------------------------------------------
    def _setUpTabs(self):
        self._groups = ["ABC", "DEF", "GHI", "JKL", "MNO", "PQR", "STU", "VW", "XYZ"]

        for i in range(len(self._groups)):
            tabName = self._groups[i]     # 'str' is a reserved word in Python

            proxyModel = QSortFilterProxyModel(self)
            proxyModel.setSourceModel(self._table)
            proxyModel.setDynamicSortFilter(True)

            tableView = QTableView()
            tableView.setModel(proxyModel)
            tableView.setSortingEnabled(True)
            tableView.setSelectionBehavior(QAbstractItemView.SelectRows)
            tableView.horizontalHeader().setStretchLastSection(True)
            tableView.verticalHeader().hide()
            tableView.setEditTriggers(QAbstractItemView.NoEditTriggers)
            tableView.setSelectionMode(QAbstractItemView.SingleSelection)

            # use a QRegExp to limit names displayed on each tab
            # to their appropriate tab group
            newStr = "^[{0}].*".format(tabName)
            proxyModel.setFilterRegExp(QRegExp(newStr, Qt.CaseInsensitive))
            proxyModel.setFilterKeyColumn(0)
            proxyModel.sort(0, Qt.AscendingOrder)

            tableView.selectionModel().selectionChanged.connect(self.selectionChanged)

            self.addTab(tableView, tabName)

    def _revName(self, name):
        # reverse name so sort will be on last name
        n = name.rsplit(' ', 1)
        if len(n) > 1:  # allow single names
            rname = n[1] + ", " + n[0]
        else:
            rname = name

        return rname

    def _switchTab(self, name):
        # switch to the tab that contains the given name
        pos = self.currentIndex()
        txt = self.tabText(pos)
        initial = name[0].upper()

        if initial not in txt:
            for tab in self._groups:
                if initial in tab:
                    index = self._groups.index(tab)
                    self.setCurrentIndex(index)
                    return

class MainWindow(QMainWindow):

    def __init__(self, parent=None):    # initialise base class
        super(MainWindow, self).__init__(parent)

        self._addrWidget = AddressWidget()
        self.setCentralWidget(self._addrWidget)
        self._createFileMenu()      # extracted from createMenus()
        self._createToolsMenu()     # extracted from createMenus()
        self._addrWidget.selectionChanged.connect(self._updateActions)
        self.setWindowTitle(self.tr("Address Book - Part 5"))

    # private methods ---------------------------------------------------------
    def _createFileMenu(self):
        # create actions
        openAct = QAction(self.tr("&Open..."), self)
        saveAct = QAction(self.tr("&Save..."), self)
        exitAct = QAction(self.tr("E&xit"), self)

        # connect signals/slots for event handling
        openAct.triggered.connect(self._openFile)
        saveAct.triggered.connect(self._saveFile)
        exitAct.triggered.connect(QApplication.instance().exit)

        # create menu
        fileMenu = self.menuBar().addMenu(self.tr("&File"))
        fileMenu.addAction(openAct)
        fileMenu.addAction(saveAct)
        fileMenu.addSeparator()
        fileMenu.addAction(exitAct)

    def _createToolsMenu(self):
        # create actions
        addAct = QAction(self.tr("&Add Entry..."), self)
        self._editAct = QAction(self.tr("&EditEntry..."), self)
        self._removeAct = QAction(self.tr("&Remove Entry"), self)

        # connect signals/slots for event handling
        addAct.triggered.connect(self._addrWidget.addEntry)
        self._editAct.triggered.connect(self._addrWidget.editEntry)
        self._removeAct.triggered.connect(self._addrWidget.removeEntry)

        # create menu
        toolMenu = self.menuBar().addMenu(self.tr("&Tools"))
        toolMenu.addAction(addAct)
        toolMenu.addAction(self._editAct)
        toolMenu.addSeparator()
        toolMenu.addAction(self._removeAct)

        # set menu item visibility
        self._editAct.setEnabled(False)
        self._removeAct.setEnabled(False)

    # private slots ---------------------------------------------------------
    @pyqtSlot()
    def _openFile(self):
        fname = QFileDialog.getOpenFileName(self)
        if fname:
            self._addrWidget.readFromFile(fname)

    @pyqtSlot()
    def _saveFile(self):
        fname = QFileDialog.getSaveFileName(self)
        if fname:
            self._addrWidget.writeToFile(fname)

    @pyqtSlot(QItemSelection)
    def _updateActions(self, sel):
        indexes = sel.indexes()

        if indexes:
            self._removeAct.setEnabled(True)
            self._editAct.setEnabled(True)
        else:
            self._removeAct.setEnabled(False)
            self._editAct.setEnabled(False)


# main ========================================================================
def main():
    import sys

    app = QApplication(sys.argv)
    mw = MainWindow()
    mw.show()

    sys.exit(app.exec_())

if __name__ == '__main__':
    main()