Level 5 · In the Wild

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()
WidgetFor
LabelText you display
EntryOne line of typed input
TextMulti-line editing
ButtonDoing something
Checkbutton / RadiobuttonChoices
ComboboxA dropdown
Listbox / TreeviewLists and tables
FrameGrouping, and the key to sane layout
CanvasDrawing anything you like

Layout: pick one and stick to it

ManagerIdeaUse for
.pack()Stack things in a directionSimple vertical or horizontal layouts
.grid()Rows and columnsForms. Almost always the right choice
.place()Exact pixel positionsAlmost never
🪤 Never mix pack and grid in the same container

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()
PARANOIA[Formidable: Success]

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

ToolkitTrade
tkinterBuilt in, everywhere, a bit dated. Perfect for small tools
PySide6 / PyQtProfessional and vast. Qt licensing matters for PyQt; PySide6 is LGPL
KivyTouch and mobile, custom look
FletFlutter-based, modern-looking, quite new
A local web appHonestly often the best answer: FastAPI plus a browser page (Lesson 44)
📦 Shipping a desktop app to someone who has no Python

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.

Exercise 1

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.

Exercise 2

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'.

Exercise 3

CLI, GUI or web?

For each, which interface would you build, and why?

  1. A tool you run every morning to tidy your downloads.
  2. A tool for your non-technical colleague to rename photo batches.
  3. A dashboard the whole team needs to see.
  4. A step in an automated build pipeline.
Reveal solution
  1. CLI. You can schedule it (Lesson 41), and it needs no clicking.
  2. 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.
  3. Web. No installation, works on any device, one deployment to update. Lesson 44.
  4. 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.

+100 XP