Python TUIs with Textual, Rich & prompt_toolkit
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production; see the section bibliography for the reference material consulted while preparing these pages. |
Python has three well-maintained libraries for the part of a terminal program that is more than print, and they sit
at different heights of the same stack. Rich styles and lays out output: it never reads a key. prompt_toolkit
reads lines and, if asked, runs a full-screen layout of its own. Textual is a complete application framework with a
widget tree, a stylesheet language, a message queue and a test harness, and it uses Rich underneath for rendering.
Below all of them is the standard curses module, a thin binding of the C library that is available on Unix only. This
page explains what each one is for, how it behaves on Windows, macOS and Linux, and builds the Bookshelf catalogue
browser (shelf tui) in Textual, with the prompt-based shelf repl in prompt_toolkit.
The page was written against Textual 8.2.8, Rich 15.0.0, prompt_toolkit 3.0.53, windows-curses 2.4.2, pytest-textual-snapshot 1.1.0 and Python 3.15.1, as listed on PyPI and in the projects' documentation on 2026-10-25. The machine used to write it has only Python 3.9 and none of these libraries installed, so the Python examples were not run; their syntax was checked with the Python 3.9 byte-compiler, and every class, method and keyword argument in them was checked against the official documentation cited after each example. Treat them as starting points and run them before relying on them. Terminals, ttys and pseudo-terminals are explained on Terminals, TTYs & Pseudo-Terminals and escape sequences on Escape Sequences & Terminal Capabilities; the vocabulary of immediate and retained mode, render pipelines and event loops is on TUI Architecture & Concepts. The Python language itself, argparse and packaging are covered by the Python standard library tour and virtual environments and packaging; Typer, Click and argparse for command-line parsing are on CLI Frameworks in Python. The rule that a TUI should be a view over operations that also work without a terminal is on TUIs vs. CLIs and is followed throughout this page.
Choosing a layer
| Library | What it does | Reads input? | Platforms |
|---|---|---|---|
Rich |
Styled text, tables, trees, panels, progress bars, live-updating displays, logging and traceback formatting |
Only a simple |
Linux, macOS and Windows |
prompt_toolkit |
Line editing with history, completion and validation; also a full-screen application toolkit with its own layout and key-binding system |
Yes |
Linux, macOS and Windows |
Textual |
Application framework: widgets, CSS-like styling, reactive attributes, messages, screens, workers, command palette, test harness, web serving |
Yes |
Linux, macOS and Windows; inline mode is not supported on Windows |
|
Character-cell windows, pads, colour pairs and key decoding over ncurses |
Yes |
Unix only in CPython; on Windows through the third-party |
A practical rule: use Rich when the program prints and exits, prompt_toolkit when it asks questions line by line,
Textual when it owns the screen until the user quits, and curses only when a dependency-free, standard-library
program is required and Windows support is not.
Rich
Rich renders to a Console. The console knows whether it is attached to a terminal, how many colours that terminal
supports and how wide it is, and strips or keeps control codes accordingly.
python -m pip install rich
Console, markup and terminal detection
from rich.console import Console
console = Console() # writes to stdout
err = Console(stderr=True) # writes to stderr
console.print("[bold green]shelf[/] is ready")
console.log("catalogue loaded") # adds a time column and the calling location
console.rule("Loans")
if console.is_terminal: # True when attached to a terminal
console.print("interactive session")
with console.status("Reading catalogue..."): # spinner around a block of work
pass
Source: Rich — The Console API
The constructor parameters that matter for a command-line tool are summarised below, as documented on the console page.
| Setting | Effect |
|---|---|
|
Write to standard error. Diagnostics and progress belong there so that standard output stays pipeable. |
|
Keep control codes even when the output is not a terminal. |
|
Auto-detect the colour system, or disable colour entirely. |
|
Fixed width, useful when writing to files. |
|
Keep a copy of everything printed so that |
|
Send output to any file object; the usual way to capture output in tests. |
Rich also reads environment variables. NO_COLOR removes colour but keeps bold and underline; FORCE_COLOR enables
colour, with NO_COLOR taking precedence over it; TERM set to dumb or unknown disables colour, styles and
cursor movement; and COLUMNS and LINES set the size when no width or height is passed. These conventions, and
why a tool should honour them, are explained on
TTY, Colour & Interactivity. The Rich documentation does not
describe a no_color constructor argument, so use the environment variable.
Square brackets in a string are markup. [bold red]text[/] styles text, [link=URL]text[/link] creates a hyperlink in
terminals that support them, and :warning: is an emoji code. Mismatched tags raise MarkupError. A backslash before
a tag shows it literally, and rich.markup.escape() escapes dynamic text. markup=False, passed to print or to
Console, turns markup off.
Tables, trees and the shelf listing
shelf list prints the catalogue as a table when its output is a person’s terminal and as tab-separated lines when
it is not, which is the pattern that keeps a command scriptable.
import sys
from rich import box
from rich.console import Console
from rich.markup import escape
from rich.table import Table
from rich.tree import Tree
BOOKS = [
("978-0-13-468599-1", "The Pragmatic Programmer", "Hunt and Thomas", 2019, None),
("978-0-262-03384-8", "Introduction to Algorithms", "Cormen et al.", 2009, "ana"),
("978-1-4919-5017-4", "Fluent Python", "Ramalho", 2022, None),
]
def list_books(console: Console) -> None:
if not console.is_terminal:
for isbn, title, author, year, borrower in BOOKS:
print(isbn, title, author, year, borrower or "", sep="\t")
return
table = Table(title="Bookshelf", box=box.MINIMAL_DOUBLE_HEAD, row_styles=["dim", ""])
table.add_column("ISBN", no_wrap=True)
table.add_column("Title", style="magenta")
table.add_column("Author")
table.add_column("Year", justify="right", style="cyan")
table.add_column("Status")
for isbn, title, author, year, borrower in BOOKS:
status = f"[yellow]on loan to {escape(borrower)}[/]" if borrower else "[green]available[/]"
table.add_row(isbn, escape(title), escape(author), str(year), status)
console.print(table)
def list_by_author(console: Console) -> None:
tree = Tree("Bookshelf")
for _, title, author, _, _ in BOOKS:
tree.add(escape(author)).add(escape(title))
console.print(tree)
if __name__ == "__main__":
console = Console()
list_books(console)
if len(sys.argv) > 1 and sys.argv[1] == "--by-author":
list_by_author(console)
Source: Rich — Tables, Rich — Tree and Rich — Console Markup
Table takes title, show_header, box and row_styles; add_column takes justify, style and no_wrap;
add_row accepts text or any renderable and end_section=True to force a line after the row. Tree(label) creates
the root, and add() returns the new branch so that branches can be chained. escape() is used above because titles
and borrower names are data: a book titled [red] would otherwise be parsed as a style tag.
Live displays and progress
Live keeps a renderable on screen and redraws it; Progress is a Live specialised for task bars.
import time
from rich.console import Console
from rich.live import Live
from rich.progress import (
BarColumn,
MofNCompleteColumn,
Progress,
SpinnerColumn,
TextColumn,
TimeRemainingColumn,
)
from rich.table import Table
console = Console(stderr=True) # progress goes to stderr, results to stdout
def import_catalogue(paths: list[str]) -> None:
with Progress(
SpinnerColumn(),
TextColumn("{task.description}"),
BarColumn(),
MofNCompleteColumn(),
TimeRemainingColumn(),
console=console,
transient=True, # remove the bar when the import is finished
) as progress:
task = progress.add_task("Importing", total=len(paths))
for path in paths:
time.sleep(0.2) # parse one file here
progress.console.print(f"imported {path}") # printed above the bar
progress.advance(task)
def watch_loans() -> None:
table = Table("ISBN", "Borrower")
with Live(table, refresh_per_second=4, transient=True, console=console) as live:
for isbn, borrower in [("978-0-13-468599-1", "ana"), ("978-1-4919-5017-4", "bo")]:
time.sleep(0.4)
table.add_row(isbn, borrower)
live.update(table)
Source: Rich — Live Display and Rich — Progress Display
Live options are refresh_per_second (default 4), auto_refresh (set to False to call refresh() yourself),
screen=True to use the alternate screen, transient=True to remove the display on exit, and vertical_overflow
(crop, ellipsis as default, or visible, in which mode the display cannot be properly cleared). Printing
through live.console, or progress.console for Progress, shows output above the live area instead of corrupting it.
Progress refreshes ten times a second by default, add_task with start=False or total=None gives a pulsing bar for
work of unknown length, and the built-in columns include BarColumn, TextColumn, TimeElapsedColumn,
TimeRemainingColumn, MofNCompleteColumn and SpinnerColumn. When the output is redirected, animated redraws are
noise; build the Console with the check shown earlier and print plain lines instead, as discussed on
Progress, Colour & Rich Output. The Rich documentation does not
describe a disable option on Progress, so do the check yourself.
Logging and tracebacks
import logging
from rich.logging import RichHandler
from rich.traceback import install
install(show_locals=True) # every uncaught exception is rendered by Rich
logging.basicConfig(
level="INFO",
format="%(message)s",
datefmt="[%X]",
handlers=[RichHandler(rich_tracebacks=True, markup=False)],
)
log = logging.getLogger("shelf")
log.info("catalogue loaded")
Source: Rich — Logging Handler and Rich — Traceback
show_locals=True prints the local variables of every frame, which can expose tokens and personal data in a shared
log; leave it off in released tools. The logging page notes that markup is off in RichHandler unless enabled, so
leave it off for messages built from user data. RichHandler writes to the console it is given, which defaults to
standard output: pass console=Console(stderr=True) when the program’s stdout is data.
Textual
Textual runs a Python application in the terminal, in a web browser through textual serve, or in the test harness.
python -m pip install textual textual-dev
python -m pip install pytest pytest-asyncio pytest-textual-snapshot
textual-dev provides the textual command (development console, run --dev, serve, keys). The installation page
states that Textual runs on Linux, macOS and Windows, that the macOS default Terminal supports only 256 colours (iTerm2,
Ghostty, Kitty and WezTerm are suggested instead) and that Windows Terminal is recommended on Windows.
Source: Textual — Getting Started and Textual — Devtools
The application model
A Textual program is an App subclass. compose() returns an iterable of widgets, usually by yielding them, and
containers are used as context managers to nest them. run() enters application mode and returns when exit() is
called; the value passed to exit() is the return value of run(), and App[str] declares its type. Textual does not
exit the process, so a tool should end with sys.exit(app.return_code or 0). Everything printed to the terminal before
run() is restored when the application exits.
The widgets form a tree, the DOM. The App is the root, each App has a stack of Screen objects, and each screen
contains containers and widgets. query_one() and query() search this tree with CSS selectors or widget types.
query_one("#send", Button) raises NoMatches when nothing matches and WrongType when the match has the wrong
type, and it searches only the descendants of the widget it is called on, so use self.screen.query_one instead to search
the whole screen.
Textual CSS
Styling lives in a stylesheet, either a .tcss file named by CSS_PATH (a relative path resolves from the file that
defines the app) or a string in the CSS class variable.
/* shelf.tcss */
Screen { layout: vertical; }
#search { dock: top; margin: 0 1; }
#main { height: 1fr; }
#catalogue { width: 2fr; }
#side { width: 1fr; }
#details { height: 1fr; border: round $primary; padding: 0 1; }
#loans { height: 1fr; }
LoanDialog { align: center middle; }
#dialog {
width: 50;
height: auto;
padding: 1 2;
border: thick $primary;
background: $surface;
}
Source: Textual — CSS and Textual — Layout
The CSS guide documents these building blocks.
| Feature | Behaviour |
|---|---|
Selectors |
Type ( |
Pseudo-classes |
Set by Textual from widget state: |
Specificity |
Compared by ID count, then class count, then type count; |
Units |
|
Layouts |
|
Docking |
|
Variables |
|
Nesting |
Nested rules inherit the outer selector, and |
Hot reload |
|
A horizontal layout does not fill its parent’s height by default: set height: 100% or 1fr explicitly. The Layout guide
shows Horizontal and Vertical containers from textual.containers for the common cases.
Reactive attributes
A reactive class attribute turns a plain attribute into one that refreshes the widget when it changes.
| Feature | Behaviour |
|---|---|
|
Assigning a new value triggers a refresh. A callable default, such as |
|
The same machinery without an automatic refresh or relayout. |
|
Called when the value changes, with the new value or with the old and new values (two parameters). Set |
|
Receives the incoming value and returns the value to store, for clamping. |
|
Cached derived value, recalculated when another reactive changes. Keep it fast. The order is compute, then validate, then watch. |
|
Removes the children and calls |
|
Binds a child’s reactive to the parent’s, one way only: |
Source: Textual — Reactivity
Events, messages and bubbling
Everything that happens in a Textual application is a message put on a widget’s queue: key presses, mouse clicks,
mounts, timers, and the messages widgets post to say that something happened (Button.Pressed, Input.Changed,
DataTable.RowSelected). A handler is a method whose name is on_, then the message’s namespace converted from
CamelCase to snake_case, then an underscore, then the message class name in snake_case. The namespace is the name of
the widget class the message is defined inside, so Input.Changed is handled by on_input_changed and
DataTable.RowSelected by on_data_table_row_selected. The @on decorator does the same thing without the naming rule
and can add a CSS selector: @on with Button.Pressed and the selector #bell calls the method only for the button with that ID.
Messages have a bubble attribute. When it is true, the message is sent to the widget’s parent after the widget has
handled it, and so on up to the App, which is the root of the DOM and has nowhere further to send it. A handler stops
the journey by calling event.stop(), which is what a text input does with a key it has consumed so that the key does
not also trigger a binding. prevent_default() is different: it stops handlers on base classes of the same widget from
running, and the documentation warns against it unless you know what the base classes do. A widget defines its own
messages as nested classes, posts them with post_message(), and may handle its own message and let it bubble so that
subclasses and parents can react too.
Message names such as Catalogue.BookChosen are handled by on_catalogue_book_chosen: the namespace is the outer
class, converted to snake_case. The Message class documents stop(), prevent_default(), control (the widget the
message is about, for the @on selector) and handler_name (the name of the default handler).
Source: Textual — Events and Messages and Textual — Message
Key bindings and actions
BINDINGS on an app, screen or widget maps keys to actions. An entry is a tuple of key, action and description, or a
Binding object when more options are needed. priority=True makes a binding win over the focused widget’s bindings,
which is how the default Ctrl+Q quit binding works, and show=False hides it from the footer. Textual checks the
focused widget first and then walks up the DOM to the app. An action string such as add_bar('red') calls the method
action_add_bar with the argument. Several keys can share an action with a comma (for example r,t as the key string). Bindings cannot be
changed at runtime; the Actions guide describes dynamic actions for conditional behaviour. For low-level handling,
on_key receives every Key event, a method named key_ plus the key name handles one key, and widgets can handle
MouseMove, MouseDown, MouseUp, Click, Enter, Leave and the scroll events (Click is preferred over
MouseDown and MouseUp; capture_mouse() sends all mouse events to one widget).
Source: Textual — Input
Screens, modal dialogs and modes
A Screen fills the terminal and has its own widgets. Only the top screen of the stack is active. push_screen puts a
screen on top (an object or an installed name from SCREENS), pop_screen removes it (the stack must keep at least
one screen or ScreenStackError is raised) and switch_screen replaces the top one. A ModalScreen also blocks
the app’s bindings behind it, so a repeated key press cannot stack copies of the dialog, and makes the background
slightly transparent. A screen returns data with dismiss(value), which pops it and passes the value to the callback
given to push_screen; ModalScreen[bool] declares the result type. push_screen_wait returns the value directly but
must be called from a worker. A mode is a named, independent screen stack: define MODES, switch with switch_mode,
and set DEFAULT_MODE; pushes and pops affect only the active mode. A screen that loses focus receives ScreenSuspend
and the new one ScreenResume.
Source: Textual — Screens
Workers
An event handler that blocks freezes the interface, because handlers run on the event loop. A worker runs the slow
part elsewhere. @work turns a method into a worker: an async method runs as an asyncio task, and a regular function
needs thread=True, otherwise Textual raises an exception. exclusive=True cancels earlier workers before starting
the new one, exit_on_error=False stops a worker exception from exiting the app, and run_worker does the same without
the decorator. A worker sends Worker.StateChanged (states PENDING, RUNNING, CANCELLED, ERROR and SUCCESS) to the
widget that created it. A thread worker must not call widget methods directly: use App.call_from_thread, or
post_message, which is thread-safe. The @work decorator’s group argument is not described on the Workers guide.
Source: Textual — Workers
The shelf TUI
The screen has a search box on top, the catalogue on the left, a details pane and a loans table on the right, and a footer showing the key bindings. The code uses everything above: a custom widget with its own message, a reactive attribute, a modal dialog with a result, a thread worker and bindings.
# shelf_tui.py
from __future__ import annotations
import sys
import time
from dataclasses import dataclass
from textual import on, work
from textual.app import App, ComposeResult
from textual.binding import Binding
from textual.containers import Horizontal, Vertical
from textual.markup import escape
from textual.message import Message
from textual.reactive import reactive
from textual.screen import ModalScreen
from textual.widgets import DataTable, Footer, Header, Input, Label, Static
from textual.worker import get_current_worker
@dataclass(frozen=True)
class Book:
isbn: str
title: str
author: str
year: int
def read_catalogue() -> list[Book]:
"""Blocking read; in the real tool this calls the same function as 'shelf list'."""
time.sleep(0.3)
return [
Book("978-0-13-468599-1", "The Pragmatic Programmer", "Hunt and Thomas", 2019),
Book("978-0-262-03384-8", "Introduction to Algorithms", "Cormen et al.", 2009),
Book("978-1-4919-5017-4", "Fluent Python", "Ramalho", 2022),
]
class Catalogue(DataTable):
"""The list of books. Posts BookChosen when the user presses Enter on a row."""
class BookChosen(Message):
def __init__(self, isbn: str) -> None:
super().__init__()
self.isbn = isbn
def on_mount(self) -> None:
self.cursor_type = "row"
self.zebra_stripes = True
self.add_columns("Title", "Author", "Year")
def show(self, books: list[Book]) -> None:
self.clear()
for book in books:
self.add_row(book.title, book.author, str(book.year), key=book.isbn)
def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None:
event.stop() # do not let the App see the raw message
if event.row_key.value is not None:
self.post_message(self.BookChosen(event.row_key.value))
class Details(Static):
"""Shows the highlighted book; refreshes itself when 'book' changes."""
book: reactive[Book | None] = reactive(None)
def watch_book(self, book: Book | None) -> None:
if book is None:
self.update("Select a book")
else:
self.update(
f"[b]{escape(book.title)}[/b]\n{escape(book.author)}, {book.year}\nISBN {book.isbn}"
)
class LoanDialog(ModalScreen[str | None]):
"""Asks who borrows the book; dismisses with the name, or None when cancelled."""
BINDINGS = [Binding("escape", "cancel", "Cancel")]
def __init__(self, title: str) -> None:
super().__init__()
self.book_title = title
def compose(self) -> ComposeResult:
with Vertical(id="dialog"):
yield Label(f"Loan: {escape(self.book_title)}")
yield Input(placeholder="Borrower", id="borrower")
@on(Input.Submitted, "#borrower")
def submit(self, event: Input.Submitted) -> None:
self.dismiss(event.value.strip() or None)
def action_cancel(self) -> None:
self.dismiss(None)
class ShelfApp(App[None]):
CSS_PATH = "shelf.tcss"
TITLE = "shelf"
BINDINGS = [
Binding("s", "focus_search", "Search"),
Binding("l", "loan", "Loan"),
Binding("r", "return_book", "Return"),
Binding("f5", "reload", "Reload"),
]
def __init__(self) -> None:
super().__init__()
self.books: list[Book] = []
self.loans: dict[str, str] = {}
def compose(self) -> ComposeResult:
yield Header()
yield Input(placeholder="Search title or author", id="search")
with Horizontal(id="main"):
yield Catalogue(id="catalogue")
with Vertical(id="side"):
yield Details(id="details")
yield DataTable(id="loans", cursor_type="row")
yield Footer()
def on_mount(self) -> None:
self.query_one("#loans", DataTable).add_columns("Title", "Borrower")
self.query_one(Catalogue).focus()
self.action_reload()
# --- background work -------------------------------------------------
def action_reload(self) -> None:
self.load_books()
@work(thread=True, exclusive=True, exit_on_error=False)
def load_books(self) -> None:
books = read_catalogue() # blocks, so it runs in a thread
if not get_current_worker().is_cancelled:
self.call_from_thread(self.set_books, books)
def set_books(self, books: list[Book]) -> None:
self.books = books
self.apply_filter(self.query_one("#search", Input).value)
# --- messages -------------------------------------------------------
@on(Input.Changed, "#search")
def search_changed(self, event: Input.Changed) -> None:
self.apply_filter(event.value)
def apply_filter(self, needle: str) -> None:
needle = needle.casefold()
shown = [b for b in self.books if needle in b.title.casefold() or needle in b.author.casefold()]
self.query_one(Catalogue).show(shown)
@on(DataTable.RowHighlighted, "#catalogue")
def highlighted(self, event: DataTable.RowHighlighted) -> None:
self.query_one(Details).book = self.find(event.row_key.value)
def on_catalogue_book_chosen(self, message: Catalogue.BookChosen) -> None:
self.query_one(Details).book = self.find(message.isbn)
self.action_loan()
def find(self, isbn: str | None) -> Book | None:
return next((b for b in self.books if b.isbn == isbn), None)
# --- actions ----------------------------------------------------------
def action_focus_search(self) -> None:
self.query_one("#search", Input).focus()
def action_loan(self) -> None:
book = self.query_one(Details).book
if book is None or book.isbn in self.loans:
self.bell()
return
def done(borrower: str | None) -> None: # called when the dialog is dismissed
if borrower:
self.loans[book.isbn] = borrower
self.refresh_loans()
self.push_screen(LoanDialog(book.title), done)
def action_return_book(self) -> None:
book = self.query_one(Details).book
if book is not None and self.loans.pop(book.isbn, None) is not None:
self.refresh_loans()
def refresh_loans(self) -> None:
table = self.query_one("#loans", DataTable)
table.clear()
for isbn, borrower in self.loans.items():
book = self.find(isbn)
table.add_row(book.title if book else isbn, borrower, key=isbn)
if __name__ == "__main__":
app = ShelfApp()
app.run()
sys.exit(app.return_code or 0)
Source: Textual — DataTable, Textual — Input widget, Textual — Static, Textual — App Basics and the guides cited in the preceding sections
Points worth noticing in the example, each following the guides cited above:
-
Cataloguehandles its ownDataTable.RowSelected, stops it, and posts aBookChosenmessage of its own, so the application depends on a domain event rather than on table internals. The handler nameon_catalogue_book_chosenis derived from the class names as described earlier. -
Details.bookis a reactive attribute: assigning to it from any handler callswatch_book, which updates the text. The reactive is declared on the class, and Textual’sStatic.updateaccepts a string, aContentobject or a Rich renderable. -
Titles and names are data, so they pass through
textual.markup.escapebefore being put into a markup string. The Content guide documents that function, and themarkup=Falseargument ofStaticfor text that should never be parsed. Textual’s widget markup is its own implementation: usetextual.markup.escapethere andrich.markup.escapeonly for Rich consoles. -
load_booksis a thread worker becauseread_catalogueblocks. It checksis_cancelledbefore delivering, and the delivery goes throughcall_from_thread.exclusive=Truemeans that pressing F5 twice cancels the first load. -
LoanDialogis aModalScreenwhose result type isstr | None; the caller supplies a callback, so the dialog knows nothing about loans. -
Plain-letter bindings (
s,l,r) do not fire while the search box has focus, because an input that consumes a key stops the event before it reaches a binding. -
The
shelf tuientry point should call the same domain functions as the one-shot commands; a TUI that duplicates the logic cannot be driven by scripts or agents.
Command palette
Ctrl+P opens the command palette, a fuzzy-searchable list of commands. The binding can be changed with
COMMAND_PALETTE_BINDING, and ENABLE_COMMAND_PALETTE = False turns the palette off. Add simple commands by
overriding get_system_commands(self, screen) and yielding SystemCommand(title, help, callback) after calling
super(); add searchable ones with a Provider subclass registered in COMMANDS.
from collections.abc import Iterable
from textual.app import App, SystemCommand
from textual.command import Hit, Hits, Provider
from textual.screen import Screen
class ShelfCommands(Provider):
async def search(self, query: str) -> Hits:
matcher = self.matcher(query)
label = "Reload catalogue"
score = matcher.match(label)
if score > 0:
yield Hit(score, matcher.highlight(label), self.app.action_reload, help="Read the books again")
class PaletteDemo(App):
COMMANDS = App.COMMANDS | {ShelfCommands}
def get_system_commands(self, screen: Screen) -> Iterable[SystemCommand]:
yield from super().get_system_commands(screen)
yield SystemCommand("Ring the bell", "Beep", self.bell)
Source: Textual — Command Palette
The search method yields Hit objects with a score (0 means no match), the highlighted text and a callback;
discover yields DiscoveryHit objects for the empty input, and startup and shutdown are optional hooks. A
COMMANDS class variable on a Screen limits commands to that screen. Errors raised by a provider do not exit the app
and are logged to the devtools console. The provider above calls action_reload, so it assumes the shelf application from the previous section.
Running modes: terminal, inline, web
python shelf_tui.py # full-screen application mode
textual run shelf_tui.py --dev # same, with live CSS reload and devtools
textual serve shelf_tui.py # serve the same app in a web browser
run(inline=True) runs the app beneath the shell prompt instead of taking over the screen (added in Textual 0.55.0).
The documentation states that inline mode is not supported on Windows. textual serve runs an app, or a command such
as the command python -m textual, in a browser, and refreshing the page picks up code changes. Ctrl+Z suspension is disabled
by default and can be bound to action_suspend_process; the documentation states it is ignored on Windows and under
textual-web. App.suspend is a context manager that temporarily leaves application mode, which is how to hand the
terminal to another program such as an editor.
Source: Textual — App Basics and Textual — Devtools
Devtools
Because a Textual app owns the terminal, print output would overwrite the display. The devtools solve this with a
second terminal:
textual console # terminal 1: receives logs, print output and events
textual run --dev shelf_tui.py # terminal 2: runs the app connected to the console
textual console -x EVENT -x SYSTEM # hide message groups; -v is verbose, --port sets the port
self.log and from textual import log send strings, variables, key/value pairs and Rich renderables to the
console, TextualHandler forwards standard logging records (strings only), and the console restores print so it
appears there instead of on the app’s screen.
Source: Textual — Devtools
Testing with Pilot and snapshots
App.run_test() runs the app headless and yields a Pilot; the default size is 80x24 and size=(cols, rows) changes
it. Pilot methods documented on the testing page are press(*keys), click(selector, offset, times, shift, meta,
control), hover(selector) and pause(delay), which waits for pending messages. Async tests need pytest-asyncio,
configured with asyncio_mode = auto in the pytest configuration (or --asyncio-mode=auto), otherwise each test
needs @pytest.mark.asyncio.
# pytest.ini
[pytest]
asyncio_mode = auto
# test_shelf_tui.py
from textual.widgets import DataTable, Input
from shelf_tui import ShelfApp
async def test_search_filters_catalogue():
app = ShelfApp()
async with app.run_test(size=(100, 30)) as pilot:
await pilot.pause(delay=0.5) # the thread worker delivers the books
table = app.query_one("#catalogue", DataTable)
assert table.row_count == 3
await pilot.press("s") # focus the search box
await pilot.press("p", "y", "t", "h", "o", "n")
await pilot.pause()
assert app.query_one("#search", Input).value == "python"
assert table.row_count == 0
async def test_loan_dialog_records_borrower():
app = ShelfApp()
async with app.run_test(size=(100, 30)) as pilot:
await pilot.pause(delay=0.5)
await pilot.press("l") # open the dialog for the highlighted book
await pilot.press("a", "n", "a", "enter")
await pilot.pause()
assert list(app.loans.values()) == ["ana"]
def test_layout_snapshot(snap_compare):
assert snap_compare("shelf_tui.py", terminal_size=(100, 30), press=["down"])
Source: Textual — Testing
The snapshot test uses the snap_compare fixture from pytest-textual-snapshot. The first run always fails because
there is no baseline; review the generated report, then save the baseline with pytest --snapshot-update and commit
the stored snapshots. run_before takes an async function that receives the pilot and runs setup code before the
capture. The testing page does not document a way to resize the terminal from a pilot, so pass size= to run_test
instead. How these tests fit with end-to-end tests through a real pseudo-terminal is on
Testing Terminal Apps & TUIs.
prompt_toolkit
prompt_toolkit is a pure-Python library for line input and for full-screen applications. It is the engine behind IPython’s prompt and many REPLs.
python -m pip install prompt_toolkit
The shelf repl
A PromptSession keeps history across prompts, and the arguments of prompt() add completion, suggestions, a toolbar
and validation.
# shelf_repl.py
from pathlib import Path
from prompt_toolkit import PromptSession
from prompt_toolkit.auto_suggest import AutoSuggestFromHistory
from prompt_toolkit.completion import FuzzyCompleter, WordCompleter
from prompt_toolkit.history import FileHistory
COMMANDS = ["list", "add", "loan", "return", "quit"]
def main() -> None:
history_file = Path.home() / ".shelf_history"
session = PromptSession(history=FileHistory(str(history_file)))
completer = FuzzyCompleter(WordCompleter(COMMANDS))
while True:
try:
line = session.prompt(
"shelf> ",
completer=completer,
auto_suggest=AutoSuggestFromHistory(),
bottom_toolbar=lambda: "Type 'quit' to leave",
)
except (EOFError, KeyboardInterrupt): # Ctrl-D and Ctrl-C
break
if line.strip() == "quit":
break
print(f"you typed: {line}")
if __name__ == "__main__":
main()
The prompt page documents these options: FileHistory persists history across runs; FuzzyCompleter wrapped around
WordCompleter lets shw match show; AutoSuggestFromHistory shows grey suggestions; bottom_toolbar accepts a
callable that is re-rendered on every redraw; Validator.from_callable (with error_message and move_cursor_to_end arguments)
builds a validator; multiline=True accepts input with Meta+Enter; is_password=True hides the input. The page warns
that complete_while_typing is incompatible with enable_history_search, and for asyncio programs recommends
prompt_async() inside patch_stdout() so that other output does not corrupt the prompt. History files hold what the
user typed: do not point a password prompt at the same session history, and keep the history file private. How readline,
libedit, rustyline and the other line editors compare is on
Interactive Prompts & Line Editing.
Full-screen applications
Application with layout, key_bindings and full_screen=True runs in the alternate screen buffer. Layouts are built
from containers (HSplit, VSplit, Window) and controls (FormattedTextControl for text, BufferControl for an
editable buffer) and wrapped in a Layout, which also tracks focus. Key bindings are registered with a
KeyBindings object.
from prompt_toolkit import Application
from prompt_toolkit.buffer import Buffer
from prompt_toolkit.key_binding import KeyBindings
from prompt_toolkit.layout.containers import HSplit, VSplit, Window
from prompt_toolkit.layout.controls import BufferControl, FormattedTextControl
from prompt_toolkit.layout.layout import Layout
kb = KeyBindings()
@kb.add("c-q")
def _(event) -> None:
event.app.exit()
root = HSplit([
Window(height=1, content=FormattedTextControl(text="shelf -- Ctrl-Q quits")),
VSplit([
Window(content=BufferControl(buffer=Buffer())), # search box
Window(width=1, char="|"),
Window(content=FormattedTextControl(text="Fluent Python\nRamalho, 2022")),
]),
])
Application(layout=Layout(root), key_bindings=kb, full_screen=True).run()
Frame and TextArea are prebuilt widgets for this toolkit; the full-screen page mentions them only in passing and does
not document mouse_support, so read the reference pages before depending on them. Compared with Textual, prompt_toolkit
has no stylesheet language, no reactive attributes and no test harness of its own: it is the better choice for a
prompt, an editor field or a small dialog, and Textual for a screen with many interacting parts.
The curses module
curses in the standard library binds the ncurses API. The documentation lists its availability as Unix, and states
that it is not available on Android, iOS or WASI; it is an optional module, so a distribution may omit it.
curses.wrapper(func, *args) is the recommended entry point: it calls initscr(), turns on cbreak mode, turns off echo,
enables keypad mode, initialises colours, calls the function with the standard screen, and restores the terminal on
exit, including when the function raises. Without it, the program must call curses.endwin() in a finally block.
# shelf_curses.py
import curses
BOOKS = ["The Pragmatic Programmer", "Introduction to Algorithms", "Fluent Python"]
def main(stdscr: "curses._CursesWindow") -> None:
curses.use_default_colors()
if curses.has_colors():
curses.init_pair(1, curses.COLOR_CYAN, -1) # -1 is the terminal's default background
selected = 0
while True:
stdscr.erase()
stdscr.addstr(0, 0, "shelf -- up/down move, q quits")
for row, title in enumerate(BOOKS, start=2):
attr = curses.A_REVERSE if row - 2 == selected else curses.color_pair(1)
stdscr.addstr(row, 2, title, attr)
stdscr.refresh()
key = stdscr.getch()
if key == curses.KEY_RESIZE:
curses.update_lines_cols() # LINES and COLS now have the new size
elif key == curses.KEY_UP:
selected = (selected - 1) % len(BOOKS)
elif key == curses.KEY_DOWN:
selected = (selected + 1) % len(BOOKS)
elif key == ord("q"):
break
curses.wrapper(main)
Source: Python — curses and Curses Programming with Python
Facts from the module documentation to keep in mind:
-
getch()returns an integer key code,-1when a non-blocking read finds nothing, and a series of byte values for a non-ASCII character (in UTF-8,éarrives as 195 and then 169);get_wch()reads a whole character as a string. -
KEY_RESIZEis returned bygetch()on terminal resize, andupdate_lines_cols()refreshesLINESandCOLS. The documentation does not spell out when it is sent, so test it on each target terminal. -
set_escdelay(ms)sets how long curses waits after an Escape byte to tell a lone Esc from the start of a sequence. -
Writing outside a window, or to its lower-right corner, raises
curses.error(for the corner, after the character is drawn). -
start_color()must precede other colour routines,init_pair(n, fg, bg)takesnfrom 1 toCOLOR_PAIRS - 1, andcurses.color_pair(n)returns the attribute, which can be combined with theA_attribute constants using the bitwise or operator. -
curses.textpad.Textboxprovides a one-line or multi-line editor with Emacs-style keys, finished with Ctrl-G. -
Thread safety depends on the underlying library; the documentation warns that with the default ncurses build the screen state is shared and calls from several threads must be serialised. The
curses.panelmodule is only mentioned there as a panel stack extension.
On Windows the standard library has no curses. The third-party windows-curses package (2.4.2, released 2026-03-31,
marked Beta on PyPI) adds the same module on top of PDCurses, with wide-character support and UTF-8 forced as the
encoding. Its PyPI page lists wheels for Windows on x86 and x86-64 and Python 3.6 through 3.14, so check that a wheel
exists for the Python you target. From version 2.0 it calls resize_term(0, 0) automatically when a read returns
KEY_RESIZE, to imitate ncurses' handling of SIGWINCH; the author describes this as a non-standard workaround.
Source: windows-curses on PyPI
Windows, macOS and Linux
| Topic | Linux | macOS | Windows |
|---|---|---|---|
Terminal |
Any terminal emulator; the Linux console has its own guidance in the Textual docs |
Default Terminal supports 256 colours only; iTerm2, Ghostty, Kitty and WezTerm suggested |
Windows Terminal recommended by the Textual docs |
Textual inline mode |
Supported |
Supported |
Not supported |
Textual Ctrl+Z suspend |
Bindable to |
Bindable to |
Ignored |
|
Standard library (ncurses) |
Standard library (ncurses) |
Not in the standard library; |
Resize notification |
|
|
|
The Rich and Textual documentation consulted do not state which Windows versions they require or how they enable virtual-terminal processing; for the Win32 background see Windows Console & Processes and Cross-Platform Portability.
Pitfalls and security
-
Escape sequences in data. A book title or borrower name that reaches the terminal unfiltered can carry control sequences. Rich and Textual parse square brackets as markup, which is a second injection channel: always escape dynamic text (
rich.markup.escape,textual.markup.escape) or turn markup off. See Security for the wider discussion. -
Output to the wrong stream. A Rich
Console()writes to stdout. Progress bars, spinners and log handlers belong on stderr (Console(stderr=True)), so thatshelf list > books.tsvstill produces clean data. -
Blocking the event loop. A slow call inside a Textual handler, or a
time.sleep, freezes the interface; use a worker. A thread worker must reach the UI throughcall_from_threadorpost_message. -
Exiting without cleanup. Textual restores the terminal on exit. With plain
curses, usecurses.wrapper, which restores the terminal when the function raises; if you callinitscr()yourself, callendwin()in afinallyblock. -
Printing from inside a TUI.
printwrites into the screen the framework is drawing. Useself.logand thetextual console, or aRichHandleraimed at a file. -
Missing 24-bit colour. macOS Terminal supports only 256 colours; test the stylesheet there and with
NO_COLORset, and never rely on colour alone to convey a state (the book is "on loan" in text as well as in yellow). Accessibility limits of full-screen interfaces are on Accessibility & Internationalization. -
show_localsin shared logs. Rich tracebacks withshow_locals=Trueprint variable values. -
Dependency versions. All three libraries release often; pin versions in
pyproject.tomland re-run the snapshot tests when upgrading Textual or Rich, because both change rendering details. -
Non-interactive use. Check
Console.is_terminal, orsys.stdin.isatty(), before starting a TUI or a prompt, and fall back to the plain command when the check fails, as the pages in the AI sub-section describe.
For shipping a Python TUI as a single executable see Packaging & Distribution and Native Binaries & Cross-Compilation. The same catalogue browser in other languages is on Ratatui: Getting Started, Go TUIs with Bubble Tea and .NET TUIs with Spectre.Console & Terminal.Gui.
References
-
Textual documentation, in particular the guides for App Basics, CSS, Layout, Reactivity, Events and Messages, Input, Screens, Workers, Queries, Content, Command Palette, Devtools and Testing
-
Textual — Getting Started, Textual — Message API, DataTable, Input and Static
-
Rich documentation: Console API, Console Markup, Tables, Tree, Live Display, Progress Display, Logging Handler and Traceback
-
prompt_toolkit documentation: Asking for input and Building full screen applications
-
Python — curses, Curses Programming with Python and windows-curses on PyPI