diff --git a/CHANGELOG.md b/CHANGELOG.md
index 9a34988faa..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
@@ -18,6 +19,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
### Fixed
- Input validation of floats no longer accepts NaN (not a number). https://github.com/Textualize/textual/pull/4784
+- Fixed issues with screenshots by simplifying segments only for snapshot tests https://github.com/Textualize/textual/issues/4929
## [0.79.1] - 2024-08-31
@@ -106,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/guide/testing.md b/docs/guide/testing.md
index f22645b604..32a4d33dc7 100644
--- a/docs/guide/testing.md
+++ b/docs/guide/testing.md
@@ -23,9 +23,16 @@ Your test code will help you find bugs early, and alert you if you accidentally
## Testing frameworks for Textual
-Textual doesn't require any particular test framework.
-You can use any test framework you are familiar with, but we will be using [pytest](https://docs.pytest.org/) in this chapter.
+Textual is an async framework powered by Python's [asyncio](https://docs.python.org/3/library/asyncio.html) library.
+While Textual doesn't require a particular test framework, it must provide support for asyncio testing.
+You can use any test framework you are familiar with, but we will be using [pytest](https://docs.pytest.org/)
+along with the [pytest-asyncio](https://pytest-asyncio.readthedocs.io/) plugin in this chapter.
+
+By default, the `pytest-asyncio` plugin requires each async test to be decorated with `@pytest.mark.asyncio`.
+You can avoid having to add this marker to every async test
+by setting `asyncio_mode = auto` in your pytest configuration
+or by running pytest with the `--asyncio-mode=auto` option.
## Testing apps
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/examples/mother.py b/examples/mother.py
new file mode 100644
index 0000000000..0cdbd96b01
--- /dev/null
+++ b/examples/mother.py
@@ -0,0 +1,104 @@
+"""
+A simple example of chatting to an LLM with Textual.
+
+Lots of room for improvement here.
+
+See https://textual.textualize.io/blog/2024/09/15/anatomy-of-a-textual-user-interface/
+
+"""
+
+# /// script
+# requires-python = ">=3.12"
+# dependencies = [
+# "llm",
+# "textual",
+# ]
+# ///
+from textual import on, work
+from textual.app import App, ComposeResult
+from textual.containers import VerticalScroll
+from textual.widgets import Footer, Header, Input, Markdown
+
+try:
+ import llm
+except ImportError:
+ raise ImportError("install the 'llm' package or run with 'uv run mother.py'")
+
+# The system prompt
+SYSTEM = """Formulate all responses as if you where the sentient AI named Mother from the Alien movies."""
+
+
+class Prompt(Markdown):
+ """Markdown for the user prompt."""
+
+
+class Response(Markdown):
+ """Markdown for the reply from the LLM."""
+
+ BORDER_TITLE = "Mother"
+
+
+class MotherApp(App):
+ """Simple app to demonstrate chatting to an LLM."""
+
+ AUTO_FOCUS = "Input"
+
+ CSS = """
+ Prompt {
+ background: $primary 10%;
+ color: $text;
+ margin: 1;
+ margin-right: 8;
+ padding: 1 2 0 2;
+ }
+
+ Response {
+ border: wide $success;
+ background: $success 10%;
+ color: $text;
+ margin: 1;
+ margin-left: 8;
+ padding: 1 2 0 2;
+ }
+ """
+
+ def compose(self) -> ComposeResult:
+ yield Header()
+ with VerticalScroll(id="chat-view"):
+ yield Response("INTERFACE 2037 READY FOR INQUIRY")
+ yield Input(placeholder="How can I help you?")
+ yield Footer()
+
+ def on_mount(self) -> None:
+ """You might want to change the model if you don't have access to it."""
+ self.model = llm.get_model("gpt-4o")
+
+ @on(Input.Submitted)
+ async def on_input(self, event: Input.Submitted) -> None:
+ """When the user hits return."""
+ chat_view = self.query_one("#chat-view")
+ event.input.clear()
+ await chat_view.mount(Prompt(event.value))
+ await chat_view.mount(response := Response())
+ response.anchor()
+ self.send_prompt(event.value, response)
+
+ @work(thread=True)
+ def send_prompt(self, prompt: str, response: Response) -> None:
+ """Get the response in a thread."""
+ response_content = ""
+ llm_response = self.model.prompt(prompt, system=SYSTEM)
+ for chunk in llm_response:
+ response_content += chunk
+ self.call_from_thread(response.update, response_content)
+
+
+if __name__ == "__main__":
+ print(
+ "https://textual.textualize.io/blog/2024/09/15/anatomy-of-a-textual-user-interface/"
+ )
+ print(
+ "You will need an OpenAI API key for this example.\nSee https://help.openai.com/en/articles/4936850-where-do-i-find-my-openai-api-key"
+ )
+ app = MotherApp()
+ app.run()
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/poetry.lock b/poetry.lock
index 4294a0ad21..a8e6709daf 100644
--- a/poetry.lock
+++ b/poetry.lock
@@ -1,4 +1,4 @@
-# This file is automatically @generated by Poetry 1.8.2 and should not be changed by hand.
+# This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand.
[[package]]
name = "aiohappyeyeballs"
@@ -1119,13 +1119,13 @@ pyyaml = ">=5.1"
[[package]]
name = "mkdocs-git-revision-date-localized-plugin"
-version = "1.2.7"
+version = "1.2.8"
description = "Mkdocs plugin that enables displaying the localized date of the last git modification of a markdown file."
optional = false
python-versions = ">=3.8"
files = [
- {file = "mkdocs_git_revision_date_localized_plugin-1.2.7-py3-none-any.whl", hash = "sha256:d2b30ccb74ec8e118298758d75ae4b4f02c620daf776a6c92fcbb58f2b78f19f"},
- {file = "mkdocs_git_revision_date_localized_plugin-1.2.7.tar.gz", hash = "sha256:2f83b52b4dad642751a79465f80394672cbad022129286f40d36b03aebee490f"},
+ {file = "mkdocs_git_revision_date_localized_plugin-1.2.8-py3-none-any.whl", hash = "sha256:c7ec3b1481ca23134269e84927bd8a5dc1aa359c0e515b832dbd5d25019b5748"},
+ {file = "mkdocs_git_revision_date_localized_plugin-1.2.8.tar.gz", hash = "sha256:6e09c308bb27bcf36b211d17b74152ecc2837cdfc351237f70cffc723ef0fd99"},
]
[package.dependencies]
@@ -1134,6 +1134,11 @@ GitPython = "*"
mkdocs = ">=1.0"
pytz = "*"
+[package.extras]
+all = ["GitPython", "babel (>=2.7.0)", "click", "codecov", "mkdocs (>=1.0)", "mkdocs-gen-files", "mkdocs-git-authors-plugin", "mkdocs-material", "mkdocs-static-i18n", "pytest", "pytest-cov", "pytz"]
+base = ["GitPython", "babel (>=2.7.0)", "mkdocs (>=1.0)", "pytz"]
+dev = ["click", "codecov", "mkdocs-gen-files", "mkdocs-git-authors-plugin", "mkdocs-material", "mkdocs-static-i18n", "pytest", "pytest-cov"]
+
[[package]]
name = "mkdocs-material"
version = "9.5.34"
@@ -1300,7 +1305,7 @@ files = [
{file = "msgpack-1.0.8-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:5fbb160554e319f7b22ecf530a80a3ff496d38e8e07ae763b9e82fadfe96f273"},
{file = "msgpack-1.0.8-cp39-cp39-win32.whl", hash = "sha256:f9af38a89b6a5c04b7d18c492c8ccf2aee7048aff1ce8437c4683bb5a1df893d"},
{file = "msgpack-1.0.8-cp39-cp39-win_amd64.whl", hash = "sha256:ed59dd52075f8fc91da6053b12e8c89e37aa043f8986efd89e61fae69dc1b011"},
- {file = "msgpack-1.0.8-py3-none-any.whl", hash = "sha256:24f727df1e20b9876fa6e95f840a2a2651e34c0ad147676356f4bf5fbb0206ca"},
+ {file = "msgpack-1.0.8.tar.gz", hash = "sha256:95c02b0e27e706e48d0e5426d1710ca78e0f0628d6e89d5b5a5b91a5f12274f3"},
]
[[package]]
@@ -1510,19 +1515,19 @@ files = [
[[package]]
name = "platformdirs"
-version = "4.3.1"
+version = "4.3.2"
description = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`."
optional = false
python-versions = ">=3.8"
files = [
- {file = "platformdirs-4.3.1-py3-none-any.whl", hash = "sha256:facaa5a3c57aa1e053e3da7b49e0cc31fe0113ca42a4659d5c2e98e545624afe"},
- {file = "platformdirs-4.3.1.tar.gz", hash = "sha256:63b79589009fa8159973601dd4563143396b35c5f93a58b36f9049ff046949b1"},
+ {file = "platformdirs-4.3.2-py3-none-any.whl", hash = "sha256:eb1c8582560b34ed4ba105009a4badf7f6f85768b30126f351328507b2beb617"},
+ {file = "platformdirs-4.3.2.tar.gz", hash = "sha256:9e5e27a08aa095dd127b9f2e764d74254f482fef22b0970773bfba79d091ab8c"},
]
[package.extras]
-docs = ["furo (>=2023.9.10)", "proselint (>=0.13)", "sphinx (>=7.2.6)", "sphinx-autodoc-typehints (>=1.25.2)"]
-test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=7.4.3)", "pytest-cov (>=4.1)", "pytest-mock (>=3.12)"]
-type = ["mypy (>=1.8)"]
+docs = ["furo (>=2024.8.6)", "proselint (>=0.14)", "sphinx (>=8.0.2)", "sphinx-autodoc-typehints (>=2.4)"]
+test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=8.3.2)", "pytest-cov (>=5)", "pytest-mock (>=3.14)"]
+type = ["mypy (>=1.11.2)"]
[[package]]
name = "pluggy"
@@ -2296,13 +2301,13 @@ zstd = ["zstandard (>=0.18.0)"]
[[package]]
name = "virtualenv"
-version = "20.26.3"
+version = "20.26.4"
description = "Virtual Python Environment builder"
optional = false
python-versions = ">=3.7"
files = [
- {file = "virtualenv-20.26.3-py3-none-any.whl", hash = "sha256:8cc4a31139e796e9a7de2cd5cf2489de1217193116a8fd42328f1bd65f434589"},
- {file = "virtualenv-20.26.3.tar.gz", hash = "sha256:4c43a2a236279d9ea36a0d76f98d84bd6ca94ac4e0f4a3b9d46d05e10fea542a"},
+ {file = "virtualenv-20.26.4-py3-none-any.whl", hash = "sha256:48f2695d9809277003f30776d155615ffc11328e6a0a8c1f0ec80188d7874a55"},
+ {file = "virtualenv-20.26.4.tar.gz", hash = "sha256:c17f4e0f3e6036e9f26700446f85c76ab11df65ff6d8a9cbfad9f71aabfcf23c"},
]
[package.dependencies]
@@ -2363,103 +2368,103 @@ watchmedo = ["PyYAML (>=3.10)"]
[[package]]
name = "yarl"
-version = "1.10.0"
+version = "1.11.0"
description = "Yet another URL library"
optional = false
python-versions = ">=3.8"
files = [
- {file = "yarl-1.10.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:1718c0bca5a61edac7a57dcc11856cb01bde13a9360a3cb6baf384b89cfc0b40"},
- {file = "yarl-1.10.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e4657fd290d556a5f3018d07c7b7deadcb622760c0125277d10a11471c340054"},
- {file = "yarl-1.10.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:044b76d069e69c6b0246f071ebac0576f89c772f806d66ef51e662bd015d03c7"},
- {file = "yarl-1.10.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c5527d32506c11150ca87f33820057dc284e2a01a87f0238555cada247a8b278"},
- {file = "yarl-1.10.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:36d12d78b8b0d46099d413c8689b5510ad9ce5e443363d1c37b6ac5b3d7cbdfb"},
- {file = "yarl-1.10.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:11f7f8a72b3e26c533fa7ffa7a8068f4e3aad7b67c5cf7b17ea8c79fc81d9830"},
- {file = "yarl-1.10.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:88173836a25b7e5dce989eeee3b92d8ef5cdf512830d4155c6212de98e616f70"},
- {file = "yarl-1.10.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c382e189af10070bcb39caa9406b9cc47b26c1d2257979f11fe03a38be09fea9"},
- {file = "yarl-1.10.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:534b8bc181dca1691cf491c263e084af678a8fb6b6181687c788027d8c317026"},
- {file = "yarl-1.10.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:5f3372f9ae1d1f001826b77d0b29d4220e84f6c5f53915e71a825cdd02600065"},
- {file = "yarl-1.10.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:4cca9ba00be4bb8a051c4007b60fc91d6c9728c8b70c86cee4c24be9d641002f"},
- {file = "yarl-1.10.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:a9d8c4be5658834dc688072239d220631ad4b71ff79a5f3d17fb653f16d10759"},
- {file = "yarl-1.10.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:ff45a655ca51e1cb778abbb586083fddb7d896332f47bb3b03bc75e30c25649f"},
- {file = "yarl-1.10.0-cp310-cp310-win32.whl", hash = "sha256:9ef7ce61958b3c7b2e2e0927c52d35cf367c5ee410e06e1337ecc83a90c23b95"},
- {file = "yarl-1.10.0-cp310-cp310-win_amd64.whl", hash = "sha256:48a48261f8d610b0e15fed033e74798763bc2f8f2c0d769a2a0732511af71f1e"},
- {file = "yarl-1.10.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:308d1cce071b5b500e3d95636bbf15dfdb8e87ed081b893555658a7f9869a156"},
- {file = "yarl-1.10.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:bc66927f6362ed613a483c22618f88f014994ccbd0b7a25ec1ebc8c472d4b40a"},
- {file = "yarl-1.10.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c4d13071c5b99974cfe2f94c749ecc4baf882f7c4b6e4c40ca3d15d1b7e81f24"},
- {file = "yarl-1.10.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:348ad53acd41caa489df7db352d620c982ab069855d9635dda73d685bbbc3636"},
- {file = "yarl-1.10.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:293f7c2b30d015de3f1441c4ee764963b86636fde881b4d6093498d1e8711f69"},
- {file = "yarl-1.10.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:315e8853d0ea46aabdce01f1f248fff7b9743de89b555c5f0487f54ac84beae8"},
- {file = "yarl-1.10.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:012c506b2c23be4500fb97509aa7e6a575996fb317b80667fa26899d456e2aaf"},
- {file = "yarl-1.10.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5f769c2708c31227c5349c3e4c668c8b4b2e25af3e7263723f2ef33e8e3906a0"},
- {file = "yarl-1.10.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:4f6ac063a4e9bbd4f6cc88cc621516a44d6aec66862ea8399ba063374e4b12c7"},
- {file = "yarl-1.10.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:18b7ce6d8c35da8e16dcc8de124a80e250fc8c73f8c02663acf2485c874f1972"},
- {file = "yarl-1.10.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:b80246bdee036381636e73ef0f19b032912064622b0e5ee44f6960fd11df12aa"},
- {file = "yarl-1.10.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:183dd37bb5471e8017ab8a998c1ea070b4a0b08a97a7c4e20e0c7ccbe8ebb999"},
- {file = "yarl-1.10.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9b6d0d7522b514f054b359409817af4c5ed76fa4fe42d8bd1ed12956804cf595"},
- {file = "yarl-1.10.0-cp311-cp311-win32.whl", hash = "sha256:6026a6ef14d038a38ca9d81422db4b6bb7d5da94f9d08f21e0ad9ebd9c4bc3bb"},
- {file = "yarl-1.10.0-cp311-cp311-win_amd64.whl", hash = "sha256:190e70d2f9f16f1c9d666c103d635c9ed4bf8de7803e9fa0495eec405a3e96a8"},
- {file = "yarl-1.10.0-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:6bc602c7413e1b5223bc988947125998cb54d6184de45a871985daacc23e6c8c"},
- {file = "yarl-1.10.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:bf733c835ebbd52bd78a52b919205e0f06d8571f71976a0259e5bcc20d0a2f44"},
- {file = "yarl-1.10.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6e91ed5f6818e1e3806eaeb7b14d9e17b90340f23089451ea59a89a29499d760"},
- {file = "yarl-1.10.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:23057a004bc9735008eb2a04b6ce94c6c06219cdf2b193997fd3ae6039eb3196"},
- {file = "yarl-1.10.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2b922c32a1cff62bc43d408d1a8745abeed0a705793f2253c622bf3521922198"},
- {file = "yarl-1.10.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:be199fed28861d72df917e355287ad6835555d8210e7f8203060561f24d7d842"},
- {file = "yarl-1.10.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5cece693380c1c4a606cdcaa0c54eda8f72cfe1ba83f5149b9023bb955e8fa8e"},
- {file = "yarl-1.10.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ff8e803d8ca170e632fb3b4df1bfd29ba29be8edc3e9306c5ffa5fadea234a4f"},
- {file = "yarl-1.10.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:30dde3a8b88c80a4f049eb4dd240d2a02e89174da6be2525541f949bf9fa38ab"},
- {file = "yarl-1.10.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:dff84623e7098cf9bfbb5187f9883051af652b0ce08b9f7084cc8630b87b6457"},
- {file = "yarl-1.10.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:8e69b55965a47dd6c79e578abd7d85637b1bb4a7565436630826bdb28aa9b7ad"},
- {file = "yarl-1.10.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:5d0c9e1dcc92d46ca89608fe4763fc2362f1e81c19a922c67dbc0f20951466e4"},
- {file = "yarl-1.10.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:32e79d5ae975f7c2cc29f7104691fc9be5ee3724f24e1a7254d72f6219672108"},
- {file = "yarl-1.10.0-cp312-cp312-win32.whl", hash = "sha256:762a196612c2aba4197cd271da65fe08308f7ddf130dc63842c7a76d774b6a2c"},
- {file = "yarl-1.10.0-cp312-cp312-win_amd64.whl", hash = "sha256:8c6214071f653d21bb7b43f7ee519afcbf7084263bb43408f4939d14558290db"},
- {file = "yarl-1.10.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:0e0aea8319fdc1ac340236e58b0b7dc763621bce6ce98124a9d58104cafd0aaa"},
- {file = "yarl-1.10.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0b3bf343b4ef9ec600d75363eb9b48ab3bd53b53d4e1c5a9fbf0cfe7ba73a47f"},
- {file = "yarl-1.10.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:05b07e6e0f715eaae9d927a302d9220724392f3c0b4e7f8dfa174bf2e1b8433e"},
- {file = "yarl-1.10.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8d7bd531d7eec4aa7ef8a99fef91962eeea5158a53af0ec507c476ddf8ebc29c"},
- {file = "yarl-1.10.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:183136dc5d5411872e7529c924189a2e26fac5a7f9769cf13ef854d1d653ad36"},
- {file = "yarl-1.10.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c77a3c10af4aaf8891578fe492ef0990c65cf7005dd371f5ea8007b420958bf6"},
- {file = "yarl-1.10.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:030d41d48217b180c5a176e59c49d212d54d89f6f53640fa4c1a1766492aec27"},
- {file = "yarl-1.10.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6f4f43ba30d604ba391bc7fe2dd104d6b87b62b0de4bbde79e362524b8a1eb75"},
- {file = "yarl-1.10.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:637dd0f55d1781d4634c23994101c509e455b5ab61af9086b5763b7eca9359aa"},
- {file = "yarl-1.10.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:99e7459ee86a3b81e57777afd3825b8b1acaac8a99f9c0bd02415d80eb3c371b"},
- {file = "yarl-1.10.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:a80cdb3c15c15b33ecdb080546dcb022789b0084ca66ad41ffa0fe09857fca11"},
- {file = "yarl-1.10.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:1824bfb932d8100e5c94f4f98c078f23ebc6f6fa93acc3d95408762089c54a06"},
- {file = "yarl-1.10.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:90fd64ce00f594db02f603efa502521c440fa1afcf6266be82eb31f19d2d9561"},
- {file = "yarl-1.10.0-cp313-cp313-win32.whl", hash = "sha256:687131ee4d045f3d58128ca28f5047ec902f7760545c39bbe003cc737c5a02b5"},
- {file = "yarl-1.10.0-cp313-cp313-win_amd64.whl", hash = "sha256:493ad061ee025c5ed3a60893cd70204eead1b3f60ccc90682e752f95b845bd46"},
- {file = "yarl-1.10.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:cd65588273d19f8483bc8f32a6fcf602e94a9a7ba287a1725977bd9527cd6c0c"},
- {file = "yarl-1.10.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:6f64f8681671624f539eea5564518bc924524c25eb90ab24a7eddc2d872e668e"},
- {file = "yarl-1.10.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:3576ed2c51f8525d4ff5c3279247aacff9540bb43b292c4a37a8e6c6e1691adb"},
- {file = "yarl-1.10.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca42a9281807fdf8fba86e671d8fdd76f92e9302a6d332957f2bae51c774f8a7"},
- {file = "yarl-1.10.0-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:54a4b5e6a060d46cad6a3cf340f4cb268e6fbc89c589d82a2da58f7db47c47c8"},
- {file = "yarl-1.10.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6eec21d8c3aa932c5a89480b58fa877e9c48092ab838ccc76788cbc917ceec0d"},
- {file = "yarl-1.10.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:273baee8a8af5989d5aab51c740e65bc2b1fc6619b9dd192cd16a3fae51100be"},
- {file = "yarl-1.10.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c1bf63ba496cd4f12d30e916d9a52daa6c91433fedd9cd0d99fef3e13232836f"},
- {file = "yarl-1.10.0-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:f8e24b9a4afdffab399191a9f0b0e80eabc7b7fdb9f2dbccdeb8e4d28e5c57bb"},
- {file = "yarl-1.10.0-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:4c46454fafa31f7241083a0dd21814f63e0fcb4ae49662dc7e286fd6a5160ea1"},
- {file = "yarl-1.10.0-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:beda87b63c08fb4df8cc5353eeefe68efe12aa4f5284958bd1466b14c85e508e"},
- {file = "yarl-1.10.0-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:9a8d6a0e2b5617b5c15c59db25f20ba429f1fea810f2c09fbf93067cb21ab085"},
- {file = "yarl-1.10.0-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:b453b3dbc1ed4c2907632d05b378123f3fb411cad05d8d96de7d95104ef11c70"},
- {file = "yarl-1.10.0-cp38-cp38-win32.whl", hash = "sha256:1ea30675fbf0ad6795c100da677ef6a8960a7db05ac5293f02a23c2230203c89"},
- {file = "yarl-1.10.0-cp38-cp38-win_amd64.whl", hash = "sha256:347011ad09a8f9be3d41fe2d7d611c3a4de4d49aa77bcb9a8c03c7a82fc45248"},
- {file = "yarl-1.10.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:18bc4600eed1907762c1816bb16ac63bc52912e53b5e9a353eb0935a78e95496"},
- {file = "yarl-1.10.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:eeb6a40c5ae2616fd38c1e039c6dd50031bbfbc2acacfd7b70a5d64fafc70901"},
- {file = "yarl-1.10.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:bc544248b5263e1c0f61332ccf35e37404b54213f77ed17457f857f40af51452"},
- {file = "yarl-1.10.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3352c69dc235850d6bf8ddad915931f00dcab208ac4248b9af46175204c2f5f9"},
- {file = "yarl-1.10.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:af5b52bfbbd5eb208cf1afe23c5ada443929e9b9d79e9fbc66cacc07e4e39748"},
- {file = "yarl-1.10.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1eafa7317063de4bc310716cdd9026c13f00b1629e649079a6908c3aafdf5046"},
- {file = "yarl-1.10.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a162cf04fd1e8d81025ec651d14cac4f6e0ca73a3c0a9482de8691b944e3098a"},
- {file = "yarl-1.10.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:179b1df5e9cd99234ea65e63d5bfc6dd524b2c3b6cf68a14b94ccbe01ab37ddd"},
- {file = "yarl-1.10.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:32d2e46848dea122484317485129f080220aa84aeb6a9572ad9015107cebeb07"},
- {file = "yarl-1.10.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:aa1aeb99408be0ca774c5126977eb085fedda6dd7d9198ce4ceb2d06a44325c7"},
- {file = "yarl-1.10.0-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:d2366e2f987f69752f0588d2035321aaf24272693d75f7f6bb7e8a0f48f7ccdd"},
- {file = "yarl-1.10.0-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:e8da33665ecc64cd3e593098adb449f9c65b4e3bc6338e75ad592da15453d898"},
- {file = "yarl-1.10.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:5b46c603bee1f2dd407b8358c2afc9b0472a22ccca528f114e1f4cd30dfecd22"},
- {file = "yarl-1.10.0-cp39-cp39-win32.whl", hash = "sha256:96422a3322b4d954f4c52403a2fc129ad118c151ee60a717847fb46a8480d1e1"},
- {file = "yarl-1.10.0-cp39-cp39-win_amd64.whl", hash = "sha256:52d1ae09b0764017e330bb5bf9af760c0168c564225085bb806f687bccffda8a"},
- {file = "yarl-1.10.0-py3-none-any.whl", hash = "sha256:99eaa7d53f509ba1c2fea8fdfec15ba3cd36caca31d57ec6665073b148b5f260"},
- {file = "yarl-1.10.0.tar.gz", hash = "sha256:3bf10a395adac62177ba8ea738617e8de6cbb1cea6aa5d5dd2accde704fc8195"},
+ {file = "yarl-1.11.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:0a657db1b9982f3dac0e360614d0e8945d2873da6e681fb7fca23ef1c3eb37f8"},
+ {file = "yarl-1.11.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:65a1a05efca52b102691e64db5fcf973030a1c88fee393804ff91f99c95a6e74"},
+ {file = "yarl-1.11.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f4cb417d380e2d77961eecec75aaaf6f7ab14e6de26eb3a498f498029a6556a1"},
+ {file = "yarl-1.11.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8aee7c8378c6aa3103b99d1eb9995268ef730fa9f88ea68b9eee4341e204eec9"},
+ {file = "yarl-1.11.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:84624db40e2358cfd5cf2558b1aaffd93366d27ee32228a97785f2ec87d44a17"},
+ {file = "yarl-1.11.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2a596bb15e036952549871a4ccd2205679902dc7f241e3ced6b2ab2e44c55795"},
+ {file = "yarl-1.11.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c9d4d2cc4b076c8ad0175a15ee9482a387b3303c97d4b71062db7356b2ac04c7"},
+ {file = "yarl-1.11.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:25f8bc849004122591104793a576e9c747b0e5d9486d6a30225521b817255748"},
+ {file = "yarl-1.11.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:e38176a559edde0cfff4b663791a007a5f9f90c73aee1d6f7ddbcf6bfb7287b3"},
+ {file = "yarl-1.11.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:706ac0f77b45e9e0278ec6c98929764e119d3ce3136792b6475e7ae961da53ec"},
+ {file = "yarl-1.11.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:48bac099586cf75ae5837b0ac17a674450d01f451f38afcb02acfc940110b60b"},
+ {file = "yarl-1.11.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:540fd5f62fe21f3d1d9efe8af5c4d9dbbb184ce03ce95acb0289500e46215dd2"},
+ {file = "yarl-1.11.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:05ab59db0bb64e847972373c5cda8924e6605480f6b13cc04573fa0d87bfc637"},
+ {file = "yarl-1.11.0-cp310-cp310-win32.whl", hash = "sha256:ddab47748933ac9cf5f29d6e9e2e2060cff40b2751d02c55129661ea4e577152"},
+ {file = "yarl-1.11.0-cp310-cp310-win_amd64.whl", hash = "sha256:976d02274e6d88b24c7131e7b26a083412b2592f2bbcef53d3b00b2508cad26c"},
+ {file = "yarl-1.11.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:39e3087e1ef70862de81e22af9eb299faee580f41673ef92829949022791b521"},
+ {file = "yarl-1.11.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:7fd535cc41b81a566ad347081b671ab5c7e5f5b6a15526d85b4e748baf065cf0"},
+ {file = "yarl-1.11.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f7cc02d8e9a612174869f4b983f159e87659096f7e2dc1fe9effd9902e408739"},
+ {file = "yarl-1.11.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:30f391ccf4b1b1e0ba4880075ba337d41a619a5350f67053927f67ebe764bf44"},
+ {file = "yarl-1.11.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4c19a0d95943bb2c914b4e71043803be34bc75c08c4a6ca232bdc649a1e9ef1b"},
+ {file = "yarl-1.11.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ead4d89eade0e09b8ef97877664abb0e2e8704787db5564f83658fdee5c36497"},
+ {file = "yarl-1.11.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:195f7791bc23d5f2480efe53f935daf8a61661000dfbfbdd70dbd06397594fff"},
+ {file = "yarl-1.11.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:01a7905e662665ca8e058635377522bc3c98bdb873be761ff42c86eb72b03914"},
+ {file = "yarl-1.11.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:53c80b1927b75aed208d7fd965a3a705dc8c1db4d50b9112418fa0f7784363e6"},
+ {file = "yarl-1.11.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:11af21bbf807688d49b7d4915bb28cbc2e3aa028a2ee194738477eabcc413c65"},
+ {file = "yarl-1.11.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:732d56da00ea7a5da4f0d15adbbd22dcb37da7825510aafde40112e53f6baa52"},
+ {file = "yarl-1.11.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:7bd54d79025b59d1dc5fb26a09734d6a9cc651a04bc381966ed264b28331a168"},
+ {file = "yarl-1.11.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:aacd62ff67efd54cb18cea2aa7ae4fb83cfbca19a07055d4777266b70561defe"},
+ {file = "yarl-1.11.0-cp311-cp311-win32.whl", hash = "sha256:68e14ae71e5b51c8282ae5db53ccb3baffc40e1551370a8a2361f1c1d8a0bf8c"},
+ {file = "yarl-1.11.0-cp311-cp311-win_amd64.whl", hash = "sha256:3ade2265716667b6bd4123d6f684b5f7cf4a8d83dcf1d5581ac44643466bb00a"},
+ {file = "yarl-1.11.0-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:6e73dab98e3c3b5441720153e72a5f28e717aac2d22f1ec4b08ef33417d9987e"},
+ {file = "yarl-1.11.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:4a0d090d296ced05edfe29c6ff34869412fa6a97d0928c12b00939c4842884cd"},
+ {file = "yarl-1.11.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d29e446cfb0a82d3df7745968b9fa286665a9be8b4d68de46bcc32d917cb218e"},
+ {file = "yarl-1.11.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4c8dc0efcf8266ecfe057b95e01f43eb62516196a4bbf3918fd1dcb8d0dc0dff"},
+ {file = "yarl-1.11.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:202f5ec49ff163dcc767426deb55020a28078e61d6bbe1f80331d92bca53b236"},
+ {file = "yarl-1.11.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8055b0d78ce1cafa657c4b455e22661e8d3b2834de66a0753c3567da47fcc4aa"},
+ {file = "yarl-1.11.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:60ed3c7f64e820959d7f682ec2f559b4f4df723dc09df619d269853a4214a4b4"},
+ {file = "yarl-1.11.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2371510367d39d74997acfdcd1dead17938c79c99365482821627f7838a8eba0"},
+ {file = "yarl-1.11.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e24bb6a8be89ccc3ce8c47e8940fdfcb7429e9efbf65ce6fa3e7d122fcf0bcf0"},
+ {file = "yarl-1.11.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:18ec42da256cfcb9b4cd5d253e04c291f69911a5228d1438a7d431c15ba0ae40"},
+ {file = "yarl-1.11.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:418eeb8f228ea36c368bf6782ebd6016ecebfb1a8b90145ef6726ffcbba65ef8"},
+ {file = "yarl-1.11.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:07e8cfb1dd7669a129f8fd5df1da65efa73aea77582bde2a3a837412e2863543"},
+ {file = "yarl-1.11.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3c458483711d393dad51340505c3fab3194748fd06bab311d2f8b5b7a7349e9a"},
+ {file = "yarl-1.11.0-cp312-cp312-win32.whl", hash = "sha256:5b008c3127382503e7a1e12b4c3a3236e3dd833a4c62a066f4a0fbd650c655d2"},
+ {file = "yarl-1.11.0-cp312-cp312-win_amd64.whl", hash = "sha256:bc94be7472b9f88d7441340534a3ecae05c86ccfec7ba75ce5b6e4778b2bfc6e"},
+ {file = "yarl-1.11.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:a45e51ba3777031e0b20c1e7ab59114ed4e1884b3c1db48962c1d8d08aefb418"},
+ {file = "yarl-1.11.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:765128029218eade3a01187cdd7f375977cc827505ed31828196c8ae9b622928"},
+ {file = "yarl-1.11.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2516e238daf0339c8ac4dfab9d7cda9afad652ff073517f200d653d5d8371f7e"},
+ {file = "yarl-1.11.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d10be62bee117f05b1ad75a6c2538ca9e5367342dc8a4f3c206c87dadbc1189c"},
+ {file = "yarl-1.11.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:50ceaeda771ee3e382291168c90c7ede62b63ecf3e181024bcfeb35c0ea6c84f"},
+ {file = "yarl-1.11.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3a601c99fc20fd0eea84e7bc0dc9e7f196f55a0ded67242d724988c754295538"},
+ {file = "yarl-1.11.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:42ff79371614764fc0a4ab8eaba9adb493bf9ad856e2a4664f6c754fc907a903"},
+ {file = "yarl-1.11.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:93fca4c9f88c17ead902b3f3285b2d039fc8f26d117e1441973ba64315109b54"},
+ {file = "yarl-1.11.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e7dddf5f41395c84fc59e0ed5493b24bfeb39fb04823e880b52c8c55085d4695"},
+ {file = "yarl-1.11.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ea501ea07e14ba6364ff2621bfc8b2381e5b1e10353927fa9a607057fd2b98e5"},
+ {file = "yarl-1.11.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:a4f7e470f2c9c8b8774a5bda72adfb8e9dc4ec32311fe9bdaa4921e36cf6659b"},
+ {file = "yarl-1.11.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:361fdb3993431157302b7104d525092b5df4d7d346df5a5ffeee2d1ca8e0d15b"},
+ {file = "yarl-1.11.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e300eaf5e0329ad31b3d53e2f3d26b4b6dff1217207c6ab1d4212967b54b2185"},
+ {file = "yarl-1.11.0-cp313-cp313-win32.whl", hash = "sha256:f1e2d4ce72e06e38a16da3e9c24a0520dbc19018a69ef6ed57b6b38527cb275c"},
+ {file = "yarl-1.11.0-cp313-cp313-win_amd64.whl", hash = "sha256:fa9de2f87be58f714a230bd1f3ef3aad1ed65c9931146e3fc55f85fcbe6bacc3"},
+ {file = "yarl-1.11.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:24da0b38274727fe9266d09229987e7f0efdb97beb94c0bb2d327d65f112e78d"},
+ {file = "yarl-1.11.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:0310eb2e63872de66047e05ad9982f2e53ad6405dc42fa60d7cc670bf6ca8aa8"},
+ {file = "yarl-1.11.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:52433604340a4ab3d1f32281c6eb9ad9b47c99435b4212f763121bf7348c8c00"},
+ {file = "yarl-1.11.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:98e2eb182d59f0845a79434003f94b4f61cd69465248f9388c2e5bf2191c9f7f"},
+ {file = "yarl-1.11.0-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b3dd10f0fe0e0f659926c1da791de5bef05fd48974ad74618c9168e302e2b7cc"},
+ {file = "yarl-1.11.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:121d3798e4bb35a4321b2422cb887f80ea39f94bf52f0eb5cb2c168bb0043c9b"},
+ {file = "yarl-1.11.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b8bbac56c80610dd659ace534765d7bcd2488f6600023f6984f35108b2b3f4f0"},
+ {file = "yarl-1.11.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:79d420399f0e82e302236a762d8b8ceec89761ce3b30c83ac1d4d6e29f811444"},
+ {file = "yarl-1.11.0-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:03a726fb50588307dfe1d233b67535d493fb0bb157bdbfda6bb34e04189f2f57"},
+ {file = "yarl-1.11.0-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:9057f5de2fade7440e6db358913bc7ae8de43ba72c83cf95420a1fc1a6c6b59e"},
+ {file = "yarl-1.11.0-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:6471d747d0ac8059895e66d32ca8630c8db5b572ca7763150d0927eaa257df67"},
+ {file = "yarl-1.11.0-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:d97cb22ad380850754fa16ef8d490d9340d8573d81f73429f3975e8e87db0586"},
+ {file = "yarl-1.11.0-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:fe78dec8caeda1e7b353cbd8aa0cc5a5bc182b22998d64ec8fa9ee59c898ab3b"},
+ {file = "yarl-1.11.0-cp38-cp38-win32.whl", hash = "sha256:7ff371002fbbb79613269d76a2932c99979dac15fac30107064ef70d25f35474"},
+ {file = "yarl-1.11.0-cp38-cp38-win_amd64.whl", hash = "sha256:4fa9d762eee63eed767895d68b994c58e29f809292a4d0fca483e9cc6fdc22c8"},
+ {file = "yarl-1.11.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:4ae63bc65e5bf8843bd1eca46e75eaa9eb157e0312fb362123181512892daad8"},
+ {file = "yarl-1.11.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:3d1bd3262e00043907e0a6d7d4f7b7a4815281acc25699a2384552870c79f1f0"},
+ {file = "yarl-1.11.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:0c58656c2e0b41b5d325130b8da4f8e216aad10029e7de5c523a6be25faa9fe8"},
+ {file = "yarl-1.11.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9425c333575fce5e0fb414b766492c6ba4aa335ef910a7540dbdefe58a78232e"},
+ {file = "yarl-1.11.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9dc66e2420e1e282105071934883bbb9c37c16901b5b8aa0a8aee370b477eac6"},
+ {file = "yarl-1.11.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2949067359d1ef5bf3228c7f1deb102c209832a13df5419239f99449bc1d3fa9"},
+ {file = "yarl-1.11.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c006fe73f851cf20b9986b3b4cc15239795bd5da9c3fda76bb3e043da5bec4ff"},
+ {file = "yarl-1.11.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:969ad4ee3892e893471b6572bbf2bbb091f93e7c81de25d6b3a5c0a5126e5ccb"},
+ {file = "yarl-1.11.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:c9fbe9dc6ee8bfe1af34137e3add6f0e49799dd5467dd6af189d27616879161e"},
+ {file = "yarl-1.11.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:69a45c711fea9b783b592a75f26f6dc59b2e4a923b97bf6eec357566fcb1d922"},
+ {file = "yarl-1.11.0-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:1a29b82c42a7791ffe53ee6dfbf29acc61ea7ec05643dcacc50510ed6187b897"},
+ {file = "yarl-1.11.0-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:ed0c090f00c3fc024f7b0799cab9dd7c419fcd8f1a00634d1f9952bab7e7bfb2"},
+ {file = "yarl-1.11.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:31df9d9b3fe6e15decee629fc7976a5fb21eaa39e290f60e57e1d422827194c6"},
+ {file = "yarl-1.11.0-cp39-cp39-win32.whl", hash = "sha256:fcb7c36ba8b663a5900e6d40533f0e698ba0f38f744aad5410d4e38129e41a70"},
+ {file = "yarl-1.11.0-cp39-cp39-win_amd64.whl", hash = "sha256:c6c0d640bad721834a737e25267fb71d296684ada21ca7d5ad2e63da7b73f1b7"},
+ {file = "yarl-1.11.0-py3-none-any.whl", hash = "sha256:03717a6627e55934b2a1d9caf24f299b461a2e8d048a90920f42ad5c20ae1b82"},
+ {file = "yarl-1.11.0.tar.gz", hash = "sha256:f86f4f4a57a29ef08fa70c4667d04c5e3ba513500da95586208b285437cb9592"},
]
[package.dependencies]
@@ -2491,4 +2496,4 @@ syntax = ["tree-sitter", "tree-sitter-languages"]
[metadata]
lock-version = "2.0"
python-versions = "^3.8.1"
-content-hash = "a334bde26213e1cae0a4be69857cbbc17529058b67db2201d8bf1ca2e65dd855"
+content-hash = "1271ee856073da0649fdb432170dc77787d906b0cb3dc5575d802ba604bbad2e"
diff --git a/pyproject.toml b/pyproject.toml
index 71a51de9c9..a13cdc868a 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -47,7 +47,7 @@ rich = ">=13.3.3"
typing-extensions = "^4.4.0"
tree-sitter = { version = "^0.20.1", optional = true }
tree-sitter-languages = { version = "1.10.2", optional = true }
-platformdirs = "^4.2.2"
+platformdirs = ">=3.6.0,<5"
[tool.poetry.extras]
syntax = ["tree-sitter", "tree_sitter_languages"]
diff --git a/src/textual/_doc.py b/src/textual/_doc.py
index 54aa7008be..c51772d160 100644
--- a/src/textual/_doc.py
+++ b/src/textual/_doc.py
@@ -42,6 +42,7 @@ def format_svg(source, language, css_class, options, md, attrs, **kwargs) -> str
title=title,
terminal_size=(columns, rows),
wait_for_animation=False,
+ simplify=False,
)
finally:
os.chdir(cwd)
@@ -65,6 +66,7 @@ def take_svg_screenshot(
terminal_size: tuple[int, int] = (80, 24),
run_before: Callable[[Pilot], Awaitable[None] | None] | None = None,
wait_for_animation: bool = True,
+ simplify=True,
) -> str:
"""
@@ -79,6 +81,7 @@ def take_svg_screenshot(
screenshot. Use this to simulate complex user interactions with the app
that cannot be simulated by key presses.
wait_for_animation: Wait for animation to complete before taking screenshot.
+ simplify: Simplify the segments by combining contiguous segments with the same style.
Returns:
An SVG string, showing the content of the terminal window at the time
@@ -129,7 +132,7 @@ async def auto_pilot(pilot: Pilot) -> None:
await pilot.pause()
await pilot.pause()
await pilot.wait_for_scheduled_animations()
- svg = app.export_screenshot(title=title)
+ svg = app.export_screenshot(title=title, simplify=simplify)
app.exit(svg)
diff --git a/src/textual/app.py b/src/textual/app.py
index 7b2675437d..24068d462e 100644
--- a/src/textual/app.py
+++ b/src/textual/app.py
@@ -1404,7 +1404,12 @@ def action_screenshot(
"""
self.deliver_screenshot(filename, path)
- def export_screenshot(self, *, title: str | None = None) -> str:
+ def export_screenshot(
+ self,
+ *,
+ title: str | None = None,
+ simplify: bool = False,
+ ) -> str:
"""Export an SVG screenshot of the current screen.
See also [save_screenshot][textual.app.App.save_screenshot] which writes the screenshot to a file.
@@ -1412,6 +1417,7 @@ def export_screenshot(self, *, title: str | None = None) -> str:
Args:
title: The title of the exported screenshot or None
to use app title.
+ simplify: Simplify the segments by combining contiguous segments with the same style.
"""
assert self._driver is not None, "App must be running"
width, height = self.size
@@ -1427,7 +1433,7 @@ def export_screenshot(self, *, title: str | None = None) -> str:
safe_box=False,
)
screen_render = self.screen._compositor.render_update(
- full=True, screen_stack=self.app._background_screens, simplify=True
+ full=True, screen_stack=self.app._background_screens, simplify=simplify
)
console.print(screen_render)
return console.export_svg(title=title or self.title)
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 f0d1fdc9b1..dea18c8423 100644
--- a/tests/snapshot_tests/test_snapshots.py
+++ b/tests/snapshot_tests/test_snapshots.py
@@ -117,6 +117,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(">-