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.
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.Appdriven 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:
- Constructor parameters →
widget.__init__ - CSS properties →
widget.styles - 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)
| File | Size | Uploaded | |
|---|---|---|---|
| genro_textual-0.4.0.tar.gz | 62.3 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| 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 logRelease 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