QCanvas

The other way of pushing pixels on the screen is using the QCanvas class. This is rather more complicated than simply painting what you want, but offers the unique capability of accessing the individual elements of the composition. Not only that, but you can also determine whether elements overlap each other, set them moving across the canvas at a predefined rate, and show and hide them at will.

In working with QCanvas, three classes play an essential role: the QCanvas itself, which is a receptacle for QCanvasItem objects — or rather, their descendants, and one or more QCanvasView widgets are used to show the canvas and its contents on screen.

the relation between QCanvas, QCanvasItems and QCanvasView

The class QCanvasItem is rather special: you cannot instantiate objects from it, nor can you directly subclass it. You can instantiate and subclass the subclasses of QCanvasItem: QCanvasPolygonalItem, QCanvasSprite and QCanvasText.

Even QCanvasPolygonalItem itself is not terribly useful: the derived classes QCanvasEllipse, QCanvasLine, QCanvasPolygon and QCanvasRectangle can be used to draw ellipses, lines, polygons and rectangles on the canvas. Interestingly, these items can have a non-square bounding box.

This means that two circles won't touch if they overlap the box that contains them: only if the circles themselves touch. This is quite special, and if you create new derivations of these classes yourself, you should take care to carefully calculate the area your object occupies.

Overlapping and non-overlapping circles.

A QCanvasSprite should be familiar to anyone who has ever played with an 8-bit home computer. A QCanvasSprite is an animated pixmap, and can move (like any QCanvasItem) across the canvas under its own steam. You fill the QCanvasSprite with a QPixMapArray. This class contains a list of QPixmaps and a list of QPoints. These define how the sprite looks and where its hot spots are. If you want to create a game using PyQt you'll probably want to use this class.

Lastly, the QCanvasText can draw a single line of text on the canvas. Let me repeat that: you can not create a whole column of text, put it in a QCanvasText object, and paste it on the canvas. This makes creating a PageMaker clone just a little bit more difficult.

Nevertheless, it is QCanvasText which we are going to use in the next section. Another example of the use QCanvasText is the Eric debugger, which is part of the PyQt source distribution.

A simple Unicode character picker

The goal of this example is to provide a point-and-click way of entering characters from the complete unicode range in Kalam. The Unicode range is divided into a few hundred scripts. What I want is a window that shows a clickable table of one of those scripts, with a combo-box that allows me to select the script I need. And when I click on a character, that character should be inserted into the current document.

A Unicode character picker

The underlying data can be retrieved from the Unicode consortium website. They provide a file, Blocks.txt, that gives you the range each script occupies:

# Start Code; End Code; Block Name
0000; 007F; Basic Latin
0080; 00FF; Latin-1 Supplement
0100; 017F; Latin Extended-A
0180; 024F; Latin Extended-B
0250; 02AF; IPA Extensions
02B0; 02FF; Spacing Modifier Letters
0300; 036F; Combining Diacritical Marks
...
F900; FAFF; CJK Compatibility Ideographs
FB00; FB4F; Alphabetic Presentation Forms
FB50; FDFF; Arabic Presentation Forms-A
FE20; FE2F; Combining Half Marks
FE30; FE4F; CJK Compatibility Forms
FE50; FE6F; Small Form Variants
FE70; FEFE; Arabic Presentation Forms-B
FEFF; FEFF; Specials
FF00; FFEF; Halfwidth and Fullwidth Forms
FFF0; FFFD; Specials
      

This file can be used to fill a combobox with all different scripts:

Example 21-2. charmap.py - a Unicode character selection widget

"""
charmap.py - A unicode character selector

copyright: (C) 2001, Boudewijn Rempt
email:         boud@rempt.xs4all.nl
"""

import string, os.path
from qt import *

TRUE=1
FALSE=0

class CharsetSelector(QComboBox):

    def __init__(self, datadir, *args):
        apply(QComboBox.__init__,(self,)+args)
        self.charsets=[]
        self.connect(self,
                     SIGNAL("activated(int)"),
                     self.sigActivated)

        f=open(os.path.join(datadir,"Blocks.txt"))
        f.readline() # skip first line
        for line in f.readlines():
            try:
                self.charsets.append((string.atoi(line[0:4],16)
                                      ,string.atoi(line[6:10],16)))
                self.insertItem(line[12:-1])
            except: pass

    def sigActivated(self, index):
        begin, start=self.charsets[index]
        self.emit(PYSIGNAL("sigActivated"),(begin, start))
      

This is simple enough: the location of Blocks.txt is retrieved, and each line is read. Every line represents one script, and for every line an entry is inserted into the QComboBox. In a separate list, self.charsets, we keep a tuple with the begin and the end of each range, converted to integers from their hexadecimal representation. Python is a great language for this kind of data massaging.

Whenever the user selects an item from the combobox, a signal is emitted, sigActivated, that carries the begin and endpoint of the range.

The canvas

Working with QCanvas entails handling two classes: QCanvas and QCanvasView. In this section, we'll lay out the Unicode table on the QCanvas. From PyQt 3.0 onwards, the canvas classes are in a separate module: qtcanvas, which has to be imported separately.

You can think of a QCanvas as a virtually boundless two-dimensional paste-board, which you can fill with QCanvasItems. The main difference between a QCanvas with QCanvasItems on it and a QWidget with a lot of sub-widgets, is that the first is a lot more efficient in terms of memory-use, and offers easy collision detection. Of course, QCanvasItems are not widgets, so you don't have easy event handling — but you can fake it easily enough, by catching mouse presses on individual QCanvasItems.

Here, we will create a QCanvasText for every Unicode glyph. In the QCanvasView mouse-clicks on those items will be caught.

class CharsetCanvas(QCanvas):

    def __init__(self, parent, font, start, end, maxW, *args):
        apply(QCanvas.__init__,(self, ) + args)
        self.parent=parent
        self.start=start
        self.end=end
        self.font=font
        self.drawTable(maxW)

    def drawTable(self, maxW):
        self.maxW=maxW
        self.items=[]
        x=0
        y=0

        fontMetrics=QFontMetrics(self.font)
        cell_width=fontMetrics.maxWidth() + 3
        if self.maxW < 16 * cell_width:
            self.maxW = 16 * cell_width
        cell_height=fontMetrics.lineSpacing()

        for wch in range(self.start, self.end + 1):
            item=QCanvasText(QString(QChar(wch)),self)
            item.setFont(self.font)

            item.setX(x)
            item.setY(y)
            item.show()

            self.items.append(item)

            x=x + cell_width
            if x >= self.maxW:
                x=0
                y=y+cell_height

        if self.parent.height() > y + cell_height:
            h = self.parent.height()
        else:
            h = y + cell_height

        self.resize(self.maxW + 20, h)
        self.update()

    def setFont(self, font):
        self.font=font
        self.drawTable(self.maxW)
        

Most of the real work is done in the drawTable() method. The maxW parameter determines how wide the canvas will be. However, if there is not place enough for at least sixteen glyphs, the width is adjusted.

Then the QCanvasText items are created, in a plain loop, starting at the beginning of the character set and running to the end. You must give these items an initial position and size, and explicitly call show() on each item. If you forget to do this, all you will see is a very empty canvas.

You will also be greeted by an equally empty canvas if you do not keep a Python reference to the items — here a list of QCanvasText items is kept in self.items.

If the end of a line is reached, drawing continues on the next line.

An essential step, and one which I tend to forget myself, is to resize the QCanvas after having determined what space the items take. You can place items outside the confines of the canvas, and they won't show unless you resize the canvas to include them.

Finally, you must update() the QCanvas — otherwise you still won't see anything. This method updates all QCanvasView objects that show this canvas.

Setting the font involves drawing the table anew. This is more efficient than applying the font change to each individual QCanvasText item — even though that is perfectly possible. The reason is that if the font metrics change, for instance because the new font is a lot larger, you will have to check for collisions and adjust the location of all items anyway. That would take not only a lot of time, it would also demand complex and unmaintainable code. Simple is good, as far as I'm concerned.

This little table shows almost nothing of the power of QCanvas — you can animate the objects, determine if they overlap, and lots more. It offers everything you need, for instance, to write your very own Asteroids clone...

