Chapter 21. Drawing on Painters and Canvases

Table of Contents
Working with painters and paint devices
QCanvas
Conclusion

Constructing windows out of predefined widgets is all very nice, but the real exciting stuff occurs when you have to leave the beaten track and push the pixels around yourself. This happens when you want to create diagram editors, drawing applications, games (especially games!), complex page layout engines (like an html renderer), charts, and plots.

Python and PyQt form a good basis for this kind of work, because Qt already has two very powerful and optimized drawing engines: the QCanvas class and the QPainter. QCanvas is extremely useful if your drawing can be composed from separate elements, and if you want to be able to track events that occur on those individual elements, such as mouse clicks.

QPainter offers finer control of what you put on screen, at the cost of losing control over the individual elements that make up the drawing. QPainter is more suited to drawing charts, bit-mapped drawings and plots. If you want real plotting power, you should investigate PyQwt, which is introduced in Appendix B

Both QCanvas and QPainter are very powerful. In fact, they are even used in assembling software used to create animated films. Animation means a lot of intensive work for your computer, though, and Python can not always cope—even on the most modern machines. In such cases, it is quite easy to replace the Python class with a Qt-based C++ object (you won't have to translate your whole Python application). See Appendix C for information on the wrapping of new C++ objects with sip.

Working with painters and paint devices

In Chapter 10 we introduced QPainter, for creating our little scribbling example application, event1.py. In this section we will take a closer look at the organization and use of the PyQt painting mechanism.

Painting in PyQt involves the cooperation of two classes: a QPainter and a QPaintDevice. The first is a very efficient abstraction of various drawing operations. It provides the brushes and the letterpress, so to speak. The second provides the ‘paper' on which to draw.

There are four subclasses of QPaintDevice: QWidget, QPicture, QPixMap and QPrinter.

You use hand-coded painting with a QPainter object on a QWidget to determine the look of a widget. The place for the painting code is in re-implementations of the paintEvent() function.

QPicture is a kind of event recorder: it records every QPainter action, and can replay them. You can also save those actions to a platform independent file. This is useful if you want to implement rolling charts with a limited replay functionality (although I would prefer to save the underlying data and reconstruct the chart every time). You cannot alter anything in the sequence of events once it is recorded. Starting with Qt 3, QPicture has become quite powerful, with the ability to load and save industry standard .svg files - the scalable vector graphics format.

Painting on a QPixMap is extraordinarily useful. Painting is always a bit slow, especially if it is done line by line, dot by dot, and character by character. This can result in visible lag or flickering if you paint directly on an exposed QWidget. By first painting the complete drawing on a QPixMap object, and then using the bitBlt() function to move the picture in one swoop the the widget, you will avoid this flickering. bitBlt() really is fast.

Finally, being able to paint on a QPrinter object means that anything you can draw on-screen can also be printed. However, printing is still quite a difficult subject — even if PyQt can generate your PostScript for you, you still have to layout everything yourself. You cannot, for instance, send the contents of a QSimpleRichText widget to a printer just like that... We'll discuss the basics of printing in Chapter 24.

A painting example

There is little we can do using QPainter and QPaintDevices in our Kalam project — but after long and hard thinking I thought a rolling chart that counts how many characters the user types per minute might be a nice, although completely useless (and possibly frustrating) except for real productivity freaks.

Example 21-1. typometer.py - A silly type-o-meter that keeps a running count of how many characters are added to a certain document and shows a chart of the typerate...

"""
typometer.py

A silly type-o-meter that keeps a running count of how many characters there
are in a certain document and shows a chart of the count...
"""
import sys, whrandom
from qt import *

FIVE_SECONDS = 1000 * 5 #  5 seconds in milli-seconds
AVERAGE_TYPESPEED = 125 # kind of calibration
BARWIDTH = 3

TRUE=1
FALSE=0
          

No surprises here—just some declarations. I like to work with names instead of magic numbers, and to conform to practice in other programming languages, those names are in all-caps, even though they are not constants.

class TypoGraph(QPixmap):
    """ TypoGraph is a subclass of QPixmap and draws a small graph of
    the current wordcount of a text.
    """
    def __init__(self, count, w, h, *args):
        apply(QPixmap.__init__, (self, w, h) + args)
        self.count = count
        self.maxCount = AVERAGE_TYPESPEED
        if count != 0:
            self.scale = float(h) / float(count)
        else:
            self.scale = float(h) / float(AVERAGE_TYPESPEED)
        self.col = 0
        self.fill(QColor("white"))
        self.drawGrid()
      

The general design of this chart drawing code consists of two parties: a specialized pixmap, descended from QPixmap, that will draw the chart and keep track of scrolling, and a widget that show the chart and can be used everywhere where you might want to use a widget.

In the constructor of TypoGraph, the specialized QPixMap, certain initial variables are set. One point of attention is scaling. The chart will have a certain fixed vertical size. It is quite possible that the plotted values won't fit into the available pixels.

This means that we have to scale the values to fit the pixels of the chart. This is done by arbitrarily deciding upon a maximum value, and dividing the height of the chart by that value. Any value greater than the maximum will go off the chart, but if you can type more than 125 characters in five seconds, you deserve to fly off the chart!

Because the scaling can be smaller than one but greater than zero, we need to use float numbers for our scale. Floats are notoriously slow, but believe me, your computer can handle more floats than you can throw at it per second, so you won't feel the penalty for not using integers.

Finally, we fill the pixmap with a background color (white in this case) and draw a nice grid:

    def drawGrid(self):
        p = QPainter(self)
        p.setBackgroundColor(QColor("white"))
        h = self.height()
        w = self.width()
        for i in range(1, h, h/5):
            p.setPen(QColor("lightgray"))
            p.drawLine(0, i, w, i)
      

This is the first encounter with QPainter. The basic procedure for working with painter objects is very simple: you create a painter for the right paintdevice. Here the paintdevice is self — our specialized QPixMap. After having created the QPainter you can mess about drawing lines, setting colors or throwing more complex shapes on the paper. Here, we draw four lines at equal distances using a light-gray pen. The distance is computed by letting the range function use the height of the widget divided by the number of rows we want as a stepsize.

If you wish to use several different painter objects, you might want to use the begin() and end() methods of the QPainter class. In normal use, as here, the begin() function is called when the QPainter is created, and end() when it is destroyed. However, because the reference goes out of scope, end() is called automatically, so you won't have to call end() yourself.

    def text(self):
        return QString(str(self.count))
      

The function text() simply returns a QString object containing the last plotted value. We will use this to set the caption of the chart window.

    def update(self, count):
        """
        Called periodically by a timer to update the count.
        """
        self.count = count

        h = self.height()
        w = self.width()

        p = QPainter(self)
        p.setBackgroundColor(QColor("white"))

        p.setBrush(QColor("black"))
        
        if self.col >= w:
            self.col = w
            # move one pixel to the left
            pixmap = QPixmap(w, h)
            pixmap.fill(QColor("white"))
            bitBlt(pixmap, 0, 0,
                   self, BARWIDTH, 0, w - BARWIDTH, h)
            
            bitBlt(self, 0, 0, pixmap, 0, 0, w, h)
            for i in range(1, h, h/5):
                p.setPen(QColor("lightgray"))
                p.drawLine(self.col - BARWIDTH , i, w, i)
        else:
            self.col += BARWIDTH

        y = float(self.scale) * float(self.count)
        # to avoid ZeroDivisionError
        if y == 0: y = 1

        # Draw gradient
        minV = 255
        H = 0
        S = 255

        vStep = float(float(128)/float(y))
        for i in range(y):
            color = QColor()
            color.setHsv(H, S, 100 + int(vStep * i))
            p.setPen(QPen(color))
            p.drawLine(self.col - BARWIDTH, h-i, self.col, h-i)
      

The update() function is where the real meat of the charting pixmap is. It draws a gradiented bar that scrolls left when the right side is reached (that is, if the current column has arrived at or gone beyond the width of the pixmap).

The scrolling is done by creating a new, empty QPixmap and blitting the right hand part of the old pixmap onto it. When writing this code, I noticed that you cannot blit a pixmap onto itself. So, after we've created a pixmap that contains the old pixmap minus the first few vertical lines, we blit it back, and add the grid to the now empty right hand side of the pixmap.

The height of the bar we want to draw is computed by multiplying the value (self.count) with the scale of the chart. If the result is 0, we make it 1.

We draw the bar in steps, with each step having a subtly differing color from the one before it. The color gradient is determined by going along the value range of a hue-saturation-value color model. Value determines darkness, with 0 being completely dark, and 255 completely light. We don't use the complete range, but step directly from 100 (fairly dark) to 228 (quite bright). The step is computed by dividing the value range we want (128) by the height of the bar. Every bar is going from 100 to 228.

Then we step through the computed height of the bar, drawing a horizontal line with the length of the bar thickness — BARWIDTH.

Computing gradients is fairly costly, but it is still possible to type comfortably when this chart is running: a testimony to the efficient design of QPainter. If your needs are more complicated, then QPainter offers a host of sophisticated drawing primitives (and not so primitives, like shearing, scaling, resizing and the drawing of quad beziers).

The TypoGraph is completely generic: it draws a nicely gradiented graph of any values that you feed the update function. There's some testing code included that uses a simple timer to update the chart with a random value.

A stand-alone chart

More application-specific is the TypoMeter widget, which keeps track of all open Kalam documents, and shows the right chart for the currently active document.

class TypoMeter(QWidget):

    def __init__(self, docmanager, workspace, w, h, *args):
        apply(QWidget.__init__, (self,) + args)

        self.docmanager = docmanager
        self.workspace = workspace

        self.resize(w, h)
        self.setMinimumSize(w,h)
        self.setMaximumSize(w,h)

        self.h = h
        self.w = w

        self.connect(self.docmanager,
                     PYSIGNAL("sigNewDocument"),
                     self.addGraph)
        self.connect(self.workspace,
                     PYSIGNAL("sigViewActivated"),
                     self.changeGraph)
        self.graphMap = {}
        self.addGraph(self.docmanager.activeDocument(),
                      self.workspace.activeWindow())

        self.timer = QTimer(self)
        self.connect(self.timer,
                     SIGNAL("timeout()"),
                     self.updateGraph)
        self.timer.start(FIVE_SECONDS, FALSE)
      

In order to implement this feature, some new signals had to be added to the document manager and the workspace classes. Note also the use of the QTimer class. A timer is created with the current object as its parent; a slot is connected to the timeout() signal, and the timer is started with a certain interval. The FALSE parameter means that the timer is supposed to keep running, instead of firing once, when the timeout is reached.

    def addGraph(self, document, view):
        self.currentGraph = TypoGraph(0,
                                      self.h,
                                      self.w)
        self.graphMap[document] = (self.currentGraph, 0)
        self.currentDocument = document


    def changeGraph(self, view):
        self.currentGraph = self.graphMap[view.document()][0]
        self.currentDocument = view.document()
        bitBlt(self, 0, 0,
               self.currentGraph,
               0, 0,
               self.w,
               self.h)

    def updateGraph(self):

        prevCount = self.graphMap[self.currentDocument][1]
        newCount = self.currentDocument.text().length()
        self.graphMap[self.currentDocument] = (self.currentGraph, newCount)

        delta = newCount - prevCount

        if delta < 0: delta = 0 # no negative productivity

        self.currentGraph.update(delta)

        bitBlt(self, 0, 0,
               self.currentGraph,
               0, 0,
               self.w,
               self.h)
        self.setCaption(self.currentGraph.text())
      

The actual keeping track of the type-rate is done in this class, not in the TypoChart class. In making good use of Python's ability to form tuples on the fly, a combination of the TypoChart instance and the last count is kept in a dictionary, indexed by the document.

Using the last count and the current length of the text, the delta (the difference) is computed and fed to the chart. This updates the chart, and the chart is then blitted onto the widget — a QWidget is a paintdevice, after all.

    def paintEvent(self, ev):
        p = QPainter(self)
        bitBlt(self, 0, 0,
               self.currentGraph,
               0, 0,
               self.w,
               self.h)

class TestWidget(QWidget):

    def __init__(self, *args):
        apply(QWidget.__init__, (self,) + args)
        self.setGeometry(10, 10, 50, 250)
        self.pixmap = TypoGraph(0, self.width(), self.height())
        self.timer = self.startTimer(100)

    def paintEvent(self, ev):
        bitBlt(self, 0, 0, self.pixmap, 0, 0, self.width(), self.height())

    def timerEvent(self, ev):
        self.pixmap.update(whrandom.randrange(0, 300))
        bitBlt(self, 0, 0, self.pixmap, 0, 0, self.width(), self.height())

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

Finally, this is some testing code, not for the TypoMeter class, which can only work together with Kalam, but for the TypoChart class. It is difficult to use the unit testing framework from Chapter 14 here— after all, in the case of graphics work, the proof of the pudding is in the eating, and it's difficult to assert things about pixels on the screen.

The code to show the type-o-meter on screen is interesting, since it shows how you can destructively delete a widget. The QAction that provides the menu option "show type-o-meter" is a toggle action, and changing the toggle emits the toggled(bool) signal. This is connected to the following function (in kalamapp.py:

    def slotSettingsTypometer(self, toggle):
        if toggle:
            self.typowindow = TypoMeter(self.docManager,
                                        self.workspace,
                                        100,
                                        100,
                                        self,
                                        "type-o-meter",
                                        Qt.WType_TopLevel or Qt.WDestructiveClose)
            self.typowindow.setCaption("Type-o-meter")
            self.typowindow.show()
        else:
            self.typowindow.close(TRUE)
      

Destroying this popup-window is important, because you don't want to waste processing power on a widget that still exists and is merely hidden. The character picker popup we will create in the next section will be hidden, not destroyed.