This is part two in our Python tkinter tutorial series. In part one, we defined the basic structure of our script, set up a logger, and got the root window of our GUI application up and running in the app's single view class. In the present article, we'll begin work on the main view in earnest. We will configure the tkinter root object, create and configure a single frame to hold all the app's contents, and then create our app's title banner. First, however, we should discuss how to structure the layout of a Python tkinter application, and our strategy for doing so in the present project.
From what I've read, it appears that a lot of people had been turned off by Tkinter in the past because of its' seemingly weird geometry management, which involved a choice between placing widgets, or packing them or putting them in a grid, when building a layout. The docs suggest using the pack method over the placement method. It is thus no surprise that many tutorials and reference web sites utilize the pack method.
But the pack method never really clicked for me when I first started playing around with Tkinter in Python 2.7 a couple years ago. These days, in Python 3.4, the preference is to configure tkinter views using the grid geometry manager. This method is suggested by Russel Keith-Magee in his defense of tkinter (mentioned in the introduction to our series), and I've come across other recent discussions of the module making a similar argument.
Grid Geometry Management: Grids of Grids
In terms of its geometry, our app will consist of a grid of grids. First, we are going to configure the root window as a 1x1 grid. Into that single root cell, we'll then install a main outer frame, which we'll structure as a grid that will grow as we add more components to our interface. We will then group related individual widgets (labels, inputs, buttons, etc.) into their own frames in a grid format, and then drop these frames into the desired cell(s) of the main outer frame's grid as needed to build our interface.
For the purposes of this tutorial, we are going to structure the View() class by creating a setup method that is run on initialization of the class. This method will call a series of others methods that build the individual elements of the app: root window, main frame, banner, input fields, output fields, button box, etc. We will build out these various methods in subsequent articles until we have a working model of our mockup design.
Configure the Root Window
Beginning from where we left off in part one, we'll now add our set_up() method to the View class, and call it from init(). In set_up(), we'll call another method, configure_root(), where – you guessed it – we configure the root window. We're going to set some basic properties on the root window by calling two methods on the self.root attribute in the View class: columnconfigure() and rowconfigure(). It is here that we could also call methods such as minsize(), if we so desired.
We use the columnconfigure() and rowconfigure() methods on root to define the properties of the outermost grid in our app. These methods each take an integer parameter defining which column and row we are referring to (in our case, since we are building a 1x1 grid, we'll be defining column 0 and row 0), as well as a kwarg that defines their respective weights to be 1. The weight keyword argument determines how the cell responds when the window is resized. Giving them both a weight of 1 means they will resize proportionally to each other when the window is resized. Your View class should now look as follows:
Learn to love the column and row configuration methods! They are the primary means by which we'll define where our app's widgets live, and how the individual cells of our app respond to changes in the window size. There are other configuration options for the root window which you can play around with on your own, but we're now done configuring the root window for the purposes of PyGest.
Configure the Main Frame
We'll now configure the main content frame of the app. As mentioned above, we are going to drop this content frame into the single cell we just defined for the root window. Unlike the root grid, this content frame will be variable in size and will grow as we add more widgets to the interface. Define a new method called configure_mainframe() in the view class and call it from the class's set_up() method. For now, we are going to add five lines of code to this method. Later we'll see it grow along with the rest of the app.
If you run the app now, you may find that your window has mysteriously disappeared! No need to panic, we'll get to that in a second. (Hint: call the minsize() method on the root object.) There's a lot going on in our configure_mainframe() method. Always start with a comment. After that, I added a simple log to make sure the method is actually being called – since I initially forgot to call this method from set_up!
Next we define the app's main frame as a tkinter.Frame() object. Notice I'm defining it as self.mainframe, i.e. as an instance attribute of the class. This is because we are going to have to reference this attribute from other methods as we add more widgets to our app. (Though not strictly necessary, I also declare this attribute in the init() method of the class and assign it an initial value of None just to be explicit about its existence, and to make my IDE stop complaining about the fact that I've defined an instance attribute outside of init().)
We thus define self.mainframe as a tkinter.Frame() object and pass it two arguments. The first identifies the pre-existing object into which we want to embed this frame. We only have one such object right now (the root window of the app!) so we pass in self.root. I've also given the frame a background color to provide visual confirmation that it is in place. You can explore other parameters in effbot's reference documentation.
Next, we call the grid() method on our newly declared mainframe object, and pass it three kwargs. The column and row arguments determine where the self.mainframe object will be located within the self.root grid that we are attaching it to (as defined in the previous line of code). Our root grid only has a single cell, with the coordinates row 0 and column 0. So that's what we pass to the mainframe's grid method. Finally, we pass in the kwarg sticky, which we define as north, south, east and west. This means that the mainframe object will stick to the north, south, east and west sides of the cell to which it has been attached, if the size of that cell changes.
Finally, we configure a single column and a single row for our mainframe content grid, and give them each a weight of 1, just as we did for the root window. At present, our main frame is now a 1x1 grid, embedded in the single cell of the root window's 1x1 grid. As we add widgets to our interface, we will add more columns and rows to the mainframe grid as necessary.
As mentioned above, if you run the app with the code above, you may find that your window as mysteriously disappeared! This is because it has nothing in it except for an empty frame! By default, tkinter windows and cells always shrink down to fit the size of their largest element. Since our frame is empty, the window is minimized to the maximum. You can easily resize the window by calling the minsize() method on the root object in your configure_root() method: self.root.minsize(200,200).
Running the app with the code above, not much should change except for the fact that the window is now blue (or whatever color you've decided upon). If you resize the app, the main frame will stick to all four sides of the root window, and the window will remain blue. However, if you play around with the sticky kwarg by using different combinations of north, south, east and west, you'll see some perhaps surprising changes in the look of things.
For example, if you make the frame sticky only to the north and south, you'll see a single vertical line through the middle of the window. If you make it sticky only to the east and west, you'll see a single horizontal line through the middle of the window. This is because in tkinter, by default, a widget object is only as big as its biggest element. But our main frame doesn't have anything in it yet, so it collapses down in this seemingly weird way.
Configure the Banner
We now have a single root window structured as a 1x1 grid. We've embedded a single frame in that root window, and this frame is (for the time being) also structured as a 1x1 grid. As we add components to our interface we will add more rows and/or columns to this frame as necessary. Let's start to fill it out by giving it a header banner that displays the name of the app. First things first, define a new method inside the View() class called configure_banner(), call this method from set_up(), give it a basic comment and a log message.
We are going to display our app's name in the title banner using a simple label object. However, we now have a choice. Label is one of the objects that can be found in both tkinter and the ttk submodule. (As mentioned in part one, the ttk submodule contains widgets that are not available in the top level tkinter library, ex. Combobox, and also "gives the application an improved look and feel," according to the docs.) Objects that are in both the tkinter top level library and the ttk submodule are styled in a slightly different manner. Styling parameters are passed directly to top level tkinter objects like labels, whereas for ttk objects you have to configure these objects using the style class. You can play around with both to get a sense of the differences, if you like. For the sake of simplicity and backward compatibility, we're going to use a basic tkinter label here.
The configure_banner() method in our View() class is going to be very simple. It will have two critical lines of code: the first defines the banner as a label attached to our main frame and gives it some basic styling, and the second defines where the label will be placed in the main frame's grid as well as some other parameters. Here's the code for the configure_banner() method:
Under the comment and the log message, I define the banner variable itself as a tkinter label. We're passing five arguments to the tkinter.Label() object: 1) self.mainframe is the object we are attaching this label to, 2) we're making the background black so we can see it, 3) the text kwarg defines the text that will be displayed in the label, 4) the font kwarg is assigned a tuple that contains the name of the font and its size, and finally 5) fg (which stands for foreground) defines what color the foreground text is (note, you can also shorten the background kwarg to bg).
In the final line of the method, we specify the behavior of the object itself by calling the grid method on it. We're passing in five arguments here as well. The row and column kwargs specify where the banner will appear in the mainframe object in which it is embedded. We want our banner to appear at the top of the interface, so we're putting it at row 0, column 0. (We'll drop our next object into the app just below the banner at row 1, column 0 in the main frame.) Next we make the object sticky to the north, south, east and west to start. Finally we give it some horizontal and vertical padding to give the object a bit of breathing room.
If you had previously defined a minimum size for your root window with the minsize() method, you should comment that out, so you can see how the default window sizing works now that we have an object with some substance embedded in the frame. Here's the state of the app with its banner and debug coloring:
Thanks for following along. As always, questions, comments, critiques and suggestions are welcome in the comments. In the next article in the series, we will build out the widgets relating to our app's user inputs: labels, text entry fields and buttons.You can find the next article in the series here.