The view on the canvas

Putting stuff on a canvas is useless, unless you can also see what you've done. You can create one or more QCanvasView objects that show the contents of canvas. Each view can show a different part, but every time you call update() (or advance(), which advances all animated objects), all views are updated.

The most important work your QCanvasView subclasses have is to react on user input. Here, we draw a cursor rectangle round selected glyphs and emit signals for every mousepress.

class CharsetBrowser(QCanvasView):

    def __init__(self, *args):
        apply(QCanvasView.__init__,(self,)+args)

    def setCursor(self, item):
        self.cursorItem=QCanvasRectangle(self.canvas())
        self.cursorItem.setX(item.boundingRect().x() -2)
        self.cursorItem.setY(item.boundingRect().y() -2)
        self.cursorItem.setSize(item.boundingRect().width() + 4,
                                item.boundingRect().height() + 4)

        self.cursorItem.setZ(-1.0)
        self.cursorItem.setPen(QPen(QColor(Qt.gray), 2, Qt.DashLine))
        self.cursorItem.show()
        self.canvas().update()

    def contentsMousePressEvent(self, ev):
        try:
            items=self.canvas().collisions(ev.pos())
            self.setCursor(items[0])
            self.emit(PYSIGNAL("sigMousePressedOn"), (items[0].text(),))
        except IndexError:
            pass

    def setFont(self, font):
        self.font=font
        self.canvas().setFont(self.font)
        

First, the drawing of the cursor. You can see that you don't need to create your canvas items in the QCanvas class or its derivatives. Here, it is done in the setCursor() method. This method is called with the activated QCanvasText item as its parameter.

A new item is created, a QCanvasRectangle called self.cursorItem. It's an instance, not a local variable, because otherwise the rectangle would disappear once the item goes out of scope (because the function finishes).

The location and dimensions of the rectangle are determined. It will be a two-pixel wide, gray, dashed line exactly outside the current glyph. Of course, it must be shown, and the canvas must call update() in order to notify the view(s). Note that you can retrieve a canvas shown by QCanvasView with the canvas() function.

If you consult PyQt's documentation (or the C++ Qt documentation) on QCanvasView, you will notice that it is not very well endowed with useful functions. QCanvasView is a type of specialized QScrollView, and this class offers lots of useful methods (for example, event handling methods for mouse events).

One of these methods, contentsMousePressEvent, is highly useful. It is called whenever a user clicks somewhere on the canvas view. You can then use the coordinates of the click to determine which QCanvasItem objects were hit. The coordinates of the mouse click can be retrieved with the pos() function of the ev QMouseEvent. You then check which QCanvasItem objects were hit using the collision detection QCanvas provides with the collisions().

The result is a list of items. Because we know that there are no overlapping items on our canvas, we can simply take the first QCanvasText item: that's items[0]. Now we have the selected glyph. The setCursor() function is called to draw a rectangle around the glyph. Then a signal is emitted, which can be caught by other widgets. This signal is ultimately responsible for getting the selected character in the Kalam document.

Tying the canvas and view together

The CharMap widget is a specialized QWidget that contains the three components we developed above.

A vertical layout manager contains the selection combobox and the CharsetBrowser QCanvasView widget. Every time a new script is selected, a new CharsetCanvas is created — this is easier than erasing the contents of the existing canvas.

class CharMap(QWidget):
    def __init__(self,
                 parent,
                 initialFont = "arial",
                 datadir = "unidata",
                 *args):

        apply(QWidget.__init__, (self, parent, ) + args)
        self.parent=parent
        self.font=initialFont
        self.box=QVBoxLayout(self)
        self.comboCharset=CharsetSelector(datadir, FALSE, self)
        self.box.addWidget(self.comboCharset)
        self.charsetCanvas=CharsetCanvas(self, self.font, 0, 0, 0)
        self.charsetBrowser=CharsetBrowser(self.charsetCanvas, self)
        self.box.addWidget(self.charsetBrowser)

        self.setCaption("Unicode Character Picker")

        self.connect(qApp,
                     PYSIGNAL("sigtextfontChanged"),
                     self.setFont)


        self.connect(self.comboCharset,
                     PYSIGNAL("sigActivated"),
                     self.slotShowCharset)

        self.connect(self.charsetBrowser,
                     PYSIGNAL("sigMousePressedOn"),
                     self.sigCharacterSelected)

        self.resize(300,300)
        self.comboCharset.sigActivated(self.comboCharset.currentItem())
        

