Merge pull request #804 from Textualize/docs-actions

Docs actions
This commit is contained in:
Will McGugan
2022-09-30 09:40:47 +01:00
committed by GitHub
9 changed files with 328 additions and 9 deletions

View File

@@ -0,0 +1,17 @@
from textual.app import App
from textual import events
class ActionsApp(App):
def action_set_background(self, color: str) -> None:
self.screen.styles.background = color
self.bell()
def on_key(self, event: events.Key) -> None:
if event.key == "r":
self.action_set_background("red")
if __name__ == "__main__":
app = ActionsApp()
app.run()

View File

@@ -0,0 +1,17 @@
from textual.app import App
from textual import events
class ActionsApp(App):
def action_set_background(self, color: str) -> None:
self.screen.styles.background = color
self.bell()
async def on_key(self, event: events.Key) -> None:
if event.key == "r":
await self.action("set_background('red')")
if __name__ == "__main__":
app = ActionsApp()
app.run()

View File

@@ -0,0 +1,23 @@
from textual.app import App, ComposeResult
from textual.widgets import Static
TEXT = """
[b]Set your background[/b]
[@click=set_background('red')]Red[/]
[@click=set_background('green')]Green[/]
[@click=set_background('blue')]Blue[/]
"""
class ActionsApp(App):
def compose(self) -> ComposeResult:
yield Static(TEXT)
def action_set_background(self, color: str) -> None:
self.screen.styles.background = color
self.bell()
if __name__ == "__main__":
app = ActionsApp()
app.run()

View File

@@ -0,0 +1,29 @@
from textual.app import App, ComposeResult
from textual.widgets import Static
TEXT = """
[b]Set your background[/b]
[@click=set_background('red')]Red[/]
[@click=set_background('green')]Green[/]
[@click=set_background('blue')]Blue[/]
"""
class ActionsApp(App):
BINDINGS = [
("r", "set_background('red')", "Red"),
("g", "set_background('green')", "Green"),
("b", "set_background('blue')", "Blue"),
]
def compose(self) -> ComposeResult:
yield Static(TEXT)
def action_set_background(self, color: str) -> None:
self.screen.styles.background = color
self.bell()
if __name__ == "__main__":
app = ActionsApp()
app.run()

View File

@@ -0,0 +1,11 @@
Screen {
layout: grid;
grid-size: 1;
grid-gutter: 2 4;
grid-rows: 1fr;
}
ColorSwitcher {
height: 100%;
margin: 2 4;
}

View File

@@ -0,0 +1,36 @@
from textual.app import App, ComposeResult
from textual.widgets import Static
TEXT = """
[b]Set your background[/b]
[@click=set_background('cyan')]Cyan[/]
[@click=set_background('magenta')]Magenta[/]
[@click=set_background('yellow')]Yellow[/]
"""
class ColorSwitcher(Static):
def action_set_background(self, color: str) -> None:
self.styles.background = color
class ActionsApp(App):
CSS_PATH = "actions05.css"
BINDINGS = [
("r", "set_background('red')", "Red"),
("g", "set_background('green')", "Green"),
("b", "set_background('blue')", "Blue"),
]
def compose(self) -> ComposeResult:
yield ColorSwitcher(TEXT)
yield ColorSwitcher(TEXT)
def action_set_background(self, color: str) -> None:
self.screen.styles.background = color
self.bell()
if __name__ == "__main__":
app = ActionsApp()
app.run()

View File

