Compared to other frameworks¶
If you are choosing between turbodesk and Textual, skip to turbodesk and Textual. That is the comparison most people want, and it is the one this page spends the most space on, because Textual is the framework two real applications were ported from.
The section before it is the reason any of the differences exist. The sections after it place turbodesk among the alternatives in Python and the same idea in other languages.
Two ways to build a UI¶
Every UI framework answers one question first: what exists between frames?
Retained mode keeps a tree of widget objects alive. You build it once and then mutate it: set a label's text, insert a row, toggle a class. The framework tracks what changed and repaints the difference. The DOM works this way, and so do Qt, GTK and Swing, and so does almost every terminal framework in Python.
Immediate mode keeps nothing. Each frame your code runs again from the top and describes the whole interface from the current state. There is no button object to hold a reference to; there is a call that draws a button and tells you whether it was clicked this frame. Casey Muratori developed the technique in 2002 and named it, and Omar Cornut's Dear ImGui made it ordinary in games and tools.
| retained | immediate | |
|---|---|---|
| where UI state lives | in the widget tree | in values you own |
| changing the screen | mutate an object | run the function again |
| work per frame | proportional to the change | proportional to the whole UI |
| "why is this widget wrong?" | find whatever mutated it | read the function |
| testing | drive the tree, then query the tree | call the function, inspect what it returned |
The trade is ownership against work. Retained mode lets the framework do less per frame, and in exchange the current state of the screen is spread across objects that anything holding a reference can change. Immediate mode redraws everything, and in exchange the screen is a function of data you can print.
Neither is better in the abstract. Retained mode suits deep, mostly-static trees and is why browsers are built that way. Immediate mode suits interfaces that change shape often, and it is much easier to test, because a test can call the function.
A third family sits close to immediate mode: the Elm Architecture and React's hooks both make the view a pure function of state, and differ mainly in how updates get back in. turbodesk borrows React's mechanism, hooks keyed by call order, and Elm's discipline about where state lives.
turbodesk and Textual¶
The short version¶
| turbodesk | Textual | |
|---|---|---|
| source | 5,562 lines | 82,423 lines |
| widgets | 22 | 45 |
| dependencies | 4 packages | 7 packages |
| 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 turbodesk would not exist without having read it.
Use turbodesk if you want a core small enough to hold in your head, and UI code you can test by calling it.
The same widget, both ways¶
Textual gives you a class that persists. 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}")
turbodesk gives you a function that 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.
turbodesk 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¶
Most comparisons of this kind are written from documentation. The numbers here come from two Textual applications moved to turbodesk in full, with their original test suites kept as the reference.
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. The deleted lines are 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 52% 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 turbodesk 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 turbodesk 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. turbodesk awaits:
That removed a class of state from four screens. Textual has push_screen_wait, which is the same idea; turbodesk has only the awaited form, which is one fewer thing to choose between.
One direct dependency. turbodesk requires Rich, which brings markdown-it-py, mdurl and pygments: four packages installed. Textual requires six directly and installs seven. The gap is smaller than it looks, and it is not a reason to choose either one.
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 turbodesk grew one, a simpler one.
Windows. turbodesk uses termios and tty directly. Textual runs on Windows.
Devtools. Textual has a console, a live CSS editor and a snapshot-testing plugin. turbodesk has none of that.
The web. textual serve puts an app in a browser. There is no turbodesk equivalent.
Maturity. Textual has thousands of users finding its edges. turbodesk has two applications, now shipped separately as Turbo Python and TurboVI, 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(), eight lines together) covered all of it. Prezo's computed layout lives in its Rich layout engine, which turbodesk 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. turbodesk has themes, which reach less far.
The rest of Python¶
Rich is a dependency here, and the layering is deliberate. Rich renders content: measuring, wrapping, tables, syntax highlighting, markdown. turbodesk composes interactive views: layout, focus, hit-testing, a render loop. turbodesk.rich.to_view draws any Rich renderable into a View, and 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, Rich alone is enough; responding to keys is what needs a layer above it.
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.
The same idea in other languages¶
Python's terminal frameworks are unusually uniform: Textual, urwid and prompt_toolkit are all retained-mode. Outside Python, drawing the whole UI from state every frame is a normal choice, and often the dominant one.
Ratatui (Rust) is the closest relative. Its documentation defines the model in the same terms turbodesk does: the UI is recreated every frame, and there is no permanent widget object in memory. You call terminal.draw() in a loop and build widgets from current state inside it.
Bubble Tea (Go) takes the Elm Architecture route: Update receives a message and returns a new model, View renders that model. Same purity, different plumbing, messages where turbodesk has hooks.
brick (Haskell) asks you to "write a pure function that describes how your user interface should be drawn based on your current application state", composed from declarative layout combinators. That sentence describes turbodesk too.
Dear ImGui (C++) is where the term comes from, and egui is its Rust equivalent. Both are graphical rather than terminal, and both share the property that matters: no tree survives the frame.
React's function components and hooks are the reason ui.state looks familiar. It is useState, with the same call-order rule and the same failure mode when you break it.
turbodesk's direct ancestor is Jane Street's bonsai_term in OCaml, which is where the View combinators and the tag-based geometry came from.
Choosing¶
Start with Textual. It is more complete, better supported, and runs in more places.
Come to turbodesk 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.