Skip to content

Commit d472cb5

Browse files
authored
Keymaps (#5038)
1 parent 521fdcf commit d472cb5

File tree

9 files changed

+828
-44
lines changed

9 files changed

+828
-44
lines changed

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](http://keepachangelog.com/)
66
and this project adheres to [Semantic Versioning](http://semver.org/).
77

8+
## Unreleased
9+
10+
### Added
11+
12+
- Added support for keymaps (user configurable key bindings) https://github.com/Textualize/textual/pull/5038
13+
814
## [0.81.0] - 2024-09-25
915

1016
### Added

src/textual/app.py

Lines changed: 49 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,7 @@
9191
from textual.actions import ActionParseResult, SkipAction
9292
from textual.await_complete import AwaitComplete
9393
from textual.await_remove import AwaitRemove
94-
from textual.binding import Binding, BindingsMap, BindingType
94+
from textual.binding import Binding, BindingsMap, BindingType, Keymap
9595
from textual.command import CommandPalette, Provider
9696
from textual.css.errors import StylesheetError
9797
from textual.css.query import NoMatches
@@ -659,6 +659,8 @@ def __init__(
659659

660660
self._registry: WeakSet[DOMNode] = WeakSet()
661661

662+
self._keymap: Keymap = {}
663+
662664
# Sensitivity on X is double the sensitivity on Y to account for
663665
# cells being twice as tall as wide
664666
self.scroll_sensitivity_x: float = 4.0
@@ -754,8 +756,8 @@ def __init__(
754756
happens.
755757
"""
756758

757-
# Size of previous inline update
758759
self._previous_inline_height: int | None = None
760+
"""Size of previous inline update."""
759761

760762
if self.ENABLE_COMMAND_PALETTE:
761763
for _key, binding in self._bindings:
@@ -3422,6 +3424,51 @@ async def _check_bindings(self, key: str, priority: bool = False) -> bool:
34223424
return True
34233425
return False
34243426

3427+
def set_keymap(self, keymap: Keymap) -> None:
3428+
"""Set the keymap, a mapping of binding IDs to key strings.
3429+
3430+
Bindings in the keymap are used to override default key bindings,
3431+
i.e. those defined in `BINDINGS` class variables.
3432+
3433+
Bindings with IDs that are present in the keymap will have
3434+
their key string replaced with the value from the keymap.
3435+
3436+
Args:
3437+
keymap: A mapping of binding IDs to key strings.
3438+
"""
3439+
self._keymap = keymap
3440+
3441+
def update_keymap(self, keymap: Keymap) -> None:
3442+
"""Update the App's keymap, merging with `keymap`.
3443+
3444+
If a Binding ID exists in both the App's keymap and the `keymap`
3445+
argument, the `keymap` argument takes precedence.
3446+
3447+
Args:
3448+
keymap: A mapping of binding IDs to key strings.
3449+
"""
3450+
self._keymap = {**self._keymap, **keymap}
3451+
3452+
def handle_bindings_clash(
3453+
self, clashed_bindings: set[Binding], node: DOMNode
3454+
) -> None:
3455+
"""Handle a clash between bindings.
3456+
3457+
Bindings clashes are likely due to users setting conflicting
3458+
keys via their keymap.
3459+
3460+
This method is intended to be overridden by subclasses.
3461+
3462+
Textual will call this each time a clash is encountered -
3463+
which may be on each keypress if a clashing widget is focused
3464+
or is in the bindings chain.
3465+
3466+
Args:
3467+
clashed_bindings: The bindings that are clashing.
3468+
node: The node that has the clashing bindings.
3469+
"""
3470+
pass
3471+
34253472
async def on_event(self, event: events.Event) -> None:
34263473
# Handle input events that haven't been forwarded
34273474
# If the event has been forwarded it may have bubbled up back to the App

src/textual/binding.py

Lines changed: 165 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,9 @@
77

88
from __future__ import annotations
99

10+
import dataclasses
1011
from dataclasses import dataclass
11-
from typing import TYPE_CHECKING, Iterable, Iterator, NamedTuple
12+
from typing import TYPE_CHECKING, Iterable, Iterator, Mapping, NamedTuple
1213

1314
import rich.repr
1415

@@ -20,6 +21,22 @@
2021
from textual.dom import DOMNode
2122

2223
BindingType: TypeAlias = "Binding | tuple[str, str] | tuple[str, str, str]"
24+
"""The possible types of a binding found in the `BINDINGS` class variable."""
25+
26+
BindingIDString: TypeAlias = str
27+
"""The ID of a Binding defined somewhere in the application.
28+
29+
Corresponds to the `id` parameter of the `Binding` class.
30+
"""
31+
32+
KeyString: TypeAlias = str
33+
"""A string that represents a key binding.
34+
35+
For example, "x", "ctrl+i", "ctrl+shift+a", "ctrl+j,space,x", etc.
36+
"""
37+
38+
Keymap = Mapping[BindingIDString, KeyString]
39+
"""A mapping of binding IDs to key strings, used for overriding default key bindings."""
2340

2441

2542
class BindingError(Exception):
@@ -47,12 +64,24 @@ class Binding:
4764
show: bool = True
4865
"""Show the action in Footer, or False to hide."""
4966
key_display: str | None = None
50-
"""How the key should be shown in footer."""
67+
"""How the key should be shown in footer.
68+
69+
If None, the display of the key will use the result of `App.get_key_display`.
70+
71+
If overridden in a keymap then this value is ignored.
72+
"""
5173
priority: bool = False
5274
"""Enable priority binding for this key."""
5375
tooltip: str = ""
5476
"""Optional tooltip to show in footer."""
5577

78+
id: str | None = None
79+
"""ID of the binding. Intended to be globally unique, but uniqueness is not enforced.
80+
81+
If specified in the App's keymap then Textual will use this ID to lookup the binding,
82+
and substitute the `key` property of the Binding with the key specified in the keymap.
83+
"""
84+
5685
def parse_key(self) -> tuple[list[str], str]:
5786
"""Parse a key in to a list of modifiers, and the actual key.
5887
@@ -62,6 +91,65 @@ def parse_key(self) -> tuple[list[str], str]:
6291
*modifiers, key = self.key.split("+")
6392
return modifiers, key
6493

94+
def with_key(self, key: str, key_display: str | None = None) -> Binding:
95+
"""Return a new binding with the key and key_display set to the specified values.
96+
97+
Args:
98+
key: The new key to set.
99+
key_display: The new key display to set.
100+
101+
Returns:
102+
A new binding with the key set to the specified value.
103+
"""
104+
return dataclasses.replace(self, key=key, key_display=key_display)
105+
106+
@classmethod
107+
def make_bindings(cls, bindings: Iterable[BindingType]) -> Iterable[Binding]:
108+
"""Convert a list of BindingType (the types that can be specified in BINDINGS)
109+
into an Iterable[Binding].
110+
111+
Compound bindings like "j,down" will be expanded into 2 Binding instances.
112+
113+
Args:
114+
bindings: An iterable of BindingType.
115+
116+
Returns:
117+
An iterable of Binding.
118+
"""
119+
bindings = list(bindings)
120+
for binding in bindings:
121+
# If it's a tuple of length 3, convert into a Binding first
122+
if isinstance(binding, tuple):
123+
if len(binding) not in (2, 3):
124+
raise BindingError(
125+
f"BINDINGS must contain a tuple of two or three strings, not {binding!r}"
126+
)
127+
# `binding` is a tuple of 2 or 3 values at this point
128+
binding = Binding(*binding) # type: ignore[reportArgumentType]
129+
130+
# At this point we have a Binding instance, but the key may
131+
# be a list of keys, so now we unroll that single Binding
132+
# into a (potential) collection of Binding instances.
133+
for key in binding.key.split(","):
134+
key = key.strip()
135+
if not key:
136+
raise InvalidBinding(
137+
f"Can not bind empty string in {binding.key!r}"
138+
)
139+
if len(key) == 1:
140+
key = _character_to_key(key)
141+
142+
yield Binding(
143+
key=key,
144+
action=binding.action,
145+
description=binding.description,
146+
show=bool(binding.description and binding.show),
147+
key_display=binding.key_display,
148+
priority=binding.priority,
149+
tooltip=binding.tooltip,
150+
id=binding.id,
151+
)
152+
65153

66154
class ActiveBinding(NamedTuple):
67155
"""Information about an active binding (returned from [active_bindings][textual.screen.Screen.active_bindings])."""
@@ -95,41 +183,10 @@ def __init__(
95183
properties of a `Binding`.
96184
"""
97185

98-
def make_bindings(bindings: Iterable[BindingType]) -> Iterable[Binding]:
99-
bindings = list(bindings)
100-
for binding in bindings:
101-
# If it's a tuple of length 3, convert into a Binding first
102-
if isinstance(binding, tuple):
103-
if len(binding) not in (2, 3):
104-
raise BindingError(
105-
f"BINDINGS must contain a tuple of two or three strings, not {binding!r}"
106-
)
107-
# `binding` is a tuple of 2 or 3 values at this point
108-
binding = Binding(*binding) # type: ignore[reportArgumentType]
109-
110-
# At this point we have a Binding instance, but the key may
111-
# be a list of keys, so now we unroll that single Binding
112-
# into a (potential) collection of Binding instances.
113-
for key in binding.key.split(","):
114-
key = key.strip()
115-
if not key:
116-
raise InvalidBinding(
117-
f"Can not bind empty string in {binding.key!r}"
118-
)
119-
if len(key) == 1:
120-
key = _character_to_key(key)
121-
yield Binding(
122-
key=key,
123-
action=binding.action,
124-
description=binding.description,
125-
show=bool(binding.description and binding.show),
126-
key_display=binding.key_display,
127-
priority=binding.priority,
128-
tooltip=binding.tooltip,
129-
)
130-
131186
self.key_to_bindings: dict[str, list[Binding]] = {}
132-
for binding in make_bindings(bindings or {}):
187+
"""Mapping of key (e.g. "ctrl+a") to list of bindings for that key."""
188+
189+
for binding in Binding.make_bindings(bindings or {}):
133190
self.key_to_bindings.setdefault(binding.key, []).append(binding)
134191

135192
def _add_binding(self, binding: Binding) -> None:
@@ -193,6 +250,71 @@ def merge(cls, bindings: Iterable[BindingsMap]) -> BindingsMap:
193250
keys.setdefault(key, []).extend(key_bindings)
194251
return BindingsMap.from_keys(keys)
195252

253+
def apply_keymap(self, keymap: Keymap) -> KeymapApplyResult:
254+
"""Replace bindings for keys that are present in `keymap`.
255+
256+
Preserves existing bindings for keys that are not in `keymap`.
257+
258+
Args:
259+
keymap: A keymap to overlay.
260+
261+
Returns:
262+
KeymapApplyResult: The result of applying the keymap, including any clashed bindings.
263+
"""
264+
clashed_bindings: set[Binding] = set()
265+
new_bindings: dict[str, list[Binding]] = {}
266+
267+
key_to_bindings = list(self.key_to_bindings.items())
268+
for key, bindings in key_to_bindings:
269+
for binding in bindings:
270+
binding_id = binding.id
271+
if binding_id is None:
272+
# Bindings without an ID are irrelevant when applying a keymap
273+
continue
274+
275+
# If the keymap has an override for this binding ID
276+
if keymap_key_string := keymap.get(binding_id):
277+
keymap_keys = keymap_key_string.split(",")
278+
279+
# Remove the old binding
280+
for key, key_bindings in key_to_bindings:
281+
key = key.strip()
282+
if any(binding.id == binding_id for binding in key_bindings):
283+
if key in self.key_to_bindings:
284+
del self.key_to_bindings[key]
285+
286+
for keymap_key in keymap_keys:
287+
if (
288+
keymap_key in self.key_to_bindings
289+
or keymap_key in new_bindings
290+
):
291+
# The key is already mapped either by default or by the keymap,
292+
# so there's a clash unless the existing binding is being rebound
293+
# to a different key.
294+
clashing_bindings = self.key_to_bindings.get(
295+
keymap_key, []
296+
) + new_bindings.get(keymap_key, [])
297+
for clashed_binding in clashing_bindings:
298+
# If the existing binding is not being rebound, it's a clash
299+
if not (
300+
clashed_binding.id
301+
and keymap.get(clashed_binding.id)
302+
!= clashed_binding.key
303+
):
304+
clashed_bindings.add(clashed_binding)
305+
306+
if keymap_key in self.key_to_bindings:
307+
del self.key_to_bindings[keymap_key]
308+
309+
for keymap_key in keymap_keys:
310+
new_bindings.setdefault(keymap_key, []).append(
311+
binding.with_key(key=keymap_key, key_display=None)
312+
)
313+
314+
# Update the key_to_bindings with the new bindings
315+
self.key_to_bindings.update(new_bindings)
316+
return KeymapApplyResult(clashed_bindings)
317+
196318
@property
197319
def shown_keys(self) -> list[Binding]:
198320
"""A list of bindings for shown keys."""
@@ -252,3 +374,10 @@ def get_bindings_for_key(self, key: str) -> list[Binding]:
252374
return self.key_to_bindings[key]
253375
except KeyError:
254376
raise NoBinding(f"No binding for {key}") from None
377+
378+
379+
class KeymapApplyResult(NamedTuple):
380+
"""The result of applying a keymap."""
381+
382+
clashed_bindings: set[Binding]
383+
"""A list of bindings that were clashed and replaced by the keymap."""

src/textual/dom.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -218,7 +218,7 @@ def __init__(
218218
self._has_hover_style: bool = False
219219
self._has_focus_within: bool = False
220220
self._reactive_connect: (
221-
dict[str, tuple[MessagePump, Reactive | object]] | None
221+
dict[str, tuple[MessagePump, Reactive[object] | object]] | None
222222
) = None
223223
self._pruning = False
224224
self._query_one_cache: LRUCache[QueryOneCacheKey, DOMNode] = LRUCache(1024)
@@ -620,12 +620,13 @@ def _merge_bindings(cls) -> BindingsMap:
620620
base.__dict__.get("BINDINGS", []),
621621
)
622622
)
623+
623624
keys: dict[str, list[Binding]] = {}
624625
for bindings_ in bindings:
625626
for key, key_bindings in bindings_.key_to_bindings.items():
626627
keys[key] = key_bindings
627628

628-
new_bindings = BindingsMap().from_keys(keys)
629+
new_bindings = BindingsMap.from_keys(keys)
629630
return new_bindings
630631

631632
def _post_register(self, app: App) -> None:

0 commit comments

Comments
 (0)