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