Architecture
Puppeteer and Puppet
genro-textual follows a strict separation between configuration and execution:
TextualApp (puppeteer) — extends
BuilderManager. Owns main, data, builder, build, binding. Creates and drives the LiveApp.LiveApp (puppet) — extends
textual.app.App. No logic of its own. Built and controlled by the puppeteer.Built Bag — the script. Produced by the build step, kept in sync by BindingManager.
Pipeline
graph TD
R[main] --> SB[Source Bag]
SB -->|build| CB[Built Bag]
CB -->|render| WT[Widget Tree]
DB[Data Bag] -->|"binding (data → widget)"| CB
WT -->|"blur/change (widget → data)"| DB
Single-Pass Build
The build step walks the source Bag once. For each node:
Clean subscription map for this subtree
Expand component if resolver present
Create node in Built Bag
Resolve
^pointersagainst data and register in subscription mapRecurse into children
Subscription Map
Flat str → list[str]:
{
"form.name": ["horizontal_0.verticalscroll_0.input_0?value",
"horizontal_0.verticalscroll_0.static_2"],
}
Key: data path. Value: built node paths (with optional ?attr suffix).
Modules
textual_builder.py
TextualWidgetsMixin + TextualBuilder(TextualWidgetsMixin, BagBuilderBase)
All @element and @component definitions live in the mixin for inheritance via MRO.
Elements include:
Containers: vertical, horizontal, grid, center, etc.
Widgets: static, button, input, checkbox, datatable, tree (with store), etc.
App config: css, binding (not rendered as widgets)
Components: fieldset, form (expanded at build time)
textual_compiler.py
TextualCompiler(BagCompilerBase)
Inherits build() from base (single-pass: expand + resolve + register).
Defines its own _do_render(built_bag, live_app):
Extract
cssnodes → apply tolive_app.stylesheetExtract
bindingnodes → calllive_app.bind()Render remaining nodes as Textual widgets via
_render_node()
Dispatch: _render_<tag> methods for special widgets (tabbedcontent, datatable, static, tree), _render_default for generic widgets via _meta (module + class).
Attribute classification at mount: for each node attribute:
Constructor parameter →
widget.__init__CSS property →
widget.stylesReactive attribute →
widget.set_reactive
Store binding: when a widget has a store attribute (a Bag), _mount subscribes to the Bag for reactive updates. The Tree widget repopulates from the Bag on changes, preserving expanded state.
textual_app.py
TextualApp(BuilderManager) — the puppeteer.
sourceproperty exposes source Bag (domain name for Textual)main(source)— user override_do_render()— mounts widgets viacompiler._do_render()_on_node_updated(node)— reactive: updates specific widget. Usescall_from_threadonly when called from a different thread (remote REPL, timer)._on_widget_changed(widget, value)— bidirectional: widget → data_find_compiled_path(node)— usesBag.relative_path()to find the node path in the Built Bag_find_data_path(compiled_path)— reverse-lookup in subscription maprun()— creates LiveApp and starts Textual event loop
LiveApp(App) — the puppet.
compose()→ root Vertical containeron_mount()→owner.setup()(store + main + build + subscribe + render)Events delegated to owner: button_pressed, key, descendant_blur, input_changed, checkbox_changed, switch_changed
Data Binding
Data to Widget (^pointer)
source.static("^greeting") # value bound to data["greeting"]
source.input(value="^form.name") # attr bound to data["form.name"]
source.vertical(width="^_system.w") # CSS property bound to data path
Flow: data["greeting"] = "Hello" → BindingManager → node updated → _on_node_updated → widget updated.
Widget to Data (bidirectional)
Input: writes on blur (via
on_descendant_blur), not every keystrokeCheckbox/Switch: writes on change (immediate)
Flow: user edits Input → Tab → on_descendant_blur → _on_widget_changed → data.set_item(path, value, _reason=compiled_path) → BindingManager notifies all subscribers except the originator.
Anti-Loop via _reason
When a widget writes to data, it passes its compiled path as _reason. The BindingManager skips updating the node whose path matches the reason. Other widgets bound to the same data path update normally.
Store (Bag-driven Widgets)
The store attribute on a widget receives a Bag object. At mount time:
The Bag reference is saved on the widget (
widget._store)A subscription is registered on the Bag
When the Bag changes, the widget repopulates (e.g., Tree clears and rebuilds, preserving expanded state)
source.tree(label="data", store=self.data)
System Data
Use _system prefix for infrastructure data (drawer state, inspector config) to separate from application data:
self.data["_system.drawer.width"] = 40
self.data["_system.drawer.display"] = "block"
Extending the Builder
Use mixins to add custom elements:
class MyMixin:
@component(sub_tags="")
def login_form(self, comp, **kwargs):
comp.input(placeholder="Username")
comp.button("Login")
class MyBuilder(MyMixin, TextualBuilder):
pass
class MyApp(TextualApp):
builder_class = MyBuilder
The mixin pattern works because _pop_decorated_methods collects decorators from non-BagBuilderBase bases in the MRO.