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

Using Event Trackers to Simplify Tool Implementation

Fredrik Lundh | September 2004 | Originally posted to online.effbot.org

The Widget Construction Kit (WCK) uses different base classes for the code that draws a widget, and the code that implement the interactive behaviour. The latter is called controller, and is used to handle incoming events, and update the widget accordingly.

Here’s a minimal example. Here, the controller responds to button clicks by printing a message to the console, and the widget doesn’t do much at all. (Besides providing a place for the user to click, that is.)

from WCK import Controller, Widget, bind

class MyController(Controller):

    @bind("<Button-1>")
    def click(self, event):
	print "CLICK!"

class MyWidget(Widget):
    ui_controller = MyController

To save resources, the WCK framework shares controller instances, so there will only be one MyController instance in memory, even if you create multiple instances of the MyWidget widget. If you need to access the widget in which an event originated, you can use the event.widget attribute:

    def click(self, event):
	print "CLICK!", event.widget

Other event attributes include event.x and event.y for mouse events, and event.char for keyboard events.

Controllers can be used for a lot more than just clicking and typing. Here’s a controller that allows the user to drag a line across a widget:

class LineController(Controller):

    @bind("<ButtonPress-1>")
    def press(self, event):
	self.anchor = event.x, event.y

    @bind("<B1-Motion>")
    def motion(self, event):
	event.widget.rubberband_line(self.anchor, (event.x, event.y))

    @bind("<ButtonRelease-1>")
    def release(self, event):
	event.widget.add_line(self.anchor, (event.x, event.y))

This controller uses rubberband and add_line methods on the widget. Note that you can use this controller with any widget that implements these methods; the controller is not dependent on any specific widget class.

However, if you set out to implement a full-fledged drawing editor, you will want more than just a single line tool. You probably want a tool palette from which the user can pick a tool, and you may also need to implement tools with a lot more options built into a single tool. In earlier versions of the WCK, the easiest way to implement this would be to install a dispatching controller, which captured all events needed by your tools, and passed them on to the current tool. Here’s an example:

class Dispatcher(Controller):

    @bind("<ButtonPress-1>")
    def press(self, event):
	event.widget.tool.press(event)

    @bind("<B1-Motion>")
    def motion(self, event):
	event.widget.tool.motion(event)

    @bind("<ButtonRelease-1>")
    def release(self, event):
	event.widget.tool.release(event)

In this example, the controller passes the events to corresponding methods on the widget’s tool attribute. To change the tool, plug another tool handler into the widget.

Recent versions of the WCK provides a new way to implement complex tools. Instead of adding an extra dispatcher, you can simply switch between controllers by calling the ui_setcontroller method.

For example, you can call the method directly from the buttons in your toolbar:

canvas = MyCanvas(...)

# build a toolbar
b1 = Button(
    image=PEN_ICON,
    command=lambda: canvas.ui_setcontroller(PenTool)
)
b2 = Button(
    image=BRUSH_ICON,
    command=lambda: canvas.ui_setcontroller(BrushTool)
)
...

You can also use this mechanism to simplify the code for more complex tools. Instead of creating a single tool controller that handles all possible cases, break the tool into smaller parts, called trackers. For a rectangle drawing tool, you could use one tracker to activate the tool, another to draw arbitrary rectangles, a third one to select existing objects, a fourth one to draw squares, etc.

To illustrate the tracker pattern, let’s look at a larger example. Here’s the widget I’m going to use for this example:

class RectangleWidget(Widget):

    ui_option_width = ui_option_height = 500

    xy = None

    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):
        if self.xy:
            draw.rectangle(self.xy, self.ui_brush("red"))

This widget creates a 500x500 pixel drawing area, and can optionally draw a red rectangle on it. To draw the rectangle, set the xy attribute and call ui_damage() to refresh the widget.

Let’s attach a simple tool to this widget. This tool allows the you to draw a new rectangle, or move an existing rectangle around. To move the existing rectangle, click the mouse on the rectangle, and drag it to the new location. If you click outside, a new rectangle is drawn.

Here’s the main tool controller (which is also a tracker, of course). If you click on the rectangle (that is, if the mouse coordinates are inside the xy rectangle), the controller hands control over to the MoveTracker controller. Otherwise, it hands control over to the DrawTracker.

class RectangleTool(Controller):

    @bind("<Button-1>")
    def click(self, event):
        widget = event.widget
        widget.anchor = event.x, event.y
        if widget.xy:
            x0, y0, x1, y1 = widget.xy
            if x0 <= event.x < x1 and y0 <= event.y < y1:
                widget.ui_setcontroller(MoveTracker)
                return
                return
        widget.ui_setcontroller(DrawTracker)

class RectangleWidget(Widget):

    ui_controller = RectangleTool

    ...

The MoveTracker simply moves all four corners, based on the difference between the last mouse position (stored in the widget.anchor attribute) and the current position:

class MoveTracker(Controller):

    @bind("<B1-Motion>")
    def move(self, event):
        widget = event.widget
        dx = event.x - widget.anchor[0]
        dy = event.y - widget.anchor[1]
        x0, y0, x1, y1 = widget.xy
        widget.xy = x0 + dx, y0 + dy, x1 + dx, y1 + dy
        widget.anchor = event.x, event.y
        widget.ui_damage()

    @bind("<ButtonRelease-1>")
    def release(self, event):
        widget = event.widget
        widget.ui_setcontroller(RectangleTool)

And finally, the DrawTracker uses the anchor and the current position to determine the location of the rectangle, and updates the widget accordingly. Also note that both controllers restore the original RectangleTool when the mouse button is released.

class DrawTracker(Controller):

    @bind("<B1-Motion>")
    def draw(self, event):
        widget = event.widget
        x0 = min(widget.anchor[0], event.x)
        y0 = min(widget.anchor[1], event.y)
        x1 = max(widget.anchor[0], event.x)
        y1 = max(widget.anchor[1], event.y)
        widget.xy = x0, y0, x1, y1
        widget.ui_damage()

    @bind("<ButtonRelease-1>")
    def release(self, event):
        widget = event.widget
        widget.ui_setcontroller(RectangleTool)

For a controller implementation for plain Tkinter, see The tkController Module.

To learn more about the Widget Construction Kit, see the Writing Widgets in Python article series over at effbot.org.