textual-wtf 0.8.0__py3-none-any.whl → 0.8.1__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 +5 -1
- textual_wtf/bound_fields.py +47 -2
- textual_wtf/demo/advanced_form.py +2 -2
- textual_wtf/demo/basic_form.py +1 -1
- textual_wtf/demo/nested_once_form.py +2 -2
- textual_wtf/demo/nested_twice_form.py +2 -2
- textual_wtf/demo/user_registration.py +2 -2
- textual_wtf/fields.py +7 -1
- textual_wtf/forms.py +62 -169
- textual_wtf/version.py +1 -1
- textual_wtf/widgets.py +28 -24
- {textual_wtf-0.8.0.dist-info → textual_wtf-0.8.1.dist-info}/METADATA +1 -1
- textual_wtf-0.8.1.dist-info/RECORD +23 -0
- textual_wtf-0.8.0.dist-info/RECORD +0 -23
- {textual_wtf-0.8.0.dist-info → textual_wtf-0.8.1.dist-info}/WHEEL +0 -0
- {textual_wtf-0.8.0.dist-info → textual_wtf-0.8.1.dist-info}/entry_points.txt +0 -0
- {textual_wtf-0.8.0.dist-info → textual_wtf-0.8.1.dist-info}/licenses/LICENSE +0 -0
textual_wtf/__init__.py
CHANGED
|
@@ -21,6 +21,7 @@ from .fields import (
|
|
|
21
21
|
TextField,
|
|
22
22
|
)
|
|
23
23
|
from .forms import Form
|
|
24
|
+
from .layouts import FormLayout, DefaultFormLayout
|
|
24
25
|
from .validators import (
|
|
25
26
|
EvenInteger,
|
|
26
27
|
Palindromic,
|
|
@@ -51,6 +52,9 @@ __all__ = [
|
|
|
51
52
|
"TextField",
|
|
52
53
|
# Forms
|
|
53
54
|
"Form",
|
|
55
|
+
# Layouts
|
|
56
|
+
"FormLayout",
|
|
57
|
+
"DefaultFormLayout",
|
|
54
58
|
# Validators
|
|
55
59
|
"EvenInteger",
|
|
56
60
|
"Palindromic",
|
|
@@ -63,5 +67,5 @@ __all__ = [
|
|
|
63
67
|
"FormSelect",
|
|
64
68
|
"WidgetRegistry",
|
|
65
69
|
# Structural
|
|
66
|
-
"__version__,
|
|
70
|
+
"__version__",
|
|
67
71
|
]
|
textual_wtf/bound_fields.py
CHANGED
|
@@ -155,14 +155,21 @@ class BoundField:
|
|
|
155
155
|
# Methods - Delegate to Field for logic
|
|
156
156
|
# ========================================================================
|
|
157
157
|
|
|
158
|
-
def create_widget(self) -> Widget:
|
|
158
|
+
def create_widget(self, **kwargs) -> Widget:
|
|
159
159
|
"""
|
|
160
160
|
Create widget using field configuration
|
|
161
161
|
|
|
162
|
+
Args:
|
|
163
|
+
**kwargs: Additional keyword arguments to pass to widget constructor
|
|
164
|
+
|
|
162
165
|
Returns:
|
|
163
166
|
Widget instance configured from Field
|
|
164
167
|
"""
|
|
165
|
-
|
|
168
|
+
# Merge kwargs with field's widget_kwargs
|
|
169
|
+
merged_kwargs = {**self.field.widget_kwargs, **kwargs}
|
|
170
|
+
|
|
171
|
+
# Create widget with merged kwargs
|
|
172
|
+
widget = self.field.create_widget(widget_kwargs=merged_kwargs)
|
|
166
173
|
self._widget_instance = widget
|
|
167
174
|
# Set back-reference
|
|
168
175
|
widget.bound_field = self
|
|
@@ -170,6 +177,44 @@ class BoundField:
|
|
|
170
177
|
widget.field = self
|
|
171
178
|
return widget
|
|
172
179
|
|
|
180
|
+
def __call__(self, **kwargs) -> Widget:
|
|
181
|
+
"""
|
|
182
|
+
Callable interface for rendering fields (WTForms-style)
|
|
183
|
+
|
|
184
|
+
Creates and returns the widget, configured with any additional
|
|
185
|
+
keyword arguments. This is the primary way to render fields in
|
|
186
|
+
custom layouts.
|
|
187
|
+
|
|
188
|
+
Args:
|
|
189
|
+
**kwargs: Keyword arguments to pass to widget constructor
|
|
190
|
+
(e.g., placeholder="Enter name", disabled=True)
|
|
191
|
+
|
|
192
|
+
Returns:
|
|
193
|
+
Widget instance configured from Field and kwargs
|
|
194
|
+
|
|
195
|
+
Example:
|
|
196
|
+
# In a custom layout's compose_form():
|
|
197
|
+
yield self.form.name(placeholder="Enter your name")
|
|
198
|
+
yield self.form.email(disabled=True)
|
|
199
|
+
"""
|
|
200
|
+
# Track rendering if we're inside a layout
|
|
201
|
+
if hasattr(self.form, '_current_layout'):
|
|
202
|
+
self.form._current_layout._track_field_render(self.name)
|
|
203
|
+
|
|
204
|
+
# Create widget with kwargs if not already created
|
|
205
|
+
if self._widget_instance is None:
|
|
206
|
+
widget = self.create_widget(**kwargs)
|
|
207
|
+
else:
|
|
208
|
+
# Widget already exists - for now, just return it
|
|
209
|
+
# (Could optionally update widget properties from kwargs)
|
|
210
|
+
widget = self._widget_instance
|
|
211
|
+
|
|
212
|
+
# Sync initial value to widget
|
|
213
|
+
if self._value is not None:
|
|
214
|
+
widget.value = self.field.to_widget(self._value)
|
|
215
|
+
|
|
216
|
+
return widget
|
|
217
|
+
|
|
173
218
|
def to_python(self, value: Any) -> Any:
|
|
174
219
|
"""Convert widget value to Python value (delegated to Field)"""
|
|
175
220
|
return self.field.to_python(value)
|
|
@@ -65,7 +65,7 @@ class AdvancedFormScreen(Screen):
|
|
|
65
65
|
align: center middle;
|
|
66
66
|
}
|
|
67
67
|
|
|
68
|
-
|
|
68
|
+
DefaultFormLayout {
|
|
69
69
|
width: 60;
|
|
70
70
|
height: auto;
|
|
71
71
|
max-height: 90%;
|
|
@@ -114,7 +114,7 @@ class AdvancedFormScreen(Screen):
|
|
|
114
114
|
|
|
115
115
|
def reset_form(self):
|
|
116
116
|
"""Clear form and create fresh one"""
|
|
117
|
-
old_form = self.query_one("
|
|
117
|
+
old_form = self.query_one("DefaultFormLayout")
|
|
118
118
|
old_form.remove()
|
|
119
119
|
|
|
120
120
|
self.form = ContactForm(title="Contact Information")
|
textual_wtf/demo/basic_form.py
CHANGED
|
@@ -63,7 +63,7 @@ class BasicFormScreen(Screen):
|
|
|
63
63
|
def reset_form(self):
|
|
64
64
|
"""Clear form and create fresh one"""
|
|
65
65
|
# Remove old form
|
|
66
|
-
old_form = self.query_one("
|
|
66
|
+
old_form = self.query_one("DefaultFormLayout")
|
|
67
67
|
old_form.remove()
|
|
68
68
|
|
|
69
69
|
# Create and mount new form
|
|
@@ -73,7 +73,7 @@ class ShopScreen(Screen):
|
|
|
73
73
|
align: center middle;
|
|
74
74
|
}
|
|
75
75
|
|
|
76
|
-
|
|
76
|
+
DefaultFormLayout {
|
|
77
77
|
width: 80;
|
|
78
78
|
height: 100%;
|
|
79
79
|
max-height: 100%;
|
|
@@ -138,7 +138,7 @@ class ShopScreen(Screen):
|
|
|
138
138
|
|
|
139
139
|
def reset_form(self):
|
|
140
140
|
"""Clear form and create fresh one"""
|
|
141
|
-
old_form = self.query_one("
|
|
141
|
+
old_form = self.query_one("DefaultFormLayout")
|
|
142
142
|
old_form.remove()
|
|
143
143
|
|
|
144
144
|
self.form = OrderForm(title="Order Form - Composed from Reusable Forms")
|
|
@@ -79,7 +79,7 @@ class ShopScreen(Screen):
|
|
|
79
79
|
align: center middle;
|
|
80
80
|
}
|
|
81
81
|
|
|
82
|
-
|
|
82
|
+
DefaultFormLayout {
|
|
83
83
|
width: 80;
|
|
84
84
|
height: 100%;
|
|
85
85
|
max-height: 100%;
|
|
@@ -147,7 +147,7 @@ class ShopScreen(Screen):
|
|
|
147
147
|
|
|
148
148
|
def reset_form(self):
|
|
149
149
|
"""Clear form and create fresh one"""
|
|
150
|
-
old_form = self.query_one("
|
|
150
|
+
old_form = self.query_one("DefaultFormLayout")
|
|
151
151
|
old_form.remove()
|
|
152
152
|
|
|
153
153
|
self.form = OrderForm(title="Order Form - Composed from Reusable Forms")
|
|
@@ -60,7 +60,7 @@ class RegistrationScreen(Screen):
|
|
|
60
60
|
border: solid blue;
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
-
|
|
63
|
+
DefaultFormLayout {
|
|
64
64
|
width: 60;
|
|
65
65
|
height: auto;
|
|
66
66
|
}
|
|
@@ -110,7 +110,7 @@ class RegistrationScreen(Screen):
|
|
|
110
110
|
|
|
111
111
|
def reset_form(self):
|
|
112
112
|
"""Clear form and create fresh one"""
|
|
113
|
-
old_form = self.query_one("
|
|
113
|
+
old_form = self.query_one("DefaultFormLayout")
|
|
114
114
|
old_form.remove()
|
|
115
115
|
self.form = RegistrationForm(title="Create Account")
|
|
116
116
|
self.query_one("VerticalScroll").mount(self.form.render())
|
textual_wtf/fields.py
CHANGED
|
@@ -83,13 +83,16 @@ class Field(ABC):
|
|
|
83
83
|
from .bound_fields import BoundField
|
|
84
84
|
return BoundField(self, form, name, initial)
|
|
85
85
|
|
|
86
|
-
def create_widget(self) -> Widget:
|
|
86
|
+
def create_widget(self, widget_kwargs: Optional[dict] = None) -> Widget:
|
|
87
87
|
"""
|
|
88
88
|
Factory method to create configured widget
|
|
89
89
|
|
|
90
90
|
Note: This creates the widget but doesn't store it.
|
|
91
91
|
The BoundField is responsible for storing widget instances.
|
|
92
92
|
|
|
93
|
+
Args:
|
|
94
|
+
widget_kwargs: Optional additional kwargs to merge with self.widget_kwargs
|
|
95
|
+
|
|
93
96
|
Returns:
|
|
94
97
|
Configured widget instance
|
|
95
98
|
"""
|
|
@@ -99,7 +102,10 @@ class Field(ABC):
|
|
|
99
102
|
f"or pass widget parameter"
|
|
100
103
|
)
|
|
101
104
|
|
|
105
|
+
# Merge base kwargs with runtime kwargs
|
|
102
106
|
kwargs = self.widget_kwargs.copy()
|
|
107
|
+
if widget_kwargs:
|
|
108
|
+
kwargs.update(widget_kwargs)
|
|
103
109
|
|
|
104
110
|
# Pass validators to widget if it supports them
|
|
105
111
|
if hasattr(self.widget_class, '__init__'):
|
textual_wtf/forms.py
CHANGED
|
@@ -1,11 +1,9 @@
|
|
|
1
1
|
"""Form metaclass and base classes"""
|
|
2
2
|
import copy
|
|
3
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
4
|
from textual.message import Message
|
|
8
|
-
|
|
5
|
+
from textual.containers import Center
|
|
6
|
+
from textual.widgets import Static
|
|
9
7
|
from .fields import Field
|
|
10
8
|
from .exceptions import FieldError, AmbiguousFieldError, ValidationError
|
|
11
9
|
|
|
@@ -100,192 +98,71 @@ class FormMetaclass(type):
|
|
|
100
98
|
return new_class
|
|
101
99
|
|
|
102
100
|
|
|
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
101
|
|
|
239
102
|
|
|
240
103
|
class BaseForm:
|
|
241
104
|
"""Base form class without metaclass"""
|
|
242
105
|
|
|
106
|
+
# Default layout class to use when rendering
|
|
107
|
+
layout_class = None # Will be set to DefaultFormLayout after import
|
|
108
|
+
|
|
243
109
|
def __init__(self, *children, data: Optional[Dict[str, Any]] = None,
|
|
244
110
|
field_order: Optional[List[str]] = None,
|
|
245
|
-
title: Optional[str] = None,
|
|
111
|
+
title: Optional[str] = None,
|
|
112
|
+
layout_class=None,
|
|
113
|
+
**kwargs):
|
|
246
114
|
"""
|
|
247
115
|
Initialize form
|
|
248
|
-
|
|
116
|
+
|
|
249
117
|
Creates BoundField instances from class-level Field definitions.
|
|
250
118
|
This is much faster than deep copying and enables thread-safe
|
|
251
119
|
Form class reuse.
|
|
252
|
-
|
|
120
|
+
|
|
253
121
|
Args:
|
|
254
122
|
data: Initial data dict
|
|
255
123
|
field_order: Custom field ordering
|
|
256
124
|
title: Form title
|
|
257
|
-
|
|
258
|
-
**kwargs: Additional kwargs for
|
|
125
|
+
layout_class: Custom layout class (overrides class-level layout_class)
|
|
126
|
+
**kwargs: Additional kwargs for layout
|
|
259
127
|
"""
|
|
260
128
|
self.data = data
|
|
261
129
|
self.children = children
|
|
262
130
|
self.field_order = field_order
|
|
263
131
|
self.title = title
|
|
264
132
|
self.kwargs = kwargs
|
|
265
|
-
|
|
133
|
+
|
|
134
|
+
# Layout configuration
|
|
135
|
+
if layout_class is not None:
|
|
136
|
+
self._layout_class = layout_class
|
|
137
|
+
elif self.layout_class is not None:
|
|
138
|
+
self._layout_class = self.layout_class
|
|
139
|
+
else:
|
|
140
|
+
# Will use DefaultFormLayout (imported at module level)
|
|
141
|
+
from .layouts import DefaultFormLayout
|
|
142
|
+
self._layout_class = DefaultFormLayout
|
|
266
143
|
|
|
267
144
|
# Create BoundFields from class-level Field definitions
|
|
268
145
|
# NO MORE DEEP COPY - just create lightweight BoundField wrappers
|
|
269
146
|
self.bound_fields: Dict[str, 'BoundField'] = {}
|
|
270
|
-
|
|
147
|
+
|
|
271
148
|
for name, field in self._base_fields.items():
|
|
272
149
|
# Get initial value from data if provided
|
|
273
150
|
initial = data.get(name) if data else None
|
|
274
151
|
# Create BoundField (holds runtime state)
|
|
275
152
|
bound_field = field.bind(self, name, initial)
|
|
276
153
|
self.bound_fields[name] = bound_field
|
|
277
|
-
|
|
154
|
+
|
|
278
155
|
# Set as attribute for direct access (form.fieldname)
|
|
279
156
|
setattr(self, name, bound_field)
|
|
280
157
|
|
|
281
158
|
# Apply custom field ordering if provided
|
|
282
159
|
self.order_fields(self.field_order)
|
|
283
|
-
|
|
160
|
+
|
|
284
161
|
@property
|
|
285
162
|
def fields(self) -> Dict[str, 'BoundField']:
|
|
286
163
|
"""
|
|
287
164
|
Backward compatibility: alias for bound_fields
|
|
288
|
-
|
|
165
|
+
|
|
289
166
|
Returns bound_fields so existing code using form.fields continues to work.
|
|
290
167
|
"""
|
|
291
168
|
return self.bound_fields
|
|
@@ -299,7 +176,7 @@ class BaseForm:
|
|
|
299
176
|
field = self.get_field(name)
|
|
300
177
|
if field:
|
|
301
178
|
return field
|
|
302
|
-
|
|
179
|
+
|
|
303
180
|
raise AttributeError(f"'{self.__class__.__name__}' object has no attribute '{name}'")
|
|
304
181
|
|
|
305
182
|
@classmethod
|
|
@@ -371,20 +248,24 @@ class BaseForm:
|
|
|
371
248
|
f"Use the full qualified name to disambiguate."
|
|
372
249
|
)
|
|
373
250
|
|
|
374
|
-
def render(self, id=None)
|
|
375
|
-
"""
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
251
|
+
def render(self, id=None):
|
|
252
|
+
"""
|
|
253
|
+
Render the form as a Textual widget
|
|
254
|
+
|
|
255
|
+
Creates a layout instance using the configured layout_class.
|
|
256
|
+
During rendering, sets self._current_layout so that field()
|
|
257
|
+
calls can track which fields have been rendered.
|
|
258
|
+
|
|
259
|
+
Returns:
|
|
260
|
+
Layout instance (e.g., DefaultFormLayout or custom layout)
|
|
261
|
+
"""
|
|
262
|
+
# Create layout instance
|
|
263
|
+
layout = self._layout_class(self, id=id, **self.kwargs)
|
|
264
|
+
|
|
265
|
+
# Store layout reference so BoundField.__call__ can track rendering
|
|
266
|
+
self._current_layout = layout
|
|
379
267
|
|
|
380
|
-
|
|
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
|
|
268
|
+
return layout
|
|
388
269
|
|
|
389
270
|
async def validate(self):
|
|
390
271
|
"""Validate all form fields"""
|
|
@@ -403,14 +284,14 @@ class BaseForm:
|
|
|
403
284
|
result = False
|
|
404
285
|
# Display errors
|
|
405
286
|
for msg in vr.failure_descriptions:
|
|
406
|
-
container.mount(Center(Static(msg, classes="form-error"))
|
|
287
|
+
container.mount(Center(Static(msg), classes="form-error"))
|
|
407
288
|
|
|
408
289
|
# Field-level validation (required, custom Field validators)
|
|
409
290
|
try:
|
|
410
291
|
field.clean(field.value)
|
|
411
292
|
except ValidationError as e:
|
|
412
293
|
result = False
|
|
413
|
-
container.mount(Center(Static(str(e), classes="form-error"))
|
|
294
|
+
container.mount(Center(Static(str(e)), classes="form-error"))
|
|
414
295
|
|
|
415
296
|
return result
|
|
416
297
|
|
|
@@ -422,12 +303,24 @@ class Form(BaseForm, metaclass=FormMetaclass):
|
|
|
422
303
|
|
|
423
304
|
class Submitted(Message):
|
|
424
305
|
"""Posted when form is submitted successfully"""
|
|
425
|
-
def __init__(self,
|
|
306
|
+
def __init__(self, layout):
|
|
307
|
+
"""
|
|
308
|
+
Args:
|
|
309
|
+
layout: The FormLayout instance that submitted the form
|
|
310
|
+
"""
|
|
426
311
|
super().__init__()
|
|
427
|
-
self.
|
|
312
|
+
self.layout = layout
|
|
313
|
+
# Provide backward compatibility
|
|
314
|
+
self.form = layout
|
|
428
315
|
|
|
429
316
|
class Cancelled(Message):
|
|
430
317
|
"""Posted when form is cancelled"""
|
|
431
|
-
def __init__(self,
|
|
318
|
+
def __init__(self, layout):
|
|
319
|
+
"""
|
|
320
|
+
Args:
|
|
321
|
+
layout: The FormLayout instance that cancelled the form
|
|
322
|
+
"""
|
|
432
323
|
super().__init__()
|
|
433
|
-
self.
|
|
324
|
+
self.layout = layout
|
|
325
|
+
# Provide backward compatibility
|
|
326
|
+
self.form = layout
|
textual_wtf/version.py
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
__version__ = "0.8.
|
|
1
|
+
__version__ = "0.8.1"
|
textual_wtf/widgets.py
CHANGED
|
@@ -4,6 +4,8 @@ from textual.widgets import Input, Checkbox, Select, TextArea, Static
|
|
|
4
4
|
from textual.containers import Center
|
|
5
5
|
from textual.validation import ValidationResult, Validator
|
|
6
6
|
|
|
7
|
+
import wingdbstub
|
|
8
|
+
|
|
7
9
|
if TYPE_CHECKING:
|
|
8
10
|
from .fields import Field
|
|
9
11
|
|
|
@@ -21,19 +23,21 @@ _id_gen = widget_id_gen()
|
|
|
21
23
|
|
|
22
24
|
class FormWidgetMixin:
|
|
23
25
|
"""Mixin to add validation error display to widgets"""
|
|
24
|
-
|
|
26
|
+
|
|
25
27
|
async def on_input_changed(self, event):
|
|
26
28
|
"""Handle input changes and display validation errors"""
|
|
27
29
|
if not hasattr(self, 'parent') or self.parent is None:
|
|
28
30
|
return
|
|
29
|
-
|
|
31
|
+
|
|
30
32
|
container = self.parent
|
|
31
|
-
|
|
33
|
+
|
|
32
34
|
# ALWAYS clear previous errors first
|
|
33
35
|
await container.remove_children(".form-error")
|
|
34
|
-
|
|
35
|
-
if
|
|
36
|
-
|
|
36
|
+
|
|
37
|
+
if (
|
|
38
|
+
hasattr(event, 'validation_result')
|
|
39
|
+
and (vr := event.validation_result) is not None
|
|
40
|
+
):
|
|
37
41
|
if not vr.is_valid:
|
|
38
42
|
# Show all errors from this validation
|
|
39
43
|
for msg in vr.failure_descriptions:
|
|
@@ -42,7 +46,7 @@ class FormWidgetMixin:
|
|
|
42
46
|
|
|
43
47
|
class FormInput(Input, FormWidgetMixin):
|
|
44
48
|
"""Text input widget for forms"""
|
|
45
|
-
|
|
49
|
+
|
|
46
50
|
def __init__(self, *, field: Optional['Field'] = None, valid_empty: bool = True,
|
|
47
51
|
validators: Optional[List[Validator]] = None, **kwargs):
|
|
48
52
|
kwargs.setdefault('id', next(_id_gen))
|
|
@@ -54,7 +58,7 @@ class FormInput(Input, FormWidgetMixin):
|
|
|
54
58
|
|
|
55
59
|
class FormIntegerInput(Input, FormWidgetMixin):
|
|
56
60
|
"""Integer input widget for forms"""
|
|
57
|
-
|
|
61
|
+
|
|
58
62
|
def __init__(self, *, field: Optional['Field'] = None, valid_empty: bool = True,
|
|
59
63
|
validators: Optional[List[Validator]] = None, **kwargs):
|
|
60
64
|
kwargs.setdefault('id', next(_id_gen))
|
|
@@ -67,22 +71,22 @@ class FormIntegerInput(Input, FormWidgetMixin):
|
|
|
67
71
|
|
|
68
72
|
class FormTextArea(TextArea, FormWidgetMixin):
|
|
69
73
|
"""Multi-line text area widget for forms"""
|
|
70
|
-
|
|
74
|
+
|
|
71
75
|
def __init__(self, *, field: Optional['Field'] = None, text: str = "", **kwargs):
|
|
72
76
|
kwargs.setdefault('id', next(_id_gen))
|
|
73
77
|
super().__init__(text=text, **kwargs)
|
|
74
78
|
self.field = field
|
|
75
|
-
|
|
79
|
+
|
|
76
80
|
@property
|
|
77
81
|
def value(self):
|
|
78
82
|
"""Get text area value"""
|
|
79
83
|
return self.text
|
|
80
|
-
|
|
84
|
+
|
|
81
85
|
@value.setter
|
|
82
86
|
def value(self, v):
|
|
83
87
|
"""Set text area value"""
|
|
84
88
|
self.text = v if v is not None else ""
|
|
85
|
-
|
|
89
|
+
|
|
86
90
|
def validate(self, value):
|
|
87
91
|
"""Validate text area value"""
|
|
88
92
|
return ValidationResult()
|
|
@@ -90,14 +94,14 @@ class FormTextArea(TextArea, FormWidgetMixin):
|
|
|
90
94
|
|
|
91
95
|
class FormCheckbox(Checkbox):
|
|
92
96
|
"""Checkbox widget for forms"""
|
|
93
|
-
|
|
97
|
+
|
|
94
98
|
def __init__(self, *, field: Optional['Field'] = None, label: str = "", **kwargs):
|
|
95
99
|
kwargs.setdefault('id', next(_id_gen))
|
|
96
100
|
super().__init__(value=False, **kwargs)
|
|
97
101
|
self.field = field
|
|
98
102
|
if label:
|
|
99
103
|
self.label = label
|
|
100
|
-
|
|
104
|
+
|
|
101
105
|
def validate(self, value):
|
|
102
106
|
"""Validate checkbox value"""
|
|
103
107
|
return ValidationResult()
|
|
@@ -105,27 +109,27 @@ class FormCheckbox(Checkbox):
|
|
|
105
109
|
|
|
106
110
|
class AlwaysValid(Validator):
|
|
107
111
|
"""Validator that always passes"""
|
|
108
|
-
|
|
112
|
+
|
|
109
113
|
def validate(self, value):
|
|
110
114
|
return self.success()
|
|
111
115
|
|
|
112
116
|
|
|
113
117
|
class FormSelect(Select):
|
|
114
118
|
"""Select dropdown widget for forms"""
|
|
115
|
-
|
|
116
|
-
def __init__(self, *, field: Optional['Field'] = None,
|
|
119
|
+
|
|
120
|
+
def __init__(self, *, field: Optional['Field'] = None,
|
|
117
121
|
choices: List[Tuple[str, str]], allow_blank: bool = False,
|
|
118
122
|
prompt: str = "Select an option", **kwargs):
|
|
119
123
|
kwargs.setdefault('id', next(_id_gen))
|
|
120
124
|
kwargs.setdefault('prompt', prompt)
|
|
121
|
-
|
|
125
|
+
|
|
122
126
|
# Convert tuples to Select.Option objects
|
|
123
127
|
options = [(label, value) for value, label in choices]
|
|
124
|
-
|
|
128
|
+
|
|
125
129
|
super().__init__(options=options, **kwargs)
|
|
126
130
|
self.field = field
|
|
127
131
|
self.allow_blank = allow_blank
|
|
128
|
-
|
|
132
|
+
|
|
129
133
|
def validate(self, value):
|
|
130
134
|
"""Validate select value"""
|
|
131
135
|
if value != Select.BLANK or self.allow_blank:
|
|
@@ -135,9 +139,9 @@ class FormSelect(Select):
|
|
|
135
139
|
|
|
136
140
|
class WidgetRegistry:
|
|
137
141
|
"""Registry for custom widgets"""
|
|
138
|
-
|
|
142
|
+
|
|
139
143
|
_widgets = {}
|
|
140
|
-
|
|
144
|
+
|
|
141
145
|
@classmethod
|
|
142
146
|
def register(cls, name: str):
|
|
143
147
|
"""Decorator to register a widget"""
|
|
@@ -145,12 +149,12 @@ class WidgetRegistry:
|
|
|
145
149
|
cls._widgets[name] = widget_class
|
|
146
150
|
return widget_class
|
|
147
151
|
return decorator
|
|
148
|
-
|
|
152
|
+
|
|
149
153
|
@classmethod
|
|
150
154
|
def get(cls, name: str):
|
|
151
155
|
"""Get a widget by name"""
|
|
152
156
|
return cls._widgets.get(name)
|
|
153
|
-
|
|
157
|
+
|
|
154
158
|
@classmethod
|
|
155
159
|
def list_widgets(cls):
|
|
156
160
|
"""List all registered widgets"""
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
textual_wtf/__init__.py,sha256=veKh7CxdNS0DruiCW_qgOmfcSQBj-307T4h9jhw4X3M,1453
|
|
2
|
+
textual_wtf/bound_fields.py,sha256=2Mnhx5Yb-D-skpfH4SSJX7PH_23bTZ38fgbwM9Zg-2U,10322
|
|
3
|
+
textual_wtf/exceptions.py,sha256=GYef5rI0ygqXVHvBO5lrV_Ltu78FPkNvAyRSnFXe2vY,594
|
|
4
|
+
textual_wtf/fields.py,sha256=IVigknWKnWnjGrdZJQWVgWIbeb4S33SaJMzvks8GRcA,9045
|
|
5
|
+
textual_wtf/forms.py,sha256=PAHtQ-8M9omRF3YhEmuJEdvcrkXwZai6hff4Ut3jbrU,11652
|
|
6
|
+
textual_wtf/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
7
|
+
textual_wtf/validators.py,sha256=635-JKuC9N0GAvy7No-mID_HtcvNvhKDBBI6FW4W0sQ,1192
|
|
8
|
+
textual_wtf/version.py,sha256=Ocl79hbbH8_jdr5dGC90VR1cAvZc05Rc0tkZttUnMjo,22
|
|
9
|
+
textual_wtf/widgets.py,sha256=4RtcDPefQmPJ5JbQXWS8MsXJ5p6Ex4ENNcWw87z8o8s,5047
|
|
10
|
+
textual_wtf/demo/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
11
|
+
textual_wtf/demo/advanced_form.py,sha256=RjJA3VbAxvdnBnMsQq4LpLvDnYJrDBR4j3lnlaJukpM,3614
|
|
12
|
+
textual_wtf/demo/basic_form.py,sha256=9hD5NSTGMuoKlogmm3H4OGiGrJJKAmGdLbPYykdwgeA,2660
|
|
13
|
+
textual_wtf/demo/basic_form.tcss,sha256=bRKdPZyIC1mLjfkmQiwcc3JwzcSDWI3EeLs42JeneSw,71
|
|
14
|
+
textual_wtf/demo/launcher.py,sha256=41DOguB7Dism9zav3NTBMJhtvkmi3bpfeocM3MvoPdk,3306
|
|
15
|
+
textual_wtf/demo/nested_once_form.py,sha256=ZsL1N6B_PhbOcDtw-4L76R-6J-q_U4rCZaM63fPGZ6E,5046
|
|
16
|
+
textual_wtf/demo/nested_twice_form.py,sha256=TvDnbIH6pzuGoR6yGzI7a0FZGNTyKk8D3_mQO7qx2aU,5073
|
|
17
|
+
textual_wtf/demo/results_screen.py,sha256=3DsL-WRXA9x9AdpT6r3HD3-snlEk9kzy_XwB7O4FsqU,2035
|
|
18
|
+
textual_wtf/demo/user_registration.py,sha256=gTs9kmtzALYIOu5rm7HMRlifWIYjmfPAUYG_jFvYr3s,3550
|
|
19
|
+
textual_wtf-0.8.1.dist-info/METADATA,sha256=l9gMOMfIPoAEpAP50tcJ6r_0DrcwlX-P3Tov207bYMw,8578
|
|
20
|
+
textual_wtf-0.8.1.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
|
|
21
|
+
textual_wtf-0.8.1.dist-info/entry_points.txt,sha256=jaJ4-T5M_61CTqJGCnRwAv6T7fVhWDzBaLKtTnoBbSM,63
|
|
22
|
+
textual_wtf-0.8.1.dist-info/licenses/LICENSE,sha256=OD-OyOXFRBQ9lM4Z-XGnP_WUfVObajlPYdVVP3c_7OM,1089
|
|
23
|
+
textual_wtf-0.8.1.dist-info/RECORD,,
|
|
@@ -1,23 +0,0 @@
|
|
|
1
|
-
textual_wtf/__init__.py,sha256=lo3oT72caUN0tiVmkVLY5JXGEisUwxA_xYPhgfdGzkQ,1345
|
|
2
|
-
textual_wtf/bound_fields.py,sha256=Jk5o81dRlvuGivR_XdtjOo7_SE-J8HuEVdrJrZhimG8,8570
|
|
3
|
-
textual_wtf/exceptions.py,sha256=GYef5rI0ygqXVHvBO5lrV_Ltu78FPkNvAyRSnFXe2vY,594
|
|
4
|
-
textual_wtf/fields.py,sha256=gODdtGJySuBycCeiUKvvU2k1nLRAdPMvALsrqKz1oSA,8782
|
|
5
|
-
textual_wtf/forms.py,sha256=tAXvf2p7OD2unBMbBA_uaOr2_6jzuMRzjVgfT_WlOco,14335
|
|
6
|
-
textual_wtf/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
7
|
-
textual_wtf/validators.py,sha256=635-JKuC9N0GAvy7No-mID_HtcvNvhKDBBI6FW4W0sQ,1192
|
|
8
|
-
textual_wtf/version.py,sha256=iPlYCcIzuzW7T2HKDkmYlMkRI51dBLfNRxPPiWrfw9U,22
|
|
9
|
-
textual_wtf/widgets.py,sha256=d5a0DiTJtdlQsQkeTvhEsHLDYwqEVn_zC6v8DzyN3NI,5130
|
|
10
|
-
textual_wtf/demo/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
11
|
-
textual_wtf/demo/advanced_form.py,sha256=sK9aRE8fp3OtDiWf9c3qpIMKz4pIjCpy5SE5J6o7t0g,3604
|
|
12
|
-
textual_wtf/demo/basic_form.py,sha256=x9RiosmkzDC0mYPdEMiGlRFqX0O9C5dJVyw1XdXdcpo,2655
|
|
13
|
-
textual_wtf/demo/basic_form.tcss,sha256=bRKdPZyIC1mLjfkmQiwcc3JwzcSDWI3EeLs42JeneSw,71
|
|
14
|
-
textual_wtf/demo/launcher.py,sha256=41DOguB7Dism9zav3NTBMJhtvkmi3bpfeocM3MvoPdk,3306
|
|
15
|
-
textual_wtf/demo/nested_once_form.py,sha256=Ta_86TDYgwg8QOjVyVgaRMKjPHAC4b64AR9ymURLR3M,5036
|
|
16
|
-
textual_wtf/demo/nested_twice_form.py,sha256=JDSDaO03p4p8tl562pNTnXXPN3Q5fDTqOvZ3dZ1L0SY,5063
|
|
17
|
-
textual_wtf/demo/results_screen.py,sha256=3DsL-WRXA9x9AdpT6r3HD3-snlEk9kzy_XwB7O4FsqU,2035
|
|
18
|
-
textual_wtf/demo/user_registration.py,sha256=LxyetdJmVs3G0ZoUr8PdGmFo-GEVPleCxdZZfEVAnJw,3540
|
|
19
|
-
textual_wtf-0.8.0.dist-info/METADATA,sha256=-IQ6VDcCKUpsGHo4mTE-ahcHmGBQ-UcftPzuVUSi-bE,8578
|
|
20
|
-
textual_wtf-0.8.0.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
|
|
21
|
-
textual_wtf-0.8.0.dist-info/entry_points.txt,sha256=jaJ4-T5M_61CTqJGCnRwAv6T7fVhWDzBaLKtTnoBbSM,63
|
|
22
|
-
textual_wtf-0.8.0.dist-info/licenses/LICENSE,sha256=OD-OyOXFRBQ9lM4Z-XGnP_WUfVObajlPYdVVP3c_7OM,1089
|
|
23
|
-
textual_wtf-0.8.0.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|