Desktop Applications 🖥️
Not everything should be a command line. tkinter ships with Python, works on every operating system, and will get a small tool into a non-programmer's hands today.
Event-driven programming
A command-line program runs top to bottom and stops. A GUI program sets up a window, registers what should happen when things are clicked, and then hands control to a loop that waits for the user. Your code becomes a set of responses rather than a sequence.
import tkinter as tk
root = tk.Tk()
root.title("Ahoy")
count = 0
def on_click():
"""This runs when the button is pressed. Not before."""
global count
count += 1
label.config(text=f"Insults learned: {count}")
label = tk.Label(root, text="Insults learned: 0", font=("Helvetica", 16))
label.pack(padx=40, pady=20)
tk.Button(root, text="Learn an insult", command=on_click).pack(pady=(0, 20))
root.mainloop() # hands control to tkinter. Nothing after this runs until the window closes
mainloop() is the equivalent of the game loop from Lesson 48: it waits for
events and dispatches them to your functions. Everything you write is a callback.
The widgets you will actually use
import tkinter as tk
from tkinter import ttk # themed widgets: they look native
root = tk.Tk()
root.title("Crew manager")
root.geometry("420x360")
name = tk.StringVar(value="Guybrush")
role = tk.StringVar(value="captain")
active = tk.BooleanVar(value=True)
frame = ttk.Frame(root, padding=16)
frame.pack(fill="both", expand=True)
ttk.Label(frame, text="Name").grid(row=0, column=0, sticky="w", pady=4)
ttk.Entry(frame, textvariable=name, width=24).grid(row=0, column=1, pady=4)
ttk.Label(frame, text="Role").grid(row=1, column=0, sticky="w", pady=4)
ttk.Combobox(frame, textvariable=role, values=["captain", "lookout", "cook"],
state="readonly").grid(row=1, column=1, pady=4)
ttk.Checkbutton(frame, text="Currently aboard", variable=active).grid(
row=2, column=0, columnspan=2, sticky="w", pady=8)
listbox = tk.Listbox(frame, height=6)
listbox.grid(row=3, column=0, columnspan=2, sticky="nsew", pady=8)
frame.rowconfigure(3, weight=1)
frame.columnconfigure(1, weight=1)
def add_member():
status = "aboard" if active.get() else "ashore"
listbox.insert("end", f"{name.get()} ({role.get()}, {status})")
name.set("")
ttk.Button(frame, text="Add to crew", command=add_member).grid(
row=4, column=0, columnspan=2, pady=4)
root.mainloop()
| Widget | For |
|---|---|
Label | Text you display |
Entry | One line of typed input |
Text | Multi-line editing |
Button | Doing something |
Checkbutton / Radiobutton | Choices |
Combobox | A dropdown |
Listbox / Treeview | Lists and tables |
Frame | Grouping, and the key to sane layout |
Canvas | Drawing anything you like |
Layout: pick one and stick to it
| Manager | Idea | Use for |
|---|---|---|
.pack() | Stack things in a direction | Simple vertical or horizontal layouts |
.grid() | Rows and columns | Forms. Almost always the right choice |
.place() | Exact pixel positions | Almost never |
tkinter will hang forever with no error while the two managers argue about the size of the container. It is a silent freeze, not a crash, and it is the single most confusing tkinter bug. Different containers may use different managers; one container must pick one.
Dialogs and files
import tkinter as tk
from tkinter import filedialog, messagebox
from pathlib import Path
root = tk.Tk()
root.withdraw() # hide the main window; we only want dialogs
def open_and_count():
path = filedialog.askopenfilename(
title="Choose a text file",
filetypes=[("Text files", "*.txt"), ("All files", "*.*")],
)
if not path:
return # the user cancelled, which is not an error
try:
words = len(Path(path).read_text(encoding="utf-8").split())
except UnicodeDecodeError:
messagebox.showerror("Cannot read", "That does not look like a text file.")
return
if messagebox.askyesno("Result", f"{words} words. Save a report?"):
target = filedialog.asksaveasfilename(defaultextension=".txt")
if target:
Path(target).write_text(f"{words} words\n", encoding="utf-8")
Note that cancelling returns an empty string, not an exception. Handling the cancel case is the most commonly forgotten branch in GUI code, and it produces the classic "the app crashed when I pressed cancel".
The rule that keeps a GUI usable
import threading
import tkinter as tk
from tkinter import ttk
root = tk.Tk()
status = ttk.Label(root, text="Ready")
status.pack(padx=20, pady=20)
def slow_work():
"""Runs on a background thread, so the window keeps repainting."""
import time
time.sleep(3)
# Never touch widgets from another thread. Schedule it on the main one:
root.after(0, lambda: status.config(text="Done"))
def start():
status.config(text="Working...")
threading.Thread(target=slow_work, daemon=True).start()
ttk.Button(root, text="Start", command=start).pack(pady=(0, 20))
root.mainloop()
Anything slow on the main thread freezes the window. No repainting, no response to clicks, and after a few seconds the operating system helpfully offers to kill your application.
So: slow work on a thread, and results back to the main thread via root.after. tkinter is not thread-safe, and updating a widget from a background thread will corrupt it in ways that look like haunting rather than like a bug.
Other options
| Toolkit | Trade |
|---|---|
| tkinter | Built in, everywhere, a bit dated. Perfect for small tools |
| PySide6 / PyQt | Professional and vast. Qt licensing matters for PyQt; PySide6 is LGPL |
| Kivy | Touch and mobile, custom look |
| Flet | Flutter-based, modern-looking, quite new |
| A local web app | Honestly often the best answer: FastAPI plus a browser page (Lesson 44) |
This is Python's weakest spot, and Base Camp 4 said so. The tools are PyInstaller and Briefcase: they bundle the interpreter and your code into one executable. Expect a 30 to 80MB file, some antivirus false positives, and a genuinely fiddly first attempt. It works, but a compiled language earns its keep here.
A unit converter
Build a window with an entry, a dropdown of conversions, and a result label. Handle bad input without crashing.
Reveal solution
import tkinter as tk
from tkinter import ttk
CONVERSIONS = {
"Celsius to Fahrenheit": lambda c: c * 9 / 5 + 32,
"Fahrenheit to Celsius": lambda f: (f - 32) * 5 / 9,
"Kilometres to Miles": lambda km: km * 0.621371,
"Miles to Kilometres": lambda mi: mi / 0.621371,
}
root = tk.Tk()
root.title("Converter")
value = tk.StringVar()
choice = tk.StringVar(value=list(CONVERSIONS)[0])
result = tk.StringVar(value="-")
frame = ttk.Frame(root, padding=16)
frame.grid(sticky="nsew")
ttk.Entry(frame, textvariable=value, width=16).grid(row=0, column=0, padx=4)
ttk.Combobox(frame, textvariable=choice, values=list(CONVERSIONS),
state="readonly", width=24).grid(row=0, column=1, padx=4)
ttk.Label(frame, textvariable=result, font=("Helvetica", 16)).grid(
row=1, column=0, columnspan=2, pady=12)
def convert(*_):
try:
number = float(value.get())
except ValueError:
result.set("Enter a number" if value.get() else "-")
return
result.set(f"{CONVERSIONS[choice.get()](number):.2f}")
value.trace_add("write", convert) # convert as they type
choice.trace_add("write", convert)
root.mainloop()trace_add reacts to the variable changing, so the result updates live with no Convert button at all. Removing a button is usually better interface design than adding one.
Why did it freeze?
A user clicks Download and the window goes white and stops responding. What happened, and what are the two fixes?
Reveal solution
The download is running on the main thread, so mainloop() never gets a chance to process events, including 'repaint yourself'. The operating system sees an application that has not responded and greys it out.
Fix one: run the work on a background thread and send results back with root.after(0, ...), as in the example above. This is right for network and disk work.
Fix two: break the work into small chunks and schedule each with root.after(10, next_chunk), so the loop runs between pieces. This suits work you can naturally divide, and avoids threads entirely.
Either way, show progress. A frozen window and a slow window look identical to the user; a progress bar is the difference between 'broken' and 'working'.
CLI, GUI or web?
For each, which interface would you build, and why?
- A tool you run every morning to tidy your downloads.
- A tool for your non-technical colleague to rename photo batches.
- A dashboard the whole team needs to see.
- A step in an automated build pipeline.
Reveal solution
- CLI. You can schedule it (Lesson 41), and it needs no clicking.
- GUI. Asking a non-programmer to open a terminal is asking them not to use your tool. tkinter, one window, a folder picker and a big button.
- Web. No installation, works on any device, one deployment to update. Lesson 44.
- CLI, with proper exit codes (Lesson 27). A pipeline cannot click anything.
The underlying question is always 'who is holding the mouse, and where are they'. Choosing the interface before writing the logic saves rewriting the logic.