@@ -1,3 +1,147 @@
# Actions # Actions
TODO: Actions docs Actions are allow-listed functions with a string syntax you can embed in links and bind to keys. In this chapter we will discuss how to create actions and how to run them.
## Action methods
Action methods are methods on your app or widgets prefixed with `action_`. Aside from the prefix these are regular methods which you could call directly if you wished.
!!! information
Action methods may be coroutines (methods with the `async` keyword).
Let's write an app with a simple action.
```python title="actions01.py" hl_lines="6-8"
--8<-- "docs/examples/guide/actions/actions01.py"
```
The `action_set_background` method is an action which sets the background of the screen. The key handler above will call this action if you press the ++r++ key.
Although it is possible (and occasionally useful) to call action methods in this way, they are intended to be parsed from an _action string_. For instance, the string `"set_background('red')"` is an action string which would call `self.action_set_background('red')`.
The following example replaces the immediate call with a call to [action()][textual.widgets.Widget.action] which parses an action string and dispatches it to the appropriate method.
```python title="actions02.py" hl_lines="10-12"
--8<-- "docs/examples/guide/actions/actions02.py"
```
Note that the `action()` method is a coroutine so `on_key` needs to be prefixed with the `async` keyword.
You will not typically need this in a real app as Textual will run actions in links or key bindings. Before we discuss these, let's have a closer look at the syntax for action strings.
## Syntax
Action strings have a simple syntax, which for the most part replicates Python's function call syntax.
!!! important
As much as they look like Python code, Textual does **not** call Python's `eval` function or similar to compile action strings.
Action strings have the following format:
- The name of an action on is own will call the action method with no parameters. For example, an action string of `"bell"` will call `action_bell()`.
- Actions may be followed by braces containing Python objects. For example, the action string `set_background("red")` will call `action_set_background("red")`.
- Actions may be prefixed with a _namespace_ (see below) follow by a dot.
<div class="excalidraw">
--8<-- "docs/images/actions/format.excalidraw.svg"
</div>
### Parameters
If the action strings contains parameters, these must be valid Python literals. Which means you can include numbers, strings, dicts, lists etc. but you can't include variables or references to any other python symbols.
Consequently `"set_background('blue')"` is a valid action string, but `"set_background(new_color)"` is not &mdash; because `new_color` is a variable and not a literal.
## Links
Actions may be embedded as links within console markup. You can create such links with a `@click` tag.
The following example mounts simple static text with embedded action links.
=== "actions03.py"
```python title="actions03.py" hl_lines="4-9 13-14"
--8<-- "docs/examples/guide/actions/actions03.py"
```
=== "Output"
```{.textual path="docs/examples/guide/actions/actions03.py"}
```
When you click any of the links, Textual runs the `"set_background"` action to change the background to the given color and plays the terminals bell.
## Bindings
Textual will also run actions that are bound to keys. The following example adds key [bindings](./input.md#bindings) for the ++r++, ++g++, and ++b++ keys which call the `"set_background"` action.
=== "actions04.py"
```python title="actions04.py" hl_lines="13-17"
--8<-- "docs/examples/guide/actions/actions04.py"
```
=== "Output"
```{.textual path="docs/examples/guide/actions/actions04.py" press="g"}
```
If you run this example, you can change the background by pressing keys in addition to clicking links.
## Namespaces
Textual will look for action methods on the widget or app where they are used. If we were to create a [custom widget](./widgets.md#custom-widgets) it can have its own set of actions.
The following example defines a custom widget with its own `set_background` action.
=== "actions05.py"
```python title="actions05.py" hl_lines="13-14"
--8<-- "docs/examples/guide/actions/actions05.py"
```
=== "actions05.py"
```sass title="actions05.css"
--8<-- "docs/examples/guide/actions/actions05.css"
```
There are two instances of the custom widget mounted. If you click the links in either of them it will changed the background for that widget only. The ++r++, ++g++, and ++b++ key bindings are set on the App so will set the background for the screen.
You can optionally prefix an action with a _namespace_, which tells Textual to run actions for a different object.
Textual supports the following action namespaces:
- `app` invokes actions on the App.
- `screen` invokes actions on the screen.
In the previous example if you wanted a link to set the background on the app rather than the widget, we could set a link to `app.set_background('red')`.
## Builtin actions
Textual supports the following builtin actions which are defined on the app.
### Bell
::: textual.app.App.action_bell
options:
show_root_heading: false
### Screenshot
::: textual.app.App.action_screenshot
### Toggle_dark
::: textual.app.App.action_toggle_dark
### Quit
::: textual.app.App.action_quit
*TODO:* document more actions

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 11 KiB

View File

@@ -539,8 +539,12 @@ class App(Generic[ReturnType], DOMNode):
self.dark = not self.dark self.dark = not self.dark
def action_screenshot(self, filename: str | None, path: str = "~/") -> None: def action_screenshot(self, filename: str | None, path: str = "~/") -> None:
"""Action to save a screenshot.""" """Save an SVG "screenshot". This action will save a SVG file containing the current contents of the screen.
self.bell()
Args:
filename (str | None, optional): Filename of screenshot, or None to auto-generate. Defaults to None.
path (str, optional): Path to directory. Defaults to "~/".
"""
self.save_screenshot(filename, path) self.save_screenshot(filename, path)
def export_screenshot(self, *, title: str | None = None) -> str: def export_screenshot(self, *, title: str | None = None) -> str:
@@ -1366,30 +1370,43 @@ class App(Generic[ReturnType], DOMNode):
await super().on_event(event) await super().on_event(event)
async def action( async def action(
self, action: str, default_namespace: object | None = None self,
) -> None: action: str | tuple[str, tuple[str, ...]],
default_namespace: object | None = None,
) -> bool:
"""Perform an action. """Perform an action.
Args: Args:
action (str): Action encoded in a string. action (str): Action encoded in a string.
default_namespace (object | None): Namespace to use if not provided in the action, default_namespace (object | None): Namespace to use if not provided in the action,
or None to use app. Defaults to None. or None to use app. Defaults to None.
Returns:
bool: True if the event has handled.
""" """
target, params = actions.parse(action) if isinstance(action, str):
target, params = actions.parse(action)
else:
target, params = action
implicit_destination = True
if "." in target: if "." in target:
destination, action_name = target.split(".", 1) destination, action_name = target.split(".", 1)
if destination not in self._action_targets: if destination not in self._action_targets:
raise ActionError("Action namespace {destination} is not known") raise ActionError("Action namespace {destination} is not known")
action_target = getattr(self, destination) action_target = getattr(self, destination)
implicit_destination = True
else: else:
action_target = default_namespace or self action_target = default_namespace or self
action_name = target action_name = target
await self._dispatch_action(action_target, action_name, params) handled = await self._dispatch_action(action_target, action_name, params)
if not handled and implicit_destination and not isinstance(action_target, App):
handled = await self.app._dispatch_action(self.app, action_name, params)
return handled
async def _dispatch_action( async def _dispatch_action(
self, namespace: object, action_name: str, params: Any self, namespace: object, action_name: str, params: Any
) -> None: ) -> bool:
log( log(
"<action>", "<action>",
namespace=namespace, namespace=namespace,
@@ -1403,6 +1420,8 @@ class App(Generic[ReturnType], DOMNode):
log(f"<action> {action_name!r} has no target") log(f"<action> {action_name!r} has no target")
if callable(method): if callable(method):
await invoke(method, *params) await invoke(method, *params)
return True
return False
async def _broker_event( async def _broker_event(
self, event_name: str, event: events.Event, default_namespace: object | None self, event_name: str, event: events.Event, default_namespace: object | None
@@ -1427,7 +1446,7 @@ class App(Generic[ReturnType], DOMNode):
return False return False
else: else:
event.stop() event.stop()
if isinstance(action, str): if isinstance(action, (str, tuple)):
await self.action(action, default_namespace=default_namespace) await self.action(action, default_namespace=default_namespace)
elif callable(action): elif callable(action):
await action() await action()
@@ -1475,15 +1494,22 @@ class App(Generic[ReturnType], DOMNode):
await self.press(key) await self.press(key)
async def action_quit(self) -> None: async def action_quit(self) -> None:
"""Quit the app as soon as possible."""
await self.shutdown() await self.shutdown()
async def action_bang(self) -> None: async def action_bang(self) -> None:
1 / 0 1 / 0
async def action_bell(self) -> None: async def action_bell(self) -> None:
"""Play the terminal 'bell'."""
self.bell() self.bell()
async def action_focus(self, widget_id: str) -> None: async def action_focus(self, widget_id: str) -> None:
"""Focus the given widget.
Args:
widget_id (str): ID of widget to focus.
"""
try: try:
node = self.query(f"#{widget_id}").first() node = self.query(f"#{widget_id}").first()
except NoMatchingNodesError: except NoMatchingNodesError: