Skip to content

Commit c73016f

Browse files
committed
Merge branch 'main' into app-focus-style
2 parents 2cd38d1 + 2429c30 commit c73016f

File tree

17 files changed

+1487
-114
lines changed

17 files changed

+1487
-114
lines changed

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
99

1010
### Added
1111

12+
- Added `MaskedInput` widget https://github.com/Textualize/textual/pull/4783
1213
- Input validation for floats and integers accept embedded underscores, e.g., "1_234_567" is valid. https://github.com/Textualize/textual/pull/4784
1314

1415
### Changed
@@ -18,6 +19,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
1819
### Fixed
1920

2021
- Input validation of floats no longer accepts NaN (not a number). https://github.com/Textualize/textual/pull/4784
22+
- Fixed issues with screenshots by simplifying segments only for snapshot tests https://github.com/Textualize/textual/issues/4929
2123

2224
## [0.79.1] - 2024-08-31
2325

@@ -106,6 +108,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
106108

107109
- Fixed issue with Enter events causing unresponsive UI https://github.com/Textualize/textual/pull/4833
108110

111+
109112
## [0.75.0] - 2024-08-01
110113

111114
### Added
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
from textual.app import App, ComposeResult
2+
from textual.widgets import Label, MaskedInput
3+
4+
5+
class MaskedInputApp(App):
6+
# (1)!
7+
CSS = """
8+
MaskedInput.-valid {
9+
border: tall $success 60%;
10+
}
11+
MaskedInput.-valid:focus {
12+
border: tall $success;
13+
}
14+
MaskedInput {
15+
margin: 1 1;
16+
}
17+
Label {
18+
margin: 1 2;
19+
}
20+
"""
21+
22+
def compose(self) -> ComposeResult:
23+
yield Label("Enter a valid credit card number.")
24+
yield MaskedInput(
25+
template="9999-9999-9999-9999;0", # (2)!
26+
)
27+
28+
29+
app = MaskedInputApp()
30+
31+
if __name__ == "__main__":
32+
app.run()

docs/guide/testing.md

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,9 +23,16 @@ Your test code will help you find bugs early, and alert you if you accidentally
2323

2424
## Testing frameworks for Textual
2525

