Tech With Galvan
Back to Articles
Development10/13/2025 • 4 min read

To-Do Desktop App using Python and Tkinter

Build a native desktop to-do app with Python and Tkinter — add, complete, and delete tasks with persistent JSON storage, no browser needed.

Galvan
Galvan

Founder & Creator

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:

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

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.