Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

input restriction #3657

Merged
merged 19 commits into from
Nov 10, 2023
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/).

- CSS error reporting will no longer provide links to the files in question https://github.com/Textualize/textual/pull/3582
- inline CSS error reporting will report widget/class variable where the CSS was read from https://github.com/Textualize/textual/pull/3582
- Added `restrict`, `type`, `max_length`, and `valid_empty` to Input https://github.com/Textualize/textual/pull/3657

## [0.41.0] - 2023-10-31

Expand Down
13 changes: 13 additions & 0 deletions docs/examples/widgets/input_types.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
from textual.app import App, ComposeResult
from textual.widgets import Input


class InputApp(App):
def compose(self) -> ComposeResult:
yield Input(placeholder="An integer", type="integer")
yield Input(placeholder="A number", type="number")


if __name__ == "__main__":
app = InputApp()
app.run()
57 changes: 50 additions & 7 deletions docs/widgets/input.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,42 @@ The example below shows how you might create a simple form using two `Input` wid
--8<-- "docs/examples/widgets/input.py"
```


### Input Types

The `Input` widgets support a `type` parameter which will prevent the user from typing invalid data.
willmcgugan marked this conversation as resolved.
Show resolved Hide resolved
There are three valid values for type: `"integer"` will limit input to a valid integer, `"number"` will limit input to a floating point number.
The default value for the `type` parameter is `"text"` which will not limit the input.
willmcgugan marked this conversation as resolved.
Show resolved Hide resolved


=== "Output"

```{.textual path="docs/examples/widgets/input_types.py" press="1234"}
```

=== "input_types.py"

```python
--8<-- "docs/examples/widgets/input_types.py"
```

If you set `type` to something other than text, then the `Input` will apply the appropriate [validator](#validating-input).
willmcgugan marked this conversation as resolved.
Show resolved Hide resolved

### Restricting Input

You can limit input to particular characters by supplying the `restrict` parameter, which should be a regular expression.
The `Input` widget will prevent the addition of any characters that would cause the regex to no longer match.
For instance, if you wanted to limit characters to binary you could set `restrict=r"[01]*"`.

!!! note

The `restrict` regular expression is applied to the full value and not just to the new character.

### Maximum Length

You can limit the length of the input by setting `max_length` to a value greater than zero.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A string of length 0 is a perfectly fine string.
If max_length can be set to None to mean "No restriction", it'd feel cleaner if we lifted the restriction on the number needing to be > 0.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

But it is paradoxical to have an input with a max length of 0.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree it doesn't sound useful but my lack of imagination shouldn't deter us from implementing things :P

This will prevent the user from typing any more characters when the maximum has been reached.

### Validating Input

You can supply one or more *[validators][textual.validation.Validator]* to the `Input` widget to validate the value.
Expand Down Expand Up @@ -71,15 +107,22 @@ Textual offers several [built-in validators][textual.validation] for common requ
but you can easily roll your own by extending [Validator][textual.validation.Validator],
as seen for `Palindrome` in the example above.

#### Validate Empty

If you set `valid_empty=True` then empty values will bypass any validators, and empty values will be considered valid.

## Reactive Attributes

| Name | Type | Default | Description |
|-------------------|--------|---------|-----------------------------------------------------------------|
| `cursor_blink` | `bool` | `True` | True if cursor blinking is enabled. |
| `value` | `str` | `""` | The value currently in the text input. |
| `cursor_position` | `int` | `0` | The index of the cursor in the value string. |
| `placeholder` | `str` | `str` | The dimmed placeholder text to display when the input is empty. |
| `password` | `bool` | `False` | True if the input should be masked. |
| Name | Type | Default | Description |
willmcgugan marked this conversation as resolved.
Show resolved Hide resolved
| ----------------- | ------ | -------- | --------------------------------------------------------------- |
| `cursor_blink` | `bool` | `True` | True if cursor blinking is enabled. |
| `value` | `str` | `""` | The value currently in the text input. |
| `cursor_position` | `int` | `0` | The index of the cursor in the value string. |
| `placeholder` | `str` | `str` | The dimmed placeholder text to display when the input is empty. |
willmcgugan marked this conversation as resolved.
Show resolved Hide resolved
| `password` | `bool` | `False` | True if the input should be masked. |
| `restrict` | `str` | `None` | Optional regular expression to restrict input. |
| `type` | `str` | `"text"` | The type of the input. |
| `max_length` | `int` | `None` | Maximum length of the input value. |

## Messages

Expand Down
120 changes: 109 additions & 11 deletions src/textual/widgets/_input.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,11 @@
from .. import events
from .._segment_tools import line_crop
from ..binding import Binding, BindingType
from ..css._error_tools import friendly_list
from ..events import Blur, Focus, Mount
from ..geometry import Offset, Size
from ..message import Message
from ..reactive import reactive
from ..reactive import reactive, var
from ..suggester import Suggester, SuggestionReady
from ..timer import Timer
from ..validation import ValidationResult, Validator
Expand All @@ -29,6 +30,13 @@
"""Set literal with the legal values for the type `InputValidationOn`."""


_RESTRICT_TYPES = {
"integer": r"[-+]?\d*",
"number": r"[-+]?\d*\.?\d?(e?\d+)?",
"text": r".*",
willmcgugan marked this conversation as resolved.
Show resolved Hide resolved
}


class _InputRenderable:
"""Render the input content."""

Expand Down Expand Up @@ -168,11 +176,18 @@ class Input(Widget, can_focus=True):
width = reactive(1)
_cursor_visible = reactive(True)
password = reactive(False)
max_size: reactive[int | None] = reactive(None)
suggester: Suggester | None
"""The suggester used to provide completions as the user types."""
_suggestion = reactive("")
"""A completion suggestion for the current value in the input."""
restrict = var["str | None"](None)
"""A regular expression that must match incoming characters."""
willmcgugan marked this conversation as resolved.
Show resolved Hide resolved
type = var("text")
"""The type of the input."""
max_length = var["int | None"](None)
"""The maximum length of the input, in characters."""
willmcgugan marked this conversation as resolved.
Show resolved Hide resolved
valid_empty = var(False)
willmcgugan marked this conversation as resolved.
Show resolved Hide resolved
"""Empty values should pass validation."""
willmcgugan marked this conversation as resolved.
Show resolved Hide resolved

@dataclass
class Changed(Message):
Expand Down Expand Up @@ -226,9 +241,13 @@ def __init__(
highlighter: Highlighter | None = None,
password: bool = False,
*,
restrict: str | None = None,
type: str = "text",
willmcgugan marked this conversation as resolved.
Show resolved Hide resolved
max_length: int = 0,
suggester: Suggester | None = None,
validators: Validator | Iterable[Validator] | None = None,
validate_on: Iterable[InputValidationOn] | None = None,
valid_empty: bool = False,
name: str | None = None,
id: str | None = None,
classes: str | None = None,
Expand All @@ -241,20 +260,22 @@ def __init__(
placeholder: Optional placeholder text for the input.
highlighter: An optional highlighter for the input.
password: Flag to say if the field should obfuscate its content.
restrict: A regex to restrict character inputs.
type: The type of the input.
max_length: The maximum length of the input, or 0 for no maximum length.
suggester: [`Suggester`][textual.suggester.Suggester] associated with this
input instance.
validators: An iterable of validators that the Input value will be checked against.
validate_on: Zero or more of the values "blur", "changed", and "submitted",
which determine when to do input validation. The default is to do
validation for all messages.
valid_empty: Empty values are valid.
name: Optional name for the input widget.
id: Optional ID for the widget.
classes: Optional initial classes for the widget.
disabled: Whether the input is disabled or not.
"""
super().__init__(name=name, id=id, classes=classes, disabled=disabled)
if value is not None:
self.value = value

self._blink_timer: Timer | None = None
"""Timer controlling the blinking of the cursor, instantiated in `on_mount`."""
Expand All @@ -263,6 +284,7 @@ def __init__(
self.highlighter = highlighter
self.password = password
self.suggester = suggester

# Ensure we always end up with an Iterable of validators
if isinstance(validators, Validator):
self.validators: list[Validator] = [validators]
Expand All @@ -288,6 +310,26 @@ def __init__(
input = Input(validate_on=["submitted"])
```
"""
self.valid_empty = valid_empty
self._valid = True

self.restrict = restrict
if type not in _RESTRICT_TYPES:
raise ValueError(
f"Input type must be one of {friendly_list(_RESTRICT_TYPES.keys())}; not {type!r}"
)
self.type = type
self.max_length = max_length
if not self.validators:
from ..validation import Integer, Number

if self.type == "integer":
self.validators.append(Integer())
elif self.type == "number":
self.validators.append(Number())
willmcgugan marked this conversation as resolved.
Show resolved Hide resolved

if value is not None:
self.value = value

def _position_to_cell(self, position: int) -> int:
"""Convert an index within the value to cell position."""
Expand Down Expand Up @@ -349,7 +391,7 @@ def cursor_screen_offset(self) -> Offset:
x, y, _width, _height = self.content_region
return Offset(x + self._cursor_offset - self.view_position, y)

async def _watch_value(self, value: str) -> None:
def _watch_value(self, value: str) -> None:
self._suggestion = ""
if self.suggester and value:
self.run_worker(self.suggester._get_suggestion(self, value))
Expand All @@ -375,18 +417,38 @@ def validate(self, value: str) -> ValidationResult | None:
That is, if *any* validator fails, the result will be an unsuccessful
validation.
"""

