From 01308f878c335e09f7ac98c45b93f4256d644718 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Thu, 16 May 2024 20:49:33 +0100 Subject: [PATCH 01/30] dynamic bindings --- src/textual/actions.py | 8 ++- src/textual/app.py | 99 ++++++++++++++++++++++------------ src/textual/binding.py | 2 + src/textual/dom.py | 18 +++++++ src/textual/widgets/_footer.py | 2 + 5 files changed, 94 insertions(+), 35 deletions(-) diff --git a/src/textual/actions.py b/src/textual/actions.py index 9365bb268b..61f6dd6fa8 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[Any, ...]]" """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=128) 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..f96d116494 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 @@ -81,7 +78,7 @@ from ._types import AnimationLevel from ._wait import wait_for_idle from ._worker_manager import WorkerManager -from .actions import ActionParseResult, SkipAction +from .actions import SkipAction from .await_remove import AwaitRemove from .binding import Binding, BindingType, _Bindings from .command import CommandPalette, Provider @@ -225,14 +222,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") @@ -640,6 +629,11 @@ def __init__( # Size of previous inline update self._previous_inline_height: int | None = None + self._bindings_updated = False + """Indicates that a binding update was requested.""" + self.bindings_updated_signal: Signal[None] = Signal(self, "bindings_updated") + """A signal published when the bindings have been updated""" + def validate_title(self, title: Any) -> str: """Make sure the title is set to a string.""" return str(title) @@ -870,6 +864,8 @@ def namespace_bindings(self) -> dict[str, tuple[DOMNode, Binding]]: bindings_map: dict[str, tuple[DOMNode, Binding]] = {} for namespace, bindings in self._binding_chain: for key, binding in bindings.keys.items(): + if not self.app._check_action_enabled(binding.action, namespace): + continue if existing_key_and_binding := bindings_map.get(key): _, existing_binding = existing_key_and_binding if binding.priority and not existing_binding.priority: @@ -879,6 +875,12 @@ def namespace_bindings(self) -> dict[str, tuple[DOMNode, Binding]]: return bindings_map + def refresh_bindings(self) -> None: + """Call to request a refresh of bindings.""" + self.log.debug("Bindings updated") + self._bindings_updated = True + self.check_idle() + def _set_active(self) -> None: """Set this app to be the currently active app.""" active_app.set(self) @@ -1037,6 +1039,11 @@ def size(self) -> Size: width, height = self.console.size return Size(width, height) + async def _on_idle(self, event: events.Idle) -> None: + if self._bindings_updated: + self._bindings_updated = False + self.bindings_updated_signal.publish(None) + def _get_inline_height(self) -> int: """Get the inline height (height when in inline mode). @@ -3026,10 +3033,46 @@ 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[Any, ...]]: + """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) + return action_target or default_namespace, action_name, params + + def _check_action_enabled(self, action: str, default_namespace: DOMNode) -> bool: + """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, + action: str, + default_namespace: DOMNode | None = None, ) -> bool: """Perform an [action](/guide/actions). @@ -3043,25 +3086,15 @@ async def run_action( Returns: 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 - else: - action_target = default_namespace if default_namespace is not None else self - action_name = target - 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 + action_target, action_name, params = self._parse_action( + action, self if default_namespace is None else default_namespace + ) + + 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 @@ -3089,7 +3122,7 @@ async def _dispatch_action( private_method = getattr(namespace, f"_action_{action_name}", None) if callable(private_method): await invoke(private_method, *params) - return True + public_method = getattr(namespace, f"action_{action_name}", None) if callable(public_method): await invoke(public_method, *params) diff --git a/src/textual/binding.py b/src/textual/binding.py index 409fe9de56..adb6304b73 100644 --- a/src/textual/binding.py +++ b/src/textual/binding.py @@ -44,6 +44,8 @@ class Binding: """Description of action.""" show: bool = True """Show the action in Footer, or False to hide.""" + disabled: bool = False + """Binding is disabled (shown but not functional).""" key_display: str | None = None """How the key should be shown in footer.""" priority: bool = False diff --git a/src/textual/dom.py b/src/textual/dom.py index 3dedcce317..cc69b52a63 100644 --- a/src/textual/dom.py +++ b/src/textual/dom.py @@ -1482,6 +1482,24 @@ def refresh( ) -> Self: return self + def check_action(self, action: str, parameters: tuple[object, ...]) -> bool: + """Check whether an action is enabled. + + Implement this method to add logic for dynamic bindings. + + Args: + action: The name of an action. + action_parameters: A tuple of any action parameters. + + Returns: + `True` if the action is enabled, `False` if it is not. + """ + return True + + def refresh_bindings(self) -> None: + """Call when the bindings state may have changed.""" + self.call_later(self.app.refresh_bindings) + async def action_toggle(self, attribute_name: str) -> None: """Toggle an attribute on the node. diff --git a/src/textual/widgets/_footer.py b/src/textual/widgets/_footer.py index 5f3c60d925..d5005f33d7 100644 --- a/src/textual/widgets/_footer.py +++ b/src/textual/widgets/_footer.py @@ -71,8 +71,10 @@ async def watch_highlight_key(self) -> None: 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.app.bindings_updated_signal.subscribe(self, self._bindings_changed) def _bindings_changed(self, _: Widget | None) -> None: + print("!!!") self._key_text = None self.refresh() From aa6e1440e1613ec3a2a9fbcebce334d06dea5ffb Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Thu, 16 May 2024 21:10:31 +0100 Subject: [PATCH 02/30] and disabled --- src/textual/actions.py | 2 +- src/textual/app.py | 19 +++++++++++-------- src/textual/dom.py | 6 ++++-- src/textual/screen.py | 6 ++++++ src/textual/widgets/_footer.py | 21 ++++++++++++--------- 5 files changed, 34 insertions(+), 20 deletions(-) diff --git a/src/textual/actions.py b/src/textual/actions.py index 61f6dd6fa8..f995692c3a 100644 --- a/src/textual/actions.py +++ b/src/textual/actions.py @@ -7,7 +7,7 @@ from typing_extensions import TypeAlias -ActionParseResult: TypeAlias = "tuple[str, str, tuple[Any, ...]]" +ActionParseResult: TypeAlias = "tuple[str, str, tuple[object, ...]]" """An action is its name and the arbitrary tuple of its arguments.""" diff --git a/src/textual/app.py b/src/textual/app.py index f96d116494..c49d983ddd 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -849,7 +849,7 @@ def focused(self) -> Widget | None: return focused @property - def namespace_bindings(self) -> dict[str, tuple[DOMNode, Binding]]: + def namespace_bindings(self) -> dict[str, tuple[DOMNode, Binding, bool]]: """Get currently active bindings. If no widget is focused, then app-level bindings are returned. @@ -861,17 +861,18 @@ def namespace_bindings(self) -> dict[str, tuple[DOMNode, Binding]]: A map of keys to a tuple containing the DOMNode and Binding that key corresponds to. """ - bindings_map: dict[str, tuple[DOMNode, Binding]] = {} + bindings_map: dict[str, tuple[DOMNode, Binding, bool]] = {} for namespace, bindings in self._binding_chain: for key, binding in bindings.keys.items(): - if not self.app._check_action_enabled(binding.action, namespace): + 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 + _, existing_binding, _ = existing_key_and_binding if binding.priority and not existing_binding.priority: - bindings_map[key] = (namespace, binding) + bindings_map[key] = (namespace, binding, bool(action_state)) else: - bindings_map[key] = (namespace, binding) + bindings_map[key] = (namespace, binding, bool(action_state)) return bindings_map @@ -3055,7 +3056,9 @@ def _parse_action( action_target = getattr(self, destination) return action_target or default_namespace, action_name, params - def _check_action_enabled(self, action: str, default_namespace: DOMNode) -> bool: + def _check_action_state( + self, action: str, default_namespace: DOMNode + ) -> bool | None: """Check if an action is enabled. Args: @@ -3122,7 +3125,7 @@ async def _dispatch_action( private_method = getattr(namespace, f"_action_{action_name}", None) if callable(private_method): await invoke(private_method, *params) - + return True public_method = getattr(namespace, f"action_{action_name}", None) if callable(public_method): await invoke(public_method, *params) diff --git a/src/textual/dom.py b/src/textual/dom.py index cc69b52a63..212fc5bad0 100644 --- a/src/textual/dom.py +++ b/src/textual/dom.py @@ -1482,7 +1482,7 @@ def refresh( ) -> Self: return self - def check_action(self, action: str, parameters: tuple[object, ...]) -> bool: + 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 bindings. @@ -1492,7 +1492,9 @@ def check_action(self, action: str, parameters: tuple[object, ...]) -> bool: action_parameters: A tuple of any action parameters. Returns: - `True` if the action is enabled, `False` if it is not. + `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 diff --git a/src/textual/screen.py b/src/textual/screen.py index 799b945981..b270b02c49 100644 --- a/src/textual/screen.py +++ b/src/textual/screen.py @@ -264,6 +264,12 @@ def layers(self) -> tuple[str, ...]: extras.append("_tooltips") return (*super().layers, *extras) + def _watch_focused(self): + self.app.refresh_bindings() + + def _watch_stack_updates(self): + self.app.refresh_bindings() + def render(self) -> RenderableType: """Render method inherited from widget, used to render the screen's background. diff --git a/src/textual/widgets/_footer.py b/src/textual/widgets/_footer.py index d5005f33d7..8a43793015 100644 --- a/src/textual/widgets/_footer.py +++ b/src/textual/widgets/_footer.py @@ -11,6 +11,7 @@ if TYPE_CHECKING: from ..app import RenderResult +from ..binding import Binding from ..reactive import reactive from ..widget import Widget @@ -69,8 +70,6 @@ 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.app.bindings_updated_signal.subscribe(self, self._bindings_changed) def _bindings_changed(self, _: Widget | None) -> None: @@ -105,17 +104,19 @@ 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.app.namespace_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] + 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: @@ -134,6 +135,8 @@ def _make_key_text(self) -> Text: "key": binding.key, }, ) + if not enabled: + key_text.stylize("dim") text.append_text(key_text) return text From 2e01ab3553090e17c446b28af9ab225ddcb20ce0 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Thu, 16 May 2024 21:15:48 +0100 Subject: [PATCH 03/30] test fixes --- tests/test_actions.py | 26 +++++++++++++++----------- 1 file changed, 15 insertions(+), 11 deletions(-) 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 From 13ee819cd7416aa299bfbfc4dd244c28d18e6831 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Thu, 16 May 2024 21:17:29 +0100 Subject: [PATCH 04/30] no need --- src/textual/binding.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/textual/binding.py b/src/textual/binding.py index adb6304b73..409fe9de56 100644 --- a/src/textual/binding.py +++ b/src/textual/binding.py @@ -44,8 +44,6 @@ class Binding: """Description of action.""" show: bool = True """Show the action in Footer, or False to hide.""" - disabled: bool = False - """Binding is disabled (shown but not functional).""" key_display: str | None = None """How the key should be shown in footer.""" priority: bool = False From 403817135aa3289c5c2bd9e0016e3b143025a534 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Thu, 16 May 2024 21:19:21 +0100 Subject: [PATCH 05/30] changelog --- CHANGELOG.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 70e5c7cff6..d92f122aef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,17 @@ 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 bindings, implement `DOMNode.check_action` https://github.com/Textualize/textual/pull/4516 + +### Changed + +- Actions will no longer check app if they are not handled https://github.com/Textualize/textual/pull/4516 + ## [0.60.1] - 2024-05-15 ### Fixed From bb7909cb013a7c46aabaaf79feb11bef567a07f8 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Thu, 16 May 2024 21:20:36 +0100 Subject: [PATCH 06/30] changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index d92f122aef..e39aae4c6c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1962,6 +1962,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 From 4eacec2deff5bba6dab6d71852cc554bb2bd28ef Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Thu, 16 May 2024 21:25:02 +0100 Subject: [PATCH 07/30] remove debug, prevent clicking disabled actions --- src/textual/widgets/_footer.py | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/src/textual/widgets/_footer.py b/src/textual/widgets/_footer.py index 8a43793015..be7f6e2395 100644 --- a/src/textual/widgets/_footer.py +++ b/src/textual/widgets/_footer.py @@ -73,7 +73,6 @@ def _on_mount(self, _: events.Mount) -> None: self.app.bindings_updated_signal.subscribe(self, self._bindings_changed) def _bindings_changed(self, _: Widget | None) -> None: - print("!!!") self._key_text = None self.refresh() @@ -130,10 +129,14 @@ 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 + else {} + ), ) if not enabled: key_text.stylize("dim") From ce964ea0c275483f9be91519c5ece2a94013ad75 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Thu, 16 May 2024 21:30:33 +0100 Subject: [PATCH 08/30] disable footer on app focus --- src/textual/app.py | 2 ++ src/textual/widgets/_footer.py | 5 +++-- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/src/textual/app.py b/src/textual/app.py index c49d983ddd..2e0c67ecdc 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -3201,11 +3201,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.bindings_updated_signal.publish(None) 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.bindings_updated_signal.publish(None) def _detach_from_dom(self, widgets: list[Widget]) -> list[Widget]: """Detach a list of widgets from the DOM. diff --git a/src/textual/widgets/_footer.py b/src/textual/widgets/_footer.py index be7f6e2395..569be8fbdf 100644 --- a/src/textual/widgets/_footer.py +++ b/src/textual/widgets/_footer.py @@ -114,6 +114,7 @@ def _make_key_text(self) -> Text: for binding, enabled in bindings: action_to_bindings[binding.action].append((binding, enabled)) + app_focus = self.app.app_focus for _, _bindings in action_to_bindings.items(): binding, enabled = _bindings[0] if binding.key_display is None: @@ -134,11 +135,11 @@ def _make_key_text(self) -> Text: "@click": f"app.check_bindings('{binding.key}')", "key": binding.key, } - if enabled + if enabled and app_focus else {} ), ) - if not enabled: + if not enabled or not app_focus: key_text.stylize("dim") text.append_text(key_text) return text From cceb6228e1dcaa37cc4b8ac261571e72a3a13a3d Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Thu, 16 May 2024 21:33:00 +0100 Subject: [PATCH 09/30] increase cache --- src/textual/actions.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/textual/actions.py b/src/textual/actions.py index f995692c3a..a7ff7bbde2 100644 --- a/src/textual/actions.py +++ b/src/textual/actions.py @@ -22,7 +22,7 @@ class ActionError(Exception): re_action_args = re.compile(r"([\w\.]+)\((.*)\)") -@lru_cache(maxsize=128) +@lru_cache(maxsize=1024) def parse(action: str) -> ActionParseResult: """Parses an action string. From e7ef05de4b1c2fe7fa1d315c2295a3db99786c17 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 17 May 2024 09:31:07 +0100 Subject: [PATCH 10/30] fix screen bindings --- src/textual/app.py | 6 +++++- src/textual/screen.py | 4 ++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/src/textual/app.py b/src/textual/app.py index 2e0c67ecdc..b86e51be2d 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -3054,7 +3054,11 @@ def _parse_action( if destination not in self._action_targets: raise ActionError(f"Action namespace {destination} is not known") action_target = getattr(self, destination) - return action_target or default_namespace, action_name, params + return ( + (default_namespace if action_target is None else action_target), + action_name, + params, + ) def _check_action_state( self, action: str, default_namespace: DOMNode diff --git a/src/textual/screen.py b/src/textual/screen.py index b270b02c49..6086b495b7 100644 --- a/src/textual/screen.py +++ b/src/textual/screen.py @@ -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__( From 9ee9ca5a7423f2110bf18e4bee844fca10fb62a2 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 17 May 2024 10:25:24 +0100 Subject: [PATCH 11/30] bindings refresh on screen --- src/textual/_event_broker.py | 20 +++++++++++---- src/textual/app.py | 46 +++++++++++++--------------------- src/textual/dom.py | 2 +- src/textual/screen.py | 19 ++++++++++++-- src/textual/widgets/_footer.py | 2 +- 5 files changed, 52 insertions(+), 37 deletions(-) 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/app.py b/src/textual/app.py index b86e51be2d..f8fbba63da 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -78,7 +78,7 @@ from ._types import AnimationLevel from ._wait import wait_for_idle from ._worker_manager import WorkerManager -from .actions import SkipAction +from .actions import ActionParseResult, SkipAction from .await_remove import AwaitRemove from .binding import Binding, BindingType, _Bindings from .command import CommandPalette, Provider @@ -629,11 +629,6 @@ def __init__( # Size of previous inline update self._previous_inline_height: int | None = None - self._bindings_updated = False - """Indicates that a binding update was requested.""" - self.bindings_updated_signal: Signal[None] = Signal(self, "bindings_updated") - """A signal published when the bindings have been updated""" - def validate_title(self, title: Any) -> str: """Make sure the title is set to a string.""" return str(title) @@ -876,12 +871,6 @@ def namespace_bindings(self) -> dict[str, tuple[DOMNode, Binding, bool]]: return bindings_map - def refresh_bindings(self) -> None: - """Call to request a refresh of bindings.""" - self.log.debug("Bindings updated") - self._bindings_updated = True - self.check_idle() - def _set_active(self) -> None: """Set this app to be the currently active app.""" active_app.set(self) @@ -1040,11 +1029,6 @@ def size(self) -> Size: width, height = self.console.size return Size(width, height) - async def _on_idle(self, event: events.Idle) -> None: - if self._bindings_updated: - self._bindings_updated = False - self.bindings_updated_signal.publish(None) - def _get_inline_height(self) -> int: """Get the inline height (height when in inline mode). @@ -3036,7 +3020,7 @@ async def on_event(self, event: events.Event) -> None: def _parse_action( self, action: str, default_namespace: DOMNode - ) -> tuple[DOMNode, str, tuple[Any, ...]]: + ) -> tuple[DOMNode, str, tuple[object, ...]]: """Parse an action. Args: @@ -3078,7 +3062,7 @@ def _check_action_state( async def run_action( self, - action: str, + action: str | ActionParseResult, default_namespace: DOMNode | None = None, ) -> bool: """Perform an [action](/guide/actions). @@ -3093,10 +3077,14 @@ async def run_action( Returns: True if the event has been handled. """ - - action_target, action_name, params = self._parse_action( - action, self if default_namespace is None else default_namespace - ) + if isinstance(action, str): + action_target, action_name, params = self._parse_action( + action, self if default_namespace is None else default_namespace + ) + else: + # assert isinstance(action, tuple) + _, action_name, params = action + action_target = self if action_target.check_action(action_name, params): return await self._dispatch_action(action_target, action_name, params) @@ -3144,7 +3132,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. @@ -3166,8 +3154,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: @@ -3205,13 +3195,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.bindings_updated_signal.publish(None) + 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.bindings_updated_signal.publish(None) + self.screen.refresh_bindings() def _detach_from_dom(self, widgets: list[Widget]) -> list[Widget]: """Detach a list of widgets from the DOM. diff --git a/src/textual/dom.py b/src/textual/dom.py index 212fc5bad0..14afb94319 100644 --- a/src/textual/dom.py +++ b/src/textual/dom.py @@ -1500,7 +1500,7 @@ def check_action(self, action: str, parameters: tuple[object, ...]) -> bool | No def refresh_bindings(self) -> None: """Call when the bindings state may have changed.""" - self.call_later(self.app.refresh_bindings) + 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/screen.py b/src/textual/screen.py index 6086b495b7..1bed4b7b2e 100644 --- a/src/textual/screen.py +++ b/src/textual/screen.py @@ -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?""" @@ -265,10 +270,16 @@ def layers(self) -> tuple[str, ...]: return (*super().layers, *extras) def _watch_focused(self): - self.app.refresh_bindings() + self.refresh_bindings() def _watch_stack_updates(self): - self.app.refresh_bindings() + 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() def render(self) -> RenderableType: """Render method inherited from widget, used to render the screen's background. @@ -672,6 +683,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/widgets/_footer.py b/src/textual/widgets/_footer.py index 569be8fbdf..a21945d085 100644 --- a/src/textual/widgets/_footer.py +++ b/src/textual/widgets/_footer.py @@ -70,7 +70,7 @@ async def watch_highlight_key(self) -> None: self.refresh() def _on_mount(self, _: events.Mount) -> None: - self.app.bindings_updated_signal.subscribe(self, self._bindings_changed) + self.screen.bindings_updated_signal.subscribe(self, self._bindings_changed) def _bindings_changed(self, _: Widget | None) -> None: self._key_text = None From 63eea86f064d0528bcf7ea6dc9df76bdc8e0f9e5 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 17 May 2024 10:39:42 +0100 Subject: [PATCH 12/30] typing --- src/textual/widgets/_footer.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/textual/widgets/_footer.py b/src/textual/widgets/_footer.py index a21945d085..9021423c08 100644 --- a/src/textual/widgets/_footer.py +++ b/src/textual/widgets/_footer.py @@ -10,6 +10,7 @@ if TYPE_CHECKING: from ..app import RenderResult + from ..screen import Screen from ..binding import Binding from ..reactive import reactive @@ -72,7 +73,7 @@ async def watch_highlight_key(self) -> None: def _on_mount(self, _: events.Mount) -> None: self.screen.bindings_updated_signal.subscribe(self, self._bindings_changed) - def _bindings_changed(self, _: Widget | None) -> None: + def _bindings_changed(self, _: Screen) -> None: self._key_text = None self.refresh() From 29077360d25b9ea7256eaadaa321701ece83c5ff Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 17 May 2024 12:14:28 +0100 Subject: [PATCH 13/30] fix for new binding mechanism --- CHANGELOG.md | 2 +- examples/five_by_five.py | 4 ++-- tests/test_screen_modes.py | 14 +++++--------- 3 files changed, 8 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e39aae4c6c..46a42a392d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,7 +14,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). ### Changed -- Actions will no longer check app if they are not handled https://github.com/Textualize/textual/pull/4516 +- Breaking change: Actions (as used in bindings) will no longer check the app if they are unhandled https://github.com/Textualize/textual/pull/4516 ## [0.60.1] - 2024-05-15 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/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]): From 78ee84335a29f883b04d56ff3f0f6cf6cbdabbff Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 17 May 2024 12:54:30 +0100 Subject: [PATCH 14/30] disabled actions --- src/textual/reactive.py | 12 ++++++++++++ tests/test_dynamic_bindings.py | 31 +++++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+) create mode 100644 tests/test_dynamic_bindings.py 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/tests/test_dynamic_bindings.py b/tests/test_dynamic_bindings.py new file mode 100644 index 0000000000..575fbc2971 --- /dev/null +++ b/tests/test_dynamic_bindings.py @@ -0,0 +1,31 @@ +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"] From c2eb3e84fd1078cb2f638c056ae279c4271d9ff1 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 17 May 2024 13:02:04 +0100 Subject: [PATCH 15/30] snapshot tests --- .../snapshot_apps/dynamic_bindings.py | 35 +++++++++++++++++++ tests/snapshot_tests/test_snapshots.py | 4 +++ 2 files changed, 39 insertions(+) create mode 100644 tests/snapshot_tests/snapshot_apps/dynamic_bindings.py 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..08000b6497 --- /dev/null +++ b/tests/snapshot_tests/snapshot_apps/dynamic_bindings.py @@ -0,0 +1,35 @@ +from textual.app import App, ComposeResult +from textual.widgets import Footer + + +class BindingsApp(App): + 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": + # A is disabled (shown grayed out) + return None + return True + + def action_a(self): + self.bell() + + def action_b(self): + 1 / 0 + + def action_c(self): + 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..2c7a3dc782 100644 --- a/tests/snapshot_tests/test_snapshots.py +++ b/tests/snapshot_tests/test_snapshots.py @@ -1253,3 +1253,7 @@ 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") From d156b1b4f1cf164d6e89353729fa7aa61ae08516 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 17 May 2024 13:03:21 +0100 Subject: [PATCH 16/30] test comments --- tests/snapshot_tests/snapshot_apps/dynamic_bindings.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/tests/snapshot_tests/snapshot_apps/dynamic_bindings.py b/tests/snapshot_tests/snapshot_apps/dynamic_bindings.py index 08000b6497..aafb134749 100644 --- a/tests/snapshot_tests/snapshot_apps/dynamic_bindings.py +++ b/tests/snapshot_tests/snapshot_apps/dynamic_bindings.py @@ -3,6 +3,8 @@ class BindingsApp(App): + """Check dynamic actions are displayed in the footer.""" + BINDINGS = [ ("a", "a", "A"), ("b", "b", "B"), @@ -17,17 +19,20 @@ def check_action(self, action: str, parameters: tuple[object, ...]) -> bool | No # A is disabled (not show) return False if action == "c": - # A is disabled (shown grayed out) + # 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 From c3bc94be7cf51edd9509c8afc7a613d86bea5b53 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 17 May 2024 13:08:03 +0100 Subject: [PATCH 17/30] changelog --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 46a42a392d..1ea2d8af3d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). ### Added -- Added dynamic bindings, implement `DOMNode.check_action` https://github.com/Textualize/textual/pull/4516 +- Added dynamic binding via `DOMNode.check_action` https://github.com/Textualize/textual/pull/4516 ### Changed From 8a717351b2e3e369da9417907f82018d6682f0a5 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 17 May 2024 13:15:25 +0100 Subject: [PATCH 18/30] changelog --- CHANGELOG.md | 1 + src/textual/app.py | 6 ++++-- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1ea2d8af3d..e96882a624 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). ### 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 ### Changed diff --git a/src/textual/app.py b/src/textual/app.py index f8fbba63da..3b737a5ed8 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -455,7 +455,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) @@ -3037,7 +3037,9 @@ def _parse_action( if destination: if destination not in self._action_targets: raise ActionError(f"Action namespace {destination} is not known") - action_target = getattr(self, destination) + 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, From e0942cf499c1812ea049749bc619a650ed7cc348 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 17 May 2024 17:33:00 +0100 Subject: [PATCH 19/30] dynamic actions docs --- docs/examples/guide/actions/actions06.py | 48 +++++++++++++ docs/examples/guide/actions/actions06.tcss | 4 ++ docs/examples/guide/actions/actions07.py | 44 ++++++++++++ docs/guide/actions.md | 78 ++++++++++++++++++++++ src/textual/dom.py | 9 ++- 5 files changed, 181 insertions(+), 2 deletions(-) create mode 100644 docs/examples/guide/actions/actions06.py create mode 100644 docs/examples/guide/actions/actions06.tcss create mode 100644 docs/examples/guide/actions/actions07.py 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..a03f7252e1 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 actions, but the [footer][textual.widgets.Footer] would still display the keys even if they wouldn't do anything. +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 he action running. + +Let's write an app to put this in to 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 show 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 they key in the footer rather than hides it. + 3. Returning `None` disables they key in the footer rather than hides it. + +=== "actions07.tcss" + + ```css title="actions06.tcss" + --8<-- "docs/examples/guide/actions/actions07.tcss" + ``` + +=== "Output" + + ```{.textual path="docs/examples/guide/actions/actions07.py"} + ``` + +Note how the logic is the same but we don't need to excplictly 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/src/textual/dom.py b/src/textual/dom.py index 14afb94319..bc42f0b6ce 100644 --- a/src/textual/dom.py +++ b/src/textual/dom.py @@ -1485,7 +1485,7 @@ def refresh( 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 bindings. + Implement this method to add logic for [dynamic actions](/guide/actions#dynamic-actions) / bindings. Args: action: The name of an action. @@ -1499,7 +1499,12 @@ def check_action(self, action: str, parameters: tuple[object, ...]) -> bool | No return True def refresh_bindings(self) -> None: - """Call when the bindings state may have changed.""" + """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: From 01f36c0d00612fd3eaed987487fc2a1be7ffe5f0 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 17 May 2024 17:40:58 +0100 Subject: [PATCH 20/30] typing fixes --- CHANGELOG.md | 3 ++- tests/snapshot_tests/snapshot_apps/dynamic_bindings.py | 1 + tests/test_dynamic_bindings.py | 2 ++ 3 files changed, 5 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e96882a624..4ec60c3198 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,10 +12,11 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - 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 https://github.com/Textualize/textual/pull/4516 +- 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 ## [0.60.1] - 2024-05-15 diff --git a/tests/snapshot_tests/snapshot_apps/dynamic_bindings.py b/tests/snapshot_tests/snapshot_apps/dynamic_bindings.py index aafb134749..373b46696c 100644 --- a/tests/snapshot_tests/snapshot_apps/dynamic_bindings.py +++ b/tests/snapshot_tests/snapshot_apps/dynamic_bindings.py @@ -1,3 +1,4 @@ +from __future__ import annotations from textual.app import App, ComposeResult from textual.widgets import Footer diff --git a/tests/test_dynamic_bindings.py b/tests/test_dynamic_bindings.py index 575fbc2971..05b9baecd3 100644 --- a/tests/test_dynamic_bindings.py +++ b/tests/test_dynamic_bindings.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from textual.app import App From ce93741ff60ef1c55bcc92b405cb839c535d099b Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 17 May 2024 17:44:02 +0100 Subject: [PATCH 21/30] typos --- docs/guide/actions.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/guide/actions.md b/docs/guide/actions.md index a03f7252e1..c7054c9a61 100644 --- a/docs/guide/actions.md +++ b/docs/guide/actions.md @@ -131,7 +131,7 @@ There may be situations where an action is temporarily unavailable due to some i 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 actions, but the [footer][textual.widgets.Footer] would still display the keys even if they wouldn't do anything. +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. @@ -172,7 +172,7 @@ The actions above call [`refresh_bindings`][textual.dom.DOMNode.refresh_bindings 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 show what the footer will show if we return `None` from `check_action` (rather than `False`): +We will also demonstrate what the footer will show if we return `None` from `check_action` (rather than `False`): === "actions07.py" @@ -196,7 +196,7 @@ We will also show what the footer will show if we return `None` from `check_acti ```{.textual path="docs/examples/guide/actions/actions07.py"} ``` -Note how the logic is the same but we don't need to excplictly call [`refresh_bindings`][textual.dom.DOMNode.refresh_bindings]. +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. From c9828e5c2e6097113c448d484ccfbcfc99dec6f6 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 17 May 2024 17:55:32 +0100 Subject: [PATCH 22/30] snapshots --- docs/guide/actions.md | 2 +- .../__snapshots__/test_snapshots.ambr | 159 ++++++++++++++++++ 2 files changed, 160 insertions(+), 1 deletion(-) diff --git a/docs/guide/actions.md b/docs/guide/actions.md index c7054c9a61..e82e953d15 100644 --- a/docs/guide/actions.md +++ b/docs/guide/actions.md @@ -123,7 +123,7 @@ Textual supports the following action namespaces: 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 +## Dynamic actions !!! tip "Added in version 0.61.0" 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 ''' From 8a3e386f3f88926ffb8a718269c2f0883a688712 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 17 May 2024 17:58:40 +0100 Subject: [PATCH 23/30] better test --- tests/snapshot_tests/test_snapshots.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/snapshot_tests/test_snapshots.py b/tests/snapshot_tests/test_snapshots.py index 2c7a3dc782..449d239533 100644 --- a/tests/snapshot_tests/test_snapshots.py +++ b/tests/snapshot_tests/test_snapshots.py @@ -1256,4 +1256,6 @@ def test_margin_multiple(snap_compare): def test_dynamic_bindings(snap_compare): - assert snap_compare(SNAPSHOT_APPS_DIR / "dynamic_bindings.py") + assert snap_compare( + SNAPSHOT_APPS_DIR / "dynamic_bindings.py", press=["a", "b", "c"] + ) From 5e898baa1b3873c8efbfa16d00438fad2deca38c Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 17 May 2024 19:18:54 +0100 Subject: [PATCH 24/30] bindings --- CHANGELOG.md | 1 + docs/examples/guide/screens/modal02.py | 6 +++ docs/guide/actions.md | 4 +- src/textual/app.py | 36 ++++----------- src/textual/binding.py | 15 +++++- src/textual/screen.py | 63 +++++++++++++++++++++++++- src/textual/widgets/_footer.py | 4 +- 7 files changed, 95 insertions(+), 34 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4ec60c3198..edda7ca1d0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). ### 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 diff --git a/docs/examples/guide/screens/modal02.py b/docs/examples/guide/screens/modal02.py index 2d3210c670..9efc856955 100644 --- a/docs/examples/guide/screens/modal02.py +++ b/docs/examples/guide/screens/modal02.py @@ -15,6 +15,8 @@ class QuitScreen(ModalScreen): """Screen with a dialog to quit.""" + BINDINGS = [("f", "request_foo", "Foo")] + def compose(self) -> ComposeResult: yield Grid( Label("Are you sure you want to quit?", id="question"), @@ -22,6 +24,7 @@ def compose(self) -> ComposeResult: Button("Cancel", variant="primary", id="cancel"), id="dialog", ) + yield Footer() def on_button_pressed(self, event: Button.Pressed) -> None: if event.button.id == "quit": @@ -29,6 +32,9 @@ def on_button_pressed(self, event: Button.Pressed) -> None: else: self.app.pop_screen() + def action_request_foo(self) -> None: + print("!!") + class ModalApp(App): """An app with a modal dialog.""" diff --git a/docs/guide/actions.md b/docs/guide/actions.md index e82e953d15..17b7288f70 100644 --- a/docs/guide/actions.md +++ b/docs/guide/actions.md @@ -185,10 +185,10 @@ We will also demonstrate what the footer will show if we return `None` from `che 2. Returning `None` disables they key in the footer rather than hides it. 3. Returning `None` disables they key in the footer rather than hides it. -=== "actions07.tcss" +=== "actions06.tcss" ```css title="actions06.tcss" - --8<-- "docs/examples/guide/actions/actions07.tcss" + --8<-- "docs/examples/guide/actions/actions06.tcss" ``` === "Output" diff --git a/src/textual/app.py b/src/textual/app.py index 3b737a5ed8..00e1eeb531 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -80,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 @@ -104,6 +104,7 @@ from .reactive import Reactive from .renderables.blank import Blank from .screen import ( + ActiveBinding, Screen, ScreenResultCallbackType, ScreenResultType, @@ -844,7 +845,7 @@ def focused(self) -> Widget | None: return focused @property - def namespace_bindings(self) -> dict[str, tuple[DOMNode, Binding, bool]]: + def active_bindings(self) -> dict[str, ActiveBinding]: """Get currently active bindings. If no widget is focused, then app-level bindings are returned. @@ -853,23 +854,9 @@ def namespace_bindings(self) -> dict[str, tuple[DOMNode, Binding, bool]]: 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, bool]] = {} - for namespace, bindings in self._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] = (namespace, binding, bool(action_state)) - else: - bindings_map[key] = (namespace, binding, bool(action_state)) - - return bindings_map + return self.screen.active_bindings def _set_active(self) -> None: """Set this app to be the currently active app.""" @@ -2930,15 +2917,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. @@ -2953,7 +2931,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: 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/screen.py b/src/textual/screen.py index 1bed4b7b2e..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 @@ -281,6 +281,67 @@ def refresh_bindings(self) -> None: 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. diff --git a/src/textual/widgets/_footer.py b/src/textual/widgets/_footer.py index 9021423c08..cc84d8f2df 100644 --- a/src/textual/widgets/_footer.py +++ b/src/textual/widgets/_footer.py @@ -73,7 +73,7 @@ async def watch_highlight_key(self) -> None: def _on_mount(self, _: events.Mount) -> None: self.screen.bindings_updated_signal.subscribe(self, self._bindings_changed) - def _bindings_changed(self, _: Screen) -> None: + def _bindings_changed(self, _screen: Screen) -> None: self._key_text = None self.refresh() @@ -105,7 +105,7 @@ def _make_key_text(self) -> Text: bindings = [ (binding, enabled) - for (_, binding, enabled) in self.app.namespace_bindings.values() + for (_, binding, enabled) in self.screen.active_bindings.values() if binding.show ] From 762c2ca37bc087252975593ba1310e50affc6a77 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 17 May 2024 19:21:06 +0100 Subject: [PATCH 25/30] typos --- docs/guide/actions.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/guide/actions.md b/docs/guide/actions.md index 17b7288f70..e9cf750901 100644 --- a/docs/guide/actions.md +++ b/docs/guide/actions.md @@ -182,8 +182,8 @@ We will also demonstrate what the footer will show if we return `None` from `che ``` 1. The `bindings=True` causes the footer to refresh when `page_no` changes. - 2. Returning `None` disables they key in the footer rather than hides it. - 3. Returning `None` disables they key in the footer rather than hides it. + 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" From 9459c03dea89f199209bf5d4e91b9a77e42c769d Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 17 May 2024 19:21:32 +0100 Subject: [PATCH 26/30] Update docs/guide/actions.md Co-authored-by: TomJGooding <101601846+TomJGooding@users.noreply.github.com> --- docs/guide/actions.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/guide/actions.md b/docs/guide/actions.md index e9cf750901..36a098816b 100644 --- a/docs/guide/actions.md +++ b/docs/guide/actions.md @@ -140,7 +140,7 @@ 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 he action running. +- `None` to disable the key (show dimmed), and prevent the action running. Let's write an app to put this in to practice: From 3eca5255b2039667d72e99502c7686e4ed598610 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 17 May 2024 19:21:44 +0100 Subject: [PATCH 27/30] Update docs/guide/actions.md Co-authored-by: TomJGooding <101601846+TomJGooding@users.noreply.github.com> --- docs/guide/actions.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/guide/actions.md b/docs/guide/actions.md index 36a098816b..19df24336c 100644 --- a/docs/guide/actions.md +++ b/docs/guide/actions.md @@ -142,7 +142,7 @@ It should return one of the following values: - `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 in to practice: +Let's write an app to put this into practice: === "actions06.py" From d6a3e0b4cad4515ad1cc916c52f659686e8441ea Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 17 May 2024 19:27:44 +0100 Subject: [PATCH 28/30] change to notifications defaults --- src/textual/app.py | 5 ++++- src/textual/widget.py | 20 ++++++++++++++++---- 2 files changed, 20 insertions(+), 5 deletions(-) diff --git a/src/textual/app.py b/src/textual/app.py index 00e1eeb531..d18ddb67a5 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -354,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 } @@ -3512,7 +3515,7 @@ def notify( *, title: str = "", severity: SeverityLevel = "information", - timeout: float = Notification.timeout, + timeout: float = NOTIFICATION_TIMEOUT, ) -> None: """Create a notification. 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, + ) From 4378f779af6ed7d664c428ccb95c45922c4844fc Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 17 May 2024 19:30:03 +0100 Subject: [PATCH 29/30] restore modal --- docs/examples/guide/screens/modal02.py | 6 ------ 1 file changed, 6 deletions(-) diff --git a/docs/examples/guide/screens/modal02.py b/docs/examples/guide/screens/modal02.py index 9efc856955..2d3210c670 100644 --- a/docs/examples/guide/screens/modal02.py +++ b/docs/examples/guide/screens/modal02.py @@ -15,8 +15,6 @@ class QuitScreen(ModalScreen): """Screen with a dialog to quit.""" - BINDINGS = [("f", "request_foo", "Foo")] - def compose(self) -> ComposeResult: yield Grid( Label("Are you sure you want to quit?", id="question"), @@ -24,7 +22,6 @@ def compose(self) -> ComposeResult: Button("Cancel", variant="primary", id="cancel"), id="dialog", ) - yield Footer() def on_button_pressed(self, event: Button.Pressed) -> None: if event.button.id == "quit": @@ -32,9 +29,6 @@ def on_button_pressed(self, event: Button.Pressed) -> None: else: self.app.pop_screen() - def action_request_foo(self) -> None: - print("!!") - class ModalApp(App): """An app with a modal dialog.""" From f4a0a1784eedcc44cee29a3fbea62b3a4bfa6e92 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 17 May 2024 19:32:49 +0100 Subject: [PATCH 30/30] timeout fix --- src/textual/app.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/textual/app.py b/src/textual/app.py index d18ddb67a5..3aef22caad 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -3515,7 +3515,7 @@ def notify( *, title: str = "", severity: SeverityLevel = "information", - timeout: float = NOTIFICATION_TIMEOUT, + timeout: float | None = None, ) -> None: """Create a notification. @@ -3528,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], @@ -3563,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))