· 6 min read ·

The Architecture Spectrum That Determines How Designable Terminal UIs Are

Source: hackernews

The terminal UI development ecosystem spans a wider range of abstraction levels than most developers realize, from raw cursor manipulation libraries to frameworks that compile CSS layout rules against a character cell grid. That range matters enormously for a tool like TUI Studio, which provides visual design tooling for terminal applications. Not all TUI frameworks are equally amenable to visual representation, and examining the differences illuminates both how these frameworks are structured and where the ecosystem might be heading.

The Lowest Level: Why ncurses Is Undesignable

ncurses remains the bedrock of terminal UI programming despite being decades old. It is a C library that provides cursor positioning, color management, keyboard input handling, and basic window primitives. The API is imperative: you move the cursor to a coordinate and write characters there.

#include <ncurses.h>

int main() {
    initscr();
    start_color();
    init_pair(1, COLOR_GREEN, COLOR_BLACK);
    attron(COLOR_PAIR(1));
    mvprintw(5, 10, "Hello from ncurses");
    attroff(COLOR_PAIR(1));
    box(stdscr, 0, 0);
    refresh();
    getch();
    endwin();
    return 0;
}

This is completely correct terminal UI code, and it produces visible output, but there is no layout model, no widget tree, no retained structure that a visual tool could inspect or manipulate. You have mvprintw(5, 10, ...) and coordinates. A visual designer would need to parse program output and reverse-engineer spatial relationships from rendered characters, which is both fragile and useless for code generation. A code generator produces layout descriptions; ncurses has no layout to describe.

Programs like vim, htop, and nmtui continue to use ncurses and will for a long time, but any visual TUI design tool that tried to support ncurses would be generating coordinate assignments rather than layout abstractions, which provides no value over writing the code directly.

Immediate Mode: Ratatui’s Tradeoffs

Ratatui chose the immediate-mode rendering model, which makes it both fast and difficult to introspect. Every frame, the application calls rendering functions that write directly to a frame buffer. There is no persistent widget tree between frames.

The constraint-based layout system is expressive and generates clean, resize-aware code:

let chunks = Layout::default()
    .direction(Direction::Vertical)
    .constraints([
        Constraint::Length(3),   // fixed-height header
        Constraint::Fill(1),     // flexible body
        Constraint::Length(1),   // status bar
    ])
    .split(frame.area());

let [left, right] = Layout::horizontal([
    Constraint::Percentage(30),
    Constraint::Fill(1),
]).areas(chunks[1]);

For a design tool, the encouraging part is that this constraint tree is a pure data structure describing spatial division before any widget renders into it. A code generator that produces nested Layout::split() calls from a visual layout has a structured, well-defined target format. The challenge is that the lack of a retained object model means runtime inspection requires instrumenting the rendering pipeline separately. Ratatui’s existing tooling has focused on testing utilities: the TestBackend captures rendered output as a buffer you can assert against, which serves automated testing well but does not help with interactive visual layout work.

Visual to code generation for Ratatui is tractable. Code to visual, and live devtools-style editing, require solving a harder problem about how to reconstruct a layout model from rendered output with no intermediate representation.

String Composition and Its Limits

Lipgloss, the styling layer for Go’s Bubble Tea ecosystem, sits at an interesting point in the abstraction spectrum. It operates on styled strings with properties like borders, padding, and alignment, but layout is expressed as string composition operations rather than a retained tree.

The model has practical appeal: call style.Render(content) to apply styling, then lipgloss.JoinHorizontal() or lipgloss.JoinVertical() to compose regions into a final string ready to print. For a visual designer, this composition tree is reconstructible from a description of which regions exist, their dimensions, and their styling. It is a tree of rectangles with content and style metadata, not a complex object graph.

The limitation is that Lipgloss’s layout exists only as Go function calls. There is no serializable intermediate representation. Round-tripping from visual to code to visual requires the tool to maintain its own separate description of the layout alongside the generated code, since it cannot parse Go source to reconstruct the layout model. This creates a two-source-of-truth problem: the visual tool’s layout description and the generated code can diverge the moment a developer edits the code manually, and there is no mechanism to reconcile them.