In the constructor of CharMap both the selector combobox and the canvasview are created. We create an initial canvas for the view to display. The qApp.sigtextfontChanged signal is used to redraw the character map when the application font changes. Recall how we synthesized signals for all configuration options in Chapter 18, and used the globally available qApp object to emit those signals.

    def setFont(self, font):
        self.font=font
        self.charsetBrowser.setFont(font)

    def sigCharacterSelected(self, text):
        self.emit(PYSIGNAL("sigCharacterSelected"), (text,))
        

Every time a user selects a character, the sigCharacterSelected signal is emitted. In KalamApp, this signal is connected to the a slot function that inserts the character in the current view or window.

    def slotShowCharset(self, begin, end):
        self.setCursor(Qt.waitCursor)
        
        self.charTable=CharsetCanvas(self,
                                     self.font,
                                     begin,
                                     end,
                                     self.width() - 40)
        self.charsetBrowser.setCanvas(self.charTable)
        self.setCursor(Qt.arrowCursor)        
        

Drawing a character map can take a while, especially if you select the set of Chinese characters, which has a few tens of thousands of entries. In order to not disquiet the user, we set a waiting cursor—this is a small wristwatch on most versions of Unix/X11, and the familiar sand-timer on Windows. Then a new canvas is created and the canvas view is told to display it.

Saving Unicode files

Recall Chapter 8 on Unicode— if you implement this character map and want to save your carefully created Thai letter, you will be greeted by an encoding error.

To avoid that, you need to use of the unicode function instead of str() when converting the KalamDoc.text QString variable to Python strings.

Input methods and foreign keyboards: If you have played around with the version of Kalam that belongs to this chapter, you will no doubt have noticed that writing a letter in, say, Tibetan, is not quite as easy as just banging on the keyboard (to say nothing of writing Chinese, which demands advanced hunting and picking skills).

A character map like we just made is useful for the occasional phonetic or mathematics character, but not a substitute for the real stuff: specific keyboard layouts for alphabetic scripts, like Cyrillic or Thai, and input method editors for languages like Chinese.

Properly speaking, it's the job of the Operating System or the GUI system to provide this functionality. Specialized keyboard layouts are fairly easy to come by, at least in the Unix/X11 world. My KDE 2 desktop has lots of keyboard layouts — perhaps you have to buy them in the Windows world. Still, it's not worthwhile to create special keyboard layouts in PyQt.

It is possible to create your own keyboard layouts in PyQt: re-implement the keyPressEvent() of the view class and use each pressed key as an index into a dictionary that maps plain keyboard key definitions to, say, Tibetan Unicode characters. This is the same technique we used in Chapter 17 to make sure tab characters ended up in the text

            keymap={Qt.Key_A: QString(u"\u0270")}

            def keyPressEvent(self, ev):
                if keymap.has_key(ev.key()):
                    self.insert(keymap[ev.key()])
                else:
                    QMultiLineEdit.keyPressEvent(self, ev)
          

Input method editors (IME's) are more difficult. Installing the free Chinese or Japanese IME's on Unix/X11 is a serious challenge. Getting your applications to work with them is another challenge. There are, however, special Chinese, Korean and Japanese versions of Qt to deal with these problems. As for Windows, I think you need a special Chinese, Korean or Japanese version of Windows.

It can be worthwhile to implement a Chinese IME, for instance, yourself:

A Chinese input method editor written in Python and PyQt.

You can find the code for a stand-alone Pinyin-based Chinese IME at http://www.valdyas.org/python/qt2.html — it's also a nice example of using large Python dictionaries (every Mandarin Chinese syllable is mapped to a list characters with that pronunciation, and Emacs cannot syntax-color the file containing the dictionary).