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-tkon 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 (aspersist()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/.appwith 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.