From 4e732ce309243c97b8387b96783b376dcd297c02 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sun, 18 Sep 2022 15:43:47 +0100 Subject: [PATCH] dictionary example --- docs/examples/events/dictionary.css | 29 ++++++++++++ docs/examples/events/dictionary.py | 43 +++++++++++++++++ docs/guide/events.md | 71 ++++++++++++++++++++++++++--- sandbox/darren/file_search.py | 2 +- sandbox/input.py | 2 +- src/textual/app.py | 15 ------ src/textual/cli/previews/easing.py | 2 +- src/textual/design.py | 6 +-- src/textual/render.py | 2 + src/textual/widget.py | 36 +++++++++------ src/textual/widgets/_static.py | 27 +++++++++-- src/textual/widgets/_text_input.py | 15 +++++- 12 files changed, 205 insertions(+), 45 deletions(-) create mode 100644 docs/examples/events/dictionary.css create mode 100644 docs/examples/events/dictionary.py diff --git a/docs/examples/events/dictionary.css b/docs/examples/events/dictionary.css new file mode 100644 index 000000000..f0e46faa7 --- /dev/null +++ b/docs/examples/events/dictionary.css @@ -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%; +} diff --git a/docs/examples/events/dictionary.py b/docs/examples/events/dictionary.py new file mode 100644 index 000000000..b6810cc8e --- /dev/null +++ b/docs/examples/events/dictionary.py @@ -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() diff --git a/docs/guide/events.md b/docs/guide/events.md index ec718168a..0e81dbf9c 100644 --- a/docs/guide/events.md +++ b/docs/guide/events.md @@ -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" -### 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. diff --git a/sandbox/darren/file_search.py b/sandbox/darren/file_search.py index c008b3d17..ab93b29fb 100644 --- a/sandbox/darren/file_search.py +++ b/sandbox/darren/file_search.py @@ -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 diff --git a/sandbox/input.py b/sandbox/input.py index 5cfba8edc..5b09e1364 100644 --- a/sandbox/input.py +++ b/sandbox/input.py @@ -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: diff --git a/src/textual/app.py b/src/textual/app.py index d63389c55..0c5956d6e 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -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. diff --git a/src/textual/cli/previews/easing.py b/src/textual/cli/previews/easing.py index a8703fce1..56399ddde 100644 --- a/src/textual/cli/previews/easing.py +++ b/src/textual/cli/previews/easing.py @@ -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: diff --git a/src/textual/design.py b/src/textual/design.py index 9a3749b1d..36b01a622 100644 --- a/src/textual/design.py +++ b/src/textual/design.py @@ -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 diff --git a/src/textual/render.py b/src/textual/render.py index 422c9f0c4..5de558857 100644 --- a/src/textual/render.py +++ b/src/textual/render.py @@ -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 diff --git a/src/textual/widget.py b/src/textual/widget.py index fa4ba0d19..cb7c2cc7b 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -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. diff --git a/src/textual/widgets/_static.py b/src/textual/widgets/_static.py index 132770c31..bab12cfa3 100644 --- a/src/textual/widgets/_static.py +++ b/src/textual/widgets/_static.py @@ -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) diff --git a/src/textual/widgets/_text_input.py b/src/textual/widgets/_text_input.py index 5b706049b..825030650 100644 --- a/src/textual/widgets/_text_input.py +++ b/src/textual/widgets/_text_input.py @@ -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)