Writing a Custom Widget Using PyGTK

Article by Mark Mruss, originally posted on www.learningpython.com

One of the things that I wanted to add to my simple PyWine application was an easy way for people to rate their wine. There were lots of different ways to do it but since I was looking for a tutorial to write I decided that I wanted to do it the way that you rate songs in iTunes. If you've never used iTunes before, you can rate songs on a sliding scale from zero to five using stars. It basically functions like a slider or a Horizontal Scale except that when drawing it's not a line, it's a row of stars.

Python PyGTK Windows

The full source for this tutorial can be downloaded here.

The three the most useful links that I found on this subject were: A song for the lovers, the writing a widget turotial on the PyGTK website, and the widget.py example in the PyGTK cvs.

The skeleton of the following code will be mostly based off of the widget.py example, but since this example will try to accomplish a bit more there will be some extra code. In order to understand this tutorial better I suggest you give widget.py a couple of reads.

The starting point is a file names starhscale.py which starts off with some rather standard python stuff:

#!/usr/bin/env python

try:
        import gtk
        import gobject
        from gtk import gdk
except:
        raise SystemExit

import pygtk
if gtk.pygtk_version < (2, 0):
        print "PyGtk 2.0 or later required for this widget"
        raise SystemExit


Not too much surprising there, now it's time to create and initialize our class, we'll call it StarHScale:
class StarHScale(gtk.Widget):
        """A horizontal Scale Widget that attempts to mimic the star
        rating scheme used in iTunes"""

        def __init__(self, max_stars=5, stars=0):
                """Initialization, numstars is the total number
                of stars that may be visible, and stars is the current
                number of stars to draw"""

                #Initialize the Widget
                gtk.Widget.__init__(self)

                self.max_stars = max_stars
                self.stars = stars

                # Init the list to blank
                self.sizes = []
                for count in range(0,self.max_stars):
                        self.sizes.append((count * PIXMAP_SIZE) + BORDER_WIDTH)

So what's happening here? Well the first thing you see is the definition of our StarHScale widget that is a subclass of gtk.Widget, which is the base class for all widgets in PyGTK. Then we have a rather simple __init__ routine where we set some parameters (the max number of stars to show and the current number of stars to show) and initialize the parent class.

You'll also notice that at the end of the function there is a list created, this list maps the X (horizontal) position of each star. It might not make much sense now, but it will become clear when you see how it is used. PIXMAP_SIZE and BORDER_WIDTH are "globals" that are defined outside of the StarHScale class as follows:

BORDER_WIDTH = 5
PIXMAP_SIZE = 22

The next function we will write is the do_realize() function. The do_realize() function is related to the gtk.Widget.realize() function and is called when a widget is supposed to allocate its GDK windowing resources.

It may seem a bit complicated, but the do_realize() function is simply where widgets create their GDK windows resources (most probably a gtk.gdk.Window) where the widget will eventually be drawn to). In order to fully understand this it may be helpful to understand what a gtk.gdk.Window is, here is an explanation from the PyGTK documentation:

gtk.gdk.Window is a rectangular region on the screen. It's a low-level object, used to implement high-level objects such as gtk.Widget and gtk.Window. A gtk.Window is a toplevel window, the object a user might think of as a "window" with a titlebar and so on. A gtk.Window may contain several gtk.gdk.Window objects since most widgets use a gtk.gdk.Window.

A gtk.gdk.Window object interacts with the native window system for input and events. Some gtk.Widget objects do not have an associated gtk.gdk.Window and therefore cannot receive events. To receive events on behalf of these "windowless" widgets a gtk.EventBox must be used.

So a gtk.gdk.Window is not a "window" as we normally think of one, it's basically a rectangular region on the screen that will be used for "drawing" of some sort. So for our StarHScale widget, it's gtk.gdk.Window will be the area where the stars will be drawn. If you have done programming with other toolkits or other languages it may be helpful to think of this as the "surface" that the widget draws on. Much of the do_realize() code is taken from the widget.py example:

