diff --git a/src/textual/errors.py b/src/textual/errors.py index dcbf724a1..032c3ed3b 100644 --- a/src/textual/errors.py +++ b/src/textual/errors.py @@ -11,3 +11,7 @@ class NoWidget(TextualError): class RenderError(TextualError): """An object could not be rendered.""" + + +class DuplicateKeyHandlers(TextualError): + """More than one handler for a single key press. E.g. key_ctrl_i and key_tab handlers both found on one object.""" diff --git a/src/textual/keys.py b/src/textual/keys.py index 6fd47cf09..4bf203218 100644 --- a/src/textual/keys.py +++ b/src/textual/keys.py @@ -1,9 +1,9 @@ -from dataclasses import dataclass +from __future__ import annotations + from enum import Enum + # Adapted from prompt toolkit https://github.com/prompt-toolkit/python-prompt-toolkit/blob/master/prompt_toolkit/keys.py - - class Keys(str, Enum): """ List of keys for use in key bindings. @@ -206,3 +206,19 @@ KEY_NAME_REPLACEMENTS = { "solidus": "slash", "reverse_solidus": "backslash", } + +# Some keys have aliases. For example, if you press `ctrl+m` on your keyboard, +# it's treated the same way as if you press `enter`. Key handlers `key_ctrl_m` and +# `key_enter` are both valid in this case. +KEY_ALIASES = { + "tab": ["ctrl+i"], + "enter": ["ctrl+m"], + "escape": ["ctrl+["], + "ctrl+@": ["ctrl+space"], + "ctrl+j": ["newline"], +} + + +def _get_key_aliases(key: str) -> list[str]: + """Return all aliases for the given key, including the key itself""" + return [key] + KEY_ALIASES.get(key, []) diff --git a/src/textual/message.py b/src/textual/message.py index c808aed80..caefe6834 100644 --- a/src/textual/message.py +++ b/src/textual/message.py @@ -10,6 +10,7 @@ from ._types import MessageTarget as MessageTarget if TYPE_CHECKING: from .widget import Widget + from .message_pump import MessagePump @rich.repr.auto @@ -113,7 +114,7 @@ class Message: self._stop_propagation = stop return self - async def _bubble_to(self, widget: Widget) -> None: + async def bubble_to(self, widget: MessagePump) -> None: """Bubble to a widget (typically the parent). Args: diff --git a/src/textual/message_pump.py b/src/textual/message_pump.py index d7567b082..c48ba92ef 100644 --- a/src/textual/message_pump.py +++ b/src/textual/message_pump.py @@ -5,7 +5,6 @@ A message pump is a class that processes messages. It is a base class for the App, Screen, and Widgets. """ - from __future__ import annotations import asyncio @@ -18,6 +17,8 @@ from weakref import WeakSet from . import events, log, messages, Logger from ._callback import invoke from ._context import NoActiveAppError, active_app +from .errors import DuplicateKeyHandlers +from .keys import _get_key_aliases from .timer import Timer, TimerCallback from .case import camel_to_snake from .events import Event @@ -417,7 +418,7 @@ class MessagePump(metaclass=MessagePumpMeta): # parent is sender, so we stop propagation after parent message.stop() if self.is_parent_active and not self._parent._closing: - await message._bubble_to(self._parent) + await message.bubble_to(self._parent) def check_idle(self) -> None: """Prompt the message pump to call idle if the queue is empty.""" @@ -526,20 +527,52 @@ class MessagePump(metaclass=MessagePumpMeta): """Dispatch a key event to method. This method will call the method named 'key_' if it exists. + Some keys have aliases. The first alias found will be invoked if it exists. + If multiple handlers exist that match the key, an exception is raised. Args: event (events.Key): A key event. Returns: bool: True if key was handled, otherwise False. + + Raises: + DuplicateKeyHandlers: When there's more than 1 handler that could handle this key. """ - key_method = getattr(self, f"key_{event.key_name}", None) or getattr( - self, f"_key_{event.key_name}", None - ) - if key_method is not None: - await invoke(key_method, event) - return True - return False + + def get_key_handler(key: str) -> Callable | None: + """Look for the public and private handler methods by name on self.""" + public_handler = getattr(self, f"key_{key}", None) + private_handler = getattr(self, f"_key_{key}", None) + if public_handler and private_handler: + raise DuplicateKeyHandlers( + f"Conflicting handlers for key press {key!r}. " + f"We found both {public_handler!r} and {private_handler!r}, and didn't know which to call. " + f"Consider combining them into a single handler. ", + ) + + return public_handler or private_handler + + invoked = False + key_name = event.key_name + if not key_name: + return invoked + + key_aliases = _get_key_aliases(key_name) + for key_alias in key_aliases: + key_method = get_key_handler(key_alias.replace("+", "_")) + if key_method is not None: + if invoked: + # Ensure we only invoke a single handler, raise an exception if the user + # has supplied multiple handlers which could handle the current key press. + raise DuplicateKeyHandlers( + f"Conflicting key handlers found for a single key press. " + f"The conflicting handler is {key_alias!r}." + ) + await invoke(key_method, event) + invoked = True + + return invoked async def on_timer(self, event: events.Timer) -> None: event.prevent_default() diff --git a/tests/test_message_pump.py b/tests/test_message_pump.py new file mode 100644 index 000000000..716704088 --- /dev/null +++ b/tests/test_message_pump.py @@ -0,0 +1,64 @@ +import pytest + +from textual.errors import DuplicateKeyHandlers +from textual.events import Key +from textual.widget import Widget + + +class ValidWidget(Widget): + called_by = None + + def key_x(self): + self.called_by = self.key_x + + def key_ctrl_i(self): + self.called_by = self.key_ctrl_i + + +async def test_dispatch_key_valid_key(): + widget = ValidWidget() + result = await widget.dispatch_key(Key(widget, key="x", char="x")) + assert result is True + assert widget.called_by == widget.key_x + + +async def test_dispatch_key_valid_key_alias(): + """When you press tab or ctrl+i, it comes through as a tab key event, but handlers for + tab and ctrl+i are both considered valid.""" + widget = ValidWidget() + result = await widget.dispatch_key(Key(widget, key="tab", char="\t")) + assert result is True + assert widget.called_by == widget.key_ctrl_i + + +class DuplicateHandlersWidget(Widget): + called_by = None + + def key_x(self): + self.called_by = self.key_x + + def _key_x(self): + self.called_by = self._key_x + + def key_tab(self): + self.called_by = self.key_tab + + def key_ctrl_i(self): + self.called_by = self.key_ctrl_i + + +async def test_dispatch_key_raises_when_public_and_private_handlers(): + """When both a public and private handler exists for one key, we fail fast via exception.""" + widget = DuplicateHandlersWidget() + with pytest.raises(DuplicateKeyHandlers): + await widget.dispatch_key(Key(widget, key="x", char="x")) + assert widget.called_by is None + + +async def test_dispatch_key_raises_when_conflicting_handler_aliases(): + """If you've got a handler for e.g. ctrl+i and a handler for tab, that's probably a mistake. + In the terminal, they're the same thing, so we fail fast via exception here.""" + widget = DuplicateHandlersWidget() + with pytest.raises(DuplicateKeyHandlers): + await widget.dispatch_key(Key(widget, key="tab", char="\t")) + assert widget.called_by == widget.key_tab