26-
Textual doesn't require any particular test framework.
27-
You can use any test framework you are familiar with, but we will be using [pytest](https://docs.pytest.org/) in this chapter.
26+
Textual is an async framework powered by Python's [asyncio](https://docs.python.org/3/library/asyncio.html) library.
27+
While Textual doesn't require a particular test framework, it must provide support for asyncio testing.
2828

29+
You can use any test framework you are familiar with, but we will be using [pytest](https://docs.pytest.org/)
30+
along with the [pytest-asyncio](https://pytest-asyncio.readthedocs.io/) plugin in this chapter.
31+
32+
By default, the `pytest-asyncio` plugin requires each async test to be decorated with `@pytest.mark.asyncio`.
33+
You can avoid having to add this marker to every async test
34+
by setting `asyncio_mode = auto` in your pytest configuration
35+
or by running pytest with the `--asyncio-mode=auto` option.
2936

3037
## Testing apps
3138

docs/widget_gallery.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,16 @@ Display a markdown document.
168168
```{.textual path="docs/examples/widgets/markdown.py"}
169169
```
170170

171+
## MaskedInput
172+
173+
A control to enter input according to a template mask.
174+
175+
[MaskedInput reference](./widgets/masked_input.md){ .md-button .md-button--primary }
176+
177+
178+
```{.textual path="docs/examples/widgets/masked_input.py"}
179+
```
180+
171181
## OptionList
172182

173183
Display a vertical list of options (options may be Rich renderables).

docs/widgets/masked_input.md

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
# MaskedInput
2+
3+
!!! tip "Added in version 0.80.0"
4+
5+
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]*.
6+
7+
- [x] Focusable
8+
- [ ] Container
9+
10+
## Example
11+
12+
The example below shows a masked input to ease entering a credit card number.
13+
14+
=== "Output"
15+
16+
```{.textual path="docs/examples/widgets/masked_input.py"}
17+
```
18+
19+
=== "checkbox.py"
20+
21+
```python
22+
--8<-- "docs/examples/widgets/masked_input.py"
23+
```
24+
25+
## Reactive Attributes
26+
27+
| Name | Type | Default | Description |
28+
| ---------- | ----- | ------- | ------------------------- |
29+
| `template` | `str` | `""` | The template mask string. |
30+
31+
### The template string format
32+
33+
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:
34+
35+
| Mask character | Regular expression | Required? |
36+
| -------------- | ------------------ | --------- |
37+
| `A` | `[A-Za-z]` | Yes |
38+
| `a` | `[A-Za-z]` | No |
39+
| `N` | `[A-Za-z0-9]` | Yes |
40+
| `n` | `[A-Za-z0-9]` | No |
41+
| `X` | `[^ ]` | Yes |
42+
| `x` | `[^ ]` | No |
43+
| `9` | `[0-9]` | Yes |
44+
| `0` | `[0-9]` | No |
45+
| `D` | `[1-9]` | Yes |
46+
| `d` | `[1-9]` | No |
47+
| `#` | `[0-9+\-]` | No |
48+
| `H` | `[A-Fa-f0-9]` | Yes |
49+
| `h` | `[A-Fa-f0-9]` | No |
50+
| `B` | `[0-1]` | Yes |
51+
| `b` | `[0-1]` | No |
52+
53+
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.
54+
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.
55+
56+
## Messages
57+
58+
- [MaskedInput.Changed][textual.widgets.MaskedInput.Changed]
59+
- [MaskedInput.Submitted][textual.widgets.MaskedInput.Submitted]
60+
61+
## Bindings
62+
63+
The masked input widget defines the following bindings:
64+
65+
::: textual.widgets.MaskedInput.BINDINGS
66+
options:
67+
show_root_heading: false
68+
show_root_toc_entry: false
69+
70+
## Component Classes
71+
72+
The masked input widget provides the following component classes:
73+
74+
::: textual.widgets.MaskedInput.COMPONENT_CLASSES
75+
options:
76+
show_root_heading: false
77+
show_root_toc_entry: false
78+
79+
---
80+
81+
82+
::: textual.widgets.MaskedInput
83+
options:
84+
heading_level: 2

examples/mother.py

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
"""
2+
A simple example of chatting to an LLM with Textual.
3+
4+
Lots of room for improvement here.
5+
6+
See https://textual.textualize.io/blog/2024/09/15/anatomy-of-a-textual-user-interface/
7+
8+
"""
9+
10+
# /// script
11+
# requires-python = ">=3.12"
12+
# dependencies = [
13+
# "llm",
14+
# "textual",
15+
# ]
16+
# ///
17+
from textual import on, work
18+
from textual.app import App, ComposeResult
19+
from textual.containers import VerticalScroll
20+
from textual.widgets import Footer, Header, Input, Markdown
21+
22+
try:
23+
import llm
24+
except ImportError:
25+
raise ImportError("install the 'llm' package or run with 'uv run mother.py'")
26+
27+
# The system prompt
28+
SYSTEM = """Formulate all responses as if you where the sentient AI named Mother from the Alien movies."""
29+
30+
31+
class Prompt(Markdown):
32+
"""Markdown for the user prompt."""
33+
34+
35+
class Response(Markdown):
36+
"""Markdown for the reply from the LLM."""
37+
38+
BORDER_TITLE = "Mother"
39+
40+
41+
class MotherApp(App):
42+
"""Simple app to demonstrate chatting to an LLM."""
43+
44+
AUTO_FOCUS = "Input"
45+
46+
CSS = """
47+
Prompt {
48+
background: $primary 10%;
49+
color: $text;
50+
margin: 1;
51+
margin-right: 8;
52+
padding: 1 2 0 2;
53+
}
54+
55+
Response {
56+
border: wide $success;
57+
background: $success 10%;
58+
color: $text;
59+
margin: 1;
60+
margin-left: 8;
61+
padding: 1 2 0 2;
62+
}
63+
"""
64+
65+
def compose(self) -> ComposeResult:
66+
yield Header()
67+
with VerticalScroll(id="chat-view"):
68+
yield Response("INTERFACE 2037 READY FOR INQUIRY")
69+
yield Input(placeholder="How can I help you?")
70+
yield Footer()
71+
72+
def on_mount(self) -> None:
73+
"""You might want to change the model if you don't have access to it."""
74+
self.model = llm.get_model("gpt-4o")
75+
76+
@on(Input.Submitted)
77+
async def on_input(self, event: Input.Submitted) -> None:
78+
"""When the user hits return."""
79+
chat_view = self.query_one("#chat-view")
80+
event.input.clear()
81+
await chat_view.mount(Prompt(event.value))
82+
await chat_view.mount(response := Response())
83+
response.anchor()
84+
self.send_prompt(event.value, response)
85+
86+
@work(thread=True)
87+
def send_prompt(self, prompt: str, response: Response) -> None:
88+
"""Get the response in a thread."""
89+
response_content = ""
90+
llm_response = self.model.prompt(prompt, system=SYSTEM)
91+
for chunk in llm_response:
92+
response_content += chunk
93+
self.call_from_thread(response.update, response_content)
94+
95+
96+
if __name__ == "__main__":
97+
print(
98+
"https://textual.textualize.io/blog/2024/09/15/anatomy-of-a-textual-user-interface/"
99+
)
100+
print(
101+
"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"
102+
)
103+
app = MotherApp()
104+
app.run()

mkdocs-nav.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,7 @@ nav:
157157
- "widgets/log.md"
158158
- "widgets/markdown_viewer.md"
159159
- "widgets/markdown.md"
160+
- "widgets/masked_input.md"
160161
- "widgets/option_list.md"
161162
- "widgets/placeholder.md"
162163
- "widgets/pretty.md"

0 commit comments

Comments
 (0)