Compared to other frameworks¶
Most comparisons of this kind are written from documentation. This one is written from two ports: hop3-tui and prezo, both real Textual applications, both moved to termopy in full, with their original test suites kept as the reference. The numbers below are measured, not estimated.
The short version¶
| termopy | Textual | |
|---|---|---|
| source | 5,562 lines | 82,423 lines |
| widgets | 22 | 45 |
| dependencies | 1 (rich) |
22 |
| model | immediate | retained |
| styling | Python values | CSS (.tcss) |
| platform | POSIX | POSIX + Windows |
| web target | no | yes |
| devtools | no | yes |
Use Textual if you want the largest widget set in Python, Windows support, CSS theming without touching code, a web target, or a mature project with a company behind it. It is very good, and termopy would not exist without having read it.
Use termopy if you want a core small enough to hold in your head, and UI code you can test by calling it.
The two models¶
Everything else follows from this difference.
Textual is retained-mode. You build a tree of widget objects. They persist, you
mutate them, and the framework works out what changed. reactive attributes trigger
watch_* methods, query_one finds widgets by selector, CSS positions them.
class AppsSummary(Static):
running: reactive[int] = reactive(0)
def compose(self) -> ComposeResult:
yield Static("APPLICATIONS", classes="panel-title")
yield Static(id="apps-summary-content")
def watch_running(self, value: int) -> None:
self._update_display()
def _update_display(self) -> None:
content = self.query_one("#apps-summary-content", Static)
content.update(f"[green]Running:[/green] {self.running}")
termopy is immediate-mode. There is no tree. Your function runs again and returns a picture.
def apps_summary(ui: UI, counts: AppCounts) -> View:
return markup.render_lines(ui.theme, f"[green]Running:[/] {counts.running}")
That is the same widget from the same application. compose, reactive, watch_* and
query_one did not need replacements: in immediate mode there is no between-frame
state to watch and nothing to query.
Neither model is better in the abstract. Retained mode does less work per frame and suits deep, mostly-static trees. Immediate mode is easier to reason about and to test, and re-renders everything. termopy renders a full 100×30 frame of a real application in under a millisecond, so "re-renders everything" has not been the constraint in practice.
The evidence: two real ports¶
hop3-tui¶
A PaaS management TUI: 12 screens, a live HTTP API, tables, confirmation dialogs.
| original | port | |
|---|---|---|
| source | 5,369 lines | 2,841 lines |
| tests | 169 | 227 |
47% smaller. Almost none of that is cleverness; it is compose(), reactive,
watch_*, query_one, CSS and the widget subclasses that existed only to hold a
Static.
prezo¶
A Markdown presentation tool: its own 1,174-line layout engine, terminal image protocols (kitty, sixel, iTerm2), PDF/HTML/SVG export, 28 key bindings.
| original | port | |
|---|---|---|
| source | 9,578 lines | 7,591 lines |
| tests | 552 | 653 |
| coverage | 70% | 75% |
Only 21% smaller, for a good reason: most of prezo is not UI. Its parser, layout engine, image protocols and exporters are 64% of the source, and all of it ported unchanged, including the Rich-based layout engine and its 1,149 lines of tests.
The UI part shrank like hop3-tui's did: app.py went from 1,660 lines to 369.
What termopy gets right¶
Testing. This is the largest practical difference. A Textual test needs
async with app.run_test() as pilot, and then asserts on the widget tree through
query_one. A termopy test calls a function:
Both ports ended up with more tests than their originals, and prezo with better coverage, mostly because writing them stopped being a chore.
Awaited modals. Textual's push_screen answers through a callback, so the original
mounted a ConfirmationDialog and stashed self._pending_action = ("stop", app_name)
for the handler to find. termopy awaits:
That removed a class of state from four screens. Textual has push_screen_wait, which
is the same idea; termopy has only the awaited form, which is one fewer thing to choose
between.
One dependency. Textual pulls 22, including eleven tree-sitter grammars. termopy pulls Rich, and Rich's heavy parts load lazily.
What you give up¶
Widgets. 22 against 45. No DataTable with sorting and cell selection, no
TabbedContent, no DirectoryTree, no Collapsible. Both ports needed a table with
columns, so termopy grew one, a simpler one.
Windows. termopy uses termios and tty directly. Textual runs on Windows.
Devtools. Textual has a console, a live CSS editor and a snapshot-testing plugin. termopy has none of that.
The web. textual serve puts an app in a browser. There is no termopy equivalent.
Maturity. Textual has thousands of users finding its edges. termopy has two applications, which was enough to find several real bugs and is certainly not enough to have found them all.
What you give up by not having CSS¶
Two ports say: less than expected, and more than zero.
Every layout in hop3-tui's twelve screens was a grid of halves or a scrolling pane. Two
helpers (halves() and rows(), eleven lines together) covered all of it. Prezo's
computed layout lives in its Rich layout engine, which termopy draws.
The cost is that hand-computed boxes crop in silence when the terminal is small. A
row vanishes and reads like a data bug. Prezo's port marks a cropped panel with a corner
…, which costs no line; CSS would have handled it without anyone thinking about it.
The second cost cannot be measured here: with CSS, someone can restyle an application without touching Python. termopy has themes, which reach less far.
Rich¶
Rich is a dependency, and the layering is deliberate.
Rich renders content: measuring, wrapping, tables, syntax highlighting, markdown. termopy composes interactive views: layout, focus, hit-testing, a render loop.
termopy.rich.to_view draws any Rich renderable into a View. It goes one way, because
a View carries click handlers and focus tags that Rich's segment stream cannot
express. See the guide.
If your program prints and exits, use Rich alone. If it responds to keys, you need something above it.
The others¶
urwid is the venerable option: retained-mode, callback-driven, with its own widget and canvas system and no Rich. It runs on things nothing else does. Its API predates modern Python and shows it.
prompt_toolkit is aimed at a different job: line editing and REPLs, and it is the best in Python at that. It can build full-screen applications, but if you are writing a shell or a prompt you should start there, not here.
blessed and curses sit a layer below all of these: terminal capabilities and cursor movement, with no layout or event model. Reach for them when you want the terminal itself.
Immediate-mode elsewhere: Dear ImGui in C++,
Streamlit and Gradio in Python for
the web, and React's function components plus hooks. If ui.state looks familiar, that
is why. termopy's direct ancestor is Jane Street's
bonsai_term in OCaml.
Choosing¶
Start with Textual. It is more complete, better supported, and runs in more places.
Come to termopy if you find yourself wanting a smaller thing: if the CSS is a layer you do not want, if testing the UI is where your time is going, or if you want to read the whole framework in an afternoon.