Textual’s CSS Model: Near the Designable End

Textual made a deliberate choice to model its layout system after CSS, and that choice pays dividends for tooling. Textual CSS (TCSS) is a separate file format parsed at application load time. The component tree is retained between renders. Both facts matter for a visual designer.

When a Textual application composes its widget tree in a compose() method and describes layout in a .tcss file, the design tool’s job becomes: generate a compose() method that instantiates widgets, and generate a .tcss file that positions them. Both outputs are declarative text files with clearly defined semantics:

class DashboardApp(App):
    CSS = """
    Screen {
        layout: grid;
        grid-size: 2 3;
        grid-rows: 1fr 3fr 1fr;
    }
    Header { column-span: 2; height: 3; }
    Footer { column-span: 2; height: 1; }
    """

    def compose(self) -> ComposeResult:
        yield Header()
        yield Sidebar()
        yield MainContent()
        yield Footer()

Textual already ships a built-in DOM inspector accessible via ctrl+backslash in any running application. It shows the widget tree, computed styles, and a live style editor. This is already more tooling than Ratatui or Bubble Tea have in their ecosystems. TUI Studio’s value proposition for Textual users is primarily in the initial design phase, before there is a running application to inspect. For Ratatui users, it provides visual feedback that is otherwise entirely absent from the workflow.

Ink: When the Terminal Gets Actual CSS Flexbox

Ink occupies a category of its own because it applies genuine CSS flexbox semantics to terminal rendering. It runs React components in a terminal, with layout provided by Yoga, the same cross-platform flexbox engine used in React Native. The flexbox properties are not a metaphor for how the terminal works; they are resolved by the same engine Facebook uses for mobile layout, then mapped to character cell coordinates.

import {Box, Text} from 'ink';

const Dashboard = () => (
    <Box flexDirection="column" width={80} height={24}>
        <Box borderStyle="round" flexGrow={0} height={3}>
            <Text bold>Header</Text>
        </Box>
        <Box flexGrow={1} gap={1}>
            <Box borderStyle="single" width="30%">
                <Text>Sidebar</Text>
            </Box>
            <Box borderStyle="single" flexGrow={1}>
                <Text>Main content</Text>
            </Box>
        </Box>
        <Box height={1} borderStyle="single">
            <Text>Status bar</Text>
        </Box>
    </Box>
);

For a visual designer, this is the most natural target in the ecosystem. The layout is expressed in JSX props that map directly to flexbox properties. Any web developer who understands flexbox already understands how an Ink layout works. Generated code from a visual tool would look like JSX that any React developer could read and modify without assistance, and the source of truth problem is less severe because JSX props are directly inspectable.

The tradeoff Ink makes is runtime overhead. React and Yoga carry overhead that matters in performance-sensitive terminal applications; Ratatui’s immediate-mode model has no such cost. For internal tooling, developer CLIs, and monitoring dashboards where correctness and speed of development matter more than raw rendering performance, the overhead is acceptable. For a terminal multiplexer or an editor, it is not.

The Pattern Across the Spectrum

A clear pattern emerges across this range. Frameworks with retained widget trees and declarative layout specifications in separate, parseable formats are significantly more tractable targets for visual tooling than frameworks with immediate-mode rendering or imperative string composition. Immediate-mode rendering has real advantages for performance and architectural simplicity; Lipgloss’s string model works well in practice for Go developers. These tradeoffs are real and deliberate. But the presence of good visual tooling correlates with the availability of a declarative, inspectable layout representation.

This suggests that TUI Studio may, over time, influence which frameworks attract new developers. If a framework’s architecture makes visual tooling easier to build, and visual tooling attracts developers to that framework, there is selection pressure toward declarative retained-tree models. This is roughly the trajectory web UI development followed: from raw DOM manipulation to jQuery to virtual DOM to frameworks with statically analyzable component trees. Whether TUI development follows a similar path is not predictable, but the reception TUI Studio received on Hacker News, 425 points and 244 comments, is evidence that the demand for visual tooling is real. Frameworks that serve that demand well have a structural advantage in attracting the next wave of TUI developers.

Was this interesting?