Introduction

Hi Wiki surfer, I am going to describe how to create a custom control in wxPython. In this article's respect, custom control means "owner-drawn".

Even if wxPython ships with a huge number of available widgets, sometimes there is the need to create a new control that performs a particular action or simply looks nicer/more modern in our opinion. That being said, the first place to look at in order to familiarize with custom control design is surely wx.lib, a directory in the wxPython installation tree which contains a large number of owner-drawn widgets.

Subclassing

Once we have decided how our widget should roughly look like and which actions it should perform, the first question that should arise is: "my control should be a subclass of what?". In wxWidgets, using C++, you can simply derive your custom class from wx.Control, wx.Panel, wx.Window and so on. However, you may want to deny your personalized widget to accept focus, or do some particular action in methods like TransferDataFromWindow. Moreover, if your widget has to be managed by a sizer, you need to be able to override methods like DoGetBestSize.

Well, wxPython provides a set of wxPy classes, like wx.PyControl, wx.PyPanel, wx.PyWindow and so on, which are just like their wxWidgets counterparts except they allow some of the more common C++ virtual methods to be overridden in Python derived classes. If you navigate through the wx.lib directory, you will see widgets derived from wx.PyControl (stattext.py), wx.PyPanel (buttonpanel.py), wx.PyScrolledWindow (customtreectrl.py).

Creating The Widget

Ok, let's imagine I have decided that I want a new and cool wx.CheckBox. I would like this new widget to have a nice checked/unchecked bitmap, and I will take care of drawing it from scratch, handling mouse events, providing it with keyboard support and much more. Obviously, a custom wx.CheckBox is composed by a bitmap (which represent the checked/unchecked state) and a label right to it. I am assuming that I already have 4 bitmaps to handle all the possible state combinations of this particular wx.CheckBox, namely:

  1. Enabled/Checked
  2. Enabled/Unchecked
  3. Disabled/Checked
  4. Disabled/Unchecked

In the complete source code I will provide, together with a demo, the enabled-state icon are included; the disabled ones are created on the fly using pure Python/wxPython methods.

So, let's start with some code, basically the initialization, and I will try to explain what I am doing.

