Skip to main content

genro-textual

Declarative Terminal UI framework built on Textual and powered by genro-builders.

Define your UI as a "recipe" — widgets, CSS, key bindings, data binding — all as Bag nodes. The compiler transforms the recipe into a live Textual app with reactive data binding.

Status: Pre-Alpha — Active development.

Documentation

Installation

pip install genro-textual

Quick Start

from genro_textual import TextualApp

class Application(TextualApp):
    def recipe(self, page):
        page.binding(key="q", action="quit", description="Quit")
        page.static("^greeting")
        page.input(value="^form.name", placeholder="Your name")
        page.button("OK")

    def setup(self):
        self.data["greeting"] = "Hello, World!"
        self.data["form.name"] = ""
        super().setup()

if __name__ == "__main__":
    Application().run()

Type a name in the input, press Tab — the greeting doesn't change (it's on a different path), but the data Bag updates. Bind the Static and Input to the same path to see reactive updates.

Architecture

genro-textual follows the puppeteer/puppet pattern:

  • TextualApp (the puppeteer) — configures recipe, data, compiler
  • LiveApp (the puppet) — a bare textual.app.App driven by the puppeteer
  • CompiledBag — the script, kept in sync by the BindingManager
┌──────────────┐     compile     ┌──────────────┐     render     ┌──────────────┐
│  Source Bag   │ ──────────────► │ Compiled Bag │ ─────────────► │   LiveApp    │
│  (recipe)     │                 │ (expanded,   │                │  (Textual)   │
│              │                 │  resolved)   │                │              │
└──────────────┘                 └──────────────┘                └──────────────┘
       ▲                                │                               │
       │                         BindingManager                         │
       │                         (data → widget)                        │
       │                                                                │
       │                         blur / change                          │
       └─────────────────────── (widget → data) ───────────────────────┘

Data Binding

Read: Data to Widget

Bind widget values to data using ^path syntax:

page.static("^user.name")              # value bound to data path
page.input(value="^form.email")        # attribute bound to data path

When data["user.name"] changes, the Static updates automatically.

Write: Widget to Data

  • Input — writes to data on blur (Tab, click away), not on every keystroke
  • Checkbox / Switch — writes to data on change (immediate)

The _reason mechanism prevents infinite loops: when a widget writes to data, the BindingManager skips updating that same widget.

Bidirectional Example

class Application(TextualApp):
    def recipe(self, page):
        page.binding(key="q", action="quit", description="Quit")
        page.input(value="^form.name", placeholder="Name")
        page.input(value="^form.surname", placeholder="Surname")
        page.static("^form.name")       # updates when Input blurs
        page.static("^form.surname")
        page.button("OK")

    def setup(self):
        self.data["form.name"] = "John"
        self.data["form.surname"] = "Doe"
        super().setup()

CSS

Inline Stylesheets

CSS in the recipe, with Textual theme variables:

page.css("""
    .title { color: green; text-style: bold; }
    #sidebar { width: 30; background: $surface; border-left: solid $primary; }
""")

Direct Style Attributes

CSS properties can be set directly on widgets and bound to data:

page.vertical(id="panel", width="^_system.panel.width", display="^_system.panel.display")

When data["_system.panel.width"] changes, the widget resizes. Style attributes are classified automatically at mount time:

  1. Constructor parameters → widget.__init__
  2. CSS properties → widget.styles
  3. Reactive attributes → widget.set_reactive

Note: CSS variables ($surface, $primary) work only in page.css(), not in direct attributes.

App Shell

The app_shell component provides a complete application layout with header, scrollable content area, resizable inspector drawer (Data/Source/Compiled tree tabs), and footer:

class Application(TextualApp):
    def recipe(self, page):
        shell = page.app_shell(
            title="My App",
            data_store=self.data,
            source_store=self.source,
            compiled_store=self.compiled,
        )
        shell.content.static("Hello!")
        shell.content.input(value="^form.name", placeholder="Name")

    def setup(self):
        self._init_shell_data()
        self.data["form.name"] = "John"
        super().setup()

The content slot is a named insertion point — widgets added to shell.content are mounted inside the scrollable content area. Press F12 to toggle the inspector drawer.

app_shell is defined in FoundationMixin, included in TextualBuilder by default. To exclude it, compose your own builder without the mixin.

Key Bindings

page.binding(key="q", action="quit", description="Quit")
page.binding(key="f12", action="toggle_drawer", description="Inspector")

Bindings appear in the Footer and are clickable.

Components

Reusable UI blocks defined with @component in mixin classes. Component mixins live in genro_textual.components.

