Skip to content

Defining Forms

A form is a Python class that inherits from Form. Fields are declared as class-level attributes. The metaclass collects them, preserves declaration order, and attaches the runtime machinery needed for validation and rendering.

Class declaration

my_forms.py
from textual_wtf import Form, StringField, IntegerField, BooleanField, ChoiceField

class UserForm(Form):
    username = StringField("Username", required=True, max_length=30)
    email    = StringField("Email", required=True)
    age      = IntegerField("Age", minimum=0, maximum=120)
    role     = ChoiceField(
        "Role",
        choices=[("Admin", "admin"), ("Editor", "editor"), ("Viewer", "viewer")],
    )
    active   = BooleanField("Active account")

No __init__ needed

You never write __init__ on a form class. The Form base class handles everything. Add class attributes (title, label_style, etc.) and field declarations — nothing else is required.

Field ordering

Fields appear in the layout in the exact order they are declared in the class body. This is guaranteed by the metaclass, which collects field definitions into a plain dict (insertion-ordered in Python 3.7+).

When you access form.bound_fields you get back a dict[str, BoundField] with the same order.

Inheritance

Fields are inherited from base classes. Subclass fields appear after the parent's fields:

class BaseProfileForm(Form):
    name  = StringField("Name", required=True)
    email = StringField("Email", required=True)

class ExtendedProfileForm(BaseProfileForm):
    bio  = TextField("Bio")        # appears after name and email
    role = ChoiceField("Role", choices=[("Admin", "admin"), ("User", "user")])

Name conflicts

Declaring a field with the same name as an inherited field raises FormError at class definition time, not at runtime.

Class attributes

Form behaviour is configured through class attributes. Each can be overridden per-instance in the constructor.

title: str = ""
Text rendered as a bold heading above the fields when using DefaultFormLayout. Has no effect on the validation or data pipeline. Also used as the tab label in TabbedForm.
label_style: LabelStyle = "above"
Default label style for every field that does not set its own. One of "above", "beside", or "placeholder". See the Layout guide for details.
help_style: HelpStyle = "below"
Default help-text style for every field that does not set its own. Either "below" (shown below the input) or "tooltip" (shown on hover). See Layout guide.
required: bool | None = None
Form-level default for the required flag. None means "do not override field-level defaults". See the required cascade below.
layout_class: type[FormLayout] | None = None
Override the layout class used by layout(). When None (the default), DefaultFormLayout is used.
my_forms.py
from textual_wtf import Form, StringField, TextField

class SupportTicketForm(Form):
    title        = "Submit a Support Ticket"
    label_style  = "beside"
    help_style   = "tooltip"
    required     = True          # all fields required by default

    subject = StringField("Subject", max_length=100)
    body    = TextField("Description", help_text="Describe the issue in detail.")

The required cascade

The required flag can be set at four levels. Lower levels are overridden by higher levels. Field-level explicit settings are the exception — they are pinned and cannot be changed by any higher level.

Level Where Priority Example
1 — Field explicit StringField("X", required=True) Pinned — never overridden Always required, regardless of form settings
2 — Class attribute class MyForm(Form): required = True Second Default for all non-explicit fields on this class
3 — Instance kwarg MyForm(required=False) Third Overrides class attribute for this instance
4 — Render time bf.simple_layout(required=True) Highest — render wins Overrides everything for this one rendering call

Pinned fields

A field that sets required=True or required=False explicitly is pinned. Embedding that form with required=False will not make a pinned field optional. This lets you protect critical fields (such as an account number) from being accidentally made optional.

cascade_example.py
from textual_wtf import Form, StringField

class AddressForm(Form):
    required = True                # class-level default: all fields required

    street   = StringField("Street")               # inherits required=True
    city     = StringField("City")                 # inherits required=True
    postcode = StringField("Postcode", required=True)  # pinned: always required


class OrderForm(Form):
    # billing: inherits class-level required=True from AddressForm
    billing  = AddressForm()

    # shipping: instance kwarg overrides class attr → all shipping fields optional
    # (except postcode, which is pinned at the field level)
    shipping = AddressForm(required=False)

Creating form instances

Instantiate a form anywhere you need one — typically at the start of compose():

form = UserForm()

Pass data= to pre-populate fields:

record = {"username": "alice", "age": 28, "active": True}
form = UserForm(data=record)

Override class attributes per-instance:

form = UserForm(
    label_style="beside",
    help_style="tooltip",
    required=False,
    title="Edit Profile",
)

BaseForm.__init__ accepts:

data: dict[str, Any] | None = None
Initial field values. Keys should match field names.
label_style: LabelStyle | None = None
Override the form-wide label style.
help_style: HelpStyle | None = None
Override the form-wide help style.
required: bool | None = None
Override the form-wide required default.
title: str | None = None
Override the form title.

Accessing fields

After instantiation, each field is available as a BoundField attribute:

form = UserForm()

bf = form.username        # BoundField for the username field
print(bf.label)           # "Username"
print(bf.required)        # True
print(bf.value)           # None (no data supplied)

form.bound_fields returns the full dict[str, BoundField]:

for name, bf in form.bound_fields.items():
    print(f"{name}: label={bf.label!r}, required={bf.required}")

Getting and setting data

get_data() returns a snapshot of all current field values as a plain dictionary:

data = form.get_data()
# {"username": "alice", "age": 28, "role": "editor", "active": True}

set_data() updates named fields without touching the others:

form.set_data({"username": "bob", "age": 35})

After validation

Always call form.clean() or form.validate() before calling get_data() if you want to be sure the data is valid. get_data() returns the current values regardless of error state.

Render lifecycle

A form instance is single-use per layout. Once layout() has been called (or a BoundField has been rendered via simple_layout() or bf()), that field is marked as rendered and cannot be composed a second time. To display the same form data again (for example, after a navigation event), create a new form instance:

old_data = form.get_data()
form = UserForm(data=old_data)
yield form.layout()