Toggle line numbers
   1 import wx
   2 
   3 class CustomCheckBox(wx.PyControl):
   4     """
   5     A custom class that replicates some of the functionalities of wx.CheckBox,
   6     while being completely owner-drawn with a nice check bitmaps.
   7     """
   8 
   9     def __init__(self, parent, id=wx.ID_ANY, label="", pos=wx.DefaultPosition,
  10                  size=wx.DefaultSize, style=wx.NO_BORDER, validator=wx.DefaultValidator,
  11                  name="CustomCheckBox"):
  12         """
  13         Default class constructor.
  14 
  15         @param parent: Parent window. Must not be None.
  16         @param id: CustomCheckBox identifier. A value of -1 indicates a default value.
  17         @param label: Text to be displayed next to the checkbox.
  18         @param pos: CustomCheckBox position. If the position (-1, -1) is specified
  19                     then a default position is chosen.
  20         @param size: CustomCheckBox size. If the default size (-1, -1) is specified
  21                      then a default size is chosen.
  22         @param style: not used in this demo, CustomCheckBox has only 2 state
  23         @param validator: Window validator.
  24         @param name: Window name.
  25         """
  26 
  27         # Ok, let's see why we have used wx.PyControl instead of wx.Control.
  28         # Basically, wx.PyControl is just like its wxWidgets counterparts
  29         # except that it allows some of the more common C++ virtual method
  30         # to be overridden in Python derived class. For CustomCheckBox, we
  31         # basically need to override DoGetBestSize and AcceptsFocusFromKeyboard
  32 
  33         wx.PyControl.__init__(self, parent, id, pos, size, style, validator, name)
  34 
  35         # Initialize our cool bitmaps
  36         self.InitializeBitmaps()
  37 
  38         # Initialize the focus pen colour/dashes, for faster drawing later
  39         self.InitializeColours()
  40 
  41         # By default, we start unchecked
  42         self._checked = False
  43 
  44         # Set the spacing between the check bitmap and the label to 3 by default.
  45         # This can be changed using SetSpacing later.
  46         self._spacing = 3
  47 
  48         # I assume at the beginning we are not focused
  49         self._hasFocus = False
  50 
  51         # Ok, set the wx.PyControl label, its initial size (formerly known an
  52         # SetBestFittingSize), and inherit the attributes from the standard
  53         # wx.CheckBox
  54         self.SetLabel(label)
  55         self.SetInitialSize(size)
  56         self.InheritAttributes()
  57 
  58         # Bind the events related to our control: first of all, we use a
  59         # combination of wx.BufferedPaintDC and an empty handler for
  60         # wx.EVT_ERASE_BACKGROUND (see later) to reduce flicker
  61         self.Bind(wx.EVT_PAINT, self.OnPaint)
  62         self.Bind(wx.EVT_ERASE_BACKGROUND, self.OnEraseBackground)
  63 
  64         # Then we want to monitor user clicks, so that we can switch our
  65         # state between checked and unchecked
  66         self.Bind(wx.EVT_LEFT_DOWN, self.OnMouseClick)
  67         if wx.Platform == '__WXMSW__':
  68             # MSW Sometimes does strange things...
  69             self.Bind(wx.EVT_LEFT_DCLICK,  self.OnMouseClick)
  70 
  71         # We want also to react to keyboard keys, namely the
  72         # space bar that can toggle our checked state
  73         self.Bind(wx.EVT_KEY_DOWN, self.OnKeyDown)
  74 
  75         # Then, we react to focus event, because we want to draw a small
  76         # dotted rectangle around the text if we have focus
  77         # This might be improved!!!
  78         self.Bind(wx.EVT_SET_FOCUS, self.OnSetFocus)
  79         self.Bind(wx.EVT_KILL_FOCUS, self.OnKillFocus)

Ok, excluding the easiest things like assigning the spacing, the check status and the checkbox label, the interesting things happen when we look at the event that I bound to CustomCheckBox. If you are going to draw your custom widget from scratch, my suggestion is to use the combination of wx.BufferedPaintDC and an empty event handler for the wx.EVT_ERASE_BACKGROUND event. wx.BufferedPaintDC is a subclass of wx.BufferedDC which can be used inside of an OnPaint() event handler. Just create an object of this class instead of wx.PaintDC and that's all you have to do to (mostly) avoid flicker.

Let's take a closer look at the wx.EVT_PAINT handler:

