Chapter 19. Using Dialog Windows

Table of Contents
Modal: a preferences dialog
Non-modal: Search and replace
Conclusion

In this chapter we add a few dialog windows to the Kalam program. Dialog windows come in two basic flavors: modal and non-modal. Modal dialog windows block the interface of you application. Settings dialog, file dialogs and such are typically modal. Non-modal dialogs can stay open while the user continues working in the application. Search and replace or style dialogs are typical examples of non-modal dialogs.

Modal: a preferences dialog

We will start with a preferences dialog. Nowadays, the taste is for dialogs with a strip of icons to the left that somehow indicates what section should be shown. But we will start out with a simple tabbed dialog that PyQt supports out of the box, and for which we don't have to draw icons (that's always the difficult bit, creating the artwork).

Designing the dialog

So: time to fire up the designer module of BlackAdder or Qt Designer!

The settings dialog - editor tab

I like to show a sample of what the user selects in the dialog. In this tab, the user can select font, text and background color for the editor windows. These changes are reflected in the little label with the "Lorem ipsum" text. There are two more options: a combobox for selecting the wrapping mode (either no wrapping, wrap to the maximum line width, or wrap to the width of the window), and a spin box to set the maximum line width.

The settings dialog - interface tab

Most users will not immediately get what we mean with "Window view" - in the interface tab w show an example of what we mean, too. I propose to make the "Look and Feel" selection automatically active, so that doesn't need a preview.

To fill in the preview I've snapshotted Kalam in all its variations and scaled the pictures down a lot. Adding these pictures as inline-image data to the dialog would make loading very slow, since Python is not so quick in reading bytecode files. It is better to create a pixmaps directory and store the pictures there.

The settings dialog - document tab

As for the document, we need two settings: the first is the document encoding. While Kalam is meant to be a Unicode editor for standard utf8 files, people might prefer things like iso-8859-1. This is mere window-dressing—actually loading and saving in encodings other than utf-8 will not be implemented for now. The second option is about document endings. A text file should end with a newline character, and we added code to make sure it does in Chapter 17—ultimately, this should be a configuration option.

Of course, during the course of development we will expand the contents of these pages, adding items when we need them. Someone once remarked that a configuration dialog presents the history of design decisions that were avoided during development—and it often feels that way indeed.

Creating the settings dialog window

The first part of the drill is well known: compile the frmsettings.ui file to Python using pyuic.

pyuic -x frmsettings.ui > frmsettings.py
      

You can either call this generated dialog directly from KalamApp, or you can subclass it and add some intelligence. Since intelligence is what is needed to synchronize the switches between interface paradigm, we will go ahead and add subclass the design and add some.

"""
dlgsettings.py - Settings dialog for Kalam.

See: frmsettings.ui

copyright: (C) 2001, Boudewijn Rempt
email:     boud@rempt.xs4all.nl
"""
import os, sys
from qt import *
import kalamconfig

from frmsettings import FrmSettings

class DlgSettings(FrmSettings):

    def __init__(self,
                 parent = None,
                 name = None,
                 modal = 0,
                 fl = 0):
        FrmSettings.__init__(self, parent, name, modal, fl)

        self.textFont = kalamconfig.get("textfont")
        self.textBackgroundColor = kalamconfig.get("textbackground")
        self.textForegroundColor = kalamconfig.get("textforeground")
        self.MDIBackgroundColor = kalamconfig.get("mdibackground")

        self.initEditorTab()
        self.initInterfaceTab()
        self.initDocumentTab()
      

The DlgSettings dialog is a subclass of FrmSettings, which we created with Designer. In the constructor we create four objects for housekeeping purposes, to store changed settings until the user chooses to apply them by pressing OK, or to cancel.

These objects represent the editor font, the editor text color, the editor background color and the background color of the MDI workspace. As you can see from the calls to kalamconfig, actually implementing this dialog necessitated quite a few changes to the kalamconfig module.

The full source of kalamconfig is not so interesting for this chapter, but it is available with the rest of the code. To summarize the development: all settings are now retrieved and set through a single pair of get/set functions. There are a lot more settings, too. If a setting requires special handling, then the relevant get/set function is retrieved from a dictionary (you can just as easily store references to functions or classes in a dictionary as in strings, since everything is considered an object) and executed with apply(). If a setting is changed, a signal is emitted from the QApplication instance, which can be reached with the global variable qApp. Note how the actual signal identifier is constructed dynamically:

