Comparison
What uniview's TUI stack is missing next to ink, opentui, and Textual
uniview's TUI stack (@uniview/tui-core + @uniview/tui-react/@uniview/tui-solid) is
new. ink has been the default React-in-terminal
renderer since 2017; opentui is a newer engine with the
same React-and-Solid-on-terminal shape as uniview, backed by a native (Zig) renderer and a
Yoga layout; Textual is the most feature-complete
TUI framework in any language, and its Python API doesn't map onto React/Solid, but its
feature list is a useful checklist of what a mature TUI framework eventually grows.
This page is an honest gap list, not a sales pitch. Every claim about ink/opentui/Textual
below was checked against the checked-out source in references/ink, references/opentui,
and references/textual (or against uniview's own source in packages/) — not against
general knowledge of these projects, which drifts across versions.
| Feature | uniview | ink | opentui | Textual |
|---|
| Layout engine | ✅ Custom pure-TS flexbox (computeLayout) + a pluggable LayoutEngine seam with an opt-in yogaLayoutEngine (real yoga-layout WASM, selected via the layoutEngine root option) | Yoga (yoga-layout) | Yoga (lib/yoga.options.ts) | Custom (_layout_resolve.py, box_model.py) — DOM/CSS box model, not flexbox |
Box/container | ✅ | ✅ <Box> | ✅ <box> | ✅ Container/Vertical/Horizontal/Grid |
| Text | ✅ Text | ✅ <Text> (wraps, truncates, transforms) | ✅ <text> | ✅ Static/Label |
| Pre-styled rich text (spans in one node) | ✅ RichText | ⚠️ via nested <Text> composition only | ✅ <span>/<b>/<i>/<u>/<a> inline modifiers | ✅ Content/markup ([bold]...[/]) |
height:"100%" under flexGrow | ✅ not a bug — bringing up yogaLayoutEngine proved Yoga produces the same geometry (content-sized parent, flex-basis:auto correct); a flexBasis:0 child fills instead | ✅ (Yoga) | ✅ (Yoga) | ✅ |
| Absolute positioning | ✅ position:"absolute" + zIndex | ❌ no absolute positioning | ✅ (Yoga position: "absolute") | ✅ (layer, offset) |
<Static> (append-only, never-repainted log region) | ✅ <Static> (React + Solid) — commits finalized lines to a scrollback channel above the live frame | ✅ <Static> | ❌ | ⚠️ RichLog/Log are closer to a scrollback widget, not a static-append region |
<Spacer> (flex-grow filler) | ✅ <Spacer> (React + Solid) | ✅ <Spacer> | ❌ (same flexGrow workaround) | ❌ (same workaround) |
<Transform> (per-line output transform, e.g. gradient text) | ✅ <Transform> (React + Solid) — per-line output transform | ✅ <Transform> | ❌ | ⚠️ via custom render_line overrides |
<Newline> | ✅ <Newline> (React + Solid) | ✅ <Newline> | ✅ <br> | N/A (markup handles it) |
<Masked> (mask child text, e.g. secrets) | ✅ <Masked> (React + Solid) | ❌ | ❌ | ❌ |
| Feature | uniview | ink | opentui | Textual |
|---|
| Focus manager | ✅ FocusManager — Tab/Shift-Tab cycling, digit-jump via nextFocus | ✅ useFocusManager (.focusNext(), .focusPrevious(), .focus(id)) | ✅ (Yoga tree + KeyHandler) | ✅ App.focused, focus_next()/focus_previous() |
| Per-node input hook | ✅ useInput((input, key) => …) global hook (React + Solid), resolved host-side via InputRouter.subscribeInput; onKeyDown prop still bubbles | ✅ useInput((input, key) => …) global hook, auto-scoped to focus/raw mode | ✅ useKeyboard() hook (React), onKeyDown events | ✅ on_key handler + Binding/BINDINGS declarative keymap |
| Key bubbling model | ✅ DOM-style: focused node → nearest ancestor onKeyDown; Enter/Space activate focused node first | ⚠️ no bubbling — useInput is effectively global, ordering is registration order | ⚠️ event-based, stopPropagation() exists (see KeyHandler.stopPropagation.test.ts) but no ancestor-chain bubbling model | ✅ message bubbling through the DOM-like widget tree (bubble=True on messages) |
| Click → focus target resolution | ✅ nearest focusable ancestor of the hit node | N/A (no mouse support) | ✅ (mouse-aware, hit-tested against Yoga tree) | ✅ |
| Mouse modes | ✅ off / click / drag / motion via TerminalDriver | ❌ no mouse support at all | ✅ click/drag/move, mock-mouse.ts test harness | ✅ click, MouseMove, MouseScroll |
| Hit-testing | ✅ OwnerTable (cell → node) | N/A | ✅ Yoga-tree hit test | ✅ compositor-based (_compositor.py) |
| Kitty keyboard protocol (disambiguated key events) | ❌ | ✅ kitty-keyboard.ts, opt-in flags | ✅ parse.keypress-kitty.ts | ⚠️ partial via _keyboard_protocol.py |
| Paste event | ✅ usePaste (React + Solid) — every paste reaches subscribers host-side | ✅ usePaste | ⚠️ raw input only, no dedicated paste hook found | ✅ Paste message |
| Terminal resize event | ✅ resize in TuiInputEvent | ✅ useStdout().columns/rows + resize listener, useWindowSize | ✅ useResize, useTerminalDimensions | ✅ Resize message |
| Feature | uniview | ink | opentui | Textual |
|---|
Panel (titled/footered bordered box) | ✅ | ❌ (compose from Box + Text yourself) | ⚠️ Box supports title-ish styling but no dedicated titled-panel component | ✅ (border titles/subtitles on any widget) |
List (selection, full-row highlight, click-select) | ✅ | ❌ (userland only) | ⚠️ Select covers single-select lists; no plain scrolling list with full-row highlight as a named component | ✅ ListView/OptionList |
VirtualList (windowed rendering) | ✅ | ❌ | ⚠️ ScrollBox virtualizes via viewport culling (objects-in-viewport.ts) but there's no list-specific windowed component | ✅ DataTable and ListView virtualize rows |
Select (keyboard-navigable single-select) | ✅ | ❌ | ✅ Select, plus TabSelect for tab-strip selection | ✅ Select, RadioSet |
ScrollView | ✅ (wheel + key scroll, scrollbar, controlled/uncontrolled) + a standalone <Scrollbar> (React + Solid) extracted for reuse | ❌ | ✅ ScrollBox + ScrollBar | ✅ ScrollView base class on every scrollable widget |
Markdown | ✅ headings/lists/quotes/tables/inline emphasis/links/fenced code+highlight | ❌ | ✅ Markdown renderable + markdown-parser.ts | ✅ Markdown, MarkdownViewer (with TOC) |
Code (syntax highlighting) | ✅ via lowlight/highlight.js, line numbers, filename→language detection | ❌ | ✅ Code renderable, tree-sitter-backed (tree-sitter-styled-text.ts), LineNumberRenderable | ✅ TextArea has syntax highlighting (tree-sitter) |
Diff (unified diff viewer) | ✅ gutters, sign column, hunk headers, per-line highlight | ❌ | ✅ Diff renderable (Diff.ts, Diff.regression.test.ts) | ❌ no built-in diff widget |
StreamingMarkdown (stable/tail split for cheap re-parses of growing text) | ✅ | ❌ | ❌ (no equivalent found in Markdown.ts) | ❌ |
| Editable multi-line text area | ❌ | ❌ | ✅ Textarea — full editor: selection, undo/redo, highlights, visual-line wrapping (Textarea.*.test.ts) | ✅ TextArea — same class of feature, tree-sitter highlighting |
| Single-line text input | ✅ <TextInput> (React + Solid) — controlled, host-driven editing (TextInputMachine) with rendered caret, mask/placeholder, onChange/onSubmit | ❌ (no built-in <Input>, common to build on useInput) | ✅ Input renderable | ✅ Input widget, with Suggester/validators |
| Tables | ✅ <Table> (React + Solid) — column layout, header, virtualized rows, per-row cursor, optional sort (editable cells still a gap) | ❌ | ❌ | ✅ DataTable — sortable, editable cells, cursor modes |
| Tree view | ✅ <Tree> / <DirectoryTree> (React + Solid) over TreeMachine — expand/collapse, indent guides (│ ├─ └─), keyboard nav | ❌ | ❌ | ✅ Tree, DirectoryTree |
| Calendar / date picker | ✅ <Calendar> (React + Solid) over a clock-free CalendarMachine — month grid, selectable date, arrows = day, PgUp/PgDn = month | ❌ | ❌ | ❌ (no core calendar widget) |
| Tabs | ✅ <Tabs> (React + Solid) — tab strip + active panel, roving arrow nav (wraparound, disabled-skip) via TabsMachine | ❌ | ✅ TabSelect renderable | ✅ TabbedContent/Tabs/TabPane |
| Feature | uniview | ink | opentui | Textual |
|---|
| Bar chart / histogram | ✅ eighth-block glyphs (▁-█, 8 steps/row); grouped & stacked multi-series (BarDatum.value accepts a per-series array) | ❌ | ❌ (no chart renderables in renderables/index.ts) | ❌ (Sparkline widget only) |
| Sparkline | ✅ | ❌ | ❌ | ✅ Sparkline widget |
| Gauge | ✅ <Gauge> (centered label on bar) + <LineGauge> (ratatui-style label · bar · percent on one line) | ❌ | ❌ | ❌ |
| Chart axes (ticks, numeric labels, axis titles) | ✅ frameChart/axisTicks — Y gutter + X rule + tick labels + xTitle/yTitle, wired into <LineChart>/<Scatter> via options.axes | ❌ | ❌ | ❌ |
| Chart legend (swatch + series name, positionable) | ✅ renderLegend — horizontal/vertical, top/bottom, colored swatch, via options.legend | ❌ | ❌ | ❌ |
| Line chart / scatter plot | ✅ braille 2×4 sub-cell canvas (SubcellCanvas, 8x character-grid resolution) | ❌ | ❌ | ❌ |
| Sub-cell / braille rendering | ✅ (packages/tui-core/src/canvas/subcell.ts) | ❌ | ❌ (no braille/subcell canvas found in core/src) | ❌ |
Public <Canvas> / shapes API (ratatui Canvas) | ✅ <Canvas> (React + Solid) with a draw(cv) callback — line/rect/circle/points, braille/dot/block/half-block markers, cv.project (shared dataToPixel), optional world Map; emits styled lines (no new primitive) | ❌ | ❌ | ❌ |
| 2D/3D scene rendering, sprites, particles, physics | ❌ | ❌ | ✅ packages/core/src/3d/ — WebGL-in-terminal: sprites, textures, phong lighting, rapier/planck physics demos | ❌ |
| Raster images | ✅ half-block path — <Image> (React + Solid) decodes-agnostic RGBA → box-downsample → ▀ cells (fg=upper px, bg=lower px), the universal chafa-style fallback that works on any truecolor terminal, no new primitive (canvas/image.ts). ❌ graphics protocols (Kitty/Sixel/iTerm2) — would need an opaque escape-passthrough region that breaks the one-grapheme-per-cell model (deferred by design) | ⚠️ detects the Kitty keyboard protocol but has no image rendering path | ⚠️ terminal-capability-detection.ts parses Kitty graphics protocol responses (capability probing), but no image renderable was found in renderables/index.ts | ❌ no built-in image widget in core (third-party textual-image exists, out of scope here) |
| Feature | uniview | ink | opentui | Textual |
|---|
| Live ANSI terminal output | ✅ AnsiCellSurface, truecolor, partial-repaint diffing (diffFrames) | ✅ log-update.ts, ANSI diffing via string reconciliation | ✅ native (Zig) buffer + diffing (buffer.ts, renderer.ts) | ✅ Rich-based compositor, partial repaint |
| Color-depth degradation (truecolor → 256 → 16) | ✅ nearestNamedColor/rgbToAnsi256 | ✅ (via chalk/ansi-styles downstream, not in ink core itself) | ✅ terminal-palette.ts | ✅ (Rich color downsampling) |
| Headless/in-memory surface for tests | ✅ MemoryCellSurface (.text(), per-cell .cells()/styles) | ✅ renderToString | ✅ test-renderer.ts (headless renderer) | ✅ App.run_test() returns a Pilot driving an in-memory app |
| SVG/static export (for docs, screenshots) | ✅ SvgCellSurface/renderSvg | ❌ | ❌ (no SVG surface found in core/src) | ⚠️ App.export_screenshot() produces SVG |
| String-render for CI logs | ✅ (via MemoryCellSurface.text()) | ✅ renderToString | ✅ (test-renderer.ts output capture, console.ts) | ✅ (Pilot/App.run_test + export_text) |
| Feature | uniview | ink | opentui | Textual |
|---|
| Query rendered tree by role/label | ✅ semantics.ts (SemanticNode, RoleQuery) | ❌ | ❌ (no role/label query API found — tests assert on buffer content) | ✅ every widget exposes .query() (CSS-selector-like), plus explicit ARIA-ish roles are not modeled, but ids/classes are queryable |
| Programmatic drive without a real terminal | ✅ AutomationSession + automation-runner.ts | ⚠️ possible via renderToString + manual stdin.write, no first-class driver | ✅ mock-keys.ts, mock-mouse.ts, test-recorder.ts (can record and replay real sessions) | ✅ Pilot — pilot.click(), pilot.press(), pilot.hover(), async, first-class |
| Snapshot testing | ⚠️ possible via MemoryCellSurface.text() + your own snapshot tool, no built-in harness | ❌ | ✅ __snapshots__/ — dedicated snapshot test setup across renderables/, tests/ | ✅ pytest-textual-snapshot (separate package, but Textualize-maintained) |
| Session recording/replay | ❌ | ❌ | ✅ test-recorder.ts | ❌ |
| Feature | uniview | ink | opentui | Textual |
|---|
| Theme object (named colors, semantic roles) | ✅ defaultTheme/ThemeColors | ❌ (no theme concept — colors are per-prop) | ⚠️ colors are per-prop (RGBA.ts), no shared theme object found | ✅ Theme class, App.theme, built-in dark/light themes, live theme switching |
| Syntax highlighting theme | ✅ SyntaxTheme | ❌ | ✅ (syntax-style.ts) | ✅ (_text_area_theme.py) |
| CSS-like stylesheet (external, cascading, selectors) | ❌ | ❌ | ❌ (styling is inline props, no selector/cascade system found) | ✅ TCSS — DEFAULT_CSS, CSS_PATH, class/id selectors, cascading, hot-reloadable at dev time |
| Per-node style props (color, bold, border, etc.) | ✅ | ✅ | ✅ | ✅ (both TCSS and inline) |
| Feature | uniview | ink | opentui | Textual |
|---|
| Frame/tick hook for building your own animation | ✅ useAnimation() → {frame, time, delta, reset} (React + Solid) over a host-side FrameClock; a Timeline class — duration, ease, loop, alternate, onUpdate/onComplete, sequenceable via .add() | ✅ useAnimation({interval}) → {frame, time, delta, reset} | ✅ Timeline class — duration, ease, loop, alternate, onUpdate/onComplete, sequenceable via .add() | ✅ _animator.py + _easing.py — animate any reactive attribute (self.animate("opacity", 0.0)) |
| Named easing functions | ✅ ease.ts — linear/quad/cubic/sine/expo/bounce (In/Out/InOut) + resolveEasing | ❌ (bring your own math) | ✅ (easingFunctions referenced in Timeline.ts) | ✅ (_easing.py) |
| Declarative "animate this style prop" API | ✅ animate("w", to, { duration, ease }) (React + Solid) — returns the current value to drop into a node's style/props; per-frame writes are local re-renders, never per-frame RPC | ❌ | ⚠️ via Timeline targeting renderable properties, not declarative JSX props | ✅ widget.animate(attribute, value, duration=…) |
| Feature | uniview | ink | opentui | Textual |
|---|
| Screen-reader detection | ❌ | ✅ useIsScreenReaderEnabled | ❌ | ⚠️ no first-class screen-reader integration; Textual is a terminal renderer, same limitation as the others |
| Semantic role model | ✅ SemanticNode/RoleQuery — but built for automation/testing, not for actual assistive tech output | ⚠️ AccessibilityContext exists but is thin | ❌ | ⚠️ has widget-level "role"-ish concepts for query purposes only |
Terminal UIs generally have poor screen-reader story across the board — this is a weak
category for every framework compared here, not a uniview-specific gap.
| Feature | uniview | ink | opentui | Textual |
|---|
| React DevTools integration | ✅ connectReactDevTools/connectSolidDevTools — dynamically imports react-devtools-core/solid-devtools behind a devtools flag | ✅ devtools.ts — connects to react-devtools-core over WebSocket when DEV=true-equivalent | ✅ reconciler/devtools.ts — same pattern, dynamically imported when DEV=true | N/A (not a React renderer) |
| Error overlay (catch render errors, show a readable overlay instead of a crash) | ✅ ErrorBoundary+ErrorOverview (React), TuiErrorBoundary+ErrorOverview (Solid, byte-identical SVG); core withTerminalRestore/installCrashGuard restore raw mode on crash | ✅ ErrorBoundary.tsx + ErrorOverview.tsx | ✅ components/error-boundary.tsx | ✅ Textual shows a formatted traceback screen on unhandled exceptions |
| Hot reload (bundler watch, not state-preserving HMR) | ⚠️ tsdown --watch / custom --watch flag in example build scripts, plain rebuild-and-restart | ⚠️ no TUI-specific HMR found; standard bundler watch | ⚠️ lib/singleton.ts mentions surviving "Bun hot reloads" for a specific singleton, not general component HMR | ⚠️ CSS_PATH stylesheets do hot-reload at dev time (textual run --dev); component code does not |
| CLI/dev tooling package | ❌ | ❌ (ink itself has none; ink-testing-library is separate) | ✅ own scripts/, dev/ tooling per package | ✅ textual CLI — textual run, textual console, textual colors, textual keys |
- Framework-agnostic host, proven byte-identical.
@uniview/tui-react and
@uniview/tui-solid sit on the same @uniview/tui-core plus the internal host layer, and
the SVG output for the same app built in React vs. Solid is byte-identical. ink is
React-only; opentui ships separate packages/react and packages/solid bindings but
they are two independent reconcilers over the same core renderables — there's no
cross-framework output-identity guarantee comparable to what uniview's test suite
asserts.
- Sub-cell rendering for charts specifically. uniview's
LineChart/Scatter use a
braille 2×4 canvas for 8x the addressable resolution of the character grid
(packages/tui-core/src/canvas/subcell.ts). Neither ink nor opentui nor Textual ship any
chart primitives at all in their core — Textual's Sparkline is the closest, and it
doesn't use sub-cell addressing.
- SVG export as a first-class render target.
SvgCellSurface/renderSvg turns a frame
into a standalone SVG file, which is how this docs site's own screenshots
(lazygit-solid.svg, charts-react.svg, 2048-solid.svg) are generated. opentui has no
SVG surface; Textual's export_screenshot() is the nearest equivalent elsewhere.
- Automation-first semantic querying built into the core package, not bolted on as a
separate testing library.
host-tui/src/automation.ts and semantics.ts let you query
by role/label and drive the tree without a real terminal, shipped alongside the renderer
rather than as an ink-testing-library-style add-on.
Ordered by (impact on "can I build a real app") × (how cheap it is given uniview's current
architecture):
- ✅ Done (Phase 3):
useInput/useKeyboard-style hook. useInput((input, key) => …)
and usePaste ship in both tui-react and tui-solid, subscribing to the host
InputRouter directly via subscribeInput — a global hotkey is handled regardless of
focus, resolved host-side with no per-event RPC and no workaround Box at the root.
- ✅ Done (Phase 4): Bundled
<TextInput> component. <TextInput> (React + Solid) wires
the host-owned TextInputMachine to a visible controlled field with a rendered caret,
mask/placeholder, and onChange/onSubmit — the host still owns editing (no per-event
RPC on a cursor move).
- ✅ Done (Phase 4): Bundled
<Tabs> component. <Tabs> (React + Solid) renders a tab
strip + active panel with roving arrow navigation through TabsMachine. A standalone
<Scrollbar> and a <LineGauge> shipped in the same phase.
- ✅ Done (Phase 3): Error boundary + overlay.
ErrorBoundary + ErrorOverview (React)
and TuiErrorBoundary + ErrorOverview (Solid, on Solid's own ErrorBoundary) render a
readable overlay instead of a crash — the two overlays are asserted byte-identical.
Core withTerminalRestore/installCrashGuard guarantee raw mode is restored on crash.
- ✅ Done (Phase 9): Yoga layout adapter.
yogaLayoutEngine (real yoga-layout WASM)
implements the pluggable LayoutEngine seam and is selectable via the layoutEngine
root option (React + Solid). Bringing it up also settled the supposed height:"100%"
"bug": Yoga produces the same geometry as the custom engine (correct flex-basis:auto
behavior), so it was never a bug. Yoga is a stricter flexbox — it honors explicit
cross-axis sizes the custom engine stretches — so customLayoutEngine remains the
default and Yoga is opt-in, keeping the zero-dependency (Worker/Deno/Bun) path WASM-free.
- A titled/scrolling
Tree/DataTable-class widget. Textual's DataTable and Tree
cover a large slice of real TUI apps (file explorers, log tables) that uniview currently
has no answer for beyond composing List/VirtualList by hand. Medium-large effort
— needs its own selection/scroll/column-layout model, though it can reuse
VirtualList's windowing.
- Raster image support (Kitty graphics protocol). Confirmed absent in uniview, and
only capability-detected (not rendered) in opentui; genuinely absent everywhere except
third-party Textual plugins. Worth building specifically because none of the
comparison frameworks have solved it well — but it's a real terminal-capability-detection
- protocol-encoding project. Large effort — needs a new
CellSurface capability
(raw escape-sequence passthrough anchored to a cell region) plus per-terminal capability
probing (a starting point already exists in opentui's
terminal-capability-detection.ts for reference).
- ✅ Done (Phase 8): Animation/tween API. A framework-neutral
Timeline +
FrameClock + named easings (ease.ts) live in @uniview/tui-core, and useAnimation
/ animate ship in both tui-react and tui-solid over a host-side clock — per-frame
value writes are local re-renders, never per-frame RPC. The React and Solid renders of
the same animation are asserted byte-identical.
- CSS-like stylesheet system. Textual's TCSS (external file, selectors, cascade,
hot-reload) is the most structurally different feature here — neither ink nor opentui
have it either, so this is the lowest-priority item measured against the closest
competitors, but it's the biggest usability win for large apps if uniview ever wants to
compete with Textual specifically. Large effort — a real parser/cascade/selector
engine, largely orthogonal to the existing per-prop styling model.