TUI Applications (Python)¶
Production-minded guidance for building terminal user interfaces in Python: when to choose a TUI, framework trade-offs, architecture, and operational concerns. For a step-by-step implementation, see the Building a Python TUI tutorial.
When a TUI is the right tool¶
- SSH or headless environments where a GUI is unavailable and a web UI is overkill.
- Operator tools (runbooks, dashboards, log tailers) that need to stay in the terminal.
- CLI-adjacent workflows where you want lists, forms, and navigation without leaving the shell.
- Low-friction distribution: single binary or
pip install; no browser or display server.
Avoid TUIs when: the workflow is primarily forms and long text (consider a local web app), or when non-technical users need maximum discoverability (consider a GUI or web UI).
Framework selection and trade-offs¶
| Framework | Strengths | Weaknesses | Use when |
|---|---|---|---|
| Textual | React-like composition, CSS, async, strong docs | Heavier dependency, opinionated | New apps; rich layouts; preferred default |
| prompt_toolkit | Flexible, mature, good for REPLs/forms | More manual layout; you build widgets | Custom input UX; REPL/CLI hybrids |
| curses | Stdlib, no deps, full control | Raw API; no widgets; cross-platform quirks | Minimal deps; embedded/ship-with-Python |
| blessed | Nicer curses wrapper, key handling | Still low-level; you own layout | Need curses with cleaner API |
Recommendation: Default to Textual for new applications. Use prompt_toolkit when you need custom prompts or REPL-style flows. Use curses or blessed only when dependency count or stdlib-only is a hard constraint.
Core architecture¶
- Separate state, update, and render. Keep a single source of truth (state object), a function that applies messages/events to state (update), and a pure view that maps state to what the framework draws (render).
- Prefer message-driven flow. User input and internal events produce messages; the update function handles them and returns new state (or side effects). Avoid tangled callbacks that mutate global state.
- Keep the TUI layer thin. Business logic (load/save tasks, validation, filtering) should live in plain Python modules. The TUI only dispatches actions and displays state.
State management and event loop design¶
- Centralized state: One model (e.g.
AppStatewithtasks,selected_id,filter_query,mode) that the whole UI reads from. - Events as messages: Map keypresses and UI events to semantic messages (
SelectNext,FilterChanged,SaveTask,Quit). The event loop or framework calls your update logic with (state, message) and you return new state or commands. - Async where it helps: Textual is async; use it for I/O (file save, network) so the UI stays responsive. Donβt block the event loop with synchronous long-running work.
Rendering strategy and component boundaries¶
- Compose with widgets/containers. Split the screen into regions (task list, detail pane, footer). Each region is a component that receives state (or a slice) and renders. Avoid one giant render function.
- Minimize full redraws. Use the frameworkβs update/diff so only changed parts re-render. Donβt rebuild the entire tree on every keystroke unless the framework expects it.
Input handling and keyboard UX¶
- Consistent bindings: Use one scheme (e.g.
j/kor arrows for list,Enterto select,qto quit) and document it in a footer or help. - Reserve a βquitβ key (e.g.
qorCtrl+C) and confirm on unsaved changes if needed. - Avoid overloaded keys without a mode or modifier; prefer a help screen over memorizing many chords.
Terminal constraints and portability¶
- Unicode width: Use a library that accounts for full-width characters (e.g. CJK) when measuring string width; otherwise layout and alignment break.
- Resize: Handle
SIGWINCH/ resize events: recompute layout and re-render. Most frameworks do this; ensure your layout doesnβt assume a fixed size. - Capabilities: Prefer a framework that abstracts alternate screen and key sequences (Textual, prompt_toolkit). If using raw curses, test on Linux, macOS, and Windows (e.g. with Windows Terminal).
Error handling and crash-safe cleanup¶
- Restore terminal on exit. Use
atexitor framework lifecycle to always switch back to the main screen and show cursor, even on exception or SIGINT. - Donβt crash on I/O errors. Catch load/save errors, show a message in the UI or log, and leave state consistent (e.g. keep in-memory state, mark βlast save failedβ).
- Validate before persist. Validate task content and IDs before writing to disk; on failure, show error and keep editing state.
Logging and observability¶
- Log to a file (or stderr), not to the TUI. Use the standard
loggingmodule; point handlers to a file orsys.stderrso that logs donβt corrupt the screen and are available after exit. - Structured logs. Log key actions (load, save, filter, errors) with context (e.g. task id) for debugging. Keep log level at INFO in production, DEBUG only when needed.
Testing strategy¶
- Unit-test state and update logic. With a message-driven design, test that for given (state, message) you get the expected new state or side effects. No TUI framework required.
- Snapshot or contract tests for render. If the framework allows, test that the view produces expected structure or text for a given state; or test small render helpers that return strings/widgets.
- Integration tests. Run the app headless if the framework supports it; send key sequences and assert on output or exit code. Use sparingly; prefer unit tests for business logic.
Packaging and distribution¶
- pyproject.toml + uv/pip: Standard. Declare dependencies (e.g.
textual); useuv buildorpip wheelfor distribution. - Console script entry point: Expose the TUI as a
[project.scripts]entry point so users runtask-runner-tuiafter install. - Optional static binary: For single-file delivery, consider PyInstaller or similar; test on target platforms. Often
pip installor a small launcher script is enough.
Performance concerns¶
- Avoid heavy work on the main loop. Offload file I/O and parsing to a thread or async task; post results back as messages.
- Limit list size in view. For large task lists, show a window (e.g. 100 items) and virtualize or paginate; donβt render thousands of widgets.
- Debounce search/filter. If filtering is expensive, debounce input (e.g. 100β200 ms) before updating state and re-rendering.
Accessibility and ergonomics¶
- Keyboard-first. All critical actions should have key bindings; avoid βmouse onlyβ assumptions.
- Contrast and readability. Use default or high-contrast themes; avoid low-contrast color combinations.
- Screen reader considerations. TUIs are largely text-based; avoid relying only on color or position to convey meaning when possible.
Anti-patterns¶
- Putting business logic inside UI callbacks β move it to modules and call from update/handlers.
- Writing logs or print to stdout during TUI run β use a file or stderr so the display isnβt corrupted.
- Ignoring resize β layout must adapt to terminal size.
- Blocking the event loop with sync I/O β use async or a worker thread and post results.
- Hardcoding dimensions β use terminal size or framework layout, not fixed 80Γ24.
- No clear quit path β always provide a documented, safe exit.
TL;DR runbook¶
- Choose Textual for new Python TUIs unless you need prompt_toolkit or stdlib-only.
- Single state, message-driven update, pure render; keep logic out of the UI layer.
- Log to file/stderr, never to the TUI; restore terminal on exit.
- Handle resize and Unicode width; test on multiple terminals.
- Unit-test state/update; optionally snapshot or integration-test render.
- Package with pyproject.toml and a console script entry point; document key bindings in the UI.
For a runnable Task Runner TUI implementation in Python, see the Building a Python TUI tutorial.