mirror of
https://github.com/Textualize/textual.git
synced 2025-10-17 02:38:12 +03:00
Support for key aliases, key handling tests
This commit is contained in:
@@ -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."""
|
||||
|
||||
@@ -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, [])
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
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)
|
||||
return True
|
||||
return False
|
||||
invoked = True
|
||||
|
||||
return invoked
|
||||
|
||||
async def on_timer(self, event: events.Timer) -> None:
|
||||
event.prevent_default()
|
||||
|
||||
64
tests/test_message_pump.py
Normal file
64
tests/test_message_pump.py
Normal 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
|
||||
Reference in New Issue
Block a user