This commit is contained in:
Darren Burns 2024-09-30 09:17:59 +01:00 committed by GitHub
parent 521fdcf3df
commit d472cb582e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 828 additions and 44 deletions

View file

@ -5,6 +5,12 @@ 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/).
## Unreleased
### Added
- Added support for keymaps (user configurable key bindings) https://github.com/Textualize/textual/pull/5038
## [0.81.0] - 2024-09-25
### Added

View file

@ -91,7 +91,7 @@ from textual._wait import wait_for_idle
from textual.actions import ActionParseResult, SkipAction
from textual.await_complete import AwaitComplete
from textual.await_remove import AwaitRemove
from textual.binding import Binding, BindingsMap, BindingType
from textual.binding import Binding, BindingsMap, BindingType, Keymap
from textual.command import CommandPalette, Provider
from textual.css.errors import StylesheetError
from textual.css.query import NoMatches
@ -659,6 +659,8 @@ class App(Generic[ReturnType], DOMNode):
self._registry: WeakSet[DOMNode] = WeakSet()
self._keymap: Keymap = {}
# Sensitivity on X is double the sensitivity on Y to account for
# cells being twice as tall as wide
self.scroll_sensitivity_x: float = 4.0
@ -754,8 +756,8 @@ class App(Generic[ReturnType], DOMNode):
happens.
"""
# Size of previous inline update
self._previous_inline_height: int | None = None
"""Size of previous inline update."""
if self.ENABLE_COMMAND_PALETTE:
for _key, binding in self._bindings:
@ -3422,6 +3424,51 @@ class App(Generic[ReturnType], DOMNode):
return True
return False
def set_keymap(self, keymap: Keymap) -> None:
"""Set the keymap, a mapping of binding IDs to key strings.
Bindings in the keymap are used to override default key bindings,
i.e. those defined in `BINDINGS` class variables.
Bindings with IDs that are present in the keymap will have
their key string replaced with the value from the keymap.
Args:
keymap: A mapping of binding IDs to key strings.
"""
self._keymap = keymap
def update_keymap(self, keymap: Keymap) -> None:
"""Update the App's keymap, merging with `keymap`.
If a Binding ID exists in both the App's keymap and the `keymap`
argument, the `keymap` argument takes precedence.
Args:
keymap: A mapping of binding IDs to key strings.
"""
self._keymap = {**self._keymap, **keymap}
def handle_bindings_clash(
self, clashed_bindings: set[Binding], node: DOMNode
) -> None:
"""Handle a clash between bindings.
Bindings clashes are likely due to users setting conflicting
keys via their keymap.
This method is intended to be overridden by subclasses.
Textual will call this each time a clash is encountered -
which may be on each keypress if a clashing widget is focused
or is in the bindings chain.
Args:
clashed_bindings: The bindings that are clashing.
node: The node that has the clashing bindings.
"""
pass
async def on_event(self, event: events.Event) -> None:
# Handle input events that haven't been forwarded
# If the event has been forwarded it may have bubbled up back to the App

View file

@ -7,8 +7,9 @@ See [bindings](/guide/input#bindings) in the guide for details.
from __future__ import annotations
import dataclasses
from dataclasses import dataclass
from typing import TYPE_CHECKING, Iterable, Iterator, NamedTuple
from typing import TYPE_CHECKING, Iterable, Iterator, Mapping, NamedTuple
import rich.repr
@ -20,6 +21,22 @@ if TYPE_CHECKING:
from textual.dom import DOMNode
BindingType: TypeAlias = "Binding | tuple[str, str] | tuple[str, str, str]"
"""The possible types of a binding found in the `BINDINGS` class variable."""
BindingIDString: TypeAlias = str
"""The ID of a Binding defined somewhere in the application.
Corresponds to the `id` parameter of the `Binding` class.
"""
KeyString: TypeAlias = str
"""A string that represents a key binding.
For example, "x", "ctrl+i", "ctrl+shift+a", "ctrl+j,space,x", etc.
"""
Keymap = Mapping[BindingIDString, KeyString]
"""A mapping of binding IDs to key strings, used for overriding default key bindings."""
class BindingError(Exception):
@ -47,12 +64,24 @@ class Binding:
show: bool = True
"""Show the action in Footer, or False to hide."""
key_display: str | None = None
"""How the key should be shown in footer."""
"""How the key should be shown in footer.
If None, the display of the key will use the result of `App.get_key_display`.
If overridden in a keymap then this value is ignored.
"""
priority: bool = False
"""Enable priority binding for this key."""
tooltip: str = ""
"""Optional tooltip to show in footer."""
id: str | None = None
"""ID of the binding. Intended to be globally unique, but uniqueness is not enforced.
If specified in the App's keymap then Textual will use this ID to lookup the binding,
and substitute the `key` property of the Binding with the key specified in the keymap.
"""
def parse_key(self) -> tuple[list[str], str]:
"""Parse a key in to a list of modifiers, and the actual key.
@ -62,6 +91,65 @@ class Binding:
*modifiers, key = self.key.split("+")
return modifiers, key
def with_key(self, key: str, key_display: str | None = None) -> Binding:
"""Return a new binding with the key and key_display set to the specified values.
Args:
key: The new key to set.
key_display: The new key display to set.
Returns:
A new binding with the key set to the specified value.
"""
return dataclasses.replace(self, key=key, key_display=key_display)
@classmethod
def make_bindings(cls, bindings: Iterable[BindingType]) -> Iterable[Binding]:
"""Convert a list of BindingType (the types that can be specified in BINDINGS)
into an Iterable[Binding].
Compound bindings like "j,down" will be expanded into 2 Binding instances.
Args:
bindings: An iterable of BindingType.
Returns:
An iterable of Binding.
"""
bindings = list(bindings)
for binding in bindings:
# If it's a tuple of length 3, convert into a Binding first
if isinstance(binding, tuple):
if len(binding) not in (2, 3):
raise BindingError(
f"BINDINGS must contain a tuple of two or three strings, not {binding!r}"
)
# `binding` is a tuple of 2 or 3 values at this point
binding = Binding(*binding) # type: ignore[reportArgumentType]
# At this point we have a Binding instance, but the key may
# be a list of keys, so now we unroll that single Binding
# into a (potential) collection of Binding instances.
for key in binding.key.split(","):
key = key.strip()
if not key:
raise InvalidBinding(
f"Can not bind empty string in {binding.key!r}"
)
if len(key) == 1:
key = _character_to_key(key)
yield Binding(
key=key,
action=binding.action,
description=binding.description,
show=bool(binding.description and binding.show),
key_display=binding.key_display,
priority=binding.priority,
tooltip=binding.tooltip,
id=binding.id,
)
class ActiveBinding(NamedTuple):
"""Information about an active binding (returned from [active_bindings][textual.screen.Screen.active_bindings])."""
@ -95,41 +183,10 @@ class BindingsMap:
properties of a `Binding`.
"""
def make_bindings(bindings: Iterable[BindingType]) -> Iterable[Binding]:
bindings = list(bindings)
for binding in bindings:
# If it's a tuple of length 3, convert into a Binding first
if isinstance(binding, tuple):
if len(binding) not in (2, 3):
raise BindingError(
f"BINDINGS must contain a tuple of two or three strings, not {binding!r}"
)
# `binding` is a tuple of 2 or 3 values at this point
binding = Binding(*binding) # type: ignore[reportArgumentType]
# At this point we have a Binding instance, but the key may
# be a list of keys, so now we unroll that single Binding
# into a (potential) collection of Binding instances.
for key in binding.key.split(","):
key = key.strip()
if not key:
raise InvalidBinding(
f"Can not bind empty string in {binding.key!r}"
)
if len(key) == 1:
key = _character_to_key(key)
yield Binding(
key=key,
action=binding.action,
description=binding.description,
show=bool(binding.description and binding.show),
key_display=binding.key_display,
priority=binding.priority,
tooltip=binding.tooltip,
)
self.key_to_bindings: dict[str, list[Binding]] = {}
for binding in make_bindings(bindings or {}):
"""Mapping of key (e.g. "ctrl+a") to list of bindings for that key."""
for binding in Binding.make_bindings(bindings or {}):
self.key_to_bindings.setdefault(binding.key, []).append(binding)
def _add_binding(self, binding: Binding) -> None:
@ -193,6 +250,71 @@ class BindingsMap:
keys.setdefault(key, []).extend(key_bindings)
return BindingsMap.from_keys(keys)
def apply_keymap(self, keymap: Keymap) -> KeymapApplyResult:
"""Replace bindings for keys that are present in `keymap`.
Preserves existing bindings for keys that are not in `keymap`.
Args:
keymap: A keymap to overlay.
Returns:
KeymapApplyResult: The result of applying the keymap, including any clashed bindings.
"""
clashed_bindings: set[Binding] = set()
new_bindings: dict[str, list[Binding]] = {}
key_to_bindings = list(self.key_to_bindings.items())
for key, bindings in key_to_bindings:
for binding in bindings:
binding_id = binding.id
if binding_id is None:
# Bindings without an ID are irrelevant when applying a keymap
continue
# If the keymap has an override for this binding ID
if keymap_key_string := keymap.get(binding_id):
keymap_keys = keymap_key_string.split(",")
# Remove the old binding
for key, key_bindings in key_to_bindings:
key = key.strip()
if any(binding.id == binding_id for binding in key_bindings):
if key in self.key_to_bindings:
del self.key_to_bindings[key]
for keymap_key in keymap_keys:
if (
keymap_key in self.key_to_bindings
or keymap_key in new_bindings
):
# The key is already mapped either by default or by the keymap,
# so there's a clash unless the existing binding is being rebound
# to a different key.
clashing_bindings = self.key_to_bindings.get(
keymap_key, []
) + new_bindings.get(keymap_key, [])
for clashed_binding in clashing_bindings:
# If the existing binding is not being rebound, it's a clash
if not (
clashed_binding.id
and keymap.get(clashed_binding.id)
!= clashed_binding.key
):
clashed_bindings.add(clashed_binding)
if keymap_key in self.key_to_bindings:
del self.key_to_bindings[keymap_key]
for keymap_key in keymap_keys:
new_bindings.setdefault(keymap_key, []).append(
binding.with_key(key=keymap_key, key_display=None)
)
# Update the key_to_bindings with the new bindings
self.key_to_bindings.update(new_bindings)
return KeymapApplyResult(clashed_bindings)
@property
def shown_keys(self) -> list[Binding]:
"""A list of bindings for shown keys."""
@ -252,3 +374,10 @@ class BindingsMap:
return self.key_to_bindings[key]
except KeyError:
raise NoBinding(f"No binding for {key}") from None
class KeymapApplyResult(NamedTuple):
"""The result of applying a keymap."""
clashed_bindings: set[Binding]
"""A list of bindings that were clashed and replaced by the keymap."""

View file

@ -218,7 +218,7 @@ class DOMNode(MessagePump):
self._has_hover_style: bool = False
self._has_focus_within: bool = False
self._reactive_connect: (
dict[str, tuple[MessagePump, Reactive | object]] | None
dict[str, tuple[MessagePump, Reactive[object] | object]] | None
) = None
self._pruning = False
self._query_one_cache: LRUCache[QueryOneCacheKey, DOMNode] = LRUCache(1024)
@ -620,12 +620,13 @@ class DOMNode(MessagePump):
base.__dict__.get("BINDINGS", []),
)
)
keys: dict[str, list[Binding]] = {}
for bindings_ in bindings:
for key, key_bindings in bindings_.key_to_bindings.items():
keys[key] = key_bindings
new_bindings = BindingsMap().from_keys(keys)
new_bindings = BindingsMap.from_keys(keys)
return new_bindings
def _post_register(self, app: App) -> None:

View file

@ -332,8 +332,8 @@ class Screen(Generic[ScreenResultType], Widget):
focused = self.focused
if focused is not None and focused.loading:
focused = None
namespace_bindings: list[tuple[DOMNode, BindingsMap]]
namespace_bindings: list[tuple[DOMNode, BindingsMap]]
if focused is None:
namespace_bindings = [
(self, self._bindings.copy()),
@ -351,9 +351,19 @@ class Screen(Generic[ScreenResultType], Widget):
check_consume_key = filter_namespace.check_consume_key
for key in list(bindings_map.key_to_bindings):
if check_consume_key(key, key_to_character(key)):
# If the widget consumes the key (e.g. like an Input widget),
# then remove the key from the bindings map.
del bindings_map.key_to_bindings[key]
filter_namespaces.append(namespace)
keymap = self.app._keymap
for namespace, bindings_map in namespace_bindings:
if keymap:
result = bindings_map.apply_keymap(keymap)
if result.clashed_bindings:
self.app.handle_bindings_clash(result.clashed_bindings, namespace)
return namespace_bindings
@property
@ -378,15 +388,17 @@ class Screen(Generic[ScreenResultType], Widget):
A map of keys to a tuple containing (NAMESPACE, BINDING, ENABLED).
"""
bindings_map: dict[str, ActiveBinding] = {}
app = self.app
for namespace, bindings in self._modal_binding_chain:
for key, binding in bindings:
# This will call the nodes `check_action` method.
action_state = self.app._check_action_state(binding.action, namespace)
action_state = app._check_action_state(binding.action, namespace)
if action_state is False:
# An action_state of False indicates the action is disabled and not shown
# Note that None has a different meaning, which is why there is an `is False`
# rather than a truthy check.
continue
enabled = bool(action_state)
if existing_key_and_binding := bindings_map.get(key):
# This key has already been bound

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 30 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 31 KiB

