textual-wtf 0.8.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- textual_wtf/__init__.py +67 -0
- textual_wtf/bound_fields.py +245 -0
- textual_wtf/demo/__init__.py +0 -0
- textual_wtf/demo/advanced_form.py +139 -0
- textual_wtf/demo/basic_form.py +92 -0
- textual_wtf/demo/basic_form.tcss +7 -0
- textual_wtf/demo/launcher.py +124 -0
- textual_wtf/demo/nested_once_form.py +165 -0
- textual_wtf/demo/nested_twice_form.py +170 -0
- textual_wtf/demo/results_screen.py +88 -0
- textual_wtf/demo/user_registration.py +134 -0
- textual_wtf/exceptions.py +25 -0
- textual_wtf/fields.py +264 -0
- textual_wtf/forms.py +433 -0
- textual_wtf/py.typed +0 -0
- textual_wtf/validators.py +40 -0
- textual_wtf/version.py +1 -0
- textual_wtf/widgets.py +165 -0
- textual_wtf-0.8.0.dist-info/METADATA +223 -0
- textual_wtf-0.8.0.dist-info/RECORD +23 -0
- textual_wtf-0.8.0.dist-info/WHEEL +4 -0
- textual_wtf-0.8.0.dist-info/entry_points.txt +2 -0
- textual_wtf-0.8.0.dist-info/licenses/LICENSE +21 -0
textual_wtf/fields.py
ADDED
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
"""Field implementations for forms"""
|
|
2
|
+
from abc import ABC, abstractmethod
|
|
3
|
+
from typing import Any, Optional, Type, List, TYPE_CHECKING
|
|
4
|
+
from textual.widget import Widget
|
|
5
|
+
from textual.validation import Validator
|
|
6
|
+
|
|
7
|
+
from .exceptions import ValidationError, FieldError
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
from .forms import Form
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class Field(ABC):
|
|
14
|
+
"""
|
|
15
|
+
Base field class - defines interface for all field types.
|
|
16
|
+
|
|
17
|
+
This class holds ONLY configuration (immutable, class-level).
|
|
18
|
+
Runtime state (widget instance, errors, current value) is held
|
|
19
|
+
in BoundField instances created when a Form is instantiated.
|
|
20
|
+
|
|
21
|
+
This separation allows Field instances to be safely shared across
|
|
22
|
+
multiple Form instances (thread-safe, no deep copying needed).
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
default_widget: Optional[Type[Widget]] = None
|
|
26
|
+
|
|
27
|
+
def __init__(self, *, widget: Optional[Type[Widget]] = None,
|
|
28
|
+
validators: Optional[List[Validator]] = None,
|
|
29
|
+
required: bool = False, initial: Any = None,
|
|
30
|
+
label: Optional[str] = None, help_text: Optional[str] = None,
|
|
31
|
+
disabled: bool = False, **widget_kwargs):
|
|
32
|
+
"""
|
|
33
|
+
Initialize field configuration
|
|
34
|
+
|
|
35
|
+
Args:
|
|
36
|
+
widget: Widget class to use (overrides default_widget)
|
|
37
|
+
validators: List of validators
|
|
38
|
+
required: Whether field is required
|
|
39
|
+
initial: Initial/default value
|
|
40
|
+
label: Field label for display
|
|
41
|
+
help_text: Help text for field
|
|
42
|
+
disabled: Whether field is disabled
|
|
43
|
+
**widget_kwargs: Additional kwargs passed to widget
|
|
44
|
+
"""
|
|
45
|
+
# Configuration only (immutable)
|
|
46
|
+
self.widget_class = widget or self.default_widget
|
|
47
|
+
self.validators = validators or []
|
|
48
|
+
self.required = required
|
|
49
|
+
self.initial = initial
|
|
50
|
+
self.label = label
|
|
51
|
+
self.help_text = help_text
|
|
52
|
+
self.disabled = disabled
|
|
53
|
+
self.widget_kwargs = widget_kwargs
|
|
54
|
+
|
|
55
|
+
# NOTE: Runtime state (_widget_instance, _errors, name, form)
|
|
56
|
+
# is now held in BoundField instances, not here!
|
|
57
|
+
|
|
58
|
+
@abstractmethod
|
|
59
|
+
def to_python(self, value: Any) -> Any:
|
|
60
|
+
"""Convert widget value to Python value"""
|
|
61
|
+
pass
|
|
62
|
+
|
|
63
|
+
@abstractmethod
|
|
64
|
+
def to_widget(self, value: Any) -> Any:
|
|
65
|
+
"""Convert Python value to widget value"""
|
|
66
|
+
pass
|
|
67
|
+
|
|
68
|
+
def bind(self, form: 'Form', name: str, initial: Any = None) -> 'BoundField':
|
|
69
|
+
"""
|
|
70
|
+
Create a BoundField from this Field configuration
|
|
71
|
+
|
|
72
|
+
This is called by Form.__init__() to create runtime state
|
|
73
|
+
for this field in a specific form instance.
|
|
74
|
+
|
|
75
|
+
Args:
|
|
76
|
+
form: Parent form instance
|
|
77
|
+
name: Field name in the form
|
|
78
|
+
initial: Initial value (overrides self.initial if provided)
|
|
79
|
+
|
|
80
|
+
Returns:
|
|
81
|
+
BoundField instance holding runtime state
|
|
82
|
+
"""
|
|
83
|
+
from .bound_fields import BoundField
|
|
84
|
+
return BoundField(self, form, name, initial)
|
|
85
|
+
|
|
86
|
+
def create_widget(self) -> Widget:
|
|
87
|
+
"""
|
|
88
|
+
Factory method to create configured widget
|
|
89
|
+
|
|
90
|
+
Note: This creates the widget but doesn't store it.
|
|
91
|
+
The BoundField is responsible for storing widget instances.
|
|
92
|
+
|
|
93
|
+
Returns:
|
|
94
|
+
Configured widget instance
|
|
95
|
+
"""
|
|
96
|
+
if not self.widget_class:
|
|
97
|
+
raise FieldError(
|
|
98
|
+
f"{self.__class__.__name__} must define default_widget "
|
|
99
|
+
f"or pass widget parameter"
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
kwargs = self.widget_kwargs.copy()
|
|
103
|
+
|
|
104
|
+
# Pass validators to widget if it supports them
|
|
105
|
+
if hasattr(self.widget_class, '__init__'):
|
|
106
|
+
init_params = self.widget_class.__init__.__code__.co_varnames
|
|
107
|
+
if 'validators' in init_params:
|
|
108
|
+
kwargs.setdefault('validators', self.validators)
|
|
109
|
+
|
|
110
|
+
widget = self.widget_class(**kwargs)
|
|
111
|
+
return widget
|
|
112
|
+
|
|
113
|
+
def validate(self, value: Any) -> None:
|
|
114
|
+
"""
|
|
115
|
+
Validate Python value (stateless)
|
|
116
|
+
|
|
117
|
+
This method is now stateless - it doesn't modify _errors.
|
|
118
|
+
Instead, it raises ValidationError which BoundField catches
|
|
119
|
+
and stores in its own _errors list.
|
|
120
|
+
|
|
121
|
+
Args:
|
|
122
|
+
value: Python value to validate
|
|
123
|
+
|
|
124
|
+
Raises:
|
|
125
|
+
ValidationError: If validation fails
|
|
126
|
+
"""
|
|
127
|
+
if self.required and value is None:
|
|
128
|
+
raise ValidationError(f"{self.label or 'Field'} is required")
|
|
129
|
+
|
|
130
|
+
def clean(self, value: Any) -> Any:
|
|
131
|
+
"""
|
|
132
|
+
Convert and validate value (stateless)
|
|
133
|
+
|
|
134
|
+
Args:
|
|
135
|
+
value: Raw value to clean
|
|
136
|
+
|
|
137
|
+
Returns:
|
|
138
|
+
Cleaned Python value
|
|
139
|
+
|
|
140
|
+
Raises:
|
|
141
|
+
ValidationError: If validation fails
|
|
142
|
+
"""
|
|
143
|
+
python_value = self.to_python(value)
|
|
144
|
+
self.validate(python_value)
|
|
145
|
+
return python_value
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
class StringField(Field):
|
|
149
|
+
"""Text field (single or multi-line)"""
|
|
150
|
+
|
|
151
|
+
def __init__(self, *, max_length: Optional[int] = None,
|
|
152
|
+
min_length: Optional[int] = None, multiline: bool = False, **kwargs):
|
|
153
|
+
# Choose widget based on multiline flag
|
|
154
|
+
if multiline and 'widget' not in kwargs:
|
|
155
|
+
from .widgets import FormTextArea
|
|
156
|
+
kwargs['widget'] = FormTextArea
|
|
157
|
+
else:
|
|
158
|
+
from .widgets import FormInput
|
|
159
|
+
if 'widget' not in kwargs:
|
|
160
|
+
kwargs['widget'] = FormInput
|
|
161
|
+
|
|
162
|
+
super().__init__(**kwargs)
|
|
163
|
+
self.max_length = max_length
|
|
164
|
+
self.min_length = min_length
|
|
165
|
+
|
|
166
|
+
def to_python(self, value: Any) -> Optional[str]:
|
|
167
|
+
"""Convert to Python string"""
|
|
168
|
+
if value in (None, ''):
|
|
169
|
+
return None
|
|
170
|
+
return str(value).strip()
|
|
171
|
+
|
|
172
|
+
def to_widget(self, value: Any) -> str:
|
|
173
|
+
"""Convert to widget string"""
|
|
174
|
+
return value if value is not None else ''
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
class IntegerField(Field):
|
|
178
|
+
"""Integer field"""
|
|
179
|
+
|
|
180
|
+
def __init__(self, *, min_value: Optional[int] = None,
|
|
181
|
+
max_value: Optional[int] = None, **kwargs):
|
|
182
|
+
from .widgets import FormIntegerInput
|
|
183
|
+
if 'widget' not in kwargs:
|
|
184
|
+
kwargs['widget'] = FormIntegerInput
|
|
185
|
+
|
|
186
|
+
super().__init__(**kwargs)
|
|
187
|
+
self.min_value = min_value
|
|
188
|
+
self.max_value = max_value
|
|
189
|
+
|
|
190
|
+
def to_python(self, value: Any) -> Optional[int]:
|
|
191
|
+
"""Convert to Python int"""
|
|
192
|
+
if value in (None, ''):
|
|
193
|
+
return None
|
|
194
|
+
try:
|
|
195
|
+
return int(value)
|
|
196
|
+
except (ValueError, TypeError) as e:
|
|
197
|
+
raise ValidationError(f"Invalid integer: {value}") from e
|
|
198
|
+
|
|
199
|
+
def to_widget(self, value: Any) -> str:
|
|
200
|
+
"""Convert to widget string"""
|
|
201
|
+
return str(value) if value is not None else ''
|
|
202
|
+
|
|
203
|
+
def validate(self, value: Any) -> None:
|
|
204
|
+
"""Validate integer constraints"""
|
|
205
|
+
super().validate(value)
|
|
206
|
+
if value is not None:
|
|
207
|
+
if self.min_value is not None and value < self.min_value:
|
|
208
|
+
raise ValidationError(f"Must be at least {self.min_value}")
|
|
209
|
+
if self.max_value is not None and value > self.max_value:
|
|
210
|
+
raise ValidationError(f"Must be at most {self.max_value}")
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
class BooleanField(Field):
|
|
214
|
+
"""Boolean/checkbox field"""
|
|
215
|
+
|
|
216
|
+
def __init__(self, **kwargs):
|
|
217
|
+
from .widgets import FormCheckbox
|
|
218
|
+
if 'widget' not in kwargs:
|
|
219
|
+
kwargs['widget'] = FormCheckbox
|
|
220
|
+
super().__init__(**kwargs)
|
|
221
|
+
|
|
222
|
+
def to_python(self, value: Any) -> bool:
|
|
223
|
+
"""Convert to Python bool"""
|
|
224
|
+
if isinstance(value, bool):
|
|
225
|
+
return value
|
|
226
|
+
if isinstance(value, str):
|
|
227
|
+
return value.lower() in ('true', 'yes', '1', 'on')
|
|
228
|
+
return bool(value)
|
|
229
|
+
|
|
230
|
+
def to_widget(self, value: Any) -> bool:
|
|
231
|
+
"""Convert to widget bool"""
|
|
232
|
+
return self.to_python(value)
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
class ChoiceField(Field):
|
|
236
|
+
"""Select/dropdown field"""
|
|
237
|
+
|
|
238
|
+
def __init__(self, *, choices: List[tuple[str, str]], **kwargs):
|
|
239
|
+
from .widgets import FormSelect
|
|
240
|
+
if 'widget' not in kwargs:
|
|
241
|
+
kwargs['widget'] = FormSelect
|
|
242
|
+
|
|
243
|
+
super().__init__(**kwargs)
|
|
244
|
+
self.choices = choices
|
|
245
|
+
self.widget_kwargs['choices'] = choices
|
|
246
|
+
self.widget_kwargs['allow_blank'] = not kwargs.get('required', False)
|
|
247
|
+
|
|
248
|
+
def to_python(self, value: Any) -> Optional[str]:
|
|
249
|
+
"""Convert to Python string"""
|
|
250
|
+
if value in (None, ''):
|
|
251
|
+
return None
|
|
252
|
+
return str(value)
|
|
253
|
+
|
|
254
|
+
def to_widget(self, value: Any) -> str:
|
|
255
|
+
"""Convert to widget string"""
|
|
256
|
+
return value if value is not None else ''
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
class TextField(StringField):
|
|
260
|
+
"""Multi-line text field (alias for StringField with multiline=True)"""
|
|
261
|
+
|
|
262
|
+
def __init__(self, **kwargs):
|
|
263
|
+
kwargs['multiline'] = True
|
|
264
|
+
super().__init__(**kwargs)
|
textual_wtf/forms.py
ADDED
|
@@ -0,0 +1,433 @@
|
|
|
1
|
+
"""Form metaclass and base classes"""
|
|
2
|
+
import copy
|
|
3
|
+
from typing import Dict, Any, Optional, List
|
|
4
|
+
from textual import on
|
|
5
|
+
from textual.containers import Vertical, Center, Horizontal, VerticalScroll
|
|
6
|
+
from textual.widgets import Button, Static, Label
|
|
7
|
+
from textual.message import Message
|
|
8
|
+
|
|
9
|
+
from .fields import Field
|
|
10
|
+
from .exceptions import FieldError, AmbiguousFieldError, ValidationError
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ComposedForm:
|
|
14
|
+
"""Marker for composed form inclusion"""
|
|
15
|
+
def __init__(self, form_class: type, prefix: str = '', title: Optional[str] = None):
|
|
16
|
+
self.form_class = form_class
|
|
17
|
+
self.prefix = prefix
|
|
18
|
+
# If no title provided but prefix exists, capitalize prefix as title
|
|
19
|
+
if title is None and prefix:
|
|
20
|
+
self.title = prefix.capitalize()
|
|
21
|
+
else:
|
|
22
|
+
self.title = title
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class FormMetaclass(type):
|
|
26
|
+
"""Metaclass that collects Field declarations and expands composed forms"""
|
|
27
|
+
|
|
28
|
+
def __new__(mcs, name, bases, attrs):
|
|
29
|
+
# Collect fields and composed forms in declaration order
|
|
30
|
+
items_in_order = []
|
|
31
|
+
|
|
32
|
+
for key, value in list(attrs.items()):
|
|
33
|
+
if isinstance(value, Field):
|
|
34
|
+
items_in_order.append(('field', key, value))
|
|
35
|
+
attrs.pop(key) # Remove from class attrs
|
|
36
|
+
elif isinstance(value, ComposedForm):
|
|
37
|
+
items_in_order.append(('composed', key, value))
|
|
38
|
+
attrs.pop(key) # Remove from class attrs
|
|
39
|
+
|
|
40
|
+
# Process items in order, expanding composed forms
|
|
41
|
+
all_fields = []
|
|
42
|
+
declared_fields = []
|
|
43
|
+
composition_metadata = {}
|
|
44
|
+
|
|
45
|
+
for item_type, attr_name, item in items_in_order:
|
|
46
|
+
if item_type == 'field':
|
|
47
|
+
# Regular field - add directly
|
|
48
|
+
all_fields.append((attr_name, item))
|
|
49
|
+
declared_fields.append((attr_name, item))
|
|
50
|
+
|
|
51
|
+
elif item_type == 'composed':
|
|
52
|
+
# Composed form - expand it
|
|
53
|
+
composed = item
|
|
54
|
+
|
|
55
|
+
if not hasattr(composed.form_class, '_base_fields'):
|
|
56
|
+
raise FieldError(
|
|
57
|
+
f"Cannot compose {composed.form_class.__name__}: "
|
|
58
|
+
f"it doesn't appear to be a Form class"
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
composed_fields = composed.form_class._base_fields
|
|
62
|
+
prefix = composed.prefix
|
|
63
|
+
|
|
64
|
+
# Expand fields with prefix
|
|
65
|
+
for field_name, field in composed_fields.items():
|
|
66
|
+
# Generate new field name
|
|
67
|
+
if prefix:
|
|
68
|
+
new_name = f"{prefix}_{field_name}"
|
|
69
|
+
else:
|
|
70
|
+
new_name = field_name
|
|
71
|
+
|
|
72
|
+
# Deep copy the field
|
|
73
|
+
new_field = copy.deepcopy(field)
|
|
74
|
+
all_fields.append((new_name, new_field))
|
|
75
|
+
|
|
76
|
+
# Track composition metadata
|
|
77
|
+
if prefix or composed.title:
|
|
78
|
+
composition_metadata[new_name] = {
|
|
79
|
+
'composed_from': attr_name,
|
|
80
|
+
'prefix': prefix,
|
|
81
|
+
'original_name': field_name,
|
|
82
|
+
'title': composed.title
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
# Check for name collisions
|
|
86
|
+
field_names = {}
|
|
87
|
+
for field_name, field in all_fields:
|
|
88
|
+
if field_name in field_names:
|
|
89
|
+
raise FieldError(
|
|
90
|
+
f"Field name collision: '{field_name}' is defined multiple times. "
|
|
91
|
+
f"Use different prefixes to avoid collisions."
|
|
92
|
+
)
|
|
93
|
+
field_names[field_name] = field
|
|
94
|
+
|
|
95
|
+
# Store as _base_fields
|
|
96
|
+
new_class = super().__new__(mcs, name, bases, attrs)
|
|
97
|
+
new_class._base_fields = dict(all_fields)
|
|
98
|
+
new_class._declared_fields = dict(declared_fields)
|
|
99
|
+
new_class._composition_metadata = composition_metadata
|
|
100
|
+
return new_class
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class RenderedForm(VerticalScroll):
|
|
104
|
+
"""Rendered form widget that displays fields and buttons"""
|
|
105
|
+
|
|
106
|
+
DEFAULT_CSS = """
|
|
107
|
+
RenderedForm {
|
|
108
|
+
keyline: thin green;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
Vertical {
|
|
112
|
+
margin: 1;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
#form-title {
|
|
116
|
+
background: blue;
|
|
117
|
+
height: auto;
|
|
118
|
+
margin: 1;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
.subform-title {
|
|
122
|
+
background: white;
|
|
123
|
+
color: black;
|
|
124
|
+
height: auto;
|
|
125
|
+
padding: 0 1;
|
|
126
|
+
margin: 1 0 0 0;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
.form-field {
|
|
130
|
+
height: auto;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
.form-error {
|
|
134
|
+
color: red;
|
|
135
|
+
width: 1fr;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
#buttons {
|
|
139
|
+
height: auto;
|
|
140
|
+
align: center middle;
|
|
141
|
+
margin: 0;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
#outer-buttons {
|
|
145
|
+
height: auto;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
Input {
|
|
149
|
+
height: auto;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
TextArea {
|
|
153
|
+
height: 6;
|
|
154
|
+
}
|
|
155
|
+
"""
|
|
156
|
+
|
|
157
|
+
def __init__(self, form, data: Optional[Dict[str, Any]] = None,
|
|
158
|
+
field_order: Optional[List[str]] = None, id=None):
|
|
159
|
+
"""
|
|
160
|
+
Initialize rendered form
|
|
161
|
+
"""
|
|
162
|
+
super().__init__(id=id, **form.kwargs)
|
|
163
|
+
self.form = form
|
|
164
|
+
self.fields = form.fields
|
|
165
|
+
self.data = data
|
|
166
|
+
self.field_order = field_order
|
|
167
|
+
|
|
168
|
+
if data is not None:
|
|
169
|
+
self.set_data(data)
|
|
170
|
+
|
|
171
|
+
def compose(self):
|
|
172
|
+
"""Compose the form UI"""
|
|
173
|
+
# Optional title
|
|
174
|
+
if self.form.title is not None:
|
|
175
|
+
yield Vertical(
|
|
176
|
+
Center(Static(f"---- {self.form.title} ----")),
|
|
177
|
+
id="form-title"
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
# Track which subforms we've already rendered headers for
|
|
181
|
+
rendered_subforms = set()
|
|
182
|
+
|
|
183
|
+
# Render each field
|
|
184
|
+
for name, field in self.form.fields.items():
|
|
185
|
+
# Check if this field is part of a composed subform with a title
|
|
186
|
+
metadata = self.form._composition_metadata.get(name)
|
|
187
|
+
if metadata and metadata.get('title'):
|
|
188
|
+
subform_id = metadata['composed_from']
|
|
189
|
+
|
|
190
|
+
# Render subform title once per subform
|
|
191
|
+
if subform_id not in rendered_subforms:
|
|
192
|
+
yield Static(metadata['title'], classes="subform-title")
|
|
193
|
+
rendered_subforms.add(subform_id)
|
|
194
|
+
|
|
195
|
+
with Vertical(classes="form-field"):
|
|
196
|
+
if field.label:
|
|
197
|
+
yield Label(field.label)
|
|
198
|
+
yield field.widget
|
|
199
|
+
|
|
200
|
+
# Set initial data if provided
|
|
201
|
+
if self.data and name in self.data:
|
|
202
|
+
field.value = self.data[name]
|
|
203
|
+
|
|
204
|
+
# Submit/Cancel buttons
|
|
205
|
+
yield Vertical(
|
|
206
|
+
Horizontal(
|
|
207
|
+
Button("Cancel", id="cancel"),
|
|
208
|
+
Button("Submit", id="submit", variant="primary"),
|
|
209
|
+
id="buttons"
|
|
210
|
+
),
|
|
211
|
+
id="outer-buttons"
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
def get_data(self) -> Dict[str, Any]:
|
|
215
|
+
"""Get current form data"""
|
|
216
|
+
return self.form.get_data()
|
|
217
|
+
|
|
218
|
+
def set_data(self, data: Dict[str, Any]):
|
|
219
|
+
"""Set form data"""
|
|
220
|
+
return self.form.set_data(data)
|
|
221
|
+
|
|
222
|
+
async def validate(self):
|
|
223
|
+
"""Validate all form fields"""
|
|
224
|
+
return await self.form.validate()
|
|
225
|
+
|
|
226
|
+
@on(Button.Pressed, "#submit")
|
|
227
|
+
async def submit_pressed(self, event: Button.Pressed) -> None:
|
|
228
|
+
"""Handle submit button press"""
|
|
229
|
+
if await self.validate():
|
|
230
|
+
self.post_message(Form.Submitted(self))
|
|
231
|
+
else:
|
|
232
|
+
self.app.notify("Please fix the errors before submitting", severity="error")
|
|
233
|
+
|
|
234
|
+
@on(Button.Pressed, "#cancel")
|
|
235
|
+
async def cancel_pressed(self, event: Button.Pressed) -> None:
|
|
236
|
+
"""Handle cancel button press"""
|
|
237
|
+
self.post_message(Form.Cancelled(self))
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
class BaseForm:
|
|
241
|
+
"""Base form class without metaclass"""
|
|
242
|
+
|
|
243
|
+
def __init__(self, *children, data: Optional[Dict[str, Any]] = None,
|
|
244
|
+
field_order: Optional[List[str]] = None,
|
|
245
|
+
title: Optional[str] = None, render_type=RenderedForm, **kwargs):
|
|
246
|
+
"""
|
|
247
|
+
Initialize form
|
|
248
|
+
|
|
249
|
+
Creates BoundField instances from class-level Field definitions.
|
|
250
|
+
This is much faster than deep copying and enables thread-safe
|
|
251
|
+
Form class reuse.
|
|
252
|
+
|
|
253
|
+
Args:
|
|
254
|
+
data: Initial data dict
|
|
255
|
+
field_order: Custom field ordering
|
|
256
|
+
title: Form title
|
|
257
|
+
render_type: Custom renderer class
|
|
258
|
+
**kwargs: Additional kwargs for renderer
|
|
259
|
+
"""
|
|
260
|
+
self.data = data
|
|
261
|
+
self.children = children
|
|
262
|
+
self.field_order = field_order
|
|
263
|
+
self.title = title
|
|
264
|
+
self.kwargs = kwargs
|
|
265
|
+
self.render_type = render_type
|
|
266
|
+
|
|
267
|
+
# Create BoundFields from class-level Field definitions
|
|
268
|
+
# NO MORE DEEP COPY - just create lightweight BoundField wrappers
|
|
269
|
+
self.bound_fields: Dict[str, 'BoundField'] = {}
|
|
270
|
+
|
|
271
|
+
for name, field in self._base_fields.items():
|
|
272
|
+
# Get initial value from data if provided
|
|
273
|
+
initial = data.get(name) if data else None
|
|
274
|
+
# Create BoundField (holds runtime state)
|
|
275
|
+
bound_field = field.bind(self, name, initial)
|
|
276
|
+
self.bound_fields[name] = bound_field
|
|
277
|
+
|
|
278
|
+
# Set as attribute for direct access (form.fieldname)
|
|
279
|
+
setattr(self, name, bound_field)
|
|
280
|
+
|
|
281
|
+
# Apply custom field ordering if provided
|
|
282
|
+
self.order_fields(self.field_order)
|
|
283
|
+
|
|
284
|
+
@property
|
|
285
|
+
def fields(self) -> Dict[str, 'BoundField']:
|
|
286
|
+
"""
|
|
287
|
+
Backward compatibility: alias for bound_fields
|
|
288
|
+
|
|
289
|
+
Returns bound_fields so existing code using form.fields continues to work.
|
|
290
|
+
"""
|
|
291
|
+
return self.bound_fields
|
|
292
|
+
|
|
293
|
+
def __getattr__(self, name: str) -> 'BoundField':
|
|
294
|
+
"""
|
|
295
|
+
Allow dot-access to fields using the "SQL-style" resolution logic.
|
|
296
|
+
This is called only if the attribute was not found by normal lookup.
|
|
297
|
+
"""
|
|
298
|
+
# Try to resolve using get_field logic
|
|
299
|
+
field = self.get_field(name)
|
|
300
|
+
if field:
|
|
301
|
+
return field
|
|
302
|
+
|
|
303
|
+
raise AttributeError(f"'{self.__class__.__name__}' object has no attribute '{name}'")
|
|
304
|
+
|
|
305
|
+
@classmethod
|
|
306
|
+
def compose(cls, prefix: str = '', title: Optional[str] = None) -> ComposedForm:
|
|
307
|
+
"""Create a composition marker for including this form in another"""
|
|
308
|
+
return ComposedForm(cls, prefix=prefix, title=title)
|
|
309
|
+
|
|
310
|
+
def get_data(self) -> Dict[str, Any]:
|
|
311
|
+
"""Get current values from all fields"""
|
|
312
|
+
data: Dict[str, Any] = {}
|
|
313
|
+
for name, field in self.fields.items():
|
|
314
|
+
data[name] = field.value
|
|
315
|
+
return data
|
|
316
|
+
|
|
317
|
+
def set_data(self, data: Dict[str, Any]):
|
|
318
|
+
"""Set values for fields from dict"""
|
|
319
|
+
for name, value in data.items():
|
|
320
|
+
if name in self.fields:
|
|
321
|
+
self.fields[name].value = value
|
|
322
|
+
|
|
323
|
+
def order_fields(self, field_order):
|
|
324
|
+
"""Reorder fields according to field_order list"""
|
|
325
|
+
if field_order is None:
|
|
326
|
+
return
|
|
327
|
+
|
|
328
|
+
# Work directly with bound_fields since fields is now a property
|
|
329
|
+
ordered = {}
|
|
330
|
+
# First add fields in specified order
|
|
331
|
+
for key in field_order:
|
|
332
|
+
if key in self.bound_fields:
|
|
333
|
+
ordered[key] = self.bound_fields.pop(key)
|
|
334
|
+
|
|
335
|
+
# Then add any remaining fields
|
|
336
|
+
for k in list(self.bound_fields):
|
|
337
|
+
ordered[k] = self.bound_fields.pop(k)
|
|
338
|
+
|
|
339
|
+
self.bound_fields = ordered
|
|
340
|
+
|
|
341
|
+
def get_fields_dict(self) -> Dict[str, 'BoundField']:
|
|
342
|
+
"""Get fields dictionary without rendering"""
|
|
343
|
+
return self.bound_fields
|
|
344
|
+
|
|
345
|
+
def get_field_names(self) -> List[str]:
|
|
346
|
+
"""Get list of field names in order"""
|
|
347
|
+
return list(self.bound_fields.keys())
|
|
348
|
+
|
|
349
|
+
def get_field(self, name: str) -> Optional['BoundField']:
|
|
350
|
+
"""
|
|
351
|
+
Get a specific field by name with SQL-style resolution
|
|
352
|
+
"""
|
|
353
|
+
# Try exact match first
|
|
354
|
+
if name in self.fields:
|
|
355
|
+
return self.fields[name]
|
|
356
|
+
|
|
357
|
+
# Try unqualified match (fields ending with _<name>)
|
|
358
|
+
candidates = [
|
|
359
|
+
field_name for field_name in self.fields
|
|
360
|
+
if field_name == name or field_name.endswith('_' + name)
|
|
361
|
+
]
|
|
362
|
+
|
|
363
|
+
if len(candidates) == 0:
|
|
364
|
+
return None # Not found
|
|
365
|
+
elif len(candidates) == 1:
|
|
366
|
+
return self.fields[candidates[0]] # Unambiguous
|
|
367
|
+
else:
|
|
368
|
+
from .exceptions import AmbiguousFieldError
|
|
369
|
+
raise AmbiguousFieldError(
|
|
370
|
+
f"Field '{name}' is ambiguous. Could be: {', '.join(sorted(candidates))}. "
|
|
371
|
+
f"Use the full qualified name to disambiguate."
|
|
372
|
+
)
|
|
373
|
+
|
|
374
|
+
def render(self, id=None) -> RenderedForm:
|
|
375
|
+
"""Render the form as a Textual widget"""
|
|
376
|
+
# Create widgets for all fields
|
|
377
|
+
for name, field in self.fields.items():
|
|
378
|
+
field.widget = field.create_widget()
|
|
379
|
+
|
|
380
|
+
# Create and return rendered form
|
|
381
|
+
self.rform = self.render_type(
|
|
382
|
+
self,
|
|
383
|
+
id=id,
|
|
384
|
+
data=self.data,
|
|
385
|
+
field_order=self.field_order
|
|
386
|
+
)
|
|
387
|
+
return self.rform
|
|
388
|
+
|
|
389
|
+
async def validate(self):
|
|
390
|
+
"""Validate all form fields"""
|
|
391
|
+
result = True
|
|
392
|
+
|
|
393
|
+
for name, field in self.fields.items():
|
|
394
|
+
widget = field.widget
|
|
395
|
+
container = widget.parent
|
|
396
|
+
|
|
397
|
+
# Clear previous errors
|
|
398
|
+
await container.remove_children(".form-error")
|
|
399
|
+
|
|
400
|
+
# Widget-level validation (validators passed to widget)
|
|
401
|
+
vr = widget.validate(widget.value)
|
|
402
|
+
if vr is not None and not vr.is_valid:
|
|
403
|
+
result = False
|
|
404
|
+
# Display errors
|
|
405
|
+
for msg in vr.failure_descriptions:
|
|
406
|
+
container.mount(Center(Static(msg, classes="form-error")))
|
|
407
|
+
|
|
408
|
+
# Field-level validation (required, custom Field validators)
|
|
409
|
+
try:
|
|
410
|
+
field.clean(field.value)
|
|
411
|
+
except ValidationError as e:
|
|
412
|
+
result = False
|
|
413
|
+
container.mount(Center(Static(str(e), classes="form-error")))
|
|
414
|
+
|
|
415
|
+
return result
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
class Form(BaseForm, metaclass=FormMetaclass):
|
|
419
|
+
"""
|
|
420
|
+
Form with declarative field syntax
|
|
421
|
+
"""
|
|
422
|
+
|
|
423
|
+
class Submitted(Message):
|
|
424
|
+
"""Posted when form is submitted successfully"""
|
|
425
|
+
def __init__(self, r_form: RenderedForm):
|
|
426
|
+
super().__init__()
|
|
427
|
+
self.form = r_form
|
|
428
|
+
|
|
429
|
+
class Cancelled(Message):
|
|
430
|
+
"""Posted when form is cancelled"""
|
|
431
|
+
def __init__(self, r_form: RenderedForm):
|
|
432
|
+
super().__init__()
|
|
433
|
+
self.form = r_form
|
textual_wtf/py.typed
ADDED
|
File without changes
|