def set_classes() -> None:
"""Set classes for valid flag."""
valid = self._valid
self.set_class(not valid, "-invalid")
self.set_class(valid, "-valid")

# If no validators are supplied, and therefore no validation occurs, we return None.
if not self.validators:
self._valid = True
set_classes()
return None

if self.valid_empty and not value:
self._valid = True
set_classes()
return None

validation_results: list[ValidationResult] = [
validator.validate(value) for validator in self.validators
]
combined_result = ValidationResult.merge(validation_results)
self.set_class(not combined_result.is_valid, "-invalid")
self.set_class(combined_result.is_valid, "-valid")
self._valid = combined_result.is_valid
set_classes()

return combined_result

@property
def is_valid(self) -> bool:
"""Check if the value has passed validation."""
return self._valid

@property
def cursor_width(self) -> int:
"""The width of the input (with extra space for cursor at the end)."""
Expand Down Expand Up @@ -494,15 +556,51 @@ def insert_text_at_cursor(self, text: str) -> None:
Args:
text: New text to insert.
"""

def check_allowed_character(value: str) -> bool:
willmcgugan marked this conversation as resolved.
Show resolved Hide resolved
"""Check if new value is restricted."""
# Check max length
if self.max_length and len(value) > self.max_length:
return False
# Check explicit restrict
if self.restrict and re.fullmatch(self.restrict, value) is None:
return False
# Check type restrict
if self.type:
type_restrict = _RESTRICT_TYPES.get(self.type, None)
if (
type_restrict is not None
and re.fullmatch(type_restrict, value) is None
):
return False
# Character is allowed
return True

if self.cursor_position >= len(self.value):
self.value += text
self.cursor_position = len(self.value)
new_value = self.value + text
if check_allowed_character(new_value):
self.value = new_value
self.cursor_position = len(self.value)
else:
self.restricted()
else:
value = self.value
before = value[: self.cursor_position]
after = value[self.cursor_position :]
self.value = f"{before}{text}{after}"
self.cursor_position += len(text)
new_value = f"{before}{text}{after}"
if check_allowed_character(new_value):
self.value = new_value
self.cursor_position += len(text)
else:
self.restricted()

def restricted(self) -> None:
"""Called when a character has been restricted.

The default behavior is to play the system bell.
You may want to override this method if you want to disable the bell or do something else entirely.
"""
self.app.bell()

def clear(self) -> None:
"""Clear the input."""
Expand Down
Loading
Loading