diff --git a/CHANGELOG.md b/CHANGELOG.md
index d4be0225d9..90da5ef291 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
### Added
+- Added `MaskedInput` widget https://github.com/Textualize/textual/pull/4783
- Input validation for floats and integers accept embedded underscores, e.g., "1_234_567" is valid. https://github.com/Textualize/textual/pull/4784
### Changed
@@ -107,6 +108,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
- Fixed issue with Enter events causing unresponsive UI https://github.com/Textualize/textual/pull/4833
+
## [0.75.0] - 2024-08-01
### Added
diff --git a/docs/examples/widgets/masked_input.py b/docs/examples/widgets/masked_input.py
new file mode 100644
index 0000000000..dab3b442b4
--- /dev/null
+++ b/docs/examples/widgets/masked_input.py
@@ -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()
diff --git a/docs/widget_gallery.md b/docs/widget_gallery.md
index 0ff0cf5a70..62d6df383f 100644
--- a/docs/widget_gallery.md
+++ b/docs/widget_gallery.md
@@ -168,6 +168,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).
diff --git a/docs/widgets/masked_input.md b/docs/widgets/masked_input.md
new file mode 100644
index 0000000000..d40350b2c8
--- /dev/null
+++ b/docs/widgets/masked_input.md
@@ -0,0 +1,84 @@
+# MaskedInput
+
+!!! tip "Added in version 0.80.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
diff --git a/mkdocs-nav.yml b/mkdocs-nav.yml
index 514c5ca346..53b75f0391 100644
--- a/mkdocs-nav.yml
+++ b/mkdocs-nav.yml
@@ -157,6 +157,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"
diff --git a/src/textual/widgets/__init__.py b/src/textual/widgets/__init__.py
index 0329e3c269..c6a59fe349 100644
--- a/src/textual/widgets/__init__.py
+++ b/src/textual/widgets/__init__.py
@@ -28,6 +28,7 @@
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
@@ -68,6 +69,7 @@
"Log",
"Markdown",
"MarkdownViewer",
+ "MaskedInput",
"OptionList",
"Placeholder",
"Pretty",
diff --git a/src/textual/widgets/_masked_input.py b/src/textual/widgets/_masked_input.py
new file mode 100644
index 0000000000..6e8e17c994
--- /dev/null
+++ b/src/textual/widgets/_masked_input.py
@@ -0,0 +1,718 @@
+from __future__ import annotations
+
+import re
+from dataclasses import dataclass
+from enum import Flag, auto
+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(Flag):
+ """Misc flags for a single template character definition"""
+
+ NONE = 0
+ """Empty flags value"""
+
+ REQUIRED = auto()
+ """Is this character required for validation?"""
+
+ SEPARATOR = auto()
+ """Is this character a separator?"""
+
+ UPPERCASE = auto()
+ """Char is forced to be uppercase"""
+
+ LOWERCASE = auto()
+ """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, (char, char_definition) in enumerate(zip(value, template.template)):
+ if char == " ":
+ 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 CharDefinition:
+ """Holds data for a single char of the template mask."""
+
+ pattern: Pattern[str]
+ """Compiled regular expression to check for matches."""
+
+ flags: _CharFlags = _CharFlags.NONE
+ """Flags defining special behaviors"""
+
+ char: str = ""
+ """Mask character (separator or blank or placeholder)"""
+
+ def __init__(self, input: Input, template_str: str) -> None:
+ """Initialise the mask enforcer, which is also a subclass of `Validator`.
+
+ Args:
+ input: The `MaskedInput` that owns this object.
+ template_str: Template string controlling masked input behavior.
+ """
+ self.input = input
+ self.template: list[_Template.CharDefinition] = []
+ self.blank: str = " "
+ escaped = False
+ flags = _CharFlags.NONE
+ template_chars: list[str] = list(template_str)
+
+ while template_chars:
+ char = template_chars.pop(0)
+ if escaped:
+ char_definition = self.CharDefinition(
+ re.compile(re.escape(char)), _CharFlags.SEPARATOR, char
+ )
+ escaped = False
+ else:
+ if char == "\\":
+ escaped = True
+ continue
+ elif char == ";":
+ break
+
+ new_flags = {
+ ">": _CharFlags.UPPERCASE,
+ "<": _CharFlags.LOWERCASE,
+ "!": _CharFlags.NONE,
+ }.get(char, None)
+ if new_flags is not None:
+ flags = new_flags
+ continue
+
+ pattern, required_flag = _TEMPLATE_CHARACTERS.get(char, (None, None))
+ if pattern:
+ char_flags = (
+ _CharFlags.REQUIRED if required_flag else _CharFlags.NONE
+ )
+ char_definition = self.CharDefinition(
+ re.compile(pattern), char_flags
+ )
+ else:
+ char_definition = self.CharDefinition(
+ re.compile(re.escape(char)), _CharFlags.SEPARATOR, char
+ )
+
+ char_definition.flags |= flags
+ self.template.append(char_definition)
+
+ if template_chars:
+ self.blank = template_chars[0]
+
+ if all(
+ (_CharFlags.SEPARATOR in char_definition.flags)
+ for char_definition 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:
+ """Checks if `value` matches this template, always returning a ValidationResult.
+
+ Args:
+ value: The string value to be validated.
+
+ Returns:
+ A ValidationResult with the validation outcome.
+
+ """
+ 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:
+ """Checks if `value matches this template, but returns result as a bool.
+
+ Args:
+ value: The string value to be validated.
+ allow_space: Consider space character in `value` as valid.
+
+ Returns:
+ True if `value` is valid for this template, False otherwise.
+ """
+ for char, char_definition in zip(value, self.template):
+ if (
+ (_CharFlags.REQUIRED in char_definition.flags)
+ and (not char_definition.pattern.match(char))
+ and ((char != " ") or not allow_space)
+ ):
+ return False
+ return True
+
+ def insert_separators(self, value: str, cursor_position: int) -> tuple[str, int]:
+ """Automatically inserts separators in `value` at `cursor_position` if expected, eventually advancing
+ the current cursor position.
+
+ Args:
+ value: Current control value entered by user.
+ cursor_position: Where to start inserting separators (if any).
+
+ Returns:
+ A tuple in the form `(value, cursor_position)` with new value and possibly advanced cursor position.
+ """
+ while cursor_position < len(self.template) and (
+ _CharFlags.SEPARATOR in self.template[cursor_position].flags
+ ):
+ 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:
+ """Inserts `text` at current cursor position. If not present in `text`, any expected separator is automatically
+ inserted at the correct position.
+
+ Args:
+ text: The text to be inserted.
+
+ Returns:
+ A tuple in the form `(value, cursor_position)` with the new control value and current cursor position if
+ `text` matches the template, None otherwise.
+ """
+ value = self.input.value
+ cursor_position = self.input.cursor_position
+ separators = set(
+ [
+ char_definition.char
+ for char_definition in self.template
+ if _CharFlags.SEPARATOR in char_definition.flags
+ ]
+ )
+ for char in text:
+ if char in separators:
+ if char == 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 (
+ _CharFlags.SEPARATOR
+ in self.template[cursor_position].flags
+ ):
+ 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_definition = self.template[cursor_position]
+ assert _CharFlags.SEPARATOR not in char_definition.flags
+ if not char_definition.pattern.match(char):
+ return None
+ if _CharFlags.LOWERCASE in char_definition.flags:
+ char = char.lower()
+ elif _CharFlags.UPPERCASE in char_definition.flags:
+ char = char.upper()
+ value = value[:cursor_position] + char + 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:
+ """Moves the cursor position by `delta` characters, skipping separators if
+ running over them.
+
+ Args:
+ delta: The number of characters to move; positive moves right, negative
+ moves left.
+ """
+ cursor_position = self.input.cursor_position
+ if delta < 0 and all(
+ [
+ (_CharFlags.SEPARATOR in char_definition.flags)
+ for char_definition in self.template[:cursor_position]
+ ]
+ ):
+ return
+ cursor_position += delta
+ while (
+ (cursor_position >= 0)
+ and (cursor_position < len(self.template))
+ and (_CharFlags.SEPARATOR in self.template[cursor_position].flags)
+ ):
+ cursor_position += delta
+ self.input.cursor_position = cursor_position
+
+ def delete_at_position(self, position: int | None = None) -> None:
+ """Deletes character at `position`.
+
+ Args:
+ position: Position within the control value where to delete a character;
+ if None the current cursor position is used.
+ """
+ value = self.input.value
+ if position is None:
+ position = self.input.cursor_position
+ cursor_position = position
+ if cursor_position < len(self.template):
+ assert _CharFlags.SEPARATOR not in self.template[cursor_position].flags
+ 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_definition = self.template[pos - 1]
+ if (_CharFlags.SEPARATOR not in char_definition.flags) 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:
+ """Checks if character at `position` is a separator.
+
+ Args:
+ position: Position within the control value where to check;
+ if None the current cursor position is used.
+
+ Returns:
+ True if character is a separator, False otherwise.
+ """
+ if position is None:
+ position = self.input.cursor_position
+ if (position >= 0) and (position < len(self.template)):
+ return _CharFlags.SEPARATOR in self.template[position].flags
+ else:
+ return False
+
+ def prev_separator_position(self, position: int | None = None) -> int | None:
+ """Obtains the position of the previous separator character starting from
+ `position` within the template string.
+
+ Args:
+ position: Starting position from which to search previous separator.
+ If None, current cursor position is used.
+
+ Returns:
+ The position of the previous separator, or None if no previous
+ separator is found.
+ """
+ if position is None:
+ position = self.input.cursor_position
+ for index in range(position - 1, 0, -1):
+ if _CharFlags.SEPARATOR in self.template[index].flags:
+ return index
+ else:
+ return None
+
+ def next_separator_position(self, position: int | None = None) -> int | None:
+ """Obtains the position of the next separator character starting from
+ `position` within the template string.
+
+ Args:
+ position: Starting position from which to search next separator.
+ If None, current cursor position is used.
+
+ Returns:
+ The position of the next separator, or None if no next
+ separator is found.
+ """
+ if position is None:
+ position = self.input.cursor_position
+ for index in range(position + 1, len(self.template)):
+ if _CharFlags.SEPARATOR in self.template[index].flags:
+ return index
+ else:
+ return None
+
+ def next_separator(self, position: int | None = None) -> str | None:
+ """Obtains the next separator character starting from `position`
+ within the template string.
+
+ Args:
+ position: Starting position from which to search next separator.
+ If None, current cursor position is used.
+
+ Returns:
+ The next separator character, or None if no next
+ separator is found.
+ """
+ position = self.next_separator_position(position)
+ if position is None:
+ return None
+ else:
+ return self.template[position].char
+
+ def display(self, value: str) -> str:
+ """Returns `value` ready for display, with spaces replaced by
+ placeholder characters.
+
+ Args:
+ value: String value to display.
+
+ Returns:
+ New string value with spaces replaced by placeholders.
+ """
+ result = []
+ for char, char_definition in zip(value, self.template):
+ if char == " ":
+ char = char_definition.char
+ result.append(char)
+ return "".join(result)
+
+ def update_mask(self, placeholder: str) -> None:
+ """Updates template placeholder characters from `placeholder`. If
+ given string is smaller than template string, template blank character
+ is used to fill remaining template placeholder characters.
+
+ Args:
+ placeholder: New placeholder string.
+ """
+ for index, char_definition in enumerate(self.template):
+ if _CharFlags.SEPARATOR not in char_definition.flags:
+ if index < len(placeholder):
+ char_definition.char = placeholder[index]
+ else:
+ char_definition.char = self.blank
+
+ @property
+ def mask(self) -> str:
+ """Property returning the template placeholder mask."""
+ return "".join([char_definition.char for char_definition in self.template])
+
+ @property
+ def empty_mask(self) -> str:
+ """Property returning the template placeholder mask with all non-separators replaced by space."""
+ return "".join(
+ [
+ (
+ " "
+ if (_CharFlags.SEPARATOR not in char_definition.flags)
+ else char_definition.char
+ )
+ for char_definition 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:
+ """Validates value against template."""
+ 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:
+ """Ensure clicking on value does not leave cursor on a separator."""
+ 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; separators are skipped."""
+ self._template.move_cursor(-1)
+
+ def action_cursor_right(self) -> None:
+ """Move the cursor one position to the right; separators are skipped."""
+ 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 next to the previous separator. If no previous
+ separator is found, moves the cursor to the start of the input."""
+ 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 next to the next separator. If no next
+ separator is found, moves the cursor to the end of the input."""
+ 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 next separator or
+ the end of the input."""
+ 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 previous separator or
+ the start of the input."""
+ 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
diff --git a/tests/snapshot_tests/__snapshots__/test_snapshots/test_masked_input.svg b/tests/snapshot_tests/__snapshots__/test_snapshots/test_masked_input.svg
new file mode 100644
index 0000000000..9bfed2123c
--- /dev/null
+++ b/tests/snapshot_tests/__snapshots__/test_snapshots/test_masked_input.svg
@@ -0,0 +1,155 @@
+
diff --git a/tests/snapshot_tests/snapshot_apps/masked_input.py b/tests/snapshot_tests/snapshot_apps/masked_input.py
new file mode 100644
index 0000000000..d5ff2e401e
--- /dev/null
+++ b/tests/snapshot_tests/snapshot_apps/masked_input.py
@@ -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()
\ No newline at end of file
diff --git a/tests/snapshot_tests/test_snapshots.py b/tests/snapshot_tests/test_snapshots.py
index 864f80e3fe..d2bde33df3 100644
--- a/tests/snapshot_tests/test_snapshots.py
+++ b/tests/snapshot_tests/test_snapshots.py
@@ -116,6 +116,15 @@ async def run_before(pilot):
)
+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"])
diff --git a/tests/test_masked_input.py b/tests/test_masked_input.py
new file mode 100644
index 0000000000..698d7f11c5
--- /dev/null
+++ b/tests/test_masked_input.py
@@ -0,0 +1,221 @@
+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
+ input.action_end()
+ 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
+ input.action_home()
+ 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.cursor_position = 0
+ 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(">-