Recently I've been building a GTK application that includes a custom drawing widget for editing a simple 2D map. When elements are selected in the map I wanted a nice way to edit those elements within the map itself.
I can think of a few options, ranging from most intrusive down to least.
- Make the user click a toolbar button or select a menu item to display a modal in which selected item can be edited.
- Have a pane on the side that contains the UI for editing the selected item.
- Use an overlay to render the UI within the editor view itself, and use expanders to allow the user to collapse down the UI when they don't need it.
The first option is pretty clunky: selecting something in the map editor and then either selecting an item from a dropdown menu, clicking on a toolbar button or pressing a hot-key, and then interacting with a modal dialog containing the editing UI. This forces the user to take too many steps to get to a common action, and is probably bad UX design.
The second option, where the editing UI is contained in a pane off to one side or another, feels less clunky than the first, but can result in a lot of wasted space if the item editing UI is fairly small. If the editing UI was quite large and likely to exceed the height of the window, then it makes more sense for it to live in a pane.
The third option is my favourite, and is reminiscent of applications like Blender.
It's interesting to me that we don't often see this kind of UI in many GTK applications, which I worry might lead people to believe that GTK is pretty underwhelming as a UI library.
In this article I thought it would be fun to walk through creating a simple GTK application that uses an overlay widget to render a set of controls over the top of a custom drawn widget.
As we're only focusing on the GTK side of things, I decided to use Python instead of C++.
The first thing we need to do is import the
gi package and load the GTK library. We'll then import the
The first thing we will establish is our main window. We'll use a simple
GtkWindow and give it a default size of 800x800 pixels. Let's define a new class called
MainWindow that inherits from
We also want to make sure that when the user closes our window the application terminates. We typically terminate a GTK application by calling
Gtk.main_quit(), which terminates the application event loop.
MainWindow class defined we can instantiate it, make it visible and then enter the application event loop using
We can now run our Python script and we should get a nice square window with nothing in it.
Now that we have our main window in place we can move on to our custom drawn control. We're going to use a
Gtk.DrawingArea that contains a simple grid. Later we'll add some interaction to allow us to pan the grid around by holding down the right mouse button.
To start with we will need to define our
MapEditor class. This class inherits from
Gtk.DrawingArea. In order to draw anything, we need to connect to the
draw event. Our event handler will be passed the current widget along with the Cairo rendering context. We can then use this to render our widget. For now we'll just draw the background as a rectangle covering the visible area of the widget.
To test our widget we'll add it as the direct child of our window. This will cause the
MapEditor widget to take up the entire interior space of the window. In the constructor for our
MainWindow we will create the instance of our
MapEditor widget and then call
add on the
MainWindow to add our editor widget.
With these changes in place, when we run our Python script we will get a window with the dark-grey background we rendered in our
No doubt your are now as overwhelmed by excitement as I am that we changed the background color of the window.
To make things a little more interesting than just a dark background we'll add a grid to our map editor. We'll also allow the user to pan the map by dragging with the right mouse button.
To get this interactivity, we will first need to tell GTK what events we are interested in receiving. We do this by calling the
set_events method in our
MapEditor constructor. This method takes an integer, where each bit of the integer represents the various events that we wish to receive. The values of these bits are available in the
Gdk.EventMask enumeration in Python.
We're interested in three events:
- When a mouse button is pressed (
- When a mouse button is released (
- When the mouse pointer moves around on our widget (
We use a bitwise OR to combine these three masks and pass them to the
With the event mask set we will start to receive events for mouse button presses, releases and pointer motion within our widget. The event masks and signal names correspond as follows:
|Event Mask||Signal Name|
We can connect event handlers to these three signals by calling the
connect method. As with our
draw signal handler, these signal handlers will receive the widget as their first argument. The second argument is a
Gdk.Event corresponding to either the button press events (a
Gdk.EventButton) or pointer motion event (with
For now, each of the signal handlers will simply log the
y fields from the event:
Putting these signal handlers together we get the following revision to our
Running our script again we will see that the terminal is filled with motion events when the mouse passes over the
MapEditor widget in our window. When we press and release the mouse buttons with the mouse pointer over our
MapEditor widget we also see press and release events.
To implement our panning we first want to keep track of the mouse motion and the currently held mouse button. We'll do so using three fields:
on_button_press_event we'll record the X and Y coordinates of the mouse when a button is pressed, along with the actual button being held.
When a mouse button is released, we will reset the mouse X and Y coordinates and set the
_button field back to
None to indicate that no more button is held.
When the right mouse button is held down, we want to update the X and Y coordinates as the pointer moves across our window. We do this by changing our
on_motion_notify_event handler to update the
_mouse_y fields if the
_button field is equal to
Gdk.BUTTON_SECONDARY (which corresponds to the right mouse button).
With these changes our
MapEditor class will track the motion of the mouse pointer when the right mouse button is held. However, we want to use this to move around the map. To do so we need to keep track of our current location in the map. We'll keep track of this in two additional fields:
For simplicity, we'll pretend that these coordinates correspond to the position of the top-left corner of the
MapEditor widget in our pretend map. We'll also assume that our world has a 1-to-1 mapping to a pixel.
As we drag the mouse pointer over the
MapEditor widget, we will need to keep track of the motion delta of the mouse pointer, and add that to our camera coordinates. That is, as we receive
motion-notify-event signals, we want to change our camera position by the same number of pixels as our pointer has moved.
We can do this quite easily in our
on_motion_notify_event handler. We have the last coordinates of the mouse in the
_mouse_y fields, and the new coordinates of the mouse is in the
y fields of the
Gdk.EventMotion we receive in the
event argument. If we subtract the last position of the mouse from it's current position, the result is how far the mouse has travelled in the X and Y direction since our last recording. We can then add this to our
_camera_y fields to move the camera.
One final thing we added to our
on_motion_notify_event method is a call to
queue_draw when we update the camera coordinates. This tells GTK that we want it to redraw our widget, without which we would not see anything until we did something to invalidate the widget (such as changing the size of the window).
We'll also change our
on_draw method to print our the camera coordinates when it renders so we can see them update as we drag the mouse around the
MapEditor widget with the right mouse button held.
With these changes in place we should see messages in our terminal when we drag the cursor over our
MapEditor that show the camera updating.
However, we're not getting anything in our widget yet, as the camera coordinates are not being used in our rendering.
Next we'll add the grid rendering to our
MapEditor widget. We'll draw two grids: a minor grid that draws a line every 10 pixels and a major grid every 100 pixels:
A fairly nice approach to rendering this grid is to offset the start position of the grid lines by the camera coordinates, modulus the size of each grid line. This way, as the camera moves, the top-left corner of the grid follows the camera until it reaches the next grid line step before resetting. By doing so we are limiting the number of grid lines we have to draw to the size of the viewport.
This may be somewhat hard to understand, so here is an animation that shows the camera position being updated by dragging the mouse. The start corner of the major grid is rendered with a green square and the minor grid with a red square.
The grid appears to be panning as the mouse moves, however we can see that all we're actually doing is slightly offsetting the starting point for the rendering of the grid.
The grid rendering code is quite simple:
Another useful thing to note is that the start X and Y coordinates are clamped to an integer and then offset by half a pixel. The reason for this half-pixel offset is to make sure that the grid lines are always cleanly rendered with little to no aliasing visible. If we rendered the grid using fractional values instead, we would end up with heavy aliasing as shown in the animation below.
You can see how the grid is almost moving in and out of focus as it pans. This is because Cairo renders a line with a thickness of one pixel centered on the line. Therefore, we offset all our lines by half a pixel to keep them relatively clean.
Putting this all together gives us our complete
With the custom drawn widget complete we can move on to our control panel overlay. First we'll define a
ControlPanel class that inherits from
Gtk.Box. This class represents our control panel and allows us to add groups of widgets that stack vertically. To support this, we'll add an
add_group method that packs a control group into the box.
In the constructor for the
ControlPanel class we invoke the
Gtk.Box constructor and set the orientation of the box to vertical, meaning that our control panel groups are arranged vertically. We also set the spacing to five pixels to ensure there's a small gap between them.
Now we'll define the class for our control panel groups. Each group is presented as an expandable widget with a title. Much like our
ControlPanel widget, we support adding child widgets as rows.
With these two classes in place we can create our demo control panel. We add two
ControlPanelGroup widgets and pack a few demo widgets into each.
We now want to overlay our
MyControlPanel widget over the
MapEditor widget that dominates our main window. To do so we will use a
Gtk.Overlay widget as the immediate child of our window, and add the
MyControlPanel widgets as overlays.
When we run our Python script we will see that the expanders and their widgets are indeed rendered over the top of our map editor. Unfortunately, when expanded, they cover far too much of the editor viewport!
What we want to do now is to tell the
Gtk.Overlay widget where to place the
MyControlPanel widget. The
Gtk.Overlay widget arranges overlays using the
valign properties of the overlaid widget. There are a number of options for these properties, which change where they are placed:
This arranges the panel in the top-right of the window, which is much better. However we can see that there is a new problem: the size of the control panel changes when we expand and collapse each of the
Of course this is perfectly natural behaviour for a GTK application. However, we want to fix the width of the control panel. We'll do this by specifying the width in the size request for each
ControlPanelGroup by calling the
set_size_request method. We'll leave the height at minus one so it's calculated based on the children of the group.
With this change our control panel seems a bit more sane. Well, it doesn't jump around as much as it did before.
We still have a problem that the control panel has no background to it. To understand why this is we can dive into the CSS nodes of our application using the GTK inspector.
We can bring up the inspector for our application by setting the
GTK_DEBUG environment variable to
interactive. We can do this by changing our invocation of the Python interpreter when we run our script:
This will bring up the GTK inspector alongside our application:
We can now navigate through the object tree until we get to our
With this selected we can switch to the CSS nodes by selecting the light-bulb in the top-left of the inspector window to change to properties view (the icon will change from a light-bulb to a list icon). We can then select CSS nodes from the dropdown beneath it.
Taking a look at the CSS properties for the
MyControlPanel widget on the right side of the inspector we can see that there is no background color or image specified:
We want to change that, so we'll edit the CSS for our application. To apply out CSS we'll add a
control-panel class to our
MyControlPanel widget from within the GTK inspector. To add a class, double-click in the Style Classes column for the current widget. This brings up the
Style Classes popover where we can add our new class:
+ button in the
Style Classes popover to add a new class, and enter the name
We can now edit the inline CSS for by selecting the CSS tab in the top of the inspector. This presents us with an editor in which we can write some CSS.
We're going to add the following CSS to set the background color of our control panel widget – to which we've just applied the
control-panel class. We'll set the background color to the standard background color for the current GTK theme.
Immediately after making this change you should see that our control panel now has a background color:
Whilst we're live editing the CSS we might as well add some padding to the control panel too.
This will nudge our control groups away from the edges of the control panel, and make the UI a little cleaner looking.
Looking closely at the bottom-left corner of the control panel, we can see quite a sharp corner:
Let's update our stylesheet a bit more to round off the bottom-left corner with a four pixel radius.
Now that we're happier with the appearance of our control panel we can add the CSS to our Python script. To do this we need to load our CSS into a
Gtk.CssProvider and then add the provider to the
Gtk.StyleContext for the current screen. We can do this in a function that we'll call
install_css, which we'll call before we create our window:
For this CSS to work we also need to add the
control-panel class to our
ControlPanel widget. We'll do that in the constructor for
ControlPanel by first retrieving the
Gtk.StyleContext for the widget and then adding the
control-panel class by calling the
After this final change to the
ControlPanel widget we should have a demo that works fairly well.
If you want to download the source code for this demo you can find it at the following GitHub Gist: