intuiTUIve against Textual: a parity audit¶
Status: planning note, 2026-10-27. Each gap worth doing has an issue,
labelled intuituive; the numbers are in the gap list.
Why this note¶
Textual is the most complete terminal UI framework in Python, by Will McGugan, the author of rich. Since intuiTUIve is built on our port of rich, Textual is the closest comparison. This note goes through Textual's widgets and framework features one by one. It records what intuiTUIve has, what it lacks, and where each gap belongs. Cornacai (#794), the agent front end, will be intuiTUIve's showcase app, so its needs set the order of the gap list.
- Compared against: Textual 8.2.8,
mainat5e5b7ef(2026-10-09). Source links below point at that commit. Doc links point at textual.textualize.io. - The intuiTUIve side was checked by reading
crates/rich-intuituive/srcat 0.0.23's extras branch (f899726c), not by matching names. Where a Textual widget maps to something in another shape, the table says so. - Licence: Textual is MIT. We may follow its designs closely, with
credit to Textual in the module docs and the guide. Its built-in themes'
palettes may be reused, with credit. Our code is still written fresh, in
Rust and in intuiTUIve's own style: signals and closures, builders on
Node. We do not translate its Python. This differs from Toad, which is AGPL: #794 forbids reading Toad's source. Textual's source is fine to read. - Core or plugin: AGENTS.md's rule applies. intuiTUIve's core holds
only generic components. A domain component is a plugin: an
off-by-default feature module or another crate, built on a generic core
piece. For each gap below, a domain proposal also names the generic shape
underneath it. Most of Textual's widgets are generic and belong in core.
The exception is
DirectoryTree, which is thefilesplugin's.
Status key: ✓ has it (or more); partial has it with a gap, and the gap is named; ✗ missing; won't-do not worth building, with the reason.
Widgets¶
Every widget in src/textual/widgets/_*.py. Constructors are intuiTUIve's.
Modules are under crates/rich-intuituive/src/.
| Textual widget | intuiTUIve equivalent | Status | Gap, and where it goes |
|---|---|---|---|
Button |
button, button_of, ButtonVariant (controls.rs) |
✓ | Three variants are themed (default, primary, danger); Textual has five (it adds success and warning). Two theme names add them. |
Checkbox |
checkbox, checkbox_tri, checkbox_of (controls.rs) |
✓ | Has a mixed state, which Textual's lacks |
Collapsible |
collapsible(title, open, child) (collapsible.rs) |
partial | The title is a fixed String. A tool-call card needs a title that follows state, plus a status slot (spinner, ✓/✗) and a summary line. Core. |
ContentSwitcher |
switch(key, build), keyed, .keep_last(n) (builtin.rs) |
✓ | |
DataTable |
table, table_with, virtual_table, TableOptions (widgets.rs) |
partial | Already has sorting, a row cursor, a cell cursor (.cells), column highlight, frozen columns and rows (freeze_rows, 0.0.24), zebra rows (row_style), resizable columns, rows from a closure (so cell updates are signal writes) and virtual rows. Missing: row labels, and cells holding renderables rather than markup. Core. |
Digits |
digits (digits.rs) |
✓ | |
DirectoryTree |
files::miller, files::file_list, files::pick_file (files feature); generic tree_lazy |
partial | No tree view of a directory. Plugin: files::dir_tree on the core's tree_lazy. |
Footer |
key_hints, key_hints_with (bars.rs) |
✓ | Built from described bindings, greyed when unavailable |
Header |
status_bar segments (bars.rs) |
partial | Nothing gives an app a title and subtitle (set per screen) with an optional clock and a tall mode. Core: header() on status_bar. |
HelpPanel |
cx.help(), HelpOptions (help.rs) |
partial | The help is a modal. Textual's panel is docked beside the app and follows the focus. Core: key_panel() node. |
Input |
input, number_input, .validate, .complete, .history, .mask, .max_length (input.rs, complete.rs, text_edit.rs) |
✓ | Ghost-text completion is the equivalent of Textual's Suggester |
KeyPanel |
key_panel() (parity.rs), cx.toggle_key_panel() (0.0.24), with the help's sections; shown or hidden by the palette's Keys system command (#803) |
✓ | A docked panel following the focus, its bindings under the help's sections |
Label |
label (node.rs) |
✓ | |
Link |
[link=url] markup gives an OSC 8 hyperlink (screen.rs keeps it on the style) |
partial | No focusable link that Enter or a click opens (through open), and markup cannot run an action (Textual's @click). Core. |
ListItem |
ListRow (markup, trailing, marker, style) (list.rs) |
partial | A row is markup, not a node. See ListView. |
ListView |
list, list_rows, virtual_list (list.rs, widgets.rs) |
partial | The cursor, scrolling and context menu are there. Rows cannot be arbitrary nodes (a card, a row with a button), as a ListItem holding widgets can. Core: list_of(len, \|i\| Node, selected). |
LoadingIndicator |
spinner, .loading_when, fx::placeholder (spinner.rs, fx.rs) |
✓ | |
Log |
Log::new(cap).view() (log.rs) |
✓ | Adds levels, targets, filters and follow, which Textual's lacks |
Markdown |
renderable(Markdown::new(..)), rs-rich's port (crates/rich/src/markdown.rs) |
partial | Static only. The whole document is parsed and drawn again on each change. Links cannot be clicked or focused, code blocks are not retained nodes, and nothing streams: Textual has Markdown.append and MarkdownStream. Core. Cornacai's first need. |
MarkdownViewer |
markdown_viewer(load), MarkdownViewer (go, back, forward, toc, on_link), files::markdown_loader(root) (markdown_viewer.rs) |
✓ | The loader is the app's (the files plugin reads files). Anchors are GitHub's slugs of the ATX and setext headings (#805) |
MaskedInput |
— | ✗ | .mask(char) is a password mask, not a template such as 9999-99-99 or >AAA. Core. |
OptionList |
list, .multiline(), list_search, .row_style |
✓ | Missing separators between options. Small, tracked with ListView. |
Placeholder |
fx::placeholder |
partial | fx::placeholder is a loading skeleton. Textual's is for laying out a prototype: each one labelled with its id, in cycling colours. Core, small. |
Pretty |
renderable(Pretty::new(&value)) or Pretty::from_value (rs-rich's port) |
✓ | Rust has no reflection, so it pretty-prints Debug or a PrettyValue |
ProgressBar |
renderable(rich::ProgressBar), seg_gauge, jobs_panel |
partial | No progress node that shows a bar, a percentage and an ETA, with an indeterminate pulse. Core, small. |
RadioButton |
radio rows |
✓ | |
RadioSet |
radio, radio_keyed (controls.rs) |
✓ | |
RichLog |
Log |
partial | A log line is markup only. Textual's RichLog.write takes any renderable (a Table, Syntax, Pretty), which a tool's output needs. Core. |
Rule |
rule, .divider() (rule.rs) |
✓ | Adds titles and border merging |
Select |
select_field, select_field_keyed, SelectOption::disabled (select_field.rs) |
✓ | |
SelectionList |
multi_select (select.rs) |
✓ | Adds a visual range |
Sparkline |
sparkline (viz feature) |
✓ | A plugin here, by the core rule |
Static |
text, text!, label, renderable |
✓ | |
Switch |
switch_toggle, switch_of |
✓ | |
Tab |
tabs titles (widgets.rs) |
✓ | |
TabPane |
a switch page, kept with keep_last |
✓ | |
TabbedContent |
tabs + switch; tab_set, tabs_for for tabs that open and close (tab_set.rs) |
✓ | Composed from two nodes, not one widget. The guide shows how. |
Tabs |
tabs, .divider, .fit, .auto_hide, .on_select |
✓ | |
TextArea |
text_area, .language, .line_numbers, .soft_wrap, .read_only, .cursor_line, search_bar, .vim (text_area.rs, text_edit.rs) |
✓ | Already has undo and redo, selection with the mouse, highlighting (syntect, or tree-sitter through rs-rich-lumis), a find bar and Vim mode; bracket matching (.match_brackets) and editor themes (EditorTheme, 0.0.24). Missing: highlighting state carried from above the view. Core. |
Toast |
cx.toast, cx.notify(Level, ..), App::toasts_at (notify.rs) |
✓ | |
ToggleButton |
the base of checkbox, radio, switch_toggle |
✓ | |
Tooltip |
.tooltip(markup) (node.rs) |
✓ | |
Tree |
tree, tree_keyed, tree_lazy, tree_reveal, tree_table (tree.rs, tree_table.rs) |
✓ | Adds rows that nest inside a table |
Welcome |
— | won't-do | A demo splash for Textual's own textual command, with no use in an app |
Counts: 44 widgets. 28 ✓, 13 partial, 2 ✗, 1 won't-do.
Framework features¶
| Area | Textual | intuiTUIve | Status | Gap |
|---|---|---|---|---|
| TCSS selectors | type, #id, .class, descendant, child >, *, & nesting |
Stylesheet (sheet.rs): kind, #name, .class, *, descendant, child >, nesting with & (read as the flattened rule) |
✓ | Sibling combinators (+, ~), which Textual lacks too (#812) |
| Pseudo-classes | :focus, :focus-within, :hover, :disabled, :blur, :can-focus, :dark, :light, :first-child, :last-child, :odd, :even, :empty, :inline, … |
:focus, :focus-within, :hover, :selected, :disabled, :error, :blur, :enabled, :can-focus, :first-child, :last-child, :odd, :even, :empty, :dark, :light |
partial | :inline, :first-of-type and the like; states change colours and text styles, not layout (#812) |
| Variables | $name: value; declared in CSS; theme variables |
$name: value; declared at the top level (values may use earlier ones); a $name not declared names a theme style |
✓ | (#812) |
| Properties | about 90 (width, height, margin, offset, opacity, tint, visibility, outline, scrollbar colours, hatch, keyline, layer, …) | about 35: colours, text style, border (titles, sides, collapse), text align, wrap and overflow, size and its limits, padding, margin, visibility, scrollbar colours, gap, grid tracks, display, dock, flex alignment | partial | Offset, opacity and tint, outline, hatch, keyline, scrollbar-color-hover and the scrollbar's size (#812's later slices) |
| @media / responsive | horizontal and vertical breakpoints that set classes (-narrow); no @media |
@media (min-width: N) and …, .hide_below, viewport() |
✓ | More than Textual |
| Live CSS reload | textual run --dev |
App::stylesheet_file, App::theme_file are read again when the file changes |
✓ | |
| Layout: grid, dock, layers, align, fr | all | grid (tracks, spans), dock, layers, Align and Justify, Size::Flex/3fr, place (Origin, Offset), the Solver, treemap, draggable split |
✓ | |
| Screens | push, pop, switch, ModalScreen, a result through dismiss(value) and a callback or push_screen_wait |
cx.push, pop, replace, modal, modal_with (shadow, backdrop); dialogs confirm, prompt and pick return through callbacks |
partial | A screen cannot hand a value back in general: there is no push_for and dismiss |
| Modes | MODES, switch_mode: named screen stacks |
modes/in_mode are binding modes, not screen stacks |
✗ | Named screen stacks |
| Command palette | Provider classes (startup, search, discover), fuzzy matching with highlights, system commands (Theme, Keys, Maximize, Screenshot, Quit, Bell) |
cx.palette over the described bindings; Provider (discover, search into a HitSink, background for a search off the app's thread, cancelled by the next query) registered with App::provider; hits merged by score with the matched characters highlighted; SystemCommands (Theme with a Themes picker, Keys, Maximize/Minimize, Screenshot, Quit), each can be turned off; cx.palette_of; action_overlay/cx.action_palette |
✓ | No startup hook (a provider's new does its setup) and no Bell (#803) |
| Bindings and keymaps | Binding(id=…), App.set_keymap remaps by id, priority, show |
.bind, .bind_in, .bind_when, .bind_unless, sequences, which-key, bind_scaled, bind_cycle; rebind_key for the editing actions only |
partial | Users cannot remap the app's own bindings: bindings have no ids and there is no keymap file |
| Focus | Tab chain, can_focus, focus_next |
Tab, focus scopes, by direction, autofocus, focus_within, assert_focused |
✓ | |
| Mouse | click, double-click, drag, scroll, hover, enter and leave | on_click, on_mouse, drag-and-drop, hover styles, wheel, text selection with the mouse captured |
✓ | |
| Scrolling | ScrollableContainer, smooth scroll, anchor() |
scroll, scroll_x, scroll_both, scroll_virtual (keyed, measured, stand-ins), scrollbar |
partial | No follow-the-end anchor: a chat that stays at the bottom until the reader scrolls up |
| Reactive, watch, compute | reactive, var, watch_, compute_, validate_, data_bind, recompose |
signal, memo, watch, on_write; the nodes that read a signal redraw |
✓ | Finer grained than Textual |
| Messages and events | Message classes that bubble, @on(Event, selector) |
closures on the node (on_key, on_click, on_select, …) and signals |
won't-do | A different model by design. Signals and closures do what messages do, typed, with no dispatch by class name. |
| Workers | @work (async or a thread), exclusive, cancellation, WorkerState |
spawn, spawn_future, spawn_stream, latest, resource, jobs (progress, cancel), Proxy |
✓ | |
| Timers, animation, easing | set_interval, set_timer, animate on any attribute, about 30 easings |
every, after, fixed_step, Timer (pause, period); cx.animate(Signal<f64>, ..); 4 easings; fx presets |
partial | Only 4 easings |
| Themes | about 20 built-in themes (textual-dark, nord, gruvbox, dracula, tokyo-night, monokai, catppuccin ×4, solarized ×2, rose-pine ×3, flexoki, atom-one ×2, ansi ×2); semantic colours ($primary, $secondary, $accent, $surface, $panel, $boost, $error, …) with generated shades ($primary-lighten-2, $text-muted); switched from the palette |
Theme::dark, light, mono; named styles (accent, muted, good, warn, bad, selected, button.*); Ctx::set_theme; theme files |
partial | No semantic palette, no shades, 3 themes, no theme command |
| Notifications | notify(severity, title, timeout) |
cx.notify(Level, title, body), toast, placement |
✓ | |
| Tooltips | tooltip |
.tooltip |
✓ | |
| Suspend | App.suspend() |
Ctrl+Z suspends; cx.handoff(Command), cx.edit_text ($EDITOR) |
✓ | |
| Maximize | screen.maximize(widget), allow_maximize, a system command |
build it by hand with size_with and focus_scope_reveal |
✗ | One call to show the focused pane alone |
| Clipboard | copy_to_clipboard (OSC 52) |
cx.copy (OSC 52), selection with the mouse, take_copies |
✓ | |
| Devtools: console, log | textual console: log and print sent to a second terminal |
the tracing plugin feeds a Log inside the app |
partial | Logs sent to a second terminal, which matters most when the app owns the screen |
| Devtools: DOM and style inspector | none in the app (textual console only) |
App::inspector / F12: tree, node details (classes, states, rules), timings, trace, pick, redraw flash |
✓ | More than Textual |
| Testing: Pilot | run_test, pilot.press, click(selector, offset), hover, double_click, resize_terminal, pause, wait_for_animation |
App::driver: press, type_text, send(Event), resize, advance, settle, run_named, assert_focused |
partial | Clicks and hovers by node name (today send takes raw coordinates) |
| Snapshot testing | pytest-textual-snapshot: SVG screenshots with an HTML diff report |
assert_screen! (plain text), assert_screen_styled! (markup); unified diff; UPDATE_SNAPSHOTS=1 |
partial | No SVG snapshot or visual report. Text snapshots read better in review, so SVG would be added alongside them. |
| Screenshots | save_screenshot, deliver_screenshot, a system command |
rich record tapes, from outside the app (rs-rich-record) |
partial | No screenshot from inside the app or from a Driver, although rs-rich-ext's frame::Frame::to_svg exists |
| Web | textual serve, textual-web |
rich serve / rs-rich-web: xterm.js or a DOM renderer with ARIA, a session per tab |
✓ | |
| Accessibility | limited | roles, states, announcements, linear text mode, screen-reader guide (a11y.rs) |
✓ | More than Textual |
| Markdown streaming | Markdown.append, Markdown.get_stream → MarkdownStream.write (batches fragments that arrive faster than they can be drawn) |
— | ✗ | The top gap for Cornacai |
| TextArea | tree-sitter highlighting, TextAreaThemes, undo and redo, selection, soft wrap, read-only, line numbers, bracket matching, tab behaviour |
all, bracket matching and editor themes included (0.0.24); highlighting through the console's highlighter (syntect, or tree-sitter through rs-rich-lumis); Vim mode | partial | Highlighting state from above the view |
| DataTable | sorting, cell, row, column and no cursor, fixed rows and columns, zebra stripes, update_cell, virtual size |
all but row labels and renderable cells (fixed rows: freeze_rows, 0.0.24) |
partial | Row labels, renderable cells |
| Inline apps | run(inline=True) |
App::inline(height), print_above |
✓ |
Counts: 35 areas. 15 ✓, 16 partial, 3 ✗, 1 won't-do.
Prioritised gaps¶
Value comes first, and Cornacai's needs (#794) count most: a conversation
view (streaming Markdown, a transcript that follows its end, tool-call
cards, diffs) and a prompt editor (@ mentions, history, a box that grows
as you type). Effort: S a day or two, M about a week, L more.
Every entry is core unless it says plugin, and each one is generic.
| # | Gap | Value | Effort | Where | Depends on | Issue |
|---|---|---|---|---|---|---|
| 1 | Streaming Markdown node: markdown(signal) with retained blocks, MarkdownStream::append that parses again only the last open block, links you can click and focus, highlighted code blocks |
Cornacai's answers | L | core | — | #797 |
| 2 | A scroll that follows its end: scroll(..).follow_end() and scroll_virtual(..).follow_end(), which stay at the bottom as content grows until the reader scrolls up |
Cornacai's transcript; logs; chats | S | core | — | #798 |
| 3 | Diff view node: rs-rich-ext's DiffView as an interactive node with hunks (move between them, fold, accept or reject each, unified or side by side) |
Cornacai's edit review | M | core | — | #799 |
| 4 | Prompt editor features on text_area: .complete_at(trigger, ..) (@ mentions with a fuzzy pop-up), .history, .grow(min, max) rows, which key submits (Enter submits, Shift+Enter or Alt+Enter adds a line) |
Cornacai's prompt | M | core (a files completer is the files plugin's) |
— | #800 |
| 5 | A theme system: semantic colours (primary, secondary, accent, surface, panel, boost, error, warning, success, foreground, background) with generated shades; built-in themes taken from Textual's palettes, with credit; a "Theme" command | Cornacai's themes; every app | M | core | 7 for the command | #801 |
| 6 | Renderables in a Log: Log::write(renderable) (a Table, Syntax, Pretty, a diff), measured to the width, kept with the capacity |
Cornacai's tool output | S | core | — | #802 |
| 7 | Command palette providers and system commands: a Provider trait (discover, search, run off the app's thread), matches highlighted, and Theme, Keys, Maximize, Screenshot and Quit built in |
Cornacai's / commands; discoverability |
M | core | 5, 10, 13 for their commands | #803 |
| 8 | Tool-call cards: collapsible with a title that follows state, a status slot (spinner, ✓, ✗) and a one-line summary when closed |
Cornacai's tool calls | S | core | — | #804 |
| 9 | MarkdownViewer: a table of contents, link-following with back and forward, anchors, # jumps |
Cornacai's docs and plans; any reader | M | core | 1 | #805 |
| 10 | User keymaps: binding ids, App::keymap(..) and App::keymap_file(..) to remap by id; the help, the palette and key_hints show the remapped keys |
every app; accessibility | M | core | — | #806 |
| 11 | Maximize the focused pane: .maximizable(), cx.maximize() and cx.minimize(), Esc to restore |
Cornacai's diff and shell panes | S | core | — | #807 |
| 12 | Screen modes and screen results: App::mode(name, build), cx.switch_mode(name) (stacks kept apart); cx.push_for::<T>(build, then) and cx.dismiss(value) |
Cornacai's sessions; wizards | S | core | — | #808 |
| 13 | Screenshots and SVG snapshots: Screen::to_svg(), cx.screenshot(path), assert_screen_svg! with an HTML report of what differs |
docs, bug reports, visual tests | M | core | — | #809 |
| 14 | Pilot-style test helpers: driver.click("name"), double_click, hover, click_at, scroll_on, wait_for_animations |
tests | S | core | — | #810 |
| 15 | Lists of nodes: list_of(len, \|i\| Node, selected), whose rows are any nodes (cards, rows with controls), with option separators |
Cornacai's session list; settings pages | M | core | — | #811 |
| 16 | Stylesheet coverage: >, & nesting, $var: value;, structural and theme pseudo-classes, margin, offset, visibility, opacity and tint, outline, scrollbar styles |
apps styled by sheets | L | core | — | #812 |
| 17 | Small widgets: link, progress_bar (percentage, ETA, pulse), header (title, subtitle, clock), key_panel (docked), masked_input, the layout placeholder |
parity; app chrome | M (S each) | core | — | #813 |
| 18 | Small framework gaps: Textual's full set of easings, fixed table rows, text-area bracket matching and editor themes, files::dir_tree |
parity | S each | core; dir_tree plugin (files) |
— | #814 |
| 19 | A log console in a second terminal: INTUITUIVE_CONSOLE sends tracing events and cx.log to rich console |
debugging full-screen apps | S | plugin (tracing) and the CLI |
— | #815 |
Cornacai needs 1 to 9 and 11, 12 and 15. Items 1, 2, 4 and 8 are its first
milestone ("a conversation view with streaming Markdown; the prompt editor
with @ files; permission prompts; a diff view"). Permission prompts are
already covered by cx.confirm and pick.
Not filed¶
- Message classes and
@onhandlers. intuiTUIve uses typed closures on nodes, and signals, not messages dispatched by class. Copying Textual's message pump would give two ways to do the same thing. Welcome. This is a demo splash for Textual's own command.- Textual's helper commands (
textual colors,keys,borders,easing). TherichCLI and the docs' tapes do this job. A keys tester might go intorichlater. It is not a framework gap.
Where intuiTUIve goes beyond Textual¶
To be fair, the comparison runs both ways. These have no Textual counterpart, or only a smaller one:
- Pixels, sprites and images:
pixels(an RGBA buffer in half blocks, quadrants or Braille),sprite::stagewith collisions,imagein Kitty, Sixel or iTerm2 graphics (art), sprite sheets, micro assets,fxeffects (fades, dissolve, sweep, glitch, rain),big_text,treemap. - Audio and visualisation: PCM in, capture, playback and WAV (
pcm,audio,wav);meter,spectrogram,bar_spectrum,persistence,tuner(viz). - Editing: Vim mode (
vim), Emacs keys, a find bar with regex, a Vim-stylecommand_linewith completion and history. - The loop and timing:
lockstepandstepped_workerfor deterministic thread-fed apps,timedemoand frame stats, paint strategies, synchronized output, held keys and key releases (the kitty keyboard protocol),fixed_step. - Interop and hosting: a
Driverfor a loop of your own, ratatui interop both ways (rs-rich-ratatui,examples/in_ratatui.rs), a terminal pane and a web view (rs-rich-embed), Python bindings (rs-rich-py). - Accessibility: roles, states, announcements and a linear text mode for screen readers.
- The inspector: the node tree, rules, timings, an event trace, and a redraw flash.
- Responsive rules:
@mediain stylesheets,hide_below. - Components Textual lacks: menu bars, drop-downs and context menus;
drag-and-drop; draggable split panes; which-key pop-ups and key
sequences; Miller columns (
miller_of); a streaming fuzzy finder (finder_of); a jobs panel with cancel; calendars, time and date pickers;hex_view; a query language for filters (query); a state and frecency store;scroll_virtualwith measured heights.