diff --git a/CHANGELOG.md b/CHANGELOG.md
index 70e5c7cff6..edda7ca1d0 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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
@@ -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
diff --git a/docs/examples/guide/actions/actions06.py b/docs/examples/guide/actions/actions06.py
new file mode 100644
index 0000000000..5114637ebc
--- /dev/null
+++ b/docs/examples/guide/actions/actions06.py
@@ -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()
diff --git a/docs/examples/guide/actions/actions06.tcss b/docs/examples/guide/actions/actions06.tcss
new file mode 100644
index 0000000000..250ee3d753
--- /dev/null
+++ b/docs/examples/guide/actions/actions06.tcss
@@ -0,0 +1,4 @@
+#page-container {
+ # This hides the scrollbar
+ scrollbar-size: 0 0;
+}
diff --git a/docs/examples/guide/actions/actions07.py b/docs/examples/guide/actions/actions07.py
new file mode 100644
index 0000000000..0344dd4609
--- /dev/null
+++ b/docs/examples/guide/actions/actions07.py
@@ -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()
diff --git a/docs/guide/actions.md b/docs/guide/actions.md
index 4e7c8f8c19..19df24336c 100644
--- a/docs/guide/actions.md
+++ b/docs/guide/actions.md
@@ -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.
diff --git a/examples/five_by_five.py b/examples/five_by_five.py
index 6859cf86c6..e9acdc168e 100644
--- a/examples/five_by_five.py
+++ b/examples/five_by_five.py
@@ -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:
@@ -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),
diff --git a/src/textual/_event_broker.py b/src/textual/_event_broker.py
index 1b63a6cf5e..fe6727e105 100644
--- a/src/textual/_event_broker.py
+++ b/src/textual/_event_broker.py
@@ -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("@"):
@@ -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()"}))
diff --git a/src/textual/actions.py b/src/textual/actions.py
index 9365bb268b..a7ff7bbde2 100644
--- a/src/textual/actions.py
+++ b/src/textual/actions.py
@@ -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."""
@@ -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.
@@ -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
diff --git a/src/textual/app.py b/src/textual/app.py
index 7c7c3fe380..3aef22caad 100644
--- a/src/textual/app.py
+++ b/src/textual/app.py
@@ -27,7 +27,6 @@
)
from datetime import datetime
from functools import partial
-from pathlib import PurePath
from time import perf_counter
from typing import (
TYPE_CHECKING,
@@ -40,11 +39,9 @@
Generic,
Iterable,
Iterator,
- List,
Sequence,
Type,
TypeVar,
- Union,
overload,
)
from weakref import WeakKeyDictionary, WeakSet
@@ -83,7 +80,7 @@
from ._worker_manager import WorkerManager
from .actions import ActionParseResult, SkipAction
from .await_remove import AwaitRemove
-from .binding import Binding, BindingType, _Bindings
+from .binding import Binding, BindingType
from .command import CommandPalette, Provider
from .css.errors import StylesheetError
from .css.query import NoMatches
@@ -107,6 +104,7 @@
from .reactive import Reactive
from .renderables.blank import Blank
from .screen import (
+ ActiveBinding,
Screen,
ScreenResultCallbackType,
ScreenResultType,
@@ -225,14 +223,6 @@ class SuspendNotSupported(Exception):
ReturnType = TypeVar("ReturnType")
-
-CSSPathType = Union[
- str,
- PurePath,
- List[Union[str, PurePath]],
-]
-"""Valid ways of specifying paths to CSS files."""
-
CallThreadReturnType = TypeVar("CallThreadReturnType")
@@ -364,6 +354,9 @@ class MyApp(App[None]):
ENABLE_COMMAND_PALETTE: ClassVar[bool] = True
"""Should the [command palette][textual.command.CommandPalette] be enabled for the application?"""
+ NOTIFICATION_TIMEOUT: ClassVar[float] = 5
+ """Default number of seconds to show notifications before removing them."""
+
COMMANDS: ClassVar[set[type[Provider] | Callable[[], type[Provider]]]] = {
get_system_commands
}
@@ -466,7 +459,7 @@ def __init__(
self._driver: Driver | None = None
self._exit_renderables: list[RenderableType] = []
- self._action_targets = {"app", "screen"}
+ self._action_targets = {"app", "screen", "focused"}
self._animator = Animator(self)
self._animate = self._animator.bind(self)
self.mouse_position = Offset(0, 0)
@@ -855,7 +848,7 @@ def focused(self) -> Widget | None:
return focused
@property
- def namespace_bindings(self) -> dict[str, tuple[DOMNode, Binding]]:
+ def active_bindings(self) -> dict[str, ActiveBinding]:
"""Get currently active bindings.
If no widget is focused, then app-level bindings are returned.
@@ -864,20 +857,9 @@ def namespace_bindings(self) -> dict[str, tuple[DOMNode, Binding]]:
This property may be used to inspect current bindings.
Returns:
- A map of keys to a tuple containing the DOMNode and Binding that key corresponds to.
+ Active binding information
"""
-
- bindings_map: dict[str, tuple[DOMNode, Binding]] = {}
- for namespace, bindings in self._binding_chain:
- for key, binding in bindings.keys.items():
- if existing_key_and_binding := bindings_map.get(key):
- _, existing_binding = existing_key_and_binding
- if binding.priority and not existing_binding.priority:
- bindings_map[key] = (namespace, binding)
- else:
- bindings_map[key] = (namespace, binding)
-
- return bindings_map
+ return self.screen.active_bindings
def _set_active(self) -> None:
"""Set this app to be the currently active app."""
@@ -2938,15 +2920,6 @@ def _binding_chain(self) -> list[tuple[DOMNode, _Bindings]]:
return namespace_bindings
- @property
- def _modal_binding_chain(self) -> list[tuple[DOMNode, _Bindings]]:
- """The binding chain, ignoring everything before the last modal."""
- binding_chain = self._binding_chain
- for index, (node, _bindings) in enumerate(binding_chain, 1):
- if node.is_modal:
- return binding_chain[:index]
- return binding_chain
-
async def check_bindings(self, key: str, priority: bool = False) -> bool:
"""Handle a key press.
@@ -2961,7 +2934,9 @@ async def check_bindings(self, key: str, priority: bool = False) -> bool:
True if the key was handled by a binding, otherwise False
"""
for namespace, bindings in (
- reversed(self._binding_chain) if priority else self._modal_binding_chain
+ reversed(self.screen._binding_chain)
+ if priority
+ else self.screen._modal_binding_chain
):
binding = bindings.keys.get(key)
if binding is not None and binding.priority == priority:
@@ -3026,10 +3001,54 @@ async def on_event(self, event: events.Event) -> None:
else:
await super().on_event(event)
+ def _parse_action(
+ self, action: str, default_namespace: DOMNode
+ ) -> tuple[DOMNode, str, tuple[object, ...]]:
+ """Parse an action.
+
+ Args:
+ action: An action string.
+
+ Raises:
+ ActionError: If there are any errors parsing the action string.
+
+ Returns:
+ A tuple of (node or None, action name, tuple of parameters).
+ """
+ destination, action_name, params = actions.parse(action)
+ action_target: DOMNode | None = None
+ if destination:
+ if destination not in self._action_targets:
+ raise ActionError(f"Action namespace {destination} is not known")
+ action_target = getattr(self, destination, None)
+ if action_target is None:
+ raise ActionError("Action target {destination!r} not available")
+ return (
+ (default_namespace if action_target is None else action_target),
+ action_name,
+ params,
+ )
+
+ def _check_action_state(
+ self, action: str, default_namespace: DOMNode
+ ) -> bool | None:
+ """Check if an action is enabled.
+
+ Args:
+ action: An action string.
+
+ Returns:
+ State of an action.
+ """
+ action_target, action_name, parameters = self._parse_action(
+ action, default_namespace
+ )
+ return action_target.check_action(action_name, parameters)
+
async def run_action(
self,
action: str | ActionParseResult,
- default_namespace: object | None = None,
+ default_namespace: DOMNode | None = None,
) -> bool:
"""Perform an [action](/guide/actions).
@@ -3044,24 +3063,18 @@ async def run_action(
True if the event has been handled.
"""
if isinstance(action, str):
- target, params = actions.parse(action)
- else:
- target, params = action
- implicit_destination = True
- if "." in target:
- destination, action_name = target.split(".", 1)
- if destination not in self._action_targets:
- raise ActionError(f"Action namespace {destination} is not known")
- action_target = getattr(self, destination)
- implicit_destination = True
+ action_target, action_name, params = self._parse_action(
+ action, self if default_namespace is None else default_namespace
+ )
else:
- action_target = default_namespace if default_namespace is not None else self
- action_name = target
+ # assert isinstance(action, tuple)
+ _, action_name, params = action
+ action_target = self
- 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
+ if action_target.check_action(action_name, params):
+ return await self._dispatch_action(action_target, action_name, params)
+ else:
+ return False
async def _dispatch_action(
self, namespace: object, action_name: str, params: Any
@@ -3104,7 +3117,7 @@ async def _dispatch_action(
return False
async def _broker_event(
- self, event_name: str, event: events.Event, default_namespace: object | None
+ self, event_name: str, event: events.Event, default_namespace: DOMNode
) -> bool:
"""Allow the app an opportunity to dispatch events to action system.
@@ -3126,8 +3139,10 @@ async def _broker_event(
return False
else:
event.stop()
- if isinstance(action, str) or (isinstance(action, tuple) and len(action) == 2):
- await self.run_action(action, default_namespace=default_namespace) # type: ignore[arg-type]
+ if isinstance(action, str):
+ await self.run_action(action, default_namespace)
+ elif isinstance(action, tuple) and len(action) == 2:
+ await self.run_action(("", *action), default_namespace)
elif callable(action):
await action()
else:
@@ -3165,11 +3180,13 @@ async def _on_app_focus(self, event: events.AppFocus) -> None:
"""App has focus."""
# Required by textual-web to manage focus in a web page.
self.app_focus = True
+ self.screen.refresh_bindings()
async def _on_app_blur(self, event: events.AppBlur) -> None:
"""App has lost focus."""
# Required by textual-web to manage focus in a web page.
self.app_focus = False
+ self.screen.refresh_bindings()
def _detach_from_dom(self, widgets: list[Widget]) -> list[Widget]:
"""Detach a list of widgets from the DOM.
@@ -3498,7 +3515,7 @@ def notify(
*,
title: str = "",
severity: SeverityLevel = "information",
- timeout: float = Notification.timeout,
+ timeout: float | None = None,
) -> None:
"""Create a notification.
@@ -3511,7 +3528,7 @@ def notify(
message: The message for the notification.
title: The title for the notification.
severity: The severity of the notification.
- timeout: The timeout (in seconds) for the notification.
+ timeout: The timeout (in seconds) for the notification, or `None` for default.
The `notify` method is used to create an application-wide
notification, shown in a [`Toast`][textual.widgets._toast.Toast],
@@ -3546,6 +3563,8 @@ def notify(
self.notify("It's against my programming to impersonate a deity.", title="")
```
"""
+ if timeout is None:
+ timeout = self.NOTIFICATION_TIMEOUT
notification = Notification(message, title, severity, timeout)
self.post_message(Notify(notification))
diff --git a/src/textual/binding.py b/src/textual/binding.py
index 409fe9de56..3cc2828df3 100644
--- a/src/textual/binding.py
+++ b/src/textual/binding.py
@@ -8,7 +8,7 @@
from __future__ import annotations
from dataclasses import dataclass
-from typing import TYPE_CHECKING, Iterable
+from typing import TYPE_CHECKING, Iterable, NamedTuple
import rich.repr
@@ -17,6 +17,8 @@
if TYPE_CHECKING:
from typing_extensions import TypeAlias
+ from .dom import DOMNode
+
BindingType: TypeAlias = "Binding | tuple[str, str] | tuple[str, str, str]"
@@ -50,6 +52,17 @@ class Binding:
"""Enable priority binding for this key."""
+class ActiveBinding(NamedTuple):
+ """Information about an active binding (returned from [active_bindings][textual.screen.active_bindings])."""
+
+ node: DOMNode
+ """The node where the binding is defined."""
+ binding: Binding
+ """The binding information."""
+ enabled: bool
+ """Is the binding enabled? (enabled bindings are typically rendered dim)"""
+
+
@rich.repr.auto
class _Bindings:
"""Manage a set of bindings."""
diff --git a/src/textual/dom.py b/src/textual/dom.py
index 3dedcce317..bc42f0b6ce 100644
--- a/src/textual/dom.py
+++ b/src/textual/dom.py
@@ -1482,6 +1482,31 @@ def refresh(
) -> Self:
return self
+ def check_action(self, action: str, parameters: tuple[object, ...]) -> bool | None:
+ """Check whether an action is enabled.
+
+ Implement this method to add logic for [dynamic actions](/guide/actions#dynamic-actions) / bindings.
+
+ Args:
+ action: The name of an action.
+ action_parameters: A tuple of any action parameters.
+
+ Returns:
+ `True` if the action is enabled+visible,
+ `False` if the action is disabled+hidden,
+ `None` if the action is disabled+visible (grayed out in footer)
+ """
+ return True
+
+ def refresh_bindings(self) -> None:
+ """Call to prompt widgets such as the [Footer][textual.widgets.Footer] to update
+ the display of key bindings.
+
+ See [actions](/guide/actions#dynamic-actions) for how to use this method.
+
+ """
+ self.call_later(self.screen.refresh_bindings)
+
async def action_toggle(self, attribute_name: str) -> None:
"""Toggle an attribute on the node.
diff --git a/src/textual/reactive.py b/src/textual/reactive.py
index 41b240c1b2..bab60a971a 100644
--- a/src/textual/reactive.py
+++ b/src/textual/reactive.py
@@ -105,6 +105,7 @@ class Reactive(Generic[ReactiveType]):
always_update: Call watchers even when the new value equals the old value.
compute: Run compute methods when attribute is changed.
recompose: Compose the widget again when the attribute changes.
+ bindings: Refresh bindings when the reactive changes.
"""
_reactives: ClassVar[dict[str, object]] = {}
@@ -119,6 +120,7 @@ def __init__(
always_update: bool = False,
compute: bool = True,
recompose: bool = False,
+ bindings: bool = False,
) -> None:
self._default = default
self._layout = layout
@@ -127,6 +129,7 @@ def __init__(
self._always_update = always_update
self._run_compute = compute
self._recompose = recompose
+ self._bindings = bindings
self._owner: Type[MessageTarget] | None = None
def __rich_repr__(self) -> rich.repr.Result:
@@ -289,6 +292,9 @@ def __set__(self, obj: Reactable, value: ReactiveType) -> None:
if self._run_compute:
self._compute(obj)
+ if self._bindings:
+ obj.refresh_bindings()
+
# Refresh according to descriptor flags
if self._layout or self._repaint or self._recompose:
obj.refresh(
@@ -367,6 +373,7 @@ class reactive(Reactive[ReactiveType]):
repaint: Perform a repaint on change.
init: Call watchers on initialize (post mount).
always_update: Call watchers even when the new value equals the old value.
+ bindings: Refresh bindings when the reactive changes.
"""
def __init__(
@@ -378,6 +385,7 @@ def __init__(
init: bool = True,
always_update: bool = False,
recompose: bool = False,
+ bindings: bool = False,
) -> None:
super().__init__(
default,
@@ -386,6 +394,7 @@ def __init__(
init=init,
always_update=always_update,
recompose=recompose,
+ bindings=bindings,
)
@@ -396,6 +405,7 @@ class var(Reactive[ReactiveType]):
default: A default value or callable that returns a default.
init: Call watchers on initialize (post mount).
always_update: Call watchers even when the new value equals the old value.
+ bindings: Refresh bindings when the reactive changes.
"""
def __init__(
@@ -403,6 +413,7 @@ def __init__(
default: ReactiveType | Callable[[], ReactiveType],
init: bool = True,
always_update: bool = False,
+ bindings: bool = False,
) -> None:
super().__init__(
default,
@@ -410,6 +421,7 @@ def __init__(
repaint=False,
init=init,
always_update=always_update,
+ bindings=bindings,
)
diff --git a/src/textual/screen.py b/src/textual/screen.py
index 799b945981..515c62fa5d 100644
--- a/src/textual/screen.py
+++ b/src/textual/screen.py
@@ -33,7 +33,7 @@
from ._context import active_message_pump, visible_screen_stack
from ._path import CSSPathType, _css_path_type_as_list, _make_path_object_relative
from ._types import CallbackType
-from .binding import Binding
+from .binding import ActiveBinding, Binding, _Bindings
from .css.match import match
from .css.parse import parse_selectors
from .css.query import NoMatches, QueryType
@@ -179,8 +179,8 @@ class Screen(Generic[ScreenResultType], Widget):
"""
BINDINGS = [
- Binding("tab", "focus_next", "Focus Next", show=False),
- Binding("shift+tab", "focus_previous", "Focus Previous", show=False),
+ Binding("tab", "app.focus_next", "Focus Next", show=False),
+ Binding("shift+tab", "app.focus_previous", "Focus Previous", show=False),
]
def __init__(
@@ -226,6 +226,11 @@ def __init__(
)
"""The signal that is published when the screen's layout is refreshed."""
+ self._bindings_updated = False
+ """Indicates that a binding update was requested."""
+ self.bindings_updated_signal: Signal[Screen] = Signal(self, "bindings_updated")
+ """A signal published when the bindings have been updated"""
+
@property
def is_modal(self) -> bool:
"""Is the screen modal?"""
@@ -264,6 +269,79 @@ def layers(self) -> tuple[str, ...]:
extras.append("_tooltips")
return (*super().layers, *extras)
+ def _watch_focused(self):
+ self.refresh_bindings()
+
+ def _watch_stack_updates(self):
+ self.refresh_bindings()
+
+ def refresh_bindings(self) -> None:
+ """Call to request a refresh of bindings."""
+ self.log.debug("Bindings updated")
+ self._bindings_updated = True
+ self.check_idle()
+
+ @property
+ def _binding_chain(self) -> list[tuple[DOMNode, _Bindings]]:
+ """Binding chain from this screen."""
+ focused = self.focused
+ if focused is not None and focused.loading:
+ focused = None
+ namespace_bindings: list[tuple[DOMNode, _Bindings]]
+
+ if focused is None:
+ namespace_bindings = [
+ (self, self._bindings),
+ (self.app, self.app._bindings),
+ ]
+ else:
+ namespace_bindings = [
+ (node, node._bindings) for node in focused.ancestors_with_self
+ ]
+
+ return namespace_bindings
+
+ @property
+ def _modal_binding_chain(self) -> list[tuple[DOMNode, _Bindings]]:
+ """The binding chain, ignoring everything before the last modal."""
+ binding_chain = self._binding_chain
+ for index, (node, _bindings) in enumerate(binding_chain, 1):
+ if node.is_modal:
+ return binding_chain[:index]
+ return binding_chain
+
+ @property
+ def active_bindings(self) -> dict[str, ActiveBinding]:
+ """Get currently active bindings for this screen.
+
+ If no widget is focused, then app-level bindings are returned.
+ If a widget is focused, then any bindings present in the screen and app are merged and returned.
+
+ This property may be used to inspect current bindings.
+
+ Returns:
+ A map of keys to a tuple containing (namespace, binding, enabled boolean).
+ """
+
+ bindings_map: dict[str, ActiveBinding] = {}
+ for namespace, bindings in self._modal_binding_chain:
+ for key, binding in bindings.keys.items():
+ action_state = self.app._check_action_state(binding.action, namespace)
+ if action_state is False:
+ continue
+ if existing_key_and_binding := bindings_map.get(key):
+ _, existing_binding, _ = existing_key_and_binding
+ if binding.priority and not existing_binding.priority:
+ bindings_map[key] = ActiveBinding(
+ namespace, binding, bool(action_state)
+ )
+ else:
+ bindings_map[key] = ActiveBinding(
+ namespace, binding, bool(action_state)
+ )
+
+ return bindings_map
+
def render(self) -> RenderableType:
"""Render method inherited from widget, used to render the screen's background.
@@ -666,6 +744,10 @@ async def _on_idle(self, event: events.Idle) -> None:
# Check for any widgets marked as 'dirty' (needs a repaint)
event.prevent_default()
+ if self._bindings_updated:
+ self._bindings_updated = False
+ self.bindings_updated_signal.publish(self)
+
if not self.app._batch_count and self.is_current:
if (
self._layout_required
diff --git a/src/textual/widget.py b/src/textual/widget.py
index bacdec9a42..48cbb67cf4 100644
--- a/src/textual/widget.py
+++ b/src/textual/widget.py
@@ -77,7 +77,7 @@
from .layouts.vertical import VerticalLayout
from .message import Message
from .messages import CallbackType
-from .notifications import Notification, SeverityLevel
+from .notifications import SeverityLevel
from .reactive import Reactive
from .render import measure
from .renderables.blank import Blank
@@ -3810,7 +3810,7 @@ def notify(
*,
title: str = "",
severity: SeverityLevel = "information",
- timeout: float = Notification.timeout,
+ timeout: float | None = None,
) -> None:
"""Create a notification.
@@ -3822,9 +3822,21 @@ def notify(
message: The message for the notification.
title: The title for the notification.
severity: The severity of the notification.
- timeout: The timeout (in seconds) for the notification.
+ timeout: The timeout (in seconds) for the notification, or `None` for default.
See [`App.notify`][textual.app.App.notify] for the full
documentation for this method.
"""
- return self.app.notify(message, title=title, severity=severity, timeout=timeout)
+ if timeout is None:
+ return self.app.notify(
+ message,
+ title=title,
+ severity=severity,
+ )
+ else:
+ return self.app.notify(
+ message,
+ title=title,
+ severity=severity,
+ timeout=timeout,
+ )
diff --git a/src/textual/widgets/_footer.py b/src/textual/widgets/_footer.py
index 5f3c60d925..cc84d8f2df 100644
--- a/src/textual/widgets/_footer.py
+++ b/src/textual/widgets/_footer.py
@@ -10,7 +10,9 @@
if TYPE_CHECKING:
from ..app import RenderResult
+ from ..screen import Screen
+from ..binding import Binding
from ..reactive import reactive
from ..widget import Widget
@@ -69,10 +71,9 @@ async def watch_highlight_key(self) -> None:
self.refresh()
def _on_mount(self, _: events.Mount) -> None:
- self.watch(self.screen, "focused", self._bindings_changed)
- self.watch(self.screen, "stack_updates", self._bindings_changed)
+ self.screen.bindings_updated_signal.subscribe(self, self._bindings_changed)
- def _bindings_changed(self, _: Widget | None) -> None:
+ def _bindings_changed(self, _screen: Screen) -> None:
self._key_text = None
self.refresh()
@@ -103,17 +104,20 @@ def _make_key_text(self) -> Text:
description_style = self.get_component_rich_style("footer--description")
bindings = [
- binding
- for (_, binding) in self.app.namespace_bindings.values()
+ (binding, enabled)
+ for (_, binding, enabled) in self.screen.active_bindings.values()
if binding.show
]
- action_to_bindings = defaultdict(list)
- for binding in bindings:
- action_to_bindings[binding.action].append(binding)
+ action_to_bindings: defaultdict[str, list[tuple[Binding, bool]]] = defaultdict(
+ list
+ )
+ for binding, enabled in bindings:
+ action_to_bindings[binding.action].append((binding, enabled))
- for _, bindings in action_to_bindings.items():
- binding = bindings[0]
+ app_focus = self.app.app_focus
+ for _, _bindings in action_to_bindings.items():
+ binding, enabled = _bindings[0]
if binding.key_display is None:
key_display = self.app.get_key_display(binding.key)
if key_display is None:
@@ -127,11 +131,17 @@ def _make_key_text(self) -> Text:
f" {binding.description} ",
highlight_style if hovered else base_style + description_style,
),
- meta={
- "@click": f"app.check_bindings('{binding.key}')",
- "key": binding.key,
- },
+ meta=(
+ {
+ "@click": f"app.check_bindings('{binding.key}')",
+ "key": binding.key,
+ }
+ if enabled and app_focus
+ else {}
+ ),
)
+ if not enabled or not app_focus:
+ key_text.stylize("dim")
text.append_text(key_text)
return text
diff --git a/tests/snapshot_tests/__snapshots__/test_snapshots.ambr b/tests/snapshot_tests/__snapshots__/test_snapshots.ambr
index 338bd6336a..cb3c416f1f 100644
--- a/tests/snapshot_tests/__snapshots__/test_snapshots.ambr
+++ b/tests/snapshot_tests/__snapshots__/test_snapshots.ambr
@@ -18877,6 +18877,165 @@
'''
# ---
+# name: test_dynamic_bindings
+ '''
+
+
+ '''
+# ---
# name: test_example_calculator
'''