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 + ''' + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + BindingsApp + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +  A  A  C  C  + + + + + ''' +# --- # name: test_example_calculator ''' diff --git a/tests/snapshot_tests/snapshot_apps/dynamic_bindings.py b/tests/snapshot_tests/snapshot_apps/dynamic_bindings.py new file mode 100644 index 0000000000..373b46696c --- /dev/null +++ b/tests/snapshot_tests/snapshot_apps/dynamic_bindings.py @@ -0,0 +1,41 @@ +from __future__ import annotations +from textual.app import App, ComposeResult +from textual.widgets import Footer + + +class BindingsApp(App): + """Check dynamic actions are displayed in the footer.""" + + BINDINGS = [ + ("a", "a", "A"), + ("b", "b", "B"), + ("c", "c", "C"), + ] + + def compose(self) -> ComposeResult: + yield Footer() + + def check_action(self, action: str, parameters: tuple[object, ...]) -> bool | None: + if action == "b": + # A is disabled (not show) + return False + if action == "c": + # B is disabled (shown grayed out) + return None + # Everything else is fine + return True + + def action_a(self): + self.bell() + + def action_b(self): + # If dynamic actions is working we won't get here + 1 / 0 + + def action_c(self): + # If dynamic actions is working we won't get here + 1 / 0 + + +if __name__ == "__main__": + BindingsApp().run() diff --git a/tests/snapshot_tests/test_snapshots.py b/tests/snapshot_tests/test_snapshots.py index f9650a016f..449d239533 100644 --- a/tests/snapshot_tests/test_snapshots.py +++ b/tests/snapshot_tests/test_snapshots.py @@ -1253,3 +1253,9 @@ def test_button_with_multiline_label(snap_compare): def test_margin_multiple(snap_compare): assert snap_compare(SNAPSHOT_APPS_DIR / "margin_multiple.py") + + +def test_dynamic_bindings(snap_compare): + assert snap_compare( + SNAPSHOT_APPS_DIR / "dynamic_bindings.py", press=["a", "b", "c"] + ) diff --git a/tests/test_actions.py b/tests/test_actions.py index 1392e73719..e8812ce227 100644 --- a/tests/test_actions.py +++ b/tests/test_actions.py @@ -8,20 +8,24 @@ @pytest.mark.parametrize( - ("action_string", "expected_name", "expected_arguments"), + ("action_string", "expected_namespace", "expected_name", "expected_arguments"), [ - ("spam", "spam", ()), - ("hypothetical_action()", "hypothetical_action", ()), - ("another_action(1)", "another_action", (1,)), - ("foo(True, False)", "foo", (True, False)), - ("foo.bar.baz(3, 3.15, 'Python')", "foo.bar.baz", (3, 3.15, "Python")), - ("m1234.n5678(None, [1, 2])", "m1234.n5678", (None, [1, 2])), + ("spam", "", "spam", ()), + ("hypothetical_action()", "", "hypothetical_action", ()), + ("another_action(1)", "", "another_action", (1,)), + ("foo(True, False)", "", "foo", (True, False)), + ("foo.bar.baz(3, 3.15, 'Python')", "foo.bar", "baz", (3, 3.15, "Python")), + ("m1234.n5678(None, [1, 2])", "m1234", "n5678", (None, [1, 2])), ], ) def test_parse_action( - action_string: str, expected_name: str, expected_arguments: tuple[Any] + action_string: str, + expected_namespace: str, + expected_name: str, + expected_arguments: tuple[Any], ) -> None: - action_name, action_arguments = parse(action_string) + namespace, action_name, action_arguments = parse(action_string) + assert namespace == expected_namespace assert action_name == expected_name assert action_arguments == expected_arguments @@ -44,7 +48,7 @@ def test_nested_and_convoluted_tuple_arguments( action_string: str, expected_arguments: tuple[Any] ) -> None: """Test that tuple arguments are parsed correctly.""" - _, args = parse(action_string) + _, _, args = parse(action_string) assert args == expected_arguments @@ -67,7 +71,7 @@ def test_parse_action_nested_special_character_arguments( See also: https://github.com/Textualize/textual/issues/2088 """ - _, args = parse(action_string) + _, _, args = parse(action_string) assert args == expected_arguments diff --git a/tests/test_dynamic_bindings.py b/tests/test_dynamic_bindings.py new file mode 100644 index 0000000000..05b9baecd3 --- /dev/null +++ b/tests/test_dynamic_bindings.py @@ -0,0 +1,33 @@ +from __future__ import annotations + +from textual.app import App + + +async def test_dynamic_disabled(): + """Check we can dynamically disable bindings.""" + actions = [] + + class DynamicApp(App): + BINDINGS = [ + ("a", "register('a')", "A"), + ("b", "register('b')", "B"), + ("c", "register('c')", "B"), + ] + + def action_register(self, key: str) -> None: + actions.append(key) + + def check_action( + self, action: str, parameters: tuple[object, ...] + ) -> bool | None: + if action == "register": + if parameters == ("b",): + return False + if parameters == ("c",): + return None + return True + + app = DynamicApp() + async with app.run_test() as pilot: + await pilot.press("a", "b", "c") + assert actions == ["a"] diff --git a/tests/test_screen_modes.py b/tests/test_screen_modes.py index 2c7cdcbb66..85d1a8b358 100644 --- a/tests/test_screen_modes.py +++ b/tests/test_screen_modes.py @@ -11,9 +11,7 @@ UnknownModeError, ) from textual.screen import ModalScreen, Screen -from textual.widgets import Footer, Header, Label, RichLog - -FRUITS = cycle("apple mango strawberry banana peach pear melon watermelon".split()) +from textual.widgets import Footer, Header, Label class ScreenBindingsMixin(Screen[None]): @@ -21,7 +19,7 @@ class ScreenBindingsMixin(Screen[None]): ("1", "one", "Mode 1"), ("2", "two", "Mode 2"), ("p", "push", "Push rnd scrn"), - ("o", "pop_screen", "Pop"), + ("o", "app.pop_screen", "Pop"), ("r", "remove", "Remove mode 1"), ] @@ -56,14 +54,12 @@ class FruitModal(ModalScreen[str], ScreenBindingsMixin): BINDINGS = [("d", "dismiss_fruit", "Dismiss")] def compose(self) -> ComposeResult: + FRUITS = cycle( + "apple mango strawberry banana peach pear melon watermelon".split() + ) yield Label(next(FRUITS)) -class FruitsScreen(ScreenBindingsMixin): - def compose(self) -> ComposeResult: - yield RichLog() - - @pytest.fixture def ModesApp(): class ModesApp(App[None]):