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,40 @@
1
+ """Validators for form fields"""
2
+ import re
3
+ from abc import ABC, abstractmethod
4
+ from typing import Any, Optional
5
+ from textual.validation import Validator, ValidationResult
6
+
7
+
8
+ SIMPLE_EMAIL_PAT = re.compile(r".+\@.+\..+")
9
+
10
+
11
+ class EvenInteger(Validator):
12
+ """Example custom validator - validates even integers"""
13
+
14
+ def validate(self, value: str) -> ValidationResult:
15
+ try:
16
+ int_value = int(value)
17
+ except ValueError:
18
+ return self.success()
19
+
20
+ if int_value % 2 != 0:
21
+ return self.failure("Must be an even number")
22
+ return self.success()
23
+
24
+
25
+ class Palindromic(Validator):
26
+ """Example custom validator - validates palindromes"""
27
+
28
+ def validate(self, value: str) -> ValidationResult:
29
+ if value == value[::-1]:
30
+ return self.success()
31
+ return self.failure("Must be a palindrome")
32
+
33
+
34
+ class EmailValidator(Validator):
35
+ """Simple email validator - rejects addresses like 'user@name' without TLD"""
36
+
37
+ def validate(self, value: str) -> ValidationResult:
38
+ if not value or not SIMPLE_EMAIL_PAT.match(value):
39
+ return self.failure("Must be a valid email address")
40
+ return self.success()
textual_wtf/version.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.8.0"
textual_wtf/widgets.py ADDED
@@ -0,0 +1,165 @@
1
+ """Form widgets with validation support"""
2
+ from typing import List, Optional, TYPE_CHECKING, Tuple
3
+ from textual.widgets import Input, Checkbox, Select, TextArea, Static
4
+ from textual.containers import Center
5
+ from textual.validation import ValidationResult, Validator
6
+
7
+ if TYPE_CHECKING:
8
+ from .fields import Field
9
+
10
+
11
+ def widget_id_gen():
12
+ """Generate unique widget IDs"""
13
+ count = 0
14
+ while True:
15
+ count += 1
16
+ yield f"field-widget-{count}"
17
+
18
+
19
+ _id_gen = widget_id_gen()
20
+
21
+
22
+ class FormWidgetMixin:
23
+ """Mixin to add validation error display to widgets"""
24
+
25
+ async def on_input_changed(self, event):
26
+ """Handle input changes and display validation errors"""
27
+ if not hasattr(self, 'parent') or self.parent is None:
28
+ return
29
+
30
+ container = self.parent
31
+
32
+ # ALWAYS clear previous errors first
33
+ await container.remove_children(".form-error")
34
+
35
+ if hasattr(event, 'validation_result') and event.validation_result is not None:
36
+ vr = event.validation_result
37
+ if not vr.is_valid:
38
+ # Show all errors from this validation
39
+ for msg in vr.failure_descriptions:
40
+ container.mount(Center(Static(msg), classes="form-error"))
41
+
42
+
43
+ class FormInput(Input, FormWidgetMixin):
44
+ """Text input widget for forms"""
45
+
46
+ def __init__(self, *, field: Optional['Field'] = None, valid_empty: bool = True,
47
+ validators: Optional[List[Validator]] = None, **kwargs):
48
+ kwargs.setdefault('id', next(_id_gen))
49
+ kwargs['validators'] = validators or []
50
+ super().__init__(**kwargs)
51
+ self.field = field
52
+ self.valid_empty = valid_empty
53
+
54
+
55
+ class FormIntegerInput(Input, FormWidgetMixin):
56
+ """Integer input widget for forms"""
57
+
58
+ def __init__(self, *, field: Optional['Field'] = None, valid_empty: bool = True,
59
+ validators: Optional[List[Validator]] = None, **kwargs):
60
+ kwargs.setdefault('id', next(_id_gen))
61
+ kwargs['type'] = 'integer'
62
+ kwargs['validators'] = validators or []
63
+ super().__init__(**kwargs)
64
+ self.field = field
65
+ self.valid_empty = valid_empty
66
+
67
+
68
+ class FormTextArea(TextArea, FormWidgetMixin):
69
+ """Multi-line text area widget for forms"""
70
+
71
+ def __init__(self, *, field: Optional['Field'] = None, text: str = "", **kwargs):
72
+ kwargs.setdefault('id', next(_id_gen))
73
+ super().__init__(text=text, **kwargs)
74
+ self.field = field
75
+
76
+ @property
77
+ def value(self):
78
+ """Get text area value"""
79
+ return self.text
80
+
81
+ @value.setter
82
+ def value(self, v):
83
+ """Set text area value"""
84
+ self.text = v if v is not None else ""
85
+
86
+ def validate(self, value):
87
+ """Validate text area value"""
88
+ return ValidationResult()
89
+
90
+
91
+ class FormCheckbox(Checkbox):
92
+ """Checkbox widget for forms"""
93
+
94
+ def __init__(self, *, field: Optional['Field'] = None, label: str = "", **kwargs):
95
+ kwargs.setdefault('id', next(_id_gen))
96
+ super().__init__(value=False, **kwargs)
97
+ self.field = field
98
+ if label:
99
+ self.label = label
100
+
101
+ def validate(self, value):
102
+ """Validate checkbox value"""
103
+ return ValidationResult()
104
+
105
+
106
+ class AlwaysValid(Validator):
107
+ """Validator that always passes"""
108
+
109
+ def validate(self, value):
110
+ return self.success()
111
+
112
+
113
+ class FormSelect(Select):
114
+ """Select dropdown widget for forms"""
115
+
116
+ def __init__(self, *, field: Optional['Field'] = None,
117
+ choices: List[Tuple[str, str]], allow_blank: bool = False,
118
+ prompt: str = "Select an option", **kwargs):
119
+ kwargs.setdefault('id', next(_id_gen))
120
+ kwargs.setdefault('prompt', prompt)
121
+
122
+ # Convert tuples to Select.Option objects
123
+ options = [(label, value) for value, label in choices]
124
+
125
+ super().__init__(options=options, **kwargs)
126
+ self.field = field
127
+ self.allow_blank = allow_blank
128
+
129
+ def validate(self, value):
130
+ """Validate select value"""
131
+ if value != Select.BLANK or self.allow_blank:
132
+ return AlwaysValid().success()
133
+ return AlwaysValid().failure("Please select a value")
134
+
135
+
136
+ class WidgetRegistry:
137
+ """Registry for custom widgets"""
138
+
139
+ _widgets = {}
140
+
141
+ @classmethod
142
+ def register(cls, name: str):
143
+ """Decorator to register a widget"""
144
+ def decorator(widget_class):
145
+ cls._widgets[name] = widget_class
146
+ return widget_class
147
+ return decorator
148
+
149
+ @classmethod
150
+ def get(cls, name: str):
151
+ """Get a widget by name"""
152
+ return cls._widgets.get(name)
153
+
154
+ @classmethod
155
+ def list_widgets(cls):
156
+ """List all registered widgets"""
157
+ return list(cls._widgets.keys())
158
+
159
+
160
+ # Register built-in widgets
161
+ WidgetRegistry.register("input")(FormInput)
162
+ WidgetRegistry.register("integer_input")(FormIntegerInput)
163
+ WidgetRegistry.register("textarea")(FormTextArea)
164
+ WidgetRegistry.register("checkbox")(FormCheckbox)
165
+ WidgetRegistry.register("select")(FormSelect)
@@ -0,0 +1,223 @@
1
+ Metadata-Version: 2.4
2
+ Name: textual-wtf
3
+ Version: 0.8.0
4
+ Summary: A declarative forms library for Textual TUI applications
5
+ Author-email: Steve Holden <steve@holdenweb.com>
6
+ License: MIT
7
+ License-File: LICENSE
8
+ Keywords: forms,terminal,textual,tui,validation
9
+ Classifier: Development Status :: 4 - Beta
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: License :: OSI Approved :: MIT License
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3.10
14
+ Classifier: Programming Language :: Python :: 3.11
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Requires-Python: <4.0,>=3.10
17
+ Requires-Dist: textual>=0.47.0
18
+ Description-Content-Type: text/markdown
19
+
20
+ # Textual Forms
21
+
22
+ A declarative, extensible forms library for [Textual](https://textual.textualize.io/) TUI applications.
23
+
24
+ ## Features/Goals
25
+
26
+ - **Declarative Syntax**: Define forms using class-based field declarations
27
+ - **Flexible Widget Assignment**: Use different widgets for the same field type
28
+ - **Easy to Extend**: Add custom field types and widgets with clear patterns
29
+ - **Built-in Validation**: Uses standard textual validators
30
+ - **Simple Integration**: Drop forms into existing Textual apps with minimal code
31
+
32
+
33
+ ## Warnings
34
+
35
+ This package is in a beta stage. No details should be regarded as final,
36
+ particularly naming details, but existing functionality won't be broken
37
+ without a good reason. Nevertheless we believe it is sufficiently close to
38
+ its final form to be rigorously exercised.
39
+
40
+ Form layout is at present embedded in the Form.render method, which returns a
41
+ RenderedForm widget making it difficult to provide flexibility in
42
+ representation. This mechanism has extremely low "textuality," and will be
43
+ replaced in release 0.9.
44
+
45
+ LLMs have been used in the development of this package, which is
46
+ subjected to serious scrutiny by a seasoned professional programmer, but this
47
+ is no guarantee of quality or correctness. _caveat usor_.
48
+
49
+ Some of the current tests are of dubious value, and may ultimately be removed.
50
+
51
+ ## Running the Examples
52
+
53
+ If you have `uv` installed you can run the examples from the repository using the command
54
+
55
+ `uvx --from git+https://github.com/holdenweb/textual-wtf.git textual-wtf`
56
+
57
+ You will see a menu of demonstration examples, each of which is also a standalone program in the src/textual_wtf/demo directory.
58
+ as shown below.
59
+
60
+ | Menu item | Program name | Description |
61
+ |:-----------------------------|:-----------------------|:---------------------------------------------|
62
+ | 1. Basic Form | `basic_form.py` | A simple form with basic fields |
63
+ | 2. Advanced Form | `advanced_form.py` | A more complex form with some validations |
64
+ | 3. User Registration | `user_registration.py` | Rendered form integrated with other components |
65
+ | 4. Composition Example | `nested_once_form.py` | Simple nested form demonstration |
66
+ | 5. Multiply Included Example | `nested_twice_form.py` | Demonstrating form component re-use |
67
+
68
+ In a clone of the repository the command `uv run examples/<name>` should suffice.
69
+
70
+ `poetry` users should find that they can create a virtual environment and run
71
+ `poetry run textual-wtf` to start the demo. You can also build a distribution
72
+ (wheel and sdist) of the project with `poetry build` and install that
73
+ wherever required. No further testing in other development environments has
74
+ so far been reported.
75
+
76
+ ## Installation
77
+
78
+ The package will shortly be released to PyPI at version 0.8.
79
+ Until then, install from this repository as follows.
80
+
81
+ ```bash
82
+ python -m pip install textual_wtf@git+https://github.com/holdenweb/textual-wtf.git
83
+ ```
84
+
85
+ `uv` users can use
86
+
87
+ ```bash
88
+ uv add textual_wtf@git+https://github.com/holdenweb/textual-wtf.git
89
+ ```
90
+
91
+ ## Quick Start
92
+
93
+ The most basic forms contain one or more fields. When a form is submitted a
94
+ `Form.Submitted` event is posted, whose `form` attribute allows access to
95
+ values using field names. Here's a simple example.
96
+
97
+ ```python
98
+ from textual_wtf import Form, StringField, IntegerField, BooleanField
99
+
100
+ class UserForm(Form):
101
+ """Simple user registration form"""
102
+ name = StringField(label="Name", required=True)
103
+ age = IntegerField(label="Age", min_value=0, max_value=130)
104
+ active = BooleanField(label="Active User")
105
+ ```
106
+
107
+ With a little styling this is how the rendered form appears after it's been filled out.
108
+
109
+ ![The rendered form](images/rendered_user_form.png "Look!")
110
+
111
+ Fields are named from the class variable to which they are assigned (in the
112
+ example above, "name", "age" and "active"). With the content shown the
113
+ form's `get_data` method will return `{'name': 'Steve Holden', 'age': 178;
114
+ 'active': True}`. Note that the integer field has been converted to an
115
+ `int`, and the Boolean field to a `bool`.
116
+
117
+ Forms are themselves reusable components so a form can contain sub-forms, as
118
+ demonstrated in the `nested_once_form.py` example, to as many levels as
119
+ required.
120
+
121
+ A form can optionally be given a prefix, which is prepended to the names of
122
+ its fields with a "_" suffix. This lets you include multiple instances of the
123
+ same sub-form, as the `nested_twice_form.py` example demonstrates.
124
+
125
+ In the event of two fields receiving the same (fully-qualified) name an
126
+ exception is raised when the form is created.
127
+
128
+ When a form is rendered "Cancel" and "Submit" buttons are added at the
129
+ bottom of the form. When clicked these buttons raise `Form.Cancelled`
130
+ and `Form.Submitted` events respectively; each has a `form` attribute
131
+ which can be used to access the form.
132
+ This behaviour will be more configurable in later releases.
133
+
134
+ ## Form Data
135
+
136
+ Once rendered, the simplest way to access the form's data is
137
+ by calling its `get_data` method.
138
+ This returns a dictionary where fields' values are keyed by their
139
+ fully-qualified names (i.e. including any sub-form prefixes,
140
+ with underscores separating the named components).
141
+
142
+ Users may find that in dealing with complex nested forms it
143
+ becomes tedious to use fully-qualified names.
144
+ An experimental `get_field` method takes a string argument. If that string is
145
+ the fully-qualified name of a field, or if there is only one field whose
146
+ fully-qualified name ends an underscore followed by the string, then it
147
+ returns a field object whose attributes include `name` and `value`.
148
+
149
+ This access mechanism is perhaps the most fluid part of the current design,
150
+ and discussions (feel free to raise a Github issue to start a discussion -
151
+ we'll move it into Discussions if it looks like developing) are encouraged.
152
+ For example, would it more usable to implement a read/write property with
153
+ dotted access? Should alternatives be offered?
154
+
155
+ ## Current Field Types
156
+
157
+ - `StringField` - Text input (single line)
158
+ - `TextField` - Text input (multi-line)
159
+ - `IntegerField` - Integer input with validation
160
+ - `BooleanField` - Checkbox
161
+ - `ChoiceField` - Select dropdown
162
+
163
+ Documentation of the foundational classes (Forms, Fields and Widgets) is the
164
+ biggest current technical debt. The `examples` directory
165
+ contains some code that we hope will help you to evalute textual-wtf
166
+ and shape its direction with your feedback.
167
+
168
+ Additional example programs, particularly those demonstrating the
169
+ styling possibilities, would be especially valuable.
170
+
171
+ ## Development
172
+
173
+ ```bash
174
+ # Create a venv with dev dependencies
175
+ uv venv
176
+
177
+ # Run tests
178
+ uv run pytest
179
+
180
+ Run specific test
181
+ uv run pytest tests/test_fields.py
182
+ ```
183
+
184
+ ## Architecture
185
+
186
+ The library uses a three-layer architecture:
187
+
188
+ 1. **Fields** - Handle data conversion and validation logic
189
+ 2. **Widgets** - Handle UI rendering and user interaction
190
+ 3. **Forms** - Coordinate fields and widgets into complete forms
191
+
192
+ No detailed architecture documentation is presently available.
193
+
194
+ ## License
195
+
196
+ MIT License - see LICENSE file for details.
197
+
198
+ ## Choice Field Format
199
+
200
+ When using `ChoiceField`, provide choices as a list of `(value, label)` tuples:
201
+
202
+ ```python
203
+ country = ChoiceField(
204
+ label="Country",
205
+ choices=[
206
+ ("us", "United States"), # value, label
207
+ ("uk", "United Kingdom"),
208
+ ("ca", "Canada"),
209
+ ]
210
+ )
211
+ ```
212
+
213
+ - The **value** (first element) is what gets stored in the form data
214
+ - The **label** (second element) is what the user sees in the dropdown
215
+
216
+ When the form is submitted, `form.get_data()['country']` will contain the
217
+ value (e.g., `"us"`), not the label.
218
+
219
+ ## Older versions
220
+
221
+ The prehistory of the project is preserved in the `prototype` branch,
222
+ a somewhat chaotic mishmash of code that nevertheless proved the basic ideas
223
+ embodied in the current design to be usable in practice.
@@ -0,0 +1,23 @@
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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.28.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ textual-wtf = textual_wtf.demo.launcher:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Steve Holden steve@holdenweb.com
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.