#
# kalamconfig.py
# Get and set - set emits a signal via Config.notifier
#
customGetSetDictionary = {
    "style" : (getStyle, setStyle),
    "workspace" : (getWorkspace, setWorkspace),
    "textfont" : (getTextFont, setTextFont),
    "textforeground" : (getTextForegroundColor, setTextForegroundColor),
    "textbackground" : (getTextBackgroundColor, setTextBackgroundColor),
    "mdibackground" : (getMDIBackgroundColor, setMDIBackgroundColor),
}

def set(attribute, value):
    if customGetSetDictionary.has_key(attribute):
        apply(customGetSetDictionary[attribute][1], (value,))
    else:
        setattr(Config, attribute, value)
    qApp.emit(PYSIGNAL("sig" + str(attribute) + "Changed"),
                         (value,))

def get(attribute):
    if customGetSetDictionary.has_key(attribute):
        value = apply(customGetSetDictionary[attribute][0])
    else:
        value = getattr(Config, attribute)
    return value
      

But, let us continue with dlgsettings.py. There are three tab pages, and every tab pages has its own initialization function.

    def initEditorTab(self):
        self.txtEditorPreview.setFont(self.textFont)

        pl = self.txtEditorPreview.palette()
        pl.setColor(QColorGroup.Base, self.textBackgroundColor)
        pl.setColor(QColorGroup.Text, self.textForegroundColor)
        
        self.cmbLineWrapping.setCurrentItem(kalamconfig.get("wrapmode"))
        self.spinLineWidth.setValue(kalamconfig.get("linewidth"))

        self.connect(self.bnBackgroundColor,
                     SIGNAL("clicked()"),
                     self.slotBackgroundColor)
        self.connect(self.bnForegroundColor,
                     SIGNAL("clicked()"),
                     self.slotForegroundColor)
        self.connect(self.bnFont,
                     SIGNAL("clicked()"),
                     self.slotFont)
      

The editor tab shows a nice preview of the font and color combination the user has chosen. Setting these colors, however, is not as straightforward as you might think. Qt widget colors are governed by a complex system based around palettes. A palette (QPalette) contains three color groups (QColorGroup), one that is used if the widget is active, one that is used if the widget is disabled, and one that is used if the widget is inactive.

A QColorGroup in its turn, is a set of colors with certain roles:

  • Background - general background color.

  • Foreground - general foreground color.

  • Base - background color text entry widgets

  • Text - the foreground color used with Base.

  • Button - general button background color

  • ButtonText - for button texts

  • Light - lighter than Button color.

  • Midlight - between Button and Light.

  • Dark - darker than Button.

  • Mid - between Button and Dark.

  • Shadow - a very dark color.

  • Highlight - a color to indicate a selected or highlighted item.

  • HighlightedText - a text color that contrasts to Highlight.

All colors are normally calculated from the Background color. Setting the background color of the editor with the convenience function setBackgroundColor() won't have an effect; we must use the Base color in the relevant QColorGroup.

This system is certainly quite complex, but it allows for tremendous flexibility. Using it isn't too arduous. First, we retrieve the palette from the editor widget:

        pl = self.txtEditorPreview.palette()
        pl.setColor(QColorGroup.Base, self.textBackgroundColor)
        pl.setColor(QColorGroup.Text, self.textForegroundColor)
      

Then we can use the function setColor, which takes a colorgroup role and a QColor as arguments. Note that if we use these functions to change the colors of a widget after it has been shown for the first time, we must call repaint(TRUE) to force the widget to redraw itself. Otherwise Qt's highly optimized drawing engine becomes confused. This will be done in the slot function that's connected to the clicked() signal of the color choice buttons.

    def initInterfaceTab(self):
        self.initStylesCombo()
        self.initWindowViewCombo()
        self.lblBackgroundColor.setBackgroundColor(self.MDIBackgroundColor)
        self.connect(self.bnWorkspaceBackgroundColor,
                     SIGNAL("clicked()"),
                     self.slotWorkspaceBackgroundColor)
      

