Skip to main content

Textual UI framework for Genro Bag-driven applications

Project description

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.

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

genro_textual-0.4.0.tar.gz (62.3 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

genro_textual-0.4.0-py3-none-any.whl (32.4 kB view details)

Uploaded Python 3

File details

Details for the file genro_textual-0.4.0.tar.gz.

File metadata

  • Download URL: genro_textual-0.4.0.tar.gz
  • Upload date:
  • Size: 62.3 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.7

File hashes

Hashes for genro_textual-0.4.0.tar.gz
Algorithm Hash digest
SHA256 e8e099250de6ff1824829dd1957d79479305147f7a806ffa18331826bbf6a4a9
MD5 139bb36ca2f1fe8572b06bb9b347f832
BLAKE2b-256 b21e63f5bd9bd63bb1cb47a239be568dc0863d8b01d9094966c6cc8c18dd3533

See more details on using hashes here.

Provenance

The following attestation bundles were made for genro_textual-0.4.0.tar.gz:

Publisher: publish.yml on genropy/genro-textual

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file genro_textual-0.4.0-py3-none-any.whl.

File metadata

  • Download URL: genro_textual-0.4.0-py3-none-any.whl
  • Upload date:
  • Size: 32.4 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.7

File hashes

Hashes for genro_textual-0.4.0-py3-none-any.whl
Algorithm Hash digest
SHA256 c391ae23cc5b5ca3fdf707cf1b885e7de34896c74912d5f6a55f8f8d8d0fb202
MD5 0a3a21053e03a7b1bae186e322269662
BLAKE2b-256 772f3747233cfcfc0060026702b27a3ad177c98938083a686beac7db4b07f63d

See more details on using hashes here.

Provenance

The following attestation bundles were made for genro_textual-0.4.0-py3-none-any.whl:

Publisher: publish.yml on genropy/genro-textual

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page