Building a Python TUI (Task Runner)¶
Step-by-step guide to building a Task Runner TUI in Python with Textual: task list, detail pane, footer, search/filter, create/edit/delete, JSON persistence, graceful quit, and file-based logging. For conceptual guidance (architecture, framework trade-offs, testing), see TUI Applications (Python).
Overview¶
You will build a terminal UI that manages a list of tasks with local JSON storage. The app uses Textual for the TUI, keeps state and update logic separate from the view, and logs to a file so the terminal output stays clean.
What you will build¶
- Task list pane (left): scrollable list of tasks with selection
- Detail pane (right): title, description, status for the selected task
- Footer: key bindings (j/k move, Enter edit, n new, d delete, / search, q quit)
- Search/filter: type to filter tasks by title/description
- Create / Edit / Delete with persistence to a JSON file
- Graceful quit with optional unsaved-changes handling
- Log file (e.g.
task_runner_tui.log) separate from the TUI - Basic tests for state and persistence logic
Prerequisites¶
- Python 3.11+
- uv (or pip) for dependency management
- A terminal that supports alternate screen and key events (e.g. iTerm2, Windows Terminal, standard Linux terminal)
Project bootstrap¶
Dependency installation¶
If you prefer pip:
Minimal pyproject.toml:
[project]
name = "task-runner-tui"
version = "0.1.0"
description = "A Textual TUI for managing tasks with JSON persistence"
requires-python = ">=3.11"
dependencies = ["textual>=0.47"]
[project.optional-dependencies]
dev = ["pytest", "pytest-asyncio"]
[project.scripts]
task-runner-tui = "task_runner_tui.main:run"
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
Project structure¶
task-runner-tui/
βββ task_runner_tui/
β βββ __init__.py
β βββ main.py # Entry point, App, key bindings
β βββ state.py # Task, AppState, filter logic
β βββ storage.py # load/save JSON
β βββ widgets.py # Task list, detail pane, footer (optional)
βββ tests/
β βββ __init__.py
β βββ test_state.py
β βββ test_storage.py
βββ pyproject.toml
βββ tasks.json # Created at runtime
Core state model¶
# task_runner_tui/state.py
from dataclasses import dataclass, field
from typing import Optional
@dataclass
class Task:
id: str
title: str
description: str
done: bool = False
def to_dict(self) -> dict:
return {"id": self.id, "title": self.title, "description": self.description, "done": self.done}
@classmethod
def from_dict(cls, d: dict) -> "Task":
return cls(
id=d["id"],
title=d.get("title", ""),
description=d.get("description", ""),
done=d.get("done", False),
)
@dataclass
class AppState:
tasks: list[Task] = field(default_factory=list)
selected_index: int = 0
filter_query: str = ""
_next_id: int = 0
def filtered_tasks(self) -> list[Task]:
if not self.filter_query:
return self.tasks
q = self.filter_query.lower()
return [t for t in self.tasks if q in t.title.lower() or q in t.description.lower()]
def selected_task(self) -> Optional[Task]:
filtered = self.filtered_tasks()
if not filtered or self.selected_index < 0 or self.selected_index >= len(filtered):
return None
return filtered[self.selected_index]
def new_id(self) -> str:
self._next_id += 1
return f"t{self._next_id}"
Event loop and message/update model¶
Textual uses reactive state. We keep AppState on the app and update it in message handlers. Events map to semantic actions (select next/prev, set filter, add/update/delete task, load/save).
# task_runner_tui/main.py (excerpt)
from textual.app import App, ComposeResult
from textual.binding import Binding
from textual.containers import Container, Horizontal, Vertical
from textual.widgets import Input, Static, ListView, ListItem, Label
from textual.message import Message
import json
import logging
from pathlib import Path
from .state import AppState, Task
from .storage import load_tasks, save_tasks
LOG_PATH = Path("task_runner_tui.log")
logging.basicConfig(level=logging.INFO, filename=str(LOG_PATH), filemode="a", format="%(asctime)s %(levelname)s %(message)s")
logger = logging.getLogger(__name__)
Weβll wire key bindings to actions that update AppState and refresh the list/detail widgets. No separate βupdateβ function is required if we keep state on the app and mutate it in handlers; for tests we can still test state.py and storage.py in isolation.
Rendering layout¶
- Left:
ListViewof filtered tasks (titles only). - Right:
Staticshowing selected task title, description, done. - Bottom:
Staticfooter with key hints. - Search:
Inputat top or in a layer; filter updatesAppState.filter_queryand list content.
Layout sketch with Textual:
# task_runner_tui/main.py
class TaskList(ListView):
def __init__(self, state: AppState, **kwargs):
super().__init__(**kwargs)
self._state = state
def on_mount(self) -> None:
self._refresh()
def _refresh(self) -> None:
self.clear()
for task in self._state.filtered_tasks():
self.append(ListItem(Label(task.title)))
def update_state(self, state: AppState) -> None:
self._state = state
self._refresh()
class DetailPane(Static):
def __init__(self, state: AppState, **kwargs):
super().__init__(**kwargs)
self._state = state
def _refresh(self) -> None:
task = self._state.selected_task()
if task:
self.update(f"[bold]{task.title}[/bold]\n\n{task.description}\n\nDone: {task.done}")
else:
self.update("No task selected")
def update_state(self, state: AppState) -> None:
self._state = state
self._refresh()
class TaskRunnerApp(App):
CSS = """
Horizontal { height: 1fr; }
#task-list { width: 30%; }
#detail { width: 70%; padding: 1 2; }
#footer { height: 1; dock: bottom; }
"""
BINDINGS = [
Binding("q", "quit", "Quit"),
Binding("j", "cursor_down", "Down", show=True),
Binding("k", "cursor_up", "Up", show=True),
Binding("n", "new_task", "New", show=True),
Binding("d", "delete_task", "Delete", show=True),
Binding("/", "focus_search", "Search", show=True),
]
def __init__(self, data_path: Path):
super().__init__()
self.data_path = data_path
self.state = AppState()
def on_mount(self) -> None:
try:
self.state.tasks = load_tasks(self.data_path)
self.state._next_id = max((int(t.id.lstrip("t")) for t in self.state.tasks if t.id.startswith("t")), default=0) + 1
except Exception as e:
logger.exception("Load failed")
self.notify(f"Load failed: {e}", severity="error")
self._refresh_ui()
def _refresh_ui(self) -> None:
tl = self.query_one("#task-list", TaskList)
tl.update_state(self.state)
dp = self.query_one("#detail", DetailPane)
dp.update_state(self.state)
if self.state.filtered_tasks():
idx = min(self.state.selected_index, len(self.state.filtered_tasks()) - 1)
self.state.selected_index = max(0, idx)
tl.index = self.state.selected_index
def compose(self) -> ComposeResult:
with Horizontal():
with Vertical(id="left-pane"):
yield Input(placeholder="Filter...", id="search-input")
yield TaskList(self.state, id="task-list")
yield DetailPane(self.state, id="detail")
yield Static("j/k: move | Enter: edit | n: new | d: delete | /: search | q: quit", id="footer")
def on_input_changed(self, event: Input.Changed) -> None:
if event.input.id == "search-input":
self.state.filter_query = event.value
self.state.selected_index = 0
self._refresh_ui()
def action_cursor_down(self) -> None:
tasks = self.state.filtered_tasks()
if tasks and self.state.selected_index < len(tasks) - 1:
self.state.selected_index += 1
self._refresh_ui()
def action_cursor_up(self) -> None:
if self.state.selected_index > 0:
self.state.selected_index -= 1
self._refresh_ui()
def action_new_task(self) -> None:
task = Task(id=self.state.new_id(), title="New task", description="", done=False)
self.state.tasks.append(task)
self.state.selected_index = len(self.state.filtered_tasks()) - 1
self._refresh_ui()
self._save()
def action_delete_task(self) -> None:
task = self.state.selected_task()
if not task:
return
self.state.tasks = [t for t in self.state.tasks if t.id != task.id]
self.state.selected_index = min(self.state.selected_index, len(self.state.filtered_tasks()) - 1) if self.state.filtered_tasks() else 0
self._refresh_ui()
self._save()
def action_focus_search(self) -> None:
self.query_one("#search-input", Input).focus()
def _save(self) -> None:
try:
save_tasks(self.data_path, self.state.tasks)
logger.info("Saved %s tasks", len(self.state.tasks))
except Exception as e:
logger.exception("Save failed")
self.notify(f"Save failed: {e}", severity="error")
def action_quit(self) -> None:
self._save()
self.exit()
Add an Input for search (e.g. in a layer or at top) and wire its Submitted/Changed to set self.state.filter_query and call _refresh_ui(). Omitted here for brevity; you can add <Input id="search-input" placeholder="Filter..."> and in on_input_submitted set self.state.filter_query = value then _refresh_ui().
Keyboard bindings¶
- j / k: move selection down/up in the task list
- Enter: (optional) edit selected task (modal or inline)
- n: new task
- d: delete selected task
- /: focus search input
- q: save and quit
Bindings are declared in BINDINGS and implemented as action_* methods.
Persistence layer¶
# task_runner_tui/storage.py
import json
from pathlib import Path
from .state import Task
def load_tasks(path: Path) -> list[Task]:
if not path.exists():
return []
with open(path, "r", encoding="utf-8") as f:
data = json.load(f)
return [Task.from_dict(item) for item in data.get("tasks", [])]
def save_tasks(path: Path, tasks: list[Task]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
with open(path, "w", encoding="utf-8") as f:
json.dump({"tasks": [t.to_dict() for t in tasks]}, f, indent=2)
Logging¶
Logging is configured in main.py to a file (task_runner_tui.log). Do not print() or log to stdout during the TUI run so the display is not corrupted. Use logger.info(...) for save/load and errors.
Running the application¶
Ensure main.py has a run() that starts the app with a data path:
# task_runner_tui/main.py (bottom)
def run() -> None:
data_path = Path.home() / ".task_runner_tui" / "tasks.json"
data_path.parent.mkdir(parents=True, exist_ok=True)
app = TaskRunnerApp(data_path)
app.run()
if __name__ == "__main__":
run()
Verification checklist¶
- Task list shows on the left; detail on the right; footer at bottom.
- j/k move selection; detail updates.
- n creates a new task and persists after save.
- d deletes the selected task and persists.
- Search/filter narrows the list when implemented.
- q saves and exits; terminal is restored.
-
task_runner_tui.logexists and contains log lines; TUI output is clean. -
pytest tests/passes fortest_stateandtest_storage.
Troubleshooting¶
- List or detail not updating: Ensure
_refresh_ui()is called after every state change and that widgets are queried by correct id. - Keys not working: Check BINDINGS and that no child widget is capturing keys; use
can_focusor focus management. - Load/save errors: Check file permissions and path; log exceptions and show a notification.
- Layout broken on resize: Textual handles resize; ensure you use relative widths (e.g. 30% / 70%) and not fixed character sizes.
Extensions / next steps¶
- Add a modal or screen for editing task title/description (e.g.
Inputin a modal). - Add SQLite instead of JSON for larger datasets.
- Add done/toggle and filter by status.
- Add basic tests for list/detail rendering by testing state and storage only; add integration test with Textualβs headless run if needed.
TL;DR summary¶
- Bootstrap with
uv init, addtextual. - Define
TaskandAppStateinstate.py;load_tasks/save_tasksinstorage.py. - Build the app in
main.py:TaskRunnerAppwithTaskList,DetailPane, footer; bind j/k, n, d, /, q. - Log to a file; never stdout. Quit saves and exits.
- Run with
uv run python -m task_runner_tui.mainortask-runner-tui; verify with pytest for state and storage.
For patterns, framework trade-offs, and production guidance, see TUI Applications (Python).