The preview for the interface style is initialized in initWindowViewCombo. Note that QLabel is rather more simple in its needs than QMultiLineEdit as regards colors. Here, you can just use the convenience function setBackgroundColor (setEraseColor() in Qt 3) to show the preview color for the MDI workspace.

    def initDocumentTab(self):
        self.initEncodingCombo()
        self.chkAddNewLine.setChecked(kalamconfig.get("forcenewline"))
      

This must be the least complex tab, but no doubt we will be adding to it during the course of our development of Kalam.

    def initStylesCombo(self):
        self.cmbStyle.clear()
        styles = kalamconfig.stylesDictionary.keys()
        styles.sort()
        try:
            currentIndex = styles.index(kalamconfig.Config.style)
        except:
            currentIndex = 0
            kalamconfig.setStyle(styles[0])
        self.cmbStyle.insertStrList(styles)
        self.cmbStyle.setCurrentItem(currentIndex)
        self.connect(self.cmbStyle,
                     SIGNAL("activated(const QString &)"),
                     self.setStyle)
      

To make life a lot easer, we have defined a dictionary that maps user-understandable style names to QStyle classes in kalamconfig. Note that we need, in order to find out which one is the current style, not the result of kalamconfig.get("style"), since that returns a QStyle object, but the actual string in the Config.style variable.

# kalamconfig.py - styles dictionary
stylesDictionary = {
    "Mac OS 8.5" : QPlatinumStyle,
    "Windows 98" : QWindowsStyle,
    "Motif" : QMotifStyle,
    "Motif+" : QMotifPlusStyle,
    "CDE" : QCDEStyle
    }
      

The keys of this dictionary are used to fill the style combo. Python dictionaries are unordered, and to ensure that the same style is alwas at the same place in the combobox, we have to sort the list of keys. Sorting a list is done in place in Python, and that means that calling sort() on a list doesn't return a list. If we'd written:

        styles = kalamconfig.stylesDictionary.keys().sort()
      

instead, styles would have been set to None... Activating an entry in the styles combobox emits a signal that is routed to the setStyle() function:

    def setStyle(self, style):
        kalamconfig.set("style", str(style))
        qApp.setStyle(kalamconfig.get("style")())
      

Changing a style is instantaneous in Kalam, if only because it is fun to run through all the styles and see the application changing under your fingers. Therefore, we immediately update the style setting, and call qApp.setStyle() to propagate the changes to the application widgets.

    def initWindowViewCombo(self):
        self.cmbWindowView.clear()

        workspaces = kalamconfig.workspacesDictionary.keys()
        workspaces.sort()
        try:
            currentIndex = workspaces.index(kalamconfig.Config.workspace)
        except:
            currentIndex = 0
            kalamconfig.setWorkspace(workspaces[0])
        self.cmbWindowView.insertStrList(workspaces)
        self.cmbWindowView.setCurrentItem(currentIndex)

        self.connect(self.cmbWindowView,
                     SIGNAL("activated(const QString &)"),
                     self.setWorkspacePreview)
      

Setting up the workspace selection combobox is similar to setting up the styles combobox. The only interesting point is the connection to setWorkspacePreview. This function updates the small image that shows what each option means. These images were made from snapshots, and scaled down with Pixie, a KDE graphics application (which is now obsolete).

    def setWorkspacePreview(self, workspace):
        workspace = str(workspace) + ".png"
        # XXX - when making installable, fix this path
        pixmap = QPixmap(os.path.join("./pixmaps",
                                      workspace))
        self.pxViewSample.setPixmap(pixmap)
      

As you can see, application development is messy, and I don't want to hide all the mess from you. Later, when we make the application distributable in Chapter 26, we will have to come back to this function and devise a way to make Kalam retrieve its pictures from the installation directory.

    def initEncodingCombo(self):
        self.cmbEncoding.clear()
        encodings = kalamconfig.codecsDictionary.keys()
        encodings.sort()
        try:
            currentIndex = encodings.index(kalamconfig.get("encoding"))
        except:
            currentIndex = 0
            Config.encoding = encodings[0]

        self.cmbEncoding.insertStrList(encodings)
        self.cmbEncoding.setCurrentItem(currentIndex)
      

The list of encodings is defined in kalamconfig, just like the list of styles and interface types:

# kalamconfig.py - encodings dictionary