Toggle line numbers
   1     def OnPaint(self, event):
   2         """ Handles the wx.EVT_PAINT event for CustomCheckBox. """
   3 
   4         # If you want to reduce flicker, a good starting point is to
   5         # use wx.BufferedPaintDC.
   6         dc = wx.BufferedPaintDC(self)
   7 
   8         # It is advisable that you don't overcrowd the OnPaint event
   9         # (or any other event) with a lot of code, so let's do the
  10         # actual drawing in the Draw() method, passing the newly
  11         # initialized wx.BufferedPaintDC
  12         self.Draw(dc)
  13 
  14 
  15     def Draw(self, dc):
  16         """
  17         Actually performs the drawing operations, for the bitmap and
  18         for the text, positioning them centered vertically.
  19         """
  20 
  21         # Get the actual client size of ourselves
  22         width, height = self.GetClientSize()
  23 
  24         if not width or not height:
  25             # Nothing to do, we still don't have dimensions!
  26             return
  27 
  28         # Initialize the wx.BufferedPaintDC, assigning a background
  29         # colour and a foreground colour (to draw the text)
  30         backColour = self.GetBackgroundColour()
  31         backBrush = wx.Brush(backColour, wx.SOLID)
  32         dc.SetBackground(backBrush)
  33         dc.Clear()
  34 
  35         if self.IsEnabled():
  36             dc.SetTextForeground(self.GetForegroundColour())
  37         else:
  38             dc.SetTextForeground(wx.SystemSettings.GetColour(wx.SYS_COLOUR_GRAYTEXT))
  39 
  40         dc.SetFont(self.GetFont())
  41 
  42         # Get the text label for the checkbox, the associated check bitmap
  43         # and the spacing between the check bitmap and the text
  44         label = self.GetLabel()
  45         bitmap = self.GetBitmap()
  46         spacing = self.GetSpacing()
  47 
  48         # Measure the text extent and get the check bitmap dimensions
  49         textWidth, textHeight = dc.GetTextExtent(label)
  50         bitmapWidth, bitmapHeight = bitmap.GetWidth(), bitmap.GetHeight()
  51 
  52         # Position the bitmap centered vertically
  53         bitmapXpos = 0
  54         bitmapYpos = (height - bitmapHeight)/2
  55 
  56         # Position the text centered vertically
  57         textXpos = bitmapWidth + spacing
  58         textYpos = (height - textHeight)/2
  59 
  60         # Draw the bitmap on the DC
  61         dc.DrawBitmap(bitmap, bitmapXpos, bitmapYpos, True)
  62 
  63         # Draw the text
  64         dc.DrawText(label, textXpos, textYpos)
  65 
  66         # Let's see if we have keyboard focus and, if this is the case,
  67         # we draw a dotted rectangle around the text (Windows behavior,
  68         # I don't know on other platforms...)
  69         if self.HasFocus():
  70             # Yes, we are focused! So, now, use a transparent brush with
  71             # a dotted black pen to draw a rectangle around the text
  72             dc.SetBrush(wx.TRANSPARENT_BRUSH)
  73             dc.SetPen(self._focusIndPen)
  74             dc.DrawRectangle(textXpos, textYpos, textWidth, textHeight)
  75 
  76 
  77     def OnEraseBackground(self, event):
  78         """ Handles the wx.EVT_ERASE_BACKGROUND event for CustomCheckBox. """
  79 
  80         # This is intentionally empty, because we are using the combination
  81         # of wx.BufferedPaintDC + an empty OnEraseBackground event to
  82         # reduce flicker
  83         pass

What did I do? Well, I have just asked the control to provide its width and height, the suitable check bitmap (depending on the control state), and then I simply positioned the bitmap centered vertically, the text beside it. In case the custom widget has focus from keyboard, I draw a thin dotted rectangle around the checkbox label. You surely have noted that the method OnEraseBackground() is empty.

Our custom control is now perfectly drawn, but we still don't know what to do to react to mouse and keyboard events. What we want the control to do is just to change its state (checked/unckecked) when the user click with the left mouse button or when he hits the spacebar and we have keyboard focus. About mouse events:

Toggle line numbers
   1     def OnMouseClick(self, event):
   2         """ Handles the wx.EVT_LEFT_DOWN event for CustomCheckBox. """
   3 
   4         if not self.IsEnabled():
   5             # Nothing to do, we are disabled
   6             return
   7 
   8         self.SendCheckBoxEvent()
   9         event.Skip()
  10 
  11 
  12     def SendCheckBoxEvent(self):
  13         """ Actually sends the wx.wxEVT_COMMAND_CHECKBOX_CLICKED event. """
  14 
  15         # This part of the code may be reduced to a 3-liner code
  16         # but it is kept for better understanding the event handling.
  17         # If you can, however, avoid code duplication; in this case,
  18         # I could have done:
  19         #
  20         # self._checked = not self.IsChecked()
  21         # checkEvent = wx.CommandEvent(wx.wxEVT_COMMAND_CHECKBOX_CLICKED,
  22         #                              self.GetId())
  23         # checkEvent.SetInt(int(self._checked))
  24         if self.IsChecked():
  25 
  26             # We were checked, so we should become unchecked
  27             self._checked = False
  28 
  29             # Fire a wx.CommandEvent: this generates a
  30             # wx.wxEVT_COMMAND_CHECKBOX_CLICKED event that can be caught by the
  31             # developer by doing something like:
  32             # MyCheckBox.Bind(wx.EVT_CHECKBOX, self.OnCheckBox)
  33             checkEvent = wx.CommandEvent(wx.wxEVT_COMMAND_CHECKBOX_CLICKED,
  34                                          self.GetId())
  35 
  36             # Set the integer event value to 0 (we are switching to unchecked state)
  37             checkEvent.SetInt(0)
  38 
  39         else:
  40 
  41             # We were unchecked, so we should become checked
  42             self._checked = True
  43 
  44             checkEvent = wx.CommandEvent(wx.wxEVT_COMMAND_CHECKBOX_CLICKED,
  45                                          self.GetId())
  46 
  47             # Set the integer event value to 1 (we are switching to checked state)
  48             checkEvent.SetInt(1)
  49 
  50         # Set the originating object for the event (ourselves)
  51         checkEvent.SetEventObject(self)
  52 
  53         # Watch for a possible listener of this event that will catch it and
  54         # eventually process it
  55         self.GetEventHandler().ProcessEvent(checkEvent)
  56 
  57         # Refresh ourselves: the bitmap has changed
  58         self.Refresh()

