Courses/Python Mastery/Module 9: Object-Oriented Python
Module 9 ยท Lesson 730 minBeginner

๐Ÿงช Lab: Tkinter To-Do (Class-Based)

What you'll build
Apply OOP: a real desktop app organized as a class with methods and state.

Introduction

Tkinter ships inside Python โ€” zero installs, zero browsers, zero JavaScript. This desktop to-do app opens as a real native window with a list, an entry box, and buttons, storing tasks in JSON between runs. It is the native twin of the Streamlit to-do list: same features, same storage, but the event loop belongs to your window instead of a web page.

If you built the GUI calculator, this adds list management โ€” the Listbox widget, selection handling, and state that persists across app launches.

Features

  • Native window โ€” runs anywhere Python runs, no dependencies.
  • Add / complete / delete โ€” full task lifecycle with keyboard support (Enter to add).
  • Persistent storage โ€” tasks survive app restarts via JSON.
  • Completed styling โ€” done tasks get a โœ“ prefix.
  • Task counter โ€” live count of open tasks in the title bar.
  • Prerequisites

  • Python 3.8+ with Tkinter (bundled on Windows/macOS installers; sudo apt install python3-tk on Ubuntu).
  • That's genuinely all.
  • Step 1: Create the Script

    Save as todo_desktop.py:

    code
    import tkinter as tk
    from tkinter import messagebox
    import json
    import os
    
    TASKS_FILE = "desktop_tasks.json"
    
    
    def load_tasks():
        if os.path.exists(TASKS_FILE):
            with open(TASKS_FILE) as f:
                return json.load(f)
        return []
    
    
    def save_tasks(tasks):
        with open(TASKS_FILE, "w") as f:
            json.dump(tasks, f, indent=2)
    
    
    class TodoApp:
        def __init__(self, root):
            self.root = root
            self.root.title("To-Do List")
            self.root.geometry("420x460")
            self.tasks = load_tasks()
    
            self.entry = tk.Entry(root, font=("Arial", 13))
            self.entry.pack(fill="x", padx=12, pady=(12, 4))
            self.entry.bind("<Return>", lambda e: self.add_task())
    
            btn_frame = tk.Frame(root)
            btn_frame.pack(fill="x", padx=12)
            tk.Button(btn_frame, text="Add", command=self.add_task).pack(side="left", expand=True, fill="x", padx=2)
            tk.Button(btn_frame, text="Toggle done", command=self.toggle_task).pack(side="left", expand=True, fill="x", padx=2)
            tk.Button(btn_frame, text="Delete", command=self.delete_task).pack(side="left", expand=True, fill="x", padx=2)
    
            self.listbox = tk.Listbox(root, font=("Arial", 12), selectmode="single", activestyle="none")
            self.listbox.pack(fill="both", expand=True, padx=12, pady=8)
    
            self.render()
    
        def render(self):
            self.listbox.delete(0, tk.END)
            for task in self.tasks:
                prefix = "โœ“ " if task["done"] else "   "
                self.listbox.insert(tk.END, prefix + task["text"])
                if task["done"]:
                    self.listbox.itemconfig(tk.END, fg="#888888")
            open_count = sum(1 for t in self.tasks if not t["done"])
            self.root.title(f"To-Do List โ€” {open_count} open")
    
        def add_task(self):
            text = self.entry.get().strip()
            if text:
                self.tasks.append({"text": text, "done": False})
                self.entry.delete(0, tk.END)
                self.persist()
    
        def selected_index(self):
            sel = self.listbox.curselection()
            return sel[0] if sel else None
    
        def toggle_task(self):
            i = self.selected_index()
            if i is not None:
                self.tasks[i]["done"] = not self.tasks[i]["done"]
                self.persist()
    
        def delete_task(self):
            i = self.selected_index()
            if i is not None:
                if messagebox.askyesno("Delete", "Delete this task?"):
                    self.tasks.pop(i)
                    self.persist()
    
        def persist(self):
            save_tasks(self.tasks)
            self.render()
    
    
    if __name__ == "__main__":
        root = tk.Tk()
        TodoApp(root)
        root.mainloop()

    Step 2: Run the App

    code
    python todo_desktop.py

    A native window opens. Type a task, press Enter, toggle it done, delete another โ€” close and reopen the app to confirm persistence.

    How It Works

    Tkinter apps are event-driven: mainloop() waits for events (clicks, keypresses), and your methods run in response. There is no rerun model to fight โ€” state changes only when an event fires, which makes the mental model simpler than web frameworks in one way: what you see is always exactly the list contents.

    The render-on-change pattern is the discipline: every mutation calls persist(), which saves and re-renders the Listbox from scratch. Deleting and re-inserting every row sounds wasteful; for a list this small it is simpler and less bug-prone than surgical index juggling โ€” the desktop equivalent of the Streamlit rerun philosophy.

    Selection handling centers on curselection(), which returns the highlighted row's index โ€” or nothing. The selected_index() helper converts that to a clean None check that every action method shares.

    The JSON storage layer is identical to the Streamlit version โ€” same file format, same load/save functions โ€” which is the point: the storage layer is UI-agnostic and you just ported it across frameworks unchanged.

    Common Errors & Fixes

  • `No module named tkinter` โ€” Linux splits it out; sudo apt install python3-tk. On Windows/macOS it's bundled with python.org installers.
  • Listbox selection persists after delete โ€” indices shift after pop; re-rendering (as persist() does) clears stale selection state.
  • Window is blank โ€” you forgot root.mainloop(); without it the window opens and immediately does nothing.
  • โœ“ shows as a box โ€” some fonts lack the glyph; switch the Listbox font to ("Segoe UI", 12) or ("Helvetica", 12).
  • Key Concepts

  • Event loop โ€” code runs in response to events, not top-to-bottom.
  • Render-on-change โ€” one render function called after every mutation.
  • `curselection()` โ€” the bridge between UI selection and data indices.
  • UI-agnostic storage โ€” the same JSON layer serves web and desktop.
  • What to Try Next

  • Add due dates with a calendar dialog and sort the list by urgency.
  • Add double-click to toggle by binding '<Double-Button-1>' on the Listbox.
  • Add drag reordering with a third-party listbox, or up/down buttons like the PDF merger UI.
  • Package it as an .exe/.app with PyInstaller so non-programmers can use it.
  • FAQ

    Is Tkinter outdated?

    It's dated visually but actively maintained, dependency-free, and perfect for utilities. For polished commercial UIs, look at PySide6 โ€” but you'd rewrite this app with the exact same storage layer.

    Why a class instead of plain functions?

    The class groups state (self.tasks) with the methods that change it โ€” at this size either style works; classes scale better as the app grows.

    Can multiple app instances corrupt the JSON?

    Yes โ€” last-writer-wins, same as the web version. For single-user desktop tools this is a non-issue in practice.

    Adapted from: To-Do Desktop App using Python and Tkinter

    Checkpoint
    The desktop app works, and you can trace every method to its button.
    What you learned
    • โœ“Class-based app structure
    • โœ“self in practice
    • โœ“Methods as event handlers