77
88from __future__ import annotations
99
10+ import dataclasses
1011from dataclasses import dataclass
11- from typing import TYPE_CHECKING , Iterable , Iterator , NamedTuple
12+ from typing import TYPE_CHECKING , Iterable , Iterator , Mapping , NamedTuple
1213
1314import rich .repr
1415
2021 from textual .dom import DOMNode
2122
2223BindingType : 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
2542class 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
66154class 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."""
0 commit comments