Textualize / textual

The lean application framework for Python. Build sophisticated user interfaces with a simple Python API. Run your apps in the terminal and a web browser.
https://textual.textualize.io/
MIT License
24.09k stars 742 forks source link

Input templates #4591

Open amottola opened 1 month ago

amottola commented 1 month ago

Issue: https://github.com/Textualize/textual/issues/4581. Sorry, couldn't wait for proper discussion, but of course this is open to changes. Please be gentle, this is my first pull request :blush:

Please review the following checklist.

willmcgugan commented 1 month ago

Looks like a lot of work! Could you summarize the changes, and maybe add screenshots / video?

amottola commented 1 month ago

In input.py I've added a _Template class that does most of the heavy lifting. As specified in the docs, a template mask assigns for each character to be allowed in the input field a regular expression based on the category of the mask character. See the docs updated by this PR (or Qt input masks since my implementation tries to follow the same rules, with the only exception being [ ] { } are not implemented - which should not be a problem since they are reserved in the Qt implementation anyway) for details on mask characters; for example N corresponds to the [0-9a-zA-Z] regex, meaning that if N is the 3rd character in the template, when cursor_position is 2 only an user input that matches that regex is allowed. We also have meta characters that allow for forced case conversions and to specify the blank character (again see the docs). The blank character is what appears - using the placeholder style - where a character can be entered by the user, unless `placeholder´ is specified - in such case the placeholder has priority and is displayed in place of the "empty" character. All characters in the template that are not mask or meta characters are considered separators: they are automatically inserted when the user reaches them while typing, and are automatically removed when the user deletes all the chars next to them. I think seeing this in action clarifies a lot of things... I've added some test cases and a snapshot test FWIW.

For an example, here you go:

from textual.app import App, ComposeResult
from textual.widgets import Input

class TemplateApp(App[None]):
    def compose(self) -> ComposeResult:
        yield Input(template=">NNNNN-NNNNN-NNNNN-NNNNN;_")
        yield Input(template="9999-99-99", placeholder="YYYY-MM-DD")

if __name__ == "__main__":
    app = TemplateApp()
    app.run()
Screenshot 2024-06-03 alle 19 19 28

The first thing to note is that the template also forces the maximum input size. As you can see, the placeholder for the first Input uses _ as it is specified as blank character (the default if unspecified is space). The second Input has an explicit placeholder that overrides any template blank character; if placeholder is smaller than the template mask, the blank character is used to fill missing entries.

Screenshot 2024-06-03 alle 19 27 57

Here we have entered ABCDE in the first Input, and since the next expected character is a separator, it is added automatically and the cursor advances next to it. Note that the template adds an implicit Validator and since the template specifies all N mask chars (which specifies a char matching regex [0-9a-zA-Z] is required), the input is not considered valid. In the second Input below a full date was inserted (by typing 2,0,2,4,1,2,3,1 - separators are automatically inserted) as since this satisfies all of the chars in the template, input is considered valid.

I don't know of a tool specifically tailored to make videos of a terminal; if you point me to one, I'd be more than happy to make a video that is worth more than thousand words.

amottola commented 1 month ago

I've just reworked docs a bit to be more clear (I hope!) with an example of using templates to input a credit card number. Just run make docs-serve-offline and navigate to the Input widget docs.

darrenburns commented 3 weeks ago

@amottola I've extracted the big if-elif block into a dictionary lookup, and added some missing type hints. Also added an xfail test for a case where "cursor word right" is failing.

amottola commented 3 weeks ago

Nice catch about converting that big if-elif block to dict lookup. See my comment above about the failing test for action_cursor_right_word() and how I fixed it

amottola commented 3 weeks ago

There's another issue that worries me: currently all Input attributes are compatible with the "template mode" enabled by setting the template, except for max_length; the template completely overrides it and makes it completely dependent on the template mask. How should we deal with this discrepancy? Should we use a watcher and raise an exception if max_length is changed while in template mode? Another possibility is just explicitly mentioning in the docs the fact that such an attribute will not be respected while in template mode. Suggestions are welcome!

amottola commented 3 weeks ago

@darrenburns I've added a validate_value() to ensure if a value is set and it does not match current template (if set), an exception is raised. Not sure about this last change though as makes we wonder why the same approach is not used for the restrict parameter...

darrenburns commented 2 weeks ago

I also think there might be a class with "suggesters" as they change the background/dim text if there's a matching suggestion.

It's making me wonder if this functionality could live as a subclass of Input which exposes only the compatible constructor parameters/methods.

amottola commented 2 weeks ago

That's a thing that started to tick in the back of my head while creating this whole PR... So I propose to open up a second PR with a refactor that leaves Input alone and introduces a new class derived by it. Since template here is an abused term, I propose to name the new class MaskedInput, having the template parameter replaced with a mask one. We can then compare both PRs and choose the most appropriate one (but I suspect the second one will fit best). What do you think of this approach?

darrenburns commented 2 weeks ago

That's a thing that started to tick in the back of my head while creating this whole PR... So I propose to open up a second PR with a refactor that leaves Input alone and introduces a new class derived by it. Since template here is an abused term, I propose to name the new class MaskedInput, having the template parameter replaced with a mask one. We can then compare both PRs and choose the most appropriate one (but I suspect the second one will fit best). What do you think of this approach?

I think that makes sense. @willmcgugan given how this interferes a bit with some of the existing functionality, do you think a more specialised MaskedInput subclass widget makes sense here?