codecsDictionary = {
    "Unicode" : "utf8",
    "Ascii": "ascii",
    "West Europe (iso 8859-1)": "iso-8859-1",
    "East Europe (iso 8859-2)": "iso-8859-2",
    "South Europe (iso 8859-3)": "iso-8859-3",
    "North Europe (iso 8859-4)": "iso-8859-4",
    "Cyrilic (iso 8859-5)": "iso-8859-5",
    "Arabic (iso 8859-6)": "iso-8859-6",
    "Greek (iso 8859-7)": "iso-8859-7",
    "Hebrew (iso 8859-8)": "iso-8859-8",
    "Turkish (iso 8859-9)": "iso-8859-9",
    "Inuit (iso 8859-10)": "iso-8859-10",
    "Thai (iso 8859-11)": "iso-8859-11",
    "Baltic (iso 8859-13)": "iso-8859-13",
    "Gaeilic, Welsh (iso 8859-14)": "iso-8859-14",
    "iso 8859-15": "iso-8859-15",
    "Cyrillic (koi-8)": "koi8_r",
    "Korean (euc-kr)": "euc_kr"}
      

A QMultiLineEdit widget always used Unicode internally, but these codecs are used as a default setting for loading and saving files. Users load an ascii file, edit it in Unicode, and save it back to ascii. Theoretically, you can retrieve the users preferences from his locale. The operating system defines the preferred encoding, but people seldom work with one encoding, and Kalam is meant to provide users with a choice.

While the selection of codecs in Python is large, not all important encodings are available from Python. Japanese (jis, shift-jis, euc-jp), Chinese (gbk) and Tamil (tscii) are only available in Qt (QTextCodec classes), and not in Python. Codecs for the tiscii encoding used for Devagari are not available anywhere. You can download separate Japanese codecs for Python from http://pseudo.grad.sccs.chukyo-u.ac.jp/~kajiyama/python/. (euc-jp, shift_jis, iso-2022-jp)

Note also that iso-8859-8 is visually ordered, and you need Qt 3.0 with the QHebrewCodec to translate iso-8859-8 correctly to Unicode.

    def slotForegroundColor(self):
        color = QColorDialog.getColor(self.textForegroundColor)
        if color.isValid():
            pl = self.txtEditorPreview.palette()
            pl.setColor(QColorGroup.Text, color)
            self.textForegroundColor = color
            self.txtEditorPreview.repaint(1)

    def slotBackgroundColor(self):
        color = QColorDialog.getColor(self.textBackgroundColor)
        if color.isValid():
            pl = self.txtEditorPreview.palette()
            pl.setColor(QColorGroup.Base, color)
            self.textBackgroundColor = color
            self.txtEditorPreview.repaint(1)

    def slotWorkspaceBackgroundColor(self):
        color = QColorDialog.getColor(self.MDIBackgroundColor)
        if color.isValid():
            self.MDIBackgroundColor = color
            self.lblBackgroundColor.setBackgroundColor(color)
      

Each of the color selection buttons is connected to one of these color slot functions. Note that QFontDialog, in contrast with QColorDialog, returns a tuple consisting of a QFont and a value that indicates whether the user pressed OK or Cancel. QColorDialog only returns a color; if the color is invalid, then the user pressed Cancel. This can be confusing, especially since an invalid QColor is just black. Note that we have to call repaint(1), here, to make sure the editor preview is updated.

    def slotFont(self):
        (font, ok) = QFontDialog.getFont(kalamconfig.getTextFont(),
                                         self)
        if ok:
            self.txtEditorPreview.setFont(font)
            self.textFont = font
      

The QFontDialog does return a tuple—and if ok is true, then we update the font of the preview and also set the textFont variable to reflect the users choice.

Finally, there's a bit of code appended to DlgSettings, to make it possible to run the dialog on its own (to test all functionality):

if __name__ == '__main__':
    a = QApplication(sys.argv)
    QObject.connect(a,SIGNAL('lastWindowClosed()'),a,SLOT('quit()'))
    w = DlgSettings()
    a.setMainWidget(w)
    w.show()
    a.exec_loop()
      

Calling the settings dialog window

In order to be able to call the dialog window, we must first create a new QAction and add it to a likely menu. This is done in KalamApp:

