mirror of
https://github.com/Textualize/textual.git
synced 2025-10-17 02:38:12 +03:00
dictionary example
This commit is contained in:
29
docs/examples/events/dictionary.css
Normal file
29
docs/examples/events/dictionary.css
Normal file
@@ -0,0 +1,29 @@
|
||||
Screen {
|
||||
background: $panel;
|
||||
}
|
||||
|
||||
TextInput {
|
||||
dock: top;
|
||||
border: tall $background;
|
||||
width: 100%;
|
||||
height: 1;
|
||||
padding: 0 1;
|
||||
margin: 1 1 0 1;
|
||||
background: $boost;
|
||||
}
|
||||
|
||||
TextInput:focus {
|
||||
border: tall $accent;
|
||||
}
|
||||
|
||||
#results {
|
||||
width: auto;
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
#results-container {
|
||||
background: $background 50%;
|
||||
overflow: auto;
|
||||
margin: 1 2;
|
||||
height: 100%;
|
||||
}
|
||||
43
docs/examples/events/dictionary.py
Normal file
43
docs/examples/events/dictionary.py
Normal file
@@ -0,0 +1,43 @@
|
||||
import asyncio
|
||||
|
||||
try:
|
||||
import httpx
|
||||
except ImportError:
|
||||
raise ImportError("Please install http with 'pip install httpx' ")
|
||||
|
||||
from rich.json import JSON
|
||||
|
||||
from textual.app import App, ComposeResult
|
||||
from textual.layout import Vertical
|
||||
from textual.widgets import Static, TextInput
|
||||
|
||||
|
||||
class DictionaryApp(App):
|
||||
"""Searches ab dictionary API as-you-type."""
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
yield TextInput(placeholder="Search for a word")
|
||||
yield Vertical(Static(id="results", fluid=False), id="results-container")
|
||||
|
||||
async def on_text_input_changed(self, message: TextInput.Changed) -> None:
|
||||
"""A coroutine to handle a text changed message."""
|
||||
if message.value:
|
||||
# Look up the word in the background
|
||||
asyncio.create_task(self.lookup_word(message.value))
|
||||
else:
|
||||
# Clear the results
|
||||
self.query_one("#results", Static).update()
|
||||
|
||||
async def lookup_word(self, word: str) -> None:
|
||||
"""Looks up a word."""
|
||||
url = f"https://api.dictionaryapi.dev/api/v2/entries/en/{word}"
|
||||
async with httpx.AsyncClient() as client:
|
||||
results = (await client.get(url)).text
|
||||
|
||||
if word == self.query_one(TextInput).value:
|
||||
self.query_one("#results", Static).update(JSON(results))
|
||||
|
||||
|
||||
app = DictionaryApp(css_path="dictionary.css")
|
||||
if __name__ == "__main__":
|
||||
app.run()
|
||||
@@ -110,9 +110,24 @@ The message class is defined within the widget class itself. This is not strictl
|
||||
- If reduces the amount of imports. If you were to import ColorButton, you have access to the message class via `ColorButton.Selected`.
|
||||
- It creates a namespace for the handler. So rather than `on_selected`, the handler name becomes `on_color_button_selected`. This makes it less likely that your chosen name will clash with another message.
|
||||
|
||||
|
||||
## Sending events
|
||||
|
||||
In the previous example we used [emit()][textual.message_pump.MessagePump.emit] to send an event to it's parent. We could also have used [emit_no_wait()][textual.message_pump.MessagePump.emit_no_wait] for non async code. Sending messages in this way allows you to write custom widgets without needing to know in what context they will be used.
|
||||
|
||||
There are other ways of sending (posting) messages, which you may need to use less frequently.
|
||||
|
||||
- [post_message][textual.message_pump.MessagePump.post_message] To post a message to a particular event.
|
||||
- [post_message_no_wait][textual.message_pump.MessagePump.post_message_no_wait] The non-async version of `post_message`.
|
||||
|
||||
|
||||
## Message handlers
|
||||
|
||||
Most of the logic in a Textual app will be written in message handlers. Let's explore handlers in more detail.
|
||||
|
||||
### Handler naming
|
||||
|
||||
Let's recap on the scheme that Textual uses to map messages classes on to a Python method name.
|
||||
Textual uses the following scheme to map messages classes on to a Python method.
|
||||
|
||||
- Start with `"on_"`.
|
||||
- Add the messages namespace (if any) converted from CamelCase to snake_case plus an underscore `"_"`
|
||||
@@ -122,12 +137,56 @@ Let's recap on the scheme that Textual uses to map messages classes on to a Pyth
|
||||
--8<-- "docs/images/events/naming.excalidraw.svg"
|
||||
</div>
|
||||
|
||||
### Sending events
|
||||
### Handler arguments
|
||||
|
||||
In the previous example we used [emit()][textual.message_pump.MessagePump.emit] to send an event to it's parent. We could also have used [emit_no_wait()][textual.message_pump.MessagePump.emit_no_wait] for non async code. Sending messages in this way allows you to write custom widgets without needing to know in what context they will be used.
|
||||
Message handler methods can be written with or without a positional argument. If you add a positional argument, Textual will call the handler with the event object. The following handler (taken from custom01.py above) contains a `message` parameter. The body of the code makes use of the message to set a preset color.
|
||||
|
||||
There are other ways of sending (posting) messages, which you may need to use less frequently.
|
||||
```python
|
||||
def on_color_button_selected(self, message: ColorButton.Selected) -> None:
|
||||
self.screen.styles.animate("background", message.color, duration=0.5)
|
||||
```
|
||||
|
||||
- [post_message][textual.message_pump.MessagePump.post_message] To post a message to a particular event.
|
||||
- [post_message_no_wait][textual.message_pump.MessagePump.post_message_no_wait] The non-async version of `post_message`.
|
||||
If the body of your handler doesn't require any information in the message you can omit it from the method signature. If we just want to play a bell noise when the button is clicked, we could write our handler like this:
|
||||
|
||||
```python
|
||||
def on_color_button_selected(self) -> None:
|
||||
self.app.bell()
|
||||
```
|
||||
|
||||
This pattern is a convenience that saves writing out a parameter that may not be used.
|
||||
|
||||
### Async handlers
|
||||
|
||||
Method handlers may be coroutines. If you prefix your handlers with the `async` keyword, Textual will `await` them. This lets your handler use the `await` keyword for asynchronous APIs.
|
||||
|
||||
If your event handlers are coroutines it will allow multiple events to be processed concurrently, but bear in mind an individual widget (or app) will not be able to pick up a new message from the message queue until the handler has returned. This is rarely a problem in practice; as long has handlers return within a few milliseconds the UI will remain responsive. But slow handlers might make your app hard to use.
|
||||
|
||||
!!! info
|
||||
|
||||
To re-use the chef analogy, if an order comes in for beef wellington (which takes a while to cook), orders may start to pile up and customers may have to wait for their meal. The _solution_ would be to have another chef work on the wellington while the first chef picks up new orders.
|
||||
|
||||
Network access is a common cause of slow handlers. If you try to retrieve a file from the internet, the message handler may take anything up to a few seconds to return, which would prevent the widget or app from updating during that time. The solution is to launch a new asyncio task to do the network task in the background.
|
||||
|
||||
Let's look at an example which looks up word definitions from an [api](https://dictionaryapi.dev/) as you type.
|
||||
|
||||
!!! note
|
||||
|
||||
You will need to install [httpx](https://www.python-httpx.org/) with `pip install httpx` to run this example.
|
||||
|
||||
=== "dictionary.py"
|
||||
|
||||
```python title="dictionary.py" hl_lines="26"
|
||||
--8<-- "docs/examples/events/dictionary.py"
|
||||
```
|
||||
=== "dictionary.css"
|
||||
|
||||
```python title="dictionary.css"
|
||||
--8<-- "docs/examples/events/dictionary.css"
|
||||
```
|
||||
|
||||
=== "Output"
|
||||
|
||||
```{.textual path="docs/examples/events/dictionary.py" press="tab,t,e,x,t,_,_,_,_,_,_,_,_,_,_,_"}
|
||||
```
|
||||
|
||||
Note the highlighted line in the above code which calls `asyncio.create_task` to run coroutine in the background. Without this you would find typing in to the text box to be unresponsive.
|
||||
|
||||
@@ -58,7 +58,7 @@ class FileSearchApp(App):
|
||||
self.mount(file_table_wrapper=Widget(self.file_table))
|
||||
self.mount(search_bar=self.search_bar)
|
||||
|
||||
def on_text_widget_base_changed(self, event: TextWidgetBase.Changed) -> None:
|
||||
def on_text_input_changed(self, event: TextInput.Changed) -> None:
|
||||
self.file_table.filter = event.value
|
||||
|
||||
|
||||
|
||||
@@ -48,7 +48,7 @@ class InputApp(App[str]):
|
||||
)
|
||||
self.mount(text_area=TextArea())
|
||||
|
||||
def on_text_widget_base_changed(self, event: TextWidgetBase.Changed) -> None:
|
||||
def on_text_input_changed_changed(self, event: TextInput.Changed) -> None:
|
||||
try:
|
||||
value = float(event.value)
|
||||
except ValueError:
|
||||
|
||||
@@ -1305,21 +1305,6 @@ class App(Generic[ReturnType], DOMNode):
|
||||
self._end_update()
|
||||
console.file.flush()
|
||||
|
||||
def measure(self, renderable: RenderableType, max_width=100_000) -> int:
|
||||
"""Get the optimal width for a widget or renderable.
|
||||
|
||||
Args:
|
||||
renderable (RenderableType): A renderable (including Widget)
|
||||
max_width ([type], optional): Maximum width. Defaults to 100_000.
|
||||
|
||||
Returns:
|
||||
int: Number of cells required to render.
|
||||
"""
|
||||
measurement = Measurement.get(
|
||||
self.console, self.console.options.update(max_width=max_width), renderable
|
||||
)
|
||||
return measurement.maximum
|
||||
|
||||
def get_widget_at(self, x: int, y: int) -> tuple[Widget, Region]:
|
||||
"""Get the widget under the given coordinates.
|
||||
|
||||
|
||||
@@ -111,7 +111,7 @@ class EasingApp(App):
|
||||
self.animated_bar.position = value
|
||||
self.opacity_widget.styles.opacity = 1 - value / END_POSITION
|
||||
|
||||
def on_text_widget_base_changed(self, event: TextWidgetBase.Changed):
|
||||
def on_text_input_changed(self, event: TextInput.Changed):
|
||||
if event.sender.id == "duration-input":
|
||||
new_duration = _try_float(event.value)
|
||||
if new_duration is not None:
|
||||
|
||||
@@ -174,8 +174,6 @@ class ColorSystem:
|
||||
for name, color in COLORS:
|
||||
is_dark_shade = dark and name in DARK_SHADES
|
||||
spread = luminosity_spread
|
||||
if name == "panel":
|
||||
spread /= 2
|
||||
for shade_name, luminosity_delta in luminosity_range(spread):
|
||||
if is_dark_shade:
|
||||
dark_background = background.blend(color, 0.15)
|
||||
@@ -188,8 +186,8 @@ class ColorSystem:
|
||||
colors[f"{name}{shade_name}"] = shade_color.hex
|
||||
|
||||
colors["text"] = "auto 95%"
|
||||
colors["text-muted"] = "auto 80%"
|
||||
colors["text-disabled"] = "auto 60%"
|
||||
colors["text-muted"] = "auto 50%"
|
||||
colors["text-disabled"] = "auto 30%"
|
||||
|
||||
return colors
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
from rich.console import Console, RenderableType
|
||||
from rich.protocol import rich_cast
|
||||
|
||||
|
||||
def measure(console: Console, renderable: RenderableType, default: int) -> int:
|
||||
@@ -12,6 +13,7 @@ def measure(console: Console, renderable: RenderableType, default: int) -> int:
|
||||
Returns:
|
||||
int: Width in cells
|
||||
"""
|
||||
renderable = rich_cast(renderable)
|
||||
get_console_width = getattr(renderable, "__rich_measure__", None)
|
||||
if get_console_width is not None:
|
||||
render_width = get_console_width(console, console.options).normalize().maximum
|
||||
|
||||
@@ -7,8 +7,13 @@ from operator import attrgetter
|
||||
from typing import TYPE_CHECKING, ClassVar, Collection, Iterable, NamedTuple, cast
|
||||
|
||||
import rich.repr
|
||||
from rich.console import Console, ConsoleRenderable, JustifyMethod, RenderableType
|
||||
from rich.measure import Measurement
|
||||
from rich.console import (
|
||||
Console,
|
||||
ConsoleRenderable,
|
||||
RichCast,
|
||||
JustifyMethod,
|
||||
RenderableType,
|
||||
)
|
||||
from rich.segment import Segment
|
||||
from rich.style import Style
|
||||
from rich.styled import Styled
|
||||
@@ -74,7 +79,7 @@ class Widget(DOMNode):
|
||||
scrollbar-background-hover: $panel-darken-2;
|
||||
scrollbar-color: $primary-lighten-1;
|
||||
scrollbar-color-active: $warning-darken-1;
|
||||
scrollbar-corner-color: $panel-darken-3;
|
||||
scrollbar-corner-color: $panel-darken-1;
|
||||
scrollbar-size-vertical: 2;
|
||||
scrollbar-size-horizontal: 1;
|
||||
}
|
||||
@@ -321,8 +326,6 @@ class Widget(DOMNode):
|
||||
self.get_content_width,
|
||||
self.get_content_height,
|
||||
)
|
||||
self.log(self)
|
||||
self.log(box_model)
|
||||
return box_model
|
||||
|
||||
def get_content_width(self, container: Size, viewport: Size) -> int:
|
||||
@@ -346,7 +349,7 @@ class Widget(DOMNode):
|
||||
return self._content_width_cache[1]
|
||||
|
||||
console = self.app.console
|
||||
renderable = self.post_render(self.render())
|
||||
renderable = self._render()
|
||||
|
||||
width = measure(console, renderable, container.width)
|
||||
if self.fluid:
|
||||
@@ -1427,13 +1430,14 @@ class Widget(DOMNode):
|
||||
if isinstance(renderable, str):
|
||||
renderable = Text.from_markup(renderable, justify=text_justify)
|
||||
|
||||
rich_style = self.rich_style
|
||||
if isinstance(renderable, Text):
|
||||
renderable.stylize(rich_style)
|
||||
if text_justify is not None and renderable.justify is None:
|
||||
renderable.justify = text_justify
|
||||
else:
|
||||
renderable = Styled(renderable, rich_style)
|
||||
if (
|
||||
isinstance(renderable, Text)
|
||||
and text_justify is not None
|
||||
and renderable.justify is None
|
||||
):
|
||||
renderable.justify = text_justify
|
||||
|
||||
renderable = Styled(renderable, self.rich_style)
|
||||
|
||||
return renderable
|
||||
|
||||
@@ -1613,6 +1617,12 @@ class Widget(DOMNode):
|
||||
render = "" if self.is_container else self.css_identifier_styled
|
||||
return render
|
||||
|
||||
def _render(self) -> ConsoleRenderable | RichCast:
|
||||
renderable = self.render()
|
||||
if isinstance(renderable, str):
|
||||
return Text(renderable)
|
||||
return renderable
|
||||
|
||||
async def action(self, action: str) -> None:
|
||||
"""Perform a given action, with this widget as the default namespace.
|
||||
|
||||
|
||||
@@ -2,7 +2,9 @@ from __future__ import annotations
|
||||
|
||||
from rich.console import RenderableType
|
||||
from rich.protocol import is_renderable
|
||||
from rich.text import Text
|
||||
|
||||
from ..reactive import reactive
|
||||
from ..errors import RenderError
|
||||
from ..widget import Widget
|
||||
|
||||
@@ -41,21 +43,40 @@ class Static(Widget):
|
||||
}
|
||||
"""
|
||||
|
||||
fluid = reactive(True, layout=True)
|
||||
_renderable: RenderableType
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
renderable: RenderableType = "",
|
||||
*,
|
||||
fluid: bool = True,
|
||||
markup: bool = True,
|
||||
name: str | None = None,
|
||||
id: str | None = None,
|
||||
classes: str | None = None,
|
||||
) -> None:
|
||||
|
||||
super().__init__(name=name, id=id, classes=classes)
|
||||
self._renderable = renderable
|
||||
self.fluid = fluid
|
||||
self.markup = markup
|
||||
self.renderable = renderable
|
||||
_check_renderable(renderable)
|
||||
|
||||
@property
|
||||
def renderable(self) -> RenderableType:
|
||||
return self._renderable or ""
|
||||
|
||||
@renderable.setter
|
||||
def renderable(self, renderable: RenderableType) -> None:
|
||||
if isinstance(renderable, str):
|
||||
if self.markup:
|
||||
self._renderable = Text.from_markup(renderable)
|
||||
else:
|
||||
self._renderable = Text(renderable)
|
||||
else:
|
||||
self._renderable = renderable
|
||||
|
||||
def render(self) -> RenderableType:
|
||||
"""Get a rich renderable for the widget's content.
|
||||
|
||||
@@ -64,12 +85,12 @@ class Static(Widget):
|
||||
"""
|
||||
return self._renderable
|
||||
|
||||
def update(self, renderable: RenderableType) -> None:
|
||||
def update(self, renderable: RenderableType = "", home: bool = False) -> None:
|
||||
"""Update the widget contents.
|
||||
|
||||
Args:
|
||||
renderable (RenderableType): A new rich renderable.
|
||||
"""
|
||||
_check_renderable(renderable)
|
||||
self._renderable = renderable
|
||||
self.renderable = renderable
|
||||
self.refresh(layout=True)
|
||||
|
||||
@@ -86,6 +86,8 @@ class TextWidgetBase(Widget):
|
||||
return display_text
|
||||
|
||||
class Changed(Message, bubble=True):
|
||||
namespace = "text_input"
|
||||
|
||||
def __init__(self, sender: MessageTarget, value: str) -> None:
|
||||
"""Message posted when the user changes the value in a TextInput
|
||||
|
||||
@@ -116,9 +118,17 @@ class TextInput(TextWidgetBase, can_focus=True):
|
||||
padding: 1;
|
||||
background: $surface;
|
||||
content-align: left middle;
|
||||
color: $text;
|
||||
}
|
||||
TextInput .text-input--placeholder {
|
||||
color: $text-muted;
|
||||
}
|
||||
"""
|
||||
|
||||
COMPONENT_CLASSES = {
|
||||
"text-input--placeholder",
|
||||
}
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
@@ -269,7 +279,10 @@ class TextInput(TextWidgetBase, can_focus=True):
|
||||
else:
|
||||
# The user has not entered text - show the placeholder
|
||||
display_text = Text(
|
||||
self.placeholder, "dim", no_wrap=True, overflow="ignore"
|
||||
self.placeholder,
|
||||
self.get_component_rich_style("text-input--placeholder"),
|
||||
no_wrap=True,
|
||||
overflow="ignore",
|
||||
)
|
||||
if show_cursor:
|
||||
display_text = self._apply_cursor_to_text(display_text, 0)
|
||||
|
||||
Reference in New Issue
Block a user