As you can see, the procedure is quite simple: wxPython detects the mouse click and call the event handler. We just query the status of our control (checked/unchecked), and we fire a wx.wxEVT_COMMAND_CHECKBOX_CLICKED, assign the event value based on the control state, and watch for a possible listener of this event (look at the demo, it is very simple).

Now, for keyboard event handling:

Toggle line numbers
   1     def OnKeyDown(self, event):
   2         """ Handles the wx.EVT_KEY_DOWN event for CustomCheckBox. """
   3 
   4         if event.GetKeyCode() == wx.WXK_SPACE:
   5             # The spacebar has been pressed: toggle our state
   6             self.SendCheckBoxEvent()
   7             event.Skip()
   8             return
   9 
  10         event.Skip()

Here, we simply check if the user has pressed the spacebar. In this case, we call SendCheckBoxEvent which actually fires the event.

To complete the event handling review, let's look at the focus events, which are quite simple:

Toggle line numbers
   1     def OnSetFocus(self, event):
   2         """ Handles the wx.EVT_SET_FOCUS event for CustomCheckBox. """
   3 
   4         self._hasFocus = True
   5 
   6         # We got focus, and we want a dotted rectangle to be painted
   7         # around the checkbox label, so we refresh ourselves
   8         self.Refresh()
   9 
  10     def OnKillFocus(self, event):
  11         """ Handles the wx.EVT_KILL_FOCUS event for CustomCheckBox. """
  12 
  13         self._hasFocus = False
  14 
  15         # We lost focus, and we want a dotted rectangle to be cleared
  16         # around the checkbox label, so we refresh ourselves
  17         self.Refresh()

Nothing really special here, we just redraw ourselves when the focus event is fired because we either want to paint a dotted focus rectangle (we have focus) or we want to remove it (we lost focus).

The only important things that are now missing are the overridden base class virtual methods, the ones that make wxPy controls so special. In order to behave correctly (in my opinion), CustomCheckBox needs to override the following methods:

Toggle line numbers
   1     def AcceptsFocusFromKeyboard(self):
   2         """Overridden base class virtual."""
   3 
   4         # We can accept focus from keyboard, obviously
   5         return True
   6 
   7 
   8     def AcceptsFocus(self):
   9         """ Overridden base class virtual. """
  10 
  11         # It seems to me that wx.CheckBox does not accept focus with mouse
  12         # but please correct me if I am wrong!
  13         return False
  14 
  15     def GetDefaultAttributes(self):
  16         """
  17         Overridden base class virtual.  By default we should use
  18         the same font/colour attributes as the native wx.CheckBox.
  19         """
  20 
  21         return wx.CheckBox.GetClassDefaultAttributes()
  22 
  23     def ShouldInheritColours(self):
  24         """
  25         Overridden base class virtual.  If the parent has non-default
  26         colours then we want this control to inherit them.
  27         """
  28 
  29         return True

