Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

dynamic bindings #4516

Merged
merged 30 commits into from
May 18, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,20 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](http://keepachangelog.com/)
and this project adheres to [Semantic Versioning](http://semver.org/).


## [0.61.0] - Unreleased

### Added

- Added dynamic binding via `DOMNode.check_action` https://github.com/Textualize/textual/pull/4516
- Added `"focused"` action namespace so you can bind a key to an action on the focused widget https://github.com/Textualize/textual/pull/4516
- Added "focused" to allowed action namespaces https://github.com/Textualize/textual/pull/4516

### Changed

- Breaking change: Actions (as used in bindings) will no longer check the app if they are unhandled. This was undocumented anyway, and not that useful. https://github.com/Textualize/textual/pull/4516
- Breaking change: Renamed `App.namespace_bindings` to `active_bindings`

## [0.60.1] - 2024-05-15

### Fixed
Expand Down Expand Up @@ -1951,6 +1965,7 @@ https://textual.textualize.io/blog/2022/11/08/version-040/#version-040
- New handler system for messages that doesn't require inheritance
- Improved traceback handling

[0.61.0]: https://github.com/Textualize/textual/compare/v0.60.1...v0.61.0
[0.60.1]: https://github.com/Textualize/textual/compare/v0.60.0...v0.60.1
[0.60.0]: https://github.com/Textualize/textual/compare/v0.59.0...v0.60.0
[0.59.0]: https://github.com/Textualize/textual/compare/v0.58.1...v0.59.0
Expand Down
48 changes: 48 additions & 0 deletions docs/examples/guide/actions/actions06.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
from textual.app import App, ComposeResult
from textual.containers import HorizontalScroll
from textual.reactive import reactive
from textual.widgets import Footer, Placeholder

PAGES_COUNT = 5


class PagesApp(App):
BINDINGS = [
("n", "next", "Next"),
("p", "previous", "Previous"),
]

CSS_PATH = "actions06.tcss"

page_no = reactive(0)

def compose(self) -> ComposeResult:
with HorizontalScroll(id="page-container"):
for page_no in range(PAGES_COUNT):
yield Placeholder(f"Page {page_no}", id=f"page-{page_no}")
yield Footer()

def action_next(self) -> None:
self.page_no += 1
self.refresh_bindings() # (1)!
self.query_one(f"#page-{self.page_no}").scroll_visible()

def action_previous(self) -> None:
self.page_no -= 1
self.refresh_bindings() # (2)!
self.query_one(f"#page-{self.page_no}").scroll_visible()

def check_action(
self, action: str, parameters: tuple[object, ...]
) -> bool | None: # (3)!
"""Check if an action may run."""
if action == "next" and self.page_no == PAGES_COUNT - 1:
return False
if action == "previous" and self.page_no == 0:
return False
return True


if __name__ == "__main__":
app = PagesApp()
app.run()
4 changes: 4 additions & 0 deletions docs/examples/guide/actions/actions06.tcss
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
#page-container {
# This hides the scrollbar
scrollbar-size: 0 0;
}
44 changes: 44 additions & 0 deletions docs/examples/guide/actions/actions07.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
from textual.app import App, ComposeResult
from textual.containers import HorizontalScroll
from textual.reactive import reactive
from textual.widgets import Footer, Placeholder

PAGES_COUNT = 5


class PagesApp(App):
BINDINGS = [
("n", "next", "Next"),
("p", "previous", "Previous"),
]

CSS_PATH = "actions06.tcss"

page_no = reactive(0, bindings=True) # (1)!

def compose(self) -> ComposeResult:
with HorizontalScroll(id="page-container"):
for page_no in range(PAGES_COUNT):
yield Placeholder(f"Page {page_no}", id=f"page-{page_no}")
yield Footer()

def action_next(self) -> None:
self.page_no += 1
self.query_one(f"#page-{self.page_no}").scroll_visible()

def action_previous(self) -> None:
self.page_no -= 1
self.query_one(f"#page-{self.page_no}").scroll_visible()

def check_action(self, action: str, parameters: tuple[object, ...]) -> bool | None:
"""Check if an action may run."""
if action == "next" and self.page_no == PAGES_COUNT - 1:
return None # (2)!
if action == "previous" and self.page_no == 0:
return None # (3)!
return True


if __name__ == "__main__":
app = PagesApp()
app.run()
78 changes: 78 additions & 0 deletions docs/guide/actions.md
Original file line number Diff line number Diff line change
Expand Up @@ -118,10 +118,88 @@ Textual supports the following action namespaces:

- `app` invokes actions on the App.
- `screen` invokes actions on the screen.
- `focused` invokes actions on the currently focused widget (if there is one).

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')`.


## Dynamic actions

!!! tip "Added in version 0.61.0"

There may be situations where an action is temporarily unavailable due to some internal state within your app.
For instance, consider an app with a fixed number of pages and actions to go to the next and previous page.
It doesn't make sense to go to the previous page if we are on the first, or the next page when we are on the last page.

We could easily add this logic to the action methods, but the [footer][textual.widgets.Footer] would still display the keys even if they would have no effect.
The user may wonder why the app is showing keys that don't appear to work.

We can solve this issue by implementing the [`check_action`][textual.dom.DOMNode.check_action] on our app, screen, or widget.
This method is called with the name of the action and any parameters, prior to running actions or refreshing the footer.
It should return one of the following values:

- `True` to show the key and run the action as normal.
- `False` to hide the key and prevent the action running.
- `None` to disable the key (show dimmed), and prevent the action running.

Let's write an app to put this into practice:

=== "actions06.py"

```python title="actions06.py" hl_lines="27 32 35-43"
--8<-- "docs/examples/guide/actions/actions06.py"
```

1. Prompts the footer to refresh, if bindings change.
2. Prompts the footer to refresh, if bindings change.
3. Guards the actions from running and also what keys are displayed in the footer.

=== "actions06.tcss"

```css title="actions06.tcss"
--8<-- "docs/examples/guide/actions/actions06.tcss"
```

=== "Output"

```{.textual path="docs/examples/guide/actions/actions06.py"}
```

This app has key bindings for ++n++ and ++p++ to navigate the pages.
Notice how the keys are hidden from the footer when they would have no effect.

The actions above call [`refresh_bindings`][textual.dom.DOMNode.refresh_bindings] to prompt Textual to refresh the footer.
An alternative to doing this manually is to set `bindings=True` on a [reactive](./reactivity.md), which will refresh the bindings if the reactive changes.

Let's make this change.
We will also demonstrate what the footer will show if we return `None` from `check_action` (rather than `False`):


=== "actions07.py"

```python title="actions06.py" hl_lines="17 36 38"
--8<-- "docs/examples/guide/actions/actions07.py"
```

1. The `bindings=True` causes the footer to refresh when `page_no` changes.
2. Returning `None` disables the key in the footer rather than hides it
3. Returning `None` disables the key in the footer rather than hides it.

=== "actions06.tcss"

```css title="actions06.tcss"
--8<-- "docs/examples/guide/actions/actions06.tcss"
```

=== "Output"

```{.textual path="docs/examples/guide/actions/actions07.py"}
```

Note how the logic is the same but we don't need to explicitly call [`refresh_bindings`][textual.dom.DOMNode.refresh_bindings].
The change to `check_action` also causes the disabled footer keys to be grayed out, indicating they are temporarily unavailable.


## Builtin actions

Textual supports the following builtin actions which are defined on the app.
Expand Down
4 changes: 2 additions & 2 deletions examples/five_by_five.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
class Help(Screen):
"""The help screen for the application."""

BINDINGS = [("escape,space,q,question_mark", "pop_screen", "Close")]
BINDINGS = [("escape,space,q,question_mark", "app.pop_screen", "Close")]
"""Bindings for the help screen."""

def compose(self) -> ComposeResult:
Expand Down Expand Up @@ -159,7 +159,7 @@ class Game(Screen):

BINDINGS = [
Binding("n", "new_game", "New Game"),
Binding("question_mark", "push_screen('help')", "Help", key_display="?"),
Binding("question_mark", "app.push_screen('help')", "Help", key_display="?"),
Binding("q", "quit", "Quit"),
Binding("up,w,k", "navigate(-1,0)", "Move Up", False),
Binding("down,s,j", "navigate(1,0)", "Move Down", False),
Expand Down
20 changes: 15 additions & 5 deletions src/textual/_event_broker.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,29 @@


class NoHandler(Exception):
pass
"""Raised when handler isn't found in the meta."""


class HandlerArguments(NamedTuple):
"""Information for event handler."""

modifiers: set[str]
action: Any


def extract_handler_actions(event_name: str, meta: dict[str, Any]) -> HandlerArguments:
"""Extract action from meta dict.

Args:
event_name: Event to check from.
meta: Meta information (stored in Rich Style)

Raises:
NoHandler: If no handler is found.

Returns:
Action information.
"""
event_path = event_name.split(".")
for key, value in meta.items():
if key.startswith("@"):
Expand All @@ -21,7 +35,3 @@ def extract_handler_actions(event_name: str, meta: dict[str, Any]) -> HandlerArg
modifiers = name_args[len(event_path) :]
return HandlerArguments(set(modifiers), value)
raise NoHandler(f"No handler for {event_name!r}")


if __name__ == "__main__":
print(extract_handler_actions("mouse.down", {"@mouse.down.hot": "app.bell()"}))
8 changes: 6 additions & 2 deletions src/textual/actions.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,12 @@

import ast
import re
from functools import lru_cache
from typing import Any

from typing_extensions import TypeAlias

ActionParseResult: TypeAlias = "tuple[str, tuple[Any, ...]]"
ActionParseResult: TypeAlias = "tuple[str, str, tuple[object, ...]]"
"""An action is its name and the arbitrary tuple of its arguments."""


Expand All @@ -21,6 +22,7 @@ class ActionError(Exception):
re_action_args = re.compile(r"([\w\.]+)\((.*)\)")


@lru_cache(maxsize=1024)
def parse(action: str) -> ActionParseResult:
"""Parses an action string.

Expand Down Expand Up @@ -52,4 +54,6 @@ def parse(action: str) -> ActionParseResult:
action_name = action
action_args = ()

return action_name, action_args
namespace, _, action_name = action_name.rpartition(".")

return namespace, action_name, action_args
Loading
Loading