Python GUI Development with Tkinter: Part 2

This is the second installment of our multi-part series on developing GUIs in Python using Tkinter. Check out the links below for the other parts to this series:

Introduction

In the first part of the StackAbuse Tkinter tutorial series, we learned how to quickly build simple graphical interfaces using Python. The article explained how to create several different widgets and position them on the screen using two different methods offered by Tkinter – but still, we barely scratched the surface of the module's capabilities.

Get ready for the second part of our tutorial, where we'll discover how to modify the appearance of our graphical interface during our program's runtime, how to cleverly connect the interface with the rest of our code, and how to easily get text input from our users.

In the last article, we got to know the grid() method that lets us orient widgets in rows and columns, which allows for much more ordered results than using the pack() method. Traditional grids have their disadvantages though, which can be illustrated by the following example:

import tkinter

root = tkinter.Tk()

frame1 = tkinter.Frame(root, borderwidth=2, relief='ridge')
frame2 = tkinter.Frame(root, borderwidth=2, relief='ridge')
frame3 = tkinter.Frame(root, borderwidth=2, relief='ridge')

frame1.grid(column=0, row=0, sticky="nsew")
frame2.grid(column=1, row=0, sticky="nsew")
frame3.grid(column=0, row=1, sticky="nsew")

label1 = tkinter.Label(frame1, text="Simple label")
button1 = tkinter.Button(frame2, text="Simple button")
button2 = tkinter.Button(frame3, text="Apply and close", command=root.destroy)

label1.pack(fill='x')
button1.pack(fill='x')
button2.pack(fill='x')

root.mainloop()


Output:

The code above should be easily understandable for you if you went through the first part of our Tkinter tutorial, but let's do a quick recap anyway. In line 3, we create our main root window. In lines 5-7 we create three frames: we define that the root is their parent widget and that their edges will be given a subtle 3D effect. In lines 9-11 the frames are distributed inside the window using the grid() method. We indicate the grid cells that are to be occupied by each widget and we use the sticky option to stretch them horizontally and vertically.

In lines 13-15 we create three simple widgets: a label, a button that does nothing, and another button that closes (destroys) the main window – one widget per frame. Then, in lines 17-19 we use the pack() method to place the widgets inside their respective parent frames.

As you can see, three widgets distributed over two rows and two columns do not generate an aesthetically pleasing outcome. Even though frame3 has its entire row for itself, and the sticky option makes it stretch horizontally, it can only stretch within its individual grid cell's boundaries. The moment we look at the window we instinctively know that the frame containing button2 should span two columns – especially considering the important function that the button executes.

Well, luckily, the creators of the grid() method predicted this kind of scenario and offers a column span option. After applying a tiny modification to line 11:

import tkinter

root = tkinter.Tk()

frame1 = tkinter.Frame(root, borderwidth=2, relief='ridge')
frame2 = tkinter.Frame(root, borderwidth=2, relief='ridge')
frame3 = tkinter.Frame(root, borderwidth=2, relief='ridge')

frame1.grid(column=0, row=0, sticky="nsew")
frame2.grid(column=1, row=0, sticky="nsew")
frame3.grid(column=0, row=1, sticky="nsew", columnspan=2)

label1 = tkinter.Label(frame1, text="Simple label")
button1 = tkinter.Button(frame2, text="Simple button")
button2 = tkinter.Button(frame3, text="Apply and close", command=root.destroy)

label1.pack(fill='x')
button1.pack(fill='x')
button2.pack(fill='x')

root.mainloop()


We can make our frame3 stretch all the way across the entire width of our window.

Output:

The place() Method

Usually when building nice and ordered Tkinter-based interfaces, place() and grid() methods should satisfy all your needs. Still, the package offers one more geometry manager – the place() method.

The place() method is based on the simplest principles out of all three of Tkinter's geometry managers. Using place() you can explicitly specify your widget's position inside the window, either by directly providing its exact coordinates, or making its position relative to the window's size. Take a look at the following example:

import tkinter

root = tkinter.Tk()

root.minsize(width=300, height=300)
root.maxsize(width=300, height=300)

button1 = tkinter.Button(root, text="B")
button1.place(x=30, y=30, anchor="center")

root.mainloop()


Output:

In lines 5 and 6 we specify that we want the dimensions of our window to be exactly 300 by 300 pixels. In line 8 we create a button. Finally, in line 9, we use the place() method to place the button inside our root window.

We provide three values. Using the x and y parameters, we define exact coordinates of the button inside the window. The third option, anchor, lets us define which part of the widget will end up at the (x,y) point. In this case, we want it to be the central pixel of our widget. Similarly to the sticky option of grid(), we can use different combinations of n, s, e and w to anchor the widget by its edges or corners.

The place() method doesn't care if we make a mistake here. If the coordinates happen to point to a place outside our window's boundaries, the button will not be displayed. A safer way of using this geometry manager is using coordinates relative to the window's size.

import tkinter

root = tkinter.Tk()

root.minsize(width=300, height=300)
root.maxsize(width=300, height=300)

button1 = tkinter.Button(root, text="B")
button1.place(relx=0.5, rely=0.5, anchor="center")

root.mainloop()


Output

In the example above, we modified line 9. Instead of absolute x and y coordinates, we now use relative coordinates. By setting relx and rely to 0.5, we make sure that regardless of the window's size, our button will be placed at its center.

Okay, there's one more thing about the place() method that you'll probably find interesting. Let's now combine examples 2 and 4 from this tutorial:

import tkinter

root = tkinter.Tk()

frame1 = tkinter.Frame(root, borderwidth=2, relief='ridge')
frame2 = tkinter.Frame(root, borderwidth=2, relief='ridge')
frame3 = tkinter.Frame(root, borderwidth=2, relief='ridge')

frame1.grid(column=0, row=0, sticky="nsew")
frame2.grid(column=1, row=0, sticky="nsew")
frame3.grid(column=0, row=1, sticky="nsew", columnspan=2)

label1 = tkinter.Label(frame1, text="Simple label")
button1 = tkinter.Button(frame2, text="Simple button")
button2 = tkinter.Button(frame3, text="Apply and close", command=root.destroy)

label1.pack(fill='x')
button1.pack(fill='x')
button2.pack(fill='x')

button1 = tkinter.Button(root, text="B")
button1.place(relx=0.5, rely=0.5, anchor="center")

root.mainloop()


Output:

In the example above we just took the code from example 2 and then, in lines 21 and 22, we created and placed our small button from example 4 inside the same window. You might be surprised that this code does not cause an exception, even though we clearly mix grid() and place() methods in the root window. Well, because of the simple and absolute nature of place(), you can actually mingle it with pack() and grid(). But only if you really have to.

The result, in this case, is obviously pretty ugly. If the centered button was bigger, it will affect the usability of the interface. Oh, and as an exercise, you can try moving lines 21 and 22 above the definitions of the frames and see what happens.

It is usually not a good idea to use place() in your interfaces. Especially in larger GUIs, setting (even relative) coordinates for every single widget is just a lot of work and your window can become messy very quickly – either if your user decides to resize the window, or especially if you decide to add more content to it.

Configuring the Widgets

The appearance of our widgets can be changed while the program is running. Most of the cosmetic aspects of the elements of our windows can be modified in our code with the help of the configure option. Let's take a look at the following example:

import tkinter

root = tkinter.Tk()

def color_label():
label1.configure(text="Changed label", bg="green", fg="white")

frame1 = tkinter.Frame(root, borderwidth=2, relief='ridge')
frame2 = tkinter.Frame(root, borderwidth=2, relief='ridge')
frame3 = tkinter.Frame(root, borderwidth=2, relief='ridge')

frame1.grid(column=0, row=0, sticky="nsew")
frame2.grid(column=1, row=0, sticky="nsew")
frame3.grid(column=0, row=1, sticky="nsew", columnspan=2)

label1 = tkinter.Label(frame1, text="Simple label")
button1 = tkinter.Button(frame2, text="Configure button", command=color_label)
button2 = tkinter.Button(frame3, text="Apply and close", command=root.destroy)

