mirror of
https://github.com/Textualize/textual.git
synced 2025-10-17 02:38:12 +03:00
Introduced MaskedInput widget
This commit is contained in:
@@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
|
||||
|
||||
## Unreleased
|
||||
|
||||
### Added
|
||||
|
||||
- Added `MaskedInput` widget
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed issues in Kitty terminal after exiting app https://github.com/Textualize/textual/issues/4779
|
||||
|
||||
32
docs/examples/widgets/masked_input.py
Normal file
32
docs/examples/widgets/masked_input.py
Normal file
@@ -0,0 +1,32 @@
|
||||
from textual.app import App, ComposeResult
|
||||
from textual.widgets import Label, MaskedInput
|
||||
|
||||
|
||||
class MaskedInputApp(App):
|
||||
# (1)!
|
||||
CSS = """
|
||||
MaskedInput.-valid {
|
||||
border: tall $success 60%;
|
||||
}
|
||||
MaskedInput.-valid:focus {
|
||||
border: tall $success;
|
||||
}
|
||||
MaskedInput {
|
||||
margin: 1 1;
|
||||
}
|
||||
Label {
|
||||
margin: 1 2;
|
||||
}
|
||||
"""
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
yield Label("Enter a valid credit card number.")
|
||||
yield MaskedInput(
|
||||
template="9999-9999-9999-9999;0", # (2)!
|
||||
)
|
||||
|
||||
|
||||
app = MaskedInputApp()
|
||||
|
||||
if __name__ == "__main__":
|
||||
app.run()
|
||||
@@ -182,6 +182,16 @@ Display a markdown document.
|
||||
```{.textual path="docs/examples/widgets/markdown.py"}
|
||||
```
|
||||
|
||||
## MaskedInput
|
||||
|
||||
A control to enter input according to a template mask.
|
||||
|
||||
[MaskedInput reference](./widgets/masked_input.md){ .md-button .md-button--primary }
|
||||
|
||||
|
||||
```{.textual path="docs/examples/widgets/masked_input.py"}
|
||||
```
|
||||
|
||||
## OptionList
|
||||
|
||||
Display a vertical list of options (options may be Rich renderables).
|
||||
|
||||
84
docs/widgets/masked_input.md
Normal file
84
docs/widgets/masked_input.md
Normal file
@@ -0,0 +1,84 @@
|
||||
# MaskedInput
|
||||
|
||||
!!! tip "Added in version 0.74.0"
|
||||
|
||||
A masked input derived from `Input`, allowing to restrict user input and give visual aid via a simple template mask, which also acts as an implicit *[validator][textual.validation.Validator]*.
|
||||
|
||||
- [x] Focusable
|
||||
- [ ] Container
|
||||
|
||||
## Example
|
||||
|
||||
The example below shows a masked input to ease entering a credit card number.
|
||||
|
||||
=== "Output"
|
||||
|
||||
```{.textual path="docs/examples/widgets/masked_input.py"}
|
||||
```
|
||||
|
||||
=== "checkbox.py"
|
||||
|
||||
```python
|
||||
--8<-- "docs/examples/widgets/masked_input.py"
|
||||
```
|
||||
|
||||
## Reactive Attributes
|
||||
|
||||
| Name | Type | Default | Description |
|
||||
| ---------- | ----- | ------- | ------------------------- |
|
||||
| `template` | `str` | `""` | The template mask string. |
|
||||
|
||||
### The template string format
|
||||
|
||||
A `MaskedInput` template length defines the maximum length of the input value. Each character of the mask defines a regular expression used to restrict what the user can insert in the corresponding position, and whether the presence of the character in the user input is required for the `MaskedInput` value to be considered valid, according to the following table:
|
||||
|
||||
| Mask character | Regular expression | Required? |
|
||||
| -------------- | ------------------ | --------- |
|
||||
| `A` | `[A-Za-z]` | Yes |
|
||||
| `a` | `[A-Za-z]` | No |
|
||||
| `N` | `[A-Za-z0-9]` | Yes |
|
||||
| `n` | `[A-Za-z0-9]` | No |
|
||||
| `X` | `[^ ]` | Yes |
|
||||
| `x` | `[^ ]` | No |
|
||||
| `9` | `[0-9]` | Yes |
|
||||
| `0` | `[0-9]` | No |
|
||||
| `D` | `[1-9]` | Yes |
|
||||
| `d` | `[1-9]` | No |
|
||||
| `#` | `[0-9+\-]` | No |
|
||||
| `H` | `[A-Fa-f0-9]` | Yes |
|
||||
| `h` | `[A-Fa-f0-9]` | No |
|
||||
| `B` | `[0-1]` | Yes |
|
||||
| `b` | `[0-1]` | No |
|
||||
|
||||
There are some special characters that can be used to control automatic case conversion during user input: `>` converts all subsequent user input to uppercase; `<` to lowercase; `!` disables automatic case conversion. Any other character that appears in the template mask is assumed to be a separator, which is a character that is automatically inserted when user reaches its position. All mask characters can be escaped by placing `\` in front of them, allowing any character to be used as separator.
|
||||
The mask can be terminated by `;c`, where `c` is any character you want to be used as placeholder character. The `placeholder` parameter inherited by `Input` can be used to override this allowing finer grain tuning of the placeholder string.
|
||||
|
||||
## Messages
|
||||
|
||||
- [MaskedInput.Changed][textual.widgets.MaskedInput.Changed]
|
||||
- [MaskedInput.Submitted][textual.widgets.MaskedInput.Submitted]
|
||||
|
||||
## Bindings
|
||||
|
||||
The masked input widget defines the following bindings:
|
||||
|
||||
::: textual.widgets.MaskedInput.BINDINGS
|
||||
options:
|
||||
show_root_heading: false
|
||||
show_root_toc_entry: false
|
||||
|
||||
## Component Classes
|
||||
|
||||
The masked input widget provides the following component classes:
|
||||
|
||||
::: textual.widgets.MaskedInput.COMPONENT_CLASSES
|
||||
options:
|
||||
show_root_heading: false
|
||||
show_root_toc_entry: false
|
||||
|
||||
---
|
||||
|
||||
|
||||
::: textual.widgets.MaskedInput
|
||||
options:
|
||||
heading_level: 2
|
||||
@@ -158,6 +158,7 @@ nav:
|
||||
- "widgets/log.md"
|
||||
- "widgets/markdown_viewer.md"
|
||||
- "widgets/markdown.md"
|
||||
- "widgets/masked_input.md"
|
||||
- "widgets/option_list.md"
|
||||
- "widgets/placeholder.md"
|
||||
- "widgets/pretty.md"
|
||||
|
||||
@@ -27,6 +27,7 @@ if typing.TYPE_CHECKING:
|
||||
from ._loading_indicator import LoadingIndicator
|
||||
from ._log import Log
|
||||
from ._markdown import Markdown, MarkdownViewer
|
||||
from ._masked_input import MaskedInput
|
||||
from ._option_list import OptionList
|
||||
from ._placeholder import Placeholder
|
||||
from ._pretty import Pretty
|
||||
@@ -66,6 +67,7 @@ __all__ = [
|
||||
"Log",
|
||||
"Markdown",
|
||||
"MarkdownViewer",
|
||||
"MaskedInput",
|
||||
"OptionList",
|
||||
"Placeholder",
|
||||
"Pretty",
|
||||
|
||||
576
src/textual/widgets/_masked_input.py
Normal file
576
src/textual/widgets/_masked_input.py
Normal file
@@ -0,0 +1,576 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from enum import IntFlag
|
||||
from typing import TYPE_CHECKING, Iterable, Pattern
|
||||
|
||||
from rich.console import Console, ConsoleOptions, RenderableType
|
||||
from rich.console import RenderResult as RichRenderResult
|
||||
from rich.segment import Segment
|
||||
from rich.text import Text
|
||||
from typing_extensions import Literal
|
||||
|
||||
from .. import events
|
||||
from .._segment_tools import line_crop
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..app import RenderResult
|
||||
|
||||
from ..reactive import var
|
||||
from ..validation import ValidationResult, Validator
|
||||
from ._input import Input
|
||||
|
||||
InputValidationOn = Literal["blur", "changed", "submitted"]
|
||||
"""Possible messages that trigger input validation."""
|
||||
|
||||
|
||||
class _CharFlags(IntFlag):
|
||||
"""Misc flags for a single template character definition"""
|
||||
|
||||
REQUIRED = 0x1
|
||||
"""Is this character required for validation?"""
|
||||
|
||||
SEPARATOR = 0x2
|
||||
"""Is this character a separator?"""
|
||||
|
||||
UPPERCASE = 0x4
|
||||
"""Char is forced to be uppercase"""
|
||||
|
||||
LOWERCASE = 0x8
|
||||
"""Char is forced to be lowercase"""
|
||||
|
||||
|
||||
_TEMPLATE_CHARACTERS = {
|
||||
"A": (r"[A-Za-z]", _CharFlags.REQUIRED),
|
||||
"a": (r"[A-Za-z]", None),
|
||||
"N": (r"[A-Za-z0-9]", _CharFlags.REQUIRED),
|
||||
"n": (r"[A-Za-z0-9]", None),
|
||||
"X": (r"[^ ]", _CharFlags.REQUIRED),
|
||||
"x": (r"[^ ]", None),
|
||||
"9": (r"[0-9]", _CharFlags.REQUIRED),
|
||||
"0": (r"[0-9]", None),
|
||||
"D": (r"[1-9]", _CharFlags.REQUIRED),
|
||||
"d": (r"[1-9]", None),
|
||||
"#": (r"[0-9+\-]", None),
|
||||
"H": (r"[A-Fa-f0-9]", _CharFlags.REQUIRED),
|
||||
"h": (r"[A-Fa-f0-9]", None),
|
||||
"B": (r"[0-1]", _CharFlags.REQUIRED),
|
||||
"b": (r"[0-1]", None),
|
||||
}
|
||||
|
||||
|
||||
class _InputRenderable:
|
||||
"""Render the input content."""
|
||||
|
||||
def __init__(self, input: Input, cursor_visible: bool) -> None:
|
||||
self.input = input
|
||||
self.cursor_visible = cursor_visible
|
||||
|
||||
def __rich_console__(
|
||||
self, console: "Console", options: "ConsoleOptions"
|
||||
) -> RichRenderResult:
|
||||
input = self.input
|
||||
result = input._value
|
||||
width = input.content_size.width
|
||||
|
||||
# Add the completion with a faded style.
|
||||
value = input.value
|
||||
value_length = len(value)
|
||||
template = input._template
|
||||
style = input.get_component_rich_style("input--placeholder")
|
||||
result += Text(
|
||||
template.mask[value_length:],
|
||||
style,
|
||||
)
|
||||
for index, (c, char_def) in enumerate(zip(value, template.template)):
|
||||
if c == " ":
|
||||
result.stylize(style, index, index + 1)
|
||||
|
||||
if self.cursor_visible and input.has_focus:
|
||||
if input._cursor_at_end:
|
||||
result.pad_right(1)
|
||||
cursor_style = input.get_component_rich_style("input--cursor")
|
||||
cursor = input.cursor_position
|
||||
result.stylize(cursor_style, cursor, cursor + 1)
|
||||
|
||||
segments = list(result.render(console))
|
||||
line_length = Segment.get_line_length(segments)
|
||||
if line_length < width:
|
||||
segments = Segment.adjust_line_length(segments, width)
|
||||
line_length = width
|
||||
|
||||
line = line_crop(
|
||||
list(segments),
|
||||
input.view_position,
|
||||
input.view_position + width,
|
||||
line_length,
|
||||
)
|
||||
yield from line
|
||||
|
||||
|
||||
class _Template(Validator):
|
||||
"""Template mask enforcer."""
|
||||
|
||||
@dataclass
|
||||
class CharDef:
|
||||
"""Holds data for a single char of the template mask."""
|
||||
|
||||
pattern: Pattern[str]
|
||||
"""Compiled regular expression to check for matches."""
|
||||
|
||||
flags: _CharFlags = _CharFlags(0)
|
||||
"""Flags defining special behaviors"""
|
||||
|
||||
char: str = ""
|
||||
"""Mask character (separator or blank or placeholder)"""
|
||||
|
||||
def __init__(self, input: Input, template_str: str) -> None:
|
||||
self.input = input
|
||||
self.template: list[_Template.CharDef] = []
|
||||
self.blank: str = " "
|
||||
escaped = False
|
||||
flags = _CharFlags(0)
|
||||
template_chars: list[str] = list(template_str)
|
||||
|
||||
while template_chars:
|
||||
c = template_chars.pop(0)
|
||||
if escaped:
|
||||
char = self.CharDef(re.compile(re.escape(c)), _CharFlags.SEPARATOR, c)
|
||||
escaped = False
|
||||
else:
|
||||
if c == "\\":
|
||||
escaped = True
|
||||
continue
|
||||
elif c == ";":
|
||||
break
|
||||
|
||||
new_flags = {
|
||||
">": _CharFlags.UPPERCASE,
|
||||
"<": _CharFlags.LOWERCASE,
|
||||
"!": 0,
|
||||
}.get(c, None)
|
||||
if new_flags is not None:
|
||||
flags = new_flags
|
||||
continue
|
||||
|
||||
pattern, required_flag = _TEMPLATE_CHARACTERS.get(c, (None, None))
|
||||
if pattern:
|
||||
char_flags = _CharFlags.REQUIRED if required_flag else _CharFlags(0)
|
||||
char = self.CharDef(re.compile(pattern), char_flags)
|
||||
else:
|
||||
char = self.CharDef(
|
||||
re.compile(re.escape(c)), _CharFlags.SEPARATOR, c
|
||||
)
|
||||
|
||||
char.flags |= flags
|
||||
self.template.append(char)
|
||||
|
||||
if template_chars:
|
||||
self.blank = template_chars[0]
|
||||
|
||||
if all(char.flags & _CharFlags.SEPARATOR for char in self.template):
|
||||
raise ValueError(
|
||||
"Template must contain at least one non-separator character"
|
||||
)
|
||||
|
||||
self.update_mask(input.placeholder)
|
||||
|
||||
def validate(self, value: str) -> ValidationResult:
|
||||
if self.check(value.ljust(len(self.template), chr(0)), False):
|
||||
return self.success()
|
||||
else:
|
||||
return self.failure("Value does not match template!", value)
|
||||
|
||||
def check(self, value: str, allow_space: bool) -> bool:
|
||||
for c, char_def in zip(value, self.template):
|
||||
if (
|
||||
(char_def.flags & _CharFlags.REQUIRED)
|
||||
and (not char_def.pattern.match(c))
|
||||
and ((c != " ") or not allow_space)
|
||||
):
|
||||
return False
|
||||
return True
|
||||
|
||||
def insert_separators(self, value: str, cursor_position: int) -> tuple[str, int]:
|
||||
while cursor_position < len(self.template) and (
|
||||
self.template[cursor_position].flags & _CharFlags.SEPARATOR
|
||||
):
|
||||
value = (
|
||||
value[:cursor_position]
|
||||
+ self.template[cursor_position].char
|
||||
+ value[cursor_position + 1 :]
|
||||
)
|
||||
cursor_position += 1
|
||||
return value, cursor_position
|
||||
|
||||
def insert_text_at_cursor(self, text: str) -> str | None:
|
||||
value = self.input.value
|
||||
cursor_position = self.input.cursor_position
|
||||
separators = set(
|
||||
[
|
||||
char_def.char
|
||||
for char_def in self.template
|
||||
if char_def.flags & _CharFlags.SEPARATOR
|
||||
]
|
||||
)
|
||||
for c in text:
|
||||
if c in separators:
|
||||
if c == self.next_separator(cursor_position):
|
||||
prev_position = self.prev_separator_position(cursor_position)
|
||||
if (cursor_position > 0) and (prev_position != cursor_position - 1):
|
||||
next_position = self.next_separator_position(cursor_position)
|
||||
while cursor_position < next_position + 1:
|
||||
if (
|
||||
self.template[cursor_position].flags
|
||||
& _CharFlags.SEPARATOR
|
||||
):
|
||||
char = self.template[cursor_position].char
|
||||
else:
|
||||
char = " "
|
||||
value = (
|
||||
value[:cursor_position]
|
||||
+ char
|
||||
+ value[cursor_position + 1 :]
|
||||
)
|
||||
cursor_position += 1
|
||||
continue
|
||||
if cursor_position >= len(self.template):
|
||||
break
|
||||
char_def = self.template[cursor_position]
|
||||
assert (char_def.flags & _CharFlags.SEPARATOR) == 0
|
||||
if not char_def.pattern.match(c):
|
||||
return None
|
||||
if char_def.flags & _CharFlags.LOWERCASE:
|
||||
c = c.lower()
|
||||
elif char_def.flags & _CharFlags.UPPERCASE:
|
||||
c = c.upper()
|
||||
value = value[:cursor_position] + c + value[cursor_position + 1 :]
|
||||
cursor_position += 1
|
||||
value, cursor_position = self.insert_separators(value, cursor_position)
|
||||
return value, cursor_position
|
||||
|
||||
def move_cursor(self, delta: int) -> None:
|
||||
cursor_position = self.input.cursor_position
|
||||
if delta < 0 and all(
|
||||
[c.flags & _CharFlags.SEPARATOR for c in self.template[:cursor_position]]
|
||||
):
|
||||
return
|
||||
cursor_position += delta
|
||||
while (
|
||||
(cursor_position >= 0)
|
||||
and (cursor_position < len(self.template))
|
||||
and (self.template[cursor_position].flags & _CharFlags.SEPARATOR)
|
||||
):
|
||||
cursor_position += delta
|
||||
self.input.cursor_position = cursor_position
|
||||
|
||||
def delete_at_position(self, position: int | None = None) -> None:
|
||||
value = self.input.value
|
||||
if position is None:
|
||||
position = self.input.cursor_position
|
||||
cursor_position = position
|
||||
if cursor_position < len(self.template):
|
||||
assert (self.template[cursor_position].flags & _CharFlags.SEPARATOR) == 0
|
||||
if cursor_position == len(value) - 1:
|
||||
value = value[:cursor_position]
|
||||
else:
|
||||
value = value[:cursor_position] + " " + value[cursor_position + 1 :]
|
||||
pos = len(value)
|
||||
while pos > 0:
|
||||
char_def = self.template[pos - 1]
|
||||
if ((char_def.flags & _CharFlags.SEPARATOR) == 0) and (
|
||||
value[pos - 1] != " "
|
||||
):
|
||||
break
|
||||
pos -= 1
|
||||
value = value[:pos]
|
||||
if cursor_position > len(value):
|
||||
cursor_position = len(value)
|
||||
value, cursor_position = self.insert_separators(value, cursor_position)
|
||||
self.input.cursor_position = cursor_position
|
||||
self.input.value = value
|
||||
|
||||
def at_separator(self, position: int | None = None) -> bool:
|
||||
if position is None:
|
||||
position = self.input.cursor_position
|
||||
if (position >= 0) and (position < len(self.template)):
|
||||
return bool(self.template[position].flags & _CharFlags.SEPARATOR)
|
||||
else:
|
||||
return False
|
||||
|
||||
def prev_separator_position(self, position: int | None = None) -> int | None:
|
||||
if position is None:
|
||||
position = self.input.cursor_position
|
||||
for index in range(position - 1, 0, -1):
|
||||
if self.template[index].flags & _CharFlags.SEPARATOR:
|
||||
return index
|
||||
else:
|
||||
return None
|
||||
|
||||
def next_separator_position(self, position: int | None = None) -> int | None:
|
||||
if position is None:
|
||||
position = self.input.cursor_position
|
||||
for index in range(position + 1, len(self.template)):
|
||||
if self.template[index].flags & _CharFlags.SEPARATOR:
|
||||
return index
|
||||
else:
|
||||
return None
|
||||
|
||||
def next_separator(self, position: int | None = None) -> str | None:
|
||||
position = self.next_separator_position(position)
|
||||
if position is None:
|
||||
return None
|
||||
else:
|
||||
return self.template[position].char
|
||||
|
||||
def display(self, value: str) -> str:
|
||||
result = []
|
||||
for c, char_def in zip(value, self.template):
|
||||
if c == " ":
|
||||
c = char_def.char
|
||||
result.append(c)
|
||||
return "".join(result)
|
||||
|
||||
def update_mask(self, placeholder: str) -> None:
|
||||
for index, char_def in enumerate(self.template):
|
||||
if (char_def.flags & _CharFlags.SEPARATOR) == 0:
|
||||
if index < len(placeholder):
|
||||
char_def.char = placeholder[index]
|
||||
else:
|
||||
char_def.char = self.blank
|
||||
|
||||
@property
|
||||
def mask(self) -> str:
|
||||
return "".join([c.char for c in self.template])
|
||||
|
||||
@property
|
||||
def empty_mask(self) -> str:
|
||||
return "".join(
|
||||
[
|
||||
" " if (c.flags & _CharFlags.SEPARATOR) == 0 else c.char
|
||||
for c in self.template
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
class MaskedInput(Input, can_focus=True):
|
||||
"""A masked text input widget."""
|
||||
|
||||
template = var("")
|
||||
"""Input template mask currently in use."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
template: str,
|
||||
value: str | None = None,
|
||||
placeholder: str = "",
|
||||
*,
|
||||
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,
|
||||
disabled: bool = False,
|
||||
tooltip: RenderableType | None = None,
|
||||
) -> None:
|
||||
"""Initialise the `Input` widget.
|
||||
|
||||
Args:
|
||||
template: Template string.
|
||||
value: An optional default value for the input.
|
||||
placeholder: Optional placeholder text for the input.
|
||||
validators: An iterable of validators that the MaskedInput 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 masked input widget.
|
||||
id: Optional ID for the widget.
|
||||
classes: Optional initial classes for the widget.
|
||||
disabled: Whether the input is disabled or not.
|
||||
tooltip: Optional tooltip.
|
||||
"""
|
||||
self._template: _Template = None
|
||||
super().__init__(
|
||||
placeholder=placeholder,
|
||||
validators=validators,
|
||||
validate_on=validate_on,
|
||||
valid_empty=valid_empty,
|
||||
name=name,
|
||||
id=id,
|
||||
classes=classes,
|
||||
disabled=disabled,
|
||||
)
|
||||
|
||||
self._template = _Template(self, template)
|
||||
self.template = template
|
||||
|
||||
value, _ = self._template.insert_separators(value or "", 0)
|
||||
self.value = value
|
||||
if tooltip is not None:
|
||||
self.tooltip = tooltip
|
||||
|
||||
def validate_value(self, value: str) -> str:
|
||||
if self._template is None:
|
||||
return value
|
||||
if not self._template.check(value, True):
|
||||
raise ValueError("Value does not match template!")
|
||||
return value[: len(self._template.mask)]
|
||||
|
||||
def _watch_template(self, template: str) -> None:
|
||||
"""Revalidate when template changes."""
|
||||
self._template = _Template(self, template) if template else None
|
||||
if self.is_mounted:
|
||||
self._watch_value(self.value)
|
||||
|
||||
def _watch_placeholder(self, placeholder: str) -> None:
|
||||
"""Update template display mask when placeholder changes."""
|
||||
if self._template is not None:
|
||||
self._template.update_mask(placeholder)
|
||||
self.refresh()
|
||||
|
||||
def validate(self, value: str) -> ValidationResult | None:
|
||||
"""Run all the validators associated with this MaskedInput on the supplied value.
|
||||
|
||||
Same as `Input.validate()` but also validates against template which acts as an
|
||||
additional implicit validator.
|
||||
|
||||
Returns:
|
||||
A ValidationResult indicating whether *all* validators succeeded or not.
|
||||
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")
|
||||
|
||||
result = super().validate(value)
|
||||
validation_results: list[ValidationResult] = [self._template.validate(value)]
|
||||
if result is not None:
|
||||
validation_results.append(result)
|
||||
combined_result = ValidationResult.merge(validation_results)
|
||||
self._valid = combined_result.is_valid
|
||||
set_classes()
|
||||
|
||||
return combined_result
|
||||
|
||||
def render(self) -> RenderResult:
|
||||
return _InputRenderable(self, self._cursor_visible)
|
||||
|
||||
@property
|
||||
def _value(self) -> Text:
|
||||
"""Value rendered as text."""
|
||||
value = self._template.display(self.value)
|
||||
return Text(value, no_wrap=True, overflow="ignore")
|
||||
|
||||
async def _on_click(self, event: events.Click) -> None:
|
||||
await super()._on_click(event)
|
||||
if self._template.at_separator():
|
||||
self._template.move_cursor(1)
|
||||
|
||||
def insert_text_at_cursor(self, text: str) -> None:
|
||||
"""Insert new text at the cursor, move the cursor to the end of the new text.
|
||||
|
||||
Args:
|
||||
text: New text to insert.
|
||||
"""
|
||||
|
||||
new_value = self._template.insert_text_at_cursor(text)
|
||||
if new_value is not None:
|
||||
self.value, self.cursor_position = new_value
|
||||
else:
|
||||
self.restricted()
|
||||
|
||||
def clear(self) -> None:
|
||||
"""Clear the masked input."""
|
||||
self.value, self.cursor_position = self._template.insert_separators("", 0)
|
||||
|
||||
def action_cursor_left(self) -> None:
|
||||
"""Move the cursor one position to the left."""
|
||||
self._template.move_cursor(-1)
|
||||
|
||||
def action_cursor_right(self) -> None:
|
||||
"""Accept an auto-completion or move the cursor one position to the right."""
|
||||
self._template.move_cursor(1)
|
||||
|
||||
def action_home(self) -> None:
|
||||
"""Move the cursor to the start of the input."""
|
||||
self._template.move_cursor(-len(self.template))
|
||||
|
||||
def action_cursor_left_word(self) -> None:
|
||||
"""Move the cursor left to the start of a word."""
|
||||
if self._template.at_separator(self.cursor_position - 1):
|
||||
position = self._template.prev_separator_position(self.cursor_position - 1)
|
||||
else:
|
||||
position = self._template.prev_separator_position()
|
||||
if position:
|
||||
position += 1
|
||||
self.cursor_position = position or 0
|
||||
|
||||
def action_cursor_right_word(self) -> None:
|
||||
"""Move the cursor right to the start of a word."""
|
||||
position = self._template.next_separator_position()
|
||||
if position is None:
|
||||
self.cursor_position = len(self._template.mask)
|
||||
else:
|
||||
self.cursor_position = position + 1
|
||||
|
||||
def action_delete_right(self) -> None:
|
||||
"""Delete one character at the current cursor position."""
|
||||
self._template.delete_at_position()
|
||||
|
||||
def action_delete_right_word(self) -> None:
|
||||
"""Delete the current character and all rightward to the start of the next word."""
|
||||
position = self._template.next_separator_position()
|
||||
if position is not None:
|
||||
position += 1
|
||||
else:
|
||||
position = len(self.value)
|
||||
for index in range(self.cursor_position, position):
|
||||
self.cursor_position = index
|
||||
if not self._template.at_separator():
|
||||
self._template.delete_at_position()
|
||||
|
||||
def action_delete_left(self) -> None:
|
||||
"""Delete one character to the left of the current cursor position."""
|
||||
if self.cursor_position <= 0:
|
||||
# Cursor at the start, so nothing to delete
|
||||
return
|
||||
self._template.move_cursor(-1)
|
||||
self._template.delete_at_position()
|
||||
|
||||
def action_delete_left_word(self) -> None:
|
||||
"""Delete leftward of the cursor position to the start of a word."""
|
||||
if self.cursor_position <= 0:
|
||||
return
|
||||
if self._template.at_separator(self.cursor_position - 1):
|
||||
position = self._template.prev_separator_position(self.cursor_position - 1)
|
||||
else:
|
||||
position = self._template.prev_separator_position()
|
||||
if position:
|
||||
position += 1
|
||||
else:
|
||||
position = 0
|
||||
for index in range(position, self.cursor_position):
|
||||
self.cursor_position = index
|
||||
if not self._template.at_separator():
|
||||
self._template.delete_at_position()
|
||||
self.cursor_position = position
|
||||
|
||||
def action_delete_left_all(self) -> None:
|
||||
"""Delete all characters to the left of the cursor position."""
|
||||
if self.cursor_position > 0:
|
||||
cursor_position = self.cursor_position
|
||||
if cursor_position >= len(self.value):
|
||||
self.value = ""
|
||||
else:
|
||||
self.value = (
|
||||
self._template.empty_mask[:cursor_position]
|
||||
+ self.value[cursor_position:]
|
||||
)
|
||||
self.cursor_position = 0
|
||||
@@ -28169,6 +28169,166 @@
|
||||
|
||||
'''
|
||||
# ---
|
||||
# name: test_masked_input
|
||||
'''
|
||||
<svg class="rich-terminal" viewBox="0 0 994 635.5999999999999" xmlns="http://www.w3.org/2000/svg">
|
||||
<!-- Generated with Rich https://www.textualize.io -->
|
||||
<style>
|
||||
|
||||
@font-face {
|
||||
font-family: "Fira Code";
|
||||
src: local("FiraCode-Regular"),
|
||||
url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff2/FiraCode-Regular.woff2") format("woff2"),
|
||||
url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff/FiraCode-Regular.woff") format("woff");
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Fira Code";
|
||||
src: local("FiraCode-Bold"),
|
||||
url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff2/FiraCode-Bold.woff2") format("woff2"),
|
||||
url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff/FiraCode-Bold.woff") format("woff");
|
||||
font-style: bold;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.terminal-2693584429-matrix {
|
||||
font-family: Fira Code, monospace;
|
||||
font-size: 20px;
|
||||
line-height: 24.4px;
|
||||
font-variant-east-asian: full-width;
|
||||
}
|
||||
|
||||
.terminal-2693584429-title {
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
font-family: arial;
|
||||
}
|
||||
|
||||
.terminal-2693584429-r1 { fill: #1e1e1e }
|
||||
.terminal-2693584429-r2 { fill: #b93c5b }
|
||||
.terminal-2693584429-r3 { fill: #c5c8c6 }
|
||||
.terminal-2693584429-r4 { fill: #e2e2e2 }
|
||||
.terminal-2693584429-r5 { fill: #787878 }
|
||||
.terminal-2693584429-r6 { fill: #121212 }
|
||||
.terminal-2693584429-r7 { fill: #e1e1e1 }
|
||||
</style>
|
||||
|
||||
<defs>
|
||||
<clipPath id="terminal-2693584429-clip-terminal">
|
||||
<rect x="0" y="0" width="975.0" height="584.5999999999999" />
|
||||
</clipPath>
|
||||
<clipPath id="terminal-2693584429-line-0">
|
||||
<rect x="0" y="1.5" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-2693584429-line-1">
|
||||
<rect x="0" y="25.9" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-2693584429-line-2">
|
||||
<rect x="0" y="50.3" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-2693584429-line-3">
|
||||
<rect x="0" y="74.7" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-2693584429-line-4">
|
||||
<rect x="0" y="99.1" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-2693584429-line-5">
|
||||
<rect x="0" y="123.5" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-2693584429-line-6">
|
||||
<rect x="0" y="147.9" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-2693584429-line-7">
|
||||
<rect x="0" y="172.3" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-2693584429-line-8">
|
||||
<rect x="0" y="196.7" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-2693584429-line-9">
|
||||
<rect x="0" y="221.1" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-2693584429-line-10">
|
||||
<rect x="0" y="245.5" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-2693584429-line-11">
|
||||
<rect x="0" y="269.9" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-2693584429-line-12">
|
||||
<rect x="0" y="294.3" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-2693584429-line-13">
|
||||
<rect x="0" y="318.7" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-2693584429-line-14">
|
||||
<rect x="0" y="343.1" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-2693584429-line-15">
|
||||
<rect x="0" y="367.5" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-2693584429-line-16">
|
||||
<rect x="0" y="391.9" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-2693584429-line-17">
|
||||
<rect x="0" y="416.3" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-2693584429-line-18">
|
||||
<rect x="0" y="440.7" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-2693584429-line-19">
|
||||
<rect x="0" y="465.1" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-2693584429-line-20">
|
||||
<rect x="0" y="489.5" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-2693584429-line-21">
|
||||
<rect x="0" y="513.9" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-2693584429-line-22">
|
||||
<rect x="0" y="538.3" width="976" height="24.65"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
|
||||
<rect fill="#292929" stroke="rgba(255,255,255,0.35)" stroke-width="1" x="1" y="1" width="992" height="633.6" rx="8"/><text class="terminal-2693584429-title" fill="#c5c8c6" text-anchor="middle" x="496" y="27">TemplateApp</text>
|
||||
<g transform="translate(26,22)">
|
||||
<circle cx="0" cy="0" r="7" fill="#ff5f57"/>
|
||||
<circle cx="22" cy="0" r="7" fill="#febc2e"/>
|
||||
<circle cx="44" cy="0" r="7" fill="#28c840"/>
|
||||
</g>
|
||||
|
||||
<g transform="translate(9, 41)" clip-path="url(#terminal-2693584429-clip-terminal)">
|
||||
<rect fill="#b93c5b" x="0" y="1.5" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#262626" x="12.2" y="1.5" width="951.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="963.8" y="1.5" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#b93c5b" x="0" y="25.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#262626" x="12.2" y="25.9" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#262626" x="36.6" y="25.9" width="97.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#e1e1e1" x="134.2" y="25.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#262626" x="146.4" y="25.9" width="170.8" height="24.65" shape-rendering="crispEdges"/><rect fill="#262626" x="317.2" y="25.9" width="622.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#262626" x="939.4" y="25.9" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="963.8" y="25.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#b93c5b" x="0" y="50.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#262626" x="12.2" y="50.3" width="951.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="963.8" y="50.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="74.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#262626" x="12.2" y="74.7" width="951.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="963.8" y="74.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="99.1" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#262626" x="12.2" y="99.1" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#262626" x="36.6" y="99.1" width="122" height="24.65" shape-rendering="crispEdges"/><rect fill="#262626" x="158.6" y="99.1" width="780.8" height="24.65" shape-rendering="crispEdges"/><rect fill="#262626" x="939.4" y="99.1" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="963.8" y="99.1" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="123.5" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#262626" x="12.2" y="123.5" width="951.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="963.8" y="123.5" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="147.9" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="172.3" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="196.7" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="221.1" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="245.5" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="269.9" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="294.3" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="318.7" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="343.1" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="367.5" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="391.9" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="416.3" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="440.7" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="465.1" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="489.5" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="513.9" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="538.3" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="562.7" width="976" height="24.65" shape-rendering="crispEdges"/>
|
||||
<g class="terminal-2693584429-matrix">
|
||||
<text class="terminal-2693584429-r1" x="0" y="20" textLength="12.2" clip-path="url(#terminal-2693584429-line-0)">▊</text><text class="terminal-2693584429-r2" x="12.2" y="20" textLength="951.6" clip-path="url(#terminal-2693584429-line-0)">▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔</text><text class="terminal-2693584429-r2" x="963.8" y="20" textLength="12.2" clip-path="url(#terminal-2693584429-line-0)">▎</text><text class="terminal-2693584429-r3" x="976" y="20" textLength="12.2" clip-path="url(#terminal-2693584429-line-0)">
|
||||
</text><text class="terminal-2693584429-r1" x="0" y="44.4" textLength="12.2" clip-path="url(#terminal-2693584429-line-1)">▊</text><text class="terminal-2693584429-r4" x="36.6" y="44.4" textLength="97.6" clip-path="url(#terminal-2693584429-line-1)">ABC01-DE</text><text class="terminal-2693584429-r1" x="134.2" y="44.4" textLength="12.2" clip-path="url(#terminal-2693584429-line-1)">_</text><text class="terminal-2693584429-r5" x="146.4" y="44.4" textLength="170.8" clip-path="url(#terminal-2693584429-line-1)">__-_____-_____</text><text class="terminal-2693584429-r2" x="963.8" y="44.4" textLength="12.2" clip-path="url(#terminal-2693584429-line-1)">▎</text><text class="terminal-2693584429-r3" x="976" y="44.4" textLength="12.2" clip-path="url(#terminal-2693584429-line-1)">
|
||||
</text><text class="terminal-2693584429-r1" x="0" y="68.8" textLength="12.2" clip-path="url(#terminal-2693584429-line-2)">▊</text><text class="terminal-2693584429-r2" x="12.2" y="68.8" textLength="951.6" clip-path="url(#terminal-2693584429-line-2)">▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁</text><text class="terminal-2693584429-r2" x="963.8" y="68.8" textLength="12.2" clip-path="url(#terminal-2693584429-line-2)">▎</text><text class="terminal-2693584429-r3" x="976" y="68.8" textLength="12.2" clip-path="url(#terminal-2693584429-line-2)">
|
||||
</text><text class="terminal-2693584429-r1" x="0" y="93.2" textLength="12.2" clip-path="url(#terminal-2693584429-line-3)">▊</text><text class="terminal-2693584429-r6" x="12.2" y="93.2" textLength="951.6" clip-path="url(#terminal-2693584429-line-3)">▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔</text><text class="terminal-2693584429-r6" x="963.8" y="93.2" textLength="12.2" clip-path="url(#terminal-2693584429-line-3)">▎</text><text class="terminal-2693584429-r3" x="976" y="93.2" textLength="12.2" clip-path="url(#terminal-2693584429-line-3)">
|
||||
</text><text class="terminal-2693584429-r1" x="0" y="117.6" textLength="12.2" clip-path="url(#terminal-2693584429-line-4)">▊</text><text class="terminal-2693584429-r5" x="36.6" y="117.6" textLength="122" clip-path="url(#terminal-2693584429-line-4)">YYYY-MM-DD</text><text class="terminal-2693584429-r6" x="963.8" y="117.6" textLength="12.2" clip-path="url(#terminal-2693584429-line-4)">▎</text><text class="terminal-2693584429-r3" x="976" y="117.6" textLength="12.2" clip-path="url(#terminal-2693584429-line-4)">
|
||||
</text><text class="terminal-2693584429-r1" x="0" y="142" textLength="12.2" clip-path="url(#terminal-2693584429-line-5)">▊</text><text class="terminal-2693584429-r6" x="12.2" y="142" textLength="951.6" clip-path="url(#terminal-2693584429-line-5)">▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁</text><text class="terminal-2693584429-r6" x="963.8" y="142" textLength="12.2" clip-path="url(#terminal-2693584429-line-5)">▎</text><text class="terminal-2693584429-r3" x="976" y="142" textLength="12.2" clip-path="url(#terminal-2693584429-line-5)">
|
||||
</text><text class="terminal-2693584429-r3" x="976" y="166.4" textLength="12.2" clip-path="url(#terminal-2693584429-line-6)">
|
||||
</text><text class="terminal-2693584429-r3" x="976" y="190.8" textLength="12.2" clip-path="url(#terminal-2693584429-line-7)">
|
||||
</text><text class="terminal-2693584429-r3" x="976" y="215.2" textLength="12.2" clip-path="url(#terminal-2693584429-line-8)">
|
||||
</text><text class="terminal-2693584429-r3" x="976" y="239.6" textLength="12.2" clip-path="url(#terminal-2693584429-line-9)">
|
||||
</text><text class="terminal-2693584429-r3" x="976" y="264" textLength="12.2" clip-path="url(#terminal-2693584429-line-10)">
|
||||
</text><text class="terminal-2693584429-r3" x="976" y="288.4" textLength="12.2" clip-path="url(#terminal-2693584429-line-11)">
|
||||
</text><text class="terminal-2693584429-r3" x="976" y="312.8" textLength="12.2" clip-path="url(#terminal-2693584429-line-12)">
|
||||
</text><text class="terminal-2693584429-r3" x="976" y="337.2" textLength="12.2" clip-path="url(#terminal-2693584429-line-13)">
|
||||
</text><text class="terminal-2693584429-r3" x="976" y="361.6" textLength="12.2" clip-path="url(#terminal-2693584429-line-14)">
|
||||
</text><text class="terminal-2693584429-r3" x="976" y="386" textLength="12.2" clip-path="url(#terminal-2693584429-line-15)">
|
||||
</text><text class="terminal-2693584429-r3" x="976" y="410.4" textLength="12.2" clip-path="url(#terminal-2693584429-line-16)">
|
||||
</text><text class="terminal-2693584429-r3" x="976" y="434.8" textLength="12.2" clip-path="url(#terminal-2693584429-line-17)">
|
||||
</text><text class="terminal-2693584429-r3" x="976" y="459.2" textLength="12.2" clip-path="url(#terminal-2693584429-line-18)">
|
||||
</text><text class="terminal-2693584429-r3" x="976" y="483.6" textLength="12.2" clip-path="url(#terminal-2693584429-line-19)">
|
||||
</text><text class="terminal-2693584429-r3" x="976" y="508" textLength="12.2" clip-path="url(#terminal-2693584429-line-20)">
|
||||
</text><text class="terminal-2693584429-r3" x="976" y="532.4" textLength="12.2" clip-path="url(#terminal-2693584429-line-21)">
|
||||
</text><text class="terminal-2693584429-r3" x="976" y="556.8" textLength="12.2" clip-path="url(#terminal-2693584429-line-22)">
|
||||
</text>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
'''
|
||||
# ---
|
||||
# name: test_max_height_100
|
||||
'''
|
||||
<svg class="rich-terminal" viewBox="0 0 994 635.5999999999999" xmlns="http://www.w3.org/2000/svg">
|
||||
|
||||
13
tests/snapshot_tests/snapshot_apps/masked_input.py
Normal file
13
tests/snapshot_tests/snapshot_apps/masked_input.py
Normal file
@@ -0,0 +1,13 @@
|
||||
from textual.app import App, ComposeResult
|
||||
from textual.widgets import MaskedInput
|
||||
|
||||
|
||||
class TemplateApp(App[None]):
|
||||
def compose(self) -> ComposeResult:
|
||||
yield MaskedInput(">NNNNN-NNNNN-NNNNN-NNNNN;_")
|
||||
yield MaskedInput("9999-99-99", placeholder="YYYY-MM-DD")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
app = TemplateApp()
|
||||
app.run()
|
||||
@@ -111,6 +111,15 @@ def test_input_suggestions(snap_compare):
|
||||
)
|
||||
|
||||
|
||||
def test_masked_input(snap_compare):
|
||||
async def run_before(pilot):
|
||||
pilot.app.query(Input).first().cursor_blink = False
|
||||
|
||||
assert snap_compare(
|
||||
SNAPSHOT_APPS_DIR / "masked_input.py", press=["A","B","C","0","1","-","D","E"], run_before=run_before
|
||||
)
|
||||
|
||||
|
||||
def test_buttons_render(snap_compare):
|
||||
# Testing button rendering. We press tab to focus the first button too.
|
||||
assert snap_compare(WIDGET_EXAMPLES_DIR / "button.py", press=["tab"])
|
||||
|
||||
218
tests/test_masked_input.py
Normal file
218
tests/test_masked_input.py
Normal file
@@ -0,0 +1,218 @@
|
||||
from typing import Union
|
||||
|
||||
import pytest
|
||||
|
||||
from textual import on
|
||||
from textual.app import App, ComposeResult
|
||||
from textual.validation import Failure, ValidationResult
|
||||
from textual.widgets import MaskedInput
|
||||
|
||||
InputEvent = Union[MaskedInput.Changed, MaskedInput.Submitted]
|
||||
|
||||
|
||||
class InputApp(App[None]):
|
||||
def __init__(self, template: str, placeholder: str = ""):
|
||||
super().__init__()
|
||||
self.messages: list[InputEvent] = []
|
||||
self.template = template
|
||||
self.placeholder = placeholder
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
yield MaskedInput(template=self.template, placeholder=self.placeholder)
|
||||
|
||||
@on(MaskedInput.Changed)
|
||||
@on(MaskedInput.Submitted)
|
||||
def on_changed_or_submitted(self, event: InputEvent) -> None:
|
||||
self.messages.append(event)
|
||||
|
||||
|
||||
async def test_missing_required():
|
||||
app = InputApp(">9999-99-99")
|
||||
async with app.run_test() as pilot:
|
||||
input = app.query_one(MaskedInput)
|
||||
input.value = "2024-12"
|
||||
assert not input.is_valid
|
||||
await pilot.pause()
|
||||
assert len(app.messages) == 1
|
||||
assert app.messages[0].validation_result == ValidationResult.failure(
|
||||
failures=[
|
||||
Failure(
|
||||
value="2024-12",
|
||||
validator=input._template,
|
||||
description="Value does not match template!",
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
async def test_valid_required():
|
||||
app = InputApp(">9999-99-99")
|
||||
async with app.run_test() as pilot:
|
||||
input = app.query_one(MaskedInput)
|
||||
input.value = "2024-12-31"
|
||||
assert input.is_valid
|
||||
await pilot.pause()
|
||||
assert len(app.messages) == 1
|
||||
assert app.messages[0].validation_result == ValidationResult.success()
|
||||
|
||||
|
||||
async def test_missing_optional():
|
||||
app = InputApp(">9999-99-00")
|
||||
async with app.run_test() as pilot:
|
||||
input = app.query_one(MaskedInput)
|
||||
input.value = "2024-12"
|
||||
assert input.is_valid
|
||||
await pilot.pause()
|
||||
assert len(app.messages) == 1
|
||||
assert app.messages[0].validation_result == ValidationResult.success()
|
||||
|
||||
|
||||
async def test_editing():
|
||||
serial = "ABCDE-FGHIJ-KLMNO-PQRST"
|
||||
app = InputApp(">NNNNN-NNNNN-NNNNN-NNNNN;_")
|
||||
async with app.run_test() as pilot:
|
||||
input = app.query_one(MaskedInput)
|
||||
await pilot.press("A", "B", "C", "D")
|
||||
assert input.cursor_position == 4
|
||||
assert input.value == "ABCD"
|
||||
await pilot.press("E")
|
||||
assert input.cursor_position == 6
|
||||
assert input.value == "ABCDE-"
|
||||
await pilot.press("backspace")
|
||||
assert input.cursor_position == 4
|
||||
assert input.value == "ABCD"
|
||||
input.value = serial
|
||||
assert input.is_valid
|
||||
app.set_focus(None)
|
||||
input.focus()
|
||||
await pilot.pause()
|
||||
assert input.cursor_position == len(serial)
|
||||
await pilot.press("U")
|
||||
assert input.cursor_position == len(serial)
|
||||
|
||||
|
||||
async def test_key_movement_actions():
|
||||
serial = "ABCDE-FGHIJ-KLMNO-PQRST"
|
||||
app = InputApp(">NNNNN-NNNNN-NNNNN-NNNNN;_")
|
||||
async with app.run_test():
|
||||
input = app.query_one(MaskedInput)
|
||||
input.value = serial
|
||||
assert input.is_valid
|
||||
input.action_cursor_right_word()
|
||||
assert input.cursor_position == 6
|
||||
input.action_cursor_right()
|
||||
input.action_cursor_right_word()
|
||||
assert input.cursor_position == 12
|
||||
input.action_cursor_left()
|
||||
input.action_cursor_left()
|
||||
assert input.cursor_position == 9
|
||||
input.action_cursor_left_word()
|
||||
assert input.cursor_position == 6
|
||||
|
||||
|
||||
async def test_key_modification_actions():
|
||||
serial = "ABCDE-FGHIJ-KLMNO-PQRST"
|
||||
app = InputApp(">NNNNN-NNNNN-NNNNN-NNNNN;_")
|
||||
async with app.run_test() as pilot:
|
||||
input = app.query_one(MaskedInput)
|
||||
input.value = serial
|
||||
assert input.is_valid
|
||||
input.action_delete_right()
|
||||
assert input.value == " BCDE-FGHIJ-KLMNO-PQRST"
|
||||
input.cursor_position = 3
|
||||
input.action_delete_left()
|
||||
assert input.value == " B DE-FGHIJ-KLMNO-PQRST"
|
||||
input.cursor_position = 6
|
||||
input.action_delete_left()
|
||||
assert input.value == " B D -FGHIJ-KLMNO-PQRST"
|
||||
input.cursor_position = 9
|
||||
input.action_delete_left_word()
|
||||
assert input.value == " B D - IJ-KLMNO-PQRST"
|
||||
input.action_delete_left_word()
|
||||
assert input.value == " - IJ-KLMNO-PQRST"
|
||||
input.cursor_position = 15
|
||||
input.action_delete_right_word()
|
||||
assert input.value == " - IJ-KLM -PQRST"
|
||||
input.action_delete_right_word()
|
||||
assert input.value == " - IJ-KLM"
|
||||
input.cursor_position = 10
|
||||
input.action_delete_right_all()
|
||||
assert input.value == " - I"
|
||||
await pilot.press("J")
|
||||
assert input.value == " - IJ-"
|
||||
input.action_cursor_left()
|
||||
input.action_delete_left_all()
|
||||
assert input.value == " - J-"
|
||||
input.clear()
|
||||
assert input.value == ""
|
||||
|
||||
|
||||
async def test_cursor_word_right_after_last_separator():
|
||||
app = InputApp(">NNN-NNN-NNN-NNNNN;_")
|
||||
async with app.run_test():
|
||||
input = app.query_one(MaskedInput)
|
||||
input.value = "123-456-789-012"
|
||||
input.cursor_position = 13
|
||||
input.action_cursor_right_word()
|
||||
assert input.cursor_position == 15
|
||||
|
||||
|
||||
async def test_case_conversion_meta_characters():
|
||||
app = InputApp("NN<-N!N>N")
|
||||
async with app.run_test() as pilot:
|
||||
input = app.query_one(MaskedInput)
|
||||
await pilot.press("a", "B", "C", "D", "e")
|
||||
assert input.value == "aB-cDE"
|
||||
assert input.is_valid
|
||||
|
||||
|
||||
async def test_case_conversion_override():
|
||||
app = InputApp(">-<NN")
|
||||
async with app.run_test() as pilot:
|
||||
input = app.query_one(MaskedInput)
|
||||
await pilot.press("a", "B")
|
||||
assert input.value == "-ab"
|
||||
assert input.is_valid
|
||||
|
||||
|
||||
async def test_case_conversion_cancel():
|
||||
app = InputApp("-!N-")
|
||||
async with app.run_test() as pilot:
|
||||
input = app.query_one(MaskedInput)
|
||||
await pilot.press("a")
|
||||
assert input.value == "-a-"
|
||||
assert input.is_valid
|
||||
|
||||
|
||||
async def test_only_separators__raises_ValueError():
|
||||
app = InputApp("---")
|
||||
with pytest.raises(ValueError):
|
||||
async with app.run_test() as pilot:
|
||||
await pilot.press("a")
|
||||
|
||||
|
||||
async def test_custom_separator_escaping():
|
||||
app = InputApp("N\\aN\\N\\cN")
|
||||
async with app.run_test() as pilot:
|
||||
input = app.query_one(MaskedInput)
|
||||
await pilot.press("D", "e", "F")
|
||||
assert input.value == "DaeNcF"
|
||||
assert input.is_valid
|
||||
|
||||
|
||||
async def test_digits_not_required():
|
||||
app = InputApp("00;_")
|
||||
async with app.run_test() as pilot:
|
||||
input = app.query_one(MaskedInput)
|
||||
await pilot.press("a", "1")
|
||||
assert input.value == "1"
|
||||
assert input.is_valid
|
||||
|
||||
|
||||
async def test_digits_required():
|
||||
app = InputApp("99;_")
|
||||
async with app.run_test() as pilot:
|
||||
input = app.query_one(MaskedInput)
|
||||
await pilot.press("a", "1")
|
||||
assert input.value == "1"
|
||||
assert not input.is_valid
|
||||
Reference in New Issue
Block a user