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
Prerequisites
sudo apt install python3-tk on Ubuntu).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.pyA 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
sudo apt install python3-tk. On Windows/macOS it's bundled with python.org installers.pop; re-rendering (as persist() does) clears stale selection state.root.mainloop(); without it the window opens and immediately does nothing.("Segoe UI", 12) or ("Helvetica", 12).Key Concepts
What to Try Next
'<Double-Button-1>' on the Listbox..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.