label1.pack(fill='x')
button1.pack(fill='x')
button2.pack(fill='x')

root.mainloop()


Output:

In lines 5 and 6 we added a simple definition of a new function. Our new color_label() function configures the state of label1. The options that the configure() method takes are the same options that we use when we create new widget objects and define initial visual aspects of their appearance.

In this case, pressing the freshly renamed "Configure button" changes the text, background color (bg), and foreground color (fg – in this case it is the color of the text) of our already-existing label1.

Now, let's say we add another button to our interface that we want to be used in order to color other widgets in a similar manner. At this point, the color_label() function is able to modify just one specific widget displayed in our interface. In order to modify multiple widgets, this solution would require us to define as many identical functions as the total number of widgets we'd like to modify. This would be possible, but obviously a very poor solution. There are, of course, ways to reach that goal in a more elegant way. Let's expand our example a little bit.

import tkinter

root = tkinter.Tk()

def color_label():
label1.configure(text="Changed label", bg="green", fg="white")

frame1 = tkinter.Frame(root, borderwidth=2, relief='ridge')
frame2 = tkinter.Frame(root, borderwidth=2, relief='ridge')
frame3 = tkinter.Frame(root, borderwidth=2, relief='ridge')
frame4 = tkinter.Frame(root, borderwidth=2, relief='ridge')
frame5 = tkinter.Frame(root, borderwidth=2, relief='ridge')

frame1.grid(column=0, row=0, sticky="nsew")
frame2.grid(column=0, row=1, sticky="nsew")
frame3.grid(column=1, row=0, sticky="nsew")
frame4.grid(column=1, row=1, sticky="nsew")
frame5.grid(column=0, row=2, sticky="nsew", columnspan=2)

label1 = tkinter.Label(frame1, text="Simple label 1")
label2 = tkinter.Label(frame2, text="Simple label 2")
button1 = tkinter.Button(frame3, text="Configure button 1", command=color_label)
button2 = tkinter.Button(frame4, text="Configure button 2", command=color_label)

button3 = tkinter.Button(frame5, text="Apply and close", command=root.destroy)

label1.pack(fill='x')
label2.pack(fill='x')
button1.pack(fill='x')
button2.pack(fill='x')
button3.pack(fill='x')

root.mainloop()


Output:

Okay, so now we have two labels and three buttons. Let's say we want "Configure button 1" to configure "Simple label 1" and "Configure button 2" to configure "Simple label 2" in the exact same way. Of course, the code above doesn't work this way – both buttons execute the color_label() function, which still only modifies one of the labels.

Probably the first solution that comes to your mind is modifying the color_label() function so that it takes a widget object as an argument and configures it. Then we could modify the button definition so that each of them passes its individual label in the command option:

# ...

def color_label(any_label):
any_label.configure(text="Changed label", bg="green", fg="white")

# ...

button1 = tkinter.Button(frame3, text="Configure button 1", command=color_label(label1))
button2 = tkinter.Button(frame4, text="Configure button 2", command=color_label(label2))

# ...


Unfortunately, when we run this code, the color_label() function is executed, the moment the buttons are created, which is not a desirable outcome.

So how do we make it work properly?

Passing Arguments via Lambda Expressions

Lambda expressions offer a special syntax to create so-called anonymous functions, defined in a single line. Going into details about how lambdas work and when they are usually utilized is not the goal of this tutorial, so let's focus on our case, in which lambda expressions definitely come in handy.

import tkinter

root = tkinter.Tk()

def color_label(any_label):
any_label.configure(text="Changed label", bg="green", fg="white")

frame1 = tkinter.Frame(root, borderwidth=2, relief='ridge')
frame2 = tkinter.Frame(root, borderwidth=2, relief='ridge')
frame3 = tkinter.Frame(root, borderwidth=2, relief='ridge')
frame4 = tkinter.Frame(root, borderwidth=2, relief='ridge')
frame5 = tkinter.Frame(root, borderwidth=2, relief='ridge')

frame1.grid(column=0, row=0, sticky="nsew")
frame2.grid(column=0, row=1, sticky="nsew")
frame3.grid(column=1, row=0, sticky="nsew")
frame4.grid(column=1, row=1, sticky="nsew")
frame5.grid(column=0, row=2, sticky="nsew", columnspan=2)

label1 = tkinter.Label(frame1, text="Simple label 1")
label2 = tkinter.Label(frame2, text="Simple label 2")
button1 = tkinter.Button(frame3, text="Configure button 1", command=lambda: color_label(label1))
button2 = tkinter.Button(frame4, text="Configure button 2", command=lambda: color_label(label2))

button3 = tkinter.Button(frame5, text="Apply and close", command=root.destroy)

label1.pack(fill='x')
label2.pack(fill='x')
button1.pack(fill='x')
button2.pack(fill='x')
button3.pack(fill='x')

root.mainloop()


Output:

We modified the color_label() function the same way as we did in the previous shortened example. We made it accept an argument, which in this case can be any label (other widgets with text would work as well) and configured it by changing its text, text color, and background color.

The interesting part is lines 22 and 23. Here, we actually define two new lambda functions, that pass different arguments to the color_label() function and execute it. This way, we can avoid invoking the color_label() function the moment the buttons are initialized.

Getting User Input

We're getting closer to the end of the second article of our Tkinter tutorial series, so at this point, it would be good to show you a way of getting input from your program's user. To do so, the Entry widget can be useful. Look at the following script:

import tkinter

root = tkinter.Tk()

def color_label(any_label, user_input):
any_label.configure(text=user_input, bg="green", fg="white")

frame1 = tkinter.Frame(root, borderwidth=2, relief='ridge')
frame2 = tkinter.Frame(root, borderwidth=2, relief='ridge')
frame3 = tkinter.Frame(root, borderwidth=2, relief='ridge')
frame4 = tkinter.Frame(root, borderwidth=2, relief='ridge')
frame5 = tkinter.Frame(root, borderwidth=2, relief='ridge')
frame6 = tkinter.Frame(root, borderwidth=2, relief='ridge')

frame1.grid(column=0, row=0, sticky="nsew")
frame2.grid(column=0, row=1, sticky="nsew")
frame3.grid(column=1, row=0, sticky="nsew")
frame4.grid(column=1, row=1, sticky="nsew")
frame5.grid(column=0, row=2, sticky="nsew", columnspan=2)
frame6.grid(column=0, row=3, sticky="nsew", columnspan=2)

label1 = tkinter.Label(frame1, text="Simple label 1")
label2 = tkinter.Label(frame2, text="Simple label 2")
button1 = tkinter.Button(frame3, text="Configure button 1", command=lambda: color_label(label1, entry.get()))
button2 = tkinter.Button(frame4, text="Configure button 2", command=lambda: color_label(label2, entry.get()))

button3 = tkinter.Button(frame5, text="Apply and close", command=root.destroy)

entry = tkinter.Entry(frame6)

label1.pack(fill='x')
label2.pack(fill='x')
button1.pack(fill='x')
button2.pack(fill='x')
button3.pack(fill='x')
entry.pack(fill='x')

root.mainloop()


Output:

Take a look at lines 5 and 6. As you can see, color_label() method accepts a new argument now. This argument – a string – is then used to modify the configured the label's text parameter. Additionally, in line 29 we create a new Entry widget (and in line 36 we pack it inside a new frame created in line 13).

In lines 24 and 25, we can see that each of our lambda functions also pass one additional argument. The get() method of the Entry class returns a string which is what the user typed into the entry field. So, as you probably already suspect, after clicking the "configure" buttons, the text of the labels assigned to them is changed to whatever text the user typed into our new entry field.

Conclusion

I hope this part of the tutorial filled some gaps in your understanding of the Tkinter module. Although some advanced features of Tkinter might seem a bit tricky at first, the general philosophy of building interfaces using the most popular GUI package for Python is very simple and intuitive.

Stay tuned for the last part of our Tkinter basics tutorial, where we'll discover some very clever shortcuts that let us create complex user interfaces with very limited code.