And, last but not least, the most fundamental one, DoGetBestSize: you need to override it if you want your control to be correctly managed by sizers:

Toggle line numbers
   1     def DoGetBestSize(self):
   2         """
   3         Overridden base class virtual.  Determines the best size of the control
   4         based on the label size, the bitmap size and the current font.
   5         """
   6 
   7         # Retrieve our properties: the text label, the font and the check
   8         # bitmap
   9         label = self.GetLabel()
  10         font = self.GetFont()
  11         bitmap = self.GetBitmap()
  12 
  13         if not font:
  14             # No font defined? So use the default GUI font provided by the system
  15             font = wx.SystemSettings.GetFont(wx.SYS_DEFAULT_GUI_FONT)
  16 
  17         # Set up a wx.ClientDC. When you don't have a dc available (almost
  18         # always you don't have it if you are not inside a wx.EVT_PAINT event),
  19         # use a wx.ClientDC (or a wx.MemoryDC) to measure text extents
  20         dc = wx.ClientDC(self)
  21         dc.SetFont(font)
  22 
  23         # Measure our label
  24         textWidth, textHeight = dc.GetTextExtent(label)
  25 
  26         # Retrieve the check bitmap dimensions
  27         bitmapWidth, bitmapHeight = bitmap.GetWidth(), bitmap.GetHeight()
  28 
  29         # Get the spacing between the check bitmap and the text
  30         spacing = self.GetSpacing()
  31 
  32         # Ok, we're almost done: the total width of the control is simply
  33         # the sum of the bitmap width, the spacing and the text width,
  34         # while the height is the maximum value between the text width and
  35         # the bitmap width
  36         totalWidth = bitmapWidth + spacing + textWidth
  37         totalHeight = max(textHeight, bitmapHeight)
  38 
  39         best = wx.Size(totalWidth, totalHeight)
  40 
  41         # Cache the best size so it doesn't need to be calculated again,
  42         # at least until some properties of the window change
  43         self.CacheBestSize(best)
  44 
  45         return best

In this method, I simply initialize a wx.ClientDC, and I use GetTextExtent to calculate the text extent of the checkbox label. Then, the total width of the control is simply the text width plus the spacing plus the bitmap width, while the total height is simply the maximum between the text height and the bitmap height. If you correctly implement the DoGetBestSize method, your control will be correctly laid out by sizers.

Ok, if you followed me till now, it means you have a lot of spare time ;-)

What's still missing? Oh, a couple of methods typical for wx.CheckBox, but nothing special:

Toggle line numbers
   1     def GetValue(self):
   2         """
   3         Returns the state of CustomCheckBox, True if checked, False
   4         otherwise.
   5         """
   6 
   7         return self._checked
   8 
   9     def IsChecked(self):
  10         """
  11         This is just a maybe more readable synonym for GetValue: just as the
  12         latter, it returns True if the CustomCheckBox is checked and False
  13         otherwise.
  14         """
  15 
  16         return self._checked
  17 
  18     def SetValue(self, state):
  19         """
  20         Sets the CustomCheckBox to the given state. This does not cause a
  21         wx.wxEVT_COMMAND_CHECKBOX_CLICKED event to get emitted.
  22         """
  23 
  24         self._checked = state
  25 
  26         # Refresh ourselves: the bitmap has changed
  27         self.Refresh()

We're done. Our custom control is ready to be tested with a demo :-)) . You can find the source code, demo and the icons in this attachment: CustomCheckBox

Conclusions

The custom control we designed is very simple, but I hope to have given enough information on how you can actually build your custom widget. As always, if you got new ideas about new widgets, please contact me ;-)

For the wxPython gurus: please correct my slugginesh in coding, there might be something I have overlooked/misinterpreted. And please don't shoot me if you think that the code is not "Pythonic" (whatever that means).

wxPython rules ;-)

Andrea.

NOTE: To edit pages in this wiki you must be a member of the TrustedEditorsGroup.