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 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + TemplateApp + + + + + + + + + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ +ABC01-DE___-_____-_____ +▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ +▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ +YYYY-MM-DD +▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + + + + + + + + + + + + + + + + + + + + + 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(">-