textual-wtf 0.8.1__tar.gz → 0.8.2__tar.gz
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-0.8.1 → textual_wtf-0.8.2}/PKG-INFO +1 -1
- {textual_wtf-0.8.1 → textual_wtf-0.8.2}/pyproject.toml +1 -1
- textual_wtf-0.8.2/src/textual_wtf/layouts.py +212 -0
- textual_wtf-0.8.2/src/textual_wtf/version.py +1 -0
- {textual_wtf-0.8.1 → textual_wtf-0.8.2}/src/textual_wtf/widgets.py +0 -2
- {textual_wtf-0.8.1 → textual_wtf-0.8.2}/uv.lock +1 -1
- textual_wtf-0.8.1/src/textual_wtf/version.py +0 -1
- {textual_wtf-0.8.1 → textual_wtf-0.8.2}/.coveragerc +0 -0
- {textual_wtf-0.8.1 → textual_wtf-0.8.2}/.github/workflows/pypi.yaml +0 -0
- {textual_wtf-0.8.1 → textual_wtf-0.8.2}/.gitignore +0 -0
- {textual_wtf-0.8.1 → textual_wtf-0.8.2}/BOUNDFIELD_REFACTORING_SUMMARY.md +0 -0
- {textual_wtf-0.8.1 → textual_wtf-0.8.2}/CHANGELOG-v0.9-dev1.md +0 -0
- {textual_wtf-0.8.1 → textual_wtf-0.8.2}/CHANGELOG.md +0 -0
- {textual_wtf-0.8.1 → textual_wtf-0.8.2}/IMPLEMENTATION_SUMMARY.md +0 -0
- {textual_wtf-0.8.1 → textual_wtf-0.8.2}/INSTALLATION.md +0 -0
- {textual_wtf-0.8.1 → textual_wtf-0.8.2}/LICENSE +0 -0
- {textual_wtf-0.8.1 → textual_wtf-0.8.2}/Makefile +0 -0
- {textual_wtf-0.8.1 → textual_wtf-0.8.2}/PACKAGE_SUMMARY.txt +0 -0
- {textual_wtf-0.8.1 → textual_wtf-0.8.2}/QUICKSTART.md +0 -0
- {textual_wtf-0.8.1 → textual_wtf-0.8.2}/README.md +0 -0
- {textual_wtf-0.8.1 → textual_wtf-0.8.2}/REFACTORING_SUMMARY.md +0 -0
- {textual_wtf-0.8.1 → textual_wtf-0.8.2}/docs/TESTING_GUIDE.md +0 -0
- {textual_wtf-0.8.1 → textual_wtf-0.8.2}/examples/custom_layouts_demo.py +0 -0
- {textual_wtf-0.8.1 → textual_wtf-0.8.2}/examples/test_new_features.py +0 -0
- {textual_wtf-0.8.1 → textual_wtf-0.8.2}/src/textual_wtf/__init__.py +0 -0
- {textual_wtf-0.8.1 → textual_wtf-0.8.2}/src/textual_wtf/bound_fields.py +0 -0
- {textual_wtf-0.8.1 → textual_wtf-0.8.2}/src/textual_wtf/demo/__init__.py +0 -0
- {textual_wtf-0.8.1 → textual_wtf-0.8.2}/src/textual_wtf/demo/advanced_form.py +0 -0
- {textual_wtf-0.8.1 → textual_wtf-0.8.2}/src/textual_wtf/demo/basic_form.py +0 -0
- {textual_wtf-0.8.1 → textual_wtf-0.8.2}/src/textual_wtf/demo/basic_form.tcss +0 -0
- {textual_wtf-0.8.1 → textual_wtf-0.8.2}/src/textual_wtf/demo/launcher.py +0 -0
- {textual_wtf-0.8.1 → textual_wtf-0.8.2}/src/textual_wtf/demo/nested_once_form.py +0 -0
- {textual_wtf-0.8.1 → textual_wtf-0.8.2}/src/textual_wtf/demo/nested_twice_form.py +0 -0
- {textual_wtf-0.8.1 → textual_wtf-0.8.2}/src/textual_wtf/demo/results_screen.py +0 -0
- {textual_wtf-0.8.1 → textual_wtf-0.8.2}/src/textual_wtf/demo/user_registration.py +0 -0
- {textual_wtf-0.8.1 → textual_wtf-0.8.2}/src/textual_wtf/exceptions.py +0 -0
- {textual_wtf-0.8.1 → textual_wtf-0.8.2}/src/textual_wtf/fields.py +0 -0
- {textual_wtf-0.8.1 → textual_wtf-0.8.2}/src/textual_wtf/forms.py +0 -0
- {textual_wtf-0.8.1 → textual_wtf-0.8.2}/src/textual_wtf/py.typed +0 -0
- {textual_wtf-0.8.1 → textual_wtf-0.8.2}/src/textual_wtf/validators.py +0 -0
- {textual_wtf-0.8.1 → textual_wtf-0.8.2}/tests/__init__.py +0 -0
- {textual_wtf-0.8.1 → textual_wtf-0.8.2}/tests/conftest.py +0 -0
- {textual_wtf-0.8.1 → textual_wtf-0.8.2}/tests/test_composition.py +0 -0
- {textual_wtf-0.8.1 → textual_wtf-0.8.2}/tests/test_exceptions.py +0 -0
- {textual_wtf-0.8.1 → textual_wtf-0.8.2}/tests/test_fields.py +0 -0
- {textual_wtf-0.8.1 → textual_wtf-0.8.2}/tests/test_forms.py +0 -0
- {textual_wtf-0.8.1 → textual_wtf-0.8.2}/tests/test_integration.py +0 -0
- {textual_wtf-0.8.1 → textual_wtf-0.8.2}/tests/test_user_input.py +0 -0
- {textual_wtf-0.8.1 → textual_wtf-0.8.2}/tests/test_validators.py +0 -0
- {textual_wtf-0.8.1 → textual_wtf-0.8.2}/tests/test_widgets.py +0 -0
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
"""Form layout classes for rendering forms"""
|
|
2
|
+
from typing import Optional, Set, TYPE_CHECKING
|
|
3
|
+
from textual import on
|
|
4
|
+
from textual.app import ComposeResult
|
|
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 .exceptions import FormError
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from .forms import Form
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class FormLayout(VerticalScroll):
|
|
16
|
+
"""
|
|
17
|
+
Base class for form layouts
|
|
18
|
+
|
|
19
|
+
Handles the visual presentation of a form. Subclass this to create
|
|
20
|
+
custom form layouts with different visual arrangements of fields.
|
|
21
|
+
|
|
22
|
+
The layout tracks which fields have been rendered to prevent duplicate
|
|
23
|
+
rendering (which would create multiple widgets for the same field).
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
DEFAULT_CSS = """
|
|
27
|
+
FormLayout {
|
|
28
|
+
keyline: thin green;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
Vertical {
|
|
32
|
+
margin: 1;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
#form-title {
|
|
36
|
+
background: blue;
|
|
37
|
+
height: auto;
|
|
38
|
+
margin: 1;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
.subform-title {
|
|
42
|
+
background: white;
|
|
43
|
+
color: black;
|
|
44
|
+
height: auto;
|
|
45
|
+
padding: 0 1;
|
|
46
|
+
margin: 1 0 0 0;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
.form-field {
|
|
50
|
+
height: auto;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
.form-error {
|
|
54
|
+
color: red;
|
|
55
|
+
width: 1fr;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
#buttons {
|
|
59
|
+
height: auto;
|
|
60
|
+
align: center middle;
|
|
61
|
+
margin: 0;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
#outer-buttons {
|
|
65
|
+
height: auto;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
Input {
|
|
69
|
+
height: auto;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
TextArea {
|
|
73
|
+
height: 6;
|
|
74
|
+
}
|
|
75
|
+
"""
|
|
76
|
+
|
|
77
|
+
def __init__(self, form: 'Form', id: Optional[str] = None, **kwargs):
|
|
78
|
+
"""
|
|
79
|
+
Initialize form layout
|
|
80
|
+
|
|
81
|
+
Args:
|
|
82
|
+
form: The Form instance to render
|
|
83
|
+
id: Optional widget ID
|
|
84
|
+
**kwargs: Additional kwargs for VerticalScroll
|
|
85
|
+
"""
|
|
86
|
+
super().__init__(id=id, **kwargs)
|
|
87
|
+
self.form = form
|
|
88
|
+
self._rendered_fields: Set[str] = set()
|
|
89
|
+
|
|
90
|
+
def _track_field_render(self, field_name: str) -> None:
|
|
91
|
+
"""
|
|
92
|
+
Track that a field has been rendered
|
|
93
|
+
|
|
94
|
+
Raises FormError if field already rendered (prevents duplicate widgets).
|
|
95
|
+
|
|
96
|
+
Args:
|
|
97
|
+
field_name: Name of the field being rendered
|
|
98
|
+
"""
|
|
99
|
+
if field_name in self._rendered_fields:
|
|
100
|
+
raise FormError(
|
|
101
|
+
f"Field '{field_name}' has already been rendered. "
|
|
102
|
+
f"Each field can only be rendered once per form."
|
|
103
|
+
)
|
|
104
|
+
self._rendered_fields.add(field_name)
|
|
105
|
+
|
|
106
|
+
def compose(self):
|
|
107
|
+
"""
|
|
108
|
+
Compose the layout
|
|
109
|
+
|
|
110
|
+
Calls compose_form() to build the form UI. Subclasses should
|
|
111
|
+
override compose_form(), not this method.
|
|
112
|
+
"""
|
|
113
|
+
yield from self.compose_form()
|
|
114
|
+
|
|
115
|
+
def compose_form(self):
|
|
116
|
+
"""
|
|
117
|
+
Compose the form UI
|
|
118
|
+
|
|
119
|
+
Override this method in subclasses to create custom layouts.
|
|
120
|
+
Use form.fieldname() to render each field.
|
|
121
|
+
|
|
122
|
+
Example:
|
|
123
|
+
def compose_form(self):
|
|
124
|
+
yield Label("My Custom Form")
|
|
125
|
+
yield self.form.name()
|
|
126
|
+
yield self.form.email()
|
|
127
|
+
yield Button("Submit", id="submit")
|
|
128
|
+
"""
|
|
129
|
+
raise NotImplementedError("FormLayout subclasses must implement compose_form()")
|
|
130
|
+
|
|
131
|
+
def get_data(self):
|
|
132
|
+
"""Get current form data"""
|
|
133
|
+
return self.form.get_data()
|
|
134
|
+
|
|
135
|
+
def set_data(self, data):
|
|
136
|
+
"""Set form data"""
|
|
137
|
+
return self.form.set_data(data)
|
|
138
|
+
|
|
139
|
+
async def validate(self):
|
|
140
|
+
"""Validate all form fields"""
|
|
141
|
+
return await self.form.validate()
|
|
142
|
+
|
|
143
|
+
@on(Button.Pressed, "#submit")
|
|
144
|
+
async def submit_pressed(self, event: Button.Pressed) -> None:
|
|
145
|
+
"""Handle submit button press"""
|
|
146
|
+
# Import here to avoid circular import
|
|
147
|
+
from .forms import Form
|
|
148
|
+
|
|
149
|
+
if await self.validate():
|
|
150
|
+
self.post_message(Form.Submitted(self))
|
|
151
|
+
else:
|
|
152
|
+
self.app.notify("Please fix the errors before submitting", severity="error")
|
|
153
|
+
|
|
154
|
+
@on(Button.Pressed, "#cancel")
|
|
155
|
+
async def cancel_pressed(self, event: Button.Pressed) -> None:
|
|
156
|
+
"""Handle cancel button press"""
|
|
157
|
+
# Import here to avoid circular import
|
|
158
|
+
from .forms import Form
|
|
159
|
+
|
|
160
|
+
self.post_message(Form.Cancelled(self))
|
|
161
|
+
|
|
162
|
+
class DefaultFormLayout(FormLayout):
|
|
163
|
+
"""
|
|
164
|
+
Default form layout
|
|
165
|
+
|
|
166
|
+
Renders forms in the traditional vertical style:
|
|
167
|
+
- Optional title
|
|
168
|
+
- Subform titles for composed forms
|
|
169
|
+
- Each field with label and widget
|
|
170
|
+
- Submit/Cancel buttons
|
|
171
|
+
"""
|
|
172
|
+
|
|
173
|
+
def compose_form(self) -> ComposeResult:
|
|
174
|
+
"""Compose the form in default vertical layout"""
|
|
175
|
+
# Optional title
|
|
176
|
+
if self.form.title is not None:
|
|
177
|
+
yield Vertical(
|
|
178
|
+
Center(Static(f"---- {self.form.title} ----")),
|
|
179
|
+
id="form-title"
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
# Track which subforms we've already rendered headers for
|
|
183
|
+
rendered_subforms = set()
|
|
184
|
+
|
|
185
|
+
# Render each field
|
|
186
|
+
for name, field in self.form.fields.items():
|
|
187
|
+
# Check if this field is part of a composed subform with a title
|
|
188
|
+
metadata = self.form._composition_metadata.get(name)
|
|
189
|
+
if metadata and metadata.get('title'):
|
|
190
|
+
subform_id = metadata['composed_from']
|
|
191
|
+
|
|
192
|
+
# Render subform title once per subform
|
|
193
|
+
if subform_id not in rendered_subforms:
|
|
194
|
+
yield Static(metadata['title'], classes="subform-title")
|
|
195
|
+
rendered_subforms.add(subform_id)
|
|
196
|
+
|
|
197
|
+
with Vertical(classes="form-field"):
|
|
198
|
+
if field.label:
|
|
199
|
+
yield Label(field.label)
|
|
200
|
+
# Call the field to get its widget
|
|
201
|
+
yield field()
|
|
202
|
+
|
|
203
|
+
# Submit/Cancel buttons
|
|
204
|
+
yield Vertical(
|
|
205
|
+
Horizontal(
|
|
206
|
+
Button("Cancel", id="cancel"),
|
|
207
|
+
Button("Submit", id="submit", variant="primary"),
|
|
208
|
+
id="buttons"
|
|
209
|
+
),
|
|
210
|
+
id="outer-buttons"
|
|
211
|
+
)
|
|
212
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.8.2"
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
__version__ = "0.8.1"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|