Declarative forms library for Textual TUI applications
Project description
textual-wtf
Declarative, validated forms for Textual TUI applications.
Write a Python class; get a fully-featured form — rendered, validated, and data-bound — with no boilerplate.
📖 Full documentation, guides and API reference: holdenweb.com/textual-wtf
Installation
pip install textual-wtf
Try the demo
uvx textual-wtf # before installation
textual-wtf # if installed in an active venv
python -m textual_wtf.examples # from a source checkout
Quick start
Define a form class, call .layout() to render it, and handle the Submitted message:
from textual.app import App, ComposeResult
from textual_wtf import Form, StringField, IntegerField, BooleanField
from textual_wtf.forms import BaseForm
class ContactForm(Form):
name = StringField("Name", required=True, max_length=80)
age = IntegerField("Age", min_value=0, max_value=150)
newsletter = BooleanField("Subscribe to newsletter")
class MyApp(App):
def compose(self) -> ComposeResult:
self.form = ContactForm()
yield self.form.layout()
# Handler name comes from BaseForm.Submitted — Form inherits it unchanged.
def on_base_form_submitted(self, event: BaseForm.Submitted) -> None:
data = event.form.get_data() # {"name": "…", "age": 42, "newsletter": False}
self.notify(f"Received: {data}")
def on_base_form_cancelled(self, event: BaseForm.Cancelled) -> None:
self.exit()
if __name__ == "__main__":
MyApp().run()
Enter submits; Escape cancels. Validation runs automatically: required fields
and range/length constraints are checked on blur and on submit, and any field with an
error displays its message directly beneath the input.
Field types
| Class | Textual widget | Key kwargs |
|---|---|---|
StringField |
Input |
required, min_length, max_length |
IntegerField |
Input (digits only) |
required, min_value, max_value |
BooleanField |
Checkbox |
— |
ChoiceField |
Select |
choices |
TextField |
TextArea |
max_length |
All fields accept help_text= and a validators= list. Extra keyword arguments
are forwarded directly to the underlying Textual widget — for example
StringField("Password", password=True) gives you a masked input.
Label and help-text styles
Control where labels and help text appear at form-class, form-instance, field, or per-render-call level:
class CompactForm(Form):
label_style = "placeholder" # "above" (default) | "beside" | "placeholder"
help_style = "tooltip" # "below" (default) | "tooltip"
username = StringField("Username", help_text="3–30 characters")
email = StringField("Email", help_text="Used for notifications")
"placeholder" folds the label into the Input placeholder — saving a full row per
text field. "tooltip" moves help text off the screen and onto a hover tooltip.
Embedded sub-forms
Assign a Form instance as a class attribute and its fields are flattened into the
parent with a prefix:
class AddressForm(Form):
street = StringField("Street", required=True)
city = StringField("City", required=True)
class OrderForm(Form):
billing = AddressForm()
shipping = AddressForm(required=False)
notes = TextField("Notes")
Fields become billing_street, billing_city, shipping_street, … and
get_data() / set_data() work on the merged flat namespace.
Cross-field validation
Override clean_form() for validation that spans more than one field:
class PasswordChangeForm(Form):
current = StringField("Current password", required=True, password=True)
new = StringField("New password", required=True, password=True)
confirm = StringField("Confirm", required=True, password=True)
def clean_form(self) -> bool:
if self.new.value == self.current.value:
self.add_error("new", "New password must differ from current")
return False
if self.new.value != self.confirm.value:
self.add_error("confirm", "Passwords do not match")
return False
return True
add_error(field_name, message) attaches the error to the named field and marks it
visible in the UI.
Multi-tab forms
from textual_wtf import TabbedForm
class SettingsScreen(Screen):
def compose(self) -> ComposeResult:
yield TabbedForm(ProfileForm(), PreferencesForm(), AccessibilityForm())
Tab labels turn red when a tab contains a validation error, guiding the user to the problem without them having to click through every tab.
Custom layouts
Subclass ControllerAwareLayout and override compose(). Access the current form
as self.form; use bf.simple_layout() for the full label + input + error chrome,
or call bf() for the raw Textual widget alone:
from textual.containers import Horizontal
from textual.widgets import Button
from textual_wtf import ControllerAwareLayout
class TwoColumnLayout(ControllerAwareLayout):
def compose(self):
with Horizontal():
yield self.form.first_name.simple_layout()
yield self.form.last_name.simple_layout()
yield self.form.email.simple_layout()
with Horizontal(id="buttons"):
yield Button("Submit", id="submit", variant="primary")
yield Button("Cancel", id="cancel")
Custom validators
Subclass Validator and raise ValidationError when the value is invalid:
from textual_wtf.validators import Validator, ValidationError
class StrongPassword(Validator):
def validate(self, value: str) -> None:
if not any(c.isdigit() for c in value):
raise ValidationError("Must contain at least one digit")
if not any(c.isupper() for c in value):
raise ValidationError("Must contain at least one uppercase letter")
class SignupForm(Form):
password = StringField("Password", required=True,
validators=[StrongPassword()])
Plain functions also work — wrap them in validators=[my_function] and they are
promoted to FunctionValidator automatically.
Development setup
git clone https://github.com/holdenweb/textual-wtf
cd textual-wtf
uv sync
uv run pytest
📖 Guides · API reference · how-to recipes: holdenweb.com/textual-wtf
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file textual_wtf-0.1.2.tar.gz.
File metadata
- Download URL: textual_wtf-0.1.2.tar.gz
- Upload date:
- Size: 198.5 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: uv/0.10.7 {"installer":{"name":"uv","version":"0.10.7","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"macOS","version":null,"id":null,"libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
8b05c1bb23a65be3248b1ca451510d038310fe9386ee35d10821b758d731a740
|
|
| MD5 |
58a293841d0e2a2c809dfd7d8f8afd74
|
|
| BLAKE2b-256 |
5af97ee677641a7ae2889f0a0390868a4ac05ba0b073292d689027348d93d22d
|
File details
Details for the file textual_wtf-0.1.2-py3-none-any.whl.
File metadata
- Download URL: textual_wtf-0.1.2-py3-none-any.whl
- Upload date:
- Size: 45.6 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: uv/0.10.7 {"installer":{"name":"uv","version":"0.10.7","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"macOS","version":null,"id":null,"libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
ef7625f70ca3fe16023cc55051ffae1b7bc10272e88c9cef685672168ba3edea
|
|
| MD5 |
5d8945ae160873697d0ba4af06d5c174
|
|
| BLAKE2b-256 |
e9bfa016ece79f5e6fb0887c43ceefce329e0fe1b4005860c595dbb513ab0570
|