We're back after a server migration that caused effbot.org to fall over a bit harder than expected. Expect some glitches.

The Drawing Interface (Writing Widgets in Python, Part 3)

Updated December 11, 2005 | May 31, 2003 | Fredrik Lundh

The Widget Construction Kit (WCK) is a programming interface that you can use to create new widgets for Tkinter and other toolkits, in pure Python.

This is the third article in a series.

In this article:

Introducing the 2D Drawing Interface
Data Types
Drawing Graphics
Drawing Text
Drawing Images
Drawing Widget Backgrounds
Animation Techniques

:::

Introducing the 2D Drawing Interface

The WCK passes a draw object to the ui_handle_repair and ui_handle_clear methods. This object provides an interface to the underlying drawing library. By default, the WCK uses a relatively simple drawing library, which lets you draw text, lines, rectangles, and other 2D graphic elements to the screen.

Here’s a simple example, which draws a black cross on the widget:

from WCK import Widget

class CrossWidget(Widget):

    def ui_handle_repair(self, draw, x0, y0, x1, y1):
        black = self.ui_pen("black", 5)

        # draw a black cross
        draw.line((x0, y0, x1, y1), black)
        draw.line((x0, y1, x1, y0), black)

The (x0, y0) coordinate is the upper left corner of the widget; (x1, y1) is the lower left.

Here’s the resulting widget. If you resize the widget, it will draw a bigger cross:

To draw a polygon, you have to provide at least three coordinate pairs:

from WCK import Widget

class TriangleWidget(Widget):

    def ui_handle_repair(self, draw, x0, y0, x1, y1):
        # draw a triangle
        draw.polygon(
            (x0, y0, x1, (y0+y1)/2, x0, y1),
            self.ui_brush("gold")
        )

Here’s the resulting widget:

Note that if the polygon intersects itself, the standard drawing interface will fill all interior regions (in technical terms, the WCK uses the zero-winding fill rule):

from WCK import Widget

class StarWidget(Widget):

    def ui_handle_repair(self, draw, x0, y0, x1, y1):
        pen = self.ui_pen("black", 1)
        brush = self.ui_brush("red")

        # draw a filled star
        xy = []
        for x, y in [(2,35),(98,35),(21,90),(50,0),(79,90)]:
            xy.append(x0 + x*(x1-x0)/100)
            xy.append(y0 + y*(y1-y0)/100)
        draw.polygon(xy, pen, brush)

To draw text, use the text method. To determine the width and height of the text, you can use the textsize method. The following example uses both methods to draw some right-aligned text in the widget area:

class MessageWidget(Widget):

    message = ["this is a", "very simple", "message", "widget"]

    def ui_handle_repair(self, draw, x0, y0, x1, y1):
        font = self.ui_font("blue", "helvetica")

        # draw some right-aligned text
        y = y0
        for text in self.message:
            w, h = draw.textsize(text, font)
            draw.text((x1 - w, y), text, font)
            y = y + h

The text method takes a single coordinate (where to draw the upper left corner of the text string), a text string, and a font object.

Here’s the resulting widget:

Data Types

Coordinates

The WCK coordinate system uses pixel coordinates, with (0, 0) in the widget’s upper left corner. To specify a rectangle, four coordinates are needed (left, top, right, bottom). To specify a polygon, you need to specify at least three coordinate pairs (x0, y0, x1, y1, x2, y2).

Note that coordinates must be given as ‘flattened’ Python sequences; PIL’s [(x, y), (x, y), …] format is not supported.

Colors

