Status: Design specification. Describes the target API. Still under development.
This document describes the design of a forms suystem for Textual. The user begins
by declaring a Form subclass, in which Field and embedded Form instances are assigned to class
variables. The FormMetaclass extracts these Field definitions, saving them in the fields
class variable.
When a Form instance is created, each of the fields is bound to the new instance as a BoundField. Thus the Field holds the configuration data for a field, which is the same for all instances of that Form, while the BoundField holds the field data for a specific Form instance.
class FieldImmutable declarative configuration for a single form field. Defined at class
level on a Form subclass and shared across all instances of that form.
Never holds runtime state.
Field(
label: str,
*,
initial: Any = None,
required: bool = False,
disabled: bool = False,
validators: list[Validator | Callable] = (),
help_text: str = "",
label_style: LabelStyle = "above",
help_style: HelpStyle = "below",
widget_class: type | None = None,
**widget_kwargs: Any,
)| Parameter | Description |
|---|---|
label |
Human-readable label shown in the UI. |
label_style |
How the label is presented. One of "above", "beside", "placeholder". See Constants. |
help_style |
How help text is presented. One of "below", "tooltip". |
widget_class |
The Textual widget class to instantiate for this field. If None, the subclass default is used. |
**b_field_kwargs |
Additional keyword arguments forwarded to the BoundField constructor. Those not used by the BoundField are later merged with (and overridden by) any kwargs passed to BoundField.__call__() at render time. |
def bind(
self,
form: BaseForm,
name: str,
initial: Any = None,
) -> BoundFieldReturns a BoundField for this Field within a specific form instance.
Called by BaseForm.__init__() and collected in the BoundField's field
dictionary under the field's name; not normally called directly.
All subclasses accept every parameter of Field in addition to their own.
Each is a thin wrapper around the appropriate Textual widget to provide a more
uniform interface.
class StringField(Field)Single-line text. Default widget_class: FormInput.
No additional parameters.
class IntegerField(Field)Integer numeric input. Default widget_class: FormInput with
type="integer" (blocks non-numeric keystrokes at the widget level).
| Extra parameter | Description |
|---|---|
min_value: int | None = None |
Minimum acceptable value. If set, a MinValue validator is added automatically. |
max_value: int | None = None |
Maximum acceptable value. If set, a MaxValue validator is added automatically. |
class BooleanField(Field)Boolean toggle. Default widget_class: FormCheckbox.
No additional parameters.
class ChoiceField(Field)Selection from a fixed list. Default widget_class: FormSelect.
| Extra parameter | Description |
|---|---|
choices: list[tuple[str, Any]] |
Required. List of (display_label, value) pairs. |
class TextField(Field)Multi-line text. Default widget_class: FormTextArea.
No additional parameters.
class BoundField(Container)Mutable runtime state for one field within one form instance. Also a
Textual Container widget, so it can be configured for horizontal or vertical orientations: when mounted, it composes its own
label, inner input widget, optional help text, and error display.
Created by Field.bind() during BaseForm.__init__(), which stores
them in the Form instances' bound_fields dictionary . Not instantiated
directly.
BoundField(
field: Field,
form: BaseForm,
name: str,
data: dict[str, Any]
)Not part of the public API; called only by Field.bind().
These are Textual reactive attributes. Watchers update the DOM
automatically.
| Attribute | Type | Description |
|---|---|---|
value |
Any |
Current Python value. Setting it updates the inner widget; the inner widget's watch_value watcher keeps this in sync when the user types. |
has_error |
bool |
True when the field has at least one validation error. |
error_messages |
list[str] |
Error messages accumulated during validation. Displayed in the error label inside the widget tree. |
| Property | Type | Description |
|---|---|---|
label |
str |
Human-readable label. |
default |
Any |
Default value from the Field declaration. |
required |
bool |
Whether the field is required. |
help_text |
str |
Help guidance string. |
label_style |
LabelStyle |
How the label is presented; overridable per-call via __call__. |
help_style |
HelpStyle |
How help text is presented; overridable per-call via __call__. |
validators |
list |
Field-level validators from the Field declaration. |
field |
Field |
The underlying shared Field configuration object. |
form |
BaseForm |
The owning form instance. |
name |
str |
The field name (its key in form.bound_fields). |
| Attribute | Type | Description |
|---|---|---|
disabled |
bool |
Independently mutable per instance. Initialised from field.disabled. Setting this updates the inner widget's disabled state. |
errors |
list[str] |
Current validation error messages. Populated by validate(); cleared before each validation pass. |
is_dirty |
bool |
True once the user has interacted with the field (changed its value from initial). |
Convert a Python value to the representation expected by the widget (typically a string).
def __call__(
self,
*,
label_style: LabelStyle | None = None,
help_style: HelpStyle | None = None,
disabled: bool | None = None,
validators: list | None = None,
**widget_kwargs: Any,
) -> BoundFieldConfigure this BoundField for rendering and return a fully-configured widget. Used when
composing the Form to yield the field into the widget tree.
Any keyword argument supplied here takes precedence over the corresponding
Field declaration. widget_kwargs are merged with (and override)
BoundField.widget_kwargs.
Raises FormError if this field has already been yielded in the current
layout (duplicate-render protection).
# Typical usage in compose_form():
yield self.form.name() # all defaults
yield self.form.age(disabled=True) # disable for this render
yield self.form.role(label_style="beside") # override label style
yield self.form.notes(help_style="tooltip") # override help styleReturns the BoundField's widget for inclusion in its FieldLayout object.
Convert a Python value to the representation expected by the widget (typically a string).
def validate(self) -> boolCalls widget.validate(), returning True if the field validates. Called
automatically change or blur via the on_change and on_blur watchers; also
called for every field by BaseForm.validate() on submission.
def compose(self) -> ComposeResultYields a FieldLayout object that contains the BoundField's widget subtree from the current label_style and help_style:
"above":Labelstacked above the inner widget."beside":Labeland inner widget in aHorizontal."placeholder": noLabelwidget;field.labelpassed asplaceholderto the inner widget.
Help text: visible Static below the widget ("below") or assigned to
widget.tooltip ("tooltip").
Error display: a Label with reactive binding to error_messages, hidden
when has_error is False.
Not normally overridden; control presentation via label_style and
help_style instead.
class BaseForm
class Form(BaseForm) # public aliasDeclarative base class for form definitions. FormMetaclass processes the
class body at definition time.
| Attribute | Type | Default | Description |
|---|---|---|---|
layout_class |
type[FormLayout] | None |
None (→ DefaultFormLayout) |
The FormLayout subclass used by render() when no layout_class is passed to the constructor. |
label_style |
LabelStyle |
"above" |
Default label_style for all fields in this form, unless overridden per-field in the Field declaration or per-render in BoundField.__call__(). |
help_style |
HelpStyle |
"below" |
Default help_style for all fields. |
Form(
data: dict[str, Any] | None = None,
*,
layout_class: type[FormLayout] | None = None,
label_style: str | None = None,
)| Parameter | Description |
|---|---|
data |
Optional initial data dict {field_name: value}. Values are applied as each BoundField's initial value. |
layout_class |
Overrides Form.layout_class for this instance only. |
label_style |
Overrides Form.label_style for this instance only. |
form.fieldname # returns BoundField; raises AttributeError if unknown
form.get_field(name) # equivalent; kept for backward compatibility
form.bound_fields # dict[str, BoundField], ordered by declaration
form.fields # alias for bound_fieldsFor embedded forms, unqualified names (e.g. form.street) resolve when
unambiguous across all prefixed fields; raise AmbiguousFieldError
otherwise. Qualified names (e.g. form.billing_street) always resolve
directly.
def clean(self, raw_value: Any) -> AnyFull form-level cleaning pipeline: run validate to validate each field.
Raise ValidationError after validation is complete if one or more fields
fail to validate. If validate returns True, follow that by form-specific checks
which can examine all the fields' data. Called only on submission.
Instantiate and return the FormLayout for this Form instance. Uses self._layout_class
(resolved from constructor arg → class attr → DefaultFormLayout).
The returned FormLayout is ready to mount in a Textual compose().
def validate(self) -> bool
def is_valid(self) -> bool # aliasCall bound_field.validate() for every field. Return True only if all
fields are valid, otherwise False. Populates BoundField.errors for any failing fields.
def get_data(self) -> dict[str, Any]Return {name: bound_field.value} for all fields.
def set_data(self, data: dict[str, Any]) -> NoneSet bound_field.value for each key present in data. Keys not present
in the form are ignored.
@classmethod
def embed(
cls,
prefix: str,
title: str = "",
) -> EmbeddedFormReturn an EmbeddedForm marker for use inside another form class body.
FormMetaclass expands it in place, prefixing all field names with
f"{prefix}_". Name collisions with existing fields raise FormError
at class-definition time.
class OrderForm(Form):
billing = AddressForm.embed(prefix="billing")
shipping = AddressForm.embed(prefix="shipping")
notes = TextField(label="Notes")@dataclass
class Form.Submitted(Message):
layout: FormLayout # the FormLayout that posted the message
form: BaseForm # convenience alias: layout.formPosted when the user submits the form (Submit button or Enter key).
@dataclass
class Form.Cancelled(Message):
layout: FormLayout
form: BaseFormPosted when the user cancels the form (Cancel button or Escape key).
class FormLayout(VerticalScroll)Base class for form renderers. Subclass and override compose_form() to
create custom layouts. The base class handles button events, keyboard
shortcuts, and duplicate-field protection.
FormLayout(
form: BaseForm,
id: str | None = None,
**kwargs: Any,
)**kwargs are forwarded to VerticalScroll.
def compose(self) -> ComposeResultDefine the form's visual structure. Yield BoundField widgets via the
callable interface, plus any other Textual widgets (buttons, labels,
containers) needed for the layout.
class TwoColumnLayout(FormLayout):
def compose_form(self):
with Horizontal():
yield self.form.first_name(label_style="above")
yield self.form.last_name(label_style="above")
yield self.form.email()
yield self.form.notes(help_style="tooltip")
with Horizontal(id="buttons"):
yield Button("Submit", id="submit", variant="primary")
yield Button("Cancel", id="cancel")Each BoundField may only be yielded once per layout; a second yield
raises FormError.
| Attribute | Type | Description |
|---|---|---|
form |
BaseForm |
The form instance passed to the constructor. |
The base class responds to:
Buttonpress withid="submit"→ postsForm.Submitted(callsform.validate()first; only posts if valid).Buttonpress withid="cancel"→ postsForm.Cancelled.Key("enter")→ triggers submit.Key("escape")→ triggers cancel.
DefaultFormLayout also adds a form title (if Form has a title
attribute) and the Submit/Cancel buttons automatically, so compose()
in DefaultFormLayout subclasses need not yield buttons explicitly.
class DefaultFormLayout(FormLayout)Renders all fields in declaration order with label_style and help_style
taken from the form's class-level defaults. Adds a title bar and
Submit/Cancel buttons. Equivalent to the old RenderedForm behaviour.
Used automatically by Form.render() when no layout_class is specified.
Not normally subclassed directly; for custom layouts, subclass FormLayout.
class Validator(ABC)Validators are subclasses of textual.validation.Validator, which thereby
inherit Validator's success() and failure() methods. Alternatively, any
callable with signature (value: Any) -> None that raises ValidationError
on failure may be used directly.
@abstractmethod
def validate(self, value: Any) -> NoneReturn self.success() when validation succeeds, otherwise return
self.failure(message) where message explains the problem..
| Class | Parameters | Raises if… |
|---|---|---|
Required |
— | value is None, "", or empty sequence |
MinLength(n) |
n: int |
len(value) < n |
MaxLength(n) |
n: int |
len(value) > n |
MinValue(n) |
n: int | float |
value < n |
MaxValue(n) |
n: int | float |
value > n |
EmailValidator |
— | value does not match a valid email pattern |
| Exception | Raised when |
|---|---|
ValidationError(message) |
A validator or Field.clean() rejects a value. Carries message: str. |
FieldError(message) |
Field configuration is invalid (e.g. unknown parameter, bad choices format). |
FormError(message) |
Form definition or rendering error: name collision in composition, duplicate field render, or invalid layout_class. |
AmbiguousFieldError(name, candidates) |
Unqualified attribute access (form.street) matches more than one field across composed sub-forms. Carries name: str and candidates: list[str]. |
LabelStyle = Literal["above", "beside", "placeholder"]
HelpStyle = Literal["below", "tooltip"]LabelStyle |
Visual effect |
|---|---|
"above" |
Label widget rendered above the input. Default. |
"beside" |
Label widget rendered to the left of the input in a Horizontal. |
"placeholder" |
No Label widget; field.label used as the input's placeholder text. |
HelpStyle |
Visual effect |
|---|---|
"below" |
Static help text always visible below the input. Default. |
"tooltip" |
Help text assigned to widget.tooltip; shown on hover. |
help_style="below" |
help_style="tooltip" |
|
|---|---|---|
label_style="above" |
Label above, help text below | Label above, help on hover |
label_style="beside" |
Label left, help text below | Label left, help on hover |
label_style="placeholder" |
Placeholder label, help text below | Placeholder label, help on hover |