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/__init__.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Textual Forms - A declarative forms library for Textual TUI applications
|
|
3
|
+
|
|
4
|
+
Example:
|
|
5
|
+
from textual_wtf import Form, StringField, IntegerField
|
|
6
|
+
|
|
7
|
+
class UserForm(Form):
|
|
8
|
+
name = StringField(label="Name", required=True)
|
|
9
|
+
age = IntegerField(label="Age", min_value=0, max_value=130)
|
|
10
|
+
"""
|
|
11
|
+
from .version import __version__
|
|
12
|
+
|
|
13
|
+
from .exceptions import ValidationError, FieldError, FormError, AmbiguousFieldError
|
|
14
|
+
from .bound_fields import BoundField
|
|
15
|
+
from .fields import (
|
|
16
|
+
Field,
|
|
17
|
+
StringField,
|
|
18
|
+
IntegerField,
|
|
19
|
+
BooleanField,
|
|
20
|
+
ChoiceField,
|
|
21
|
+
TextField,
|
|
22
|
+
)
|
|
23
|
+
from .forms import Form
|
|
24
|
+
from .validators import (
|
|
25
|
+
EvenInteger,
|
|
26
|
+
Palindromic,
|
|
27
|
+
EmailValidator,
|
|
28
|
+
)
|
|
29
|
+
from .widgets import (
|
|
30
|
+
FormInput,
|
|
31
|
+
FormIntegerInput,
|
|
32
|
+
FormTextArea,
|
|
33
|
+
FormCheckbox,
|
|
34
|
+
FormSelect,
|
|
35
|
+
WidgetRegistry,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
__all__ = [
|
|
39
|
+
# Exceptions
|
|
40
|
+
"ValidationError",
|
|
41
|
+
"FieldError",
|
|
42
|
+
"FormError",
|
|
43
|
+
"AmbiguousFieldError",
|
|
44
|
+
# Fields
|
|
45
|
+
"Field",
|
|
46
|
+
"BoundField",
|
|
47
|
+
"StringField",
|
|
48
|
+
"IntegerField",
|
|
49
|
+
"BooleanField",
|
|
50
|
+
"ChoiceField",
|
|
51
|
+
"TextField",
|
|
52
|
+
# Forms
|
|
53
|
+
"Form",
|
|
54
|
+
# Validators
|
|
55
|
+
"EvenInteger",
|
|
56
|
+
"Palindromic",
|
|
57
|
+
"EmailValidator",
|
|
58
|
+
# Widgets
|
|
59
|
+
"FormInput",
|
|
60
|
+
"FormIntegerInput",
|
|
61
|
+
"FormTextArea",
|
|
62
|
+
"FormCheckbox",
|
|
63
|
+
"FormSelect",
|
|
64
|
+
"WidgetRegistry",
|
|
65
|
+
# Structural
|
|
66
|
+
"__version__,"
|
|
67
|
+
]
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
"""BoundField - runtime state for fields in form instances"""
|
|
2
|
+
from typing import Any, Optional, List, TYPE_CHECKING
|
|
3
|
+
from textual.widget import Widget
|
|
4
|
+
from textual.validation import Validator
|
|
5
|
+
|
|
6
|
+
from .exceptions import ValidationError
|
|
7
|
+
|
|
8
|
+
if TYPE_CHECKING:
|
|
9
|
+
from .fields import Field
|
|
10
|
+
from .forms import Form
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class BoundField:
|
|
14
|
+
"""
|
|
15
|
+
Holds runtime state for a field in a form instance.
|
|
16
|
+
|
|
17
|
+
Separates mutable state (_widget, _errors, _value) from immutable
|
|
18
|
+
configuration (stored in the Field instance). This allows Field
|
|
19
|
+
instances to be safely shared across multiple form instances.
|
|
20
|
+
|
|
21
|
+
CRITICAL INVARIANT: _value must always contain the current Python value,
|
|
22
|
+
regardless of whether a widget exists. This ensures data is never lost
|
|
23
|
+
and provides a canonical source of truth.
|
|
24
|
+
|
|
25
|
+
Attributes:
|
|
26
|
+
field: Reference to the Field configuration (shared, immutable)
|
|
27
|
+
form: Parent form instance
|
|
28
|
+
name: Field name in the form
|
|
29
|
+
_widget_instance: The actual Textual widget (or None)
|
|
30
|
+
_errors: Current validation errors
|
|
31
|
+
_value: Current Python value (ALWAYS KEPT IN SYNC)
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
def __init__(self, field: 'Field', form: 'Form', name: str, initial: Any = None):
|
|
35
|
+
"""
|
|
36
|
+
Initialize BoundField with runtime state
|
|
37
|
+
|
|
38
|
+
Args:
|
|
39
|
+
field: Field configuration (shared across instances)
|
|
40
|
+
form: Parent form instance
|
|
41
|
+
name: Field name
|
|
42
|
+
initial: Initial value (overrides field.initial if provided)
|
|
43
|
+
"""
|
|
44
|
+
# Reference to configuration
|
|
45
|
+
self.field = field
|
|
46
|
+
self.form = form
|
|
47
|
+
self.name = name
|
|
48
|
+
|
|
49
|
+
# Runtime state (mutable, instance-specific)
|
|
50
|
+
self._widget_instance: Optional[Widget] = None
|
|
51
|
+
self._errors: List[str] = []
|
|
52
|
+
self._value = initial if initial is not None else field.initial
|
|
53
|
+
|
|
54
|
+
# ========================================================================
|
|
55
|
+
# Properties - Delegate configuration to Field
|
|
56
|
+
# ========================================================================
|
|
57
|
+
|
|
58
|
+
@property
|
|
59
|
+
def label(self) -> Optional[str]:
|
|
60
|
+
"""Field label (delegated to Field config)"""
|
|
61
|
+
return self.field.label
|
|
62
|
+
|
|
63
|
+
@property
|
|
64
|
+
def help_text(self) -> Optional[str]:
|
|
65
|
+
"""Field help text (delegated to Field config)"""
|
|
66
|
+
return self.field.help_text
|
|
67
|
+
|
|
68
|
+
@property
|
|
69
|
+
def required(self) -> bool:
|
|
70
|
+
"""Whether field is required (delegated to Field config)"""
|
|
71
|
+
return self.field.required
|
|
72
|
+
|
|
73
|
+
@property
|
|
74
|
+
def disabled(self) -> bool:
|
|
75
|
+
"""Whether field is disabled (delegated to Field config)"""
|
|
76
|
+
return self.field.disabled
|
|
77
|
+
|
|
78
|
+
@property
|
|
79
|
+
def validators(self) -> List[Validator]:
|
|
80
|
+
"""Field validators (delegated to Field config)"""
|
|
81
|
+
return self.field.validators
|
|
82
|
+
|
|
83
|
+
@property
|
|
84
|
+
def initial(self) -> Any:
|
|
85
|
+
"""Initial value (delegated to Field config)"""
|
|
86
|
+
return self.field.initial
|
|
87
|
+
|
|
88
|
+
# ========================================================================
|
|
89
|
+
# Properties - Runtime state
|
|
90
|
+
# ========================================================================
|
|
91
|
+
|
|
92
|
+
@property
|
|
93
|
+
def widget(self) -> Optional[Widget]:
|
|
94
|
+
"""Get widget instance"""
|
|
95
|
+
return self._widget_instance
|
|
96
|
+
|
|
97
|
+
@widget.setter
|
|
98
|
+
def widget(self, widget: Widget) -> None:
|
|
99
|
+
"""Set widget instance"""
|
|
100
|
+
# Sync value from departing widget before it's replaced or cleared,
|
|
101
|
+
# so that any user input is preserved in _value.
|
|
102
|
+
if self._widget_instance is not None:
|
|
103
|
+
self._value = self.field.to_python(self._widget_instance.value)
|
|
104
|
+
|
|
105
|
+
self._widget_instance = widget
|
|
106
|
+
if widget:
|
|
107
|
+
# Create back-reference so widget can access bound field
|
|
108
|
+
widget.bound_field = self
|
|
109
|
+
|
|
110
|
+
@property
|
|
111
|
+
def errors(self) -> List[str]:
|
|
112
|
+
"""Get validation errors"""
|
|
113
|
+
return self._errors
|
|
114
|
+
|
|
115
|
+
@errors.setter
|
|
116
|
+
def errors(self, errors: List[str]) -> None:
|
|
117
|
+
"""Set validation errors"""
|
|
118
|
+
self._errors = errors
|
|
119
|
+
|
|
120
|
+
@property
|
|
121
|
+
def value(self) -> Any:
|
|
122
|
+
"""
|
|
123
|
+
Get current field value
|
|
124
|
+
|
|
125
|
+
CRITICAL: If widget exists, reads from widget and SYNCS to _value.
|
|
126
|
+
This ensures _value is always current, even when user types in widget.
|
|
127
|
+
|
|
128
|
+
Returns:
|
|
129
|
+
Current Python value (from widget if exists, otherwise from _value)
|
|
130
|
+
"""
|
|
131
|
+
if self._widget_instance is not None:
|
|
132
|
+
# Read from widget and keep _value in sync
|
|
133
|
+
python_value = self.field.to_python(self._widget_instance.value)
|
|
134
|
+
self._value = python_value # CRITICAL: Always sync
|
|
135
|
+
return python_value
|
|
136
|
+
return self._value
|
|
137
|
+
|
|
138
|
+
@value.setter
|
|
139
|
+
def value(self, value: Any) -> None:
|
|
140
|
+
"""
|
|
141
|
+
Set field value
|
|
142
|
+
|
|
143
|
+
CRITICAL: Always updates _value (canonical storage) and syncs to
|
|
144
|
+
widget if it exists. This ensures both storage locations stay in sync.
|
|
145
|
+
|
|
146
|
+
Args:
|
|
147
|
+
value: Python value to set
|
|
148
|
+
"""
|
|
149
|
+
self._value = value # CRITICAL: Always update canonical storage
|
|
150
|
+
if self._widget_instance is not None:
|
|
151
|
+
# Also sync to widget if it exists
|
|
152
|
+
self._widget_instance.value = self.field.to_widget(value)
|
|
153
|
+
|
|
154
|
+
# ========================================================================
|
|
155
|
+
# Methods - Delegate to Field for logic
|
|
156
|
+
# ========================================================================
|
|
157
|
+
|
|
158
|
+
def create_widget(self) -> Widget:
|
|
159
|
+
"""
|
|
160
|
+
Create widget using field configuration
|
|
161
|
+
|
|
162
|
+
Returns:
|
|
163
|
+
Widget instance configured from Field
|
|
164
|
+
"""
|
|
165
|
+
widget = self.field.create_widget()
|
|
166
|
+
self._widget_instance = widget
|
|
167
|
+
# Set back-reference
|
|
168
|
+
widget.bound_field = self
|
|
169
|
+
# Also set field reference for backward compatibility
|
|
170
|
+
widget.field = self
|
|
171
|
+
return widget
|
|
172
|
+
|
|
173
|
+
def to_python(self, value: Any) -> Any:
|
|
174
|
+
"""Convert widget value to Python value (delegated to Field)"""
|
|
175
|
+
return self.field.to_python(value)
|
|
176
|
+
|
|
177
|
+
def to_widget(self, value: Any) -> Any:
|
|
178
|
+
"""Convert Python value to widget value (delegated to Field)"""
|
|
179
|
+
return self.field.to_widget(value)
|
|
180
|
+
|
|
181
|
+
def validate(self, value: Any) -> None:
|
|
182
|
+
"""
|
|
183
|
+
Validate value using field configuration
|
|
184
|
+
|
|
185
|
+
Clears existing errors and validates the value.
|
|
186
|
+
Stores validation errors in _errors list.
|
|
187
|
+
|
|
188
|
+
Args:
|
|
189
|
+
value: Value to validate
|
|
190
|
+
"""
|
|
191
|
+
self._errors = []
|
|
192
|
+
try:
|
|
193
|
+
self.field.validate(value)
|
|
194
|
+
except ValidationError as e:
|
|
195
|
+
self._errors.append(str(e))
|
|
196
|
+
|
|
197
|
+
def clean(self, value: Any) -> Any:
|
|
198
|
+
"""
|
|
199
|
+
Convert and validate value
|
|
200
|
+
|
|
201
|
+
Args:
|
|
202
|
+
value: Raw value to clean
|
|
203
|
+
|
|
204
|
+
Returns:
|
|
205
|
+
Cleaned Python value
|
|
206
|
+
|
|
207
|
+
Raises:
|
|
208
|
+
ValidationError: If validation fails
|
|
209
|
+
"""
|
|
210
|
+
python_value = self.field.to_python(value)
|
|
211
|
+
self.validate(python_value)
|
|
212
|
+
if self._errors:
|
|
213
|
+
raise ValidationError(self._errors[0])
|
|
214
|
+
return python_value
|
|
215
|
+
|
|
216
|
+
# ========================================================================
|
|
217
|
+
# Additional properties for compatibility with code that accesses
|
|
218
|
+
# field-specific attributes (e.g., IntegerField.min_value)
|
|
219
|
+
# ========================================================================
|
|
220
|
+
|
|
221
|
+
def __getattr__(self, name: str) -> Any:
|
|
222
|
+
"""
|
|
223
|
+
Delegate attribute access to Field for any attributes not found on BoundField
|
|
224
|
+
|
|
225
|
+
This allows access to field-specific configuration like:
|
|
226
|
+
- IntegerField.min_value, max_value
|
|
227
|
+
- ChoiceField.choices
|
|
228
|
+
- etc.
|
|
229
|
+
"""
|
|
230
|
+
# Avoid infinite recursion for special attributes
|
|
231
|
+
if name.startswith('_'):
|
|
232
|
+
raise AttributeError(f"'{type(self).__name__}' object has no attribute '{name}'")
|
|
233
|
+
|
|
234
|
+
# Try to get attribute from Field
|
|
235
|
+
try:
|
|
236
|
+
return getattr(self.field, name)
|
|
237
|
+
except AttributeError:
|
|
238
|
+
raise AttributeError(
|
|
239
|
+
f"'{type(self).__name__}' and '{type(self.field).__name__}' "
|
|
240
|
+
f"have no attribute '{name}'"
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
def __repr__(self) -> str:
|
|
244
|
+
"""String representation for debugging"""
|
|
245
|
+
return f"<BoundField: {self.name} ({self.field.__class__.__name__})>"
|
|
File without changes
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"""Advanced form example with results display"""
|
|
2
|
+
from textual.app import App, ComposeResult
|
|
3
|
+
from textual.containers import Container
|
|
4
|
+
from textual.screen import Screen
|
|
5
|
+
from textual.widgets import Static
|
|
6
|
+
from textual_wtf import (
|
|
7
|
+
Form, StringField, IntegerField, BooleanField, ChoiceField, TextField
|
|
8
|
+
)
|
|
9
|
+
from textual_wtf.validators import EmailValidator, EvenInteger
|
|
10
|
+
from textual_wtf.demo.results_screen import ResultsDisplayScreen
|
|
11
|
+
|
|
12
|
+
class ContactForm(Form):
|
|
13
|
+
"""Contact form with multiple field types"""
|
|
14
|
+
name = StringField(
|
|
15
|
+
label="Full Name",
|
|
16
|
+
required=True,
|
|
17
|
+
help_text="Enter your full name"
|
|
18
|
+
)
|
|
19
|
+
email = StringField(
|
|
20
|
+
label="Email",
|
|
21
|
+
required=True,
|
|
22
|
+
validators=[EmailValidator()]
|
|
23
|
+
)
|
|
24
|
+
age = IntegerField(
|
|
25
|
+
label="Age (even numbers only)",
|
|
26
|
+
min_value=18,
|
|
27
|
+
max_value=100,
|
|
28
|
+
validators=[EvenInteger()]
|
|
29
|
+
)
|
|
30
|
+
country = ChoiceField(
|
|
31
|
+
label="Country",
|
|
32
|
+
choices=[
|
|
33
|
+
("us", "United States"),
|
|
34
|
+
("uk", "United Kingdom"),
|
|
35
|
+
("ca", "Canada"),
|
|
36
|
+
("au", "Australia"),
|
|
37
|
+
],
|
|
38
|
+
required=True
|
|
39
|
+
)
|
|
40
|
+
subscribe = BooleanField(
|
|
41
|
+
label="Subscribe to newsletter"
|
|
42
|
+
)
|
|
43
|
+
message = TextField(
|
|
44
|
+
label="Message",
|
|
45
|
+
help_text="Tell us about yourself"
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class ResultsScreen(ResultsDisplayScreen):
|
|
50
|
+
"""Utility Screen to display form results"""
|
|
51
|
+
|
|
52
|
+
def compose(self) -> ComposeResult:
|
|
53
|
+
with Container(id="results-container"):
|
|
54
|
+
yield Static(self.result_title, id="results-title")
|
|
55
|
+
|
|
56
|
+
yield from self.show_data()
|
|
57
|
+
yield from self.buttons()
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class AdvancedFormScreen(Screen):
|
|
61
|
+
"""Demo app for advanced form features"""
|
|
62
|
+
|
|
63
|
+
CSS = """
|
|
64
|
+
Screen {
|
|
65
|
+
align: center middle;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
RenderedForm {
|
|
69
|
+
width: 60;
|
|
70
|
+
height: auto;
|
|
71
|
+
max-height: 90%;
|
|
72
|
+
}
|
|
73
|
+
"""
|
|
74
|
+
|
|
75
|
+
def compose(self) -> ComposeResult:
|
|
76
|
+
# Pre-populate with example data
|
|
77
|
+
initial_data = {
|
|
78
|
+
"name": "John Doe",
|
|
79
|
+
"email": "john@example.com",
|
|
80
|
+
"age": 30,
|
|
81
|
+
"country": "us",
|
|
82
|
+
"subscribe": True,
|
|
83
|
+
}
|
|
84
|
+
self.form = ContactForm(
|
|
85
|
+
title="Contact Information",
|
|
86
|
+
data=initial_data
|
|
87
|
+
)
|
|
88
|
+
yield self.form.render()
|
|
89
|
+
|
|
90
|
+
def on_form_submitted(self, event: Form.Submitted):
|
|
91
|
+
"""Handle form submission"""
|
|
92
|
+
data = event.form.get_data()
|
|
93
|
+
|
|
94
|
+
def check_reset(cont):
|
|
95
|
+
if cont:
|
|
96
|
+
self.reset_form()
|
|
97
|
+
else:
|
|
98
|
+
self.dismiss(cont)
|
|
99
|
+
|
|
100
|
+
self.app.push_screen(
|
|
101
|
+
ResultsScreen("Form Submitted Successfully!", data), check_reset
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
def on_form_cancelled(self, event: Form.Cancelled):
|
|
105
|
+
"""Handle form cancellation"""
|
|
106
|
+
|
|
107
|
+
def check_reset(cont):
|
|
108
|
+
if cont:
|
|
109
|
+
self.reset_form()
|
|
110
|
+
else:
|
|
111
|
+
self.dismiss(cont)
|
|
112
|
+
|
|
113
|
+
self.app.push_screen(ResultsScreen("Form Cancelled", None), check_reset)
|
|
114
|
+
|
|
115
|
+
def reset_form(self):
|
|
116
|
+
"""Clear form and create fresh one"""
|
|
117
|
+
old_form = self.query_one("RenderedForm")
|
|
118
|
+
old_form.remove()
|
|
119
|
+
|
|
120
|
+
self.form = ContactForm(title="Contact Information")
|
|
121
|
+
self.mount(self.form.render())
|
|
122
|
+
|
|
123
|
+
class AdvancedFormApp(App):
|
|
124
|
+
|
|
125
|
+
def on_mount(self):
|
|
126
|
+
self.app.push_screen(AdvancedFormScreen(), callback=self.exit_app)
|
|
127
|
+
|
|
128
|
+
def exit_app(self, result=None) -> None:
|
|
129
|
+
"""Called when BasicFormScreen is dismissed."""
|
|
130
|
+
self.exit(result)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def main():
|
|
135
|
+
AdvancedFormApp().run()
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
if __name__ == "__main__":
|
|
139
|
+
main()
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""Basic form example with results display"""
|
|
2
|
+
from textual.app import App, ComposeResult
|
|
3
|
+
from textual.containers import Container, Center
|
|
4
|
+
from textual.screen import Screen
|
|
5
|
+
from textual.widgets import Static, Button
|
|
6
|
+
from textual_wtf import Form, StringField, IntegerField, BooleanField
|
|
7
|
+
from textual_wtf.demo.results_screen import ResultsDisplayScreen # Demo library utility
|
|
8
|
+
|
|
9
|
+
class UserForm(Form):
|
|
10
|
+
"""Simple user registration form"""
|
|
11
|
+
name = StringField(label="Name", required=True)
|
|
12
|
+
age = IntegerField(label="Age", min_value=0, max_value=130)
|
|
13
|
+
active = BooleanField(label="Active User")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ResultScreen(ResultsDisplayScreen):
|
|
17
|
+
"""Utility Screen to display form results"""
|
|
18
|
+
|
|
19
|
+
def compose(self) -> ComposeResult:
|
|
20
|
+
with Container(id="results-container"):
|
|
21
|
+
yield Static(self.result_title, id="results-title")
|
|
22
|
+
|
|
23
|
+
yield from self.show_data()
|
|
24
|
+
yield from self.buttons()
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class BasicFormScreen(Screen):
|
|
28
|
+
"""Demo app for basic form"""
|
|
29
|
+
|
|
30
|
+
CSS_PATH = "basic_form.tcss"
|
|
31
|
+
|
|
32
|
+
def compose(self) -> ComposeResult:
|
|
33
|
+
self.form = UserForm(title="User Registration")
|
|
34
|
+
yield self.form.render()
|
|
35
|
+
|
|
36
|
+
def on_form_submitted(self, event: Form.Submitted):
|
|
37
|
+
"""Handle form submission"""
|
|
38
|
+
data = event.form.get_data()
|
|
39
|
+
# Show results screen
|
|
40
|
+
|
|
41
|
+
def check_reset(cont):
|
|
42
|
+
if cont:
|
|
43
|
+
self.reset_form()
|
|
44
|
+
else:
|
|
45
|
+
self.dismiss(cont)
|
|
46
|
+
|
|
47
|
+
self.app.push_screen(
|
|
48
|
+
ResultScreen("Form Submitted Successfully!", data), check_reset
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
def on_form_cancelled(self, event: Form.Cancelled):
|
|
52
|
+
"""Handle form cancellation"""
|
|
53
|
+
# Show cancellation screen
|
|
54
|
+
|
|
55
|
+
def check_reset(cont):
|
|
56
|
+
if cont:
|
|
57
|
+
self.reset_form()
|
|
58
|
+
else:
|
|
59
|
+
self.dismiss(cont)
|
|
60
|
+
|
|
61
|
+
self.app.push_screen(ResultScreen("Form Cancelled", None), check_reset)
|
|
62
|
+
|
|
63
|
+
def reset_form(self):
|
|
64
|
+
"""Clear form and create fresh one"""
|
|
65
|
+
# Remove old form
|
|
66
|
+
old_form = self.query_one("RenderedForm")
|
|
67
|
+
old_form.remove()
|
|
68
|
+
|
|
69
|
+
# Create and mount new form
|
|
70
|
+
self.form = UserForm(title="User Registration")
|
|
71
|
+
self.mount(self.form.render())
|
|
72
|
+
|
|
73
|
+
def on_click(self):
|
|
74
|
+
self.app.log(self.css_tree)
|
|
75
|
+
self.app.log(self.tree)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class BasicFormApp(App):
|
|
79
|
+
|
|
80
|
+
def on_mount(self):
|
|
81
|
+
self.app.push_screen(BasicFormScreen(), callback=self.exit_app)
|
|
82
|
+
|
|
83
|
+
def exit_app(self, result=None) -> None:
|
|
84
|
+
"""Called when BasicFormScreen is dismissed."""
|
|
85
|
+
self.exit(result)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def main():
|
|
89
|
+
BasicFormApp().run()
|
|
90
|
+
|
|
91
|
+
if __name__ == "__main__":
|
|
92
|
+
main()
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
import os
|
|
3
|
+
import subprocess
|
|
4
|
+
import importlib.resources as i_r
|
|
5
|
+
|
|
6
|
+
from textual.app import App, ComposeResult
|
|
7
|
+
from textual.containers import Vertical
|
|
8
|
+
from textual.screen import Screen
|
|
9
|
+
from textual.widgets import Label, Header, Footer
|
|
10
|
+
from textual.message import Message
|
|
11
|
+
|
|
12
|
+
from textual_wtf.demo.basic_form import BasicFormScreen as BasicScreen
|
|
13
|
+
from textual_wtf.demo.advanced_form import AdvancedFormScreen as AdvancedScreen
|
|
14
|
+
from textual_wtf.demo.user_registration import RegistrationScreen
|
|
15
|
+
from textual_wtf.demo.nested_once_form import ShopScreen as NestedOnceScreen
|
|
16
|
+
from textual_wtf.demo.nested_twice_form import ShopScreen as NestedTwiceScreen
|
|
17
|
+
|
|
18
|
+
class MenuLink(Label):
|
|
19
|
+
"""A clickable text link widget."""
|
|
20
|
+
|
|
21
|
+
DEFAULT_CSS = """
|
|
22
|
+
MenuLink {
|
|
23
|
+
color: $accent;
|
|
24
|
+
text-style: underline;
|
|
25
|
+
margin-bottom: 1;
|
|
26
|
+
width: auto;
|
|
27
|
+
content-align: center middle;
|
|
28
|
+
}
|
|
29
|
+
MenuLink:hover {
|
|
30
|
+
color: $text;
|
|
31
|
+
text-style: bold underline;
|
|
32
|
+
background: $accent 10%;
|
|
33
|
+
}
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
class Clicked(Message):
|
|
37
|
+
"""Message posted when the link is clicked."""
|
|
38
|
+
|
|
39
|
+
def __init__(self, link: "MenuLink") -> None:
|
|
40
|
+
self.link = link
|
|
41
|
+
super().__init__()
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def __init__(self, title: str, app_screen: Screen = None, is_exit: bool = False):
|
|
46
|
+
super().__init__(title)
|
|
47
|
+
self.app_screen = app_screen
|
|
48
|
+
self.is_exit = is_exit
|
|
49
|
+
|
|
50
|
+
def on_click(self) -> None:
|
|
51
|
+
"""Handle click event."""
|
|
52
|
+
self.post_message(self.Clicked(self))
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class LauncherApp(App):
|
|
56
|
+
"""A simple launcher for Textual Forms examples."""
|
|
57
|
+
|
|
58
|
+
CSS = """
|
|
59
|
+
Screen {
|
|
60
|
+
align: center middle;
|
|
61
|
+
background: $surface;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
#menu-container {
|
|
65
|
+
width: 50;
|
|
66
|
+
height: auto;
|
|
67
|
+
border: thick $primary;
|
|
68
|
+
background: $panel;
|
|
69
|
+
padding: 1 2;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
#title-label {
|
|
73
|
+
width: 100%;
|
|
74
|
+
content-align: center middle;
|
|
75
|
+
text-style: bold;
|
|
76
|
+
padding-bottom: 2;
|
|
77
|
+
color: $text-muted;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/* Style the exit link differently */
|
|
81
|
+
.exit-link {
|
|
82
|
+
color: $error;
|
|
83
|
+
margin-top: 1;
|
|
84
|
+
}
|
|
85
|
+
.exit-link:hover {
|
|
86
|
+
color: $error-lighten-1;
|
|
87
|
+
background: $error 10%;
|
|
88
|
+
}
|
|
89
|
+
"""
|
|
90
|
+
|
|
91
|
+
def compose(self) -> ComposeResult:
|
|
92
|
+
yield Header()
|
|
93
|
+
|
|
94
|
+
with Vertical(id="menu-container"):
|
|
95
|
+
yield Label("Select an Application", id="title-label")
|
|
96
|
+
|
|
97
|
+
# Application Links
|
|
98
|
+
yield MenuLink("1. Basic Form", app_screen=BasicScreen)
|
|
99
|
+
yield MenuLink("2. Advanced Form", app_screen=AdvancedScreen)
|
|
100
|
+
yield MenuLink("3. User Registration", app_screen=RegistrationScreen)
|
|
101
|
+
yield MenuLink("4. Composition Example", app_screen=NestedOnceScreen)
|
|
102
|
+
yield MenuLink("5. Multiply Included Example", app_screen=NestedTwiceScreen)
|
|
103
|
+
|
|
104
|
+
# Exit Link
|
|
105
|
+
yield MenuLink("Exit Launcher", is_exit=True)
|
|
106
|
+
|
|
107
|
+
yield Footer()
|
|
108
|
+
|
|
109
|
+
def on_menu_link_clicked(self, event: MenuLink.Clicked) -> None:
|
|
110
|
+
"""Handle the custom link click message."""
|
|
111
|
+
link = event.link
|
|
112
|
+
|
|
113
|
+
if link.is_exit:
|
|
114
|
+
self.exit()
|
|
115
|
+
elif link.app_screen:
|
|
116
|
+
self.app.push_screen(link.app_screen())
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def main():
|
|
120
|
+
LauncherApp().run()
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
if __name__ == "__main__":
|
|
124
|
+
main()
|