def do_realize(self):
        """Called when the widget should create all of its
        windowing resources.  We will create our gtk.gdk.Window
        and load our star pixmap."""

        # First set an internal flag telling that we're realized
        self.set_flags(self.flags() | gtk.REALIZED)

        # Create a new gdk.Window which we can draw on.
        # Also say that we want to receive exposure events
        # and button click and button press events

        self.window = gdk.Window(
                self.get_parent_window(),
                width=self.allocation.width,
                height=self.allocation.height,
                window_type=gdk.WINDOW_CHILD,
                wclass=gdk.INPUT_OUTPUT,
                event_mask=self.get_events() | gdk.EXPOSURE_MASK
                        | gdk.BUTTON1_MOTION_MASK | gdk.BUTTON_PRESS_MASK
                        | gtk.gdk.POINTER_MOTION_MASK
                        | gtk.gdk.POINTER_MOTION_HINT_MASK)

        # Associate the gdk.Window with ourselves, Gtk+ needs a reference
        # between the widget and the gdk window
        self.window.set_user_data(self)

        # Attach the style to the gdk.Window, a style contains colors and
        # GC contextes used for drawing
        self.style.attach(self.window)

        # The default color of the background should be what
        # the style (theme engine) tells us.
        self.style.set_background(self.window, gtk.STATE_NORMAL)
        self.window.move_resize(*self.allocation)
        # load the star xpm
        self.pixmap, mask = gtk.gdk.pixmap_create_from_xpm_d(
                self.window, self.style.bg[gtk.STATE_NORMAL], STAR_PIXMAP)

        # self.style is a gtk.Style object, self.style.fg_gc is
        # an array or graphic contexts used for drawing the forground
        # colours
        self.gc = self.style.fg_gc[gtk.STATE_NORMAL]

        self.connect("motion_notify_event", self.motion_notify_event)

There is quite a bit of code here so I'll take some time to explain it. The first step is to set a flag so that lets us, and anyone else that wants to know, that we have been realized - that we have a gtk.gdk.Window associated with ourselves.

The next step is to actually create the gtk.gdk.Window that will be associated with the StarHScale widget. When we create it we also set many of it's attributes. You can read more about all the available attributes in the PyGTK documentation but here are the attributes that we are setting:

parent: a gtk.gdk.Window
width: the width of the window in pixels
height: the height of the window in pixels
window_type: the window type
event_mask: the bitmask of events received by the window
wclass: the class of window - either gtk.gdk.INPUT_OUTPUT or gtk.gdk.INPUT_ONLY

We add a few events to the event mask of the gtk.gdk.Window because this widget will be interacting with the mouse. Then we make some necessary connections between the gtk.gdk.Window, the widget, and the widgets style. Finally we set the background colour and move the window into the position that has been allocated for us (self.allocation).

The next step is where the do_realize() code begins to diverge from the widget.py example. The next step is where we create our star pixmap using the pixmap_create_from_xmp_d function:

# load the star xpm
self.pixmap, mask = gtk.gdk.pixmap_create_from_xpm_d(
        self.window
        , self.style.bg[gtk.STATE_NORMAL]
        , STAR_PIXMAP)

Here is a description of what a gtk.gdk.Pixmap is:

A gtk.gdk.Pixmap is an offscreen gtk.gdk.Drawable. It can be drawn upon with the standard gtk.gdk.Drawable drawing primitives, then copied to another gtk.gdk.Drawable (such as a gtk.gdk.Window) with the draw_drawable() method. The depth of a pixmap is the number of bits per pixels. A bitmaps are simply a gtk.gdk.Pixmap with a depth of 1. (That is, they are monochrome pixmaps - each pixel can be either on or off).

What we will use the pixmap for is the drawing of each of our stars. Since we want the widget to be portable without having an xpm file around we simple load it's data. To do so we have to define the STAR_PIXMAP "global" outside of our StarHScale as follows:

STAR_PIXMAP = ["22 22 77 1",
"         c None",
".        c #626260",
"+        c #5E5F5C",
"@        c #636461",
"#        c #949492",
"$        c #62625F",
"%        c #6E6E6B",
"&        c #AEAEAC",
"*        c #757673",
"=        c #61625F",
"-        c #9C9C9B",
";        c #ACACAB",
">        c #9F9F9E",
",        c #61635F",
"'        c #656663",
")        c #A5A5A4",
"!        c #ADADAB",
"~        c #646562",
"{        c #61615F",
"]        c #6C6D6A",
"^        c #797977",
"/        c #868684",
"(        c #A0A19E",
"_        c #AAAAA8",
":        c #A3A3A2",
"<        c #AAAAA7",
"[        c #9F9F9F",
"}        c #888887",
"|        c #7E7E7C",
"1        c #6C6C69",
"2        c #626360",
"3        c #A5A5A3",
"4        c #ABABAA",
"5        c #A9A9A7",
"6        c #A2A2A1",
"7        c #A3A3A1",
"8        c #A7A7A6",
"9        c #A8A8A6",
"0        c #686866",
"a        c #A4A4A2",
"b        c #A4A4A3",
"c        c #A1A19F",
"d        c #9D9D9C",
"e        c #9D9D9B",
"f        c #A7A7A5",
"g        c #666664",
"h        c #A1A1A0",
"i        c #9E9E9D",
"j        c #646461",
"k        c #A6A6A4",
"l        c #A0A09F",
"m        c #9F9F9D",
"n        c #A9A9A8",
"o        c #A0A09E",
"p        c #9B9B9A",
"q        c #ACACAA",
"r        c #60615E",
"s        c #ADADAC",
"t        c #A2A2A0",
"u        c #A8A8A7",
"v        c #6E6F6C",
"w        c #787976",
"x        c #969695",
"y        c #8B8B8A",
"z        c #91918F",
"A        c #71716E",
"B        c #636360",
"C        c #686966",
"D        c #999997",
"E        c #71716F",
"F        c #61615E",
"G        c #6C6C6A",
"H        c #616260",
"I        c #5F605E",
"J        c #5D5E5B",
"K        c #565654",
"L        c #5F5F5D",
"                      ",
"                      ",
"          .           ",
"          +           ",
"         @#$          ",
"         %&*          ",
"        =-;>,         ",
"        ';)!'         ",
"  ~{{]^/(_:< [}|*1@,   ",
"   23&4_5367895&80    ",
"    2a4b:7c>def)g     ",
"     2c4:h>id56j      ",
"      {k8lmeln2       ",
"      j8bmoppqr       ",
"      {stusnd4v       ",
"      ws;x@yq;/       ",
"      zfAB {CmD{      ",
"     rE{     FGH      ",
"     IJ       KL      ",
"                      ",
"                      ",
"                      "]