Colors are given as X-style color names, hexadecimal color specifiers (“#rrggbb”), or packed RGB integers (0xrrggbb). Examples (all specifying the same color):

    "red"
    "#ff0000"
    0xff0000

The exact set of available color names is somewhat platform dependent, but most english names are available. If you need more control, use hexadecimal specifiers.

Pens

Pen objects are used to draw lines, as well as the outline of shapes like rectangles and polygons.

To create a pen, pass in the pen color and an optional width to the ui_pen factory method (note that this is a widget method, not a method of the drawing object):

pen = self.ui_pen(color, width)

You can store the pen in the widget instance for later use, but you cannot use a pen in other widgets than the one it was created for.

FIXME: implementation note: it may be time to drop this restriction, and force the implementation to work around this problem in platforms where this might be a real problem…

Brushes

Brush objects are used to fill the interior of shapes, such as rectangles and polygons.

To create a brush, pass in the brush color to the ui_brush factory method:

brush = self.ui_brush(color)

As with pens, you can store a brush for later use in the same widget.

Fonts

Font objects are used to draw text in a widget. In the WCK drawing interface, a font object represents both the actual font, and what color to use to draw the text.

To create a font, pass in a font color and a font specifier to the ui_font constructor:

font = self.ui_font(color, specifier)

As with pens and brushes, you can store a font object for later use in the same widget, but you cannot use it in another widget.

The color argument is a color string, as described above.

The font specifier argument is a Tkinter-style font specifier, using the following syntax: “{family} size style…”, or slight variations thereof. Some examples:

    "Times"
    "Arial 20 bold"
    "{Trebuchet MS} 12"
    "Textile 12"

The family part specifies what font to use. Common families like Helvetica, Times, and Courier are supported on all platforms (they’re mapped to native fonts where necessary). What other families you can use depend on the WCK implementation, and the fonts installed on the host computer.

You can leave out the braces if the family name doesn’t contain whitespace, and doesn’t start with a digit. If omitted, the family name defaults to Courier.

The size is given in points (defined as 1/72 inch). If omitted, it defaults to 12 points. Note that the toolkit takes the logical screen size into account when calculating the actual font size. On low resolution screens, this means that a 12-point font is usually larger than 12/72 inches.

The style attributes can be any combination of normal, bold, roman (upright), italic, underline, and overstrike, separated by whitespace. If omitted, it defaults to the default style setting for that family; usually normal roman.

For Tkinter compatibility, you can also pass in a font tuple: (“family”, size, style…). In this case, there should be no braces around the family name. You can also leave out the size and/or the style arguments. The defaults are the same as for the string syntax.

Object Caching

Some WCK implementations may cache pens, brushes, and font objects to speed up object construction. This means that if you pass in the same arguments to an object factory, you will most likely get back exactly the same object.

While caching can simplify code (you can rely on the cache, instead of storing lots of objects in instance variables), it can cause problems for widgets that use lots of objects during their lifetime. If you need to clear the cache, call the ui_purge method.

Note: The object cache was introduced in release 1.1. Earlier versions do not use an object cache.

Drawing Graphics

The standard draw object provides basic 2D drawing operations for lines and basic shapes:

draw.line(xy, pen)
draw.rectangle(xy, brush)
draw.polygon(xy, brush)
draw.ellipse(xy, brush)

The line method takes a single pen object, created by the ui_pen method.

The rectangle, polygon and ellipse methods take brush objects, created by ui_brush, and use it to fill the given region. These methods can also take an optional pen object, which is used to draw an outline.

Drawing Text

The WCK also provides basic text operations:

draw.text(xy, text, font)
width, height = draw.textsize(xy, text, font)

The coordinate argument gives the upper left corner of the text box.

The text string can be a standard 8-bit Python string containing either ASCII or UTF-8 encoded text. In modern implementations, you can also use Unicode strings.

The font is a logical or physical font created with the ui_font method.

Some implementations also accept 8-bit Python strings using the ISO-8859-1 (Latin-1) encoding, but you should not rely on that.

Drawing Images

In addition to the basic 2D drawing primities, the WCK allows you to display images in a widget. The WCK image support consists of two similar mechanisms: pixmaps and images.

A pixmap is an ordinary raster image, with the same resolution and format as the display. You can use the drawing interface to draw in a pixmap, just like you can draw on the screen.

The ui_pixmap method creates a pixmap object:

pixmap = self.ui_pixmap(width, height)

In an X window system running across the network, the pixmap is stored in the terminal (the X server). On other platforms, the pixmap may be stored in an off-screen portion of the display memory. In either case, copying pixels from a pixmap to the screen is a very fast operation.

An image is similar to a pixmap, but does not necessarily have the same format as the display. Images may be stored in client memory instead of display memory, and copying may be less efficient than for pixmaps. Image objects are used to import raster images from external sources; you cannot use them for drawing.

The ui_image method creates an image object:

image = self.ui_image(source)

In the Tkinter version, the source object can be either a Tkinter BitmapImage or PhotoImage (or a compatible object, such as a PIL ImageTk.PhotoImage object), or a PIL image memory. WCK 1.1 also allows you to create an image by specifing a PIL-compatible mode, a size tuple, and the pixel data as a string.

You can use the paste operation to copy an image or a pixmap into a window. The paste method is similar to the same method in PIL, and takes a source image object, and an optional target offset or target rectangle:

draw.paste(image, xy)

The image object can be either an image (created with ui_image) or a pixmap (created with ui_pixmap). Note that the image is pasted into the drawing area, not the other way around.

The target can be a 2-tuple offset (upper left corner) or a 4-tuple rectangle. If omitted, the target defaults to (0, 0).

Drawing Widget Backgrounds

By default, the WCK framework clears the widget background before it calls the ui_handle_repair method. To clear the background, it simply fills it with the background color (as given by the background option).

You can modify this behavior by overriding the ui_handle_clear method. For example, if the repair method always redraws the entire widget, you can save a little time (and reduce flicker) by adding an empty ui_handle_clear method:

from WCK import Widget

class CheckerboardWidget(Widget):

    def ui_handle_clear(self, draw, x0, y0, x1, y1):
        pass # ui_handle_repair updates the entire widget

    def ui_handle_repair(self, draw, x0, y0, x1, y1):
        # draw a 2x2 checkerboard pattern

        # calculate widget center
        cx = (x0 + x1) / 2; cy = (y0 + y1) / 2

        # allocate brushes
        white = self.ui_brush("white")
        black = self.ui_brush("black")

        # draw tiles
        draw.rectangle((x0, y0, cx, cy), white)
        draw.rectangle((cx, y0, x1, cy), black)
        draw.rectangle((x0, cy, cx, y1), black)
        draw.rectangle((cx, cy, x1, y1), white)

from Tkinter import *

root = Tk()

w = CheckerboardWidget(root)
w.pack(fill=BOTH, expand=1)

root.mainloop()

To see the difference, you can run this example with and without the ui_handle_clear method. Without it, there’s usually a slight flicker in the black regions when you resize the widget.

Animation Techniques

The checkerboard example showed how you can eliminate flicker for widgets that cover their entire surface with non-overlapping elements (such as the checkerboard tiles).

But figuring out how to avoid overlaps isn’t always that easy. For example, consider something as simple as adding a red piece to the checkerboard. Drawing the piece is trivial; just call the ellipse method with a suitable brush:

class CheckerboardWidget(Widget):

    ...

    def ui_handle_repair(self, draw, x0, y0, x1, y1):
        ... draw checkerboard background ...
        # add a piece to the board
        draw.ellipse((x0+10, cy+10, cx-10, y1-10), self.ui_brush("red"))

    ...

But since you draw the background first, it may appear on the screen very briefly, before the piece is drawn. Create a larger board, and add more pieces, and the flicker may become really annoying. And unfortunately, it’s not obvious how to calculate the area not covered by the ellipse. You could probably approximate the piece with a polygon, and use the same coordinates to draw a polygon covering only background, but that’s a lot of work.

The WCK provides a much simpler solution: create an off-screen pixmap, draw into the pixmap instead of the widget, and copy the pixmap to the screen when everything is drawn.

pixmap = self.ui_pixmap(width, height)
do the drawing
draw.paste(pixmap)

To illustrate this approach, here’s a much simplified version of Tkinter’s Canvas widget. The SimpleCanvas widget maintains an ordered list of graphic elements (the stack), and draws it to the display. To minimize flicker, the repair method draws into a pixmap, which is then copied to the screen.

A Simple Canvas Widget
from WCK import Widget

class SimpleCanvas(Widget):

    ui_option_width = 100
    ui_option_height = 100

    def __init__(self, master, **options):
        self.stack = []
        self.ui_init(master, options)

    #
    # implementation

    def ui_handle_config(self):
        return int(self.ui_option_width), int(self.ui_option_height)

    def ui_handle_clear(self, draw, x0, y0, x1, y1):
        pass

    def ui_handle_repair(self, draw, x0, y0, x1, y1):
        # redraw entire stack into a pixmap
        pixmap = self.ui_pixmap(x1, y1)
        pixmap.rectangle(
            (x0, y0, x1, y1), self.ui_brush(self.ui_option_background)
            )
        for action, xy, args in self.stack:
            getattr(pixmap, action)(xy, *args)
        draw.paste(pixmap)

    #
    # canvas interface

    def append(self, action, xy, *args):
        # add item to top of stack
        index = len(self.stack)
        self.stack.append((action, xy, args))
        self.ui_damage()
        return index

    def insert(self, index, action, xy, *args):
        # insert item into stack
        self.stack.insert(index, (action, xy, args))
        self.ui_damage()
        return index

    def delete(self, index):
        # remove item from stack
        action, xy, args = self.stack.pop(index)
        self.ui_damage()
        return (action, xy) + args

The widget provides a simple list-like interface; you can add new items using append and insert, and remove existing items using delete.

The following sample creates a canvas widget, and adds three rectangles to it. It also installs a Tkinter event handler that lets you move the second item (the blue rectangle), by pressing the mouse button over the widget and moving the mouse around.

from Tkinter import *

root = Tk()

w = SimpleCanvas(root)
w.pack(fill=BOTH, expand=1)

pen = w.ui_pen("black")

w.append("rectangle", [10, 10, 50, 50], w.ui_brush("red"))
w.append("rectangle", [30, 30, 70, 70], w.ui_brush("blue"))
w.append("rectangle", [50, 50, 90, 90], w.ui_brush("yellow"), pen)

def drag(event):
    # move second item to mouse coordinate
    item = list(w.delete(1))
    item[1] = event.x-20, event.y-20, event.x+20, event.y+20
    w.insert(1, *item)

w.bind("<B1-Motion>", drag)

root.mainloop()

Using the ui_doublebuffer flag

Full widget animation of this kind is pretty common, so the WCK provides an easier way to draw via an extra pixmap; just set the ui_doublebuffer class attribute to a true value, and leave the rest to the framework:

from WCK import Widget

class SimpleCanvas(Widget):

    ui_option_width = 100
    ui_option_height = 100

    ui_doublebuffer = 1

    def __init__(self, master, **options):
        self.stack = []
        self.ui_init(master, options)

    #
    # implementation

    def ui_handle_config(self):
        return int(self.ui_option_width), int(self.ui_option_height)

    def ui_handle_repair(self, draw, x0, y0, x1, y1):
        # redraw entire stack into a background buffer
        for action, xy, args in self.stack:
            getattr(draw, action)(xy, *args)

    # ...append, insert, delete as above...