Introduced MaskedInput widget

This commit is contained in:
Angelo Mottola
2024-07-22 00:38:41 +02:00
parent dd10561994
commit e437bf5bbe
11 changed files with 1109 additions and 0 deletions

View File

@@ -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

View 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()

View File

@@ -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).

View 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

View File

@@ -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"

View File

@@ -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",

View 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

View File

@@ -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">

View 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()

View File

@@ -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
View 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