# kalamapp.py
    def initActions(self):
        self.actions = {}
        ...
        #
        # Settings actions
        #

        self.actions["settingsSettings"] = QAction("Settings",
                                           "&Settings",
                                           QAccel.stringToKey(""),
                                           self)
        self.connect(self.actions["settingsSettings"],
                     SIGNAL("activated()"),
                     self.slotSettingsSettings)
        ...

    def initMenuBar(self):
        ...
        self.settingsMenu = QPopupMenu()
        self.actions["settingsSettings"].addTo(self.settingsMenu)
        self.menuBar().insertItem("&Settings", self.settingsMenu)
        ...
      

The settingsSettings is connected to a new slot in KalamApp:

    # Settings slots

    def slotSettingsSettings(self):
        dlg = DlgSettings(self,
                          "Settings",
                          TRUE,
                          Qt.WStyle_Dialog)
      

The dialog window is constructed as a function-local variable. That means that if the function reaches its end, the dlg object is deleted. A settings dialog is typically modal. Whether a dialog is created modal or non-modal is determined in the constructor. The first argument to DlgSettings.__init__() is the parent window, in this case KalamApp. The second argument is a name. The third argument determines whether the dialog is modal—TRUE means modal, FALSE means non-modal. FALSE is also the default. The last argument can be any combination of widget flags. For a dialog box, Qt.WStyle_Dialog seems rather appropriate. Note that in Qt 3, this flag is renamed to Qt.WType_Dialog There are a whole lot of flags (the following list is based on Qt 2 - there have been some changes):

  • WType_TopLevel - a toplevel window

  • WType_Modal - Makes the widget modal and inplies WStyle_Dialog.

  • WType_Popup - this widget is a popup top-level window, it is modal, but has a window system frame appropriate for popup menus.

  • WType_Desktop - This widget is the desktop - you can actually use PyQt to paint on you desktop.

  • WStyle_NormalBorder - The window has a normal border.

  • WStyle_DialogBorder - A thin dialog (if you windowmanager on X11 supports that).

  • WStyle_NoBorder - gives a borderless window. However, it is better to use WStyle_NoBorderEx instead, because this flag will make the window completely unusable on X11.

  • WStyle_NoBorderEx - gives a borderless window.

  • WStyle_Title - The window jas a title bar.

  • WStyle_SysMenu - adds a window system menu.

  • WStyle_Minimize - adds a minimize button. On Windows this must be combined with WStyle_SysMenu to work.

  • WStyle_Maximize - adds a maximize button. See WStyle_Minimize.

  • WStyle_MinMax - is equal to WStyle_Minimize|WStyle_Maximize. On Windows this must be combined with WStyle_SysMenu to work.

  • WStyle_ContextHelp - adds a context help button to dialogs.

  • WStyle_Tool - A tool window is a small window that contains tools (for instance, drawing tools, or the step buttons of a debugger). The tool window will always be kept on top of its parent, if there is one.

  • WStyle_StaysOnTop - the window should stay on top of all other windows.

  • WStyle_Dialog - indicates that the window is a dialog window. The window will not get its own taskbar entry and be kept on top of its parent by the window system. This is the flag QDialog uses, and it is not necessary for us to explicitly pass it to DlgSettings.

  • WDestructiveClose - makes Qt delete this object when the object has accepted closeEvent(). Don't use this for dialog windows, or your application will crash.

  • WPaintDesktop - gives this widget paint events for the desktop.

  • WPaintUnclipped - makes all painters operating on this widget unclipped. Children of this widget, or other widgets in front of it, do not clip the area the painter can paint on.

  • WPaintClever - indicates that Qt should not try to optimize repainting for the widget, but instead pass the window system repaint events directly on to the widget.

  • WResizeNoErase - indicates that resizing the widget should not erase it. This allows smart-repainting to avoid flicker.

  • WMouseNoMask - indicates that even if the widget has a mask, it wants mouse events for its entire rectangle.

  • WNorthWestGravity - indicates that the widget contents are north-west aligned and static. On resize, such a widget will receive paint events only for the newly visible part of itself.

  • WRepaintNoErase - indicates that the widget paints all its pixels. Updating, scrolling and focus changes should therefore not erase the widget. This allows smart-repainting to avoid flicker.

  • WGroupLeader - makes this widget or window a group leader. Modality of secondary windows only affects windows within the same group.

You can combine these flags with the or (or |) operator.

Showing a modal dialog is a matter of simply calling exec_loop():

        dlg.exec_loop()
        if dlg.result() == QDialog.Accepted:
            kalamconfig.set("textfont", dlg.textFont)
            kalamconfig.set("workspace", str(dlg.cmbWindowView.currentText()))
            kalamconfig.set("style", str(dlg.cmbStyle.currentText()))
            kalamconfig.set("textbackground", dlg.textBackgroundColor)
            kalamconfig.set("textforeground", dlg.textForegroundColor)
            kalamconfig.set("mdibackground", dlg.MDIBackgroundColor)
            kalamconfig.set("wrapmode", dlg.cmbLineWrapping.currentItem())
            kalamconfig.set("linewidth", int(str(dlg.spinLineWidth.text())))
            kalamconfig.set("encoding", str(dlg.cmbEncoding.currentText()))
            kalamconfig.set("forcenewline", dlg.chkAddNewLine.isChecked())
      

If the execution loop of a modal dialog terminates, the dialog object is not destroyed, and you can use the reference to the object to retrieve the contents of its widgets. By calling result() on the dialog object you can determine whether the user pressed OK or Cancel.

In this example, if the user presses OK, all relevant settings in kalamconfig are updated. This causes kalamconfig to emit change signals that are caught by all relevant objects.

The workspace object is updated:

    def initWorkSpace(self):
        workspace = kalamconfig.get("workspace")(self)
        workspace.setBackgroundColor(kalamconfig.get("mdibackground"))
        self.connect(qApp,
                     PYSIGNAL("sigmdibackgroundChanged"),
                     workspace.setBackgroundColor)
        self.setCentralWidget(workspace)
        return workspace
      

All view objects are updated, too. Some of the changes can be directly connected to the editor widget, the font setting, while others need a bit of processing, like the wrap mode:

# kalamview.py - extract
...
import kalamconfig
...
class KalamView(QWidget):

    def __init__(self, parent, doc, *args):
        ...
        self.editor.setFont(kalamconfig.get("textfont"))
        self.setWordWrap(kalamconfig.get("wrapmode"))
        self.setBackgroundColor(kalamconfig.get("textbackground"))
        self.setTextColor(kalamconfig.get("textforeground"))
        ...
        self.connect(qApp,
                     PYSIGNAL("siglinewidthChanged"),
                     self.editor.setWrapColumnOrWidth)
        self.connect(qApp,
                     PYSIGNAL("sigwrapmodeChanged"),
                     self.setWordWrap)
        self.connect(qApp,
                     PYSIGNAL("sigtextfontChanged"),
                     self.editor.setFont)
        self.connect(qApp,
                     PYSIGNAL("sigtextforegroundChanged"),
                     self.setTextColor)
        self.connect(qApp,
                     PYSIGNAL("sigtextbackgroundChanged"),
                     self.setBackgroundColor)
        ...

    def setTextColor(self, qcolor):
        pl = self.editor.palette()
        pl.setColor(QColorGroup.Text, qcolor)
        self.editor.repaint(TRUE)

    def setBackgroundColor(self, qcolor):
        pl = self.editor.palette()
        pl.setColor(QColorGroup.Base, qcolor)
        self.editor.setBackgroundColor(qcolor)        
        self.editor.repaint(TRUE)
        
    def setWordWrap(self, wrapmode):

        if wrapmode == 0:
            self.editor.setWordWrap(QMultiLineEdit.NoWrap)
        elif wrapmode == 1:
            self.editor.setWordWrap(QMultiLineEdit.WidgetWidth)
        else:
            self.editor.setWordWrap(QMultiLineEdit.FixedColumnWidth)
            self.editor.setWrapColumnOrWidth(kalamconfig.get("linewidth"))
    ...
      

Not all changes can be activated while the application is running. The workspace style is determined when the application is restarted. It is nice and courteous to inform the user so. The best place to do that is in slotSettingsSettings():

    def slotSettingsSettings(self):
        ...
        if dlg.result() == QDialog.Accepted:
            ...
            workspace = str(dlg.cmbWindowView.currentText())
            if kalamconfig.Config.workspace <> workspace:
                kalamconfig.set("workspace", workspace)
                QMessageBox.information(self,
                                        "Kalam",
                                        "Changes to the interface style " +
                                        "will only be activated when you " +
                                        "restart the application.")
        ...