The star is based off of star found in the Art Libre Set of the wonderful Tango Desktop Project. I simply darkened it a bit.

Then we make a quick reference to the normal state foreground gtk.gdk.GC (graphic context) associated with our style. A gtk.gdk.GC is simply an object that "encapsulates information about the way things are drawn, such as the foreground color or line width. By using graphics contexts, the number of arguments to each drawing call is greatly reduced, and communication overhead is minimized, since identical arguments do not need to be passed repeatedly. (From the PYGTK Docs )" So it's basically a bunch of drawing settings encapsulated in one simple object.

Finally to finish off the do_realize() function we connect ourselves with the "motion_notify_event" which we will use to track when the user moves the mouse over our widget.

The next step in our widget creation is the do_unrealize() function, which is called when a widget should free all of its resources. The widget.py example calls:

self.window.set_user_data(None)

But I got a type error running that, so instead I simply destroyed the window. I'm not entirely sure what the correct approach is, or if one even has to worry about clearing the resources, either way this is code that i used:

def do_unrealize(self):
        # The do_unrealized method is responsible for freeing the GDK resources
        # De-associate the window we created in do_realize with ourselves
        self.window.destroy()

The next two functions deal with the size of our widget. The first function do_size_request() is called by PyGTK so that PyGTK can figure out how large the widget wants to be. The second function, do_size_allocate() is called by PyGTK in order to tell the widget how large it should actually be:

def do_size_request(self, requisition):
        """From Widget.py: The do_size_request method Gtk+ is calling
         on a widget to ask it the widget how large it wishes to be.
         It's not guaranteed that gtk+ will actually give this size
         to the widget.  So we will send gtk+ the size needed for
         the maximum amount of stars"""

        requisition.height = PIXMAP_SIZE
        requisition.width = (PIXMAP_SIZE * self.max_stars) + (BORDER_WIDTH * 2)


def do_size_allocate(self, allocation):
        """The do_size_allocate is called by when the actual
        size is known and the widget is told how much space
        could actually be allocated Save the allocated space
        self.allocation = allocation. The following code is
        identical to the widget.py example"""

        if self.flags() & gtk.REALIZED:
                self.window.move_resize(*allocation)

The next function is the do_expose_event() function, which is called when the widget should actually draw itself. For the StarHScale this function is actually pretty simple:

def do_expose_event(self, event):
        """This is where the widget must draw itself."""

        #Draw the correct number of stars.  Each time you draw another star
        #move over by 22 pixels. which is the size of the star.
        for count in range(0,self.stars):
                self.window.draw_drawable(self.gc, self.pixmap, 0, 0
                                                                                        , self.sizes[count]
                                                                                        , 0,-1, -1)

Basically we simply loop through the current number of stars (self.stars) and draw our star pixmap to the window using the draw_drawable function. We use the self.sizes list (which we calculated in the __init__ function) to determine the x position where we will draw the star.

Now comes the time where we actually need to let the user interact with the widget and show and hide the stars. To do so we need to pay attention to the "motion_notify_event" and the "button_press_event". One thing you may have noticed in the do_realize() function is that we pay attention to the gtk.POINTER_MOTION_MASK and the gtk.POINTER_MOTION_HINT_MASK, the reason for this is explained in the PyGTK documentation:

It turns out, however, that there is a problem with just specifying POINTER_MOTION_MASK. This will cause the server to add a new motion event to the event queue every time the user moves the mouse. Imagine that it takes us 0.1 seconds to handle a motion event, but the X server queues a new motion event every 0.05 seconds. We will soon get way behind the users drawing. If the user draws for 5 seconds, it will take us another 5 seconds to catch up after they release the mouse button! What we would like is to only get one motion event for each event we process. The way to do this is to specify POINTER_MOTION_HINT_MASK.

