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.
@@ -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,7 @@
1
+ Screen {
2
+ align: center middle;
3
+ }
4
+
5
+ RenderedForm {
6
+ width: 80%;
7
+ }
@@ -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()