Simple component (no slots)

from genro_builders.builder import component
from genro_textual import TextualApp, TextualBuilder

class MyMixin:
    @component(sub_tags="")
    def login_form(self, comp, title="Login", **kwargs):
        comp.static(title)
        comp.input(placeholder="Username", value="^.username")
        comp.input(placeholder="Password", value="^.password")
        comp.button("Submit", variant="primary")

class MyBuilder(MyMixin, TextualBuilder):
    pass

class Application(TextualApp):
    builder_class = MyBuilder

    def recipe(self, page):
        page.login_form(title="Sign In")

Component with named slots

Components can declare named slots — insertion points where the caller adds content:

class DashboardMixin:
    @component(sub_tags="*", slots=["left", "right"])
    def dashboard(self, comp, title="", **kwargs):
        comp.static(title)
        main = comp.horizontal()
        left_node = main.vertical(id="left-panel")
        right_node = main.vertical(id="right-panel")
        return {"left": left_node, "right": right_node}

class MyBuilder(DashboardMixin, TextualBuilder):
    pass

Usage in recipe:

def recipe(self, page):
    dash = page.dashboard(title="Overview")
    dash.left.tree(label="nav", store=self.data)
    dash.right.static("Main content")

The handler body returns a dict mapping slot names to destination nodes. Content added to slots at recipe time is mounted into those nodes at compile time.

Live REPL

Connect to a running app and modify it in real-time:

# Terminal 1: Start the app
pygui run examples/complex_app.py

# Terminal 2: Connect to it
pygui connect complex_app
>>> app.data["form.name"] = "New value"
>>> app.page.static("Added from REPL!")

CLI Reference

Command Description
pygui run FILE.py Run a TextualApp
pygui run -r FILE.py Run with auto-reload (watches for file changes)
pygui run -c FILE.py Run and connect REPL in tmux split
pygui list List registered running apps
pygui connect NAME Connect REPL to a running app
pygui stop NAME Stop a running app
pygui completions zsh Generate shell completions

Supported Widgets

genro-textual supports 60+ Textual elements:

Containers

container, vertical, horizontal, center, middle, grid, verticalscroll, horizontalscroll, scrollablecontainer, verticalgroup, horizontalgroup, itemgrid

Input Widgets

button, checkbox, input, maskedinput, switch, select, selectionlist, optionlist, radiobutton, radioset, textarea

Display Widgets

static, label, link, header, footer, rule, markdown, markdownviewer, richlog, log, pretty, digits, sparkline, progressbar, placeholder, loadingindicator, welcome

Complex Widgets

tabbedcontent, tabpane, tabs, tab, datatable (with column, row), tree (with store), directorytree, listview, listitem, collapsible, contentswitcher, helppanel, keypanel

App Configuration

css, binding

Components

fieldset, form, app_shell (with content slot)

License

Apache License 2.0 — See LICENSE for details.

Copyright 2025 Softwell S.r.l.

Metadata

Release files for genro-textual 0.4.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for genro-textual 0.4.0
File Size Uploaded
genro_textual-0.4.0.tar.gz 62.3 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for genro-textual 0.4.0
File Interpreter ABI Platform
genro_textual-0.4.0-py3-none-any.whl Python 3 none any Details

Total release size: 94.6 kB

Release files / genro_textual-0.4.0.tar.gz

Download URL genro_textual-0.4.0.tar.gz
Size 62.3 kB
Tags Source
SHA-256 checksum
How to use checksums
e8e099250de6ff1824829dd1957d79479305147f7a806ffa18331826bbf6a4a9
BLAKE2b-256 checksum
How to use checksums
b21e63f5bd9bd63bb1cb47a239be568dc0863d8b01d9094966c6cc8c18dd3533
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/6.1.0 CPython/3.13.7

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Mar 25, 2026.

Transparency log

Release files / genro_textual-0.4.0-py3-none-any.whl

Download URL genro_textual-0.4.0-py3-none-any.whl
Size 32.4 kB
Tags Python 3
SHA-256 checksum
How to use checksums
c391ae23cc5b5ca3fdf707cf1b885e7de34896c74912d5f6a55f8f8d8d0fb202
BLAKE2b-256 checksum
How to use checksums
772f3747233cfcfc0060026702b27a3ad177c98938083a686beac7db4b07f63d
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/6.1.0 CPython/3.13.7

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Mar 25, 2026.

Transparency log

Release history Release notifications | RSS feed

This release

0.4.0 This release

2 release files

0.3.0

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page