When we specify POINTER_MOTION_HINT_MASK, the server sends us a motion event the first time the pointer moves after entering our window, or after a button press or release event. Subsequent motion events will be suppressed until we explicitly ask for the position of the pointer using the gtk.gdk.Window method:

x, y, mask = window.get_pointer()

Our motion_notify_event handler is as follows:

def motion_notify_event(self, widget, event):
        # if this is a hint, then let's get all the necessary
        # information, if not it's all we need.
        if event.is_hint:
                x, y, state = event.window.get_pointer()
        else:
                x = event.x
                y = event.y
                state = event.state

        new_stars = 0
        if (state & gtk.gdk.BUTTON1_MASK):
                # loop through the sizes and see if the
                # number of stars should change
                self.check_for_new_stars(event.x)

This function is pretty simple, first we check to see if the event is a hint or not, if it is a hint we ask GTK+ to get us the real pointer information. If it is not a hint then we just collect the information from the passed gtk.gdk.Event object.

Then we check the events state to make sure that the left mouse button is down, and if it is we pass the x coordinate of the mouse pointer to the self.check_for_new_stars() function which will determine how many stars should be shown.

The other event that lets the user hide and show stars is the button press event which we handle using the do_button_press_event() gtk.Wdiget virtual method that gets called when a button is pressed on the widget:

def do_button_press_event(self, event):
        """The button press event virtual method"""

        # make sure it was the first button
        if event.button == 1:
                #check for new stars
                self.check_for_new_stars(event.x)
        return True

This function is very simple, first we check to make sure that it was the left button that fired the gtk.gdk.BUTTON_PRESS_EVENT, and if it was we pass event.x (the position the mouse was in at the time of the event) to the check_for_new_stars() function.

def check_for_new_stars(self, xPos):
        """This function will determine how many stars
        will be show based on an x coordinate. If the
        number of stars changes the widget will be invalidated
        and the new number drawn"""

        # loop through the sizes and see if the
        # number of stars should change
        new_stars = 0
        for size in self.sizes:
                if (xPos < size):
                        # we've reached the star number
                        break
                new_stars = new_stars + 1

        #set the new value
        self.set_value(new_stars)

check_for_new_stars() is a relatively straight-forward function. It takes an x coordinate as a parameter and then determines how many stars should be visible based on that. To see how many stars should be visible we loop through the self.sizes list and compare the pre-calculated starting point of each star with the passed in x coordinate. We keep adding more stars until the x coordinate is no longer larger then the starting position of the current star. Then we make sure that a new star should be added and if it is we call self.set_value() to set the number of stars.

def set_value(self, value):
        """Sets the current number of stars that will be
        drawn.  If the number is different then the current
        number the widget will be redrawn"""

        if (value >= 0):
                if (self.stars != value):
                        self.stars = value
                        #check for the maximum
                        if (self.stars > self.max_stars):
                                self.stars = self.max_stars
                        #redraw the widget
                        self.window.invalidate_rect(self.allocation,True)

set_value() is another simple function that performs a few validation checks and then sets the current number of stars. If the number of stars has changed, the widget will be redrawn.

Now there are three functions remaining and these are simply to make the widget more usable. They are pretty self explanatory:

def get_value(self):
        """Get the current number of stars displayed"""

        return self.stars

def set_max_value(self, max_value):
        """set the maximum number of stars"""

        if (self.max_stars != max_value):
                """Save the old max in case it is less then the
                current number of stars, in which case we will
                have to redraw"""

                if (max_value > 0):
                        self.max_stars = max_value
                        #reinit the sizes list (should really be a separate function
                        self.sizes = []
                        for count in range(0,self.max_stars):
                                self.sizes.append((count * PIXMAP_SIZE) + BORDER_WIDTH)
                        """do we have to change the current number of
                        stars?"""
                        if (self.stars > self.max_stars):
                                self.set_value(self.max_stars)

def get_max_value(self):
        """Get the maximum number of stars that can be shown"""

        return self.max_stars

Now we finish of starhscale.py with a little bit of code that will simply create a window and add the StarHScale widget to that window if someone executes the starhscale.py file directly:

if __name__ == "__main__":
        # register the class as a Gtk widget
        gobject.type_register(StarHScale)

        win = gtk.Window()
        win.resize(200,50)
        win.connect('delete-event', gtk.main_quit)

        starScale = StarHScale(10,5)

        win.add(starScale)
        win.show_all()
        gtk.main()

So if you run the file you should see the following:


Whew! So that's it, I hope that you found this tutorial useful, now the next step (in the next tutorial) is to add it to the gtk.TreeView.

The full source can be downloaded here.