View file

@ -2,12 +2,13 @@ from pathlib import Path
import pytest
from rich.panel import Panel
from rich.table import Table
from rich.text import Text
from tests.snapshot_tests.language_snippets import SNIPPETS
from textual import events
from textual import events, on
from textual.app import App, ComposeResult
from textual.binding import Binding, Keymap
from textual.containers import Vertical
from textual.binding import Binding
from textual.containers import Vertical, VerticalScroll
from textual.pilot import Pilot
@ -1993,6 +1994,84 @@ def test_disabled(snap_compare):
assert snap_compare(app)
def test_keymap_bindings_display_footer_and_help_panel(snap_compare):
"""Bindings overridden by the Keymap are shown as expected in the Footer
and help panel. Testing that the keys work as expected is done elsewhere.
Footer should show bindings `k` to Increment, and `down` to Decrement.
Key panel should show bindings `k, plus` to increment,
and `down, minus, j` to decrement.
"""
class Counter(App[None]):
BINDINGS = [
Binding(
key="i,up",
action="increment",
description="Increment",
id="app.increment",
),
Binding(
key="d,down",
action="decrement",
description="Decrement",
id="app.decrement",
),
]
def compose(self) -> ComposeResult:
yield Label("Counter")
yield Footer()
def on_mount(self) -> None:
self.action_show_help_panel()
self.set_keymap(
{
"app.increment": "k,plus",
"app.decrement": "down,minus,j",
}
)
assert snap_compare(Counter())
def test_keymap_bindings_key_display(snap_compare):
"""If a default binding in `BINDINGS` has a key_display, it should be reset
when that binding is overridden by a Keymap.
The key_display should be taken from `App.get_key_display`, so in this case
it should be "THIS IS CORRECT" in the Footer and help panel, not "INCORRECT".
"""
class MyApp(App[None]):
BINDINGS = [
Binding(
key="i,up",
action="increment",
description="Increment",
id="app.increment",
key_display="INCORRECT",
),
]
def compose(self) -> ComposeResult:
yield Label("Check the footer and help panel")
yield Footer()
def on_mount(self) -> None:
self.action_show_help_panel()
self.set_keymap({"app.increment": "k,plus,j,l"})
def get_key_display(self, binding: Binding) -> str:
if binding.id == "app.increment":
return "correct"
return super().get_key_display(binding)
assert snap_compare(MyApp())
def test_missing_new_widgets(snap_compare):
"""Regression test for https://github.com/Textualize/textual/issues/5024"""

