Fields & Validators¶
Fields are immutable declarative objects that live at class level on a Form subclass. They describe what the field looks like, what its constraints are, and which widget renders it. At form instantiation time each field is bound to a BoundField that carries the mutable runtime state.
Common parameters¶
Every field accepts these keyword-only arguments (after the positional label):
initial: Any = None- Default value shown when no
data=is supplied to the form constructor. required: bool = False- Whether the field must have a non-empty value. When
True, aRequiredvalidator is prepended automatically. Setting this explicitly pins the value — the form-level cascade cannot override it. disabled: bool = False- Render the underlying widget in a disabled state.
validators: list | tuple = ()- Additional validators to run. Accepts
Validatorinstances or plain callables. Plain callables are wrapped inFunctionValidatorautomatically. help_text: str = ""- Descriptive text shown below the input (or as a tooltip, depending on
help_style). label_style: LabelStyle | None = None- Override the form-wide label style for this field only.
help_style: HelpStyle | None = None- Override the form-wide help style for this field only.
Any additional keyword arguments are forwarded to the underlying Textual widget constructor (**widget_kwargs).
StringField¶
Single-line text input. Backed by FormInput (a textual.widgets.Input subclass).
from textual_wtf import Form, StringField
class ProfileForm(Form):
username = StringField(
"Username",
required=True,
min_length=3,
max_length=20,
help_text="Letters, numbers, and underscores only.",
)
bio_url = StringField(
"Website",
initial="https://",
help_text="Your personal or company site.",
)
min_length: int | None = None- Minimum number of characters. Enforced by
MinLengthvalidator (fires on blur and submit). max_length: int | None = None- Maximum number of characters. Enforced by
MaxLengthvalidator (fires on every keystroke, blur, and submit).
IntegerField¶
Integer input. Backs by FormInput with a restrict pattern that only allows digits and the minus sign.
from textual_wtf import Form, IntegerField
class SurveyForm(Form):
rating = IntegerField(
"Rating",
minimum=1,
maximum=10,
help_text="Score from 1 (poor) to 10 (excellent).",
)
year_born = IntegerField("Year of birth", minimum=1900, maximum=2010)
minimum: int | None = None- Inclusive lower bound. Enforced by
MinValue(fires on blur and submit). maximum: int | None = None- Inclusive upper bound. Enforced by
MaxValue(fires on every keystroke, blur, and submit).
get_data() returns the value as int | None. An empty input produces None.
BooleanField¶
Toggle checkbox. Backed by FormCheckbox. The initial value defaults to False unless explicitly overridden.
from textual_wtf import Form, BooleanField
class PreferencesForm(Form):
dark_mode = BooleanField("Use dark mode")
notifications = BooleanField("Enable notifications", initial=True)
beta_features = BooleanField("Opt in to beta features", disabled=True)
get_data() returns True or False.
ChoiceField¶
Dropdown selection. Backed by FormSelect. Requires at least one choice.
from textual_wtf import Form, ChoiceField
COUNTRIES = [
("United Kingdom", "gb"),
("United States", "us"),
("Canada", "ca"),
("Australia", "au"),
]
class ShippingForm(Form):
country = ChoiceField(
"Country",
choices=COUNTRIES,
required=True,
)
priority = ChoiceField(
"Shipping speed",
choices=[
("Standard (3–5 days)", "standard"),
("Express (1–2 days)", "express"),
("Next day", "next_day"),
],
initial="standard",
)
choices: list[tuple[str, Any]]- A list of
(display_label, value)pairs. The display label is shown in the dropdown; the value is whatget_data()returns. Providing an empty list raisesFieldErrorimmediately.
TextField¶
Multi-line text area. Backed by FormTextArea (a textual.widgets.TextArea subclass).
from textual_wtf import Form, TextField
class ArticleForm(Form):
content = TextField(
"Body",
min_length=50,
max_length=10000,
help_text="Markdown is supported.",
)
notes = TextField("Internal notes")
min_length: int | None = None- Minimum character count.
MinLengthvalidator fires on blur and submit. max_length: int | None = None- Maximum character count.
MaxLengthvalidator fires on every keystroke, blur, and submit.
Built-in validators¶
Validators live in textual_wtf.validators and are also importable from textual_wtf directly.
Required¶
Passes if the value is not None, not an empty string, and not an empty collection.
from textual_wtf import StringField, Required
# Equivalent ways to mark a field as required:
name = StringField("Name", required=True) # shorthand
name = StringField("Name", validators=[Required()]) # explicit validator
Fires on: blur, submit.
MinLength / MaxLength¶
from textual_wtf import StringField
username = StringField("Username", min_length=3, max_length=30)
# Or pass validators directly for finer control:
from textual_wtf import MinLength, MaxLength
username = StringField("Username", validators=[MinLength(3), MaxLength(30)])
MinLength(n) fires on: blur, submit.
MaxLength(n) fires on: change, blur, submit.
MinValue / MaxValue¶
from textual_wtf import IntegerField
age = IntegerField("Age", minimum=0, maximum=120)
# Or explicitly:
from textual_wtf import MinValue, MaxValue
age = IntegerField("Age", validators=[MinValue(0), MaxValue(120)])
MinValue(n) fires on: blur, submit.
MaxValue(n) fires on: change, blur, submit.
EmailValidator¶
from textual_wtf import Form, StringField, EmailValidator
class ContactForm(Form):
email = StringField(
"Email",
required=True,
validators=[EmailValidator()],
)
Checks that the value matches the pattern user@domain.tld. Skips validation if the field is empty (combine with Required when you need a non-empty email).
Fires on: blur, submit.
Custom validators¶
Inline with FunctionValidator¶
The simplest approach: pass a plain callable to validators=. It receives the field value and should raise ValidationError if the value is invalid.
from textual_wtf import Form, StringField, ValidationError
def no_spaces(value: str) -> None:
if value and " " in value:
raise ValidationError("Usernames cannot contain spaces.")
def starts_with_letter(value: str) -> None:
if value and not value[0].isalpha():
raise ValidationError("Must start with a letter.")
class SignupForm(Form):
username = StringField(
"Username",
required=True,
validators=[no_spaces, starts_with_letter],
)
Plain callables are automatically wrapped in FunctionValidator, which fires on blur and submit.
Subclassing Validator¶
For reusable validators, subclass Validator and override validate():
from typing import Any
from textual.validation import ValidationResult
from textual_wtf import Validator, ValidationError
class StartsWithLetter(Validator):
"""Value must start with an ASCII letter."""
def validate(self, value: Any) -> ValidationResult:
if value and not str(value)[0].isalpha():
return self.failure("Must start with a letter.")
return self.success()
Use it like any built-in validator:
from textual_wtf import Form, StringField
class SignupForm(Form):
username = StringField(
"Username",
required=True,
validators=[StartsWithLetter()],
)
Controlling when a validator fires¶
Every Validator has a validate_on class attribute — a frozenset[str] of event names. Override it to control firing timing:
class StrictFormatValidator(Validator):
# Fire on every keystroke, blur, and submit
validate_on: frozenset[str] = frozenset({"change", "blur", "submit"})
def validate(self, value: Any) -> ValidationResult:
# ... your logic
return self.success()
Or pass it at instantiation time:
class MyForm(Form):
code = StringField(
"Code",
validators=[
FunctionValidator(
my_check_fn,
validate_on=frozenset({"change", "blur", "submit"}),
)
],
)
Performance tip
Keep validate_on as {"blur", "submit"} for expensive validators (network lookups, regex on large text). Reserve "change" for lightweight checks like MaxLength where instant feedback is important.