Support for key aliases, key handling tests

This commit is contained in:
Darren Burns
2022-10-07 11:30:08 +01:00
parent 82d68c43a9
commit 17bc375e08
5 changed files with 131 additions and 13 deletions

View File

@@ -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."""

View File

@@ -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, [])

View File

@@ -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:

View File

@@ -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_<event.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()

View File

@@ -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