194
tests/test_keymap.py Normal file
View file

@ -0,0 +1,194 @@
from __future__ import annotations
from typing import Any
from textual.app import App, ComposeResult
from textual.binding import Binding, Keymap
from textual.dom import DOMNode
from textual.widget import Widget
from textual.widgets import Label
class Counter(App[None]):
BINDINGS = [
Binding(key="i,up", action="increment", id="app.increment"),
Binding(key="d,down", action="decrement", id="app.decrement"),
]
def __init__(self, keymap: Keymap, *args: Any, **kwargs: Any):
super().__init__(*args, **kwargs)
self.count = 0
self.clashed_bindings: set[Binding] | None = None
self.clashed_node: DOMNode | None = None
self.keymap = keymap
def compose(self) -> ComposeResult:
yield Label("foo")
def on_mount(self) -> None:
self.set_keymap(self.keymap)
def action_increment(self) -> None:
self.count += 1
def action_decrement(self) -> None:
self.count -= 1
def handle_bindings_clash(
self, clashed_bindings: set[Binding], node: DOMNode
) -> None:
self.clashed_bindings = clashed_bindings
self.clashed_node = node
async def test_keymap_default_binding_replaces_old_binding():
app = Counter({"app.increment": "right,k"})
async with app.run_test() as pilot:
# The original bindings are removed - action not called.
await pilot.press("i", "up")
assert app.count == 0
# The new bindings are active and call the action.
await pilot.press("right", "k")
assert app.count == 2
async def test_keymap_sends_message_when_clash():
app = Counter({"app.increment": "d"})
async with app.run_test() as pilot:
await pilot.press("d")
assert app.clashed_bindings is not None
assert len(app.clashed_bindings) == 1
clash = app.clashed_bindings.pop()
assert app.clashed_node is app
assert clash.key == "d"
assert clash.action == "increment"
assert clash.id == "app.increment"
async def test_keymap_with_unknown_id_is_noop():
app = Counter({"this.is.an.unknown.id": "d"})
async with app.run_test() as pilot:
await pilot.press("d")
assert app.count == -1
async def test_keymap_inherited_bindings_same_id():
"""When a child widget inherits from a parent widget, if they have
a binding with the same ID, then both parent and child bindings will
be overridden by the keymap (assuming the keymap has a mapping with the
same ID)."""
parent_counter = 0
child_counter = 0
class Parent(Widget, can_focus=True):
BINDINGS = [
Binding(key="x", action="increment", id="increment"),
]
def action_increment(self) -> None:
nonlocal parent_counter
parent_counter += 1
class Child(Parent):
BINDINGS = [
Binding(key="x", action="increment", id="increment"),
]
def action_increment(self) -> None:
nonlocal child_counter
child_counter += 1
class MyApp(App[None]):
def compose(self) -> ComposeResult:
yield Parent()
yield Child()
def on_mount(self) -> None:
self.set_keymap({"increment": "i"})
app = MyApp()
async with app.run_test() as pilot:
# Default binding is unbound due to keymap.
await pilot.press("x")
assert parent_counter == 0
assert child_counter == 0
# New binding is active, parent is focused - action called.
await pilot.press("i")
assert parent_counter == 1
assert child_counter == 0
# Tab to focus the child.
await pilot.press("tab")
# Default binding results in no change.
await pilot.press("x")
assert parent_counter == 1
assert child_counter == 0
# New binding is active, child is focused - action called.
await pilot.press("i")
assert parent_counter == 1
assert child_counter == 1
async def test_keymap_child_with_different_id_overridden():
"""Ensures that overriding a parent binding doesn't influence a child
binding with a different ID."""
parent_counter = 0
child_counter = 0
class Parent(Widget, can_focus=True):
BINDINGS = [
Binding(key="x", action="increment", id="parent.increment"),
]
def action_increment(self) -> None:
nonlocal parent_counter
parent_counter += 1
class Child(Parent):
BINDINGS = [
Binding(key="x", action="increment", id="child.increment"),
]
def action_increment(self) -> None:
nonlocal child_counter
child_counter += 1
class MyApp(App[None]):
def compose(self) -> ComposeResult:
yield Parent()
yield Child()
def on_mount(self) -> None:
self.set_keymap({"parent.increment": "i"})
app = MyApp()
async with app.run_test() as pilot:
# Default binding is unbound due to keymap.
await pilot.press("x")
assert parent_counter == 0
assert child_counter == 0
# New binding is active, parent is focused - action called.
await pilot.press("i")
assert parent_counter == 1
assert child_counter == 0
# Tab to focus the child.
await pilot.press("tab")
# Default binding is still active on the child.
await pilot.press("x")
assert parent_counter == 1
assert child_counter == 1
# The binding from the keymap only affects the parent, so
# pressing it with the child focused does nothing.
await pilot.press("i")
assert parent_counter == 1
assert child_counter == 1